
Java 虚拟机基础八股
1. Java 内存区域与内存溢出异常
1.1 运行时数据区域
根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:
1.1.1 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在 Java 虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于 Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。
此内存区域是唯一一个在《Java 虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域。
1.1.2 Java 虚拟机栈
线程私有,生命周期与线程相同。
每个 Java 方法被执行的时候,Java 虚拟机都会同步创建一个栈帧用于存储以下内容,而方法执行完的时候就弹出这个栈帧:
局部变量表:
存放基本数据类型(boolean、byte、char、int等)、对象引用(reference)、returnAddress
以局部变量槽(slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。1个slot占用32bit或者64bit由虚拟机实现自行决定。
所需的内存空间在编译期间完成分配,即局部变量空间在运行时是完全确定的。
操作数栈
动态连接
方法出口
StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度,抛出 StackOverflowError 异常。如无限制创建递归栈。
OutOfMemoryError:HotSpot 虚拟机中,如果线程申请栈空间失败,抛出 OutOfMemoryError 异常。
1.1.3 本地方法栈
线程私有,生命周期与线程相同。
与 Java 虚拟机栈唯一区别在于调用的是本地 Native 方法。HotSpot 虚拟机将这两个空间合二为一。
《Java虚拟机规范》中对本地方法使用语言、使用方式与数据结构没有任何强制规定。
抛出的两个异常也是和前者一样的。
StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度,抛出 StackOverflowError 异常。如无限制创建递归栈。
OutOfMemoryError:HotSpot 虚拟机中,如果线程申请栈空间失败,抛出 OutOfMemoryError 异常。
1.1.4 Java 堆
线程共享,在虚拟机启动时创建。
此内存区域的唯一目的是存放对象实例。
从实现角度,Java 对象实例也不绝对都放在了堆上
Java 堆是垃圾收集器管理的内存区域。其中 HotSpot 虚拟机的一些垃圾收集器基于“经典分代”设计,在堆中创建了新生代、老年代等等空间。
Java 堆中还可以划分出多个线程私有的分配缓冲区,以提升对象分配时的效率。
Java 堆可以处于物理上不连续的内存空间,但是在逻辑上应该视为连续的。
Java 堆可以实现成固定大小的,也可以是可扩展的。主流虚拟机都是可扩展,通过参数 -Xmx 和 -Xms 设定。
OutOfMemoryError:如果 Java 堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出 OutOfMemoryError 异常。
1.1.5 方法区
线程共享,和 Java 堆是区分开来的。
存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。但是针对常量池的回收和类型的卸载有时是必要的。
OutOfMemoryError:如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。
1.1.6 运行时常量池
线程共享,是方法区的一部分。
存储字面量与类的符号引用。
编译期预制:Class 文件中有一项信息 常量池表,存放编译期生成的各种字面量与类的符号引用。它们在类加载后存放在方法区的运行时常量池中。符号引用翻译出来的直接引用也存储在运行时常量池中。
运行时:常量可以在运行时产生并放入运行时常量池,如 String 类的 intern() 方法。
OutOfMemoryError:如果常量池无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。
1.1.7 直接内存
并不是虚拟机数据区的一部分,也不是《规范》中定义的内存区域。
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区 (Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
这部分内存的存在不受 -Xmx 参数信息的限制,所以这部分区域可能会导致各个区域内存总和大于物理内存限制,从而导致动态扩展时出现 OutOfMemoryError 异常。
import java.nio.ByteBuffer;
public class JVMMemoryExample {
public static void main(String[] args) {
// 程序计数器(Program Counter Register)
// 程序计数器是线程私有的,记录当前线程执行的字节码行号
// 无法直接在代码中展示,但可以通过线程切换等场景体现其作用
// Java虚拟机栈(Java Virtual Machine Stack)
// 局部变量表、操作数栈等
int a = 10; // 局部变量表存储基本数据类型
String str = "Hello"; // 局部变量表存储对象引用
int result = add(a, 20); // 调用方法,创建栈帧
// 本地方法栈(Native Method Stack)
// 调用本地方法时使用
System.out.println("Hello from Native Method Stack"); // println 是本地方法
// Java堆(Java Heap)
// 存放对象实例
User user = new User("Kimi", 25); // 创建对象实例存储在堆中
System.out.println(user.getName() + ": " + user.getAge());
// 方法区(Method Area)
// 存储类型信息、常量、静态变量等
System.out.println("Static variable: " + User.COUNTRY); // 访问静态变量
// 运行时常量池(Runtime Constant Pool)
// 存储字面量和符号引用
String literal = "Hello"; // 字面量存储在运行时常量池中
String interned = "Hello".intern(); // intern 方法操作运行时常量池
// 直接内存(Direct Memory)
// 使用 NIO 的 ByteBuffer 分配直接内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024); // 分配堆外内存
System.out.println("Direct memory allocated: " + buffer.capacity() + " bytes");
}
// 一个简单的Java方法,演示Java虚拟机栈的使用
public static int add(int x, int y) {
return x + y; // 操作数栈用于执行加法操作
}
}
class User {
private String name;
private int age;
public static final String COUNTRY = "China"; // 静态变量存储在方法区
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
1.2 HotSpot 虚拟机对象生命周期
1.2.1 对象的创建
Java虚拟机在执行new
指令创建对象时,会经历以下关键步骤:
类加载检查:检查常量池中的符号引用是否能找到对应的类,并确认该类是否已被加载、解析和初始化。如果未加载,则先执行类加载过程。
内存分配:
分配方式:根据Java堆的规整性选择内存分配方式。规整时采用“指针碰撞”,不规整时采用“空闲列表”。
线程安全:通过CAS操作或本地线程分配缓冲(TLAB)解决并发分配时的线程安全问题。
内存初始化:将分配的内存空间(除对象头外)初始化为零值,确保对象字段在未赋值时能访问到零值。
对象设置:在对象头中设置对象的类信息、元数据、哈希码(延迟计算)、GC分代年龄等信息。
构造函数执行:调用
<init>()
方法对对象进行初始化,完成对象的构造过程。
最终,从虚拟机视角看对象已创建,但从Java程序视角看,对象需执行构造函数后才算完全可用。
1.2.2 对象的内存布局
在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
每个Java对象都有对象头。如果是非数组类型,则用2个字宽来存储对象头,如果是数组,则会用3个字宽来存储对象头。在32位处理器中,一个字宽是32bit;在64位虚拟机中,一个字宽是64bit。
对象头部分包含三类信息:
Mark Word:
用于存储对象自身的运行时数据,例如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
Mark Word是一个动态定义的数据结构,根据对象的状态复用存储空间。
类型指针:
对象指向其类型元数据的指针,用于确定对象所属的类。
并非所有虚拟机实现都必须在对象数据上保留类型指针,查找对象的元数据信息不一定需要经过对象本身。
数组长度:
如果对象是一个Java数组,对象头中还会包含一块用于记录数组长度的数据。这是因为虚拟机无法通过普通Java对象的元数据信息推断出数组的大小。
实例数据部分是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段内容,无论是从父类继承的还是在子类中定义的字段。存储顺序会受到以下因素的影响:
虚拟机分配策略参数(如
-XX:FieldsAllocationStyle
参数)。字段在Java源码中的定义顺序。
HotSpot虚拟机默认的分配顺序为:
longs/doubles
、ints
、shorts/chars
、bytes/booleans
、oops
(Ordinary Object Pointers,OOPs)。相同宽度的字段会被分配到一起存放。在满足这一前提下,父类中定义的变量会出现在子类变量之前。如果
-XX:CompactFields
参数值为true
(默认值),子类中较窄的变量可以插入父类变量的空隙中,以节省空间。
对齐填充部分并不是必然存在的,也没有特别的含义,它仅起占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,即任何对象的大小都必须是8字节的整数倍。对象头部分已经被设计成正好是8字节的倍数(1倍或2倍),因此如果对象实例数据部分没有对齐,就需要通过对齐填充来补全。
1.2.3 对象的访问定位
HotSpot 的对象访问方式有两种:
使用句柄访问:
Java 堆中画出一块内存作为句柄池,reference 中存储的是句柄地址,而句柄中包含了对象数据与类型数据各自具体的地址信息。
好处:reference 中存储的是稳定句柄地址,在垃圾回收时对象被移动,只会改变句柄中实例数据指针,而 reference 本身不会被修改
使用直接指针访问:
Java 堆中对象的内存布局需要有到对象类型数据的指针,reference 本身中存储的直接是对象地址。
好处:速度更快,如果只需要对象实例数据,则节省一次指针定位的时间开销。HotSpot 主要采用这种方式进行对象访问。
1.3 实战 OOM 异常
1.3.1 Java 堆异常
/**
* VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
* @author zzm
*/
public class HeapOOM {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while (true) {
list.add(new OOMObject());
}
}
}
输出如下:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid3856.hprof ...
Heap dump file created [28418624 bytes in 0.057 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:267)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:241)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:233)
at java.util.ArrayList.add(ArrayList.java:464)
at org.example.HeapOOM.main(HeapOOM.java:16)
Process finished with exit code 1
IDEA 打开 java_pid3856.hprof 可以看到(建议将 IDEA 升级到 2024.x 版本):
可以看到 HeapOOM$OOMObject 这个对象占用很多,内存占用了 12.97 MB。而 Object 对象一共占用了 19.52MB,我们的堆内存配置就是 20MB。
1.3.2 栈溢出错误
/**
* VM Args:-Xss128k
* @author zzm
*/
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + oom.stackLength);
throw e;
}
}
}
输出如下:
stack length:987
Exception in thread "main" java.lang.StackOverflowError
at org.example.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:11)
at org.example.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12)
1.3.3 运行时常量池溢出
首先来看比较特殊的字符串常量池:
/**
* VM Args:-XX:PermSize=6M -XX:MaxPermSize=6M
* @author zzm
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
// 使用Set保持着常量池引用,避免Full GC回收常量池行为
Set<String> set = new HashSet<String>();
// 在short范围内足以让6MB的PermSize产生OOM了
short i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}
自 JDK 7 起,原本存放在永久代的字符串常量池被移至 Java 堆之中,所以也是报了 Java heap space 异常。
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid31072.hprof ...
Heap dump file created [7879018 bytes in 0.036 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.HashMap.resize(HashMap.java:705)
at java.util.HashMap.putVal(HashMap.java:664)
at java.util.HashMap.put(HashMap.java:613)
at java.util.HashSet.add(HashSet.java:220)
at org.example.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:17)
接下来我们需要用 cglib 来模拟一般的运行时常量池的OOM:
/**
* 演示Java方法区(永久代/元空间)内存溢出的示例
*
* VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M (Java 7及之前版本)
* Java 8及之后版本使用:-XX:MaxMetaspaceSize=10M
*
*/
public class JavaMethodAreaOOM {
public static void main(String[] args) {
// 无限循环创建动态代理类
while (true) {
// 使用CGLIB的Enhancer创建动态代理
Enhancer enhancer = new Enhancer();
// 设置代理的父类
enhancer.setSuperclass(OOMObject.class);
// 禁用缓存,确保每次都会创建新的类
enhancer.setUseCache(false);
// 设置方法拦截器
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
// 简单的拦截器实现,直接调用父类方法
return proxy.invokeSuper(obj, args);
}
});
// 创建代理对象,这会生成新的代理类并加载到方法区
enhancer.create();
}
}
// 简单的静态内部类,作为代理的父类
static class OOMObject {
}
}
Java 8 控制台输出如下:
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:345)
at net.sf.cglib.proxy.Enhancer.generate(Enhancer.java:492)
at net.sf.cglib.core.AbstractClassGenerator$ClassLoaderData.get(AbstractClassGenerator.java:114)
at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:291)
at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:480)
at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:305)
at org.example.JavaMethodAreaOOM.main(JavaMethodAreaOOM.java:29)
1.3.4 直接内存溢出
/**
* VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M -XX:+HeapDumpOnOutOfMemoryError
* @author zzm
*/
public class DirectMemoryOOM {
// 定义1MB的常量
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws Exception {
// 通过反射获取Unsafe类的实例
// Unsafe类提供了直接操作内存的方法
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true); // 设置可访问,因为Unsafe的字段是private的
Unsafe unsafe = (Unsafe) unsafeField.get(null); // 获取静态Unsafe实例
// 无限循环分配直接内存
while (true) {
// 每次分配1MB的直接内存
unsafe.allocateMemory(_1MB);
}
}
}
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at org.example.DirectMemoryOOM.main(DirectMemoryOOM.java:18)
如果项目里使用了 NIO 而出现了如上错误,大概率是直接内存引发的 OOM。
2. 垃圾收集器与内存分配策略
2.1 垃圾对象标记判定算法
2.1.1 引用计数算法
引用计数算法:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就算不可能再被使用的。
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
缺点:无法处理循环引用的问题。
Python 语言使用了引用计数算法,并解决了循环引用的问题。
JVM 没有使用这个算法。
/**
* testGC()方法执行后,objA和objB会不会被GC呢?
* @author zzm
*/
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
/**
* 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过
*/
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
// 假设在这行发生GC,objA和objB 能被回收
System.gc();
}
}
2.1.2 可达性分析算法
可达性分析算法:通过一系列成为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,即从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。
Java 中,固定可作为 GC Roots 的对象包括以下几种:
Java虚拟机栈帧(局部变量表)中引用的对象,如局部变量、参数、临时变量等;
本地方法栈JNI(Native 方法)引用的对象;
方法区中类静态属性引用的对象,如 Java 类引用类型静态变量;
方法去中常量引用的对象,如字符串常量池的引用;
基本数据类型对应的 Class 对象、常驻的异常对象(如空指针异常、OOM Error 等)、类加载器;
被
synchronized
修饰的对象。
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不 同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。
2.1.3 引用概念扩充
在JDK 1.2版之前,Java里面的引用是很传统的定义: 如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表 某块内存、某个对象的引用。
这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在 这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。
譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应用场景。
强引用 Strongly Reference:最传统的引用定义,程序代码中普遍存在的引用赋值,类似
Object o = new Object()
。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。软引用 Soft Reference:只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。
弱引用 Weak Reference:只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
虚引用 Phantom Reference:相当于没有引用,一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
一些应用:
缓存:使用软引用实现缓存,可以在内存不足时自动释放缓存,避免内存溢出。
监听器模式:使用弱引用管理监听器,避免监听器对象无法被回收导致的内存泄漏。
临时数据:使用弱引用管理临时数据,确保不再需要的数据可以被及时回收。
2.1.4 对象死亡的标记
如果一个对象通过 GC Roots 不可达,那么其在变成垃圾收集器的“可回收对象”前还需要经历如下过程:
如果一个对象重写了 finalize 方法,那么在用户没有调用 finalize 方法时,对象又变成可达,则会继续存活。
finalize 方法的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明不推荐使用的语法。
2.1.5 方法区回收的内容
方法区的垃圾收集主要回收两部分内容:
废弃的常量
不再使用的类型,进行类型卸载
回收废弃常量与回收 Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用 常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且 垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。
而对于类型卸载,不是所有的垃圾收集器都支持类型卸载的,并且类型卸载的回收成果通常较低。
判断一个类是否需要回收需要同时满足三个条件:
该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
加载该类的类加载器已经被回收。(这个条件除非是经过精心设计的可替换类加载器的场景,否则通常是很难达成的。)
该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
2.1.6 可达性分析:三色标记法
在可达性分析的过程中,我们引入三色标记作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:
白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
关于可达性分析的扫描过程,读者不妨发挥一下想象力,把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。
白色误标为黑色:一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。
黑色误标为白色:另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误,下面演示了这样的致命错误具体是如何产生的。
Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:
赋值器插入了一条或多条从黑色对象到白色对象的新引用;
赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:
增量更新(CMS):黑色对象一旦新插入了指向白色对象的引用之后,就变回灰色对象(重新扫描)。
原始快照(G1):当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。
2.2 垃圾收集算法
本节介绍的所有算法均属于追踪式垃圾收集的范畴。
2.2.1 分代收集理论
分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,建立在两个分代假说之上:
弱分代假说:绝大多数对象都是朝生夕灭的。
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
上面两个假说奠定了垃圾收集器一致的设计原则:将回收对象根据其年龄(即对象熬过垃圾收集过程的次数)分配到不同的区域中存储。
将大多数难以熬过 GC 的对象集中放在一起,每次回收时只关注如何保留少量存活,这样就能以较低代价回收大量空间;
剩下的难以消亡的对象集中在一块,虚拟机以较低的频率回收这个区域。
前者一般称为新生代(Young Generation),后者成为老年代(Old Generation)。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
不过它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。
假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也一样。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。
依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。
虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
对于垃圾收集区域的划分,可以大致划分如下:
整堆收集 Full GC:收集整个Java堆和方法区的垃圾收集。
部分收集 Partial GC:指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
新生代收集 Minor GC / Young GC:指目标只是新生代的垃圾收集。
老年代收集 Major GC / Old GC :指目标只是老年代的垃圾收集。目前只有 CMS 收集器会有单独收集老年代的行为。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
混合收集 Mixed GC:指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有 G1 收集器会有这种行为。
2.2.2 标记-清除算法
最早最基础的一种算法,算法分为“标记”和“清除”两阶段:
通过垃圾对象判定标记算法,标记出所有需要回收的对象;
标记完成后,统一回收掉所有被标记的对象。
缺点:
针对新生代:执行效率不稳定,标记和清除两个过程的执行效率都随对象数量增长而降低。如果 Java 堆中包含大量对象,而且其中大部分是需要回收的,这时必须进行大量标记和清除的动作。
针对老年代:内存空间碎片化。标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2.2.3 标记-复制算法
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,“半区复制”这一算法被提出。
半区复制算法将可用的内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
缺点:将可用内存缩小为了原来的一半,造成空间浪费。
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,根据实际情况出发,并没有按照 1:1的比例划分新生代。
HotSpot 虚拟机的 Serial、ParNew 等新生代收集器是这么做的:
将新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间
每次分配内存只使用 Eden 和其中一块 Survivor
发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉原来的 Eden 和 Survivor 空间。
HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例 8:1
当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(如老年代)进行分配担保。
大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。而大对象,就是需要大量连续内存空间的对象(比如:字符串、数组),会直接进入老年代。
2.2.4 标记-整理算法
针对老年代对象的存亡特征,“标记-整理”算法被提出。
其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
移动存活对象,内存回收时更复杂:移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行(Stop The World);
不移动存活对象,内存分配时更复杂:空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。内存的访问是用户程序最频繁的操作,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。
一般而言,从整个程序的吞吐量来看,移动对象会更划算。
2.3 垃圾收集器
JDK 默认的垃圾收集器为:
JDK 8:Parallel Scavenge(新生代)+ Parallel Old(老年代)
JDK 9 ~ JDK 22:G1
2.3.1 衡量垃圾收集器的重要指标
垃圾收集器(Garbage Collector,简称GC)是Java等编程语言中用于自动回收内存的机制。衡量垃圾收集器性能的三项重要指标是内存占用、吞吐量和延迟。以下是这三方面的简单介绍:
内存占用
定义:内存占用是指垃圾收集器在运行过程中所占用的内存资源。它包括堆内存的大小、垃圾收集器自身运行所需的内存,以及因垃圾收集导致的内存碎片化等情况。
重要性:较低的内存占用意味着程序可以更高效地利用有限的内存资源,减少因内存不足而导致的频繁垃圾回收或程序崩溃。
影响因素:垃圾收集器的算法、堆内存的配置(如初始堆大小、最大堆大小)、对象的分配和回收频率等都会影响内存占用。
吞吐量
定义:吞吐量是指垃圾收集器在单位时间内完成的垃圾回收工作量。它通常用“垃圾收集时间占总运行时间的比例”来衡量,也可以用“程序执行时间与垃圾收集时间的比值”来表示。
重要性:高吞吐量意味着垃圾收集器在运行过程中对程序性能的影响较小,程序可以更多地执行实际业务逻辑,而不是频繁地进行垃圾回收。
影响因素:垃圾收集算法的效率、堆内存的大小、垃圾回收的频率等都会影响吞吐量。例如,并行垃圾收集器通过多线程执行垃圾回收,可以提高吞吐量。
延迟
定义:延迟是指垃圾收集器在运行过程中对程序执行时间的影响,通常用垃圾收集暂停时间(Stop-the-World,STW)来衡量。STW是指垃圾收集器在执行某些操作时,会暂停程序的正常运行,直到垃圾回收完成。
重要性:低延迟是实时系统(如金融交易系统、游戏等)的关键要求。如果垃圾收集器的延迟过高,可能会导致程序响应时间过长,甚至出现卡顿或超时。
影响因素:垃圾收集算法的设计、堆内存的大小、垃圾回收的触发条件等都会影响延迟。例如,G1垃圾收集器通过分代收集和并发收集的方式,尽量减少STW时间,从而降低延迟。
总结:
内存占用关注垃圾收集器对内存资源的利用效率;
吞吐量关注垃圾收集器对程序整体运行效率的影响;
延迟关注垃圾收集器对程序响应时间的影响。
不同的应用场景对这三项指标的侧重点不同。例如,批处理系统可能更关注吞吐量,而实时系统则更关注延迟。垃圾收集器的设计和选择需要根据具体需求进行权衡。
从下面的收集器开始引用了 JavaGuide 的内容。
2.3.2 Serial 收集器
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。
新生代采用标记-复制算法,老年代采用标记-整理算法。
虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。
但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。
2.3.3 ParNew 收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
新生代采用标记-复制算法,老年代采用标记-整理算法。
并行和并发概念补充:
并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。
2.3.4 Parallel Scavenge 收集器
JDK 1.8 的默认收集器。
Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。
Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。
如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。
新生代采用标记-复制算法,老年代采用标记-整理算法。
2.3.5 CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:
初始标记: 短暂停顿,标记直接与 root 相连的对象(根对象);
并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:
对 CPU 资源敏感;
无法处理浮动垃圾;
它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
CMS 垃圾回收器在 Java 9 中已经被标记为过时(deprecated),并在 Java 14 中被移除。
2.3.6 G1 收集器
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。
被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备以下特点:
并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
G1 收集器的运作大致分为以下几个步骤:
初始标记: 短暂停顿(Stop-The-World,STW),标记从 GC Roots 可直接引用的对象,即标记所有直接可达的活跃对象
并发标记:与应用并发运行,标记所有可达对象。 这一阶段可能持续较长时间,取决于堆的大小和对象的数量。
最终标记: 短暂停顿(STW),处理并发标记阶段结束后残留的少量未处理的引用变更。
筛选回收:根据标记结果,选择回收价值高的区域,复制存活对象到新区域,回收旧区域内存。这一阶段包含一个或多个停顿(STW),具体取决于回收的复杂度。
G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。
从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器。
3. 类文件与类加载
3.1 Class 类文件结构
.class
文件是不同的语言在 Java 虚拟机之间的重要桥梁,同时也是支持 Java 跨平台很重要的一个原因。
根据 Java 虚拟机规范,Class 文件通过 ClassFile
定义,有点类似 C 语言的结构体。
ClassFile
的结构如下:
ClassFile {
u4 magic; //Class 文件的标志
u2 minor_version; //Class 的小版本号
u2 major_version; //Class 的大版本号
u2 constant_pool_count; //常量池的数量
cp_info constant_pool[constant_pool_count-1]; //常量池
u2 access_flags; //Class 的访问标记
u2 this_class; //当前类
u2 super_class; //父类
u2 interfaces_count; //接口数量
u2 interfaces[interfaces_count]; //一个类可以实现多个接口
u2 fields_count; //字段数量
field_info fields[fields_count];//一个类可以有多个字段
u2 methods_count;//方法数量
method_info methods[methods_count];//一个类可以有个多个方法
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
}
通过分析 ClassFile
的内容,我们便可以知道 class 文件的组成。
3.1.1 魔数 Magic Number
u4 magic; //Class 文件的标志
每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Class 文件。Java 规范规定魔数为固定值:0xCAFEBABE。如果读取的文件不是以这个魔数开头,Java 虚拟机将拒绝加载它。
3.1.2 文件版本号 Major & Minor Version
u2 minor_version;//Class 的小版本号
u2 major_version;//Class 的大版本号
紧接着魔数的四个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本号,第 7 和第 8 个字节是主版本号。
每当 Java 发布大版本(比如 Java 8,Java9)的时候,主版本号都会加 1。你可以使用 javap -v
命令来快速查看 Class 文件的版本号信息。
高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致.
3.1.3 常量池 Constant Pool
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
常量池主要存放两大常量:字面量和符号引用。字面量比较接近于 Java 语言层面的的常量概念,如文本字符串、声明为 final 的常量值等。而符号引用则属于编译原理方面的概念。包括下面三类常量:
类和接口的全限定名
字段的名称和描述符
方法的名称和描述符
常量池中每一项常量都是一个表,这 14 种表有一个共同的特点:开始的第一位是一个 u1 类型的标志位 -tag 来标识常量的类型,代表当前这个常量属于哪种常量类型.
3.1.4 访问标志 Access Flags
u2 access_flags;//Class 的访问标记
在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口,是否为 public
或者 abstract
类型,如果是类的话是否声明为 final
等等。
3.1.5 当前类(This Class)、父类(Super Class)、接口(Interfaces)索引集合
u2 this_class;//当前类
u2 super_class;//父类
u2 interfaces_count;//接口数量
u2 interfaces[interfaces_count];//一个类可以实现多个接口
Java 类的继承关系由类索引、父类索引和接口索引集合三项确定。类索引、父类索引和接口索引集合按照顺序排在访问标志之后,
类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object
之外,所有的 Java 类都有父类,因此除了 java.lang.Object
外,所有 Java 类的父类索引都不为 0。
接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口将按 implements
(如果这个类本身是接口的话则是extends
) 后的接口顺序从左到右排列在接口索引集合中。
3.1.6 字段表集合 Fields & 方法表集合 Methods
u2 fields_count; //字段数量
field_info fields[fields_count];//字段
字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。
u2 methods_count;//方法数量
method_info methods[methods_count];//一个类可以有个多个方法
methods_count 表示方法的数量,而 method_info 表示方法表。
access_flags: 字段的作用域(
public
,private
,protected
修饰符),是实例变量还是类变量(static
修饰符),可否被序列化(transient 修饰符),可变性(final),可见性(volatile 修饰符,是否强制从主内存读写)。name_index: 对常量池的引用,表示的字段的名称;
descriptor_index: 对常量池的引用,表示字段和方法的描述符;
attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数;
attributes[attributes_count]: 存放具体属性具体内容。
3.1.7 属性表集合 Attributes
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
在 Class 文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。
3.2 类加载的时机
3.2.1 类的生命周期
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,注意只是开始的顺序确定。
3.2.2 初始化的六种情况
虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化,在这里介绍 4 种常见情况:
生成四条指令,其 Java 代码场景:
使用 new 关键字实例化对象;
读取或设置一个变量的静态字段;(除了常量池中,比如 final 修饰,3.1.3 讲解)
调用一个类型的静态方法。
使用
java.lang.reflect
包的方法对类进行反射调用时初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。(注意,接口不受此限制)
虚拟机需要在启动时初始化包含 main 方法的主类
3.2.3 关于类初始化的代码习题
我们来看如下代码:
package classload;
class Person {
{
System.out.println(1);
}
static {
System.out.println(2);
}
public Person() {
System.out.println(3);
}
public static int personStaticVal = 4;
public int personVal = 5;
public static final String HELLO_WORLD = "Hello World";
}
class Son extends Person {
{
System.out.println(6);
}
static {
System.out.println(7);
}
public Son() {
System.out.println(8);
}
public static int sonStaticVal = 9;
public int sonVal = 10;
}
第一题:
public class Main {
public static void main(String[] args) {
Son son = new Son();
}
}
静态初始化块(Static Initialization Blocks):
静态块在类加载时执行,且只执行一次。
首先加载
Person
类,执行其静态块System.out.println(2);
,输出2
。然后加载
Son
类,执行其静态块System.out.println(7);
,输出7
。
实例初始化块(Instance Initialization Blocks):
实例块在每次创建对象时执行,且在构造函数之前执行。
当
new Son()
被调用时,首先会调用父类Person
的构造函数。在调用
Person
的构造函数之前,先执行Person
的实例块System.out.println(1);
,输出1
。然后执行
Person
的构造函数System.out.println(3);
,输出3
。
子类的实例初始化块和构造函数:
父类的构造函数执行完毕后,回到子类
Son
的初始化过程。先执行
Son
的实例块System.out.println(6);
,输出6
。最后执行
Son
的构造函数System.out.println(8);
,输出8
。
第二题:
public class Main {
public static void main(String[] args) {
System.out.println(Son.personStaticVal);
}
}
只会输出 2 和 4,不会输出 7
对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类种定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。(不过子类也会加载)
第三题:
public class Main {
public static void main(String[] args) {
Person[] people = new Person[10];
}
}
运行后发现什么都没有输出,没有输出 2 ,说明没有触发 Person 类的初始化阶段。
但是这段代码里触发了另一个名为 “[Lorg.classload.Person" 的类初始化阶段,是由虚拟机自动生成的,直接继承于 java,lang.Object 的子类,创建动作由字节码指令触发。
这个类代表了一个元素类型为 Person 的一维数组,数组中应有的属性和方法(如 length 属性)都实现在这个类里。
第四题:
public class Main {
public static void main(String[] args) {
System.out.println(Son.HELLO_WORLD);
}
}
运行,程序只会输出 Hello World
在编译阶段,通过常量传播优化,已经将此常量的值直接存储在 Main 类的常量池中。即以后 Main 对这个常量 Son.HELLO_WORLD 的引用,实际都被转化为 Main 类对自身常量池的引用了。
也就是说,实际上 Main 类的 Class 文件之中并没有 ConstClass 类的符号引用入口,这两个类在编译成 Class 文件后就已经不存在任何联系了。
3.3 类加载的过程
3.3.1 加载
在加载阶段,Java 虚拟机需要完成以下三件事情:
通过一个类的全限定名来获取定义此类的二进制字节流。其中方式有很多:
从 class 文件中读取;
从 zip 压缩包或 jar 包中读取;
运行时计算生成,如动态代理……
将这个字节流所代表的静态存储结构转化为方法区运行时的数据结构。
在堆内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
加载过程既可以使用 Java 虚拟机里内置的启动类加载器来完成,也可以由用户自定义的类加载器去完成,如开发人员通过定义自己的类加载器去控制字节流的获取方式。
3.3.2 验证
验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段主要由四个检验阶段组成:
文件格式验证(Class 文件格式检查)
元数据验证(字节码语义检查)
字节码验证(程序语义检查)
符号引用验证(类的正确性检查)在解析阶段
3.3.3 准备
为静态变量分配内存并设置初始值(零值)。
内存在方法区中进行分配,具体实现与 JDK 版本有关。
3.3.4 解析
将常量池当中的符号引用替换为直接引用。
主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这 7 类符号引用进行。
3.3.5 初始化
执行类构造器 <clinit> 方法的过程。
3.4 类加载器
Java 虚拟机设计团队有意把类加载阶段中的 “通过一个类的全限定名来获取定义此类的二进制字节流” 这个动作放到 JVM 外部,以边让应用程序自己决定如何去获取所需的类。实现这个动作的代码就被称为类加载器(ClassLoader)。
3.4.1 类与类加载器
对于任意一个类,都必须由它的类加载器和这个类本身一起共同确立其在虚拟机中的唯一性,每一个类加载器都拥有一个类名称空间。
比较两个类是否“相等”,只有在这两个类由同一个类加载器加载的前提下才有意义:
类的Class对象的 equals 方法
isInstance() 方法 和 instanceof 关键字
public class ClassLoaderTest {
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
// 自定义一个类加载器 加载与自己相同路径的 class 文件
ClassLoader myClassLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
byte[] bytes = new byte[is.available()];
is.read(bytes);
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException();
}
}
};
// 用这个类加载器加载一个名为 "classload.ClassLoaderTest" 的类并实例化对象
Object object = myClassLoader.loadClass("classload.ClassLoaderTest").newInstance();
System.out.println(object.getClass());
System.out.println(object instanceof ClassLoaderTest);
}
}
代码输出:
class classload.ClassLoaderTest
false
两行输出结果中,第一行表示该对象是类 classload.ClassLoaderTest 实例化的,但是第二行输出中,发现这个对象与类 classload.ClassLoaderTest 做所属类型检查的时候返回了 false。
这是因为 Java 虚拟机中同时存在了两个 ClassLoaderTest 类,一个是由虚拟机的应用程序类加载器所加载的,另外一个是由我们自定义的类加载器加载的,虽然它们都来自同一个 Class 文件,但在 Java 虚拟机中仍然是两个互相独立的类,做对象所属类型检查时的结果自然为 false。
3.4.2 双亲委派模型
从 Java 虚拟机角度看只有两种类加载器:
启动类加载器:HotSpot 由 C++ 实现,是虚拟机自身的一部分;
其他所有的类加载器:由 Java 实现,独立存在于虚拟机外部,全部继承于抽象类 java.lang.ClassLoader。
而从 Java 开发者角度可以划分更细致一些:
启动类加载器:负责加载 <JAVA_HOME>\lib 目录(可以通过 JVM 启动参数配置),按照文件名识别类库,无法被 Java 程序直接引用。
扩展类加载器:在 sun.misc.Launcher$ExtClassLoader 中以 Java 代码的形式实现,一种 Java 系统类库的扩展机制,加载 <JAVA_HOME>\lib\ext 目录,可以在 Java 程序直接引用。
应用程序类加载器:在 sun.misc.Launcher$AppClassLoader 中以 Java 代码形式实现,负责加载用户类路径 ClassPath 上所有的类库,可以在 Java 代码中使用,默认的类加载器。
自定义类加载器。
这些类加载器之间有一定层次关系,其中一种最佳实践被称为 “双亲委派模型”:
要求:除了最顶层的启动类,其余类加载器都要有自己的类加载器,不通过继承方式而通过组合方式实现。
工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
好处:Java 的类随着它的类加载器一起具备了一种带有优先级的层次关系,保证了 Java 程序的稳定运行。比如我们在 ClassPath 中定义了一个 java.lang.Object 类,但是我们在加载 Object 类时,由于双亲委派机制,我们在启动类加载器中就已经找到 rt.jar 中的 java.lang.Object 类,而不会使用应用程序类加载器 AppClassLoader 加载自定义的 java.lang.Object 类,这个类永远无法被加载运行。
代码很简单:
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 首先,检查请求的类是否已经被加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出 ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载时
// 再调用本身的 findClass 方法进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
3.4.3 SPI 机制破坏双亲委派
双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),但是有时候基础类型又要调用回用户的代码,如 JNDI 服务。
JNDI 服务的代码由 JDK1.3 时加入到 rt.jar,由启动类加载器完成加载。但是 JNDI 需要调用其他厂商实现并附属再应用程序的 ClassPath 下的 JNDI 服务提供者接口(Service Provider Interface,SPI)的代码。为了解决这个困境,Java 设计团队引入了一个新的类加载器:线程上下文类加载器。
线程上下文类加载器的原理是将一个类加载器保存在线程私有数据里,跟线程绑定,然后在需要的时候取出来使用。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader() 方法进行设置。如果创建线程是还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
JNDI 服务使用这个线程上下文类加载器去加载所需的 SPI 服务代码,是一种父类加载器去请求子类加载器完成类加载的行为,打破了双亲委派模型。
参考
周志明 深入理解 Java 虚拟机 第三版
JavaGuide