常见的锁策略
乐观锁和悲观锁
乐观锁:
乐观锁认为产生并发冲突的概率不大,只在数据提交更新时,才会检测数据是否产生并发冲突,如果发生并发冲突,就返回错误信息,让用户决定怎么做。
悲观锁:
悲观锁认为产生并发冲突的概率大,认为每次拿数据的时候都会被别人修改,所以会先上锁,再去拿数据进行操作,这样别人想拿这个数据就会阻塞等待。
轻量级锁和重量级锁
轻量级锁:
加锁解锁,过程更快更高效。
通常是纯用户态的,用户态不能完成才切换内核态。
- 少量的内核态于用户态之间的切换。
- 不容易引发线程调度。
重量级锁:
加锁解锁,过程更慢更低效。
重度依赖内核态。
- 大量的内核态于用户态之间的切换。
- 很容易引发线程调度。
简单理解用户态和内核态:
用户态可以理解为代码层面上完成的,时间是可控的。
内核态可以理解为操作系统内部完成的,时间不太可控。
因为操作系统要处理很多事,比较忙,所以用户态和内核态的切换效率很低。
自旋锁和挂起等待锁
自旋锁(Spin lock):
- 自旋锁是轻量级锁的一种典型实现
- 加锁失败时,自旋锁会循环进行加锁,一旦锁被释放可以第一时间拿到锁,速度较快。但是消耗 cpu 资源,忙等。
挂起等待锁:
- 挂起等待锁是重量级锁的一种典型实现
- 加锁失败时,通过内核的机制来挂起等待,锁被释放不能第一时间拿到锁。
互斥锁和读写锁
synchronized
只是单纯的互斥锁, 没有具体的细分。
多线程读取同一个数据时是没有线程安全问题的。但是多线程中,对同一个数据,读取的线程和写入的线程之间需要进行互斥,如果这种场景下使用 synchronized
效率较低, 因为如果写入数据的线程没在工作,只有读取的线程也会有锁竞争。
读写锁分为读锁和写锁,Java 标准库提供了 ReentrantReadWriteLock
类,实现了读写锁。
ReentrantReadWriteLock.ReadLock
类表示一个读锁。这个对象提供了 lock / unlock 方法进行加锁解锁。ReentrantReadWriteLock.WriteLock
类表示一个写锁。这个对象也提供了 lock / unlock 方法进行加锁解锁。
读写锁的规则是:
- 读锁和读锁之间,不会互斥
- 读锁和写锁之间,互斥
- 写锁和写锁之间,互斥
互斥意味着会有阻塞等待,线程一旦阻塞挂起,就不知道何时能被唤醒。减少互斥,就是提高效率的办法,这就是读写锁的意义。
读写锁适用于 “频繁读取,不频繁写入” 的场景中。
公平锁和非公平锁
- 公平锁:遵守 “先来后到” ,B比C先来,A释放锁,那B就能先拿到锁。
- 非公平锁:不遵守 “先来后到” ,B和A都有可能拿到锁。
操作系统内部的线程调度是随机的。
如果不做额外的限制,锁就是非公平锁。
想实现公平锁,需要依靠额外的数据结构来记录先后顺序。
可重入锁和不可重入锁
可重入锁:
-
允许同一个线程多次获取同一把锁。
-
例如一个递归函数里有加锁操作,递归过程中这个锁不会阻塞自己,那么这个锁就是可重入锁(也叫递归锁)。
不可重入锁:
-
不允许同一个线程获取同一把锁。
-
一个线程获取到锁,还没释放锁,然后又尝试再次加锁,这时就会死锁,因为第二次加锁需要等锁释放,但是释放锁也是由这个线程来完成。
Java 中的锁都是可重入锁:Reentrant开头命名的锁、JDK提供的所有现成的Lock实现类,synchronized
关键字锁。
而 Linux 系统提供的 mutex 是不可重入锁。
死锁
死锁就是线程无限期的阻塞,等待某个资源被释放。
死锁的情况:
- 一个线程对同一个锁多次加锁,如果是不可重入锁就死锁了。
- 两个线程两把锁,两个线程都获取到一把锁,然后都要获取对方的锁,这时候这两个线程就僵住了,这就是死锁。
死锁是很严重的BUG,会导致程序卡死。
如何避免死锁
死锁产生的必要条件:
- 互斥使用:当一个线程拿到了一把锁,其他的线程不能使用。
- 不可抢占:一个线程拿到锁后,只能由这个线程主动释放,其他线程不能抢占。
- 请求和保持:一个线程拿到锁后,没有释放,想要拿另一个锁。
- 循环等待:闭环等待,即A等待B释放锁,B等待C释放锁,C又等待A释放锁。
这四个条件都成立时,死锁就产生了,当然,只要等打破任何一个条件,就能避免死锁。
最容易打破的条件是:循环等待。
如何破打破循环等待:
针对锁进行编号,如果需要获取多个锁,规定一个加锁顺序,先获取编号小的锁,再获取编号大的锁。
不规定加锁顺序:
Object locker1 = new Object();
Object locker2 = new Object();
Thread A = new Thread(() -> {
synchronized(locker1) {
synchronized(locker2) {
}
}
});
Thread B = new Thread(() -> {
synchronized(locker2) {
synchronized(locker1) {
}
}
});
A.start();
B.start();
如果线程A拿到locker1,线程B拿到locker2,两个线程就会产生循环等待。
规定加锁顺序:
Object locker1 = new Object();
Object locker2 = new Object();
Thread A = new Thread(() -> {
synchronized(locker1) {
synchronized(locker2) {
}
}
});
Thread B = new Thread(() -> {
synchronized(locker1) {
synchronized(locker2) {
}
}
});
A.start();
B.start();
规定加锁顺序后,如果 A 线程获取到 locker1,B 线程就不能获取 locker1,更不会获取到 locker2,那么 A 线程就正常执行了。