Java 里锁的简单使用回顾

当代码中在多个线程中访问一个数据时,该数据就需要进行保护,保证在查询和修改时不会因为其它线程的操作而产生不可预料的异常。

下面简单总结了 Java 多线程开发中几个不同场景下的线程安全类和锁的使用样例。

JUC 工具包

对于临界资源访问,最先考虑使用的是 java.util.concurrent 包下的工具类,如 CountDownLatch、ConcurrentMap 和 BlockingQueue 等,还有一众原子类 AtomicInteger、AtomicBoolean 和 AtomicLong 等。

这些类的实现是线程安全的,所以可以很放心地在多线程环境中使用。

1
private final ConcurrentMap<String,CachedResource> resourceCache = new ConcurrentHashMap<>();

注意:虽说这些类是线程安全的,但指的是操作这些类的时候是线程安全的,并不是说取出来的数据是线程安全的。

取出来的数据在操作的时候和 ConcurrentMap 并无关系,所以当线程在修改这些数据时,其它线程可能也在修改同样的一个对象,此时可能就会有冲突。

这个时候就需要锁的帮忙。

Java 中的锁

Java 中的锁常分成两类,synchronized 锁和 JUC 包中的锁,这两种锁的实现方式不太相同,具体细节这里不深究,感兴趣的同学可以自行深入。总得来说,通常认为 synchronized 锁比 JUC 锁更重,JUC 锁更灵活,可以根据需要选择。

synchronized 锁

使用 synchronized 锁时,不要将这个关键字放在类方法上,这样加锁的范围太大,容易导致其它线程等待过久。

下面是错误的用法:

1
2
3
public static synchronized ClusterClient getClient() {
// ...
}

正确应该是

1
2
3
4
5
6
7
public static synchronized ClusterClient getClient() {
// ...

synchronized (ClusterClientBuilder.class) {
// ...
}
}

JUC 工具包中的锁

java.util.concurrent.locks 包下提供了一些非常常用的工具,如: ReentrantLockReentrantReadWriteLock 等。

使用 ReentrantLock 这些锁时,要特别注意的是解锁时间,最好是把解锁操作放到 finally 代码块中,防止临界区出现异常而导致跳过解锁而死锁。

1
2
3
4
5
6
reentrantLock.lock();
try {
// do sth
} finally {
reentrantLock.unlock();
}

分布式锁

当涉及到的数据不是内存中的数据,而是某个公共服务或数据库中的数据,那么单纯的内存锁就不能满足需求了。

常见的分布式锁主要有:数据库锁、Redis 锁和 Zookeeper 锁(etcd 锁)。

这几种锁的可靠程度、性能和实现难度都是不一样的。具体选用哪种实现应该取决于业务于数据一致性的要求。

  1. 如果对数据一致性要求非常高,优先选择 zk/etcd 方式实现的锁;
  2. 对于一般的一致性要求,可以选用 Redis 锁实现;
  3. 一般不建议使用数据库锁实现。

要使用分布式锁时,还是建议使用开源的成熟方案,而不是自己根据原理造轮子。自己造的轮子要满足重入、独占、等待、超时等功能并且代码健壮可靠不是件容易的事,很容易踩坑。

ZK

ZK 锁的原理是基于 ZK 中的临时顺序节点实现,这里简单看一下如何使用 Curator 框架来使用锁。

首先创建一个 Curator client,用于和 ZK 通信:

1
2
3
4
5
6
7
8
RetryPolicy policy = new ExponentialBackoffRetry(3000, 3);

CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString(connectString)
.connectionTimeoutMs(connectionTimeout)
.sessionTimeoutMs(sessionTimeout)
.retryPolicy(policy).build();
client.start();

使用的时候只需要创建一个 InterProcessMutex 实例就可以使用分布式锁,调用 acquire() 方法可以获取锁,release() 方法可以释放锁:

1
2
3
4
InterProcessLock lock = new InterProcessMutex(getCuratorFramework(), rootNode);

lock.acquire();
lock.release();

Redis

Redis 锁常用的组件是 redisson,该组件封装了众多接口,使用起来很方便。下面是官方文档的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RLock lock = redisson.getLock("myLock");

// 传统的取锁方法
lock.lock();

// 等待锁,取锁后 10s 自动解锁
lock.lock(10, TimeUnit.SECONDS);

// 等待 100s 获取锁,10s 后自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
try {
...
} finally {
lock.unlock();
}
}

数据库锁

对于数据库锁,基本是没有成熟的开源组件可以用。大多数都是根据业务场景来定制的,不过大致还可以分为两种类型:

  • 乐观锁
  • 悲观锁

数据库乐观锁

乐观锁是假定操作时无其它人同时操作,直接将操作提交到数据库处理,如果处理出现冲突就说明有争用。

乐观锁也可以通过在变更数据时指定数据版本号实现,如:

1
update data set field=value1 where id=111

这是一种 CAS 变更数据的方式,如果更新失败说明有人在更新这个数据,此时需要执行重试逻辑重新更新。

数据库悲观锁

悲观锁是假定每次操作都有人在抢锁,所以在进行数据操作前先进行加锁操作。

如使用 insert 语句进行加锁操作可以这样实现:

1
insert into methodLock(method_name, desc) values('method_name', 'desc')

由于 method_name 字段有唯一约束,多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功。解锁时把记录删除既可。

这种方式的加锁,数据库层面不会存在阻塞,只有应用层面存在因为加锁失败(创建记录失败)而采取的重试和等待。这种方式有死锁风险,需要额外的逻辑保证锁的解除。

也可以在查询的数据后加 for update 对关联行进行加锁。

注意,for update 这里是数据库层面的加锁,对应数据行的其它加锁操作都会被阻塞,直到当前事务结束。

这种方式算是比较常见且容易实现的,在一些跑批处理的系统,会简单地使用 for update 对满足条件的记录进行加锁,然后执行业务处理,更新状态。

1
select * from order where status='02' for update

总结

本文简单回顾了多线程中常见的线程安全类和几种加锁方式,同时介绍了三种常用分布式锁。

作者

Jakes Lee

发布于

2023-08-06

更新于

2023-08-21

许可协议

评论