在本章中,我们将扩展主谋游戏。就像现在这样,它能猜出隐藏的秘密,也能藏起钉子。测试代码甚至可以同时做这两件事。它可以与自己作对,只留给我们编程的乐趣。它不能做的是利用我们今天笔记本和服务器上的所有处理器。代码同步运行,只使用一个处理器内核。
我们将修改扩展猜测算法的代码,以便将猜测分割成子任务并并行执行代码。这样,我们将熟悉 Java 并发编程。这将是一个巨大的话题,许多微妙的曲折潜伏在阴影中。我们将深入了解这些最重要的细节,并为您需要并行程序时的进一步学习奠定坚实的基础。
由于比赛的结果和以前一样,只是速度更快,我们必须评估什么是更快。为此,我们将利用 Java9 中引入的一个新特性——微基准测试工具。
在本章中,我们将介绍以下主题:
- 进程、线程和纤程的含义
- Java 中的多线程技术
- 多线程编程的问题及避免方法
- 锁定、同步和阻塞队列
- 微基准
旧的算法是遍历所有的变化,并试图找到与表的当前状态相匹配的猜测。假设当前检查的猜测是秘密,我们会得到与实际答案相同的答案吗?如果是的话,那么当前的猜测就是秘密,它和其他猜测一样好。
更复杂的方法可以实现 min-max 算法。这个算法不只是简单地得到下一个可能的猜测,而是查看所有可能的猜测,并选择一个最缩短游戏结果的猜测。如果有一个猜测在最坏的情况下可以再进行三次猜测,而另一个猜测的数字只有两次,那么 minmax 将选择后者。对于那些感兴趣的读者来说,实现 minmax 算法是一个很好的练习。在六种颜色和四列的情况下,最小-最大算法在不超过五个步骤的情况下解决游戏。我们实现的简单算法也分五步求解游戏。然而,我们没有朝这个方向走。
相反,我们希望有一个版本的游戏,利用一个以上的处理器。如何将算法转换为并行算法?这个问题没有简单的答案。当你有一个算法,你可以分析计算和部分算法,你可以尝试找到依赖关系。如果有一个计算,B
需要数据,这是另一个计算,a
的结果,那么很明显,a
只能在B
准备就绪时执行。如果算法的某些部分不依赖于其他部分的结果,那么它们可以并行执行。
例如,快速排序有两个主要任务,分别对这两个部分进行分区和排序。很明显,在我们开始对两个分区的部分进行排序之前,分区必须完成。但是,这两部分的排序任务并不相互依赖,它们可以独立完成。你可以给他们两个不同的处理器。一个会很高兴地将包含较小元素的部分分类,而另一个会携带较大的元素。
如果我们回想一下非递归快速排序实现,您可以看到我们将排序任务安排到一个栈中,然后通过在一个while
循环中从栈中获取元素来执行排序:
public class NonRecursiveQuickSort<E> {
// ... same fields and constructor as in Qsort are deleted from print ...
private static class StackElement {
final int begin;
final int fin;
public StackElement(int begin, int fin) {
this.begin = begin;
this.fin = fin;
}
}
public void qsort(Sortable<E> sortable, int start, int end) {
final var stack = new LinkedList<StackElement>();
final var partitioner = new Partitioner<E>(comparator, swapper);
stack.add(new StackElement(start, end));
var i = 1;
while (!stack.isEmpty()) {
var it = stack.remove(0);
if (it.begin < it.fin) {
final E pivot = sortable.get(it.begin);
var cutIndex = partitioner.partition(sortable, it.begin, it.fin, pivot);
if( cutIndex == it.begin ){
cutIndex++;
}
stack.add(new StackElement(it.begin, cutIndex - 1));
stack.add(new StackElement(cutIndex, it.fin));
}
}
}
}
我们可以将任务传递给异步线程来执行排序,然后返回到下一个等待的任务,而不是在循环的核心执行排序。我们只是不知道怎么做。但是。这就是我们在这一章的原因。
处理器、线程和进程是复杂而抽象的东西,它们很难想象。不同的程序员有不同的技术来想象并行处理和算法。我可以告诉你我是怎么做的。这不能保证对你有用。其他人的头脑中可能有不同的技巧。事实上,我刚刚意识到,在我写这篇文章的时候,我从来没有告诉过任何人。这可能看起来很幼稚,但不管怎样,还是来了。
当我想象算法时,我想象人。一个处理器就是一个人。这有助于我克服一个奇怪的事实,即一个处理器可以在一秒钟内执行数十亿次计算。我真的想象一个穿棕色西装的官僚在做计算。当我为一个并行算法创建一个代码时,我想象他们中的许多人在办公桌后面工作。他们一个人工作,不说话。重要的是他们不要互相交谈。他们非常正式。当需要交换信息时,他们会拿着一张纸站起来,上面写着什么,然后把它带给对方。有时,他们的工作需要一张纸。然后,他们站起来,走到放报纸的地方,拿着它,把它带回办公桌,继续工作。准备好后,他们回去把报纸拿回来。如果他们需要的时候报纸不在那里,他们就会排队等候,直到有人把报纸带来。
这对主谋游戏有什么帮助?
我想象一个老板对猜测负责。办公室的墙上有一张桌子,上面有以前的猜测和每行的结果。老板懒得提出新的猜测,所以他把这个任务交给下属。当下属提出猜测时,老板会检查猜测是否有效。他不信任下属,如果猜得好,他就把它当成官方的猜测,和结果一起放在桌子上。
下属把猜测写在小便笺上,然后放在老板桌上的盒子里。老板时不时地看盒子,如果有纸条,老板就收了。如果箱子满了,下属想把一张纸放在那里,他就会停下来,等老板至少记下一张纸条,这样箱子里就有地方放新纸条了。如果下属们排队把猜词放进盒子里,他们都会等着轮到他们。
下属之间要协调,否则,他们也会有同样的猜测。他们每个人都应该有一段时间的猜测。例如,如果我们用数字表示颜色,第一个应该检查从 1234 到 2134 的猜测,第二个应该检查从 2134 到 3124 的猜测,依此类推。
这个结构能用吗?常识告诉我们会的。然而,在这种情况下,官僚是隐喻,隐喻并不确切。官僚主义者是人,即使他们看起来不是人,也远比线程或处理器更像人。他们有时行为异常,做正常人不常做的事情。然而,如果这个比喻有助于我们想象并行算法是如何工作的,我们仍然可以使用它。
我们可以想象,老板去度假,不碰桌上堆积如山的一堆纸。我们可以想象,有些工人比其他工人生产的结果快得多。因为这只是想象,所以加速可以是 1000 倍(想想一个延时视频)。想象这些情况可以帮助我们发现很少发生的特殊行为,但这可能会导致问题。当线程并行工作时,大量细微的差异可能会极大地影响一般行为。
在早期版本中,当我编写并行主谋算法时,官僚们开始工作,在老板把它们中的任何一个都摆在桌子上之前,他们就开始用猜测填满老板的盒子。由于桌上没有猜测,官僚们只是发现了他们的间隔中可能出现的所有可能的变化,这可能构成了一个很好的猜测。老板在并行助手的帮助下什么也没有得到;老板必须从所有可能的猜测中选择正确的,而猜测者只是闲着。
还有一次,当老板在猜测的时候,官僚们正在对照桌子核对猜测。按照我们的比喻,一些官僚吓了一跳,说如果有人在换桌子,就不可能对照桌子来核对猜测。更准确地说,在官僚线程中执行代码时,当表的List
被修改时抛出ConcurrentModificationException
。
另一次,我试图避免官僚们过于迅速的工作,我限制了他们可以把包含猜测的文件放在盒子里的大小。当老板终于发现了这个秘密,游戏结束后,老板告诉官僚们可以回家了。老板是这样做的,他写了一份小报告,上面写着指示,你可以回家把它放在官僚们的桌子上。官僚们做了什么?他们一直等着箱子有地方放报纸,因为在那儿等的时候,他们没有在看桌子上的零钱!(直到进程被终止。这在 MacOS 和 Linux 上相当于从 Windows 上的任务管理器结束进程。)
这样的编码错误时有发生,为了尽可能避免,我们至少要做两件事。首先,我们必须了解 Java 多线程是如何工作的,其次,要有一个尽可能干净的代码。第二,我们将进一步清理代码,然后我们将研究如何在 Java 中实现前面描述的并行算法,在 JVM 上运行,而不是使用官僚程序。
当我们完成上一章的时候,我们用一种完美的面向对象的方式设计和编码了 Mastermind 游戏的类,这种方式没有破坏任何一个 OO 原则。是吗?荒谬的。除了一些微不足道的例子外,没有任何代码是不能让它看起来更好或更好的。通常,当我们开发代码并完成编码时,它看起来很棒。它工作了,测试都运行了,文档也准备好了。从专业的角度来看,它确实是完美的。好吧,够了。我们尚未测试的最大问题是可维护性。修改代码的成本是多少?
这不是一个容易的问题,特别是因为它不是一个确定的问题。改成什么?我们要做什么修改?当我们首先创建代码时,我们不知道这一点。如果修改是为了修复一个 bug,那么很明显我们事先并不知道这一点。如果我们知道的话,我们一开始就不会引入这个 bug。如果这是一个新特性,那么就有可能预见到该功能。然而,通常情况并非如此。当开发人员试图预测未来,以及程序将来需要什么特性时,通常都会失败。了解业务是客户的任务。在专业软件开发的情况下,需要的特性是由业务驱动的。毕竟,这就是专业的含义。
尽管我们不知道代码后面需要修改什么,但是有些东西可能会给有经验的软件开发人员一些提示。通常情况下,OO 代码比即兴代码更容易维护,并且有一种可以检测的代码香气。例如,请查看以下代码行:
while (guesser.guess() != Row.none) {
. . .
while (guesser.nextGuess() != Guesser.none) {
. . .
public void addNewGuess(Row row) {
. . .
Color[] guess = super.nextGuess();
我们可能感觉到某种奇怪的气味。(每一行都在我们在第 4 章、“策划者-创建游戏”中完成的应用代码中。)guess()
方法的返回值与Row.none
进行比较,后者是一个Row
。在下一个示例行中,我们将nextGuess()
方法的返回值与Guesser.none
进行比较,后者应该是猜测,而不是Guesser
。当我们在下一个示例行中添加新的猜测时,我们实际上添加了一个Row
。最后,我们可以意识到方法nextGuess()
返回的猜测不是一个有自己声明类的对象。猜测只是Colors
的一个数组。这些东西乱七八糟。我们如何提高代码的质量?
我们应该引入另一层抽象来创建一个Guess
类吗?它会使代码更易于维护吗?还是只会让代码更复杂?通常情况下,代码行越少,出现错误的可能性就越小。然而,有时缺乏抽象会使代码变得复杂和纠结。在这种情况下是什么情况?一般的决定方法是什么?
你的经验越多,你就越容易通过看代码和敏锐地知道你想要做什么修改来判断。很多时候,您不会费心让代码更抽象,而很多时候,您会毫不犹豫地创建新的类。当有疑问时,创建新类并查看结果。重要的是不要破坏已经存在的功能。只有在有足够的单元测试的情况下才能这样做。
当您想引入一些新功能或修复一个 bug,但代码不合适时,您必须首先修改它。当您修改代码以使功能不改变时,这个过程被命名为重构。在有限的时间内更改一小部分代码,然后构建它。如果它编译并运行所有单元测试,那么您可以继续。提示是要经常运行构建。这就像在现有道路附近修建一条新道路。每隔几英里,你就会遇到一条旧路线。如果做不到这一点,你最终会在沙漠中的某个地方走上完全错误的方向,你所能做的就是回到你要重构的旧代码的起点。努力白费了。
迫使我们频繁运行构建的不仅是安全性,还有时间限制。重构并不能直接带来收益。该计划的功能直接与收入挂钩。没有人会为无限的重构工作付钱给我们。重构必须在某个时候停止,而且通常不再是什么都不需要重构的时候。代码永远不会是完美的,但是当它足够好的时候你可以停下来。而且,很多时候,程序员对代码的质量并不满意,当他们被一些外部因素(通常称为项目经理)强迫停止时,应该编译代码并运行测试,以便在实际的代码基础上执行新特性和错误修复。
重构是一个巨大的主题,在这样的活动中可以遵循许多技术。它是如此的复杂以至于有一整本马丁·福勒的书,很快就会有第二版。
在我们的例子中,我们希望对代码进行的修改是实现一个并行算法。首先要修改的是ColorManager
。当我们想在终端上打印猜测和行时,我们使用了一些糟糕的技巧来实现它。为什么没有可以打印的颜色实现?我们可以有一个扩展原始Color
类的类,并有一个返回表示该颜色的内容的方法。你有那种方法的候选名称吗?这是toString()
方法。它在Object
类中实现,任何类都可以自由覆盖它。当您将一个对象连接到一个字符串时,自动类型转换将调用此方法将该对象转换为String
。顺便说一下,使用""+object
而不是object.toString()
来避免null
指针异常是一个老把戏。不用说,我们不使用诡计。
当调试器想要显示某个对象的值时,toString()
方法也会被 IDE 调用,因此如果没有其他原因,那么为了便于开发,通常建议实现toString()
。如果我们有一个实现了toString()
的Color
类,那么PrettyPrintRow
类就变得相当简单,欺骗性更小:
public class PrettyPrintRow {
public static String pprint(Row row) {
var string = "";
var pRow = new PrintableRow(row);
for (int i = 0; i < pRow.nrOfColumns(); i++) {
string += pRow.pos(i);
}
string += " ";
string += pRow.full();
string += "/";
string += pRow.partial();
return string;
}
}
我们从打印类中删除了这个问题,但是您可能会认为问题仍然存在,您是对的。通常,当类设计中出现问题时,解决问题的方法是将问题从一个类转移到另一个类。如果它仍然是一个问题,那么你可能会越来越分裂的设计,在最后阶段,你会意识到你所拥有的是一个问题,而不是一个问题。
实现一个LetteredColor
类也很简单:
package packt.java189fundamentals.mastermind.lettered;
import packt.java189fundamentals.mastermind.Color;
public class LetteredColor extends Color {
private final String letter;
public LetteredColor(String letter){
this.letter = letter;
}
@Override
public String toString(){
return letter;
}
}
问题再次被推进。但实际上,这不是问题。这是一个OO设计。印刷不负责为颜色指定一个String
来表示颜色。而颜色实现本身也不对此负责。必须在生成颜色的地方执行赋值,然后必须将String
传递给LetteredColor
类的构造器。color
实例是在ColorManager
中创建的,所以必须在ColorManager
类中实现。还是不?ColorManager
做什么?它创造了颜色和。。。
当您对列出功能的类进行解释或描述时,您可能会立即看到违反了单一责任原则。ColorManager
应该管理颜色。管理就是提供一种方法,使颜色按一定的顺序排列,当我们知道一种颜色时,得到第一种和第二种颜色。我们应该在一个单独的类中实现另一个职责,即创建颜色。
只有创建另一个类实例的功能的类称为factory
。这与使用new
运算符几乎相同,但与new
不同的是,工厂可以以更灵活的方式使用。我们马上就会看到。ColorFactory
接口包含一个方法,如下所示:
package packt.java189fundamentals.mastermind;
public interface ColorFactory {
Color newColor();
}
只定义一个方法的接口称为函数式接口,因为它们的实现可以作为 Lambda 表达式提供,也可以作为方法引用提供,方法引用位于您要使用的对象的位置,对象是实现函数式接口的类的实例。例如,SimpleColorFactory
实现创建以下Color
对象:
package packt.java189fundamentals.mastermind;
public class SimpleColorFactory implements ColorFactory {
@Override
public Color newColor() {
return new Color();
}
}
在代码中使用new SimpleColorFactory()
的地方,我们也可以编写Color::new
或() -> new Color()
。
这很像我们如何创建一个接口,然后创建一个实现,而不是仅仅在ColorManager
中的代码中编写new Color()
。LetteredColorFactory
更有趣一点:
package packt.java189fundamentals.mastermind.lettered;
import packt.java189fundamentals.mastermind.Color;
import packt.java189fundamentals.mastermind.ColorFactory;
public class LetteredColorFactory implements ColorFactory {
private static final String letters = "0123456789ABCDEFGHIJKLMNOPQRSTVWXYZabcdefghijklmnopqrstvwxzy";
private int counter = 0;
@Override
public Color newColor() {
Color color = new LetteredColor(letters.substring(counter, counter + 1));
counter++;
return color;
}
}
现在,在这里,我们有一个功能,当String
对象被创建时,将它们分配给Color
对象。非常重要的是,跟踪已经创建的颜色的counter
变量不是static
。上一章中的类似变量是static
,这意味着每当较新的ColorManager
对象创建太多颜色时,它可能会用完字符。当每个测试创建ColorManager
对象和新的Color
实例时,它确实发生在单元测试执行期间。印刷代码试图将新字母分配给新颜色。这些测试运行在同一个 JVM 中的同一个类加载器下,不幸的static
变量不知道什么时候可以从零开始计算新的测试。
另一方面,这种工厂解决方案的缺点是,某个地方的某个人必须实例化工厂,而它不是ColorManager
。ColorManager
已经有责任了,不是要创建一个色彩工厂。ColorManager
必须在其构造器中获得ColorFactory
:
package packt.java189fundamentals.mastermind;
import java.util.HashMap;
import java.util.Map;
public class ColorManager {
protected final int nrColors;
protected final Map<Color, Color> successor = new HashMap<>();
private final ColorFactory factory;
private Color first;
public ColorManager(int nrColors, ColorFactory factory) {
this.nrColors = nrColors;
this.factory = factory;
createOrdering();
}
private Color[] createColors() {
var colors = new Color[nrColors];
for (int i = 0; i < colors.length; i++) {
colors[i] = factory.newColor();
}
return colors;
}
private void createOrdering() {
var colors = createColors();
first = colors[0];
for (int i = 0; i < nrColors - 1; i++) {
successor.put(colors[i], colors[i + 1]);
}
}
public Color firstColor() {
return first;
}
public boolean thereIsNextColor(Color color) {
return successor.containsKey(color);
}
public Color nextColor(Color color) {
return successor.get(color);
}
public int getNrColors() {
return nrColors;
}
}
您可能还注意到,我忍不住将createColors
方法重构为两种方法,以遵循单一责任原则。
现在,创建ColorManager
的代码必须创建一个工厂并将其传递给构造器。例如,单元测试的ColorManagerTest
类将包含以下方法:
@Test
public void thereIsAFirstColor() {
var manager = new ColorManager(NR_COLORS, Color::new);
Assert.assertNotNull(manager.firstColor());
}
这是实现由函数式接口定义的工厂的最简单方法。只需命名类并引用new
操作符,就好像它是通过创建方法引用的方法一样。
接下来我们要重构的是Guess
类,实际上,到目前为止我们还没有这个类。Guess
类包含猜测的标记,可以计算完全匹配(颜色和位置)和部分匹配(颜色存在但位置错误)的数量。它还可以计算出这个猜测之后的下一个Guess
。到目前为止,这个功能是在Guesser
类中实现的,但是这并不是我们在检查表上已经做出的猜测时如何选择猜测的功能。如果我们遵循为颜色设置的模式,我们可以在一个名为GuessManager
的单独类中实现这个功能,但是,到目前为止,还不需要它。同样,所需的抽象层次在很大程度上是一个品味的问题;这个东西不是黑的也不是白的。
需要注意的是,Guess
对象只能一次生成。如果放在桌上,球员就不能换。如果我们有一个Guess
还没有出现在桌子上,它仍然只是一个Guess
,通过钉子的颜色和顺序来识别。Guess
对象在创建后不会更改。这样的对象很容易在多线程程序中使用,被称为不可变对象。因为这是一个相对较长的类,所以我们将在本书的各个部分中研究代码:
package packt.java189fundamentals.mastermind;
import java.util.Arrays;
import java.util.HashSet;
public class Guess {
public final static Guess none = new Guess(new Color[0]);
private final Color[] colors;
private boolean uniquenessWasNotCalculated = true;
private boolean unique;
public Guess(Color[] colors) {
this.colors = Arrays.copyOf(colors, colors.length);
}
构造器正在创建作为参数传递的颜色数组的副本。因为Guess
是不可变的,所以这是非常重要的。如果我们只保留原始数组,那么Guess
类之外的任何代码都可能改变数组的元素,实质上改变了不应该改变的Guess
的内容。
代码的下一部分是两个简单的获取器:
public Color getColor(int i) {
return colors[i];
}
public int nrOfColumns() {
return colors.length;
}
下一种方法是计算nextGuess
:
public Guess nextGuess(ColorManager manager) {
final var colors = Arrays.copyOf(this.colors, nrOfColumns());
int i = 0;
var guessFound = false;
while (i < colors.length && !guessFound) {
if (manager.thereIsNextColor(getColor(i))) {
colors[i] = manager.nextColor(colors[i]);
guessFound = true;
} else {
colors[i] = manager.firstColor();
i++;
}
}
if (guessFound) {
return new Guess(colors);
} else {
return Guess.none;
}
}
在这种方法中,我们从实际对象中包含的颜色数组开始计算nextGuess
。我们需要一个工作数组,它被修改了,所以我们将复制原始数组。最后一个新对象可以使用我们在计算过程中使用的数组。为了实现这一点,我们需要一个独立的构造器,它不会创建Color
数组的副本。这是一个可能的额外代码。只有当我们看到这是代码中的瓶颈并且对实际性能不满意时,我们才应该考虑创建它。在这个应用中,它也不是瓶颈,我们对性能感到满意,您将在稍后讨论基准测试时看到这一点。
下一种方法只是检查通过的Guess
是否与实际的颜色数相同:
private void assertCompatibility(Guess guess) {
if (nrOfColumns() != guess.nrOfColumns()) {
throw new IllegalArgumentException("Can not compare different length guesses");
}
}
这只是计算匹配的下两种方法使用的安全检查:
public int nrOfPartialMatches(Guess guess) {
assertCompatibility(guess);
int count = 0;
for (int i = 0; i < nrOfColumns(); i++) {
for (int j = 0; j < nrOfColumns(); j++) {
if (i != j &&
guess.getColor(i) == this.getColor(j)) {
count++;
}
}
}
return count;
}
public int nrOfFullMatches(Guess guess) {
assertCompatibility(guess);
int count = 0;
for (int i = 0; i < nrOfColumns(); i++) {
if (guess.getColor(i) == this.getColor(i)) {
count++;
}
}
return count;
}
下一个isUnique()
方法检查Guess
中是否有不止一次的颜色。因为Guess
是不可变的,所以Guess
在某一时刻是唯一的,而在另一时刻不是唯一的。无论何时对特定对象调用此方法,都应返回相同的结果。因此,可以缓存结果。此方法执行此操作,将返回值保存到实例变量。
你可能会说这是过早的优化。是的,是的。我决定这么做有一个原因。它演示了一个本地保存的结果,在此基础上,您可以尝试修改nextGuess()
方法来执行相同的操作。isUnique()
方法如下:
public boolean isUnique() {
if (uniquenessWasNotCalculated) {
final var alreadyPresent = new HashSet<Color>();
unique = true;
for (final var color : colors) {
if (alreadyPresent.contains(color)) {
unique = false;
break;
}
alreadyPresent.add(color);
}
uniquenessWasNotCalculated = false;
}
return unique;
}
对于相同的参数返回相同结果的方法称为幂等。如果该方法被多次调用并且计算占用大量资源,那么缓存该方法的返回值可能非常重要。当方法有参数时,缓存结果并不简单。object
方法必须记住已计算的所有参数的结果,并且该存储必须有效。如果查找存储的结果比计算结果需要更多的资源,那么使用缓存不仅会占用更多的内存,而且会降低程序的速度。如果在对象的生存期内为多个参数调用了该方法,那么存储内存可能会变得太大。不再需要的元素必须清除。但是,我们无法知道缓存的哪些元素以后不需要。我们不是算命的,所以我们得猜。(就像算命师一样)
如您所见,缓存可能会变得复杂。要专业地做到这一点,最好使用一些现成的缓存实现。我们在这里使用的缓存只是冰山一角。或者,它甚至只是在它身上瞥见的阳光。
其余的类都相当标准,我们已经详细讨论了一些内容——对您的知识的一个很好的检查就是理解equals()
、hashCode()
和toString()
方法是如何以这种方式实现的。我实现了toString()
方法来帮助我进行调试,但它也被用于接下来的示例输出中。方法如下:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || !(o instanceof Guess)) return false;
var guess = (Guess) o;
return Arrays.equals(colors, guess.colors);
}
@Override
public int hashCode() {
return Arrays.hashCode(colors);
}
@Override
public String toString() {
if (this == none) {
return "none";
} else {
String s = "";
for (int i = colors.length - 1; i >= 0; i--) {
s += colors[i];
}
return s;
}
}
主要地,这是我在开发并行算法时所需要的所有修改。在这些更改之后,代码看起来更好了,并且很好地描述了功能,因此我们可以关注本章的主要主题如何在 Java 中并行执行代码。
Java 中代码的并行执行是在线程中完成的。您可能知道 Java 运行时中有一个Thread
对象,但如果不了解计算机中的线程是什么,就没有意义了。在下面的小节中,我们将学习这些线程是什么,如何启动一个新线程,如何同步线程之间的数据交换,最后,将所有这些放在一起并实现 Mastermind 游戏并行猜测算法。
打开计算机电源后,启动的程序是操作系统(OS)。操作系统控制机器硬件和可以在机器上运行的程序。当你启动一个程序时,操作系统会创建一个新的进程。这意味着操作系统在一个表(数组)中分配一个新的条目,在这个表(数组)中它管理进程,并填充它知道的和需要知道的有关进程的参数。例如,它注册允许进程使用的内存段、进程的 ID、启动它的用户以及启动它的其他进程。你不能凭空开始一个进程。当你双击一个 EXE 文件时,你实际上告诉文件管理器(一个作为进程运行的程序)把 EXE 文件作为一个单独的进程启动。浏览器通过一个 API 调用系统,并请求操作系统这样做。操作系统将把资源管理器进程注册为新进程的父进程。此时操作系统实际上并不启动进程,而是创建它随后启动进程所需的所有数据,当有一些空闲的 CPU 资源时,进程启动,然后很快暂停,重新启动,然后暂停,依此类推。您不会注意到它,因为操作系统会一次又一次地启动它,并且总是反复暂停进程。它需要这样做才能为所有进程提供运行的可能性。这样,我们可以体验到所有进程同时运行。实际上,进程不会在单个处理器上同时运行,但它们经常会有时间段运行,因此我们感觉它们一直在运行。
如果计算机中有多个 CPU,那么进程实际上可以与有 CPU 的多个进程同时运行。随着集成的日益高级,台式计算机拥有包含多个核心的 CPU,它们几乎与单独的 CPU 一样运行。在我的机器上,我有四个内核,每个内核都能同时执行两个线程;所以,我的 MacOS 几乎就像一台 8 CPU 机器。当我开始工作时,一台 8 CPU 的电脑是一台价值百万美元的机器。
进程有不同的记忆。允许它们使用内存的一部分,如果进程试图使用不属于它的部分,处理器将停止这样做。操作系统将终止进程。
试想一下,最初的 Unix 开发人员将停止进程的程序命名为kill
,他们一定很沮丧。停止一个进程叫做终止它。就像中世纪,他们砍掉了一个重罪犯的手。你碰错了记忆的一部分,就死定了。我不想成为一个过程。
操作系统的内存处理非常复杂,除了将进程彼此分离之外。当内存不足时,操作系统会将内存的一部分写入磁盘,释放内存,并在需要时重新加载该部分。这是一个非常复杂、低层次实现和高度优化的算法,由特殊的硬件操作支持。这是操作系统的责任。
当我说操作系统在时隙中执行进程时,我简化了这种情况的实际发生方式。每个进程都有一个或多个线程,线程被执行。线程是由外部调度器管理的最小执行。较旧的操作系统没有线程的概念,正在执行进程。事实上,第一个线程实现只是共享内存的进程的副本。
如果你读一些旧的东西,你可能会听到术语轻量级进程。意思是一根线。
重要的是线程没有自己的内存。他们利用记忆的过程。换句话说,在同一进程中运行的线程对同一内存段具有不可区分的访问权限。
实现并行算法的可能性,该算法在机器中使用多个核非常强大,但同时,它可能会导致错误:
假设两个线程递增相同的长变量。增量首先计算低 32 位的增量值,如果有溢出位,则计算高 32 位的增量值。这是操作系统可能中断的两个或多个步骤。一个线程可能会增加低 32 位,它会记住对高 32 位有一些操作,开始计算,但在中断之前没有时间存储结果。然后,另一个线程增加低 32 位,高 32 位,然后第一个线程只保存它计算的高 32 位。结果变得混乱。在旧的 32 位 Java 实现上,演示这种效果非常容易。在 64 位 Java 实现中,所有的 64 位都加载到寄存器中,并在一个步骤中保存回内存,因此演示这个多线程不是那么容易,但并不意味着没有多线程。
当一个线程暂停而另一个线程启动时,操作系统必须执行上下文切换。这意味着,除其他外,必须保存 CPU 寄存器,然后将其设置为其他线程应有的值。上下文切换总是保存线程的状态,并加载要启动的线程先前保存的状态。这是一个 CPU 寄存器级别。这种上下文切换非常耗时;因此,上下文切换越多,用于线程管理的 CPU 资源就越多,而不是让它们运行。另一方面,如果没有足够的开关,一些线程可能没有足够的时间执行,程序就会挂起。
Java 版本 11 没有纤程,但是有一些库支持有限的纤程处理,还有一个 JDK 项目,其目标是拥有支持纤程的更高版本的 JVM。因此,我们迟早会有 Java 中的纤程,因此,理解和了解它们是什么很重要。
纤程是比线更细的单位。在线程中执行的程序代码可能会决定放弃执行,并告诉纤程管理器只执行其他纤程。有什么意义?为什么它比使用另一个线程更好?原因是这样,纤程可以避免部分上下文切换。上下文切换不能完全避免,因为开始执行它的代码的不同部分可能以完全不同的方式使用 CPU 寄存器。因为是同一个线程,所以上下文切换不是操作系统的任务,而是应用的任务。
操作系统不知道是否使用了寄存器的值。寄存器中有位,只有看到处理器的状态,没有人能分辨出这些位是与当前代码执行相关,还是恰好以这种方式存在。编译器生成的程序确实知道哪些寄存器很重要,哪些寄存器可以忽略。这些信息在代码中的位置不同,但当需要交换机时,纤程会将需要在该点进行切换的信息传递给进行切换的代码。
编译器计算这些信息,但 Java 在当前版本中不支持纤程。在 Java 中实现纤程的工具在编译阶段之后会分析和修改类的字节码。
Golang 的 GoRoutine 是纤程类型,这就是为什么您可以轻松地在 Go 中启动数千个,甚至数百万个 GoRoutine 的原因,但是建议您将 Java 中的线程数限制为较低的数目。他们不是一回事。
尽管术语轻量线程正在慢慢消失,被越来越少的人使用,但纤程仍然经常被称为轻量线。
Java 中的一切(几乎)都是一个对象。如果我们想启动一个新线程,我们将需要一个对象,因此,一个代表线程的类。这个类是java.lang.Thread
,它内置在 JDK 中。当您启动 Java 代码时,JVM 会自动创建一些Thread
对象,并使用它们来运行它所需要的不同任务。如果您启动了 VisualVM,您可以选择任何 JVM 进程的线程选项卡,并查看 JVM 中的实际线程。例如,我启动的 VisualVM 有 29 个活动线程。其中一个是名为main
的线程。这是一个开始执行main
方法的方法(惊喜!)。main
线程启动了大多数其他线程。当我们要编写一个多线程应用时,我们必须创建新的Thread
对象并启动它们。最简单的方法是启动new Thread()
,然后在线程上调用start()
方法。它将开始一个新的Thread
,它将立即结束,因为我们没有给它任何事情做。在 JDK 中,Thread
类不执行我们的业务逻辑。以下是指定业务逻辑的两种方法:
- 创建实现
Runnable
接口的类并将其实例传递给Thread
对象 - 创建扩展
Thread
类并覆盖run
方法的类
下面的代码块是一个非常简单的演示程序:
package packt.java189fundamentals.thread;
public class SimpleThreadIntermingling {
public static void main(String[] args) {
Thread t1 = new MyThread("t1");
Thread t2 = new MyThread("t2");
t1.start();
t2.start();
System.out.print("started ");
}
static class MyThread extends Thread {
private final String name;
MyThread(String name) {
this.name = name;
}
@Override
public void run() {
for (int i = 1; i < 1000; i++) {
System.out.print(name + " " + i + ", ");
}
}
}
}
前面的代码创建两个线程,然后一个接一个地启动它们。当调用start
方法时,它调度要执行的线程对象,然后返回。因此,当调用线程继续执行时,新线程将很快开始异步执行。在下面的示例中,两个线程和main
线程并行运行,并创建如下输出:
started t2 1, t2 2, t2 3, t2 4, t2 5, t2 6, t2 7, t2 8, t1 1, t2 9, t2 10, t2 11, t2 12,...
实际输出随运行而变化。没有明确的执行顺序,或者线程如何访问单屏幕输出。甚至不能保证在每次执行中,消息started
都是在任何线程消息之前打印的。
为了更好地理解这一点,我们查看线程的状态图。Java 线程可以处于以下状态之一:
NEW
RUNNABLE
BLOCKED
WAITING
TIMED_WAITING
TERMINATED
这些状态在enumThread.State
中定义。创建新线程对象时,它处于NEW
状态。此时,线程并没有什么特别之处,它只是一个对象,但操作系统的执行调度并不知道它。在某种意义上,它只是 JVM 分配的一块内存。
当启动方法被调用时,关于线程的信息被传递给操作系统,操作系统对线程进行调度,以便在有适当的时隙时可以由它执行。这样做是一种足智多谋的行为,这就是为什么我们不创建,尤其是不在需要时才开始新的Thread
对象的原因。我们不会创建新的Threads
,而是将现有的线程保留一段时间,即使目前不需要,如果有合适的线程,也会重用现有的线程。
当操作系统调度和执行线程时,操作系统中的线程也可以处于运行状态,也可以处于可运行状态。目前,Java JDK API 没有很好的理由将两者区分开来。那就没用了。当一个线程处于RUNNABLE
状态时,从线程内部询问它是否真的在运行,如果代码刚从Thread
类中实现的getState()
方法返回,那么它就会运行。如果它没有运行,它本来就不会从调用中返回。更进一步说,在未运行的Thread
中调用方法getState()
也是不可能的。如果getState()
方法是从另一个线程调用的,那么在该方法返回时与另一个线程相关的结果将是无意义的。到那时,操作系统可能已经多次停止或启动被查询的线程。
当线程中执行的代码试图访问当前不可用的资源时,线程处于BLOCKED
状态。为了避免资源的不断轮询,操作系统提供了一种有效的通知机制,以便线程在需要的资源可用时返回到RUNNABLE
状态。
线程在等待其他线程或锁时处于WAIT
或TIMED_WAITING
状态。TIMED_WAITING
等待开始时的状态,调用有超时的方法版本。
最后,当线程完成执行后,达到TERMINATED
状态。如果在前面示例的末尾附加以下行,则将得到一个TERMINATED
打印输出,并向屏幕抛出异常,抱怨线程状态非法,这是因为您无法启动已终止的线程:
System.out.println();
System.out.println(t1.getState());
System.out.println();
t1.start();
我们可以创建一个实现Runnable
接口的类,而不是扩展Thread
类来定义异步执行什么,这样做与OO编程方法更为一致。我们在类中实现的东西不是线程的功能。它更像是一种可以执行的东西。这是一个可以运行的东西。
如果在不同的线程中执行是异步的,或者在调用run
方法的同一个线程中执行,那么这是一个需要分离的不同关注点。如果这样做的话,我们可以将类作为构造器参数传递给一个Thread
对象。对Thread
对象调用start
将启动我们传递的对象的run
方法。这不是收益。好处是我们还可以将Runnable
对象传递给Executor
(可怕的名字,哈!)。Executor
是一个接口,实现以高效的方式在Thread
对象中执行Runnable
(还有Callable
,见下文)对象。执行者通常有一个准备就绪并处于BLOCKED
状态的Thread
对象池。当Executor
有一个新任务要执行时,它将它交给Thread
对象之一,并释放阻塞线程的锁。Thread
进入RUNNABLE
状态,执行Runnable
,再次被阻塞。它不会终止,因此,可以在以后重用它来执行另一个Runnable
。这样,Executor
实现就避免了操作系统中线程注册的资源消耗过程。
专业应用代码从不创建新的Thread
。应用代码使用框架来处理代码的并行执行,或者使用一些ExecutorService
提供的Executor
实现来启动Runnable
或Callable
对象。
我们已经讨论了在开发并行程序时可能遇到的许多问题。在本节中,我们将用解决问题的常用术语对它们进行总结。术语不仅有趣,而且在与同事交谈时也很重要,这样你们就可以互相理解了。
死锁是最臭名昭著的并行编程陷阱,因此,我们将从这个开始。为了描述这种情况,我们将采用官僚的比喻。
官僚必须在纸上盖章。为此,他需要邮票和纸张。首先,他走到放邮票的抽屉里拿了邮票。然后,他走向放纸的抽屉,拿起纸。他在邮票上涂上墨水,然后在纸上按。然后,他把邮票和纸放回原处。一切都是桃色的;我们在云端 9。
如果另一个官僚先拿报纸,然后再拿邮票,会怎么样?他们很快就会变成一个拿着邮票等着报纸的官僚,一个拿着报纸等着邮票的官僚。而且,他们可能只是呆在那里,永远冻结,然后越来越多的人开始等待这些锁,纸张永远不会被盖章,整个系统陷入冻结的无政府状态。
为了避免这种情况,必须对锁进行排序,并且应该始终以相同的顺序获取锁。在前面的示例中,首先获取墨垫,然后获取戳记的简单约定解决了问题。无论是谁得到了邮票,都可以肯定墨水垫是免费的,或者很快就会免费的。
我们讨论竞态条件,当计算结果可能基于不同并行运行线程的速度和 CPU 访问而不同时。我们来看看以下两种方法:
void method1(){
1 a = b;
2 b = a+1;
}
void method2(){
3 c = b;
4 b = c+2;
}
线路的顺序可以是 1234、1324、1342、3412、3142 或 3142。四行执行顺序,可保证1
在2
和3
运行前4
前运行,但无其他限制。假设b
的值在开始时为零,则b
的值在段执行结束时为 1 或2£
。这几乎是我们永远不想要的。我们更喜欢我们的程序的行为不是随机的,除非,也许在实现随机生成器时。
注意,并行主谋游戏的实现也面临着一种种族条件。实际猜测很大程度上取决于不同线程的速度,但从最终结果的角度来看,这与此无关。我们可能在不同的运行中有不同的猜测,这样,算法就不确定了。我们所保证的是我们找到了最终的解决方案。
在许多情况下,可能会发生线程在等待锁,锁保护资源不受并发访问的影响。如果资源不能同时被多个线程使用,并且线程数较多,则线程将处于饥饿状态。然而,在许多情况下,资源可以以某种方式组织,以便线程能够访问资源提供的某些服务,并且锁结构可以更少地限制。在这种情况下,锁被过度使用,并且可以修复这种情况,而不为线程分配更多资源。可以使用多个锁来控制对资源不同功能的访问。
饥饿是指多个线程等待一个资源试图获取锁,而一些线程只有在很长一段时间后才能访问该锁,或者从来没有访问过该锁。当锁被释放并且有线程在等待它时,其中一个线程就可以获得锁。如果线程等待的时间足够长,通常无法保证它能获得锁。这样的机制需要对线程进行密集的管理,在等待队列中对线程进行排序。由于锁定应该是一种低延迟和高性能的操作,因此即使只有几个 CPU 时钟周期也很重要;因此,默认情况下,锁不提供这种类型的公平访问。如果锁只有一个线程在等待,那么在线程调度中不浪费时间和公平性是一个很好的方法。锁的主要目标不是调度等待的线程,而是阻止对资源的并行访问。
它就像一家商店。如果有人在收银台,你就等着。它是隐式内置的锁。如果人们不排队等候收银台,只要几乎总是有一个免费的就没有问题。然而,当有几个人在收银台前等候时,如果没有排队和等待顺序,肯定会导致缓慢进入收银台的人等待很长时间。通常,公平性和创建等待线程(客户)队列的解决方案不是一个好的解决方案。好的解决办法是消除导致排队等候的情况。你可以雇佣更多的收银员,或者你可以做一些完全不同的事情,使峰值负荷更小。在商店里,你可以给在非高峰时间开车来的顾客打折。在编程中,通常可以应用几种技术,这取决于我们编写的实际业务,而锁的公平调度通常是一种解决方法。
ExecutorService
是 JDK 中的一个接口。接口的实现可以异步执行一个Runnable
或Callable
类。接口只定义实现的 API,不要求调用是异步的。实际上,这就是为什么我们使用这样的服务。以同步方式调用Runnable
接口的run
方法只是调用一个方法。我们不需要特殊的类。
Runnable
接口定义了一个run
方法。它没有参数,不返回值,也不引发异常。Callable
接口是参数化的,它定义的唯一方法call
没有参数,但返回泛型值,还可能抛出Exception
。在代码中,如果我们只想运行某个东西,我们就实现了Runnable
,如果我们想返回某个东西,我们就实现了Callable
。这两个接口都是函数式接口;因此,它们是使用 Lambda 实现的很好的候选接口。
为了获得一个ExecutorService
实现的实例,我们可以使用实用类Executors
。通常,当 JDK 中有一个XYZ
接口时,可以有一个XYZs
(复数)工具类,为接口的实现提供工厂。如果我们想多次启动t1
任务,我们可以不创建新的Thread
就这样做。我们应该使用以下执行器服务:
public class ThreadIntermingling {
public static void main(String[] args) throws InterruptedException, ExecutionException {
final var es = Executors.newFixedThreadPool(2);
final var t1 = new MyRunnable("t1");
final var t2 = new MyRunnable("t2");
final Future<?> f1 = es.submit(t1);
final Future<?> f2 = es.submit(t2);
System.out.print("started ");
var o = f1.get();
System.out.println("object returned " + o);
f2.get();
System.out.println();
es.submit(t1);
es.shutdown();
}
static class MyRunnable implements Runnable {
private final String name;
MyRunnable(String name) {
this.name = name;
}
@Override
public void run() {
for (int i = 1; i < 10; i++) {
System.out.print(name + " " + i + ", ");
}
}
}
}
这次,我们第二次提交任务时没有异常。在本例中,我们使用的是一个固定大小的线程池,该线程池有两个Thread
插槽。因为我们只想同时启动两个线程,这就足够了。有些实现动态地扩展和缩小池的大小。当我们想要限制线程数量或从其他信息源中事先知道线程数时,应该使用固定大小的池。在这种情况下,将池的大小更改为一个是一个好的实验,并且在这种情况下,第二个任务在第一个任务完成之前不会开始。服务将没有另一个线程用于t2
,并且必须等到池中的一个线程和唯一的Thread
可用。
当我们将任务提交给服务时,即使任务当前无法执行,它也会返回。这些任务被放入队列中,一旦有足够的资源启动它们,它们就会立即开始执行。submit
方法返回一个Future<?>
对象,正如我们在前面的示例中看到的那样。
它就像一张服务票。你把车交给修理工,然后你就得到了一张罚单。在汽车修好之前,你不需要呆在那里,但是,你可以随时询问汽车是否准备好了。你只需要一张票。你也可以决定等到车子准备好。物体是类似的东西。你没有得到你需要的值。它将异步计算。然而,有一个Future
承诺它会在那里,而您访问所需对象的票证就是Future
对象。
当您有一个Future
对象时,您可以调用isDone()
方法来查看它是否准备就绪。您可以开始等待它在有或没有超时的情况下调用get()
。您也可以取消执行它的任务,但是,在这种情况下,结果可能是有问题的。就像,在你的车的情况下,如果你决定取消任务,你可能会得到你的车与电机拆解。类似地,取消一个没有准备好的任务可能会导致资源丢失、数据库连接被打开和无法访问(这对我来说是一个痛苦的记忆,即使 10 年之后),或者只是一个乱七八糟的不可用对象。准备要取消的任务或不要取消它们。
在前面的示例中,由于我们提交了Runnable
对象,而不是Callable
对象,所以Future
没有返回值。在这种情况下,不使用传递给Future
的值。通常是null
,但这并不是什么可依赖的。
最后也是最重要的一件事,许多开发人员都错过了,即使是我,在多年没有使用代码编写多线程 JavaAPI 之后,就是关闭了ExecutorService
。创建了ExecutorService
,它有Thread
个元素。当所有非守护线程停止时,JVM 将停止。”直到胖女人唱歌,一切才结束。”
如果线程在启动前被设置为守护进程(调用setDaemon(true)
),那么它就是守护线程。一个自动成为启动它的守护线程的线程也是守护线程。当所有其他线程都完成并且 JVM 想要完成时,守护线程被 JVM 停止。JVM 本身执行的一些线程是守护线程,但是在应用中创建守护线程可能没有实际用途。
不关闭服务只会阻止 JVM 停止。在main
方法完成后,代码将挂起。为了告诉ExecutorService
不需要它拥有的线程,我们必须shutdown
服务。调用只会启动关机并立即返回。在这种情况下,我们不想等待。无论如何,JVM 都会这样做。如果我们需要等待,我们将不得不调用awaitTermination
。
Java 版本 1.8 引入了接口Future
—CompletableFuture
的新实现。java.util.concurrent.CompletableFuture
类可用于异步执行定义回调的程序以处理结果。由于 Java1.8 还引入了 Lambda 表达式,因此可以使用它们来描述回调:
public static void main(String[] args) throws ExecutionException, InterruptedException {
var future = CompletableFuture.supplyAsync(() ->
{
var negative = true;
var pi = 0.0;
for (int i = 3; i < 100000; i += 2) {
if (negative)
pi -= (1.0 / i);
else
pi += (1.0 / i);
negative = !negative;
}
pi += 1.0;
pi *= 4.0;
return pi;
}
).thenAcceptAsync(piCalculated -> System.out.println("pi is " + piCalculated));
System.out.println("All is scheduled");
future.get();
}
completable future 类实现了Future
接口,但它还提供了其他方法,当我们需要描述异步代码的执行时,它也提供了其他方便的方法。额外的方法在CompletionStage
接口中定义,起初这个名字有点奇怪,但我们很快就会理解它的真正含义。
我们已经看到了在这个接口中定义的许多方法之一-thenAcceptAsync()
。前面的代码创建了一个由 Lambda 表达式定义的完全Future
。静态方法supplyAsync()
接受Supplier
作为参数。Java 的线程系统稍后会调用这个供应器。此方法的返回值是一个CompletableFuture
,用于使用thenAcceptAsync()
方法创建另一个CompletableFuture
。第二个CompletableFuture
与第一个CompletableFuture
相连。只有当第一个完成时,它才会开始。thenAcceptAsync()
的参数是一个消费者,它将消费Supplier
提供的第一个CompletableFuture
的结果。代码的结构可以用以下伪代码来描述:
CompletableFuture.supplyAsync( supply_value ).thenAcceptAsync( consume_the_value )
它说启动由supply_value
表示的Supplier
,当它完成时,将这个值提供给由consume_the_value
表示的消费者。示例代码计算 PI 的值并提供该值。consume_the_value
部分将值打印到输出。当我们运行代码时,文本All is scheduled
可能会首先打印到输出中,然后才打印 PI 的计算值。
类还实现了许多其他方法。当CompletableFuture
不产生任何值或者我们不需要消耗值时,我们应该使用thenRunAsync(Runnable r)
方法。
如果我们想消费值,同时又想从中创造新的值,那么我们应该使用thenApplyAsync()
方法。此方法的参数是一个Function
,它获取运行后CompletableFuture
的结果,结果是CompletableFuture thenApplyAsync()
返回的值。
在CompletableFuture
完成之后,还有许多其他方法执行代码。所有这些都用于在第一个可完成的将来完成后指定某个回调。CompletableFuture
代码的执行可能引发异常。在这种情况下,CompletableFuture
就完成了;它不会抛出异常。异常被捕获并存储在CompletableFuture
对象中,只有当我们想访问调用get()
方法的结果时才会抛出异常。方法get()
抛出一个封装原始异常的ExecutionException
。join()
方法抛出原始异常。
像thenAcceptAsync()
这样的方法有它们的同步对,例如thenAccept()
。如果调用此函数,则将执行传递的代码:
- 如果此代码所依赖的
CompletableFuture
尚未完成,则使用用于执行原始CompletableFuture
的同一线程;或者 - 如果
CompletableFuture
已经完成,则使用普通调用线程
换句话说,如果我们再看看伪代码:
var cf = CompletableFuture.supplyAsync( supply_value );
cf.thenAccept( consume_the_value )
但这次是thenAccept()
而不是thenAcceptAsync()
,所以执行supply_value
表示的代码的线程在完成supply_value
后继续执行consume_the_value
,或者,如果调用方法thenAccept()
时supply_value
的执行已经完成,则只执行如下:
consume_the_value( cf.get() )
在本例中,代码consume_the_value
只是同步执行。(请注意,如果发生异常,它将被存储,而不是直接抛出。)
使用CompletableFuture
的最佳用例是当我们进行异步计算并且需要回调方法来处理结果时。
ForkJoinPool
是一个特殊的ExecutorService
,它有执行ForkJoinTask
对象的方法。当我们要执行的任务可以被分解成许多小任务,然后当结果可用时,这些类非常方便。使用这个执行器,我们不需要关心线程池的大小和关闭执行器。线程池的大小根据给定机器上的处理器数量进行调整,以获得最佳性能。因为ForkJoinPool
是一个特殊的ExecutorService
是为短期运行的任务而设计的,所以它不希望有任何任务在那里停留更长的时间,也不希望在没有更多任务要运行时需要任何任务。因此,它作为守护线程执行;当 JVM 关闭时,ForkJoinPool
自动停止。
为了创建任务,程序员应该扩展java.util.concurrent.RecursiveTask
或java.util.concurrent.RecursiveAction
。第一个是在任务有返回值时使用的,第二个是在没有返回计算值时使用的。它们之所以被称为递归的,是因为很多时候,这些任务会分解它们必须解决的问题,并通过 Fork/Join API 异步调用这些任务。
使用此 API 要解决的一个典型问题是快速排序。在第 3 章“优化专业排序代码”中,我们创建了两个版本的快速排序算法,一个使用递归调用,一个不使用递归调用。我们还可以创建一个新的任务,它不是递归地调用自己,而是将任务调度到另一个处理器执行。调度是ForkJoinPool
实现ExecutorService
的任务。
您可以重温第 3 章中的Qsort.java
代码,“优化专业排序代码”。以下是使用ForkJoinPool
的版本,没有一些明显的代码,包括构造器和final
字段定义:
public void qsort(Sortable<E> sortable, int start, int end) {
ForkJoinPool pool = new ForkJoinPool();
pool.invoke(new RASort(sortable, start, end));
}
private class RASort extends RecursiveAction {
final Sortable<E> sortable;
final int start, end;
public RASort(Sortable<E> sortable, int start, int end) {
this.sortable = sortable;
this.start = start;
this.end = end;
}
public void compute() {
if (start < end) {
final E pivot = sortable.get(start);
int cutIndex = partitioner.partition(sortable, start, end, pivot);
if (cutIndex == start) {
cutIndex++;
}
RecursiveAction left = new RASort(sortable, start, cutIndex - 1);
RecursiveAction right = new RASort(sortable, cutIndex, end);
invokeAll(left, right);
left.join();
right.join();
}
}
}
数组被枢轴元素拆分后,创建两个RecursiveAction
对象。它们存储对数组的左侧和右侧进行排序所需的所有信息。当invokeAll()
被调用时,这些操作被安排。invokeAll()
方法由前面的代码通过RecursiveAction
从ForkJoinClass
类继承,而RecursiveAction
本身在该代码中进行了扩展。
API 和 Oracle 的 Javadoc 文档的应用上都有很好的阅读材料。
既然我们可以启动线程并创建并行运行的代码,现在是时候谈谈这些线程如何在彼此之间交换数据了。乍一看,这似乎相当简单。线程使用相同的共享内存;因此,它们可以读取和写入 Java 访问保护允许它们的所有变量。这是正确的,只是有些线程可能只是决定不读取内存。毕竟,如果他们最近刚刚读取了一个特定变量的值,如果没有修改,为什么还要从内存中再次读取到寄存器中呢?谁会修改它?下面是一个简短的例子:
package packt.java189fundamentals.thread;
public class VolatileDemonstration implements Runnable {
private final Object o;
private static final Object NON_NULL = new Object();
@Override
public void run() {
while( o == null );
System.out.println("o is not null");
}
public VolatileDemonstration() throws InterruptedException {
new Thread(this).start();
Thread.sleep(1000);
this.o = NON_NULL;
}
public static void main(String[] args) throws InterruptedException {
VolatileDemonstration me = new VolatileDemonstration();
}
}
会发生什么?您可能期望代码启动,启动新线程,然后,当main
线程将对象设置为非null
的对象时,它会停止吗?不会的。
它可能会在一些 Java 实现上停止,但在大多数实现中,它只会继续旋转。原因是 JIT 编译器优化了代码。它看到循环什么也不做,而且变量永远不会是非空的。允许假设因为没有声明为volatile
的变量不应该被不同的线程修改,所以 JIT 可以进行优化。如果我们将Object o
变量声明为volatile
,那么代码将停止。您还必须删除final
关键字,因为变量不能同时是final
和volatile
。
如果您试图删除对sleep
的调用,代码也将停止。然而,这并不能解决这个问题。原因是 JIT 优化只在大约 5000 次代码执行循环之后才开始。在此之前,代码运行简单,并在优化之前停止,这将消除对非易失性变量的额外访问(通常不需要)。
如果这是如此可怕,那么为什么我们不声明所有变量都是易变的呢?为什么 Java 不能为我们做到这一点?答案是速度。为了更深入地理解这一点,我们将用办公室和官僚来比喻。
现在,CPU 在 2 到 4GHz 频率的处理器上运行。这意味着处理器每秒得到 2 到 4 倍于10**9
的时钟信号来做某事。处理器不能执行比这更快的任何原子操作,而且也没有理由创建一个比处理器可以遵循的更快的时钟。这意味着 CPU 在半纳秒或四分之一纳秒内执行一个简单的操作,例如递增寄存器。这是处理器的心跳,如果我们认为官僚是人,他们是谁,那么它相当于一秒钟,大约,他们的心跳。在我们的想象中,这会将计算机的运行速度减慢到可以理解的速度。
处理器在芯片上有不同级别的寄存器和高速缓存;L1、L2,有时还有 L3;还有存储器、SSD、磁盘、磁盘、网络和磁带,可能需要它们来检索数据。
访问一级缓存中的数据大约需要 0.5 ns。你可以抓起你桌上半秒钟的一张纸。访问二级缓存中的数据需要 7 ns。这是抽屉里的一张纸。你得把椅子往后推一点,弯曲成坐姿,拉出抽屉,拿着纸,把抽屉往后推,把纸抬起来放在桌子上;这需要 10 秒钟,左右。
主存读取为 100ns。官僚站起来,走到靠墙的共享文件库,等着其他官僚把文件拿出来或放回去,选择抽屉,把它拿出来,拿着文件,走回办公桌。这需要两分钟。这是一种易变的变量访问,每次你在一个文档上写一个单词,它必须做两次,一次读,一次写,即使你碰巧知道下一件事就是在同一张纸上填写表单的另一个字段。
现代架构没有多个 cpu,而是有多个核的单个 cpu,速度要快一些。一个内核可以检查另一个内核的缓存,以查看是否对同一变量进行了任何修改。这将易变访问加速到 20ns 左右,这仍然比非易变慢一个数量级。
尽管其余部分不太关注多线程编程,但这里值得一提,因为它很好地理解了不同的时间量级。
从 SSD 读取一个块(通常为 4k 块)需要 150000ns。以人类的速度,这比 5 天多一点。在 Gb 本地以太网上通过网络向服务器读取或发送数据需要 0.5 毫秒,这就好像是在等一个月的时间。如果网络上的数据是在一个旋转的磁盘上,那么寻道时间加起来(直到磁盘旋转以使磁表面的一部分进入读取头下的时间)为 20ms。对于在我们的计算环境中来回运行的想象中的小官僚来说,这大约是一年。
如果我们在互联网上通过大西洋发送一个网络数据包,大约需要 150 毫秒。这就像 14 年,而这仅仅是一个数据包;如果我们要通过海洋发送数据,这将构成数千年的历史。如果我们计算一台机器启动一分钟,它相当于我们整个文明的时间跨度。
当我们想了解 CPU 大部分时间在做什么时,我们应该考虑这些数字。它等待着。此外,当你想到现实生活中官僚的速度时,这个比喻也有助于安抚你的神经。如果我们考虑他们的心跳,他们毕竟没有那么慢,这意味着他们有心脏。然而,让我们回到现实生活中,CPU,L1 和 L2 缓存,以及易失性变量。
让我们在示例代码中修改o
变量的声明,如下所示:
private volatile Object o = null;
前面的代码运行良好,大约一秒钟后停止。任何 Java 实现都必须保证多个线程可以访问volatile
字段,并且该字段的值是一致更新的。这并不意味着volatile
声明将解决所有的同步问题,而是保证不同的变量及其值变化关系是一致的。例如,让我们考虑在一个方法中增加以下两个字段:
private int i=0,j=0;
public void method(){
i++; j++;
}
在前面的代码中,在不同的线程中读取i
和j
可能永远不会产生i>j
。如果没有volatile
声明,编译器可以自由地重新组织增量操作的执行,因此,它不能保证异步线程读取一致的值。
声明变量不是确保线程之间一致性的唯一工具。Java 语言中还有其他工具,其中一个是同步块。synchronized
关键字是语言的一部分,它可以在方法或程序块前面使用,该方法、构造器或初始化器块中。
Java 程序中的每个对象都有一个监视器,可以被任何正在运行的线程锁定和解锁。当一个线程锁定一个监视器时,据说该线程持有该锁,并且没有两个线程可以同时持有一个监视器的锁。如果一个线程试图锁定一个已经被锁定的监视器,它会得到BLOCKED
,直到监视器被释放。同步块以synchronized
关键字开始,然后在括号之间指定一个对象实例,然后发生阻塞。下面的小程序演示了synchronized
块:
package packt.java189fundamentals.thread;
public class SynchronizedDemo implements Runnable {
public static final int N = 1000;
public static final int MAX_TRY = 1_000_000;
private final char threadChar;
private final StringBuffer sb;
public SynchronizedDemo(char threadChar, StringBuffer sb) {
this.threadChar = threadChar;
this.sb = sb;
}
@Override
public void run() {
for (int i = 0; i < N; i++) {
synchronized (sb) {
sb.append(threadChar);
sleep();
sb.append(threadChar);
}
}
}
private void sleep() {
try {
Thread.sleep(1);
} catch (InterruptedException ignored) {
}
}
public static void main(String[] args) {
boolean failed = false;
int tries = 0;
while (!failed && tries < MAX_TRY) {
tries++;
StringBuffer sb = new StringBuffer(4 * N);
new Thread(new SynchronizedDemo('a', sb)).start();
new Thread(new SynchronizedDemo('b', sb)).start();
failed = sb.indexOf("aba") != -1 || sb.indexOf("bab") != -1;
}
System.out.println(failed ? "failed after " + tries + " tries" : "not failed");
}
}
代码从两个不同的线程开始。其中一个线程将aa
附加到名为sb
的StringBuffer
上。另一个附加bb
。这个附加操作分两个阶段进行,中间是睡眠。睡眠是为了避免 JIT 将两个单独的步骤优化为一个步骤。每个线程执行append
1000 次,每次追加a
或b
两次。由于两个append
一个接一个,并且它们在synchronized
块内,所以aba
或bab
序列不可能进入StringBuffer
中。当一个线程执行同步块时,另一个线程不能执行它。
如果我删除synchronized
块,那么我用来测试 Java HotSpot(TM)64 位服务器 VM 的 JVM(对于本书的第二版,构建 9-ea+121,混合模式和 18.3 b 构建 10+46,混合模式)打印出失败,尝试数量大约为几百次。(看看 Packt 提供的代码库中的SynchronizedDemoFailing
类。)
它清楚地说明了同步意味着什么,但它也将我们的注意力吸引到另一个重要的现象上。错误只发生在大约每几十万次执行中。这是极为罕见的,即使这个例子是用来证明这样的灾难。如果一个 bug 很少出现,那么很难重现,甚至更难调试和修复。大多数同步错误都以神秘的方式表现出来,修复它们通常是仔细检查代码而不是调试的结果。因此,在启动商业多线程应用之前,清楚地了解 Java 多线程行为的真正本质是非常重要的。
synchronized
关键字也可以用在方法前面。在这种情况下,获取锁的对象是this
对象。在static
方法的情况下,对整个类执行同步。
在Object
类中实现了五个方法,可以用来获得进一步的同步功能—wait
,其中有三个不同的超时参数签名notify
和notifyAll
。要调用wait
,调用线程应该拥有调用wait
的Object
的锁。这意味着您只能从同步块内部调用wait
,当调用它时,线程得到BLOCKED
并释放锁。当另一个线程对同一个Object
调用notifyAll
时,该线程进入RUNNABLE
状态。它无法立即继续执行,因为它无法获得对象上的锁。此时锁被刚才称为notifyAll
的线程所持有。然而,在另一个线程释放锁之后的某个时候,换句话说,它从synchronized
块中出来,等待的线程可以获取它并继续执行。
如果有更多线程在等待一个对象,那么所有线程都会脱离BLOCKED
状态。notify
方法只唤醒一个等待的线程。不能保证哪根线被唤醒。
wait
、notify
和notifyAll
的典型用法是当一个或多个线程正在创建被另一个或多个线程使用的对象时。对象在线程之间移动的存储是一种队列。使用者等待,直到队列中有要读取的内容,生产者将对象一个接一个地放入队列。生产者在队列中放入内容时通知消费者。如果队列中没有剩余的空间,生产者必须停止并等待,直到队列有一些空间。在这种情况下,生产者调用wait
方法。为了唤醒生产者,消费者在读到某样东西时会打电话给notifyAll
。
使用者在循环中使用队列中的对象,并且只有在队列中没有可读取的内容时才调用wait
。当生产者调用notifyAll
时,没有消费者等待,通知被忽略。它飞走了,但这不是问题;消费者没有等待。当消费者消费了一个对象并调用了notifyAll
,并且没有生产者等待时,情况也是一样的。这不是问题。
消费者消费,调用notifyAll
,在通知悬而未决后,找不到等待的生产者,生产者就开始等待,这是不可能发生的。这不可能发生,因为整个代码都在一个synchronized
块中,它确保没有生产者在关键部分。这就是为什么只有在获取Object
类的锁时才能调用wait
、notify
和notifyAll
的原因。
如果有许多使用者执行相同的代码,并且他们同样擅长使用对象,那么调用notify
而不是notifyAll
就是一种优化。在这种情况下,notifyAll
只会唤醒所有使用者线程。然而,只有幸运的人才会意识到他们被吵醒了;其他人会看到其他人已经逃脱了诱饵。
我建议您至少练习一次,以实现可用于在线程之间传递对象的阻塞队列。只作为实践来做,不要在生产中使用实践代码。从 Java1.5 开始,有BlockingQueue
接口的实现。用一个适合你需要的。在我们的示例代码中,我们也将这样做。
幸运的是你能用 Java11 编写代码。我在 Java1.4 的时候就开始专业地使用它,有一次,我不得不实现一个阻塞队列。有了 Java,生活变得越来越美好和轻松。
在专业代码中,我们通常避免使用synchronized
方法或块和volatile
字段以及wait
和notify
方法,如果可能的话,还可以使用notifyAll
。我们可以在线程之间使用异步通信,也可以将整个多线程过程传递给框架进行处理。在某些特殊情况下,当代码的性能很重要时,synchronized
和volatile
关键字是不可避免的,或者我们找不到更好的构造。有时,特定代码和数据结构的直接同步比 JDK 类提供的方法更有效。但是,应该注意的是,这些类也使用这些低级同步结构,因此它们的工作方式并不神奇。要从专业代码中学习,可以在实现自己的版本之前查看 JDK 类的代码。您将认识到,实现这些队列并不是那么简单;没有充分的理由,类的代码并不复杂。如果你觉得代码很简单,那就意味着你有足够的资历去知道哪些东西不能重新实现。或者,你甚至不知道你读了什么代码。
锁包含在 Java 中;每个Object
都有一个锁,线程在进入synchronized
块时可以获得该锁。我们已经讨论过了。在某些编程代码中,这种结构有时不是最优的。
在某些情况下,可以排列锁的结构以避免死锁。可能需要在B
之前获取锁A
,在C
之前获取B
。但是,A
应该尽快释放,以允许访问受锁D
保护的资源,也需要先锁A
。在复杂且高度并行的结构中,锁通常被构造为树。一个线程应该沿着树向下爬到一个表示获取锁的资源的叶子上。在攀爬的过程中,线程先抓住一个节点上的锁,然后抓住它下面的一个节点上的锁,然后释放上面的锁,就像一个真正的攀爬者在下降一样(或者攀爬,如果你想象树的叶子在顶部,这更真实;然而,图形通常显示树是颠倒的)。
你不能留下一个synchronized
块留在第一个街区内的另一个。同步块嵌套。java.util.concurrent.Lock
接口定义了处理这种情况的方法,并且在我们的代码中使用的 JDK 中也有实现。有锁时,可以调用lock()
和unlock()
方法,实际顺序在手中,可以写下一行代码,得到锁顺序:
a.lock(); b.lock(); a.unlock(); c.lock()
然而,伴随着巨大的自由,也伴随着巨大的责任。与同步块的情况不同,锁定和解锁并不与代码的执行序列相关联,在某些情况下,创建代码可能非常容易,因为在某些情况下,它只是丢失了一个锁而没有解锁,从而导致一些资源无法使用。这种情况类似于内存泄漏。你会分配(锁定)一些东西而忘记释放(解锁)它。一段时间后,程序将耗尽资源。
我个人的建议是尽可能避免使用锁,而是在线程之间使用更高级别的构造和异步通信,比如阻塞队列。
java.util.concurrent.Condition
接口在功能上与内置的wait()
、notify()
和notifyAll()
对象类似。任何Lock
的实现都应该创建新的Condition
对象,并将它们作为newCondition()
方法调用的结果返回。当线程有一个Condition
时,当线程有创建条件对象的锁时,它可以调用await()
、signal()
和signalAll()
。
其功能与前面提到的Object
方法非常相似。最大的区别是,你可以为一个Lock
创建许多Condition
对象,它们彼此独立地工作,而不是独立于Lock
。
ReentrantLock
是 JDK 中Lock
接口的最简单实现。创建这种类型的锁有两种方法,一种是使用公平策略,另一种是不使用公平策略。如果以true
作为参数调用ReentrantLock(Boolean fair)
构造器,那么在有多个线程等待的情况下,锁将被分配给等待锁时间最长的线程。这将避免线程等待过多的时间和饥饿。另一方面,以这种方式处理锁需要更多的来自ReentrantLock
代码的管理,并且运行速度较慢。(在测量代码之前,不要害怕代码太慢。)
这个类是ReadWriteLock
的一个实现。ReadWriteLock
是一种可用于并行读访问和独占写访问的锁。这意味着多个线程可以读取受锁保护的资源,但是当一个线程写入资源时,没有其他线程可以访问它,甚至在此期间也不能读取它。ReadWriteLock
只是readLock()
和writeLock()
方法返回的两个Lock
对象。为了获得对ReadWriteLock
的读访问权,代码必须调用myLock.readLock().lock()
,并获得对写锁myLock.writeLock().lock()
的访问权。获取其中一个锁并在实现中释放它与另一个锁是耦合的。例如,要获取写锁,任何线程都不应该具有活动的读锁。
使用不同的锁有几个复杂的地方。例如,可以获取读锁,但只要具有读锁,就无法获取写锁。必须先释放读锁才能获得写锁。这只是一个简单的细节,但这是一个新手程序员有很多次麻烦。为什么要这样实现?为什么程序要获得一个写锁,当它仍然不确定是否要写入资源时,从锁定其他线程的概率更高的意义上讲,写锁的成本更高?代码想要读取它,并且基于内容,它可能稍后决定要编写它。
问题不在于执行。库的开发人员决定了这个规则,并不是因为他们喜欢这样,也不是因为他们知道并行算法和死锁的可能性。当两个线程有readLock
并且每个线程都决定将锁升级到writeLock
时,它们本质上会创建死锁。每个人都会在等待writeLock
的时候拿着readLock
,没有人会得到它。
另一方面,您可以将writeLock
降级为readLock
,而无需冒风险,同时,有人获得writeLock
并修改资源。
原子类将原始类型值封装到对象中,并对其提供原子操作。我们讨论了竞争条件和可变变量。例如,如果我们有一个int
变量用作计数器,并且我们想为我们处理的对象分配一个唯一的值,我们可以增加该值并将结果用作唯一的 ID。但是,当多个线程使用同一代码时,我们不能确定在增加后读取的值。同时,另一个线程也可能增加该值。为了避免这种情况,我们必须将增量括起来,并将增量值赋给synchronized
块中的对象。这也可以使用AtomicInteger
来完成。
如果我们有一个变量AtomicInteger
,那么调用incrementAndGet
会增加类中包含的int
的值,并返回增加的值。为什么不使用同步块而使用它呢?第一个答案是,如果功能在 JDK 中,那么使用它会比再次实现它产生更少的代码行。维护您创建的代码的开发人员应该了解 JDK 库。另一方面,为他们学习代码需要时间,时间就是金钱。
另一个原因是,这些类经过了高度优化,而且它们通常使用特定于平台的本机代码来实现特性,这大大优于我们可以使用同步块实现的版本。过早地担心性能是不好的,但是当性能至关重要时,通常使用并行算法和线程之间的同步;因此,使用原子类的代码的性能很有可能是重要的。尽管如此,主要原因仍然是可读性和简单性。
java.util.concurrent.atomic
包中有AtomicInteger
、AtomicBoolean
、AtomicLong
、AtomicReference
等几种类别。它们都提供了特定于封装值的方法。
compareAndSet()
方法由每个原子类实现。这是具有以下格式的条件值设置操作:
boolean compareAndSet(expectedValue, updateValue);
当它应用于一个原子类时,它将实际值与一个expectedValue
进行比较,如果它们相同,则将值设置为updateValue
。如果值被更新,方法返回true
,并在原子操作中完成所有这一切。不用说,如果条件不成立并且没有执行更新,则返回值为false
。
你可能会问这样一个问题:如果这个方法在所有这些类中,为什么没有Interface
定义这个方法?原因是参数类型根据封装的类型不同而不同,这些类型是原始类型。由于原始类型还不能用作泛型类型,因此无法定义接口。
在AtomicXXXArray
的情况下,方法有一个额外的第一个参数,它是调用中处理的数组元素的索引。
就运行在不同处理器内核上的多个线程的重新排序和访问而言,封装的变量的处理方式与volatile
相同。原子类的实际实现可能使用特殊的硬件代码,这些代码可以提供比 Java 中的原始实现更好的性能,因此原子类可能比使用易失性变量和同步块的普通 Java 代码中实现的相同功能具有更好的性能。
一般的建议是,如果有可用的原子类,可以考虑使用原子类,您将发现自己正在为检查和设置、原子增量或加法操作创建一个同步块。
BlockingQueue
是一个用适合多线程应用使用的方法扩展标准Queue
接口的接口。此接口的任何实现都提供了允许不同线程将元素放入队列、从队列中拉出元素并等待队列中的元素的方法。
当队列中要存储新元素时,您可以add()
它、offer()
它或put()
它。这些是存储元素的方法的名称,它们做同样的事情,只是有点不同。如果队列已满且元素没有空间,add()
方法抛出异常。offer()
方法不抛出异常,而是根据操作是否成功返回true
或false
。如果可以将元素存储在队列中,则返回true
。还有一个版本的offer()
指定超时。如果在此期间无法将值存储在队列中,则该版本的方法将等待并仅返回false
。put()
方法是最简单的版本;它会等到它能完成它的工作。
当谈到队列中的可用空间时,不要感到困惑,不要把它与一般的 Java 内存管理混淆起来。如果没有更多的内存,垃圾收集器也无法释放任何内存,您肯定会得到一个OutOfMemoryError
。异常由add()
抛出,当达到队列限制时false
值由offer()
返回。一些BlockingQueue
实现可以限制可以同时存储在队列中的元素的数量。如果达到该限制,则队列已满,无法接受更多元素。
从BlockingQueue
实现中获取元素有四种不同的方法。在这个方向上,特殊情况是队列为空。在这种情况下,remove()
方法抛出异常而不是返回元素,poll()
方法返回null
如果没有元素,take()
方法只是等待它可以返回元素。
最后,有两个继承自Queues
接口的方法不使用队列中的元素,而只是查看它。element()
方法返回队列的头,如果队列为空,则抛出异常。如果队列中没有元素,peek()
方法返回null
。下表总结了从接口文档中借用的操作:
| | 抛出异常 | 特殊值 | 阻塞 | 超时 |
| --- | --- | --- |
| 插入 | add(e)
| offer(e)
| put(e)
| offer(e, time, unit)
|
| 弹出 | remove()
| poll()
| take()
| poll(time, unit)
|
| 检查 | element()
| peek()
| not applicable
| not applicable
|
这是BlockingQueue
接口的一个实现,它由一个链表备份。默认情况下,队列的大小不受限制(准确地说,它是Integer.MAX_VALUE
),但是可以选择在构造器参数中进行限制。在这个实现中限制大小的原因是,当并行算法在有限大小的队列中执行得更好时,可以帮助使用。实现本身对大小没有任何限制,只有Integer.MAX_VALUE
比较大。
这是BlockingQueue
及其BlockingDeque
子接口的最简单实现,如前一章所述,Deque
是一个双端队列,具有add
、remove
、offer
等方法类型,以xxxFirst
和xxxLast
的形式与队列的一端或另一端执行动作。Deque
接口定义了getFirst
和getLast
,而不是一致地命名elementFirst
和elementLast
,所以这是你应该习惯的。毕竟,IDE 有助于自动补全代码,所以这应该不是什么大问题。
ArrayBlockingQueue
实现BlockingQueue
接口,因此实现Queue
接口。此实现管理具有固定大小元素的队列。实现中的存储是一个数组,元素以先进先出的方式进行处理。这是一个类,我们也将在“策划”的并行实现中使用,用于老板和下属官僚之间的沟通。
TransferQueue
接口正在扩展BlockingQueue
,在 JDK 中它的唯一实现是LinkedTransferQueue
。当一个线程想要将一些数据移交给另一个线程,并且需要确保另一个线程接受元素时,TransferQueue
就很有用了。这个TransferQueue
有一个transfer()
方法,它将一个元素放在队列中,但是直到其他线程调用remove()
之后才返回,从而删除它(或者调用poll()
,从而轮询它)。这样,生产线程就可以确保放入队列的对象在另一个处理线程手中,而不是在队列中等待。transfer()
方法还有一种格式tryTransfer()
,您可以在其中指定超时值。如果方法超时,则元素不会放入队列。
我们讨论了可用于实现并行算法的不同 Java 语言元素和 JDK 类。现在,我们将看到如何使用这些方法来实现主谋游戏的并行猜测器。
在我们开始之前,我必须承认这个任务不是一个典型的并行编程教程任务。讨论并发编程技术的教程倾向于选择易于使用并行代码解决且可扩展性好的问题作为示例。如果在N
处理器上运行的并行算法实际运行的速度是非并行解的N
倍,那么问题就可以很好地扩展。我个人的看法是,这些例子描绘的天空蓝色没有风暴云。然而,当你面对现实生活中的并发编程时,那些云彩就在那里,你会看到雷声和闪电,如果你没有经验,你会大惊小怪的。
现实生活中的问题往往规模不理想。我们已经访问了一个扩展性很好的示例,尽管它不是理想的快速排序。这一次,我们将为更接近现实问题的问题开发一个并行算法。在N
个处理器上解算 Mastermind 游戏不会使解算速度提高N
倍,而且代码也不平凡。这个例子将向您展示现实生活中的问题是什么样子的,尽管它不会教您所有可能的问题,但是当您在商业环境中第一次看到其中一个问题时,您不会感到震惊。
这个解决方案中最重要的类之一是IntervalGuesser
。这是影响创建猜测的类。它在开始猜测和结束猜测之间创建猜测,并将它们发送到BlockingQueue
。类实现了Runnable
,因此可以在单独的Thread
中运行。纯粹主义的实现将Runnable
功能与区间猜测分开,但是,由于整个类几乎不超过 50 行,在单个类中实现这两个功能是可以原谅的错误:
public class IntervalGuesser extends UniqueGuesser implements Runnable {
private final Guess start;
private final Guess end;
private Guess lastGuess;
private final BlockingQueue<Guess> guessQueue;
public IntervalGuesser(Table table,
Guess start,
Guess end,
BlockingQueue<Guess> guessQueue) {
super(table);
this.start = start;
this.end = end;
this.lastGuess = start;
this.guessQueue = guessQueue;
nextGuess = start;
}
@Override
public void run() {
Thread.currentThread()
.setName("guesser [" + start + "," + end + "]");
var guess = guess();
try {
while (guess != Guess.none) {
guessQueue.put(guess);
guess = guess();
}
} catch (InterruptedException ignored) {
}
}
@Override
protected Guess nextGuess() {
var guess = super.nextGuess();
if (guess.equals(end)) {
guess = Guess.none;
}
lastGuess = guess;
return guess;
}
public String toString() {
return "[" + start + "," + end + "]";
}
}
实现非常简单,因为大多数功能已经在抽象的Guesser
类中实现了。更有趣的代码是调用IntervalGuesser
的代码。
ParallelGamePlayer
类实现定义play
方法的Player
接口:
@Override
public void play() {
final var table = new Table(NR_COLUMNS, colorManager);
final var secret = new RandomSecret(colorManager);
final var secretGuess = secret.createSecret(NR_COLUMNS);
final var game = new Game(table, secretGuess);
final var guessers = createGuessers(table);
final var finalCheckGuesser = new UniqueGuesser(table);
startAsynchronousGuessers(guessers);
try {
while (!game.isFinished()) {
final var guess = guessQueue.take();
if (finalCheckGuesser.guessMatch(guess)) {
game.addNewGuess(guess);
}
}
} catch (InterruptedException ie) {
} finally {
stopAsynchronousGuessers(guessers);
}
}
此方法创建一个Table
、一个以随机方式创建用作秘密的猜测的RandomSecret
、一个Game
对象、IntervalGuesser
对象和一个UniqueGuesser
。
IntervalGuesser
对象是官僚;UniqueGuesser
对象是老板,他交叉检查IntervalGuesser
对象产生的猜测。我们用一个单独的方法创建区间猜测器,createGuessers()
:
private IntervalGuesser[] createGuessers(Table table) {
final var colors = new Color[NR_COLUMNS];
var start = firstIntervalStart(colors);
final IntervalGuesser[] guessers = new IntervalGuesser[nrThreads];
for (int i = 0; i < nrThreads - 1; i++) {
Guess end = nextIntervalStart(colors);
guessers[i] = new IntervalGuesser(table, start, end, guessQueue);
start = end;
}
guessers[nrThreads - 1] = new IntervalGuesser(table, start, Guess.none, guessQueue);
return guessers;
}
private Guess firstIntervalStart(Color[] colors) {
for (int i = 0; i < colors.length; i++) {
colors[i] = colorManager.firstColor();
}
return new Guess(colors);
}
private Guess nextIntervalStart(Color[] colors) {
final int index = colors.length - 1;
int step = NR_COLORS / nrThreads;
if (step == 0) {
step = 1;
}
while (step > 0) {
if (colorManager.thereIsNextColor(colors[index])) {
colors[index] = colorManager.nextColor(colors[index]);
step--;
} else {
return Guess.none;
}
}
Guess guess = new Guess(colors);
while (!guess.isUnique()) {
guess = guess.nextGuess(colorManager);
}
return guess;
}
间隔猜测器的创建方式是,每种颜色都有其独特的颜色变化范围,因此,它们一起涵盖了所有可能的颜色猜测。firstIntervalStart()
方法返回在所有位置包含第一个颜色的猜测。nextIntervalStart()
方法返回开始下一个范围的颜色集,推进颜色,以便每个猜测者在结束时有相同数量的猜测要检查(加或减一)。
startAsynchronousGuessers()
方法启动异步猜测器,然后从它们那里读取循环中的猜测,如果它们正常的话,就把它们放在桌子上,直到游戏结束。在方法的末尾,在finally
块中,异步猜测器停止。
异步猜测器的启动和停止方法采用ExecutorService
:
private void startAsynchronousGuessers(IntervalGuesser[] guessers) {
executorService = Executors.newFixedThreadPool(nrThreads);
for (IntervalGuesser guesser : guessers) {
executorService.execute(guesser);
}
}
private void stopAsynchronousGuessers(IntervalGuesser[] guessers) {
executorService.shutdown();
guessQueue.drainTo(new LinkedList<>());
}
代码非常简单。唯一需要解释的是drainTo()
电话。这个方法将工作线程仍然拥有的未使用的猜测排出到一个我们立即丢弃的链表中(我们不保留对它的任何引用)。这是必要的,以帮助任何IntervalGuesser
,这可能是等待与建议猜测在手,试图把它放入队列。当我们排空队列时,猜测线程从IntervalGuesser
中guessQueue.put(guess);
行的put()
方法返回,并可以捕获中断。代码的其余部分不包含任何与我们已经看到的完全不同的内容。
在本章中,我们仍然要讨论的最后一个问题是,通过使代码并行,我们获得了多少时间?
微基准是衡量一个小代码片段的性能。当我们想要优化我们的代码时,我们必须对它进行度量。没有度量,代码优化就像蒙着眼睛射击。你不会击中目标,但很可能会射杀其他人。
射击是一个很好的比喻,因为你通常不应该这样做,但当你真的必须这样做,那么你就别无选择。如果没有性能问题,并且软件满足要求,那么任何优化,包括速度测量,都是浪费金钱。这并不意味着鼓励您编写慢而草率的代码。当我们衡量性能时,我们会将其与需求进行比较,而需求通常在用户级别,类似于“应用的响应时间应该少于 2 秒”。为了进行这样的度量,我们通常在一个测试环境中创建负载测试,并使用不同的分析工具,以防度量的性能不令人满意,这些工具告诉我们什么是最耗时的,以及我们应该在哪里进行优化。很多时候,不仅仅是 Java 代码,还有配置优化,使用更大的数据库连接池、更多的内存等等。
微基准是另一回事。它是关于一个小的 Java 代码片段的性能,因此更接近于 Java 编程。
它很少使用,在开始为实际商业环境执行微基准之前,我们必须三思而后行。MicroBenchmark 是一个诱人的工具,可以在不知道是否值得优化代码的情况下优化一些小东西。当我们有一个在多个服务器上运行多个模块的大型应用时,我们如何确保改进应用的某个特殊部分能够显著提高性能?它是否会回报增加的收入,产生如此多的利润,以弥补性能测试和开发中产生的成本?从统计学上讲,你几乎可以肯定,这样的优化,包括微基准,不会有回报。
我曾经维护过一位资深同事的密码。他创建了一个高度优化的代码来识别文件中存在的配置关键字。他创建了一个程序结构,它表示基于键字符串中的字符的决策树。如果配置文件中有一个关键字拼写错误,代码会在第一个字符处抛出异常,从而确定关键字不正确。要插入一个新的关键字,它需要通过代码结构来找到新关键字最初与已有关键字不同的地方,并扩展深度嵌套的if/else
结构。阅读关键字列表处理是可能的,从注释中列出了所有的关键字,他没有忘记文件。代码运行速度惊人,可能节省了 Servlet 应用几毫秒的启动时间。应用仅在每隔几天进行一次系统维护之后才启动几个月。你呢感受一下讽刺吧?资历并不总是年数。那些更幸运的人可以拯救他们内心的孩子。
那么,什么时候应该使用微基准呢?我可以看到两个方面:
- 您已经确定了消耗应用中大部分资源的代码段,可以通过微基准测试改进
- 您无法识别将消耗应用中大部分资源的代码段,但您可能会怀疑它
第一种是通常情况。第二种情况是,当您开发一个库时,您并不知道将使用它的所有应用。在这种情况下,您将尝试优化您认为对大多数想象中的可疑应用最关键的部分。即使在这种情况下,最好还是采集一些由库用户创建的示例应用,并收集一些有关使用情况的统计信息。
为什么我们要详细讨论微基准?陷阱是什么?基准测试是一个实验。我写的第一个程序是一个 TI 计算器代码,我只需计算程序将两个大素数(当时 10 位是大素数)分解的步数。即使在那个时候,我也在用一块老式的俄罗斯机械秒表测量时间,懒得计算步数。实验和测量更容易。
现在,即使您想手动计算 CPU 的步数,也无法手动计算。有太多的小因素可能会改变程序员无法控制的应用的性能,这使得计算步骤变得不可能。我们还有度量,我们将获得与度量相关的所有问题。
最大的问题是什么?我们对某物感兴趣,比如说X
,我们通常无法测量它。因此,我们将测量Y
,并希望Y
和X
的值耦合在一起。我们想测量房间的长度,但我们测量的是激光束从一端传输到另一端所需的时间。在这种情况下,长度,X
和时间,Y
是强耦合的。很多时候,X
和Y
只是或多或少的相关。大多数情况下,当一个人进行测量时,X
和Y
值根本没有关系。尽管如此,人们还是把自己的房子,甚至更多的钱,押在有这些衡量标准支持的决策上。
微基准也不例外。第一个问题是,我们如何衡量执行时间?小代码运行的时间很短,System.currentTimeMillis()
可能只是在测量开始和结束时返回相同的值,因为我们仍然在同一毫秒内。即使执行时间为 10ms,测量误差仍至少为 10%,这纯粹是因为我们测量的时间被量化了。幸运的是,有System.nanoTime()
。但是有吗?仅仅因为它的名字说它从一个特定的开始时间返回纳秒数并不一定意味着它真的可以。
这在很大程度上取决于硬件和方法在 JDK 中的实现。它被称为纳米,因为这是我们无法达到的精度。如果是微秒,那么一些实现可能会受到定义的限制,即使在特定的硬件上有更精确的时钟。然而,这不仅关系到可用硬件时钟的精度水平,还关系到硬件的精度。
让我们记住官僚们的心跳,以及从记忆中读东西所需要的时间。打电话给一个方法,比如System.nanoTime(),
,就像让酒店的行李员从二楼跑到大堂,往外看一眼路对面塔楼上的钟,回来,准确地告诉我们询问的时间。胡说。我们应该知道塔台上的钟的精确度,以及行李员从地板到大堂和大厅的速度。这不仅仅是打电话给System.nanoTime()
。这就是微型标记装置为我们所做的。
Java 微基准线束(JMH)作为库提供了一段时间。它是由 Oracle 开发的,用于调整几个核心 JDK 类的性能。这对那些为新硬件开发 Java 平台的人来说是个好消息,但对开发人员来说也是个好消息,因为这意味着 JMH 现在和将来都会受到 Oracle 的支持。
“JMH 是一个 Java 工具,用于构建、运行和分析以 Java 编写的 nano/micro/mili/macro 基准,以及其他针对 JVM 的语言。”
(引自 JMH 官方网站)。
您可以独立于您测量的实际项目作为单独的项目运行jmh
,或者您可以将测量代码存储在单独的目录中。线束将根据生产类文件编译,并将执行基准。我看到的最简单的方法是使用 Gradle 插件来执行 JMH。可以将基准代码存储在一个名为jmh
(与main
和test
相同级别)的目录中,创建一个可以启动基准的main
类。
Gradle 构建脚本已扩展为包含以下行:
buildscript {
repositories {
jcenter()
}
dependencies {
classpath "me.champeau.gradle:jmh-gradle-plugin:0.2.0"
}
}
apply plugin: "me.champeau.gradle.jmh"
jmh {
jmhVersion = '1.13'
includeTests = true
}
MicroBenchmark
类如下:
public class MicroBenchmark {
public static void main(String... args)
throws RunnerException {
var opt = new OptionsBuilder()
.include(MicroBenchmark.class.getSimpleName())
.forks(1)
.build();
new Runner(opt).run();
}
@Benchmark
@Fork(1)
public void playParallel(ThreadsAndQueueSizes t3qs) {
int nrThreads = Integer.valueOf(t3qs.nrThreads);
int queueSize = Integer.valueOf(t3qs.queueSize);
new ParallelGamePlayer(nrThreads, queueSize).play();
}
@Benchmark
@Fork(1)
public void playSimple() {
new SimpleGamePlayer().play();
}
@State(Scope.Benchmark)
public static class ThreadsAndQueueSizes {
@Param(value = {"1", "4", "8"})
String nrThreads;
@Param(value = {"-1", "1", "10", "100", "1000000"})
String queueSize;
}
}
创建ParallelGamePlayer
是为了用 -1、1、4 和 8IntervalGuesser
线程玩游戏,在每种情况下,都有一个测试运行,队列长度分别为 1、10、100 和 100 万。这是 16 个测试执行。当线程数为负数时,构造器使用LinkedBlockingDeque
。还有另一个单独的测量方法来测量非并行玩家。测试是用独特的猜测和秘密(没有颜色使用超过一次)、十种颜色和六列来执行的。
当线束启动时,它会自动执行所有校准,并运行多次迭代的测试,以让 JVM 启动。您可能会想起从未停止过的代码,除非我们对用于向代码发出停止信号的变量使用了volatile
修饰符。这是因为 JIT 编译器优化了代码。只有当代码已经运行了几千次时,才会这样做。线束执行这些执行是为了预热代码,并确保在 JVM 已经全速运行时完成测量。
在我的机器上运行这个基准测试大约需要 15 分钟。在执行过程中,建议停止所有其他进程,并让基准使用所有可用资源。如果在测量过程中有任何使用资源的情况,则会反映在结果中:
Benchmark (nrThreads) (queueSize) Score Error
playParallel 1 -1 15,636 ± 1,905
playParallel 1 1 15,316 ± 1,237
playParallel 1 10 15,425 ± 1,673
playParallel 1 100 16,580 ± 1,133
playParallel 1 1000000 15,035 ± 1,148
playParallel 4 -1 25,945 ± 0,939
playParallel 4 1 25,559 ± 1,250
playParallel 4 10 25,034 ± 1,414
playParallel 4 100 24,971 ± 1,010
playParallel 4 1000000 20,584 ± 0,655
playParallel 8 -1 24,713 ± 0,687
playParallel 8 1 24,265 ± 1,022
playParallel 8 10 24,475 ± 1,137
playParallel 8 100 24,514 ± 0,836
playParallel 8 1000000 16,595 ± 0,739
playSimple N/A N/A 18,613 ± 2,040
程序的实际输出要详细一些;它是为了打印而编辑的。Score
列显示了基准测试在一秒钟内可以运行多少次。Error
列显示测量值的散射小于 10%。
我们拥有的最快性能是算法在 8 个线程上运行时,这是处理器在我的机器上可以独立处理的线程数。有趣的是,限制队列的大小并没有提高性能。我真的以为会不一样。使用一个一百万长度的数组作为阻塞队列有着巨大的开销,在这种情况下,执行速度比队列中只有 100 个元素时要慢也就不足为奇了。另一方面,具有无限链接的基于列表的队列处理速度相当快,并且清楚地表明,对于 100 个元素的有限队列,额外的速度并不是因为限制防止了IntervalThreads
跑得太远。
当我们启动一个线程时,我们期望得到与运行串行算法时类似的结果。串行算法胜过在一个线程上运行的并行算法这一事实并不奇怪。线程的创建以及主线程和额外的单线程之间的通信都有开销。开销很大,特别是当队列不必要的大时。
在这一章中,我们学到了很多东西。首先,我们重构了代码,为使用并行猜测的进一步开发做好准备。我们熟悉了进程和线程,甚至还提到了纤程。之后,我们研究了 Java 如何实现线程以及如何创建在多个线程上运行的代码。此外,我们还看到了 Java 为需要并行程序、启动线程或只是在现有线程中启动任务的程序员提供的不同方法。
也许这一章最重要的部分你应该记住的是官僚和不同速度的隐喻。当您想了解并发应用的性能时,这一点非常重要。我希望这是一幅引人入胜的图画,一幅容易记住的图画。
关于 Java 提供的不同同步方式有一个很大的话题,您还了解了程序员在编写并发应用时可能遇到的陷阱。
最后,但并非最不重要的是,我们创建了 Mastermind 猜测器的并发版本,并且还测量了它确实比只使用一个处理器的版本(至少在我的机器上)要快。我们在 Gradle 构建工具中使用了 JavaMicroBenchmark 工具,并讨论了如何执行微基准。
这是一个漫长的章节,并不容易。我可能倾向于认为这是最复杂和理论的一章。如果你一开始就理解了一半,你会感到骄傲的。另一方面,请注意,这仅仅是一个坚实的基础,可以从中开始试验并发编程,在被公认为该领域的经验丰富和专业人士之前,还有很长的路要走。而且,这一章也不容易。但是,首先,在这一章的结尾,要为自己感到骄傲。
在接下来的章节中,我们将学习更多关于 Web 和 Web 编程的知识。在下一章中,我们将开发我们的小游戏,这样它就可以在服务器上运行,玩家可以使用 Web 浏览器玩它。这将为网络编程奠定基础知识。稍后,我们将在此基础上开发基于 Web 的服务应用、反应式编程,以及使您成为专业 Java 开发人员的所有工具和领域。