关于java:我所知道JVM虚拟机之类加载子系统内存结构类加载器与类加载过程

2次阅读

共计 12755 个字符,预计需要花费 32 分钟才能阅读完成。

前言


从本篇文章开始进入 JVM 的学习,后面咱们介绍了 JAVA、JVM 等等

这篇文章咱们从类加载子系统开始进入学习

一、内存构造概述


请先看以下的简图,class Files 咱们称为字节码,从字节码开始后续操作都须要 JVM 负责

第一步:咱们要将 Class 文件加载到内存当中,而类加载须要用到类加载子系统 Class Loader 来进行加载

同时对应到咱们的内存当中,生成一个大的 Class 对象并且将必要的动态属性进行初始化等等(办法区提现)

第二步:当咱们真正去执行字节码指令的时候,就须要执行引擎去发挥作用,依照咱们程序的字节码指令去顺次执行(波及到虚拟机栈里去局部变量表取数据,以及操作入栈),若须要创建对象的话还须要用到堆空间

第三步:当程序持续往下走的时候,还会用到程序计数器,若用到本地的 C 类库,还须要用到本地办法栈

下面是简图,咱们能够看以下的具体图

依据具体图,咱们能够看到类加载子系统分三个局部

  • 加载阶段
  • 链接阶段
  • 初始化阶段

如图所知加载阶段分三个环节:疏导类、扩大类、零碎类等加载器

如图所知链接阶段分三个环节:验证、筹备、解析

紧接着就是动态变量的一个显示初始化,接下来就将每个字节码文件要用到的,在对应的在内存中把类或者接口加载进来

在内存层面运行时数据区有:PC 寄存器(程序计数器)、栈(虚拟机栈)、本地办法栈、堆区、办法区

PC 寄存器:每一个线程一份

虚拟机栈:每一个线程一份,每一个线程用的栈外面一个一个构造称为栈桢,栈桢又分为局部变量表、操作数栈、动静链接、办法返回地址等

本地办法栈:波及到本地办法接口 API 调用叫本地办法栈

堆区:次要应答 Java 对象等都放在堆空间中,也是 GC 重点思考的一个空间因为堆区会被线程共享的

办法区:次要寄存类的信息(常量、办法信息等等)都放在办法区

留神:办法区只有 HotSpot 虚拟机有,J9,JRockit 都没有

执行引擎又分解释器、即时编译器、垃圾回收器,将咱们的指令变成机器指令供 CPU 去执行,要想和操作系统打交道须要关注执行引擎打交道

若想真正理解一个虚拟机,能够手写一个虚拟机

如果本人想手写一个 Java 虚拟机的话,次要思考类加载器、执行引擎构造

二、类加载器与类的加载过程


咱们刚刚提到类加载子系统呢分三个阶段:加载、链接、初始化等阶段

JAVA 虚拟机提到阐明:任何语言能够思考用间接的编辑器生成合乎 Java 虚拟机标准的 Class 文件来在 Java 虚拟机进行解释运行

类加载器 ClassLoader 角色

1.class file(在上图中就是 Car.class 文件)存在于本地硬盘上,通过类加载器把它加载到内存运行时数据区

2.class file 加载到 JVM 中后会被称为 DNA 元数据模板放在办法区

3.car.class 文件能够调用 getClassLoader()办法获取加载此类的加载器,同时能够依据 car.class 的结构器创在堆空间中创立多个对象

4. 对应的对象能够通过 getClass()获取到类的自身,晓得由那个类创立的对象

而本地磁盘的 Class 文件是由二进制流的形式加载到内存中,类加载器起到快递员的身份

接下来咱们应用一段代码来领会一下加载过程

public class HelloLoader {public static void main(String[] args) {System.out.println("谢谢 ClassLoader 加载我....");
        System.out.println("你的大恩大德,我下辈子再报!");
    }
}
// 运行后果如下:谢谢 ClassLoader 加载我....
你的大恩大德,我下辈子再报!

