JVM之垃圾回收-垃圾收集算法

JVM之垃圾回收-垃圾收集算法

  • 如何判断对象是否存活
    • 引用计数算法
    • 可达性分析(GC Roots Tracing)算法
    • 效率
    • 对象之间相互循环引用的问题
      • 使用引用计数算法
      • 使用可达性算法
    • Java引用的四种状态
      • 强引用(Strong Reference)
      • 软引用(Soft Reference)
      • 弱引用(Weak Reference)
      • 虚引用(Phantom Reference)
    • 什么情况下回收对象
    • 回收方法区
  • 垃圾收集算法
    • 标记-清除算法(Mark and Sweep)
      • 缺点
      • 写时复制
    • 复制收集算法(Copy and Collection)
      • 优点
        • 1. 优秀的吞吐量
        • 2.可实现高速分配
        • 3.不会发生碎片化
        • 4.与缓存兼容
      • 缺点
        • 1.堆使用率低下
        • 2.不兼容保守式GC算法
          • 保守式GC
        • 3.递归调用函数
      • 实际应用
    • 标记-整理算法
      • 优点
      • 缺点
    • 垃圾收集算法比较
    • 分代收集算法
    • 小结

开发对账的系统在公司已经落地上线了,由于时间&工作原因,对账二期系统的文章还在准备中。
在开发对账系统中,对于JVM的调优使用的比较多,在这里整理了一些资料,先与大家共享。
本系列(JVM之垃圾回收)会有多篇文章。后期会徐徐道来。(看文章之前,若已学JVM内存模型的相关知识,看本系列的文章会更容易)

如何判断对象是否存活

在Java中,如何回收对象,第一步,肯定是需要知道对象是否还有引用,是否还存活的。这样,JVM才能进行下一步的操作。判断对象是否还存活有着如下两种算法:引用计数算法与可达性分析算法。

引用计数算法

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

引用计数算法的优点是实现简单,判定效率也高。缺点是它很难解决对象之间相互循环引用的问题。所以目前主流的虚拟机中都没有使用该算法来管理内存,Java虚拟机当然也并没有使用该算法判断对象是否应该回收。

可达性分析(GC Roots Tracing)算法

这个算法的基本思想就是通过一系列的称为“GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的话,则证明此对象是不可用的,也就是可以回收的对象。
(可作为GC Roots的对象包括:虚拟机栈中(栈帧中的本地变量表)引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI(native方法)引用的对象。)
JVM之垃圾回收-垃圾收集算法_第1张图片

效率

采用引用计数算法的系统只需在每个实例对象创建之初,通过计数器来记录所有的引用次数即可。而可达性算法,则需要再次GC时,遍历整个GC根节点来判断是否回收。(所以引用计数回收的效率比可达性算法效率高)

对象之间相互循环引用的问题

看一个简单的例子:

 public class GcDemo {
    public static void main(String[] args) {
        //分为6个步骤
        GcObject obj1 = new GcObject(); //Step 1
        GcObject obj2 = new GcObject(); //Step 2
        
        obj1.instance = obj2; //Step 3
        obj2.instance = obj1; //Step 4

        obj1 = null; //Step 5
        obj2 = null; //Step 6
    }
}
class GcObject{
    public Object instance = null;
}

如果采用引用计数算法,上述代码中obj1和obj2指向的对象已经不可能再被访问,彼此互相引用对方导致引用计数都不为0,最终无法被GC回收,而可达性算法能解决这个问题。
JVM之垃圾回收-垃圾收集算法_第2张图片

接下来进行分析为什么

使用引用计数算法

如果采用的是引用计数算法:

再回到前面代码GcDemo的main方法共分为6个步骤:

  1. Step1:GcObject实例1的引用计数加1,对象实例1的引用计数=1;
  2. Step2:GcObject实例2的引用计数加1,对象实例2的引用计数=1;
  3. Step3:GcObject实例2的引用计数再加1,对象实例2的引用计数=2;
  4. Step4:GcObject实例1的引用计数再加1,对象实例1的引用计数=2;
    执行到Step 4,则GcObject实例1和实例2的引用计数都等于2。
    如图,在虚拟机栈中有obj1和obj2对象的引用
    JVM之垃圾回收-垃圾收集算法_第3张图片

