《Java并发编程实战》【第一部分 基础知识】

文章目录

第1章 简介

   编写正确的程序很难,而编写正确的并发程序则难上加难。与串行程序相比,在并发程序中存在更多容易出错的地方。那么,为什么还要编写并发程序?线程是Java语言不可或缺的重要功能,它们能使复杂的异步代码变得更简单,从而极大的简化了复杂系统的开发。此外,要想充分发挥多处理器系统的强大计算能力,最简单的方式就是使用线程。随着处理器数量的持续增长,如何高效地使用并发正变得越来越重要。

1.1 并发简史

   在早期的计算机中不包含操作系统,它们从头到尾只执行一个程序,并且这个程序能访问计算机中的所有资源。在这种裸机环境中,不仅很难编写和运行程序,而且每次只能运行一个程序,这对于昂贵并且稀有的计算机资源来说也是一种浪费。
   操作系统的出现使得计算机每次能运行多个程序,并且不同的程序都在单独的进程中运行:操作系统为各个独立执行的进程分配各种资源,包括内存,文件句柄以及安全证书等。如果需要的话,在不同的进程之间可以通过一些粗粒度的通信机制来交换数据,包括:套接字、信号处理器、共享内存、信号量以及文件等。
   之所以在计算机中加入操作系统来实现多个程序的同时执行,主要是基于以下原因:
   资源利用率。在某些情况下,程序必须等待某个外部操作执行完成,例如输入操作或输出操作等,而在等待时程序无法执行其他任何工作。因此,如果在等待的同时可以运行另一个程序,那么无疑将提高资源的利用率。
   公平性。不同的用户和程序对于计算机上的资源有着同等的使用权。一种高效的运行方式是通过粗粒度的时间分片(Time Slicing)使这些用户和程序能共享计算机资源,而不是由一个程序从头运行到尾,然后再启动下一个程序。
   便利性。通常来说,在计算多个任务时,应该编写多个程序,每个程序执行一个任务并在必要时相互通信,这比只编写一个程序来计算所有任务更容易实现。
   在早期的分时系统中,每个进程相当于一台虚拟的冯·诺依曼计算机,它拥有存储指令和数据的内存空间,根据机器语言的语义以串行方式执行指令,并通过一组I/O指令与外部设备通信。对每条被执行的指令,都有相应的“下一条指令”,程序中的控制流是按照指令集的规则来确定的。当前,几乎所有的主流编程语言都遵循这种串行编程模型,并且在这些语言的规范中也都清晰地定义了在某个动作完成之后需要执行的“下一个动作”。
   串行编程模型的优势在于其直观性和简单性,因为它模仿了人类的工作方式:每次只做一件事情,做完之后再做另一件。例如,首先起床,穿上睡衣,然后下楼,喝早茶。在编程语言中,这些现实世界中的动作可以被进一步抽象为一组粒度更细的动作。例如,喝早茶的动作可以被进一步细化为:打开橱柜,挑选喜欢的茶叶,将一些茶叶倒入杯中,看看茶壶中是否有足够的水,如果没有的话加些水,将茶壶放到火炉上,点燃火炉,然后等水烧开等等。在最后一步等水烧开的过程中包含了一定程度的异步性。当正在烧水时,你可以干等着,也可以做些其他事情,例如开始烤面包(这是另一个异步任务)或者看报纸,同时留意茶壶水是否烧开。茶壶和面包机的生产商都很清楚:用户通常会采用异步方式来使用他们的产品,因此当这些机器完成任务时都会发出声音提示。但凡做事高效的人,总能在串行性与异步性之间找到合理的平衡,对于程序来说同样如此。
   这些促使进程出现的因素(资源利用率、公平性以及便利性等)同样也促使着线程的出现。线程允许在同一个进程中同时存在多个程序控制流。线程会共享进程范围内的资源,例如内存句柄和文件句柄,但每个线程都有各自的程序计数器(Program Counter)、栈以及局部变量等。线程还提供了一种直观的分解模式来充分利用多处理器系统中的硬件并行性,而在同一个程序中的多个线程也可以被同时调度到多个CPU上运行。
   线程也被称为轻量级进程。在大多数现代操作系统中,都是以线程为基本的调度单位,而不是进程。如果没有明确的协同机制,那么线程将彼此独立执行。由于同一个进程中的所有线程都将共享进程的内存地址空间,因此这些线程都能访问相同的变量并在同一个堆上分配对象,这就需要实现一种比在进程间共享数据粒度更细的数据共享机制。如果没有明确的同步机制来协同对共享数据的访问,那么当一个线程正在使用某个变量时,另一个线程可能同时访问这个变量,这将造成不可预测的结果。

1.2 线程的优势

   如果使用得当,线程可以有效地降低程序的开发和维护等成本,同时提升复杂应用程序的性能。线程能够将大部分的异步工作流转换成串行工作流,因此能更好地模拟人类的工作方式和交互方式。此外,线程还可以降低代码的复杂度,使代码更容易编写、阅读和维护。
   在GUI(Graphic User Interface,图形用户界面)应用程序中,线程可以提高用户界面的响应灵敏度,而在服务器应用程序中,可以提升资源利用率以及系统吞吐率。线程还可以简化JVM的实现,垃圾收集器通常在一个或多个专门的线程中运行。在许多重要的Java应用程序中,都在一定程度上用到了线程。

1.2.1 发挥多处理器的强大能力

   过去,多处理器系统是非常昂贵和稀少的,通常只有在大型数据中心和科学计算设备中才会使用多处理器系统。但现在,多处理器系统正日益普及,并且价格也在不断地降低,即使在低端服务器和中端桌面系统中,通常也会采用多个处理器。这种趋势还将进一步加快,因为通过提高时钟频率来提升性能已变得越来越困难,处理器生产厂商都开始转而在单个芯片上放置多个处理器核。所有的主流芯片制造商都开始了这种转变,而我们也已经看到了在一些机器上出现了更多的处理器。
   由于基本的调度单位是线程,因此如果在程序中只有一个线程,那么最多同时只能在一个处理器上运行。在双处理器系统上,单线程的程序只能使用一半的CPU资源,而在拥有100个处理器的系统上,将有99%的资源无法使用。另一方面,多线程程序可以同时在多个处理器上执行。如果设计正确,多线程程序可以通过提高处理器资源的利用率来提升系统吞吐率。
   使用多个线程还有助于在单处理器系统上获得更高的吞吐率。如果程序是单线程的,那么当程序等待某个同步I/O操作完成时,处理器将处于空闲状态。而在多线程程序中,如果一个线程在等待I/O操作完成,另一个线程可以继续运行,使程序能够在I/O阻塞期间继续运行。(这就好比在等待水烧开的同时看报纸,而不是等待水烧开之后再开始看报纸)。

