一、乐观锁和悲观锁:
(一)乐观锁 和 悲观锁概念
悲观锁:总是假设最坏的情况,认为每次读写数据都会冲突,所以每次在读写数据的时候都会上锁,保证同一时间段只有一个线程在读写数据。
乐观锁:每次读写数据时都认为不会发生冲突,线程不会阻塞,只有进行数据更新时才会检查是否发生冲突,若没有冲突,直接更新,只有冲突了(多个线程都在更新数据)才解决冲突问题。
举栗时间:
同学A 和同学B想请教老师一个问题.
同学A认为 "老师是比较忙的, 我来问问题, 老师不一定有空解答". 因此同学A会先给老师发消息: "老师,你忙嘛? 我下午两点能来找你问个问题嘛?" (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.
同学B认为 "老师是比较闲的, 我来问问题, 老师大概率是有空解答的". 因此同学B直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学B也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.
(二)乐观锁 和 悲观锁的实现
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
乐观锁的实现可以引入版本号机制. 借助版本号识别出当前的数据访问是否冲突.
(核心就是,线程能否成功刷新主内存的值。当工作内存的版本号 == 主内存的版本号才能更新,更新成功之后,同步刷新自己的版本号和主内存的版本号,表示此时更新成功。)
1) 线程1 此时准备将其读出( version=1, balance=100 ),线程 2 也读入此信息( version=1, balance=100 )
2) 线程 1 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 2从其帐户余额中扣除 20 ( 100-20 );
3) 线程 1完成修改工作,连同帐户扣除后余额( balance=50 ),写回到内存中,并将工作内存版本号和主内存版本号加1( version=2 ),;
4) 线程 2 完成了操作,试图向主内存中提交数据( balance=80 ),但此时比对版本发现自己工作内存的版本号 != 主内存的版本号。就认为这次操作失败.
当线程2的version和主线程的version不相等时,有两种解决办法
1.直接报错,线程2退出,不写回
2.线程2从主内存中读取最新的值50以及版本号2,再次在50的基础上执行-20操作 = 30,然后尝试重新写会主内存。 (CAS策略:不断重试写会,直到成功为止)
一般锁的实现都是乐观锁和悲观锁并用的策略。
二、读写锁(适用于线程基本上都在读数据,很少有写数据的情况)
多线程访问数据时,并发读取数据不会有线程安全问题,数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
将锁分为读锁和写锁。
- 多个线程并发访问读锁(读数据),则多个线程都能访问到读锁,读锁和读锁是并发的,不互斥。
- 两个线程都需要访问写锁(写数据),则这两个线程互斥,只有一个线程能成功的获取到写锁,其他写线程阻塞。
- 当一个线程读,另一个线程写(也互斥,只有当写线程结束,读线程才能继续执行。)
例子:当读者在追连载小说时,只能等作者写完,读者才能阅读
Synchronized不是读写锁,JDK内置了另一个ReentrantReadWriteLock实现读写锁。
三、重量级锁和轻量级锁:
重量级锁: 重量级锁的加锁机制重度依赖操作系统提供的 mutex (互斥锁),线程获取重量级锁失败进入阻塞状态,操作系统进行状态的切换(操作系统频繁地从用户态切换到内核态,或者从内核态切换到用户态),开销非常大。)
轻量级锁:轻量级锁的加锁机制尽可能不使用操作系统的mutex, 而是尽量在用户态执行操作, 实在搞不定了, 再使用 mutex.(很少)进行状态切换,开销较小。
四、自旋锁
while(获取lock == false){
//不断地循环
}
自旋锁表示当线程获取锁失败后,并不会让出CPU,线程也不阻塞,不会从用户态切换到内核态,而是在CPU上空跑,当锁被释放,此时这个线程就会很快获取到锁。
理解自旋锁 vs 挂起等待锁
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~ 挂起等待锁: 陷入沉沦不能自拔.... 过了很久很久之后, 突然女神发来消息, "咱俩要不试试?" (注意, 这个很长的时间间隔里, 女神可能已经换了好几个男票了).
自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能 立刻抓住机会上位.
优点: 自旋锁没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.
缺点:如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源
轻量级锁的常用实现就是采用自旋锁:
五、公平锁和非公平锁:
公平锁- 获取锁失败的线程进入阻塞队列,当锁被释放,第一个进入阻塞队列的线程首先获取到锁(等待时间最长的线程获取到锁)。
非公平锁- 获取锁失败的线程进入阻塞队列,当锁被释放,所有在队列中的线程都有机会获取到锁,获取到锁的线程不一定就是等待时间最长的线程。(synchronized就是非公平锁)
六、可重入锁 和不可重入锁
可重入锁:可重入锁的字面意思是“可以重新进入的锁”,获取到对象锁的线程可以再次加锁,这种操作就称为可重入。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的。 线程安全问题_explorer363的博客-CSDN博客
不可重入锁: Linux 系统提供的 mutex 是不可重入锁.
一个线程没有释放锁, 然后又尝试再次加锁. 按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第 二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无 法进行解锁操作. 这时候就会死锁。
七、CAS
(一)什么是CAS?
CAS(Compare and swap)比较并交换,是乐观锁的一种实现方法。不会真正阻塞线程,而是不断尝试更新。
CAS的操作流程:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1. 比较 A 与 V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V。(交换)
若比较不相等,说明此时线程的值A已经过时啦(即主内存中的值已经发生了变化),将当前主内存的最新值V保存到当前的工作内存中,此时无法将B写回主内存,继续循环,直到 A== V,将 B 写入 V.
3. 返回操作是否成功。
(二)由CAS实现的应用
(1)CAS实现原子类
int i = 0;
i++ ;i--等操作都是非原子性操作,多线程并发会产生线程安全问题。
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的. 典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
public class AtomicTest {
static class Counter {
// 基于整型的原子类
AtomicInteger count = new AtomicInteger();
void increase() {
// i++
count.incrementAndGet();
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count.get());
}
}
(2)CAS实现自旋锁
自旋锁指的是获取锁失败的线程,不会让出CPU,不会进入阻塞队列,而是在CPU上空转,不断查询当前锁的状态。
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引发的ABA问题
此时有两个线程t1,t2,同时修改共享变量num,num== A;
正常情况:
只有一个线程会将num值该为正确的值,而另一个线程则无法修改,因为这个线程的值已经过期了(num != A)
1. num == A
2. t1 --> num == B
3. CAS(V,A,B) 其中 V== B,A==A,B ==Z V != A,t2在CAS的过程中,主内存的值与工作内存的值不相等,因此,无法修改,需要将最新值读取到工作内存后再次尝试CAS。
特殊情况:
当一个线程连续对num值进行了修改,且最后一次修改将num值又重新修改为了它原来的值,对于另一个线程来说,是可以成功修改num的值的,因为主内存的值 == 工作内存的值
t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程.
解决方案:引入版本号机制
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候, 如果当前版本号(主内存)和读到的版本号(工作内存)相同, 则修改数据, 并把版本号 + 1.
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
八、synchronized关键字背后锁的升级流程
基本特点
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
3. 实现轻量级锁的时候大概率用到的自旋锁策略
4. 是一种不公平锁
5. 是一种可重入锁
6. 不是读写锁
JVM对synchronized锁的升级流程
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级(自动升级,程序员不用做任何处理)。
synchronized void increase() {
val++;
}
1)无锁:没有任何线程尝试获取锁 (此时没有任何线程调用increase(),因此对象就是无锁状态)
2) 偏向锁:第一个尝试加锁的线程, 优先进入偏向锁状态.
偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程. 如果后续没有其他线程来竞争该锁, 当这个线程再次进行其他同步操作时(重入或再次执行),就验证下是否为被标记线程,若是直接放行(避免了加锁解锁的开销)
当有第二个线程也在尝试获取锁后(开始有竞争后),JVM就会取消偏向锁状态,将锁升级为轻量级锁。
3) 轻量级锁: 随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现.通过 CAS 检查并更新一块内存 (比如 null => 该线程引用) 如果更新成功, 则认为加锁成功
如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
第二个尝试加锁的线程通过自旋的方式来获取轻量级锁,如果还有其他线程在想尝试获取锁,都在自旋等待第二个线程执行结束。
4) 重量级锁 如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁(悲观锁的实现)
当竞争非常激烈,多个线程都同时在竞争轻量级锁(一般来说就是当前线程数为CPU核数的一半),or 自旋次数超过默认值以上 , 就会将轻量级锁膨胀为重量级锁。
只要程序中调用了wait()方法,直接会膨胀为重量级锁,无论当前竞争是否激烈
此处的重量级锁就是指用到内核提供的 mutex .
执行加锁操作, 先进入内核态. 在内核态判定当前锁是否已经被占用
如果该锁没有占用, 则加锁成功, 并切换回用户态.
如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 等待被操作系统唤醒.
经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁.
优化操作
1、锁消除
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加 锁解锁操作是没有必要的, 白白浪费了一些资源开销.因此,JVM会自动进行锁消除。
2、锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁. 但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.
九、java.util.concurrent.lock
Lock接口是标准库的一个接口, 在 JVM 外实现的(基于 Java 实现).
使用Lock接口需要显示的进行加锁和解锁操作。
lock(): 加锁, 获取锁失败的线程进入阻塞状态,直到其他线程释放锁,再次竞争,如果获取不到锁就死等.
trylock(超时时间): 加锁, 获取锁失败的线程进入阻塞状态, 等待一定的时间,时间过了若还未获取到锁恢复执行,就放弃加锁,执行其他代码
unlock(): 解锁
synchronized和lock的区别:
1.synchronized是Java的关键字,由JVM实现,需要依赖操作系统提供的线程互斥锁(mutex);Lock是标准库的接口,其中一个最常用子类(ReentrantLock,可重入锁),由Java本身实现,不需要依赖操作系统。
2.synchronized 是隐式的加锁和解锁,而lock需要显示进行加锁和解锁
3.synchronized 在获取锁失败的线程时,死等;lock可以使用tryLock等待一段时间之后自动放弃加锁,线程恢复执行。
4.synchronized是非公平锁,RenntrantLock默认是非公平锁,可以在构造方法中传入true开启公平锁。
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
5.synchronized不支持读写锁,Lock的子类ReentrantReadWriteLock支持读写锁。
一般场景下,使用synchronized足够用了,需要用到超时等待锁,公平锁,读写锁再考虑使用lock接口。
十、死锁
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线 程被无限期地阻塞,因此程序不可能正常终止。
举个栗子理解死锁
滑稽老哥和女神一起去饺子馆吃饺子. 吃饺子需要酱油和醋.
滑稽老哥抄起了酱油瓶, 女神抄起了醋瓶.
滑稽: 你先把醋瓶给我, 我用完了就把酱油瓶给你.
女神: 你先把酱油瓶给我, 我用完了就把醋瓶给你.
如果这俩人彼此之间互不相让, 就构成了死锁.
酱油和醋相当于是两把锁, 这两个人就是两个线程.
如何避免死锁
死锁产生的四个必要条件:
互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样 就形成了一个等待环路。
当上述四个条件都成立的时候,便形成死锁。
当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。 其中最容易破坏的就是 "循环等待".