前言
接下来讲解的锁策略不仅仅是局限于 Java . 任何和 "锁" 相关的话题, 都可能会涉及到以下内容. 这
些特性主要是给锁的实现者来参考的,我们普通程序员多了解了解锁,对使用也有很大的帮助。
下面我会使用很多插图来更好的了解。
接下来的插图皆来自于网络。
1. 乐观锁 VS 悲观锁
锁的实现者,预测接下来发生的冲突概率大不大?根据这个冲突的概率,由实现者来决定接下来该咋办:
乐观锁
乐观锁,顾名思义,乐观豁达;
我们假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候(读多写少),才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
悲观锁
相比于乐观锁,悲观锁是一种非常悲观的思想,遇到事总是想到最坏的情况,认为写多读少,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁
2. 轻量级锁 VS 重量级锁
轻重量级区别在于效率,效率高那么就认为是轻量级,效率低那么就认为是重量级:
轻量级锁
这里的 CAS 操作,我们后面就会介绍到。
“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。
轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。
轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
加锁机制重度依赖了 OS 提供了 mutex
- 大量的内核态用户态切换
- 很容易引发线程的调度
这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 "沧海桑田"
3. 自旋锁 VS 挂起等待锁
自旋锁
自旋锁是轻量级锁的一种具体表现;
这通常是用户态的,不经过内核态,所以消耗相对时间较短;
举个栗子🌰🌰
这是我们老师上课所举的:
他呢跟他女神表白,但是被女神发了好人卡(加锁失败),但是失败之后怎么办呢?
自旋锁:每天锲而不舍,向女神发早安晚安,终于有一天,女神和她男朋友分手了,这是机会就来了;第一时间就有机会枪到锁,然后加锁。
优点:一旦锁被释放,那么可以第一时间抢到锁。
缺点:但是每天围绕着女神转,啥也不干,那么就会造成资源的浪费(忙等,照成CPU 资源的浪费)。
挂起等待锁
还是拿上面的栗子:
一旦被拒绝,那么就不理女神了,潜心敲代码,学习;终于有一天,女神分手了,想起了他来,主动要求加锁;
挂起等待锁表示当获取锁失败之后,对应的线程就要在内核中挂起等待(放弃CPU,进入等待队列),需要在锁被释放之后由操作系统唤醒;这是典型的重量级锁的栗子。
优点:减少了CPU 资源的浪费
缺点:不能第一时间抢到锁,什么时候能加锁,由系统决定
挂起等待锁与自旋锁的区别
- 最明显的区别就是,挂起等待锁开销比自旋锁要大,且挂起等待锁效率不如自旋锁。
- 挂起等待锁会放弃CPU资源,自旋锁不会放弃CPU资源,会一直等到锁释放为止。
- 自旋锁相较于挂起等待锁更能及时获取到刚释放的锁。
- 自旋锁相较于挂起等待锁的劣势就是当自旋的时间长了,会持续地销耗CPU资源,因此自旋锁也可以说是乐观锁。
针对上述三组策略,我们synchronized 属于哪种锁呢?
synchronized 即属于乐观锁又属于悲观锁,既是轻量级锁又是重级锁,既是自旋锁又是挂起等待锁。
synchronized 会根据锁竞争的激烈程度,自己适应。
竞争激烈程度高,那么就是一个悲观锁,以重量级锁的状态运行。
竞争激烈程度低,那么就是一个乐观锁,以轻量级锁的状态运行。
4. 读写锁 VS 互斥锁
我们的synchronized 就是个典型的互斥锁;加锁就是单纯的加锁,没有更细的划分了
像synchronized 只有两步操作:
- 进入代码块,加锁
- 除了代码块,解锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。
互斥锁用最简单的一句话来理解:某个资源只能被一个线程访问,读读,读写,写读,写写都是一样的。
下图也很好的演示了读写锁和互斥锁的特性:
读写锁是通过ReentrantReadWriteLock这个类来实现,在JAVA里面,为了提高性能而提供了这么个东西,读的地方用读锁,写的地方用写锁,读锁并不互斥,读写互斥,这部分直接由JVM进行控制。
来看代码(不要求会,下次需要用直接查):
// 创建一个读写锁 private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); // 获取读锁 rwLock.readLock().lock(); // 释放读锁 rwLock.readLock().unlock(); // 创建一个写锁 rwLock.writeLock().lock(); // 写锁 释放 rwLock.writeLock().unlock();
5. 可重入锁 vs 不可重入锁
可重入锁(递归锁),指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码;但不受影响 在 JAVA 环境下 ReentrantLock 和 synchronized 都是 可重入锁
举个不恰当的栗子:
滑稽老铁在上厕所,突然时空错乱,滑稽老铁跑到外面来了,但是厕所门还是锁着的,这就是不可重入锁也叫做死锁。如果还是可以进去那么就是可重入锁。
什么叫死锁?
死锁
就是多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。
我们来写个伪代码:
Object locker = new Object(); sychronized(loker) { ......//中途有很多代码 ...... sychronized(loker) { } }
那么此时,我们就发生了死锁
我们要想进入第二个锁那么就需要先第一个锁释放;
我们要想进入第一个锁那么就需要先第二个锁释放。
逻辑上矛盾了 --> 死锁。
发生死锁的情况
1. 一个线程一把锁,例如上述的情况;可重入锁没事,但不可重入锁会死锁
2. 两个线程两把锁,即使可冲突也可以发生死锁的情况。例如:
死锁产生的四个必要条件如下:
- 互斥条件:一个资源同一时间能且只能被一个线程访问;
- 不可掠夺:当资源被一个线程占用时,其他线程不可抢夺该资源;
- 请求与等待:当资源被一个线程占用时,其他线程只能等待资源的释放再拥有;
- 指的是若干线程形成头尾相接的情况,将所有资源都占用导致的整体死锁或局部死锁。
既然了解了死锁的必要条件,那么我们只要破坏其中一个条件则可避免产生死锁。
通常我们在业务中,可以设置等待时间。例如尝试占用资源时,设置等待时间,时间内未获得资源,则放弃尝试,避免程序长时间等待,占用过高的CPU资源。
尽量一次只占用一个资源,不要一次嵌套的占用多个资源,占用资源链越长,越容易产生死锁问题。
6. 公平锁和非公平锁
我们认为在我们追求女神的时候,每个追求者都有相同的概率追求到,我们认为这样的叫做公平锁;女神有偏爱,可能会对某个追求者有好感,我们认为是非公平锁。
但是在计算机看来刚刚相反,先到先得才是公平锁,等概率事件才是非公平锁。
公平锁
我们来看看图:
公平锁是一种设计思想,多线程在进行数据请求的过程中,先去队列中申请锁,按照FIFO先进先出的原则拿到线程,然后占有锁。
非公平所
既然有公平锁,那就有非公平锁,也是一种设计思想。线程尝试获取锁,如果获取不到,这时候采用公平锁的方式进行,与此同时,多个线程获取锁的顺序有一定的随机性,并非按照先到先得的方式进行。
优点:性能上高于公平锁
缺点:存在线程饥饿问题,存在某一个线程一直获取不到锁导致一直等待,“饿死了”
synchronized (只考虑 JDK 1.8)
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到的自旋锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
CAS
什么是CAS ,CAS 全称叫做 compare and swap (比较与交换)
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1. 比较 A 与 V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V。(交换)
3. 返回操作是否成功
下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解
CAS 的工作流程.
寄存器A 的值与 内存地址中的值相比,如果相等,就将寄存器B 的值与内存地址中的值进行交换。
CAS 是一个原子的硬件指令,仅靠硬件支持,并非是段代码,那么这就打开了新世界的大门,我们可以 不需要通过多线程也可以保证线程安全问题。
基于 CAS的操作:
1. 实现原子类
还记得我们最开始将多线程的案例吗?
两个线程各自增加 5W 次,结果是小于 10W的。
我们来看看通过CAS操作,如何实习:
代码:
结果:
线程是安全的。
我们内置了一个 AtomicInteger 这个原子类,这个类保证了 ++ 和 -- 的线程安全。
我们来看看getAndInteger 方法:
- 如果发现 value 值和 oldValue 值相等,那么就将 oldValue +1 赋给 value,那么就相当于 ++ 了一次;返回 true。
- 反之,如果 value 值和 oldValue 值不相等,那么返回 false ,继续循环。
在多线程的案例下,是可能发生 上述两种情况的;但是我们 CAS 反复观察它的值是否发生了变化,没变化就自增,没变化过就先更新在自增。
我们之前的案例为什么 线程不安全呢?就是 一个线程无法及时感应到 另一个线程的变化!
2. 实现自旋锁
来看代码:
如果我们现在的 owner 为 null 那么就将 当前的引用设置到 owner 中,完成加锁操作。
否则,owner 不为 null ,那么就飞快地进行循环,反复的询问,锁是否被释放,一旦被释放了,那么就将第一时间拿到这个锁。
好处就是能第一时间拿到锁;
坏处就是消耗CPU 资源忙等;
我们一般是在乐观锁的情况下使用;此时锁竞争程度不激烈。
还有一个经典的面试题:
ABA 问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要
1. 先读取 num 的值, 记录到 oldNum 变量中.
2. 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候:
1.如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
2.如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).