嚎羸的博客

因为Hexo是静态博客,部署多有不便,建议查看我的语雀文档

0%

02、类加载子系统

类加载子系统的全貌

首先我们来看,类加载子系统的一个简图

image-20201209115041037

下面我们来看类加载子系统的全面图

image-20201209115847926

下面我们从上图来分析一下,这里的东西以后都会具体展开来讲,先来整体看一下

1、字节码文件

2、类加载子系统

这就是我们本章内容要讲解的部分,也是字节码文件首先进入的地方

进入字节码文件之后还有三部分:

1、Loading:加载

此部分是将字节码文件加载到内存中,需要使用到的是类的加载器,有这样几种典型的加载器:

1、BootStrapClassLoader:引导类加载器

2、ExtensionClassLoader:扩展类加载器

3、ApplicationClassLoader:系统类加载器

2、Linking:链接

链接又分为:验证、准备、解析三部分

3、Initialization:初始化

3、运行时数据区

运行时数据区包含:

1、PC寄存器(程序计数器):PC Registers,每一个线程都有一个

2、Stack Area:栈,也是我们平常所讲的虚拟机栈,每一个线程一份,每一个线程中的一个一个的结构称为栈帧,栈帧也分为一些内部结构

3、Native Method Stack:本地方法栈,和虚拟机栈的区别是涉及到了本地方法的调用

4、heap:堆,是最大的一块空间,也是GC中要考虑的一块空间,堆区共享

5、Method Area:方法区,存放类的信息,一些域的信息,只有HotSpot才有

4、执行引擎

和操作系统打交道

5、本地方法接口

6、本地方法库


如果要手写一个Java虚拟机,需要考虑到:

1、类加载器

2、执行引擎

这两方面必须要考虑到


类加载器和类的加载过程

类加载子系统的作用

类加载子系统的左右主要有:

1、类加载子系统负责从文件系统或者网络中加载Class文件,Class文件在文件开头有特定的文件标识:CA FE BA BE

image-20201218124116376

2、ClassLoader只负责Class文件的加载,至于它是否可以运行,则由ExecutionEngine(执行引擎)决定

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

类加载器的全貌

image-20201209164204449

类加载器只负责加载

这个意思比如,有个媒婆给你领来了一个相亲对象,具体能不能成得看你两个聊的怎么样。

在这个场景下,相亲对象就是class字节码文件,媒婆就是类加载器,你就是执行引擎

类加载子系统(类加载器)的三个阶段

加载阶段

首先我们要和class字节码的加载区分开,class字节码的加载过程分为三个阶段:加载,链接,初始化。

而我们平常说的class字节码的加载是一种宏观上的概念,我们这里说的加载是具体的过程。那么就是这样的:

class字节码加载

  • 加载:Loading
  • 链接:Linking
  • 初始化:Initialization

下面我们来看以下class文件加载的三个环节之一:加载环节

它有如下步骤:

1、通过一个类的全限定类名获取这个类的二进制字节流

加载.class文件有如下方法:

  • 从本地系统中直接加载
  • 从网络中获取,典型场景就是Web Applet
  • 从zip压缩包中获取,成为日后jar、war格式的基础
  • 运行时计算生成,典型场景是JSP应用
  • 从专有数据库中提取.class文件,比较少见
  • 从加密文件中获取,典型的防Class文件被反编译的保护措施

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

==类的信息==我们本身要==存储到方法区中==,==这个方法区是一个虚的概念,具体的落地实现在不同的JDK版本中有所不同==

在JDK7及以前,我们称之为永久带。在JDK8及之后,我们称之为元空间。我们现在就叫做方法区得了

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

使用过反射,应该就非常清楚这个Class,比如我们使用Class.forName的时候,就使用了这个Class


链接阶段

验证(Verify)

  • 主要目的是==确保Class文件时正确的,符合虚拟机的要求的,不会损害虚拟机的自身安全==(比如校验是否符合虚拟机的Class开头:CA FE BA BE)
  • 主要包含四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证