1.2.2 建模的简单性

   通常,当只需要执行一种类型的任务(例如修改12个错误)时,在时间管理方面比执行多种类型的任务(例如,修复错误、面试系统管理员的接任者、完成团队的绩效考核,以及为下个星期的报告做幻灯片)要简单。当只有一种类型的任务需要完成时,只需埋头工作,直到完成所有的任务(或者你已经精疲力尽),你不需要花任何精力来琢磨下一步该做什么。而另一方面,如果需要完成多种类型的任务,那么需要管理不同任务之间的优先级和执行时间,并在任务之间进行切换,这将带来额外的开销。
   对于软件来说同样如此:如果在程序中只包含一种类型的任务,那么比包含多种不同类型任务的程序要更易于编写,错误更少,也更容易测试。如果为模型中每种类型的任务都分配一个专门的线程,那么可以形成一种串行执行的假象,并将程序的执行逻辑与调度机制的细节,交替执行的操作,异步I/O以及资源等待等问题分离开来。通过使用线程,可以将复杂并且异步的工作流进一步分解为一组简单并且同步的工作流,每个工作流在一个单独的线程中运行,并在特定的同步位置进行交互。
   我们可以通过一些现有的框架来实现上述目标,例如Servlet和RMI(Remote Method Invocation,远程方法调用)。框架负责解决一些细节问题,例如请求管理、线程创建、负载平衡,并在正确的时刻将请求分发给正确的应用程序组件。编写Servlet的开发人员不需要了解有多少请求在同一时刻要被处理,也不需要了解套接字的输入流或输出流是否被阻塞。当调用Servlet的service方法来响应Web请求时,可以以同步方式来处理这个请求,就好像它是一个单线程程序。这种方式可以简化组件的开发,并缩短掌握这种框架的学习时间。

1.2.3 异步事件的简化处理

   服务器应用程序在接受来自多个远程客户端的套接字连接请求时,如果为每个连接都分配其各自的线程并且使用同步I/O,那么就会降低这类程序的开发难度。
   如果某个应用程序对套接字执行读操作而此时还没有数据到来,那么这个读操作将一直阻塞,直到有数据到达。在单线程应用程序中,这不仅意味着在处理请求的过程中将停顿,而且还意味着在这个线程被阻塞期间,对所有请求的处理都将停顿。为了避免这个问题,单线程服务器应用程序必须使用非阻塞I/O,这种I/O的复杂性要远远高于同步I/O,并且很容易出错。然而,如果每个请求都拥有自己的处理线程,那么在处理某个请求时发生的阻塞将不会影响其他请求的处理。
   早期的操作系统通常会将进程中可创建的线程数量限制在一个较低的阈值内,大约在数百个(甚至更少)左右。因此,操作系统提供了一些高效的方法来实现多路I/O,例如Unix的select和poll等系统调用,要调用这些方法,Java类库需要获得一组实现非阻塞I/O的包(java.nio)。然而,在现代操作系统中,线程数量已得到极大的提升,这使得在某些平台上,即使有更多的客户端,为每个客户端分配一个线程也是可行的。(NPTL线程软件包是专门设计用于支持数十万个线程的,在大多数Linux发布版本中都包含了这个软件包。非阻塞I/O有其自身的优势,但如果操作系统能更好地支持线程,那么需要使用非阻塞I/O的情况将变得更少。)

1.2.4 响应更灵敏的用户界面

   传统的GUI应用程序通常都是单线程的,从而在代码的各个位置都需要调用poll方法来获得输入事件(这种方式将给代码带来极大的混乱),或者通过一个“主事件循环(Main Event Loop)”来间接地执行应用程序的所有代码。如果在主事件循环中调用的代码需要很长时间才能执行完成,那么用户界面就会“冻结”,直到代码执行完成。这是因为只有当执行控制权返回到主事件循环后,才能处理后续的用户界面事件。
   在现代的GUI框架中,例如AWT和Swing等工具,都采用一个事件分发线程(Event Dispatch Thread, EDT)来替代主事件循环。当某个用户界面事件发生时(例如按下一个按钮),在事件线程中将调用应用程序的事件处理器。由于大多数GUI框架都是单线程子系统,因此到目前为止仍然存在主事件循环,但它现在处于GUI工具的控制下并在其自己的线程中运行,而不是在应用程序的控制下。
   如果在事件线程中执行的任务都是短暂的,那么界面的响应灵敏度就较高,因为事件线程能够很快地处理用户的动作。然而,如果事件线程中的任务需要很长的执行时间,例如对一个大型文档进行拼写检查,或者从网络上获取一个资源,那界面的响应灵敏度就较高,因为事件线程能够很快地处理用户的动作。然而,如果事件线程中的任务需要很长的执行时间,例如对一个大型文档进行拼写检查,或者从网络上获取一个资源,那界面的响应灵敏度就会降低。如果用户在执行这类任务时触发了某个动作,那么必须等待很长时间才能获得响应,因为事件线程要先执行完该任务。更糟糕的是,不仅界面失去响应,而且即使在界面上包含了“取消”按钮,也无法取消这个长时间执行的任务,因为事件线程只有在执行完该任务后才能响应“取消”按钮的点击事件。然而,如果将这个长时间运行的任务放在一个单独的线程中运行,那么事件线程就能及时地处理界面事件,从而使用户界面具有更高的灵敏度。

1.3 线程带来的风险

   Java对线程的支持其实是一把双刃剑。虽然Java提供了相应的语言和库,以及一种明确的跨平台内存模型(该内存模型实现了在Java中开发“编写一次,随处运行”的并发应用程序),这些工具简化了并发应用程序的开发,但同时也提高了对开发人员的技术要求,因为在更多的程序中会使用线程。当线程还是一项鲜为人知的技术时,并发性是一个“高深的”主题,但现在,主流开发人员都必须了解线程方面的内容。

1.3.1 安全性问题

   线程安全性可能是非常复杂的,在没有充足同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至会产生奇怪的结果。在程序清单1-1的UnsafeSequence类中将产生一个整数值序列,该序列中的每个值都是唯一的。在这个类中简要地说明了多个线程之间的交替操作将如何导致不可预料的结果。在单线程环境中,这个类能正确地工作,但在多线程环境中则不能。
   程序清单1-1 非线程安全的数值序列生成器

@NotThreadSafe
public class UnsafeSequence {
   

    private int value;

    /** 返回一个唯一的整数 */
    public int getNext() {
   
        return value++;
    }
}

   UnsafeSequence的问题在于,如果执行时机不对,那么两个线程在调用getNext时会得到相同的值。在图1-1中给出了这种错误情况。虽然递增运算someVariable++看上去是单个操作,但事实上它包含三个独立的操作:读取value,将value加一,并将结算结果写入value。由于运行时可能将多个线程之间的操作交替执行,因此这两个线程可能同时执行读操作,从而使它们得到相同的值,并将这个值加1。结果就是,在不同线程的调用中返回了相同的数值。
