第三章 垃圾收集器与内存分配策略
3.1 概述
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到高并发的瓶颈时,就必须研究垃圾回收了。
程序计数器、虚拟机栈、本地方法栈随线程生,随线程死,不用过多的考虑垃圾回收的问题。
java堆和方法区则有着很明显的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同分支所需要的内存也可能不一样(因为创建的对象不一样),只有运行期间才知道程序会创建哪些对象,创建多少,这部分的内存分配是动态的。垃圾收集器关注的是这部分内存如何管理。
3.2 对象已死?
3.2.1引用计数算法
其实主流的虚拟机没用这个算法。因为互相循环引用的问题不好解决。
比如,如下的代码,果使用引用计数的话,就无法被GC回收。
package testGC; public class ReferenceCountingGC { public Object instance = null; private static final int _1MB = 1024 * 1024; private byte[] bigSize = new byte[2 * _1MB]; public static void main(String[] args) { ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; System.gc(); } }
3.2.2 可达性分析
java的内存管理子系统,是通过可达性分析(Reachability Analysis)算法来判定对象是否存活。这个算法的基本思路是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连(从GC Roots到这个对象不可达时),则证明此对象是不可能再被使用的。
Java技术体系中,固定可作为GC Roots的对象包括以下几种:
- 在虚拟机栈中引用的对象(栈帧中的本地变量):比如各个线程被调用的方法堆栈中用到的参数、局部变量、临时变量。
- 方法区中类静态属性引用的对象,比如Java类的引用类型静态变量。
- 方法区中常量引用的对象,比如字符串常量池。
- 本地方法栈中JNI(即通常说的Native方法)引用的对象。
- java虚拟机内部的引用,如基本数据类型对象的Class对象,一些常驻的异常对象(比如NullPointEception,OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
3.2.3 再谈引用
Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、和虚引用,四种引用强度依次减弱:
强引用:是传统的”引用“定义,值引用赋值,即类似“Object obj = new Object()”, 这种引用关系存在,是永远不会被垃圾回收器回收的。
软引用:描述还有用,但非必须的对象。在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行二次回收。
弱引用:有用来描述那些非必须的对象,但比软引用更弱一些。能生存到下一次垃圾回收发生为止。
虚引用:最弱的引用关系,虚引用的唯一目的,是对象被垃圾回收器收集后,会收到一个系统通知。
3.2.4 生存还是死亡
即使在可达性分析中判定为不可达对象,也不是非死不可,这时候处于缓刑状态,真正宣告一个对象死亡,至少要经理2次标记过程:如果对对象在进行可达性分析后发现没有与GC Roots相连接的引用,那么会被第一次标记,随后进行一次筛选,筛选的条件是该对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或finalize()方法已经被调用过,那么这两种情况都是“没必要执行”。
如果该对象判定为确认有必要执行finalize()方法,那么该对象被放在一个名为F-Queue队列中,并由一个虚拟机建立的低优先级的finalizer线程去执行它们的finalize()方法,这个执行仅仅是触发,但不等待其执行结束。而处在F-Queue中的对象如果能重新和引用链上的对象建立关系,那么也可以拯救自己。
finalize()方法最多只会被系统调用一次。
最后,finalize()方法不推荐使用,不确定行太大
3.2.5 回收方法区
方法区垃圾收集的“性价比”通常比较低:在Java堆中,特别是新生代,对常规应用进行一次垃圾回收,可以回收70% ~ 99%的内存,相比较而言,方法区回收过于苛刻的判定条件,导致性价比很低。
方法区的垃圾收集主要有两个部分:废弃的常量和不再使用的类型。
回收废弃常量与回收Java堆中的常量非常类似,判定方法比较简单,没有任何字符串对象引用常量池中的“java”常量。
但是判断一个类型是否属于“不再被使用的类”的条件就比较苛刻,需要满足3个条件:
该类的所有实例都被收回(Java堆中)。也就是Java堆中不存在该类及其任何派生子类的实列。
加载该类的类加载器已经被回收,
该类对应的java.lang.Class对象没有在任何地方被引用,也就是不能再任何地方通过反射访问该类的方法。
3.3 垃圾收集算法
从如何判定对象消亡的角度出发,垃圾收集算法可以分为:“引用计数垃圾收集” 和 “追踪式垃圾收集”两大类。引用计数垃圾收集在主流Java虚拟机中均为涉及,所以,本节介绍的所有算法均属于追踪式垃圾收集的范畴。
3.3.1 分代收集理论
当前的商业虚拟机的垃圾收集器,大多数都遵循了分代收集的理论进行设计,分代收集建立在两个分代假说之上:
1)弱分代假说:绝大数对象是朝生夕灭的。
2)强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
基于上述两点,垃圾收集器的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据年龄(年龄就是对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。如果一个区域中大多数对象是朝生夕灭的,难以熬过垃圾收集过程的话,那么把他们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中到一起,虚拟机遍可以用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存空间的有效利用。
java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域----因而才有了“Minor GC”, “Major GC”, “Full GC”这样的回收类型的划分,也才能够针对不同的区域安排与里面存储对象的存亡特征相匹配的垃圾收集算法------因而发展出了如下的标记 - 清除算法,标记 - 复制算法,标记 - 整理算法等有针对的垃圾回收算法。
新生代:每次收集都发现有大量对象死去,每次收集完后少量的存活对象会逐渐晋升到老年代中存放。
老年代:
3.3.2 标记 - 清除算法
算法分为“标记”和“清除”两个阶段:首先标记所有需要回收对象,标记完成后,统一回收所有被标记的对象(也可以标记存活的对象,统一回收没有被标记的对象)标记的过程就是对象是否属于垃圾的判定过程。
标记 - 清除是最基础的算法,其后续的算法大多是以其为基础,对其缺点进行改进而得到的。
它的缺点有两个:第一是执行效率不稳定,如果堆中包含大量对象,而且大部分是要回收的,这时要进行大量的标记和清除的动作,导致标记和清楚两个过程的执行效率都随对象数量增长而降低;第二是内存空间的碎片化的问题,标记、清除后会产生大量不连续的内存碎片,空间碎片太多,导致再要分配大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.3.3 标记 - 复制算法
标记 - 复制是为了解决标记-清除算法面对大量回收对象时执行效率低的问题而提出的,将内存按照容量分为想等的两块,每次只使用其中的一块,当这一块用完了后,就将还活着的对象复制到另一块上面,然后将已使用的内存空间一次清理掉。如果内存中多数对象是存活的,这种算法会产生大量的内存空间复制的开销(不不适合老年代),但是对于多数对象都是可回收的情况,算法需要复制的就是少数的存活对象,而且每次都是针对整个半区进行内存回收(适合新生代),分配内存时就不用考虑空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。实现简单,运行高效。
缺点也很明显,可用内存缩小了一半。空间浪费太多。
现在的商用虚拟机大多数优先采用这种算法回收新生代,IBM曾经对“朝生夕灭”进行量化----新生代中的对象有98%熬不过第一轮收集。因此不用按照1:1d的比例来划分新生代的内存空间。
1989年有一种优化的Appel式回收:HotSpot虚拟机的Serial、ParNew等新生代收集器均采用这种策略来设计新生代的内存布局。把新生代分成一个较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一个Survivor,发生垃圾收集时,将Eden和Survivor中仍存活的对象一次性复制到另外一块Survivor空间,然后直接清理Eden和Survivor. HotSpot默认的Eden和Survivor的比例是8:1,每次新生代可用内存空间为整个新生代容量的90%,只有一个Survivor的空间会被浪费掉,当然谁也不能保证每次这 10%的Survivor够一次Minor GC用的,所以还是有“逃生门”的设计(分配担保。)
3.3.4 标记 - 整理算法
标记-复制算法在对象存活率较高时,要进行较多的复制操作,效率将降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,来应对被使用的内存中所有对象都100%存活的极端情况,所以老年代不使用这种算法(标记-复制)。
针对老年代,1974年提出了标记-整理算法:其中的标记过程与“标记-清除”算法一样,但是后续步骤不是对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
标记-整理与标记-清除的本质差别在于前一种是非移动式的回收算法,而后者是移动式的。
是否移动回收后的存活对象是优缺点并存的策略:
移动存活对象(目的是为了清理内存碎片),尤其是老年代这种每次回收都有大量存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种移动必须在暂停应用程序。
但是如果跟标记-清除那样完全不考虑移动和整理存活对象的话,弥散在堆中的存活对象导致的空间碎片话问题只能依赖更为复杂的内存分配器和内存访问器来解决,譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片话的磁盘上存储和访问就是通过硬盘分区表实现的),内存访问频繁,如果引入额外的负担,一定会影响吞吐量。
基于以上两点,是否移动对象都有弊端,移动则内存回收时更复杂,不移动则内存分配时更复杂。
---------------------------------------------------------以下内容太枯燥以后补充--------------------------------------------------------------
3.4 HotSpot的算法细节实现
3.5 经典垃圾收集器
3.6 低延迟垃圾收集器
3.7 选择合适的垃圾收集器
3.8 实战:内存分配与回收策略