本文节选自 Effective Java by Joshua Bloch 和 Concurrent Programming in Java by Doug Lea.

1.1 概述

多线程程序设计比单线程程序设计要困难的多,所以,如果一个库中的类能够帮助你从低层的多线程程序设计中解脱出来,那么一定要使用这个类。比如java.util.Timer。另外,util.concurrent包是一个高层次的线程工具集合。在 Java 语言中,协调对共享字段的访问的传统方法是使用同步,确保完成对共享字段的所有访问。对于现代 JVM 而言,无竞争的同步现在非常便宜。以下是两个简单例子:

  1. public class SynchronizedCounter
  2. {
  3. private int value;
  4. public synchronized int getValue() { return value; }
  5. public synchronized int increment() { return ++value; }
  6. public synchronized int decrement() { return --value; }
  7. }
  8. public class SynchronizedMutex
  9. {
  10. private Thread curOwner = null;
  11. public synchronized void acquire() throws InterruptedException
  12. {
  13. if (Thread.interrupted()) throw new InterruptedException();
  14. while (curOwner != null)
  15. {
  16. wait();
  17. }
  18. curOwner = Thread.currentThread();
  19. }
  20. public synchronized void release()
  21. {
  22. if (curOwner == Thread.currentThread())
  23. {
  24. curOwner = null;
  25. notify();
  26. }
  27. else
  28. {
  29. throw new IllegalStateException("not owner of mutex");
  30. }
  31. }
  32. }

1.2 synchronized关键字

1.2.1 语义

    synchronized关键字不属于方法签名的一部分。所以当子类覆盖父类的方法时,synchronized修饰符不会被继承。因此接口中的方法不能被声明为synchronized。同样地,构造函数也不能被声明为synchronized(尽管构造函数内的程序块可以被声明为synchronized)。

    在java语言中,锁是递归(recursive)的,锁操作是基于“每线程”而不是“每调用”,如果调用线程已经拥有了锁,当他试图再次获得锁的时候,即使此时该锁保护的数据上有另一个完全不相关的操作正在进行,它也会成功。本质上讲,这时候该锁没有起到应有的作用。递归锁简化了多线程面向对象程序的设计和构造,但是可能会把活性失败(liveness failure)变成安全性失败(safety failure)。锁的申请和释放操作是在使用synchronized关键字的时候根据内部的获得-释放协议来使用的。所有的锁都是块结构。当进入synchronized方法或块的时候得到锁,退出的时候释放锁,即使因为异常也会释放锁。

1.2.2作为类成员函数的修饰符

    当把synchronized关键字作为类成员函数的修饰符时,这时候锁定的是被调用同步方法的对象, 例如

  1. public synchronized int increment() { return ++value; }

实际上等同于

  1. public int increment()
  2. {
  3. synchronized(this)
  4. {
  5. return ++value;
  6. }
  7. }

子类和父类的方法使用同一个锁,但是内部类的锁和它的外部类无关,然而,一个非静态的内部类可以锁住它的外部类,例如:

  1. synchronized(OuterClass.this){ /* body */ }

1.2.3作为类静态函数的修饰符

    锁住一个对象并不代表不可访问这个对象或者其任何父类的静态数据。可以通过synchronized static方法或块来实现静态数据的保护。当把synchronized关键字作为类静态函数的修饰符时,这时候锁定的是被调用同步方法的类对象,和每个类相关的静态锁与任何其它类的锁都没有关系,包括它的父类。如果想在子类中增加一个静态同步方法来达到保护父类的静态数据的目的是不可能的,应该用明确同步块版本。以下是synchronized关键字作为类静态函数的修饰符的例子:

  1. public synchronized static int increment() { return ++value; }

实际上等同于

  1. public int increment()
  2. {
  3. synchronized(SynchronizedCounter.class)
  4. {
  5. return ++value;
  6. }
  7. }

1.2.4同步块

    当有一个明确的对象作为同步锁的时候, 就可以使用同步块。另外, 如果只是想同步一段代码,那么可以创建一个特殊的对象来充当锁

  1. public void foo(Object obj)
  2. {
  3. synchronized(obj)
  4. {
  5. // do something
  6. }
  7. }

1.3 原子数据的同步

java语言保证读或写一个变量是原子(atomic)的,除非这个变量的类型是long或double.换句话说,读入一个非long或double类型的变量,可以保证返回值一定是某个线程保存在该变量中的,即使多个线程在没有同步的时候并发地修改这个变量,也是如此。   

    虽然原子性保证了一个线程在读写数据的时候,不会看到一个随机的数值,但是它并不保证一个线程写入的值对于另外一个线程是可见的。java的内存模型决定,为了在线程之间可靠地通信,以及为了互斥访问,对原子数据的读写进行同步是需要的。考虑下边的序列号生成程序:

  1. private static int nextSerialNumber = 0;
  2. public static int generateSerialNumber()
  3. {
  4. return nextSerialNumber++;
  5. }

这个程序的意图是保证每次调用generateSerialNumber都会返回一个不同的序列号,然而,如果没有同步,这个方法并不能正确的工作。递增操作符(++)既要读nextSerialNumber域,也要写nextSerialNumber域,所以它不是原子的。读和写相互独立的操作。因此,多个并发的线程可能会看到nextSerialNumber有相同的值,因而返回相同的序列号。此外,一个线程重复地调用generateSerialNumber,获得从0到n的一系列序列号之后,另外一个线程调用generateSerialNumber并获得一个序列号是0,这是有可能发生的。如果没有同步机制,第二个线程可能根本看不到第一个线程所作的改变。

