阿里P7架构师对Java虚拟机、类加载机制是怎么理解的?
概述
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载
(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化
(Initialization)、使用(Using)和卸载(Unloading)7 个阶段。其中验证、准备、解析
3 个部分统称为连接(Linking)
于初始化阶段,虚拟机规范则是严格规定了有且只有 5 种情况必须立即对类进行“初始
化”(而加载、验证、准备自然需要在此之前开始):
1)遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行
过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:使用
new 关键字实例化对象的时候、读取或设置一个类的静态字段(被 final 修饰、已在编译期
把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
2)使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,
则需要先触发其初始化。
3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类
的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()方法的那个类),
虚拟机会先初始化这个主类。
5)当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最
后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个
方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
注意:
对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中
定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
常量 HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello
world”存储到了 NotInitialization 类的常量池中,以后 NotInitialization 对常量
ConstClass.HELLOWORLD 的引用实际都被转化为 NotInitialization 类对自身常量池的引
用了。
也就是说,实际上 NotInitialization 的 Class 文件之中并没有 ConstClass 类的符号引用入
口,这两个类在编译成 Class 之后就不存在任何联系了。
加载阶段
虚拟机需要完成以下 3 件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据
的访问入口。
验证
是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合
当前虚拟机的要求,并且不会危害虚拟机自身的安全。但从整体上看,验证阶段大致上会
完成下面 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验
证。
准备阶段
是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法
区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行
内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在
对象实例化时随着对象一起分配在 Java 堆中。其次,这里所说的初始值“通常情况”下
是数据类型的零值,假设一个类变量的定义为:
public static int value=123;
那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时候尚未开始执行任何
Java 方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器<
clinit>()方法之中,所以把 value 赋值为 123 的动作将在初始化阶段才会执行。表 7-1
列出了 Java 中所有基本数据类型的零值。
假设上面类变量 value 的定义变为:public static final int value=123;
编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据
ConstantValue 的设置将 value 赋值为 123。
解析阶段
是虚拟机将常量池内的符号引用替换为直接引用的过程
类初始化阶段
是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过
自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正
开始执行类中定义的 Java 程序代码在准备阶段,变量已经赋过一次系统要求的初始值,
而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或
者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。<
clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}
块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没
有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个
线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他
线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit
>()方法中有耗时很长的操作,就可能造成多个进程阻塞。
类加载器
如何自定义类加载器,看代码
系统的类加载器
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中
的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一
些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意
义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们
的类加载器不同,那这两个类就必定不相等。
这里所指的“相等”,包括代表类的 Class 对象的 equals()方法、isAssignableFrom
()方法、isInstance()方法的返回结果,也包括使用 instanceof 关键字做对象所属关
系判定等情况。
在自定义 ClassLoader 的子类时候,我们常见的会有两种做法,一种是重写 loadClass 方
法,另一种是重写 findClass 方法。其实这两种方法本质上差不多,毕竟 loadClass 也会
调用 findClass,但是从逻辑上讲我们最好不要直接修改 loadClass 的内部逻辑。我建议的
做法是只在 findClass 里重写自定义类的加载方法。
loadClass 这个方法是实现双亲委托模型逻辑的地方,擅自修改这个方法会导致模型被破
坏,容易造成问题。因此我们最好是在双亲委托模型框架内进行小范围的改动,不破坏原
有的稳定结构。同时,也避免了自己重写 loadClass 方法的过程中必须写双亲委托的重复
代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择。
双亲委派模型
从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器
(Bootstrap ClassLoader),这个类加载器使用 C++语言实现,是虚拟机自身的一部分;
另一种就是所有其他的类加载器,这些类加载器都由 Java 语言实现,独立于虚拟机外
部,并且全都继承自抽象类 java.lang.ClassLoader。
启动类加载器(Bootstrap ClassLoader):这个类将器负责将存放在<JAVA_HOME>\lib
目录中的,或者被-Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照
文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到
虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器
时,如果需要把加载请求委派给引导类加载器,那直接使用 null 代替即可。
扩展类加载器(Extension ClassLoader):这个加载器由
sun.misc.Launcher$ExtClassLoader 实现,它负责加载<JAVA_HOME>\lib\ext 目录中
的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展
类加载器。
应用程序类加载器(Application ClassLoader):这个类加载器由 sun.misc.Launcher
$App-ClassLoader 实现。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader
()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径
(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没
有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
我们的应用程序都是由这 3 种类加载器互相配合进行加载的,如果有必要,还可以加入自
己定义的类加载器。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载
器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都
使用组合(Composition)关系来复用父加载器的代码。
使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着
它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object,它存放在
rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类
加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果
没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为
java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的
Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。
Tomcat 类加载机制
Tomcat 本身也是一个 java 项目,因此其也需要被 JDK 的类加载机制加载,也就必然存在
引导类加载器、扩展类加载器和应用(系统)类加载器。
Common ClassLoader 作为 Catalina ClassLoader 和 Shared ClassLoader 的 parent,而
Shared ClassLoader 又可能存在多个 children 类加载器 WebApp ClassLoader,一个
WebApp ClassLoader 实际上就对应一个 Web 应用,那 Web 应用就有可能存在 Jsp 页
面,这些 Jsp 页面最终会转成 class 类被加载,因此也需要一个 Jsp 的类加载器。
需要注意的是,在代码层面 Catalina ClassLoader、Shared ClassLoader、Common
ClassLoader 对应的实体类实际上都是 URLClassLoader 或者 SecureClassLoader,一般
我们只是根据加载内容的不同和加载父子顺序的关系,在逻辑上划分为这三个类加载器;
而 WebApp ClassLoader 和 JasperLoader 都是存在对应的类加载器类的。
当 tomcat 启动时,会创建几种类加载器:
1 Bootstrap 引导类加载器 加载 JVM 启动所需的类,以及标准扩展类(位于 jre/lib/ext
下)
2 System 系统类加载器 加载 tomcat 启动的类,比如 bootstrap.jar,通常在 catalina.bat
或者 catalina.sh 中指定。位于 CATALINA_HOME/bin 下。
3 Common 通用类加载器 加载 tomcat 使用以及应用通用的一些类,位于
CATALINA_HOME/lib 下,比如 servlet-api.jar
4 webapp 应用类加载器每个应用在部署后,都会创建一个唯一的类加载器。该类加载器
会加载位于 WEB-INF/lib 下的 jar 文件中的 class 和 WEB-INF/classes 下的 class 文件。
方法调用详解
解析
调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解
析。
在 Java 语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法
和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特
点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段
进行解析。
静态分派
多见于方法的重载。
“Human”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent
Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在
程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态
类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行
期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
代码中定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)
在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期
可知的,因此,在编译阶段,Javac 编译器会根据参数的静态类型决定使用哪个重载版
本,所以选择了 sayHello(Human)作为调用目标。所有依赖静态类型来定位方法执行版
本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶
段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
动态分派
静态类型同样都是 Human 的两个变量 man 和 woman 在调用 sayHello()方法时执行了
不同的行为,并且变量 man 在两次调用中执行了不同的方法。导致这个现象的原因很明
显,是这两个变量的实际类型不同。
在实现上,最常用的手段就是为类在方法区中建立一个虚方法表。虚方法表中存放着各个
方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址
入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这
个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。PPT 图中,Son
重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但
是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承
来的方法都指向了 Object 的数据类型。
基于栈的字节码解释执行引擎
Java 编译器输出的指令流,基本上]是一种基于栈的指令集架构,指令流中的指令大部分
都是零地址指令,它们依赖操作数栈进行工作。与
基于寄存器的指令集,最典型的就是 x86 的二地址指令集,说得通俗一些,就是现在我们
主流 PC 机中直接支持的指令集架构,这些指令依赖寄存器进行工作。
举个最简单的例子,分别使用这两种指令集计算“1+1”的结果,基于栈的指令集会是这
样子的:
iconst_1
iconst_1
iadd
istore_0
两条 iconst_1 指令连续把两个常量 1 压入栈后,iadd 指令把栈顶的两个值出栈、相加,然
后把结果放回栈顶,最后 istore_0 把栈顶的值放到局部变量表的第 0 个 Slot 中。
如果基于寄存器,那程序可能会是这个样子:
mov eax,1
add eax,1
mov 指令把 EAX 寄存器的值设为 1,然后 add 指令再把这个值加 1,结果就保存在 EAX
寄存器里面。
基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件
寄存器则不可避免地要受到硬件的约束。栈架构指令集的主要缺点是执行速度相对来说会
稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。