从 JVM 锁到 Redis 分布式锁:Java 并发编程全面指南

490 阅读9分钟

一、JVM 内的锁机制

1. 为什么需要锁?

在多线程环境中,多个线程同时操作共享资源(如计数器、缓存)时,可能会出现数据不一致的问题。例如:

public class Counter {
    private int count = 0;

    public void increment() {
        count++; // 非原子操作:读值->加1->写回
    }
}

若多个线程同时调用increment(),可能导致最终结果小于预期值。这是因为count++不是原子操作,可能被多个线程交叉执行。

3e94bb264efd25cbf4a360c9b280aa2.png 锁的作用是让多个线程「排队」访问共享资源,保证同一时间只有一个线程能执行关键代码。

2. synchronized 关键字

Java 最早提供的内置锁机制,使用简单:

public class SafeCounter {
    private int count = 0;

    // 修饰方法:锁住当前对象
    public synchronized void increment() {
        count++;
    }

    // 修饰代码块:锁粒度更细
    public void decrement() {
        synchronized (this) {
            count--;
        }
    }
}

synchronized的特点:

  • 自动加锁 / 解锁:进入同步块时自动加锁,退出时自动解锁
  • 可重入:同一线程可多次获取同一把锁
  • 悲观锁:假设一定有竞争,在每次操作前都会先加锁

3. ReentrantLock 显式锁

JDK 5 引入的Lock接口,提供更灵活的锁控制:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class SafeCounterWithLock {
    private int count = 0;
    private Lock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock(); // 必须在finally中释放锁
        }
    }
}

ReentrantLock的优势:

  • 可中断lockInterruptibly()允许线程在等待锁时被中断
  • 超时获取tryLock(long timeout, TimeUnit unit)避免无限等待
  • 公平锁new ReentrantLock(true)按请求顺序分配锁

4. 读写锁 ReentrantReadWriteLock

适用于读多写少的场景:

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Cache {
    private Object data;
    private ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private Lock readLock = rwLock.readLock();
    private Lock writeLock = rwLock.writeLock();

    // 允许多个线程同时读
    public Object get() {
        readLock.lock();
        try {
            return data;
        } finally {
            readLock.unlock();
        }
    }

    // 写操作独占锁
    public void set(Object newData) {
        writeLock.lock();
        try {
            data = newData;
        } finally {
            writeLock.unlock();
        }
    }
}

锁策略

  • 读锁(共享锁):允许多个线程同时获取
  • 写锁(排他锁):同一时间只能有一个线程获取
  • 读写互斥:写时禁止读,读时禁止写

5. 乐观锁与 Atomic 类

乐观锁假设冲突很少发生,不直接加锁,而是在更新时检查数据是否被修改:

import java.util.concurrent.atomic.AtomicInteger;

public class SafeCounterWithAtomic {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet(); // 基于CAS实现原子操作
    }
}

AtomicIntegerincrementAndGet()方法基于 CAS(Compare And Swap)实现,本质是:

// CAS伪代码
do {
    oldValue = getCurrentValue();
    newValue = oldValue + 1;
} while (!compareAndSet(oldValue, newValue));

CAS 是一种无锁算法,适用于冲突较少的场景,性能优于传统锁。

6. 死锁问题

死锁是指两个或多个线程互相持有对方需要的锁,导致所有线程都被阻塞:

public class DeadLockDemo {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {
        // 线程1:先拿A锁,再拿B锁
        new Thread(() -> {
            synchronized (lockA) {
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lockB) {
                    System.out.println("线程1获得两把锁");
                }
            }
        }).start();

        // 线程2:先拿B锁,再拿A锁
        new Thread(() -> {
            synchronized (lockB) {
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lockA) {
                    System.out.println("线程2获得两把锁");
                }
            }
        }).start();
    }
}

死锁的四个必要条件

  1. 互斥条件:资源不能被共享
  2. 请求和保持条件:线程已持有至少一个资源,又请求新资源
  3. 不剥夺条件:线程已获得的资源,在未使用完之前,不能被剥夺
  4. 循环等待条件:若干线程形成头尾相接的循环等待资源关系

预防死锁

  • 按固定顺序获取锁
  • 设置锁超时时间
  • 减少锁的持有时间

