JVM study notes and other loading process

table of Contents

background

Overview

Loading phase

Load completed operation

How to get the binary stream

The location of the class model and the class instance

Array class loading

Link phase

verification

ready

Parsing

Initialization phase

The collocation of static and final

Initialization of the class: active use and passive use

Active use

Passive use

use

Uninstall

Reference relationship between class, class loader and instance of class

Class life cycle

About unloading of classes

Conclusion

background

Before reading this article, you can take a look at the chapter about the class loader subsystem in my previous notes . This note will describe in detail the various loading processes of the class, especially the initialization, active loading and passive loading of the class.

Overview

Data types in java are divided into basic data types and reference data types. The basic data types are predefined by the virtual machine, and the reference data types require class loading.

According to the JVM specification, from the class file to the class loaded into memory, until the class is unloaded from the memory, the entire life cycle of a class includes the following three stages:

Loading phase

The so-called loading is to load the bytecode file of the java class into the memory, and construct the prototype of the java class in the memory-the class template object. The so-called class template object is a snapshot of the java class in the JVM memory, which stores the constant pool, class field, class method and other information parsed from the bytecode file, so that the JVM can obtain java through the class template at runtime Any information in the class, such as traversing the member variables of the java class, method invocation, reflection, etc.

Load completed operation

In short, it is to find and load the binary data of the class, and generate the class instance.

When loading a class, the JVM must complete the following three things:

  1. Obtain the binary data stream of the class through the full name of the class;
  2. The binary data stream of the parsing class is the data structure of the method area (template class)
  3. Create an instance of the class class to represent this class, as the various data access entrances of this class in the method area

How to get the binary stream

For the binary data stream of the class, the JVM can be generated or obtained in a variety of ways: (as long as the bytecode read conforms to the JVM specification)

  • Read a file with a class suffix from the file system;
  • Read in compressed packages such as jar and zip, and extract class files;
  • Binary-like data stored in the database in advance;
  • Use network protocol to load through network transmission;
  • Generate a piece of class binary information at runtime, etc.

After obtaining the binary information of the class, the JVM will process the data and finally convert it into a class instance. If the input data is not the structure of ClassFile, ClassFormatError will be thrown

The location of the class model and the class instance

The loaded class is created as a corresponding class structure in the JVM and stored in the method area.

After the class loads the class file into the method area, a Class object is created in the heap to encapsulate the data structure of the class in the method area. This Class object is created during the process of loading the class. Each class corresponds to a Class. Object

The construction method of the Class class is private, and this class can only be created by the JVM. Class instance is the interface for accessing type metadata, and it is also the key data and entry point to realize reflection. Through the interface provided by the Class class, the specific data structure in the class file associated with the target class can be obtained, such as methods, fields, etc.

Array class loading

The situation of creating an array class is slightly special, because the array class itself is not created by the class loader, but is created directly by the JVM at runtime as needed, but the element type of the array still needs to be created by the class loader area. The process is as follows :

  1. If the array element type is a reference type, then follow the defined loading process to recursively load the element type of the created array; otherwise, proceed directly to the next step
  2. The JVM then uses the specified element type and array dimension to create an array class object

If the data type of the array element is a reference type, the accessibility of the array class is determined by the accessibility of the element type, otherwise it is public by default

Link phase

verification

When the class is loaded into the memory, the link operation starts, and the first step of linking is verification. The purpose of verification is to ensure that the loaded bytecode is legal and reasonable and conforms to the specification. The verification steps are more complicated, generally as shown in the figure below

 

Overall description:

  • The content of verification solder paste includes format verification, semantic check, bytecode verification, symbol reference verification, etc. of data information. The format check will be performed together with the loading phase. After the verification is passed, the class loader will successfully load the binary data of the class into the method area. Verification operations other than format verification will be performed in the method area.
  • Although the verification at the link stage slows down the loading speed, it avoids various checks when the bytecode is running.

