java并发编程实践 4.4 向已有的线程安全类添加功能

缘起

《Java并发编程实践》 4.4节

分析

本节的目的是介绍往一个线程安全的类中添加一个新的线程安全的方法. 以”往一个线程安全的List中添加一个线程安全的putIfAbsent方法”. 我们任意想出下面的几种策略

策略1 扩展原有的类

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.Vector;

public class ExtendList<T> extends Vector<T>{

public synchronized boolean putIfAbsent(T t) {
boolean absent = !contains(t);
if (absent) {
add(t);
}
return absent;
}

}

这种策略的缺点是——

  1. 如果Vector其他的方法使用的锁是别的锁的话(例如是Vector的内部私有锁。当然这种情况不可能,因为我们已经知道了Vector的源码内部就是使用this做锁), 那么ExtendList和基类Vector的其他方法对Vecotor的一些状态的并发访问就可能是线程不安全的. 这里之所以不存在这种情况,完全是因为我们已经知晓了Vector的锁协议.
  2. 就算这样没问题了,但是我们的代码也已经和基类耦合在了一起——我们将使用Vecotor的锁协议(this)的代码从Vecotor扩展到了外部(ExtendList),即将加锁的代码分布到了继承体系中的多个类中,使得排查并发问题更加的困难.

策略2 客户端加锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@NotThreadSafe
public class ListHelper<T> {

private final List<T> list = Collections.synchronizedList(new ArrayList<>());

public synchronized boolean putIfAbsent(T t) {
boolean absent = !list.contains(t);
if (absent) {
list.add(t);
}
return absent;
}

public void remove(T t) { // 因为是裸调list的remove方法, 所以可以"放心"的裸调list的方法
list.remove(t);
}
}

即我们并没有继承任何List容器,而是作为客户端(XXXHelper助手类)使用了list. 但是我们说上面的做法是错误的. putIfAbsent方法并不是线程安全的. 因为synchronizedList使用的锁是 java.util.Collections.SynchronizedCollection.mutex

而这里使用的锁是ListHelper的对象锁. 都是不一样的锁,从而在并发修改list的时候, 一根线程调用ListHelper的putIfAbsent方法,一根线程调用此ListHelper实例的remove方法, 则此ListHelper实例的list属性就处于不安全并发的危险境地.

经过调研,我们知道mutex就是this. 何以为证? 参见下面的源码(SynchronizedList是SynchronizedCollection的子类)

1
2
3
4
SynchronizedCollection(Collection<E> c) {
this.c = Objects.requireNonNull(c);
mutex = this;
}

所以,正确的客户端加锁的姿势是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@ThreadSafe
public class ListHelper<T> {

private final List<T> list = Collections.synchronizedList(new ArrayList<>());

public boolean putIfAbsent(T t) {
synchronized (list) { // 使用list的对象锁进行加锁(这样就和)
boolean absent = !list.contains(t);
if (absent) {
list.add(t);
}
return absent;
}
}

public void remove(T t) { // 因为是裸调list的remove方法, 所以可以真的放心的裸调list的方法,而且和上面的putIfAbsent使用的是一把锁, 所以线程安全
list.remove(t);
}
}

就算上面成功加锁了,但是有缺点,

  1. 必须要了解原先的加锁方式(和策略1的第一个缺点一样)
  2. 耦合(策略1的第二个缺点)——这里其实更加过分——我们甚至将原先的加锁(锁和原先的锁是一把锁)代码放置到了和原先的代码完全无关的客户端代码中去了,这使得问题的排查更加的困难.

那么向已有的线程安全的类中添加线程安全的方法的最佳实践是什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ImprovedList<T> implements List<T> {

private final List<T> list; // 没有发布这个list, 所以可以认为外部代码要使用这个list只能通过ImprovedList

public ImprovedList(List<T> list) {
this.list = list;
}

public synchronized boolean putIfAbsent(T t) {
boolean contain = !list.contains(t);
if (contain) {
list.add(t);
}
return contain;
}
...
public synchronized void clear() { list.clear(); } // 其余代理list的方法统统加上synchronized.
}

那么上述最佳实践和策略1有什么不同呢? 这里是增加了一个锁层——即属于ImprovedList自己的锁层. 这个锁层的存在使得我们根本不需要关心被包装的list到底是不是线程安全的容器. 或者被包装的list使用的锁协议到底是什么. ImprovedList 都可以使用自己的锁来保证线程安全性. 虽然额外的一层同步会带来微弱的性能损失(因为对于已经获取到ImprovedList锁层的线程而言,它获取更进一步的锁是没有竞争的,所以额外的性能消耗是非常小的).