多线程进阶(上篇)--- 常见锁策略、CAS、Synchronized 原理以及相关面试题~~

本文详细介绍了多线程中的各种锁策略,包括乐观锁与悲观锁的工作原理,读写锁的使用场景,重量级锁与轻量级锁的区别,自旋锁与挂起等待锁的特性,以及公平锁与非公平锁的对比。此外,还探讨了CAS操作及其在实现原子类和自旋锁中的应用,以及ABA问题和解决方案。最后,概述了Synchronized的基本特性和加锁过程,包括偏向锁、轻量级锁和重量级锁的转变。
摘要由CSDN通过智能技术生成

目录

🌲一、常见锁策略

⚽1. 乐观锁 VS 悲观锁

⚾2. 读写锁

🥎3. 重量级锁 VS 轻量级锁

🏀4. 自旋锁(Spin Lock) VS 挂起等待锁

🏐5. 公平锁 VS 非公平锁

🏈6. 可重入锁 VS 不可重入锁

🎱7. 相关面试题

🌳二、CAS

💐1. 什么是 CAS

🌸2. CAS 是怎么实现的(稍微了解一下就行了)

🌹3. CAS 有哪些应用

🍋3.1 实现原子类

🍊3.2 实现自旋锁

🌻4. CAS 的 ABA 问题

🍉4.1 什么是 ABA 问题

🍈4.2 ABA 问题引起的 BUG

🍎4.3 解决方案

🌷5. 相关面试题

🌴三、Synchronized 原理

🥜1.基本特点

🥕2.加锁过程

🍱2.1偏向锁

🥡2.2轻量级锁

🍘2.3重量级锁

🧅3.其他优化操作

🍵3.1锁销除

☕3.2锁粗化

🌽4.相关面试题


                                                        哥几个来学多线程啦~~

                                                       

🌲一、常见锁策略

锁策略就是用来研究如何加锁才最合适的。

锁策略不只是Java有,任何和锁相关的地方都可能涉及到锁策略~~

⚽1. 乐观锁 VS 悲观锁

乐观锁:

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

悲观锁:

总是做最坏的打算,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿到这个数据就必须阻塞等待直到它拿到锁。

synchronized开始使用的是乐观锁,当发现锁竞争过于频繁,就会自动转换为悲观锁。

上面提到乐观锁的一个重要作用就检测数据是否发生并发冲突,它的通过检测版本号来实现的:

有那么一个场景:我们需要使用多个线程对银行余额进行修改,假设余额 Balance = 100,版本号 Version = 1,并且我们规定提交版本必须大于当前版本才能更新余额

🍕(1)线程1工作内存里的余额为100,版本号为1。线程2工作内存里的余额为100,版本号为1。主内存里的余额为100,版本号为1。

🍔 (2)在两个线程操作过程中,线程1工作内存里的余额扣50(Balance = 50),线程2工作内存里的余额扣20(Balance = 80)。

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

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

 

⚾2. 读写锁

        在多线程中,只读数据(两个线程读取同一个数据)是不会产生线程安全问题的,但是边写边读(一个线程读取数据,一个线程写入数据)或只读(两个线程写入数据)是会产生线程安全的。如果“只读”和“边写边读或只读”这两种情况都共用同一种锁,那么就会产生很多不必要的开销。因此,读写锁应运而生。

        

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

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

其中

  • 读加锁与读加锁之间不互斥
  • 写加锁与读加锁之间互斥
  • 写加锁与写加锁之间互斥

注意:

        只要涉及到“互斥”,线程就会挂起等待,一旦挂起就不知道什么时候能被唤醒了,因此应该尽量避免“互斥”现象的出现。

读写锁比较适合“频繁读,不频繁写”的场景。

synchronized不是读写锁。

🥎3. 重量级锁 VS 轻量级锁

重量级锁:

  • 大量的内核态用户态之间的切换
  • 很容易引发线程的调度
  • 锁的开销比较大,做的事情比较多
  • 重量级锁由于依赖操作系统提供的锁,因此容易产生阻塞等待

