深入拆解Java虚拟机 - 随记
1. Java的运行机制
Java虚拟机通过编译器将Java程序转换为该虚拟机能够识别的指令序列,也称为Java字节码,字节码再运行时将转换为系统指令码交由硬件执行,之所以叫字节码,是因为Java字节码指令的操作码(opcode)被固定为一个字节
以HotSpot为例子,从虚拟机的视角来看,执行Java代码首先需要将编译后的class文件加载到Java虚拟机中,加载后的Java类会被存放到方法区(Method Area)中,实际运行时,虚拟机会执行方法区中的代码
Java虚拟机在内存中划分出堆和栈来存储运行时数据,并将栈细分为面向Java方法的Java方法栈,面向本地的本地方法栈,以及存放各个线程执行位置的PC寄存器,其中线程共享区域是方法区和堆,线程私有区域是PC寄存器、Java方法栈和本地方法栈,如下所示:
在运行的过程中,每当调用进入一个Java方法,Java虚拟机会在当前线程的Java方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数,同时栈帧的大小是提前计算好的,在退出当前执行的方法时,不管是正常返回还是异常返回,Java虚拟机都会弹出当前线程的当前栈帧,并将其舍弃
在HotSpot中,将字节码翻译成机器码的过程有2种形式:解释执行和即时编译,默认采用的是混合模式,综合了两种翻译方式,它会先解释执行字节码,然后将其反复执行的热点代码,以方法为单位进行即时编译
- 解释执行:逐条将字节码翻译成机器码并执行,可以理解成是同声传译
1.1 即时编译
即时编译(Just-In-Time compilation, JIT)是将一个方法中包含的所有字节码编译成机器码后再执行,优势在于实际运行中,由于字节码已编译好,因此实际运行速度更快
HotSpot虚拟机中内置了2个(或3个)即时编译器,其中2个编译器存在已久,分别是客户端编译器(Client Compiler)和服务端编译器(Server Complier),这两个编译器也被称为C1编译器和C2编译器,第三个编译器则是JDK 10出现的Graal编译器
编译器和解释器搭配使用的方式在虚拟机中被称为是混合模式(Mixed Mode),也可以通过参数-Xint
强制虚拟机运行于解释模式(Interpreted Mode),这时候编译器完全不介入工作,全部的代码编译都使用解释方式执行,另外,也可以使用参数-Xcomp
强制虚拟机运行于编译模式(Compiled Mode)
可以通过-version
来确认当前虚拟机的运行方式,如下所示:
1 | java -version |
C1编译器和C2编译器分别应用于不同的场景:
- C1编译器:被称为客户端编译器,主要用于较轻量级的优化,目标是快速生成可执行代码,减少编译时间和延迟,并且由于优化较少,C1编译器的编译速度较快,因此应用程序的启动时间较短,比较适合桌面应用程序或者需要快速响应的小型应用
- C2编译器:也被称为服务器编译器,进行更激进和深入的优化,以便生成高效的机器代码,目标是提高代码的执行效率,尽可能地提升性能,由于其进行优化比较多,所以C2编译器的编译速度较慢,因此会增加应用程序的启动时,C2编译器会进行诸如方法内联、逃逸分析、指令调度等高级优化技术,适用于编译执行时间较长或运行频率较高的方法
为什么不提前将所有的Java代码全部编译成机器码?
已经有相关的AOT(ahead of time compilation)做提前预编译的事情,用于解决启动性能不好的问题,对于长时间运行的服务,选择线下编译和即时编译都是一样的,因为最多1-2小时,所有该即时编译的已经都编译完成了,另外即时编译器因为有运行时信息,优化效果会更好一些,也就是峰值表现更好
如何区分热点方法?
JVM会统计每个方法调用了多少次,达到某个阈值属于热点方法,HotSpot采用的热点探测方式是基于计数器的热点探测,其中包含了2个不同类型的计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter),默认情况下,方法调用计数器并不是统计的方法被调用的绝对次数,而是一个相对的执行频率,即在一段时间内方法被调用的次数,当超过一定的时间限度,如果方法的调用次数仍然不足以提交给即时编译器编译,则这个方法的调用计数器会被减少一半,这个过程叫热度衰减(Counter Decay),而这里提到的一定时间限度,则称之为半衰周期(Counter Half Life Time),可以通过
-XX: CounterHalfLifeTime
来配置半衰周期的时间,单位是秒,进行热度衰减的动作是在JVM进行垃圾回收的时候顺便进行的,可以通过-XX: -UseCounterDecay
来关闭热度衰减,让方法计数器来统计方法调用的绝对次数(回边计数器没有热度衰减的过程)
- 方法调用计数器:统计方法的调用次数
- 回边计数器:统计循环体执行的循环次数
JIT在程序重启后需要重新执行吗?
程序停止后,即时编译的结果就会消失
方法区是不是堆的一部分?
不属于,JVM中的堆是用来存放Java对象的
2. Java的基本类型
Java语言规范中,boolean
类型的值只有两种可能,分别用符号true
和false
来表示,然而这两个符号实际上是不能够被Java虚拟机直接识别和使用的
在虚拟机规范中,boolean
类型则是被映射成了int
类型,也就是说,true
被映射为了1
,而false
被映射为了0
,并且在Java编译器中,实际上也是使用整数相关的字节码来实现逻辑运算,因此在当*.java
文件被编译为*.class
文件后,在其文件中除了字段和入参以外,是看不出boolean
类型的痕迹的
Java的基本类型包含了:boolean, short, int, long, float, double, char, byte
共8种类型,它们都有默认的值域以及默认值,如下所示:
有一点需要注意的是,虽然以上几种基本类型的默认值均不一样,但是实际上在内存中的值均是0
声明为
byte, short, boolean
的局部变量是否可以存储超过它们取值范围的值呢?可以,通过绕过编译器对已生成的字节码变量进行值的变更,即可达到这个效果,但在通常情况下,生成的字节码会遵守Java虚拟机规范对编译器的约束,也就是说,正常情况下不会出现局部变量超过它们的取值范围
Java的浮点类型采用IEEE 754浮点数格式,其中包含了一个符号位、指数位和一个尾数位
以float为例子,浮点类型会有2个0,对应是 +0.0F 和 -0.0F ,其中+0.0F
对应的是Java中的0,而后者是符号位为1、其他位均为0的浮点数,但在Java中 +0.0F == -0.0F
会返回true
,在某些场景下,区分正负零可以保持数值计算的精度,比如,通过任意正浮点数除以+0.0F
便可以得到正无穷,通过任意负无穷浮点数除以-0.0F
便可以得到负无穷,这两个无穷值对应在内存分别是0x7F800000
和0xFF800000
,可以通过JShell进行测试,如下所示:
1 | jshell> Float.intBitsToFloat(0x7F800000) |
而现在我们知道了Float的取值区间在[0xFF800000, 0x7F800000],而如果实际超过了这个区间,对应表现的值则会是NaN
,全称是Not-a-Number,并且NaN的特性在于除了!=
始终返回true
外,其他的任何比较结果都会是false
可以通过以下命令进行测试:
1 | jshell> Float.NaN < 1.0F |
已知栈帧有两个主要的组成部分:局部变量区(从Java虚拟机的规范来理解它就是一个大数组)和字节码的操作数栈
局部变量实际上就是一块连续的区域,它是在编译时刻就确定的,其中每个数组单元都存储着一个局部变量的值,数组单元的大小是一致的,具体大小则取决于操作系统的位数
而操作数栈则用于存储在方法字节码执行时涉及到的变量值,以及运算完的结果
对于long
和double
类型的局部变量来说,则需要使用两个连续的数组单元来存储值,而其他的基本类型以及引用类型的值均占用一个数组单元,也就是说boolean, short, byte, char, float
五种基本类型所占的空间和int
类型是一样的,因此在32位的HotSpot中,这些类型在栈上将占用4个字节,在64位的HotSpot中,将占用8个字节,以上情况仅针对于存储在局部变量,而不会存储于堆里的字段或者数组元素上
对于byte, short, char
三种类型的字段或数组单元,分别在堆中占的空间是1字节、2字节以及2字节,而如果将一个int
类型的值,存储到一个声明为char
类型的字段中,由于char
类型只存储2个字节大小的内容,因此会取出低两位的值进行存储
针对boolean
字段来说,它只占用1个字节,且它数组的表现形式较为特殊,它是采用byte
数组实现的,也就是说,实际上只会取boolean
值的最后一位(通过掩码的方式)存入boolean
类型的字段或数组,是为了保证堆中的boolean
值是合法的,简单的理解:boolean
类型在计算时被映射成了int
类型,在堆中boolean
类型占用的成1个字节
总结下来,基本类型所占空间大小需要分为以下2种场景讨论:存在于栈帧种的局部变量大小以及存在于堆中的所占空间大小,而同基本类型的不同大小主要取决于操作系统的位数
类型 | 栈帧 | 堆 |
---|---|---|
int | 4 / 8字节 | 4字节 |
short | 4 / 8字节 | 2字节 |
char | 4 / 8字节 | 2字节 |
byte | 4 / 8字节 | 1字节 |
float | 4 / 8字节 | 4字节 |
boolean | 4 / 8字节 | 1字节 |
double | 8 / 16字节 | 8字节 |
long | 8 / 16字节 | 8字节 |
3. Java类加载机制
Java中的*.class
文件到内存中的类,按照先后顺序需要经过加载、链接以及初始化三大步骤,Java的类加载器,就是将字节码格式*.class
文件加载到JVM的方法区,并在JVM的堆建立起一个java,lang.Class
的对象
Java语言的类型可以分为2大类:基本类型(primitive types)和引用类型(reference types),其中引用类型细分为四种:类、接口、数组类和泛型参数,其中泛型参数会在编译的过程中被擦除
3.1 加载
加载指的是查找字节流,并且据此创建类的过程,可以直接理解成加载*.class
文件,主要的就是将字节码从各个位置转化为二进制加载到内存当中
对于数组类来说,它并没有对应的字节流,而是由Java虚拟机直接生成的,对于其他类来说,Java虚拟机需要借助类加载器来完成查找字节流的过程
说到类加载器,最最底层的类加载器是启动类加载器(bootstrap class loader),可以理解成是二叉树的根节点,启动类加载器是用C++实现的,同时在Java中它没有一个对应的对象
其他的类加载器都是java.lang.ClassLoader
的子类,因此它们均有对应的Java对象,并且这些类加载器需要先由另一个类加载器加载到Java虚拟机中,才能发挥它们的作用
在Java 9前,最为重要的几个类加载器分别为:
- 启动类加载器:负责加载JRE的lib目录下的JAR包
- 扩展类加载器:负责加载次要、通用的类,比如放在JRE的lib/ext目录下的JAR包,其父类加载器是启动类加载器
- 应用类加载器:负责加载应用路径下的类,其父类加载器是扩展类加载器
在Java虚拟机中,类的唯一性是由类加载器实例以及类的全名来一起确定的,因为即便是同一份字节流,经由不同的类加载器加载出来,也会得到2个不同的类
3.2 链接
链接指的是将创建的类合并至Java虚拟机中,使之能够执行的过程,它包含了验证、准备和解析三个阶段:
- 验证:确保被加载类能够满足Java虚拟机的约束条件
- 准备:为被加载类的静态字段分配内存,在
*.class
文件正式被加载到Java虚拟机之前,这个类无法知道其他类及其使用方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址,因此,当需要引用其他成员(如:字段、对象)时,Java编译器会生成一个符号引用,并且在运行阶段,这个符号引用可以准确地对应到具体目标上 - 解析:将符号引用解析为实际引用,如果符号引用指向的是一个未被加载的类,或者未被加载的字段或方法,则解析将触发这个类的加载(但是不一定就会触发这个类的链接和初始化动作)
符号引用:存储在
*.class
文件的常量池中,根据目标方法是否为接口方法,这些引用可以分为接口符号引用和非接口符号引用,可以通过javap -v
来打印出某个类的常量池
3.3 初始化
初始化是类加载的最后一步,即是为标记为常量值的字段赋值,同时执行<clinit>
方法的过程,Java虚拟机会通过加锁来确保类的<clinit>
仅被执行一次
如果在Java中需要初始化一个静态字段,可以在声明时直接赋值,也可以在静态代码块中对其赋值
如果直接赋值的静态字段被修饰为final
,并且它的类型是基本类型或字符串,则该字段会被Java编译器标记为常量值(Constant Value),其初始化直接由Java虚拟机完成,除了这类直接赋值的操作外,所有的静态代码块中的代码,也会直接放置到同一个方法中,这个方法就是上述提到的<clinit>
方法
只有当初始化完成后,类才会正式成为可执行的状态,以下是类初始化的触发情况:
- 当虚拟机启动时,初始化用户指定的主类
- 当遇到新建目标的new指令,初始化对应的目标类
- 当遇到用静态方法的指令时,初始化静态方法所在的类
- 当遇到访问静态字段的指令时,初始化静态字段所在的类,以下一个单例延迟初始化的例子,可以很好地体现这一点:
1 | public class Singleton { |
以上代码中,有且仅当调用了Singleton.getInstance()
时,才会访问LazyHolder.INSTANCE
,才会触发对LazyHolder
的初始化,从而创建一个Singleton
实例,类初始化是线程安全的,并且仅被执行一次,因此可以确保多线程环境下有且仅有一个Singleton
实例
- 子类的初始化会触发父类的初始化
- 如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化
- 使用反射API对某个类进行反射调用,初始化该目标类
- 当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类
类加载器使用了双亲委派模型,即接收到加载请求时,会把请求转发给父类加载器,只有在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载
4. 重载与重写
在Java中,如果同一个类出现多个名字相同,并且参数类型相同的方法,则会无法通过编译,也就是说如果在一个类中定义名字相同的方法,则参数类型必须是不同的,这种方式称之为重载
以上限制可以通过字节码工具绕开,也就是说在编译完成后,可以通过编辑修改*.class
文件达到方法名字相同且参数类型相同,返回类型不同的方法,而具体应该调用哪个方法,则会由Java编译器直接选取第一个方法名以及参数类型匹配的方法
重载的方法在编译的过程即可识别出来,具体到每一个方法调用,Java编译器会根据传入参数的声明类型来选取重载方法,选取会分为以下三步走:
- 在不考虑对基本类型自动拆箱装箱的情况下,以及可变长参数的情况下选取重载方法(可以直接理解成正常选取参数匹配的方法)
- 如果在第一步没有找到合适的方法,则会允许自动拆箱和自动装箱,但不允许可变长参数的情况下选取重载方法
- 如果在第二步也没有找到合适的方法,则会允许自动拆箱和自动装箱,以及可变长参数的情况下选取方法
如果在同一阶段中找到了多个适配的方法,则会在其中选择一个最为匹配的,而决定是否是最为匹配的一个关键就是参数类型的继承关系,打个比方,存在以下两个方法:
1 | void invoke(Object obj, Object... args) { ... } |
如果调用方法是invoke(null, 1)
,则第一个参数null
既可以匹配第一个方法中声明的Object
参数,也可以匹配第二个方法中声明为String
的参数,由于String
是Object
的子类,因此Java编译器会认为第二个方法更为精准贴切
如果子类定义了父类中非私有方法同名的方法,并且两个方法的参数类型不同,则在子类中,这两个方法也构成了重载
如果子类父类的方法名一致,参数名一致,则这两个方法构成了重写,这正是Java语言多态特性的重要体现,允许子类在继承父类部分功能的同时,拥有自己独特的行为(打个比方:打10086电话时,会根据拨打所在地对应动态地调度到所在地的客服,重写的调用也是如此)
Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor),其中,方法描述符由方法的参数类型与返回类型构成,在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,则Java虚拟机会在类的验证阶段报错
Java虚拟机中关于方法重写的判定是基于方法描述符,如果子类定义了与父类中非私有、非静态方法同名的方法,只有当两个方法的参数类型与返回类型均一致,Java虚拟机才会判定为重写
Java虚拟机中提到的静态绑定(Static Binding)指的是在解析时可以直接识别目标方法的情况,而动态绑定(Dynamic Bingding)则是代表需要在运行过程中根据调用者的动态类型来识别目标方法的情况
Java字节码中与调用相关的指令有以下五种:
invokestatic
: 用于调用静态方法invokespecial
: 用于调用私有实例方法、构造器以及使用super
关键字调用父类的实例方法或构造器和所实现的默认方法invokevirtual
: 用于调用非私有实例方法invokeinterface
: 用于调用接口方法invokedynamic
: 用于调用动态方法
5. 虚方法
在Java中,虚方法指的是在父类中声明,但在子类中被重写的方法,这种方法的调用需要取决于实际对象的类型,当调用一个对象的虚方法时,Java会动态地确定应该调用哪个方法
Java虚拟机所使用到的invokevirtual
指令与invokeinterface
指令,都属于Java虚拟机的虚方法调用,大多数情况下,Java虚拟机需要根据调用者的动态类型来确定虚方法调用的具体目标方法,这个过程称之为动态绑定,相对于绑定的非虚方法调用来说,虚方法调用会更加耗时
5.1 方法表
Java虚拟机中采取了一种空间换时间的策略来实现动态绑定,为每个类生成一张方法表,用于快速定位目标方法
方法表是在类加载的准备阶段构造的,同阶段下还会为类的静态字段进行内存分配
方法表实际上就是Java虚拟机实现动态绑定的关键所在,它实质上是一个数组,每个数组元素都会指向一个当前类及其祖先类中非私有的实例方法,方法表有以下特性:
- 可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法
- 子类方法表中包含父类方法表中的所有方法
- 子类方法在方法表这的索引,与所重写的父类方法的索引值相同,其中索引值相同是为了保证当前类的实际引用不会生效,如果子类重写父类的方法,索引值就变得不同,则会导致解析阶段生成的索引值失效,调用到了错误的方法
方法调用指令中的符号引用会在执行之前解析成实际引用,对于静态绑定的方法调用来说,实际引用则是指向具体的目标方法,对于动态绑定的方法调用而言,实际引用则是方法表的索引值,并在具体调用的时候根据实际类型方法表配合着索引值来获得目标方法
因此实际上来说,动态绑定与静态绑定相对比下,动态绑定仅仅多出以下几步:
- 访问栈上的调用者,读取调用者的动态类型
- 读取该类对应的方法表
- 读取方法表中索引值对应的目标方法
5.2 内联缓存
Tips: 在Java8版本之后,内联缓存被称为Megamorphic Cache
跟即时编译有关的2种性能优化手段分别是内联缓存(inlining cache)和方法内联(method inlining)
内联缓存是一种加快动态绑定的优化技术,它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法,在之后的执行过程中,如果碰到已缓存的类型,内联缓存将会直接调用该类型所对应的目标方法,如果碰到没有缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定(简单理解就是内联缓存可以加快虚方法的动态绑定过程,即动态绑定一次后,后续会将对应关联关系进行缓存)
Java虚拟机在有关内联缓存的机制上,实施的是单态内联缓存(可以理解成,当某个类存在多个子类时,内联缓存始终只会缓存一个子类的动态绑定),这么做的目的是为了节省内存空间,因此当内联缓存没有命中的情况下,有两种处理方式:
- 替换单态内联缓存中的记录(最坏的情况下,两个不同的子类间隔调用同个方法,没有用到内联缓存带来的性能提升,并且会有额外的写缓存开销)
- 直接访问方法表,而不再做替换内联缓存中的记录这个动作,节省写缓存的额外开销
可以通过调整以下JVM参数来进行调整:
-XX:MaxInlineSize=<size>
:设置内联缓存的最大大小
-XX:FreqInlineSize=<size>
:当方法被调用的频率超过这个值时,JVM会考虑将其内联
6. 异常处理
异常处理的两大要素分别是抛出异常和捕获异常,其中抛出异常可以分为两种:显式异常和隐式异常
- 显式异常:主体是应用程序,指的是在程序中使用
throw
关键字,手动将异常实例抛出 - 隐式异常:主体是Java虚拟机,指的是在Java虚拟机的执行过程中,碰到无法继续执行的异常状态,自动抛出异常
代码中可以通过try-catch-finally
三段式代码块来进行异常捕获,其中finally
代码块会在任何情况下确保执行,包括try
代码块发生异常和catch
代码块发生异常的情况,如果在finally
代码块发生了异常,则会终止finally
代码块的执行,并向外抛出异常
Java中存在着Throwable
类,所有的异常都是它的子孙类,Throwable
有两大子类:Exception
和Error
,如下所示:
其中当Error
被触发时,则意味着程序的执行状态已经无法恢复,需要中止线程甚至是虚拟机,而Exception
则涵盖了程序可能需要捕获并且处理的异常
RuntimeException
和Error
两类属于Java中的非检查异常(unchecked exception),在Java中所有的检查异常都需要显式捕获,或者在方法声明中使用throw
关键字
Java虚拟机会在抛出异常的新建异常实例动作时,生成该异常的栈轨迹,主要依赖于新建异常类,因此在抛出异常时,不应当使用缓存的异常实例,而是应该在抛出异常的同时构建新的异常实例(简单地理解就是如果在代码中提前new
好异常类,会导致收集到的堆栈信息是在new
时候的信息,而不是异常发生时的堆栈信息)
在编译生成字节码时,每个方法都会附带一个异常表,异常表中的每一个条目代表一个异常处理器,并且由from
指针、to
指针、target
指针以及所捕获的异常类型构成,指针的值是字节码索引(bytecode index,bci),用以定位字节码
其中from
指针和to
指针代表了这个异常处理器的监控范围,可以简单地理解为是try
代码块所覆盖的范围,target
指针则指向异常处理器的起始位置,可以简单地理解成是catch
代码块的起始位置
当程序发生异常时,Java虚拟机则会从上至下遍历异常表中的所有条目,当触发异常的字节码的索引值在某个异常表条目的监控范围内,则Java虚拟机需要进一步判断所抛出的异常和捕获的异常类型是否一致,如果匹配则代码需要转移至target
指针指向的字节码,如果遍历完异常表仍无匹配的条目,则会弹出当前方法的栈帧,并且在调用者重复以上动作,最坏的情况下,Java虚拟机需要遍历所有栈对应方法的异常表
在Java8的编译器中,关于finally
代码块的处理方式是将其复制到try
代码块和catch
代码块的结尾处,如下所示:
以下定义了一份例子代码,可以通过javap -c Foo
来查看对应编译后的字节码以及上述提到的异常表:
1 | public class Foo { |
字节码内容如下所示:
1 | ❯ javap -c Foo |
以上得到的异常表在每个Java版本中的表现可能存在差异,其中any
表示的是所有种类的异常
如果catch
代码块发生了异常,则finally
捕获且对外抛出的实际会是catch
代码块中发生的异常
try-with-resources
代码块实际上需要依赖资源中实现AutoCloseable
接口,用于生成的字节码自动在代码块的结尾调用close
方法来关闭资源
如果finally
代码块中有return
语句,则会导致catch
代码块的throw
异常会被忽略,是因为finally
代码块实际上确实捕获了catch
代码块抛出的异常,正常来讲会在fianlly
代码块执行完成后再重新对外抛出该异常,而finally
代码块中的return
语句会导致在重新抛出该异常前就返回
7. 对象内存
在HotSpot虚拟机中,对象在堆内存中的存储布局主要分为3个部分:对象头、实例数据和对齐填充
7.1 对象头
对象头中包含了运行时数据(也有一部分称之为标记信息)、类型指针、记录数组长度的数据(如果是对象数据的话)和对齐填充
- 类型指针:可以简单理解成是指向对象所属类元数据信息的指针,其中,所属类元数据包含了类的方法表、字段表等信息,通过这个类型指针,就可以确定对象的具体类型,从而进行方法调用、字段访问等等操作
- 运行时数据:包含了哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID和偏向时间戳
以上两部分数据(类型指针和运行时数据)分别会占用8个字节(在64位环境下),因此每个对象的额外内存为16个字节,如下所示:
7.2 实例数据
实例数据(Instance Data)指的是程序中定义的各种类型的字段内容,其中HotSpot中默认分配的顺序是long/double -> int -> short -> byte/boolean -> oops[Ordinary Object Pointers]
7.3 对齐填充
这部分数据没有任何意义,纯粹是用于将对象大小填充至8N个字节(也就是8个整数倍),以保证访问时的效率
7.4 指针压缩
这个概念是从JDK 1.6开始的,以支持JVM在64位的操作系统中进行指针压缩,可以通过-XX: +UseCompressedOops
开启指针压缩(默认是开启状态),通过-XX: -UseCompressedOops
禁止指针压缩
指针压缩的一个直接目的其实是为了减少64位平台下内存的消耗,减少指针的内存占用,将64位的指针压缩至32位,需要注意的是指针压缩不会压缩所有的指针,而是只会压缩Java堆中的对象引用指针,这部分指针通常是占用内存空间的主要部分,通过压缩对象引用指针,可以显著地减少Java堆占用的内存空间
以一个32位的JVM为例,所能够表达的地址数量则是2^32
个地址,其所占用的空间则是2^32/1024/1024/1024=4GB
Tips: 有关
2^32
个地址占用空间大小为4GB的计算逻辑可以参考此篇文章
使用指针压缩时,会在对象指针存入堆内存时对其进行压缩计算得到32位地址,并在取出地址时做一次解码计算得到原有的64位地址
一个简单的理解方式:原先的64位地址只能存放1个对象地址,指针压缩后64位可以存放2个对象地址
需要注意的是,当堆内存小于4G时,不需要启用指针压缩,JVM会直接去除高32位(也就是Java对象的引用指针只使用了32位,高32位会被直接丢弃)
堆内存在大于32G时,压缩指针会失效,会强制使用64位来对Java对象进行寻址
7.5 字段重排序
实际上就是Java虚拟机重新分配字段的先后顺序,以达到内存对其的目的,因为每个字段占用的空间大小都是不一样的
8. 垃圾回收
垃圾回收(也被称为GC)指的是Java虚拟机中的自动内存管理,其本质在于将原本需要由开发人员手动回收的内存交由垃圾回收器来自动回收,而这其中涉及到了如何判断对象为垃圾对象、怎么在不影响现有线程的情况下进行垃圾回收和垃圾回收应该怎么处理空间碎片等等问题
其中,引用计数法和可达性分析便是用于判断对象是否已死亡的两种常见算法
8.1 引用计数法
引用计数法(reference counting)通俗的理解就是给对象中加一个引用计数器,当进行引用的时候,计数器就+1,当引用失效的使用,计数器就-1,而当计数器为0时,则表示当前对象死亡,可以被回收(空间可以被释放)
引用计数法的缺点在于它需要额外的空间来存储计数器,同时需要繁琐的更新操作,并且这个算法还无法解决循环引用的问题
什么是循环引用?
打个比方,当A引用了B,同时B引用了A,那么这个情况下会导致A/B双方永远无法被判断为无效引用,在这套算法逻辑下,这两个对象还活着,因此会导致循环引用的对象所占用的空间无法被回收,从而造成内存泄露
8.2 可达性分析
从Java 1.2版本起,JVM就一直使用“可达性分析算法”来确定对象是否可被回收,这个算法会将一系列的GC Roots作为初始的存活对象集合(live set),然后从集合触发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程也被称为标记,最终剩余的未被探索到的对象则认为是死亡对象,可以直接回收
GC Roots一般可以直接理解成是由堆外指向堆内的引用,一般来说包含以下几种:
- Java方法栈帧中的局部变量
- 已加载类的静态变量
- JNI handles
- 已启动且未停止的Java线程
可达性分析可以解决引用计数法无法解决的循环引用问题,打个比方,如果存在A、B、C三个引用,有且仅有A引用B,B引用C,C引用A,则这三个对象都不会存在于 GC Roots的引用下,也就认为这三个引用是可回收状态
8.3 安全点
在JVM进行垃圾回收时,为了避免造成对象的误清除,需要停止其他非垃圾回收线程的工作,直到垃圾回收完成,也就是所谓的Stop-the-world,这段停止的时间就称为垃圾回收的暂停时间(GC pause)
JVM的Stop-the-world概念是通过安全点(Safepoint)机制来实现的,当虚拟机接收到Stop-the-world请求时,所有的非垃圾回收线程都需要暂停,但只有当这些线程到达了下一个安全点时才会真正停下来,而GC线程则会等待所有的非垃圾回收线程都到达安全点,才会允许Stop-the-world现成进行独占工作
安全点是指执行过程中可以安全暂停的点,在这些安全点上,能够允许JVM进行GC、线程栈的扫描等操作,其实现方式主要基于以下两个核心步骤:
- 轮询:各线程会在执行时会定期检查某个标志位,看看是否需要进入安全点
- 编译植入:JVM编译器会在生成的字节码中植入安全点检查指令,确保线程在执行的过程中能够及时响应进入安全点的请求
同时,线程到达安全点的方式也分为两种:抢占式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)
- 抢占式中断:不需要线程的执行代码主动配合,而是当垃圾收集发生时,将所有的用户线程全部中断,再依次判断哪些线程未到达安全点,将该线程恢复执行直至下一个安全点(非目前主流虚拟机所采用的方式)
- 主动式中断:当垃圾收集需要中断线程执行时,不直接对线程进行操作,仅简单设置一个标志位,各个线程执行过程时不停地轮询这个标志,来判断当前线程需不需要在安全点位置进行挂起等待垃圾回收
8.4 安全区域
当进行垃圾回收时,安全点机制仅仅能够保证当时在运行的用户线程能够进入一个挂起等待状态,而实际上当遇到程序不执行的情况下(比如位于休眠或阻塞未分配CPU运行时间)的线程,则线程无法响应虚拟机的中断请求,无法继续运行到达下一个安全点位置,针对以上情况,则需要引入安全区域(Safe Region)来解决
安全区域指的是在其中的某一代码片段下开展垃圾收集都是安全的,其引用对象不会发生变化
当用户线程执行到了安全区域中的代码时,会首先标记自己已经进入了安全区域,如此这般可以在虚拟机需要进行垃圾回收时,可以直接放心地相信位于安全区域中的线程(不需要做额外的干预动作),同时当线程需要离开安全区域时,也会检查虚拟机是否已经完成了垃圾收集,如果垃圾收集未完成,则该线程会一直进行等待直至收到可以离开安全区域的信号为止
8.5 回收方式
垃圾回收的具体方式分为三种:标记-清除算法、复制算法、标记-整理算法和一个分代收集理论
a. 标记-清除
标记-清除(Mark-Sweep)算法分为两个阶段:标记阶段和清除阶段
- 标记阶段:会标记出所有需要回收的对象
- 清除阶段:在完成以上标记阶段后,统一回收直接碾压清除所有未被标记的空间
整个过程如下所示:
以上算法的两大缺陷在于效率问题和空间问题
其中空间问题指的是标记-清除后会产生大量不连续的碎片,因此这个算法比较适合存活对象多的情况,而另一个效率问题指的是整个回收过程会有2次完整的空间扫描(第一次的标记和第二次的清除)
b. 复制
复制算法主要解决的是效率问题,它实际上会将内存分为大小相同的两部分,每次只使用其中的一块,当其中一块内存满了的时候,则会标记这块内存中的存活对象,并将这批存活对象直接复制到另一块内存中,随后将先前满了的内存空间直接做清除操作,如下所示:
复制算法比起标记-清除算法的一大优势在于它不会产生内存碎片,在一般情况下,这种算法一般用于新生代的垃圾回收
新生代:堆内存中通常分为新生代和老年代,新生代内存会进一步分为Eden区和两个Survivor区(也经常被称为S0和S1)
- Eden区:大部分新创建的对象首先分配在Eden区
- Survivor区:有两个Survivor区(S0和S1),用于在垃圾回收过程中保存从Eden区和其他Survivor区复制过来的存活对象
有关该复制算法的一个变种叫Appel式回收,它是一种优化的新生代垃圾回收算法,目的是提高内存分配和回收的效率,这个算法主要是针对新生代(Young Generation)内存进行优化,其内存布局中包含了一个大的Eden区和两个小的Survivor区
Appel式回收在每次分配内存只使用Eden和其中的一块Survivor,发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间,其中Eden区和Survivor区在HotSpot虚拟机中的默认比例是8:1,也就是Eden区占总大小80%,而2个Survivor区占总大小20%
按照对象“朝生夕灭”的特点,新生代中对象有98%熬不过第一次垃圾回收,但这个98%并不是绝对的,因此Appel式回收还有一个类似逃生门的安全设计,当剩余的10%空间不够时,超出10%的对象会通过分配担保机制直接进入老年代
c. 标记-整理
标记-整理算法也经常被称为“标记-压缩算法”,实际上是根据老年代提出的一种标记算法,标记的过程和“标记-清除”的标记过程一致,但不同的点在于后续的步骤不是直接对可回收对象进行回收,而是让所有的存活对象向一端移动,直至清理掉边界以外的内存,如下所示
d. 分代收集理论
实际上是一种组合策略,是指将堆内存分为新生代和老年代,并分别使用不同的回收算法,当前的虚拟机对应的垃圾回收都采用的是分代收集理论,根据对象的存活周期不同将内存分为鸡块
在新生代中,采用复制算法进行垃圾回收,只需要对少量的对象进行复制就能完成垃圾回收,效率相对较高
在老年代中,对象的平均存活概率都挺高,所以一般采用的是标记-清理或者标记-整理的算法进行垃圾回收
P.S. 现代垃圾收集器大部分都是基于分代理论设计的
9. Java堆
Java堆(Java Heap)是虚拟机所管理的内存中最大的一块,Java堆是被所有线程共享的一块内存区域,这块内存区域的唯一目的就是存放对象实例
几乎所有的对象实例都在Java堆中分配内存
Java堆也是垃圾收集器管理的内存区域,因此也有一些资料称呼其为GC堆(Garbage Collected Heap)
Java虚拟机将堆空间划分为年轻代和老年代,其中年轻代又被划分为Eden区以及两个Survivor区(这两个Survivor区域被称为from区和to区),其中Survivor的to区是空的
以上的堆空间划分仅仅是一部分垃圾收集器的共同特性或是设计风格而已,并不是所有的垃圾回收器都是基于以上规则进行划分(并非固定的),而HotSpot虚拟机的内部垃圾收集器则都是基于以上规则进行空间划分
默认情况下,Java虚拟机采取的是动态分配策略(对应Java虚拟机参数为-XX:+UsePSAdaptiveSurvivorSizePolicy
),即根据对象的创建速率,以及Survivor区的使用情况来动态调整Eden区和Survivor区的比例
Eden区和Survivor区的比例可以通过-XX:SurvivorRatio
来调整
需要注意的是,Survivor区域中,会固定有一个区域一直为空,因此比例越低浪费的堆空间也就越高,如下所示:
在以上经典分代的设计下,新生对象通常会分配在新生代中的Eden区,少数情况下(比方说对象的大小超过了新生代的剩余可分配空间)会直接分配到老年代中
另外,由于Java堆是内存共享的,也就意味着所有的线程都会在堆上为对象分配空间,而为了避免分配空间冲突,采用的是每个线程预先申请空间用于该线程下的对象的创建开支,如果申请的空间用完了,则可以再次申请,这种情况下可以保证A线程下申请的空间不会被线程B所占用导致内存对象冲突,这项内存分配技术称为TLAB(Thread Local Allocation Buffer),对应的虚拟机参数为-XX:+UserTLAB
,默认情况下是开启的
再举一个具体的TLAB例子:
每个线程可以独立地向Java虚拟机申请一段连续的内存(用于对象的初始化),作为线程私有的TLAB,并且这个申请的过程是加锁的,同时在申请完成后,线程需要维护两个指针:一个起始指针指向TLAB空余区域的起始位置,另一个则指向TLAB末尾
当该线程遇到需要进行对象初始化的动作时,则通过指针加法(bump the pointer)的方式进行实现,将指向空余区域的指针加上所需要创建对象的字节数,此时当移动尝试移动指针时剩余位置不够,则需要重新申请新的TLAB
当发生Minor GC时,Eden区域和from指向的Survivor区中的存活对象都将被直接复制到to指向的Survivor区,随后清除除了to指针的Survivor以外的区域,完成垃圾清除后再交换Survivor区中的from指针和to指针
Java虚拟机会记录Survivor区域中的对象一共被来回复制了多少次,如果一个对象被复制的次数达到了15(这个次数可以通过-XX:+MaxTenuringThreshold
调整),那么这个对象将被晋升至老年代,另外如果单个Survivor区域已经被占用了50%,那么较高复制次数的对象也会被直接晋升到老年代中
10. 记忆集和卡表
对象跨代引用指的是老年代的对象可能引用了新生代的对象,也就是说在标记存活的时候,需要扫描老年代中的对象,如果该对象拥有对新生代对象的引用,则这个引用会被识别为是GC Roots,如果对此类情境不做任何干预,则会演变成一次全堆扫描
而记忆集和卡表则是专注于解决对象跨代引用的问题,可以把记忆集和卡表理解成是Java中的Map
和HashMap
的关系
垃圾收集器在新生代中建立了记忆集(Remembered Set),用于避免将整个老年代加进GC Roots扫描范围
记忆集是一种用于记录从非收集区域指向收集区域的指针集和的抽象数据结构
一种记忆集的最简单实现是用非收集区域中的所有跨代引用的对象数组来实现这个数据结构,但这种实现空间占用和维护成本都很高
记忆集可以实现以下(不完全)维度的跨代引用记录精度:
- 字长精度:每个记录精确到机器字长,也就是处理器的寻址位数,如常见的32位或64位,该字包含了跨代指针
- 对象精度:每个记录精确到一个对象,该对象中有字段含有的跨代指针
- 卡精度:每个记录精确到一块内存区域,该区域中包含有跨代指针
其中第三种记录精度(卡精度),所指的其实是一种被称为卡表(Card Table)的方式去实现的记忆集
卡表的最简单形式是一个字节数组(也是HotSpot虚拟机所采用的实现方式),以下是一个HotSpot虚拟机默认的卡表标记逻辑:
1 | CARD_TABLE [this address >> 9] = 0; |
字节数组的每一个元素都对应着其标识的内存区域中的一块特定大小的内存块,这个内存块被称之为卡页(Card Page),一般来说卡页的大小是2的N次方的字节数,以上可以看出HotSpot所使用的卡页是2的9次方,也就是512字节
一个卡页的内存中通常包含不止一个对象,只要卡页中有一个(或多个)对象的字段存在跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则默认为0
当垃圾收集器发生时,只需要筛选出卡表中变脏的元素,则可以轻易地得出哪些卡页内存块中包含了跨代指针,并将其加入GC Roots中一并扫描
11. 锁
高效并发是JDK 5升级到JDK 6的一个重要优化点,HotSpot虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术,比如:适应性自旋、锁消除、锁膨胀、轻量级锁和偏向锁,目的是为了在线程之间更高效地共享数据以及解决竞争问题,从而提高程序的执行效率
11.1 synchronized关键字
在Java中,最基本的互斥同步手段就是synchronized
关键字,它是一种块结构的同步语法,这个关键字经过javac
编译过后,会在同步块的前后分别插入monitorenter
和monitorexit
两个字节码指令,如下所示:
1 | public void foo(Object lock) { |
其中具体synchronized
关键字锁住的是什么,需要看代码中的写法,如果指定了一个对象,则以这个对象的引用作为reference,如果没有明确地指定,则需要看synchronized
所修饰的方法类型,来决定是取代码所在的对象实例还是取类型对应Class对象来作为线程要持有的锁
至于其中提到的monitorenter
指令,Java虚拟机在运行至此时,会尝试获取对象的锁,如果这个对象没有被锁定,或者当前线程已经持有了这个对象的锁,就会把锁的计数器值进行+1,当锁的计数器为0时,锁就会立即释放,如果获取对象锁失败,则当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的现成释放为止
根据以上,可以得出synchronized
的机制:
- 被
synchronized
修饰的同步块对同一条线程来说是可重入的,也就是这一持有锁的现成可以反复进入同步块也不会产生死锁的情况 - 被
synchronized
修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后边其他线程的进入,这期间无法线程释放锁,也无法强制正在等待锁的线程中断等待或超时退出
跟synchronized
关键字用法上比较相似的是重入锁ReentrantLock
,它与synchroinized
一样是可重入的,在基本用法上,可以参照以下代码:
1 | import java.util.concurrent.locks.Lock; |
ReentrantLock
相比较于synchronized
关键字增加的一些功能如下:
- 等待可中断:指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,这一点可以通过超时获取锁机制来实现,比如
lock.tryLock(1, TimeUnit.SECONDS)
,会尝试等待1秒钟 - 公平锁:指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁,而非公平锁不保证这一点,会在锁被释放时,任何一个等待锁的线程都有机会获得锁
- 锁绑定多个条件:指一个
ReentrantLock
对象可以同时绑定多个Condition
对象,如以下生产者-消费者模型所示:
1 | import java.util.concurrent.locks.Condition; |
synchronized
关键字的性能在JDK 5表现不如ReentrantLock
,直到JDK 6性能才与ReentrantLock
持平
11.2 重量级锁
内置锁在Java中被抽象为monitor
,在JDK 1.6以前,监控锁可以直接操作底层操作系统的互斥量,这种同步方式的成本很高,还包含了系统调用引起的内核态与用户态的切换,线程阻塞造成的线程切换,因此这种锁被称之为重量级锁
比如上述提到的synchronized
关键字和ReentrantLock
都属于重量级锁
- 优点:线程竞争不使用自旋,不消耗CPU
- 缺点:线程阻塞,响应时间慢
- 适用场景:追求吞吐量,同步块执行时间较长
11.3 自旋锁
重量级锁比较大的一个缺点在于它需要阻塞线程,而如果遇到的场景不需要长时间的阻塞,只需要很快便可以拿到锁,那么阻塞-恢复这一动作反而会带来较大的压力,并且还涉及到了内核态和用户态的切换
由于内核态和用户态的切换开销不容易优化,因此通过自旋锁,可以减少线程阻塞造成的线程切换,自旋锁的做法是当线程遇到需要等待锁的场景时,不进入阻塞状态,而是让自己“稍等一会”,但不会放弃处理器的执行时间,看看锁是不是很快就会释放,为了让线程进入自身等待状态,需要让线程执行一个自旋,这个技术也就是所谓的自旋锁
如果锁的粒度小,那么锁的持有时间比较短,对于竞争这些锁的线程而言,因为阻塞造成的线程切换时间与锁持有的时间相当,减少现成阻塞造成的线程切换,就能得到较大的提升
自旋锁在JDK 1.4.2中引入,在此前一直是默认关闭的,直到JDK 6改为默认开启,可以通过-XX:+UseSpinning
参数来进行控制
自旋锁的一个弊端在于长时间自旋的话会白白消耗处理器的资源,反之如果锁被占用时间短,那么自旋等待的效果就会非常好,因此自旋等待的时间是有一定限度的,如果自旋超过了指定的次数,依旧没有等到锁,则应当用传统的方式来挂起线程,自旋次数的默认值是10,也可以通过-XX:PreBlockSpin
来进行修改
JDK 6针对自旋锁引入了自适应自旋技术,解决的是锁竞争时间不确定的问题,JVM很难感知到确切的锁竞争时间,因此自适应自旋假定不同线程持有同一个锁对象的时间基本相同,竞争程度趋于稳定,因此可以根据上一次自旋的时间与结果来调整下一次自旋的时间
11.4 轻量级锁
Tips: 轻量级锁是JVM内部的优化机制,开发者无法直接控制
轻量级锁是JDK 6加入的新型锁机制,其名字中的“轻量级”是相对于操作系统的互斥量实现的传统锁而言的,设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量所产生的性能消耗,主要是通过自旋和CAS操作来避免上下文切换
轻量级锁是基于对象头中的Mark Word实现的,当一个线程进入同步块时,如果该对象没有被锁住,JVM会在当前线程的栈帧中创建一个锁记录(Lock Record),并将对象头中的Mark Word复制到这个锁记录中,然后尝试使用CAS操作将对象头中的Mark Word替换为指向锁记录的指针,如果成功,则表示该线程获得了锁
如果CAS操作失败,表示已经有其他线程持有该锁,JVM会使用自旋锁来等待一段时间,尝试获取锁。如果自旋次数超过一定阈值,或者检测到需要进行线程调度时,JVM会将轻量级锁膨胀为重量级锁
以下是一个轻量级锁的示例:
1 | public class LightweightLockExample { |
11.5 偏向锁
偏向锁(Biased Locking)是Java虚拟机(JVM)中一种锁优化技术,用于减少在无竞争环境下的同步开销。偏向锁的基本思想是,假设大多数锁在整个生命周期中只会由一个线程获取,因此可以将锁偏向于第一次获取它的线程,在没有其他线程竞争的情况下,后续的加锁和解锁操作可以变得非常轻量
当有一个线程尝试获取同一个锁时,JVM会撤销偏向锁,将其升级为轻量锁或重量级锁,撤销偏向锁需要一个全局安全点,所有线程都暂停执行,然后由JVM检查并更新锁的状态
在没有实际竞争的情况下,还能够针对部分场景继续优化,如果没有实际竞争,自始至终,使用锁的线程都只有一个,那么维护轻量级锁都是浪费的,偏向锁的目标是减少无竞争且只有一个线程使用锁的情况下使用轻量级锁产生的性能消耗
它的一个优点在于加锁和解锁都不需要额外的消耗,和执行非同步方法相比仅有纳秒级别的差距
可以通过-XX:-UseBiasedLocking
来禁用偏向锁,以下是一个简单的例子用于展示偏向锁启用与禁用带来的性能差距:
1 | public class BiasedLockExample { |
- 当启用偏向锁时,如下所示:
1 | java -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 BiasedLockExample |
- 当禁用偏向锁时,如下所示:
1 | java -XX:-UseBiasedLocking BiasedLockExample |
总而言之,偏向锁非常适用于无锁竞争或竞争比较少的场景,特别是单线程多次进入同步块的时候,效果十分显著
11.6 锁消除
锁消除是指虚拟机即时编译器在运行时检测到某段需要同步的代码根本不可能存在共享数据竞争而实施的一种对锁进行消除的优化策略
其主要判断依据来源于逃逸分析的数据支持,如果判断一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那么就可以把它们当做栈上的数据进行处理,认为是私有数据,此时加锁也就无需再进行
举一个例子,如下代码:
1 | public String concatString(String s1, String s2, String s3) { |
String类型是一个不可变的类,对字符串进行操作会生成新的String对象,因此Javac编译器会对以上String连接做优化,在JDK 5之前,以上字符串加法会转换为StringBuffer
对象的连续append
操作,在JDK 5即及之后的版本,会转化为StringBuilder
对象,然后做连续的append
操作,即经过Javac编译后的代码会变为以下:
1 | public String concatString(String s1, String s2, String s3) { |
以上转化后的代码,使用了StringBuffer
类,实际上这段代码并不涉及到同步,而每个StringBuffer
的append()
方法都有一个同步块,锁的是sb
对象,虚拟机经过观察变量sb
并结合逃逸分析会发现它的动态作用域被限制在了concatString
方法内部,其他线程无法访问到它,因此虽然这里有锁,但实际上可以被安全地消除掉,当经过编译器的即时编译后,这段代码会忽略所有的同步措施而直接执行
11.7 锁粗化
在大多数情况下,都是建议将同步块的范围控制得尽可能的小,这么做的目的是为了使得需要同步的操作数量尽可能的少,即使存在锁竞争,等待锁的线程也能尽快地拿到锁
而少数情况下,如果一系列的连续操作都对同一个对象进行反复地加锁解锁,打个比方,上述操作出现在for循环中进行,这种情况下,即使没有锁竞争,也会频繁地进行互斥操作导致不必要的性能损耗
比如上述提到的append
操作例子,进行了多次的append
,而加锁的操作在append
内部中进行,那么虚拟机探测到这一系列零碎的加锁-解锁操作时,就会将加锁同步的范围进行扩展(也就是粗化)到整个操作序列的外部,也就是将同步范围扩展到第一个append
方法和最后一个append
方法之后,这样就只需要加锁-解锁一次