learn-tech/专栏/深入浅出Java虚拟机-完/06深入剖析:垃圾回收你真的了解吗?(上).md
2024-10-16 09:22:22 +08:00

411 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
06 深入剖析:垃圾回收你真的了解吗?(上)
本课时我们重点剖析 JVM 的垃圾回收机制。关于 JVM 垃圾回收机制面试中主要涉及这三个考题:
JVM 中有哪些垃圾回收算法?它们各自有什么优劣?
CMS 垃圾回收器是怎么工作的?有哪些阶段?
服务卡顿的元凶到底是谁?
虽然 Java 不用“手动管理”内存回收,代码写起来很顺畅。但是你有没有想过,这些内存是怎么被回收的?
其实JVM 是有专门的线程在做这件事情。当我们的内存空间达到一定条件时,会自动触发。这个过程就叫作 GC负责 GC 的组件,就叫作垃圾回收器。
JVM 规范并没有规定垃圾回收器怎么实现,它只需要保证不要把正在使用的对象给回收掉就可以。在现在的服务器环境中,经常被使用的垃圾回收器有 CMS 和 G1但 JVM 还有其他几个常见的垃圾回收器。
按照语义上的意思,垃圾回收,首先就需要找到这些垃圾,然后回收掉。但是 GC 过程正好相反,它是先找到活跃的对象,然后把其他不活跃的对象判定为垃圾,然后删除。所以垃圾回收只与活跃的对象有关,和堆的大小无关。这个概念是我们一直在强调的,你一定要牢记。
本课时将首先介绍几种非常重要的回收算法,然后着重介绍分代垃圾回收的内存划分和 GC 过程,最后介绍当前 JVM 中的几种常见垃圾回收器。
这部分内容比较多,也比较细。为了知识的连贯性,这里我直接将它们放在一个课时。篇幅有点长,你一定要有耐心学完,也希望你可以对 JVM 的了解上一个档次。
为什么这部分这么重要呢?是因为几乎所有的垃圾回收器,都是在这些基本思想上演化出来的,如果你对此不熟悉,那么我们后面讲解 CMS、G1、ZGC 的时候,就会有诸多障碍。这将直接影响到我们对实践课的理解。
标记Mark
垃圾回收的第一步,就是找出活跃的对象。我们反复强调 GC 过程是逆向的。
我们在前面的课时谈到 GC Roots。根据 GC Roots 遍历所有的可达对象,这个过程,就叫作标记。
如图所示,圆圈代表的是对象。绿色的代表 GC Roots红色的代表可以追溯到的对象。可以看到标记之后仍然有多个灰色的圆圈它们都是被回收的对象。
清除Sweep
清除阶段就是把未被标记的对象回收掉。
但是这种简单的清除方式,有一个明显的弊端,那就是碎片问题。
比如我申请了 1k、2k、3k、4k、5k 的内存。
由于某种原因 2k 和 4k 的内存,我不再使用,就需要交给垃圾回收器回收。
这个时候,我应该有足足 6k 的空闲空间。接下来,我打算申请另外一个 5k 的空间,结果系统告诉我内存不足了。系统运行时间越长,这种碎片就越多。
在很久之前使用 Windows 系统时,有一个非常有用的功能,就是内存整理和磁盘整理,运行之后有可能会显著提高系统性能。这个出发点是一样的。
复制Copy
解决碎片问题没有银弹,只有老老实实的进行内存整理。
有一个比较好的思路可以完成这个整理过程,就是提供一个对等的内存空间,将存活的对象复制过去,然后清除原内存空间。
在程序设计中一般遇到扩缩容或者碎片整理问题时复制算法都是非常有效的。比如HashMap 的扩容也是使用同样的思路Redis 的 rehash 也是类似的。
整个过程如图所示:
这种方式看似非常完美的,解决了碎片问题。但是,它的弊端也非常明显。它浪费了几乎一半的内存空间来做这个事情,如果资源本来就很有限,这就是一种无法容忍的浪费。
整理Compact
其实,不用分配一个对等的额外空间,也是可以完成内存的整理工作。
你可以把内存想象成一个非常大的数组,根据随机的 index 删除了一些数据。那么对整个数组的清理,其实是不需要另外一个数组来进行支持的,使用程序就可以实现。
它的主要思路,就是移动所有存活的对象,且按照内存地址顺序依次排列,然后将末端内存地址以后的内存全部回收。
我们可以用一个理想的算法来看一下这个过程。
last = 0
for(i=0;i<mems.length;i++){
if(mems[i] != null){
mems[last++] = mems[i]
changeReference(mems[last])
}
}
clear(mems,last,mems.length)
但是需要注意这只是一个理想状态对象的引用关系一般都是非常复杂的我们这里不对具体的算法进行描述你只需要了解从效率上来说一般整理算法是要低于复制算法的
分代
我们简要介绍了一些常见的内存回收算法目前JVM 的垃圾回收器都是对几种朴素算法的发扬光大简单看一下它们的特点
复制算法Copy
复制算法是所有算法里面效率最高的缺点是会造成一定的空间浪费
标记-清除Mark-Sweep
效率一般缺点是会造成内存碎片问题
标记-整理Mark-Compact
效率比前两者要差但没有空间浪费也消除了内存碎片问题
所以没有最优的算法只有最合适的算法
JVM 是计算节点而不是存储节点最理想的情况就是对象在用完之后它的生命周期立马就结束了而那些被频繁访问的资源我们希望它能够常驻在内存里
研究表明大部分对象可以分为两类
大部分对象的生命周期都很短
其他对象则很可能会存活很长时间
大部分死的快其他的活的长这个假设我们称之为弱代假设weak generational hypothesis
接下来划重点
从图中可以看到大部分对象是朝生夕灭的其他的则活的很久
现在的垃圾回收器都会在物理上或者逻辑上把这两类对象进行区分我们把死的快的对象所占的区域叫作年轻代Young generation把其他活的长的对象所占的区域叫作老年代Old generation
老年代在有些地方也会叫作 Tenured Generation你在看到时明白它的意思就可以了
年轻代
年轻代使用的垃圾回收算法是复制算法因为年轻代发生 GC 只会有非常少的对象存活复制这部分对象是非常高效的
我们前面也了解到复制算法会造成一定的空间浪费所以年轻代中间也会分很多区域
如图所示年轻代分为一个伊甸园空间Eden 两个幸存者空间Survivor
当年轻代中的 Eden 区分配满的时候就会触发年轻代的 GCMinor GC具体过程如下
Eden 区执行了第一次 GC 之后存活的对象会被移动到其中一个 Survivor 分区以下简称from
Eden 区再次 GC这时会采用复制算法 Eden from 区一起清理存活的对象会被复制到 to 接下来只需要清空 from 区就可以了
所以在这个过程中总会有一个 Survivor 分区是空置的Edenfromto 的默认比例是 8:1:1所以只会造成 10% 的空间浪费
这个比例是由参数 -XX:SurvivorRatio 进行配置的默认为 8
一般情况下我们只需要了解到这一层面就 OK 但是在平常的面试中还有一个点会经常提到虽然频率不太高它就是 TLAB我们在这里也简单介绍一下
TLAB 的全称是 Thread Local Allocation BufferJVM 默认给每个线程开辟一个 buffer 区域用来加速对象分配这个 buffer 就放在 Eden 区中
这个道理和 Java 语言中的 ThreadLocal 类似避免了对公共区的操作以及一些锁竞争
对象的分配优先在 TLAB上 分配 TLAB 通常都很小所以对象相对比较大的时候会在 Eden 区的共享区域进行分配
TLAB 是一种优化技术类似的优化还有对象的栈上分配这可以引出逃逸分析的话题默认开启这属于非常细节的优化不做过多介绍但偶尔面试也会被问到
老年代
老年代一般使用标记-清除标记-整理算法因为老年代的对象存活率一般是比较高的空间又比较大拷贝起来并不划算还不如采取就地收集的方式
那么对象是怎么进入老年代的呢有多种途径
1提升Promotion
如果对象够老会通过提升进入老年代
关于对象老不老是通过它的年龄age来判断的每当发生一次 Minor GC存活下来的对象年龄都会加 1直到达到一定的阈值就会把这些老顽固给提升到老年代
这些对象如果变的不可达直到老年代发生 GC 的时候才会被清理掉
这个阈值可以通过参数 XX:+MaxTenuringThreshold 进行配置最大值是 15因为它是用 4bit 存储的所以网络上那些要把这个值调的很大的文章是没有什么根据的
2分配担保
看一下年轻代的图每次存活的对象都会放入其中一个幸存区这个区域默认的比例是 10%但是我们无法保证每次存活的对象都小于 10% Survivor 空间不够就需要依赖其他内存指老年代进行分配担保这个时候对象也会直接在老年代上分配
3大对象直接在老年代分配
超出某个大小的对象将直接在老年代分配这个值是通过参数 -XX:PretenureSizeThreshold 进行配置的默认为 0意思是全部首选 Eden 区进行分配
4动态对象年龄判定
有的垃圾回收算法并不要求 age 必须达到 15 才能晋升到老年代它会使用一些动态的计算方法比如如果幸存区中相同年龄对象大小的和大于幸存区的一半大于或等于 age 的对象将会直接进入老年代
这些动态判定一般不受外部控制我们知道有这么回事就可以了通过下图可以看一下一个对象的分配逻辑
卡片标记card marking
你可以看到对象的引用关系是一个巨大的网状有的对象可能在 Eden 有的可能在老年代那么这种跨代的引用是如何处理的呢由于 Minor GC 是单独发生的如果一个老年代的对象引用了它如何确保能够让年轻代的对象存活呢
对于是否的判断我们通常都会用 Bitmap位图和布隆过滤器来加快搜索的速度如果你不知道这个概念就需要课后补补课了
JVM 也是用了类似的方法其实老年代是被分成众多的卡页card page一般数量是 2 的次幂
卡表Card Table就是用于标记卡页状态的一个集合每个卡表项对应一个卡页
如果年轻代有对象分配而且老年代有对象指向这个新对象 那么这个老年代对象所对应内存的卡页就会标识为 dirty卡表只需要非常小的存储空间就可以保留这些状态
垃圾回收时就可以先读这个卡表进行快速判断
HotSpot 垃圾回收器
接下来介绍 HotSpot 的几个垃圾回收器每种回收器都有各自的特点我们在平常的 GC 优化时一定要搞清楚现在用的是哪种垃圾回收器
在此之前我们把上面的分代垃圾回收整理成一张大图在介绍下面的收集器时你可以对应一下它们的位置
年轻代垃圾回收器
1Serial 垃圾收集器
处理 GC 的只有一条线程并且在垃圾回收的过程中暂停一切用户线程
这可以说是最简单的垃圾回收器但千万别以为它没有用武之地因为简单所以高效它通常用在客户端应用上因为客户端应用不会频繁创建很多对象用户也不会感觉出明显的卡顿相反它使用的资源更少也更轻量级
2ParNew 垃圾收集器
ParNew Serial 的多线程版本由多条 GC 线程并行地进行垃圾清理清理过程依然要停止用户线程
ParNew 追求低停顿时间 Serial 唯一区别就是使用了多线程进行垃圾收集在多 CPU 环境下性能比 Serial 会有一定程度的提升但线程切换需要额外的开销因此在单 CPU 环境中表现不如 Serial
3Parallel Scavenge 垃圾收集器
另一个多线程版本的垃圾回收器它与 ParNew 的主要区别是
Parallel Scavenge追求 CPU 吞吐量能够在较短时间内完成指定任务适合没有交互的后台计算弱交互强计算
ParNew追求降低用户停顿时间适合交互式应用强交互弱计算
老年代垃圾收集器
1Serial Old 垃圾收集器
与年轻代的 Serial 垃圾收集器对应都是单线程版本同样适合客户端使用
年轻代的 Serial使用复制算法
老年代的 Old Serial使用标记-整理算法
2Parallel Old
Parallel Old 收集器是 Parallel Scavenge 的老年代版本追求 CPU 吞吐量
3CMS 垃圾收集器
CMSConcurrent Mark Sweep收集器是以获取最短 GC 停顿时间为目标的收集器它在垃圾收集时使得用户线程和 GC 线程能够并发执行因此在垃圾收集过程中用户也不会感到明显的卡顿我们会在后面的课时详细介绍它
长期来看CMS 垃圾回收器是要被 G1 等垃圾回收器替换掉的 Java8 之后使用它将会抛出一个警告
Java HotSpot 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated in version 9.0 and will likely be removed in a future release.
配置参数
除了上面几个垃圾回收器我们还有 G1ZGC 等更加高级的垃圾回收器它们都有专门的配置参数来使其生效
通过 -XX:+PrintCommandLineFlags 参数可以查看当前 Java 版本默认使用的垃圾回收器你可以看下我的系统中 Java13 默认的收集器就是 G1
java -XX:+PrintCommandLineFlags -version
-XX:G1ConcRefinementThreads=4 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
java version 13.0.1 2019-10-15
Java SE Runtime Environment (build 13.0.1+9)
Java HotSpot 64-Bit Server VM (build 13.0.1+9, mixed mode, sharing)
以下是一些配置参数
-XX:+UseSerialGC 年轻代和老年代都用串行收集器
-XX:+UseParNewGC 年轻代使用 ParNew老年代使用 Serial Old
-XX:+UseParallelGC 年轻代使用 ParallerGC老年代使用 Serial Old
-XX:+UseParallelOldGC 新生代和老年代都使用并行收集器
-XX:+UseConcMarkSweepGC表示年轻代使用 ParNew老年代的用 CMS
-XX:+UseG1GC 使用 G1垃圾回收器
-XX:+UseZGC 使用 ZGC 垃圾回收器
为了让你有个更好的印象请看下图它们的关系还是比较复杂的尤其注意 -XX:+UseParNewGC 这个参数已经在 Java9 中就被抛弃了很多程序比如 ES会报这个错误不要感到奇怪
有这么多垃圾回收器和参数那我们到底用什么在什么地方优化呢
目前虽然 Java 的版本比较高但是使用最多的还是 Java8 Java8 升级到高版本的 Java 体系是有一定成本的所以 CMS 垃圾回收器还会持续一段时间
线上使用最多的垃圾回收器就有 CMS G1以及 Java8 默认的 Parallel Scavenge
CMS 的设置参数-XX:+UseConcMarkSweepGC
Java8 的默认参数-XX:+UseParallelGC
Java13 的默认参数-XX:+UseG1GC
我们的实战练习的课时中就集中会使用这几个参数
STW
你有没有想过如果在垃圾回收的时候不管是标记还是整理复制又有新的对象进入怎么办
为了保证程序不会乱套最好的办法就是暂停用户的一切线程也就是在这段时间你是不能 new 对象的只能等待表现在 JVM 上就是短暂的卡顿什么都干不了这个头疼的现象就叫作 Stop the world简称 STW
标记阶段大多数是要 STW 如果不暂停用户进程在标记对象的时候有可能有其他用户线程会产生一些新的对象和引用造成混乱
现在的垃圾回收器都会尽量去减少这个过程但即使是最先进的 ZGC也会有短暂的 STW 过程我们要做的就是在现有基础设施上尽量减少 GC 停顿
你可能对 STW 的影响没有什么概念我举个例子来说明下
某个高并发服务的峰值流量是 10 万次/后面有 10 台负载均衡的机器那么每台机器平均下来需要 1w/s假如某台机器在这段时间内发生了 STW持续了 1 那么本来需要 10ms 就可以返回的 1 万个请求需要至少等待 1 秒钟
在用户那里的表现就是系统发生了卡顿如果我们的 GC 非常的频繁这种卡顿就会特别的明显严重影响用户体验
虽然说 Java 为我们提供了非常棒的自动内存管理机制但也不能滥用因为它是有 STW 硬伤的
小结
本课时的内容很多由于篇幅有限我们仅介绍了最重要的点要是深挖下去估计一本书都写不完
归根结底各色的垃圾回收器就是为了解决头疼的 STW 问题 GC 时间更短停顿更小吞吐量更大
现在的回收器基于弱代假设大多是分代回收的理念针对年轻代和老年代有多种不同的垃圾回收算法有些可以组合使用
我们尤其讲解了年轻代的垃圾回收
年轻代是 GC 的重灾区大部分对象活不到老年代
面试经常问都是些非常朴素的原理
为我们后面对 G1 ZGC 的介绍打下基础
我们也接触了大量的名词让我们来总结一下
算法
Mark
Sweep
Copy
Compact
分代
Young generation
Survivor
Eden
Old generation | Tenured Generation
GC
Minor GC
Major GC
名词
weak generational hypothesis
分配担保
提升
卡片标记
STW
文中图片关于 Edenfromto 区的划分以及堆的划分是很多面试官非常喜欢问的但是有些面试官的问题非常陈旧因为 JVM 的更新迭代有点快你不要去反驳有些痛点是需要实践才能体验到心平气和的讲解这些变化会让你在面试中掌握主动地位