轻量级锁:

  • 少量的内核态用户态之间的切换
  • 不容易引发线程的调度
  • 锁的开销比较小,做的事情比较少
  • 尽量在用户态下完成功能,尽量避免用户态内核态之间的切换,尽量避免线程挂起等待

悲观锁往往是重量级锁,乐观锁往往是轻量级锁,但是并不绝对,悲观锁也有可能是轻量级锁,乐观锁也有可能是重量级锁。(感觉说了,又感觉没说~~)

                                                        

synchronized是自适应锁,初始态是轻量级锁,如果锁冲突频繁,就会自动切换为重量级锁。

🏀4. 自旋锁(Spin Lock) VS 挂起等待锁

自旋锁:

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

        自旋锁就是如果获取锁失败,就会马上重新尝试获取锁,直到获取到锁为止(第一次获取失败,第二次尝试获取就会马上到来)。

自旋锁伪代码:

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

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

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

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

挂起等待锁:

        挂起等待锁就是如果获取锁失败,并不会马上尝试获取锁,而是挂起等待一段时间,等操作系统调度的时候再重新获取锁。

挂起等待锁是一种典型的 重量级锁 的实现方式。

挂起等待锁优点:在锁被其他线程持有时,会放弃CPU资源,因此不会消耗CPU资源。

挂起等待锁缺点:再其他线程释放锁时,不能第一时间获取锁,导致程序运行效率变慢。

🏐5. 公平锁 VS 非公平锁

公平锁:

        公平锁遵循“先来后到”,如果B线程比C线程先尝试获取锁,那么当A线程释放锁之后,B线程回比C线程先获得锁。

非公平锁:

        非公平锁不遵循“先来后到”,即使B线程比C线程先尝试获取锁,那么当A线程释放锁之后,B线程、C线程都有几率获得锁。

注意:

  • 操作系统调度线程时无序的、随机的,因此,如果不做任何额外的限制,那么锁就是非公平锁。如果想要实现公平锁,那么就要使用额外的数据结构来记录线程的顺序。
  • 公平锁和非公平锁没有好坏之分,要看使用场景

synchronized是非公平锁

🏈6. 可重入锁 VS 不可重入锁

可重入锁:

可重入锁就是允许同一个线程可以多次获取同一把锁。

实现方式:

在锁中记录该锁持有的线程身份,以及一个计数器(记录加锁次数)。如果发现当前加锁的线程就是持有锁的线程,则直接计数自增。

不可重入锁:

不可重入锁就是不允许同一个线程多次获取同一把锁。

如何理解这句话呢?

来看代码:

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

第一次加锁的时候,加锁成功。在第二次加锁的时候会阻塞等待,直到第一次的锁被释放,才能获取到第二个锁。但是释放第一个锁也是由该线程来完成的,结果这个线程还在搁这 阻塞等待 呢,就无法进行解锁的操作,这时候就会 死锁 。

也就是:第一次释放锁依赖于第二次获取锁,第二次获取锁依赖于第一次释放锁。

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

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

🎱7. 相关面试题

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

理解:

        悲观锁认为多个线程访问同一个共享变量冲突的概率较大,会在每次访问共享变量之前都去真正加 锁。

        乐观锁认为多个线程访问同一个共享变量冲突的概率不大,并不会真的加锁,而是直接尝试访问数 据. 在访问的同时识别当前的数据是否出现访问冲突。

实现:

        悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex),获取到锁再操作数据。获取不到锁就 等待。

        乐观锁的实现可以引入一个版本号,借助版本号识别出当前的数据访问是否冲突。

2) 介绍下读写锁?

读写锁就是把读操作和写操作分别进行加锁。

  • 读锁和读锁之间不互斥。
  • 写锁和写锁之间互斥。
  • 写锁和读锁之间互斥。

读写锁最主要用在 "频繁读, 不频繁写" 的场景中。

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

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

相比于挂起等待锁:

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

4) synchronized 是可重入锁么?

是可重入锁.。

可重入锁指的就是连续两次加锁不会导致死锁。

