JVM垃圾回收原理

1. 标记回收对象-对象已死?

Java堆是JVM主要的内存管理区域,里面存放着大量的对象实例和数组。在垃圾回收算法和垃圾收集器之前,首先要做的就是判断哪些对象已经“死去”,需要进行回收即不可能再被任何途径使用的对象。

1.1 引用计数法

引用计数法是这样:给对象中添加一个引用计数器,每当有一个地方使用它时,计数器值就加1。当引用失效时,计数器就减1。任何时刻计数器为0的对象就是不可能再被使用的。

现在主流的Java虚拟机都没有使用引用计数法,最主要的原因就是它很难解决对象之间互相循环引用的问题

1.2 可达性分析

可达性分析的基本思路:通过一系列称为"GC Roots"的对象作为起点,从这些节点开始向下搜索,如果从GC Roots到一个对象不可达,则证明此对象是不可用的,如下图所示。

Java语言中,可作为GC Roots的对象包括下面几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 本地方法栈JNI(即一般说的Native方法)引用的对象
  3. 方法区中类静态常量引用的对象
  4. 方法区中常量引用的对象

对于Java程序而言,对象基本都位于堆内存中,简单来说GC Roots就是有被堆外区域引用的对象。

2. 四种引用

JDK 1.2以前的版本中,若一个对象不被任何变量引用,那么程序就无法再使用这个对象。也就是说,只有对象处于(reachable)可达状态,程序才能使用它。

JDK 1.2版本开始,对象的引用被划分为4种级别,从而使程序能更加灵活地控制对象的生命周期。这4种级别由高到低依次为:强引用软引用弱引用虚引用

2.1 强引用(StrongReference)

强引用是使用最普遍的引用,如下的方式就是强引用:

Object strongReference = new Object();
复制代码
  1. 如果一个对象具有强引用,那垃圾回收器绝不会回收它。直到强引用的对象不使用或者超出对象的生命周期范围。则GC认为该对象不存在引用,这时候就可以回收这个对象。
  2. 当内存不足时,JVM宁愿抛出OutOfMemoryError的错误,使程序异常终止,也不会靠随意回收具有强引用对象来解决内存不足的问题。

举例来说,

  1. 如下图在一个方法内部具有一个强引用,这个引用保存在虚拟栈的栈帧中,而真正的引用内容Object则保存在Java堆中。当这个方法运行完成后,退出方法栈。这个对象不再被GC Roots可达,那么这个对象在下次GC时就会被回收。
public void test() {
        Object strongReference = new Object();
        // 省略其他操作
    }
复制代码
  1. 如下图一个类的静态变量需要一个强引用,这个引用保存在方法区中,而真正的引用内容Object则保存在Java堆中。当将这个引用手动制空strongReference = null后。这个对象不再被GC Roots可达,那么这个对象在下次GC时就会被回收。
class Obj {
    pulic static Object strongReference = new Object();
}
复制代码

2.2 软引用(SoftReference)

如果对象具有软引用,则

  1. 内存空间充足时,垃圾回收器不会回收
  2. 内存空间不足时,就会尝试回收这些对象。只要垃圾回收器没有回收它,该对象就可以被程序使用
// 强引用
    String strongReference = new String("abc");
    String str = new String("abc");
    // 软引用
    SoftReference<String> softReference = new SoftReference<String>(str);
复制代码

软引用可以和一个引用队列(ReferenceQueue)联合使用。如果软引用的对象被垃圾回收,JVM就会把这个软引用加入到与之关联的引用队列中

ReferenceQueue<String> referenceQueue = new ReferenceQueue<>();
    // 强引用
    String str = new String("abc");
    SoftReference<String> softReference = new SoftReference<>(str, referenceQueue);
	// 消除强引用
    str = null;
    // Notify GC
    System.gc();

    System.out.println(softReference.get()); // abc

    Reference<? extends String> reference = referenceQueue.poll();
    System.out.println(reference); //null
