Java-内存模型-引用总结

Java-内存模型-引用总结

0x01 摘要

本篇文章主要是总结下java中的各种引用即:强引用、软引用、弱引用、虚引用。
大多情况下,Java中对象并不是从GC Roots集直接引用的,目前hotspot主要使用两类作为gc roots:

  1. 全局性引用:常量或类的静态属性
  2. 执行上下文:栈帧中的本地(局部)变量表

一般来说,对象都是被若干其他对象引用,从而构成一个以根集为顶的树形结构

《Java-内存模型-引用总结》

在这个树形的引用链中,箭头的方向代表了引用的方向,所指向的对象是被引用对象。由图可以看出,从GC Roots集到一个对象可以有很多条路。比如到达对象5的路径就有①-⑤,③-⑦。可达性路径判定规则:

  • 单条路径:在这条路径中,最弱的一个引用决定对象的可达性。
  • 多条路径:几条路径中,最强的一条的引用决定对象的可达性。

比如,我们假设图中引用①和③为强引用,⑤为软引用,⑦为弱引用,对于对象5按照这两个判断原则,路径①-⑤取最弱的引用⑤,因此该路径对对象5的引用为软引用。同样,③-⑦为弱引用。在这两条路径之间取最强的引用,于是对象5是一个软可达对象。

0x02 强引用

引用是JAVA中默认采用的一种方式,我们平时创建的引用都属于强引用。
如果一个对象没有强引用,那么对象就可能会被回收。

使用强引用一定要注意避免内存泄露。

测试代码如下:

    public void strongReferenceTest(){
        Object obj = new Object();
        Object objRef = obj;
        obj = null;
        System.gc();
        System.out.println(obj);
    }

0x03 软引用

如果一个对象只具有软引用那这个对象有以下特点:

  • 内存空间足够时,垃圾回收器不会回收它。也就是说 即使发生了gc 但是还有大量内存可用,那也不会立刻回收软引用
  • 如果内存空间不足,就会回收这些对象的内存。具体来说,JVM的FC线程对软可达对象和其他一般Java对象进行了区别对待:软可达对象的清理是由GC线程根据其特定算法按照内存需求决定的。也就是说,GC线程会在虚拟机抛出OutOfMemoryError之前回收软可达对象,而且JVM会尽可能优先回收长时间闲置不用的软可达对象,对那些刚刚构建的或刚刚使用过的“新”软可达对象会被虚拟机尽可能保留。
  • 只要垃圾回收器没有回收它,该对象就可以被程序使用。
  • 软引用可用来实现内存敏感的高速缓存。
  • 可以和ReferenceQueue联合使用,GC发生时会将此软引用(SoftReference)对象放入与之关联的引用队列RQ中

示例如下:

    public void softReferenceTest(){
        String str=new String("abc");                                     // 强引用
        SoftReference<String> softRef=new SoftReference<String>(str);     // 软引用
        str = null;
// 当内存不足时,等价于:
       /* If(JVM.内存不足()) { str = null; // 转换为软引用 System.gc(); // 垃圾回收器进行回收 }*/
    }

0x04 弱引用

4.1 弱引用概念

弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。

当你想引用一个对象,但是这个对象有自己的生命周期,你不想介入这个对象的生命周期,此时最佳方式就是弱引用。

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

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

4.2 弱引用示例

示例代码如下:

    public void weakReferenceTest(){
    	// str强引用
        String str = new String("hello"); 
        // 引用队列
        ReferenceQueue<String> rq = new ReferenceQueue<String>(); 
        // 弱引用,关联了str rq队列
        WeakReference<String> wf = new WeakReference<String>(str, rq); 
        // 取消"hello"对象的强引用
        str=null;
        // 强制gc
        // 注意,实际场景中要慎用 System.gc(); 会导致FUll gc即整个heap gc
        System.gc();
        // 假如"hello"对象没有被回收,str1会强引用"hello"对象
        String str1=wf.get(); 
        System.out.println("str1 = " + str1);
        // 假如"hello"对象没有被回收,rq.poll()返回null
        Reference<? extends String> ref=rq.poll();
        System.out.println("ref = " + ref);
    }

