首页 » Java程序员修炼之道 » Java程序员修炼之道全文在线阅读

《Java程序员修炼之道》4.2 块结构并发(Java 5之前)

关灯直达底部

本章大部分内容都在讨论块同步并发方式的替代方案。如果你想从我们的讨论中获益,就需要深刻理解传统并发的优缺点的重要性。

为此我们要讨论用到synchronizedvolatile等并发关键字的那种原始、低级的多线程编程方式。我们把这个讨论放在设计原则的情境中,并且会着眼于下一节将要讨论的内容。

之后我们会简略地解释一下线程的生命周期,然后讨论常见的并发编程技巧和陷阱,比如完全同步的对象,死锁,volatile关键字和不变性。

我们先重温一下同步吧。

4.2.1 同步与锁

你知道的,synchronized既可以用在代码块上也可以用在方法上。它表明在执行整个代码块或方法之前线程必须取得合适的锁。对于方法而言,这意味着要取得对象实例锁(对于静态方法而言则是类锁)。对于代码块,程序员则应该指明要取得哪个对象的锁。

在任何一个对象的同步块或方法中,每次只能有一个线程进入;如果其他线程试图进入,JVM会挂起它们。无论其他线程试图进入的是该对象的同一同步块还是不同的同步块,JVM都会如此处理。这种结构在并发理论中被称为临界区。

注意 你有没有想过Java中用于确立临界区的关键字为什么是synchronized?为什么不是“critical”或“locked”?同步的是什么?我们会在4.2.5节回到这一话题上来,但如果你不知道,或者从来没想过这个问题,你最好花几分钟想一想再继续。

本章要讨论一些比较新的并发技术。但既然说到了同步,我们就顺便看看与Java中的同步和锁相关的一些基本事实吧。希望你对这里的大多数(或全部)知识都已经烂熟于心了。

  • 只能锁定对象,不能锁定原始类型。

  • 被锁定的对象数组中的单个对象不会被锁定。

  • 同步方法可以视同为包含整个方法的同步(this) { ... }代码块(但要注意它们的二进制码表示是不同的)。

  • 静态同步方法会锁定它的Class对象,因为没有实例对象可以锁定。

  • 如果要锁定一个类对象,请慎重考虑是用显式锁定,还是用getClass,两种方式对子类的影响不同。

  • 内部类的同步是独立于外部类的(要明白为什么会这样,请记住内部类是如何实现的)。

  • synchronized并不是方法签名的组成部分,所以不能出现在接口的方法声明中。

  • 非同步的方法不查看或关心任何锁的状态,而且在同步方法运行时它们仍能继续运行。

  • Java的线程锁是可重入的。也就是说持有锁的线程在遇到同一个锁的同步点(比如一个同步方法调用同一个类内的另一个同步方法)时是可以继续的。

警告 在其他语言中存在不可重入的锁机制(用java也能实现相同的效果,如果你想了解那些让人看了毛骨悚然的详细信息,请参见java.util.concurrent.locks中的ReentrantLock的Javadoc),但和它们打交道太痛苦了,除非你真的知道自己在做什么,否则还是躲之为妙。

对Java同步的温习就到此为止吧。现在我们来看一下线程在其生命周期中的状态变迁。

4.2.2 线程的状态模型

图4-2展示了线程生命周期的发展过程——从创建到运行,到再次运行之前(或被资源阻塞)可能被挂起,再到最终完成。

图4-2 Java的线程状态模型

线程最初创建时处于就绪(Ready)状态。然后调度器会找个核心来运行它,如果机器负载过重,那它就可能需要多些时间。开始运行之后,线程通常会消耗掉分配给它的时间,然后回到就绪状态,等到下次再有处理器分配时间片给它。这是我们在4.1.1节提过的抢占式线程调度的标准动作。

除了由调度器发起的标准动作,线程本身也能表明它此时无法使用核心工作。这可能是因为程序代码通过Thread.sleep告诉线程在继续之前应该暂停,或者因为线程必须等待通知(通常需要满足某些外部条件)。这时线程会从核心中移走,并释放它持有的锁。只有通过唤醒才能再次运行线程(在达到睡眠时长之后,或收到了恰当的信号),进入就绪状态。

线程可能会因为等待I/O或等待获取其他线程持有的锁而被阻塞。这时线程并没有被交换出核心,而是仍然处于繁忙状态,等着获取可用的锁或数据。在得到锁或数据之后,线程会继续执行直到它的时间片结束。

我们接下来讨论一个著名的解决同步问题的办法——完全同步对象。

4.2.3 完全同步对象

前面介绍了并发类型安全的概念,还提到了一种用来达成这种安全性的策略(在“保证安全”的边栏中)。现在我们来看一下这个策略更完整的描述,它通常被称为完全同步对象。如果一个类遵从下面所有规则,就可以认为它是线程安全并且活跃的。