接下来继续
5. Step5:栈帧中obj1不再指向Java堆,GcObject实例1的引用计数减1,结果为1;
6. Step6:栈帧中obj2不再指向Java堆,GcObject实例2的引用计数减1,结果为1。
到此,发现GcObject实例1和实例2的计数引用都不为0,那么如果采用的引用计数算法的话,那么这两个实例所占的内存将得不到释放,这便产生了内存泄露。

使用可达性算法

看下面的这个图:
JVM之垃圾回收-垃圾收集算法_第4张图片

对象实例1、2、4、6都具有GC Roots可达性,也就是存活对象,不能被GC回收的对象。而对于对象实例3、5直接虽然连通,但并没有任何一个GC Roots与之相连,这便是GC Roots不可达的对象,这就是GC需要回收的垃圾对象。

回到前面的那个代码,当执行完Step 5和Step 6时,obj1和obj2作为GC Roots都指向null,对象实例1与对象实例2都脱离了GC Roots,没有GC Roots链指向,因此,从可达性算法来看,都是GC Roots不可达的对象。

所以,对于对象之间循环引用的情况,使用引用计数算法,GC是无法回收掉对象的,而使用可达性算法可以正确回收。(这也是为什么C++容易发生内存泄露,而Java不容易发生的原因)

Java引用的四种状态

JDK1.2以后,Java对于引用进行了扩充,将引用继续细分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)四种(引用强度逐渐减弱)

强引用(Strong Reference)

强引用一般指的就是new出来的对象(反射出来使用的对象也属于强引用),这是使用最普遍的引用。

只要某个对象有强引用与之关联,JVM必定不会回收这个对象,即使在内存不足的情况下,JVM宁愿抛出OutOfMemory错误也不会回收这种对象。

如果想中断强引用和某个对象之间的关联,可以显示地将引用赋值为null,这样一来的话,JVM在合适的时间就会回收该对象。

软引用(Soft Reference)

软引用是用来描述一些有用但并不是必需的对象,在Java中用java.lang.ref.SoftReference类来表示。对于软引用关联着的对象,只有在内存不足的时候JVM才会回收该对象。因此,这一点可以很好地用来解决OOM的问题,并且这个特性很适合用来实现缓存:比如网页缓存、图片缓存等。

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

如下面的代码使用 SoftReference 类来创建软引用。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联  

弱引用(Weak Reference)

弱引用也是用来描述非必需对象的,当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象(也就是说它只能存活到下一次垃圾收集发生之前)。在java中,用java.lang.ref.WeakReference类来表示。
使用 WeakReference 类来实现弱引用。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;  // 使对象只被弱引用关联  

java.util中的WeakHashMap通常用来实现缓存,该类中的Entry继承自WeakReference。

private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V>

Tomcat 中的 ConcurrentCache 就使用了 WeakHashMap 来实现缓存功能。ConcurrentCache 采取的是分代缓存,经常使用的对象放入 eden 中,而不常用的对象放入 longterm。eden 使用 ConcurrentHashMap 实现,longterm 使用 WeakHashMap,保证了不常使用的对象容易被回收。
源码如下,挺好理解

public final class ConcurrentCache<K, V> {

    private final int size;

    private final Map<K, V> eden;

    private final Map<K, V> longterm;

    public ConcurrentCache(int size) {
        this.size = size;
        this.eden = new ConcurrentHashMap<>(size);
        this.longterm = new WeakHashMap<>(size);
    }

    public V get(K k) {
        V v = this.eden.get(k);
        if (v == null) {
            v = this.longterm.get(k);
            if (v != null)
                this.eden.put(k, v);
        }
        return v;
    }

    public void put(K k, V v) {
        if (this.eden.size() >= size) {
            this.longterm.putAll(this.eden);
            this.eden.clear();
        }
        this.eden.put(k, v);
    }
}

虚引用(Phantom Reference)

又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对对象的生命周期构成影响,也无法通过虚引用取得一个对象实例。

