今天我们来讨论下 Java 虚拟机,通过一系列常见的问题来逐渐深入了解 JVM 创建对象过程,内存布局,类加载以及 GC 回收算法等机制。
十问 JVM 问题整理:
-
Java虚拟机创建对象的过程 (使用 new 的方式)
-
对象的内存布局
-
双亲委派机制
-
JVM的内存布局
-
JVM 中的类加载机制
-
垃圾回收算法与垃圾收集器
-
在什么情况下对象从年轻代转移到老年代
-
Java方法区中存哪些东西, 如何控制方法区的大小
-
JVM 如何判断对象已死,可达性算法分析
-
happen-before 原则
下面我们从 Java 创建对象的过程开始,逐渐深入了解下 JVM。
-
Java虚拟机创建对象的过程 (使用 new 的方式):
-
虚拟机接到一条new指令时,首先检查这个类的参数是否能在常量池中定位到一个类的符号引用并且检查这个符号引用代表的类是否已被加载、解析和初始化过。
-
类加载检查通过后,虚拟机为新生对象分配内存(堆中分配内存方法:碰撞指针与空闲列表)
-
内存分配完成后,虚拟机需要将分配到的内存空间初始化为零值(默认值)
-
对对象进行必要的设置,如设置对象的哈希码,分代年龄等信息
-
执行 init 方法,按照程序值初始化。
-
经过以上步骤虚拟机会新创建一个对象,对象的内存布局如下:
-
对象头包括两部分信息,第一部分用于存储对象自身运行数据,如哈希码,GC分代年龄,锁状态标志,线程持有锁,偏向线程ID等,官方成为 Mark Word。
-
第二部分是类型指针,即对象指向它的类元数据信息
如图所示:
-
了解完了创建对象的过程与对象内存布局,在开始讨论 JVM 内存布局之前首先要了解下 JVM 的双亲委派机制:
主要目的是为了确定类在 JVM 中的唯一性,确保程序运行稳定,安全。
当需要加载类时,类本身的加载器不会去加载而是先调用父类加载器,将请求委派给父类加载器,每层都是如此,因此所有加载最终都会到达顶层的启动类加载器。只有当父类加载器反馈不能加载,才会将加载任务给子加载器。
例如通过双亲委派机制使得用户自定义的 String 类不会被加载,从而保证系统的安全稳定。
-
启动类加载器 BootStrap 是 C++实现,主要负责加载 JAVA_HOME lib下类或被 –Xbootclasspath 参数指定的类库不能直接使用该加载器;
-
扩展类加载器 Extension 主要负责加载 JAVA_HOME下 lib/ext 下的类或系统变量 java.ext.dirs 指定类路径下的类库开发者可以直接使用;
-
应用类加载器 Application 加载用户指定的路径即 classpath 下的类如果应用程序没有指定加载器则默认使用该加载器。
-
JVM的内存布局
如图所示:
-
程序计数器:内存中较小的一块区域。当前线程在执行字节码的行号指示器。
线程是私有的,每个线程都有一个程序计数器,线程之间相互独立,是虚拟机中唯一一个没有规定OutOfMemoryError 情况的区域。
-
虚拟机栈
栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题。
8种基本类型的变量+对象的引用变量+实例方法都是在函数的栈内存中分配。
栈帧中主要保存3 类数据:
-
本地变量(Local Variables):输入参数和输出参数以及方法内的变量;
-
栈操作(Operand Stack):记录出栈、入栈的操作;
-
栈帧数据(Frame Data):包括类文件、方法等等。
-
本地方法栈
同虚拟机栈相同,只不过本地方法栈是为native 方法服务
-
堆
Java 堆是被线程共享的一块区域。 Java 堆是用来存放实例对象和数组对象。由于现在有了逃逸分析技术,也可以将对象分配在栈上。
同时 Java堆也可以是物理上不连续的区域,只要逻辑上连续即可。
在堆中为对象分配空间的方法有指针碰撞 和 空闲列表。
-
指针碰撞:内存规整
-
空闲列表:内存不连续
选择哪一种分配方法取决于是否规整,是否规整取决于垃圾回收算法是否压缩。
-
方法区
与堆一样,是线程共享区域。用于存储已经被虚拟机加载的类的类信息、常量池、静态变量、编译后的代码,运行时常量池(存储编译器生产的各种字面值与符号引用),方法区不足时抛出OutOfMemoryError PermGen Space 异常。
-
堆外内存
即直接内存。堆外内存能减少IO时的内存复制,实现零拷贝,不需要GC。
-
JVM 中的类加载机制
主要过程分为 :加载验证 准备 解析 初始化
-
加载:将类转换成二进制字节流,将字节流代表的静态结构转化为动态结构,在内存中生产一个代表这个类的 java.lang.Class 对象,作为方法区中的这个类的访问入口。
-
验证:验证Class文件中的字节流是否符合java 虚拟机规范,包括文件格式,元数据验证等。
-
准备:为类变量分配内存并设置类变量的初始值,分配的内存在方法区中(类变量是类模板对象)
-
解析:将常量池中的符号引用转化为直接引用。
符号引用是使用一组符号描述锁引用的目标。Class 文件中的常量池中包括字面值与符号引用(字段的名称和描述符、方法名称和描述符、类和接口的权限定名),在 Class文件阿忠不会保存各个方法字段的最终布局信息。因此这些符号不住安慰是无法得到真正的内存入口地址。
直接引用于虚拟机实现的内存布局有关,可以直接指向目标的指针,偏移量或者指向目标的句柄。
-
初始化阶段:该阶段才会真正的开始执行类中定义java代码。初始化时执行类构造器 clinit()方法的过程。
-
垃圾回收算法与垃圾收集器
垃圾回收算法 :
-
标记清除算法:将所有需要回收的对象进行标记,标记结束后对标记的对象进行回收,但是效率低,会造成大量碎片。
-
复制算法:复制算法将空间分为两部分,每次使用其中的一部分,当一块内存用完了,就将这块的所有对象复制到另一块,将已使用的块清除。不会产生碎片,会浪费一定的内存空间。在堆中的年轻代使用该算法。
因为年轻代对象多为生命周期比较短的对象。
年轻代将内存分为一个 Eden ,两个 survivor(from区、to区)。每次使用Eden与一个survivor。当回收时,将from 与Eden 中存活的对象复制到另一个to区,最后清理掉Eden与from。当survivor 与Eden 中存活对象大小超过另一个 survivor,则需要老年代来担保。
-
标记整理算法
复制算法在对象存活率较高时,复制会使效率降低,根据老年代特点,使用标记整理算法。标记之后将所有存活对象移向一端,将其他的清理,解决了碎片的问题。
-
分代收集算法
年轻代,老年代根据各自不同特点采用不同算法。
垃圾收集器:
-
Serial 收集器是单线程的收集器,在进行垃圾回收时需要停止其他的所有工作线程。
-
ParNew 收集器是Serial 的多线程版本。在单线程的环境下,parNew绝不比 Serial 收集器有更好的优势,随着spu增加可以提现出优势。
-
Parallel Scavenge 收集器,年轻代收集器,多线程并行收集,使用复制算法,与parNew 相似。
-
Serial Old 是serial 在老年代的版本
-
CMS:是一种获取最短停顿时间为目标的收集器。基于标记清除的算法实现。
-
G1收集器 可以用于年轻代与老年代的垃圾收集器。采用标记整理算法。
7. 将对象从年轻代到老年代是如何判断该对象执行力多久,在什么情况下转移? 哪些对象在老年代中?
轻GC 发生在年轻代,频率高速度快。
Full GC 是清理整个堆空间—包括年轻代和永久代。
一般新生成的对象都出现在 Eden 区,当 Eden 区被填满时,所有经过垃圾回收还存活的对象将被复制到两个 Survivor 区域中的一个,假定是 From 区,当From 区也被填满时,这个区域经过垃圾回收仍存活的对象将会被复制进 To 区,原From 区被清空,并且从 Eden 区过来的数据将直接进入To区。当To区也被填满时,之前从From区过来的数据如果仍在活动,则将被放入年老代,需要注意的是,两个survivor 区域总有一个是空的。
通过年龄计数器。对象每经过一个GC 存活,年龄计数器加一。当年龄超过设定的值,则将其通过担保机制转移到老年代。
老年代对象:
-
大对象(字符串与数组),超过了设定值的对象,直接在老年代中分配。
-
长期存活的对象进入老年代。
8. Java方法区中存哪些东西? JVM 如何控制方法区的大小以及内存溢出的原因和解决方法。
方法区大小不是固定的,可以通过 JVM 参数动态调整。方法区中主要存放常量、静态变量、虚拟机加载的类信息和编译后的代码,运行时常量池。
减少程序中class 的数量。尽量使用较少的静态变量。修改–XX : MaxPermSize调大。
错误类型为方法区溢出:PermGen space 错误。
9. JVM 如何判断对象已死,可达性算法分析
首先有一系列的 GC root 根节点。从这些根节点开始向下搜索,走过的路径成为引用链。当一个对象到 GC root 不存在任何引用链时,则此对象不可活,当对象不可活时,可用 finalize() 方法自救。但第二次被回收时则会死亡。
可作为 GC root 根节点的对象包括:
-
方法区中常量引用的对象
-
方法区中静态属性引用的对象
-
虚拟机栈中引用的对象
-
本地方法中引用的对象
引用类型:
-
强引用
特点:我们平常典型编码Object obj = new Object()中的obj就是强引用。通过关键字new创建的对象所关联的引用就是强引用。 当JVM内存空间不足,JVM宁愿抛出OutOfMemoryError运行时错误(OOM),使程序异常终止,也不会靠随意回收具有强引用的“存活”对象来解决内存不足的问题。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是可以被垃圾收集的了,具体回收时机还是要看垃圾收集策略。
-
软引用
特点:软引用通过SoftReference类实现。软引用的生命周期比强引用短一些。只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象:即JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。 -
弱引用
弱引用通过WeakReference类实现。弱引用的生命周期比软引用短。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。 -
虚引用
特点:虚引用也叫幻象引用,通过PhantomReference类来实现。无法通过虚引用访问对象的任何属性或函数。幻象引用仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
10. happen before 原则
如果两个操作之间具有 happen –before 关系,那么前一个操作结果会对后一个操作可见。
常见的happen-before原则:
-
程序顺序规则
线程中的每个操作,happen-before于线程中任意后续操作可见。
-
锁规则
对一个锁解锁,happen-before于随后给这个锁加锁。
-
volatile 规则
对 volatile的写happen-before 于任意后续volatile的读。
至此,十问JVM的问答也结束了,通过这一系列的问题我们讨论了 JVM 的一些特性,在以后的学习中,我们还会更加深入的去探讨JVM更底层的一些性质。
参考资料:
《深入理解 Java虚拟机 第二版》
关注一下,我写的就更来劲儿啦