《深入理解Java虚拟机》第三章

第三章 垃圾收集器与内存分配策略

在这里插入图片描述

如何判断对象已死?

·引用计数算法:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器加一;引用失效时,计数器减一;为0的对象不可再被使用。会出现对象间循环调用问题。
·可达性分析方法:

  1. Java的内存管理子系统,是通过它来判断对象是否存活的。
  2. 基本思路:一系列称为GC Root的根对象作为起始节点集,根据引用关系向下搜索(引用链),如果对象不可达,就判断为不能再用对象。
  3. 可作为GC Root的对象:1.栈帧中的本地变量表中引用的对象2.字符串常量池里的引用等
    主要在全局性引用(常量或类静态属性)和执行上下文(栈帧中的本地变量表)中
    判断对象有没有在使用,而使用只能发生在运行时,运行的基本单位是线程,所以GC Root两个来源:一是线程共享的元数据,在方法区或者堆中的字符串常量池;二是线程启动以来的方法调用栈。
    根据用户选择的垃圾收集器和回收的区域不同,也可以有其他对象临时加入。比如局部回收时候,一个区域可能调用另一个区域的对象引用。
  • 引用:
    强引用:赋值,将一个内存地址赋给另一块,关系在,GC就不会收到被引用对象
    软引用:描述有些用,但非必须对象。在内存溢出发生前,会把这些对象列入范围进行第二次回收,但如果这次回收后依旧没有足够内存,才抛出异常
    弱引用:非必须对象。被弱引用关联的对象只能活到下一次垃圾回收
    虚引用:无法通过虚引用获取对象实例,只是为了在这个对象被回收前收到一个通知
  1. 判断不可达也不是非死不可,还可以有缓刑。死亡前会经过至少两次的标记过程:如果判断为不可达,对象被标记一次,之后VM再进行一次筛选,如果对象覆盖finalize()方法,并且之前没有被调用过finalize()方法,那么虚拟机判断“有必要执行”,这个对象被放入F-Queue队列中,通过VM自动建立的线程去执行它们的该方法,不承诺等待运行结束,只是给它们一个机会。稍后收集器对队列进行二次标记,如果有的成果自救,那么移除“即将回收”集合,反之,就被真正回收。

回收方法区

  1. 不是所有收集器都能完整实现垃圾收集(ZGC收集器就不支持类卸载)
  2. 方法区的回收主要是废弃的常量和对不再使用的类型的卸载。
  3. 判断废弃的常量:没有任何对象引用该常量,VM中也没有地方引用它。内存回收时,GC判断确有必要的话就会把它清除
  4. 判断类型不再使用,满足以下三点:
  5. java堆中不存在它和它的派生子类的实例
  6. 加载该类的类加载器已被回收
    3。该类对应的java.lang.Class对象没有在任何地方被引用

垃圾收集算法

分代收集三大假说

  1. 弱分代假说:绝大多数对象朝生夕灭
  2. 强分代假说:熬过越多次垃圾收集,越难消亡
  3. 跨代引用假说:新生代和老年代间的跨代引用占极少数

垃圾收集算法

  1. 标记-清除算法:
    最基础的GC算法。首先标记出想要回收的对象,接着统一进行清除。也可以标记存后对象,回收未标记对象。
  2. 标记-复制算法:
    “半区复制”:将内存分为两部分(1:1),每次只使用其中一块。用完一块,就将存后的对象复制到另一块上,清理掉这一块内存空间。缺点是如果对象有很多存活,那复制开销很大。所以多用于新生代收集。但是浪费比较多。
    “Appel式回收”:将新生代分为一块较大的Eden空间和两块较小的Survivor空间(8:1:1),每次只使用Eden和一块Survivor空间,用完后复制到另一块Survivor空间上。再将这两块空间清除。当Survivor不够存放存活对象,就要依赖老年代进行分配担保。
  3. 标记-整理算法:
    针对老年代对象的死· 亡特征。与复制算法相似,区别是先让所有存活的对象都向内存一端移动,再清理掉后面的内存空间。
  • 从垃圾收集的停顿时间来看,不移动对象停顿时间更短甚至没有。但内存分配时更复杂。内存分配时更复杂。
    从整个程序的吞吐量来看,移动对象更划算。但内存回收时更复杂。
  • CMS使用的是标记 -清除算法,直到内存碎片过多,进行一次标记-整理算法。