为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
使用 PhantomReference 来实现虚引用。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null; //使该对象只有虚引用 

对于强引用,我们平时在编写代码时经常会用到。而对于其他三种类型的引用,使用得最多的就是软引用和弱引用,这2种既有相似之处又有区别。
它们都是用来描述非必需对象的,但是被软引用关联的对象只有在内存不足时才会被回收,而被弱引用关联的对象在JVM进行垃圾回收时总会被回收。

针对上面的特性,软引用适合用来进行缓存,当内存不够时能让JVM回收内存。
弱引用可以用来在回调函数中防止内存泄露。

因为回调函数往往是匿名内部类,隐式保存有对外部类的引用,所以如果回调函数是在另一个线程里面被回调,而这时如果需要回收外部类,那么就会内存泄露,因为匿名内部类保存有对外部类的强引用。

什么情况下回收对象

即使是在可行性分析算法中不可达的对象,也并不是立即回收的。至少要经历两次标志过程,才真正宣告该对象"死亡";

可达性分析算法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法。

当对象没有覆盖finalize方法,或finalize方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行finalize方法。被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

所以,一般情况下,不建议重写finalize方法。会影响对象回收的性能。

回收方法区

方法区(或Hotspot虚拟中的永久代)的垃圾收集主要回收两部分内容:废弃常量和无用的类。

回收废弃常量与回收Java堆中的对象非常相似。以常量池中字符串的回收为例,若字符串“abc”已经进入常量池中,但当前系统没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用该字面量,若发生内存回收,且必要的话,该“abc”就会被系统清理出常量池。常量池中其他的类(接口)、方法、字段的符号引用与此类似。

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面3个条件才能算是 “无用的类”:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  2. 加载该类的ClassLoader已经被回收。
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
    虚拟机可以对满足上述3个条件的无用类进行回收,此处仅仅是“可以”,而并不是和对象一样(不使用了就必然回收)

垃圾收集算法

标记-清除算法(Mark and Sweep)

算法分为“标记”和“清除”阶段。
网上有些文章是这样介绍的:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。(错误,颠倒了概念)

正确的概念是:首先从根开始将可能被引用的对象用递归的方式进行标记,然后将没有标记到的对象作为垃圾进行回收。

JVM之垃圾回收-垃圾收集算法_第5张图片
图一

图一显示了标记清除算法的大致原理。图中的(1)部分显示了随着程序的运行而分配出一些对象的状态,一个对象可以对其他的对象进行引用。

图中(2)部分中,GC开始执行,从根开始对可能被引用的对象打上“标记”。大多数情况下,这种标记是通过对象内部的标志(Flag)来实现的。

于是,被标记的对象我们把它们涂黑。图中(3)部分中,被标记的对象所能够引用的对象也被打上标记。

重复这一步骤的话,就可以将从根开始可能被间接引用到的对象全部打上标记。到此为止的操作,称为标记阶段(Mark phase)。

标记阶段完成时,被标记的对象就被视为“存活”对象。图1中的(4)部分中,将全部对象按顺序扫描一遍,将没有被标记的对象进行回收。这一操作被称为清除阶段(Sweep phase)。

在扫描的同时,还需要将存活对象的标记清除掉,以便为下一次GC操作做好准备。标记清除算法的处理时间,是和存活对象数与对象总数的总和相关的。

缺点

它是最基础的收集算法,但是会带来两个明显的问题;

  1. 标记和清除的过程效率不高(由于空闲区块是用链表实现,分块可能都不连续,每次分配都需要遍历空闲链表,极端情况是需要遍历整个链表的)
  2. 空间问题(标记清除后会产生大量不连续的碎片)
  3. 与写时复制技术不兼容

写时复制

写时复制(copy-on-write)是众多 UNIX 操作系统用到的内存优化的方法。比如在 Linux 系统中使用 fork() 函数复制进程时,大部分内存空间都不会被复制,只是复制进程,只有在内存中内容被改变时才会复制内存数据。
但是如果使用标记清除算法,这时内存会被设置标志位,就会频繁发生不应该发生的复制。

另外,关于标记清除的变形,还有一种叫做标记压缩(Mark and Compact)的算法,它不是将被标记的对象清除,而是将它们不断压缩。

