线程安全与锁优化

本篇将介绍线程安全所涉及的概念和分类、同步实现的方式及虚拟机的底层运作原理,以及虚拟机为了实现高效并发所采取的一系列锁优化措施。

线程安全

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

这个定义比较严谨,它要求线程安全的代码都必须具备一个特征

代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程的问题,更无须

自己采取任何措施来保证多线程的正确调用。

Java语言中的线程安全

分类:按照线程安全的程度由强至弱分成五类

  • 不可变:外部的可见状态永远不会改变,在多个线程之中永远都是一致的状态。
    • 一定是线程安全的
    • 如何实现:
      • 如果共享数据是一个基本数据类型,只要在定义时用final关键子修饰;
      • 如果共享数据是一个对象,最简单的方法是把对象中带有状态的变量都声明为final
  • 绝对的线程安全:完全满足之前给出的线程安全的定义,即达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”。
  • 相对的线程安全:能保证对该对象单独的操作是线程安全的,在调用时无需做额外保障措施,当对于一些特定顺序的连续调用,可能需要在调用端使用额外的同步措施来保证调用的正确性。
    • 是通常意义上所讲的线程安全
    • 大部分的线程安全类都属于这种类型,如VectorHashTableCollections#synchronizedCollection()包装的集合..
  • 线程兼容:对象本身非线程安全的,但可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。
    • 是通常意义上所讲的非线程安全
    • Java API中大部分类都是属于线程兼容的,如ArrayListHashMap
  • 线程对立:无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。

线程安全的实现方式

可分为两大手段,本篇重点在虚拟机本身

  • 通过代码编写实现线程安全
  • 通过虚拟机本身实现同步与锁
  1. 互斥同步
  • 含义:
    • 同步:在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用。
    • 互斥:是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。

互斥是因,同步是果;互斥是方法,同步是目的。

  • 属于悲观并发策略,即认为只要不做正确的同步措施就肯定会出现问题,因此无论共享数据是否真的会出现竞争,都要加锁。
  • 最大的问题是进行线程阻塞和唤醒所带来的性能问题,也称为阻塞同步(Blocking Synchronization)
  • 手段:
    • 使用synchronized关键字:
      • 原理:编译后会在同步块的前后分别形成monitorentermonitorexit这两个字节码指令,并通过一个reference类型的参数来指明要锁定和解锁的对象。若明确指定了对象参数,则取该对象的reference;否则,会根据synchronized修饰的是实例方法还是类方法去取对应的对象实例或Class对象来作为锁对象。
      • 过程:执行monitorenter指令时先要尝试获取对象的锁。若该对象没被锁定或者已被当前线程获取,那么锁计数器+1;而在执行monitorexit指令时,锁计数器-1;当锁计数器=0时,锁就被释放;若获取对象锁失败,那当前线程会一直被阻塞等待,直到对象锁被另外一个线程释放为止。
      • 特别注意:synchronized同步块对同一条线程来说是可重入的,不会出现自我锁死的问题;还有,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。
    • 使用重入锁ReentrantLock
      • 相同:用法与synchronized很相似,且都可重入。
      • 不同:
        • 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
        • 公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。而synchronized是非公平的,即在锁被释放时,任何一个等待锁的线程都有机会获得锁。ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数改用公平锁。
        • 锁绑定多个条件:一个ReentrantLock对象可以通过多次调用newCondition()同时绑定多个Condition对象。而在synchronized中,锁对象的wait()notify()notifyAll()只能实现一个隐含的条件,若要和多于一个的条件关联不得不额外地添加一个锁。
      • 选择:synchronized能实现需求的情况下,优先考虑使用它来进行同步。下两张图是两者在不同处理器上的吞吐量对比。

  1. 非阻塞同步(Non-Blocking Synchronization):
  • 基于冲突检测的乐观并发策略,即先进行操作,若无其他线程争用共享数据,操作成功;反之产生了冲突再去采取其他的补偿措施。

  • 为了保证操作和冲突检测这两步具备原子性,需要用到硬件指令集,比如:

    • 测试并设置(Test-and-Set)
    • 获取并增加(Fetch-and-Increment)
    • 交换(Swap)
    • 比较并交换(Compare-and-Swap,CAS)
    • 加载链接/条件存储(Load-Linked/Store-Conditional,LL/SC)
  1. 无同步方案
  • 定义:不同同步的方式保证线程安全,因为有些代码天生就是线程安全的。下面列举两个例子:

    1. 可重入代码(Reentrant Code)/纯代码(Pure Code)

      • 含义:可在代码执行的任何时刻中断它去执行另外一段代码,当控制权返回后原来的程序并不会出现任何错误。
      • 共同特征:不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法…
      • 判定依据:如果一个方法,它的返回结果是可预测的,只要输入相同的数据就都能返回相同的结果,就满足可重入性。

      满足可重入性的代码一定是线程安全的,反之,满足线程安全的代码不一定是可重入的。

    2. 线程本地存储(Thread Local Storage)

      • 含义:把共享数据的可见范围限制在同一个线程之内,无须同步就能保证线程之间不出现数据争用的问题。
      • 使用ThreadLocal类可实现线程本地存储的功能:每个线程的Thread对象中都有一个ThreadLocalMap对象,它存储了一组以ThreadLocal.threadLocalHashCode为key、以本地线程变量为value的键值对,而ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,也就包含了一个独一无二的ThreadLocalHashCode值,通过这个值就可以在线程键值值对中找回对应的本地线程变量。

