《深入理解JAVA虚拟机笔记》并发与线程安全原理
除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能对输入代码进行乱序执行(Out-Of-Order Execution)优化。处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果一致,但不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此,如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java 虚拟机的 JIT 编译器中也有类似的指令重排优化。
主内存与工作内存
Java 内存模型规定了所有的变量都存储在主内存,每条线程都有自己单独的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存的副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
线程、主内存、工作内存三者的交互关系如图所示:
此处请读者注意区分概念:如果局部变量是一个reference
类型,它引用的对象在Java堆中可被各个线程共享,但是reference
本身在Java栈的局部变量表中是线程私有的。
有部分读者会对这段描述中的“副本”提出疑问,如“假设线程中访问一个10MB
大小的对象,也会把这10MB
的内存复制一份出来吗?”,事实上并不会如此,这个对象的引用、对象中某个在线程访问到的字段是有可能被复制的,但不会有虚拟机把整个对象复制一次。
根据《Java虚拟机规范》的约定,volatile
变量依然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般,因此这里的描述对于volatile
也并不存在例外。
这里所讲的主内存、工作内存与第 2 章所讲的 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有任何关系的。如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。
除了实例数据,Java 堆还保存了对象的其他信息,对于 HotSpot 虚拟机来讲,有 Mark Word(存储对象哈希码、GC标志、GC年龄、同步锁等信息)、Klass Point(指向存储类型元数据的指针)及一些用于字节对齐补白的填充数据(如果实例数据刚好满足8字节对齐,则可以不存在补白)。
内存间交互操作
关于主内存与工作内存间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的细节,Java 内存模型定义了以下 8 种操作来完成。虚拟机实现时必须保证下面的每一种操作都是原子的、不可再分的。
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
基于理解难度和严谨性考虑,最新的 JSR-133 文档中,已经放弃了采用这 8 种操作去定义 Java 内存模型的访问协议,缩减为 4 种(仅是描述方式改变了,Java 内存模型并没有改变)。
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read
和load
操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store
和write
操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。也就是说read
与load
之间、store
与write
之间是可插入其他指令的。
除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起会写了但主内存不接受的情况出现。
- 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存中。
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是一个变量实施use、store操作之前,必须先执行assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以别同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量事先没有被lock操作锁定,那就不允许对他执行unlock操作,也不允许去unlock一个被其他线程锁定的变量
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store,write操作)。
这8种内存访问操作以及上述规则规定,再加上转为针对volatile
的一些特殊规定,就已经能准确的描述出Java程序中哪些内存访问操作在并发下才是安全的。这种定义相当严谨,但也是极为繁琐,实现起来更是无比麻烦。后来Java设计团队大概也意识到了这个问题,将Java内存模型的操作简化为read
、write
、lock
和unlock
四种,但这只是语言描述上的等价化简,Java 内存模型的基础设计并未改变,即使是这四种操作,对于普通用户来说阅读使用起来仍然不方便。不过读者对此无需过分担忧,除了进行虚拟机开发的团队外,大概没有其他开发人员会以这种方式来思考并发问题,我们只需要理解Java内存模型的定义即可。
对于 volatile 型变量的特殊规则
当一个变量被定义成volatile
之后,它将具备两项特性:第一项是保证此变量对所以线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后对主内存进行读取操作,新变量值才会对线程B可见。
关于volatile变量的可见性,经常会被开发人员误解,他们会误以为下面的描述是正确的: “volatile变量对所以线程是立即可见的,对volatile变量所有的写操作都能立即反映到其他线程之中。换句话说,volatile变量的运算在并发下是线程安全的” 这句话的论据部分并没有错,但是由其论据并不能得出 “基于volatile变量的运算在并发下是线程安全的” 这样的结论。volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是 Java 里面的运算操作符并非原子操作,这导致volatile
变量的运算在并发下一样是不安全的。
由于 volatile
变量值只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized
、java.util.concurrent
中的锁或原子类)来保证原子性:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。
而在如下代码所示的这类场景中就很适合使用volatile
变量来控制并发,当shutdown()
方法被调用时,能保证所有线程中执行的doWork()
方法都立即停下来。
// 代码清单 12-3 volatile 的使用场景
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// 代码的业务逻辑
}
}
使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
上面描述仍然比较拗口难明,我们继续通过一个例子来看看为何指令重排序会干扰程序的并发执行:
// 代码清单 12—4 指令重排序
Map configOptions;
char[] configText;
// 此变量必须定义为 volatile
volatile boolean initialized = false;
// 假设以下代码在线程 A 中执行
// 模拟读取配置信息,当读取完成后
// 将 initialized 设置为 true,通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText,configOptions);
initialized = true;
// 假设以下代码在线程 B 中执行
// 等待 initialized 为 true,代表线程 A 已经把配置信息初始化完成
while (!initialized) {
sleep();
}
// 使用线程 A 中初始化好的配置信息
doSomethingWithConfig();
以上是一段伪代码,其中描述的场景十分常见,只是我们在处理配置文件时一般不会出现并发而已。 如果定义initialized
变量时没有使用volatile
修饰,就可能会由于指令重排序的优化,导致位于线程A中最后一句的代码“initialized=true
”被提前执行(这里虽然使用Java作为伪代码,但所指的重排序优化是机器级的优化操作,提前执行是指这句话对应的汇编代码被提前执行),这样在线程B中使用配置信息的代码就可能出现错误,而volatile
关键字则可以避免此类情况的发生。
// 代码清单 12—5 DCL 单例模式
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) {
Singleton.getInstance();
}
}
编译后,这段代码对instance
变量赋值部分如下所示:
// 代码清单 12—6 对 instance 变量赋值
0x01a3de0f: mov $0x3375cdb0, %esi ;...beb0cd75 33
; {oop('Singleton')}
0x01a3de14: mov %eax, 0x150(%esi) ;...89865001 0000
0x01a3dela: shr $0x9, %esi ;...clee09
0x01a3deld: movb $0x0, 0x1104800(%esi) ;...c6860048 100100
0x01a3de24: lock addl $0x0, (%esp) ;...f0830424 00
;*putstatic instance
; - Singleton::getInstance@24
通过对比发现,关键变化在于有volatile
修饰的变量,赋值后(前面mov%eax, 0x150 (%esi)
这句便是赋值操作)多执行了一个“lock addl $0x0, (%esp)
”操作,这个操作相当于一个内存屏障(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;但如果有两个或更多CPU访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。
这句指令中的 “addl $0x0, (%esp)
”(把ESP
寄存器的值加0
)显然是一个空操作(采用这个空操作而不是空操作指令nop
是因为IA32手册规定lock
前缀不允许配合nop
指令使用),关键在于 lock
前缀,查询IA32手册,它的作用是使得本CPU的缓存写入了内存,该写入动作也会引起别的CPU或者内核无效化(Invalidate)其缓存,这种操作相当于对缓存中的变量做了一次前面介绍Java内存模式中所说的“store和write”操作[2]。 所以通过这样一个空操作,可让前面volatile
变量的修改对其他CPU立即可见。
那为何说它禁止指令重排序呢?从硬件架构上讲,指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。 但并不是说指令任意重排,CPU需要能正确处理指令依赖情况以保障程序能得出正确的执行结果。 譬如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指令2是有依赖的,它们之间的顺序不能重排——(A+10)*2
与A*2+10
显然不相等,但指令3可以重排到指令 1、 2 之前或者中间,只要保证CPU执行后面依赖到A、 B值的操作时能获取到正确的A和B值即可。 所以在本内CPU中,重排序看起来依然是有序的。 因此,lock addl $0x0, (%esp)
指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。
针对 long 和 double 型变量的特殊规则
Java内存模型要求 lock、unlock、read、load、use、assign、store、write 这八种操作都具有原子性,但是对于64位的数据类型(long
和double
),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile
修饰的64位数据读写操作划分为2次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的read、load、store、write
这4个操作的原子性,这就是所谓的“long
和double
的非原子性协定”(Non-Atomic Treatment of double and long V ariables)。
如果有多个线程共享一个并未声明为volatile
的long
或double
类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变量”的数值。不过这种读取到“半个变量”的情况是非常罕见的,经过实际测试,在目前主流平台下商用的64位Java虚拟机中并不会出现非原子性访问行为,但是对于 32 位的 Java 虚拟机,譬如比较常用的32位x86平台下的HotSpot虚拟机,对long类型的数据确实存在非原子性访问的风险。
从 JDK 9 起,HotSpot增加了一个实验性的参数-XX:+AlwaysAtomicAccesses(这是JEP 188对Java内存模型更新的一部分内容)来约束虚拟机对所有数据类型进行原子性的访问。而针对double
类型,由于现代中央处理器中一般都包含专门用于处理浮点数据的浮点运算器(Floating Point Unit,FPU),用来专门处理单、双精度的浮点数据,所以哪怕是32位虚拟机中通常也不会出现非原子性访问的问题,实际测试也证实了这一点。一般认为,在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码时一般不需要因为这个原因刻意把用到的long
和double
变量专门声明为volatile
。
一句话就是 long
和double
的读写,在主流64位机器上都是原子性,但是在32位机器上不保证原子性! 虽然volatile
只能保证可见性不能保证原子性,但用volatile
修饰long
和double
可以保证其操作原子性!(Java语言规范规定的)
原子性、可见性与有序性
Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,我们逐个来看一下哪些操作实现了这三个特性。
1. 原子性(Atomicity)
由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store
和write
这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是long
和double
的非原子性协定,大家只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)
如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java内存模型还提供了lock
和unlock
操作来满足这种需求,尽管虚拟机未把lock
和unlock
操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter
和monitorexit
来隐式地使用这两个操作。这两个字节码指令反映到Java代码中就是同步块——synchronized
关键字,因此在synchronized
块之间的操作也具备原子性。
2. 可见性(Visibility)
可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。上文在讲解volatile
变量的时候我们已详细讨论过这一点。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile
变量都是如此。普通变量与volatile
变量的区别是,volatile
的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile
保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
除了volatile
之外,Java还有两个关键字能实现可见性,它们是synchronized
和final
。
- 同步块的可见性是由“对一个变量执行
unlock
操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的。 - 而
final
关键字的可见性是指:被final
修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this
”的引用传递出去(this
引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final
字段的值。
3. 有序性(Ordering)
Java内存模型的有序性在前面讲解volatile时也比较详细地讨论过了,Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
Java语言提供了volatile
和synchronized
两个关键字来保证线程之间操作的有序性,volatile
关键字本身就包含了禁止指令重排序的语义,而synchronized
则是由“一个变量在同一个时刻只允许一条线程对其进行lock
操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
先行发生原则
下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。
- 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 管程锁定规则(Monitor Lock Rule):一个
unlock
操作先行发生于后面对同一个锁的lock
操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。 - volatile变量规则(Volatile Variable Rule):对一个
volatile
变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后。 - 线程启动规则(Thread Start Rule):Thread对象的
start()
方法先行发生于此线程的每一个动作 - 线程中断规则(Thread Interruption Rule):对线程
interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()
方法检测到是否有中断发生。 - 线程终止规则(Thread Termination Rule) : 线程中的所有操作都先行发生于对此线程的终止检测, 我们可以通过
Thread::join()
方法是否结束、Thread::isAlive()
的返回值等手段检测线程是否已经终止执行; - 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的
finalize()
方法的开始。 - 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
Java语言无须任何同步手段保障就能成立的先行发生规则有且只有上面这些,下面演示一下如何使用这些规则去判定操作间是否具备顺序性,对于读写共享变量的操作来说,就是线程是否安全。读者还可以从下面这个例子中感受一下“时间上的先后顺序”与“先行发生”之间有什么不同。演示例子如代码清单12-9所示。
// 代码清单 12—9 先行发生原则示例2
private int value = 0;
pubilc void setValue(int value) {
this.value= value;
}
public int getValue() {
return value;
}
代码清单12-9中显示的是一组再普通不过的getter/setter
方法,假设存在线程A和B,线程A先(时间上的先后)调用了setValue(1)
,然后线程B调用了同一个对象的getValue()
,那么线程B收到的返回值是什么?
我们依次分析一下先行发生原则中的各项规则。由于两个方法分别由线程A和B调用,不在一个线程中,所以程序次序规则在这里不适用;由于没有同步块,自然就不会发生lock
和unlock
操作,所以管程锁定规则不适用;由于value
变量没有被volatile
关键字修饰,所以volatile
变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定,尽管线程A在操作时间上先于线程B,但是无法确定线程B中getValue()
方法的返回结果,换句话说,这里面的操作不是线程安全的。
那怎么修复这个问题呢?我们至少有两种比较简单的方案可以选择:要么把getter/setter
方法都定义为synchronized
方法,这样就可以套用管程锁定规则;要么把value
定义为volatile
变量,由于setter
方法对value
的修改不依赖value
的原值,满足volatile
关键字使用场景,这样就可以套用volatile
变量规则来实现先行发生关系。
通过上面的例子,我们可以得出结论:一个操作“时间上的先发生”不代表这个操作会是“先行发生”。那如果一个操作“先行发生”,是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,这个推论也是不成立的。一个典型的例子就是多次提到的“指令重排序”,演示例子如代码清单12-10所示。
// 代码清单 12—10 先行发生原则示例3
// 以下操作在同一个线程中执行
int i = 1;
int j = 2;
代码清单12-10所示的两条赋值语句在同一个线程之中,根据程序次序规则,“int i=1
”的操作先行发生于“int j=2
”,但是“int j=2
”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程之中没有办法感知到这一点。
上面两个例子综合起来证明了一个结论:时间先后顺序与先行发生原则之间基本没有因果关系,所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。
线程的实现
我们注意到Thread类与大部分的Java类库API有着显著差别,它的所有关键方法都被声明为Native。在Java类库API中,一个Native方法往往就意味着这个方法没有使用或无法使用平台无关的手段来实现(当然也可能是为了执行效率而使用 Native 方法,不过通常最高效的手段也是平台相关的手段)。
实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)。
1. 内核线程实现
使用内核线程实现的方式也被称为 1 : 1 实现。内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核(Multi-Threads Kernel)。
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(LightWeight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间 1 : 1 的关系称为一对一的线程模型,如图12-3所示。
由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程在系统调用中被阻塞了,也不会影响整个进程继续工作。
轻量级进程也具有它的局限性:
- 首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。
- 其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
2. 用户线程实现
使用用户线程实现的方式被称为 1 : N 实现。广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程(User Thread,UT)的一种,因此从这个定义上看,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会受到限制,并不具备通常意义上的用户线程的优点。
而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间 1 : N 的关系称为一对多的线程模型,如图12-4所示。
用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至有些是不可能实现的。因为使用用户线程实现的程序通常都比较复杂,除了有明确的需求外(譬如以前在不支持多线程的操作系统中的多线程程序、需要支持大规模线程数量的应用),一般的应用程序都不倾向使用用户线程。Java、Ruby等语言都曾经使用过用户线程,最终又都放弃了使用它。但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang、Erlang等,使得用户线程的使用率有所回升。
3. 混合实现
线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式,被称为 N : M 实现。在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是 N : M 的关系,如图12-5所示,这种就是多对多的线程模型。
许多 UNIX 系列的操作系统,如 Solaris、HP-UX 等都提供了 N : M 的线程模型实现。在这些操作系统上的应用也相对更容易应用 N : M 的线程模型。
4. Java线程的实现
Java线程如何实现并不受Java虚拟机规范的约束,这是一个与具体虚拟机相关的话题。Java线程在早期的Classic虚拟机上(JDK 1.2以前),是基于一种被称为“绿色线程”(Green Threads)的用户线程实现的,但从JDK 1.3 起,“主流”平台上的“主流”商用 Java 虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用 1:1 的线程模型。
以 HotSpot 为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以 HotSpot 自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。
操作系统支持怎样的线程模型,在很大程度上会影响上面的Java虚拟机的线程是怎样映射的,这一点在不同的平台上很难达成一致,因此《Java虚拟机规范》中才不去限定Java线程需要使用哪种线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编码和运行过程来说,这些差异都是完全透明的。
Java 线程调度
线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式(Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。
如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么线程同步的问题。Lua语言中的“协同例程”就是这类实现。它的坏处也很明显:线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。很久以前的Windows 3.x系统就是使用协同式来实现多进程多任务的,那是相当不稳定的,只要有一个进程坚持不让出处理器执行时间,就可能会导致整个系统崩溃。
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。譬如在Java中,有 Thread::yield() 方法可以主动让出执行时间,但是如果想要主动获取执行时间,线程本身是没有什么办法的。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程甚至整个系统阻塞的问题。Java使用的线程调度方式就是抢占式调度。与前面所说的Windows 3.x的例子相对,在Windows 9x/NT内核中就是使用抢占式来实现多进程的,当一个进程出了问题,我们还可以使用任务管理器把这个进程杀掉,而不至于导致系统崩溃。
虽然说Java线程调度是系统自动完成的,但是我们仍然可以“建议”操作系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点——这项操作是通过设置线程优先级来完成的。Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY
至Thread.MAX_PRIORITY
)。在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。
不过,线程优先级并不是一项稳定的调节手段,很显然因为主流虚拟机上的Java线程是被映射到系统的原生线程上来实现的,所以线程调度最终还是由操作系统说了算。尽管现代的操作系统基本都提供线程优先级的概念,但是并不见得能与Java线程的优先级一一对应,如Solaris中线程有2147483648(2的31次幂)种优先级,但Windows中就只有七种优先级。如果操作系统的优先级比Java线程优先级更多,那问题还比较好处理,中间留出一点空位就是了,但对于比Java线程优先级少的系统,就不得不出现几个线程优先级对应到同一个操作系统优先级的情况了。表12-1显示了Java线程优先级与Windows线程优先级之间的对应关系,Windows平台的虚拟机中使用了除THREAD_PRIORITY_IDLE之外的其余6种线程优先级,因此在Windows下设置线程优先级为1和2、3和4、6和7、8和9的效果是完全相同的。
线程状态转换
Java语言定义了 6 种线程状态,在任意一个时间点中,一个线程只能有且只有其中的一种状态,并且可以通过特定的方法在不同状态之间转换。这6种状态分别是:
- 新建(New):创建后尚未启动的线程处于这种状态。
- 运行(Runnable):包括操作系统线程状态中的 Running 和 Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。
- 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:
- 没有设置
Timeout
参数的Object::wait()
方法; - 没有设置
Timeout
参数的Thread::join()
方法; LockSupport::park()
方法。
- 没有设置
- 限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
Thread::sleep()
方法;- 设置了
Timeout
参数的Object::wait()
方法; - 设置了
Timeout
参数的Thread::join()
方法; LockSupport::parkNanos()
方法;LockSupport::parkUntil()
方法。
- 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
- 结束(Terminated):已终止线程的线程状态,线程已经结束执行。
上述6种状态在遇到特定事件发生的时候将会互相转换,它们的转换关系如下图所示。
不可变
在 Java 语言里面(特指 JDK 5 以后,即 Java 内存模型被修正之后的 Java 语言),不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。
Java语言中,如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final
关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,由于Java语言目前暂时还没有提供值类型的支持,那就需要对象自行保证其行为不会对其状态产生任何影响才行。如果读者没想明白这句话所指的意思,不妨类比 java.lang.String
类的对象实例,它是一个典型的不可变对象,用户调用它的substring()
、replace()
和concat()
这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。
保证对象行为不影响自己状态的途径有很多种,最简单的一种就是把对象里面带有状态的变量都声明为final
,这样在构造函数结束之后,它就是不可变的。例如代码清单13-1中所示的 java.lang.Integer
构造函数,它通过将内部状态变量 value
定义为 final
来保障状态不变。
// 代码清单 13—1 JDK 中 Integer 类的构造函数
/**
* The int value represented by this Integer
*/
private final int value;
/**
* Constructs a new {@code Integer} with the specified primitive integer value.
* @param value the primitive integer value to store in the new instance.
*/
public Integer(int value) {
this.value = value;
}
在 Java 类库 API 中符合不可变要求的类型,除了上面提到的 String
之外,常用的还有枚举类型及 java.lang.Number
的部分子类,如 Long
和 Double
等数值包装类型、BigInteger
和 BigDecimal
等大数据类型。但同为 Number
子类型的原子类 AtomicInteger
和 AtomicLong
则是可变的。
互斥同步
互斥同步(Mutual Exclusion & Synchronization)是一种最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条线程使用(或者是一些,当使用信号量的时候)。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。因此在“互斥同步”这四个字里面,互斥是因,同步是果;互斥是方法,同步是目的。
在 Java 里面,最基本的互斥同步手段就是 synchronized 关键字,这是一种块结构(Block Structured)的同步语法。synchronized 关键字经过 Javac 编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令。这两个字节码指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象。如果 Java 源码中的 synchronized
明确指定了对象参数,那就以这个对象的引用作为 reference
;如果没有明确指定,那将根据 synchronized
修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的 Class 对象来作为线程要持有的锁。
根据《Java虚拟机规范》的要求,在执行 monitorenter 指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行 monitorexit 指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
从功能上看,根据以上《Java虚拟机规范》对 monitorenter
和 monitorexit
的行为描述,我们可以得出两个关于 synchronized
的直接推论,这是使用它时需特别注意的:
- 被 synchronized 修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。
- 被 synchronized 修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。
从执行成本的角度看,持有锁是一个重量级(Heavy-Weight)的操作。在主流 Java 虚拟机实现中,Java 的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。尤其是对于代码特别简单的同步块(譬如被 synchronized
修饰的 getter()
或 setter()
方法),状态转换消耗的时间甚至会比用户代码本身执行的时间还要长。因此才说,synchronized
是 Java 语言中一个重量级的操作,有经验的程序员都只会在确实必要的情况下才使用这种操作。而虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,以避免频繁地切入核心态之中。
重入锁(ReentrantLock) 是 Lock 接口最常见的一种实现,顾名思义,它与 synchronized
一样是可重入的。在基本用法上,ReentrantLock
也与 synchronized
很相似,只是代码写法上稍有区别而已。不过,ReentrantLock
与 synchronized
相比增加了一些高级功能,主要有以下三项:等待可中断、可实现公平锁及锁可以绑定多个条件。
- 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。
- 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。
synchronized
中的锁是非公平的,ReentrantLock
在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock
的性能急剧下降,会明显影响吞吐量。 - 锁绑定多个条件:是指一个
ReentrantLock
对象可以同时绑定多个Condition
对象。在synchronized
中,锁对象的wait()
跟它的notify()
或者notifyAll()
方法配合可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁;而ReentrantLock
则无须这样做,多次调用newCondition()
方法即可。
如果需要使用上述功能,使用 ReentrantLock
是一个很好的选择,那如果是基于性能考虑呢?synchronized
对性能的影响,尤其在 JDK 5 之前是很显著的,为此在 JDK 6 中还专门进行过针对性的优化。当 JDK 6 中加入了大量针对 synchronized
锁的优化措施之后,相同的测试中就发现 synchronized
与 ReentrantLock
的性能基本上能够持平。
根据上面的讨论,ReentrantLock
在功能上是 synchronized
的超集,在性能上又至少不会弱于 synchronized
,那 synchronized
修饰符是否应该被直接抛弃,不再使用了呢?当然不是,基于以下理由,仍然推荐在 synchronized
与 ReentrantLock
都可满足需要时优先使用 synchronized
:
synchronized
是在 Java 语法层面的同步,足够清晰,也足够简单。每个 Java 程序员都熟悉synchronized
,但 J.U.C 中的Lock
接口则并非如此。因此在只需要基础的同步功能时,更推荐synchronized
。Lock
应该确保在finally
块中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远不会释放持有的锁。这一点必须由程序员自己来保证,而使用synchronized
的话则可以由 Java 虚拟机来确保即使出现异常,锁也能被自动释放。- 尽管在 JDK 5 时代
ReentrantLock
曾经在性能上领先过synchronized
,但这已经是十多年之前的胜利了。从长远来看,Java 虚拟机更容易针对synchronized
来进行优化,因为 Java 虚拟机可以在线程和对象的元数据中记录synchronized
中锁的相关信息,而使用 J.U.C 中的Lock
的话,Java 虚拟机是很难得知具体哪些锁对象是由特定线程锁持有的。
非阻塞同步
互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronization)。从解决问题的方式上看,互斥同步属于一种悲观的并发策略,其总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享的数据是否真的会出现竞争,它都会进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁),这将会导致用户态到核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。
随着硬件指令集的发展,我们已经有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization),使用这种措施的代码也常被称为无锁编程(Lock-Free)。
为什么说使用乐观并发策略需要 “硬件指令集的发展” ?因为我们必须要求操作和冲突检测这两个步骤具备原子性。靠什么来保证原子性?如果这里再使用互斥同步来保证就完全失去意义了,所以我们只能靠硬件来实现这件事情,硬件保证某些从语义上看起来需要多次操作的行为可以只通过一条处理器指令就能完成,这类指令常用的有:
- 测试并设置(Test-and-Set);
- 获取并增加(Fetch-and-Increment);
- 交换(Swap);
- 比较并交换(Compare-and-Swap,下文称CAS);
- 加载链接/条件储存(Load-Linked/Store-Conditional,下文称LL/SC)。
其中,前面的三条是20世纪就已经存在于大多数指令集之中的处理器指令,后面的两条是现代处理器新增的,而且这两条指令的目的和功能也是类似的。在 IA64、x86 指令集中有用 cmpxchg 指令完成的 CAS 功能,在 SPARC-TSO中 也有用 casa 指令实现的,而在 ARM 和 PowerPC 架构下,则需要使用一对 ldrex/strex 指令来完成 LL/SC 的功能。因为 Java 里最终暴露出来的是 CAS 操作,所以以CAS指令为例进行讲解。
CAS 指令需要有三个操作数:
- 内存位置(在Java中可以简单地理解为变量的内存地址,用 V 表示)
- 旧的预期值(用 A 表示)
- 准备设置的新值(用 B 表示)
CAS 指令执行时,当且仅当 V 符合 A 时,处理器才会用 B 更新 V 的值,否则它就不执行更新。但是,不管是否更新了 V 的值,都会返回 V 的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。
在 JDK 5 之后,Java 类库中才开始使用 CAS 操作,该操作由 sun.misc.Unsafe
类里面的 compareAndSwapInt()
和 compareAndSwapLong()
等几个方法包装提供。 HotSpot 虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器 CAS 指令,没有方法调用的过程,或者可以认为是无条件内联进去了。不过由于 Unsafe
类在设计上就不是提供给用户程序调用的类(Unsafe::getUnsafe()
的代码中限制了只有启动类加载器(Bootstrap ClassLoader
)加载的 Class
才能访问它),因此在 JDK 9 之前只有 Java 类库可以使用 CAS,譬如 J.U.C 包里面的整数原子类,其中的 compareAndSet()
和 getAndIncrement()
等方法都使用了 Unsafe
类的 CAS 操作来实现。而如果用户程序也有使用 CAS 操作的需求,那要么就采用反射手段突破 Unsafe
的访问限制,要么就只能通过 Java 类库 API 来间接使用它。直到 JDK 9 之后,Java 类库才在 VarHandle
类里开放了面向用户程序使用的 CAS 操作。
// 代码清单 13-5 incrementAndGet() 方法的 JDK 源码
/**
* Atomically increment by one the current value.
* @return the updated value
*/
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next)) {
return next;
}
// If the compareAndSet fails, the loop will retry.
}
}
incrementAndGet()
方法在一个无限循环中,不断尝试将一个比当前值大一的新值赋值给自己。如果失败了,那说明在执行 CAS 操作的时候,旧值已经发生改变,于是再次循环进行下一次操作,直到设置成功为止。
尽管 CAS 看起来很美好,既简单又高效,但显然这种操作无法涵盖互斥同步的所有使用场景,并且 CAS 从语义上来说并不是真正完美的,它存在一个逻辑漏洞:如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然为 A 值,那就能说明它的值没有被其他线程改变过了吗?这是不能的,因为如果在这段期间它的值曾经被改成 B,后来又被改回为 A ,那 CAS 操作就会误认为它从来没有被改变过。这个漏洞称为 CAS 操作的 “ABA问题”。J.U.C 包为了解决这个问题,提供了一个带有标记的原子引用类 AtomicStampedReference
,它可以通过控制变量值的版本来保证 CAS 的正确性。不过目前来说这个类处于相当鸡肋的位置,大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更为高效。
线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
Java 语言中,如果一个变量要被多线程访问,可以使用 volatile
关键字将它声明为“易变的”;如果一个变量只要被某个线程独享,Java中 就没有类似C++中__declspec(thread) 这样的关键字去修饰,不过我们还是可以通过 java.lang.ThreadLocal
类来实现线程本地存储的功能。每一个线程的 Thread
对象中都有一个 ThreadLocalMap
对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode
为键,以本地线程变量为值的 K-V
值对,ThreadLocal
对象就是当前线程的 ThreadLocalMap
的访问入口,每一个 ThreadLocal
对象都包含了一个独一无二的 threadLocalHashCode
值,使用这个值就可以在线程 K-V
值对中找回对应的本地线程变量。
锁优化
高效并发是从 JDK 5 升级到 JDK 6 后一项重要的改进项,HotSpot 虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序地执行效率。
自旋锁与自适应自旋
互斥同步对性能最大地影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给 Java 虚拟机的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。
现在绝大多数的电脑和服务器都是多路(核)处理器系统,如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁,为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自旋锁在JDK1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK6中就已经改为默认开启了。
自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作。这就会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是10次,用户也可以使用参数-XX:PreBlockSpin来自行更改。
不过无论是默认值还是用户指定的自旋次数,对整个 Java 虚拟机中所有的锁来说都是相同的。在 JDK6 中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越聪明了。
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。
锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上的数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。
也许有读者会有疑问,变量是否逃逸,对于虚拟机来说是需要使用复杂的过程间分析才能确定的,但是程序员自己应该很清楚,怎么会在明知道不存在数据争用的情况下还要求同步呢?这个问题的答案是:有许多同步措施并不是程序员自己加入的,同步的代码在java程序中出现的频繁程度也许超过了很多人的想象。
如下代码,输出三个字符串相加的结果,无论是源代码字面上,还是程序语义上都没有进行同步。
// 代码清单 13—6 一段看起来没有同步的代码
public String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
我们也知道,由于String
是一个不可变类,对字符串的连接操作总是通过生成新的String
对象来进行的,因此Javac编译器会对String
连接做自动优化。在 JDK5 之前,字符串加法会转化为 StringBuffer
对象的连续append()
操作,在 JDK5 及以后的版本中,会转化为StringBuilder
对象的连续append()
操作。即变为如下所示:
// 代码清单 13—7 Javac 转化后的字符串连接操作
public String concatString(String sl, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
现在大家还认为这段代码没有涉及同步吗?每个StringBuffer.append()
方法中都有一个同步块,锁就是sb
对象。
synchronized StringBuffer append(AbstractStringBuilder asb) {
toStringCache = null;
super.append(asb);
return this;
}
虚拟机观察变量sb
,经过逃逸分析后会发现它的动态作用域被限制在concatString()
方法内部。也就是sb
的所有引用都永远不会逃逸到concatString()
方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除掉。 在解释执行时这里仍然会加锁,但是经过服务端编译器的即时编译之后,这段代码就会忽略所有的同步措施而直接执行。
客观地说,既然谈到锁消除与逃逸分析,那虚拟机就不可能是JDK5之前的版本,所以实际上会转化为非线程安全的
StringBuilder
来完成字符串拼接,并不会加锁。但是这也不影响笔者用这个例子证明Java对象中同步的普遍性。
锁粗化
原则上,编写代码总是推荐将同步块的作用范围限制得尽量小,只在共享数据得实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。
大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
代码清单13-7所示连续的append()
方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,如上代码为例,就是扩展到第一个append()
操作之前直至最后一个append()
操作之后,这样只需要加锁一次就可以了。
轻量级锁
轻量级锁是 JDK6 时加入的新型锁机制,它名字中的 “轻量级” 是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级锁”。不过,需要强调的是,轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
要理解轻量级锁,以及后面会讲到的偏向锁的原理和运作过程,必须要对 HotSpot 虚拟机对象的内存布局(尤其是对象头部分)有所了解。HotSpot虚拟机的对象头分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄等。这部分数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特,官方称它为“Mark Word”。这部分是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。
由于对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到 Java 虚拟机的空间使用效率,Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志 位,1Bit固定为0(这表示未进入偏向模式)。对象除了未被锁定的正常状态外,还有轻量级锁定、重量级锁定、GC标记、可偏向等几种不同状态。
我们简单回顾了对象的内存布局后,接下来就可以介绍轻量级锁的工作过程了:在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为 “01” 状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word),这时候线程堆栈与对象头的状态如图所示
然后,虚拟机将使用 CAS 操作尝试把对象的 Mark Word 更新为指向 Lock Record 的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象 Mark Word 的锁标志位(Mark Word的最后两个比特)将转变为 “00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如图所示:
如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。
上面描述的是轻量级锁的加锁过程,它的解锁过程也同样是通过 CAS 操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。
轻量级锁能提升程序同步性能的依据是一项经验法则: “对于绝大部分的锁,在整个同步周期内都是不存在竞争的”。如果没有竞争,轻量级锁便通过 CAS 操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了 CAS 操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。
偏向锁
偏向锁也是 JDK 6 中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不去做了。
偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
假设当前虚拟机启用了偏向锁(启用参数-XX:+UseBiased Locking,这是自JDK 6起HotSpot虚拟机的默认值),那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)
一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!