【常见错误】2、Java并发编程避坑指南:从加锁失效到死锁,10个案例教你正确使用锁

Java并发编程避坑指南:从加锁失效到死锁,10个案例教你正确使用锁

并发编程中锁是最常见的同步工具,但也是最容易出错的地方。本文将带你深入剖析4类典型锁陷阱,通过代码复现、原因分析和优化方案,让你彻底掌握Java加锁的正确姿势。

引言:锁------并发编程的"双刃剑"

在Java多线程开发中,锁是保证线程安全的核心手段。然而,越是基础的工具,越容易被误用。我在多年的代码审查和线上问题排查中发现,90%的并发Bug都与锁的不当使用有关:有的加了锁却依然数据错乱,有的因为锁粒度太粗导致性能雪崩,有的则陷入死锁让业务彻底瘫痪。

本文将通过4个极具代表性的案例,还原这些"锁事"烦恼的现场,并给出切实可行的解决方案。每个案例都配有Mermaid流程图,帮助你直观理解线程间的交互与锁的状态变化。读完本文,你将掌握:

  • 如何精准定位需要保护的共享资源
  • 如何选择合适的锁粒度和范围
  • 如何避免死锁的四大必要条件
  • 如何应对锁超时释放带来的重复执行问题

一、不完整的同步:只锁方法就够了吗?

1.1 诡异的日志:a < b 且 a > b 同时成立?

先来看一段让很多开发者困惑的代码。有一个Interesting类,包含两个volatile int变量a和b,以及两个方法:add()循环1万次对a和b进行自增,compare()循环1万次判断a是否小于b,如果成立则打印a、b的值并判断a > b是否成立。

java 复制代码
@Slf4j
public class Interesting {
    volatile int a = 1;
    volatile int b = 1;

    public void add() {
        log.info("add start");
        for (int i = 0; i < 10000; i++) {
            a++;
            b++;
        }
        log.info("add done");
    }

    public void compare() {
        log.info("compare start");
        for (int i = 0; i < 10000; i++) {
            if (a < b) {
                log.info("a:{},b:{},{}", a, b, a > b);
            }
        }
        log.info("compare done");
    }
}

然后启动两个线程分别执行add()compare()

java 复制代码
Interesting interesting = new Interesting();
new Thread(() -> interesting.add()).start();
new Thread(() -> interesting.compare()).start();

按常理,a和b同步增加,始终相等,a < b永远不会成立,不应该有任何输出。然而执行结果却让人大跌眼镜:

复制代码
21:12:38.619 [Thread-1] INFO Interesting - a:124,b:125,true
21:12:38.619 [Thread-1] INFO Interesting - a:233,b:234,true
...

不仅输出了日志,甚至出现了a < b成立的同时a > b也成立的情况!

1.2 故障分析:原子性被破坏

为什么会出现这种违背数学常识的现象?问题出在add()compare()方法的执行是交错的,且它们都不是原子操作。

  • a++b++在字节码层面是"读取-修改-写入"三步操作,线程可能在执行完a++后暂停,此时a比b大1,然后compare()线程读取a和b,就可能看到a > b。
  • a < b判断本身也是"加载a、加载b、比较"三步,同样可能被add()线程的赋值操作穿插。

如果用synchronized只修饰add()方法,能解决问题吗?不能!因为add()本身只有一个线程在执行,锁住它并不能阻止compare()线程在中间读取。正确的做法是为两个方法都加上同一把锁 ,保证add()执行期间compare()无法读取,反之亦然:

java 复制代码
public synchronized void add() { ... }
public synchronized void compare() { ... }

1.3 Mermaid时序图:线程交错现场还原

下面用Mermaid时序图模拟a++b++a < b交错的情况,让你更清晰地看到错误如何发生:
变量b 变量a compare线程 add线程 变量b 变量a compare线程 add线程 初始状态 a=1, b=1 T2此时开始比较 一段时间后... T2读取时a=124,b=124 输出 a:124,b:125,false 读取a=1 a++ → a=2 读取b=1 读取a=2 读取b=1 判断 a < b ? 2<1 false b++ → b=2 读取a=2 读取b=2 a < b? false a++ (a=124) 读取b=124 读取a=124 读取b=124 a < b? false b++ → b=125 读取a=124 读取b=125 a < b? 124<125 true 记录日志 读取a=124 读取b=125 a > b? 124>125 false

