悲观锁、乐观锁、公平锁、非公平锁、独享锁、共享锁、可重入锁、自旋锁、偏向锁、轻量/重量级锁、读写锁、各种锁
面试必备:深入了解Java中乐观锁和悲观锁的秘密 (qq.com)
通俗易懂 悲观锁、乐观锁、可重入锁、自旋锁、偏向锁、轻量/重量级锁、读写锁、各种锁及其Java实现! (qq.com)
Java锁最全详解:乐观锁/悲观锁+公平锁/非公平锁+独享锁/共享锁 (qq.com)
Java中加锁方式有两种,一种是synchronized关键字,另一种是用Lock接口的实现类。
synchronized关键字是自动档,可以满足一切日常驾驶需求。但是如果你想要玩漂移或者各种骚操作,就需要手动档了——各种Lock的实现类。
ReentrantLock、ReadLock、WriteLock 是Lock接口最重要的三个实现类。对应了“可重入锁”、“读锁”和“写锁”。
ReadWriteLock是一个工厂接口,而ReentrantReadWriteLock是ReadWriteLock的实现类,它包含两个静态内部类ReadLock和WriteLock。这两个静态内部类又分别实现了Lock接口。
悲观锁&乐观锁
锁的一种宏观分类方式是悲观锁和乐观锁。悲观锁与乐观锁并不是特指某个锁,而是在并发情况下的两种不同策略。
乐观锁假设多个线程之间很少会发生冲突,因此在读取时不会加锁,而在更新数据时会检查是否有其他线程修改了数据。如果没有冲突就执行更新操作;
悲观锁假设多个线程之间经常会发生冲突,因此在读取数据时会加锁,防止其他线程修改数据,直到操作完成后才释放锁。
乐观锁的实现方式
乐观锁的实现方式常见的有版本号机制和CAS(比较并交换)机制
- 版本号机制:在数据库表中添加一个版本号字段,每次更新操作时都会将版本号+1。当线程要更新数据时,会先读取数据的版本号,然后进行更新操作。在提交更新时,若刚才读取到的version值等于当前数据库中的version值才会更新;否则重试更新操作,直到更新成功。
- CAS:Compare And Swap(比较与交换)。就是用一个预期值和要更新的变量进行比较,两值相等才会更新。CAS是一个原子操作,底层依赖于一条CPU对的原子指令。CAS包含三个操作数:内存地址V,旧的预期值A和新的值B。CAS操作首先读取内存地址V中的值,如果该值等于旧的预期值A,那么将内存地址V中的值更新为新的值B;否则,不进行任何操作。CAS操作失败会重试。
Java中的Atomic类就是基于CAS机制实现的乐观锁,比如AtomicInteger、AtomicLong等(java.util.concurrent.atomic
包里面的原子类都是利用乐观锁实现的。)。
悲观锁的实现方式
就是在读取数据时直接加锁,防止其他线程修改数据。常见的悲观锁实现方式包括:
-
synchronized关键字:synchronized关键字是Java中最基本的锁机制,他可以用来修饰方法或者代码块,保证同一时间只有一个线程可以执行被锁定的代码
-
ReentrantLock类:ReentrantLock是Java中高级的锁机制,他提供了更灵活的锁定方式,可以实现公平锁和非公平锁,支持可重入特性,同时还可以配合条件变量等功能进行更复杂的线程同步操作。
乐观锁与悲观锁的选择
乐观锁适用于并发写比较少的场景,因为乐观锁不会阻塞读操作,适合读多写少的场景。
悲观锁适合并发写比较多的场景,因为悲观锁可以有效地阻塞其他线程的读和写操作,保证数据的一致性。但是,悲观锁有可能会引起线程竞争、降低性能。
乐观锁存在的问题
CAS虽然是高效的原子操作,但是存在三大问题:
ABA问题:CAS在比较和替换时只考虑了值是否相等,而没有考虑到值的版本信息。如果一个值在操作过程中被修改了两次,从A->B->A,那么CAS会认为值没有变化,从而进行操作。
ABA问题的解决思路是在变量前追加上版本号或者时间戳。JDK1.5以后的AtomicStampedReference类就是用来解决ABA问题的,其中的CompareAndSet()方法就是首先检查当前饮用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志设置为给定的更新值。
自旋时间过长:CAS算法在失败时会一直自旋,等待共享变量可用,如果共享变量一直不可用,就会出现自旋时间过长的问题,浪费CPU资源
如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。
只能保证单个变量的原子性:CAS只能保证单个变量的原子性
如果需要多个变量的原子操作,就需要使用锁等其他方式进行保护。
从JDK1.5开始提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
公平锁 VS 非公平锁
就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后会按照FIFO的规则从队列中取到自己。
java jdk并发包中的ReentrantLock可以指定构造函数的boolean类型来创建公平锁,比如:公平锁可以使用new ReentrantLock(true)
实现。
非公平锁上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式。
非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程,缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
java jdk并发包中的ReentrantLock的构造函数的默认就是采用非公平锁的实现,使用new ReentrantLock(false)
来实现,与上面的公平锁相反的声明方式。
独享锁 VS 共享锁
是指该锁一次只能被一个线程所持有,比如:刚刚谈到的ReentrantLock就是独享锁。
是指该锁可被多个线程所持有,Lock的另一个实现类ReadWriteLock,其读锁就是共享锁,其写锁却是独享锁。
ReadWriteLock的读锁(共享锁)可保证并发读非常高效,但读写,写读 ,写写的过程是互斥的。
这样设计的原因是:就是尽最大的解放并发读的操作,因为读占据了更大的访问请求,我只会在涉及少部分写的操作的时候,才考虑独享锁,从而提升并发的效率。
自旋锁
有一种锁叫自旋锁。所谓自旋,说白了就是一个 while(true)
无限循环。
刚刚的乐观锁就有类似的无限循环操作,那么它是自旋锁吗?
不是。尽管自旋与 while(true) 的操作是一样的,但还是应该将这两个术语分开。“自旋”这两个字,特指自旋锁的自旋。
synchronized锁升级:偏向锁 → 轻量级锁 → 重量级锁
前面提到,synchronized关键字就像是汽车的自动档,现在详细讲这个过程。一脚油门踩下去,synchronized会从无锁升级为偏向锁,再升级为轻量级锁,最后升级为重量级锁,就像自动换挡一样。那么自旋锁在哪里呢?这里的轻量级锁就是一种自旋锁。
初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。
在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。
长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。在JDK1.6之前,synchronized直接加重量级锁,很明显现在得到了很好的优化。
一个锁只能按照 偏向锁、轻量级锁、重量级锁的顺序逐渐升级(也有叫锁膨胀的),不允许降级。
可重入锁(递归锁)
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。
可中断锁
可中断锁,字面意思是“可以响应中断的锁”。
这里的关键是理解什么是中断。Java并没有提供任何直接中断某线程的方法,只提供了中断机制。何谓“中断机制”?线程A向线程B发出“请你停止运行”的请求(线程B也可以自己给自己发送此请求),但线程B并不会立刻停止运行,而是自行选择合适的时机以自己的方式响应中断,也可以直接忽略此中断。也就是说,Java的中断不能直接终止线程,而是需要被中断的线程自己决定怎么处理。
这好比是父母叮嘱在外的子女要注意身体,但子女是否注意身体,怎么注意身体则完全取决于自己。
在Java中,synchronized就是不可中断锁,而Lock的实现类都是可中断锁,可以简单看下Lock接口。
/* 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();
}