共享对数据的访问 共享变量 要使多个线程在一个程序中有用,它们必须有某种方法可以互相通信或共享它们的结果。 让线程共享其结果的最简单方法是使用共享变量。它们还应该使用同步来确保值从一个线程正确传播到另一个线程,以及防止当一个线程正在更新一些相关数据项时,另一个线程看到不一致的中间结果。 线程基础 中计算素数的示例使用了一个共享布尔变量,用于表示指定的时间段已经过去了。这说明了在线程间共享数据最简单的形式是:轮询共享变量以查看另一个线程是否已经完成执行某项任务。 存在于同一个内存空间中的所有线程 正如前面讨论过的,线程与进程有许多共同点,不同的是线程与同一进程中的其它线程共享相同的进程上下文,包括内存。这非常便利,但也有重大责任。只要访问共享变量(静态或实例字段),线程就可以方便地互相交换数据,但线程还必须确保它们以受控的方式访问共享变量,以免它们互相干扰对方的更改。 任何线程可以访问所有其作用域内的变量,就象主线程可以访问该变量一样。素数示例使用了一个公用实例字段,叫做 finished,用于表示已经过了指定的时间。当计时器过期时,一个线程会写这个字段;另一个线程会定期读取这个字段,以检查它是否应该停止。注:这个字段被声明成 volatile,这对于这个程序的正确运行非常重要。在本章的后面,我们将看到原因。 -------------------------------------------------------------------------------- 受控访问的同步为了确保可以在线程之间以受控方式共享数据,Java 语言提供了两个关键字:synchronized 和 volatile。 Synchronized 有两个重要含义:它确保了一次只有一个线程可以执行代码的受保护部分(互斥,mutual exclusion 或者说 mutex),而且它确保了一个线程更改的数据对于其它线程是可见的(更改的可见性)。 如果没有同步,数据很容易就处于不一致状态。例如,如果一个线程正在更新两个相关值(比如,粒子的位置和速率),而另一个线程正在读取这两个值,有可能在第一个线程只写了一个值,还没有写另一个值的时候,调度第二个线程运行,这样它就会看到一个旧值和一个新值。同步让我们可以定义必须原子地运行的代码块,这样对于其他线程而言,它们要么都执行,要么都不执行。 同步的原子执行或互斥方面类似于其它操作环境中的临界段的概念。 确保共享数据更改的可见性同步可以让我们确保线程看到一致的内存视图。 处理器可以使用高速缓存加速对内存的访问(或者编译器可以将值存储到寄存器中以便进行更快的访问)。在一些多处理器体系结构上,如果在一个处理器的高速缓存中修改了内存位置,没有必要让其它处理器看到这一修改,直到刷新了写入器的高速缓存并且使读取器的高速缓存无效。 这表示在这样的系统上,对于同一变量,在两个不同处理器上执行的两个线程可能会看到两个不同的值!这听起来很吓人,但它却很常见。它只是表示在访问其它线程使用或修改的数据时,必须遵循某些规则。 Volatile 比同步更简单,只适合于控制对基本变量(整数、布尔变量等)的单个实例的访问。当一个变量被声明成 volatile,任何对该变量的写操作都会绕过高速缓存,直接写入主内存,而任何对该变量的读取也都绕过高速缓存,直接取自主内存。这表示所有线程在任何时候看到的 volatile 变量值都相同。 如果没有正确的同步,线程可能会看到旧的变量值,或者引起其它形式的数据损坏。 用锁保护的原子代码块Volatile 对于确保每个线程看到最新的变量值非常有用,但有时我们需要保护比较大的代码片段,如涉及更新多个变量的片段。 同步使用监控器(monitor)或锁的概念,以协调对特定代码块的访问。 每个 Java 对象都有一个相关的锁。同一时间只能有一个线程持有 Java 锁。当线程进入 synchronized 代码块时,线程会阻塞并等待,直到锁可用,当它可用时,就会获得这个锁,然后执行代码块。当控制退出受保护的代码块时,即到达了代码块末尾或者抛出了没有在 synchronized 块中捕获的异常时,它就会释放该锁。 这样,每次只有一个线程可以执行受给定监控器保护的代码块。从其它线程的角度看,该代码块可以看作是原子的,它要么全部执行,要么根本不执行。 简单的同步示例使用 synchronized 块可以让您将一组相关更新作为一个集合来执行,而不必担心其它线程中断或看到计算的中间结果。以下示例代码将打印“1 0”或“0 1”。如果没有同步,它还会打印“1 1”(或“0 0”,随便您信不信)。 public class SyncExample { private static lockObject = new Object(); private static class Thread1 extends Thread { public void run() { synchronized (lockObject) { x = y = 0; System.out.println(x); } } } private static class Thread2 extends Thread { public void run() { synchronized (lockObject) { x = y = 1; System.out.println(y); } } } public static void main(String[] args) { new Thread1().run(); new Thread2().run(); } }
在这两个线程中都必须使用同步,以便使这个程序正确工作。 Java 锁定Java 锁定合并了一种互斥形式。每次只有一个线程可以持有锁。锁用于保护代码块或整个方法,必须记住是锁的身份保护了代码块,而不是代码块本身,这一点很重要。一个锁可以保护许多代码块或方法。 反之,仅仅因为代码块由锁保护并不表示两个线程不能同时执行该代码块。它只表示如果两个线程正在等待相同的锁,则它们不能同时执行该代码。 在以下示例中,两个线程可以同时不受限制地执行 setLastAccess() 中的 synchronized 块,因为每个线程有一个不同的 thingie 值。因此,synchronized 代码块受到两个正在执行的线程中不同锁的保护。 public class SyncExample { public static class Thingie { private Date lastAccess; public synchronized void setLastAccess(Date date) { this.lastAccess = date; } } public static class MyThread extends Thread { private Thingie thingie; public MyThread(Thingie thingie) { this.thingie = thingie; } public void run() { thingie.setLastAccess(new Date()); } } public static void main() { Thingie thingie1 = new Thingie(), thingie2 = new Thingie(); new MyThread(thingie1).start(); new MyThread(thingie2).start(); } }
同步的方法创建 synchronized 块的最简单方法是将方法声明成 synchronized。这表示在进入方法主体之前,调用者必须获得锁: public class Point { public synchronized void setXY(int x, int y) { this.x = x; this.y = y; } }
对于普通的 synchronized方法,这个锁是一个对象,将针对它调用方法。对于静态 synchronized 方法,这个锁是与 Class 对象相关的监控器,在该对象中声明了方法。 仅仅因为 setXY() 被声明成 synchronized 并不表示两个不同的线程不能同时执行 setXY(),只要它们调用不同的 Point 实例的 setXY() 就可同时执行。对于一个 Point 实例,一次只能有一个线程执行 setXY(),或 Point 的任何其它 synchronized 方法。 同步的块synchronized 块的语法比 synchronized 方法稍微复杂一点,因为还需要显式地指定锁要保护哪个块。Point 的以下版本等价于前一页中显示的版本: public class Point { public void setXY(int x, int y) { synchronized (this) { this.x = x; this.y = y; } } }
使用 this 引用作为锁很常见,但这并不是必需的。这表示该代码块将与这个类中的 synchronized 方法使用同一个锁。 由于同步防止了多个线程同时执行一个代码块,因此性能上就有问题,即使是在单处理器系统上。最好在尽可能最小的需要保护的代码块上使用同步。 访问局部(基于堆栈的)变量从来不需要受到保护,因为它们只能被自己所属的线程访问。 大多数类并没有同步因为同步会带来小小的性能损失,大多数通用类,如 java.util 中的 Collection 类,不在内部使用同步。这表示在没有附加同步的情况下,不能在多个线程中使用诸如 HashMap 这样的类。 通过每次访问共享集合中的方法时使用同步,可以在多线程应用程序中使用 Collection 类。对于任何给定的集合,每次必须用同一个锁进行同步。通常可以选择集合对象本身作为锁。 下一页中的示例类 SimpleCache 显示了如何使用 HashMap 以线程安全的方式提供高速缓存。但是,通常适当的同步并不只是意味着同步每个方法。 Collections 类提供了一组便利的用于 List、Map 和 Set 接口的封装器。您可以用 Collections.synchronizedMap 封装 Map,它将确保所有对该映射的访问都被正确同步。 如果类的文档没有说明它是线程安全的,那么您必须假设它不是。 示例:简单的线程安全的高速缓存如以下代码样本所示,SimpleCache.java 使用 HashMap 为对象装入器提供了一个简单的高速缓存。load() 方法知道怎样按对象的键装入对象。在一次装入对象之后,该对象就被存储到高速缓存中,这样以后的访问就会从高速缓存中检索它,而不是每次都全部地装入它。对共享高速缓存的每个访问都受到 synchronized 块保护。由于它被正确同步,所以多个线程可以同时调用 getObject 和 clearCache 方法,而没有数据损坏的风险。 public class SimpleCache { private final Map cache = new HashMap(); public Object load(String objectName) { // load the object somehow } public void clearCache() { synchronized (cache) { cache.clear(); } } public Object getObject(String objectName) { synchronized (cache) { Object o = cache.get(objectName); if (o == null) { o = load(objectName); cache.put(objectName, o); } } return o; } }
小结由于线程执行的计时是不确定的,我们需要小心,以控制线程对共享数据的访问。否则,多个并发线程会互相干扰对方的更改,从而损坏数据,或者其它线程也许不能及时看到对共享数据的更改。 通过使用同步来保护对共享变量的访问,我们可以确保线程以可预料的方式与程序变量进行交互。 每个 Java 对象都可以充当锁,synchronized 块可以确保一次只有一个线程执行由给定锁保护的 synchronized 代码。同步详细信息互斥在共享对数据的访问 中,我们讨论了 synchronized 块的特征,并在实现典型互斥锁(即,互斥或临界段)时说明了它们,其中每次只有一个线程可以执行受给定锁保护的代码块。 互斥是同步所做工作的重要部分,但同步还有其它几种特征,这些特征对于在多处理器系统上取得正确结果非常重要。 -------------------------------------------------------------------------------- 回页首 可见性除了互斥,同步(如 volatile)强制某些可见性约束。当对象获取锁时,它首先使自己的高速缓存无效,这样就可以保证直接从主内存中装入变量。 同样,在对象释放锁之前,它会刷新其高速缓存,强制使已做的任何更改都出现在主内存中。 这样,会保证在同一个锁上同步的两个线程看到在 synchronized 块内修改的变量的相同值。 -------------------------------------------------------------------------------- 回页首 什么时候必须同步?要跨线程维护正确的可见性,只要在几个线程之间共享非 final 变量,就必须使用 synchronized(或 volatile)以确保一个线程可以看见另一个线程做的更改。 可见性同步的基本规则是在以下情况中必须同步:•读取上一次可能是由另一个线程写入的变量 •写入下一次可能由另一个线程读取的变量 --------------------------------------------------------------------------------用于一致性的同步 除了用于可见性的同步,从应用程序角度看,您还必须用同步来确保一致性得到了维护。当修改多个相关值时,您想要其它线程原子地看到这组更改 ― 要么看到全部更改,要么什么也看不到。这适用于相关数据项(如粒子的位置和速率)和元数据项(如链表中包含的数据值和列表自身中的数据项的链)。 考虑以下示例,它实现了一个简单(但不是线程安全的)的整数堆栈: public class UnsafeStack { public int top = 0; public int[] values = new int[1000]; public void push(int n) { values[top++] = n; } public int pop() { return values[--top]; } } 如果多个线程试图同时使用这个类,会发生什么?这可能是个灾难。因为没有同步,多个线程可以同时执行 push() 和 pop()。如果一个线程调用 push(),而另一个线程正好在递增了 top 并要把它用作 values 的下标之间调用 push(),会发生什么?结果,这两个线程会把它们的新值存储到相同的位置!当多个线程依赖于数据值之间的已知关系,但没有确保只有一个线程可以在给定时间操作那些值时,可能会发生许多形式的数据损坏,而这只是其中之一。 对于这种情况,补救办法很简单:同步 push() 和 pop() 这两者,您将防止线程执行相互干扰。 请注意,使用 volatile 还不够 ― 需要使用 synchronized 来确保 top 和 values 之间的关系保持一致。 递增共享计数器 通常,如果正在保护一个基本变量(如一个整数),有时只使用 volatile 就可以侥幸过关。但是,如果变量的新值派生自以前的值,就必须使用同步。为什么?考虑这个类: public class Counter { private int counter = 0; public int get() { return counter; } public void set(int n) { counter = n; } public void increment() { set(get() + 1); } } 当我们要递增计数器时,会发生什么?请看 increment() 的代码。它很清楚,但不是线程安全的。如果两个线程试图同时执行 increment(),会发生什么?计数器也许会增加 1,也许增加 2。令人惊奇的是,把 counter 标记成 volatile 没有帮助,使 get() 和 set() 都变成 synchronized 也没有帮助。 设想计数器是零,而两个线程同时执行递增操作代码。这两个线程会调用 Counter.get(),并且看到计数器是零。现在两个线程都对它加一,然后调用 Counter.set()。如果我们的计时不太凑巧,那么这两个线程都看不到对方的更新,即使 counter 是 volatile,或者 get() 和 set() 是 synchronized。现在,即使计数器递增了两次,得到的值也许只是一,而不是二。 要使递增操作正确运行,不仅 get() 和 set() 必须是 synchronized,而且 increment() 也必需是 synchronized!否则,调用 increment() 的线程可能会中断另一个调用 increment() 的线程。如果您不走运,最终结果将会是计数器只增加了一次,不是两次。同步 increment() 防止了这种情况的发生,因为整个递增操作是原子的。 当循环遍历 Vector 的元素时,同样如此。即使同步了 Vector 的方法,但在循环遍历时,Vector 的内容仍然会更改。如果要确保 Vector 的内容在循环遍历时不更改,必须同步整个代码块。 -------------------------------------------------------------------------------- 不变性和 final 字段 许多 Java 类,包括 String、Integer 和 BigDecimal,都是不可改变的:一旦构造之后,它们的状态就永远不会更改。如果某个类的所有字段都被声明成 final,那么这个类就是不可改变的。(实际上,许多不可改变的类都有非 final 字段,用于高速缓存以前计算的方法结果,如 String.hashCode(),但调用者看不到这些字段。) 不可改变的类使并发编程变得非常简单。因为不能更改它们的字段,所以就不需要担心把状态的更改从一个线程传递到另一个线程。在正确构造了对象之后,可以把它看作是常量。 同样,final 字段对于线程也更友好。因为 final 字段在初始化之后,它们的值就不能更改,所以当在线程之间共享 final 字段时,不需要担心同步访问。 什么时候不需要同步 在某些情况中,您不必用同步来将数据从一个线程传递到另一个,因为 JVM 已经隐含地为您执行同步。这些情况包括: •由静态初始化器(在静态字段上或 static{} 块中的初始化器)初始化数据时 •访问 final 字段时 •在创建线程之前创建对象时 •线程可以看见它将要处理的对象时 死锁 只要您拥有多个进程,而且它们要争用对多个锁的独占访问,那么就有可能发生死锁。如果有一组进程或线程,其中每个都在等待一个只有其它进程或线程才可以执行的操作,那么就称它们被死锁了。 最常见的死锁形式是当线程 1 持有对象 A 上的锁,而且正在等待与 B 上的锁,而线程 2 持有对象 B 上的锁,却正在等待对象 A 上的锁。这两个线程永远都不会获得第二个锁,或者释放第一个锁。它们只会永远等待下去。 要避免死锁,应该确保在获取多个锁时,在所有的线程中都以相同的顺序获取锁。 -------------------------------------------------------------------------------- 性能考虑事项 关于同步的性能代价有许多说法 ― 其中有许多是错的。同步,尤其是争用的同步,确实有性能问题,但这些问题并没有象人们普遍怀疑的那么大。 许多人都使用别出心裁但不起作用的技巧以试图避免必须使用同步,但最终都陷入了麻烦。一个典型的示例是双重检查锁定模式(请参阅参考资料,其中有几篇文章讲述了这种模式有什么问题)。这种看似无害的结构据说可以避免公共代码路径上的同步,但却令人费解地失败了,而且所有试图修正它的尝试也失败了。 在编写并发代码时,除非看到性能问题的确凿证据,否则不要过多考虑性能。瓶颈往往出现在我们最不会怀疑的地方。投机性地优化一个也许最终根本不会成为性能问题的代码路径 ― 以程序正确性为代价 ― 是一桩赔本的生意。 -------------------------------------------------------------------------------- 同步准则 当编写 synchronized 块时,有几个简单的准则可以遵循,这些准则在避免死锁和性能危险的风险方面大有帮助: •使代码块保持简短。 Synchronized 块应该简短 ― 在保证相关数据操作的完整性的同时,尽量简短。把不随线程变化的预处理和后处理移出 synchronized 块。 •不要阻塞。 不要在 synchronized 块或方法中调用可能引起阻塞的方法,如 InputStream.read()。 •在持有锁的时候,不要对其它对象调用方法。这听起来可能有些极端,但它消除了最常见的死锁源头。
请看此文章:
http://www.flydmeng.com/index.php/code/dont-use-executors-and-nondaemon-thread-in-java-web.html
在java web项目中慎用Executors以及非守护线程
共享变量
要使多个线程在一个程序中有用,它们必须有某种方法可以互相通信或共享它们的结果。
让线程共享其结果的最简单方法是使用共享变量。它们还应该使用同步来确保值从一个线程正确传播到另一个线程,以及防止当一个线程正在更新一些相关数据项时,另一个线程看到不一致的中间结果。
线程基础 中计算素数的示例使用了一个共享布尔变量,用于表示指定的时间段已经过去了。这说明了在线程间共享数据最简单的形式是:轮询共享变量以查看另一个线程是否已经完成执行某项任务。
存在于同一个内存空间中的所有线程
正如前面讨论过的,线程与进程有许多共同点,不同的是线程与同一进程中的其它线程共享相同的进程上下文,包括内存。这非常便利,但也有重大责任。只要访问共享变量(静态或实例字段),线程就可以方便地互相交换数据,但线程还必须确保它们以受控的方式访问共享变量,以免它们互相干扰对方的更改。 任何线程可以访问所有其作用域内的变量,就象主线程可以访问该变量一样。素数示例使用了一个公用实例字段,叫做 finished,用于表示已经过了指定的时间。当计时器过期时,一个线程会写这个字段;另一个线程会定期读取这个字段,以检查它是否应该停止。注:这个字段被声明成 volatile,这对于这个程序的正确运行非常重要。在本章的后面,我们将看到原因。
--------------------------------------------------------------------------------
受控访问的同步为了确保可以在线程之间以受控方式共享数据,Java 语言提供了两个关键字:synchronized 和 volatile。 Synchronized 有两个重要含义:它确保了一次只有一个线程可以执行代码的受保护部分(互斥,mutual exclusion 或者说 mutex),而且它确保了一个线程更改的数据对于其它线程是可见的(更改的可见性)。 如果没有同步,数据很容易就处于不一致状态。例如,如果一个线程正在更新两个相关值(比如,粒子的位置和速率),而另一个线程正在读取这两个值,有可能在第一个线程只写了一个值,还没有写另一个值的时候,调度第二个线程运行,这样它就会看到一个旧值和一个新值。同步让我们可以定义必须原子地运行的代码块,这样对于其他线程而言,它们要么都执行,要么都不执行。 同步的原子执行或互斥方面类似于其它操作环境中的临界段的概念。 确保共享数据更改的可见性同步可以让我们确保线程看到一致的内存视图。 处理器可以使用高速缓存加速对内存的访问(或者编译器可以将值存储到寄存器中以便进行更快的访问)。在一些多处理器体系结构上,如果在一个处理器的高速缓存中修改了内存位置,没有必要让其它处理器看到这一修改,直到刷新了写入器的高速缓存并且使读取器的高速缓存无效。 这表示在这样的系统上,对于同一变量,在两个不同处理器上执行的两个线程可能会看到两个不同的值!这听起来很吓人,但它却很常见。它只是表示在访问其它线程使用或修改的数据时,必须遵循某些规则。 Volatile 比同步更简单,只适合于控制对基本变量(整数、布尔变量等)的单个实例的访问。当一个变量被声明成 volatile,任何对该变量的写操作都会绕过高速缓存,直接写入主内存,而任何对该变量的读取也都绕过高速缓存,直接取自主内存。这表示所有线程在任何时候看到的 volatile 变量值都相同。 如果没有正确的同步,线程可能会看到旧的变量值,或者引起其它形式的数据损坏。 用锁保护的原子代码块Volatile 对于确保每个线程看到最新的变量值非常有用,但有时我们需要保护比较大的代码片段,如涉及更新多个变量的片段。 同步使用监控器(monitor)或锁的概念,以协调对特定代码块的访问。 每个 Java 对象都有一个相关的锁。同一时间只能有一个线程持有 Java 锁。当线程进入 synchronized 代码块时,线程会阻塞并等待,直到锁可用,当它可用时,就会获得这个锁,然后执行代码块。当控制退出受保护的代码块时,即到达了代码块末尾或者抛出了没有在 synchronized 块中捕获的异常时,它就会释放该锁。 这样,每次只有一个线程可以执行受给定监控器保护的代码块。从其它线程的角度看,该代码块可以看作是原子的,它要么全部执行,要么根本不执行。 简单的同步示例使用 synchronized 块可以让您将一组相关更新作为一个集合来执行,而不必担心其它线程中断或看到计算的中间结果。以下示例代码将打印“1 0”或“0 1”。如果没有同步,它还会打印“1 1”(或“0 0”,随便您信不信)。 public class SyncExample {
private static lockObject = new Object();
private static class Thread1 extends Thread {
public void run() {
synchronized (lockObject) {
x = y = 0;
System.out.println(x);
}
}
} private static class Thread2 extends Thread {
public void run() {
synchronized (lockObject) {
x = y = 1;
System.out.println(y);
}
}
} public static void main(String[] args) {
new Thread1().run();
new Thread2().run();
}
}
在这两个线程中都必须使用同步,以便使这个程序正确工作。 Java 锁定Java 锁定合并了一种互斥形式。每次只有一个线程可以持有锁。锁用于保护代码块或整个方法,必须记住是锁的身份保护了代码块,而不是代码块本身,这一点很重要。一个锁可以保护许多代码块或方法。 反之,仅仅因为代码块由锁保护并不表示两个线程不能同时执行该代码块。它只表示如果两个线程正在等待相同的锁,则它们不能同时执行该代码。 在以下示例中,两个线程可以同时不受限制地执行 setLastAccess() 中的 synchronized 块,因为每个线程有一个不同的 thingie 值。因此,synchronized 代码块受到两个正在执行的线程中不同锁的保护。 public class SyncExample {
public static class Thingie { private Date lastAccess; public synchronized void setLastAccess(Date date) {
this.lastAccess = date;
}
} public static class MyThread extends Thread {
private Thingie thingie; public MyThread(Thingie thingie) {
this.thingie = thingie;
} public void run() {
thingie.setLastAccess(new Date());
}
} public static void main() {
Thingie thingie1 = new Thingie(),
thingie2 = new Thingie(); new MyThread(thingie1).start();
new MyThread(thingie2).start();
}
}
同步的方法创建 synchronized 块的最简单方法是将方法声明成 synchronized。这表示在进入方法主体之前,调用者必须获得锁: public class Point {
public synchronized void setXY(int x, int y) {
this.x = x;
this.y = y;
}
}
对于普通的 synchronized方法,这个锁是一个对象,将针对它调用方法。对于静态 synchronized 方法,这个锁是与 Class 对象相关的监控器,在该对象中声明了方法。 仅仅因为 setXY() 被声明成 synchronized 并不表示两个不同的线程不能同时执行 setXY(),只要它们调用不同的 Point 实例的 setXY() 就可同时执行。对于一个 Point 实例,一次只能有一个线程执行 setXY(),或 Point 的任何其它 synchronized 方法。 同步的块synchronized 块的语法比 synchronized 方法稍微复杂一点,因为还需要显式地指定锁要保护哪个块。Point 的以下版本等价于前一页中显示的版本: public class Point {
public void setXY(int x, int y) {
synchronized (this) {
this.x = x;
this.y = y;
}
}
}
使用 this 引用作为锁很常见,但这并不是必需的。这表示该代码块将与这个类中的 synchronized 方法使用同一个锁。 由于同步防止了多个线程同时执行一个代码块,因此性能上就有问题,即使是在单处理器系统上。最好在尽可能最小的需要保护的代码块上使用同步。 访问局部(基于堆栈的)变量从来不需要受到保护,因为它们只能被自己所属的线程访问。 大多数类并没有同步因为同步会带来小小的性能损失,大多数通用类,如 java.util 中的 Collection 类,不在内部使用同步。这表示在没有附加同步的情况下,不能在多个线程中使用诸如 HashMap 这样的类。 通过每次访问共享集合中的方法时使用同步,可以在多线程应用程序中使用 Collection 类。对于任何给定的集合,每次必须用同一个锁进行同步。通常可以选择集合对象本身作为锁。 下一页中的示例类 SimpleCache 显示了如何使用 HashMap 以线程安全的方式提供高速缓存。但是,通常适当的同步并不只是意味着同步每个方法。 Collections 类提供了一组便利的用于 List、Map 和 Set 接口的封装器。您可以用 Collections.synchronizedMap 封装 Map,它将确保所有对该映射的访问都被正确同步。 如果类的文档没有说明它是线程安全的,那么您必须假设它不是。 示例:简单的线程安全的高速缓存如以下代码样本所示,SimpleCache.java 使用 HashMap 为对象装入器提供了一个简单的高速缓存。load() 方法知道怎样按对象的键装入对象。在一次装入对象之后,该对象就被存储到高速缓存中,这样以后的访问就会从高速缓存中检索它,而不是每次都全部地装入它。对共享高速缓存的每个访问都受到 synchronized 块保护。由于它被正确同步,所以多个线程可以同时调用 getObject 和 clearCache 方法,而没有数据损坏的风险。 public class SimpleCache {
private final Map cache = new HashMap(); public Object load(String objectName) {
// load the object somehow
} public void clearCache() {
synchronized (cache) {
cache.clear();
}
} public Object getObject(String objectName) {
synchronized (cache) {
Object o = cache.get(objectName);
if (o == null) {
o = load(objectName);
cache.put(objectName, o);
}
} return o;
}
}
小结由于线程执行的计时是不确定的,我们需要小心,以控制线程对共享数据的访问。否则,多个并发线程会互相干扰对方的更改,从而损坏数据,或者其它线程也许不能及时看到对共享数据的更改。 通过使用同步来保护对共享变量的访问,我们可以确保线程以可预料的方式与程序变量进行交互。 每个 Java 对象都可以充当锁,synchronized 块可以确保一次只有一个线程执行由给定锁保护的 synchronized 代码。同步详细信息互斥在共享对数据的访问 中,我们讨论了 synchronized 块的特征,并在实现典型互斥锁(即,互斥或临界段)时说明了它们,其中每次只有一个线程可以执行受给定锁保护的代码块。 互斥是同步所做工作的重要部分,但同步还有其它几种特征,这些特征对于在多处理器系统上取得正确结果非常重要。
--------------------------------------------------------------------------------
回页首
可见性除了互斥,同步(如 volatile)强制某些可见性约束。当对象获取锁时,它首先使自己的高速缓存无效,这样就可以保证直接从主内存中装入变量。 同样,在对象释放锁之前,它会刷新其高速缓存,强制使已做的任何更改都出现在主内存中。 这样,会保证在同一个锁上同步的两个线程看到在 synchronized 块内修改的变量的相同值。
--------------------------------------------------------------------------------
回页首
什么时候必须同步?要跨线程维护正确的可见性,只要在几个线程之间共享非 final 变量,就必须使用 synchronized(或 volatile)以确保一个线程可以看见另一个线程做的更改。 可见性同步的基本规则是在以下情况中必须同步:•读取上一次可能是由另一个线程写入的变量
•写入下一次可能由另一个线程读取的变量
--------------------------------------------------------------------------------用于一致性的同步
除了用于可见性的同步,从应用程序角度看,您还必须用同步来确保一致性得到了维护。当修改多个相关值时,您想要其它线程原子地看到这组更改 ― 要么看到全部更改,要么什么也看不到。这适用于相关数据项(如粒子的位置和速率)和元数据项(如链表中包含的数据值和列表自身中的数据项的链)。
考虑以下示例,它实现了一个简单(但不是线程安全的)的整数堆栈:
public class UnsafeStack {
public int top = 0;
public int[] values = new int[1000]; public void push(int n) {
values[top++] = n;
} public int pop() {
return values[--top];
}
}
如果多个线程试图同时使用这个类,会发生什么?这可能是个灾难。因为没有同步,多个线程可以同时执行 push() 和 pop()。如果一个线程调用 push(),而另一个线程正好在递增了 top 并要把它用作 values 的下标之间调用 push(),会发生什么?结果,这两个线程会把它们的新值存储到相同的位置!当多个线程依赖于数据值之间的已知关系,但没有确保只有一个线程可以在给定时间操作那些值时,可能会发生许多形式的数据损坏,而这只是其中之一。
对于这种情况,补救办法很简单:同步 push() 和 pop() 这两者,您将防止线程执行相互干扰。
请注意,使用 volatile 还不够 ― 需要使用 synchronized 来确保 top 和 values 之间的关系保持一致。
递增共享计数器
通常,如果正在保护一个基本变量(如一个整数),有时只使用 volatile 就可以侥幸过关。但是,如果变量的新值派生自以前的值,就必须使用同步。为什么?考虑这个类:
public class Counter {
private int counter = 0; public int get() { return counter; }
public void set(int n) { counter = n; }
public void increment() {
set(get() + 1);
}
}
当我们要递增计数器时,会发生什么?请看 increment() 的代码。它很清楚,但不是线程安全的。如果两个线程试图同时执行 increment(),会发生什么?计数器也许会增加 1,也许增加 2。令人惊奇的是,把 counter 标记成 volatile 没有帮助,使 get() 和 set() 都变成 synchronized 也没有帮助。
设想计数器是零,而两个线程同时执行递增操作代码。这两个线程会调用 Counter.get(),并且看到计数器是零。现在两个线程都对它加一,然后调用 Counter.set()。如果我们的计时不太凑巧,那么这两个线程都看不到对方的更新,即使 counter 是 volatile,或者 get() 和 set() 是 synchronized。现在,即使计数器递增了两次,得到的值也许只是一,而不是二。
要使递增操作正确运行,不仅 get() 和 set() 必须是 synchronized,而且 increment() 也必需是 synchronized!否则,调用 increment() 的线程可能会中断另一个调用 increment() 的线程。如果您不走运,最终结果将会是计数器只增加了一次,不是两次。同步 increment() 防止了这种情况的发生,因为整个递增操作是原子的。
当循环遍历 Vector 的元素时,同样如此。即使同步了 Vector 的方法,但在循环遍历时,Vector 的内容仍然会更改。如果要确保 Vector 的内容在循环遍历时不更改,必须同步整个代码块。
--------------------------------------------------------------------------------
不变性和 final 字段
许多 Java 类,包括 String、Integer 和 BigDecimal,都是不可改变的:一旦构造之后,它们的状态就永远不会更改。如果某个类的所有字段都被声明成 final,那么这个类就是不可改变的。(实际上,许多不可改变的类都有非 final 字段,用于高速缓存以前计算的方法结果,如 String.hashCode(),但调用者看不到这些字段。)
不可改变的类使并发编程变得非常简单。因为不能更改它们的字段,所以就不需要担心把状态的更改从一个线程传递到另一个线程。在正确构造了对象之后,可以把它看作是常量。
同样,final 字段对于线程也更友好。因为 final 字段在初始化之后,它们的值就不能更改,所以当在线程之间共享 final 字段时,不需要担心同步访问。
什么时候不需要同步
在某些情况中,您不必用同步来将数据从一个线程传递到另一个,因为 JVM 已经隐含地为您执行同步。这些情况包括:
•由静态初始化器(在静态字段上或 static{} 块中的初始化器)初始化数据时
•访问 final 字段时
•在创建线程之前创建对象时
•线程可以看见它将要处理的对象时
死锁
只要您拥有多个进程,而且它们要争用对多个锁的独占访问,那么就有可能发生死锁。如果有一组进程或线程,其中每个都在等待一个只有其它进程或线程才可以执行的操作,那么就称它们被死锁了。
最常见的死锁形式是当线程 1 持有对象 A 上的锁,而且正在等待与 B 上的锁,而线程 2 持有对象 B 上的锁,却正在等待对象 A 上的锁。这两个线程永远都不会获得第二个锁,或者释放第一个锁。它们只会永远等待下去。
要避免死锁,应该确保在获取多个锁时,在所有的线程中都以相同的顺序获取锁。
--------------------------------------------------------------------------------
性能考虑事项
关于同步的性能代价有许多说法 ― 其中有许多是错的。同步,尤其是争用的同步,确实有性能问题,但这些问题并没有象人们普遍怀疑的那么大。
许多人都使用别出心裁但不起作用的技巧以试图避免必须使用同步,但最终都陷入了麻烦。一个典型的示例是双重检查锁定模式(请参阅参考资料,其中有几篇文章讲述了这种模式有什么问题)。这种看似无害的结构据说可以避免公共代码路径上的同步,但却令人费解地失败了,而且所有试图修正它的尝试也失败了。
在编写并发代码时,除非看到性能问题的确凿证据,否则不要过多考虑性能。瓶颈往往出现在我们最不会怀疑的地方。投机性地优化一个也许最终根本不会成为性能问题的代码路径 ― 以程序正确性为代价 ― 是一桩赔本的生意。
--------------------------------------------------------------------------------
同步准则
当编写 synchronized 块时,有几个简单的准则可以遵循,这些准则在避免死锁和性能危险的风险方面大有帮助:
•使代码块保持简短。 Synchronized 块应该简短 ― 在保证相关数据操作的完整性的同时,尽量简短。把不随线程变化的预处理和后处理移出 synchronized 块。
•不要阻塞。 不要在 synchronized 块或方法中调用可能引起阻塞的方法,如 InputStream.read()。
•在持有锁的时候,不要对其它对象调用方法。这听起来可能有些极端,但它消除了最常见的死锁源头。