1.4 监控机制

    正如每个Object都有一个锁, 每个Object也有一个等待集合(wait set),它有wait、notify、notifyAll和Thread.interrupt方法来操作。同时拥有锁和等待集合的实体,通常被成为监视器(monitor)。每个Object的等待集合是由JVM维护的。等待集合一直存放着那些因为调用对象的wait方法而被阻塞的线程。由于等待集合和锁之间的交互机制,只有获得目标对象的同步锁时,才可以调用它的wait、notify和notifyAll方法。这种要求通常无法靠编译来检查,如果条件不能满足,那么在运行的时候调用以上方法就会导致其抛出IllegalMonitorStateException。

wait 方法被调用后,会执行如下操作

  • 如果当前线程已经被中断,那么该方法立刻退出,然后抛出一个InterruptedException异常。否则线程会被阻塞。
  • JVM把该线程放入目标对象内部且无法访问的等待集合中。
  • 目标对象的同步锁被释放,但是这个线程锁拥有的其他锁依然会被这个线程保留着。当线程重新恢复质执行时,它会重新获得目标对象的同步锁

notify方法被调用后,会执行如下操作

  • 如果存在的话,JVM会从目标对象内部的等待集合中任意移除一个线程T。如果等待集合中的线程数大于1,那么哪个线程被选中完全是随机的。
  • T必须重新获得目标对象的同步锁,这必然导致它将会被阻塞到调用Thead.notify的线程释放该同步锁。如果其他线程在T获得此锁之前就获得它,那么T就要一直被阻塞下去。
  • T从执行wait的那点恢复执行。

notifyAll方法被调用后的操作和notify类似,不同的只是等待集合中所有的线程(同时)都要执行那些操作。然而等待集合中的线程必须要在竞争到目标对象的同步锁之后,才能继续执行。

interrupt。如果对一个因为调用了wait方法而被挂起的对象调用Thread.interrupt方法,那么这个方法的执行机制就和notify类似,只是在重新获得对象锁后,该方法就会抛出InterruptedException异常,并且该线程的中断状态被置为false。

对于Object.wait()方法,它一定是在一个同步区域中被调用,而且该同步区域锁住了被调用的对象。下边是使用Object.wait()方法的标准模式:

  1. synchronized(obj)
  2. {
  3. while( condition checking)
  4. {
  5. obj.wait();
  6. }
  7. …// Other operations
  8. }

总是要使用wait循环模式来调用wait方法,永远不要在循环的外边调用wait方法。循环的作用在于在等待的前、后都能测试条件。在等待之前测试条件,如果条件成立的话则跳过等待,这对于确保程序的活性(liveness)是必要的。如果条件已经成立,而且在线程等待之前notify(或者notifyAll)方法已经被调用过,那么无法保证该线程将总会从等待中醒过来。在等待之后测试条件,如果条件不成立的话则继续等待,这对于确保程序的安全性(safety)是必要的。当条件不成立的时候,如果线程继续执行,那么可能破坏被锁保护的约束关系。当条件不成立的时候,有以下一些理由可以使一个线程醒过来:

  1. 从一个线程调用notify方法的时刻起,到等待线程被唤醒的时刻之间,另一个线程得到了锁,并且改变了被保护的状态。
  2. 条件没有成立,但是另外一个线程可能意外或者恶意地调用了notify方法。在公有对象上调用wait方法,这其实是将自己暴露在危险的境地中。因为任何持有这个对象引用的线程都可以调用该对象的notify方法。
  3. 在没有被通知的情况下等待线程也可能被唤醒。这被称为“伪唤醒(spurious wakeup)”。虽然《Java语言规范(The Java Language Specification )》并没有提到这种可能,但是许多JVM实现都使用了具有伪唤醒功能的线程设施,尽管用的很少。

与此相关的一个问题是,为了唤醒正在等待的线程,到底应该使用notify方法还是应该使用notifyAll方法。假设所有的wait调用都是在循环的内部,那么使用notifyAll方法是一个合理而保守的做法。它总会产生正确的结果,它可以保证会唤醒所有需要被唤醒的线程。当然,这样也会唤醒其它一些线程,但是这不会影响程序的正确性。这些线程醒来之后会检查等待条件,发现条件不满足,就会继续等待。使用notifyAll方法的另外一个优点在于可以避免来自不相关线程的意外或者恶意等待。否则的话,这样的等待可能会“吞掉”一个关键的通知,使真正的接收线程无限地等待下去。关于使用notifyAll方法的一个不足在于,虽然使用notifyAll方法不会影响程序的正确性,但是会影响程序的性能。

