从一次线上事故聊到类加载机制

作者爱说话

这周好像没有发生什么特别好玩的事,就说说今天写的文章吧。

今天这篇文章还是公司技术培训带来的灵感,给我们培训的大佬讲的还是很对的,关于类加载这样的基础知识不应该只停留在了解的阶段,还应该清清楚楚明明白白这个阶段到底干了什么。

下来后,又翻翻了周志明大佬写的《深入理解 Java虚拟机》,理解了一下双亲委派,类加载过程。每次都有新感觉,本篇文章中也有许多的素材来自这一本书,也推荐小伙伴有空可以看一看。

本期的文章后半部分可能会有点枯燥,但还是要坚持坚持看下去,毕竟我们没有劳力士。(向朱老师致敬)

想粗略了解一下虚拟机加载类过程的,可以直接翻到文末总结,也是个面试高频考点。

背景引入

王经理:林步动,你站小美那么近干嘛?

小林:我最近有点腰酸背痛,听网友说,美女身边站,病痛少一半。

王经理:美女眼前走,精神又抖擞?

从一次线上事故聊到类加载机制

小林:经理,行家啊。

王经理:咳咳咳咳,我什么都没说。

小林:经理,听说前前前前些日子,公司项目线上出问题啦?我听小美说是内存溢出了,是不是有这回事啊?

从一次线上事故聊到类加载机制

王经理:你小子,工作不努力,听八卦倒是有一手。

小林:嘿嘿,就是想多学点东西,经理,你可以给我们讲讲是怎么排查解决的吗?

王经理:安排

问题引入

王经理:那天风和日丽,我正在午睡,我梦见了战士上战场,竟然不认识将军…..

小林:经理,能不能进正题

王经理:咳咳,我正在做梦的时候,客户打了电话给我,客户反馈登录管理后台时页面报504错误,检查应用后发现应用存在内存溢出情况,应用处于假死状态,只能临时通过重启应用暂时性解决系统无法正常使用问题

从一次线上事故聊到类加载机制

小林:后来详细原因怎么排查的啊?

王经理:运维故障申报后,我们将生产环境所产生的 dump 文件拿下来进行了分析后发现造成该问题的原因为代码进行分页查询时使用了 ibatis 原生内存分页,很显然它的缺点是把结果集全部加载进缓存(如果查询是从100万条数据开始取100条,会把前100万条数据也加载进缓存),容易造成内存溢出,性能也很差,除非必要,一般不使用。按理框架层已对ibatis原生分页进行了扩展重写,使用了mysql原生limit分页,按理不会造成内存溢出这样的问题

从一次线上事故聊到类加载机制

小林:什么是重写啊?从一次线上事故聊到类加载机制王经理:重写是子类对父类的允许访问的方法的实现过程进行重新编写, 返回值和形参都不能改变。即外壳不变,核心重写!重写的好处在于子类可以根据需要,定义特定于自己的行为。也就是说子类能够根据需要实现父类的方法。

王经理:不过经过仔细排查,发现其实没有进行重写,事情的真相只有一个,由于历史原因,项目组的同学自己又写了一个与框架同名的 LimitSqlExecutor 类覆盖了框架本身的分页查询实现,但在重新 executeQuery() 方法时【定义参数类型与基类不匹配】,造成方法重写的假象,因此根本原因为压根未进行方法重写

从一次线上事故聊到类加载机制

小美:我看见了,两个 Connect 链接不同

王经理:对,这样就造成了执行的时候可能走的原生的 JDBC 的方法,造成了一种重写的假象

小林:OMG,删它

王经理:对,直接删除这个错误的 LimitSqlExecutor 类就可以啦

虚拟机类加载机制

王经理:步动,你说这个错误场景一定会发生吗?

小林:我觉得,可能吧,应该吧,不会一定发生。

王经理:ok,那你说说为什么不一定。

小林:(淦,我哪里知道,猜一个?)因为我们部署的容器的不同,可能是 Tomcat,可能是 Jetty,加载的顺序不同,就有可能导致其实有时候会用到正确的 LimitSqlExecutor 类,说到底就就是那个爸爸妈妈委派机制

