20+图详解你不知道的虚拟机类加载机制

「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战」。

本文主要图解JAVA虚拟机的类加载机制,多图(20+)详解、抽丝剥茧。

类加载详解

Java类加载一览

程序运行的时候,class文件类通过类加载器ClassLoader把字节码加载到常量池,并进行校验、准备、解析和初始化。

jvm0.png

虚拟机黑盒操作了校验、准备、解析和初始化等过程。但加载不是虚拟机的事情,而是类加载器(ClassLoader类)的事情。

什么是类加载

类加载就是类加载器通过一个类的全限定名去获取这个类的二进制字节流到JAVA虚拟机。
如下图所示:

jvm1.png

从图上可以看出:

  • 类加载阶段的“通过一个类的全限定名去获取这个类的二进制字节流”的操作是放到虚拟机的外部去实现的
  • 类加载是让程序员自己去决定如何获取所需要的类。
  • 可帮助程序员实现这个加载动作的代码模块称为类加载器(ClassLoader)。

对于Classloader而言,加载其实要做三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。用字节流就很方便从网络获取,字节码的二进制流还可以运行时计算生成,动态代理技术Proxy就是用了ProxyGenerator来为特定接口生成形式为$Proxy的二进制字节流。
  2. 将这个字节流代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据结构的访问入口。

类和类加载器的关系

不同的类加载器加载的类都放在虚拟机内部。如果两个不同的classloader都加载同一个类,但是他们被认为不是同一个类。这是为什么呢?

cl2.png
如上图所示,
Classloader1和classloader2都加载了a.b.A 这个class文件,

但在虚拟机里会存在两个名字为a.b.A类的Class类实例,如下图所示

cl3.png

findClass和defineClass的逻辑都是ClassLoader(程序员可以自定义自己的类加载器)里面定义的,这里生成了两个不同的Class对象。

类加载的时机

某一个类加载的时机?

JVM虚拟机并没有强制规定什么时候需要,但是对于类的初始化的时机却是有强制规定的(也意味着此时这个类需要加载了),总结为以下几点:

  • 虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的类),虚拟机会先初始化这个主类

vm1.png

如上图,虚拟机启动时,
Main是执行的主类
虚拟机会先初始化Main

  • 当初始化一个类的时候,如果父类还没进行初始化,那一定先触发父类的初始化

Object类是所有类的父类,所以在虚拟机中,Object类一定是最先开始进行初始化的。
这个从上面的图也看的出来。

  • 遇到调用点(new、getstatic、putstatic和invokestatic)这四条字节码指令,如果类还没进行过初始化,则触发其初始化,而且默认和调用点所在类的类加载器使用同样的类加载器。
  • 特殊情况: final字段有些特殊,在编译阶段就放入调用类的常量池了,所以直接引用类的静态常量字段,也不会导致类的初始化
  • reflect 的包对类进行反射调用的时候,如果类还没有进行过初始化,则需要先进行初始化。
  • 1.7对动态语言的支持,MethodHandle实例最后的解析结果REF_getStatic的方法句柄,如果还没有进行过初始化,则需要先触发其初始化。

Class的世界

上文一直说,字节码通过类加载器加载到方法区的是Class对象。这里很需要先理解一下Class对象,如下图

jvm3.png

  • Object类:可表示万事万物
  • Class类:可表示字节码,无论是java源文件编译的或者程序生成的字节码都可以
  • Object类:没有成员
  • Class类成员:成员包含classloader, name …
    JVM加载类的时候会先加载Object类,所以启动JVM的时候大致的流程可能如下:

jvm2.png

(上面这个图为了演示ClassLoader加载类,有一个故意的bug,下文会做出更正。)

另外抛出一个问题,Object类是哪个类加载器加载的?这个问题在下文中给出解答。

认识常用的类加载器

cl7.png

  • 引导类加载器 Bootstrap ClassLoader,负责加载JDK的核心lib资源。
  • 扩展类加载器 Extension ClassLoader,负责JDK 自身扩展机制的包资源。
  • 程序员编程的类加载器,负责加载用户类路径(Classpath) 上所指定的类库。如果APP中没有定义自己的Classloader,一般来说Application Classloader就是程序中默认的类加载器。

需要理解Classpath是Application Classloader作用域(每个JVM都应该有自己的Classpath,比如当前程序运行的目录,本地maven仓库等等)

  • 在这里可以看出,java世界存在三个基本的作用域:JVM lib、JVM lib的扩展和ClassPath。

