JVM详解:垃圾回收机制
java作为大型服务开发的主流语言,其运行会占用大量的内存空间,那么合理的使用有限的服务器资源至关重要。和大多数翻译性语言一样,java的运行环境jvm也内置垃圾回收机制,其通过一些合理的算法组合,定时来对堆中保存的不可达的临时实例进行清除,来保证运行环境的内存空间。
首先我们要清楚,一切的垃圾回收都是针对于堆内存的,堆内存的作用是存储所有的临时对象(即使其可能存活时间较长),而垃圾回收则是查看这些临时对象时候到了可以被清除的时候。
垃圾回收算法
jvm中的垃圾回收设计多种算法的组合使用,具体如下:
1. 标记清除法
标记清除法通过从根节点开始,寻找实例对象引用,并进行标记,而对于未被标记的实例对象则会在清除阶段进行清除。根节点并非只有一个,java代码中,或jvm中存在的生命周期和jvm声明周期一致的内存引用都有可能垃圾回收寻找并标记对象的根节点,如方法中初始化的一个实例,静态变量,方法区中常量池中的常量,线程对象,jvm中保存的一些实例等。
2. 标记整理法
整理算法的原理简单,但不好口述。首先整理算法也需要对所有可达对象进行标记,过程和标记清除法一直,而整理阶段,整理算法是从内存的两侧开始查找,一侧查找没有被标记的可删除或已经删除当空闲的区域,而另一侧则查找被标记的可达对象,将可达对象放入覆盖可删除或已经删除的空闲区域,当两侧查找到相同位置时,则证明查找完成,所有的可达对象都已经被整理到了内存的一侧,而另一侧将被全部清除。
3. 复制算法
复制算法的原理更加简单,且高效,但代价是只能使用一半的内存空间。复制算法将堆内存分为两个部分,其中一侧为活动区,一侧为空闲区,活动区的内存正常保存对象实例,而空闲区完全空闲。当垃圾回收开始时,依旧会查找互动区的可达对象,不同的时此时不会执行标记操作,而是直接将其放入空闲区,等到整个活动区查找完成后,删除活动区的所有内容,活动区和空闲区身份替换,以此类推。
4. 分代收集算法
分代收集算法相对复杂,其将堆内存分为两个区域,一个是年轻代 (Young Generation),一个是老年代 (Old Generation)。当一个实例被创建时,会被放入年轻代 (Young Generation)中,随着垃圾回收的不断进行,年轻代 (Young Generation)中一部分实例一直存活,当这个次数达到一个阀值时,则认为这个实例可能存活时间较长,则会放入老年代 (Old Generation)中。老年代 (Old Generation)中的垃圾回收频率较低,所以通常使用标记清除法或标记整理法。而年轻代 (Young Generation)会频繁的进行垃圾回收,通常会选择复制算法,确保对java服务的性能影响较小。
年轻代 (Young Generation)中的复制算法和上面说的复制算法有些许不同,年轻代 (Young Generation)中的复制算法将年轻代 (Young Generation)内存分为了两个区域,分别是E区(Eden)和S区(Survivor),其中S区被分为S0和S1,E区永远为活动区,而S区则是一个活动区一个空闲区。当一个实例对象创建时会直接被放入E区。垃圾回收开始时,会扫描E区和S区的活动区中的可达对象,并将可达对象放入S区的空闲区,然后清空E区和S区的活动区。大多数的实例对象在使用一次后就会被销毁,而少部分的存活对象则被保存在S区中等待被清除或进入老年代 (Old Generation)。通过这种方式不仅有效的节省内存,而切还能够利用到复制算法的高效性,提高性能。
操作系统通过分页解决了外部碎片问题,但这不意味着jvm的内存就没有外部碎片问题了,因为操作系统操作的是真实的内存地址,而jvm操作的是虚拟的连续内存地址,如果对这一块不了解,可以看我的操作系统相关文章。
标记清楚法会引起外部碎片问题,而标记整理和复制算法都不会,图片画的很清晰了,这里就不说了。
垃圾回收器(GC)
对于不同的垃圾回收算法的合理利用,可以出现不同的效果,jvm提供了多种垃圾回收器,需要根据实际情况选择最合适的一款使用。所有的垃圾回收机都会使用分代收集算法,但具体实现有一些不同。
了解GC之间的差异,我们需要了解两个重要的概念,分别的吞吐量和停顿(STW)。吞吐量指的是GC在执行回收工作时用的时间长短,时间越少证明吞吐量就会越高,而停顿则是说停止用户线程的时间。说到这就有了第一个嗯题,为什么要停止用户线程,用户线程和GC之间两个线程执行不就可以了吗?
无论是用户线程,还是GC线程,都是在操作堆中的对象实例,而处理两个线程对于堆中实例的操作都是耗时操作,所以有的GC为了维持垃圾回收时间更短,则会选择直接将线程停止,然后专心进行垃圾回收,这样会让整体效率变高,但用户侧会感受到明显卡顿。有的GC则相反。
针对这两个概念,我们就可以了解各个GC的区别,并择优选择合适的GC。
1. Serial GC(串行垃圾回收器)
串行垃圾回收器的所有工作是在一个线程中完成,并且会在垃圾回收的整个过程暂停用户线程的进行。其在老年代中使用的标记整理算法,新生代中使用复制算法。
其资源消耗最少,但吞吐量低,停顿时间长,在小型服务,并且资源有限的情况下可以尝试。
2. Parallel GC(并行垃圾回收器)
并行垃圾回收器采用多线程垃圾回收机制,其致力于提高吞吐量,而不管停顿时间。新生代采用的复制算法,老年代采用的标记整理法。这种架构使得其可以更快的垃圾回收速度,但用户停顿感依旧明显,停顿时间较长
3. CMS GC(Concurrent Mark-Sweep,标记清除回收器)
不同于其他的GC,CMS致力于老年代的区域回收,其新生代的回收依赖于并行垃圾回收器的新生代垃圾回收器部分。也就是说CMS老年代使用自身的标记清除法,而新生代则使用并行垃圾回收器的复制算法。
CMS致力于缩短用户线程的停顿时间,也就是说在CMS对老年代进行垃圾回收时,会尽可能的让用户线程与垃圾回收线程一同执行,其具体操作主要分为初始标记,并发标记,重新标记以及并发清除四个阶段:
- 初始标记;初始标记时使用单线程标记根节点
- 并发标记:并发标记是使用多线程,从根节点开始,标记可达实例。此步骤和用户线程同时执行。
- 重新标记:重新标记是使用多线程对修改的引用,重新标记。此步骤需要讲用户线程暂停,但由于是对修改过的引用重新标记,所以停顿时间较短。
- 并发清除:多线程并发清除不可达实例。无需暂停用户线程。
重新标记时是如何知道哪些引用被修改过呢?
JVM在管理自身的虚拟内存时使用和操作系统相同的分页管理模式,在JVM中称其为卡片,JVM使用了卡表记录每一个卡片的初始引用,类似于操作系统的页表。当一个实例修改时,会触发JVM的写屏障,会将修改的目标卡片在页表中标记为脏。比如A引用B改为了A引用C,那么A所在的卡片就会标记为脏。在重新标记时就会顺着A再次查找,标记C为可达引用,防止被垃圾回收器清除
4. G1 GC(Garbage First)
相较于其他的垃圾回收器,G1的机制相对来说比较复杂。其并不在简单的区分堆内存为新生代还是老年代,而是将整个堆内存分为若干份,每一份都可能是老年代,Eden区或S区。G1针对这些区域垃圾回收分为三种类型,分别是新生代垃圾回收,混合垃圾回收,全局回收。
1. 新生代垃圾回收
触发时机
当存在新生代Eden区占用内存为满时,则会触发新生代回收。
原理
新生代回收只会对Eden区以及S区实例进行回收,整个过程为复制算法,将E区复制到S区,删除E区,S区到达一定次数后转移至老年区,但和其他时候不同的时,转移到老年区的实例会被立即标记为可达,后续会说原因
3. 混合垃圾回收
触发时机
混合垃圾回收的触发条件时老年代空间使用率大于一半时触发。
原理
混合垃圾回收会将新生代和老年代一起进行回收,对于新生代则是和新生代垃圾回收一致,全部进行扫描回收。而对于老年代,则会对扫描结果进行排序,清理垃圾比例靠前的若干老年代。
垃圾混合回收的扫描和清除阶段也是分开的,触发时机不同。老年代内存利用率为一半时只会触发清理,只有当整个堆内存利用率为一半时,才会触发标记扫描,而老年代清除则是不断复用上一次的扫描结果,这也是为什么转移到老年代的实例会被立即标记为可达的原因,方式新的老年代实例被误删。整个标记过程和CMS基本一致。
3. 全局清除
触发时机
全局清除的触发时机有多种可能,如堆内存耗尽,混合垃圾回收并没有将老年代内存利用率控制住,老年代内存爆满等等,总结来说就是当堆内存已经快要饱满,可能无法正常工作的极端情况才会使用全局清除,全局清除会造成较长时间的停顿,JVM会尽量避免使用全局清除。
以上四种GC是在Java8中可以使用的,其中G1在Java8中为实验性GC,除此之外还有ZGC(Z Garbage Collector)和Shenandoah GC其在Java11中才可以使用。这里暂时先不说了。