参考
1、周志明,深入理解Java虚拟机:JVM高级特性与最佳实践,机械工业出版社
转载整理自
1、http://blog.csdn.net/u011080472/article/details/51324103
2、http://blog.csdn.net/u010425776/article/details/51189318
3、http://blog.csdn.net/jarvan_song/article/details/70196587
正文
Java虚拟机的内存模型分为五个部分,分别是:程序计数器、Java虚拟机栈、本地方法栈、堆、方法区。
这五个区域既然是存储空间,那么为了避免Java虚拟机在运行期间内存存满的情况,就必须得有一个垃圾收集者的角色,不定期地回收一些无效内存,以保障Java虚拟机能够健康地持续运行。
这个垃圾收集者就是平常我们所说的“垃圾收集器”,那么垃圾收集器在何时清扫内存?清扫哪些数据?这就是接下来我们要解决的问题。
程序计数器、Java虚拟机栈、本地方法栈都是线程私有的,也就是每条线程都拥有这三块区域,而且会随着线程的创建而创建,线程的结束而销毁。那么,垃圾收集器在何时清扫这三块区域的问题就解决了。
此外,Java虚拟机栈、本地方法栈中的栈帧会随着方法的开始而入栈,方法的结束而出栈,并且每个栈帧中的本地变量表都是在类被加载的时候就确定的。因此以上三个区域的垃圾收集工作具有确定性,垃圾收集器能够清楚地知道何时清扫这三块区域中的哪些数据。
然而,堆和方法区中的内存清理工作就没那么容易了。
堆和方法区所有线程共享,并且都在JVM启动时创建,一直得运行到JVM停止时。因此它们没办法根据线程的创建而创建、线程的结束而释放。
堆中存放JVM运行期间的所有对象,虽然每个对象的内存大小在加载该对象所属类的时候就确定了,但究竟创建多少个对象只有在程序运行期间才能确定。
方法区中存放类信息、静态成员变量、常量。类的加载是在程序运行过程中,当需要创建这个类的对象时才会加载这个类。因此,JVM究竟要加载多少个类也需要在程序运行期间确定。
因此,堆和方法区的内存回收具有不确定性,因此垃圾收集器在回收堆和方法区内存的时候花了一些心思。
堆内存的回收
1. 如何判定哪些对象需要回收?
在对堆进行对象回收之前,首先要判断哪些是无效对象。我们知道,一个对象不被任何对象或变量引用,那么就是无效对象,需要被回收。一般有两种判别方式:
引用计数法
每个对象都有一个计数器,当这个对象被一个变量或另一个对象引用一次,该计数器加一;若该引用失效则计数器减一。当计数器为0时,就认为该对象是无效对象。-
可达性分析法
所有和GC Roots直接或间接关联的对象都是有效对象,和GC Roots没有关联的对象就是无效对象。
GC Roots是指:- Java虚拟机栈所引用的对象(栈帧中局部变量表中引用类型的变量所引用的对象)
- 方法区中静态属性引用的对象
- 方法区中常量所引用的对象
- 本地方法栈所引用的对象
PS:注意!GC Roots并不包括堆中对象所引用的对象!这样就不会出现循环引用。
两者对比:
引用计数法虽然简单,但存在一个严重的问题,它无法解决循环引用的问题。
因此,目前主流语言均使用可达性分析方法来判断对象是否有效。
2. 回收无效对象的过程
当JVM筛选出失效的对象之后,并不是立即清除,而是再给对象一次重生的机会,具体过程如下:
-
判断该对象是否覆盖了finalize()方法
- 若已覆盖该方法,并该对象的finalize()方法还没有被执行过,那么就会将finalize()扔到F-Queue队列中;
- 若未覆盖该方法,则直接释放对象内存。
执行F-Queue队列中的finalize()方法
虚拟机会以较低的优先级执行这些finalize()方法们,也不会确保所有的finalize()方法都会执行结束。如果finalize()方法中出现耗时操作,虚拟机就直接停止执行,将该对象清除。对象重生或死亡
如果在执行finalize()方法时,将this赋给了某一个引用,那么该对象就重生了。如果没有,那么就会被垃圾收集器清除。
注意:
强烈不建议使用finalize()函数进行任何操作!如果需要释放资源,请使用try-finally。
因为finalize()不确定性大,开销大,无法保证顺利执行。
方法区的内存回收
我们知道,如果使用复制算法实现堆的内存回收,堆就会被分为新生代和老年代,新生代中的对象“朝生夕死”,每次垃圾回收都会清除掉大量的对象;而老年代中的对象生命较长,每次垃圾回收只有少量的对象被清除掉。
由于方法区中存放生命周期较长的类信息、常量、静态变量,因此方法区就像是堆的老年代,每次垃圾收集的只有少量的垃圾被清除掉。
方法区中主要清除两种垃圾:
1. 废弃常量
2. 废弃的类
1. 如何判定废弃常量?
清除废弃的常量和清除对象类似,只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。
2. 如何废弃废弃的类?
清除废弃类的条件较为苛刻:
1. 该类的所有对象都已被清除
2. 该类的java.lang.Class对象没有被任何对象或变量引用
只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区的时候创建,在方法区中该类被删除时清除。
3. 加载该类的ClassLoader已经被回收
垃圾收集算法
现在我们知道了判定一个对象是无效对象、判定一个类是废弃类、判定一个常量是废弃常量的方法,也就是知道了垃圾收集器会清除哪些数据,那么接下来介绍如何清除这些数据。
垃圾收集算法主要有以下几种:标记-清除算法(mark-sweep)、复制算法(copying)和标记-整理算法(mark-compact)。
标记-清除算法
(先利用刚才介绍的方法判断需要清除哪些数据,并给它们做上标记;然后清除被标记的数据)算法的执行过程与名字一样,先标记所有需要回收的对象,在标记完成后统一回收所有被标记的对象。该算法有两个问题:
- 标记和清除过程效率不高。主要由于垃圾收集器需要从GC Roots根对象中遍历所有可达的对象,并给这些对象加上一个标记,表明此对象在清除的时候被跳过,然后在清除阶段,垃圾收集器会从Java堆中从头到尾进行遍历,如果有对象没有被打上标记,那么这个对象就会被清除。显然遍历的效率是很低的
- 会产生很多不连续的空间碎片,所以可能会导致程序运行过程中需要分配较大的对象的时候,无法找到足够的内存而不得不提前出发一次垃圾回收。
分析:这种算法标记和清除过程效率都很低,而且清除完后存在大量碎片空间,导致无法存储大对象,降低了空间利用率。
复制算法
复制算法是为了解决标记-清除算法的效率问题的,其思想如下:将可用内存的容量分为大小相等的两块,每次只使用其中的一块,当这一块内存使用完了,就把存活着的对象复制到另外一块上面,然后再把已使用过的内存空间清理掉。
- 优点:每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
- 缺点:算法的代价是将内存缩小为了原来的一半,未免太高了一点。
分析:
这种算法避免了碎片空间,但内存被缩小了一半。
而且每次都需要将有用的数据全部复制到另一片内存上去,效率不高。现在的商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1∶1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。
当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被“浪费”,解决了空间利用率问题。
当然,90%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时(例如,存活的对象需要的空间大于剩余一块Survivor的空间),需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
什么是分配担保?
当JVM准备为一个对象分配内存空间时,发现此时Eden+Survior中空闲的区域无法装下该对象,那么就会触发MinorGC,对该区域的废弃对象进行回收。但如果MinorGC过后只有少量对象被回收,仍然无法装下新对象,那么此时需要将Eden+Survior中的所有对象都转移到老年代中,然后再将新对象存入Eden区。这个过程就是“分配担保”。
更详细的分配担保参见 http://blog.csdn.net/jarvan_song/article/details/70196587
标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
与标记-清除算法过程一样,只不过在标记后不是对未标记的内存区域进行清理,而是让所有的存活对象都向一端移动,然后清理掉边界外的内存。该方法主要用于老年代。
分代收集算法
目前商用虚拟机都使用“分代收集算法”,所谓分代就是根据对象的生命周期把内存分为几块,一般把Java堆中分为新生代和老年代,这样就可以根据对象的“年龄”选择合适的垃圾回收算法。
- 新生代:“朝生夕死”,存活率低,使用复制算法。
- 老年代:存活率较高,使用“标记-清除”算法或者“标记-整理”算法。
Hotspot的算法实现
GC Roots与GC停顿
我们回到标记-清除算法,在清除阶段,为了枚举未被标记的对象,所以需要从根节点(GC Roots)开始查找引用链,这个过程会导致GC停顿,意思就是在GC的时候Java的执行线程都被停顿,好像被冻结在某一个时间点,也叫“Stop the world”。然而目前主流的Java虚拟机都是用准确式GC(所谓准确式GC,即使虚拟机知道内存中的某个位置的数据是什么类型),当“Stop the world”的时候并不需要检查所有的引用位置,虚拟机通过使用OopMap这个数据结构知道哪些地方存放着对象的引用。
类加载完成后,HotSpot将对象内什么偏移量上是什么类型的数据计算出来;在JIT编译过程中,在特定的位置记录下栈和寄存器(程序计数器)中哪些位置是引用。
安全点
使用OopMap,虚拟机已经知道哪些位置存放着对象,从而GC Roots可以迅速的枚举可达对象的引用链。但是问题来了:是不是需要对所有的指令都使用OopMap呢?答案是否定的。实际上,虚拟机只在“特定的位置”记录了对象的引用信息,比如我们使用方法调用或者循环的时候,就会设定这样的位置,如果越过这个位置的继续执行指令,然而程序是不允许因为指令流长度太长而执行过长时间,所以这个“特定位置“就成为了程序是否具有长时间运行的分界点。这个”特定的位置“也称为安全点(SafePoint)。
主动式中断线程
现在虚拟机有了安全点,于是只会到安全点寻找对象的引用信息,并且在安全点暂停Java执行线程,然而还有一个问题如何在GC发生时让所有线程(不包括JNI调用的线程)都“跑”到最近的安全点上再停顿下来?
在Hotspot使用主动式中断来中断执行线程,其思想如下:当GC的时候不需要直接对线程操作以中断线程,仅仅是设置一个标志,然后让执行线程去轮询这个标志,发现中断标志为真的时候就自己中断线程。需要注意的是,轮询标志的地方与安全点的位置是重合的,另外再加上创建对象需要分配的地方。
安全区域
现在通过GC Roots和安全点,程序能够在不太长的时间就可以到达安全点,并暂停执行线程。那么如果程序在阻塞(Blocked)或者睡眠(Sleep)的状态的时候,执行线程如何中断呢?
JVM设置了安全区域(Safe Region)。安全区域可以看成是被扩展了的安全点,是指在这个区域内,对象的引用关系不会发生改变,在这个区域内的任何地方开始GC都是安全的。