锁优化

解决并发的正确性之后,为了能在线程之间更『高效』地共享数据、解决竞争问题、提高程序的执行效率,下面介绍五种锁优化技术。

  • 适应性自旋(Adaptive Spinning)

    • 背景:互斥同步在实现阻塞和唤醒时需要挂起线程和恢复线程的操作,都需要转入内核态中完成,很影响系统的并发性能;同时,在许多应用上共享数据的锁定状态只是暂时,没必要去挂起和恢复线程。
    • 自旋锁:当物理机器有多个处理器使得多个线程同时并行执行时,先让后请求锁的线程等待,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁,这时只需让线程执行一个忙循环,即自旋

    注意:自旋等待不能代替阻塞,它虽然能避免线程切换的开销,但会占用处理器时间,因此自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍未成功获锁,就需要挂线程了。

    • 自适应自旋锁:自旋的时间不再固定,而是由该锁上的上次自旋时间及锁的拥有者的状态共同决定。具体表现是:
      • 如果对于某个锁,自旋等待刚刚成功获得,且持有锁的线程正在运行中,那么虚拟机很可能允许自旋等待的时间更久点。
      • 如果对于某个锁,自旋很少成功获得过,那么很可能以后将省略自旋等待这个锁,避免浪费处理器资源。
  • 锁消除(Lock Elimination)

    • 锁消除:指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
    • 判定依据:如果一段代码中上的所有数据都不会逃逸出去被其他线程访问到,可把它们当做上数据对待,即线程私有的,无须同步加锁。
  • 锁粗化(Lock Coarsening)

一般情况下,会将同步块的作用范围限制到只在共享数据的实际作用域中才进行同步,使得需要同步的操作数量尽可能变小,保证就算存在锁竞争,等待锁的线程也能尽快拿到锁。

但如果反复操作对同一个对象进行加锁和解锁,即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗,此时,虚拟机将会把加锁同步的范围粗化到整个操作序列的外部,这样只需加一次锁。

  • 轻量级锁(Lightweight Locking)

    • 目的:在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,注意不是用来代替重量级锁的。

    首先先理解HotSpot虚拟机的对象头的内存布局:分为两部分

    • 第一部分用于存储对象自身的运行时数据,这部分被称为Mark Word,是实现轻量级锁和偏向锁的关键。如哈希码、GC分代年龄等。
    • 另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象还会有一个额外的部分用于存储数组长度。

    HotSpot虚拟机对象头 Mark Word

    • 加锁过程:代码进入同步块时,如果同步对象未被锁定(锁标志位为01),虚拟机会在当前线程的栈帧中建立一个名为Lock Record的空间,用于存储锁对象Mark Word的拷贝。如下图。
      轻量级锁CAS操作之前堆栈与对象的状态

      之后虚拟机会尝试用CAS操作将对象的Mark Word更新为指向Lock Record的指针。若更新动作成功,那么当前线程就拥有了该对象的锁,且对象Mark Word的锁标志位变为00,即处于轻量级锁定状态;反之,虚拟机会先检查对象的Mark Word是否指向当前线程的栈帧,若当前线程已有该对象的锁,可直接进入同步块继续执行,否则说明改对象已被其他线程抢占。如下图。

      轻量级锁CAS操作之后堆栈与对象的状态

      如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志位变为10,Mark Word中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态。

    • 解锁过程:若对象的Mark Word仍指向着线程的Lock Record,就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。若替换成功,那么就完成了整个同步过程;反之,说明有其他线程尝试获取该锁,那么就要在释放锁的同时唤醒被挂起的线程。

    • 优点:因为对于绝大部分的锁,在整个同步周期内都是不存在竞争的,所以轻量级锁通过使用CAS操作消除同步使用的互斥量。

  • 偏向锁(Biased Locking)

    • 目的:消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。

    • 含义:偏向锁会偏向于第一个获得它的线程,如果在后面的执行中该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

    • 加锁过程:启用偏向锁的锁对象在第一次被线程获取时,Mark Word的锁标志位会被设置为01,即偏向模式,同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word中。若操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时都可不再进行任何同步操作。

    • 解锁过程:当有另外的线程去尝试获取这个锁时,根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定01或轻量级锁定00的状态,后续的同步操作就如轻量级锁执行过程。如下图。

      偏向锁、轻量级锁的状态转化及对象Mark Word的关系

    • 优点:可提高带有同步但无竞争的程序性能,但若程序中大多数锁总被多个线程访问,此模式就没必要了。

参考

简书-厘米姑娘:要点提炼| 理解JVM之线程安全&锁优化

0%