1. 介绍下 Java 内存区域(运⾏时数据区)
Java 虚拟机在执⾏ Java 程序的过程中会把它管理的内存划分成若⼲个不同的数据区域。(JDK. 1.8 和之前的版本略有不同,下⾯会介绍到。)
JDK 1.8之前:
JDK 1.8 :线程私有的:程序计数器、虚拟机栈、本地⽅法栈。
线程共享的:堆、⽅法区、直接内存(⾮运⾏时数据区的⼀部分)。
程序计数器
主要有两个作⽤:
- 字节码解释器通过改变程序计数器来依次读取指令,从⽽实现代码的流程控制,如:顺序执⾏、选择、循环、异常处理。
- 在多线程的情况下,程序计数器⽤于记录当前线程执⾏的位置,从⽽当线程被切换回来的时候能够知道该线程上次运⾏到哪⼉了。
注意:程序计数器是唯⼀⼀个不会出现 OutOfMemoryError 的内存区域,它的⽣命周期随着线程的创建⽽创建,随着线程的结束⽽死亡。
虚拟机栈
与程序计数器⼀样,Java虚拟机栈也是线程私有的,它的⽣命周期和线程相同,⽽且随着线程的创建⽽创建,随着线程的死亡⽽死亡。Java虚拟机栈是由⼀个个栈帧组成,⽽每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。局部变量表主要存放了编译器可知的各种数据类型和对象引⽤。
问:那么⽅法/函数如何调⽤?
虚拟机栈可类⽐数据结构中栈,其中保存的主要内容是栈帧,每⼀次函数调⽤都会有⼀个对应的栈帧被压⼊,每⼀个函数调⽤结束后,都会有⼀个栈帧被弹出。
Java⽅法有两种返回⽅式:1. return 语句。2. 抛出异常。不管哪种返回⽅式都会导致栈帧被弹出。
虚拟机栈规定了两种异常状况:
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;
如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常;
本地⽅法栈
和虚拟机栈所发挥的作⽤⾮常相似,区别是: 虚拟机栈为虚拟机执⾏ Java ⽅法 (也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务。 在 HotSpot 虚拟机中,本地⽅法栈和 虚拟机栈合⼆为⼀。
本地⽅法被执⾏的时候,在本地⽅法栈也会创建⼀个栈帧,⽤于存放该本地⽅法的局部变量表、操作数栈、动态链接、方法返回地址。
⽅法执⾏完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和OutOfMemoryError 两种异常。
native关键字:
被native关键字修饰的方法叫做本地方法,本地方法和其它方法不一样,本地方法意味着和平台有关,因此使用了native的程序可移植性都不太高。另外native方法在JVM中运行时数据区也和其它方法不一样,它有专门的本地方法栈。native方法主要用于加载文件和动态链接库,由于Java语言无法访问操作系统底层信息(比如:底层硬件设备等),这时候就需要借助C语言来完成了。被native修饰的方法可以被C语言重写。
堆
Java 虚拟机所管理的内存中最⼤的⼀块,Java 堆是所有线程共享的⼀块内存区域,在虚拟机启动时创建。
此内存区域的唯⼀⽬的就是存放对象实例,⼏乎所有的对象实例以及数组都在这⾥分配内存。Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。
java进程运行过程中创建的对象存放在堆中,从垃圾回收的⻆度,由于现在收集器基本都采⽤分代垃圾收集算法,所以Java堆还可以细分为:新生代 ( Young )、老年代 ( Old )。新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。进⼀步划分的⽬的是更好地回收内存,或者更快地分配内存。
堆的内存模型大致为:
新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 ),即:新生代 ( Young ) = 1/3 的堆空间大小。老年代 ( Old ) = 2/3 的堆空间大小。
其中,新生代 ( Young ) 被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分。默认的,Eden : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。
JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。
From Survivor区域与To Survivor区域是交替切换空间,在同一时间内两者中只有一个不为空。(具体原理解析推荐下面这篇文章:https://blog.csdn.net/u012799221/article/details/73180509)
⽅法区
⽅法区与 Java 堆⼀样,是各个线程共享的内存区域,它⽤于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把⽅法区描述为堆的⼀个逻辑部分,但是它却有⼀个别名叫做 Non-Heap(⾮堆),⽬的是与 Java 堆区分开来。
⽅法区也被称为永久代。永久代就是HotSpot虚拟机对虚拟机规范中⽅法区的⼀种实现⽅式。
常见参数:
-XX:PermSize=N //⽅法区(永久代)初始⼤⼩
-XX:MaxPermSize=N //⽅法区(永久代)最⼤⼤⼩,超过这个值将会抛出OutOfMemoryError异常
相对⽽⾔,垃圾收集⾏为在这个区域是⽐较少出现的,但并⾮数据进⼊⽅法区后就“永久存在”了。
JDK 1.8 的时候,⽅法区(HotSpot的永久代)被彻底移除了(JDK1.7就已经开始了),取⽽代之是元空间,元空间使⽤的是直接内存。与永久代最大的不同就是,元空间的大小默认只受本地内存限制。
下⾯是⼀些常⽤参数:
-XX:MetaspaceSize=N //设置Metaspace的初始⼤⼩,如果未指定此标志,则 Metaspace 将根据运⾏时的应⽤程序需求动态地重新调整⼤⼩。
-XX:MaxMetaspaceSize=N //设置Metaspace的最⼤⼤⼩,默认值为 unlimited,这意味着它只受系统内存的限制。
为什么要将永久代(PermGen)替换为元空间(MetaSpace)呢?
因为永久代设置空间大小很难确定。
一个应用动态加载的类的数目是很难确定的,如果永久代设置的过小,会频繁触发OOM。
而元空间的大小默认只受本地内存限制,这样出现OOM的机会比较小。
运⾏时常量池
运⾏时常量池是⽅法区的⼀部分,⽤于存放编译期⽣成的各种字⾯量和符号引⽤。
既然运⾏时常量池是⽅法区的⼀部分,⾃然受到⽅法区内存的限制,当常量池⽆法再申请到内存时会抛出 OutOfMemoryError 异常。
JDK1.7及之后版本的 JVM 已经将运⾏时常量池从⽅法区中移了出来,在 Java 堆(Heap)中开辟了⼀块区域存放运⾏时常量池。
直接内存
直接内存并不是虚拟机运⾏时数据区的⼀部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使⽤。⽽且也可能导致 OutOfMemoryError 异常出现。
本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存⼤⼩以及处理器寻址空间的限制。
2. 说⼀下Java对象的创建过程
①类加载检查:
根据new的参数在常量池中定位一个类的符号引用。如果没有找到这个符号引用,说明类还没有被加载,则进行类的加载,解析和初始化。
②分配内存:
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来。
这个步骤有两个问题:
1.如何划分内存。
2.在并发情况下, 可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
划分内存的方法:
-
“指针碰撞”(Bump the Pointer)
如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分 内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。 -
“空闲列表”(Free List)
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录。
解决并发问题的方法:
-
CAS(compare and swap)
虚拟机采用CAS加上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。 -
本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
把内存分配的动作按照线程划分在不同的空间之中进行,即为每⼀个线程预先在Eden区分配⼀块⼉内存,JVM在给线程中的对象分配内存时,⾸先在TLAB分配,当对象⼤于TLAB中的剩余内存或TLAB的内存已⽤尽时,再采⽤上述的CAS进⾏内存分配。
③初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。
④设置对象头: 初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。
⑤执⾏ init ⽅法: 把对象按照程序员的意愿进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来。
3.对象的访问定位有哪两种⽅式?
建⽴对象就是为了使⽤对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问⽅式由虚拟机实现⽽定,⽬前主流的访问⽅式有①使⽤句柄和②直接指针两种:
- 句柄: 如果使⽤句柄的话,那么Java堆中将会划分出⼀块内存来作为句柄池,reference 中存储的就是对象的句柄地址,⽽句柄中包含了对象实例数据与类型数据各⾃的具体地址信息;
- 直接指针: 如果使⽤直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,⽽reference 中存储的直接就是对象的地址。
这两种对象访问⽅式各有优势。使⽤句柄来访问的最⼤好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,⽽ reference 本身不需要修改。使⽤直接指针访问⽅式最⼤的好处就是速度快,它节省了⼀次指针定位的时间开销。