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 区(即没有晋升这一过程,直接到老年代了)。