Java有根儿:Class文件以及类加载器

2022-05-30 09:30:01 浏览数 (1)

JVM 是Java的基石,Java从业者需要了解。然而相比JavaSE来讲,不了解JVM的一般来说也不会影响到工作,但是对于有调优需求或者系统架构师的岗位来说,JVM非常重要。JVM不是一个新的知识,网上文章很多,本篇的不同之处在于参考一手资料、内容经过反复推敲、思维逻辑更加连贯、知识更加系统化、研究路线采取按图索骥的方式。本文将会有筛选地研究JVM的精华部分,至少达到准系统架构师够用的程度。本篇主要分享学习Java Class文件以及类加载器CLassLoader的知识。以下是一些说明: ①由于篇幅有限,默认一些基础背景知识已经达成了共识,不会赘述。 ②本文重点研究JVM的抽象标准(或者理解为一套接口),至于实现的内容不是本文的重点学习对象。 (那么实现的内容包括哪些呢?例如像运行时数据区的内存排布、垃圾收集算法的使用,以及任何基于JVM指令集的内在优化等。这其中关于GC的部分是我们都比较热衷的,将会额外开一篇进行学习。) ③本文不会介绍Java不同版本的区别或特性升级,仅以目前工作中用到最多的java 8为学习材料。 ④本文不会重点介绍javaSE的内容。 ⑤class文件的编译过程(*.java =javac=> *.class)可能不会包含在本文中。 最后补充一下,文章题目“Java有根儿”的由来及含义:“有根儿”通常指胸有成竹、有底气、有靠山、自信的来源。这里通过这种比较戏谑的词语表达了Class文件以及类加载器对于Java的一个重要地位关系,同时也突出了娱乐时代,学习也是从兴趣出发的一种心态,学习也是娱乐的一种 ^ ^。 关键字:JVM、Java、Class、字节码、BootstrapClassLoader、ClassLoader、双亲委派机制、热部署

JVM前置知识

  1. JVM是Java的基石,但不限于Java语言使用,任何能够生成class文件的语言皆可使用。 实际上,JVM对Java语言一无所知,它只认识class文件,通过ClassLoader来加载,这是一种JVM特定的二进制文件,该文件包含了JVM指令、符号表以及一些附加信息。
  2. JVM是一个抽象计算机,有自己的指令集以及运行时内存操作区。
  3. JVM包括解释器和JIT编译器以及执行引擎,一般采用混合模式。编译器会针对不同操作系统直接生成可执行文件,而解释器是在运行时边解释边执行。一般调用次数较多的类库或程序会直接编译成本地代码,提高效率。 编译器和解释器在其他语言也有广泛的运用,总之活是一样多,看你先干还是后干,各有利弊。纯编译器语言编译的时候就慢但执行快,纯解释器语言编译是很快,但执行稍慢。
  4. JVM对主流的不同操作系统都做了支持,JVM之上的语言层面不需要考虑操作系统的异构,继而实现了语言的跨平台。
  5. JRE包括JVM和JavaSE核心类库。而JDK包括JRE和开发工具,包括核心类库源码等。一般作为开发者需要JDK,而运行Java程序只需要JRE即可。

1.class文件

class文件是JVM的输入,内容是已编译的代码,它是一种跨硬件和跨操作系统的二进制格式。class文件可以准确定义类和接口,以及他们内部的针对不同平台分配的内存字节表示。下面我们看一下一个class文件的16进制内容。

图1-A Class文件字节码

图1-A是通过IDEA的BinEd插件,查看到的一个最简单的类编译出来的class文件的16进制内容,这个类源码如下:

代码语言:javascript复制
package com.evswards.jvm;
public class Test001 {}

由此我们能获得一些信息:

  1. 每个字节由两个16进制数构成,每个16进制数我们知道是4位(bit),那么一个字节就是8位。class文件的最小描述单位就是8位的一个字节,表现为16进制就是2个16进制数,所以图中每两个数要组合在一起不可分割。
  2. 按照每2个16进制数为最小单位来看,class文件的16进制格式有16列。图1-A中是使用1个16进制数来表示每列的标号,其实也可以用十进制,但是由于列数固定在16,16进制看起来比较方便。
  3. 行数依据源代码的内容大小而定,是不固定的。图1-A中仍旧是使用16进制表示,好处是除去最右一位,剩下的位数可作为行数,而若算上最右一位,可作为整体字节的个数。也相当于十进制的行数乘以列数的计算。

