[关闭]
@lambeta 2016-08-21T08:32:24.000000Z 字数 12163 阅读 318

第2章

translation


同步

线程交互通常是通过共享变量完成的,当线程之间没有交互,开发多线程的应用程序会变得简单许多。一旦交互发生了,很多诱发线程不安全(在多线程环境下不正确)的因素就会暴露出来。在这一章中,你将会认识到这些问题,同时也会学习正确地使用Java面向同步的特性克服它们。

线程中的问题

Java对线程的支持促进了响应式、可扩展应用程序的发展。不过,这样的支持是以增加复杂性作为代价的。如果不多加小心,你的代码就会变得到处充斥着极难以察觉的bug,这些bug多和竞态条件,数据竞争,以及缓存变量有关。

竞态条件

当计算的正确性取决于相对时间或者调度器所控制的多线程交叉时,竞态条件就会发生。下面的代码片段描述了只要满足一个特定的前置条件,就会触发计算:

  1. if (a == 10.0)
  2. b = a / 2.0;

在单线程的环境中,这段程序没有任何问题。在多线程环境下,ab如果都是局部变量,那么也没有问题。但是,假设ab是实例变量或者类(static)变量,并且有两条线程同时访问这段代码,就有问题了。

假设一条线程已经执行完if (a == 10.0),在即将执行b = a / 2.0时被调度器暂停了,与此同时,调度器恢复了另一条线程改变了a的值;当前一条线程恢复执行,变量b却不会等于5.0。(如果ab是局部变量,因为每个线程都会有自己的局部变量拷贝,所以竞态条件不会发生。)

这段代码就是竞态条件中被称为check-then-act的一个经典例子。在这种竞态条件下,很可能发生用过时的观测状态决定下一步的动作。在前面的代码片段中,“检查”是if (a == 10.0),“动作”则是b = a / 2.0;

另外一种类型的竞态条件就是read-modify-write,这种情况下,新状态继承自旧状态。旧状态被读取,然后更改,最后更新,借由这三个不可分割的操作来得到更改后的结果。只不过,这些操作的组合并非不可分割。

典型的read-modify-write的例子就是用一个递增的变量来生成唯一的数字标识。在下面的代码片段当中,假设counter变量是一个类型为int(初始化为1)的实例变量,两条线程同时访问这段代码:

  1. public int getID()
  2. {
  3. return counter++;
  4. }

尽管看上去这是个单一操作,但事实上表达式counter++是三个单独的操作:读取counter的值,给值加1,然后把更新之后的值存储到counter当中。当时读取的值就是整个表达式的返回值。

假设在被调度器阻断之前,线程1调用了getID()方法,同时读取了counter的值,此时其值是1。现在,假设线程2运行,调用了getID()方法,读取了counter的值(1),对这个值加1,把结果(2)存储到counter当中,然后将1返回给调用者。

在这种情况下,假设线程1恢复过来了,对之前读到的值(1)加1,然后把结果(2)存储到counter变量当中,然后将1返回给调用者。由于线程1撤销了线程2的动作,我们就会错过一次递增并生成了一个重复的ID。所以这个方法无效。

数据竞争

两条或两条以上的线程(在单个应用中)并发地访问同一块内存区域,其中至少有一条是为了写,并且这些线程没有协调对那块内存区域访问的场景中,竞态条件经常会和数据竞争相混淆。当这些条件发生的时候,访问顺序就是不确定的。依据这种顺序,每次运行可能会产生不同的结果。看下面的例子:

  1. private static Parser parser;
  2. public static Parser getInstance()
  3. {
  4. if (parser == null)
  5. parser = new Parser();
  6. return parser;
  7. }

假设线程1首先调用了getInstance()方法。由于它检测到属性parser是空值,线程1就会实例化Parser并且将引用赋给变量parser。随后,当线程2调用getInstance()方法时,它可能检测到parser已经包含了一个非空的引用,于是简单地返回了parser的值。另一种可能是,线程2检测到parser的值是空然后创建一个新的Parser的对象。因为在线程1对parser的写和线程2对parser的读之间没有happens-before ordering(一个动作先于另一个动作发生)保证(这里不存在对parser访问顺序的协同),所以数据竞争产生了。

缓存变量