假如我们想要查看字节码文件,那么我们需要两个工具:

  • JClassLib:这个工具主要是查看Class文件类似反编译之后的文件
  • PXBinaryView:这个工具主要查看Class文件的二进制码

两个文件在尚硅谷提供的资料里面都有

准备(Prepare)

  • 为==类变量==分配内存并且设置该类变量的默认初始值,即零值

举个例子,我们现在有一个代码:static int a = 1

那么在这个过程中,a不会成为1,而是成为零值(默认值),int的默认值就是0,那么a就是0

  • ==这里不包含使用final修饰的static,因为static在编译的时候就会分配了==,准备阶段会显示初始化
  • ==这里不会为实例变量分配初始化==,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中

解析(Resolve)

  • 将常量池内的符号引用转换为直接引用的过程
  • 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行
  • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或者一个间接定位到目标的句柄
  • 解析动作主要针对类或者接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等

初始化阶段

  • 初始化阶段就是执行类构造器方法:<clinit>()的过程

这个**类构造器方法<clinit>()**不是我们所说的类的构造器

这个构造器方法是虚拟机视角下的**<init>()**

这个方法不需要我们去定义,==它是javac编译器自动收集类中的所有变量的赋值动作和静态代码块中的语句合并而来==

举个例子,假如我们现在有这么一段代码:

1
2
3
4
5
6
7
8
9
10
11
public class ClassInitTest{
//类的静态变量
private static int num = 1;
//类的静态代码块
static{
num = 2;a
}
public static void main(String[] args){
System.out.println(num);
}
}

上面这段代码中,首先有一个类的静态变量,后来在类的静态代码块中进行了重新赋值,那么这个时候按照我们之前的定义,**<clinit>()会将类的变量赋值动作和静态代码块中的语句合并,那么我们在对字节码文件进行反编译之后,应该会在<clinit>()**方法中看到它被定义和重新赋值的过程

image-20201224182158478

  • 构造器方法中指令按语句在源文件中出现的顺序执行

  • 假如该类具有父类,那么JVM会保证子类的**<clinit>()执行之前,父类的<clint>()**已经执行完毕

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

  • 假如没有静态的内容,那么不会生成**<clinit>()**这个方法

    虚拟机在加载类的时候只会执行一次**<clinit>()**,也就是类的加载只加载一次,其他时间都是使用这个加载之后的,所以Class模板只有一个

    我们在之前的饿汉式单例模式中,一开始就使用了 static final 类 = new 类()的方式创建了一个新的对象,这种而饿汉式单例模式其实就是利用了类只能加载一次这种天然的锁机制来保证的线程安全


我们再看**<init>(),这其实就是我们类中的构造器函数,我们以前在讲Java语言的时候,曾经说过,任何一个类都有构造器,至少有一个,那么构造器对应过来就是<init>()**


再看类的加载

现在有一个题目:

1
2
3
4
5
6
7
8
9
10
public class Demo {
static {
a = 20;
}
static int a = 10;

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

现在问:a的结果是几?答案其实是10。

那么为什么是10呢?我们其实在之前背过类的加载顺序,当时是这样说的:

首先是静态,然后是非静态。其中静态的顺序,首先是静态的变量,然后是静态的代码块。

其实现在再来看这段话,确实是没有问题的,但同时也是有问题的。

我们刚才讲过的,类加载子系统的三个阶段:

1、加载阶段

2、链接阶段

3、初始化阶段

其中在链接阶段中的准备阶段里面,static的变量会被加载进去,同时赋值一个默认值,int的默认值也就是0

在初始化阶段,执行**<clinit>()**,它会收集赋值和静态代码块中的语句,同时按照顺序执行

那么会首先覆盖为20,然后覆盖为10。

看字节码的时候,它是这样的:

image-20201224184148782

所以a这个变量走的阶段应当是:0–>20–>10

说是静态变量先加载也没有问题,因为它确实加载了,只不过等会被覆盖了而已。

这里需要注意一点:虽然static代码块可以在前面进行赋值,但是它不可以在前面使用,因为初始化还没有完成


类加载器

类加载器的分类

前面我们介绍了类加载器和类的加载过程,这里我们来介绍以下类加载器的分类

JVM支持两种类型的类加载器:

  • 引导类加载器:Bootstrap ClassLoader
  • 自定义类加载器:User-Defined ClassLoader

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机的规范却没有这么定义,而是==将所有的派生自抽象类ClassLoader的类加载器都划分为自定义类加载器==、

也就是说,只要是继承ClassLoader的类加载器都被划分为自定义加载器

或者可以说,引导类加载器是一类,其他的加载器是一类

image-20201224215746145

其中SystemClassLoader也可以叫做ApplicationClassLoader

注意,各种类加载器都没有一种继承的关系,不是父子的关系。

我们可以认为它是一种包含的关系,比如A目录下面有一个B的文件,那么A就包含B。在这里也同理

我们在这个图中可以看到,BootStrap这个引导类加载器包含Extension这个扩展类加载器,扩展类加载器又包含System这个系统类加载器

那么我们可以写一段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
package com.howling;

public class ClassInitTest {
public static void main(String[] args) {

//获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

//获取扩展类加载器
ClassLoader extensionClassLoader = systemClassLoader.getParent();
System.out.println(extensionClassLoader);//sun.misc.Launcher$ExtClassLoader@1b6d3586

//试图获取引导类加载器
ClassLoader bootstrapClassLoader = extensionClassLoader.getParent();
System.out.println(bootstrapClassLoader);//null

//获取当前用户所使用的类加载器
ClassLoader userClassLoader = ClassInitTest.class.getClassLoader();
System.out.println(userClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

//试图获取String的类加载器
ClassLoader stringClassLoader = String.class.getClassLoader();
System.out.println(stringClassLoader);//null
}
}

注意有:

1、这个BootStrap这个引导类加载器是获取不到的,因为BootStrap类加载器是C语言写的,不是Java语言写的

2、当前用户所使用的类加载器就是系统类加载器,所以对于用户的自定义的类来说,它的默认的类加载器就是系统类加载器

3、String类的类加载器也是null,这也就间接证明了String类的类加载器其实是BootStrapClassLoader,这也间接证明了系统的核心类库的加载器都是BootStrapClassLoader


虚拟机自带的类加载器

启动类加载器:引导类加载器BootStrapClassLoader

1、使用C/C++语言编写的,嵌套在JVM内部

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

也就是说,这些核心类库的加载需要引导类加载器来进行加载

我们可以使用URL[] urls = Launcher.getBootstrapClassPath().getURLs();来获取所有能够加载的路径

总共有如下内容:

image-20201227152315485

3、因为是C++实现的,所以没有父加载器

4、加载扩展类和系统类加载器,并指定它们的父类加载器

其实在上面的代码中我们获取到的系统类加载器,扩展类加载器获取到的也是类似对象的一种结构。

既然是对象就会有对应的类,那么它们对应的类也算是核心类库,所以也需要使用BootStrap来进行加载

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

扩展类加载器:Extension ClassLoader

1、==Java语言编写==

2、==派生自ClassLoader类==

3、父类加载器为启动类加载器。

注意,这里的父类加载器和继承没有关系,严格来讲是上级加载器

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

我们可以使用

Arrays.stream(System.getProperty("java.ext.dirs").split(";")).forEach(System.out::println);

来获取所有的扩展类加载器能够加载的位置

image-20201227152728394


用户自定义的类加载器

这部分先简单声明一下,具体的内容放到内存与垃圾回收篇来讲

那么这里主要来探讨几个事情:

什么情况下要自定义类加载器

1、隔离加载类

比如在某些情况下我们需要使用中间件,而某些中间件之间的类(比如类名之类的)可能是相互冲突的

那么这个时候类的冲突就需要我们人工进行仲裁,发生了冲突,那么自然就需要将类隔离开来

2、修改类加载的方式

比如除了BootStrap之外,其他的类加载器完全可以在我们需要的时候动态加载,而不是在一开始的时候就完全加载进去

3、扩展加载源

刚才我们讲类的加载时,曾经说过可以在网络中,在磁盘中,在jar包里面等等这些加载源中去加载

那么假如我们自定义了加载器,就可以实现扩展加载源,你可以在数据库中,甚至可以从电视机的机顶盒里面获取字节码

4、防止源码泄漏

Java代码假如没有Java的反编译,那么拿到你的字节码就很容易被篡改,为了防止被篡改,那么就可以实现一个类的加载器去实现加密解密的操作

自定义一个类加载器,它的主要步骤有哪些

1、继承java.lang.ClassLoader

2、在JDK1.2之前需要重写loadClass(),但是之后不建议覆盖loadClass()了,而是建议把自定义的类加载逻辑写在findClass()方法中

3、在编写自定义类加载器时,如果没有太过复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法和其获取字节码流的方式,使自定义类加载器的编写更加简洁


ClassLoader的常用方法和获取方法

ClassLoader类是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)

方法名称 描述
getParent() 返回该类加载器的超类加载器
loadClass(String name) 加载名称为name的类,返回结果为java.lang.Class类的实例
findClass(String name) 查找名称为name的类,返回结果为java.lang.Class类的实例
findLoadedClass(String name) 查找名称为name的已经被加载过的类,返回结果为java.lang.Class类的实例
defineClass(String name,byte[] b,int off,int len) 把字节数组b中的内容转换为一个Java类,返回结果为java.lang.Class类的实例
resolveClass(Class<?> c) 连接指定的一个Java类

以上的这几个方法都不是抽象方法

获取ClassLoader的方式:

1、获取当前类的ClassLoader:Class.getClassLoader()

1
2
3
ClassLoader classLoader1 = ClassInitTest.class.getClassLoader();

ClassLoader classLoader2 = Class.forName("com.howling.ClassInitTest").getClassLoader();

2、获取当前线程上下文的ClassLoader:Thread.currentThread().getContextClassLoader()

1
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();

3、获取系统的ClassLoader:ClassLoader.getSystemClassLoader()

1
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();

双亲委派机制

双亲委派机制概述

Java虚拟机对class文件采用的是==按需加载==的方式,也就是说当需要使用该对象的时候才会将它的class文件加载到内存中生成class对象。

而且加载某个类的class文件时,Java虚拟机采用的是==双亲委派机制==,即把请求交给父类处理,它是一种任务委派模式。

那么我们来看一个例子来理解双亲委派机制

我们现在有如下结构:

1
2
3
4
5
6
7
|-java
|--com
|----howling
|------Demo.java
|--java
|----lang
|------String.java

我们看重点:我们创建了一个java.lang包,然后又在这个包下新建了一个String的类,我们来看一下这个类:

1
2
3
4
5
6
7
8
package java.lang;

public class String {

static {
System.out.println("自定义的静态代码块");
}
}

根据我们之前讲的,类加载器的三个阶段中的链接阶段中的初始化阶段中,会进行static代码块中的内容进行执行

我们再看Demo中的内容

1
2
3
4
5
6
7
package com.howling;

public class Demo {
public static void main(String[] args) {
java.lang.String str = new java.lang.String();
}
}

在这段代码中,假如它执行的是我们自己自定义的String,那么它一定会输出一句:自定义的静态代码块

然而在我们实际的测试中,发现它并没有执行

我们接下来看一下字节码文件,也没有**<clinit>()**,所以根据这两个标准判断,我们就知道String并没有初始化我们自己写的,而是JDK的

以上的这个就是双亲委派机制的具体实现,接下来我们再来仔细聊一下双亲委派机制是如何实现的


双亲委派机制的原理

双亲委派机制其实十分简单:

1、假如一个类加载器收到了类加载的请求,那么它不会首先去加载,而是去把这个加载请求委托给父类的加载器,让父类去加载

2、假如它的父类加载器还存在父类的加载器,那么就再次向上提交委托,直到达到顶层的启动类加载器

在之前我们已经讲过了,BootStrapClassLoader会加载java、javax等等包下的内容,而ExtenstionClassLoader则会加载指定目录下的jar包

现在按照我们这个情况,当程序运行时,默认的类加载器是系统类加载器,系统类加载器会托管给上一级扩展类加载器,扩展类加载器会托管给引导类加载器。

然后引导类加载器一看,是java包下的,所以它就直接去加载JDK原生的String了。

所以我们的类根本就不会执行。这也是为了安全,否则随便写一个加载器都会执行,那岂不是任意的垃圾数据都会执行。


现在我们再次来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
package java.lang;

public class String {

static {
System.out.println("自定义的静态代码块");
}

public static void main(String[] args) {
System.out.println("Hello JVM");
}
}
1
2
3
错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application

我们可以看到,直接报错了,原因也十分容易理解。

刚才我们说,双亲委派机制最终执行的String类其实是JRE里面的String类,所以我们这个虽然看起来执行了,但是实际上加载进去的其实不是我们自己写的String。所以这也就说明了双亲委派机制的特点。


双亲委派机制的优势

说完了双亲委派机制,那么我们说明一下双亲委派机制的优点是什么:

1、避免类的重复加载

2、保证程序的安全,防止核心API被随意篡改(比如上面我们自定义的String类)

并且,JVM会直接阻止我们的包名起为java.lang,否则会报出安全异常:java.lang.SecurityException


沙箱安全机制

沙箱安全机制其实十分好理解。

我们刚才在讲解双亲委派机制的时候,无论是直接定义java.lang.String,还是直接定义java.lang包,都会报错。

这种安全防御的机制就叫做沙箱安全机制。

沙箱我们知道,比如我们经常使用的虚拟机就可以称为一种沙箱,在里面随便造也不会对外界的内容产生一点影响。


类的主动使用和被动使用

现在要普及一些概念:

1、在JVM中,如何判断两个class对象是否完全一致,有两个必要的条件