ClassLoader的parent继承机制

parent继承机制是JVM类加载器的基本思想,下面详细详细讲讲双亲委派模型。

Classloader都要定义自己的parent(如果为null,则默认是Bootstrap ClassLoader),双亲委派模型的意思就是这些类在loadClass的时候都先委派给parent去找,如果parent找到了,那自身就不找了,否则才由自身去处理。

目前JAVA世界的ClassLoader基本如下所示:

vm3.png

这样的好处就是Object类是Bootstrap Classloader加载的,那大家读到的Object都是rt.jar定义的那个,如果不采用双亲委托模型,那用户定义一个自己的java.lang.Object,并且使用默认的Application Classloader来加载,那么Object就被替换了,这个世界也就乱套了。

双亲委派的具体实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码protected Class<?> loadClass(String name, boolean resolve) 
{
synchronized (getClassLoadingLock(name)) {
// 这个类如果已经存在虚拟机之中,直接返回,就不再找了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//委托给parent去找
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} //...
if (c == null) {
// 尝试去找到这个类
long t1 = System.nanoTime();
c = findClass(name);
...
}
...
return c;
}
}

loadClass的时候,这个类如果已经存在虚拟机之中,直接返回,就不再找了。
上面讲【Class的世界】错误的地方可以改为:

vm5.png

另外可以看出,双亲委派模型给出了类的几个作用域的不同的权限等级:

vm4.png

在C2的实现严格遵守
双亲委派模型的情况下,C2加载A类(因为已找到,不再加载)。

因为这样的加载机制,按照全限定名去加载的时候,

  • JDK lib Domain中的类具有最高优先级,
  • JDK lib ext Domain的类具有第二优先级,
  • classpath Domain的类具有第三优先级
  • 其他Domain(网络,代码生成)等产生的类需要用户自定义编写的ClassLoader去加载。

Object 类是哪个Classloader加载的?

Object 类是哪个Classloader加载的?这个问题已经很好回答了。

根据类加载的时机、类加载的委派模型,我们知道事情发生的步骤如下:

  • 虚拟机启动时,Main是执行的主类,虚拟机会先初始化Main。
  • 根据初始化原则,会先初始化父类Object。
  • Application Classloader 通过parent委派模型给Extension Classloader。
  • Extension Classloader 委派给BootStrap Classloaderloader。
  • BootStrap Classloaderloader加载Object类对象(因为Object的定义在rt.jar里)。
  • Applciation Classloader继续加载Main类,先委派给parent,但parent都没找到(往上层递归),最后Applciation Classloader自己在classpath找到这个Main并加载Main对象。
    vm2.png

Classloader隔离和可见性问题

上面讲【Class的世界】的那个图,在符合parent委派规则的场景下,那个图是不正确的。

但是如果不是遵守这个机制,还是可以存在的。只是如果JVM里面有两个名字相同的类对象怎么办呢?

vm6.png
这里做了一个小实验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
java复制代码package com.ywz;
public class ClassLoaderTest {
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
ClassLoader myloader=new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String filename = name.substring(name.lastIndexOf(".") + 1) + ".class";
// 预备加载的类:ClassLoaderTest.class
InputStream stream = getClass().getResourceAsStream(filename);
if (stream == null) {
return super.loadClass(name);
} else {
byte[] bytes = new byte[stream.available()];
stream.read(bytes);
return defineClass(name, bytes, 0, bytes.length);
}
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
Class loadClass = myloader.loadClass("com.ywz.ClassLoaderTest");
//类对象是否相等
System.out.println("class equal ? " + loadClass.equals(com.ywz.ClassLoaderTest.class));
//类对象的名字是否相等
System.out.println("class name equal ? " + loadClass.getName().equals(com.ywz.ClassLoaderTest.class.getName()));
//输出类对象的ClassLoader
System.out.println("the classLoader = " + loadClass.getClassLoader());
System.out.println("default classLoader = " + com.ywz.ClassLoaderTest.class.getClassLoader());
}
}

获得的结果

1
2
3
4
java复制代码class equal ? false
class name equal ? true
the classLoader = com.ywz.ClassLoaderTest$1@d716361
default classLoader = sun.misc.Launcher$AppClassLoader@18b4aac2

也就是说Class对Classloader具有可见性问题,
Classloader只能看到自己和parent加载的类,其他的类加载器加载的类对象它看不到。

