JUC并发编程06 - syn-ed(03)

Java并发编程中的锁优化与活跃性问题

syn-ed

锁优化

自旋锁

重量级锁竞争时,尝试获取锁的线程不会立即阻塞,可以使用自旋(默认 10 次)来进行优化,采用循环的方式去尝试获取锁

注意:

  • 自旋占用 CPU 时间,单核 CPU 自旋就是浪费时间,因为同一时刻只能运行一个线程,多核 CPU 自旋才能发挥优势
  • 自旋失败的线程会进入阻塞状态

优点:不会进入阻塞状态,减少线程上下文切换的消耗

缺点:当自旋的线程越来越多时,会不断的消耗 CPU 资源

自旋锁是什么?

想象一下你去银行办业务:

  • 你发现只有一个窗口(相当于“锁”),现在有人正在办理(相当于“线程 A 拿着锁”)。
  • 你想办业务(相当于“线程 B 想拿锁”),但窗口被占了。

传统做法(普通锁):你说:“那我先回家等会儿,等叫到我再回来。”
→ 这就是 线程阻塞:你(线程)被挂起,不占 CPU,但等会儿要重新叫你起床(上下文切换),很耗时间。

自旋锁的做法:你说:“我不走,我就站这儿盯着窗口,每秒问一次‘办完了吗?’”这就是 自旋:你一直站着(占用 CPU),不断循环检查锁有没有释放。

但你不能一直问啊!你最多问 10 次,比如:“1次…2次…3次…”,问到第10次还没轮到你,你就说:“算了,我去旁边坐着等叫号吧。”这就是:自旋失败后进入阻塞状态。

什么时候用自旋锁最划算?

多核 CPU:你在1号窗口等,别人在2号窗口办业务,你们互不影响。你“自旋”是在浪费自己的CPU时间,但不影响别人干活。

单核 CPU:只有一个窗口,你站着问“办完了吗”,其实别人根本没法办业务(因为CPU被你占着),你就是在瞎忙,纯浪费时间。

所以,自旋锁只在多核 CPU下才有意义。

编码模拟自旋过程:

public class SpinLock {
    // 标志位:谁拿着锁?null 表示没人,Thread 表示某个线程拿着
    private Thread owner = null;

    // 尝试加锁(自旋)
    public void lock() {
        Thread current = Thread.currentThread();
        // 开始“自旋”:不断尝试
        while (!compareAndSet(null, current)) {
            // 自旋中... 一直问:“锁释放了吗?”
            // 可以加个计数,比如最多自旋10次
            // 这里简化,无限自旋,实际JVM有默认限制(如10次)
            System.out.println(current.getName() + " 正在自旋等待...");
            Thread.yield(); // 礼让一下CPU,避免太霸道
        }
        System.out.println(current.getName() + " 成功拿到锁!");
    }

    // 解锁
    public void unlock() {
        Thread current = Thread.currentThread();
        if (owner == current) {
            owner = null;
            System.out.println(current.getName() + " 释放了锁");
        }
    }

    // 模拟 CAS 操作:只有当前值是 expect 时,才设置为 update
    private synchronized boolean compareAndSet(Thread expect, Thread update) {
        if (owner == expect) {
            owner = update;
            return true;
        }
        return false;
    }
}

