农场主的黑科技.

Java并发编程实战-第4章-对象的组合

字数统计: 3.3k阅读时长: 20 min
2018/10/28 Share

在本章中涉及的组合模式中,将会把一些现有的线程安全组件组合成更大的程序,从而实现线程安全。

设计线程安全的类

我们可以通过封装来确保程序是线程安全的。线程安全的类的设计过程:

  1. 找出构成对象状态的所有属性
  2. 找出约束这些属性的不变性条件
  3. 建立这些属性的并发访问策略(同步策略

比如我们想要设计出下面这样的线程安全的程序,需要什么样的过程?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@ThreadSafe
public final class Counter {
@GuardedBy("this") private long value = 0;

public synchronized long getValue() {
return value;
}

public synchronized long increment() {
if (value == Long.MAX_VALUE)
throw new IllegalStateException("counter overflow");
return ++value;
}
}

首先,一目了然的是:

设计步骤1 : 只有value属性

第三步的同步策略(Synchronized Policy)定义了如何在不违背对象不变条件或后验条件的情况下对其状态的访问操作进行协同。为了规定同步策略,我们需要先找出对象的不变条件或后验条件等同步需求。

收集同步需求

  • 不可变条件 : 用于来判断状态是否有效。
    设计步骤2.1 : value属性是long类型,所以他的状态必需是Long.MIN_VALUELong.MAX_VALUE,并且在目前的代码中value属性不能是负值。

  • 后验条件:来判断状态迁移是否是有效。

    也就是说,当下一个状态需要依赖当前状态时,这个操作就必须是一个复合操作(读取-修改-写入)。

    设计步骤2.2 : 如果Counter的当前值为17,那么下一个有效状态只能是18。必须是复合操作

  • 先验条件 : 某些对象的方法中还包含一些基于状态的先验条件。比如不能从空队列中移除元素,这种就是依赖状态的操作

  • 状态的所有权 : 一个对象的状态将是对象图中所有对象包含的域的一个子集。
    比如一个HashMap中包含多个Entry,Entry中可能包含了内部对象。只要它封装了某个对象,那么他就有那个对象的所有权。而一旦把他发布出去那他就不再有独占的控制权,而是共享控制权。容器类中的大多都是所有权分离的形式。

实例封闭

实例封闭:把一个对象封装到另一个对象中,从而限制他的访问途径。配合加锁策略使用,使得对象本身不会逸出。换句话说就是,将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//        4-2     通过封闭机制来确保线程安全
@ThreadSafe
public class PersonSet {
@GuardedBy("this") private final Set<Person> mySet = new HashSet<Person>();//HashSet并非线程安全的,但由于mySet是私有的并且不会逸出,因此HashSet被封闭在PersonSet中

public synchronized void addPerson(Person p) { //在执行它们它们时都要获得PersonSet上的锁
mySet.add(p);
}

public synchronized boolean containsPerson(Person p) {//在执行它们它们时都要获得PersonSet上的锁
return mySet.contains(p);
}

interface Person {
}
}

在上面的代码中唯一能访问mySet的代码路径是addPerson与containsPerson,在执行它们它们时都要获得PersonSet上的锁(this)。PersonSet的状态完全由它的内置锁保护,因此PersonSet是一个线程安全的类。

设计步骤3 :使用实例封闭的方式,限制外部对value的访问

Java监视器模式

JAVA每个对象(Object/class) 都关联一个监视器,更好的说法应该是每个对象(Object/class)都有一个监视器,对象可以有它自己的临界区,并且能够监视线程序列为了使线程协作。

`Java监视器模式`:一种编写代码的约定。把所有可变状态都封装起来,并由对象自己的内置锁保护。VectorHashTable都使用了这种同步策略。

1
2
3
4
5
6
7
8
9
10
11
//        4-3   通过一个私有锁来保护状态
public class PrivateLock {
private final Object myLock = new Object();
@GuardedBy("myLock") Widget widget;

void someMethod() {
synchronized (myLock) {
// Access or modify the state of widget
}
}
}

私有的锁对象可以将锁封装起来,只让客户端通过公有的方法去获取他。

线程安全性的委托

在很多情况下,通过多个线程安全类组合而成的类是线程安全的。(虽然在某些情况下,这仅仅是一个好的开端)

在第二章中的CountingFactorizer类是通过一个AtomicLong来保护count属性,因此CountingFactorizer是线程安全的。换句话说就是CountingFactorizer将它的线程安全性委托给AtomicLong来保证:因为AtomicLong是线程安全的,所以CountingFactorizer是线程安全的。

基于委托的车辆追踪器

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
//      4-7  将线程安全委托给ConcurrentHashMap
@ThreadSafe
public class DelegatingVehicleTracker {
private final ConcurrentMap<String, Point> locations;
private final Map<String, Point> unmodifiableMap;

public DelegatingVehicleTracker(Map<String, Point> points) {
locations = new ConcurrentHashMap<String, Point>(points); //所有对状态的访问都由ConcurrentHashMap来管理
unmodifiableMap = Collections.unmodifiableMap(locations); //Map所有的键值对都是不可变的
}

public Map<String, Point> getLocations() {
return unmodifiableMap;
}
//返回实时拷贝
public Point getLocation(String id) {
return locations.get(id);
}

public void setLocation(String id, int x, int y) {
if (locations.replace(id, new Point(x, y)) == null) //如果不能设置车辆信息
throw new IllegalArgumentException("invalid vehicle name: " + id);
}

// 返回locations的静态拷贝而非实时拷贝
public Map<String, Point> getLocationsAsStatic() {
return Collections.unmodifiableMap(
new HashMap<String, Point>(locations));
}
}

在上面的程序中,所有对状态的访问都由ConcurrentHashMap来管理,Map所有的键值对都是不可变的,如果线程A调用getLocations,而线程B随后修改了某些点的位置,那么在返回给线程A的Map中将反映出这些变化。

独立的状态变量

我们还可以将线程安全委托给多个状态变量,只要这些变量是彼此独立的。在下面的代码中VisualComponent 可以将线程安全性委托给两个线程安全的监听器列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//             4-9   将线程安全性委托给多个状态变量
public class VisualComponent {
private final List<KeyListener> keyListeners
= new CopyOnWriteArrayList<KeyListener>(); //CopyOnWriteArrayList是线程安全的链表
private final List<MouseListener> mouseListeners
= new CopyOnWriteArrayList<MouseListener>();//两个状态变量是彼此独立的

public void addKeyListener(KeyListener listener) {
keyListeners.add(listener);
}

public void addMouseListener(MouseListener listener) {
mouseListeners.add(listener);
}

public void removeKeyListener(KeyListener listener) {
keyListeners.remove(listener);
}

public void removeMouseListener(MouseListener listener) {
mouseListeners.remove(listener);
}
}

当委托失效时

但大多数组合对象都不会像VisualComponent这样简单,在它们的状态变量之间存在着某些不变性条件。

😧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//      4-10   NumberRange类并不足以保护它的不变性条件(不要这么做)
public class NumberRange {
// 不变性条件: lower <= upper
private final AtomicInteger lower = new AtomicInteger(0);
private final AtomicInteger upper = new AtomicInteger(0);

public void setLower(int i) {
// 注意——不安全的“先检查后执行”
if (i > upper.get())
throw new IllegalArgumentException("can't set lower to " + i + " > upper");
lower.set(i);
}

public void setUpper(int i) {
// 注意——不安全的“先检查后执行”
if (i < lower.get())
throw new IllegalArgumentException("can't set upper to " + i + " < lower");
upper.set(i);
}

public boolean isInRange(int i) {
return (i >= lower.get() && i <= upper.get());
}
}

上面的代码是线程不安全的,因为setLower和setUpper都是“先检查后执行”的操作,但它们没有使用足够的加锁机制来保证这些操作的原子性,可能出现(5,4)的取值范围。

虽然AtomicInteger是线程安全的,但由于lower和upper不是彼此独立,因此委托失效,需要另外实现加锁机制。volatile的变量规则也是如此。

发布底层的状态变量

如果一个状态变量是线程安全,并且没有任何不变性条件来约束它的值,在变量的操作上也存在任何不允许的状态转换,那么就可以安全地发布这个变量。

例如,发布VisualComponent中的mouseListener和keyListener等变量就是安全的。由于VisualComponent并没有在监听器链表的合法状态上施加任何约束,因此这些与可以声明为公有域或者发布,而不会破坏线程安全性。

在现有的线程安全类中添加功能

Java类库中包含许多有用的“基础模块”类,通常,我们应该优先选择重用这些现有的类而不是创建新的类。那么该在现有的线程安全类中添加功能呢?

策略1 : 修改原始的线程安全类,从而添加新功能。但通常无法做到,因为你可能无法访问或者修改类的源代码。若要修改就必须理解原来的同步策略。

策略2 : 通过继承去扩展原始的类

对Vector进行了扩展(继承了Vector):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//      4-13  继承Vector并增加一个“若没有则添加”方法
@ThreadSafe
public class BetterVector <E> extends Vector<E> {
// When extending a serializable class, you should redefine serialVersionUID
//Java的序列化机制是通过在运行时判断类的serialVersionUID来验证版本一致性的。在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体(类)的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常。(InvalidCastException)
static final long serialVersionUID = -3963416950630760754L;

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

但是这种通过“继承”的办法更脆弱,因为当父类的同步策略发生变化时,子类可能无法再用正确的方式进行并发访问。

客户端加锁机制

策略3:客户端加锁机制—扩展类的功能,但并不是继承类本身,而是将扩展代码放入一个“辅助类”中。

😧

1
2
3
4
5
6
7
8
9
10
11
12
//           4-14    非线程安全的“若没有则添加(不要这么做)”
@NotThreadSafe
class BadListHelper <E> {
public List<E> list = Collections.synchronizedList(new ArrayList<E>());

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

上面的代码是错误的,因为尽管所有的链表操作都被声明为synchronized,但却使用了不同的锁,这意味着putIfAbsent相对于List的其他操作来说并不是原子的。

😊 修改后的ListHelper,客户端加锁或外部加锁使用的都是同一个锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//           4-15   通过客户端加锁来实现“若没有则添加”
@ThreadSafe
class GoodListHelper <E> {
public List<E> list = Collections.synchronizedList(new ArrayList<E>());

public boolean putIfAbsent(E x) {
synchronized (list) { //使用
boolean absent = !list.contains(x);
if (absent)
list.add(x);
return absent;
}
}
}

这种客户端加锁相比上面的继承策略更加脆弱,因为它将类C的加锁代码放到与C完全无关的其他类中,当在那些并不承诺遵守加锁策略的类上使用客户端加锁时,要特别小心。

组合

策略4 : 组合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//            4-16  通过组合实现“若没有则添加”
@ThreadSafe
public class ImprovedList<T> implements List<T> { //实现List接口
private final List<T> list;

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

public synchronized boolean putIfAbsent(T x) {//Java监视器模式
boolean contains = list.contains(x);
if (contains)
list.add(x);
return !contains;
}
public synchronized void clear() { list.clear(); }
//按照类似方式委托list的其他方法
}

通过自身的内置锁来保护List,也就是通过实例封闭来封装现有的List。事实上这种策略是最健壮的,虽然可能会降低性能。

将同步策略文档化

在文档中说明客户代码需要了解的线程安全性保证,以及代码维护人员需要了解的同步策略。

Reference

Java并发编程实战

源代码

Java并发编程实战(学习笔记三 第四章 对象的组合)

CATALOG
  1. 1. 设计线程安全的类
    1. 1.1. 收集同步需求
  2. 2. 实例封闭
    1. 2.1. Java监视器模式
  3. 3. 线程安全性的委托
    1. 3.1. 基于委托的车辆追踪器
    2. 3.2. 独立的状态变量
    3. 3.3. 当委托失效时
    4. 3.4. 发布底层的状态变量
  4. 4. 在现有的线程安全类中添加功能
    1. 4.1. 客户端加锁机制
    2. 4.2. 组合
  5. 5. 将同步策略文档化
  6. 6. Reference