JUC从入门到成神(基础篇)- 有关“锁”

👏作者简介:大家好,我是笙一X,java大二练习生,喜欢算法和Java相关知识。

📕正进行的系列:算法 、JUC从入门到成神

目录

前言

理解Monitor

Java对象头

Monitor(锁)

synchronized 原理进阶

轻量级锁

锁膨胀

自旋优化

偏向锁

偏向状态

撤销-调用对象 hashCode

撤销-其它线程使用对象

撤销-调用 wait / notify

批量重偏向

批量撤销

锁消除

wait/notify

原理

API

wait notify 的正确姿势

Park & Unpark

基本使用

特点

原理

多把锁

多把不相干的锁

活跃性

死锁

定位死锁

活锁

饥饿

ReentrantLock

可重入

可打断

锁超时

公平锁

条件变量

总结


前言

本文知识较为基础,很多来源于网络,经作者总结归纳,只做学习用途!!!

理解Monitor

        我们在学习了 synchronized 之后,有没有考虑过,synchronized 底层是如何控制线程安全的?

        在Java中,Monitor通常通过关键字 synchronized 实现,它可以用来修饰方法或代码块。当一个线程进入一个被 synchronized 修饰的方法或代码块时,它会尝试获取对象的锁。如果这个锁已经被其他线程获取了,那么当前线程就会被阻塞,直到获得锁的线程释放锁为止。这样就确保了同一时间只有一个线程可以执行被 synchronized 修饰的代码,从而避免了竞态条件的发生。

        我们在详细讲解 Monitor 前,先来介绍一个概念:

Java对象头

在Java虚拟机中,每个对象都有一个对象头(Object Header),它是对象在内存中的一部分,用于存储对象的元数据信息。对象头包含了一些重要的信息,例如对象的哈希码、锁状态、垃圾回收信息等。

Java对象头的具体结构在不同的Java虚拟机实现中可能有所不同,但通常包括以下几个部分:

  1. 标记字(Mark Word):标记字是对象头的主要部分,用于存储对象的元数据信息,如锁状态、垃圾回收信息等。其中包括了锁信息(存储对象是否被锁定以及锁的类型)、GC相关信息(如对象的分代信息、是否被标记为可回收等)等。标记字的具体结构可以根据具体的Java虚拟机实现而变化。

  2. 类型指针(Class Metadata Address / Klass Word):指向对象所属类的指针,用于确定对象的类型信息。这个指针指向对象的类元数据,包括类的方法、字段、父类等信息。

对象头的大小和内容会受到Java虚拟机的具体实现和配置的影响,通常在不同的虚拟机版本和不同的JVM参数下可能会有所不同。对象头的存在使得Java虚拟机能够对对象进行有效管理和操作,如垃圾回收、同步等。

以32位虚拟机为例

普通对象

|------------------------------------------------------|
|               Object Header (64 bits)                |
|------------------------------|-----------------------|
|     Mark Word(32 bits)       |   Klass Word(32 bits) |
|------------------------------|-----------------------|

数组对象

|--------------------------------------------------------------------------------|
|                           Object Header (96 bits)                              |
|------------------------------|-------------------------|-----------------------|
|     Mark Word(32 bits)       |   Klass Word(32 bits)   | array length(32 bits) |
|------------------------------|-------------------------|-----------------------|

其中 Mark Word 结构为:

注:比较陌生的词(只考虑 Normal 情况下):

                        age - 分代年龄(GC,垃圾回收中使用,年龄超过一定次数会从幸存区到老年代“JVM知识”)

                        biased_lock - 偏向锁(后面再讲)

                        01 / 00 / 10 / 11 - 加锁状态(

  • 01:表示对象处于可偏向状态(Biased Locking),此时对象头中存储的是偏向线程的 ID。这表示这个对象的锁被某个线程偏向于此线程,因此没有与 Monitor 关联。

  • 00:表示对象处于轻量级锁状态(Lightweight Locking),此时对象头中存储的是指向锁记录(Lock Record)的指针。这表示与该对象关联了一个轻量级锁。

  • 10:表示对象处于重量级锁状态(Heavyweight Locking),此时对象头中存储的是指向互斥量(Mutex)的指针。这表示与该对象关联了一个重量级锁。

  • 11:表示对象处于 GC 标记状态(GC Marking),此时对象头中存储的是用于标记对象是否被 GC 标记的相关信息。

|---------------------------------------------------------|-------------------------------|
|                    Mark Word(32 bits)                   |             State             |
|---------------------------------------------------------|-------------------------------|
|  hashcode:25            | age:4 | biased_lock:0 | 01    |            Normal             |
|---------------------------------------------------------|-------------------------------|
|   thread:23   | epoch:2 | age:4 | biased_lock:1 | 01    |             Biased            |
|---------------------------------------------------------|-------------------------------|
|              ptr_to_lock_record:30              | 00    |        Lightweight Locked     |
|---------------------------------------------------------|-------------------------------|
|              ptr_to_heavyweight_monitor:30      | 10    |        Heavyweight Locked     |
|---------------------------------------------------------|-------------------------------|
|                                                 | 11    |          Marked for GC        |
|---------------------------------------------------------|-------------------------------|

例如,对于 Integer 对象来说,原本 int 类型占 4 个字节, 而装箱为 Integer 后,多了 对象头 ,所以再多加 8 个字节(32位虚拟机),一共12个字节。

Monitor(锁)

Monitor 被翻译为监视器管程

注:(很多文章和视频中说Monit由操作系统提供,这并不完全正确。在软件工程中,特别是在并发编程中,"monitor" 是一种同步机制,用于控制对共享资源的访问。在这种情况下,Monitor 是一种高级抽象,通常由编程语言或库提供。操作系统提供的是基本的同步原语,如互斥锁和信号量,而不是直接提供 Monitor。倒不如说是JVM创建的Monitor,JVM(Java虚拟机)在执行Java程序时会使用Monitor。在Java中,Monitor是一种同步机制,用于实现线程之间的互斥访问共享资源。当你在Java代码中使用synchronized关键字或者使用ReentrantLock等锁机制时,JVM会创建Monitor来确保线程安全。)

每个Java对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该 对象头的Mark Word中就被设置指向 Monitor 对象的指针

Monitor 结构如下

Owner:当前锁的所有者

EntryList:等待队列 / 阻塞队列

  • 刚开始 Monitor 中 Owner 为 null

  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个Owner

  • 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入EntryList BLOCKED

  • Thread-2执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时候是非公平的(根据JDK底层实现来看)

  • 图中 WaitSet 中的 Thread-0,Thread-1是之前获得过锁,但条件不满足进入WAITING 状态的线程,后面讲 wait-notify 时会分析

注意:

  • synchronized 必须是进入同一个对象的 moniter 才有上述的效果

  • 不加 synchronized 的对象不会关联监视器,不遵从以上规则

synchronized 原理进阶

轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以用轻量级锁来优化。

轻量级锁对使用者是透明的,即语法仍然是 synchronized

假设有两个方法同步块,利用同一个对象加锁

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,表示由该线程给对象加锁,这时图示如下

  • 如果 cas 失败,有两种情况

    • 如果是其它线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀过程

    • 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数

  • 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一

  • 当退出 synchronized 代码块(解锁时),锁记录的值不为null,这时使用 cas 将 Mark Word 的值恢复给对象头

    • 成功,则解锁成功

    • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

锁膨胀

如果在尝试轻量级加锁的过程中,CAS操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

static Object obj = new Object();
public static void method1(){
    synchronized(obj){
        //同步块
    }
}
  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

  • 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程

    • 即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址

    • 然后自己进入 Monitor 的 EntryList BLOCKED

  • 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置Owner为null,唤醒EntryList中的 BLOCKED 线程

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

自选重试成功的情况

线程1(cpu 1上)                         对象Mark                                            线程2(cpu 2上)


—                                                 10(重量锁)                                                 —

访问同步块,获取 monitor           10(重量锁)重量锁指针                               —


成功(加锁)                               10(重量锁)重量锁指针                               —


执行同步块                                   10(重量锁)重量锁指针                               —

执行同步块                                   10(重量锁)重量锁指针                 访问同步块,获取 monitor


执行完毕                                       10(重量锁)重量锁指针                  自旋重试

成功(解锁)                                01(无锁)                                        自旋重试


—                                                 10(重量锁)重量锁指针                    成功(加锁)


—                                                 10(重量锁)重量锁指针                    执行同步块

—                                                         ...                                                         ...

自选重试失败的情况

线程1(cpu 1上)                                 对象Mark                                     线程2(cpu 2上)


—                                                         10(重量锁)                                         —

访问同步块,获取 monitor                   10(重量锁)重量锁指针                       —


成功(加锁)                                       10(重量锁)重量锁指针                       —


执行同步块                                         10(重量锁)重量锁指针                         —

执行同步块                                         10(重量锁)重量锁指针             访问同步块,获取 monitor


执行同步块                                         10(重量锁)重量锁指针                   自旋重试

执行同步块                                         10(重量锁)重量锁指针                   自旋重试


执行同步块                                         10(重量锁)重量锁指针                   自旋重试


执行同步块                                         10(重量锁)重量锁指针                    阻塞

...                                                                ...                                                  ...

  • 在 Java 6之后的自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。

  • Java 7 之后不能控制是否开启自旋功能

偏向锁

|---------------------------------------------------------|-------------------------------|
|                    Mark Word(32 bits)                   |             State             |
|---------------------------------------------------------|-------------------------------|
|  hashcode:25            | age:4 | biased_lock:0 | 01    |            Normal             |
|---------------------------------------------------------|-------------------------------|
|   thread:23   | epoch:2 | age:4 | biased_lock:1 | 01    |             Biased            |
|---------------------------------------------------------|-------------------------------|
|              ptr_to_lock_record:30              | 00    |        Lightweight Locked     |
|---------------------------------------------------------|-------------------------------|
|              ptr_to_heavyweight_monitor:30      | 10    |        Heavyweight Locked     |
|---------------------------------------------------------|-------------------------------|
|                                                 | 11    |          Marked for GC        |
|---------------------------------------------------------|-------------------------------|

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。

Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word头,之后发现这个线程 ID(图中的thread) 是自己的就表示没有竞争,不用重新 CAS 。以后只要不发生竞争,这个对象就归该线程所有

例如:

static final Object obj = new Object();
public static void m1(){
    synchronized(obj){
        //同步块 A
        m2();
    }
}
public static void m2(){
    synchronized(obj){
        //同步块 B
        m3();
    }
}
public static void m3(){
    synchronized(obj){
        //同步块 C
    }
}

偏向状态

回忆一下对象头格式

|----------------------------------------------------------------|-----------------------|
|               	 Mark Word(64 bits)				             |     State			 |
|----------------------------------------------------------------|-----------------------|
|  unused:25| hashcode:31| unused:1 | age:4 | biased_lock:0 | 01 |      Normal			 |
|----------------------------------------------------------------|-----------------------|
|  thread:54| epoch:2    | unused:1 | age:4 | biased_lock:1 | 01 |     Biased            |
|----------------------------------------------------------------|-----------------------|
|                ptr_to_lock_record:62                   | 00    |  Lightweight Locked   |
|----------------------------------------------------------------|-----------------------|
|                ptr_to_heavyweight_monitor:62           | 10    |  Heavyweight Locked   |
|----------------------------------------------------------------|-----------------------|
|                                                        | 11    |   Marked for GC       |
|----------------------------------------------------------------|-----------------------|

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为101,这时它的 thread、epoch、age 都为 0

  • 偏向锁默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 - XX:BiasedLockingStartupDelay=0 来禁用延迟

  • 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后3位为 001,这时它的 hashcode、age 都为0,第一次用到 hashcode 时才会赋值

注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中。这就是偏向锁的优势,等到下次重入发现Thread相同,就不做cas尝试

测试禁用:

在测试代码运行时再添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁

撤销-调用对象 hashCode

通过测试:

对象.hashCode();

调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,此时hashcode存放后,会无处存放线程id(空间不足),所以如果调用 hashCode 会导致偏向锁被撤销

  • 轻量级锁会在锁记录中记录 hashCode

  • 重量级锁会在 Monitor 中记录 hashCode

在调用 hashCode 后使用偏向锁,记得去掉 -XX:-UseBiasedLocking

我们可以思考一下为什么轻量级锁和重量级锁调用hashCode不会有这样的问题?

因为轻量级锁的 hashcode 存在于线程栈帧的锁记录里,重量级锁的 hashcode 存在 monitor 里。

撤销-其它线程使用对象

当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁

private static void test2() throws InterruptedException{
    
    Dog d = new dog();
    Thread t1 = new Thread(() -> {
        synchronized(d){
            log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
        }
        synchronized(TestBiased.class){
            TestBiased.class.notify();
        }
        // 如果不用 wait/notify 使用 join 必须打开下面的注释
        // 因为:t1 线程不能结束,否则底层线程可能被 jvm 重用作为 t2 线程,底层线程 id 是一样的
        /*try{
        	System.in.read();
        }catch(IOException e){
        
        }*/
    }, "t1");
    t1.start();
}
撤销-调用 wait / notify

这个我们先不予理会,讲到此方法再做认识

批量重偏向

如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID

当撤销偏向锁阈值超过20次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程

private static void test3() throws InterruptedException{
    
    Vector<Dog> list = new Vector<>();
    Thread t1 = new Thread(() -> {
        for(int i = 0; i < 30; i++){
            Dog d = new Dog();
            list.add(d);
            synchronized(d){
                log.debug(i + "t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
            }
        }
        synchronized(list){
            list.notify();
        }
    }, "t1");
    t1.start();
    
    
    Thread t2 = new Thread(() -> {
        synchronized(list){
            try{
                list.wait();
            }catch(InterruptedException e){
                e.printStackTrace();
            }
        }
        log.debug("=============>");
        for(int i = 0; i < 30; i++){
            Dog d = list.get(i);
            log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
            synchronized(d){
                log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
            }
            log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
        }
    }, "t2");
    t2.start();
}
批量撤销

当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的

有无锁的性能差异

比较下面两个方法的性能相差

@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iteration=3)
@Measurement(iteration=5)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class MyBenchmark{
    static int x = 0;
    @Benchmark
    public void a() throws Exception{
        x++;
    }
    @Benchmark
    public void b() throws Exception{
        Object o = new Object();
        synchronized(o){
            x++;
        }
    }
}

因为 JIT 即时编译器的存在,Object o 因为离不开该方法范围,所以syn操作会被优化掉

先打包再运行

java -jar benchmarks.jar

Benchmark			Mode      Samples     Score    Score error    Units
c.i.MyBenchmark.a    avgt      	     5     1.542          0.056    ns/op
c.i.MyBenchmark.b    avgt      	     5     1.518          0.091    ns/op

可以看到几乎没有区别,这就是JIT所导致的,syn操作被优化。所以关个开关:不使用锁消除

java -XX:-EliminateLocks -jar benchmarks.jar

Benchmark			Mode      Samples     Score    Score error    Units
c.i.MyBenchmark.a    avgt      	     5     1.542          0.056    ns/op
c.i.MyBenchmark.b    avgt      	     5    16.976          1.572    ns/op

可以看到差了十几倍,所以知道加了syn性能会差很多。

wait/notify

原理

  • Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态

  • BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用CPU时间片

  • BLOCKED 线程会在 Owner 线程释放锁时唤醒

  • WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味着立刻获得锁,仍需进入 EntryList重新竞争

API

  • obj.wait() 让进入 object 监视器的线程到waitSet 等待(可带long参数 timeout,表示多少毫秒之后,停止等待,重新进入EntrySet)。其实wait()内部实现的是一个wait(0),表示无期限等待。              还有一个有趣的地方,wait()方法里还可填入除 timeout 参数外另一个int参数 nanos,表示纳秒,但他其实不做纳秒计时,只要其大于0且在一定范围内,作timeout++ 操作。

  • obj.notify() 在 object 上正在 waitSet 等待的线程中挑一个唤醒(随机)

  • obj.notifyAll() 让 object上正在 waitSet 等待的线程全部唤醒

它们都是线程之间进行协作的手段,都属于 Object 对象的方法。必须获得此对象的锁,才能调用这几个方法

代码展示:

如果不先获取到锁

public class Test3 {
    private final static Object OBJECT = new Object();

    public static void main(String[] args) {
        try {
            OBJECT.wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

报错

Exception in thread "main" java.lang.IllegalMonitorStateException
    at java.lang.Object.wait(Native Method)
    at java.lang.Object.wait(Object.java:502)
    at com.sheng1.jucBase.Test3.main(Test3.java:8)

需要获得当前锁

public class Test3 {
    private final static Object OBJECT = new Object();

    public static void main(String[] args) {
        synchronized (OBJECT) {
            try {
                OBJECT.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

sleep(long n)和 wait(long n)的区别

1)sleep 是Thread 静态方法,而 wait 是 Object 的方法

2)sleep 不需要强制和 synchronized 配合使用,而 wait 需要和 synchronized 一起使用

3)sleep 在睡眠的同时,不会释放对象锁的,但 wait 在等待的时候会释放对象锁。

4)sleep(long)和wait(long)进入TIMED_WAITING,但wait()会进入WAITING状态

synchronized(lock){
    while(条件不成立){
        lock.wait();
    }
    //干活
}

//另一个线程
synchronized(lock){
    lock.notifyAll();
}

Park & Unpark

基本使用

它们是 LockSupport 类中的方法

LockSupprot 用来阻塞和唤醒线程,底层实现依赖于 Unsafe 类

//暂停当前线程
LockSupport.park();

//恢复某个线程的运行
LockSupport.unpark(暂停线程对象);

先 park 再 unpark

Thread t1 = new Thread(() -> {
    log.debug("start...");
    sleep(1);
    log.debug("park...");
    LockSupport.park();
    log.debug("resume...");
}, "t1");
t1.start();

sleep(2);
log.debug("unpark...");
LockSupport.unpark(t1);

与wait/notify很大的区别是unpark可以在park后调用,也可以在park前调用,具体就是改变两个方法运行顺序,resume的日志也能记录

Thread t1 = new Thread(() -> {
    log.debug("start...");
    sleep(2);
    log.debug("park...");
    LockSupport.park();
    log.debug("resume...");
}, "t1");
t1.start();

sleep(1);
log.debug("unpark...");
LockSupport.unpark(t1);

特点

与 Object 的 wait&notify相比

  • wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 unpark 不必

  • park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程,就不那么【精确】

  • park & unpark 可以先 unpark,而wait & notify 不能先 notify

原理

首先要知道,每个线程都有自己的一个Parker对象,内部封装了_counter,_cond,_mutex。

1.当前线程调用 Unsafe.park() 方法

2.检查 _counter,本情况为0,这时,获得 _mutex 互斥锁

3.线程进入 _cond 条件变量阻塞

4.设置 _counter = 0

1.调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1

2.唤醒 _cond 条件变量中的 Thread_0

3.Thread_0 恢复运行

4.设置 _counter 为 0

1.调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1

2.当前线程调用 Unsafe.park() 方法

3.检查 _counter,本情况为1,这时线程无需阻塞,继续运行

4.设置 _counter 为 0

多把锁

多把不相干的锁

如果业务间没有关联,可以做粒度锁的细分(分成多把不相干的锁)

将锁的粒度细分

  • 好处,是可以增强并发度

  • 坏处,如果一个线程需要同时获得多把锁,就容易发生死锁

活跃性

死锁

有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁

t1 线程获得 A对象 锁,接下来想获取 B对象 锁

t2 线程获得 B对象 锁,接下来想获取 A对象 锁

例如:

Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
    synchronized(A){
        log.debug("lock A");
        sleep(1);
        sychronized(B){
            log.debug("lock B");
            log.debug("操作...");
        }
    }
}, "t1");

Thread t2 = new Thread(() -> {
    synchronized(B){
        log.debug("lock B");
        sleep(0.5);
        sychronized(A){
            log.debug("lock A");
            log.debug("操作...");
        }
    }
},"t2");
t1.start();
t2.start();

定位死锁

  • 检测死锁可以使用 jconsole 工具,或者使用 jps 定位进程 id,再用 jstack 定位死锁:

活锁

活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如

public class TestLiveLock
    static volatile int count = 10;
	static final Object lock = new Object();
	
	public static void main(String[] args){
        new Thread(() -> {
            //期望减到 0 退出循环
            while(count > 0){
                sleep(0.2);
                count--;
                log.debug("count:{}", count);
            }
        }, "t1").start();
        new Thread(() -> {
            //期望超过 20 就退出循环
            while(count < 20){
                sleep(0.2);
                count++;
                log.debug("count:{}", count);
            }
        }, "t2").start(); 
    }

饥饿

很多教程中把饥饿定义为,一个线程由于优先级太低,始终得不到CPU调度执行,也不能够结束,饥饿的情况不易演示,讲读写锁时会涉及饥饿问题

这是一个线程饥饿的例子,先来看看使用顺序加锁的方式解决之前的死锁问题

顺序加锁的解决方案

ReentrantLock

相对于 synchronized 它具备如下特点

  • 可中断

  • 可以设置超时时间

  • 可以设置为公平锁

  • 支持多个条件变量

与 synchronized 一样,都支持可重入

基本语法

//获取锁
reentrantLock.lock();
try {
    //临界区
} finally {
    //释放锁
    reentrantLock.unlock();
}

可重入

可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权力再次获取这把锁

如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住

static ReentrantLock lock = new ReentrantLock();

public static void main(String[] args) {
    method1();
}

public static void method1(){
    lock.lock();
    try {
        log.debug("execute method1");
        method2();
    } finally {
        lock.unlock();
    }
}

public static void method2(){
    lock.lock();
    try {
        log.debug("execute method2");
        method3();
    } finally {
        lock.unlock();
    }
}

public static void method3(){
    lock.lock();
    try {
        log.debug("execute method3");
    } finally {
        lock.unlock();
    }
}

可打断

在等待锁的过程中,其它线程可以用interrupt()打断

需要用 lockInterruptibly() 锁住

public class Test22 {
    private static ReentrantLock lock = new RentrantLock();
	private static void main(String[] args) {
        new Thread(() -> {
            try {
                // 如果没有竞争,那么此方法就会获取 lock 对象锁
                // 如果有竞争就进入阻塞队列,可以被其它线程用 interrupt() 方法打断
                log.debug("尝试获得锁");
            	lock.lockInterruptibly();
        	} catch (InterruptedException e) {
            	e.printStackTrace();
                log.debug("没有获得锁,返回");
                return;
        	}
            try {
                log.debug("获取到锁");
            } finally {
                lock.unlock();
            }
        }, "t1").start();
    }
    
    lock.lock();
    t1.start();
    
    sleep(1);
    log.debug("打断 t1");
    t1.interrupt();
}

锁超时

立即失败

ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
   	log.debug("启动...");
    if(! lock.tryLock()) {
        log.debug("获取立刻失败,返回");
        return;
    }
    try {
        log.debug("获得了锁");
    } finally {
        lock.unlock();
    }
}, "t1");

