深入学习掌握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)线程

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:偏向锁升级为重量级锁
  • 批量重偏向:
    • 条件:对象被多个线程错开访问,没有竞争
    • 例子:偏向了线程 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

  • 先unpark再park:
    • 当前线程调用 Unsafe.unpark(Thread_0) 方法:
      • 设置 _counter 为 1
    • 当前线程调用 Unsafe.park() 方法 :
      • 检查 _counter(1),线程无需阻塞,继续运行
      • 设置 _counter 为 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()
  • 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 或 reentrantlock达到共享资源互斥效果(保证临界区原子性)
    • 同步:使用wait/notify或 reentrantlock的多个条件变量达到线程间通讯效果
  • 原理:
    • monitor、wait/notify
    • 锁膨胀、锁消除
    • park/unpark
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值