垃圾收集器

垃圾收集是Java语言的重要特性,Java能够自动回收内存而不是像C/C++那样需要程序员手动freedelete能够极大提高开发效率,减少程序员出错的可能。这篇笔记我们简单了解一些垃圾收集算法,以及具体到JVM中的垃圾收集器。

垃圾收集算法

在对JVM的堆内存执行垃圾收集前,我们先需要区分出堆内存中哪些对象是还需要使用的,哪些是可以收集的“垃圾”对象,只有被标记为“垃圾”的对象才会在垃圾回收时释放内存,这个比较操作我们称为垃圾标记阶段。当区分出有用的对象和“垃圾”后,接下来才是真正的垃圾回收阶段,垃圾收集器会采用特定的算法和策略释放内存空间,以便有足够的内存空间分配新对象。

标记阶段的主流基础算法有:引用计数法、可达性分析算法;垃圾回收阶段的主流基础算法有:标记清除算法、标记复制算法、标记压缩算法。实际上,没有一种算法能够解决所有问题,在实际的实现中都有着复杂的优化和改进,下面介绍的算法也是搭配使用的,这也是JVM实现分代收集的原因。

标记:引用计数法

引用计数法其实很简单,每个对象保存一个引用计数器,用来记录对象被引用的次数。对于一个对象A,只要有任何一个位置引用了对象A,就将其引用计数器加1,引用失效就将其减1,当其引用计数器值为0时,表示对象A不可能再被引用了,标记为其可以被垃圾回收。

引用计数法实现简单,垃圾对象便于辨识,判定效率高,回收没有延迟性。然而,引用计数器最大的问题就是无法处理循环引用。如果两个或多个对象互相形成环形的循环引用,那么其引用计数器就永远不会变为0,造成了内存泄漏。此时一般需要程序员手动解决,在丢弃对象前拆开循环来手动清零引用计数器。

实际上,Java没有采用引用计数法,而是采用了下面介绍的可达性分析算法。

标记:可达性分析算法

相对于前面介绍的引用计数法,可达性分析算法能够解决循环引用问题,防止内存泄漏的问题发生。

可达性分析算法需要一组根对象集合(GC Roots)作为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达,此时内存中的存活对象都会被根对象集合直接和间接的连接着,搜索所走过的路径被称为引用链(Reference Chain)。如果一个对象没有任何引用链相连,意味着其不可达,可以标记为垃圾对象。

在Java语言中,根对象集合包括以下元素:

  1. 虚拟机栈中引用的对象,比如方法中的参数、局部变量等
  2. 本地方法栈内JNI引用的对象
  3. 方法区中类静态属性引用的对象
  4. 方法区中常量引用的对象
  5. 同步锁持有的对象
  6. Java虚拟机内部的引用
  7. 反映Java虚拟机内部情况的JMXBean等

如果要使用可达性分析算法判断内存是否可以回收,那么分析就必须在一个能保障一致性的快照中进行,此时就会引起STW(Stop The World)停顿,这也是可达性分析算法的一个缺点。

回收:标记清除算法

标记清除算法(Mark-Sweep)是最基础的垃圾收集算法,最早用于Lisp语言。

标记清除算法维护一个空闲地址列表,当堆中需要分配新的对象,首先查看空闲地址列表中是否有足够的连续空间,当空间不足时,就会暂时停止程序(STW),进行标记和清除。标记阶段前面已经介绍过,清除阶段也十分简单,收集器直接对堆内存从头到尾进行线性遍历,发现不可达对象就将其回收(这里所说的回收是指将需要清除的对象地址记录在空闲地址列表)。

标记清除算法的缺点是效率较低,在进行GC时需要停止程序,此外清除的内存空间还是不连续的,导致了大量内存碎片,不利于内存的再次分配。

回收:复制算法

复制算法(Copying)将整个堆内存空间分为两块,每次只是用其中一块,在垃圾回收阶段将存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

复制算法巧妙的解决了前面标记清除算法带来的内存碎片问题,此外复制算法非常适合垃圾对象很多但存活对象很少的场景,比如新生代的S0和S1区,但对于老年代则由于需要复制的对象多,因此效率不高。当然其缺点也很明显,复制算法需要双倍的内存空间。

回收:标记压缩算法

标记压缩算法(Mark-Compact)比较适合老年代的垃圾收集,该算法可以看作是标记清除算法的改进。

标记压缩算法第一阶段和标记清除算法一样,从根节点对所有对象进行标记,第二阶段则将所有存活对象压缩到内存的一端,按顺序排放,之后回收边界外的空间。因此,标记压缩算法不会产生内存碎片,它是一种移动式的回收算法。