Specific instructions:

  1. Format verification: whether it starts with the magic number 0xCAFEBABE, whether the major version number and minor version number are within the support range of the current JVM, whether each item in the data has the correct length, etc.
  2. Semantic check: whether all classes have parent classes (except Object class), whether some methods or classes defined as final are overwritten or inherited; whether non-abstract classes implement all abstract methods or interface methods; whether they exist Incompatible methods (for example, the signature of the method is the same except for the return value; abstract and final coexist, etc.)
  3. Bytecode verification: This is the most complicated process in the verification process. It tries to determine whether the bytecode can be executed correctly by analyzing the bytecode stream, such as whether it will jump to a non-existent instruction during the execution of the bytecode, and whether the function call passes the correct type of parameters. , Whether the variable assignment is given the correct data type, etc. The stack map frame occurs at this stage and is used to detect whether the local variable table and operand stack have the correct data type at a specific bytecode. Unfortunately, it is impossible to determine with 100% accuracy whether a bytecode file can be executed safely, so this process can only detect obvious and predictable problems as much as possible. If it fails the check at this stage, the JVM will not load the class correctly, but even if it passes the check at this stage, it does not mean that the class is completely free of problems.
  4. Reference symbol verification: In the previous three checks, errors in file format, semantics, and bytecode have been eliminated, but there is still no guarantee that the class is correct. Because the class file records other classes or methods that it will use through strings in its constant pool, the JVM will check that these classes or methods actually exist during the reference symbol verification, and that the current class has permission to access these data. If a required class or method cannot be found in the system, NoClassDefFountError or NoSuchMethodError will be thrown. Checks at this stage will be executed in the parsing stage

ready

This stage is responsible for allocating memory for static variables of the class and initializing them to default values.

When a class is verified, the JVM link to it will enter the preparation phase. At this stage, the JVM will allocate the corresponding memory space for this class and set the default initial value. The default initial values ​​set by the JVM for various types of variables are shown in the following table

Types of

Default initial value

byte

(byte) 0

short

(short) 0

int

0

long

0L

float

0.0f

double

0.0

char

\u0000

boolean

false

reference

null

note:

  • The bottom layer of the JVM does not support the boolean type. For the boolean type, the internal implementation is int. Since the default value of int is 0, the default value of the corresponding boolean is false;
  • This does not include the use of static final to modify the basic data type field, because the value of the final field is allocated in the constant pool as a literal at compile time, and the final field will be explicitly assigned to the final field during the preparation phase;
  • Instance variables will not be initialized here, class variables will be allocated in the method area, and instance variables will be allocated to the java heap along with the object;
  • This stage will not be initialized or code executed like the initialization stage

For the assignment of static fields:

  • String constants defined by basic data types or literals: non-final will be initialized and assigned by default in the preparation phase, and final fields will be directly assigned explicitly in the preparation phase
  • For reference data types or non-literal defined string constants (new String), it will be postponed to the initialization stage before assignment

Parsing

The parsing phase is responsible for converting the symbolic references of classes, interfaces, fields, and methods into direct references.

Symbolic references are literal references, which have nothing to do with the internal data structure and memory layout of the virtual machine. But when the program is running, the system only needs to wait for the location of the symbol referenced in the memory.

Take the method as an example. JVM prepares a method table for each class and lists all its methods in the table. When you need to call a method of a class, you only need to know the bias of this method in the method table. You can call it by moving the amount. Through the parsing operation, the symbol reference can become the position of the target method in the class method table, so that the method is successfully called.

Therefore, the so-called resolution is to convert symbol references into direct references, that is, to obtain pointers or offsets of classes, fields, and methods in memory. It can be said that if the direct reference exists, there must be such, method or field in the system, but the existence of symbolic reference cannot confirm that the structure must exist in the system.

However, the JVM does not explicitly require that the parsing phase must be executed in order. In the HotSpot virtual machine, loading, verification, preparation and initialization will be carried out in an orderly manner, but the parsing operation in the link phase is often executed after the JVM has completed the initialization.

Initialization phase

The initialization phase is responsible for assigning correct initial values ​​to static variables of the class. This stage is the last stage of class loading. If there are no problems with the previous steps, it means that the class can be loaded into memory smoothly. At this time, the class will begin to execute the bytecode.

The important task of the initialization phase is to execute the initialization method (<clinit>()) of the class. This method can only be generated by the java compiler and called by the JVM. The program developer cannot customize a method with the same name, let alone in the java program. Call this method directly, even if this method is also composed of bytecode instructions. This method is generated by combining the assignment statement of the static member of the class and the static code block, for example, the following class

public class LinkTest {
    public static int a = 1;
    public static int b;