在这里插入图片描述
   在图1-1中给出了不同线程之间的一种交替执行情况。在图中,执行时序按照从左到右的顺序递增,每行表示一个线程的动作。这些交替执行示意图给出的是最糟糕的执行情况(事实上,在第3章将看到,由于存在指令重排序的问题,因此实际情况可能会更糟糕),目的是为了说明:如果错误地假设程序中的操作将按照某种特定顺序来执行,那么会存在各种可能的危险。
   在UnsafeSequence中使用了一种非标准的标注:@NotThreadSafe。这是在本书中使用的几个自定义标注之一,用于说明类和类成员的并发属性。(其他标注包括@ThreadSafe和@Immutable,请参见附录A的详细信息)。线程安全性标注在许多方面都是有用的。如果用@ThreadSafe来标注某个类,那么开发人员可以放心地在多线程环境下使用这个类,维护人员也会发现它能保证线程安全性,而软件分析工具还可以识别出潜在的编码错误。
   在UnsafeSequence类中说明的是一种常见的并发安全问题,称为竞态条件(Race Condition)。在多线程环境下,getValue是否会返回唯一的值,要取决于运行时对线程中操作的交替执行方式,这并不是我们希望看到的情况。
   由于多个线程要共享相同的内存地址空间,并且是并发运行,因此它们可能会访问或修改线程正在使用的变量。当然,这是一种极大的便利,因为这种方式比其他线程间通信机制更容易实现数据共享。但它也同样带来了风险:线程会由于无法预料的数据变化而发生错误。当多个线程同时访问和修改相同的变量时,将会在串行编程模型中引入非串行因素,而这种非串行性是很难分析的。要使多线程程序的行为可以预测,必须对共享变量的访问操作进行协同,这样才不会在线程之间发生彼此干扰。幸运的是,Java提供了各种同步机制来协同这种访问。
   通过将getNext修改为一个同步方法,可以修复UnsafeSequence中的错误,如程序清单1-2中的Sequence(在2.4节中介绍了@GuardedBy,这个标注说明了Sequence的同步策略),这个类可以防止图1-1中错误的交替执行情况。(第2章和第3章将进一步分析这个类的工作原理。)   程序清单1-2 线程安全的数值序列生成器

@ThreadSafe
public class Sequence {
   

    @GuardedBy("this") private int value;
    
    public synchronized int getNext() {
   
        return value++;
    }
}

   如果没有同步,那么无论是编译器、硬件还是运行时,都可以随意安排操作的执行时间和顺序,例如对寄存器或者处理器中的变量进行缓存,而这些被缓存的变量对于其他线程来说是暂时(甚至永久)不可见的。虽然这些技术有助于实现更优的性能,并且通常也是值得采用的方法,但它们也为开发人员带来了负担,因为开发人员必须找出这些数据在哪些位置被多个线程共享,只有这样才能使这些优化措施不破坏线程安全性。(第16章将详细介绍JVM实现了哪些顺序保证,以及同步将如何影响这些保证,但如果遵循第2章和第3章给出的指导原则,那么就可以绕开这些底层细节问题。)

1.3.2 活跃性问题

   在开发并发代码时,一定要注意线程安全性是不可破坏的。安全性不仅对于多线程序很重要,对于单线程程序同样重要。此外,线程还会导致一些在单线程程序中不会出现的问题,例如活跃性问题。
   安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注于另一个目标,即“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,活跃性问题的形式之一就是无意中造成的无限循环,从而使循环之后的代码无法得到执行。线程将带来其他一些活跃性问题。例如,如果线程A在等待线程B释放其持有的资源,而线程B永远都不释放该资源,那么A就会永久地等待下去。第10章将介绍各种形式的活跃性问题,以及如何避免这些问题,包括死锁(10.1节),饥饿(10.3.1节),以及活锁(10.3.3节)。与大多数并发性错误一样,导致活跃性问题的错误同样是难以分析的,因为它们依赖于不同线程的事件发生时序,因此在开发或者测试中并不总是能够重现。

1.3.3 性能问题

   与活跃性问题密切相关的是性能问题。活跃性意味着某件正确的事情最终会发生,但却不够好,因为我们通常希望正确的事情尽快发生。性能问题包括多个方面,例如服务时间过长,响应不灵敏,吞吐率过低,资源消耗过高,或者可伸缩性较低等。与安全性和活跃性一样,在多线程程序中不仅存在与单线程程序相同的性能问题,而且还存在由于使用线程而引入的其他性能问题。
   在设计良好的并发应用程序中,线程能提升程序的性能,但无论如何,线程总会带来某种程度的运行时开销。在多线程程序中,当线程调度器临时挂起活跃线程并转而运行另一个线程时,就会频繁地出现上下文切换操作(Context Switch),这种操作将带来极大的开销:保存和恢复执行上下文,丢失局部性,并且CPU时间将更多地花在线程调度而不是线程运行上。当线程共享数据时,必须使用同步机制,而这些机制往往会抑制某些编译器优化,使内存缓存区中的数据无效,以及增加共享内存总线的同步流量。所有这些因素都将带来额外的性能开销,第11章将详细介绍如何分析和减少这些开销。

