深入理解Java虚拟机系列(三)--虚拟机类加载机制

系列文章目录

深入理解Java虚拟机系列文章

一.类加载时机

类从被加载到虚拟机内存开始,到卸载出内存为止,他的整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载7个阶段。
其发生顺序如图所示:
在这里插入图片描述

  • 加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的,即类的加载过程必须按照这种顺序按部就班的开始。
  • 解析阶段可以在初始化阶段后开始(为了支持Java语言的运行时绑定,如对象方法的调用)。
  • Java虚拟机规范中并没有对第一个阶段:加载进行一个强制约束,但是严格规定了什么时候必须对类立即进行初始化。(而加载、验证、准备这3个阶段必须发生在初始化之前,因此也决定了什么时候开始加载)

其中有5种情况必须对类立即进行初始化:

  1. 遇到new、getstatic、putstatic、invokestatic这4个字节码指令时,如果类没有经过初始化、则需要先触发初始化。

这4条指令最常见的场景:
new——>实例化对象的时候。
getstatic、putstatic——>读取或者设置一个类的静态字段(被final修饰)的时候。
invokestatic——>调用一个类的静态方法的时候。

  1. 使用java.lang.reflect包中的方法对类进行反射调用的时候,如果类没有经过初始化、则需要先触发初始化。
  2. 当初始化一个类的时候,如果发现它的父类还没有进行过初始化,那么需要先触发其父类的初始化。
  3. 当虚拟机启动的时候,用户需要指定一个要执行的主类(包含main方法的类),虚拟机会先初始化这个类。
  4. (了解即可)当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,那么先触发其初始化。

注意:
以上5种情况的行为被称之为对一个类进行主动引用,除此之外,所有引用类的方式都不会被触发初始化。举个简单的例子来说明什么是被动引用。

代码:

public class SuperClass {
    
    
    static {
    
    
        System.out.println("父类初始化完成!");
    }

    public static int value = 123;
}

public class SubClass extends SuperClass{
    
    
    static {
    
    
        System.out.println("子类初始化完成!");
    }
}

public class TestInit {
    
    
    public static void main(String[] args) {
    
    
        System.out.println(SubClass.value);
    }
}

结果:
在这里插入图片描述
上述代码当中可以看到最终只有父类进行了初始化。对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

二.类加载过程

2.1 加载

加载作为类加载过程的第一个阶段,需要完成3件事情:

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

获得类的二进制字节流可以有哪些途径?

扫描二维码关注公众号,回复: 12238505 查看本文章

从Zip压缩包获取。
1.从网络中获取,典型的应用为Applet。
2.运行时计算机生成,如动态代理技术。
3.由其他文件生成,如JSP应用,JSP文件生成对应的Class类。(JSP的顶层父类为Servlet类,所以本质上是个Servlet)

注意:
加载阶段与连接阶段的部分内容(如一部分字节码文件格式的验证动作)是交叉进行的。加载阶段尚未完成,连接阶段就可能已经开始。

2.2 验证

验证阶段为连接阶段的第一步,这一阶段的目的:确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并不会危害虚拟机自身的安全。 也因此,验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击。验证阶段大致会完成4个阶段的检验:

  1. 文件格式验证:验证字节流是否符合Class文件格式的规范,并能被当前版本的虚拟机处理。

可能的验证点:
1.常量池中的常量是否有不被支持的常量类型。
2.主、次版本号是否在当前虚拟机处理范围内。
3.指向常量的各种索引值中是否有指向不存在的常量或者不符合类型的常量。

  1. 元数据验证:对字节码描述的信息进行语义分析,以保证其符合Java语言规范。

可能的验证点:
1.这个类是否有父类。
2.这个类的父类是否继承了不允许被继承的类。(final修饰)
3.如果该类不是抽象类,是否实现了其父类或者接口中要实现的所有方法。

  1. 字节码验证:通过数据流和控制流分析,确定程序语义是合法的。

该阶段一般对类的方法体进行校验分析。
1.保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作。例如这种情况:在操作栈中放了一个int类型的数据,但是使用的时候却按照long类型来加载到本地变量表中。
2.保证跳转指令不会跳转到方法体以外的字节码指令上。
3.保证方法体中的类型转换是有效的。

  1. 符号引用验证:对类自身以外的信息进行匹配性校验。目的:确保解析动作能够正常执行。

