目录
1.4.1、读写锁的使用场景(适用于"频繁 读,不频繁写"的场景)
3.2、synchronized加锁工作过程(锁膨胀/锁升级)
锁的实现者,预测接下来所冲突的概率是比较大,还是比较小,根据这个冲突的概率,来决定接下来该咋做。
- 乐观锁:预测锁冲突比较小。假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
- 悲观锁:预测锁冲突比较大。总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到他拿到锁。
【举个🌰】:2022年国家疫情放开,有的人听到这个消息,是悲观的态度,想着社会上有大面积的人感染,为了和这些人不接触,所以这些比较悲观的人会屯大量的物资。所以悲观锁一般要做的工作更多一些,效率会更低一些。乐观的人,提到这个消息,无所谓,既然国家都放开了,这就说明病毒的已经没有太大的威胁了,也就没有屯物资。所以乐观锁做的工作就会更少一点,效率更高一点。但这并不绝对。
- 重量级锁:加锁解锁过程更慢,更低效。加锁机制重度依赖了OS提供了mutex,大量的内核态和用户态的切换,很容易引发线程的调度。
- 轻量级锁:加锁解锁工程更快,更高效。加锁机制尽可能不适用mutex,而是尽量在用户态代码完成。是在搞不定了,在使用mutex。少量的内核态和用户态的切换,不太容易引发线程调度。
也可以认为一个乐观锁很可能是一个轻量级锁,一个悲观锁很可能也是一个重量级锁(但是这个结论不绝对)
自旋锁是轻量级锁的一种典型实现,挂起等待锁是重量级锁的一种典型实现。
- 自旋锁:如果获取锁失败了,立即再次尝试获取锁,无限次循环,直到获取到锁为止,第一次获取锁失败,第二次的尝试会在极短的时间内到来。一旦锁被其他线程释放,就能第一时间获取到锁。(通常自旋锁是纯用户态的不需要经过内核态,获取锁的时间相对更短)
- 挂起等待锁:当某个线程在获取到锁的时候,其他那些没有获取到锁的线程只能挂起等待,此时这些线程会被CPU调度走,等到锁被释放,这些被CPU调度走的线程,在被CPU调度回来后,就会重新进行锁竞争。(通过内核的机制来实现挂起等待,获取锁的时间更长了)
【举个🌰】:比如我们在追求自己的女神的时候,被发好人卡,这个时候我们没有气馁,和之前一样每天给女神发,早安,晚安。有一天女神和自己的男朋友分手了,这个时候,我们就能第一时间抓住时机上位。这就相当于自旋锁,他会一直占用CPU的资源,进行忙等;而另一种情况,就是我们在被拒绝之后,潜心敲代码,将女神抛掷脑后,突然有一天女神说要不咱俩处对象试试。这种情况就相当于挂起等待锁,CPU将没有获得锁的线程调度去干别的事情,当“女神”这个锁被释放了,在将这些线程调度回来,进行锁竞争,然后某个线程获取锁。
✨ 自旋锁的优缺点:
- 优点:没有放弃CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁。
- 缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗CPU资源。(而挂起等待的时候是不消耗CPU的)。
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的。
- 互斥锁:是一种独占锁,之前的博客中使用synchronized加锁之后,线程A获得这个锁了,线程A在没有释放这个锁之前,那么线程B就会加锁失败,失败的线程B就会释放CPU让给其他线程,既然线程B释放掉了CPU,自然线程B加锁的代码就会被阻塞。对于互斥锁只有两个操作:加锁和解锁。只有两个线程针对同一个锁对象加锁时,才会产生锁竞争(互斥)。
- 读写锁:一个线程对于数据的访问,主要存在两种操作:读数据和写数据。读写锁就是把读操作和写操作区分对待。对于读写锁来说,分为三个操作:读加锁,写加锁,解锁。
✨读写锁中约定:
- 读锁和读锁之间,不会锁竞争,不会产生阻塞等待。(不会影响程序的速度,代码还是可以跑的很快)
- 写锁和写锁之间,有锁竞争。
- 读锁和写锁之间,也有锁竞争(2,3这两种,速度虽然减慢,但是保证了准确性)。
读写锁就是把读操作和写操作区分对待,Java标准库提供了ReentrantReadWriteLock类,实现了读写锁。
- ReentrantReadWriteLock.ReadLock类表示一个读锁,这个对象提供了lock/unlock方法进行加锁解锁。
- ReentrantReadWriteLock.WriteLock类表示一个写锁,这个对象也提供了lock/unlock方法进行加锁解锁。
比如学习通,每节课老师都要使用学习通点名,点名就需要查看班级的同学列表(读操作),这个操作可能要每周执行好几次,而什么时候修改同学列表呢(写操作)?就是有同学加入这个班级的时候,可能一年都不必改一次。
再比如,同学使用学习通查看作业时(读操作),一个班级的同学很多,多操作一天就要进行几十次,但是这一节课的作业,老师只是布置了一次(写操作)。
❗❗❗总结
- 读写锁在多个线程进行读一个数据的时候,此时并没有线程安全问题,直接并发的读取即可
- 多个线程都要写一个数据的时候,有线程安全问题。这个时候就需要对这个数据进行加锁。
- 多个线程,一些在读数据,一些在修改这些数据,也存在线程安全问题,这个时候就要正对这个数据进行读加锁和写加锁。(在写的时候,不允许读;再读的时候,不允许写)
- synchronized不是读写锁
一个线程,针对一把锁,连续加锁两次。
- 出现了死锁,就是不可重入锁;
- 不出现死锁,就是可重入锁
Java里面只要是以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有线程的Lock实现类,包括synchronized关键字锁都是可重入的。
我们通过一个伪代码来了解一下可重入锁和不可重入锁。
- class BlockingQueue{
- synchronized void put(){
- this.size();
- }
-
- synchronized int size(){
- }
- }
-
- public static void main(String[] ){
- BlockingQueue queue = new BlockingQueue();
- Thread t = new Thread(()->{
- queue.put();
- });
这个时候,两个方法的锁对象都是queue,t线程调用put方法,针对锁对象queue,put方法进行了加锁,这个时候t线程被占用了,但是在执行put的方法体的时候,size方法也针对queue对象加锁,这个时候第二个锁尝试加锁,需要等待第一个锁被释放。第一个锁要释放,就需要第二个锁加锁成功。这在逻辑上就矛盾了。也就形成了死锁。这样的锁称为不可重入锁。上述使用synchronized对不可重入锁进行了逻辑上的讲解,但是synchronized是可重入锁。
❓❓❓上述这种情况在我们的日常开发中很容易遇到,当遇到这种情况的时候,就真的的死锁了吗?
❗❗❗当然是不会,因为我们的synchronized是个"可重入锁"。在上述的场景中不会死锁,一个线程在第二次对同一个锁对象加锁的时候会判定一下,看当前尝试申请锁的线程是不是已经就是锁的拥有者了,如果是,则直接放行。
1️⃣上述说到的,一个线程,一把锁,加锁两次,可重入锁没事,不可重入锁死锁了。
2️⃣两个线程两把锁,即使是可重入锁,也会死锁。
3️⃣ N个线程,M把锁。
这里通过哲学家,就餐问题来了解这种死锁情况。
✨死锁的四个必要条件(只要发生死锁,这四个条件都有体现。)
- 互斥使用:一个线程拿到一把锁之后,另一个线程不能使用。(锁的基本特点)
- 不可抢占:一个线程拿到所,只能自己主动释放,不能被其他线程强行占有【挖墙脚行为是不行的】(锁的基本特点)
- 请求和保持:就像上面的例子,哲学家拿到一个筷子之后,去哪另一支筷子,拿到的绝不放手。【吃着碗里的,惦记锅里的】(代码的特点,看我们自己怎样设计代码)
- 循环等待:上面的哲学家例子中,同时5个哲学家同时拿起左手边的筷子,想要拿起另一支筷子吃面条。这个时候就形成了循环等待。(代码的特点)
❓❓❓死锁是一个比较严重的bug,实践中如何避免出现死锁呢?
❗❗❗这个时候,很多老铁想到了银行家算法,但是这里我们并不推荐这个写法,因为银行家算法实现起来比较复杂,再开发中,追求的是简单可靠。
- 所以这里我们推荐一个简单有效的做法,可以通过破解死锁的必要条件中的一个,就可以避免死锁的发生。这里最好破解的就是循环等待这个条件。
- 我们针对锁进行编号,如果需要同时获取多把锁,约定加锁顺序,务必是先对小的编号加锁,后对大的编号加锁。这里还是通过哲学家就餐的例子来理解破解的方法
再代码的设计时,让多个线程按照顺序加锁就可以了,多个线程多把锁,让其中两个线程同时获取最小的一把锁,这个时候就会形成一个线程一个锁都没有拿到。这个时候就会将死锁破解。
- 公平锁:多个线程等待同一个锁的时候,谁先来,谁就先获取到这把锁(遵守先来后到)
- 非公平锁:多个线程在等待同一个锁的时候,不遵守先来后到(每个等待的线程获取到锁的概率时均等的)。
❗❗❗注意:
- 操作系统内部的线程调度就可以视为时随机的,如果不做任何额外限制,锁就是非公平锁,如果要想实现公平锁,就需要依赖额外的数据结构(队列),来记录线程门的先后顺序。
- 公平锁和非公平锁没有好坏之分,关键还是看使用场景。
- synchronized是非公平锁。
CAS的全称就是Compare and swap(比较和交换)
这里是将寄存器A的值和内存M的值进行对比,如果值相同,就把寄存器B和内存M的值进行交换。
我们通过下面的这个不是原子的伪代码来了解CAS的硬件指令,真实的CAS是原子的硬件指令来完成的,这个伪代码只是辅助理解CAS的工作流程。
此处所谓的CAS指的是CPU提供的一个单独的CAS指令,通过这一条指令,就完成上述伪代码描述的过程,CPU指令已经是不可分割的最小单位。当多个线程同时对某个资源进行CAS操作,只能由一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
CAS可以视为是一种乐观锁(或者可以理解成CAS是乐观锁的一种实现方式)。
CAS最大的意义:可以让我们在写多线程代码的时候不加锁,就能够保证线程安全。
当多线程编程的时候,我们既要保证线程安全,又不想加锁,就可以使用CAS进行"无锁编程",下面的原子类和自旋锁都是无锁编程中的一些具体实现。
标准库中提供了Java.util.concurrent.atomic包,里面的类都是基于这个方式实现的,典型的就是Atomiclnteger类,其中getAndIncrement相当于i++操作。
我们创建AtomicInteger类对象,在线程t1和t2线程中对这个对象进行自增50000次。查看最终结果。
-
- import java.util.concurrent.atomic.AtomicInteger;
- public class ThreadDemo26 {
- public static void main(String[] args) throws InterruptedException {
- AtomicInteger num = new AtomicInteger(0);
- Thread t1 = new Thread(()->{
- for (int i = 0; i < 50000; i++) {
- //num++ 后置++
- num.getAndIncrement();
- // //++num 前置++
- // num.incrementAndGet();
- // //--num 前置--
- // num.decrementAndGet();
- // //num-- 后置--
- // num.getAndDecrement();
- }
- });
- Thread t2 = new Thread(()->{
- for (int i = 0; i < 50000; i++) {
- num.getAndIncrement();
- }
- });
- t1.start();
- t2.start();
-
- t1.join();
- t2.join();
- //get 获取到数据
- System.out.println(num.get());
- }
- }
通过这个伪代码来,了解AtomicInteger类。
❓❓❓上述伪代码中在执行CAS之前,已经将value的值赋给了oldValue,这个时候在使用CAS进行比较value和oldValue的值相等,是不是没有意义?
❗❗❗肯定是有意义的,因为在多线程的环境下,线程的调度是随机的,可能线程1在执行完oldvalue = value,这个时候线程2将,线程2寄存器中的oldvalue值修改了并且传给了内存(value改变了),这个时候再执行线程t1,这个时候内存中的值(value)就和t1线程中寄存器中的值(oldvalue)不相同了。
我们还是通过下面的伪代码来了解CAS实现自旋锁。
- public class SpinLock {
- //记录当前的锁被那个线程持有,为null就是没有线程持有。
- private Thread owner = null;
- public void lock(){
- // 通过 CAS 看当前锁是否被某个线程持有.
- // 如果这个锁已经被别的线程持有, 那么就自旋等待.
- // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
- while(!CAS(this.owner, null, Thread.currentThread())){
- }
- }
- public void unlock (){
- this.owner = null;
- }
- }
假设存在两个线程t1和t2,有一个共享变量num在内存中,初始值为A。
接下来,线程t1想使用CAS把内存中的值(num)改成Z,那么就需要
- 先从内存中读取num的值,记录到oldNum变量(寄存器)中。
- 使用CAS判定当前内存中的值num和寄存器中的值oldNum是否相等为A,为A,就将内存中的值修改成B。
但是内存中的值(num)和寄存器中的值(oldNum)相等中间存在两种情况。
- t1线程在执行上述两个操作的时候,中间没有其他线程修改内存中num的值,一直就是A
- t1线程在执行这两个操作时,可能将第一个操作执行完,t2线程被系统调度给CPU,内存中的值(num)被t2线程改成了B,又将B改成了A。
第二种情况,t1线程就无法区分但钱这个变量始终是A,还是经历了一个变化过程。(就好比我们买手机,买了一个翻新机,但是我们看不出来。)CAS只能对比值是否相同,不能确定这个值是否中间发生过改变
大部分情况下,t2线程这样的一个反复横跳改动,对于t1是否修改num是没有影响的,但是不排除一些特殊情况。
- 我们要解决这个问题,可以通过约定数据只能单方向变化(只能增加,或者只能减小),问题就迎刃而解了。
- 但是如果我们的需求是该数值,既能增加也能减小,这个时候我们可以引入另外一个版本号变量,约定版本号只能增加(每次修改,都会增加一个版本号),这样每次CAS对比的时候,就不是对比数值本身,而是对比版本号。
下面的图不是完全正确,但是在大体范围内描述了 使用版本号解决CAS的ABA问题。
只要约定版本号,只能递增或者递减,就能保证此时不会出现ABA反复横跳的问题,以版本号为基准,而不是以变量数值为基准了。
上面说到的CAS,我们用来解释了自旋锁的实现。这里来了解synchronizedla工作过程,来看synchronized里面具体都干了啥。
根据前面所说的所策略,我们就可以总结出,synchronized具有一下特性(只考虑JDK1.8)
- 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁。
- 开始时轻量级锁实现,如果锁被持有时间较长,就会转化为重量级锁。
- 实现轻量级锁的时候大概率用到自旋锁策略。
- synchronized是一种不公平锁
- synchronized是一种可重入锁
- synchronized不是读写锁
JVM将synchronized锁分为无锁,偏向锁、轻量级锁、重量级锁状态。会根据锁的竞争激烈程度,对锁状态进行升级。
- 锁的级别按照下面的先后顺序升级,我们把这个升级过程称为"锁膨胀"。
- 锁的升级是单向的,也就是说只能从低到高升级,不会出现降级的情况。
当锁升级为轻量级锁的时候,如果当前锁竞争非常的激烈,比如10个线程,竞争1个锁,1个竞争上 了,另外9个等待,也就是说这10个线程都在轻量级锁策略的情况下,那么9个线程进行自旋等待(忙等),CPU的消耗就非常大,既然如此就要将锁升级为重量级锁,在内核里进行阻塞等待,这个时候就意味着等待的线程暂时放弃CPU,有内核进行后续调度。
上述锁状态中只有偏向锁,没有介绍,我们在这里来了解一下偏向锁。
- 偏向锁不是真的"加锁",只是给对象头中做一个"偏向锁标记",记录这个锁属于那个线程。
- 如果后续没有其他线程来竞争该锁,那么此时就不用真的加锁了(避免了加锁和解锁的开销)
- 但是一旦有别的线程尝试来竞争这个锁,于是偏向锁就会立即升级为真的锁(轻量级锁),此时别的线程只能等待。(偏向锁的策略,既保证了效率,又保证了线程安全)
- 上面的锁升级是在代码运行阶段进行的优化手段,这里的锁消除是在编译阶段进行的优化手段。
- 锁消除:编译器+JVM会检测当前代码是否是多线程执行,是否有必要加锁,如果没有必要,在编写的时候又把锁给写上了,就会在编译过程中自动把锁去掉。
例如我们之前说到的StringBuffer,他是一个线程安全的字符串类 ,它的关键方法都加了synchronized关键字。
如果是单线程情况下使用StringBuffer,不会涉及线程安全问题,不需要使用synchronized关键字,但是StringBuffer类的关键方法中都加了synchronized关键字,这个时候每次调用StringBuffer类中的方法,就会经行加锁和解锁,加锁和解锁这个操作,会浪费一些资源。所以使用消除锁的策略,就能在编译阶段消除这个问题。
- 锁的粒度:synchronized代码块,包含代码的多少(代码越多,粒度越大 ;代码越少,粒度越细)。
- 一般写代码的时候,多数情况下,是希望锁的粒度更小一点(串行执行的代码少,并发执行的代码就多)。串行代码越少越少,程序执行就越快。
但是事无绝对,有的时候并不是锁的粒度越小越好,如果频繁加锁和解锁,此时编译器就可能把这个操作优化成一个粒度更粗的锁。因为每次加锁解锁,都会有开销的,尤其是释放锁之后,想要重新加锁,还需要重新竞争。
我们举个例子来看:
滑稽老哥当了领导, 给下属交代工作任务:
方式一:
- 打电话, 交代任务1, 挂电话.
- 打电话, 交代任务2, 挂电话.
- 打电话, 交代任务3, 挂电话.
方式二:
- 打电话, 交代任务1, 任务2, 任务3, 挂电话.
显然, 方式二是更高效的方案