Bootstrap

Java多线程进阶:锁策略、CAS和Synchronized的精髓解析与优化

目录

1. 锁策略

乐观锁 和 悲观锁

读写锁

重量级锁 和 轻量级锁

自旋锁 和 挂起等待锁

公平锁 和 非公平锁

可重入锁 和 不可重入锁

CAS

什么是CAS?

CAS 是怎么实现的

CAS 有哪些应用?

CAS 的 ABA 问题

ABA 问题引来的 BUG

Synchronized 原理

Synchronized的特点

Synchronized的加锁工作过程

其他的一些优化操作

锁消除

锁粗化


1. 锁策略

乐观锁 和 悲观锁

乐观锁:

假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并 发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做

悲观锁:

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这 样别人想拿这个数据就会阻塞直到它拿到锁

Synchronized初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略

乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 "版本号" 来解决

假设我们需要多线程修改 "用户账户余额",设当前余额为 100,引入一个版本号 version,初始值为 1,并且我们规定 "提交版本必须大于记录 当前版本才能执行更新余额"

(1) 线程 A 此时准备将其读出( version=1,balance=100 ),线程 B 也读入此信息( version=1,balance=100 )

(2) 线程 A 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 B 从其帐户余额中扣除 20 ( 100-20 ); 

 (3)线程 A 完成修改工作,将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=50 ),写回到内存中

 (4)线程 B 完成了操作,也将版本号加1( version=2 )试图向内存中提交数据( balance=80 ),但此时比对版本发现,操作员 B 提交的数据版本号为2,数据库记录的当前版本也为2,不满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。就认为这次操作失败

 读写锁

多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。

所以读写锁因此而产生,读写锁在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥,读写锁特别适合于 "频繁读, 不频繁写" 的场景中

一个线程对于数据的访问, 主要存在两种操作:读数据写数据

1. 两个线程都只是读一个数据,此时并没有线程安全问题,直接并发的读取即可

2. 两个线程都要写一个数据,有线程安全问题

3. 一个线程读另外一个线程写,也有线程安全问题

读写锁就是把读操作和写操作区分对待;Java 标准库提供了 ReentrantReadWriteLock 类,实现了读写锁

1. ReentrantReadWriteLock.ReadLock 类表示一个读锁,这个对象提供了 lock / unlock 方法进行 加锁解锁

2. ReentrantReadWriteLock.WriteLock 类表示一个写锁,这个对象也提供了 lock / unlock 方法进 行加锁解锁

注意:

1. 读加锁和读加锁之间,不互斥

2. 写加锁和写加锁之间,互斥

3. 读加锁和写加锁之间,互斥

只要是涉及到 "互斥",就会产生线程的挂起等待,一旦线程挂起,再次被唤醒就不知道隔了多久了,如果想要效率高,应当减少"互斥"的机会

Synchronized 不是读写锁

重量级锁 和 轻量级锁

锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的

CPU 提供了 "原子操作指令"

操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁

JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类

重量级锁:加锁机制重度依赖了 OS 提供了 mutex

1.大量的内核态用户态切换

2. 很容易引发线程的调度

这两个操作,成本比较高. 一旦涉及到用户态和内核态的切换,成本一下子就非常高了

轻量级锁:加锁机制尽可能不使用 mutex,而是尽量在用户态代码完成,实在搞不定了,再使用 mutex

1. 少量的内核态用户态切换

2. 不太容易引发线程调度

什么是用户态和内核态?

想象去银行办业务,在窗口外,自己办理业务,这是用户态 用户态的时间成本是比较可控的

在窗口内,工作人员做,这是内核态 内核态的时间成本是不太可控的 如果办业务的时候反复和工作人员沟通,还需要重新排队,这时效率是很低的,而且时间成本也很高

synchronized 开始是一个轻量级锁;如果锁冲突比较严重,就会变成重量级锁

自旋锁 和 挂起等待锁

自旋锁:

按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度;但实际大部分情况,虽然当前抢锁失败,但过不了很久,锁就会被释放;没必要就放弃 CPU~ 这个时候就可以使用自旋锁来处理这样的问题

自旋锁伪代码:

while (抢锁(lock) == 失败) {

}

如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止,一旦锁被其他线程释放, 就能第一时间获取到锁

自旋锁是一种典型的 轻量级锁 的实现方式

优点: 没有放弃 CPU, 不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁

缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗 CPU 资源(而挂起等待的时候是 不消耗 CPU 的)

synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的

挂起等待锁:

相当于自旋锁的反面例子,锁冲突时线程会离开CPU进行阻塞等待,等到锁被其他线程释放了再进入CPU运行

公平锁 和 非公平锁

假设有三个线程,A,B,C,A先尝试获取锁,获取成功,B,C再尝试获取锁的时候就失败了,都阻塞等待,那么,当A释放锁的时候,会发生什么呢

公平锁:遵守 "先来后到",B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁

非公平锁:不遵守 "先来后到",B 和 C 都有可能获取到锁

这就好比一群男生追同一个女神,当女神和前任分手之后,先来追女神的男生做她的男朋友,这就是公平锁;如果是女神不按先后顺序挑一个自己看的顺眼的,就是非公平锁

操作系统内部的线程调度就可以视为是随机的,如果不做任何额外的限制,锁就是非公平锁. 如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序

注意:synchronized 是非公平锁

可重入锁 和 不可重入锁

可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁

Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的

而 Linux 系统提供的 mutex 是不可重入锁

"把自己锁死" :一个线程没有释放锁, 然后又尝试再次加锁

// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待. 
lock();

按照之前对于锁的设定,第二次加锁的时候,就会阻塞等待;直到第一次的锁被释放,才能获取到第二个锁,但是释放第一个锁也是由该线程来完成,这个线程不释放锁,造成了死锁

这样的锁称为 不可重入锁

synchronized 是可重入锁


CAS

什么是CAS?

CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B

1. 比较 A 与 V 是否相等。(比较)

2. 如果比较相等,将 B 写入 V。(交换)

3. 返回操作是否成功

CAS 伪代码

boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false;
}

上面的代码不是原子的,真实的 CAS 是一个原子的硬件指令完成的;这个伪代码只是帮助我们能辅助理解 CAS 的工作流程

当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。

CAS 可以视为是一种乐观锁

CAS 是怎么实现的

针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:

1. java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作

2. unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg

3. Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性

实现CAS是因为硬件予以了支持,软件层面才能做到

CAS 有哪些应用?

实现原子类

标准库中提供了 java.util.concurrent.atomic 包,里面的类都是基于这种方式来实现的

典型的就是 AtomicInteger 类,其中的 getAndIncrement 相当于 i++ 操作

AtomicInteger atomicInteger = new AtomicInteger(0);
atomicInteger.getAndIncrement();// 这一步操作相当于 i++

伪代码:

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
       }
        return oldValue;
   }
}

假设两个线程同时调用 getAndIncrement

1. 两个线程都读取 value 的值到 oldValue 中 (oldValue 是一个局部变量,在栈上,每个线程有自己的栈)

 2. 线程1 先执行 CAS 操作,由于 oldValue 和 value 的值相同,直接进行对 value 赋值

CAS 是直接读写内存的,而不是操作寄存器

CAS 的读内存,比较,写内存操作是一条硬件指令,是原子的

3. 线程2 再执行 CAS 操作,第一次 CAS 的时候发现 oldValue 和 value 不相等,不能进行赋值;因此需要进入循环:在循环里重新读取 value 的值赋给 oldValue

4. 线程2 接下来第二次执行 CAS,此时 oldValue 和 value 相同,于是直接执行赋值操作 

5. 线程1 和 线程2 返回各自的 oldValue 的值即可

通过形如上述代码就可以实现一个原子类,可以高效的完成多线程的自增操作

CAS 的 ABA 问题

ABA 的问题:

假设存在两个线程 t1 和 t2. 有一个共享变量 num,初始值为 A,接下来,线程 t1 想使用 CAS 把 num 值改成 Z,那么就需要

1. 先读取 num 的值, 记录到 oldNum 变量中

2. 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z

但是,在 t1 执行这两个操作之间,t2 线程可能把 num 的值从 A 改成了 B,又从 B 改成了 A

