Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

Java中多种锁的实现详解

1、synchronized和Lock

java中有两种锁的加锁方式:一种是用于synchronized关键字,另一种是用Lock接口实现类

形象地说,synchronized关键字是自动挡,可以满足一切日常驾驶需求。但是如果你想要玩其他的骚操作,就需要手动挡—各种Lock实现类

所以如果你只是想要简单的加个锁,对性能也没有什么特别的要求,用synchronized关键字就足够了。自从Java 5 之后,才在java.util.concurrent.locks包下有了另一种方式来实现锁那就是Lock。也就是说,synchronized是Java语言内置的关键字,而Lock是一个接口,这个接口的实现类在代码层面实现了锁的功能,具体实现有兴趣可以自己研究一下哦。

v2-ddb71ab0b68d65ae70244bfdeb0d6704_r

​ 其实只需要关注三个类就可以了:ReentrantLock类、ReadLock类、WriteLock类。

ReentrantLock、ReadLock、WriteLock是Lock接口最重要的三个实现类。对应了“可重入锁”、“读锁”、“写锁”,后面会讲具体的用途。

ReadWriteLock其实是一个工厂接口,而ReentrantReadWriteLock是ReadWrite的实现类,它包含连个静态内部类ReadLock和WriteLock。这个静态类又分别实现了Lock接口

2、悲观锁与乐观锁

锁的一种宏观分类方式是悲观锁乐观锁,悲观锁与乐观锁并不是特指某个锁(Java中没有那个Lock实现类就叫PessimisticLock或OptimisticLock),而是在并发情况下的两种不同策略。

悲观锁,就是很悲观,每次去拿数据的时候都认为别人会修改。所以每次在拿数据的时候都会上锁,这样别人想拿数据就被挡住,直到悲观锁被释放。

乐观锁,就是很乐观,每次拿数据的时候都认为别人不会修改。所以不会上锁,不会上锁!但是如果想要更新数据,则会在更新件检查在读取至更新这段时间别人有没有修改过这个数据。如果修改过,则重新读取,再次尝试更新,循环上述步骤直到更新成功(当然也允许更新失败的线程放弃操作)、

悲观锁阻塞事务,乐观锁回滚重试,他们各有优点,不要认为一种一定好于另一种。像乐观锁比较适合用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去锁的开销,加大系统的吞吐量。但如果经常产生冲突,上层应用会不断地进行重试,这样反倒是降低了性能,所以这种情况下用悲观锁比较合适,

3、乐观锁的基础——CAS

说到乐观锁,就必须提到一个概念:CAS

什么事CAS呢?Compare-and-Swap,即比较并替换,也有叫做Compare-and-Set的,比较并设置

1、比较:读取一个值A,在将其更新为B之前,检查原值是否仍为A(未被其他线程改动)。

2、设置:如果是将A更新为B,结束。如果不是,则什么都不做。

上面的两步操作时原子性的,可以简单地理解为瞬间完成,在CPU看来是一步操作。

有了CAS,就可以实现一个乐观锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
data = 123; // 共享数据

/* 更新数据的线程会进行如下操作 */
flag = true;
while (flag) {
oldValue = data; // 保存原始数据
newValue = doSomething(oldValue);

// 下面的部分为CAS操作,尝试更新data的值
if (data == oldValue) { // 比较
data = newValue; // 设置
flag = false; // 结束
} else {
// 啥也不干,循环重试
}
}
/*
很明显,这样的代码根本不是原子性的,
因为真正的CAS利用了CPU指令,
这里只是为了展示执行流程,本意是一样的。
*/

这是一个简单直观的乐观锁实现,它允许多个线程同时读取(因为根本没有加锁操作),但是只有一个线程可以成功更新数据,并导致其他的更新数据的线程回滚重试。CAS利用CPU的指令,从硬件层面保证了操作的原子性,以达到类似于锁的效果

因为整个过程中并没用,“加锁“和”解锁“操作,因此乐观锁策略也被成为无锁编程。换句话说乐观锁其实不是”锁“,它仅仅是一个循环重试CAS的算法而已。

4、自旋锁

有一种锁叫自旋锁,所谓自旋,说白了就是一个while(true)无限循环

刚刚的乐观锁就有类似无限循环的操作,那么它是自旋锁吗?

不是,尽管自旋于while(true)的操作是一样的,但是应该将这个术语分开。“自旋”这个字特指自旋锁的自旋。

然而在JDK中并没有自旋锁(SpinLock)这个类,那么什么才是自旋锁呢?看完下面就知道了。

5、synchronized锁升级:偏向锁→轻量级锁→重量级锁

前面提到,synchronized关键字就像是汽车的自动挡,现在详细讲这个过程。一脚油门下去,synchronized会从无锁升级为偏向锁,再升级为轻量级锁,最后升级为重量级锁,就像自动挡一样,那么自旋锁在哪里呢?这里的轻量级锁就是一种自旋锁

初次执行到synchronized代码块的时候,锁的对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁机会没有额外开销,性能极高。

一旦有第二个线程加入锁竞争,偏向锁就升级为**轻量级锁(自旋锁)**。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某个线程尝试获取锁的时候,发现该锁已经被占用了,只有等待释放,这才发生了锁竞争。

