【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 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。如果发现当前版本号和之前读到的版本号一致,就真正执行修改操作,并让版本号自增。如果发现当前版本号比之前读到的版本号大,就认为操作失败。

结语:

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

相关推荐
Abladol-aj1 小时前
并发和并行的基础知识
java·linux·windows
清水白石0081 小时前
从一个“支付状态不一致“的bug,看大型分布式系统的“隐藏杀机“
java·数据库·bug
Elihuss2 小时前
ONVIF协议操作摄像头方法
开发语言·php
Swift社区5 小时前
在 Swift 中实现字符串分割问题:以字典中的单词构造句子
开发语言·ios·swift
没头脑的ht5 小时前
Swift内存访问冲突
开发语言·ios·swift
没头脑的ht5 小时前
Swift闭包的本质
开发语言·ios·swift
wjs20245 小时前
Swift 数组
开发语言
吾日三省吾码6 小时前
JVM 性能调优
java
stm 学习ing6 小时前
FPGA 第十讲 避免latch的产生
c语言·开发语言·单片机·嵌入式硬件·fpga开发·fpga
Estar.Lee6 小时前
查手机号归属地免费API接口教程
android·网络·后端·网络协议·tcp/ip·oneapi