到这里,t1 线程无法区分当前这个变量始终是 A,还是经历了一个变化过程

这就相当于,我们买一个手机,无法判定这个手机是刚出厂的新手机,还是别人用旧了,又翻新过的手机 

ABA 问题引来的 BUG

大部分的情况下,t2 线程这样的一个反复横跳改动,对于 t1 是否修改 num 是没有影响的. 但是有一些特殊情况,会使结果截然不同

举个栗子:

假设 张三 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程,并发的来执行 -50 操作. 我们期望一个线程执行 -50 成功,另一个线程 -50 失败. 如果使用 CAS 的方式来完成这个扣款过程就可能出现问题

正常情况下

1. 存款 100 线程1 获取到当前存款值为 100,期望更新为 50;线程2 获取到当前存款值为 100, 期 望更新为 50

2. 线程1 执行扣款成功,存款被改成 50. 线程2 阻塞等待中

3. 轮到线程2 执行了,发现当前存款为 50,和之前读到的 100 不相同,执行失败

异常情况下

1. 存款 100. 线程1 获取到当前存款值为 100,期望更新为 50;线程2 获取到当前存款值为 100,期望更新为 50

2. 线程1 执行扣款成功,存款被改成 50. 线程2 阻塞等待中

3. 在线程2 执行之前, 张三的朋友正好给张三转账 50,账户余额变成 100

4. 轮到线程2 执行了,发现当前存款为 100,和之前读到的 100 相同,再次执行扣款操作

这个时候, 扣款操作被执行了两次~~

解决方案

给要修改的值,引入版本号. 在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期

1. CAS 操作在读取旧值的同时, 也要读取版本号

2. 修改的时候如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1;如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了)


Synchronized 原理

Synchronized的特点

结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性:

1. 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁

2. 开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁

3. 实现轻量级锁的时候大概率用到的自旋锁策略

4. 是一种不公平锁

5. 是一种可重入锁

6. 不是读写锁

Synchronized的​​​​​​​加锁工作过程

JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级

 ​​​​​​​

1. 偏向锁

第一个尝试加锁的线程, 优先进入偏向锁状态

偏向锁不是真的 "加锁",只是给对象头中做一个 "偏向锁的标记",记录这个锁属于哪个线程. 如果后续没有其他线程来竞争该锁,那么就不用进行加锁解锁之类的操作了(避免了加锁解锁的开销)

如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了,很容易识别 当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进入一般的轻量级锁状态

偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销

2. 轻量级锁

随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态(自适应的自旋锁)

此处的轻量级锁就是通过 CAS 来实现

通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)

如果更新成功,则认为加锁成功

如果更新失败,则认为锁被占用,继续自旋式的等待(并不放弃 CPU)

自旋操作是一直让 CPU 空转,比较浪费 CPU 资源,因此此处的自旋不会一直持续进行,而是达到一定的时间/重试次数,就不再自旋了

3. 重量级锁

如果竞争进一步激烈,自旋不能快速获取到锁状态,就会膨胀为重量级锁

此处的重量级锁就是指用到内核提供的 mutex

1. 执行加锁操作,先进入内核态

2. 在内核态判定当前锁是否已经被占用

3. 如果该锁没有占用,则加锁成功,并切换回用户态

4. 如果该锁被占用,则加锁失败. 此时线程进入锁的等待队列,挂起. 等待被操作系统唤醒

5. 过了很久,当这个锁被其他线程释放了,操作系统也想起了这个挂起的线程,于是唤醒这个线程,尝试重新获取锁


其他的一些优化操作

锁消除

编译器+JVM 判断锁是否可消除. 如果可以,就直接消除

什么是锁消除?

​​​​​​​有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码,那么这些加 锁解锁操作是没有必要的,白白浪费了一些资源开销

锁粗化

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化

锁的粒度: 粗和细

实际开发过程中,使用细粒度锁,是期望释放锁的时候其他线程能使用锁

但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化,避免频繁申请释放锁

​​​​​​​​​​​​​​

谢谢大家的观看,喜欢就关注我吧,下节我会给大家带来一些关于多线程的面试题~~

;