【AICon】探索RAG 技术在实际应用中遇到的挑战及应对策略!AICon精华内容已上线73%>>> 了解详情
写点什么

一文看懂 JVM 内存布局及 GC 原理

  • 2019-09-06
  • 本文字数:12278 字

    阅读完需:约 40 分钟

一文看懂JVM内存布局及GC原理

“java 的内存布局以及 GC 原理”是 java 开发人员绕不开的话题,也是面试中常见的高频问题之一。


java 发展历史上出现过很多垃圾回收器,各有各的适应场景,很多网上的旧文章已经跟不上最新的变化。本文详细介绍了 java 的内存布局以及各种垃圾回收器的原理(包括最新的 ZGC),希望阅读完后,大家对这方面的知识不再陌生,有所收获,同时也欢迎大家留言讨论。

一、JVM 运行时内存布局

java 8虚拟机规范的原始表达:(jvm)Run-Time Data Areas, 暂时翻译为“jvm 运行时内存布局”。


从概念上大致分为 6 个(逻辑)区域,参考下图。注:Method Area 中还有一个常量池区,图中未明确标出。



这 6 块区域按是否被线程共享,可以分为两大类:



一类是每个线程所独享的:


1)PC Register:也称为程序计数器, 记录每个线程当前执行的指令信。eg:当前执行到哪一条指令,下一条该取哪条指令。


2)JVM Stack:也称为虚拟机栈,记录每个栈帧(Frame)中的局部变量、方法返回地址等。注:这里出现了一个新名词“栈帧”,它的结构如下:



线程中每次有方法调用时,会创建 Frame,方法调用结束时 Frame 销毁。


3)Native Method Stack:本地(原生)方法栈,顾名思义就是调用操作系统原生本地方法时,所需要的内存区域。


上述 3 类区域,生命周期与 Thread 相同,即:线程创建时,相应的区域分配内存,线程销毁时,释放相应内存。


另一类是所有线程共享的:


1)Heap:即鼎鼎大名的堆内存区,也是 GC 垃圾回收的主站场,用于存放类的实例对象及 Arrays 实例等。


2)Method Area:方法区,主要存放类结构、类成员定义,static 静态成员等。


3)Runtime Constant Pool:运行时常量池,比如:字符串,int -128~127 范围的值等,它是 Method Area 中的一部分。


Heap、Method Area 都是在虚拟机启动时创建,虚拟机退出时释放。


注:Method Area 区,虚拟机规范只是说必须要有,但是具体怎么实现(比如:是否需要垃圾回收? ),交给具体的 JVM 实现去决定,逻辑上讲,视为 Heap 区的一部分。所以,如果你看见类似下面的图,也不要觉得画错了。



上述 6 个区域,除了 PC Register 区不会抛出 StackOverflowError 或 OutOfMemoryError ,其它 5 个区域,当请求分配的内存不足时,均会抛出 OutOfMemoryError(即:OOM),其中 thread 独立的 JVM Stack 区及 Native Method Stack 区还会抛出 StackOverflowError。


最后,还有一类不受 JVM 虚拟机管控的内存区,这里也提一下,即:堆外内存。



可以通过 Unsafe 和 NIO 包下的 DirectByteBuffer 来操作堆外内存。如上图,虽然堆外内存不受 JVM 管控,但是堆内存中会持有对它的引用,以便进行 GC。


提一个问题:总体来看,JVM 把内存划分为“栈(stack)”与“堆(heap)”两大类,为何要这样设计?


个人理解,程序运行时,内存中的信息大致分为两类,一是跟程序执行逻辑相关的指令数据,这类数据通常不大,而且生命周期短;一是跟对象实例相关的数据,这类数据可能会很大,而且可以被多个线程长时间内反复共用,比如字符串常量、缓存对象这类。


将这两类特点不同的数据分开管理,体现了软件设计上“模块隔离”的思想。好比我们通常会把后端 service 与前端 website 解耦类似,也更便于内存管理。

二、GC 垃圾回收原理

2.1 如何判断对象是垃圾 ?

有两种经典的判断方法,借用网友的图(文中最后有给出链接):



引用计数法,思路很简单,但是如果出现循环引用,即:A 引用 B,B 又引用 A,这种情况下就不好办了,所以 JVM 中使用了另一种称为“可达性分析”的判断方法:



还是刚才的循环引用问题(也是某些公司面试官可能会问到的问题),如果 A 引用 B,B 又引用 A,这 2 个对象是否能被 GC 回收?


答案:关键不是在于 A、B 之间是否有引用,而是 A、B 是否可以一直向上追溯到 GC Roots。如果与 GC Roots 没有关联,则会被回收,否则将继续存活。