1.符号引用中通过字符串描述的全限定名是否能找到对应的类。
2.在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。

2.3 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都在方法区中分配。

注意:

  1. 这里进行内存分配的仅包括类变量(被static修饰的变量),不包括实例变量,实例变量将会在对象实例化的时候随着对象一起分配在堆中。
  2. 这里所说的初始值”通常情况“下是数据类型的零值(默认值)。

假设一个类变量定义为:
public static int value = 123;
那么变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value复制为123的putstatic指令实在程序被编译后。 如果一个类变量存在ConstantValue属性,那么在准备阶段value就会被初始化对应的值, 例如:
public static final int value = 123;
那这个时候value在准备阶段会赋值为123。
static final修饰的字段在javac编译时生成constantValue属性,在类加载的准备阶段直接把constantValue的值赋给该字段。

数据类型 零值(默认值)
int 0
long 0L
short (short)0
char ‘\u0000’
byte (byte)0
boolean false
float 0.0f
double o.0d
reference null

2.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

这里讲解下什么是直接引用和符号引用:

  1. 符号引用:符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量

符号引用与虚拟机实现的内存布局无关,引用的目标也不一定已经加载到内存当中,并且符号引用的字面量形式已经明确定义,即格式都是有一定要求的。如:CONSTANT_Class_info、CONSTANT_Fieldref_info等。

  1. 直接引用:直接引用是直接指向目标的指针、相对偏移量或者是一个能够够间接定位到目标的句柄。

直接引用和虚拟机实现的内存布局相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般会不同,如果有了直接引用,那么引用的目标必然在内存中存在。

两者区别:

  1. 虚拟机实现的内存关系上:符号引用与之无关,直接引用与之有关。
  2. 引用对象:符号引用引用的对象不一定存在于内存,直接引用的对象一定存在于内存。
  3. 本质上:符号引用用符号去描述引用的目标,直接引用则是直接指向目标或其有关的句柄。

解析的内容包括:

  1. 类与接口的解析(相关的符号引用:CONSTANT_Class_info)
  2. 字段的解析(相关的符号引用:CONSTANT_Fieldref_info)
  3. 类方法解析(相关的符号引用:CONSTANT_Methodref_info)
  4. 接口方法解析(相关的符号引用:CONSTANT_InterfaceMethodref_info)

2.5 初始化

初始化阶段是类加载过程的最后一步,前面的阶段当中,除了在第一步加载阶段之中用户应用程序可以通过自定义类加载器来参与之外,其他的动作完全由JVM来主导和控制。到了初始化阶段,才真正开始执行代码。

在准备阶段,前面提到,变量已经赋值过一次系统要求的初始值(除了static final修饰的),而在初始化阶段,会根据我们自己制定的Java代码去初始化类变量和其他资源。换句话说,初始化阶段是执行类构造器< clinit >()方法的过程。

< clinit >()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})合并而成。

2.5.1重点1:

  1. 编译器收集的顺序是由语句在源文件中出现的顺序做决定。
  2. 静态语句块中只能访问到定义在静态语句块之前的变量。
  3. 定义在静态语句块之后的变量,允许前面的静态语句块赋值,但不允许访问。

案例1:

public class TestInit {
    
    
    static {
    
    
        i = 0;// 这里可以正常编译通过
        System.out.println(i);// 到这里就会发现idea上这行代码已经报红了
    }

    static int i = 1;
}

运行结果:
在这里插入图片描述

2.5.2重点2:

  1. < clinit >()方法与类的构造函数不同,他不需要显式的调用父类构造器,虚拟机会保证在子类的< clinit >()方法执行之前,父类的< clinit >()方法已经执行完毕。
  2. 父类中定义的静态语句块要优先于子类的变量赋值操作。

案例2:

public class Parent {
    
    
    public static int a = 1;

    static {
    
    
        a = 2;
    }
}

public class Sub extends Parent {
    
    
    public static int b = a;

    public static void main(String[] args) {
    
    
        System.out.println(Sub.b);// 结果输出2,父类的static{}会比子类的b=a这个操作优先
    }
}

2.5.3重点3:

  1. < clinit >()方法对于类or接口来说并不是必须的,若一个类中无静态语句块也没有对变量的赋值操作,那么编译器可以部位这个类生成< clinit >()方法。
  2. 接口中不能使用静态语句块,但是仍然有变量初始化的赋值操作