那么咱们的这个 HelloLoader 类,它的加载过程是怎么样的呢?

  • 执行 main() 办法(静态方法)就须要先加载 main 办法所在类 HelloLoader
  • 若 HelloLoader 没有加载,须要应用相应的 ClassLoader 进行加载
  • 加载失败则抛出异样
  • 加载胜利则进行链接、初始化等操作
  • 加载实现后调用 HelloLoader 类中的静态方法 main

接下来咱们对类加载器进行加载、链接、初始化不同阶段进行开展,看看做了哪些事件

三、类加载器中的加载阶段


咱们一起看看加载阶段的一些加载阐明:

  • 通过一个类的全限定名获取定义此类的二进制字节流
  • 将这个字节流所代表的动态存储构造转化为办法区的运行时数据结构
  • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为办法区这个类的各种数据的拜访入口

那么对于一些加载.class 文件的形式咱们能够进行一些举例说明

  • 从本地零碎中间接加载
  • 通过网络获取,典型场景:Web Applet
  • 从 zip 压缩包中读取,成为日后 jar、war 格局的根底
  • 运行时计算生成,应用最多的是:动静代理技术
  • 由其余文件生成,典型场景:JSP 利用从专有数据库中提取.class 文件,比拟少见
  • 从加密文件中获取,典型的防 Class 文件被反编译的保护措施

四、类加载器中的链接阶段


后面咱们提到过链接阶段分三个环节:验证、筹备、解析

对于后面的 加载阶段实现后 ,咱们就曾经 生成了一个比拟大的 Class 对象,第一个验证环节次要做以下几件事件

  • 目标在于 确保 Class 文件的字节流中蕴含信息合乎以后虚拟机要求,保障被加载类的正确性,不会危害虚拟机本身平安
  • 次要包含四种验证:文件格式验证,元数据验证,字节码验证,符号援用验证

咱们能够将下面举例的 HelloLoader 类查看它的字节码

应用 BinaryViewer 软件查看字节码文件,能被 java 虚拟机辨认的其结尾均为 CAFE BABE

class 文件在文件结尾有特定的文件标识说的就是这

如果发现你不是一个非法的字节码文件,那么将会验证不通过

刚刚介绍的是验证环节,接下来是链接阶段的筹备环节介绍次要以下事件

  • 为类变量(static 变量)分配内存并且设置该类变量的默认初始值,即零值
  • 这里不蕴含用 final 润饰的 static,因为 final 在编译的时候就会调配好了默认值,筹备阶段会显式初始化

留神:这里不会为实例变量调配初始化,类变量会调配在办法区中,而实例变量是会随着对象一起调配到 Java 堆中

咱们能够举个例子一起来看看 static 动态变量在筹备阶段的初始值

public class HelloApp {

    //prepare:a = 0 ---> initial : a = 1
    private static int a = 1;

    public static void main(String[] args) {System.out.println(a);
    }
}

刚刚介绍的是筹备环节,接下来是链接阶段的解析环节介绍次要以下事件

  • 将常量池内的符号援用转换为间接援用的过程
  • 符号援用就是一组符号来形容所援用的指标。符号援用的字面量模式明确定义在《java 虚拟机标准》的 class 文件格式中。间接援用就是间接指向指标的指针、绝对偏移量或一个间接定位到指标的句柄
  • 解析动作次要针对类或接口、字段、类办法、接口办法、办法类型等。对应常量池中的 CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info 等

咱们能够反编译 class 文件后能够查看符号援用,上面带# 的就是符号援用

四、类加载器中的初始化阶段


当执行完加载阶段、链接阶段达到初始化阶段时,就会执行类结构器办法 <clinit>()的过程。

此办法不需定义,是 javac 编译器主动收集类中的 所有类变量的赋值动作和动态代码块中的语句合并而来

咱们能够举例一个示例代码一起来看看具体的 <clinit>()

public class ClassInitTest {
    
    private static int num = 1;
        
    public static void main(String[] args) {System.out.println(a);
    }  
}