王经理:你说的双亲委派吧?

小林:对对对,就是这样的吧?老爸老妈都不做家务,而是让自己的崽子去!

从一次线上事故聊到类加载机制

王经理:分析的有点道理,但是双亲委派理解有点错误,应该是爸爸妈妈做,崽子不做!

王经理:呸呸呸,我说的啥,Java类加载机制是我们应该掌握的基础性知识,今天我们还是来好好回顾下吧,我先提问你们一下,一个类从被加载到虚拟机到卸载出内存为止,它的整个生命周期是

小美:我知道,需要经历 加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 ->卸载,其中验证 -> 准备 -> 解析统称为连接

从一次线上事故聊到类加载机制

王经理坏笑一声:那你知道使用前那五个阶段每一步都具体做了什么吗?

小美:

1,加载

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问的入口

其中加载还区分为数组类与非数组类

(一)非数组类:

一个非数组类的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass()方法)

(二)数组类:对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)最终是要靠类加载器去创建

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式有虚拟机实现自行定义,虚拟机并未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较 特殊,它虽然是个对象,但在存放在方法区里),这个对象作为程序访问方法区中的这些类型数据的外部接口。

加载阶段与链接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,链接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于链接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

相对于类加载过程的其他阶段,加载阶段(准备地说,是加载阶段中获取类的二进制字节流的动作)是开发期可控性最强的阶段,因为加载阶段可以使用系统提供的类加载器(ClassLoader)来完成,也可以由用户自定义的类加载器完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式

2,验证

验证是链接阶段的第一步,这一步主要的目的是确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全。

验证阶段是非常重要的,这个阶段是否严谨,直接决定了 Java 虚拟机是否能承受住恶意代码的攻击从代码量和耗费的执行性能角度上讲,验证阶段的工作量在虚拟机加载的过程中占了非常大的比重

验证阶段主要包括四个检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。

文件格式校验 -> 主要是校验字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理 元数据校验 -> 对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求 字节码验证 -> 通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的,确保被校验的方法不会做出危害虚拟机安全的行为 符号引用验证 -> 查看该类是否缺少或者被禁止访问它依赖的某些外部类,方法,资源等

3,准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。特别注意:

  • 这个时候进行内存分配的仅包含类变量(被Static修饰的变量),则不包括实例变量(实例变量将会在对象实例化时随着对象一起体在Java堆中)。
  • 这里所说的初始值“通常情况”下是数据类型的零值。假设一个类变量定义为: public static int value = 12;那么变量value在准备阶段过后的初始值为0而不是12,因为这时候尚未开始执行任何java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值为123的动作将在初始化阶段才会被执行。
  • 初始值“通常情况”下是零值,但在“特殊情况”下:如果类字段的字段属性表中包含ConstantValue属性,那在准备阶段变量就会被初始化为ConstantValue属性所指定的值,即如果a变量定义变为publicfinal static int a = 1;,编译时javac会为a生成ConstantValue属性,准备阶段虚拟机就会根据ConstantValue的设置将a的值置为123

4,解析

解析阶段是虚拟机常量池内的符号引用替换为直接引用的过程。符号引用在Class文件格式中它以CONSTANT_Class_info、CONSTANT_Fieldref_info等类型的常量出现。

解析的动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行。

5,初始化

类的初始化阶段是类加载过程的最后一步,该阶段才真正开始执行类中定义的Java程序代码(或者说是字节码)。

王经理:小美很棒,回答的很到位,那我问步动一个问题,你知道什么是类加载器么

小林:经理,你太看不起我了,我当然知道,类加载器就是用来加载类的东西呗

王经理:emm,你这么说也没错。当时的 Java 设计团队有意把类加载阶段中的“通过一个类的全限定名获取描述该类的二进制字节流” 这个动作放到虚拟机外部实现,以便让应用程序自己决定获取所需的类,实现这个动作的代码就被叫做“类加载器” 

王经理:对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立在 Java 虚拟机中的唯一性,每一个类的加载器,都拥有一个独立的类名称空间

