透彻理解JVM类加载子系统

Posted 纵横千里,捭阖四方

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了透彻理解JVM类加载子系统相关的知识,希望对你有一定的参考价值。

概述

本文我们学习类装载系统的第一个组成部分—类装载器,其功能是将文件加载到内存中,同时该子系统还要执行校验、初步的初始化等等,因此掌握其执行过程是我们学习JVM的第一步。

具体的学习内容是:

  • 1 掌握类加载器子系统的基本工作流程。

  • 2 掌握初始化阶段如何初始化static类型变量的。

  • 3 掌握类加载器的分类、特征、和关联关系。

  • 4 理解什么是双亲委派,以及为什么要采用双亲委派方式。

Java类从读取到最后执行完成,大致包含三个部分:装载器ClassLoader用来将文件加载到内存中、运行时数据(也就是JVM内存)用来创建和管理对象实例,最后的字节码由执行引擎来具体执行。整个过程的大致结构下图所示:

上图中,运行时数据区和执行引擎是协同工作的,执行引擎通过各种指令来操作内存中的数据。如果创建了一些全局变量或者静态变量就会通过指令来操作方法区,如果创建和管理对象就会操作堆区,如果需要指令一直向下走就会使用程序计数器。如果需要本地C的类库就会使用本地方法栈等等。上述结构中,方法区、堆区和PC计数器是最重要的,本地方法接口等只在需要的时候才有空,有的JVM甚至没有本地方法栈。例如方法区只有HotSpot虚拟机有,J9,JRockit都没有。

加载系统又分为加载、链接和初始化三个阶段,每个阶段又有更具体的功能,其中类加载子系统的详细结构如下:

 这就是我们本章要研究的内容。

1.类加载器子系统

首先说明一点,随着JVM的发展,能够处理的不仅仅是Java程序了,其他语言的程序只要编译出来的是字节码符合JVM的规范就可以被装载和使用,例如Groovy等等,如下图。

类加载器子系统作用:

  1. 类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识。

  2. ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。

  3. 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)

class file(在下图中就是Car.class文件)存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来根据这个文件实例化出n个一模一样的实例。class file加载到JVM中,被称为DNA元数据模板(在下图中就是内存中的Car Class),放在方法区。在.class文件–>JVM–>最终成为元数据模板,此过程就要一个运输工具(类装载器Class Loader),扮演一个快递员的角色。

 例如我们现在要加载这个类:

public class HelloLoader 
    public static void main(String[] args) 
        System.out.println("Hello world....");
    

它的加载过程大致如下所示:

 首先是执行 main() 方法(静态方法)就需要先加载main方法所在类 HelloLoader。加载成功之后,则进行链接、初始化等操作。完成后调用 HelloLoader 类中的静态方法 main。如果失败了则抛出异常。

接下来我们就具体看一下每个步骤的工作。

1.1 加载阶段

这里的加载是加载器里更具体的文件加载了:

  1. 通过一个类的全限定名获取定义此类的二进制字节流

  2. 将这个字节流所代表的静态存 储结构转化为内存结构。

  3. 在内存中生成一个代表这个类的java.lang.Class对象,这个对象就是字节流本身,作为方法区这个类的各种数据的访问入口。

加载class文件的方式:

  1. 从本地系统中直接加载

  2. 通过网络获取,典型场景:Web Applet

  3. 从zip压缩包中读取,成为日后jar、war格式的基础

  4. 运行时计算生成,使用最多的是:动态代理技术

  5. 从加密文件中获取,典型的防Class文件被反编译的保护措施

1.2 链接阶段

链接分为三个子阶段:验证 -> 准备 -> 解析。

二进制文件(没有)

doc (txt word)

第一步:验证(Verify)

  1. 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。

  2. 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。

编译好的class文件是不能直接打开的,在windows下我们可以使用UE,在mac下可以使用sublime等直接打开编译好的class文件。高级一点的工具可以使用 BinaryViewer软件查看字节码文件,会发现文件开头均为 CAFE BABE ,如果出现不合法的字节码文件,那么将会验证不通过。

 上图中我们打开了两个编译好的class文件,可以看到其开头都是cafe babe。

