目录
[Serial Old](#Serial Old)
[Parallel Scavenge](#Parallel Scavenge)
[Parallel Old](#Parallel Old)
[garbage first 收集器](#garbage first 收集器)
前言
回顾一下之前的几种垃圾收集算法:
- 标记清除算法: 对需要回收的对象进行标记, 然后在GC的时候, 直接将对象被标记的对象清除即可, 无需更改引用地址, 简单高效, 但是容易产生大量的碎片空间
- 标记复制算法: 每次只能使用部分空间(另外一部分空间不使用), 对需要保留或者是清除的对象进行单独的标记, 然后将要保留的对象规整的复制到另外一部分空间, 好处是不会产生碎片空间, 但是每次只能使用部分内存
- 标记整理算法: 对需要被回收的对象进行标记, 如果内存不够了, 就进行GC, 然后将存活的对象放在一边, 然后将其余部分清理掉, 缺点就是如果存活的对象过多, 那么复制的消耗同样很大, 优点就是你可以先进行标记清除, 如果接下来的内存不够了, 在进行整理, 这样就不会太消耗性能.
标记整理算法
光有理论还不行, 肯定有对应的实现, 来看看几个常备问到的垃圾回收器.
上图展示了 HotSpot虚拟机的垃圾收集器的分布和使用. 图中连线说明两个虚拟机可以搭配使用, 图中指定了JDK 的适用版本, 这个图看不懂没关系, 举个例子, CMS可以在JDK9中与serial搭配使用.
首先需要说明的一点是, 虽然有这么多虚拟机, 对比了他们的各种特性, 但是目的不是为了挑选出最好的垃圾回收器, 而是让读者去了解他们的特性, 然后可以灵活的从各种场景中选择合适的垃圾回收器. 就比如你去买电脑, 你挑选了许久, 但是不管怎么挑选, 都不可能挑选到价格低, 只要一两千但是配置是14900K + 4090的电脑, 垃圾回收器同样如此.
下面我们来注意介绍里面涉及到的垃圾回收器.
Serial
英语不好的友友们我都帮你们查出来了
见名知意, 你可以自己给他取名字, 例如序列收集器, 或者串行收集器, 但是无论是什么叫法, 其实最主要的意思就是想说明 serial是一个串行化的垃圾收集器, 这里的串行化是指的什么?
它并不是指的像redis那样单线程的去处理所有的请求和垃圾收集任务, 而是强调, 它收集垃圾的时候, 需要暂停其他线程的工作任务, 直到垃圾回收完毕, 其实自己思考, 也有这样设计的道理, 例如在垃圾回收的时候, 如果是遇到例如标记复制算法和标记整理算法这种需要移动对象在内存中位置的垃圾回收算法的时候, 其被保留的对象的内存地址会发生变化, 因此你需要去更改引用, 但是如果这个时候工作内存还在运行的话, 就会因为使用了错误的地址而报错.
例如垃圾回收的时候, 有一个a对象被标记为存活, 在进行GC的时候其内存地址被整理到了另外一块内存区域, 如果这个时候恰好有一个线程需要执行跟a对象有关的代码, 就会出错, 它在使用的时候发现了使用的地址是错误的. 得到了一串无法识别执行的指令集
这也是为了系统的安全考虑, 但是也因此有了一个标志性的词语, 那么就是stop the world. 现在的虚拟机, 基本上都在这个垃圾回收的时候, 用户线程需要暂停的场景下 下了很多功夫, 想法设法的让垃圾回收的时候, 工作线程能同步执行, 减少工作线程的延迟.
但是无论垃圾回收器的构思如何巧妙, 如何优秀, 都无法完全消除用户线程在垃圾回收的时候的延迟时间(或者称为停顿时间)
如图3-7所示, Serial收集器使用的标记复制算法来进行垃圾收集的操作.
说这么多并不是说serial这个垃圾收集器没有任何作用, 事实上这款收集器依然是很多java客户端模式下的主流垃圾收集器. 它的优点就是简单高效, 内存消耗小, Serial垃圾收集器由于其线程特性, 没有额外的线程交互的开销, 专心做垃圾收集, 可以获得最高的单线程收集效率
近年来随着微服务的发展, 分配给虚拟机的管理的内存一般不会很大, 收集一两百兆的的新生代(桌面应用很少超过这个数), 只要不是频繁的垃圾收集, 延迟基本可以控制在十几 ~ 一百毫秒以内, 这种延迟对于客户端来说, 足够了, 你想想, 你打游戏50ms的延迟已经是可以流程的打王者了吧, 何况对于那种实时延迟更低的垃圾收集.
Serial垃圾收集器是客户端模式下一种非常合适的选择.
从上图(3-7)中可以看出, Serial这个垃圾收集器可以和Serial Old搭配使用, Serial Old是Serial垃圾收集器的永久代版本, Serial是新生代.
Serial Old
Serial Old 收集器是Serial收集器(新生代) 的老年代版本, 它同样是一个单线程收集器, 并且在运行的时候, 需要暂停其他用户线程, 使用标记整理算法.
Serial Old可以和Parallel Scavenge 搭配使用, 还可以作为CMS(一种老年代收集器, 使用的是标记清除算法, 如果垃圾收集时候内存不够用, 那么CMS就会出现并发处理失败的结果, 然后启动备用方案, 停止所有的线程, 使用Serial old来处理) 失败后的替代方案
ParNew
直接搜ParNew是搜不到的, 因为ParNew 收集器是 Parallel New Generation 的缩写, parallel你应该熟悉, 在使用Java8的新特性的流的时候, 你经常会看到SteamAPI里面的parallel流:
例如下面:
java
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.parallelStream() // 创建并行流
.filter(n -> n % 2 == 0) // 并行过滤偶数
.map(n -> n * 2) // 并行映射为原数的两倍
.forEach(System.out::println); // 并行打印结果
New自然就是指的新生代, 而Generation既是代的意思, 新生代的代, 从命名来说, 他无疑就是Serial的升级版, Serial是需要在垃圾回收的时候, 暂停用户线程, 而Parallel可谓是打开了并行垃圾处理的新世界, 并行处理在理论上同样是指的用户线程和垃圾收集线程同时进行, 但是实际上只是无线的缩短这个停顿时间, 来在用户层面基本感受不到这个停顿时间, 从而达到"同时进行的效果"
例如, 下图是Parallel收集器的工作模式(复制算法), 它不只是一个收集线程, 它同时拥有多个线程来收集垃圾, 多线程处理任务的速度变快了, 此时停顿时间变短了, 也就缩小了延迟, 但是本质上还是需要暂停用户线程
作为Serial垃圾收集的升级版, 其余的, 包括收集器可用参数, 收集算法, stop the world, 对象分配规则和回收策略, 都和Serial一样. 在实现上Serial和Parallel也公用了许多相同的代码. 没有太多的创新之处. (除了Serial收集器之外, 只有它能和CMS收集器配合工作)
在jdk1.5中, 使用CMS来收集老年代的时候, 只能选择ParNew(Parallel new Generation) 或者是Serial中的一个
当然, 也不是说多GC线程并发运行, 就一定比Serial快, 例如在单核的CPU下, 多线程的运行需要额外的线程交互开销(超线程技术下形成的伪双核环境), 当然随着核心数的增加, ParNew会比Serial要高效很多.
Parallel Scavenge
见名思意, Parallel Scavenge, 并行处理的意思, 同样是基于 标记-复制算法实现, 也是能够并行处理的垃圾收集器. 貌似它和ParNew一样 ... ... 那么他有什么特别之处?
我们上述说的Serial和ParNew的关注点, 其实都旨在缩小GC的时间, 也就是减少用户线程的停顿时间, 而这款收集器的焦点则不同于上面这两款, Parallel Scavenge更关注 "GC时候的吞吐量"
其实你可以理解为, 将部分用户线程的执行时间交给GC线程进行GC, 例如如果虚拟机完成某个程序需要花费100分钟, 其中GC时间为1分钟, 那么吞吐量就为99%, 停顿时间越短, 程序于用户交互的响应质量就更高, 能够提升用户体验.
其实这个停顿时间和吞吐量的度量收集器性能的指标有许多共同之处, 他们都是用户体验的直接体现, 其次, 停顿时间更关注一次GC的用户线程的停顿数值, 而Parallel Scavenge更关注程序运行的生命周期中, GC的时间到底占比多少?
Parallel Scavenge 收集器提供了两个参数来控制吞吐量:
- -XX:MaxGCPauseMillis : 最大垃圾停顿时间
- -XX:GCTimeRatio : 直接设置吞吐量大小.
Parallel Old
parallel Old是Parallel Scavenge的老年代版本, 支持多线程并发收集, 基于标记整理算法实现,
parallel Scavenge一直处于比较尴尬的场景, 因为parallel Scavenge一直处于比较尴尬的场景, 因为这个Scavenge除了Serial Old(一种单线程的老年代垃圾收集器)之外, 再无别的垃圾收集器与之配合, 其他表现良好的垃圾收集器例如CMS, 无法配合其工作, 由于老年代的拖累,是的 Parallel Scavenge收集器也未必能在整体上获得吞吐量最大化的效果, Parallel Scavenge + Serial Old的组合甚至不如Parnew + CMS, 直到Parallel Old出现之后, Parallel scavenge和Parallel old成为了注重吞吐量下的唯一选择.
CMS收集器
CMS这种收集器, 是一种, 获取最短回收停顿时间为目标的收集器, 目前, 很大一部分的java应用集中在互联网的基于浏览器的BS架构上, 因此非常关注请求的相应停顿.
CMS是基于标记清除算法实现的, 它的运作可以分为下面四部分:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
初始标记其实就是指的标记清除算法中的标记, 此时仍然需要用户线程暂停, 只是简单的标记一下GC Roots直接能关联到的对象, 速度很快.
初始标记中被标记的从GC Roots能直接关联到的对象开始, 遍历整个对象图, 这个过程需要较长时间, 但是不需要暂停用户线程.
由于用户线程在并发标记的时候还在工作, 因此会与GC产生一定的误差, 此时需要通过重新标记来纠正误差, 因为在运行过程中, 对象的标记可能会发生变化,
最后是清除阶段, 清理掉已经死亡的对象, 直接使用标记清除, 但是并不需要修改引用, 因为没有任何任何对象的移动
整个过程中, 耗时最长的并发标记和清除阶段中, 垃圾收集器线程都可以与用户线程一起工作, 所以从总体上性能不错, 里面的执行过程总体上可以看做是与用户线程同步执行的.
我们知道, 只要是标记清除算法, 其实就逃不过内存碎片的产生, 随着时间的推移, 产生内存碎片几乎是不可避免的, 因此在后期因为内存碎片而无法在内存中找到合适大小的空间的时候, 就需要Full GC, 然后整理内存, 所以一般在GC之后的老年代中, 如果CMS仍然无法腾出有效空间(虽然有很多空间, 但是碎片率太高了), 就会出现并发失败, 将会冻结所有的用户线程, 然后启用临时的Seral Old 垃圾回收器(使用标记整理算法的一种老年代垃圾回收器), 进行垃圾回收.
既然是偏向于并发执行的垃圾收集器 , 就不可避免产生占用核心线程资源(处理器的计算资源)而导致应用变慢, 降低总的吞吐量, 如果当前在有限的线程资源的情况下, GC线程数越多, 当然占用用户线程的资源就多, 再加上如果处理器本身负荷就比较高的情况, 例如有的情况下, GC线程数量比较多, 突然发送GC, 导致用户线程分到的计算资源不足, 导致吞吐量大幅度下降
为了避免这种情况, CMS推出了一种增量式的垃圾回收策略, 也就是在GC阶段让并发标记和清除的阶段中, 通过用户线程和垃圾回收器线程的核心切换, 来减少吞吐量的骤降, 但是也会延迟总体的GC时间.
同时在并发阶段, 用户线程是同事进行的, 如果此时分配的新对象, 就会产生新的垃圾, 但是这些垃圾基本上是标记阶段结束后的, CMS无法在当次处理他们, 只能在下一次垃圾回收中处理他们.
garbage first 收集器
我觉得设计思路最牛的还是这个garbege first (G1)垃圾收集器, 因为它拜托了一种惯性思维, 我们将对象的存活时间来将其分为两类, 一种是老年代, 一种是新生代, 所以我们会习惯的将堆区划分为新生代和老年代两个区域来管理, 但是由于一整块内存"耦合度"很高, 例如我无法灵活的给新生代或者是老年代中最小的一部分内存进行对象内存分配.
它可以面向任何一块微小的内存进行分配, 而不局限于新生代老年代, 每一块内存都可以是eden空间, survivor空间, 或者老年代空间, 回收标准从分代, 变为了哪个区回收的收益更高.
garbage first垃圾收集器, 开创了局部收集的设计思路, 和基于 Region的内存布局:
后续G1替代了 Parallel Old + Parallel Scavenge的组合. G1也关注停顿时间, 但是他关注的停顿时间也是在某个时间段中, 同Parallel Scavenge类型.
// todo 还有些许没写完