// 测试类
public class SpinLockDemo {
    public static void main(String[] args) {
        SpinLock lock = new SpinLock();

        Thread t1 = new Thread(() -> {
            lock.lock();
            try {
                System.out.println("t1 正在执行任务...");
                Thread.sleep(2000); // 模拟干活
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            lock.lock(); // t1 拿着锁,t2 开始自旋
            try {
                System.out.println("t2 正在执行任务...");
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }, "t2");

        t1.start();
        t2.start();
    }
}
自旋锁情况

自旋成功的情况:

初始状态

  • 线程1和线程2都未开始执行。
  • 对象的Mark字段为10(重量锁),表示当前对象处于重量级锁状态,并且有一个指向重量锁的指针。

线程1获取锁并执行同步块

  1. 线程1访问同步块,获取monitor

    • 线程1尝试获取对象的monitor(即锁)。此时,对象的Mark字段仍然是10(重量锁)重量锁指针
  2. 线程1成功加锁

    • 线程1成功获取了锁,可以开始执行同步块中的代码。此时,对象的Mark字段保持不变,因为锁已经被线程1持有。
  3. 线程1执行同步块

    • 线程1在核心1上执行同步块中的代码。在此期间,对象的Mark字段仍然显示为10(重量锁)重量锁指针,表明锁被线程1持有。

线程2尝试获取锁并自旋

  1. 线程2访问同步块,获取monitor

    • 在线程1执行同步块的过程中,线程2也尝试获取同一个对象的monitor。由于锁已经被线程1持有,线程2无法立即获取锁。
  2. 线程2自旋重试

    • 线程2进入自旋状态,不断检查对象的Mark字段,看锁是否已被释放。在这个过程中,线程2不会被阻塞,而是占用CPU时间进行循环检查。
  3. 线程1执行完毕并解锁

    • 线程1完成同步块中的任务后,释放锁。此时,对象的Mark字段变为01(无锁),表示锁已被释放。

自旋成功:线程2获取锁

  1. 线程2成功加锁

    • 由于线程1已经释放了锁,线程2在自旋过程中检测到锁可用,成功获取了锁。此时,对象的Mark字段再次变为10(重量锁)重量锁指针,但这次是指向线程2持有的重量锁。
  2. 线程2执行同步块

    • 线程2开始在核心2上执行同步块中的代码。在此期间,对象的Mark字段保持为10(重量锁)重量锁指针,表明锁被线程2持有。

总结

通过上述步骤,我们可以看到自旋锁成功的情况:

  • 当一个线程(如线程1)持有锁并在执行同步块时,其他线程(如线程2)会进入自旋状态,不断检查锁是否可用。
  • 如果持有锁的线程很快释放了锁,自旋中的线程可以立即获取锁,避免了进入阻塞状态和上下文切换的开销。
  • 这种机制在多核CPU环境下特别有效,因为它允许线程在等待锁时继续占用CPU资源,而不是被挂起。

初始状态

  • 线程1和线程2都未开始执行。
  • 对象的Mark字段为10(重量锁),表示当前对象处于重量级锁状态,并且有一个指向重量锁的指针。

线程1获取锁并执行同步块

  1. 线程1访问同步块,获取monitor

    • 线程1尝试获取对象的monitor(即锁)。此时,对象的Mark字段仍然是10(重量锁)重量锁指针
  2. 线程1成功加锁

    • 线程1成功获取了锁,可以开始执行同步块中的代码。此时,对象的Mark字段保持不变,因为锁已经被线程1持有。
  3. 线程1执行同步块

    • 线程1在核心1上执行同步块中的代码。在此期间,对象的Mark字段仍然显示为10(重量锁)重量锁指针,表明锁被线程1持有。

线程2尝试获取锁并自旋

  1. 线程2访问同步块,获取monitor

    • 在线程1执行同步块的过程中,线程2也尝试获取同一个对象的monitor。由于锁已经被线程1持有,线程2无法立即获取锁。
  2. 线程2自旋重试

    • 线程2进入自旋状态,不断检查对象的Mark字段,看锁是否已被释放。在这个过程中,线程2不会被阻塞,而是占用CPU时间进行循环检查。
  3. 线程2自旋多次但未成功

    • 假设线程2进行了多次自旋重试(例如默认的10次),但在这期间线程1仍未释放锁。因此,线程2的自旋尝试均未成功。

自旋失败:线程2进入阻塞状态

  1. 线程2阻塞
    • 由于自旋次数达到上限(例如10次)后仍未获取到锁,线程2将停止自旋并进入阻塞状态。此时,线程2会被挂起,等待操作系统调度器将其唤醒。

通过上述步骤,我们可以看到自旋锁失败的情况:

  • 当一个线程(如线程1)持有锁并在执行同步块时,其他线程(如线程2)会进入自旋状态,不断检查锁是否可用。
  • 如果持有锁的线程长时间未释放锁,导致自旋中的线程达到自旋次数上限(例如10次),则自旋失败。
  • 自旋失败后,线程将进入阻塞状态,不再占用CPU资源,等待锁被释放后再被调度器唤醒。
自旋锁说明
  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能
  • Java 7 之后不能控制是否开启自旋功能,由 JVM 控制

手写自旋锁:

import java.util.concurrent.atomic.AtomicReference;

public class SpinLock {
    // 原子引用,保存当前持有锁的线程
    private AtomicReference<Thread> atomicReference = new AtomicReference<>();

    // 加锁方法
    public void lock() {
        Thread currentThread = Thread.currentThread();
        System.out.println(currentThread.getName() + " 来了,准备抢锁");

        // 开始“自旋”:不断尝试用 CAS 把 null 改成自己
        while (!atomicReference.compareAndSet(null, currentThread)) {
            // 锁被别人占着,我就在这儿“打转”等待
            System.out.println(currentThread.getName() + " 正在自旋中...");
            // 为了演示效果,加个 sleep(1000),实际中不会这么干(太耗CPU)
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println(currentThread.getName() + " 成功抢到锁!");
    }

    // 解锁方法
    public void unlock() {
        Thread currentThread = Thread.currentThread();
        // 把锁设置为 null,表示释放
        if (atomicReference.compareAndSet(currentThread, null)) {
            System.out.println(currentThread.getName() + " 释放了锁");
        }
    }

    // ================== 测试 ==================
    public static void main(String[] args) throws InterruptedException {
        SpinLock lock = new SpinLock();

        // 线程 t1:先抢锁,占着10秒
        new Thread(() -> {
            lock.lock();
            try {
                System.out.println("t1 正在执行任务...");
                Thread.sleep(10000);  // 模拟工作10秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }, "t1").start();

        // 主线程睡1秒,确保 t1 先运行并抢到锁
        Thread.sleep(1000);

        // 线程 t2:发现锁被占,开始自旋等待
        new Thread(() -> {
            lock.lock();
            try {
                System.out.println("t2 正在执行任务...");
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }, "t2").start();
    }
}

最后输出如下:

AtomicReference<Thread> 是干啥的?它是一个可以原子操作的引用变量,用来保存当前持有锁的线程。
compareAndSet(null, thread) 是啥意思?CAS(Compare And Set):如果当前值是 null(没人拿锁),就把值设为当前线程,表示“我抢到了”。
为啥叫“自旋”?因为 while 循环一直在跑,像陀螺一样“ spinning ”,直到抢到锁为止。
实际开发能这么写吗?不能! 这只是教学 demo。真实场景用 synchronized 或 ReentrantLock 即可,它们内部已经高度优化。

Java 中的自旋锁进化史

Java 6 之前:

  • 自旋次数是固定的,比如最多自旋 10 次。
  • 不管上次有没有成功,都一样处理,比较“傻”。

Java 6 之后:自适应自旋锁

JVM 变聪明了!它会“记性”:

  • 如果某个锁上次自旋成功了,说明这次也可能很快拿到,那就多自旋几次
  • 如果上次自旋失败了,说明竞争激烈,这次就少自旋甚至不自旋,直接放弃,进入阻塞。

就像你在银行发现:

  • 昨天你等了3分钟就办上了 → 今天你愿意多等一会儿。
  • 昨天等了半小时都没办上 → 今天你直接拿号去休息区坐着。

这就是“自适应”——根据历史表现动态调整策略。

Java 7 之后:

  • 开发者不能手动控制是否开启自旋。
  • 全部交给 JVM 自动管理,你说了不算。

所以:你写 synchronized 的时候,JVM 自己决定要不要自旋、自旋几次。

锁清除

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除,这是 JVM 即时编译器的优化

锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除(同步消除:JVM 逃逸分析)

锁消除,就是 JVM 发现你加了个“锁”,但其实根本没人跟你抢,那它就干脆把这把锁直接“删了”。你写了代码,JVM 发现你“多此一举”,就帮你优化掉了。

为什么会有锁消除?我们写代码的时候,有时候为了线程安全,会用 synchronized 给代码块或者方法加锁。但是!有些情况下,这个数据其实只有你自己在用,别的线程根本碰不到它。这时候,加锁就完全没有必要,反而拖慢速度(锁是有开销的)。于是,JVM 就说:“我来帮你分析一下,这个对象是不是真的会被多个线程访问?如果不会,那锁就别加了。”这个分析过程,就叫 逃逸分析

什么是逃逸分析?

JVM 会分析一个对象:

  • 是不是只在一个方法里用
  • 是不是没传给别的线程
  • 是不是没被别的对象引用

如果都没,那这个对象就“没逃出去”,JVM 就认为它是线程私有的,即使你加了锁,也可以安全地去掉。比如以下这个代码:

public class LockEliminationDemo {

    public static void main(String[] args) {
        // 开始一个方法
        someMethod();
    }

    public static void someMethod() {
        // 这个 StringBuffer 是在方法内部创建的
        StringBuffer sb = new StringBuffer();

        // 你用了 synchronized 方法(内部有锁)
        sb.append("Hello");
        sb.append(" ");
        sb.append("World");

        // toString() 后,sb 就结束了
        String result = sb.toString();
        System.out.println(result);
    }
}

StringBufferappend() 方法是 synchronized 的,也就是说每次调用都会上锁。但你看这个 sb

  • 在 someMethod() 里创建
  • 没传给别的方法
  • 没被别的线程拿到
  • 方法一结束就没了

所以,JVM 通过逃逸分析发现:这个 sb 只有当前线程能访问,不可能有线程安全问题!于是,JVM 在编译成机器码的时候,直接把那些 synchronized 锁给“消除”了!结果:代码运行得更快,没有锁的开销!

锁粗化

对相同对象多次加锁,导致线程发生多次重入,频繁的加锁操作就会导致性能损耗,可以使用锁粗化方式优化

如果虚拟机探测到一串的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部

锁粗化,就是 JVM 发现你“反反复复”地给同一个东西加锁、解锁,太啰嗦了,干脆帮你“合并成一次锁”,锁的时间长一点,但次数少很多!

就像你进厨房做饭,不是每拿一次菜就锁一次门,而是一次性进去,做完再出来,效率更高。

为什么要锁粗化?

一些看起来没有加锁的代码,其实隐式的加了很多锁:

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,转化为 StringBuffer 对象的连续 append() 操作,每个 append() 方法中都有一个同步块

public static String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,只需要加锁一次就可以

我们都知道,加锁和解锁本身是有开销的,尤其是 synchronized,涉及线程竞争、进入/退出 monitor 等操作。如果在一个方法里,连续多次对同一个对象加锁,比如:

synchronized(obj) { ... }
synchronized(obj) { ... }
synchronized(obj) { ... }

这就像你:

  • 进厨房 → 拿个鸡蛋 → 出来锁门
  • 再进厨房 → 拿个番茄 → 出来锁门
  • 再进厨房 → 开火 → 出来锁门

累不累?烦不烦?JVM 就说:“你这不是来回折腾吗?干脆一次性进去,把所有事做完,再出来锁门!”这就是锁粗化:把多个小锁,合并成一个大锁。

场景:字符串拼接(JDK 1.5 之前)

在早期 Java 中,String 拼接会被编译器转成 StringBuffer.append(),而 StringBufferappend()synchronized 的!

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;  // 看起来没加锁?
}

上面这行代码,编译后其实是这样的

public static String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);  // synchronized!
    sb.append(s2);  // synchronized!
    sb.append(s3);  // synchronized!
    return sb.toString();
}

每次 append() 都要加锁、解锁一次,连续加了3次锁!如果 sb 是局部变量,没人抢,你还这么频繁加锁,性能就浪费了

JVM 发现:

  • 这几个 append() 都是对同一个对象sb)操作
  • 而且是连续操作
  • 完全可以把锁的范围扩大

于是 JVM 把它优化成这样:

public static String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    
    // JVM 自动“粗化”:只锁一次,包住所有 append
    synchronized(sb) {
        sb.append(s1);
        sb.append(s2);
        sb.append(s3);
    }
    
    return sb.toString();
}

结果:从 3次加锁 + 3次解锁 → 变成 1次加锁 + 1次解锁!这就是锁粗化:把多个细碎的锁,合并成一个“粗”的大锁。

多把锁

多把不相干的锁:一间大屋子有两个功能睡觉、学习,互不相干。现在一人要学习,一人要睡觉,如果只用一间屋子(一个对象锁)的话,那么并发度很低

将锁的粒度细分:

  • 好处,是可以增强并发度
  • 坏处,如果一个线程需要同时获得多把锁,就容易发生死锁

解决方法:准备多个对象锁

public static void main(String[] args) {
    BigRoom bigRoom = new BigRoom();
    new Thread(() -> { bigRoom.study(); }).start();
    new Thread(() -> { bigRoom.sleep(); }).start();
}
class BigRoom {
    private final Object studyRoom = new Object();
    private final Object sleepRoom = new Object();

    public void sleep() throws InterruptedException {
        synchronized (sleepRoom) {
            System.out.println("sleeping 2 小时");
            Thread.sleep(2000);
        }
    }

    public void study() throws InterruptedException {
        synchronized (studyRoom) {
            System.out.println("study 1 小时");
            Thread.sleep(1000);
        }
    }
}

多把锁,就是把“一间大屋一把锁”改成“睡觉有睡房锁,学习有书房锁”,各干各的,互不打扰,提升效率!你想睡觉?你去睡你的。我想学习?我去学我的。咱俩不用抢同一个屋子,并发度就上去了!

举个生活大例子:一个家,两个功能假设你家只有一个房间,这房间既当卧室又当书房。问题来了(一把锁的坏处):你朋友来你家 学习,占了房间,上了锁:“别打扰我!”结果你困了,想 睡觉,但进不去——房间被占着!虽然一个是学习,一个是睡觉,根本不冲突,但因为只有一把锁,你就得等!这就是:并发度低,资源利用率差。

怎么办?—— 搞“多把锁”!

把房间分成两个小区域:

  • 一个(睡觉专用)
  • 一个书桌(学习专用)

然后:

  • 床上加一把锁:sleepLock
  • 书桌上加一把锁:studyLock

现在:

  • 你朋友在书桌学习(锁了书桌)
  • 你可以在床上睡觉(锁了床)

两人同时干自己的事,互不干扰!这就是多把锁的核心思想把大锁拆成小锁,让不相干的事可以并发执行。

public class MultiLockDemo {
    public static void main(String[] args) {
        BigRoom bigRoom = new BigRoom();

        // 线程1:去睡觉
        new Thread(() -> {
            bigRoom.sleep();
        }, "睡觉线程").start();

        // 线程2:去学习
        new Thread(() -> {
            bigRoom.study();
        }, "学习线程").start();
    }
}

// 大房间类
class BigRoom {
    // 两把独立的锁:各管一摊事
    private final Object sleepRoom = new Object();  // 睡觉专用锁
    private final Object studyRoom = new Object();  // 学习专用锁

    // 睡觉方法:只锁睡觉区域
    public void sleep() {
        synchronized (sleepRoom) {
            System.out.println(Thread.currentThread().getName() + " 开始睡觉,要睡 2 小时...");
            try {
                Thread.sleep(2000);  // 模拟睡2秒(代表2小时)
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " 睡醒了!");
        }
    }

    // 学习方法:只锁学习区域
    public void study() {
        synchronized (studyRoom) {
            System.out.println(Thread.currentThread().getName() + " 开始学习,要学 1 小时...");
            try {
                Thread.sleep(1000);  // 模拟学1秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " 学完了!");
        }
    }
}

输出结果如下:

两个线程同时运行,没有互相等待。

多把锁的坏处:容易死锁

死锁例子:需要同时用两个房间

假设现在有个线程,它要:

  1. 先锁住“床” → 睡觉
  2. 再锁住“书桌” → 学习

另一个线程反过来:

  1. 先锁住“书桌” → 学习
  2. 再锁住“床” → 睡觉

如果两个线程同时运行,就可能:

  • A 线程锁了床,等着书桌
  • B 线程锁了书桌,等着床
  • 谁也不让,互相等,死锁了!
// 危险代码:可能死锁
public void sleepAndStudy() {
    synchronized (sleepRoom) {
        System.out.println("先睡觉...");
        try { Thread.sleep(1000); } catch (Exception e) {}

        synchronized (studyRoom) {
            System.out.println("再学习...");
        }
    }
}

public void studyAndSleep() {
    synchronized (studyRoom) {
        System.out.println("先学习...");
        try { Thread.sleep(1000); } catch (Exception e) {}

        synchronized (sleepRoom) {
            System.out.println("再睡觉...");
        }
    }
}

如何避免死锁?

  1. 固定加锁顺序:大家都先锁 sleepRoom,再锁 studyRoom,不能乱来。
  2. 使用 tryLock(ReentrantLock):尝试拿锁,拿不到就放弃,避免无限等。
  3. 减少多锁操作:能不用多把锁,就不用。

活跃性

死锁

形成

死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放,由于线程被无限期地阻塞,因此程序不可能正常终止。Java 死锁产生的四个必要条件:

  1. 互斥条件,即当资源被一个线程使用(占有)时,别的线程不能使用
  2. 不可剥夺条件,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放
  3. 请求和保持条件,即当资源请求者在请求其他的资源的同时保持对原有资源的占有
  4. 循环等待条件,即存在一个等待循环队列:p1 要 p2 的资源,p2 要 p1 的资源,形成了一个等待环路

四个条件都成立的时候,便形成死锁。死锁情况下打破上述任何一个条件,便可让死锁消失

死锁”——互相等待,无限卡住,程序就卡死了,再也动不了。

死锁不是随便发生的,必须同时满足 4 个条件

条件大白话解释生活例子
1. 互斥条件一个资源,一次只能一个人用桥一次只能过一辆车
2. 不可剥夺不能抢别人手里的资源,只能等他自己放你不能把对方车推开,只能等他退
3. 请求和保持我已经占着一个资源,还想去拿另一个车A已经上了桥,还想让车B退
4. 循环等待A等B,B等A,形成一个“死循环”A要B退,B要A退,互相等

编码实现双线程死锁例子:

public class DeadlockDemo {
    // 两把锁(两个资源)
    public static final Object resource1 = new Object();
    public static final Object resource2 = new Object();

    public static void main(String[] args) {
        // 线程1:先占 resource1,再请求 resource2
        Thread t1 = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("线程1:已占用 resource1,准备请求 resource2...");

                try {
                    Thread.sleep(1000); // 模拟处理时间,让t2有机会先占resource2
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                System.out.println("线程1:尝试获取 resource2...");
                synchronized (resource2) {
                    System.out.println("线程1:成功获得 resource2!");
                }
            }
        }, "线程1");

        // 线程2:先占 resource2,再请求 resource1
        Thread t2 = new Thread(() -> {
            synchronized (resource2) {
                System.out.println("线程2:已占用 resource2,准备请求 resource1...");

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                System.out.println("线程2:尝试获取 resource1...");
                synchronized (resource1) {
                    System.out.println("线程2:成功获得 resource1!");
                }
            }
        }, "线程2");

        // 启动两个线程
        t1.start();
        t2.start();
    }
}

观察控制台,其实是卡死不动的,就是死锁:

如何解决死锁?

方法1:固定加锁顺序(打破“循环等待”)大家都约定:先锁 resource1,再锁 resource2

// 线程2 改成:先请求 resource1,再请求 resource2
Thread t2 = new Thread(() -> {
    synchronized (resource1) {  // 先锁 resource1
        System.out.println("线程2:已占用 resource1");
        try { Thread.sleep(1000); } catch (Exception e) {}

        synchronized (resource2) {
            System.out.println("线程2:已占用 resource2");
        }
    }
});

方法2:使用 tryLock(打破“请求和保持”)ReentrantLocktryLock(),尝试拿锁,拿不到就放弃或重试。

import java.util.concurrent.locks.ReentrantLock;

ReentrantLock lock1 = new ReentrantLock();
ReentrantLock lock2 = new ReentrantLock();

Thread t1 = new Thread(() -> {
    lock1.lock();
    try {
        System.out.println("线程1 拿到 lock1");
        Thread.sleep(1000);

        if (lock2.tryLock()) {  // 尝试拿 lock2
            try {
                System.out.println("线程1 拿到 lock2");
            } finally {
                lock2.unlock();
            }
        } else {
            System.out.println("线程1 拿不到 lock2,放弃!");
        }
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        lock1.unlock();
    }
});

方法3:设置超时(打破“无限等待”)

boolean locked = lock2.tryLock(3, TimeUnit.SECONDS);
if (!locked) {
    // 超时了,放弃或重试
}

死锁的危害

