JVM和ClassLoader


JVM和ClassLoader

2019-11-08

JVM架构整体架构
  1.1 类加载器子系统
    1.1.1 加载
    1.1.2 链接
    1.1.3 初始化
  1.2 运行时数据区(Runtime Data Area)
  1.3 执行引擎
  1.4 示例
2 classloader加载class文件的原理和机制
  2.1 Classloader 类结构分析
  2.2 实现类的热部署
  2.3 类加载器的双亲委派模型
  2.4 类加载的三种方式
  2.5 自定义类加载器的两种方式
参考

返回

图1 JVM整体架构图

JVM被分为三个主要的子系统:

  • 类加载器子系统
  • 运行时数据区
  • 执行引擎

返回

图2 类加载器

Java的动态类加载功能是由类加载器子系统处理。当它在运行时(不是编译时)首次引用一个类时,它加载、链接并初始化该类文件。

返回

  • The 运行时数据区域被划分为5个主要组件:
  • 方法区 (线程共享) 常量 静态变量 JIT(即时编译器)编译后代码也在方法区存放
  • 堆内存(线程共享) 垃圾回收的主要场地
  • 程序计数器 当前线程执行的字节码的位置指示器
  • Java虚拟机栈(栈内存) :保存局部变量,基本数据类型以及堆内存中对象的引用变量
  • 本地方法栈 (C栈):为JVM提供使用native方法的服务

图4 运行时数据区

返回

分配给运行时数据区的字节码将由执行引擎执行。执行引擎读取字节码并逐段执行。

解释器:解释器能快速的解释字节码,但执行却很慢。 解释器的缺点就是,当一个方法被调用多次,每次都需要重新解释。

编译器:JIT编译器消除了解释器的缺点。执行引擎利用解释器转换字节码,但如果是重复的代码则使用JIT编译器将全部字节码编译成本机代码。本机代码将直接用于重复的方法调用,这提高了系统的性能。

  • 中间代码生成器– 生成中间代码
  • 代码优化器– 负责优化上面生成的中间代码
  • 目标代码生成器– 负责生成机器代码或本机代码d. 探测器(Profiler) – 一个特殊的组件,负责寻找被多次调用的方法。

垃圾回收器: 收集并删除未引用的对象。可以通过调用"System.gc()"来触发垃圾回收,但并不保证会确实进行垃圾回收。JVM的垃圾回收只收集哪些由new关键字创建的对象。所以,如果不是用new创建的对象,你可以使用finalize函数来执行清理。Java本地接口 (JNI): JNI会与本地方法库进行交互并提供执行引擎所需的本地库。本地方法库:它是一个执行引擎所需的本地库的集合。

返回

通过以下代码看JVM类加载执行过程

 1 package com.example.demo.classloader;
 2 /**
 3  * 从JVM调用的角度分析java程序堆内存空间的使用:
 4  * 当JVM进程启动的时候,会从类加载路径中找到包含main方法的入口类HelloJVM
 5  * 找到HelloJVM会直接读取该文件中的二进制数据,并且把该类的信息放到运行时的Method内存区域中。
 6  * 然后会定位到HelloJVM中的main方法的字节码中,并开始执行Main方法中的指令
 7  * 此时会创建Student实例对象,并且使用student来引用该对象(或者说给该对象命名),其内幕如下:
 8  * 第一步:JVM会直接到Method区域中去查找Student类的信息,此时发现没有Student类,就通过类加载器加载该Student类文件;
 9  * 第二步:在JVM的Method区域中加载并找到了Student类之后会在Heap区域中为Student实例对象分配内存,
10  *         并且在Student的实例对象中持有指向方法区域中的Student类的引用(内存地址);
11  * 第三步:JVM实例化完成后会在当前线程中为Stack中的reference建立实际的应用关系,此时会赋值给student
12  * 接下来就是调用方法
13  * 在JVM中方法的调用一定是属于线程的行为,也就是说方法调用本身会发生在线程的方法调用栈:
14  * 线程的方法调用栈(Method Stack Frames),每一个方法的调用就是方法调用栈中的一个Frame,
15  * 该Frame包含了方法的参数,局部变量,临时数据等 student.sayHello();
16  */
17 public class HelloJVM {
18     //在JVM运行的时候会通过反射的方式到Method区域找到入口方法main
19     public static void main(String[] args) {//main方法也是放在Method方法区域中的
20         /**
21          * student(小写的)是放在主线程中的Stack区域中的
22          * Student对象实例是放在所有线程共享的Heap区域中的
23          */
24         Student student = new Student("spark");
25         /**
26          * 首先会通过student指针(或句柄)(指针就直接指向堆中的对象,句柄表明有一个中间的,student指向句柄,句柄指向对象)
27          * 找Student对象,当找到该对象后会通过对象内部指向方法区域中的指针来调用具体的方法去执行任务
28          */
29         student.sayHello();
30     }
31 }
32 class Student {
33     // name本身作为成员是放在stack区域的但是name指向的String对象是放在Heap中
34     private String name;
35     public Student(String name) {
36         this.name = name;
37     }
38     //sayHello这个方法是放在方法区中的
39     public void sayHello() {
40         System.out.println("Hello, this is " + this.name);
41     }
42 }

