目录
🚩乐观锁和悲观锁
🚩读写锁和普通互斥锁
🚩轻量级锁和重量级锁
🚩自旋锁和挂起等待锁
🚩公平锁和非公平锁
🚩可重入锁和不可重入锁
🚩关于synchronized的锁策略以及自适应
接下来讲解的锁策略不仅仅是局限于 Java . 任何和 " 锁 " 相关的话题 , 都可能会涉及到以下内容 . 这
🚩乐观锁和悲观锁
这两种锁是站在加锁解锁的角度看待的,看的是加锁解锁的过程中所干的活是多还是少
悲观和乐观是对后续锁冲突是否激烈(频繁)给出的预测。
- 如果预测接下来锁冲突的概率不大,就可以少做一些工作,就称为 乐观锁
- 如果预测接下来锁冲突的概率很大,就应该多做一些工作,就称为 悲观锁
悲观锁: 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。举个例子:同学 A 认为 " 老师是比较忙的 , 我来问问题 , 老师不一定有空解答 ". 因此同学 A 会先给老师发消息 : " 老师你忙嘛? 我下午两点能来找你问个问题嘛 ?" ( 相当于加锁操作 ) 得到肯定的答复之后 , 才会真的来问问题 .如果得到了否定的答复, 那就等一段时间 , 下次再来和老师确定时间 . 这个是悲观锁。乐观锁: 假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并 发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。举个例子:同学 B 认为 " 老师是比较闲的 , 我来问问题 , 老师大概率是有空解答的 ". 因此同学 B 直接就来找老师 .( 没加锁, 直接访问资源 ) 如果老师确实比较闲 , 那么直接问题就解决了 . 如果老师这会确实很忙 , 那么同学 B也不会打扰老师, 就下次再来 ( 虽然没加锁 , 但是能识别出数据访问冲突 ). 这个是乐观锁。
这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.
- 如果当前老师确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致 "白跑很多趟", 耗费额外的资源。
- 如果当前老师确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低.
Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
就好比同学 C 开始认为 "老师比较闲的", 问问题都会直接去找老师.但是直接来找两次老师之后, 发现老师都挺忙的, 于是下次再来问问题, 就先发个消息问问老师忙不忙, 再决定是否来问问题。
🚩读写锁和普通互斥锁
读写锁:
- Ⅰ:加读锁
- Ⅱ:加写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁( readers-writer lock ),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。
- 读锁和读锁之间不会产生竞争
- 写锁和写锁之间会产生竞争
- 读锁和写锁会产生竞争
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
- 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
- 两个线程都要写一个数据, 有线程安全问题.
- 一个线程读另外一个线程写, 也有线程安全问题.
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
- ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
- ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
读写锁特别适合于 "频繁读, 不频繁写" 的场景中. (这样的场景其实也是非常广泛存在的).比如对一个系统管理
- 每节课老师都会进行点名,点名就需要查看班级的同学列表(读操作),这个操作可能要每周执行好几次。
- 而什么时候修改同学列表呢?(写操作),就新同学加入的时候,可能一个月都不必改一次。
- 再比如,同学们使用教务系统查看作业(读操作),一个班级的同学很多,读操作一天就要进行几十次上百次。
- 但是这一节课的作业,老师只布置了一次(写操作)
普通互斥锁:就如同Synchronized,当两个线程竞争同一把锁的时候就会产生阻塞等待。synchronize不是读写锁,只是普通互斥锁。
多个线程同时读一个变量并没有问题,而且读的场景相比于写的场景就多了很多,使用读写锁相比于普通互斥锁就减少了很多的锁竞争,大大的优化了效率。(因为我们上述说明了,读写锁在读锁和读锁之间不会产生竞争)
🚩轻量级锁和重量级锁
- 轻量级锁的加锁解锁开销比较少,典型的是纯用户态的加锁解锁逻辑,开销是比较少的
- 重量级锁的加锁解锁开销比较大,典型的是进入了系统内核态的加锁解锁逻辑,开销是比较大的
这两种锁是站在结果的角度看待最终加锁解锁消耗的时间是多还是少,和乐观锁与悲观锁并不一样通常情况下乐观锁比较轻量,悲观锁比较重量,但是也并不绝对。
🚩自旋锁和挂起等待锁
- 自旋锁:相当于是"忙等"的状态,大量消耗的CPU资源,反复询问当前锁是否就绪。
- 挂起等待锁:先把CPU资源空闲出来去做其他的事情,过一段时间才询问当前锁是否就绪。
自旋锁:
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU ,需要过很久才能再次被调度 .但实际上 , 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题。自旋锁伪代码:while (抢锁(lock) == 失败) {}
如果获取锁失败 , 立即再尝试获取锁 , 无限循环 , 直到获取到锁为止 . 第一次获取锁失败 , 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁 .
理解自旋锁 vs 挂起等待锁
举个例子:
在我们等人的时候,对方还没有到达约定的地点,一直反复的打电话催促就是自旋锁,而当你发现对方还没到的时候,就在约定的地方找个地方玩手机,叫他来了再在约定的地点旁边找我们一下,多消耗一点时间,却能够用这些时间去做其他的事情,时间被利用起来了,这就是挂起等待锁。
自旋锁是轻量级锁的一种体现,挂起等待锁是重量级锁的一种体现。
🚩公平锁和非公平锁
- 公平锁:公平锁是先来后到,谁先来谁就拿到锁
- 非公平锁:多个线程同时竞争一把锁,有一个线程是比较晚来的,却比其他先来的线程先拿到锁
举个例子:
- t1,t2,t3三个线程竞争同一把锁,t1先来的,所以t1先拿到了锁,这就叫公平锁.
- 而如果t3是晚来的,然后t3比其他两个线程先拿到了锁,这就叫非公平锁
操作系统默认的锁的调度,是非公平的情况,想要实现一个公平锁,就需要引入额外的数据结构,来记录线程加锁的顺序,需要一定的额外开销。
公平锁和非公平锁没有好坏之分 , 关键还是看适用场景 .synchronized 是非公平锁 .
🚩可重入锁和不可重入锁
- 可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
- 比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
可重入锁:同一个线程对同一把锁连续加锁两次不会造成死锁
不可重入锁:同一个线程对同一把锁连续加锁两次会造成死锁
理解 "把自己锁死"一个线程没有释放锁, 然后又尝试再次加锁.// 第一次加锁, 加锁成功 lock(); // 第二次加锁, 锁已经被占用, 阻塞等待. lock();
按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁。这样的锁称为 不可重入锁.synchronized 是可重入锁 (因为我们之前在synchronized中说到了死锁,一个线程,连续针对一把锁,加锁两次,不会出现 死锁 )但是俩个线程俩把锁, 线程t1获取锁A,t2线程获取锁,B 线程t1获取锁B,t2线程获取锁A.这样大概率是会导致死锁现象。
🚩关于synchronized的锁策略以及自适应
①既是乐观锁,也是悲观锁
②既是轻量级锁,也是重量级锁
③既是自旋锁,也是挂起等待锁
④不是读写锁,是普通互斥锁
⑤是非公平锁
⑥是可重入锁
- 初始情况下,synchronized会预测当前的锁冲突的概率不大。此时以乐观锁的模式来运行。(此时也就是轻量级锁,基于自旋锁方式实现)
- 在实际使用过程中,如果发现锁冲突的情况比较多,synchronized就会升级成 悲观锁(也就是重量级锁,基于挂起等待的方式实现)
synchronized是自适应的,初始使用的时候,是 乐观锁/轻量级锁/自旋锁,如果竞争不激烈则保持这个状态不变,如果锁竞争激烈了,synchronized会自动升级成为 悲观锁/重量级锁/挂起等待锁,所以synchronized是"智能"的。
能“开心的话,当笨蛋也没关系".