JVM常见的几种GC算法

Posted by RussXia on July 25, 2019

JVM常见的几种GC算法

什么是垃圾回收(GC)

GC::Garbage Collection(垃圾收集)。JVM会自动对内存进行管理和垃圾清扫,这种行为称之为垃圾回收。

常见的垃圾回收算法有哪些

常见的垃圾回收算法有三种,它们各有优缺点:

  • 标记-复制:将内存划分为大小相等的两块,当一块用完后,将还存活的对象copy到另一块上
    • 优点:实现简单,效率高,不产生内存碎片
    • 缺点:内存空间利用率低
  • 标记-清理:先标记需要回收的对象,完成标记后,清除对象。
    • 优点:效率高,空间利用率高
    • 缺点:内存空间碎片化严重
  • 标记-整理:先标记需要会后的对象,完成标记后,清除对象,将存活的对象都想一端移动。(标记-清除+整理)
    • 优点:不会产生内存碎片
    • 缺点:效率低

对象存活分析

常见的对象存活分析有两种算法:引用计数法可达性分析算法

引用计数法

引用计数法的逻辑比较简单,对象维护一个counter计数器,如果有一个引用与之相连,则counter++。如果一个与之相连的引用失效了,则counter–。如果一个对象的counter为0,则表明这个对象已经被废弃了,可以被GC。

对于循环引用的两个对象A,B,引用计数法永远无法A,B对象。

可达性分析算法

所谓的可达性分析算法,就是通过一组GC Roots集合,或者说tracing GC的“根集合”,就是一组必须活跃的引用 作为起点,通过引用关系遍历对象图,能被遍历到的对象就判定为存活的,其余的对象判定为死亡。

常见的可以作为GC Roots引用的有:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象。

R大的关于分代和GC Roots知乎文章

强/软/弱/虚引用

从JDK1.2开始,对象的引用被分为四个级别,从而使JVM可以更好地控制对象的生命周期。这四个引用级别就是:强引用软引用弱引用虚引用

强引用(Strong Reference)

强引用可以直接访问目标对象。如果一个对象具有强引用,那垃圾回收器绝不会回收它。JVM宁愿抛出OOM异常也不会回收这一类对象。

显示地将对象引用置为null,或者超出对象引用的使用范围(比如方法出栈,方法内部的强引用,是保存在Java栈中的)。

java.util.ArrayList#clear方法的含义,其实就是保留了容器,但是将里面的元素全部置为null,方便gc。

public void clear() {
    modCount++;

    // clear to let GC do its work
    for (int i = 0; i < size; i++)
        elementData[i] = null;

    size = 0;
}

软引用(SoftReference)

对于软引用对象,如果JVM内存空间充足,JVM就不会GC它;如果内存空间不足,就会酌情回收这些对象。软引用的这一特性很适合用来做内存缓存。

SoftReference<String> str = new SoftReference<String>("Hello World");
System.out.println(str.get());

弱引用(WeakReference)

弱引用和软引用区别在于,弱引用的对象拥有更短暂的生命周期,当JVM在GC时扫描到只具有弱引用的对象时,无论当前内存空间是否足够,都会回收它。常见的使用WeakReference的有:ThreadLocal,WeakHashMap

ThreadLocal中,内部使用了ThreadLocalMap来保存key-vlaue,这个map使用的entry定义如下:

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

key是ThreadLocal本身,使用的WeakReference。value对应具体要保存的值,是强引用。

一般来说,ThreadLocal是静态变量,其内存模型大致如下: static变量ThreadLocal内存模型

ThreadLocal JDK1.6中文版API

ThreadLocal 实例通常是类中的private static字段,它们希望将状态与某一个线程>(例如,用户 ID 或事务 ID)相关联。 关于ThreadLocal,ThreadLocal并不是用来解决多线程共享变量的问题,它们希望将状态与某一个线程相关联。

ThreadLocalMap维护 ThreadLocal 变量与具体value的映射,由于key是WeakReference的,所以Entry中的key可以被回收,是null的。这样一来,就无法访问对应的value了。但是只要当前的线程没有退出,就存在一条Thread Ref->Thread->ThreadLocalMap->Entry->value的强引用链,所以value无法被GC。只有当线程退出,Current Thread Ref不在栈中,value的强引用断开,value才会被正确GC。

所以关于ThreadLocal的内存泄漏,其实是对于ThreadLocal的使用不当。使用ThreadLocal时,避免内存泄漏,可以注意两点:

  • static
  • 手动的remove

WeakHashMap的设计和ThreadLocal类似,不过,WeakHashMap会在读和写操作之前,调用java.util.WeakHashMap#expungeStaleEntries方法,遍历整个ReferenceQueue来除去value上的强引用(e.value = null).

虚引用(PhantomReference)

虚引用并不会影响对象的生命周期,如果一个对象仅持有虚引用,那么和没有任何引用一样,随时可以被GC。

虚引用对象,在回收器确定其指示对象可另外回收之后,被加入队列。虚引用最常见的用法是以某种可能比使用 Java 终结机制更灵活的方式来指派 pre-mortem 清除动作。

java.lang.ref.PhantomReference#get方法及其注释。