1.1 class文件结构

字段

占位(byte)

值(参照图1-A)

Decimal

解释

magic

4

0xCAFEBABE

不用记

与扩展名功能类似,但不可轻易修改

minor_version

2

0x0000

0

次版本号:不能低于该版本

major_version

2

0x0034

52

主版本号:即java 1.8,不能高于该版本

constant_pool_count

2

0x0010

16

常量池计数器长度为16

constant_pool

↑count-1

0x0A...626A656374

见1.2

∵从#1开始,#0引用留做他用了∴长度-1

access_flags

2

0x0021

不用记

类访问权限public

this_class

2

0x0002

2

本类索引:#2【去常量池中找第2个】

super_class

2

0x0003

3

父类索引:#3「constant_pool」

interfaces_count

2

0x0000

0

源码能看到就一个空类,没声明接口

interfaces

↑count

见1.3

∵长度为0∴为空,不占用字节

fields_count

2

0x0000

0

同样没声明字段

fields

↑count

见1.4

∵长度为0∴为空,不占用字节

methods_count

2

0x0001

1

有1个方法,是什么呢?

methods

2||↑count

0x0001...000A0000

见1.5

其实是默认加的空构造函数

attributes_count

2

0x0001

1

有1个属性信息

attributes

2||↑count

0x000B...0002000C

见1.6

记录值SourceFile:Test001.java

表1-1-A Class文件结构

class文件结构中共有16个字段,其中需要深研究的有常量池、接口、字段、方法、属性,后面逐一展开。

这里field和attribute有点容易混淆,多聊两句他们的区别: 1、先说class文件结构中的16个字段,这种表述的理由是将class文件看成一个结构体,它的内容分类就是表1-1-A中列出16行内容。其中fields这一行也是class文件结构的字段,但它也是class文件代表的类源码Test001.java中我们显示声明的Java语言层面的字段,例如:public String name;。 2、表1-1-A中的attributes这一行也是class文件结构的字段,但它同时也是class文件代表的类源码Test001.java文件的属性,例如文件名。

1.2 常量池

JVM对于类、接口、类实例,以及数组的引用并不是在运行时完成的,而是通过class文件中的常量池来表示。常量池是一个数组,每条记录都是由:

1、占用一个字节的常量池标签,例如CONSTANT_Methodref

2、对应的具体内容就是结尾加_info后缀,例如CONSTANT_Methodref_info

所组成。先贴一个常量池标签的对照表。

标签类型(前缀CONSTANT_)

值(十进制)

转换十六进制(1字节)

解释

Class

7

0x0007

Fieldref

9

0x09

字段引用

Methodref

10

0x0A

方法引用

Interfacemethodref

11

0x0B

接口方法引用

String

8

0x08

字符串

Integer

3

0x03

整型

Float

4

0x04

单精度浮点

Long

5

0x05

长整型

Double

6

0x06

双精度浮点

NameAndType

12

0x0C

名称类型

Utf8

1

0x01

utf8字符串

MethodHandle

15

0x0F

方法处理

MethodType

16

0x10

方法类型

InvokeDynamic

18

0x12

动态调用

表1-2-A 常量池标签对照表

至于表1-2-A为啥没有2、13、14、17,不需要知道。。。

下面,仍旧以图1-A为例,参照表1-2-A,我们去尝试解析表1-1-A中常量池的十六进制数据。首先先找正确答案,可通过IDEA的插件jclasslib Bytecode Viewer,分析class文件结构,其中常量池的部分如下图1-2-A所示。

图1-2-A 字节码视图插件

有了参考答案以后,我们去继续解析表1-1-A中常量池的部分,它的值是图1-A中的0x0A...626A656374部分。我们找到图1-A中对应的部分,然后从0x0A开始往下解析:

1、0x0A是一个常量池标签,对照表1-2-A,可以找到是CONSTANT_Methodref,它对应的具体内容是CONSTANT_Methodref_info。

2、通过官方JVM规范的4.4.2可查找到CONSTANT_Methodref_info。(把官方文档当做字典来查是正确的打开方式。)看一下它的结构:

代码语言:javascript复制
CONSTANT_Methodref_info {
       u1 tag;
       u2 class_index;
       u2 name_and_type_index;
   }

