Java并发编程学习6-同步容器类和并发容器

简介: 【1月更文挑战第6天】本篇介绍同步容器类和并发容器的相关内容(Vector、ConcurrentHashMap、CopyOnWriteArrayList)

《Java并发编程学习系列》

java-concurrency-logo.png

引言

本篇开始将要介绍 Java 平台类库下的一些最常用的 并发基础构建模块,以及使用这些模块来构造并发应用程序时的一些常用模式。

ea2147b669614f27acab7d5605a505cb.png

同步容器类

同步容器类包括 VectorHashtable,还有由 Collections.synchronizedXxx 等工厂方法创建的同步的封装器类。

这些类实现线程安全性的方法是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。

1. 同步容器类的问题

同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来保护复合操作。

容器里常见的复合操作包括:

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

下面假设我们在 Vector 中定义两个复合操作的方法:getLastdeleteLast,它们都会执行 “先检查后执行” 操作。

    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);
    }

上面定义的两个方法,看似没有任何问题,从某种程度上来看也的确如此,无论多少个线程同时调用它们,也不会破坏 Vector

但如果从调用者的角度去看,如果线程 A 在包含 10 个元素的 Vector 上调用 getLast,同时线程 B 在同一个 Vector 上调用 deleteLast,这些操作交替执行如下图所示,getLast 将抛出 ArrayIndexOutOfBoundsException 异常。这里虽然很好地遵循了 Vector 的规范(如果请求一个不存在的元素,那么将抛出一个异常),但这并不是调用者所希望看到的结果,除非 Vector 一开始就是空的。

image.png

同步容器类通过自身的锁来保护它的每个方法,因此只要获得容器类的锁,上面的 getLastdeleteLast 方法就可以成为原子操作。

下面看一下代码示例:

    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);
        }
    }

getLast 一样,如果在对 Vector 进行迭代时,另一个线程删除了一个元素,并且这两个操作交替执行,那么这种迭代方法也将抛出 ArrayIndexOutOfBoundsException 异常。

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

虽然上述迭代操作可能抛出异常,但并不意味着 Vector 就不是线程安全的。Vector 的状态仍然是有效的,而抛出的异常也与其规范保持一致。

像读取最后一个元素或者迭代这样的简单操作中抛出异常,显然是调用者不愿意看到的。我们可以通过在迭代期间持有 Vector 的锁,可以防止其他线程在迭代期间修改 Vector。当然这会导致其他线程在迭代期间无法访问它,从而降低了并发性。

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

2. 迭代器与 ConcurrentModificationException

在设计同步容器类的迭代器时并没有考虑到并发修改的问题,并且它们表现出的行为是 “及时失败” 的。这意味着,当它们发现容器在迭代过程中被修改时,就会抛出一个 ConcurrentModificationException 异常。

这种 “及时失败” 的迭代器只能作为并发问题的预警指示器。如果在迭代期间计数器被修改,那么 hasNextnext 将抛出 ConcurrentModificationException。然而,这种检查是在没有同步的情况下进行的,因此可能会看到失效的计数值,而迭代器可能并没有意识到已经发生了修改。这是一种设计上的权衡,从而降低并发修改操作的检测代码对程序性能带来的影响。

下面我们看一个代码示例,使用 for-each 循环语法对 List 容器进行迭代。

    List<Person> personList = Collections.synchronizedList(new ArrayList<Person>());

    // 可能抛出 ConcurrentModificationException
    for (Person p : personList)
        doSomething(p);

从编译后的Class文件来看,上述 for-each 循环语法,javac 将生成使用 Iterator 的代码,反复调用 hasNextnext 来迭代 List 对象。 与迭代 Vector 一样,想要避免出现 ConcurrentModificationException,就必须在迭代过程中持有容器的锁。

如果不希望在迭代期间对容器加锁,那么可以“克隆”容器,并在副本上进行迭代。由于副本被封闭在线程内,因此其他线程不会在迭代期间对其进行修改,这样就避免了抛出 ConcurrentModificationException(在克隆过程中仍然需要对容器加锁)。