上图是一个用“可达性分析”标记垃圾对象的示例图,灰色的对象表示不可达对象,将等待回收。

2.2 哪些内存区域需要 GC ?


在第一部分 JVM 内存布局中,我们知道了 thread 独享的区域:PC Regiester、JVM Stack、Native Method Stack,其生命周期都与线程相同(即:与线程共生死),所以无需 GC。线程共享的 Heap 区、Method Area 则是 GC 关注的重点对象。

2.3 常用的 GC 算法

1)mark-sweep 标记清除法



如上图,黑色区域表示待清理的垃圾对象,标记出来后直接清空。该方法简单快速,但是缺点也很明显,会产生很多内存碎片。


2)mark-copy 标记复制法



思路也很简单,将内存对半分,总是保留一块空着(上图中的右侧),将左侧存活的对象(浅灰色区域)复制到右侧,然后左侧全部清空。避免了内存碎片问题,但是内存浪费很严重,相当于只能使用 50%的内存。


3)mark-compact 标记-整理(也称标记-压缩)法



避免了上述两种算法的缺点,将垃圾对象清理掉后,同时将剩下的存活对象进行整理挪动(类似于 windows 的磁盘碎片整理),保证它们占用的空间连续,这样就避免了内存碎片问题,但是整理过程也会降低 GC 的效率。


4)generation-collect 分代收集算法


上述三种算法,每种都有各自的优缺点,都不完美。在现代 JVM 中,往往是综合使用的,经过大量实际分析,发现内存中的对象,大致可以分为两类:有些生命周期很短,比如一些局部变量/临时对象,而另一些则会存活很久,典型的比如 websocket 长连接中的 connection 对象,如下图:



纵向 y 轴可以理解分配内存的字节数,横向 x 轴理解为随着时间流逝(伴随着 GC),可以发现大部分对象其实相当短命,很少有对象能在 GC 后活下来。因此诞生了分代的思想,以 Hotspot 为例(JDK 7):



将内存分成了三大块:年青代(Young Genaration),老年代(Old Generation),永久代(Permanent Generation),其中 Young Genaration 更是又细为分 eden,S0,S1 三个区。


结合我们经常使用的一些 jvm 调优参数后,一些参数能影响的各区域内存大小值,示意图如下:



注:jdk8 开始,用 MetaSpace 区取代了 Perm 区(永久代),所以相应的 jvm 参数变成-XX:MetaspaceSize 及 -XX:MaxMetaspaceSize。


以 Hotspot 为例,我们来分析下 GC 的主要过程:


刚开始时,对象分配在 eden 区,s0(即:from)及 s1(即:to)区,几乎是空着。



随着应用的运行,越来越多的对象被分配到 eden 区。



当 eden 区放不下时,就会发生 minor GC(也被称为 young GC),第 1 步当然是要先标识出不可达垃圾对象(即:下图中的黄色块),然后将可达对象,移动到 s0 区(即:4 个淡蓝色的方块挪到 s0 区),然后将黄色的垃圾块清理掉,这一轮过后,eden 区就成空的了。


注:这里其实已经综合运用了“【标记-清理 eden】 + 【标记-复制 eden->s0】”算法。



随着时间推移,eden 如果又满了,再次触发 minor GC,同样还是先做标记,这时 eden 和 s0 区可能都有垃圾对象了(下图中的黄色块),注意:这时 s1(即:to)区是空的,s0 区和 eden 区的存活对象,将直接搬到 s1 区。然后将 eden 和 s0 区的垃圾清理掉,这一轮 minor GC 后,eden 和 s0 区就变成了空的了。



继续,随着对象的不断分配,eden 空可能又满了,这时会重复刚才的 minor GC 过程,不过要注意的是,这时候 s0 是空的,所以 s0 与 s1 的角色其实会互换,即:存活的对象,会从 eden 和 s1 区,向 s0 区移动。然后再把 eden 和 s1 区中的垃圾清除,这一轮完成后,eden 与 s1 区变成空的,如下图。



对于那些比较“长寿”的对象一直在 s0 与 s1 中挪来挪去,一来很占地方,而且也会造成一定开销,降低 gc 效率,于是有了“代龄(age)”及“晋升”。


对象在年青代的 3 个区(edge,s0,s1)之间,每次从 1 个区移到另 1 区,年龄+1,在 young 区达到一定的年龄阈值后,将晋升到老年代。下图中是 8,即:挪动 8 次后,如果还活着,下次 minor GC 时,将移动到 Tenured 区。



下图是晋升的主要过程:对象先分配在年青代,经过多次 Young GC 后,如果对象还活着,晋升到老年代。