为了提升性能,编译器,Java虚拟机(JVM),以及操作系统会协调在寄存器中或者处理器缓存中缓存变量,而不是依赖主存。每条线程都会有其自己的变量拷贝。当线程写入这个变量的时候,其实是写入自己的拷贝;其它线程不可能在它们自己的变量拷贝中看到更改。

第一章给出的ThreadDemo的应用程序(参见清单1-3)暴露了这个问题。这里我重新提取了部分源码以供参考:

  1. private static BigDecimal result;
  2. public static void main(String[] args)
  3. {
  4. Runnable r = () ->
  5. {
  6. result = computePi(50000);
  7. };
  8. Thread t = new Thread(r);
  9. t.start();
  10. try
  11. {
  12. t.join();
  13. }
  14. catch (InterruptedException ie)
  15. {
  16. // Should never arrive here because interrupt() is never
  17. // called.
  18. }
  19. System.out.println(result);
  20. }

类属性result示范了缓存变量的问题。该属性在lambda表达式的上下文当中被一条工作线程访问并执行代码result = computePi(50000);。然后默认主线程执行System.out.println(result);

这个工作线程能够存储computePi()的返回值到自己的result变量的拷贝中。默认主线程很可能无法看到result = computePi(50000);的赋值并且它的本地拷贝会保持原来默认的null值。这个null值会取代result的字符串表示(即计算好的pi的值)被打印出来。

同步临界区的访问

你会看到同步能够解决之前的线程问题。Synchronization是JVM的一个特性,旨在保证两个或者多个并发的线程不会同时执行同一块临界区,临界区就是必须以串行方式(一次一条线程)访问的一段代码块。

因为其它线程在临界区当中的时候每条线程对该临界区的访问都会互斥地执行,所以这种同步属性就被称为互斥。由于这个原因,线程获取到的锁经常被称为互斥锁。

同步也表现出可见性,该属性能够保证一条线程在临界区执行的时候总是能看到共享变量最近的修改。当进入临界区时,它从主存中读入这些变量,离开时把这些变量的值写入主存。

同步是通过监听器来实现的,监听器是针对临界区构建的并发访问控制,并发必须以不可分割的形式执行。每一个Java对象都和一个监听器相关联,这样线程就可以通过获取和释放监听器的锁(一个标识)来锁上和解锁。


注意 当调用Thread任意的sleep()方法时,已经获取锁的线程不会释放锁。


只有一个线程可以持有监听器的锁,任意尝试锁住该监听器的线程都会一直阻塞直到能够获取锁为止。当线程离开临界区,它会通过释放锁来解锁监听器。

为了防止发生死锁(后面会讨论),锁被设计成可重入的。。当线程尝试获取它已经持有的锁,请求会成功。


小贴士 java.lang.Thread类声明了一个static boolean holdsLock(Object 0)方法,即当调用线程持有对象o的锁,该方法会返回true。你会很容易在断言语句中找到这个方法,例如:assert Thread.holdsLock(o);


Java提供synchronized关键字来串行线程对方法和语句块(临界区)的访问。

使用同步方法

同步方法会在方法头部包含synchronized关键字。举个例子,你可以使用这一关键字同步之前的getID()方法,像下面一样克服read-modify-write竞态条件:

  1. public synchronized int getID()
  2. {
  3. return counter++;
  4. }

当同步在实例方法上,锁会和调用该方法的实例对象关联。举个例子,参考下面的ID类:

  1. public class ID
  2. {
  3. private int counter; // initialized to 0 by default
  4. public synchronized int getID()
  5. {
  6. return counter++;
  7. }
  8. }

假设你指定了下面的代码序列:

  1. ID id = new ID();
  2. System.out.println(id.getID());

锁和ID对象相关联,对象的引用存储在id变量中。如果其它线程在该方法执行过程中调用id.getID()方法,这些线程不得不等待正在执行的线程释放锁。

当同步在一个类方法上时,锁会和该类方法被调用的类所对应的java.lang.Class对象相关联。举个例子,参考下面的ID类:

  1. public class ID
  2. {
  3. private static int counter; // initialized to 0 by default
  4. public static synchronized int getID()
  5. {
  6. return counter++;
  7. }
  8. }

假设你指定了下面的代码序列:

  1. System.out.println(ID.getID());

