Java 类加载与运行
# Java 类加载与运行
类从加载到内存直至被卸载,整个生命周期包括:加载、验证、准备、解析(绑定)、初始化、使用和卸载7个阶段,对于不同的 JVM 实现,这 7 个阶段可能各有重叠,但大致过程相同
从类到对象
粗略来看,类的加载一般需要经过下面三个过程
- 通过一个类的全限定名来获取定义此类的二进制字节流 ,例如使用文件(Jar、war)、网络(Applet) 等方式
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 生成一个代表这个类的
java.lang.Class
对象(方法区或者堆),作为方法区这个类的各种数据的访问入口
下面介绍部分细节
验证
class 文件不一定由 Java 编写,也可能由 C 或其他语言生成,所以为了安全,载入 class 文件时 JVM 会对 class 文件进行验证。验证 阶段大致上会完成下面 4 个阶段的检验动作:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
准备
准备阶段主要初始化方法区内存
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有 两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量, 实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。其次,这里所说的初始值“ 通常情况” 下是数据类型的零值,因为这时候尚未开始执行任何 Java 方法,成员变量的赋值编译后常置于构造器 <clinit>()
中。但是,如果类字段为 const 类型,那么在编译与准备时变量的值就已经确定了,不需要写进构造器中
public static int value = 123; // 变量 value 在准备阶段过后的初始值为 0 而不是 123
public static final int value = 123; // 变量 value 在准备阶段的初始值为 123
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程
类初始化
注意,这里的初始化指的不是类对象的初始化,而是类或接口首次使用(创建对象)前所做的准备工作
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外, 其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码(或者说是字节码)。从另外一个角度来表达:初始化阶段是执行类构造器 <clinit>()
方法的过程,调用<clinit>()
是一个类或接口被首次使用前的最后一项工作
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}
块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量, 在前面的静态语句块可以赋值,但是不能访问
public class Test{
static
{
i= 0;// 给变量赋值可以正常编译通过
System.out.print(i)// 这句编译器会提示"非法向前引用"
}
static int i = 1;
}
<clinit>()
方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit>()
方法
<clinit>()
方法与类的构造函数(或者说实例构造器 <init>()
方法)不同,它不需要显式地调用父类构造器,虚拟机会 保证在子类的 <clinit>()
方法执行之前,父类的 <clinit>()
方法已经执行完毕。虚拟机会保证一个类的 <clinit>()
方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会 有一个线程去执行这个类的<clinit>()
初始化时机
JVM 规范对类初始化的时机做了明确的规定,有且仅有下面 5 种情况必须对类进行初始化,且下面 5 种方式被称为主动引用
- 遇见 new、getstatic、putstatic 和 invokestatic 这 4 条字节码命令时,如果类未初始化过,则需要初始化
- new,调用构造函数初始化对象堆内存,创建对象需要知道对象的类型,而
java.lang.Class
是在类初始化时载入的 - 类中静态初始化块只会在类第一次初始化时调用,后三个静态成员访问指令需要触发首次类初始化
- new,调用构造函数初始化对象堆内存,创建对象需要知道对象的类型,而
- 使用
java.lang.reflect
包的方法对类进行反射调用时,如果类未进行过初始化,则需先触发初始化- 参考这里,反射需要维护对象的一个 Class 对象,而 Class 对象由类初始化载入
- 初始化一些类时,如果发现其父类未被初始化,则需先触发父类的初始化
- 对于接口而言,只有用到的时候才会初始化父接口
- 程序的入口类(包含 main 函数的类),一定会被初始化
- JDK 7 中如果一个
java.lang.invoke.MethodHandle
最后解析结果REF_getstatic
等方法句柄,且这个方法句柄对应的类没有进行过初始化,则需要先触发初始化
除上述 5 种方式(主动引用)外其他对类的应用方式均不会触发类的初始化,这些引用被称为被动引用。下面有几个被动引用的示例
- 通过子类引用父类的静态字段,不会导致子类的初始化
- 通过数组定义引用类,不会触发此类的初始化:
classA a = new classA[10]
,不会触发classA
的初始化 - 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
类加载器
虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流” 这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类
类加载器在类层次划分、OSGi、热部署、代码加密等领域十分重要,是 Java 技术体系中一块重要的基石
如果两个类由不同的类加载器加载,那么即使类源自同一个 class 文件,这两个类也是不同的(包括 equals、isAssignableFrom、isInstance ),因为每一个类加载器都有自己的名字空间
双亲委派模型
开发中常用的 Java 类加载器有三类,这三类加载器分层,可以由底层加载器委派上层加载器去载入数据,后续再详细看
Tomcat & OSGi
若想详细学习类加载技术,可以看一看 tomcat 和 OSGi(Open Service Gateway Initiative) 的实现
运行时栈
一个栈帧中包含的内容如下:局部变量表、操作栈、动态连接、返回地址等数据,下面一一介绍
C/C++ 的栈是动态变化的,变量只有执行到指定位置才会在栈中为之分配内存,且内存大小和变量类型息息相关
局部变量表(Slots)
存储方法内部定义的局部变量和方法参数
JVM 规定局部变量的容量以变量槽(Variable Slot)作为最小单位,一个槽可以存放一个 32 位以内的数据类型。JVM 使用索引定位的方式使用局部变量表,如果是 64 位数据就同时使用相邻的两个 Slot,JVM 不允许访问 64 位数据其中一个 Slot
局部变量槽第 0 个Slot 保存 this 指针,其余变量从 1 开始依次占用槽位,先是函数的参数,再是方法内部的局部变量
JVM 并不会像初始化类变量那样初始化局部变量,所以需要手动初始化
为失效变量赋 null
JVM 栈帧中的变量有时是复用的,下面两段代码,前者触发了垃圾回收,后者未触发,就是因为 Slot 的复用
这条原则没必要体现在代码中,JIT 会自动优化这类问题,此处提出这个概念是为了加深对 Slot 的理解
// 即使调用强制系统进行 GC,p 对应的内存也没有被回收
// 因为在调用 gc 时 p 依旧保持在栈中(Slots),作为 GC Root 指向堆内存
public static void main(String[] args)
{
{
byte[] p = new byte[64*1024*1024];
// 部分书籍推荐对不使用的引用赋 null 值,避免回收失效
// 部分 JIT 会优化掉下面的语句,所以具体情况具体对待
// p = null;
}
System.gc();
}
// 很多实现复用了不用的 Slot,所以下面这段代码可以触发垃圾回收
public static void main(String[] args)
{
{
byte[] p = new byte[64*1024*1024];
}
int a = 0; // a 复用了 p 的 Slot,p 对应的内存失去了引用,可被回收
System.gc();
}
操作数栈
Java 中的指令面向操作数栈而非寄存器,故 Java 指令要操作的数据都需要保存在操作数栈中,与之相对应的是 x86 等 CPU 架构,指令的操作数一般都保存在 CPU 寄存器中
使用操作数栈的架构便于移植,不同 CPU 有不同的寄存器结构,移植时需要重新编译代码;当然使用操作数栈结构会降低性能
操作数栈的最大深度在编译时已写入 Code 属性的 max_stacks
中
动态链接
和 C/C++ 这类语言生成的可执行文件概念类似,有些代码在编译时即已写入可执行文件中(如静态链接),有些代码需要在运行时动态决断(如系统库的调用)
运行时栈都包含一个指向运行时常量池中当前栈帧所属方法的引用,这个引用的存在是为了支持方法调用过程的动态链接
返回地址
Java 中从一个函数返回有两种情况:正常 return 和异常返回
附加信息
例如调试信息等
方法调用
分派
-
静态分派(类似 C++ 中的静态绑定)
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载
-
动态分派(类似 C++ 的动态绑定)
- C++ 的动态绑定常见的实现方式是虚函数表和虚函数指针,虚函数指针保存在对象中,C++ 想实现多态,只能用指针(或引用)。Java 和 C++ 实现类似,不过 Java 的“虚函数指针”,在栈帧中有明确的存储位置
- Java 在解析虚函数时会使用 Slots 槽中第 0 个位置的 this 指针获取对象的类型信息,然后查找对应的方法地址
字节码指令
- invokestatic
- invokespecial,调用示例构造
<init>
方法、私有方法和父类方法 - invokevirtual
- invokeinterface
- invokedynamic,前 4 条语句函数的解析由 JVM 控制,当前指令函数解析由用户决定
Java 的动态语言特性
动态语言的特点是它的类型检测主体过程在运行期而不是编译期。以 C++ 和 Java 为例,假设 obj 为一个对象实例,则在 C++ 和 Java 中使用 obj.method()
的前提是 obj 的静态类型中声明的有 method
这个方法,否则无法通过编译。考虑语言的多态性,非动态语言的特点是:可以修改对象方法的行为,但不能调用对象中不存在的方法
动态语言就不一样了,例如 JavaScript,你甚至可以在运行时为一个对象赋予全新的方法,简单来说,动态语言在运行时查询对象信息,如果有对应方法就调用,没有就报运行时错误
Java 可以使用反射和 invoke 包实现动态方法调用,示例如下
import java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
// 假设 ClassA 中实现了 println 方法
Object obj = System.currentTimeMillis()%2 == 0?System.out:new ClassA();
// 在 Java 中,下面用法是不合法的,编译器会提示找不到 println
// 编译器只能查询 obj 本身所包含的信息,不能查询 obj 所引用的对象的类型
// obj 指向的对象是包含 println 方法的,但 obj 的静态类型种没有 println 方法
// obj.println("Hello");
// JDK 7之后可以使用 invoke 类库实现方法的动态调用,方法如下
getPrintlnMH(obj).invokeExact("Hello");
// 使用下面的方法获得方法并和指定的对象绑定
MethodHandle getPrintlnMH(Object receiver)
{
// 获得方法类型,第一个参数为方法的返回值类型,后面为方法的参数类型
MethodType mt = MethodType.methodType(void.class, String.class);
// 从 receiver 中查找方面名为 println,形参类型为 mt 的方法,并与 receiver 绑定
// 执行下面语句前,JVM 是不知道 receiver 有 println 这个成员函数的
return lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver);
}
和反射相比,反射比 invoke 中的方法更重量级。反射模拟的是代码层次的调用,MethodHandle 模拟字节码层次的调用;反射比 MethodHandle 包含了更多额外的信息,例如方法签名、属性等,后者仅仅包含方法调用信息
MethodHandle 面向所有语言,是 JVM 的特性;反射一般只用于 Java
invokedynamic
JVM 的 invokedynamic 指令和上面的 getPrintlnMH 函数所展示的功能类似,具体实现请参看其他资料
字节码执行引擎
JVM 在执行 Java 代码时有两种选择:解释执行和编译执行,有时候这两种方法是同时存在的,例如包含JIT 的 JVM
先举个字节码执行过程的例子。使用 javac
命令编译下面代码,并使用 javap
查看 cal 函数的字节码:
class TestClass {
public int cal()
{
int a = 100;
int b = 200;
int c = 300;
return (a+b)*c;
}
}
上面代码对应的字节码如下,编译器可能会对字节码进行优化,所以下面过程只用于说明字节码执行过程
public int cal();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: bipush 100 // 将 100 放入操作数栈
2: istore_1 // 将栈顶数据写入局部变量第一个 Slot 中
3: sipush 200 // 后面 4 条语句与前两条功能相同,即初始化变量 a,b,c
6: istore_2
7: sipush 300
10: istore_3
11: iload_1 // 这两行把 Slot 中的值写进操作数栈
12: iload_2
13: iadd // 弹出操作数栈栈顶的两个值求和并将结果入栈,此时栈顶为求和结果
14: iload_3 // 将第三个 Slot 中的值,即 c 压入操作数栈
15: imul // 弹出操作数栈顶两个值求积并将结果入栈,此时栈顶为最终结果
16: ireturn
LineNumberTable:
line 8: 0
line 9: 3
line 10: 7
line 12: 11
}
字节码生成与动态代理
动态代理的简单示例, 动态代理可以实现适配器(Adapter)或修饰器(Decorator) 等模式
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
public class TestClass {
interface IHello {
void sayHello();
void sayGoodbye();
}
static class Hello implements IHello {
@Override
public void sayHello() { System.out.println(" hello world"); }
@Override
public void sayGoodbye() { System.out.println(" bye bye!!!"); }
}
static class DynamicProxy implements InvocationHandler {
Object originalObj;
Object bind(Object originalObj) {
this.originalObj = originalObj;
// 三个参数,Class Loader;需要实现的接口数组;this
return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(),\
originalObj.getClass().getInterfaces(), \
this);
}
// 所有原始成员函数都以下面的方式调用
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println(" welcome");
return method.invoke(originalObj, args);
}
}
public static void main(String[] args) {
IHello hello = (IHello) new DynamicProxy().bind(new Hello());
hello.sayHello();
hello.sayGoodbye();
/* // 上面两行代码编译后的结果类似于下面代码
public final void sayHello() throws {
try { // m3 是绑定在 hello 上的实际方法,即原始的 sayHello 或 sayGoodbye
this.h.invoke(this, m3, null); return;
}
catch...
}
*/
}
}
/* 执行结果
welcome
hello world
welcome
bye bye!!!
*/
Retrotranslator
同时执行不同版本的 Java 代码,Retrotranslator 可以将 jdk5 的代码编译为 jdk 5以前的 class 文件