    static {
        b = 4;
    }
}

First of all it has a <clinit> method

Furthermore, its <clinit> bytecode command is as follows

0 iconst_1
1 putstatic #2 <LinkTest.a>
4 iconst_4
5 putstatic #3 <LinkTest.b>
8 return

Before loading a class, the JVM will always try to load the parent class of the class, so the <clinit> of the parent class is always called before the subclass <clinit>, which means that the priority of the static block of the parent class is higher than Subclass

public class SonTest extends FatherL {
    static {
        System.out.println("Son static");
    }

    public static void main(String[] args) {
        System.out.println(SonL.x); // 1
    }
}

class FatherL {
    public static int x;

    static {
        x = 1;
        System.out.println("Father static");
    }
}

However, if you only call the static fields of the parent class in the subclass, instead of calling methods or fields in the subclass, instantiating the subclass object, etc., then the subclass will not be loaded into the JVM at all. Its initialization phase Will not execute

public class LinkTest {
    public static void main(String[] args) {
        System.out.println(SonL.x); // 1
    }
}


class FatherL {
    public static int x;

    static {
        x = 1;
        System.out.println("Father static");
    }
}


class SonL extends FatherL {
    static {
        x = 4;
        System.out.println("Son static");
    }
}

The java compiler does not generate the <clinit>() initialization method for all classes. These classes include:

  • There is no declaration of any class variables, and no static code block of the class;
  • The class variable is declared, but the initialization statement of the class variable and the static code block are not used to initialize the class;
  • A class containing basic data type fields modified by static final. The initialization statement of this field will use the constant expression at compile time

The collocation of static and final

Case 1: Assignment in the preparation link of the link phase

Case 2: Assignment in the initialization phase

The sample code is as follows:

public class CliInitTest {
    public static int a; // 情况2
    public static final int b = 1; // 情况1
    
    public static Integer i0 = Integer.valueOf(10); // 情况2
    public static final Integer i1 = Integer.valueOf(100); // 情况2
    
    public static final String s = "szc"; // 情况1 
    public static final String s1 = new String("sss"); // 情况2
}

For the initialization of the static field, let’s summarize:

  • Non-final: Initialization phase
  • Final and non-literal initialization: the initialization phase
  • Final and literal initialization: the preparation link when linking

 

2) Thread safety of <clinit>()

For the call of the <clinit> method, the JVM will ensure its thread safety internally. In other words, the virtual machine ensures that the <clinit> method of a class is correctly locked and synchronized in a multi-threaded scenario. If multiple threads initialize a class at the same time, only one thread will execute this type of <clinit> method. The thread has to block and wait until the active thread finishes executing <clinit>.

It is precisely because <clinit> is thread-safe with a lock, so if a long operation is performed in the <clinit> method of a class, it may cause multiple threads to block and cause a deadlock, and this deadlock is very It's hard to find, because there doesn't seem to be any information available. For example the following code

public class StaticDeadlockTest extends Thread {
    private int flag;
    public static void main(String[] args) {
        StaticDeadlockTest test1 = new StaticDeadlockTest();
        StaticDeadlockTest test2 = new StaticDeadlockTest();

        test1.flag = 1;
        test2.flag = 2;

        test1.start();
        test2.start();
    }