1.4 线程无处不在

   即使在程序中没有显式地创建线程,但在框架中仍可能会创建线程,因此在这些线程中调用的代码同样必须是线程安全的。这将给开发人员在设计和实现上带来沉重负担,因为开发线程安全的类比开发非线程安全的类要更加谨慎和细致。
   每个Java应用程序都会使用线程。当JVM启动时,它将为JVM的内部任务(例如,垃圾收集、终结操作等)创建后台线程,并创建一个主线程来运行main方法。AWT(Abstract Window Toolkit,抽象窗口工具库)和Swing的用户界面框架将创建线程来管理用户界面事件。Timer将创建线程来执行延迟任务。一些组件框架,例如Servlet和RMI,都会创建线程池并调用这些线程中的方法。
   如果要使用这些功能,那么就必须熟悉并发性和线程安全性,因为这些框架将创建线程并且在这些线程中调用程序中的代码。虽然将并发性认为是一种“可选的”或者“高级的”语言功能固然理想,但现实情况是,几乎所有的Java应用程序都是多线程的,因此在使用这些框架时仍然需要对应用程序状态的访问进行协同。
   当某个框架在应用程序中引入并发性时,通常不可能将并发性仅局限于框架代码,因为框架本身会回调(Callback)应用程序的代码,而这些代码将访问应用程序的状态。同样,对线程安全性的需求也不能局限于被调用的代码,而是要延伸到需要访问这些代码所访问的程序状态的所有代码路径。因此,对线程安全性的需求将在程序中蔓延开来。
   框架通过在框架线程中调用应用程序代码将并发性引入到程序中。在代码中将不可避免地访问应用程序状态,因此所有访问这些状态的代码路径都必须是线程安全的。
   下面给出的模块都将在应用程序之外的线程中调用应用程序的代码。尽管线程安全性需求可能源自这些模块,但却不会止步于它们,而是会延伸到整个应用程序。
   Timer。Timer类的作用是使任务在稍后的时刻运行,或者运行一次,或者周期性地运行。引入Timer可能会使串行程序变得复杂,因为TimerTask将在Timer管理的线程中执行,而不是由应用程序来管理。如果某个TimerTask访问了应用程序中其他线程访问的数据,那么不仅TimerTask需要以线程安全的方式来访问数据,其他类也必须采用线程安全的方式来访问该数据。通常,要实现这个目标,最简单的方式是确保TimerTask访问的对象本身是线程安全的,从而就能把线程安全性封装在共享对象内部。
   Servlet和JavaServer Page(JSP)。Servlet框架用于部署网页应用程序以及分发来自HTTP客户端的请求。到达服务器的请求可能会通过一个过滤器链被分发到正确的Servlet或JSP。每个Servlet都表示一个程序逻辑组件,在高吞吐率的网站中,多个客户端可能同时请求同一个Servlet的服务。在Servlet规范中,Servlet同样需要满足被多个线程同时调用,换句话说,Servlet需要是线程安全的。
   即使你可以确保每次只有一个线程调用某个Servlet,但在构建网页应用程序时仍然必须注意线程安全性。Servlet通常会访问与其他Servlet共享的信息,例如应用程序中的对象(这些对象保存在ServletContext中)或者会话中的对象(这些对象保存在每个客户端的HttpSession中)。当一个Servlet访问在多个Servlet或者请求中共享的对象时,必须正确地协同对这些对象的访问,因为多个请求可能在不同的线程中同时访问这些对象。Servlet和JSP,以及在ServletContext和HttpSession等容器中保存的Servlet过滤器和对象等,都必须是线程安全的。
   远程方法调用(Remote Method Invocation, RMI)。RMI使代码能够调用在其他JVM中运行的对象。当通过RMI调用某个远程方法时,传递给方法的参数必须被打包(也称为列集[Marshaled])到一个字节流中,通过网络传输给远程JVM,然后由远程JVM拆包(或者称为散集[Unmarshaled])并传递给远程方法。
   当RMI代码调用远程对象时,这个调用将在哪个线程中执行?你并不知道,但肯定不会在你创建的线程中,而是将在一个由RMI管理的线程中调用对象。RMI会创建多少个线程?同一个远程对象上的同一个远程方法会不会在多个RMI线程中被同时调用(答案是会的。但在javadoc中并没有清楚的指出这一点,你需要阅读RMI规范。)远程对象必须注意两个线程安全性问题:正确地协同在多个对象中共享的状态,以及对远程对象本身状态的访问(由于同一个对象可能会在多个线程中被同时访问)。与Servlet相同,RMI对象应该做好被多个线程同时调用的准备,并且必须确保它们自身的线程安全性。
   Swing和AWT。GUI应用程序的一个固有属性是异步性。用户可以在任意时刻选择一个菜单项或者按下一个按钮,应用程序就会及时响应,即使应用程序当时正在执行其他的任务。Swing和AWT很好地解决了这个问题,它们创建了一个单独的线程来处理用户触发的事件,并对呈现给用户的图形界面进行更新。
   Swing的一些组件并不是线程安全的,例如JTable。相反,Swing程序通过将所有对GUI组件的访问局限在事件线程中以实现线程安全性。如果某个应用程序希望在事件线程之外控制GUI,那么必须将控制GUI的代码放在事件线程中运行。
   当用户触发某个UI动作时,在事件线程中就会有一个事件处理器被调用以执行用户请求的操作。如果事件处理器需要访问由其他线程同时访问的应用程序状态(例如编辑某个文档),那么这个事件处理器,以及访问这个状态的所有其他代码,都必须采用一种线程安全的方式来访问该状态。

第一部分 基础知识

第2章 线程安全性

   你或许会感到奇怪,线程或者锁在并发编程中的作用,类似于铆钉和工字梁在土木工程中的作用。要建筑一座坚固的桥梁,必须正确地使用大量的铆钉和工字梁。同理,在构建稳健的并发程序时,必须正确地使用线程和锁。但这些终归只是一些机制。要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态的访问。
   从非正式的意义上来说,对象的状态是指存储在状态变量(例如实例或静态域)中的数据。对象的状态可能包括其他依赖对象的域。例如,某个HashMap的状态不仅存储在HashMap对象本身,还存储在许多Map.Entry对象中。在对象的状态中包含了任何可能影响其外部可见行为的数据。
   “共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期内可以发生变化。我们将像讨论代码那样来讨论线程安全性,但更侧重于如何防止在数据上发生不受控的并发访问。
   一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。
   当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但“同步”这个术语还包括volatile类型的变量,显式锁(Explicit Lock)以及原子变量。
   在上述规则中并不存在一些想象中的“例外”情况。即使在某个程序中省略了必要同步机制并且看上去似乎能正确执行,而且通过了测试并在随后几年时间里都能正确地执行,但程序仍可能在某个时刻发生错误。
   如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:
1.不在线程之间共享该变量
2.将状态变量改为不可变的变量
3.在访问状态变量时使用同步
   如果在设计类的时候没有考虑并发访问的情况,那么在采用上述方法时可能需要对设计进行重大修改,因此要修复这个问题可谓是知易行难。如果从一开始就设计一个线程安全的类,那么比在以后再将这个类修改为线程安全的类要容易得多。
   在一些大型程序中,要找出多个线程在哪些位置上将访问同一个变量是非常复杂的。幸运的是,面向对象这种技术不仅有助于编写出结构优雅、可维护性高的类,还有助于编写出线程安全的类。访问某个变量的代码越少,就越容易确保对变量的所有访问都实现正确同步,同时也更容易找出变量在哪些条件下被访问。Java语言并没有强制要求将状态都封装在类中,开发人员完全可以将状态保存在某个公开的域(甚至公开的静态域)中,或者提供一个对内部对象的公开引用。然而,程序状态的封装性越好,就越容易实现程序的线程安全性,并且代码的维护人员也越容易保持这种方式。
   当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明晰的不变性规范都能起到一定的帮助作用。
   在某些情况中,良好的面向对象设计技术与实际情况的需求并不一致。在这些情况中,可能需要牺牲一些良好的设计原则,以换取性能或者对遗留代码的向后兼容。有时候,面向对象中的抽象和封装会降低程序的性能(尽管很少有开发人员相信),但在编写并发应用程序时,一种正确的编程方法就是:首先使代码正确运行,然后再提高代码的速度。即便如此,最好也只是当性能测试结果和应用需求告诉你必须提高性能,以及测量结果表明这种优化在实际环境中确实能带来性能提升时,才进行优化。(在编写并发程序时,应当始终遵循这个原则。由于并发错误是非常难以重现和调试的,因此如果只是在某段很少执行的代码路径上获得了性能提升,那么很可能被程序运行时存在的失败风险而抵消。)
   如果你必须打破封装,那么也并非不可以,你仍然可以实现程序的线程安全性,只是更困难,而且,程序的线程安全性将更加脆弱,不仅增加了开发的成本和风险,而且也增加了维护的成本和风险。第4章详细介绍了在哪些条件下可以安全地放宽状态变量的封装性。
   到目前为止,我们使用了“线程安全类”和“线程安全程序”这两个术语,二者的含义基本相同。线程安全的程序是否完全由线程安全类构成?答案是否定的,完全由线程安全类构成的程序并不一定就是线程安全的,而在线程安全类中也可以包含非线程安全的类。第4章还将进一步介绍如何对线程安全类进行组合的相关问题。在任何情况中,只有当类中仅包含自己的状态时,线程安全类才是有意义的。线程安全性是一个在代码上使用的术语,但它只是与状态相关的,因此只能应用于封装其状态的整个代码,这可能是一个对象,也可能是整个程序。

