JVM 是 Java Virtual Machine(Java虚拟机)的缩写,它是一种规范,HotSpot VM是其最主流的实现(其他实现),通常我们讨论JVM如果没有特意说明是何种实现,便指的是HotSpot VM。JVM也并非仅支持Java语言,任何可编译为字节码的编程语言能可以运行在JVM上,例如前不久谷歌在 I/O 2017宣布将作为 Android 开发 First-Class 语言的 Kotlin。理解Class文件的组成结构,其如何通过类加载器加载进JVM,类加载器又是以何种机制工作,这有助于我们更进一步的理解JVM。本文将结合作者自己的理解对Class文件与类加载机制做一粗浅的解析,不对之处,望指出,共勉。
Class 文件指的是以.class
为后缀的文件,它包含可被JVM执行的字节码,通常由JVM平台编程语言源代码文件(例如.java、.kt、.groovy
文件等)编译而来,也可通过字节码工具生成(例如ASM),当然如果你想手写字节码我也不会拦你,只要符合规范即可。
一个Class文件通常由上图中这10个部分组成,本文不再敖述每部分所代表的意思,相信你看完下面这两篇文章应该会对其有一个大致的理解。
在 Java 中默认提供了三个类加载器,分别是BootstarapClassLoader
、ExtClassLoader
、AppClassLoader
,它们各自只负载加载规定目录内的Class文件,结构关系及目录见上图。
public class Test {
public static void main(String[] args) {
System.out.println(Test.class.getClassLoader());
System.out.println(Test.class.getClassLoader().getParent());
System.out.println(Test.class.getClassLoader().getParent().getParent());
/* 输出:
sun.misc.Launcher$AppClassLoader@3da997a
sun.misc.Launcher$ExtClassLoader@4921a90
null*/
}
}
注:AppClassLoader
和 ExtClassLoader
由 Java 编写并且都是 java.lang.ClassLoader
的子类,而 BootstarapClassLoader
并非由 Java 实现而是由C++
实现,所以打印结果为null
。
简单的来说,双亲委派制就是当加载一个Class文件时会先交由上层ClassLoader来加载,如果发现已加载则直接返回,如果没有加载则去当前ClassLoader 的classes目录寻找该Class文件,找到则加载,找不到则交由下层ClassLoader来继续加载,如果直到最下层加载器都无法加载(找不到该Class文件)则抛出ClassNotFoundException
异常。下面通过解读java.lang.ClassLoader
的 loadClass(String name, boolean resolve)
源码来进一步了解该机制是如何运转的,代码如下所示。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 首先,检查该Class是否已经被加载,如果已加载直接返回。
Class c = findLoadedClass(name);
// 没有被加载
if (c == null) {
long t0 = System.nanoTime();
try {
//是否存在上层加载器,如果存在交由上层加载器加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {//如果不存在继续向上委派给BootstarapClassLoader加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
//所有上层加载器都无法加载,由当前加载器进行加载
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
注:JVM规范的这3点要求其实并不算具体,因此JVM实现与具体应用的灵活度都是相当大的
相对于类加载过程的其他阶段,一个非数组类的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器区完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass()方法)。
对于数组类而言,数组类本身不通过类加载器创建,它是由JVM直接创建的。但数组类与类加载器任然有很密切的关系,因为数组类的元素类型最终是要靠类加载器去创建。
加载阶段完成后,JVM外部的二进制字节流就按照JVM所需的格式存储在方法区之中,方法区中的数据存储格式由JVM实现自行定义,JVM规范未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot VM而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。
加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
验证
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前JVM的要求,并且不会危害JVM自身的安全。比如验证“魔数”是否为0xCAFEBABE
、Class文件编译版本号是否符合当前JVM等。
Java语言本身是相对安全的语言,使用纯粹的Java代码无法做到注入访问数组边界意外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果这样做了,编译器将拒绝编译。
但前面已经说过,Class文件并不一定要求用Java源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生Class文件。在字节码语言层面上,上述Java代码无法做到的事情都是可以实现的,至少语义上是可以表达出来的。
JVM如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是JVM对自身保护的一项重要工作。
准备
准备阶段将为静态变量申请内存,并赋予初始值(基本类型为其默认值,引用类型为null
),假设有如下代码:
public static int value = 123;
在该阶段value
的值将根据其类型int
初始化为 0
。而将 value
赋值为123
的动作在初始化阶段才会执行(调用<clinit()>
方法,执行putstatic
指令)。
解析
解析阶段是JVM将常量池内的符号引用替换为直接引用的过程。
初始化
在初始化阶段会调用类的初始化方法<clinit()>
为静态变量赋予实际的值(例如将value
赋值为123
)、执行静态代码块。在 JVM 规范中没有强制约束加载的时机,不过对于初始化JVM规范严格规定了有且只有5种情况必须立即对类进行初始化:
new、getstatic、putstatic、invokestatic
这四条字节码指令时,如果类还没有进行过初始化,则需要先触发其初始化。生成这四条指令最常见的Java代码场景是:使用new
关键字实例化对象时、读取或设置一个类的静态字段(static
)时(被static修饰又被final
修饰的,已在编译期把结果放入常量池的静态字段除外)、以及调用一个类的静态方法时。public static void main(String[] args)
方法的类),JVM会对该主类触发初始化。java.lang.invoke.MethodHandle
实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。如果你觉得上的面描述太过臃肿,下面是我总结出的一个脑图(查看源文件),供你参考。
在实际开发过程中,可能会需要创建应用自己的类加载器。典型的场景包括实现特定的Java字节代码查找方式、对字节代码进行加密/解密以及实现同名 Java 类的隔离等。创建自己的类加载器并不是一件复杂的事情,只需继承java.lang.ClassLoader
类并覆写相应的方法即可。 java.lang.ClassLoader
中提供的方法有不少,下面介绍几个创建类加载器时需要考虑的:
java.lang.Class
对象的转换,由本地方法实现,通常不会去覆写该方法。通常我们实现一个自定义ClassLoader 只需继承 java.lang.ClassLoader
并覆写protected Class<?> findClass(String name)
即可,如果你要打破双亲委派制则需要同时覆写protected Class<?> loadClass(String name, boolean resolve)
,下面将展示如何实现一个自定义ClassLoader。
首先创建一个测试类并编译为.class
文件以供稍后测试使用,本文将E:\classes目录作为类加载器的加载目录,所以需要将编译后的.class
文件复制到该目录。
public class Test {
public void say (){
System.out.println("Hello");
}
}
实现自定义ClassLoader
public class CustomClassLoader extends ClassLoader {
private final String classesDir;
public CustomClassLoader(String classesDir) {
this.classesDir = classesDir;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String fileName = name;
if (fileName.indexOf('.') != -1) {
fileName = fileName.replaceAll("\\.", "\\\\");
}
fileName = fileName + ".class";
try {
try (FileInputStream in = new FileInputStream(classesDir + fileName)) {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len = 0;
while ((len = in.read(buffer)) != -1) {
out.write(buffer,0,len);
}
byte[] data = out.toByteArray();
return defineClass(name, data, 0, data.length);
}
}
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
public static void main(String[] args) throws ReflectiveOperationException{
//1. 将Test.java 编译为Test.class 后复制到 E:\classes 下,当然也可以选择其他目录作为加载目录。
//2. 加载
ClassLoader classLoader = new CustomClassLoader("E:\\classes\\");
Class<?> clazz = classLoader.loadClass("Test");//如果你的Test在一个包内,需要加上包名,如x.y.z.Test
//3. 通过反射调用say()方法
Object instance = clazz.newInstance();
Method method = clazz.getMethod("say", null);
method.invoke(instance);//Hello
}
}