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

《Java程序员修炼之道》4.1 并发理论简介

关灯直达底部

为了理解在Java中编写并发程序的方法,我们来聊聊相关理论。先讨论一下Java线程模型的基础知识。

之后,我们会讨论系统设计和实现中“设计原则”的影响以及其中最主要的两个原则:安全性和活跃度。我们还会提到其他一些原则,然后讨论这些原则经常相互冲突的原因,以及并发系统中为什么会有开销。

本节的最后我们会看一个多线程系统的例子,并向你证明java.util.concurrent是多么自然的编码方法。

4.1.1 解释Java线程模型

Java线程模型建立在两个基本概念之上:

  • 共享的、默认可见的可变状态
  • 抢占式线程调度

我们从几个侧面思考一下这两个概念。

  • 所有线程可以很容易地共享同一进程中的对象。

  • 能够引用这些对象的任何线程都可以修改这些对象。

  • 线程调度程序差不多任何时候都能在核心上调入或调出线程。

  • 必须能调出运行时的方法,否则无限循环的方法会一直占用CPU。

    然而这种不可预料的线程调度可能会导致方法“半途而废”,并出现状态不一致的对象。某一线程对数据做出修改时,会让其他线程无法见到本应可见的修改。为了缓解这些风险,Java提出了最后一点要求。

  • 为了保护脆弱的数据,对象可以被锁住。

Java基于线程和锁的并发非常底层,并且一般都比较难用。为了解决这个问题,Java 5引入了一组并发类库java.util.concurrent。这个包中提供了一套编写并发代码的工具,很多程序员都觉得它要比传统的块结构并发原语易用。

经验教训

Java是第一个内置多线程编码支持的主流编程语言。这在当时可以说是一个巨大的进步,但15年之后的今天,我们对于如何编写并发代码已经是了若指掌了。

事实证明,Java最初的一些设计决策给大多数程序员编写多线程代码带来了很多困难。这的确很糟糕,因为硬件一直朝着多核处理器方向发展,而唯一能利用好这些核心的就是并发代码。本章会讨论一些在编写并发代码时所遇到的困难。现代处理器对并发编程有着合理的需求,我们在第6章讨论性能时还会涉及其中的一些细节。

随着开发人员编写并发代码的经验越来越丰富,他们发现自己所关注的一些重要系统问题一再出现。我们把这些关注点称为“设计原则”——存在于并发OO系统实际设计中的指导性原则(并经常相互冲突)。

在后面几节,我们会花点时间了解一下其中最重要的几个原则。

4.1.2 设计理念

Doug Lea在创造他那里程碑式的作品java.util.concurrent时列出了下面这些最重要的设计原则:

  • 安全性(也叫做并发类型安全性)
  • 活跃度
  • 性能
  • 重用性

下面我们来逐一解读。

1.安全性与并发类型安全性

安全性是指不管同时发生多少操作都能确保对象保持自相一致。如果一个对象系统具备这一特性,那它就是并发类型安全的。

可能你从它的名字就猜出来了,并发可以看做是常规对象建模和类型安全概念的一种延伸。在非并发代码中,要确保不管调用了对象中的什么公开方法,对象最后总是处于一个定义良好并且一致的状态下。通常用来达成这一点的做法是保证对象所有状态都私有,并且开放出来的公开API方法只能以自相一致的方式修改对象状态。

并发类型安全的概念跟对象类型安全一样,但它用在更复杂的环境下。在这样的环境中,其他线程在不同CPU内核上同时操作同一对象。

保证安全

保证安全的策略之一是在处于非一致状态时绝不能从非私有方法中返回,也绝不能调用任何非私有方法,而且也绝不能调用其他任何对象中的方法。如果把这个策略跟某种对非一致对象的保护办法(比如同步锁或临界区)结合起来,就可以保证系统是安全的。

2.活跃度

在一个活跃的系统中,所有做出尝试的活动最终或者取得进展,或者失败。

这个定义中的关键词是“最终”——运行中的瞬时故障(尽管不理想,但单独来看这不是问题)和永久故障是不同的。下面这几种底层问题可能会导致系统出现瞬时故障:

  • 处于锁定状态或者在等待得到线程锁
  • 等待输入(比如网络I/O)
  • 资源的暂时故障
  • CPU没有足够的空闲时间运行该线程

导致系统出现永久故障的原因较多,其中最常见的是:

  • 死锁
  • 不可恢复的资源问题(比如NFS不可访问)
  • 信号丢失

尽管你对它们可能都已经很熟悉了,但本章后续还是会讨论一下锁定和其他几个问题。

3.性能

系统性能可以通过几种不同的方式量化。我们会在第6章讨论性能分析和优化技术,并且会介绍一些你应该了解的指标。现在,你可以把性能看成是测量系统用给定资源能做多少工作的办法。

