深入理解java虚拟机(三)---类加载机制

这部分内容都是参考《深入理解java虚拟机—jvm高级特性与最佳实战》-周志明  这本书基本都是讲的底层实现的理论,国内很少有这方面的著作

想骂人了,前边花了大量功夫写的文章,保存的时候卡了一下,等出来再登录发现之前写的都没存下来,唉,算了,把我参考过的内容拼接一下,先看着吧,稍微修改一下。靠自己回忆的话有太多内容容易漏掉,这篇内容基本是原书的复述。


1、类加载机制概述

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制
在java中,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会带来一些性能开销,但是却为java应用程序提供了高度的灵活性,java动态扩展的语言特性就是依赖运行期动态加载和动态链接这个特点形成的,所谓java动态扩展,比如,如果编写了一个面向接口的应用程序,可以等到运行时再指定其实际的实现类。

2、类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载,共七个阶段。其中,验证、准备、解析3个阶段称为连接(Linking),7个过程发生顺序如下:

上面这七个过程,除了解析这个过程外,其余过程必须按部就班地执行,即顺序是确定的,而解析过程不一定,在某些情况下可以在初始化阶段之后再执行,这是为了支持java语言的运行时绑定(也称为动态绑定或晚期绑定)。

java虚拟机规范中,并没有规定类加载过程中的第一个阶段(即加载阶段)的执行时机,但是对于初始化阶段,虚拟机规范中严格规定了“有且只有”下面5种情况下必须立即对类进行初始化(而这时,加载、验证、准备自然需要在此之前开始):
(1)遇到new、getstatic、putstatic、invokestatic这四条指令时,必须触发其初始化。这四条指令最常见的场景是:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已经在编译期把结果放入常量池的静态字段除外,即常量除外)、调用一个类的静态方法的时候;
(2)进行反射调用的时候;
(3)初始化一个类的时候,如果其父类还没有初始化,则需要先触发其父类的初始化;
(4)当虚拟机启动时,需要先初始化那个包含main方法的要执行的主类;
(5)当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic 、REF_putStatic、REF_invokeStatic的方法句柄,句柄对应的类会被初始化;

上面五个场景通俗的说法就是

(1) 创建类的实例,也就是new的方式

(2) 访问某个类或接口的静态变量,或者对该静态变量赋值

(3) 调用类的静态方法

(4) 反射(如Class.forName(“com.shengsiyuan.Test”))

(5) 初始化某个类的子类,则其父类也会被初始化

(6) Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
第五种我也没搞太明白,如果有明白的,欢迎留言告诉我,哈哈。。。



上面五种场景触发类进行初始化的行为称为对一个类进行“主动引用”,除此之外,所有其他引用类的方式都不会触发初始化步骤(注意,此时已经是引用了,只不过不会触发初始化,其他阶段是否触发要看具体虚拟机的实现),这些引用称为“被动引用”。
被动引用的几个例子
(1)对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要出发子类的加载、验证需要看具体虚拟机实现;如下:
[java] view plain copy
  1. class SuperClass{  
  2.     static{  
  3.         System.out.println("SuperClass init!");  
  4.     }  
  5.     public static int value = 123;  
  6. }  
  7.   
  8. class SubClass extends SuperClass{  
  9.     static{  
  10.         System.out.println("SubClass init!");//子类中引用父类的静态字段,不会导致类初始化  
  11.     }  
  12. }  
  13.   
  14. public class Test {  
  15.     public static void main(String[] args) {  
  16.         System.out.println(SubClass.value);  
  17.     }  
  18. }  
运行结果:
SuperClass init!
123
可以看到,只会打印出父类的初始化语句。

