JAVA SE 多线程(下)
文章目录
- 📕1. 常见的锁策略
- ✏️1.1 乐观锁VS悲观锁
- ✏️1.2 轻量级锁VS重量级锁
- ✏️1.3 自旋锁
- ✏️1.4 公平锁VS非公平锁
- ✏️1.5 可重入锁和不可重入锁
- ✏️1.6 读写锁
- 📕2. 死锁
- ✏️2.1 哲学家就餐问题
- ✏️2.2 形成死锁的必要条件
- ✏️2.3 如何避免死锁
- 📕3. JUC(java.util.concurrent) 的常见类
- ✏️3.1 Callable 接口
- ✏️3.2 ReentrantLock
- ✏️3.3 原子类
- ✏️3.4 信号量 Semaphore
- ✏️3.5 CountDownLatch
- 📕4. synchronized 原理
- ✏️4.1 加锁工作过程
- ✏️4.2 其他的优化操作
- 📕5. CAS
- ✏️5.1 CAS的ABA问题
📕1. 常见的锁策略
注意 : 以下所介绍的锁策略, 不仅仅局限于Java这一种语言 , 这些性质通常也是给锁的实现者参考的.当然我们普通人也是可以了解一下的 , 这或许对使用锁也有帮助.
✏️1.1 乐观锁VS悲观锁
悲观锁: 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
乐观锁: 假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
🌰举个栗子: 同学 A 和 同学 B 想请教老师一个问题.
同学 A 认为 “老师是比较忙的, 我来问问题, 老师不一定有空解答”. 因此同学 A 会先给老师发消息: “老师你忙嘛? 我下午两点能来找你问个问题嘛?” (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题.如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.
同学 B 认为 “老师是比较闲的, 我来问问题, 老师大概率是有空解答的”. 因此同学 B 直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.
Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
就好比同学 C 开始认为 “老师比较闲的”, 问问题都会直接去找老师.但是直接来找两次老师之后, 发现老师都挺忙的, 于是下次再来问问题, 就先发个消息问问老师忙不忙,再决定是否来问问题.
✏️1.2 轻量级锁VS重量级锁
轻量级锁: 当一个线程尝试获取某个对象的锁时,如果该对象没有被其他线程锁定,则当前线程会将对象头中的Mark Word设置为指向当前线程栈帧的一个指针,这个过程称为“偏向锁”。如果多个线程同时竞争同一个锁,那么JVM会升级锁的状态,从偏向锁升级到轻量级锁。此时,每个线程都会尝试使用CAS操作来获取锁,如果成功则获得锁并进入临界区;如果失败,则自旋等待一段时间后再次尝试。
特定:
- 减少了操作系统上下文切换的开销。
- 在线程间竞争不激烈的情况下表现良好。
- 如果竞争过于激烈,可能会导致频繁的自旋,浪费CPU资源。
重量级锁: 传统的Java锁机制,如synchronized关键字所实现的锁,通常被称为重量级锁。当一个线程获取了某个对象的锁后,其他试图获取同一对象锁的线程会被阻塞,直到第一个线程释放锁为止。被阻塞的线程将进入等待队列,由操作系统负责管理这些线程的调度。
特点:
- 线程阻塞和唤醒的代价较高。
- 更适用于线程竞争激烈的场景,因为它可以避免CPU空转浪费资源。
- 相比轻量级锁,重量级锁的实现更加简单直接。
✏️1.3 自旋锁
按之前的方式,线程在抢锁失败后进⼊阻塞状态,放弃 CPU,需要过很久才能再次被调度 . 但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题 , 如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止.
理解⾃旋锁 vs 挂起等待锁 想象⼀下, 去追求⼀个⼥神. 当男⽣向⼥神表⽩后, ⼥神说: 你是个好⼈, 但是我有男朋友了~~ 挂起等待锁: 陷⼊沉沦不能⾃拔.... 过了很久很久之后, 突然⼥神发来消息, '咱俩要不试试?' (注意, 这 个很⻓的时间间隔⾥, ⼥神可能已经换了好⼏个男朋友了). ⾃旋锁: 死⽪赖脸坚韧不拔. 仍然每天持续的和⼥神说早安晚安. ⼀旦⼥神和上⼀任分⼿, 那么就能⽴刻 抓住机会上位.
优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).
✏️1.4 公平锁VS非公平锁
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后 C也尝试获取锁, C 也获取失败, 也阻塞等待. 当线程 A 释放锁的时候, 会发生啥呢?
公平锁 : 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁 : 不遵守 “先来后到”. B 和 C 都有可能获取到锁
这就好⽐⼀群男⽣追同⼀个⼥神. 当⼥神和前任分⼿之后, 先来追⼥神的男⽣上位, 这就是公平锁; 如果是⼥神不按先后顺序挑⼀个⾃⼰看的顺眼的, 就是⾮公平锁.
公平锁
非公平锁
💡💡💡注意 :
- 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
- 公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
- synchronized 是非公平锁
✏️1.5 可重入锁和不可重入锁
可重入锁: 简单来说就是一个线程如果抢占到了互斥锁资源,在锁释放之前再去该竞争同一把锁的时候,不需要等待,只需要记录重入次数。在多线程并发编程里面,绝大部分锁都是可重入的,比如Synchronized、ReentrantLock等,但是也有不支持重入的锁,比如JDK8里面提供的读写锁StampedLock。锁的可重入性,主要解决的问题是避免线程死锁的问题。
✏️1.6 读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同⼀个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。
⼀个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
两个线程都只是读⼀个数据, 此时并没有线程安全问题. 直接并发的读取即可.
两个线程都要写⼀个数据, 有线程安全问题.
⼀个线程读另外⼀个线程写, 也有线程安全问题
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁
ReentrantReadWriteLock.ReadLock 类表示⼀个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
ReentrantReadWriteLock.WriteLock 类表示⼀个写锁. 这个对象也提供了 lock / unlock方法进行加锁解锁.
其中,
读加锁和读加锁之间, 不互斥.
写加锁和写加锁之间, 互斥.
读加锁和写加锁之间, 互斥
读写锁特别适合于 “频繁读, 不频繁写” 的场景中.
📕2. 死锁
什么是死锁?
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
🌰举个栗子理解死锁 :
当我和我对象(当然现在还没有😭)⼀起去饺子馆吃饺子时 , 吃饺子需要酱油和醋.
我拿起了酱油瓶, 我对象拿起了醋瓶.
我 : 你先把醋瓶给我, 我用完了就把酱油瓶给你.
我对象 : 你先把酱油瓶给我, 我用完了就把醋瓶给你.
如果我们俩彼此之间互不相让, 就构成了死锁.
酱油和醋相当于是两把锁, 我们两个人就是两个线程.
✏️2.1 哲学家就餐问题
有个桌子 , 围着一圈哲学家 , 桌子中间放着一盘意大利面 . 每个哲学家两两之间, 放着一根筷子.
每个哲学家只做两件事 : 思考人生或者吃面条. 思考人生的时候就会放下筷子. 吃⾯条就会拿起左右两边的筷子(先拿起左边, 再拿起右边).
如果哲学家发现筷子拿不起来了 , 就会阻塞等待
关键点 : 如果5位哲学家同时拿起左手边的筷子时 , 然后再尝试拿右手的筷子, 就会发现右手的筷子都被占用了. 由于哲学家们互不相让, 这个时候就形成了死锁
死锁是一种严重的 BUG!! 导致一个程序的线程 “卡死”, 无法正常工作!
✏️2.2 形成死锁的必要条件
死锁产生的四个必要条件:
-
互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
-
不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
-
请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
-
循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。
当上述四个条件都成⽴的时候,便形成死锁。当然,死锁的情况下如果打破上述任何⼀个条件,便可让死锁消失。
✏️2.3 如何避免死锁
破坏循环等待
最常用的一种死锁阻止技术就是锁排序. 假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号 (1, 2, 3…M).
N 个线程尝试获取锁的时候, 都按照固定的按编号由小到⼤顺序来获取锁. 这样就可以避免环路等待
可能产生循环等待死锁的代码
//产生环路等待不是100%发生的,这只是概率问题,哲学家就餐产生死锁也是概率问题 public class Test { private static Object locker1 = new Object(); private static Object lockre2 = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (locker1) { synchronized (lockre2) { System.out.println("this is thread t1"); } } }); Thread t2 = new Thread(() -> { synchronized (lockre2) { synchronized (locker1) { System.out.println("this is thread t2"); } } }); t1.start(); t2.start(); } }
不会产生循环等待的代码
public class Test { private static Object locker1 = new Object(); private static Object lockre2 = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (locker1) { synchronized (lockre2) { System.out.println("this is thread t1"); } } }); Thread t2 = new Thread(() -> { synchronized (locker1) { synchronized (lockre2) { System.out.println("this is thread t2"); } } }); t1.start(); t2.start(); } }
📕3. JUC(java.util.concurrent) 的常见类
✏️3.1 Callable 接口
Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便我们借助多线程的方式计算结果
🌰代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 使用 Callable 版本
- 创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
- 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.
- 把 callable 实例使用 FutureTask 包装一下
- 创建线程, 线程的构造方法传入FutureTask . 此时新线程就会执行FutureTask 内部的 Callable 的call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
- 在主线程中调用futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果.
import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; public class Test { public static void main(String[] args) throws ExecutionException, InterruptedException { Callable callable = new Callable() { @Override public Integer call() throws Exception { int result = 0; for (int i = 0; i result+=i; } return result; } }; FutureTask public static void main(String[] args) { Semaphore semaphore = new Semaphore(4); Runnable runnable = new Runnable() { @Override public void run() { System.out.println("apply"); try { semaphore.acquire(); System.out.println("accussful"); Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } semaphore.release(); System.out.println("release"); } }; for (int i = 0; i
- ✏️5.1 CAS的ABA问题