当然咱们运行后会输入:1,这时咱们应用 BinaryViewer 软件查看字节码文件的 <clinit>()办法里有什么

咱们刚刚 <clinit>()是对所有类变量的赋值动作和动态代码块中的语句合并而来

若咱们没有类变量它会呈现吗?

public class ClinitTest {
    
    private int a = 1;
    
    public static void main(String[] args) {System.out.println(a);
    }
}

这时咱们再应用应用 BinaryViewer 软件查看字节码文件的 <clinit>()办法里有什么

你就会发现没有,这就阐明没有类变量的赋值动作和动态代码块中的语句它就不会有

咱们之前说任何一个类申明后,至多存在一个类的结构器,应用看看并且察看一下

public class ClinitTest {
    
    private int a = 1;
    
    public ClinitTest(){
        a =10;
        int d =20;
    }
    
    public static void main(String[] args) {System.out.println(a);
    }
}

那么咱们应用 BinaryViewer 软件查看字节码文件 init 办法里有什么

结构器办法中指令按语句在源文件中呈现的程序执行。

而 <clinit>()不同于类的结构器。(关联: 结构器是虚拟机视角下的 <init>())

并且当若该类具备父类,JVM 会保障子类的 <clinit>()执行前,父类的 <clinit>()曾经执行结束。

咱们举例一个示例代码进行阐明这种状况并通过字节码察看看看是否这样

public class ClinitTestl {
    
    static class Father {
        
        public static int A = 1 ;
        
        statict{A=2;}
    }
    
    static class Son extends Father {public static int B= A;}
    
    public static void main (String[] args){
        // 加载 Father 类,其次加载 Son 类。System.out.println(Son.B);//2
    }
}

当咱们执行执行 main() 办法须要加载 ClinitTest1 类,再调用另一个类 Son 的动态变量所以此时须要加载 Son 类(此时执行 <Clinet>()办法)然而在此之前须要执行父类的加载,一起来看看字节码是怎么样的

以及虚拟机必须保障一个类的 <clinit>()办法在多线程下被同步加锁。

也就是说保障咱们的类只加载一次

咱们能够应用示例代码来领会一下这个说法

class DeadThread{
    static{if(true){System.out.println(Thread.currentThread().getName() + "初始化以后类");
            while(true){}}
    }
}
public class DeadThreadTest {public static void main(String[] args) {Runnable r = () -> {System.out.println(Thread.currentThread().getName() + "开始");
            DeadThread dead = new DeadThread();
            System.out.println(Thread.currentThread().getName() + "完结");
        };

        Thread t1 = new Thread(r,"线程 1");
        Thread t2 = new Thread(r,"线程 2");

        t1.start();
        t2.start();}
}
// 运行后果如下:线程 2 开始
线程 1 开始
线程 2 初始化以后类

// 程序卡死了...

当咱们的两个线程同时去加载 DeadThread 类,先加载 DeadThread 类的线程抢到了同步锁,而后在类的动态代码块中执行死循环,而另一个线程在期待同步锁的开释

所以无论哪个线程先执行 DeadThread 类的加载,另外一个类也不会继续执行。(一个类只会被加载一次)

应用死循环是模仿虚拟机在加载的时候只执行一次,而其余线程进入阻塞状态

五、几品种加载器的介绍与应用领会


个别 JVM 反对反对两种类型的类加载器别离为

  • 疏导类加载器(Bootstrap ClassLoader)
  • 自定义类加载器(User-Defined ClassLoader)

从概念上来讲,自定义类加载器个别指的是程序中由开发人员自定义的一类类加载器

然而 Java 虚拟机标准却没有这么定义,而是将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器

咱们能够看上面这个图,能够清晰的晓得这个继承树的意义

所以将扩大类加载器、零碎类加载器也认为是自定义类加载器