对于这一条,接口和类还是有一定的不同:
1.执行接口的< clinit >()方法不需要先执行父类的< clinit >()方法。
2.只有父接口中定义的变量被使用的时候,父接口才会对其初始化。
3.接口的实现类在初始化的时候也不会先执行接口的< clinit >()方法。

  1. 虚拟机会保证一个类的< clinit >()方法在多并发的情况下被正确的加锁、同步,有且只有一个线程执行< clinit >()方法(执行期间其他线程进入阻塞状态)。

案例3:

public class Test {
    
    
    static class DeadLoopClass {
    
    
        static {
    
    
            // 若不加if语句,则编译时报错:Initializer does not complete normally
            if (true) {
    
    
                System.out.println(Thread.currentThread() + "init DeadLoopClass");
                while (true) {
    
    

                }
            }
        }
    }

    public static void main(String[] args) {
    
    
        Runnable runnable = new Runnable() {
    
    
            @Override
            public void run() {
    
    
                System.out.println(Thread.currentThread() + "开始");
                DeadLoopClass deadLoopClass = new DeadLoopClass();
                System.out.println(Thread.currentThread() + "结束");
            }
        };

        Thread t1 = new Thread(runnable);
        Thread t2 = new Thread(runnable);
        t1.start();
        t2.start();
    }
}

结果:
在这里插入图片描述
解释下结果:

  1. 首先线程做了什么事:new 了一个对象DeadLoopClass。
  2. new了这个对象,肯定要经历他的初始化阶段。
  3. 初始化阶段进入到static{}代码块中,这里代码写成了死循环。
  4. 最终结果的线程0对其进行了初始化,然后进入死循环,这个时候线程1进入堵塞状态,并且可以看到,程序是没跑完的(还是那句话,进入了死循环)。
  5. 说明了什么?见上述的第三条。
    在这里插入图片描述

2.6 小总结

总的来说JVM类加载的步骤:

  1. 加载(由类加载器完成):1.获得二进制字节流。2.静态存储结构转化为运行时的数据结构。3.生成代表类的对象。
  2. 校验:确保字节流包含的信息符合当前虚拟机的要求。
  3. 准备:为类变量分配内存,初始化为默认值。
  4. 解析:将类型中的符号引用转化为直接引用。
  5. 初始化:执行类构造器< client> 方法的过程(包括static{})

三.类加载器

如果能通过一个类的全限定名称来获取描述该类的二进制字节流,那这样的角色称作为类加载器。

3.1 类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,并且每一个类加载器都有一个独立的类名称空间。

比较两个类是否相等,只有在这两个类是在同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要家在他们的类加载器不同,这两个类就必定不相等。

举个例子:

package jvm;

import java.io.IOException;
import java.io.InputStream;

public class ClassLoaderTest {
    
    
    public static void main(String[] args) throws Exception {
    
    
        ClassLoader loader = new ClassLoader() {
    
    
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
    
    
                try {
    
    
                    // 文件名格式:类名.class
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if (is == null) {
    
    
                        return super.loadClass(name);
                    }
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
    
    
                    throw new ClassNotFoundException(name);
                }
            }
        };
        // 将这个类进行实例化。
        Object obj = loader.loadClass("jvm.ClassLoaderTest").newInstance();
        System.out.println(obj.getClass());
        System.out.println(obj instanceof jvm.ClassLoaderTest);
    }
}

结果:
在这里插入图片描述
结果解释:

  1. 首先代码的大致意图就是我们使用了自定义的类加载器去加载了与该类同一路径下的Class文件,并且实例化了这个类的对象。
  2. 第一行输出class jvm.ClassLoaderTest可以看出,我们实例出的对象却是是ClassLoaderTest的实例。
  3. 从第二行输出false可以看出,这个对象与ClassLoaderTest做所属类型检查的时候返回false。因为虚拟机中存在了两个ClassLoaderTest类。
  4. 其中一个是由系统引用程序类加载器加载的,另一个是由我们自定义的类加载器加载的。即使来自于同一个Class文件,但是使用的类加载器不同,产生的类是两个独立的类,因此做类型检查的时候返回了false。(如果属于同一个类,那么返回true)

3.2 双亲委派模型

在介绍双亲委派模型之前,先介绍下类加载器的种类。

  • 启动类加载器(Bootstrap ClassLoader):由C++语言实现,是JVM自身的一部分。

