类加载器

java执行过程

先回顾一下要执行java程序,需要经过哪些步骤

执行java程序

  1. 编写java代码
  2. 通过javac把源代码编译成class
  3. 把class载入JVM

1、2两步是需要开发人员参与的,而第3步是JVM的行为,对开发人员透明

JVM类加载

详细看下第三点,class载入JVM过程

从内存空间视角,会分配到各个空间:

内存结构

每个内存空间详情可参考:《GC及JVM参数》

从类生命周期角度,分阶段:

类生命周期

其中类加载的过程包括了加载验证准备解析初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

1.加载

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口

在加载阶段,虚拟机需要完成以下3件事情:

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

加载.class文件的方式

  • 从本地系统中直接加载
  • 通过网络下载.class文件
  • 从zip,jar等归档文件中加载.class文件
  • 从专有数据库中提取.class文件
  • 将Java源文件动态编译为.class文件

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载

加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,
而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据

加载时机

  • 当应用程序启动的时候,所有的类不会被一次性加载,因为如果一次性加载,内存资源有限,可能会影响应用程序的正常运行。例如,A a=new A(),
    一个类真正被加载的时机是在创建对象的时候,才会去执行以上过程,加载类。当我们测试的时候,最先加载拥有main方法的主线程所在类

  • Java虚拟机有预加载功能。类加载器并不需要等到某个类被”首次主动使用”时再加载它,JVM规范规定JVM可以预测加载某一个类,如果这个类出错,但是应用程序没有调用这个类, JVM也不会报错;如果调用这个类的话,JVM才会报错,(LinkAgeError错误)

加载方式

隐式加载
  1. 创建类对象
  2. 使用类的静态域
  3. 创建子类对象
  4. 使用子类的静态域
  5. 在JVM启动时,BootStrapLoader会加载一些JVM自身运行所需的class
  6. 在JVM启动时,ExtClassLoader会加载指定目录下一些特殊的class
  7. 在JVM启动时,AppClassLoader会加载classpath路径下的class,以及main函数所在的类的class文件
显式加载
  1. ClassLoader.loadClass(className),不会进行初始化
  2. Class.forName(String name, boolean initialize,ClassLoader loader); 借助当前调用者的class的ClassLoader完成class的加载,加载class的同时根据initialize是否初始化

2.连接

2.1.验证:确保被加载的类的正确性

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:

文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。

元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了java.lang.Object之外。

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

符号引用验证:确保解析动作能正确执行。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间

2.2. 准备:为类的静态变量分配内存,并将其初始化为默认值

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  • 2.2.1、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
  • 2.2.2、这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。
    假设一个类变量的定义为:public static int value = 3;
    那么变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,而把value赋值为3的putstatic指令是在程序编译后,存放于类构造器方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行
  • 2.2.3、如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。

    假设上面的类变量value被定义为: public static final int value = 3;

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

2.3. 解析:虚拟机将常量池中的符号引用替换为直接引用(内存地址)的过程

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄

常量池
  1. 字面量:比较接近Java语言层面,如String字符串,声明final的常量等
  2. 符号引用:属于编译原理方面的概念:1、包括类和接口的全限定名 2、字段的名称和描述符3.方法的名称和描述符
常量项结构

常量项结构

这些内容,需要再去分析class文件详细结构,后续再学习了

3.初始化,为类的静态变量赋予正确的初始值

类加载的最后一个阶段,除了加载阶段我们可以通过自定义类加载器参与之外,其余完全又JVM主导。到了初始化阶段,才真正开始执行程序,也就是由java转换成的class

JVM负责对类进行初始化,主要对类变量进行初始化。

在Java中对类变量进行初始值设定有两种方式:

  1. 声明类变量是指定初始值

  2. 使用静态代码块为类变量指定初始值

JVM初始化规则

类初始化时机:只有当对类的主动使用的时候才会导致类的初始化

Java程序对类的使用方式可以分为两种:

  1. 主动使用:会执行加载、连接、初始化静态域
  2. 被动使用:只执行加载、连接,不执行类的初始化静态域