锁和和ID类关联的Class对象ID.class相关联。如果其它线程在该方法执行过程中调用ID.getID()方法,这些线程也不得不等待正在执行的线程释放掉锁。

使用同步块

一个同步块语句把这个待锁住的对象作为前缀头。它具有以下语法结构:

  1. synchronized(object) {
  2. /* statements */
  3. }

从这个语法来看,object是某个对象的引用。锁和该对象相关联。

我之前摘录了第1章中一段遭遇缓存变量的程序。你可以通过两个同步块解决这一问题。

  1. Runnable r = () ->
  2. {
  3. synchronized(FOUR)
  4. {
  5. result = computePi(50000);
  6. }
  7. };
  8. synchronized(FOUR)
  9. {
  10. System.out.println(result);
  11. }

这两个同步块标识了一对临界区。每个同步块被同一个对象所保护以致于同一时间只能有一条线程能在这些同步块中执行。每条线程在进入它的临界区之前必须获取被常量FOUR所引用对象的锁。

这段代码块打开了一个关于同步块和同步方法很重要的点。要么访问同一段代码序列的两条或两条以上线程必须获取同一个锁,要么不存在同步。这也暗示着(进入临界区)必须访问同一个对象。在之前的例子当中,FOUR被放到两个位置以致于同时只能有一条线程出现在其中一个临界区。如果我在一个位置指定synchronized(FOUR),在另一个地方指定synchronized("ABC"),由于涉及到两个不同的锁,也就不存在同步了。

谨防活性问题

活性这个词代表着最终会发生的好事。活性失败发生在应用程序触及到了一种无法继续执行的状态。在单线程的应用程序中,无限循环就是一个例子。多线程应用程序面临着额外的诸如死锁、活锁和饿死的挑战。

死锁会发生在synchronized关键字带来的过多同步上。如果不小心,你可能就会遭遇锁同时被多条线程竞争的情形。即线程自身缺失继续执行的锁,却持有其它线程需要的锁,同时由于其它线程持有临界区的锁,导致没有一条线程能够通过临界区,进而释放自己所持有的锁。清单2-1就是一个描述该场景的典型例子。

清单2-1. 一个死锁的问题

  1. public class DeadlockDemo
  2. {
  3. private final Object lock1 = new Object();
  4. private final Object lock2 = new Object();
  5. public void instanceMethod1()
  6. {
  7. synchronized(lock1)
  8. {
  9. synchronized(lock2)
  10. {
  11. System.out.println("first thread in instanceMethod1");
  12. // critical section guarded first by
  13. // lock1 and then by lock2
  14. }
  15. }
  16. }
  17. public void instanceMethod2()
  18. {
  19. synchronized(lock2)
  20. {
  21. synchronized(lock1)
  22. {
  23. System.out.println("second thread in instanceMethod2");
  24. // critical section guarded first by
  25. // lock2 and then by lock1
  26. }
  27. }
  28. }
  29. public static void main(String[] args)
  30. {
  31. final DeadlockDemo dld = new DeadlockDemo();
  32. Runnable r1 = new Runnable() {
  33. @Override
  34. public void run()
  35. {
  36. while(true)
  37. {
  38. dld.instanceMethod1();
  39. try
  40. {
  41. Thread.sleep(50);
  42. }
  43. catch (InterruptedException ie)
  44. {
  45. }
  46. }
  47. }
  48. };
  49. Thread thdA = new Thread(r1);
  50. Runnable r2 = new Runnable()
  51. {
  52. @Override
  53. public void run()
  54. {
  55. while(true)
  56. {
  57. dld.instanceMethod2();
  58. try
  59. {
  60. Thread.sleep(50);
  61. }
  62. catch (InterruptedException ie)
  63. {
  64. }
  65. }
  66. }
  67. };
  68. Thread thdB = new Thread(r2);
  69. thdA.start();
  70. thdB.start();
  71. }
  72. }