这个类加载器主要负责将存放在< JAVA_HOME>\lib目录中、被-Xbootclasspath参数指定的路径中、并且被虚拟机识别的类库加载到虚拟机内存当中。

  • 扩展类加载器(Extension ClassLoader):负责加载< JAVA_HOME>\lib\ext目录中的或者被java.ext.dirs系统变量所制定的路径中的所有类库。(开发者可以直接使用)
  • 应用程序类加载器(Application ClassLoader):负责加载用户类路径(ClassPath)上所制定的类库。

他们的关系如图:
在这里插入图片描述
那么像上面图中展示的,类加载器之间的这种层次关系,称作为双亲委派模型双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器

双亲委派模型的工作流程:

  1. 如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。
  2. 因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个请求的时候,子加载器才会尝试自己去加载。

使用双亲委派模型的好处:

让Java类随着他的类加载器一起具备了带有优先级的层次关系。

例如java.lang.Object,存放于rt.jar之中,无论哪个类加载器要加载这个类,最终都是委派给顶层的启动类加载器来完成加载,因此Object类在程序的各种类加载器环境中都是同一个类。
否则,如果没有双亲委派模型,由各个不同的类加载器自行去加载的话,系统中会出现多个不同的Object类,则Java类型体系中的最基础行为也无法得到保证。(Object是一切类的父类)

双亲委派模型的实现:
核心代码贴出(JDK1.8):

