JVM快速入门

前言

JVM是JavaVirtualMachine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
理解JAVA虚拟机对我们学习Java有巨大的帮助,接下来我们来快速入门Java虚拟机,更深入的理解还需要大家在不断地学习中探索。

注:本篇文章大部分参考于【狂神说Java】JVM快速入门篇
部分参考于尚硅谷的教程尚硅谷宋红康JVM全套教程(详解java虚拟机),如果问题,欢迎大家踊跃提出!

JVM常见面试题

  1. 请你谈谈你对JVM的理解?java8虚拟机和之前的变化更新?
  2. 什么是OOM,什么是栈溢出StackOverFlowError?怎么分析?
  3. JVM的常用调优参数有哪些?
  4. 内存快照如何抓取,怎么分析Dump文件?
  5. 谈谈JVM中,类加载器你的认识?

以下我们会对这些问题进行详细的分析
接下来我们进入正题!!!

1、JVM的位置

2、JVM的体系结构

  • 简要过程

  • 具体过程

3、类加载器

作用:加载Class文件

new一个对象的过程(对象实例化的过程)

public class Car {
    public int age;
    
    public static void main(String[] args) {
        //类是模板,对象是具体的

        Car car1 = new Car();
        Car car2 = new Car();
        Car car3 = new Car();
        
        car1.age = 1;
        car2.age = 2;
        car3.age = 3;

        System.out.println(car1.hashCode());
        System.out.println(car2.hashCode());
        System.out.println(car3.hashCode());

        Class<? extends Car> aClass1 = car1.getClass();
        Class<? extends Car> aClass2 = car1.getClass();
        Class<? extends Car> aClass3 = car1.getClass();


        System.out.println(aClass1.hashCode());
        System.out.println(aClass2.hashCode());
        System.out.println(aClass3.hashCode());

    }
}

结果:

类加载器

1、虚拟机自带的加载器

2、启动类(根)加载器

3、扩展类加载器

4、应用程序(系统类)加载器

类加载器的常用方法

ClassLoader类,它是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)

方法名称 描述
getParent() 返回该类加载器的超类加载器
loadClass(String name) 加载名称为name的类,返回结果为java.lang.Class类的实例
findClass(String name) 查找名称为name的类,返回结果为java.lang.Class类的实例
findLoadedClass(String name) 查找名称为name的已经被加载过的类,返回结果为java.lang.Class类的实例
defineClass(String name,byte[] b,int off,int len) 把字节数组b中的内容转换为一个Java类 ,返回结果为java.lang.Class类的实例
resolveClass(Class<?> c) 连接指定的一个java类

代码实例

public class Car {
    public int age;

    public static void main(String[] args) {
        //类是模板,对象是具体的

        Car car1 = new Car();
        Car car2 = new Car();
        Car car3 = new Car();

        car1.age = 1;
        car2.age = 2;
        car3.age = 3;

        System.out.println(car1.hashCode());
        System.out.println(car2.hashCode());
        System.out.println(car3.hashCode());

        Class<? extends Car> aClass1 = car1.getClass();


        ClassLoader classLoader = aClass1.getClassLoader();
        System.out.println(classLoader);                         //AppClassLoader  应用程序(系统类)加载器
        System.out.println(classLoader.getParent());             //ExtClassLoader  扩展类加载器
        System.out.println(classLoader.getParent().getParent()); //null   1.不存在   2.java程序获取不到


    }
}

4、双亲委派机制

4.1、简介:

Java虚拟机对class文件采用的是按需加载的方式,

也就是说当需要使用该类时才会将它的class文件加载到内存生成的class对象。

而且加载某个类的class文件时,java虚拟机采用的是双亲委派模式。

即把请求交由父类处理,它是一种任务委派模式

4.2、工作原理:

  1. 如果一个类加载器收到了类加载的请求,它并不会自己加载,而是先把请求委托给父类的加载器执行

  2. 如果父类加载器还有父类,则进一步向上委托,依次递归,请求到达最顶层的引导类加载器。

  3. 如果顶层类的加载器加载成功,则成功返回。如果失败,则子加载器会尝试加载。直到加载成功。

  4. 如果步骤3不成功,则就可能会报Class Not Found

  • 查看最顶层父类ClassLoader的loaderClass方法,我们可以验证双亲委派机制。

    protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException
        {
            synchronized (getClassLoadingLock(name)) {
                // 首先检查此类是否被加载过了 
                Class<?> c = findLoadedClass(name);
                if (c == null) {
                    long t0 = System.nanoTime();
                    try {
                        // 调用父类的加载器方法
                        if (parent != null) {
                            c = parent.loadClass(name, false);
                        } else {
                            // 此时是最顶级的启动类加载器
                            c = findBootstrapClassOrNull(name);
                        }
                    } catch (ClassNotFoundException e) {
                        // 抛出异常说明父类无法加载
                    }
    
                    if (c == null) {
                        //父类无法加载的时候,由子类进行加载。
                        // to find the class.
                        long t1 = System.nanoTime();
                        c = findClass(name);
                        //记录加载时间已经加载耗时
                        sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                        sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                        sun.misc.PerfCounter.getFindClasses().increment();
                    }
                }
                if (resolve) {
                    resolveClass(c);
                }
                return c;
            }
        }
    
    