注意,当add()线程执行完a++但未执行b++时,a比b大1;当compare()线程恰好在此时读取,就会看到a > b。而compare()线程执行a < b比较时,可能a和b都处于中间状态,导致输出看起来矛盾。

1.4 小结:加锁必须覆盖所有相关操作

这个案例告诉我们:锁的保护范围必须覆盖所有操作共享资源的代码路径 。只锁写操作不锁读操作,读到的可能是中间状态;只锁部分写操作,其他写操作仍可穿插。使用synchronized修饰方法时,要思考该方法涉及的资源是否还有其他线程访问,如果有,那些方法也必须加同一把锁。


二、锁与被保护对象:你锁对"人"了吗?

2.1 静态变量 vs 实例锁

看下面这个Data类,它有一个静态字段counter,以及一个实例方法wrong(),该方法用synchronized修饰,对counter进行自增。

java 复制代码
class Data {
    @Getter
    private static int counter = 0;
    
    public static int reset() { counter = 0; return counter; }

    public synchronized void wrong() {
        counter++;
    }
}

测试代码:使用并行流创建100万个Data实例,每个实例调用一次wrong()方法,期望最终counter为100万。

java 复制代码
@GetMapping("wrong")
public int wrong(@RequestParam(defaultValue = "1000000") int count) {
    Data.reset();
    IntStream.rangeClosed(1, count).parallel()
             .forEach(i -> new Data().wrong());
    return Data.getCounter();
}

执行结果却是:639242,远小于100万。为什么会这样?

2.2 原因:多个实例对应多把锁

问题在于synchronized修饰实例方法时,锁的是当前实例对象(this)。而代码中创建了100万个不同的Data实例,每个实例都有自己的锁,因此这100万个线程可以同时 执行不同实例的wrong()方法,同时操作静态变量counter,线程安全问题自然暴露。
静态区
实例2
实例1
线程池
获取锁this1
获取锁this2
操作
操作
线程1
线程2
线程3
Data实例1

锁对象:this1
wrong方法
Data实例2

锁对象:this2
wrong方法
static counter

如图所示,线程1和线程2可以同时持有不同的实例锁,同时进入wrong()方法,对静态变量counter的并发操作完全不受控制。

2.3 解决方案:使用类级别锁或静态锁对象

要保护静态变量,必须使用类级别的锁一个所有实例共享的锁。修正方案如下:

java 复制代码
class Data {
    private static int counter = 0;
    private static final Object locker = new Object();

    public void right() {
        synchronized (locker) {
            counter++;
        }
    }
}

或者将wrong()方法改为静态同步方法:

java 复制代码
public static synchronized void right() {
    counter++;
}

静态同步方法锁的是Data.class对象,所有实例共享同一个类锁。

2.4 扩展:同步代码块 vs 同步方法

从JVM字节码层面看,同步方法使用ACC_SYNCHRONIZED标志,而同步代码块使用monitorentermonitorexit指令。前者更简洁,但后者可以更精细地控制锁的范围和对象。本例中,使用同步代码块并传入一个静态的locker对象,既保护了静态变量,又保持了方法的实例化特征。


三、锁的粒度:一杆子打死还是精准打击?

3.1 粗锁的性能之痛

很多开发者为图省事,直接在方法上加上synchronized,或者将一大段代码放入同步块。这在并发量低时或许没问题,但在高并发下会严重拖垮性能。

考虑一个场景:有一个ArrayList需要被多个线程安全地添加元素,同时还有一个耗时的非共享操作slow()。错误的做法是给整个方法加锁:

java 复制代码
private List<Integer> data = new ArrayList<>();

private void slow() {
    try { TimeUnit.MILLISECONDS.sleep(10); } catch (InterruptedException e) { }
}

@GetMapping("wrong")
public int wrong() {
    long begin = System.currentTimeMillis();
    IntStream.rangeClosed(1, 1000).parallel().forEach(i -> {
        synchronized (this) {  // 锁了整个方法块
            slow();            // 无共享资源,也被锁住
            data.add(i);
        }
    });
    log.info("took:{}", System.currentTimeMillis() - begin);
    return data.size();
}

这段代码执行1000次操作,耗时约11秒。因为synchronized(this)锁住了当前对象,导致1000个线程串行执行,每个线程都要先执行10ms的slow(),再添加元素。

3.2 细粒度锁的性能提升

正确的做法是只对共享资源加锁 ,将slow()移出同步块:

java 复制代码
@GetMapping("right")
public int right() {
    long begin = System.currentTimeMillis();
    IntStream.rangeClosed(1, 1000).parallel().forEach(i -> {
        slow();  // 无锁,并发执行
        synchronized (data) {  // 只对List加锁
            data.add(i);
        }
    });
    log.info("took:{}", System.currentTimeMillis() - begin);
    return data.size();
}

优化后,耗时仅1.4秒,性能提升近8倍!下面是两种加锁方式的执行流程对比:
00:00 00:05 00:10 00:15 00:20 00:25 00:30 线程1 线程1 slow 线程2 slow 线程3 slow 线程2 线程1 add 线程2 add 线程3 add 线程3 粗锁(总耗时约11s) 细锁(总耗时约1.4s) 粗锁 vs 细锁执行时间对比

在细锁模式下,所有线程的slow()方法并行执行,仅在最后add时短暂串行,大大提升了吞吐量。

3.3 进阶粒度:读写锁与乐观锁

如果共享资源的读操作远多于写操作,还可以使用ReentrantReadWriteLock进一步优化:读锁共享,写锁互斥。例如一个缓存场景:

java 复制代码
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock r = rwLock.readLock();
private final Lock w = rwLock.writeLock();
private Map<String, Object> cache = new HashMap<>();

public Object get(String key) {
    r.lock();
    try { return cache.get(key); }
    finally { r.unlock(); }
}

public void put(String key, Object value) {
    w.lock();
    try { cache.put(key, value); }
    finally { w.unlock(); }
}

JDK 1.8引入的StampedLock更提供了乐观读,在读多写少且冲突概率低时性能极高。但要注意,这些高级锁的使用复杂度更高,需谨慎评估。

3.4 小结:锁粒度原则

  • 尽可能缩小同步块:只保护必要的共享资源。
  • 区分读写场景:读多写少考虑读写锁。
  • 慎用公平锁:公平锁会大幅降低吞吐量,无特殊需求不要开启。

四、多把锁的噩梦:死锁是如何发生的?

4.1 商品下单的死锁现场

某电商系统在下单时需要锁定购物车中多个商品的库存,伪代码如下:

java 复制代码
@Data
static class Item {
    final String name;
    int remaining = 1000;
    ReentrantLock lock = new ReentrantLock();
}

private boolean createOrder(List<Item> order) {
    List<ReentrantLock> locks = new ArrayList<>();
    for (Item item : order) {
        try {
            if (item.lock.tryLock(10, TimeUnit.SECONDS)) {
                locks.add(item.lock);
            } else {
                locks.forEach(ReentrantLock::unlock);
                return false;
            }
        } catch (InterruptedException e) { }
    }
    try {
        order.forEach(item -> item.remaining--);
    } finally {
        locks.forEach(ReentrantLock::unlock);
    }
    return true;
}

并发测试:100个线程随机选择3个商品下单。运行后发现成功率只有65%左右,总耗时50秒。通过VisualVM抓取线程栈,发现典型的死锁:

复制代码
Found one Java-level deadlock:
=============================
"Thread-4":
  waiting for lock 0x000000076ae7c8e0 owned by "Thread-3"
"Thread-3":
  waiting for lock 0x000000076ae7c8b0 owned by "Thread-4"

4.2 死锁分析:循环等待

为什么会产生死锁?假设购物车A的商品是[item1, item2],购物车B的商品是[item2, item1]。线程A先获得item1的锁,线程B先获得item2的锁,然后线程A尝试获取item2的锁(被B持有),线程B尝试获取item1的锁(被A持有),两者相互等待,陷入死锁。
渲染错误: Mermaid 渲染失败: Lexical error on line 2. Unrecognized text. ...subgraph 线程A持有item1锁,等待item2锁 A[ -----------------------^

这就是死锁的四个必要条件:互斥、持有并等待、不可剥夺、循环等待同时满足。

4.3 解决方案:锁顺序与资源排序

打破死锁最简单的方法是消除循环等待------让所有线程以固定的顺序获取锁。对购物车中的商品按名称排序即可:

java 复制代码
List<Item> cart = createCart().stream()
        .sorted(Comparator.comparing(Item::getName))
        .collect(Collectors.toList());

这样,所有线程总是先锁item1再锁item2,循环等待被打破。优化后100次下单全部成功,耗时仅需1.5秒左右。
渲染错误: Mermaid 渲染失败: Lexical error on line 2. Unrecognized text. ... subgraph 所有线程按顺序加锁:item1 -> item2 -----------------------^

4.4 其他死锁预防技巧

  • 使用定时锁tryLock(timeout)避免无限等待。
  • 使用并发数据结构 :如ConcurrentHashMap代替手动锁。
  • 检测死锁:通过ThreadMXBean定期检测并恢复。

五、进阶思考:volatile、锁配对与超时释放

5.1 volatile的可见性保证

文章开头的案例中,a和b使用了volatile关键字,但这并不能解决原子性问题,它只能保证可见性和禁止指令重排。那么思考题1:如果用一个boolean变量控制线程循环退出,不加volatile会怎样?

java 复制代码
static boolean running = true;  // 不加volatile
public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
        while (running) {
            // 循环体为空
        }
        System.out.println("线程退出");
    }).start();
    Thread.sleep(1000);
    running = false;  // 主线程修改
}

