Loading

Java并发——对象的组合

本篇博文是Java并发编程实战的笔记。

在开发中一定需要组合多个组件,这一章讲述了如何将多个(线程安全或非线程安全的)组件组合起来,向外界提供一个线程安全的类,使得外界可以放心的去使用我们的类。此外,还介绍了一些编码时应保持的习惯——书写文档。

设计线程安全的类

如何将很多个组件安全的组合起来,向外界提供线程安全的功能,而不管这些组件本身是否线程安全呢?答案是使用封装

一般来说,设计一个线程安全的类分为三步:

  1. 确定构成对象状态的变量
  2. 确定状态变量的不变性约束
  3. 建立管理并发访问对象状态的同步策略

有很多非常专业的名词哈,其实在上一篇笔记里也出现过,但是当时懒得解释,现在解释一下,这样就算大家回去看原书,应该也不会看得一头雾水了。

状态

状态即在某一时刻的对象,比如一个Point(int x, int y)对象,当x=1, y=10时,该对象是一种状态,当x=2, y=3时,该对象的状态发生了改变(状态转移),变成了另一种状态。

当一个对象的所有属性都是基本类型时,该对象的状态由它的所有属性构成,当一个对象的属性包含了一些复杂的引用类型时,那些引用类型的状态也是该对象的状态。

状态转移

当一个对象的状态发生改变时,我们就说该对象从一个状态转换到了另一个状态

不变性约束

对象的状态可以发生改变,但是很多情况下该改变需要在一个特定的约束条件下进行。比如一个计数器——Counter(int number)类,它的当前数值number不可能变成负数,这是它的不变性约束。

再比如代表一个区间的对象NumberRegion(int start, int end),它的start值必须小于等于end值。

不变性约束不能够被破坏,对于一些限定在某些范围的状态,那么必须对这种状态的底层变量进行封装

对于那种约束多个状态变量的不变性条件,如NumberRegion,对它们的访问和修改必须加以同步来保证原子性,否则可能会产生不一致的状态

同步策略

同步策略是用来保证对对象状态的访问和改变操作进行协同,以使其满足不变性约束的策略,保证一个类的线程安全。

线程安全

没有办法给出线程安全一个明确的定义,因为我们不知道何为“安全”,每个类对于“安全性”的要求不一致,所以我认为,只要满足一个类预期的安全性,那么这个类就是线程安全的。

先验条件和后验条件

先验条件是对象发生状态转移之前对对象的状态合法性进行验证,比如不能在一个空的集合中删除数据。

后验条件是对象状态发生转移之后对对象的状态合法性进行验证,比如Counter(int number)递增计数器之后,number的值一定是之前的值加1。

在并发程序中,如果不满足先验条件,程序不一定运行失败,比如在生产者消费者队列中,消费者想要在空的队列中拿一个产品时,它会阻塞在队列上,等待生产者放置产品,而非直接失败。

emmm,下面开始介绍组合对象的方法。

实例封闭

第一种方法,如果我们需要让一个非线程安全的类变得线程安全,我们可以将它封闭到另一个类中,并确保这个非线程安全的类不会逸出,然后由封闭它的外层类来实现线程安全。

@ThreadSafe
public class SafeList<T> {
    @GuardedBy("this")
    private final List<T> underlyingList = new ArrayList<>();
    
    public synchronized void add(T t) {
        underlyingList.add(t);
    }
    
    public synchronized boolean remove(T t) {
        return underlyingList.remove(t);
    }
}

SafeList类通过私有final域创建了一个线程不安全的底层ArrayList,并且没有通过任何手段发布它,它只向外界提供两个方法用于操作这个底层的ArrayList,并且这两个方法是同步的,本身不会被多个线程同时访问,这样就提供了线程安全。

并非必须将实例封闭在一个私有final属性中,只要确保它的可见性并且不会逸出造成多个线程可以一起访问的情况即可。