  • 程序假死,不报错也不结束
  • 线程卡住,资源不释放
  • 严重时导致系统瘫痪
定位

定位死锁的方法:

  • 使用 jps 定位进程 id,再用 jstack id 定位死锁,找到死锁的线程去查看源码,解决优化

"Thread-1" #12 prio=5 os_prio=0 tid=0x000000001eb69000 nid=0xd40 waiting formonitor entry [0x000000001f54f000]
	java.lang.Thread.State: BLOCKED (on object monitor)
#省略    
"Thread-1" #12 prio=5 os_prio=0 tid=0x000000001eb69000 nid=0xd40 waiting for monitor entry [0x000000001f54f000]
	java.lang.Thread.State: BLOCKED (on object monitor)
#省略

Found one Java-level deadlock:
===================================================
"Thread-1":
    waiting to lock monitor 0x000000000361d378 (object 0x000000076b5bf1c0, a java.lang.Object),
    which is held by "Thread-0"
"Thread-0":
    waiting to lock monitor 0x000000000361e768 (object 0x000000076b5bf1d0, a java.lang.Object),
    which is held by "Thread-1"
    
Java stack information for the threads listed above:
===================================================
"Thread-1":
    at thread.TestDeadLock.lambda$main$1(TestDeadLock.java:28)
    - waiting to lock <0x000000076b5bf1c0> (a java.lang.Object)
    - locked <0x000000076b5bf1d0> (a java.lang.Object)
    at thread.TestDeadLock$$Lambda$2/883049899.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:745)
