文章目录
一、概述
1.1 含义
jvm将class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型。这个过程就是jvm的类加载机制。
二、类加载时机
生命周期
从被加载到虚拟机内存开始到卸载出内存,共经历如下7个阶段:
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 卸载(Unloading)
上图的加载、验证、准备、初始化和卸载的顺序固定,但解析可能在初始化前也可能在初始化后进行。
2.1 加载
加载阶段由虚拟机的具体实现自由把握
2.2 初始化时机
加载、验证、准备自然在初始化之前已经完成。
对一个类型的引用分为:主动引用和被动引用
以下8个情况是对一个类型的主动引用,一定会触发初始化(也只有该8种情况会触发初始化),而被动引用不会触发初始化。
2.2.1 主动引用
- 使用new实例化对象
- 操作一个类型的静态字段时(被final修饰、已在编译期把结果放入常量池的静态字段除外)。*
- 调用一个类的静态方法时 *
- 对类型进行反射调用时 *
- 初始化类时,如果父类未初始化,则先触发父类的初始化 *
- 虚拟机启动时,用户需指定一个要执行的主类(包含main),会先初始化该类 *
- 接口中定义了default方法(JDK8中加入的默认方法),如果该接口的实现类进行了初始化,则该接口一定在该类前进行初始化。 *
- 使用jdk7新加入的动态语言支持时,如果一个
java.lang.invoke.MethodHadnle
实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发器初始化。
2.2.2 被动引用
下面的被动引用情景则不会进行初始化:
-
通过子类引用父类的静态字段,子类不会初始化
public class _01PassiveReference { public static void main(String[] args) { //1.直接引用父类的静态字段,只会初始化父类 System.out.println(Son.value); } } class Parent{ static { System.out.println("parent!"); } public static int value=3; } class Son extends Parent{ static { System.out.println("son"); } }
结果:
parent! 3
只初始化了父类
-
通过数组定义引用该类,该类不会进行初始化
public static void main(String[] args) { Parent[]parents=new Parent[1]; }
-
调用一个类的常量不会对该类进行初始化
常量在编译阶段会存入调用类的常量池,未直接引用到该常量所在类
public class _01PassiveReference { public static void main(String[] args) { System.out.println(ConstantClass.VALUE); } } class ConstantClass{ static { System.out.println("constant"); } public static final int VALUE=1; }
2.2.3 接口的初始化
-
初始化一个类时,不会初始化其实现的接口
public class _02InterfaceInit { public static void main(String[] args) { System.out.println( TestClass.value); } } interface Test{ Thread thred=new Thread(){{ System.out.println("test1"); }}; } class TestClass implements Test{ public static int value=1; static { System.out.println("testClass"); } }
结果:
testClass 1
未初始化接口
-
初始化一个接口时,不需要初始化其父接口
public class _02InterfaceInit { public static void main(String[] args) { System.out.println(Test2.thred); } } interface Test{ Thread thred=new Thread(){{ System.out.println("test1"); }}; } interface Test2 extends Test{ Thread thred=new Thread(){{ System.out.println("test2"); }}; }
结果:
test2 Thread[Thread-0,5,main]
-
只有真正引用到父接口(如父接口中定义的常量)时,才会触发父接口的初始化
public class _02InterfaceInit { public static void main(String[] args) { System.out.println(Test2.thred); } } interface Test{ Thread thred1=new Thread(){{ System.out.println("test1"); }}; } interface Test2 extends Test{ Thread thred=new Thread(){{ System.out.println("test2"); System.out.println(thred1); }}; }
结果:
test2 test1 Thread[Thread-1,5,main] Thread[Thread-0,5,main]
三、类加载全过程
3.1 加载(Loading)
过程:
- 通过类的全限定名获取该类的二进制字节流
- 将该字节流代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中实例化一个代表该类的
java.lang.Class
对象,作为程序访问方法区中的类型数据的外部接口
加载阶段与连接阶段的部分动作交叉进行,但这些部分动作仍属于连接将诶段,两个阶段的开始时间仍保持固定的先后顺序。
3.2 验证(Verification)
目的:
确保class文件的字节流中包含的信息符合《java虚拟机规范》的全部约束,且当做代码运行后不会危害虚拟机自身的安全。
主要包括以下四个阶段:
3.2.1 文件格式验证
基于二进制字节流进行:
-
是否以魔数0xCAFEBABE开头
-
主次版本号是否在当前java虚拟机接受范围内
-
常量池中是否有不被支持的常量类型(检查常量的tag标志,标志目前为1到20,对应不同的常量类型)
-
指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量
。。。。。。。。。。。。。。。
通过该阶段验证后,字节流才被允许进入java虚拟机内存的方法区进行存储。
后面三个验证阶段都是基于方法区的存储结构进行,不会再直接读取操作字节流。
3.2.2 元数据验证
对字节码描述的信息进行语义分析,对类的元数据信息进行语义校验:
-
该类是否有父类(除了java.lang.Object之外所有的类都应有父类)
-
该类是否继承了不允许被继承的类(被final修饰的类)
-
若该类不是抽象类,是否实现其父类或接口中要求实现的所有方法
-
类中字段、方法是否与父类产生矛盾(如覆盖了父类的final字段,或出现了不符合规则的重载等)
。。。。。。。。。。。。。
3.2.3 字节码验证
通过数据流和控制流分析,保证程序语义是合法的符合逻辑的。
该阶段实际是对方法体(Class文件中的Code属性)进行校验,保证该类方法在运行时不会危害虚拟机安全:
- 保证任意时刻操作数栈的数据类型和指令代码序列都能配合工作。
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
- 保证方法体中的类型转换总是有效的。如子类对象赋值给父类对象时安全合法的,反之则不安全不合法。
版本号大于50的Class文件,使用类型检查的方式来完成该阶段的校验。
所谓类型检查,就是将尽可能多的校验辅助措施移到javac编译器中进行。
具体实现就是为方法体Code属性的属性表添加了一项新属性:StackMapTable
,描述了方法体所有的基本块开始时本地变量表和操作栈应有的状态。
因此字节码验证时,只需检查StackMapTable属性中的记录是否合法,不需根据程序推导其合法性。节省了大量的校验时间。
3.2.4 符号引用验证
该阶段发生在解析阶段中-------将符号引用转换为直接引用
可看做是对类自身以外的各类信息进行匹配性校验。包括以下内容:
- 符号引用中通过字符串描述的全限定类名是否能找到对应的类
- 指定类中是否存在符合方法的字段描述及简单名称锁描述的方法和字段
- 符号引用中的类、字段和方法的可访问性是否可被当前类访问。
确保解析行为能正常执行。如未通过符号引用验证,虚拟机会抛出如下相关异常:
java.lang.IllegalAccessError
、java.lang.NoSuchFieldError
、java.lang.NoSuchMethodError
等。
该阶段只有通过和不通过的差别,只要通过就对运行几乎没有影响,因此不是必须要执行的。
当程序的全部代码(自己编写的、第三方包中的等)已被反复使用和验证过,在生产环境就可使用
-Xverify:none参数关闭大部分的类验证,以缩短类加载时间。
3.3 准备(Preparation)
正式为类中定义的变量(静态变量等)分配内存并设置类变量初始值。
注意:
- jdk8以后,不再使用永久代实现方法区,类变量会随着Class对象一起放在堆中。
- 这时候的内存分配仅包括类变量,不包括实例变量(实例变量会在对象实例化时随着对象一起分配到java堆)
- 类变量初始值非用户给其赋的值,而是各个数据类型的零值。如int类型为0,long类型为0L等。
- 但如果是常量(如:
public static final.....
)会在该阶段将其初始化为用户指定的值。
3.4 解析
将常量池中的符号引用替换为直接引用的过程。
-
符号引用
以一组符号描述引用的目标,可以使任何形式的字面量,只要使用时能无歧义地定位到目标即可。与虚拟机实现的内存布局无关。
-
直接引用
可直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄,与虚拟机实现的内存布局直接相关。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。
3.4.1 类或接口的解析
假设当前类为D,如要把一个未解析过的符号引用为N解析为一个类或接口C的直接引用,那么会进行以下三个解析过程:
- 如果C不是一个数组类型,则将代表N的全限定类名传递给D的类加载器这个类C。一旦加载过程出现任何异常,则解析过程宣告失败
- 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似
[Ljava/lang/Integer
形式,则会按照第一点的规则加载数组元素类型。即假设的 java.lang.Integer,接着虚拟机生成一个代表该数组维度和元素的数组对象 - 如果上面没出息异常,C在虚拟机中实际上已成为一个有效的类或接口了,但解析完毕前,还需要进行符号引用验证,确认D是否具备对C的访问权限。
3.4.2 字段解析
查找失败抛出NoSuchFieldError异常。
访问权限验证失败抛出IllegalAccessError
3.4.3 方法解析
3.4.4 接口方法解析
3.5 初始化
虚拟机将主动权交应用程序。
初始化阶段就是执行类构造器 <clinit>()方法的过程,<clinit>()方法是javac编译期的自动生成物。
-
<clinit> 方法由编译期自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。收集的顺序即语句出现的顺序。
静态语句块只能访问定义在其之前的变量。定义在其之后的变量,在前面的静态语句块可以赋值,但不能访问,如下:
编译期会给出非法向前引用的提示。
-
<clinit>方法与类的构造函数(虚拟机视角的实例构造器<init>()方法)不同,它不需要显式调用父类构造器,因为虚拟机会保证在子类的<clinit>执行前,父类的<clinit>方法已经执行完毕,因此第一个<clinit>方法类型一定是Object
-
由于父类的<clinit>方法先执行,即父类定义的静态语句块要优于子类的变量赋值操作:
static class Parent{ public static int A=2; static { A=1; } } static class Son extends Parent{ public static int B=A; } public static void main(String[] args) { System.out.println(Son.B);//1 }
-
如果类中无静态语句块也没有对变量的赋值操作,则编译期可以不生成<clinit>方法
-
接口中因为有变量赋值操作,因此也有<clinit>方法,但执行接口的这个方法不需要先执行父接口的这个方法,因为前面也说过只有父接口中定义的变量被使用时,父接口才会被初始化。
四、类与类加载器
ClassLoader负责加载class文件,class文件在文件开头有特定的文件标识(cafe babe),将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构并且ClassLoader只负责class文件的加载,其是否能够运行,由执行引擎Execution Engine决定
任意一个类,都由加载它的类加载器和其自身共同确立其在java虚拟机中的唯一性,每个类加载器都由一个独立的类名称空间。
即比较两个类是否“相等”,必须是在同一个类加载器的前提下才有意义。否则即使两个类源于同一个Class文件,被同一个虚拟机加载,但如果类加载器不同,也必定不相等。
public class _04ClassLoaderTest {
public static void main(String[] args) throws ClassNotFoundException {
ClassLoader classLoader=new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is=getClass().getResourceAsStream(fileName);
if (is==null){
return super.loadClass(name);
}
byte[]b=new byte[is.available()];
is.read(b);
return defineClass(name,b,0,b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
Object obj=classLoader.loadClass("com.wml.jvm.classload._04ClassLoaderTest");
System.out.println(obj.getClass());
System.out.println(obj instanceof com.wml.jvm.classload._04ClassLoaderTest);//false
}
}
如上,自定义了一个类加载器,可以加载和它一个路径下的Class文件,我们让它加载自身所在的类com.wml.jvm.classload._04ClassLoaderTest并实例化出对象,与该类本身instance of发现是false,因为一个是使用我们自定义的类加载器,一个是虚拟机应用程序类加载器加载的,所以比较结果一定是不相等的。
五、双亲委派
模型如下:
-
启动类加载器
C++实现
不可直接被java程序引用
-
扩展类加载器
由java实现,可直接在程序中使用扩展类加载器加载class文件
-
应用程序类加载器
也叫系统类加载器,负责加载用户类路径上所有的类库,可直接在代码中使用之。
如果没有自定义类加载器,则这个就是程序中的默认的类加载器
除启动类加载器外,其他的类加载器都继承自抽象类java.lang.ClassLoader
如下我们打印Object的类加载器,发现是null,因为Object由启动类加载器加载,其是由C++写的,不可直接被java程序引用。
Objecet以及String这些基础类都在rt.jar包中,rt即Runtime,提供了java运行时环境所需的基础类
public static void main(String[] args) {
Object object=new Object();
System.out.println(object.getClass().getClassLoader());//null
我们再定义一个Person类,分别打印其父类加载器和父类加载器的类加载器:
public static void main(String[] args) {
//null
System.out.println(person.getClass().getClassLoader().getParent().getParent());
//sun.misc.Launcher$ExtClassLoader@1b6d3586
System.out.println(person.getClass().getClassLoader().getParent());
//sun.misc.Launcher$AppClassLoader@18b4aac2
System.out.println(person.getClass().getClassLoader());
由双亲委派结构图可知,Person由应用程序(系统)类加载器加载,打印的是/sun.misc.Launcher$AppClassLoader@18b4aac2
,其父类加载器就是扩展类加载器(sun.misc.Launcher$ExtClassLoader@1b6d3586
),再往上就是启动类加载器(null)
好处:
比如加载位于rt.jar包中的类java.lang.Object,不管是采用哪个加载器加载这个类,最终都是委托给顶层的启动类加载,这样就保证了使用不同的类加载器最终都是同样一个Object
双亲委派工作流程
-
如果一个类收到了类加载的请求,它不会自己去加载这个类,
-
而是把这个请求委派给父类加载器完成,
-
每个层次的类加载器都是如此,
-
因此所有的加载请求最终都应该传送到最顶层的类加载器中,
-
只有当父加载器反馈自己无法完成这个加载请求(搜索范围内没有找到所需类)时,子加载器才会尝试自己去完成加载。
即:每次都将请求自下向上委派到最顶层类加载器,然后再顶向下在各个类加载器搜索所需类,找不到就向下传。
源码实现如下:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 1.检查请求的类是否已经被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 2.没被加载,且父加载器不为空,则调用父加载器的loadClass()方法
c = parent.loadClass(name, false);
} else {
//3. 父加载器为空,则默认使用BootstrapClassLoader启动类加载器作为父加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//如果父加载器加载失败,抛出ClassNotFoundException异常,说明父加载器无法完成请求
}
if (c == null) {
long t1 = System.nanoTime();
//父加载器无法完成请求,则调用自身的findClass方法进行类加载
c = findClass(name);
............
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
在jdk9后,因为加入了模块化管理,双亲委派的机制被 “ 打破 ”
JVM笔记(一)java内存区域与内存溢出以及对象的创建、布局和定位
JVM笔记(二)对象的生死与java的四大引用
JVM笔记(三)垃圾收集算法以及HotSpot的算法实现(安全点、记忆集与卡表、写屏障、三色标记等)
JVM笔记《四》七个常见的垃圾收集器
参考:《深入理解java虚拟机第三版》