浅谈java虚拟机的类加载

浅谈java虚拟机的类加载

Scroll Down

浅谈java虚拟机的类加载

从java字节码说起

java代码的执行是先又java编译器将java代码编译成.class文件的java字节码,然后再jvm中运行的,所以了解jvm的字节码是熟悉类加载的第一步。如下图所示的java代码

public class ClassLoadTest {

    static {
        System.out.println("root class initialized");
    }


    public static class Father {
        static {
            System.out.println("father initialized!");
        }

        public Father() {
            System.out.println("father instancing");
        }
    }

    public static class Son extends Father {

        public static final String fields = "father static final field!";

        static {
            System.out.println("son initialized!");
        }

        public Son() {
            System.out.println("son instancing");
        }
    }

    public static void main(String[] args) {
        System.out.println(Son.fields);
        System.out.println("-------start instance son-------");
        Son son = new Son();
    }
}

那么上面一段代码的打印顺序究竟是什么样的呢?

这段java代码在经过java虚拟机编译后,产生的java字节码文件如下图所示,
你可以通过javap -c -l ClassLoadTest 这个命令得到java字节码的信息,我使用的是工具classpy 加载ClassLoadTest.class文件,结果如下图所示
image.png
从图中可以看到,在ClassLoadTest的字节码中,存储着这个class的信息
主要信息有:

  • constant_pool 常量池,存储着这个类相关的所有常量信息
  • fields 类字段
  • methods 类方法
    这里重点说一下methods方法的具体属性
    image.png

图中,对上文中的main方法,在java字节码中被解析出来的意义中,第一个access_flags为方法的作用域,图中为public,name_index为#22,可以通过21这个索引到constant_pool中解析拿到name为main,descriiptor_index为方法描述符索引。方法描述符是个很重要的概念,它是由方法的参数类型以及返回类型所构成,在同一个类中不能有两个相同的方法描述符的方法。在java语言中一个实现多态的重要概念-重载就是通过方法描述符实现的。
下面attribute下的code即是对该方法的具体的指令。jvm在执行方法时,运行的线程(thread)上是一个一个的栈桢(frame),每一个栈桢是由操作数栈和本地变量表以及返回地址等信息组成。操作数栈中存储和执行的就是code中的指令。他和本地变量表配合使用,完成了函数的执行。

通过上图可以看出java字节码的三种特别的方法 方法代表java类的构造函数,main方法代表java的main方法,方法代表java的static代码块。其实我们看到main方法的字节码,我们其实应该能够看出一些上面那段程序打印顺序的猫腻。下面会具体说到。

有关java字节码每个字段及指令的具体意义,可以查看java虚拟机规范 第八版

java类加载

类加载的步骤很多其他的博客上都有,这里就不赘述了,大概分为 加载->链接(linking)->初始化,链接分为验证->准备->解析这几个步骤。这篇博客主要聚焦一些类加载中的一些细节的问题,以及从源码的角度分析一下类加载的过程。

什么时候会进行类加载

在java虚拟机规范中,进行类加载的实际并没有进行严格规定,jvm会在合适的实际进行类加载。但是严格规定了在什么情况下必须做初始化。

在java虚拟机规范中规定了再需要对一个类进行主动引用的时候,如果这个类还没有加载进虚拟机,那么将触发类初始化。一般情况下,主要有以下几个时机会出发类初始化

  • 遇到new、getstatic、putstatic、invokestatic指令时,如果这个类没有加载到虚拟机,将会触发类加载。具体表现在以下代码形式:
    • new关键字实例化对象的时候
    • 读取或设置或者调用一个静态字段,如果是static final的除外,它会在编译期就放入常量池中
  • 使用反射的时候
  • jdk8接口的default方法
  • 初始化类的时候,如果发现父类未初始化将将父类进行类加载
  • 当虚拟机启动时执行的main类
  • 当jdk7引入执行动态语言支持的invokedynamic指令时,如果java.lang.invoke.methodHandle并且最后的解析结果为REF_getstatic、REF_putstatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化

实际在hotspot中,类加载的步骤有时也是放在遇到new指令后执行的,java虚拟机会遇到new指令后先去查找是否加载了该类,如果没有回加载该类顺带加载它的父类。然后才会继续链接和初始化。

类加载器

前面提到,类加载的步骤一共分为加载、链接、初始化三步,在jvm的设计中,对于加载这一步,即“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作,jvm的设计是可以自定义放到外部实现。如可以通过网络获取字节码二进制流。所以设计了类加载器(class loader)这个类来加载对应的字节码。通过这样的方式,我们可以对字节码做一些特定的操作,如加密、也可以从各种途径获取字节码。是OSGI、热部署、容器隔离等技术的基础。

