述
Java 锁有很多种,从不通的角度来看,锁大概有以下几种分类
下面分别看一下这几种锁
悲观锁和乐观锁
简单来说,悲观锁就是面对同步资源的时候,首先认为会有别的线程会来修改数据,所以先上锁,锁住之后再去修改资源,上面提到的 synchronized
和 Lock
就都是悲观锁
乐观锁就是先认为没有线程去修改这个资源,所以不上锁先去修改,等修改完成提交的时候再做检查,如果这个时间段有别的线程修改了,那就做其他的处理,没有的话就提交,比如我们的git仓库,也是提交的时候才去判断有没有别人修改,有的话解决冲突或者其他操作,没有的话直接提交成功
开销对比
悲观锁的开销要高于乐观锁,但是特点是一劳永逸,临界区持锁的时间就算越来越差,也不会对互斥锁的开销造成影响
乐观锁最开始的消耗是要比悲观锁小的,因为不用先去上锁,但是如果自旋时间很长,或者不停的重试,那么消耗的资源也会越来越多
使用场景
两种锁各有个的使用场景
- 悲观锁: 适用于并发写入多,临界区持锁时间较长的情况,悲观锁可以避免大量的无用自旋操作,比如临界区有IO操作,临界区代码复杂或循环量大,或者线程竞争激烈的情况
- 乐观锁: 适用于并发写入少,大部分是读取的场景,不加锁能让读取的性能大大提高
可重入锁与非可重入锁
可重入锁,也叫做递归锁,指的是同一个线程T在进入外层函数A获得锁L之后,T继续进入内层递归函数B时,仍然有获取该锁L的代码,在不释放锁L的情况下,可以重复获取该锁L。
非可重入锁,也叫做自旋锁,对比上面,指的是同一个线程T在进入外层函数A获得锁L之后,T继续进入内层递归函数B时,仍然有获取该锁L的代码,必须要先释放进入函数A的锁L,才可以获取进入函数B的锁L。
简单来说就是同一个线程能不能重复获取自己已经获取到的锁.
案例
用一个简单的demo来理解可重入锁与非可重入锁
不可重入锁1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public class Lock{
private boolean isLocked = false;
public synchronized void lock() throws InterruptedException{
while(isLocked){
wait();
}
isLocked = true;
}
public synchronized void unlock(){
isLocked = false;
notify();
}
}
可重入锁1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24public class Lock{
boolean isLocked = false;
Thread lockedBy = null;
int lockedCount = 0;
public synchronized void lock()
throws InterruptedException{
Thread thread = Thread.currentThread();
while(isLocked && lockedBy != thread){
wait();
}
isLocked = true;
lockedCount++;
lockedBy = thread;
}
public synchronized void unlock(){
if(Thread.currentThread() == this.lockedBy){
lockedCount--;
if(lockedCount == 0){
isLocked = false;
notify();
}
}
}
}
可以看到不可重入锁,就是一个有没有上锁的标记,如果之前的锁没有被释放那就不能再次获取, 可重入锁呢,维护了一个当前获取到锁的线程,还有一个锁的个数,表示同一个线程可以反复获取这个锁,每获取一次 lockCount 递增
ReentrantLock
和 synchronized
就是可重入锁,可重入锁的作用其实就是为了防止死锁
常用方法
isHeldByCurrentThread()
: 查看锁是否被当前线程持有getQueueLength()
: 返回当前正在等待这把锁的队列有多长
这两个方法一般在开发调试的时候用
公平锁和非公平锁
从名字上看,公平锁就是保障了各个线程获取锁都是按照顺序来的,先到的线程先获取锁,而非公平锁则不一定按照顺序,是可以插队的
在公平锁中,比如线程 1234 依次去获取锁, 线程1首先获取到了锁,然后它处理完成之后会释放,释放之后会唤醒下一个线程,依次获取锁
而非公平锁中,线程1释放掉锁之后,唤醒线程2的这个过程中,如果有别的线程比如线程5去请求锁,那么线程5是可以先获取到的,就是插队,因为线程2的唤醒需要CPU的上下文切换,这个需要一定的时间,线程1释放锁和线程2被唤醒的这段时间,锁是空闲的,所以在非公平锁中,就可以先让别的线程获取到,这样做的目的主要是利用锁的空档期,提高效率
优缺点
类型 | 优势 | 劣势 |
---|---|---|
公平锁 | 各个线程公平平等,在等待一段时间之后总有执行的机会 | 更慢,吞吐量小 |
非公平锁 | 更快,吞吐量大 | 可能会产生线程饥饿的问题(某些线程长时间等待但是得不到执行) |
ReentrantLock
默认就是一个非公平锁,如果要设置为公平锁的话可以在构造种传入true new ReentrantLock(true)
共享锁和排他锁
典型的就是读写锁(ReentrantReadWriteLock),比如读操作可以有很多线程一起读,但是写操作只能有一个线程去写,而且在写入的时候,别的线程也不能进行读的操作
如果没有读写锁,只用 ReentrantLock
那么虽然可以保证线程安全,但是也会浪费一部分资源,因为多个读操作并没有线程安全问题,所以在读的地方使用读锁,在写的地方用写锁,可以提高程序执行效率
读写锁的规则
- 多个线程申请读锁,都可以申请到
- 如果有一个线程已经占用了读锁,则此时其他线程如果申请写锁,则申请写锁的线程会一直等读锁被释放
- 如果有一个线程获取到了写锁,则其他线程不管申请读锁还是写锁,都得等当前的写锁被释放
总结一下,就是要么多个线程读,要么多个线程写
使用方式
1 | private static ReentrantReadWriteLock reentrantLock = new ReentrantReadWriteLock(); |
读锁插队策略
在非公平锁的情况下,假设有这样一个场景,线程2和线程4正在读,线程3想要写入,但是拿不到锁,于是在等待队列里面等待,线程5不在等待队列里,但是它想要读,那么线程5能插队直接获取到读锁吗
这里无非有两种情况,第一种可以插队,这样效率高,但是读请求多的情况下写线程就会造成饥饿,一直获取不到锁, 第二种情况就是不能插队,等写线程写完再获取
ReentrantReadWriteLock
实现的就是第二种情况
ReentrantReadWriteLock
的插队策略就是
- 公平锁: 不允许插队
- 非公平锁: 写锁可以插队,读锁仅在等待队列头节点不是想获取写锁的线程的时候可以插队
升降级策略
- 写锁可以降级为读锁,读锁不能升级为写锁,主要是为了防止死锁
适用场景
读写锁适用于读多写少的情况,合理使用可以提高并发效率
自旋锁和阻塞锁
自旋锁是采用让当前线程不停的在循环体内执行实现,当循环的条件被其它线程改变时才能进入临界区,用代码举个例子
1 | public class SpinLock { |
这里就是一个自旋锁,上锁的时候,一直循环,直到上锁成功
如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁
而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销,这就是自旋锁,
而阻塞锁,就是改变线程的状态,让线程进入等待的状态,等待唤醒,之后再去竞争锁
优缺点分析
- 自旋锁: 由于自旋锁只是将当前线程不停地执行循环体,不进行线程状态的改变,所以响应速度更快.但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间.如果线程竞争不激烈,并且保持锁的时间段.适合使用自旋锁
- 阻塞锁: 阻塞锁的优势在于,阻塞的线程不会占用cpu时间,不会导致 CPU 占用率过高,但进入时间以及恢复时间都要比自旋锁略慢,在竞争激烈的情况下,阻塞锁的性能要明显高于自旋锁
可中断锁和不可中断锁
Java中 synchronized
就是一个不可中断锁,而 ReentrantLock
就是一个可中断锁,之前介绍的 tryLock()
和 lockInterruptibly()
方法都可以中断锁的获取
简单来说在获取锁的过程中能放弃获取锁,中断获取锁的过程,那就是可中断锁,否则就是不可中断锁
总结
关于锁就先介绍到这里,掌握这几种锁的特点,在开发过程中选择最合适的使用