第二步:准备(Prepare)

  1. 为类变量(static变量)分配内存并且设置该类变量的默认初始值,即零值。

  2. 这里不包含用final修饰的static,因为final在编译的时候就会分配好了默认值,准备阶段会显式初始化。

  3. 注意:这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。

代码:变量a在准备阶段会赋初始值,但不是1,而是0,只有到了初始化阶段会被赋值为 1。

public class HelloApp 
    private static int a = 1;//prepare:a = 0 ---> initial : a = 1
​
    public static void main(String[] args) 
        System.out.println(a);
    

第三步:解析(Resolve)

  1. 将常量池内的符号引用转换为直接引用的过程

  2. 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行

  3. 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄

  4. 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等。

符号引用

  • 反编译 class 文件后可以查看符号引用,下面带# 的就是符号引用

1.3 初始化阶段

类在什么时候初始化呢?一般来说主要在下面几种情况下初始化时机:

  1. 创建类的实例

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

  3. 调用类的静态方法

  4. 反射(比如:Class.forName(“com.lqc.Test”))

  5. 初始化一个类的子类

  6. Java虚拟机启动时被标明为启动类的类

  7. JDK7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化,即不会执行初始化阶段(不会调用 clinit() 方法和 init() 方法)。

初始化阶段就是执行类构造器方法<clinit>()的过程。<clinit>()方法中的指令按语句在源文件中出现的顺序执行的。此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。也就是说,当我们代码中包含static变量的时候,就会有clinit方法。

IDEA 中安装 JClassLib Bytecode viewer 插件,可以很方便的看字节码,下面我们通过多个例子来解释。

问题1:有无static变量的区别

查看下面这个代码的字节码,可以发现有一个<clinit>()方法。

public class ClassInitTest 
    private static int num = 2;
    public static void main(String[] args) 
        System.out.println("init  ...");
    

当我们代码中包含static变量的时候,就会有一个clinit方法。如果在idea中集成了JClassLib,我们可以清楚地看到加或者不加static变量的区别。

如果没有 static 变量,此时可以看到只有一个init方法,添加了static变量之后,会发现此时就多了一个cinit()方法:

 虽然两者看起来都是初始化代码,但是执行的功能并不一样,前者是指令是invokespecial用来加载基类java/lang/Object的,而后者指令是putstatic,是处理变量的。

如果是静态代码块会怎么样呢?看个例子:

 从上面可以清楚的看到,先将变量num赋值为1,然后再赋值为3。

问题2:锁的问题

虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁:

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();
    

​
class DeadThread
    static
        if(true)
            System.out.println(Thread.currentThread().getName() + "初始化当前类");
            while(true)
​
            
        
    

输出结果:

线程2开始
线程1开始
线程2初始化当前类
​
/然后程序卡死了

程序卡死的原因是:

  • 两个线程同时去加载 DeadThread 类,而 DeadThread 类中静态代码块中有一处死循环。

  • 先加载 DeadThread 类的线程抢到了同步锁,然后在类的静态代码块中执行死循环,而另一个线程在等待同步锁的释放。

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

2.类加载器的分类

JVM严格来讲支持两种类型的类加载器 。分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个,如下所示

 上面的四类之间的关系是包含关系,不是上下层关系,也不是简单的父子类继承关系。这个怎么理解呢?BootstrapClassLoader是hotspot里用C++实现的,而扩展加载器、系统加载器和用户自定义加载器都是使用Java语言来基于BootstrapClassLoader扩展了加载器的功能。例如,如下图所示,URLClassLoader就是间接继承了ClassLoader。

 虽然被其他加载器继承,但是因为是用C++实现的,所以我们在应用程序里是获取不到BootstrapClassLoader的,例如:

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@610455d6
​
   //获取其上层:获取不到引导类加载器
   ClassLoader bootstrapClassLoader = extClassLoader.getParent();
   System.out.println(bootstrapClassLoader);//null

