JAVA虚拟机系列-垃圾回收

概述

Java和C/C++,虽然同为老牌且热门的语言,但它们最大的差别之一就是GC了。C/C++需要自己管理内存,而Java有垃圾自动收集回收机制,简直不要太爽。(然而,如果你搞一些骚操作,还是有可能会导致内存泄露)。

Java垃圾回收机制是在JVM实现的,那么JVM是怎么进行GC的呢。

JVM进行垃圾回收可以概括成两个阶段。

1. 判断对象是否可回收
要进行GC,就要判断哪些对象是不会引用的,从而将它们进行释放处理。

2. 回收算法
对象是否可回收已经确定,要考虑使用什么算法来高效的进行回收。

当然,JVM为了快速的GC,还将内存分为多个区域:Eden区(俗称新生代),Old区(俗称老年代)
在回收时,也根据新生代和老年代采用了不同的方法

标记算法

要进行回收,就要判断对象是否可回收。

1. 引用计数法

原理是为每一个对象做计数处理,当该对象被引用时,计数+1。如果某个对象的引用计数为0,则说明该对象可以被回收。

听起来蛮不错的,但是引用计数法有一个致命的缺点:无法解决相互引用的问题

什么是互相引用呢,这里用一段代码来解释解释。

public class A{
    B b;
}

public class B{
   A a;
}

public void test(){
     A a = new A();
     B b = new B();
     a.b = b;
     b.a = a;
}

上述代码中,对象a,b互相引用对方,它们的计数肯定不为0,事实上a,b都是可回收对象,如此一来a,b将永远不会被回收,导致内存泄露。

2. 可达性分析算法

根据一些根对象(GC Roots)来遍历查找对象间引用关系,当遍历完成后对象还未被引用,则认为是可回收的对象。

20191129-1

JVM可作为 GC Roots 对象如下:

  1. 虚拟机栈中的引用的对象
  2. 方法区中类的静态对象
  3. 方法区中常量引用对象
  4. 本地方法栈引用的对象

回收算法

已确定了对象是否可回收,接下来进行回收操作。JVM回收算法有以下几种

  • 标记-清除
  • 复制算法
  • 标记-整理

1. 标记-清除算法

在一块大内存中,对对象做标记,然后回收不再使用的对象区域。但是这样做会导致内存碎片化,内存被分为七零八碎时,大对象的分配可能会因为内存不足,触发FullGC。

已对对象进行标记
20191129-2

清除之后
20191129-3

2. 复制算法

先标记对象,在回收的时候清除掉可回收对象,然后整理还存活的对象。
这样虽然解决了碎片问题,但是这样做效率很低。

标记处理
20191129-4

回收并复制对象
20191129-5

3. 标记-整理法

标记整理法在一开始就对内存分为两块,进行内存分配时,分配在其中一块,当进行垃圾回收时,将还存活的对象复制到另一块,先前一块清空。(这个就是JVM年轻代使用的算法)

标记对象
20191129-6

整理并清除不再使用的对象
20191129-7

4. JVM中使用的垃圾回收算法

在JVM中,将堆内存分为 年轻代老年代(如果是使用G1垃圾回收器的话,就没有分代这个概念)。不同的内存有不同的特性和回收算法。

年轻代
新对象都会在这个区域分配(大对象就不是了),这里存放的一般都是生命周期比较短的对象,也是垃圾回收的主要区域。
年轻代主要使用的是标记-整理法,因为对象分配的多,回收的也快,所以要采用高效的收集算法。
当年轻代的对象进行了多次后还存活(默认为15次),该对象将会被移到老年代。

老年代
老年代存储的都是能长久存活的对象,所以一般不会对老年代进行回收处理。
老年代因为不会被频繁回收,一般采用的是标记-清理算法,当然标记-整理也有可能被使用

JVM中的垃圾收集器

HotSpot虚拟机根据年轻代老年代分别使用不同的垃圾回收器,具体如下图所示:

20191129-8

上图的连线代表新生代和老年代可以搭配用的组合

1. 年轻代收集器:Serial收集器

单线程回收器,在低版本的JDK中用到。它在进行垃圾回收时要停止其他所有线程,直到垃圾收集结束。这种收集方法就是大名鼎鼎的Stop The World。在低版本的Go语言回收垃圾也是会将其他工作线程停掉,然后回收。

这种垃圾回收器非常慢,对系统而言并不友好,现在基本不会用到。

2. 年轻代收集器:ParNew收集器

其实就是 Serial 的多线程版本,用多个线程进行垃圾回收,在回收的时候也会暂停其他工作线程,它的收集算法、对象分配规则、回收策略都和Serial 一样

3. 年轻代收集器:Parallel Scavenge收集器

Parallel Scavenge 也是使用复制算法的收集器,也是多线程收集器,这个与ParNew类似,但它的关注点不一样。

Parallel Scavenge 关注的是吞吐量,目标是达到一个可控制的吞吐量。吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾回收时间)

吞吐量越大,说明能够更快的响应用户的请求,达到良好的交互效果。

4. 老年代收集器:Serial Old收集器

Serial OldSerial 老年代版,使用单线程处理器,在进行垃圾回收时也会暂停其他工作线程,直到回收结束。

5. 老年代收集器:CMS收集器

CMS是以最短回收停顿为目标的垃圾收集器,也是目前使用最广的老年代垃圾收集器。(在JDK8中Hotspot默认的老年代收集器,JDK11就改为G1了)。

CMS 采用的是 标记-清理 算法,一般清理一遍过后,内存会有些许碎片,如果碎片过多或者老年代空间不够用,则会进行内存整理。

CMS收集的过程可以归纳为5个:

  1. 初始标记
    • 暂停其他线程,对所有对象进行第一次标记
  2. 并行标记
    • 与业务线程一起并发执行,对对象进行第二次标记
  3. 重新标记
    • 暂停其他线程,修正在并发标记时标记错误的对象
  4. 并发清除
    • 与业务线程一起并发执行,采用 标记-清理 算法
  5. 重置状态,等待下一次执行

可以看到,CMS在初始标记和重新标记时,才会暂停业务线程,第二次标记和清除时都是并发执行的,这样整个垃圾回收的停顿时间就被大大减少

CMS虽然是一款优秀的垃圾回收器,但还有一些缺点

  1. 会占用一些的CPU,毕竟要并发标记和并发清除。默认的线程数为 (CPU数量+3)/4
  2. 不会清除浮动的垃圾。因为CMS在回收的时候是并发执行的,此时业务线程新产生的垃圾是无法回收的。
  3. 会导致内存碎片化

6. 新时代的收集器:G1收集器

G1是一款当今最前沿的垃圾收集器,目的是低延迟、高吞吐量、可以预测停顿时间。

G1在空间分配上与其他的收集器不一样,并且能够利用多个CPU来缩短线程停顿时间。

总结

垃圾回收一般会有两个步骤

  1. 标记对象是否可回收
  2. 清除

标记和清除都有不同的算法,各有优劣。

JVM中有多种垃圾回收器, 各有优劣,可以满足不同的业务需求