Hotspot算法具体实现

  • 查找引用链已经可以并行,但是根节点枚举依旧需要停顿。
  • 不需要遍历所有根节点的位置,VM就可以得到哪些地方存放着对象引用。HotSpot使用一组OopMap的数据结构来达到这个目的。在类加载动作完成后,Hotspot会把对象内什么偏移量上是什么类型的数据计算出来,即时编译中,也会记录栈和寄存器内哪些位置是引用。当GC扫描时能够直接获得这些信息。
  • 只有当程序进行方法调用、循环跳转、异常跳转等长时间执行的地方(安全点)才能进行垃圾收集,和记录OopMap。
  • 让GC发生时,所有线程(不包括JNI调用的)跑到最近的安全点停顿下来,可采用抢先式中断(GC发生时,系统把线程全部中断,不在安全点的就继续跑)和主动式中断(设置标志位,轮询方式。标志位在安全点和所有创建对象和其他需要在java堆分配内存的地方。避免内存不足)。
  • 由于当程序不执行的时候,不能响应中断,无法走到安全点。所以引入安全区域(引用关系不会发生变化的区域)来解决。离开安全区域前会检查是否完成枚举,没有则等待。

记忆集与卡表

  • 记忆集是一种记录跨代指针(从非收集区域指向收集区域)的抽象数据结构
  • 有三种记录精度:
    1.字节精度:精确到一个机器字长(处理器寻址位数,如32/64位),该字包含跨代指针
    2.对象精度:该对象含有跨代指针
    3.卡精度:一块内存区域内有对象含有跨代指针
  • 卡精度是用“卡表”形式去实现的记忆集。卡表在HotSpot虚拟机中是一个字节数组。(记录“我指向谁”)每个元素对应着标识内存区域中的特定大小的一块内存块,叫卡页。只要卡页中有一个跨代指针,那数组元素对应值为1,称为“元素变脏”。(HotSpot中使用的卡页是2的9次幂,即512字节)
  • 所以发生GC时,可以从卡表找到含义跨代指针的卡页,加入GC Root一起扫描。

写屏障

如何在有对象产生跨代指针(对象赋值那一刻)导致元素变脏时维护卡表?HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。
写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在赋值时候产生环形通知,供程序执行额外动作。赋值前的部分的写屏障叫“写前屏障”,之后叫“写后屏障”。在G1出现前,GC都使用的是写后屏障。
写屏障通常用于在运行的时候记录相关指针。在只回收堆中部分区域时,所有从其他区域指向该回收区域的指针都将被写屏障捕获,这些指针会在垃圾回收的时候作为标记开始的根。
缺点:

  1. 在写屏障中增加更新列表的操作,会产生一定开销。
  2. 伪共享问题:不同线程更新对象处于一个缓存行,就会彼此影响而导致性能降低(例如一个卡表的元素共享一个缓存行,在对这些元素里的不同对象赋值写屏障时候,就会无效化或者同步)
    JDK7后可以开启卡表更新的条件判断(检查卡表标记)来避免,但会增大开销。

并发的可达性分析

GC Roots枚举过程在OopMap等一系列优化下,已经可以做到停顿时间短且基本固定,但从根节点向下遍历对象图,这一步的停顿时间与堆容量成正比。
为什么必须在一个能保证一致性的快照上才能进行对象图遍历?
采用三色标记(黑:安全存活;白:未访问;灰:访问过,但该对象至少存在一个引用没有被扫描过)如果用户线程和收集器并发工作,在扫描过程中用户线程也在改变引用关系,有的甚至会导致原本不该被回收的对象被回收。这显然不可以。

