详解JVM内存管理与垃圾回收机制5 - Java中的4种引用类型

2018-10-17 12:53:00 275

在Java语言中,除了基础数据类型的变量以外,其他的都是引用类型,指向各种不同的对象。在前文我们也已经知道,Java中的引用可以是认为对指针的封装,这个指针中存储的值代表的是另外一块内存的起始地址(对象所在内存的首地址)。在这种定义下,一个对象只有被引用和没有被引用两种状态,当对象没有被引用的时候,即被JVM回收,但这种设计并不能满足所有的应用场景,比如,缓存:在内存还足够时,希望这些对象一直保持在内存中,而如果内存在GC后还是非常紧张,则可以丢弃某些对象。因此,在JDK1.2以后,Java对引用的概念进行扩充,将引用分为:强引用、软引用、弱引用和虚引用4种。如何理解这4种引用类型,它们之间有何区别?具体的应用场景有哪些?

对象的生命周期

1.1 Finalizer机制

在Java中,当对象处于没有被引用的状态下,一段时间后,其内存将被垃圾收集器回收。然而,内存并不是唯一需要清理并回收的资源。比如,当创建一个FileOutputStream对象时,会从操作系统分配一个文件句柄,用于文件操作 (文件句柄属于OS资源)。当这个流不在被引用且即将关闭时,这个文件句柄会发生什么?答案就在finalize()中,这个方法会在垃圾收集器回收对象之前由JVM来调用。在FileOutputStream对象中,finalize()会关闭文件输出流、释放句柄、刷新缓冲区以确保所有的数据被正确的写入磁盘文件。

关于文件句柄的相关内容可参考附录的资料1

所有的对象都可以有一个finalizer,你仅需要在对象中定义如下的finalize方法:


protected void finalize() throws IOException { 
    // 清理垃圾、释放资源的代码写在这儿 
} 


在日常开发中,很多地方都需要做一些清理的工作,finalize()看起来是一个不错的方法,但我们却很少使用它,甚至在业界,finalize()也被证明是一种非常不好的实践,主要是因为:

  • 不能控制finalize()的调用时机,甚至很多时候,JVM根本就不会调用这个方法。虽然可以通过调用System.runFinalization()告诉JVM更积极地执行finalize方法,但调用时机仍是不可预测的。
  • finalize()方法的执行和GC关联在一起,一旦实现了非空的finalize方法,JVM就要对它进行额外的处理,这样它就变成了快速回收的阻碍者,有可能导致对象需要经过多次GC才能被回收。
  • 一般来说,需要执行清理工作的对象都是消耗资源的大户,而finalize拖慢垃圾回收,严重的情况下会导致OOM。

1.2 对象的可达性

一个对象的生命周期可以简单的概括为:对象首先被创建,然后初始化,被使用,接着没有其他对象引用它(不可达),这时候,它可以被垃圾收集器回收,最终它被回收掉。简单的示意图如下所示,其中,阴影部分便是该对象的强可达 阶段。

简单对象的生命周期

从JDK1.2起,Java对对象的生命周期进行了扩充,除了强可达阶段 (如上图所示),另增加了3个新阶段:软可达弱可达虚可达(幻象可达)

当一个对象可以通过普通引用链 (不包括引用对象) 从根集访问时,则表示该对象强可达。换一种说法,强可达对象可以不通过各种引用直接访问到。延伸开来,如果达到对象的唯一路径上涉及至少一个软弱引用对象,则称该对象是软弱可达。最后,虚可达对象就是没有强、软、弱引用关联,并且 finalize 过了,只有幻象引用指向它的这类对象。这个定义稍微有点绕,下面的示意图应该可以帮助你理解。

img_9269673d63923df761156af9e4bc6e2f.png

图中A对象,虽然它被弱引用对象B引用,但由于根集直接引用它,因此根集到A对象强可达,同样的根集到对象B、C也是强可达。而对象D、E直接或间接地仅被弱引用对象引用,因此根集到D、E弱可达。