需要注意的是,List中的泛型<T>不一定是线程安全的,它也是SafeList的状态,但SafeList本身并未对其做任何约束,如果外界想要<T>的访问也是线程安全的,那么外界应该做出其它额外的同步机制。

监视器模式

Java提供的synchronized机制在操作系统理论中被称作“管程”,英文就是Monitor,所以也常被译作监视器。在Java编译后的字节码中,synchronized语句也会生成一对monitorentermonitorexit用于执行一些进入和退出管程的工作。

相较于传统的异步编程手段(如信号量),管程更加简单易用。直接将synchronized作用于方法上是Java提供的一个管程的更简单的写法,synchronized需要锁定在一个对象上,如果你使用上面的写法,那么锁定的就是this对象,如果你将它写在static方法上,那么锁定的就是该类的类对象。

synchronized的完整写法如下:

上面的图锁定在了一个私有锁上,这样的好处是使用该类的其它客户代码无法获得这个锁,就无法参与到该类的同步策略中。

示例:车辆追踪

VehicleTracker是一个车辆追踪器,其中保存一些车辆与它们的位置,每一台车辆都是一个String,它们的位置是一个Location对象。

视图线程会读取车辆的名字和位置,并将它们渲染在界面上(vehiclesVehicleTracker对象):

更新线程使用如下方式修改车辆的位置:

现在给定一个非线程安全的MutableLocation对象,你需要实现VehicleTracker为一个线程安全的类。

@NotThreadSafe
public class MutableLocation implements Location {
    private volatile int x;
    private volatile int y;

    public void setX(int x) {
        this.x = x;
    }

    public void setY(int y) {
        this.y = y;
    }

    @Override
    public int getX() {
        return this.x;
    }

    @Override
    public int getY() {
        return this.y;
    }
}

实现:

@ThreadSafe
public class SafeVehicleTracker implements VehicleTracker {

    @GuardedBy("this")
    private final Map<String, MutableLocation> locationMap;

    public SafeVehicleTracker(Map<String, MutableLocation> locationMap) {
        this.locationMap = locationMap;
    }

    @Override
    public synchronized Location getLocation(String carId) {
        if (!locationMap.containsKey(carId)) return null;
        Location orginalLocation = locationMap.get(carId);
        return new MutableLocation(orginalLocation.getX(), orginalLocation.getY());
    }

    @Override
    public synchronized Map<String, Location> getLocations() {
        return deepCopy();
    }

    @Override
    public synchronized void setLocation(String carId, int x, int y) {
        if (!locationMap.containsKey(carId))
            throw new IllegalArgumentException("No such carid: " + carId);
        locationMap.put(carId, new MutableLocation(x, y));
    }

    private Map<String, Location> deepCopy() {
        Map<String, Location> capture = new HashMap<>();
        for (String car : locationMap.keySet()) {
            MutableLocation originalLocation = locationMap.get(car);
            capture.put(car, new MutableLocation(originalLocation.getX(), originalLocation.getY()));
        }
        return Collections.unmodifiableMap(capture);
    }

}

首先,最简单的就是这个synchronized,和之前的SafeList一样,它用来提供封闭在类中的Map<String, Localtion>的同步访问。

然后就是对于两个get方法,它们返回的都是对应MutableLocation的快照,而非原MutableLocation,否则外界就可以不通过该类去修改原位置对象,这个修改是不受SafeVehicleTracker类的同步策略限制的,这可能会使得该类的一致性约束失效(当有其它类与当前类并发的修改MutableLocation的状态时)而产生错误的状态。

对于getLocations方法,它要保护两个状态,首先就是locationMap,这个被封闭的对象不能逸出,其次是其中的MutableLocation对象。

deepCopy需要复制所有的对象,可能产生性能问题,而且由于它返回的都是某一时刻汽车所在位置的快照,所以后面的改变不会反映到getLocationgetLocations方法返回的对象身上,也就是说调用者需要不断的主动调用get方法来刷新位置信息。

如上所说的一些特性并不是缺点,确实有很多场景下我们就是需要快照,这也是比较常见的并发对象组合方式。