"Thread-0":
    at thread.TestDeadLock.lambda$main$0(TestDeadLock.java:15)
    - waiting to lock <0x000000076b5bf1d0> (a java.lang.Object)
    - locked <0x000000076b5bf1c0> (a java.lang.Object)
    at thread.TestDeadLock$$Lambda$1/495053715
  • Linux 下可以通过 top 先定位到 CPU 占用高的 Java 进程,再利用 top -Hp 进程id 来定位是哪个线程,最后再用 jstack 的输出来看各个线程栈

  • 避免死锁:避免死锁要注意加锁顺序

  • 可以使用 jconsole 工具,在 jdk\bin 目录下

定位死锁,就是当你的程序“卡死了”,你要像个“侦探”一样,找出到底是哪两个线程在“互不相让”,然后去代码里“抓现行”!

这就需要两个工具:jpsjstack

工具作用大白话
jps查看当前所有 Java 进程的 ID告诉我哪个 Java 程序在跑
jstack <进程ID>查看某个 Java 进程的所有线程状态和调用栈把每个线程在干啥列出来

第一步:用 jps 找到进程 ID

第二步:用 jstack 查看线程状态

它会输出一大堆信息,全是线程的“现场记录”。我们要找的关键信息是:

关键词1:BLOCKED (on object monitor)