这是一个伪码,主要看结构中的字段,每个字段前是字节数,例如u1就是1个字节,按照这个规范再回去跟踪图1-A的字节码。

3、0x0A本身就是1字节的tag,再往后是2字节的class_index,即0x0003,这是一个类索引,指向#3号的常量池记录。

4、再往后是2字节的name_and_type_index,即0x000D,这是一个名字和描述符,也是一个引用,执行#13的常量池记录。

到此常量池的第一条记录就解析完了,我们去看一下正确答案图1-2-A的右侧部分的内容,正好是与上面的分析对应上,证明我们的解析是正确的。

Bytecode Viewer

上面我们按照JVM规范逐一解析了class文件的16进制内容,解析的结果得到了验证。JVM规范的本质就是在描述这件事,告知大家它是如何设定不同的区域所对应的字节码,如何通过这些字节码的规范去表示类、方法、字段等等,由此可以支持非常复杂的信息化需求。其实就是一本翻译书,我说”hello“,它告诉我是”打招呼,你好“的意思。前面验证字节码的方式是通过IDEA的插件jclass Bytecode Viewer,那么接下来就不用再费劲去比对十六进制了,直接通过插件来查看即可。接下来继续分析。

1、前面分析到常量池的第一条记录,表示的是方法引用,其中类名是#3,名字描述符是#13。首先看#3,在插件视图中也可以直接点击,跳转过去更加方便。由于篇幅有限,这里就不粘贴了,直接文字描述。

2、#3号常量池记录是CONSTANT_Class_info,说明是类信息,它的值指向了#15。

3、#15号常量池记录是CONSTANT_Utf8_info,说明是utf8字符串,长度是16,值是字面量:java/lang/Object。

4、回到1,我们已经知道了类名,继续去查#13,#13是名字和描述符,其中名字指向#4,描述符指向#5。

5、#4也是字符串,长度为6,值是<init>

6、#5是字符串,长度为3,值是()V,代表的是参数为空,返回值为void。

好,到此我们总结一下,这个过程列出来,我们这个类由于内容为空,默认会添加父类的空构造函数,即Object类的构造函数init(),返回值是void。另外,我们也能够发现,也不需要去跳转查看,相关类信息或者各种数据类型的值都会在插件中显示出来。这就更加方便了我们分析class文件的内容。我们在这个过程中已经把常量池中的一部分记录所覆盖到了,剩下的内容将在下面的接口、字段、方法以及属性中会被引用到。

1.3 接口

由于图1-A没有接口的内容,我新写一个接口,有了Bytecode Viewer插件,看起来比较方便了。

图1-3-A ①源码-②字节码-③字节码分析

图1-3-A显示几个信息:

1、①的部分是Test002的源码,②的部分是字节码,③的部分是字节码视图插件的显示。

2、直接看③的部分,有疑议的可以参照①和②的部分。可以看到接口、字段、方法、属性都比较齐全。那么下面的分析都将以此为例。

本小节是分析接口的部分,这里的接口指的是类源码中实现的接口,参照①的部分,这里实现了Cloneable接口。因此,可以在③的部分看到接口。接口项展开以后,有一条记录,引用了#4号常量池。#4号常量池记录是一个类信息,又指向了#19的字符串,最终显示java/lang/Cloneable。这里就不粘贴图片了,可自行查看。

1.4 字段

下面看字段的部分,还是通过查看③的区域,字段有一条记录,包括3个子项:

1、名字:指向#5常量池,对应的是一个字符串,值为<name>

2、描述符:指向#6常量池,对应的也是一个字符串<Ljava/lang/String>

3、访问标志:0x0002,是代表private的含义,与表1-1-A class文件结构中的access_flags的规则一致。

字段的部分要注意对于源码字段的类型(descriptor_index),是用常量池的字符串来表示,例如private int age;字段,也会在常量池中已utf8的方式存储字段的数据类型,这里是int,存为utf8的字面量是I,String对应的是Ljava.lang.String,所有引用类型都是L加全限定类名。其他的映射关系是:byte->B, char->C, double->D, float->F, long->J, short->S, boolean->Z。

1.5 方法

方法的部分在本例中仍旧是默认添加的构造函数,这个内容在常量池的部分介绍到了。这里再重申一下,方法有一条记录,包括3个子项:

1、名字:引用#7常量池,值为<init>