如果老年代,最终也放满了,就会发生 major GC(即 Full GC),由于老年代的的对象通常会比较多,因为标记-清理-整理(压缩)的耗时通常会比较长,会让应用出现卡顿的现象,这也是为什么很多应用要优化,尽量避免或减少 Full GC 的原因。



注:上面的过程主要来自 oracle 官网的资料,但是有一个细节官网没有提到,如果分配的新对象比较大,eden 区放不下,但是 old 区可以放下时,会直接分配到 old 区(即没有晋升这一过程,直接到老年代了)。


下图引自阿里出品的《码出高效-Java 开发手册》一书,梳理了 GC 的主要过程。


三、垃圾回收器

不算最新出现的神器 ZGC,历史上出现过 7 种经典的垃圾回收器。



这些回收器都是基于分代的,把 G1 除外,按回收的分代划分,横线以上的 3 种:Serial ,ParNew, Parellel Scavenge 都是回收年青代的,横线以下的 3 种:CMS,Serial Old, Parallel Old 都是回收老年代的。

3.1 Serial 收集器

单线程用标记-复制算法,快刀斩乱麻,单线程的好处避免上下文切换,早期的机器,大多是单核,也比较实用。但执行期间,会发生 STW(Stop The World)。

3.2 ParNew 收集器

Serial 的多线程版本,同样会 STW,在多核机器上会更适用。

3.3 Parallel Scavenge 收集器

ParNew 的升级版本,主要区别在于提供了两个参数:-XX:MaxGCPauseMillis 最大垃圾回收停顿时间;-XX:GCTimeRatio 垃圾回收时间与总时间占比,通过这 2 个参数,可以适当控制回收的节奏,更关注于吞吐率,即总时间与垃圾回收时间的比例。

3.4 Serial Old 收集器

因为老年代的对象通常比较多,占用的空间通常也会更大,如果采用复制算法,得留 50%的空间用于复制,相当不划算,而且因为对象多,从 1 个区,复制到另 1 个区,耗时也会比较长,所以老年代的收集,通常会采用“标记-整理”法。从名字就可以看出来,这是单线程(串行)的, 依然会有 STW。

3.5 Parallel Old 收集器

一句话:Serial Old 的多线程版本。

3.6 CMS 收集器

全称:Concurrent Mark Sweep,从名字上看,就能猜出它是并发多线程的。这是 JDK 7 中广泛使用的收集器,有必要多说一下,借一张网友的图说话:



相对 3.4 Serial Old 收集器或 3.5 Parallel Old 收集器而言,这个明显要复杂多了,分为 4 个阶段:


1)Inital Mark 初始标记:主要是标记 GC Root 开始的下级(注:仅下一级)对象,这个过程会 STW,但是跟 GC Root 直接关联的下级对象不会很多,因此这个过程其实很快。


2)Concurrent Mark 并发标记:根据上一步的结果,继续向下标识所有关联的对象,直到这条链上的最尽头。这个过程是多线程的,虽然耗时理论上会比较长,但是其它工作线程并不会阻塞,没有 STW。


3)Remark 再标志:为啥还要再标记一次?因为第 2 步并没有阻塞其它工作线程,其它线程在标识过程中,很有可能会产生新的垃圾。


试想下,高铁上的垃圾清理员,从车厢一头开始吆喝“有需要扔垃圾的乘客,请把垃圾扔一下”,一边工作一边向前走,等走到车厢另一头时,刚才走过的位置上,可能又有乘客产生了新的空瓶垃圾。所以,要完全把这个车厢清理干净的话,她应该喊一下:所有乘客不要再扔垃圾了(STW),然后把新产生的垃圾收走。当然,因为刚才已经把收过一遍垃圾,所以这次收集新产生的垃圾,用不了多长时间(即:STW 时间不会很长)。


4)Concurrent Sweep:并行清理,这里使用多线程以“Mark Sweep-标记清理”算法,把垃圾清掉,其它工作线程仍然能继续支行,不会造成卡顿。


等等,刚才我们不是提到过“标记清理”法,会留下很多内存碎片吗?确实,但是也没办法,如果换成“Mark Compact 标记-整理”法,把垃圾清理后,剩下的对象也顺便排整理,会导致这些对象的内存地址发生变化,别忘了,此时其它线程还在工作,如果引用的对象地址变了,就天下大乱了。


另外,由于这一步是并行处理,并不阻塞其它线程,所以还有一个副使用,在清理的过程中,仍然可能会有新垃圾对象产生,只能等到下一轮 GC,才会被清理掉。