1.5 死锁

    尽管完全同步的原子操作很安全,但是线程可能却因此失去了活性(liveness)。死锁(dead lock)是在两个或多个线程都有权限访问两个或多个对象,并且每个线程都在已经得到一个锁的情况下等待其它线程已经得到的锁。假设线程A持有的对象X的锁,并且正在试图获得对象Y的锁,同时,线程B已经拥有的对象Y的锁,并在试图获得对象X的锁。因此没有哪个线程能够执行进一步的操作,死锁就产生了。例如:

  1. public class Cell {
  2. private long value;
  3. public Cell(long value) {
  4. this.value = value;
  5. }
  6. public synchronized long getValue() {
  7. return value;
  8. }
  9. public synchronized void setValue(long value) {
  10. this.value = value;
  11. }
  12. public synchronized void swap(Cell other) {
  13. long t = getValue();
  14. long v = other.getValue();
  15. setValue(v);
  16. other.setValue(t);
  17. }
  18. public static void main(String args[]) throws Exception {
  19. //
  20. final Cell c1 = new Cell(100);
  21. final Cell c2 = new Cell(200);
  22. //
  23. Thread t1 = new Thread(new Runnable() {
  24. public void run() {
  25. long count = 0;
  26. try {
  27. while(true) {
  28. c1.swap(c2);
  29. count++;
  30. if(count % 100 == 0) {
  31. System.out.println("thread1's current progress: " + count);
  32. }
  33. }
  34. } catch (Exception e) {
  35. e.printStackTrace();
  36. }
  37. }
  38. });
  39. t1.setName("thread1");
  40. //
  41. Thread t2 = new Thread(new Runnable() {
  42. public void run() {
  43. long count = 0;
  44. try {
  45. while(true) {
  46. c2.swap(c1);
  47. count++;
  48. if(count % 100 == 0) {
  49. System.out.println("thread2's current progress: " + count);
  50. }
  51. }
  52. } catch (Exception e) {
  53. e.printStackTrace();
  54. }
  55. }
  56. });
  57. t2.setName("thread2");
  58. //
  59. t1.start();
  60. t2.start();
  61. t1.join();
  62. t2.join();
  63. }
  64. }

如果按照下面的时序执行时序,就会导致死锁:

线程A 线程B
进入a.swap(b)时获得a的锁  
在执行t = getValue()时,顺利获得a的锁(因为已经持有)  进入b.swap(a)时获得b的锁
执行v = other.getValue()时,由于需要b的锁而处于等待的状态 在执行t = getValue()时,顺利获得b的锁
  执行v = other.getValue()时,由于需要a的锁而处于等待状态

以上的代码执行一段时间后可能就会发生死锁。此时可以通过thread dump获得线程的栈跟踪信息。在Unix平台下可以通过向JVM发送SIGQUIT信号(kill -3)获得thread dump,在Windows平台下则通过Ctrl+Break。以上代码在死锁时的thread dump如下:

Found one Java-level deadlock:

    =============================

    "thread2":

      waiting to lock monitor 0x0003e664 (object 0x230c3f40, a Cell),

      which is held by "thread1"

    "thread1":

      waiting to lock monitor 0x0003e6a4 (object 0x230c3f50, a Cell),

      which is held by "thread2"



    Java stack information for the threads listed above:

    ===================================================

    "thread2":

            at Cell.getValue(Cell.java:18)

            - waiting to lock <0x230c3f40> (a Cell)

            at Cell.swap(Cell.java:27)

            - locked <0x230c3f50> (a Cell)

            at Cell$2.run(Cell.java:65)

            at java.lang.Thread.run(Unknown Source)

    "thread1":

            at Cell.setValue(Cell.java:22)

            - waiting to lock <0x230c3f50> (a Cell)

            at Cell.swap(Cell.java:29)

            - locked <0x230c3f40> (a Cell)

            at Cell$1.run(Cell.java:46)

            at java.lang.Thread.run(Unknown Source)



    Found 1 deadlock.

为了避免死锁的危险,在一个同步的方法或者代码块中,永远不要放弃对客户的控制。换句话说,在一个被同步的区域内部,不要调用一个可被改写的公有或受保护的方法。从包含该同步区域的类的角度来看,这样的一个方法是一个外来者(alien)。这个类不知道该方法会做什么事情,也控制不了它。假设客户的方法创建另一个线程,再回调到这个类中。然后,新建的线程试图获取原线程所拥有的那把锁,这样就会导致新建的线程被阻塞。如果创建该线程的方法正在等待这个线程完成任务,则会导致死锁。   

    另外一种比较简单的避免死锁的独占技术是顺序化资源(resource ordering),它的思想就是把一个嵌套的synchronized方法或块中使用的对象和一个数字标签关联起来。如果同步操作是根据对象标签的最小优先(least first)的原则,那么刚才介绍的例子的情况就不会发生。也就是说,如果线程A和线程B都按照相同的顺序获得锁,就可以避免死锁的发生。对于数字标签的选择,可以使用System.identityHashCode的返回值,尽管没有什么机制可以保证identityHashCode的惟一性,但是在实际运行的系统中,这个方法的惟一性在很大程度上得到了保证。swap的一个更好的实现如下:

  1. public void swap(Cell other)
  2. {
  3. if(this == other) return; // Alias check
  4. else if(System.identityHashCode(this) < System.identityHashCode(other))
  5. {
  6. this.doSwap(other);
  7. }
  8. else
  9. {
  10. other.doSwap(this);
  11. }
  12. }
  13. private synchronized void doSwap(Cell Other)
  14. {
  15. long t = getValue();
  16. long v = other.getValue();
  17. setValue(v);
  18. other.setValue(t);
  19. }

1.6 避免过多的同步

1.6.1是否需要同步

    过多的同步可能会导致性能降低、死锁,甚至不确定行为。通常,在同步区域内应该做尽可能少的工作。同步区域之外被调用的外来方法被称为“开放调用(open call)”。除了可以避免死锁之外,开放调用还可以极大地增加并发性。

    考虑StringBuffer类和BufferedInputStream类,这些类都是线程安全(thread-safe)的,但是它们往往被用于单个线程中,所以它们所做的锁操作往往是不必要的,虽然同步的开销自java平台早期开始就一直在下降,但是它永远也不会消失。一个给定的类是否应该执行内部同步并不总是很清楚,下面是一些指导性的原则。

