SheepDog1998

博客园 首页 新随笔 联系 订阅 管理

Java 并发编程:ReentrantLock 公平锁与非公平锁深度解析

前言

在 Java 并发编程中,ReentrantLock 是 synchronized 关键字之外最常用的互斥锁实现。
 
相比 synchronized,它更加灵活:可以手动加锁解锁尝试加锁响应中断
 
还可以选择公平模式非公平模式
 
但很多开发者在使用时只写 new ReentrantLock(),没有仔细想过 "非公平" 意味着什么,
 
以及什么时候必须用公平锁,什么时候应该用非公平锁。
 
本文从原理、应用场景、性能对比、选型指南四个维度,带你彻底吃透这对概念。

一、什么是 ReentrantLock

java.util.concurrent.locks.ReentrantLock 是 JDK 提供的可重入互斥锁。
 
它的核心特性:
  • 可重入:同一个线程可以多次获得同一把锁(计数 +1),必须释放相同次数
  • 可中断:支持 lockInterruptibly(),等待锁的线程可以被中断
  • 尝试加锁:支持 tryLock() / tryLock(timeout),拿不到就返回
  • 公平 / 非公平:构造参数决定排队策略
 
// 默认非公平
ReentrantLock lock1 = new ReentrantLock();
// 显式非公平
ReentrantLock lock2 = new ReentrantLock(false);
// 公平锁
ReentrantLock lock3 = new ReentrantLock(true);
 
它的内部维护一个等待队列,线程来抢锁时根据 "公平 / 非公平" 规则决定谁先拿到。

二、非公平锁(Nonfair Sync)

2.1 工作方式

不排队,谁抢到算谁。
 
当一个线程尝试获取锁时:
  1. 先尝试一次 CAS 抢锁
  2. 抢到就执行临界区
  3. 抢不到才进入等待队列尾部
注意:并不是锁释放的方法内部主动给新线程插队机会;
 
当锁释放 state 置为 0,队列中的等待线程还未被唤醒时,如果新线程恰好执行 lock (),会直接 CAS 抢占 state,插队成功;队列里已经排队的线程继续阻塞等待。

2.2 例子

假设 .smwu 锁被线程 A 持有,B、C、D 已经在等待队列里。
时刻 1:A 持有锁,等待队列: B -> C -> D
时刻 2:A 释放锁的瞬间,新线程 E 也来抢锁
 
非公平锁下的执行顺序
         ┌─ E 直接插队抢锁 ─┐
         │                 ▼
释放锁 → 队列头: B  C  D   ←  E 抢到了
         │
         └─ 队列里 B/C/D 还在等
 
E 虽然后到,但因为 CAS 抢锁成功,B 反而被插队

2.3 优点

  • 吞吐量大:减少线程切换开销
  • 适合锁持有时间极短的场景(CAS 成功率很高)

2.4 缺点

  • 可能 "饿死":B 如果运气差,被 E、F、G 一直插队,永远轮不到
  • 不保证 FIFO

三、公平锁(Fair Sync)

3.1 工作方式

严格按申请顺序排队(FIFO),新来的线程不能插队,必须排到队尾。
 
当一个线程尝试获取锁时:
  1. 通过 hasQueuedPredecessors() 判断队列是否存在排在自己前面的等待线程
  2. 如果存在前驱等待线程,直接进入队列尾部排队;只有无前驱时,才允许尝试 CAS 获取锁
  3. 锁释放时,唤醒队列头部的线程
边界:可重入场景,如果当前线程本身就是队列头节点,允许直接获取锁,不需要排队。

3.2 同样场景

时刻 1:A 持有锁,等待队列: B -> C -> D
时刻 2:A 释放锁的瞬间,新线程 E 也来抢锁
 
公平锁下的执行顺序
释放锁 → 锁交给 B(B 先到,理所当然)
         队列变成: C -> D -> E
 
E 只能乖乖排到队尾,不能插队。

3.3 优点

  • 不会饿死:先到的一定先服务,FIFO 极大降低饥饿概率
  • 可预测:知道每个线程大致的等待时间
补充:公平锁是应用层 FIFO 排队,受操作系统线程调度优先级影响,不能做到绝对时序保障

3.4 缺点

  • 吞吐量略低:每次释放锁都要唤醒队头,带来一定线程切换成本
  • 但在 "锁持有时间较长" 的场景下,这点开销基本可以忽略

四、原理对比

4.1 AQS 队列

ReentrantLock 内部基于 AbstractQueuedSynchronizer(AQS)实现。
 
AQS 维护一个变体 CLH 双向阻塞队列;原版 CLH 为自旋锁,AQS 使用 LockSupport.park() 实现线程阻塞,队列中每个 Node 节点代表一个等待线程。
  • 非公平锁:线程先尝试 CAS 修改 state,失败才入队
  • 公平锁:线程先判断是否存在前驱等待节点,有就直接入队

4.2 源码核心差异

// 非公平 lock
final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

// 公平 lock
final void lock() {
    acquire(1);
}

// 公平 tryAcquire
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&  // 关键:有前驱等待线程就放弃抢锁
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // ... 可重入逻辑
}
 
关键差异就在 hasQueuedPredecessors()
  • 非公平:直接 CAS 抢,不管队列
  • 公平:发现队列里有排在自己前面的线程,就老老实实排队
⚠️重要隐藏坑:公平锁实例调用无参 tryLock(),会无视公平策略直接 CAS 抢锁,可以插队!
  • lock() / lockInterruptibly() / tryLock(timeout, unit):遵守公平规则
  • tryLock()(无参):无论公平 / 非公平实例,直接抢锁,不做排队判断
 
ReentrantLock fairLock = new ReentrantLock(true);
fairLock.tryLock();                 // ❗无视公平策略,可插队
fairLock.tryLock(5, TimeUnit.SECONDS); // ✅遵守公平排队
fairLock.lock();                    // ✅遵守公平排队
 

五、性能对比

很多人有个误解:"公平锁一定比非公平慢很多"。其实不一定。

5.1 锁持有时间极短时(ns 级)

非公平锁:吞吐量 100%
公平锁  :吞吐量 70% 左右
差异明显,因为唤醒队头的开销占大头。
 

5.2 锁持有时间较长时(ms/s 级)

非公平锁:吞吐量 100%
公平锁  :吞吐量 95% 左右
差异很小,因为唤醒队头的开销相对临界区可忽略。
 

5.3 性能压测参考

注:压测数据为理论参考值,不同 CPU、JDK 版本实际 QPS 会浮动。
 
临界区耗时 线程数 公平锁 QPS 非公平锁 QPS 差异
100ns 16 800 万 1100 万 ~30%
1μs 16 90 万 110 万 ~20%
10μs 16 9 万 10 万 ~10%
100μs 16 9000 9500 ~5%
1ms 16 900 920 ~2%
10ms+ 16 ~ 持平 ~ 持平 <1%
结论:临界区越长,公平锁的性能损失越小。

六、应用场景

6.1 选非公平锁的场景

特点:锁持有时间极短,争用激烈但单次持有时间可以忽略。

场景 A:内存计数器

private final ReentrantLock lock = new ReentrantLock();
public void increment() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}
 
  • count++ 一行纳秒级操作
  • 唤醒队头线程的开销比这次操作本身还大
  • 谁先抢到无所谓,反正所有线程都加一次

场景 B:缓存击穿保护

public Object get(String key) {
    Object v = cache.get(key);
    if (v == null) {
        lock.lock();
        try {
            v = cache.get(key);
            if (v == null) {
                v = db.query(key);
                cache.put(key, v);
            }
        } finally {
            lock.unlock();
        }
    }
    return v;
}
 
  • 重建缓存的等待时间主要花在 DB 查询上
  • 锁本身持有时间相对较短
  • 用公平锁反而拖慢整体响应

场景 C:短临界区资源池

// 数据库连接池、线程池内部的任务队列
// 锁保护的就是"取出/放入一个对象"的动作
 
  • 临界区是 queue.poll() / queue.offer() 一次操作
  • 公平锁的唤醒开销不划算

6.2 选公平锁的场景

特点:锁持有时间长,绝对不能容忍 "先到的一直拿不到"。
提示:公平锁仅保证排队顺序,不能解决任务堆积问题,业务上游仍需要做任务限流,避免大量任务堆积在锁队列。

场景 A:超图 .smwu 出图

private final Map<String, ReentrantLock> workspaceLockMap = new ConcurrentHashMap<>();
ReentrantLock lock = new ReentrantLock(true); // 公平锁
// lock.lockInterruptibly(); // 获取锁过程响应线程中断,无超时,会永久阻塞
// doMap(...);   // 出图 5~15 秒
// lock.unlock();
 
  • 每张图出图要 5~15 秒(栅格裁剪 + DEM 统计 + 渲染 + IO)
  • 必须保证 "先启动的图先出图"
  • 否则后启动的图全部插队到前面,先到的图会等满 60s 超时
⚠️业务风险提醒:lockInterruptibly() 没有超时能力,如果某个任务卡死没有 unlock,所有后续任务会永久阻塞;如果需要保留原有 60s 超时逻辑,应当使用带超时的 tryLock(60, TimeUnit.SECONDS)

场景 B:文件 / 打印机独占

// 多个任务往同一文件写日志
// 多个任务调用同一台打印机
 
  • 写一次日志可能要几毫秒到几秒
  • 公平排队能让每个任务有可预测的等待时间

场景 C:交易 / 订单处理

// 同一账户的多个并发扣款
// 必须按用户请求的顺序处理
 
  • 业务上严格要求时序
  • "A 先下单应该先扣款,结果 B 先扣了" 是不能接受的

场景 D:限流代理

// 本地缓存代理外部限流接口(每秒只能调 10 次)
// 所有调用方都要排队
 
  • 锁内要做 "等令牌 → 调外部 → 解析"
  • 业务要求 "先到先调"

七、决策树

锁内操作时间?
├── 极短(ns/μs)
│   └── 业务能容忍饿死吗?
│       ├── 能 → 非公平锁
│       └── 不能 → 公平锁(或换无锁方案)
│
└── 较长(ms/s)
    └── 业务能容忍饿死吗?
        ├── 能 → 非公平锁(性能稍好)
        └── 不能 → 公平锁(推荐)
 
更简洁的版本:
如果你在乎 "先到先服务",就用公平锁。
 
如果你只在乎 "整体吞吐最大",就用非公平锁。

八、选型对照表

场景 推荐 理由
内存计数器、缓存重建 非公平 临界区极短,吞吐优先
出图、报表、PDF 生成 公平 持有时间长,绝不能饿死
文件独占写 公平 持有时间中等,要求有序
订单 / 账户处理 公平 业务要求时序
短 CAS 保护 非公平 临界区 ns 级
限流代理、外部资源串行调用 公平 持有时间取决于外部,不可预测
数据库连接池内部 非公平 临界区极短
多线程出同一份 .smwu 公平 持有时间长,业务强一致

九、实战案例:.smwu 出图为什么必须用公平锁

9.1 业务背景

地震应急出图系统,多个专题图(居民点、地质灾害、震中等)共用同一份 gsdzyjct.smwu 工作空间。
 
iDesktop Java SDK 不支持多线程并发访问同一 .smwu,必须串行。
 
旧代码用 synchronized + tryLock(60s):排队是 "看谁先抢到",不是 "看谁先来"。

9.2 时间线复盘

08:53:15.855  Earthquake_epicenter 启动
08:53:15.855  Earthquake_epicenter 等待锁(先到)
08:53:18.XXX  EQ_Station 启动
08:53:18.XXX  EQ_Station 等待锁(后到)
08:53:38.725  EQ_Station 拿到锁(先释放的先抢)
08:53:38.725  Earthquake_epicenter 还在等
08:53:50.XXX  EQ_Fracture 启动(更晚到)
08:53:50.XXX  EQ_Fracture 直接抢到锁(插队!)
08:53:50.XXX  Earthquake_epicenter 还在等
08:54:00.865  EQ_Fracture 释放
08:54:15.862  Earthquake_epicenter 60s 超时放弃
08:54:24.XXX  EQ_Traffic 才完成(最后才轮到)
 

9.3 问题分析

  1. Earthquake_epicenter 是先到的,应该优先服务
  2. 但因为非公平,后到的 EQ_Fracture、EQ_Traffic 反而先抢到
  3. Earthquake_epicenter 一直等不到,60s 超时后被丢弃,这张图就缺了

9.4 公平锁改造

方案一:lockInterruptibly,无超时,会永久阻塞
ReentrantLock lock = new ReentrantLock(true);  // 公平锁
lock.lockInterruptibly();                       // 获取锁过程响应线程中断,无超时,会永久阻塞
try {
    doMapInner(...);                            // 出图
} finally {
    lock.unlock();
}
 
方案二【业务推荐】:保留原有 60s 超时,公平锁下带超时 tryLock,兼顾排队与超时防护
ReentrantLock lock = new ReentrantLock(true);
boolean locked = lock.tryLock(60, TimeUnit.SECONDS);
if (!locked) {
    throw new TimeoutException("获取工作空间锁超时60s");
}
try {
    doMapInner(...);
} finally {
    lock.unlock();
}
 
