常见的锁策略
1.悲观锁 和 乐观锁 (概念)
悲观锁: 预期锁冲突的概率很高
乐观锁 : 预期锁冲突的概率很低
悲观锁 做的工作更多, 付出成本更多,更低效
悲观锁 做的工作更少, 付出成本更低,更高效
2. 读写锁 和 普通互斥锁
对于普通锁 只有两个操作
加锁 和 解锁
只要两个线程针对同一个对象加锁 , 就会产生互斥
对于读写锁来说, 分成了三个操作
加读锁 - > 如果代码只是进行了读操作, 就加读锁
加写锁 -> 如果代码中进行了修改操作 就得加写锁
解锁
好处就是针对读锁和读锁之间 (多线程之间读取同一个变量不会出现线程安全问题)是不存在互斥关系的
读锁和写锁, 写锁和写锁 之间才需要互斥.
读写锁 一般出现在 " 频繁读, 但不频繁写" 的场景中
3. 重量级锁 和 轻量级锁 (结果)
重量级锁: 做的事情更多 开销更大 加锁解锁 过程更慢,更低效
轻量级锁: 做的事情更少, 开销更少, 加锁解锁,过程更快更高效
可以认为 在一般情况下:
悲观锁 一般都是重量级锁
乐观锁 一般都是轻量级锁
在使用的锁中,如果锁基于内核的一些功能来实现的(比如调用了操做系统提供的 mutex 接口) , 此时一般认为这是重量级锁(操作系统会在内核中的很多的事情,比如让线程阻塞等待)
如果锁纯用户态实现, 此时一般认为是轻量级锁(用户态代码 可控, 也高效)
4. 挂起等待锁 和 自旋锁 (实现)
挂起等待锁 一般就是通过内核的一些机制来实现了,往往较重; [重量级锁的典型实现]
自旋锁, 一般都是通过用户态代码来实现的,往往较轻 [轻量级锁的典型实现]
- 自旋锁 相当于 一旦锁被释放, 就能第一时间拿到锁,速度会更快,(忙等, 消耗cpu资源)
- 挂起等待锁, 如果锁被释放,不能第一时间拿到所, 可能需要过很久才能拿到锁.但这个时间空出来了, 可以实现别的任务…
5.公平锁 和 非公平锁
公平锁: 多个线程等待一把锁的时候, 谁先来的 谁就能获得这个锁(遵守先来后到)
非公平锁: 多个线程等待一把锁的时候,不遵守先来后到(每个等待的线程获取到锁的概率都是均等的)
对于操做系统来说,本身线程之间的调度就是随机的(机会均等的) 操做系统的mutex这个锁 就是属于非公平锁.
要想实现公平锁 反而要实现更多的代价 (整个队列,来把这些参与竞争的线程给排一排先来后到);
6. 可重入锁 和 不可重入锁 (Reetrant)
可重入锁 : 一个线程 针对一把锁,上锁两次,不会出现死锁
不可重入锁 : 一个线程 针对一把锁,上锁两次,会出现死锁
CAS (compare and swap)
要做的事情就是 拿着寄存器/ 某个内存中的值, 和另外一个内存的值 进行比较 如果值相同的, 就把另一个寄存器/ 内存的值和当前的这个内存交换.
伪代码如下:
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
//此处的"交换" 可以理解成赋值
//返回true 代表成功,返回false 代表失败.
此时CAS指的是 CPU提供了一个单独的CAS指令,通过这条指令 就完成了上述伪代码的描述的过程 ,
一条CPU指令也就是保证了原子性 中途不会被其他线程干扰(对于CPU来说 指令就是最小的不可分割的最小单位.)
CAS最大的意义就是让我们写多线程安全的代码, 提供了一个新的思路和方向(这就和锁不太一样)用硬件实现通过一条指令 , 直接让程序员使用 .
CAS的作用:
-
基于CAS能够实现"原子类"
(Java标准库里提供了一些原子类, 针对所常用多一些的int , long , int arry … 进行封装 基于CAS的修改方式, 并且线程安全)
public class Demo19 {
public static void main(String[] args) throws InterruptedException {
//原子类 (整型)
AtomicInteger num = new AtomicInteger(0);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
//相当于 i ++;
num.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
//相当于 i ++;
num.getAndIncrement();
// ++i
// num.incrementAndGet();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(num.get());
}
}
这个代码中不存在线程安全问题
基于CAS 实现的++ 操作
这里面就可以保证既能够线程安全, 由比 synchronized 更高效
synchronized 就会涉及到锁的竞争, 两个线程需要相互等待
CAS 不会涉及到线程的阻塞等待问题
伪代码实现
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
上述代码 while循环的CAS是核心
进行判定,当前内存的值 是不是和刚才寄存器里取到的值是不是一致,
如果判定成功,就把Value 设为oldValue + 1 返回 true ; 循环结束
如果判定不成功 就啥也不做 返回false 继续下次循环
// 站在单线程的角度 , 如果代码操作value 那么读到的value 值 是一样的 那么一定会true
// 但是如果站在多线程的角度 , 可能会存在 多个线程都在 load 和 cas 这个值的情况 ,就拿上述原子类代码来说.
对于线程操作来说如果t1 和 t 2都是先读取的数据
那么两者内部都是 0 此时 t1 先进行cas 判断 因为 内存和寄存器的值是相同的 因此 自增1 然后交换
此时就成为了这个样子. t1线程自增一次结束
此时 t2 进行 cas 判断 因为不相等 所以判定为 cas () != true 判定成功就 进入循环
循环内部的值是继续加载一次 内存中的值给寄存器
也就成为了这样 此时 寄存器内部的值和内存相等 因此 内存器中的值进行 + 1 后交换 循环结束
因此不管有多少个线程,自增多少次,内存中的值就会准确无误的自增多少次.
- CAS 能够实现" 自旋锁 "
CAS 的 ABA 问题
如果当前内存的值就是旧值, 那么就认为当前的内存没有发生改变(也就是 没有别的进行进行修改,) 于是就会继续进行执行交换的操作 但是这样的设定并不一定就准确 可能会出现一些极端情况
例如: 本来旧值是A,被其他线程改成了B ,又改回成了A
这样就导致B线程内这样的误操作成功了, 这就是典型的ABA问题.
解决思路:
引入一个"版本号" 只能变大, 不能变小, 修改变量的时候,比较的就不是变量本身了 , 而是比较版本号
(相当于就是引入一个变量 只能往一个方向进行 然后比较这个变量是否相等 在进行 操作 …)\
就相当于 两个线程进行 100 - 50 操作
但是这次只需要减少1次50 即可 本来余额会变成 50 但是此时 t3 这里 + 50 就会变成 100 此时 t2 判定成功 就会误操作 再减 50 如果引入版本号 ( 只能加 不能 减 那么 判断版本号就不会出现这样的问题.)
如果只拿变量本身进行判定 , 因为变量的值有加有减 就容易出现ABA问题
但是现在拿版本号来进行判定, 要求版本号只能增加(固定方向) , 这个时候就不会有ABA问题.
基于版本号的方式来进行多线程数据的控制,也是一种乐观锁的典型体现.
- 数据库里
- 版本管理工具(SVN) 通过版本号来进行多人开发协同.
synchronized 中的锁优化机制
-
锁膨胀/ 锁升级
体现了synchronized 能够"自适应" 能力
-
锁粗化
锁的粗细就指的是"锁的粒度"(加锁代码涉及范围,加锁代码的范围越大,锁的粒度越粗 ,加锁代码范围越小,锁的粒度越细)
也就是锁粒度越细 优点: 说明多个线程的并发性就更高,
如果锁粒度比较粗 优点: 就说明加锁解锁的开销就更小,
因此编译器就会有一个优化 自动判定 如果某个代码锁的粒度太细, 就会进行粗化 ( 扩大加锁代码的范围 )
如果两次加锁之间的间隔较大,(中间隔的代码多) ,一般不会优化,
如果两次加锁之间的间隔较小(中间隔的代码少) , 就很有可能出发这个优化.
-
锁消除
有些代码,明明不用加锁 , 但是你还是加锁了,编译器就会发现加锁好像没有必要, 此时就直接把锁给去掉了.