如果你正在编写的类主要被用于同步环境中,同时也被用于不要求同步的环境中,那么一个合理的方法是,同时提供同步版本和和未同步版本。这也正是Collections Framework采用的方法。还有,java.util.Random也是采用这一种做法是提供一个包装类(wrapper class),它实现一个描述该类的接口,同时在将方法调用转发给内部对象中对应的方法之前执行适当的同步操作。种方法。第二种方法适用于那些不是被设计用来扩展或者重新实现的类,它提供一个未同步的类和一个子类,在子类中包含一些被同步的方法,它们依次调用到超类中对应的方法上。

关于是否对一个用于存取成员变量的方法进行同步,需要考虑两点:合法性和陈旧性。如果成员变量不总是合法的,那么可以的选择是:

  • 同步所有存取方法
  • 确保用户在得到非法值的时候能得到通知
  • 省略存取方法。在并发程序中,对象的属性可以被异步修改,客户通过某行代码得到的值可能在下一行代码中就改变了。因此需要仔细评估存取方法存在的必要性。

如果成员变量的值总是合法的,但是不能是陈旧数据,那么可以的选择是:

  • 把成员变量定义为volatile,并去掉存取方法的同步。

1.6.2 分解同步和分解锁

    另外一种增加程序并发性的方法是分解同步,如果一个类的行为可以分解为互相独立、互不干扰或者不冲突的子部分,那么就值得用细粒度的辅助对象来重新构造类。普遍的原则是,把类的内部同步操作分得越细,在大多数情况下,它的活性就越高。但是这一点是以更加复杂和潜在的错误为代价的。例如:

  1. public class Shape
  2. {
  3. public synchronized vodi adjustLocation(){ /*Long time operation*/ }
  4. public synchronized vodi adjustDimensions(){ /*Long time operation*/ }
  5. }

我们假设adjustLocation不处理维度信息,adjustDimensions不处理位置信息,那么可以考虑把维度和位置信息分解到两个类中, 例如:

  1. public class Shape
  2. {
  3. private final Location location = new Location();
  4. private final Dimensions dimensions = new Dimensions();
  5. public void adjustLocation(){ location.adjustLocation(); }
  6. public void adjustDimensions(){ dimensions.adjustDimensions(); }
  7. }
  8. public class Location
  9. {
  10. public synchronized void adjustLocation(){ /*Long time operation*/ }
  11. }
  12. public class Dimensions
  13. {
  14. public synchronized void adjustDimensions(){ /*Long time operation*/ }
  15. }

如果你不能或者不想分解类,则可以分解每个子功能相关的同步锁。例如

  1. public class Shape
  2. {
  3. private final Object locationLock = new Object();
  4. private final Object dimensionsLock = new Object();
  5. public void adjustLocation()
  6. {
  7. synchronized(locationLock)
  8. {
  9. /*Long time operation*/
  10. }
  11. }
  12. public void adjustDimensions()
  13. {
  14. synchronized(dimensionsLock)
  15. {
  16. /*Long time operation*/
  17. }
  18. }
  19. }

1.6.3 冲突集合

    设想有一个Inventory类,它有store和retrieve方法来存取对象。以下的例子中使用了Hashtable来演示,虽然这种完全同步的Hashtable允许Inventory类的实现无需考虑底层的实现细节。但是,我们仍然想store和retrieve方法添加一些语义上的约束,如下:

  • retrieve操作不应该和store操作并发执行。
  • 两个或者两个以上的retrieve方法不应该同时执行。
  • 两个或者两个以上的store方法可以同时执行。

以下的非正规符号描述了冲突集合,即不能并发的方法对的集合.

{(store, retrieve), (retrieve, retrieve)}

    基于冲突集合的类可以使用before/after这种模式,即基本操作被那些维护者独占关系的代码所环绕。首先,对于每个方法,定义一个计数变量,用以表示该方法是否在执行中。其次,把每个基本操作都隔离入非公共方法中。最后,编写那些基本操作的公共版本,即在那些基本操作的前后添加上before/after的控制。以下是个示例代码:

  1. public class Inventory
  2. {
  3. protected final Hashtable items = new Hashtable();
  4. protected final Hashtable suppliers = new Hashtable();
  5. protected int storing = 0;
  6. protected int retrieving = 0;
  7. public void store(String desc, Object item, String supplier)
  8. throws InterruptedException
  9. {
  10. synchronized(this)
  11. {
  12. while(retrieving != 0)
  13. {
  14. wait();
  15. ++storing;
  16. }
  17. }
  18. try
  19. {
  20. doStore(desc, item, supplier);
  21. }
  22. finally
  23. {
  24. synchronized(this)
  25. {
  26. if(--storing == 0)
  27. {
  28. notifyAll();
  29. }
  30. }
  31. }
  32. }
  33. public Object retrieve(String desc)
  34. throws InterruptedException
  35. {
  36. synchronized(this)
  37. {
  38. while(storing != 0 || retrieving != 0)
  39. {
  40. wait();
  41. ++retrieving;
  42. }
  43. }
  44. try
  45. {
  46. return doRetrieve(desc);
  47. }
  48. finally
  49. {
  50. synchronized(this)
  51. {
  52. if(--retrieving == 0)
  53. {
  54. notifyAll();
  55. }
  56. }
  57. }
  58. }
  59. protected void doStore(String desc, Object item, String supplier)
  60. {
  61. items.put(desc, item);
  62. suppliers.put(supplier, desc);
  63. }
  64. protected Object doRetrieve(String desc)
  65. {
  66. Object x = items.get(desc);
  67. if(x != null)
  68. {
  69. items.remove(desc);
  70. }
  71. return x;
  72. }
  73. }

