【JavaEE】常见锁策略与CAS手术刀剖析

2年前 (2022) 程序员胖胖胖虎阿
202 0 0

文章目录

  • 一、常见锁策略
    • 1.乐观锁和悲观锁
    • 2.读写锁
    • 3.重量级锁和轻量级锁
    • 4.挂起等待锁和自旋锁
    • 5.公平锁和非公平锁
    • 6.可重入锁和不可重入锁
    • 7.synchronized是什么锁?
  • 二、CAS(Compare and swap)
    • 1.CAS是什么?
    • 2.CAS如何解决线程安全问题
    • 3.CAS 的 ABA 问题
    • 4.相关面试题
  • 三、最后

一、常见锁策略

  其实这个锁策略跟我们以后当程序猿没啥关系,跟“实现锁”的人才有关系,那我们为啥还有了解呢?

  问就是面试需要。

1.乐观锁和悲观锁

(1)乐观锁:预期锁冲突的概率很低

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

(2)悲观锁:预期锁冲突的概率很高

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

  为了更好的理解什么是悲观锁,什么是乐观锁,这里举一个例子:
  假如我今年毕业了,面临找工作,我可能会有两种态度,一种乐观,一种是悲观。
  乐观的态度:现在大环境虽然不怎么好,但是找工作还是很容易的,就不必为毕业找工作做过多的准备,也就是平时不用那么卷了。

  悲观的态度:现在大环境不好,不卷就找不到工作,毕业即失业,所以得抓紧时间卷了

  这两种态度意味着要准备的东西多少不同,悲观的态度,要卷得东西更多,准备也更多;而乐观的态度也做的事情少一点。

  因此:

乐观锁:做的工作更少,付出的成本更低,更高效
悲观锁:做的工作更多,付出的成本更高,更低效


2.读写锁

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

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

  这个读写锁一般与普通互斥锁是区分开的。
  
  对于普通的互斥锁来说,只有两个操作:

加锁和解锁。因此这里只要两个线程针对同一个对象加锁,就会产生互斥。

  对于读写锁来说,则分成了三个操作:

加读锁、加写锁、解锁。如果代码只是进行读操作,就加读锁;如果代码中进行了修改,就加写锁。

  针对读锁和读锁之间,是不存在互斥关系的;
  读锁和写锁之间,才需要互斥。

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

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

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

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

  其中

  • 读加锁和读加锁之间, 不互斥.
  • 写加锁和写加锁之间, 互斥.
  • 读加锁和写加锁之间, 互斥

【注意】

只要是涉及到 “互斥”, 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多 久了. 因此尽可能减少 “互斥” 的机会,就是提高效率的重要途径

  读写锁特别适合于 “频繁读, 不频繁写” 的场景中. (这样的场景其实也是非常广泛存在的),比如说学校的教务系统。

  Synchronized 不是读写锁


3.重量级锁和轻量级锁

  这里的重量级锁和轻量级锁与上面的悲观锁和乐观锁有一定的重叠(相似)。
  重量级锁:就是做的事情更多了,开销更大
  轻量级锁:做的事情更少了,开销更小。

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

  • CPU 提供了 “原子操作指令”.
  • 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
  • JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和ReentrantLock 等关键字和类.

【JavaEE】常见锁策略与CAS手术刀剖析

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

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

  • 大量的内核态用户态切换
  • 很容易引发线程的调度

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

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

  • 少量的内核态用户态切换.
  • 不太容易引发线程调度

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


4.挂起等待锁和自旋锁

  • 挂起等待锁往往是通过内核的一些机制来实现的,往往较重。这是重量级锁的一种典型实现
  • 自旋锁往往是通过用户态代码来实现的,往往较轻。这是轻量级锁的一种典型实现。

  自旋锁的伪代码:

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

理解自旋锁 vs 挂起等待锁:

   想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~

  挂起等待锁: 陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意,这个很长的时间间隔里, 女神可能已经换了好几个男票了).

  自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.

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

  • 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
  • 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的)

5.公平锁和非公平锁

  • 公平锁:遵守先来后到的规则。当多个线程等待一把锁的时候,先等待的,先获取到这个锁
  • 非公平锁:不遵循先来后到规则,每一个等待的线程获取到锁的机会是均等的。

【注意】

  1. 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
  2. 公平锁和非公平锁没有好坏之分, 关键还是看适用场景

6.可重入锁和不可重入锁

  可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
  比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
  Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。而 Linux 系统提供的 mutex 是不可重入锁.

  理解可重入锁最重要的是理解什么是死锁,之前文章详细介绍过死锁,这里可以借鉴一下:死锁。