线程安全性的委托

除了封闭一个对象到类中,自己使用同步机制去保证并发的互斥访问外,还可以直接将保证线程安全的任务委托给本就提供线程安全性的组件。

某些情况下,当你在一个类中使用多个线程安全的类进行组合时,你不用提供任何额外的同步机制就可以编写出一个线程安全的类,比如如下示例:

基于委托的车辆追踪器

下面的类使用ImmutableLocationConcurrentHashMapCollections.unmodifiableMap这三个线程安全的组件来提供线程安全性。

@Immutable
public class ImmutableLocation implements Location {
    private final int x;
    private final int y;

    public ImmutableLocation(int x, int y) {
        this.x = x;
        this.y = y;
    }
    @Override
    public int getX() {
        return x;
    }

    @Override
    public int getY() {
        return y;
    }
}
@ThreadSafe
public class DelegateSafeVehicleTracker implements VehicleTracker {
    private final Map<String, Location> locationMap;
    private final Map<String, Location> unmodifiableMap;

    public DelegateSafeVehicleTracker(Map<String, ImmutableLocation> locationMap) {
        this.locationMap = new ConcurrentHashMap<>(locationMap);
        this.unmodifiableMap = Collections.unmodifiableMap(this.locationMap);
    }

    @Override
    public Location getLocation(String carId) {
        return locationMap.get(carId);
    }

    @Override
    public Map<String, Location> getLocations() {
        return unmodifiableMap;
    }

    @Override
    public void setLocation(String carId, int x, int y) {
        if (!locationMap.containsKey(carId))
            throw new IllegalArgumentException("Unknown carid: " + carId);
        locationMap.put(carId, new ImmutableLocation(x, y));
    }
}

而且修改后的getLocations方法不在返回某一时刻的车辆位置快照,而是一个实时的视图(该视图由Collections.unmodifiableMap来提供不变性),即后面所有通过setLocation对车辆进行的修改都可以实时的反馈给getLocations的返回值。

独立的状态变量

现在为止我们的类都是将线程安全委托到单个线程安全的状态变量上,现在我们可以委托多个线程安全的状态变量,只要它们之间是独立的,你就不需要提供任何额外的同步机制,因为不变性条件并没有增加

比如如下代码,keyListener用于保存键盘事件,MouseListener用于保存鼠标事件,这两个组件单独都是线程安全的,并且它们相互独立,一个的改变对另一个没有任何副作用,所以我们不需要提供任何额外的同步,直接将线程安全委托给它们就好。

非独立的多个状态变量

@NotThreadSafe
public class NumberRange {
    private final AtomicInteger lower;
    private final AtomicInteger upper;
    public NumberRange(int lower, int upper) {
        this.lower = new AtomicInteger(lower);
        this.upper = new AtomicInteger(upper);
    }

    public void setLower(int i) {
        if (i > upper.get())
            throw new IllegalArgumentException("lower is bigger than upper");
        lower.set(i);
    }

    public void setUpper(int i) {
        if (i < lower.get())
            throw new IllegalArgumentException("upper is smaller than lower");
        upper.set(i);
    }

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

如上的类使用了两个线程安全的组件来表示NumberRange的下界和上界,但是由于它们之间并非独立的,引入了新的不变性规则(即upper不能小于lower),所以上面的类是线程不安全的,必须使用额外的同步机制来保证线程安全。

发布底层的状态变量

底层的状态变量并非不可发布,只要不变性条件中允许即可发布。

Counter(int number)中有一个不变性条件,number不能小于0,所以自然不能随意发布它的状态,因为外界可能随时将它修改称不符合不变性条件的值。

VisualComponent显然就可以发布,因为并未对该类的状态定义任何不变性约束。

下面是一个发布底层状态的VehicleTracker

// 线程安全的可修改Location对象
@ThreadSafe
public class SafeLocation {
    private volatile int x;
    private volatile int y;
    public SafeLocation(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;
    }

}
public class PublishStateVehicleTracker {