4.3、双亲委派机制的优势

  • 避免类的重复加载

    当自己程序中定义了一个和Java.lang包同名的类,此时,由于使用的是双亲委派机制,会由启动类加载器去加载JAVA_HOME/lib中的类,而不是加载用户自定义的类。此时,程序可以正常编译,但是自己定义的类无法被加载运行。

  • 保护程序安全,防止核心API被随意篡改

5、沙箱安全机制(了解)

5.1、定义

自定义String类,但是在加载自定义String类的时候会率先使用引导类加载器加载, 而引导类加载器在加载过程中会先加载jdk自带的文件(rt.jar包中的java\lang\String.class), 报错信息说没有main方法就是因为加载的是rt.jar包中的String类。 这样可以保证对java核心源代码的保护,这就是沙箱安全机制.

5.2、类的主动使用和被动使用

java程序对类的使用方式分为:主动使用和被动使用

  • 主动使用,分为七种情况
    1. 创建类的实例
    2. 访问某各类或接口的静态变量,或者对静态变量赋值
    3. 调用类的静态方法
    4. 反射 比如Class.forName(com.dsh.jvm.xxx)
    5. 初始化一个类的子类
    6. java虚拟机启动时被标明为启动类的类
    7. JDK 7 开始提供的动态语言支持:
    8. java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化
  • 除了以上七种情况,其他使用java类的方式都被看作是对类的被动使用,都不会导致类的初始化。

6、Native、方法区

Native

public class Demo {
    public static void main(String[] args) {
        new Thread(() ->{

        },"my thread name").start();
    }

    //native:凡是带了native关键字的,说明java的作用范围达不到了,会去调用底层C语言的库
    //会进入本地方法栈,会调用本地方法接口,JNI
    //JNI(JavaNativeInterface)的作用:扩展Java的使用,融合不同的编程语言为Java所用(像C,C++)
    //它在内存区域中专门开辟了一块标记区域:Native Method Stack,登记 native 方法
    //在最终执行的时候,通过JNI加载本地方法库中的方法


    //Java程序驱动打印机,管理系统,掌握即可,在企业级应用中较为少见
    private native void start0();
}
  • start()方法
// Thread类中的start方法,底层是把线程加入到线程组,然后去调用本地方法start0
 public class Thread implements Runnable {
         public synchronized void start() {
         if (threadStatus != 0)
             throw new IllegalThreadStateException();
         group.add(this);
         boolean started = false;
         try {
             start0();
             started = true;
         } finally {
             try {
                 if (!started) {
                     group.threadStartFailed(this);
                 }
             } catch (Throwable ignore) {
                 /* do nothing. If start0 threw a Throwable then
                   it will be passed up the call stack */
             }
         }
     }
     private native void start0();
 }

程序计数器

程序计数器:Program Counter Register
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向像一条指令的地址,也即将要执行的指令代码),在执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计

方法区

Method Area方法区(此区域属于共享区间,所有定义的方法的信息都保存在该区域)
方法区是被所有线程共享,所有字段、方法字节码、以及一些特殊方法(如构造函数,接口代码)也在此定义。