清单2-1中线程A和线程B在不同的时间分别调用了instanceMethod1()instanceMethod2()方法。参考下面的执行序列:
1. 线程A调用instanceMethod1(),获取到lock1引用对象的锁,然后进入它外部的临界区(但是还没有获取lock2引用对象的锁)。
2. 线程B调用instanceMethod2(),获取到lock2引用对象的锁,然后进入它外部的临界区(但是还没有获取lock1引用对象的锁)。
3. 线程A尝试去获取和lock2相关联的锁。JVM强制线程在内部临界区之外等待,由于线程B持有那个锁。
4. 线程B尝试去获取和lock1相关联的锁。JVM强制线程在内部临界区之外等待,由于线程A持有那个锁。
5. 由于其它线程持有了必要的锁,所以没有一条线程能继续执行。遭遇死锁,程序(至少在这两条线程的上下文当中)就冻结住了。

照下面那样编译清单2-1:

javac DeadlockDemo.java

像下面那样运行结果程序:

java DeadlockDemo

你应该能在标准输出流中观测到交替打印的的first thread in instanceMethod1second thread in instanceMethod2信息,直到程序因死锁冻结。

尽管前面的例子很清晰地识别死锁的状态,但侦测死锁还是不容易。举个例子,你的代码可能在多个类中(在多个源文件里)包含如下的环形关系:

如果线程A调用了类A的同步方法,线程B调用了类C的同步方法,因为线程A还在那个方法当中,当线程B尝试调用类A的同步方法时会被阻塞住。线程A会继续执行直至其调用类C的同步方法,然后阻塞住。死锁发生了。


注意 Java语言和JVM都没有提供一种方式来避免死锁,所以这一任务就落到你的身上。避免死锁最简单的方式就是阻止同步方法或者同步块调用其它的同步方法和同步块。尽管这个建议能避免死锁发生,但还是不现实,因为你的某一个同步方法和同步块中很可能需要调用Java API中的同步方法,而且这个建议有点因噎废食,因为被调用的同步方法和同步块很可能不会调用其它的同步方法和同步块,从而不会导致死锁发生。


Volatile和Final变量

你之前学到的同步展示了两种属性:互斥性和可见性。synchronized关键字与两者都有关系。Java同时也提供了一种更弱的仅仅包含可见性的同步形式,并且只以volatile关键字关联。

假设你自己设计了一个停止线程的机制(因为无法使用Thread不安全的stop()方法))。清单2-2中ThreadStopping程序源码展示了该如何完成这项任务。

清单2-2. 尝试停止一个线程

  1. public class ThreadStopping
  2. {
  3. public static void main(String[] args)
  4. {
  5. class StoppableThread extends Thread
  6. {
  7. private boolean stopped; // defaults to false
  8. @Override
  9. public void run()
  10. {
  11. while(!stopped)
  12. System.out.println("running");
  13. }
  14. void stopThread()
  15. {
  16. stopped = true;
  17. }
  18. }
  19. StoppableThread thd = new StoppableThread();
  20. thd.start();
  21. try
  22. {
  23. Thread.sleep(1000); // sleep for 1 second
  24. }
  25. catch (InterruptedException ie)
  26. {
  27. }
  28. thd.stopThread();
  29. }
  30. }

清单2-2中的main()方法声明了一个叫做StoppableThread的本地类继承自Thread。在初始化完StoppableThread之后,默认的主线程启动和这个Thread对象关联的线程。之后它睡眠一秒钟,并且在死亡之前调用StoppableThreadstop()方法。

StoppableThread声明了一个被初始化为false的stopped实例变量,stopThread()方法会设置该变量为true,同时run()方法中的while循环会在每次迭代中检查stopped的值是否已经修改为true。

照下面编译清单2-2:

javac ThreadStopping.java

运行结果程序如下:

java ThreadStopping

你应该能观测到一系列运行时的消息

当你在单处理器/单核的机器上运行这个程序的时候,很可能观测到程序停止。但是在一个多处理器的机器或多核单处理器的机器上可能就看不到程序停止,由于每个处理器或者核心很可能有自己的一份stopped的拷贝,当一条线程修改了自己的拷贝,其它线程的拷贝并没有被改变。

你或许决定使用synchronized关键字以确保只能访问主存中的stopped变量。然后经过一番思考,你决定在清单2-3中使用同步访问一对临界区的方式解决这个问题。

