【JavaEE精炼宝库】多线程进阶(1)常见锁策略 | CAS | ABA问题

目录

一、常见的锁策略:

[1.1 悲观锁 | 乐观锁:](#1.1 悲观锁 | 乐观锁:)

[1.2 重量级锁 | 轻量级锁:](#1.2 重量级锁 | 轻量级锁:)

[1.3 自旋锁 | 挂起等待锁:](#1.3 自旋锁 | 挂起等待锁:)

[1.4 公平锁 | 非公平锁:](#1.4 公平锁 | 非公平锁:)

[1.5 可重入锁 | 不可重入锁:](#1.5 可重入锁 | 不可重入锁:)

[1.6 互斥锁 | 读写锁:](#1.6 互斥锁 | 读写锁:)

[1.7 面试题:](#1.7 面试题:)

二、CAS

[2.1 CAS 的概念:](#2.1 CAS 的概念:)

[2.2 CAS 的实现的:](#2.2 CAS 的实现的:)

[2.3 CAS 的应用:](#2.3 CAS 的应用:)

[2.3.1 实现原子类:](#2.3.1 实现原子类:)

[2.3.2 实现自旋锁:](#2.3.2 实现自旋锁:)

[2.4 CAS 的 ABA 问题:](#2.4 CAS 的 ABA 问题:)

[2.4.1 ABA 问题的概述:](#2.4.1 ABA 问题的概述:)

[2.4.2 ABA 问题引来的 BUG:](#2.4.2 ABA 问题引来的 BUG:)

[2.5 解决方案:](#2.5 解决方案:)

[2.6 面试题:](#2.6 面试题:)


终于进入到多线程的进阶了,这里面涉及到的内容面试容易考,但是工作中很少直接用到。

一、常见的锁策略:

注意:接下来讲解的锁策略不仅仅是局限于 Java 。任何和 "锁" 相关的话题,都可能会涉及到以下内容。这些特性主要是给锁的实现者来参考的。我们了解一些,也能更加合理的使用锁。

1.1 悲观锁 | 乐观锁:

加锁的时候,预测当前锁冲突的概率是大还是小。

• 悲观锁:

预测当前锁的冲突概率大,后续要做的工作往往就会更多。加锁的开销就会更大(时间,系统资源)。

• 乐观锁:

预测当前锁的冲突概率不大,后续要做的工作往往就会更少。加锁的开销就会更小(时间,系统资源)。

synchronized 初始使用乐观锁策略。当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。所以 synchronized 既是乐观锁也是悲观锁,支持自适应。

1.2 重量级锁 | 轻量级锁:

一般来说,悲观锁往往就是重量级锁(加锁过程做的事情多),乐观锁往往就是轻量级锁(加锁过程做的事情少)。

锁的核心特性 "原子性",这样的机制追根溯源是 CPU 这样的硬件设备提供的。硬件有提供,软件层面才能实现。

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

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

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

注意:synchronized 并不仅仅是对 mutex 进行封装,在 synchronized 内部还做了很多其他的工作。

• 重量级锁:

加锁机制重度依赖了 OS 提供了 mutex。

这样做的特点有:1. 大量的内核态用户态切换。2. 很容易引发线程的调度。

这两个操作,成本比较高,一旦涉及到用户态和内核态的切换,就意味着 "沧海桑田"。

• 轻量级锁:

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

这样做的特点有:1. 少量的内核态用户态切换。2. 不太容易引发线程调度。

为什么会有这样的好处呢?举个栗子:

想象去银行办业务。在窗口外,自己做,这是用户态,用户态的时间成本是比较可控的。在窗口内让工作人员做,这是内核态,内核态的时间成本是不太可控的(可能人家处理一半,去做别的事情了)。如果办业务的时候反复和工作人员沟通,还需要重新排队,这时效率是很低的。

重量级锁、轻量级锁和悲观锁、乐观锁的概念有重合的地方,面试的时候要能转的过来。

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

1.3 自旋锁 | 挂起等待锁:

• 自旋锁:

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

自旋锁伪代码:

java 复制代码
while (抢锁(lock) == 失败) {}

⼀旦锁被其他线程释放,就能第⼀时间获取到锁(线程没有被调度)。

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

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

缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗 CPU 资源,CPU 在空转。

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

• 挂起等待锁:

是重量级锁的一种典型的实现方式,借助系统中的线程调度机制,当尝试加锁,并且锁被占用了,出现锁冲突,就会让当前这个尝试加锁的线程,被挂起(阻塞状态)。此时这个线程就不会参与线程调度了。知道这个锁被释放,然后系统才能唤醒这个线程,去尝试重新获取锁。

1.4 公平锁 | 非公平锁:

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

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

其实这两个策略都挺公平的,只是最初的 Java 大佬把先来后到定义成公平,均等机会定义成不公平。

注意:

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

• 公平锁和非公平锁没有好坏之分,关键还是看业务场景。

synchronized 非公平锁。

1.5 可重入锁 | 不可重入锁:

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

例如:一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。

Java里只要以 Reentrant 开头命名的锁都是可重入锁,而且 JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。而 Linux 系统提供的 mutex 是不可重入锁。**synchronized 是可重入锁。**这个前面几篇文章有涉及到,这里就不再赘述。

1.6 互斥锁 | 读写锁:

我们平时见到的 synchronized 是普通的互斥锁,读写锁是更加特殊的存在。

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

读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。

Java 的读写锁是这样设定的:

• 读锁和读锁之间,不会产生互斥。

• 写锁和写锁之间,会产生互斥。

• 读锁和写锁之间,会产生互斥。

突出体现的是 "读操作和读操作" 之间是共享的(不会互斥),有利于降低锁冲突的概率,提高并发能力。

注意:和之前谈到的数据库中的事务,给读操作加锁:读的时候不能写。给写操作加锁:写的时候不能读。不是一回事。这是在减低并发能力。

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

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

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

读写锁特别适合于 "频繁读,不频繁写" 的业务中。(这样的场景其实也是非常广泛存在的)。

1.7 面试题:

  1. 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?

答:悲观锁认为多个线程访问同一个共享变量冲突的概率较大,会在每次访问共享变量之前都去真正加锁。乐观锁认为多个线程访问同⼀个共享变量冲突的概率不大,并不会真的加锁,而是直接尝试访问数据。在访问的同时识别当前的数据是否出现访问冲突。悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex)获取到锁再操作数据。获取不到锁就等待。乐观锁的实现可以引入一个版本号。借助版本号识别出当前的数据访问是否冲突。

  1. 介绍下读写锁?

答: 读写锁就是把读操作和写操作分别进行加锁。读锁和读锁之间不互斥。写锁和写锁之间互斥,写锁和读锁之间互斥,读写锁最主要用在 "频繁读,不频繁写" 的场景中。

  1. 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?

答:如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止,第一次获取锁失败,第二次的尝试,会在极短的时间内到来。一旦锁被其他线程释放,就能第一时间获取到锁。

相比于挂起等待锁:

• 优点:没有放弃 CPU 资源,一旦锁被释放就能第一时间获取到锁,更高效。在锁持有时间比较短的场景下非常有用。

• 缺点:如果锁的持有时间较长,就会浪费 CPU 资源。

  1. synchronized 是可重入锁么?

答:是可重入锁。可重入锁指的就是连续两次加锁不会导致死锁。实现的方式是在锁中记录该锁持有的线程身份,以及⼀个计数器(记录加锁次数),如果发现当前加锁的线程就是持有锁的线程,则直接计数自增。

二、CAS

2.1 CAS 的概念:

CAS:全称Compare and swap,字面意思:"比较并交换"。一个 CAS 涉及到以下操作:我们假设内存中的原数据V,旧的预期值A,需要修改成的新值B。

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

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

  3. 返回操作是否成功。

这是一条 CPU 指令(原子的),可以完成比较和交换。这给我们编写线程安全的代码,打开了新世界的大门。

• CAS 伪代码:

注意:下面写的代码不是原子的,真实的 CAS 是⼀个原子的硬件指令完成的。这个伪代码只是辅助理解 CAS 的工作流程。

address 是内存地址,expectValue 和 swapValue 都是寄存器的值(CPU)。

java 复制代码
boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
 &address = swapValue;
 return true;
 }
 return false;
}

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

CAS 可以视为是一种乐观锁。(或者可以理解成 CAS 是乐观锁的一种实现方式)

2.2 CAS 的实现的:

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

• java 的 CAS 利用的是 unsafe 这个类提供的 CAS 操作。

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

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

简而言之,是因为硬件予以了支持,软件层面才能做到。

2.3 CAS 的应用:

基本涉及到锁,程序就和高性能无缘了。这里可以为无锁编程提供一些思路(当然大部分情况下,只有加锁才行)。

2.3.1 实现原子类:

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

如下案例:

java 复制代码
import java.util.concurrent.atomic.*;
public class demo1 {
    static AtomicInteger count = new AtomicInteger();
    public static void main(String[] args) throws InterruptedException {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for(int i = 0;i < 50000;i++){
                    count.getAndIncrement();
                }
            }
        };
        Thread t1 = new Thread(runnable);
        Thread t2 = new Thread(runnable);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

案例演示结果如下:

可以发现是线程安全的。因为这里的 ++ 操作是原子的。

• 伪代码实现:

java 复制代码
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 是⼀个局部变量,在栈上,每个线程有自己的栈)。
  1. 线程 1 先执行 CAS 操作。由于 oldValue 和 value 的值相同,直接进行对 value 赋值。

注意:CAS 是直接读写内存的,而不是操作寄存器。 CAS 的读内存,比较,写内存操作是⼀条硬件指令,是原子的。

  1. 线程 2 再执行 CAS 操作,第一次 CAS 的时候发现 oldValue 和 value 不相等,不能进行赋值。因此需要进入循环。在循环里重新读取 value 的值赋给 oldValue。
  1. 线程 2 接下来第二次执行 CAS,此时 oldValue 和 value 相同,于是直接执行赋值操作。
  1. 线程 1 和线程 2 返回各自的 oldValue 的值即可。

通过形如上述代码就可以实现⼀个原子类。不需要使用重量级锁,就可以高效的完成多线程的自增操作。

2.3.2 实现自旋锁:

• 自旋锁伪代码:

java 复制代码
public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有.  
        // 如果这个锁已经被别的线程持有, 那么就⾃旋等待.  
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.  
        while(!CAS(this.owner, null, Thread.currentThread())){
        }
    }
    public void unlock (){
        this.owner = null;
    }
}

2.4 CAS 的 ABA 问题:

2.4.1 ABA 问题的概述:

假设存在两个线程 t1 和 t2。有一个共享变量 num,初始值为 A。接下来,线程 t1 想使用CAS 把 num 值改成 Z,那么就需要先读取 num 的值,记录到 oldNum 变量中。使用 CAS 判定当前 num 的值是否为 A,如果为 A,就修改成 Z。

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

到了这里就有个问题:线程 t1 的 CAS 是期望 num 不变就修改。但是 num 的值已经被 t2 给改了。只不过又改成 A 了。这个时候 t1 究竟是否要更新 num 的值为 Z ?

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

2.4.2 ABA 问题引来的 BUG:

大部分的情况下,t2 线程这样的一个反复横跳改动,对于 t1 是否修改 num 是没有影响的。但是不排除一些特殊情况。

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

• 正常的过程:

存款 100,线程 1 获取到当前存款值为 100,期望更新为 50。线程 2 获取到当前存款为 100,期望更新为 50。 线程 1 执行扣款成功,存款被改成 50。线程 2 阻塞等待中。轮到线程 2 执行,发现当前存款为 50,和之前读到的 100 不相同,执行失败。

• 异常的过程:

存款 100。线程 1 获取到当前存款值为 100,期望更新为 50。线程 2 获取到当前存款为 100,期望更新为 50。线程 1 执行扣款成功,存款被改成 50。线程 2 阻塞等待中。在线程 2 执行之前,滑稽的朋友正好给滑稽转账 50,账户余额变成 100。轮到线程 2 执行了,发现当前存款为 100,和之前读到的 100 相同,再次执行扣款操作,这个时候,扣款操作被执行了两次。这就是是 ABA 问题搞的鬼。

2.5 解决方案:

给要修改的值,引入版本号(约定版本号只能加,不能减,每次操作一次余额,版本号都要 + 1)。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。CAS 操作在读取旧值的同时,也要读取版本号。真正修改的时候,如果当前版本号和读到的版本号相同,则修改数据,并把版本号 +1。如果当前版本号高于读到的版本号。就操作失败(认为数据已经被修改过了)。

可以看到:如果数据本身属于 "能加也能减",就容易出现 ABA 问题。

2.6 面试题:

1. 讲解下你自己理解的 CAS 机制:

全称 Compare and swap,即"比较并交换"。相当于通过⼀个原子的操作,同时完成 "读取内存,比较是否相等,修改内存" 这三个步骤。本质上需要 CPU 指令的支撑。

2. ABA问题怎么解决?

给要修改的数据引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。如果发现当前版本号和之前读到的版本号一致,就真正执行修改操作,并让版本号自增。如果发现当前版本号比之前读到的版本号大,就认为操作失败。

结语:

其实写博客不仅仅是为了教大家,同时这也有利于我巩固知识点,和做一个学习的总结,由于作者水平有限,对文章有任何问题还请指出,非常感谢。如果大家有所收获的话还请不要吝啬你们的点赞收藏和关注,这可以激励我写出更加优秀的文章。

相关推荐
奋斗的小花生1 小时前
c++ 多态性
开发语言·c++
魔道不误砍柴功1 小时前
Java 中如何巧妙应用 Function 让方法复用性更强
java·开发语言·python
NiNg_1_2341 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
闲晨1 小时前
C++ 继承:代码传承的魔法棒,开启奇幻编程之旅
java·c语言·开发语言·c++·经验分享
老猿讲编程1 小时前
一个例子来说明Ada语言的实时性支持
开发语言·ada
Chrikk2 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*2 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue2 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man2 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
测开小菜鸟2 小时前
使用python向钉钉群聊发送消息
java·python·钉钉