“对象消失”必须同时满足两个条件:

  1. 赋值器插入了至少1条从黒对象到白对象的新引用
  2. 赋值器删除了全部从灰色对象到该白对象的引用

解决方法:破坏其中一个条件

  1. 增量更新: 当插入新引用时,记录下来。并发扫描结束后,再将记录的引用关系中的黑对象为根,重新扫描一次。
  2. 原始快照: 灰色对象要删除指向白色对象的引用关系时,把这删除的引用记录下来。并发扫描结束后,再将记录的引用关系中的灰色对象为根,重新扫描一次。
    (1,2中的记录的插入和删除,都是通过写屏障实现的)

CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

经典垃圾收集器

在这里插入图片描述

并行(Parallel) :多条垃圾收集器线程在同一时间段协同工作,用户线程在等待

并发(Concurrent) : 垃圾收集器线程和用户线程同一时间都在运行。垃圾收集器线程占用了一部分系统资源,所以程序的处理的吞吐量受到一定影响。

Serial收集器

特点:

  1. 单线程。进行GC时,必须暂停其他所有工作进程,STW(Stop the world)
  2. 标记-复制算法。是新生代收集器里额外内存资源里面最小的,目前还是HotSpot运行在客户端模式下默认新生代收集器

ParNew收集器

  1. 实质上是Serial的多线程版本,支持多线程并行收集。控制参数、收集算法、回收策略等与Serial完全一致
  2. 多运行在服务端模式下。由于CMS目前只能与Serial和ParNew GC配合工作,所以是JDK7之前首选的新生代收集器。JDK9开始,官方希望面向全堆的G1收集器能逐渐取代服务端模式下的ParNew加CMS的之前默认组合。
  3. 最后ParNew相当于被合并到CMS中作为处理新生代的部分,PerNew收集器基本退出历史舞台。

Parllel Scavenge收集器

多线程并行收集,新生代GC,基于标记-复制算法。看上去与ParNew很相似。不同之处在于:

  1. 它目标是达成一个可控制的吞吐量。高吞吐量保证高效利用处理器资源。也叫做“吞吐量优先收集器”
  2. Parllel Scavenge收集器有一个自动调优参数,开启后虚拟机可以自动调节参数,把内存管理进行调优。这种方式叫做垃圾收集的自适应调节策略。
    主要适用于在后台运算而不需要太多交互的工作

Serial Old收集器

特点:

  1. 是Serial收集器的老年代版本,单线程
  2. 标记-整理算法
    主要是供客户端模式下的HotSpot虚拟机使用
    在服务端模式下:JDK5及其之前,与Parallel Scavenge收集器搭配使用(Parallel Scavenge架构本身含有PS MarkSweep收集器,与Serial Old实现几乎一样);作为CMS内存分配失败后的后备方案,在并发收集发生Concurrent Mode Failure时使用

Parallel Old收集器

是Paralle Scavenge的老年代版本,多线程并发收集,基于标记-整理算法。在JDK6之前,Paralle Scavenge只能搭配Serial Old,不能发挥出最大的吞吐量最大化效果。
在注重吞吐量或者处理器资源比较稀缺的场合适用。

CMS收集器(Concurrent Mark Sweep)