标记压缩算法的优点是不会像标记清除算法产生内存碎片,也不会像复制算法浪费内存空间;但缺点是运行效率最慢。

JVM中的垃圾收集器

Java虚拟机规范中没有对垃圾收集器进行过多规定,不同厂商、不同版本的JVM中垃圾收集器也是各不相同。HotSpot中也衍生出了众多版本的垃圾收集器。

垃圾收集器按线程数分,可以分为串行垃圾收集器并行垃圾收集器;按工作模式分,可以分为并发垃圾收集器独占垃圾收集器

评估GC性能的指标:

  • 吞吐量:即运行用户代码的时间占总运行时间的比例
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间
  • 内存占用:Java堆区所占的内存大小

对于计算密集型程序(如离线的数据分析和报表),我们应该关注吞吐量,高的吞吐量意味着计算线程运行的比例更大;而对于延迟敏感型的程序(如网络游戏的服务端),我们应该关注暂停时间,以避免长时间STW给用户造成的影响。

常见的几种经典垃圾收集器,包括:

  • 新生代收集器:Serial、ParNew、Parallel Scavenge
  • 老年代收集器:Serial Old、Parallel Old、CMS
  • 整堆收集器:G1

Java8的HotSpot中,默认使用Parallel收集器,Java9之后则为G1。

Serial收集器和Serial Old收集器

Serial串行收集器是最古老的垃圾收集器。Serial收集器用于新生代,采用复制算法,单线程进行内存回收,同时STW。Serial Old和Serial稍有区别,Serial Old用于老年代,采用标记压缩算法,单线程回收同时STW。

Serial的优点就是实现比较简单,不过随着多核CPU的出现目前没有什么理由使用单线程的串行收集器了。

ParNew收集器

ParNew其实就是Serial的多线程版本,ParNew只针对新生代堆内存,也是采用复制算法和STW机制。ParNew在多核CPU环境下可以充分利用硬件资源,可以更快速的完成垃圾收集,提升程序的吞吐量。

Parallel Scavenge收集器和Parallel Old收集器

Parallel Scavenge针对新生代进行垃圾收集,和前面相同也是采用复制算法和STW机制,但和ParNew不同的是Parallel Scavenge的目标是达到一个可控制的吞吐量,具有自适应调节策略。Parallel Old针对老年代进行垃圾收集,采用标记压缩算法,以及STW机制。

Java8的HotSpot中,默认就是该组垃圾收集器。

CMS收集器

JDK1.5时的HotSpot推出了第一款真正意义上的并发收集器CMS(Concurrent Mark Sweep),CMS能够尽可能缩短垃圾收集时用户线程的停顿时间,其第一次实现了让垃圾收集线程和用户线程同时工作。不过其也有诸多缺点,在JDK14时被彻底移除了。

CMS的垃圾收集过程分为4个阶段:

  1. 初始标记:在这个阶段会发生极为短暂的STW,垃圾收集线程会标记出GC Roots直接关联的对象。
  2. 并发标记:并发标记时用户线程和垃圾收集线程交叉运行,垃圾收集线程开始从上一阶段标记的对象开始遍历对象图。
  3. 重新标记:这一阶段会发生短暂的STW,垃圾收集线程对并发标记阶段交叉运行造成的变动进行修正。
  4. 并发清除:使用标记清除算法释放内存空间。

这里我们可以看到,CMS收集器将标记阶段进行了拆分,耗时最多的遍历对象图的操作不会发生STW,STW只发生在起始标记和后面的修正,因此能够明显降低总的STW时间;此外在清除阶段,采用标记清除算法只会操作“垃圾”对象,而不会移动存活对象,因此清除阶段也可以实现和用户线程的并发运行,不过这也限制了并发清除阶段无法采用复制算法或是标记压缩算法。

CMS收集器的优点是低延迟,缺点则是会产生内存碎片,内存碎片会造成在无法分配大对象时不得不触发Full GC。此外CMS收集器在并发标记阶段如果产生新的“垃圾”对象,CMS收集器是无法对其进行标记的,这些对象只能等到下一次GC时再释放了。

G1收集器

G1(Garbage First)并行垃圾收集器在JDK7时引入,从JDK9开始,默认的垃圾收集器从Parallel GC换成了G1 GC。在JDK8中使用G1可以使用参数-XX:+UseG1GC

G1把内存分割为很多区域(Region)作为垃圾回收的基本单位而不是分代,G1使用不同的区域表示Eden、S0、S1、老年代等,但G1不要求各代是连续的,G1跟踪各个区域里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的区域,因此起名垃圾优先(Garbage First)。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。
Copyright © 2017-2024 Gacfox All Rights Reserved.
Build with NextJS | Sitemap