关注「Java视界」公众号,获取更多技术干货

【三】不要问我JVM !—— 堆

一、堆(Heap)的核心概述

一个进程就对应一个JVM实例,一个JVM实例就只有一个运行时数据区,只对应一个堆及一个方法区。

一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。Java 堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。

《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(ThreadLocal Allocation BufferTLAB)。

《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated )
实际上是:“几乎”所有的对象实例都在这里分配内存。

数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。

在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

堆,是GC ( Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

二、设置堆的大小

  • Xms10m:堆的初始空间为10M ,等价于 -XX:InitialHeapSize
  • Xmx10M:堆的最大空间为10M, 等价于 -XX:MaxHeapSize

(-X是JVM的运行参数,ms是memory start,mx是memory max)

通常会将-Xms-Xmx两个参数配置相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。

默认情况下,初始内存大小:物理电脑内存大小 / 64
最大内存大小:物理电脑内存大小 / 4

public class ClassLoaderTest {
    public static void main(String[] args) {
        try {
            Class<?> aClass = Class.forName("JVM.GetClassLoader");
            ClassLoader classLoader = aClass.getClassLoader();
            Console.log("【1】{}", classLoader);
            ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
            Console.log("【2】{}", contextClassLoader);
            ClassLoader parent = ClassLoader.getSystemClassLoader().getParent();
            Console.log("【3】{}", parent);
            Thread.sleep(100000000);
        } catch (ClassNotFoundException | InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这里插入图片描述
在这里插入图片描述
运行以上代码,启动时设置堆内存为 10M,通过VisualVM可以看到eden+s0+s1+old的大小正好是10M。而堆细分下来正好是这三部分组成的。

三、堆内存细分

在这里插入图片描述

  • 永久代是JDK7及之前的叫法,JDK8后叫元空间
  • 实际物理上元空间不在堆内存中,而是在方法区。只是逻辑上认为永久代或元空间是在堆内存上的。实际方法区是独立于堆的一块内存空间。
  • 堆空间 = 年轻代 + 老年代

在上面的启动参数上加上GC日志打印参数:
在这里插入图片描述在这里插入图片描述
上面年轻代total只有2560K,因为年轻代的S1和S2只会有一个在使用中,另一个始终是空的。

还以这样查看堆内存情况:
在这里插入图片描述
在这里插入图片描述

四、堆内存 OOM

一旦堆区中的内存大小超过-Xmx所指定的最大内存时,将会抛出OutOfMemoryError异常。

以下每20毫秒在堆中创建一个大的byte数组对象,一段时间后堆内存就会耗光:

public class OOMDemo {
    public static void main(String[] args) {
        ArrayList<Picture> list = new ArrayList<>();
        while (true) {
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            list.add(new Picture(new Random().nextInt(1024 * 1024)));
        }
    }
}

class Picture {
    private byte[] picsize;

    public Picture(int length) {
        this.picsize = new byte[length];
    }
}

在这里插入图片描述
看下VisualVM的Visual GC页:
在这里插入图片描述
可以看到,老年代的对象是逐渐递增的,最终耗尽,OOM。
在这里插入图片描述
可以看到是byte[]数组类型的变量造成了OOM。

五、年轻代与老年代

在这里插入图片描述
堆分为年轻代(YoungGen)和老年代(OldGen)
其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间 (有时也叫做from区、to区)。

5.1 年轻代(YoungGen)和老年代(OldGen)的在堆中的占比

年轻代(YoungGen)和老年代(OldGen)的在堆中的占比是可以按下面的方式配置的:

  • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
  • 修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
    在这里插入图片描述在这里插入图片描述

以上就是默认的年轻代和老年代的比例。(20+40 = 60,新生代占1/3)

一般若老年代的对象较多时考虑修这个参数比例,将老年代扩大一些。

5.2 Eden、S0、S1在年轻代中的占比

Eden:S0:S1 默认是 8:1:1

可以通过选项-XX:SurvivorRatio调整这个空间比例。

六、对象在堆内存中的分配过程

  • 几乎所有的Java对象都是在Eden区被new出来的(还有一些大对象直接会在老年代生成)
  • 绝大部分的Java对象的销毁都在新生代进行了(IBM公司的研究表明,新生代中80%的对象都是“朝生夕死”的)
  • 可以使用选项-Xmn设置新生代最大内存大小(这个参数一般使用默认值就可以了)

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。

6.1 对象分配流程

  1. new的对象先放Eden区。此区有大小限制。
  2. Eden的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对Eden区进行垃圾回收(MinorGC),将Eden区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到Eden元区
  3. 然后将Eden中的剩余幸存对象移动到Survivor0区(Survivor 幸存者的意思)。
  4. 如果Eden又满了会再次触发MinorGC,会回收Eden和幸存区域的垃圾对象,此时上次幸存下来的放到Survivor0区的,如果没有回收,就会放到Survivor1区。
  5. 如果再次经历垃圾回收,此时会重新放回Survivor0区,接着再去Survivor1区。
  6. 啥时候能去老年区呢?可以设置次数。默认是15次。可以设置参数: -XX:MaxTenuringThreshold=<N>进行设置。
  7. 在老年区,相对悠闲。当老年区内存不足时,再次触发GC:(Major GC),进行老年区的内存清理。
  8. 若老年区执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常

① Eden区满的时候才会触发YGC或者说Minor GC这里是引用
② 红色是垃圾,数字是age,每次移动但不回收的对象age+1
在这里插入图片描述
③ s0和s1之间是互为from、 to 的,里面的对象每次Minor GC只后会由一个转移到另一个,它两总是一个有对象,一个是空的,那个空的在每次Minor GC后会变成to,即每次Minor GC时Eden和另一个有值的survivor里面幸存的对象会先移到这个空的survivor,age超过阈值就会进入老年代。
在这里插入图片描述
④ age默认是15,即15次Minor GC后,再幸存的对象都会进入老年代。

注意:

  1. 只有Eden区满的时候才会触发Minor GC,survivor区域满了不会触发
  2. 针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to
  3. 关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区(元空间)收集
  4. 如果对象在Eden出生并经过第一次Minor GC 后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。不能被Survivor容纳的,则会直接进入老年代,若老年代也放不下就会触发Full GC,之后再放不下就OOM

6.2 对象分配特殊的情况

上面是一般的对象内存分配过程,还有一些特殊的情况:
在这里插入图片描述
特殊情况主要是针对一些大对象的内存分配。

所谓的大对象,比如代码中比较长的字符串或者数组,需要连续的内存空间

上面介绍了年轻代和老年代的占堆内存的占比默认是1:2 (-XX:NewRatio=2),而保存新new出来的对象的Eden区域默认只占年轻代的80%,因此年轻代的空间有限。

一些超大对象在Eden中申请内存可能不够,此时就直接在老年代去创建。

若老年代也放不下,会触发FullGC,再往老年代放,若还是放不下就会OOM。

6.3 对象提升(Promotion)规则

针对不同年龄段的对象分配原则如下:

  • 优先分配到Eden
  • 大对象Eden放不下时直接分配到老年代(应尽量避免程序中出现过多的大对象)
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断
    ➢如果Survivor中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
  • 空间分配担保 -XX:HandlePromotionFailure
    ➢空间分配担保是说,Minor GC之后没有几个对象被回收,极端的情况是一个对象都没回收,这个时候年轻代就存不下这个新来的对象了(存的下也不会MinorGC了),那就会使用这个空间分配担保,把survivor区域无法容纳的对象保存到老年代(前提是老年代空间富余)

七、分配过程演示

在这里插入图片描述在这里插入图片描述
以上每隔10ms就创建一个HeapInstanceTest类型的对象放入List,这个对象又包含一个大的buffer对象,上面的介绍可知,新建的对象都是先放到Eden区域,当Eden满了就会触发Minor GC,因为有长生命周期的list对象引用在,因此这些对象都不会被回收,经过几番from、to之后最终会进入老年代,因此老年代对象会越来越多,最终OOM。

JvisualVM如下:
在这里插入图片描述
结合GC日志详情看下:

[GC (Allocation Failure) [PSYoungGen: 153600K->25585K(179200K)] 153600K->105194K(588800K), 0.0301019 secs] [Times: user=0.09 sys=0.33, real=0.03 secs] 
[GC (Allocation Failure) [PSYoungGen: 179185K->25590K(179200K)] 258794K->243887K(588800K), 0.0466722 secs] [Times: user=0.11 sys=0.50, real=0.05 secs] 
[GC (Allocation Failure) [PSYoungGen: 179169K->25587K(179200K)] 397466K->389284K(588800K), 0.0486050 secs] [Times: user=0.08 sys=0.52, real=0.05 secs] 
[Full GC (Ergonomics) [PSYoungGen: 25587K->0K(179200K)] [ParOldGen: 363697K->388293K(409600K)] 389284K->388293K(588800K), [Metaspace: 9668K->9668K(1058816K)], 0.0971607 secs] [Times: user=1.03 sys=0.00, real=0.10 secs] 
[Full GC (Ergonomics) [PSYoungGen: 153600K->126664K(179200K)] [ParOldGen: 388293K->409332K(409600K)] 541893K->535996K(588800K), [Metaspace: 9729K->9729K(1058816K)], 0.0465593 secs] [Times: user=0.50 sys=0.09, real=0.05 secs] 
[Full GC (Ergonomics) [PSYoungGen: 153600K->152490K(179200K)] [ParOldGen: 409332K->409332K(409600K)] 562932K->561822K(588800K), [Metaspace: 9734K->9734K(1058816K)], 0.0135729 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 153600K->153046K(179200K)] [ParOldGen: 409332K->409544K(409600K)] 562932K->562590K(588800K), [Metaspace: 9734K->9667K(1058816K)], 0.1266872 secs] [Times: user=1.27 sys=0.00, real=0.13 secs] 
[Full GC (Ergonomics) [PSYoungGen: 153600K->153174K(179200K)] [ParOldGen: 409544K->409544K(409600K)] 563144K->562719K(588800K), [Metaspace: 9667K->9667K(1058816K)], 0.0186866 secs] [Times: user=0.31 sys=0.00, real=0.02 secs] 
[Full GC (Ergonomics) [PSYoungGen: 153600K->153174K(179200K)] [ParOldGen: 409544K->409544K(409600K)] 563144K->562719K(588800K), [Metaspace: 9667K->9667K(1058816K)], 0.0112202 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 153600K->153315K(179200K)] [ParOldGen: 409544K->409544K(409600K)] 563144K->562860K(588800K), [Metaspace: 9667K->9667K(1058816K)], 0.0107946 secs] [Times: user=0.20 sys=0.00, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 153600K->153425K(179200K)] [ParOldGen: 409544K->409544K(409600K)] 563144K->562970K(588800K), [Metaspace: 9667K->9667K(1058816K)], 0.0098381 secs] [Times: user=0.20 sys=0.02, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 153600K->153425K(179200K)] [ParOldGen: 409544K->409544K(409600K)] 563144K->562970K(588800K), [Metaspace: 9667K->9667K(1058816K)], 0.0097163 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 153600K->153425K(179200K)] [ParOldGen: 409544K->409544K(409600K)] 563144K->562970K(588800K), [Metaspace: 9667K->9667K(1058816K)], 0.0097015 secs] [Times: user=0.20 sys=0.02, real=0.01 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 153425K->153425K(179200K)] [ParOldGen: 409544K->409507K(409600K)] 562970K->562933K(588800K), [Metaspace: 9667K->9589K(1058816K)], 0.0839591 secs] [Times: user=0.86 sys=0.05, real=0.08 secs] 
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at JVM.HeapInstanceTest.<init>(HeapInstanceTest.java:7)
	at JVM.HeapInstanceTest.main(HeapInstanceTest.java:12)
