【大厂原题】
公平锁/非公平锁/可重入锁/递归锁/自旋锁谈谈你的理解, 请手写一个自旋锁
1 公平锁和非公平锁
1.1 是什么?
公平锁 是指多个线程按照申请锁的顺序来获取锁. 类似排队打饭,先来后到.
非公平锁 是指多个线程获取锁的顺序并不是按照申请锁的顺序, 有可能后申请的线程比先申请的线程优先获取锁,在高并发的情况下, 有可能会造成优先级反转或者饥饿现象
并发包中
ReentrantLock
的创建可以指定构造函数的boolean
类型来得到公平锁或非公平锁, 默认是非公平锁(即传入false);
1.2 两者区别?
公平锁: Threads acquire a fair lock in the order in which they requested it
公平锁就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列, 如果为空, 或者当前线程是等待队列的第一个,就占有锁, 否则就会加入到等待队列中, 以后会按照FIFO的规则从队列中取到自己
非公平锁: a nonfair lock permits barging: threads requesting a lock can jump ahead of the queue of waiting threads if the lock happens to be available when it is requested;
非公平锁比较粗鲁, 上来就直接尝试占有锁, 如果尝试失败, 就再采用类似公平锁那种方式;
1.3 题外话
Java ReentrantLock
而言, 通过构造函数指定该锁是否是公平锁, 默认是非公平锁. 非公平锁的优点在于吞吐量比公平锁大
对于syncronized
而言, 也是一种非公平锁;
2 可重入锁
2.1 是什么
可重入锁也叫递归锁
指的是同一线程外层函数获得锁之后, 内层递归函数仍然能获取该做的代码, 在同一个线程在外层方法获取锁的时候, 进入内层方法会自动获取锁;
也就是说:线程可以进入任何一个它已经拥有的锁的所同步着的代码块;
ReentrantLock
和syncronized
就是两个典型的可重入锁
2.2 作用
可重入锁最大的作用就是避免死锁
2.3 可重入锁小Demo
syncronized
是可重入锁
class Phone{
public synchronized void sendSMS() throws Exception{
System.out.println(Thread.currentThread().getName() + "\t invoke sendSMS");
this.sendEmail();
}
public synchronized void sendEmail() throws Exception{
System.out.println(Thread.currentThread().getName() + "\t ###invoke sendEmail");
}
}
public class ReentrantLockDemo {
public static void main(String[] args) {
Phone phone = new Phone();
new Thread(()->{
try {
phone.sendSMS();
} catch (Exception e) {
e.printStackTrace();
}
},"t1").start();
new Thread(()->{
try {
phone.sendSMS();
} catch (Exception e) {
e.printStackTrace();
}
},"t2").start();
}
/* 打印结果
t1 invoke sendSMS
t1 ###invoke sendEmail
t2 invoke sendSMS
t2 ###invoke sendEmail
*/
}
LocK
中的ReentrantLock
是可重入锁
class Fruit implements Runnable{
private Lock lock = new ReentrantLock();
@Override
public void run() {
this.set();
}
public void set(){
lock.lock();
try {
System.out.println(Thread.currentThread().getName()+"\t invoke set");
this.get();
}catch (Exception e){e.printStackTrace();}
finally {
lock.unlock();
}
}
public void get(){
lock.lock();
try {
System.out.println(Thread.currentThread().getName()+"\t ###invoke get");
}catch (Exception e){e.printStackTrace();}
finally {
lock.unlock();
}
}
}
public class ReentrantLockDemo {
public static void main(String[] args) {
Fruit frult = new Fruit();
Thread t3 = new Thread(frult,"t3");
Thread t4 = new Thread(frult,"t4");
t3.start();
t4.start();
}
/* 执行结果
t3 invoke set
t3 ###invoke get
t4 invoke set
t4 ###invoke get
*/
}
至此我们再温习一下可重入锁的定义👇
可重入锁也叫递归锁
指的是同一线程外层函数获得锁之后, 内层递归函数仍然能获取该做的代码, 在同一个线程在外层方法获取锁的时候, 进入内层方法会自动获取锁;
也就是说: 线程可以进入任何一个它已经拥有的锁的所同步着的代码块;
ReentrantLock
和syncronized
就是两个典型的可重入锁
可重入锁问题延伸
阅读一下代码,请问代码执行结果
class Fruit implements Runnable{
private Lock lock = new ReentrantLock();
@Override
public void run() {
this.set();
}
public void set(){
lock.lock();
lock.lock();
try {
System.out.println(Thread.currentThread().getName()+"\t invoke set");
this.get();
}catch (Exception e){e.printStackTrace();}
finally {
lock.unlock();
lock.unlock();
}
}
public void get(){
lock.lock();
try {
System.out.println(Thread.currentThread().getName()+"\t ###invoke get");
}catch (Exception e){e.printStackTrace();}
finally {
lock.unlock();
}
}
}
public class ReentrantLockDemo {
public static void main(String[] args) {
Fruit frult = new Fruit();
Thread t3 = new Thread(frult,"t3");
Thread t4 = new Thread(frult,"t4");
t3.start();
t4.start();
}
}
我们可以看到Fruit
类的set()
方法中加了两把锁,这个时候如果执行main()
方法会有什么问题?
以下答案👇
编译无错
运行也无错
记住,不管加几把锁,只要对应匹配解锁几次就没有问题!!!
3 自旋锁(spinLock)
自旋锁我们在
CAS
中讲过一点.
AtomicInteger
作为原子类中的一个例子,被我们拿出来举例, 我们知道 **AtomicInteger
的CAS
**实现就是Unsafe
类 + 自旋
再重温下代码👇
3.1 是什么
自旋锁,是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁, 这样的好处是减少线程上下文切换的消耗, 缺点是循环会消耗CPU
3.2 手写一个自旋锁
自旋锁Demo
public class SpinlockDemo {
//原子引用线程
AtomicReference<Thread> atomicReference = new AtomicReference<>();
public void myLock() {
Thread thread = Thread.currentThread();
System.out.println("线程"+Thread.currentThread().getName()+"\t come in ^_^");
/**
* 这里某线程进入CAS成功之后,其它线程再进入该方法到此处CAS取反就一直是true,就会卡在这里
* 直到atomicReference Set进去的这个线程调用myUnlock方法,将atomicReference
* 存储的线程引用值重新改为null
*/
while (!atomicReference.compareAndSet(null, thread)){
//就旋着
}
}
public void myUnlock() {
Thread thread = Thread.currentThread();
//用完了,将原子引用改回到最初的值
atomicReference.compareAndSet(thread,null);
System.out.println("线程"+Thread.currentThread().getName()+"\t invoke myUnlock^_^");
}
public static void main(String[] args) throws InterruptedException {
SpinlockDemo spinlockDemo = new SpinlockDemo();
//线程A 获取自旋锁, 假设业务操作耗时五秒
new Thread(()->{
try {
//获取自旋锁
spinlockDemo.myLock();
//业务操作5秒
System.out.println("线程"+Thread.currentThread().getName()+"\t 开始业务操作");
TimeUnit.SECONDS.sleep(5);
System.out.println("线程"+Thread.currentThread().getName()+"\t 业务操作完成");
//释放锁
spinlockDemo.myUnlock();
} catch (InterruptedException e) {
e.printStackTrace();
}
},"A").start();
//我们模拟中, 希望A先获取锁,因此在这里暂停1秒,保证线程A能够启动
TimeUnit.SECONDS.sleep(1);
//线程B 获取自旋锁, 进行业务操作,释放锁
new Thread(()->{
try {
//获取自旋锁
spinlockDemo.myLock();
//业务操作1秒
System.out.println("线程"+Thread.currentThread().getName()+"\t 开始业务操作");
TimeUnit.SECONDS.sleep(1);
System.out.println("线程"+Thread.currentThread().getName()+"\t 业务操作完成");
//释放锁
spinlockDemo.myUnlock();
} catch (InterruptedException e) {
e.printStackTrace();
}
},"B").start();
}
/**打印结果
线程 A come in ^_^ 进入lock
线程 A 开始业务操作 说明获取到了锁
线程 B come in ^_^ 进入lock但是会在while()这里一直自旋,直到线程A释放锁
线程 A 业务操作完成
线程 A invoke myUnlock^_^ 释放锁
线程 B 开始业务操作 线程B获取到了锁
线程 B 业务操作完成
线程 B invoke myUnlock^_^
*/
}
4 读/写锁
共享锁(读锁)
指该锁可被多个线程所持有.
独占锁(写锁)
指该锁一次只能被一个线程所持有. 对
ReentrantLock
和Syncronized
而言都是独占锁.
互斥锁
读锁的共享锁可保证并发读是非常高效的, 读写, 写读, 写写 的过程是互斥的
4.1 读写锁 是什么
多个线程同时读一个资源类没有任何问题, 所以为了满足并发量, 读取共享资源应该可以同时进行
但是
如果有一个线程想去写共享资源时, 就不应该再有其它线程可以对该资源进行读或写
高并发场景下的写操作要注意两点: 原子+独占, 整个过程必须是一个完整的统一体, 中间不允许被分割, 不允许被打断;
对ReentrantReadWriteLock
其读锁是共享锁, 其写锁时独占锁
对ReentrantReadWriteLock
读写分离,既保证了数据的一致性,又保证了并发性
4.2 读写锁小总结
读-读 能共存
读-写 不能共存
写-写 不能共存
4.3 读写锁小Demo
我们先看一下没有加锁的时候,5个线程写,5个线程读,会出现什么错乱的情况
/**
无锁版,并发场景的读写
*/
/**
* 该资源类加上 清理操作就变成 手写简单缓存的demo
*/
class DataCatch{
private volatile Map<String, Object> map = new HashMap<>();
public void write(String key, String val) {
System.out.println("线程 "+Thread.currentThread().getName()+"\t 开始写入:{"+key+","+val+"}");
//暂停一会线程,模拟执行业务操作
try {
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put(key,val);
System.out.println("线程 "+Thread.currentThread().getName()+"\t 写入完成");
}
public void read(String key) {
System.out.println("线程 "+Thread.currentThread().getName()+"\t 开始读取");
//暂停一会线程,模拟执行业务操作
Object o = map.get(key);
System.out.println("线程 "+Thread.currentThread().getName()+"\t 读取完成:"+o);
}
//加一个cleanmap的操作,就是清除缓存
}
public class ReadWriteLockDemo {
public static void main(String[] args) {
DataCatch dc = new DataCatch();
//5个线程执行写操作
for (int i = 0; i < 5; i++) {
final int tempInt = i;
new Thread(()->{
dc.write(tempInt+"",tempInt+"");
},String.valueOf(i)).start();
}
//5个线程执行读操作
for (int i = 0; i < 5; i++) {
final int tempInt = i;
new Thread(()->{
dc.read(tempInt+"");
},String.valueOf(i)).start();
}
}
/* 看一下执行结果
线程 1 开始写入:{1,1}
线程 4 开始写入:{4,4}
线程 3 开始写入:{3,3}
线程 0 开始写入:{0,0}
线程 2 开始写入:{2,2}
线程 1 开始读取
线程 2 开始读取
线程 2 读取完成:null
线程 1 读取完成:null
线程 0 开始读取
线程 0 读取完成:null
线程 3 开始读取
线程 3 读取完成:null
线程 4 开始读取
线程 4 读取完成:null
线程 1 写入完成
线程 4 写入完成
线程 2 写入完成
线程 3 写入完成
线程 0 写入完成
*/
}
线程的写操作,并非原子性,而写没有独占,
线程的读操作,读取数据错误,原因是还没有写入完成,就开始读导致的
此时在高并发场景下就是一个很严重的bug
我们再看一下通过可重入读写锁ReentrantReadWriteLock
进行优化,其结果如何
/**
ReentrantReadWriteLock版,并发场景的读写操作
*/
/**
* 该资源类加上 清理操作就变成 手写简单缓存的demo
*/
class DataCatch{
private volatile Map<String, Object> map = new HashMap<>();
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
public void write(String key, String val) {
//读写锁->读锁加锁
rwLock.writeLock().lock();
//暂停一会线程,模拟执行业务操作
try {
System.out.println("线程 "+Thread.currentThread().getName()+"\t 开始写入:{"+key+","+val+"}");
TimeUnit.MILLISECONDS.sleep(300);
map.put(key,val);
System.out.println("线程 "+Thread.currentThread().getName()+"\t 写入完成");
} catch (Exception e) {
e.printStackTrace();
}finally {
//读写锁->读锁释放锁
rwLock.writeLock().unlock();
}
}
public void read(String key) {
rwLock.readLock().lock();
try {
System.out.println("线程 "+Thread.currentThread().getName()+"\t 开始读取");
//暂停一会线程,模拟执行业务操作
TimeUnit.MILLISECONDS.sleep(300);
Object o = map.get(key);
System.out.println("线程 "+Thread.currentThread().getName()+"\t 读取完成:"+o);
}catch (Exception e){e.printStackTrace();}
finally {
rwLock.readLock().unlock();
}
}
//加一个cleanmap的操作,就是清除缓存
}
public class ReadWriteLockDemo {
public static void main(String[] args) {
DataCatch dc = new DataCatch();
//5个线程执行写操作
for (int i = 0; i < 5; i++) {
final int tempInt = i;
new Thread(()->{
dc.write(tempInt+"",tempInt+"");
},String.valueOf(i)).start();
}
//5个线程执行读操作
for (int i = 0; i < 5; i++) {
final int tempInt = i;
new Thread(()->{
dc.read(tempInt+"");
},String.valueOf(i)).start();
}
}
/* 执行结果
线程 0 开始写入:{0,0}
线程 0 写入完成
线程 1 开始写入:{1,1}
线程 1 写入完成
线程 3 开始写入:{3,3}
线程 3 写入完成
线程 2 开始写入:{2,2}
线程 2 写入完成
线程 4 开始写入:{4,4}
线程 4 写入完成
线程 0 开始读取
线程 1 开始读取
线程 2 开始读取
线程 3 开始读取
线程 4 开始读取
线程 2 读取完成:2
线程 4 读取完成:4
线程 0 读取完成:0
线程 3 读取完成:3
线程 1 读取完成:1
*/
}
看结果就知道啦,
ReentrantReadWriteLock
完全将写操作变成原子性+独占的,某个线程在进行写操作的时候,其它线程不能读更不能写
而对读操作并没有做独占限制
以上,就是我们多线程场景下锁相关的要点知识的梳理