复制代码

注意:

  1. 软引用对象是在JVM内存不够的时候才会被回收,我们调用System.gc()方法只是起通知作用,JVM什么时候扫描回收对象是JVM自己的状态决定的。
  2. 就算扫描到软引用对象真正开始GC也不一定会回收它,只有内存不够的时候才会回收。
  3. 软引用对象回收规则适应的前提条件是这个对象只有软引用。所以在上面的用例中要把强引用清除。

也就是说,垃圾收集线程会在虚拟机抛出OutOfMemoryError之前回收软引用对象,而且虚拟机会尽可能优先回收长时间闲置不用的软引用对象。对那些刚构建的或刚使用过的较新的软对象会被虚拟机尽可能保留

应用场景:

浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。

  1. 如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建;
  2. 如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出。

这时候就可以使用软引用,很好的解决了实际的问题:

// 获取浏览器对象进行浏览
   Browser browser = new Browser();
   // 从后台程序加载浏览页面
   BrowserPage page = browser.getPage();
   // 将浏览完毕的页面置为软引用
   SoftReference softReference = new SoftReference(page);
   // 消除强引用
   page = null;
   
   // 回退或者再次浏览此页面时
   if(softReference.get() != null) {
       // 内存充足,还没有被回收器回收,直接获取缓存
       page = softReference.get();
   } else {
       // 内存不足,软引用的对象已经回收
       page = browser.getPage();
       // 重新构建软引用
       softReference = new SoftReference(page);
   }
复制代码

2.3 弱引用(WeakReference)

相比较软引用,具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它锁管辖的内存区域的过程中,一旦发现了具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。

String str = new String("abc");
    WeakReference<String> weakReference = new WeakReference<>(str);
    // 消除强引用
    str = null;
复制代码

同样,弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用的对象被垃圾回收,JVM就会把这个弱引用加入到与之关联的引用队列中