对象的几种可达性状态可以按照如下示意图进行流转,可以看到,图中有些地方是双向箭头,这意味着,我们可以人为地改变对象的可达性状态。具体的原因,我们会在下文详细说明。

对象可达性转换示意图

4种引用类型及其应用场景

前面我们已经提到的这4种引用类型之间的区别主要体现在对象的不同可达性级别以及对垃圾收集器的影响。因而,引用可以认为是Java提供的一种人为干预GC的手段之一。

2.1 强引用 (Strong Reference)

所谓强引用,就是我们最常见的普通对象引用,类似Object obj = new Object()这类的引用,只要还有强引用指向一个对象,垃圾收集器永远不会回收这个对象。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应的(强)引用赋值为 null,就是可以被JVM回收的,当然具体回收时机还是要看垃圾收集策略。来看一个简单的例子:

Class StrongReferenceObject {
    // code here
}

public Class Example {
    public static void main(String[] args) {
        StrongReferenceObject obj = new StrongReferenceObject();
        // obj指向的对象已经可以被GC回收
        obj = null;
    }
}

代码中obj为强引用,指向堆中一个StrongReferenceObject类型的实例对象,当给obj赋值为null以后,让这个强引用指向null,那么原来堆中的实例对象即可以被GC回收,简单的内存示意图如下所示。

强引用对象内存示意图

有些优化建议:不使用的对象应手动赋值为null,有利于GC更早回收内存,减少内存占用。


现在我们来讨论下,这条优化建议是否合理?首先,请思考两个问题,内存回收的早晚,对应用的影响很大吗?即使赋值为null,内存就立即被回收了吗?其次,强引用obj是在栈空间中分配内存,当方法执行完成后,栈帧弹出,所占用的内存被回收,这时候已经没有引用指向堆中的对象。综上,绝大多数时候方法的执行都耗时很短,因此赋值为null到底又能够把GC回收的时间点提前多少呢?相信大家会有自己的答案,当然,这里并不是说这条建议毫无意义,只是希望大家可以思考其应用场景,更不用把它当做一条普遍适应的优化建议。

2.2 软引用 (Soft Reference)

软引用一般用于描述一些有用但非必需的对象,它相对强引用来说,引用的关系更弱一些。当JVM认为内存不足时,才会去尝试回收软引用指向的对象,如果回收以后,还没有足够的内存,才会抛出内存溢出错误。因此,JVM会确保在抛出内存溢出错误之前,回收软引用指向的对象。软引用通常用来实现内存敏感的缓存,当有足够内存时,保留缓存,反之则清理掉部分缓存,这样在使用缓存的同时,尽量避免耗尽内存。

同样地,从一个简单的示例着手:

class SoftRefObject { 
    public void m() { 
        System.out.println("I'm Soft Reference Object"); 
    } 
} 
  
public class Example { 
    public static void main(String[] args) { 
        // 强引用
        SoftRefObject obj = new SoftRefObject();    
        obj.m(); 
          
        // 创建一个软引用指向SoftRefObject类型的实例对象'obj'
        SoftReference<SoftRefObject> softRef = new SoftReference<SoftRefObject>(obj); 
          
        // 去掉 SoftRefObject 对象上面的强引用,这时,对象可以被回收
        obj = null;  
          
        // 返回弱引用指向的对象,对象的可达性状态发生改变
        obj = softRef.get(); 
        if (obj != null) {
            obj.m();
        }
    } 
} 

在内存足够的情况下,以上程序将输出:

I'm Soft Reference Object
I'm Soft Reference Object

来看代码,首先创建一个强引用obj指向堆中一个SoftRefObject实例对象,然后我们创建一个软引用,软引用中的referent指向堆中的SoftRefObject实例,其内存结构示意图如下图的上半部分。当我们去掉强引用时,这时候obj指向的对象是可以被内存回收的,当内存充足时,我们可以通过get()方法,得到堆中的实例对象,其内存示意图如下图中的下半部分。

软引用的内存示意图