(2)通过数组定义来引用类,不会触发此类的初始化。如 A[] ints = new A[10] ,  不会触发A 类的初始化。而是会触发名为 LA的类初始化。它是一个由虚拟机自动生成的、直接继承于Object 的子类,创建动作由字节码指令 newarray 触发。这个类代表了一个元素类型为 A 的一位数组,数组中的属性和方法都实现在这个类中。Java 语言中数组的访问比C/C++ 安全是因为这个类封装了数组元素的访问方法。如下:
public class Test {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];
    }
}
SuperClass类为上面的那个,运行后发现并没有打印出SuperClass init!,说明没有触发SuperClass类的初始化阶段。

(3)常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化,如下:
[java] view plain copy
  1. class ConstClass{  
  2.     static{  
  3.         System.out.println("ConstClass init!");  
  4.     }  
  5.     public static final String HELLOWORLD = "hello world";  
  6. }  
  7.   
  8. public class Test {  
  9.     public static void main(String[] args) {  
  10.         System.out.println(ConstClass.HELLOWORLD);  
  11.     }  
  12. }  
运行结果:
hello world
只是输出了hello world,并没有输出ConstClass init!,可见ConstClass类并没有被初始化。

注意:
上面讲的三个例子是被动引用的情况,很多情况下我们会通过new来初始化一个类,这个情形它属于上面提到的5种主动引用的场景,因此会触发这个类的初始化,如果这个类有父类的话,会先触发父类的初始化。注意不要和上面的被动引用搞混了。

接口的初始化
上面代码中用static语句块进行初始化,而结构中不能使用static语句块,但是编译器仍然回味接口生成<clinit>()类构造器来初始化接口中的成员变量(常量);接口与类初始化的区别主要是在上面五种主动引用中的第三种:当一个类在初始化时,要求其父类全部已经初始化过了,但是对于接口的初始化来说,并不要求父接口全部都完成了初始化,只有在真正使用到付接口的时候(如引用接口中定义的常量)才会初始化

3、类加载过程

3.1 加载

在加载阶段,需要完成三件事情:

(1)通过一个类的全限定名来获取其定义的二进制字节流。

(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

(3)在内存中生成一个代表这个类的java.lang.Class对象(并没有明确规定是在java堆中,对于HotSpot虚拟机来说,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),作为对方法区中这些数据的访问入口。

对于(1),并没有指明二进制字节流的获取途径,也即不一定都是从一个Class文件中获取,还可以从如下方式获取:

    1)从压缩包中获取,比如 JAR包、EAR、WAR包等
    2)从网络中获取,比如红极一时的Applet技术
    3)从运行过程中动态生成,最出名的便是动态代理技术,在java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 来为特定接口生成形式为“$Proxy”的代理类的二进制流
    4)从其它文件生成,如JSP文件生成Class 类
    5)从数据库中读取,比如说有些中间件服务器,通过数据库完成程序代码在集群之间的分发

相对于类加载过程的其他阶段,加载这一步骤是开发人员可控的,即可以通过自定义类加载器来控制加载过程。

对于数组来说,数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的,但是数组的元素类型,最终是要靠类加载器去创建。

3.2 验证

验证阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
Java语言本身是相对安全的,因为使用纯粹的java代码无法做到诸如访问数组边界意外的数据、讲一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果我们这样做了,那编译器将拒绝编译,也就保证了安全。但是前面说过,Class文件并不一定要用Java源码编译而来,它还可以从很多途径产生,在字节码层面,其他方式可能能做到java代码无法做到的事情,因此虚拟机需要对加载尽量的字节流进行验证。验证过程分为四步:
(1)文件格式验证
这一阶段是要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。包括以下这些验证点:
    - 是否以魔数0xCAFEBABE开头
    - 主、次版本号是否在当前虚拟机处理范围之内
    - 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)
    - 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
    - CONSTANT_Utf8_info 型的常量中是否有不符合UTF8 编码的数据
    - Class 文件中各个部分以及文件本身是否有被删除的或被附加的其它信息
    ...
这一阶段验证的目的是保证输入的字节流能正确的解析并存储到方法区中,这阶段是基于二进制字节流进行的,通过验证后,字节流才会进入到内存的方法区中进行存储。因此,后面的3个验证阶段是基于方法区的存储结构进行分析的,不会再直接操作字节流了。

