Java 虚拟机 - JVM
本文主要通过以下几个方面来系统的介绍 JVM
:
【参考资料】
零、什么是 JVM?
JVM (Java Virtual Machine)
是 Java 程序的运行环境(Java 二进制字节码的运行环境)
好处:
- 可以提供一个跨平台的一致的运行环境, 达到平台无关性;
- 提供内存管理, 垃圾回收功能;
JRE = JVM + 基础类库
JDK = JVM + 基础类库 + 编译工具
一、JVM 结构
总体分为三大部分:
ClassLoader
类加载器:Java
代码编译成二进制后,会经过类加载器,这样才能加载到JVM
中运行。JVM
内存结构- 执行引擎
二、JVM 内存结构
- 程序计数器 (Program Counter Register)
- 虚拟机栈 (JVM Stacks)
- 本地方法栈 (Native Method Stacks)
- 堆 (Heap)
- 方法区 (Method Area)
1. 程序计数器
Java中 JVM
指令的实行流程
作用: 在指令的执行中, 记住下一条 JVM
指令的执行地址. 在物理上可使用寄存器实现.
特点:
- 线程私有。在多线程下, 线程间切换时需要保存当前环境, 需要用到程序计数器记住下一条
JVM
指令的执行地址 - 不存在内存溢出。
2. 虚拟机栈
2.1 定义
回忆数据结构中“栈”的结构: 先进后出
虚拟机栈是**线程运行需要的内存空间**,一个栈由多个栈帧组成。一个栈帧对应一次方法的调用,栈帧(Frame)即每个方法调用时需要的内存(参数、局部变量、返回地址等)。
- 每个线程只能有一个**活动栈帧,对应着当前正在执行的那个方法**,栈顶的栈帧。
注意⚠️:可以在 IDEA 中用 “debug” 模式下的“Debugger”视图中看到栈和栈帧.
思考:
-
在函数的调用中,
- 先把主调函数入栈,调用被调函数,紧接着被调函数入栈,活动栈帧为被调函数;
- 等被调函数返回返回值时,被调函数出栈,活动栈帧为主调函数。
-
垃圾回收不涉及栈内存, 因为每次执行后栈内存都会被清空(出栈)
-
栈内存越大, 线程数越小 (默认 1024KB)
1
-Xss 1m or 1024k or 1048576
2.2 栈内存溢出
1 | java.lang.StackOverflowError |
-
栈帧过多导致内存溢出
-
想象一下,在不断的调用方法时,一直入栈没有出栈,直到某一次调用时无法分配新的栈帧内存。
e.g. 无递归终止条件的递归调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class StackOverflowTest {
public static int count;
public static void main(String[] args){
try {
method1();
} catch (Throwable a) {
e.printStackTrace();
System.out.print(count);
}
}
private static void method1() {
count++;
method1();
}
} -
-
栈帧过大导致内存溢出,栈帧 > 栈内存
2.3 线程运行诊断
如何查看某个进程中 CPU 的占用情况
1 | ps H -eo pid,tid,%cpu | grep 进程id |
JDK
自带一个工具 JStack
命令, 用于定位 CPU占用过多的 Java线程(TID). 根据线程 id(TID) 找到有问题的线程,即可能有问题的代码行数. 也可以发现有死锁的进程.
1 | jstack 进程id(PID) |
3. 本地方法栈
本地方法是由于 Java 语言限制, 不能直接和操作系统底层“打交道”,所以需要 c/c++
语言编写的方法直接与底层操作系统“打交道”, 而java代码可以使用本地方法调用来调用这些方法。
本地方法使用的内存就是本地方法栈.
- 例如
hashCode()
,wait()
,notify()
,notifyAll()
等 - 由
native
修饰
4. 堆
线程共享的区域,都要考虑线程安全问题
4.1 定义
- 通过
new
关键字 创建一个堆,都会使用堆的内存
特点:
- 线程共享,堆中对象都要考虑线程安全问题
- 有垃圾回收机制,当对象不再被引用时,其占用的内存会被回收
4.2 堆内存溢出
1 | java.lang.OutOfMemoryError |
1 | public static void main (String[] args){ |
4.3 堆内存诊断
Java
常用工具:
jps
工具- 查看当前系统中有哪些java进程
jmap
工具- 查看某一时刻下,堆内存的占用情况
1 | $ jps |
jconsole
工具- 图形界面的, 多功能的检查工具, 可以连续监测
jvisualvm
工具 (需要自行下载)
1 | public static void main (String[] args){ |
5. 方法区
5.1 定义
方法区是 JVM
中所有线程共享的区域.
存储了与类结构相关的信息:
- 成员变量 (field)
- 方法的数据 (method data)
- 方法的代码 (code of method)
- 构造器的代码 (code of constructor)
- 运行时常量池 (run-time constant pool)
方法区在 JVM
**启动时**创建,逻辑上是堆的一部分。
5.2 组成
JDK 1.6 与 JDK 1.8。
- JDK 1.6 中,方法区这种概念的实现方式(永久代)属于
JVM
的内存结构; - JDK 1.8 中,方法区这种概念的实现方式(元空间)从
JVM
的内存结构中提取出来,属于操作系统内存结构的一部分
5.3 方法区的内存溢出
- 永久代内存溢出(JDK 1.8以前)
1 | ERROR INFO: java.lang.OutOfMemoryError: PermGen space |
- 元空间内存溢出(JDK 1.8以后)
1 | ERROR INFO: java.lang.OutOfMemoryError: Metaspace |
1 | public class Demo extends ClassLoader { // 类加载器: 可以用来加载类的二进制字节码, 动态加载 |
有可能的溢出场景:实际生产中,动态产生并加载类时容易产生这种内存溢出
Spring
框架中的cglib
字节码技术,AOP
的核心 - 生成动态代理类Mybatis
框架中的cglib
字节码技术
5.4 运行时常量池
编译后的二进制字节码包含: 类基本信息、常量池、类方法定义、虚拟机指令
1 | javap -v <xxx.class> // -v 显示反编译后的详细信息 |
例如:
1 | public class test { |
反编译后的详细信息:
1 | Classfile /test.class // 类基本信息 |
- 常量池就是一张常量查找表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量(如字符串、整型、bool类型等)等信息;
- 运行时常量池,就是当该类被加载时,它的常量池信息会放入运行常量池,地址会替换为真正的内存地址。
5.5 StringTable串池
特征:
-
常量池中的信息,都会被加载到运行时常量池中。这时
"a", "b", "ab"
都是常量池中的符号,还不是 字符串对象 -
常量池中的字符串仅是符号,只有在被第一次引用到时才会转化为对象
ldc
-
StringTable在内存结构上是哈希表,不能扩容
-
利用串池的机制,来避免重复创建字符串对象
-
字符串变量拼接的原理是StringBuilder
-
字符串常量拼接的原理是编译器优化
-
可以使用
intern()
方法,主动将串池中还没有的字符串对象放入串池中- JDK 1.8 中,尝试将串池中还没有的字符串对象放入串池时,如果串池中有该对象则不会放入;若没有,则放入串池,且将串池中的对象返回
- JDK 1.6 中,尝试将串池中还没有的字符串对象放入串池时,如果串池中有该对象则不会放入;若没有,则会先将该对象复制一份,然后放入串池,最后将串池中的对象返回
注意:无论是串池还是堆里面的字符串,都是对象
5.5.1 串池
1 | public class StringTableStudy { |
常量池中的信息:
1 | 0: ldc #2 // String a |
-
当执行到
ldc #2
时,会把符号 a 变为 “a” 字符串对象,并放入串池中(hashtable结构 不可扩容) -
当执行到
ldc #3
时,会把符号 b 变为 “b” 字符串对象,并放入串池中 -
当执行到
ldc #4
时,会把符号 ab 变为 “ab” 字符串对象,并放入串池中 -
最终
StringTable ["a", "b", "ab"]
注意:字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候(如 ldc #2)时,该字符串才会被创建并放入串池中。
5.5.2 串池:拼接变量字符串对象创建字符串
使用拼接字符串变量对象创建字符串的过程:
1 | public class StringTableStudy { |
反编译后的结果
1 | Code: |
通过拼接的方式来创建字符串的过程是:new StringBuilder().append("a").append("b").toString()
,地址应该在堆中
最后的toString方法的返回值是一个新的字符串,但字符串的值和拼接的字符串一致,但是两个不同的字符串,一个存在于串池之中,一个存在于堆内存之中
5.5.3 串池:拼接常量字符串对象的方法创建字符串
使用拼接字符串常量对象的方法创建字符串
1 | public class StringTableStudy { |
反编译后的结果
1 | Code: |
-
当虚拟机执行到第0、3、5行时,会将“a” “b“ ”ab“放入串池。当执行到29行时我们可以看到,虚拟机不会先找“a” 再找“b”然后再将它们拼接起来,而是之间找到拼接后的“ab”。
1
StringTable["a", "b", "ab"]
⚠️ 需要注意的是:
- 使用拼接字符串常量 的方法来创建新的字符串时,因为内容是常量,
javac
在编译期会进行优化,结果已在编译期确定为ab
,而创建ab
的时候已经在串池中放入了"ab"
,所以ab3
直接从串池中获取值,所以进行的操作和ab = "ab"
一致。- 使用拼接字符串变量 的方法来创建新的字符串时,因为内容是变量,只能在运行期确定它的值,所以需要使用StringBuilder来创建
5.5.4 串池:intern()
方法(JDK1.8)
调用字符串对象的 intern()
方法,会将该字符串对象尝试放入到串池中
- 如果串池中没有该字符串对象,则放入成功
- 如果有该字符串对象,则放入失败
- 无论放入是否成功,都会返回串池中的字符串对象
例1
JDK 1.8 环境下
1 | public class StringTableInternMethod1 { |
例2
JDK 1.8 与 JDK 1.6
1 | public class SringTableInternMethod2 { |
例3
JDK 1.6环境下,与例1进行比较。
1 | public class StringTableInternMethod3 { |
5.5.5 串池的位置
在 JDK 1.6 中:
我们可以看到,由于串池逻辑上处于方法区中,而方法区是由永久代实现的,在垃圾回收时需要 FullGC
才能清理永久代,这样就会造成串池迟迟得不到清理,从而导致内存溢出。
1 | JDK1.6 环境下: |
1 | public class StringTableJDK1_6Demo{ |
1 | ERROR INFO: |
为了解决以上问题,在 JDK 1.8 中改进了串池的位置。
在 JDK 1.8 中:
串池位于堆中,在垃圾回收时需要 MinorGC
进行垃圾回收,从而减轻内存占用。
1 | JDK1.6 环境下: |
1 | public class StringTableJDK1_8Demo{ |
1 | ERROR INFO: |
5.5.6 串池的垃圾回收
StringTable
在内存紧张时,会发生垃圾回收。
5.5.7 串池的性能调优
-
因为
StringTable
是用HashTable
实现的,所以我们可以适当增加HashTable
的桶的个数,来减少字符串放入串池所需要的时间1
-XX:StringTableSize=xxxx
-
考虑是否需要将字符串对象入池,可以通过
intern()
方法减少重复入池
6. 直接内存
6.1 定义
直接内存不属于 JVM
内存结构,而是操作系统的内存。
- 属于操作系统,常见于 NIO 操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受
JVM
内存回收管理
6.2 基本使用
使用了 DirectBuffer
后,
【直接内存】是操作系统和 Java
代码都可以访问的一块区域,无需将代码从系统内存复制到 Java
堆内存,从而提高了效率
6.3 分配和回收原理
- 使用了
Unsafe
类来完成直接内存的分配回收,而且回收需要主动调用**unsafe.freeMemory()**方法 ByteBuffer
的实现内部使用了Cleaner
(虚引用)来检测ByteBuffer
。一旦ByteBuffer
被垃圾回收,那么会由ReferenceHandler
来调用 Cleaner 的clean()
方法调用freeMemory
来释放内存
直接内存的回收不是通过JVM的垃圾回收来释放的,而是通过**unsafe.freeMemory()**来手动释放
1 | //通过ByteBuffer申请1M的直接内存 |
申请直接内存,但JVM并不能回收直接内存中的内容,它是如何实现回收的呢?
allocateDirect() 的实现底层源码分析
1 | public static ByteBuffer allocateDirect(int capacity) { |
DirectByteBuffer
类
1 | DirectByteBuffer(int cap) { // package-private |
这里调用了一个Cleaner的 create()
方法,且后台线程还会对虚引用的对象监测,如果虚引用的实际对象(这里是 DirectByteBuffer
)被回收以后,就会调用Cleaner的 clean()
方法,来清除直接内存中占用的内存
1 | public void clean() { |
对应对象的 run()
方法
1 | public void run() { |
三、JVM 垃圾回收
0 主要内容大纲
【概述】
之前我们讲解了 JVM 的内存结构,其中我们了解到堆存在着垃圾回收机制。这一章我们将重点介绍这一部分内容。
1 如何判断对象可以被回收
1.1 引用计数法
- 只要一个对象被其他变量所引用,那我们就让这个对象的计数 ,如果被引用两次,该计数就为2。
- 如果某个变量不再引用这个对象,该对象的引用计数 。
- 当计数为 0 时,表示没有变量引用这个对象了,则可作为垃圾回收掉。
弊端:在例如上图的循环引用时,两个对象的计数都为 1,导致两个对象都无法被释放
1.2 可达性分析算法
首先先要确定【根对象】。那么什么是根对象呢?就是那些肯定不能被当成垃圾回收的对象。
在垃圾回收之前,我们先扫描堆内存中的所有对象,检查对象是否被根对象直接或者间接的引用。若是,则不能被回收;反之则可以被回收。
- JVM中的垃圾回收器通过可达性分析来探索所有存活的对象
- 扫描堆中的对象,看能否沿着
GC Root
对象(根对象)为起点的引用链找到该对象,如果找不到,则表示可以回收
可以作为
GC Root
的对象:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(即一般说的 Native方法)引用的对象
1.3 五种引用
【总结】引用应用垃圾回收 GC 的时机:
- 强引用:只有 GC Root 都不引用该对象时,才会回收强引用对象
- 软引用:
- 仅有软引用引用该对象时,在垃圾回收之后,内存仍不足时会再次触发垃圾回收,回收软引用对象
- 可以配合引用队列来释放软引用自身
- 弱引用:
- 只有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用所引用的对象。
- 可以配合引用队列来释放弱引用自身
- 虚引用:
- 必须配合引用队列使用,主要配合
ByteBuffer
使用。 - 被引用对象回收时,会讲虚引用入队列,由
Reference Handler
线程调用虚引用的相关方法释放内存。
- 必须配合引用队列使用,主要配合
- 终结器引用:
- 无需手动编码,但其内部配合引用队列使用。
- 在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由
Finalizer
线程通过终结器引用找到被引用对象并调用它的finalize()
方法,第二次 GC 时才能回收被引用对象
1.3.1 强引用
如上图,实线箭头表示强引用。日常使用中的引用都属于强引用。例如,new
一个对象,使用 "="
将该对象赋值给一个变量,那么这个变量就强引用该对象。
垃圾回收的条件:
只有 GC Root 都不引用该对象时,才会回收强引用对象
- 如上图 B、C 对象都不引用 A1 对象时,A1 对象才会被回收
案例
1 | public class DemoStrongReference { |
1 | java.lang.OutOfMemoryError: Java heap space |
1.3.2 软引用 (Soft Reference)
使用场景:当内存空间有限时,一些不重要的资源可以用软引用。
只要 A2、A3 两个对象没有被直接的强引用所引用,当垃圾回收发生时,都有可以被回收。
垃圾回收的条件:
当 GC Root 指向软引用对象(垃圾回收)时,在内存不足时,会回收软引用所引用的对象。(先回收一次,如果内存还不够,回收软引用所引用的对象)
- 如上图如果 B 对象不再引用 A2 对象且内存不足时,软引用所引用的 A2 对象就会被回收
案例 1
1 | /** |
1 | [B@75b84c92 |
如果在垃圾回收时发现内存不足,在回收软引用所指向的对象时,软引用本身不会被清理
如果想要清理软引用,需要使用引用队列
1 | public class DemoSoftReference2 { |
1 | [B@75b84c92 |
**大概思路为:**查看引用队列中有无软引用,如果有,则将该软引用从存放它的集合中移除(这里为一个list集合)
1.3.3 弱引用 (Weak Referrnce)
垃圾回收的条件:
当只有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用所引用的对象。
- 如上图如果B对象不再引用A3对象,则A3对象会被回收
弱引用的使用和软引用类似,只是将 SoftReference 换为了 WeakReference
1 | public class DemoWeakReference1 { |
1 | [B@75b84c92 |
1.3.4 虚引用 (Phantom Reference)
必须配合引用队列一同使用。当虚(终结器)引用被创建时,会关联一个引用队列。
- 当虚引用对象所引用的对象被回收以后,虚引用对象就会被放入引用队列中,调用虚引用的方法
- 虚引用的一个体现是释放直接内存所分配的内存,当引用的对象
ByteBuffer
被垃圾回收以后,虚引用对象Cleaner
就会被放入引用队列中,然后调用Cleaner
的clean()
方法来释放直接内存
- 如上图,B 对象不再引用
ByteBuffer
对象,ByteBuffer
就会被回收。但是直接内存中的内存还未被回收。这时需要将虚引用对象Cleaner
放入引用队列中,然后调用它的clean()
方法来释放直接内存
1.3.5 终结器引用 (Finalize Reference)
所有的对象都继承自 Object
类,Object
类有一个 finalize()
方法。
当某个对象不再被其他的对象所引用时,会先将终结器引用对象放入引用队列中(处理这个引用队列的FinalizeHandler 线程 优先级很低),然后根据终结器引用对象找到它所引用的对象,然后调用该对象的
finalize()
方法。调用以后,该对象就可以被垃圾回收了。
- 如上图,B对象不再引用A4对象。这是终结器对象就会被放入引用队列中,引用队列会根据它,找到它所引用的对象。然后调用被引用对象的finalize()方法。调用以后,该对象就可以被垃圾回收了
2 垃圾回收算法
2.1 标记 - 清除 算法 (Mark - Sweep)
定义:标记清除算法顾名思义,是指在虚拟机执行垃圾回收的过程中,先采用标记算法确定可回收对象(图中为没有GC Root引用的块),然后垃圾收集器根据标识清除相应的内容,给堆内存腾出相应的空间。
- 这里的腾出内存空间并不是将内存空间的字节清零,而是记录下这段内存的起始结束地址,下次分配内存的时候,会直接覆盖这段内存。同理于操作系统中的内存管理
优点:垃圾回收速度快
缺点:容易产生大量的内存碎片,可能无法满足大对象的内存分配,一旦导致无法分配对象,那就会导致
JVM
启动 GC,一旦启动 GC,我们的应用程序就会暂停,这就导致应用的响应速度变慢。同理于操作系统中的内存碎片。
2.2 标记 - 整理 算法 (Mark - Compact)
标记-整理 会将不被 GC Root 引用的对象回收,清理其占用的内存空间。然后整理剩余的对象(将其地址向前移动,使内存更为紧凑,连续空间更多).
优点:可以有效避免因内存碎片而导致的问题
缺点:但是因为整体需要消耗一定的时间,所以效率较低
2.3 复制 算法 (Copy)
将内存分为等大小的两个区域,FROM
和 TO
(其中 TO
中是空闲的)。
先将被 GC Root 引用的对象从 FROM
复制到 TO
中,再回收不被 GC Root 引用的对象。然后交换 FROM
和TO
。
- 如下图,先采用标记算法确定可回收对象(图中为没有 GC Root 引用的块)
- 将
FROM
区域中存活的对象复制到TO
区域
- 此时由于
FROM
区域中全是垃圾,全部清空
- 交换
FROM
区域 和TO
区域 的位置
优点:可以避免内存碎片的问题
缺点:但是会占用双倍的内存空间。
2.4 总结
- 标记 - 清除 算法 (Mark - Sweep)
- 优点:垃圾回收速度快
- 缺点:容易产生大量的内存碎片
- 标记 - 整理 算法 (Mark - Compact)
- 优点:可以有效避免因内存碎片而导致的问题
- 缺点:但是因为整体需要消耗一定的时间,所以效率较低
- 复制 算法 (Copy)
- 优点:可以避免内存碎片的问题
- 缺点:但是会占用双倍的内存空间。
3 分代垃圾回收机制
如上图,我们将堆内存划分成两个部分,一个是左边的 YoungGeneration 新生代 (新生代又分为【伊甸园 Eden】、【幸存区 FROM】和【幸存区 TO】三个部分),另一个是老年代 OldGeneration。
Java 中,长时间使用的对象放在老年代中,用完就可以丢弃的对象放在新生代中。这样就可以根据对象的存活时间的不同特点进行不用的回收策略。老年代中的垃圾回收很久发生一次,而新生代中回收更频繁。
3.1 分代回收流程
简要流程:
- 对象首先分配在伊甸园区域;
- 新生代空间不足时,触发 Minor GC,伊甸园和 FROM 存活的对象使用 copy 复制到 TO 中,存活的对象年龄加 1,并且交换 FROM 和 TO;
- 当幸存区中的对象的寿命超过阈值(最大为15,4bit),就会晋升到老年代中;
- 如果新生代中的内存空间不足时,先触发 Minor GC;垃圾回收后发现新生代中的内存空间仍然不足,且老年代中的内存空间也不足,再触发 Full GC (整体清理);
- 内存分配失败,触发
java.lang.OutOfMemoryError
。
【流程图解】
1、新创建的对象都被放在了新生代的伊甸园中,伊甸园逐渐就会被占满。
2、当伊甸园中的内存不足时,就会进行一次垃圾回收,这时新生代的垃圾回收叫做 Minor GC
(1) Minor GC 触发后,采用“可达性分析算法”,沿着以 GC Root 对象(根对象)为起点的引用链,采用“标记算法”确定可回收对象;
(2) 标记完成后,采用“复制算法”将**伊甸园和幸存区 FROM** 存活的对象先复制到**幸存区 TO** 中, 并让其寿命;
(3) 根据复制算法,我们将交换幸存区 FROM 和幸存区 TO 的位置
3、再次创建对象,若新生代的伊甸园又满了,则会再次触发 Minor GC(会触发 “Stop the world”, 暂停其他用户线程,只让垃圾回收线程工作);
这时不仅会回收伊甸园中的垃圾,还会回收幸存区中的垃圾,再将活跃对象复制到幸存区 TO 中;
回收以后会交换两个幸存区,并让幸存区中的对象寿命加1
4、如此反复。如果幸存区中的对象的寿命超过某个阈值(最大为15,4bit),就会晋升到老年代中
5、如果新生代中的内存空间不足时,先触发 Minor GC;垃圾回收后发现新生代中的内存空间仍然不足,且老年代中的内存空间也不足,再触发 Full GC (整体清理),也会触发“Stop the world”,时间更长,以扫描新生代和老年代中所有不再使用的对象并回收
6、如果老年代的内存也不够,内存分配失败,触发 java.lang.OutOfMemoryError
。
3.2 相关虚拟机参数
含义 | 参数 |
---|---|
堆初始大小 | -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 |
3.3 GC分析
1 | -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc |
1 | Heap |
垃圾回收信息:
1 | [GC (Allocation Failure) [DefNew: 1333K->331K(9216K), 0.0003968 secs] 1333K->331K(23552K), 0.0004083 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] |
GC
表示是新生代的MinorGC
;FullGC
表示是老年代的垃圾回收DefNew
表示垃圾回收发生在新生代,xxx -> xxx
表示回收之前的占用和回收之后的占用
案例
1 | public class Demo { |
1 | [GC (Allocation Failure) [DefNew: 1508K->366K(9216K), 0.0006691 secs] 1508K->366K(23552K), 0.0006841 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] |
3.3.1 大对象处理策略
当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代
1 | public class Demo { |
1 | Heap |
3.3.2 线程内存溢出
1 | public class Demo { |
1 | sleep... |
某个线程的内存溢出了而抛异常 (java.lang.OutOfMemoryError
),不会让其他的线程结束运行,原因如下:
- 当一个线程抛出
OutOfMemoryError
异常后,它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行,其他进程依然正常
4 垃圾回收器
4.0 概述
- 串行垃圾回收器
- 实质是一个单线程的垃圾回收器
- 使用场景:堆内存较小,适合个人电脑
- 吞吐量优先垃圾回收器
- 多线程
- 使用场景:堆内存较大,多核 CPU 支持
- 在单位时间内,STW 的时间最短。例如,在一小时内,垃圾回收了 2 次,总时长是 0.2 + 0.1 秒
- 响应时间优先垃圾回收器
- 多线程
- 使用场景:堆内存较大,多核 CPU 支持
- 垃圾清理(STW)的单次时间尽可能最短
4.1 串行垃圾回收器
1 | -XX:+UseSerialGC=Serial+SerialOld // 新生代copy算法,老年代 标记整理 |
4.2 吞吐量优先垃圾回收器
1 | -XX:+UseParallelGC -XX:+UseParallelOldGC |
4.3 响应时间优先垃圾回收器
1 | -XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld |
4.4 G1 (Garbage First) 垃圾回收器
JDK 1.9 之后代替 CMS
回收器,成为默认的垃圾回收器。
【适用场景】
1 | -XX:+UseG1GC |
-
同时注重吞吐量 (Throughput) 和低延迟 (Low latency),默认的暂停目标是 200 ms
1
-XX:MaxGCPauseMillis=time
-
超大堆内存,会将堆划分为多个大小相等的
Region
1
-XX:G1HeapRegionSize=size // size = 2^n
-
整体上是标记整理算法,两个区域之间是复制
4.4.1 G1 垃圾回收阶段
4.4.2 Young Collection
首先,G1 垃圾回收器会将堆内存 (Heap) 分成若干个大小相等的区域 (Region),也就是说,Region 是 G1 操作时的单位。
当一个为伊甸园 E 的 Region 被占满时,使用拷贝算法将非垃圾对象放入幸存区 S,如下图
继续运行一段时间,当:
- 幸存区 S 中的对象年龄到达可以晋升老年代 O 的阈值时;
- 或幸存区 S 的空间不足时
4.4.3 Young Collection + Concurrent Mark
-
在
Young GC
时会进行GC Root
的初始标记 -
老年代占用堆空间比例达到阈值时,进行并发标记 (不会 STW),由下面的
JVM
参数决定1
-XX:InitiatingHeapOccupancyPercent=percent (默认45%)
4.4.4 Mixed Collection
会对伊甸园 E、幸存区 S、老年代 O 进行全面垃圾回收:
-
最终标记 (重新标记 Remark) 会 STW
-
拷贝存活 (Evacuation) 会 STW
1
-XX: MaxGCPauseMillis=ms
如上图所示,对于伊甸园 E 和幸存区 S 的垃圾回收与之前一样:
- 伊甸园 E 被占满时,使用拷贝算法将非垃圾对象放入幸存区 S;
- 幸存区 S 中的对象年龄到达可以晋升老年代 O 的阈值时,将其放入老年代 O ;
- 幸存区 S 的空间不足时,复制存活的对象到另一个幸存区 S 中。
对于老年代 O,我是使用一系列的算法,在最大暂停时间 MaxGCPauseMillis
内,来筛选“有价值”的存活对象,复制到另一个老年代 O 中。
那么,什么是“有价值”的存活对象呢?
个人理解是在空间不足的老年代 O 中,挑选出那些可以在最大暂停时间 MaxGCPauseMillis
内完成复制的存活对象后,并且该老年代 O 可以最大程度上的被回收,从而尽可能多的释放空间。我们将那些存活对象复制到另一个老年代 O 中。从 Region 的角度考虑,就是优先回收垃圾最多的 Region。
4.4.5 Full GC
- 串行垃圾回收器
- 新生代内存不足时发生的垃圾收集:Minor GC
- 老年代内存不足时发生的垃圾收集:Full GC
- 并行垃圾回收器
- 新生代内存不足时发生的垃圾收集:Minor GC
- 老年代内存不足时发生的垃圾收集:Full GC
- 并发垃圾回收器 CMS
- 新生代内存不足时发生的垃圾收集:Minor GC
- 老年代内存不足时发生的垃圾收集:触发并发垃圾回收
- G1 垃圾回收器
- 新生代内存不足时发生的垃圾收集:Minor GC
- 老年代内存不足时发生的垃圾收集:老年代占用堆空间比例达到阈值时,进行并发标记 (不会 STW),并进入到 Mixed Collection 阶段。当产生垃圾的速度大于垃圾收集的速度,触发 Full GC。
4.4.6 Young Collection 的跨代引用问题
持续更新中。。。
四、类加载与字节码技术
0 主要内容大纲
提示:本章节可与 [" Sémantique et Traduction des Langages "](https://github.com/Dave0126/S8_2A_SN_ENSEEIHT/tree/master/UE - Sémantique et Traduction des Langages/Sémantique et Traduction des Langages) 结合理解
1 类文件结构
我们以 HelloWorld.java
文件为例
1 | package ClassFile; |
1 | javac -parameters -d src/main/java/ClassFile/DemoHelloWorld.java |
编译后的 DemoHelloWorld.class
是这样的:
1 | od -t xC ClassFile/DemoHelloWorld.class |
1 | // 标号 内容 |
根据 JVM 规范,类文件的结构如下
1 | ClassFile { |
示意图如下:
1.1 魔数 Magic
前四个字节 (0~3)
,表示该文件是否是.class
类型的文件
0000000 ca fe ba be 00 00 00 3b 00 1f 0a 00 02 00 03 07
1.2 版本 Version
4~7 个字节
,表示类的版本 (minor_version + major_version
)。00 3b
表示 JDK 15。
0000000 ca fe ba be 00 00 00 3b 00 1f 0a 00 02 00 03 07
1.3 常量池 Constant Pool
Constant Type (Bytes) | Value (hex) |
---|---|
CONSTANT_Class(1B): 引用索引(2B) |
07 |
CONSTANT_Filedref(1B): CONSTANT_Class(2B) + CONSTANT_NameAndType(2B) |
09 |
CONSTANT_Methodref(1B): CONSTANT_Class(2B) + CONSTANT_NameAndType(2B) |
0a |
CONSTANT_InterfaceMethodref(1B): CONSTANT_Class(2B) + CONSTANT_NameAndType(2B) |
0b |
CONSTANT_String(1B): CONSTANT_Utf8(2B) |
08 |
CONSTANT_Integer(1B): value(4B) |
03 |
CONSTANT_Float(1B): value(4B) |
04 |
CONSTANT_Long(1B): value(8B) |
05 |
CONSTANT_Double(1B): value(8B) |
06 |
CONSTANT_NameAndType(1B): Name_引用索引(2B) + Type_引用索引(2B) |
0c |
CONSTANT_Utf8(1B): 长度(2B) + 数据(nB) |
01 |
CONSTANT_MethodHandle(1B) |
0f |
CONSTANT_MethodType(1B) |
10 |
CONSTANT_InvokeDynamic(1B) |
12 |
8~9 字节
,表示常量池的长度。00 1f
表示常量池的长度是 31,有 #1~#30 项。注意 #0 项不计入,也没有值
0000000 ca fe ba be 00 00 00 3b 00 1f 0a 00 02 00 03 07
-
(第 #1 项)
10~14 字节
中,10 字节
表示常量的类型(见上表),0a
表示一个 Methodref 信息;11~12 字节
和13~14 字节
,00 02
和00 03
表示它引用了常量池中 #2 和 #3 项来获得这个方法的【所属类】和【方法名】
0000000| ca fe ba be 00 00 00 3b 00 1f 0a 00 02 00 03 07
-
(第 #2 项)
15~17 字节
中,15 字节
表示常量的类型(见上表),07
表示一个 Class 信息;16~17 字节
,00 04
表示它 表示它引用了常量池中 #4 项。
0000000| ca fe ba be 00 00 00 3b 00 1f 0a 00 02 00 03 07
0000020| 00 04 0c 00 05 00 06 01 00 10 6a 61 76 61 2f 6c
-
(第 #3 项)
18~22 字节
中,18 字节
表示常量的类型(见上表),0c
表示一个 “名称 + 类型” 信息;19~20 字节
和21~22 字节
,00 05
和00 06
表示它 表示它引用了常量池中 #5 项和第 #6 项。
0000020| 00 04 0c 00 05 00 06 01 00 10 6a 61 76 61 2f 6c
- (第 #4 项)
23~41 字节
中,23 字节:01
表示一个 utf8 串,后两位00 10
表示这个串的长度是 16。所以后面紧跟的 16 个字节就是这个串的内容:6a 61 76 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63 74
,转义后是:java/lang/Object
0000020| 00 04 0c 00 05 00 06 01 00 10 6a 61 76 61 2f 6c
0000040| 61 6e 67 2f 4f 62 6a 65 63 74 01 00 06 3c 69 6e
- (第 #5 项)
42~50 字节
中,42 字节:01
表示一个 utf8 串,后两位00 06
表示这个串的长度是 6。所以后面紧跟的 6 个字节就是这个串的内容:3c 69 6e 69 74 3e
,转义后是:<init>
0000040| 61 6e 67 2f 4f 62 6a 65 63 74 01 00 06 3c 69 6e
0000060| 69 74 3e 01 00 03 28 29 56 09 00 08 00 09 07 00
- (第 #6 项)
51~56 字节
中,51 字节:01
表示一个 utf8 串,后两位00 03
表示这个串的长度是 3。所以后面紧跟的 3 个字节就是这个串的内容:28 29 56
,转义后是:()V
,表示无参、无返回值
0000060| 69 74 3e 01 00 03 28 29 56 09 00 08 00 09 07 00
- (第 #7 项)
57~61 字节
中,57 字节:09
表示一个 Filedref 信息,后四位00 08
和00 09
表示它引用了常量池中 #8 和 #9 项来获得这个成员变量的【所属类】和【成员变量名】
0000060| 69 74 3e 01 00 03 28 29 56 09 00 08 00 09 07 00
以此类推……
1.4 访问标识与继承信息
常量池 Constant Pool 结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是接口还是类;是否被定义为 public
类型;是否定义为 abstract
类型;如果是类的话,是否被声明为 final
等。
Flag Name | Value (hex) | Description |
---|---|---|
ACC_PUBLIC |
00 01 |
是否为 public 类型 |
ACC_FINAL |
00 10 |
是否被声明为 final ,只有类可以设置 |
ACC_SUPER |
00 20 |
是否允许使用 invokespecial 字节码指令的新语义 |
ACC_INTERFACE |
02 00 |
标志这是一个接口 |
ACC_ABSTRACT |
04 00 |
是否为 abstract 类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 |
ACC_SYNTHETIC |
10 00 |
标志这个类并非由用户代码产生 |
ACC_ANNOTATION |
20 00 |
标志这是一个注解 |
ACC_ENUM |
40 00 |
标志这是一个枚举 |
00 21
可以推断是由ACC_PUBLIC
和ACC_SUPER
通过OR
组合而成。
0000540| 76 61 00 21 00 15 00 02 00 00 00 00 00 02 00 01
00 15
表示根据常量池中的第 #15 项找到本类的全限定名
0000540| 76 61 00 21 00 15 00 02 00 00 00 00 00 02 00 01
00 02
表示根据常量池中的第 #2 项找到父类的全限定名
0000540| 76 61 00 21 00 15 00 02 00 00 00 00 00 02 00 01
00 00
表示本类中接口的数量,为 0
0000540| 76 61 00 21 00 15 00 02 00 00 00 00 00 02 00 01
1.5 成员变量 Field
字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。
field info
的结构:
1 | field_info { |
access_flags
: 字段的作用域(public, private, protected
修饰符),是实例变量还是类变量(static
修饰符),可否被序列化(transient
修饰符),可变性(final
),可见性(volatile
修饰符,是否强制从主内存读写)。name_index
: 对常量池的引用,表示的字段的名称;descriptor_index
: 对常量池的引用,表示字段和方法的描述符;attributes_count
: 一个字段还会拥有一些额外的属性,attributes_count
存放属性的个数;attributes[attributes_count]
: 存放具体属性具体内容。
紧接接口信息后的两个字节为 00 00
表示成员变量的数量,为 0。
0000540| 76 61 00 21 00 15 00 02. 00 00 00 00 00 02 00 01
在 .class
文件中,成员变量的类型由更简洁的字符表示,其与源代码 .java
中的类型对应表如下:
Field Type(.class ) |
Field Type(.java ) |
Description |
---|---|---|
B |
byte |
byte 类型 |
C |
char |
char 类型 |
D |
double |
double 类型 |
F |
float |
float 类型 |
I |
int |
int 类型 |
J |
long |
long 类型 |
L ClassName |
reference |
类 ClassName 的引用类型 |
S |
short |
short 类型 |
Z |
boolean |
boolean 类型 |
[ |
reference |
一维数组类型 |
1.6 方法 Method
1 | u2 methods_count; |
methods_count
表示方法的数量,而 method_info
表示的方法表。
.class
文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。
method_info
(方法表的) 结构:
1 | method_info { |
方法表的 access_flags
取值:
access_flags |
Value (hex) | Description |
---|---|---|
ACC_PUBLIC |
00 01 |
是否为 public 类型 |
ACC_PRIVATE |
00 02 |
是否为 private 类型 |
ACC_PROTECTED |
00 04 |
是否为 protected 类型 |
ACC_STATIC |
00 08 |
是否为 static 类型 |
ACC_FINAL |
00 10 |
是否被声明为 final ,只有类可以设置 |
ACC_VOLATILE |
00 40 |
是否为 volatile 类型,不可和 ACC_FIANL 一起使用 |
ACC_TRANSIENT |
00 80 |
在序列化中被忽略的字段 |
ACC_SYNTHETIC |
10 00 |
标志这个类并非由用户代码产生 |
ACC_ENUM |
40 00 |
标志这是一个枚举 |
1.7 属性 Attribute
1 | u2 attributes_count;//此类的属性表中的属性数 |
在 .class
文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 .class
文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。
2 字节码指令
2.1 入门
接着上一节,研究一下两组字节码指令,一个是
public ClassFile.DemoHelloWorld();
构造方法的字节码指令
1 | 2a b7 00 01 b1 |
2a
aload_0
加载slot 0
的局部变量,即this
,做为下面的imvokespecial
构造方法调用的参数b7
invokespecial
预备调用构造方法,哪个方法呢?0001
引用常量池中第 #1 项,即Method java/lang/Object."<init>":()V
b1
表示return
另一个是主方法的字节码指令 public static void main(java.lang.String[]);
1 | b2 00 02 12 03 b6 00 04 b1 |
b2
getstatic
用来加载静态变量,哪个静态变量呢?00 02
引用常量池中 #2 项,即Field javallang System.out:Ljava/io/Printstream;
12
ldc
加载参数,哪个参数呢?03
引用常量池中 #3 项,即String hello world!
b6
invokevirtual
预备调用成员方法,哪个方法呢?00 04
引用常量池中 #4 项,即Method java/io/Printstream.println:(Ljava/lang/String;)V
b1
表示return
2.2 javap
工具
由以上步骤我们可以深刻体会到,手动分析 .class
文件太繁琐了。我们可以使用 Oracle 提供的 javap
工具来反编译 .class
文件。
1 | javap -v file_name.class |
javap
反编译后的 DemoHelloWorld.class
输出信息为:
1 | Classfile /Users/JavaVM/target/classes/ClassFile/DemoHelloWorld.class |
2.3 图解方法执行流程
2.3.1 原始 Java 代码
1 | package ClassFile; |
2.3.2 编译后的字节码文件
1 | Classfile /Users/JavaVM/target/classes/ClassFile/Demo2.class |
2.3.3 常量池载入 “运行时常量池”
2.3.4 方法字节码载入方法区
2.3.5 主线程开始运行,分配栈帧内存
1 | stack=2, locals=4 // 操作的栈的最大深度=2、本地变量的个数=4 |
bipush 10
将一个 byte 压入操作数栈(其长度会补齐4个字节),类似的指令还有
sipush
将一个short
压入操作数栈(其长度会补齐4个字节)ldc
将一个int
压入操作数栈lde2_w
将一个long
压入操作数栈(分两次压入,因为long
是 8 个字节)- 这里小的数字(4 个字节)都是和字节码指令存在一起,超过
short
范围的数字存入了常量池
istore 1
将操作数栈栈顶的元素弹出,存入本地变量表的 slot 1
ldc #3
- 从常量池加载 #3 数据到操作数栈
- 注意:
Short.MAX_ VAIUE
是32767
,所以32768 = Short. MAX_ VAIUE + 1
实际是在编译期间计算
istore 2
iload 1
iload 2
iadd
istore 3
getstatic #4
iload 3
invokevirtual #5
- 找到常量池 #5 项
- 定位到方法区
java/io/PrintStream.println:(I)V
方法 - 生成新的栈帧(分配
locals
、stack
等) - 传递参数,执行新栈帧中的字节码
- 执行操作,弹出栈帧
- 清除
main
栈帧中操作数栈内容
return
- 完成
main
方法调用,弹出main
栈帧 - 程序结束
2.4 从字节码角度分析 i++
与 ++i
i++
先执行iload
,再执行iinc
++i
先执行iinc
,再执行iload
iinc 1 -1
自增运算操作再本地变量表中进行操作,而非操作数栈。意为在 slot 1 的本地变量自增-1
2.5 条件判断指令
指令 (hex) | 助记符 | 含义 |
---|---|---|
99 |
ifeq |
判断是否 |
9a |
ifne |
判断是否 |
9b |
iflt |
判断是否 |
9c |
ifge |
判断是否 $ \ge 0$ |
9d |
ifgt |
判断是否 |
9e |
ifle |
判断是否 |
9f |
if_icmpeq |
判断两个 int 是否 |
a0 |
if_icmpne |
判断两个 int 是否 |
a1 |
if_icmplt |
判断两个 int 是否 |
a2 |
if_icmpge |
判断两个 int 是否 |
a3 |
if_icmpgt |
判断两个 int 是否 |
a4 |
if_icmple |
判断两个 int 是否 |
a5 |
if_acmpeq |
判断两个 引用 是否 |
a6 |
if_acmpne |
判断两个 引用 是否 |
c6 |
ifnull |
判断是否为 null |
c7 |
ifnonnull |
判断是否为 非null |
byte, short, char
都会按照int
来比较,因为操作数栈都是 4 个字节- 条件判断指令后,使用
goto
语句来跳转到指定位置
2.6 构造方法
2.6.1 <clinit>()V
<clinit>()V
是类构造器方法,也就是在 JVM 进行"类加载 - 链接 - 初始化"中的初始化阶段,JVM 会调用 <clinit>()V
方法。<clinit>()V
是类构造器对静态变量,静态代码块进行初始化。
从字节码角度分析如下代码:
1 | public class Demo { |
编译器会按照从上至下的顺序,收集所有的 static
静态代码块的静态成员赋值的代码,合并成一个特殊的方法 <clinit>()V
1 | 0: bipush 10 |
2.6.2 <init>()V
<init>()V
是对象构造器方法,也就是说在程序执行 new
一个对象,调用该对象类的构造器方法时才会执行 <init>()V
方法。<init>()V
是实例构造器,对非静态变量解析初始化。
1 | package ClassFile; |
编译器会按照从上至下的顺序,收集所有的 {}
初始化代码块的静态成员赋值的代码,合并成一个特殊的方法 <init>()V
,但原始构造方法内的代码总是在最后。
1 | 0: aload_0 |
2.7 方法的调用
1 | package ClassFile; |
字节码:
1 | 0: new #2 // class ClassFile/DemoCallMethod |
在以上例子中,出现了三种不同的方法调用:
invokespecial
:构造方法、私有方法private
、final
修饰的方法invokevirtual
:公共方法public
invokestatic
:静态方法static
在 JVM 中,invokespecial
和 invokestatic
属于静态方法绑定,在字节码文件生成时就已知该方法属于哪个类;但 public
方法有可能出现方法重写的情况,编译期间无法确定该方法属于哪个类(子类或父类),所以使用 invokevirtual
动态方法绑定,需要在运行时确定。
细节说明:
new
关键字调用构造方法时,先在堆空间分配对象所需的内存,分配成功后,再将对象引用放入操作数栈。dup
复制栈顶元素
2.8 多态的原理
在上一小节中,我们提到 invokevirtual
动态方法绑定,在此节中,我们将了解 Java 中多态的原理。
视频讲解:BiliBili - 黑马程序员 JVM - P119 多态原理
当执行 invokevirtual
指令时,
- 先通过栈帧中的对象引用找到对象
- 分析对象头,找到对象的实际
Class
Class
结构中有vtable
,它在类加载的链接阶段就已经根据方法的重写规则生成好了- 查表得到方法的具体地址
- 执行方法的字节码
2.9 处理异常
2.9.1 单 try-catch
代码块
1 | package ClassFile; |
字节码(重要的部分):
1 | public static void main(java.lang.String[]); |
- 可以看到,字节码文件中多出来一个 ”Exception table“ 的结构,
[from,to)
是前闭后开的检测范围,一旦这个范围内的字节码执行出现异常,则通过type
匹配异常类型,如果一致,进入target
所指示行号; - 8 行的字节码指令
astore 2
是将异常对象引用存入局部变量表的slot 2
位置
2.9.2 单 try
多 catch
代码块 (一)
1 | package ClassFile; |
字节码(重要的部分):
1 | public static void main(java.lang.String[]); |
- 因为异常出现时,只能进入
Exception table
中一个分支,所以局部变量表slot 2
位置被共用
2.9.3 单 try
多 catch
代码块 (二)
1 | package ClassFile; |
字节码(重要的部分):
1 | public static void main(java.lang.String[]); |
- 本质上与之前是一样的。
2.9.4 finally
代码块
1 | package ClassFile; |
字节码(重要的部分):
1 | public static void main(java.lang.String[]); |
- 可以看到
finally
中的代码被复制了 3 份,分别放入try
流程,catch
流程以及catch
剩余的异常类型流程
2.9.5 finally
块中返回值的问题
我们考虑如下代码,分析其最后的返回值:
1 | public class DemoException { |
返回结果为 20
。其字节码如下:
1 | public static int test(); |
我们再考虑如下代码:
1 | public class DemoException { |
返回结果为 10
。其字节码如下:
1 | public static int test(); |
我们通过分析以上代码,画出以下流程图:
sequenceDiagram participant e1 as 例1 participant e2 as 例2 Note over e1,e2 :将 10 入栈,栈[10] Note over e1,e2 :将栈顶元素 10 存入 i,栈[ ],i=10 e1 -> e2 :try 代码块 Note over e1,e2 :将 i=10 入栈,栈[10] Note over e1,e2 :⚠️准备 return,但存在 finally 代码块,不能 return。将要返回的元素暂存 Note over e1,e2 :将栈顶元素 10 存入本地变量表中,栈[ ] e1 -> e2 :finally 代码块 Note over e1,e2 :将 20 入栈,栈[20] Note over e1,e2 :将栈顶元素 20 存入 i,栈[ ],i=20 Note over e1 :将 i=20 入栈,栈[20] Note over e1 :ireturn 栈顶元素 20 Note over e1 :程序结束 ❌ e1 -> e2 :finally 代码块结束,回到 try 中的 return Note over e2 :恢复 return,将之前暂存的 10 入栈,栈[10, 20] Note over e2 :ireturn 栈顶元素 10 Note over e2 :程序结束 ❌
注意⚠️:
- 由上我们可知,在
try - catch - finally
代码块中,try
中的return
会因为finally
代码块而被“打断”,从而需要暂存,以保护返回的值。
2.10 synchronized
代码块分析
1 | public class DemoSynchronized { |
其字节码如下:
1 | public static void main(java.lang.String[]); |
注意 ⚠️
- 方法级别的
synchronized
不会体现在字节码指令中
3 编译期处理
所谓的语法糖,其实就是指 java编译器把 .java 源码
编译为 .class 字节码
的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担,算是 java 编译器给我们的一个额外福利。
注意⚠️
以下代码的分析,借助了 javap 工具,idea 的反编译功能,idea 插件
jclasslib
等工具。另外,编译器转换的结果直接就是class 字节码
,只是为了便于阅读,给出了几乎等价 的 java 源码方式,并不是编译器还会转换出中间的 java 源码,切记。
3.1 默认构造器
1 | public class Constructor { |
编译成 .class
后的等效代码为
1 | public class Constructor { |
3.2 自动拆装箱
这个特性是 JDK5 开始加入的,如下代码举例:
1 | public class BoxingUnboxing { |
这段代码在 JDK5 以前的版本是不能通过编译的,基本类型和包装类型还不能自动转化。必须改写成如下代码:
1 | public class BoxingUnboxing { |
显然代码太麻烦了,需要在基本类型和包装类型之间来回转换(尤其是集合类中操作的都是包装类型),因此这些转换的事情在 JDK 5以后都由编译器在编译阶段完成。即代码片段 1 都会在编译阶段被转换为代码片段 2。
3.3 范型集合取值
泛型 也是在 JDK5 开始加入的特性,但 java 在编译泛型代码后会执行泛型擦除的动作,即泛型信息在编译为字节码之后就丢失了,实际的类型都当做了 object 类型来处理:
1 | public class DemoGeneric { |
所以在取值时,编译器真正生成的字节码中,还要额外做一个类型转换的操作:
1 | // 需要将 object 转为 Integer |
如果前面的 x
变量类型修改为 int
基本类型那么最终生成的字节码是:
1 | //需要将 object 转为 Integer,并执行拆箱操作 |
在 JDK5 以后,以上操作在编译期间自动完成了。
1 | public static void main(java.lang.String[]); |
擦除的是字节码上的泛型信息,可以看到 LocalVariableTypeTable
仍然保留了方法参数泛型的信息。可以供类型转换时使用。
3.4 可变参数
在 Java 5 中提供了变长参数,允许在调用方法时传入不定长度的参数。变长参数是 Java 的一个语法糖,本质上还是基于数组的实现:
1 | void foo(String... args); |
在定义方法时,在最后一个形参后加上三点 …,就表示该形参可以接受多个参数值,多个参数值被当成数组传入。上述定义有几个要点需要注意:
-
可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数
-
由于可变参数必须是最后一个参数,所以一个函数最多只能有一个可变参数
-
Java 的可变参数,会被编译器转型为一个数组
-
变长参数在编译为字节码后,在方法签名中就是以数组形态出现的。这两个方法的签名是一致的,不能作为方法的重载。如果同时出现,是不能编译通过的。可变参数可以兼容数组,反之则不成立
1
2
3
4
5public void foo(String...varargs){}
foo("arg1", "arg2", "arg3");
//上述过程和下面的调用是等价的
foo(new String[]{"arg1", "arg2", "arg3"});
3.5 foreach
循环
foreach
语句是 java5 的新特征之一,在遍历数组、集合方面,foreach
为开发人员提供了极大的方便。
foreach
语法格式如下:
1 | for(type 元素变量x : 遍历对象obj){ |
以下实例演示了数组的 for
和 foreach
循环使用
1 | public class DemoForeach { |
而对于集合:
1 | public class DemoForeach { |
实际被编译器转换为对迭代器的调用:
1 | public class DemoForeach { |
3.6 switch
字符串
从 JDK 7开始,switch
可以作用于字符串和枚举类,这个功能其实也是语法糖,例如:
1 | public class DemoSwitch { |
⚠️注意
switch
配合string
和enum
使用时,变量不能为null
会被编译器转换为:
1 | public class DemoSwitch { |
可以看到,执行了两遍 switch
,第一遍是根据字符串的 hashCode()
和 equals()
将字符串的转换为相应 byte
类型,第二遍才是利用 byte
执行进行比较。
为什么第一遍时必须既比较
hashCode
,又利用equals
比较呢?hashcode 是为了提高效率,减少可能的比较;而
equals
是为了防止hashCode
冲突,例如"EM"
和"C."
这两个字符串的hashCode
值都是2123
。
3.7 switch
枚举
switch
枚举的例子如下
1 | enum Gender { |
1 | public class DemoSwitchEnum { |
转换后代码为:
1 | public class DemoSwitchEnum { |
4 类加载阶段
4.1 加载
-
在 Java 类编译成字节码以后,在运行时通过类加载器 将类的字节码加载到方法区 中,内部采用 C++ 的
instanceKlass
描述 Java 类,它的重要 field 有:-
_java_mirror
即 Java 的类镜像,例如对String
来说,就是String.class
,作用是把instanceKlass
暴露给 Java 使用。对String
来说,String.class
就是instanceKlass
的类镜像,它们两者间相互持有对方的指针。 -
_super
即父类 -
_fields
即成员变量 -
_methods
即方法 -
_constants
即常量池 -
_class_loader
即类加载器 -
_vtable
虚方法表 -
_itable
接口方法表
-
-
如果这个类还有父类没有加载,先加载父类
-
加载和链接可能是交替运行的
注意 ⚠️
instanceKlass
这样的“元数据”是存储在方法区(JDK 1.8 后是元空间),但_java_mirror
是存储在队中的- 可以通过 HSDB 工具查看
4.2 链接
(1) 验证
验证 .class
文件是否符合 JVM 的规范,安全性检查。例如如果我们修改 .class
文件的字节码,JVM 会在加载过后验证类的规范性。如果不符合规范,则会报错。
(2) 准备
准备:为 static
静态变量分配空间,设置默认值
-
static
变量在 JDK 1.7 之前存储于instanceKlass
末尾,从 JDK 1.7 开始,存储于_java mirror
末尾 -
static
变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成 -
如果
static
变量是final
的基本类型,那么编译阶段值就确定了,赋值在准备阶段完成 -
如果
static
变量是final
的,但属于引用类型,那么赋值也会在初始化阶段完成。因为引用一个新对象
new Object()
的时候,新对象引用的创建需要在堆中完成,所以需要在初始化阶段赋值。
(3) 解析
将常量池中的符号引用,解析为直接引用。
1 | package ClassLoad; |
个人理解:在上述例子中,我们在通过
loadClass()
加载类的时候,由于C
类不会被初始化,所以C
类中引用的D
类不会被加载,而是只是作为一个“class ClassLoad.D
” 存在常量池中,并不是一个类。此所谓“符号引用”,“直接引用”则是D
类也被加载,有了地址,此时引用的就是一个真实的类了。
4.3 初始化
初始化就是调用 <clinit>()V
,虚拟机会保证这个类的构造方法的线程安全。
那么何时初始化呢?概括得说,类初始化是【懒情的】
main
方法所在的类,总会被首先初始化- 首次访问这个类的静态变量或静态方法时
- 子类初始化,会引发父类的初始化
- 子类访问父类的静态变量,只会触发父类的初始化
- 执行
Class.forName
时 new
一个对象的时候,会导致该对象的初始化
不会导致类初始化的情况
- 访问类的
static final
静态常量(基本类型和字符串) 不会触发初始化 xxx.class
不会触发该类的初始化- 创建该类的数组不会触发初始化
- 类加载器的
loadClass
方法 Class.forName
的参数2
为false
时
4.4 练习
(1)
从字节码分析,使用 a, b, c
这三个常量是否会导致 E
初始化。
1 | package ClassLoad; |
1 | static {}; |
(2)
完成懒惰初始化单例模式
1 | package ClassLoad; |
5 类加载器
在 JDK 中,类加载器有一定的层级关系。以 JDK 1.8 为例,从顶至底为:
名称 | 加载哪里的类 | 说明 |
---|---|---|
Bootstrap ClassLoader |
JAVA_HOME/jre/lib |
无法直接访问 |
Extension ClassLoader |
JAVA_HOME/jre/lib/ext |
上级为 Bootstrap ,显示为 null |
Application ClassLoader |
classpath |
上级为 Extension |
自定义 ClassLoader |
自定义 | 上级为 Application |
- 类加载器在加载类时需要分层级加载。例如
Application ClassLoader
加载类时会检查该类是否被上级类加载器加载,若没有,则再由Extension ClassLoader
向上层检查。若都没有,则由Application ClassLoader
加载。(双亲委派)
5.1 Bootstrap ClassLoader
用 Bootstrap ClassLoader
加载类:
1 | package ClassLoad; |
执行
1 | package ClassLoad; |
输出
1 | java -Xbootclasspath/a:. ClassLoad.DemoBootstrapClassLoader |
1 | BootStrap ClassLoader Klass init |
5.2 Extension ClassLoader
还是上一节的例子,如果我们使用 .jar
:
1 | jar .cvf Test_Klass.jar ClassLoad/Klass.class |
将我们打包好的 Test_Klass.class
复制到 Java 安装目录下的 JAVA_HOME/jre/lib/ext
扩展目录下。重新运行程序,我们得到以下输出:
1 | Extension ClassLoader Klass init |
5.3 双亲委派模式
所谓双亲委派,就是指调用类加载器的 loadClass()
方法时,查找上级类加载器是否已经加载该类的行为。
图解如下:
源码分析
1 | protected Class<?> loadClass(String name, boolean resolve) |
5.4 线程上下文类加载器
5.5 自定义类加载器
需要自定义类加载器的场景:
- 想加载非 classpath 随意路径中的类文件
- 都是通过接口来使用实现,希望解耦时,常用在框架设计
- 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器
【步骤】
- 继承
ClassLoader
父类 - 要遵从双亲委派机制,重写
findClass
方法- 注意不是重写
loadClass
方法,否则不会走双亲委派机制
- 注意不是重写
- 读取类文件的字节码
- 调用父类的
defineClass
方法来加载类 - 使用者调用该类加载器的
loadClass
方法
6 运行期优化
6.1 即时编译
分层编译
我们先考虑一下例子
1 | public class DemoJit1 { |
输出如下
1 | 0 42542 |
我们可以看到,JVM 在执行这些相同的代码时所用的时间并不相同。
原因是什么呢?
JVM将执行状态分成了 5个层次:
- 0层,解释执行 I (Interpreter)
- 1层,使用 C1 即时编译器编译执行 (不带 profiling)
- 2层,使用 C1 即时编译器编译执行(带基本的 profiling)
- 3层,使用 C1 即时编译器编译执行(带完全的 profiling)
- 4层,使用 C2 即时编译器编译执行
profling 是指在运行过程中收集一些程序执行状态的数据,例如方法的调用次数,循环的回边次数 等
即时编译器(JIT) 与解释器的区别
- 解释器 是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
- JIT 是将一些字节码编译为机器码,并存入 Code Cache, 下次遇到相同的代码,直接执行,无需再编译
- 解释器是将字节码解释为针对所有平台都通用的机器码
- JIT 会根据平台类型,生成平台特定的机器码
对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另
一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。执行效率上 Interpreter < C1 < C2。目的是为了发现那些热点代码,加以优化。
这一种优化手段我们称之为【逃逸分析】,发现新建的对象是否逃逸。可以使用如下指令
1 | -XX:-DoEscapeAnalysis |
关闭逃逸分析。
逃逸分析的作用:
经过逃逸分析的对象,可以直接在栈空间进行分配,而非堆空间。因为这样的对象不会在其他的方法中被引用,所以它可以被分配在当前栈上,可以随着栈消亡。从而极大的降低了 GC 次数,提升了程序整体的执行效率。
方法内联 (Inlining)
1 | private static int square(final int i) { |
如果发现 square()
是热点方法,并旦长度不太长时,会进行内联,所谓的内联就是把方法内代码拷贝、粘贴到调用者的位置:
1 | System.out.println(9 * 9): |
还能够进行常量折叠 (constant folding)的优化
1 | System.out.println(81); |
实验:
1 | public class DemoJit2 { |
1 | 0 81 26792 |
可以使用如下指令打印内联信息
1 | -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining |
1 | -XX:CompileCommand=doninline,包名.类名.方法名 // 关闭方法的内联 |
JMH
JMH(Java Microbenchmark Harness)是用于代码微基准测试的工具套件,主要是基于方法层面的基准测试,精度可以达到纳秒级。当你定位到热点方法,希望进一步优化方法性能的时候,就可以使用 JMH 对优化的结果进行量化的分析。
JMH 比较典型的应用场景如下:
- 想准确地知道某个方法需要执行多长时间,以及执行时间和输入之间的相关性
- 对比接口不同实现在给定条件下的吞吐量
- 查看多少百分比的请求在多长时间内完成
关于 JMH 工具的具体运用,恕在此不作详解。
五、JMM 内存模型
本章节内容单独记录,点击链接以跳转