所有引用类型,都是抽象类java.lang.ref.Reference的子类,它提供了get() 方法,其部分代码如下所示。其中referent指向具体的实例对象,因此,如果referent指向的对象还没有被回收,都可以通过 get 方法获取原有对象。这意味着,利用软引用 (弱引用也类似,下文不再说明),我们可以将访问到的对象,重新指向强引用(obj=softRef.get()),也就是人为的改变了对象的可达性状态。

public abstract class Reference<T> {
    private T referent;

    public T get() {
        return this.referent;
    }
}

当软引用对象被JVM标记为可回收状态时,仍然可以通过get方法,让其重新被强引用关联,这时候就需要JVM进行第二次确认,以确保正在使用的对象不会被回收,这也是部分对象真正死亡至少需要经历两次标记的原因 ( 相关内容在详解JVM内存管理与垃圾回收机制2 - 何为垃圾 - 简书 )。

2.3 软引用的内存回收策略

软引用的内存结构示意图如下所示。这种情况下,虽然堆中对应的实例对象已经没有强引用指向它,但softRef作为强引用指向referent,而referent则指向SoftRefObject type object,看起来堆中的对象还是被强引用关联着,JVM到底是如何回收这部分内存的呢?

软引用可被回收时内存示意图

JVM在进行垃圾回收的时候,首先会遍历引用列表,判断列表中每个软引用中的referent是否存活 (存活的条件是referent指向的对象不为空且被GC Roots可达),如果对象还活着,不进行任何处理,如果对象已死,则尝试回收该对象。这里还有一个疑问,通过前面我们知道,软引用会在内存不足时被回收,那JVM是如何判断内存是否充足,其标准是什么?


JVM在回收软引用的时候使用了两种不同的策略,具体我们来看下源码:

// 源码来源于openjdk-jdk8u: hotspot/src/share/memory/referenceProcessor.cpp
// 软引用回收策略的选择:调用此方法时,always_clear = false
ReferencePolicy* setup_policy(bool always_clear) {
    _current_soft_ref_policy = always_clear ? _always_clear_soft_ref_policy : _default_soft_ref_policy;
    _current_soft_ref_policy->setup();
    return _current_soft_ref_policy;
}
// 默认策略的定义:在 LRUMaxHeapPolicy 和 LRUCurrentHeapPolicy 两种策略中任选其一
// openJdk中对Compiler1和Compiler12作了解释,简单的说:
// Compiler1对应Client JVM
// Compiler2对应Server JVM
_default_soft_ref_policy = new COMPILER2_PRESENT(LRUMaxHeapPolicy())
                           NOT_COMPILER2(LRUCurrentHeapPolicy());


简单说来,在Client VM模式下,使用LRUCurrentHeapPolicy策略,而Server VM模式下使用LRUMaxHeapPolicy策略,这两种策略有何区别?注意上面的源码,在setup_policy函数中,调用了不同策略的setup函数,接下来,重点关注这两种策略的setup函数:


void LRUMaxHeapPolicy::setup() {
    // 设置的最大堆内存
    size_t max_heap = MaxHeapSize;
    // 最大堆内存 - 上次GC后已使用的堆内存
    max_heap -= Universe::get_heap_used_at_last_gc();
    // 单位换算 = ?M
    max_heap /= M;
    // _max_interval = max_heap * (VM参数:-XX:SoftRefLRUPolicyMSPerMB的值)
    _max_interval = max_heap * SoftRefLRUPolicyMSPerMB;
    assert(_max_interval >= 0,"Sanity check");
}

void LRUCurrentHeapPolicy::setup() {
    // 上次GC后空闲堆内存或者说当前可用的堆内存(单位换算) * (VM参数:-XX:SoftRefLRUPolicyMSPerMB的值)
    _max_interval = (Universe::get_heap_free_at_last_gc() / M) * SoftRefLRUPolicyMSPerMB;
    assert(_max_interval >= 0,"Sanity check");
}


通过代码中的注释,应该能够很清楚的理解,两种不同策略的区别,而计算得到的_max_interval有什么作用?我们再看:


