目录
锁策略:
1. 乐观锁 vs 悲观锁
2. 轻量级锁 vs 重量级锁
3. 自旋锁 vs 挂起等待锁
4. 公平锁 vs 非公平锁
5. 可重入锁 vs 不可重入锁
6. 读写锁 vs 互斥锁
Java中 synchronized 内部实现策略 (内部原理)
Java中的synchronized具体采用了哪些锁策略呢?
死锁相关
什么死锁
死锁的三种典型情况 :
如何避免死锁 ?
死锁的四个必要条件 :
如何解决死锁
锁消除
锁粗化
锁策略:
// 实现一把锁的时候, 针对这个锁要进行的一些设定
1. 乐观锁 vs 悲观锁
// 悲观锁 : 总是假设最坏的情况, 每次去拿数据的时候都认为别人会修改, 所以每次在拿数据的时候都会加锁, 这样别人想拿这个数据就会阻塞, 直到它拿到锁
// 乐观锁 : 假设数据一般情况下不会产生并发冲突, 所以在数据进行提交更新的时候, 才会正式对数据是否产生并发冲突进行检测, 如果发现并发冲突了, 则让返回用户错误的信息, 让用户决定如何去做
2. 轻量级锁 vs 重量级锁
// 锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的
// CPU 提供了 "原子操作指令"
// 操作系统基于 CPU 的原子指令, 实现 mutex 互斥锁
// JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 reentrantlock 等关键字和类
// 重量级锁 : 加锁机制重度依赖了 OS 提供了mutex
// 大量的内核态用户态切换 ; 很容易引发线程的调度
// 轻量级锁 : 加锁机制尽可能不使用 mutex , 而是尽量在用户态代码完成, 实在搞不定再使用 mutex
// 少量的内核态用户态切换 ; 不太容易引发线程调度
3. 自旋锁 vs 挂起等待锁
// 自旋锁 : 当第一次获取锁失败后, 立即再尝试获取锁, 无限循环, 直到获取到锁为止, 这样一旦锁被其他线程释放, 就能第一时间获取到锁
// 自旋锁是一种典型的轻量级锁的实现方式, 其优点为: 没有放弃 CPU , 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁; 缺点是: 如果锁被其他线程持有的时间比较久, 就会持续消耗 CPU 的资源 (挂起等待的时候不消耗 CPU 资源)
// 挂起等待锁 : 当第一次获取锁失败后, 就挂起等待 (阻塞等待), 一直等到系统调用再次调度才能获取锁
4. 公平锁 vs 非公平锁
// 公平锁 : 遵循 "先来后到" , 当有锁释放后按照顺序获取锁
// 非公平锁 : 不遵循 "先来后到" , 当有锁释放后每个需要锁的进程都可以获取锁
// 注意 : 操作系统内部的线程调度就可以视为是随机的, 如果不做任何额外的限制, 锁就是非公平锁, 如果要实现公平锁, 就需要依赖额外的数据结构来记录线程的先后顺序; 公平锁和非公平锁没有好坏之分, 关键看适用场景
// synchronized 是非公平锁
5. 可重入锁 vs 不可重入锁
// 可重入锁 : 允许同一个线程多次获取同一把锁
// 比如在一个递归函数里面有加锁操作, 递归过程中这个锁会阻塞自己吗? 如果不会, 那么这个锁就是可重入锁 (就因为这个原因, 可重入锁又叫做递归锁)
// Java 里只要以 Reentrant 开头命名的锁都是可重入锁, 而且 JDK 提供的所以现成的Lock 实现类, 包括 synchronized 关键字锁都是可重入的, 而 Linux 系统提供的 mutex 是不可重入锁
6. 读写锁 vs 互斥锁
// 读写锁 : 在执行加锁操作时需要额外表明读写意图, 读者之间互不排斥, 而写者之间则要求与任何人互斥
// 一个线程对于数据的访问, 主要存在两种操作: 读数据和写数据
// 两个线程都只读一个数据, 此时并没有线程安全问题, 直接并发读就行
// 两个线程同时写一个数据, 此时就会存在线程安全问题
// 一个线程读另一个线程写, 也会存在线程安全问题
// 读写锁是将 读操作和写操作区分对待, Java 标准库中提供了 ReentrantReadWriteLock 类, 实现了读写锁
// 读写锁特别适用于 "频繁读, 不频繁写" 的场景中
// synchronized 不是读写锁
Java中 synchronized 内部实现策略 (内部原理)
// 代码中写了一个synchronized 之后, 这里可能会产生一系列的 "自适应过程" , 锁升级(锁膨胀)
// 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
// 偏向锁,不是真的加锁, 而只是做了一个 "标记" . 如果有别的线程来竞争锁了, 才会真的加锁, 如果没有, 那么自始至终都不会真的加锁 (加锁本身有一定开销, 能不加就不加, 有人竞争才加)
// 偏向锁在没有其他人竞争的时候, 就仅仅是一个简单的标记 (非常轻量). 一旦别的线程尝试进行加锁, 就会立刻把偏向锁升级成真正的加锁状态, 让别人阻塞等待
Java中的synchronized具体采用了哪些锁策略呢?
// 因为synchronized 的自适应特性,所以它包含很多锁策略
1. synchronized 既是悲观锁, 也是乐观锁
// synchronized 初始使用乐观锁策略, 当发现锁竞争频繁的时候, 就会自动切换成悲观锁策略
2. synchronized 既是重量级锁, 也是轻量级锁
3. synchronized 重量级锁部分是基于系统的互斥锁实现的; 轻量级锁部分是基于自旋锁实现的
// 轻量级锁 : synchronized 通过自旋锁的方式来实现轻量级锁
// 我这边把锁占据了, 另一个线程就会按照自旋的方式, 来反复查询当前的锁是否被释放了, 但是, 后续如果竞争这把锁的线程越来越多 (锁竞争更激烈了), 从轻量级锁, 升级成重量级锁
4. synchronized 是非公平锁 (不会遵循先来后到, 锁释放之后, 哪个线程拿到锁, 各凭本事)
5. synchronized 是可重入锁 (内部会记录那个线程拿到了锁, 记录引用计数)
6. synchronized 不是读写锁
死锁相关
什么死锁
死锁是指在多进程或多线程系统中,两个或两个以上的进程(线程)在执行过程中,因争夺资源而造成的一种互相等待的僵局状态,若无外力作用,这些进程(线程)都将无法向前推进
死锁的三种典型情况 :
1. 一个线程, 一把锁, 但是是不可重入锁. 该线程针对这个锁连续加锁两次, 就会出现死锁
2. 两个线程, 两把锁, 这两个线程先分别获取到一把锁, 然后再同时尝试获取对方的锁
3. N个线程, M把锁
如何避免死锁 ?
// 首先要明确死锁产生的原因, 即 : 死锁的四个必要条件
// 想产生死锁那么四个必要条件缺一不可, 所以只要能够破坏其中的任意一个条件都可以避免出现死锁情况
死锁的四个必要条件 :
1. 互斥使用 : 一个线程获取到一把锁之后, 别的线程不能获取到这个锁
// 实际使用的锁, 一般都是互斥的 (锁的基本特性)
2. 不可抢占 : 锁只能被持有者主动释放, 而不能是被其他线程直接抢走
// 也是锁的基本特性
3. 请求和保持 : 这个一个线程去尝试获取多把锁, 在获取第二把锁的过程中, 会保持对第一把锁的获取状态
// 取决于代码结构
4. 循环等待 : t1 尝试获取 locker2, 需要 t2 执行完, 释放 locker2; t2 尝试获取 locker1, 需要 t1 执行完, 释放 locker1
// 代码展示一下产生死锁时的情况
java">Thread t1 = new Thread(() -> {synchronized (locker1) {try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker2) {System.out.println("t1 两把锁加锁成功!");}}});Thread t2 = new Thread(() -> {synchronized (locker2) {try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker1) {System.out.println("t2 两把锁加锁成功!");}}});t1.start();t2.start();
// 取决于代码结构, 是日常解决死锁问题的最关键要点
如何解决死锁
1. 经典算法 : 银行家算法
2. 比较简单的一个解决死锁的办法 : 针对锁进行编号, 并且规定加锁的顺序
// 比如 : 约定, 每个线程如果想要获取多把锁, 必须先获取编号小的锁, 后获取编号大的锁
// 将上面的代码进行更改, 即 : 都先获取锁 locker1 , 就可以很好的解决死锁问题
锁消除
// 编译器, 会智能的判断, 当前这个代码, 是否必要加锁
// 如果你写了加锁, 但是实际上没有必要加锁, 就会把加锁操作自动删除掉
锁粗化
// 关于"锁的粒度" : 如果加锁操作里面包含的实际要执行的代码越多, 就认为锁的粒度越大
// 具体 "锁的粒度" 要根据实际情况来确定, 没有好坏之分