小美:经理,你的意思是不是,只有在两个类是由同一个类加载器加载的前提下才有意义,否则,即使两个类来源同一个 Class 文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那么,这两个类一定不相同。

王经理:对,你们知道类加载器有哪些么

小林:经理,我知道。站在 Java 虚拟机角度来说,只有两种不同的类加载器,一种是启动类加载器,使用 C++ 实现,是虚拟机自身的一部分。一种就是其他所有的类加载器,由 Java 实现,独立与虚拟机外,继承于 java.langClassLoader

从一次线上事故聊到类加载机制

小美:我举报,步动刚百度的

小林:小美,你怎么能这样对我呢?呜呜呜

王经理:步动百度的答案很正确啊,从另一方面来看,站在开发人员的角度来说,自 JDK 1.2以来,Java就一直保持着三层类加载器,双亲委派的类加载架构,就是酱紫的。

从一次线上事故聊到类加载机制

小林:经理,你别卖萌,好恶心

王经理:咳咳

小美:经理,是不是类加载器的类别就是酱紫的

从一次线上事故聊到类加载机制

小林:小美,卖萌好可爱啊 😊

王经理反手一个煤气罐砸向了步动

王经理:好了,清理干净了,我们继续讲讲这三种加载器有什么作用

1,应用程序加载器。这个类加载器由 sun.misc.Launcher$AppClassLoader 来实现。之所以应用程序加载器也叫做系统类加载器,是因为它是 ClassLoader 类中的 getSystemClassLoader() 方法的返回值,所以有些场合中,它也叫做系统类加载器,他负责加载用户类路径上所有的类库,开发者一般可以直接在代码中使用这个类加载器,如果爱动的程序员没有自定义自己的类加载器,这个就是程序中默认的加载器

2,扩展类加载器。这个类加载器由sun.misc.Launcher$ExtClassloader 来实现,它负责加载 <JAVA/HOME>libext 目录中,或者被 java.ext.dirs 系统变量指定的路径中所有的类库。字如其人,名如其器,这是一种 Java 系统类库的扩展机制

3,启动类加载器。这个类负责加载存放在 <JAVA/HOME>lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的,可被 Java 虚拟机识别(按照名字识别,诸如 rt.jar ,tools.jar 像 maer.jar 就是不能被识别的)的类库加载到虚拟机内存中。

受伤的小林从地上爬了起来:经理你讲的好枯燥,我都快睡着了

王经理:哦

从一次线上事故聊到类加载机制

王经理拍了拍手:小美,你有什么问题吗?

小美:经理,我结合你给我讲的,是不是这种双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器

王经理:是的,不过这边要注意的是,加载器的父子关系一般不是以继承关系来实现的,通常是使用组合关系来复用父加载器的代码。

小美:好的,我明白了,经理你能和我说说双亲委派模型的工作过程吗?

王经理:对,这也是个重点,拿起小本本记起来

王经理:如果一个类加载器收到了类加载的请求,因为它懒,他 首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(也就是在自己搜索范围实在找不到所需要的类)时,子加载器才会尝试自己去完成加载

小美:哦哦,使用了双亲委派,Java 中的类随着它的加载器一起都有了一种优先层级关系耶

王经理:对的,就比如类 java.lang.Object 它存在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都能够保证是同一个类,如果没有双亲委派模型,一旦有调皮的程序员写了个同路径同名类,那 Java 不是得被搞晕了。

小美:经理,这么复杂的双亲委派模型,实现起来是不是也很复杂。

王经理:有一说一,还是挺简单的,就十几行,全部集中在 java.lang.ClassLoader的loadClass() 中

protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    //1 首先检查类是否被加载
    Class c = findLoadedClass(name);
    if (c == null) {
        try {
            if (parent != null) {
             //2 没有则调用父类加载器的loadClass()方法;
                c = parent.loadClass(name, false);
            } else {
            //3 若父类加载器为空,则默认使用启动类加载器作为父加载器;
                c = findBootstrapClass0(name);
            }
        } catch (ClassNotFoundException e) {
           //4 若父类加载失败,抛出ClassNotFoundException 异常后
            c = findClass(name);
        }
    }
    if (resolve) {
        //5 再调用自己的findClass() 方法。
        resolveClass(c);
    }
    return c;
}

