前言
阅读过王子之前 JVM 文章的小伙伴们, 应该已经对 JVM 的内存分布情况有了一个清晰的认识了, 今天我们就接着来聊聊 JVM 的垃圾回收机制, 让小伙伴们轻松理解 JVM 是怎么进行垃圾回收的.
复制算法, Eden 区和 Survivor 区
首先我们就来探索一下对于 JVM 堆内存中的新生代区域, 是怎么进行垃圾回收的.
实际上 JVM 是把新生代分为三块区域的: 1 个 Eden 区, 2 个 Survivor 区.
其中 Eden 区占用 80% 的内存空间, 每块 Survivor 各占用 10% 的内存空间. 比如 Eden 区有 800M, 那么每个 Survivor 区就有 100M.
平时可以使用的区域是 Eden 区和其中一块 Survivor 区, 也就是 900M 的内存空间.
刚开始创建对象的时候, 对象都是分配在 Eden 区中的, 如果 Eden 区快满了, 就会触发垃圾回收 Young GC, 使用的就是复制算法进行垃圾回收, 流程如下:
首先会把 Eden 区中的存活对象一次性转入其中一块空着的 Survivor 区中.
然后清空 Eden 区, 之后新创建的对象就再次被放入了 Eden 区中了.
如果下次 Eden 区快满了, 就会再次触发 Young GC, 这个时候会把 Eden 区和存在对象的 Survivor 区中存活的对象转移到另一块空着的 Survivor 区中, 并清空 Eden 区和之前存在对象的 Survivor 区.
这就是复制算法的流程.
一直要保持一个 Survivor 区是空的以供复制算法垃圾回收, 而这块区域只占用整个内存的 10%, 其他 90% 的内存都能被使用, 可见内存利用率还是相当高的.
什么时候进入老年代
接下来我们就来看一下什么时候会进入老年代, 这个问题上篇文章轻松理解 JVM 的分代模型 https://mp.weixin.qq.com/s/VlSdgQXWkMMHEZoxszoorw 中已经简单的介绍过了, 今天会对此展开进行详细探索.
1. 躲过 15 次 GC 后进入老年代
在默认的情况下, 如果新生代中的某个对象经历了 15 次 GC 后, 还是没有被回收掉, 那么它就会被转入到老年代中.
这个具体躲过多少次, 是可以自己设置的, 通过 JVM 参数 "-XX:MaxTenuringThreshold" 来设置, 默认是 15.
2. 动态对象年龄判断
另一种判断方式也可以进入老年代, 是不用等待 GC15 次的.
它的大致规则是, 假如一批对象总大小大于了当前 Survivor 区域内存的大小的 50%, 那么大于等于这批对象年龄的对象就会被转移到老年代.
小伙伴们可能觉得有些没看明白这句话的意思, 没关系, 我们看一下图
假设 Survivor 中有两个对象, 它们都经历过 2 次 GC, 年龄是 2 岁, 而且两个对象加在一起的大小大于 50M, 也就是超过了 Survivor 区域内存大小的 50%, 那么这个时候, Survivor 区域中年龄大于等于 2 岁的对象就要全部转移到老年代中.
这就是所谓的动态年龄判断规则.
要注意的是, 年龄 1 + 年龄 2 + 年龄 n 的多个年龄对象大小超过 Survivor 区的 50%, 此时会把年龄 n 以上的对象放入老年代.
3. 大对象直接进入老年代
有一个 JVM 参数 "-XX:PretenureSizeThreshold", 默认值是 0, 表示任何情况都先把对象分配给 Eden 区.
我们可以给他设置一个字节数 1048576 字节, 也就是 1M.
它的意思就是当要创建的对象大于 1M 的时候, 就会直接把这个对象放入到老年代中, 压根不会经过新生代.
因为大对象在经历复制算法进行 GC 的时候是会降低性能的, 所以直接放入老年代就可以了.
4.Young GC 后存活的对象太多无法放入 Survivor 区
还有一种情况, 就是 Young GC 后存活的对象太多, Survivor 区放不下了, 这个时候就会把这些对象直接转移到老年代中.
这里我们就要思考一个问题了, 如果老年代也放不下了怎么办呢?
老年代空间分配担保原则
首先, 在执行任何一次 Young GC 之前, JVM 都会先检查一下老年代可用的内存空间是否大于新生代所有对象的总大小.
为啥要检查这个呢? 因为在极端情况下, Young GC 后, 新生代中所有的对象都存活下来了, 那就会把所有新生代中的对象放入老年代中.
如果说老年代可用内存大于新生代对象总大小, 那么就可以放心的执行 Young GC 了.
但是如果老年代的可用内存小于新生代对象的总大小, 这个时候就会看一个参数 "-XX:HandlePromotionFailure" 是否设置为 true 了 (可以认为 jdk7 之后, 默认设置为 true).
如果设置为 true, 那么进入下一步判断, 就是看看老年代可用的内存, 是否大于之前每次 Young GC 后进入老年代对象的平均大小.
如果说老年代的可用内存小于平均大小, 或者说参数没有设置成 true, 那么就会直接触发 "Full GC", 就是对老年代进行垃圾回收, 腾出空间后, 再进行 Young GC.
如果上边两种情况判断成功, 没有执行 Full GC, 进行了 Young GC, 有以下几种可能:
1. 如果 Young GC 后, 存活的对象大小小于 Survivor 区域的大小, 那么直接进入 Survivor 区域即可.
2. 如果 Young GC 后, 存活的对象大小大于 Survivor 区域的大小, 但是小于老年代可用内存大小, 那就直接进入老年代.
3. 很不幸, 老年代可用空间也放不下这些存活对象了, 那就会发生 "Handle Promotion Failure" 的情况, 触发 Full GC.
如果 Full GC 后, 老年代可用内存还是不够, 那么就会导致 OOM 内存溢出了.
这段内容可能比较繁琐, 结合内存模型, 多看两遍相信小伙伴们是可以读懂的.
老年代的垃圾回收算法
接下来我们就来介绍一下老年代的垃圾回收算法, 标记整理算法, 理解起来还是比较容易的.
开始时我们的对象是胡乱分布的, 经过垃圾回收后, 会标记出哪些是存活对象, 哪些是垃圾对象, 而后会把这些存活对象在内存中进行整理移动, 尽量都挪到一边去靠在一起, 然后再把垃圾对象进行清除, 这样做的好处就是避免了垃圾回收后产生大片的内存碎片.
但是这一过程其实是比较耗时的, 至少要比新生代的垃圾回收算法慢 10 倍.
所以如果系统频繁出现 Full GC, 会严重影响系统性能, 出现频繁卡顿.
所以 JVM 优化的一大问题就是减少 Full GC 频率.
垃圾回收器
新生代和老年代进行垃圾回收的时候是通过不同的垃圾回收器进行回收的.
Seral 和 Seral Old 垃圾回收器: 分别用于回收新生代和老年代.
工作原理是单线程运行, 垃圾回收的时候会停止我们系统的其他线程, 让系统卡死不动, 然后执行垃圾回收, 这个现在基本已经不会使用了
ParNew 和 CMS 垃圾回收器: 分别用于回收新生代和老年代.
它们都是多线程并发的, 性能更好, 现在一般是线上生产系统的标配.
G1 垃圾回收器: 统一收集新生代和老年代, 采用了更加优秀的算法机制.
这里只是给大家做一下简单的介绍, 更详细的内容以后文章会单独解析.
Stop the World
JVM 最大的痛点就是 Stop the World 了.
在垃圾回收的时候, 尽可能的要让垃圾回收器专心的去做垃圾回收的操作 (防止垃圾回收的时候还在创建新对象, 那不就乱套了吗), 所以此时 JVM 会在后台进入 Stop the World 状态.
进入这个状态后, 会直接停止我们系统的工作线程, 让我们的代码不在运行.
接着垃圾回收完成后, 会恢复工作线程, 代码就可以继续运行了.
所以说只要是经历 GC, 其实就会让系统卡死一段时间, 新生代的垃圾回收可能感受不到太多, 单老年代的垃圾回收耗时更多, 可能会明显的感觉到系统的卡死.
所以说无论是新生代的垃圾回收还是老年代的垃圾回收, 我们都应该尽量的减少它们的频率.
总结
今天的干货内容还是比较多的, 相信小伙伴们阅读后对 JVM 会有一个更深的了解.
建议小伙伴们自己找资料了解一下几种垃圾回收器的实现原理, 我们之后的文章会陆续介绍.
好了, 那就到这里了, 欢迎评论区留言讨论. 你的支持就是我更新的动力!
往期文章推荐:
大白话谈 JVM 的类加载机制 https://mp.weixin.qq.com/s/e_fj3crV8YwcCGy5Jl8W-A
JVM 内存模型不再是秘密 https://mp.weixin.qq.com/s/5ozxG-uPyP8ATKQOwZXCng
轻松理解 JVM 的分代模型 https://mp.weixin.qq.com/s/VlSdgQXWkMMHEZoxszoorw
来源: https://www.cnblogs.com/lm970585581/p/13814400.html