0%

java-类加载全过程

类加载全过程

一个Java文件从编码完成到最终执行,一般主要包括两个过程

  • 编译

    即把java文件,通过javac命令编译成字节码,也就是.class文件。

  • 运行

    则是把编译生成的.class文件交给Java虚拟机(JVM)执行。

类加载过程即是指JVM虚拟机把.class文件中类信息加载进内存,并进行解析生成对应的class对象的过程。

JVM不是一开始就把所有的类都加载进内存中,而是只有第一次遇到某个需要运行的类时才会加载,且只加载一次

类加载

类加载的过程主要分为三个部分:

  • 加载
  • 链接
  • 初始化

而链接又可以细分为三个小部分:

  • 验证
  • 准备
  • 解析

img

加载

加载指的是把class字节码文件从各个来源通过类加载器装载入内存中。

这里有两个重点:

  • 字节码来源。一般的加载来源包括从本地路径下编译生成的.class文件,从jar包中的.class文件,从远程网络,以及动态代理实时编译
  • 类加载器。一般包括启动类加载器扩展类加载器应用类加载器,以及用户的自定义类加载器

注:为什么会有自定义类加载器?

  • 一方面是由于java代码很容易被反编译,如果需要对自己的代码加密的话,可以对编译后的代码进行加密,然后再通过实现自己的自定义类加载器进行解密,最后再加载。
  • 另一方面也有可能从非标准的来源加载代码,比如从网络来源,那就需要自己实现一个类加载器,从指定源进行加载。

验证

主要是为了保证加载进来的字节流符合虚拟机规范,不会造成安全错误。

包括对于文件格式的验证,比如常量中是否有不被支持的常量?文件中是否有不规范的或者附加的其他信息?

对于元数据的验证,比如该类是否继承了被final修饰的类?类中的字段,方法是否与父类冲突?是否出现了不合理的重载?

对于字节码的验证,保证程序语义的合理性,比如要保证类型转换的合理性。

对于符号引用的验证,比如校验符号引用中通过全限定名是否能够找到对应的类?校验符号引用中的访问性(private,public等)是否可被当前类访问?

准备

主要是为类变量(注意,不是实例变量)分配内存,并且赋予初值

特别需要注意,初值,不是代码中具体写的初始化的值,而是Java虚拟机根据不同变量类型的默认初始值。

比如8种基本类型的初值,默认为0;引用类型的初值则为null;常量的初值即为代码中设置的值,final static tmp = 456, 那么该阶段tmp的初值就是456

解析

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

两个重点:

  • 符号引用。即一个字符串,但是这个字符串给出了一些能够唯一性识别一个方法,一个变量,一个类的相关信息。
  • 直接引用。可以理解为一个内存地址,或者一个偏移量。比如类方法,类变量的直接引用是指向方法区的指针;而实例方法,实例变量的直接引用则是从实例的头指针开始算起到这个实例变量位置的偏移量

举个例子来说,现在调用方法hello(),这个方法的地址是1234567,那么hello就是符号引用,1234567就是直接引用。

在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。

初始化

这个阶段主要是对类变量初始化,是执行类构造器的过程。类构造器()方法是由编译器自动收集类中的所有类变量的赋值动作和**静态语句块(static块)**中的语句合并产生的。

换句话说,只对static修饰的变量或语句进行初始化。

如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。

如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

  • 虚拟机会保证一个类的()方法在多线程环境中被正确加锁和同步。

Java程序初始化顺序:

1、父类的静态变量
2、父类的静态代码块
3、子类的静态变量
4、子类的静态代码块
5、父类的非静态变量
6、父类的非静态代码块
7、父类的构造方法
8、子类的非静态变量
9、子类的非静态代码块
10、子类的构造方法

类加载时机

  1. 创建类的实例,也就是new一个对象
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射(Class.forName(“com.lyj.load”))
  5. 初始化一个类的子类(会首先初始化子类的父类)
  6. JVM启动时标明的启动类,即文件名和类名相同的那个类

除此之外,下面几种情形需要特别指出:

对于一个final类型的静态变量,如果该变量的值在编译时就可以确定下来,那么这个变量相当于“宏变量”。Java编译器会在编译时直接把这个变量出现的地方替换成它的值,因此即使程序使用该静态变量,也不会导致该类的初始化。反之,如果final类型的静态Field的值不能在编译时确定下来,则必须等到运行时才可以确定该变量的值,如果通过该类来访问它的静态变量,则会导致该类被初始化。

