深入理解Java虚拟机(三)——垃圾回收策略
所谓垃圾收集器的作用就是回收内存空间中不需要了的内容,需要解决的问题是回收哪些数据,什么时候回收,怎么回收。
Java虚拟机的内存分为五个部分:程序计数器、虚拟机栈、本地方法栈、堆和方法区。
其中程序计数器、虚拟机栈和本地方法栈是线程私有的,所以对于何时回收这三部分内存只需要根据线程的生存周期就可以了。
而堆和方法区是线程共享的,其诞生和销毁伴随的虚拟机的启动和停止,所以需要特定的算法来判断内存是否可以被回收。
堆内存的回收
判断那些对象需要回收
垃圾回收器在回收之前,需要判断那些对象已经不会被使用,那些是需要被使用的。对于那些无效的对象,有两种判定算法:
- 引用技术法:在对象中添加一个引用技术器,每当对象被引用一次时,计数器就加一;当一个引用失效时,计数器减一;当计数器的值为零时,对象是不可能再被使用的,就是无效的。
- 可达性分析算法:通过一列称为“GC Roots”的根对象作为起始节点,根据它们所包含的引用关系,进行向下搜索,对于搜索到的对象都是有效的,没有关联的对象就是无效的。
GC Roots包括:- 虚拟机栈中引用的对象(栈帧中的局部变量表中引用类型变量所引用的对象)
- 方法区中静态属性引用的对象
- 方法区中常量引用对象
- 本地方法栈中JNI(即通常所说的Native方法)引用的对象
- 虚拟机内部的引用(基本数据类型对象的Class对象,异常对象,系统类加载器)
- 所有被同步锁(synchronized)持有的对象
- 反映Java虚拟机内部情况的回调、本地代码缓存
比较:引用计数法虽然简单,但是无法解决循环引用的问题,目前主流的语言采用可达性方法。
回收无效对象的过程
要真正宣告一个对象的死亡,至少需要进行两次标记:当在可达性算法标记之后,发现对象没有引用链,就被第一次标记,随后进程筛选,如果对象的finalize方法没有被重写或者finalize方法被调用过了,就进行第二次标记,这样对象就死了。
如果finalize方法没被调用,虚拟机就会给对象一次重生机会:
- 将对象的finalize方法放进F-Queue队列中;
- 执行队列中的所有finalize方法,虚拟机会以较低的优先级执行这些方法,不会确保所有的方法都被执行了,如果执行的时候超时了,就把产生这个方法的对象直接干掉。
- 如果执行的时候重新将对象与GC Roots产生关联,则对象就被救活了,如果没有,就被干掉。
注意:
强烈不建议使用finalize()函数进行任何操作!如果需要释放资源,请使用try-finally。
因为finalize()不确定性大,开销大,无法保证顺利执行。
方法区的内存回收
由于方法区中存放的信息生命周期较长,每次回收的信息较少,回收的性价比较低,所以方法区就是堆的老年代。
方法区的垃圾收集主要分为两部分内容:
- 废弃的常量
- 不在使用的类型
废弃常量判断
和清除对象类似,如果常量没有被引用,则被清理出常量池。
判断废弃的类型
同时满足三个条件:
- 该类所有的实例被回收。
- 加载该类的类加载器被回收。
- 该类对应的Java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类。只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区的时候创建,在方法区中该类被删除时清除。
垃圾回收算法
当我们知道内存中哪些区域需要被回收,那么就需要垃圾回收算法来清理这些数据。
分代收集理论
分代收集实质是一套符合大多数程序运行实际情况的经验法则,建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨代引用假说,跨代引用相对于同代引用来说仅占极少数。
这两个分代假说奠定了垃圾收集的一致的原则设计:收集器应该将Java堆分为不同的区域,然后将对象依据其年龄,将其分配到不同的区域中进行存储。
Java虚拟机至少把Java堆分为新生代和老年代两个区域。新生代中,每次垃圾回收都会有大量的对象被死去,然后存活少量的对象,将会逐步晋升到老年代中。
新生代中的对象可能会被老年代中的所引用,为了找出新生代中存活的对象,不但要从GC Roots开始扫描,而且还需要遍历整个老年代来获得引用节点,这样为了少量跨代区扫描整个老年代,性价比太低。所以,只需要在新生代中建立一个全局的数据结构记忆集,这个结构把老年代分为若干块,并标识出那些块会存在跨代引用,在GC扫描的时候,扫描这一部分老年代就好了,这样节约时间。
分代收集定义
- 部分收集(Partical GC):指的是不对整个Java堆进行垃圾收集,其中又分为:
- 新生代收集(Minor )
- 老年代收集(Major GC/Old GC)
- 混合收集(Mixed GC)
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
标记-清除算法
首先标记所有需要回收的对象,然后统一回收被标记的对象,清除数据。
缺点:执行效率不稳定,标记和清除的效率都随着对象数量的增长而降低,而且会产生内存碎片,无法存储较大的对象。
标记-复制算法
将内存分为两个大小相等的部分,每次使用其中一块,如果这一块使用完了,就将存活的对象复制到另一块,然后将这一块的数据清除。
优点:不会产生内存碎片。
缺点:浪费了一半的内存空间,而且每次要将可用数据复制一下,效率低。
现在绝大多数的虚拟机采用这一算法来回收新生代,因为新生代存活的对象少,每次需要复制的数据就比较少。
解决空间利用率低的问题:重新布局新生代,将其分为一块较大的Eden空间(伊甸园)和两块较小的Survivor1和Survivior2空间,HotSpot对其默认比例是8:1:1。分配内存时,只使用Eden和Survivor1,发生垃圾收集时,将Eden和Survivor1中任然存活的的对象复制到Survivor2上,然后直接清理原有的两个空间,然后将Survivor2 与Survivor1的空间引用互换,继续分配。
这样做,只有10%的新生代空间被浪费。
分配担保
当Survivor空间不足以容纳一次Minor GC之后的存活对象,就需要依赖其他区域进行分配担保。所谓分配担保,就是当Survivor区域内存不够,向老年代借一些空间从来存储对象。
标记-整理算法
在垃圾回收之前,将需要回收的垃圾进行标记,然后在垃圾回收的时候,将没有被标记的数据放到一端,最后清除另一端的数据。
这是一种老年代的垃圾收集算法。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。移动和不移动都会有优缺点,移动会耗时,不移动造成内存碎片,降低吞吐量。移动则内存回收时会更复杂,不移动则内存分配时会更复杂。
关注吞吐量就使用标记整理算法,关注延迟就使用标记清除算法。
吞吐量是指对网络、设备、端口、虚电路或其他设施,单位时间内成功地传送数据的数量。
算法混合使用
让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。
分代收集策略
对象优先再Eden区中分配
主流的垃圾收起器都会采用分代回收算法,将堆内存区分为新生代和老年代。新生代中为了提高内存利用率,减少碎片,采用标记复制算法,将新生代内存空间再细分为Eden、survivor1区和survivor2区。
每次对象创建时,首先会分配到Eden区。
如果Eden区满了,则将对象分配到survivor1区。
如果Eden 和 survivor1区内存满了,就会进行Minor回收,将前者的可用对象标记复制到survivor2区,并清理原有空间,然后将survivor2区的名称与survivor1互换,继续分配新对象。
如果survivor2区域在复制存活对象的时候内存不足,则会启用分配担保,借用老年代的空间,暂时存放存活对象,当标记复完成之后,在将借用空间内的对象复制到Eden区。
大对象直接进入老年代
大对象的定义就是占用连续空间较多的队形,比如数组。
当大对象在新生代中存不下的之后,直接分配到老年代中,这样就能避免大量复制操作。
-XX:PretrnureSizeThreshold参数设置大对象的下限值
该参数用于设置大小超过该参数的对象被认为是“大对象”,直接进入老年代。
注意:该参数只对Serial和ParNew收集器有效。
生命周期较长的对象进入老年代
对象头中具有该对象的年龄计数器,每次新生代中发生一次Minor GC后,存活下来的对象年龄加一,当年龄超过一定的值,就将该对象放入老年代。
-XXMaxTenuringThreshold设置新生代的最大年龄
相同年龄的对象内存超过Survivor区一半的对象进入老年代
在Survivor区中,相同年龄的对象内存超过了Surviror区的一般大小,那么所有相同年龄的对象和超过年龄的对象被放到老年代中,这样就无需等到年龄超过一定值。
Java中引用的种类
- 强引用:平时直接用引用就是强引用,只要存在强引用,则对象不会被回收。
- 软引用:只要内存足够就不会被回收,当出现OOM,就会被回收。通过SoftRerefence实现。
- 弱引用:只要发生垃圾回收,就会被回收。通过WeakReference类实现。
- 虚引用:无法通过虚引用访问到对象的任何属性和方法,唯一的作用时检测垃圾收集器时候进行回收。通过PhantomReference类实现。