🔥个人主页:寻星探路

🎬作者简介:Java研发方向学习者

📖个人专栏: 、《

⭐️人生格言:没有人生来就会编程,但我生来倔强!!!



目录

一、乐观锁 vs 悲观锁

二、重量级锁 vs 轻量级锁

三、自旋锁(Spin  Lock)

四、公平锁 vs 非公平锁

五、可重入锁 vs 不可重入锁

六、读写锁

七、相关面试题


#注:

        接下来讲解的锁策略不仅仅是局限于Java,任何和"锁"相关的话题,都可能会涉及到以下内容,这些特性主要是给锁的实现者来参考的。

        普通的程序猿也需要了解一些,对于合理的使用锁也是有很大帮助的。

一、乐观锁 vs 悲观锁

悲观锁:

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

乐观锁:

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

举个例子:同学A和同学B想请教老师一个问题。

        同学A认为"⽼师是比较忙的,我来问问题,⽼师不一定有空解答",因此同学A会先给老师发消息:"⽼师,你忙嘛?我下午两点能来找你问个问题嘛?"(相当于加锁操作)得到肯定的答复之后,才会真的来问问题。如果得到了否定的答复,那就等⼀段时间,下次再来和⽼师确定时间,这个是悲观锁。

        同学B认为"老师是比较闲的,我来问问题,⽼师大概率是有空解答的",因此同学B直接就来找老师,(没加锁,直接访问资源)如果老师确实比较闲,那么直接问题就解决了。如果老师这会确实很忙,那么同学B也不会打扰⽼师,就下次再来(虽然没加锁,但是能识别出数据访问冲突),这个是乐观锁。

        这两种思路不能说谁优谁劣,而是看当前的场景是否合适。

        如果当前⽼师确实比较忙,那么使用悲观锁的策略更合适,使用乐观锁会导致"白跑很多趟",耗费额外的资源。

        如果当前老师确实比较闲,那么使用乐观锁的策略更合适,使用悲观锁会让效率比较低。

Synchronized 初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略

        就好比同学C开始认为"老师比较闲的",问问题都会直接去找老师。

        但是直接来找两次老师之后,发现⽼师都挺忙的,于是下次再来问问题,就先发个消息问问⽼师忙不忙,再决定是否来问问题。

二、重量级锁 vs 轻量级锁

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

        CPU提供了"原子操作指令"。

        操作系统基于CPU的原⼦指令,实现了 mutex 互斥锁。

        JVM基于操作系统提供的互斥锁,实现了 synchronized 和 ReentrantLock 等关键字和类。

#注:

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

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

        大量的内核态用户态切换

        很容易引发线程的调度

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

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

        少量的内核态用户态切换。

        不太容易引发线程调度。

理解用户态vs内核态

想象去银行办业务。

在窗口外,自己做,这是用户态,用户态的时间成本是比较可控的。

在窗口内,工作人员做,这是内核态,内核态的时间成本是不太可控的。

如果办业务的时候反复和工作人员沟通,还需要重新排队,这时效率是很低的。

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

三、自旋锁(Spin  Lock)

        按之前的方式,线程在抢锁失败后进入阻塞状态,放弃CPU,需要过很久才能再次被调度

        但实际上,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃CPU,这个时候就可以使用自旋锁来处理这样的问题。

自旋锁伪代码:

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

        如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止,第一次获取锁失败,第⼆次的尝试会在极短的时间内到来。

        一旦锁被其他线程释放,就能第一时间获取到锁。

理解⾃旋锁 vs 挂起等待锁

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

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

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

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

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

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

synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的

四、公平锁 vs 非公平锁

        假设三个线程A、B、C,A先尝试获取锁,获取成功,然后B再尝试获取锁,获取失败,阻塞等待;然后C也尝试获取锁,C也获取失败,也阻塞等待。

        当线程A释放锁的时候,会发生啥呢?

公平锁:遵守"先来后到",B比C先来的,当A释放锁的之后,B就能先于C获取到锁。

非公平锁:不遵守"先来后到",B和C都有可能获取到锁。

        这就好一群男生追同一个女神,当女神和前任分⼿之后,先来追女神的男生上位,这就是公平锁;如果是女神不按先后顺序挑一个自己看的顺眼的,就是非公平锁。

公平锁:

非公平锁:

#注:

        操作系统内部的线程调度就可以视为是随机的,如果不做任何额外的限制,锁就是非公平锁,如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序。

        公平锁和非公平锁没有好坏之分,关键还是看适用场景。

synchronized 是非公平锁。

五、可重入锁 vs 不可重入锁

        可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁

        比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。

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

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

理解"把自己锁死"

一个线程没有释放锁,然后又尝试再次加锁。

 // 第⼀次加锁, 加锁成功 
 lock();
 // 第⼆次加锁, 锁已经被占⽤, 阻塞等待.  
 lock();

        按照之前对于锁的设定,第⼆次加锁的时候,就会阻塞等待,直到第一次的锁被释放,才能获取到第⼆个锁,但是释放第一个锁也是由该线程来完成,结果这个线程已经躺平了,啥都不想干了,也就无法进行解锁操作,这时候就会死锁。

这样的锁称为不可重入锁

synchronized 是可重入锁

六、读写锁

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

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

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

        两个线程都只是读一个数据,此时并没有线程安全问题,直接并发的读取即可。

        两个线程都要写一个数据,有线程安全问题。

        一个线程读另外一个线程写,也有线程安全问题。

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

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

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

其中:

        读加锁和读加锁之间,不互斥。

        写加锁和写加锁之间,互斥。

        读加锁和写加锁之间,互斥。

#注:

        只要是涉及到"互斥",就会产生线程的挂起等待,一旦线程挂起,再次被唤醒就不知道隔了多久了。

        因此尽可能减少"互斥"的机会,就是提高效率的重要途径。

读写锁特别适合于"频繁读,不频繁写"的场景中。(这样的场景其实也是非常广泛存在的)。

Synchronized 不是读写锁

七、相关面试题

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

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

        悲观锁的实现就是先加锁(比如借助操作系统提供的mutex),获取到锁再操作数据,获取不到锁就等待;乐观锁的实现可以引入一个版本号,借助版本号识别出当前的数据访问是否冲突,(实现细节参考上面的图).

2、介绍下读写锁?

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

读锁和读锁之间不互斥。

写锁和写锁之间互斥。

写锁和读锁之间互斥。

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

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

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

相比于挂起等待锁,

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

缺点:如果锁的持有时间较长,就会浪费CPU资源。

4、synchronized 是可重入锁么?

是可重入锁。

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

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

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