JUC系列:(三)同步
临界区
临界资源:一次仅允许一个进程使用的资源
临界区:访问临界资源的代码块
竞态条件:多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测
避免临界区竞态条件发生:
- 阻塞式:synchronized,lock
- 非阻塞式:原子变量
synchronized
使用锁完成同步
synchronized是可重入、不公平的重量级锁
同步代码块:
- 锁对象建议使用共享资源
- 实例方法使用:this
- 静态方法使用:类名.class
1 | synchronized(锁对象){ |
同步方法:
synchronized修饰的方法的不具备继承性,所以子类是线程不安全的
- 如果子类的方法也被synchronized修饰,两个锁对象其实是一把锁,而且是子类对象作为锁
实例方法:默认用,this作为的锁对象
静态方法:默认用,类名 .class作为的锁对象
1 | // 同步方法 |
锁原理
- 每个 Java 对象都可以关联一个Monitor对象(Monitor也是class,实例存储在堆中)
- 如果使用synchronized给对象上锁,对象头的Mark World指向Monitor对象(重量级锁)
- Mark Word结构如下(最后两位是锁标志位)
工作流程:
- 开始时,Monitor中,Owner为空
- 当Thread-1执行synchronized时
- 将Monitor的Owner设为Thread-2
- obj对象的Mark Word指向Monitor
- obj对象原有的Mark Word存入线程栈中的锁记录中(轻量级锁部分有讲)
- 在Thread-1上锁的过程中,后续执行synchronized的线程,会进入EntryList中(BLOCKED状态)
- 在Thread-1执行完同步代码后,根据obj对象头找到Monitor
- 将Monitor的Owner设为空
- obj对象的Mark Word还原
- 唤醒EntryList中等待的线程来竞争
- WaitSet中的Thread,是之前获得过锁,但条件不满足进入WAITING状态的线程(wait-notify机制)
字节码:
1 | public class SyncTest { |
1 | # syncBlock()方法 |
1 | # syncMethod()函数 |
锁升级
synchronized是可重入、不公平的重量级锁,可以进行优化
1 | 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 // 随着竞争增加,只能锁升级,不能降级 |
偏向锁
意义:偏向于第一个获取锁的线程,之后重新获取该锁不再需要同步操作
- 锁对象第一次被获取时,obj对象的Mark Word末尾标记为101;使用CAS操作,将线程ID记录到Mark Word中
- 以后这个线程进入同步块,如果线程ID还是自己的,证明没有竞争,就不再需要任何同步操作
- 一旦另一个线程尝试获取锁对象,偏向状态就宣告结束,Mark Word恢复到未锁定或者轻量级锁状态
对象创建时:
默认开启偏向锁,对象创建后,Mark Word后三位为101,thread,epoch,age都为0
JDK8默认延迟4s开启偏向锁(避免刚开始,好多线程都来抢锁)
-XX:BiasedLockingStartupDelay=0
设置延迟时间-XX:-UseBiasedLocking
禁用偏向锁
如果一个对象计算过hashCode,就再也无法进入偏向状态了
撤销偏向锁的状态:
- 调用对象的hashCode方法(偏向锁对象的Mark Word存储的是线程ID,调用hashCode将导致偏向锁被撤销)
- 其他线程尝试获取锁,偏向锁升级为轻量锁
- 偏向锁状态执行obj.notify()会升级为轻量级锁
- 调用obj.wait()会升级为重量级锁
批量重偏向:
为了解决:一个线程创建大量对象并执行同步操作,另一个线程将这些对象作为锁对象进行操作,导致大量的偏向锁撤销操作
具体方法:
- 以class为单位,维护一个偏向锁撤销计数器,每一次该class的对象发生偏向锁撤销时,该计数器+1
- 当计数器值达到阈值(默认20),JVM认为该class偏向锁有问题,进行批量重偏向
批量撤销:
- 应用场景:在明显多线程竞争剧烈的场景下,使用偏向锁是不合适的
- 具体方法:
- 当计数器值达到阈值(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向
- 之后对于该class的锁,直接走轻量级锁的逻辑
轻量级锁
意义:一个对象有多个线程要加锁,但加锁的时间是错开的(没有竞争),可以使用轻量级锁来优化
进入同步代码块时:
- 如果锁对象为无锁状态,JVM在当前线程的栈中创建一个锁记录(Lock Record),存储锁对象的Mark Word拷贝
加锁时:
- 当前线程使用CAS操作,把当前线程对象Mark Word的锁标志为“00”
- 如果CAS成功,Mark Word中的指针指向栈中的锁对象,锁对象中的指针指向Mark Word
- 如果CAS失败,有两种情况
- 其他线程已经持有该轻量级锁,表明有竞争,进入锁膨胀过程
- 线程自己执行了锁重入,就加一条Lock Record作为重入的计数
解锁时:
- 如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减1
- 如果锁记录的值不为null,使用 CAS 将 Mark Word 的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
重量级锁
在尝试加轻量级锁的过程中,CAS 操作无法成功。可能是其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
- 当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁了(有竞争)
- Thread-1加锁失败,进入锁膨胀流程
- 为锁对象申请Monitor锁
- 通过对象头获取到持锁线程,将Monitor的Owner置为Thread-0,将对象头指向重量级锁地址
- 自己进入Monitor的EntryList(BLOCKED状态)
- 当Thread-0退出同步块解锁时,使用CAS将Mark Word的值恢复给对象头失败
- 这时进入重量级解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为 null,唤醒EntryList中BLOCKED线程
锁优化
自旋锁
重量级锁竞争时,尝试获取锁的线程不会立即阻塞,可以使用自旋(默认10次)来进行优化
注意事项:
单核CPU自旋就是浪费时间,多核才有意义
自旋失败后进入阻塞状态
JDK7之后不能控制是否开启自旋功能,由 JVM控制
手动模拟自旋锁:
1 | public class SpinLock { |
锁消除
JVM的JIT编译器的优化。通过逃逸分析来支持。如果堆上的共享数据不可能逃逸出去被其它线程访问到,可以将它们的锁进行消除
锁粗化
如果虚拟机探测到一串的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部
- 看似没有锁,其实有很多锁
1 | public static String concatString(String s1, String s2, String s3) { |
- StringBuffer的append方法是synchronized修饰的
- 扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,只需要加锁一次就可以
1 | public static String concatString(String s1, String s2, String s3) { |
多把锁
将锁的粒度细化:
- 好处,可以增强并发度
- 坏处,容易发生死锁
活跃性
死锁
四个必要条件:
- 互斥
- 不可剥夺
- 请求与保持
- 循环等待
打破任意一个都可以破坏死锁
1 | new Thread(() -> { |
定位死锁的方法:
- 使用jps定位进程ID,再用
jstack 进程ID
定位死锁,找到死锁的线程去查看源码,解决优化
1 |
|
- Linux下,可以通过top定位到CPU占用高的Java进程。再利用
top -Hp 进程ID
定位是哪个线程。最后用jstack 进程ID
查看这个线程的线程栈
避免死锁:
- 注意加锁的顺序
活锁
两个线程并没有被阻塞,但是互相改变,最后谁也没法结束
- 比如一个线程++,另一个–
饥饿
一个线程优先级太低,一直得不到CPU调度执行
wait-notify
基本使用
需要获取对象锁后才可以调用 obj.wait()
,notify随机唤醒一个线程,notifyAll唤醒所有线程去竞争CPU
1 | // Object类API |
对比Thread.sleep:
Thread.sleep()
用来控制自己,暂停一段时间,将执行机会让给别的线程;obj.wait()
用于线程间通信Thread.sleep()
不会释放锁;obj.wait()
会放弃对象锁Thread.sleep()
可以在任何地方用;obj.wait()
必须在同步代码中用(先获取锁)
底层原理
调用wait方法,线程即可进入WaitSet变为WAITING状态
BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间
BLOCKED线程会在Owner线程释放锁时唤醒
WAITING线程会在Owner线程调用notify或notifyAll时唤醒,进入EntryList重新竞争
代码优化
问题:虚假唤醒,notify只能随机唤醒WaitSet中的一个线程,可能无法唤醒正确的线程
解决方法:采用notifyAll,使用while + wait保证能执行
1 | synchronized (room) { |
park-unpark
基本使用
LockSupport类方法:
LockSupport.park()
:挂起原语,暂停当前线程LockSupport.unpark(暂停的线程对象)
:恢复某个线程的运行
1 | // 先park再unpark;先unpark再park,最终结果是一样的,都能恢复线程的运行 |
出现原因
LockSupport出现就是为了增强wait-notify的功能:
- wait-notify必须配合Object Monitor一起使用,而 park-unpark 不需要
- park-unpark以线程为单位来阻塞和唤醒线程,而notify只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程
- park-unpark可以先unpark,wait-notify不能先notify
- wait会释放锁资源,park不会释放锁资源,只会阻塞当前线程释放CPU
底层原理
类似生产者消费者
如果先park:
当前线程调用
Unsafe.park()
方法检查
_counter
(当前为0),此时获得_mutex
互斥锁线程进入
_cond
条件变量,挂起
其他地方调用
Unsafe.unpark(thread0)
方法设置_counter为1
唤醒
_cond
条件变量中的Thread-0,Thread-0恢复运行设置_counter为0
如果先unpark:
- 其他地方调用
Unsafe.unpark(thread0)
方法- 设置_counter为1
- 当前线程调用
Unsafe.park()
方法- 检查
_counter
(当前为1),无需挂起,继续运行 - 设置_counter为0
- 检查
安全分析
线程安全:多个线程调用同一个实例的方法时,是线程安全的
即使每个方法是原子的,但多个方法的组合不是原子的,只能保证调用的方法内部安全:
1 | Hashtable table = new Hashtable(); |
- 常见线程安全类:
String
,Integer
,StringBuffer
,Random
,Vector
,Hashtable
,java.util.concurrent
包String
,Integer
等都是不可变类,内部的状态不可以改变,所以方法是线程安全- replace等方法底层是新建一个对象,复制过去
同步模式
保护性暂停
单任务版:
Guarded Suspension,用在一个线程等待另一个线程的执行结果
- 有一个结果需要从一个线程传递到另一个线程,让它们关联同一个
GuardedObject
- join的实现、Future的实现,采用的就是此模式
- 注意,由于虚假唤醒,我们需要使用while将wait包裹起来
1 | class GuardedObject { |
1 | GuardedObject guard = new GuardedObject(); |
多任务版:
- 其实就是给每个guard一个id,并且将所有gurad保存在一个Map中
1 | class Mailboxes { |
1 | for (int i = 0; i < 10; i++) { |
顺序输出
顺序输出2,1,2,1(也有可能多个2连在一起)
t2给t1发放许可(多次连续调用unpark只会发放一个许可)
1 | Thread t1 = new Thread(() -> { |
交替输出
- 暂时略过
异步模式
- 暂时略过
- 标题: JUC系列:(三)同步
- 作者: 布鸽不鸽
- 创建于 : 2024-04-10 20:34:10
- 更新于 : 2024-01-10 14:58:18
- 链接: https://xuedongyun.cn//post/4016/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。