答案:子线程可能永远无法退出。 因为没有volatile,子线程可能一直读取工作内存中的running副本(初始为true),而主线程修改的是主内存中的running,子线程看不到变化。加上volatile后,每次读取都强制从主内存刷新,保证可见性。

5.2 锁配对:谁加锁谁解锁

使用ReentrantLock时,一定要保证lock()unlock()成对出现,且unlock()必须放在finally块中。但即使这样,也可能出现锁超时自动释放导致的问题------这在分布式锁场景尤为常见。

例如使用Redis分布式锁,设置了10秒超时,但业务逻辑执行了15秒。锁超时自动释放后,其他线程可能获取到锁并开始执行相同逻辑,导致重复处理。
线程2 Redis锁 线程1 线程2 Redis锁 线程1 10s后锁超时释放 抛出异常:锁已被他人持有 加锁成功(超时10s) 执行长任务(15s) 加锁成功 执行相同任务 任务完成,尝试解锁

解决这类问题有两种思路:

  1. 锁续期:使用Redisson等客户端,它有一个Watch Dog线程在锁超时前自动续期,确保锁在业务执行期间不会释放。
  2. 业务幂等:即使重复执行,通过唯一索引、状态机等手段保证最终结果一致。

5.3 发现锁配对问题的工具

  • 代码静态检查 :SonarQube、FindBugs可以检测到Lock未在finally中释放的问题。
  • 压测与监控:通过高并发压测,观察锁竞争情况和死锁,使用JVisualVM、Arthas等工具在线程栈中查找异常。

六、总结:锁的最佳实践

  1. 明确锁保护的对象:分清静态变量与实例变量,选择类锁或实例锁。
  2. 锁的范围尽可能小:只锁共享资源,非共享操作移出同步块。
  3. 避免死锁:按固定顺序获取多把锁,或使用定时锁。
  4. 考虑读写分离 :读多写少场景使用ReadWriteLockStampedLock
  5. 分布式锁考虑续期:使用带Watch Dog的客户端,或设计业务幂等。
  6. 测试锁的正确性:通过并发压测验证锁的可靠性和性能。

最后,回到开头的疑问------那个"疑似JVM Bug"的问题,其实是对锁机制理解不足造成的。希望本文的4个案例和Mermaid图解,能帮你彻底避开这些锁陷阱,让"锁"事不再烦心。

互动话题:你在项目中遇到过哪些奇葩的锁问题?欢迎在评论区分享你的经历,我们一起探讨解决方案。

相关推荐
我爱学习好爱好爱1 小时前
Kubernetes 1.29集群上部署Java网站项目
java·容器·kubernetes
青衫码上行1 小时前
【项目开发日记 | Java架构】第一天
java·开发语言·spring cloud
至为芯1 小时前
IP2075_34S至为芯支持C口快充的30W功率AC/DC芯片
c语言·开发语言
DJ斯特拉2 小时前
自定义jar包导入maven&&注册第三方bean
java·maven·jar
困死,根本不会2 小时前
Python 连接 iBeacon 蓝牙设备超详细学习笔记
python·蓝牙服务·ibeacon
AI_56782 小时前
基于智优达平台的Python教学实践:从环境搭建到自动评测
开发语言·前端·人工智能·后端·python
j_xxx404_2 小时前
力扣困难算法精解:串联所有单词的子串与最小覆盖子串
java·开发语言·c++·算法·leetcode·哈希算法
嘉琪0012 小时前
前端数组核心方法(高级视角 + 场景 + 精简)——————2026 0309
开发语言·前端·javascript
怪侠_岭南一只猿2 小时前
爬虫阶段一实战练习题二:爬取当当网图书列表
css·爬虫·python·html