写给 Java 新手的锁机制指南,用最通俗的语言和代码,搞懂锁到底是干嘛的、怎么用。


目录

  1. 为什么需要锁?

  2. 悲观锁:先上锁再干活

  3. 乐观锁:先干活,有冲突再重试

  4. 分布式锁:多台机器怎么办?

  5. 看门狗机制:锁过期了业务还没做完怎么办?

  6. 一句话总结:什么时候用什么锁?


1. 为什么需要锁?

先看一个生活中的例子:

你和室友共用一个冰箱,里面只剩 1 瓶可乐。 你打开冰箱看到有可乐,去拿杯子。 同时室友也打开冰箱看到有可乐,也去拿杯子。 你回来拿走了可乐,室友回来发现可乐没了——傻眼了

这就是超卖问题。在程序里也一样:

// 库存只剩 1 件
int stock = 1;
​
// 线程A读到 stock=1
// 线程B也读到 stock=1
// 线程A扣减:stock = 0,下单成功
// 线程B扣减:stock = -1,也下单成功了!→ 超卖了!
​
stock = stock - 1;  // 两个线程同时执行这行,结果是0而不是-1?不一定!

锁就是解决这个问题的:同一时刻只允许一个人操作库存,其他人排队等。


2. 悲观锁:先上锁再干活

什么是悲观锁?

悲观锁的想法很"悲观":它认为只要有人来抢,就一定会出问题。所以它先锁住资源,操作完再放开,期间其他人只能等着。

就像你去上厕所,先把门锁上,用完再开门。别人看到门锁着就只能等。

代码示例

方式1:synchronized 关键字(最简单)
public class Warehouse {
​
    private int stock = 100; // 库存100件
​
    // synchronized 会让方法同一时间只能被一个线程执行
    public synchronized boolean deduct(int quantity) {
        if (stock >= quantity) {
            stock -= quantity;
            System.out.println("扣减成功,剩余库存: " + stock);
            return true;
        }
        System.out.println("库存不足,当前库存: " + stock);
        return false;
    }
​
    public static void main(String[] args) {
        Warehouse warehouse = new Warehouse();
​
        // 模拟100个人同时抢购
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                warehouse.deduct(1);
            }).start();
        }
        // 结果:库存永远不会变成负数
    }
}

简单理解:加了 synchronized 的方法,同一时间只能有一个人进去执行,其他人排队等。

方式2:ReentrantLock(更灵活)
import java.util.concurrent.locks.ReentrantLock;
​
public class Warehouse {
​
    private int stock = 100;
    private ReentrantLock lock = new ReentrantLock();
​
    public boolean deduct(int quantity) {
        lock.lock();  // 上锁
        try {
            if (stock >= quantity) {
                stock -= quantity;
                return true;
            }
            return false;
        } finally {
            lock.unlock();  // 一定要在 finally 里解锁,否则出异常就死锁了
        }
    }
}