(2)元数据验证
对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,主要是验证类的继承关系、数据类型是否符合,验证点包括:
    - 这个类是否有父类(除Object类外,其他所有类都应当有父类)
    - 这个类的父类是否继承了不允许被继承的类(final 修饰的类)
    - 这个类如果不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
    - 类中的字段、方法是否和父类产生矛盾(如覆盖了父类final 字段,出现了非法的方法重载,如方法参数一致,但返回类型却不同)

(3)字节码验证
最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在元数据验证阶段对数据类型做完校验后,这个阶段将对类的方法体进行校验分析,以保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,有如下一些验证点:
    - 保证任何时候,操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放入了一个int类型数据,使用时却按 long 类型加载到本地变量表中
    - 保证跳转指令不会跳转到方法体外的字节码指令上
    - 保证方法体中类型转换是有效的

(4)符号引用验证
这一阶段发生在虚拟机将符号引用转化为直接引用的时候,而这个转化动作发生在解析阶段,符号引用可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,验证点如下:
    - 符号引用中通过字符串描述的全限定名是否能找到相应的类
    - 在指定类中对否存在符合方法的字段描述符以及简单名称所描述的方法和字段
    - 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问
这一阶段验证的目的是确保解析动作能正常执行。

对于虚拟机来说,验证阶段是一个非常重要的,但不是一定必要(因为对程序运行期没有影响)的的阶段。

3.3 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。有两点需要注意:
(1)这阶段进行内存分配的仅包括类变量(即被static修饰的变量),不包括实例变量,实例变量会在对象实例化时随着对象一起分配在Java堆中
(2)这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义如下:
    public static int value = 123;
那变量value在准备阶段过后的零值为0而不是123,因为这时候并未执行任何Java方法,把value赋值为123的动作是在初始化阶段才会进行。对于“非通常情况”,是指定义为常量的那些变量(即final修饰的),会在这一阶段就被赋值,如:
    public static final int value = 123;
此时在准备阶段过后,value的值将会被赋值为123。

3.4 解析

解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。
    - 符号引用(Symbolic References):即用一组符号来描述所引用的目标。它与虚拟机的内存布局无关,引用的目标不一定已经加载到内存中
    - 直接引用(Direct References):直接引用可以是指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。它是和虚拟机内存布局相关的,如果有了直接引用,那引用的目标必定已经在内存中存在了。
解析动作主要针对 类或接口、字段、类方法、接口方法、方法类型、方法句柄 和 调用限定符 7类符号引用进行。
(1)类或接口的解析
判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。
(2)字段解析
在对字段进行解析前,会先查看该字段所属的类或接口的符号引用是否已经解析过,没有就先对字段所属的接口或类进行解析。在对字段进行解析的时候,先查找本类或接口中是否有该字段,有就直接返回;否则,再对实现的接口进行遍历,会按照继承关系从下往上递归(也就是说,每个父接口都会走一遍)搜索各个接口和它的父接口,返回最近一个接口的直接引用;再对继承的父类进行遍历,会按照继承关系从下往上递归(也就是说,每个父类都会走一遍)搜索各个父类,返回最近一个父类的直接引用。
(3)类方法解析
和字段解析搜索步骤差不多,只不过是先搜索父类,再搜索接口。
(4)接口方法解析
和类方法解析差不多,只不过接口中不会有父类,因此只需要对父接口进行搜索即可。

3.5 初始化

初始化是类加载过程的最后一步,此阶段才开始真正执行类中定义的Java程序代码(或者说字节码,也仅限与执行<clinit>()方法)。在准备阶段,我们已经给变量付过一次系统要求的初始值(零值)而在初始化阶段,则会根据程序员的意愿给类变量和其他资源赋值。主要是通过<clinit>()方法来执行的:
 (1)<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问如下:
[java] view plain copy
  1. public class Test {  
  2.     static{  
  3.         i = 0;//可以给变量赋值,编译通过  
  4.         System.out.println(i);//编译不通过!!不能进行访问后面的静态变量  
  5.     }  
  6.     static int i =1;  
  7. }  
有点与我们平常的认知相反,这里是可以下赋值,却不能访问...

 (2)<clinit>()方法与实例构造器<init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object

 (3)<clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

 (4)接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口与类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

 (5)虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

4、类加载器

前面说过,在类加载过程的第一个阶段:加载阶段,除了可以使用系统提供的引导类加载器外,还可以使用用户自定义的类加载器,以便让用户决定如何去获取所需要的类(是从Class文件中?还是从jar、或者其他方式...可以自由决定)。

4.1 类和类加载器

任意一个类,都需要由加载它的类加载器这个类本身共同确定其在Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达的更通俗一些:比较两个类是否相等,只有在这两个类是同一个类加载器加载的前提下才意义。否则,即使这两个类来自同一个Class文件,被同一个虚拟机加载,但只要加载他们的类加载器不同,那这两个类就必定不相等

这里的“相等”,包括代表类的 Class 对象的equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括 instanceof 关键字对对象所属关系判定等情况。下面代码演示了不同类加载器对 instanceof 关键字运算的结果的影响。

[java] view plain copy
  1. public class ClassLoaderTest {    
  2.     public static void main(String[] args) throws Exception {    
  3.         ClassLoader myLoader = new ClassLoader() {    
  4.             @Override    
  5.             public Class<?> loadClass(String name)    
  6.                     throws ClassNotFoundException {    
  7.                 try {    
  8.                     String fileName = name.substring(name.lastIndexOf(".") + 1)    
  9.                             + ".class";    
  10.                     InputStream is = getClass().getResourceAsStream(fileName);    
  11.                     if (is == null) {    
  12.                         return super.loadClass(name);    
  13.                     }    
  14.                     byte[] b = new byte[is.available()];    
  15.                     is.read(b);    
  16.                     return defineClass(name, b, 0, b.length);    
  17.                 } catch (IOException e) {    
  18.                     throw new ClassNotFoundException(name);    
  19.                 }    
  20.             }    
  21.         };    
  22.   
  23.         Class c = myLoader.loadClass("org.bupt.xiaoye.blog.ClassLoaderTest");    
  24.         Object obj = c.newInstance();    
  25.         System.out.println(obj.getClass());    
  26.         System.out.println(ClassLoaderTest.class);    
  27.         System.out.println(obj instanceof ClassLoaderTest);    
  28.   
  29.     }    
  30. }  
运行结果如下:
class org.bupt.xiaoye.blog.ClassLoaderTest  
class org.bupt.xiaoye.blog.ClassLoaderTest  
false
我们使用了一个自定义的类加载器去加载ClassLoaderTest,由第一句也可以看出这个对象也的确是ClassLoaderTest实例化出来的对象,但是这个对象在与类class org.bupt.xiaoye.blog.ClassLoaderTest 做属性检查的时候却反悔了false,这就是因为虚拟机中存在了两个ClassLoaderTest类,一个由系统应用程序类加载器加载,一个由我们自定义的类加载器加载,虽然是 来自同一个Class文件,但依然是两个独立的类

因此,类是否相等,取决于类本身加载该类的类加载器是否是同一个类加载器

4.2 双亲委派模型(这个是重点,一定要搞清楚)

从虚拟机的角度来讲,只存在两种不同的类加载器:

    一种是启动类加载器(Bootstrap ClassLoader),这个类加载器用 C++  语言实现, 是虚拟机自身的一部分:

    另一种就是所有其它的类加载器, 这些类加载器用Java 语言实现,独立于虚拟机外部,并且全都继承与抽象类 java.lang.ClassLoader。

