线程安全与锁优化

一、线程安全

1、Java 语言中的线程安全

Java 中共享的数据分为 5 类:

1)不可变:不可变的对象一定是线程安全的,不需要再采取任何的线程安全保障措施

2)绝对线程安全:不管运行时环境如何,调用者都不需要额外的同步措施

3)相对线程安全:一个对象单独的操作是线程安全的,但是对于一些特定顺序的操作,就可能需要在调用端使用额外的同步手段来保证调用的正确性。比如 Hashtable,Vector

4)线程兼容:对象本身不是线程安全的,但是可以通过同步手段来保证并发时安全。比如 ArrayList,HashMap

5)线程对立:无论是否采取同步措施,都无法在多线程环境中并发使用

2、线程安全的实现方法

1)互斥同步

最基本的互斥手段就是 Synchronized 关键字。

  • 在 synchronized 关键字经过编译之后,会在同步块前后形成 monitorenter 和 monitorexit 这两个字节码指令
  • 指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象,可以是对象的 reference,或者对象实例或 class 对象
  • 执行 moniterenter 指令时,先尝试获取对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有锁,那么锁计时器+1
  • 执行 moniterexit 指令时,锁计数器-1,当计数器=0 时,锁被释放
  • synchronized 同步块对同一条线程来说是可重入的,不会出现把自己死锁的问题
  • 如果要阻塞或唤醒一个线程,需要从用户态转换到内核态,耗费很高,所以是重量级锁

ReentrantLock:

  • lock 和 unlock 配合 try/finally 语句来完成

  • 等待可中断:当持有锁的线程长时间不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情

  • 公平锁:公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来排队。synchronized 锁是非公平锁,所有的线程都有机会获得,ReentrantLock 默认也是非公平的。
  • 锁绑定多个条件:一个 ReentrantLocl 对象可以同时绑定多个 condition 对象,多次调用 newCondition 方法即可。而 synchronized 中,锁对象的 wait、notify、notifyAll 方法可以实现一个隐含的条件,但是如果要和多于一个的条件做关联,就不得不额外的添加一个锁。

JDK1.5 之前,ReentrantLock 性能好于 Synchronized。

JDK1.6之后,Synchronized 进行了很多优化,所以性能基本持平,建议使用 synchronized。

2)非阻塞同步

互斥同步会带来线程阻塞和唤醒的性能问题,也成为阻塞同步,是一种悲观的并发策略。

非阻塞同步是一种乐观的并发策略。

  • 先进行操作,如果没有其他的线程争用共享数据,那操作就成功了。如果有冲突,那么再采取其他补偿措施
  • 这种乐观的并发策略的许多实现都不需要将线程挂起,因此这种同步操作被称为非阻塞同步
  • 硬件指令集发展之后,有了 CAS 功能,才能采用这种策略
  • 原子类的 CompareAndSet、getAndIncrement 方法都使用了 Unsafe 的 CAS 操作

3)无同步方案

如果一个方法不涉及共享数据,那它自然就无须任何同步措施去保证正确性。有些代码天生就是线程安全的。

  • 可重入代码:
    • 不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入,不调用非可重入方法。
    • 如果一个方法的返回是可预测的,只要输入了相同的数据,就能返回相同的结果,那么就是线程安全的
  • 线程本地存储:
    • java 通过 threadLocal 类来实现线程本地存储功能

二、锁优化

1、自旋锁与自适应自旋

共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。

可以让线程执行一个忙循环(自旋),等待锁定状态的释放,这项技术就是自旋锁。

  • 自旋锁虽然避免了线程切换的开销,但是会占用处理器时间
  • 如果自旋超过了限定的次数还没有获得锁,那就会挂起线程
  • JDK1.6 中引入了自适应的自旋锁,意味着自旋时间不再固定了,根据上一次的结果进行推断

2、锁消除

在 JIT 运行时,对一些 代码上要求同步但是检测到不可能存在竞争的锁 进行消除。

主要依据是逃逸分析,如果一段代码中,堆上所有的数据都不会逃逸,不会被其他线程访问,那么就可以把他们当做栈上数据,是私有的,无需加锁。

3、锁粗化

如果一系列连续操作对同一个对象反复加锁和解锁,或者加锁操作出现在循环中,那么这时候可以把锁扩展到操作序列之外。

4、轻量级锁

传统的锁是重量级锁。轻量级锁是在没有多线程竞争的情况下,减少重量级锁使用操作系统互斥产生的性能消耗。但是在有竞争的情况下,除了互斥量的开销,还额外发生了 CAS 操作,因此会比重量级锁更慢。

HotSpot 虚拟机的对象(对象头部分)的内存布局:

对象头信息分为两部分:

  • Mark Word:一部分用于存储对象自身的运行时数据,比如 hashcode,GC 分代年龄
  • 另一部分存储指向方法区对象类型数据的指针

在代码进入同步块时,看一下轻量级加锁的步骤是什么样的:

  1. 当同步对象没有被锁定时,虚拟机会首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前的 mark word 拷贝。

  2. 然后虚拟机尝试通过 CAS 将 Mark Word 更新为指向 Lock Record 的指针,

    1. 成功的话,就获取了这个对象的锁,此时 Mark Word 标志位变为 01,表示处于轻量级锁定状态
    2. 失败的话,虚拟机会检查 Mark Word 是否指向当前线程的栈帧,如果当前线程已经持有锁,则继续执行,否则说明这个对象的锁被其他线程抢占了。
    3. 如果有两条线程以上来争用同一个锁,那么轻量级锁就会膨胀为重量级锁,标志位变为“10”,Mark Word 存储的就是指向重量级锁的纸质怎,后面等待锁的线程也会阻塞

轻量级锁的解锁步骤:

  1. 如果对象的 Mark Word 仍然指向线程的锁记录 Lock Record,那么用 CAS 逆向换回来,
    1. 替换成功的话就说明解锁了
    2. 不成功的话说明其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程

5、偏向锁

偏向锁是为了在无竞争的情况下把所有的同步操作都消除掉,包括 CAS。

  1. 当锁对象第一次被线程获取的时候,虚拟机会把头对象的标志位设置为“01”,即偏向模式
  2. 然后使用 CAS 操作把这个线程 ID 记录在对象的 Mark Word 之中
    1. 如果 CAS 成功,那么持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作
  3. 当另一个线程尝试去获取这个锁的时候,偏向模式就宣告结束。然后将标志位回复到“01”或者“00“
  4. 然后进入轻量级锁的执行步骤