探秘 JVM:字节码文件结构与指令

Class 文件(也叫字节码文件)与 JVM 一起支撑着 java 程序的跨平台运行,虽然两者目前主要服务于 java 语言,但是其最初的设计是为编程语言构建一个跨平台的基础运行环境,任何语言只要可以被编译为字节码文件,就可以依托于这一套基础运行环境,实现平台无关性。

字节码文件结构

字节码文件是一组以 8 位字节为基础单位的二进制流,各个数据项目(见下图)严格按照顺序紧凑排列,中间没有分隔符,当遇到需要占用 8 位字节以上空间的数据项时,则按照 高位优先 的方式分割成若干个 8 位字节进行存储。

image

字节码文件主要包含两种数据类型:

  1. 无符号数 :无符号数属于基本数据类型,用于描述数字、索引引用、数量值,以及按照 UTF-8 编码的字符串,以 u1、u2、u4、u8 分表代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数。
  2. :通常由多个无符号数或其它表作为数据项构成复合数据类型,一般以 _info 结尾,用于描述具有层次关系的复合机构的数据。

文件类型与版本

字节码文件作为一种文件类型,和普通文件类型一样,也是通过在字节流最开始设置标志位来表示当前文件为字节码类型文件,标志位的值为: 0xCAFEBABE 。如下所示,类型标志位占用前 4 个字节,第 5 和 6 两个字节表示 次版本号(Minor Version) ,第 7 和 8 两个字节表示 主版本号(Major Version) 。示例中的次版本号是 0,主版本号是 0x0034,对应十进制是 52,也就是 java1.8 版本,通常 jdk 版本过低导致编译出错时,编译器会打印出需要的 jdk 版本信息就是这里对应的十进制数字。

1
2
编号:0102 0304 0506 0708 0910 1112 1314 1516
字节:CAFE BABE 0000 0034 0014 0a00 0300 1107

除了查看字节码文件的字节流,我们还可以通过 javap -verbose <class file> 来查看一个字节码文件的结构,相对于字节流更加直观。

常量池

常量池挨着版本信息,主要用于存放字面量和符号引用,其大小通常是不固定的,所以在常量池的最开始需要设置一个 u2 类型的数据(即 constant_pool_count),以表示常量池的大小。需要注意的一点是, 这个容量值的计数是从 1 开始的,而 0 则作为一个特殊值,用于某些指向常量池的索引在特定情况下需要表达“不引用任何常量池项目”的含义

Java 截止目前(JDK 13)共定义了 17 种常量池表类型(如下表所示),这些类型的共同点就是在表开始的第一位定义了一个 u1 类型的标志位(tag),用于表示当前具体的常量类型。

类型 标志 描述
CONSTANT_Utf8_info 1 UTF-8 编码的字符串
CONSTANT_Integer_info 3 整型字面量
CONSTANT_Float_info 4 浮点型字面量
CONSTANT_Long_info 5 长整型字面量
CONSTANT_Double_info 6 双精度浮点型字面量
CONSTANT_Class_info 7 类或接口的符号引用
CONSTANT_String_info 8 字符串类型字面量
CONSTANT_Filedref_info 9 字段的符号引用
CONSTANT_Methodref_info 10 类中方法的符号引用
CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
CONSTANT_NameAndType_info 12 字段或方法的部分符号引用
CONSTANT_MethodHandle_info 15 方法句柄
CONSTANT_MethodType_info 16 方法类型
CONSTANT_Dynamic_info 17 动态计算常量
CONSTANT_InvokeDynamic_info 18 动态方法调用点
CONSTANT_Module_info 19 模块
CONSTANT_Package_info 20 模块中开放或者导出的包

详细结构总表:

image

访问标志位

访问标志位用于标识类或接口的访问信息,占据一个 u2,可用的标志位有 16 个,当前只定义了 9 个,具体如下:

名称 标志位 说明
ACC_PUBLIC 0x0001 是否是 public 类型
ACC_FINAL 0x0010 是否被声明为 final
ACC_SUPER 0x0020 是否允许使用 invokespecial 字节码指令的新语义, invokespecial 在 jdk1.0.2 语义发生过改变,所以需要标识使用的是新语义还是旧语义
ACC_INTERFACE 0x0200 标识接口
ACC_ABSTRACT 0x0400 对于接口和抽象类而言值为 true
ACC_SYNTHETIC 0x1000 标识当前类不是由用户代码产生的
ACC_ANNOTATION 0x2000 标识注解
ACC_ENUM 0x4000 标识枚举
ACC_MODULE 0x8000 标识模块

索引

索引包含类索引(this_class)、父类索引(super_class),以及接口索引集合(interfaces),这三项联合起来确定类的继承关系。

类索引 用于确定类的全限定名,占用一个 u2,指向一个类型为 CONSTANT_Class_info 的类描述符常量,据此可以找到定义在 CONSTANT_Utf8_info 类型常量中的全限定名字符串。

父类索引 用于确定当前类的父类的全限定名,由于 java 不允许多重继承,且对象默认继承 Object 类,所以除了 Object 类外,所有的其它类都有父类索引。

父类索引占用一个 u2,指向一个类型为 CONSTANT_Class_info 的类描述符常量,据此可以找到定义在 CONSTANT_Utf8_info 类型常量中的全限定名字符串。

接口索引集合 用于描述一个类实现了哪些接口,按照在 implements 关键字后面的组织顺序在字节码文件中依次排列。接口索引是一组 u2 类型数据的集合,并在最前面设置一个 u2 类型的接口计数器(interfaces_count),用于记录实现接口的数目。

字段表集合

字段表用于描述接口或者类中声明的变量(包括类变量和实例变量,但不包括局部变量),不包含从父类或父接口继承而来的字段。字段表的格式如下:

类型 名称 数量 说明
u2 access_flags 1 访问标识符,参考字段访问标识符表
u2 name_index 1 对于常量池项的引用,代表字段的简单名称
u2 descriptor_index 1 对于常量池项的引用,代表字段的描述符
u2 arrtibutes_count 1 属性个数
attribute_info attributes attributes_count 属性表
  • 字段访问标识符表
标志名称 标志值 说明
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_TRANSIENT 0x0080 transient
ACC_SYNTHETIC 0x1000 是否由编译器自动生成
ACC_ENUM 0x4000 enum
  • 方法和字段描述符

描述符的作用在于用来描述字段的 数据类型方法的参数列表(数量、类型、顺序) ,以及 返回值 。依据规则,基本数据类型和 void 都用一个大写字母表示(具体如下),而对象类型则用字符 L 加对象的全限定名来表示。

  • B:表示 byte
  • C:表示 char
  • D:表示 double
  • F:表示 float
  • I:表示 int
  • J:表示 long,因为 L 表示对象
  • S:表示 short
  • Z:表示 boolean
  • V:表示 void
  • L:表示对象,包括所有基本类型的包装类型,比如 Ljava/lang/Object

