类加载子系统
类加载子系统负责从文件或者网络中加载Class文件,class文件在开头有特定的标识
ClassLoader只负责class文件的加载,是否可运行是执行引擎决定的
加载的类信息放在方法区。除了类信息之外,方法区也会放运行时常量池,可能放置字符串字面量和数字字面量(这部分常量信息是Class文件中常量池部分内存映射)
加载
- 通过一个类的全限定名获取此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区(JDK8以前是永久代,之后是元数据区)的运行时数据结构
- 在内存中生成一个
java.lang.Class
对象,作为方法区这个类的各种数据访问入口
加载的.class来自哪里
- 本地系统
- 网络,如
Web Applet
- 从zip包获取(jar,war都属于此类)
- 运行时动态计算,大多来自动态代理
- 其他文件生成,如JSP
- 从专用数据库中读取出.class文件,比较少见
- 从加密文件中获取,典型应用场景是防止Class文件被反编译
链接
1.验证
- 目的在于确保Class文件中包含的信息符合当前虚拟机要求,保证被加载类的正确性,不会危及虚拟机的安全
- 主要包括四种验证:
- 文件格式验证:比如开头是
CAFABABE
- 元数据验证
- 字节码验证
- 符号引用验证
- 文件格式验证:比如开头是
2.准备
- 为类变量分配内存并且设置该类变量的默认初始值,即零值。
- 这里不包含用
final
修饰的static
常量,因为final
在编译的时候就分配了,准备阶段会显式的初始化 - 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量会随着类的实例化分配在Java堆中
3. 解析
-
将常量池中的符号引用转换为直接引用
-
事实上,解析操作往往会伴随着JVM执行完初始化后再执行
-
符号引用就是一组符号来描述所引用的目标,直接引用就是直接指向目标的指针、相对偏移量或者一个间接定位到目标的句柄
-
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对常量池中的
CONSTANT_class_info
,CONSTANT_Fieldref_info
,CONSTANT_Methodref_info
等 -
符号引用/直接引用理解
javapublic class Main{ private static int a = 1; public static void main(String[] args){ System.out.println(a); } }
上面的代码编译成字节码,常量池部分:
#1 = Methodref #6.#24 // java/lang/Object."<init>":()V #2 = Fieldref #25.#26 // java/lang/System.out:Ljava/io/PrintStream; #3 = Fieldref #5.#27 // com/example/demo/Main.a:I #4 = Methodref #28.#29 // java/io/PrintStream.println:(I)V #5 = Class #30 // com/example/demo/Main #6 = Class #31 // java/lang/Object #7 = Utf8 a #8 = Utf8 I #9 = Utf8 <init> ... //省略其余部分
这些
#数字
就是符号引用,直接引用就是内存的真实地址或者偏移量或者句柄
初始化
- 初始化过程就是执行类的构造器方法
<clinit()>
过程 - 此方法不需要定义,是
javac
编译器自动收集类中所有变量的赋值动作和静态代码块中的语句合并而来 - 构造器方法中指令按语句在源文件中出现的顺序执行
clinit()
不同于类的构造器。关联:构造器是虚拟机视角下的init()
而非clinit()
- 若该类具有父类,JVM会保证子类的
clinit()
执行之前,父类的clinit()
已经执行完成 - 虚拟机必须保证同一个类的
clinit()
在多线程下被同步加锁
java
public class Main{
//以下这个变量在准备阶段会赋零值,也就是a=0
//在初始化阶段才会被赋值1
private static int a = 1;
public static void main(String[] args){
System.out.println(a);
}
}
以上代码块编译以后,使用bytecode-viewer
看下clinit()
方法的字节码:
java
static { // <clinit> //()V
L0 {
iconst_1
putstatic com/example/demo/Main.a:int
return
}
}
然后上面代码修改成:
java
public class Main{
private static int a = 1;
static{
a = 2;
}
public static void main(String[] args){
System.out.println(a);
}
}
重新编译后查看clinit()
方法字节码:
java
static { // <clinit> //()V
L0 {
iconst_1
putstatic com/example/demo/Main.a:int
}
L1 {
iconst_2
putstatic com/example/demo/Main.a:int
}
L2 {
return
}
}
可以看到先赋值为1,后面又赋值为2。
如果我们改变一下static代码块和声明变量a的顺序,代码如下:
java
public class Main {
static{
a = 2;
}
private static int a = 1;
public static void main(String[] args){
System.out.println(a);
}
}
最终输出的值是什么?答案是 1;
原因:
在链接中的准备阶段,会给a申请内存并赋0值,在初始化阶段指令按语句出现的顺序执行 ,static代码块和声明赋值a=1
都会被收集到clinit()
中,按照收集顺序,先执行到a=2
,再执行a=1
,所以最终输出的值是1。
如果此时:
java
public class Main {
static{
a = 2;
System.out.println(a);//编译错误 Illegal forward reference 非法前向引用错误 static里可以赋值但不能调用
}
private static int a = 1;
public static void main(String[] args){
System.out.println(a);
}
}
static域中不能调用声明在它下面的变量。
注意:没有static修饰的变量,没有static域的话,是没有clinit()
方法的
每个类必然存在一个默认构造器(当然也可以我们显式提供),编译完成后必然有一个init()
方法
上面的Main
类没有显式的构造方法,编译时会自动加一个,编译后如下:
java
0 aload_0
1 invokespecial #1 <java/lang/Object.<init> : ()V>
4 return
来看一个有继承的例子:
java
package com.example.demo;
public class Main {
public static void main(String[] args) {
System.out.println(B.b);
}
}
class A{
public static int a = 1;
static {
a = 2;
}
}
class B extends A{
public static int b = a;
}
运行Main.main
,输出什么? 答案:2
分析:执行Main.main
,Main
类被加载,执行main
函数时引用了B
类,此时加载B
类,发现B
类继承自A
类,那么先加载A
类,加载完A
类后执行链接,初始化步骤,初始化时执行A
类的clinit()
方法,此时先执行了a = 1
,后执行a = 2
,A
类初始化完成后A.a
的值是2。A
类初始化完成后B
类执行链接和初始化,B
类初始化过程中执行它的clinit()
,此时B.b
赋值为 2,然后Main.main
读到的值是2。
接下来验证下多线程情况下,clinit()
是否是只会执行1次:
java
package com.example.demo;
public class Main {
public static void main(String[] args) {
Runnable runnable = () ->{
System.out.println(Thread.currentThread().getName() + "开始执行");
Test test = new Test();
System.out.println(Thread.currentThread().getName() + "执行完成");
};
Thread thread1 = new Thread(runnable,"线程1");
Thread thread2 = new Thread(runnable,"线程2");
thread1.start();
thread2.start();
}
}
class Test{
static {
System.out.println(Thread.currentThread().getName() + ": Test被加载");
if(true){
while(true){
}
}
}
}
执行日志为:
log
线程2开始执行
线程1开始执行
线程2: Test被加载
启动了2个线程去new Test()
触发加载,只有线程2成功了,线程1阻塞在了Test test = new Test();
这一行。
类加载器概述
- Java支持两种类型的类加载器,分别为:引导类加载器(Bootstrap Classloader)和自定义类加载器(User-Defined ClassLoader)
- 从概念上来讲,自定义类加载器一般是指程序中由开发人员自定义的一类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器
- 无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个:
注意:上面的ClassLoader是类似等级关系,不是继承关系。Bootstrap ClassLoader
不是Java语言实现的。
我们来看几个典型类加载器:
java
public class Main {
public static void main(String[] args) {
//获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
//输出:sun.misc.Launcher$AppClassLoader@18b4aac2 看的出来是应用类加载器
System.out.println(systemClassLoader);
//获取SystemClassLoader的上一层(这里不是继承关系,只是上一层)类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
//输出:sun.misc.Launcher$ExtClassLoader@3b22cdd0 AppClassLoader的上一层是ExtClassLoader
System.out.println(extClassLoader);
//获取ext类加载器的上一层
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
//输出:null ExtClassLoader的上一层是null,其实是BootstrapClassLoader
System.out.println(bootstrapClassLoader);
//获取当前类的加载器
ClassLoader classLoaderOfMain = Main.class.getClassLoader();
//输出:sun.misc.Launcher$AppClassLoader@18b4aac2和系统类加载器一模一样
System.out.println(classLoaderOfMain);
//看下String类的类加载器
ClassLoader classLoaderOfString = String.class.getClassLoader();
//输出:null,是BootstrapClassLoader加载的
System.out.println(classLoaderOfString);
}
}
注意:Java的核心库都是BootstrapClassLoader加载的
类加载器分类-虚拟机自带的加载器
启动类加载器(引导类加载器)
-
启动类加载器使用
C/C++
语言实现的,嵌套在JVM内部。 -
它用来加载Java核心类库(
${JAVA_HOME}/jre/lib/rt.jar
,${JAVA_HOME}/jre/lib/resource.jar
或sun.boot.class.path
下的内容)用于提供JVM自身需要的类 -
并不继承自
java.lang.ClassLoader
,没有父类加载器 -
加载扩展类加载器和应用类加载器,并指定他们的父类加载器
-
出于安全考虑,
Bootstrap
启动类加载器只加载包名为java
,javax
,sun
等开头的类我们看下这个加载器都加载哪些类:
javaURL[] urLs = Launcher.getBootstrapClassPath().getURLs(); Arrays.stream(urLs).map(URL::toExternalForm).forEach(System.out::println); 输出: file:/Library/Java/JavaVirtualMachines/jdk1.8.0_341.jdk/Contents/Home/jre/lib/resources.jar file:/Library/Java/JavaVirtualMachines/jdk1.8.0_341.jdk/Contents/Home/jre/lib/rt.jar file:/Library/Java/JavaVirtualMachines/jdk1.8.0_341.jdk/Contents/Home/jre/lib/jsse.jar file:/Library/Java/JavaVirtualMachines/jdk1.8.0_341.jdk/Contents/Home/jre/lib/jce.jar file:/Library/Java/JavaVirtualMachines/jdk1.8.0_341.jdk/Contents/Home/jre/lib/charsets.jar file:/Library/Java/JavaVirtualMachines/jdk1.8.0_341.jdk/Contents/Home/jre/lib/jfr.jar file:/Library/Java/JavaVirtualMachines/jdk1.8.0_341.jdk/Contents/Home/jre/classes //这个正常情况下不应该有 只是我的IDEA添加了这一项 file:/Users/xxx/Library/Caches/JetBrains/IdeaIC2022.3/captureAgent/debugger-agent.jar
扩展类加载器
- Java语言编写,由
sun.misc.Launcher$ExtClassLoader
实现。 - 派生于
ClassLoader
类 - 父类加载器为启动类加载器
- 从
java.ext.dirs
系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext
子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
看下它能加载哪些类:
java
String extDirs = System.getProperty("java.ext.dirs");
//根据 ; 分割字符串
Arrays.stream(extDirs.split(";"))
//每个分割的字符串再根据 : 分割 并合并成一个流
.flatMap(extDir -> Stream.of(extDir.split(":")))
.forEach(System.out::println);
输出结果:(大多都是MacOs自己加上去的,只有${JAVA_HOME}/jre/lib/ext和/usr/lib/java是默认有的)
/Users/xxx/Library/Java/Extensions
/Library/Java/JavaVirtualMachines/jdk1.8.0_341.jdk/Contents/Home/jre/lib/ext
/Library/Java/Extensions
/Network/Library/Java/Extensions
/System/Library/Java/Extensions
/usr/lib/java
应用程序类加载器(系统类加载器 ApplicationClassLoader)
- Java语言编写,由
sun.misc.Launcher$ApplicaitonClassLoader
实现 - 派生于
ClassLoader
类 - 父类加载器为扩展类加载器
- 它负责加载环境变量
classpath
或者系统属性java.class.path
指定路径下的类库 - 该类加载器是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
- 通过
ClassLoader#getSystemClassLoader()
方法可以获取到该类加载器
类加载器分类-用户自定义的加载器
在Java的日常莹莹程序开发中,累的加载几乎是由上述3中类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。
为什么要自定义类加载器
- 隔离加载类,比如特定的中间件
- 修改类加载的方式
- 扩展加载源
- 防止源码泄露
用户自定义类加载器的实现步骤:
- 开发人员可以通过继承抽象类
java.lang.ClassLoader
的方式 - JDK1.2以后不再建议用户覆盖
loadClass()
方法,而是吧自定义类加载逻辑写在findClass()
方法中 - 在编写自定义类加载器是,如果没有太过复杂的需求,可以直接继承
URLClassLoader
类,这样可以避免自己去编写findClass()
方法及其获取字节码流的方式,使自定义类加载器编写更加高效
双亲委派机制
Java虚拟机对class文件采用的是按需加载 的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把任务交由父类处理,他是一种任务委派模式。
工作原理:
- 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将达到顶层的启动类加载器
- 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式
优势:
- 避免类重复加载
- 保护程序安全,防止核心库类被篡改
类加载器补充
-
在JVM中表示两个class对象是否为同一个类存在两个必要条件
- 类的全限定名必须一致
- 加载这个类的ClassLoader实例对象必须相同
换句话说,在JVM中,及时两个类对象(class对象)来源于同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的。
-
JVM必须知道一个类型是由启动类加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。
类的主动使用和被动使用
Java程序对类的使用方式分为主动使用和被动使用,区别就是会不会导致类的初始化
-
主动使用的七种情况
-
创建类的实例
-
访问某个类或接口的静态变量,或对该静态变量赋值
-
调用类的静态方法
-
反射(比如:Class.forName("com.xxx.Test"))
-
初始化一个类的子类
-
Java虚拟机启动时被标明为启动类
-
JDK7开始提供的动态语言支持:
java.lang.invoke.MethodHandler
实例的解析结果REF_getStatuc
、REF_putStatic
、REF_invokeStatic
句柄对应的类没有初始化,则初始化
-
-
除了以上7种情况,其他使用Java类的方式都被看做是对类的被动使用,都不会导致类的初始化。