7.synchronized是什么锁?

  1. 既是一个乐观锁,也是一个悲观锁,根据锁竞争的激烈程度进行自适应;
  2. 不是读写锁,只是一个普通的互斥锁;
  3. 既是一个轻量级锁,也是一个重量级锁,这也是根据锁竞争的激烈程度进行自适应;
  4. synchronized的轻量级锁是基于自旋锁来实现的,而其重量级锁是基于挂起等待锁来实现的;
  5. 是一个非公平锁;
  6. 是一个可重入锁。


二、CAS(Compare and swap)

1.CAS是什么?

  CAS: 全称Compare and swap,字面意思:”比较并交换“。
  简单来说,CAS就是拿着寄存器/内存中的值和另外一个内存的值进行比较。如果值相同了,就把另一个寄存器/内存的值和当前的这个内存进行交互。

  一个CAS的具体操作涉及到以下步骤:

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

  1. 比较 A 与 V 是否相等。(比较)
  2. 如果比较相等,将 B 写入 V。(交换)
  3. 返回操作是否成功。

  我们通过CAS的伪代码来了解一下:
  注意:这里的伪代码不是原子的,真实的CAS是一个原子的硬件指令完成的,这个伪代码只是辅助理解CAS的工作流程。

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

【JavaEE】常见锁策略与CAS手术刀剖析
  此处的CAS指的是,CPU提供一个单独的CAS指令,通过这一条指令,就可以完成上面伪代码的过程。

  那么想想,如果“一条指令”就可以完成上述的过程,那么是不是就说明了此时已经是线程安全的呢?

  为什么呢?因为我们的指令是不可分割的最小单位,我们CPU上的指令是一条一条执行的。上述的操作如果也只是一条指令的化,是不是就相当于原子的了。

  基于上述的思考,所以我们可以得出,CAS的最大意义就是可以帮助我们完成一个线程安全的代码,这种线程安全不同于用锁来实现的线程安全。


2.CAS如何解决线程安全问题

(1)基于CAS能够实现原子类
   Java标准库中提供了一组原子类,针对常用的一些int,long,int array…进行了封装,我们可以基于CAS的方式进行修改,这个操作是线程安全的。

  仔细看一下一下代码:

public class Demo8 {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger num = new AtomicInteger(0);
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                //这个方法讲相当于num++
                num.getAndIncrement();
            }
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                num.getAndIncrement();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        //通过get方法得到原子类内部的数值
        System.out.println(num.get());
    }
}

【JavaEE】常见锁策略与CAS手术刀剖析
  上述代码中AtomicInteger 类中其实还有很多其他的方法:

  //      等同于++num  
        num.incrementAndGet();
        // 等同于--num
        num.decrementAndGet();
        //等同于 num--
        num.getAndDecrement();
        //等同于 += 10
        num.getAndAdd(10);


  那么这个原子类具体背后是如何实现的呢?我们来分析一下下面的伪代码:

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

【JavaEE】常见锁策略与CAS手术刀剖析
  

  我们下面来分析一下,为什么上述实现的操作是线程安全。

【JavaEE】常见锁策略与CAS手术刀剖析

(2)实现自旋锁:基于 CAS 实现更灵活的锁, 获取到更多的控制权。

  自旋锁的伪代码:

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;
        }
    }

【JavaEE】常见锁策略与CAS手术刀剖析
  自旋锁是一个轻量级锁,也可以视为一个乐观锁。当前这把锁虽然没能立即拿到,但是预期很快就会拿到(假设锁冲突不激烈),那么短暂的自旋几次,浪费一点CPU,问题都不大;而好处就是只要锁一释放,就能立即的拿到锁。


3.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 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这个时候 t1 究竟是否要更新 num 的值为 Z 呢?

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

【JavaEE】常见锁策略与CAS手术刀剖析

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


  举一个典型的例子,了解一下ABA 问题引来的 BUG。

  滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.

  正常情况下:

【JavaEE】常见锁策略与CAS手术刀剖析  
  异常情况下,也就是引入了ABA问题,这里假设去取钱的时候,有一个朋友转了50块过来,此时就会触发ABA问题。

【JavaEE】常见锁策略与CAS手术刀剖析

  上面的两次巧合导致了存在BUG的ABA问题。

  那么我们该如何解决ABA问题呢?

我们在这里引入一个版本号,这个版本号只能变大,不能变小,那么在修改变量的时候,比较就不是比较变量本身了,而是比较版本号。

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

【JavaEE】常见锁策略与CAS手术刀剖析
  此外,还能采用时间戳的方式,因为时间也是一直往前进的。

4.相关面试题

  1. 讲解下你自己理解的 CAS 机制。
      全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作, 同时完成 “读取内存, 比
    较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的支撑.

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


三、最后

  事常与人违,事总在人为,加油。

版权声明:程序员胖胖胖虎阿 发表于 2022年10月11日 上午2:16。
转载请注明:【JavaEE】常见锁策略与CAS手术刀剖析 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...