表示这个线程被锁住了,正在等别人释放锁。

"Thread-1" #12 prio=5 ...
    java.lang.Thread.State: BLOCKED (on object monitor)

关键词2:waiting to lock ... which is held by ...

这是“死锁铁证”!它直接告诉你:

  • 谁在等
  • 等什么
  • 被谁占着

比如:

Found one Java-level deadlock:
===================================================
"Thread-1":
    waiting to lock monitor 0x000000076b5bf1c0 (a java.lang.Object),
    which is held by "Thread-0"
"Thread-0":
    waiting to lock monitor 0x000000076b5bf1d0 (a java.lang.Object),
    which is held by "Thread-1"
  • Thread-1 在等 0x000...c0 这把锁,但这把锁被 Thread-0 拿着
  • Thread-0 在等 0x000...d0 这把锁,但这把锁被 Thread-1 拿着

关键词3:Java stack information —— 精确定位到代码行

Java stack information for the threads listed above:
===================================================
"Thread-1":
    at thread.TestDeadLock.lambda$main$1(TestDeadLock.java:28)
    - waiting to lock <0x000000076b5bf1c0> (a java.lang.Object)
    - locked <0x000000076b5bf1d0> (a java.lang.Object)

"Thread-0":
    at thread.TestDeadLock.lambda$main$0(TestDeadLock.java:15)
    - waiting to lock <0x000000076b5bf1d0> (a java.lang.Object)
    - locked <0x000000076b5bf1c0> (a java.lang.Object)
  • TestDeadLock.java:28:Thread-1 正在第 28 行等锁,它已经占了 0x...d0
  • TestDeadLock.java:15:Thread-0 正在第 15 行等锁,它已经占了 0x...c0