启动类加载器(疏导类加载器,Bootstrap ClassLoader)

  • 这个类加载应用 C /C++ 语言实现的,嵌套在 JVM 外部
  • 它用来加载 Java 的外围库(JAVA_HOME/jre/lib/rt.jar、resources.jar 或 sun.boot.class.path 门路下的内容),用于提供 JVM 本身须要的类
  • 并不继承自 java.lang.ClassLoader,没有父加载器
  • 加载扩大类和应用程序类加载器,并作为他们的父类加载器
  • 出于平安思考,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等结尾的类

扩大类加载器(Extension ClassLoader)

  • Java 语言编写,由 sun.misc.Launcher$ExtClassLoader 实现
  • 派生于 ClassLoader 类
  • 父类加载器为启动类加载器
  • 从 java.ext.dirs 零碎属性所指定的目录中加载类库,或从 JDK 的装置目录的 jre/lib/ext 子目录(扩大目录)下加载类库。如果用户创立的 JAR 放在此目录下,也会主动由扩大类加载器加载

应用程序类加载器(也称为零碎类加载器,AppClassLoader)

  • Java 语言编写,由 sun.misc.LaunchersAppClassLoader 实现
  • 派生于 ClassLoader 类
  • 父类加载器为扩大类加载器
  • 它负责加载环境变量 classpath 或零碎属性 java.class.path 指定门路下的类库
  • 该类加载是程序中默认的类加载器,一般来说,Java 利用的类都是由它来实现加载
  • 通过 classLoader.getSystemclassLoader()办法能够获取到该类加载器

咱们应用代码来领会一些这几种提到加载器

public class ClassLoaderTest {public static void main(String[] args) {

        // 获取零碎类加载器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

        // 获取零碎类加载器其下层:扩大类加载器
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d

        // 获取扩大类加载器其下层:获取不到疏导类加载器
        ClassLoader bootstrapClassLoader = extClassLoader.getParent();
        System.out.println(bootstrapClassLoader);//null

    }
}

那么对于这些加载器别离能加载哪些门路下的文件呢?

public class ClassLoaderTest1 {public static void main(String[] args) {System.out.println("********** 启动类加载器 **************");
        // 获取 BootstrapClassLoader 可能加载的 api 的门路
        URL[] urLs = sun.misc.Launcher.getBootstrapClassPath().getURLs();
        for (URL element : urLs) {System.out.println(element.toExternalForm());
        }
    }
}
// 运行后果如下:********** 启动类加载器 **************
file: /D:/developer_tools/Java/jdk1.8.0_131/jre/lib/resources.jar
file: /D:/developer_tools/Java/jdk1.8.0_131/jre/lib/rt.jar
file: /D:/developer_tools/Java/jdk1.8.0_131/jre/lib/sunrsasign.jar
file: /D:/developer.tools/Java/jdk1.8.0_131/jre/lib/jsse.jar
file: /D:/developer.tools/Java/jdk1.8.0_131/jre/lib/jce.jar
file: /D:/developer.tools/Java/jdk1.8.0_131/jre/lib/charsets.jar
file: /D:/developer.tools/Java/jdk1.8.0_131/jre/lib/jfr.jar
file: /D:/developer.tools/Java/jdk1.8.0_131/jre/classes

咱们能够关上门路下的 jsee.jar 包里的 Class 文件反查看加载器是什么

public class ClassLoaderTest1 {public static void main(String[] args) {
        
        //file: /D:/developer.tools/Java/jdk1.8.0_131/jre/lib/jsse.jar
        // 从门路中随便抉择一个类, 来看看他的类加载器是什么: 疏导类加载器
        ClassLoader classLoader = Provider.class.getClassLoader();
        System.out.println(classLoader);// 运行后果:null
    }
}

接下来咱们接着看看扩大类的加载器有哪一些

public class ClassLoaderTest1 {public static void main(String[] args) {System.out.println("*********** 扩大类加载器 *************");
        String extDirs = System.getProperty("java.ext.dirs");
        for (String path : extDirs.split(";")) {System.out.println(path);
        }
    }
}
// 运行后果如下:*********** 扩大类加载器 **** *** ******
D: \developer_tools\Java\jdk1.8.0_131\jre\lib\ext
C: \Windows\Sun\Java\lib\ext

