写点什么

Java Reference 核心原理分析

用户头像
AI乔治
关注
发布于: 2020 年 10 月 20 日
Java Reference核心原理分析

带着问题,看源码针对性会更强一点、印象会更深刻、并且效果也会更好。所以我先卖个关子,提两个问题(没准下次跳槽时就被问到)。

  • 我们可以用 ByteBuffer 的 allocateDirect 方法,申请一块堆外内存创建一个 DirectByteBuffer 对象,然后利用它去操作堆外内存。这些申请完的堆外内存,我们可以回收吗?可以的话是通过什么样的机制回收的?

  • 大家应该都知道 WeakHashMap 可以用来实现内存相对敏感的本地缓存,为什么 WeakHashMap 合适这种业务场景,其内部实现会做什么特殊处理呢?

GC 可到达性与 JDK 中 Reference 类型

上面提到的两个问题,其答案都在 JDK 的 Reference 里面。JDK 早期版本中并没有 Reference 相关的类,这导致对象被 GC 回收后如果想做一些额外的清理工作(比如 socket、堆外内存等)是无法实现的,同样如果想要根据堆内存的实际使用情况决定要不要去清理一些内存敏感的对象也是法实现的。为此 JDK1.2 中引入的 Reference 相关的类,即今天要介绍的 Reference、SoftReference、WeakReference、PhantomReference,还有与之相关的 Cleaner、ReferenceQueue、ReferenceHandler 等。与 Reference 相关核心类基本都在 java.lang.ref 包下面。其类关系如下:



其中,SoftReference 代表软引用对象,垃圾回收器会根据内存需求酌情回收软引用指向的对象。普通的 GC 并不会回收软引用,只有在即将 OOM 的时候(也就是最后一次 Full GC)如果被引用的对象只有 SoftReference 指向的引用,才会回收。WeakReference 代表弱引用对象,当发生 GC 时,如果被引用的对象只有 WeakReference 指向的引用,就会被回收。PhantomReference 代表虚引用对象(也有叫幻象引用的,个人认为还是虚引用更加贴切),其是一种特殊的引用类型,不能通过虚引用获取到其关联的对象,但当 GC 时如果其引用的对象被回收,这个事件程序可以感知,这样我们可以做相应的处理。最后就是最常见强引用对象,也就是通常我们 new 出来的对象。在继续介绍 Reference 相关类的源码前,先来简单的看一下 GC 如何决定一个对象是否可被回收。其基本思路是从 GC Root 开始向下搜索,如果对象与 GC Root 之间存在引用链,则对象是可达的,GC 会根据是否可到达与可到达性决定对象是否可以被回收。而对象的可达性与引用类型密切相关,对象的可到达性可分为 5 种。

强可到达,如果从 GC Root 搜索后,发现对象与 GC Root 之间存在强引用链则为强可到达。强引用链即有强引用对象,引用了该对象。

软可到达,如果从 GC Root 搜索后,发现对象与 GC Root 之间不存在强引用链,但存在软引用链,则为软可到达。软引用链即有软引用对象,引用了该对象。

弱可到达,如果从 GC Root 搜索后,发现对象与 GC Root 之间不存在强引用链与软引用链,但有弱引用链,则为弱可到达。弱引用链即有弱引用对象,引用了该对象。

虚可到达,如果从 GC Root 搜索后,发现对象与 GC Root 之间只存在虚引用链则为虚可到达。虚引用链即有虚引用对象,引用了该对象。

不可达,如果从 GC Root 搜索后,找不到对象与 GC Root 之间的引用链,则为不可到达。

看一个简单的列子:

ObjectA 为强可到达,ObjectB 也为强可到达,虽然 ObjectB 对象被 SoftReference ObjcetE 引用但由于其还被 ObjectA 引用所以为强可到达;而 ObjectC 和 ObjectD 为弱引用达到,虽然 ObjectD 对象被 PhantomReference ObjcetG 引用但由于其还被 ObjectC 引用,而 ObjectC 又为弱引用达到,所以 ObjectD 为弱引用达到;而 ObjectH 与 ObjectI 是不可到达。引用链的强弱有关系依次是 强引用 > 软引用 > 弱引用 > 虚引用,如果有更强的引用关系存在,那么引用链到达性,将由更强的引用有关系决定。

Reference 核心处理流程