ReferenceQueue<String> queue = new ReferenceQueue<>();
        String str = new String("abc");
        WeakReference<String> weakReference = new WeakReference<>(str, queue);
        str = null;
        System.gc();
        try {
            // 休息几分钟,等待上面的垃圾回收线程运行完成
            Thread.sleep(6000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(weakReference.get());  // null
        System.out.println(queue.poll());  // java.lang.ref.WeakReference@22a71081
复制代码

2.4 虚引用(PhantomReference)

虚引用顾名思义,就是形同虚设。与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。

应用场景:

虚引用主要用来跟踪对象被垃圾回收器回收的活动。 虚引用软引用弱引用的一个区别在于:

虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。

String str = new String("abc");
    ReferenceQueue queue = new ReferenceQueue();
    // 创建虚引用,要求必须与一个引用队列关联
    PhantomReference pr = new PhantomReference(str, queue);
复制代码

程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要进行垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

3. 垃圾收集算法

3.1 标记-清除算法

标记-清除算法分为“标记”和“清除”两个阶段,执行过程如下图所示:

  1. 标记:首先标记出所有需要回收的对象
  2. 清除:在标记完成后统一回收所有被标记的对象

标记-清除算法主要有两个不足:

  1. 效率问题,标记和清除的两个过程效率都不高
  2. 标记-清除会产生大量不连续的内存碎片,这会导致在后面需要分配连续的大对象时,无法找到足够大的连续内存而导致不得不提前触发另一次垃圾收集动作

3.2 复制算法

复制算法的大致思路如下,其执行过程如下图所示:

  1. 首先将可用内存分为大小相等的两块,每次只使用其中的一块。
  2. 当这一块的内存用完了,就将还存活的对象连续复制到另一块上面,然后把使用过的内存空间一次清理掉

复制算法的代价就是将内存缩小为原来的一半。

现在的商业虚拟机都是采用复制算法来回收新生代。

  1. 新生代的内存分为一块较大的Eden空间和两块较小的Survivor空间。
  2. 每次使用Eden和一块Survivor,当进行回收是,将Eden和Survivor中还存活的对象一次性复制到另一个Survivor空间上。然后,清理掉Eden和刚刚使用过的Survivor空间。
  3. HotSpot虚拟机默认Eden和Survivor的大小比例为8 : 1,这样每次新生代可用内存为整个新生代的90% (10% + 80%),只有10%的内存会被浪费。

3.3 标记-整理算法

标记-整理算法分为“标记”和“整理”两个阶段,执行过程如下图所示:

  1. 标记:首先标记出所有需要回收的对象
  2. 整理:让所有的存活的对象都向一端移动,然后直接清除掉边界以外的内存

3.4 分代收集算法

分代收集算法就是降Java堆分为新生代和老年代,根据其各自的特点采用最适当的收集算法。

  1. 新生代中大批对象死去,只有少量存活,就选用复制算法
  2. 老年代中对象存活几率高,没有额外的空间对它进行分配担保,就必须使用标记-清除或者标记-整理算法。

4. 垃圾回收器

JVM垃圾收集器发展历程大致可以分为以下四个阶段: Serial(串行)收集器 -> Parallel(并行)收集器 -> CMS(并发)收集器 -> G1(并发)收集器

下图展示了7种作用域不同分代的收集器,如果两个收集器之间存在连续,就说明它们可以搭配使用。下面逐一介绍这些收集器的特性、基本原理和使用场景。

4.1 Serial类收集器

Serial类收集器是一个单线程的收集器:

  1. 它只会用单个收集线程去进行垃圾回收的工作
  2. 它在进行垃圾收集的时候会“Stop The World”暂停其他所有的工作表线程,直到它收集结束
  3. Serial收集器采取复制算法新生代进行单线程的回收工作
  4. Serial Old收集器采取标记-整理算法在老年代进行单线程的回收工作

4.2 Parallel类收集器

Parallel类收集器就是Serial收集器的多线程版本:

  1. 它使用多个收集线程取进行垃圾回收工作
  2. 它在进行垃圾收集的时候也会“Stop The World”暂停其他所有的工作表线程,直到它收集结束
  3. ParNew收集器采取复制算法新生代进行多线程的回收工作
  4. Parallel Scavenge收集器也是一个新生代收集器,不过它被称为“吞吐量优先”收集器,它提供了2个能精确控制吞吐量的参数:
  • -XX : MaxGCPauseMillis:控制最大垃圾收集停顿时间
  • -XX : GCTimeRatio : 直接设置吞吐量大小,垃圾收集时间占总时间的比率


Parallel Scavenge收集器还有一个开关参数-XX: UseAdaptiveSizePolicy,打开这个开关后就不用手动指定新生代的大小(-Xmn),Eden与Survivor区的比例(-XX:SurvivorRatio)等细节参数了,JVM会动态调整这些参数已提供最合适的停顿时间或者最大吞吐量。

  1. Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法在老年代进行垃圾回收。

4.3 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它是一个基于标记-清除算法实现的,运作过程分为4个步骤:

  • 初始标记(CMS initial mark): 需要“Stop The World”,仅仅只是标记下GC Roots能直接关联到的对象,速度很快
  • 并发标记(CMS concurrent mark): CMS线程与应用线程一起并发执行,从GC Roots开始对堆中对象进行可达性分析,找出存活对象,耗时较长
  • 重新标记(CMS remark):重新标记就是为了修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录,可以多线程并行
  • 并发清除(CMS concurrent sweep):CMS线程与应用线程一起并发执行,进行垃圾清除

CMS收集器优点:并发收集低停顿
CMS的三个明显的缺点:

    1. CMS收集器对CPU的资源非常敏感。CPU的数量较少不足4个(比如2个)时,CMS对用户程序的影响就可能变的很大。
    2. CMS收集器无法处理浮动垃圾(Floating Carbage),可能出现"Concurrent Mode Failture"失败而导致产生另一次Full GC的产生。浮动垃圾就是并发清理阶段,用户线程产生的新垃圾
    3. CMS是基于标记-清除算法的,收集结束后会有大量的空间碎片,就可能会在老年代中无法分配足够大的连续空间而不得不触发另一次Full GC。


4.4 G1收集器

同优秀的CMS一样,G1也是关注最小停顿时间的垃圾回收器,也同样适合大尺寸堆内存,官方也推荐用G1来代替选择CMS。

  1. G1收集器的最大特点就是引入了分区的思路,弱化了分代的概念
  2. G1从整体来看是基于标记-整理算法实现的,从局部(两个Region之间)来看是基于复制算法实现的

4.4.1 G1相对于CMS的改进

  1. G1是基于标记-整理算法,不会产生空间碎片,在分配大的连续对象是不会因为无法得到连续空间而不得不提前触发一次Full GC
  2. 停顿时间可控,G1可以通过设置停顿时间来控制垃圾回收时间
  3. 并行与并发,G1能更充分的利用CPU,多核环境下的硬件优势来缩短stop the world的停顿时间

4.4.2 G1与CMS的区别

(1)堆内存模型的不同

G1之前的JVM堆内存模型,堆被分为新生代,老年代,永久代(1.8之前,1.8之后是元空间),新生代中又分为Eden和两个Survivor区。

  1. G1收集器的堆内存模型,堆被分为很多个大小连续的区域(Region),Region的大小可以通过-XX: G1HeapRegionSize参数指定,大小区间为[1M,32M]。
  2. 每个Region被标记了E、S、O和H,这些区域在逻辑上被映射为Eden,Survivor,老年代和巨型区(Humongous Region)。巨型区域是为了存储超过50%标准region大小的巨型对象。


(2)应用分代的不同

G1可以在新生代和老年代使用,而CMS只能在老年代使用。

(3)收集算法的不同

G1是复制+标记-整理算法,CMS是标记清除算法。

4.4.3 G1收集器的工作流程

G1收集器的工作流程大致分为如下几个步骤:

  1. 初始标记(Initial Marking): 需要“Stop The World”,仅仅只是标记下GC Roots能直接关联到的对象,速度很快
  2. 并发标记(Concurrent Marking): G1线程与应用线程一起并发执行,从GC Roots开始对堆中对象进行可达性分析,找出存活对象,耗时较长
  3. 最终标记(Final Marking): 最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,需要“Stop The World”,可以多线程并行
  4. 筛选回收(Live Data Counting and Evacuation): 对各个Region的回收价值和成本进行排序,根据用户所期待的GC停顿时间制定回收计划。具体地,在后台维护一个优先队列,每次根据允许的收集停顿时间,优先回收价值最大的Region

4.4.4 G1的GC模式

G1提供了两种GC模式,Young GC和Mixed GC,两种都是完全Stop The World的

(1)YoungGC

  1. 在分配一般对象(非巨型对象)时,当所有的Eden Region使用达到最大阈值并且无法申请到足够内存时,会触发一次YoungGC。
  2. 每次YoungGC会回收所有的Eden以及Survivor区,并且将存活对象复制到Old区以及另一部分的Survivor。

(2)MixedGC

当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一次mixed gc,该算法并不是一个old gc,除了回收整个young region,还会回收一部分的old region。这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进行控制

G1没有fullGC概念,需要fullGC时,调用serialOldGC进行全堆扫描(包括eden、survivor、o、perm)。

参考与感谢

  1. 理解Java的强引用、软引用、弱引用和虚引用
  2. 深入剖析JVM:G1收集器+回收流程+推荐用例
  3. Java Hotspot G1 GC的一些关键技术

来源:掘金

发布于 2020-05-14 14:11