一、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实现原子操作
}
}
AtomicInteger
的incrementAndGet()
方法基于 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();
}
}
死锁的四个必要条件:
- 互斥条件:资源不能被共享
- 请求和保持条件:线程已持有至少一个资源,又请求新资源
- 不剥夺条件:线程已获得的资源,在未使用完之前,不能被剥夺
- 循环等待条件:若干线程形成头尾相接的循环等待资源关系
预防死锁:
- 按固定顺序获取锁
- 设置锁超时时间
- 减少锁的持有时间
二、分布式系统中的锁: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);
}
}
问题分析:
- 死锁风险:若客户端获取锁后崩溃,锁永远不会释放
- 锁误释放:若客户端 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);
}
}
改进点:
- 原子操作 :使用
SET key value NX EX timeout
原子地创建锁并设置过期时间 - 唯一标识:每个客户端生成唯一 requestId 作为锁的值,避免误释放
- 原子释放:使用 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 节点机制) |
锁释放机制 | 依赖过期时间 | 客户端会话结束自动释放 |
三、锁的最佳实践
- 选择合适的锁 :
- 单 JVM 内:优先使用
synchronized
或ReentrantLock
- 分布式系统:优先使用 Redis 分布式锁(性能高)或 ZooKeeper 分布式锁(可靠性高)
- 单 JVM 内:优先使用
- 控制锁粒度 :
- 只锁关键代码,避免锁范围过大影响性能
- 读写分离场景使用读写锁
- 防范死锁 :
- 按固定顺序获取锁
- 使用带超时的锁获取方法
- 监控与报警 :
- 监控锁的持有时间和竞争情况
- 设置锁超时报警,及时发现异常
- 考虑性能开销 :
- 分布式锁比 JVM 内锁性能低很多,避免频繁加锁解锁
四、总结
锁是并发编程中的重要工具,但使用不当会带来性能问题和死锁风险。理解各种锁的适用场景和实现原理,是写出高质量并发代码的关键。
- JVM 内锁:简单高效,适合单进程内的线程同步
- Redis 分布式锁:高性能,适合高并发场景,需注意锁丢失问题
- ZooKeeper 分布式锁:高可靠性,适合对锁可靠性要求极高的场景
根据业务需求选择合适的锁机制,并遵循最佳实践,才能在保证数据一致性的同时,获得良好的性能。