Files
OnJava8/docs/book/24-Concurrent-Programming.md
2019-07-19 10:29:30 +08:00

356 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
[TOC]
<!-- Concurrent Programming -->
# 第二十四章 并发编程
>爱丽丝:“但是我不想进入疯狂的人群众”
>
>猫咪“oh你无能为力我们都疯了我疯了你也疯了”
>
>爱丽丝:“你怎么知道我疯了”。
>
>猫咪:“你一定疯了,否则你不会来到这里”——爱丽丝梦游仙境 第6章。
到目前为止我们一直在编程就像文学中的意识流叙事设备一样首先发生一件事然后是下一件事。我们完全控制所有步骤及其发生的顺序。如果我们将值设置为5那么稍后会回来并发现它是47这将是非常令人惊讶的。
我们现在进入了一个奇怪的并发世界,在此这个结果并不令人惊讶。你信赖的一切都不再可靠。它可能有效,也可能没有。很可能它会在某些条件下有效,而不是在其他条件下,你必须知道和了解这些情况以确定哪些有效。
作为类比,你的正常生活是在牛顿力学中发生的。物体具有质量:它们会下降并移动它们的动量。电线具有阻力,过线可以直线传播。但是,如果你进入非常小、热、冷、或者大的世界(我们不能生存),这些事情会发生变化。我们无法判断某物体是粒子还是波,光是否受到重力影响,一些物质变为超导体。
而不是单一的意识流叙事,我们在同时多条故事线进行的间谍小说里。一个间谍在一个特殊的岩石下李璐下微缩胶片,当第二个间谍来取回包裹时,它可能已经被第三个间谍带走了。但是这部特别的小说并没有把事情搞得一团糟;你可以轻松地走到尽头,永远不会弄明白什么。
构建并发应用程序非常类似于游戏[Jenga](https://en.wikipedia.org/wiki/Jenga),每当你拉出一个块并将其放置在塔上时,一切都会崩溃。每个塔楼和每个应用程序都是独一无二的,有自己的作用。您从构建系统中学到的东西可能不适用于下一个系统。
本章是对并发性的一个非常基本的介绍。虽然我使用了最现代的Java 8工具来演示原理但这一章远非对该主题的全面处理。我的目标是为你提供足够的基础知识使你能够解决问题的复杂性和危险性从而安全的通过这些鲨鱼肆虐的困难水域。
对于更多凌乱,低级别的细节,请参阅附录:[并发底层原理](./Appendix-Low-Level-Concurrency.md)。要进一步深入这个领域你还必须阅读Brian Goetz等人的Java Concurrency in Practice。虽然在写作时这本书已有十多年的历史但它仍然包含你必须了解和理解的必需品。理想情况下本章和附录是该书的精心准备。另一个有价值的资源是**Bill Venner**的Inside the Java Virtual Machine它详细描述了JVM的最内部工作方式包括线程。
<!-- The Terminology Problem -->
## 术语问题
在编程文献中并发、并行、多任务、多处理、多线程、分布式系统以及可能的其他使用了许多相互冲突的方式并且经常被混淆。Brian Goetz在2016年的演讲中指出了这一点[From Concurrent to Parallel](https://www.youtube.com/watch?v=NsDE7E8sIdQ),他提出了一个合理的解释:
- 并发是关于正确有效地控制对共享资源的访问。
- 并行是使用额外的资源来更快地产生结果。
这些都是很好的定义但有几十年的混乱产生了反对解决问题的历史。一般来说当人们使用“并发”这个词时他们的意思是“一切变得混乱”事实上我可能会在很多地方自己陷入这种想法大多数书籍包括Brian Goetz的Java Concurrency in Practice都在标题中使用这个词。
并发通常意味着“不止一个任务正在执行中”,而并行性几乎总是意味着“不止一个任务同时执行。”你可以立即看到这些定义的区别:并行也有不止一个任务“正在进行”。区别在于细节,究竟是如何“执行”发生的。此外,重叠:为并行编写的程序有时可以在单个处理器上运行,而一些并发编程系统可以利用多个处理器。
这是另一种方法,在减速[原文slowdown]发生的地方写下定义:
_并发_
同时完成多个任务。在开始处理其他任务之前当前任务不需要完成。并发解决了阻塞发生的问题。当任务无法进一步执行直到外部环境发生变化时才会继续执行。最常见的例子是I/O其中任务必须等待一些input在这种情况下会被阻止。这个问题产生在I/O密集型。
_并行_
同时在多个地方完成多个任务。这解决了所谓的计算密集型问题,如果将程序分成多个部分并在不同的处理器上编辑不同的部分,程序可以运行得更快。
术语混淆的原因在上面的定义中显示其中核心是“在同一时间完成多个任务。”并行性通过多个处理器增加分布。更重要的是两者解决了不同类型的问题解决I/O密集型问题并行化可能对你没有任何好处因为问题不是整体速度而是阻塞。并且考虑到计算力限制问题并试图在单个处理器上使用并发来解决它可能会浪费时间。两种方法都试图在更短的时间内完成更多但它们实现加速的方式是不同的并且取决于问题所带来的约束。
这两个概念混合在一起的一个主要原因是包括Java在内的许多编程语言使用相同的机制**线程**来实现并发和并行。
我们甚至可以尝试添加细致的粒度去定义(但是,这不是标准化的术语):
- **纯并发**任务仍然在单个CPU上运行。纯并发系统产生的结果比顺序系统更快但如果有更多的处理器则运行速度不会更快
- **并发-并行**:使用并发技术,结果程序利用更多处理器并更快地生成结果
- **并行-并发**使用并行编程技术编写如果只有一个处理器结果程序仍然可以运行Java 8 **Streams**就是一个很好的例子)。
- **纯并行**:除非有多个处理器,否则不会运行。
在某些情况下,这可能是一个有用的分类法。
对并发性的语言和库支持似乎[Leaky Abstraction](https://en.wikipedia.org/wiki/Leaky_abstraction)是完美候选者。抽象的目标是“抽象出”那些对于手头想法不重要的东西,从不必要的细节中汲取灵感。如果抽象是漏洞,那些碎片和细节会不断重新声明自己是重要的,无论你试图隐藏它们多少
我开始怀疑是否真的有高度抽象。当编写这些类型的程序时你永远不会被底层系统和工具屏蔽甚至关于CPU缓存如何工作的细节。最后如果你非常小心你创作的东西在特定的情况下起作用但它在其他情况下不起作用。有时区别在于两台机器的配置方式或者程序的估计负载。这不是Java特有的-它是并发和并行编程的本质。
您可能会认为[纯函数式](https://en.wikipedia.org/wiki/Purely_functional)语言没有这些限制。实际上,纯函数式语言解决了大量并发问题,所以如果你正在解决一个困难的并发问题,你可以考虑用纯函数语言编写这个部分。但最终,如果你编写一个使用队列的系统,例如,如果它没有正确调整并且输入速率要么没有被正确估计或被限制(并且限制意味着,在不同情况下不同的东西具有不同的影响),该队列将填满并阻塞或溢出。最后,您必须了解所有细节,任何问题都可能会破坏您的系统。这是一种非常不同的编程方式
<!-- A New Definition ofConcurrencyFor -->
### 并发的新定义
几十年来,我一直在努力解决各种形式的并发问题,其中一个最大的挑战一直是简单地定义它。在撰写本章的过程中,我终于有了这样的洞察力,我认为可以定义它:
>**并发性是一系列性能技术,专注于减少等待**
这实际上是一个相当多的声明,所以我将其分解:
- 这是一个集合:有许多不同的方法来解决这个问题。这是使定义并发性如此具有挑战性的问题之一,因为技术差别很大
- 这些是性能技术就是这样。并发的关键点在于让您的程序运行得更快。在Java中并发是非常棘手和困难的所以绝对不要使用它除非你有一个重大的性能问题 - 即使这样,使用最简单的方法产生你需要的性能,因为并发很快变得无法管理。
- “减少等待”部分很重要而且微妙。无论例如你运行多少个处理器你只能在等待某个地方时产生结果。如果您发起I/O请求并立即获得结果没有延迟因此无需改进。如果您在多个处理器上运行多个任务并且每个处理器都以满容量运行并且任何其他任务都没有等待那么尝试提高吞吐量是没有意义的。并发的唯一形式是如果程序的某些部分被迫等待。等待可以以多种形式出现 - 这解释了为什么存在如此不同的并发方法。
值得强调的是,这个定义的有效性取决于等待这个词。如果没有什么可以等待,那就没有机会了。如果有什么东西在等待,那么就会有很多方法可以加快速度,这取决于多种因素,包括系统运行的配置,你要解决的问题类型以及其他许多问题。
<!-- Concurrency Superpowers -->
## 并发的超能力
想象一下,你是一部科幻电影。您必须在高层建筑中搜索一个精心巧妙地隐藏在建筑物的一千万个房间之一中的单个物品。您进入建筑物并移动走廊。走廊分开了。
你自己完成这项任务需要一百个生命周期。
现在假设你有一个奇怪的超级大国。你可以将自己分开,然后在继续前进的同时将另一半送到另一个走廊。每当你在走廊或楼梯上遇到分隔到下一层时,你都会重复这个分裂的技巧。最后,你会有一个人在整个建筑物的每个终点走廊。
每个走廊都有一千个房间。你的超级大国正在变得有点瘦所以你只能让自己50个人同时搜索房间。
一旦克隆体进入房间,它必须搜索房间的所有裂缝和隐藏的口袋。它切换到第二个超级大国。它分成了一百万个纳米机器人,每个机器人都会飞到或爬到房间里一些看不见的地方。你不明白这种力量 - 一旦你启动它就会起作用。在他们自己的控制下,纳米机器人开始行动,搜索房间然后回来重新组装成你,突然,不知何故,你只知道物品是否在房间里
我很想能够说,“你在科幻小说中的超级大国?这就是并发性。“每当你有更多的任务要解决时,它就像分裂两个一样简单。问题是我们用来描述这种现象的任何模型最终都是抽象的
以下是其中一个漏洞:在理想的世界中,每次克隆自己时,您还会复制硬件处理器来运行该克隆。但当然不会发生这种情况 - 您的机器上可能有四个或八个处理器(通常在写入时)。您可能还有更多,并且仍有许多情况只有一个处理器。在抽象的讨论中,物理处理器的分配方式不仅可以泄漏,甚至可以支配您的决策
让我们在科幻电影中改变一些东西。现在当每个克隆搜索者最终到达一扇门时,他们必须敲门并等到有人回答。如果我们每个搜索者有一个处理器,这没有问题 - 处理器只是空闲直到门被回答。但是如果我们只有8个处理器和数千个搜索者那么只是因为搜索者恰好是因为处理器闲置了被锁等待一扇门被接听。相反我们希望将处理器应用于搜索在那里它可以做一些真正的工作因此需要将处理器从一个任务切换到另一个任务的机制。
许多型号能够有效地隐藏处理器的数量,并允许您假装您的数量非常大。但是有些情况会发生故障的时候,你必须知道处理器的数量,以便你可以解决这个问题。
其中一个最大的影响取决于您是单个处理器还是多个处理器。如果你只有一个处理器,那么任务切换的成本也由该处理器承担,将并发技术应用于你的系统会使它运行得更慢。
这可能会让您决定,在单个处理器的情况下,编写并发代码时没有意义。然而,有些情况下,并发模型会产生更简单的代码,实际上值得让它运行得更慢以实现。
在克隆体敲门等待的情况下,即使单处理器系统也能从并发中受益,因为它可以从等待(阻塞)的任务切换到准备好的任务。但是如果所有任务都可以一直运行那么切换的成本会降低一切,在这种情况下,如果你有多个进程,并发通常只会有意义。
在接听电话的客户服务部门,你只有一定数量的人,但是你可以拨打很多电话。那些人(处理器)必须一次拨打一个电话,直到完成电话和额外的电话必须排队。
在“鞋匠和精灵”的童话故事中,鞋匠做了很多工作,当他睡着时,一群精灵来为他制作鞋子。这里的工作是分布式的,但即使使用大量的物理处理器,在制造鞋子的某些部件时会产生限制 - 例如,如果鞋底需要制作鞋子,这会限制制鞋的速度并改变您设计解决方案的方式。
因此,您尝试解决的问题驱动解决方案的设计。打破一个“独立运行”问题的高级[原文lovely ]抽象,然后就是实际发生的现实。物理现实不断侵入和震撼,这种抽象。
这只是问题的一部分。考虑一个制作蛋糕的工厂。我们不知何故在工人中分发了蛋糕制作任务,但是现在是时候让工人把蛋糕放在盒子里了。那里有一个盒子,准备收到蛋糕。但是,在工人将蛋糕放入盒子之前,另一名工人投入并将蛋糕放入盒子中!我们的工人已经把蛋糕放进去了,然后就开始了!这两个蛋糕被砸碎并毁了。这是常见的“共享内存”问题,产生我们称之为竞争条件的问题,其结果取决于哪个工作人员可以首先在框中获取蛋糕(通常使用锁定机制来解决问题,因此一个工作人员可以先抓住框并防止蛋糕砸)。
当“同时”执行的任务相互干扰时,会出现问题。他可以以如此微妙和偶然的方式发生,可能公平地说,并发性“可以说是确定性的,但实际上是非确定性的。”也就是说,你可以假设编写通过维护和代码检查正常工作的并发程序。然而,在实践中,编写仅看起来可行的并发程序更为常见,但是在适当的条件下,将会失败。这些情况可能会发生,或者很少发生,你在测试期间从未看到它们。实际上,编写测试代码通常无法为并发程序生成故障条件。由此产生的失败只会偶尔发生,因此它们以客户投诉的形式出现。
这是推动并发的最强有力的论据之一:如果你忽略它,你可能会被咬。
因此并发似乎充满了危险如果这让你有点害怕这可能是一件好事。尽管Java 8在并发性方面做出了很大改进但仍然没有像编译时验证或检查异常那样的安全网来告诉您何时出现错误。通过并发您可以自己动手只有知识渊博可疑和积极才能用Java编写可靠的并发代码。
<!-- Concurrency is for Speed -->
<!-- 不知道是否可以找到之前翻译的针对速度感觉太直了 -->
## 并发为速度而生
在听说并发编程的问题之后,你可能会想知道它是否值得这么麻烦。答案是“不,除非你的程序运行速度不够快。”并且在决定它没有之前你会想要仔细思考。不要随便跳进并发编程的悲痛之中。如果有一种方法可以在更快的机器上运行您的程序,或者如果您可以对其进行分析并发现瓶颈并在该位置交换更快的算法,那么请执行此操作。只有在显然没有其他选择时才开始使用并发,然后仅在孤立的地方。
速度问题一开始听起来很简单:如果你想要一个程序运行得更快,将其分解成碎片并在一个单独的处理器上运行每个部分。由于我们能够提高时钟速度流(至少对于传统芯片),速度的提高是出现在多核处理器的形式而不是更快的芯片。为了使你的程序运行得更快,你必须学习利用那些超级处理器,这是并发性给你的一个建议。
使用多处理器机器可以在这些处理器之间分配多个任务这可以显着提高吞吐量。强大的多处理器Web服务器通常就是这种情况它可以在程序中为CPU分配大量用户请求每个请求分配一个线程。
但是,并发性通常可以提高在单个处理器上运行的程序的性能。这听起来像是一个双向的。如果考虑一下,由于上下文切换的成本增加(从一个任务更改为另一个任务),在单个处理器上运行的并发程序实际上应该比程序的所有部分顺序运行具有更多的开销。在表面上,将程序的所有部分作为单个任务运行并节省上下文切换的成本似乎更便宜。
可以产生影响的问题是阻塞。如果你的程序中的一个任务由于程序控制之外的某些条件通常是I/O而无法继续我们会说任务或线程阻塞在我们的科幻故事中克隆体已敲门而且是等待它打开。如果没有并发性整个程序就会停止直到外部条件发生变化。但是如果使用并发编写程序则当一个任务被阻止时程序中的其他任务可以继续执行因此程序继续向前移动。实际上从性能的角度来看在单处理器机器上使用并发是没有意义的除非其中一个任务可能阻塞。
单处理器系统中性能改进的一个常见例子是事件驱动编程,特别是用户界面编程。考虑一个程序执行一些长时间运行操作,从而最终忽略用户输入和无响应。如果你有一个“退出”按钮,你不想在你编写的每段代码中轮询它。这会产生笨拙的代码,无法保证程序员不会忘记执行检查。没有并发性,生成响应式用户界面的唯一方法是让所有任务定期检查用户输入。通过创建单独的执行线程来响应用户输入,该程序保证了一定程度的响应。
实现并发的直接方法是在操作系统级别使用与线程不同的进程。进程是一个在自己的地址空间内运行的自包含程序。进程很有吸引力因为操作系统通常将一个进程与另一个进程隔离因此它们不会相互干扰这使得进程编程相对容易。相比之下线程共享内存和I/O等资源因此编写多线程程序时遇到的困难是在不同的线程驱动的任务之间协调这些资源一次不能通过多个任务访问它们。
<!-- 文献引用未加,因为暂时没看到很好的解决办法 -->
有些人甚至提倡将进程作为并发的唯一合理方法[^1],但不幸的是,通常存在数量和开销限制,以防止它们在并发频谱中的适用性(最终你习惯了标准的并发性克制,“这种方法适用于一些情况但不适用于其他情况”)
一些编程语言旨在将并发任务彼此隔离。这些通常被称为_函数式语言_其中每个函数调用不产生其他影响因此不能与其他函数干涉因此可以作为独立的任务来驱动。Erlang就是这样一种语言它包括一个任务与另一个任务进行通信的安全机制。如果您发现程序的一部分必须大量使用并发性并且您在尝试构建该部分时遇到了过多的问题那么您可能会考虑使用专用并发语言创建程序的那一部分。
<!-- 文献标记 -->
Java采用了更传统的方法[^2],即在顺序语言之上添加对线程的支持而不是在多任务操作系统中分配外部进程,线程在执行程序所代表的单个进程中创建任务交换。
并发性会带来成本,包括复杂性成本,但可以通过程序设计,资源平衡和用户便利性的改进来抵消。通常,并发性使您能够创建更加松散耦合的设计;否则,您的代码部分将被迫明确标注通常由并发处理的操作。
<!-- The Four Maxims of Java Concurrency -->
## 四句格言
在经历了多年的Java并发之后我总结了以下四个格言
>1.不要这样做
>
>2.没有什么是真的,一切可能都有问题
>
>3.它起作用,并不意味着它没有问题
>
>4.你仍然必须理解它
这些特别是关于Java设计中的问题尽管它也可以应用于其他一些语言。但是确实存在旨在防止这些问题的语言。
### 1.不要这样做
(不要自己动手)
避免纠缠于并发产生的深层问题的最简单方法就是不要这样做。虽然它是诱人的,并且似乎足够安全,可以尝试做简单的事情,但它存在无数、微妙的陷阱。如果你可以避免它,你的生活会更容易。
证明并发性的唯一因素是速度。如果你的程序运行速度不够快 - 在这里要小心,因为只是希望它运行得更快是不合理的 - 首先应用一个分析器(参见代码校验章中分析和优化)来发现你是否可以执行其他一些优化。
如果您被迫进行并发,请采取最简单,最安全的方法来解决问题。使用众所周知的库并尽可能少地编写自己的代码。有了并发性,就没有“太简单了”。自负才是你的敌人。
### 2.没有什么是真的,一切可能都有问题
没有并发性的编程,你会发现你的世界有一定的顺序和一致性。通过简单地将变量赋值给某个值,很明显它应该始终正常工作。
在并发领域,有些事情可能是真的而有些事情却不是,你必须认为没有什么是真的。你必须质疑一切。即使将变量设置为某个值也可能或者可能不会按预期的方式工作,并且从那里开始走下坡路。我已经很熟悉的东西,认为它显然有效但实际上并没有。
在非并发程序中你可以忽略的各种事情突然变得非常重要。例如,您必须知道处理器缓存以及保持本地缓存与主内存一致的问题。您必须了解对象构造的深度复杂性,以便您的构造对象不会意外地将数据暴露给其他线程进行更改。问题还有很多。
虽然这些主题太复杂无法为您提供本章的专业知识再次参见Java Concurrency in Practice但您必须了解它们。
### 3.它起作用,并不意味着它没有问题
您可以轻松编写一个似乎可以工作,但实际上是有问题的并发程序,并且该问题仅在最极限的条件下显示出来 - 在您部署程序后不可避免地会出现用户问题。
- 你不能证明并发程序是正确的,你只能(有时)证明它是不正确的。
- 大多数情况下你甚至不能这样做:如果它有问题,你可能无法检测到它。
- 您通常不能编写有用的测试,因此您必须依靠代码检查结合深入的并发知识来发现错误。
- 即使是有效的程序也只能在其设计参数下工作。当超出这些设计参数时,大多数并发程序会以某种方式失败。
在其他Java主题中我们培养了一种感觉-决定论。一切都按照语言的承诺(或隐含)进行,这是令人欣慰和期待的 - 毕竟,编程语言的目的是让机器做我们想要的。从确定性编程的世界进入并发编程领域,我们遇到了一种称为[Dunning-Kruger](https://en.wikipedia.org/wiki/Dunning%E2%80%93Kruger_effect)效应的认知偏差,可以概括为“你知道的越多,你认为你知道得越多。”这意味着“......相对不熟练的人拥有着虚幻的优越感,错误地评估他们的能力远高于实际。
我自己的经验是,无论你是多么确定你的代码是线程安全的,它可能已经无效了。你可以很容易地了解所有的问题,然后几个月或几年后你会发现一些概念让你意识到你编写的大多数内容实际上都容易受到并发错误的影响。当某些内容不正确时,编译器不会告诉您。为了使它正确,你必须在研究代码时掌握前脑的所有并发问题。
在Java的所有非并发领域“没有明显的错误和没有明显的编译错误”似乎意味着一切都好。对于并发它没有任何意义。你可以在这个情况下做的最糟糕的事情是“自信”。
### 4.你必须仍然理解
在格言1-3之后您可能会对并发性感到害怕并且认为“到目前为止我已经避免了它也许我可以继续保留它。
这是一种理性的反应。您可能知道其他编程语言更好地设计用于构建并发程序 - 甚至是在JVM上运行的程序从而提供与Java的轻松通信例如Clojure或Scala。为什么不用这些语言编写并发部分并将Java用于其他所有部分呢
唉,你不能轻易逃脱:
- 即使你从未明确地创建一个线程,你可能使用的框架 - 例如Swing图形用户界面GUI或者像**Timer** clas那样简单的东西。
- 这是最糟糕的事情:当您创建组件时,您必须假设这些组件可能在多线程环境中重用。即使你的解决方案是放弃并声明你的组件“不是线程安全的”,你仍然必须知道这样的声明是重要的,它是什么意思?
人们有时会认为并发性太难,不能包含在介绍该语言的书中。他们认为并发是一个可以独立对待的独立主题,并且它在日常编程中出现的少数情况(例如图形用户界面)可以用特殊的习语来处理。如果你可以避免它,为什么要介绍这样的复杂的主题。
如果只是这样的话那就太好了。但不幸的是您无法选择何时在Java程序中出现线程。仅仅你从未写过自己的线程并不意味着你可以避免编写线程代码。例如Web系统是最常见的Java应用程序之一本质上是多线程的Web服务器通常包含多个处理器而并行性是利用这些处理器的理想方式。就像这样的系统看起来那么简单你必须理解并发才能正确地编写它。
Java是一种多线程语言如果您了解它们是否存在并发问题。因此有许多Java程序正在使用中或者只是偶然工作或者大部分时间工作并且不时地发生问题因为。有时这种问题是相对良性的但有时它意味着丢失有价值的数据如果你没有意识到并发问题你最终可能会把问题放在其他地方而不是你的代码中。如果将程序移动到多处理器系统则可以暴露或放大这些类型的问题。基本上了解并发性使您意识到正确的程序可能会表现出错误的行为。
<!-- The Brutal Truth -->
## <span id = "The-Brutal-Truth">残酷的真相</span>
当人类开始烹饪他们的食物时,他们大大减少了他们的身体分解和消化食物所需的能量。烹饪创造了一个“外化的胃”,从而释放出追去其他的的能力。火的使用促成了文明。
我们现在通过计算机和网络技术创造了一个“外化大脑”,开始了第二次基本转变。虽然我们只是触及表面,但已经引发了其他转变,例如设计生物机制的能力,并且已经看到文化演变的显着加速(过去,人们不得不前往混合文化,但现在他们开始混合互联网)。这些转变的影响和好处已经超出了科幻作家预测它们的能力(他们在预测文化和个人变化,甚至技术转变的次要影响方面都特别困难)。
有了这种根本性的人类变化,看到许多破坏和失败的实验并不令人惊讶。实际上,进化依赖于无数的实验,其中大多数都失败了。这些实验是向前发展的必要条件
Java是在充满自信热情和睿智的氛围中创建的。在发明一种编程语言时很容易就像语言的初始可塑性会持续存在一样你可以把某些东西拿出来如果不能解决问题那么就修复它。编程语言以这种方式是独一无二的 - 它们经历了类似水的改变:气态,液态和最终的固态。在气体相位期间,灵活性似乎是无限的,并且很容易认为它总是那样。一旦人们开始使用您的语言,变化就会变得更加严重,环境变得更加粘稠。语言设计的过程本身就是一门艺术。
紧迫感来自互联网的最初兴起。它似乎是一场比赛第一个通过起跑线的人将“获胜”事实上JavaJavaScript和PHP等语言的流行程度可以证明这一点。唉通过匆忙设计语言而产生的认知负荷和技术债务最终会赶上我们。
[Turing completeness](https://en.wikipedia.org/wiki/Turing_completeness)是不足够的;语言需要更多的东西:它们必须能够创造性地表达,而不是用不必要的东西来衡量我们。解放我们的心理能力只是为了扭转并再次陷入困境,这是毫无意义的。我承认,尽管存在这些问题,我们已经完成了令人惊奇的事情,但我也知道如果没有这些问题我们能做得更多。
热情使原始Java设计师因为看起来有必要而投入功能。信心以及原始语言的气味让他们认为任何问题都可以解决。在时间轴的某个地方有人认为任何加入Java的东西是固定的和永久性的 - 这是非常有信心相信第一个决定永远是正确的因此我们看到Java的体系中充斥着糟糕的决策。其中一些决定最终没有什么后果;例如您可以告诉人们不要使用Vector但保留了对之前版本的支持。
线程包含在Java 1.0中。当然并发性是影响语言远角的基本语言设计决策很难想象以后添加它。公平地说当时并不清楚基本的并发性是多少。像C这样的其他语言能够将线程视为一个附加功能因此Java设计师也纷纷效仿包括一个Thread类和必要的JVM支持这比你想象的要复杂得多
C语言是面向过程语言这限制了它的野心。这些限制使附加线程库合理。当采用原始模型并将其粘贴到复杂语言中时Java的大规模扩展迅速暴露了基本问题。在Thread类中的许多方法的弃用以及后续的高级库浪潮中这种情况变得明显这些库试图提供更好的并发抽象。
不幸的是为了在更高级别的语言中获得并发性所有语言功能都会受到影响包括最基本的功能例如标识符代表可变值。在函数和方法中所有不变和防止副作用的方法都会导致简化并发编程这些是纯函数式编程语言的基础的变化但当时对于主流语言的创建者来说似乎是奇怪的想法。最初的Java设计师要么对这些选择有所了解要么认为它们太不同了并且会抛弃许多潜在的语言采用者。我们可以慷慨地说语言设计社区当时根本没有足够的经验来理解调整在线程库中的影响。
Java实验告诉我们结果是悄然灾难性的。程序员很容易陷入认为Java 线程并不那么困难的陷阱。似乎工作的程序充满了微妙的并发bug。
为了获得正确的并发性,语言功能必须从头开始设计并考虑并发性。这艘船航行了;Java将不再是为并发而设计的语言而只是一种允许它的语言。
尽管有这些基本的不可修复的缺陷但令人印象深刻的是它还有多远。Java的后续版本添加了库以便在使用并发时提升抽象级别。事实上我根本不会想到有可能在Java 8中进行改进并行流和**CompletableFutures** - 这是惊人的史诗般的变化,我会惊奇地重复的查看它[^3]。
这些改进非常有用,我们将在本章重点介绍并行流和**CompletableFutures**。虽然它们可以大大简化您对并发和后续代码的思考方式但基本问题仍然存在由于Java的原始设计代码的所有部分仍然容易受到攻击您仍然必须理解这些复杂和微妙的问题。Java中的线程绝不是简单或安全的;那种经历必须降级为另一种更新的语言。
<!-- The Rest of the Chapter -->
## 本章其余部分
这是我们将在本章的其余部分介绍的内容。请记住本章的重点是使用最新的高级Java并发结构。使用这些使得您的生活比旧的替代品更加轻松。但是您仍会在遗留代码中遇到一些低级工具。有时你可能会被迫自己使用其中的一些。附录[并发底层原理](./Appendix-Low-Level-Concurrency.md)包含一些更原始的Java并发元素的介绍。
- Parallel Streams并发流
到目前为止我已经强调了Java 8 Streams提供的改进语法。现在您对该语法作为一个粉丝我希望感到满意您可以获得额外的好处您可以通过简单地将parallel添加到表达式来并行化流。这是一种简单强大坦率地说是利用多处理器的惊人方式
添加parallel来提高速度似乎是微不足道的但是它就像你刚刚在[残酷的真相](#The-Brutal-Truth)中学到的那样简单。我将演示并解释一些盲目添加parallel到Stream表达式的缺陷。
- 创建和运行任务
任务是一段可以独立运行的代码。为了解释创建和运行任务的一些基础知识本节介绍了一种比并行流或CompletableFuturesExecutor更复杂的机制。执行者管理一些低级Thread对象Java中最原始的并发形式。您创建一个任务然后将其交给Executorto运行。
有多种类型的Executor用于不同的目的。在这里我们将展示规范形式代表创建和运行任务的最简单和最佳方法。
- 终止长时间运行的任务
任务独立运行因此需要一种机制来关闭它们。典型的方法使用了一个标志这引入了共享内存的问题我们将使用Java的“Atomic”库来回避它。
- Completable Futures
当您将衣服带到干洗店时他们会给您一张收据。你继续完成其他任务最终你的衣服很干净你可以拿起它。收据是您与干洗店在后台执行的任务的连接。这是Java 5中引入的Future的方法。
Future比以前的方法更方便但你仍然必须出现并用收据取出干洗等待任务没有完成。对于一系列操作Futures并没有真正帮助那么多。
Java 8 CompletableFuture是一个更好的解决方案它允许您将操作链接在一起因此您不必将代码写入接口排序操作。有了CompletableFuture完美的结合就可以更容易地做出“采购原料组合成分烹饪食物提供食物清理菜肴储存菜肴”等一系列链式操作。
- 死锁
某些任务必须去**等待 - 阻塞**来获得其他任务的结果。被阻止的任务有可能等待另一个被阻止的任务,等待另一个被阻止的任务,等等。如果被阻止的任务链循环到第一个,没有人可以取得任何进展,你就会陷入僵局。
如果在运行程序时没有立即出现死锁,则会出现最大的问题。您的系统可能容易出现死锁,并且只会在某些条件下死锁。程序可能在某个平台上运行正常,例如您的开发机器,但是当您将其部署到不同的硬件时会开始死锁。
死锁通常源于细微的编程错误;一系列无辜的决定,最终意外地创建了一个依赖循环。本节包含一个经典示例,演示了死锁的特性。
我们将通过模拟创建披萨的过程完成本章,首先使用并行流实现它,然后是完成配置。这不仅仅是两种方法的比较,更重要的是探索你应该投入多少工作来加速计划。
- 努力,复杂,成本
<!-- Parallel Streams -->
## 并行流
Java 8流的一个显着优点是在某些情况下它们可以很容易地并行化。这来自仔细的库设计特别是流使用内部迭代的方式 - 也就是说它们控制着自己的迭代器。特别是它们使用一种特殊的迭代器称为Spliterator它被限制为易于自动分类。这产生了相当神奇的结果只能说.parallel并且你的流中的所有东西都是作为一组并行任务运行的。如果您的代码是使用Streams编写的那么并行化以提高速度似乎微不足道。
例如考虑来自Streams的Prime.java。查找质数可能是一个耗时的过程我们可以看到该程序的计时
```java
// concurrent/ParallelPrime.java
import java.util.*;
import java.util.stream.*;
import static java.util.stream.LongStream.*;
import java.io.*;
import java.nio.file.*;
import onjava.Timer;
public class ParallelPrime {
static final int COUNT = 100_000;
public static boolean isPrime(long n){
return rangeClosed(2, (long)Math.sqrt(n)).noneMatch(i -> n % i == 0);
}
public static void main(String[] args)
throws IOException {
Timer timer = new Timer();
List<String> primes =
iterate(2, i -> i + 1)
.parallel() // [1]
.filter(ParallelPrime::isPrime)
.limit(COUNT)
.mapToObj(Long::toString)
.collect(Collectors.toList());
System.out.println(timer.duration());
Files.write(Paths.get("primes.txt"), primes, StandardOpenOption.CREATE);
}
}
/*
Output:
1224
*/
```
请注意,这不是微基准测试,因为我们计时整个程序。我们将数据保存在磁盘上以防止激进的优化;如果我们没有对结果做任何事情那么一个狡猾的编译器可能会观察到程序没有意义并且消除了计算这不太可能但并非不可能。请注意使用nio2库编写文件的简单性在[文件](./17-Files.md)一章中有描述)。
<!-- Creating and Running Tasks -->
## 创建和运行任务
<!-- Terminating Long-Running Tasks -->
## 终止耗时任务
<!-- CompletableFutures -->
## CompletableFuture类
<!-- Deadlock -->
## 死锁
<!-- Constructors are not Thread-Safe -->
## 构造函数非线程安全
<!-- Effort, Complexity,Cost -->
## 复杂性和代价
<!-- Summary -->
## 本章小结
[^1]:例如,Eric-Raymond在“VIIX编程艺术”Addison-Wesley2004中提出了一个很好的案例。
[^2]:可以说,试图将并发性用于后续语言是一种注定要失败的方法,但你必须得出自己的结论
[^3]:有人谈论在Java——10中围绕泛型做一些类似的基本改进这将是非常令人难以置信的。
<!-- 分页 -->
<div style="page-break-after: always;"></div>