对于数组类型,每一维度都用一个前置的 [ 字符来描述,比如二维数组 String[][] 表示成 [[Ljava.lang.String;,一维整型数组 int[] 表示成 [I

对于方法的描述,则按照 先参数列表,后返回值 的原则,所有的参数按照定义的顺序放在 () 内,比如:

1
2
3
void method() -> ()V
String method() -> ()Ljava.lang.String;
int method(char[] a, int b, int c, double d) -> ([CIID)I

方法表集合

Class 文件存储格式对于方法的描述几乎与属性完全一致,同样主要包含访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index),以及属性集合(attributes)等,如下表所示:

类型 名称 数量 说明
u2 access_flags 1 访问标识符,参考方法访问标识符表
u2 name_index 1 对于常量池项的引用,代表方法的简单名称
u2 descriptor_index 1 对于常量池项的引用,代表方法的描述符
u2 arrtibutes_count 1 属性个数
attribute_info attributes attributes_count 属性表
  • 方法访问标识符表
标志名称 标志值 说明
ACC_PUBLIC 0x0001 public
ACC_PRIVATE 0x0002 private
ACC_PROTECTED 0x0004 protected
ACC_STATIC 0x0008 static
ACC_FINAL 0x0010 final
ACC_SYNCHRONIZED 0x0020 synchronized
ACC_BRIDGE 0x0040 是否是由编译器生成的桥接方法
ACC_VARARGS 0x0080 是否接受可变参数
ACC_NATIVE 0x0100 native
ACC_ABSTRACT 0x0400 abstract
ACC_STRICT 0x0800 strictfp
ACC_SYNTHETIC 0x1000 是否由编译器自生成

对于方法体而言,在经过编译成字节码指令之后存放在属性表的一个名为 Code 的属性中。下面的示例是一个构造方法的字节码(javap 指令默认不显示 private 方法,需要添加 -p 参数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public org.zhenchao.jvm.ClassFormat();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: ldc #2 // String any
7: putfield #3 // Field instanceVariable:Ljava/lang/String;
10: return
LineNumberTable:
line 11: 0
line 19: 4
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lorg/zhenchao/jvm/ClassFormat;

在 java 程序中,方法的重载条件是方法名相同,但是参数签名不同(类型、个数,以及顺序),如果仅仅是返回值不同则不能视为重载。然而,对应到字节码文件则范围则更大, 也就是说如果两个方法的名称和参数签名相同,但是返回值不同,同样可以共存于同一个字节码文件中。这也为一些其他运行在 JVM 上的语言实现有别于 java 语言的特性留出了发挥空间。

属性表集合

在 Class 文件、字段表,以及方法表中都可以携带自己的属性表集合,各属性表不要求严格顺序,只要不与已有的属性名重复即可,JVM 针对不能识别的属性在运行时会选择忽略。截止 JDK 12,预定义的属性共有 29 项,如下表所示:

属性 作用域 描述
Code 方法表 用于记录 java 方法体编译后的字节码
ConstantValue 字段表 记录由 final 关键字定义的常量值
Deprecated 类 / 方法表 / 字段表 deprecated 标识
Exceptions 方法表 方法异常列表
EnclosingMethod 类文件 用于标识类所属的外围方法,只有当该类是局部类或匿名类时才会拥有此属性
InnerClasses 类文件 内部类列表
LineNumberTable Code 属性 源码中行号与字节码指令的映射关系
LocalVariableTable Code 属性 方法的局部变量描述
LocalVariableTypeTable 使用特征签名代替描述符,以支持在引入泛型语法后能够描述泛型参数化类型,JDK 5 新增
StackMapTable Code 属性 服务于类型检查验证器(Type Checker),用于检查和处理目标方法的局部变量和操作数栈所需要的的类型是否匹配,JDK 6 新增
Signature 类 / 方法表 / 字段表 由于 java 的泛型采用擦除法实现,为了避免擦除后导致签名混乱,需要使用此属性记录泛型信息,JDK 5 新增
SourceFile 类文件 记录源文件名称
SourceDebugExtension 类文件 用于存储额外的调试信息,JDK 5 新增
Synthetic 类 / 方法表 / 字段表 标识方法或字段为编译期自动生成的
RuntimeVisibleAnnotations 类 / 方法表 / 字段表 用于指明哪些注解是运行时可见的,JDK 5 新增
RuntimeInvisibleAnnotations 类 / 方法表 / 字段表 用于指明哪些注解是运行时不可见的,JDK 5 新增
RuntimeVisibleParameterAnnotations 方法表 用于指明哪些方法参数注解是运行时可见的,JDK 5 新增
RuntimeInvisibleParameterAnnotations 方法表 用于指明哪些方法参数注解是运行时不可见的,JDK 5 新增
AnnotationDefault 方法表 记录注解类元素的默认值,JDK 5 新增
BootstrapMethods 类文件 用于保存 invokedynamic 指令引用的引导方法限定符,JDK 7 新增
RuntimeVisibleTypeAnnotations 类 / 方法表 / 字段表 / Code 属性 为实现 JSR 308 中新增的类型注解提供支持,用于指明哪些类注解是运行时可见的,JDK 8 新增
RuntimeInvisibleTypeAnnotations 类 / 方法表 / 字段表 / Code 属性 为实现 JSR 308 中新增的类型注解提供支持,用于指明哪些类注解是运行时不可见的,JDK 8 新增
MethodParameters 方法表 用于支持将方法名称编译进 Class 文件(编译时添加 -parameters 参数),以在运行时获取,JDK 8 新增
Module 用于记录一个 Module 的名称和相关信息,JDK 9 新增
ModulePackages 用于记录一个模块中所有被 exports 或 opens 的包,JDK 9 新增
ModuleMainClass 用于指定一个模块的主类,JDK 9 新增
NestHost 用于支持嵌套类的反射和访问控制 API,一个内部类可以通过该属性获取自己的宿主类,JDK 11 新增
NestMembers 用于支持嵌套类的反射和访问控制 API,一个内部类可以通过该属性获取自己有哪些内部类,JDK 11 新增

下面来重点来看一下 Code 属性。对于存在方法体的 java 方法,方法体部分经过 javac 编译之后变为字节码存储在方法表的 Code 属性中,该属性的结构如下:

名称 类型 数量 说明
attribute_name_index u2 1 固定为 Code,代表属性名
attribute_length u4 1 属性的长度
max_stack u2 1 代表操作数栈的最大深度,JVM 依据该值分配栈帧
max_locals u2 1 代表局部变量表所需的存储空间,单位是 Slot
code_length u4 1 字节码指令长度,实际中最大只使用了 u2 的空间,也就是说 JVM 限制一个方法最大不允许超过 65535 条字节码指令 ,否则拒绝编译
code u1 code_length 存储方法体对应的字节码指令
exception_table_length u2 1 异常表长度
exception_table exception_info exception_table_length 异常表
attributes_count u2 1 属性表长度
attributes attribute_info attributes_count 属性表

Code 属性示例:

1
2
3
public int publicMethod(int a, int b) throws Exception {
return a + b;
}

上述方法编译成字节码之后如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public int publicMethod(int, int) throws java.lang.Exception;
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
0: iload_1
1: iload_2
2: iadd
3: ireturn
LineNumberTable:
line 39: 0
LocalVariableTable:
Start Length Slot Name Signature // start和length合起来显示了该变量的作用域
0 4 0 this Lorg/zhenchao/jvm/chapter6/ClassStructTest;
0 4 1 a I
0 4 2 b I
Exceptions: // 与 Code 属性平级
throws java.lang.Exception

上述字节码的局部变量数 locals 和参数个数 args_size 值均为 3,然而实际上我们只定义了两个参数,多的那个是 this 引用,每个方法都隐式持有一个 this 引用,用于指向当前方法所属的类实例。

LineNumberTable 属性用于描述源码与字节码行号之间的映射关系,是生成字节码时的可选项(在执行 javac 时可以通过设置 -g:none-g:lines 来关闭和打开)。如果指定不生成 LineNumberTable,当程序抛出异常时将不会显示发生异常的行号,并且在调试程序时也无法按照源码行来设置断点。

LocalVariableTable 用于描述栈帧局部变量表中的变量与源码中定义的变量之间的映射关系,也是生成字节码时的可选项(在执行 javac 时可以通过设置 -g:none-g:vars 来关闭和打开)。如果指定不生成 LocalVariableTable,那么反编译或者引用这个方法时,所有的参数名称都会丢失,取而代之的是 arg0、arg1 之类的占位符。JDK 5 之后增加了姊妹属性 LocalVariableTypeTable,用于描述泛型类型。

上述字节码中最后一行的 Exceptions 属性与 Code 属性平级(不要与 exception_table 混淆啦),其作用是列举出方法中可能抛出的受检异常,也就是 throws 关键字后面列举的异常列表。

字节码指令

Java 字节码由一个操作码(Opcode)和零至多个紧随其后的操作数(Operand)构成,格式如下:

1
操作码 [操作数1] [操作数2] ...

其中操作码占用 1 个字节,因此最多只能有 256 个,由于 JVM 采用面向操作数栈而不是寄存器的架构,所以 大多数操作码不包含操作数

大多数操作码都包含一个字母用于表明其操作的数据类型(大部分都是首字母)。例如,i 表示 int 类型的数据操作,l 表示 long 类型的数据操作,s 代表 short 类型的数据操作,b 代表 byte 类型的数据操作,c 代表 char 类型的数据操作,f 代表 float 类型的数据操作,d 代表 double 类型的数据操作,以及 a 代表 reference 类型的数据操作。不过,仍然包含一些指令没有明确指明对应的操作类型。

由于 java 的操作码只占用 1 个字节,上限个数只有 256 个,秉承着节约主义,就不能为所有的数据类型和对应的操作设计独立的操作码,这样会导致 256 个操作码很快被用完。所以,大部分操作码都不支持直接操作 byte、char,以及 short 类型,甚至没有任何操作码支持操作 boolean 类型,但是编译器会在编译期或运行期将 byte 和 short 类型带符号扩展(Sign-Extend)为相应的 int 类型,将 boolean 和 char 类型零位扩展(Zero-Extend)为相应的 int 类型数据,所以大多数对于这些类型的操作,本质上是在操作 int 类型。

加载和存储指令

加载和存储指令用于 将数据在栈帧中的局部变量表和操作数栈之间来回进行传输 ,包括:

  • 将一个局部变量加载到操作数栈

iloadiload_<n>lloadlload_<n>floadfload_<n>dloaddload_<n>aloadaload_<n>,其中 a 表示引用类型,n 表示操作数,比如 iload_0 等同于 iload 0

  • 将一个数值从操作数栈存储到局部变量表

istoreistore_<n>lstorelstore_<n>fstorefstore_<n>dstoredstore_<n>astoreastore_<n>

  • 将一个常量加载到操作数栈

bipushsipushldcldc_wldc2_waconst_nulliconst_m1iconst_<i>lconst_<l>fconst_<f>dconst_<d>

  • 扩充局部变量表的访问索引

wide

运算指令

运算指令用于对两个操作数栈上的数值进行某种特定的运算,并把结果重新存入到操作数栈顶。运算指令整体上可以分为两种:对整型数据进行运算的指令和对浮点型数据进行运算的指令。所有的运算指令包括:

  • 加法:iaddladdfadddadd
  • 减法:isublsubfsubdsub
  • 乘法:imullmulfmuldmul
  • 除法:idivldivfdivddiv
  • 求余:iremlremfremdrem
  • 取反:ineglnegfnegdneg
  • 位移:ishllshlfshldshl
  • 按位或:iorlor
  • 按位与:iandland
  • 按位异或:ixorlxor
  • 局部变量自增:iinc
  • 比较:dcmpgdcmplfcmpgfcmpllcmp

整型和浮点型数的运算指令在溢出和被零除时有各自不同的行为表现。对于整型数据,JVM 规范并没有明确定义溢出的计算规则,仅规定只有除法指令(idiv 和 ldiv)和求余指令(irem 和 lrem)在遇到除数为零时会抛出 ArithmeticException 异常。对于浮点型数据,JVM 要求必须严格遵循 IEEE-754 规范。

JVM 在处理浮点数据运算时不会抛出任何异常,当操作溢出时将使用有符号的无穷大进行表示;如果某个操作结果没有明确的数学定义的话,将使用 NaN(Not a Number)值进行表示;所有使用 NaN 值作为操作数的运算,结果都会返回 NaN。

在对 long 类型数据进行比较时,JVM 采用带符号的比较方式,而对于浮点型数据则采用 IEEE-754 规范所定义的无信号比较(Nonsignaling Comparison)方式。

类型转换指令

类型转换指令用于将两种不同的数值类型进行相互转换,一般用于实现代码中的显式类型转换,或者处理数据类型相关指令无法与数据类型一一对应的问题。数据类型转换分为 宽化转换窄化转换 ,其中宽化转换是虚拟机直接支持的,无需依赖对应的转换指令。宽化转换包括:

  1. int 类型到 long、float 或 double 类型的转换。
  2. long 类型到 float 和 double 类型的转换。
  3. float 类型到 double 类型的转换。

窄化转换由于会导致结果正负变换或精度丢失等问题,所以需要使用转换指令来完成,包括:i2bi2ci2sl2if2if2ld2id2ld2f。JVM 规范明确规定数值类型的窄化转换指令永远不会导致 JVM 抛出运行时异常。

JVM 在将一个浮点数窄化转换成 int 或 long 类型时,必须遵循以下转换原则:

  1. 如果浮点数是 NaN,则转换结果是 0。
  2. 如果浮点数不是无穷大,则使用 IEEE-754 的向零舍入模式取整,得到的结果如果如果溢出,则依据正负号使用对应的最大最小正数值代替。

对于从 double 类型到 float 类型的转换,则使用 IEEE-754 向最接近数舍入模式舍入得到一个可以使用 float 类型表示的数字。如果转换结果的绝对值太小,无法使用 float 类型进行表示的话,则返回 float 类型的正负零;如果转换结果的绝对值太大,无法使用 float 类型进行表示的话,则返回 float 类型的正负无穷大。

对象创建和访问指令

虽然类实例和数据都是对象,但是 JVM 对于类实例和数据的创建与操作分别定义了不同的指令,如下所示:

  • 创建类实例:new
  • 创建数组:newarrayanewarraymultianewarray
  • 访问类字段和实例字段:getfieldputfieldgetstaticputstatic
  • 加载一个数组元素到操作数栈:baloadcaloadsaloadialoadlaloadfaloaddaloadaaload
  • 将一个操作数栈的值存储到数组元素:bastorecastoresastoreiastorefastoredastoreaastore
  • 取数组长度:arraylength
  • 检查类实例类型:instanceofcheckcast

操作数栈管理指令

  • 将栈顶一个或两个元素出栈:poppop2
  • 复制栈顶一个或两个数值,并将复制值或双份复制值重新压入栈顶:dupdup2dup_x1dup2_x1dup_x2dup2_x2
  • 交换栈最顶端两个元素:swap

控制转移指令

控制转移指令可以让 JVM 有条件或无条件的从指定位置指令的下一条指令继续执行。控制转移指令包括:

  • 条件分支:ifeqifltifleifneifgtifgeifnullifnonnullif_icmpeqif_icmpneif_icmpltif_icmpgtif_icmpleif_icmpgeif_acmpeqif_acmpne
  • 复合条件分支:tableswitchlookupswitch
  • 无条件分支:gotogoto_wjsrjsr_wret

JVM 定义了专门的指令用于处理 int 和 reference 类型的条件分支比较操作,也有专门的指令用来检测 null。对于 long 、float 和 double 类型的条件分支比较,JVM 会先执行相应类型的比较运算指令,比较操作返回一个 int 型值到操作数栈,随后再执行 int 类型的条件分支比较操作。

方法调用和返回指令

方法调用指令与数据类型无关,但是方法返回指令需要依据返回值区分数据类型。方法调用指令包括:

  • invokevirtual:用于调用对象的实例方法。
  • invokeinterface:用于调用接口方法。
  • invokespecial:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法、父类方法。
  • invokestatic:用于调用类静态方法。
  • invokedynamic:用于在运行时动态解析出调用点限定符所引用的方法。

返回指令与返回类型有关,包括 ireturnlreturnfreturndreturnareturn,以及供 void 方法、<init><clinit> 方法使用的 return 指令。

异常处理指令

显式抛出异常的操作(throw 语句)都由 athrow 指令实现,而处理异常(catch 语句)却不是由字节码指令实现,而是采用异常表来完成。

同步指令

同步(synchronized 关键字)分为方法级别的同步和代码块级别的同步,这两种同步在 JVM 中都是基于 管程(Monitor) 实现。

方法级别的同步是隐式的,通过方法表中的 ACC_SYNCHRONIZED 访问标志来标记一个方法是同步方法,当指令调用该方法时,会先检查 ACC_SYNCHRONIZED 标志位是否被设置,如果被设置了则无法获取管程(锁),必须等待其它指令释放。

同步代码段则基于指令 monitorenter 和 monitorexit 两条指令实现同步,对于异常情况需要中断同步,所以字节码在实现时会主动添加一个异常处理器,并在异常产生时执行 monitorexit 指令,保证不因为异常而导致持有的锁无法正确释放。

关于 synchronized 关键字推荐进一步阅读 深入理解 JUC:synchronized 关键字

参考

  1. Java 虚拟机规范(Java SE 8 版)
  2. 深入理解 java 虚拟机(第 2 版)
  3. 深入理解 java 虚拟机(第 3 版)