深入理解Java类加载机制,再也不用死记硬背了

Posted 编程一生

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入理解Java类加载机制,再也不用死记硬背了相关的知识,希望对你有一定的参考价值。

谈谈“会”的三个层次

在《说透分布式事务》中,我举例里说明了会与会的差别。对一门语言的学习,这里谈谈我理解的“会”的三个层次:

第一层:了解这门语言的语法、写法,我把它叫做 hello world 级别;

第二层:了解这门语言的优劣势以及它的生态,了解这门语言的能力范围,我把它叫做 应用 级别;

第三层:了解这门语言的底层运行机制,这有利于对程序进行调优,以及当程序遇到了比较罕见的问题时能够从根上分析解决它。我把它叫做 掌握 级别。

在简历上写掌握某种语言的,一般面试官也会问一些很深入原理的问题,个人认为比较合理。自己作为一个15年一线Java开发,自认为有资格把掌握Java写在简历上。今天,我就来聊聊我对双亲委派机制一些理解。

插个题外话,在《高并发下秒杀商品,你必须知道的9个细节》中,有朋友问配图是用什么画的。这里介绍一下自己的经验:

1)思维导图还是processon更加方便:

https://www.processon.com/i/594d313ae4b08b003f2ec84a

2)流程图还是draw.io,这个不推荐在线编辑,慢到怀疑人生。安装版本也是免费的,官网可轻松下载。开头图的框图效果是draw.io的框图有个 Sketch 样式。这个样式很好看,但是不建议用于文献等正式场合。正式场合的图最好方方正正,不要太圆润,粗细均匀。

3)生成曲线图、柱状图这些,还是习惯用excel。

Java类加载机制

首先我们需要思考一件事,我们编写的Java代码,是如何在各种各样的操作系统上运行起来的。

Java文件通过Javac编译成class文件,这种中间码被称为字节码。然后由JVM加载字节码。这个过程就称为类加载。


运行时,由解释器将字节码解释为一行行的机器码来执行。在程序运行期间,即时编译器会针对热点代码,将该部分字节码编译成机器码以获取更高的执行效率。在整个运行时,解释器和即时编译器相互配合,使程序几乎能达到和编译型语言几乎一样的执行速度。这个部分交给专业的编译器开发人员来做,咱们本篇不做深入讲解。

到此上面那张图就讲完了,不要问我右上角那两个表情是怎么回事。就是发现编辑的时候竟然可以添加表情,觉得好玩就试试看。

类的生命周期

在详细讲解之前,我们明确一下类加载流程的目的。站在高处去看,就是把一份被javac编译过的文件通过加载,生成某种形式的class文件的数据结构送进内存。程序可以调用这个数据结构来构造出Java对象。这个过程是在运行时进行的,也是Java动态拓展性的根基。

上面这张图表现了类的整个生命周期。而类加载呢,只包含了加载、链接和初始化三个阶段。加载只是类加载的第一个环节,两者要注意区分。解析部分是灵活的,它可以在初始化环节之前或者之后进行,实现后期绑定。类加载的其他环节的顺序是不可改变的。

加载

加载是一个读取class文件,将其转化为某种静态数据结构而存储在方法区内,并在堆中生成一个便于用户调用的Java对象的过程。


这里值得注意的是,这个Java文件不一定是本地文件,泛指各种来源的二进制流,比如网络、数据库或者比如动态代理技术这样的即时生成的class文件。

验证

验证的步骤很多,上面的图画得不完全准确。对文件格式的校验其实是发生在加载阶段的。通过才能顺利加载。顺利加载并不代表JVM完全认可了这个类,还要进行语法和语义上的分析,保证这个类不会危害JVM,这是对元数据和字节码上的验证。在解析阶段,还会进行符号引用的验证。随着JVM版本的升高,验证过程也在被不断丰富。

准备

准备就是为静态变量赋初始值,注意这里的初始值是JVM默认初始值,是固定的,不是咱们写代码时的那个初始值。这里有个比较容易混淆的概念。