在jvm中默认定义了一些基础的类加载器,其中启动类加载器(Bootstrap ClassLoader)是由c++实现的,是虚拟机的一部分,其他的均继承了java.lang.ClassLoader类
本文主要讨论一下jdk8极其之前的主要的类加载器,jdk9版本以后由于修改为模块化后类加载器部分设计放在后续添加。下面列举一下jdk8版本的主要的类加载器以及作用

  1. 启动类加载器(bootstrap ClassLoader)
    主要负责加载存放在<JAVA_HOME>/lib下的目录,如rt.jar、tools.jar。启动类加载器无法被java程序直接调用。
  2. 扩展类加载器(Extension ClassLoader)
    这个类加载器是在Launche$ExtClassLoader下以java代码形式实现。它负责加载<JAVA_HOME>/lib/ext目录下的类库,或者被java.ext.dirs系统变量指定的路径的所有类库。设计扩展类加载器的主要目的是允许用户将具有通用性的类库放在ext目录里以扩展jvm的功能。
  3. 应用程序加载器(App ClassLoader)
    这个类加载器是在Launcher$AppClassLoader实现的,它负责加载应用目录下的所有的类库。如果应用程序没有自己定义过自己的类加载器,一般情况下就是这个类加载器加载的。它也是ClassLoader类中getSystemClassLoader()方法的返回值。

双亲委派模型

双亲委派模型是java实现稳定运行的重要基础,它要求除了顶层启动类家在其外,其余的类加载器均应有自己的父加载器。如果一个类加载器收到了类加载的请求,它首先不会自己尝试去加载这个类,而是把这个请求委派给父类加载器去完成,因此所有的类加载请求都应该被传送到最顶层的启动类加载器中。只有当父类无法反馈无法加载时,才会给子类加载。
使用双亲委派模型的好处是将java中的类随着类加载器一起建立了一种带有优先级的层次关系。如java.lang.Object类,它存在于rt.jar中,最终都是让启动类加载器(bootstrap ClassLoader)加载的。因此object类在jvm中各种类加载器环境都能保证是同一个类。如果没有这种机制,如果用户编写了一个java.lang.Object的类,那么系统就会出现多个不同的Object类。这样应用程序就会变得一团乱。

用类加载器实现java容器隔离

对于类加载器的一个典型应用就是容器隔离,假设有这样一个场景:应用app依赖库A和库B,而库A和k库B又同时依赖一个底层库C,A和B是又公司其他团队开发的。其中库A依赖C的2.0版本,其中对C中的同一个类删除了一些方法又新增了一些方法。此时在app在引用库A和库B的时候就会出现问题。

隔离容器用于解决这种问题,它可以把A和B的环境完全隔离开来,这样来及时C类名完全相同也互相不冲突,使得A和B互不影响。实现容器隔离主要是利用了以下java特性

  • class的区分由class name和载入其的class loader共同决定
  • 当在class A中使用了class B时,JVM默认会用class A的class loader去加载class B
  • 双亲委派模型
  • URLClassLoader会从指定的目录及*.jar中加载类

类加载后的链接

当一个类使用类加载器加载后,java虚拟机获取了这个类的二进制字节流。他们会在合适的时机会进行链接和初始化。链接的主要目的大概有以下几个

  • 验证字节码的正确性和安全性
  • 为静态字段分配空间
  • 将符号引用重写为实际引用(rewrite)
  • 虚方法表和接口方法表

在hotspot中链接的实现代码的简要版本贴在了下面

