目录
哥几个来学多线程啦~~
🌲一、常见锁策略
锁策略就是用来研究如何加锁才最合适的。
锁策略不只是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
- 比较A与V是否相等。(比较)
- 如果比较相等,将B写入V。(交换)
- 返回操作是否成功。
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 实现原理是什么?
参考上文🥰
好啦,这就是多线程进阶上篇啦,感谢大家阅读~~