类加载子系统

类加载的时机

一个类的生命周期

其中加载,验证,准备,初始化,和卸载这五个阶段顺序是确定的,解析阶段则不一定,在某些情况下可以在初始化阶段之后再进行,这是为了支持java语言的运行时绑定特性。

类加载的过程

加载

加载阶段,java虚拟机需要完成:

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

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

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

这个java.lang.Class对象,将相当于一个模具,假如你在家里面自制雪糕,必须有个模具吧,用这个模具来生产雪糕,假如你这个模具是塑料的。

1.中的字节流就相当于这个模具的第一道加工,把塑料的原料加工成塑料

2.中的就是相当于第二道加工,塑料模具基本成型,然后运输到方法区

3.就相当于这个模具上色美化等,真正的成为了一个可以使用的模具了,然后你就可以用它来制作雪糕,你制作的每个雪糕就是一个个对象,他们的形状是一样的,但是具体的内容即原料颜色等可能不一样,这也就是对象的具体内容可能不同

验证

  1. 文件格式验证,验证字节流是否符合Class文件格式规范。

就是看看你这个文件是不是符合我要加载的,就相当于一个图片还有各种格式.jpg.png,不能你把随便一个文件的后缀名改成.class就加载你吧。

  1. 元数据验证,对字节码描述的信息进行验证,保证描述的信息符合,比如一个类是否继承了被final修饰的类,如果一个类不是抽象类,是否实现了其父类或接口中要求实现的方法等。

就是看看程序中代码是否做了规定以外的事情,这个规定就好java的语言规范,就相当于一个人要遵守法律一样,就相当于查一下你有没有犯法,做了规定以外的事情。比如你不是我老婆,我和你睡在了一块???

  1. 字节码验证,验证程序语义是否为合法的,符合逻辑的。

就是详细的检查程序代码是否是安全的,会不会对虚拟机造成威胁,这个阶段最为复杂,三言两语很难说清,要检查的东西有很多

  1. 符号引用验证,验证一个类是否缺少或者被禁止访问它依赖的某些外部类、方法等。

就是我依赖的类,我能不能访问。比如买了一个苹果手机,看一下我能不能使用它,不能使用的话不就出问题了

准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值阶段。

例如一个变量定义为:

1
public static int value = 123;

那变量在准备阶段过后的初始值为0而不是123,真正把value赋值的操作是在类的初始化阶段才会被执行。

但是如果 value被声明为 final那么在准备阶段就会给value赋值为123

例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Test {
public static void main(String[] args) {
Test2 instance = Test2.getInstance();
}
}

class Test2 {
private static int a;
private static Test2 test2 = new Test2();
private static int b = 0;
private Test2() {
a++;
b++;
}

public static Test2 getInstance() {
System.out.println("a=" + a);
System.out.println("b=" + b);
return test2;
}
}

程序的输出结果为 a=1 b=0 并不是a=1 b=1

分析:

  1. 准备阶段,会将静态变量从上到下设置为默认值,a=0,test2=null,b=0
  2. 当调用一个类的静态变量或静态方法会导致该类初始化,并设置成实际的默认值
  3. 此时a还是0,因为它并没有默认值,然后test2 赋值,调用了Test2()构造方法,对a++,b++ ,此时a = 1 b = 1
  4. 然后给b赋值默认值0,这样就导致了覆盖了前面的1,导致最后b=0

如果变成private static int b = 0;private static Test2 test2 = new Test2();那最后的结果就变成了a=1 b=1了,和代码的顺序有关。

解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。这个阶段很重要,需要了解Class文件的类文件结构。后续会详细讲解。

初始化

类的初始化是类加载过程的最后一个步骤,直到初始化阶段,Java虚拟机才真正开始执行类中编写的java程序代码。

这个阶段是为静态变量赋予正确的初始值,执行静态代码块,执行类构造器<clinit>()方法的过程。

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

静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,可以赋值但是不能访问。例如:

java虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。这个说明父类中定义的静态语句块肯定优先于子类执行。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Test {
public static int a = 1;
static {
a = 2;
}

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

class Test1 extends Test {
public static int b = a;
}

输出的结果为2,这不就说明了Test父类中的静态代码块被执行了吗。

如果一个类中没有静态语句块,也没有对变量的赋值操作,编译器就不会生成<clinit>()方法。

执行接口的<clint>()方法无需先执行父类的<clinit>()方法,因为当只有父接口中定义的变量被使用时,父接口才会被初始化。接口的实现类在初始化时也不会执行接口的<clinit>()方法。

<clinit>()在多线程环境下是安全的,并且只会被执行一次,只会有一个线程去执行,其它线程阻塞,直到执行完该方法后,其它线程才能操作,否则一直阻塞,并且<clinit>()只会被多个线程执行一次,执行完后,其它线程将不会再次执行。

这就好比你的目的是制作一辆汽车(一个类的模板),你有必要去生产两个车壳子(其中的静态代码块,赋值操作)吗?程序中只需要一个类模板就行了,<clinit>()方法就相当于类模板的装饰,装饰这个类一开始有哪些属性,也就是出厂设置吧。其他你初始化赋值<init>()方法,就是你自定义的值了。你开多个线程,就相当于多个工厂,一旦其中一个工厂接下了这份活,你得赶紧通知其他的工厂不要做了,如果几个工厂都生产的话,你总不能只给一份钱吧(浪费系统资源)。

这就相当于生产一个汽车零件,你的目的只是制作一辆汽车,你完全没有必要去生产

<clinit>()<init>()方法

详情见 <clinit>()<init>()详解

类加载器ClassLoader

  • 启动类加载器(Bootstrap Class Loader)
  1. 这个类加载器使用C/C++语言实现的,嵌套在JVM内部。所以java程序中是获取不到的。
  2. 负责加载放在\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,并且必须被java虚拟机识别(按照文件名识别,.jar),名字不符合即使放在此目录下也不能加载。
  3. 不继承自java.lang.ClassLoader,没有父加载器
  4. 出于安全考虑,只能加载包名为java、javax、sun等开头的类
  • 扩展类加载器(Extension Class Loader)
  1. 这个类加载器是在sun.misc.Launcher$ExtClassLoader中以java代码的形式实现的。
  2. 负责加载\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。
  3. 派生于ClassLoader类
  4. 父类加载器为启动类加载器
  • 应用程序类加载器(Application Class Loader)

这个类加载器由sun.misc.Launcher$AppClassLoader来实现。它负责加载用户类路径上所有的类库。如果应用程序中没有自定义自己的类加载器,一般情况下这个就是程序中默认的类加载器。

自定义类加载器步骤

  1. 继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器。
  2. jdk1.2之前,继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载,之后不在建议这样做,而是建议把自定义的类加载逻辑写在findClass()方法中。
  3. 在编写自定义类加载器时,如果需求简单,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,可以使自定义类加载器编写更加简洁。

双亲委派机制

JVM对class文件采用的是按需加载的方式,当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,JVM采用的是双亲委派机制

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

双亲委派机制就是:自底向上检查类是否已经加载,自顶向下尝试是否可以加载类

类加载流程图:

使用双亲委派机制的优势?(为什么要使用双亲委派机制?优点有哪些?)

  1. 避免类的重复加载。
  2. 保护核心API被随意篡改。通过委托方式,不会去篡改核心.class,即使篡改也不会去加载,即使加载也不会是同一个.class对象了。不同的加载器加载同一个.class也不是同一个Class对象。这样保证了Class执行安全。

弊端:

双亲委派模型是单向的,永远是子加载器请求父加载器,这就造成了上层的类加载器无法访问下层类加载器所加载的类。例如常见的JDBC就是这样,JDBC在java.sql.Driver中只定义了接口,具体的实现是由具体的数据库厂商来实现的,例如你使用msql,mysql肯定实现了Driver中的接口,但是它的jar在你的目录下,系统类加载器无法识别,你要是用mysql的话就会出现问题,自然就会出现解决的办法。线程上下文类加载器,和jdk6提供的java.util.ServiceLoader类具体不过多介绍。

破坏双亲委派模型

当父加载器去委托子加载器去加载类的时候就破坏了这个双亲委派模型,双亲委派模型有三次被破坏,具体不做说明。

这两篇文章写得挺好。

双亲委派模型的破坏(JDBC例子)

双亲委派模式破坏-JDBC

jdk9下发生的变化(简)

jdk9对java进行了模块化,是一次重大的变化,这个模型也随之而变。

  1. 扩展类加载器被平台类加载器替代。
  2. 它们都不在派生自java.net.URLClassLoader,而是全部继承于jdk.internal.loader.BuiltinClassLoader。
  3. 这个双亲委派模型发生了变化。

jdk9相当于对java核心代码进行了重构,变成模块化,启动类加载器,平台类加载器,应用程序类加载器,都被明确规定了加载哪些模块的类,这样类的加载过程就变成了,在委派给父类加载器的时候首先看自己是否可以加载此类,这个变化可以说破坏了双亲委派的这个模型。