虽然仍不完美,但是从这 4 步的处理过程来看,以往收集器中最让人诟病的长时间 STW,通过上述设计,被分解成二次短暂的 STW,所以从总体效果上看,应用在 GC 期间卡顿的情况会大大改善,这也是 CMS 一度十分流行的重要原因。

3.7 G1 收集器

G1 的全称是 Garbage-First,为什么叫这个名字,呆会儿会详细说明。鉴于 CMS 的一些不足之外,比如: 老年代内存碎片化,STW 时间虽然已经改善了很多,但是仍然有提升空间。G1 就横空出世了,它对于 heap 区的内存划思路很新颖,有点算法中分治法“分而治之”的味道。


如下图,G1 将 heap 内存区,划分为一个个大小相等(1-32M,2 的 n 次方)、内存连续的 Region 区域,每个 region 都对应 Eden、Survivor 、Old、Humongous 四种角色之一,但是 region 与 region 之间不要求连续。


注:Humongous,简称 H 区是专用于存放超大对象的区域,通常>= 1/2 Region Size,且只有 Full GC 阶段,才会回收 H 区,避免了频繁扫描、复制/移动大对象。


所有的垃圾回收,都是基于 1 个个 region 的。JVM 内部知道,哪些 region 的对象最少(即:该区域最空),总是会优先收集这些 region(因为对象少,内存相对较空,肯定快),这也是 Garbage-First 得名的由来,G 即是 Garbage 的缩写, 1 即 First。



G1 Young GC


young GC 前:



young GC 后:



理论上讲,只要有一个 Empty Region(空区域),就可以进行垃圾回收。



由于 region 与 region 之间并不要求连续,而使用 G1 的场景通常是大内存,比如 64G 甚至更大,为了提高扫描根对象和标记的效率,G1 使用了二个新的辅助存储结构:


Remembered Sets:简称 RSets,用于根据每个 region 里的对象,是从哪指向过来的(即:谁引用了我),每个 Region 都有独立的 RSets。(Other Region -> Self Region)。


Collection Sets :简称 CSets,记录了等待回收的 Region 集合,GC 时这些 Region 中的对象会被回收(copied or moved)。



RSets 的引入,在 YGC 时,将年青代 Region 的 RSets 做为根对象,可以避免扫描老年代的 region,能大大减轻 GC 的负担。注:在老年代收集 Mixed GC 时,RSets 记录了 Old->Old 的引用,也可以避免扫描所有 Old 区。


Old Generation Collection(也称为 Mixed GC)


按 oracle 官网文档描述分为 5 个阶段:Initial Mark(STW) -> Root Region Scan -> Cocurrent Marking -> Remark(STW) -> Copying/Cleanup(STW && Concurrent)


注:也有很多文章会把 Root Region Scan 省略掉,合并到 Initial Mark 里,变成 4 个阶段。



存活对象的“初始标记”依赖于 Young GC,GC 日志中会记录成 young 字样。


2019-06-09T15:24:37.086+0800: 500993.392: [GC pause (G1 Evacuation Pause) (young), 0.0493588 secs]   [Parallel Time: 41.9 ms, GC Workers: 8]      [GC Worker Start (ms): Min: 500993393.7, Avg: 500993393.7, Max: 500993393.7, Diff: 0.1]      [Ext Root Scanning (ms): Min: 1.5, Avg: 2.2, Max: 4.4, Diff: 2.8, Sum: 17.2]      [Update RS (ms): Min: 15.8, Avg: 18.1, Max: 18.9, Diff: 3.1, Sum: 144.8]         [Processed Buffers: Min: 110, Avg: 144.9, Max: 163, Diff: 53, Sum: 1159]      [Scan RS (ms): Min: 4.7, Avg: 5.0, Max: 5.1, Diff: 0.4, Sum: 39.7]      [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]      [Object Copy (ms): Min: 16.4, Avg: 16.5, Max: 16.6, Diff: 0.2, Sum: 132.0]      [Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]         [Termination Attempts: Min: 1, Avg: 4.9, Max: 7, Diff: 6, Sum: 39]      [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.3]      [GC Worker Total (ms): Min: 41.7, Avg: 41.8, Max: 41.8, Diff: 0.1, Sum: 334.1]      [GC Worker End (ms): Min: 500993435.5, Avg: 500993435.5, Max: 500993435.5, Diff: 0.0]   [Code Root Fixup: 0.0 ms]   [Code Root Purge: 0.0 ms]   [Clear CT: 0.2 ms]   [Other: 7.2 ms]      [Choose CSet: 0.0 ms]      [Ref Proc: 4.3 ms]      [Ref Enq: 0.1 ms]      [Redirty Cards: 0.1 ms]      [Humongous Register: 0.1 ms]      [Humongous Reclaim: 0.1 ms]      [Free CSet: 0.6 ms]   [Eden: 1340.0M(1340.0M)->0.0B(548.0M) Survivors: 40.0M->64.0M Heap: 2868.2M(12.0G)->1499.8M(12.0G)] [Times: user=0.35 sys=0.00, real=0.05 secs]
复制代码



