《Java并发编程实战》中对线程安全的定义如下:
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要考虑额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。
将Java语言中各种操作共享的数据分为以下5类,不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
1.不可变
不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。
Java语言中,如果多线程共享的数据是一个基本数据类型,那么只要定义时使用final关键字修饰他就可以保证它是不可变的,但如果是个对象,目前还没有啥办法,得需要对象自身保证其性威不会对其状态产生任何影响才行。比如String类的对象实例,它就是一个典型的不可变对象,调用substring()等方法并不会影响它的值,而是返回一个新构造的字符串对象。
保证对象性威不影响自己状态的方式有很多,最简单的就是将对象里带有状态的变量声明为final。在Java类库API中符合不可变要求的类型比如枚举类型和java.lang.Number的部分子类(Long、Double等)、BigInteger、BigDecimal等。
2.绝对线程安全
Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。java.util.Vector是个线程安全的容器,add()、get()、size()等方法都是被synchronized
修饰的,保证了具备原子性、可见性和有序性。不过,就算是所有方法都被synchronized
修饰,那也不意味着调用它的时候就不需要同步手段了。
3.相对线程安全
相对线程安全需要保证对这个对象单次的操作是线程安全的,调用的时候不需要额外的保障措施,Vector、HashTable、Collections的synchronizedCollection()方法包装的集合都属于相对线程安全的。需要说明的一点,对于一些特定顺序的连续调用,需要使用额外的同步手段保证调用的正确性。
4.线程兼容
线程兼容是指对象本身并不是线程安全的,但可以通过使用同步手段保证对象在并发环境中可以安全使用。平时说的一个类不是线程安全的,通常就是这种情况。比如ArrayList和HashMap。
5.线程对立
不管是不是采取了同步措施,都没法在多线程环境下并发使用代码。
举个线程对立的例子,Thread类的suspend()方法和resume()方法,如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发情况下,无论调用的时候是否进行了同步,目标线程都存在死锁的风险——假如suspend()终端的线程就是即将要执行resume()的哪个线程,那必然发生死锁。
现在这两个方法已经被废弃了。除此之外,常见的线程对立的操作还有System.setIn()、System.setOut()、System.runFinalizersOnExit()等。
1.互斥同步
同步指的是在多个线程并发访问贡献数据的时候,保证贡献数据在同一个时刻只被一条线程使用,互斥是实现同步的一种手段,临界区、互斥量和信号量都是常见的互斥实现方式(操作系统这门课中讲过…)。
Java里面,最基本的互斥同步手段是synchronized
关键字,这是一种块结构的同步语法,synchronized关键字经过Javac编译之后,会在同步块前后形成monitorenter和monirotexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。
在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
被synchronized
修饰的同步块可以被同一线程反复进入,这个线程并不会出现自己锁死自己的情况,此外,该同步块在持有锁的线程执行完毕并释放锁之前,会无条件阻塞后面其他线程的进入。
synchronized
是Java语言中的一个重量级操作,因为需要操作系统来完成,就涉及到用户态和核心态之间的转换,该转换耗费大量的处理器时间,简单的同步块消耗的时间比较长。
除此之外,JDK 5提供了J.U.C包,java.util.concurrent.locks.Lock接口变成了一种全新的互斥同步手段。基于该接口,可以以非块结构实现互斥同步。重入锁(ReentrantLock)是Lock接口最常见的实现,这个锁有一些高级功能,主要是:等待可中断、可实现公平锁及锁可以绑定多个条件。
等待可中断说的是当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其它事情。
公平锁是指多个线程在等待同一个锁的时候,必须按照申请锁的时间顺序来依次获得锁,而非公平锁并不保证这一点。锁释放的时候,任何一个等待锁的线程都有机会获得锁,synchronized和ReentrantLock在默认情况下是非公平的,但是ReentrantLock可以通过带布尔值的构造函数要求使用公平锁。
锁绑定多个对象是指可以同时绑定多个Condition对象,如果要和多个条件关联的时候,多次调用newCondition()方法即可。
2.非阻塞同步
非阻塞同步是一种乐观并发的策略,不管风险,先进行操作,没有其它线程争用共享数据,操作直接成功,如果产生冲突,在进行其它的补偿(不断地重试)。互斥同步是一种阻塞同步,属于悲观的并发策略。
常用的指令有:
测试并设置(Test-and-Set)
获取并增加(Fetch-and-Incerment)
交换(Swap)
比较并交换(Compare-and-Swap,简称CAS,在很多面经中经常看到..)
加载连接/条件储存(Load-Linked/Store-Conditional,简称LL/SC)
CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。
JDK 9之前只有Java类库可以使用CAS,譬如J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作来实现。而如果用户程序也有使用CAS操作的需求,那要么就采用反射手段突破Unsafe的访问限制,要么就只能通过Java类库API来间接使用它。直到JDK 9之后,Java类库才在VarHandle类里开放了面向用户程序使用的CAS操作。
CAS它存在一个逻辑漏洞:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,那就能说明它的值没有被其他线程改变过了吗?这是不能的,因为如果在这段期间它的值曾经被改成B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA问题”。J.U.C为了解决这个问题提供了一个原子引用类(控制变量值的版本保证CAS的正确,但大部分情况ABA不影响程序并发的正确性),但互斥同步可能比原子类更高效。
3.无同步方案
如果能让一个方法本来就不涉及共享数据,那么也就不需要什么同步措施保证正确性,所以有的代码天生就是线程安全。简单列举两类。
先撂下几个词和对应的英文,说不定啥时候就蹦跶出来。适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)。
自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin来自行更改。
在JDK 6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
轻量级锁是JDK 6加入的。理解轻量级锁和偏向锁,需要了解下HotSpot虚拟机对象的内存布局尤其是对象头,这部分内容在之前的读书笔记中记录过了,不了解可以点我。轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量。
在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的Mark Word的拷贝。
然后虚拟机使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果更新成功,该线程就拥有了这个对象的锁,并且把对象Mark Word的锁标志位转变为“00”,表示处于轻量级锁定状态。如果更新失败,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。
轻量级锁的解锁过程也是CAS操作进行的。如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的DisplacedMark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。
偏向锁也是JDK 6引入的锁优化措施,目的在于消除数据在无竞争情况下的同步原语,进一步提供高程序的运行性能。偏向锁是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。这个偏是偏向第一个获得它的线程,如果在接下来的执行过程里,该锁一直没有被其它线程获取,那么持有偏向锁的线程将永远不需要在进行同步啦。
当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word中。若CAS操作成功,持有偏向锁的线程以后每次进入到这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
一旦出现另外一个线程去尝试获取这个锁,偏向模式就宣告结束。
有时候使用参数-XX:-UseBiasedLocking
来禁止偏向锁优化反而可以提升性能。