    private final Map<String, SafeLocation> locationMap;
    private final Map<String, SafeLocation> unmodifiableMap;
    public PublishStateVehicleTracker(Map<String, SafeLocation> locationMap) {
        this.locationMap = new ConcurrentHashMap<>(locationMap);
        this.unmodifiableMap = Collections.unmodifiableMap(this.locationMap);
    }

    public SafeLocation getLocation(String carId) {
        return this.locationMap.get(carId);
    }

    public Map<String, SafeLocation> getLocations() {
        return unmodifiableMap;
    }

    public void setLocation(String carId, int x, int y) {
        if (!this.locationMap.containsKey(carId)) throw new IllegalArgumentException("");
        this.locationMap.get(carId).set(x, y);
    }
}

这次SafeLocation类本身是线程安全且可被修改的,然后我们的PublishStateVehicleTracker通过get方法将它发布了出去,这次,所有的setLocation产生的更改或者外界对SafeLocation的更改都能被该类内部或者调用者发现。

再次重申,该类的线程安全性只是因为该类并未在车辆位置的xy取值上做任何不变性约束,一旦这种约束出现,该类立即就会变成线程不安全的。这也再次印证了线程安全不安全的说法取决于类的不变性要求。

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

Java提供了一系列线程安全的组件,如果它们提供的功能不够,你可以再通过一些手段向其中添加功能,这一段介绍了一些向现有的线程安全类库中添加功能的手段。

通过继承扩展

下面的代码展示了如何向一个Java提供的线程安全类Vector中添加一个putIfAbsent(若没有则添加)方法。

public class BetterVector<T> extends Vector<T> {
    /**
     * 对于一个元素t,如果它不存在于当前Vector中,则添加
     * @return 添加是否成功
     */
    public synchronized boolean putIfAbsent(T t) {
        boolean absent = !contains(t);
        if (absent) add(t);
        return absent;
    }
}

当你扩展已有的线程安全组件,提供新的线程安全功能的时候,请注意你要使用与原来的类一致的同步机制,Vector类的所有操作都锁定在this上,所以我们的putIfAbsent方法也让它锁定在this上。

可以通过查询对应类的文档来获得类的同步策略。

如果你是通过查阅代码来获得的一个类的同步策略,并使用相同的策略扩展该类,那么如果以后被扩展类的同步机制发生变化,你的新类的同步机制将失效。并且,通过代码来获得的同步策略始终是一种猜测,它不一定是正确的。

通过持有扩展

对于一些类,我们完全无法继承它,比如Collections.synchronizedList,这时可以选择通过持有该类的示例,并提供一个辅助类来扩展。

下面的代码是一个错误的示例,因为putIfAbsent锁定的是ListHelper.this对象,而非那个list对象。

public class ListHelper<E> {
    private final List<E> list = Collections.synchronizedList(new ArrayList<>());

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

}

下面的才是正确的,就如上一小段所说,你必须确保你的类采取和你扩展的类一样的同步机制,你扩展的类对什么对象加锁,你就对什么对象加锁。另外,其它的操作你可以直接委托list对象去做,这里就省略了

public class ListHelper<E> {
    private final List<E> list = Collections.synchronizedList(new ArrayList<>());

    public boolean putIfAbsent(E x) {
        synchronized (list) {
            boolean absent = !list.contains(x);
            if (absent) list.add(x);
            return absent;
        }
    }
    // ... 省略其它的委托操作 ...

}

组合

与被扩展的类实现同一个接口,提供扩展方法,并在扩展类中提供一致的同步策略(上一样的锁)。

上面的类不关心底层list是否是线程安全的,如果底层list也加了锁,那么就会有一层额外的锁开销。

同步策略文档化

就是说一定要将你的类中的同步策略在文档中体现的事无巨细,比如加了什么锁,哪个方法将阻塞在哪个对象上,什么属性是volatile的等等,否则使用你的类的人会很痛苦。

posted @ 2022-04-04 15:15  yudoge  阅读(204)  评论(0编辑  收藏  举报