· 老年代收集器,基于标记-清除算法。第一款真正并行收集的收集器。
· 目标:获取最短回收停顿时间,使用户有良好交互体验。
· 主要应用于B/S系统服务端。JDK9开始,沦落为不推荐使用,未来可能会被废弃,由G1取代。但由于CMS与Hotspot有千丝万缕关系,因此,规划GDK10时,提出了“统一垃圾收集器接口”,将内存回收的行为与实现职责分离,而收集器都重构成基于这套接口的实现。这样日后更换收集器风险可控。
· 运行过程:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除
    初始标记只是简单标记与GC Roots 直接关联的对象,速度很快,需要STW。并发标记是开始遍历对象图,这个过程是与用户线程并发运行的,时间较长。重新标记是为了修正并发标记中导致标记产生变动的那部分标记记录(基于增量更新方法),需要STW,时间比初始标记稍长。并发清除阶段,并发清除掉已死亡对象。
    · 缺点:
  5. 对处理器资源很敏感。并发阶段会与用户进程共享处理器资源,占用部分线程,降低吞吐量。CMS默认回收线程是(处理器核心数量+3)/4,所以当处理器核心数量4个或以上时,线程数降低,影响回收效率。但如果小于4个,又会对应用程序受到影响。
    采用了“增量式并发收集器 i-CMS”方式,即在并发标记和清理时,垃圾收集器线程和用户线程改为交替并发运行。但效果不好,JDK9后废弃。
  6. CMS无法处理“浮动垃圾”,导致出现“Concurrent Model Failure”失败导致完全STW的Full GC产生。
    浮动垃圾是指:在并行标记和清理时,由于用户进程还在推进,可能会在已经标记完之后产生新的垃圾对象,那么这些对象只能留到下次垃圾收集时回收。
    也正是由于在并行标记和清理时,由于用户进程还在推进,所以不能等到老年代已经满了再开始收集,垃圾收集必须要预留一些内存空间。JDK6默认启动GC阈值92%。
    但是阈值设的太高又容易出现内存分配时空间不足。出现“并发失败”(Concurrent Model Failure)。这时候CMS只能冻结用户线程的执行,临时启用Serial Old收集器来重新完成老年代的垃圾收集。
  7. 标记-清除算法自带的缺点——收集结束会产生大量空间碎片。当碎片过多导致找不到一个连续大的内存空间来分配当前对象时,又会触发Full GC。
    解决:1.(JDK9开始废弃)设置了一个参数,用于不得不开启Full GC时,开启内存碎片整合,移动存活对象。该过程无法并发,停顿时间变长。2. (JDK9也开始废弃)又设置了一个参数,要求CMS在执行若干次的不整理空间碎片的Full GC后,下一次进行碎片整理的Full GC(解决1中是每次进入Full GC就先整理一次)

G1收集器

· 开创了收集器面向局部收集的设计思路和基于Region的内存布局模式。
· 目标是希望能建立起“停顿模型”,支持指定一个长度为M毫秒的片段内,停顿时间不超过n毫秒。并且希望在延迟可控的情况下尽可能提高吞吐量。
· 主要面向服务端应用的垃圾收集器。希望成为CMS的“替代者”
· G1不分代,只是保留了代的概念,由一系列不必连续的区域组成。堆内分为大小相同的独立区域(Region),每个Region根据需要扮演新生代的Eden、Survivor空间,或者老年代空间。Region中还有一类Humongous区域(G1多数行为把它当作老年代的一部分),专门存储大对象(大小超过Region一半的对象),如果大对象过大,也可以存放在连续的N个Humongous区域中。
· G1可在堆内任意部分组成回收集(CSet)。哪块内存存放垃圾多,优先回收,即Mixed GC模式。每次收集Region大小的整数倍,避免Full GC从而实现停顿时间可控。
· 实现的困难:

  1. 跨Region引用对象:我们知道使用记忆集可以避免全堆作为GC Roots扫描,但是G1上更为复杂。因为有许多Region,每个Region都维护一个“双向卡表”(本质为哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号),(?Key中记录谁指向我,Value记录我指向谁)里面除了记录了原普通卡表的“我指向谁”,还有“谁指向我”。内存负担很大,需要耗费Java堆容量的10%~20%的额外内存来维护工作。
  2. 并发标记阶段需要保证不能破坏原本对象图结构。G1采用的是“原始快照(SATB)算法“保证的。G1通过写前屏障,在引用更新之前,把要删除的引用存到一个log buffer中,在最终标记时,扫描SATB,修正SATB的误差。

