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

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

相关推荐
都叫我大帅哥2 分钟前
🌈 深入浅出Java Ribbon:微服务负载均衡的艺术与避坑大全
java·spring cloud
梅落几点秋.31 分钟前
java-字符串和集合
java·开发语言
都叫我大帅哥40 分钟前
阿里巴巴Sentinel:高可用防护的瑞士军刀
java·spring cloud
二向箔reverse1 小时前
Linux 文件操作命令大全:从入门到精通的实用指南
java·linux·服务器
万能小锦鲤1 小时前
《大数据技术原理与应用》实验报告三 熟悉HBase常用操作
java·hadoop·eclipse·hbase·shell·vmware·实验报告
努力的小郑1 小时前
深入剖析异常日志:为什么你该立刻告别 `e.printStackTrace()` ?
java·apache log4j
都叫我大帅哥1 小时前
Nacos全解:从微服务管家到AI协作者,一篇让你笑中带悟的指南
java·spring cloud
秋千码途1 小时前
小架构step系列14:白盒集成测试原理
java·架构·集成测试
佛说"獨"1 小时前
Docker swarm集群部署,包含compose.yml文件详情
java·docker·容器
Bella_chene1 小时前
IDEA 中使用 <jsp:useBean>动作指令时,class属性引用无效
java·intellij-idea·jsp·java web开发