类的主动使用包括以下六种:
  • 创建类的实例,如(1)new (2)反射newInstance (3)序列化生成obj;遇到new、getstatic、putstatic、invokestatic这四条字节码指令
  • 访问某个类或接口的静态变量,或者对该静态变量赋值 (注意static 与static final的区别)
  • 调用类的静态方法
  • 反射(如Class.forName(“Test”))
  • 初始化某个类的子类,则其父类也会被初始化;接口初始化不会导致父接口的初始化(这其实也是static final的原因);对于静态字段,
    • 只有直接定义这个字段的类才会被初始化,因此,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化
  • Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
被动使用,不在主动使用的六种以内都是被动的
  • 1.如通过子类引用父类的静态字段,为子类的被动使用,不会导致子类初始化
  • 2.通过数组定义类引用类,为类的被动使用,不会触发此类的初始化
    • 2.1 原因:其实数组已经不是E类型了,E的数组jvm在运行期,会动态生成一个新的类型,新类型为:
      如果是一维数组,则为:[L+元素的类全名;二维数组,则为[[L+元素的类全名
      如果是基础类型(int/float等),则为[I(int类型)、[F(float类型)等
      
  • 3.常量在编译阶段会存入调用方法所在的类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 主动 被动使用问题测试
* Created by Jack on 2018/9/28.
*/
public class ClassInitTest3 {
public static void main(String[] args) {
String x = F.s;
}
}

class F {
//因为UUID.randomUUID().toString()这个方法,是运行期确认的,所以,这不是被动使用
static final String s = UUID.randomUUID().toString();

static {
//这儿会被输出
System.out.println("Initialize class F");
}
}

clinit 与 init

在编译生成class文件时,编译器会产生两个方法加于class文件中,一个是类的初始化方法clinit, 另一个是实例的初始化方法init

clinit:

clinit指的是类构造器,这个构造器是jvm自动合并生成的,在jvm第一次加载class文件时调用,包括静态变量初始化语句和静态块的执行

它合并static变量的赋值操作

  1. 注意是赋值操作,(仅声明,或者final static)不会触发,毕竟前面准备阶段已经默认赋过值为0了
  2. static{}语句块生成,且虚拟机保证执行前,父类的已经执行完毕,所以说父类如果定义static块的话,一定比子类先执行
  3. 如果一个类或接口中没有static变量的赋值操作和static{}语句块,那么不会被JVM生成
  4. static变量的赋值操作和static{}语句块合并的顺序是由语句在源文件中出现的顺序所决定的。
init:

在实例创建出来的时候调用,也就是构造函数,包括:

  1. new操作符
  2. 普通代码块
  3. 调用Class或java.lang.reflect.Constructor对象的newInstance()方法;
  4. 调用任何现有对象的clone()方法;
  5. 通过java.io.ObjectInputStream类的getObject()方法反序列化。
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
/**
* <clinit> 与 <init> 区别
*/
public class ClassInitTest2 {
static {
System.out.println("cinit");

i = 3;//可以赋值
//System.out.println(i);//但不能使用,语法错误
}

private static int i = 1;

{
System.out.println("init");//实例化构造器,
}

public static void main(String [] args) {
new ClassInitTest2();
new ClassInitTest2();
String str = "str";
System.out.println(str);
}
}

// 输出
cinit
init
init
str

static 与 static final 对初始化的区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* static 与 static final 对初始化的区别
*/
public class ClassInitFinalTest {
public static int age = 20;

static {
//如果age定义为static final,这儿就不会执行
System.out.println("静态初始化!");
}

public static void main(String args[]){
System.out.println(ClassInitFinalTest.age);
}
}

不会执行类初始化的几种情况

  • 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
  • 定义对象数组,不会触发该类的初始化
  • 类A引用类B的static final常量不会导致类B初始化 (看上面的ClassInitFinalTest)
  • 通过类名获取Class对象,不会触发类的初始化。如
  • System.out.println(Person.class);
  • 通过Class.forName加载指定类时,如果指定参数initialize为false时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
  • 通过ClassLoader默认的loadClass方法,也不会触发初始化动作

结束生命周期

在如下几种情况下,Java虚拟机将结束生命周期

  • 执行了System.exit()方法
  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止

类加载测试

看到一段代码,很有意思

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
/**
* 测试类加载及初始化顺序问题
* Created by jack01.zhu on 2018/9/28.
*/
public class ClassInit {
private static ClassInit singleton = new ClassInit();
public static int counter1;
public static int counter2 = 0;
private ClassInit() {
counter1++;
counter2++;
}
public static ClassInit getSingleton() {
return singleton;
}
}

/**
* 通过输出结果,推测类加载过程
* Created by jack01.zhu on 2018/9/28.
*/
public class ClassInitTestMain {

public static void main(String []args) {
ClassInit classInitTest = ClassInit.getSingleton();
System.out.println("counter1="+classInitTest.counter1);
System.out.println("counter2="+classInitTest.counter2);
}
}

这段代码输出的结果是什么?

1
2
counter1=1
counter2=0

  1. 入口肯定是ClassInitTestMain.main(),从这儿开始加载,初始化
  2. ClassInit.getSingleton(),首次使用化,所以从加载部分开始执行,执行到准备阶段所有static变量都被设置为初始值。此时

    1
    2
    3
    public static int counter1 = 0;
    public static int counter2 = 0;
    private static ClassInit singleton = null;
  3. ClassInit执行到初始化阶段,生成类构造器,类构造器会合并 static变量的赋值操作和 static语句块。合并后执行

    1
    2
    3
    4
    5
    6
    public static int counter1 ; // 由于 counter1没被赋值,所以不会被合并进去

    public void clinit() {// 伪代码:<clinit>方法体内容
    ClassInit singleton = new ClassInit();//(1)
    int counter2 = 0;// (2)
    }
  4. 初始化阶段 执行clinit内代码,执行到(1)处,此时counter1和counter2都变为1。

  5. 初始化阶段 执行clinit内代码,执行到(2)处,counter2又被设置为0。
  6. 初始化结束 ,回到Main方法的ClassInit.getSingleton();继续执行main方法,最后输出结束。

以上,就是一个类的生命周期,这篇重点就是加载部分,如上面所说,加载阶段相对别的阶段,对开发人员而言有更强的可控性;下面学习一下类加载器相关知识

类加载器

类加载器

  1. BootstrapClassLoader:加载路径: System.getProperty(“java.class.path”) 或直接通过 -Xbootclasspath 指定

    特性: 用C语言写的

    手动获取加载路径: sun.misc.Launcher.getBootstrapClassPath().getURLs()

  2. ExtClassLoader:加载路径: System.getProperty(“java.ext.dirs”) 或直接通过 -Djava.ext.dirs 指定

    特性: 继承 URLClassLoader

    手动获取加载路径:((URLClassLoader)App.class.getClassLoader().getParent()).getURLs()

  3. AppClassLoader:加载路径: System.getProperty(“sun.boot.class.path”) 或直接通过 -cp, -classpath 指定

    特性: 继承 URLClassLoader

    手动获取加载路径: ((URLClassLoader)App.class.getClassLoader()).getURLs()
    通过 ClassLoader.getSystemClassLoader() 就可以获取 AppClassLoader, 自己写的程序中写的 ClassLoader(继承 URLClassLoader), 若不指定 parent, 默认的parent就是 AppClassLoader

同一个class

在JVM中,如何确定一个类型实例:

同一个Class = 相同的 ClassName + PackageName + ClassLoader

在JVM中,类型被定义在一个叫SystemDictionary 的数据结构中,该数据结构接受类加载器和全类名作为参数,返回类型实例。

SystemDictionary 如图所示:

SystemDictionary

加载机制

  1. 全盘负责,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入
  2. 双亲委托,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
  3. 缓存机制,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效

双亲委托模型

双亲委托的工作过程:如果一个类加载器收到了一个类加载请求,它首先不会自己去加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成加载请求(它管理的范围之中没有这个类)时,子加载器才会尝试着自己去加载

  1. 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。

  2. 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。

  3. 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;

  4. 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。

1
javac –verbose查看运行类是加载了jar文件
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
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{

// 首先检查,jvm中是否已经加载了对应名称的类,findLoadedClass(String )方法实际上是findLoadedClass0方法的wrapped方法,做了检查类名的工
//作,而findLoadedClass0则是一个native方法,通过底层来查看jvm中的对象。
Class c = findLoadedClass(name);
if (c == null) {//类还未加载
try {
if (parent != null) {
//在类还未加载的情况下,我们首先应该将加载工作交由父classloader来处理。
c = parent.loadClass(name, false);
} else {
//返回一个由bootstrap class loader加载的类,如果不存在就返回null
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;
}

从上面的方法也看出我们在实现自己的加载器的时候,不要覆盖locaClass方法,而是重写findClass(),这样能保证双亲委派模型,同时也实现了自己的方法

为什么要使用双亲委托这种模型呢?

  1. 节约系统资源: 因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子ClassLoader再加载一次
  2. 保证Java核心库的类型安全: 我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中定义的类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况,因为String已经在启动时就被引导类加载器(Bootstrcp ClassLoader)加载,所以用户自定义的ClassLoader永远也无法加载一个自己写的String,除非你改变JDK中ClassLoader搜索类的默认算法。

自定义加载器

既然JVM已经提供了默认的类加载器,为什么还要定义自已的类加载器呢?

因为Java中提供的默认ClassLoader,只加载指定目录下的jar和class,如果我们想加载其它位置的类或jar时,比如:我要加载网络上的一个class文件,通过动态加载到内存之后,要调用这个类中的方法实现业务逻辑。在这样的情况下,默认的ClassLoader就不能满足我们的需求了,所以需要定义自己的ClassLoader

定义自已的类加载器分为两步:

  1. 继承java.lang.ClassLoader
  2. 重写父类的findClass方法

示例

很简单的两个类,方法中打印出各自的类加载器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class LoaderClass {

public void loader(){
System.out.println("LoaderClass:"+this.getClass().getClassLoader());
LoaderClass1 class1 = new LoaderClass1();
class1.loader();
}
}

public class LoaderClass1 {

public void loader() {
System.out.println(this.getClass().getName() + " loader:"+this.getClass().getClassLoader());

}
}

自定义加载器

  1. 重写findClass方法,从class文件加载
  2. 通过defineClass从bytes构建class
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
public class MyClassLoader extends ClassLoader {

protected Class<?> findClass(String name) throws ClassNotFoundException {

String root = "d:/";

byte[] bytes = null;
try {
//路径改到根目录下
String file = root + name.substring(name.lastIndexOf(".")+1) + ".class";
InputStream ins = new FileInputStream(file);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
bytes = baos.toByteArray();

ins.close();
} catch (Exception e) {
e.printStackTrace();
}
return defineClass(name, bytes, 0, bytes.length);
}
}

测试类

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
public class ClassLoaderTest {

public static void main(String[]args) throws Exception {
ClassLoaderTest test = new ClassLoaderTest();

System.out.println(test.getClass().getClassLoader());//输出sun.misc.Launcher$AppClassLoader

System.out.println(test.getClass().getClassLoader().getParent());//输出sun.misc.Launcher$ExtClassLoader

System.out.println(test.getClass().getClassLoader().getParent().getParent());//输出null

//=====测试重复加载,类路径中LoaderClass.class存在=================
//======虽然指定了classloader,但依然输出的是LoaderClass:sun.misc.Launcher$AppClassLoader
//==删除类路径下的LoaderClass.class,才会输出LoaderClass:com.jack.classloader.MyClassLoader
//并且loaderclass中创建的对象类加载器也是MyClassLoader
MyClassLoader classLoader = new MyClassLoader();
Class<?> loadClass = Class.forName("com.jack.classloader.LoaderClass", true, classLoader);
Method startMethod = loadClass.getMethod("loader");
startMethod.invoke(loadClass.newInstance());

//===当类加载器不一样时,两个class不相等
MyClassLoader classLoader1 = new MyClassLoader();
Class<?> loadClass1 = Class.forName("com.jack.classloader.LoaderClass", true, classLoader1);
System.out.println(loadClass.equals(loadClass1));//输出false
}
}

参考资料

class加载时机及两种显示加载的区别

JVM类加载机制—类加载的过程

<init>和<clinit>

类加载原理分析&动态加载Jar/Dex

java类的主动使用/被动使用

公众号:码农戏码
欢迎关注微信公众号『码农戏码』