目录
1 回收范围
2 堆区回收
2.1 堆区-哪些要回收
2.1.1 可以作为GCRoot的节点
2.2 堆区-什么时候回收
2.3 堆区-回收过程
3 方法区回收
3.1 常量回收
3.2 类回收
4 垃圾回收算法
4.1 分代收集理论
4.1.1 分代收集假说
4.1.2 各种GC回收方式
4.2 标记-清除法
4.3 标记-整理法
4.4 复制算法
4.5 分代收集算法
5 HotSpot算法实现
5.1 枚举根节点
5.2 安全点
5.2.1 多线程回收方案
5.3 安全区域
5.4 记忆集
5.5 写屏障
5.6 伪共享
5.7 并发的可达性分析
5.7.1 堆大小和 GC Roots 遍历的关系
5.7.2 用户线程和回收线程并发可能产生的问题
5.7.3 产生对象消失的条件
5.7.4 解决对象消失问题的方案
6 垃圾回收器
6.1 新生代回收器
6.1.1 Serial
6.1.2 ParNew
6.1.3 Parallel Scavenge
6.2 老年代回收器
6.2.1 Serial Old
6.2.2 Parallel Old
6.2.3 CMS
6.3 G1回收器
6.4 低延迟收集器
6.4.1 收集器延迟性分析
6.4.2 Shenandoah
6.4.3 ZGC
凡是分配了内存的区域,最终都要进行内存回收
线程独有的区域:线程消亡,肯定消亡,一定可以回收;超出方法区域、作用域,对应的方法中的局部变量表、操作数栈等自然可以回收(无需过于关注,这部分的回收,基本上编译器已经可以确定)
java堆:处于线程共享区,判断回收更困难,需要统一的标准
方法区:需要考虑类的回收和常量回收
对象死亡的情况可以回收,判断对象死亡可以用引用计数法和可达性分析法
引用计数法:有新引用加一,解引用减一。优点:简单高效;缺点:无法解决循环引用回收问题
可达性分析法:从GCRoot开始查找引用链,逐一进行标记,未被标记的判断为对象死亡
a.虚拟机栈局部变量表引用对象
b.本地方法栈native方法的局部变量表引用的对象
c.方法区静态属性引用对象
d.方法区常量引用对象
和对象采用的引用方式有关,强引用,软引用,弱引用,虚引用
强引用:JVM宁可报错,也不回收
软引用:内存即将溢出时回收
弱引用:下一次垃圾回收器轮询时回收
虚引用:相当于没有引用,唯一作用是用来跟踪回收状态,被回收时可以拿到通知
没有引用的情况下是可以回收的,如a = null;
a. 检查对象是否死亡
先用【1.2】 的方式判断引用类型,如果符合回收条件,可以回收;
不满足的情况,按照对应虚拟机的属性(HotSpot是可达性分析)采用引用计数法或可达性分析法判断对象死亡,不满足回收条件则这部分无需回收,满足回收条件的进入【b】
b.是否有覆盖finalize()方法
没有覆盖finalize()方法的,垃圾回收器可以直接进行回收,有覆盖finalize()方法的,进入【c】
c.是否对象已经调用过finalize()方法
如果已经调用过finalize()方法,垃圾回收器可以直接进行回收;没有调用过finalize()方法,进入【d】
d.放入F-Queue,使用低优先级的回收器进行回收
如果调用finalize()后,重新建立引用链,对象存活,否则,对象被垃圾回收器回收
未被引用的常量可以被回收
类不是一定要回收的,可以进行回收
可以回收的类满足以下三个条件
a. Java 堆中此类的对象已经被回收
b. 加载类的ClassLoader已经被回收
c. 没有此类对应的Class类的引用
上一节了解到哪些内存,什么情况下可以回收,那么具体是怎么回收的呢?
(1)弱分代假说:绝大多数对象都是朝生夕灭的
(2)强分代假说:熬过越多次垃圾回收过程的对象的对象越难以消亡
(3)跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
跨代引用:既被新生代引用又被老年代引用的对象,需要单独进行标记,否则在单独进行新生代回收或者老年代回收的情况,会存在回收错误的情况;如果要保证绝对正确性,又会消耗大量的性能
解决方案:新生单独分一块作为记忆集(Remember Set),用于单独标识老年代跨代内存
大体分为部分收集(Partial GC)和整堆收集(Full GC)
部分收集又分为新生代收集(Minor GC/Young GC),老年代收集(Major GC/Old GC),混合收集(Mixed GC)
部分收集(Partial GC):Java堆回收时只回收一部分,不进行整堆回收
整堆收集(Full GC):收集整个Java堆+方法区
新生代收集(Minor GC/Young GC):仅收集新生代
老年代收集(Major GC/Old GC):仅收集老年代
混合收集(Mixed GC):收集整个新生代和部分老年代
做法:在需要进行垃圾回收的内存块中,标记要清除的对象,统一进行回收
缺点:效率低,产生大量碎片
做法:统一将存活对象整理到内存的一端,超出的部分进行统一收集。
优点:不需要额外内存,不会产生碎片
缺点:效率低
做法:内存缩小为原来的一半,清理时把存活对象复制整理到另一半内存中,当前块内存清理
优点:简单高效
缺点:额外消耗一半内存
做法:依照新生代和老年代的特点决定。新生代存活率低用复制算法,老年代存活率高用标记-清除法或者标记-整理法
可以做根节点的GCRoot非常多,实际上是使用一种叫做OopMap的数据结构来实现目的。类加载完成时,会在特定位置记录这些引用。因为引用是实时变化的,所以最终进行回收操作还是要“Stop the World”,截取一个特定时间点的根节点,进行垃圾回收。OopMap数据结构的记录,最终在枚举根节点过程中会被直接扫描,从而大幅度缩短了枚举根节点的时间,进而缩短“Stop the World”的时间。
不是所有节点都可以进行垃圾回收的,必须到达安全点才能进行垃圾回收。
安全点:长时间执行的程序。特点:方法调用、循环跳转、异常跳转。
抢先式中断:所有线程全部停,再把每有到达安全点的线程一个个启动直到运行到安全点,最终达到所有线程的目的。
主动式中断:所有线程运行到安全点时都观察一个共同的参数,如果这个参数提示需要进行中断则进行中断,直到所有线程都完成中断。
用于解决线程在 Sleep/Block 状态下的垃圾回收。
安全区域:一段代码中,如果引用不会发生变化,那么这段代码就可称作安全区域,在安全区域内页可以进行收集器的垃圾回收。
跨代收集:新生代和老年代同时包含某对象的情况。
记忆集出现是为了解决跨代收集问题,了解包含跨代收集的区域。由于精度过细,可能大幅度降低性能,所以这里采用了空间换时间的处理方式。根据实际需要选用不同精度的记忆集进行处理。
字节精度:以机器字长作为单位进行处理
对象精度:以对象作为单位进行处理
卡精度(卡表):以内存区域作为单位进行处理,内存地址右移9位实现
机器码级别的AOP(针对写操作),采用环绕模式
写前屏障:写操作前执行的代码
写后屏障:写操作后执行的代码
使用写后屏障,可以用于记录卡表,从而保证在新生代回收时,不用扫描整个老年代区域。虽然对性能的消耗较大,但是优于扫描整个老年代区域。
伪共享:CPU内存存在不同缓存行,多个线程同时访问同一个卡表时,会相互影响,从而降低性能的现象。
CPU内存在不同的缓存行,每个缓存行内有不同的卡表,如果同时访问同一个卡表,可能会互相影响,出于这个原因,有一种优化方式,是采用对卡表更新的条件判断,没有标记过才进行标记。
启用条件参数:-XX:+UseCondCardMark
注意,不一定加了这个参数就会性能提高,要以实际测试的结果为准。
GC Roots根节点遍历:存在OopMap数据结构的优化,不随堆大小的变化发生变化
实际堆中存活对象遍历:随着堆大小增长进行增长
对象冗余:回收器扫描后,用户线程产生了新的可回收对象,导致有对象没能参与回收。这种情况不会产生大的影响,下次回收器扫描时可以进行回收。
对象消失:回收器使用过程,未扫描完的区域断开引用链和已扫描区域建立引用。这种情况会导致对象消失,从而导致程序出错。(必须解决)
未扫描完对象断开引用和已扫描完对象建立引用
增量更新:破坏已扫描完对象建立引用条件,让有新引用的节点重新变为未扫描完成状态,可以重新被来及回收器扫描。
原始快照:垃圾回收器回收时,产生此时刻快照,按垃圾回收器进入扫描的那一刻为准,破坏了未扫描完对象断开引用条件
两种方案最终都会放在写屏障中进行
垃圾回收器没有最好,只有最合适的,每种垃圾回收器都有各自的优点,需要根据场景选用合适的垃圾回收器
并行:同一个时间点,多个线程同时运行
并发:同一段时间内,多个线程都执行(不一定同时)
单线程回收器,使用过程中,需要停止用户线程
联用:CMS和Serial Old
算法:复制算法
适用:桌面应用
优点:简单高效,减少线程交互的开销
缺点:整个过程需要停止用户线程,体验较差
Serial回收器的多线程版本,JDK9与CMS合并,成为第一个退出历史舞台的回收器
联用:CMS
算法:复制算法
注重单位时间内的吞吐量,吞吐量是可预估的,未配置情况可以进行自适应调节
吞吐量:用户线程运行时间/(用户线程运行时间+垃圾回收器运行时间)
联用:Serial Old/Parallel Old(老年代较大的场景,不建议和Serial Old联用,效率比不上CMS+Serial)
缺点:兼容性不强,只有和Parallel Old联用时能发挥较好的性能。不支持CMS
相关参数:
-XX:MaxGCPauseMillis 最长垃圾收集停顿时间
-XX:GCTimeRatio,吞吐量比例
-XX:+UseAdaptiveSizePolicy 根据系统运行情况,动态指定-XX:SurvivorRatio和-XX:PretenureSizeThreshold 这两个部分
Serial 回收器的老年代版本
联用:Parallel Scavenge,Serial,CMS
算法:标记-整理法
是 Parallel Scavenge 的老年代版本
联用:Parallel Scanvenge
用途:充分发挥Parallel Scavenge性能
算法:标记-整理法
以获取最短停顿时间为目标。
联用:Serial
算法:标记-清除法
步骤:
(1)初始标记:对当前的GC Roots进行一次标记,标记过程需要停止用户线程,因为有OopMap存在,此步骤停顿时间较短
(2)并发标记:启动用户线程,根据之前标记的GC Roots进行遍历,新产生的GC Roots进行记录,此部分不需要停止用户线程
(3)重新标记:对(2)过程产生的标记,进行遍历,需要停止用户线程
(4)并发清除:对未标记的部分进行清除,不需要停止用户线程
优点:停顿时间短,用户体验较好
缺点:
(1)CPU敏感:资源占用公式:线程数量=(CPU数量 +3)/4,少于4核CPU,对资源的消耗非常大
(2)无法处理浮动垃圾:并发清除阶段,无法对新产生的垃圾进行回收
(3)产生碎片:基于标记-清除法,未进行整理,本身会产生内存碎片,碎片问题缓解的调参:
-XX:+UseCMSCompactAtFullCollection | 在没有足够内存时触发一次标记整理(GC时间会变长) |
-XX:CMSFullGCsBeforeCompaction | 置Full GC时进行整理的频率,执行多少次不压缩的Full GC后,跟着执行一次压缩的Full GC |
调参可以减少碎片,但是会增加CMS的停顿时间
CMS存在明显的缺陷,G1回收器出现是希望能替换CMS回收器,达到缩短停顿用户线程时间的目的。
执行步骤:
(1)初始标记:记录到记忆集中
(2)并发标记:根据记忆集进行标记,新产生的节点放到记忆集日志中(停顿)
(3)最终标记:合并记忆集和记忆集日志
(4)筛选回收:优先回收价值更大的垃圾,只回收一部分(停顿)
优点:
并行并发:能够充分发挥多核CPU的优势,缩短停顿时间
分代收集:可以回收整个GC堆,也可以和其他收集器联合
空间整合:采用标记-整理法,不会像CMS那样产生碎片
可预测的停顿:停顿时间可以根据模型预估,优先回收价值更大的垃圾,从而避免长时间停顿
缺点:内存占用和负载【大概可以理解为硬件功耗?】比CMS高
衡量垃圾收集器的三项重要指标:内存占用、吞吐量和延迟,最多可以同时达成其中的两项【可以想象成一个装一半水的三棱柱,当向一边倾斜时,1或2条边水柱长度提高,其他方向长度水柱长度缩短】
三项指标和硬件提升的关系:
内存占用:内存总量提高,我们能够容忍更多的内存占用
吞吐量:硬件性能增长,直接会提高软件系统处理能力,吞吐量会更高
延迟:负面效果,需要回收的堆空间增长,需要耗费更多时间,是收集器最重要的指标
Serial、Serial Old、ParNew、Parallel Scavenge,Parallel Old:所有回收步骤全部停顿
CMS:增量更新减少停顿,但是会产生内存碎片,最终需要进行Full GC停顿来清除碎片
G1:原始快照减少停顿,粒度更小,但是还是要停顿
Shenandoah和ZGC(记忆:深谙冬奥会和自个唱):只在初始标记和最终标记阶段停顿,其他阶段并发执行,停顿时间稳定在10毫秒内,不随堆增长而增长。ZGC:JDK11,Shenandoah:JDK12,这两个收集器叫做低延迟收集器
非Sun、Oracle开发的第三方收集器,仅在Open JDK12存在,Oracle JDK12中反而不存在。和G1收集器相互借鉴,但是两者依然存在差异性。
Shenandoah和G1的区别:
(1) Sheandoah支持并发整理算法,G1的回收阶段虽然是多线程执行,但是是不能并发执行的
(2) Shenandoah不支持分代收集,G1支持分代收集
(3) G1使用记忆集,Shenandoah摒弃了记忆集,改用连接矩阵处理跨代引用关系,降低处理记忆集维护消耗,降低伪共享出现概率
连接矩阵:可理解为二维数组,当N指向M时,a[N][M]=1,进行标记(猜测实际为状态压缩实现)
Shenandoah执行阶段:
(1)初始标记:标记GC Roots对象,停顿时间与堆大小无关,只与GC Roots数量有关(停顿)
(2)并发标记:标记可达对象(并发)
(3)最终标记:并发剩余部分扫描,组成回收集(并发)
(4)并发清理:全区域无引用对象清理(并发)【个人理解为引用计数法引用数目0的对象回收,不能解决循环引用对象回收】
(5)并发回收:存活对象复制,用到读屏障和转发指针,执行时长取决于回收集大小(并发)
(6)初始引用更新:修改旧对象引用到复制后的新地址【实际上只进行收集,不进行实际引用更新】(停顿)
(7)并发引用更新:真正进行引用更新,按物理内存搜索,不按对象图搜索(并发)
(8)最终引用更新:修正GC Roots引用(停顿)
(9)并发清理:清理整个回收空间(并发)
其中(2)并发标记、(5)并发回收、(7)并发引用更新为核心步骤,分别对应下图的中间三个:
(此图来自书中,原文有图例和文字说明,因为都是英文,这里直接文字表述更清晰,黄色代表回收区域,绿色存活对象,橙色迁移的目标内存块,蓝色分配给对象的内存)
省流大师:标记、复制、清除
其中,并发回收的读屏障和转发指针是精华所在
保护陷阱:转发指针出现前实现对象移动并发的解决方案,对旧对象设置异常处理,再由代码逻辑转发到新对象上。如果没有操作系统层面的支持,会频繁进行用户态和内核态切换(系统调用、异常处理、系统调用是三种产生用户态内核态切换的场景,异常处理在这三种方式之中),代价非常大,不能频繁操作。
转发指针(Brooks Pointer):实现对象移动和用户程序并发的解决方案。对象头增加引用指向自己(类似于早期的对象句柄)。(个人感觉像是发生一次链接的并查集)
访问对象的两种方式:对象句柄、直接指针。
对象句柄:在方法区单独划分一块作为句柄池,reference指向句柄池,句柄池有两个指向方法区的对象类型和堆中的对象实例的指针
直接指针:reference指向堆中的对象,对象中包含指向方法区的对象类型的指针
转发指针事实上完成了以下三件事(书中原为只写了(1)(2),(3)其实算是(2)中的一部分):
(1)复制对象A(对象头指针指向A),产生副本A'(对象头指针指向A')
reference->A->A
A'->A'
(2)修改对象A的对象头指针指向A',变成:A(A'),A'(A')
reference->A->A'->A'
(3)按照类似链表删除节点的方式删除旧对象
reference.next = reference.next.next,即可完成对象引用转换,最终变为:
reference->A'->A'
假设我们只关注尾节点,(2)到(3)的过程,发生用户线程并发更新是没有问题的,因为最终都作用在新对象上,不会产生问题。(1)到(2)的转换过程中,如果发生用户线程并发更新则会有问题,(1)步骤,reference对应的尾节点是旧对象,而(2)步骤是新对象,如果这时候允许用户更新,则会造成修改丢失
因此,(1)到(2)的过程中,使用了CAS自旋锁
因为要给每个对象的对象头都做出修改,所以Shenandoah需要同时增加读屏障和写屏障,庞大的读屏障带来了额外的性能开销
引用访问屏障:JDK13新出的减少Shenandoah开销的优化,Shenandoah的读屏障只拦截对象中引用类型读写,不拦截非引用类型读写JDK11开始支持,基于区域内存布局,不设分代,低延迟为首要目标,吞吐量为次要目标的垃圾收集器
垃圾回收算法:标记-整理法
技术:读屏障、染色指针和内存多重映射
区域:具有动态性,动态创建、销毁,动态分配大小
型号 | 容量 | 存放对象大小 | 是否可重分配 |
---|---|---|---|
小型区域 | 2M | <256K | 是 |
中型区域 | 32M | >=256K且<4K | 是 |
大型区域 | 2M的整数倍【注:可能比中型区域小】 | >4K,每个大型区域只存放一个对象 | 否,代价太高 |
染色指针:使用引用对象的指针来进行标记。64位系统中,内存块能够存2^64的数据,高18位不能做寻址,剩余低42位用来存对象指针,剩下4位可以用于染色指针信息
染色指针标记:三色标记状态,重分配集(是否移动),是否使用finalize()才能访问
以下是书中原图:
缺点:不支持32位系统、只能管理4TB内存
比Shenandoah优势:
对象移动立刻清理:这意味着移动过程中可能只需要1个区域的额外内存,Shenandoah需要全部复制完成,才能清理出旧的区域,这意味着最坏全部存活情况,需要一半的空余内存
减少额外写屏障:垃圾回收过程中使用写屏障是为了记录对象移动情况,已经保存在指针中,可以省略这个步骤,ZGC仅使用读屏障,因此对吞吐量的影响较低
染色指针修改操作系统的指针实现,依赖于虚拟内存多重映射技术
多重映射:将多个虚拟地址内存映射到同一块物理内存上
垃圾回收器的标记方式:
对象头:Serial
额外数据结构:G1(记忆集-卡表),Shenandoah(连接矩阵)
回收步骤:
(1)并发标记:修改染色指针
(2)并发预备重分配:根据条件统计回收区域,组成重分配集。对重分配集全部扫描,确定需要移动的对象进行标记。
(3)并发重分配:每个区域维护转发表,记录旧对象到新对象转发关系,当进行对象读取时,如果拿到的指针上有重分配标志,进入读屏障,转发到复制的新对象,修正引用值。这种行为称为“自愈”(个人觉得这算是虚拟机级别的懒加载)
(4)并发重映射:修正所有旧对象引用,因为这一步只是为了不变慢,而且其中很大一部分经过并发重分配可以自愈,因此,这一步放在下次回收进行扫描时进行。从而节省一次遍历对象图。
优点:不使用写屏障,用户线程负担更小
缺点:大堆快速创建大量对象场景,会产生大量浮动垃圾,并发回收过程长,这意味着浮动垃圾在一段时间内无法完成清除,从而造成堆区更大的内存占用
(未完待续。。。这章没结束,会继续在本篇更新,为了方便观看先发布)
上章答案:
Java虚拟机的内存分区分为哪些部分?每一部分保存哪些数据?
堆:存放对象
方法区(后转入直接内存变成元空间):静态变量、常量、即时编译器
虚拟机栈:栈帧组成,存放Java方法,内部包括局部变量表、操作数栈、动态链接、返回地址、附加信息
本地方法栈:栈帧组成,存放Java方法,内部包括局部变量表、操作数栈、动态链接、返回地址、附加信息
程序计数器:记录行号,流程控制,保证线程切回后,能够继续执行后面的部分哪些虚拟机内存是线程共享的?哪些是线程独有的?
堆、方法区是线程共享的,虚拟机栈、本地方法栈、程序计数器是线程独有的哪些内存区域可能产生OOM?哪些内存区域可能产生SOF?
堆、方法区、虚拟机栈、本地方法栈可能产生OOM,虚拟机栈、本地方法栈可能产生SOF对象创建的过程是怎样的?
类加载检查、分配内存、初始化零值、设置对象头、调用<init>方法内存分配的方式有哪些?
指针碰撞、空闲列表,如果是连续内存,可使用指针碰撞,通过移动指针来划分已使用、未使用区域;非连续内存,使用空闲列表,逐一检查剩余内存区域,如果有足够大的内存,则将数据分配到这块内存之中内存分配的冲突如何解决?
CAS+失败重试、TLAB使用线程本地的小块内存对象内存布局分为哪几部分?每部分存放哪些数据?各自有什么特点?
对象头: 64bit,通过各个位进行标识不同信息
运行时数据:【线程状态、是否有锁、锁类型、哈希值、程序计数器】
类元指针:类型+长度(数组才有)
实例数据:相同大小的分在一起
对齐填充:8byte的倍数,填占位符,如果已满可不填哪些区域可能产生内存溢出?出现此类溢出后有什么特征?如何解决?
堆、方法区、栈、直接内存
堆:OutOfMemoryError:Java heap Space,通过Dump快照分析是否有内存泄露,如果有内存泄露需要修改代码,否则加大内存,1.8以后字符串常量池也可能产生此溢出(1.7+搬到堆区,所以不会在方法区溢出)
方法区:OutOfMemoryError:Metaspace, Java中主要可能是AOP
栈溢出:StackOverflowError/OutOfMemoryError:unable to create new native thread,栈深度超出会出第一个错误,线程数太多会出第二个错误,和Xss128k这个参数有关,调大能扩大栈区但减少线程数,调小能增加线程数但缩小栈区,栈大小和线程数量成反比
直接内存:OutOfMemoryError,没有后缀,增加分配内存或增加物理内存