二、分布式系统中的锁:Redis 分布式锁

1. 为什么需要分布式锁?

在分布式系统中,多个服务实例可能同时操作共享资源(如库存扣减),JVM 内的锁无法跨进程工作:

// 多个服务实例可能同时执行这段代码
public void deductStock() {
    // JVM内的锁只能保证单实例内线程安全
    synchronized (this) {
        int stock = getStockFromDB();
        if (stock > 0) {
            updateStock(stock - 1);
        }
    }
}

分布式锁需要满足:

  • 互斥性:同一时间只有一个客户端能持有锁
  • 可重入性:同一客户端可多次获取同一把锁
  • 锁超时:防止死锁
  • 高可用:锁服务不能单点故障

2. Redis 分布式锁的基本实现

Redis 实现分布式锁主要基于两个特性:

  • SETNX(SET if Not eXists):原子地创建键值对
  • 过期机制:设置键的过期时间,避免死锁

2.1 基础版本(有缺陷)

import redis.clients.jedis.Jedis;

public class RedisLockBasic {
    private Jedis jedis;
    private static final String LOCK_KEY = "product_stock_lock";

    public RedisLockBasic(Jedis jedis) {
        this.jedis = jedis;
    }

    // 获取锁
    public boolean acquireLock() {
        // SETNX命令:如果键不存在,设置值并返回1;否则返回0
        Long result = jedis.setnx(LOCK_KEY, "locked");
        return result == 1;
    }

    // 释放锁
    public void releaseLock() {
        jedis.del(LOCK_KEY);
    }
}

问题分析

  1. 死锁风险:若客户端获取锁后崩溃,锁永远不会释放
  2. 锁误释放:若客户端 A 的锁过期自动释放,客户端 B 获取锁,此时 A 释放锁会误释放 B 的锁,如果此时有C要获取锁,那么C能成功的获取到锁,从而导致了并行问题

2.2 改进版:带唯一标识和过期时间

import redis.clients.jedis.Jedis;
import java.util.UUID;

public class RedisLockImproved {
    private Jedis jedis;
    private static final String LOCK_KEY = "product_stock_lock";
    private static final int LOCK_EXPIRE = 30 * 1000; // 锁过期时间30秒

    public RedisLockImproved(Jedis jedis) {
        this.jedis = jedis;
    }

    // 获取锁
    public String acquireLock() {
        String requestId = UUID.randomUUID().toString(); // 生成唯一标识
        // 使用SET命令,同时设置NX和EX选项(原子操作)
        String result = jedis.set(LOCK_KEY, requestId, "NX", "EX", LOCK_EXPIRE / 1000);
        return "OK".equals(result) ? requestId : null;
    }

    // 释放锁
    public boolean releaseLock(String requestId) {
        // 使用Lua脚本保证原子性:先判断锁是否是自己的,再释放
        String script = 
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "    return redis.call('del', KEYS[1]) " +
            "else " +
            "    return 0 " +
            "end";
        return jedis.eval(script, 1, LOCK_KEY, requestId).equals(1L);
    }
}

改进点

  1. 原子操作:使用SET key value NX EX timeout原子地创建锁并设置过期时间
  2. 唯一标识:每个客户端生成唯一 requestId 作为锁的值,避免误释放
  3. 原子释放:使用 Lua 脚本保证判断锁归属和释放锁的原子性 (如果判断锁和释放锁不是原子性,可能出现这样一种情况: 线程1在判断锁之后若因为JVM垃圾回收而导致阻塞,锁未能及时释放,而触发了超时释放锁,那么在另一个线程获取锁并执行相关业务时,此时线程1恢复,它会错误的释放线程2的锁)

3. Redis 分布式锁的进阶问题

3.1 锁过期时间如何设置?

如果业务执行时间超过锁的过期时间,会导致锁提前释放,出现并发问题。但设置过长的过期时间,又会增加死锁风险。

解决方案

  • 合理预估时间:根据业务执行时间,设置一个安全的过期时间(如 30 秒)
  • 自动续期:使用「看门狗」机制,在客户端获取锁后,启动一个后台线程定期延长锁的过期时间(如每 10 秒续期一次)

3.2 主从架构下的锁丢失问题

