线程安全-读笔记

1. 概述

线程安全定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调动作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。摘至《Java并发编程实践
编写的线程安全的代码,本质上就是管理对状态(state)的访问,而且通常都是共享的、可变的状态。共享指一个变量可以被多个线程访问;可变指变量的值在生命周期内可以改变。
无论何时,只要有多于一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问。
保证对象线程安全的三种措施:

  • 不跨线程共享变量
  • 使状态变量不可变
  • 在任何访问状态变量的时候使用同步

2. Java线程安全

2.1 线程安全强弱等级

Java语言中操作各种共享的数据可根据安全程度分为以下5类:

2.1.1 不可变

在Java线程里面,不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何的线程安全保障措施。

Java语言中,如果共享数据是一个基本数据类型,只要在定义时使用final关键字修饰它就可以保证它时不可变的;如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行。只要一个不可变的对象被正确构建出来(没有发生this引用逃逸的情况),那么其外部的可见状态也不会改变。保证对象的行为不影响自己状态的途径有很多种,最简单的就是把对象中带有状态的变量都声明为final。

2.1.2 绝对线程安全

不管运行时环境如何,调用者都不需要任何额外的同步措施。

2.1.3 相对线程安全

对象单独的操作时线程安全的,我们在调用的时候不需要做额外的保障措施,对于特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。例如:Vector、ConcurrentHashMap等。

2.1.4 线程兼容

线程兼容是指对象本身不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境中安全地使用。例如:HashMap等。

2.1.5 线程对立

线程对立指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。例如:Thread类中的suspend(),resume()方法。

2.2 Java线程安全实现

2.2.1 互斥同步

互斥同步(Mutual Exclusion & Synchronization)是最常见的一种并发正确性保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条线程使用。是一种悲观的并发策略,无论是否并发都需要加锁。
手段:synchronized、ReentrantLock。
缺点:线程阻塞和唤醒带来的性能问题,因此互斥同步也称为阻塞同步(Blocking Synchronization)。

2.2.2 非阻塞同步

非阻塞同步(Non-Blocking Synchronization)是基于冲突检测的乐观并发策略,通俗的讲就是先进行操作,如果没有其他线程争用共享数据,则操作成功,否则产生冲突,然后进行补偿措施(最常见的就是不断的重试,知道试成功为止)。
要求:硬件指令集的发展,需要操作和检测两个步骤具备原子性。
常见指令:测试并设置(Test-and-Set)、获取并增加(Fetch-and-Increment)、交换(Swap)、比较并交换(Compare-and-Swap,CAS)、加载链接/条件存储(Load-linked/Store-Conditional,LL/SC)。

2.2.3 无同步方案

要保证线程同步,不一定就要进行同步,两者没有因果关系。同步知识保证共享数据争用时正确性的手段,如果有些代码不涉及共享数据,自然无需同步保证共享数据争用时的正确性。

  • 可重入代码(Reentrant Code):这种代码也叫纯代码(Pure Code),可以在代码执行的任何时刻中断它,转去执行另外一段代码,而控制权回来后继续执行代码,程序不会出现任何错误。
  • 线程本地存储(Thread Local Storage):如果一段代码所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行,如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,无需同步也能实现线程之间不出现数据争用的问题。

3. Java锁优化

为了在线程之间更高效地共享数据,以及解决数据竞争问题,JDK实现了各种锁的优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等。

3.1 自旋锁与自适应性自旋

互斥同步对性能最大的影响是阻塞的实现,挂起和恢复线程的操作都需要转入内核态完成,这些操作给并发性能带来很大的压力。如果获取共享数据锁时仅需要等待很短的时间,为了很短的时间去挂起和恢复线程并不值得。如果处理器有一个以上的处理器,我们可以线程让请求锁的线程忙循环,不放弃处理器的执行时间,这项技术就是所谓的自旋锁。
自适应自旋锁意味着自旋的时间不再固定,根据前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

3.2 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为是线程私有的,同步加锁自然无需执行。

3.3 锁粗化

如果一系列的连续操作都对同一个对象反复加锁解锁,甚至加锁操作出现在循环体中。如果虚拟机探测到有这样一串零碎的操作都对一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,这样只加锁一次就可以。

3.4 轻量级锁

轻量级锁是JDK1.6 中加入的新型锁机制,轻量级是相对于使用操作系统互斥量来实现传统锁而言,因此传统锁机制被称为重量级锁。轻量级锁本意指在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

HotSpot虚拟机的对象头(Object Header)分为两部分,第一用于存储自身的运行时数据,这部分数据的长度在32位和64位的虚拟机分别是32个和64个bits,官网称它为”Mark Word”。另一部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还有一个额外的部分用于存储数组长度。
mark word
表摘自Java并发编程艺术 2.2小节
对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机空间效率,Mark Word被设计生一个非固定的数据结构以便在极小的空间内存储尽量多的信息,根据对象的状态复用自己的存储空间。
HotSpot 虚拟机对象头Mark Word不同状态下存储内容表:
mark word
表摘自深入理解Java虚拟机-JVM高级特性与实践 13.3小节
mark word status
表摘自Java并发编程艺术 2.2小节
轻量级锁的执行过程:在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word拷贝,如图:
markword-cas-before
图摘自深入理解Java虚拟机-JVM高级特性与实践 13.3小节
然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新成功了,那么该线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转为“00”,即表示该对象处于轻量级锁定状态。
markword-cas-after
图摘自深入理解Java虚拟机-JVM高级特性与实践 13.3小节
如果更新失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了该对象的锁,可直接进入同步代码块继续执行,否则说明这个锁对象被其他线程抢占了。如果有两条以上(包括两条)的线程争用同一个锁,轻量级锁不再有效要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁的指针,后面等待的线程也要进入阻塞状态。
轻量级锁初始化及膨胀流程图:
lightweight
图摘自Java并发编程艺术 2.2小节
解锁过程也是通过CAS操作来进行的,如果对象的Mark Word仍指向线程的锁记录,就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,同步完成。替换失败,有其他线程尝试获取该锁,此时锁已经膨胀,就要在释放锁的同时,唤醒被挂起的线程。

3.5 偏向锁

偏向锁目的是消除数据在无竞争情况下的同步原语。
使用场景:锁不仅不存在多线程竞争,而且总是由同一线程多次获得。
偏向锁原理:当锁对象第一次被线程获取的时候,虚拟机将会吧对象头中的标志位设为“01”(Mark Word见3.4小节的表中),即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持久偏向锁的线程以后每次进入这个锁相关的同步时,虚拟机都可以不再进行任何同步操作,只需简单测试对象头的Mark Word是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得锁。如果测试失败,则需要在测试一下Mark Word中偏向锁的表识是否设置成”01”:如果没有设置,则使用CAS竞争锁,如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,继续执行同步块;如果竞争失败,则进行偏向锁撤销。如果设置了则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁撤销(Revoke Bias):当有另外一个线程尝试去获取这个锁时,偏向模式结束。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果不处于活动状态,则将对象头设置成无锁状态;如果线程还活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
偏向锁、轻量级锁的状态转化及对象Mark Word的关系如图:
markword-change
图摘自深入理解Java虚拟机-JVM高级特性与实践 13.3小节
偏向锁初始化及膨胀流程图:
bias-expand
图摘自Java并发编程艺术 2.2小节

3.6 轻量级锁、偏向锁、重量级锁对比

偏向锁初始化及膨胀流程图:
lock
图摘自Java并发编程艺术 2.2小节

引用

深入理解Java虚拟机-JVM高级特性与实践
Java并发编程实践
Java并发编程艺术