静态变量、常量、类信息(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关。

static final Class 常量池

7、栈

7.1、栈的作用

栈内存,主管程序的运行,生命周期和线程同步;
线程结束,栈内存也就释放了,对于栈来说,不存在垃圾回收问题

7.2、栈存储的东西

8大基本类型、对象引用,实例的方法

7.3、栈运行原理

简单结构图

详细结构图

栈+堆+方法区的交互关系

8、堆

8.1、三种JVM(了解)

  • Sun公司 HotSpot(常用)

  • BEA JRockit

  • IBM J9VM

8.2、堆

Heap,一个JVM只有一个堆内存,堆内存的大小是可以调节的。

类加载器读取了类文件后,一般会把什么东西放到堆中?
类、方法、常量、变量、保存我们所有引用类型的真实对象。

堆内存中细分为三个区域:

  • 新生区(伊甸园区)Young/New
  • 养老区 old
  • 永久区 Perm

GC垃圾回收,主要是在新生区和老年区

假设内存满了,OOM,堆内存不够!

新生区

  • 类:诞生和成长的地方,甚至是死亡

  • 伊甸园,所有对象都是在伊甸园区new出来的

  • 幸存者区(from,to动态交换)

老年区

老年区是新生区剩下来的被淘汰的了(没有被杀死)

永久区

(1)什么是永久代和元空间??
方法区是一种规范,不同的虚拟机厂商可以基于规范做出不同的实现,永久代和元空间就是出于不同jdk版本的实现。
方法区就像是一个接口,永久代与元空间分别是两个不同的实现类。
只不过永久代是这个接口最初的实现类,后来这个接口一直进行变更,直到最后彻底废弃这个实现类,由新实现类—元空间进行替代。

jdk1.8之前:永久代

jdk1.8之后:元空间(逻辑上存在,物理上不存在)

(2)常量池

  • jdk1.6之前:永久代,运行时常量池+字符串常量池是存放在方法区中,HotSpot VM对方法区的实现称为永久代。

  • jdk1.7:永久代,但是慢慢退化了,去永久代,字符串常量池从方法区移到堆中,运行时常量池保留在方法区中

  • jdk1.8之后:无永久代,常量池在元空间,字符串常量池保留在堆中,运行时常量池保留在方法区中,只是实现不一样了,JVM内存变成了直接内存。

(3)永久区

这个区域是常驻内存的。
用来存放JDK自身携带的Class对象、Interface元数据,存储的是Java运行时的一些环境或类信息。
这个区域不存在垃圾回收
关闭JVM虚拟机就会释放这个区域的内存。

什么情况下,在永久区就崩了?

  • 一个启动类,加载了大量的第三方jar包。
  • Tomcat部署了太多的应用。
  • 大量动态生成的反射类;不断的被加载,直到内存满,就会出现OOM

9、堆内存调优

修改jvm参数

常见的jvm参数:

  • 标准参数选项

        -d32          使用 32 位数据模型 (如果可用)
        -d64          使用 64 位数据模型 (如果可用)
        -server       选择 "server" VM
                      默认 VM 是 server.
    
        -cp <目录和 zip/jar 文件的类搜索路径>
        -classpath <目录和 zip/jar 文件的类搜索路径>
                      用 ; 分隔的目录, JAR 档案
                      和 ZIP 档案列表, 用于搜索类文件。
        -D<名称>=<值>
                      设置系统属性
        -verbose:[class|gc|jni]
                      启用详细输出
        -version      输出产品版本并退出
        -version:<值>
                      警告: 此功能已过时, 将在
                      未来发行版中删除。
                      需要指定的版本才能运行
        -showversion  输出产品版本并继续
        -jre-restrict-search | -no-jre-restrict-search
                      警告: 此功能已过时, 将在
                      未来发行版中删除。
                      在版本搜索中包括/排除用户专用 JRE
        -? -help      输出此帮助消息
        -X            输出非标准选项的帮助
        -ea[:<packagename>...|:<classname>]
        -enableassertions[:<packagename>...|:<classname>]
                      按指定的粒度启用断言
        -da[:<packagename>...|:<classname>]
        -disableassertions[:<packagename>...|:<classname>]
                      禁用具有指定粒度的断言
        -esa | -enablesystemassertions
                      启用系统断言
        -dsa | -disablesystemassertions
                      禁用系统断言
        -agentlib:<libname>[=<选项>]
                      加载本机代理库 <libname>, 例如 -agentlib:hprof
                      另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help
        -agentpath:<pathname>[=<选项>]
                      按完整路径名加载本机代理库
        -javaagent:<jarpath>[=<选项>]
                      加载 Java 编程语言代理, 请参阅 java.lang.instrument
        -splash:<imagepath>
                      使用指定的图像显示启动屏幕
    
    
  • -X参数选项

        -Xmixed           混合模式执行(默认)
        -Xint             仅解释模式执行
        -Xbootclasspath:<用 ; 分隔的目录和 zip/jar 文件>
                          设置引导类和资源的搜索路径
        -Xbootclasspath/a:<用 ; 分隔的目录和 zip/jar 文件>
                          附加在引导类路径末尾
        -Xbootclasspath/p:<用 ; 分隔的目录和 zip/jar 文件>
                          置于引导类路径之前
        -Xdiag            显示附加诊断消息
        -Xnoclassgc        禁用类垃圾收集
        -Xincgc           启用增量垃圾收集
        -Xloggc:<file>    将 GC 状态记录在文件中(带时间戳)
        -Xbatch           禁用后台编译
        -Xms<size>        设置初始 Java 堆大小
        -Xmx<size>        设置最大 Java 堆大小
        -Xss<size>        设置 Java 线程堆栈大小
        -Xprof            输出 cpu 分析数据
        -Xfuture          启用最严格的检查,预计会成为将来的默认值
        -Xrs              减少 Java/VM 对操作系统信号的使用(请参阅文档)
        -Xcheck:jni       对 JNI 函数执行其他检查
        -Xshare:off       不尝试使用共享类数据
        -Xshare:auto      在可能的情况下使用共享类数据(默认)
        -Xshare:on        要求使用共享类数据,否则将失败。
        -XshowSettings    显示所有设置并继续
        -XshowSettings:system
                          (仅限 Linux)显示系统或容器
                          配置并继续
        -XshowSettings:all
                          显示所有设置并继续
        -XshowSettings:vm 显示所有与 vm 相关的设置并继续
        -XshowSettings:properties
                          显示所有属性设置并继续
        -XshowSettings:locale
                          显示所有与区域设置相关的设置并继续
    
    
  • 打印设置的参数

    • -XX:+PrintCommandLineFlags 表示程序运行前打印出JVM参数

    • -XX:+PrintFlagsInitial 表示打印出所有参数的默认值

    • -XX:+PrintFlagsFinal 打印出最终的参数值

    • -XX:+PrintVMOptions 打印JVM的参数

    • -Xss128k
    • -Xms600m 设置堆的初始大小

    • -Xmx600m 设置堆的最大大小

    • -XX:NewSize=1024m 设置年轻代的初始大小

    • -XX:MaxNewSize=1024m 设置年轻代的最大值

    • -XX:SurvivorRatio=8 伊甸园和幸存者的比例

    • -XX:NewRatio=4 设置老年代和新生代的比例

    • -XX:MaxTenuringThreshold=15 设置晋升老年代的年龄条件

  • 方法区

    • 永久代

      • -XX:PermSize=256m 设置永久代初始大小
      • -XX:MaxPernSize=256m 设置永久代的最大大小
    • 元空间

      • -XX:MetasapceSize=256m 设置初始元空间大小
      • -XX:MaxMatespaceSize=256m 设置最大元空间大小 默认无限制
  • 直接内存

    • -XX:MaxDirectMemorySize 设置直接内存的容量,默认与堆最大值一样。

我们来看看JVM所占的内存

public class Demo02 {
    public static void main(String[] args) {
        //返回虚拟机试图使用的最大内存
        long maxMemory = Runtime.getRuntime().maxMemory();//字节 1024*1024=1M
        //返回JVM初始化的总内存
        long totalMemory = Runtime.getRuntime().totalMemory();


        System.out.println("maxMemory="+maxMemory+"字节\t"+(maxMemory/(double)1024/1024)+"MB");
        System.out.println("totalMemory="+maxMemory+"字节\t"+(totalMemory/(double)1024/1024)+"MB");


        // 默认情况下:分配的最大内存是电脑内存的1/4;初始化的内存是电脑内存的1/64
        // 分析OOM:
        //          1.尝试扩大堆内存,看结果
        //          2.分析内存,看一下哪个地方出现了问题(专业工具)JProfiler

        //手动调参:
        //-Xms设置堆的最小空间大小。
        //-Xmx设置堆的最大空间大小。
        // -Xms1024m -Xmx1024m -XX:+PrintGCDetails
        // 305664K+699392K = 1005056K   981.5M(新生区加老年区,物理上不存在元空间)

    }
}

结果:

  • 接下来我们手动修改参数

再运行程序查看结果

  • 我们来看一个OOM错误

    public class Test {
        public static void main(String[] args) {
            String str = "sdagnvsihlnvuxjvns";
    
            while (true){
                str += str + new Random().nextInt(888888888) + new Random().nextInt(888888888);
            }
        }
    }
    

    修改jvm参数-Xms8m -Xmx8m -XX:+PrintGCDetails会更快发生OOM错误

使用JProfiler工具分析OOM原因

在一个项目中,突然出现了OOM故障OutOfMemoryError,那么该如何排除,研究为什么出错?

  • 能够看到代码第几行出错:内存快照分析工具,MAT,Jprofiler

  • Dubug,一行行分析代码!

MAT,Jprofiler作用:

  • 分析Dump内存文件,快速定位内存泄露
  • 获得堆中的数据
  • 获得大的对象
  • ...

具体使用Jprofile流程

实例分析(在idea中要下载Jprofiler插件并在官网下载Jprofiler):

public class Demo03 {
    byte[] array = new byte[1024*1024];

    public static void main(String[] args) {
        ArrayList<Demo03> list = new ArrayList<>();
        int count = 0;

        try {
            while (true){
                list.add(new Demo03());
                count = count + 1;

            }
        }catch (Error e){
            System.out.println("count:"+count);
            e.printStackTrace();
        }

    }
}

注:使用Jprofiler查看完分析文件后记得删除文件,因为会产生大量的分析文件占用存储空间

10、GC

10.1、垃圾回收的作用区域

JVM在进行GC时,并不是对这三个区域统一回收。大部分时候,回收都是新生代~

  • 新生代

  • 幸存区(form,to)

  • 老年区

GC两种类:轻GC(普通的GC),重GC(全局GC)

题目:

  1. JVM的内存模型和分区~详细到每个区放什么?
  2. 堆里面的分区有哪些?Eden,from,to,老年区,说说他们的特点
  3. GC的算法有哪些?标记清除法,标记压缩,复制算法,引用计数器
  4. 轻GC,重GC分别在什么时候发生?

10.2、引用计数法(不高效)

10.3、复制算法

复制算法简述:

复制算法轻GC流程:

复制算法小结:

  • 好处:没有内存的碎片。
  • 坏处:浪费了内存空间(多了一半空间to永远是空)。假设对象100%存活(极端情况(全部复制)),不适合使用复制算法
  • 复制算法最佳使用场景:对象存活度较低的时候(新生区)

10.4、标记压缩清除算法

标记清除

  • 优点:不需要额外的空间。
  • 缺点:两次扫描,严重浪费时间,会产生内存碎片

标记压缩

标记清除压缩

可以进行多次标记清除,再进行一次压缩

10.4、GC算法总结

  • 内存效率:复制算法>标记清除算法>标记压缩算法(时间复杂度)
  • 内存整齐度:复制算法=标记压缩算法>标记清除算法
  • 内存利用率:标记压缩算法=标记清除算法>复制算法

思考一个问题:难道没有最优算法吗?
答案:没有,没有最好的算法,只有最合适的算法——>GC:分代收集算法

年轻代:

  • 存活率低
  • 复制算法

老年代:

  • 区域大:存活率高
  • 标记清除(内存碎片不是太多)+标记压缩混合实现

11、JMM

11.1、 JMM是什么

JMM(Java Memory Model),Java的内存模型。

JVM虚拟机规范中曾经试图定义一种Java内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都可以达到一致性的内存访问效果。

11.2、 JMM的作用

缓存一致性的协议,用来定义数据读写的规则。

JMM定义了线程工作内存和主内存的抽象关系:线程的共享变量存储在主内存中,每个线程都有一个私有的本地工作内存。

使用volatile关键字来解决共享变量的可见性的问题。

Java内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的。

11.3、 JMM的操作

JMM定义了8种操作来完成(每一种操作都是原子的、不可再拆分的)。

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎(每当虚拟机遇到一个需要使用到该变量的值的字节码指令时将会执行这个操作)。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量(每当虚拟机遇到一个给该变量赋值的字节码指令时执行这个操作)。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

11.4、 JMM定义的规则

8种操作必须满足的规则:

  • 不允许read和load、store和write操作之一单独出现。(不允许一个变量从主内存读取了但工作内存不接受;或者从工作内存发起回写了但主内存不接受的情况出现)
  • 不允许一个线程丢弃它的最近的assign操作。(变量在工作内存中改变了值之后,必须把该变化同步回主内存)
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。(就是对一个变量实施use、store操作之前,必须先执行过了load和assign操作)
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

11.5、 并发编程的三大特性

  1. 原子性

一个或多个程序指令,要么全部正确执行完毕不能被打断,或者全部不执行

  1. 可见性

当一个线程修改了某个共享变量的值,其它线程应当能够立即看到修改后的值。

  1. 有序性

程序执行代码指令的顺序应当保证按照程序指定的顺序执行,即便是编译优化,也应当保证程序源语一致。

12、总结

这里有一张思维导图基本涵盖了Java虚拟机的内容,大家可以通过这张图来巩固上面学习的知识

到这里Java虚拟机的快速入门就结束了,希望大家都有所收获!

posted @ 2022-08-29 15:10  鹤鸣呦呦、、  阅读(83)  评论(0编辑  收藏  举报