对象的访问定位

java程序需要通过引用(ref)数据来操作堆上面的对象,那么如何通过引用定位、访问到对象的具体位置。

对象的访问方式由虚拟机决定,java虚拟机提供两种主流的方式

  • 句柄访问对象
  • 直接指针访问对象。(Sun HotSpot使用这种方式)

句柄访问优点:引用中存储的是稳定的句柄地址,在对象被移动【垃圾收集时移动对象是常态】只需改变句柄中实例数据的指针,不需要改动引用【ref】本身。

直接指针访问优点:优势很明显,就是速度快,相比于句柄访问少了一次指针定位的开销时间。【可能是出于Java中对象的访问时十分频繁的,平时我们常用的JVM HotSpot采用此种方式】

图5 通过句柄访问对象

图6 通过指针访问对象

返回

返回

  • 同一个classLoader的两个实例加载同一个类,JVM也会识别为两个
  • 不能重复加载同一个类(全名相同,并使用同一个类加载器),会报错
  • 不应该动态加载类,因为对象被引用后,对象的属性结构被修改会引发问题

注意:使用不同classLoader加载的同一个类文件得到的类,JVM将当作是两个不同类,使用单例模式,强制类型转换时都可能因为这个原因出问题。

返回

图3 类加载器双亲委派模型

类加载器双亲委派模型加载顺序:java的三种类加载器存在父子关系,子 加载器保存着附加在其的引用,当一个类加载器需要加载一个目标类时,会先委托父加载器去加载,然后父加载器会在自己的加载路径中搜索目标类,父加载器在自己的加载范围中找不到时,才会交给子加载器加载目标类。

采用双亲委托模式可以避免类加载混乱,而且还将类分层次了,例如java中lang包下的类在jvm启动时就被启动类加载器加载了,而用户一些代码类则由应用程序类加载器(AppClassLoader)加载,基于双亲委托模式,就算用户定义了与lang包中一样的类,最终还是由应用程序类加载器委托给启动类加载器去加载,这个时候启动类加载器发现已经加载过了lang包下的类了,所以两者都不会再重新加载。当然,如果使用者通过自定义的类加载器可以强行打破这种双亲委托模型,但也不会成功的,java安全管理器抛出将会抛出java.lang.SecurityException异常。 

返回

  • 通过命令行启动应用时由JVM初始化加载含有main()方法的主类。
  • 通过Class.forName()方法动态加载,会默认执行初始化块(static{}),但是Class.forName(name,initialize,loader)中的initialze可指定是否要执行初始化块。
  • 通过ClassLoader.loadClass()方法动态加载,不会执行初始化块。
//1 由new关键字创建一个类的实例,在由运行时刻用 new 方法载入 
Person person = new Person();
//2 使用Class.forName()    通过反射加载类型,并创建对象实例
Class clazz = Class.forName("Person");
Object person =clazz.newInstance();
//3 使用某个ClassLoader实例的loadClass()方法,通过该 ClassLoader 实例的 loadClass() 方法载入。应用程序可以通过继承 ClassLoader 实现自己的类装载器。
Class clazz = classLoader.loadClass("Person");
Object person =clazz.newInstance();

其中:

  • 1和2使用的类加载器是相同的,都是当前类加载器(即:this.getClass.getClassLoader)。
  • 3由用户指定类加载器。如果需要在当前类路径以外寻找类,则只能采用第3种方式。即第3种方式加载的类与当前类分属不同的命名空间。
  • 1是静态加载,2、3是动态加载
  • Class.forName(className)方法,内部实际调用的方法是 Class.forName(className,true,classloader);

    第2个boolean参数表示类是否需要初始化, Class.forName(className)默认是需要初始化。

    一旦初始化,就会触发目标对象的 static块代码执行,static参数也也会被再次初始化。

  • ClassLoader.loadClass(className)方法,内部实际调用的方法是 ClassLoader.loadClass(className,false);

    第2个 boolean参数,表示目标对象是否进行链接,false表示不进行链接,由上面介绍可以,

    不进行链接意味着不进行包括初始化等一些列步骤,那么静态块和静态对象就不会得到执行

返回

  • 遵守双亲委派模型:继承ClassLoader,重写findClass()方法。
  • 破坏双亲委派模型:继承ClassLoader,重写loadClass()方法。

通常我们推荐采用第一种方法自定义类加载器,最大程度上的遵守双亲委派模型。 自定义类加载的目的是想要手动控制类的加载,那除了通过自定义的类加载器来手动加载类这种方式,还有其他的方式么?

利用现成的类加载器进行加载:

  • 利用当前类加载器
    Class.forName();
  • 通过系统类加载器
    Classloader.getSystemClassLoader().loadClass();
  • 通过上下文类加载器
    Thread.currentThread().getContextClassLoader().loadClass(); 

参考

[1] classloader加载class文件的原理和机制

[2] java 类加载器双亲委派模型

[3] 深入理解JVM-内存模型(jmm)和GC

[4] 【深入Java虚拟机】之二:Class类文件结构