JVM类加载机制
概述
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称为虚拟机的类加载机制。
类加载器与类加载过程
类加载过程
- 类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识。
- ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。
- 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
加载阶段 Loading
加载(Loading)阶段是整个类加载(Class Loading)过程中的一个阶段。不要混淆两个不同的概念。
加载阶段需要完成的三件事:
- 通过一个类的全限定名称来获取定义此类的二进制节流。
- 将这个字节流所代表的静态存储结构转化为方法区运行时的数据结构。
- 在内存中生成一个代表这个类的
java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
链接阶段 Linking
验证 Verify
目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
1. 文件格式验证
主要验证文件格式规范
- 是否以魔数0xCAFEBABE开头。
- 主、次版本号是否在当前Java虚拟机接受范围之内。
- 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
- CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据。
- Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
- …
2. 元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,这个阶段可能包括的验证点如下:
- 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
- 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
- ……
第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在与《Java语言规范》定义相悖的元数据信息。
3. 字节码验证
第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要 对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害 虚拟机安全的行为,例如:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况。
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全
的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个 数据类型,则是危险和不合法的。 - …
4. 符号引用验证
最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号 引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部 类、方法、字段等资源。本阶段通常需要校验下列内容:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
- 符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当前类访问。
准备 Prepare
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
关于准备阶段,还有两个容易产生混淆的概念需要着重强调,首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
基本数据类型的零值:
上面提到在“通常情况”下初始值是零值,那言外之意是相对的会有某些“特殊情况”:如果类字段的字段属性表中存在ConstantValue(常量)属性,那在准备阶段变量值就会被初始化为常量属性所指定的初始值,假设上面类变量value的定义修改为:
public static final int value = 123;
也就是说给属性value加上了final关键字,value就会直接初始化为123。
解析 Resolve
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。
符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
初始化 Initialization
类的初始化时机
- 创建类的实例
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(比如:Class.forName(“com.atguigu.Test”))
- 初始化一个类的子类
- Java虚拟机启动时被标明为启动类的类
- JDK7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化
除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化,即不会执行初始化阶段(不会调用 clinit() 方法和 init() 方法)
- 初始化阶段是执行初始化方法
<clinit> () 方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。 - 初始化方法
<clinit> () 用来初始化类变量,这个初始化与之前的准备(prepare) 阶段的为变量赋初值不一样,这个初始化是当我们显示的将值赋予变量也就是手动赋予初始时调用的,如果我们没有手动为其赋予初始值就不会调用此初始化方法。
public class ClintTest {
public static int value = 1;
public static void main(String[] args) {
}
}
<clinit>() 中字节码:
0 iconst_1
1 putstatic #2 <java1/ClintTest.value>
4 return
在静态代码块中初始化也一样会调用:
public class ClintTest {
public static int value;
static {
value = 123;
}
}
3、构造器方法中指令按语句在源文件中出现的顺序执行
但是这是会出现这样的情况:
看起来像先为变量赋值然后再定义变量
public class ClintTest {
static {
value = 123;
}
public static int value;
}
这是因为JVM加载过程中会现将类变量进行初始化,然后再使用<clinit> () 为类变量赋值。
4、虚拟机必须保证一个类的<clinit>() 方法在多线程下被同步加锁
public class DeadThreadTest {
public static void main(String[] args) {
Runnable r = () -> {
System.out.println(Thread.currentThread().getName() + "开始");
DeadThread dead = new DeadThread();
System.out.println(Thread.currentThread().getName() + "结束");
};
Thread t1 = new Thread(r,"线程1");
Thread t2 = new Thread(r,"线程2");
t1.start();
t2.start();
}
}
class DeadThread{
static{
if(true){
System.out.println(Thread.currentThread().getName() + "初始化当前类");
while(true){
}
}
}
}
线程一因为陷入了死循环不会释放锁,所以程序就会卡在这里。
线程1开始
线程2开始
线程1初始化当前类
类加载器的分类
- JVM支持两种类型的类加载器,分为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)。
- 从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器
- 无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个:
虽然在上图中类加载器有 Bootstrap、Extension、System和Optional的,但是按照Java虚拟机规范Extension、System都是自定义类加载器,因为它们都继承了ClassLoader抽象类。
BootstrapClassLoader 引导类加载器
- 使用C、C++实现,嵌套在JVM内部。
- 用来加载Java核心库,用于提供JVM自身需要的类。
- 不继承
java.lang.ClassLoader ,没有父类加载器。 - 加载扩展类和应用程序类加载器,并之指定为它们的父类加载器。
- 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类。
- 在Java程序中,我们无法获取,就算获取也只能获取null
ExtensionClassLoader 扩展类加载器
- Java语言编写,由
sun.misc.Launcher$ExtClassLoader 实现。 - 派生与ClassLoader类。
- 父类加载器为启动类加载器。
- 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录) 下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
- 可以在Java程序中获取。
APPClassLoader 系统类加载器
- Java语言编写,由
sun.misc.Launcher$AppClassLoader 实现。 - 派生与ClassLoader类。
- 父类加载器为启动类加载器。
- 用户自定义的类一般都由这个加载。
用户自定义类加载器
在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。那为什么还需要自定义类加载器?
如何自定义类加载器:
- 开发人员可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求
- 在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findclass()方法中
- 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URIClassLoader类,这样就可以避免自己去编写findclass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
双亲委派机制
原理
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。
- 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行。
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器。
- 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
- 父类加载器一层一层往下分配任务,如果子类加载器能加载,则加载此类,如果将加载任务分配至系统类加载器也无法加载此类,则抛出异常。
代码示例
举例1
我们在自己的工程下创建一个java.lang.String :
package java.lang;
public class String {
static {
System.out.println("自定义的String类");
}
}
在用一个测试类进行测试:
public class Main {
public static void main(String[] args) {
String str = new String();
System.out.println("Hello, World!");
}
}
输出:
Hello, World
Hello, World!
可以看到没有输出静态代码块中的内容,这就是因为双亲委派机制,在加载类之前先去委托父类类加载器,如果父类也有父类类加载器,那么就继续向上委托。这是我们自定义的String类的全类名是java.lang.String ,最终被委托到引导类加载器加载,所以按照全类名加载了jdk原生的String类。
举例2
如果在自定义的String类中创建了一个main方法呢?或者原生String类没有的方法呢?
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
输出:
错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application
还是因为双亲委派机制,委派到引导类加载器Bootstrap ClassLoader来加载,但是加载的原生String类中没有这个方法,所以加载失败。
举例3
我们在用自定义一个java.lang包这种核心包中没有的类:
package java.lang;
public class Test {
}
-------------------------------------------
package java2;
public class Main {
public static void main(String[] args) {
Test test = new Test();
System.out.println(test);
}
}
输出:
Exception in thread "main" java.lang.SecurityException: Prohibited package name: java.lang
at java.lang.ClassLoader.preDefineClass(ClassLoader.java:655)
at java.lang.ClassLoader.defineClass(ClassLoader.java:754)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:473)
at java.net.URLClassLoader.access$100(URLClassLoader.java:74)
at java.net.URLClassLoader$1.run(URLClassLoader.java:369)
at java.net.URLClassLoader$1.run(URLClassLoader.java:363)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:362)
at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355)
at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
at java2.Main.main(Main.java:5)
这是因为有一种保护机制,即使类名没有重复,包名也不能这么命名。
沙箱安全机制
- 自定义String类时:在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java.lang.String.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的String类。
- 这样可以保证对java核心源代码的保护,这就是沙箱安全机制。
补充
在JVM中表示两个class对象是否为同一个类存在两个条件:
- 类的完整类名必须一直,包括包名。
- 加载这个类的ClassLoader(ClassLoader实例对象)必须相同。
JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的;如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。
|