// java.lang.ClassLoader中的方法
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
    
    
        synchronized (getClassLoadingLock(name)) {
    
    
            // 1.首先检查请求的类是否被加载过
            Class<?> c = findLoadedClass(name);
            // 2.如果c为null,则代表请求的类没有被加载过
            if (c == null) {
    
    
                long t0 = System.nanoTime();
                try {
    
    
                	// 3.如果有父类加载器,则优先让他的父类加载器进行加载
                    if (parent != null) {
    
    
                        c = parent.loadClass(name, false);
                    } else {
    
    
                    	// 4.如果父类加载器为空,则默认使用启动类加载器作为父加载器
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
    
    
                    // 若父类加载器抛出ClassNotFoundException则代表父类加载器无法完成该请求。
                }

                if (c == null) {
    
    
                    // 5.如果父类加载器无法加载,则调用本身的findClass方法来进行加载
                    long t1 = System.nanoTime();
                    c = findClass(name);
               		// jvm统计
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
    
    
                resolveClass(c);
            }
            return c;
        }
    }

3.3 打破双亲委派模型

首先来说下为什么要破坏双亲委派模型:
原因:因为在某些情况下父类加载器需要委托子类加载器去加载class文件。
举个例子:

我们编程当中常用的JDBC就是个很好的打破了双亲委派模型的例子。
1.其中的Driver接口定义与jdk当中,而其实现是由不同的数据库服务商来提供。
如:Mysql、Oracle、KingbaseES等数据库。比如咱们使用mysql数据库的时候,不是要去下载对应的jar包嘛。如:mysql-connector-java,那么可以说这个jar包是Driver接口的一个具体实现。
2.DriverManager会负责加载各个实现了Driver接口的实现类并进行管理,但是它是由启动类加载器来实现加载(顶层父类),问题是,具体的实现是由服务商提供的,也就是这些具体实现是用户自己写的代码,按理来说顶层父类是处理不了的,需要用户程序类加载器去加载。
3.因此这个时候需要启动类加载来委托子类加载器来加载Driver的具体实现,也就打破了双亲委派模型。

3.3.1 JDBC打破双亲委派模型

证明:
pom文件:

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.47</version>
</dependency>

代码:

import java.sql.Connection;
import java.sql.DriverManager;

public class TestJDBC {
    
    
    public static void main(String[] args)throws Exception {
    
    
        Connection connection=null;
        try {
    
    
            connection= DriverManager.getConnection("jdbc:mysql://localhost:3306/test?useSSL=false","root","root");
        }catch (Exception e){
    
    
            e.printStackTrace();
        }
        
        System.out.println(connection.getClass().getClassLoader());
        // 获取线程上下文类加载器
        System.out.println(Thread.currentThread().getContextClassLoader());
        // 获取Connection类的类加载器
        System.out.println(Connection.class.getClassLoader());
    }
}

结果:
在这里插入图片描述
结果分析:
Connection类的类加载器是启动类加载器(因为它输出的是null),但是!但是从第一行和第二行的输出语句中可以看到Connection类是委派给应用类加载器加载的(输出的AppClassLoader)

看一下getConnection的代码:

private static Connection getConnection(
        String url, java.util.Properties info, Class<?> caller) throws SQLException {
    
    
        ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
        synchronized(DriverManager.class) {
    
    
            if (callerCL == null) {
    
    
            	// 若callerCL为null,则把当前线程上下文类加载器赋值给他。
            	// 获取线程上下文类加载器,从而也就获得了应用程序类加载器(也可能是自定义的类加载器)
                callerCL = Thread.currentThread().getContextClassLoader();
            }
        }

        if(url == null) {
    
    
            throw new SQLException("The url cannot be null", "08001");
        }

        println("DriverManager.getConnection(\"" + url + "\")");

        // Walk through the loaded registeredDrivers attempting to make a connection.
        // Remember the first exception that gets raised so we can reraise it.
        SQLException reason = null;

        for(DriverInfo aDriver : registeredDrivers) {
    
    
            // If the caller does not have permission to load the driver then
            // skip it.
            if(isDriverAllowed(aDriver.driver, callerCL)) {
    
    
                try {
    
    
                    println("    trying " + aDriver.driver.getClass().getName());
                    // 加载com.mysql.jdbc.Driver
                    // 即通过线程上下文类加载器去加载这个Driver类,从而打破了双亲委派模型
                    Connection con = aDriver.driver.connect(url, info);
                    if (con != null) {
    
    
                        // Success!
                        println("getConnection returning " + aDriver.driver.getClass().getName());
                        return (con);
                    }
                } catch (SQLException ex) {
    
    
                    if (reason == null) {
    
    
                        reason = ex;
                    }
                }

            } else {
    
    
                println("    skipping: " + aDriver.getClass().getName());
            }

        }

        // if we got here nobody could connect.
        if (reason != null)    {
    
    
            println("getConnection failed: " + reason);
            throw reason;
        }

        println("getConnection: no suitable driver found for "+ url);
        throw new SQLException("No suitable driver found for "+ url, "08001");
    }

private static boolean isDriverAllowed(Driver driver, ClassLoader classLoader) {
    
    
        boolean result = false;
        if(driver != null) {
    
    
            Class<?> aClass = null;
            try {
    
    
            	// 这一步则对类进行初始化,而初始化之前必然要先进性类的加载工作。
                aClass =  Class.forName(driver.getClass().getName(), true, classLoader);
            } catch (Exception ex) {
    
    
                result = false;
            }

             result = ( aClass == driver.getClass() ) ? true : false;
        }

        return result;
    }

代码太长没关系,我这里做个小总结:

  1. DriverManager这个类的getConnection 方法是干什么的?目的:需要根据参数传进来的 url 从所有已经加载过的 Drivers 里找到一个合适的 Driver 实现类去连接数据库。我们这里是mysql。
  2. Driver 实现类(这里是mysql)在第三方 jar 里, 要用AppClassLoader加载. 而 DriverManager被BootstrapClassLoader加载。并且,启动类加载器无法加载Driver的实现类(因为不在lib目录下)因此只能打破双亲委派模型,用他的下级类加载器类加载。
  3. 获取上下文的类加载器有关代码:这里获取了应用类加载器
callerCL = Thread.currentThread().getContextClassLoader();
  1. 调用isDriverAllowed(aDriver.driver, callerCL)方法,用第三步获得的应用类加载器去加载我们连接MySQL的相关jar。
// isDriverAllowed方法内部的一行代码
// 这里获取的Name为com.mysql.jdbc.Driver
aClass =  Class.forName(driver.getClass().getName(), true, classLoader);

最后,本篇文章主要从类加载的过程、类加载器、以及双亲委派模型3个层面来进行介绍。我自己写这篇文章的时候其实还看了很多的博客做了对比,受益匪浅,也希望看到这篇博客的读者们能有一个好的观感和理解。最后的最后顺便说一下,Tomcat也是打破了双亲委派模型,但是这个原理上就比较复杂了,有兴趣的读者可以去相关的博客去学习下(我还没有深入的了解)。

猜你喜欢

转载自blog.csdn.net/Zong_0915/article/details/110289906