4.可重用性

可重用性是第四个设计原则,其他它原则中并没涉及这一点。尽管有时不容易实现,但我们还是非常希望能设计出易于重用的并发系统。用可重用工具集(比如java.util.concurrent),并把不可重用的应用代码构建在工具集之上是一种可行的办法。

4.1.3 这些原则如何以及为何会相互冲突

设计原则经常相互对立,这种紧张关系使得并发系统的设计很难达到优秀的水准。

  • 安全性与活跃度相互对立——安全性是为了确保坏事不会发生,而活跃度要求见到进展。
  • 可重用的系统倾向于对外开放其内核,可这会引发安全问题。
  • 一个安全但编写方式幼稚的系统性能通常都不会太好,因为里面一般会用大量的锁来保证安全性。

最终应该尽量让代码达到一种平衡的状态,使其能够灵活地适用于各种问题,却又能保证安全性,同时活跃度和性能也可以达到一定水平。这种境界相当高,但你很幸运,我们马上教你一些实战技巧。下面是几个最常见的粗浅办法。

  • 尽可能限制子系统之间的通信。隐藏数据对安全性非常有帮助。

  • 尽可能保证子系统内部结构的确定性。比如说,即便子系统会以并发的、非确定性的方式进行交互,子系统内部的设计也应该参照线程和对象的静态知识。

  • 采用客户端应用必须遵守的策略方针。这个技巧虽然强大,却依赖于用户应用程序的合作程度,并且如果某个糟糕的应用不遵守规则,便很难发现问题所在。

  • 在文档中记录所要求的行为。这是最逊的办法,但如果代码要部署在非常通用的环境中,就必须采用这个办法。

开发人员应该了解所有可能的安全机制,而且尽可能采用最强的技术,但同时你也应该知道,在某些情况下只能采用那些比较逊的办法。

4.1.4 系统开销之源

并发系统中的系统开销是与生俱来的,这些开销来自:

  • 锁与监测
  • 环境切换的次数
  • 线程的个数
  • 调度
  • 内存的局部性1
  • 算法设计

1 局部性指的是程序行为的一种规律:在程序运行中的短时间内,程序访问数据位置的集合限于局部范围。局部性有两种基本形式:时间局部性与空间局部性。时间局部性指的是反复访问同一个位置的数据;空间局部性指的是反复访问相邻的数据。——译者注

你应该以此为基础在大脑中列一个检查列表。在编写并发代码时,应该确保自己对列表中的每一项都认真考虑过了,然后再来“搞定”代码。

算法设计

这是一个能让开发人员脱颖而出的领域。无论用什么语言,学习算法设计都能让你成为更好的程序员。在这里我们向你推荐由Thomas H. Corman等人编著的《算法导论》(MIT,2009)和Steven Skiena写的《算法设计手册》(Springer-Verlag,2008)。无论你是想了解单线程算法还是想学习并发算法,它们都是值得阅读的好书。

本章会提到许多系统开销的源头(还有第6章讨论性能的部分)。

4.1.5 一个事务处理的例子

本节前面的内容都太理论化了,所以我们补充一个并发程序设计的例子来实证一下。在这个例子中你将看到如何用java.util.concurrent中的高层类完成这个任务。

假设有一个基本事务处理系统。构建这种程序有个简单的标准办法,就是先将业务流程的不同环节对应到应用程序的不同阶段,然后用不同的线程池表示不同的应用阶段,每个线程池逐一接受工作项,在对每个工作项进行一系列的处理后,交给下一个线程池。通常来说,好的设计会让每个线程池所做的处理集中在一个特定功能区内。如图4-1所示。

图4-1 多线程应用程序示例

如果你设计成这样的程序,就可以提高吞吐量,因为可以设计成同时处理几个工作项。比如在检查一个工作项的信用情况时,可以检查另一个工作项的库存。根据应用程序的处理细节不同,甚至可以同时检查多个订单的库存。

这种设计非常适合用java.util.concurrent包中的类来实现。这个包里有用于执行任务的线程池(Executors类中有一套工厂方法可以创建它们)和在不同线程池之间传递工作的队列,还有并发数据结构(可以用来构建共享缓存,或用于其他用途)和很多其他底层工具。

你可能会问,在Java 5之前,还没有这些类时是怎么办的?一般情况下,开发小组会自己编写并发编程类库,最终会构建出跟java.util.concurrent类似的组件。但这种定制组件大多存在设计缺陷,还会有难以捉摸的并发bug。如果没有java.util.concurrent,开发人员就得重复实现其中的大部分组件(可能会有很多bug,测试也不充分)。

请记住这个例子,我们要转入下一主题——温习一下Java的“传统”并发,并深入了解用它编程困难的原因。