类的引用

主动引用(一定会初始化)

当虚拟机启动,先初始化main方法所在的类
1.new一个类的对象
2.调用类的静态成员(除了final常量)和静态方法
3.使用java.lang.reflect包的方法对类进行反射调用
4.当初始化一个类的时候,如果其父类没有被初始化,则会先初始化它的父类

被动引用

1.访问一个静态域时,只有真正的声名这个域的类才会被初始化。如:当通过子类引用父类的静态变量,不会导致子类被初始化
2.通过数组引用定义类时,不会触发此类的初始化
3.引用常量不会触发此类的初始化(常量在链接阶段就存入调用类的常量池中了)

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
47
48
49
50
51
52
53
54
55
public class Demo3 {
static{
System.out.println("mian方法所在类被初始化");
}
public static void main(String[] args) throws ClassNotFoundException{
/*
类的主动引用,一定会引起类的初始化 当虚拟机启动,先初始化main方法所在的类
1.new一个类的对象
2.调用类的静态成员(除了final常量)和静态方法
3.使用java.lang.reflect包的方法对类进行反射调用
4.当初始化一个类的时候,如果其父类没有被初始化,则会先初始化它的父类
*/
//new一个类的对象
//Animal animal = new Animal();//mian方法所在类被初始化 父类被初始化
//调用类的静态成员(除了final常量)和静态方法
//System.out.println(Animal.num);//mian方法所在类被初始化 3
//System.out.println(Animal.name);//mian方法所在类被初始化 父类被初始化 狗
//Animal.print();//mian方法所在类被初始化 父类被初始化 动物在叫
//使用java.lang.reflect包的方法对类进行反射调用
//Class c1 = Class.forName("exam.reflect.Cat");// mian方法所在类被初始化 父类被初始化 子类被初始化
//当初始化一个类的时候,如果其父类没有被初始化,则会先初始化它的父类
//Cat cat = new Cat();// mian方法所在类被初始化 父类被初始化 子类被初始化
/*
类的被动引用 不会发生类的初始化
1.访问一个静态域时,只有真正的声名这个域的类才会被初始化。如:当通过子类引用父类的静态变量,不会导致子类被初始化
2.通过数组引用定义类时,不会触发此类的初始化
3.引用常量不会触发此类的初始化(常量在链接阶段就存入调用类的常量池中了)
*/
//子类引用父类的静态变量,不会子类被初始化
//System.out.println(Cat.name);mian方法所在类被初始化 父类被初始化 狗通过数组引用定义类时,不会触发此类的初始化
//Animal[] animals = new Animal[5];//mian方法所在类被初始化
//Cat[] cats = new Cat[6];//mian方法所在类被初始化
//引用常量不会触发此类的初始化
//System.out.println(Animal.NAME);//mian方法所在类被初始化 动物

}
}
class Animal{
static{
System.out.println("父类被初始化");
name = "猫";
}
static String name = "狗";
static int num = 3;
public static final String NAME = "动物";
public static void print(){
System.out.println("动物在叫");
}
} class Cat extends Animal{
static {
System.out.println("子类被初始化");
}
static String color = "黄色";
}

类加载器的原理

类缓存

标准的Java SE类加载器可以按要求查找类,一旦某个类被加载到类加载器中,它将维持加载(缓存)一段时间。不过,JVM垃圾收集器可以回收这些Class对象。

类加载器分类

img

A.从Java虚拟机的角度:

1.Bootstrap ClassLoader启动类加载器
2.其他类加载器
从JVM的角度,加载器只分为两类,即JVM自身实现的Bootstrap启动类加载器,和其他JVM以外的所有类加载器。Bootstrap翻译为根,故也叫根类加载器。

B.从开发者的角度:

1.Bootstrap ClassLoader根类加载器
2.Extension ClassLoader拓展类加载器
3.Application ClassLoader应用程序类加载器
1.根类加载器,加载位于/jre/lib目录中的或者被参数-Xbootclasspath所指定的目录下的核心Java类库。此类加载器是Java虚拟机的一部分,使用native代码(C++)编写。

2.扩展类加载器,加载位于/jre/lib/ext目录中的或者java.ext.dirs系统变量所指定的目录下的拓展类库。此加载器由sun.misc.Launcher ExtClassLoader实现。