接下来考虑一个更复杂的例子,一个读出者和写入者模型,与Inventroy不同,读出者和写入者策略不仅应用于特定方法,而是控制所有具有读出和写入语义的方法。假设我们需要进行有目的的锁定(intention lock),比如,要求按照write,read,write,read,write的顺序等。这时候我们需要考虑的有以下几点:

  • 如果当前已经存在一个或者多个活动(执行中)的读出者,而且有一个写入者正在等待的时候,一个新的读出者是否能否立即加入?如果答案是肯定的话,那么不断增加的读出者将会使写入者无法执行;如果答案为否,那么读出者的吞吐量就会下降。
  • 如果某些读出者与写入者同时在等待一个活动的写入者完成操作,那么你的处理策略会偏向读出者还是写入者?先到者优先?随意?轮流?

虽然以上策略没有明确的答案,但是一些标准的解决方案和相关的实现还是存在的,以下一个通用的实现,使用了模板类和before/after这种模式,其子类版本不需要做过多的修改。而且可以通过让allowReader和allowWriter方法中的谓词依赖与这个值,来调整控制策略。以下是示例代码:

  1. public abstract class ReadWrite
  2. {
  3. protected int activeReaders = 0;
  4. protected int activeWriters = 0;
  5. protected int waitingReaders = 0;
  6. protected int waitingWriters = 0;
  7. protected abstract void doRead();
  8. protected abstract void doWrite();
  9. public void read() throws InterruptedException
  10. {
  11. beforeRead();
  12. try { doRead(); }
  13. finally { afterRead(); }
  14. }
  15. public void write() throws InterruptedException
  16. {
  17. beforeWrite();
  18. try { doWrite(); }
  19. finally { afterWrite(); }
  20. }
  21. protected boolean allowReader()
  22. {
  23. return waitingWriters == 0 && activeWriters == 0;
  24. }
  25. protected boolean allowWriter()
  26. {
  27. return activeReaders == 0 && activeWriters == 0;
  28. }
  29. protected synchronized void beforeRead() throws InterruptedException
  30. {
  31. ++waitingReaders;
  32. while(!allowReader())
  33. {
  34. try { wait(); }
  35. catch(InterruptedException ie)
  36. {
  37. --waitingReaders;
  38. throw ie;
  39. }
  40. }
  41. --waitingReaders;
  42. ++activeReaders;
  43. }
  44. protected synchronized void afterRead()
  45. {
  46. --activeReaders;
  47. notifyAll();
  48. }
  49. protected synchronized void beforeWrite() throws InterruptedException
  50. {
  51. ++waitingWriters;
  52. while(!allowWriter())
  53. {
  54. try { wait(); }
  55. catch(InterruptedException ie)
  56. {
  57. --waitingWriters;
  58. throw ie;
  59. }
  60. }
  61. --waitingWriters;
  62. ++activeWriters;
  63. }
  64. protected synchronized void afterWrite()
  65. {
  66. --activeWriters;
  67. notifyAll();
  68. }
  69. }

1.7 使用锁工具

1.7.1 synchronized 的限制

    内部的synchronized方法和块可以满足很多基于锁的应用,但是它有以下限制:

  • 如果某个线程试图获得锁,而这个锁已经被其他线程持有,那么没有办法回退,也没有办法在等待一段时间后放弃等待,或者在某个中断之后取消获取锁的企图,这些使得线程很难从活性问题中恢复。
  • 没有办法改变锁的语义形式,例如重入性、读何写保护或者公平性等方面。
  • 没有同步的访问控制,任何一个方法都可以对其可访问的对象执行synchronized(obj)操作,这样导致由于所需要的锁已经被占用而引起拒绝服务的问题。
  • 方法和块内的同步,使得只能够够对严格的块结构使用锁。例如:不能在一个方法中获得锁,而在另外一个方法中释放锁。

1.7.2 util.concurrent工具包

    util.concurrent工具包是Doug Lea在基本的Java同步工具(synchronization tools)之上,编写的高质量、高效率、语义上准确的线程控制结构工具包。下面简要介绍几个接口和实现。

1.7.2.1 ReentrantLock

    ReentrantLock具有与内部锁相同的互斥、重入性和内存可见性的保证,它必须被显式地释放。ReentrantLock是可中断的、可定时的,非块结构锁。在Java5中,ReentrantLock的性能要远远高于内部锁。在Java6中,由于管理内部锁的算法采用了类似于 ReentrantLock使用的算法,因此内部锁和ReentrantLock之间的性能差别不大。

    ReentrantLock的构造函数提供了两种公平性选择:创建非公平锁(默认)或者公平锁。在公平锁中,如果锁已被其它线程占有,那么请求线程会加入到等待队列中,并按顺序获得锁;在非公平锁中,当请求锁的时候,如果锁的状态是可用,那么请求线程可以直接获得锁,而不管等待队列中是否有线程已经在等待该锁。公平锁的代价是更多的挂起和重新开始线程的性能开销。在多数情况下,非公平锁的性能高于公平锁。Java内部锁也没有提供确定的公平性保证, Java语言规范也没有要求JVM公平地实现内部锁,因此ReentrantLock并没有减少锁的公平性。下面是关于ReentrantLock的一个例子:

  1. import java.util.concurrent.locks.Condition;
  2. import java.util.concurrent.locks.Lock;
  3. import java.util.concurrent.locks.ReentrantLock;
  4. public class BoundedBuffer<T> {
  5. //
  6. private int head;
  7. private int tail;
  8. private int count;
  9. private final T buffer[];
  10. //
  11. private final Lock lock = new ReentrantLock();
  12. private final Condition notEmpty = lock.newCondition();
  13. private final Condition notFull = lock.newCondition();
  14. @SuppressWarnings("unchecked")
  15. public BoundedBuffer(int capacity) {
  16. this.buffer = (T[]) new Object[capacity];
  17. }
  18. public T take() throws InterruptedException {
  19. lock.lock();
  20. try {
  21. while(isEmpty()) {
  22. notEmpty.await();
  23. }
  24. T t = doTake();
  25. notFull.signal();
  26. return t;
  27. } finally {
  28. lock.unlock();
  29. }
  30. }
  31. public void put(T t) throws InterruptedException {
  32. lock.lock();
  33. try {
  34. while(isFull()) {
  35. notFull.await();
  36. }
  37. doPut(t);
  38. notEmpty.signal();
  39. } finally {
  40. lock.unlock();
  41. }
  42. }
  43. private boolean isEmpty() {
  44. return count == 0;
  45. }
  46. private boolean isFull() {
  47. return count == buffer.length;
  48. }
  49. private T doTake() {
  50. T t = buffer[head];
  51. buffer[head] = null;
  52. if(++head == buffer.length) {
  53. head = 0;
  54. }
  55. --count;
  56. return t;
  57. }
  58. private void doPut(T t) {
  59. buffer[tail] = t;
  60. if(++tail == buffer.length) {
  61. tail = 0;
  62. }
  63. ++count;
  64. }
  65. }

