Java教程

《深入理解Java虚拟机》第3章 垃圾收集器与内存分配策略-好家伙,收垃圾也是技术活

本文主要是介绍《深入理解Java虚拟机》第3章 垃圾收集器与内存分配策略-好家伙,收垃圾也是技术活,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

目录

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


1 回收范围

凡是分配了内存的区域,最终都要进行内存回收

线程独有的区域:线程消亡,肯定消亡,一定可以回收;超出方法区域、作用域,对应的方法中的局部变量表、操作数栈等自然可以回收(无需过于关注,这部分的回收,基本上编译器已经可以确定)

java堆:处于线程共享区,判断回收更困难,需要统一的标准

方法区:需要考虑类的回收和常量回收

2 堆区回收

2.1 堆区-哪些要回收

对象死亡的情况可以回收,判断对象死亡可以用引用计数法可达性分析法

引用计数法:有新引用加一,解引用减一。优点:简单高效;缺点:无法解决循环引用回收问题

可达性分析法:从GCRoot开始查找引用链,逐一进行标记,未被标记的判断为对象死亡

2.1.1 可以作为GCRoot的节点

a.虚拟机栈局部变量表引用对象

b.本地方法栈native方法的局部变量表引用的对象

c.方法区静态属性引用对象

d.方法区常量引用对象

2.2 堆区-什么时候回收 

和对象采用的引用方式有关,强引用,软引用,弱引用,虚引用

强引用:JVM宁可报错,也不回收

软引用:内存即将溢出时回收

弱引用:下一次垃圾回收器轮询时回收

虚引用:相当于没有引用,唯一作用是用来跟踪回收状态,被回收时可以拿到通知

没有引用的情况下是可以回收的,如a = null;

2.3 堆区-回收过程

a. 检查对象是否死亡

先用【1.2】 的方式判断引用类型,如果符合回收条件,可以回收;

不满足的情况,按照对应虚拟机的属性(HotSpot是可达性分析)采用引用计数法或可达性分析法判断对象死亡,不满足回收条件则这部分无需回收,满足回收条件的进入【b】

b.是否有覆盖finalize()方法

没有覆盖finalize()方法的,垃圾回收器可以直接进行回收,有覆盖finalize()方法的,进入【c】

c.是否对象已经调用过finalize()方法

 如果已经调用过finalize()方法,垃圾回收器可以直接进行回收;没有调用过finalize()方法,进入【d】

d.放入F-Queue,使用低优先级的回收器进行回收

如果调用finalize()后,重新建立引用链,对象存活,否则,对象被垃圾回收器回收

3 方法区回收

3.1 常量回收

未被引用的常量可以被回收

3.2 类回收

类不是一定要回收的,可以进行回收

可以回收的类满足以下三个条件

a. Java 堆中此类的对象已经被回收

b. 加载类的ClassLoader已经被回收

c. 没有此类对应的Class类的引用

4 垃圾回收算法

上一节了解到哪些内存,什么情况下可以回收,那么具体是怎么回收的呢?

4.1 分代收集理论

4.1.1 分代收集假说

(1)弱分代假说:绝大多数对象都是朝生夕灭的

(2)强分代假说:熬过越多次垃圾回收过程的对象的对象越难以消亡

(3)跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

跨代引用:既被新生代引用又被老年代引用的对象,需要单独进行标记,否则在单独进行新生代回收或者老年代回收的情况,会存在回收错误的情况;如果要保证绝对正确性,又会消耗大量的性能

解决方案:新生单独分一块作为记忆集(Remember Set),用于单独标识老年代跨代内存

4.1.2 各种GC回收方式

大体分为部分收集(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):收集整个新生代和部分老年代

4.2 标记-清除法

做法:在需要进行垃圾回收的内存块中,标记要清除的对象,统一进行回收

缺点:效率低,产生大量碎片

4.3 标记-整理法

做法:统一将存活对象整理到内存的一端,超出的部分进行统一收集。

优点:不需要额外内存,不会产生碎片

缺点:效率低

4.4 复制算法

做法:内存缩小为原来的一半,清理时把存活对象复制整理到另一半内存中,当前块内存清理

优点:简单高效

缺点:额外消耗一半内存