实现的方式是在锁中记录该锁持有的线程身份,以及一个计数器(记录加锁次数)。如果发现当前加锁的线程就是持有锁的线程,则直接计数自增。

🌳二、CAS

💐1. 什么是 CAS

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

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

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

CAS伪代码:

    public static boolean CAS(int address, int expectValue, int swapValue) {
        if (address == expectValue) {
            address = swapValue;
            return true;
        }
        return false;
    }

真实的CAS是一个原子的硬件指令完成的,伪代码只是让大家更好地理解。

🌸2. CAS 是怎么实现的(稍微了解一下就行了)

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

  • java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
  • unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
  • Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性。

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

🌹3. CAS 有哪些应用

🍋3.1 实现原子类

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

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

    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(0);
        //创建一个AtomicInteger类的对象,它的初始值为 0
        System.out.println("自增前" + atomicInteger.getAndIncrement());
        //incrementAndGet()方法相当于 atomicInteger++ 操作,先获取再自增
        System.out.println("自增后" + atomicInteger);
        //再次打印该值
    }

伪代码实现:

    public int getAndIncrement() {
        int oldvalue = value;
        while ( CAS(value, oldvalue, oldvalue + 1) != true) {
            oldvalue = value;
        }
        return oldvalue;
    }

如何理解上述代码呢?

 假设同时有两个线程调用了getAndIncrement()方法

1)两个线程都读取内存里的value值到oldvalue中

 2)线程1先执行CAS操作,由于oldvalue的值与value的值相同,那么可以直接进行替换。

注意:

  • 寄存器操作是直接写入内存的,而不是寄存器操作
  • CAS读内存、比较、写内存是原子的操作,是一条硬件指令

 3)线程2再执行CAS操作,第一次进行CAS的时候发现oldvalue与value不同,不能进行赋值,那么就会进入循环体内,执行oldvalue = value操作

4)接下来进行CAS操作发现oldvalue与value相等了,然后就会执行赋值操作

 5)线程1和线程2各自返回oldvalue即可

利用以上操作就能实现一个原子类,不用使用重量级锁,可以更加高效地完成多线程的自增操作

 

🍊3.2 实现自旋锁

基于CAS完成更灵活的锁,获取更多的控制权。

自旋锁伪代码:

class spinLock {
    private Thread owner = null;

    public void lock() {
        //通过CAS查看当前锁是否被某个线程持有
        //如果当前锁被其他线程持有,则自旋等待
        //如果这个锁没有被其他线程持有,那么就把owner设置为当前尝试获取锁的线程
        while (!CAS(this.owner, null, Thread.currentThread())){
        }
    }

    public void unlock() {
        //释放锁
        this.owner = null;
    }
}

🌻4. CAS 的 ABA 问题

🍉4.1 什么是 ABA 问题

假设存在两个线程,线程1的oldvalue不变,值为A。线程2的oldvalue从 A 变到 B,再由 B 变到 A

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

🍈4.2 ABA 问题引起的 BUG

假设:小白由100存款,小白想从ATM里取50元,取款器创建了两个线程,并发执行扣50元的操作,我们预期是一个线程执行扣50元成功,另一个线程扣50元失败。

此时如果使用CAS的方式来完成这个扣款过程就有可能出现问题:

正常情况:

1)存款里有100元,线程1读取到当前存款为100元,期望更新为50元。线程2读取到当前存款为100元,期望更新为50元。

2)线程1率先执行CAS,发现线程1栈内存里的值与存款相等,可以执行扣款操作,于是将存款扣50元。

3)线程2再执行CAS,发现线程2栈内存里的值为100,与存款50不同,于是扣款失败。

异常情况:

1)存款里有100元,线程1读取到当前存款为100元,期望更新为50元。线程2读取到当前存款为100元,期望更新为50元。

2)线程1率先执行CAS,发现线程1栈内存里的值与存款相等,可以执行扣款操作,于是将存款扣50元。

3)再线程2执行之前,小棕给小白转了50元,此时存款就变为100元了。