Java内存规范定义了方法区这种抽象概念。主流的JVM实现如HotSpot在JDK8之前使用永久代这种在堆中开辟专门空间的实现方式,JDK8之后使用元空间这种直接内存取代。

HotSpot的实现:类的元信息、常量池、静态变量等都存在在JDK8之前都存在在永久代这种方法区的具体实现中。JDK8之后,常量池、静态变量被从方法区移除,转移到了堆中。元信息这些依然保留在方法区,具体的存储方式改成了元空间。

解析

解析是将符号引用替换为直接引用。

静态解析

符号引用就是假如类A引用了类B,加载阶段是静态解析,这时候B还没有被放到JVM内存中,这时候A引用的只是代表B的符号,这是符号引用。

直接引用就是类A在解析阶段发现自己引用了B,如果这个时候B还没被加载。就是直接触发B的类加载,之后B的符号引用会被替换成实际地址。这被称为直接引用。

动态解析

本文类的生命周期部分引出了后期绑定这个概念。后期绑定其实就是动态解析。如果代码使用了多态。B是一个抽象类或者接口,A就不能知道究竟要用哪个来替换,只能等到实际发生调动时在进行实际地址的替换。这就是为什么有的解析发生在初始化之后。

总结

类加载的过程今天就讲这些。咱们来回顾一下类加载的五个阶段。

从JVM的角度看,加载的读取二进制流和初始化阶段,是开放了主导权给用户的。用户可以使用动态代理等手段选择是否这个阶段进行加载。还可以使用多态的手段选择是否在这个阶段进行初始化。而剩下的所有部分都是JVM内部完成的。

此时你可以闭上眼睛回顾一下类加载的五个阶段,是不是不用死记硬背也能了然于胸了。

内容参考:

https://www.bilibili.com/video/BV14U4y1L75q

五个经典的破坏双亲委派场景,Java被啪啪打脸

《深入理解Java类加载机制,再也不用死记硬背了》这篇文章中提到,从JVM的角度看,加载的读取二进制流和初始化阶段,是开放了主导权给用户的。而剩下的所有部分都是JVM内部完成的。


那为什么要这样做呢?这是符合面向对象中的开闭原则和封装思想的设计。JVM将类加载内部复杂的实现封装了起来,拒绝开发者修改。只提供了一个拓展接口,用于二进制流的读取。

流程上搞懂了,那JVM是怎样使用代码来实现这些步骤的呢?这就要聊到Java的类加载器了。

类加载器

类加载器的分类是Java规范,属于抽象的概念。规范将类加载器分成启动类和非启动类两大类。

HotSpot对类加载器实现有以下分类(以下描述中省略了HotSpot定语):

启动类加载器是C/C++语言实现的,无法作为对象被程序引用。主要用来加载Java的核心类库。类库主要由Java启动参数指定,默认是$JAVA_HOME/lib。HotSpot还会对类名进行白名单校验来提高安全性。

非启动类加载器都是使用Java来实现,继承自java.lang.ClassLoader,可以作为类对象被程序引用。它也分成3种,简单说一下区别。

扩展类加载器ExtensionClassLoader加载的是$JAVA_HOME/lib/ext下或者启动时指定的类库。它目标是加载Java类库的扩展,是标准类库的补充。

应用类加载器ApplicationClassLoader加载的是环境变量classpath指定的类库。咱们平时使用maven的话,就是使用maven加载的类库和自己编写的代码。

用户类加载器就是用户自己定义的类加载器,也叫自定义类加载器。只要继承于应用类加载器即可。定义不同的类加载器会使用不同的命名空间。因为不同的命名空间是个隐含的限定名区分,是不同的对象。


双亲委派模型

双亲委派的目标是在默认情况下,一个限定名的类只会被一个类加载器加载并解析使用,这样在程序中,它就是唯一的,不会产生歧义。