2、描述符:引用#8常量池,值为<()V>

3、访问标志:0x0001,为public。

而往下深入查看,会发现在方法记录中还有更深的层级,显示的是[0]code

方法代码

使用字节码视图查看插件,可以看到[0]code包括一般信息和特有信息,一般信息就是将code以utf8保存在常量池。特有信息比较重要,这里的是对应的空构造函数源码,给出的字节码是:

`0 aload_0

1 invokespecial #1 <java/lang/Object. : ()V>

4 return`

这是JVM的指令集,要去规范文档中查询所代表的意思。

1、aload_0代表本地变量保存在内存中栈帧第0项,默认是this(下面内存的部分会学习),字节码是0x2a,如果细心的话可以在图1-3-A②的字节码中找到。

2、invokespecial代表调用实例方法,包括对于父类、私有以及实例初始化的处理。这里指的是调用父类即Object的方法。

3、return返回void。

处了代码的字节码以外,特有信息还包括异常表和杂项,不在这里介绍了。

[0]code再往下还有更深一层,包括:

1、[0]LineNumberTable,代表源代码行号

2、[1]LocalVariableTable,方法执行时本地变量的值

1.6 属性

属性包括一条名称为SourceFile的记录,包括一般信息和特有信息,一般信息就是记录字符串”SourceFile“,特有信息就是源码文件的实际名称,Test002.java。

这里要注意的是属性也可以包括在字段、方法中,也可以是整个class结构的属性,他们的内容规范是一致的,只是取决于作用域。属性是比较复杂的部分,上面提到的LineNumberTable和LocalVariableTable实际上都是属性,code也是属性(属性信息本身作为一个事物也可以有自己的属性,就像方法的属性code也可以有自己的属性LineNumberTable和LocalVariableTable),这种属性的规范还有很多,JVM规范文档中4.7的章节有详细说明,在有用到的时候可以根据目录快速查看。

2. ClassLoader

我们在第一章对Class文件的结构建立了初步印象。作为JVM的输入,class文件在进入JVM的第一关就是通过ClassLoader也就是类加载器将Class静态文件中的字节码解析并加载到JVM内存中。本章就介绍类加载器ClassLoader。

JVM会动态的对类和接口进行加载、链接以及初始化。加载是一个过程,为一个类或接口类型的二进制文件找到一个特定的名字并从该二进制描述中创建一个类或接口。链接是另外一个过程,拿到一个类或接口,将其合并到JVM运行时状态中,由此它才可以被执行。最后,一个类或接口的初始化,其实就是执行类或接口的初始化方法,例如构造函数。

JVM的启动过程

①通过bootstrap类加载器创建一个入口类。

②链接该入口类、初始化,然后调用public的main方法。