ReentrantLock 比 synchronized 多了几个本事

  • 可以尝试获取锁,获取不到就不等了(tryLock()

  • 可以设置等待时间(等 3 秒获取不到就放弃)

  • 可以中断等待(不想等了可以取消)

// 试试能不能拿到锁,拿不到就算了
if (lock.tryLock()) {
    try {
        // 拿到锁了,干活
    } finally {
        lock.unlock();
    }
} else {
    // 没拿到锁,做别的事情
}
​
// 最多等3秒
if (lock.tryLock(3, TimeUnit.SECONDS)) {
    try {
        // 3秒内拿到了锁
    } finally {
        lock.unlock();
    }
} else {
    // 3秒都没拿到,放弃
}
方式3:数据库悲观锁(SELECT FOR UPDATE)

在数据库层面加锁,适合需要锁住数据库记录的场景。

-- 先锁定这条记录(别人改不了也删不了)
BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE;
​
-- 在锁住的状态下修改
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;  -- 提交后锁才释放
@Transactional
public boolean deductStock(Long productId, int quantity) {
    // SELECT FOR UPDATE 会锁住这行记录
    Product product = productMapper.selectForUpdate(productId);
​
    if (product.getStock() >= quantity) {
        productMapper.updateStock(productId, product.getStock() - quantity);
        return true;
    }
    return false;
}

悲观锁的特点

  • 一定会锁住资源,别人必须等

  • 安全,不会超卖

  • 性能稍差(要等锁)

  • 适合写操作多的场景


3. 乐观锁:先干活,有冲突再重试

什么是乐观锁?

乐观锁的想法很"乐观":它认为大家同时抢同一件东西的概率很小。所以它先去操作,操作完再检查有没有人跟我抢过,如果有就重试。

就像你去食堂打饭,你看到红烧肉还有,直接说"来一份"。如果刚好被前面的人打完了,你就重新看看还有什么菜。

代码示例

方式1:CAS 机制(Compare And Swap)

CAS 是乐观锁的核心,意思是比较并交换

CAS(内存地址, 期望值, 新值):
  如果内存里的值 == 期望值,就改成新值(成功)
  如果不等于,说明被人改过了,不改了(失败,重试)
import java.util.concurrent.atomic.AtomicInteger;
​
public class Warehouse {
​
    // AtomicInteger 是 Java 提供的线程安全整数
    private AtomicInteger stock = new AtomicInteger(100);
​
    public boolean deduct(int quantity) {
        while (true) {
            int current = stock.get();  // 读取当前值
            if (current < quantity) {
                return false;  // 库存不足
            }
            // CAS:如果当前值还是 current,就改成 current - quantity
            if (stock.compareAndSet(current, current - quantity)) {
                return true;  // 扣减成功
            }
            // 如果失败了,说明有人抢先改了,循环重试
        }
    }
}

为什么不用加锁? 因为 compareAndSet 是 CPU 指令级别的原子操作,硬件保证同一时间只有一个线程能成功。

方式2:数据库乐观锁(版本号)

在表里加一个 version 字段,每次更新都检查版本号。

-- 表结构
CREATE TABLE product (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100),
    stock INT,
    version INT DEFAULT 0  -- 版本号
);
// 查询时把版本号读出来
Product product = productMapper.selectById(1);
// stock=100, version=0
​
// 更新时检查版本号是否还是0
int affected = productMapper.update(
    "UPDATE product SET stock=stock-1, version=version+1 " +
    "WHERE id=1 AND version=0"
);
​
if (affected > 0) {
    // 更新成功,没人跟我抢
} else {
    // affected=0,说明版本号变了,有人改过了,重试!
}

通俗理解

  1. 你看到商品库存 100,版本号是 0

  2. 你准备扣减库存

  3. 更新的时候告诉数据库:"只有版本号还是 0 的时候才帮我改"

  4. 如果版本号被别人改成 1 了,说明有人抢先了,你就重试

乐观锁的特点

  • 不锁资源,先操作再检查

  • 性能好(不用等锁)

  • 适合读多写少、冲突少的场景

  • 冲突多的时候会反复重试,反而浪费性能


4. 分布式锁:多台机器怎么办?

为什么需要分布式锁?

前面说的 synchronizedReentrantLock 只在一台机器内有效。

现在你的系统部署在 3 台服务器上:

用户请求 → 负载均衡 → 服务器1(synchronized 锁住了)
         → 负载均衡 → 服务器2(也 synchronized,但这是另一把锁!)
         → 负载均衡 → 服务器3(也 synchronized,又是另一把锁!)

三台机器各锁各的,根本没用!库存还是会超卖。

分布式锁就是让所有服务器共用同一把锁,通常用 Redis 或 Zookeeper 实现。

方式1:Redis 分布式锁(用 Redisson)

Redisson 是操作 Redis 的 Java 工具,已经封装好了分布式锁。

Maven 依赖

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.27.0</version>
</dependency>

代码示例

import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
​
public class DistributedWarehouse {
​
    private static RedissonClient redisson;
    private int stock = 100;
​
    static {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        redisson = Redisson.create(config);
    }
​
    public boolean deduct(int quantity) {
        // 获取分布式锁(所有服务器共用这一把锁)
        RLock lock = redisson.getLock("warehouse:stock");
​
        lock.lock();  // 上锁
        try {
            if (stock >= quantity) {
                stock -= quantity;
                return true;
            }
            return false;
        } finally {
            lock.unlock();  // 解锁
        }
    }
}

原理很简单:往 Redis 里存一个 key 作为锁,谁存成功了谁就拿到锁。

