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

一、JVM 内的锁机制

1. 为什么需要锁?

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

java 复制代码
public class Counter {
    private int count = 0;

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

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

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

2. synchronized 关键字

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

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接口,提供更灵活的锁控制:

java 复制代码
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

适用于读多写少的场景:

java 复制代码
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 类

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

java 复制代码
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)实现,本质是:

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

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

6. 死锁问题

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

java 复制代码
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 内的锁无法跨进程工作:

java 复制代码
// 多个服务实例可能同时执行这段代码
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 基础版本(有缺陷)

java 复制代码
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 改进版:带唯一标识和过期时间

java 复制代码
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 中实现可重入锁,需要在锁的值中记录线程标识和重入次数:

java 复制代码
// 可重入锁的简单实现思路
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 添加依赖

xml 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.16.2</version>
</dependency>

4.2 配置 Redisson 客户端

java 复制代码
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 使用分布式锁

java 复制代码
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 分布式锁:高可靠性,适合对锁可靠性要求极高的场景

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

相关推荐
苹果醋326 分钟前
Deep Dive React 4 How does React State actually work
java·运维·spring boot·mysql·nginx
嫩萝卜头儿31 分钟前
深入理解 Java AWT Container:原理、实战与性能优化
java·python·性能优化
蓝倾9761 小时前
唯品会以图搜图(拍立淘)API接口调用指南详解
java·大数据·前端·数据库·开放api接口
考虑考虑1 小时前
JDK17随机数生成
java·后端·java ee
D_alyoo1 小时前
Activiti 中各种 startProcessInstance 接口之间的区别
java·activiti
斯普信专业组1 小时前
k8s调度问题
java·容器·kubernetes
慕y2741 小时前
Java学习第一百一十一部分——Jenkins(二)
java·开发语言·学习·jenkins
草履虫建模1 小时前
RuoYi OpenAPI集成从单体到微服务改造全过程记录
java·运维·vue.js·spring cloud·微服务·云原生·架构
Fireworkitte2 小时前
接口为什么要设计出v1和v2
java·服务器
bin91532 小时前
解锁Java开发新姿势:飞算JavaAI深度探秘 #飞算JavaAl炫技赛 #Java开发
java·人工智能·python·java开发·飞算javaai·javaai·飞算javaal炫技赛