在轻量级锁的状态下继续竞争,没有抢到锁的线程将自旋,即不停地循环判断该锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前标志位是否“释放”,如果是则将其设置为”锁定“,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前持有者的信息修改为自己。

长时间的自旋锁是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CUP,执行不了任何有效的任务,这种现象叫做忙等。如果多线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折中的想法,短时间的忙等,换取线程在用户太和内核态之间切换的开销

显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,是不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。在JDK1.6之前,synchronized直接加重量级锁,很明显现在的得到了很好的优化。

一个锁只能按照偏向锁、轻量级锁、重量级锁的顺序逐渐升级(也有叫锁膨胀),不允许降级。

偏向锁的一个特性是,持有锁的线程在执行完同步代码块时不会释放锁。那么当第二个线程执行到这个synchronized代码块时是否一定会发生锁竞争然后升级为轻量级锁呢?

线程A第一次执行完同步代码块后,当线程B尝试获取锁的时候,发现是偏向锁,会判断线程A是否仍然存活。如果线程A仍然存活,将线程A暂停,此时偏向锁升级为轻量级锁,之后线程A继续执行,线程B自旋,但是如果判断是线程A不存在了,则线程B持有此偏向锁,锁不升级。

6、可重入锁(递归锁)

可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。比如一个递归函数里面有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫作递归锁)。

Java里只要以Reentrant开头命名的锁都是可重入的,而且JDK提供的所有线程的Lock实现类,包括synchronized关键字锁都是可重入的。如果你需要不可重入锁,只能自己去实现了。

v2-ffbe0e21512c64a1b444cf55d4b3bf61_r
JDK提供的Lock的实现类都是可重入的

7、公平锁、非公平锁

如果多个线程申请一把公平锁,那么当锁释放的时候,先申请的先得到,非常公平。显然如果是非公平锁,后申请的线程可能先获取到锁,是随机或者按照其他优先级排序的。

对Reentrant类而言,通过构造函数可以指定该锁是否是公平锁,默认是非公平锁。一般情况下,非公平锁的吞吐量比公平锁大,如果没有特殊要求,优先使用非公平锁。

对于synchronized而言,它也是一种非公平锁,但是没有任何办法使其变为公平锁。

8、可中断锁

可中断锁,字面意思是“可以响应中断的锁”。

这里的关键是理解什么是中断。Java并没有提供任何直接中断某线程的方法,只是提供了中断机制。何谓“中断机制”?线程A向线程B发出“请你停止运行”的请求(线程B也可以给自己发送此请求),但线程B并不会立刻停止运行,而是自行选择合适的时机以自己的方式响应中断,也可以直接忽略此中断。也就是说,Java的中断不能直接终止线程,而是需要被中断的线程自己决定怎么处理。这好比是父母叮嘱在外的子女要注意身体,但子女是否注意身体,怎么注意身体则完全取决于自己。

如果线程A持有锁,线程B等待获取该锁。由于线程A持有锁的时间过长,线程B不想继续等待了,我们可以让线程B中断自己或者在别的线程里中断它,这种就是可中断锁

在Java中,synchronized就是不可中断锁,而Lock的实现类都是可中断锁,可以简单看下Lock接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* Lock接口 */
public interface Lock {

void lock(); // 拿不到锁就一直等,拿到马上返回。

void lockInterruptibly() throws InterruptedException; // 拿不到锁就一直等,如果等待时收到中断请求,则需要处理InterruptedException。

boolean tryLock(); // 无论拿不拿得到锁,都马上返回。拿到返回true,拿不到返回false。

boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 同上,可以自定义等待的时间。

void unlock();

Condition newCondition();
}

9、读写锁、共享锁、互斥锁

读写锁其实是一对锁,一个读锁(共享锁),一个写锁(互斥锁、排他锁)。

看下Java里的ReadWriteLock接口,它只规定了两个方法,一个返回读锁,一个返回写锁。

v2-5ec6ed066c75e59c4f3829ca51db8148_r

记得之前的乐观锁策略吗?所有线程随时都可以读,仅在写之前判断值有没有被更改。

读写锁其实做的事情是一样的,只是策略稍有不同。很多情况下,线程知道自己读取数据后,是否是为了更新它。那么何不在加锁的时候直接明确这一点呢?如果我读取值是为了更新它(SQL的for update就是这个意思),那么加锁的时候就直接加写锁,我持有写锁的时候别的线程无论读还是写都需要等待;如果我读取数据仅为了前端展示,那么加锁时就明确地加一个读锁,其他线程如果也要加读锁,不需要等待,可以直接获取(读锁计数器+1)。

虽然读写锁感觉与乐观锁有点像,但是读写锁是悲观锁策略。因为读写锁并没有在更新前判断值有没有被修改过,而是在加锁前决定应该用读锁还是写锁。乐观锁特指无锁编程。

JDK提供的唯一一个ReadWriteLock接口实现类是ReentrantReadWriteLock。看名字就知道,它不仅提供了 读写锁,而且都是可重入锁。除了两个接口方法以外,ReentrantReadWriteLock还提供了一些便于外界监控其内部工作状态的方法,这里就不一一展开了。

本文参考知乎大佬

评论