服务器1:SET lock_key "我拿到了" NX  →  成功!我干活
服务器2:SET lock_key "我拿到了" NX  →  失败!key已存在,我等着
服务器3:SET lock_key "我拿到了" NX  →  失败!key已存在,我等着
​
服务器1干完活:DEL lock_key  →  锁释放了
服务器2:SET lock_key "我拿到了" NX  →  成功!轮到我了

分布式锁的特点

  • 所有服务器共用一把锁

  • 安全,不会超卖

  • 有网络开销,性能比本地锁差

  • 适合集群部署的场景


5. 看门狗机制:锁过期了业务还没做完怎么办?

问题:锁过期了

分布式锁通常会设置一个过期时间(比如 30 秒),防止服务器宕机后锁永远不释放(死锁)。

但问题是:如果业务执行超过 30 秒,锁自动过期了,其他服务器就进来了,这时候又会超卖!

服务器1:拿到锁,开始处理(预计30秒能完成)
第30秒:锁自动过期了!业务还没做完!
服务器2:拿到锁,也开始处理
结果:两台服务器同时在操作库存 → 超卖!

解决方案:看门狗

看门狗就像一个保安,每隔一段时间检查一下你干完活没有。如果没干完,就帮你把锁的过期时间延长。

服务器1:拿到锁(30秒过期)
第10秒:看门狗检查 → 还没干完 → 续期到30秒后
第20秒:看门狗检查 → 还没干完 → 续期到30秒后
第25秒:业务干完了 → 主动释放锁 → 看门狗下班

Redisson 的看门狗

好消息:Redisson 自带看门狗,你不需要自己实现!

public boolean deduct(int quantity) {
    RLock lock = redisson.getLock("warehouse:stock");
​
    // 不指定过期时间 → 自动启用看门狗
    lock.lock();
    try {
        // 不管执行多久,锁都不会过期
        // 看门狗每10秒续期一次,续期到30秒后
        doSomeLongTimeWork();
        return true;
    } finally {
        lock.unlock();  // 干完了释放锁
    }
}

对比一下

// 方式1:不指定过期时间 → 有看门狗,锁不会过期
lock.lock();
​
// 方式2:指定过期时间 → 没有看门狗,30秒后锁自动过期
lock.lock(30, TimeUnit.SECONDS);
​
// 方式3:尝试获取锁,最多等5秒,10秒后过期 → 没有看门狗
lock.tryLock(5, 10, TimeUnit.SECONDS);

建议

  • 业务执行时间不确定 → 用 lock.lock(),让看门狗自动续期

  • 业务执行时间很短且确定 → 用 lock.lock(10, TimeUnit.SECONDS),不需要看门狗


6. 一句话总结:什么时候用什么锁?

一张图看懂

需要加锁吗?
    │
    ├── 单台机器就能搞定?
    │   ├── 是 → 用 synchronized 或 ReentrantLock(悲观锁)
    │   └── 读多写少?→ 用 ReadWriteLock
    │
    └── 多台机器一起干?
        └── 是 → 用分布式锁(Redis/Zookeeper)

对比表

悲观锁乐观锁分布式锁
一句话先锁再干先干再说多台机器共用一把锁
实现synchronized、ReentrantLock、SELECT FOR UPDATECAS、Atomic、版本号Redis、Zookeeper
性能要等锁,稍慢不等锁,快有网络开销,最慢
安全最安全冲突少时安全安全
适合场景写操作多、冲突多读多写少、冲突少集群部署
典型场景转账、扣库存计数器、缓存更新秒杀、分布式系统

最佳实践

  1. 锁的范围要小:只锁必要的代码,不要锁整个方法

  2. 一定要在 finally 里解锁:不然出异常就死锁了

  3. 分布式锁一定要设过期时间:防止服务器宕机后锁永远不释放

  4. 不确定执行多久就用看门狗:让 Redisson 自动续期

// ❌ 错误示范:锁太大,性能差
public synchronized void process() {
    readData();      // 不需要锁
    updateData();    // 需要锁
    sendEmail();     // 不需要锁
}
​
// ✅ 正确示范:只锁必要的部分
public void process() {
    readData();
    synchronized (this) {
        updateData();
    }
    sendEmail();
}

记住一句话:单机用 synchronized,集群用 Redis 锁,不确定多久就用看门狗。就这么简单!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