改造后时序:
08:53:15.855  Earthquake_epicenter 入队
08:53:18.XXX  EQ_Station 入队
08:53:50.XXX  EQ_Fracture 入队
释放锁后按顺序出锁:
  1. Earthquake_epicenter(先到先服务)✅
  2. EQ_Station
  3. EQ_Fracture
  ...
每张图最终都会轮到,不会饿死。
 

十、常见误区

误区 1:公平锁一定慢

错。锁持有时间越长,公平锁的性能损失越小。
 
在 "ms/s 级" 临界区下,公平锁与非公平锁的性能差异 < 5%。

误区 2:默认就用非公平

不一定。JDK 默认非公平是因为 "大部分场景临界区短、吞吐优先"。
 
但在你的业务场景下,必须根据业务特性来选。

误区 3:公平锁就一定好

错。公平锁也有适用场景,不是银弹。
 
短临界区用公平锁反而拖慢整体性能。

误区 4:synchronized 是公平锁

错。synchronized 是非公平锁,不能保证 FIFO。HotSpot 的 monitor 锁队列没有公平开关。

误区 5:公平锁实例所有 API 都遵守公平排队

错。公平锁实例调用无参 tryLock() 直接 CAS 抢锁,可以插队
 
只有 lock() / lockInterruptibly() / tryLock(time,unit) 才执行公平判断。

十一、总结

短临界区 + 不在乎顺序 = 非公平;长临界区 + 必须有序 = 公平。
选用原则就两句话:
  1. 在乎 "先到先服务" → 公平锁
  2. 在乎 "整体吞吐最大" → 非公平锁
你的 .smwu 出图属于 "长临界区 + 必须有序",必须用公平锁
 
非公平锁在这种场景下不是性能优化,而是bug
额外业务提醒:
  1. 公平锁不等于不会阻塞,长临界区业务务必加上锁等待超时,防止任务卡死导致全量阻塞;
  2. 警惕无参 tryLock() 破坏公平语义;
  3. 公平锁只保证应用层排队顺序,上游需要限流,避免任务大量堆积锁队列。

附录:核心代码片段

A. 公平锁的标准用法

public class FairLockDemo {
    private final ReentrantLock lock = new ReentrantLock(true);
    public void doWork() {
        lock.lock();
        try {
            // 临界区
        } finally {
            lock.unlock();
        }
    }
}
 

B. .smwu 出图的公平锁改造(推荐带超时版本)

private void doMap(String eqId, String eqTaskId, String filePath, String mapType,
                   String eqTime, String eqAddr, String eqLon, String eqLat,
                   String eqDepth, String eqMag, String mapTitle,
                   Map<String, Object> mapStyle, Workspace workspace, String workSpacePath) throws TimeoutException, InterruptedException {
    ReentrantLock lock = workspaceLockMap.get(workSpacePath);
    if (lock == null) {
        synchronized (workspaceLockMap) {
            lock = workspaceLockMap.get(workSpacePath);
            if (lock == null) {
                lock = new ReentrantLock(true); // 公平锁
                workspaceLockMap.put(workSpacePath, lock);
            }
        }
    }
    boolean locked = false;
    try {
        log.info("【{}】等待工作空间锁 | path={}", mapType, workSpacePath);
        // 公平锁,最多等待60秒,响应中断
        locked = lock.tryLock(60, TimeUnit.SECONDS);
        if (!locked) {
            throw new TimeoutException("获取工作空间锁超时60s, path:" + workSpacePath);
        }
        log.info("【{}】拿到工作空间锁 | path={}", mapType, workSpacePath);
        doMapInner(eqId, eqTaskId, filePath, mapType, eqTime, eqAddr,
                eqLon, eqLat, eqDepth, eqMag, mapTitle, mapStyle, workspace, workSpacePath);
    } finally {
        if (locked) {
            lock.unlock();
        }
    }
}
 

C. 锁关键字纳入可恢复异常白名单

private boolean isRecoverableException(Throwable t) {
    if (t == null) return false;
    String msg = t.getMessage();
    if (msg == null) return false;
    String low = msg.toLowerCase();
    return low.contains("workspace")
        || low.contains("datasource")
        || low.contains("timeout");
        // 提示:线上环境异常栈多为英文,不建议匹配中文关键字"锁、工作空间",会出现匹配失效
}
 

 

 

posted on 2026-08-20 17:16  SheepDog1998  阅读(19)  评论(0)    收藏  举报