前言
本篇文章主要针对jvm的垃圾回收机制进行讲解,主要分享内容从JVM的主要区域、如何定位垃圾、GC的算法和GC回收器的类型和垃圾回收器的比对特性。
一、JVM的运行时数据区
JVM主要分为5个区域,程序计数器、虚拟机栈和本地方法栈是线程私有的,堆和方法区是线程共享的。
![](https://i-blog.csdnimg.cn/blog_migrate/94fb9fee0511914ea0957f39e3cd0418.png)
程序计数器(progrom count)
它是用来记录当前线程执行到了哪一个位置,记录当前线程执行到达的行号指示器,用于线程切换的场景
字节码解释器工作的时候,就是通过改变这个计数器的值来获取到下一条需要执行的字节码,比如循环、分支、跳转、异常处理、线程恢复等这一系列基础的操作都需要依赖这个程序计数器。
每个线程都有自己的计数器,互不影响,用来记录各自执行字节码的位置,程序计数器是线程私有的。
虚拟机栈(JVM STACKS)
虚拟机栈描述的是java方法执行的内存模型,这里的java方法是指程序员在代码里写的java方法,在每个方法执行的时候,都会创建一个“栈帧”。
这个栈帧用来保存局部变量,操作数栈、动态链接,方法出口等信息。一个方法从调用到执行完成,都对应着一个栈帧从入栈到出栈的过程。当一个方法执行完成的时候,方法对应的栈帧就会弹出栈帧的元素作为方法的返回值,并且清除这个栈帧。
java栈的栈顶的栈帧就是当前正在执行的方法,方法之间的调用,其实就是栈帧的切换过程。
![](https://i-blog.csdnimg.cn/blog_migrate/38ba30eb78093a135b11b5a70d68cfcf.png)
本地方法栈(Native Method Stack)
本地方法栈跟上面的虚拟机栈很相似,但是,虚拟机栈是为java方法(即字节码)服务的,本地方法栈是为虚拟机使用到的Native方法服务的。简单说就是一个不是用java代码写的方法。本地方法栈就是为了虚拟机调用非java代码编写的接口服务。
堆(Heap)-垃圾回收发生的主要区域
堆是被所有线程共享的区域,也是OutOfMemoryError异常的主要事故区,这里也是虚拟机中被分配空间最大的一块。堆中存储这几乎所有的实例对象,这里也是垃圾回收器的工作区域。如果无休止创建大量的对象,当消耗完所有内存空间,GC之后无法继续为新的对象分配内存空间的时候,就会发生OOM。
堆的内存可以通过-Xms设置初始值、-Xmx设置最大值。
![](https://i-blog.csdnimg.cn/blog_migrate/3e76531c3ed370028f067a22385c5f39.png)
方法区(Method Area)
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载
的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把 方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区 分开来。
具体的方法区内容可以查看之前文章:方法区内部结构
![](https://i-blog.csdnimg.cn/blog_migrate/4d59280dcb789c388e3c2cb35e1d577c.png)
二、GC垃圾定位
根据上面聊到的,jvm的垃圾回收主要发生在堆和方法区这种线程共享区域,那如何定位jvm的垃圾对象呢?没有引用的对象就被认定为垃圾。
引用计数算法(reference count)-不能解决循环引用的问题
一个对象被创建之后,系统会给这个对象初始化一个引用计数器,当这个对象被引用了,则计数器 +1,而当该引用失效后,计数器便 -1,直到计数器为 0,意味着该对象不再被使用了,则可以将其进行回收了。这种算法其实很好用,判定比较简单,效率也很高,但是却有一个很致命的缺点,就是它无法避免循环引用,即两个对象之间循环引用的时候,各自的计数器始终不会变成 0。
![](https://i-blog.csdnimg.cn/blog_migrate/d5025fd681ad7ad983d31d7b58b42a67.png)
可达性分析算法(root searching)
根搜索算法的中心思想,就是从某一些指定的根对象(GC Roots)出发,一步步遍历找到和这个根对象具有引用关系的对象,然后再从这些对象开始继续寻找,从而形成一个个的引用链(其实就和图论的思想一致),然后不在这些引用链上面的对象便被标识为引用不可达对象,也就是我们说的“垃圾”,这些对象便需要回收掉。这种算法很好地解决了上面 引用计数算法 的循环引用的问题了。
![](https://i-blog.csdnimg.cn/blog_migrate/a9d694ebd377042677f179ae634f5c4f.png)
三、GC的算法
垃圾回收的算法可以按区域进行区分回收,就是经常说的分代收集,分别是年轻代-YGC和老年代-FGC,年轻代回收的时候存活对象数量少,老年代回收的时候存活对象大,碎片化严重,所以采用不同的分代回收策略。
标记-清除法(Mark-Sweep)
标记-清除算法分为“标记”和“清除”两个阶段:标记清除算法是先找到内存里的存活对象并对其进行标记,然后统一把未标记的对象统一的清理。
优点:当存活对象比较多的时候,性能比较高,因为该算法只需要处理待回收的对象,而不需要处理存活的对象。
缺点:在执行完 标记-整理 之后,由于将“垃圾对象”回收掉了,所以原本连续使用的内存块便会变得不连续,这样会导致内存块上面会出现很多小单元的内存区域,这些小单元的内存区域只能够存放比较小的对象,而比较大的对象是无法直接存储的
![](https://i-blog.csdnimg.cn/blog_migrate/4dd3f710e6a52383ed5a66daf3687748.png)
标记-复制法(Mark-Copying)
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
优点:①不会产生内存碎片;② 标记和复制可以同时进行;③ 复制时也只需要移动栈顶指针即可,按顺序分配内存,简单高效;④ 每次只需要回收一块内存区域即可,而不用回收整块内存区域,所以性能会相对高效一点。
缺点:可用的内存减小了一半,存在内存浪费的情况。
所以 复制算法 一般会用于对象存活时间比较短的区域,例如 年轻代,而存活时间比较长的 老年代 是不适合的,因为老年代存在大量存活时间长的对象,采用复制算法的时候会要求复制的对象较多,效率也就急剧下降,所以老年代一般会使用上文提到的 标记-整理算法
![](https://i-blog.csdnimg.cn/blog_migrate/05f28dac45ca71ccc8b5f53472d4de21.png)
标记-整理算法(Mark-Compact)
上述的 标记-清除 算法会产生内存区域使用的间断,所以为了将内存区域尽可能地连续使用, 标记-整理 算法应运而生。
标记-整理 算法也是由两步组成,标记 和 整理。
第一步的 标记 动作也是使用的 根搜索算法,但是在标记完成之后的动作却和 标记-清除算法 天壤之别,该算法并不会直接清除掉可回收对象 ,而是让所有的对象都向一端移动,然后将端边界以外的内存全部清理掉。
优点:使得内存上面不会再有碎片问题,并且新对象的分配只需要通过简单的指针碰撞便可完成。
缺点:移动存活对象并更新 所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用 程序才能进行
![](https://i-blog.csdnimg.cn/blog_migrate/eeb62d0fbc9bf8984904a8c60cba07cf.png)
四、常用垃圾回收器
前面我们介绍的所有回收算法都是为实现垃圾回收器服务的,而垃圾回收器就是内存回收的具体实现。
主要的垃圾回收器包括一下几种:
![](https://i-blog.csdnimg.cn/blog_migrate/9251f21a9f455672a5d57f3aa81ef185.png)
Serial(串行垃圾回收器)
单线程处理,新生代采用Serial,是利用复制算法,会出现STW;通过-XX:+UseSerialGC可以开启上述回收模式
![](https://i-blog.csdnimg.cn/blog_migrate/f096a023d33da804063cda6193f445c7.png)
Serial Old(串行垃圾回收器)
Serial Old 收集器同样也采用了串行回收和“Stop-the-World”机制,只不过内存回收算法使用的是标记-压缩算法。
a、Serial Old 是运行在Client模式下默认的老年代的垃圾回收器
b、Serial Old在Server模式下主要有两个用途:
(1)与新生代的Parallel Scavenge配合使用。
(2)作为老年代CMS收集器的后备垃圾收集方案。
![](https://i-blog.csdnimg.cn/blog_migrate/fb04d9957627bb89c6c0a0931457e90a.png)
ParNew(并行垃圾回收器)
ParNew收集器则是Serial收集器的多线程版,一般配合CMS使用。
ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、“Stop-the-World”机制。
![](https://i-blog.csdnimg.cn/blog_migrate/6e4b4ebe60691b271f80e00a41784b52.png)
Parallel Scavenge(并行垃圾回收器)
Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于标记 -复制算法实现的收集器,是 能够并行收集的多线程收集器。
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同。Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)
![](https://i-blog.csdnimg.cn/blog_migrate/bb0bb9562c4b2e03e0c3b856d669a2d9.png)
Parllel Old(并行垃圾回收器)
Parallel Old 是 Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实 现。
![](https://i-blog.csdnimg.cn/blog_migrate/da5af1caa0c795fe36fc79c7d96a9ba4.png)
CMS(并发标记清除)
CMS(Concurrent Mark Sweep)收集器是一种以获得最短回收停顿时间为目标的收集器。从名字就能直到其是基于标记-清除算法的。但是它比一般的标记-清除算法要复杂一些,分为以下4个阶段:
初始标记:标记一下GC Roots能直接关联到的对象,会“Stop The World”。
并发标记:GC Roots Tracing,可以和用户线程并发执行。
重新标记:标记期间产生的对象存活的再次判断,修正对这些对象的标记,执行时间相对并发标记短,会“Stop The World”。
并发清除:清除对象,可以和用户线程并发执行。
-XX:+UseConcMarkSweepGC
优点:减少STW的时间,优化用户体验
缺点:
回收时间长,吞吐量不如Parallel
无法处理浮动垃圾
会产生大量的空间碎片
![](https://i-blog.csdnimg.cn/blog_migrate/63252dc94bc44db4715fe944652c0b8e.png)
G1
G1收集器的运作大致可以分为以下步骤:
初始标记
初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Set)的值,让下一个阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这个阶段需要STW,但耗时很短。
并发标记
并发标记阶段是从GC Roots开始对堆中对象进行可达性分析,找到存活的对象,这阶段耗时较长,但是可以和用户线程并发运行。
最终标记
最终标记阶段则是为了修正在并发标记期间因用户程序继续运行而导致标记产生变化的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记需要把Remembered Set Logs的数据合并到Remembered Sets中,这阶段需要暂停线程,但是可并行执行。
筛选回收
筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来确定回收计划。
-XX:+UseG1GC
![](https://i-blog.csdnimg.cn/blog_migrate/797932e425f23cd5fe76aaff1becc28a.jpeg)
ZGC
ZGC 使用内存多重映射技术,把物理内存映射为 Marked0、Marked1 和 Remapped 三个地址视图,利用地址视图的切换,ZGC 实现了高效的并发收集。
ZGC 的垃圾收集过程包括标记、转移和重定位三个阶段。
![](https://i-blog.csdnimg.cn/blog_migrate/b7cbd6214e11383a984ea741c8c4f92a.png)
初始标记
从 GC Roots 出发,找出 GC Roots 直接引用的对象,放入活跃对象集合,这个过程需要 STW,不过 STW 的时间跟 GC Roots 数量成正比,耗时比较短。
并发标记
并发标记过程中,GC 线程和 Java 应用线程会并行运行。这个过程需要注意下面几点:
GC 标记线程访问对象时,如果对象地址视图是 Remapped,就把对象地址视图切换到 Marked0,如果对象地址视图已经是 Marked0,说明已经被其他标记线程访问过了,跳过不处理。
标记过程中Java 应用线程新创建的对象会直接进入 Marked0 视图。
标记过程中Java 应用线程访问对象时,如果对象的地址视图是 Remapped,就把对象地址视图切换到 Marked0,可以参考前面讲的读屏障。
标记结束后,如果对象地址视图是 Marked0,那就是活跃的,如果对象地址视图是 Remapped,那就是不活跃的。
再标记
并发标记阶段 GC 线程和 Java 应用线程并发执行,标记过程中可能会有引用关系发生变化而导致的漏标记问题。再标记阶段重新标记并发标记阶段发生变化的对象,还会对非强引用(软应用,虚引用等)进行并行标记。
这个阶段需要 STW,但是需要标记的对象少,耗时很短。
初始转移
转移就是把活跃对象复制到新的内存,之前的内存空间可以被回收。
初始转移需要扫描 GC Roots 直接引用的对象并进行转移,这个过程需要 STW,STW 时间跟 GC Roots 成正比
并非转移
并发转移过程 GC 线程和 Java 线程是并发进行的。上面已经讲过,转移过程中对象视图会被切回 Remapped 。转移过程需要注意以下几点:
如果 GC 线程访问对象的视图是 Marked0,则转移对象,并把对象视图设置成 Remapped。
如果 GC 线程访问对象的视图是 Remapped,说明被其他 GC 线程处理过,跳过不再处理。
并发转移过程中 Java 应用线程创建的新对象地址视图是 Remapped。
如果 Java 应用线程访问的对象被标记为活跃并且对象视图是 Marked0,则转移对象,并把对象视图设置成 Remapped。
重定位
转移过程对象的地址发生了变化,在这个阶段,把所有指向对象旧地址的指针调整到对象的新地址上。
ZGC总结
内存多重映射和染色指针的引入,使 ZGC 的并发性能大幅度提升。
ZGC 只有 3 个需要 STW 的阶段,其中初始标记和初始转移只需要扫描所有 GC Roots,STW 时间 GC Roots 的数量成正比,不会耗费太多时间。再标记过程主要处理并发标记引用地址发生变化的对象,这些对象数量比较少,耗时非常短。可见整个 ZGC 的 STW 时间几乎只跟 GC Roots 数量有关系,不会随着堆大小和对象数量的变化而变化。
ZGC 也有一个缺点,就是浮动垃圾。因为 ZGC 没有分代概念,虽然 ZGC 的 STW 时间在 1ms 以内,但是 ZGC 的整个执行过程耗时还是挺长的。在这个过程中 Java 线程可能会创建大量的新对象,这些对象会成为浮动垃圾,只能等下次 GC 的时候进行回收。
五、垃圾回收器的特性和比较
Serial、SerialOld、ParallelScavenger、ParNew、CMS在物理和逻辑上都分代。G1只在逻辑上分代,物理上不分代。ZGC、Shenandoah逻辑和物理上都不分代。Epsilon是jdk11提出debug使用的,不用考虑。
Serial、ParallelScavenger、ParNew是用于年轻代的,SerialOld、ParallelOld、CMS是用于老年代的。所以产生了垃圾回收器几种常见的组合:Serial+SerialOld、ParallelScavenger+ParallelOld、ParNew+CMS。
CMS本身的原因,并不是任何一个版本JDK的默认垃圾回收器。JDK8的默认垃圾回收器是PS+PO,JKD9的默认垃圾回收器是G1。G1的响应时间比PS+PO短,但G1的吞吐量比PS+PO降低了
结语
对常用垃圾回收器的算法和流程进行了分析,从常见的Serial、SerialOld、ParallelScavenger、ParNew、ParOld到CMS的三色标记再到G1、ZGC的出现讲解,希望能对您了解JVM的GC有一些帮助,后期我会专门针对G1、ZGC这两种垃圾回收器做专文分享。