③main方法驱动所有其他的远程执行,按照这个执行时机,所关联到的其他类或接口都会被逐一加载、创建、链接以及初始化,包括他们的方法。(有一些JVM的实现,会将入口类作为JVM命令行启动的参数,或者有固定的入口类设定。

2.1 双亲委派

类加载器并不是一个,而是多个,按照顺序,他们是父子加载器的关系:

1、Bootstrap

2、Extension

3、App

4、Custom ClassLoader

其中最为基础的是Bootstrap类加载器,它是JVM内置的由C 所编写的,固定地用来加载核心类库到JVM运行时,这是操作系统级别的代码。接下来是Extension扩展类加载器,加载扩展包jre/lib/ext/*.jar,或者由-Djava.ext.dirs参数来指定类加载路径。接下来是App,加载classpath指定的内容。最后是自定义类加载器,对于我们JVM的使用者来讲,这部分是应用最多的。

下面学习双亲委派的概念。

当一个类要被加载到JVM的时候,会自底向上的查找是否加载过。 首先是自定义类加载器,找不到的话再向上去查App类加载器,接着是Extension,最后到Bootstrap。如果都没有找到,则需要触发类加载。类加载的过程是自顶向下的。Boostrap首先会执行加载的方法findClass(),但它不会加载核心类库以外的类,所以会往下传递到Extension。如果这个类不在Extension加载的findClass()逻辑覆盖,则它也不会加载,会往下继续传给App。同样的,App类加载器也有自己的findClass(),如果也不在逻辑内,则继续传给自定义类加载器。如果自定义类加载器也没有开发相关的逻辑,即重写findClass(),这个类就会被丢弃,不再加载。而一般情况下,我们会在自定义类加载器中去重写findClass()处理要自定义加载的类的逻辑。

这个加载过程就用到了双亲委派,前面提到了这4个类加载器按照顺序是父子层级关系,因此一个新类的加载,需要孩子向父亲方向逐层查找,然后再从父亲向孩子方向逐层加载的过程。这就是双亲委派。

双亲委派的意义

前面讲到了,4中类加载器有各自不同的实现和权限,那么双亲委派的过程实际上就对新加载类进行了层层校验,以避免底层类库被替换的情况发生,所以主要是从安全角度考虑而设计的。

2.2 ClassLoader源码

进入java.lang.ClassLoader类源码中,首先看它的类注释。第一段概况性描述了ClassLoader的功能,本质就是在系统中定位到class文件并读入进来,这个过程中做了一些处理,例如安全、并发(多线程情况下去执行类加载的策略,为保证不会重复加载,会加锁,通过registerAsParallelCapable()方法),以及IO(class文件不再是狭隘的系统中的一个文件,而是一个二进制文件流,它的来源可以是本地文件也可以是网络传输。通过defineClass()方法读入)。

1、首先ClassLoader类是一个抽象类,定义了一个类加载器的规范,它的子类包括了SecureClassLoader、RBClassLoader、DelegatingClassLoader等,包括我们自己实现的子类也属于直属于java.lang.ClassLoader的子类。

2、Java语言里面,类型的加载是在程序运行期间完成的,也就是说用到的时候再创建,而不是在程序编译时或者启动时就把所有的对象准备好,这一点常用Java的人应该了解。这种策略是与其他语言稍有不同的,虽然会令类加载时增加一些性能开销,但会提高Java应用程序的灵活性。

Java里天生可以动态扩展的语言特性就是依赖运行期动态加载这个特点实现的。(包括动态的链接,后面会学习到)。这种动态加载也被称为懒加载。

3、根据以上2点,可以得知ClassLoader子类会在使用到的时候去创建实例,那么核心类加载器的创建时机是什么呢?其实在上面的JVM启动过程中提到了,指定入口类的main方法作为整个JVM运行的开始,会执行Launcher类,该类是ClassLoader的包装类,其中包括了前面提到的Bootstrap类加载器、Extension类加载器以及App类加载器,那么剩下的自定义类加载器其实就是第一点中提到的java.lang.ClassLoader的子类,按照动态加载策略被加载进来。

下面我们进入源码的学习。

父类加载器

代码语言:javascript复制
private final ClassLoader parent;

每一个类加载器都会有一个类加载器对象作为属性,属性名称是parent,这就是父类加载器,它是final的,即定义好就不可修改。由于该父类加载器是一个成员属性,所以要与继承的父类概念相区分。当然,它也不是当前类加载器的创建者。

并行加载器类

代码语言:javascript复制
private static final Set<Class<? extends ClassLoader>> loaderTypes =
    Collections.newSetFromMap(
        new WeakHashMap<Class<? extends ClassLoader>, Boolean>());

接下来是一个并行加载器类,该类中包含一个如上面粘贴的源码内容的Set集合,该集合的元素只能是ClassLoader的子类,它的数据结构是由一个WeakHashMap类型转型过来的集合。该WeakHashMap类型的key是ClassLoader子类(注意不是对象),value是Boolean类型。默认在静态方法中会初始加入ClassLoader类。

静态方法:该并行加载类定义好上面这个内存结构以后,又给出了注册register(子类)以及判断是否注册isRegistered(子类)的方法。其中都包含了针对并发的synchronized处理。register方法会在registerAsParallelCapable()方法中被使用到。registerAsParallelCapable()方法在类注释中提到过,主要是为了并行。

loadClass方法

类加载器最重要的是加载方法,loadClass方法就是核心方法,这个方法的源码就粘贴完整一些。

代码语言:javascript复制
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

1、检查该类是否已经被加载,通过findLoadedClass()方法(该方法最终实现指向了native方法,是系统级别方法,可能不是java写的,无源码)。如果查到已被加载则执行解析逻辑resolve(解析的最终实现也是个native方法),再直接返回。

2、若该类未被加载,检查父类加载器是否存在,若不存在则去查找Bootstrap类加载器中是否存在(最终实现也是个native方法),不存在会返回null。

3、若父类加载器存在,则当前子类加载器的loadClass方法阻塞在这里,线程转而去执行父类加载器的loadClass方法。父类加载器同样也是ClassLoader类的子类,loadClass方法的代码是相同的,因此它也会执行到这里仍旧去查是否存在它的父类加载器。就像执行一个递归函数那样以此类推。

4、程序会执行直到没有父类加载器的最底层类加载器,我们前面介绍到了,就是Bootstrap类加载器,它是没有父类加载器的,因此通过findBootstrapClassOrNull(name)方法来查询。这个方法的最终实现同样要指向native本地代码,如果找到则返回Class类,未找到则返回null。到此我们的递归函数开始收拢。

5、Boostrap类加载器的一级子类加载器会得到前者的返回值,如果找到了,则执行解析逻辑resolve,再直接返回。

6、如果没找到,则往下执行findClass方法。该方法是每一个ClassLoader子类都会重写的方法,如果找不到仍旧会继续往上返回给自己的子类null。递归函数继续收拢。

7、继续找,直到在某一层级的子类加载器中找到了,则执行解析逻辑resolve,再直接返回。如果最终整个递归函数已经收拢回首层也没有找到,会有两种可能。第一、直接返回null。第二,就是过程中某一层类加载器显式抛出了ClassNotFoundException异常,被下一层的孩子捕捉到了以后做了处理。注意,这个过程我们在ClassLoader源码中可以看到一个框架结构,但并没有具体实现,这是留给子类去发挥的地方。

总结一下,我们会发现整个这个过程通过parent父类加载器以及loadClass方法的代码逻辑,完成了对于双亲委派策略的实现。

findClass方法

前面在loadClass方法的源码分析中,在递归调用的各级类加载器的逻辑中,他们对于ClassLoader类的findClass方法的重写内容显得至关重要。由于子类非常多,也包括在jdk以外的子类实现,我们挑选到URLClassLoader类的源码作为研究对象,看一下它的findClass方法是如何重写的。

代码语言:javascript复制
protected Class<?> findClass(final String name)
    throws ClassNotFoundException
{
    final Class<?> result;
    try {
        result = AccessController.doPrivileged(
            new PrivilegedExceptionAction<Class<?>>() {
                public Class<?> run() throws ClassNotFoundException {
                    String path = name.replace('.', '/').concat(".class");
                    Resource res = ucp.getResource(path, false);
                    if (res != null) {
                        try {
                            return defineClass(name, res);
                        } catch (IOException e) {
                            throw new ClassNotFoundException(name, e);
                        }
                    } else {
                        return null;
                    }
                }
            }, acc);
    } catch (java.security.PrivilegedActionException pae) {
        throw (ClassNotFoundException) pae.getException();
    }
    if (result == null) {
        throw new ClassNotFoundException(name);
    }
    return result;
}

这个源码的逻辑简单介绍一下。

1、参数约定传入的是全限定类名,因此首先要对参数进行改造,得到它的文件路径。

2、然后通过getResource获得文件的Resource对象。

3、最后调用defineClass获得类返回值。

defineClass方法

还是由前面的findClass方法继续分析,一路追踪到defineClass方法。首先来看它的入参,除了传递了全限定类名的字符串以外,还传入了Resource对象。核心的代码如下:

代码语言:javascript复制
java.nio.ByteBuffer bb = res.getByteBuffer();
if (bb != null) {
    // Use (direct) ByteBuffer:
    CodeSigner[] signers = res.getCodeSigners();
    CodeSource cs = new CodeSource(url, signers);
    sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0);
    return defineClass(name, bb, cs);
} else {
    byte[] b = res.getBytes();
    // must read certificates AFTER reading bytes.
    CodeSigner[] signers = res.getCodeSigners();
    CodeSource cs = new CodeSource(url, signers);
    sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0);
    return defineClass(name, b, 0, b.length, cs);
}

这里首先定义了一个nio包的ByteBuffer对象bb,然后有两个分支。如果bb有值,则直接使用ByteBuffer数据结构。如果bb为空,则读出它的字节码,然后去调用另一个入参为字节码的defineClass方法。其实直接使用ByteBuffer的分支跟踪进去最终也会调用这个入参为字节码的defineClass方法,这个方法的最终实现也是native本地方法,实现细节我们不得而知,除非去分析C 源码。对于defineClass我们只要知道,不仅是文件路径,只要是能转为字节码的格式,类加载器都支持。

双亲委派机制的打破

前面仔细介绍了类加载过程中的双亲委派机制,主要是在ClassLoader的loadClass方法中固定实现的,那么有没有情况是要打破这个机制的呢?答案是有的,当我们希望类的加载可以实现对JVM现有的类进行替换的时候。我们知道在双亲委派机制下,重复的类不会被加载进来,因为会自底向上去查询,一旦查到JVM已经加载过了,就直接返回而不会再加载你新准备覆盖传入的同名类。

所以对应的实现方法就是我们自定义的类加载器不能仅仅去重写findClass方法了,而是要重写loadClass方法,把其中向上查找,找到就返回的逻辑给去掉。修改为找到Class文件,不再去判断是否有同名。

Tomcat的底层实现就是基于对双亲委派机制的打破以及垃圾回收的结合应用,从而实现了热部署,也即在不停机的情况下对代码进行更新操作。那么具体是如何实现的呢?这里不做tomcat源码级别的学习,而是说一个原理:

1、重写loadClass方法,去除双亲委派的查找逻辑,也就是允许同名的类加载进来。

2、然后同名类在加载的时候,不再使用原来的类加载器的实例,而是新创建一个实例来加载。

3、这时候,JVM内存中是存在两个类加载器的实例,他们各自都加载了一个同名的类。

4、此时,再通过Java垃圾回收机制,通过判定标记,将旧的类加载器实例进行主动销毁。

5、这时候内存中就只留下最新的类了,实现了不停机的一个代码替换。

不过这里也有很多细节问题需要研究tomcat源码去完善,例如类加载器实例不仅仅加载了这一个类,还有很多未更新的类在新的实例创建的时候也要同时再加载一遍进来,这个逻辑的具体实现。还有像新创建一个类加载器的实例的机制,实例是如何被管理的,以及具体的判定旧实例的过时和销毁等等。

2.3 Launcher源码

前面提到了Bootstrap、Extension以及App类加载器的层级关系,那么他们是如何定义的,JVM在启动时是如何初始化类加载器的,其实答案都在Launcher类中。

代码语言:javascript复制
private ClassLoader loader;

1、Launcher类是ClassLoader的包装类,它有一个ClassLoader的成员。

2、接着,它定义了Bootstrap、Extension以及App类加载器的文件扫描路径,这些路径可以通过JVM启动参数手动指定,但启动以后就不可修改(不包括热部署的情况)。

3、Launcher类包含了内部类APPClassLoader、BootClassPathHolder、ExtClassLoader分别对应以上三种类加载器,这里面与其他不同的是Bootstrap类加载器并不是ClassLoader而是PathHolder。Bootstrap类加载器,前面提到它是C 编写到操作系统的本地类库,因此它的具体实现并不是java.lang.ClassLoader的子类。这里只是通过它来确定文件路径sun.boot.class.path的逻辑。

4、其他两个类加载器都是ClassLoader的子类,具体来说是URLClassLoader的子类,URLClassLoader我们在前面的findClass方法的重写部分做了充分研究。这里的两个类加载器在URLClassLoader的基础上,做了一些针对自己功能责任的调整。

2.4 findClass方法的妙用

前面详细学习了findClass方法,ClassLoader的子类包括我们自定义的类加载器都会去重写该方法。那么通过对该方法的内容实现的灵活使用,可以实现一些特殊的功能。例如Class文件的加密。我们可以给自己的源码编译出来的Class文件进行加密,Class文件是一个二进制文件,可以通过位运算或其他加密算法的逻辑运算把原始字节加密成密文字节。所谓的密文字节其实就是通用的解析方式不再适配了,这个通用的解析方式其实就是前面介绍的JVM规范。那么我们自己如何进行加载呢?可以通过重写findClass方法,因为我们知道自己Class字节码的加密方式,所以可以在findClass方法中写入自己的解密逻辑,从而就实现了源码的加密保护,只有我自己可以加载,而其他人只要不清楚我的加密方式以及加密种子,就不会完成加密类文件的一个正常加载,直接反编译也会显示乱码。

参考资料

  • *JVM 1.8官方文档
  • java SE 1.8官方文档
  • 《JVM调优》马士兵
  • 《深入理解Java虚拟机》周志明

更多文章请转到一面千人的博客园

0 人点赞