Fork me on GitHub
0%

synchronized 的替代品 ReentrantLock

v本来这篇文章打算写下细粒度锁的几种通用实现的,但在实践的过程中,我觉得有必要先介绍一下 ReentrantLock 这个类,可能大部分人都没有使用过,其实我也是一样,在接触到这个类之前都是只用过 synchronized 关键字,直到接触到了 ReentrantLock 这个类才知道还有这个东西,哎,还是对并发编程不是非常熟悉,对 JUC 并发包还需要进一步的去学习,下面我们就一起来看下 ReentrantLock 这个类是怎样的。

在 Java 中当我们需要对某个方法或代码块进行加锁时,往往我们第一时间想到的是 synchronized 关键字,通过 synchronized 来保证多线程环境下的线程安全。比如说下面两段代码:

1
2
3
private synchronized void syncMethod(){
System.out.println("execute sync method");
}
1
2
3
4
5
6
private void syncMethod(){
System.out.println("enter sync method");
synchronized (this){
System.out.println("execute sync method");
}
}

上面两段代码很简单,一个是在 syncMethod 方法上加上 synchronized 关键字,另一个是在方法内部对部分代码块进行加锁,或许我们都知道这样都能保证 syncMethod 方法或 syncMethod 方法内部分代码块是线程安全的,那么有没有另外一种方式来实现这一效果呢?

答案是有的,也就是我们上面提到的 ReentrantLock 类了,他可以说是 synchronized 的替代品,一般翻译成再入锁,和 synchronized 关键字一样都是可重入的,也就是说当一个线程尝试获取它已经获取成功的锁时,这时锁直接获取成功。那我们先看如何使用 ReentrantLock 类实现上面的效果吧。

其实第一种也可以认为是使用 synchronized 关键字将整个 syncMethod 方法的代码包裹起来,就像下面这样:

1
2
3
4
5
private void syncMethod(){
synchronized (this){
System.out.println("execute sync method");
}
}

使用 ReentrantLock 类实现加锁方式如下:

1
2
3
4
5
6
7
8
9
ReentrantLock lock = new ReentrantLock();
private void syncMethod(){
lock.lock();
try {
System.out.println("execute sync method");
}finally {
lock.unlock();
}
}

可以看到使用 ReentrantLock 来加锁的话其实就跟使用普通对象一样,调用 lock 方法加锁,unlock 方法解锁,只不过千万需要注意的是在我们调用 lock 方法进行加锁之后一定要保证有解锁的操作。一般我们是通过 try-finally 的方式来确保 unlock 方法被调用,而且 unlock 方法的调用需要放在 finally 代码块的第一行。还有就是 lock 方法的调用最好不要放到 try 代码块中,不然万一 lock 方法加锁失败,最终 finally 代码块中的解锁方法被调用会抛出异常,因为是在锁根本没有加成功的情况下去解锁。

如果说仅仅是作为 synchronized 关键字的替代品那就太弱了,ReentrantLock 还能够实现一些 synchronized 无法做到的场景,比如说带超时的加锁操作。有时候由于业务需要,在尝试进入加锁的代码块时,如果 3s 之内没有获取到锁的话直接返回,提示用户重新操作,这可以有效减小高并发给系统带来的压力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void syncMethod() {
if(lock.tryLock(3, TimeUnit.SECONDS)){
try {
System.out.println("execute sync method");
TimeUnit.SECONDS.sleep(5);
} catch (Exception e){
e.printStackTrace();
} finally {
lock.unlock();
}
}else{
System.out.println("execute other logic");
}
}

上面的代码中通过调用 ReentrantLock 类的 tryLock 带超时时间参数的方法,在 3s 内加锁失败的话执行其他逻辑。

对于 ReentrantLock 类,它还提供保证获取锁公平性,判断是否有其他线程也在排队等待获取锁等一些特性。

1
2
3
4
5
6
7
8
// 创建公平性锁
ReentrantLock lock = new ReentrantLock(true);
// 队列中是否有线程在等待获取锁
lock.hasQueuedThreads();
// 锁是否已经被线程持有
lock.isLocked();
// 是否是当前线程持有该锁
lock.isHeldByCurrentThread();

上面锁在创建的时候设置 boolean 参数指定是否需要提供公平性,一般是不太建议引入公平性锁,因为只要引入公平性锁必然就要有额外的开销来保证线程获取锁的公平性,所以说除非业务实在是需要一般不引入。

接下来我们再看一下和 ReentrantLock 类有关的一个比较实用的功能,配合 Condition 条件变量非常优雅的实现线程间的通信操作,就有点类似于 Object 类中的 wait,notify/notifyAll 方法,都是实现线程间的通信,而这里是采用 await,signal/signalAll 组合,我们可以看下面一段很简单的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private List<String> list = new ArrayList<>();
private ReentrantLock reentrantLock = new ReentrantLock();
private Condition notEmpty = reentrantLock.newCondition();

private void produce(String str) throws InterruptedException {
reentrantLock.lock();
try {
list.add(str);
System.out.println("send a signal to add elements to the list");
notEmpty.signal();
TimeUnit.SECONDS.sleep(5);
}finally {
reentrantLock.unlock();
System.out.println("producer unlock");
}
}

private void consume() throws InterruptedException {
reentrantLock.lock();
try {
if(list.size() == 0){
System.out.println("waiting for elements to add the list");
notEmpty.await();
System.out.println("received a signal to add elements to the list");
}
list.remove(list.size() - 1);
System.out.println("consume list element");
TimeUnit.SECONDS.sleep(1);
}finally {
reentrantLock.unlock();
System.out.println("consumer unlock");
}
}

上面代码我们拿一个 list 来简单模仿队列的生产和消费操作,同时通过 ReentrantLock 创建一个 notEmpty 的 condition 来实现当 list 集合大小为 0 时,消费端则需要等待生产端生产元素,一旦生产端有元素进入,立马通过 notEmpty 的 condition 来通知消费端消费,消费端在收到生产端的信号之后则可以继续消费。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Thread consumer = new Thread(() -> {
try {
test.consume();
}catch (Exception e){
e.printStackTrace();
}
});

Thread producer = new Thread(() -> {
try {
test.produce("string");
}catch (Exception e){
e.printStackTrace();
}
});

consumer.start();
TimeUnit.SECONDS.sleep(1);
producer.start();

测试代码中先启动消费线程,消费线程启动之后,等待元素进入集合,接着启动生产线程,生产元素进入集合,紧接着发送有元素加入的信号通知消费线程开始消费。测试结果如下:

1
2
3
4
5
6
waiting for elements to add the list
send a signal to add elements to the list
producer unlock
received a signal to add elements to the list
consume list element
consumer unlock

当然上面例子中的代码可能不是很严瑾,但我觉得足以说明 ReentrantLock 配合 Condition 一起使用可以实现的功能了,如果你想看下标准的实现方式,可以去看下标准类库中的 ArrayBlockingQueue,它里面的入队和出队操作就是利用了上面例子所表达的这一功能。

上面描述了 ReentrantLock 类的整体使用,基本上能够做到 synchronized 关键字所能做到功能,那么它们两者的性能如何呢?其实在 Java 6 之前的版本,synchronized 关键字的性能表现不是很理想,直到在 Java 6 中进行了很大的改进,加入了偏斜锁,轻量级锁,重量级锁实现,这才让 synchronized 关键字的性能得到很大的改善,在这之后,如果是并发冲突很高的情况下,ReentrantLock 的性能表现会更好点,相反,synchronized 关键字的表现会更好些。

 wechat
扫描上面图中二维码关注微信公众号