JVM的GC读书笔记
- STW:Stop The World,GC时暂停用户的线程。
- STAB:Snapshot At The Beginning,原始快照(保留开始时的对象图)。用于解决并发扫描时对象消失的问题。
- TAMS:Top at Mark Start,G1为每个Region设计的两个名为TAMS的指针,并发回收时新分配的对象地址都必须要在这两个指针位置以上。
1. 内存分配与回收策略
Java堆是垃圾回收器管理的主要区域,因此也被称为GC堆。现代收集器基本采用分代垃圾回收算法。所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间、老生代等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
总结:Eden区:标记-复制; Survivor区:标记-复制; 老生代:标记整理。
大部分情况下,对象首先在Eden区分配,在一次新生代GC后,如果对象还活着,则会进入S0或者S1,并且对象的年龄还会加1(Eden->Survivor区后对象的初始年龄变为1),当它的年龄增加到一定的程度,就会晋升到老年代中,年龄阈值可以通过参数-XX:MaxTenuringThreshold
设置。
动态年龄计算:“Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。
对象首先在Eden区诞生,如果Eden区满了,执行Minor GC,将Eden区和Survivor From区存活对象复制到Survivor To区,清除Eden区和Survivor From区。
Eden区相当于标记-复制算法(标记后复制到Survivor To区,清除Eden区)。Survivor From/To区是标记-复制算法,Survivor区一分为二,From区对象经历一次标记后,复制到To区,清除From区。
1.1 HotSpot虚拟机GC
针对HotSpot VM的实现,它里面的GC准确的分类只有两大种:
-
部分GC(Partial GC):
- 新生代收集(Minor GC / Young GC) :只对新生代进行垃圾收集
- 老年代GC(Major GC / Old GC) :只对老年代进行垃圾收集。(Major GC在有的语境下= Full GC,注意问清楚提问者意图)
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集
-
整堆收集(Full GC):收集整个Java堆和方法区
2. 对象已死?
java堆是垃圾回收的主要区域,因此也成为GC堆。在进行回收前,需要判断对象是否死亡。算法:引用计数法、可达性分析算法。
2.1 引用计数法
给对象添加一个引用计数器,每当有一个对象引用它,计数器加一;当引用失效时,计数器减一;任何时刻计数器为零的对象就是不可能在被使用的。
这个方法原理简单,效率也高。但是,在主流Java虚拟机没有使用引用计数法来管理内存,主要原因是很难解决对象的相互循环引用的问题。(相互循环引用:对象A与对象B相互引用,除此之外再无其他引用,实际上这两个对象已经不能再被访问,但是他们相互引用着对方,导致他们的引用计数不为零,引用计数算法无法回收他们)。
1 | public class ReferenceCountingGc { |
2.2 可达性分析算法
基本思路是通过一系列称为"GC Roots"的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索通过的路径称为"引用链"(Reference Chain),如果某个对象到GC Roots没有任何引用链相连,则该对象是不可用的。
Java中,可以作为GC Roots的对象是:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 本地方法栈(Native方法)中引用的对象
- 在方法区中的类静态属性引用的对象
- 在方法区中常量引用的对象
- 所有被同步锁持有的对象
2.3 再谈引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。希望描述一些对象:当内存空间足够时,能够保留在内存之中,如果内存空间在GC后仍然紧张,就可以抛弃这些对象。
在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强 度依次逐渐减弱。
- 强引用:指程序代码之中最普遍存在的引用赋值,类似"Object obj = new Object()",(栈上的对象指向堆中的对象)这种引用关系,只要强引用关系还在,GC回收器永远不会回收被强引用的对象。(宁愿抛出OOM错误)
- 软引用:指一些有用,但非必须的对象。在系统将要发生内存溢出前,会对这些对象进行二次回收,如果这次回收没有足够的内存,才会抛出OOM(Out Of Memory Error)异常。JDK1.2之后提供SoftReference类实现软引用
- 弱引用:指一些有用,但非必须的对象,但它的强度比软引用更弱。当垃圾收集器开始工作时,无论当前内存是否足够,都会回收被弱引用关联的对象。JDK1.2之后提供了WeakReference类实现弱引用
- 虚引用:是一种最弱的引用关系,形同虚设,无法通过虚引用获取一个对象实例,任何时候都能被回收。JDK1.2之后提供了PhantomReference类实现虚引用。
为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
特别注意:在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
2.4 不可达对象并非"非死不可"
即使在可达性分析算法中判定为不可达的对象,也不是"非死不可"的,这时候他处于"缓刑"阶段。
宣判死亡至少要经历两两次标记过程:如果对象在进行可达性分析后发现不可达,那么进行第一次标记,随后筛选一次,如果该对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况视为"没有必要执行"。(finalize()方法只会执行一次,第二次不会执行(即使覆盖) )。
如果这个对象有必要执行finalize()方法,那么该对象会被放置在一个名为F-Queue的队列中,在之后虚拟经济自动建立一个低优先级的Finalizer线程区执行finalize()方法。
不推荐使用finalize()方法。
2.5 方法区(元空间)的回收
Java虚拟机规范不强制要求是否在虚拟机的方法区(元空间)实现垃圾回收,方法区的垃圾回收"性价比比较低"。
方法区的垃圾回收主要有两部分内容:废弃的常量和不再使用的类型。
判断一个常量是否废弃:
常量池中的方法、字段符号等等如果没有对象引用它,如果这时发生回收且有必要的话,该对象就会被清理出常量池。
判断一个类是否废弃:需要满足下列3个条件(仅说明被允许,不一定必然):
- 该类的所有实例都已经被回收(堆中不再有该类与该类的子类的实例)
- 加载该类的类加载器已经被回收(除非是设计的可替换的类加载器,否则很难达成)
- 改了对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
3. 垃圾回收算法
标记清除、标记复制、标记整理、分代垃圾回收。
3.1 标记-清除算法
分为"标记"和"清除"两个阶段:标记出所有不需要回收的对象,标记完成后,统一回收所有未被标记的对象。最基础的算法。其他算法都是对其缺点改进而来。
缺点:效率不稳定、内存空间的碎片化。
3.2 标记-复制算法
半区复制:将可用内存一分为二相等的两块,每次使用其中的一块。当这一块使用完后,将还存活的对象复制到另一块去,然后把使用的空间清理。这样就使每次的内存回收都是对内存区间的一半进行回收。
优点:不会出现碎片化问题,效率高。
缺点:内存只能使用一半
3.3 标记-整理算法
针对老年代存亡的特征,提出了"标记-整理"算法。标记过程同"标记清除"算法,但后续的步骤不是直接对回收对象进行清理,而是让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。
如果移动所有的存活对象,将会是一种极为负重的操作,而且会暂停用户应用程序才能进行,这样的停顿被称为"Stop The World"。 如果不移动,采用标记清除,空间会碎片化。 关注吞吐量的虚拟机采用标记-整理,关注延迟的采用标记-清除。
3.4 分代收集算法
根据弱分代假说
与强分代假说
,现代垃圾收集器将Java堆至少划分为新生代
与老年代
两个区域。
根据跨代引用假说
:存在互相引用关系的两个对象,是应该倾 向于同时生存或者同时消亡的。只需在新生代上建立一个全局的数据结构(该结构被称 为“记忆集”,Remembered Set)
比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
弱分代假说:绝大多数对象都是朝生夕灭的。
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
4. 经典垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
jdk8环境下,默认使用 Parallel Scavenge(新生代)+ Parallel Old(老年代)。
现在JVM64位默认使用Server类型
1 | java -XX:+PrintCommandLineFlags -version |
4.1 Serial(串行)收集器
Serial (串行)收集器是最基础的收集器,如其名一样是一个单线程工作的收集器,不仅意味着只会使用一个处理器或一条线程去完成垃圾收集,最重要的是强调它进行垃圾回收时需要暂停其他所有工作线程(“Stop the World”),直到收集结束。Stop The World对很多应用而言是不可接受的。
新生代采用标记-复制
,老生代采用(Serial Old)标记-整理
,该收集器应用在新生代
优缺点:简单而高效,额外内存消耗最小,没有线程的交互开销。具有很高的单线程收集效率,Seriall收集器对于运行在Client模式下的虚拟机来说是个不错的选择。
4.2 ParNew(并行)收集器
ParNew收集器实质上是Serial收集器的多线程版,除了同时使用多条线程进行垃圾收集之外,其余的行为(控制参数、收集算法、对象分配规则、回收策略)与Serial收集器完全一致。
新生代采用标记-复制
,老生代采用(Serial Old)标记-整理
,该收集器应用在新生代
在JDK1.7之前ParNew
是许多运行在Server模式下的HotSpot虚拟机的新生代首要选择。除了Serial
收集器外(JDK9之前),目前只有ParNew
能与CMS收集器配合工作。
(G1这个面向全堆的垃圾收集器诞生,自JDK9开始,ParNew
加CMS
不再是官方推荐的服务端模式下的收集器解决方案。官推荐G1,同时:取消了ParNew加 Serial Old以及Serial加CMS这两组收集器组合的支持,意味着ParNew和CMS从此只能互相搭配使用)
并行和并发概念补充:
-
并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
-
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上,由于收集器占用一部分资源,此时程序的吞吐量受一定影响。
4.3 Parallel Scavenge(并行JDK8)收集器
JDK8默认新生代收集器,“吞吐量优先收集器”。-XX:+UseParallelGC -XX:-UseParallelOldGC
同时启用两个收集器搭配。JDK9时官宣G1替代Parallel Scavenge加Parallel Old组合。
Parallel Scavenge
与ParNew
非常相似,并行收集的多线程收集器,CMS
等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间(Stop The World),而Parallel Scavenge
收集器的目标则是达到一个可控制的吞吐量(Throughput),高效率的利用CPU。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。
如果对收集器运作不了解,手工优化困难,可以使用Paralle Scavenge收集器配合自适应调节策略,只需要设置好基本的内存数据(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis
(更关注最大停顿时间),或者-XX:GCTimeRatio
(更关注吞吐量)给虚拟机设置一个优化目标,具体细节由虚拟机去调节。
1 | -XX:+UseParallelGC |
新生代采用标记-复制
,老生代采用(Serial Old)标记-整理
,该收集器应用在新生代
官方建议策略
- 尽量不设置最大堆,选择合适的目标吞吐量
- 如果可以达到吞吐量目标,但是暂停时间太长,请选择一个暂停时间目标进行折衷(以降低吞吐量为代价)
- 如果未达到吞吐量目标,请设置尽可能大的堆(小于物理可用内存)
4.4 Serial Old(串行)收集器
Serial 收集器的老年代版本,他是一个单线程的收集器,使用标记-整理算法,主要意义供客户端的HotSpot虚拟机使用。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge
收集器搭配使用,另一种用途是作为 CMS
收集器的后备方案。
3.5.5 Parallel Old(并行)收集器
-XX:+UseParallelOldGC
开启该收集器,老年代并行收集器
Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记整理。JDK6提供,解决了Parallel Scavenge只能与Serial Old使用的尴尬地位,无法与CMS配合使用。至此"吞吐量优先"收集器有了比较名副其实的搭配组合。注重吞吐量或者处理器比较稀缺的场合可以考虑。
3.5.6 CMS(并发)收集器
-XX:+UseConcMarkSweepGC
开启CMS,老年代并发收集器,
CMS(Concurrent Mark Sweep)收集器以最短停顿时间为目标,互联网网站或者B/S系统的服务端非常适合使用(如果老年代不频繁GC或者内存<6g推荐,JDK9时被官方不推荐,推荐G1)。从名称可以看出CMS收集器基于标记清除算法实现.
整体分为四个步骤:
- 初始标记:暂停所有其他线程(STW),记录下与GCRoots相连的对象,速度很快;
- 并发标记:从GCRoots关联的对象开始遍历整个对象标记,耗时长但不需要STW,因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。(采用增量更新算法解决并发扫描时对象消失的问题)
- 重新标记:为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。通常停顿时间比初始标记时间长,远比并发标记时间短。
- 并发清除:清理删除标记阶段判断已经死亡的对象,不需要移动存活对象,可以与用户线程并发。
可以看到,耗时最长的并发标记和并发清除,垃圾收集器和用户线程可以一起并发执行。
优点:并发收集、低停顿
缺点:
- 对处理器资源敏感:并发阶段虽不会STW,但是会占用一部分线程。
- 无法收集浮动垃圾:
(浮动垃圾:并发标记/清理阶段,用户线程运行,垃圾对象产生,CMS这次GC无法处理,只能下次处理)。
由于无法收集浮动垃圾可能出现:“Concurrent Mode Failure"失败而导致STW的Full GC产生。
JDk1.6时,CMS收集器的启动阈值老年代使用空间
默认提升至92%,要是CMS运行期间,预留的内存无法满足分配对象,会出现一次"并发失败”,导致冻结用户线程(STW),临时启用Serial Old收集器。请根据实际需要设置参数-XX: CMSInitiatingOccupancyFraction
参数(启用阈值) - 标记清除,产生空间碎片:
-XX: +UseCMS-CompactAtFullCollection
默认开启,JDK9废弃,CMS收集器不得不FullGC时开启合并整理(STW)
-XX: CMSFullGCsBeforeCompaction
JDK9废弃,表示CMS执行n次FullGC后,整理,默认为0:每次FullGC会碎片整理。
3.5.7 G1收集器
G1(Garbage First)收集器主要是面向服务端的收集器,开创局部收集思路和基于Region的内存布局形式,主要针对配备多颗处理器及大容量内存($\geq$6g)的机器。 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。G1的出现(JDK7正式出现)导致CMS
被官方声明为不推荐的收集器,同时用来取代Parallel Scavenge
加Parallel Old
组合。G1是一个面向整堆(新生代+老年代)的收集器。
G1虽然仍是遵循分代收集理论设计的,但是:G1把连续的Java堆划分为多个相等的独立区域(Region),每个Region都可以根据需要扮演Eden空间、Survivor空间或者老年代,G1能够对扮演不同角色的Region采用不同的策略去处理。
Region种有一类特殊的Humongous
区域,专门存储大对象(大小超过一个Region容量的一半),每个Region可以通过参数-XX:G1HeapRegionSize
设置,取值1MB~32MB,且为,对于超过整个Region的大对象,会被存放在N个连续的Humongous Region
中,G1大多数把Humongous Region
作为老年代的一部分看待。
1. 布局模型:
2. G1收集器的特点:
- 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
- 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
- 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
- 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。
3. G1收集器的运作过程:
- 初始标记(Initial Marking):仅标记GC Roots能直接关联的对象,需要修改TAMS指针,需要STW,但耗时短,且是借用Minor GC的时候同步完成,所以没有额外停顿。
- 并发标记(Concurrent Marking):从GC Roots开始进行可达性分析,递归扫描整个堆,找到要回收的对象,耗时长,但可与用户线程并发执行,重新处理SATB记录下并发时有引用变动的对象。(采用SATB解决并发扫描时对象消失的问题)
- 最终标记(Final Marking):对用户线程做一个短暂的暂停,用于处理并发标记阶段结束后仍遗留的STAB记录。
- 筛选回收(Live Data Counting and Evacuation):对各个Region的回收价值和成本排序,制定回收计划,自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间,对象移动,需要STW。多条收集器线程并行执行。
G1除了并发标记阶段,其余阶段都需要STW,符合官方设定的目标:在延迟可控的情况下获得尽可能高的吞吐 量。
4. G1常用参数
G1的参数 | 作用 |
---|---|
-XX:+UseG1GC | 使用 G1 垃圾收集器 |
-XX:MaxGCPauseMillis=200 | 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到) |
-XX:InitiatingHeapOccupancyPercent=45 | 启动并发GC周期时的堆内存占用百分比. G1之类的垃圾收集器用它来触发并发GC周期,基于整个堆的使用率,而不只是某一代内存的使用比值. 为 0 则表示”一直执行GC循环”. 默认占用率是整个 Java 堆的 45% |
-XX:NewRatio=n | 新生代与老生代(new/old generation)的大小比例(Ratio). 默认值为 2. |
-XX:SurvivorRatio=n | eden/survivor 空间大小的比例(Ratio). 默认值为 8. |
-XX:MaxTenuringThreshold=n | 提升年老代的最大临界值(tenuring threshold). 默认值为 15. |
-XX:ParallelGCThreads=n | 设置垃圾收集器在并行阶段使用的线程数,默认值随JVM运行的平台不同而不同.最多为8 |
-XX:ConcGCThreads=n | 并发垃圾收集器使用的线程数量. 默认值随JVM运行的平台不同而不同. |
-XX:G1HeapRegionSize=n | 使用G1时Java堆会被分为大小统一的的区(region)。此参数可以指定每个heap区的大小. 默认值将根据 heap size 算出最优解. 最小值为 1Mb, 最大值为 32Mb. |
5. G1收集器与CMS收集器的比较:
由于G1与CMS都关注停顿时间的控制,因此它门经常会被拿来比较。
CMS | G1 | |
---|---|---|
JDk | 1.6以上 | 1.7以上 |
回收区域 | 老年代 | 整堆 |
回收算法 | 标记清除 | 整体来看“标记-整理”;局部来看“标记-复制” |
内存布局 | 传统连续的新生代 和老年代 |
分成Region区,每个区域根据需要扮演新生代与老年代 |
指定最大停顿时间 | 否 | 是 |
按收益动态收集 | 否 | 是 |
浮动垃圾 | 是 | 否 |
内存碎片 | 是 | 否(最终标记STW,不会产生) |
卡表(处理跨代指针) | 卡表简单 | 复杂,每个Region都有,可能需要更多空间 |
Full GC | 内存回收达不到分配Full GC | 内存回收达不到分配Full GC |
6. G1解决的一些问题
- 跨Region引用对象解决:使用记忆集,每个Region维护一份,记忆集记录别的Region指向自己的指针,标记指针在哪些卡也页范围。本质上是哈希表,这种双向卡表:我指向谁,同时有谁指向我。
- 并发标记阶段收集线程与用户线程互不干扰:
- 保证用户线程改变对象引用关系时,不会打破原本的对象图结构,导致标记结果出错。CMS采用增量更新算法,而G1采用原始快照(SATB)。
- 回收过程中新对象创建,每个Region两个TAMS指针,把Region一部分空间划分用于并发回收的对象分配,必须分配到TAMS指针位置上,G1默认不回收他们。
- 停顿预测模型:以衰减均值(Decaying Average)为理论基础来实现。通过这些信息预测现在开始回收,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
5. 低延迟垃圾收集器
垃圾收集器的三项指标:内存占用、吞吐量、延迟。优秀的收集器通常最多能实现其中的两项。目前更关注:延迟。内存变大的发展,完整的GC会导致延迟更高。出现了低延迟垃圾收集器。
Compact:整理; Concuurent=Conc:并发; partial:
5.1 Shenandoah收集器
ToDo
5.2 ZGC
可参考 新一代垃圾回收器ZGC的探索与实践
在对吞吐量影响不大的情况,实现对任意堆内存大小垃圾收集停顿时间限制在10毫秒内。
ZGC可以降低延迟(在低延迟:TP999<200ms收益较大)。 但会带来吞吐量下降情况(ZGC单代垃圾回收,每次回收处理对象更多,更耗CPU资源;ZGC使用读屏障,需要额外消耗计算资源)。
JDK11(Linux)开始。JDK15(Windows)开始。
1. 特点:
基于Region(官方:page/ZPage)内存布局,染色指针和读屏障解决转移过程中对象的访问问题,同时实现了可并发的标记-整理算法,以低延迟位首要目标。
2. 内存布局
ZGC基于Region堆内存布局,但ZGC的Region具有动态性:动态创建与销毁、动态区域容量大小。
- 小型Region(Small Region):容量固定2MB,放置小于256KB对象。
- 中型Region(Small Region):固定32MB,放置256KBn$\leq$4MB对象。
- 大型Region(Large Region):容量不固定,但必须为。
3. ZGC的流程
大致如图四个阶段,每个阶段都可以并发,两个阶段间会存在短暂停顿小阶段(Pause),短暂停顿只与GC Roots相关,与堆内存无关。
ZGC采用并发整理算法,ZGC在标记、转移、和重定位阶段几乎是并发。
- 并发标记(Concurrent Mark):前后要经历Pause Mark Start与Pause Mark End短暂停顿。ZGC的标记是指针上而不是对象上,标记阶段会更新染色指针中的Marked 0、Marked 1标志。
- 并发预备重分配(Concurrent Prepare for Relocate):根据查询条件统计清理哪些Region,将这些Region组成重分配集(Relocation Set)。并非为了收益优先GC,而只是决定里面的存活对象会被重新复制到其他Region中。
- 并发重分配(Concurrent Relocate):核心阶段,把重分配集中的对象复制到新的Region上,为重分配集中的每个Region维护一个转发表。
ZGC的指针"自愈"(如果用户线程访问了重分配集的对象,这次访问会被预置的内存屏障所截获,并根据Region的转发表记录访问到新复制的对象,同时修正更新的引用值,指向新对象)。好处只有第一次会转发,慢一次。 - 并发重映射(Concurrent Remap):修正整个堆中指向重分配集中旧对象的所有引用,因为有"自愈",ZGC的该阶段并不迫切,主要目的是为了不变慢。ZGC把并发重映射阶段的工作合并到下次垃圾收集的并发标记阶段处理。
4. ZGC关键技术
ZGC通过着色指针和读屏障技术,解决转移过程中的对象问题,实现并发整理。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。JVM利用对象引用的地址判断对象被移动过,即着色指针。
4.1 着色指针:
将信息存储在指针中的技术
直接将少量的额外信息存储在指针上(Linux下64位指针高18位不能用来寻址,剩下的的46位指针取其高4位存储4个标志信息,可以直接从指针上看到引用对象的三色标记、是否进入重分配集(移动过)、是否只能通过finalize()方法才能访问)。
ZGC只支持64位系统,把64位虚拟地址空间划分多个子空间:
其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[12TB ~ 16TB) 预留未使用,[16TB ~ 20TB) 称为Remapped空间。
当对象创建时,首先在堆中申请一个虚拟地址,不会真的映射物理地址,ZGC同时会在M0、M1和Remapped空间分别申请一个虚拟地址,且三个虚拟地址对应一个物理地址。但同一时刻只有一个空间有效。因为为了用"空间换时间",降低GC停顿时间。
ZGC实际只使用64位地址空间的0~41位,42~45存储元数据,47~63位固定为0。ZGC将对象存活信息存储在42~45位,与传统德垃圾回收将对象存活信息放在对象头中不同。
4.2 读屏障
读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
读屏障示例:
1 | Object o = obj.FieldA // 从堆中读取引用,需要加入屏障 |
ZGC的读屏障代码作用:对象标记与转移过程中,用于确定对象的引用地址是否满足条件,做出相应动作。
4.3 ZGC并发地址实体切换
- 初始化:ZGC初始化之后,整个内存空间的地址视图被设置为Remapped。程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动,此时进入标记阶段。
- 并发标记阶段:第一次进入标记阶段时视图为M0,如果对象被GC标记线程或者应用线程访问过,那么就将对象的地址视图从Remapped调整为M0。所以,在标记阶段结束之后,对象的地址要么是M0视图,要么是Remapped。如果对象的地址是M0视图,那么说明对象是活跃的;如果对象的地址是Remapped视图,说明对象是不活跃的。
- 并发转移(重分配)阶段:标记结束后就进入转移阶段,此时地址视图再次被设置为Remapped。如果对象被GC转移线程或者应用线程访问过,那么就将对象的地址视图从M0调整为Remapped。
其实,在标记阶段存在两个地址视图M0和M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。也即,第二次进入并发标记阶段后,地址视图调整为M1,而非M0。
着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在ZGC中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。
5. 三大优势:
- 某个Region的存活对象被移走后,Region能够立即被释放和重用。(能够"自愈")。
- 大幅减少垃圾收集中的内存屏障使用量,只使用了读屏障,没使用写屏障(染色指针+不支持分代收集)。
- 可扩展的存储结构,Linux下64位指针还有18位没有使用。
6. 问题与解决方案:
虚拟机重新定义内存中的某几位指针,处理器只会将整个指针都视为内存地址。但x86-64不支持类似SPARC硬件的虚拟地址掩码。因此ZGC采用了虚拟内存映射技术。
Linux/x86-64平台的ZGC使用多重映射将多个不同的虚拟内存地址映射到同一个物理内存地址上,多对一意味着虚拟内存中看到的地址空间比实际的堆内存容量更大。
7. ZGC调优
请参考: 新一代垃圾回收器ZGC的探索与实践
以下参数来自上面链接的文章,
1 | -Xms10G -Xmx10G -- 堆的最大最小内存:10g(按服务器调整) |
6. 垃圾收集器的选择
- 如果是数据分析、科学计算,目标是尽快算出结果,则应该关注吞吐量。
- 如果是SLA应用(网络服务提供),停顿时间影响服务质量,延迟是关注点。
- 客户端应用或者嵌入式应用,应该关注垃圾收集器的占用内存。
在此基础上应该考虑JDK的发行商、JDK版本。
例如:面向用户提供服务或者软件解决方案
- 如果有充足的预算,没有调优经验:可以考虑商业的Zing VM。
- 使用较新的硬件与JDK,可以考虑ZGC。
- 如果是遗留系统,根据内存规模衡量:4GB~6GB堆内存,推荐CMS,对于更大的堆,可以考虑G1。
参考
- 《深入理解java虚拟机 JVM高级特性与最佳实践》第二部分第三章
- 新一代垃圾回收器ZGC的探索与实践