类加载子系统负责从文件或者网络中加载Class文件,class文件在开头有特定的标识
ClassLoader只负责class文件的加载,是否可运行是执行引擎决定的
加载的类信息放在方法区。除了类信息之外,方法区也会放运行时常量池,可能放置字符串字面量和数字字面量(这部分常量信息是Class文件中常量池部分内存映射)
java.lang.Class
对象,作为方法区这个类的各种数据访问入口Web Applet
CAFABABE
final
修饰的static
常量,因为final
在编译的时候就分配了,准备阶段会显式的初始化将常量池中的符号引用转换为直接引用
事实上,解析操作往往会伴随着JVM执行完初始化后再执行
符号引用就是一组符号来描述所引用的目标,直接引用就是直接指向目标的指针、相对偏移量或者一个间接定位到目标的句柄
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对常量池中的CONSTANT_class_info
,CONSTANT_Fieldref_info
,CONSTANT_Methodref_info
等
符号引用/直接引用理解
public class Main{
private static int a = 1;
public static void main(String[] args){
System.out.println(a);
}
}
上面的代码编译成字节码,常量池部分:
#1 = Methodref #6.#24 // java/lang/Object."":()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
... //省略其余部分
这些#数字
就是符号引用,直接引用就是内存的真实地址或者偏移量或者句柄
过程javac
编译器自动收集类中所有变量的赋值动作和静态代码块中的语句合并而来clinit()
不同于类的构造器。关联:构造器是虚拟机视角下的init()
而非clinit()
clinit()
执行之前,父类的clinit()
已经执行完成clinit()
在多线程下被同步加锁public class Main{
//以下这个变量在准备阶段会赋零值,也就是a=0
//在初始化阶段才会被赋值1
private static int a = 1;
public static void main(String[] args){
System.out.println(a);
}
}
以上代码块编译以后,使用bytecode-viewer
看下clinit()
方法的字节码:
static { // //()V
L0 {
iconst_1
putstatic com/example/demo/Main.a:int
return
}
}
然后上面代码修改成:
public class Main{
private static int a = 1;
static{
a = 2;
}
public static void main(String[] args){
System.out.println(a);
}
}
重新编译后查看clinit()
方法字节码:
static { // //()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的顺序,代码如下:
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。
如果此时:
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
类没有显式的构造方法,编译时会自动加一个,编译后如下:
0 aload_0
1 invokespecial #1 <java/lang/Object.<init> : ()V>
4 return
来看一个有继承的例子:
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次:
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){
}
}
}
}
执行日志为:
线程2开始执行
线程1开始执行
线程2: Test被加载
启动了2个线程去new Test()
触发加载,只有线程2成功了,线程1阻塞在了Test test = new Test();
这一行。
注意:上面的ClassLoader是类似等级关系,不是继承关系。Bootstrap ClassLoader
不是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
等开头的类
我们看下这个加载器都加载哪些类:
URL[] 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
sun.misc.Launcher$ExtClassLoader
实现。ClassLoader
类java.ext.dirs
系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext
子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。看下它能加载哪些类:
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
sun.misc.Launcher$ApplicaitonClassLoader
实现ClassLoader
类classpath
或者系统属性java.class.path
指定路径下的类库ClassLoader#getSystemClassLoader()
方法可以获取到该类加载器在Java的日常莹莹程序开发中,累的加载几乎是由上述3中类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。
为什么要自定义类加载器
用户自定义类加载器的实现步骤:
java.lang.ClassLoader
的方式loadClass()
方法,而是吧自定义类加载逻辑写在findClass()
方法中URLClassLoader
类,这样可以避免自己去编写findClass()
方法及其获取字节码流的方式,使自定义类加载器编写更加高效Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把任务交由父类处理,他是一种任务委派模式。
工作原理:
优势:
在JVM中表示两个class对象是否为同一个类存在两个必要条件
换句话说,在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类的方式都被看做是对类的被动使用,都不会导致类的初始化。