Java-JVM
Java-JVM
①JVM概述
❶基本介绍
JVM:全称 Java Virtual Machine,一个虚拟计算机,Java 程序的运行环境(Java二进制字节码的运行环境)
特点:
- Java 虚拟机基于二进制字节码执行,由一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆、一个方法区等组成
- JVM 屏蔽了与操作系统平台相关的信息,从而能够让 Java 程序只需要生成能够在 JVM 上运行的字节码文件,通过该机制实现的跨平台性。即一次编译,处处执行
- 自动的内存管理,垃圾回收机制
JVM 结构:
image-20221220215946131
Java 代码执行流程:Java 程序(.java) --(编译)--> 字节码文件(.class)--(解释执行/JIT)--> 操作系统(Win,Linux)
JVM、JRE、JDK 对比:
- JDK(Java SE Development Kit):Java 标准开发包,提供了编译、运行 Java 程序所需的各种工具和资源
- JRE( Java Runtime Environment):Java 运行环境,用于解释执行 Java 的字节码文件
❷架构模型
Java 编译器输入的指令流是一种基于栈的指令集架构。因为跨平台的设计,Java 的指令都是根据栈来设计的,不同平台 CPU 架构不同,所以不能设计为基于寄存器架构
- 基于栈式架构的特点:
- 设计和实现简单,适用于资源受限的系统
- 使用零地址指令方式分配,执行过程依赖操作栈,指令集更小,编译器容易实现
- 零地址指令:机器指令的一种,是指令系统中的一种不设地址字段的指令,只有操作码而没有地址码。这种指令有两种情况:一是无需操作数,另一种是操作数为默认的(隐含的),默认为操作数在寄存器(ACC)中,指令可直接访问寄存器
- 一地址指令:一个操作码对应一个地址码,通过地址码寻找操作数
- 不需要硬件的支持,可移植性更好,更好实现跨平台
- 基于寄存器架构的特点:
- 需要硬件的支持,可移植性差
- 性能更好,执行更高效,寄存器比内存快
- 以一地址指令、二地址指令、三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主
❸生命周期
JVM 的生命周期分为三个阶段,分别为:启动、运行、死亡
-
启动:当启动一个 Java 程序时,通过引导类加载器(bootstrap class loader)创建一个初始类(initial class),对于拥有 main 函数的类就是 JVM 实例运行的起点
-
运行:
-
main() 方法是一个程序的初始起点,任何线程均可由在此处启动
-
在 JVM 内部有两种线程类型,分别为:用户线程和守护线程,JVM 使用的是守护线程,main() 和其他线程使用的是用户线程,守护线程会随着用户线程的结束而结束
-
执行一个 Java 程序时,真真正正在执行的是一个 Java 虚拟机的进程
-
JVM 有两种运行模式 Server 与 Client,两种模式的区别在于:Client 模式启动速度较快,Server 模式启动较慢;但是启动进入稳定期长期运行之后 Server 模式的程序运行速度比 Client 要快很多
Server 模式启动的 JVM 采用的是重量级的虚拟机,对程序采用了更多的优化;Client 模式启动的 JVM 采用的是轻量级的虚拟机
-
-
死亡:
- 当程序中的用户线程都中止,JVM 才会退出
- 程序正常执行结束、程序异常或错误而异常终止、操作系统错误导致终止
- 线程调用 Runtime 类 halt 方法或 System 类 exit 方法,并且 Java 安全管理器允许这次 exit 或 halt 操作
②内存结构
❶JVM内存
0.内存概述
内存结构是 JVM 中非常重要的一部分,是非常重要的系统资源,是硬盘和 CPU 的桥梁,承载着操作系统和应用程序的实时运行,又叫运行时数据区。
JVM 内存结构规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行
- Java1.8 以前的内存结构图:
- Java1.8 之后的内存结果图:
1.程序计数器
Program Counter Register 程序计数器(寄存器)
作用:内部保存字节码的行号,用于记录正在执行的字节码指令地址(如果正在执行的是本地方法则为空)
原理:
- JVM 对于多线程是通过线程轮流切换并且分配线程执行时间,一个处理器只会处理执行一个线程
- 切换线程需要从程序计数器中来回切换到当前的线程上一次执行的行号
特点:
- 是线程私有的
- 不会存在内存溢出,是 JVM 规范中唯一一个不出现 OOM 的区域,所以这个空间不会进行 GC
2.虚拟机栈
Java 虚拟机栈:Java Virtual Machine Stacks,每个线程运行时所需要的内存
异常:
java.lang.StackOverflowError
- 每个方法被执行时,都会在虚拟机栈中创建一个栈帧 stack frame(一个方法一个栈帧)
- Java 虚拟机规范允许 Java 栈的大小是动态的或者是固定不变的
- 虚拟机栈是每个线程私有的,每个线程只能有一个活动栈帧,对应方法调用到执行完成的整个过程
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存,每个栈帧中存储着:
- 局部变量表:存储方法里的 Java 基本数据类型以及对象的引用(reference 类型)
- 动态链接:也叫指向运行时常量池的方法引用
- 方法返回地址:方法正常退出或者异常退出的定义
- 操作数栈或表达式栈和其他一些附加信息
设置栈内存大小:-Xss size
-Xss 1024k
(在 VM options 中设置)
- 在 JDK 1.4 中默认为 256K,而在 JDK 1.5+ 默认为 1M
虚拟机栈特点:
- 栈内存不需要进行GC,方法开始执行的时候会进栈,方法调用后自动弹栈,相当于清空了数据
- 栈内存分配越大,可用的线程数越少(内存越大,每个线程拥有的内存越大)
- 方法内的局部变量是否线程安全:
- 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的(逃逸分析)
- 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
异常:
- 栈帧过多导致栈内存溢出 (超过了栈的容量),会抛出 OutOfMemoryError 异常
- 当线程请求的栈深度超过虚拟机允许的最大深度时,会抛出 StackOverflowError 异常
线程运行诊断:
- 定位:
- jps 定位进程 ID
- top定位哪个进程对cpu的占用过高
- ps -eo pid,%cpu | grep 进程id (用ps命令进一步定位是哪个线程引起的cpu占用过高)
- jstack 进程 ID:用于打印出给定的 Java 进程 ID 或 core file 或远程调试服务的 Java 堆栈信息
3.本地方法栈
本地方法栈:Native Method Stacks, 为虚拟机执行本地方法(native方法)时提供服务的
- 本地方法一般是由其他语言编写(C/C++),并且被编译为基于本机硬件和操作系统的程序
- 与虚拟机栈类似,不需要进行 GC,也是线程私有的,有 StackOverFlowError 和 OutOfMemoryError 异常
- 本地方法用
native
关键字修饰,例如:Object类中的wait()、clone()、hashCode等
1 | public final native void wait(); |
当某个线程调用一个本地方法时,就进入了不再受虚拟机限制的世界,和虚拟机拥有同样的权限
- 本地方法可以通过本地方法接口(JNI:Java Native Interface)来访问虚拟机内部的运行时数据区
- 直接从本地内存的堆中分配任意数量的内存
- 可以直接使用本地处理器中的寄存器
4.堆
堆概述
堆:Heap ,是 JVM 内存中最大的一块,由所有线程共享,由垃圾回收器管理的主要区域,堆中对象大部分都需要考虑线程安全的问题
异常:java.lang.OutOfMemoryError:java heap space
存放哪些资源:
-
对象实例:new 创建的对象,类初始化生成的对象,基本数据类型的数组也是对象实例(new 创建)
-
字符串常量池
StringTable/String Pool: JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
- 字符串常量池原本存放于方法区,JDK7 开始放置于堆中
- 字符串常量池存储的是 String 对象的直接引用或者对象,是一张 stringTable
- 在jdk 7之后,原先位于方法区里的字符串常量池已被移动到了java堆中。
-
静态变量:静态变量是有 static 修饰的变量,JDK8 时从方法区迁移至堆中
-
线程分配缓冲区 Thread Local Allocation Buffer:线程私有但不影响堆的共性,可以提升对象分配的效率
内存溢出:new 出对象,循环添加字符数据,当堆中没有内存空间可分配给实例,也无法再扩展时,就会抛出 OutOfMemoryError 异常
设置堆内存指令:-Xmx Size
1 | /** |
堆内存诊断工具
- jps:查看当前系统中有哪些 Java 进程
- jmap:查看某一时刻堆内存占用情况
jhsdb jmap --heap --pid 进程id
- jconsole:图形界面的,多功能的监测工具,可以连续监测
- jvisualvm:图形界面的,多功能的监测工具,可以连续监测
Java7/8堆变化
在 Java7 中堆内会存在年轻代、老年代和方法区(永久代),Java8 永久代被元空间代替了
- Young 区被划分为三部分,Eden 区和两个大小严格相同的 Survivor 区。Survivor 区某一时刻只有其中一个是被使用的,另外一个留做垃圾回收时复制对象。在 Eden 区变满的时候,GC 就会将存活的对象移到空闲的 Survivor 区间中,根据 JVM 的策略,在经过几次垃圾回收后,仍然存活于 Survivor 的对象将被移动到 Tenured 区间
- Old 区主要保存生命周期长的对象,一般是一些老的对象,当一些对象在 Young 复制转移一定的次数以后,对象就会被转移到 Tenured 区
- Perm 代主要保存 Class、ClassLoader、静态变量、常量、编译后的代码,在 Java7 中堆内方法区会受到 GC 的管理
分代原因:不同对象的生命周期不同,70%-99% 的对象都是临时对象,优化 GC 性能
1 | public static void main(String[] args) { |
StringTable/String Pool
字符串常量池(String Pool / StringTable / 串池)存储的是 String 对象的直接引用或者对象,即保存着所有字符串字面量(literal strings),这些字面量在编译时期就确定,字符串常量池类似于 Java 系统级别提供的缓存,存放对象和引用
StringTable,类似 HashTable 结构,通过
-XX:StringTableSize
设置大小,JDK 1.8 中默认 60013
字符串拼接
- 常量池中的字符串仅是符号,第一次使用时才变为对象(加入到运行时常量池),可以避免重复创建字符串对象
1 | // 字符串常量池(StringTable): [ "a", "b" ,"ab" ] hashtable 结构,不能扩容 |
字节码:Java 反编译指令javap -v 文件名.class
1 | //常量池 |
- 字符串变量的拼接的原理是 StringBuilder#append,append 方法比字符串拼接效率高(JDK 1.8)
- 字符串常量拼接的原理是编译期优化,拼接结果放入常量池
1 | // 字符串常量池(StringTable): [ "a", "b" ,"ab" ] hashtable 结构,不能扩容 |
intern()
JDK 1.8:将这个字符串对象尝试放入串池,如果 String Pool 中:
- 存在一个字符串和该字符串值相等,就会返回 String Pool 中字符串的引用(需要变量接收)
- 不存在,会把对象的引用地址复制一份放入串池,并返回串池中的引用地址,前提是堆内存有该对象,因为 Pool 在堆中,为了节省内存不再创建新对象
JDK 1.6:将这个字符串对象尝试放入串池,如果 String Pool 中:
- 如果有就不放入,返回已有的串池中的对象的引用;
- 如果没有会把此对象复制一份,放入串池,把串池中的对象返回
1 | // StringTable: ["ab", "a", "b"] |
- == 比较基本数据类型:比较的是具体的值
- == 比较引用数据类型:比较的是对象地址值
结论:
1 | String s1 = "ab"; // ab仅放入串池 StringTable: ["ab"] |
常见问题
问题一:
1 | public static void main(String[] args) { |
问题二:
1 | public static void main(String[] args) { |
原因:
-
System 类当调用 Version 的静态方法,导致 Version 初始化:
1
2
3private static void initializeSystemClass() {
sun.misc.Version.init();
} -
Version 类初始化时需要对静态常量字段初始化,被 launcher_name 静态常量字段所引用的
"java"
字符串字面量就被放入的字符串常量池:1
2
3
4
5
6
7
8
9
10package sun.misc;
public class Version {
private static final String launcher_name = "java";
private static final String java_version = "1.8.0_221";
private static final String java_runtime_name = "Java(TM) SE Runtime Environment";
private static final String java_profile_name = "";
private static final String java_runtime_version = "1.8.0_221-b11";
//...
}
内存位置
Java 7 之前,String Pool 被放在运行时常量池中,属于永久代;Java 7 以后,String Pool 被移到堆中,这是因为永久代的空间有限,在大量使用字符串的场景下会导致 OutOfMemoryError 错误
演示 StringTable 位置:
-
-Xmx10m
设置堆内存 10m -
在 JDK8 下设置:
-Xmx10m -XX:-UseGCOverheadLimit
-
在 JDK6 下设置:
-XX:MaxPermSize=10m
1
2
3
4
5
6
7
8
9
10
11
12
13
14public static void main(String[] args) throws InterruptedException {
List<String> list = new ArrayList<String>();
int i = 0;
try {
for (int j = 0; j < 260000; j++) {
list.add(String.valueOf(j).intern());
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
优化常量池
两种方式:
- 调整
-XX:StringTableSize=桶个数
,数量越少,性能越差 - intern 将字符串对象放入常量池,通过复用字符串的引用,减少内存占用
1 | /** |
不可变好处
- 可以缓存 hash 值,例如 String 用做 HashMap 的 key,不可变的特性可以使得 hash 值也不可变,只要进行一次计算
- String Pool 的需要,如果一个 String 对象已经被创建过了,就会从 String Pool 中取得引用,只有 String 是不可变的,才可能使用 String Pool
- 安全性,String 经常作为参数,String 不可变性可以保证参数不可变。例如在作为网络连接参数的情况下如果 String 是可变的,那么在网络连接过程中,String 被改变,改变 String 的那一方以为现在连接的是其它主机,而实际情况却不一定是
- String 不可变性天生具备线程安全,可以在多个线程中安全地使用
- 防止子类继承,破坏 String 的 API 的使用
5.方法区
方法区 Method Area:是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、即时编译器编译后的代码等数据,虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是也叫 Non-Heap(非堆)
Java 1.8 以前:方法区由永久代实现
Java 1.8 之后:方法区由元空间实现
异常:java.lang.OutOfMemoryError:Metaspace
方法区构成:
-
类元信息:在类编译期间放入方法区,存放了类的基本信息,包括类的方法、参数、接口以及常量池表
-
常量池表(Constant Pool Table)是 Class 文件的一部分,存储了类在编译期间生成的字面量、符号引用,JVM 为每个已加载的类维护一个常量池
-
字面量:基本数据类型、字符串类型常量、声明为 final 的常量值等
-
java 代码在编译过程中是无法构建引用的,字面量就是在编译时对于数据的一种表示
1
2final int a = 1; //这个1便是字面量
String b = "jwt"; //jwt便是字面量 -
-
符号引用:类、字段、方法、接口等的符号引用
- 在编译过程中并不知道每个类的地址,因为可能这个类还没有加载,如果在一个类中引用了另一个类,无法知道它的内存地址,只能用它的类名作为符号引用,在类加载完后用这个符号引用去获取内存地址
-
-
运行时常量池
- 常量池(编译器生成的字面量和符号引用)中的数据会在类加载的加载阶段放入运行时常量池
- 类在解析阶段将这些符号引用替换成直接引用
- 除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern()
特点:
- 方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式
- 方法区的大小不必是固定的,可以动态扩展,加载的类太多,可能导致永久代内存溢出 (OutOfMemoryError)
- 方法区的 GC:针对常量池的回收及对类型的卸载,比较难实现
- 为了避免方法区出现 OOM,在 JDK8 中将堆内的方法区(永久代)移动到了本地内存上,重新开辟了一块空间,叫做元空间,元空间存储类的元信息,静态变量和字符串常量池等放入堆中
- Java 1.8 以前:永久代内存溢出 java.lang.OutOfMemoryError: PermGen space
- Java 1.8 之后:元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
❷本地内存
JVM内存:Java 虚拟机在执行的时候会把管理的内存分配成不同的区域,受虚拟机内存大小的参数控制,当大小超过参数设置的大小时就会报 OOM
本地内存:又叫做堆外内存,线程共享的区域,本地内存这块区域是不会受到 JVM 的控制的,不会发生 GC;因此对于整个 Java 的执行效率是提升非常大,但是如果内存的占用超出物理内存的大小,同样也会报 OOM
1.方法区/元空间
Java8 开始 PermGen 被元空间代替,永久代的类信息、方法、常量池等都移动到元空间区
元空间与永久代区别:元空间不在虚拟机中,使用的本地内存,默认情况下,元空间的大小仅受本地内存限制
方法区内存溢出:
-
JDK1.8 以前会导致永久代内存溢出:java.lang.OutOfMemoryError: PerGen space
1
-XX:MaxPermSize=8m #参数设置
-
JDK1.8 以后会导致元空间内存溢出:java.lang.OutOfMemoryError: Metaspace
1
-XX:MaxMetaspaceSize=8m #参数设置
元空间内存溢出演示:
1 | /** |
2.直接内存
直接内存是 Java 堆外、直接向系统申请的内存区间,不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
概述
Direct Memory 优点:
- Java 的 NIO 库允许 Java 程序使用直接内存,使用 native 函数直接分配堆外内存
- 读写性能高,读写频繁的场合可能会考虑使用直接内存
- 大大提高 IO 性能,避免了在 Java 堆和 native 堆来回复制数据
直接内存缺点:
- 不能使用内核缓冲区 Page Cache 的缓存优势,无法缓存最近被访问的数据和使用预读功能
- 分配回收成本较高,不受 JVM 内存回收管理
- 可能导致 OutOfMemoryError 异常:OutOfMemoryError: Direct buffer memory
- 回收依赖 System.gc() 的调用,但这个调用 JVM 不保证执行、也不保证何时执行,行为是不可控的。程序一般需要自行管理,成对去调用 malloc、free
应用场景:
- 传输很大的数据文件,数据的生命周期很长,导致 Page Cache 没有起到缓存的作用,一般采用直接 IO 的方式
- 适合频繁的 IO 操作,比如网络并发场景
数据流的角度:
- 非直接内存的作用链:本地 IO → 内核缓冲区→ 用户(JVM)缓冲区 →内核缓冲区 → 本地 IO
- 直接内存的作用链:本地 IO → 直接内存 → 本地 IO
直接内存 | |
---|---|
非直 接内存 |
ByteBuffer
ByteBuffer 有两种类型:
- 一种是基于直接内存(非堆内存):DirectByteBuffer
- 一种是非直接内存(堆内存):HeapByteBuffer
描述 | 优点 | |
---|---|---|
HeapByteBuffer | 在jvm堆上面的一个buffer,底层的本质是一个数组 | 由于内容维护在jvm里,所以把内容写进buffer里速度会快些;并且,可以更容易回收 |
DirectByteBuffer | 底层的数据其实是维护在操作系统的内存中,而不是jvm里,DirectByteBuffer里维护了一个引用address指向了数据,从而操作数据 | 跟外设(IO设备)打交道时会快很多,因为外设读取jvm堆里的数据时,不是直接读取的,而是把jvm里的数据读到一个内存块里,再在这个块里读取的,如果使用DirectByteBuffer,则可以省去这一步,实现zero copy |
分配回收
直接内存 DirectByteBuffer 源码分析:
1 | DirectByteBuffer(int cap) { |
分配和回收原理:
- 使用了
Unsafe
对象的allocateMemory
方法完成直接内存的分配,setMemory 方法完成赋值 - ByteBuffer 的实现类内部,使用了 Cleaner(虚引用)来监测 ByteBuffer 对象,一旦 ByteBuffer 对象被垃圾回收,那么 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 Deallocator 的 run方法,最后通过
freeMemory
来释放直接内存
❸JVM运行原理
接下来,我们通过一个案例来了解下代码和对象是如何分配存储的,Java 代码又是如何在 JVM 中运行的。
1 | public class JVMCase { |
运行上面代码时,JVM的整个处理过程如下:
- 1.JVM 向操作系统申请内存,JVM 首先通过配置参数或者默认配置参数向操作系统申请内存空间,根据内存大小找到具体的内存分配表,然后把内存段的起始地址和终止地址分配给 JVM。
- 2.JVM 获得内存空间后,就进行内部分配。JVM 根据配置参数分配堆、栈以及方法区的内存大小。
- 3.class 文件加载、验证、准备以及解析,其中准备阶段会为类的静态变量分配内存,初始化为系统的初始值
-
4.完成上一个步骤后,将会进行最后一个初始化阶段。在这个阶段中,JVM 首先会执行构造器
()
方法,编译器会在.java 文件被编译成.class 文件时,收集所有类的初始化代码,包括静态变量赋值语句、静态代码块、静态方法,收集在一起成为()
方法。 -
5.执行方法。启动 main 线程,执行 main 方法,开始执行第一行代码。此时堆内存中会创建一个 student 对象,对象引用 student 就存放在栈中。
-
6.创建一个 JVMCase 对象,调用 sayHello 非静态方法,sayHello 方法属于对象 JVMCase,此时 sayHello 方法入栈,并通过栈中的 student 引用调用堆中的 Student 对象;之后,调用静态方法 print,print 静态方法属于 JVMCase 类,是从静态方法中获取,之后放入到栈中,也通过 student 引用调用堆中的 student 对象。
❹总结
1.异常
常见 Out Of Memory(OOM) 错误:
-
java.lang.StackOverflowError
- 栈溢出
- 设置栈内存大小:
-Xss size
-
java.lang.OutOfMemoryError:java heap space
- 堆溢出
- 设置堆内存指令:
-Xmx Size
-
java.lang.OutOfMemoryError:GC overhead limit exceeded
- 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
- 即程序基本上耗尽了所有的可用内存, GC也清理不了。
-
java.lang.OutOfMemoryError:Direct buffer memory
- 直接内存溢出
-
java.lang.OutOfMemoryError:unable to create new native thread
- 系统内存耗尽,无法为新线程分配内存或者创建线程数超过了操作系统的限制
-
java.lang.OutOfMemoryError: PermGen space
- Java 1.8 以前:永久代内存溢出
-XX:MaxPermSize=8m
-
java.lang.OutOfMemoryError:Metaspace
- Java 1.8 之后:元空间内存溢出
-XX:MaxMetaspaceSize=8m
Java 编译指令:javac -g 文件名.java
-g 可以生成所有相关信息
Java 反编译指令:javap -v 文件名.class
-v 输出附加信息
后台运行:nohup java 全路径名
2.三种常量池
常量池:主要存放编译期生成的各种字面量(Literal)和符号引用(Symbolic References)。
- 字面量:例如文本字符串、fina修饰的常量。
- 符号引用:例如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符
运行时常量池:运行时常量池里面存储的主要是编译期间生成的字面量、符号引用等等。
- 当类加载到内存中后,JVM就会将常量池中的内容存放到运行时常量池中;
- 类加载在链接环节的解析过程,会符号引用转换成直接引用(静态链接)。此处得到的直接引用也是放到运行时常量池中的。
- 运行期间可以动态放入新的常量。
字符串常量池:可以理解成运行时常量池分出来的一部分。类加载到内存的时候,字符串会存到字符串常量池里面。
- JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
- JDK6时字符串常量池位于运行时常量池,JDK7挪到堆中。
3者区别?
- 常量池与运行时常量池都存储在方法区,而字符串常量池在 Jdk7 时就已经从方法区迁移到了 Java 堆中
在类编译过程中,会把类元信息放到方法区,类元信息的其中一部分便是常量池,主要存放字面量和符号引用,而字面量的一部分便是文本字符;在类加载时将字面量和符号引用解析为直接引用存储在运行时常量池;对于文本字符,会在解析时查找字符串常量池,查出这个文本字符对应的字符串对象的直接引用,将直接引用存储在运行时常量池
3.变量位置
变量的位置不取决于它是基本数据类型还是引用数据类型,取决于它的声明位置
静态内部类和其他内部类:方法区/堆
- 一个 class 文件只能对应一个 public 类型的类,这个类可以有内部类,但不会生成新的 class 文件
- 静态内部类属于类本身,加载到方法区,其他内部类属于内部类的属性,加载到堆
类变量:堆
- 类变量是用 static 修饰符修饰,定义在方法外的变量,随着 Java 进程产生和销毁
- 在 Java8 之前把静态变量存放于方法区,在 Java8 时存放在堆中的静态变量区
实例变量:堆
- 实例(成员)变量是定义在类中,没有 static 修饰的变量,随着类的实例产生和销毁,是类实例的一部分
- 在类初始化的时候,从运行时常量池取出直接引用或者值,与初始化的对象一起放入堆中
局部变量:虚拟机栈
- 局部变量是定义在类的方法中的变量
- 在所在方法被调用时放入虚拟机栈的栈帧中,方法执行结束后从虚拟机栈中弹出
③对象实例化
❶对象内存结构
一个 Java 对象内存中存储为三部分:对象头(Header)、实例数据(Instance Data)和对齐填充 (Padding)
对象头:
- 普通对象:分为
Mark Word
和Klass Word
两部分
1 | |--------------------------------------------------------------| |
Mark Word
:用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志(最后两位)、线程持有的锁、偏向线程ID、偏向时间戳等等
32 位虚拟机 Mark Word
1 | |-------------------------------------------------------|--------------------| |
64 位虚拟机 Mark Word
1 | |--------------------------------------------------------------------|--------------------| |
1 | hash(25) + age(4) + lock(3) = 32bit #32位系统 |
Klass Word
:类型指针,指向该对象的 Class 类对象的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;在 64 位系统中,默认开启指针压缩(-XX:+UseCompressedOops
),使用32bits指针。堆内存大于32G时,压缩指针会失效,会强制使用64bits来进行对象寻址
- 数组对象:如果对象是一个数组,那在对象头中还有一块数据用于记录数组长度(12 字节)
1 | |-------------------------------------------------------------------------------| |
实例数据:实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的,都需要记录起来
对齐填充:Padding 起占位符的作用。64 位系统,由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,就是对象的大小必须是 8 字节的整数倍,而对象头部分正好是 8 字节的倍数(1 倍或者 2 倍),因此当对象实例数据部分没有对齐时,就需要通过对齐填充来补全
32 位系统:
-
一个 int 在 java 中占据 4byte,所以 Integer 的大小为:
1
2# 需要补位4byte
4(Mark Word) + 4(Klass Word) + 4(data) + 4(Padding) = 16byte -
int[] arr = new int[10]
1
2# 由于需要8位对齐,所以最终大小为56byte
4(Mark Word) + 4(Klass Word) + 4(length) + 4*10(10个int大小) + 4(Padding) = 56sbyte
❷对象访问方式
JVM 是通过**栈帧中的对象引用(reference)**访问到堆中的对象实例:
-
句柄访问:Java 堆中会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息
优点:reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改
-
直接指针(HotSpot 采用):reference 中直接存储的对象地址,对象中包含对象类型数据的指针,通过这个指针可以访问对象类型数据。
优点:速度更快,节省了一次指针定位的时间开销
缺点:对象被移动时(如进行 GC 后的内存重新排列),对象的 reference 也需要同步更新
❸对象创建过程
创建对象的方式
- new:最常见的方式
- Class的newInstance方法(反射机制)
- Constructor的newInstance(XXX)(反射机制)
- 使用clone():不调用任何的构造器,要求当前的类需要实现Cloneable接口,实现clone()
- 使用序列化:从文件中、从网络中获取一个对象的二进制流
- 第三方库 Objenesis
创建对象的过程
- 类加载检查
当虚拟机遇到一条 new
指令时,首先检查是否能在运行时常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那先执行类加载。
- 为对象分配内存
首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。
- 如果内存规整:虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存。
- 如果内存不规整:虚拟机需要维护一个空闲列表(Free List)来为对象分配内存。
选择哪种分配方式由堆是否规整所决定,而堆是否规整又由所采用的GC收集器是否带有压缩整理功能决定。
内存分配并发问题
在创建对象的时候有一个很重要的问题,就是线程安全,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB: 为每一个线程预先在 Eden 区分配一块内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配,通过设置
-XX:+UseTLAB
参数来设定
- 初始化零值
分配到的内存空间都初始化为零值,通过这个操作保证了对象的字段可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
- 设置对象头
将对象的所属类(即类的元数据信息)、对象的哈希码、对象的GC分代年龄、锁信息等数据存储在对象的对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- 执行 init 方法
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,执行 new 指令之后会接着执行 `` 方法(初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量),把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
③垃圾回收
❶内存分配
1.两种方式
JVM 为对象分配内存的过程:首先计算对象占用空间大小,接着在堆中划分一块内存给新对象
- 如果内存规整,使用指针碰撞(Bump The Pointer)。所有用过的内存在一边,空闲的内存在另外一边,中间有一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离
- 如果内存不规整,虚拟机维护一个空闲列表(Free List)。已使用的内存和未使用的内存相互交错,列表上记录哪些内存块是可用的,分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容
2.TLAB
Thread Local Allocation Buffer,TLAB 是虚拟机在堆内存的 Eden 划分出来的一块专用空间,是线程专属的。
在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。
多线程分配内存时,使用 TLAB 可以避免线程安全问题,同时还能够提升内存分配的吞吐量,这种内存分配方式叫做快速分配策略
我们说TLAB是线程独享的,但是只是在“分配”这个动作上是线程独享的,至于在读取、垃圾回收等动作上都是线程共享的。
- 栈上分配使用的是栈来进行对象内存的分配
- TLAB 分配使用的是 Eden 区域进行内存分配,属于堆内存
堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据,由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
问题:堆空间都是共享的么? 不一定,因为还有 TLAB,在堆中划分出一块区域,为每个线程所独占
JVM 是将 TLAB 作为内存分配的首选,但不是所有的对象实例都能够在 TLAB 中成功分配内存,一旦对象在 TLAB 空间分配内存失败时,JVM 就会通过使用加锁机制确保数据操作的原子性,从而直接在堆中分配内存
栈上分配优先于 TLAB 分配进行,逃逸分析中若可进行栈上分配优化,会优先进行对象栈上直接分配内存
参数设置:
-XX:UseTLAB
:设置是否开启 TLAB 空间-XX:TLABWasteTargetPercent
:设置 TLAB 空间所占用 Eden 空间的百分比大小,默认情况下 TLAB 空间的内存非常小,仅占有整个 Eden 空间的1%-XX:TLABRefillWasteFraction
:指当 TLAB 空间不足,请求分配的对象内存大小超过此阈值时不会进行 TLAB 分配,直接进行堆内存分配,否则还是会优先进行 TLAB 分配
❷分代思想
1.分代介绍
Java8 时,堆被分为了两份:新生代和老年代(1:2),在 Java7 时,还存在一个永久代
- 新生代使用:复制算法
- 老年代使用:标记 - 清除 或者 标记 - 整理 算法
Young 区被划分为三部分,Eden 区和两个大小严格相同的 Survivor 区。 Eden 和 Survivor 大小比例默认为 8:1:1
- Survivor 区某一时刻只有其中一个是被使用的,另外一个留做垃圾回收时复制对象。
- 在 Eden 区变满的时候,GC 就会将存活的对象移到空闲的 Survivor 区间中,根据 JVM 的策略,在经过几次垃圾回收后,仍然存活于 Survivor 的对象将被移动到 Old 区间
Old 区主要保存生命周期长的对象,一般是一些老的对象,当一些对象在 Young 复制转移一定的次数以后,对象就会被转移到 Old 区
分代原因:不同对象的生命周期不同,70%-99% 的对象都是临时对象,优化 GC 性能
GC:
- Minor GC:回收新生代,新生代对象存活时间很短,所以 Minor GC 会频繁执行,执行的速度比较快
- Major GC:回收老年代。目前只有CMS收集器会有单独收集老年代的行为。
- Mixed GC:回收整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
- Full GC:回收整个Java堆和方法区。回收老年代和新生代,老年代对象其存活时间长,所以 Full GC 很少执行,执行速度会比 Minor GC 慢很多
2.分代分配
工作机制:
- 对象优先在 Eden 分配:当创建一个对象的时候,对象会被分配在新生代的 Eden 区,当 Eden 区要满了时候,触发 Minor GC
- 当进行 Minor GC 时,将 Eden 区存活的对象被移动到 to 区,并且当前对象的年龄会加 1,清空 Eden 区,再将 from 和 to 两个区域互换
- 当再一次触发 Minor GC 的时候,会把 Eden 区中存活下来的对象和 from 中的对象,移动到 to 区中,这些对象的年龄会加 1,清空 Eden 区和 from 区,再将 from 和 to 两个区域互换
- To 区永远是空 Survivor 区,From 区是有数据的,每次 MinorGC 后两个区域互换
- From 区和 To 区 也可以叫做 S0 区和 S1 区
晋升到老年代:
-
大对象直接进入老年代:大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。为了避免在 Eden 和 Survivor 之间的大量复制而降低效率。
-XX:PretenureSizeThreshold
:大于此值的对象直接在老年代分配 -
长期存活的对象进入老年代:为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中
-XX:MaxTenuringThreshold
:定义年龄的阈值,对象头中用 4 个 bit 存储,所以最大值是 15,默认也是 15 -
动态对象年龄判定:Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累加,当累加到某个年龄时,所占用大小超过了 Survivor 空间的 50% 时,则大于等于该年龄的对象就可以直接进入老年代,无须等到
MaxTenuringThreshold
中要求的年龄。-XX:TargetSurvivorRatio=percent
:设定survivor区的目标使用率,默认值是 50%取这个年龄和
MaxTenuringThreshold
中更小的一个值,作为新的晋升年龄的阈值
空间分配担保:
- 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的
- 如果不成立,虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于将尝试着进行一次 Minor GC;如果小于或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC
❸回收策略
1.触发条件
内存垃圾回收机制主要集中的区域就是线程共享区域:堆和方法区
Minor GC 触发条件:当 Eden 空间满时,就将触发一次 Minor GC
Full GC 同时回收新生代、老年代和方法区,有以下触发条件:
- 调用 System.gc():
- 在默认情况下,通过 System.gc() 或 Runtime.getRuntime().gc() 的调用,会显式触发 FullGC,同时对老年代和新生代进行回收,但是虚拟机不一定真正去执行,无法保证对垃圾收集器的调用
- 不建议使用这种方式,应该让虚拟机管理内存。一般情况下,垃圾回收应该是自动进行的,无须手动触发;在一些特殊情况下,如正在编写一个性能基准,可以在运行之间调用 System.gc()
- 老年代空间不足:
- 为了避免引起的 Full GC,应当尽量不要创建过大的对象以及数组
- 通过 -Xmn 参数调整新生代的大小,让对象尽量在新生代被回收掉不进入老年代,可以通过
-XX:MaxTenuringThreshold
调大对象进入老年代的年龄,让对象在新生代多存活一段时间
- 空间分配担保失败
- JDK 1.7 及以前的永久代(方法区)空间不足
- Concurrent Mode Failure:执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC
手动 GC 测试,VM参数:-XX:+PrintGcDetails
1 | public void localvarGC1() { |
2.安全区域
安全点 (Safepoint):程序执行时并非在所有地方都能停顿下来开始 GC,只有在安全点才能停下
- Safe Point 的选择很重要,如果太少可能导致 GC 等待的时间太长,如果太多可能导致运行时的性能问题
- 大部分指令的执行时间都非常短,通常会根据是否具有让程序长时间执行的特征为标准,选择些执行时间较长的指令作为 Safe Point, 如方法调用、循环跳转和异常跳转等
在 GC 发生时,让所有线程都在最近的安全点停顿下来的方法:
- 抢先式中断:没有虚拟机采用,首先中断所有线程,如有线程不在安全点,就恢复线程让线程运行到安全点
- 主动式中断:设置一个中断标志,各个线程运行到各个 Safe Point 时就轮询这个标志,如果中断标志为真,则将自己进行中断挂起
问题:Safepoint 保证程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint,但是当线程处于 Waiting 状态或 Blocked 状态,线程无法响应 JVM 的中断请求,运行到安全点去中断挂起,JVM 也不可能等待线程被唤醒,对于这种情况,需要安全区域来解决
安全区域 (Safe Region):指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 GC 都是安全的
运行流程:
- 当线程运行到 Safe Region 的代码时,首先标识已经进入了 Safe Region,如果这段时间内发生 GC,JVM 会忽略标识为 Safe Region 状态的线程
- 当线程即将离开 Safe Region 时,会检查 JVM 是否已经完成 GC,如果完成了则继续运行,否则线程必须等待 GC 完成,收到可以安全离开 Safe Region 的信号
3.GC分类
- Minor GC:回收新生代,新生代对象存活时间很短,所以 Minor GC 会频繁执行,执行的速度比较快
- Major GC:回收老年代。目前只有CMS收集器会有单独收集老年代的行为。
- Mixed GC:回收整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
- Full GC:回收整个Java堆和方法区。老年代对象其存活时间长,所以 Full GC 很少执行,执行速度会比 Minor GC 慢很多
❹垃圾判断
1.垃圾介绍
垃圾:如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾
作用:释放没用的对象,清除内存里的记录碎片,碎片整理将所占用的堆内存移到堆的一端,以便 JVM 将整理出的内存分配给新的对象
区域:垃圾收集主要是针对堆和方法区进行,程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收
在堆里存放着几乎所有的 Java 对象实例,在 GC 执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC 才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程可以称为垃圾标记阶段,判断对象存活一般有两种方式:引用计数算法和可达性分析算法
2.引用计数法
引用计数算法(Reference Counting):对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加 1;当引用失效时,引用计数器就减 1;当对象 A 的引用计数器的值为 0,即表示对象A不可能再被使用,可进行回收(Java 没有采用)
优点:
- 回收没有延迟性,无需等到内存不够的时候才开始回收,运行时根据对象计数器是否为 0,可以直接回收
- 在垃圾回收过程中,应用无需挂起;如果申请内存时,内存不足,则立刻报 OOM 错误
- 区域性,更新对象的计数器时,只是影响到该对象,不会扫描全部对象
缺点:
- 每次对象被引用时,都需要去更新计数器,有一点时间开销
- 浪费 CPU 资源,即使内存够用,仍然在运行时进行计数器的统计。
- 无法解决循环引用问题,会引发内存泄露(最大的缺点)
3.可达性分析
可达性分析算法:也可以称为根搜索算法、追踪性垃圾收集
GC Roots :GC Roots 是一组活跃的引用,不是对象,放在 GC Roots Set 集合
- 虚拟机栈中局部变量表中引用的对象:各个线程被调用的方法中使用到的参数、局部变量等
- 本地方法栈中引用的对象
- 堆中类静态属性引用的对象
- 方法区中的常量引用的对象
- 字符串常量池(string Table)里的引用
- 同步锁 synchronized 持有的对象
工作原理
可达性分析算法以 GC Roots 为起始点,从上至下的方式搜索被 GC Roots 所连接的目标对象
- 可达性分析算法后,内存中的存活对象都会被 GC Roots 直接或间接连接着,搜索走过的路径称为引用链
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象
- 在可达性分析算法中,只有能够被 GC Roots 直接或者间接连接的对象才是存活对象
分析工作必须在一个保障一致性的快照中进行,否则结果的准确性无法保证,这也是导致 GC 进行时必须 Stop The World
的一个原因
4.引用分析
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关,Java 提供了四种强度不同的引用类型
-
1.强引用:被强引用关联的对象不会被回收,只有当所有 GC Roots 都不通过【强引用】引用该对象,才能被垃圾回收
- 强引用可以直接访问目标对象
- 虚拟机宁愿抛出 OOM 异常,也不会回收强引用所指向对象
- 强引用可能导致内存泄漏
1
Object obj = new Object();//使用 new 一个新对象的方式来创建强引用
-
2.软引用(SoftReference):被软引用关联的对象只有在内存不够的情况下才会被回收
-
**仅(可能有强引用,一个对象可以被多个引用)**有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象
- 配合引用队列来释放软引用自身,在构造软引用时,可以指定一个引用队列,当软引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况
- 软引用通常用来实现内存敏感的缓存,比如高速缓存就有用到软引用;如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时不会耗尽内存
1
2
3Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联
-
-
3.弱引用(WeakReference):被弱引用关联的对象一定会被回收,只能存活到下一次垃圾回收发生之前
-
仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
- 配合引用队列来释放弱引用自身
- WeakHashMap 用来存储图片信息,可以在内存不足的时候及时回收,避免了 OOM
1
2
3Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
-
-
4.虚引用(PhantomReference):也称为幽灵引用或者幻影引用,是所有引用类型中最弱的一个
-
必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
-
一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象
- 为对象设置虚引用的唯一目的是在于跟踪垃圾回收过程,能在这个对象被回收时收到一个系统通知
1
2
3Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null; -
-
5.终结器引用(finalization)
- 无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象
5.三色标记
基本算法
三色标记法把遍历对象图过程中遇到的对象,标记成以下三种颜色:
- 白色:尚未访问过
- 灰色:正在访问的(本对象已访问过,但是本对象引用到的其他对象尚未全部访问)
- 黑色:访问完成的(本对象已访问过,而且本对象引用到的其他对象也全部访问完成)
当 Stop The World (STW) 时,对象间的引用是不会发生变化的,可以轻松完成标记,遍历访问过程为:
- 初始时,所有对象都在 【白色集合】中;
- 将 GC Roots 直接引用到的对象挪到 【灰色集合】中;
- 从灰色集合中获取对象:
- 将本对象引用到的其他对象全部挪到 【灰色集合】中;
- 将本对象挪到 【黑色集合】里面。
- 重复步骤3,直至【灰色集合】为空时结束。
- 结束后,仍在【白色集合】的对象即为 GC Roots 不可达,可以进行回收。
三色标记遍历过程
并发标记时,对象间的引用可能发生变化,多标和漏标的情况就有可能发生
多标-浮动垃圾
多标情况:当 E 变为灰色时,断开 D 对 E 的引用,导致对象 E/F/G 仍会被标记为存活,本轮 GC 不会回收这部分内存,这部分本应该回收但是没有回收到的内存,被称之为浮动垃圾
- 针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,也算浮动垃圾
- 浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除
漏标-读写屏障
漏标情况:当 E 变为灰色时,断开 E 对 G 的引用,再让 D 引用 G。此时切回 GC 线程继续跑,因为 E 已经没有对 G 的引用了,所以不会将 G 放到灰色集合;尽管 D 重新引用了G,但 D 已经是黑色了,不会再重新做遍历处理。
最终导致的结果是:G 会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。
即漏标只有同时满足以下两个条件时才会发生:
- 条件一:灰色对象断开了对一个白色对象的引用(直接或间接),即灰色对象原成员变量的引用发生了变化
- 条件二:其他线程中修改了黑色对象,插入了一条或多条对该白色对象的新引用
- 结果:导致该白色对象当作垃圾被 GC,影响到了程序的正确性
代码角度解释漏标:
1 | var G = objE.fieldG; // 1.读 |
- 读取 对象E的成员变量fieldG的引用值,即对象G;
- 对象E 往其成员变量fieldG,写入 null值。
- 对象D 往其成员变量fieldG,写入 对象G ;
为了解决问题,我们只要在上面这三步中的任意一步中做一些“手脚”,将对象G记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的GC Roots遍历完(并发标记),再遍历该集合(重新标记)。
重新标记通常是需要STW的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象,导致永远都跑不完。当然,并发标记期间也可以将该集合中的大部分先跑了,从而缩短重新标记STW的时间,这个是优化问题了。
解决方法:添加读写屏障,读屏障拦截第一步,写屏障拦截第二三步,在读写前后进行一些后置处理:
-
写屏障 + 增量更新:黑色对象新增引用,会将黑色对象变成灰色对象,最后对该节点重新扫描,增量更新 (Incremental Update) 破坏了条件二,从而保证了不会漏标
缺点:对黑色变灰的对象重新扫描所有引用,比较耗费时间
-
写屏障 (Store Barrier) + SATB:当原来成员变量的引用发生变化之前,记录下原来的引用对象
保留 GC 开始时的对象图,即原始快照 SATB,当 GC Roots 确定后,对象图就已经确定,那后续的标记也应该是按照这个时刻的对象图走,如果期间对白色对象有了新的引用会记录下来,并且将白色对象变灰(说明可达了,并且原始快照中本来就应该是灰色对象),最后重新扫描该对象的引用关系
SATB (Snapshot At The Beginning) 破坏了条件一,从而保证了不会漏标
-
读屏障 (Load Barrier):破坏条件二,黑色对象引用白色对象的前提是获取到该对象,此时读屏障发挥作用
以 Java HotSpot VM 为例,其并发标记时对漏标的处理方案如下:
- CMS:写屏障 + 增量更新
- G1:写屏障 + SATB
- ZGC:读屏障
6.finalization
Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑
垃圾回收对象之前,会先调用这个对象的 finalize()
方法,finalize() 方法允许在子类中被重写,用于在对象被回收时进行后置处理,通常在这个方法中进行一些资源释放和清理,比如关闭文件、套接字和数据库连接等
生存 OR 死亡:如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用,此对象需要被回收。但事实上这时候它们暂时处于缓刑阶段。一个无法触及的对象有可能在某个条件下复活自己,所以虚拟机中的对象可能的三种状态:
- 可触及的:从根节点开始,可以到达这个对象
- 可复活的:对象的所有引用都被释放,但是对象有可能在 finalize() 中复活
- 不可触及的:对象的 finalize() 被调用并且没有复活,那么就会进入不可触及状态,不可触及的对象不可能被复活,因为 finalize() 只会被调用一次,等到这个对象再被标记为可回收时就必须回收
永远不要主动调用某个对象的 finalize() 方法,应该交给垃圾回收机制调用,原因:
- finalize() 时可能会导致对象复活
- finalize() 方法的执行时间是没有保障的,完全由 GC 线程决定,极端情况下,若不发生 GC,则 finalize() 方法将没有执行机会,因为优先级比较低,即使主动调用该方法,也不会因此就直接进行回收
- 一个糟糕的 finalize() 会严重影响 GC 的性能
7.无用属性
无用类
方法区主要回收的是无用的类
判定一个类是否是无用的类,需要同时满足下面 3 个条件:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例
- 加载该类的
ClassLoader
已经被回收 - 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是可以,而并不是和对象一样不使用了就会必然被回收
废弃常量
在常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该常量,说明常量 “abc” 是废弃常量,如果这时发生内存回收的话而且有必要的话(内存不够用),”abc” 就会被系统清理出常量池
静态变量
类加载时(第一次访问),这个类中所有静态成员就会被加载到静态变量区,该区域的成员一旦创建,直到程序退出才会被回收
如果是静态引用类型的变量,静态变量区只存储一份对象的引用地址,真正的对象在堆内,如果要回收该对象可以设置引用为 null
❺回收算法
1.标记复制
复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清理,交换两个内存的角色,完成垃圾的回收
算法优点:
- 没有标记和清除过程,实现简单,运行速度快
- 复制过去以后保证空间的连续性,不会出现碎片问题
算法缺点:
- 主要不足是只使用了内存的一半
- 对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销都不小
基于新生代 “朝生夕灭” 的特点,大多数虚拟机都不会按照 1:1 的比例来进行内存划分,例如 HotSpot 虚拟机会将内存空间划分为一块较大的 Eden
和 两块较小的 Survivor
空间,它们之间的比例是 8:1:1 。 每次分配时只会使用 Eden
和其中的一块 Survivor
,发生垃圾回收时,只需要将存活的对象一次性复制到另外一块 Survivor
上,这样只有 10% 的内存空间会被浪费掉。当 Survivor
空间不足以容纳一次 Minor GC
时,此时由其他内存区域(通常是老年代)来进行分配担保。
应用场景:如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方式并且效率比较高,反之则不适合
现在的商业虚拟机都采用这种收集算法回收新生代,因为新生代 GC 频繁并且对象的存活率不高,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间
2.标记清除
标记清除算法,是将垃圾回收分为两个阶段,分别是标记和清除
- 标记:Collector 从引用根节点开始遍历,标记所有被引用的对象,一般是在对象的 Header 中记录为可达对象,标记的是引用的对象,不是垃圾
- 清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收,把分块连接到空闲列表的单向链表,判断回收后的分块与前一个空闲分块是否连续,若连续会合并这两个分块,之后进行分配时只需要遍历这个空闲列表,就可以找到分块
- 分配阶段:程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block,如果找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 block - size 的两部分,返回大小为 size 的分块,并把大小为 block - size 的块返回给空闲列表
算法缺点:
- 标记和清除过程效率都不高
- 会产生大量不连续的内存碎片,导致无法给大对象分配内存,需要维护一个空闲链表
算法优点:
- 速度较快
3.标记整理
标记整理(压缩)算法是在标记清除算法的基础之上,做了优化改进的算法
- 标记阶段和标记清除算法一样,也是从根节点开始,将对象的引用进行标记
- 清理阶段,并不是简单的直接清理可回收对象,而是将存活对象都向内存另一端移动,然后清理边界以外的垃圾,从而解决了碎片化的问题
优点:不会产生内存碎片
缺点:需要移动大量对象,处理效率比较低
4.对比总结
算法 | 速度 | 空间开销 | 移动对象 |
---|---|---|---|
复制算法 | 最快 | 通常需要活对象的 2 倍大小(不堆积碎片) | 是 |
标记清除 | 中等 | 少(但会堆积碎片) | 否 |
标记整理 | 最慢 | 少(不堆积碎片) | 是 |
❻垃圾回收器
0.概述
a.垃圾收集器分类
-
按线程数分(垃圾回收线程数),可以分为
串行垃圾回收器
和
并行垃圾回收器
- 除了 CMS 和 G1 之外,其它垃圾收集器都是以串行(并发也是串行)的方式执行
-
按照工作模式分,可以分为
并发式垃圾回收器
和
独占式垃圾回收器
- 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间
- 独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束
-
按碎片处理方式分,可分为
压缩式垃圾回收器
和
非压缩式垃圾回收器
- 压缩式垃圾回收器在回收完成后进行压缩整理,消除回收后的碎片,再分配对象空间使用指针碰撞
- 非压缩式的垃圾回收器不进行这步操作,再分配对象空间使用空闲列表
-
按工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器
b.GC 性能指标
- 吞吐量:程序的运行时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间)
- 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例
- 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间
- 收集频率:相对于应用程序的执行,收集操作发生的频率
- 内存占用:Java 堆区所占的内存大小
- 快速:一个对象从诞生到被回收所经历的时间
- 吞吐量优先:单位时间内,STW(stop the world,停掉其他所有工作线程)时间最短
- 响应时间优先:尽可能让单次STW时间变短(尽量不影响其他线程运行)
c.垃圾收集器的组合关系
- 新生代收集器:Serial、ParNew、Parallel Scavenge
- 老年代收集器:Serial old、Parallel old、CMS
- 整堆收集器:G1
红色虚线在 JDK9 移除、绿色虚线在 JDK14 弃用该组合、青色虚线在 JDK14 删除 CMS 垃圾回收器
Serial GC、Parallel GC、Concurrent Mark Sweep GC 这三个 GC 不同:
- 最小化地使用内存和并行开销,选 Serial GC
- 最大化应用程序的吞吐量,选 Parallel GC
- 最小化 GC 的中断或停顿时间,选 CMS GC
查看默认的垃圾收回收器:
-XX:+PrintcommandLineFlags
:查看命令行相关参数(包含使用的垃圾收集器)- 使用命令行指令:jinfo -flag 相关垃圾回收器参数 进程 ID
1.Serial/Serial old
Serial:串行垃圾收集器,作用于新生代,使用单线程进行垃圾回收,采用复制算法,新生代基本都是复制算法
STW(Stop-The-World):垃圾回收时,只有一个线程在工作,并且 Java 应用中的所有线程都要暂停,等待垃圾回收的完成
Serial old:执行老年代垃圾回收的串行收集器,内存回收算法使用的是标记-整理算法,同样也采用了串行回收和 STW 机制
- Serial old 是 Client 模式下默认的老年代的垃圾回收器
- Serial old 在 Server 模式下主要有两个用途:
- 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用
- 作为老年代 CMS 收集器的后备垃圾回收方案,在并发收集发生 Concurrent Mode Failure 时使用
开启参数:-XX:+UseSerialGC
等价于新生代用 Serial GC 且老年代用 Serial old GC
优点:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,可以获得最高的单线程收集效率
缺点:对于交互性较强的应用而言,这种垃圾收集器是不能够接受的,比如 JavaWeb 应用
2.ParNew
Par 是 Parallel 并行的缩写,New 是只能处理的是新生代
并行垃圾收集器在串行垃圾收集器的基础之上做了改进,采用复制算法,将单线程改为了多线程进行垃圾回收,可以缩短垃圾回收的时间
对于其他的行为(收集算法、stop the world、对象分配规则、回收策略等)同 Serial 收集器一样,应用在年轻代,除 Serial 外,只有ParNew GC 能与 CMS 收集器配合工作
相关参数:
-XX:+UseParNewGC
:表示新生代使用并行收集器,不影响老年代-XX:ParallelGCThreads
:默认开启和 CPU 数量相同的线程数
ParNew 是很多 JVM 运行在 Server 模式下新生代的默认垃圾收集器
- 对于新生代,回收次数频繁,使用并行方式高效
- 对于老年代,回收次数少,使用串行方式节省资源(CPU 并行需切换线程,串行可以省去切换线程的资源)
3.Parallel/Parallel Old
Parallel Scavenge 收集器:是应用于新生代的并行垃圾回收器,采用复制算法、并行回收和 Stop the World 机制
Parallel Old :是应用于老年代的并行垃圾回收器,采用标记-整理算法
对比其他回收器:
- 其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间
- Parallel 目标是达到一个可控制的吞吐量,被称为吞吐量优先收集器
- Parallel Scavenge 对比 ParNew 拥有自适应调节策略,可以通过一个开关参数打开 GC Ergonomics
应用场景:
- 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验
- 高吞吐量可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互
停顿时间和吞吐量的关系:新生代空间变小 → 缩短停顿时间 → 垃圾回收变得频繁 → 导致吞吐量下降
在注重吞吐量及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge + Parallel Old 收集器,在 Server 模式下的内存回收性能很好,Java8 默认是此垃圾收集器组合
参数配置:
-
-XX:+UseParallelGC
:手动指定年轻代使用 Paralle 并行收集器执行内存回收任务 -
-XX:+UseParalleloldcc
:设置年轻代并行收集器的线程数,一般与 CPU 数量相等,以避免过多的线程数影响垃圾收集性能 - 在默认情况下,当 CPU 数量小于 8 个,ParallelGcThreads 的值等于 CPU 数量 - 当 CPU 数量大于 8 个,ParallelGCThreads 的值等于 3+[5*CPU Count]/8]1
2
3
4
5
6
7
8
9
:手动指定老年代使用并行回收收集器执行内存回收任务
- 上面两个参数,默认开启一个,另一个也会被开启(互相激活),默认 JDK8 是开启的
- `-XX:+UseAdaptivesizepplicy`:设置 Parallel Scavenge 收集器具有**自适应调节策略**,在这种模式下,年轻代的大小、Eden 和 Survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量
- ```
-XX:ParallelGcThreads -
-XX:MaxGCPauseMillis
:垃圾收集时间占总时间的比例 =1/(N+1),用于衡量吞吐量的大小 - 取值范围(0,100)。默认值 99,也就是垃圾回收时间不超过 1 - 与 `-xx:MaxGCPauseMillis` 参数有一定矛盾性,暂停时间越长,Radio 参数就容易超过设定的比例1
2
3
4
5
6
7
8
:设置垃圾收集器最大停顿时间(即 STW 的时间),单位是毫秒
- 对于用户来讲,停顿时间越短体验越好;在服务器端,注重高并发,整体的吞吐量
- 为了把停顿时间控制在 MaxGCPauseMillis 以内,收集器在工作时会调整 Java 堆大小或其他一些参数
- ```
-XX:GCTimeRatio
4.CMS
Concurrent Mark Sweep(CMS),是一款并发的、使用标记-清除算法、响应时间优先、针对老年代的垃圾回收器,其最大特点是让垃圾收集线程与用户线程同时工作
CMS 收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短(低延迟)越适合与用户交互的程序,良好的响应速度能提升用户体验
分为以下四个流程:
- 初始标记:仅标记 GC Roots 能直接关联到的对象,速度很快,出现短暂STW
- 并发标记:进行 GC Roots 开始遍历整个对象图,在整个回收过程中耗时最长,不需要 STW,可以与用户线程并发运行
- 重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象,比初始标记时间长但远比并发标记时间短,需要 STW(不停顿就会一直变化,采用写屏障 + 增量更新来避免漏标情况)
- 并发清除:清除标记为可以回收的对象,不需要移动存活对象,所以这个阶段可以与用户线程同时并发的
Mark Sweep 会造成内存碎片,不把算法换成 Mark Compact 的原因:Mark Compact 算法会整理内存,导致用户线程使用的对象的地址改变,影响用户线程继续执行
在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿
优点:并发收集、低延迟
缺点:
-
吞吐量降低:在并发阶段虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,CPU 利用率不够高
-
CMS 收集器无法处理浮动垃圾,可能出现 Concurrent Mode Failure 导致另一次 Full GC 的产生
浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾(产生了新对象),这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,CMS 收集需要预留出一部分内存,不能等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS,导致很长的停顿时间
-
标记 - 清除算法导致的空间碎片,往往出现老年代空间无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC;为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配
参数设置:
-
-XX:+UseConcMarkSweepGC
:手动指定使用 CMS 收集器执行内存回收任务开启该参数后会自动将
-XX:+UseParNewGC
打开,即:ParNew + CMS + Serial old的组合 -
-XX:CMSInitiatingoccupanyFraction
:设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收- JDK5 及以前版本的默认值为 68,即当老年代的空间使用率达到 68% 时,会执行一次CMS回收
- JDK6 及以上版本默认值为 92%
-
-XX:+UseCMSCompactAtFullCollection
:用于指定在执行完 Full GC 后对内存空间进行压缩整理,以此避免内存碎片的产生,由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长 -
-XX:CMSFullGCsBeforecompaction
:设置在执行多少次 Full GC 后对内存空间进行压缩整理 -
-XX:ParallelCMSThreads
:设置 CMS 的线程数量- CMS 默认启动的线程数是 (ParallelGCThreads+3)/4,ParallelGCThreads 是年轻代并行收集器的线程数
- 收集线程占用的 CPU 资源多于25%,对用户程序影响可能较大;当 CPU 资源比较紧张时,受到 CMS 收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕
5.G1
G1(Garbage-First)是一款面向服务端应用的垃圾收集器,应用于新生代和老年代、采用标记-整理算法、响应时间优先、软实时、低延迟、可设定目标(最大 STW 停顿时间)的垃圾回收器,用于代替 CMS,适用于较大的堆(>4 ~ 6G),在 JDK9 之后默认使用 G1
JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器
应用场景:
- 面向服务端应用,针对具有大内存、多处理器的机器
- 需要低 GC 延迟,并具有大堆的应用程序提供解决方案
G1 优点
-
并发与并行:
- 并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力,此时用户线程 STW
- 并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此不会在整个回收阶段发生完全阻塞应用程序的情况
- 其他的垃圾收集器使用内置的 JVM 线程执行 GC 的多线程操作,而 G1 GC 可以采用应用线程承担后台运行的 GC 工作,JVM 的 GC 线程处理速度慢时,系统会调用应用程序线程加速垃圾回收过程
-
分区算法:
- 从分代上看,G1 属于分代型垃圾回收器,区分年轻代和老年代,年轻代依然有 Eden 区和 Survivor 区。从堆结构上看,新生代和老年代不再物理隔离,不用担心每个代内存是否足够,这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC
- G1 把堆划分成多个大小相等的独立区域(Region),使得每个小空间可以单独进行垃圾回收
- 将整个堆划分成约 2048 个大小相同的独立 Region 块,所有 Region 大小相同,在 JVM 生命周期内不会被改变。
- 每个Region的大小可以通过参数
-XX:G1HeapRegionSize
设定,取值范围为1MB~32MB之间且为 2 的 N 次幂 - Region中还有一类特殊的 Humongous 区域,专门用来存储大对象,本身属于老年代区。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储,为了能找到连续的 H 区,有时候不得不启动 Full GC
- G1 不会对巨型对象进行拷贝,回收时被优先考虑,G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为 0 的巨型对象就可以在新生代垃圾回收时处理掉
Region 结构图
-
空间整合:
- CMS:标记-清除算法、内存碎片、若干次 GC 后进行一次碎片整理
- G1:整体来看是基于标记 - 整理算法实现的收集器,从局部(Region 之间)上来看是基于复制算法实现的,两种算法都可以避免内存碎片
-
可预测的停顿时间模型(软实时 soft real-time):
-
可以指定在 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒
-
由于分块的原因,G1 可以只选取部分区域进行内存回收,这样缩小了回收的范围,对于全局停顿情况也能得到较好的控制
-
G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间,通过过去回收的经验获得),在后台维护一个优先列表,每次根据允许的收集时间优先回收价值最大的 Region,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率
-
相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多
-
通过
-XX:MaxGCPauseMillis
参数指定的停顿时间只意味着垃圾收集发生之前的期望值
-
G1 缺点
- 相较于 CMS,G1 还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比 CMS 要高
- 从经验上来说,在小内存应用上 CMS 的表现大概率会优于 G1,而 G1 在大内存应用上则发挥其优势,平衡点在 6-8GB 之间
记忆集-RSet
为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进 GC Roots 扫描范围。
记忆集 Remembered Set 在新生代中,每个 Region 都有一个 Remembered Set,用来记录被哪些其他 Region 里的对象引用(谁引用了我就记录谁)
通过写屏障来更新记忆集:
程序对 Reference 类型数据写操作时,产生一个写屏障 Write Barrier 暂时中断操作,检查该对象和 Reference 类型数据是否在不同的 Region(跨代引用),不同就将相关引用信息记录到 Reference 类型所属的 Region 的 Remembered Set 之中,进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏
卡表-Card Table
垃圾收集器在新生代中建立了记忆集这样的数据结构,可以理解为它是一个抽象类,具体实现记忆集的三种方式:
- 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- 卡精度(卡表):每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
卡表(Card Table)在老年代中,是一种对记忆集的具体实现,主要定义了记忆集的记录精度、与堆内存的映射关系等
卡表中的每一个元素都对应着一块特定大小的内存块,这个内存块称之为卡页(card page),当存在跨代引用时,会将卡页标记为 dirty,在垃圾收集发生时,只要筛选出卡表中 dirty 的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。JVM 对于卡页的维护也是通过写屏障的方式
写屏障-Write Barrier
我们已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。
卡表元素何时变脏
有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。
如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?
在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。
写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。
应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。
回收集-CSet
Collection Set 代表每次 GC 暂停时回收的一系列目标分区,在任意一次收集暂停中,CSet 所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。年轻代收集 CSet 只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到 CSet 中
CSet根据两种不同的回收类型分为两种不同CSet。
- CSet of Young Collection
- 只专注回收 Young Region 跟 Survivor Region
- CSet of Mix Collection
- 则会通过RSet计算Region中对象的活跃度,
- 活跃度阈值
-XX:G1MixedGCLiveThresholdPercent
(默认85%),只有活跃度高于这个阈值的才会准入CSet - 还可通过
-XX:G1OldCSetRegionThresholdPercent
(默认10%)设置,CSet跟整个堆的比例的数量上限。
工作原理
G1 中提供了三种垃圾回收模式:YoungGC、Mixed GC 和 Full GC,在不同的条件下被触发
- 当堆内存使用达到一定值(默认 45%)时,开始老年代并发标记过程
- 标记完成马上开始混合回收过程
顺时针:Minor GC → Minor GC + Concurrent Mark → Mixed GC 顺序,进行垃圾回收
-
Minor GC:发生在年轻代的 GC 算法,一般对象(除了巨型对象)都是在 Eden 区 中分配内存,当Eden 区被耗尽无法申请内存时,就会触发一次 Minor GC,G1 停止应用程序的执行 STW,把活跃对象放入老年代,垃圾对象回收
回收过程:
- 扫描根:根引用连同 RSet 记录的外部引用作为扫描存活对象的入口
- 更新 RSet:处理 dirty card queue 更新 RSet,此后 RSet 准确的反映对象的引用关系
- dirty card queue:类似缓存,产生了引用先记录在这里,然后更新到 RSet
- 作用:产生引用直接更新 RSet 需要线程同步开销很大,使用队列性能好
- 处理 RSet:识别被老年代对象指向的 Eden 中的对象,这些被指向的对象被认为是存活的对象,把需要回收的分区放入 Young CSet 中进行回收
- 复制对象:Eden 区内存段中存活的对象会被复制到 survivor 区,survivor 区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到 Old 区中空的内存分段,如果 survivor 空间不够,Eden 空间的部分数据会直接晋升到老年代空间
- 处理引用:处理 Soft,Weak,Phantom,JNI Weak 等引用,最终 Eden 空间的数据为空,GC 停止工作
-
**Concurrent Mark **:
- 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,这个阶段是 STW 的,并且会触发一次 Minor GC
- 并发标记 (Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
- 老年代占用堆空间比例达到阈值时,就会进行并发标记
-XX:InitiatingHeapOccupancyPercent
设置阈值(默认45%)
- 最终标记(Final Marking):为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,对用户线程做另一个短暂 STW,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。但是可并行执行(防止漏标)
- 筛选回收(Live Data Counting and Evacuation):首先对 CSet 中各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,也需要 STW,由多条收集器线程并行完成的。
-
Mixed GC:当很多对象晋升到老年代时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,除了回收整个 young region,还会回收一部分的 old region。
注意:是一部分老年代,而不是全部老年代,选择回收价值高的老年代 region 进行回收,从而对垃圾回收的时间进行控制
-XX:MaxGCPauseMillis
:设置期望达到的最大 GC 停顿时间指标,JVM会尽力实现,但不保证达到,默认值是 200ms -
Full GC:对象内存分配速度过快,Mixed GC 来不及回收,导致老年代被填满,就会触发一次 Full GC,G1 的 Full GC 算法就是单线程执行的垃圾回收,会导致长时间的暂停时间,需要进行不断的调优,尽可能的避免 Full GC
产生 Full GC 的原因:
- 晋升时没有足够的空间存放晋升的对象
- 并发处理过程完成之前空间耗尽,浮动垃圾
相关参数
-XX:+UseG1GC
:手动指定使用 G1 垃圾收集器执行内存回收任务-XX:G1HeapRegionSize
:设置每个 Region 的大小。值是 2 的幂,范围是 1MB 到 32MB 之间,目标是根据最小的 Java 堆大小划分出约 2048 个区域,默认是堆内存的 1/2000-XX:MaxGCPauseMillis
:设置期望达到的最大 GC 停顿时间指标,JVM会尽力实现,但不保证达到,默认值是 200ms-XX:+ParallelGcThread
:设置 STW 时 GC 线程数的值,最多设置为 8-XX:ConcGCThreads
:设置并发标记线程数,设置为并行垃圾回收线程数 ParallelGcThreads 的1/4左右-XX:InitiatingHeapOccupancyPercent
:设置并发标记阈值,默认值是 45-XX:+ClassUnloadingWithConcurrentMark
:并发标记类卸载,默认启用,所有对象都经过并发标记后,就可以知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类-XX:G1NewSizePercent
:新生代占用整个堆内存的最小百分比(默认5%)-XX:G1MaxNewSizePercent
:新生代占用整个堆内存的最大百分比(默认60%)-XX:G1ReservePercent=10
:保留内存区域,防止Survivor中的 to 区溢出
调优
G1 的设计原则就是简化 JVM 性能调优,只需要简单的三步即可完成调优:
- 开启 G1 垃圾收集器
- 设置堆的最大内存
- 设置最大的停顿时间(STW)
不断调优暂停时间指标:
-XX:MaxGCPauseMillis=x
可以设置启动应用程序暂停的时间,G1会根据这个参数选择 CSet 来满足响应时间的设置- 设置到 100ms 或者 200ms 都可以(不同情况下会不一样),但设置成50ms就不太合理
- 暂停时间设置的太短,就会导致出现 G1 跟不上垃圾产生的速度,最终退化成 Full GC
- 对这个参数的调优是一个持续的过程,逐步调整到最佳状态
不要设置新生代和老年代的大小:
- 避免使用
-Xmn
或-XX:NewRatio
等相关选项显式设置年轻代大小,G1 收集器在运行的时候会调整新生代和老年代的大小,从而达到我们为收集器设置的暂停时间目标 - 设置了新生代大小相当于放弃了 G1 的自动调优,我们只需要设置整个堆内存的大小,剩下的交给 G1 自己去分配各个代的大小
6.ZGC
ZGC 收集器是JDK 11中推出的一个可伸缩的、低延迟的垃圾收集器,基于 Region 内存布局的,不设分代,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法
-
在 CMS 和 G1 中都用到了写屏障,而 ZGC 用到了读屏障
-
染色指针:直接
将少量额外的信息存储在指针上的技术
,从 64 位的指针中拿高 4 位来标识对象此时的状态
- 染色指针可以使某个 Region 的存活对象被移走之后,这个 Region 立即就能够被释放和重用
- 可以直接从指针中看到引用对象的三色标记状态(Marked0、Marked1)、是否进入了重分配集、是否被移动过(Remapped)、是否只能通过 finalize() 方法才能被访问到(Finalizable)
- 可以大幅减少在垃圾收集过程中内存屏障的使用数量,写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作
- 可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据
-
内存多重映射:多个虚拟地址指向同一个物理地址
-
可并发的标记整理算法:染色指针标识对象是否被标记或移动,读屏障保证在每次应用程序或 GC 程序访问对象时先根据染色指针的标识判断是否被移动,如果被移动就根据转发表访问新的移动对象,并更新引用,不会像 G1 一样必须等待垃圾回收完成才能访问
ZGC 目标:
- 停顿时间不会超过 10ms
- 停顿时间不会随着堆的增大而增大(不管多大的堆都能保持在 10ms 以下)
- 可支持几百 M,甚至几 T 的堆大小(最大支持4T)
ZGC 的工作过程可以分为 4 个阶段:
- 并发标记(Concurrent Mark): 遍历对象图做可达性分析的阶段,做可达性分析的阶段,前后也要经过类似于G1的初始标记和最终标记的短暂停顿。与G1不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位。
- 并发预备重分配(Concurrent Prepare for Relocate):根据特定的查询条件统计得出本次收集过程要清理哪些 Region,将这些 Region 组成重分配集(Relocation Set)
- 并发重分配(Concurrent Relocate): 重分配是 ZGC 执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表(Forward Table),记录从旧地址到新地址的转向关系
- 并发重映射(Concurrent Remap):修正整个堆中指向重分配集中旧对象的所有引用,ZGC 的并发映射并不是一个必须要立即完成的任务,ZGC 很巧妙地把并发重映射阶段要做的工作,合并到下一次垃圾收集循环中的并发标记阶段里去完成,因为都是要遍历所有对象,这样合并节省了一次遍历的开销
ZGC 几乎在所有地方并发执行的,除了初始标记的是 STW 的,但这部分的实际时间是非常少的,所以响应速度快,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟
优点:高吞吐量、低延迟
缺点:浮动垃圾,当 ZGC 准备要对一个很大的堆做一次完整的并发收集,其全过程要持续十分钟以上,由于应用的对象分配速率很高,将创造大量的新对象产生浮动垃圾
对比:
G1 需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现Region的增量回收。记忆集要占用大量的内存空间,写屏障也对正常程序运行造成额外负担,这些都是权衡选择的代价。
ZGC就完全没有使用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担也要小得多。可是,当 ZGC 准备要对一个很大的堆做一次完整的并发收集,其全过程要持续十分钟以上,由于应用的对象分配速率很高,将创造大量的新对象产生浮动垃圾
新一代垃圾回收器ZGC的探索与实践 - 美团技术团队 (meituan.com)
对比总结
- 最小化地使用内存和并行开销,选 Serial GC
- 最大化应用程序的吞吐量,选 Parallel GC
- 最小化 GC 的中断或停顿时间,选 CMS GC
jdk8环境下,默认使用 Parallel Scavenge + Parallel Old
④类加载
❶类文件结构
1 | ClassFile { |
类型 | 名称 | 说明 | 长度 | 数量 |
---|---|---|---|---|
u4 | magic | 魔数,识别类文件格式 | 4个字节 | 1 |
u2 | minor_version | 副版本号(小版本) | 2个字节 | 1 |
u2 | major_version | 主版本号(大版本) | 2个字节 | 1 |
u2 | constant_pool_count | 常量池计数器 | 2个字节 | 1 |
cp_info | constant_pool | 常量池表 | n个字节 | constant_pool_count-1 |
u2 | access_flags | 访问标识 | 2个字节 | 1 |
u2 | this_class | 类索引 | 2个字节 | 1 |
u2 | super_class | 父类索引 | 2个字节 | 1 |
u2 | interfaces_count | 接口计数 | 2个字节 | 1 |
u2 | interfaces | 接口索引集合 | 2个字节 | interfaces_count |
u2 | fields_count | 字段计数器 | 2个字节 | 1 |
field_info | fields | 字段表 | n个字节 | fields_count |
u2 | methods_count | 方法计数器 | 2个字节 | 1 |
method_info | methods | 方法表 | n个字节 | methods_count |
u2 | attributes_count | 属性计数器 | 2个字节 | 1 |
attribute_info | attributes | 属性表 | n个字节 | attributes_count |
Class 文件格式只有两种数据类型:无符号数和表
- 无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串
- 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,表都以
_info
结尾,用于描述有层次关系的数据,整个 Class 文件本质上就是一张表,由于表没有固定长度,所以通常会在其前面加上个数说明
Class 文件获取方式:
- HelloWorld.java 执行
javac -g xxx.java
指令 - 写入文件指令
javap -v xxx.class >xxx.txt
- IDEA 插件 jclasslib 、桌面版 jclasslib、bytecode-viewer
案例
接下来以下面代码进行讲解类文件结构
1 | package JJTest; |
上面代码的字节码对应的16进制
魔数
每个 Class 文件开头的 4 个字节的无符号整数称为魔数(Magic Number),是 Class 文件的标识符,代表这是一个能被虚拟机接受的有效合法的 Class 文件,
- 魔数值固定为
0xCAFEBABE
,不符合则会抛出错误 - 使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动
版本
4 个 字节,5 6两个字节代表的是编译的副版本号 minor_version,7 8 两个字节是编译的主版本号 major_version
- 不同版本的 Java 编译器编译的 Class 文件对应的版本是不一样的,高版本的 Java 虚拟机可以执行由低版本编译器生成的 Class 文件,反之 JVM 会抛出异常
java.lang.UnsupportedClassVersionError
- 案例中
00 34
: 转换成 10 进制 16*3+4 = 52,代表 JDK1.8
主版本(十进制) | 副版本(十进制) | 编译器版本 |
---|---|---|
45 | 3 | 1.1 |
46 | 0 | 1.2 |
47 | 0 | 1.3 |
48 | 0 | 1.4 |
49 | 0 | 1.5 |
50 | 0 | 1.6 |
51 | 0 | 1.7 |
52 | 0 | 1.8 |
53 | 0 | 1.9 |
54 | 0 | 1.10 |
55 | 0 | 1.11 |
常量池计数器
- 由于常量池中常量的数量不固定,所以放置 u2 类型的无符号数表示常量池计数器(constant_pool_count)
- 常量池计数器值:从1开始,表示常量池中有多少项常量。即constant_pool_count=1表示常量池中有0个常量项。
- 这个计数器是从 1 而不是 0 开始,是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达不引用任何一个常量池项目,这种情况可用索引值 0 来表示
- 案例中
00 16
:转换成10进制 22,因此有 21 个常量池
常量池
constant_pool 是一种表结构,以 1 ~ constant_pool_count - 1 为索引,表明有多少个常量池表项。表项中存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池
-
字面量(Literal) :基本数据类型、字符串类型常量、声明为 final 的常量值等
-
符号引用(Symbolic References):类和接口的全限定名、字段的名称和描述符、方法的名称和描述符
-
全限定名:com/test/Demo 这个就是类的全限定名,仅仅是把包名的
.
替换成/
,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个;
表示全限定名结束 -
简单名称:指没有类型和参数修饰的方法或者字段名称,比如字段 x 的简单名称就是 x
-
描述符:用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值
标志符 含义 B 基本数据类型 byte C 基本数据类型 char D 基本数据类型 double F 基本数据类型 float I 基本数据类型 int J 基本数据类型 long S 基本数据类型 short Z 基本数据类型 boolean V 代表 void 类型 L 对象类型,比如: Ljava/lang/Object;
,不同方法间用;
隔开[ 数组类型,代表一维数组。比如: double[][][] is [[[D
-
常量池中常量类型
常量池的每一项常量都是一个表,表开始的第一位是一个 u1 类型的标志位(tag),代表当前这个常量属于哪种常量类型。
18 种常量没有出现 byte、short、char,boolean 的原因:编译之后都可以理解为 Integer
-
案例中
1
0a 00 04 00 12
:
- 0a –> 10 –> CONSTANT_Methodref_info –> u2 u2
- u2:00 04 –> CONSTANT_Class_info #4 –> CONSTANT_Class_info #21 –> java/lang/Object
- u2:00 12 –> CONSTANT_NameAndType_info #18 –> CONSTANT_Class_info #7 & CONSTANT_Class_info #8 –>
& ()V 【无参返回值void】
访问标识
访问标识(access_flag),又叫访问标志、访问标记,该标识用两个字节表示,用于识别一些类或者接口层次的访问信息,包括这个 Class 是类还是接口,是否定义为 public类型,是否定义为 abstract类型等
- 类的访问权限通常为 ACC_ 开头的常量
- 每一种类型的表示都是通过设置访问标记的 32 位中的特定位来实现的,比如若是 public final 的类,则该标记为
ACC_PUBLIC | ACC_FINAL
- 使用
ACC_SUPER
可以让类更准确地定位到父类的方法,确定类或接口里面的 invokespecial 指令使用的是哪一种执行语义,现代编译器都会设置并且使用这个标记 - 案例中
00 21
: 0x0001 + 0x0020 代表 ACC_PUBLIC 和 ACC_SUPER
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 标志为 public 类型 |
ACC_FINAL | 0x0010 | 标志被声明为 final,只有类可以设置 |
ACC_SUPER | 0x0020 | 标志允许使用 invokespecial 字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真,使用增强的方法调用父类方法 |
ACC_INTERFACE | 0x0200 | 标志这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为 abstract 类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 |
ACC_SYNTHETIC | 0x1000 | 标志此类并非由用户代码产生(由编译器产生的类,没有源码对应) |
ACC_ANNOTATION | 0x2000 | 标志这是一个注解 |
ACC_ENUM | 0x4000 | 标志这是一个枚举 |
索引集合
本类索引、父类索引、接口索引集合
- 本类索引用于确定这个类的全限定名
- 案例中
00 03
: cp_info #3 【即3号常量池】–> cp_info #20 –> JJTest/Demo
- 案例中
- 父类索引用于确定这个类的父类的全限定名,Java 语言不允许多重继承,所以父类索引只有一个,除了Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为0
- 案例中
00 04
:cp_info #4 –> cp_info #21 –> java/lang/Object
- 案例中
- 接口索引集合就用来描述这个类实现了哪些接口
- interfaces_count 项的值表示当前类或接口的直接超接口数量
- interfaces[] 接口索引集合,被实现的接口将按 implements 语句后的接口顺序从左到右排列在接口索引集合中
- 案例中
00 00
:表示当前类没有实现接口
长度 | 含义 |
---|---|
u2 | this_class |
u2 | super_class |
u2 | interfaces_count |
u2 | interfaces[interfaces_count] |
字段表
字段 fields 用于描述接口或类中声明的变量,包括类变量以及实例变量,但不包括方法内部、代码块内部声明的局部变量(local variables)以及从父类或父接口继承。字段叫什么名字、被定义为什么数据类型,都是无法固定的,只能引用常量池中的常量来描述
fields_count(字段计数器),表示当前 class 文件 fields 表的成员个数,用两个字节来表示
- 案例中
00 01
:表示当前 class 文件只有一个字段,即num
fields[](字段表):
- fields表中的每个成员都是一个 fields_info 结构的数据项,用于表示当前类或接口中某个字段的完整描述
标志名称 | 标志值 | 含义 | 数量 |
---|---|---|---|
u2 | access_flags | 访问标志 | 1 |
u2 | name_index | 字段名索引 | 1 |
u2 | descriptor_index | 描述符索引 | 1 |
u2 | attributes_count | 属性计数器 | 1 |
attribute_info | attributes | 属性集合 | attributes_count |
字段访问标志:
- 案例中
00 02
:ACC_PRIVATE,字段为private
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 字段是否为public |
ACC_PRIVATE | 0x0002 | 字段是否为private |
ACC_PROTECTED | 0x0004 | 字段是否为protected |
ACC_STATIC | 0x0008 | 字段是否为static |
ACC_FINAL | 0x0010 | 字段是否为final |
ACC_VOLATILE | 0x0040 | 字段是否为volatile |
ACC_TRANSTENT | 0x0080 | 字段是否为transient |
ACC_SYNCHETIC | 0x1000 | 字段是否为由编译器自动产生 |
ACC_ENUM | 0x4000 | 字段是否为enum |
字段名索引:根据该值查询常量池中的指定索引项即可
- 案例中
00 05
:cp_info #5 –>num
描述符索引:用来描述字段的数据类型、方法的参数列表和返回值
- 案例中
00 06
:cp_info #6 –> I –> 整型
字符 | 类型 | 含义 |
---|---|---|
B | byte | 有符号字节型树 |
C | char | Unicode字符,UTF-16编码 |
D | double | 双精度浮点数 |
F | float | 单精度浮点数 |
I | int | 整型数 |
J | long | 长整数 |
S | short | 有符号短整数 |
Z | boolean | 布尔值true/false |
V | void | 代表void类型 |
L Classname | reference | 一个名为Classname的实例 |
[ | reference | 一个一维数组 |
属性表集合:属性个数存放在 attribute_count 中,属性具体内容存放在 attribute 数组中,一个字段还可能拥有一些属性,用于存储更多的额外信息,比如常量的初始化值、一些注释信息等,对于常量属性而言,attribute_length 值恒为2
- 案例中
00 00
:当前字段没有属性
1 | ConstantValue_attribute{ |
方法表
方法表是 methods 指向常量池索引集合,其中每一个 method_info 项都对应着一个类或者接口中的方法信息,完整描述了每个方法的签名
- 如果这个方法不是抽象的或者不是 native 的,字节码中就会体现出来
- methods 表只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法
- methods 表可能会出现由编译器自动添加的方法,比如初始化方法
和实例化方法
- methods 表可能会出现由编译器自动添加的方法,比如初始化方法
要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,因为返回值不会包含在特征签名之中,因此 Java 语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。但在 Class 文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存
methods_count(方法计数器):表示 class 文件 methods 表的成员个数,使用两个字节来表示
- 案例中
00 02
:表示两个方法,和 add
methods[](方法表):每个表项都是一个 method_info 结构,表示当前类或接口中某个方法的完整描述
方法表结构如下:
类型 | 名称 | 含义 | 数量 |
---|---|---|---|
u2 | access_flags | 访问标志 | 1 |
u2 | name_index | 字段名索引 | 1 |
u2 | descriptor_index | 描述符索引 | 1 |
u2 | attrubutes_count | 属性计数器 | 1 |
attribute_info | attributes | 属性集合 | attributes_count |
方法表访问标志:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | public,方法可以从包外访问 |
ACC_PRIVATE | 0x0002 | private,方法只能本类访问 |
ACC_PROTECTED | 0x0004 | protected,方法在自身和子类可以访问 |
ACC_STATIC | 0x0008 | static,静态方法 |
- 案例中
00 01
:ACC_PUBLIC,public,方法可以从包外访问 - 案例中
00 07
:,实例初始化方法 - 案例中
00 08
:()V,无参,返回值类型void - 案例中
00 01
:当前方法有一个属性
属性表
属性表集合,指的是 Class 文件所携带的辅助信息,比如该 Class 文件的源文件的名称,以及任何带有 RetentionPolicy.CLASS
或者 RetentionPolicy.RUNTIME
的注解,这类信息通常被用于 Java 虚拟机的验证和运行,以及 Java 程序的调试。字段表、方法表都可以有自己的属性表,用于描述某些场景专有的信息
attributes_ count(属性计数器):表示当前文件属性表的成员个数
- 案例中
00 01
:表示有1个属性表
attributes[](属性表):属性表的每个项的值必须是 attribute_info 结构
-
案例中
00 10
:属性名索引 cp_info #16 –>SourceFile -
案例中
00 00 00 02
:属性长度为 2 -
案例中
00 11
:源码文件素引cp_info #17 –>Demo.java -
属性的通用格式:
1
2
3
4
5ConstantValue_attribute{
u2 attribute_name_index; //属性名索引
u4 attribute_length; //属性长度
u2 attribute_info; //属性表
}
属性类型:
属性名称 | 使用位置 | 含义 |
---|---|---|
Code | 方法表 | Java 代码编译成的字节码指令 |
ConstantValue | 字段表 | final 关键字定义的常量池 |
Deprecated | 类、方法、字段表 | 被声明为 deprecated 的方法和字段 |
Exceptions | 方法表 | 方法抛出的异常 |
EnclosingMethod | 类文件 | 仅当一个类为局部类或者匿名类是才能拥有这个属性,这个属性用于标识这个类所在的外围方法 |
InnerClass | 类文件 | 内部类列表 |
LineNumberTable | Code 属性 | Java 源码的行号与字节码指令的对应关系 |
LocalVariableTable | Code 属性 | 方法的局部变量描述 |
StackMapTable | Code 属性 | JDK1.6 中新增的属性,供新的类型检查检验器检查和处理目标方法的局部变量和操作数有所需要的类是否匹配 |
Signature | 类,方法表,字段表 | 用于支持泛型情况下的方法签名 |
SourceFile | 类文件 | 记录源文件名称 |
SourceDebugExtension | 类文件 | 用于存储额外的调试信息 |
Syothetic | 类,方法表,字段表 | 标志方法或字段为编泽器自动生成的 |
LocalVariableTypeTable | 类 | 使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加 |
RuntimeVisibleAnnotations | 类,方法表,字段表 | 为动态注解提供支持 |
RuntimelnvisibleAnnotations | 类,方法表,字段表 | 用于指明哪些注解是运行时不可见的 |
RuntimeVisibleParameterAnnotation | 方法表 | 作用与 RuntimeVisibleAnnotations 属性类似,只不过作用对象为方法 |
RuntirmelnvisibleParameterAnniotation | 方法表 | 作用与 RuntimelnvisibleAnnotations 属性类似,作用对象哪个为方法参数 |
AnnotationDefauit | 方法表 | 用于记录注解类元素的默认值 |
BootstrapMethods | 类文件 | 用于保存 invokeddynanic 指令引用的引导方式限定符 |
部分属性详解
① ConstantValue属性
ConstantValue属性表示一个常量字段的值。位于field_info结构的属性表中。
② Deprecated 属性
Deprecated 属性是在JDK1.1为了支持注释中的关键词@deprecated而引入的。
③ Code属性
Code属性就是存放方法体里面的代码。但是,并非所有方法表都有Code属性。像接口或者抽象方法,他们没有具体的方法体,因此也就不会有Code属性了。Code属性表的结构,如下图:
类型 | 名称 | 数量 | 含义 |
---|---|---|---|
u2 | attribute_name_index | 1 | 属性名索引 |
u4 | attribute_length | 1 | 属性长度 |
u2 | max_stack | 1 | 操作数栈深度的最大值 |
u2 | max_locals | 1 | 局部变量表所需的存续空间 |
u4 | code_length | 1 | 字节码指令的长度 |
u1 | code | code_lenth | 存储字节码指令 |
u2 | exception_table_length | 1 | 异常表长度 |
exception_info | exception_table | exception_length | 异常表 |
u2 | attributes_count | 1 | 属性集合计数器 |
attribute_info | attributes | attributes_count | 属性集合 |
可以看到:Code属性表的前两项跟属性表是一致的,即Code属性表遵循属性表的结构,后面那些则是他自定义的结构。
④ InnerClasses 属性
为了方便说明特别定义一个表示类或接口的Class格式为C。如果C的常量池中包含某个CONSTANT_Class_info成员,且这个成员所表示的类或接口不属于任何一个包,那么C的ClassFile结构的属性表中就必须含有对应的InnerClasses属性。InnerClasses属性是在JDK1.1中为了支持内部类和内部接口而引入的,位于ClassFile结构的属性表。
⑤ LineNumberTable属性
LineNumberTable属性是可选变长属性,位于Code结构的属性表。
LineNumberTable属性是用来描述Java源码行号与字节码行号之间的对应关系。这个属性可以用来在调试的时候定位代码执行的行数。
- start_pc,即字节码行号;
- line_number,即Java源代码行号。
在Code属性的属性表中,LineNumberTable属性可以按照任意顺序出现,此外,多个LineNumberTable属性可以共同表示一个行号在源文件中表示的内容,即LineNumberTable属性不需要与源文件的行一一对应。
⑥ LocalVariableTable属性
LocalVariableTable是可选变长属性,位于Code属性的属性表中。它被调试器用于确定方法在执行过程中局部变量的信息。在Code属性的属性表中,LocalVariableTable属性可以按照任意顺序出现。Code属性中的每个局部变量最多只能有一个LocalVariableTable属性。
- start pc + length表示这个变量在字节码中的生命周期起始和结束的偏移位置(this生命周期从头e到结尾10)
- index就是这个变量在局部变量表中的槽位(槽位可复用)
- name就是变量名
- Descriptor表示局部变量类型描述
1 | // LocalVariableTable属性表结构: |
⑦ Signature属性
Signature属性是可选的定长属性,位于ClassFile,field_info或method_info结构的属性表中。在Java语言中,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则Signature属性会为它记录泛型签名信息。
⑧ SourceFile属性
类型 | 名称 | 数量 | 含义 |
---|---|---|---|
u2 | attribute_name_index | 1 | 属性名索引 |
u4 | attribute_length | 1 | 属性长度 |
u2 | sourcefile index | 1 | 源码文件素引 |
可以看到,其长度总是固定的8个字节。
⑨ 其他属性
Java虚拟机中预定义的属性有20多个,这里就不一一介绍了,通过上面几个属性的介绍,只要领会其精髓,其他属性的解读也是易如反掌。
❷类加载时机
主动引用:对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化
-
当遇到
1
new
、
1
getstatic
、
1
putstatic
、
1
invokestatic
这 4 条直接码指令时
- 当 jvm 执行
new
指令时会初始化类。即当程序创建一个类的实例对象。 - 当 jvm 执行
getstatic
指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。 - 当 jvm 执行
putstatic
指令时会初始化类。即程序给类的静态变量赋值。 - 当 jvm 执行
invokestatic
指令时会初始化类。即程序调用类的静态方法。
- 当 jvm 执行
-
使用
java.lang.reflect
包的方法对类进行反射调用时如Class.forname("...")
,newInstance()
等等。如果类没初始化,需要触发其初始化。 -
初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
-
当虚拟机启动时,用户需要定义一个要执行的主类 (包含
main
方法的那个类),虚拟机会先初始化这个类。 -
MethodHandle
和VarHandle
可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用findStaticVarHandle
来初始化要调用的类。 -
当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
被动引用:所有引用类的方式都不会触发初始化,称为被动引用
- 通过子类引用父类的静态字段,不会导致子类初始化,只会触发父类的初始化
- 通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法
- 常量(final 修饰)在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
- 调用 ClassLoader 类的 loadClass() 方法加载一个类,并不是对类的主动使用,不会导致类的初始化
❸类加载过程
0.生命周期
在Java中数据类型分为基本数据类型和引用数据类型。基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载。
按照Java虚拟机规范,从class文件到加载到内存中的类,到类卸载出内存为止,它的整个生命周期包括如下7个阶段:
- 加载(Loading)
- 链接:验证(Verification)、准备(Preparation)、解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 卸载(Unloading)
1.加载
加载过程完成以下三件事:
- 通过全限定类名获取定义此类的二进制字节流
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构(Java类模型)
- 在内存中生成一个代表该类的
Class
对象,作为方法区这些数据的访问入口
二进制字节流可以从以下方式中获取:
- 通过文件系统读入一个class后缀的文件
- 从 ZIP 包读取,成为 JAR、EAR、WAR 格式的基础
- 从网络中获取,最典型的应用是 Applet
- 由其他文件生成,例如由 JSP 文件生成对应的 Class 类
- 运行时计算生成,例如动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 生成字节码
方法区内部采用 C++ 的 instanceKlass 描述 Java 类的数据结构:
_java_mirror
即 Java 的类镜像,例如对 String 来说就是 String.class,作用是把 class 暴露给 Java 使用_super
即父类、_fields
即成员变量、_methods
即方法、_constants
即常量池、_class_loader
即类加载器、_vtable
虚方法表、_itable
接口方法表
加载过程:
- 如果这个类还有父类没有加载,先加载父类
Class 对象
和_java_mirror
相互持有对方的地址,堆中对象通过 instanceKlass 和元空间进行交互- 加载和链接可能是交替运行的
类实例&类模型位置 | 加载过程 |
---|---|
数组类型不通过类加载器创建,它由 Java 虚拟机直接创建。
创建数组类有些特殊,因为数组类本身并不是由类加载器负责创建,而是由 JVM 在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建,创建数组类的过程:
- 如果数组的元素类型是引用类型,那么遵循定义的加载过程递归加载和创建数组的元素类型
- JVM 使用指定的元素类型和数组维度来创建新的数组类
- 基本数据类型由启动类加载器加载
2.链接
2.1验证
确保 Class 文件的字节流中包含的信息是否符合 JVM 规范,保证被加载类的正确性,不会危害虚拟机自身的安全
主要包括四种验证:
- 文件格式验证
- 魔数检查
- 版本检查
- 长度检查
- 元数据验证
- 是否所有的类都有父类的存在(除了 Object 外,其他类都应该有父类)
- 是否一些被定义为 final 的方法或者类被重写或继承了
- 非抽象类是否实现了所有抽象方法或者接口方法
- 是否存在不兼容的方法
- 字节码验证
- 在字节码的执行过程中,是否会跳转到一条不存在的指令
- 函数的调用是否传递了正确类型的参数
- 变量的赋值是不是给了正确的数据类型
- 栈映射帧(StackMapTable)在这个阶段用于检测在特定的字节码处,其局部变量表和操作数栈是否有着正确的数据类型
- 符号引用验证
- Class 文件在其常量池会通过字符串记录将要使用的其他类或者方法
- 因此虚拟机就会检查这些类和方法是否存在,并且当前类有权限访问这些数据
- 如果一个需要使用类无法在系统中找到,则会抛出NoClassDefFoundError,如果一个方法无法被找到,则会抛出NoSuchMethodError。此阶段在解析环节才会执行。
2.2准备
为类变量/静态变量分配内存并设置默认初始值的阶段,使用的是方法区的内存。
说明:实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次
类变量初始化:
- static 变量分配空间和赋值是两个步骤:分配空间在准备阶段完成,赋值在初始化阶段完成
- 如果 static 变量是 final 的基本类型以及字符串常量,那么编译阶段值(方法区)就确定了,准备阶段会显式初始化
- 如果 static 变量是 final 的,但属于引用类型或者构造器方法的字符串,赋值在初始化阶段完成
实例:
-
初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123:
1
public static int value = 123;
-
常量 value 被初始化为 123 而不是 0:
1
public static final int value = 123;
-
Java 并不支持 boolean 类型,对于 boolean 类型,内部实现是 int,由于 int 的默认值是 0,故 boolean 的默认值就是 false
类型 | 默认初始值 |
---|---|
byte | (byte)0 |
short | (short)0 |
int | 0 |
long | 0L |
float | 0.0f |
double | 0.0 |
char | \u0000 |
boolean | false |
reference | null |
2.3解析
将常量池中类、接口、字段、方法的符号引用替换为直接引用(内存地址)的过程。
- 符号引用:用于描述目标。可以是任何字面量,属于编译原理方面的概念,如:包括类和接口的全限名、字段的名称和描述符、方法的名称和方法描述符(因为类还没有加载完,很多方法是找不到的)
- 直接引用:直接指向目标的地址。如果有了直接引用,那说明引用的目标必定已经存在于内存之中
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等
- 在类加载阶段解析的是非虚方法,静态绑定
- 也可以在初始化阶段之后再开始解析,这是为了支持 Java 的动态绑定
- 通过解析操作,符号引用就可以转变为目标方法在类的虚方法表中的位置,从而使得方法被成功调用
3.初始化
初始化阶段是执行初始化方法
()
方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
在编译生成 class 文件时,编译器会产生两个方法加于 class 文件中,一个是类的初始化方法 ()
,另一个是实例的初始化方法 ()
类构造器 ()
与实例构造器 ()
不同,它不需要程序员进行显式调用,在一个类的生命周期中,类构造器最多被虚拟机调用一次,后续实例化不再加载,引用第一次加载的类,而实例构造器则会被虚拟机调用多次,只要程序员创建对象
3.1 clinit
()
:类构造器,由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的
作用:是在类加载过程中的初始化阶段进行静态变量初始化和执行静态代码块
- 如果类中没有静态变量或静态代码块,那么
()
方法将不会被生成 ()
方法只执行一次,在执行()
方法时,必须先执行父类的()
方法- static 变量的赋值操作和静态代码块的合并顺序由源文件中出现的顺序决定
- static 不加 final 的变量都在初始化环节赋值
线程安全问题:
- 虚拟机会保证一个类的
()
方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的()
方法,其它线程都阻塞等待,直到活动线程执行()
方法完毕 - 如果在一个类的
()
方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽
特别注意:静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问
1 | public class Test { |
接口中不可以使用静态语句块,但有类变量初始化的赋值操作,因此接口与类一样都会生成 ()
方法
- 在初始化一个接口时,并不会先初始化它的父接口,所以执行接口的
()
方法不需要先执行父接口的()
方法 - 在初始化一个实现类时,不会先初始化所实现的接口,所以接口的实现类在初始化时不会执行接口的
()
方法 - 只有当父接口中定义的变量使用时,父接口才会初始化
3.3 init
()
指实例构造器,主要作用是在类实例化过程中执行,执行内容包括成员变量初始化和代码块的执行
实例化即调用 ()V
,虚拟机会保证这个类的构造方法的线程安全,先为实例变量分配内存空间,再执行赋默认值,然后根据源码中的顺序执行赋初值或代码块,没有成员变量初始化和代码块则不会执行
new 关键字会创建对象并复制 dup 一个对象引用,一个调用 `` 方法,另一个用来赋值给接收者
4.卸载
卸载类即该类的 Class 对象被 GC。
卸载类需要满足3个要求:
- 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象
- 该类没有在其他任何地方被引用
- 该类的类加载器的实例已被 GC
- JVM 自带的类加载器加载的类是不会被卸载的
- 因为 JVM 会始终引用启动、扩展、系统类加载器,这些类加载器始终引用它们所加载的类,这些类始终是可及的
- 自定义的类加载器加载的类是可能被卸载。
❹类实例化过程
父类的类构造器 ➔ 子类的类构造器
➔ 父类的实例构造器 ➔ 父类的构造函数 ➔ 子类的的实例构造器
➔ 子类的构造函数
1 | //父类 |
❺类加载器
0.基础知识
类加载分类
-
显式加载:在代码中通过调用ClassLoader加载class对象,如直接使用Class.forName(name)或ClassLoader.loadClass(name)加载class对象。
- ClassLoader.loadClass(className):只加载和链接,不会进行初始化
- Class.forName(String name, boolean initialize, ClassLoader loader):使用 loader 进行加载和链接,根据参数 initialize 决定是否初始化
-
隐式加载:不直接在代码中调用 ClassLoader 的方法加载类对象,如在加载某个类的class文件时,该类的class文件中引用了另外一个类的对象,此时额外引用的类将通过JVM自动加载到内存中。
- 创建类对象、使用类的静态域、创建子类对象、使用子类的静态域
- 在 JVM 启动时,通过三大类加载器加载 class
1 | //隐式加载 |
类加载器基本特征:
- 可见性,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的
- 单一性,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,不会在子加载器中重复加载
ClassLoader
ClassLoader 类,是一个抽象类,除启动类加载器外其它类加载器都继承自 ClassLoader
获取 ClassLoader 的途径:
- 获取当前类的 ClassLoader:
clazz.getClassLoader()
- 获取当前线程上下文的 ClassLoader:
Thread.currentThread.getContextClassLoader()
- 获取系统的 ClassLoader:
ClassLoader.getSystemClassLoader()
- 获取调用者的 ClassLoader:
DriverManager.getCallerClassLoader()
ClassLoader 类常用方法:
getParent()
:返回该类加载器的超类加载器loadclass(String name)
:加载名为name的类,返回结果为Class类的实例,该方法就是双亲委派模式findclass(String name)
:查找二进制名称为 name 的类,返回结果为 Class 类的实例,该方法会在检查完父类加载器之后被 loadClass() 方法调用findLoadedClass(String name)
:查找名称为 name 的已经被加载过的类,final 修饰无法重写defineClass(String name, byte[] b, int off, int len)
:将字节流解析成 JVM 能够识别的类对象resolveclass(Class c)
:链接指定的 Java 类,可以使类的 Class 对象创建完成的同时也被解析InputStream getResourceAsStream(String name)
:指定资源名称获取输入流
类加载模型
在 JVM 中,对于类加载模型提供了三种,分别为全盘加载、双亲委派、缓存机制
- **全盘加载:**当一个类加载器负责加载某个 Class 时,该 Class 所依赖和引用的其他 Class 也将由该类加载器负责载入,除非显示指定使用另外一个类加载器来载入
- **双亲委派:**某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有当父加载器无法完成此加载任务时,才自己去加载
- **缓存机制:**会保证所有加载过的 Class 都会被缓存,当程序中需要使用某个 Class 时,类加载器先从缓存区中搜寻该 Class,只有当缓存区中不存在该 Class 对象时,系统才会读取该类对应的二进制数据,并将其转换成 Class 对象存入缓冲区(方法区)中。这就是修改了 Class 后,必须重新启动 JVM,程序所做的修改才会生效的原因
1.加载器
类加载器是 Java 的核心组件,用于加载字节码到 JVM 内存,得到 Class 类的对象
从 Java 虚拟机规范来讲,只存在以下两种不同的类加载器:
- 启动类加载器(Bootstrap ClassLoader):使用 C++ 实现,是虚拟机自身的一部分
- 自定义类加载器(User-Defined ClassLoader):Java 虚拟机规范将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器,使用 Java 语言实现,独立于虚拟机
从 Java 开发人员的角度看:
- 启动类加载器(Bootstrap ClassLoader):
- 出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类
- 启动类加载器负责加载在
JAVA_HOME/jre/lib
或sun.boot.class.path
目录中的,或者被-Xbootclasspath
参数所指定的路径中的类,并且是虚拟机识别的类库加载到虚拟机内存中 - 仅按照文件名识别,如 rt.jar 名字不符合的类库即使放在 lib 目录中也不会被加载
- 启动类加载器无法被 Java 程序直接引用,编写自定义类加载器时,如果要把加载请求委派给启动类加载器,直接使用 null 代替
- 扩展类加载器(Extension ClassLoader):
- 由
ExtClassLoader (sun.misc.Launcher$ExtClassLoader)
实现,上级为 Bootstrap,显示为 null - 将
JAVA_HOME/jre/lib/ext
或者被java.ext.dir
系统变量所指定路径中的所有类库加载到内存中 - 开发者可以使用扩展类加载器,创建的 JAR 放在此目录下,会由扩展类加载器自动加载
- 由
- 应用程序类加载器(Application ClassLoader):
- 由
AppClassLoader(sun.misc.Launcher$AppClassLoader)
实现,上级为 Extension - 负责加载环境变量 classpath 或系统属性
java.class.path
指定路径下的类库 - 这个类加载器是 ClassLoader 中的
getSystemClassLoader()
方法的返回值,因此称为系统类加载器 - 可以直接使用这个类加载器,如果应用程序中没有自定义类加载器,这个就是程序中默认的类加载器
- 由
- 自定义类加载器:由开发人员自定义的类加载器,上级是 Application
1 | public class ClassLoaderTest { |
2.双亲委派
- 向上委托:如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器。如果父类加载器可以完成类加载任务,就成功返回;
- 向下委派:倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
双亲委派机制的优点:
-
可以避免类被重复加载,当父类已经加载后则无需重复加载,保证类的全局唯一性
-
保护程序安全,防止类库的核心 API 被随意篡改
例如:在工程中新建 java.lang 包,接着在该包下新建 String 类,并定义 main 函数
1
2
3
4
5public class String {
public static void main(String[] args) {
System.out.println("demo info");
}
}此时执行 main 函数会出现异常,在类 java.lang.String 中找不到 main 方法。因为双亲委派的机制,java.lang.String 在启动类加载器(Bootstrap)得到加载,启动类加载器优先级更高,在核心 jre 库中有其相同名字的类文件,但该类中并没有 main 方法
双亲委派机制的缺点:
检查类是否加载的委托过程是单向的,这个方式虽然从结构上看比较清晰,使各个 ClassLoader 的职责非常明确,但顶层的 ClassLoader 无法访问底层的 ClassLoader 所加载的类(可见性)
通常情况下,启动类加载器中的类为系统核心类,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问题,但是系统类访问应用类就会出现问题。
参考:https://m.imooc.com/wiki/jvm-loadparent
3.源码分析
双亲委派机制在java.lang.ClassLoader.loadClass(String,boolean)
方法中体现。逻辑如下:
- 先在当前加载器的缓存中查找有无目标类
findLoadedClass(name)
,如果有,直接返回。 - 判断当前加载器的父加载器是否为空,如果不为空,则调用
parent.loadClass(name,false)
接口进行加载。 - 如果当前加载器的父加载器为空,则调用
findBootstrapClassorNull(name)
接口,让启动类加载器进行加载。 - 如果通过以上3条路径都没能成功加载,则调用
findClass(name)
接口进行加载。该接口最终会调用java.lang.ClassLoader
接口的defineClass
系列的native
接口加载目标Java类。
双亲委派的模型就隐藏在这第2和第3步中。
1 | protected Class<?> loadClass(String name, boolean resolve) |
4.破坏委派
双亲委派模型并不是一个具有强制性约束的模型,而是 Java 设计者推荐给开发者的类加载器实现方式
破坏双亲委派模型的方式:
-
1.自定义 ClassLoader
- 如果不想破坏双亲委派模型,只需要重写 findClass 方法
- 如果想要去破坏双亲委派模型,需要去**重写 loadClass **方法
-
2.引入线程上下文类加载器
Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的有 JDBC、JCE、JNDI 等。这些 SPI 接口由 Java 核心库来提供,而 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径 classpath 里,SPI 接口中的代码需要加载具体的实现类:
- SPI 的接口是 Java 核心库的一部分,是由启动类加载器来加载的
- SPI 的实现类是由系统类加载器加载,启动类加载器是无法找到 SPI 的实现类,因为双亲委派模型中 BootstrapClassloader 无法委派 AppClassLoader 来加载类
JDK 开发人员引入了线程上下文类加载器(Thread Context ClassLoader),这种类加载器可以通过 Thread 类的 setContextClassLoader 方法设置线程上下文类加载器,在执行线程中抛弃双亲委派加载模式,使程序可以逆向使用类加载器,使 Bootstrap 加载器拿到了 Application 加载器加载的类,破坏了双亲委派模型
-
3.实现程序的动态性,如代码热替换(Hot Swap)、模块热部署(Hot Deployment)
IBM 公司主导的 JSR一291(OSGiR4.2)实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi 中称为 Bundle)都有一个自己的类加载器,当更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换,在 OSGi 环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构
当收到类加载请求时,OSGi 将按照下面的顺序进行类搜索:
- 将以 java.* 开头的类,委派给父类加载器加载
- 否则,将委派列表名单内的类,委派给父类加载器加载
- 否则,将 Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器加载
- 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载
- 否则,查找类是否在自己的 Fragment Bundle 中,如果在就委派给 Fragment Bundle 类加载器加载
- 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载
- 否则,类查找失败
热替换是指在程序的运行过程中,不停止服务,只通过替换程序文件来修改程序的行为,热替换的关键需求在于服务不能中断,修改必须立即表现正在运行的系统之中
5.自定义加载器
对于自定义类加载器的实现,只需要继承 ClassLoader 类,覆写 findClass 方法即可
作用:隔离加载类、修改类加载的方式、拓展加载源、防止源码泄漏
1 | //自定义类加载器,读取指定的类路径classPath下的class文件 |
1 | public static void main(String[] args) { |
6.JDK9新特性
为了保证兼容性,JDK9 没有改变三层类加载器架构和双亲委派模型,但为了模块化系统的顺利运行做了一些变动:
- 扩展机制被移除,扩展类加载器由于向后兼容性的原因被保留,不过被重命名为平台类加载器(platform classloader),可以通过 ClassLoader 的新方法 getPlatformClassLoader() 来获取
- JDK9 基于模块化进行构建(原来的 rt.jar 和 tools.jar 被拆分成数个 JMOD 文件),其中 Java 类库就满足了可扩展的需求,那就无须再保留
\lib\ext
目录,此前使用这个目录或者java.ext.dirs
系统变量来扩展 JDK 功能的机制就不需要再存在 - 启动类加载器、平台类加载器、应用程序类加载器全都继承于
jdk.internal.loader.BuiltinClassLoader
⑤程序编译
Java 代码执行流程:
Java 程序(.java) --(编译)--> 字节码文件(.class)--(解释执行/JIT)--> 操作系统(Win,Linux)
❶字节码指令集
Java 字节码由操作码和操作数组成。
- 操作码(Opcode):一个字节长度(0-255,意味着指令集的操作码总数不可能超过 256 条),代表着某种特定的操作含义。
- 操作数(Operands):零个或者多个,紧跟在操作码之后,代表此操作需要的参数。
由于 Java 虚拟机是基于栈而不是寄存器的结构,所以大多数指令都只有一个操作码。比如 aload_0
(将局部变量表中下标为 0 的数据压入操作数栈中)就只有操作码没有操作数,而 invokespecial #1
(调用成员方法或者构造方法,并传递常量池中下标为 1 的常量)就是由操作码和操作数组成的。
0.字节码与数据类型
在 JVM 的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如 iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据
- i 代表对 int 类型的数据操作
- l 代表 long
- s 代表 short
- b 代表 byte
- c 代表 char
- f 代表 float
- d 代表 double
- a代表引用类型
大部分的指令都没有支持 byte、char、short、boolean 类型,编译器会在编译期或运行期将 byte 和 short 类型的数据带符号扩展(Sign-Extend-)为相应的 int 类型数据,将 boolean 和 char 类型数据零位扩展(Zero-Extend)为相应的 int 类型数据
在做值相关操作时:
- 一个指令,可以从局部变量表、常量池、堆中对象、方法调用、系统调用中等取得数据,这些数据(可能是值,也可能是对象的引用)被压入操作数栈
- 一个指令,也可以从操作数栈中取出一到多个值(pop 多次),完成赋值、加减乘除、方法传参、系统调用等等操作
1.加载与存储指令
加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递
局部变量压栈指令:将给定的局部变量表中的数据压入操作数栈
xload
、xload_n
,x 表示数据类型,为 i、l、f、d、a; n 为 0 到 3- 指令
xload_n
表示将第 n 个局部变量压入操作数栈,aload_n 表示将一个对象引用压栈 - 指令
xload n
通过指定参数的形式,把局部变量压入操作数栈,局部变量数量超过 4 个时使用这个命令
常量入栈指令:将常数压入操作数栈,根据数据类型和入栈内容的不同,又分为 const_
、push
、ldc
指令
push
:包括 bipush 和 sipush,区别在于接收数据类型的不同,bipush 接收 8 位整数作为参数,sipush 接收 16 位整数ldc
:如果以上指令不能满足需求,可以使用 ldc 指令,接收一个 8 位的参数,该参数指向常量池中的 int、 float 或者 String 的索引,将指定的内容压入堆栈。ldc_w
接收两个 8 位参数,能支持的索引范围更大,如果要压入的元素是 long 或 double 类型的,则使用ldc2_w
指令aconst_null
将 null 对象引用压入栈,iconst_m1
将 int 类型常量 -1 压入栈,iconst_0
将 int 类型常量 0 压入栈
出栈装入局部变量表指令:将操作数栈中栈顶元素弹出后,装入局部变量表的指定位置,用于给局部变量赋值
xstore
、xstore_n
,x 表示取值类型为 i、l、f、d、a, n 为 0 到 3xastore
表示存入数组,x 取值为 i、l、f、d、a、b、c、s
扩充局部变量表的访问索引的指令:wide
2.算术指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把计算结果重新压入操作数栈
没有直接支持 byte、 short、 char 和 boolean 类型的算术指令,对于这些数据的运算,都使用 int 类型的指令来处理,数组类型也是转换成 int 数组
- 加法指令:iadd、ladd、fadd、dadd
- 减法指令:isub、lsub、fsub、dsub
- 乘法指令:imu、lmu、fmul、dmul
- 除法指令:idiv、ldiv、fdiv、ddiv
- 求余指令:irem、lrem、frem、drem(remainder 余数)
- 取反指令:ineg、lneg、fneg、dneg (negation 取反)
- 自增指令:iinc 1 1(直接在局部变量 slot 上进行运算,不用放入操作数栈)
- 自减指令:iinc 1 -1
- 位运算指令,又可分为:
- 位移指令:ishl、ishr、 iushr、lshl、lshr、 lushr
- 按位或指令:ior、lor
- 按位与指令:iand、land
- 按位异或指令:ixor、lxor
- 比较指令:dcmpg、dcmpl、 fcmpg、fcmpl、lcmp
运算模式:
- 向最接近数舍入模式:JVM 在进行浮点数计算时,所有的运算结果都必须舍入到适当的精度,非精确结果必须舍入为可被表示的最接近的精确值,如果有两种可表示形式与该值一样接近,将优先选择最低有效位为零的
- 向零舍入模式:将浮点数转换为整数时,该模式将在目标数值类型中选择一个最接近但是不大于原值的数字作为最精确的舍入结果
NaN 值:当一个操作产生溢出时,将会使用有符号的无穷大表示,如果某个操作结果没有明确的数学定义,将使用 NaN 值来表示
1 | double j = i / 0.0; |
分析 i++:从字节码角度分析:a++ 和 ++a 的区别是先执行 iload 还是先执行 iinc
1 | 4 iload_1 //存入操作数栈 |
1 | public class Demo { |
判断结果:
1 | public class Demo { |
3.类型转换指令
类型转换指令可以将两种不同的数值类型进行相互转换,除了 boolean 之外的七种类型
宽化类型转换:
- JVM 支持以下数值的宽化类型转换(widening numeric conversion),小范围类型到大范围类型的安全转换
- 从 int 类型到 long、float 或者 double 类型,对应的指令为 i2l、i2f、i2d
- 从 long 类型到 float、 double 类型,对应的指令为 l2f、l2d
- 从 float 类型到 double 类型,对应的指令为 f2d
- 精度损失问题
- 宽化类型转换是不会因为超过目标类型最大值而丢失信息
- 从 int 转换到 float 或者 long 类型转换到 double 时,将可能发生精度丢失
- 从 byte、char 和 short 类型到 int 类型的宽化类型转换实际上是不存在的,JVM 把它们当作 int 处理
窄化类型转换:
- Java 虚拟机直接支持以下窄化类型转换:
- 从 int 类型至 byte、 short 或者 char 类型,对应的指令有 i2b、i2c、i2s
- 从 long 类型到 int 类型,对应的指令有 l2i
- 从 float 类型到 int 或者 long 类型,对应的指令有:f2i、f2l
- 从 double 类型到 int、long 或 float 者类型,对应的指令有 d2i、d2、d2f
- 精度损失问题:
- 窄化类型转换可能会导致转换结果具备不同的正负号、不同数量级,转换过程可能会导致数值丢失精度
- 将一个浮点值窄化转换为整数类型 T(T 限于 int 或 long 类型之一)时,将遵循以下转换规则:
- 如果浮点值是 NaN,那转换结果就是 int 或 long 类型的 0
- 如果浮点值不是无穷大的话,浮点值使用 IEEE 754 的向零舍入模式取整,获得整数值 v,如果 v 在目标类型 T 的表示范围之内,那转换结果就是 v,否则将根据 v 的符号,转换为 T 所能表示的最大或者最小正数
4.对象的创建与访问指令
-
创建类实例指令:new,接收一个操作数指向常量池的索引,表示要创建的类型,执行完成后将对象的引用压入栈
-
创建数组的指令:newarray、anewarray、multianewarray
- newarray:创建基本类型数组
- anewarray:创建引用类型数组
- multianewarray:创建多维数组
arraylength:取数组长度的指令。该指令弹出栈顶的数组元素,获取数组的长度,将长度压入栈。
-
字段访问指令:对象创建后可以通过对象访问指令获取对象实例或数组实例中的字段或者数组元素
- getstatic:从类中获取静态字段(static字段/类变量)
- putstatic: 设置类中静态字段的值
- getfield:获取类实例字段(非static字段/实例变量)
- putfield:设置类实例字段的值
-
类型检查指令:检查类实例或数组类型的指令
- checkcast:用于检查类型强制转换是否可以进行,如果可以进行 checkcast 指令不会改变操作数栈,否则它会抛出 ClassCastException 异常
- instanceof:判断给定对象是否是某一个类的实例,会将判断结果压入操作数栈
5.方法调用与返回指令
方法调用指令
普通调用指令:
- invokestatic:调用静态方法
- invokespecial:调用私有方法、构造器,和父类的实例方法或构造器,以及所实现接口的默认方法
- invokevirtual:调用所有虚方法(虚方法分派),支持多态
- invokeinterface:调用接口方法
动态调用指令:
- invokedynamic:调用动态绑定的方法,动态解析出需要调用的方法
- Java7 为了实现动态类型语言支持而引入了该指令,但是并没有提供直接生成 invokedynamic 指令的方法,需要借助 ASM 这种底层字节码工具来产生 invokedynamic 指令
- Java8 的 lambda 表达式的出现,invokedynamic 指令在 Java 中才有了直接生成方式
指令对比:
- 普通调用指令固化在虚拟机内部,方法的调用执行不可干预,根据方法的符号引用链接到具体的目标方法
- 动态调用指令支持用户确定方法
- invokestatic 和 invokespecial 指令调用的方法称为非虚方法,虚拟机能够直接识别具体的目标方法
- invokevirtual 和 invokeinterface 指令调用的方法称为虚方法,虚拟机需要在执行过程中根据调用者的动态类型来确定目标方法
指令说明:
- 如果虚拟机能够确定目标方法有且仅有一个,比如说目标方法被标记为 final,那么可以不通过动态绑定,直接确定目标方法
- 普通成员方法是由 invokevirtual 调用,属于动态绑定,即支持多态
方法返回指令
方法调用结束前,需要进行返回。方法返回指令是根据返回值的类型区分的。
方法返回指令 | void | int | long | float | double | reference |
---|---|---|---|---|---|---|
xreturn | return | ireturn | lreturn | freutrn | dreturn | areturn |
ireturn(当返回值是boolean、byte、char、short和int 类型时使用)
通过ireturn指令,将当前函数操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中(因为调用者非常关心函数的返回值),所有在当前函数操作数栈中的其他元素都会被丢弃。
如果当前返回的是synchronized方法,那么还会执行一个隐含的monitorexit指令,退出临界区。
最后,会丢弃当前方法的整个帧,恢复调用者的帧,并将控制权转交给调用者。
6.操作数栈管理指令
JVM 提供的操作数栈管理指令,可以用于直接操作操作数栈的指令
- pop、pop2:将一个或两个元素从栈顶弹出,并且直接废弃
- dup、dup2,dup_x1、dup2_x1,dup_x2、dup2_x2:复制栈顶一个或两个数值并重新压入栈顶
- swap:将栈最顶端的两个 slot 数值位置交换,JVM 没有提供交换两个 64 位数据类型数值的指令
- nop:一个非常特殊的指令,字节码为 0x00,和汇编语言中的 nop 一样,表示什么都不做,一般可用于调试、占位等
7.比较控制指令
比较指令:比较栈顶两个元素的大小,并将比较结果入栈
- lcmp:比较两个 long 类型值
- fcmpl:比较两个 float 类型值(当遇到NaN时,返回-1)
- fcmpg:比较两个 float 类型值(当遇到NaN时,返回1)
- dcmpl:比较两个 double 类型值(当遇到NaN时,返回-1)
- dcmpg:比较两个 double 类型值(当遇到NaN时,返回1)
条件跳转指令:
指令 | 说明 |
---|---|
ifeq | equals,当栈顶int类型数值等于0时跳转 |
ifne | not equals,当栈顶in类型数值不等于0时跳转 |
iflt | lower than,当栈顶in类型数值小于0时跳转 |
ifle | lower or equals,当栈顶in类型数值小于等于0时跳转 |
ifgt | greater than,当栈顶int类型数组大于0时跳转 |
ifge | greater or equals,当栈顶in类型数值大于等于0时跳转 |
ifnull | 为 null 时跳转 |
ifnonnull | 不为 null 时跳转 |
比较条件跳转指令:
指令 | 说明 |
---|---|
if_icmpeq | 比较栈顶两 int 类型数值大小(下同),当前者等于后者时跳转 |
if_icmpne | 当前者不等于后者时跳转 |
if_icmplt | 当前者小于后者时跳转 |
if_icmple | 当前者小于等于后者时跳转 |
if_icmpgt | 当前者大于后者时跳转 |
if_icmpge | 当前者大于等于后者时跳转 |
if_acmpeq | 当结果相等时跳转 |
if_acmpne | 当结果不相等时跳转 |
多条件分支跳转指令:
- tableswitch:用于 switch 条件跳转,case 值连续
- lookupswitch:用于 switch 条件跳转,case 值不连续
无条件跳转指令:
- goto:用来进行跳转到指定行号的字节码
- goto_w:无条件跳转(宽索引)
8.异常处理指令
抛出异常指令:athrow 指令,在Java程序中显示抛出异常的操作(throw语句)都是由athrow指令来实现。
处理异常:
JVM 处理异常(catch 语句)不是由字节码指令来实现的,而是采用异常表来完成的
-
代码:
1
2
3
4
5
6
7
8
9
10public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
} finally {
i = 30;
}
} -
字节码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
300: iconst_0
1: istore_1 // 0 -> i ->赋值
2: bipush 10 // try 10 放入操作数栈顶
4: istore_1 // 10 -> i 将操作数栈顶数据弹出,存入局部变量表的 slot1
5: bipush 30 // 【finally】
7: istore_1 // 30 -> i
8: goto 27 // return
11: astore_2 // catch Exceptin -> e
12: bipush 20 //
14: istore_1 // 20 -> i
15: bipush 30 // 【finally】
17: istore_1 // 30 -> i
18: goto 27 // return
21: astore_3 // catch any -> slot 3
22: bipush 30 // 【finally】
24: istore_1 // 30 -> i
25: aload_3 // 将局部变量表的slot 3数据弹出,放入操作数栈栈顶
26: athrow // throw 抛出异常
27: return
Exception table: // 任何阶段出现任务异常都会执行 finally
from to target type
2 5 11 Class java/lang/Exception
2 5 21 any // 剩余的异常类型,比如 Error
11 15 21 any // 剩余的异常类型,比如 Error
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
12 3 2 e Ljava/lang/Exception;
0 28 0 args [Ljava/lang/String;
2 26 1 i I -
如果一个方法定义了一个try-catch 或者try-finally的异常处理,就会创建一个 Exception table 的结构,异常表保存了每个异常处理信息
1 | Exception table: |
-
[from, to) 是前闭后开的检测范围,一旦这个范围内的字节码执行出现异常,则通过 type 匹配异常类型,如果一致,进入 target 所指示行号
-
11 行的字节码指令 astore_2 是将异常对象引用存入局部变量表的 slot 2 位置,如果有多个catch语句,因为异常出现时,只能进入 Exception table 中一个分支,所以局部变量表 slot 2 位置被共用
-
字节码中 finally 中的代码被复制了 3 份,分别放入 try 流程,catch 流程以及 catch 剩余的异常类型流程,因此finally中的代码一定会执行
9.同步控制指令
Java虚拟机支持两种同步结构:方法级的同步和方法内部一段指令序列的同步,这两种同步都是使用monitor来支持的
方法级的同步:是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中,虚拟机可以从方法常量池的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否声明为同步方法
方法内指定指令序列的同步:有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义
- montiorenter:进入并获取对象监视器,即为栈顶对象加锁
- monitorexit:释放并退出对象监视器,即为栈顶对象解锁
🌟图解字节码运行
1)原始java 代码
1 | /* |
2)编译后的核心字节码文件
1 | MD5 checksum cc8fc12b6e178b8f28e787497e993363 |
3)常量池载入运行时常量池
4)方法字节码载入方法区
5)main线程开始运行,分配栈帧内存(stack=2, locals=4)
6)执行引擎开始执行字节码
bipush 10
- 将一个byte压入操作数栈(其长度会补齐4个字节),类似的指令还有
- sipush将一个short压入操作数栈(其长度会补齐4个字节)
- idc将一个int压入操作数栈
- idc2_w将一个long压入操作数栈(分两次压入,因为long是8个字节)
- 这里小的数字都是和字节码指令存在一起,超过short范围的数字存入常量池
istore_1
- 将操作数栈顶数据弹出,存入局部变量表的槽位slot 1中,下图的args/a/b/c就是对应的槽位
idc #3
- idc 从常量池加载 #3 数据到操作数栈
- 注意: Short.MAX_VALUE是32767,所以32768 = Short.MAX_VALUE + 1 超过了Short.MAX_VALUE的值,所以编译器将其放在运行时常量池中。实际是在编译期间计算好的,是一种优化,叫常量折叠优化
istore_2
- 将操作数栈顶数据弹出,存入局部变量表的2号槽位中
iload_1
再接下来,需要执行int c = a + b;执行引擎不能直接在局部变量表进行a+b操作,需要先将a、b进行读取,然后放入操作数栈中才能进行计算分析
- 把局部变量从1号槽位加载数据到操作数栈中
iload 2
- 再把局部变量从2号槽位加载数据到操作数栈中
iadd
- iadd会弹出操作数栈中的2个变量,并进行求和得到32778,最后将结果32778写回到操作数栈中
istore_3
- 将操作数栈的数弹出,存入局部变量表3号槽位。到目前为止,局部变量a、b、c都有值了
getstatic #4
- 去常量池找到成员变量引用,去堆中找到System.out对象。getstatic 会把对象引用放入操作数栈中
iload_3
- 将局部变量表3号槽位的值加载到操作数栈中
invokevirtual #5
- 找到常量池 #5项
- 定位到方法区 java/io/PrintStream.println: (I)V方法
- 生成新的栈帧(分配locals、stack等)
- 传递传数,执行新栈帧中的字节码
- 执行完毕,弹出栈帧
- 清除main操作数栈内容
return
- 完成main方法调用,弹出main栈帧
- 程序结束
🌟a++ 字节码分析
在日常的项目开发中,经常遇到a++、++a、a–之类,下面我们开始从字节码的视角来分析a++。
java代码如下:
1 | /* |
使用javap -v xxx.class 来查看类文件全部指令信息,如下:
1 | public static void main(java.lang.String[]); |
分析:
- iinc指令是直接在局部变量槽位slot上进行运算
- a++ 和 ++a 的区别是先执行 iload 还是先执行 iinc
- a++是先加载iload,再自增iinc
- ++a是先自增iinc,再加载iload
下面我们通过字节码来剖析如下两行代码在内存当中整个执行过程
1 | int a = 10; |
下图是先将10通过bipush 放入操作数栈中
接着将10从操作数栈上弹出存入局部变量表1号槽位,相当于代码 int a = 10 执行完成
接着执行:int b = a++ + ++a + a–; 因为有从左往右的执行顺序,所以先执行a++,先将a的值加载到操作数栈中;通过iload_1加载1号槽位的数据到操作数栈中
接着执行a++自增1操作,这个操作是在局部变量表中完成的。相当于完成了a++执行
再接着执行++a自增1操作,这个操作也是在局部变量表中完成的
接着从局部变量表1号槽位加载数据到操作数栈中,即12入栈,完成发a++ 、++a 各自的执行了
然后,iadd是将操作数栈中弹出(出栈)两个数12、10进行求和操作,得到22,最后将累加的结果22存入栈中。即完成了a++ + ++a的执行
接着,需要执行a–,先将局部变量表槽位1的数据12加载到操作数栈中
然后,将局部变量表槽位1的数据自减1
接着,执行iadd操作,将操作数栈12、22弹出栈后,进行求和操作得到34,再将34结果压入栈
最后,执行istore_2操作,将操作数栈弹出数据34,并压入局部变量表2号槽位中
❷编译器
-
前端编译器:把
*.java
文件转变成.class
文件的过程;如 JDK 的 Javac,Eclipse JDT 中的增量式编译器。 -
提前编译器:直接把程序编译成目标机器指令集相关的二进制代码的过程。如 JDK 的 jaotc,GUN Compiler for the Java(GCJ),Excelsior JET 。
-
即时编译器
:常称为 JIT 编译器(Just In Time Complier),在运行期把字节码转变成本地机器码的过程;如 HotSpot 虚拟机中的 C1、C2 编译器,Graal 编译器。
- 客户端编译器 (Client Complier):简称 C1;
- 服务端编译器 (Servier Complier):简称 C2;
- Graal 编译器:在 JDK 10 时才出现,长期目标是替代 C2。
C1 编译器会对字节码进行简单可靠的优化,耗时短,以达到更快的编译速度。
C1 编译器的优化方法:
- 方法内联:将调用的函数代码编译到调用点处,可以减少栈帧的生成,减少参数传递以及跳转过程
- 冗余消除:根据运行时状况进行代码折叠或削除
- 内联缓存:是一种加快动态绑定的优化技术(方法调用部分详解)
C2 编译器进行耗时较长的优化以及激进优化,优化的代码执行效率更高,当激进优化的假设不成立时,再退回使用 C1 编译,这也是使用分层编译的原因
C2 的优化主要是在全局层面,逃逸分析是优化的基础,如果不存在逃逸行为,则可进行如下优化:
- 栈上分配 (Stack Allocations):如果一个对象不会逃逸到线程外,那么将会在栈上分配内存来创建这个对象,而不是 Java 堆上,此时对象所占用的内存空间就会随着栈帧的出栈而销毁,从而可以减轻垃圾回收的压力。
- 标量替换 (Scalar Replacement):如果一个数据已经无法再分解成为更小的数据类型,那么这些数据就称为标量(如 int、long 等数值类型及 reference 类型等);反之,如果一个数据可以继续分解,那它就被称为聚合量(如对象)。如果一个对象不会逃逸外方法外,那么就可以将其改为直接创建若干个被这个方法使用的成员变量来替代,从而减少内存占用。
- *同步消除 (Synchronization Elimination)**:线程同步本身比较耗时,如果确定一个对象不会逃逸出线程,不被其它线程访问到,那对象的读写就不会存在竞争,则可以消除对该对象的*同步锁,通过 `
❸执行引擎(解释+JIT)
Java 是半编译半解释型语言,将解释执行与编译执行二者结合起来进行:
-
解释器:根据预定义的规范对字节码采用逐行解释的方式执行,即将每条字节码指令翻译为对应平台的本地机器指令执行
-
即时编译器(JIT : Just In Time Compiler):虚拟机在运行期把字节码编译成本地机器码后执行,并存入 Code Cache,下次遇到相同的代码直接执行,效率高
-
编译器(Compiler)和解释器(Interpreter)的工作都是将程序员的源代码翻译成可执行的机器代码,要么一次性翻译(编译器),要么逐行解释并运行(解释器)。
HostSpot 的默认执行方式:
- 当程序启动后,解释器可以马上发挥作用立即执行,让程序快速启动,省去编译器编译的时间(解释器存在的必要性)
- 随着程序运行时间的推移,如果虚拟机发现某个方法或代码块的运行特别频繁(热点探测功能),就会使用即时编译器将其编译为本地机器码,并使用各种手段进行优化,从而提高执行效率
HotSpot 可以通过 VM 参数设置程序执行方式:
- -Xint:完全采用解释器模式执行程序
- -Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行
- -Xmixed:采用解释器 + 即时编译器的混合模式共同执行程序
❹分层编译
在分层编译的工作模式出现前,采用客户端编译器还是服务端编译器完全取决于虚拟机是运行在客户端模式还是服务端模式下,可以在启动时通过
-client
或-server
参数进行指定,也可以让虚拟机根据自身版本和宿主机性能来自主选择。
要编译出优化程度越高的代码通常都需要越长的编译时间,为了在程序启动速度与运行效率之间达到最佳平衡,HotSpot 虚拟机在编译子系统中加入了分层编译(Tiered Compilation),JVM 将执行状态分成了 5 个层次:
- 第 0 层:纯解释器执行,并且解释器不开启性能监控功能;
- 第 1 层:使用C1即时编译器编译执行,进行简单可靠的稳定优化,不开启性能监控功能;
- 第 2 层:使用C1即时编译器编译执行,仅开启方法及回边次数统计等有限的性能监控;
- 第 3 层:使用C1即时编译器编译执行,开启全部性能监控;
- 第 4 层:使用C2即时编译器编译执行,并且会根据性能监控信息进行一些不可靠的激进优化。
C1 编译器会对字节码进行简单可靠的优化,耗时短,以达到更快的编译速度
C2 编译器进行耗时较长的优化以及激进优化,优化的代码执行效率更高
实施分层编译后,解释器、C1编译器和C2编译器就会同时工作,可以用C1编译器获取更高的编译速度、用C2编译器来获取更好的编译质量。
❺热点探测
即时编译器编译的目标是 “热点代码”,它主要分为以下两类:
- 被多次调用的方法。
- 被多次执行循环体。这里指的是一个方法只被少量调用过,但方法体内部存在循环次数较多的循环体,此时也认为是热点代码。但编译器编译的仍然是循环体所在的方法,而不会单独编译循环体。
判断某段代码是否是热点代码的行为称为 “热点探测” (Hot Spot Code Detection),主流的热点探测方法有以下两种:
- 基于采样的热点探测 (Sample Based Hot Spot Code Detection):采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那么就认为它是 “热点方法”。
- 基于计数的热点探测 (Counter Based Hot Spot Code Detection):采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是 “热点方法”。
HotSpot VM 采用的热点探测方式是基于计数器的热点探测,为每一个方法都建立 2 个不同类型的计数器:方法调用计数器(Invocation Counter)和回边计数器(BackEdge Counter)
-
方法调用计数器:用于统计方法被调用的次数,默认阈值在 Client 模式 下是 1500 次,在 Server 模式下是 10000 次(需要进行激进的优化),超过这个阈值,就会触发 JIT 编译,阈值可以通过虚拟机参数
-XX:CompileThreshold
设置工作流程:当一个方法被调用时, 会先检查该方法是否存在被 JIT 编译过的版本,存在则使用编译后的本地代码来执行;如果不存在则将此方法的调用计数器值加 1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值,如果超过阈值会向即时编译器提交一个该方法的代码编译请求
-
回边计数器:统计一个方法中循环体代码执行的次数,在字节码中控制流向后跳转的指令称为回边
如果一个方法中的循环体需要执行多次,可以优化为为栈上替换,简称 OSR (On StackReplacement) 编译,OSR 替换循环代码体的入口,C1、C2 替换的是方法调用的入口,OSR 编译后会出现方法的整段代码被编译了,但是只有循环体部分才执行编译后的机器码,其他部分仍是解释执行
⑥代码优化
❶语法糖
语法糖:指 Java 编译器把 *.java 源码编译为 *.class 字节码的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担
构造器
1 | public class Candy { |
1 | public class Candy { |
拆装箱
这段代码在 JDK 5 之前是无法编译通过的
1 | Integer x = 1; |
必须写成如下代码:
1 | Integer x = Integer.valueOf(1); //装箱 |
JDK5 以后编译阶段自动转换成上述片段
泛型擦除
泛型也是在 JDK 5 开始加入的特性,但 Java 在编译泛型代码后会执行泛型擦除的动作,即泛型信息在编译为字节码之后就丢失了,实际的类型都当做了 Object 类型来处理:
1 | List<Integer> list = new ArrayList<>(); |
编译器真正生成的字节码中,还要额外做一个类型转换的操作:
1 | // 需要将 Object 转为 Integer |
如果前面的 x 变量类型修改为 int 基本类型那么最终生成的字节码是:
1 | // 需要将 Object 转为 Integer, 并执行拆箱操作 |
可变参数
1 | public class Candy { |
可变参数 String... args
其实是 String[] args
, Java 编译器会在编译期间将上述代码变换为:
1 | public static void main(String[] args) { |
注:如果调用了 foo()
则等价代码为 foo(new String[]{})
,创建了一个空的数组,而不会传递 null 进去
foreach
数组的循环:
1 | int[] array = {1, 2, 3, 4, 5}; // 数组赋初值的简化写法也是语法糖 |
编译后为循环取数:
1 | for(int i = 0; i < array.length; ++i) { |
集合的循环:
1 | List<Integer> list = Arrays.asList(1,2,3,4,5); |
编译后转换为对迭代器的调用:
1 | List<Integer> list = Arrays.asList(1, 2, 3, 4, 5); |
注:foreach 循环写法,能够配合数组以及所有实现了 Iterable 接口的集合类一起使用,其中 Iterable 用来获取集合的迭代器
switch
switch 可以作用于字符串和枚举类:
字符串
1 | switch (str) { |
注意:switch 配合 String 和枚举使用时,变量不能为 null
会被编译器转换为:
1 | byte x = -1; |
总结:
- 执行了两遍 switch,第一遍是根据字符串的 hashCode 和 equals 将字符串的转换为相应 byte 类型,第二遍才是利用 byte 执行进行比较
- hashCode 是为了提高效率,减少可能的比较;而 equals 是为了防止 hashCode 冲突
枚举
switch 枚举的例子,原始代码:
1 | enum Sex { |
编译转换后的代码:
1 | /** |
枚举类
JDK 7 新增了枚举类:
1 | enum Sex { |
编译转换后:
1 | public final class Sex extends Enum<Sex> { |
try-w-r
JDK 7 开始新增了对需要关闭的资源处理的特殊语法 try-with-resources
,格式:
1 | try(资源变量 = 创建资源对象){ |
其中资源对象需要实现 AutoCloseable 接口,例如 InputStream、OutputStream、Connection、Statement、ResultSet 等接口都实现了 AutoCloseable ,使用 try-withresources可以不用写 finally 语句块,编译器会帮助生成关闭资源代码:
1 | try(InputStream is = new FileInputStream("a.txt")) { |
转换成:
1 | try { |
addSuppressed(Throwable e)
:添加被压制异常,是为了防止异常信息的丢失(fianlly 中如果抛出了异常)
方法重写
方法重写时对返回值分两种情况:
- 父子类的返回值完全一致
- 子类返回值可以是父类返回值的子类
1 | class A { |
对于子类,Java 编译器会做如下处理:
1 | class B extends A { |
其中桥接方法比较特殊,仅对 Java 虚拟机可见,并且与原来的 public Integer m() 没有命名冲突
匿名内部类
无参优化
源代码:
1 | public class Candy { |
转化后代码:
1 | // 额外生成的类 |
带参优化
引用局部变量的匿名内部类,源代码:
1 | public class Candy { |
转换后代码:
1 | final class Candy$1 implements Runnable { |
局部变量在底层创建为内部类的成员变量,必须是 final 的原因:
-
在 Java 中方法调用是值传递的,在匿名内部类中对变量的操作都是基于原变量的副本,不会影响到原变量的值,所以原变量的值的改变也无法同步到副本中
-
外部变量为 final 是在编译期以强制手段确保用户不会在内部类中做修改原变量值的操作,也是防止外部操作修改了变量而内部类无法随之变化出现的影响
在创建
Candy$1
对象时,将 x 的值赋值给了Candy$1
对象的 val 属性,x 不应该再发生变化了,因为发生变化,this.val$x 属性没有机会再跟着变化
反射优化
1 | public class Reflect1 { |
foo.invoke 0 ~ 15 次调用的是 MethodAccessor 的实现类 NativeMethodAccessorImpl.invoke0()
,本地方法执行速度慢;当调用到第 16 次时,会采用运行时生成的类 sun.reflect.GeneratedMethodAccessor1
代替
1 | public Object invoke(Object obj, Object[] args)throws Exception { |
1 | public class GeneratedMethodAccessor1 extends MethodAccessorImpl { |
通过查看 ReflectionFactory 源码可知:
- sun.reflect.noInflation 可以用来禁用膨胀,直接生成 GeneratedMethodAccessor1,但首次生成比较耗时,如果仅反射调用一次,不划算
- sun.reflect.inflationThreshold 可以修改膨胀阈值
❷运行期优化(JIT优化)
即时编译器除了将字节码编译为本地机器码外,还会对代码进行一定程度的优化,它包含多达几十种优化技术,这里选取其中代表性的进行介绍:
逃逸分析
逃逸分析并不是直接的优化手段,而是一个代码分析方式,通过动态分析对象的作用域,为优化手段如栈上分配、标量替换和同步消除等提供依据,发生逃逸行为的情况有两种:方法逃逸和线程逃逸
- 方法逃逸:当一个对象在方法中定义之后,被外部方法引用
- 全局逃逸:一个对象的作用范围逃出了当前方法或者当前线程,比如对象是一个静态变量、全局变量赋值、已经发生逃逸的对象、作为当前方法的返回值
- 参数逃逸:一个对象被作为方法参数传递或者被参数引用
- 线程逃逸:如类变量或实例变量,可能被其它线程访问到
1 | public static StringBuilder concat(String... strings) { |
如果不存在逃逸行为,则可以对该对象进行如下优化:同步消除、标量替换和栈上分配
- 栈上分配 (Stack Allocations):如果一个对象不会逃逸到线程外,那么将会在栈上分配内存来创建这个对象,而不是 Java 堆上,此时对象所占用的内存空间就会随着栈帧的出栈而销毁,从而可以减轻垃圾回收的压力。
- 标量替换 (Scalar Replacement):如果一个数据已经无法再分解成为更小的数据类型,那么这些数据就称为标量(如 int、long 等数值类型及 reference 类型等);反之,如果一个数据可以继续分解,那它就被称为聚合量(如对象)。如果一个对象不会逃逸外方法外,那么就可以将其改为直接创建若干个被这个方法使用的成员变量来替代,从而减少内存占用。
-XX:+EliminateAllocations
:开启标量替换-XX:+PrintEliminateAllocations
:查看标量替换情况
- *同步消除 (Synchronization Elimination)**:线程同步本身比较耗时,如果确定一个对象不会逃逸出线程,不被其它线程访问到,那对象的读写就不会存在竞争,则可以消除对该对象的*同步锁,通过
-XX:+EliminateLocks
可以开启同步消除 ( - 号关闭)
方法内联
方法内联:将调用的函数代码编译到调用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
方法内联能够消除方法调用的固定开销,任何方法除非被内联,否则调用都会有固定开销,来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。
1 | private static int square(final int i) { |
square 是热点方法,会进行内联,把方法内代码拷贝粘贴到调用者的位置:
1 | System.out.println(9 * 9); |
还能够进行常量折叠(constant folding)的优化:
1 | System.out.println(81); |
- 冗余消除:根据运行时状况进行代码折叠或削除
- 内联缓存:是一种加快动态绑定的优化技术(方法调用部分详解)
公共子表达式消除
如果一个表达式 E 之前已经被计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生过变化,那么 E 这次的出现就称为公共子表达式。对于这种表达式,无需再重新进行计算,只需要直接使用前面的计算结果即可。
数组边界检查消除
对于虚拟机执行子系统来说,每次数组元素的读写都带有一次隐含的上下文检查以避免访问越界。如果数组的访问发生在循环之中,并且使用循环变量来访问数据,即循环变量的取值永远在 [0,list.length) 之间,那么此时就可以消除整个循环的数据边界检查,从而避免多次无用的判断。
⑦性能监控&调优
❶监控&诊断工具
命令行工具
- jps:查看正在运行的Java进程
- jstat:查看JVM统计信息
- jinfo:实时查看和修改JVM配置参数
- jmap:导出内存映像文件&内存使用情况
- jhat:JDK自带堆分析工具
- jstack:打印JVM中线程快照
- jcmd:多功能命令行,可以用来实现前面除了jstat之外所有命令的功能
- jstatd:远程主机信息收集
GUI工具
JConsole
从Java5开始,在JDK中自带的Java监控和管理控制台。用于对JVM中内存、线程和类等的监控。
Visual VM
多功能的监测工具,可以连续监测,集成了多个JDK命令行工具,用于显示虚拟机进程及进程的配置和环境信息(jps,jinfo),监视应用程序的CPU、GC、堆、方法区及线程的信息(jstat、jstack)等,甚至代替JConsole。
JProfiler
主要功能:
1-方法调用:对方法调用的分析可以帮助您了解应用程序正在做什么,并找到提高其性能的方法
2-内存分配:通过分析堆上对象、引用链和垃圾收集能帮您修复内存泄露问题,优化内存使用
3-线程和锁:JProfiler提供多种针对线程和锁的分析视图助您发现多线程问题
4-高级子系统:许多性能问题都发生在更高的语义级别上。例如,对于JDBC调用,您可能希望找出执行最慢的SQL语句。JProfiler支持对这些子系统进行集成分析
Arthas
Arthas是Alibaba开源的Java诊断工具
HSDB
JDK自带的工具,用于查看JVM运行时的状态
1 | java -cp /Library/Java/JavaVirtualMachines/jdk1.8.0_351.jdk/Contents/Home/lib/sa-jdi.jar sun.jvm.hotspot.HSDB |
❷JVM运行时参数
JVM参数⼤致可以分为三类:
-
标注指令: -开头,所有的JVM实现都必须实现这些参数的功能,而且向后兼容。可以⽤ java -help 打印出来。
-
⾮标准指令: -X开头,默认jvm实现这些参数的功能,但是并不保证所有jvm实现都满足,且不保证向后兼容。可以⽤ java -X 打印出来。
-
非稳定参数: -XX 开头,此类参数各个jvm实现会有所不同,将来可能会随时取消,需要慎重使用;
1
2
3
4
5java -XX:+PrintCommandLineFlags // 查看当前JVM的不稳定指令。
java -XX:+PrintFlagsInitial // 查看所有不稳定指令的默认值。
java -XX:+PrintFlagsFinal // 查看所有不稳定指令最终⽣效的实际值。
-
设置堆内存指令:
-Xmx Size
-
设置栈内存大小:
-Xss size
-
-XX:StringTableSize=个数
StringTable桶个数 -
-XX:MaxTenuringThreshold
:定义年龄的阈值,默认是 15 -
-XX:PretenureSizeThreshold
:大于此值的对象直接在老年代分配 -
-XX:MaxMetaspaceSize=size
元空间大小设置 -
-XX:MaxTenuringThreshold
调大对象进入老年代的年龄,让对象在新生代多存活一段时间 -
-XX:UseTLAB
:设置是否开启 TLAB 空间 -
-XX:TLABWasteTargetPercent
:设置 TLAB 空间所占用 Eden 空间的百分比大小,默认情况下 TLAB 空间的内存非常小,仅占有整个 Eden 空间的1% -
-XX:TLABRefillWasteFraction
:指当 TLAB 空间不足,请求分配的对象内存大小超过此阈值时不会进行 TLAB 分配,直接进行堆内存分配,否则还是会优先进行 TLAB 分配 -
-XX:+EliminateAllocations
:开启标量替换 -
-XX:+PrintEliminateAllocations
:查看标量替换情况 -
-XX:PremSize:设置永久代的初始大小
-
-XX:MaxPermSize: 设置永久代的最大值
含义参数
堆初始大小-Xms
堆最大大小-Xmx 或 -XX:MaxHeapSize=size
新生代大小-Xmn 或 (-XX:NewSize=size + -XX:MaxNewSize=size )
幸存区比例(动态) -XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy
幸存区比例-XX:SurvivorRatio=ratio
晋升阈值-XX:MaxTenuringThreshold=threshold
晋升详情-XX:+PrintTenuringDistribution
GC详情-XX:+PrintGCDetails -verbose:gc
FullGC 前 MinorGC -XX:+ScavengeBeforeFullGC