From dd0766880959b7c3d7a969d2dfcda27f1715e4ee Mon Sep 17 00:00:00 2001 From: crimson <1291463831@qq.com> Date: Tue, 21 Jan 2020 13:36:29 +0800 Subject: [PATCH] =?UTF-8?q?Fix=20issue:#112=20=E7=BF=BB=E8=AF=91Summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: crimson <1291463831@qq.com> --- docs/book/24-Concurrent-Programming.md | 97 +++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/docs/book/24-Concurrent-Programming.md b/docs/book/24-Concurrent-Programming.md index 5a82b1e6..9ecea280 100644 --- a/docs/book/24-Concurrent-Programming.md +++ b/docs/book/24-Concurrent-Programming.md @@ -247,7 +247,7 @@ Java实验告诉我们,结果是悄然灾难性的。程序员很容易陷入 这是我们将在本章的其余部分介绍的内容。请记住,本章的重点是使用最新的高级Java并发结构。使用这些使得你的生活比旧的替代品更加轻松。但是,你仍会在遗留代码中遇到一些低级工具。有时,你可能会被迫自己使用其中的一些。附录:[并发底层原理](./Appendix-Low-Level-Concurrency.md)包含一些更原始的Java并发元素的介绍。 -- Parallel Streams(并发流) +- Parallel Streams(并行流) 到目前为止,我已经强调了Java 8 Streams提供的改进语法。现在你对该语法(作为一个粉丝,我希望)感到满意,你可以获得额外的好处:你可以通过简单地将parallel()添加到表达式来并行化流。这是一种简单,强大,坦率地说是利用多处理器的惊人方式 添加parallel()来提高速度似乎是微不足道的,但是,唉,它就像你刚刚在[残酷的真相](#The-Brutal-Truth)中学到的那样简单。我将演示并解释一些盲目添加parallel()到Stream表达式的缺陷。 @@ -2280,7 +2280,7 @@ public class DiningPhilosophers { } ``` -当你停止查看输出时,该程序将死锁。但是,根据你的计算机配置,你可能不会看到死锁。看来这取决于计算机上的内核数7。两个核心似乎不会产生死锁,但似乎有两个以上的核心很容易产生死锁。此行为使该示例更好地说明了死锁,因为你可能正在具有两个内核的计算机上编写程序(如果确实是导致问题的原因),并且确信该程序可以正常工作,只能启动它将其安装在另一台计算机上时出现死锁。请注意,仅仅因为你不容易看到死锁,并不意味着该程序就不会在两核计算机上死锁。该程序仍然容易死锁,很少发生-可以说是最坏的情况,因为问题不容易解决。 +当你停止查看输出时,该程序将死锁。但是,根据你的计算机配置,你可能不会看到死锁。看来这取决于计算机上的内核数[^7]。两个核心似乎不会产生死锁,但似乎有两个以上的核心很容易产生死锁。此行为使该示例更好地说明了死锁,因为你可能正在具有两个内核的计算机上编写程序(如果确实是导致问题的原因),并且确信该程序可以正常工作,只能启动它将其安装在另一台计算机上时出现死锁。请注意,仅仅因为你不容易看到死锁,并不意味着该程序就不会在两核计算机上死锁。该程序仍然容易死锁,很少发生-可以说是最坏的情况,因为问题不容易解决。 在DiningPhilosophers构造函数中,每个哲学家都获得一个左右StickHolder的引用。除最后一个哲学家外,每个哲学家都通过以下方式初始化: 哲学家之间的下一双筷子。最后一位哲学家右手的筷子为零,因此圆桌会议完成了。那是因为最后一位哲学家正坐在第一个哲学家的旁边,而且他们俩都共用零筷子。[1]显示了以n为模数选择的右摇杆,将最后一个哲学家缠绕在第一个哲学家的旁边。 现在,所有哲学家都可以尝试吃饭,每个哲学家都在旁边等待哲学家放下筷子。 @@ -2308,10 +2308,10 @@ public class DiningPhilosophers { ## 构造函数非线程安全 -当你在脑子里想象一个对象构造的过程,你会很容易认为这个过程是线程安全的。毕竟,在对象初始化完成前对外不可见,所以又怎会对此产生争议呢?确实,Java 语言规范(JLS)自信满满地陈述道:“没必要使构造器的线程同步,因为它会锁定正在构造的对象,直到构造器完成初始化后才对其他线程可见。” +当你在脑子里想象一个对象构造的过程,你会很容易认为这个过程是线程安全的。毕竟,在对象初始化完成前对外不可见,所以又怎会对此产生争议呢?确实,[Java 语言规范](https://docs.oracle.com/javase/specs/jls/se8/html/jls-8.html#jls-8.8.3) (JLS)自信满满地陈述道:“*没必要使构造器的线程同步,因为它会锁定正在构造的对象,直到构造器完成初始化后才对其他线程可见。*” 不幸的是,对象的构造过程如其他操作一样,也会受到共享内存并发问题的影响,只是作用机制可能更微妙罢了。 -设想下使用一个静态字段为每个对象自动创建唯一标识符的过程。为了测试其不同的实现过程,我们从一个接口开始。代码示例: +设想下使用一个**静态**字段为每个对象自动创建唯一标识符的过程。为了测试其不同的实现过程,我们从一个接口开始。代码示例: ```java //concurrent/HasID.java @@ -2516,7 +2516,7 @@ public class SynchronizedConstructor{ 0 ``` -Unsafe类的共享使用现在就变得安全了。另一种方法是将构造器设为私有(因此可以防止继承),并提供一个静态Factory方法来生成新对象: +**Unsafe**类的共享使用现在就变得安全了。另一种方法是将构造器设为私有(因此可以防止继承),并提供一个静态Factory方法来生成新对象: ```java // concurrent/SynchronizedFactory.java @@ -2642,7 +2642,7 @@ public class Pizza{ } ``` -这只算得上是一个简单的状态机,就像Machina类一样。 +这只算得上是一个平凡的状态机,就像**Machina**类一样。 制作一个披萨,当披萨饼最终被放在盒子中时,就算完成最终任务了。 如果一个人在做一个披萨饼,那么所有步骤都是线性进行的,即一个接一个地进行: @@ -2736,9 +2736,9 @@ Pizza 3:BOXED 1739 ``` -现在,我们制作五个披萨的时间与制作单个披萨的时间就差不多了。 尝试删除标记为[1]的行后,你会发现它花费的时间是原来的五倍。 你还可以尝试将QUANTITY更改为4、8、10、16和17,看看会有什么不同,并猜猜看为什么会这样。 +现在,我们制作五个披萨的时间与制作单个披萨的时间就差不多了。 尝试删除标记为[1]的行后,你会发现它花费的时间是原来的五倍。 你还可以尝试将**QUANTITY**更改为4、8、10、16和17,看看会有什么不同,并猜猜看为什么会这样。 -**PizzaStreams** 类产生的每个并行流在它的forEach()内完成所有工作,如果我们将其各个步骤用映射的方式一步一步处理,情况会有所不同吗? +**PizzaStreams** 类产生的每个并行流在它的`forEach()`内完成所有工作,如果我们将其各个步骤用映射的方式一步一步处理,情况会有所不同吗? ```java // concurrent/PizzaParallelSteps.java @@ -2910,9 +2910,9 @@ Pizza4: complete 1797 ``` -并行流和 **CompletableFutures** 是 Java 并发工具箱中最先进发达的技术。 你应该始终首先选择其中之一。 当一个问题很容易并行处理时,或者说,很容易把数据分解成相同的、易于处理的各个部分时,使用并行流方法处理最为合适(而如果你决定不借助它而由自己完成,你就必须撸起袖子,深入研究Spliterator的文档)。 +并行流和 **CompletableFutures** 是 Java 并发工具箱中最先进发达的技术。 你应该始终首先选择其中之一。 当一个问题很容易并行处理时,或者说,很容易把数据分解成相同的、易于处理的各个部分时,使用并行流方法处理最为合适(而如果你决定不借助它而由自己完成,你就必须撸起袖子,深入研究**Spliterator**的文档)。 -而当工作的各个部分内容各不相同时,使用 **CompletableFutures** 是最好的选择。比起面向数据,CompletableFutures** 更像是面向任务的。 +而当工作的各个部分内容各不相同时,使用 **CompletableFutures** 是最好的选择。比起面向数据,**CompletableFutures** 更像是面向任务的。 对于披萨问题,结果似乎也没有什么不同。实际上,并行流方法看起来更简洁,仅出于这个原因,我认为并行流作为解决问题的首次尝试方法更具吸引力。 @@ -2924,12 +2924,87 @@ Pizza4: complete ## 本章小结 -[^1]:例如,Eric-Raymond在“VIIX编程艺术”(Addison-Wesley,2004)中提出了一个很好的案例。 +需要并发的唯一理由是“等待太多”。这也可以包括用户界面的响应速度,但是由于Java用于构建用户界面时并不高效,因此[^8]这仅仅意味着“您的程序运行速度还不够快”。 + +如果并发很容易,则没有理由拒绝并发。 正因为并发实际上很难,所以您应该仔细考虑是否值得为此付出努力,并考虑您能否以其他方式提升速度。 + +例如,迁移到更快的硬件(这可能比消耗程序员的时间要便宜得多)或者将程序分解成多个部分,然后在不同的机器上运行这些部分。 + +奥卡姆剃刀是一个经常被误解的原则。 我看过至少一部电影,他们将其定义为”最简单的解决方案是正确的解决方案“,就好像这是某种毋庸置疑的法律。实际上,这是一个准则:面对多种方法时,请先尝试需要最少假设的方法。 在编程世界中,这已演变为“尝试可能可行的最简单的方法”。当您了解了特定工具的知识时——就像你现在了解了有关并发性的知识一样,你可能会很想使用它,或者提前规定你的解决方案必须能够“速度飞快”,从而来证明从一开始就进行并发设计是合理的。但是,我们的奥卡姆剃刀编程版本表示您应该首先尝试最简单的方法(这种方法开发起来也更便宜),然后看看它是否足够好。 + +由于我出身于底层学术背景(物理学和计算机工程),所以我很容易想到所有小轮子转动的成本。我确定使用最简单的方法不够快的场景出现的次数已经数不过来了,但是尝试后却发现它实际上绰绰有余。 + +### 缺点 + +并发编程的主要缺点是: + +1. 在线程等待共享资源时会降低速度。 + +2. 线程管理产生额外CPU开销。 + +3. 糟糕的设计决策带来无法弥补的复杂性。 + +4. 诸如饥饿,竞速,死锁和活锁(多线程各自处理单个任务而整体却无法完成)之类的问题。 + +5. 跨平台的不一致。 通过一些示例,我发现了某些计算机上很快出现的竞争状况,而在其他计算机上却没有。 如果您在后者上开发程序,则在分发程序时可能会感到非常惊讶。 + + + +另外,并发的应用是一门艺术。 Java旨在允许您创建尽可能多的所需要的对象来解决问题——至少在理论上是这样。[^9]但是,线程不是典型的对象:每个线程都有其自己的执行环境,包括堆栈和其他必要的元素,使其比普通对象大得多。 在大多数环境中,只能在内存用光之前创建数千个**Thread**对象。通常,您只需要几个线程即可解决问题,因此一般来说创建线程没有什么限制,但是对于某些设计而言,它会成为一种约束,可能迫使您使用完全不同的方案。 + +### 共享内存陷阱 + +并发性的主要困难之一是因为可能有多个任务共享一个资源(例如对象中的内存),并且您必须确保多个任务不会同时读取和更改该资源。 + +我花了多年的时间研究并发并发。 我了解到您永远无法相信使用共享内存并发的程序可以正常工作。 您可以轻易发现它是错误的,但永远无法证明它是正确的。 这是众所周知的并发原则之一。[^10] + +我遇到了许多人,他们对编写正确的线程程序的能力充满信心。 我偶尔开始认为我也可以做好。 对于一个特定的程序,我最初是在只有单个CPU的机器上编写的。 那时我能够说服自己该程序是正确的,因为我以为我对Java工具很了解。 而且在我的单CPU计算机上也没有失败。而到了具有多个CPU的计算机,程序出现问题不能运行后,我感到很惊讶,但这还只是众多问题中的一个而已。 这不是Java的错; “写一次,到处运行”,在单核与多核计算机间无法扩展到并发编程领域。这是并发编程的基本问题。 实际上您可以在单CPU机器上发现一些并发问题,但是在多线程实际上真的在并行运行的多CPU机器上,就会出现一些其他问题。 + +再举一个例子,哲学家就餐的问题可以很容易地进行调整,因此几乎不会产生死锁,这会给您一种一切都棒极了的印象。当涉及到共享内存并发编程时,您永远不应该对自己的编程能力变得过于自信。 + + + +### This Albatross is Big + +如果您对Java并发感到不知所措,那说明您身处在一家出色的公司里。您 可以访问**Thread**类的[Javadoc](https://docs.oracle.com/javase/8/docs/api/java/lang/Thread.html)页面, 看一下哪些方法现在是**Deprecated**(废弃的)。这些是Java语言设计者犯过错的地方,因为他们在设计语言时对并发性了解不足。 + +事实证明,在Java的后续版本中添加的许多库解决方案都是无效的,甚至是无用的。 幸运的是,Java 8中的并行**Streams**和**CompletableFutures**都非常有价值。但是当您使用旧代码时,仍然会遇到旧的解决方案。 + +在本书的其他地方,我谈到了Java的一个基本问题:每个失败的实验都永远嵌入在语言或库中。 Java并发强调了这个问题。尽管有不少错误,但错误并不是那么多,因为有很多不同的尝试方法来解决问题。 好的方面是,这些尝试产生了更好,更简单的设计。 不利之处在于,在找到好的方法之前,您很容易迷失于旧的设计中。 + +### 其他类库 + +本章重点介绍了相对安全易用的并行工具流和**CompletableFutures**,并且仅涉及Java标准库中一些更细粒度的工具。 为避免您不知所措,我没有介绍您可能实际在实践中使用的某些库。我们使用了几个**Atomic**(原子)类,**ConcurrentLinkedDeque**,**ExecutorService**和**ArrayBlockingQueue**。附录:[并发底层原理](./Appendix-Low-Level-Concurrency.md)涵盖了其他一些内容,但是您还想探索**java.util.concurrent**的Javadocs。 但是要小心,因为某些库组件已被新的更好的组件所取代。 + +### 考虑为并发设计的语言 + +通常,请谨慎地使用并发。 如果需要使用它,请尝试使用最现代的方法:并行流或**CompletableFutures**。 这些功能旨在(假设您不尝试共享内存)使您摆脱麻烦(在Java的世界范围内)。 + +如果您的并发问题变得比高级Java构造所支持的问题更大且更复杂,请考虑使用专为并发设计的语言,仅在需要并发的程序部分中使用这种语言是有可能的。 在撰写本文时,JVM上最纯粹的功能语言是Clojure(Lisp的一种版本)和Frege(Haskell的一种实现)。这些使您可以在其中编写应用程序的并发部分语言,并通过JVM轻松地与您的主要Java代码进行交互。 或者,您可以选择更复杂的方法,即通过外部功能接口(FFI)将JVM之外的语言与另一种为并发设计的语言进行通信。[^11] + +你很容易被一种语言绑定,迫使自己尝试使用该语言来做所有事情。 一个常见的示例是构建HTML / JavaScript用户界面。 这些工具确实很难使用,令人讨厌,并且有许多库允许您通过使用自己喜欢的语言编写代码来生成这些工具(例如,**Scala.js**允许您在Scala中完成代码)。 + +心理上的便利是一个合理的考虑因素。 但是,我希望我在本章(以及附录:[并发底层原理](./Appendix-Low-Level-Concurrency.md))中已经表明Java并发是一个你可能无法逃离很深的洞。 与Java语言的任何其他部分相比,在视觉上检查代码同时记住所有陷阱所需要的的知识要困难得多。 + +无论使用特定的语言、库使得并发看起来多么简单,都要将其视为一种妖术,因为总是有东西会在您最不期望出现的时候咬您。 + +### 拓展阅读 + +《Java Concurrency in Practice》,出自Brian Goetz,Tim Peierls, Joshua Bloch,Joseph Bowbeer,David Holmes和 Doug Lea (Addison Wesley,2006年)——这些基本上就是Java并发世界中的名人名单了《Java Concurrency in Practice》第二版,出自 Doug Lea (Addison-Wesley,2000年)。尽管这本书出版时间远远早于Java 5发布,但Doug的大部分工作都写入了**java.util.concurrent**库。因此,这本书对于全面理解并发问题至关重要。 它超越了Java,讨论了跨语言和技术的并发编程。 尽管它在某些地方可能很钝,但值得多次重读(最好是在两个月之间进行消化)。 道格(Doug)是世界上为数不多的真正了解并发编程的人之一,因此这是值得的。 + + + +[^1]:例如,Eric-Raymond在“Unix编程艺术”(Addison-Wesley,2004)中提出了一个很好的案例。 [^2]:可以说,试图将并发性用于后续语言是一种注定要失败的方法,但你必须得出自己的结论 [^3]:有人谈论在Java——10中围绕泛型做一些类似的基本改进,这将是非常令人难以置信的。 [^4]:这是一种有趣的,虽然不一致的方法。通常,我们期望在公共接口上使用显式类表示不同的行为 [^5]:不,永远不会有纯粹的功能性Java。我们所能期望的最好的是一种在JVM上运行的全新语言。 [^6]:当两个任务能够更改其状态以使它们不会被阻止但它们从未取得任何有用的进展时,你也可以使用活动锁。 +[^7]: 而不是超线程;通常每个内核有两个超线程,并且在询问内核数量时,本书所使用的Java版本会报告超线程的数量。超线程产生了更快的上下文切换,但是只有实际的内核才真的工作,而不是超线程。 ↩ +[^8]: 库就在那里用于调用,而语言本身就被设计用于此目的,但实际上它很少发生,以至于可以说”没有“。↩ +[^9]: 举例来说,如果没有Flyweight设计模式,在工程中创建数百万个对象用于有限元分析可能在Java中不可行。↩ +[^10]: 在科学中,虽然从来没有一种理论被证实过,但是一种理论必须是可证伪的才有意义。而对于并发性,我们大部分时间甚至都无法得到这种可证伪性。↩ +[^11]: 尽管**Go**语言显示了FFI的前景,但在撰写本文时,它并未提供跨所有平台的解决方案。