    @Override
    public void run() {
        try {
            Class.forName("Test" + flag);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}


class Test1 {
    static {
        try {
            Thread.sleep(1000);
            Class.forName("Test2");
        } catch (Exception e) {}
    }
}


class Test2 {
    static {
        try {
            Thread.sleep(1000);
            Class.forName("Test1");
        } catch (Exception e) { }
    }
}

Two threads load Test1 and Test2 respectively, but Test1 has to load Test2, and Test2 has to load Test1, so a circular wait is formed and a deadlock is formed

If the previous thread successfully loads the class, the threads in the waiting queue will not have the opportunity to execute the <clinit> method. Then when you need to use this class, the JVM will directly return the information it has prepared

Initialization of the class: active use and passive use

Java's use of classes is divided into two types: active use and passive use.

Active use: Class will be loaded only when it must be used for the first time, and the JVM will not unconditionally load the Class type. JVM stipulates that a class or interface must be initialized before it is used for the first time. The use here is active use. There are only the following cases for active use (the class will be initialized when the following cases occur, and the loading, verification, preparation, and parsing processes before initialization are all executed correctly):

  • Create an instance of the class (new, reflection, clone, deserialization, etc.);
  • Call the static method of the class (using the bytecode instruction invokestatic);
  • Use static fields of classes and interfaces (using getstatic and putstatic instructions);
  • The method of using the method in the java.lang.reflect package to reflect the class is (such as Class.forName());
  • When initializing a subclass, if it is found that its parent class has not yet been initialized, the initialization of the parent class must be triggered first;
  • An interface defines the default method, then if the implementation class that directly implements or indirectly implements the interface is initialized, the interface will be initialized before that;
  • When the JVM starts, the user must specify a main class (the class where the main() method is located), and the JVM will initialize the main class first;
  • When the MethodHandle instance is called for the first time, initialize the class of the method pointed to by the MethodHandle (corresponding to the class corresponding to the REF_getStatic, REF_putStatic, and REF_invokeStatic method handles)

Note that when the JVM initializes a class, all its parent classes are required to be initialized, but the interfaces it implements are not initialized first

 

Passive use: Except for active use, it is passive use. Passive use will not cause the initialization of the class. In other words, not all classes that appear in the code will be loaded or initialized. If the conditions for active use are not met, the class will not be initialized. The passive use cases are as follows:

  • When accessing a static field, only the class that actually declares this field will be initialized (when the static variable of the parent class is referenced through the subclass, it will not cause the initialization of the subclass);
  • Defining class references through arrays will not trigger class initialization;
  • Referencing the constant will not trigger the initialization of the class or interface, because the constant has been explicitly assigned in the preparation phase;
  • Calling the loadClass() method of ClassLoader to load a class will not cause the initialization of the class

Let's verify the above scenario of active and passive use of classes

Active use

Create an instance of the class (new, reflection, clone, deserialization, etc.)

public class ActiveUse {
    public static void main(String[] args) {
        Test t = new Test(); // new对象,会导致类的初始化,<clinit>被执行
    }
}

class Test {
    static {
        System.out.println("Test has been initialized");
    }
}

Deserialization of classes

public class ActiveUse {
    public static void main(String[] args) {
//        serialization(); // 要先执行序列化
        deserialization();
    }


    private static void serialization() {
        try {
            Test t = new Test();

            ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("test"));
            outputStream.writeObject(t);
            outputStream.flush();
            outputStream.close();
        } catch (Exception e) {}
    }


    private static void deserialization() {
        try {
            ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("test"));

            Test t = (Test) inputStream.readObject(); // 反序列化也会导致<clinit>的执行

            inputStream.close();
        } catch (Exception e) {}
    }
}


class Test implements Serializable {
    static {
        System.out.println("Test has been initialized");
    }
}

Call the static method of the class (using the bytecode instruction invokestatic)

public class ActiveUse {
    public static void main(String[] args) {
        Test.f(); // 调用类的静态方法,会导致<clinit>的执行
    }
}


class Test implements Serializable {
    static {
        System.out.println("Test has been initialized");
    }

    public static void f() {
        System.out.println("f has been invoked");
    }
}

Use static fields of classes and interfaces (using getstatic, putstatic instructions), pay attention to final static, you must also consider whether it is literal initialization, if it is, it will not

public class ActiveUse {
    public static void main(String[] args) {
        System.out.println(Test.s); // 使用静态字段,会导致<clinit>的执行
        System.out.println(Test.s1); // 准备期初始化,不会执行<clinit>
    }
}


class Test implements Serializable {
    public static int s = 0;
    public static final int s1 = 1;
    static {
        System.out.println("Test has been initialized");
    }
}

The method of using the method in the java.lang.reflect package to reflect the class is (such as Class.forName())