从Java 开发人员的角度来看,类加载器还可以划分的更细致一些,绝大多数Java 程序都会用到以下3种系统提供的类加载器:

   (1)启动类加载器(Bootstrap ClassLoader) : 这个类加载器负责将存放在 <JAVA_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar ,名字不符合类库不会加载) 类库加载到虚拟机内存中。启动类加载器无法被 java 程序直接引用,如需要,直接使用 null 代替即可。
   (2)扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader 实现,它负责加载<JAVA_HOME>\lib\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
   (3)应用程序类加载器(Application ClassLoader):这个类加载器由 sun.misc.Launcher$AppClassLoader 实现。这个这个类加载器是 ClassLoader 中的getSystemClassLoader() 方法的返回值,所以一般称它为系统类加载器。它负责加载用户路径(ClassPath)上所指定的类库,开发者可以使用这个类加载器,如果应用程序没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

我们的应用程序都是由这3中类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。这些类加载器之间的关系一般如下图所示:


图中的类加载器之间的这种层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器,其余的类加载器都应该有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承关系来实现,而是使用组合关系来复用父加载器的代码。
双亲委派模型的工作过程是如果一个类加载器收到了类加载器的请求,它首先不会自己尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类时),子加载类才会尝试自己去加载

使用双亲委派模型的好处:就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。比如对于类Object来说,它存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器去加载,因此Object类在程序中的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类自己去加载的话,按照我们前面说的,如果用户自己编写了一个Object类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,此时Java类型提醒中最基础的行为也就无法保证了,应用程序也将变得混乱。

因此,双亲委派模型对于保证Java程序的稳定运作很重要,但是他的实现其实很简单,实现双亲委派模型的代码几种在java.lang.ClassLoader的loadClass()方法之中,逻辑清晰易懂:先检查类是否被加载过,若没有则调用父加载器的loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父加载器失败,抛出 ClassNotFoundException 异常后,再调用自己的 finClass() 方法进行加载,如下:
[java] view plain copy
  1. protected Class<?> loadClass(String name, boolean resolve)    
  2.         throws ClassNotFoundException {    
  3.     synchronized (getClassLoadingLock(name)) {    
  4.         // 首先检查类是否已经被加载过    
  5.         Class c = findLoadedClass(name);    
  6.         if (c == null) {    
  7.             long t0 = System.nanoTime();    
  8.             try {    
  9.                 if (parent != null) {    
  10.                     // 调用父类加载器加载    
  11.                     c = parent.loadClass(name, false);    
  12.                 } else {    
  13.                     c = findBootstrapClassOrNull(name);    
  14.                 }    
  15.             } catch (ClassNotFoundException e) {    
  16.                 // ClassNotFoundException thrown if class not found    
  17.                 // from the non-null parent class loader    
  18.             }    
  19.   
  20.             if (c == null) {    
  21.                 // If still not found, then invoke findClass in order    
  22.                 // to find the class.    
  23.                 //父类加载器无法完成加载,调用本身的加载器加载  
  24.                 long t1 = System.nanoTime();    
  25.                 c = findClass(name);    
  26.   
  27.                 // this is the defining class loader; record the stats    
  28.                 sun.misc.PerfCounter.getParentDelegationTime().addTime(    
  29.                         t1 - t0);    
  30.                 sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(    
  31.                         t1);    
  32.                 sun.misc.PerfCounter.getFindClasses().increment();    
  33.             }    
  34.         }    
  35.         if (resolve) {    
  36.             resolveClass(c);    
  37.         }    
  38.         return c;    
  39.     }    
  40. }  
好了,就总结这么多了,过一段时间可以回头看一下

(注:

内容来源:https://blog.csdn.net/shakespeare001/article/details/51765353

文中图片来源于:


阅读更多
版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/hewenya12/article/details/80689468
文章标签: jvm 类加载机制
上一篇深入理解java虚拟机(二)---GC标记清除算法与垃圾回收器总结
下一篇使用Oracle SQL Developer迁移DB2至Oracle数据库
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

关闭
关闭
关闭