复制收集算法(Copy and Collection)

标记清除算法有一个缺点,就是在分配了大量对象,并且其中只有一小部分存活的情况下,所消耗的时间会大大超过必要的值,这是因为在清除阶段还需要对大量死亡对象进行扫描。

复制收集(Copy and Collection)则试图克服这一缺点。在这种算法中,会将从根开始被引用的对象复制到另外的空间中,然后,再将复制的对象所能够引用的对象用递归的方式不断复制下去。

简单的说:它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
JVM之垃圾回收-垃圾收集算法_第6张图片
图二

图二中的(1)部分是GC开始前的内存状态,这和图一的(1)部分是一样的。

图二的(2)部分中,在旧对象所在的“旧空间”以外,再准备出一块“新空间”,并将可能从根被引用的对象复制到新空间中。
图中(3)部分中,从已经复制的对象开始,再将可以被引用的对象像一串糖葫芦一样复制到新空间中。复制完成之后,“死亡”对象就被留在了旧空间中。

图中(4)部分中,将旧空间废弃掉,就可以将死亡对象所占用的空间一口气全部释放出来,而没有必要再次扫描每个对象。下次GC的时候,现在的新空间也就变成了将来的旧空间。

通过图二我们可以发现,复制收集方式中,只存在相当于标记清除方式中的标记阶段。由于清除阶段中需要对现存的所有对象进行扫描,在存在大量对象,且其中大部分都即将死亡的情况下,全部扫描一遍的开销实在是不小。而在复制收集方式中,就不存在这样的开销。

但是,和标记相比,将对象复制一份所需要的开销则比较大,因此在“存活”对象比例较高的情况下,反而会比较不利(极端情况下,实际使用的内存效率只有50%)。

优点

1. 优秀的吞吐量

GC标记-清除算法消耗的吞吐量是搜索活动对象(标记阶段)所花费的时间和搜索整体堆(清除阶段)所花费的时间之和。

另一方面,因为GC复制算法只搜索并复制活动对象,所以跟一般的GC标记-清除算法相比,它能在短时间内完成GC,也就是说其吞吐量优秀。

尤其是堆越大,差距越明显。GC标记-清除算法在清除阶段所花费的时间会不断增加,但GC复制算法就不会。因为它消耗的时间是与活动对象的数量成比例的。

2.可实现高速分配

GC复制算法不使用空闲链表,因为分块是一块连续的内存空间。因此,调查这个分块的大小,只要这个分块大小不小于所申请的大小,那么移动指针就可以进行分配了。

比起GC标记-清除算法和引用计数算法等使用空闲链表的分配,GC复制算法明显快得多。使用空闲链表是为了找到满足要求的分块,需要遍历空闲链表,最坏的情况是我们不得不从空闲链表中取出最后一个分块,这样就用了大量时间把所有分块都调查一遍。

3.不会发生碎片化

基于算法性质,活动对象被集中安排在From空间的开头。像这样把对象重新集中,放在堆中一端的行为叫作压缩。在GC复制算法中,每次运行GC时都会执行压缩。

因此GC算法有个非常优秀的特点,就是不会发生碎片化,也就是说可以安排分块允许范围内大小的对象。

另一方面,在GC标记-清除算法等GC算法中,一旦安排了对象,原则上就不能再移动它了,所以会多多少少产生碎片化。

4.与缓存兼容

复制算法具有局部性(Lo-cality)。在复制收集过程中,会按照对象被引用的顺序将对象复制到新空间中。

于是,关系较近的对象被放在距离较近的内存空间中的可能性会提高,这被称为局部性。

在局部性高的情况下,内存缓存会更容易有效运作,程序的运行性能也能够得到提高。

缺点

1.堆使用率低下

GC复制算法把堆分成二等分,通常只能利用其中一半来安排对象。也就是说只有一半堆能被使用,相比其他能使用整个堆的GC算法而言,这是GC复制算法的一个重大缺陷。

详细介绍:
现在的商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块比较大的Eden Space(伊甸园)空间和两块较小的Survivor Space(幸存者区)空间,每次使用Eden和其中一块Survivor。