/链接的具体实现
bool InstanceKlass::link_class_impl(bool throw_verifyerror, TRAPS) {
 
  // return if already verified
  if (is_linked()) {
    return true;
  }
  // link super class before linking this class
  // 链接此类之前先链接父类
  Klass* super_klass = super();
  if (super_klass != NULL) {
    InstanceKlass* ik_super = InstanceKlass::cast(super_klass);
    ik_super->link_class_impl(throw_verifyerror, CHECK_false);
  }

  // 链接此类之前先链接接口
  Array<Klass*>* interfaces = local_interfaces();
  int num_interfaces = interfaces->length();
  for (int index = 0; index < num_interfaces; index++) {
    InstanceKlass* interk = InstanceKlass::cast(interfaces->at(index));
    interk->link_class_impl(throw_verifyerror, CHECK_false);
  }


  // 验证
  // verification & rewriting
  {
    HandleMark hm(THREAD);
    Handle h_init_lock(THREAD, init_lock());
    ObjectLocker ol(h_init_lock, THREAD, h_init_lock() != NULL);
    // rewritten will have been set if loader constraint error found
    // on an earlier link attempt
    // don't verify or rewrite if already rewritten
    //
    // 
    if (!is_linked()) {
      if (!is_rewritten()) {
        {
          bool verify_ok = verify_code(throw_verifyerror, THREAD);
          if (!verify_ok) {
            return false;
          }
        }

        if (is_linked()) {
          return true;
        }

        // 重写类(就是解析的过程)
        rewrite_class(CHECK_false);
      } else if (is_shared()) {
      
	//链接方法,将符号引用指向具体的实际引用 
      link_methods(CHECK_false);

	//链接初始化 vtable 和 itable  即虚方法表和接口方法表
        vtable().initialize_vtable(true, CHECK_false); 
        itable().initialize_itable(true, CHECK_false);
      }
	//设置为link完的状态
      set_init_state(linked);
     
  return true;
}

虚方法表和接口方法表

前面几个链接的目的大概都很好理解,我们主要讨论一下对虚方法表和接口方法表。在jvm中,当一个类需要调用另一个方法的时候,如何找到那个方法的具体地址呢,链接的时候是可以找到的,但是如果这个方法是重载和重写的,我们又怎么判断呢?jvm对于重载的解决方案为:通过抽象出方法的类名,方法名,以及方法描述符,可以确定唯一一个方法。这是在编译时期就已经完全确定了的,从而可以和他们进行绑定,一般称这种绑定为静态绑定。而重写就稍微麻烦一点,jvm需要需要在运行过程中根据调用者的动态类型来识别目标方法的情况。为此,jvm为这种情况设计了虚方法表和接口方法表。方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。
image.png

在链接的时候,jvm对方法的符号引用转实际引用的过程中,对于静态绑定的方法,jvm会直接将指令指向目标方法。对于动态绑定而言,则会指向方法表的索引。

具体的,jvm的方法调用指令一共有以下几个,jvm会在编译期确定应该调用哪个指令(此处暂不讨论jdk7以后的invokedynamic指令)

  • invokestatic 用于调用静态方法
  • invokespecial 用于调用private实例方法以及构造器,还有接口默认方法
  • invokevirtual 用于调用public、protected实例方法
  • invokeinterface 用于调用接口方法

对于虚方法的调用就是通过invokevirtual指令完成的。调用虚方法所带来的的额外性能开销,主要在于访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。对此,jvm会采取一些优化手段如内联缓存(inlining cache)和方法内联(method inlining)

在hotspot上调试类加载的代码

为了更加充分的证明上述的理论,下面会对hotspot中类加载的代码进行调试
我是在macos 10.15.5的平台上调试的jdk11。
我们再执行上面这段代码的时候加上-Xlog:class+load=info参数以便打印它的类加载信息。
用jdk11版本跑上述代码,会出现以下结果

Root class Class Load Test
-------start instance son-------
[info][class,load] ClassLoadTest$Father source: file:/Users/windyrjc/Desktop/study/studyJava/target/classes/
[info][class,load] ClassLoadTest$Son source: file:/Users/windyrjc/Desktop/study/studyJava/target/classes/
father initialized!
son initialized!
father instancing
son instancing

下面我们从源码的角度讨论一下以上结果是如何执行的

我们在jvm中执行调用classloader.defineClass的native方法出进行了断点
我们知道,任何classLoader的loadClass方法最终会执行到一个本地方法

  private native Class<?> defineClass1(String name, byte[] b, int off, int len,
                                         ProtectionDomain pd, String source);

上述代码最终会执行到以下本地方法的c++调用

JNIEXPORT jclass JNICALL
Java_java_lang_ClassLoader_defineClass1(JNIEnv *env,
                                        jclass cls,
                                        jobject loader,
                                        jstring name,
                                        jbyteArray data, //字节码流
                                        jint offset, 
                                        jint length,
                                        jobject pd,
                                        jstring source)
{

该方法最终会执行到一个jvm的通用类加载的方法jvm_define_class_common
image.png
在此处断点后,打出此处的堆栈信息:
image.png
可以看到,在jvm启动之后,首先需要加载main方法所在的类,这样才能拿到main方法并执行。于是从线程栈中由下至上可以看到,hotspot依次调用了LoadMainClass方法加载主类,然后调用invokestatic指令从c++调用了java函数Class.forname加载类。可以看到在javaCalls::call_helper和java_lang_Class_forname0这个期间调试器并不能确定代码的具体位置,是因为jvm默认使用了模板解释器解释jvm指令,模板解释器是用汇编语言完成的。紧接着继续往下,Class_forname会去SystemDictionary寻找是否有主类,如果没有就又会执行一个java调用从c++代码调用到java的defineClass中去。
image.png
紧接着,类加载完后,jvm会去初始化主类,加载的操作和初始化的操作是两个独立的操作,上面提到,java虚拟机规范只提到了再什么时候会进行必要的初始化,并没有严格规定在什么时候应该进行类加载。
在执行完loadMainClass后,jvm最终会拿到main方法并执行,下面这张图是看看hotspot是如何初始化主类的
image.png
可以看到,最终线程栈调用了InstanceKlass::initialize方法执行了初始化操作,里面具体逻辑在InstanceKlass::initialize_impl中,这里只贴出关键代码:

void InstanceKlass::initialize_impl(TRAPS) {
  HandleMark hm(THREAD);

  // Make sure klass is linked (verified) before initialization
  // 链接这个类
  link_class(CHECK);

  DTRACE_CLASSINIT_PROBE(required, -1);

  bool wait = false;

  // refer to the JVM book page 47 for description of steps
  // Step 1 通过objectLocker在初始化之前进行加锁,防止多个线程并发初始化
  

    // Step 2 如果当前insanceKlass处于being_initialized状态,且正在被其他线程初始化,则执行ol.waitUninterruptibly(CHECK)等待其他线程完成后通知
  
    // Step 3 如果当前instanceKlass处于being_initialized状态,且被当前线程初始化,则直接返回。
  
    // Step 4 如果当前instanceKlass处于being_initialized状态,且被当前线程初始化,则直接返回。
   
    // Step 5 如果当前instanceKlass处于initialization_error状态,说明初始化失败了,抛出异常。

    // Step 6 设置当前instanceKlass的状态为 being_initialized;设置初始化线程为当前线程
 
    // Step 7 如果当前instanceKlass不是接口类型,并且父类不为空,且还未初始化,则执行父类的初始化。

    // Step 8 通过this_oop->call_class_initializer方法执行静态块代码
  {
    call_class_initializer(THREAD); //执行static代码块的逻辑
  }

  // Step 9 如果初始化过程没有异常,说明instanceKlass对象已经初始完成,则设置当前instanceKlass的状态为 fully_initialized,最后通知其它线程初始化已经完成;否则执行step10 and 11。

    // Step 10 and 11 如果初始化发生异常,则设置当前instanceKlass的状态为 initialization_error,并通知其它线程初始化发生异常。
}

这里执行完后,控制台打出了第一句话

root class initialized

说明主类static代码块被执行

紧接着,在我们在下一个define_class出打断点后停下,发现控制台输出以下语句

root class initialized
father static final field!
-------start instance son-------

此处说明:在执行主方法后,虚拟机并未开始加载Father及Son相关的类,但是却调用了Son.fields方法。这是因为如果一个变量被修饰为static final那么它将会在编译器就确定字节码并分配空间在常量池中。不再是类加载的影响。

此时,查看这是的栈轨迹会发现
image.png
hotspot在加载Son类的时机是遇到了new指令,即上段代码中的new Son()
这里查看栈桢中使用new指令的代码,我们可以看到:
image.png
hotspot首先取常量池中寻找是否有这个类被加载,如果没有,就回去加载它,如果已经加载过,在判断不是抽象类后就会对这个类进行初始化。我们在初始化这个地方打上端点后,控制台输出了以下语句

root class initialized
father static final field!
-------start instance son-------
[1222.061s][info][class,load] ClassLoadTest$Father source: file:/Users/windyrjc/Desktop/study/studyJava/target/classes/
[1222.063s][info][class,load] ClassLoadTest$Son source: file:/Users/windyrjc/Desktop/study/studyJava/target/classes/

此时,说明类加载的过程把父类也一起加载进来了,但是父类的加载是由于子类的加载触发的。这就验证了前文提到的,java虚拟机规范并未规定什么时候一定需要对类进行类加载,而是规定了什么时候一定得对类进行初始化。

紧接着,就是对类的初始化动作
image.png
代码和上面一样,会先去链接,然后初始化,注意它会先执行父类的初始化,再执行子类的初始化。
执行完后,控制台打印如下:

root class initialized
father static final field!
-------start instance son-------
[1222.061s][info][class,load] ClassLoadTest$Father source: file:/Users/windyrjc/Desktop/study/studyJava/target/classes/
[1222.063s][info][class,load] ClassLoadTest$Son source: file:/Users/windyrjc/Desktop/study/studyJava/target/classes/
father initialized!
son initialized!