public class ActiveUse {
    public static void main(String[] args) {
        try {
            Class.forName("Test"); // 对类进行反射,会导致<clinit>的执行
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}


class Test implements Serializable {
    static {
       System.out.println("Test has been initialized");
    }

    public static void f() {
        System.out.println("f has been invoked");
    }
}

When initializing a subclass, if it is found that the parent class has not been initialized, the initialization of the parent class must be triggered first, but the initialization of the subinterface will not cause the initialization of the parent interface, and if there is no default method in the interface, then the initialization of the implementation class is also Does not cause the initialization of the interface

public class ActiveUse {
    public static void main(String[] args) {
        TestSon s = new TestSon(); // 会先输出Test,再输出Test Son,但不会输出iA和iFather
    }
}


class Test {
    static {
        System.out.println("Test has been initialized");
    }
}


class TestSon extends Test implements iA {
    static {
        System.out.println("Test Son has been initialized");
    }

    @Override
    public void f0() {}
}

interface iFather {
    public static Object o = new Object() {
        {
            System.out.println("iFather has been initialized");
        }
    };
}


interface iA extends iFather {
    public static Object o = new Object() {
        {
            System.out.println("iA has been initialized");
        }
    };

    public void f0();

    // default public void f0() {} // 如果换成这种写法,TestSon的初始化导致iA的初始化
}

Regarding whether the parent subclass, interface and implementation class, parent interface and subinterface will be initialized, you can summarize: it depends on the currently accessed field or method called, combined with the rules of interface initialization, which classes and interfaces should be initialized at least , Which classes and interfaces are initialized. Look at the following example

public class ActiveUse3 {
    public void test4() {
        System.out.println(Son2.num); // Son2.num在Son2中被初始化,且非final,因此先后初始化父类和子类
        System.out.println(Son2.NUM1); // Son2.NUM1在子接口中被初始化,且非final,因此只初始化子接口
        System.out.println(Son2.numf); // Son2.numf在父类中被初始化,且非final,因此只初始化父类
    }

    public static void main(String[] args) {
        new ActiveUse3().test4();
    }
}

class Father2 {
    static {
        System.out.println("Father类的初始化过程");
    }

    public static int numf = 1;
}

class Son2 extends Father2 implements CompareC {
    static {
        System.out.println("Son类的初始化过程");
    }

    public static int num = 1;

    @Override
    public void method1() {}
}

interface CompareB {
    public static final Thread t = new Thread() {
        {
            System.out.println("CompareB的初始化");
        }
    };
    public void method1();
}

interface CompareC extends CompareB {
    public static final Thread t = new Thread() {
        {
            System.out.println("CompareC的初始化");
        }
    };

    public static final int NUM1 = new Random().nextInt();
}

An interface defines the default method, then if you initialize the implementation class that directly implements or indirectly implements the interface, the interface will be initialized before that:

public class ActiveUse {
    public static void main(String[] args) {
        TestInterface testInterface = new TestInterface(); // 先输出iA,再输出TestInterface
    }
}

interface iA {
    public static Object o = new Object() {
        { // 如果iA的<clinit>被执行,这个静态对象必然被实例化,以下代码必被执行
            System.out.println("iA has been initialized");
        }
    };


    default void f0() {
        System.out.println("f0 in iA");
    }
}


class TestInterface implements iA {
    static {
        System.out.println("TestInterface has been initialized");
    }


    @Override
    public void f0() {
        System.out.println("f0 in TestInterface");
    }
}

When the JVM is started, the user must specify a main class (the class where the main() method is located), and the JVM will first initialize the main class

public class ActiveUse {
    static {
        System.out.println("ActiveUse被初始化"); // 主类的启动会导致其<clinit>被执行
    }
    
    public static void main(String[] args) {
    }
}

When the MethodHandle instance is called for the first time, initialize the class of the method pointed to by the MethodHandle (corresponding to the classes corresponding to the REF_getStatic, REF_putStatic, and REF_invokeStatic method handles).

Passive use

When accessing a static field, only the class that actually declares this field will be initialized (when the static variable of the parent class is referenced through the subclass, it will not cause the initialization of the subclass)

public class PassiveUse {
    public static void main(String[] args) {
        System.out.println(Son3.num); // 通过子类访问父类静态非final字段,只会初始化父类
    }
}

class Father3 {
    public static int num = 1;

    static {
        System.out.println("Father has been intialized");
    }
}

class Son3 extends Father3 {
    static {
        System.out.println("Son has been initialized");
    }
}

Define class references through arrays, which will not trigger class initialization

public class PassiveUse {
    public static void main(String[] args) {
        Father3[] fathers = new Father3[10]; // 数组不会触发元素类的初始化
    }
}

class Father3 {
    static {
        System.out.println("Father has been intialized");
    }
}

Referencing a constant will not trigger the initialization of the class or interface, because the constant has been explicitly assigned in the preparation phase

public class PassiveUse {
    public static void main(String[] args) {
        System.out.println(Father3.s); // 字面量初始化的常量,不会导致类或接口的初始化
    }
}

class Father3 {
    public static final String s = "szc";

    static {
        System.out.println("Father has been intialized");
    }
}

The following will not trigger the initialization of the class or interface

public class PassiveUse {
    public static void main(String[] args) {
        System.out.println(Father3.s1); // 字面量常量不会引发类或接口相关的初始化
    }
}

interface iF {
    public static final String s1 = "s";

    public static Object o = new Object() {
        {
            System.out.println("iF has been initialized");
        }
    };
}

class Father3 implements iF {
    public static final String s = "szc";

    static {
        System.out.println("Father has been intialized");
    }
}

 

Calling the loadClass() method of ClassLoader to load a class will not cause the initialization of the class.

use

At this point, a class has gone through the loading, linking, and initialization phases and is ready for developers to use

Uninstall

Reference relationship between class, class loader and instance of class

In the internal implementation of the class loader, a java collection is used to store the references of the loaded classes. On the other hand, a Class object will always refer to its class loader, and its class loader can be obtained by calling the getClassLoader() method of the Class object. It can be seen that there is a two-way binding relationship between a Class instance of a class and its class loader

An instance of a class always refers to the Class object representing this class. The getClass() method is defined in the Object class. This method returns a reference to the Class object representing the class to which the object belongs. In addition, all java classes have a static attribute class, which refers to the Class object representing this class

Class life cycle

When a certain class (Sample) is loaded, linked and initialized, its life cycle begins. When the Class object representing the Sample class is no longer referenced, that is, it cannot be touched in time, the Class object will end its life cycle, and the data of the Sample class in the method area will also be unloaded, thus ending its life cycle.

When a class ends its life cycle depends on when the life cycle of the Class object representing it ends

Recall the garbage collection in the method area:

    The garbage collection in the method area mainly recycles two parts: the discarded constants in the constant pool and the no longer used types.

    The HotSpot virtual machine has a clear recycling strategy for the constant pool. As long as the constants in the constant pool are not referenced anywhere, they can be recycled.

    It is relatively simple to judge whether a constant is discarded, but to judge whether a type belongs to a class that is no longer used, there are more stringent conditions, and three points must be met at the same time:

        a) All instances of this class have been recycled, that is, there are no instance objects of this class and any derived subclasses in the heap