当然克隆容器存在显著的性能开销。这种方式的好坏,取决于容器的大小,在每个元素上执行的操作,迭代操作相对于容器其他操作的调用频率,以及在响应时间和吞吐量等方面的需求。

3. 隐藏迭代器

虽然加锁可以防止迭代器抛出 ConcurrentModificationException,但需要记住在所有对共享容器进行迭代的地方都需要加锁。

下面我们来看一个示例,在 HiddenIterator 中没有显式的容器迭代操作,但在 System.out.pringln 中将执行迭代操作。

    @NotThreadSafe
    public class HiddenIterator {
   
        @GuardedBy("this")
        private final Set<Integer> set = new HashSet<Integer>();

        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);
        }
    }

上述 System.out.println 代码中,编译器将字符串的连接操作转换为调用 StringBuilder.append(Object),而这个方法又会调用容器的 toString 方法,标准容器的 toString 方法将迭代容器,并在每个元素上调用 toString 来生成容器内容的格式化表示。并发环境下,addTenThings 方法可能会抛出 ConcurrentModificationException

如果状态与保护它的同步代码之间相隔越远,那么开发人员就越容易忘记在访问状态时使用正确的同步。如果 HiddenIteratorsynchronizedSet 来包装 HashSet,并且对同步的代码进行封装,那么就不会发生这种错误。

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

除了 toString 对容器进行迭代,还有容器的 hashCodeequalscontainsAllremoveAllretainAll 等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都有可能抛出 ConcurrentModificationException

并发容器

上面提到的同步容器,它是将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种方式的代价就是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重降低。

并发容器是针对多个线程并发访问而设计,如 ConcurrentHashMap,用于替代同步且基于散列的 Map;CopyOnWriteArrayList,用于在遍历操作为主要操作的情况下代替同步的 List。

通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险。

1. ConcurrentHashMap

HashMap 一样,ConcurrentHashMap 也是一个基于散列的 Map, 但它使用了一种粒度更细的加锁机制来实现更大程度的共享,提供更高的并发性和伸缩性,这种机制称为分段锁(Lock Striping,以后的博文会讲解到)。在这种机制中,任意数量的读取线程可以并发地访问 Map,执行读取操作的线程和执行写入操作的线程可以并发地访问 Map,并且一定数量的写入线程可以并发地修改 Map

ConcurrentHashMap 与其他并发容器一起增强了同步容器类,有如下的特点:

  • 它们提供的迭代器不会抛出 ConcurrentModificationException,因此不需要再迭代过程中对容器加锁。
  • ConcurrentHashMap 返回的迭代器具有弱一致性(Weakly Consistent),而并非 ”及时失败“。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。

对于一些需要在整个 Map 上进行计算的方法,例如 sizeisEmpty,这些方法的语义被略微减弱了以反映容器的并发特性。由于 size 返回的结果在计算时可能已经过期了,它实际上只是一个估计值,因此允许 size 返回一个近似值而不是一个精确值。事实上 sizeisEmpty 这样的方法在并发环境下的用处很小,因为它们的返回值总是不断变化。因此,这些操作的需求被弱化了,以及换取对其他更重要操作的性能优化,包括 getputcontainsKeyremove 等。

ConcurrentHashMap 中没有实现对 Map 加锁以提供独占访问,而在 HashtablesynchronizedMap 中,获得 Map 的锁能防止其他线程访问这个 Map。大多数情况下,用 ConcurrentHashMap 来代替同步 Map 能进一步提高代码的可伸缩性。

2. 额外的原子Map操作

由于 ConcurrentHashMap 不能被加锁来执行独占访问,因此无法使用客户端加锁来创造新的原子操作。 不过像 “若没有则添加”、“若相等则移除” 和 “若相等则替换” 等,都已经实现为原子操作并且在 ConcurrentMap 的接口中声明,如下代码所示:

    public intercace ConcurrentHashMap<K, V> extends Map<K, V> {
   
        // 仅当 K 没有相应的映射值时才插入
        V putIfAbsent(K key, V value);

        // 仅当 K 被映射到 V 才移除
        boolean remove(K key, V value);

        // 仅当 K 被映射到 oldValue 时才替换为 newValue
        boolean replace(K key, V oldValue, V newValue);

        // 仅当K 被映射到某个值时才替换为 newValue
        V replace(K key, V newValue);
    }