类的可见性可以总结为一句话:一个类对象只对它的类加载器和类加载器的子孙可见

上面的示例代码其实就是下图:

VM7.png

基本意思就是:

  • App ClassLoader先加载ClassLoaderTest类
  • myClassLoader也加载ClassLoaderTest类(破坏了委托模型)

所以虽然现在系统里面有两个a.b.ClassLoaderTest Class对象了,
但是系统的默认类加载器是AppClassLoader。因为main的类是AppClassLoader加载的,
所以调用com.ywz.ClassLoaderTest获取到的是AppClassLoader加载的Class对象。

默认类加载器

JVM运行程序的默认类加载器是Application Classloader。为什么呢?
这要从JVM启动过程设置说起。

launcher.png

从下面的源码可以看到,这是Launcher初始化设置的。

1
2
3
4
5
6
7
8
9
10
java复制代码public Launcher() {
//返回默认的classloader
public ClassLoader getClassLoader() {
return this.loader;
}
try {//AppClassLoader就是默认的loader
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}

另外,当一个ClassLoader装载一个类时,除非显示地使用另一个ClassLoader,则该类所依赖及引用的类也由这个ClassLoader载入。简单点说,程序的入口使用的是什么类加载器,那么后面的类new出的对象也使用该类加载器,如下图:

invoke.png

再说说类加载的细节-数组和基本数据类型

在JAVA世界里一切都是对象,对象一定是属于某个类,
但数组和基本数据类型在JVM里面是特殊的类。

  • 非数组类的加载阶段可以使用系统提供的各层类加载器完成。也可以使用用户自定义的类加载器去完成,开发者可以通过定义自己的类加载器去控制字节流的获取方式。
  • 基本数据类型(int,char)等通过Bootstrap Classloader加载的。
  • 数组类是特殊的:数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的,虚拟机创建一个数组需要考虑分配多大的内存。

cl6.png
在上图中,

  • 数据类型的元素类是引用类型B,B是由类加载器C1加载创建的。要在数组类对象标记上加载B的ClassLoader C1。故而,虚拟机创建了Class对象(vector B[10],C1)
  • 非引用类型,比如int,元素类型的加载就是和引导类加载器关联。故而,虚拟机直接创建Class对象(vector int[10],引导类加载器)

开放的类加载机制

上面已经讲完了类加载的基本原理和事项,下面会开放式的谈一谈开放的类加载机制。

灵活的类加载

JVM为了给程序员提供便利,开放了类加载器,这是java语言的创新。

我们可以定义自己的classloader做一些很有用的事情,比如

  • 加载加密的字节码
  • 热加载:开发阶段可以监听变化的字节码,再使用自定义的类加载器重新加载这些类,最后使用反射重启main函数。

vm9.png
基本流程大致是这样,想要具体了解热加载的文章,可以看我写的另一篇深入源码解读SpringBoot热加载工具DevTools-类加载机制和基本流程

破坏parent继承模型

上面的图定义的myClassloader破坏了双亲继承,但这是在我的小世界里,无伤大雅。

双亲委派很好的解决了解决了各个类加载器的基础类的统一问题,但是如果基础类想要调用用户代码怎么办?
实际上,JVM的世界存在一些大规模破坏parent继承模型的案例,比如JNDI。

JNDI的代码由启动类加载器去加载(在JDK 1.3时放入rt.jar),但JNDI的目的是对资源进行集中管理和查找,它需要调用独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者的代码(这原本是APP ClassLoader应该做的事情。)Bootstrap ClassLoader却不能加载ClassPath下的代码,应该怎么办?

jndi1.png

可以看出,JNDI想加载使用classpath下的代码,但有两个问题:

  • 这会破坏双亲继承
  • Bootstrap ClassLoader做不到加载Classpath下的代码

为了解决第二个问题,JVM引入Thread Context ClassLoader

jvm4.png

线程会继承父线程的Context ClassLoader,如果在应用程序的全局范围内都没有设置过Context ClassLoader的话,
这个类加载器默认使用App ClassLoader。

这样的话,这个问题就可以这么解决了:

jndi3.png
JNDI使用contextClassLoader便可以加载到Classpath下的代码。

引入ContextClassLoader这样的一个设计,是走旁路曲线救国,但这破坏了parent继承模型。
但这也能解决问题。

参考文献

  • 深入理解JAVA虚拟机(第2版)

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%