G1收集器写屏障时采用双重过滤来减轻回收器负担:

  1. 过滤掉同一个Region内部引用;
  2. 过滤掉空引用

G1的写屏障采用一种两级的log buffer结构:

  1. global set of filled buffer:全局的,全部线程共享。存放了满的log buffer
  2. thread log buffer:每个线程把写屏障的记录都存到自己的log buffer中。存满了就放到global set of filled buffer里,然后重新申请一个 log buffer
    Marking bitmap是一种数据结构,其中每一个bit是代表一个可以分配给对象的起始地址。
    在这里插入图片描述
    绿色的块代表该地址的对象是存活的,白色代表是垃圾对象。

在G1并发标记中用到两个Bitmap,一个PrevBitmap记录第n-1轮的并发标记完成之后构造的bitmap;nextBitmap记录第n轮的并发标记中正在构造的bitmap。因为此时正在用户进程仍在运行,还不能使用。在当前的并发标记结束后,当前标记的nextBitmap就变成了PrevBitmap

并发标记阶段,bitmap和TAMS的作用如图
在这里插入图片描述

[Bottom, PTAMS) 这部分对象的存活信息可以通过PrevBitmap来得知
[prevTAMS, nextTAMS) 这部分是第n-1次并发标记时默认存活的
[nextTAMS, top) 这部分是第n次并发标记时用户进程新产生的对象分配的内存,默认存活

在初始标记的最后,G1会分配一个空的next Bitmap将top的地址值赋给NTAMS。PrevBitmap记录上次标记的数据,PTAMS是上次的标记位置。从PTAMS向上分配给新对象内存。并发标记线程将找到PTAMS, NTAMS之间的所有存活对象,将标记数据存储到next Bitmap中。

此外,由于并行会继续不断产生新对象,需要内存分配,G1设置了两TAMS(Top at Mark Start)指针,在Region中划分出一块空间来用于新对象的分配。新地址在这两个指针之上,G1默认这个地址之上的对象是被隐式标记过的,是默认存活的。

  1. 怎么建立起可靠的停顿预测模型?
    基于衰减平均值(更容易受到新数据的影响)理论实现,根据信息预测现在回收,哪些region组成的回收集的收益更大。

运行过程:

  1. 初始标记。仅标记GC Roots能直接关联到的对象,并修改TAMS指针的值,让下一阶段用户线程并发运行时候,能够正确地在可用Region中分配对象。需要短暂停顿线程。而且可以借助进行Minor GC时候完成。
  2. 并发标记。从根节点对对象图开始进行可达性分析,标记出不可达节点。耗时较长,与用户线程并发执行。
  3. 最终标记。处理SAATB记录中发生改变的对象
  4. 筛选回收。选择收益大的组成回收集,把回收集中存活对复制到新的Region上,再清理掉整个空间。必须暂停用户线程,多收集器线程并行完成。

CMS和G1的对比:

  1. G1可以设置最大停顿时间
  2. G1从整体来看,采用标记-整理算法,局部来看则是标记-复制算法,不会像CMS一样,由于采用标记-清除算法而产生大量空间碎片
  3. CMS只要一个记忆集,而且是单向的。G1为双向卡表,而且每个区域都要有一个记忆集,这导致需要占用堆容量的20%甚至更多的内存空间。
  4. CMS写后屏障维护卡表,G1不仅需要写后屏障维护卡表,为了实现SATB,还需要写前屏障来跟踪并发时候指针的情况。虽然原始快照搜索可以避免像CMS的增量更新算法一样,在初始标记和重新标记中长时间停顿,但是实现算法更繁琐,消耗更多运算资源。
  5. CMS主要用于小内存应用,G1更适合大内存应用。这个优劣势分界点多在6~8GB。

低延迟垃圾收集器