Java synchronized死锁陷阱与内存可见性解析

Ruby
Ruby
发布时间:2026-10-13 | 2人浏览过
原创

synchronized是Java实现线程互斥的关键字,但其隐式的对象锁机制若使用不当,极易在异步回调或跨方法等待场景中引发死锁。同时,作为Java内存模型(JMM)的核心组件,它通过Happens-Before规则确保了线程间的内存可见性,防止因指令重排序导致的数据读取错误。

结果与目标

理解synchronized在并发编程中的双重作用:一是作为互斥锁防止数据竞争,二是通过内存屏障保证变量的可见性。重点排查因锁机制误解导致的程序卡死(死锁)现象,并掌握其底层的内存语义。

准备条件

需要基本的Java多线程知识,了解线程启动、run方法执行及基本同步概念。熟悉JDK环境下的代码调试能力。

真实操作顺序:死锁成因剖析

第1步:重现隐式锁导致的死锁场景

目的:验证为何在同一对象的两个synchronized方法间相互等待会引发死锁。
背景:synchronized修饰实例方法时,隐式锁定的是当前实例对象(this)。持有该锁的线程在未退出同步块前,其他任何试图获取同一对象锁的线程都将被阻塞。

常见错误代码结构如下,主线程调用方法a,其中包含异步回调等待;异步线程调用方法b以解除等待:

synchronized void a(){
    CountDownLatch countDownLatch = new CountDownLatch(1);
    // 执行部分业务逻辑
    // ... 
    // 触发异步线程执行b,并在此处阻塞等待
    countDownLatch.await();
}

synchronized void b(){
    countDownLatch.countDown();
}

现象:主线程执行a后卡住,无论等待多久,程序都不会继续。即使b由独立的异步线程执行,也无法运行。

第2步:分析锁竞争机制

入口:理解synchronized的作用范围。
动作:将上述方法拆解为显式同步块进行对比:
- 方法a执行时,首先获取对象锁(mutex)。
- 进入内部逻辑后,发起对方法b的调用请求。
- 方法b也是synchronized方法,进入时必须再次获取同一个对象锁。
关键参数/状态:a持有的锁尚未释放(因为await()发生在同步方法体内,方法未结束),但a又在等待b执行完毕。b在等待获取a持有的锁。这就形成了“A等B,B等A”的经典死锁环路。

这证明了放在方法声明前的synchronized,其底层行为等价于在方法体开头和结尾自动包裹了 synchronized(this) { ... }。

第3步:验证锁的对象实质

入口:编写测试代码验证实例方法的隐式锁对象。
动作:创建一个类,定义一个synchronized方法m1和一个在方法内使用synchronized(this)的代码块方法m2。启动两个线程分别调用m1和m2。

public class MultiThreadSync {
    public synchronized void m1() throws InterruptedException {
        System.out.println("m1 call");
        Thread.sleep(2000);
        System.out.println("m1 call done");
    }

    public void m2() throws InterruptedException {
        synchronized (this) {
            System.out.println("m2 call");
            Thread.sleep(2000);
            System.out.println("m2 call done");
        }
    }

    public static void main(String[] args) {
        final MultiThreadSync thisObj = new MultiThreadSync();
        Thread t1 = new Thread(() -> {
            try { thisObj.m1(); } catch (InterruptedException e) { e.printStackTrace(); }
        });
        Thread t2 = new Thread(() -> {
            try { thisObj.m2(); } catch (InterruptedException e) { e.printStackTrace(); }
        });
        t1.start();
        t2.start();
    }
}

可观察结果:控制台先输出 m1 call 和 m1 call done,随后才输出 m2 call 和 m2 call done。
结论:m2执行时等待了m1的完成,证实两者锁住了同一个对象(实例本身)。对于static方法,锁定的则是Class对象实例。

结果与目标:内存可见性与Happens-Before

第1步:识别无同步状态下的内存可见性问题

目的:展示不使用synchronized时,线程间可能读不到最新变量值。
场景:写线程修改变量,读线程循环等待标识位。

public class NoVisibility {
    private static boolean ready = false;
    private static int number = 0;
    private static class ReaderThread extends Thread {
        @Override
        public void run() {
            while (!ready) {
                Thread.yield();
            }
            System.out.println(number); // 可能输出0,也可能不输出
        }
    }
    public static void main(String[] args) {
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

风险提醒:JVM编译器和CPU指令可能会进行重排序。若ready=true先于number=42执行,读线程可能看到ready为true但number仍为默认值0。更严重的是,由于每个线程拥有独立的工作内存(Cache/寄存器),写线程更新后的值可能始终未同步回主存,导致死循环。

第2步:利用synchronized恢复可见性

入口:引入共享锁对象。
动作:在读写两侧均使用synchronized块保护共享变量访问。

private static Object lock = new Object();
// ... 读取端
synchronized (lock) {
    while (!ready) { Thread.yield(); }
    System.out.println(number);
}
// ... 写入端
synchronized (lock) {
    number = 42;
    ready = true;
}

原理:JMM规定,解锁操作 happens-before 后续的加锁操作。当ThreadA释放锁时,其工作内存中所有变量的更新都会刷新到主存。ThreadB在获取同一把锁时,会强制失效自己的工作内存缓存,重新从主存加载变量。从而确保读到最新值。

完成判断

通过添加正确的synchronized同步代码,原本可能输出0或无限等待的程序,稳定输出42。死锁问题解决后,程序按预期流程正常结束。

排错指南

若遇到程序“假死”且堆栈显示大量线程BLOCKED在synchronized代码段,请检查:
1. 是否在同一实例的不同synchronized方法间存在直接或间接的互相等待?
2. 异步回调中是否调用了当前线程持有的锁对应的synchronized方法?
3. 是否误以为synchronized只锁代码块内部,忽略了方法级同步对整个对象锁的占用?

简要总结

以上就是Java中synchronized关键字的死锁和内存占用问题的详细内容,更多关于Java并发编程的资料请关注本站其它相关文章!
相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表