注意,加入引用队列的引用对象不会被自动清理,需要手动调用poll,然后将拿到的对象设为null帮助GC快速清理这个已经无用的引用实例。

4.3 WeakHashMap

WeakHashMap是一个特殊的map,有着弱引用的key。也就是说,当某个key没有再被正常使用时(被GC干掉了),WeakHashMap中的这个key对应的entry会被自动高效移除。这一点是WeakHashMap与其他map最大的不同之处。

但要注意的是,WeakHashMap的自动移除entry前提是对WeakHashMap进行了访问。大多数访问行为会自动调用其expungeStaleEntries方法,清理无效key对应的entry。也就是说,不访问WeakHashMap的话即使key已经变为null,对应的entry也不会被回收!

当使用 WeakHashMap 时,即使没有显示的添加或删除任何元素,也可能发生如下情况:

  • 调用两次size()方法返回不同的值;
  • 两次调用isEmpty()方法,第一次返回false,第二次返回true;
  • 两次调用containsKey()方法,第一次返回true,第二次返回false,尽管两次使用的是同一个key;
  • 两次调用get()方法,第一次返回一个value,第二次返回null,尽管两次使用的是同一个对象。

WeakHashMap是软引用的一个典型的应用,更多信息请点击Java学习-容器-WeakHashMap

0x05 虚引用

虚引用形同虚设,它所引用的对象随时可能被垃圾回收。

虚引用与软引用和弱引用的不同之处在于必须和引用队列一起使用:

private static final ReferenceQueue<Object> queue = new ReferenceQueue<>();
new PhantomReference<>(object, queue);

虚引用的使用场景很窄,在JDK中,目前只知道在申请堆外内存时有它的身影。申请堆外内存时,在JVM堆中会创建一个对应的Cleaner对象,这个Cleaner类继承了PhantomReference,当DirectByteBuffer对象被回收时,可以执行对应的Cleaner对象的clean方法,做一些后续工作,这里是释放之前申请的堆外内存。

由于虚引用的get方法无法拿到真实对象,所以当你不想让真实对象被访问时,可以选择使用虚引用。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

看到有些文章说虚引用可以清理已经执行finalize方法,但是还没被回收的对象,这简直就是误导人嘛,与finalize方法有关的引用是FinalReference,这个引用就是之前说的其它两种中的一个。

0x06 ReferenceQueue引用队列

首先要说明一点,虽然ReferenceQueue名为queue,但其实不是一个queue,而更像一个用链表实现的stack,因为入队元素是后进先出的。

6.1 作用

前面多次提到的ReferenceQueue,GC在检测到适当的可达性状态(根据引用类型不同而不同)发生变化后会将注册的引用放入引用队列,方便我们处理它,因为引用对象指向的对象会被GC自动清理。但是引用对象本身也是对象(是对象就占用一定资源),所以需要我们自己清理。看这个小示例:

// str强引用
String str = new String("hello"); 
// 引用队列
ReferenceQueue<String> rq = new ReferenceQueue<String>(); 
// wf是一个WeakReference类型的强引用,有一个弱引用指向str
WeakReference<String> wf = new WeakReference<String>(str, rq); 
// str强引用消除,wf指向str的弱引用会在gc后被干掉。但是wf这个对象还在,需要手动处理
str=null

6.2 源码分析

6.2.1 类和构造方法

public class ReferenceQueue<T> {
/** * Constructs a new reference-object queue. */
public ReferenceQueue() { }

可以看到ReferenceQueue类有一个泛型T

6.2.2 属性和内部类

// 静态内部类Null 继承自ReferenceQueue,泛型和ReferenceQueue的相同
private static class Null<S> extends ReferenceQueue<S> {
       boolean enqueue(Reference<? extends S> r) {
           return false;
       }
   }

static ReferenceQueue<Object> NULL = new Null<>();
static ReferenceQueue<Object> ENQUEUED = new Null<>();
// 静态内部类Lock
static private class Lock { };
// Lock实例
private Lock lock = new Lock();
// 队列的头,初始值为null
private volatile Reference<? extends T> head = null;
// 队列长度
private long queueLength = 0;

6.2.3 enqueue

// 这个方法只会被Reference对象调用
boolean enqueue(Reference<? extends T> r) { 
    synchronized (lock) {
    	// 这里的queue就是在Reference创建时与之关联的ReferenceQueue
        ReferenceQueue<?> queue = r.queue;
        // 检查在拿到锁之后,该引用还没有入队(甚至是被移除掉了)
        if ((queue == NULL) || (queue == ENQUEUED)) {
            return false;
        }
        assert queue == this;
        // 表示该引用的关联的引用队列状态为已入队?
        r.queue = ENQUEUED;
        // 该引用next指向原来队列的头部,然后head指针移动到当前引用处
        r.next = (head == null) ? r : head;
        head = r;
        // 引用队列长度自增
        queueLength++;
        // 如果引用是FinalReference就干啥。。这里没看懂
        if (r instanceof FinalReference) {
            sun.misc.VM.addFinalRefCount(1);
        }
        //将wait在此锁上的线程全部唤醒
        lock.notifyAll();
        return true;
    }
}

值得注意的是,这个ReferenceQueue名字为队列,其实是个栈,因为入队时是用的头插法,也就是说会先进后出。

6.2.4 reallyPoll

/* Must hold lock */
// 真正获取并删除队列中头结点
private Reference<? extends T> reallyPoll() {   
		// 获取引用队列头结点Reference 
        Reference<? extends T> r = head;
        if (r != null) {
        	// r.next == r表示只有一个引用结点,此时head置为null
        	// 否则将head指针移动到r.next处
            head = (r.next == r) ?
                null :
                r.next; // Unchecked due to the next field having a raw type in Reference
            // poll后需要将r.queue置为空队列
            r.queue = NULL;
            // 这里的操作就是将r.next指向本身,相当于r和head之间就已经没有联系了
            r.next = r;
            // 队列长度减一
            queueLength--;
            if (r instanceof FinalReference) {
                sun.misc.VM.addFinalRefCount(-1);
            }
            // 返回该reference
            return r;
        }
        return null;
}

6.2.5 poll

public Reference<? extends T> poll() {
        if (head == null)
            return null;
        synchronized (lock) {
            return reallyPoll();
        }
}

如果引用队列头为空,就立即返回null;否则就尝试获取锁,然后去执行reallyPoll来获取队列头引用

6.2.6 remove

remove方法有两个,先来看带有timeout参数的方法:

 public Reference<? extends T> remove(long timeout)
        throws IllegalArgumentException, InterruptedException
    {
        if (timeout < 0) {
            throw new IllegalArgumentException("Negative timeout value");
        }
        synchronized (lock) {
        	// 拉队列头的reference
            Reference<? extends T> r = reallyPoll();
            // 不为空就直接返回
            if (r != null) return r;
            // 走到这里说明没有拉取到reference
           
            long start = (timeout == 0) ? 0 : System.nanoTime();
             // 开始根据timeout循环等待
            for (;;) {
            	// 这个地方wait唤醒有两种情况
            	// 一种是timeout时间到,那么会结束循环
            	// 另一种是被enqueue方法加入引用成功后唤醒,此时会尝试拉取队头
                lock.wait(timeout);
                r = reallyPoll();
                if (r != null) return r;
                // 如果设的超时时间不为0,就把总超时时间减去消耗的时间。
                // 如果timeout为0 会无限循环这个过程
                if (timeout != 0) {
                    long end = System.nanoTime();
                    timeout -= (end - start) / 1000_000;
                    // 剩余超时时间小于0,就返回null
                    if (timeout <= 0) return null;
                    // 否则重置start为当前时间,然后进入下一次循环
                    start = end;
                }
            }
        }
    }

该方法用来移除引用队列中的下一个reference。当前线程会阻塞直到返回一个reference对象或给定的超时时间到了。

另一个不带参数的方法其实就是调用我们上面提到的方法,传入参数0,也就是说会永久等待直到从引用队列中拿到可用的reference:

public Reference<? extends T> remove() throws InterruptedException {
        return remove(0);
}

0x07 Reference

7.1 简介

public abstract class Reference<T>

Reference是所有引用对象的基抽象类,他定义了一些通用的操作。因为引用对象的实现和GC紧密关联,所以该类可能不会直接进行子类化。

7.2 Reference的四种状态

Reference实例只会有四种状态:
《Java-内存模型-引用总结》

7.2.1 Active

新创建的Reference实例状态就为Active

当GC发现被该Reference引用的对象的可达性已经变为恰当的状态时,会根据该Reference实例在创建时是否已经注册到一个引用队列,来判断将该Reference实例的状态变更为PendingInactive。 在前一种(注册到RQ)总是会将该Reference实例添加到pending-Reference列表。

7.2.2 Pending

Pending状态表示pending-Reference列表中的元素等待被Reference-handler线程入队。

没有注册到RQ的Reference实例不会进入Pending状态。

7.2.3 Enqueued

Pending状态表示该Reference实例是创建时就注册到的RQ中的元素,而且该引用指向的对象已经为待回收状态,并且该Reference实例已经放到RQ当中了。当一个Reference实例被从RQ中移除时,状态会迁移到Inactive

没有注册到RQ的Reference实例不会进入Enqueued状态。

7.2.4 Inactive

即此Reference对象已经由外部从queue中获取到,并且已经处理掉了。也就是说,此Reference对象可以被回收,并且其内部封装的对象也可以被回收( 实际的回收运行取决于clear动作是否被调用 )。可以理解为进入到Inactive状态的肯定是应该被回收掉的。

Reference实例处于Inactive这个状态以后不会再做任何操作,也不会再变化。

7.3 状态判断

7.3.1 queue和next

JVM并不需要定义上述的状态值来判断相应引用的状态处于哪个状态,只需要通过以下计算nextqueue即可:

状态queuenext
Active如果RQ对象为空或者没有传入,则为ReferenceQueue.NULL;否则queue为创建一个Reference对象时传入的RQ对象;null
Pending初始化时引用注册的RQ对象this
EnqueueReferenceQueue.ENQUEUED为RQ中下一个要处理的Reference对象;如果本对象已经是队列尾部,那就是this;
InactiveReferenceQueue.NULLthis

7.3.2 小结

通过这个组合,GC如果需要抉择一个Reference实例是否需要特殊处理时,只需要检测next

  • 如果next==null,则Reference实例是Active状态;
  • 如果next!=null,GC就会正常对待

为了确保并发垃圾收集器能够发现Active状态的Reference对象,而且不干扰可能将enqueue()方法应用于这些对象的应用程序线程,收集器应通过discovered字段链接(link) discovered的对象。discovered字段也用于链接pending列表中的Reference对象。

7.4 源码分析

7.4.1 重要属性

// 引用对象
private T referent;         /* Treated specially by GC */

// 引用队列,但其实只是用来标识是空队列还是已经创建的队列
volatile ReferenceQueue<? super T> queue;
/** * 指向ReferenceQueue中的下一个Reference节点 * When active: NULL,此时还没有加入ReferenceQueue * pending: this,此时指向自身 * Enqueued: 队列中的下一个引用对象,或已经是最后一个时就指向自己 * Inactive: this,此时指向自身 */
Reference next;

/* When active: 由GC维护的一个已发现的reference列表中的下一个元素(或已经是最后一个时就指向自己) * pending: pending列表中的下一个元素(或已经是最后一个时就指向自己) * otherwise: NULL */
transient private Reference<T> discovered;  /* used by VM */


/** * * 被GC使用的同步锁,当GC在每次垃圾搜集前必须先获取此锁。 * 所以这个锁至关重要,每个持有该锁的代码必须尽快完成从而释放锁,不要使用新对象、调用其他用户代码 */
static private class Lock { }
private static Lock lock = new Lock();

/** * 等待入队的reference对象列表 * 由GC将引用添加到此队列,同时Reference-handler线程负责移除他们 * 这个列表由前面提到的Lock对象进行同步锁 * * 这个list还会使用已发现的域来连接里面的元素 */
private static Reference<Object> pending = null;

7.4.2 内部类-ReferenceHandler

// 高优先级的线程来将pending状态的reference入队
private static class ReferenceHandler extends Thread {
    private static void ensureClassInitialized(Class<?> clazz) {
        try {
            Class.forName(clazz.getName(), true, clazz.getClassLoader());
        } catch (ClassNotFoundException e) {
            throw (Error) new NoClassDefFoundError(e.getMessage()).initCause(e);
        }
    }

    static {
        // 预加载和初始化以下两个类,避免在后面代码中出现加载顺序问题
        ensureClassInitialized(InterruptedException.class);
        ensureClassInitialized(Cleaner.class);
    }
	
    ReferenceHandler(ThreadGroup g, String name) {
        super(g, name);
    }

    public void run() {
        while (true) {
            // 处理pending状态的reference
            tryHandlePending(true);
        }
    }
}

7.4.3

static {
    ThreadGroup tg = Thread.currentThread().getThreadGroup();
    // 为当前线程组和其父线程组构建并启动pending状态reference的处理线程
    for (ThreadGroup tgn = tg;
         tgn != null;
         tg = tgn, tgn = tg.getParent());
    // 构建一个处理pending状态reference的处理线程
    Thread handler = new ReferenceHandler(tg, "Reference Handler");
    /* If there were a special system-only priority greater than * MAX_PRIORITY, it would be used here */
    // 该处理线程优先级设为最高,并设该线程为守护线程
    handler.setPriority(Thread.MAX_PRIORITY);
    handler.setDaemon(true);
    // 开始执行该处理线程
    handler.start();

    // provide access in SharedSecrets 提供一个java.lang的访问特权
    SharedSecrets.setJavaLangRefAccess(new JavaLangRefAccess() {
        @Override
        public boolean tryHandlePendingReference() {
            return tryHandlePending(false);
        }
    });
}

7.4.4 构造方法

// 不带引用队列的版本
Reference(T referent) {
	this(referent, null);
}

// 带引用队列的构造方法
Reference(T referent, ReferenceQueue<? super T> queue) {
    this.referent = referent;
    // 若干引用队列为空就用ReferenceQueue.NULL表示空队列,不允许加入元素
    this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
}

7.4.5 重要方法

7.4.5.1 tryHandlePending
/** * 尝试处理pending状态的reference * * 当返回true的时候代表还有其他pending状态的reference * 返回false代表没有其他reference节点了,线程可以干其他有意义的事情而不是继续循环 * * @param waitForNotify * 该参数为true且没有pending状态的reference时,就开始等待直到VM notify或是被中断 * 为false代表没有pending状态的reference时立刻返回 * * @return * true: 表示存在一个已经被处理的pending状态的reference, * 或是我们等待通知并且在收到通知之前得到它或者线程被中断; * false: 与true相反 */
static boolean tryHandlePending(boolean waitForNotify) {
    Reference<Object> r;
    // 这个GC器继承自PhantomReference
    Cleaner c;
    try {
        // 同步锁
        synchronized (lock) {
            // 注意,我们之前提到过pending状态的reference是由GC线程负责添加
            if (pending != null) {
                r = pending;
                // 'instanceof'操作可能导致OOM,所以这个操作要在移除r到pending链之前
                c = r instanceof Cleaner ? (Cleaner) r : null;
                // 移除r和pending链之间的连接
                // 把r发现的加入pending链等待入队
                pending = r.discovered;
                r.discovered = null;
            } else {
                // 此时pending为空,即没有等待入队的reference
                
                // 等待锁过程可能导致OOM
                // 因为可能尝试分配exception对象

                if (waitForNotify) {
                    lock.wait();
                }
                
                // retry if waited
                return waitForNotify;
            }
        }
    } catch (OutOfMemoryError x) {
        // 此时执行Thread.yield(),尝试放弃CPU运行权
        // 可以给其他线程CPU时间,给其他线程CPU时间,希望他们删除一些存活的引用,让GC回收一些空间
        // 如果上面的'r instanceof Cleaner'持续抛出OOM一段时间,也可以防止CPU集中式地自旋
        Thread.yield();
        // retry
        return true;
    } catch (InterruptedException x) {
        // retry
        return true;
    }
	 // 此时pending != null
    // c是Cleaner,清理并返回
    if (c != null) {
        c.clean();
        return true;
    }
	 // 否则pengind不为空,且不是Cleaner
	 
	 // 此时就将该pending元素入队到ReferenceQueue
    ReferenceQueue<? super Object> q = r.queue;
    if (q != ReferenceQueue.NULL) q.enqueue(r);
    return true;
}
7.4.5.2 get
/** * 返回当前reference所引用的对象 * 如果该引用对象已经程序或是GC被清除,那么该方法会返回null * */
public T get() {
    return this.referent;
}
7.4.5.3 clear
/** * 清理所引用的对象 * 调用此方法不会导致引用的对象入队到ReferenceQueue * * 注意,此方法只会被java用户代码调用,因为GC不需要调用此方法就能清理对象 */
public void clear() {
    this.referent = null;
}

7.5 小结

Reference就是一个强引用对象,同时他有一个引用指向目标对象。GC会在清理指向的引用对象时将该Reference放入ReferenceQueue,用户还需要自行清理这个Reference对象。

0x08 综合例子

在以下例程的References类中,依次创建了10个软引用、10个弱引用和10个虚引用,它们各自引用一个Grocery对象。

import java.lang.ref.*;
import java.util.*;
class Grocery {
    private static final int SIZE = 10000;
    // 属性d使得每个Grocery对象占用较多内存,有80K左右
    private double[] d = new double[SIZE];
    private String id;
    public Grocery(String id) {
        this.id = id;
    }
    public String toString() {
        return id;
    }
    // 通过finalize方法观察该对象被gc认为可以回收
    public void finalize() {
        System.out.println("Finalizing " + id);
    }
}
public class References {
    private static ReferenceQueue<Grocery> rq = new ReferenceQueue<Grocery>();
    public static void checkQueue() {
        // 从队列中取出一个引用
        Reference<? extends Grocery> inq = rq.poll();
        if (inq != null)
            System.out.println("In queue: " + inq + " : " + inq.get());
    }
    public static void main(String[] args) {
        final int size = 10;
        // 创建10个Grocery对象以及10个软引用
        Set<SoftReference<Grocery>> sa = new HashSet<SoftReference<Grocery>>();
        for (int i = 0; i < size; i++) {
            SoftReference<Grocery> ref = new SoftReference<Grocery>(
                    new Grocery("Soft " + i), rq);
            System.out.println("Just created: " + ref.get());
            sa.add(ref);
        }
        System.gc();
        checkQueue();
        // 创建10个Grocery对象以及10个弱引用
        Set<WeakReference<Grocery>> wa = new HashSet<WeakReference<Grocery>>();
        for (int i = 0; i < size; i++) {
            WeakReference<Grocery> ref = new WeakReference<Grocery>(
                    new Grocery("Weak " + i), rq);
            System.out.println("Just created: " + ref.get());
            wa.add(ref);
        }
        System.gc();
        checkQueue();
        // 创建10个Grocery对象以及10个虚引用
        Set<PhantomReference<Grocery>> pa = new HashSet<PhantomReference<Grocery>>();
        for (int i = 0; i < size; i++) {
            PhantomReference<Grocery> ref = new PhantomReference<Grocery>(
                    new Grocery("Phantom " + i), rq);
            System.out.println("Just created: " + ref.get());
            pa.add(ref);
        }
        System.gc();
        checkQueue();
    }
}

从程序运行时的打印结果可以得出以下结论:

  • 虚引用形同虚设,它所引用的对象随时可能被垃圾回收
  • 具有弱引用的对象拥有稍微长的生命周期,当垃圾回收器执行回收操作时,有可能被垃圾回收
  • 具有软引用的对象拥有较长的生命周期,但在Java虚拟机认为内存不足的情况下,也会被垃圾回收

0x09 总结

《Java-内存模型-引用总结》

0xFF 参考文档

Java8-API
Java中软引用、弱引用和虚引用的使用方法示例
java强引用,软引用,弱引用,虚引用
JDK源码解析/深入理解Reference和ReferenceQueue
Reference、ReferenceQueue 详解
Java对象的强、软、弱和虚引用原理+结合ReferenceQueue对象构造Java对象的高速缓存器

    原文作者:java内存模型
    原文地址: https://blog.csdn.net/baichoufei90/article/details/83550049
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞

发表评论

电子邮件地址不会被公开。 必填项已用*标注