同理咱们关上文件门路通过 Class 文件反查一下加载器是什么

public class ClassLoaderTest1 {public static void main(String[] args) {
        
        //file: D:\developer_tools\Java\jdk1.8.0_131\jre\lib\ext
        ClassLoader classLoader1 = CurveDB.class.getClassLoader();
        System.out.println(classLoader1);
    }
}
// 运行后果如下:sun.misc.Launcher$ExtClassLoader@1540e19d

六、用户自定义加载器


在 Java 的日常利用程序开发中,类的加载简直是由上述 3 品种加载器相互配合执行的,在必要时咱们还能够自定义类加载器,来定制类的加载形式。

那为什么还须要自定义类加载器?

  • 隔离加载类(比如说我假如当初 Spring 框架,和 RocketMQ 有包名门路齐全一样的类,类名也一样,这个时候类就抵触了。不过个别的支流框架和中间件都会自定义类加载器,实现不同的框架,中间价之间是隔离的)
  • 批改类加载的形式
  • 扩大加载源(还能够思考从数据库中加载类,路由器等等不同的中央)
  • 避免源码透露(对字节码文件进行解密,本人用的时候通过自定义类加载器来对其进行解密)

如何自定义类加载器?

  • 开发人员能够通过继承抽象类 java.lang.ClassLoader 类的形式,实现本人的类加载器,以满足一些非凡的需要
  • 在 JDK1.2 之前,在自定义类加载器时,总会去继承 ClassLoader 类并重写 loadClass()办法,从而实现自定义的类加载类,然而在 JDK1.2 之后已不再倡议用户去笼罩 loadClass()办法,而是倡议把自定义的类加载逻辑写在 findclass()办法中
  • 在编写自定义类加载器时,如果没有太过于简单的需要,能够间接继承 URIClassLoader 类,这样就能够防止本人去编写 findclass()办法及其获取字节码流的形式,使自定义类加载器编写更加简洁。
public class CustomClassLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {

        try {
            // 将门路下的文件以流的模式存入到内存中
            byte[] result = getClassFromCustomPath(name);
            if (result == null) {throw new FileNotFoundException();
            } else {
                //defineClass 和 findClass 搭配应用
                return defineClass(name, result, 0, result.length);
            }
        } catch (FileNotFoundException e) {e.printStackTrace();
        }

        throw new ClassNotFoundException(name);
    }
    // 自定义流的获取形式
    private byte[] getClassFromCustomPath(String name) {
        // 从自定义门路中加载指定类: 细节略
        // 如果指定门路的字节码文件进行了加密,则须要在此办法中进行解密操作。return null;
    }
}

七、对于 ClassLoader


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

以下这些办法都不是形象办法,能够具体的实现

对于 ClassLoader 的路径

咱们能够依据代码示例领会看看一下

public class ClassLoaderTest2 {public static void main(String[] args) {
        try {
            //1.
            ClassLoader classLoader = Class.forName("java.lang.String").getClassLoader();
            System.out.println(classLoader);
            //2.
            ClassLoader classLoader1 = Thread.currentThread().getContextClassLoader();
            System.out.println(classLoader1);

            //3.
            ClassLoader classLoader2 = ClassLoader.getSystemClassLoader().getParent();
            System.out.println(classLoader2);

        } catch (ClassNotFoundException e) {e.printStackTrace();
        }
    }
}
// 运行后果如下:null
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1540e19d

八、双亲委派机制


Java 虚拟机对 class 文件采纳的是按需加载的形式,也就是说当须要应用该类时才会将它的 class 文件加载到内存生成 class 对象。

而且加载某个类的 class 文件时,Java 虚拟机采纳的是双亲委派模式,即把申请交由父类解决,它是一种工作委派模式

咱们应用一个案例引入这个双亲委派机制,咱们在本人的 src 门路下创立本人的 java.lang.String 类

public class String {
    //
    static{System.out.println("我是自定义的 String 类的动态代码块");
    }
}

这时咱们在创立一个新的 Test 类来援用它,并且看看他的加载器是什么