2.1 什么是线程安全性

   要对线程安全性给出一个确切的定义是非常复杂的。定义越正式,就越复杂,不仅很难提供有实际意义的指导建议,而且也很难从直观上去理解。因此,下面给出了一些非正式的描述,看上去令人困惑。在互联网上可以搜索到许多“定义”,例如:
   ……可以在多个线程中调用,并且在线程之间不会出现错误的交互。   ……可以同时被多个线程调用,而调用者无须执行额外的动作。
   看看这些定义,难怪我们会对线程安全性感到困惑。它们听起来非常像“如果某个类可以在多个线程中安全地使用,那么它就是一个线程安全的类”。对于这种说法,虽然没有太多的争议,但同样也不会带来太多的帮助。我们如何区分线程安全的类以及非线程安全的类?进一步说,“安全”的含义是什么?
   在线程安全性的定义中,最核心的概念就是正确性。如果对线程安全性的定义是模糊的,那么就是因为缺乏对正确性的清晰定义。
   正确性的含义是,某个类的行为与其规范完全一致。在良好的规范中通常会定义各种不变性条件(Invariant)来约束对象的状态,以及定义各种后验条件(Postcondition)来描述对象操作的结果。由于我们通常不会为类编写详细的规范,那么如何知道这些类是否正确呢?我们无法知道,但这并不妨碍我们在确信“类的代码能工作”后使用它们。这种“代码可信性”非常接近于我们对正确性的理解,因此我们可以将单线程的正确性近似定义为“所见即所知(we know it when wesee it)”。在对“正确性”给出了一个较为清晰的定义后,就可以定义线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。
   当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
   由于单线程程序也可以看成是一个多线程程序,如果某个类在单线程环境(如果你觉得这里对“正确性” 的定义有些模糊,那么可以将线程安全类认为是一个在并发环境和单线程环境中都不会被破坏的类。)中都不是正确的,那么它肯定不会是线程安全的。如果正确地实现了某个对象,那么在任何操作中(包括调用对象的公有方法或者对其公有域进行读/写操作)都不会违背不变性条件或后验条件。
   在线程安全类的对象实例上执行的任何串行或并行操作都不会使对象处于无效状态。在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。

示例 一个无状态的Servlet

   我们在第1章列出了一组框架,其中每个框架都能创建多个线程并在这些线程中调用你编写的代码,因此你需要保证编写的代码是线程安全的。通常,线程安全性的需求并非来源于对线程的直接使用,而是使用像Servlet这样的框架。我们来看一个简单的示例——一个基于Servlet的因数分解服务,并逐渐扩展它的功能,同时确保它的线程安全性。
   程序清单2-1给出了一个简单的因数分解Servlet。这个Servlet从请求中提取出数值,执行因数分解,然后将结果封装到该Servlet的响应中。
   程序清单2-1 一个无状态的Servlet

@ThreadSafe
public class StatelessFactorizer implements Servlet{
   
    public void service(ServletRequest erq, ServletResponse resp) {
   
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp, factors);
    }
}

   与大多数Servlet相同,StatelessFactorizer是无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。访问StatelessFactorizer的线程不会影响另一个访问同一个StatelessFactorizer的线程的计算结果,因为这两个线程并没有共享状态,就好像它们都在访问不同的实例。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。
   无状态对象一定是线程安全的
   大多数Servlet都是无状态的,从而极大地降低了在实现Servlet线程安全性时的复杂性。只有当Servlet在处理请求时需要保存一些信息,线程安全性才会成为一个问题。

2.2 原子性

   当我们在无状态对象中增加一个状态时,会出现什么情况?假设我们希望增加一个“命中计数器”(HitCounter)来统计所处理的请求数量。一种直观的方法是在Servlet中增加一个long类型的域,并且每处理一个请求就将这个值加1,如程序清单2-2中的UnsafeCountingFactorizer所示。
   程序清单2-2 在没有同步的情况下统计已处理请求数量的Servlet(不要这么做)

