面试JVM(四)类加载机制

这次我们来学习类加载的机制,我们用一个问题来作为本次学习的开端:一个Java程序为什么可以跨平台运行

众所周知,在我们完成一个Java程序之后,这个程序就会被编译器编译为.class字节码文件。由于不同的硬件平台有不同的JVM,这些不同的JVM可以把字节码文件翻译为对应自己硬件平台能够执行的机器码。

明白了上述问题,我们就可以思考一些细节了:一个Java程序,一个Java类,是如何被编译成.class字节码文件的?编译出来的字节码文件长啥样子?如何加载运行这个字节码文件?这个字节码文件在哪里运行?

类是在运行期间动态加载的。

类的生命周期


包括以下 7 个阶段:

  • 加载(Loading)
  • 验证(Verification)
  • 准备(Preparation)
  • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定。

类初始化时机

虚拟机规范中并没有强制约束何时进行加载,但是规范严格规定了有且只有下列五种情况必须对类进行初始化(加载、验证、准备都会随着发生):

  • 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行过初始化,则必须先触发其初始化。最常见的生成这 4 条指令的场景是:使用 new 关键字实例化对象的时候;读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候;以及调用一个类的静态方法的时候。

  • 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。

  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类;

  • 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化;

以上 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。被动引用的常见例子包括:

  • 通过子类引用父类的静态字段,不会导致子类初始化。
System.out.println(SubClass.value); // value 字段在 SuperClass 中定义
  • 通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法。
SuperClass[] sca = new SuperClass[10];
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
System.out.println(ConstClass.HELLOWORLD);

类加载过程

包含了加载、验证、准备、解析和初始化这 5 个阶段。

1. 加载

加载是类加载的一个阶段,注意不要混淆。

加载过程完成以下三件事:

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

其中二进制字节流可以从以下方式中获取:

  • 从 ZIP 包读取,这很常见,最终成为日后 JAR、EAR、WAR 格式的基础。
  • 从网络中获取,这种场景最典型的应用是 Applet。
  • 运行时计算生成,这种场景使用得最多得就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。
  • 由其他文件生成,典型场景是 JSP 应用,即由 JSP 文件生成对应的 Class 类。
  • 从数据库读取,这种场景相对少见,例如有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。 ...

2. 验证

确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

  • 文件格式验证:验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。
  • 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。
  • 字节码验证:通过数据流和控制流分析,确保程序语义是合法、符合逻辑的。
  • 符号引用验证:发生在虚拟机将符号引用转换为直接引用的时候,对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。

3. 准备

类变量是被 static 修饰的变量,准备阶段为类变量分配内存并设置初始值,使用的是方法区的内存。

实例变量不会在这阶段分配内存,它将会在对象实例化时随着对象一起分配在 Java 堆中。(实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次)

初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123。

public static int value = 123;

如果类变量是常量,那么会按照表达式来进行初始化,而不是赋值为 0。

public static final int value = 123;

4. 解析

将常量池的符号引用替换为直接引用的过程。

5. 初始化

初始化阶段才真正开始执行类中的定义的 Java 程序代码。初始化阶段即虚拟机执行类构造器 <clinit>() 方法的过程。

在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。

<clinit>() 方法具有以下特点:

  • 是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。例如以下代码:
public class Test {
    static {
        i = 0;                // 给变量赋值可以正常编译通过
        System.out.print(i);  // 这句编译器会提示“非法向前引用”
    }
    static int i = 1;
}
  • 与类的构造函数(或者说实例构造器 <init>())不同,不需要显式的调用父类的构造器。虚拟机会自动保证在子类的 <clinit>() 方法运行之前,父类的 <clinit>() 方法已经执行结束。因此虚拟机中第一个执行 <clinit>() 方法的类肯定为 java.lang.Object。

  • 由于父类的 <clinit>() 方法先执行,也就意味着父类中定义的静态语句块要优于子类的变量赋值操作。例如以下代码:

static class Parent {
    public static int A = 1;
    static {
        A = 2;
    }
}

static class Sub extends Parent {
    public static int B = A;
}

public static void main(String[] args) {
     System.out.println(Sub.B);  // 输出结果是父类中的静态变量 A 的值,也就是 2。
}
  • <clinit>() 方法对于类或接口不是必须的,如果一个类中不包含静态语句块,也没有对类变量的赋值操作,编译器可以不为该类生成 <clinit>() 方法。

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

  • 虚拟机会保证一个类的 <clinit>() 方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit>() 方法,其它线程都会阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽。

类加载器

实现类的加载动作。在 Java 虚拟机外部实现,以便让应用程序自己决定如何去获取所需要的类。

1. 类与类加载器

两个类相等:类本身相等,并且使用同一个类加载器进行加载。这是因为每一个类加载器都拥有一个独立的类名称空间。

这里的相等,包括类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true。

2. 类加载器分类

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

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

  • 所有其他类的加载器,这些类由 Java 实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。

从 Java 开发人员的角度看,类加载器可以划分得更细致一些:

  • 启动类加载器(Bootstrap ClassLoader)此类加载器负责将存放在 <JAVA_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。

  • 扩展类加载器(Extension ClassLoader)这个类加载器是由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将 <JAVA_HOME>/lib/ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。

  • 应用程序类加载器(Application ClassLoader)这个类加载器是由 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

3. 双亲委派模型

应用程序都是由三种类加载器相互配合进行加载的,如果有必要,还可以加入自己定义的类加载器。

下图展示的类加载器之间的层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。这里类加载器之间的父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)的关系实现。


(一)工作过程

一个类加载器首先将类加载请求传送到父类加载器,只有当父类加载器无法完成类加载请求时才尝试加载。

(二)好处

使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一。

例如 java.lang.Object 存放在 rt.jar 中,如果编写另外一个 java.lang.Object 的类并放到 ClassPath 中,程序可以编译通过。因为双亲委派模型的存在,所以在 rt.jar 中的 Object 比在 ClassPath 中的 Object 优先级更高,因为 rt.jar 中的 Object 使用的是启动类加载器,而 ClassPath 中的 Object 使用的是应用程序类加载器。正因为 rt.jar 中的 Object 优先级更高,因为程序中所有的 Object 都是这个 Object。

(三)实现

以下是抽象类 java.lang.ClassLoader 的代码片段,其中的 loadClass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出 ClassNotFoundException,此时尝试自己去加载。

public abstract class ClassLoader {
    // The parent class loader for delegation
    private final ClassLoader parent;

    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }
}

4. 自定义类加载器实现

FileSystemClassLoader 是自定义类加载器,继承自 java.lang.ClassLoader,用于加载文件系统上的类。它首先根据类的全名在文件系统上查找类的字节代码文件(.class 文件),然后读取该文件内容,最后通过 defineClass() 方法来把这些字节代码转换成 java.lang.Class 类的实例。

java.lang.ClassLoader 类的方法 loadClass() 实现了双亲委派模型的逻辑,因此自定义类加载器一般不去重写它,而是通过重写 findClass() 方法。

public class FileSystemClassLoader extends ClassLoader {

    private String rootDir;

