常见的锁策略
悲观锁 VS 乐观锁
悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
预期锁冲突的概率很高
乐观锁:
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做.
预期锁冲突的概率很低
例子
同学 A 和 同学 B 想请教老师一个问题.
同学 A 认为 “老师是比较忙的, 我来问问题, 老师不一定有空解答”. 因此同学 A 会先给老师发消息: “老师
你忙嘛? 我下午两点能来找你问个问题嘛?” (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题.
如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.
同学 B 认为 “老师是比较闲的, 我来问问题, 老师大概率是有空解答的”. 因此同学 B 直接就来找老师.(没
加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B
也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.
这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.
如果当前老师确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致 “白跑很多趟”, 耗费额
外的资源.
如果当前老师确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低.
总的来说
悲观锁,做的工作更多,付出的成本更多,更低效
乐观锁,做的工作更少,付出的成本更低,更高效
读写锁 VS普通的互斥锁
对于普通的互斥锁,只有两个操作,加锁和解锁
只要两个线程对同一个对象加锁,就会产生互斥
对于读写锁来说,分成了三个操作
加读锁:如果代码只进行了读操作,就加读锁
加写锁:如果代码进行了修改操作,就加写锁
解锁
针对读锁和读锁之间,是不存在互斥关系的
读锁和写锁之间,写锁和写锁之间,才需要互斥
重量级锁 VS轻量级锁
重量级锁,就是做了更多的事情,开销更大
轻量级锁,做的事情更少,开销更小
通常情况下,可以理解为悲观锁就是重量级锁,乐观锁就是轻量级锁
在使用的锁中,如果锁是基于内核的一些功能来实现的(比如调用了操作系统的mutex接口),此时一般认为是重量级锁(操作系统的锁会在内核中做很多事情,比如让线程阻塞等待…)
如果锁是纯用户态来实现的,此时一般认为这是轻量级锁(用户态的代码更可控更高效)
挂起等待锁 VS 自旋锁
挂起等待锁,往往就是通过内核的一些机制来实现的,往往较重[重量级锁的一种典型实现]
自旋锁往往是通过用户态代码来实现的,往往较轻[轻量级锁的一种典型实现]
理解自旋锁 vs 挂起等待锁
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~
挂起等待锁: 陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意,
这个很长的时间间隔里, 女神可能已经换了好几个男票了).
自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能
立刻抓住机会上位
公平锁 VS 非公平锁
这俩可能有点容易搞反,要记住
公平锁:多个线程在等待一把锁的时候,谁是先来的,谁就先获得这把锁(遵循先来后到原则)
非公平锁:多个线程等待同一把锁,不遵守先来后到原则,每个人等待线程获取锁的概率是均等的;(对于操作系统来说,本身线程之间的调度就是随机的(机会均等),操作系统提供的mutex这个锁,就属于非公平锁
可重入锁 VS 不可重入锁
一个线程针对一把锁,能连续加锁两次,会死锁,就是不可重入锁,如果不会死锁,就是可重入锁
理解 “把自己锁死”
一个线程没有释放锁, 然后又尝试再次加锁
// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待.
lock();
例子:
一个人闪现进了上锁的厕所,里面出不去,外面进不来
谈谈常用的synchronized这把锁
1:既是一个乐观锁,也是一个悲观锁(根据竞争的激烈程度,自适应)
2:不是读写锁,只是一个普通互斥锁
3:既是一个重量级锁,也是一个轻量级锁(根据竞争的激烈程度,自适应)
4:轻量级锁的部分基于自旋来实现,重量级的部分基于挂起等待锁来实现
5:非公平锁
6:可重入锁
CAS
什么是CAS:
CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功。
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线
程只会收到操作失败的信号。
CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)
CAS最大的意义,就是我们写这种线程安全的代码,提供了一个新的思路和方向
CAS都能2干啥?如何帮我们解决一些线程安全问题??
基于CAS能够实现"原子类"
(java标准库里提供了一组 原子类,针对锁常用一些int,long,int array…进行封装,可以基于CAS的方式进行修改,并且线程安全)
import java.util.concurrent.atomic.AtomicInteger;
public class Demo27 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger num = new AtomicInteger(0);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
// 这个方法就相当于 num++
num.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
// 这个方法就相当于 num++
num.getAndIncrement();
}
});
// // ++num
// num.incrementAndGet();
// // --num
// num.decrementAndGet();
// // num--
// num.getAndDecrement();
// // += 10
// num.getAndAdd(10);
t1.start();
t2.start();
t1.join();
t2.join();
// 通过 get 方法得到 原子类 内部的数值.
System.out.println(num.get());
}
}
这个代码里面就不存在线程安全问题,基于CAS实现的++操作,这里就可以保证既能够线程安全,又能够比synchronized高效
synchronized会涉及到锁的竞争,两个线程要互相等待
CAS不涉及到线程阻塞等待
针对上诉为啥++是线程安全的
这里谈到CAS
==重点理解:==这整个代码实现的就是++,CAS他同时要执行比较,+1,交换这三个动作,如果失败,就没有交换的事情
伪代码:
基于CAS能够实现自旋锁
伪代码:
CAS中的ABA问题
ABA 的问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要
先读取 num 的值, 记录到 oldNum 变量中.
使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这
个时候 t1 究竟是否要更新 num 的值为 Z 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程.
ABA 问题引来的 BUG
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况.
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50
操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.
正常的过程
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期
望更新为 50. - 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
- 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.
异常的过程 - 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期
望更新为 50. - 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100 !!
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作
这个时候, 扣款操作被执行了两次!!! 都是 ABA 问题搞的鬼
解决方案
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候,
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
对比理解上面的转账例子
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50
操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.
- 存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100,
版本号为 1, 期望更新为 50. - 线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100, 版本号变成3.
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读
到的版本号为 1, 版本小于当前版本, 认为操作失败
synchronized中的优化机制
锁膨胀/锁升级
体现了synchronized能够自适应这样的能力
锁粗化
细化,粗化,此处粗细指的是"锁的粒度"
加锁代码涉及到的范围
加粗代码的范围越大,认为锁的粒度越粗
范围越细,则认为粒度越细
如果锁粒度比较细,多个线程之间并发性就更高
如果比较粗,加锁解锁的开销就更小
编译器就会有一个优化,就会自动判定,如果某个地方锁的粒度太细了就会进行粗化,
如果两次加锁之间的间隔较大(中间隔的代码多),一般不会进行这种优化,如果间隔较小(中间隔的代码少),就可能触发这个优化
锁消除
有些代码,明明不用加锁,如果你给加上了锁,编译器发现了这个加锁好像没啥必要,就直接把锁干掉了
有的时候加锁操作不是很明显,稍不留神就做出了这种错误的决定
StringBuffer,Vector…在标准库中进行了加锁操作,在单个线程中用到了上诉的类,就是单线程进行了加锁