JVM 第二篇:垃圾收集器以及算法

本文内容过于硬核,建议有 Java 相关经验人士阅读。

0. 引言

一说到 JVM ,大多数人第一个想到的可能就是 GC ,今天我们就来聊一聊和 GC 关系最大的垃圾收集器以及垃圾收集算法,希望能通过本篇文章,让各位同学对 GC 有一个初步大体的认知。

1. 运行时数据区

JVM 在执行的时候会把它所管理的内存划分为几个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:

1.1 程序计数器

指向当前线程所执行的字节码的行号,其实就是一小块内存,记录着当前程序运行到哪了字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支,循环,跳转,异常处理,线程回复等都需要依赖这个计数器来完成。

由于Java的多线程是通过线程轮流切换完成的,一个线程没有执行完时就需要一个东西记录它执行到哪了,下次抢占到了CPU资源时再从这开始,这个东西就是程序计数器,正是因为这样,所以它也是“线程私有”的内存。

如果一个线程执行一个主要方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个本地方法,这个计数器的值则为空,此内存区域是唯一一个在Java的虚拟机规范中没有规定任何OutOfMemoryError异常情况的区域。

1.2 Java 虚拟机栈

与程序计数器一样, Java 虚拟机栈(Java Virtual Machine Stack)也是现成私有的,它的生命周期与线程相同。

虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候, Java 虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

经常有人把 Java 内存区域笼统地划分为堆内存(Heap)和栈内存(Stack),这种划分方式直接继承自传统的 C 、 C++ 程序的内存布局结构,在 Java 语言里就显得有些粗糙了,实际的内存区域划分要比这更复杂。不过这也说明了程序员最关注的实际上是「堆」和「栈」两块,这里的「栈」通常指的就是 Java 虚拟机栈,或者更多情况下只是指虚拟机栈中的局部变量表部分。

1.3 本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

1.4 Java 堆

Java 堆(Java Heap)是虚拟机所管理的内存中最大的一块。 Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例, Java 世界里“几乎”所有的对象实例都在这里分配内存。

1.5 方法区

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与 Java 堆区分开来。

说到这里,不得不提一下「永久代(Permanent Generation)」这个概念,大多数的程序员,都是在 Hotspot 虚拟机上进行开发、部署程序的,因此很多人都愿意把方法区称之为永久代,实际上这两者并不是一个等价的关系,而仅仅只是 Hotspot 团队使用「永久代」来实现方法区,这样使得 Hotspot 可以像管理 Java 堆内存一样管理这部分内存,实际上现在回过头来看,当年使用「永久代」来实现方法区并不是一个好主意,这种设计导致 Java 更容易遇到内存溢出的问题。因为永久代有 -XX:MaxPermSize 的上限,即使不设置也有默认大小,甚至在一些大型项目中,启动参数不设置这个直接就启动失败,这种项目我接触过不止一个。。。

那有没其他队方法区的实现方案,当然有,比如 BEA JRockit、IBM J9 ,是不存在永久代概念的,在 JRockit 和 J9 当中,只要没有触碰到进程可用的内存上限,就不会有问题,在 32 位系统中上限是 4GB 。

2. 垃圾收集器

2.1 Serial 收集器

Serial 收集器是最基础、历史最悠久的收集器,曾经(在JDK 1.3.1之前)是 HotSpot 虚拟机新生代收集器的唯一选择。

Serial 是一个单线程的收集器,这个单线程并不是仅仅局限在它在进行垃圾回收的时候是单线程工作的,更重要的是它在进行 GC 的时候,是需要所有的线程都停掉的,直到它工作结束才能继续工作。

2.2 PerNew 收集器

PerNew 收集器实质上是 Serial 的多线程并行版本。

PerNew 除了支持多线程并行收集以外,与 Serial 并没有太多不一样的地方,但它却是运行在服务端模式下的 HotSpot 虚拟机,尤其是 JDK 7 之前的遗留系统中首选的新生代收集器。

这其中有一个很重要的原因,和功能、性能都无关,是因为目前除了 Serial 以外, PerNew 是唯一一个可以和 CMS 配合工作。

2.3 Parallel Scavenge 收集器

Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。