@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
   
    
    private long count = 0;
    
    public long getCount() {
     return count; }
    
    public void service(ServletRequest erq, ServletResponse resp) {
   
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        ++count;
        encodeIntoResponse(resp, factors);
    }
}

   不幸的是,UnsafeCountingFactorizer并非线程安全的,尽管它在单线程环境中能正确运行。与前面的UnsafeSequence一样,这个类很可能会丢失一些更新操作。虽然递增操作++count是一种紧凑的语法,使其看上去只是一个操作,但这个操作并非原子的,因而它并不会作为一个不可分割的操作来执行。实际上,它包含了三个独立的操作:读取count的值,将值加1,然后将计算结果写入count。这是一个“读取-修改-写入”的操作序列,并且其结果状态依赖于之前的状态。
   图1-1给出了两个线程在没有同步的情况下同时对一个计数器执行递增操作时发生的情况。如果计数器的初始值为9,那么在某些情况下,每个线程读到的值都为9,接着执行递增操作,并且都将计数器的值设为10。显然,这并不是我们希望看到的情况,如果有一次递增操作丢失了,命中计数器的值就将偏差1。
   你可能会认为,在基于Web的服务中,命中计数器值的少量偏差或许是可以接受的,在某些情况下也确实如此。但如果该计数器被用来生成数值序列或者唯一的对象标识符,那么在多次调用中返回相同的值将导致严重的数据完整性问题(在UnsafeSequence和UnsafeCountingFactorizer中还存在一些其他的严重问题,例如可能出现失效数据(Stale Data)问题(3.1.1节)。在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件(RaceCondition)。

2.2.1 竞态条件

   在UnsafeCountingFactorizer中存在多个竞态条件,从而使结果变得不可靠。当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,就是正确的结果要取决于运气(竞态条件这个术语很容易与另一个相关术语“数据竞争(Data Race)”相混淆。数据竞争是指,如果在访问共享的非final类型的域时没有采用同步来进行协同,那么就会出现数据竞争。当一个线程写入一个变量而另一个线程接下来读取这个变量,或者读取一个之前由另一个线程写入的变量时,并且在这两个线程之间没有使用同步,那么就可能出现数据竞争。在Java内存模型中,如果在代码中存在数据竞争,那么这段代码就没有确定的语义。并非所有的竞态条件都是数据竞争,同样并非所有的数据竞争都是竞态条件,但二者都可能使并发程序失败。在UnsafeCountingFactorizer中既存在竞态条件,又存在数据竞争。参见第16章了解数据竞争的更详细内容。)。最常见的竞态条件类型就是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作。
   在实际情况中经常会遇到竞态条件。例如,假定你计划中午在University Avenue的星巴克与一位朋友会面。但当你到达那里时,发现在University Avenue上有两家星巴克,并且你不知道说好碰面的是哪一家。在12:10时,你没有在星巴克A看到朋友,那么就会去星巴克B看看他是否在那里,但他也不在那里。这有几种可能:你的朋友迟到了,还没到任何一家星巴克;你的朋友在你离开后到了星巴克A;你的朋友在星巴克B,但他去星巴克A找你,并且此时正在去星巴克A的途中。我们假设是最糟糕的情况,即最后一种可能。现在是12:15,你们两个都去过了两家星巴克,并且都开始怀疑对方是否失约了。现在你会怎么做?回到另一家星巴克?来来回回要走多少次?除非你们之间约定了某种协议,否则你们整天都在University Avenue上走来走去,倍感沮丧。
   在“我去看看他是否在另一家星巴克”这种方法中,问题在于:当你在街上走时,你的朋友可能已经离开了你要去的星巴克。你首先看了看星巴克A,发现“他不在”,并且开始去找他。你可以在星巴克B中做同样的选择,但不是同时发生。两家星巴克之间有几分钟的路程,而就在这几分钟的时间里,系统的状态可能会发生变化。
   在星巴克这个示例中说明了一种竞态条件,因为要获得正确的结果(与朋友会面),必须取决于事件的发生时序(当你们到达星巴克时,在离开并去另一家星巴克之前会等待多长时间……)。当你迈出前门时,你在星巴克A的观察结果将变得无效,你的朋友可能从后门进来了,而你却不知道。这种观察结果的失效就是大多数竞态条件的本质——基于一种可能失效的观察结果来做出判断或者执行某个计算。这种类型的竞态条件称为“先检查后执行”:首先观察到某个条件为真(例如文件X不存在),然后根据这个观察结果采用相应的动作(创建文件X),但事实上,在你观察到这个结果以及开始创建文件之间,观察结果可能变得无效(另一个线程在这期间创建了文件X),从而导致各种问题(未预期的异常、数据被覆盖、文件被破坏等)。

2.2.2 示例 延迟初始化中的竞态条件

   使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次。在程序清单2-3中的LazyInitRace说明了这种延迟初始化情况。getInstance方法首先判断ExpensiveObject是否已经被初始化,如果已经初始化则返回现有的实例,否则,它将创建一个新的实例,并返回一个引用,从而在后来的调用中就无须再执行这段高开销的代码路径。
   程序清单2-3 延迟初始化中的竞态条件(不要这么做)

@NotThreadSafe
public class LazyInitRace{
   
    private ExpensiveObject instance = null;
    
    public ExpensiveObject getInstance() {
   
        if (instance == null) {
   
            instance = new ExpensiveObject();
        }
        return instance;
    }
}

   在LazyInitRace中包含了一个竞态条件,它可能会破坏这个类的正确性。假定线程A和线程B同时执行getInstance。A看到instance为空,因而创建一个新的ExpensiveObject实例。B同样需要判断instance是否为空。此时的instance是否为空,要取决于不可预测的时序,包括线程的调度方式,以及A需要花多长时间来初始化ExpensiveObject并设置instance。如果当B检查时,instance为空,那么在两次调用getInstance时可能会得到不同的结果,即使getInstance通常被认为是返回相同的实例。
   在UnsafeCountingFactorizer的统计命中计数操作中存在另一种竞态条件。在“读取-修改-写入”这种操作(例如递增一个计数器)中,基于对象之前的状态来定义对象状态的转换。要递增一个计数器,你必须知道它之前的值,并确保在执行更新的过程中没有其他线程会修改或使用这个值。
   与大多数并发错误一样,竞态条件并不总是会产生错误,还需要某种不恰当的执行时序。然而,竞态条件也可能导致严重的问题。假定LazyInitRace被用于初始化应用程序范围内的注册表,如果在多次调用中返回不同的实例,那么要么会丢失部分注册信息,要么多个行为对同一组注册对象表现出不一致的视图。如果将UnsafeSequence用于在某个持久化框架中生成对象的标识,那么两个不同的对象最终将获得相同的标识,这就违反了标识的完整性约束条件。

2.2.3 复合操作

   LazyInitRace和UnsafeCountingFactorizer都包含一组需要以原子方式执行(或者说不可分割)的操作。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。
   假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。
   如果UnsafeSequence中的递增操作是原子操作,那么图1-1中的竞态条件就不会发生,并且递增操作在每次执行时都会把计数器增加1。为了确保线程安全性,“先检查后执行”(例如延迟初始化)和“读取-修改-写入”(例如递增运算)等操作必须是原子的。我们将“先检查后执行”以及“读取-修改-写入”等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。在2.3节中,我们将介绍加锁机制,这是Java中用于确保原子性的内置机制。就目前而言,我们先采用另一种方式来修复这个问题,即使用一个现有的线程安全类,如程序清单2-4中的CountingFactorizer所示。
   程序清单2-4 使用AtomicLong类型的变量来统计已处理请求的数量

@ThreadSafe
public class CountingFactorizer implements Servlet {
   
  
  private final AtomicLong count = new AtomicLong(0);

  public long getCount() {
    return count.get(); }
  
  @Override
  public void service(ServletRequest req, ServletResponse resp) {
   
      BigInteger i = extractFromRequest(req);
      BigInteger[] factores = factor(i);
      count.incrementAndGet();
      encodeIntoResponse(resp, factores);
  }
}

   在java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过用AtomicLong来代替long类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。(CountingFactorizer调用incrementAndGet来递增计数器,同时会返回递增后的值。这里忽略了返回值。) 由于Servlet的状态就是计数器的状态,并且计数器是线程安全的,因此这里的Servlet也是线程安全的。
   我们在因数分解的Servlet中增加了一个计数器,并通过使用线程安全类AtomicLong来管理计数器的状态,从而确保了代码的线程安全性。当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。然而,在2.3节你将看到,当状态变量的数量由一个变为多个时,并不会像状态变量数量由零个变为一个那样简单。
   在实际情况中,应尽可能地使用现有的线程安全对象(例如AcomicLong)来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为容易,从而也更容易维护和验证线程安全性。

2.3 加锁机制

   当在Servlet中添加一个状态变量时,可以通过线程安全的对象来管理Servlet的状态以维护Servlet的线程安全性。但如果想在Servlet中添加更多的状态,那么是否只需添加更多的线程安全状态变量就足够了?
   假设我们希望提升Servlet的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无须重新计算。(这并非一种有效的缓存策略,5.6节将给出一种更好的策略。)要实现该缓存策略,需要保存两个状态:最近执行因数分解的数值,以及分解结果。
   我们曾通过AtomicLong以线程安全的方式来管理计数器的状态,那么,在这里是否可以使用类似的AtomicReference(AtomicLong是一种替代long类型整数的线程安全类,类似的,AtomicReference是一种替代对象引用的线程安全类(Atomic Variable) 及其优势。)管理最近执行因数分解的数值及其分解结果吗?在程序清单2-5中的UnsafeCachingFactorizer实现了这种思想。
   程序清单2-5 该Servlet在没有足够原子性保证的情况下对其最近计算结果进行缓存(不要这么做)

@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
   
  
  private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();
  private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();
  
  public void service(ServletRequest req, ServletResponse resp) {
   
      BigInteger i = extractFromRequest(req);
      if (i.equals(lastNumber.get())) {
   
        encodeIntoResponse(resp, lastFactors.get());
      }
      else {
   
        BigInteger[] factors = factor(i);
        lastNumber.set(i);
        lastFactors.set(factors);
        encodeIntoResponse(resp, factors);
      }
  }
}

   然而,这种方法并不正确。尽管这些原子引用本身都是线程安全的,但在UnsafeCachingFactorizer中存在着竞态条件,这可能产生错误的结果。
   在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。UnsafeCachingFactorizer的不变性条件之一是:在lastFactors中缓存的因数之积应该等于在lastNumber中缓存的数值。只有确保了这个不变性条件不被破坏,上面的Servlet才是正确的。当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新。
   在某些执行时序中,UnsafeCachingFactorizer可能会破坏这个不变性条件。在使用原子引用的情况下,尽管对set方法的每次调用都是原子的,但仍然无法同时更新lastNumber和lastFactors。如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变性条件被破坏了。同样,我们也不能保证会同时获取两个值:在线程A获取这两个值的过程中,线程B可能修改了它们,这样线程A也会发现不变性条件被破坏了。   要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量