小林又从地上爬了起来,给我机会经理,我看得懂:

1)首先检查类是否被加载,没有则调用父类加载器的loadClass()方法;

2)若父类加载器为空,则默认使用启动类加载器作为父加载器;

3)若父类加载失败,抛出ClassNotFoundException 异常后,再调用自己的findClass() 方法。

王经理;还行,小林你放仿佛吸收了煤气的精华,聪明了不少。

小美:经理我之前了解过双亲委派模型不是一个强有力的模型,而是 Java 设计者推荐给我们的,是偷偷放在我们“购物车”的,建议我们去用这种类实现方式,那么是不是意味着可以被强制破坏掉这种模型。

王经理:对的,小美,在很远很远的历史上,的的确确被破坏过,还不止一次,我来数一数,一共三次

1.第一次破坏 由于双亲委派模型是在 JDK1.2 之后才被引入的,而类加载器和抽象类 java.lang.ClassLoader 则在 JDK1.0 时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java 设计者引入双亲委派模型时不得不做出一些妥协。在此之前,用户去继承 java.lang.ClassLoader 的唯一目的就是为了重写 loadClass() 方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法 loadClassInternal() ,而这个方法唯一逻辑就是去调用自己的loadClass()

2.第二次破坏 双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的同一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API,但世事往往没有绝对的完美。

如果基础类又要调用回用户的代码,那该么办?一个典型的例子就是JNDI服务,JNDI现在已经是Java的标准服务, 它的代码由启动类加载器去加载(在JDK1.3时放进去的rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者的代码,但启动类加载器不可能“认识”这些代码。

为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。

3.第三次破坏 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求导致的,这里所说的“动态性”指的是:代码热替换、模块热部署等,简答的说就是机器不用重启,只要部署上就能用。OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi幻境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当受到类加载请求时,OSGi将按照下面的顺序进行类搜索:

1)将java.*开头的类委派给父类加载器加载。

2)否则,将委派列表名单内的类委派给父类加载器加载。

3)否则,将Import列表中的类委派给Export这个类的Bundle的类加载器加载。

4)否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。

5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。

6)否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。

7)否则,类加载器失败。

上面的查找顺序就开头亮点符合模型规则,其余的类查找都在平级的类加载器中。

总结一下(面试高频考点)

1,类的加载阶段

加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 ->卸载,其中验证 -> 准备 -> 解析统称为连接

2,类加载器

2.1,应用程序加载器。这个类加载器由 sun.misc.Launcher$AppClassLoader 来实现。之所以应用程序加载器也叫做系统类加载器,是因为它是 ClassLoader 类中的 getSystemClassLoader() 方法的返回值,所以有些场合中,它也叫做系统类加载器,他负责加载用户类路径上所有的类库,开发者一般可以直接在代码中使用这个类加载器,如果爱动的程序员没有自定义自己的类加载器,这个就是程序中默认的加载器

2.2,扩展类加载器。这个类加载器由sun.misc.Launcher$ExtClassloader 来实现,它负责加载 <JAVA/HOME>libext 目录中,或者被 java.ext.dirs 系统变量指定的路径中所有的类库。字如其人,名如其器,这是一种 Java 系统类库的扩展机制

2.3,启动类加载器。这个类负责加载存放在 <JAVA/HOME>lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的,可被 Java 虚拟机识别(按照名字识别,诸如 rt.jar ,tools.jar 像 maer.jar 就是不能被识别的)的类库加载到虚拟机内存中。

3,双亲委派工作过程

如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(也就是在自己搜索范围实在找不到所需要的类)时,子加载器才会尝试自己去完成加载


从一次线上事故聊到类加载机制

你的 Git 还在用小乌龟?


从一次线上事故聊到类加载机制

不会吧,不会吧?MySQL 索引最佳实践你都不看看


从一次线上事故聊到类加载机制

3分钟为女同事解决Maven依赖冲突,不香吗?



从一次线上事故聊到类加载机制


原文始发于微信公众号(Issues):从一次线上事故聊到类加载机制

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/107259.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!