1.7.2.2 Mutex

    一个Mutex类(互斥独占锁mutual exclusion lock)的所写可以定义为

  1. public class Mutex implemets Sync
  2. {
  3. public void acquire() throws InterruptedException;
  4. public void release();
  5. public boolean attempt(long msec) throws InterruptedException;
  6. }

acquire和同步块的入口操作相似,release和同步块的释放锁操作相似。attempt操作只有在规定的时间内得到锁才返回true。0是合法的,这表明如果得不到锁的话则不需要等待。和内建的同步机制不同的是,如果当前的线程在试图获得锁的过程中被中断,acquire和attempt方法会抛出InterruptedException异常,这一点增加了使用的复杂性,但是提供了编写响应良好的健壮代码的来处理取消操作的机制。和synchronized方法或块不同的是,标准的Mutex类不能重入。如果锁已经被执行acquire的线程持有,如果这个线程继续调用acquire,那么它会被阻塞。ReentrantLock是可重入的锁。

1.7.2.3 Semaphore

    信号量(Semaphore) 是并发控制中的经典构件。同其他工具类一样,它们也遵守获得-释放协议。从概念上说,一个信号量维护着一组在构造方法中初始化了的许可证。如果必要的话,每次acquire操作都会阻塞直到有一个许可证可用,然后占用这个许可证。attempt方法执行类似的操作,但是它可以在超时的时候失败并退出。每一次release都会添加一个许可证。不过事实上并没有使用真实的许可证对象,信号量只需要知道当前可用的许可证的数量并执行相关的操作即可。Mutex可以看成许可数是1的Semaphore。下面是关于信号量的一个典型例子:

  1. public class SyncQueue implements Queue
  2. {
  3. private final Queue mQueue;
  4. private final int mCapacity;
  5. private final Semaphore mSemProducer;
  6. private final Semaphore mSemConsumer;
  7. public SyncQueue(Queue queue)
  8. {
  9. this(queue, Integer.MAX_VALUE);
  10. }
  11. public SyncQueue(Queue queue, int capacity)
  12. {
  13. mQueue = queue;
  14. mCapacity = capacity;
  15. mSemProducer = new Semaphore(capacity);
  16. mSemConsumer = new Semaphore(0);
  17. }
  18. public Object get()
  19. {
  20. // Accquire consumer's semaphore
  21. try
  22. {
  23. mSemConsumer.acquire();
  24. }
  25. catch(InterruptedException ie)
  26. {
  27. Thread.currentThread().interrupt();
  28. return null;
  29. }
  30. // Get the item
  31. Object item = null;
  32. synchronized(mQueue)
  33. {
  34. item = mQueue.get();
  35. }
  36. //
  37. mSemProducer.release();
  38. return item;
  39. }
  40. public boolean put(Object item)
  41. {
  42. // Precondition checking
  43. if(item == null)
  44. {
  45. return false;
  46. }
  47. // Accquire producer's semaphore
  48. try
  49. {
  50. mSemProducer.acquire();
  51. }
  52. catch(InterruptedException ie)
  53. {
  54. Thread.currentThread().interrupt();
  55. return false;
  56. }
  57. // Add the item
  58. synchronized(mQueue)
  59. {
  60. mQueue.put(item);
  61. }
  62. // Release consumer's semaphore
  63. mSemConsumer.release();
  64. return true;
  65. }
  66. }

1.7.2.4 Latch

    闭锁(latch)是指那些一旦获得某个值就再不变化的变量或者条件。二元闭锁变量或者条件(通常就被成为闭锁)的值只能改变一次,即从其初始化状态到其最终状态。和闭锁相关的并发控制技术封装在Latch类中,并遵守通用的获得-释放协议。但是它的语义为:一个release操作将使得所有之前或者之后的acquire操作都恢复执行。

    闭锁的扩展之一就是倒数计数器(countdown),其acquire操作在release操作执行了固定的次数,而不仅仅是一次后恢复执行。闭锁,倒数计数器以及建立在他们基础之上的简单工具类可以被用于处理一下这些条件的响应操作。

  • 完成指示器。例如,强制某些线程直到某些操作执行完毕后才能继续执行。
  • 定时阀值。例如,在某个时期触发一组线程。
  • 事件指示。例如,触发那些只有收到特定报文或者特定按钮被按下后才能继续的操作。
  • 错误指示。例如,触发在全局性的关闭人去执行时才可以运行的一组线程

