Luckylau's Blog

Java虚拟机之类的加载器机制与过程

JVM类加载器机制与类加载过程

修改内容 时间
初始化 2017-6-02
丰富 2017-7-14

java是一种动态语言

​ 我们都知道JVM(java虚拟机)执行的不是本地机器码指令,而是执行一种称之为字节码的指令(存在于class文件中)。这就要求虚拟机在真正执行字节码之前,先把相关的class文件加载到内存中。虚拟机不是一次性加载所有需要的class文件,因为它在执行的时候根本不会知道以后会用到哪些class文件。它是每用到一个类,就会在运行时“动态地”加载和这个类相关的class文件。这就是java被称之为动态性语言的根本原因。除了动态加载类之外,还会动态的初始化类,对类进行动态链接。其中在JVM中负责对类进行加载的正是本文要介绍的类加载器(ClassLoader),所以,类加载器是JVM不可或缺的重要组件。

虚拟机类加载机制

类加载的生命周期

​ 虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、 转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。与那些在编译时需要进行连接工作的语言不同,在Java语言里面,类型的加载、 连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

​ 类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、 验证(Verification)、 准备(Preparation)、 解析(Resolution)、 初始化(Initialization)、 使用(Using)和卸载(Unloading)7个阶段。 其中验证、 准备、 解析3个部分统称为连接(Linking)。

类加载的过程

类加载包括加载(Loading)、 验证(Verification)、 准备(Preparation)、 解析(Resolution)。

加载

​ “加载”是“类加载”(Class Loading)过程的一个阶段。在加载阶段,虚拟机需要完成以下3件事情:

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

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

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

​ 相对于类加载过程的其他阶段,一个非数组类的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass( )方法)。

数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。 但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)最终是要靠类加载器去创建,一个数组类创建过程就遵循以下规则:

如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型)是引用类型,那就递归采用上述加载过程去加载这个组件类型,数组C将在加载该组件类型的类加载器的类名称空间上被标识(一个类必须与类加载器一起确定唯一性)。
如果数组的组件类型不是引用类型(例如int[]数组),Java虚拟机将会把数组C标记为与引导类加载器关联。
数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public。

加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证

​ 验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。从整体上看,验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、 字节码验证、 符号引用验证。

准备

​ 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。 有两点需要注意:这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中;这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:

1
public static int value=123;

那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。

上面提到,在“通常情况”下初始值是零值,那相对的会有一些“特殊情况”:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,假设上面类变量value的定义变为

1
public static final int value=123;

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

解析

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

​ 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。 符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。 各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

​ 直接引用(Direct References):直接引用可以是直接指向目标的指针、 相对偏移量或是一个能间接定位到目标的句柄。 直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。 如果有了直接引用,那引用的目标必定已经在内存中存在。

虚拟机规范之中并未规定解析阶段发生的具体时间,只要求了在执行anewarray、checkcast、 getfield、 getstatic、 instanceof、 invokedynamic、 invokeinterface、 invokespecial、invokestatic、 invokevirtual、 ldc、 ldc_w、 multianewarray、 new、 putfield和putstatic这16个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。 所以虚拟机实现可以根据需要来判断到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。

初始化的过程

​ 其中对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、 验证、 准备自然需要在此之前开始)。

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

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

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

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

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

类加载器

​ 从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。

类加载器与Class 实例的关系

引导类加载器(Bootstrap Class Loader)

​ 负责加载JVM虚拟机运行时所需的基本系统级别的类,如java.lang.String, java.lang.Object等等。引导类加载器(Bootstrap Classloader)会读取 {JRE_HOME}/lib 下的jar包和配置,然后将这些系统类加载到方法区内。启动类加载器无法被Java程序直接引用,用户在编写自定义类加
载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可。

引导类加载器(Bootstrap ClassLoader)加载系统类后,JVM内存会呈现如下格局:对于某一个特定的类而言,在方法区中它应该有运行时常量池、类型信息、字段信息、方法信息、类加载器的引用,对应class实例的引用等信息,其中类加载器的引用,由于这些类是由引导类加载器(Bootstrap Classloader)进行加载的,而 引导类加载器是有C++语言实现的,所以是无法访问的,故而该引用为NULL。对应class实例的引用, 类加载器在加载类信息放到方法区中后,会创建一个对应的Class 类型的实例放到堆(Heap)中, 作为开发人员访问方法区中类定义的入口和切入点。

扩展类加载器(Extension ClassLoader)

​ 这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

应用程序类加载器(Application ClassLoader)

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

​ 说明:除了引导类加载器(Bootstrap Class Loader )之外的其他类加载器,都有一个能力,就是判断某一个类是否被引导类加载器加载过,如果加载过,可以直接返回对应的Class instance,如果没有,则返回null。图上的指向引导类加载器的虚线表示类加载器有限的访问引导类加载器的功能。

双亲委派模型

1
2
sun.misc.Launcher launcher = sun.misc.Launcher.getLauncher(); //获取Java启动器
ClassLoader classLoader = launcher.getClassLoader(); //获取类加载器ClassLoader用来加载class到内存来

​ launcher.getClassLoader() 方法将会返回 AppClassLoader 实例,AppClassLoader将ExtClassLoader作为自己的父加载器。当AppClassLoader加载类时,会首先尝试让父加载器ExtClassLoader进行加载,如果父加载器ExtClassLoader加载成功,则AppClassLoader直接返回父加载器ExtClassLoader加载的结果;如果父加载器ExtClassLoader加载失败,AppClassLoader则会判断该类是否是引导的系统类(即是否是通过Bootstrap类加载器加载,这会调用Native方法进行查找);若要加载的类不是系统引导类,那么ClassLoader将会尝试自己加载,加载失败将会抛出“ClassNotFoundException”。