/**
    * Returns this reference object's referent.  Because the referent of a
    * phantom reference is always inaccessible, this method always returns
    * <code>null</code>.
    *
    * @return  <code>null</code>
    */
public T get() {
    return null;
}

引用队列(ReferenceQueue)

引用队列,在检测到适当的可到达性更改后,垃圾回收器将已注册的引用对象添加到该队列中。

有了上述的四种引用类型后,还需要一个引用队列来配合使用。在检测到适当的可到达性更改后,垃圾回收器将已注册的引用对象添加到该队列中。(性质更类似与监听器?)

ReferenceQueue其实就是一个简单的队列,主要提供了enqueuepoll两个操作。

Reference是引用对象的抽象基类。Reference有四种状态:

  • Active:新建实例的状态,当GC检查到reference的可达性变更了,GC会”通知”当前引用实例改变其状态为”pending”或者”inactive”。
  • Pending:当前的引用在pending-Reference列表中,等待ReferenceHandler线程处理
  • Enqueued:当前的引用实例已经被添加到ReferenceQueue中,但是尚未移除。
  • Inactive:当前的引用实例已被从ReferenceQueue中移除或者并没有注册过Queue的直接被回收。

Reference的四种状态的转变

Guava中的内存缓存-LoadingCache

Guava中的内存缓存LoadingCache就使用了Reference的特性。

LoadingCache<Object, Object> cache = CacheBuilder.newBuilder()
        .maximumSize(5)
        .expireAfterWrite(5, TimeUnit.SECONDS)
        .weakKeys()
        .weakValues()
        //.softValues()
        .build(new CacheLoader<Object, Object>() {
            @Override
            public Object load(Object key) {
                System.out.println("load key " + key);
                return "hello " + key + "!";
            }
        });

为什么抛弃了softKeys()可以参考Why is softKeys() deprecated in Guava 10?

JVM中的分代收集

Java应用中,大部分的对象都是’朝生暮死’。不同阶段适合使用不同的GC算法进行GC回收。分代收集就是基于这种思想。

以hotspot为例,JVM将堆分为 新生代(Young),老年代(Old),永久代(Permanent),并从JDK1.7开始,去掉永久代,将常量池等以前存放在永久代的内容从永久代中移出。并在JDK1.8中完全去掉了永久代,取而代之的是元空间(Metaspace),元空间使用的是本地内存,Metaspace的大小仅受限于native memory的剩余大小。 hotspot中的内存分代

对传统的、基本的GC实现来说,在收集的工程中,无可避免的要stop-the-world,现代的各种收集器其实都是在致力于低停顿高并发的进行GC。

Safepoint

如果要触发GC,那么JVM中的所有Java线程必须达到GC SafePoint。

程序执行时并非在所有的地方都能停顿下来开始 GC,只有在到达安全点时才能暂停。 Safepoint 的选定既不能太少以至于让 GC 等待时间太长,也不能过于频繁以至于过分增大运行时的负荷。 所以安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行。 “长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生 Safepoint。

周志明-JVM 内存回收理论与实现

https://www.sczyh30.com/posts/Java/jvm-gc-safepoint-condition/

http://xiao-feng.blogspot.com/2008/01/gc-safe-point-and-safe-region.html

新生代垃圾收集器

SerialNew(串行)收集器

最早最基本的收集器,单线程收集器,在进行垃圾回收的时候,需要进行STW(Stop The World) SerialNew(串行)收集器

ParNew(并行)收集器

SerialNew的多线程版本,在单核cpu场景下,效率远远低于SerialNew。目前只有它能与cms配合工作。 ParNew(并行)收集器

Parallel Scavenge(并行)收集器 并行的多线程收集器,与ParNew类似,也采用的复制算法。但是和ParNew不同的是,Parallel Scavenge关注的是更高的吞吐量。

老年代垃圾收集器

SerialOld收集器

Serial收集器的老年代版本,单线程收集器,采用标记-整理算法。 SerialOld收集器

Parallel Old收集器

parallel scavenger的老年代版本,多线程收集器,采用标记整理算法 Parallel Old收集器

CMS收集器

Concurrent Mark Swap,以获取最短回收停顿时间为目标。基于标记-清除算法实现。 主要流程分为:

  • 初始标记(STW):从GCRoots开始,只扫描能之间关联GCRoots的对象,速度很快
  • 并发标记:和用户线程并发执行,在初始标记的基础上向下追溯。
  • 并发预清理(可关闭):查找在并发标记过程中新进入老年代的对象,减少下一阶段”重新标记”的工作
  • 重新标记(STW):扫描CMS堆中的剩余对象,STW
  • 并发清理 :清理垃圾对象,和用户线程并发执行
  • 并发重置:重置CMS收集器的数据结构,等待下一次GC。 CMS收集器 CMS算法的缺点:
  • 基于标记-清扫算法,不会整理,压缩空间,容易产生内纯碎片,需要定期重启。
  • 对cpu资源敏感,并发阶段,用户线程和gc线程并行,总吞吐量会降低。
  • 无法处理浮动垃圾,在cms并发清理时,用户线程新产生的垃圾,只能留待下次gc时处理。

混合的垃圾收集器-G1

美团技术团队关于G1垃圾收集器的文章

|ParNew|

拓展:ZGC

关于CMS GC日志和 G1 GC日志的分析