写给 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 UPDATE CAS、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 锁,不确定多久就用看门狗。就这么简单!

更多推荐