并发标记过程中,如果发现某些 region 全是空的,会被直接清除。



进入重新标记阶段。



并发复制/清查阶段。这个阶段,Young 区和 Old 区的对象有可能会被同时清理。GC 日志中,会记录为 mixed 字段,这也是 G1 的老年代收集,也称为 Mixed GC 的原因。


2019-06-09T15:24:23.959+0800: 500980.265: [GC pause (G1 Evacuation Pause) (mixed), 0.0885388 secs]   [Parallel Time: 74.2 ms, GC Workers: 8]      [GC Worker Start (ms): Min: 500980270.6, Avg: 500980270.6, Max: 500980270.6, Diff: 0.1]      [Ext Root Scanning (ms): Min: 1.7, Avg: 2.2, Max: 4.1, Diff: 2.4, Sum: 17.3]      [Update RS (ms): Min: 11.7, Avg: 13.7, Max: 14.3, Diff: 2.6, Sum: 109.8]         [Processed Buffers: Min: 136, Avg: 141.5, Max: 152, Diff: 16, Sum: 1132]      [Scan RS (ms): Min: 42.5, Avg: 42.9, Max: 43.1, Diff: 0.5, Sum: 343.1]      [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]      [Object Copy (ms): Min: 14.9, Avg: 15.2, Max: 15.4, Diff: 0.5, Sum: 121.7]      [Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]         [Termination Attempts: Min: 1, Avg: 8.2, Max: 11, Diff: 10, Sum: 66]      [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.2]      [GC Worker Total (ms): Min: 74.0, Avg: 74.0, Max: 74.1, Diff: 0.1, Sum: 592.3]      [GC Worker End (ms): Min: 500980344.6, Avg: 500980344.6, Max: 500980344.6, Diff: 0.0]   [Code Root Fixup: 0.0 ms]   [Code Root Purge: 0.0 ms]   [Clear CT: 0.5 ms]   [Other: 13.9 ms]      [Choose CSet: 4.1 ms]      [Ref Proc: 1.8 ms]      [Ref Enq: 0.1 ms]      [Redirty Cards: 0.2 ms]      [Humongous Register: 0.1 ms]      [Humongous Reclaim: 0.1 ms]      [Free CSet: 5.6 ms]   [Eden: 584.0M(584.0M)->0.0B(576.0M) Survivors: 28.0M->36.0M Heap: 4749.3M(12.0G)->2930.0M(12.0G)] [Times: user=0.61 sys=0.00, real=0.09 secs]
复制代码



上图是,老年代收集完后的示意图。


通过这几个阶段的分析,虽然看上去很多阶段仍然会发生 STW,但是 G1 提供了一个预测模型,通过统计方法,根据历史数据来预测本次收集,需要选择多少个 Region 来回收,尽量满足用户的预期停顿值(-XX:MaxGCPauseMillis 参数可指定预期停顿值)。


注:如果 Mixed GC 仍然效果不理想,跟不上新对象分配内存的需求,会使用 Serial Old GC(Full GC)强制收集整个 Heap。


小结:与 CMS 相比,G1 有内存整理过程(标记-压缩),避免了内存碎片;STW 时间可控(能预测 GC 停顿时间)。

3.8 ZGC (截止目前史上最好的 GC 收集器)

在 G1 的基础上,做了很多改进(JDK 11 开始引入)

3.8.1 动态调整大小的 Region

G1 中每个 Region 的大小是固定的,创建和销毁 Region,可以动态调整大小,内存使用更高效。


3.8.2 不分代,干掉了 RSets

G1 中每个 Region 需要借助额外的 RSets 来记录“谁引用了我”,占用了额外的内存空间,每次对象移动时,RSets 也需要更新,会产生开销。


注:ZGC 没有为止,没有实现分代机制,每次都是并发的对所有 region 进行回收,不象 G1 是增量回收,所以用不着 RSets。不分代的带来的可能性能下降,会用下面马上提到的 Colored Pointer && Load Barrier 来优化。

3.8.3 带颜色的指针 Colored Pointer


这里的指针类似 java 中的引用,意为对某块虚拟内存的引用。ZGC 采用了 64 位指针(注:目前只支持 linux 64 位系统),将 42-45 这 4 个 bit 位置赋予了不同的含义,即所谓的颜色标志位,也换为指针的 metadata。


