深入理解JVM:内存模型与垃圾收集器详解
Executive Summary
核心观点(金字塔原理)
结论先行: 深入理解JVM需要掌握三个层次:运行时数据区的内存布局、垃圾收集的算法原理、以及各收集器的适用场景与权衡。
支撑论点:
- 运行时数据区包含程序计数器、虚拟机栈、本地方法栈、堆、方法区五大区域,各有不同的线程共享特性
- 垃圾收集通过可达性分析判断对象存活,采用标记-清除、复制、标记-整理等算法实现回收
- 从Serial到G1,收集器演进体现了吞吐量与延迟的平衡艺术
SWOT 分析
| 维度 | 分析 |
|---|---|
| S 优势 | 体系化梳理JVM核心概念,从内存区域到GC算法再到收集器,层层递进 |
| W 劣势 | 部分章节内容较简略,类加载、字节码执行等章节待完善 |
| O 机会 | 作为《深入理解Java虚拟机》的学习笔记,适合系统性学习JVM |
| T 威胁 | 内容基于HotSpot,其他JVM实现(如GraalVM)机制可能不同 |
适用场景
- JVM原理系统性学习与复习
- 理解对象生命周期与内存分配策略
- 收集器选型(Serial/ParNew/CMS/G1)的理论依据
第一部分 走进Java
第1章 走进Java
第二部分 自动内存管理机制
第2章 Java内存区域与内存溢出异常
- 运行时数据区。
- 程序计数器。线程私有,是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器 的指来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。如果线程正在执行Java方法,这个 计数器记录的是正在执行的虚拟机字节码指令的地址。如果执行的是Native方法,这个计数器值则为空(Undefined).此内存区域是唯一一个在Java虚拟机 规范中没有规定任何OutOfMemoryError情况的区域。
- Java虚拟机栈。线程私有,生命周期与线程相同。描述的是Java方法执行的内存模型。 用于存储局部变量表,局部变量表存放了编译期可知的各种基本数据类型(boolean, byte, char, short, int, float, long, double),对象引用(reference类型,它不等同对象本身,可能是一个指向对象起始地址的引用指针,也可能 是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型。
- 本地方法栈。与虚拟机栈发挥的作用类似。
- Java堆。线程共享内存区域。所有的对象实例以及数组都要在堆山分配。Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。
- 方法区。与Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 运行时常量池。它是方法区的一部分。用于存放编译期生成的各种字面量和符号引用,这个部分内容将在类加载后进入方法区的运行时常量池存放。
- -XX:+HeapDumpOnOutOfMemoryError 虚拟机出现内存溢出异常时Dump出当前的内存堆快照转储快照以便事后进行分析。
- 常见堆内存溢出是因为new了大量对象,并无法回收。产生栈溢出常见是大量局部变量,不断迭代。
第3章 垃圾收集器与内存分配策略
- 对象已死吗?
- 引用计数器;高效,有对象间循环引用问题。
- 可达性分析算法;GC Roots(可以作为GCRoot的对象包括:虚拟机栈中引用的对象;方法区中类静态属性引用的对象和常量引用的对象;本地方法栈中JNI引用对象)。
- 引用分类;四类:
- 强引用就是指在程序代码中普遍存在的类似”Object object = new Object();”这类引用,只要强引用还在,垃圾收集器永远不会回收掉被引用的对象。
- 软引用用来描述一些还有用但并非必须的对象。
- 弱引用也是用来描述非必需对象的,但它的强度比软引用更弱一些,被弱引用用关联的对象只能生存到下一次垃圾收集发生之前。
- 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。设置为它的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
- 生存还是死亡. 一个对象真正的回收必须要经过两个标记过程。1是否重载finalize()它只会被调用一次。2是否还存在与存活的对象之间的引用链。
- 回收方法区;无用类的判定:该类所有的实例都已经被收回,也就是Java堆中不存在该类的任何实例。加载该类的ClassLoader已经被收回。该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
- 垃圾收集算法
- 标记-清除算法:标记和清除两个阶段,效率低,产生大量不连续的内存碎片,若此时需要分配大对象不得不提前出发一次垃圾收集动作。
- 复制算法:将内存按容量大小分大小相同的两块,每次只用一块,将存活的放到另一块,然后清除上一块。简单高效,代价就是将内存缩小为原来的一半。新生代采用这种算法,因为新生代对象”朝生夕死”,所以并不需要按照1:1的比例划分内存空间,分一块较大的Eden空间和两块较小的Survivor空间,每次只使用Eden和其中一块Survivor。
- 标记-整理算法:对象存活较多时效率低。
- 分代收集算法。
- HotSpot的算法实现。GC分析时需要确保”一致性”指的就是整个分析期间整个系统”Stop The World”。
- Serial收集器:单线程意味着只会使用一个CPU或一条收集器线程去完成垃圾收集工作,会出现”Stop The World”的情况。简单高效,没有线程切换的开销,新生代收集器。运行client模式下比较好。
- ParNew收集器:是Serial收集器的多线程版本,一起特性几乎与Serial一样。运行在server模式下比较好,也是新生代收集器,目前只有它与Serial可以与CMS收集器配合工作。
- ParallelScavenge收集器:新生代收集器,复制算法,并行的多线程收集器。关注点是吞吐量即CPU用于运行用户代码的时间与CPU总消耗时间的比值。比如虚拟机执行用时共100min,其中GC用时1min则吞吐量99%。可以高效率的利用CPU时间。-XX:MaxGCPauseMillis;-XX:GCTimeRatio这两个参数可以做调整。具有自动调节策略。
- Serial Old收集器:老年代版本,单线程收集器,采用标记-整理算法,主要意义是在于给client模式下的虚拟机使用。可以在Server模式下使用。
- parallel Old收集器:老年代,多线程,标记-整理。
- CMS(Concurrent Mark Sweep)收集器。以获取最短回收停顿时间为目标的收集器。重视响应速度,以带给用户更好的体验。基于标记-清除算法。
- 初始标记 STW
- 并发标记
- 重新标记 STW
- 并发清除
- 缺点:对CPU资源非常敏感,并发回收时要求不少于25%的CPU资源。无法处理浮动垃圾。由于采用标记-清除算法所以继承了标记-清除算法的缺点。
- G1(Grabage-First):并行与并发,分代收集,空间整合,可预测停顿。将整个堆划分为多个大小相等的独立区域Region。当Region之间的对象存在引用时, 虚拟机使用Remembered Set来避免全堆扫描,G1中每个Region都有一个与之对应的Remembered Set。可以理解为是把对其他Region的扫描在产生引用时就放到Remembered Set中 当GC时就直接从Remembered Set中获取引用从而避免全堆扫描。浮动对象的处理是暂存到Remembered Set Logs中在最终标记阶段需要将logs的数据合并到Remembered Set中。
第4章 虚拟机性能监控与故障处理工具
- Sun JDK 监控和故障处理工具
| 名称 | 主要作用 |
|---|---|
| jps | JVM Process Status Tool,显示指定系统内所有的HotSpot虚拟机进程 |
| jstat | JVM Statistics Monitoring Tool, 用于收集HotSpot虚拟机各方面的运行数据 |
| jinfo | Configuration Info for Java,显示虚拟机配置信息 |
| jmap | Memory Map for Java,生成虚拟机的内存转储快照(heapdump文件) |
| jhat | JVM Heap Dump Browser,用于分析heapdump文件,它会建立一个HTTP/HTML服务器,让用户可以在浏览器上查看分析结果 |
| jstack | Stack Trace for Java,显示虚拟机的线程快照 |
- jmap -dump:format=b,file=abc.bin pid 用于生成dump快照文件
第5章 调优案例分析与实战
- 堆外内存也可能造成溢出异常。包括:Direct Memory,可通过-XX:MaxDirectMemorySize调整大小;线程堆栈,可通过-Xss调整;Socket缓存区;JNI代码,如果代码中使用JNI调用本地库,那么本地库使用的内存也不在堆中。虚拟机和GC。
- 高性能硬件上的程序部署策略。
- 集群间同步导致的内存溢出。
- 服务器JVM进程崩溃。
- 不恰当的数据结构导致内存占用过大。
- 编译时间和类加载时间的优化
- 调整内存设置控制垃圾收集器频率。
- 选择收集器降低延迟
第三部分 虚拟机执行子系统
第6章 类文件结构
- Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑排列。文件头的魔数为
0xCAFEBABE,紧接着是次版本号(Minor Version)和主版本号(Major Version),用于标识Class文件的版本。 - 常量池(Constant Pool)是Class文件中的资源仓库,主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。符号引用包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。常量池中每一项常量都是一个表,共有14种不同的表结构(JDK 7时代)。
- 访问标志(Access Flags)用于识别类或接口的访问信息,例如这个Class是类还是接口、是否定义为
public、是否定义为abstract、如果是类是否被声明为final等。 - 类索引(This Class)、父类索引(Super Class)和接口索引集合(Interfaces)用来确定这个类的继承关系。类索引和父类索引各自指向一个
CONSTANT_Class_info的类描述符常量。 - 字段表集合(Fields)用于描述接口或类中声明的变量,包括类级变量和实例变量,但不包括方法内部声明的局部变量。字段表包含访问标志、名称索引、描述符索引和属性表集合。
- 方法表集合(Methods)与字段表结构类似,包含访问标志、名称索引、描述符索引和属性表集合。方法中的Java代码经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为
Code的属性里。 - 属性表集合(Attributes)是Class文件格式中最具扩展性的数据项。常见属性包括
Code(方法体中的代码)、ConstantValue(final关键字定义的常量值)、Exceptions(方法抛出的异常)、LineNumberTable(源码行号与字节码指令的对应关系)、LocalVariableTable(局部变量描述)等。
第7章 虚拟机类加载机制
- 类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段。其中验证、准备、解析三个阶段统称为连接(Linking)。
- 加载阶段需要完成三件事:通过类的全限定名获取定义此类的二进制字节流;将字节流所代表的静态存储结构转化为方法区的运行时数据结构;在内存中生成一个代表这个类的
java.lang.Class对象,作为方法区这个类的各种数据的访问入口。 - 验证阶段是连接的第一步,目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求。主要包括文件格式验证(如是否以
0xCAFEBABE开头)、元数据验证(语义分析)、字节码验证(数据流和控制流分析)和符号引用验证。 - 准备阶段是正式为类变量(
static修饰的变量)分配内存并设置类变量初始零值的阶段。注意此时进行内存分配的仅包括类变量,不包括实例变量。例如public static int value = 123;在准备阶段后value的值为0而非123,赋值为123的动作在初始化阶段的<clinit>()方法中执行。但如果是public static final int value = 123;则在准备阶段就会被赋值为123。 - 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用是用一组符号来描述所引用的目标,直接引用则是直接指向目标的指针、相对偏移量或间接定位到目标的句柄。
- 初始化阶段是执行类构造器
<clinit>()方法的过程。JVM规范严格规定了有且只有五种情况必须立即对类进行初始化:遇到new、getstatic、putstatic或invokestatic字节码指令时;使用java.lang.reflect进行反射调用时;初始化一个类时发现父类还未初始化;虚拟机启动时主类;JDK 7的动态语言支持中MethodHandle解析结果为特定方法句柄时。 - 类加载器采用双亲委派模型(Parents Delegation Model),从JVM角度分为启动类加载器(Bootstrap ClassLoader,由C++实现)和其他所有类加载器(由Java实现,继承自
java.lang.ClassLoader)。从开发者角度分为三层:启动类加载器(加载<JAVA_HOME>/lib目录下的类库)、扩展类加载器(Extension ClassLoader,加载<JAVA_HOME>/lib/ext目录)、应用程序类加载器(Application ClassLoader,加载用户类路径ClassPath上的类库)。 - 双亲委派模型的工作过程:如果一个类加载器收到类加载请求,它首先不会自己尝试加载,而是把请求委派给父类加载器去完成,每一层都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中。只有当父加载器反馈自己无法完成加载请求时,子加载器才会尝试自己去加载。这种模型保证了Java类型体系的稳定性,例如
java.lang.Object无论被哪个类加载器加载,最终都委派给启动类加载器,从而保证了Object类在各个类加载器环境中都是同一个类。
第8章 虚拟机字节码执行引擎
- 运行时栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机栈的栈元素。栈帧中存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
- 局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。容量以变量槽(Variable Slot)为最小单位,一个Slot可以存放一个32位以内的数据类型(
boolean、byte、char、short、int、float、reference、returnAddress),long和double占用两个Slot。如果执行的是实例方法(非static),局部变量表中第0位索引的Slot默认用于传递方法所属对象实例的引用(this)。 - 操作数栈(Operand Stack)是一个后入先出栈,最大深度在编译时写入
Code属性的max_stacks中。方法执行过程中各种字节码指令往操作数栈中写入和提取内容,即入栈和出栈操作。例如整数加法指令iadd在运行时将栈顶两个int值出栈相加后将结果入栈。 - 动态连接(Dynamic Linking):每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。字节码中方法调用指令以常量池中指向方法的符号引用作为参数,这些符号引用一部分在类加载阶段或第一次使用时转化为直接引用(静态解析),另一部分在每次运行期间转化为直接引用(动态连接)。
- Java虚拟机提供了五条方法调用字节码指令:
invokestatic(调用静态方法)、invokespecial(调用实例构造器<init>方法、私有方法和父类方法)、invokevirtual(调用所有的虚方法)、invokeinterface(调用接口方法,在运行时确定实现该接口的对象)、invokedynamic(先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,是JDK 7新增的指令,为动态类型语言支持而生)。 - 方法分派分为静态分派和动态分派。静态分派发生在编译阶段,典型应用是方法重载(Overload),编译器根据参数的静态类型(外观类型)来确定方法版本。动态分派与多态性中的方法重写(Override)密切相关,
invokevirtual指令在运行时根据对象的实际类型来确定方法版本,这就是Java语言中方法重写的本质。 - 虚拟机动态分派的实现:为了提高性能,虚拟机通常会在类的方法区建立一个虚方法表(Virtual Method Table,简称vtable),使用虚方法表索引来替代元数据查找以提高性能。vtable中存放着各个方法的实际入口地址,如果子类没有重写父类方法,那么子类vtable中该方法的地址与父类vtable中该方法的地址一致,都指向父类的实现。
第9章 类加载及执行子系统的案例与实战
- Tomcat的类加载器架构:作为Web服务器需要解决多个Web应用之间类库隔离、共享以及与服务器自身类库隔离的问题。Tomcat自定义了多个类加载器,包括Common ClassLoader(加载
/common/*,对Tomcat和所有Web应用可见)、Catalina ClassLoader(加载/server/*,仅对Tomcat可见)、Shared ClassLoader(加载/shared/*,对所有Web应用可见)以及每个Web应用私有的WebApp ClassLoader(加载/WebApp/WEB-INF/*,仅对当前应用可见)。 - OSGi(Open Service Gateway Initiative)实现了模块化热部署的关键是其自定义的类加载器机制。每个Bundle(模块)都有自己独立的类加载器,当需要替换一个Bundle时,就把Bundle连同它的类加载器一起替换掉,以实现代码的热替换。OSGi的类加载器不再是双亲委派模型中的树状结构,而是一种更为复杂的网状结构。
- 字节码生成技术:Java中常用的字节码操作类库包括ASM和CGLib。ASM是一个轻量级的字节码操作框架,可以直接生成
.class字节码文件,也可以在类被加载之前动态修改类的行为。CGLib(Code Generation Library)底层基于ASM,提供了更高层次的API,广泛应用于AOP框架(如Spring AOP)中生成代理类。 - 动态代理:JDK动态代理基于
java.lang.reflect.Proxy和InvocationHandler接口实现,只能对实现了接口的类生成代理。CGLib动态代理则通过生成目标类的子类来实现代理,因此可以代理没有实现接口的类,但无法代理final类和final方法。Spring AOP中如果目标对象实现了接口则默认使用JDK动态代理,否则使用CGLib代理。 - Retrotranslator等字节码转换工具可以把JDK高版本编译的Class文件转换为能在低版本JDK上部署的版本,其原理是对Class文件进行字节码层面的转换,将高版本特有的API调用替换为低版本兼容的实现。
第四部分 程序编译与代码优化
第10章 早期(编译期)优化
- Javac编译器的编译过程大致可以分为三个步骤:解析与填充符号表过程(词法分析、语法分析,生成抽象语法树AST)、插入式注解处理器的注解处理过程、语义分析与字节码生成过程(标注检查、数据流及控制流分析、解语法糖、字节码生成)。
- 语法糖(Syntactic Sugar)是指在计算机语言中添加的某种语法,对语言的功能没有影响,但方便程序员使用。Java中常见的语法糖包括泛型擦除、自动装箱/拆箱、
foreach循环、变长参数、条件编译等,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖(Desugar)。 - Java泛型采用类型擦除(Type Erasure)的方式实现,即泛型信息只存在于编译阶段,在编译后的字节码中所有泛型类型都被替换为原始类型(Raw Type),并在相应位置插入强制转型代码。例如
List<String>和List<Integer>在运行时实际上是同一个类型List。这与C#的具现化式泛型(每个泛型类型在运行时都是独立的类型)有本质区别。 - 自动装箱/拆箱:编译器会自动在基本类型与对应的包装类型之间进行转换。需要注意
Integer.valueOf()在-128至127范围内会使用缓存对象,因此Integer a = 127; Integer b = 127;时a == b为true,而Integer a = 128; Integer b = 128;时a == b为false。包装类型的equals()方法不处理类型转换,所以new Integer(1).equals(new Long(1))返回false。 foreach循环(增强for循环)在编译后会被转换为迭代器(Iterator)模式的遍历代码(对于实现了Iterable接口的集合),或者转换为带下标访问的普通for循环(对于数组)。- 条件编译:Java语言可以使用条件为常量的
if语句实现条件编译。编译器在编译阶段会将分支中不成立的代码块消除(Dead Code Elimination),例如if (true) { ... } else { ... }中else块的代码不会被编译进Class文件中。这是Java语言实现条件编译的唯一方式。
第11章 晚期(运行期)优化
- HotSpot虚拟机内置了两个即时编译器(JIT Compiler):Client Compiler(简称C1编译器)和Server Compiler(简称C2编译器)。C1编译器关注编译速度,进行简单可靠的优化;C2编译器关注编译质量,进行更耗时但更高效的深度优化。
- 分层编译(Tiered Compilation)策略:第0层为程序解释执行,不开启性能监控;第1层为C1编译,将字节码编译为本地代码,进行简单可靠的优化;第2层为C2编译,将字节码编译为本地代码,会启用一些编译耗时较长的优化。分层编译可以在程序启动速度与运行效率之间取得平衡。
- 热点探测(Hot Spot Detection):HotSpot虚拟机采用基于计数器的热点探测方法,为每个方法建立两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,统计循环体代码执行次数)。当计数器超过阈值时触发JIT编译。方法调用计数器默认阈值在Client模式下为1500次,Server模式下为10000次(可通过
-XX:CompileThreshold调整)。 - 方法内联(Method Inlining)是编译器最重要的优化手段之一,它把目标方法的代码”复制”到发起调用的方法之中,消除方法调用的开销,同时为后续优化建立良好的基础。只有非虚方法(
private、static、final方法及构造器)才能直接内联,对于虚方法则采用类型继承关系分析(CHA)等技术尝试进行守护内联(Guarded Inlining)。 - 逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术,它分析对象的动态作用域:如果一个对象在方法中被定义后,不会被外部方法引用(方法逃逸)或被外部线程访问(线程逃逸),则可以进行以下优化:栈上分配(Stack Allocation,对象直接在栈上分配内存,方法结束自动销毁)、标量替换(Scalar Replacement,将对象拆散为基本类型的局部变量)、同步消除(Lock Elimination,消除不必要的同步操作)。
- 公共子表达式消除(Common Subexpression Elimination):如果一个表达式之前已经计算过且各变量的值没有变化,那么后续出现这个表达式时就不必重复计算,直接使用之前计算的结果。数组边界检查消除(Array Bounds Checking Elimination):在循环中对数组的访问如果编译器能判定不会越界,则可以消除每次访问时的边界检查。
第五部分 高效并发
第12章 Java内存模型与线程
- Java内存模型(Java Memory Model,JMM)的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。JMM规定所有变量都存储在主内存(Main Memory)中,每条线程有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作必须在工作内存中进行,不能直接读写主内存。
- JMM定义了八种内存间交互操作:
lock(锁定,作用于主内存变量)、unlock(解锁)、read(读取,从主内存传输到工作内存)、load(载入,将read的值放入工作内存的变量副本)、use(使用,将工作内存变量值传递给执行引擎)、assign(赋值,将执行引擎的值赋给工作内存变量)、store(存储,将工作内存变量值传送到主内存)、write(写入,将store的值放入主内存变量)。这八种操作必须满足一系列规则以保证内存访问的正确性。 volatile关键字具有两层语义:保证变量对所有线程的可见性(当一条线程修改了变量值,新值对其他线程来说是立即可见的)和禁止指令重排序优化。但volatile并不能保证原子性,例如volatile int count; count++在多线程下仍然不安全,因为count++包含读取、加一、写回三个操作。volatile的适用场景:运算结果并不依赖变量的当前值,或者能够确保只有单一线程修改变量的值;变量不需要与其他状态变量共同参与不变约束。- 先行发生原则(Happens-Before)是JMM中定义的两项操作之间的偏序关系。如果操作A先行发生于操作B,则A的影响能被B观察到。JMM内置的happens-before规则包括:程序次序规则(同一线程内按代码顺序)、管程锁定规则(
unlock先行发生于后续的lock)、volatile变量规则(写操作先行发生于后续读操作)、线程启动规则(Thread.start()先行发生于该线程的每个动作)、线程终止规则、线程中断规则、对象终结规则和传递性。 - Java线程的实现方式:主流的操作系统上Java线程采用1:1的内核线程实现(即每个Java线程直接映射到一个操作系统原生线程)。线程的调度由操作系统内核完成,线程的创建、销毁和切换都需要在用户态和内核态之间转换,代价相对较高。这也是为什么Java虚拟线程(Virtual Thread,JDK 19预览/JDK 21正式引入)有重大意义,它采用M:N的用户态线程模型。
- Java线程有六种状态:新建(New)、运行(Runnable,包含操作系统的Running和Ready状态)、无限期等待(Waiting,等待其他线程显式唤醒,如
Object.wait()、Thread.join()、LockSupport.park())、限期等待(Timed Waiting,在一定时间后由系统自动唤醒,如Thread.sleep()、设置了超时的Object.wait())、阻塞(Blocked,等待获取排他锁)、结束(Terminated)。
第13章 线程安全与锁优化
- 线程安全的强度由强至弱可以分为五个层次:不可变(Immutable,如
final修饰的基本类型、String、Long等不可变对象天然线程安全)、绝对线程安全(在任何使用环境下都不需要额外的同步措施,Java API中标注为线程安全的类大多不是绝对线程安全)、相对线程安全(对对象的单次操作是线程安全的,如Vector、HashTable、Collections.synchronizedXxx()包装的集合)、线程兼容(对象本身不是线程安全的但可以通过调用端正确使用同步手段保证,如ArrayList、HashMap)、线程对立(不管是否采取同步措施都无法并发使用,如Thread.suspend()和Thread.resume())。 - 互斥同步(Mutual Exclusion & Synchronization)是最常见的并发正确性保障手段。
synchronized关键字经过编译后会在同步块前后分别形成monitorenter和monitorexit两个字节码指令,它是一个可重入锁,在已经持有锁的线程再次请求锁时会成功。java.util.concurrent.locks.ReentrantLock与synchronized类似,但增加了等待可中断、可实现公平锁、锁可以绑定多个条件等高级功能。在JDK 6之后,synchronized与ReentrantLock的性能已经基本持平。 - 非阻塞同步(Non-Blocking Synchronization)是一种基于冲突检测的乐观并发策略,先进行操作,如果没有其他线程争用共享数据就操作成功;如果有争用则采取补偿措施(通常是不断重试直到成功)。这种策略依赖硬件指令集的支持,如CAS(Compare-And-Swap)操作。Java中的
java.util.concurrent.atomic包中的原子类(如AtomicInteger)就是基于CAS实现的。CAS操作存在ABA问题,可使用AtomicStampedReference通过版本号来解决。 - 自旋锁(Spin Lock)与自适应自旋:互斥同步中线程阻塞和唤醒会带来性能压力,如果锁被占用的时间很短,则让等待线程执行一个忙循环(自旋)等待而非挂起。JDK 6中引入了自适应自旋(Adaptive Spinning),自旋时间不再固定,由虚拟机根据前一次在同一锁上的自旋时间和锁的拥有者的状态来决定。
- 锁消除(Lock Elimination):即时编译器在运行时,对一些代码上要求同步但被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析,如果判断一段代码中堆上的所有数据都不会逃逸出去被其他线程访问到,就可以把它们当作栈上数据对待,认为是线程私有的,同步加锁自然无须进行。
- 锁粗化(Lock Coarsening):如果虚拟机探测到有一串零碎的操作都对同一个对象加锁(例如循环体内反复加锁解锁),将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,这样只需加锁一次。
- 轻量级锁(Lightweight Locking):在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量产生的性能消耗。轻量级锁通过CAS操作将对象头的Mark Word更新为指向锁记录(Lock Record)的指针来实现加锁。如果存在两条以上的线程竞争同一个锁,轻量级锁会膨胀为重量级锁。
- 偏向锁(Biased Locking):目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。偏向锁会偏向于第一个获取它的线程,如果在接下来的执行过程中该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。当有另外一个线程尝试获取这个锁时,偏向模式宣告结束,根据锁对象目前是否处于被锁定的状态撤销偏向后恢复到未锁定或轻量级锁定的状态。锁升级路径为:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,且锁只能升级不能降级。