tws mirror 物联网项目 log4j bam validation threejs configuration joomla laravel4 tags vue例子 jquery事件绑定方法 安卓小程序源码 linux环境变量生效 mysql批量更新数据 js字符串排序 html好看的字体样式 windows杀进程命令 python或运算 python中的循环 python网页编程 java包 java时间转时间戳 java当前日期 linux教程 谷歌地球打不开 java小程序 摩尔斯电码翻译器 制作字幕的软件 h370主板 如何强行退出小米账号 begininvoke 小米手环充电多久 dxsetup 电脑代码雨 python图片处理 dos系统下载 大数据之路 windows游戏编程 mathcad
当前位置: 首页 > 学习教程  > 编程语言

Java并发编程 同步容器类

2021/1/28 23:29:31 文章标签:

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

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

1. 同步器类的问题

同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来保护复合操作。容器上常见的复合操作包括:迭代(反复访问元素,直到遍历完容器中所有元素)、跳转(根据指定顺序找到当前元素的下一个元素〉以及条件运算,例如“若没有则添加”(检查在Map 中是否存在键值K,如果没有,就加入二元组(K,V)。在同步容器类中,这些复合操作在没有客户端加锁的情况下仍然是线程安全的,但当其他线程并发地修改容器时,它们可能会表现出意料之外的行为。

下面程序给出了在 Vector 中定义的两个方法:getLast 和 deleteLast,它们都会执行“先检查再运行”操作。每个方法首先都获得数组的大小,然后通过结果来获取或删除最后一个元素。

// Vector 上可能导致混乱结果的复合操作
public static Object getLast(Vector list) {
    int lastIndex = list.size() - 1;
    return list.get(lastIndex);
}

public static void deleteLast(Vector list) {
    int lastIndex = last.size() - 1;
    list.remove(lastIndex);
}

这样的方法看似没有任何问题,从某种程度上来看也确实如此——无论多少个线程同时调用它们,也不破坏Vector。但从这些方法的调用者角度来看,情况就不同了。如果线程 A 在包含 10 个元素的 Vector 上调用 getLast,同时线程 B 在同一个 Vector 上调用 deleteLast,这些操作的交替执行如下图所示,getLast 将抛出异常。在调用 size 与调用 getLast 这两个操作之间,Vector 变小了,因此在调用 size 时得到的索引值将不再有效。这种情况很好地遵循了 Vector 的规范——如果请求一个不存在的元素,那么将抛出一个异常。但这并不是getLast 的调用者所希望得到的结果(即使在并发修改的情况下也不希望看到),除非 Vector 从一开始就是空的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XGLozjuN-1611847535413)(…/images/image-20210128230038797.png)]

由于同步容器类要遵守同步策略,即支持客户端加锁,因此可能会创建一些新的操作,只要我们知道应该使用哪一个锁,那么这些新操作就与容器的其他操作一样都是原子操作。同步容器类通过其自身的锁来保护它的每个方法。通过获得容器类的锁,我们可以使 getLast 和deleteLast 成为原子操作,并确保 Vector 的大小在调用 size 和 get 之间不会发生变化,如下面程序所示。

// 在使用客户端加锁的 Vector 上的复合操作
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);
    }
}

在调用 size 和相应的 get 之间,Vector 的长度可能会发生变化,这种风险在对 Vector 中的元素进行迭代时仍然会出现,如下面程序所示。

// 可能抛出 ArrayIndexOutOfBoundsException 的迭代操作
for (int i = 0; i < vector.size(); ++i) {
    vector.get(i);
}

这种迭代操作的正确性要依赖于运气,即在调用 size 和 get 之间没有线程会修改 Vector。在单线程环境中,但在有其他线程并发地修改 Vector 时,则可能导致麻烦。与 getLast 一样,如果在对 Vector 进行迭代时,另一个线程删除了一个元素,并且这两个操作交替执行,那么这种迭代方法将抛出 ArrayIndexOutOfBoundsException 异常。
虽然在迭代操作中可能抛出异常,但并不意味着 Vector 就不是线程安全的。Vector 的状态仍然是有效的,而抛出的异常也与其规范保持一致。
我们可以通过在客户端加锁来解决不可靠迭代的问题,但要牺牲一些伸缩性。通过在迭代.期间持有 Vector 的锁,可以防止其他线程在迭代期间修改 Vector,如下面代码所示。然而,这同样会导致其他线程在迭代期间无法访问它,因此降低了并发性。

// 带有客户端加锁的迭代
synchronized (vector) {
    for (int i = 0; i < vector.size(); i++) {
        vector.get(i);
    }
}

2. 迭代器与 ConcurrentModificationException