4.5 分代收集算法

做法:依照新生代和老年代的特点决定。新生代存活率低用复制算法,老年代存活率高用标记-清除法或者标记-整理法

5 HotSpot算法实现

5.1 枚举根节点

可以做根节点的GCRoot非常多,实际上是使用一种叫做OopMap的数据结构来实现目的。类加载完成时,会在特定位置记录这些引用。因为引用是实时变化的,所以最终进行回收操作还是要“Stop the World”,截取一个特定时间点的根节点,进行垃圾回收。OopMap数据结构的记录,最终在枚举根节点过程中会被直接扫描,从而大幅度缩短了枚举根节点的时间,进而缩短“Stop the World”的时间。

5.2 安全点

不是所有节点都可以进行垃圾回收的,必须到达安全点才能进行垃圾回收

安全点:长时间执行的程序。特点:方法调用、循环跳转、异常跳转。

5.2.1 多线程回收方案

抢先式中断:所有线程全部停,再把每有到达安全点的线程一个个启动直到运行到安全点,最终达到所有线程的目的。

主动式中断:所有线程运行到安全点时都观察一个共同的参数,如果这个参数提示需要进行中断则进行中断,直到所有线程都完成中断。

5.3 安全区域

用于解决线程在 Sleep/Block 状态下的垃圾回收。

安全区域:一段代码中,如果引用不会发生变化,那么这段代码就可称作安全区域,在安全区域内页可以进行收集器的垃圾回收。

5.4 记忆集

跨代收集:新生代和老年代同时包含某对象的情况。

记忆集出现是为了解决跨代收集问题,了解包含跨代收集的区域。由于精度过细,可能大幅度降低性能,所以这里采用了空间换时间的处理方式。根据实际需要选用不同精度的记忆集进行处理。

字节精度:以机器字长作为单位进行处理

对象精度:以对象作为单位进行处理

卡精度(卡表):以内存区域作为单位进行处理,内存地址右移9位实现

5.5 写屏障

机器码级别的AOP(针对写操作),采用环绕模式

写前屏障:写操作前执行的代码

写后屏障:写操作后执行的代码

使用写后屏障,可以用于记录卡表,从而保证在新生代回收时,不用扫描整个老年代区域。虽然对性能的消耗较大,但是优于扫描整个老年代区域。

5.6 伪共享

伪共享:CPU内存存在不同缓存行,多个线程同时访问同一个卡表时,会相互影响,从而降低性能的现象。

CPU内存在不同的缓存行,每个缓存行内有不同的卡表,如果同时访问同一个卡表,可能会互相影响,出于这个原因,有一种优化方式,是采用对卡表更新的条件判断,没有标记过才进行标记。

 启用条件参数:-XX:+UseCondCardMark

注意,不一定加了这个参数就会性能提高,要以实际测试的结果为准。

5.7 并发的可达性分析

5.7.1 堆大小和 GC Roots 遍历的关系

GC Roots根节点遍历:存在OopMap数据结构的优化,不随堆大小的变化发生变化

实际堆中存活对象遍历:随着堆大小增长进行增长

5.7.2 用户线程和回收线程并发可能产生的问题

对象冗余:回收器扫描后,用户线程产生了新的可回收对象,导致有对象没能参与回收。这种情况不会产生大的影响,下次回收器扫描时可以进行回收。

对象消失:回收器使用过程,未扫描完的区域断开引用链和已扫描区域建立引用。这种情况会导致对象消失,从而导致程序出错。(必须解决)

5.7.3 产生对象消失的条件

未扫描完对象断开引用已扫描完对象建立引用

5.7.4 解决对象消失问题的方案

增量更新:破坏已扫描完对象建立引用条件,让有新引用的节点重新变为未扫描完成状态,可以重新被来及回收器扫描。

原始快照:垃圾回收器回收时,产生此时刻快照,按垃圾回收器进入扫描的那一刻为准,破坏了未扫描完对象断开引用条件

两种方案最终都会放在写屏障中进行

6 垃圾回收器

垃圾回收器没有最好,只有最合适的,每种垃圾回收器都有各自的优点,需要根据场景选用合适的垃圾回收器

并行:同一个时间点,多个线程同时运行

