目录
1.乐观锁和悲观锁
2.读写锁和普通的互斥锁
3.重量级锁和轻量级锁
4. 挂起等待锁和自旋锁
4. 公平锁和不公平锁
5. 可重入锁 和 不可重入锁
6. synchronized 的锁总结
7. CAS
7.1 CAS 伪代码
7.2 CAS是怎么实现的
7.3 CAS 实现原子类
7.4 实现自旋锁
8. CAS 的 ABA 问题
8.1解决方案
8.2 相关面试题
1)乐观锁,即预期锁冲突的概率很低。
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁
例如,下一波疫情即使来了,也不用担心,生活还能正常运转,很多吃的和用品都可以买到,不需要专门做准备。(乐观锁)
2)悲观锁,即预期锁冲突的概率很高
1)对于普通的互斥锁,只有两个操作:加锁和解锁
两个线程针对同一个对象加锁,就会产生互斥
加读锁:如果代码只是进行读操作,就加读锁加写锁:如果代码中进行了修改操作,就加写锁解锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需 要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
ReentrantReadWriteLock.ReadLock 类表示一个读锁 . 这个对象提供了 lock / unlock 方法进行加锁解锁。ReentrantReadWriteLock.WriteLock 类表示一个写锁 . 这个对象也提供了 lock / unlock 方法进 行加锁解锁
针对读锁和读锁之间,是不存在互斥关系的(多线程同时读一个变量,不会有线程安全问题)
读锁和写锁之间,写锁和写锁之间,才需要互斥
CPU 提供了 " 原子操作指令 "。操作系统基于 CPU 的原子指令 , 实现了 mutex 互斥锁。JVM 基于操作系统提供的互斥锁 , 实现了 synchronized 和 ReentrantLock 等关键字和类。
注意, synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作
synchronized 开始是一个轻量级锁。 如果锁冲突比较严重, 就会变成重量级锁
2)重量级锁
大量的内核态用户态切换很容易引发线程的调度
3)轻量级锁
少量的内核态用户态切换。不太容易引发线程调度。
4) 理解用户态 vs 内核态
想象去银行办业务
挂起等待锁是 重量级锁 的实现方式
优点 : 没有放弃 CPU, 不涉及线程阻塞和调度 , 一旦锁被释放 , 就能第一时间获取到锁 .缺点 : 如果锁被其他线程持有的时间比较久 , 那么就会持续的消耗 CPU 资源 . ( 而挂起等待的时候是不消耗 CPU 的
一个线程,针对同一把锁连续加锁两次,如果会死锁就是不可重入锁,如果不会死锁,就是可重入锁。synchronized 是可重入锁
1)既是一个乐观锁,也是一个悲观锁. (根据锁竞争的激烈程度自适应)
2)不是读写锁只是一个普通互斥锁.
3)既是一个轻量级锁,也是一个重量级锁(根据锁竞争的激烈程度,自适应)
4)轻量级锁的部分基于自旋锁来实现.重量级的部分基于挂起等待锁来实现
5)非公平锁
6)可重入锁.
我们假设内存中的原数据 V ,旧的预期值 A ,需要修改的新值 B 。1. 比较 A 与 V 是否相等。(比较)2. 如果比较相等,将 B 写入 V 。(交换)3. 返回操作是否成功。
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)
java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg ;Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性
public class Demo5 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger num = new AtomicInteger(0);
Thread t1 = new Thread(() ->{
for (int i = 0; i < 5000; i++) {
//这个方法相当于num++
num.getAndIncrement();
}
});
Thread t2 = new Thread(() ->{
for (int i = 0; i < 5000; i++) {
//这个方法相当于num++
num.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(num.get());
}
}
上述代码里面不存在线程安全问题.
基于CAS实现的++操作。
这里面就可以保证既能够线程安全,又能够比synchronized高效。
synchronized会涉及到锁的竞争,两个线程要相互等待。
CAS不涉及到线程阻塞等待
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
和上面的原子类类似,也是通过一个循环来实现的。
循环里面调用CAS。CAS会比较当前的owner值是否是null,
如果是null就改成当前线程.意思就是当前线程拿到了锁。
如果不是null就返回false,进入下次循环。
下次循环仍然是进行CAS操作
如果当前这个锁一 直被别人持有,当前尝试加锁的线程就会在这个while的地方快速
反复的进行循环~~~ =>自旋~~ (忙等)
自旋锁是一个轻量级锁也可以视为是一个乐观锁。
假设 A有 100 存款.,A想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.我们期望一个线程执行 -50 成功, 另一个线程 -50 失败。(另一个线程就是在机器出故障时卡了,又按多了一个取款,启用了该线程,使用要取款失败)
1) 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.2) 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中。3) 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败。最后取出来50
异常的过程:
1) 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.2) 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.3) 在线程2 执行之前, A的朋友正好给A转账 50, 账户余额变成 1004) 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作最后取出来100
这个时候, 扣款操作被执行了两次,就是ABA引起的
给要修改的值, 引入版本号.。在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期。
CAS 操作在读取旧值的同时 , 也要读取版本号 .真正修改的时候 ,如果当前版本号和读到的版本号相同 , 则修改数据 , 并把版本号 + 1。如果当前版本号高于读到的版本号 . 就操作失败 ( 认为数据已经被修改过了 )。
在 Java 标准库中提供了 AtomicStampedReference
全称 Compare and swap, 即 "比较并交换". 相当于通过一个原子的操作, 同时完成 "读取内存, 比较是否相等, 修改内存" 这三个步骤. 本质上需要 CPU 指令的支撑。
2) ABA问题怎么解决?
给要修改的数据引入版本号 . 在 CAS 比较数据当前值和旧值的同时 , 也要比较版本号是否符合预期。如果发现当前版本号和之前读到的版本号一致 , 就真正执行修改操作 , 并让版本号自增 ; 如果发现当前版本号比之前读到的版本号大, 就认为操作失败。