清单2-3. 尝试使用synchronized来停止一个线程

  1. public class ThreadStopping
  2. {
  3. public static void main(String[] args)
  4. {
  5. class StoppableThread extends Thread
  6. {
  7. private boolean stopped; // defaults to false
  8. @Override
  9. public void run()
  10. {
  11. synchronized(this)
  12. {
  13. while(!stopped)
  14. System.out.println("running");
  15. }
  16. }
  17. synchronized void stopThread()
  18. {
  19. stopped = true;
  20. }
  21. }
  22. StoppableThread thd = new StoppableThread();
  23. thd.start();
  24. try
  25. {
  26. Thread.sleep(1000); // sleep for 1 second
  27. }
  28. catch (InterruptedException ie)
  29. {
  30. }
  31. thd.stopThread();
  32. } }

出于两个因素考虑,清单2-3不是个好主意。第一,尽管你只需解决可见性的问题,synchronized却同时解决了互斥的问题(在该程序中不是个问题))。更重要的是,你还往程序中引进了另一个更严重的问题。

你已经正确地对stopped进行了同步访问,但是进一步观察run()方法中的同步块,尤其是这个while循环。由于正在执行循环的这个线程已经获取了当前StoppableThread对象(借由synchronized(this)方式)的锁所以这个循环不会终止。因为默认的主线程需要获取相同的锁,所以它在该对象上调用stopThread()方法的任意尝试都会导致自己被阻塞住。

你可以通过使用局部变量并在同步块中将stopped的值赋给这个变量来解决这一问题。如下:

  1. public void run()
  2. {
  3. boolean _stopped = false;
  4. while (!_stopped)
  5. {
  6. synchronized(this)
  7. {
  8. _stopped = stopped;
  9. }
  10. System.out.println("running");
  11. }
  12. }

不过,每次循环迭代都要尝试获取锁的方式存在性能开销(还不如以前),所以这个解决方式是得不偿失的。清单2-4展示了一个更为高效且整洁的方法。

清单2-4. 尝试通过volatile关键字来停止一个线程

  1. public class ThreadStopping
  2. {
  3. public static void main(String[] args)
  4. {
  5. class StoppableThread extends Thread
  6. {
  7. private volatile boolean stopped; // defaults to false
  8. @Override
  9. public void run()
  10. {
  11. while(!stopped)
  12. System.out.println("running");
  13. }
  14. void stopThread()
  15. {
  16. stopped = true;
  17. }
  18. }
  19. StoppableThread thd = new StoppableThread();
  20. thd.start();
  21. try
  22. {
  23. Thread.sleep(1000); // sleep for 1 second
  24. }
  25. catch (InterruptedException ie)
  26. {
  27. }
  28. thd.stopThread();
  29. }
  30. }

由于stopped已经被标记为volatile,每条线程都会访问主存中该变量的拷贝而不会访问缓存中的拷贝。这样即使在多处理器或者多核的机器上,该程序也会停止。


留心 只有可见性导致问题了才应该使用volatile。而且,你也只能在属性声明处才能使用这个保留字(如果你尝试将局部变量声明成volatitle会收到一个错误)。最后,你可以将double和long型的属性声明成volatile,但是应该避免在32位的JVM上这样做,原因是此时访问一个double或者long型的变量值需要进行两步操作,若要安全地访问它们的值,互斥(借由synchronized)是必要的。


当一个属性变量被声明成volatile,就不能同时被声明成final的。不过,由于Java可以让你安全地访问final的属性而无需同步,这也就不能称之为一个问题了。为了克服DeadlockDemo中的缓存变量问题,我把lock1lock2都标记成final,尽管我也能将它们标记成volatile的。

以后你会经常使用final关键字来确保在不可变(不会发生改变)类的上下文中线程的安全性。参考清单2-5。

清单 2-5. 借助于final创建一个不可变且线程安全的类

  1. import java.util.Set;
  2. import java.util.TreeSet;
  3. public final class Planets
  4. {
  5. private final Set<String> planets = new TreeSet<>();
  6. public Planets()
  7. {
  8. planets.add("Mercury");
  9. planets.add("Venus");
  10. planets.add("Earth");
  11. planets.add("Mars");
  12. planets.add("Jupiter");
  13. planets.add("Saturn");
  14. planets.add("Uranus");
  15. planets.add("Neptune");
  16. }
  17. public boolean isPlanet(String planetName)
  18. {
  19. return planets.contains(planetName);
  20. }
  21. }