并发:同一段时间内,多个线程都执行(不一定同时)

6.1 新生代回收器

6.1.1 Serial

单线程回收器,使用过程中,需要停止用户线程

联用:CMS和Serial Old

算法:复制算法

适用:桌面应用

优点:简单高效,减少线程交互的开销

缺点:整个过程需要停止用户线程,体验较差

6.1.2 ParNew

Serial回收器的多线程版本,JDK9与CMS合并,成为第一个退出历史舞台的回收器

联用:CMS

算法:复制算法

6.1.3 Parallel Scavenge

注重单位时间内的吞吐量,吞吐量是可预估的,未配置情况可以进行自适应调节

吞吐量:用户线程运行时间/(用户线程运行时间+垃圾回收器运行时间)

联用:Serial Old/Parallel Old(老年代较大的场景,不建议和Serial Old联用,效率比不上CMS+Serial)

缺点:兼容性不强,只有和Parallel Old联用时能发挥较好的性能。不支持CMS

相关参数
-XX:MaxGCPauseMillis 最长垃圾收集停顿时间
-XX:GCTimeRatio,吞吐量比例
-XX:+UseAdaptiveSizePolicy 根据系统运行情况,动态指定-XX:SurvivorRatio和-XX:PretenureSizeThreshold 这两个部分

6.2 老年代回收器

6.2.1 Serial Old

Serial 回收器的老年代版本

联用:Parallel Scavenge,Serial,CMS

算法:标记-整理法

6.2.2 Parallel Old

是 Parallel Scavenge 的老年代版本

联用:Parallel Scanvenge

用途:充分发挥Parallel Scavenge性能

算法:标记-整理法

6.2.3 CMS

以获取最短停顿时间为目标。

联用: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的停顿时间

6.3 G1回收器

CMS存在明显的缺陷,G1回收器出现是希望能替换CMS回收器,达到缩短停顿用户线程时间的目的。

执行步骤

(1)初始标记:记录到记忆集中

(2)并发标记:根据记忆集进行标记,新产生的节点放到记忆集日志中(停顿)

(3)最终标记:合并记忆集和记忆集日志

(4)筛选回收:优先回收价值更大的垃圾,只回收一部分(停顿)

优点

并行并发:能够充分发挥多核CPU的优势,缩短停顿时间

分代收集:可以回收整个GC堆,也可以和其他收集器联合

空间整合:采用标记-整理法,不会像CMS那样产生碎片

可预测的停顿:停顿时间可以根据模型预估,优先回收价值更大的垃圾,从而避免长时间停顿

缺点:内存占用和负载【大概可以理解为硬件功耗?】比CMS高

6.4 低延迟收集器

衡量垃圾收集器的三项重要指标:内存占用、吞吐量和延迟,最多可以同时达成其中的两项【可以想象成一个装一半水的三棱柱,当向一边倾斜时,1或2条边水柱长度提高,其他方向长度水柱长度缩短】

三项指标和硬件提升的关系:

内存占用:内存总量提高,我们能够容忍更多的内存占用

吞吐量:硬件性能增长,直接会提高软件系统处理能力,吞吐量会更高

延迟:负面效果,需要回收的堆空间增长,需要耗费更多时间,是收集器最重要的指标

6.4.1 收集器延迟性分析

Serial、Serial Old、ParNew、Parallel Scavenge,Parallel Old:所有回收步骤全部停顿

CMS:增量更新减少停顿,但是会产生内存碎片,最终需要进行Full GC停顿来清除碎片

G1:原始快照减少停顿,粒度更小,但是还是要停顿

Shenandoah和ZGC(记忆:深谙冬奥会和自个唱):只在初始标记和最终标记阶段停顿,其他阶段并发执行,停顿时间稳定在10毫秒内,不随堆增长而增长。ZGC:JDK11,Shenandoah:JDK12,这两个收集器叫做低延迟收集器

6.4.2 Shenandoah

非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的读屏障只拦截对象中引用类型读写,不拦截非引用类型读写

6.4.3 ZGC

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,没有后缀,增加分配内存或增加物理内存
 

这篇关于《深入理解Java虚拟机》第3章 垃圾收集器与内存分配策略-好家伙,收垃圾也是技术活的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!