    public FileSystemClassLoader(String rootDir) {
        this.rootDir = rootDir;
    }

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = getClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] getClassData(String className) {
        String path = classNameToPath(className);
        try {
            InputStream ins = new FileInputStream(path);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead;
            while ((bytesNumRead = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    private String classNameToPath(String className) {
        return rootDir + File.separatorChar
                + className.replace('.', File.separatorChar) + ".class";
    }
}
 
 

1.       Java程序编译成为一个.class字节码文件

一个写好的Java程序,是如何编译成为.class字节码文件的呢?

其实我也不了解其中的细节,网上查阅了一些资料,比较少也比较难懂…毕竟编译方面的东西,太底层了。我们需要知道的就是一个Java程序是由javac编译器编译成为.class字节码文件的即可。

那么编译出来的.class文件长啥样子呢?

这时候,我们就需要了解Class类文件的结构了。

以下是摘自《深入理解Java虚拟机的片段》:

Class类文件的结构

         Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部都是程序运行的必要数据,没有空隙存在。当遇到需要占用8位字节以上的空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储。

根据Java虚拟机规范的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储,这种伪结构中只有两种数据类型:无符号数

无符号数属于基本的数据类型,以u1u2u4u8来分别代表1个字节、2个字节、4个字节、8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值,或者按照UTF-8编码构成字符串值。

表是由多个无符号数或者其它表作为数据项构成的复合数据类型,所有表都习惯性地以"_info"结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。

其他的就不多说了,自己可以百度,太底层的东西看起来是挺有难度的。。。

2.       Class加载

当我们成功的把一个Java程序编译成为.class字节码文件之后,会对class进行装载,读取类的二进制流,然后把类的信息存储至方法区中,然后在堆中生成java.lang.Class对象。

也就是说,在加载阶段,JVM做了一下几件事:

1.      通过全限定类名,获取类的二进制流

2.      把这个转化为方法区的运行时数据结构

3.      将内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

这里需要说一下java.lang.Class,每种不同的类型都有自己对应的Class对象,Class对象和对应类型的类的实例的关系可以用一张图来描述:

关于Class这个东西其实还有很多其他的东西需要摆,比如类的反射机制等等

3.      连接

连接总共分为三个步骤:验证,准备,解析。

1.      验证:验证的目的是为了确保类的二进制流是正确的,是符合当前虚拟机规范的,那么怎么判断类的二进制流是否正确呢?

1.      文件格式验证:是否以魔数0xCAFEBABE开头?主次版本号是否合理?

2.      元数据验证:检查类是final的类不拥有子类?检查final方法不能被覆盖

3.      字节码验证:检查字节码的完整性,听说很复杂,就先不看了

4.      符号引用验证:常量池中描述的类是否存在,访问的方法或字段是否有足够的权限。

这里要解释一下这个常量池,常量池分为两种,一种是运行时常量池,一种是静态常量池。

静态常量池是Class文件中的常量池,前面我们提到了Class字节码文件的结构,一个字节码文件是由魔数,版本号,紧接着就是常量池信息组成的。Class字节码文件中的这个常量池,就是静态常量池。

下面引申一下这两种常量池的概念:

1. 静态常量池里保存了字面量符号引用量,我们需要解释一下,什么是字面量,什么是符号引用量。

字面量其实就是常量,比如int a = 10,Stringstr = “abc”,这里的10abc就是字面量,再比如声明为final的变量也是一种常量:final int a = 10,那么这个a也可以作为字面量,常量。

符号引用量包括了三种类型的常量类和接口的全限定名,字段名称和描述符,方法名称和描述符。(这部分不怎么看懂,以后再看的时候深入看一下)

2.运行时常量池:运行时常量池存在于方法区中,相对于静态的class常量池,运行时常量池注重的是动态性,有些常量不一定是编译时就产生的,运行时也会产生常量,比如String.intern()。常量池的好处之一就是节省内存空间,另一方面是在比较的时候直接比较值,而不是比较引用,节省运行时间。

 

2.      准备:

在方法区中,为各个对象和变量分配内存,并且为类设置初始值。

比如static int a = 1这种,在准备期间,a的值就是0,在初始化的时候才会变为1。

但像常量:static final int a = 1,在准备期间,a的值就是1。

3.      解析:

在这个阶段,符号引用将会被转化为直接引用

刚才并没有说清什么是符号引用,举一个简单的例子:在Java程序编译期间,比如你的类java.exskil.A引用了java.exskil.B,但是编译的时候并不能直接获取到类B的引用,因此只能用一串描述符比如java.exskil.B(描述符)的常量来代替,说明这里应该是B类引用的地址,但是我现在还不知道,所以就用B类的一个描述符常量先来替代了。

直接引用就相当于指针,代表了B的地址。

3.      初始化<clinit>( classinitialize阶段)

         把静态变量赋值为初始值,执行static{}静态代码块。

         如果这个类有父类,而且父类还没有被加载,那么久停下来,先加载他的父类。

         Classinitialize方法是线程安全的,每次初始化一个类,都要加载Classinitialize方法。

         什么时候类会初始化?

1.      new的时候

2.      使用反射机制的时候(Class.forName())

3.      初始化一个类的子类的时候

4.      调用类的静态方法的时候

4.      ClassLoader类加载器

         什么是ClassLoader?

1.      ClassLoader是一个抽象类

2.      ClassLoader的实例负责读入Java字节码文件,把类装载至JVM中

3.      ClassLoader负责类的装载阶段中的加载部分

4.      你可以根据针对不同字节码流的不同获取方式,自定义自己的ClassLoader

类加载的动态性

一个Java程序肯定是由很多个类组成的,但是程序启动时,并不是一次性就把所有程序加载完成的,而是先加载一部分保证程序能够正常运行的基础类一次性加载至内存中,其他的类等到需要使用到时在加载,减少了内存开销

都有哪些ClassLoader

1.      App ClassLoader:加载自己写的类,也就是当前应用的classpath的所有类

(classpath是什么? 它的作用是什么?

classpath是javac编译器的一个环境变量。

它的作用是指定类搜索路径,它与import、package关键字有关。

CLASSPATH 包含了一个或多个目录,它们作为一种特殊的“”使用,从这里展开对.class 文件的搜索。从那个根开始,解释器会寻找包名,并将每个点号(句点)替换成一个斜杠,从而生成从CLASSPATH 根开始的一个路径名(所以package foo.bar.baz 会变成foo\bar\baz 或者foo/bar/baz;具体是正斜杠还是反斜杠由操作系统决定)。随后将它们连接到一起,成为CLASSPATH 内的各个条目(入口)。以后搜索.class 文件时,就可从这些地方开始查找与准备创建的类名对应的名字。)

2.      BootStrap ClassLoader:最顶层的加载类,由C++语言编写(所以Java中是看不到他的哦),主要加载核心类库。%JRE_HOME%\lib,目录下的rt.jar,resources.jar的文件

3.      Extention ClassLoader:扩展的类加载器,主要加载%JRE_HOME%\lib\ext目录下的jar包和class文件

4.      一幅图描述三个ClassLoader之间的关系

在类的加载的时候,是从顶层向下,先从Bootstrap ClassLoader开始的。

在类的查找的时候,是从底层向上,先从AppClassLoader开始查找的,若AppClassLoader能够查找到该类,就不在向上继续查找了。

 

Tomcat的类加载器

         双亲委派模型:

         父加载器的概念:AppClassLoader的父加载器是ExtClassLoader,ExtClassLoader的父加载器是BootstrapClassLoader,但是由于BootstrapClassLoader是由C++写的,所以ExtClassLoader获取父加载器是null的。

         当你需要加载一个类的时候,比如java.exskil.A这个类,他会先在AppClassLoader的缓存里看看这个类是否已经被加载,如果没有,那么首先会将加载任务委托给父类,让父类去加载这个类,依递归。如果父类可以加载,那就成功返回;如果父类不能加载,那就让这个类自己加载。

         这样做的好处?举个例子,java.lang.Object这个类在rt.jar下,这个类本来就是由BootstrapClassLoader加载的,在任何类加载器环境下都是唯一的这一个类,无论哪一个类加载器要加载这个类,最终都会到最顶层的BootstrapClassLoader中去加载。试想,如果用户自定义了一个名字为java.lang.Object的类,并且放在了ClassPath中,那么就会出现两个重名的类。



猜你喜欢

转载自blog.csdn.net/lettyisme/article/details/80565075