[Full GC (Ergonomics) [PSYoungGen: 153600K->0K(179200K)] [ParOldGen: 409517K->2599K(409600K)] 563117K->2599K(588800K), [Metaspace: 9620K->9620K(1058816K)], 0.0132342 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen      total 179200K, used 1590K [0x00000000f3800000, 0x0000000100000000, 0x0000000100000000)
  eden space 153600K, 1% used [0x00000000f3800000,0x00000000f398d8a8,0x00000000fce00000)
  from space 25600K, 0% used [0x00000000fce00000,0x00000000fce00000,0x00000000fe700000)
  to   space 25600K, 0% used [0x00000000fe700000,0x00000000fe700000,0x0000000100000000)
 ParOldGen       total 409600K, used 2599K [0x00000000da800000, 0x00000000f3800000, 0x00000000f3800000)
  object space 409600K, 0% used [0x00000000da800000,0x00000000daa89c58,0x00000000f3800000)
 Metaspace       used 9624K, capacity 9922K, committed 10240K, reserved 1058816K
  class space    used 1070K, capacity 1158K, committed 1280K, reserved 1048576K
Disconnected from the target VM, address: '127.0.0.1:62032', transport: 'socket'

可以看到报OOM前会进行至少一次Full GC

另外,若没有发生GC,是不会打印GC日志的。

八、Minor GC、Major GC、Full GC

JVM在进行GC时,并非每次都对上面三个内存(新生代、老年代、元空间)区域一起回收的,大部分时候回收的都是指新生代。

针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

  1. 部分收集:
    ➢新生代收集(Minor GC / Young GC) :只是新生代的垃圾收集
    ➢老年代收集(Major GC / Old GC) :只是老年代的垃圾收集。
    目前,只有CMS GC(并发垃圾回收器)会有单独收集老年代的行为。
    注意很多时候Major GC会和Full GC混淆使用,要具体分辨是老年代回收还是整堆回收。
  2. 混合收集(Mixed GC): 收集整个新生代以及部分老年代的垃圾收集。
    目前,只有G1 GC会有这种行为
  3. 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。

8.1 Minor GC 触发条件

  • 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC(但每次Minor GC会清理年轻代Eden + Survivor的内存)
  • Java对象大多都具备朝生夕灭的特性,Minor GC非常频繁,一般回收速度也比较快。
  • Minor GC会引发STW(Stop The World), 暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。

8.2 Major GC 触发条件

  1. 指发生在老年代的GC,对象从老年代消失时,我们说Major GC发生了
  2. 出现了Major GC,经常会伴随至少一次的Minor GC (但非绝对的,在ParallelScavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。
  3. 也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC
  4. Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。
  5. 如果Major GC后,内存还不足,就报OOM了。

8.3 Full GC 触发机制

触发Full GC执行的情况有如下五种:

  1. 调用System.gc()时,系统建议执行Full GC,但是不必然执行
  2. 老年代空间不足
  3. 方法区空间不足
  4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  5. Eden区、Survivor0 (From Space)区向Survivor1 (To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

Full GC是开发或调优中尽量要避免的,这样暂时时间会短一些。

九、堆空间分代思想

在这里插入图片描述为什么需要把Java堆分代?不分代就不能正常工作了吗?

经研究,不同对象的生命周期不同,70%-99%的对象是临时对象。
➢新生代:有Eden、 两块大小相同的Survivor (又称为from/to,s0/s1) 构成,to总为空。.
➢老年代:存放新生代中经历多次GC仍然存活的对象。

其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样在更短的时间能腾出更大的空间。

十、TLAB

TLABThread Local Allocation Buffer)线程本地分配缓冲区

10.1 为什么有TLAB ?

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据。
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的,为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。

10.2 什么是TLAB ?

  • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量
    在这里插入图片描述
  • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
  • 在程序中,开发人员可以通过选项-XX:UseTLAB设置是否开启TLAB空间
  • 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小
  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存
  • 这一块是堆内存线程私有的区域,所以堆内存也不完全是数据共享的
    在这里插入图片描述

十一、堆相关参数

  1. -XX:+PrintFlagsInitial :查看所有的参数的默认初始值
  2. -XX:+PrintFlagsFinal :查看所有的参数的最终值(可能会存在修改,不再是初始值)
  3. -Xms:初始堆空间内存( 默认为物理内存的1/64)
  4. -Xmx:最大堆空间内存(默认为物理内存的1/4)
  5. -Xmn:设置新生代的大小。(初始值 及最大值)
  6. -XX:NewRatio:配置新生代与老年代在堆结构的占比
  7. -XX:SurvivorRatio:设置新生代中EdenS0/S1空间的比例
  8. -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
  9. -XX:+PrintGCDetails:输出详细的GC处理日志
  10. -XX:+PrintGC-verbose:gc :打印gc简要信息
  11. -XX: HandlePromotionFailure:是否设置空间分配担保

-XX: HandlePromotionFailure 说明:
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

  • 如果大于,则此次Minor GC是安全的
  • 如果小于,则虛拟机会查看-XX:HandlePromotionFailure设置值是否允许担保失败。
    ➢如果HandlePromotionFailure=true, 那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
    如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
    如果小于,则改为进行一次Full GC。
    ➢如果HandlePromotionFailure=false, 则改为进行一次Full GC。
  • 在JDK6之后(JDK7),HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,虽然源码中还定义了HandlePromotionFailure参数, 但是在代码中已经不会再使用它。JDK6之后(JDK7)之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

十二、堆小结

  • 年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
  • 老年代放置长生命周期的对象,通常都是从Survivor区域筛(年龄计数器为15的对象)选拷贝过来的Java对象。
  • 普通的对象可能会被分配在TLAB上;
  • 如果对象较大,无法分配在 TLAB 上,则JVM会试图直接分配在Eden其他位置上
  • 如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代
  • 当GC只发生在年轻代中,回收年轻代对象的行为被称为Minor GC。
  • GC发生在老年代时则被称为Major GC或者Full GC。
  • 一般的,Minor GC的发生频率要比Major GC高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。 (因为new的对象一般都分配在新生代, 新生代的对象都是朝生夕死的, 所以GC得频率很高)

补充一、如何解决OOM ?

  • 1、要解决OOM异常或heapspace的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer) 对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(MemoryLeak)还是内存溢出(Memory Overflow)
  • 2、如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GCRoots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。
  • 3、如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx-Xms) 与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
posted @ 2022-06-25 14:01  沙滩de流沙  阅读(26)  评论(0编辑  收藏  举报

关注「Java视界」公众号,获取更多技术干货