本文为《深入学习 JVM 系列》第三篇文章
文章更新履历:
20220323: 补充了初始化的知识点,尤其是初始化死锁的情况;增加了案例来演示解析过程
20220321: 补充了类加载器的知识和案例
在上一篇文章中我们知道 Java 语言的类型可以分为两大类:基本类型(primitive types)和引用类型(reference types)。比如 Java 的基本类型,它们是由 Java 虚拟机预先定义好的。
Java 引用类型主要分为四种:类、接口、数组类和泛型参数。由于泛型参数会在编译过程中被擦除,因此 Java 虚拟机实际上只有前三种。在类、接口和数组类中,数组类是由 Java 虚拟机直接生成的,其他两种则有对应的字节流。
说到字节流,最常见的形式要属由 Java 编译器生成的 class 文件。Java 使用编译器将源码文件编译得到 class 文件时,会严格按照 Java 虚拟机规范进行校验,关于这部分内容在《Java虚拟机规范》一书中的第四章节做了详细介绍,这里就不介绍了。
无论是直接生成的数组类,还是非数组的类或接口,都要经过一系列步骤后才能被 JVM 直接使用,其中包括如下步骤:对数据进行校验、转换解析、初始化等等,这个说来简单但实际复杂的过程叫做 JVM 的类加载机制。
Class 文件中的“类”从加载到 JVM 内存中,到卸载出内存过程有七个生命周期阶段。类加载机制包括了前五个阶段。
如下图所示:
其中,加载、验证、准备、初始化、卸载的开始顺序是确定的,注意,只是按顺序开始,进行与结束的顺序并不一定。解析阶段可能在初始化之后开始。
另外,类加载无需等到程序中“首次使用”的时候才开始,JVM 预先加载某些类也是被允许的。(类加载的时机)
加载
我们平常说的加载大多不是指的类加载机制,只是类加载机制中的第一步加载。加载是指查找字节流,并且据此创建类的过程。在这个阶段,JVM 主要完成三件事:
1、通过一个类的全限定名(包名与类名)来获取定义此类的二进制字节流(Class文件)。而获取的方式,可以通过 jar包、war 包、网络中获取、JSP 文件生成等方式。
2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。这里只是转化了数据结构,并未合并数据。(方法区就是用来存放已被加载的类信息,常量,静态变量,编译后的代码的运行时内存区域)
3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。这个Class对象并没有规定是在Java堆内存中,它比较特殊,虽为对象,但存放在方法区中。
对于数组类来说,它并没有对应的字节流,而是由 Java 虚拟机直接生成的。对于其他的类来说,Java 虚拟机则需要借助类加载器来完成查找字节流的过程。
类加载器
Java 虚拟机支持两种类加载器:Java 虚拟机提供的引导类加载器和用户自定义的类加载器。每个用户自定义的类加载器应该是抽象类 ClassLoader 的某个子类的实例。
关于 JVM 提供的类加载器,介绍如下:
JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader,其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader:
- BootstrapClassLoader(启动类加载器) :最顶层的加载类,由C++实现,负责加载 %JAVA_HOME%/lib目录下的 jar 包和类或者或被 -Xbootclasspath参数指定的路径中的所有类。
- ExtensionClassLoader(扩展类加载器) :主要负责加载目录 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包。
- SystemClassLoader(应用程序类加载器) :面向我们用户的加载器,它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。
类加载器虽然只用于实现类的加载动作,但其作用不止于此,还可以用来比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,如果两个类被同一个类加载器加载,那么再进行更深一步的比较;但是加载这两个类的类加载器不同,那么这两个类必定不相等。
这里说的“相等”,包括代表类的 Class 对象的 equals()方法、 isAssignableFrom()方法、 isInstance()方法的返回结果, 也包括了使用instanceof 关键字做对象所属关系判定等各种情况。
我们通过如下案例来看看不同的类加载器生成的类对象是否“相等”。
1、定义一个实体类 Product
package com.msdn.java.hotspot.classLoader; public class Product { private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } } 复制代码
2、测试
public class ClassLoaderTest { public static void main(String[] args) throws Exception { ClassLoader myLoader = 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); } } }; Class<?> cl = myLoader.loadClass("com.msdn.java.hotspot.classLoader.Product"); System.out.println(cl.getClassLoader()); System.out.println(com.msdn.java.hotspot.classLoader.Product.class.getClassLoader()); Object obj = cl.newInstance(); System.out.println(obj instanceof com.msdn.java.hotspot.classLoader.Product); Object obj2 = Class.forName("com.msdn.java.hotspot.classLoader.Product").newInstance(); System.out.println(obj.equals(obj2)); } } 复制代码
注意,Class.forName 有一个三个参数的重载方法,可以指定类加载器,平时我们使用的 Class.forName()都是使用的系统类加载器Application ClassLoader。
上述执行结果为:
com.msdn.java.hotspot.classLoader.ClassLoaderTest$1@25f38edc sun.misc.Launcher$AppClassLoader@18b4aac2 false false 复制代码
根据结果可知,针对同一个实体类进行加载,虚拟机采用了两个不同的类加载器,所以最后获得的对象也是不相等的。
双亲委派模型
每一个类都有一个对应它的类加载器。系统中的 ClassLoder 在协同工作的时候会默认使用双亲委派模型 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。
双亲委派模型图如下所示:
如上图所示的类加载器之间的这种层次关系,被称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里的类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。
每个类加载都有一个父类加载器,我们通过下面的程序来验证。
public class ClassLoaderDemo { public static void main(String[] args) { System.out.println("ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader()); System.out.println("The Parent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent()); System.out.println("The GrandParent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent().getParent()); } } 复制代码
执行结果为:
ClassLodarDemo's ClassLoader is sun.misc.Launcher$AppClassLoader@18b4aac2 The Parent of ClassLodarDemo's ClassLoader is sun.misc.Launcher$ExtClassLoader@60e53b93 The GrandParent of ClassLodarDemo's ClassLoader is null 复制代码
AppClassLoader
的父类加载器为ExtClassLoader
ExtClassLoader
的父类加载器为null,null并不代表ExtClassLoader
没有父类加载器,而是 BootstrapClassLoader
。
双亲委派模型有什么好处?负责加载的 ClassLoader 职责非常明确。
比如位于 rt.jar 包中的类 java.lang.Object,无论哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,确保了 Object 类在各种加载器环境中都是同一个类。
双亲委派模型有什么弊端?
前面提到,检查类是否已经加载的委托过程是单向的。那么带来一个问题,即顶层的 ClassLoader 无法访问底层的 ClassLoader 所加载的类。
比如说启动类加载器无法访问应用类加载器加载过的类,这种问题存在于某些组件中,比如 JDBC、Xml Parser 等。
为了解决上述弊端,在 Java 平台中,把核心类(rt.jar)中提供外部服务,可由应用层自行实现的接口,通常可以称为 Service Provider Interface,即 SPI。具体到 JDK 源码中,启动类加载器为了访问应用类加载器中的类,加了上下文加载器作为中介,这里就不做过多分析了。
连接
类的加载过程后生成了类的 java.lang.Class 对象,接着会进入连接阶段,连接阶段负责将类的二进制数据合并入JRE(Java运行时环境)中。类的连接大致分三个阶段。
1、验证:验证被加载后的类是否有正确的结构,类数据是否会符合虚拟机的要求,确保不会危害虚拟机安全。验证内容:文件格式验证(魔数、版本、常量池....)、元数据验证(字节码的语义验证)、字节码验证(类的方法)、符号引用验证。
2、准备:为类的静态字段在方法区分配内存,并赋默认初值(0值或null值)。
如 static int a = 100; 类变量 a 就会在准备阶段被赋默认值 0。
对于一般的成员变量是在类实例化时候,随对象一起分配在堆内存中。
另外,静态常量(static final filed)会在准备阶段赋程序设定的初值,如 static final int a = 666; 静态常量a就会在准备阶段被直接赋值为666,对于静态变量,这个操作是在初始化阶段进行的。
除了分配内存外,部分 Java 虚拟机还会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表。关于方法表的描述,在后续 JVM系列之:JVM如何执行方法调用一文中会介绍到。
3、解析:将常量池内的符号引用换为直接引用。
符号引用和直接引用有什么不同?
- 符号引用使用一组符号来描述所引用的目标,可以是任何形式的字面常量,定义在Class文件格式中。
- 直接引用可以是直接指向目标的指针、相对偏移量或则能间接定位到目标的句柄。
关于解析发生的具体时间,只要求在执行 ane-warray、checkcast、 getfield、 getstatic、 instanceof、 invokedynamic、 invokeinterface、 invoke-special、invokestatic、 invokevirtual、 ldc、 ldc_w、 ldc2_w、 multianewarray、 new、 putfield和putstatic 这 17个用于操作符号引用的字节码指令之前, 先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来自行判断, 到底是在类被加载器加载时就对常量池中的符号引用进行解析, 还是等到一个符号引用将要被使用前才去解析它。
如下案例,我们实现一个最简单的输出方法调用。
public static void main(String[] args) { System.out.println("hresh"); } 复制代码
System.out.println()对应的字节码为:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String hresh 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return 复制代码
然后我们来看一下常量池中的内容:
Constant pool: #1 = Methodref #6.#15 // java/lang/Object."<init>":()V #2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream; #3 = String #18 // hresh #4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V #5 = Class #21 // com/msdn/java/hotspot/classLoader/MethodLoadTest #6 = Class #22 // java/lang/Object #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 main #12 = Utf8 ([Ljava/lang/String;)V #13 = Utf8 SourceFile #14 = Utf8 MethodLoadTest.java #15 = NameAndType #7:#8 // "<init>":()V #16 = Class #23 // java/lang/System #17 = NameAndType #24:#25 // out:Ljava/io/PrintStream; #18 = Utf8 hresh #19 = Class #26 // java/io/PrintStream #20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V #21 = Utf8 com/msdn/java/hotspot/classLoader/MethodLoadTest #22 = Utf8 java/lang/Object #23 = Utf8 java/lang/System #24 = Utf8 out #25 = Utf8 Ljava/io/PrintStream; #26 = Utf8 java/io/PrintStream #27 = Utf8 println #28 = Utf8 (Ljava/lang/String;)V 复制代码
综上可知,println()方法使用了常量池第4项,再具体一点,分别使用了第 19项和第20项,我们用图形描述一下。
在常量池中关于 println()方法的调用,已经通过字面量的引用描述表达的非常清楚了,这就是符号引用。
但在程序实际运行时,只有符号引用是不够的,当调用 println()方法时,JVM 需要知道该方法的位置。以方法为例,JVM 为每个类都准备了一张方法表(在准备阶段会生成方法表),将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用就可以转变为目标方法在类中方法表中的位置,从而使得方法被成功调用。
上述演示了方法调用时是如何将符号引用转为直接引用,同理也能得到类或字段在内存中的指针或者偏移量。
初始化
初始化阶段是执行类构造器方法的过程,主要为静态变量赋程序设定的初值。方法是由编译器自动生成的,它是由类静态成员的赋值语句以及 static 语句块合并产生的,如果要初始化一个静态字段,可以直接在声明时直接赋值,也可以在静态代码块中对其赋值。
public class StaticTest { public static int num = 1; public static int price; static { price = 10; } } 复制代码
编译器生成如下的:
0: iconst_1 1: putstatic #2 // Field num:I 4: bipush 10 6: putstatic #3 // Field price:I 9: return 复制代码
由于在加载一个类之前,虚拟机总是会试图加载该类的父类,因此父类的总是在子类之前被调用,所以子类的 static 块优先级高于父类。
如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),常量的赋值在准备阶段初始化,也就不会出现在中。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 。Java 虚拟机会通过加锁来确保类的 方法仅被执行一次,但是,在多线程环境下进行类初始化的时候,可能会引起死锁,而且还不好发现问题。
类初始化死锁问题
我们通过一个案例来演示在类初始化下产生线程死锁的问题。
首先看一下类初始化只会执行一次的情况。
public class DeadLockTest { public static void main(String[] args) { try { Class.forName("com.msdn.java.hotspot.classLoader.StaticA"); Thread.sleep(1000); Class.forName("com.msdn.java.hotspot.classLoader.StaticB"); } catch (ClassNotFoundException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); } } } class StaticA { static { System.out.println("---StaticA clinit start"); try { Thread.sleep(100); } catch (InterruptedException e) { } System.out.println("---开始争夺StaticB资源"); try { Class.forName("com.msdn.java.hotspot.classLoader.StaticB"); } catch (ClassNotFoundException e) { } System.out.println("---夺得StaticB资源"); System.out.println("---StaticA clinit over"); } } class StaticB { static { System.out.println("StaticB clinit start"); try { Thread.sleep(100); } catch (InterruptedException e) { } System.out.println("开始争夺StaticA资源"); try { Class.forName("com.msdn.java.hotspot.classLoader.StaticA"); } catch (ClassNotFoundException e) { } System.out.println("夺得StaticA资源"); System.out.println("StaticB clinit over"); } } 复制代码
执行结果为:
---StaticA clinit start ---开始争夺StaticB资源 StaticB clinit start 开始争夺StaticA资源 夺得StaticA资源 StaticB clinit over ---夺得StaticB资源 ---StaticA clinit over 复制代码
接下来演示死锁的情况,只需要修改一下 main 方法。
public static void main(String[] args) { new Thread(() -> { try { Class.forName("com.msdn.java.hotspot.classLoader.StaticA"); } catch (ClassNotFoundException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " over"); }, "Thread-A").start(); new Thread(() -> { try { Class.forName("com.msdn.java.hotspot.classLoader.StaticB"); } catch (ClassNotFoundException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " over"); }, "Thread-B").start(); } 复制代码
执行上述代码,控制台仅输出下述语句,就没其他反应,也不会结束掉。
---StaticA clinit start StaticB clinit start ---开始争夺StaticB资源 开始争夺StaticA资源 复制代码
我们通过 VisualVM 工具来查看线程堆栈信息,在 Threads 页面并没有任何死锁信息提示,如下图所示,但是我们介绍 VisualVM 工具时提到过,程序发生死锁后,会在图中标注的位置显示相关信息。(VisualVM 工具使用后续会有对应的文章)
点击 Thread Dump 按钮,观察这两个线程的执行状态。
可以发现线程处于 Runnable 状态,可见系统并不会给出足够的信息来判定死锁,但它确实存在。
初始化场景
是 instance 实例构造器,对非静态变量解析初始化,而 是 class 类构造器对静态变量,静态代码块进行初始化。
Java 虚拟机中严格规定了有且只有5种情况必须对类进行初始化。
1、执行 new(实例化对象)、getstatic(读取静态字段)、putstatic(设置静态字段值) 和 invokestatic 指令(调用一个静态方法),注意静态字段如果被 final 修饰、已在编译期把结果放入常量池,则不会触发初始化。
2、使用 reflect 对类进行反射调用;
3、初始化一个类的时候,父类还没有初始化,会事先初始化父类;
4、启动虚拟机时,需要初始化包含 main 方法的类;
5、在 JDK1.7 中,如果 java.lang.invoke.MethodHandler 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄对应的类没有进行初始化;
除了上述所述的 5种类初始化场景,还有一种初始化场景:接口的初始化。
从 JDK8 开始,在接口中定义 default 修饰的方法,如果该接口的实现类发生了初始化,那么该接口会在其之前被初始化。
既然只有那么 5种情况会触发类初始化,那么肯定有一些场景不会触发类初始化,这里我们认识一下几种具有代表性的场景。
1、通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
class Parent { static int a = 100; static { System.out.println("parent init!"); } } class Child extends Parent { static { System.out.println("child init!"); } } public class Init{ public static void main(String[] args){ System.out.println(Child.a); } } //输出结果 parent init! 100 复制代码
2、定义对象数组,不会触发该类的初始化。
public class Init{ public static void main(String[] args){ Parent[] parents = new Parent[10]; } } //无输出内容 复制代码
3、常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
class Const { static final int A = 100; static { System.out.println("Const init"); } } public class Init{ public static void main(String[] args){ System.out.println(Const.A); } } //输出 100 复制代码
4、通过类名获取 Class 对象,不会触发类的初始化。
public class test { public static void main(String[] args) throws ClassNotFoundException { Class c_dog = Dog.class; Class clazz = Class.forName("zzzzzz.Cat"); } } class Cat { private String name; private int age; static { System.out.println("Cat is load"); } } class Dog { private String name; private int age; static { System.out.println("Dog is load"); } } //输出 Cat is load 复制代码
5、通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
Class clazz = Class.forName("com.msdn.domain.Cat", false, Cat.class.getClassLoader()); 复制代码
6、通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。
new ClassLoader(){}.loadClass("com.msdn.domain.Cat"); 复制代码
扩展
我们之前都接触过单例模式,接下来看一个延迟单例初始化的案例:
public class Singleton { private Singleton() {} private static class LazyHolder { static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return LazyHolder.INSTANCE; } } 复制代码
只有当调用 Singleton.getInstance 时,程序才会访问 LazyHolder.INSTANCE,才会触发对 LazyHolder 的初始化(对应执行invokestatic指令),继而新建一个 Singleton 的实例。
根据上文我们得知静态内部类实现单例模式的背后原理:懒加载模式内部类通过JVM的特性(类初始化是线程安全的)来实现。类是先加载到JVM中,但是只有在具体使用的时候会进行初始化,而这个初始化是JVM自身实现并且是线程安全的,因此程序可以确保多线程环境下有且仅有一个 Singleton 实例。
这里引申出另一个案例:
public class Singleton { private Singleton() { } private static class LazyHolder { static final Singleton INSTANCE = new Singleton(); static { System.out.println("LazyHolder.<clinit>"); } } public static Object getInstance(boolean flag) { if (flag) { return new LazyHolder[2]; } return LazyHolder.INSTANCE; } public static void main(String[] args) { getInstance(true); System.out.println("----"); getInstance(false); } } 复制代码
执行结果为:
---- LazyHolder.<clinit> 复制代码
先说结论:如果 flag 为 true,则会新建数组,执行该语句只会加载 LazyHolder,但是并不会初始化该类。在getInstance(false)时才真正连接和初始化 LazyHolder。
可以通过下述命令查看区别:
$ javac Singleton.java $ java -verbose:class Singleton