public class StringTest {public static void main(String[] args) {java.lang.String str = new java.lang.String();
        System.out.println("hello,atguigu.com");

        StringTest test = new StringTest();
        System.out.println(test.getClass().getClassLoader());
    }
}
// 运行后果如下:hello,atguigu.com
sun.misc.Launcher$AppClassLoader@18b4aac2

咱们发现程序并没有输入咱们动态代码块中的内容,可见依然加载的是 JDK 自带的 String 类。

这时咱们将代码进行批改一下,再来运行起来看看是怎么样的输入后果

package java.lang;
public class String {
    //
    static{System.out.println("我是自定义的 String 类的动态代码块");
    }
    // 谬误: 在类 java.lang.String 中找不到 main 办法
    public static void main(String[] args) {System.out.println("hello,String");
    }
}
// 运行后果如下:谬误: 在类 java.lang.String 中找不到 main 办法,请将 main 办法定义为:
public static void main (String[] args)
否则 JavaFX 应用程序类必须扩大 javafx.application.Application

因为双亲委派机制始终找父类,所以最初找到了 Bootstrap ClassLoader,Bootstrap ClassLoader 找到的是 JDK 自带的 String 类,在那个 String 类中并没有 main() 办法,所以就报了下面的谬误

双亲委派机制原理

  • 如果一个类加载器收到了类加载申请,它并不会本人先去加载,而是把这个申请委托给父类的加载器去执行;
  • 如果父类加载器还存在其父类加载器,则进一步向上委托,顺次递归,申请最终将达到顶层的启动类加载器;
  • 如果父类加载器能够实现类加载工作,就胜利返回,假使父类加载器无奈实现此加载工作,子加载器才会尝试本人去加载,这就是双亲委派模式。

双亲委派机制劣势

接下来咱们在创立一个示例来 java.lang 包下看看是否能运行起来

package java.lang;

public class ShkStart {public static void main(String[] args) {System.out.println("hello!");
    }
}
// 运行后果如下:java.lang.SecurityException: Prohibited package name: java.lang
    at java.lang.ClassLoader.preDefineClass(ClassLoader.java:662)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:761)
    at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
    at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
    at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
    at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
    at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
    at java.security.AccessController.doPrivileged(Native Method)
    at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
    at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:335)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
    at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" 

即便类名没有反复,也禁止应用 java.lang 这种包名。这是一种爱护机制

在比方咱们应用加载 jdbc.jar 用于实现数据库连贯的时候须要用到 SPI 接口,而 SPI 接口属于 rt.jar 包中 Java 外围 api

这个时候咱们就要应用双清委派机制,疏导类加载器把 rt.jar 包加载进来针对具体的第三方实现 jar 包时应用零碎类加载器来加载

从这外面就能够看到 SPI 外围接口由疏导类加载器来加载,SPI 具体实现类由零碎类加载器来加载

通过下面的例子,咱们能够晓得,双亲机制能够

  • 防止类的反复加载
  • 爱护程序平安,避免外围 API 被随便篡改

    • 自定义类:自定义 java.lang.String 没有被加载。
    • 自定义类:java.lang.ShkStart(报错:阻止创立 java.lang 结尾的类)

沙箱平安机制

当咱们运行自定义 String 类 main 办法的时候呈现了报错,这种其实就是沙箱平安机制,不容许你在程序中毁坏外围的源代码程序

九、其余


如何判断两个 class 对象是否雷同?

在 JVM 中示意两个 class 对象是否为同一个类存在两个必要条件:

  • 类的残缺类名必须统一,包含包名
  • 加载这个类的 ClassLoader(指 ClassLoader 实例对象)必须雷同

换句话说,在 JVM 中,即便这两个类对象(class 对象)起源同一个 Class 文件,被同一个虚拟机所加载,但只有加载它们的 ClassLoader 实例对象不同,那么这两个类对象也是不相等的

参考资料


尚硅谷:JVM 虚拟机(宋红康老师)

正文完
 0