粗谈Java虚拟机3_类加载机制
Posted 云时代架构
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了粗谈Java虚拟机3_类加载机制相关的知识,希望对你有一定的参考价值。
精品技术文章准时送上!
加载类目的是为了使用,换作说使用类前,必须先加载该类。这点不难理解,一开始.class文件还静静的躺在磁盘里,而程序运行要在内存当中。读完上篇分析 class文件 的文章,照猫画虎手写 class字节流 还真能做到,真的是可以为所欲为,直接使用会带来很大的安全问题。所以由磁盘到内存只是第一步,到真真可以使用,还需要进行各种验证,准备等步骤,这一整套下来就是类加载机制,明白这一点,学习起来就容易得多了。类的生命周期:
java虚拟机规范中,明确规定在开始初始化之前,必须先连接类,完成加载、验证、准备步骤,解析不要求,原文:
Prior to initialization, a class or interface must be linked, that is, verified, prepared, and optionally resolved.
规范中提到以下 6 种情况才会触发类初始化:
JVM 执行遇到 new、getstatic、putstatic、invokestatic 4条指令时,如果该类还未初始化,则需要先对其进行初始化。
使用动态语言时,如果第一次调用 java.lang.invoke.MethodHandle 解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial的方法句柄,并且整个方法句柄对应的类还未初始化,则需要先对其进行初始化。
使用 java.lang.reflect 包下的反射功能。
被动触发初始化,初始化某个类时如果父类还未初始化,父类则需要先初始化。
接口中定义一个非抽象、非静态的方法,初始化实现类时,会先初始化该接口,如下代码所示,在接口当中定义一个 default void 描述符的方法,初始化接口 K ,会先初始化接口 I
public class IntfInit {
interface I {
int i = IntfInit.out("I::i", 1);
default void method() { } // causes initialization!
}
interface J extends I {
int j = IntfInit.out("J::j", 2);
}
interface K extends J {
int k = IntfInit.out("K::k", 3);
}
public static void main(String[] args) {
System.out.println(K.k);
}
static int out(String s, int i) {
System.out.println(s + "=" + i);
return i;
}
}
>log:
I::i=1
K::k=3
3
作为 JVM 的启动类(包含 main() 方法)。
加载阶段,是整个类加载机制的第一步,也是唯一能够由程序所控的一步。Java 提供了通过字节流生成Class对象的函数,足以说明加载阶段的自由性,通过磁盘、及时生成等方式都是可以的。
protected final Class<?> defineClass(String name, byte[] b, int off, int len)throws ClassFormatError{
return defineClass(name, b, off, len, null);
}
加载阶段,会在堆内存中创建 Class 对象
在很多地方都看到过这句话,不禁产生疑问,如果 class 字节流不经过验证,确认是否安全,就创建 class 对象,是否合理?
将一个经过 javac 编译器编译后的 class 文件,随便删除一些代码,再通过自定义加载器区加载,报 ClassFormatError,获取 class 对象失败。
public class MyClassLoader extends ClassLoader{
protected Class<?> findClass(String name) {
try {
String path = "C:\\Users\\xx\\workspace\\Demo\\build\\classes\\linked\\Transform2.class";
FileInputStream in = new FileInputStream(path) ;
ByteArrayOutputStream baos = new ByteArrayOutputStream() ;
byte[] buf = new byte[1024] ;
int len = -1 ;
while((len = in.read(buf)) != -1){
baos.write(buf , 0 , len);
}
in.close();
byte[] classBytes = baos.toByteArray();
return defineClass(classBytes , 0 , classBytes.length) ;
} catch (Exception e) {
e.printStackTrace();
}
return null ;
}
public static void main(String[] args) throws ClassNotFoundException {
MyClassLoader myClassLoader = new MyClassLoader();
Class<?> loadClass = myClassLoader.loadClass("");
System.out.println(loadClass);
}
}
猜测:类加载器在将 calss 字节流加载到内存后,至少会进行 文件格式校验 ,才会创建 class 对象。为什么说 至少会经行文件格式校验,分别做了 2 次测试,第一次删除魔数、第二次删除了一个字符串的 符号引用,均都报了 ClassFormatError 异常。但是我尝试自定义一个类并且继承被 final 修饰的父类,通过自定义加载器和 类名.class 两种方法均都可以创建 class 对象,并且可以访问该类中定义的常量,静态变量访问不到,创建对象报错(说明没有验证元数据)。
测试了如下代码:
public class StringTest extends String {
public static final String type = "1";
public static String str = "abc";
}
public static void main(String[] args) {
Class<?> class1 = StringTest.class;
System.out.println("class>>>"+class1);
Field type = class1.getField("type");
String anInt = (String) type.get(class1);
System.out.println("type>>>"+anInt);
Field str = class1.getField("str");
System.out.println("str>>>"+(String) str.get(str));
Object newInstance = class1.newInstance();
System.out.println("object>>>"+newInstance);
}
>log:
class>>>class linked.StringTest
type>>>1
str>>>null
Exception in thread "main" java.lang.Error: Unresolved compilation problem:
Java虚拟机规范中没有确切的讲,加载阶段会创建 class 对象。可以这样说,类加载器会创建返回 class 对象,可能这个过程会涉及到对字节码的验证。以上观点,纯属个人看法,以后有阅读源码的能力,再来论证此观点。
验证的目的是为了校验字节码是否符合规范、安全。如果验证失败则会抛出 java.lang.VerifyError 异常。验证阶段太多枯燥,具体的可参考 java虚拟机规范:
文件格式验证
元数据验证
字节码指令验证
符号引用验证
准备阶段为类变量(静态变量)分配空间,并初始化为数据类型的默认值。同时被 final 修饰的常量,会直接在准备阶段赋为定义的值。举个例子:
//准备阶段后,serialVersionUID = 0L;
private static long serialVersionUID = 1L;
//准备阶段后,serialVersionUID = 1L;
private static final long serialVersionUID = 1L;
为此,通过断点求证了这一事实:
常量断点走不过去,可以走到静态变量(静态变量在初始化阶段赋值)。走到静态变量时,常量已经为定义的值,说明常量已经先行一步赋为定义的值。
Jvm 执行遇到 anewarray, checkcast, getfield, getstatic, instanceof, invokedynamic, invokeinterface, invokespecial, invokestatic, invokevirtual, ldc, ldc_w, multianewarray, new, putfield, and putstatic 着 16 条指令时。都需要将指令后面跟着的符号引用,替换为直接引用。如下图所示,invokespecial 指令后跟着所要执行方法的符号引用,解析阶段则是将该符号引用,替换为方法位于内存中的位置。如果该类还未加载,则会触发该类加载 ( 如果遇到初始化命令则初始化该类 )。
需要值得注意的是 HotSpot虚拟机 并不会在类加载阶段去解析这些符号引用,在使用时才去解析。如果在类加载阶段就解析常量池中的符号引用,加载某个类时,势必会去解析引用的多个类,这样做划不来。以下面代码为例,创建类的实例时,并不会触发成员变量引用类的类加载。
public class Person{
//......
private Card card;
//......
}
public static void main(String[] args) {
System.out.println(new Person());
}
打印JVM加载类的日志 -XX:+TraceClassLoading,从日志并未查找到 Card 类。
类或接口解析
假如当前所处类 D,要将未解析的符号引用 C 解析为直接引用 N,需要执行以下步骤:
加载类 D 的类加载器去加载符号引用 C,触发 C 的类加载机制。
如果类 C 是一个数组类,并且元素类型是引用类型,则按照上面第一点的方法去加载元素类,再由虚拟机创建数组对象。如果是基础类型的数组,也会在堆中开辟内存。
最后检查 D 是否有权访问 C,如果无法访问到 C,则会抛出 IllegalAccessError 异常,解析失败。
如果步骤 1 和步骤 2 成功但步骤 3 失败,C 任然有效且可用。但是 D 被禁止访问 C
为了加深大家对内存分配的印象,以下面代码为例,分析论证基础类型的数组在堆中分配。
public static void main(String[] args) throws Exception {
byte[] arrays = new byte[1024*1024*256];
arrays[0] = 0;
arrays[1] = 1;
arrays[2] = 2;
arrays[3] = 3;
System.out.println("阻塞...");
System.in.read();
}
使用 JDK 自带的 VisualVM 工具调试如下:
可以看到内存已经直接就飙到 200M 多了,接着再 dump 分析下堆内存:
数组大小和元素值,和代码中的一样。
字段解析
解析字段时,如果字段所属的类或接口的符号引用还未解析,则需要先解析该类或接口的符号引用。
如果字段所属类的符号引用解析失败,都会导致字段符号引用解析失败。解析字段时,首先尝试从其父类向上递归查找,过程如下:
如果字段直接声明在当前类,查找成功。
否则,递归从接口查找。
否则,递归从父类查找。
否则,查找失败,抛出NoSuchFieldError。
如果字段解析成功,但是字段无法访问,抛出 IllegalAccessError。
类方法解析
类方法解析,和字段解析的第一个步骤一样,都需要先解析方法所属类的符号引用,再寻找方法位于内存中的位置。以 C 类方法为例,分析执行以下步骤:
如果 C 是一个接口,则抛出 IncompatibleClassChangeError。
否则,先从 C 类和父类查找:
如果该方法本身就是 C 类的方法(非实现继承),则查找成功
否则,如果 C 具有超类,则递归从父类中查找。
否则,从 C 类的实现接口开始查找。
如果实现接口的方法名称和方法引用的描述符相同,并且不是抽象方法,则查找成功。
如果 C 类的的接口声明了和方法引用描述符相同的方法,并且该方法不是 private、static 方法,则查找成功。
否则,查找失败,抛出 NoSuchMethodError 异常
如果方法查找成功,但是无法访问,则类方法解析抛出 IllegalAccessError 异常。
接口方法解析
接口方法解析和字段解析的第一个步骤一样,都需要先解析方法所属类的符号引用。
如果 C 类不是接口,则抛出 IncompatibleClassChangeError 异常。
如果该方法本身就是 C 接口的方法,则查找成功。
否则,递归从 C 接口的父接口开始查找,包括 java.lang.Object 类。
否则,查找失败,抛出 NoSuchMethodError 异常。
如果接口方法查找成功,但是引用的方法无法访问,则接口方法解析会抛出 IllegalAccessError 异常。
初始化只干一件事:为类变量和静态代码块分配内存空间并初始化为定义的值。如果某个类存在静态变量和静态代码块,则生成 <clinit> 方法 ,如果没有则不生成。成员变量呢?凉了?其实不然,成员变量是属于对象的。在 <init> 方法中初始化。
有静态变量和静态代码块时,自动生成 clinit 方法初始化静态变量和静态代码块,创建对象时,调用 init 方法初始化成员变量。
双亲委派原则
JVM提供了两种类加载器:bootstrap类加载器(其它的系统类加载器都继承该类)和自定义的加载器。不同的类加载器,加载同一个源class,会创建不同的 class 对象。
还是以上面的自定义类加载为例,只不过将 StringTest 类改为能编译通过的代码。
public static void main(String[] args) throws Exception {
MyClassLoader myClassLoader = new MyClassLoader();
Class<?> loadClass = myClassLoader.loadClass("");
System.out.println( loadClass.newInstance() instanceof StringTest);
}
>log
false
类加载器在收到某个类的加载请求时,自己不会去加载这个类,而是将加载请求委派给父类去处理,每个层次的加载器都是如此。因此所有的类加载请求都会委派到 bootstrap类加载器 手中。只有当父加载器提出自己无法处理该请求时(超出规定的加载范围),子加载器才会尝试自己去加载。
使用双亲委派模型来组织类加载器之前的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如java.lang.Object类,它存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都会委派给最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载环境中都是同一个类。相反,如果没有使用加载器进行加载,由各个类加载器自行去加载的话,加入用户自己编写了一个java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类。Java体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。
做互联网时代适合的架构:开放、分享、协作
求在看|求转发
以上是关于粗谈Java虚拟机3_类加载机制的主要内容,如果未能解决你的问题,请参考以下文章