清单2-5展示了一个不可变类Planets,其对象存储着星球名字的集合。尽管集合是可变的,但这个类的设计却保证在构造函数退出之后集合不会再被改变。通过声明planetfinal,这个属性的引用不能被更改。而且,该引用也不能被缓存,所以缓存变量的问题也不复存在。

关于不可变对象,Java提供了一个特殊的线程安全保证。即便没有用同步来发布(暴露)这些对象的引用,它们依然可以被多条线程安全地访问。不可变对象提供了下列易于识别的规则:

最后一点很让人迷惑,所以这里有个this显式地脱离构造函数的简单例子:

  1. public class ThisEscapeDemo
  2. {
  3. private static ThisEscapeDemo lastCreatedInstance;
  4. public ThisEscapeDemo()
  5. {
  6. lastCreatedInstance = this;
  7. }
  8. }

在www.ibm.com/developerworks/library/j-jtp0618/上查看《Java理论与实践:安全的构造技术》学习更多常见线程风险的相关知识。

练习

下面的练习被设计出来测试你对第2章内容的理解程度:
1. 明确与线程相关的三个问题。
2. 判断对错:当程序计算的正确性取决于相对时间或者调度器所控制的多线程交叉时,你会遇到数据竞争问题。
3. 定义同步。
4. 明确同步的两种属性。
5. 同步是如何被实现出来的?
6. 判断对错:当一个已经获取锁的线程调用任意Thread的任意sleep()方法时不会释放锁。
7. 如何指定一个同步方法?
8. 如何指定一个同步块?
9. 定义活性。
10. 明确三种活性挑战。
11. 如何区分volatile和synchronized关键字?
12. 判断对错:Java也让你能够安全地访问一个final的属性而无需进行同步。
13. 识别出下面CheckingAccount类存在的线程问题

  1. public class CheckingAccount
  2. {
  3. private int balance;
  4. public CheckingAccount(int initialBalance)
  5. {
  6. balance = initialBalance;
  7. }
  8. public boolean withdraw(int amount)
  9. {
  10. if (amount <= balance)
  11. {
  12. try
  13. {
  14. Thread.sleep((int) (Math.random() * 200));
  15. }
  16. catch (InterruptedException ie)
  17. {
  18. }
  19. balance -= amount;
  20. return true;
  21. }
  22. return false;
  23. }
  24. public static void main(String[] args)
  25. {
  26. final CheckingAccount ca = new CheckingAccount(100);
  27. Runnable r = new Runnable()
  28. {
  29. @Override
  30. public void run()
  31. {
  32. String name = Thread.currentThread().getName();
  33. for (int i = 0; i < 10; i++)
  34. System.out.println (name + "
  35. withdraws $10: " +
  36. ca.withdraw(10));
  37. }
  38. };
  39. Thread thdHusband = new Thread(r);
  40. thdHusband.setName("Husband");
  41. Thread thdWife = new Thread(r);
  42. thdWife.setName("Wife");
  43. thdHusband.start();
  44. thdWife.start();
  45. }
  46. }

14. 修复前面CheckingAccount类的线程问题。


小结

线程交互通常是通过共享变量完成的,当线程之间没有交互,开发多线程的应用程序会变得简单许多。一旦交互发生了,竞态条件,数据竞争,以及缓存变量等诱发线程不安全(在多线程环境下不正确)的因素就会暴露出来。

你可以使用同步解决之前的线程问题。Synchronization是JVM的一个特性旨在保证两条或者两条以上并发的线程不会同时进入同一块临界区。临界区就是必须以串行方式访问的一段代码块。

活性这个词代表着最终会发生的好事。活性失败发生在应用程序触及到了一种无法继续执行的状态。在单线程的应用程序中,无限循环就是一个例子。多线程应用程序面临着额外的诸如死锁、活锁和饿死的挑战。

同步展示了两种属性:互斥性和可见性。synchronized关键字与两者都有关系。Java同时也提供了一种更弱的仅仅包含可见性的同步形式,并且只以volatile关键字关联。

当一个属性变量被声明成volatile,就不能同时被声明成final的。不过,由于Java可以让你安全地访问final的属性而无需同步,这也就不能称之为一个问题了。以后你会经常使用final来确保在不可变(不会发生改变)类的上下文中线程的安全性。

第3章会涉及等待和通知相关的话题。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注