线程安全
给“线程安全”下一个严谨且可操作的定义:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果。
Java代码与JVM实现线程安全
在编写Java代码时,有一些不同的方法保证线程安全,这些方法背后的JVM实现也是不同的。
互斥同步
互斥同步(Mutual Exclusion & Synchronization)是最常见的一种保证并发正确的手段。同步的意思是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者一些)线程使用。而互斥是实现同步的方法。
在Java中实现互斥同步的手段主要有两种:
synchronized
关键字java.util.concurrent.locks.Lock
接口
其中Lock
接口是从JDK5开始支持的,最常见的一种实现为重入锁ReentrantLock
,其具有一些高级特性:等待可中断,公平锁,锁绑定多个条件。并且在实现之初的效率要高于synchronized
关键字。不过其并不是Java语言的内置特性,而是类库实现,JVM很难对其做针对性的优化。
synchronized
关键字有两种使用方法:
- 同步代码块:指定对象参数,那么锁定的对象就是指定的对象。
- 同步方法:如果是实例方法,那么锁定的对象就是实例对象;如果是类方法,那么锁定的对象就是对应的Class对象
在JDK1.5版本时,JVM对synchronized
关键字优化相当有限,只使用了最传统的锁机制(现在也称为重量级锁机制)。每个对象都会有一个对应的监视器(monitor)负责监视该对象的上锁、解锁过程,JVM在实现时监视器中的操作就可以认为是直接使用系统调用,从用户态切换到核心态,利用系统的互斥量实现同步。这个过程中涉及了用户态、核心态的切换以及线程的阻塞和唤醒,开销很大,因此监视器也称为重量级锁。这个过程有很大的优化空间,本文的第二部分锁优化就是JVM针对synchronzied
关键字的优化。
监视器的实现在JVM中,以HotSpot为例,
objectMonitor
的定义与实现在 hotspot/src/share/vm/runtime 路径下,是C++实现的。等待该锁的线程会被保存到objectMonitor
类实例中的_WaitSet
属性里,在锁被释放时,从_WaitSet
中再取出一个等待线程并将其唤醒。唤醒等待线程的顺序并不是按照放入的顺序,因此监视器实现的是一个非公平锁。
非阻塞同步
非阻塞同步(Non-Blocking Synchronization) 指的是访问共享数据时,先进行操作;如果没有其他线程争用共享数据,操作会直接成功,如果有其他的线程在争用共享数据,那么再进行其他的补偿措施。最常用的补偿措施是不断地重试,直到出现没有其他线程竞争共享数据为止。
非阻塞同步是一种乐观的并发策略,即处理时认为冲突发生的可能性很小。相对的,互斥同步是一种悲观的并发策略,其总是认为只要不去做正确的同步措施就一定会出现问题,无论共享数据是否真的会出现竞争,都会进行加锁。
非阻塞同步的实现需要依赖硬件指令集的支持,需要在硬件层面将“冲突检测”和“操作”两个步骤封装为一个具有原子性的指令。虽然在不同的硬件平台上有不同的硬件实现,但是在JVM中暴露出来的是统一的CAS(比较并交换,Compare and Swap)操作,通过使用该操作指令能够实现“尝试直接操作共享数据”的功能。
CAS操作需要三个操作数,分别是内存位置(可以简单理解为变量的内存地址,用V表示)、预期的旧值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V中的值符合A时,才将该位置的值更新为B,否则该指令不执行。同时,该指令有一条返回值为V上的旧值。
在java.util.concurrent
(J.U.C)包下的AtomicInteger
类的实现就是基于CAS操作的非阻塞同步。如AtomicInteger.getAndIncrement()
的实现如下:
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
其中unsafe
是一个sun.misc.Unsafe
类型的对象,该类型可以理解为jvm为Java类库开的一个直通CPU的后门类,可以调用一些很底层的功能。其中的getAndAddInt
的实现经过反编译如下:
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
可以看出,该方法的实现就是依赖于Unsafe.compareAndSwapInt()
方法,该方法经过JVM的处理,将会直接翻译成CPU相关的CAS操作指令。
在JDK5之后,Java类库中才开始使用CAS操作(在
sun.misc.Unsafe
类中);在JDK9之后,才在VarHandle
类中开放了面向用户程序使用的CAS操作
无同步方案
ThreadLocal
类实现了将数据拷贝到线程,该拷贝将只能在线程中访问。该方法更多依赖于Java类库的实现,而非JVM的支持。
锁优化
JDK6开发了许多锁优化技术,提高了并发效率。这些技术的总的方向就是在进入同步代码块时减少线程切换或者减少重量级锁的使用。
自旋锁与自适应自旋
传统的synchronized
关键字的实现中,如果线程在请求锁时发现已经被其他线程占用,那么该线程就会被阻塞从而引起线程的切换,这个过程对性能的影响很大,挂起线程和恢复线程的操作都需要转入内核态中完成。由此产生了一个优化方向,即这种情况下的线程切换是否可以减少?以下两个事实支持了该优化方向:
- 共享数据的锁定状态只会持续很短的一段时间,很多时候甚至少于线程切换的时间
- 现在的计算机很多都是多线程并行的,可以做到持有锁的线程和等待锁的线程并行执行
由此就有了基本的优化方法:让后面请求锁的线程“稍等一会”,不放弃处理器资源,而是执行一个空循环,这个空循环就称为自旋(Spinning)。自旋的开启关闭以及自旋的循环次数都可以通过JVM参数设置。
普通自旋锁的问题是整个JVM中所有的锁的自旋次数都是相同的,但是如果一些锁被占用的时间很长,自旋锁反而会浪费处理器资源。在JDK6中对自旋锁进行了优化,引入了自适应的自旋(Adaptive Spinning),自旋的时间由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。
锁消除
锁消除(Lock Elimination)指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。
锁粗化
如果有一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗,如连续的append操作。
锁粗化,就是如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展到整个操作序列的外部,这样就只需要加锁一次就够了。
Mark word
下面要讨论的两种类型的优化涉及到JVM中对象的内存布局。在HotSpot虚拟机中,对象头(Object Header)分为两部分,第一部分用于存储对象自身的运行时数据,锁相关的数据也保存在该部分,官方称其为Mark Word。第二部分用于存储指向方法区对象类型数据的指针。这里主要讨论Mark Word部分,其在不同状态下的存储内容如下:
轻量级锁
轻量级锁(Lightweight Locking) 是JDK6加入的新型锁机制,是为了在无需要使用传统重量级锁的情况下,提高性能的同时保证同步效果的锁机制。轻量级锁机制不使用操作系统互斥量因而效率较高,但是只能在无实际竞争时起作用,一旦JVM发现某轻量级锁锁定的对象上发生竞争,轻量级锁将膨胀为传统的重量级锁。轻量级锁的工作过程如下:
- 在代码即将计入同步块时,如果同步对象没有被锁定(锁标志位为“01”),虚拟机在栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储目前的Mark Word的拷贝,这份拷贝称为Displaced Mark Word。
- 然后,虚拟机使用CAS操作将对象的Mark Word更新为指向Lock Record的指针。如果更新成功,表示当前线程获得了该对象的锁;否则说明有其他线程在争用,接下来将膨胀为重量级锁,会把Mark Word中存储的内容变为指向重量级锁的的指针,并把标志为置为“10”,等待锁的线程也将进入阻塞状态。
- 持有轻量级锁的线程在解锁时,用CAS操作将Displaced Mark Word复制回对象头。如果成功,则同步过程完成;如果不成功,则说明有其他线程尝试过获取该锁,那么在释放锁的同时需要唤醒挂起的线程。
总结 :轻量级锁适用于多个线程交替获取锁的场景,即如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销。但是如果确实存在锁竞争,轻量级锁反而比重量级锁更慢。
偏向锁
偏向锁是一种比轻量级锁更弱的锁机制,这个锁会偏向于第一个获得它的线程,如果在接下来执行的过程中该锁一直没有被其他的线程获取,则持有偏向锁的线程永远不需要再进行同步。偏向锁的执行过程如下:
- 当锁对象第一次被线程获取的时虚拟机会把Mark Word中的标志为置为“01”,偏向模式置为“1”。同时使用CAS操作将Mark Word的其余部分填充。如果CAS操作成功,表示当前线程持有了该对象的偏向锁,以后该线程每次进入这个锁相关的同步块时,虚拟机就不需要进行任何同步,持有锁的线程永远不会主动释放。
- 一旦出现另一个线程去尝试获取这个锁的情况,虚拟机会查看持有该偏向锁的线程,如果持有锁的线程目前未锁定该对象,该对象将恢复到无锁状态,进行重偏向。如果持有锁的线程正锁定该对象,那么该偏向锁膨胀为轻量级锁。
总结 :偏向锁适用于几乎只被一个线程访问的锁对象。但是如果程序中大多数的锁都总是被多个不同的线程访问,那么偏向模式就是多余的。