2.3.1 内置锁

   Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。(第3章将介绍加锁机制以及其他同步机制的另一个重要方面:可见性)同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。

synchronized(lock) {
   
  // 访问或修改由锁保护的共享状态
}

   每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock) 或 监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过代码块中抛出异常退出。获得内置锁的唯一途径就是进入这个锁保护的同步代码块或方法。
   Java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A也将永远地等下去。
   由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义——一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。
   这种同步机制使得要确保因数分解Servlet的线程安全性变得更简单。在程序清单2-6中使用了关键字synchronized来修饰service方法,因此在同一时刻只有一个线程可以执行service方法。现在的SynchronizedFactorizer是线程安全的。然而,这种方法却过于极端,因为多个客户端无法同时使用因数分解Servlet,服务的响应性非常低,无法令人接受。这是一个性能问题,而不是线程安全问题,我们将在2.5节解决这个问题。
   程序清单2-6 这个Servlet能正确地缓存最新的计算结果,但并发性却非常糟糕(不要这么做)

@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
   

   @GuardedBy("this") private BigInteger lastNumber;
   @GuardedBy("this") private BigInteger[] lastFactors;

   public synchronized void service(ServletRequest req, ServletResponse resp) {
   
     BigInteger i = extractFromRequest(erq);
      if (i.equals(lastNumber)) {
   
        encodeIntoResponse(resp, lastFactors);
      }
      else {
   
        BigInteger[] factors = factor(i);
        lastNumber = i;
        lastFactors = factors;
        encodeIntoResponse(resp, factors);
      }
   }
}
2.3.2 重入

   当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”(这与pthread(POSIX线程)互斥体的默认加锁行为不同,pthread互斥体的获取操作是以“调用”为粒度的。)。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。
   重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。在程序清单2-7的代码中,子类改写了父类的synchronized方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。由于Widget和LoggingWidget中doSomething方法都是synchronized方法,因此每个doSomething方法在执行前都会获取Widget上的锁。然而,如果内置锁不是可重入的,那么在调用super.doSomething时将无法获得Widget上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。重入则避免了这种死锁情况的发生。
   程序清单2-7 如果内置锁不是可重入的,那么这段代码将发生死锁

 public class Widget {
   
  public synchronized void doSomeThing() {
   
    // ...
  }
}
public class LoggingWidget extends Widget {
   
  public synchronized void doSomeThing() {
   
    System.out.println(toString()+":calling doSomething.");
    super.doSomeThing();
  }
}

2.4 用锁来保护状态

   由于锁能使其保护的代码路径以串行形式【对象的串行访问(Serializing Access)与对象的序列化(Serialization,即 将对象转化为字节流)操作毫不相干。串行访问意味着多个线程依次以独占的方式访问对象,而不是并发的访问。】来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。
   访问共享状态的复合操作,例如命中计数器的递增操作(读取-修改-写入)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竞态条件。如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。
   一种常见的错误是认为,只有在写入共享变量时才需要使用同步,然而事实并非如此(3.1节将进一步解释其中的原因)。
   对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。
   在程序清单2-6的SynchronizedFactorizer中,lastNumber和lastFactors这两个变量都是由Servlet对象的内置锁来保护的,在标注@GuardedBy中也已经说明了这一点。
   对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用做一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。【回想起来,这种设计决策或许比较糟糕:不仅会引起混乱,而且还迫使JVM需要在对象大小与加锁性能之间进行权衡。】你需要自行构造加锁协议或者同步策略来实现对共享状态的安全访问,并且在程序中自始至终地使用它们。
   每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。
   一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上并不会发生并发访问。在许多线程安全类中都使用了这种模式,例如Vector和其他的同步集合类。在这种情况下,对象状态中的所有变量都由对象的内置锁保护起来。然而,这种模式并没有任何特殊之处,编译器或运行时都不会强制实施这种(或者其他的)模式【如果某个变量在多个位置上的访问操作中都持有一个锁,但并非在所有位置上的访问操作都如此时,那么通过一些代码核查工具,例如FindBugs,就可以发现这种情况,并报告可能出现了一个错误。】。如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议会很容易被破坏。
   并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。第1章曾介绍,当添加一个简单的异步事件时,例如TimerTask,整个程序都需要满足线程安全性要求,尤其是当程序状态的封装性比较糟糕时。考虑一个处理大规模数据的单线程程序,由于任何数据都不会在多个线程之间共享,因此在单线程程序中不需要同步。现在,假设希望添加一个新功能,即定期地对数据处理进度生成快照,这样当程序崩溃或者必须停止时无须再次从头开始。你可能会选择使用TimerTask,每十分钟触发一次,并将程序状态保存到一个文件中。
   由于TimerTask在另一个(由Timer管理的)线程中调用,因此现在就有两个线程同时访问快照中的数据:程序的主线程与Timer线程。这意味着,当访问程序的状态时,不仅TimerTask代码必须使用同步,而且程序中所有访问相同数据的代码路径也必须使用同步。原本在程序中不需要使用同步,现在变成了在程序的各个位置都需要使用同步。
   当某个变量由锁来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护。因此可以在单个原子操作中访问或更新这些变量,从而确保不变性条件不被破坏。在SynchronizedFactorizer类中说明了这条规则:缓存的数值和因数分解结果都由Servlet对象的内置锁来保护。
   对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
   如果同步可以避免竞态条件问题,那么为什么不在每个方法声明时都使用关键字synchronized?事实上,如果不加区别地滥用synchronized,可能导致程序中出现过多的同步。此外,如果只是将每个方法都作为同步方法,例如Vector,那么并不足以确保Vector上复合操作都是原子的:

if (!vector.contains(element)) {
   
  vector.add(element);
}

   虽然contains和add等方法都是原子方法,但在上面这个“如果不存在则添加(put-if-absent)”的操作中仍然存在竞态条件。虽然synchronized方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还是需要额外的加锁机制(请参见4.4节了解如何在线程安全对象中添加原子操作的方法)。此外,将每个方法都作为同步方法还可能导致活跃性问题(Liveness)或性能问题(Performance),我们在SynchronizedFactorizer中已经看到了这些问题。

2.5 活跃性与性能

   在UnsafeCachingFactorizer中,我们通过在因数分解Servlet中引入了缓存机制来提升性能。在缓存中需要使用共享状态,因此需要通过同步来维护状态的完整性。然而,如果使用SynchronizedFactorizer中的同步方式,那么代码的执行性能将非常糟糕。SynchronizedFactorizer中采用的同步策略是,通过Servlet对象的内置锁来保护每一个状态变量,该策略的实现方式也就是对整个service方法进行同步。虽然这种简单且粗粒度的方法能确保线程安全性,但付出的代价却很高。
   由于service是一个synchronized方法,因此每次只有一个线程可以执行。这就背离了Serlvet框架的初衷,即Serlvet需要能同时处理多个请求,这在负载过高的情况下将给用户带来糟糕的体验。如果Servlet在对某个大数值进行因数分解时需要很长的执行时间,那么其他的客户端必须一直等待,直到Servlet处理完当前的请求,才能开始另一个新的因数分解运算。如果在系统中有多个CPU系统,那么当负载很高时,仍然会有处理器处于空闲状态。即使一些执行时间很短的请求,比如访问缓存的值,仍然需要很长时间,因为这些请求都必须等待前一个请求执行完成。
   图2-1给出了当多个请求同时到达因数分解Servlet时发生的情况:这些请求将排队等待处理。我们将这种Web应用程序称之为不良并发(Poor Concurrency)应用程序:可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。幸运的是,通过缩小同步代码块的作用范围,我们很容易做到既确保Servlet的并发性,同时又维护线程安全性。要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。
在这里插入图片描述
   程序清单2-8中的CachedFactorizer将Servlet的代码修改为使用两个独立的同步代码块,每个同步代码块都只包含一小段代码。其中一个同步代码块负责保护判断是否只需返回缓存结果的“先检查后执行”操作序列,另一个同步代码块则负责确保对缓存的数值和因数分解结果进行同步更新。此外,我们还重新引入了“命中计数器”,添加了一个“缓存命中”计数器,并在第一个同步代码块中更新这两个变量。由于这两个计数器也是共享可变状态的一部分,因此必须在所有访问它们的位置上都使用同步。位于同步代码块之外的代码将以独占方式来访问局部(位于栈上的)变量,这些变量不会在多个线程间共享,因此不需要同步。
   程序清单2-8 缓存最近执行因数分解的数值及其计算结果的Servlet

 @ThreadSafe
public class CachedFactorizer implements Servlet {
   
 @GuardedBy("this") private BigInteger lastNumber;
 @GuardedBy("this") private BigInteger[] lastFactors;
 @GuardedBy("this") private long hits;
 @GuardedBy("this") private long cacheHits;
 
 public synchronized long getHits() {
    return hits; }
 public synchronized double getCacheHitRatio() {
    
     return (double) cacheHits / (double) hits;
 }
 public void service(ServletRequest req, ServletResponse resp) {
   
   BigInteger i = extractFromRequest(req);
   BigInteger[] factors = null;
   synchronized (this) {
   
     ++hits;
      if (i.equals(lastNumber)) {
   
        ++cacheHits;
         factors = lastFactors.clone();
      }
   }
   if (factors == null) {
   
     factors = factor(i);
      synchronized (this) {
   
        lastNumber = i;
        lastFactors = factors.clone();
      }
   }
   encodeIntoResponse(resp, factors);
 }
}

   在CachedFactorizer中不再使用AtomicLong类型的命中计数器,而是使用了一个long类型的变量。当然也可以使用AtomicLong类型,但使用CountingFactorizer带来的好处更多。对在单个变量上实现原子操作来说,原子变量是很有用的,但由于我们已经使用了同步代码块来构造原子操作,而使用两种不同的同步机制不仅会带来混乱,也不会在性能或安全性上带来任何好处,因此在这里不使用原子变量。
   重新构造后的CachedFactorizer实现了在简单性(对整个方法进行同步)与并发性(对尽可能短的代码路径进行同步)之间的平衡。在获取与释放锁等操作上都需要一定的开销,因此如果将同步代码块分解得过细(例如将++hits分解到它自己的同步代码块中),那么通常并不好,尽管这样做不会破坏原子性。当访问状态变量或者在复合操作的执行期间,CachedFactorizer需要持有锁,但在执行时间较长的因数分解运算之前要释放锁。这样既确保了线程安全性,也不会过多地影响并发性,而且在每个同步代码块中的代码路径都“足够短”。
   要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这个需求必须得到满足)、简单性和性能。有时候,在简单性与性能之间会发生冲突,但在CachedFactorizer中已经说明了,在二者之间通常能找到某种合理的平衡。
   通常,在简单性与性能之间存在着相互制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)。
   当使用锁时,你应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。
   当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。

第3章 对象的共享

   第2章的开头曾指出,要编写正确的并发程序,关键问题在于:在访问共享的可变状态时需要进行正确的管理。第2章介绍了如何通过同步来避免多个线程在同一时刻访问相同的数据,而本章将介绍如何共享和发布对象,从而使它们能够安全地由多个线程同时访问。这两章合在一起,就形成了构建线程安全类以及通过java.util.concurrent类库来构建并发应用程序的重要基础。
   我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字synchronized只能用于实现原子性或者确定“临界区(Critical Section)”。同步还有另一个重要的方面:内存可见性(Memory Visibility)。我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。如果没有同步,那么这种情况就无法实现。你可以通过显式的同步或者类库中内置的同步来保证对象被安全地发布。

3.1 可见性

   可见性是一种复杂的属性,因为可见性的错误总是会违背我们的直觉。在单线程环境中,如果向某个变量先写入值,然后在没有其他写入操作的情况下读取这个变量,那么总能得到相同的值。这看起来很自然。然而,当读操作和写操作在不同的线程中执行时,情况却并非如此,这听起来或许有些难以接受。通常,我们无法确定执行读操作的线程能适时的看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
   在程序清单3-1中的NoVisibility说明了当多个线程在没有同步的情况下共享数据时出现的错误。在代码中,主线程启动读线程,然后将number设成42,并将ready设成true。读线程一直循环直到发现ready的值变为true,然后输出number的值。虽然NoVisibility看起来会输出42,但事实上很可能输出0,或者根本无法终止。这是因为在代码中没有使用足够的同步机制,因此无法保证主线程写入的ready值和number值对于读线程来说是可见的。
   程序清单3-1 在没有同步的情况下共享变量(不要这么做)

public class NoVisibility {
   

 private static boolean ready;
 private static int number;

 private static class ReaderThread extends Thread {
   
  @Override public void run() {
   
	 while (!ready) 
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值