JUC-锁-1-锁的分类

Java 锁有很多种,从不通的角度来看,锁大概有以下几种分类

image

下面分别看一下这几种锁

悲观锁和乐观锁

简单来说,悲观锁就是面对同步资源的时候,首先认为会有别的线程会来修改数据,所以先上锁,锁住之后再去修改资源,上面提到的 synchronizedLock 就都是悲观锁

乐观锁就是先认为没有线程去修改这个资源,所以不上锁先去修改,等修改完成提交的时候再做检查,如果这个时间段有别的线程修改了,那就做其他的处理,没有的话就提交,比如我们的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
15
public 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
24
public 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 递增

ReentrantLocksynchronized 就是可重入锁,可重入锁的作用其实就是为了防止死锁

常用方法

  • isHeldByCurrentThread(): 查看锁是否被当前线程持有
  • getQueueLength(): 返回当前正在等待这把锁的队列有多长

这两个方法一般在开发调试的时候用

公平锁和非公平锁

从名字上看,公平锁就是保障了各个线程获取锁都是按照顺序来的,先到的线程先获取锁,而非公平锁则不一定按照顺序,是可以插队的

在公平锁中,比如线程 1234 依次去获取锁, 线程1首先获取到了锁,然后它处理完成之后会释放,释放之后会唤醒下一个线程,依次获取锁

而非公平锁中,线程1释放掉锁之后,唤醒线程2的这个过程中,如果有别的线程比如线程5去请求锁,那么线程5是可以先获取到的,就是插队,因为线程2的唤醒需要CPU的上下文切换,这个需要一定的时间,线程1释放锁和线程2被唤醒的这段时间,锁是空闲的,所以在非公平锁中,就可以先让别的线程获取到,这样做的目的主要是利用锁的空档期,提高效率

优缺点

类型 优势 劣势
公平锁 各个线程公平平等,在等待一段时间之后总有执行的机会 更慢,吞吐量小
非公平锁 更快,吞吐量大 可能会产生线程饥饿的问题(某些线程长时间等待但是得不到执行)

ReentrantLock 默认就是一个非公平锁,如果要设置为公平锁的话可以在构造种传入true new ReentrantLock(true)

共享锁和排他锁

典型的就是读写锁(ReentrantReadWriteLock),比如读操作可以有很多线程一起读,但是写操作只能有一个线程去写,而且在写入的时候,别的线程也不能进行读的操作

如果没有读写锁,只用 ReentrantLock 那么虽然可以保证线程安全,但是也会浪费一部分资源,因为多个读操作并没有线程安全问题,所以在读的地方使用读锁,在写的地方用写锁,可以提高程序执行效率

读写锁的规则

  1. 多个线程申请读锁,都可以申请到
  2. 如果有一个线程已经占用了读锁,则此时其他线程如果申请写锁,则申请写锁的线程会一直等读锁被释放
  3. 如果有一个线程获取到了写锁,则其他线程不管申请读锁还是写锁,都得等当前的写锁被释放

总结一下,就是要么多个线程读,要么多个线程写

使用方式

1
2
3
4
5
private static ReentrantReadWriteLock reentrantLock = new ReentrantReadWriteLock();
// 读锁
private static ReentrantReadWriteLock.ReadLock readLock = reentrantLock.readLock();
// 写锁
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantLock.writeLock();

读锁插队策略

在非公平锁的情况下,假设有这样一个场景,线程2和线程4正在读,线程3想要写入,但是拿不到锁,于是在等待队列里面等待,线程5不在等待队列里,但是它想要读,那么线程5能插队直接获取到读锁吗

这里无非有两种情况,第一种可以插队,这样效率高,但是读请求多的情况下写线程就会造成饥饿,一直获取不到锁, 第二种情况就是不能插队,等写线程写完再获取

ReentrantReadWriteLock 实现的就是第二种情况

ReentrantReadWriteLock 的插队策略就是

  • 公平锁: 不允许插队
  • 非公平锁: 写锁可以插队,读锁仅在等待队列头节点不是想获取写锁的线程的时候可以插队

升降级策略

  • 写锁可以降级为读锁,读锁不能升级为写锁,主要是为了防止死锁

适用场景

读写锁适用于读多写少的情况,合理使用可以提高并发效率

自旋锁和阻塞锁

自旋锁是采用让当前线程不停的在循环体内执行实现,当循环的条件被其它线程改变时才能进入临界区,用代码举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SpinLock {

private AtomicReference<Thread> sign = new AtomicReference<>();

private void lock(){
Thread current = Thread.currentThread();
while (!sign.compareAndSet(null, current)) {
}
}

private void unLock(){
Thread current = Thread.currentThread();
sign.compareAndSet(current, null);
}
}

这里就是一个自旋锁,上锁的时候,一直循环,直到上锁成功

如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁

而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销,这就是自旋锁,

而阻塞锁,就是改变线程的状态,让线程进入等待的状态,等待唤醒,之后再去竞争锁

优缺点分析

  • 自旋锁: 由于自旋锁只是将当前线程不停地执行循环体,不进行线程状态的改变,所以响应速度更快.但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间.如果线程竞争不激烈,并且保持锁的时间段.适合使用自旋锁
  • 阻塞锁: 阻塞锁的优势在于,阻塞的线程不会占用cpu时间,不会导致 CPU 占用率过高,但进入时间以及恢复时间都要比自旋锁略慢,在竞争激烈的情况下,阻塞锁的性能要明显高于自旋锁

可中断锁和不可中断锁

Java中 synchronized 就是一个不可中断锁,而 ReentrantLock 就是一个可中断锁,之前介绍的 tryLock()lockInterruptibly() 方法都可以中断锁的获取

简单来说在获取锁的过程中能放弃获取锁,中断获取锁的过程,那就是可中断锁,否则就是不可中断锁

总结

关于锁就先介绍到这里,掌握这几种锁的特点,在开发过程中选择最合适的使用