        b) The class loader that loaded this class has been recycled. Unless this condition is a carefully designed alternative class loader scenario, such as OSGi, JSP reloading, etc., it is generally difficult to achieve

        c). The Class object corresponding to this class is not referenced anywhere, and the methods or fields of this class cannot be accessed through reflection anywhere.

    The java virtual machine allows the recycling of useless classes that meet the above three conditions. It is said here that it is allowed because the class is not like an object, and it will be recycled quickly without reference.

The sample diagram is shown below, only the classLoader, sampleClasss and sample three reference variables on the stack are destroyed, the ClassLoader, Class and Sample objects in the heap may be recycled, and the Sample class data in the method area may be unloaded

 

About unloading of classes

The type loaded by the startup class loader cannot be unloaded during the entire run (JVM and jls specifications)

The classes loaded by the system class loader and the extended class loader are unlikely to be recycled during the runtime, because the system class loader instance or the extended class loader instance can always be directly or indirectly accessed during the entire runtime, which becomes Very unlikely to be unreachable

The class loaded by the developer's custom class loader instance can only be unloaded in a very simple context, and generally it can be done with the help of forcing the garbage collection function of the virtual machine to be invoked. It can be expected that in slightly more complicated application scenarios (for example, many programmers use caching strategies to improve system performance when developing custom class loaders), it is almost impossible for the loaded classes to be unloaded during runtime ( At least the uninstall time is uncertain)

In summary, the probability of a loaded class being unloaded is very small, at least the unloading time is uncertain. At the same time, when developing code, we should not implement specific functions in the system under the premise of making any assumptions about the type of virtual machine uninstallation.

Conclusion

The next note will review class loaders

Guess you like

Origin blog.csdn.net/qq_37475168/article/details/113942517