线程安全与锁优化

首先需要保证正确性,然后在此基础上实现高效

概述

  • 「面向过程」的编程思想:站在计算机的角度去抽象问题和解决问题,把数据和过程分别作为独立的部分来考虑,数据代表问题空间中的客体,程序代码则用于处理这些数据。
  • 「面向对象」的编程思想:站在现实世界的角度去抽象和解决问题,把数据和行为都看做是对象的一部分,这样可以让程序员能以符合现实世界的思维方式来编写和组织程序。

线程安全

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

这个定义比较严谨,要求线程安全的代码有一个特征:代码本身封装了所有必要的正确性保障手段,这点听起来容易,实际很难做到,在大多数场景中,我们会把定义弱化一点。把“调用这个对象的行为”改为“单词调用”,就可以称之为线程安全了。

Java语言中的线程安全

按照线程安全的“安全程度”由强至弱来排序,可以将Java语言中各种操作共享的数据分为以下5类:不可变绝对线程安全相对线程安全线程兼容和线程对立

不可变

  • 在Java语言中,不可变(Immutable)的对象一定是线程安全的。“不可变”带来的安全性是最简单和最纯粹的。
  • Java语言中,
    • 如果共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的;
    • 如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产出任何影响才行。
  • 例如java.lang.String类的对象就是一个典型的不可变对象,我们调用它的substring()、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象
  • 保证对象行为不影响自己状态的途径有很多种,最简单的就是对象中 带有状态的变量都声明为final ,这样在构造函数结束之后,它就是不可变的。
  • 符合不可变要求的类型,除了String外,常用的还有枚举类型,以及java.lang.Number的部分子类,如LongDouble等数值包装类型,BigIntegerBigDecimal等大数据类型;但同为Number的子类型的原子类 AtomicIntegerAtomicLong 则并非不可变的。

绝对线程安全

  • 不管运行时环境如何,调用者都不需要任何额外的同步措施。
  • 在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。

相对线程安全

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

线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数的时候指的是这一种情况。Java API中大部分的类都是属于线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayListHashMap等。

线程对立

线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。如Thread类的suspend()resume()方法,System.setIn()、System.setOut()System.runFinalizersOnExit() 等。

线程安全的实现的实现方法

互斥同步(Mutual Exclusion & Synchronization)

  • 同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段, 「临界区(Critical Section)」、「互斥量(Mutex)」和「信号量(Semaphore)」都是主要的互斥实现方式。
  • 在Java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成 monitorentermonitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。
  • 在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将所计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
  • 两点值得注意:
    • 首先,synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
    • 其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。
  • synchronized是Java语言中一个「重量级(Heavyweight)」的操作,在确实有必要情况下才使用。
  • 除了synchronized之外,还可以使用java.util.concurrent包中的「重入锁(ReentrantLock)」 来实现同步。ReentrantLock和synchronized在代码写法上有点区别:一个表现为API层面的互斥锁(lock()h和unlock()方法配合try/finally语句块来完成),另一个表现为原生语法层面的互斥锁。
  • ReentrantLock增加了一些高级功能,主要有以下3项:等待可中断可实现公平锁,以及锁可以绑定多个条件
    • 等待可中断是值当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
    • 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。 synchronized中的锁是非公平的 ,ReetrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁
    • 锁绑定多个条件是指一个ReetrantLock对象可以同时绑定多个Condition对象,只需多次调用newCondition()方法即可。 > 提倡在synchronized能实现需求的情况下,优先考虑synchronized来进行同步。

非阻塞同步(Non-Blocking Synchronization)

  • 互斥同步主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也被称为「阻塞同步」(Blocking Synchronization),属于一种悲观的并发策略。
  • 基于冲突检测的乐观并发策略:就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,就采取其他的补救措施(如不断重试,直到成功),这种乐观的并发策略的许多实现不需要把线程挂起,因此这种同步策略称为「非阻塞同步」。
  • 使用乐观并发策略需要硬件指令集的发展,硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:
    • 测试并设置(Test-and-Set)
    • 获取并增加(Fetch-and-Increment)
    • 交换(Swap)
    • 比较并交换(Compare-and-Swap,CAS)—— 核心
    • 加载链接/条件存储(Load-Linked/Store-Conditional, LL/SC) > CAS指令需要三个操作数,分别是内存位置V,旧的预期值A,新值B,CAS执行时,当且仅当V符合A时,处理器用B值更新V的值,否则不执行。无论是否更新,都是原子操作。

无同步方案

  • 要保证线程安全,并不是一定就要进行同步,两个没有因果关系。
  • 「可重入代码」(Reentrant Code):这种代码也叫做纯代码(Pure Code),所有的可重入的代码都是线程安全的。如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求。
  • 「线程本地存储」(Thread Local Storage):共享数据的代码保证在一个线程中执行。如使用消费队列的架构模式(如“生产者-消费者”模式)中经典Web交互模型中的“一个请求对应一个服务器线程(Thread-per-Request)”的处理方式。Java可以通过java.lang.ThreadLocal类来实现线程本地存储功能。

锁优化

自旋锁与自适应自旋

  • 互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态完成。
  • 如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋) ,这项技术就是所谓的「自旋锁 」。
  • 自适应的自旋锁:自适应意味着自旋的时间不在固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

锁消除

  • 锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
  • 怎么会在明知道不存在数据征用的情况下设置同步呢?原因是许多同步措施不是程序员自己加进去的,而是Java中的代码自己加的。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
//一段看起来没有同步的代码
public String concatString(String s1, String s2, String s3){
  return s1 + s2 + s3;
}
//经javac转化后的字符串链接操作
public String concatString(String s1, String s2, String s3){
  StringBuffer sb = new StringBuffer();
  sb.append(s1);
  sb.append(s2);
  sb.append(s3);
  return sb.toString();
}
  • 每个StringBuffer.appeng()方法中都有一个同步块,锁就是sb对象。虚拟机观察变量sb,很快发现它的动态作用域被限制在concatString()方法内部,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

锁粗化

  • 原则上,总是推荐将同步块的作用范围限制的尽量小——只在共享数据的实际作用域才进行同步。
  • 但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体内的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损坏。
  • 如果虚拟机探测到有这样一串零碎的操作对同一个对象加锁(如上面的append()),将会把加锁同步的范围扩展(粗化)到这个操作序列的外部。

轻量级锁

  • 轻量级锁的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
  • HotSpot虚拟机的对象头(Object Header)分为两部分信息:
    • 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等,这部分数据的长度在32位和64位的虚拟机分别为32bit和64bit,称为“Mark Word”,它是实现轻量级锁和偏向锁的关键
    • 另一部分用于存储指向方法区对象类型数据的指针。
  • Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽可能多的信息。

jvmb-13.1.png * 在代码进入同步块的时候 * 如果此同步对象没有被锁定,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。 jvm-tu-13.3.png * 然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为“00”,即表示 此对象处于轻量级锁定状态。 jvm-tu-13.4.png * 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就膨胀为重量级锁,Mark Word中存储的就是执行重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 * 轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。 * 如果没有竞争,轻量级锁可以使用CAS操作避免互斥操作的开销;如果有竞争,则算是额外发生了CAS操作,因此比传统的重量级锁还慢。

偏向锁

  • 轻量级锁是在无竞争的情况下使用CAS操作去取消除同步使用的互斥量
  • 偏向锁是在无竞争的情况下把整个同步都取消掉,连CAS操作都不做了。

jvm-tu-13.6.png