Skip to content

Latest commit

 

History

History
147 lines (112 loc) · 15.3 KB

05_Collections_and_Thread_Safety.md

File metadata and controls

147 lines (112 loc) · 15.3 KB

《《《 返回首页
《《《 上一节

集合和线程安全

当一个 Java 程序正在运行时,它正在执行一个或多个执行流或线程。 线程就像一个轻量级进程,所以同时执行多个线程的程序可以被认为是同时运行多个程序的计算 机,但是有一个重要区别:不同的线程可以同时访问相同的内存位置和其他系统资源。 在具有多个处理器的机器上,可以通过为每个线程分配处理器来实现真正的并发线 程执行。 但是,如果线程多于处理器 - 通常情况下 - 多线程是通过时间分片来实现的,其中处理器在切换到下一个线程之前依次执行来自每个线程的一些指令。

使用多线程编程有两个很好的理由。对于多内核和多处理器机器来说,一个明显的例子就是分享工作并更快完成工作。 (随着硬件设计人员越来越多地将并行化作为提高 整体性能的方式,这个原因变得越来越引人注目)。第二个原因是两个操作可能需要不同的(可能不知道的)时间量,而您不希望响应一个操作等待另一个完成。对于图形 用户界面(GUI)尤其如此,其中用户单击按钮的响应应该是立即的,并且如果程序碰巧正在运行应用程序的计算密集型部分,则不应该延迟时间。

尽管并发性对于获得良好的性能可能是至关重要的,但它的价格是有限的。不同的线程同时访问相同的内存位置可能会产生意想不到的结果,除非您小心限制其访问。考虑 示例 11-2,其中 ArrayStack 类使用数组和索引来实现接口 Stack,该接口 Stack 模拟 int 堆栈(尽管名称相似,但此示例与示例 5-1 不同)。为 了使 ArrayStack 正常工作,无论向堆栈中添加或删除多少个元素,变量索引都应始终指向堆栈的顶层元素。这是一个不变的类。现在想想如果两个线程同时尝试将元 素推入堆栈,会发生什么情况。作为 push 方法的一部分,每个方法都将执行行 // 1// 2,这些行在单线程环境中是正确的,但是在多线程环境中可能会破 坏不变量。例如,如果线程 A 执行行 // 1,则线程 B 执行行 // 1,然后执行行 // 2,最后线程 A 执行行 // 2,只有线程 B 添加的值现在将 位于堆栈上,它会覆盖线程 A 添加的值。但是,堆栈指针会增加 2,因此堆栈顶部位置的值就是之前发生的任何事情。这被称为竞争条件,它会使程序处于不一致的 状态,可能会失败,因为它的其他部分将取决于不变是真实的。

11-2。 非线程安全的堆栈实现

   interface Stack {
     public void push(int elt);
     public int pop();
     public boolean isEmpty();
   }
   class ArrayStack implements Stack{
     private final int MAX_ELEMENTS = 10;
     private int[] stack;
     private int index;
     public ArrayStack() {
       stack = new int[MAX_ELEMENTS];
       index = -1;
     }
     public void push(int elt) {
       if (index != stack.length - 1) {
         index++; 
         stack[index] = elt; //2
       } else {
         throw new IllegalStateException("stack overflow");
       }
     }
     public int pop() {
       if (index != -1) {
         return stack[index];
         index--;
     } else {
       throw new IllegalStateException("stack underflow");
     }
     }
     public boolean isEmpty() { return index == -1; }
   }

并发编程在 Java 生命周期中的重要性日益增加,集合库中对灵活和高效的并发策略的强调也相应增强。 作为 Java 集合的用户,您需要对不同集合的并发策略有 基本的了解,以了解如何在它们之间进行选择以及如何正确使用它们。 在本节中,我们将简要介绍框架集合处理并发的不同方式,以及对程序员的影响。 有关并发编程 的一般理论的完整处理,请参阅 Doug LeaAddison-Wesley)的 Java 中的并发编程,以及有关 Java 中的并发性和集合实现的详细信息,请参阅 Brian Goetz 等人的中的 Java并发实践。(Addison-Wesley 出版社)。

同步和旧版集合

类似于 ArrayStack 的代码不是线程安全的,它由单线程执行,但可能在多线程环境中中断。 由于我们观察到的错误行为涉及两个线程同时执行 push 方法,因此 我们可以更改程序以使其不可能。 使用 synchronized 来修改 push 方法的声明将保证一旦一个线程开始执行它,所有其他线程将被排除在该方法之外直到执行完 成:

   public synchronized void push(int elt) { ... }