should_clear_reference(oop p, jlong timestamp_clock) {
    // java_lang_ref_SoftReference返回该引用对象上次执行get方法的时间点,
    // 如果没有执行过get方法,就是初始化的时间点
    jlong interval = timestamp_clock - java_lang_ref_SoftReference::timestamp(p);
    if(interval <= _max_interval) {
        return false;
    }
    return true;
}


两种策略均包含这个函数,它用于判断是否需要清理引用对象:interval大于_max_interval,就回收该对象。现在就剩下一个问题了,interval表示什么,如何得来的?再回顾一下SoftReference类的定义,重点关注clocktimestamp两个属性。


public class SoftReference<T> extends Reference<T> {
    // JVM在每次GC时会更新这个值
    static private long clock; 
    private long timestamp;

    public T get() {
        T o = super.get();
        if (o != null && this.timestamp != clock)
            this.timestamp = clock;
        return o;
    }
}


JVM在每次GC时会更新clock,而在调用get方式会更新timestamp的值为clock,这两者之差,就是这个软引用对象距离上次GC时一直没有被使用的时间,即上文中的interval(时间间隔),如果这个时间间隔大于_max_interval,说明这个软引用已经被废弃足够长的时间,认为是可以被回收的,这也跟策略名称中的LRU相吻合。


最后总结下,-XX:SoftRefLRUPolicyMSPerMB可以影响软引用的存活时间,在其他因素不变的情况下,VM参数的值越大,软引用对象存活越久,同样地,如果应用已使用堆内存不变的情况下,设置的堆内存越大,软引用对象也存活的更久。

2.3 软引用的应用场景

前面我们提到过,可以利用软引用来实现缓存,比如一些图片缓存框架中,均大量使用到软引用。由于软引用和弱引用均可以应用在缓存的实现,因而,具体的实现原理我放在下文弱引用的部分详细说明。这里我们介绍软引用的另外一个应用场景:内存熔断。


熔断机制来源于电力行业,当电流超过规定值时,产生的热量使溶体熔断,断开电路以达到保护电路的目的。在分布式系统中也大量运用熔断机制,以实现快速失败,防止服务间调用的雪崩效应。而这里所讲的内存熔断也类似,当应用大量使用内存时,容易造成内存溢出错误,甚至程序崩溃,这种情况下,可以使用软引用来避免OutOfMemoryError,以实现自我保护的目的。


回想刚开始学习JDBC的时候,你一定写过如下代码,它使用一个通用的方法处理ResultSet并返回一个List<Map>


// 去掉资源关闭,异常处理等细节
public static List<Map<String, Object>> processResults(ResultSet rs) {
    List<Map<String, Object>> list = Lists.newArrayList(); 
    ResultSetMetaData meta = rs.getMetaData(); 
    int colCount = meta.getColumnCount(); 
    while (rs.next()) { 
        Map<String, Object> map = new HashMap<String, Object>(); 
        // 每行数据放入一个map中
        for (int i = 0; i < colCount; i++) { 
            map.put(meta.getColumnName(i), rs.getObject(i)); 
        } 
        list.add(map); 
    } 
}


这段代码在大部分情况下,都能很好的运行,但它有一个小的缺陷:如果查询返回一百万行而你没有可用内存来存储它们会发生什么? 现在使用软引用在完善上面这段代码:


// 去掉资源关闭,异常处理等细节
public static List<Map<String, Object>> processResults(ResultSet rs)  { 
    ResultSetMetaData meta = rs.getMetaData(); 
    int colCount = meta.getColumnCount(); 
    // 软引用指向最终返回的List
    SoftReference<List<Map<String, Object>>> ref = new SoftReference<>(new LinkedList<Map<String, Object>>()); 
    while (rs.next()) { 
        Map<String, Object> map = new HashMap<>(); 
        for (int i = 0; i < colCount; i++) { 
            map.put(meta.getColumnName(i), rs.getObject(i)); 
        } 
        // 如果List已经被回收,那么说明内存不足,直接返回自定义的异常通知上层服务
        List<Map<String, Object>> result = ref.get(); 
        if (result == null) { 
            throw new TooManyResultsException(); 
        } else { 
            result.add(map); 
        } 
    } 
    return ref.get(); 
} 


