读书笔记 | Java 线程安全与锁优化

一、概述

本篇文章是基于《深入理解Java虚拟机》一书的读书笔记,针对线程安全以及同步锁的相关知识做了介绍。上一篇文章Java 内存模型与线程关注的是虚拟机如何实现并发以及并发控制,本篇文章的关注点是高效并发。文章结构如下所示:

  • 线程安全
  • 线程安全的实现方法
  • 锁优化

二、线程安全

并发能够更加充分地利用计算机资源,同时处理多个任务。但是并发首先我们需要确保的应当是正确性,其次才是实现高效的性能,并发的正确性所涉及到的就是线程安全

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

    线程安全的代码都必须具备的特征:代码本身封装了所有必要的正确性保障手段(如互斥同步),令调用者无需关系多线程问题,更无需自己采取任何措施来保证多线程的正确调用。

  • 分类:按照“安全程度”由强至弱可分为以下 5 类:

    • Ⅰ. 不可变:一定是线程安全的,外部的可见状态永远也不会改变,永远也不会看到其在多个线程之中处于不一致的状态。

      • 如果共享数据是一个基本数据类型,在定义时使用 final 关键字即可保证它是不可变的。
      • 如果共享数据是一个对象,则需要将将对象中带有状态的变量都声明为 final
    • Ⅱ. 绝对线程安全:满足上述定义的线程安全级别,即要达到“无论运行时环境如何,调用者都不需要任何额外的同步措施”的效果。但绝对线程安全的要求很严苛,即使是诸如 Java内置的 Vectors 类也无法达到这种绝对的安全!

    • Ⅲ. 相对线程安全:通常意义上的线程安全,需要保证对对象单独的操作是线程安全的,在调用时无需做额外的保障措施,但对于一些特定顺序的连续调用,可能仍然需要额外的同步手段保障调用的正确性。Java中大部分线程安全类属于这个类型,例如 VectorsHashTable 等。4

    • Ⅳ. 线程兼容:本身不是线程安全的对象通过使用正确的同步手段达到线程安全的效果。我们平常使用的绝大多数类是属于线程兼容的,例如 ArrayListHashMap 等。

    • Ⅴ. 线程对立:无论是否采用同步措施,都无法在多线程环境中并发使用的代码。Java语言天生具备多线程特性,线程对立这种排斥多线程的代码很少出现。Thread 类中的 suspend()resume() 方法就是典型的线程对立的例子,但是这两个方法已经被废弃了。

      suspend() 和 resume() 分别用于暂停和恢复线程,当两个线程同时持有一个线程对象时,如果一个尝试去暂停线程,另一个尝试去恢复线程,在并发的情况下,无论调用时是否进行了同步,目标线程都存在死锁的风险。


三、线程安全的实现方法