1.7.2.5 Barrier

Barrier能够阻塞一组线程,其与闭锁的区别在于:闭锁等待的是事件,barrier等待的是线程。CyclicBarrier允许给定数量的线程多次集中在一个barrier point。当某个线程调用await方法时会被阻塞,当所有的线程都调用await方法时,barrier被突破,所有的线程都可以继续执行,barrier也被reset以备下一次使用。如果await调用超时,或者阻塞中的线程被中断,那么barrier就认为是失败的,所有未完成的await调用都通过BrokenBarrierException终止。如果await调用成功,那么它返回一个唯一的到达索引号。CyclicBarrier也允许你向构造函数中传递一个Runnable型的barrier
action,当成功通过barrier的时候会被执行。下面是关于CyclicBarrier的一个例子:

  1. import java.util.concurrent.BrokenBarrierException;
  2. import java.util.concurrent.CountDownLatch;
  3. import java.util.concurrent.CyclicBarrier;
  4. public class Solver {
  5. //
  6. private final String[][] data;
  7. private final CyclicBarrier barrier;
  8. private final CountDownLatch latch;
  9. public Solver(String[][] data) {
  10. this.data = data;
  11. this.barrier = new CyclicBarrier(data.length, new BarrierAction());
  12. this.latch = new CountDownLatch(data.length);
  13. }
  14. public void start() {
  15. //
  16. for (int i = 0; i < data.length; ++i)  {
  17. new Thread(new Worker("worker" + i, this.data[i])).start();
  18. }
  19. //
  20. try {
  21. latch.await();
  22. } catch (InterruptedException e) {
  23. e.printStackTrace();
  24. }
  25. }
  26. public static void main(String args[]) {
  27. String[][] data = new String[][]{{"a1", "a2", "a3"}, {"b1", "b2", "b3"}, {"c1", "c2", "c3"}};
  28. Solver solver = new Solver(data);
  29. solver.start();
  30. }
  31. private class BarrierAction implements Runnable {
  32. public void run() {
  33. System.out.println(Thread.currentThread().getName() + " is processing barrier action");
  34. }
  35. }
  36. private class Worker implements Runnable {
  37. //
  38. private String name;
  39. private String[] row;
  40. Worker(String name, String[] row) {
  41. this.name = name;
  42. this.row = row;
  43. }
  44. public void run() {
  45. for(int i = 0; i < row.length; i++) {
  46. System.out.println(name + " is processing row[" + i +"]" + row[i]);
  47. try {
  48. barrier.await();
  49. } catch (InterruptedException ex) {
  50. break;
  51. } catch (BrokenBarrierException ex) {
  52. break;
  53. }
  54. }
  55. //
  56. latch.countDown();
  57. }
  58. }
  59. }

1.8 并发处理实践

    假设你设计了一个集合类,现在想提供一个多线程环境下的遍历方法。最于这个设计问题一般有三种解决方法:同步聚集操作、索引化遍历和版本化迭代变量,每种方法都有设计的利弊。



1.8.1 同步聚集操作

    一种安全使用枚举的方法就是吧作用于每个元素的操作抽取出来,这样可以把它作为synchronized applyToAll方法的参数(比如C/C++中的函数指针(function pointer),java中的接口或者闭包(colsure))。例如:

  1. public interface Procedure
  2. {
  3. void apply(Object obj);
  4. }
  5. public class Vector
  6. {
  7. public syncronized void applyToAll(Procedure p)
  8. {
  9. for(int i = 0;  i < size; i++)
  10. {
  11. p.apply(data[i]);
  12. }
  13. }
  14. }

这种方法消除了在遍历过程中其它线程是否增加或者减少集合元素可能带来的干扰,但是代价是拥有集合的锁的时间太长。

1.8.2 索引化遍历和客户端锁

    这种遍历策略是要求客户端使用索引的访问方法来遍历,例如:

  1. for(int i = 0;  i < v.size(); i++)
  2. {
  3. System.out.println(v.get(i));
  4. }

size(), get(int)方法都是同步的,但是为了处理有细锁类度产生的潜在冲突,比如像v.size()方法可能成功,但是之后,另一个线程可能删除了最后一个元素,如果这时调用v.get(i)可能就会出错。解决这个问题的一个办法就是使用客户端锁,来保证大小检查和访问的原子性。

    这种方法使用起来比较灵活,但是是在破会封装为代价的前提下,而且正确与否依赖于对Vector内部实现的了解程度。

1.8.3 版本化迭代变量

    这用遍历方法是涉及的集合类支持失败即放弃的迭代变量,如果在遍历的过程中集合元素被修改,迭代操作就会抛出一个异常。实现这种策略的最简单的方法就是维护一个迭代操作的版本号,这个版本号在每次更新集合时都会增长。每当迭代变量访问下一个元素的时候,都会先看一下这个版本号,如果它已经改变了,则抛出一个异常。这个版本号应该足够大,使得在一次遍历的过程中版本号不会循环。一般来讲,整形(int)就足够了。

    Java语言集合框架中的java.util.Iterator使用的就是这用策略。ConcurrentModificationException经常说明了在线程之间存在无计划而且不希望看到的交互,然而这些问题的修正仅靠异常处理代码往往是不够的。对于集合类来说,版本化迭代变量还是一个比较好的选择,部分因为可以在这些迭代化变量之上使用聚合遍历或客户端锁。


