摘要 开发者有时创建的多线程程序会生成错误值或产生其它奇怪的行为。古怪行为一般出现在一个多线程程序没使用同步连载线程访问关键代码部份的时候。同步连载线程访问关键代码部份是什么意思呢?在这篇文章中解释了同步,Java的同步机制,以及当开发者没有正确使用这个机制时出现的两个问题。一旦你看完这篇文章,你就可以避免在你的多线程Java程序中因缺乏同步而产生的奇怪行为。 创建多线程Java程序难吗?仅从《用Java线程获取优异性能(I)》中获得的信息你就可以回答,不。毕竟,我已经向你显示了如何轻松地创建线程对象,通过调用Thread的start()方法起动与这些对象相关的线程,以及通过调用其它Thread方法,比如三个重载的join()方法执行简单的线程操作。至今仍有许多开发者在开发一些多线程程序时面临困难境遇。他们的程序经常功能不稳定或产生错误值。例如,一个多线程程序可能将不正确的雇员资料存贮在数据库中,比如姓名和地址。姓名可能属于一个雇员的,而地址却属于另一个的。是什么引起这种奇怪行为的呢? 是缺乏同步:连载行为,或在同一时间排序,线程访问那些让多重线程操作的类和字段变量实例的代码序列,以及其他共享资源。我称这些代码序列为关键代码部份。 注意:不象类和实例字段变量,线程不能共享本地变量和参数。原因是:本地变量和参数在一个线程方法中分配——叫堆栈。结果,每一个线程都收到它自己对那些变量的拷贝。相反,线程能够共享类字段和实例字段因为那些变量在一个线程方法(叫堆栈)中没有被分配。取而代之,它们作为类(类字段)或对象(实例字段)的一部份在共享内存堆中被分配。 这篇文章将教你如何使用同步连载线程访问关键代码部份。我用一个说明为什么一些多线程程序必须使用同步的例子作为开始。我接下来就监视器和锁探讨Java的同步机制和synchronized 关键字。我通过研究由这样的错用产生的两个问题判定常常因为不正确的使用同步机制而否认了它的好处。 阅读关于线程程序的整个系列: · 第I部份:介绍线程、线程类及Runnable · 第II部份:使用同步连载线程访问关键代码部份 对于同步的需要 为什么我们需要同步呢?一种回答,考虑这个例子:你写一个使用一对线程模拟取款/存款金融事务的Java程序。在那个程序中,一个线程处理存款,同时其它线程正处理取款。每一个线程操作一对共享变量、类及实例字段变量,这些用来标识金融事务的姓名和账号。对于一个正确的金融事务,每一个线程必须在其它线程开始给name和amount赋值前(并且同时打印那些值)给name和amount变量赋值(并打印那些值,模拟存贮事务)。其源代码如下: 列表1. NeedForSynchronizationDemo.java // NeedForSynchronizationDemo.java class NeedForSynchronizationDemo { public static void main (String [] args) { FinTrans ft = new FinTrans (); TransThread tt1 = new TransThread (ft, "Deposit Thread"); TransThread tt2 = new TransThread (ft, "Withdrawal Thread"); tt1.start (); tt2.start (); } } class FinTrans { public static String transName; public static double amount; } class TransThread extends Thread { private FinTrans ft; TransThread (FinTrans ft, String name) { super (name); //保存线程名称 this.ft = ft; //保存对金融事务对象的引用 } public void run () { for (int i = 0; i < 100; i++) { if (getName ().equals ("Deposit Thread")) { //存款线程关键代码部份的开始 ft.transName = "Deposit"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 2000.0; System.out.println (ft.transName + " " + ft.amount); //存款线程关键代码部份的结束 } else { //取款线程关键代码部份的开始 ft.transName = "Withdrawal"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 250.0; System.out.println (ft.transName + " " + ft.amount); //取款线程关键代码部份的结束 } } } } NeedForSynchronizationDemo的源代码有两个关键代码部份:一个可理解为存款线程,另一个可理解为取款线程。在存款线程关键代码部份中,线程分配Deposit String对象的引用给共享变量transName及分配2000.0 给共享变量amount。同样,在取款关键代码部份,线程分配Withdrawal String对象的引用给transName及分配250.0给amount。在每个线程的分配之后打印那些变量的内容。当你运行NeedForSynchronizationDemo时,你可能期望输出类似于Withdrawal 250.0 和Deposit 2000.0两行组成的列表。相反,你收到的输出如下所示: Withdrawal 250.0 Withdrawal 2000.0 Deposit 2000.0 Deposit 2000.0 Deposit 250.0 程序明显有问题。取款线程不应该模拟$2,000的取款,存款线程不应该模拟$250的存款。每一个线程产生不一致的输出。是什么引起了这些矛盾呢?我们是如下认为的: · 在一个单处理器机器上,线程共享处理器。结果,一个线程仅能执行一定时间段。在其它时间里, JVM/操作系统暂停那个线程的执行并允许其它线程执行——一种线程时序安排。在一个多处理器机器上,依靠线程和处理器的数目,每一个线程都能拥有它自己的处理器。 · 在一单处理器机器上,一个线程的执行时间段没有足够长到在其它线程开始执行的关键代码部份前完成它自己的关键代码部分。在一个多处理器机器上,线程能够同时执行它们自己的关键代码部份。然而,它们可能在不同的时间进入它们的关键代码部份。 · 无论是单处理器或是多处理器机器,下面的情形都可能发生:线程A在它的关键代码部份分配一个值给共享变量X并决定执行一个要求100毫秒的输入/输出操作。接下来线程B进入它的关键代码部份,分配一个不同的值给X,执行一个50毫秒的输入/输出操作并分配值给共享变量Y 和Z。线程A的输入/输出操作完成,并分配它自己的值给Y和Z。因为X包含一个B分配的值,然而Y和Z包含A分配的值,这是一个矛盾的结果。 这个矛盾是怎样在NeedForSynchronizationDemo中产生的呢?假设存款线程执行ft.transName = "Deposit"并且接下来调用Thread.sleep()。在那一点,存款线程交出处理器控制一段时间进行休眠,让取款线程执行。假定存款线程休眠500毫秒(感谢Math.random()从0到999毫秒范围随机选取一个值)。在存款线程休眠期间,取款线程执行ft.transName = "Withdrawal",休眠50毫秒 (取款线程随机选取休眠值),醒后执行ft.amount = 250.0并执行System.out.println (ft.transName + " " + ft.amount)—所有都在存款线程醒来之前。结果,取款线程打印Withdrawal 250.0,那是正确的。当存款线程醒来执行ft.amount = 2000.0,接下来执行System.out.println (ft.transName + " " + ft.amount)。这个时间Withdrawal 2000.0 打印,那是不正确的。虽然存款线程先前分配"Deposit"的引用给transName,但这个引用随后会在取款线程分配Withdrawal引用给那个共享变量时消失。当存款线程醒来时,它就不能存贮正确的引用到transName,但通过分配2000.0给amount继续它的执行。虽然两个变量都不会有无效的值,但它们的结合值却是矛盾的。假如这样的话,它们的值显示企图取款$2,000。 很久以前,计算机科学家发明了描述导致矛盾的多线程组合行为的一个术语。术语是竞态条件(race condition)—每一个线程竞相在其它线程进入同一关键代码部份前完成它自己的关键代码部份的行为。作为NeedForSynchronizationDemo示范,线程的执行顺序是不可知的。这里不能保证一个线程能够在其它线程进入关键代码部份前完成它自己的关键代码部份。因此,我们会有竞态条件引起不一致。要阻止竞态条件,每一个线程必须在其它线程进入同一关键代码部份或其它操作同一共享变量或资源的相关关键代码部份前完成它自己的关键代码部份。对于一个关键代码部份没有连载访问方法(即是在一个时间只允许访问一个线程),你就不能阻止竞态条件或不一致的出现。幸运的是,Java提供了连载线程访问的方法:通过它的同步机制。 注意:对于Java的类型,只有长整型和双精度浮点型变量倾向于不一致。为什么?一个32位JVM一般用两个临近32位步长访问一个64位的长整型变量或一个64位双精度浮点型变量。一个线程可能在完成第一步后等待其它线程执行所有的两步。接下来,第一个线程可能醒来并完成第二步,产生一个值既不同于第一个线程也不同于第二线程的值的变量。结果,如果至少一个线程能够修改一个长整型变量或一个双精度浮点型变量,那些读取和(或)修改那个变量的所有线程就必须使用同步连载访问。 Java的同步机制 Java提供一个同步机制以阻止多于一个的线程在时间的任意一点在一个或多个关键代码部份执行代码。这种机制将自己建立在监视器和锁的概念基础上。一个监视器被作为包在关键代码部份周围的保护,一个锁被作为监视器用来防止多重线程进入监视器的一个软件实体。其想法是:当一个线程想进入一个监视器监视着的关键代码部份时,那个线程必须获得一个与监视器相关的对象的锁。(每个对象都有它自己的锁)如果一些其它线程保存着这个锁, JVM会强迫请求线程在一个与监视器/锁有关的等待区域等待。当监视器中的线程释放锁时, JVM从监视器的等待区域中移出等待线程并允许那个线程获得锁且处理监视器的关键代码部份。 要和监视器/锁一起工作, JVM提供了monitorenter和monitorexit 指令。幸运地是,你不需要在如此低级别地工作。取而代之,你能够在synchronized声明和同步方法中使用Java的synchronized关键字。 同步声明 一些关键代码部份占了它们封装方法的一小部份。为了防止多重线程访问这们的关键代码部份,你可使用synchronized声明。这个声明有如下的语法: synchronized ( objectidentifier ) { //关键代码部份 } synchronized声明用关键字synchronized开始及用一个objectidentifier,这出现在一对圆括弧之间。objectidentifier 引用一个与synchronized 声明描述的监视器相关的锁对象。最后,Java声明的关键代码部份出现在一对花括弧之间。你怎样解释synchronized声明呢?看看如下代码片断: synchronized ("sync object") { //访问共享变量及其它共享资源 } 从一个源代码观点看,一个线程企图进入synchronized声明保护的关键代码部份。在内部, JVM 检查是否一些其它线程控制着与"sync object"对象相关的锁。如果没有其它线程控制着锁, JVM将锁给请求线程并允许那个线程进入花括弧之间的关键代码部份。然而,如果有其它线程控制着锁, JVM会强迫请求线程在一个私有等待区域等待直到在关键代码部份内的当前线程完成执行最后声明及经过最后的花括弧。 你能够使用synchronized声明去消除NeedForSynchronizationDemo的竞态条件。如何消除,请看练习列表2: 列表2. SynchronizationDemo1.java // SynchronizationDemo1.java class SynchronizationDemo1 { public static void main (String [] args) { FinTrans ft = new FinTrans (); TransThread tt1 = new TransThread (ft, "Deposit Thread"); TransThread tt2 = new TransThread (ft, "Withdrawal Thread"); tt1.start (); tt2.start (); } } class FinTrans { public static String transName; public static double amount; } class TransThread extends Thread { private FinTrans ft; TransThread (FinTrans ft, String name) { super (name); //保存线程的名称 Save threads name this.ft = ft; //保存对金融事务对象的引用 } public void run () { for (int i = 0; i < 100; i++) { if (getName ().equals ("Deposit Thread")) { synchronized (ft) { ft.transName = "Deposit"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 2000.0; System.out.println (ft.transName + " " + ft.amount); } } else { synchronized (ft) { ft.transName = "Withdrawal"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 250.0; System.out.println (ft.transName + " " + ft.amount); } } } } } 仔细看看SynchronizationDemo1,run()方法包含两个夹在synchronized (ft) { and }间的关键代码部份。每个存款和取款线程必须在任一线程进入它的关键代码部份前获得与ft引用的FinTrans对象相关的锁。假如如果存款线程在它的关键代码部份且取款线程想进入它自己的关键代码部份,取款线程就应努力获得锁。因为当存款线程在它的关键代码部份执行时控制着锁, JVM 便强迫取款线程等待直到存款线程执行完关键代码部份并释放锁。(当执行离开关键代码部份时,锁自动释放) 技巧:当你需要决定是否一个线程控制与一个给定对象相关的锁时,调用Thread的静态布尔holdsLock(Object o)方法。如果线程调用控制着与对象相关的锁的方法,这个方法便返回一个布尔真值。否则,返回一个假值。例如,如果你打算将System.out.println (Thread.holdsLock (ft))放置在SynchronizationDemo1的main()方法末尾, holdsLock()将返回假值。返回 假值是因为执行main()方法的主线程没有使用同步机制获得任何锁。可是,如果你打算将System.out.println (Thread.holdsLock (ft))放在run()的synchronized (ft)声明中, holdsLock()将返回真值因为无论是存款线程或是取款线程都不得不在那些线程能够进入它的关键代码部份前获得与ft引用的FinTrans对象相关的锁。 Synchronized方法 你能够通过你的程序的源代码使用synchronized声明。然而,你也可能陷入过多使用这样的声明而导致代码效率低。例如,假设你的程序包含一个带两个连续synchronized声明的方法,每一个声明都企图获得同一公共对象的锁。因为获得和翻译对象的锁要消耗时间,重复调用(在一个循环中)那个方法会降低程序的性能。每次对那个方法的一个调用都必须获得和释放两个锁。程序花费大量的时间获得和释放锁。要消除这个问题,你应考虑使用同步方法。 一个同步方法不是一个实例就是一个其头包含synchronized关键字的类方法。例如: synchronized void print (String s)。当你同步一个完整实例方法时,一个线程必须获得与那个方法调用出现的对象相关的锁。例如,给一个ft.update("Deposit", 2000.0)实例方法调用,并且假定update()是同步的,一个方法必须获得与ft引用的对象相关的锁。要看一个SynchronizationDemo1版本的同步方法的源代码,请查看列表3: 列表3. SynchronizationDemo2.java // SynchronizationDemo2.java class SynchronizationDemo2 { public static void main (String [] args) { FinTrans ft = new FinTrans (); TransThread tt1 = new TransThread (ft, "Deposit Thread"); TransThread tt2 = new TransThread (ft, "Withdrawal Thread"); tt1.start (); tt2.start (); } } class FinTrans { private String transName; private double amount; synchronized void update (String transName, double amount) { this.transName = transName; this.amount = amount; System.out.println (this.transName + " " + this.amount); } } class TransThread extends Thread { private FinTrans ft; TransThread (FinTrans ft, String name) { super (name); //保存线程名称 this.ft = ft; //保存对金融事务对象的引用 } public void run () { for (int i = 0; i < 100; i++) if (getName ().equals ("Deposit Thread")) ft.update ("Deposit", 2000.0); else ft.update ("Withdrawal", 250.0); } } 虽然比列表2稍微更简洁,表3达到的是同一目的。如果存款线程调用update()方法, JVM检查看是否取款线程已经获得与ft引用的对象相关的锁。如果是这样,存款线程就等待。否则,那个线程就进入关键代码部份。 SynchronizationDemo2示范了一个同步实例方法。然而,你也能够同步class 方法。例如, java.util.Calendar类声明了一个public static synchronized Locale [] getAvailableLocales() 方法。因为类方法没有一个this引用的概念,那么类方法从哪里获得它的锁呢?类方法从类对象获得它们的锁——每一个与Class对象相关的载入的类,从那些载入的类的类方法得到它们的锁。我称这样的锁为class locks。 一些程序混淆同步实例方法和同步类方法。为帮助你理解在同步类方法调用同步实例方法的程序中到底发生了什么,应在头脑里保持如下两个观点: 1. 对象锁和类锁互相没有关系。它们是不同的实体。你独立地获得和释放每一个锁。一个调用同步类方法的同步实例方法获得两个锁。首先,同步实例方法获得它的对象的对象锁。其次,那个方法获得同步类方法的类锁。 2. 同步类方法能够调用一个对象的同步方法或使用对象去锁住一个同步块。在那种情形下,一个线程最初获得同步类方法的类锁并且接下来获得对象的对象锁。因此,调用同步实例方法的一个同步类方法也获得两个锁。 下面的代码片断描述了这两个观点: class LockTypes { //刚好在执行进入instanceMethod()前获得对象锁 synchronized void instanceMethod () { //当线程离开instanceMethod()时释放对象锁 } //刚好在执行进入classMethod()前获得类锁 synchronized static void classMethod (LockTypes lt) { lt.instanceMethod (); //刚好在关键代码部份执行前获得对象锁 synchronized (lt) { //关键代码部份 //当线程离开关键代码部份时释放对象锁 } //当线程离开classMethod()时释放类锁 } } 代码段示范了调用同步实例方法instanceMethod()的同步类方法classMethod()。通过阅读注解,你看到classMethod()首先获得它的类锁接下来获得与lt引用的LockTypes对象相关的对象锁。 警告:不要同步一个线程对象的run()方法因为多线程需要执行run()。因为那些线程企图对同一个对象同步,所以在一个时间里只有一个线程能够执行run()。结果,在每一个线程能访问run()前必须等待前一线程结束。 同步机制的两个问题 尽管其简单,开发者经常滥用Java的同步机制会导致程序由不同步变得死锁。这章将检查这些问题并提供一对避免它们的建议。 注意:一个与同步机制有关的线程问题是与锁的获得和释放有关的时间成本。换句话说,一个线程将花费时间去获得或释放一个锁。当在一个循环中获得/释放一个锁,单独的时间成本合计起来就会降低性能。对于旧的JVMs,锁的获得时间成本经常导致重大的性能损失。幸运地是, Sun微系统的HotSpot JVM (其装载在J2SE SDK上)提供快速的锁的获得和释放,大大减少了对这些程序的影响。 不同步 在一个线程自动或不自动(通过一个例外)退出一个关键代码部份时,它释放一个锁以便另一个线程能够得以进入。假设两个线程想进入同一个关键代码部份,为了阻止两个线程同时进入那个关键代码部份,每个线程必须努力获得同一个锁。如果每一个线程企图获得一个不同的锁并成功了,两个线程都进入了关键代码部份,则两个线程都不得不等待其它线程释放它的锁因为其它线程获得了一个不同的锁。最终结果是:没有同步。示范如列表4: 列表4. NoSynchronizationDemo.java // NoSynchronizationDemo.java class NoSynchronizationDemo { public static void main (String [] args) { FinTrans ft = new FinTrans (); TransThread tt1 = new TransThread (ft, "Deposit Thread"); TransThread tt2 = new TransThread (ft, "Withdrawal Thread"); tt1.start (); tt2.start (); } } class FinTrans { public static String transName; public static double amount; } class TransThread extends Thread { private FinTrans ft; TransThread (FinTrans ft, String name) { super (name); //保存线程的名称 this.ft = ft; //保存对金融事务对象的引用 } public void run () { for (int i = 0; i < 100; i++) { if (getName ().equals ("Deposit Thread")) { synchronized (this) { ft.transName = "Deposit"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 2000.0; System.out.println (ft.transName + " " + ft.amount); } } else { synchronized (this) { ft.transName = "Withdrawal"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 250.0; System.out.println (ft.transName + " " + ft.amount); } } } } } 当你运行NoSynchronizationDemo时,你将看到类似如下的输出: Withdrawal 250.0 Withdrawal 2000.0 Deposit 250.0 Withdrawal 2000.0 Deposit 2000.0 尽管使用了synchronized声明,但没有同步发生。为什么?检查synchronized (this)。因为关键字this指向当前对象,存款线程企图获得与初始化分配给tt1的TransThread对象引用有关的锁。 (在main()方法中)。类似的,取款线程企图获得与初始化分配给tt2的TransThread对象引用有关的锁。我们有两个不同的TransThread对象,并且每一个线程企图在进入它自己关键代码部份前获得与其各自TransThread对象相关的锁。因为线程获得不同的锁,两个线程都能在同一时间进入它们自己的关键代码部份。结果是没有同步。 技巧:为了避免一个没有同步的情形,选择一个对于所有相关线程都公有的对象。那样的话,这些线程竞相获得同一个对象的锁,并且同一时间仅有一个线程在能够进入相关的关键代码部份。 死锁 在有些程序中,下面的情形可能出现:在线程B能够进入B的关键代码部份前线程A获得一个线程B需要的锁。类似的,在线程A能够进入A的关键代码部份前线程B获得一个线程A需要的锁。因为两个线程都没有拥有它自己需要的锁,每个线程都必须等待获得它的锁。此外,因为没有线程能够执行,没有线程能够释放其它线程的锁,并且程序执行被冻结。这种行为叫作死锁(deadlock)。其示范列如表5: 列表5. DeadlockDemo.java // DeadlockDemo.java class DeadlockDemo { public static void main (String [] args) { FinTrans ft = new FinTrans (); TransThread tt1 = new TransThread (ft, "Deposit Thread"); TransThread tt2 = new TransThread (ft, "Withdrawal Thread"); tt1.start (); tt2.start (); } } class FinTrans { public static String transName; public static double amount; } class TransThread extends Thread { private FinTrans ft; private static String anotherSharedLock = ""; TransThread (FinTrans ft, String name) { super (name); //保存线程的名称 this.ft = ft; //保存对金融事务对象的引用 } public void run () { for (int i = 0; i < 100; i++) { if (getName ().equals ("Deposit Thread")) { synchronized (ft) { synchronized (anotherSharedLock) { ft.transName = "Deposit"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 2000.0; System.out.println (ft.transName + " " + ft.amount); } } } else { synchronized (anotherSharedLock) { synchronized (ft) { ft.transName = "Withdrawal"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 250.0; System.out.println (ft.transName + " " + ft.amount); } } } } } } 如果你运行DeadlockDemo,你将可能看到在应用程序冻结前仅一个单独输出行。要解冻DeadlockDemo,按Ctrl-C (假如你正在一个Windows命令提示符中使用Sun的SDK1.4)。 什么将引起死锁呢?仔细查看源代码。存款线程必须在它能够进入其内部关键代码部份前获得两个锁。与ft引用的FinTrans对象有关的外部锁和与anotherSharedLock引用的String对象有关的内部锁。类似的,取款线程必须在其能够进入它自己的内部关键代码部份前获得两个锁。与anotherSharedLock引用的String对象有关的外部锁和与ft引用的FinTrans对象有关的内部锁。假定两个线程的执行命令是每个线程获得它的外部锁。因此,存款线程获得它的FinTrans锁,以及取款线程获得它的String锁。现在两个线程都执行它们的外部锁,它们处在它们相应的外部关键代码部份。两个线程接下来企图获得内部锁,因此它们能够进入相应的内部关键代码部份。 存款线程企图获得与anotherSharedLock引用对象相关的锁。然而,因为取款线程控制着锁所以存款线程必须等待。类似的,取款线程企图获得与ft引用对象相关的锁。但是取款线程不能获得那个锁因为存款线程(它正在等待)控制着它。因此,取款线程也必须等待。两个线程都不能操作因为两个线程都不能释放它控制着的锁。两个线程不能释放它控制着的锁是因为每个线程都正在等待。每个线程都死锁,并且程序冻结。 技巧:为了避免死锁,仔细分析你的源代码看看当一个同步方法调用其它同步方法时什么地方可能出现线程互相企图获得彼此的锁。你必须这样做因为JVM不能探测并防止死锁。 回顾 为了使用线程达到优异性能,你将遇到你的多线程程序需要连载访问关键代码部份的情形。同步可以有效地阻止在奇怪程序行为中产生的不一致。你能够使用synchronized声明以保护一个方法的部份,或同步整个方法。但应仔细检查你的代码以防止可能造成同步失败或死锁的故障。 |