线程安全的实现可分为代码编程实现以及虚拟机的内部实现,本篇文章侧重点是虚拟机的实现,下面是虚拟机实现线程安全的运作过程:

  • Ⅰ. 互斥同步(Mutual Exclusion & Synchronization)
    • 同步:指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用。

    • 互斥:是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。

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

    • 悲观策略:互斥同步也成为阻塞同步,属于悲观的并发策略,认为只要不去做正确的同步措施,看到会出现问题,无论共享数据是否真的存在竞争,它都要进行加锁。

    • Java中的实现手段

      • 使用 synchronized 关键字:
        • 原理synchronized 经过编译之后,会在同步块前后形成 monitorenter 和 monitorexit 字节码指令。如果 synchronized 明确指定了锁定对象,那么锁定的就是该对象;否则就需要根据修饰的是实例方法还是类方法来获取对应的对象实例或 Class对象作为锁对象。
        • 过程:在执行 monitorenter 指令时,如果获取成功,该对象未被锁定或者当前线程已经拥有了对象的锁,锁计数器加1,相应的在执行 monitorexit 指令时锁计数器减1,当计数器为时,锁就会被释放。如果获取锁失败,当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
        • 注意synchronized 关键字是一种可重入锁,不会出现自己把自己锁死的情况。同步块在已进入的线程执行完之前,会阻塞后面的线程进入。并且由于 Java的线程是映射到操作系统的元素线程之上,因此唤醒和阻塞线程的操作都需要从用户态切换到内核态中,这种切换状态需要耗费很多时间。因此 synchronized 在 Java中属于一个重量级操作。
      • 使用 ReentrantLock 类:
        • ReentrantLock 同样为一个可重入锁,通过提供 lock()/unlock() 方法来实现同步的操作,它和 synchronized 有以下区别:
          • 等待可中断:当持有锁的线程长期不释放锁时,正在等待的线程可以选择放弃等待,改为处理其他事情。
          • 公平锁synchronized 属于非公平锁,即后阻塞的线程也能先获得锁,有可能造成线程饥饿现象。ReentrantLock 默认也是非公平锁,但可以通过构造方法传递参数改为公平锁。一般而言非公平锁的效率会比公平锁的效率快。
          • 绑定多个条件:当使用 synchronized 关键字时,如果要和多于一个的条件关联的时候,就不得不额外地添加锁。而 ReentrantLock 可以同时绑定多个 Condition 对象,只需要多次调用 newCondition() 方法即可。
      • 关于 ReentrantLocksynchronized 的选择:提倡使用 synchronized 关键字,虽然它属于重量级锁,但是 Java 已经对其做了相当多的优化用于提升性能。
  • Ⅱ. 非阻塞同步(Non-Blocking Synchronization)
    • 乐观策略:非阻塞同步属于一种乐观策略,它是基于冲突检测的,即先操作,如果没有其他线程争用数据,操作成功;如果共享数据有争用,产生了冲突,就采取其他的补偿措施,最常见的措施就是不断地进行重试,直到成功为止。
    • 由于操作和冲突检测的操作均需要具备原子性,因此需要借助硬件指令集:
      • 测试并设置(Test-and-Set)
      • 获取并增加(Fetch-and-Increment)
      • 交换(Swap)
      • 比较并交换(Compare-and-Swap, CAS)
      • 比较链接/条件存储(Load-Linked/Store-Conditional)
  • Ⅲ. 无同步方案
    • 含义:保证线程安全并不一定就要进行同步,同步只是保证共享数据争用时的正确性的手段。如果一个方法本来就不涉及共享数据,那么就不需要任何同步去保证其正确性。下面是两个例子:
    • 可重入代码(Reentrant Code)
      • 含义:可重入代码也叫纯代码(Pure Code),可以在代码指定的任意时刻中断它去执行另外一段代码,而在控制权返回之后,原来的程序不会出现任何错误。
      • 共同特征:不依赖存储在堆上的数据或公共的系统资源、用到的状态量都是由参数中传入、不调用非可重入的方法等。
      • 判断原则:如果一个方法的返回结果是可预测的,只要输入了相同的数据都能返回相同的结果,那么它就是可重入代码。
    • 线程本地存储(Thread Local Storage)
      • 含义:如果共享数据的可见范围限制在同一个线程之内,无需同步也能保证线程之间不出现数据争用问题。
      • 实现:通过 ThreadLocal 类实现本地存储的功能,它的内部是一个 Map,通过以 ThreadLocal.threadLocalHashCode 为键,本地线程变量为值的 K-V 值对进行本地变量的存储,每个线程都有独一无二的 ThreadLocal.threadLocalHashCode 值,使用这个值就可以找回对应的本地线程变量。Android 异步消息处理机制中的 Looper 对象就是通过 ThreadLocal 的方式存储的。

四、锁优化