为了将问题阐述清楚,我们使用了 Vector,这是一个“古老”的容器类。然而,许多“现代”的容器类也并没有消除复合操作中的问题。然而,如果有其他线程并发地修改容器,那么即使是使用迭代器也无法避免在迭代期间对容器加锁。在设计同步容器类的迭代器时并没有考虑到并发修改的问题,并且它们表现出的行为是“及时失败”( fail-fast)的。这意味着,当它们发现容器在迭代过程中被修改时,就会抛出一个 ConcurrentModificationException 异常。
这种“及时失败”的迭代器并不是一种完备的处理机制,而只是“善意地”捕获并发错误,因此只能作为并发问题的预警指示器。它们采用的实现方式是,将计数器的变化与容器关联起来:如果在迭代期间计数器被修改,那么 hasNext 或 next 将抛出 ConcurrentModificationException。然而,这种检查是在没有同步的情况下进行的,因此可能会看到失效的计数值,而迭代器可能并没有意识到已经发生了修改。这是一种设计上的权衡,从而降低并发修改操作的检测代码对程序性能带来的影响。

下面代码说明了如何使用 for-each 循环语法对 List 容器进行迭代。从内部来看,javac 将生成使用 Iterator 的代码,反复调用hasNext 和 next 来迭代 List 对象。与迭代 Vector 一样,要想避免出现 ConcurrentModificationException,就必须在迭代过程持有容器的锁。

// 通过 iterator 来迭代 List
List<Widget> widgetList = Collections.synchronizedList(new ArrayList<Widget>());
...
// 可能抛出异常
for (Widget w : widgetList) {
    doSomething(w);
}

有时候开发人员并不希望在迭代期间对容器加锁。如果容器的规模很大,或者在每个元素上执行操作的时间很长,那么这些线程将长时间等待。同样,如果容器那样加锁,那么在调用 doSomething 时将持有一个锁,这可能会产生死锁。即使不存在饥饿或者死锁等风险,长时间地对容器加锁也会降低程序的可伸缩性。持有锁的时间越长,那么在锁上的竞争就可能越激烈,如果许多线程都在等待锁被释放,那么将极大地降低吞吐量和 CPU 的利用率。
如果不希望在迭代期间对容器加锁,那么一种替代方法就是“克隆”容器,并在副本土进行迭代。由于副本被封闭在线程内,因此其他线程不会在选代期间对其进行修改,这样就避免了抛出ConcurrentModificationException (在克隆过程中仍然需要对容器加锁)。在克隆容器时存在显著的性能开销。这种方式的好坏取决于多个因素,包括容器的大小,在每个元素上执行的工作,迭代操作相对于容器其他操作的调用频率,以及在响应时间和吞吐量等方面的需求。

3. 隐藏迭代器

虽然加锁可以防止迭代器抛出 ConcurrentModificationException,但你必须要记住在所有对共享容器进行迭代的地方都需要加锁。实际情况要更加复杂,因为在某些情况下,迭代器会隐藏起来,如下面程序中的 HiddenIterator 所示。在 HiddenIterator 中没有显式的迭代操作。编译器将字符串的连接操作转换为调用 StringBuilder.append(Object),而这个方法又会调用容器的toString 方法,标准容器的 toString 方法将迭代容器,并在每个元素上调用 toString 来生成容器内容的格式化表示。

// 隐藏在字符串连接中的迭代操作(不要这么做)
public class HiddenIterator {
    @GuardeBy("this")
    private final Set<Integer> set = new HastSet<>();
    
    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);
    }
}

addTenThings 方法可能会抛出 ConcurrentModificationException,因为在生成调试消息的过程中,toString 对容器进行迭代。当然,真正的问题在于 HiddenIteracor 不是线程安全的。在使用println 中的 set 之前必须首先获取 HiddenIterator 的锁,但在调试代码和日志代码中通常会忽视这个要求。
这里得到的教训是,如果状态与保护它的同步代码之间相隔越远,那么开发人员就越容易忘记在访问状态时使用正确的同步。如果 HiddenIterator 用 synchronizedSet 来包装HashSet,并且对同步代码进行封装,那么就不会发生这种错误。

正如封装对象的状态有助于维持不变性条件一样,封装对象的同步机制同样有助于确保实施同步策略。

容器的 hashCode 和 equals 等方法也会间接地执行迭代操作,当容器作为另一个容器的元素或键值时,就会出现这种情况。同样,containsAll、rennoveAll 和 retainAll等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都可能抛出 ConcurrentModificationException。


本文链接: http://www.dtmao.cc/news_show_650228.shtml

附件下载

相关教程

    暂无相关的数据...

共有条评论 网友评论

验证码: 看不清楚?