[笔记] Java 并发 – 基础构建模块

By | 7月 16, 2018

同步容器类

同步容器类包括 Vector / Hashtable / … 这些同步的封装器类是由 Collections.synchronizedXxx 等工厂方法创建的。
这些类实现线程安全的方式是:把它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。

同步容器类的问题

同步容器类都是线程安全的,也就是说直接调用暴露的公共方法都是没有任何安全问题的。但问题出在复合操作上,常见的比如:

  • 迭代(反复访问元素,直到遍历完容器中的所有元素)
  • 跳转(根据指定顺序找到当前元素的下一个元素)
  • 条件运算(若没有则添加之类的)

这些操作进行的过程中,如果出现其他线程并发的修改容器,那么就会出现意料之外的错误。
比如下面这两个非常精简的方法:

public static Object getLast(Vector list) {
    int lastIndex = list.size() - 1;
    return list.get(lastIndex);
}
public static void deleteLast(Vector list) {
    int lastIndex = list.size() - 1;
    list.remove(lastIndex);
}

在不同线程并发调用这两个方法将会出现问题,考虑一个 10 个元素的 Vector,两个线程同时调用了 list.size() 获得了元素个数 10,现在 deleteList 先执行了 remove 操作,删除了最后一个元素,之后 getLast 操作被执行,尝试去获取第 10 个元素,显然现在已经没有第 10 个元素了,所以会抛出 ArrayIndexOutOfBoundsException 异常。
解决方案也很简单,因为同步容器类要遵守同步策略,即支持客户端加锁,所以只要在执行前获取该容器对象的锁即可:

public static Object getLast(Vector list) {
    synchronized (list) {
        int lastIndex = list.size() - 1;
        return list.get(lastIndex);
    }
}
public static void deleteLast(Vector list) {
    synchronized (list) {
        int lastIndex = list.size() - 1;
        list.remove(lastIndex);
    }
}

这样可以确保 Vector 的大小在调用 size 和 get 之间不会发生变化。
同理对于下面的迭代操作,如果涉及多线程调用,也要进行加锁:

for (int i = 0; i < vector.size(); i++) {
    doSomething(vector.get(i));
}

正确姿势:

synchronized (vector) {
    for (int i = 0; i < vector.size(); i++) {
        doSomething(vector.get(i));
    }
}

迭代器与 ConcurrentModificationException

迭代器在使用的过程中,如果他们发现容器在迭代过程中被修改,那么就会抛出一个 ConcurrentModificationException 异常,这被称为 fail-fast。
内部实现的方式是:将计数器的变化与容器关联起来,如果在迭代期间计数器被修改,那么 hasNext 或 next 将抛出 ConcurrentModificationException。但是这种检查并没有同步,所以也有可能会看到失效的计数值,在容器数量发生变化的时候也有可能迭代器并没有感知到。算是一种设计上的权衡。
解决方式有两种:

  • 如果在使用迭代器的时候避免这种情况,那么也需要持有容器的锁
  • 另外一种解决方式是克隆容器,并在副本上进行迭代

两种方式的使用根据自己的场景,各有优劣。

隐藏的迭代器

上面的例子是非常显而易见的,但是有的时候,错误并不是那么明显,比如下面的大坑:

public class HiddenIterator {
    private final Set set = new HashSet();
    public synchronized void add(Integer i) {
        set.add(i);
    }
    public synchronized void remove(Integer i) {
        set.remove(i);
    }
    public void addTenThings() {
        Random r = new Random();
        for (int i = 0; i < 10; i++) {
            add(r.nextInt());
        }
        System.out.println("DEBUG: added ten elements to " + set);
    }
}

可能会抛出 ConcurrentModificationException 的地方是在调用 System.out.println 的地方:

  • 字符串连接会转换为调用 StringBuilder.append(Object)
  • 这个方法又会调用容器的 toString() 方法
  • 标准容器的 toString 又会在每个元素上调用 toString 来生成容器内容的格式化表示

所以:如果状态与保护它的代码之间相隔越远,那么开发人员就越容易忘记在访问状态时使用正确的同步策略

并发容器

在多线程场景下,如非特殊需要,一律使用并发容器来代替同步容器,可以极大的降低风险。

ConcurrentHashMap

ConcurrentHashMap 使用分段锁来实现了更细粒度的加锁机制,所以执行读取操作的线程可以并发的访问 Map,并且可以有一定数量的写入线程在并发的修改 Map。
ConcurrentHashMap 还增强了迭代器,在迭代的过程中,不会抛出 ConcurrentModificationException,因此不需要在迭代过程中对容器加锁。ConcurrentHashMap 的迭代器具有弱一致性,可以容忍并发的修改。
但是有了这些操作的 ConcurrentHashMap 并不完美,有一些方法的精确性就被权衡掉了,比如 size 和 isEmpty,因为在计算他们的时候可能已经过期了,所以返回的只能算是一个估计值。但因为在并发环境下,他们的用处很小,因为他们的返回值总在不断变化,所以他们的精确度的要求被弱化了,来换取其他更重要的操作的性能优化,比如 get/put/containsKey/remove 等。
ConcurrentHashMap 中没有实现对 Map 加锁以提供独占访问。
所以如果有这个需求的时候,比如对 Map 迭代若干次并在此期间保持元素顺序相同,那么就不能使用它,反而会需要使用 synchronizedMap /Hashtable 等。
常见的复合操作已经都放到了 ConcurrentMap 接口中,比如若没有则添加(putIfAbsent),若相等则替换(replace)等。如果有这方面的需求,直接使用 ConcurrentMap 对应的实现是最佳的选择。

发表回复

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