看起来和 PerNew 貌似没啥区别,但是 Parallel Scavenge 的关注点是达到一个可控制的吞吐量(Throughput)。

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的 -XX:GCTimeRatio 参数。

  • -XX:MaxGCPauseMillis: 参数允许的值是一个大于 0 毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。
  • -XX:GCTimeRatio: 参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。

2.4 Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

2.5 Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器是直到 JDK 6 时才开始提供的。

2.6 CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发清除(CMS concurrent sweep)

在这个过程中,「初始标记」和「重新标记」这两个过程仍然需要停止当前所有的进程,然后单独进行执行。

初始标记仅仅只是标记一下 GC Root 能关联到的对象,速度很快。

并发标记是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程虽然耗时较多但是不需要停止用户线程,可以和用户线程一起并行。

重新标记则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段停顿的时间会比初始标记的时间长,但是也远比并发标记的时间短。

并发清除,在这个阶段是清理删除掉已经进行标记判断死亡的对象,由于不需要移动存活的对象,所以这个阶段也可以和用户线程一起并发的进行。

2.7 Garbage First 收集器

Garbage First 就是后来大名鼎鼎的 G1 收集器, G1 收集器是是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。

  • 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
  • 并发标记:从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  • 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
  • 筛选回收:负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

从上述阶段的描述可以看出, G1 收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起「全功能收集器」的重任与期望。

3. 垃圾收集算法

垃圾收集都是建立在分代收集之上的,一般而言,我们对垃圾收集分类如下:

  1. 部分收集(Partial GC):指目标不是完整收集整个 Java 堆的垃圾收集,其中又分为:
    1. 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
    2. 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。
  2. 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  3. 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

3.1 标记-清除算法

标记-清除算法,是最早出现同时也是最基础的垃圾收集算法,后续的大多数算法都是以标记-清除算法为基础,对其缺点进行改进而来的。

标记-清除算法的具体执行过程如下:

这个算法有两个大的缺陷:

  • 执行效率不稳定:如果这时的 Java 堆中包含了大量的对象,而且其中大部分是需要回收的,这时就必须进行大量的标记和清除动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
  • 内存空间碎片化:标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

3.2 标记-复制算法

标记-复制算法是一种半区复制的垃圾回收算法,它将内存大小按照容量划分为大小相等的两块,每次只使用其中的一块,当这一块用完了,再将还活着的对象一次复制到另一块上面,然后将已使用过的全部清除掉,具体执行过程如下:

这种方案的缺陷显而易见,那就是可用内存直接缩小了一半。

IBM 公司针对新生代「朝生夕灭」的特点做出了更量化的诠释:新生代中的对象有 98% 熬不过第一轮的收集,因此无需按照 1:1 的比例来划分新生代的内存空间。

Andrew Appel 针对具备「朝生夕灭」特点的对象,提出了一种更优化的半区复制分代策略,现在称为「Appel式回收」。

HotSpot 虚拟机的 Serial 、 ParNew 等新生代收集器均采用了这种策略来设计新生代的内存布局。

具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次内存分配至分配 Eden 空间和一块 Survivor 空间,发生垃圾回收时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间。

由于没有人可以确定的说每次垃圾回收存活的对象都能放入 Survivor 空间中,所以,这种垃圾回收算法还设计了一个「逃生门」的安全设计,就是如果 Survivor 空间无法容纳一次垃圾回收后的对象,就需要依赖其他区域(大多数是老年代)进行分配担保(Handle Promotion)。

3.3 标记-整理算法

由于老年代大多数的对象都不会被回收,所以标记-复制算法并不适用于老年代的垃圾回收算法,这时,就需要一个新的算法来应对老年代的垃圾回收。

标记-整理算法应运而生,标记-整理算法和标记-清除算法非常的像,标记-整理算法后续的步骤并不是直接对可回收的垃圾进行整理,而是让所有存活的对象都想空间的一端进行移动,然后处理掉其余的内存,具体执行过程如下:

参考

《深入理解Java虚拟机:JVM高级特性与最佳实践_周志明》

https://blog.csdn.net/fanxing1964/article/details/79349824

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章