目录
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
// 代码展示一下产生死锁时的情况
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 , 就可以很好的解决死锁问题
锁消除
// 编译器, 会智能的判断, 当前这个代码, 是否必要加锁
// 如果你写了加锁, 但是实际上没有必要加锁, 就会把加锁操作自动删除掉
锁粗化
// 关于"锁的粒度" : 如果加锁操作里面包含的实际要执行的代码越多, 就认为锁的粒度越大
// 具体 "锁的粒度" 要根据实际情况来确定, 没有好坏之分