1.背景
并发程序开发不可避免地要涉及多线程、多线程协作、数据共享和线程安全等问题。在多线程并发场景下,由于采用数据共享的线程通信模型可能导致多个线程之间并发时相互干扰,影响到程序的正常逻辑、无法保证正常的结果。为了保证程序在并发环境的正确性,有必要对多线程并发进行防范,因此就有了并发控制机制。
Java并发控制机制
并发防范机制等价于并发控制机制,同步(有序)机制可以说是并发防范的一个子集。Java并发提供了多个维度的并发防范机制。我们可划分JVM、JDK 2个层面:
- JVM层面主要指关键字同步原语(volatile、synchronized、final)。通过字节码指令禁止指令重排序来保证顺序一致性。
- JDK层面是JUC并发包,比如基于队列同步器实现的重入锁RetrantLock、读写锁ReentrantReadWriteLock,此外还有Semaphore、CountDownLatch、CyclicBarrier等并发工具(本质还是锁)、原子操作类(比如AtomicInteger)、ThreadLocal线程局部变量(无锁防并发方案)、线程安全的并发容器(ConcurrentHashMap、BlockingQueue等)。
关于线程安全
“线程安全”网上大部分的解释是:如果一个对象可以安全地被多个线程同时使用,那它就是线程安全的。并不能说它不对,但是不够精确,几乎获取不到什么有用信息。
《Java Concurrency In Practice》的作者Brian Goetz为“线程安全”做出了一个比较恰当的定义:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。”
这个定义就很严谨而且有可操作性,它要求线程安全的代码都必须具备一个共同特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程下的调用问题,更无须自己实现任何措施来保证多线程环境下的正确调用。这点听起来简单,但其实并不容易做到。
2.JVM同步机制
volatile
volatile关键字的并发安全性承诺(即声明为volatile的变量可以做到):
- 线程对volatile变量的修改,可以及时反应到其他线程(对volatile变量的写入可以及时作用到主内存,其他线程读取volatile变量也是直接从主内存读取)。
- volatile变量的读写有序性(JVM通过字节码指令enterexit禁止指令重排序来保证有序性),即两线程并发使volatile变量的写入总是先行发生于对volatile变量的读取。
以上描述的是volatile变量在多个线程间的可见性和有序性(禁止指令重排序),说到底volatile变量需要保证volatile写/读顺序,volatile重排序规则表如下(JSR-133):
- 首先,若第二个操作是volatile写,则不允许指令重排序。
- 其次,若第一个操作是volatile读,同样不允许指令重排序。
- 最后,当第一个操作是volatile写,第二个操作volatile读,则不允许指令重排序。
那么volatile具体是如何做到的?
为了实现volatile的内存语义,JVM采用基于保守策略的JMM内存屏障插入策略。
- 在每个volatile写操作前面插入StoreStore屏障、在每个volatile写的后面插入StoreLoad屏障。
- 在每个volatile读操作后面插入LoadLoad屏障、在每个volatile读的后面插入LoadStore屏障。
基于保守策略可保证在任意平台、任意程序都得到正确的volatile语义。
通过加入屏障可以保证volatile写-读与锁的释放-获取具有相同的内存效果:锁的释放总是先行发生于获取锁;同理,volatile写总是先行发生于volatile读。
synchronized
synchronized是内部锁(也叫重量级锁,实际上1.6后它做过优化,没那么重量级了),是Java最重要的同步机制之一。
虽然synchronized可以保证对象和代码段的线程安全,但仅通过synchonized还不足以控制拥有复杂逻辑的线程交互,为了实现多线程交互,还需要和object的wait()和notify()两个方法联合使用。
synchronized(obj) {while(<?>) {obj.wait()// 收到通知后继续执行}
}
synchronzied配合wait()、notify()是并发编程的基本技能之一。
synchronized关键字的并发安全性承诺:
- 临界区互斥执行。
- 锁的释放先行发生于锁的获取的内存语义。
synchronized是如何做到互斥和保证先行发生关系的
Java中每个对象都可以作为锁(对象的锁)。普通同步方法,锁是当前实例对象;静态同步方法,锁是当前类的Class对象;同步方法块,锁是synchronized括号里配置的对象。这些实例对象、Class对象、配置的对象在锁范畴内叫Monitor对象。 JVM基于进入和退出Monitor对象来实现临界区互斥执行和锁的释放先行发生于锁的获取的内存语义。
- 代码块同步使用monitorenter和monitorexit指令实现。
- 同步方法是通过检查方法是否标志ACC_SYNCHRONIZED实现。
锁优化
方案1:自旋
首先,分析一下synchronized的性能瓶颈。互斥同步对性能影响最大的是阻塞的实现。线程阻塞和用户态内核态转换带来的性能开销。虚拟机团队注意到在大部分应用,共享数据的锁定状态只会持续很短一段时间,如果在这个很短的共享数据锁定状态去挂起和恢复线程是划不来的,对于多处理器系统,当发现共享资源被锁定后,能否让这个线程稍等一会儿,但不放弃处理器执行时间呢?答案是肯定的,方案可行,前提是共享资源很快会被释放。我们只需要让线程执行一个忙等待(自旋),这就是自旋锁的由来。我们可以通过-XX:+UseSpinning开启自旋锁。
其次,自旋锁不能替代阻塞,自旋锁对处理器有要求(即多处理器),虽然避免了阻塞但会占用CPU执行时间,如果锁定很短效果会很好,但如果锁定很长呢?那是否就白白浪费的处理器执行时间了。因此自旋的等待时间必须有一个限度,如果自旋超过了限定次数仍然没有成功获得锁,就应该使用传统方式挂起线程。在虚拟机默认设置中自旋次数是10次,可通过参数-XX:PreBlockSpin来更改。
最后,不过无论是默认值还是用户指定的自旋次数,对整个Java虚拟机中所有的锁来说都是相同的。在 JDK 6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了。
a.Java对象头和MarkWord设计
首先,synchronized用的锁是存在Java对象头里的,对象如果是数组类型,则JVM用3个字宽(一个字宽32bit)存储对象头;如果对象是普通类型,则使用2字宽。Java对象头组成如下所示:
下面,我们看下Mark Word的字段组成情况。
首先,在无锁状态下,32bit Mark Word划分如下:
在运行期,Mark Word存储的数据会随着标志位的变化而变化,如下所示:
以上是32位虚拟机的Mark Word字段分配。
注:无锁状态的Mark Word当有线程获取Monitor对象时,会拷贝到栈帧的锁记录中。
b.锁的升级过程(锁膨胀)
从以上分析我们知道锁有4种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。这几种状态随着竞争情况而逐渐升级。锁只能升级而不能降级,只所以这样做是为了提高获取锁和释放锁的效率。
偏向锁
Hotspot作者发现,大多数情况下,锁不仅不存在多线程竞争,而且只是由同一线程获取,为了让线程获取锁的代价更低而引入了偏向锁。
- 当一个线程访问同步块时,首先会判断锁的状态,如果是01,且允许偏向,则进入第2步,否则进入第4步。
- 获取锁,在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时,不需要进行CAS操作来加锁和解锁,只需要简单测试一下对象头Mark Word是否存储了指向当前线程的偏向锁。如果偏向锁没有设置,且此时锁标志位为01,则尝试CAS设置偏向锁。
- 偏向锁的撤销使用了一种等待竞争出现才释放锁的机制,当有其它线程竞争锁时,持有偏向锁的线程需要等待全局安全点(没有正在执行的字节码这个点),它会暂停拥有偏向锁的线程,判断线程的活跃状态,如果不活跃,则设置为无锁状态,否则升级。
轻量级锁
轻量级对性能的提升的前提条件是同步块可以很快执行完成,且系统是多核,这样只需要忙等轮询很小一段时间就可以获取锁,避免线程阻塞导致的开销。
- 在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word)。
- 虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。
- 如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了,空转轮询一段时间锁要膨胀为重量级锁,锁标志的状态值变为“10”,跳转到7。
重量级锁
- 此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。
锁膨胀到重量级锁后,可能导致线程阻塞,而线程阻塞时需要通过操作系统指令完成的,这种系统调用会导致程序用户态内核态的切换,消耗系统资源。
偏向锁、轻量级锁的状态转化及对象Mark Word的关系如下所示。
锁的整体膨胀过程如下图所示:
偏向锁、轻量级、重量级锁优缺点分析
final
final的安全承诺:
- final对象只在初始化构建时进行赋值,实例化成功后不允许改变其值,从根本上避免了并发写入带来的线程安全问题。
- 读一个对象的final域之前,一定会先这个对象的引用,如果引入对象不为null,则final域一定被初始化了,
怎么做到的:
- JMM禁止编译器把final域的写重排序到构造函数之外,实现方法是在final域写之后,构造函数return前插入一个StoreStore屏障。
- 读对象final域之前插入LoadLoad屏障,保证读对象final域之前,一定会先读对象本身。
3.JUC并发防范机制
ReentrantLock
RetrantLock提供了比synchronized更强大的功能,更好的灵活性。它可以响应中断、支持超时时间设置、支持公平和非公平策略。
lock.tryLock(5, TimeUtil.SECONDS);
lock.lockInterruptibly();
ReadWriteLock
读写锁可以有效减少读写并发时的锁竞争,进而减少线程阻塞提高响应时间。
Condition
Condition用于协调多线程的复杂协作,常与Lock配合使用,通过lock.newCondition()可以生成与Lock绑定的Condition实例。
Semaphore
信号量为多线程协作提供了更加强大的控制方法。信号量是对锁的扩展,无论是内部锁synchronized还是重入锁ReentrantLock,一次仅允许一个线程访问资源,而信号量则可以指定多个线程同时访问资源。
构造方法如下:
public Semaphore(int permits) {}
public Semaphore(int permits, boolean fair) {}
主要方法:
public void acquire() throws InterruptedException {}
public void acquireUninterruptibly() {}
public boolean tryAcquire() {}
public boolean tryAcquire(long timeout, TimeUtil unit) throews InterruptedException {}
public void release() {}
CountDownLatch
CountDownLatch允许一个或多个线程等待其他线程完成操作。一个线程调用countDown方法happen-before另外一个线程调用await方法。API如下
CountDownLatch latch = new CountDownLath(2);
latch.countDown();
latch.await();
CyclicBarrier
循环屏障可以做的事是让一组线程到达一个屏障(也叫同步点)时被阻塞,直到最后一个现线程到达屏障才会打开。CyclicBarrier可用于多线程计算数据,最后合并计算结果的场景。CyclicBarrier API如下:
CyclicBarrier barrier = new CyclicBarrier(4, this);
barrier.await();
ThreadLocal
ThreadLocal提供的并发防范机制有别于以上在数据共享常见下通过加锁来达到并发控制,防范线程非安全情况出现(即保证线程安全)。ThreadLocal为每个线程提供变量的独立副本,从而从根本上杜绝了数据共享,线程之间根本就不会相互干扰,也就不会有线程安全问题。
4.线程安全集合
- ConcurrentHashMap是线程安全且高效的HashMap。
- BlockingQueue常用语生产者消费者场景、是线程安全的Queue。
线程安全集合并不在本次讨论范围。
总结
本文较全面的讨论了Java并发控制机制,在JVM层面通过volatile保证了内存的可见性和volatile写/读的先行发生关系。通过synchronized保证了多线程并发时对临界区的互斥访问以及锁的释放先行发生于锁的获取内存语义,为了提供并发性能,本文重点分析了内部锁的膨胀过程。通过final关键字保证了构造函数的调用先行发生于final域的读取并保证了final域的不可变性。除了JVM层面通过JMM定义的先行发生顺序外,JUC也提供了并发防范工具,包括:RetrantLock、ReentrantReadWriteLock、Condition、Semaphore、CountDownLatch、CyclicBarrier以及ThreadLocal。