如果 Redis 是主从架构,当主节点获取锁后还没同步到从节点就挂了,从节点晋升为主节点,新的主节点上没有这个锁,其他客户端可能会再次获取到锁。

解决方案

  • RedLock 算法:使用多个独立的 Redis 实例(如 5 个),获取锁时需要在多数节点(至少 3 个)上成功获取锁才算成功。释放锁时,向所有节点释放。

3.3 可重入锁如何实现?

在 Redis 中实现可重入锁,需要在锁的值中记录线程标识和重入次数:

// 可重入锁的简单实现思路
public String acquireLockWithRetry(String requestId, int retryCount) {
    String currentValue = jedis.get(LOCK_KEY);
    if (requestId.equals(currentValue)) {
        // 如果是自己持有的锁,增加重入次数
        jedis.incr(LOCK_KEY + "_retry");
        return requestId;
    }
    
    // 尝试获取锁
    String result = jedis.set(LOCK_KEY, requestId, "NX", "EX", LOCK_EXPIRE / 1000);
    if ("OK".equals(result)) {
        jedis.set(LOCK_KEY + "_retry", "1"); // 初始化重入次数
        return requestId;
    }
    
    return null;
}

4. 使用 Redisson 框架简化开发

Redisson 是一个基于 Redis 的 Java 驻内存数据网格(In-Memory Data Grid),提供了分布式锁等丰富功能。

4.1 添加依赖

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

4.2 配置 Redisson 客户端

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedissonConfig {
    public static RedissonClient getClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        return Redisson.create(config);
    }
}

4.3 使用分布式锁

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;

public class RedissonLockDemo {
    private static final RedissonClient client = RedissonConfig.getClient();

    public static void main(String[] args) {
        RLock lock = client.getLock("product_stock_lock");
        
        try {
            // 尝试获取锁,最多等待100秒,锁持有时间30秒
            boolean isLocked = lock.tryLock(100, 30, java.util.concurrent.TimeUnit.SECONDS);
            if (isLocked) {
                try {
                    // 操作共享资源
                    System.out.println("获取到锁,执行业务逻辑");
                } finally {
                    lock.unlock(); // 释放锁
                }
            } else {
                System.out.println("获取锁失败,稍后重试");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

Redisson 的优势

  • 自动续期:内置看门狗机制,会自动延长锁的过期时间
  • 可重入:支持同一个线程多次获取同一把锁
  • 多种锁类型:提供公平锁、读写锁、联锁等多种锁类型
  • 集群支持:支持 Redis 单节点、主从、哨兵和集群模式

5. Redis 分布式锁 vs ZooKeeper 分布式锁

特性Redis 分布式锁ZooKeeper 分布式锁
性能高(基于内存操作)较低(需要写磁盘日志)
可靠性主从架构有锁丢失风险(需 RedLock)高(基于 Paxos 协议,leader 选举后锁状态一致)
实现复杂度中等(需处理过期时间、原子性等)较高(需理解 ZooKeeper 节点机制)
锁释放机制依赖过期时间客户端会话结束自动释放

三、锁的最佳实践

  1. 选择合适的锁
    • 单 JVM 内:优先使用synchronizedReentrantLock
    • 分布式系统:优先使用 Redis 分布式锁(性能高)或 ZooKeeper 分布式锁(可靠性高)
  2. 控制锁粒度
    • 只锁关键代码,避免锁范围过大影响性能
    • 读写分离场景使用读写锁
  3. 防范死锁
    • 按固定顺序获取锁
    • 使用带超时的锁获取方法
  4. 监控与报警
    • 监控锁的持有时间和竞争情况
    • 设置锁超时报警,及时发现异常
  5. 考虑性能开销
    • 分布式锁比 JVM 内锁性能低很多,避免频繁加锁解锁

四、总结

锁是并发编程中的重要工具,但使用不当会带来性能问题和死锁风险。理解各种锁的适用场景和实现原理,是写出高质量并发代码的关键。

  • JVM 内锁:简单高效,适合单进程内的线程同步
  • Redis 分布式锁:高性能,适合高并发场景,需注意锁丢失问题
  • ZooKeeper 分布式锁:高可靠性,适合对锁可靠性要求极高的场景

根据业务需求选择合适的锁机制,并遵循最佳实践,才能在保证数据一致性的同时,获得良好的性能。