JDK 还自带一个可视化工具:jconsole它会弹出一个图形界面,选择你的 Java 进程连接。然后点“线程”标签,再点“检测死锁”按钮。它会自动帮你找出死锁的线程,并高亮显示。

Linux 下更高级的定位方法(CPU 高时用)

有时候死锁不一定卡住 CPU,但如果线程在“忙等”或频繁竞争,CPU 可能很高。可以用 top 找出占用高的 Java 进程:

top

看到某个 Java 进程 CPU 很高,记下它的 PID(比如 2345),然后看这个进程里哪个线程最耗 CPU:

top -Hp 2345

输出可能看到:

  PID USER      PR  NI    VIRT    RES    SHR S %CPU %MEM     TIME+ COMMAND
 2356 user      20   0 2816664  55624  14304 R 99.0  0.7   0:10.12 java

PID 2356 的线程 CPU 占用 99%!但 jstack 用的是线程ID的十六进制,所以要把 2356 转成十六进制:

printf "%x\n" 2356
# 输出:934

然后在 jstack 2345 的输出里搜 934,就能定位到是哪个 Java 线程(比如 nid=0x934),再看它的调用栈,就知道它在干啥了。

活锁

指的是任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试—失败—尝试—失败的过程。两个线程互相改变对方的结束条件,最后谁也无法结束。

活锁,就是两个(或多个人)太“礼貌”或太“较劲”,都在主动让步,结果反而一直动来动去,谁也干不成事!

class TestLiveLock {
    // 共享变量:一个数
    static volatile int count = 10;
    
    public static void main(String[] args) {
        // 线程1:想把 count 减到 0 就停
        new Thread(() -> {
            while (count > 0) {
                try {
                    Thread.sleep(200);  // 模拟工作
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                count--;  // 往目标靠近
                System.out.println("线程一:把 count 减了,现在是 " + count);
            }
            System.out.println("线程一:终于完成了!");
        }, "线程一").start();

        // 线程二:想把 count 加到 20 就停
        new Thread(() -> {
            while (count < 20) {
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                count++;  // 往目标靠近
                System.out.println("线程二:把 count 加了,现在是 " + count);
            }
            System.out.println("线程二:终于完成了!");
        }, "线程二").start();
    }
}

控制台输出如下,一直在活锁状态:

饥饿

一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值