深入学习掌握JUC并发编程系列(三) -- 深入浅出管程-悲观锁
一、共享带来的问题
- Java中对静态变量的自增自减不是原子操作
- i++对应的字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
- 多线程访问共享资源:多个线程对共享资源读写操作时发生指令交错
- 临界区(Critical Section):存在对共享资源的多线程读写操作的代码块
- 竞态条件(Race Condition):多个线程在临界区内执行,代码的执行序列不同导致结果无法预测的情况
- 问题:在多线程访问临界区时发生了竞态条件
二、synchronized 解决问题
- 避免临界区发生竞态条件的解决方案(互斥):
- 阻塞式:synchronized、Lock
- 非阻塞式:原子变量
- synchronized(对象锁):
- 实际:用对象锁保证了临界区内代码的原子性(采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其它线程想要获取这个对象锁,就会阻塞住,保证有锁的线程在上下文切换的情况下,安全执行临界区的代码)
- 语法:synchronized(对象){ 临界区 } // 线程1(拥有锁)、线程2(blocked)
- synchronized保证互斥和同步:
- 互斥:保证临界区内,同一时刻只有一个线程执行代码,避免发生竞态条件
- 同步:由于线程执行的先后、顺序不同,需要一个线程等待其它线程(join方法)
三、加在方法上的synchronized
- 加在成员方法上(非静态方法):相当于锁住this对象
class Test{
public synchronized void test() {
}
}
// 等价于
class Test{
public void test() {
synchronized(this) {
}
}
}
- 加在静态方法上(static):相当于锁住类对象(class对象)
class Test{
public synchronized static void test() {
}
}
//等价于
class Test{
public static void test() {
synchronized(Test.class) {
}
}
}
- 线程八锁
@Slf4j(topic = "c.Number")
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
a()是静态方法锁住的是类对象
b()是成员方法锁住的是this对象
锁住的是不同对象,所以先“2”1s后“1”
@Slf4j(topic = "c.Number")
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public static synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
a()和b()都是静态方法,锁住的都是类对象
所以,先“2”1s后“1”,或者1s后“1”再“2”
四、变量的线程安全分析
- 成员变量和静态变量:
- 没有共享:线程安全
- 共享:只有读操作(线程安全);读写操作(临界区,需要考虑线程安全)
- 局部变量:在每个线程的栈帧中被创建多份,不存在共享
- 局部变量是线程安全的
- 引用对象:对象没有逃离方法作用范围(线程安全);对象逃离了方法作用范围(需要考虑线程安全)
- 使用private修饰符和final关键字,可以加强线程安全性
- 常见线程安全类:
- String、Integer(包装类),StringBuffer、Random、Vector(list)、Hashtable(map)、java.util.concurrent包下的类
- 每个方法是原子的(线程安全);多个方法的组合不是原子的(需要考虑线程安全)
五、Monitor(管程)
1. Java对象头
- 32位虚拟机中普通对象的对象头(数组对象,对象头多一个32bits的array length)
- Klass Word:类型指针(指向对象所属类)
- Mark Word:
- Normal(01 正常状态):hashcode(哈希码)、age(GC分代年龄)、biased_lock(偏向锁 0)
- Biased(01 偏向状态):thread(偏向线程)、epoch(批量重偏向阈值、age、biased_lock(偏向锁 1)
- Lightweight Locked(00 轻量级锁):ptr_to_lock_record(指向锁记录的指针)
- Heavyweight Locked(10 重量级锁):ptr_to_heavyweight_monitor(指向monitor的指针)
- 001:不可偏向(Normal)、101:可偏向(对象创建默认Biased)
2. Monitor(管程/监视器)
- 使用 synchronized 给对象上锁(重量级)后,对象头Mark Word 中就会设置指向 Monitor 对象的指针
- Monitor结构:
- 开始:Owner为null
- Thread-2拥有了synchronized(obj)锁,Owner指向Thread-2(Monitor中只能有一个Owner)
- Thread-3/4/5执行了synchronized(obj),会进入EntryList阻塞(blocked状态)
- Thread-0/1之前获得过synchronized(obj)锁,条件不满足(wait/notify)进入WaitSet(waiting状态)
- Thread-2执行完临界区解锁后,唤醒EntryList中所有阻塞线程,来竞争锁(非公平)
- 原理:字节码
- monitorenter/monitorexit(获取锁/释放锁)
- 两个monitorexit保证线程异常退出的情况下,能够释放锁
3. 轻量级锁
- 使用场景:多个线程访问某个对象的时间是错开的(没有竞争)
- 两个方法同步块,对同一个对象(obj)加锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
- 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
- 让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录
- cas替换成功,对象头中存储了锁记录地址和状态 00 (轻量级锁),表示线程给对象加锁(01->00)
- cas替换失败:
- 其它线程已经持有了 Object 的轻量级锁,表明有竞争,进入锁膨胀过程
- 线程自己执行了 synchronized 表示锁重入,再添加一条 Lock Record (null)作为锁重入的计数
- 解锁时(退出synchronized代码块):
- 锁记录取值为 null ,表示有重入,这时重置锁记录,表示重入计数减一
- 锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头
- 成功:解锁
- 不成功:轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
4. 锁膨胀
- 在尝试加轻量级锁的过程中,CAS 操作失败,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
- 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁(有竞争)
- Thread-1 加轻量级锁失败,进入锁膨胀流程:
- 为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址(00->10)
- Owner为Thread-0,Thread-1进入 Monitor 的 EntryList(blocked)
- Thread-0 退出同步块解锁:
- 使用 cas 将 Mark Word 的值恢复给对象头(失败),进入重量级解锁流程
- 按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 blocked(Thread-1)线程
- Thread-1 加轻量级锁失败,进入锁膨胀流程:
5. 自旋优化
- 使用场景:重量级锁竞争时
- 自旋成功:持锁线程退出了同步代码块,释放了锁(当前线程可以避免进入EntryList阻塞)
- 自旋失败:自旋重试多次后,持锁线程仍然未解锁(当前线程进入EntryList阻塞)
- 自旋会占用 CPU 时间(适合多核CPU)
- Java6后自旋锁是自适应的(对象自旋成功后,下次自旋会重试多次,反之,重试少次)
- Java7后不能控制是否开启自旋功能
6. 偏向锁
- 问题:轻量级锁在没有竞争时,每次重入仍然需要执行 CAS 操作(线程自己内部多次使用锁)
- 优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word ,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS,以后只要不发生竞争,这个对象就归该线程所有
- 偏向状态:
- 偏向锁默认开启,所以对象创建后 markword 值为 0x05 (最后 3 位为 101),thread、epoch、age 都为 0
- 偏向锁默认是延迟的,不会在程序启动时立即生效( 禁用延迟:加 JVM 参数 -XX:BiasedLockingStartupDelay=0)
- 偏向锁关闭,对象创建时,markword 值为 0x01 (最后 3 位为 001), hashcode、age 都为 0,第一次用到 hashcode 时才会赋值
- 偏向锁被撤销:
- hash Code:
- 正常状态(001)对象一开始的 hashCode 为 0,只有第一次调用才生成,在调用了 hashCode()方法后会禁用该对象的偏向锁
- 撤销原因:偏向锁要在markword中存储偏向线程id,没空间存储hashcode
- 轻量级锁和重量级锁会在锁记录和monitor中存储hashcode
- 其它线程使用偏向锁对象:
- 错开使用(没有竞争):偏向锁升级为轻量级锁
- 未错开(竞争):偏向锁升级为重量级锁
- 调用wait/notify:偏向锁升级为重量级锁
- hash Code:
- 批量重偏向:
- 条件:对象被多个线程错开访问,没有竞争
- 例子:偏向了线程 T1 的对象,在T2线程错开访问该对象时,会撤销偏向锁(升级为轻量级锁),当同一类型的对象们撤销偏向锁次数超过 20 次(阈值)后,之后对象(同一类型)的偏向锁会重新偏向 T2,重偏向会重置这些对象的 Thread ID
- 前19个:偏向锁升级为轻量级锁;第20个开始:重新偏向新加锁的线程
- 批量撤销:当撤销偏向锁阈值超过 40 次后,整个类的所有对象都会变为不可偏向的,新建的该类型对象也是不可偏向(001 normal状态),加锁后都是轻量级锁
7. 锁消除
- JIT即时编译器会对字节码进行优化,若加的锁没有用到,JIT会将锁消除
- 优化默认打开:设置JVM参数可关闭优化(-XX:-EliminateLocks)
六、wait/notify
- 原理:
- Owner 线程(拥有锁)发现条件不满足,调用 wait 方法,进入 WaitSet(WAITING 状态)
- BLOCKED 和 WAITING 线程:
- 相同:都处于阻塞状态,不占用 CPU 时间片
- 不同:BLOCKED 线程会在 Owner 线程释放锁时被唤醒 ,WAITING 线程会在 Owner 线程调用 notify/notifyAll 时被唤醒,且唤醒后不会立刻获得锁,需进入EntryList 重新竞争锁
- API:属于对象的方法,必须先获得该对象的锁,才能调用方法
- obj.wait():让进入 object monitor(获得锁)的线程到 waitSet 等待
- wait(long timeout):带参数等待,等待一段时间后继续运行
- obj.notify():随机挑一个线程唤醒
- obj.notifyAll() :唤醒全部线程
- wait()和sleep()方法:
- 区别:
- wait是对象(object)的方法,sleep是线程(thread)的方法
- wait必须和synchronized配合使用(必须先获得锁),sleep可以不和synchronized配合使用
- wait在等待时会释放锁(放弃),sleep在睡眠时不会释放锁
- 相同:线程状态都是TIMED_WAITING(阻塞状态)
- 区别:
- 存在问题:
- 虚假唤醒:notify 只能随机唤醒一个 WaitSet 中的线程,如果有多个线程在等待,可能唤醒不了正确的线程
- 解决虚假唤醒问题:使用notifyAll(),并且使用while+wait替换if+wait(只能判断一次)的条件判断(让线程不满足条件时可以继续wait)
synchronized(lock) {
while(条件不成立) {
lock.wait();
}
// 干活
}
//另一个线程
synchronized(lock) {
lock.notifyAll();
}
七、Park/Unpark
// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark("暂停线程对象")
- 以线程为单位,阻塞和唤醒线程
- LockSupport类中的方法,park后线程进入(WAITING状态)
- 可以先unpark(),再park()
- 原理:每个线程都有自己的(C代码实现) Parker 对象,由三部分组成:
- _counter:0(阻塞),1(不阻塞),一开始默认为0
- _cond:条件变量(线程等待队列)
- _mutex:互斥锁
- park()会先检查_counter,并将_coun ter设为0,unpark()直接将_counter设为1
- 先park再unpak:
- 当前线程调用 Unsafe.park() 方法 :
- 检查 _counter(0),获得 _mutex 互斥锁
- 线程进入 _cond 条件变量(阻塞)
- 设置 _counter = 0
- 其它线程调用Unsafe.unpark(Thread_0) 方法:
- 设置 _counter = 1
- 唤醒 _cond 条件变量中的 Thread_0 ,线程恢复运行
- 设置 _counter 为 0
- 当前线程调用 Unsafe.park() 方法 :
- 先unpark再park:
- 当前线程调用 Unsafe.unpark(Thread_0) 方法:
- 设置 _counter 为 1
- 当前线程调用 Unsafe.park() 方法 :
- 检查 _counter(1),线程无需阻塞,继续运行
- 设置 _counter 为 0
- 当前线程调用 Unsafe.unpark(Thread_0) 方法:
八、线程状态转换
- 情况一(t线程 NEW–>RUNNABLE):调用t.start()方法
- RUNNABLE<–>WAITING:
- 情况二(t线程):wait/notify
- RUNNABLE --> WAITING :t 线程用 synchronized(obj) 获取了对象锁后,调用了 obj.wait()方法
- WAITING --> RUNNABLE:其它线程调用了 obj.notify() 、obj.notifyAll()、t.interrupt()后,t线程竞争锁成功
- WAITING --> BLOCKED:t线程竞争锁失败
- 情况三(当前线程):t.join()
- RUNNABLE --> WAITING:当前线程调用t.join()方法,(当前线程在 t 线程对象的监视器上等待)
- WAITING --> RUNNABLE:等待 t 线程运行结束,或调用了当前线程的 interrupt() 方法时
- 情况四(当前线程):park/unpark
- RUNNABLE --> WAITING:当前线程调用了LockSupport.park() 方法
- WAITING -->RUNNABLE:其它线程调用了LockSupport.unpark(“当前线程”) 或调用了当前线程的interrupt()
- 情况二(t线程):wait/notify
- RUNNABLE<–>TIMED_WAITING:
- 情况五(t线程):带等待时间参数的 wait(long timeout)
- 情况六(当前线程):带等待时间参数的 t.join(long timeout)
- 情况七(当前线程):带等待时间参数parkNanos(long nanos)、parkUntil(long millis)
- 情况八(当前线程):当前线程调用了Thread.sleep(long n )方法
- 情况九(t线程 RUNNABLE <–> BLOCKED):t线程用 synchronized(obj) 获取了对象锁后,竞争失败,进入monitor的EntryList阻塞
- 情况十(RUNNABLE <–>TERMINATED):线程执行完毕
- 阻塞状态总结:
- wait/join/park(WAITING)
- 带等待时间参数的 wait/join/park/sleep(TIMED_WAIRTING)
- synchronized(BLOCKED)
九、多把锁和活跃性
1. 多把锁
将锁的粒度细分:好处(增强并发度)、坏处(容易发生死锁)
2. 活跃性:线程没有按预期结束,执行不下去的情况
- 死锁(无法继续执行):
- 定义:t1线程获得了A对象锁,想获取B对像的锁;t2线程获得了B对象锁,想获取A对象的锁(两个线程各自持有一把锁,却想获取对方的锁)
- 定位死锁:检测死锁工具(基于命令行的jps+jstack、图形化工具jconsole)
- 活锁(无法结束):
- 定义:两个线程互相改变对方的结束条件,最后谁也无法结束
- 解决:让指令交错,或者增加随机睡眠时间
- 饥饿:
- 定义:线程由于优先级太低,得不到CPU调度执行
- 例子:解决死锁问题时,采用顺序加锁的方式,但若线程1一直不释放锁,则线程2会出现饥饿问题
- 哲学家吃饭问题:
- 死锁:可能出现每个哲学家都拿着一根筷子,等着其他人的筷子,导致都吃不了饭的问题
- 饥饿:可能出现筷子集中在某几个哲学家手中,有个哲学家得不到机会吃饭
- 解决:锁超时(reentrantLock的trylock()方法),筷子对象继承reentrantLock,获得锁属性,当获得锁失败时,会释放自己手中的锁(另一根筷子)
十、ReentrantLock(可重入锁)
- 特点:相对于synchronized
- 特有:可中断、可以设置超时时间、支持公平锁、支持多个条件变量
- 共同点:可重入
- 语法:(juc下的工具)
- 需要先创建reentrantLock对象
- finally内必须释放锁
- lock和unlock成对出现
//创建reentrantLock对象
ReentrantLock reentrantLock = new ReentrantLock();
// 获取锁
reentrantLock.lock();
try {
// 临界区
} finally {
// 释放锁
reentrantLock.unlock();
}
- 可重入:同一个线程如果首次获得了这把锁,那么它有权利再次获取这把锁
- 可打断:防止竞争锁时,线程在阻塞队列一直死等的情况(一定程度上避免死锁)
- 定义:其它线程可以使用interrupt方法打断,竞争锁失败的进入阻塞队列的线程
- 条件:使用reentrantLock对象的lockInterruptibly()方法加锁
- 结果:打断后,报InterruptException异常
- 锁超时:可打断是被动的避免死等(其他线程打断),锁超时是通过设置一个超时时间主动的避免死等
- 定义:尝试获得锁(可以设置一段时间),若获得锁失败,则放弃等待锁(避免无限期阻塞等待)
- 条件:使用reentrantLock对象的trylock()方法加锁(尝试获得锁),返回bool值
- 两种模式(trylock()方法):
- 不带参数:立刻判断能否获得锁,得不到则立刻放弃等待锁
- 带参数(long, timeunit):等待一段时间看能否获得锁
- 可以解决哲学家就餐问题
- 公平锁:阻塞线程在阻塞队列中等待锁时,按先后顺序获得锁(先入先得)
- ReentrantLock默认是不公平锁
- 设置公平锁:ReentrantLock lock = new ReentrantLock(true) ,构造方法中传入true参数
- 一般不会设置为公平锁,会降低并发度
- 多个条件变量:
- synchronized的条件变量:wait/notify,所有不满足条件的线程,都进入waitSet等待
- ReentrantLock的条件变量:await/signal,支持多个条件变量,按不同条件唤醒等待线程
- 语法:
- 创建条件变量对象:Condition condition1 = lock.newCondition();(可以创建多个)
- 必须先获得锁对象:lock.lock();
- 调用await()方法,进入conditionObject等待:condition1.await();(可以设置等待时间)
- 调用signal()/signalAll()方法,唤醒线程:condition1.signal();
- 注意事项:
- await前必须先获得锁
- 等待的线程被唤醒(被打断、超过等待时间),会重新竞争锁
- 竞争锁成功后,代码从await后继续执行
总结
- 多线程访问共享资源时,分析哪些代码属于临界区(存在读写)
- 解决临界区线程安全问题:
- synchronized(JVM层面):
- 锁对象语法
- 加在成员方法(this对象)和静态方法(class类对象)上
- wait/notify解决同步问题(条件不满足时,等待)
- Reentrantlock(Java层面):可打断、锁超时、公平锁、多个条件变量
- synchronized(JVM层面):
- 分析变量的线程安全性、常见线程安全类(方法内部是原子的,方法的组合不是)
- 线程活跃性问题:死锁、活锁、饥饿
- 应用:
- 互斥:使用 synchronized 或 reentrantlock达到共享资源互斥效果(保证临界区原子性)
- 同步:使用wait/notify或 reentrantlock的多个条件变量达到线程间通讯效果
- 原理:
- monitor、wait/notify
- 锁膨胀、锁消除
- park/unpark