finalizable 位:仅 finalizer(类比 c++中的析构函数)可访问;


remap 位:指向对象当前(最新)的内存地址,参考下面提到的 relocation;


marked0 && marked1 位:用于标志可达对象;


这 4 个标志位,同一时刻只会有 1 个位置是 1。每当指针对应的内存数据发生变化,比如内存被移动,颜色会发生变化。

3.8.4 读屏障 Load Barrier

传统 GC 做标记时,为了防止其它线程在标记期间修改对象,通常会简单的 STW。而 ZGC 有了 Colored Pointer 后,引入了所谓的读屏障,当指针引用的内存正被移动时,指针上的颜色就会变化,ZGC 会先把指针更新成最新状态,然后再返回。(大家可以回想下 java 中的volatile关键字,有异曲同工之妙),这样仅读取该指针时可能会略有开销,而不用将整个 heap STW。

3.8.5 重定位 relocation


如上图,在标记过程中,先从 Roots 对象找到了直接关联的下级对象 1,2,4。



然后继续向下层标记,找到了 5,8 对象, 此时已经可以判定 3,6,7 为垃圾对象。



如果按常规思路,一般会将 8 从最右侧的 Region 移动或复制到中间的 Region,然后再将中间 Region 的 3 干掉,最后再对中间 Region 做压缩 compact 整理。但 ZGC 做得更高明,它直接将 4,5 复制到了一个空的新 Region 就完事了,然后中间的 2 个 Region 直接废弃,或理解为“释放”,做为下次回收的“新”Region。这样的好处是避免了中间 Region 的 compact 整理过程。



最后,指针重新调整为正确的指向(即:remap),而且上一阶段的 remap 与下一阶段的 mark 是混在一起处理的,相对更高效。


Remap 的流程图如下:


3.8.6 多重映射 Multi-Mapping

这个优化,说实话没完全看懂,只能谈下自己的理解(如果有误,欢迎指正)。虚拟内存与实际物理内存,OS 会维护一个映射关系,才能正常使用。如下图:



zgc 的 64 位颜色指针,在解除映射关系时,代价较高(需要屏蔽额外的 42-45 的颜色标志位)。考虑到这 4 个标志位,同 1 时刻,只会有 1 位置成 1(如下图),另外 finalizable 标志位,永远不希望被解除映射绑定(可不用考虑映射问题)。


所以剩下 3 种颜色的虚拟内存,可以都映射到同 1 段物理内存。即映射复用,或者更通俗点讲,本来 3 种不同颜色的指针,哪怕 0-41 位完全相同,也需要映射到 3 段不同的物理内存,现在只需要映射到同 1 段物理内存即可。



3.8.7 支持NUMA架构

NUMA 是一种多核服务器的架构,简单来讲,一个多核服务器(比如 2core),每个 cpu 都有属于自己的存储器,会比访问另一个核的存储器会慢很多(类似于就近访问更快)。


相对之前的 GC 算法,ZGC 首次支持了 NUMA 架构,申请堆内存时,判断当前线程属是哪个 CPU 在执行,然后就近申请该 CPU 能使用的内存。


小结:革命性的 ZGC 经过上述一堆优化后,每次 GC 总体卡顿时间按官方说法<10ms。注:启用 zgc,需要设置-XX:+UnlockExperimentalVMOptions -XX:+UseZGC。

四、实战练习

前面介绍了一堆理论,最后来做一个小的练习,下面是一段模拟 OOM 的测试代码,我们在 G1、CMS 这二种常用垃圾回收器上试验一下。