Java Concurrent的更多相关文章

  1. java concurrent包的学习(转)

    java concurrent包的学习(转) http://my.oschina.net/adwangxiao/blog/110188 我们都知道,在JDK1.5之前,Java中要进行业务并发时,通常 ...

  2. How to Create a Java Concurrent Program

    In this Document   Goal   Solution   Overview   Steps in writing Java Concurrent Program   Template ...

  3. Java Concurrent Topics

    To prevent Memory Consistency Errors(MCEs), it is good practice to specify synchronized class specif ...

  4. Java中编写线程安全代码的原理(Java concurrent in practice的快速要点)

    Java concurrent in practice是一本好书,不过太繁冗.本文主要简述第一部分的内容. 多线程 优势 与单线程相比,可以利用多核的能力; 可以方便的建模成一个线程处理一种任务; 与 ...

  5. Java Concurrent之 AbstractQueuedSynchronizer

    ReentrantLock/CountDownLatch/Semaphore/FutureTask/ThreadPoolExecutor的源码中都会包含一个静态的内部类Sync,它继承了Abstrac ...

  6. [Java Concurrent] 多线程合作 producer-consumers / queue 的简单案例

    在多线程环境下,通过 BlockingQueue,实现生产者-消费者场景. Toast 被生产和消费的对象. ToastQueue 继承了 LinkedblockingQueue ,用于中间存储 To ...

  7. [Java Concurrent] 多线程合作 wait / notifyAll 的简单案例

    本案例描述的是,给一辆汽车打蜡.抛光的场景. Car 是一辆被打蜡抛光的汽车,扮演共享资源的角色. WaxOnCommand 负责给汽车打蜡,打蜡时需要独占整部车,一次打一部分蜡,等待抛光,然后再打一 ...

  8. [Java Concurrent] 并发访问共享资源的简单案例

    EvenGenerator 是一个偶数生成器,每调用一个 next() 就会加 2 并返回叠加后结果.在本案例中,充当被共享的资源. EvenChecker 实现了 Runnable 接口,可以启动新 ...

  9. 利用java concurrent 包实现日志写数据库的并发处理

    一.概述 在很多系统中,往往需要将各种操作写入数据库(比如客户端发起的操作). 最简单的做法是,封装一个公共的写日志的api,各个操作中调用该api完成自己操作日志的入库.但因为入数据库效率比较低,如 ...

  10. [Java concurrent][Collections]

    同步容器类 同步容器类包括Vector和Hashtable,二者是早期JDK的一部分.以及一些在JDK1.2中添加的可以由Collections.synchronizedXxx等工厂方法创建的. 这些 ...

随机推荐

  1. MJUCTF—WP

    1.猫娘 点开发现有两个文件, 一个加密压缩包, 一个word文档 点开word发现是兽音加密, 点开在线网站进行解密 # 得到一段文本, 先进行分割 小小年内则伏勤, 阵阵寒风刺骨寒. 是处寂寞无人 ...

  2. 7.22-27MY&MS&ORA等SQL数据库提权

    7.22-27MY&MS&ORA等SQL数据库提权 思路:在利用系统溢出漏洞无果情况下,可以采用数据库提权: 前提:数据库服务开启,且获得数据库最高权限账号密码:除Access数据库外 ...

  3. Round #2022/11/26

    问题 B:染色 题目描述 有长度为 \(n\) 的一个序列,编号为 \(1\) 到 \(n\) ,现要对这些元素进行染色标记,若编号 \(i-j\) 为素数,且 \(1\le i < j \le ...

  4. Spring —— bean实例化

    bean 实例化 bean本质上就是对象,创建bean使用构造方法完成(反射)      构造方法(常用)        静态工厂*        实例工厂*        FactoryBean(实 ...

  5. 修改Kubernetes主节点(控制节点)名称

    1.修改物理机主机名 hostnamectl set-hostname <hostname> 2.修改 /etc/kubernetes/manifests 目录下的文件,将文件内容包含旧主 ...

  6. USB和CAN都是用差分信号来传输数据,为什么CAN的传输距离能比USB远那么多?

    USB和CAN的区别 今天在看USB项目设计实例的时候,突然想到一个问题,从而引发了一些思考.经过思考加上查阅资料,写出了这一篇文章作为记录. 问题 ​ USB和CAN都是用两条线作为差分线以差分信号 ...

  7. 2022年1月国产数据库排行榜:TiDB霸榜两年势头不减,openGauss与OceanBase分数大涨

    奎钩粲粲光华动,群玉森森气象新.国产数据库行业在经历了2021年的躬行实践之后,产品.服务.生态等取得了蓬勃发展.从2022年1月份的国产数据库流行度排行榜上,我们可以看到,相较于去年12月份,榜单上 ...

  8. 74.数组map能干什么,会改变原数组吗

    map是处理数据的方法,不会改变原数组,会返回一个新数组 : filter 也不会改变原数组,会返回新数组 : forEach 也不会改变原数组,不会返回新数组 : reduce不会改变原数组 : 是 ...

  9. kotlin基础——>基本数据类型、控制流、返回和跳转

    1.对于数字的定义,支持java 8的下划线分割的方式 val a = 1_2_3 与 val a = 123 是相同的 2.如果要指定数字类型有两种方式 val a : Float = 1 或者 v ...

  10. KubeSphere 社区双周报| 2024.08.16-08.29

    KubeSphere 社区双周报主要整理展示新增的贡献者名单和证书.新增的讲师证书以及两周内提交过 commit 的贡献者,并对近期重要的 PR 进行解析,同时还包含了线上/线下活动和布道推广等一系列 ...