上述应用类加载器的模式就是我们说的双亲委派模型(parent-delegation model)

对于某个特定的类加载器而言,应该为其指定一个父类加载器,当用其进行加载类的时候:

  1. 委托父类加载器帮忙加载;
  2. 父类加载器加载不了,则查询引导类加载器有没有加载过该类;
  3. 如果引导类加载器没有加载过该类,则当前的类加载器应该自己加载该类;
  4. 若加载成功,返回 对应的Class 对象;若失败,抛出异常“ClassNotFoundException”。

双亲委派模型中的”双亲”并不是指它有两个父类加载器的意思,一个类加载器只应该有一个父加载器。

双亲加载模型如下:

使用双亲委派模型的好处在于Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存在在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的Bootstrap ClassLoader进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类并放在ClassPath中,那系统中将会出现多个不同的Object类,程序将混乱。因此,如果开发者尝试编写一个与rt.jar类库中重名的Java类,可以正常编译,但是永远无法被加载运行。

举例子比如我们通过window的cmd命令执行javac test.java 生成class文件后,再执行java test运行时,有如下几个过程。

第一步,根据JVM内存配置要求,为JVM申请特定大小的内存空间。

第二步,创建一个引导类加载器实例,初步加载系统类到内存方法区区域中。

第三步,创建JVM 启动器实例 Launcher,并取得类加载器ClassLoader

第四步,使用类加载器ClassLoader加载test

第五步,使用Main类的main方法作为程序入口运行程序

第六步,方法执行完毕,JVM销毁,释放内存

面试题

1.类加载器有哪些?其组织结构是怎样的?有哪些特性?

​ JVM自身定义了三个类加载器:引导类加载器(Bootstrap Class Loader)、拓展类加载器(Extension Class Loader )、应用加载器(Application Class Loader)。当然用户可以自定义类加载器。

​ 引导类加载器(Bootstrap Class Loader): 该类加载器使JVM使用C/C++底层代码实现的加载器,用以加载JVM运行时所需要的系统类,这些系统类在{JRE_HOME}/lib目录下。由于类加载器是使用平台相关的底层C/C++语言实现的, 所以该加载器不能被Java代码访问到。但是,我们可以查询某个类是否被引导类加载器加载过。我们经常使用的系统类如:java.lang.String,java.lang.Object,java.lang*……. 这些都被放在 {JRE_HOME}/lib/rt.jar包内, 当JVM系统启动的时候,引导类加载器会将其加载到 JVM内存的方法区中。

​ 拓展类加载器(Extension Class Loader): 该加载器是用于加载 java 的拓展类 ,拓展类一般会放在 {JRE_HOME}/lib/ext/ 目录下,用来提供除了系统类之外的额外功能。拓展类加载器是是整个JVM加载器的Java代码可以访问到的类加载器的最顶端,即是超级父加载器,拓展类加载器是没有父类加载器的。

​ 应用类加载器(Applocatoin Class Loader): 该类加载器是用于加载用户代码,是用户代码的入口。我经常执行指令 java xxx.x.xxx.x.x.XClass , 实际上,JVM就是使用的AppClassLoader加载 xxx.x.xxx.x.x.XClass 类的。应用类加载器将拓展类加载器当成自己的父类加载器,当其尝试加载类的时候,首先尝试让其父加载器-拓展类加载器加载;如果拓展类加载器加载成功,则直接返回加载结果Class instance,加载失败,则会询问是否引导类加载器已经加载了该类;只有没有加载的时候,应用类加载器才会尝试自己加载。由于xxx.x.xxx.x.x.XClass是整个用户代码的入口,在Java虚拟机规范中,称其为 初始类(Initial Class)。

​ 用户自定义类加载器(Customized Class Loader):用户可以自己定义类加载器来加载类。所有的类加载器都要继承java.lang.ClassLoader类。

类加载器有如下特性:

  • 委托机制是指将加载一个类的请求交给父类加载器,如果这个父类加载器不能够找到或者加载这个类,那么再加载它。
  • 可见性的原理是子类的加载器可以看见所有的父类加载器加载的类,而父类加载器看不到子类加载器加载的类。
  • 单一性原理是指仅加载一个类一次,这是由委托机制确保子类加载器不会再次加载父类加载器加载过的类。

2.双亲加载模型的逻辑和底层代码实现是怎样的?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//提供class类的二进制名称表示,加载对应class,加载成功,则返回表示该类对应的Class<T> instance 实例
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先,检查是否已经被当前的类加载器记载过了,如果已经被加载,直接返回对应的Class<T>实例
Class<?> c = findLoadedClass(name);
//初次加载
if (c == null) {
long t0 = System.nanoTime();
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.
long t1 = System.nanoTime();
// 自己尝试加载
c = findClass(name);
// this is the defining class loader; record the stats
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.什么是线程上下文加载器?(破坏双亲委派模型)

线程上下文类加载器是从线程的角度来看待类的加载,为每一个线程绑定一个类加载器,可以将类的加载从单纯的 双亲加载模型解放出来,进而实现特定的加载需求。

参考:

http://blog.csdn.net/luanlouis/article/details/50529868

http://blog.csdn.net/zhangjg_blog/article/details/16102131

Luckylau wechat
如果对您有价值,看官可以打赏的!