JVM 是如何加载 Java 类的?

看到这个题目的时候,你可能就会觉得,阿粉,这不是挺简单的一个问题么

如何加载?不就是 加载,链接,初始化 这三步嘛,说白了不就是类加载过程么

那么,你知道这三步具体又做了什么嘛?这就是本篇文章想要写的

加载

加载的过程,就是查找字节流,并根据查找到的字节流来创建类的一个过程

Java 语言的类型可以分成两大类:基本类型和引用类型。基本类型就是由 JVM 预先定义好的,所以也就没有查找字节流这一说了

对于引用类型来说的话,又可以细分为四种:类,接口,数组类和泛型参数。因为泛型参数在编译过程中会被擦除,所以在 JVM 中就只有前三种。而数组类又是由 JVM 直接生成的,所以查找字节流的话,就只有类和接口了。

那么 JVM 是怎么查找字节流的呢?如果你对这块内容比较熟的话,应该就能想起来类加载器,它主要有四类: 启动类加载器,扩展类加载器,应用程序类加载器和用户自定义类加载器

这块又有个知识点就是双亲委派机制:大概就是如果一个类加载器收到了类加载的请求,首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成。通过双亲委派机制就能保证同样一个类只被加载一次

经过类加载器之后,这个类就算是加载进来了

链接

对链接过程而言, jvm 实现具有灵活性,但必须保留下列属性:

1、在链接之前,类或者接口必须已经被完全加载;

2、在初始化之前,类或者接口必须已经被完全验证和准备;

3、链接过程中检测到的程序错误会抛出到程序中某个位置,在该位置上,程序将采取某些操作,这些操作可能会直接或间接地链接到类或者接口所涉及到的类或者接口。

链接这块又分为三部分:验证,准备,解析

验证阶段就是想要看看 class 文件的前 8 位是不是 java 标识符,想看看符不符合规范什么的

准备阶段就是给静态字段分配内存。除了分配内存之外,部分 JVM 还会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表,这个方法表是用来解决动态绑定的问题的,解析时通过这个方法表,根据实际类型来解析获取对应的方法。

在 class 文件被加载到 JVM 之前,这个类没办法知道其他类和方法,字段所对应的具体地址,甚至都不知道自己的方法,字段的地址,所以如果需要引用这些成员时, Java 编译器就会生成一个符号引用,在运行阶段,这个符号引用一般都可以准确的定位到具体目标上

解析阶段主要就是将符号引用解析成实际引用。如果符号引用指向一个未被加载的类,或者没有被加载类的字段或方法,此时解析阶段就会触发这个类的加载(但不一定会触发这个类的链接以及初始化)

在解析阶段,不同的 JVM 有不同的解析策略,例如:

1
2
3
4
5
public class A {
  public void main(String args[]) {
    B b = null;
  }
}

策略 1 :链接 A 的时候发现引用了 B,因此加载 B

策略 2 :链接 A 的时候发现引用了 B,但是 B 没有被使用,所以暂时不加载 B。在真正使用 B 的时候才进行加载,比如 b = new B();

所以在一些 JVM 实现中,可能采取在使用时才会解析类或接口中的符号引用,或采取在该类或者接口被验证时一次性解析全部符号引用。这取决于采用的是哪种策略,也意味着解析过程可能在类或者接口被初始化后还会进行

初始化

在 Java 代码中,如果想要初始化一个静态字段,可以在声明的时候直接赋值,也可以选择在静态代码块中对它赋值

如果直接赋值的静态字段被 final 修饰了,而且这个静态字段是基本类型或者字符串时,就会被 Java 编译器标记成常量值,初始化就直接被 JVM 完成了。除此之外的直接赋值操作,还有所有静态代码块中的代码,就会被 Java 编译器放到同一个方法中,并且把它命名为 <clinit>

类加载的最后一步就是初始化,就是给标记为常量值的字段赋值,执行 <clinit> 方法的过程。这个时候 JVM 会通过加锁来确保类的 <clinit> 方法只被执行一次

<clinit> 方法可厉害了,因为:

  • <clinit> 方法与类的构造函数不同,它不需要显示的调用父类的 <clinit> 方法,虚拟机会保证在子类的 <clinit> 方法执行之前,父类的 <clinit> 方法已经执行完毕。因此在虚拟机中第一个被执行的 <clinit> 方法的类肯定是 java.lang.Object

  • <clinit> 方法对于类或接口来说不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit> 方法。

  • 接口中不能使用静态初始化块,但是仍有 static 变量的赋值操作,所以也会有 <clinit> 方法,但是接口执行 <clinit> 方法不需要先执行父接口的 <clinit> 方法。只有当父接口中定义的变量被使用到时,才会执行 <clinit> 方法。

  • 虚拟机会保证一个类的 <clinit> 方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit> 方法,其它线程都需要阻塞等待

<clinit> 方法执行之后, JVM 才算成功的加载了 Java 类

类的初始化何时会被触发?

那么,类的初始化什么时候会被触发呢?

JVM 规范列举了以下几种触发情况:

1 , 当虚拟机启动时,初始化用户指定的主类;

2 ,当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;

3 ,当遇到调用静态方法的指令时,初始化该静态方法所在的类;

4 ,当遇到访问静态字段的指令时,初始化该静态字段所在的类;

5 ,子类的初始化会触发父类的初始化;

6 ,如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;

7 ,使用反射 API 对某个类进行反射调用时,初始化这个类;

8 ,当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类

再谈 双亲委派机制

在上面类加载机制那块,提了一下双亲委派机制

我觉得之所以有这样的机制,就是为了避免资源的浪费。上面的双亲委派机制我们在现实中也可以找到例子,比如说:公司部门有位程序员 A 发现如果做一个数据系统的话,来把公司各部门的数据打通,这样就可以减少很多交流成本,那么他可能就会和老大去说,申请去做这个系统,老大一看,这个方案完全可以抽成公共的呀,就自己去写了(父类加载公共方法),也可能老大一看,你就自己去写吧(父类不加载时,子类再进行加载),更巧的是,程序员 B 也发现了,他也去找老大说,这个时候老大会说什么呢?这个事情 A 去做了,就不用太担心了

那如果程序员 A 和 B 发现了之后没有和老大交流,都自己闷头去做了,这样的话,同样的系统做了两遍,还浪费了两个人的时间精力,由此造成的资源浪费太大了

我觉得双亲委派的机制类似于这样,因为这个机制的存在,让资源浪费的现象大大减少了

但是 tomcat 打破了这种机制,这怎么说?

我们都知道 tomcat 是个 web 容器,那么它应该:

  • 支持部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,就比如两个应用程序,其中一个依赖的是一个类库的 v1.0 ,另外一个依赖的是同样一个类库的 v2.0 ,那么 tomcat 是不是应该允许这个类库的 1.0 和 2.0 版本都存在?

  • 部署在同一个 web 容器中相同的类库相同的版本是应该可以共享的。就比如,服务器上有 100 个应用程序,这些程序依赖的都是相同的类库,那 tomcat 总不能把这 100 份相同的类库都加载到虚拟机里面去吧,要是非要加载进去,那服务器不得分分钟炸了

  • web 容器需要支持 jsp 文件的修改,也就是说,当程序运行之后,我对 jsp 文件进行了修改,那么 tomcat 是不是也应该支持?如果不支持的话,那我修改一次就不能用了,不合适吧?

基于上面三点,就能看到 tomcat 其实是打破了双亲委派机制的

比如第一个问题,第三方类库就是同样一个资源,在双亲委派机制中,同样一个资源是不应该加载两次的,但是在 tomcat 里面却被允许了;但是第二个问题好像又在说双亲委派的机制,正是因为双亲委派机制的存在,所以第二个问题就不是问题了嘛;第三个问题又打破了双亲委派机制,因为如果不打破的话,原来的 jsp 文件已经加载进来了,现在对它进行了修改,那么应该还会加载原来的 jsp 文件,这样的话修改岂不是无效了?

所以, tomcat 打破了双亲委派机制,但并不是完全打破

至于 tomcat 打破双亲委派的机制,阿粉还没搞懂,等阿粉搞懂了再来写吧

或者你搞懂了嘛?给阿粉讲讲好不好~

参考: 极客时间 – 深入拆解 Java 虚拟机

Java Geek Tech wechat
欢迎订阅 Java 极客技术,这里分享关于 Java 的一切。