[笔记] Java 并发 – 对象的组合

By | 28 6 月, 2018

更多情况下,我们希望的是将一些现有的线程安全的组件组合为更大规模的组件或者程序,并且在维护的时候不会无意中破坏类的安全性保证。

实例封闭

如果某对象不是线程安全的,那么可以通过多种技术使其在多线程程序中安全地使用。
你可以确保该对象只由单个线程访问(线程封闭),或者通过一个锁来保护对该对象的所有访问。
一般情况下,我们将数据封装在对象的内部,并将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。

监视器模式

遵循 Java 监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护。
一般情况下,我们可以使用对象的内置锁,但这里更推荐使用私有的锁对象,优点如下:

  • 私有的锁对象可以将锁封装起来,使客户代码无法得到锁
  • 与此同时,客户代码可以通过公有方法来访问锁,以便参与到它的同步策略中

如果使用对象的内置锁,或者其他可以通过公有方式访问的锁,会存在以下问题:

  • 如果客户的代码错误的获取了对象的锁,那么就可能产生活跃性的问题
  • 如果需要验证某个公有访问的锁在程序中是否被正确的使用,需要检查整个程序,而私有的锁仅需检查单个的类

使用私有的锁的示例代码如下:

public class PrivateLock {
    private final Object myLock = new Object();
    Widget widget;
    void someMethod() {
        synchronized(myLock) {
            // do something
        }
    }
}

线程安全性的委托

当从头开始构建一个类,或者将多个非线程安全的类组合为一个类的时候,Java 监视器模式是非常有用的。
但如果类里面的每个组件都是线程安全的情况下呢?是不是就不需要额外的线程安全层呢?答案是“视情况而定”。
看下面的示例:

class Point {
    public final int x, y;
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}
public class DelegatingVehicleTracker {
    private final ConcurrentMap<String, Point> locations;
    private final Map<String, Point> unmodifiableMap;
    public DelegatingVehicleTracker(Map<String, Point> points) {
        locations = new ConcurrentHashMap<>(points);
        unmodifiableMap = Collections.unmodifiableMap(locations);
    }
    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) {
        Point result = locations.replace(id, new Point(x, y));
        if (result == null) {
            throw new IllegalArgumentException("invalid vehicle name: " + id);
        }
    }
}

这段代码中可以提炼出下面几个问题:

  • Q:如果 Point 类的内部数据是可变的,没有被 final 修饰,也没有对应的锁,那么整个类还会是线程安全的吗?
    A:不是。当 Point 类可变时,也就意味着 locations 中的 Point 可以通过 getLocation 方法取出,然后被多个线程共享,一起修改,引起错乱。
  • Q:getLocations 方法返回的是快照数据还是实时数据?
    A:实时数据。这意味着,如果线程 A 调用 getLocations,而线程 B 在随后调用 setLocation 修改了某些点的位置,那么在 A 线程中拿到的 Map 中将反映出这些变化。这个根据业务需要,没有对错之分,如果需要返回快照数据,那么 getLocations 方法需要改成下面这个样子,这样返回的是静态拷贝而非实时拷贝:
public Map<String, Point> getLocations() {
    return Collections.unmodifiableMap(new HashMap<String, Point>(locations));
}

其他几点需要注意的问题:

  • final 修饰了 locations,意味着 locations 引用在初始化后不能再次修改,但并不代表其中的数据不能修改
  • Collections.unmodifiableMap 包装后的 map 引用禁用了修改相关的方法,如 put 等,但对其中存放的值的修改是不受影响的

状态变量的委托

当存在多个状态变量时,只要这些变量是彼此独立的,那么就可以将线程安全性委托给多个状态变量。
但注意,如果状态变量之间存在着某些不变性条件的约束,那么就要注意了,这个类必须提供自己的加锁机制来保证这些复合操作都是原子操作。
下面我们来修改上面的那个例子,把 Point 类改为安全可变的 SafePoint:

class SafePoint {
    private int x, y;
    private SafePoint(int[] a) {
        this(a[0], a[1]);
    }
    public SafePoint(SafePoint p) {
        this(p.get());
    }
    public SafePoint(int x, int y) {
        this.x = x;
        this.y = y;
    }
    public synchronized int[] get() {
        return new int[] { x, y };
    }
    public synchronized void set(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

这里的 get 方法使用 synchronized 修饰,并且返回了 int[] 数组,这样可以避免分开获取导致的竞态条件,从而产生不正确的数据。set 也是同理。
另外这里的拷贝构造函数使用了 this(p.get()) 的方式,这个是一个很有意思的实现,为什么不直接实现为 this(p.x, p.y) 呢?
其实还是因为上面的那个原因,如果实现为 this(p.x, p.y) 的话,会产生竞态条件,而私有的构造函数可以避免这种竞态条件。这也被称为私有构造函数捕获模式。

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

有时候,某个现有的线程安全类能支持我们需要的所有操作,但更多时候,现有的类只能支持大部分的操作,此时就需要在不破坏线程安全性的情况下添加一个新的操作。

客户端加锁

下面是一个正确的例子,来扩展一个“若没有则添加”的操作接口:

public class ListHelper {
    public List list = Collections.synchronizedList(new ArrayList<>());
    public boolean putIfAbsent(E x) {
        synchronized (list) {
            boolean absent = !list.contains(x);
            if (absent) {
                list.add(x);
            }
            return absent;
        }
    }
}

注意我们这里没有直接给 putIfAbsent 本身添加 synchronized,因为如果那样的话就是错的,使用了不同的锁。
不过这种加锁策略很脆弱,需要非常明确对应的类的加锁策略是什么。

组合方式加锁

public class ImprovedList implements List {
    private final List list;
    public ImprovedList(List list) {
        this.list = list;
    }
    public synchronized boolean putIfAbsent(T x) {
        boolean contains = list.contains(x);
        if (contains) {
            list.add(x);
        }
        return !contains;
    }
    public synchronized void clear() {
        list.clear();
    }
    // ...
}

ImprovedList 通过自身的内置锁来保证了线程安全。它并不关心底层的数据是否是线程安全的,因为本身就提供了加锁机制来实现线程安全性。
虽然有轻微的性能损耗,但是相比之下,这份代码更为健壮。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注