4)当线程2在执行CAS时,发现线程2栈内存里的值为100,而存款也为100元,于是扣款成功,存款变为50元。

这样两个线程共计扣了两次50元😢,这都是ABA问题的锅~~

我感觉小白要裂开了~~

 

🍎4.3 解决方案

给需要修改的值引入版本号。在CAS比较数据当前值和旧值的时候也要比较版本号,如果当前版本号(version)与读到的版本号(oldversion)相同,则修改数据,并把版本号 + 1。如果当前版本号(version)大于读到的版本号(oldversion),则不允许修改。

以上面存款为例:

0)我们给余额设置一个版本号,初始为1。

1)存款里有100元,线程1读取到当前存款为100元、版本号为1,期望更新为50元。线程2读取到当前存款为100元、版本号为1,期望更新为50元。

2)线程1率先执行CAS,发现线程1栈内存里的值与存款相等,可以执行扣款操作,于是将存款扣50元,同时存款的版本号变为 2。

3)再线程2执行之前,小棕给小白转了50元,此时存款就变为100元了,版本号变为 3。

4)当线程2在执行CAS时,发现线程2栈内存里的值为100,而存款也为100元,但是线程2栈内存里的版本号为 1,小于存款的版本号 3,于是扣款失败。

🌷5. 相关面试题

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

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

2)ABA问题怎么解决?

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

🌴三、Synchronized 原理

🥜1.基本特点

在Java1.8中:

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

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

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

🌭4.是一种不公平锁。

🍿5.是一种可重入锁。

🥓6.不是读写锁。

🥕2.加锁过程

🍱2.1偏向锁

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

偏向锁并不是真正的“加锁”,而是在对象头加一个“偏向锁标记”,记录当前锁是属于哪个线程的。

如果后续没有其他线程来竞争该锁,那么就不用进行其他同步操作了(避免了加锁的开销)

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

偏向锁就是一个“延迟锁”,非必要不加锁,减少不必要加锁的开销。

🥡2.2轻量级锁

随着其他线程的竞争,偏向锁转为轻量级锁。

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

  • 通过CAS检查并更新一块内存(比如 null => 该线程引用)
  • 如果更新成功,则认为加锁成功
  • 如果更新失败,则认为锁被占用,继续自旋式地等待(并不放弃CPU)

注意:

自旋锁操作会一直让CPU空转,因此比较浪费CPU资源

所以此处的自旋并不会一直进行,而是到达一定时间 / 重试次数,就不再自旋了。

🍘2.3重量级锁

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

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

  • 执行加锁操作,先进入内核态
  • 在内核态判定当前锁是否被占用
  • 如果该锁没有被占用,则加锁成功,切换为用户态
  • 如果该锁被占用,则加锁失败。此时线程进入了锁的等待队列,挂起,等待被操作系统唤醒。
  • 经历了这一系列操作,这个锁已经被线程释放掉了,操作系统也想起了这个挂起的线程,于是唤醒这个线程,重新尝试获取锁

🧅3.其他优化操作

🍵3.1锁销除

编译器 + JVM来判断该锁是否能被消除

锁销除就是在有些应用场景下,用到了synchronized ,但是没有在多线程的环境下:

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

就像以上这个代码,使用了许多次append操作,但是是在单线程的情况下完成的,这时候就没有必要进行加锁操作,以减少资源开销。

☕3.2锁粗化

如果一段代码出现了多次加锁解锁操作,那么编译器 + JVM 就会自动进行锁粗化:

 

🌽4.相关面试题

1) 什么是偏向锁?

偏向锁不是真的加锁,而只是在锁的对象头中记录一个标记(记录该锁所属的线程)。如果没有其他线 程参与竞争锁,那么就不会真正执行加锁操作,从而降低程序开销。一旦真的涉及到其他的线程竞争,再取消偏向锁状态,进入轻量级锁状态。

2)synchronized 实现原理是什么?

参考上文🥰

 

                        好啦,这就是多线程进阶上篇啦,感谢大家阅读~~

                                                        

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值