锁优化是为了实现高效并发,在线程之间更高效地共享数据以及解决竞争问题,从而提高程序的执行效率,以下是 5 种锁优化技术:

  • Ⅰ. 自适应自旋(Adaptive Spinning)

    • 背景:在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,因为这两个操作都涉及到状态切换,很消耗资源。
    • 原理:如果物理机器有一个以上的处理器,能让两个线程同时并行执行,就可以让后面请求的线程进行忙循环等待(自旋),不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁,这项技术就是所谓的自旋锁
    • 优化:在 JDK1.6 之后引入了自适应自旋锁,自旋时间不再固定。在同一个锁对象上,自旋等待刚刚成功获得过锁,就会让该线程的自旋持续更长的时间;而如果对于某个锁自旋很少成功获得过,那在以后获取这个锁时将可能省略掉自旋过程,避免浪费处理器资源。
    • 注意事项:自旋本身避免了线程切换的开销,但是它是要占用处理器时间的,因此自旋锁对于自旋等待时间很短的线程才能显示其效果,如果等待时间过长,自旋只能白白浪费处理器资源。因此自旋锁并不能替代阻塞。
  • Ⅱ. 锁消除(Lock Elimination)

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

    • 背景:原则上我们总是推荐将同步块的范围限制的尽可能小,即只在共享数据的实际作用域中才进行同步,但如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,即便没有线程竞争,频繁地进行互斥同步操作也会造成不必要的性能损耗。
    • 例子:在一个方法中连续调用 StringBuilder 类的 append() 方法进行字符串的拼接时,虚拟机探测到有这样一串零碎的操作都是对同一个对象加锁,将会把加锁同步的范围粗化到整个操作序列的外部,即将锁扩展到第一个 append() 之前直至最后一个 append() 之后,这样只需加锁一次即可。
  • Ⅳ. 轻量级锁(Lightweight Locking)

    • 背景:对于绝大部分的锁,在整个同步周期内都是不存在竞争的。需要注意的是这是一个经验数据。
    • 目的:用于在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
    • 对象头(Object Header):对象头的 Mark Word 部分是实现轻量级锁和偏向锁的关键,在 HotSpot 虚拟机中它的标志位对应的存储内容和状态如下表所示:
    存储内容 标志 状态
    对象哈希码、对象分代年龄 01 未锁定
    指向锁记录的指针 00 轻量级锁定
    指向重量级锁的指针 10 膨胀(重量级锁定)
    空,不需要记录信息 11 GC标记
    偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向
    • 加锁过程:代码进入同步块时,如果此同步对象没有被锁定(锁标志位为01),虚拟机首先将在当前线程的栈帧中建立一个锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,如下图所示:
      读书笔记 | Java 线程安全与锁优化_第1张图片
      然后虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,如果这个更新操作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位将转变为 00,表示此对象处于轻量级锁定状态,如下图所示:
      读书笔记 | Java 线程安全与锁优化_第2张图片
      如果这个更新操作失败了,虚拟机首先检查对象的 Mark Word 是否指向当前线程的栈帧,如果是说明当前线程已经拥有了这个对象的锁,直接进入同步块执行,否则说明这个锁对象已经被其他线程抢占了。
    • 解锁过程:和加锁过程一样通过 CAS 操作完成。如果对象的 Mark Word 仍然指向线程的锁记录,就用 CAS 操作将对象当前的 Mark Word 和线程中复制的 Mark Word 拷贝替换回来,如果替换成功,整个同步过程就完成了;如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
    • 注意事项
      • 如果有两条以上的线程争用同一个锁,轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为 10。
      • 在不存在竞争的情况下,轻量级锁采用 CAS 操作避免了使用互斥量的开销,但如果存在竞争,除了互斥量的开销外还额外发生了 CAS 操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
  • Ⅴ. 偏向锁(Biased Locking)

    • 目的:消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。
    • 原理:偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要同步。
    • 过程:当锁对象第一次被线程获取的时候,虚拟机将会把对象头的标志位设置为 01,即偏向模式。同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 Mark Word 中,如果 CAS 成功,持有偏向锁的线程以后每次进入这个锁的相关同步块时,虚拟机都可以不再进行任何同步操作。
    • 注意:当有另一个线程去尝试获取这个锁时,偏向模式宣告结束。根据对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定或轻量级锁定的状态,后续的同步操作就和上面的轻量级锁那样执行。偏向锁和轻量级锁的关系如下图所示:
      读书笔记 | Java 线程安全与锁优化_第3张图片
      偏向锁可以题号带有同步但无竞争的程序性能,但如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。使用参数 -XX:-UseBiasedLocking 可禁止偏向锁优化。

五、参考

本篇文章参考自:

  • 《深入理解Java虚拟机》
    • 第五部分 高效并发
      • 第13章 Java线程安全与锁优化

要点提炼| 理解JVM之线程安全&锁优化

你可能感兴趣的:(JVM,Java,虚拟机,线程安全,锁优化)