import sun.misc.Unsafe; import java.lang.reflect.Field;import java.util.ArrayList;import java.util.List; public class OOMTest {      public static void main(String[] args) {        OOMTest test = new OOMTest();        //heap区OOM测试             //test.heapOOM();         //虚拟机栈和本地方法栈溢出        //test.stackOverflow();         //metaspace OOM测试        //test.metaspaceOOM();         //堆外内存 OOM测试        //test.directOOM();    }     /**     * heap OOM测试     */    public void heapOOM() {        List<OOMTest> list = new ArrayList<>();        while (true) {            list.add(new OOMTest());        }    }      private int stackLength = 1;     public void stackLeak() {        stackLength += 1;        stackLeak();    }     /**     * VM Stack / Native method Stack 溢出测试     */    public void stackOverflow() {        OOMTest test = new OOMTest();        try {            test.stackLeak();        } catch (Throwable e) {            System.out.println("stack length:" + test.stackLength);            throw e;        }    }     public void genString() {        List<String> list = new ArrayList<>();        int i = 0;        while (true) {            list.add("string-" + i);            i++;        }    }     /**     * metaspace/常量池 OOM测试     */    public void metaspaceOOM() {        OOMTest test = new OOMTest();        test.metaspaceOOM();    }     public void allocDirectMemory() {        final int _1MB = 1024 * 1024;         Field unsafeField = Unsafe.class.getDeclaredFields()[0];        unsafeField.setAccessible(true);        Unsafe unsafe = null;        try {            unsafe = (Unsafe) unsafeField.get(null);        } catch (IllegalArgumentException | IllegalAccessException e) {            e.printStackTrace();        }         while (true) {            unsafe.allocateMemory(_1MB);        }    }     /**     * 堆外内存OOM测试     */    public void directOOM() {        OOMTest test = new OOMTest();        test.allocDirectMemory();    }}
复制代码

4.1 openjdk 11.0.3 环境:+ G1 回收

4.1.1 验证 heap OOM

把 main 方法中的 test.heapOOM()行,注释打开,然后命令行下运行:


java -Xmx10M -XX:+UseG1GC -Xlog:gc* -Xlog:gc:gc.log -XX:+HeapDumpBeforeFullGC  OOMTest.
复制代码


最后会输出:


[1.892s][info][gc             ] GC(42) Concurrent Cycle 228.393msException in thread "main" java.lang.OutOfMemoryError: Java heap space        at java.base/java.util.Arrays.copyOf(Arrays.java:3689)        at java.base/java.util.ArrayList.grow(ArrayList.java:237)        at java.base/java.util.ArrayList.grow(ArrayList.java:242)        at java.base/java.util.ArrayList.add(ArrayList.java:485)        at java.base/java.util.ArrayList.add(ArrayList.java:498)        at oom.OOMTest.heapOOM(OOMTest.java:37)        at oom.OOMTest.main(OOMTest.java:16)[1.895s][info][gc,heap,exit   ] Heap
复制代码


其中 OutOfMemoryError:Java heap space 即表示 heap OOM。

4.1.2 验证 stack 溢出

把 main 方法中的 test.stackOverflow()行,注释打开,然后命令行下运行:


java -Xmx20M -Xss180k -XX:+UseG1GC -Xlog:gc* -Xlog:gc:gc.log  -XX:+HeapDumpBeforeFullGC OOMTest.jav
复制代码


最后会输出:


[0.821s][info][gc           ] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 12M->7M(20M) 5.245ms[0.821s][info][gc,cpu       ] GC(4) User=0.00s Sys=0.00s Real=0.00sstack length:1699Exception in thread "main" java.lang.StackOverflowError        at oom.OOMTest.stackLeak(OOMTest.java:45)        at oom.OOMTest.stackLeak(OOMTest.java:45)
复制代码


其中 StackOverflowError 即表示 stack 栈区内存不足,导致溢出。

4.1.3 验证 metaspace OOM

把 main 方法中的 test.metaspaceOOM()行,注释打开,然后命令行下运行:


java -Xmx20M -XX:MaxMetaspaceSize=10M -XX:+UseG1GC -Xlog:gc*  -Xlog:gc:gc.log -XX:+HeapDumpBefor
复制代码


最后会输出:


[0.582s][info][gc,metaspace,freelist,oom]Exception in thread "main" java.lang.OutOfMemoryError: Metaspace[0.584s][info][gc,heap,exit             ] Heap
复制代码


其中 OutOfMemoryError: Metaspace 即表示 Metaspace 区 OOM。

4.1.4 验证堆外内存 OOM

把 main 方法中的 test.directOOM()行,注释打开,然后命令行下运行:


最后会输出:


[0.842s][info][gc,cpu       ] GC(4) User=0.06s Sys=0.00s Real=0.01sException in thread "main" java.lang.OutOfMemoryError        at java.base/jdk.internal.misc.Unsafe.allocateMemory(Unsafe.java:616)...
复制代码


其中 OutOfMemoryError 行并没有输出具体哪个区(注:堆外内存不属于 JVM 内存中的任何一个区,所以无法输出),但紧接着有一行 jdk.internal.misc.Unsafe.allocateMemory 可以看出是“堆外内存直接分配”导致的异常。

4.2 openjdk 1.8.0_212 + CMS 回收

jdk1.8 下,java 命令无法直接运行.java 文件,必须先编译,即:


javac OOMTest.java -encoding utf-8
复制代码


(注:-encoding utf-8 是为了防止中文注释 javac 无法识别)成功后,会生成 OOMTest.class 文件, 然后再可以参考下面的命令进行测试。

4.2.1 heap OOM 测试:

java -Xmx10M -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:
复制代码

4.2.2 验证 stack 溢出

java -Xmx10M -Xss128k -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xlog
复制代码

4.2.3 验证 metaspace OOM

java -Xmx20M -XX:+UseConcMarkSweepGC -XX:MaxMetaspaceSize=10M -XX:+PrintGCDetails -XX:+PrintGCDateS
复制代码

4.2.4 验证堆外内存 OOM

java -Xmx20M -XX:+UseConcMarkSweepGC -XX:MaxDirectMemorySize=10M -XX:+PrintGCDetails -XX:+PrintGCDa
复制代码

4.3 GC 日志查看工具

生成的 gc 日志文件,可以用开源工具GCViewer查看,这是一个纯 java 写的 GUI 程序,使用很简单,File→Open File 选择 gc 日志文件即可。目前支持 CMS/G1 生成的日志文件,另外如果 GC 文件过大时,可能打不开。




GCViewer 可以很方便的统计出 GC 的类型,次数,停顿时间,年青代/老年代的大小等,还有图表显示,非常方便。


作者介绍


杨俊明,携程云客服平台研发部软件技术专家。从事 IT 行业 10 余年,腾讯云+社区、阿里云栖社区、华为云社区认证专家。近年来主要研究分布式架构、微服务、java 技术等方向。


本文转载自公众号携程技术中心(ID:ctriptech)


原文链接


https://mp.weixin.qq.com/s?__biz=MjM5MDI3MjA5MQ==&mid=2697268836&idx=1&sn=4c783b7c6541a9df0baf0b9060ff4c08&chksm=8376f150b401784656eef2414311b8a0290e1902fc6ab2665bc12c9d5c9c8ef194b968c7b7cb&scene=27#wechat_redirect


2019-09-06 08:0030693

评论 6 条评论

发布
用户头像
写的很全面,感谢答疑解惑
2023-04-11 13:33 · 上海
回复
用户头像
解惑,感谢
2021-02-26 08:54
回复
用户头像
为作者无私奉献点赞!
2021-01-12 17:24
回复
用户头像
写的 很全面的 👍
2020-07-14 15:23
回复
用户头像
点赞
2020-05-21 11:40
回复
用户头像
写的太好了!
2020-04-23 19:00
回复
没有更多了
发现更多内容

架构实战营模块二作业

子豪sirius

架构实战营

模块二-微信朋友圈高性能复杂度分析

kk

架构实战营

大数据训练营-第一次作业

西伯利亚鼯鼠

架构实战营 - 模块 2 - 微信朋友圈高性能复杂度分析

雪中亮

架构实战营 #架构实战营

架构训练营第 1 期 模块二作业

高远

设计消息队列存储消息数据的MySQL表格

俞嘉彬

架构实战营

多维数据分析(OLAP)技术选型(2):数据分析与OLAP差异

水滴

数据分析 OLAP 技术选型

微信朋友圈高性能复杂度分析

gawaine

架构实战营

架构训练营模块 2 作业 - 听闻

听闻

实时数据湖:Flink CDC流式写入Hudi

王知无

架构训练营模块二作业

老实人Honey

「架构师训练营第 1 期」

进阶指南!深入理解Java注解

Jackpop

Java

架构实战营第二课作业——微信朋友圈的高性能复杂度分析

tt

架构实战营

到底什么时候要分库分表?

卢卡多多

分库分表 7月日更

架构训练营模块二作业

BlingBling

架构实战营

架构实战营作业 M02

Shawn Liu

架构实战营

Spark SQL和DataSet(六)

数据与智能

sql spark RDD

2.4如何提高架构设计的质量

Lemon

架构训练营 1 期 - 模块二作业

蔸蔸

区块链的宿命,数字经济的局

CECBC

架构实战营 - 模块二(作业)

Cingk

架构实战营 - 模块二

Testcase

架构实战营

模块二作业

俊杰

MapReduce案例(一)-- 流量统计

钱江兵

架构实战营模块二作业

Morphling

#架构实战营

清晰了!一文彻底理解Java事件处理

Jackpop

Java

一篇并不起眼的数据仓库面试题

王知无

当我们在学习Hive的时候在学习什么?「硬刚Hive续集」

王知无

模块二作业

seawolflin

架构实战营

知乎热文 | 如何高效学习Spring Boot?

Jackpop

Java Spring Boot

Python OpenCV 图像的膨胀与腐蚀,图像处理取经之旅第 38 篇

梦想橡皮擦

7月日更

一文看懂JVM内存布局及GC原理_技术管理_杨俊明_InfoQ精选文章