粗谈Java虚拟机3_类加载机制

Posted 云时代架构

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了粗谈Java虚拟机3_类加载机制相关的知识,希望对你有一定的参考价值。

精品技术文章准时送上!

粗谈Java虚拟机3_类加载机制

1
前言

加载类目的是为了使用,换作说使用类前,必须先加载该类。这点不难理解,一开始.class文件还静静的躺在磁盘里,而程序运行要在内存当中。读完上篇分析 class文件 的文章,照猫画虎手写 class字节流 还真能做到,真的是可以为所欲为,直接使用会带来很大的安全问题。所以由磁盘到内存只是第一步,到真真可以使用,还需要进行各种验证,准备等步骤,这一整套下来就是类加载机制,明白这一点,学习起来就容易得多了。类的生命周期:


粗谈Java虚拟机3_类加载机制


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() 方法)。


2
加载

加载阶段,是整个类加载机制的第一步,也是唯一能够由程序所控的一步。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 对象,可能这个过程会涉及到对字节码的验证。以上观点,纯属个人看法,以后有阅读源码的能力,再来论证此观点。


3
验证

验证的目的是为了校验字节码是否符合规范、安全。如果验证失败则会抛出 java.lang.VerifyError 异常。验证阶段太多枯燥,具体的可参考 java虚拟机规范:


  • 文件格式验证

  • 元数据验证

  • 字节码指令验证

  • 符号引用验证


4
准备

准备阶段为类变量(静态变量)分配空间,并初始化为数据类型的默认值。同时被 final 修饰的常量,会直接在准备阶段赋为定义的值。举个例子:

//准备阶段后,serialVersionUID = 0L;private static long serialVersionUID = 1L;//准备阶段后,serialVersionUID = 1L;private static final long serialVersionUID = 1L;


为此,通过断点求证了这一事实:

粗谈Java虚拟机3_类加载机制

常量断点走不过去,可以走到静态变量(静态变量在初始化阶赋值)。走到静态变量时,常量已经为定义的值,说明常量已经先行一步赋为定义的值。


5
解析

Jvm 执行遇到 anewarray, checkcast, getfield, getstatic, instanceof, invokedynamic, invokeinterface, invokespecial, invokestatic, invokevirtual, ldc, ldc_w, multianewarray, new, putfield, and putstatic 着 16 条指令时。都需要将指令后面跟着的符号引用,替换为直接引用。如下图所示,invokespecial 指令后跟着所要执行方法的符号引用,解析阶段则是将该符号引用,替换为方法位于内存中的位置。如果该类还未加载,则会触发该类加载 ( 如果遇到初始化命令则初始化该类 )。

粗谈Java虚拟机3_类加载机制


需要值得注意的是 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 工具调试如下:

粗谈Java虚拟机3_类加载机制


可以看到内存已经直接就飙到 200M 多了,接着再 dump 分析下堆内存:

粗谈Java虚拟机3_类加载机制

数组大小和元素值,和代码中的一样。


字段解析


解析字段时,如果字段所属的类或接口的符号引用还未解析,则需要先解析该类或接口的符号引用。


如果字段所属类的符号引用解析失败,都会导致字段符号引用解析失败。解析字段时,首先尝试从其父类向上递归查找,过程如下:


  • 如果字段直接声明在当前类,查找成功。

  • 否则,递归从接口查找。

  • 否则,递归从父类查找。

  • 否则,查找失败,抛出NoSuchFieldError

  • 如果字段解析成功,但是字段无法访问,抛出 IllegalAccessError


类方法解析


类方法解析,和字段解析的第一个步骤一样,都需要先解析方法所属类的符号引用,再寻找方法位于内存中的位置。以 C 类方法为例,分析执行以下步骤:


  1. 如果 C 是一个接口,则抛出 IncompatibleClassChangeError。

  2. 否则,先从 C 类和父类查找:

    如果该方法本身就是 C 类的方法(非实现继承),则查找成功

    否则,如果 C 具有超类,则递归从父类中查找。


  3. 否则,从 C 类的实现接口开始查找。

    如果实现接口的方法名称和方法引用的描述符相同,并且不是抽象方法,则查找成功。

    如果 C 类的的接口声明了和方法引用描述符相同的方法,并且该方法不是 private、static 方法,则查找成功。

    否则,查找失败,抛出 NoSuchMethodError 异常


如果方法查找成功,但是无法访问,则类方法解析抛出 IllegalAccessError 异常。


接口方法解析


接口方法解析和字段解析的第一个步骤一样,都需要先解析方法所属类的符号引用。

  1. 如果 C 类不是接口,则抛出 IncompatibleClassChangeError 异常。

  2. 如果该方法本身就是 C 接口的方法,则查找成功。

  3. 否则,递归从 C 接口的父接口开始查找,包括 java.lang.Object 类。

  4. 否则,查找失败,抛出 NoSuchMethodError 异常。


如果接口方法查找成功,但是引用的方法无法访问,则接口方法解析会抛出 IllegalAccessError 异常。


6
初始化

初始化只干一件事:为类变量和静态代码块分配内存空间并初始化为定义的值。如果某个类存在静态变量和静态代码块,则生成 <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_类加载机制

粗谈Java虚拟机3_类加载机制









做互联网时代适合的架构:开放、分享、协作

在看|求转发

以上是关于粗谈Java虚拟机3_类加载机制的主要内容,如果未能解决你的问题,请参考以下文章

深入理解Java虚拟机 -- 虚拟机类加载机制

Java虚拟机类加载机制

Java 虚拟机程序执行:02 虚拟机的类加载机制

Java虚拟机类加载机制——案例分析

java虚拟机java虚拟机的类加载机制

Java虚拟机类加载机制