一个满足下面所有条件的类就是完全同步类。

  • 所有域在任何构造方法中的初始化都能达到一致的状态。
  • 没有公共域。
  • 从任何非私有方法返回后,都可以保证对象实例处于一致的状态(假定调用方法时状态是一致的)。
  • 所有方法经证明都可在有限时间内终止。
  • 所有方法都是同步的。
  • 当处于非一致状态时,不会调用其他实例的方法。
  • 当处于非一致状态时,不会调用非私有方法。

假定有一个分布式微博工具,代码清单4-1是其后台中的类。在它的propagateUpdate方法被调用时,ExampleTimingNode类会收到更新,也可以通过查询看它是否收到了特定更新。这是经典的读写操作相互冲突的情景,需要通过同步防止出现不一致状态。

代码清单4-1 完全同步类

public class ExampleTimingNode implements SimpleMicroBlogNode {    /*没有公开域*/  private final String identifier;  private final Map<Update, Long> arrivalTime = new HashMap<>;  //所有域在构造方法中初始化  public ExampleTimingNode(String identifier_) {    identifier = identifier_;  }  /*所有方法都是同步的*/  public synchronized String getIdentifier {    return identifier;  }  public synchronized void propagateUpdate(Update update_) {    long currentTime = System.currentTimeMillis;    arrivalTime.put(update_, currentTime);  }    public synchronized boolean confirmUpdateReceived(Update update_) {      Long timeRecvd = arrivalTime.get(update_);      return timeRecvd != null;  }}  

这是一个既安全又活跃的类,第一眼看上去让人感觉很了不起。但随之而来的是性能问题,既安全又活跃的东西速度不一定也能很快。必须用synchronized去协调对Map arrivalTime的所有访问(getput),而这个锁最终会把你的速度拖慢。这是并发处理方式的主要问题。

代码的脆弱性

除了性能问题,代码清单4-1中的代码还很脆弱。你看,它从来不会在同步方法之外去碰arrivalTime,实际上只是调用getput方法,但这只有在代码量很小的情况下才有可能。在真实的大型系统中,代码太多而无法实现这种方法。同时,bug也很容易潜伏在庞大的代码库中,这也是Java社区开始寻求更完善的解决方法的另一个原因。

4.2.4 死锁

并发的另一个经典问题是死锁。代码清单4-2稍微扩展了一下上个例子。在这一版中,除了记录最近一次更新的时间,每个节点收到更新时还会通知另外一个节点。

这段代码试图构建一个多线程的更新处理系统。注意,这段代码是为了解释死锁,不要把它用到你的工作中。

代码清单4-2 死锁的例子

public class MicroBlogNode implements SimpleMicroBlogNode {  private final String ident;  public MicroBlogNode(String ident_) {    ident = ident_;  }    public String getIdent {      return ident;  }    public synchronized void propagateUpdate(Update upd_, MicroBlogNode       backup_) {      System.out.println(ident +/": recvd: /"+ upd_.getUpdateText +/" ; backup: /"+backup_.getIdent);      backup_.confirmUpdate(this, upd_);  }  public synchronized void confirmUpdate(MicroBlogNode other_, Update     update_) {    System.out.println(ident +/": recvd confirm: /"+update_.getUpdateText +/" from /"+other_.getIdentk);  }}//关键字final是必需的final MicroBlogNode local = new MicroBlogNode(/"localhost:8888/");final MicroBlogNode other = new MicroBlogNode(/"localhost:8988/");final Update first = getUpdate(/"1/");final Update second = getUpdate(/"2/");new Thread(new Runnable {  public void run {    local.propagateUpdate(first, other);//第一个更新发送给第一个线程    } }).start; new Thread(new Runnable {   public void run {     other.propagateUpdate(second, local);//第二个更新发送给第二个线程  }}).start;  

乍一看,这段代码没什么毛病。有两个更新分别发送给不同的线程,每个都必须由后备线程进行确认。这看起来不是什么离奇古怪的设计——如果一个线程失效,另外一个线程还可以挑起重担。

如果你运行这段代码,一般都会碰到死锁——两个线程都说自己收到了更新,但它俩谁都不会以备份线程的身份确认收到了更新。因为每个线程在确认方法能够确认之前都要求另外一个线程释放线程锁,如图4-3所示。

图4-3 死锁线程

有一个处理死锁的技巧,就是在所有线程中都以相同的顺序获取线程锁。在前例中,第一个线程以A、B的顺序获取锁,而第二个线程获取锁的顺序是B、A。如果两个线程都用A、B的顺序,死锁的情况就可以避免,因为第二个线程在第一个线程完成并释放锁之前会一直被阻塞住。

就完全同步对象方式而言,要防止这种死锁出现是因为代码破坏了状态一致性规则。当有消息到达时,接受节点会在消息处理过程中调用另外一个对象——它发起这个调用时状态是不一致的。

接下来,我们会返回来解释前面抛出的那个问题:为什么Java中用来标识临界区的关键字是synchronized?这会引导我们转而讨论不可变性和关键字volatile

4.2.5 为什么是synchronized

最近几年并发编程变化最大的是硬件领域。在以前,程序员可能常年累月都碰不到需要支持多处理器核心(两个或最多三个)的系统。因此并发编程过去主要考虑如何分享CPU时间——线程们在单核上轮流上位,相互调换。

现如今,任何比手机大点儿的东西都是多核的,所以我们的认知模型也该换换了,应该把多个线程在同一物理时刻运行在不同核心(并且很可能会操作共享的数据)的情况也考虑在内。如图4-4所示。为了提高效率,同时运行的每个线程可能都会有它正在处理的数据的缓存复本。记住这幅图,让我们回到选择用什么关键字来表示被锁定的代码块或方法这个问题上。

图4-4 考虑并发和线程的新、老方式

我们在前面问过,代码清单4-1中被同步的是什么?答案是:被同步的是在不同线程中表示被锁定对象的内存块。也就是说,在synchronized代码块(或方法)执行完之后,对被锁定对象所做的任何修改全部都会在线程锁释放之前刷回到主内存中,如图4-5所示:

图4-5 不同线程对一个对象的修改通过主内存传播

另外,当进入一个同步的代码块,得到线程锁之后,对被锁定对象的任何修改都是从主内存中读出来的,所以在锁定区域代码开始执行之前,持有锁的线程就和锁定对象主内存中的视图同步了。

4.2.6 关键字volatile

Java在其混沌初开的时期(Java 1.0)就已经把volatile作为关键字了,它是一种简单的对象域同步处理办法,包括原始类型。一个volatile域需遵循如下规则:

  • 线程所见的值在使用之前总会从主内存中再读出来。
  • 线程所写的值总会在指令完成之前被刷回到主内存中。

可以把围绕该域的操作看成是一个小小的同步块。程序员可以借此编写简化的代码,但付出的代价是每次访问都要额外刷一次内存。还有一点要注意,volatile变量不会引入线程锁,所以使用volatile变量不可能发生死锁。

更加微妙的是,volatile变量是真正线程安全的,但只有写入时不依赖当前状态(读取的状态)的变量才应该声明为volatile变量。对于要关注当前状态的变量,只能借助线程锁保证其绝对安全性。

4.2.7 不可变性

不可变对象的应用是十分有价值的技术。这些对象或没有状态,或只有final域(因此只能在构造方法中赋值)。它们总是安全而又活跃的。它们的状态不能修改,所以不可能出现不一致的情况。

可这样对象初始化的所有值都必须传入构造方法。这会导致构造方法的参数很多,看起来又蠢又笨。因此很多程序员选择工厂方法FactoryMethod代替构造方法。工厂方法很简单,就是类中的一个静态方法,用来代替构造方法创建新对象。此时构造方法通常被声明为protectedprivate的,从而使工厂方法成为实例化对象的唯一办法。

但是还存在要将众多参数传入FactoryMethod的问题。有时候这不太方便,尤其是初始化对象所需的状态参数有多个不同来源时。

构建器模式可以解决这个问题。它由两部分组成:一个是实现了构建器泛型接口的内部静态类,另一个是构建不可变类实例的私有构造方法。

内部静态类是不可变类的构建器,开发人员只能通过它获取不可变类的新实例。比较常见的实现方式是让构建器类拥有与不可变类一模一样的域,但构建器的域是可修改的。

下面这段代码展示了如何建立不可变的微博更新模型(根据本章前面的例子所构建)。

代码清单4-3 不可变对象及构建器

/**构建器接口*/public interface ObjBuilder<T> {  T build;}public class Update {  //必须在构造方法中初始化final域  private final Author author;  private final String updateText;  private Update(Builder b_) {    author = b_.author;    updateText = b_.updateText;  }  /**构造器类必须是静态内部类*/  public static class Builder implements ObjBuilder<Update> {    private Author author;    private String updateText;    /**可用在调用链中返回Builder的方法*/    public Builder author(Author author_) {      author = author_;      return this;    }    public Builder updateText(String updateText_) {      updateText = updateText_;      return this;    }    public Update build {      return new Update(this);    }  }  //略去hashCode 和equals方法}  

有了这段代码,你就可以创建新的Update对象:

Update.Builder ub = new Update.Builder;Update u = ub.author(myAuthor).updateText(/"Hello/").build;  

这是一个得到广泛应用的通用模式。实际上,在代码清单4-1和4-2中我们已经使用了不可变对象的特性。

关于不可变对象的最后一点——关键字final仅对其直接指向的对象有用。如图4-6所示,对主对象的引用不能赋值为对象3,但在主对象内部,对1的引用可以改为指向对象2。也就是说final引用可以指向带有非final域的对象。

图4-6 值的不可变性与引用

不可变是非常强的技术,用处十分广泛。但有时候只用不可变对象开发效率不行,因为每次修改对象状态就需要构建一个新对象。所以可变对象很有必要保留。

我们马上就要开始讨论本章最重要的主题——java.util.concurrent中更加现代化、概念更简单的并发API。看看怎么用它们写代码。