此时我们可以 获取系统类加载器地址,也可以获取其上层扩展类加载器地址,但是无法获得引导类地址。我们一般的业务类都是由系统加载器来加载的,例如:

//对于用户自定义类来说:默认使用系统类加载器进行加载
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

那jdk提供的核心类库呢?我们可以测试一下:

ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1);//null

此时获取不到,这是因为引导类加载器由 C/C++实现的。这说明String类使用引导类加载器进行加载的,由此也明确了 Java的核心类库都是使用引导类加载器进行加载的。

另外我们注意到系统加载器的地址都是sun.misc.Launcher$AppClassLoader@18b4aac2,这也说明JVM此时只有一个系统类加载器,是全局唯一的。

2.1 类加载器分类

第一种:启动类加载器

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

  1. 这个类加载使用C/C++语言实现的,嵌套在JVM内部

  2. 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类

  3. 万物之祖,不继承其他类,没有父加载器

  4. 加载扩展类和应用程序类加载器,并作为他们的父类加载器

  5. 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类

第二种:扩展类加载器

扩展类加载器(Extension ClassLoader)

  1. Java语言编写,由sun.misc.Launcher$ExtClassLoader实现

  2. 派生于ClassLoader类

  3. 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载

第三种:系统类加载器

应用程序类加载器(也称为系统类加载器,AppClassLoader)

  1. Java语言编写,由sun.misc.LaunchersAppClassLoader实现

  2. 派生于ClassLoader类,父类加载器为扩展类加载器

  3. 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库

  4. 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载

  5. 通过classLoader.getSystemclassLoader()方法可以获取到该类加载器

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());
        
        //从上面的路径中随意选择一个类,来看看他的类加载器是什么:引导类加载器
        ClassLoader classLoader = Provider.class.getClassLoader();
        System.out.println(classLoader);
​
        System.out.println("***********扩展类加载器*************");
        String extDirs = System.getProperty("java.ext.dirs");
        for (String path : extDirs.split(";")) 
            System.out.println(path);
        
​
        //从上面的路径中随意选择一个类,来看看他的类加载器是什么:扩展类加载器
        ClassLoader classLoader1 = CurveDB.class.getClassLoader();
        System.out.println(classLoader1);//sun.misc.Launcher$ExtClassLoader@4dc63996
    

输出结果为:

**********启动类加载器**************
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/resources.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/rt.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/sunrsasign.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/jsse.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/jce.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/charsets.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/jfr.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/classes
null
***********扩展类加载器*************
/Users/liuqingchao/Library/Java/Extensions:/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/ext:/Library/Java/Extensions:/Network/Library/Java/Extensions:/System/Library/Java/Extensions:/usr/lib/java
sun.misc.Launcher$ExtClassLoader@4dc63996

2.2 用户自定义类加载器

在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。那为什么还需要自定义类加载器呢?

  1. 隔离加载类(比如说我假设现在Spring框架,和RocketMQ有包名路径完全一样的类,类名也一样,这个时候类就冲突了。不过一般的主流框架和中间件都会自定义类加载器,实现不同的框架,中间价之间是隔离的)

  2. 修改类加载的方式

  3. 扩展加载源(还可以考虑从数据库中加载类,路由器等等不同的地方)

  4. 防止源码泄漏(对字节码文件进行解密,自己用的时候通过自定义类加载器来对其进行解密)

3.双亲委派机制

3.1 双亲委派机制原理

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。

  1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;

  2. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;

  3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

  4. 父类加载器一层一层往下分配任务,如果子类加载器能加载,则加载此类,如果将加载任务分配至系统类加载器也无法加载此类,则抛出异常。

以上是关于透彻理解JVM类加载子系统的主要内容,如果未能解决你的问题,请参考以下文章

一文理解JVM的类加载系统

JVM类加载续

深入拆解类加载器,这样的姿势你还不懂吗?

深入理解JVM之类加载

深入理解JVM虚拟机6:深入理解JVM类加载机制

深入理解JVM-类加载初始化阶段-类的主动与被动引用