这称为同步代码的关键部分,在这种情况下是整个推送方法。在一个线程可以执行同步代码之前,它必须获得某个对象的锁定 - 默认情况下,就像在这种情况下一样,当前对象。当锁由一个线程持有时,另一个线程尝试进入在该锁上同步的任何关键部分将会阻塞 - 即,将被暂停 - 直到它可以获得锁。这种同步版本的推送是线程安全的;在多线程环境中,每个线程的行为与其在单线程环境中的行为保持一致。为了保证不变,并使 ArrayStack 作为一个整体线程安全,方法pop和isEmpty也必须在同一个对象上同步。方法 isEmpty 不写入共享数据,因此不需要同步它来防止竞争条件,但出于不同的原因。每个线程都可以使用单独的内存缓存,这意味着一个线程的写入操作可能不会被另一个线程看到,除非它们都发生在同一个锁上同步的块内,或者除非变量标记了 volatile 关键字。

实际上,完全方法同步是 JDK1.0 中提供的集合类的策略:VectorHashtable 及其子类;访问其实例数据的所有方法都是同步的。这些现在被视为遗留类,因为这种政策对这些类的所有客户提出高昂的价格是要避免的,无论它们是否需要线程安全。同步可能非常昂贵:强制线程逐个排队进入关键部分,会减慢程序的整体执行速度,如果经常发生争用,管理锁的开销可能非常高。

JDK 1.2:同步集合和失败快速迭代器

JDK 1.2 中首次引入集合框架时,JDK 1.0 集合中内部同步的性能成本使得设计人员避免了这种情况。相反,接口 ListSetMap 的平台实现扩大了程序员对并发策略的选择范围。为了为单线程执行提供最高性能,新的集合根本没有提供并发控制。 (最近,对同步类 StringBuffer 进行了相同的策略更改,在 Java 5 中通过其未同步的等效 StringBuilder 对其进行了补充。)

随着这一变化,为集合迭代器提供了一个新的并发策略。在多线程环境中,获取迭代器的线程通常会继续使用它,而其他线程修改原始集合。因此,迭代器行为必须被视为集合并发策略的组成部分。如第 11.1 节所述,Java 2 集合迭代器的策略是快速失败:每次访问后备集合时,都会检查它是否进行结构修改(通常意味着元素已添加或从中删除集合)。如果他们检测到结构修改,则立即失败,抛出 ConcurrentModificationException 而不是继续尝试迭代修改后的集合,结果不可预知。请注意,此故障快速行为用于帮助查找和诊断错误;它不作为收集合同的一部分得到保证。

没有强制同步的 Java 集合的出现是一个值得欢迎的发展。 然而,在很多情况下仍然需要线程安全的集合,所以框架通过同步包装提供了一个选择,使用新的集合和旧的并发策略(请参阅第 17 章)。 这些是通过调用 Collections 类中的一个工厂方法创建的,它提供了一个将被封装的非同步集合。 例如,要创建一个同步列表,您可以提供一个要包装的 ArrayList 实例。 包装器通过将方法调用委托给您提供的集合来实现接口,但调用在包装器对象本身上同步。 例 11-3 显示了例 11-2 的接口 Stack 的一个同步封装器。 要得到一个线程安全的堆栈,你可以这样写:

   Stack threadSafe = new SynchronizedArrayStack(new ArrayStack());

这是使用同步包装的首选方式; 对包装对象的唯一引用是由包装器保存的,因此包装对象上的所有调用都将在属于包装器对象本身的同一个锁上进行同步。 让同步的包装器可用是非常重要的,但是你不会使用它们,因为它们与传统集合具有相同的性能缺点。

11-3。 一个 ArrayStack 的同步包装器

   public class SynchronizedArrayStack implements Stack {
     private final Stack stack;
     public SynchronizedArrayStack(Stack stack) {
       this.stack = stack;
     }
     public synchronized void push(int elt) { stack.push(elt); }
     public synchronized int pop() { return stack.pop(); }
     public synchronized boolean isEmpty() { return stack.isEmpty(); }
   }

安全地使用同步集合即使像 SynchronizedArrayStack 这样拥有完全同步方法并且本身是线程安全的类仍然必须在并发环境中小心使用。 例如,这个客户端代码不是线程安全的:

   Stack stack = new SynchronizedArrayStack(new ArrayStack());
   ...
   // 不要在多线程环境中执行此操作
   if (!stack.isEmpty()) {
   stack.pop(); // 可以抛出IllegalStateException
   }

如果在评估 isEmpty 和执行 pop 之间的时间内堆栈中的最后一个元素被另一个线程删除,则会引发异常。 这是一个常见的并发程序错误的例子,有时称为 test-then-act,其中程序行为由在某些情况下会过时的信息指导。 为了避免它,测试和操作必须以原子方式执行。 对于同步集合(与旧集合一样),必须通过客户端锁定实施:

   synchronized(stack) {
     if (!stack.isEmpty()) {
       stack.pop();
     }
   }

