线程安全与锁优化(第十三章)

118 阅读12分钟

线程安全

当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称为这个对象是线程安全的。

Java语言中的线程安全

为了更深入地理解线程安全,在这里我们可以不把线程安全当作一个非真即假的二院排他选项来待,而是按照线程安全的“安全程度”由强至弱来排序,我们可以将Java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容与线程对立。

  1. 不可变

    在Java语言里面,不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它不可变。如果共享数据是一个对象,由于Java语言目前暂时还没有提供值类型的支持,那就需要对象自行保证其行为不会对其状态产生任何影响才行。譬如java.lang.String类的对象实例,它就是一个不可变的对象,用户调用它的substring()、replace()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。

  2. 绝对线程安全

    假设Vector一定要做到绝对的线程安全,那就必须在它内部维护一组一致性的快照访问才行,每次对其中元素进行改动都要产生新的快照,这样要付出的时间和空间成本都是非常大的。

  3. 相对线程安全

    相对安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单词的操作时线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能在调用端使用额外的同步手段来保证调用的正确性。在Java语言中,大部分声称线程安全的类都属于这种类型,例如Vector、HashTable、Collections的synchronizedCollection方法包装的集合等。

  4. 线程对立

    线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。由于Java语言听声就支持多线程的特性,线程对立这种排斥多线程的代码是很少出现的,而且通常是有害的,应该尽量避免。例如Thread类的susupend()和resume()方法。

线程安全的实现方法

互斥同步(阻塞同步)

互斥同步是一种最常见也是最主要的并发正确性保障手段。同步是值在多个线程并发访问共享数据时,保证共享数据在同一时刻只能有一条线程使用。互斥是实现同步的一种手段,临界区、互斥量和信号量都是常见的互斥实现方式。

synchronized:在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构的同步语法。synchronized关键字经过javac编译之后,会在同步快的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中synchronized明确指定来对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修改是方法类型,来决定是取代码所在的对象实例还是取类型对象的class对象来作为线程要持有的锁。

  • 被synchronized修饰的同步块对同一条线程来说是可重入对。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。
  • 被synchronized修饰的同步块在持有锁的线程执行完毕并释放之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁。也无法强制正在等待锁的线程中断等待或超市退出。

Lock:java.util.concurrent.locks.Lock接口是Java另外一种全新的实现互斥同步的方法。基于Lock接口,用户能够以非块结构来实现互斥同步。重入锁是Lock接口最常见的一种实现,顾名思义,它与synchronized一样是可重入的。在基本用法上,ReentrantLock也与synchronized很相似,只是代码写法稍有区别而已。不过,ReentrantLock与synchronized相比增加了一些高级功能,主要有以下三项:

  • 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 公平锁:是指多个线程在等待同一个锁,必须按照申请锁的时间顺序来一次获得锁;而非公平锁则不保证这一点。,在锁释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁上非公平的,ReentrantLock在默认情况下也是非公平的,但可以通过待布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量。
  • 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个condition对象。在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,如果要和对于一个条件关联的时候,就不得不额外调价一个锁;而ReentrantLock则无须这样做,多次调用newCondition()方法即可。
非阻塞同步

基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那在进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步。

常用的非阻塞同步方法是CAS(Compare-and-Swap),在JDK5之后,Java类库才开始使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。HotSpot虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令,没有方法调用的过程,或者可以认为是无条件内联进去了。不过由于Unsafe类在设计上就不是提供给用户程序调用的类,因此在JDK9之前只有Java类库可以使用CAS,譬如java.util.concurrent包里面的整数原子类,其中的compareAndSet()和getAndINcrement()等方法都使用了Unsafe类的CAS操作来实现。

ABA问题:CAS从语义上来说并不是真正完美的,它存在一个逻辑漏洞,如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,不能证明它未被其它线程改变过,因为如果在这段起劲啊它的值曾经被改成B,后来又被改回A,那CAS操作就会误认为它从来没有被改变过。

无同步方案

如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性;

可重入代码:这种代码又称为村代码,是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。

线程本地存储(ThreadLocal):如果一段代码中所需要的数据必须与其它代码共享,那就看看这些共享数据的代码是否能保证在同一线程中执行。如果能保证,完美就可以把共享数据的可见范围限制在同一线程之内,这样,无须同步也能保证线程之间不出现数据争用的情况。

锁优化

自旋锁与自适应自旋

共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。为了让线程等待,完美只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

自旋等待的时间必须有一定限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次,用户可以使用参数-XX:PreBlockSpin来自行更改。

在JDK6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不在是固定的来,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋呢等待持续相对更长的时间,比如持续100次忙循环。另外,如果对于某个锁,很少成功获得过锁,拿在以后要获取这个锁时将有可能直接省略掉自旋的过程。

锁消除

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

锁粗化

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围拓展(粗化)到整个操作序列的外部。

轻量级锁

轻量级锁上JDK6时加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。不过,轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

加锁过程
  1. 在线程执行同步代码块之前,JVM会现在当前线程的栈桢中创建用于存储锁记录的空间,并将锁对象头中的 markWord 信息复制到锁记录中,这个官方称为 Displaced Mard Word。然后线程尝试使用 CAS 将对象头中的 MarkWord 替换为指向锁记录的指针。

    preview

  2. 将锁对象头中的 markWord 信息复制到锁记录中,这个官方称为 Displaced Mard Word。然后线程尝试使用 CAS 将对象头中的 MarkWord 替换为指向锁记录的指针。如果替换成功,则进入步骤3,失败则进入步骤4。

  3. CAS 替换成功说明当前线程已获得该锁,此时在栈桢中锁标志位信息也更新为轻量级锁状态:00。此时的栈桢与锁对象头的状态如上图所示

    preview

  4. 如果CAS 替换失败则说明当前时间锁对象已被某个线程占有,那么此时当前线程只有通过自旋的方式去获取锁。如果在自旋一定次数后仍为获得锁,那么轻量级锁将会升级成重量级锁。

轻量级锁升级过程大概流程图如下:

preview

轻量级锁的优缺点

轻量级锁涉及到一个自旋的问题,而自旋操作是会消耗CPU资源的。为了避免无用的自旋,当锁资源存在线程竞争时,偏向锁就会升级为重量级锁来避免其他线程无用的自旋操作。所以这就引出了偏向锁的一个缺点:如果始终无法获得锁资源,线程就会自旋消耗CPU资源。

但是偏向锁相对于重量级锁的一个有点就是:因为线程在竞争资源时采用的是自旋,而不是阻塞,也就避免了线程的切换带来的时间消耗,提高了程序的响应速度。

偏向锁

偏向锁也是JDK6中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。

\