3.系统类加载器,加载用户路径(ClassPath)上所指定的类库。此加载器由sun.misc.Launcher$ AppClassLoader实现。

引导类加载器(bootstrap class loader)
(1)它用来加载 Java 的核心库(JAVA_HOME/jre/lib/rt.jar,sun.boot.class.path路径下的内容),是用原生代码(C语言)来实现的,并不继承自 java.lang.ClassLoader。
(2)加载扩展类和应用程序类加载器。并指定他们的父类加载器。

扩展类加载器(extensions class loader)
(1)用来加载 Java 的扩展库(JAVA_HOME/jre/ext/*.jar,或java.ext.dirs路径下的内容) 。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java类。
(2)由sun.misc.Launcher$ExtClassLoader实现。

应用程序类加载器(application class loader)
(1)它根据 Java 应用的类路径(classpath,java.class.path 路径下的内容)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。
(2)由sun.misc.Launcher$AppClassLoader实现。

类加载器加载Class大致要经过如下8个步骤:

  1. 检测此Class是否载入过,即在缓冲区中是否有此Class,如果有直接进入第8步,否则进入第2步。
  2. 如果没有父类加载器,则要么Parent是根类加载器,要么本身就是根类加载器,则跳到第4步,如果父类加载器存在,则进入第3步。
  3. 请求使用父类加载器去载入目标类,如果载入成功则跳至第8步,否则接着执行第5步。
  4. 请求使用根类加载器去载入目标类,如果载入成功则跳至第8步,否则跳至第7步。
  5. 当前类加载器尝试寻找Class文件,如果找到则执行第6步,如果找不到则执行第7步。
  6. 从文件中载入Class,成功后跳至第8步。
  7. 抛出ClassNotFountException异常。
  8. 返回对应的java.lang.Class对象。

类加载器的代理模式

代理模式即是将指定类的加载交给其他的类加载器。常用双亲委托机制。

JVM的类加载机制主要有如下3种。

  • 全盘负责:所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖和引用其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
  • 双亲委派:所谓的双亲委派,则是先让父类加载器试图加载该Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载。
  • 缓存机制。缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。这就是为很么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。

1、双亲委托机制

双亲委派机制,其工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,如果都不能加载则报错——ClassNotFoundException。这就是双亲委派模式,即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己才想办法去完成。

双亲委派机制的优势:采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

值得注意是,双亲委托机制是代理模式的一种,但并不是所有的类加载器都采用双亲委托机制。在tomcat服务器类加载器也使用代理模式,所不同的是它是首先尝试去加载某个类,如果找不到再代理给父类加载器。这与一般类加载器的顺序是相反的。

自定义类加载器

(1)首先检查请求的类型是否已经被这个类装载器装载到命名空间中了,如果已经装载,直接返回;否则转入步骤2。

(2)委派类加载请求给父类加载器,如果父类加载器能够完成,则返回父类加载器加载的Class实例;否则转入步骤3。

(3)调用本类加载器的findClass(…)方法,试图获取对应的字节码,如果获取的到,则调用defineClass(…)导入类型到方法区;如果获取不到对应的字节码或者其他原因失败,返回异常给loadClass(…), loadClass(…)转抛异常,终止加载过程(注意:这里的异常种类不止一种)。

- 注意:被两个类加载器加载的同一个类,JVM认为是不相同的类。

线程上下文类加载器

通常当你需要动态加载资源的时候 , 你至少有三个 ClassLoader 可以选择 :
1.系统类加载器或叫作应用类加载器 (system classloader or application classloader)
2.当前类加载器
3.当前线程类加载器

• 当前线程类加载器是为了抛弃双亲委派加载链模式。
每个线程都有一个关联的上下文类加载器。如果你使用new Thread()方式生成新的线程,新线程将继承其父线程的上下文类加载器。如果程序对线程上下文类加载器没有任何改动的话,程序中所有的线程将都使用系统类加载器作为上下文类加载器。
• Thread.currentThread().getContextClassLoader()

tomcat服务器的类加载器

每个 Web 应用都有一个对应的类加载器实例。该类加载器也使用代理模式(不同于前面说的双亲委托机制),所不同的是它是首先尝试去加载某个类,如果找不到再代理给父类加载器。这与一般类加载器的顺序是相反的。但也是为了保证安全,这样核心库就不在查询范围之内。