Java Concurrency
1. Introduction
Java Concurrency
是一个涵盖 Java 平台多线程,并发和并行的术语,包括并发工具,并发问题和并发解决方案。本文将介绍多线程的核心概念,并发结构,并发问题,以及多线程的代价和优点。
What is Multithreading?
多线程即同一应用内部执行多个线程。假如单线程代表只有一个 CPU 执行你的应用,那么多线程应用中,则同时存在多个 CPU 执行不同部分的代码。
然而,单线程并不等同于单个 CPU。通常,一个 CPU 把执行时间分享给多个线程,每隔一段时间,CPU 会切换一个线程。当然,让不同线程被不同 CPU 执行也是可能的。
Why Multithreading?
在应用中使用多线程有许多原因,最常见的是以下几个:
- 更好地利用单个 CPU
- 更好地利用多个 CPU 或多核
- 更快的用户响应
- 更公平的用户响应
Better Utilization of a Single CPU
最常见的原因是可以更好利用计算机中的资源。例如,如果一个线程正在等待网络请求响应,另一个线程可以同时使用 CPU 做其它事情。此外,如果计算机有多个 CPU,或者 CPU 拥有多个运算核心,那么多线程也可以帮助应用利用这些额外的核心。
Better Utilization of Multiple CPUs or CPU Cores
如果一台计算机包含多个 CPU 或 CPU 含有多个执行核心,那么你需要通过多线程让你的应用利用所有 CPU 或 CPU 核心。单线程最多可以利用单个 CPU,并且就像我上面提到的,有时还不能完全利用它。
Better User Experience with Regards to Responsiveness
使用多线程的另一个原因是提供更好的用户体验。例如,你点击了界面上的一个按钮,它将触发一个网络请求,那么请求由哪个线程发出是很重要的。如果你仍然使用更新 GUI 的线程,那么在等待请求响应时,用户可能无法操作界面。相反,这一请求可以被后台线程执行,那么界面线程就可以同时响应其它用户操作。
Better User Experience with Regards to Fairness
第四个原因是在各个用户间更公平地共享计算机资源。就像例子想象的那样,一个接收客户端请求的服务器,如果仅有一个线程执行这些请求,当某个客户端发送了一个长耗时请求时,其它客户端将不得不等待那个请求结束。如果每个客户端请求被它们自己的线程执行,就没有任何一个任务可以完全独占CPU。
Multithreading vs. Multitasking
以前,那些只有一个 CPU 的计算机只能同时运行一个程序。大多数小型机没有足够的性能同时执行多个程序,所以它们没有那样做。公平的说,大型系统具备同时执行多个程序的能力要比个人电脑早许多年。
Multitasking
随后出现了多任务,即计算机可以同时执行多个程序(任务或进程),但这并非真正的同时,而是单个 CPU 被多个程序共享,操作系统会切换不同的程序执行,每个程序运行一小段时间。
多任务也为开发者带来了新挑战。程序再也不能假定拥有所有 CPU 时间,同样还有内存或其它计算资源。程序中的 “良好公民” 应该释放所有不再使用的资源,这样其它程序才能获得它们。
Multithreading
最后出现的是多线程,它意味着你可以在同一应用内部执行多个线程。单线程执行可以想象成只有一个 CPU 执行这个程序。当你拥有多线程时,就像同一程序同时被不同 CPU 执行。
Multithreading is Hard
多线程是增强某类程序性能的绝佳手段。然而,实现多线程比多任务更具挑战性。同一程序中的多个线程会同时读写相同的内存单元。这会导致单线程应用中不会出现的错误。某些错误可能不会出现在单 CPU 机器,因为这种情况下,两个线程永远不能真正同时运行。然而,现代计算机的 CPU 都拥有多核心,有些计算机还拥有多个 CPU,这意味着不同线程可以被不同核心或 CPU 同时执行。
如果一个线程正在读取另一个线程将要写入的内存地址,那么它最终读到的将是哪个值?旧的值?第二个线程写入后的值?还是一个介于两者之间的值?又或者,两个线程同时向同一内存地址写入数据,当它们完成时,该内存位置的值哪个呢?第一个线程写入的?第二个线程写入的?还是两个值的混合呢?
如果没有恰当的处理,以上结果都有可能出现。它们的行为甚至是不可预测的,结果每次都可能不同。因此了解和采取恰当措施对开发者来说是十分重要的——即学习控制线程如何访问共享资源,如内存,文件,数据库等。这就是本文要讨论的话题之一。
Multithreading and Concurrency in Java
Java 是第一批让开发者能方便地使用多线程的语言之一,它从一开始就支持多线程。因此,Java 开发者总是面临上面描述的问题。这就是我写这篇教程的原因,作为我和可能从本文获得帮助的 Java 开发者的笔记。
这篇教程主要讨论 Java 中的多线程,但多线程中的一些问题和多任务及分布式系统中的许多问题是类似的。本文也会提到多任务和分布式系统,所以使用了 "concurrency" 而不是 "multithreading"。
Concurrency Models
第一个 Java 并发模型假定同一应用中执行的多线程也会共享对象。这类并发模型通常被叫做 共享状态并发模型 (shared state concurrency model)
。许多并发语言结构和工具都支持这种模型。
但是,自从第一本 Java 并发书籍编写,特别是 Java 5 并发工具发布以来,并发架构和设计已经发生了很多改变。
共享状态并发模型导致的许多问题不能得到优雅解决。因此,一种被称为 非共享 (shared nothing)
或 状态分离 (separate state)
的并发模型流行起来。在这一模型中,线程不共享任何对象和状态,这避免了许多共享模型存在的访问问题。
现在出现了新的异步状态分离 (separate state) 平台和工具,如 Netty, Vert.x and Play / Akka and Qbit;新的非阻塞并发算法和工具,如 LMax Disrupter;Java 7 引入的 Fork and Join 框架,支持函数式并行;还有 Java 8 引入的集合流式 API。
在如此多进展的情况下,是时候更新这篇 Java 并发教程了。因此,这篇教程又一次 work in progress
。
2. Multithreading Benefits
Better CPU Utilization
想象一个应用从本地文件系统读取和处理文件。假定从磁盘读取文件需要 5s,处理需要 2s,那么处理 2 个文件需要花费:
5 seconds reading file A
2 seconds processing file A
5 seconds reading file B
2 seconds processing file B
-----------------------
14 seconds total
读取文件时,大多数 CPU 时间都用来等待磁盘。在此期间,CPU 非常空闲,它本可以做其他事情。通过改变操作顺序,CPU 就能被更好利用。看下面的顺序:
5 seconds reading file A
5 seconds reading file B + 2 seconds processing file A
2 seconds processing file B
-----------------------
12 seconds total
CPU 首先等待第一个文件被读取。随后它开始读取第二个文件。在计算机的 IO 组件读取第二个文件时,CPU 同时处理第一个文件。记住,等待文件从磁盘读取时,CPU 很大可能是空闲的。
换句话说,等待 IO 时,CPU 可以做其它事情。这不仅局限于磁盘 IO,也可以是网络 IO 或用户输入。网络和磁盘 IO 总是比 CPU 和内存 IO 慢很多。
Simpler Program Design
如果你在单线程应用中处理上述读取和处理顺序,那么你必须跟踪每个文件的读取和处理状态。取而代之,你可以启动两个线程,让它们分别读取和处理单个文件。每个线程在等待文件读取时会阻塞。一个线程等待时,其它线程可以使用 CPU 处理它们已经取得的文件部分。结果是,磁盘总是处于繁忙状态,将多个文件读取到内存。这不仅提高了磁盘和 CPU 利用率,实现起来也更简单,因为每个线程只需要跟踪单个文件。
More Responsive Programs
重构单线程为多线程应用的另一个常见目标是获得及时响应。想象一个监听多个端口请求的服务程序。当请求到达时,它处理请求随后继续监听。服务循环的骨架就像下面这样:
while(server is active) {
listen for request
process request
}
如果请求需要很长时间处理,那么在此期间,没有其它客户端可以发送请求。只有当服务处于监听状态时,请求才能被接收。
替代的设计是监听线程把请求传递给工作线程,随后立即返回继续监听。工作线程会处理请求,进而响应客户端。这种设计的伪代码如下:
while(server is active) {
listen for request
hand request to worker thread
}
这样服务线程可以马上返回监听,因此更多客户端可以发送请求给服务器,响应性能得到提升。
对于桌面应用同样如此。你点击一个按钮,它会启动一个长时间任务,如果执行该任务和更新窗口,按钮等使用同一线程,那么在执行任务时应用就会无响应。相反,任务可以被工作线程处理。即使工作线程非常繁忙,窗口线程也能响应其它用户请求。当工作线程完成工作时,它会向窗口线程发送信号。窗口线程便可使用任务结果更新窗口。使用工作线程设计的应用在用户看来会更具响应性。
More Fair Distribution of CPU Resources
想象一个接收客户端请求的服务器。如果其中一个客户端的请求需要长时间处理,例如 10s。如果服务器使用单线程处理所有任务,那么所有后续请求必须等待该任务被处理。
通过将 CPU 时间在多线程间切换,CPU 可以更公平地执行每个请求。即使某个请求需要长时间处理,服务器也可以同时处理其它短耗时请求。当然,慢请求的处理将变得更加缓慢,因为它不能独享 CPU 时间。但是其它请求的等待时间将大大缩短,因为它们无需等待慢请求结束。如果只有慢请求要处理,它仍然能独享 CPU。
3. Multithreading Costs
重构应用为多线程并不仅仅带来好处,它也伴随着一定的代价。不要仅仅因为你能使用就在应用中使用多线程。你应该有足够理由相信引入多线程的优势大于代价。有疑问时,尝试测试应用的性能和响应,而非仅靠猜测。
More complex design
尽管多线程应用的某些部分较单线程简单,但其它部分会更加复杂。多线程访问共享数据的代码需要特别关照,线程竞争远不简单。由不正确的线程同步带来的错误将很难被检测,重现和修复。
Context Switching Overhead
CPU 从一个线程切换到另一个时,它需要保存当前线程的局部数据,程序指针等,还需加载下一线程的局部数据,程序指针。这种切换叫 上下文切换 (context switch)
,CPU 从一个线程的运行上下文切换到另一个。
上下文切换并不便宜。如非必要,不要进行。你可以阅读 维基百科 了解更多。
Increased Resource Consumption
线程运行需要计算机的某些资源。除了 CPU 时间,线程还需内存保存本地栈。操作系统为了管理线程,也要消耗一些内部资源。尝试创建包含 100 个忙等线程的应用,运行它,观察它会占用多少内存。
4. Concurrency Models
并发系统可以使用不同并发模型实现。并发模型指定了系统中的线程如何合作完成分配给它们的任务。不同并发模型划分任务的方式不同,线程间通信和合作的方式也不同。本章将深入介绍当前节点 (2015 - 2019) 最流行的并发模型。
Concurrency Models and Distributed System Similarities
本文描述的并发模型和分布式系统中使用的不同架构很相似。并发系统中的多线程互相通信,而分布式系统中的不同进程也相互通信(很大可能位于不同计算机)。线程和进程在本质上非常相似。这就是为何不同并发模型看上去就像不同分布式系统架构。
当然,分布式系统会面临额外挑战,如网络故障,远程计算机宕机或进程崩溃等。但运行于大型服务的并发系统也会面临类似问题,如 CPU 故障,网卡故障,磁盘故障等。虽然故障率很低,但它们在理论上都可发生。
由于并发模型和分布式系统的相似性,它们可以互相借鉴各自的思想。例如,工作线程分发模型类似于 分布式系统中的负载均衡。错误处理技术也是类似的,如日志记录,故障转移,任务的幂等性 等。
Shared State vs. Separate State
并发模型的一个重要方面是,组件和线程被设计为共享状态或者分离状态,即状态不在线程间共享。
共享状态 (Shared state)
指系统中的不同线程共享某些状态。状态指数据,通常是一到多个对象。当线程共享状态时,像 竞态条件 和 死锁 问题便会发生。当然,这取决于线程如何使用和访问共享对象。
分离状态 (Separate state)
指系统中的不同线程间不共享任何状态。它们之间的通信,要么通过交换不可变对象,要么发送对象(数据)的拷贝。因此,由于不会出现两个线程向相同对象(data / state)写入数据,便可避免大多数常见的并发问题。
使用分离状态并发设计通常可以使代码的某些部分变得易于实现和理解,因为你知道只有一个线程会向特定对象写入数据。你无需担心对象的并发访问。但是,分离状态并发可能需要你花更多时间从全局思考应用设计。
Parallel Workers
第一个并发模型我把它叫做 平行作业 (parallel workers)
。新的工作被分配给不同作业者。看下图说明:
代理人负责分发任务,作业者完成委派的全部工作。作业者平行运作,运行于不同的线程,很可能位于不同 CPU。
如果用平行作业模型实现汽车工厂,那么每个作业者将生产一整台车。作业者会得到汽车的建造规范,完整生产需要的每个部件。
平行作业模型是 Java 应用中最常用的并发模型(虽然在不断变化)。java.util.concurrent 包下的许多并发工具都设计成这种模型。你也能在 Java EE 服务应用中见到这种模型。
平行作业既可共享状态,也能分离状态,所以作业者能够访问某些共享状态(共享对象或数据),也可能不共享状态。
Parallel Workers Advantages
平行作业模型的优点是易于理解。要增加应用的并发性,你只需添加更多作业者。
例如,你正在实现一个网页爬虫,你可以使用不同数量的作业者爬取特定网页,观察多少作业者会获得最短的爬取时间(意味着最高性能)。由于网页爬虫是 IO 敏感型工作,对于计算机上的每个 CPU 或核心,你最终可能需要几个线程。每个 CPU 一个线程就太少了,因为在等待数据下载时,它会空闲很长时间。
Parallel Workers Disadvantages
然而,平行作业模型的简单外表下潜藏着许多缺点。下面我会挑选几个最明显的缺点予以说明。
Shared State Can Get Complex
一旦共享作业者需要访问某类共享数据,它要么在内存中,要么位于共享数据库,正确的管理并发访问将变得困难。下图展示了共享状态如何使该模型变得复杂:
某些共享状态通过工作队列机制通信。但还有些共享状态是业务数据,数据缓存,数据库连接池等。
一旦平行作业不经意间引入了共享状态,它将变得复杂起来。线程访问共享数据时必须确保它对数据的改变对其它线程可见(变更同步到主存,而不仅仅是执行线程的 CPU 所在的缓存)。线程需要避免 竞态条件,死锁 和许多其它共享状态并发问题。
此外,当共享数据结构正在被访问时,工作线程必须互相等待,这将导致部分并发性损失。许多并发数据结构是阻塞的,这就意味着每次只允许一个或有限的线程集访问它们。在这些共享数据结构上会发生争夺,大量争用基本上会让访问共享数据结构的那些代码一定程度上退化为串行执行(消除了并行)。
现代 非阻塞并发算法 可能会减少争用,增加性能,但非阻塞算法难于实现。
持久化数据结构是另一个解决方案。当被修改时,它总是保存之前的版本。因此,如果多线程指向同一个持久化数据结构,如果其中一个线程修改了它,该线程将得到一份新结构的拷贝。其它线程仍然保持旧结构的引用,它包含着未改变的值,因此是一致的。Scala 标准 API 包含若干持久化数据结构。
尽管持久化数据结构是并发修改共享数据的优雅方案,但这种结构的表现并不理想。
举例来说,一个持久化列表会把新加元素放到表头,返回新加元素的引用(表头指向链表的剩余部分)。所有其它线程仍然指向旧的表头,对于这些线程而言,链表似乎没有改变。它们看不到新添加的元素。
这种持久化列表以链式结构实现。不幸的是,链表在现代硬件上的性能并不完美。其中的每个元素都是单独的对象,这些对象可以散布于整个计算机内存。现代 CPU 善于访问顺序数据,所以基于数组的列表性能会更好。数组顺序储存数据,CPU 缓存可以一次加载大块片段。而链表的元素分散在整个 RAM,不便批量加载。
Stateless Workers
共享状态可以被系统中的其它线程修改,因此作业者必须每次重载状态来确保它们获得的是最新拷贝。无论共享状态位于内存还是外部数据库,线程都要完成这种操作。不维护内部状态的线程称为 无状态 (stateless)
线程(但是仍需每次重载)。
每次需要时重载很慢,尤其当状态位于外部数据库时。
Job Ordering is Nondeterministic
平行作业的另一个缺点是任务执行的顺序无法指定。你无法保证哪个任务最先或最后执行。任务 A 可能先于任务 B 被分发,但任务 A 的执行可能后于任务 B。
这种不可确定性让给定时间点获取系统状态变得困难。它也使保证任务先后顺序变得困难(如果可能的话)。但这不总是一个问题,取决于系统的需求。
Assembly Line
第二个模型我把它叫做 流水作业 (assembly line)
并发模型。我使用这个名称仅为了与前面的平行作业比喻一致。其它开发者可能根据平台或社区使用其它名称(如响应式系统,或事件驱动系统)。示意图如下:
作业者就像工厂中的装配线工人一样被组织。每个作业者只完成任务的一部分。当该部分完成时,上游作业者将其传递给下游作业者。
使用流水作业模型的系统通常设计成使用非阻塞 IO。非阻塞 IO 指作业者发起 IO 操作时(如从网络取得文件或数据),它并不等待该操作完成。IO 操作是很缓慢的,所以等待它是对 CPU 时间的浪费。CPU 在此期间可以完成其他事情。当 IO 操作结束时,其结果(如读数据或写数据状态)会被传递给下个作业者。
使用无阻塞 IO,IO 操作决定作业者边界。作业者可以尽可能做更多,直到不得不发起一个 IO 操作。随后它放弃对作业的控制。当 IO 操作完成时,流水线中的下个作业者继续之前的工作,直到它也必须发起 IO 操作,如此往复。
实际上,系统可能不限于单条流水线。因为大多数系统可以执行许多作业,只有作业部分需要往下执行,作业流才会在作业者间传递。现实中,可能同时运行着多条虚拟流水线,见下图:
作业甚至可能被转发给多个线程同步处理。例如,作业可能被同时转发给作业执行者和作业记录者。下图展示了三条流水线最终把任务都转发给了相同作业者。
真正的流水线可能比上面还要复杂。
Reactive, Event Driven Systems
使用流水线并发模型的系统有时也被叫做响应式系统或事件驱动系统。系统中的作业者响应发生的事件,这些事件要么是外部世界发送的,要么是其它作业者发出的。例如新的 HTTP 请求或某个文件完成了内存加载等。
截止成文,已经出现许多有趣的响应式/事件驱动平台,未来会出现更多。其中最流行的几个可能是:
- Vert.x
- Akka
- Node.JS (JavaScript)
我个人认为 Vert.x 非常有意思(尤其对于像我这样守旧的 Java / JVM 使用者)。
Actors vs. Channels
行动者和频道是两个类似的流水线模型(响应式 / 事件驱动)示例。
行动者模型中,每个作业者被称为行动者。行动者间可以直接互相发送消息。消息被异步发送和处理。就像前面描述的,行动者可以实现一到多条作业处理流水线,下图展示了这种模型:
在频道模型中,作业者间不能直接交流。取而代之,它们会将消息(事件)发布到不同频道。其它作业者可以监听这些频道上的消息,发送者对此并无感知。下图阐述了这种模型:
截止成文,我觉得频道模型更具弹性。作业者无需知道流水线上的下个作业者是谁。它只需知道要将作业转发(或消息发送等)给哪个频道。频道的监听者可以订阅和退订频道,这不影响作业发送者。这允许对作业者进行一定程度的解耦。
Assembly Line Advantages
相比平行作业,流水线作业有不少优点,我将挑选最大的几个予以说明。
No Shared State
作业者间不共享状态意味着实现上不用考虑那些并发访问共享状态才会出现的问题。这使作业者的实现更加简单。你实现作业者时就像它是唯一执行作业的线程——基本上是单线程实现。
Stateful Workers
因为作业者知道其它线程不会修改它们的数据,所以可以持有状态。有状态指它们可以在内存中保存它们需要操作的数据,只要在最后把变化写回外部存储系统。有状态作业者通常快于其无状态实现。
Better Hardware Conformity
单线程代码的优点是,它总是更好符合底层硬件的工作方式。首先,如果你能确保代码以单线程执行,你就可能创造更优化的数据结构和算法。
其次,如上所说,有状态的单线程作业者可以将数据缓存到内存。当数据被缓存到内存中时,它有很大可能也会被缓存到执行线程的 CPU 缓存。缓存数据的访问会更快。
我把以天然符合底层硬件工作方式编写的代码称为具有 硬件一致性 (hardware conformity)
。有些开发者把它叫做 机械共情 (mechanical sympathy)
。我倾向于前者,因为计算机的机械部件很少,并且在当前语境下,同情是对 配合更好
的比喻,我认为 一致
更加合理。好吧,这是吹毛求疵,使用你喜欢的术语就好。
Job Ordering is Possible
流水线并发模型可以保证作业的顺序。而顺序又使得特定时间点系统状态的获取变得简单。更进一步,你可以把所有新作业写入日志。如果系统的某个部分出现故障,这份日志可以用来从零开始重建系统状态。作业以特定顺序写入日志,此顺序也是作业顺序的保证。见下图:
保证作业顺序的实现并不简单,但总是可能的。如果可以,它将大大简化像备份,数据恢复,数据复制等任务,因为它们都可以借助日志完成。
Assembly Line Disadvantages
流水线并发模型的主要缺点是,作业的执行通常遍布多个作业者,因此你的项目会存在许多类。所以要确定特定作业的确切代码将变得困难。
编写代码也会变得困难。作业者总是作为回调处理器。嵌套太多的回调处理可能导致通常所说的 回调地狱 callback hell
。这意味着跟踪代码在整个回调过程中做了什么将变得困难,同样困难的是确定每个回调对所需数据的访问。
在平行作业模型中这些工作相对简单。你可以打开作业者代码,从头读到尾,它们基本就是整个作业的代码。当然平行作业代码也可能分散成不同的类,但执行顺序通常容易从代码辨识。
Functional Parallelism
函数式并行
是第三个在当前时间点(2015)被讨论较多的并发模型。
它的基本思想是使用函数调用实现程序。函数可以视作 “代理” 或 “行动者”,它们互相发送消息,就像流水线模型那样。函数的调用就像消息的发送。
传递给函数的参数都会被拷贝,所以除接收函数以外的实体都无法操纵数据。这种拷贝有效避免了共享数据上的竞态条件。它使函数执行类似于原子操作。函数调用之间相互独立。
每个函数调用相互独立意味着它们可以被不同 CPU 执行。也就是说,函数式算法可以在多个 CPU 上并行执行。
Java 7 带来的 java.util.concurrent
包下的 ForkAndJoinPool 可以帮助你实现类似的函数式并行。Java 8 带来的并行 streams 可以帮助你并行地进行大集合遍历。记住有些开发者对 ForkAndJoinPool
持批判态度(你可以在我的 ForkAndJoinPool 教程中看到)。
函数式并行的难点在于指定哪些函数调用去并行。跨 CPU 的函数协同调用伴随着额外消耗。函数要完成的作业单元必须具有一定规模才能抵消这一消耗。如果函数调用规模很小,那么尝试对它并行化实际可能比单线程,单 CPU 执行更慢。
按照我的理解(它一点也不完美),你可以使用响应式,事件驱动模型实现一个算法,它可以获得类似于函数式并行的工作拆分。通过事件驱动,你还可以更加精确地控制并行作业及其规模(我的观点)。
此外,将任务拆分到多个 CPU 伴随着协作消耗,这仅当它是程序的唯一任务时才有意义。然而,如果系统当前正在执行多个其它任务(如网页服务器,数据库服务器和许多其它系统),尝试并行化单一任务就没有多大意义。无论如何,计算机中的其它 CPU 都正忙于其它任务,所以试图用一个更加缓慢的函数式并行任务打扰它们并不合理。
使用流水线模型可能更好,因为它的消耗更小(以单线程顺序执行),并且更符合底层硬件机制。
Which Concurrency Model is Best?
所以,哪种并发模型最好呢?
和通常案例一样,答案是取决于你的系统要做什么。如果你的任务天然平行,独立,也没有必要共享状态,那么可以使用平行作业实现它。
但是很多任务并不天然平行且独立。对于它们,我相信流水线模型的优势大于劣势,并且要优于平行作业。
你甚至无需自己实现流水线架构。像 Vert.x 那样的现代平台已经为你做了很多。我会在下个项目里探索这些平台的设计。我觉得 Java EE 完全没有边界。
5. Same-threading
同线程是一种并发模型,它将单线程系统扩展为 N 线程系统,结果是 N 条线程并行执行。
同线程系统并非只有一个线程,而是每个线程像单线程系统那样运行。因此使用术语 同线程 (same-threaded)
而非 单线程(single-threaded)
。
Why Single-threaded Systems?
你可能好奇为何今天还有人设计单线程系统。单线程系统流行的原因是它的并发模型相较多线程简单。单线程系统无状态共享,能够使用非并发数据结构,并且充分利用 CPU 及其缓存。
然而,单线程系统无法完全利用现代 CPU。现代 CPU 通常有 2,4,6,8 甚至更多核心。每个核心的功能等价于独立 CPU。如下图所示,单线程系统只能使用一个 CPU:
Same-threading: Single-threading Scaled Out
为了使用所有 CPU 核心,单线程系统可以被扩展。
One Thread Per CPU
通常,计算机的每个 CPU 核心包含一个同线程。如果一台计算机拥有 4 个 CPU 或者包含 4 个核心,那么运行 4 个同线程实例是很正常的,下图展示了这一原则:
No Shared State
同线程系统看上去和传统多线程类似,因为它的内部也运行着多条线程。但它们之间存在一点微妙区别,即同线程系统不共享状态。
同线程系统中的线程不会并发访问共享内存,不存在线程共享的并发数据结构。下图展示了这种不同:
共享状态的缺失使得每个线程表现得就像是单线程系统。同线程的基本含义是数据在同一线程处理,线程间不共享并发数据。有时它仅被叫做 无共享状态 (no shared state)
或 状态分离 (separate state)
并发。
Load Distribution
显然,同线程系统需要在单线程实例间分享工作负载。如果只有一个线程工作,那它就变成了真正的单线程。
如何在不同线程间分发负载取决于你的系统设计,下面我将简要介绍。
Single-threaded Microservices
如果你的系统由许多微服务组成,每个微服务能够以单线程模式运行。当你在相同机器上部署多个单线程微服务时,每个微服务能够以单线程占用单个 CPU。
微服务天然无数据共享,所以它是同线程系统的良好用例。
Services With Shared Data
如果你的系统确实需要共享数据,或者至少是一个数据库,你也许可以对数据库进行拆分。拆分指把数据划分到多个数据库。通常的数据拆分原则是让彼此关联的数据位于相同数据库。例如,某个实体所属的所有数据被插入一个数据库中。然而分库超出了本篇教程的范畴,你需要搜索该话题的教程。
Thread Communication
同线程系统中,线程通过消息传递通信。如果线程 A 需要向线程 B 发送一条消息,它可以生成一个字节序列。线程 B 会复制并读取该消息。消息复制保证了线程 B 在读取时不会被线程 A 修改。一旦消息被复制,线程 A 便失去了对它的访问权。下图阐释了通过消息完成线程通信:
消息可以通过队列,管道,UNIX 套接字,TCP 套接字等承载,只要适合你的系统。
Simpler Concurrency Model
每个系统运行在自己的线程里组成同线程系统,实现上就像它是单线程的。这意味着它的内部并发模型比线程共享状态简单。你无需担心并发数据结构,及其产生的问题。
Illustrations
下面几张图比较了单线程,多线程和同线程系统,你能很容易看出它们的大体不同。首先是单线程系统。
随后是多线程系统,线程间存在共享数据。
最后是同线程系统,它有两个线程,各自持有独立数据,通过传递消息通信。
Thread Ops for Java
Thread Ops for Java 是一个开源工具,它能帮你更容易地实现分离状态的同线程系统。它可以启动和停止独立线程,通过单线程获得一定的并发。如果你对同线程应用设计感兴趣,不妨去看一看。要了解更多,阅读我的 Thread Ops for Java Tutorial。
6. Concurrency vs. Parallelism
术语 并发和并行 (concurrency and parallelism)
经常在多线程编程中出现。起初它们似乎指代相同事物,但实际上其含义并不相同。我将会在这篇教程分别解答。
为了澄清,本文中的并发和并行都位于一个应用 —— 一个进程。不是多应用,进程或计算机。
Concurrency
并发指应用同一时间(至少是表面上)进行着多个任务。
如果计算机只有一个 CPU,那应用不可能真的同时进行多个任务,为了看上去同时进行,CPU 要在不同任务间切换,就像下图这样:
Parallel Execution
并行执行是当计算机拥有多个 CPU 或多核时,同步执行多个任务。但是并行执行并不代指并行性,后者我待会介绍。下面是并行执行示意图:
Parallel Concurrent Execution
并行并发执行是可能的,这种情况下线程被分发到多个 CPU。因此,相同 CPU 上的线程并发执行,不同 CPU 间的线程并行执行,下图展示了这种情况:
Parallelism
并发性指应用将任务拆分成更小的子任务,它们可以被并行处理。例如,多 CPU 可以真正做到同时处理。因此,并行性与并行并发执行的模型并不相同,尽管它们表面看去很像。
要获得真正的并行性,你的应用必须包含多条线程,并且每个线程运行在独立 CPU / 核心 / GPU 等。
下图中,大任务被拆分成 4 个小任务,每个任务是一个线程,它们运行于 2 个 CPU。这意味着部分任务是并发执行的(那些处于相同 CPU 上的),还有部分是并行执行的(那些处于不同 CPU 上的)。
取而代之,如果 4 个子任务的 4 条线程都运行于自己的 CPU (总共 4 个 CPU),那么这些任务就是完全并行执行的。但是,要把任务拆分成和 CPU 数量相等是不容易的。通常,更容易的做法是按实际意义拆分任务,让线程调度器去决定如何在 CPU 间分发线程。
Concurrency and Parallelism Combinations
回想下,并发指单个 CPU 如何看上去同时执行多个任务。
并行则和应用如何平行化执行单个任务有关 —— 通常通过将任务拆分成可以完全平行的子任务。
Concurrent, Not Parallel
应用可以是并发非并行的。这意味着它表面上同时进行多个任务,但会在每个任务间切换,直到所有任务完成。并不存在真正位于并行线程 / CPU 的平行化任务
Parallel, Not Concurrent
应用也可以是并行非并发的。这意味着它每次只能处理一个任务,该任务被拆分为可以平行化执行的子任务。但是,在下个任务被拆分和并行执行前,前一个已处理完毕。
Neither Concurrent Nor Parallel
此外,应用可以既不并发也不并行。这意味着它只能同时处理一个任务,并且该任务不会被拆分后并行执行。这种情况可能是小型命令行程序,它只有一个任务,太小了以至于没有必要并行化执行。
Concurrent and Parallel
最后,应用可以以两种方式同时并发和并行。
首先是简单的并行并发执行。即应用启动多个线程,它们在多个 CPU 上执行。
其次是应用并发执行多个任务,并且将每个任务拆分成并行执行的子任务。但是,这种情况下,某些并发和并行优势可能丧失,因为计算机中的 CPU 可能已经相当忙于单独的并发或并行任务。将两者结合可能只能提升些微性能,甚至会带来性能损失。确保在盲目使用并发并行模型前分析和测试它。
7. Single-threaded Concurrency
单线程并发 (Single-threaded Concurrency)
指在单线程内同时执行多个任务。传统上,你会使用多线程执行这些任务,让每个线程完成自己的工作。使用传统多线程并发,任务切换是操作系统完成的,CPU 会在不同线程间切换。但是,使用单线程并发技术,通过切换执行每个任务,一个线程事实上也能完成多任务执行。本章我将解释单线程并发是如何工作的,这种设计能带来哪些好处。
Single-threaded Concurrency is Still New Ground
我研究单线程并发设计始于为使用非阻塞 IO 的单线程服务设计寻找更好的线程模型,它使用 Java NIO。如 Netty,Vert.x 和 Undertow 这样的高性能 IO 工具都使用单线程服务设计。Node.JS 也使用单线程设计。据我所知,Vert.x 和 Undertow 底层使用了 Netty,因此都符合它的线程模型。
Netty 线程模型的核心概念是事件循环。一个事件循环是一个处于循环中的线程 —— 查询系统中发生的事件。当事件发生时,你的代码被调用来响应它。
尽管事件循环在某些类型系统和工作负载上表现良好,但也存在表现不佳的情况。因此我决定到别处寻找满足我需求的单线程并发模型。那么听我娓娓道来。
除了 Netty 和 Node.JS 我并未发现太多其它单线程并发设计。也许它隐藏在付费墙后的科学文章中,又或许它不是热点研究领域。
所有我不得不自己设计一些模型。我将它们发布在本篇教程中,但我怀疑随着时间推移可能会有更好的设计出现(如果它们出现我的设计会变得很奇怪)。如果你有任何想法或引用,我非常乐意学习,在社交网站上给我留言。
请注意:本文仍在完善中,不久的将来会添加更多内容。
Classic Multi-threaded Concurrency
在传统多线程并发架构中,你通常会将任务分配给独立线程执行。每个线程每次只能执行一个任务。在一些设计中,每个任务会创建一个线程,任务结束线程也会死亡。在其它设计中,会维护一个线程池,它每次从任务队列中取出一个任务执行它,然后取下一个,如果往复。阅读我的 thread pools 教程了解更多信息。
多线程架构的优势是可以相对容易地进行跨线程和 CPU 的负载分发。只要把任务交给线程,让操作系统和 CPU 调度它们。
但是,如果任务间需要共享数据,多线程架构便会出现许多并发问题,如 race conditions,deadlock,slipped conditions,nested monitor lockout 等。大体上,共享相同数据和数据结构的线程越多,发生并发问题的可能性越大。换句话说,就需要越仔细地检查你的设计。
一个经典多线程架构有时也可能导致拥塞,当多线程试图同时访问相同的数据结构时。取决于特定数据结构的实现有多优秀。当其它线程访问该数据结构时,一些线程可能会等待访问而阻塞。
Single-threaded or Same-threaded Concurrency
经典多线程架构的取代物是单线程或 同线程 并发。在单线程并发设计中,你需要自己实现任务切换。
你可以将单线程架构扩展为多线程,这些线程自身就像是单独,隔离的单线程系统。这种情况我把它叫做同线程。所有任务需要的数据被隔离在线程内部。
Single-threaded Concurrency Benefits
我认为单线程设计相比多线程有一些优势,下面是我信服的结论。
Full Thread Visibility
首先,单线程并发避免了大多数多线程并发的问题。当相同线程执行多个任务时,你避免了诸如线程可见性的并发问题,它指对共享数据结构的更新对其它线程不可见。
使用多线程并发,你必须确保正确使用了 synchronization,volatile 变量和(或)并发数据结构,确保对共享数据的更新对其它线程可见。
阅读我的 Java Memory Model 和 Java Happens Before Guarantee 了解更多 Java 线程可见性问题。
No Race Conditions
当仅有一个线程访问多任务的共享数据结构时(因为所有任务都被相同线程执行),你就能避免竞态条件问题。竞态条件发生的原因是多线程访问相同的关键代码而不保证线程访问顺序。你可以阅读 Race Conditions and Critical Sections 深入了解该问题。
Control Over Task Switching
当手动切换任务时,你能控制切换何时发生。你能确保切换前共享资源处于合理状态,也能决定达到多少负载增量(chunks)切换合理。
控制切换前负载增量的大小有利于更好控制 CPU 的使用。增量过小会导致任务切换过多。更大的增量意味着更少的切换。你想要的是减少任务切换消耗。花费在暂停任务和恢复另一个任务的 CPU 周期会被浪费。这部分 CPU 时间自身没有在应用中产生任何有用结果。你也许不想小于特定大小的任务被打扰 —— 来避免不必要的切换。
能够决定工作增量的大小也让你可以指定任务优先级。如果有两个任务要切换,你可以决定其中之一的增量是 1,而另一个的增量为 2 或者更多。这样,第二个任务就会获得更多 CPU 时间。你可以自己控制任务的优先级。
Control Over Task Prioritization
实现一个这样的单线程任务切换,你可以指定任务优先级 —— 使某些任务获得更多 CPU 时间。我将会在后面的教程中再次讨论。
Single-threaded Concurrency Challenges
使用单线程并发设计也伴随着一些挑战。下面我将挑选几个介绍。
在不损失单线程架构的简单优势和太过复杂化整体设计的前提下克服这些挑战是可能的。
Implementation Required
自己实现任务切换需要你学习如何实现这种设计,并且手动编码,这是第一个挑战。它也会增加一些代码库开销(尽管不是很大我要说)。幸运的是,你可以创建一个可重用跨应用的单线程任务切换设计,那样就能最小化实现开销。
Blocking Operations Have to be Avoided or Handled in Background Threads
如果任务需要执行阻塞 IO 操作,那么该任务和线程会一直阻塞直到 IO 完成。在此之前,线程不能切换到其它任务。
由于这种阻塞 IO 限制,有必要把其放到自己的后台线程执行,系统也因此恢复为经典多线程设计。
A Single Thread Can Only Utilize a Single CPU
单线程只能使用单个 CPU。如果你的应用运行在包含多 CPU 或多核的计算机,并且想要充分利用它们,你就不得不扩展单线程设计为同线程。这是可能的,但需要额外工作。
Single-threaded Concurrency Designs
现在让我们看看一些单线程设计,它们提供了我之前描述的功能,这样你就能明白它们如何工作,了解它们的优缺点。
Thread Loops
大多数长期运行的应用都运行于某种循环中,其中主线程时刻监听外部输入,继而处理输入,最后继续监听。
这种线程循环在服务程序(web services, services etc.)和 GUI 应用中都存在。有时线程循环被隐藏了,有时没有。
Pausing the Thread Loop
你可能好奇线程进行密集循环是否会浪费大量 CPU 时间。如果线程没有任何真正的工作要完成那确实如此。但是,执行循环的线程可以自由 “睡眠”,如果它认为睡眠几毫秒没有问题。那样的话,浪费的 CPU 时间就能减少。
Agents
通常线程循环会调用一些携带应用任务的组件。我把这些组件叫 代理 (Agent)
。代理是一个携带工作负载的类任务组件。
代理的生命周期可能不同。它可以:
- 运行于整个应用的生命周期
- 运行一个长期工作 —— 最终会结束
- 运行一个短期任务 —— 马上结束
因此,代理可以执行应用的长周期逻辑,一个由许多小任务组成的长期工作;或者一个一次性任务,它几乎马上完成。所以,代理覆盖了许多规模的任务和职责。
我倾向于使用代理这个术语,而不是工作或任务,因为它的生命周期,职责和能力可能超过了我们正常认为的单个工作或任务。我把代理看作执行工作或任务的组件。它自身并非工作或任务,尽管有时看上去类似。
Thread Loop Call Agents
通常,线程循环会重复调用代理组件 —— 把实际应用责任交给代理。这种设计分离了线程循环和代理的职责:
线程循环专注循环(重复调用代理)并检测代理何时终止,继而终止线程循环。代理的职责是执行应用逻辑自身,而非循环。
使用这种设计,你可以将不同类型的线程循环和代理结合。见下图:
Agents May Call Other Agents
一个代理可能把它的工作分给其它代理。因此,代理有不同等级的职责。下图包含一个应用级别代理和多个任务级别代理。
Single-threaded Task Switching
如果上图中的某些任务代理需要长时间执行会怎样呢?如果任务代理在被应用代理第一次调用时简单地执行全部工作,那么整个系统(线程循环和代理)将会阻塞直到第一个任务代理完成所有工作。
为了同时进行多个任务(宏观上),线程必须能够在不同任务间切换。这也被叫做 任务切换 (task switching)
。当只有一个线程时,你需要通过代理设计完成任务切换。
为了在单线程内完成任务切换,每个任务必须划分成一到多个增量:
任何时刻代理被调用,它都将执行一到多个增量。一旦增量全部被执行,整个任务就完成了。
循环不断调用代理,让它们每次执行一些增量:
Increment Size Balancing
如果单线程能够在多任务间切换,那就说明这些任务没有被过大划分。换句话说,帮助确保公平的执行时间划分是每个任务的职责。大体上说,具体的增量大小取决于具体任务和应用。
Prioritized Execution
指定某些任务由其它任务执行是可能的。你可以将参数传递给代理,告诉它执行多少工作增量。因此,一些代理可能被命令执行 1 个工作增量,而另一些可能被命令执行 2 个或更多增量。这就导致一些任务获得更多 CPU 执行时间,它们因此更快完成。
Agent Parking
如果代理在等待某些异步操作完成,例如远程服务响应,那么只有等到异步任务完成,它才能继续工作。这种情况下,一遍遍调用该代理是没有意义的,那样只是让它意识到它无法继续工作,随后立即返回到调用线程。
这种情况,如果能使代理 “停车 (Park)” 是很有意义的,那样它就不再会被调用。一旦异步任务完成,代理可以解除停车状态,并插入到活跃代理队列,活跃代理可以被持续调用将任务进行下去。当然,要能够解除停车,系统中的某些其它部分必须负责检测异步任务完成,并且计算哪个代理需要解除。
Scaling Single-threaded Concurrency
显然,如果应用中只有一个线程执行,你将无法使用超过一个 CPU。解决办法是启动多个线程。通常是每个 CPU 一个线程,取决于线程需要执行什么任务。如果你的任务需要执行阻塞 IO,例如从文件系统或网络读取文件,那么每个 CPU 可能需要多个线程。在等待阻塞 IO 完成时,线程除了等待不能做任何事情。
当你把线程扩展为多个单线程子系统时,它在技术上就不再是单线程了。但是,每个单线程子系统会被设计成并且表现成典型的单线程系统。我习惯于把这种多线程的单线程系统称作 同线程 (same-threaded) 系统,尽管我不确定最准确的术语是什么。我们可能需要重新观察不同的设计,想出更具描述性的术语。
Event Loop vs. Thread Loops
本章开头,我提到像 Netty 那样的事件循环工具与本文的线程循环有所不同。为了展示这种不同,我总结了下面两张控制流图。
第一张图是事件循环。执行事件循环的线程首先调用事件循环,当不同事件发生时,事件循环又调用你的应用代码。事件间隔的时间由事件循环代码控制,你的应用不能使用它们。
第二张图是线程循环。事件循环线程首先调用你的应用,应用调用 IO 工具检查新的入站连接,或者已有连接的入站数据,或者计时器事件。
在线程循环设计中,任何处于事件之间的时间都可以被应用使用做任何想做的事。例如,应用可以使用单线程任务切换继续调用没有完成的任务集合。
此外,如果应用有许多负载需要完成,它可以选择不检测入站连接,或读取已有连接的入站数据,以这种方式抵抗网络压力,应用便可集中精力执行当前任务。
这种可以选择何时检测事件,如何利用事件间时间的自由,就是我喜爱线程循环而非事件循环设计的原因。的确,这种区别很小,并且需要更多代码,但也给了你更多控制和弹性。
8. Creating and Starting Java Threads
一个 Java Thread
就像一个可以执行代码的虚拟 CPU —— 在你的 Java 应用中。main()
方法启动后会被主线程执行。主线程是一个特殊线程,它由 Java 虚拟机创建来运行你的应用。在应用内部,你可以创建和启动更多线程,它们可以和主线程并行执行你的代码。
Java 线程是和其它任何对象一样的 Java 对象,它是 java.lang.Thread
的实例。除了是对象,它还能执行代码。本章我将解释如何创建和启动线程。
Creating and Starting Threads
在 Java 里可以像这样创建线程:
Thread thread = new Thread();
要启动线程,你需要调用它的 start()
方法:
thread.start();
这个示例并未指定线程执行任何代码,因此启动后它会立即停止。
有两种方式指定线程要执行什么代码。首先是创建 Thread 的子类并重写它的 run()
方法。其次是传递一个 java.lang.Runnable
实例给 Thread 的构造器。下面逐一介绍。
Thread Subclass
Thread 的 run()
方法会在调用 start()
后被择机执行。下面是创建一个 Thread 子类的示例:
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("MyThread running");
}
}
你可以像下面这样创建 MyThread 实例并启动它:
MyThread myThread = new MyThread();
myThread.start();
一旦线程启动,start()
就会立即返回,它不会等待 run()
完成。run()
方法会像在其它 CPU 上一样。执行时,它会打印 "MyThread running"。
你也可以像下面这样创建一个匿名 Thread 子类:
Thread thread = new Thread() {
@Override
public void run() {
System.out.println("Thread Running");
}
};
thread.start();
run()
方法执行时,它将打印 "Thread running"。
Runnable Interface Implementation
第二种指定线程执行代码的方式是创建一个实现了 java.lang.Runnable
的类。实现了 Runnable 接口的对象可以被 Thread 类执行。
Runnable 是一个 Java 平台的的标准 Interface,它只有一个 run()
方法,下面是它的声明:
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
线程应该做的事情必须包含在实现类的 run()
方法中。有三种方式实现这个接口:
- 创建一个实现该接口的具名类
- 创建一个实现该接口的匿名类
- 创建一个实现该接口的 Lambda 表达式
我将逐一解释。
Java Class Implements Runnable
下面是一个实现 Runnable
的自定义类:
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("MyRunnable running");
}
}
该实现要完成的事是打印 MyRunnable running
。打印完字符后,run()
就会退出,线程也随之停止。
Anonymous Implementation of Runnable
下面是一个实现了 Runnable
接口的匿名类实例:
Runnable myRunnable = new Runnable() {
@Override
public void run() {
System.out.println("Runnable running");
}
};
除了匿名,它和具名类实现非常相似。
Java Lambda Implementation of Runnable
第三种实现 Runnable
接口的方式是创建 Java Lambda,因为 Runnable
接口是单方法接口,即 functional Java interface。
Runnable runnable = () -> System.out.println("Lambda Runnable running");
Starting a Thread With a Runnable
要让 run()
方法被线程执行,传递一个实现了 Runnable
接口的具名、匿名类实例或 Lambda 表达式给 Thread
构造器。就像下面这样:
// or an anonymous class, or lambda...
Runnable runnable = new MyRunnable();
Thread thread = new Thread(runnable);
thread.start();
线程执行时,它会调用 MyRunnable
实例而非自身的 run()
方法。上例会打印 "MyRunnable running"。
Subclass or Runnable?
没有规则规定哪种方式最好,它们都能工作。但我个人更喜欢实现 Runnable
接口,把实例传给 Thread
。当使用 thread pool 执行 Runnable 时,很容易将 Runnable 实例排队,直到来自该池的线程处于空闲状态。这通过 Thread
类是比较困难的。
有时你可能需要同时继承 Thread
类和继承 Runnable
接口。例如,要创建一个可以执行多个 Runnable
的 Thread
子类,这是实现线程池的典型用例。
Common Pitfall: Calling run() Instead of start()
创建和启动线程时的常见错误是像下面这样调用 Thread
的 run()
而不是 start()
方法:
Thread newThread = new Thread(MyRunnable());
newThread.run(); // should be start();
起初你可能没有注意到任何事情,因为 run()
就像你期待的那样运行了。但是,它并非由新创建的线程执行,而是创建线程的线程,也就是执行上面两行代码的线程。为了让它被新创建的线程调用,你必须调用 newThread.start()
方法。
Thread Names
创建线程时,你可以给它一个名称。名称可以帮助你区分不同线程。比如,如果有多个线程向 System.out
输出内容,通过名称你可以方便地识别出是哪个线程。下面是一个示例:
Thread thread = new Thread("New Thread") {
@Override
public void run() {
System.out.println("run by:" + getName());
}
};
thread.start();
System.out.println(thread.getName());
注意作为参数传递给 Thread 构造器的字符串 "New Thread",它就是线程的名称。可以通过 Thread.getName()
获得线程的名称。对于 Runnable
的实现类,你也能通过类似方法为线程命名,下面是一个例子:
MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable, "New Thread");
thread.start();
System.out.println(thread.getName());
但是注意,因为 MyRunnable
不是 Thread
的子类,所以它不能访问执行它的线程的 getName()
方法。
Thread.currentThread()
Thread.currentThread()
方法返回调用该方法的线程实例的引用。通过它,你可以访问执行特定代码块的线程对象。下面是使用示例:
Thread thread = Thread.currentThread();
一旦获得了线程对象的引用,你就能调用它的方法。例如,你可以获得执行当前代码线程的名称:
String threadName = Thread.currentThread().getName();
Java Thread Example
下面是一个简单示例。首先,它打印了执行 main()
方法的线程的名称。该线程由 JVM 分配。随后启动了 10 个线程,它们以数字命名。每个线程都会打印自己的名称,随后结束执行。
public class ThreadExample {
public static void main(String[] args){
System.out.println(Thread.currentThread().getName());
for(int i=0; i<10; i++){
new Thread("" + i){
public void run(){
System.out.println("Thread: " + getName() + " running");
}
}.start();
}
}
}
注意,即便这些线程以数字顺序启动(1,2,3 等),它们可能不会按顺序执行,这意味着线程 1 可能不是首先向 System.out
写入名称的线程。这是因为线程原则上并行执行,而非串行。JVM 和(或)操作系统决定线程执行的顺序。该顺序无需与启动顺序一致。
Pause a Thread
线程可以通过调用 Thread.sleep()
使自己暂停。该方法接收一个数字作为参数,含义是毫秒。它会使线程睡眠指定毫秒再恢复运行。该方法不是 100% 准确的,但相对而言还可以。下面的示例使线程暂停 3 秒(3000 毫秒):
try {
Thread.sleep(3 * 1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
Stop a Thread
停止线程需要额外的准备来实现。Thread
类包含一个 stop()
方法,但它过时了。该方法起初也不保证线程停止时它会处于何种状态。这意味着,被停止线程访问的所有对象都将处于不定状态。如果应用中的其它线程也访问了相同对象,你的应用可能会在非预料和不可预测的情况下崩溃。
替代方法是实现自己的线程停止代码。下面的示例中有一个 Runnable
实现类,它包含额外的 doStop()
方法,该方法会发出停止信号,Runnable
检测该信号在适当时候主动停止线程。
public class MyRunnable implements Runnable {
private boolean doStop = false;
public synchronized void doStop() {
this.doStop = true;
}
public synchronized boolean keepRunning() {
return !this.doStop;
}
@Override
public void run() {
while (keepRunning()) {
// keep doing what this thread should do.
System.out.println("Running");
try {
Thread.sleep(3 * 1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
注意 doStop()
和 keepRunning()
方法,前者意在被其它线程调用,后者被执行 MyRunnable.run()
的线程调用。只要 doStop()
没有被调用,keepRunning()
就会返回 true
,这意味着执行 run()
方法的线程要继续运行。
下面的例子中,一个线程启动了上述 MyRunnable
实例,在一段延迟后停止了它。
public class MyRunnableMain {
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
try {
Thread.sleep(10L * 1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
myRunnable.doStop();
}
}
本例中,main()
方法(主线程)睡眠 10 秒后调用了 MyRunnable.doStop()
方法。这会导致执行 MyRunnable
方法的线程停止,因为在此之后 keepRunning()
会返回 false
。
请记住,如果你的 Runnable
实现需要除了 run()
之外的其他方法(例如 stop()
或 pause()
),那么你就不能使用 Lambda 表达式,因为 Lambda 表达式只可以实现单方法接口。取而代之,你必须使用自定义类,或继承了 Runnable
的自定义接口,它可以包含额外方法。
9. Race Conditions and Critical Sections
竞态条件 (race condition)
是一个可能发生在临界区的并发问题。临界区 (critical section)
是一个被多线程执行的代码段,在该区域,不同线程执行顺序会导致不同结果。
如果执行临界区的多个线程的执行顺序不同会导致结果不同,就称该临界区包含竞态条件。竞态条件这个术语是个比喻,表示线程在临界区内竞速,竞速结果影响临界区的执行结果。
这听起来可能有点复杂,接下来我会详述它们。
Two Types of Race Conditions
竞态条件发生的时机是多个线程通过以下两种模式读写相同变量:
- Read-modify-write
- Check-then-act
Read-modify-write 模式指,多个线程首先读取特定变量,随后修改它的值,最后把修改写回变量。导致问题的必要条件是,新值依赖旧值。产生的问题是,如果两个线程读取变量值(到 CPU 寄存器),随后修改它(在 CPU 寄存器中),最后写回主存,结果会不合预期。后文还将详细解释。
Check-then-act 模式指,多个线程检测特定条件,例如 Map 中是否包含给定值,随后根据条件执行动作,例如把值从 Map 中取出。问题发生的条件是,如果两个线程同时检测 Map 中是否存在指定值,它们都看到该值存在,随后都试图拿走(remove)该值。但是,只有其中之一可以真正拿到值,另一个会得到 null。使用队列时也会发生这种情况。
Read-Modify-Write Critical Sections
如上所说,一个读改写临界区可能导致竞态条件。本节我们将近距离观察它为何会发生。下面是一个读改写临界区代码示例,多个线程同时执行它时会产生错误。
public class Counter {
protected long count = 0;
public void add(long value) {
this.count = this.count + value;
}
}
想象有两个线程 A 和 B 正在执行同一个 Counter
实例的 add 方法。我们无法知道操作系统何时会切换两个线程。add()
方法中的代码并非被 JVM 以原子操作执行,而是一组更小的指令集合,类似下面这样:
- 把
this.count
从内存读到寄存器。 - 在寄存器中执行加法。
- 把结果从寄存器写回内存。
以下面这种顺序混合执行线程 A 和 B,观察会发生什么:
/**
this.count = 0;
A: Reads this.count into a register (0)
B: Reads this.count into a register (0)
B: Adds value 2 to register
B: Writes register value (2) back to memory. this.count now equals 2
A: Adds value 3 to register
A: Writes register value (3) back to memory. this.count now equals 3
*/
两个线程想要把 2 和 3 加到计数器,因此最后的值应该是 5。但由于两个线程交织执行,实际结果与期望不符。
在上面的执行顺序示例中,两个线程都从内存读到了 0。随后它们独立把自己的值 2 和 3 加到计数器上,最后把结果写回内存。结果不是 5,取而代之是最后一个写值的线程写入的值。上例中它是线程 A,但客观上也可能是线程 B。
Race Conditions in Read-Modify-Write Critical Sections
之前 add()
方法中的代码包含一个临界区。当多线程执行临界区时,竞态条件就发生了。
更正式的说,当两个线程竞争相同资源,资源被访问的顺序非常重要时,这种情况叫竞态条件。导致竞态条件的代码段称为临界区。
Check-Then-Act Critical Sections
同样如上所说,检测行动模型也会导致竞态条件。如果两个线程检测相同条件,根据它执行动作,此动作会改变条件,此时竞态条件就会发生。如果两个线程同时检测条件,其中之一首先改变了条件,这将导致另一线程表现异常。
要解释检测行动模型为何会导致竞态条件,看下面这个示例:
public class CheckThenActExample {
public void checkThenAct(Map<String, String> sharedMap) {
if (sharedMap.containsKey("key")) {
String val = sharedMap.remove("key");
if (val == null) {
System.out.println("Value for 'key' was null");
}
} else {
sharedMap.put("key", "value");
}
}
}
如果多个线程调用同一 CheckThenActExample 对象的 checkThenAct()
方法,且超过两个线程同时执行 sharedMap.containsKey("key")
得到 true
,因此进入 if 语句块内部。在那儿,它们都试图移除键为 key
的键值对,但只有一个线程能执行成功,其它线程都会得到 null
值。
Preventing Race Conditions
要防止竞态条件发生,你必须确保临界区作为原子指令执行。那意味着一旦有线程执行它,其它线程必须等待该线程离开临界区才能执行。
竞态条件可以通过恰当的线程同步避免。使用 synchronized block of Java code 可以同步线程,像 locks 或原子变量,如 java.util.concurrent.atomic.AtomicInteger 一样的同步结构也可以实现线程同步。
Critical Section Throughput
对于较小的临界区,让其全部成为同步块就能工作。但对于较大的那些,把它们拆分成多个较小区域比较有益,这样可以让多个线程执行每个小临界区。原因是它可以减少共享资源竞争,因此增加整个临界区的并发量。
下面是一段非常简单的示例代码:
public class TwoSums {
private int sum1 = 0;
private int sum2 = 0;
public void add(int val1, int val2) {
synchronized (this) {
this.sum1 += val1;
this.sum2 += val2;
}
}
}
注意 add()
方法是如何把值加到两个不同求和成员变量的。为了防止竞态条件,求和只能在同步块里执行。在这种实现下,一次只有一个线程可以执行该方法。
但是,由于两个求和变量是互相独立的,你可以把它们拆分成两个独立同步块,就像下面这样:
public class TwoSums {
private int sum1 = 0;
private int sum2 = 0;
private final Integer sum1Lock = new Integer(1);
private final Integer sum2Lock = new Integer(2);
public void add(int val1, int val2) {
synchronized (this.sum1Lock) {
this.sum1 += val1;
}
synchronized (this.sum2Lock) {
this.sum2 += val2;
}
}
}
现在两个线程就可以同时执行 add()
方法了。其中之一处于第一个同步块,另一个处于第二个。两个同步块对不同对象执行同步,所以两个不同线程可以独立执行它们。这种方式线程需要互相等待的时间就会减少。
当然,本示例非常简单。真实的共享资源案例中,拆分临界区可能非常困难,并且需要对执行顺序的可能性做更多分析。
10. Thread Safety and Shared Resources
可以安全地同时被多个线程调用的代码被认为是 线程安全 (thread safe)
的。如果一段代码线程安全,那它就不包含 竞态条件。竞态条件仅在多线程更新共享资源时发生。因此搞清楚什么资源在执行时会被线程共享是很重要的。
Local Variables
局部变量储存在线程自己的栈中。这意味着它们永远不会被多线程共享。这也意味着所有局部基本变量都是线程安全的。下面是一个线程安全的局部基本变量示例:
public void someMethod() {
long threadSafeInt = 0;
threadSafeInt++;
}
Local Object References
局部对象引用有点不同。引用自身不是共享的,但被引用的对象并非存储在线程栈。所有对象都存储在共享堆上。
如果一个局部变量永远不会从创建方法逃出,它就是线程安全的。事实上,你也可以把它传给其它方法或对象,只要这些方法和对象不会把该对象传给其它线程。
下面是一个线程安全的局部对象示例:
public void someMethod() {
LocalObject localObject = new LocalObject();
localObject.callMethod();
method2(localObject);
}
public void method2(LocalObject localObject) {
localObject.setValue("value");
}
每个执行 someMethod()
的线程都会创建自己的 LocalObject
实例,并把它赋值给 localObject
引用。即使 LocalObject
实例被作为参数传给同一类的其它方法,或者其它类,因为这些方法和类是线程安全的,所以这里的 LocalObject
是线程安全的。
当然,唯一例外是,存在一个把 LocalObject
当成参数的方法,把该实例存储到允许其它线程访问的地方。
Object Member Variables
对象的成员变量(属性)和对象一样存储在堆中。因此,如果两个线程调用同一对象实例的某个方法,并且该方法更新对象的成员变量,那么该方法就不是线程安全的。下面是一个实例:
public class NotThreadSafe {
StringBuilder builder = new StringBuilder();
public void add(String text) {
this.builder.append(text);
}
}
如果两个线程同时调用相同 NotThreadSafe
实例的 add()
方法,它将导致竞态条件。例如:
public class MyRunnable implements Runnable {
NotThreadSafe instance = null;
public MyRunnable(NotThreadSafe instance) {
this.instance = instance;
}
@Override
public void run() {
this.instance.add("some text");
}
public static void main(String[] args) {
NotThreadSafe sharedInstance = new NotThreadSafe();
new Thread(new MyRunnable(sharedInstance)).start();
new Thread(new MyRunnable(sharedInstance)).start();
}
}
注意 MyRunnable
是如何共享相同 NotThreadSafe
实例的。这种情况,当它们调用 add()
方法时,竞态条件就发生了。
但是,如果两个线程调用不同实例的 add()
方法就不会发生竞态条件。下面的示例和之前类似,但稍有不同:
new Thread(new MyRunnable(new NotThreadSafe())).start();
new Thread(new MyRunnable(new NotThreadSafe())).start();
现在,两个线程拥有自己的 NotThreadSafe
实例,所以它们调用 add 时彼此互不干扰。现在代码不会发生竞态条件。所以,即便一个对象不是线程安全的,它仍然可被以不会导致竞态条件的方式使用。
The Thread Control Escape Rule
要评估你的代码对特定资源的访问是否是线程安全的,可以使用线程失控规则:
/**
* thread control escape rule
*
* If a resource is created, used and disposed within
* the control of the same thread, and never escape the
* control of this thread, the use of that resource is
* thread safe.
*/
资源可以是任何共享资源,像对象,数组,文件,数据库连接,套接字等。在 Java 中,你总是不显示释放对象,所以 "disposed" 意味着失去或者 nulling 对象的引用。
即便一个对象的使用是线程安全的,如果它指向一个共享资源,如文件或数据库,那么你的应用整体上也不是线程安全的。例如,如果线程 1 和 2 创建了自己的数据库连接,连接 1 和 2,对每个连接的使用是线程安全的。但使用连接指向的数据库可能不是线程安全的。例如,如果两个线程都执行下面的代码:
check if record X exists
if not, insert record X
如果两个线程同时执行上述代码,并且它们检查的 X 是相同记录,那么两个线程都执行插入操作最终会导致危险。下面是解释:
Thread 1 checks if record X exists. Result = no
Thread 2 checks if record X exists. Result = no
Thread 1 inserts record X
Thread 2 inserts record X
这对于操纵文件或其它共享资源的线程也是同样的。因此区分被线程控制的对象是资源,还是仅仅是资源的引用(如数据库连接)是非常重要的。
11. Thread Safety and Immutability
只有多线程访问相同资源,并且有线程修改该资源时才会发生 竞态条件。如果多个线程只是读取相同资源,竞态条件不会发生。
通过使线程共享对象不可变,我们可以确保它永远不会被任何线程更新,因此是线程安全的。下面是一个示例:
public class ImmutableValue {
private int value = 0;
public ImmutableValue(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
注意 ImmutableValue
实例的值是通过构造器传递的,并且它没有 setter 方法。一旦实例被创建,你就不能更改它的值,它是不可变的。但是你可以使用 getValue()
方法获得它的值。
你可以通过返回新实例的方式,在该实例上执行操作。下面是加法操作示例:
public ImmutableValue add(int valueToAdd) {
return new ImmutableValue(this.value + valueToAdd);
}
注意到,并非在自身的值上进行加法,而是把加法结果通过新实例返回。
The Reference is not Thread Safe
需要记住,即便对象是不可变的,因此是线程安全的,但它的引用可能不是线程安全的。看下面的示例:
public class Calculator {
private ImmutableValue currentValue = null;
public ImmutableValue getCurrentValue() {
return currentValue;
}
public void setCurrentValue(ImmutableValue currentValue) {
this.currentValue = currentValue;
}
public void add(int newValue) {
this.currentValue = this.currentValue.add(newValue);
}
}
Calculator
类持有一个 ImmutableValue
实例,注意到可以通过 setValue()
和 add()
方法改变该引用。因此,即使它内部使用了一个不可变对象,但它自身可以改变,因此不是线程安全的。换句话说,ImmutableValue
类是线程安全的,但使用它并不安全。通过不可变获得线程安全时要谨记这一点。
要使 Calculator
类线程安全,你可以把 setValue()
,getValue()
和 add()
都声明为 synchronized
。那样便可取得效果。
12. Java Memory Model
Java 内存模型指定了 Java 虚拟机如何使用计算机内存。Java 虚拟机是完备的计算机模型,所以它自然包含内存模型 —— 也就是 Java 内存模型。
想要正确设计表现良好的并发程序,搞懂 Java 内存模型非常重要。Java 内存模型指定了不同线程怎样以及何时能看到被其它线程写入的共享变量,在必要时如何同步对共享变量的访问。
原始的 Java 内存模型是不完备的,因此 Java 1.5 对其进行了改进,这一版本在今天 (Java 14+)仍被使用。
The Internal Java Memory Model
JVM 内部使用的内存模型分为线程栈和堆。下图展示了它的逻辑视图:
Java 虚拟机内运行的每个线程都有自己的线程栈。线程栈包含线程调用的方法信息,它标示了线程如何执行到当前点。我喜欢把它叫做 “调用栈”。随着线程执行其代码,调用栈随之改变。
线程栈也包含每个被执行方法(调用栈的所有方法)的所有局部变量。线程只可访问它自己的线程栈。线程创建的局部变量对其它所有线程都不可见除了自己。即使两个线程执行完全相同的代码,它们仍然会在各自线程栈中创建自己的局部变量。因此,每个线程保存着自己的局部变量版本。
所有基本类型(boolean, byte, short, char, int, long, float, double)局部变量可以完全存储于线程栈,因此对其它线程不可见。线程可以传递一份基本类型变量拷贝给其他线程,但它们不能分享基本类型局部变量自身。
堆上包含 Java 应用创建的所有对象,无论哪个线程创建了它。这包含基本类型版本的包装类(Byte,Integer,Long 等)。无论对象被创建和赋值给局部变量,还是作为其它对象的成员变量,它们都储存于堆。
下面的图表中,调用栈和局部变量存储在线程栈,对象储存于堆:
局部变量只要是基本类型,它们都储存在线程栈。
它也可能是对象引用。此时引用(局部变量)仍然存储在线程栈,但对象自身存储于堆。
一个对象可能包含方法,这些方法可能包含局部变量。这些局部变量仍然存储于线程栈,尽管方法所属对象存储于堆。
对象的成员变量和对象一起存储于堆。无论成员变量是基本类型还是引用类型。
静态变量也和类定义一起存储于堆。
堆上的对象可以被所有持有它引用的线程访问。当线程访问对象时,它也可以访问该对象的成员变量。如果两个线程同时调用相同对象的方法,它们都能访问到对象的成员变量,但每个线程会持有局部变量的拷贝。
下面这张图展示了上述情况:
两个线程都有自己的局部变量集合。其中一个局部变量(Local Variable 2)指向堆上的共享对象(Object 3)。两个线程对相同对象持有的引用不同。引用自身是局部变量,因此存储在各自的线程栈。但两个不同引用都指向堆上同一个对象。
注意到共享对象(Object 3)持有 Object 2 和 Object 4 的引用,这两个对象是 Object 3 的成员变量(Object 3 两侧的箭头指向了 Object 2 和 Object 4)。通过 Object 3 的成员变量引用,两个线程都能访问 Object 2 和 Object 4。
图中还显示了一个指向堆上两个不同对象的局部变量(Object 1 和 Object 5)。理论上,两个线程都可以访问 Object 1 和 Object 5,如果它们具有两个对象的引用。但图中每个线程只可以访问其中一个对象。
所以,什么样的 Java 代码会导致上图中的内存布局?实际上,代码就像下面这样简单:
public class MySharedObject {
// static variable pointing to instance of MySharedObject
public static final MySharedObject sharedInstance = new MySharedObject();
// member variables pointing to two objects on the heap
public Integer object2 = new Integer(22);
public Integer object4 = new Integer(44);
public long member1 = 12345;
public long member2 = 67890;
}
public class MyRunnable implements Runnable {
@Override
public void run() {
methodOne();
}
public void methodOne() {
int localVariable1 = 45;
MySharedObject localVariable2 = MySharedObject.sharedInstance;
// ... do more with local variable.
methodTwo();
}
public void methodTwo() {
Integer localVariable1 = new Integer(99);
// ... do more with local variable.
}
}
如果有两个线程执行 run()
方法,图表中的结果就出现了。run()
方法调用了 methodOne()
,methodOne()
调用了 methodTwo()
。
methodOne()
声明了一个基本类型局部变量(int 型的 localVariable1
),和一个引用类型局部变量(localVariable2
)。
每个执行 methodOne()
的线程都会在各自线程栈上创建 localVariable1
和 localVariable2
拷贝。localVariable1
会完全独立,只存活于各自线程的栈中。一个线程对自己 localVariable1
的拷贝对另一个线程不可见。
执行 methodOne()
的每个线程也会创建自己的 localVariable2
拷贝。但这两份不同的拷贝最终都指向堆上的同一个对象。localVariable2
指向的是一个被静态变量引用的对象。静态变量的拷贝只有一份,它存储在堆上。因此,两份 localVariable2
拷贝最终都指向相同的实例 MySharedObject
,该实例又被静态变量引用,并且也存储于堆。它对应上图中的 Object 3。
注意到,MySharedObject
类还包含两个成员变量。成员变量自身和对象一起存储于堆。两个成员变量指向两个其它 Integer
对象,对应上图中的 Object 2 和 Object 4。
注意到 methodTwo()
创建了一个名为 localVariable1
的局部变量。该变量指向一个 Integer
对象。localVariable1
的引用将被存储在每个线程的拷贝中。两个 Integer
对象会被存储到堆上,但因为方法每次都会创建一个新对象,两个线程创建独立的 Integer
实例,对应上图中的 Object 1 和 Object 5。
还注意到 MySharedObject
类还有两个类型为 long
的成员变量,它们是基本类型。由于它们是成员变量,因此也会和对象一起存储到堆中。只有局部变量会存储到线程栈。
Hardware Memory Architecture
现代硬件内存架构与 Java 内存模型有所不同。要搞懂两者如何搭配工作,理解硬件内存架构非常重要。本节介绍常见硬件内存架构,下节描述 Java 内存模型如何与其搭配工作。
下图简单展示了现代计算机硬件结构:
现代计算机通常包含 2 到多个 CPU,这些 CPU 有时还有多核。重点是,这种计算机允许同时运行多个线程。单个 CPU 能够每次执行一个线程。这意味着如果你的应用是多线程的,每个 CPU 上可能都有一个线程,这些线程在应用中同时执行。
每个 CPU 都包含一套寄存器,它们基本上是 CPU 内部的内存。CPU 在寄存器上执行操作比在内存中快。这是因为 CPU 访问寄存器的速度快于访问主存。
每个 CPU 还可以有一个 CPU 缓存层。事实上,大多数现代 CPU 都包含一定大小的缓存层。CPU 访问缓存的速度大于主存,但通常小于访问内部寄存器。所以,CPU 缓存的速度介于内部寄存器和主存之间。某些 CPU 可能有多个缓存层(Level 1 和 Level 2),但这对于理解 Java 内存模型如何与硬件内存交互并不重要。重要的是要知道 CPU 包含某种缓存层。
每台计算机还包含一个主内存区(RAM)。所有 CPU 都可以访问主存。主存尺寸通常远比 CPU 的缓存大。
通常,当 CPU 需要访问主存时,它会把主存中的部分数据读到 CPU 缓存。它甚至会把部分缓存读到内部寄存器,在寄存器上执行操作。当 CPU 需要把结果写回主存时,它会从内部寄存器将值刷新到缓存,并且在恰当时刻再把值刷回主存。
当 CPU 需要在缓存中储存其它东西,通常会将缓存中的值刷新回主存。CPU 可以一次写入和刷新部分缓存,不需要读写整个缓存。通常缓存以名为 “缓存行” 的小内存块更新。一到多个缓存行可能被读进缓存,或者被刷回主存。
Bridging The Gap Between The Java Memory Model And The Hardware Memory Architecture
就像之前提到的,Java 内存模型和硬件内存架构有所不同。硬件内存架构不区分线程栈和堆。硬件层面,线程栈和堆都位于主存。部分线程栈和堆有时可能存在于 CPU 缓存和 CPU 内部寄存器。下图阐述了这种情况: