From 0adcdb9000441156dffe05b69fbbb52f39ab33e5 Mon Sep 17 00:00:00 2001 From: miaoswen <55910248+miaoswen@users.noreply.github.com> Date: Mon, 23 Dec 2019 23:10:17 +0800 Subject: [PATCH] week 02 --- week_02/39/AtomicInteger.md | 145 +++++++++++++ week_02/39/AtomicStampedReference.md | 146 +++++++++++++ .../39/LongAdder\345\210\206\346\236\220.md" | 205 ++++++++++++++++++ week_02/39/Unsafe.md | 121 +++++++++++ 4 files changed, 617 insertions(+) create mode 100644 week_02/39/AtomicInteger.md create mode 100644 week_02/39/AtomicStampedReference.md create mode 100644 "week_02/39/LongAdder\345\210\206\346\236\220.md" create mode 100644 week_02/39/Unsafe.md diff --git a/week_02/39/AtomicInteger.md b/week_02/39/AtomicInteger.md new file mode 100644 index 0000000..09010b5 --- /dev/null +++ b/week_02/39/AtomicInteger.md @@ -0,0 +1,145 @@ +## AtomicInteger介绍 + +AtomicInteger是一个提供原子操作的Integer类,通过线程安全的方式操作加减。 +AtomicInteger提供原子操作来进行Integer的使用,因此十分适合高并发情况下的使用。 +首先看两段代码,一段是Integer的,一段是AtomicInteger的,为以下: + + + public class Sample1 { + + private static Integer count = 0; + + synchronized public static void increment() { + count++; + } + + } + +以下是AtomicInteger的: + + + public class Sample2 { + + private static AtomicInteger count = new AtomicInteger(0); + + public static void increment() { + count.getAndIncrement(); + } + + } + +以上两段代码,在使用Integer的时候,必须加上synchronized保证不会出现并发线程同时访问的情况,而在AtomicInteger中却不用加上synchronized,在这里AtomicInteger是提供原子操作的,下面就对这进行相应的介绍。 + +## AtomicInteger源码部分 + + + + public class AtomicInteger extends Number implements java.io.Serializable { + private static final long serialVersionUID = 6214790243416807050L; + + // setup to use Unsafe.compareAndSwapInt for updates + private static final Unsafe unsafe = Unsafe.getUnsafe(); + private static final long valueOffset; + + static { + try { + valueOffset = unsafe.objectFieldOffset + (AtomicInteger.class.getDeclaredField("value")); + } catch (Exception ex) { throw new Error(ex); } + } + + private volatile int value; + +以上为AtomicInteger中的部分源码,在这里说下其中的value,这里value使用了volatile关键字,volatile在这里可以做到的作用是使得多个线程可以共享变量,但是问题在于使用volatile将使得VM优化失去作用,导致效率较低,所以要在必要的时候使用,因此AtomicInteger类不要随意使用,要在使用场景下使用。 + +**AtomicInteger实例使用** + +以下就是在多线程情况下,使用AtomicInteger的一个实例 + + public class AtomicTest { + + static long randomTime() { + return (long) (Math.random() * 1000); + } + + public static void main(String[] args) { + // 阻塞队列,能容纳100个文件 + final BlockingQueue queue = new LinkedBlockingQueue(100); + // 线程池 + final ExecutorService exec = Executors.newFixedThreadPool(5); + final File root = new File("D:\\ISO"); + // 完成标志 + final File exitFile = new File(""); + // 原子整型,读个数 + // AtomicInteger可以在并发情况下达到原子化更新,避免使用了synchronized,而且性能非常高。 + final AtomicInteger rc = new AtomicInteger(); + // 原子整型,写个数 + final AtomicInteger wc = new AtomicInteger(); + // 读线程 + Runnable read = new Runnable() { + public void run() { + scanFile(root); + scanFile(exitFile); + } + + public void scanFile(File file) { + if (file.isDirectory()) { + File[] files = file.listFiles(new FileFilter() { + public boolean accept(File pathname) { + return pathname.isDirectory() || pathname.getPath().endsWith(".iso"); + } + }); + for (File one : files) + scanFile(one); + } else { + try { + // 原子整型的incrementAndGet方法,以原子方式将当前值加 1,返回更新的值 + int index = rc.incrementAndGet(); + System.out.println("Read0: " + index + " " + file.getPath()); + // 添加到阻塞队列中 + queue.put(file); + } catch (InterruptedException e) { + + } + } + } + }; + // submit方法提交一个 Runnable 任务用于执行,并返回一个表示该任务的 Future。 + exec.submit(read); + + // 四个写线程 + for (int index = 0; index < 4; index++) { + // write thread + final int num = index; + Runnable write = new Runnable() { + String threadName = "Write" + num; + + public void run() { + while (true) { + try { + Thread.sleep(randomTime()); + // 原子整型的incrementAndGet方法,以原子方式将当前值加 1,返回更新的值 + int index = wc.incrementAndGet(); + // 获取并移除此队列的头部,在元素变得可用之前一直等待(如果有必要)。 + File file = queue.take(); + // 队列已经无对象 + if (file == exitFile) { + // 再次添加"标志",以让其他线程正常退出 + queue.put(exitFile); + break; + } + System.out.println(threadName + ": " + index + " " + file.getPath()); + } catch (InterruptedException e) { + } + } + } + + }; + exec.submit(write); + } + exec.shutdown(); + } + + } + +AtomicInteger是在使用非阻塞算法实现并发控制,在一些高并发程序中非常适合,但并不能每一种场景都适合,不同场景要使用使用不同的数值类。 \ No newline at end of file diff --git a/week_02/39/AtomicStampedReference.md b/week_02/39/AtomicStampedReference.md new file mode 100644 index 0000000..7de8015 --- /dev/null +++ b/week_02/39/AtomicStampedReference.md @@ -0,0 +1,146 @@ +## **AtomicStampedReference简介** + +AtomicStampedReference内部使用Pair来存储元素值及其版本号,主要用来解决ABA问题。 + +**ABA问题** + +CAS操作可能存在ABA的问题,就是说:假如一个值原来是A,变成了B,又变成了A,那么CAS检查时会发现它的值没有发生变化,但是实际上却变化了。 + +**如下代码存在ABA问题:** + + + public static void main(String[] args) { + AtomicInteger atomicInteger = new AtomicInteger(1); + new Thread(()->{ + int value = atomicInteger.get(); + System.out.println("thread 1 read value: " + value); + // 阻塞1s + LockSupport.parkNanos(1000000000L); + if (atomicInteger.compareAndSet(value, 3)) { + System.out.println("thread 1 update from " + value + " to 3"); + } else { + System.out.println("thread 1 update fail!"); + } + }).start(); + + new Thread(()->{ + int value = atomicInteger.get(); + System.out.println("thread 2 read value: " + value); + if (atomicInteger.compareAndSet(value, 2)) { + System.out.println("thread 2 update from " + value + " to 2"); + // do sth + value = atomicInteger.get(); + System.out.println("thread 2 read value: " + value); + if (atomicInteger.compareAndSet(value, 1)) { + System.out.println("thread 2 update from " + value + " to 1"); + } + } + }).start(); + +**AtomicStampedReference使用** + +AtomicStampedReference解决ABA问题: + + + public static void main(String[] args) { + AtomicStampedReference atomicStampedReference = new AtomicStampedReference<>(1, 1); + new Thread(()->{ + int[] stampHolder = new int[1]; + int value = atomicStampedReference.get(stampHolder); + int stamp = stampHolder[0]; + System.out.println("thread 1 read value: " + value + ", stamp: " + stamp); + + // 阻塞1s + LockSupport.parkNanos(1000000000L); + + if (atomicStampedReference.compareAndSet(value, 3, stamp, stamp + 1)) { + System.out.println("thread 1 update from " + value + " to 3"); + } else { + System.out.println("thread 1 update fail!"); + } + }).start(); + + new Thread(()->{ + int[] stampHolder = new int[1]; + int value = atomicStampedReference.get(stampHolder); + int stamp = stampHolder[0]; + System.out.println("thread 2 read value: " + value + ", stamp: " + stamp); + if (atomicStampedReference.compareAndSet(value, 2, stamp, stamp + 1)) { + System.out.println("thread 2 update from " + value + " to 2"); + + // do sth + + value = atomicStampedReference.get(stampHolder); + stamp = stampHolder[0]; + System.out.println("thread 2 read value: " + value + ", stamp: " + stamp); + if (atomicStampedReference.compareAndSet(value, 1, stamp, stamp + 1)) { + System.out.println("thread 2 update from " + value + " to 1"); + } + } + }).start(); + } + +**主要属性** + + + // 内部类 + private static class Pair { + // 存储元素 + final T reference; + // 存储元素的版本号 + final int stamp; + private Pair(T reference, int stamp) { + this.reference = reference; + this.stamp = stamp; + } + // 创建Pair的静态方法 + static Pair of(T reference, int stamp) { + return new Pair(reference, stamp); + } + } + + // 存储元素的Pair对象 + private volatile Pair pair; + + // 构造函数,initialRef为初始值,initialStamp为初始的版本号 + public AtomicStampedReference(V initialRef, int initialStamp) { + pair = Pair.of(initialRef, initialStamp); + } + + +**get()** + + + public V get(int[] stampHolder) { + Pair pair = this.pair; + stampHolder[0] = pair.stamp; // 将当前值的版本设置到stampHolder[0] + return pair.reference; // 返回当前值 + } + +**compareAndSet()** + + + // expectedReference:期望的引用 + // newReference:新的引用 + // expectedStamp:期望版本号 + // newStamp:新的版本号 + public boolean compareAndSet(V expectedReference, + V newReference, + int expectedStamp, + int newStamp) { + // 获取当前的元素与版本号 + Pair current = pair; + + return + expectedReference == current.reference && // 引用未改变,就是元素的值没有被改变 + expectedStamp == current.stamp && // 元素的版本号没有改变 + ((newReference == current.reference && + newStamp == current.stamp) || // 如果新的元素和新的版本号都与旧元素相等,则不需要更新 + casPair(current, Pair.of(newReference, newStamp))); // CAS更新 + } + + private boolean casPair(Pair cmp, Pair val) { + return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val); + } + + diff --git "a/week_02/39/LongAdder\345\210\206\346\236\220.md" "b/week_02/39/LongAdder\345\210\206\346\236\220.md" new file mode 100644 index 0000000..ce50d07 --- /dev/null +++ "b/week_02/39/LongAdder\345\210\206\346\236\220.md" @@ -0,0 +1,205 @@ +LongAdder的java doc的描述: + +> One or more variables that together maintain an initially zero +{@code long} sum. When updates (method {@link #add}) are contended +across threads, the set of variables may grow dynamically to reduce +contention. Method {@link #sum} (or, equivalently, {@link +longValue}) returns the current total combined across the +variables maintaining the sum. +>

This class is usually preferable to {@link AtomicLong} when +> multiple threads update a common sum that is used for purposes such as +> collecting statistics, not for fine-grained synchronization control. +> Under low update contention, the two classes have similar +> characteristics. But under high contention, expected throughput of +> this class is significantly higher, at the expense of higher space +> consumption.

This class extends {@link Number}, but does +> not define methods such as {@code hashCode} and {@code +> compareTo} because instances are expected to be mutated, and so are +> not useful as collection keys. jsr166e note: This class is targeted to +> be placed in java.util.concurrent.atomic + +翻译为: + +> LongAdder中会维护一个或多个变量,这些变量共同组成一个long型的“和”。当多个线程同时更新(特指“add”)值时,为了减少竞争,可能会动态地增加这组变量的数量。“sum”方法(等效于longValue方法)返回这组变量的“和”值。 +当我们的场景是为了统计技术,而不是为了更细粒度的同步控制时,并且是在多线程更新的场景时,LongAdder类比AtomicLong更好用。 在小并发的环境下,论更新的效率,两者都差不多。但是高并发的场景下,LongAdder有着明显更高的吞吐量,但是有着更高的空间复杂度。 +> +> 从上面的java +> doc来看,LongAdder有两大方法,add和sum。其更适合使用在多线程统计计数的场景下,在这个限定的场景下比AtomicLong要高效一些,下面我们来分析下为啥在这种场景下LongAdder会更高效。 + +**add方法** + + + public void add(long x) { + Cell[] as; long b, v; HashCode hc; Cell a; int n; + //首先判断cells是否还没被初始化,并且尝试对value值进行cas操作 + if ((as = cells) != null || !casBase(b = base, b + x)) { + boolean uncontended = true; + //查看当前线程中HashCode中存储的随机值 + int h = (hc = threadHashCode.get()).code; + //此处有多个判断条件,依次是 + //1.cell[]数组还未初始化 + //2.cell[]数组虽然初始化了但是数组长度为0 + //3.该线程所对应的cell为null,其中要注意的是,当n为2的n次幂时,((n - 1) & h)等效于h%n + //4.尝试对该线程对应的cell单元进行cas更新(加上x) + if (as == null || (n = as.length) < 1 || + (a = as[(n - 1) & h]) == null || + !(uncontended = a.cas(v = a.value, v + x))) + //在以上条件都失效的情况下,重试update + retryUpdate(x, hc, uncontended); + } + } + + //一个ThreadLocal类 + static final class ThreadHashCode extends ThreadLocal { + public HashCode initialValue() { return new HashCode(); } + } + + + static final ThreadHashCode threadHashCode = new ThreadHashCode(); + + + //每个HashCode在初始化时会产生并保存一个非0的随机数 + static final class HashCode { + static final Random rng = new Random(); + int code; + HashCode() { + int h = rng.nextInt(); // Avoid zero to allow xorShift rehash + code = (h == 0) ? 1 : h; + } + } + + //尝试使用casBase对value值进行update,baseOffset是value相对于LongAdder对象初始位置的内存偏移量 + final boolean casBase(long cmp, long val) { + return UNSAFE.compareAndSwapLong(this, baseOffset, cmp, val); + } + +add方法的注释在其中,让我们再看看重要的retryUpdate方法。retryUpdate在上述四个条件都失败的情况下尝试再次update,我们猜测在四个条件都失败的情况下在retryUpdate中肯定都对应四个条件失败的处理方法,并且update一定要成功,所以肯定有相应的循环+cas的方式出现。 + + + final void retryUpdate(long x, HashCode hc, boolean wasUncontended) { + int h = hc.code; + boolean collide = false; // True if last slot nonempty + //我们猜测的for循环 + for (;;) { + Cell[] as; Cell a; int n; long v; + //这个if分支处理上述四个条件中的3和4,此时cells数组已经初始化了并且长度大于0 + if ((as = cells) != null && (n = as.length) > 0) { + //该分支处理四个条件中的3分支,线程对应的cell为null + if ((a = as[(n - 1) & h]) == null) { + //如果busy锁没被占有 + if (busy == 0) { // Try to attach new Cell + //新建一个cell + Cell r = new Cell(x); // Optimistically create + //double check busy,并且尝试锁busy(乐观锁) + if (busy == 0 && casBusy()) { + boolean created = false; + try { // Recheck under lock + Cell[] rs; int m, j; + if ((rs = cells) != null && + (m = rs.length) > 0 && + rs[j = (m - 1) & h] == null) { + //再次确认线程hashcode所对应的cell为null,将新建的cell赋值 + rs[j] = r; + created = true; + } + } finally { + //解锁 + busy = 0; + } + if (created) + break; + //如果失败,再次尝试 + continue; // Slot is now non-empty + } + } + collide = false; + } + //处理四个条件中的条件4,置为true后交给循环重试 + else if (!wasUncontended) // CAS already known to fail + wasUncontended = true; // Continue after rehash + //尝试给线程对应的cell update + else if (a.cas(v = a.value, fn(v, x))) + break; + else if (n >= NCPU || cells != as) + collide = false; // At max size or stale + else if (!collide) + collide = true; + //在以上办法都不管用的情况下尝试扩大cell + else if (busy == 0 && casBusy()) { + try { + if (cells == as) { // Expand table unless stale + //扩大一倍,将前N个拷贝过去 + Cell[] rs = new Cell[n << 1]; + for (int i = 0; i < n; ++i) + rs[i] = as[i]; + cells = rs; + } + } finally { + busy = 0; + } + collide = false; + continue; // Retry with expanded table + } + //rehash下,走到这一步基本是因为多个线程的竞争太激烈了,所以在扩展cell后rehash h,等待下次循环处理好这次更新 + h ^= h << 13; // Rehash + h ^= h >>> 17; + h ^= h << 5; + } + //主要针对上述四个条件中的1.2,此时cells还未进行第一次初始化,其中casBusy的理解参照下面busy的 注释,如果casBusy能成功才进入这个分支 + else if (busy == 0 && cells == as && casBusy()) { + boolean init = false; + try { // Initialize table + if (cells == as) { + //创建数量为2的cell数组,2很重要,因为每次都是n<<1进行扩大一倍的,所以n永远是2的幂 + Cell[] rs = new Cell[2]; + //需要注意的是h&1 = h%2,将线程对应的cell初始值设置为x + rs[h & 1] = new Cell(x); + cells = rs; + init = true; + } + } finally { + //释放busy锁 + busy = 0; + } + if (init) + break; + } + //busy锁不成功或者忙,则再重试一次casBase对value直接累加 + else if (casBase(v = base, fn(v, x))) + break; // Fall back on using base + } + hc.code = h; // Record index for next time + } + + /** + * Spinlock (locked via CAS) used when resizing and/or creating Cells. + 通过cas实现的自旋锁,用于扩大或者初始化cells + */ + transient volatile int busy; + + +从以上分析来看,retryUpdate非常的复杂,所做的努力就是为了尽量减少多个线程更新同一个值value,能用简单的方式解决的绝对不采用开销更大的方法(resize cell也是走投无路的时候) + +回过头来总结分析下LongAdder减少冲突的方法以及在求和场景下比AtomicLong更高效的原因 + +首先和AtomicLong一样,都会先采用cas方式更新值 +在初次cas方式失败的情况下(通常证明多个线程同时想更新这个值),尝试将这个值分隔成多个cell(sum的时候求和就好),让这些竞争的线程只管更新自己所属的cell(因为在rehash之前,每个线程中存储的hashcode不会变,所以每次都应该会找到同一个cell),这样就将竞争压力分散了 + +**sum方法** + + + public long sum() { + long sum = base; + Cell[] as = cells; + if (as != null) { + int n = as.length; + for (int i = 0; i < n; ++i) { + Cell a = as[i]; + if (a != null) + sum += a.value; + } + } + return sum; + } + +sum方法就简单多了,将cell数组中的value求和就好 diff --git a/week_02/39/Unsafe.md b/week_02/39/Unsafe.md new file mode 100644 index 0000000..d519aac --- /dev/null +++ b/week_02/39/Unsafe.md @@ -0,0 +1,121 @@ +Java和C++语言的一个重要区别就是Java中我们无法直接操作一块内存区域,不能像C++中那样可以自己申请内存和释放内存。Java中的Unsafe类为我们提供了类似C++手动管理内存的能力。 +Unsafe类,全限定名是`sun.misc.Unsafe,`从名字中我们可以看出来这个类对普通程序员来说是“危险”的,一般应用开发者不会用到这个类。 +Unsafe类是在`sun.misc`包下,不属于Java标准。但是很多Java的基础类库,包括一些被广泛使用的高性能开发库都是基于Unsafe类开发的,比如Netty、Cassandra、Hadoop、Kafka等。Unsafe类在提升Java运行效率,增强Java语言底层操作能力方面起了很大的作用。 + +我们看下下面的源码: + + public final class Unsafe { + private static final Unsafe theUnsafe; + public static final int INVALID_FIELD_OFFSET = -1; + + private static native void registerNatives(); + // 构造函数是private的,不允许外部实例化 + private Unsafe() { + } + ... + } + +由此看出Unsafe类是"final"的,不允许继承。且构造函数是private的:因此我们无法在外部对Unsafe进行实例化。 + +## 获取Unsafe +既然Unsafe无法实例化,那我们如何获取Unsafe呢?看下面部分源码: + + + public Unsafe getUnsafe() throws IllegalAccessException { + + Field unsafeField = Unsafe.class.getDeclaredFields()[0]; + + unsafeField.setAccessible(true); + + Unsafe unsafe = (Unsafe) unsafeField.get(null); + + return unsafe; + + } + +如此看来是通过反射来获取的。 + +## Unsafe主要功能: +![在这里插入图片描述](https://img-blog.csdnimg.cn/20191223203011963.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L01pYW9zaHVvd2Vu,size_16,color_FFFFFF,t_70) +**普通读写** + +通过Unsafe可以读写一个类的属性,即使这个属性是私有的,也可以对这个属性进行读写。 + +读写一个Object属性的相关方法 + + + + public native int getInt(Object var1, long var2); + + public native void putInt(Object var1, long var2, int var4); + +getInt用于从对象的指定偏移地址处读取一个int。putInt用于在对象指定偏移地址处写入一个int。其他的primitive type也有对应的方法。 +Unsafe还可以直接在一个地址上读写,方法如下: + + + public native byte getByte(long var1); + + public native void putByte(long var1, byte var3); + +getByte用于从指定内存地址处开始读取一个byte。putByte用于从指定内存地址写入一个byte。其他的primitive type也有对应的方法。 + +**volatile读写** + +普通的读写无法保证可见性和有序性,而volatile读写就可以保证可见性和有序性。 + + + public native int getIntVolatile(Object var1, long var2); + + public native void putIntVolatile(Object var1, long var2, int var4); + +getIntVolatile方法用于在对象指定偏移地址处volatile读取一个int。putIntVolatile方法用于在对象指定偏移地址处volatile写入一个int。 + +volatile读写相对普通读写是更加昂贵的,因为需要保证可见性和有序性,而与volatile写入相比putOrderedXX写入代价相对较低,putOrderedXX写入不保证可见性,但是保证有序性,所谓有序性,就是保证指令不会重排序。 + +**有序写入** + +有序写入只保证写入的有序性,不保证可见性,就是说一个线程的写入不保证其他线程立马可见。 + + + public native void putOrderedObject(Object var1, long var2, Object var4); + + public native void putOrderedInt(Object var1, long var2, int var4); + + public native void putOrderedLong(Object var1, long var2, long var4); + +**内存管理** + +该部分包括了allocateMemory(分配内存)、reallocateMemory(重新分配内存)、copyMemory(拷贝内存)、freeMemory(释放内存 )、getAddress(获取内存地址)、addressSize、pageSize、getInt(获取内存地址指向的整数)、getIntVolatile(获取内存地址指向的整数,并支持volatile语义)、putInt(将整数写入指定内存地址)、putIntVolatile(将整数写入指定内存地址,并支持volatile语义)、putOrderedInt(将整数写入指定内存地址、有序或者有延迟的方法)等方法。getXXX和putXXX包含了各种基本类型的操作。 + +利用copyMemory方法,我们可以实现一个通用的对象拷贝方法,无需再对每一个对象都实现clone方法,当然这通用的方法只能做到对象浅拷贝。 + +**多线程同步、CAS** + +这部分包括了monitorEnter、tryMonitorEnter、monitorExit compareAndSwapInt、compareAndSwap等方法。 + +其中monitorEnter、tryMonitorEnter、monitorExit已经被标记为deprecated,不建议使用。 + +Unsafe类的CAS操作可能是用的最多的,它为Java的锁机制提供了一种新的解决办法,比如AtomicInteger等类都是通过该方法来实现的。compareAndSwap方法是原子的,可以避免繁重的锁机制,提高代码效率。这是一种乐观锁,通常认为在大部分情况下不出现竞态条件,如果操作失败,会不断重试直到成功。 + +**偏移量相关** + + + public native long staticFieldOffset(Field var1); + + public native long objectFieldOffset(Field var1); + + public native Object staticFieldBase(Field var1); + + public native int arrayBaseOffset(Class var1); + + public native int arrayIndexScale(Class var1); + +staticFieldOffset方法用于获取静态属性Field在对象中的偏移量,读写静态属性时必须获取其偏移量。objectFieldOffset方法用于获取非静态属性Field在对象实例中的偏移量,读写对象的非静态属性时会用到这个偏移量。staticFieldBase方法用于返回Field所在的对象。arrayBaseOffset方法用于返回数组中第一个元素实际地址相对整个数组对象的地址的偏移量。arrayIndexScale方法用于计算数组中第一个元素所占用的内存空间。 + +**操作类、对象、变量** + +这部分包括了staticFieldOffset(静态域偏移)、defineClass(定义类)、defineAnonymousClass(定义匿名类)、ensureClassInitialized(确保类初始化)、objectFieldOffset(对象域偏移)等方法。 + +通过这些方法我们可以获取对象的指针,通过对指针进行偏移,我们不仅可以直接修改指针指向的数据(即使它们是私有的),甚至可以找到JVM已经认定为垃圾、可以进行回收的对象。 + + -- Gitee