需要注意的是,ResultSet并不会直接获取所有的查询结果,一般会通过fetchSize方法来设置每次返回的行数。


而整个过程中,内存分配都集中在两个地方:调用next()和将行数据存储在自己的列表中。调用next()时,ResultSet通常会检索包含多行数据库数据的大块二进制数据,以判断数据是否取完。当需要存储数据时,调用getObject()方法提取数据并包装成Java对象,然后在扔到列表中。


当进行这些操作的时候,如果发现内存不足,GC会回收到列表占用的内存,这时候在通过软引用获取列表对象,得到的就是null。当上层得到自定义的异常时,可以进行相关的处理:再次检索或者减少获取数据的行数。需要注意的是,这里的列表使用的是LinkedList而不是ArrayList,这是因为ArrayList在扩容的时候会创建新的数组,占用更多的内存。


最后,这仅是一个示例而已,提供另外一个应用场景和解决问题的思路,并不是建议大家在操作JDBC时要使用软引用。

2.4 弱引用 (Weak Reference)

弱引用的强度比软引用更弱一些,当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。它一般用于维护一种非强制的映射关系,如果获取的对象还在,就是用它,否则就重新实例化,因此,很多缓存框架均基于它来实现。


弱引用的内存结果示意图与软引用类似,这里就不在使用示例代码来说明,具体可以参考前面的内容。

2.5 弱引用的应用场景

弱引用的一个应用场景就是缓存,经常使用的数据结构为WeakHashMap。WeakHashMap与其他Map最主要的区别在于它的Key是弱引用类型,每次GC时,WeakHashMap的Key均会被回收,而后,其Value也会被回收,简单的看下其具体的实现:

// 构造方法
public WeakHashMap(int initialCapacity, float loadFactor) {
    table = newTable(capacity);
}
// newTable的实现
private Entry<K,V>[] newTable(int n) {
    return (Entry<K,V>[]) new Entry<?,?>[n];
}

private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
    V value;
    final int hash;
    Entry<K,V> next;

    Entry(Object key, V value, ReferenceQueue<Object> queue, int hash, Entry<K,V> next) {
        // 调用WeakReference的构造方法,并传入Map的Key和引用队列
        super(key, queue);
        this.value = value;
        this.hash  = hash;
        this.next  = next;
    }
    // other code here ...
}


通过源码可以知道,在构造WeakHashMap的Entry时,会将key关联到一个弱引用上,GC发生时,Map的Key会被清理掉,但Map的Value仍然是强引用,它会在WeakHashMap的expungeStaleEntries()方法中被移除数组(Entry[]),这时Value关联的强应用被干掉,即处于可回收状态。

由于本文不是专门分析WeakHashMap源码的文章,因此,对于WeakHashMap的实现,点到即止,在这儿只需要理解WeakHashMap的Key会在GC时被回收,进而回收其对应的Value。关于WeakHashMap源码的文章,大家可以自行搜索,网上已经有很多啦,这里推荐: Java WeakHashMap 源码解析,推荐它,主要是因为文章对为什么要使用引用队列ReferenceQueue讲得很透彻。

可能大家已经想到了,WeakHashMap中缓存的数据其实活不了多久的,特别是GC非常频繁的场景下,没两下,缓存的数据就没了,又得重新加载,那它作为缓存的意义何在?确实,单纯的作为缓存的话,如果有SoftHashMap貌似更合适一下,毕竟当内存够用时,并不希望缓存被GC掉。其实,WeakHashMap应用在更为复杂的场景,比如下面的代码:


// 代码来自于:org.apache.tomcat.util.collections.ConcurrentCache.java
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);
    }
    // 先从ConcurrentHashMap中取值,取不到就在WeakHashMap中取值
    public V get(K k) {
        V v = this.eden.get(k);
        if (v == null) {
            synchronized (longterm) {
                v = this.longterm.get(k);
            }
            // 如果在WeakHashMap取到值以后,在放入ConcurrentHashMap中
            if (v != null) {
                this.eden.put(k, v);
            }
        }
        return v;
    }
    // 如果ConcurrentHashMap已满,则把所有的数据放到WeakHashMap中,并清空自己
    public void put(K k, V v) {
        if (this.eden.size() >= size) {
            synchronized (longterm) {
                this.longterm.putAll(this.eden);
            }
            this.eden.clear();
        }
        // 如果ConcurrentHashMap未满,直接放入ConcurrentHashMap中
        this.eden.put(k, v);
    }
}


你看懂这段代码了吗?是不是跟JVM堆的划分有一点点相似?这其实是一个热点缓存的实现方案,一段时间以后,经常使用的缓存就会在eden这个Map中,而不常用的缓存就会逐渐被清理。在以前,如果让你来设计热点缓存的实现方案,可能会想很多方案,但不可避免的,会写非常多的代码,但使用WeakHashMap,就变得非常的简单了。


WeakHashMap的另外一个应用场景就是ThreadLocal,考虑篇幅这里就不再讲解,大家可以参考:这才是 Thread Local 的正确原理与适用场景。但文中没有考虑到的一个场景:在线程池下的ThreadLocal确实存在内存泄漏,供大家思考。

2.6 虚引用 (Phantom Reference)

虚引用也被称为幽灵引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。也就是说,通过其get()方法得到的对象永远是null。


那虚引用到底有什么作用?其实虚引用主要被用来跟踪对象被垃圾回收的状态,当目标对象被回收之前,它的引用会被放入一个ReferenceQueue对象中,通过查看引用队列中是否包含对象所对应的虚引用来判断它是否即将被垃圾回收,从而采取行动。因此,在是创建虚引用的时候,它必须传入一个 ReferenceQueue 对象,比如:


public static void main(String[] args) {
    ReferenceQueue<String> refQueue = new ReferenceQueue<String>();
    PhantomReference<String> referent = new PhantomReference<String>(new String("CSC"), refQueue);
    // null
    System.out.println(referent.get());

    System.gc();
    System.runFinalization();
    //true
    System.out.println(refQueue.poll() == referent);
}


另外值得注意的是,其实 SoftReference, WeakReference 以及 PhantomReference 的构造函数都可以接收一个 ReferenceQueue 对象。当 SoftReference 以及 WeakReference 被清空的同时,也就是 Java 垃圾回收器准备对它们所指向的对象进行回收时,调用对象的 finalize() 方法之前,它们自身会被加入到这个 ReferenceQueue 对象中,此时可以通过 ReferenceQueue 的 poll() 方法取到它们。而 PhantomReference 只有当 Java 垃圾回收器对其所指向的对象真正进行回收时,会将其加入到这个 ReferenceQueue 对象中,这样就可以追综对象的销毁情况。

总结

限于篇幅和侧重点的原因,本文在介绍应用场景的时候,主要着眼于SoftReferenceWeakReference,而对于虚引用的应用场景并未作过多的说明,如果你感兴趣的话,可以阅读Java Reference Objects 的最后一个小节,它使用虚引用定制了一个资源收集器用于在释放数据库连接的同时,释放占用的资源。


关于引用,还有一个重要的知识点没有涉及,就是引用队列ReferenceQueue,在参考资料的文章中有更详细的讲解,如果我来写的话,也不见得比他们好,所以就省略。


最后,整个文章的基本逻辑还是挺清楚的,但后期校对的时候,发现有遗漏某些知识点,就直接补上了,所以,如果读起来有感觉到别扭的话,请见谅。


最后的最后,下一篇会说说GC Roots,感兴趣的同学可以继续关注。也顺便撘一句,可以直接关注我的微信公众号:「一纸代码」,第一时间阅读最新的文章。

参考资料

  1. Linux文件句柄的这些技术内幕,只有1%的人知道
  2. Java Reference Objects
  3. Types of References in Java
  4. Java软引用究竟什么时候被回收

java jvm 内存管理 string

作者

迷失的月亮
TA的文章

相关文章