当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机(Java虚拟机的一个实现)默认Eden和Survivor的大小比例是8:1,也就是说,每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的空间会被浪费(也就是说,至少会浪费10%的空间)。

当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖于老年代进行分配担保,所以大对象直接进入老年代。

2.不兼容保守式GC算法

GC标记-清除算法有着跟保守式GC算法相兼容的优点。因为GC标记-清除算法不用移动对象。
另一方面,GC复制算法必须移动对象重写指针,所以有着跟保守式GC算法不相容的性质。虽然有限制条件,GC复制算法和保守式GC算法可以进行组合。

保守式GC

简单的说,就是不能识别指针和非指针的GC

把不能识别指针还是非指针的对象当做指针来保守处理,也就是当成活动对象保留下

3.递归调用函数

在算法中,复制某个对象时要递归复制它的子对象,因此在每次进行复制的时候都要调用函数,由此带来的额外负担不容忽视。比起递归算法,迭代算法更能有效地执行。
此外,因为在每次递归调用时都会消耗栈,所以还有栈溢出的可能。

实际应用

对账系统就非常适合使用复制收集算法进行垃圾回收,因为对象存活率不高,且在对账期间会产生大量的对象,也就是分配速率非常大,峰值几千MB/S

标记-整理算法

复制算法对于对象存活率很低的情况是高效的,但是当对象的存活率非常高时,就变得非常低效了。在老年代中,对象的存活率很高,所以不能使用复制算法。于是根据老年代的对象特点,提出了标记-整理(Mark-Compact)算法。
标记-整理算法也分为两个阶段:标记和整理。
第一个阶段与标记-清除算法一样:标记出所有可以被回收的对象。
第二个阶段不再是简单的清除无用对象的空间,而是将后面的活着的对象依次向前移动。将所有的活着的对象都移动成内存空间中前段连续一个区域,之后的连续的区域都是可分配的没有使用的内存空间。
如下图所示:
JVM之垃圾回收-垃圾收集算法_第7张图片

优点

不会产生内存碎片。

缺点

在标记的基础之上还需要进行对象的移动,成本相对较高,效率也不高。

垃圾收集算法比较

  1. 效率:复制算法 > 标记/整理算法 > 标记/清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
  2. 内存整齐度:复制算法=标记/整理算法>标记/清除算法。
  3. 内存利用率:标记/整理算法=标记/清除算法>复制算法。
    (>表示前者要优于后者,=表示两者效果一样)

注1:标记-整理算法不仅可以弥补标记-清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。
注2:可以看到标记/清除算法是比较落后的算法了,但是后两种算法却是在此基础上建立的。
注3:时间与空间不可兼得。

分代收集算法

当前商业虚拟机中一般采用“分代收集算法”。分代收集算法是根据对象的特点将内存空间分成不同的区域(即不同的代),对每个区域使用合适的收集算法。
在JVM中一般分为新生代和老年代,新生代中对象的存活率比较低,使用复制算法简单高效;在老年代中,由于对象的存活率较高,所以一般采用标记-整理算法。
简单的理解:

  1. 存活率低:少量对象存活,适合复制算法
  2. 存活率高:大量对象存活,适合用标记-清理/标记-整理算法
    注:老年代的对象中,有一小部分是因为在新生代回收时,老年代做担保,进来的对象;绝大部分对象是因为很多次GC都没有被回收掉而进入老年代。

小结

没有最好的算法,只有最适合的算法。
每一种算法的存在必然有其使用场景和适合的地方。

本系列中涉及到的所有相关名词, 我会专门整理出一篇文章作为说明与解释。
本篇主要是讲解基础知识,在下一篇将会讲解垃圾收集器(也就是垃圾收集算法的实际应用&实现)。

为什么会有本系列文章:
原本打算是直接出对账二期的文章,毕竟是千万级订单项目的实战与经验。但是由于近期会在部门内进行分享JVM的垃圾回收以及调优,所以该分享的准备资料我也就写成文章形式先进行分享了。后面抽时间再写对账二期的文章。

你可能感兴趣的:(❷,Java之行,性能优化)