lock.lock();
log.debug("获得了锁");
t1.start();
try {
    sleep(2);
} finally {
    lock.unlock();
}

tryLock()可以设置等待时间

ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
   	log.debug("启动...");
    try {
        if(! lock.tryLock(2, TimeUnit.SECONDS)) {
        	log.debug("获取不到锁");
        	return;
    	}
    } catch (InterruptedException e) {
        e.printStackTrace();
        log.debug("获取不到锁");
        return;
    }
    try {
        log.debug("获得了锁");
    } finally {
        lock.unlock();
    }
}, "t1");

lock.lock();
log.debug("获得了锁");
t1.start();
sleep(1);	
log.debug("释放了锁");
lock.unlock();

公平锁

ReentrantLock 默认是不公平的

ReentrantLock lock = new ReentrantLock(false);	//false 不公平,true 公平

lock.lock();
for(int i = 0; i < 500; i++){
    new Thread(() -> {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "running...");
        } finally {
            lock.unlock();
        }
    }, "t" + 1).start();
}

// 1s 之后去争抢锁
Thread.sleep(1000);
new Thread(() -> {
    System.out.println(Thread.currentThread().getName() + "start...");
    lock.lock();
    try {
        System.out.println(Thread.currentThread().getName() + "running...");
    } finally {
        lock.unlock();
    }
}, "强行插入").start();

公平锁一般没有必要,会降低开发度,后面分析原理时会讲解

条件变量

synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待

ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比

  • synchronized 是那些不满足条件的线程都在一间休息室等消息

  • 而 ReentrantLock 支持多间休息室,唤醒时也是按休息室来唤醒

使用流程

  • await 前需要获得锁

  • await 执行后,会释放锁,进入 conditionObject 等待

  • await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁

  • 竞争 lock 锁成功后,从 await 后继续执行

static ReentrantLock lock = new ReentrantLock();

public static void main(String[] args){
    
    //创建一个新的条件变量(休息室)
    Condition condition1 = lock.newCondition();
    Condition condition2 = lock.newCondition();
    
    
  	lock.lock();
    //进入休息室等待
    condition1.await();
    
    condition1.signal();
    condition1.signalAll();
}

总结

        前期学习的 JUC “锁” 知识大概就是这些,勤加记忆与巩固。我是笙一X,一个正在努力拼搏的人,感谢你的支持,同时期待交流和指导。

  • 25
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值