垃圾回收与内存分配策略
- 垃圾回收与内存分配策略
- “垃圾”的定义
- 对象是否为“垃圾”
- 何为“引用”--四种引用类型
- 最后的挣扎--finalize()方法
- 回收方法区
- 垃圾回收算法
- 回收的前置--分代理论
- 标记-清除算法(Mark Sweep)
- 标记-复制算法
- 标记-整理算法(Mark Compact)
- 标记?清除:整理
- 经典垃圾回收器
- Serial收集器
- ParNew收集器
- Parallel Scavenge搜集器
- 参数说明
- Serial Old 收集器
- Parallel Old 收集器
- CMS收集器
- Garbage First 收集器
- 低延迟垃圾收集器
- Shenandoah 收集器
- ZGC 收集器
- “垃圾”的定义
“垃圾”的定义
对象是否为“垃圾”
判断对象是否已成为“垃圾”的两种方法:引用计数法、可达性分析算法
- 引用计数法
如果一个对象被引用一次,则加1,如果没人引用则被回收;存在问题:如果两个对象循环引用,但是没有任何外部对象引用他们俩,则那两个对象无法被回收。
- 可达性分析算法(主流JVM采用)
没有被根对象(GC ROOT)直接或简介引用的对象则会被回收
根对象--肯定不能对回收的对象
GC ROOT对象:system class、同步锁、线程类、本地方法类
何为“引用”--四种引用类型
JDK1.2以后将引用分为:强引用、软引用、弱引用和虚引用4种,强度依次减弱。
- 强引用
被GC ROOT直接引用(等号赋值 - 软引用
被GC ROOT间接引用;当内存不足时被回收,内存充足时不会被回收 - 弱引用
没有GC ROOT直接引用,当发生垃圾回收时,不管内存是否充足都会被回收 - 虚引用
没有GC ROOT直接引用,虚引用使用时必须配合引用队列进行管理。
比如创建一个ByteBuffer实现类对象时,会创建个一个Cleaner对象,当ByteBuffer实现类对象没有再被引用时,ByteBuffer实现类对象会被回收,Cleaner对象则会进入引用队列,这时候一个referencehandles线程会查找引用队列中是否存在cleaner对象,如果有则调用Cleaner.clean方法,clean方法则根据记录的直接内存的地址,调用unsafe.freememory方法释放直接内存
- 补充:引用队列
软引用、弱引用本身也要占用一定内存,当软引用、弱引用的引用对象都被回收时,则进入引用队列,会对引用队列进行后续管理;虚引用引用的对象被释放后,虚引用会进入引用队列
最后的挣扎--finalize()方法
即使可达性分析后,对象被判定为“垃圾”,也并非非死不可。一个对象的死亡至少需要两次标记:
没有与GC Root的引用链,标记一次
对象没有重写finalize()方法,或finalize()重写但已被调用过一次,标记第二次
如果重写了finalize()方法,且还没有被调用,那么对象会被放置在F-Queue的队列中,会有一条虚拟机自建的、优先度较低的线程Finalizer线程去执行对象的finalize()方法,但为了防止finalize()方法出现死循环等异常,并不会保证等待finalize()方法执行结束。在此期间,若对象建立了引用链,则对象可以存活一次,否则就“死定了”。
不建议使用该finalize()方法
回收方法区
方法区的垃圾回收主要包含两部分:废弃的常量、不再使用的类型
常量的回收类似与Java堆中的对象,当没有引用时,则允许回收
类型的回收相对比较苛刻,需要同时满足以下条件,才允许被回收
- 该类所有实例都已被回收
- 该类的类加载器已被回收
- 该类对应的java.lang.Class对象没有被引用,且在任何地方都不可以通过反射访问该类方法
垃圾回收算法
从判定垃圾消亡的角度出发,垃圾回收算法可以划分为“引用计数式垃圾收集”、“追踪式垃圾收集”两类。在Java虚拟机中的讨论都在追踪式垃圾收集的范畴中。
回收的前置--分代理论
分代设计的理论建立在两个分代假说之上:
- 弱分代假说:新生对象都是朝生夕死
强分代假说:熬过越多次垃圾回收的对象,就越难以消亡
设计原则:
垃圾收集器应该依据对象的年龄,把Java堆划分为不同的区域。
- 新生代
朝生夕灭的对象集中在一个区域,每次回收只需关注少量需要存活的对象即可 - 老年代
难以消亡的对象集中在一个区域,可以使用较低的频率去触发回收机制
但是,在对新生代进行垃圾收集的时候,不免会出现新生代的中的对象被老年代引用的情况。所以,为了确定新生代区域的存活对象,除了GC Root之外还需要遍历整个老年代中所有对象来获得准确的可达性分析。基于此,引入第三条经验法则:
- 新生代
跨代引用假说:跨代引用相对于同代引用来说只占少数
跨代引用一般倾向于两个对象同时生存或同时消亡的
设计原则:
在新生代建立全局数据结构(记忆集),把老年代分为若干小块,记录老年代中哪一块内存存在跨代引用
此后,发生minor gc时只有包含了跨代引用的小块内存中的对象才会被加入到GC Root进行扫描
标记-清除算法(Mark Sweep)
先标记需要回收的对象,再统一清除
效率不稳定,随着对象数量增多,标记、清除两个过程的执行效率降低
内存碎片化,导致存入大对象时无法获得足够的连续内存空间,触发另一次垃圾收集动作
标记-复制算法
将可用内存划分为两个完全相等空间,每次只使用其中的一块。如果其中的一块内存用完,则将存活的对象完全复制到另一块,再对原来的空间进行统一清除回收。
- 缺点
内存空间的浪费
若空间内大量对象都是存活的,复制的开销增大- 优点
简单高效
不用考虑内存空间碎片化
PS.
现商用Java虚拟机多在新生代中采用该方法
Appel式回收
HotSpot虚拟机中的Serial、ParNew等新生代收集器均采取该策略。具体如下:
把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配只使用Eden和一块Survivor,发生垃圾回收时,将存活的对象一次性复制给另一块Survivor空间内,然后清理已用的空间。HotSpot虚拟机给Eden和Survivor默认大小比例为8:1,也就是说会有10%的空间会被浪费。当预留的10%的内存空间存不下存活的对象时,,就需要依赖其它内存空间(大多为老年代)进行内存分配。
标记-整理算法(Mark Compact)
区别与标记--清除算法,标记--整理算法,在标记后将存活的对象移向一端,然后将另一端的空间整体回收,是一种移动式的算法。
- 优点
不存在碎片化内存,则无需依赖复杂的内存分配器- 缺点
对象的移动操作需要触发“Stop The World”耗时较久
标记?清除:整理
标记-清除是一种非移动式算法、标记-整理是一种移动式算法,两者比较说明:
吞吐量比较
吞吐量定义:赋值器和收集器效率之和
不移动会使得收集器效率增大,但是内存分配和访问会比垃圾回收频率高得多,所以整体吞吐量还是降低的。举例说明
HotSpot虚拟机中关注吞吐量的Parallel Scavenger收集器基于标记-整理算法;关注低延迟的CMS收集器基于标记-清除算法混合方案
使虚拟机多数时间采用标记-清除算法,暂时容忍碎片的存在,等到碎片化程度开始影响对象的内存分配时,在采用标记-整理算法收集一次(CMS就采取该方式)
经典垃圾回收器
所谓“经典”垃圾回收器是指区别于实验室阶段的、已通过应用实践的垃圾回收器。
Serial收集器
Serial:新生代:标记-复制算法
Serial Old:老年代:标记-整理算法
HotSpot虚拟机运行在客户端模式下的默认新生代收集器
简单高效、内存消耗最小
ParNew收集器
ParNew:新生代:标记-复制算法
Serial Old:老年代:标记-整理算法
激活CMS后,默认的新生代收集器
Serial的多线程版本,默认开启的线程数与CPU核心数相同
Parallel Scavenge搜集器
标记-复制算法,与ParNew相似
关注点在于达成可控制的吞吐量(吞吐量=用户代码运行时间/总时间;总时间=用户代码运行时间+垃圾回收时间)
参数说明
-XXMaxGCPauseMillis
更关注停顿时间
一个大于0的毫秒数,尽量使回收时间不超过这个值
实现原理:牺牲吞吐量和新生代空间获取,小内存新生代空间的回收速度一定由于高内存速度,但是回收频率也会增加-XXGCTimeRatio
更关注吞吐量
0到100之间的整数,代表垃圾回收时间占总时间的比率,相当于吞吐量的倒数-UserAdaptiveSizePolicy
开关函数,激活后虚拟机会根据当前运行情况自动调整Eden与Survivor的内存比例、老年代内存大小等参数,已提供合适的停顿时间和最大吞吐量
Serial Old 收集器
serial 收集器的老年版本,标记-整理算法
在CMS收集器并发失败时的预备方案
Parallel Old 收集器
Parallel Scavenge 收集器的老年版本,标记-整理算法
在注重吞吐量或处理器资源稀缺时使用
CMS收集器
获取最短停顿时间的为目标,采用并发-清除算法
- 工作步骤
初始标记
标记GC Roots能直接关联的对象,速度很快并发标记
从GC Roots直接关联到的对象开始遍历整个对象图,耗时较长重新标记
修正并发标记期间,因用户继续运作导致标记产生变动的部分对象的标记记录并发清除
清除掉标记的已死亡的对象
整个过程中,并发标记和并发清除耗时最久
- 关键问题
并发过程中会占用部分资源
当处理器核心数大于4时,默认回收线程数不超过25%(处理器核心数+3)/4
但是当处理器核心数小于4时,用户线程执行速度会大幅降低“浮动垃圾”与并发失败
与用户程序运行并发运行就必然产生新的垃圾只有等下一次回收时才清理,这部分垃圾称为“浮动垃圾”,所以需要给用户线程预留足够空间。因此,CMS不能等老年代满了才进行收集,必须预留一部分作为并发时使用。如果CMS运行期间预留的内存无法满足程序分配新对象的需求,就会出现“并发失败”,这时候需要STW,临时启用Serial Old收集器对老年代的垃圾进行收集内存碎片
基于标记-清除算法必然产生内存碎片,导致大对象分配时出现内存不足进而触发Full GC。CMS提供-XX:UseCMSCompactAtFullCollection
开关参数(默认开启),当不得不进行Full GC时进行内存碎片整合,即移动存活对象。会使得停顿时间延长
Garbage First 收集器
建立可预测的停顿时间模型,开创了面向局部收集的内存设计思路,基于Region的内存布局形式。默认停顿时间为200毫秒
基于Region的内存布局
把连续的Java堆内存划分为多个大小相等的独立空间,每个空间都可以扮演Eden、Survivor空间或者老年代空间,其中Humongous区域转为收集大对象(大小超过了一个Region的对象,Region的大小可通过参数调整),G1大多会把Humongous当做老年代看待。收集器可以根据不同的角色采取不同的收集策略。局部收集思想
Region作为每次回收的最小内存单位,每次收集到的空间都是Region的整倍数,G1会跟踪Region堆积的“价值”大小(回收所获空间/回收所需时间的经验值),再后台维护一个优先级列表,优先回收价值大的Region- 工作步骤
- 初始标记
标记GC Roots能直接关联的对象,并修改TAMS指针的值,是借助Minor GC完成,所以不会造成额外的时间成本。 - 并发标记
从GC Roots开始对堆中对象进行可达性分析,可并发执行,扫描完成时重新处理SATB记录的引用变动 - 最终标记
处理并发标记时的发生变动的对象,STW,并发完成 - 筛选回收
更新Region的统计数据,根据用户期望的停顿时间结合回收价值,确定需要回收Region集合。把需要回收的Region中存活的对象复制到空Region中,再清理需要回收的全部Region区域。
- 初始标记
- 关键问题
跨Region引用的处理办法
每个Region都维护一张自己的记忆集,记录别的Region指向自己的指针,并标记这些指针在哪些卡页范围之内。其存储结构本质上是一种哈希表,key是别的Region的起始地址,value是一个集合,存储卡表的索引号。G1要耗费大越10%到20%的额外内存来维持收集器的工作。并发干扰问题
CMS在并发标记时采用增量更新的算法实现,而G1则通过原始快照(SATB)算法实现。此外,G1在回收过程中创建新对象的内存分配上也做了改动,G1为每个Region设计了两个名为TAMS(Top At Mark Start)的指针,并发标记中新分配的对象都要在这两个指针位置以上。G1收集器默认这部分对象是隐式标记过的,默认为存活可靠地停顿预测
-XX:MaxGCPauseMillis
参数指用户期望的停顿时间,具体实现是以“衰减均值”为理论基础:在垃圾回收过程中,会记录每个Region的回收耗时、记忆集中里的脏卡数量等各个可测量的步骤所花费的成本。“衰减均值”更能体现“最近”一段时间的平均状态,更能在当下使回收不超过预期。(有点活在当下的感觉)
- G1与CMS
- 优点:
可以指定最大停顿时间、分Region的内存布局、按收益动态回收、不会产生内存碎片、回收完成后可提供规整的可用内存 - 缺点:
内存占用、程序执行的额外负载都较高
G1的卡表更为复杂;运行负载方面,CMS使用写后屏障来更细维护卡表,而G1为了实现原始搜索(SATB)快照算法,还需要写前屏障来跟踪并发时的指针变化情况,G1能减少并发标记和重新标记的消耗,避免像CMS那样在最终标记阶段停顿时间过长。CMS直接同步处理,而G1异步处理
- 优点:
总结
小内存上使用CMS有优势,而大内存状态下使用G1有更多优势,而Java堆内存容量平衡点大约在6-8GB之间(经验数据)