JVM 在 GC 时如果当前对象只被 Reference 对象引用,JVM 会根据 Reference 具体类型与堆内存的使用情况决定是否把对应的 Reference 对象加入到一个由 Reference 构成的 pending 链表上,如果能加入 pending 链表 JVM 同时会通知 ReferenceHandler 线程进行处理。ReferenceHandler 线程是在 Reference 类被初始化时调用的,其是一个守护进程并且拥有最高的优先级。Reference 类静态初始化块代码如下:

static {    //省略部分代码...    Thread handler = new ReferenceHandler(tg, "Reference Handler");    handler.setPriority(Thread.MAX_PRIORITY);    handler.setDaemon(true);    handler.start();    //省略部分代码...}
复制代码

而 ReferenceHandler 线程内部的 run 方法会不断地从 Reference 构成的 pending 链表上获取 Reference 对象,如果能获取则根据 Reference 的具体类型进行不同的处理,不能则调用 wait 方法等待 GC 回收对象处理 pending 链表的通知。ReferenceHandler 线程 run 方法源码:

public void run() {    //死循环,线程启动后会一直运行    while (true) {        tryHandlePending(true);    }}
复制代码

run 内部调用的 tryHandlePending 源码:

static boolean tryHandlePending(boolean waitForNotify) {    Reference<Object> r;    Cleaner c;    try {        synchronized (lock) {            if (pending != null) {                r = pending;                //instanceof 可能会抛出OOME,所以在将r从pending链上断开前,做这个处理                c = r instanceof Cleaner ? (Cleaner) r : null;                //将将r从pending链上断开                pending = r.discovered;                r.discovered = null;            } else {                //等待CG后的通知                if (waitForNotify) {                    lock.wait();                }                //重试                return waitForNotify;            }        }    } catch (OutOfMemoryError x) {        //当抛出OOME时,放弃CPU的运行时间,这样有希望收回一些存活的引用并且GC能回收部分空间。同时能避免频繁地自旋重试,导致连续的OOME异常        Thread.yield();        //重试        return true;    } catch (InterruptedException x) {         //重试        return true;    }    //如果是Cleaner类型的Reference调用其clean方法并退出    if (c != null) {        c.clean();        return true;    }    ReferenceQueue<? super Object> q = r.queue;    //如果Reference有注册ReferenceQueue,则处理pending指向的Reference结点将其加入ReferenceQueue中    if (q != ReferenceQueue.NULL) q.enqueue(r);    return true;}
复制代码

上面 tryHandlePending 方法中比较重要的点是 c.clean()与 q.enqueue®,这个是文章最开始提到的两个问题答案的入口。Cleaner 的 clean 方法用于完成清理工作,而 ReferenceQueue 是将被回收对象加入到对应的 Reference 列队中,等待其他线程的后继处理。更具体地关于 Cleaner 与 ReferenceQueue 后面会再详细说明。Reference 的核心处理流程可总结如下:



对 Reference 的核心处理流程有整体了解后,再来回过头细看一下 Reference 类的源码。

/* Reference实例有四种内部的状态 * Active: 新创建Reference的实例其状态为Active。当GC检测到Reference引用的referent可达到状态发生改变时, * 为改变Reference的状态为Pending或Inactive。这个取决于创建Reference实例时是否注册过ReferenceQueue。 * 注册过其状态会转换为Pending,同时GC会将其加入pending-Reference链表中,否则为转换为Inactive状态。 * Pending: 代表Reference是pending-Reference链表的成员,等待ReferenceHandler线程调用Cleaner#clean * 或ReferenceQueue#enqueue操作。未注册过ReferenceQueue的实例不会达到这个状态 * Enqueued: Reference实例成为其被创建时注册过的ReferenceQueue的成员,代表已入队列。当其从ReferenceQueue * 中移除后,其状态会变为Inactive。 * Inactive: 什么也不会做,一旦处理该状态,就不可再转换。 * 不同状态时,Reference对应的queue与成员next变量值(next可理解为ReferenceQueue中的下个结点的引用)如下: * Active: queue为Reference实例被创建时注册的ReferenceQueue,如果没注册为Null。此时,next为null, * Reference实例与queue真正产生关系。 * Pending: queue为Reference实例被创建时注册的ReferenceQueue。next为当前实例本身。 * Enqueued: queue为ReferenceQueue.ENQUEUED代表当前实例已入队列。next为queue中的下一实列结点, * 如果是queue尾部则为当前实例本身 * Inactive: queue为ReferenceQueue.NULL,当前实例已从queue中移除与queue无关联。next为当前实例本身。 */public abstract class Reference<T> {// Reference 引用的对象private T referent;/* Reference注册的queue用于ReferenceHandler线程入队列处理与用户线程取Reference处理。 * 其取值会根据Reference不同状态发生改变,具体取值见上面的分析 */volatile ReferenceQueue<? super T> queue;// 可理解为注册的queue中的下一个结点的引用。其取值会根据Reference不同状态发生改变,具体取值见上面的分析volatile Reference next;/* 其由VM维护,取值会根据Reference不同状态发生改变, * 状态为active时,代表由GC维护的discovered-Reference链表的下个节点,如果是尾部则为当前实例本身 * 状态为pending时,代表pending-Reference的下个节点的引用。否则为null */transient private Reference<T> discovered;/* pending-Reference 链表头指针,GC回收referent后会将Reference加pending-Reference链表。 * 同时ReferenceHandler线程会获取pending指针,不为空时Cleaner.clean()或入列queue。 * pending-Reference会采用discovered引用接链表的下个节点。 */private static Reference<Object> pending = null;// 可理解为注册的queue中的下一个结点的引用。其取值会根据Reference不同状态发生改变,具体取值见上面的分析volatile Reference next;//用于CG同步Reference成员变量值的对象。static private class Lock { }private static Lock lock = new Lock();//省略部分代码...}
复制代码

上面解释了 Reference 中的主要成员的作用,其中比较重要是 Reference 内部维护的不同状态,其状态不同成员变量 queue、pending、discovered、next 的取值都会发生变化。Reference 的主要方法如下:

//构造函数,指定引用的对象referentReference(T referent) {    this(referent, null);}//构造函数,指定引用的对象referent与注册的queueReference(T referent, ReferenceQueue<? super T> queue) {    this.referent = referent;    this.queue = (queue == null) ? ReferenceQueue.NULL : queue;}//获取引用的对象referentpublic T get() {    return this.referent;}//将当前对象加入创建时注册的queue中public boolean enqueue() {    return this.queue.enqueue(this);}
复制代码

ReferenecQueue 与 Cleaner 源码分析

先来看下 ReferenceQueue 的主要成员变量的含义。

//代表Reference的queue为null。Null为ReferenceQueue子类static ReferenceQueue<Object> NULL = new Null<>();//代表Reference已加入当前ReferenceQueue中。static ReferenceQueue<Object> ENQUEUED = new Null<>();//用于同步的对象private Lock lock = new Lock();//当前ReferenceQueue中的头节点private volatile Reference<? extends T> head = null;//ReferenceQueue的长度private long queueLength = 0;
复制代码

ReferenceQueue 中比较重要的方法为 enqueue、poll、remove 方法。

//入列队enqueue方法,只被Reference类调用,也就是上面分析中ReferenceHandler线程为调用boolean enqueue(Reference<? extends T> r) {	//获取同步对象lock对应的监视器对象    synchronized (lock) {        //获取r关联的ReferenceQueue,如果创建r时未注册ReferenceQueue则为NULL,同样如果r已从ReferenceQueue中移除其也为null        ReferenceQueue<?> queue = r.queue;        //判断queue是否为NULL 或者 r已加入ReferenceQueue中,是的话则入队列失败        if ((queue == NULL) || (queue == ENQUEUED)) {            return false;        }        assert queue == this;        //设置r的queue为已入队列        r.queue = ENQUEUED;        //如果ReferenceQueue头节点为null则r的next节点指向当前节点,否则指向头节点        r.next = (head == null) ? r : head;        //更新ReferenceQueue头节点        head = r;        //列队长度加1        queueLength++;        //为FinalReference类型引用增加FinalRefCount数量        if (r instanceof FinalReference) {            sun.misc.VM.addFinalRefCount(1);        }        //通知remove操作队列有节点        lock.notifyAll();        return true;    }}
复制代码

poll 方法源码相对简单,其就是从 ReferenceQueue 的头节点获取 Reference。

public Reference<? extends T> poll() {    //头结点为null直接返回,代表Reference还没有加入ReferenceQueue中    if (head == null)        return null;    //获取同步对象lock对应的监视器对象    synchronized (lock) {        return reallyPoll();    }}//从队列中真正poll元素的方法private Reference<? extends T> reallyPoll() {    Reference<? extends T> r = head;    //double check 头节点不为null    if (r != null) {    	//保存头节点的下个节点引用        Reference<? extends T> rn = r.next;        //更新queue头节点引用        head = (rn == r) ? null : rn;        //更新Reference的queue值,代表r已从队列中移除		r.queue = NULL;		//更新Reference的next为其本身        r.next = r;        queueLength--;        //为FinalReference节点FinalRefCount数量减1        if (r instanceof FinalReference) {            sun.misc.VM.addFinalRefCount(-1);        }        //返回获取的节点        return r;    }    return null;}
复制代码

remove 方法的源码如下:

public Reference<? extends T> remove(long timeout) throws IllegalArgumentException, InterruptedException {    if (timeout < 0) {        throw new IllegalArgumentException("Negative timeout value");    }    //获取同步对象lock对应的监视器对象    synchronized (lock) {    	//获取队列头节点指向的Reference        Reference<? extends T> r = reallyPoll();        //获取到返回        if (r != null) return r;        long start = (timeout == 0) ? 0 : System.nanoTime();        //在timeout时间内尝试重试获取        for (;;) {        	//等待队列上有结点通知            lock.wait(timeout);            //获取队列中的头节点指向的Reference            r = reallyPoll();            //获取到返回            if (r != null) return r;            if (timeout != 0) {                long end = System.nanoTime();                timeout -= (end - start) / 1000_000;                //已超时但还没有获取到队列中的头节点指向的Reference返回null                if (timeout <= 0) return null;                start = end;            }        }    }}
复制代码

简单的分析完 ReferenceQueue 的源码后,再来整体回顾一下 Reference 的核心处理流程。JVM 在 GC 时如果当前对象只被 Reference 对象引用,JVM 会根据 Reference 具体类型与堆内存的使用情况决定是否把对应的 Reference 对象加入到一个由 Reference 构成的 pending 链表上,如果能加入 pending 链表 JVM 同时会通知 ReferenceHandler 线程进行处理。ReferenceHandler 线程收到通知后会调用 Cleaner#clean 或 ReferenceQueue#enqueue 方法进行处理。如果引用当前对象的 Reference 类型为 WeakReference 且堆内存不足,那么 JVM 就会把 WeakReference 加入到 pending-Reference 链表上,然后 ReferenceHandler 线程收到通知后会异步地做入队列操作。而我们的应用程序中的线程便可以不断地去拉取 ReferenceQueue 中的元素来感知 JVM 的堆内存是否出现了不足的情况,最终达到根据堆内存的情况来做一些处理的操作。实际上 WeakHashMap 低层便是过通上述过程实现的,只不过实现细节上有所偏差,这个后面再分析。再来看看 ReferenceHandler 线程收到通知后可能会调用的另外一个类 Cleaner 的实现。

同样先看一下 Cleaner 的成员变量,再看主要的方法实现。

//继承了PhantomReference类也就是虚引用,PhantomReference源码很简单只是重写了get方法返回nullpublic class Cleaner extends PhantomReference<Object> {	/* 虚队列,命名很到位。之前说CG把ReferenceQueue加入pending-Reference链中后,ReferenceHandler线程在处理时     * 是不会将对应的Reference加入列队的,而是调用Cleaner.clean方法。但如果Reference不注册ReferenceQueue,GC处理时     * 又无法把他加入到pending-Reference链中,所以Cleaner里面有了一个dummyQueue成员变量。     */    private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue();    //Cleaner链表的头结点    private static Cleaner first = null;    //当前Cleaner节点的后续节点    private Cleaner next = null;    //当前Cleaner节点的前续节点    private Cleaner prev = null;    //真正执行清理工作的Runnable对象,实际clean内部调用thunk.run()方法    private final Runnable thunk;    //省略部分代码...}
复制代码

从上面的成变量分析知道 Cleaner 实现了双向链表的结构。先看构造函数与 clean 方法。

//私有方法,不能直接newprivate Cleaner(Object var1, Runnable var2) {    super(var1, dummyQueue);    this.thunk = var2;}//创建Cleaner对象,同时加入Cleaner链中。public static Cleaner create(Object var0, Runnable var1) {    return var1 == null ? null : add(new Cleaner(var0, var1));}//头插法将新创意的Cleaner对象加入双向链表,synchronized保证同步private static synchronized Cleaner add(Cleaner var0) {    if (first != null) {        var0.next = first;        first.prev = var0;    }    //更新头节点引用    first = var0;    return var0;}
public void clean() { //从Cleaner链表中先移除当前节点 if (remove(this)) { try { //调用thunk.run()方法执行对应清理逻辑 this.thunk.run(); } catch (final Throwable var2) { //省略部分代码.. }
}}
复制代码

可以看到 Cleaner 的实现还是比较简单,Cleaner 实现为 PhantomReference 类型的引用。当 JVM GC 时如果发现当前处理的对象只被 PhantomReference 类型对象引用,同之前说的一样其会将该 Reference 加 pending-Reference 链中上,只是 ReferenceHandler 线程在处理时如果 PhantomReference 类型实际类型又是 Cleaner 的话。其就是调用 Cleaner.clean 方法做清理逻辑处理。Cleaner 实际是 DirectByteBuffer 分配的堆外内存收回的实现,具体见下面的分析。

DirectByteBuffer 堆外内存回收与 WeakHashMap 敏感内存回收

绕开了一大圈终于回到了文章最开始提到的两个问题,先来看一下分配给 DirectByteBuffer 堆外内存是如何回收的。在创建 DirectByteBuffer 时我们实际是调用 ByteBuffer#allocateDirect 方法,而其实现如下:

public static ByteBuffer allocateDirect(int capacity) {    return new DirectByteBuffer(capacity);}
DirectByteBuffer(int cap) { //省略部分代码... try { //调用unsafe分配内存 base = unsafe.allocateMemory(size); } catch (OutOfMemoryError x) { //省略部分代码... } //省略部分代码... //前面分析中的Cleaner对象创建,持有当前DirectByteBuffer的引用 cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); att = null;}
复制代码

里面和 DirectByteBuffer 堆外内存回收相关的代码便是 Cleaner.create(this, new Deallocator(base, size, cap))这部分。还记得之前说实际的清理逻辑是里面和 DirectByteBuffer 堆外内存回收相关的代码便是 Cleaner 里面的 Runnable#run 方法吗?直接看 Deallocator.run 方法源码:

public void run() {    if (address == 0) {        // Paranoia        return;    }    //通过unsafe.freeMemory释放创建的堆外内存    unsafe.freeMemory(address);    address = 0;    Bits.unreserveMemory(size, capacity);}
复制代码

终于找到了分配给 DirectByteBuffer 堆外内存是如何回收的的答案。再总结一下,创建 DirectByteBuffer 对象时会创建一个 Cleaner 对象,Cleaner 对象持有了 DirectByteBuffer 对象的引用。当 JVM 在 GC 时,如果发现 DirectByteBuffer 被地方法没被引用啦,JVM 会将其对应的 Cleaner 加入到 pending-reference 链表中,同时通知 ReferenceHandler 线程处理,ReferenceHandler 收到通知后,会调用 Cleaner#clean 方法,而对于 DirectByteBuffer 创建的 Cleaner 对象其 clean 方法内部会调用 unsafe.freeMemory 释放堆外内存。最终达到了 DirectByteBuffer 对象被 GC 回收其对应的堆外内存也被回收的目的。

再来看一下文章开始提到的另外一个问题 WeakHashMap 如何实现敏感内存的回收。实际 WeakHashMap 实现上其 Entry 继承了 WeakReference。


//Entry继承了WeakReference, WeakReference引用的是Map的keyprivate static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> { V value; final int hash; Entry<K,V> next; /** * 创建Entry对象,上面分析过的ReferenceQueue,这个queue实际是WeakHashMap的成员变量, * 创建WeakHashMap时其便被初始化 final ReferenceQueue<Object> queue = new ReferenceQueue<>() */ Entry(Object key, V value, ReferenceQueue<Object> queue, int hash, Entry<K,V> next) { super(key, queue); this.value = value; this.hash = hash; this.next = next; } //省略部分原码...}
复制代码

往 WeakHashMap 添加元素时,实际都会调用 Entry 的构造方法,也就是会创建一个 WeakReference 对象,这个对象的引用的是 WeakHashMap 刚加入的 Key,而所有的 WeakReference 对象关联在同一个 ReferenceQueue 上。我们上面说过 JVM 在 GC 时,如果发现当前对象只有被 WeakReference 对象引用,那么会把其对应的 WeakReference 对象加入到 pending-reference 链表上,并通知 ReferenceHandler 线程处理。而 ReferenceHandler 线程收到通知后,对于 WeakReference 对象会调用 ReferenceQueue#enqueue 方法把他加入队列里面。现在我们只要关注 queue 里面的元素在 WeakHashMap 里面是在哪里被拿出去啦做了什么样的操作,就能找到文章开始问题的答案啦。最终能定位到 WeakHashMap 的 expungeStaleEntries 方法。

private void expungeStaleEntries() {    //不断地从ReferenceQueue中取出,那些只有被WeakReference对象引用的对象的Reference    for (Object x; (x = queue.poll()) != null; ) {        synchronized (queue) {            //转为 entry            Entry<K,V> e = (Entry<K,V>) x;            //计算其对应的桶的下标            int i = indexFor(e.hash, table.length);            //取出桶中元素            Entry<K,V> prev = table[i];            Entry<K,V> p = prev;            //桶中对应位置有元素,遍历桶链表所有元素            while (p != null) {                Entry<K,V> next = p.next;                //如果当前元素(也就是entry)与queue取出的一致,将entry从链表中去除                if (p == e) {                    if (prev == e)                        table[i] = next;                    else                        prev.next = next;                    // Must not null out e.next;                    //清空entry对应的value                    e.value = null;                    size--;                    break;                }                prev = p;                p = next;            }        }    }}
复制代码

现在只看一下 WeakHashMap 哪些地方会调用 expungeStaleEntries 方法就知道什么时候 WeakHashMap 里面的 Key 变得软可达时我们就可以将其对应的 Entry 从 WeakHashMap 里面移除。直接调用有三个地方分别是 getTable 方法、size 方法、resize 方法。 getTable 方法又被很多地方调用如 get、containsKey、put、remove、containsValue、replaceAll。最终看下来,只要对 WeakHashMap 进行操作就行调用 expungeStaleEntries 方法。所有只要操作了 WeakHashMap,没 WeakHashMap 里面被再用到的 Key 对应的 Entry 就会被清除。再来总结一下,为什么 WeakHashMap 适合作为内存敏感缓存的实现。当 JVM 在 GC 时,如果发现 WeakHashMap 里面某些 Key 没地方在被引用啦(WeakReference 除外),JVM 会将其对应的 WeakReference 对象加入到 pending-reference 链表上,并通知 ReferenceHandler 线程处理。而 ReferenceHandler 线程收到通知后将对应引用 Key 的 WeakReference 对象加入到 WeakHashMap 内部的 ReferenceQueue 中,下次再对 WeakHashMap 做操作时,WeakHashMap 内部会清除那些没有被引用的 Key 对应的 Entry。这样就达到了每操作 WeakHashMap 时,自动的检索并清量没有被引用的 Key 对应的 Entry 的目地。

总结

本文通过两个问题引出了 JDK 中 Reference 相关类的源码分析,最终给出了问题的答案。但实际上一般开发规范中都会建议禁止重写 Object#finalize 方法同样与 Reference 类关系密切(具体而言是 Finalizer 类)。受篇幅的限制本文并未给出分析,有待各位自己看源码啦。半年没有写文章啦,有点对不住关注的小伙伴。希望看完本文各位或多或少能有所收获。如果觉得本文不错就帮忙转发记得标一下出处,谢谢。后面我还会继续分享一些自己觉得比较重要的东西给大家。由于个人能力有限,文中不足与错误还望指正。


看完三件事❤️

如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:

  1. 点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。

  2. 关注公众号 『 java 烂猪皮 』,不定期分享原创知识。

  3. 同时可以期待后续文章 ing🚀




本文作者:叶易

出处:https://club.perfma.com/article/125010


用户头像

AI乔治

关注

分享后端技术干货。公众号【 Java烂猪皮】 2019.06.30 加入

一名默默无闻的扫地僧!

评论

发布
暂无评论
Java Reference核心原理分析