为了使这种技术能够可靠地工作,客户端用来保护原子动作的锁定应该与同步包装器的方法所使用的相同。在本例中,与同步集合中一样,包装器的方法是 在包装器对象本身上同步。(另一种方法是在单个客户端中限制对集合的引用,从而强化自己的同步规则。但是这种策略的适用性有限。)

客户端锁定确保线程安全,但代价是:由于其他线程在执行操作时不能使用任何集合的方法,因此守护长时间操作(例如遍历整个阵列)将影响吞吐量 如果同步方法大量使用,这种影响可能非常大; 除非您的应用程序需要同步集合的功能,例如独占锁定,否则 Java 5 并发集合几乎总是更好的选择。

并发集合:Java 5 和其他

Java 5 引入了线程安全的并发集合,作为一组更大的并发实用程序的一部分,其中包括原语 - 原子变量和锁 - 它们使 Java 程序员能够访问管理并发线程的相对最新的硬件创新,特别是比较和交换操作,下面解释。并发集合删除了前面部分描述的客户端锁定的必要性 - 事实上,这些集合甚至不可能实现外部同步,因为没有一个对象在被锁定时会阻止所有方法。操作需要是原子操作的,例如,只有当元素当前不存在时才将元素插入到Map中 - 并发集合提供了一个指定为自动执行的方法 - 在本例中为 ConcurrentMap.putIfAbsent

如果您需要线程安全性,并发集合通常提供比同步集合更好的性能。这主要是因为它们的吞吐量并未因需要序列化访问而降低,正如同步集合的情况一样。同步的集合也遭受管理锁的开销,如果争用太多,则锁可能很高。这些差异会导致超过几个线程的并发访问的两个数量级的效率差异。

机制并发集合通过几种不同的机制实现线程安全。其中第一个是唯一不使用新基元的是写时复制。使用 copy-on-write 的类将它们的值存储在内部数组中,该数组实际上是不可变的;对集合值的任何更改都会导致创建一个新数组来表示新值。同步被这些类使用,尽管只是简单地在创建新数组期间使用;由于读取操作不需要同步,因此写入时复制集合在设计它们的情况下表现良好,其中读取在写入方面占据主导地位。Copy-on-write 由集合类 CopyOnWriteArrayListCopyOnWriteArraySet 使用。

第二组线程安全集合依赖于比较和交换(CAS),这是对传统同步的根本改进。为了看它是如何工作的,考虑一种计算,其中将单个变量的值用作长期运行计算的输入,该计算的最终结果用于更新变量。传统的同步使得整个计算为原子操作,排除其他任何线程同时访问变量。这减少了并行执行的机会并且损害了吞吐量。基于 CAS 的算法表现方式不同:它会生成变量的本地副本并执行计算而不会获得排他访问权限。只有当准备好更新变量时,它才会调用 CASCAS 在一次原子操作中将变量值与开始时的值进行比较,如果它们相同,则用新值更新它。如果它们不相同,则该变量必须由另一个线程修改;在这种情况下,CAS 线程可以使用新值再次尝试整个计算,或者放弃,或者在某些算法中继续执行,因为干扰实际上已经完成了它的工作!使用 CAS 的集合包括 ConcurrentLinkedQueueConcurrentSkipListMap

第三组使用 java.util.concurrent.locks.Lock 的实现,Java 5 中引入的接口作为经典同步的更灵活的替代方案。锁具有与传统同步相同的基本行为,但线程也可以在特殊情况下获得它:只有当锁没有被保持,或者超时,或者线程没有中断时。与代码块或方法执行时保持对象锁的同步代码不同,锁被保持,直到它的解锁方法被调用。该组中的一些集合类使用这些工具将集合划分为可以单独锁定的部分,从而提高了并发性。例如,LinkedBlockingQueue 对队列的头部和尾部分别具有锁定,以便可以并行添加和删除元素。其他使用这些锁的集合包括 ConcurrentHashMapBlockingQueue 的大部分实现

迭代器上面描述的机制导致迭代器策略更适合并发使用,而不是快速失败,这隐含地将并发修改视为要消除的问题。写时复制集合具有快照迭代器。这些集合由数组支持,这些数组一旦创建,就永远不会改变;如果集合中的值需要更改,则会创建一个新数组。所以迭代器可以读取其中一个数组中的值(但从不修改它们),而没有被另一个线程更改的危险。快照迭代器不会抛出 ConcurrentModificationException

上述第三组也具有弱一致的迭代器。 在 Java 6 中,这包括 DelayQueuePriorityBlockingQueue,它们在 Java 5 中具有快速迭代器。这意味着,除非这些队列是静态的,否则当不添加或插入元素时,您不能迭代这些队列的 Java 5 版本; 在其他时候,你必须使用 toArray 将它们的元素复制到一个数组中,然后遍历它。

《《《 下一节
《《《 返回首页