在被动的情况下,当一个类加载器收到类加载请求,它不会首先自己去加载。而是传递给父加载器。这样,所有的类首先都会先由最上层的启动类加载器进行加载,只有父加载器无法完成类加载才会由子加载器完成。


白话来说:双亲委派模型中,如果类A调用了类B,那类B可能是类A在使用过程中被动加载进来的。那如果类A是用应用程序类加载器加载的,那么类B只能由应用程序类加载器或其父类或者上一层父类来加载。不能由自定义类加载器来来加载。

双亲委派模型并不是一个拥有强约束力的模型。它存在设计缺陷,在一些情况下可以被主动破坏。


五种经典的破坏双亲委派场景


第一次破坏


在《深入理解Java虚拟机》这本书中,记录了怎样破坏双亲委派:因为双亲委派机制原则在java.lang.ClassLoader的loadClass方法中。只要重写loadClass方法就可以破坏。书中还写了一个重写loadClass方法来进行破坏的小样例,这个小样例被称为双亲委派的第一次破坏。


Tomcat场景

这个破坏的样例有没有什么实际价值意义呢?还真有,后来Tomcat就使用这种方式对双亲委派进行破坏,来达到使用一个web容器部署两个或者多个应用程序,不同的应用程序,可能会依赖同一个第三方类库的不同版本,还要能保证每一个应用程序的类库都是独立、相互隔离的效果。


tomcat自定义了类加载器,重写loadClass方法使其优先加载自己目录下的class文件,来达到class私有的效果。不过咱们现在流行使用的都是嵌入式的web容器了,将来更多的场景还是一个应用程序使用一个单独的web容器。所以这种破坏双亲委派的价值在降低。


基于SPI的三种经典破坏场景


还有一种java特性叫做SPI,在《JAVA SPI(Service Provider Interface)原理、设计及源码解析》里,我不仅说了什么是SPI,还提到了三种经典场景都在使用,它们分别是jdbc、Dubbo、Eleasticsearch。没错,这三种经典场景通过SPI都使得双亲委派遭到破坏。下面以jdbc为例做说明。

jdbc驱动Driver在十几年前,我还手写过用DriverManager来加载的代码。

Class.forName("com.mysql.cj.jdbc.Driver");

Connection conn= DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "root", "1234");

DriverManager初始化时是这样的,注意红框内容。

红框内容翻译一下就是说:由启动类加载器加载的DriverManager初始化时要去加载Driver驱动。

jdbc驱动由Serverloader.load加载。


Serverloader.load里优先使用当前线程的类加载器而不是自身使用的类加载器来加载Driver。当前线程是使用方要么是应用类加载器要么是自定义类加载器,总归类A调用类B,B没有使用父类而使用了子类加载器,所以破坏了双亲委派。


双亲委派被破坏的补救措施


那朋友就问了,java.lang.ClassLoader把loadClass方法定义为final是不是就解决了双亲委派被破坏的问题呢?java.lang.ClassLoader的loadClass方法在Java很早的版本就有了,而双亲委派模型是在JDK1.2中引入的。Java是向下兼容的,所以不是不想改而是改不了了。一个补救措施是推荐使用findClass方法而不是直接重写loadClass。当然了,如果有人登录了服务器,把JDK文件给替换了,也就失效了。这个不在讨论范围。



如果文章对你有帮助,请点【在看

如果文章你喜欢,请点【

如果文章既没有帮助又不喜欢,或者有其他建议,欢迎【留言或者直接加我微信 brmayi 反馈。


反馈是你的思考,我的成长,我们共同的进步源泉~

以上是关于深入理解Java类加载机制,再也不用死记硬背了的主要内容,如果未能解决你的问题,请参考以下文章

深入理解Java虚拟机类加载机制

《深入理解java虚拟机》笔记类的加载机制

深入理解JVM虚拟机6:深入理解JVM类加载机制

深入理解Java虚拟机——类加载机制

深入理解java虚拟机--类加载机制

深入理解JVM-Java垃圾回收机制GC