如果你需要在现有的同步 Map 中添加如上的操作,那么也就意味着应该考虑使用 ConcurrentMap 了。

3. CopyOnWriteArrayList

CopyOnWriteArrayList 用于替代同步 List,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。

类似地,CopyOnWriteArraySet 用于替代同步Set。

“写入时复制(Copy-On-Write)”容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。“写入时复制” 容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需确保数组内容的可见性。

显然,每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。仅当迭代操作远远多于修改操作时,才应该使用 “写入时复制” 容器。

许多事件通知系统中,在分发通知时需要迭代已注册监听器链表,并调用每一个监听器,在大多数情况下,注册和注销事件监听器的操作远少于接收事件通知的操作。

4. 阻塞队列

这块的篇幅较多,下一篇博文将会详细介绍,尽情期待!

目录
相关文章
|
3天前
|
安全 算法 Java
深入理解Java并发编程:线程安全与性能优化
【4月更文挑战第11天】 在Java中,高效的并发编程是提升应用性能和响应能力的关键。本文将探讨Java并发的核心概念,包括线程安全、锁机制、线程池以及并发集合等,同时提供实用的编程技巧和最佳实践,帮助开发者在保证线程安全的前提下,优化程序性能。我们将通过分析常见的并发问题,如竞态条件、死锁,以及如何利用现代Java并发工具来避免这些问题,从而构建更加健壮和高效的多线程应用程序。
|
1天前
|
Java 编译器
Java并发编程中的锁优化策略
【4月更文挑战第13天】 在Java并发编程中,锁是一种常见的同步机制,用于保证多个线程之间的数据一致性。然而,不当的锁使用可能导致性能下降,甚至死锁。本文将探讨Java并发编程中的锁优化策略,包括锁粗化、锁消除、锁降级等方法,以提高程序的执行效率。
8 4
|
2天前
|
Java 调度 开发者
Java 21时代的标志:虚拟线程带来的并发编程新境界
Java 21时代的标志:虚拟线程带来的并发编程新境界
11 0
|
2天前
|
Java Shell
Java 21颠覆传统:未命名类与实例Main方法的编码变革
Java 21颠覆传统:未命名类与实例Main方法的编码变革
9 0
|
2天前
|
Java
Java 15 神秘登场:隐藏类解析未知领域
Java 15 神秘登场:隐藏类解析未知领域
9 0
|
2天前
|
Java 存储
键值之道:深入学习Java中强大的HashMap(二)
键值之道:深入学习Java中强大的HashMap
8 0
键值之道:深入学习Java中强大的HashMap(二)
|
4天前
|
安全 Java
append在Java中是哪个类下的方法
append在Java中是哪个类下的方法
21 9
|
4天前
|
JavaScript Java 测试技术
基于Java的网络类课程思政学习系统的设计与实现(源码+lw+部署文档+讲解等)
基于Java的网络类课程思政学习系统的设计与实现(源码+lw+部署文档+讲解等)
13 0
基于Java的网络类课程思政学习系统的设计与实现(源码+lw+部署文档+讲解等)
|
1月前
|
安全 Java API
Java并发 - J.U.C并发容器类 list、set、queue
Queue API 阻塞是通过 condition 来实现的,可参考 Java 并发 - Lock 接口 ArrayBlockingQueue 阻塞 LinkedBlockingQueue 阻塞 ArrayQueue 非阻塞 LinkedQueue 非阻塞
|
10月前
|
存储 安全 算法
【Java并发编程 十一】JUC并发包下并发容器类(下)
【Java并发编程 十一】JUC并发包下并发容器类(下)
73 0