  • 类的完整名称必须一致,包括包名和类名
  • 加载这个类的类加载器相同(指的是==ClassLoader实例对象==)

这个实例对象是个什么意思呢

刚才我们在将获取类加载器的时候,无论是系统类加载器还是扩展类加载器都是有地址的,但是引导类加载器是null

这个时候我们可以这样去理解:

类加载器和类加载器的实例相当于类和对象之间的关系。

类加载器本身只是模板,而最终执行的时候是类加载器的实例对类进行的加载。

引导类加载器是C++编写的,所以我们不能获得它的实例地址。

但是扩展类加载器和系统类加载器的实例是由引导类加载器加载的,所以有地址。

2、对类加载器的引用

JVM必须知道一个类型是由启动类加载器加载的还是由用户类加载器加载的。

如果一个类型是由用户类加载器加载的,那么==JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区==

当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的

也就是说,我们的字节码文件本来就是放在方法区中保存的,但是方法区不仅保存了字节码文件,还保存了当前类加载器的实例

当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的,这个现在或许还不太好理解,但是在后面动态链接的时候,需要这个信息

3、类的主动使用和被动使用

Java程序对类的使用分为主动使用和被动使用,主要就是主动使用会导致类的初始化,而被动使用不会

初始化的意思就是我们的初始化阶段。我们刚才在讲一共有类的加载、类的链接、类的初始化 这三个阶段

类的初始化就是指的类的初始化阶段,进而研究就是说最终有没有调用**<clinit>()**

主动使用又分为七种情况:

  • 创建类的实例
  • 访问某个类或者接口的静态变量,或者对该静态变量进行赋值
  • 调用类的静态方法
  • 反射
  • 初始化一个类的子类
  • Java虚拟机启动时被标记为启动的类
  • JDK7开始提供的动态语言支持
  • java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化则进行初始化

其他的情况都为类的被动使用,不会对类进行初始化


以上就是类加载子系统的全部内容