Java-编程问题-全-

Java 编程问题(全)

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

零、前言

JDK 在版本 8 和版本 12 之间的超快发展增加了现代 Java 的学习曲线,因此增加了将开发人员置于生产力平台所需的时间。它的新特性和新概念可以用来解决各种现代问题。这本书使您能够通过解释有关复杂性、性能、可读性等方面的正确实践和决策,对常见问题采取客观的方法。

《Java 编程问题》将帮助您完成日常任务并在截止日期前完成。本书中包含 1000 多个示例的 300 多个应用涵盖了共同和基本的兴趣领域:字符串、数字、数组、集合、数据结构、日期和时间、不变性、类型推断、Optional、Java I/O、Java 反射、函数式编程、并发和 HTTP 客户端 API。把你的技能和问题放在一起,这些问题是经过精心设计的,以突出和涵盖日常工作中获得的核心知识。换句话说(不管你的任务是简单的、中等的还是复杂的),在你的工具带下掌握这些知识是必须的,而不是一个选择。

在本书的最后,您将对 Java 概念有了深刻的理解,并有信心为您的问题开发和选择正确的解决方案。

这本书是给谁的

《Java 编程问题》对于初学者和中级 Java 开发人员特别有用。然而,这里看到的问题是任何 Java 开发人员在日常工作中都会遇到的

所需的技术背景相当薄弱。主要来说,你应该是一个 Java 爱好者,并且在跟随一段 Java 代码方面有很好的技巧和直觉

充分利用这本书

您应该具备 Java 语言的基本知识。您应该安装以下组件:

  • IDE(推荐 Apache NetBeans 11.x,但不是必须的)
  • JDK12 和 Maven 3.3.x
  • 需要在适当的时候安装其他第三方库(无特殊要求)

下载示例代码文件

您可以从您的帐户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问这个页面并注册,将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 登录或注册这个页面
  2. 选择“支持”选项卡。
  3. 点击代码下载。
  4. 在搜索框中输入图书名称,然后按照屏幕上的说明进行操作。

下载文件后,请确保使用最新版本的解压缩或解压缩文件夹:

  • 用于 Windows 的 WinRAR/7-Zip
  • Mac 的 Zipeg/iZip/UnRarX
  • 用于 Linux 的 7-Zip/PeaZip

这本书的代码包也托管在 GitHub 上。如果代码有更新,它将在现有 GitHub 存储库中更新。

我们的丰富书籍和视频目录中还有其他代码包,可在这个页面上找到。看看他们!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载

行为准则

要查看正在执行的代码,请访问以下链接

使用的约定

这本书中使用了许多文本约定。

CodeInText:表示文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。下面是一个例子:“如果当前字符存在于Map实例中,那么只需使用(character, occurrences+1)将其出现次数增加 1。”

代码块设置如下:

public Map<Character, Integer> countDuplicateCharacters(String str) {

  Map<Character, Integer> result = new HashMap<>();

  // or use for(char ch: str.toCharArray()) { ... }
  for (int i = 0; i<str.length(); i++) {
    char ch = str.charAt(i); 

    result.compute(ch, (k, v) -> (v == null) ? 1 : ++v);
  }

  return result;
}

当我们希望提请您注意代码块的特定部分时,相关行或项以粗体显示:

for (int i = 0; i < str.length(); i++) {
 int cp = str.codePointAt(i);
 String ch = String.valueOf(Character.toChars(cp));
 if(Character.charCount(cp) == 2) { // 2 means a surrogate pair
 i++;
 }
}

任何命令行输入或输出的编写方式如下:

$ mkdir css
$ cd css

粗体:表示一个新术语、一个重要单词或屏幕上显示的单词。例如,菜单或对话框中的单词会像这样出现在文本中。这里有一个例子:“在 Java 中,逻辑的运算符表示为&&,逻辑的运算符表示为||,逻辑的异或运算符表示为^|。”

警告或重要提示如下所示。

提示和窍门是这样出现的。

一、字符串、数字和数学

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括 39 个涉及字符串、数字和数学运算的问题。我们将从研究字符串的一系列经典问题开始,例如计算重复项、反转字符串和删除空格。然后,我们将研究专门用于数字和数学运算的问题,例如两个大数求和和和运算溢出,比较两个无符号数,以及计算除法和模的下限。每个问题都要经过几个解决方案,包括 Java8 的函数风格。此外,我们将讨论与 JDK9、10、11 和 12 有关的问题。

在本章结束时,您将知道如何使用一系列技术,以便您可以操纵字符串并应用、调整它们以适应许多其他问题。你还将知道如何解决可能导致奇怪和不可预测的结果的数学角落的情况。

问题

使用以下问题来测试您的字符串操作和数学角大小写编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 重复字符计数:编写一个程序,对给定字符串中的重复字符进行计数。

  2. 寻找第一个非重复字符:编写一个程序,返回给定字符串中的第一个非重复字符。

  3. 反转字母和单词:编写一个反转每个单词字母的程序,以及一个反转每个单词字母和单词本身的程序。

  4. 检查字符串是否只包含数字:编写一个程序检查给定字符串是否只包含数字。

  5. 计数元音和辅音:编写一个程序,计算给定字符串中元音和辅音的数量。对于英语,有五个元音(a、e、i、o 和 u)。

  6. 计数某个字符的出现次数:编写一个程序,对给定字符串中某个字符的出现次数进行计数。

  7. String转换成intlongfloatdouble:编写一个程序,将给定的String对象(代表数字)转换成intlongfloatdouble

  8. 删除字符串中的空格:编写一个程序,删除给定字符串中的所有空格。

  9. 用一个分隔符连接多个字符串:编写一个程序,用给定的分隔符连接给定的字符串。

  10. 生成所有排列:编写一个程序,生成给定字符串的所有排列。

  11. 检查字符串是否为回文:编写一个程序,确定给定的字符串是否为回文。

  12. 删除重复字符:编写一个程序,从给定字符串中删除重复字符。

  13. 删除给定字符:编写一个从给定字符串中删除给定字符的程序。

  14. 查找出现次数最多的字符:编写一个程序,在给定的字符串中查找出现次数最多的字符。

  15. 按长度排序字符串数组:编写按给定字符串数组长度排序的程序。

  16. 检查字符串是否包含子字符串:编写程序检查给定字符串是否包含给定子字符串。

  17. 计算子串在字符串中出现的次数:编写一个程序,计算给定字符串在另一个给定字符串中出现的次数。

  18. 检查两个字符串是否是:编写一个检查两个字符串是否是异序词的程序。假设一个字符串的一个异序词是这个字符串的一个排列,忽略了大小写和空格。

  19. 声明多行字符串(文本块):编写声明多行字符串或文本块的程序。

  20. 连接同一字符串n:编写一个程序,将同一字符串连接给定次数。

  21. 删除前导和尾随空格:编写一个程序,删除给定字符串的前导和尾随空格。

  22. 查找最长公共前缀:编写一个程序,查找给定字符串的最长公共前缀。

  23. 应用缩进:编写几个代码片段,对给定的文本应用缩进。

  24. 转换字符串:写几段代码将一个字符串转换成另一个字符串。

  25. 计算两个数的最小值和最大值:编写一个程序,返回两个数的最小值和最大值。

  26. 两个大int/long数的求和运算溢出:编写一个程序,对两个大int/long数求和,运算溢出时抛出算术异常。

  27. 作为基数的字符串中的无符号数:编写一个程序,将给定字符串解析为给定基数中的无符号数(intlong)。

  28. 无符号数字的转换:编写一个程序,将给定的int数字无符号转换成long

  29. 比较两个无符号数字:编写一个程序,将给定的两个数字作为无符号数字进行比较。

  30. 无符号值的除法和模:编写一个程序,计算给定无符号值的除法和模。

  31. double/float是否是有限浮点值:编写一个程序来确定给定的double/float值是否是一个有限浮点值。

  32. 对两个布尔表达式应用逻辑 AND/OR/XOR:编写一个程序,对两个布尔表达式应用逻辑 AND/OR/XOR。

  33. BigInteger转换成原始类型:编写程序,从给定的BigInteger中提取原始类型值。

  34. long转换成int:编写一个将long转换成int的程序。

  35. 计算下限除法和模:编写程序,计算给定除法(x)和除法(y)的下限除法和下限模。

  36. 下一个浮点值:编写一个程序,返回给定的float/double值在正负无穷方向上相邻的下一个浮点值。

  37. 两个大int/long值相乘运算溢出:编写一个程序,将两个大int/long值相乘,运算溢出时抛出算术异常。

  38. 融合乘加FMA):编写一个程序,取三个float/double值(abc),高效计算ab+c*。

  39. 紧凑数字格式化:编写一个程序,将数字 1000000 格式化为 1M(美国地区)和 1ML(意大利地区)。另外,将一个字符串中的 1M 和 1MLN 解析为一个数字。

解决方案

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释只包括解决问题所需的最有趣和最重要的细节。您可以从这个页面下载示例解决方案以查看更多详细信息并尝试程序。

1 重复字符计数

计算字符串中的字符(包括特殊字符,如#、$和%)的解决方案意味着取每个字符并将它们与其他字符进行比较。在比较过程中,计数状态是通过一个数字计数器来保持的,每次找到当前字符时,该计数器都会增加一个。

这个问题有两种解决办法。

第一种解决方案迭代字符串,并使用Map将字符存储为键,将出现的次数存储为值。如果当前字符从未添加到Map,则将其添加为(character, 1)。如果当前字符存在于Map中,则只需将其出现次数增加 1,例如(character, occurrences+1)。如下代码所示:

public Map<Character, Integer> countDuplicateCharacters(String str) {

  Map<Character, Integer> result = new HashMap<>();

  // or use for(char ch: str.toCharArray()) { ... }
  for (int i = 0; i<str.length(); i++) {
    char ch = str.charAt(i); 

    result.compute(ch, (k, v) -> (v == null) ? 1 : ++v);
  }

  return result;
}

另一个解决方案依赖于 Java8 的流特性。这个解决方案有三个步骤。前两步是将给定的字符串转换成Stream<Character>,最后一步是对字符进行分组和计数。步骤如下:

  1. 对原始字符串调用String.chars()方法。这将返回IntStream。这个IntStream包含给定字符串中字符的整数表示。
  2. 通过mapToObj()方法将IntStream转换成字符流(将整数表示转换成人性化的字符形式)。
  3. 最后,将字符分组(Collectors.groupingBy())并计数(Collectors.counting())。

以下代码片段将这三个步骤粘在一个方法中:

public Map<Character, Long> countDuplicateCharacters(String str) {

  Map<Character, Long> result = str.chars()
    .mapToObj(c -> (char) c)
    .collect(Collectors.groupingBy(c -> c, Collectors.counting()));

  return result;
}

Unicode 字符呢?

我们非常熟悉 ASCII 字符。我们有 0-31 之间的不可打印控制码,32-127 之间的可打印字符,128-255 之间的扩展 ASCII 码。但是 Unicode 字符呢?对于每个需要操作 Unicode 字符的问题,请考虑本节

因此,简而言之,早期的 Unicode 版本包含值小于 65535(0xFFFF)的字符。Java 使用 16 位char数据类型表示这些字符。只要i不超过 65535,调用charAt(i)就可以正常工作。但随着时间的推移,Unicode 添加了更多字符,最大值达到了 1114111(0x10FFFF)。这些字符不适合 16 位,因此 UTF-32 编码方案考虑 32 位值(称为码位)。

不幸的是,Java 不支持 UTF-32!尽管如此,Unicode 还是提出了一个解决方案,仍然使用 16 位来表示这些字符。此解决方案意味着:

  • 16 位高位代理:1024 个值(U+D800 到 U+DBFF)
  • 16 位低位代理:1024 个值(U+DC00 到 U+DFFF)

现在,一个高代理后跟一个低代理定义了所谓的代理对。代理项对用于表示 65536(0x10000)和 1114111(0x10FFFF)之间的值。因此,某些字符(称为 Unicode 补充字符)被表示为 Unicode 代理项对(一个字符(符号)适合于一对字符的空间),这些代理项对被合并到一个代码点中。Java 利用了这种表示,并通过一组方法来公开它,如codePointAt()codePoints()codePointCount(),和offsetByCodePoints()(请查看 Java 文档了解详细信息)。调用codePointAt()而不是charAt()codePoints()而不是chars()等等,可以帮助我们编写覆盖 ASCII 和 Unicode 字符的解决方案。

例如,众所周知的双心符号是 Unicode 代理项对,可以表示为包含两个值的char[]\uD83D\uDC95。此符号的码位为128149。要从此代码点获取一个String对象,请调用String str = String.valueOf(Character.toChars(128149))str中的码点计数可以通过调用str.codePointCount(0, str.length())来完成,即使str长度为 2,也返回 1;调用str.codePointAt(0)返回128149,调用str.codePointAt(1)返回56469。调用Character.toChars(128149)返回 2,因为需要两个字符来表示此代码点是 Unicode 代理项对。对于 ASCII 和 Unicode 16 位字符,它将返回 1。

因此,如果我们尝试重写第一个解决方案(迭代字符串并使用Map将字符存储为键,将出现次数存储为值)以支持 ASCII 和 Unicode(包括代理项对),我们将获得以下代码:

public static Map<String, Integer> 
    countDuplicateCharacters(String str) {

  Map<String, Integer> result = new HashMap<>();

  for (int i = 0; i < str.length(); i++) {
 int cp = str.codePointAt(i);
 String ch = String.valueOf(Character.toChars(cp));
 if(Character.charCount(cp) == 2) { // 2 means a surrogate pair
 i++;
 }

    result.compute(ch, (k, v) -> (v == null) ? 1 : ++v);
  }

  return result;
}

突出显示的代码也可以编写如下:

String ch = String.valueOf(Character.toChars(str.codePointAt(i)));
if (i < str.length() - 1 && str.codePointCount(i, i + 2) == 1) {
  i++;
}

最后,尝试重写 Java8 函数式解决方案以覆盖 Unicode 代理项对,可以执行以下操作:

public static Map<String, Long> countDuplicateCharacters(String str) { 

  Map<String, Long> result = str.codePoints()
    .mapToObj(c -> String.valueOf(Character.toChars(c)))
    .collect(Collectors.groupingBy(c -> c, Collectors.counting()));

  return result;
}

对于第三方库支持,请考虑 Guava:Multiset<String>

下面的一些问题将提供包括 ASCII、16 位 Unicode 和 Unicode 代理项对的解决方案。它们是任意选择的,因此,通过依赖这些解决方案,您可以轻松地为没有提供此类解决方案的问题编写解决方案。

2 找到第一个非重复字符

找到字符串中第一个不重复的字符有不同的解决方案。主要地,这个问题可以通过字符串的一次遍历或更完整/部分的遍历来解决。

在单遍历方法中,我们填充一个数组,该数组用于存储字符串中恰好出现一次的所有字符的索引。使用此数组,只需返回包含非重复字符的最小索引:

private static final int EXTENDED_ASCII_CODES = 256;
...
public char firstNonRepeatedCharacter(String str) {

  int[] flags = new int[EXTENDED_ASCII_CODES];

  for (int i = 0; i < flags.length; i++) {
    flags[i] = -1;
  }

  for (int i = 0; i < str.length(); i++) {
    char ch = str.charAt(i);
    if (flags[ch] == -1) {
      flags[ch] = i;
    } else {
      flags[ch] = -2;
    }
  }

  int position = Integer.MAX_VALUE;

  for (int i = 0; i < EXTENDED_ASCII_CODES; i++) {
    if (flags[i] >= 0) {
      position = Math.min(position, flags[i]);
    }
  }

  return position == Integer.MAX_VALUE ?
    Character.MIN_VALUE : str.charAt(position);
}

此解决方案假定字符串中的每个字符都是扩展 ASCII 表(256 个代码)的一部分。如果代码大于 256,则需要相应地增加数组的大小。只要数组大小不超过char类型的最大值,即Character.MAX_VALUE,即 65535,该解决方案就可以工作。另一方面,Character.MAX_CODE_POINT返回 Unicode 码位的最大值 1114111。为了覆盖这个范围,我们需要另一个基于codePointAt()codePoints()的实现

由于采用了单次遍历的方法,所以速度非常快。另一种解决方案是循环每个字符的字符串并计算出现的次数。每出现一次(重复)就会打断循环,跳到下一个字符,并重复算法。如果到达字符串的结尾,则返回当前字符作为第一个不可重复的字符。本书附带的代码中提供了此解决方案。

这里介绍的另一个解决方案依赖于LinkedHashMap。这个 Java 映射是一个插入顺序映射(它保持了键插入到映射中的顺序),对于这个解决方案非常方便。LinkedHashMap以字符作为键,以出现次数作为值填充。一旦LinkedHashMap完成,它将返回值等于 1 的第一个键。由于插入顺序功能,这是字符串中第一个不可重复的字符:

public char firstNonRepeatedCharacter(String str) {

  Map<Character, Integer> chars = new LinkedHashMap<>();

  // or use for(char ch: str.toCharArray()) { ... }
  for (int i = 0; i < str.length(); i++) {
    char ch = str.charAt(i);

    chars.compute(ch, (k, v) -> (v == null) ? 1 : ++v);
  }

  for (Map.Entry<Character, Integer> entry: chars.entrySet()) {
    if (entry.getValue() == 1) {
      return entry.getKey();
    }
  }

  return Character.MIN_VALUE;
}

在本书附带的代码中,前面提到的解决方案是用 Java8 函数式编写的。此外,支持 ASCII、16 位 Unicode 和 Unicode 代理项对的函数式解决方案如下:

public static String firstNonRepeatedCharacter(String str) {

  Map<Integer, Long> chs = str.codePoints()
    .mapToObj(cp -> cp)
    .collect(Collectors.groupingBy(Function.identity(),
      LinkedHashMap::new, Collectors.counting()));

  int cp = chs.entrySet().stream()
   .filter(e -> e.getValue() == 1L)
   .findFirst()
   .map(Map.Entry::getKey)
   .orElse(Integer.valueOf(Character.MIN_VALUE));

  return String.valueOf(Character.toChars(cp));
}

要更详细地理解这段代码,请考虑“Unicode 字符是什么?计数重复字符”部分的小节

3 反转字母和单词

首先,让我们只反转每个单词的字母。这个问题的解决方案可以利用StringBuilder类。第一步包括使用空格作为分隔符(Spring.split(" "))将字符串拆分为单词数组。此外,我们使用相应的 ASCII 码反转每个单词,并将结果附加到StringBuilder。首先,我们将给定的字符串按空格拆分。然后,我们循环得到的单词数组,并通过charAt()按相反的顺序获取每个字符来反转每个单词:

private static final String WHITESPACE = " ";
...
public String reverseWords(String str) {

 String[] words = str.split(WHITESPACE);
 StringBuilder reversedString = new StringBuilder();

 for (String word: words) {
   StringBuilder reverseWord = new StringBuilder();

   for (int i = word.length() - 1; i >= 0; i--) {
     reverseWord.append(word.charAt(i));
   }

   reversedString.append(reverseWord).append(WHITESPACE);
 }

 return reversedString.toString();
}

在 Java8 函数样式中获得相同的结果可以如下所示:

private static final Pattern PATTERN = Pattern.compile(" +");
...
public static String reverseWords(String str) {

  return PATTERN.splitAsStream(str)
    .map(w -> new StringBuilder(w).reverse())
    .collect(Collectors.joining(" "));
}

请注意,前面两个方法返回一个字符串,其中包含每个单词的字母,但单词本身的初始顺序相同。现在,让我们考虑另一种方法,它反转每个单词的字母和单词本身。由于内置的StringBuilder.reverse()方法,这非常容易实现:

public String reverse(String str) {

  return new StringBuilder(str).reverse().toString();
}

对于第三方库支持,请考虑 ApacheCommonsLang,StringUtils.reverse()

4 检查字符串是否只包含数字

这个问题的解决依赖于Character.isDigit()String.matches()方法

依赖于Character.isDigit()的解决方案是非常简单和快速地循环字符串,如果此方法返回false,则中断循环:

public static boolean containsOnlyDigits(String str) {

  for (int i = 0; i < str.length(); i++) {
    if (!Character.isDigit(str.charAt(i))) {
      return false;
    }
  }

  return true;
}

在 Java8 函数式中,前面的代码可以使用anyMatch()重写:

public static boolean containsOnlyDigits(String str) {

  return !str.chars()
    .anyMatch(n -> !Character.isDigit(n));
}

另一种解决方案依赖于String.matches()。此方法返回一个boolean值,指示此字符串是否与给定的正则表达式匹配:

public static boolean containsOnlyDigits(String str) {

  return str.matches("[0-9]+");
}

请注意,Java8 函数式和基于正则表达式的解决方案通常比较慢,因此如果速度是一个要求,那么最好使用第一个使用Character.isDigit()的解决方案。

避免通过parseInt()parseLong()解决此问题。首先,捕捉NumberFormatException并在catch块中进行业务逻辑决策是不好的做法。其次,这些方法验证字符串是否为有效数字,而不是仅包含数字(例如,-4 有效)。
对于第三方库支持,请考虑 ApacheCommonsLang,StringUtils.isNumeric()

5 元音和辅音计数

以下代码适用于英语,但取决于您所涵盖的语言种类,元音和辅音的数量可能会有所不同,因此应相应调整代码。

此问题的第一个解决方案需要遍历字符串并执行以下操作:

  1. 我们需要检查当前字符是否是元音(这很方便,因为英语中只有五个纯元音;其他语言有更多元音,但数量仍然很小)。
  2. 如果当前字符不是元音,则检查它是否位于'a''z'之间(这意味着当前字符是辅音)。

注意,最初,给定的String对象被转换为小写。这有助于避免与大写字符进行比较。例如,仅对'a'进行比较,而不是对'A''a'进行比较。

此解决方案的代码如下:

private static final Set<Character> allVowels
            = new HashSet(Arrays.asList('a', 'e', 'i', 'o', 'u'));

public static Pair<Integer, Integer> 
    countVowelsAndConsonants(String str) {

  str = str.toLowerCase();
  int vowels = 0;
  int consonants = 0;

  for (int i = 0; i < str.length(); i++) {
    char ch = str.charAt(i);
    if (allVowels.contains(ch)) {
      vowels++;
    } else if ((ch >= 'a' && ch <= 'z')) {
      consonants++;
    }
  }

  return Pair.of(vowels, consonants);
}

在 Java8 函数式中,可以使用chars()filter()重写此代码:

private static final Set<Character> allVowels
            = new HashSet(Arrays.asList('a', 'e', 'i', 'o', 'u'));

public static Pair<Long, Long> countVowelsAndConsonants(String str) {

  str = str.toLowerCase();

  long vowels = str.chars()
    .filter(c -> allVowels.contains((char) c))
    .count();

  long consonants = str.chars()
    .filter(c -> !allVowels.contains((char) c))
    .filter(ch -> (ch >= 'a' && ch<= 'z'))
    .count();

  return Pair.of(vowels, consonants);
}

相应地过滤给定的字符串,count()终端操作返回结果。依赖partitioningBy()将减少代码,如下所示:

Map<Boolean, Long> result = str.chars()
  .mapToObj(c -> (char) c)
  .filter(ch -> (ch >= 'a' && ch <= 'z'))
  .collect(partitioningBy(c -> allVowels.contains(c), counting()));

return Pair.of(result.get(true), result.get(false));

完成!现在,让我们看看如何计算字符串中某个字符的出现次数。

6 计算某个字符的出现次数

此问题的简单解决方案包括以下两个步骤:

  1. 将给定字符串中出现的每个字符替换为""(基本上,这类似于删除给定字符串中出现的所有字符)。
  2. 从初始字符串的长度中减去在第一步中获得的字符串的长度。

此方法的代码如下:

public static int countOccurrencesOfACertainCharacter(
    String str, char ch) {

  return str.length() - str.replace(String.valueOf(ch), "").length();
}

以下解决方案还包括 Unicode 代理项对:

public static int countOccurrencesOfACertainCharacter(
    String str, String ch) { 

  if (ch.codePointCount(0, ch.length()) > 1) {
    // there is more than 1 Unicode character in the given String
    return -1; 
  }

  int result = str.length() - str.replace(ch, "").length();

  // if ch.length() return 2 then this is a Unicode surrogate pair
  return ch.length() == 2 ? result / 2 : result;
}

另一个易于实现且快速的解决方案包括循环字符串(一次遍历)并将每个字符与给定的字符进行比较。每一场比赛增加一个计数器:

public static int countOccurrencesOfACertainCharacter(
    String str, char ch) {

  int count = 0;

  for (int i = 0; i < str.length(); i++) {
    if (str.charAt(i) == ch) {
      count++;
    }
  }

  return count;
}

涵盖 Unicode 代理项对的解决方案包含在本书附带的代码中。在 Java8 函数风格中,一种解决方案是使用filter()reduce()。例如,使用filter()将产生以下代码:

public static long countOccurrencesOfACertainCharacter(
    String str, char ch) {

  return str.chars()
    .filter(c -> c == ch)
    .count();
}

涵盖 Unicode 代理项对的解决方案包含在本书附带的代码中。

对于第三方库支持,请考虑 Apache Commons Lang、StringUtils.countMatches()、Spring Framework、StringUtils.countOccurrencesOf()和 Guava、CharMatcher.is().countIn()

7 将字符串转换为intlongfloatdouble

让我们考虑以下字符串(也可以使用负数):

private static final String TO_INT = "453"; 
private static final String TO_LONG = "45234223233"; 
private static final String TO_FLOAT = "45.823F";
private static final String TO_DOUBLE = "13.83423D";

String转换为intlongfloatdouble的合适解决方案包括使用IntegerLongFloatDouble类的以下 Java 方法-parseInt()parseLong()parseFloat()parseDouble()

int toInt = Integer.parseInt(TO_INT);
long toLong = Long.parseLong(TO_LONG);
float toFloat = Float.parseFloat(TO_FLOAT);
double toDouble = Double.parseDouble(TO_DOUBLE);

String转换成IntegerLongFloatDouble对象,可以通过以下 Java 方法来完成:Integer.valueOf()Long.valueOf()Float.valueOf()Double.valueOf()

Integer toInt = Integer.valueOf(TO_INT);
Long toLong = Long.valueOf(TO_LONG);
Float toFloat = Float.valueOf(TO_FLOAT);
Double toDouble = Double.valueOf(TO_DOUBLE);

String无法成功转换时,Java 抛出NumberFormatException异常。以下代码不言自明:

private static final String WRONG_NUMBER = "452w";

try {
  Integer toIntWrong1 = Integer.valueOf(WRONG_NUMBER);
} catch (NumberFormatException e) {
  System.err.println(e);
  // handle exception
}

try {
  int toIntWrong2 = Integer.parseInt(WRONG_NUMBER);
} catch (NumberFormatException e) {
  System.err.println(e);
  // handle exception
}

对于第三方库支持,请考虑 ApacheCommons BeanUtils:IntegerConverterLongConverterFloatConverterDoubleConverter

8 从字符串中删除空格

这个问题的解决方案是使用带有正则表达式的String.replaceAll()方法。主要是\s删除所有的空白,包括不可见的空白,如\t\n\r

public static String removeWhitespaces(String str) {
  return str.replaceAll("\\s", "");
}

从 JDK11 开始,String.isBlank()检查字符串是空的还是只包含空格代码点。对于第三方库支持,请考虑 Apache Commons Lang,StringUtils.deleteWhitespace()和 Spring 框架,StringUtils.trimAllWhitespace()

9 用分隔符连接多个字符串

有几种解决方案很适合解决这个问题。在 Java8 之前,一种方便的方法依赖于StringBuilder,如下所示:

public static String joinByDelimiter(char delimiter, String...args) {

  StringBuilder result = new StringBuilder();

  int i = 0;
  for (i = 0; i < args.length - 1; i++) {
    result.append(args[i]).append(delimiter);
  }
  result.append(args[i]);

  return result.toString();
}

从 Java8 开始,这个问题至少还有三种解决方案。其中一个解决方案依赖于StringJoiner工具类。此类可用于构造由分隔符(例如逗号)分隔的字符序列。

它还支持可选的前缀和后缀(此处忽略):

public static String joinByDelimiter(char delimiter, String...args) {
  StringJoiner joiner = new StringJoiner(String.valueOf(delimiter));

  for (String arg: args) {
    joiner.add(arg);
  }

  return joiner.toString();
}

另一种解决方案依赖于String.join()方法。此方法是在 Java8 中引入的,有两种风格:

String join​(CharSequence delimiter, CharSequence... elems)
String join​(CharSequence delimiter,
  Iterable<? extends CharSequence> elems)

连接由空格分隔的多个字符串的示例如下:

String result = String.join(" ", "how", "are", "you"); // how are you

更进一步说,Java8 流和Collectors.joining()也很有用:

public static String joinByDelimiter(char delimiter, String...args) {
  return Arrays.stream(args, 0, args.length)
    .collect(Collectors.joining(String.valueOf(delimiter)));
}

注意通过+=运算符以及concat()String.format()方法连接字符串。这些字符串可以用于连接多个字符串,但它们容易导致性能下降。例如,下面的代码依赖于+=并且比依赖于StringBuilder慢得多:

String str = "";``for(int i = 0; i < 1_000_000; i++) {``  str += "x";

+=被附加到一个字符串并重建一个新的字符串,这需要时间。

对于第三方库支持,请考虑 Apache Commons Lang,StringUtils.join()和 Guava,Joiner

10 生成所有置换

涉及置换的问题通常也涉及递归性。基本上,递归性被定义为一个过程,其中给定了一些初始状态,并且根据前一个状态定义了每个连续状态

在我们的例子中,状态可以通过给定字符串的字母来具体化。初始状态包含初始字符串,每个连续状态可通过以下公式计算字符串的每个字母将成为字符串的第一个字母(交换位置),然后使用递归调用排列所有剩余字母。虽然存在非递归或其他递归解决方案,但这是该问题的经典解决方案。

将这个解决方案表示为字符串ABC,可以这样做(注意排列是如何完成的):

对该算法进行编码将产生如下结果:

public static void permuteAndPrint(String str) {

  permuteAndPrint("", str);
}

private static void permuteAndPrint(String prefix, String str) {

  int n = str.length();

  if (n == 0) {
    System.out.print(prefix + " ");
  } else {
    for (int i = 0; i < n; i++) {
      permuteAndPrint(prefix + str.charAt(i),
        str.substring(i + 1, n) + str.substring(0, i));
    }
  }
}

最初,前缀应该是一个空字符串""。在每次迭代中,前缀将连接(固定)字符串中的下一个字母。剩下的字母将再次通过该方法传递。

假设这个方法存在于一个名为Strings的实用类中。你可以这样称呼它:

Strings.permuteAndStore("ABC");

这将产生以下输出:

ABC ACB BCA BAC CAB CBA

注意,这个解决方案在屏幕上打印结果。存储结果意味着将Set添加到实现中。最好使用Set,因为它消除了重复:

public static Set<String> permuteAndStore(String str) {

  return permuteAndStore("", str);
}

private static Set<String> 
    permuteAndStore(String prefix, String str) {

  Set<String> permutations = new HashSet<>();
  int n = str.length();

  if (n == 0) {
    permutations.add(prefix);
  } else {
    for (int i = 0; i < n; i++) {
      permutations.addAll(permuteAndStore(prefix + str.charAt(i),
        str.substring(i + 1, n) + str.substring(0, i)));
    }
  }

  return permutations;
}

例如,如果传递的字符串是TEST,那么Set将导致以下输出(这些都是唯一的排列):

ETST SETT TEST TTSE STTE STET TETS TSTE TSET TTES ESTT ETTS

使用List代替Set将产生以下输出(注意重复项):

TEST TETS TSTE TSET TTES TTSE ESTT ESTT ETTS ETST ETST ETTS STTE STET STET STTE SETT SETT TTES TTSE TEST TETS TSTE TSET

有 24 个排列。通过计算n阶乘(n!)。对于n = 4(字符串长度),4! = 1 x 2 x 3 x 4 = 24。当以递归方式表示时,这是n! = n x (n-1)!

自从n!以极快的速度生成大量数据(例如,10! = 3628800),建议避免存储结果。对于 10 个字符的字符串(如直升机),有 3628800 个排列!

尝试用 Java8 函数式实现此解决方案将导致如下结果:

private static void permuteAndPrintStream(String prefix, String str) {

  int n = str.length();

  if (n == 0) {
    System.out.print(prefix + " ");
  } else {
    IntStream.range(0, n)
      .parallel()
      .forEach(i -> permuteAndPrintStream(prefix + str.charAt(i),
        str.substring(i + 1, n) + str.substring(0, i)));
  }
}

作为奖励,本书附带的代码中提供了返回Stream<String>的解决方案。

11 检查字符串是否为回文

作为一个快速的提醒,回文(无论是字符串还是数字)在反转时看起来是不变的。这意味着可以从两个方向处理(读取)回文,并且将获得相同的结果(例如,单词madam是回文,而单词madam不是)。

一个易于实现的解决方案是用中间相遇的方法比较给定字符串的字母。基本上,此解决方案将第一个字符与最后一个字符进行比较,第二个字符与最后一个字符逐个进行比较,依此类推,直到到达字符串的中间。实现依赖于while语句:

public static boolean isPalindrome(String str) {

  int left = 0;
  int right = str.length() - 1;

  while (right > left) {
    if (str.charAt(left) != str.charAt(right)) {
      return false;
    }

    left++;
    right--;
  }
  return true;
}

以更简洁的方法重写上述解决方案将包括依赖于for语句而不是while语句,如下所示:

public static boolean isPalindrome(String str) {

  int n = str.length();

  for (int i = 0; i < n / 2; i++) {
    if (str.charAt(i) != str.charAt(n - i - 1)) {
      return false;
    }
  }
  return true;
}

但是这个解决方案可以简化为一行代码吗?答案是肯定的。

JavaAPI 提供了StringBuilder类,该类使用reverse()方法。顾名思义,reverse()方法返回相反的给定字符串。对于回文,给定的字符串应等于它的反向版本:

public static boolean isPalindrome(String str) {

  return str.equals(new StringBuilder(str).reverse().toString());
}

在 Java8 函数风格中,也有一行代码用于此。只需定义从 0 到给定字符串一半的IntStream,并使用noneMatch()短路终端操作和谓词,按照中间相遇的方法比较字母:

public static boolean isPalindrome(String str) {

  return IntStream.range(0, str.length() / 2)
    .noneMatch(p -> str.charAt(p) != 
      str.charAt(str.length() - p - 1));
}

现在,让我们讨论从给定字符串中删除重复字符。

12 删除重复字符

让我们从依赖于StringBuilder的这个问题的解决方案开始。解决方案主要应该循环给定字符串的字符,并构造一个包含唯一字符的新字符串(不可能简单地从给定字符串中删除字符,因为在 Java 中,字符串是不可变的)。

StringBuilder类公开了一个名为indexOf()的方法,该方法返回指定子字符串(在本例中是指定字符)第一次出现的给定字符串中的索引。因此,这个问题的一个潜在解决方案是,每次应用于当前字符的indexOf()方法返回 -1(这个负数意味着StringBuilder不包含当前字符)时,循环给定字符串的字符并将它们逐个添加到StringBuilder

public static String removeDuplicates(String str) {

  char[] chArray = str.toCharArray(); // or, use charAt(i)
  StringBuilder sb = new StringBuilder();

  for (char ch : chArray) {
    if (sb.indexOf(String.valueOf(ch)) == -1) {
      sb.append(ch);
    }
  }
  return sb.toString();
}

下一个解决方案依赖于HashSetStringBuilder之间的协作。主要是,HashSet确保消除重复,而StringBuilder存储结果字符串。如果HashSet.add()返回true,那么我们也在StringBuilder中添加字符:

public static String removeDuplicates(String str) {

  char[] chArray = str.toCharArray();
  StringBuilder sb = new StringBuilder();
  Set<Character> chHashSet = new HashSet<>();

  for (char c: chArray) {
    if (chHashSet.add(c)) {
      sb.append(c);
    }
  }
  return sb.toString();
}

到目前为止,我们提供的解决方案使用toCharArray()方法将给定字符串转换为char[]。或者,两种解决方案也可以使用str.charAt(position)

第三种解决方案依赖于 Java8 函数式风格:

public static String removeDuplicates(String str) {

  return Arrays.asList(str.split("")).stream()
    .distinct()
    .collect(Collectors.joining());
}

首先,解决方案将给定的字符串转换为Stream<String>,其中每个条目实际上是一个字符。此外,该解决方案应用了有状态的中间操作distinct()。此操作将从流中消除重复项,因此它返回一个没有重复项的流。最后,该解决方案调用了collect()终端操作并依赖于Collectors.joining(),它只是将字符按照相遇顺序连接成一个字符串。

13 删除给定字符

依赖于 JDK 支持的解决方案可以利用String.replaceAll()方法。此方法将给定字符串中与给定正则表达式(在本例中,正则表达式是字符本身)匹配的每个子字符串(在本例中,每个字符)替换为给定的替换(在本例中,替换为空字符串,""):

public static String removeCharacter(String str, char ch) {

  return str.replaceAll(Pattern.quote(String.valueOf(ch)), "");
}

注意,正则表达式被包装在Pattern.quote()方法中。这是转义特殊字符所必需的,例如“<,(,[,{,\,^,-,=,$,!”!, |, ], }, ), ?、*、+、、和>。该方法主要返回指定字符串的文本模式字符串。

现在,让我们来看一个避免正则表达式的解决方案。这一次,解决方案依赖于StringBuilder。基本上,解决方案循环给定字符串的字符,并将每个字符与要删除的字符进行比较。每次当前字符与要删除的字符不同时,都会在StringBuilder中追加当前字符:

public static String removeCharacter(String str, char ch) {

  StringBuilder sb = new StringBuilder();
  char[] chArray = str.toCharArray();

  for (char c : chArray) {
    if (c != ch) {
      sb.append(c);
    }
  }

  return sb.toString();
}

最后,让我们关注 Java8 函数式方法。这是一个四步方法:

  1. 通过String.chars()方法将字符串转换成IntStream
  2. 过滤IntStream消除重复
  3. 将结果IntStream映射到Stream<String>
  4. 将此流中的字符串连接起来,并将它们收集为单个字符串

此解决方案的代码可以编写如下:

public static String removeCharacter(String str, char ch) {

  return str.chars()
    .filter(c -> c != ch)
    .mapToObj(c -> String.valueOf((char) c))
    .collect(Collectors.joining());
}

或者,如果我们想要移除 Unicode 代理项对,那么我们可以依赖于codePointAt()codePoints(),如下面的实现所示:

public static String removeCharacter(String str, String ch) {

   int codePoint = ch.codePointAt(0);

   return str.codePoints()
     .filter(c -> c != codePoint)
     .mapToObj(c -> String.valueOf(Character.toChars(c)))
     .collect(Collectors.joining());
 }

对于第三方库支持,请考虑 ApacheCommonsLang,StringUtils.remove()

现在,让我们来谈谈如何找到最形象的字符。

14 寻找最形象的字符

一个非常简单的解决方案依赖于HashMap。此解决方案包括三个步骤:

  1. 首先,循环给定字符串的字符,并将键值对放入HashMap,其中键是当前字符,值是当前出现的次数
  2. 其次,计算HashMap中表示最大出现次数的最大值(例如,使用Collections.max()
  3. 最后,通过循环HashMap条目集,得到出现次数最多的字符

工具方法返回Pair<Character, Integer>,其中包含出现次数最多的字符和出现次数(注意,忽略了空格)。如果你不喜欢这个额外的类,也就是说,Pair,那就依赖Map.Entry<K, V>

public static Pair<Character, Integer> maxOccurenceCharacter(
  String str) {

  Map<Character, Integer> counter = new HashMap<>();
  char[] chStr = str.toCharArray();

  for (int i = 0; i < chStr.length; i++) {
    char currentCh = chStr[i];
    if (!Character.isWhitespace(currentCh)) { // ignore spaces
      Integer noCh = counter.get(currentCh);
      if (noCh == null) {
        counter.put(currentCh, 1);
      } else {
        counter.put(currentCh, ++noCh);
      }
    }
  }

  int maxOccurrences = Collections.max(counter.values());
  char maxCharacter = Character.MIN_VALUE;

  for (Entry<Character, Integer> entry: counter.entrySet()) {
    if (entry.getValue() == maxOccurrences) {
      maxCharacter = entry.getKey();
    }
  }

  return Pair.of(maxCharacter, maxOccurrences);
}

如果使用HashMap看起来很麻烦,那么另一个解决方案(稍微快一点)就是依赖 ASCII 代码。此解决方案从 256 个索引的空数组开始(256 是扩展 ASCII 表代码的最大数目;更多信息可在“查找第一个非重复字符”部分中找到)。此外,此解决方案循环给定字符串的字符,并通过增加此数组中的相应索引来跟踪每个字符的出现次数:

private static final int EXTENDED_ASCII_CODES = 256;
...
public static Pair<Character, Integer> maxOccurenceCharacter(
  String str) {

  int maxOccurrences = -1;
  char maxCharacter = Character.MIN_VALUE;
  char[] chStr = str.toCharArray();
  int[] asciiCodes = new int[EXTENDED_ASCII_CODES];

  for (int i = 0; i < chStr.length; i++) {
    char currentCh = chStr[i];
    if (!Character.isWhitespace(currentCh)) { // ignoring space
      int code = (int) currentCh;
      asciiCodes[code]++;
      if (asciiCodes[code] > maxOccurrences) {
        maxOccurrences = asciiCodes[code];
        maxCharacter = currentCh;
      }
    }
  }

  return Pair.of(maxCharacter, maxOccurrences);
}

我们将在这里讨论的最后一个解决方案依赖于 Java8 函数式风格:

public static Pair<Character, Long> 
    maxOccurenceCharacter(String str) {

  return str.chars()
    .filter(c -> Character.isWhitespace(c) == false) // ignoring space
    .mapToObj(c -> (char) c)
    .collect(groupingBy(c -> c, counting()))
    .entrySet()
    .stream()
    .max(comparingByValue())
    .map(p -> Pair.of(p.getKey(), p.getValue()))
    .orElse(Pair.of(Character.MIN_VALUE, -1L));
}

首先,这个解决方案收集不同的字符作为Map中的键,以及它们的出现次数作为值。此外,它使用 Java8Map.Entry.comparingByValue()max()终端操作来确定映射中具有最高值(最高出现次数)的条目。因为max()是终端操作,所以解决方案可以返回Optional<Entry<Character, Long>>,但是这个解决方案增加了一个额外的步骤,并将这个条目映射到Pair<Character, Long>

15 按长度对字符串数组排序

排序时首先想到的是比较器的使用。

在这种情况下,解决方案应该比较字符串的长度,因此通过调用给定数组中每个字符串的String.length()来返回整数。因此,如果整数被排序(升序或降序),那么字符串将被排序。

JavaArrays类已经提供了一个sort()方法,该方法使用数组进行排序和一个比较器。在这种情况下,Comparator<String>应该完成这项工作。

在 Java7 之前,实现比较器的代码依赖于compareTo()方法。这种方法的常见用法是计算x1 - x2类型的差值,但这种计算可能导致溢出。这使得compareTo()相当乏味。从 Java7 开始,Integer.compare()就是一条路(没有溢出风险)。

下面是一个依赖于Arrays.sort()方法对给定数组排序的方法:

public static void sortArrayByLength(String[] strs, Sort direction) {
  if (direction.equals(Sort.ASC)) {
    Arrays.sort(strs, (String s1, String s2) 
      -> Integer.compare(s1.length(), s2.length()));
  } else {
    Arrays.sort(strs, (String s1, String s2) 
      -> (-1) * Integer.compare(s1.length(), s2.length()));
  }
}

原始数字类型的每个包装器都有一个compare()方法。

从 Java8 开始,Comparator接口被大量有用的方法丰富了。其中一个方法是comparingInt(),它接受一个函数,该函数从泛型类型中提取int排序键,并返回一个Comparator<T>值,将其与该排序键进行比较。另一个有用的方法是reversed(),它反转当前的Comparator值。

基于这两种方法,我们可以授权Arrays.sort()如下:

public static void sortArrayByLength(String[] strs, Sort direction) {
  if (direction.equals(Sort.ASC)) {
    Arrays.sort(strs, Comparator.comparingInt(String::length));
  } else {
    Arrays.sort(strs, 
      Comparator.comparingInt(String::length).reversed());
  }
}

比较器可以用thenComparing()方法链接。

我们在这里给出的解决方案返回void,这意味着它们对给定的数组进行排序。为了返回一个新的排序数组而不改变给定的数组,我们可以使用 Java8 函数样式,如下代码片段所示:

public static String[] sortArrayByLength(String[] strs, 
    Sort direction) {

  if (direction.equals(Sort.ASC)) {
    return Arrays.stream(strs)
      .sorted(Comparator.comparingInt(String::length))
      .toArray(String[]::new);
  } else {
    return Arrays.stream(strs)
      .sorted(Comparator.comparingInt(String::length).reversed())
      .toArray(String[]::new);
  }
}

因此,代码从给定的数组中创建一个流,通过有状态的中间操作对其进行排序,并将结果收集到另一个数组中。

16 检查字符串是否包含子字符串

一个非常简单的一行代码解决方案依赖于String.contains()方法。

此方法返回一个boolean值,指示字符串中是否存在给定的子字符串:

String text = "hello world!";
String subtext = "orl";

// pay attention that this will return true for subtext=""
boolean contains = text.contains(subtext);

或者,可以通过依赖于String.indexOf()(或String.lastIndexOf()来实现解决方案,如下所示:

public static boolean contains(String text, String subtext) {

  return text.indexOf(subtext) != -1; // or lastIndexOf()
}

另一种解决方案可以基于正则表达式实现,如下所示:

public static boolean contains(String text, String subtext) {

  return text.matches("(?i).*" + Pattern.quote(subtext) + ".*");
}

注意,正则表达式被包装在Pattern.quote()方法中。这是转义特殊字符(如“<”)([{^-=$)所必需的!|]})?*+. 给定子串中的>。

对于第三方库支持,请考虑 ApacheCommonsLang,StringUtils.containsIgnoreCase()

17 计算字符串中子字符串的出现次数

计算一个字符串在另一个字符串中出现的次数是一个问题,它至少有两种解释:

  • 11/111 发生 1 次
  • 11/111 发生 2 次

在第一种情况下(111 中的 11 发生 1 次),可以依赖于String.indexOf()方法来解决。此方法的一种风格允许我们从指定的索引(如果没有这样的索引,则为 -1)开始获取指定子字符串第一次出现的字符串中的索引。基于此方法,该解决方案可以简单地遍历给定的字符串并计算给定子字符串的出现次数。遍历从位置 0 开始,直到找不到子字符串为止:

public static int countStringInString(String string, String toFind) {

  int position = 0;
  int count = 0;
  int n = toFind.length();

  while ((position = string.indexOf(toFind, position)) != -1) {
    position = position + n;
    count++;
  }

  return count;
}

或者,该溶液可以使用String.split()方法。基本上,该解决方案可以使用给定的子字符串作为分隔符来拆分给定的字符串。结果String[]数组的长度应等于预期出现的次数:

public static int countStringInString(String string, String toFind) {

  int result = string.split(Pattern.quote(toFind), -1).length - 1;

  return result < 0 ? 0 : result;
}

在第二种情况下(111 中的 11 出现了 2 次),解决方案可以在一个简单的实现中依赖于PatternMatcher类,如下所示:

public static int countStringInString(String string, String toFind) {

  Pattern pattern = Pattern.compile(Pattern.quote(toFind));
  Matcher matcher = pattern.matcher(string);

  int position = 0;
  int count = 0;

  while (matcher.find(position)) {

    position = matcher.start() + 1;
    count++;
  }

  return count;
}

很好!让我们继续讨论字符串的另一个问题。

18 检查两个字符串是否为异序词

两个具有相同字符但顺序不同的字符串是异序词。一些定义强制要求字谜不区分大小写和/或应忽略空格(空格)。

因此,与应用的算法无关,解决方案必须将给定的字符串转换为小写并删除空格(空格)。除此之外,我们提到的第一个解决方案通过Arrays.sort()对数组进行排序,并通过Arrays.equals()检查它们的相等性。

一旦它们被排序,如果它们是字谜,它们将相等(下图显示了两个字谜):

此解决方案(包括其 Java8 函数式版本)在本书附带的代码中提供。这两种解决方案的主要缺点是排序部分。以下解决方案消除了这一步骤,并依赖于 256 个索引的空数组(最初仅包含 0)(字符的扩展 ASCII 表代码更多信息可在“查找第一个非重复字符”部分中找到)。

算法非常简单:

  • 对于第一个字符串中的每个字符,此解决方案将此数组中对应于 ASCII 代码的值增加 1
  • 对于第二个字符串中的每个字符,此解决方案将此数组中对应于 ASCII 代码的值减少 1

代码如下:

private static final int EXTENDED_ASCII_CODES = 256;
...
public static boolean isAnagram(String str1, String str2) {

  int[] chCounts = new int[EXTENDED_ASCII_CODES];
  char[] chStr1 = str1.replaceAll("\\s", 
    "").toLowerCase().toCharArray();
  char[] chStr2 = str2.replaceAll("\\s", 
    "").toLowerCase().toCharArray();

  if (chStr1.length != chStr2.length) {
    return false;
  }

  for (int i = 0; i < chStr1.length; i++) {
    chCounts[chStr1[i]]++;
    chCounts[chStr2[i]]--;
  }

  for (int i = 0; i < chCounts.length; i++) {
    if (chCounts[i] != 0) {
      return false;
    }
  }

  return true;
}

在遍历结束时,如果给定的字符串是异序词,那么这个数组只包含 0。

19 声明多行字符串(文本块)

在写这本书的时候,JDK12 有一个关于添加多行字符串的建议,称为 JEP326:原始字符串字面值。但这是在最后一刻掉的

从 JDK13 开始,重新考虑了这个想法,与被拒绝的原始字符串字面值不同,文本块被三个双引号包围,""",如下所示:

String text = """My high school,
the Illinois Mathematics and Science Academy,
showed me that anything is possible
and that you're never too young to think big.""";

文本块对于编写多行 SQL 语句、使用 polyglot 语言等非常有用。更多详情见这个页面

尽管如此,在 JDK13 之前有几种替代解决方案可以使用。这些解决方案有一个共同点,即使用行分隔符:

private static final String LS = System.lineSeparator();

从 JDK8 开始,解决方案可以依赖于String.join(),如下所示:

String text = String.join(LS,
  "My high school, ",
  "the Illinois Mathematics and Science Academy,",
  "showed me that anything is possible ",
  "and that you're never too young to think big.");

在 JDK8 之前,一个优雅的解决方案可能依赖于StringBuilder。本书附带的代码中提供了此解决方案。

虽然前面的解决方案适合于相对大量的字符串,但如果我们只有几个字符串,下面的两个就可以了。第一个使用+运算符:

String text = "My high school, " + LS +
  "the Illinois Mathematics and Science Academy," + LS +
  "showed me that anything is possible " + LS +
  "and that you're never too young to think big.";

第二个使用String.format()

String text = String.format("%s" + LS + "%s" + LS + "%s" + LS + "%s",
  "My high school, ",
  "the Illinois Mathematics and Science Academy,",
  "showed me that anything is possible ",
  "and that you're never too young to think big.");

如何处理多行字符串的每一行?好吧,快速方法需要 JDK11,它与String.lines()方法一起提供。该方法通过行分隔符(支持\n\r\r\n对给定字符串进行拆分,并将其转换为Stream<String>。或者,也可以使用String.split()方法(从 JDK1.4 开始提供)。如果字符串的数量变得重要,建议将它们放入一个文件中,并逐个读取/处理它们(例如,通过getResourceAsStream()方法)。其他方法依赖于StringWriterBufferedWriter.newLine()

对于第三方库支持,请考虑 Apache Commons Lang、StringUtils.join()、Guava、Joiner和自定义注解@Multiline

20 连接相同字符串 n 次

在 JDK11 之前,可以通过StringBuilder快速提供解决方案,如下:

public static String concatRepeat(String str, int n) {

  StringBuilder sb = new StringBuilder(str.length() * n);

  for (int i = 1; i <= n; i++) {
    sb.append(str);
  }

  return sb.toString();
}

从 JDK11 开始,解决方案依赖于String.repeat(int count)方法。此方法返回一个字符串,该字符串通过将此字符串count连接几次而得到。在幕后,这个方法使用了System.arraycopy(),这使得这个速度非常快:

String result = "hello".repeat(5);

其他适合不同场景的解决方案如下:

  • 以下是基于String.join()的解决方案:
String result = String.join("", Collections.nCopies(5, TEXT));
  • 以下是基于Stream.generate()的解决方案:
String result = Stream.generate(() -> TEXT)
  .limit(5)
  .collect(joining());
  • 以下是基于String.format()的解决方案:
String result = String.format("%0" + 5 + "d", 0)
  .replace("0", TEXT);
  • 以下是基于char[]的解决方案:
String result = new String(new char[5]).replace("\0", TEXT);

对于第三方库支持,请考虑 ApacheCommonsLang,StringUtils.repeat()和 Guava,Strings.repeat()

要检查字符串是否是相同子字符串的序列,请使用以下方法:

public static boolean hasOnlySubstrings(String str) {

  StringBuilder sb = new StringBuilder();

  for (int i = 0; i < str.length() / 2; i++) {
    sb.append(str.charAt(i));
    String resultStr = str.replaceAll(sb.toString(), "");
    if (resultStr.length() == 0) {
      return true;
    }
  }

  return false;
}

该解决方案循环给定字符串的一半,并通过逐字符将原始字符串追加到StringBuilder中,逐步用""(子字符串构建)替换它。如果这些替换结果是空字符串,则表示给定的字符串是相同子字符串的序列。

21 删除前导空格和尾随空格

这个问题的最快解决方法可能依赖于String.trim()方法。此方法能够删除所有前导和尾随空格,即代码点小于或等于 U+0020 或 32 的任何字符(空格字符):

String text = "\n \n\n hello \t \n \r";
String trimmed = text.trim();

前面的代码片段将按预期工作。修剪后的字符串将为hello。这只适用于所有正在使用的空格小于 U+0020 或 32(空格字符)。有 25 个字符定义为空格,trim()只覆盖其中的一部分(简而言之,trim()不知道 Unicode)。让我们考虑以下字符串:

char space = '\u2002';
String text = space + "\n \n\n hello \t \n \r" + space;

\u2002trim()无法识别的另一种类型的空白(\u2002\u0020之上)。这意味着,在这种情况下,trim()将无法按预期工作。从 JDK11 开始,这个问题有一个名为strip()的解决方案。此方法将trim()的功能扩展到 Unicode 领域:

String stripped = text.strip();

这一次,所有的前导和尾随空格都将被删除。

此外,JDK11 还提供了两种类型的strip(),用于仅删除前导(stripLeading())或尾部(stripTrailing())空格。trim()方法没有这些味道。

22 查找最长的公共前缀

让我们考虑以下字符串数组:

String[] texts = {"abc", "abcd", "abcde", "ab", "abcd", "abcdef"};

现在,让我们把这些线一根接一根,如下所示:

abc
abcd
abcde
ab abcd
abcdef

通过对这些字符串的简单比较可以看出,ab是最长的公共前缀。现在,让我们深入研究解决此问题的解决方案。我们在这里提出的解决方案依赖于一个简单的比较。此解决方案从数组中获取第一个字符串,并将其每个字符与其余字符串进行比较。如果发生以下任一情况,算法将停止:

  • 第一个字符串的长度大于任何其他字符串的长度
  • 第一个字符串的当前字符与任何其他字符串的当前字符不同

如果由于上述情况之一而强制停止算法,则最长的公共前缀是从 0 到第一个字符串的当前字符索引的子字符串。否则,最长的公共前缀是数组中的第一个字符串。此解决方案的代码如下:

public static String longestCommonPrefix(String[] strs) {

  if (strs.length == 1) {
    return strs[0];
  }

  int firstLen = strs[0].length();

  for (int prefixLen = 0; prefixLen < firstLen; prefixLen++) {
    char ch = strs[0].charAt(prefixLen);
    for (int i = 1; i < strs.length; i++) {
      if (prefixLen >= strs[i].length() 
         || strs[i].charAt(prefixLen) != ch) {
          return strs[i].substring(0, prefixLen);
      }
    }
  }

  return strs[0];
}

这个问题的其他解决方案使用众所周知的算法,例如二分搜索Trie。在本书附带的源代码中,还有一个基于二分搜索的解决方案。

23 应用缩进

从 JDK12 开始,我们可以通过String.indent(int n)方法缩进文本。

假设我们有以下String值:

String days = "Sunday\n" 
  + "Monday\n" 
  + "Tuesday\n" 
  + "Wednesday\n" 
  + "Thursday\n" 
  + "Friday\n" 
  + "Saturday";

用 10 个空格的缩进打印这个String值可以如下所示:

System.out.print(days.indent(10));

输出如下:

现在,让我们试试层叠缩进:

List<String> days = Arrays.asList("Sunday", "Monday", "Tuesday",
  "Wednesday", "Thursday", "Friday", "Saturday");

for (int i = 0; i < days.size(); i++) {
  System.out.print(days.get(i).indent(i));
}

输出如下:

现在,让我们根据String值的长度缩进:

days.stream()
  .forEachOrdered(d -> System.out.print(d.indent(d.length())));

输出如下:

缩进一段 HTML 代码怎么样?让我们看看:

String html = "<html>";
String body = "<body>";
String h2 = "<h2>";
String text = "Hello world!";
String closeH2 = "</h2>";
String closeBody = "</body>";
String closeHtml = "</html>";

System.out.println(html.indent(0) + body.indent(4) + h2.indent(8) 
  + text.indent(12) + closeH2.indent(8) + closeBody.indent(4)
  + closeHtml.indent(0));

输出如下:

24 转换字符串

假设我们有一个字符串,我们想把它转换成另一个字符串(例如,把它转换成大写)。我们可以通过应用像Function<? super String,​ ? extends R>这样的函数来实现这一点。

在 JDK8 中,我们可以通过map()来实现,如下两个简单的例子所示:

// hello world
String resultMap = Stream.of("hello")
  .map(s -> s + " world")
  .findFirst()
  .get();

// GOOOOOOOOOOOOOOOOL! GOOOOOOOOOOOOOOOOL!
String resultMap = Stream.of("gooool! ")
  .map(String::toUpperCase)
  .map(s -> s.repeat(2))
  .map(s -> s.replaceAll("O", "OOOO"))
  .findFirst()
  .get();

从 JDK12 开始,我们可以依赖一个名为transform​(Function<? super String, ​? extends R> f)的新方法。让我们通过transform()重写前面的代码片段:

// hello world
String result = "hello".transform(s -> s + " world");

// GOOOOOOOOOOOOOOOOL! GOOOOOOOOOOOOOOOOL!
String result = "gooool! ".transform(String::toUpperCase)
  .transform(s -> s.repeat(2))
  .transform(s -> s.replaceAll("O", "OOOO"));

虽然map()更一般,但transform()专用于将函数应用于字符串并返回结果字符串。

25 计算两个数的最小值和最大值

在 JDK8 之前,一个可能的解决方案是依赖于Math.min()Math.max()方法,如下所示:

int i1 = -45;
int i2 = -15;
int min = Math.min(i1, i2);
int max = Math.max(i1, i2);

Math类为每个原始数字类型(intlongfloatdouble提供了min()max()方法。

从 JDK8 开始,每个原始数字类型的包装器类(IntegerLongFloatDouble都有专用的min()max()方法,在这些方法后面,还有来自Math类的对应调用。请参见下面的示例(这是一个更具表现力的示例):

double d1 = 0.023844D;
double d2 = 0.35468856D;
double min = Double.min(d1, d2);
double max = Double.max(d1, d2);

在函数式风格的上下文中,潜在的解决方案将依赖于BinaryOperator函数式接口。此接口有两种方式,minBy()maxBy()

float f1 = 33.34F;
final float f2 = 33.213F;
float min = BinaryOperator.minBy(Float::compare).apply(f1, f2);
float max = BinaryOperator.maxBy(Float::compare).apply(f1, f2);

这两种方法能够根据指定的比较器返回两个元素的最小值(分别是最大值)。

26 两个大的int/long值求和并导致操作溢出

让我们从+操作符开始深入研究解决方案,如下例所示:

int x = 2;
int y = 7;
int z = x + y; // 9

这是一种非常简单的方法,适用于大多数涉及intlongfloatdouble的计算。

现在,让我们将此运算符应用于以下两个大数(与其自身相加为 2147483647):

int x = Integer.MAX_VALUE;
int y = Integer.MAX_VALUE;
int z = x + y; // -2

此时,z将等于 -2,这不是预期的结果,即 4294967294。仅将z类型从int更改为long将无济于事。但是,将xy的类型从int改为long将有所帮助:

long x = Integer.MAX_VALUE;
long y = Integer.MAX_VALUE;
long z = x + y; // 4294967294

但如果不是Integer.MAX_VALUE,而是Long.MAX_VALUE,问题就会再次出现:

long x = Long.MAX_VALUE;
long y = Long.MAX_VALUE;
long z = x + y; // -2

从 JDK8 开始,+操作符被一个原始类型数字类型的包装器以一种更具表现力的方式包装。因此,IntegerLongFloatDouble类具有sum()方法:

long z = Long.sum(); // -2

在幕后,sum()方法也使用+操作符,因此它们只产生相同的结果。

但同样从 JDK8 开始,Math类用两种addExact()方法进行了丰富。一个addExact()用于两个int变量的求和,一个用于两个long变量的求和。如果结果容易溢出intlong,这些方法非常有用,如前面的例子所示。在这种情况下,这些方法抛出ArithmeticException,而不是返回误导性的结果,如下例所示:

int z = Math.addExact(x, y); // throw ArithmeticException

代码将抛出一个异常,例如java.lang.ArithmeticException: integer overflow。这是很有用的,因为它允许我们避免在进一步的计算中引入误导性的结果(例如,早期,-2 可以悄悄地进入进一步的计算)。

在函数式上下文中,潜在的解决方案将依赖于BinaryOperator函数式接口,如下所示(只需定义相同类型的两个操作数的操作):

BinaryOperator<Integer> operator = Math::addExact;
int z = operator.apply(x, y);

addExact()外,Math还有multiplyExact()substractExact()negateExact()。此外,众所周知的增量和减量表达式i++i--可以通过incrementExact()decrementExact()方法(例如Math.incrementExact(i))来控制溢出它们的域。请注意,这些方法仅适用于intlong

在处理大量数字时,还要关注BigInteger(不可变任意精度整数)和BigDecimal(不可变任意精度带符号十进制数)类。

27 字符串按照基数转换为无符号数

对无符号算术的支持从版本 8 开始添加到 Java 中。ByteShortIntegerLong类受此影响最大。

在 Java 中,表示正数的字符串可以通过parseUnsignedInt()parseUnsignedLong()JDK8 方法解析为无符号的intlong类型。例如,我们将以下整数视为字符串:

String nri = "255500";

将其解析为以 36 为基数(最大可接受基数)的无符号int值的解决方案如下:

int result = Integer.parseUnsignedInt(nri, Character.MAX_RADIX);

第一个参数是数字,第二个参数是基数。基数应在[2, 36][Character.MIN_RADIX, Character.MAX_RADIX]范围内。

使用基数 10 可以很容易地完成如下操作(此方法默认应用基数 10):

int result = Integer.parseUnsignedInt(nri);

从 JDK9 开始,parseUnsignedInt()有了新的味道。除了字符串和基数之外,这个方法还接受一个范围的[beginIndex, endIndex]类型。这一次,解析是在这个范围内完成的。例如,可以按如下方式指定范围[1, 3]

int result = Integer.parseUnsignedInt(nri, 1, 4, Character.MAX_RADIX);

parseUnsignedInt()方法可以解析表示大于Integer.MAX_VALUE的数字的字符串(尝试通过Integer.parseInt()完成此操作将引发java.lang.NumberFormatException异常):

// Integer.MAX_VALUE + 1 = 2147483647 + 1 = 2147483648
int maxValuePlus1 = Integer.parseUnsignedInt("2147483648");

对于Long类中的长数存在相同的方法集(例如,parseUnsignedLong()

28 通过无符号转换转换数字

这个问题要求我们通过无符号转换将给定的有符号的int转换成long。那么,让我们考虑签名的Integer.MIN_VALUE,即 -2147483648。

在 JDK8 中,使用Integer.toUnsignedLong()方法,转换如下(结果为 2147483648):

long result = Integer.toUnsignedLong(Integer.MIN_VALUE);

下面是另一个将有符号的Short.MIN_VALUEShort.MAX_VALUE转换为无符号整数的示例:

int result1 = Short.toUnsignedInt(Short.MIN_VALUE);
int result2 = Short.toUnsignedInt(Short.MAX_VALUE);

其他同类方法有Integer.toUnsignedString()Long.toUnsignedString()Byte.toUnsignedInt()Byte.toUnsignedLong()Short.toUnsignedInt()Short.toUnsignedLong()

29 比较两个无符号数

让我们考虑两个有符号整数,Integer.MIN_VALUE(-2147483648)和Integer.MAX_VALUE(2147483647)。比较这些整数(有符号值)将导致 -2147483648 小于 2147483647:

// resultSigned is equal to -1 indicating that
// MIN_VALUE is smaller than MAX_VALUE
int resultSigned = Integer.compare(Integer.MIN_VALUE, 
  Integer.MAX_VALUE);

在 JDK8 中,这两个整数可以通过Integer.compareUnsigned()方法作为无符号值进行比较(这相当于无符号值的Integer.compare())。该方法主要忽略了符号位的概念,最左边的被认为是最重要的位。在无符号值保护伞下,如果比较的数字相等,则此方法返回 0;如果第一个无符号值小于第二个无符号值,则此方法返回小于 0 的值;如果第一个无符号值大于第二个无符号值,则此方法返回大于 0 的值。

下面的比较返回 1,表示Integer.MIN_VALUE的无符号值大于Integer.MAX_VALUE的无符号值:

// resultSigned is equal to 1 indicating that
// MIN_VALUE is greater than MAX_VALUE
int resultUnsigned 
  = Integer.compareUnsigned(Integer.MIN_VALUE, Integer.MAX_VALUE);

compareUnsigned()方法在以 JDK8 开始的IntegerLong类中可用,在以 JDK9 开始的ByteShort类中可用。

30 无符号值的除法和模

JDK8 无符号算术 API 通过divideUnsigned()remainderUnsigned()方法支持计算两个无符号值的除法所得的无符号商和余数。

让我们考虑一下Interger.MIN_VALUEInteger.MAX_VALUE有符号数,然后应用除法和模。这里没有什么新鲜事:

// signed division
// -1
int divisionSignedMinMax = Integer.MIN_VALUE / Integer.MAX_VALUE; 

// 0
int divisionSignedMaxMin = Integer.MAX_VALUE / Integer.MIN_VALUE;

// signed modulo
// -1
int moduloSignedMinMax = Integer.MIN_VALUE % Integer.MAX_VALUE; 

// 2147483647
int moduloSignedMaxMin = Integer.MAX_VALUE % Integer.MIN_VALUE; 

现在,我们将Integer.MIN_VALUEInteger.MAX_VALUE视为无符号值,并应用divideUnsigned()remainderUnsigned()

// division unsigned
int divisionUnsignedMinMax = Integer.divideUnsigned(
  Integer.MIN_VALUE, Integer.MAX_VALUE); // 1
int divisionUnsignedMaxMin = Integer.divideUnsigned(
  Integer.MAX_VALUE, Integer.MIN_VALUE); // 0

// modulo unsigned
int moduloUnsignedMinMax = Integer.remainderUnsigned(
  Integer.MIN_VALUE, Integer.MAX_VALUE); // 1
int moduloUnsignedMaxMin = Integer.remainderUnsigned(
  Integer.MAX_VALUE, Integer.MIN_VALUE); // 2147483647

注意它们与比较操作的相似性。这两种操作,即无符号除法和无符号模运算,都将所有位解释为值位,并忽略符号位

divideUnsigned() and remainderUnsigned() are present in the Integer and Long classes, respectively.

31 double/float是一个有限的浮点值

这个问题产生于这样一个事实:一些浮点方法和操作产生InfinityNaN作为结果,而不是抛出异常。

检查给定的float/double是否为有限浮点值的解决方案取决于以下条件:给定的float/double值的绝对值不得超过float/double类型的最大正有限值:

// for float
Math.abs(f) <= Float.MAX_VALUE;

// for double
Math.abs(d) <= Double.MAX_VALUE

从 Java8 开始,前面的条件通过两个专用的标志方法Float.isFinite()Double.isFinite()公开。因此,以下示例是有限浮点值的有效测试用例:

Float f1 = 4.5f;
boolean f1f = Float.isFinite(f1); // f1 = 4.5, is finite

Float f2 = f1 / 0;
boolean f2f = Float.isFinite(f2); // f2 = Infinity, is not finite

Float f3 = 0f / 0f;
boolean f3f = Float.isFinite(f3); // f3 = NaN, is not finite

Double d1 = 0.000333411333d;
boolean d1f = Double.isFinite(d1); // d1 = 3.33411333E-4,is finite

Double d2 = d1 / 0;
boolean d2f = Double.isFinite(d2); // d2 = Infinity, is not finite

Double d3 = Double.POSITIVE_INFINITY * 0;
boolean d3f = Double.isFinite(d3); // d3 = NaN, is not finite

这些方法在以下情况下非常方便:

if (Float.isFinite(d1)) {
  // do a computation with d1 finite floating-point value
} else {
  // d1 cannot enter in further computations
}

32 对两个布尔表达式应用逻辑与/或/异或

基本逻辑运算的真值表(异或)如下:

在 Java 中,逻辑运算符表示为&&,逻辑运算符表示为||,逻辑异或运算符表示为^。从 JDK8 开始,这些运算符被应用于两个布尔值,并被包装在三个static方法中—Boolean.logicalAnd()Boolean.logicalOr()Boolean.logicalXor()

int s = 10;
int m = 21;

// if (s > m && m < 50) { } else { }
if (Boolean.logicalAnd(s > m, m < 50)) {} else {}

// if (s > m || m < 50) { } else { }
if (Boolean.logicalOr(s > m, m < 50)) {} else {}

// if (s > m ^ m < 50) { } else { }
if (Boolean.logicalXor(s > m, m < 50)) {} else {}

也可以结合使用这些方法:

if (Boolean.logicalAnd(
    Boolean.logicalOr(s > m, m < 50),
    Boolean.logicalOr(s <= m, m > 50))) {} else {}

33 将BigInteger转换为原始类型

BigInteger类是表示不可变的任意精度整数的非常方便的工具。

此类还包含用于将BigInteger转换为bytelongdouble等原始类型的方法(源于java.lang.Number)。然而,这些方法会产生意想不到的结果和混乱。例如,假设有BigInteger包裹Long.MAX_VALUE

BigInteger nr = BigInteger.valueOf(Long.MAX_VALUE);

让我们通过BigInteger.longValue()方法将这个BigInteger转换成一个原始long

long nrLong = nr.longValue();

到目前为止,由于Long.MAX_VALUE是 9223372036854775807,nrLong原始类型变量正好有这个值,所以一切都按预期工作。

现在,让我们尝试通过BigInteger.intValue()方法将这个BigInteger类转换成一个原始的int值:

int nrInt = nr.intValue();

此时,nrInt原始类型变量的值将为 -1(相同的结果将产生shortValue()byteValue()。根据文档,如果BigInteger的值太大,无法容纳指定的原始类型,则只返回低位n位(n取决于指定的原始类型)。但是如果代码没有意识到这个语句,那么它将在进一步的计算中把值推为 -1,这将导致混淆。

但是,从 JDK8 开始,添加了一组新的方法。这些方法专门用于识别从BigInteger转换为指定的原始类型过程中丢失的信息。如果检测到丢失的信息,则抛出ArithmeticException。这样,代码表示转换遇到了一些问题,并防止了这种不愉快的情况。

这些方法是longValueExact()intValueExact()shortValueExact()byteValueExact()

long nrExactLong = nr.longValueExact(); // works as expected
int nrExactInt = nr.intValueExact();    // throws ArithmeticException

注意,intValueExact()没有返回 -1 作为intValue()。这一次,由于试图将最大的long值转换为int而导致的信息丢失通过ArithmeticException类型的异常发出信号。

34 将long转换为int

long值转换为int值似乎是一件容易的工作。例如,潜在的解决方案可以依赖于以下条件:

long nr = Integer.MAX_VALUE;
int intNrCast = (int) nr;

或者,它可以依赖于Long.intValue(),如下所示:

int intNrValue = Long.valueOf(nrLong).intValue();

两种方法都很有效。现在,假设我们有以下long值:

long nrMaxLong = Long.MAX_VALUE;

这一次,两种方法都将返回 -1。为了避免这种结果,建议使用 JDK8,即Math.toIntExact()。此方法获取一个long类型的参数,并尝试将其转换为int。如果得到的值溢出了int,则该方法抛出ArithmeticException

// throws ArithmeticException
int intNrMaxExact = Math.toIntExact(nrMaxLong); 

在幕后,toIntExact()依赖于((int)value != value)条件。

35 除法的下限与模的计算

假设我们有以下划分:

double z = (double)222/14;

这将用这个除法的结果初始化z,即 15.85,但是我们的问题要求这个除法的下限是 15(这是小于或等于代数商的最大整数值)。获得该期望结果的解决方案将包括应用Math.floor(15.85),即 15。

但是,222 和 14 是整数,因此前面的除法如下:

int z = 222/14;

这一次,z将等于 15,这正是预期的结果(/运算符返回最接近零的整数)。无需申请Math.floor(z)。此外,如果除数为 0,则222/0将抛出ArithmeticException

到目前为止的结论是,两个符号相同的整数(都是正的或负的)的除法底可以通过/运算符得到。

好的,到目前为止,很好,但是假设我们有以下两个整数(相反的符号;被除数是负数,除数是正数,反之亦然):

double z = (double) -222/14;

此时,z将等于 -15.85。同样,通过应用Math.floor(z),结果将是 -16,这是正确的(这是小于或等于代数商的最大整数值)。

让我们用int再次讨论同样的问题:

int z = -222/14;

这次,z将等于 -15。这是不正确的,Math.floor(z)在这种情况下对我们没有帮助,因为Math.floor(-15)是 -15。所以,这是一个应该考虑的问题。

从 JDK8 开始,所有这些病例都通过Math.floorDiv()方法被覆盖和暴露。此方法以表示被除数和除数的两个整数为参数,返回小于或等于代数商的最大值(最接近正无穷大)int值:

int x = -222;
int y = 14;

// x is the dividend, y is the divisor
int z = Math.floorDiv(x, y); // -16

Math.floorDiv()方法有三种口味:floorDiv(int x, int y)floorDiv(long x, int y)floorDiv(long x, long y)

Math.floorDiv()之后,JDK8 附带了Math.floorMod(),它返回给定参数的地板模量。这是作为x - (floorDiv(x, y) * y)的结果计算的,因此对于符号相同的参数,它将返回与%运算符相同的结果;对于符号不相同的参数,它将返回不同的结果。

将两个正整数(a/b)的除法结果四舍五入可以快速完成,如下所示:

long result = (a + b - 1) / b;

下面是一个例子(我们有4/3=1.33,我们想要 2):

long result = (4 + 3 - 1) / 3; // 2

下面是另一个例子(我们有17/7=2.42,我们想要 3):

long result = (17 + 7 - 1) / 7; // 3

如果整数不是正的,那么我们可以依赖于Math.ceil()

long result = (long) Math.ceil((double) a/b);

36 下一个浮点值

有一个整数值,比如 10,使得我们很容易得到下一个整数值,比如10+1(在正无穷方向)或者10-1(在负无穷方向)。尝试为floatdouble实现同样的目标并不像对整数那么容易。

从 JDK6 开始,Math类通过nextAfter()方法得到了丰富。此方法采用两个参数,即初始数字(floatdouble)和方向(Float/Double.NEGATIVE/POSITIVE_INFINITY)——并返回下一个浮点值。在这里,返回 0.1 附近的下一个负无穷方向的浮点值是这种方法的一个特色:

float f = 0.1f;

// 0.099999994
float nextf = Math.nextAfter(f, Float.NEGATIVE_INFINITY);

从 JDK8 开始,Math类通过两个方法进行了丰富,这两个方法充当了nextAfter()的快捷方式,而且速度更快。这些方法是nextDown()nextUp()

float f = 0.1f;

float nextdownf = Math.nextDown(f); // 0.099999994
float nextupf = Math.nextUp(f); // 0.10000001

double d = 0.1d;

double nextdownd = Math.nextDown(d); // 0.09999999999999999
double nextupd = Math.nextUp(d); // 0.10000000000000002

因此,在负无穷大方向上的nextAfter()可通过Math.nextDown()nextAfter()获得,而在正无穷大方向上的Math.nextUp()可通过Math.nextUp()获得。

37 将两个大int/long值相乘并溢出

让我们从*操作符开始深入研究解决方案,如下例所示:

int x = 10;
int y = 5;
int z = x * y; // 50

这是一种非常简单的方法,对于大多数涉及intlongfloatdouble的计算都很好。

现在,让我们将此运算符应用于以下两个大数(将 2147483647 与自身相乘):

int x = Integer.MAX_VALUE;
int y = Integer.MAX_VALUE;
int z = x * y; // 1

此时,z将等于 1,这不是预期的结果,即 4611686014132420609。仅将z类型从int更改为long将无济于事。但是,将xy的类型从int改为long将:

long x = Integer.MAX_VALUE;
long y = Integer.MAX_VALUE;
long z = x * y; // 4611686014132420609

但是如果我们用Long.MAX_VALUE代替Integer.MAX_VALUE,问题会再次出现:

long x = Long.MAX_VALUE;
long y = Long.MAX_VALUE;
long z = x * y; // 1

因此,溢出域并依赖于*运算符的计算将最终导致误导性结果。

与其在进一步的计算中使用这些结果,不如在发生溢出操作时及时得到通知。JDK8 附带了Math.multiplyExact()方法。此方法尝试将两个整数相乘。如果结果溢出,int只抛出ArithmeticException

int x = Integer.MAX_VALUE;
int y = Integer.MAX_VALUE;
int z = Math.multiplyExact(x, y); // throw ArithmeticException

在 JDK8 中,Math.muliplyExact(int x, int y)返回intMath.muliplyExact(long x, long y)返回long。在 JDK9 中,还添加了Math.muliplyExact(long, int y)返回long

JDK9 带有返回值为longMath.multiplyFull(int x, int y)。此方法对于获得两个整数的精确数学积long非常有用,如下所示:

int x = Integer.MAX_VALUE;
int y = Integer.MAX_VALUE;
long z = Math.multiplyFull(x, y); // 4611686014132420609

为了记录在案,JDK9 还附带了一个名为Math.muliptlyHigh(long x, long y)的方法,返回一个long。此方法返回的long值表示两个 64 位因子的 128 位乘积的最高有效 64 位:

long x = Long.MAX_VALUE;
long y = Long.MAX_VALUE;
// 9223372036854775807 * 9223372036854775807 = 4611686018427387903
long z = Math.multiplyHigh(x, y);

在函数式上下文中,潜在的解决方案将依赖于BinaryOperator函数式接口,如下所示(只需定义相同类型的两个操作数的操作):

int x = Integer.MAX_VALUE;
int y = Integer.MAX_VALUE;
BinaryOperator<Integer> operator = Math::multiplyExact;
int z = operator.apply(x, y); // throw ArithmeticException

对于处理大数,还应关注BigInteger(不可变任意精度整数)和BigDecimal(不可变任意精度带符号十进制数)类。

38 融合乘法加法

数学计算a * b + c在矩阵乘法中被大量利用,在高性能计算、人工智能应用、机器学习、深度学习、神经网络等领域有着广泛的应用。

实现此计算的最简单方法直接依赖于*+运算符,如下所示:

double x = 49.29d;
double y = -28.58d;
double z = 33.63d;
double q = (x * y) + z;

这种实现的主要问题是由两个舍入误差(一个用于乘法运算,一个用于加法运算)引起的精度和性能低下。

不过,多亏了 Intel AVX 执行 SIMD 操作的指令和 JDK9,JDK9 添加了Math.fma()方法,这种计算才得以提高。依靠Math.fma(),使用“四舍五入到最接近的偶数四舍五入”模式只进行一次四舍五入:

double fma = Math.fma(x, y, z);

请注意,这种改进适用于现代 Intel 处理器,因此仅使用 JDK9 是不够的。

39 紧凑数字格式

从 JDK12 开始,添加了一个用于紧凑数字格式的新类。这个类被命名为java.text.CompactNumberFormat。这个类的主要目标是扩展现有的 Java 数字格式化 API,支持区域设置和压缩。

数字可以格式化为短样式(例如,1000变成1K),也可以格式化为长样式(例如,1000变成1000)。这两种风格在Style枚举中分为SHORTLONG

除了CompactNumberFormat构造器外,还可以通过两个static方法创建CompactNumberFormat,这两个方法被添加到NumberFormat类中:

  • 第一种是默认语言环境的紧凑数字格式,带有NumberFormat.Style.SHORT
public static NumberFormat getCompactNumberInstance()
  • 第二种是指定区域设置的紧凑数字格式,带有NumberFormat.Style
public static NumberFormat getCompactNumberInstance​(
    Locale locale, NumberFormat.Style formatStyle)

让我们仔细看看格式化和解析。

格式化

默认情况下,使用RoundingMode.HALF_EVEN格式化数字。但是,我们可以通过NumberFormat.setRoundingMode()显式设置舍入模式。

尝试将这些信息压缩成一个名为NumberFormatters的工具类可以实现如下:

public static String forLocale(Locale locale, double number) {

  return format(locale, Style.SHORT, null, number);
}

public static String forLocaleStyle(
  Locale locale, Style style, double number) {

  return format(locale, style, null, number);
}

public static String forLocaleStyleRound(
  Locale locale, Style style, RoundingMode mode, double number) {

  return format(locale, style, mode, number);
}

private static String format(
  Locale locale, Style style, RoundingMode mode, double number) {

  if (locale == null || style == null) {
    return String.valueOf(number); // or use a default format
  }

  NumberFormat nf = NumberFormat.getCompactNumberInstance(locale,
     style);

  if (mode != null) {
    nf.setRoundingMode(mode);
  }

  return nf.format(number);
}

现在,我们将数字100010000001000000000格式化为US语言环境、SHORT样式和默认舍入模式:

// 1K
NumberFormatters.forLocaleStyle(Locale.US, Style.SHORT, 1_000);

// 1M
NumberFormatters.forLocaleStyle(Locale.US, Style.SHORT, 1_000_000);

// 1B
NumberFormatters.forLocaleStyle(Locale.US, Style.SHORT, 
  1_000_000_000);

我们可以对LONG样式做同样的处理:

// 1thousand
NumberFormatters.forLocaleStyle(Locale.US, Style.LONG, 1_000);

// 1million
NumberFormatters.forLocaleStyle(Locale.US, Style.LONG, 1_000_000);

// 1billion
NumberFormatters.forLocaleStyle(Locale.US, Style.LONG, 1_000_000_000);

我们也可以使用ITALIAN区域设置和SHORT样式:

// 1.000
NumberFormatters.forLocaleStyle(Locale.ITALIAN, Style.SHORT, 
  1_000);

// 1 Mln
NumberFormatters.forLocaleStyle(Locale.ITALIAN, Style.SHORT, 
  1_000_000);

// 1 Mld
NumberFormatters.forLocaleStyle(Locale.ITALIAN, Style.SHORT, 
  1_000_000_000);

最后,我们还可以使用ITALIAN语言环境和LONG样式:

// 1 mille
NumberFormatters.forLocaleStyle(Locale.ITALIAN, Style.LONG, 
  1_000);

// 1 milione
NumberFormatters.forLocaleStyle(Locale.ITALIAN, Style.LONG, 
  1_000_000);

// 1 miliardo
NumberFormatters.forLocaleStyle(Locale.ITALIAN, Style.LONG, 
  1_000_000_000);

现在,假设我们有两个数字:12001600

从取整方式来看,分别取整为10002000。默认取整模式HALF_EVEN1200取整为1000,将1600取整为2000。但是如果我们想让1200变成20001600变成1000,那么我们需要明确设置取整模式如下:

// 2000 (2 thousand)
NumberFormatters.forLocaleStyleRound(
  Locale.US, Style.LONG, RoundingMode.UP, 1_200);

// 1000 (1 thousand)
NumberFormatters.forLocaleStyleRound(
  Locale.US, Style.LONG, RoundingMode.DOWN, 1_600);

解析

解析是格式化的相反过程。我们有一个给定的字符串,并尝试将其解析为一个数字。这可以通过NumberFormat.parse()方法来实现。默认情况下,解析不利用分组(例如,不分组时,5,50K解析为5;分组时,5,50K解析为550000)。

如果我们将此信息压缩为一组助手方法,则获得以下输出:

public static Number parseLocale(Locale locale, String number) 
    throws ParseException {

  return parse(locale, Style.SHORT, false, number);
}

public static Number parseLocaleStyle(
  Locale locale, Style style, String number) throws ParseException {

  return parse(locale, style, false, number);
}

public static Number parseLocaleStyleRound(
  Locale locale, Style style, boolean grouping, String number)
    throws ParseException {

  return parse(locale, style, grouping, number);
}

private static Number parse(
  Locale locale, Style style, boolean grouping, String number)
    throws ParseException {

  if (locale == null || style == null || number == null) {
    throw new IllegalArgumentException(
      "Locale/style/number cannot be null");
  }

  NumberFormat nf = NumberFormat.getCompactNumberInstance(locale, 
    style);
  nf.setGroupingUsed(grouping);

  return nf.parse(number);
}

让我们将5K5 thousand解析为5000,而不显式分组:

// 5000
NumberFormatters.parseLocaleStyle(Locale.US, Style.SHORT, "5K");

// 5000
NumberFormatters.parseLocaleStyle(Locale.US, Style.LONG, "5 thousand");

现在,我们用显式分组解析5K5 thousand5000

// 550000
NumberFormatters.parseLocaleStyleRound(
  Locale.US, Style.SHORT, true, "5,50K");

// 550000
NumberFormatters.parseLocaleStyleRound(
  Locale.US, Style.LONG, true, "5,50 thousand");

通过setCurrency​()setParseIntegerOnly()setMaximumIntegerDigits()setMinimumIntegerDigits()setMinimumFractionDigits()setMaximumFractionDigits()方法可以获得更多的调优。

总结

本章收集了一系列涉及字符串和数字的最常见问题。显然,这样的问题有很多,试图涵盖所有这些问题远远超出了任何一本书的范围。然而,了解如何解决本章中提出的问题,为您自己解决许多其他相关问题提供了坚实的基础

从本章下载应用以查看结果和其他详细信息。

二、对象、不变性和switch表达式

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括 18 个涉及对象、不变性和switch表达式的问题。本章从处理null引用的几个问题入手。它继续处理有关检查索引、equals()hashCode()以及不变性(例如,编写不可变类和从不可变类传递/返回可变对象)的问题。本章的最后一部分讨论了克隆对象和 JDK12switch表达式。本章结束时,您将掌握对象和不变性的基本知识。此外,你将知道如何处理新的switch表达式。在任何 Java 开发人员的武库中,这些都是有价值的、非可选的知识。

问题

使用以下问题来测试您的对象、不变性和switch表达式编程能力。我强烈建议您在转向解决方案和下载示例程序之前,尝试一下每个问题:

  1. 使用命令式代码检查null函数式引用:编写程序,对给定的函数式引用和命令式代码进行null检查。
  2. 检查null引用并抛出一个定制的NullPointerException错误:编写一个程序,对给定的引用执行null检查并抛出带有定制消息的NullPointerException
  3. 检查null引用并抛出指定的异常(例如,IllegalArgumentException:编写一个程序,对给定的引用执行null检查并抛出指定的异常。
  4. 检查null引用并返回非null默认引用:编写程序,对给定引用执行null检查,如果是非null,则返回;否则返回非null默认引用。
  5. 检查从 0 到长度范围内的索引:编写一个程序,检查给定索引是否在 0(含)到给定长度(不含)之间。如果给定索引超出 0 到给定长度的范围,则抛出IndexOutOfBoundsException
  6. 检查从 0 到长度范围内的子范围:编写一个程序,检查给定的开始到给定的结束的给定的子范围,是否在 0 到给定的长度的范围内。如果给定的子范围不在范围内,则抛出IndexOutOfBoundsException
  7. 解释equals()hashCode()并举例说明equals()hashCode()方法在 Java 中是如何工作的。
  8. 不可变对象概述:解释并举例说明什么是 Java 中的不可变对象。
  9. 不可变字符串:解释String类不可变的原因。
  10. 编写不可变类:写一个表示不可变类的程序。
  11. 向不可变类传递或从不可变类返回可变对象:编写一个程序,向不可变类传递或从不可变类返回可变对象。
  12. 通过构建器模式编写一个不可变类:编写一个表示不可变类中构建器模式实现的程序。51. 避免不可变对象中的坏数据:编写防止不可变对象中的坏数据的程序。
  13. 克隆对象:编写一个程序,演示浅层和深层克隆技术。
  14. 覆盖toString():解释并举例说明覆盖toString()的实践。
  15. switch表达式:简要概述 JDK12 中的switch表达式。
  16. 多个case标签:写一段代码,用多个case标签举例说明 JDK12switch
  17. 语句块:编写一段代码,用于举例说明 JDK12 switch,其中的case标签指向花括号块。

以下各节介绍上述每个问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释仅包括解决问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多详细信息,并尝试程序

40 在函数式和命令式代码中检查空引用

与函数样式或命令式代码无关,检查null引用是一种常用且推荐的技术,用于减少著名的NullPointerException异常的发生。这种检查被大量用于方法参数,以确保传递的引用不会导致NullPointerException或意外行为。

例如,将List<Integer>传递给方法可能需要至少两个null检查。首先,该方法应该确保列表引用本身不是null。其次,根据列表的使用方式,该方法应确保列表不包含null对象:

List<Integer> numbers 
  = Arrays.asList(1, 2, null, 4, null, 16, 7, null);

此列表将传递给以下方法:

public static List<Integer> evenIntegers(List<Integer> integers) {

  if (integers == null) {
    return Collections.EMPTY_LIST;
  }

  List<Integer> evens = new ArrayList<>();
  for (Integer nr: integers) {
    if (nr != null && nr % 2 == 0) {
      evens.add(nr);
    }
  }

  return evens;
}

注意,前面的代码使用依赖于==!=运算符(integers==nullnr !=null的经典检查。从 JDK8 开始,java.util.Objects类包含两个方法,它们基于这两个操作符包装null检查:object == null包装在Objects.isNull()中,object != null包装在Objects.nonNull()中。

基于这些方法,前面的代码可以重写如下:

public static List<Integer> evenIntegers(List<Integer> integers) {

  if (Objects.isNull(integers)) {
    return Collections.EMPTY_LIST;
  }

  List<Integer> evens = new ArrayList<>();

  for (Integer nr: integers) {
    if (Objects.nonNull(nr) && nr % 2 == 0) {
      evens.add(nr);
    }
  }

  return evens;
}

现在,代码在某种程度上更具表现力,但这并不是这两种方法的主要用法。实际上,这两个方法是为了另一个目的(符合 API 注解)而添加的——在 Java8 函数式代码中用作谓词。在函数式代码中,null检查可以如下例所示完成:

public static int sumIntegers(List<Integer> integers) {

  if (integers == null) {
    throw new IllegalArgumentException("List cannot be null");
  }

  return integers.stream()
    .filter(i -> i != null)
    .mapToInt(Integer::intValue).sum();
}

public static boolean integersContainsNulls(List<Integer> integers) {

  if (integers == null) {
    return false;
  }

  return integers.stream()
    .anyMatch(i -> i == null);
}

很明显,i -> i != nulli -> i == null的表达方式与周围的代码不一样。让我们用Objects.nonNull()Objects.isNull()替换这些代码片段:

public static int sumIntegers(List<Integer> integers) {

  if (integers == null) {
    throw new IllegalArgumentException("List cannot be null");
  }

  return integers.stream()
    .filter(Objects::nonNull)
    .mapToInt(Integer::intValue).sum();
}

public static boolean integersContainsNulls(List<Integer> integers) {

  if (integers == null) {
    return false;
  }

  return integers.stream()
    .anyMatch(Objects::isNull);
}

或者,我们也可以使用Objects.nonNull()Objects.isNull()方法作为参数:

public static int sumIntegers(List<Integer> integers) {

  if (Objects.isNull(integers)) {
    throw new IllegalArgumentException("List cannot be null");
  }

  return integers.stream()
    .filter(Objects::nonNull)
    .mapToInt(Integer::intValue).sum();
}

public static boolean integersContainsNulls(List<Integer> integers) {

  if (Objects.isNull(integers)) {
    return false;
  }

  return integers.stream()
    .anyMatch(Objects::isNull);
}

令人惊叹的!因此,作为结论,无论何时需要进行null检查,函数式代码都应该依赖于这两种方法,而在命令式代码中,这是一种偏好。

41 检查空引用并引发自定义的NullPointerException

检查null引用并用定制消息抛出NullPointerException可以使用以下代码完成(此代码执行这四次,在构造器中执行两次,在assignDriver()方法中执行两次):

public class Car {

  private final String name;
  private final Color color;

  public Car(String name, Color color) {

    if (name == null) {
      throw new NullPointerException("Car name cannot be null");
    }

    if (color == null) {
      throw new NullPointerException("Car color cannot be null");
    }

    this.name = name;
    this.color = color;
  }

  public void assignDriver(String license, Point location) {

    if (license == null) {
      throw new NullPointerException("License cannot be null");
    }

    if (location == null) {
      throw new NullPointerException("Location cannot be null");
    }
  }
}

因此,这段代码通过结合==操作符和NullPointerException类的手动实例化来解决这个问题。从 JDK7 开始,这种代码组合隐藏在一个名为Objects.requireNonNull()static方法中。通过这种方法,前面的代码可以用表达的方式重写:

public class Car {

  private final String name;
  private final Color color;

  public Car(String name, Color color) {

    this.name = Objects.requireNonNull(name, "Car name cannot be 
      null");
    this.color = Objects.requireNonNull(color, "Car color cannot be 
      null");
  }

  public void assignDriver(String license, Point location) {

    Objects.requireNonNull(license, "License cannot be null");
    Objects.requireNonNull(location, "Location cannot be null");
  }
}

因此,如果指定的引用是null,那么Objects.requireNonNull()将抛出一个包含所提供消息的NullPointerException。否则,它将返回选中的引用。

在构造器中,当提供的引用是null时,有一种典型的抛出NullPointerException的方法。但在方法上(例如,assignDriver()),这是一个有争议的方法。一些开发人员更喜欢返回一个无害的结果或者抛出IllegalArgumentException。下一个问题,检查空引用并抛出指定的异常(例如,IllegalArgumentException),解决了IllegalArgumentException方法。

在 JDK7 中,有两个Objects.requireNonNull()方法,一个是以前使用的,另一个是抛出带有默认消息的NullPointerException,如下例所示:

this.name = Objects.requireNonNull(name);

从 JDK8 开始,还有一个Objects.requireNonNull()。这个将NullPointerException的自定义消息封装在Supplier中。这意味着消息创建被推迟,直到给定的引用是null(这意味着使用+操作符连接消息的各个部分不再是一个问题)。

举个例子:

this.name = Objects.requireNonNull(name, () 
  -> "Car name cannot be null ... Consider one from " + carsList);

如果此引用不是null,则不创建消息。

42 检查空引用并引发指定的异常

当然,一种解决方案需要直接依赖于==操作符,如下所示:

if (name == null) {
  throw new IllegalArgumentException("Name cannot be null");
}

因为没有requireNonNullElseThrow()方法,所以这个问题不能用java.util.Objects的方法来解决。抛出IllegalArgumentException或其他指定的异常可能需要一组方法,如下面的屏幕截图所示:

让我们关注一下requireNonNullElseThrowIAE()方法。这两个方法抛出IllegalArgumentException,其中一个自定义消息被指定为StringSupplier(在null被求值为true之前避免创建):

public static <T> T requireNonNullElseThrowIAE(
    T obj, String message) {

  if (obj == null) {
    throw new IllegalArgumentException(message);
  }

  return obj;
}

public static <T> T requireNonNullElseThrowIAE(T obj,
    Supplier<String> messageSupplier) {

  if (obj == null) {
    throw new IllegalArgumentException(messageSupplier == null 
      ? null : messageSupplier.get());
  }

  return obj;
}

所以,投掷IllegalArgumentException可以通过这两种方法来完成。但这还不够。例如,代码可能需要抛出IllegalStateExceptionUnsupportedOperationException等。对于这种情况,最好采用以下方法:

public static <T, X extends Throwable> T requireNonNullElseThrow(
    T obj, X exception) throws X {

  if (obj == null) {
    throw exception;
  }

  return obj;
}

public static <T, X extends Throwable> T requireNotNullElseThrow(
    T obj, Supplier<<? extends X> exceptionSupplier) throws X {

  if (obj != null) {
    return obj;
  } else {
    throw exceptionSupplier.get();
  }
}

考虑将这些方法添加到名为MyObjects的助手类中。如以下示例所示调用这些方法:

public Car(String name, Color color) {

  this.name = MyObjects.requireNonNullElseThrow(name,
    new UnsupportedOperationException("Name cannot be set as null"));
  this.color = MyObjects.requireNotNullElseThrow(color, () ->
    new UnsupportedOperationException("Color cannot be set as null"));
}

此外,我们也可以通过这些例子来丰富MyObjects中的其他异常。

43 检查空引用并返回非空默认引用

通过if-else(或三元运算符)可以很容易地提供该问题的解决方案,如以下示例所示(作为变体,namecolor可以声明为非final,并在声明时用默认值初始化):

public class Car {

  private final String name;
  private final Color color;
  public Car(String name, Color color) {

    if (name == null) {
      this.name = "No name";
    } else {
      this.name = name;
    }

    if (color == null) {
      this.color = new Color(0, 0, 0);
    } else {
      this.color = color;
    }
  }
}

但是,从 JDK9 开始,前面的代码可以通过Objects类的两个方法简化。这些方法是requireNonNullElse()requireNonNullElseGet()。它们都有两个参数,一个是检查空值的引用,另一个是在检查的引用为null时返回的非null默认引用:

public class Car {

  private final String name;
  private final Color color;

  public Car(String name, Color color) {

    this.name = Objects.requireNonNullElse(name, "No name");
    this.color = Objects.requireNonNullElseGet(color,
      () -> new Color(0, 0, 0));
  }
}

在前面的示例中,这些方法在构造器中使用,但也可以在方法中使用。

44 检查从 0 到长度范围内的索引

首先,让我们用一个简单的场景来突出这个问题。此场景可能在以下简单类中实现:

public class Function {

  private final int x;

  public Function(int x) {

    this.x = x;
  }

  public int xMinusY(int y) {

    return x - y;
  }

  public static int oneMinusY(int y) {

    return 1 - y;
  }
}

注意,前面的代码片段没有对xy进行任何范围限制。现在,让我们施加以下范围(这在数学函数中非常常见):

  • x必须介于 0(含)和 11(不含)之间,所以x属于[0, 11)
  • xMinusY()方法中,y必须在 0(含)x(不含)之间,所以y属于[0, x)
  • oneMinusY()方法中,y必须介于 0(包含)和 16(排除)之间,所以y属于[0, 16)

这些范围可以通过if语句在代码中施加,如下所示:

public class Function {

  private static final int X_UPPER_BOUND = 11;
  private static final int Y_UPPER_BOUND = 16;
  private final int x;

  public Function(int x) {

    if (x < 0 || x >= X_UPPER_BOUND) {
      throw new IndexOutOfBoundsException("..."); 
    }

    this.x = x;
  }

  public int xMinusY(int y) {

    if (y < 0 || y >= x) {
      throw new IndexOutOfBoundsException("...");
    }

    return x - y;
  }

  public static int oneMinusY(int y) {

    if (y < 0 || y >= Y_UPPER_BOUND) {
      throw new IndexOutOfBoundsException("...");
    }

    return 1 - y;
  }
}

考虑用更有意义的异常替换IndexOutOfBoundsException(例如,扩展IndexOutOfBoundsException并创建一个类型为RangeOutOfBoundsException的自定义异常)。

从 JDK9 开始,可以重写代码以使用Objects.checkIndex()方法。此方法验证给定索引是否在 0 到长度的范围内,并返回该范围内的给定索引或抛出IndexOutOfBoundsException

public class Function {

  private static final int X_UPPER_BOUND = 11;
  private static final int Y_UPPER_BOUND = 16;
  private final int x;

  public Function(int x) {

    this.x = Objects.checkIndex(x, X_UPPER_BOUND);
  }

  public int xMinusY(int y) {

    Objects.checkIndex(y, x);

    return x - y;
  }

  public static int oneMinusY(int y) {

    Objects.checkIndex(y, Y_UPPER_BOUND);

    return 1 - y;
  }
}

例如,调用oneMinusY(),如下一个代码片段所示,将导致IndexOutOfBoundsException,因为y可以取[0, 16]之间的值:

int result = Function.oneMinusY(20);

现在,让我们进一步检查从 0 到给定长度的子范围。

45 检查从 0 到长度范围内的子范围

让我们遵循上一个问题的相同流程。所以,这一次,Function类将如下所示:

public class Function {

  private final int n;

  public Function(int n) {

    this.n = n;
  }

  public int yMinusX(int x, int y) {

    return y - x;
  }
}

注意,前面的代码片段没有对xyn进行任何范围限制。现在,让我们施加以下范围:

  • n必须介于 0(含)和 101(不含)之间,所以n属于[0, 101]
  • yMinusX()方法中,由xyxy限定的范围必须是[0, n]的子范围。

这些范围可以通过if语句在代码中施加,如下所示:

public class Function {

  private static final int N_UPPER_BOUND = 101;
  private final int n;

  public Function(int n) {

    if (n < 0 || n >= N_UPPER_BOUND) {
      throw new IndexOutOfBoundsException("...");
    }

    this.n = n;
  }

  public int yMinusX(int x, int y) {

    if (x < 0 || x > y || y >= n) {
      throw new IndexOutOfBoundsException("...");
    }

    return y - x;
  }
}

基于前面的问题,n的条件可以替换为Objects.checkIndex()。此外,JDK9Objects类还提供了一个名为checkFromToIndex(int start, int end, int length)的方法,该方法检查给定的子范围给定的开始给定的结束是否在 0 到给定的长度的范围内。因此,此方法可应用于yMinusX()方法,以检查xy所限定的范围是否为 0 到n的子范围:

public class Function {

  private static final int N_UPPER_BOUND = 101;
  private final int n;

  public Function(int n) {

    this.n = Objects.checkIndex(n, N_UPPER_BOUND);
  }

  public int yMinusX(int x, int y) {

    Objects.checkFromToIndex(x, y, n);
    return y - x;
  }
}

例如,由于x大于y,下面的测试将导致IndexOutOfBoundsException

Function f = new Function(50);
int r = f.yMinusX(30, 20);

除了这个方法之外,Objects还有另一个名为checkFromIndexSize(int start, int size, int length)的方法。该方法检查给定开始时间给定开始时间加给定大小的子范围,是否在 0 到给定长度的范围内。

46 equals()hashCode()

equals()hashCode()方法在java.lang.Object中定义。因为Object是所有 Java 对象的超类,所以这两种方法对所有对象都可用。他们的主要目标是为比较对象提供一个简单、高效、健壮的解决方案,并确定它们是否相等。如果没有这些方法和它们的契约,解决方案依赖于庞大而繁琐的if语句来比较对象的每个字段。

当这些方法没有被覆盖时,Java 将使用它们的默认实现。不幸的是,默认实现并不能真正实现确定两个对象是否具有相同值的目标。默认情况下,equals()检查相等性。换言之,当且仅当两个对象由相同的内存地址(相同的对象引用)表示时,它认为这两个对象相等,而hashCode()返回对象内存地址的整数表示。这是一个本机函数,称为标识**哈希码。

例如,假设以下类:

public class Player {

  private int id;
  private String name;

  public Player(int id, String name) {

    this.id = id;
    this.name = name;
  }
}

然后,让我们创建包含相同信息的此类的两个实例,并比较它们是否相等:

Player p1 = new Player(1, "Rafael Nadal");
Player p2 = new Player(1, "Rafael Nadal");

System.out.println(p1.equals(p2)); // false
System.out.println("p1 hash code: " + p1.hashCode()); // 1809787067
System.out.println("p2 hash code: " + p2.hashCode()); // 157627094

不要使用==运算符来测试对象的相等性(避免使用if(p1 == p2)==操作符比较两个对象的引用是否指向同一个对象,而equals()比较对象值(作为人类,这是我们关心的)。

根据经验,如果两个变量拥有相同的引用,则它们相同,但是如果它们引用相同的值,则它们相等相同值的含义由equals()定义。

对我们来说,p1p2是相等的,但是请注意equals()返回了falsep1p2实例的字段值完全相同,但是它们存储在不同的内存地址)。这意味着依赖于equals()的默认实现是不可接受的。解决方法是覆盖此方法,为此,重要的是要了解equals()合同,该合同规定了以下声明:

  • 自反性:对象等于自身,即p1.equals(p1)必须返回true
  • 对称性p1.equals(p2)必须返回与p2.equals(p1)相同的结果(true/false)。
  • 传递性:如果是p1.equals(p2)p2.equals(p3),那么也是p1.equals(p3)
  • 一致性:两个相等的物体必须一直保持相等,除非其中一个改变。
  • null返回false:所有对象必须不等于null

因此,为了遵守此约定,Player类的equals()方法可以覆盖如下:

@Override
public boolean equals(Object obj) {

  if (this == obj) {
    return true;
  }

  if (obj == null) {
    return false;
  }

  if (getClass() != obj.getClass()) {
    return false;
  }

  final Player other = (Player) obj;

  if (this.id != other.id) {
    return false;
  }

  if (!Objects.equals(this.name, other.name)) {
    return false;
  }

  return true;
}

现在,让我们再次执行相等性测试(这次,p1等于p2

System.out.println(p1.equals(p2)); // true

好的,到目前为止还不错!现在,让我们将这两个Player实例添加到集合中。例如,让我们将它们添加到一个HashSet(一个不允许重复的 Java 集合):

Set<Player> players = new HashSet<>();
players.add(p1);
players.add(p2);

让我们检查一下这个HashSet的大小以及它是否包含p1

System.out.println("p1 hash code: " + p1.hashCode()); // 1809787067
System.out.println("p2 hash code: " + p2.hashCode()); // 157627094
System.out.println("Set size: " + players.size());    // 2
System.out.println("Set contains Rafael Nadal: "
  + players.contains(new Player(1, "Rafael Nadal"))); // false

与前面实现的equals()一致,p1p2是相等的,因此HashSet的大小应该是 1,而不是 2。此外,它应该包含纳达尔。那么,发生了什么?

一般的答案在于 Java 是如何创建的。凭直觉很容易看出,equals()不是一种快速的方法;因此,当需要大量的相等比较时,查找将面临性能损失。例如,在通过集合中的特定值(例如,HashSetHashMapHashTable进行查找的情况下,这增加了一个严重的缺点,因为它可能需要大量的相等比较。

基于这个语句,Java 试图通过添加来减少相等比较。桶是一个基于散列的容器,它将相等的对象分组。这意味着相等的对象应该返回相同的哈希码,而不相等的对象应该返回不同的哈希码(如果两个不相等的对象具有相同的哈希码,则这是一个散列冲突,并且对象将进入同一个桶)。因此,Java 会比较散列代码,只有当两个不同的对象引用的散列代码相同(而不是相同的对象引用)时,它才会进一步调用equals()。基本上,这会加速集合中的查找。

但我们的案子发生了什么?让我们一步一步来看看:

  • 当创建p1时,Java 将根据p1内存地址为其分配一个哈希码。
  • p1被添加到Set时,Java 会将一个新的桶链接到p1哈希码。
  • 当创建p2时,Java 将根据p2内存地址为其分配一个哈希码。
  • p2被添加到Set时,Java 会将一个新的桶链接到p2哈希码(当这种情况发生时,看起来HashSet没有按预期工作,它允许重复)。
  • 当执行players.contains(new Player(1, "Rafael Nadal"))时,基于p3存储器地址用新的哈希码创建新的播放器p3
  • 因此,在contains()的框架中,分别测试p1p3 p2p3的相等性涉及检查它们的哈希码,由于p1哈希码不同于p3哈希码,而p2哈希码不同于p3哈希码,比较停止,没有求值equals(),这意味着HashSet不包含对象(p3

为了回到正轨,代码也必须覆盖hashCode()方法。hashCode()合同规定如下:

  • 符合equals()的两个相等对象必须返回相同的哈希码。
  • 具有相同哈希码的两个对象不是强制相等的。
  • 只要对象保持不变,hashCode()必须返回相同的值。

根据经验,为了尊重equals()hashCode()合同,遵循两条黄金法则:

  • equals()被覆盖时,hashCode()也必须被覆盖,反之亦然。
  • 以相同的顺序对两个方法使用相同的标识属性。

对于Player类,hashCode()可以被覆盖如下:

@Override
public int hashCode() {

  int hash = 7;
  hash = 79 * hash + this.id;
  hash = 79 * hash + Objects.hashCode(this.name);

  return hash;
}

现在,让我们执行另一个测试(这次,它按预期工作):

System.out.println("p1 hash code: " + p1.hashCode()); // -322171805
System.out.println("p2 hash code: " + p2.hashCode()); // -322171805
System.out.println("Set size: " + players.size());    // 1
System.out.println("Set contains Rafael Nadal: "
  + players.contains(new Player(1, "Rafael Nadal"))); // true

现在,让我们列举一下使用equals()hashCode()时的一些常见错误:

  • 您覆盖了equals()并忘记覆盖hashCode(),反之亦然(覆盖两者或无)。

  • 您使用==运算符而不是equals()来比较对象值。

  • equals()中,省略以下一项或多项:

    • 从添加自检if (this == obj)...开始。
    • 因为没有实例应该等于null,所以继续添加空校验if(obj == null)...)。
    • 确保实例是我们期望的(使用getClass()instanceof
    • 最后,在这些角落案例之后,添加字段比较。
  • 你通过继承来破坏对称。假设一个类A和一个类B扩展了A并添加了一个新字段。B类覆盖从A继承的equals()实现,并将此实现添加到新字段中。依赖instanceof会发现b.equals(a)会返回false(如预期),而a.equals(b)会返回true(非预期),因此对称性被破坏。依赖切片比较是行不通的,因为这会破坏及物性和自反性。解决这个问题意味着依赖于getClass()而不是instanceof(通过getClass(),类型及其子类型的实例不能相等),或者更好地依赖于组合而不是继承,就像绑定到本书中的应用(P46_ViolateEqualsViaSymmetry一样)。

  • 返回一个来自hashCode()的常量,而不是每个对象的唯一哈希码。

自 JDK7 以来,Objects类提供了几个帮助程序来处理对象相等和哈希码,如下所示:

  • Objects.equals(Object a, Object b):测试a对象是否等于b对象。
  • Objects.deepEquals(Object a, Object b):用于测试两个对象是否相等(如果是数组,则通过Arrays.deepEquals()进行测试)。
  • Objects.hash(Object ... values):为输入值序列生成哈希码。

通过EqualsVerifier库(确保equals()hashCode()尊重 Java SE 合同)。

依赖Lombok库从对象的字段生成hashCode()equals()。但请注意Lombok与 JPA 实体结合的特殊情况。

47 不可变对象简述

不可变对象是一个一旦创建就不能更改的对象(其状态是固定的)。

在 Java 中,以下内容适用:

  • 原始类型是不可变的。
  • 著名的 JavaString类是不可变的(其他类也是不可变的,比如PatternLocalDate
  • 数组不是不变的。
  • 集合可以是可变的、不可修改的或不可变的。

不可修改的集合不是自动不变的。它取决于集合中存储的对象。如果存储的对象是可变的,那么集合是可变的和不可修改的。但是如果存储的对象是不可变的,那么集合实际上是不可变的。

不可变对象在并发(多线程)应用和流中很有用。由于不可变对象不能更改,因此它们无法处理并发问题,并且不会有损坏或不一致的风险。

使用不可变对象的一个主要问题与创建新对象的代价有关,而不是管理可变对象的状态。但是请记住,不可变对象在垃圾收集期间利用了特殊处理。此外,它们不容易出现并发问题,并且消除了管理可变对象状态所需的代码。管理可变对象状态所需的代码往往比创建新对象慢。

通过研究以下问题,我们可以更深入地了解 Java 中的对象不变性。

48 不可变字符串

每种编程语言都有一种表示字符串的方法。作为基本类型,字符串是预定义类型的一部分,几乎所有类型的 Java 应用都使用它们。

在 Java 中,字符串不是由一个像intlongfloat这样的原始类型来表示的。它们由名为String的引用类型表示。几乎所有 Java 应用都使用字符串,例如,Java 应用的main()方法获取一个String类型的数组作为参数。

String的臭名昭著及其广泛的应用意味着我们应该详细了解它。除了知道如何声明和操作字符串(例如,反转和大写)之外,开发人员还应该理解为什么这个类是以特殊或不同的方式设计的。更确切地说,String为什么是不可变的?或者这个问题有一个更好的共鸣,比如说,String不变的利弊是什么?

字符串不变性的优点

在下一节中,我们来看看字符串不变性的一些优点。

字符串常量池或缓存池

支持字符串不变性的原因之一是由字符串常量池SCP)或缓存池表示的。为了理解这种说法,让我们深入了解一下String类是如何在内部工作的。

SCP 是内存中的一个特殊区域(不是普通的堆内存),用于存储字符串文本。假设以下三个String变量:

String x = "book";
String y = "book";
String z = "book";

创建了多少个String对象?说三个很有诱惑力,但实际上 Java 只创建一个具有"book"值的String对象。其思想是,引号之间的所有内容都被视为一个字符串文本,Java 通过遵循这样的算法(该算法称为字符串内化)将字符串文本存储在称为 SCP 的特殊内存区域中:

  • 当一个字符串文本被创建时(例如,String x = "book"),Java 检查 SCP 以查看这个字符串文本是否存在。
  • 如果在 SCP 中找不到字符串字面值,则在 SCP 中为字符串字面值创建一个新的字符串对象,并且相应的变量x将指向它。
  • 如果在 SCP 中找到字符串字面值(例如,String y = "book"String z = "book"),那么新变量将指向String对象(基本上,所有具有相同值的变量都将指向相同的String对象):

但是x应该是"cook"而不是"book",所以我们用"c"-x = x.replace("b", "c");来代替"b"

x应该是"cook"yz应该保持不变。这种行为是由不变性提供的。Java 将创建一个新对象,并对其执行如下更改:

因此,字符串不变性允许缓存字符串文本,这允许应用使用大量字符串文本,对堆内存和垃圾收集器的影响最小。在可变上下文中,修改字符串字面值可能导致变量损坏。

不要创建一个字符串作为String x = new String("book")。这不是字符串文本;这是一个String实例(通过构造器构建),它将进入普通内存堆而不是 SCP。在普通堆内存中创建的字符串可以通过显式调用String.intern()方法作为x.intern()指向 SCP。

安全

字符串不变性的另一个好处是它的安全性。通常,许多敏感信息(用户名、密码、URL、端口、数据库、套接字连接、参数、属性等)都以字符串的形式表示和传递。通过使这些信息保持不变,代码对于各种安全威胁(例如,意外或故意修改引用)变得安全。

线程安全性

想象一个应用使用成千上万个可变的String对象并处理线程安全代码。幸运的是,在这种情况下,由于不变性,我们想象的场景不会变成现实。任何不可变对象本质上都是线程安全的。这意味着字符串可以由多个线程共享和操作,没有损坏和不一致的风险。

哈希码缓存

equals()hashCode()部分讨论了equals()hashCode()。每次对特定活动进行哈希运算(例如,搜索集合中的元素)时,都应该计算哈希码。因为String是不可变的,所以每个字符串都有一个不可变的哈希码,可以缓存和重用,因为它在创建字符串后不能更改。这意味着可以从缓存中使用字符串的哈希码,而不是每次使用时重新计算它们。例如,HashMap为不同的操作(例如,put()get())散列其键,如果这些键属于String类型,则哈希码将从缓存中重用,而不是重新计算它们。

类加载

在内存中加载类的典型方法依赖于调用Class.forName(String className)方法。注意表示类名的参数String。由于字符串不变性,在加载过程中不能更改类名。然而,如果String是可变的,那么想象加载class A(例如,Class.forName("A")),在加载过程中,它的名称将被更改为BadA。现在,BadA物体可以做坏事!

字符串不变性的缺点

在下一节中,我们来看看字符串不变性的一些缺点。

字符串不能扩展

应该声明一个不可变的类final,以避免扩展性。然而,开发人员需要扩展String类以添加更多的特性,这一限制可以被认为是不变性的一个缺点。

然而,开发人员可以编写工具类(例如,Apache Commons Lang、StringUtils、Spring 框架、StringUtils、Guava 和字符串)来提供额外的特性,并将字符串作为参数传递给这些类的方法。

敏感数据长时间存储在内存中

字符串中的敏感数据(例如密码)可能长时间驻留在内存(SCP)中。作为缓存,SCP 利用了来自垃圾收集器的特殊处理。更准确地说,垃圾收集器不会以与其他内存区域相同的频率(周期)访问 SCP。作为这种特殊处理的结果,敏感数据在 SCP 中保存了很长一段时间,并且很容易被不必要的使用。

为了避免这一潜在缺陷,建议将敏感数据(例如密码)存储在char[]而不是String中。

OutOfMemoryError错误

SCP 是一个很小的内存区,可以很快被填满。在 SCP 中存储过多的字符串字面值将导致OutOfMemoryError

字符串是完全不变的吗?

在幕后,String使用private final char[]来存储字符串的每个字符。通过使用 Java 反射 API,在 JDK8 中,以下代码将修改此char[](JDK11 中的相同代码将抛出java.lang.ClassCastException):

String user = "guest";
System.out.println("User is of type: " + user);

Class<String> type = String.class;
Field field = type.getDeclaredField("value");
field.setAccessible(true);

char[] chars = (char[]) field.get(user);

chars[0] = 'a';
chars[1] = 'd';
chars[2] = 'm';
chars[3] = 'i';
chars[4] = 'n';

System.out.println("User is of type: " + user);

因此,在 JDK8 中,String有效不可变的,但不是完全

49 编写不可变类

一个不可变的类必须满足几个要求,例如:

  • 该类应标记为final以抑制可扩展性(其他类不能扩展该类;因此,它们不能覆盖方法)
  • 所有字段都应该声明为privatefinal(在其他类中不可见,在这个类的构造器中只初始化一次)
  • 类应该包含一个参数化的public构造器(或者一个private构造器和用于创建实例的工厂方法),用于初始化字段
  • 类应该为字段提供获取器
  • 类不应公开设置器

例如,以下Point类是不可变的,因为它成功地通过了前面的检查表:

public final class Point {

  private final double x;
  private final double y;

  public Point(double x, double y) {
    this.x = x;
    this.y = y;
  }

  public double getX() {
    return x;
  }

  public double getY() {
    return y;
  }
}

如果不可变类应该操作可变对象,请考虑以下问题。

50 向不可变类传递/从不可变类返回可变对象

将可变对象传递给不可变类可能会破坏不可变性。让我们考虑以下可变类:

public class Radius {

  private int start;
  private int end;

  public int getStart() {
    return start;
  }

  public void setStart(int start) {
    this.start = start;
  }

  public int getEnd() {
    return end;
  }

  public void setEnd(int end) {
    this.end = end;
  }
}

然后,让我们将这个类的一个实例传递给一个名为Point的不可变类。乍一看,Point类可以写为:

public final class Point {

  private final double x;
  private final double y;
  private final Radius radius;

  public Point(double x, double y, Radius radius) {
    this.x = x;
    this.y = y;
    this.radius = radius;
  }

  public double getX() {
    return x;
  }

  public double getY() {
    return y;
  }

  public Radius getRadius() {
    return radius;
  }
}

这个类仍然是不变的吗?答案是否定的,Point类不再是不变的,因为它的状态可以改变,如下例所示:

Radius r = new Radius();
r.setStart(0);
r.setEnd(120);

Point p = new Point(1.23, 4.12, r);

System.out.println("Radius start: " + p.getRadius().getStart()); // 0
r.setStart(5);
System.out.println("Radius start: " + p.getRadius().getStart()); // 5

注意,调用p.getRadius().getStart()返回两个不同的结果;因此,p的状态已经改变,所以Point不再是不可变的。该问题的解决方案是克隆Radius对象并将克隆存储为Point的字段:

public final class Point {

  private final double x;
  private final double y;
  private final Radius radius;

  public Point(double x, double y, Radius radius) {
    this.x = x;
    this.y = y;

    Radius clone = new Radius();
    clone.setStart(radius.getStart());
    clone.setEnd(radius.getEnd());

    this.radius = clone;
  }

  public double getX() {
    return x;
  }

  public double getY() {
    return y;
  }

  public Radius getRadius() {
    return radius;
  }
}

这一次,Point类的不变性级别增加了(调用r.setStart(5)不会影响radius字段,因为该字段是r的克隆)。但是Point类并不是完全不可变的,因为还有一个问题需要解决,从不可变类返回可变对象会破坏不可变性。检查下面的代码,它分解了Point的不变性:

Radius r = new Radius();
r.setStart(0);
r.setEnd(120);

Point p = new Point(1.23, 4.12, r);

System.out.println("Radius start: " + p.getRadius().getStart()); // 0
p.getRadius().setStart(5);
System.out.println("Radius start: " + p.getRadius().getStart()); // 5

再次调用p.getRadius().getStart()返回两个不同的结果;因此,p的状态已经改变。解决方案包括修改getRadius()方法以返回radius字段的克隆,如下所示:

...
public Radius getRadius() {
    Radius clone = new Radius();
    clone.setStart(this.radius.getStart());
    clone.setEnd(this.radius.getEnd());

    return clone;
  }
...

现在,Point类又是不可变的。问题解决了!

在选择克隆技术/工具之前,在某些情况下,建议您花点时间分析/学习 Java 和第三方库中可用的各种可能性(例如,检查本章中的”克隆对象“部分)。对于浅拷贝,前面的技术可能是正确的选择,但是对于深拷贝,代码可能需要依赖不同的方法,例如复制构造器、Cloneable接口或外部库(例如,Apache Commons LangObjectUtils、JSON 序列化与Gson或 Jackson,或任何其他方法)。

51 通过生成器模式编写不可变类

当一个类(不可变或可变)有太多字段时,它需要一个具有许多参数的构造器。当其中一些字段是必需的,而其他字段是可选的时,这个类将需要几个构造器来覆盖所有可能的组合。这对于开发人员和类的用户来说都是很麻烦的。这就是构建器模式的用武之地。

根据四人帮GoF),构建器模式将复杂对象的构造与其表示分离,以便相同的构造过程可以创建不同的表示

生成器模式可以作为一个单独的类或内部的static类来实现。让我们关注第二个案例。User类有三个必填字段(nicknamepasswordcreated)和三个可选字段(emailfirstnamelastname)。

现在,依赖于构建器模式的不可变的User类将显示如下:

public final class User {

  private final String nickname;
  private final String password;
  private final String firstname;
  private final String lastname;
  private final String email;
  private final Date created;

  private User(UserBuilder builder) {
    this.nickname = builder.nickname;
    this.password = builder.password;
    this.created = builder.created;
    this.firstname = builder.firstname;
    this.lastname = builder.lastname;
    this.email = builder.email;
  }

  public static UserBuilder getBuilder(
      String nickname, String password) {
    return new User.UserBuilder(nickname, password);
  }

  public static final class UserBuilder {

    private final String nickname;
    private final String password;
    private final Date created;
    private String email;
    private String firstname;
    private String lastname;

    public UserBuilder(String nickname, String password) {
      this.nickname = nickname;
      this.password = password;
      this.created = new Date();
    }

    public UserBuilder firstName(String firstname) {
      this.firstname = firstname;
      return this;
    }

    public UserBuilder lastName(String lastname) {
      this.lastname = lastname;
      return this;
    }

    public UserBuilder email(String email) {
      this.email = email;
      return this;
    }

    public User build() {
      return new User(this);
    }
  }

  public String getNickname() {
    return nickname;
  }

  public String getPassword() {
    return password;
  }

  public String getFirstname() {
    return firstname;
  }

  public String getLastname() {
    return lastname;
  }

  public String getEmail() {
    return email;
  }

  public Date getCreated() {
    return new Date(created.getTime());
  }
}

以下是一些用法示例:

import static modern.challenge.User.getBuilder;
...
// user with nickname and password
User user1 = getBuilder("marin21", "hjju9887h").build();

// user with nickname, password and email
User user2 = getBuilder("ionk", "44fef22")
  .email("ion@gmail.com")
  .build();

// user with nickname, password, email, firstname and lastname
User user3 = getBuilder("monika", "klooi0988")
  .email("monika@gmail.com")
  .firstName("Monika")
  .lastName("Ghuenter")
  .build();

52 避免不可变对象中的坏数据

坏数据是任何对不可变对象有负面影响的数据(例如,损坏的数据)。最有可能的是,这些数据来自用户输入或不受我们直接控制的外部数据源。在这种情况下,坏数据可能会击中不可变的对象,最糟糕的是没有修复它的方法。不可变的对象在创建后不能更改;因此,只要对象存在,坏数据就会快乐地存在。

这个问题的解决方案是根据一组全面的约束来验证输入到不可变对象中的所有数据。

执行验证有不同的方法,从自定义验证到内置解决方案。验证可以在不可变对象类的外部或内部执行,具体取决于应用设计。例如,如果不可变对象是通过构建器模式构建的,那么可以在 Builder 类中执行验证。

JSR380 是用于 bean 验证的 Java API(JavaSE/EE)规范,可用于通过注解进行验证。Hibernate 验证器是验证 API 的参考实现,它可以很容易地作为 Maven 依赖项在pom.xml文件中提供(请查看本书附带的源代码)。

此外,我们依赖于专用注解来提供所需的约束(例如,@NotNull@Min@Max@Size@Email)。在以下示例中,将约束添加到生成器类中,如下所示:

...
public static final class UserBuilder {

  @NotNull(message = "cannot be null")
  @Size(min = 3, max = 20, message = "must be between 3 and 20 
    characters")
  private final String nickname;

  @NotNull(message = "cannot be null")
  @Size(min = 6, max = 50, message = "must be between 6 and 50 
    characters")
  private final String password;

  @Size(min = 3, max = 20, message = "must be between 3 and 20 
    characters")
  private String firstname;

  @Size(min = 3, max = 20, message = "must be between 3 and 20 
    characters")
  private String lastname;

  @Email(message = "must be valid")
  private String email;

  private final Date created;

  public UserBuilder(String nickname, String password) {
    this.nickname = nickname;
    this.password = password;
    this.created = new Date();
  }
...

最后,验证过程通过ValidatorAPI 从代码中触发(这仅在 JavaSE 中需要)。如果进入生成器类的数据无效,则不创建不可变对象(不要调用build()方法):

User user;
Validator validator 
  = Validation.buildDefaultValidatorFactory().getValidator();

User.UserBuilder userBuilder 
  = new User.UserBuilder("monika", "klooi0988")
    .email("monika@gmail.com")
    .firstName("Monika").lastName("Gunther");

final Set<ConstraintViolation<User.UserBuilder>> violations 
  = validator.validate(userBuilder);
if (violations.isEmpty()) {
  user = userBuilder.build();
  System.out.println("User successfully created on: " 
    + user.getCreated());
} else {
  printConstraintViolations("UserBuilder Violations: ", violations);
}

这样,坏数据就不能触及不可变的对象。如果没有生成器类,则可以直接在不可变对象的字段级别添加约束。前面的解决方案只是在控制台上显示潜在的冲突,但是根据情况,该解决方案可能执行不同的操作(例如,抛出特定的异常)。

53 克隆对象

克隆对象不是一项日常任务,但正确地克隆对象很重要。克隆对象主要是指创建对象的副本。拷贝主要有两种类型:拷贝(尽可能少拷贝)和拷贝(复制所有内容)。

假设下面的类:

public class Point {

  private double x;
  private double y;

  public Point() {}
  public Point(double x, double y) {
    this.x = x;
    this.y = y;
  }

  // getters and setters
}

所以,我们在一个类中映射了一个类型点(x, y)。现在,让我们进行一些克隆。

手动克隆

快速方法包括添加一个手动将当前Point复制到新Point的方法(这是一个浅复制):

public Point clonePoint() {
  Point point = new Point();
  point.setX(this.x);
  point.setY(this.y);

  return point;
}

这里的代码非常简单。只需创建一个新的Point实例,并用当前Point的字段填充其字段。返回的Point是当前Point的浅拷贝(因为Point不依赖其他对象,所以深拷贝是完全相同的):

Point point = new Point(...);
Point clone = point.clonePoint();

通过clone()克隆

Object类包含一个名为clone()的方法。此方法对于创建浅拷贝非常有用(也可以用于深拷贝)。为了使用它,类应该遵循给定的步骤:

  • 实现Cloneable接口(如果该接口没有实现,则抛出CloneNotSupportedException
  • 覆盖clone()方法(Object.clone()protected)。
  • 调用super.clone()

Cloneable接口不包含任何方法。这只是 JVM 可以克隆这个对象的一个信号。一旦实现了这个接口,代码就需要覆盖Object.clone()方法。这是需要的,因为Object.clone()protected,为了通过super调用它,代码需要覆盖这个方法。如果将clone()添加到子类中,这可能是一个严重的缺点,因为所有超类都应该定义一个clone()方法,以避免super.clone()链调用失败。

此外,Object.clone()不依赖构造器调用,因此开发人员无法控制对象构造:

public class Point implements Cloneable {

  private double x;
  private double y;

  public Point() {}

  public Point(double x, double y) {
    this.x = x;
    this.y = y;
  }

  @Override
  public Point clone() throws CloneNotSupportedException {
    return (Point) super.clone();
  }

  // getters and setters
}

创建克隆的步骤如下:

Point point = new Point(...);
Point clone = point.clone();

通过构造器克隆

此克隆技术要求您使用构造器来丰富类,该构造器接受表示将用于创建克隆的类实例的单个参数。

让我们看看代码:

public class Point {

  private double x;
  private double y;

  public Point() {}

  public Point(double x, double y) {
    this.x = x;
    this.y = y;
  }

  public Point(Point another) {
    this.x = another.x;
    this.y = another.y;
  }

  // getters and setters
}

创建克隆的步骤如下:

Point point = new Point(...);
Point clone = new Point(point);

通过克隆库进行克隆

当一个对象依赖于另一个对象时,需要一个深度副本。执行深度复制意味着复制对象,包括其依赖链。例如,假设Point有一个Radius类型的字段:

public class Radius {

  private int start;
  private int end;

  // getters and setters
}

public class Point {

  private double x;
  private double y;
  private Radius radius;

  public Point(double x, double y, Radius radius) {
    this.x = x;
    this.y = y;
    this.radius = radius;
  }

  // getters and setters
}

执行Point的浅拷贝将创建xy的拷贝,但不会创建radius对象的拷贝。这意味着影响radius对象的修改也将反映在克隆中。是时候进行深度复制了。

一个麻烦的解决方案将涉及到调整以前提出的浅拷贝技术以支持深拷贝。幸运的是,有一些现成的解决方案可以应用,其中之一就是克隆库

import com.rits.cloning.Cloner;
...
Point point = new Point(...);
Cloner cloner = new Cloner();
Point clone = cloner.deepClone(point);

代码是不言自明的。请注意,克隆库还附带了其他一些好处,如下面的屏幕截图所示:

通过序列化克隆

这种技术需要可序列化的对象(实现java.io.Serializable。基本上,对象在新对象中被序列化(writeObject())和反序列化(readObject())。可以实现这一点的助手方法如下所示:

private static <T> T cloneThroughSerialization(T t) {

  try {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    ObjectOutputStream oos = new ObjectOutputStream(baos);
    oos.writeObject(t);

    ByteArrayInputStream bais 
      = new ByteArrayInputStream(baos.toByteArray());
    ObjectInputStream ois = new ObjectInputStream(bais);

    return (T) ois.readObject();
  } catch (IOException | ClassNotFoundException ex) {
    // log exception
    return t;
  }
}

因此,对象在ObjectOutputStream中序列化,在ObjectInputStream中反序列化。通过此方法克隆对象的步骤如下:

Point point = new Point(...);
Point clone = cloneThroughSerialization(point);

ApacheCommonsLang 通过SerializationUtils提供了一个基于序列化的内置解决方案。在它的方法中,这个类提供了一个名为clone()的方法,可以如下使用:

Point point = new Point(...);
Point clone = SerializationUtils.clone(point);

通过 JSON 克隆

几乎所有 Java 中的 JSON 库都可以序列化任何普通的旧 Java 对象POJO),而不需要任何额外的配置/映射。在项目中有一个 JSON 库(很多项目都有)可以避免我们添加额外的库来提供深度克隆。主要来说,该解决方案可以利用现有的 JSON 库来获得相同的效果。

以下是使用Gson库的示例:

private static <T> T cloneThroughJson(T t) {

  Gson gson = new Gson();
  String json = gson.toJson(t);

  return (T) gson.fromJson(json, t.getClass());
}

Point point = new Point(...);
Point clone = cloneThroughJson(point);

除此之外,您还可以选择编写专用于克隆对象的库。

54 覆盖toString()

toString()方法在java.lang.Object中定义,JDK 附带了它的默认实现。此默认实现自动用于print()println()printf()、开发期间调试、日志记录、异常中的信息消息等的所有对象。

不幸的是,默认实现返回的对象的字符串表示形式信息量不大。例如,让我们考虑下面的User类:

public class User {
  private final String nickname;
  private final String password;
  private final String firstname;
  private final String lastname;
  private final String email;
  private final Date created;

  // constructor and getters skipped for brevity
}

现在,让我们创建这个类的一个实例,并在控制台上打印它:

User user = new User("sparg21", "kkd454ffc",
  "Leopold", "Mark", "markl@yahoo.com");

System.out.println(user);

这个println()方法的输出如下:

在前面的屏幕截图中,避免输出的解决方案包括覆盖toString()方法。例如,让我们覆盖它以公开用户详细信息,如下所示:

@Override
public String toString() {
  return "User{" + "nickname=" + nickname + ", password=" + password
    + ", firstname=" + firstname + ", lastname=" + lastname
    + ", email=" + email + ", created=" + created + '}';
}

这次,println()将显示以下输出:

User {
  nickname = sparg21, password = kkd454ffc, 
  firstname = Leopold, lastname = Mark, 
  email = markl@yahoo.com, created = Fri Feb 22 10: 49: 32 EET 2019
}

这比以前的输出信息更丰富。

但是,请记住,toString()是为不同的目的自动调用的。例如,日志记录可以如下所示:

logger.log(Level.INFO, "This user rocks: {0}", user);

在这里,用户密码将命中日志,这可能表示有问题。在应用中公开日志敏感数据(如密码、帐户和秘密 IP)绝对是一种不好的做法。

因此,请特别注意仔细选择进入toString()的信息,因为这些信息最终可能会被恶意利用。在我们的例子中,密码不应该是toString()的一部分:

@Override
public String toString() {
  return "User{" + "nickname=" + nickname
    + ", firstname=" + firstname + ", lastname=" + lastname
    + ", email=" + email + ", created=" + created + '}';
}

通常,toString()是通过 IDE 生成的方法。因此,在 IDE 为您生成代码之前,请注意您选择了哪些字段。

55 switch表达式

在简要概述 JDK12 中引入的switch表达式之前,让我们先来看一个典型的老式方法示例:

private static Player createPlayer(PlayerTypes playerType) {

  switch (playerType) {

    case TENNIS:
      return new TennisPlayer();
    case FOOTBALL:
      return new FootballPlayer();
    case SNOOKER:      
      return new SnookerPlayer();
    case UNKNOWN:
      throw new UnknownPlayerException("Player type is unknown");
    default:
      throw new IllegalArgumentException(
        "Invalid player type: " + playerType);

  }
}

如果我们忘记了default,那么代码将无法编译。

显然,前面的例子是可以接受的。在最坏的情况下,我们可以添加一个伪变量(例如,player),一些杂乱的break语句,如果default丢失,就不会收到投诉。所以,下面的代码是一个老派,非常难看的switch

private static Player createPlayerSwitch(PlayerTypes playerType) {

  Player player = null;

  switch (playerType) {
    case TENNIS:
      player = new TennisPlayer();
      break;
    case FOOTBALL:
      player = new FootballPlayer();
      break;
    case SNOOKER:
      player = new SnookerPlayer();
      break;
    case UNKNOWN:
      throw new UnknownPlayerException(
        "Player type is unknown");
    default:
      throw new IllegalArgumentException(
        "Invalid player type: " + playerType);
  }

  return player;
}

如果我们忘记了default,那么编译器方面就不会有任何抱怨了。在这种情况下,丢失的default案例可能导致null播放器。

然而,自从 JDK12 以来,我们已经能够依赖于switch表达式。在 JDK12 之前,switch是一个语句,一个用来控制流的构造(例如,if语句),而不表示结果。另一方面,表达式的求值结果。因此,switch表达可产生结果。

前面的switch表达式可以用 JDK12 的样式写成如下:

private static Player createPlayer(PlayerTypes playerType) {

  return switch (playerType) {
    case TENNIS ->
      new TennisPlayer();
    case FOOTBALL ->
      new FootballPlayer();
    case SNOOKER ->
      new SnookerPlayer();
    case UNKNOWN ->
      throw new UnknownPlayerException(
        "Player type is unknown");
    // default is not mandatory
    default ->
      throw new IllegalArgumentException(
        "Invalid player type: " + playerType);
  };
}

这次,default不是强制性的。我们可以跳过它。

JDK12switch足够聪明,可以在switch没有覆盖所有可能的输入值时发出信号。这在 Java enum值的情况下非常有用。JDK12switch可以检测所有enum值是否都被覆盖,如果没有被覆盖,则不会强制一个无用的default。例如,如果我们删除default并向PlayerTypes enum添加一个新条目(例如GOLF),那么编译器将通过一条消息向它发送信号,如下面的屏幕截图(这是来自 NetBeans 的):

注意,在标签和执行之间,我们将冒号替换为箭头(Lambda 样式的语法)。此箭头的主要作用是防止跳转,这意味着只执行其右侧的代码块。不需要使用break

不要断定箭头将switch语句转换为switch表达式。switch表达可用于结肠和break,如下所示:

private static Player createPlayer(PlayerTypes playerType) {

  return switch (playerType) {
    case TENNIS:
      break new TennisPlayer();
    case FOOTBALL:
      break new FootballPlayer();
    case SNOOKER:
      break new SnookerPlayer();
    case UNKNOWN:
      throw new UnknownPlayerException(
        "Player type is unknown");
    // default is not mandatory
    default:
      throw new IllegalArgumentException(
        "Invalid player type: " + playerType);
  };
}

我们的示例在enum上发布了switch,但是 JDK12switch也可以在intIntegershortShortbyteBytecharCharacterString上使用。

注意,JDK12 带来了switch表达式作为预览特性。这意味着它很容易在接下来的几个版本中发生更改,需要在编译和运行时通过--enable-preview命令行选项来解锁它。

56 多个case标签

在 JDK12 之前,switch语句允许每个case有一个标签。从switch表达式开始,case可以有多个用逗号分隔的标签。请看下面举例说明多个case标签的方法:

private static SportType 
  fetchSportTypeByPlayerType(PlayerTypes playerType) {

  return switch (playerType) {
    case TENNIS, GOLF, SNOOKER ->
      new Individual();
    case FOOTBALL, VOLLEY ->  
      new Team();    
  };
}

因此,如果我们传递给这个方法TENNISGOLFSNOOKER,它将返回一个Individual类的实例。如果我们通过了FOOTBALLVOLLEY,它将返回一个Team类的实例。

57 case语句块

标签的箭头可以指向单个语句(如前两个问题中的示例)或大括号中的块。这与 Lambda 块非常相似。查看以下解决方案:

private static Player createPlayer(PlayerTypes playerType) {
  return switch (playerType) {
    case TENNIS -> {
      System.out.println("Creating a TennisPlayer ...");
      break new TennisPlayer();
    }
    case FOOTBALL -> {
      System.out.println("Creating a FootballPlayer ...");
      break new FootballPlayer();
    }
    case SNOOKER -> {
      System.out.println("Creating a SnookerPlayer ...");
      break new SnookerPlayer();
    }
    default ->
      throw new IllegalArgumentException(
        "Invalid player type: " + playerType);
  };
}

注意,我们通过break而不是return从花括号块中退出。换句话说,虽然我们可以从一个switch语句中return,但我们不能从一个表达式中return

总结

这就是所有的人!本章向您介绍了几个涉及对象、不变性和switch表达式的问题。虽然覆盖对象和不变性的问题代表了编程的基本概念,但覆盖switch表达式的问题致力于引入新的 JDK12 特性来解决这个问题。

从本章下载应用以查看结果和其他详细信息。****

三、使用日期和时间

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括 20 个涉及日期和时间的问题。这些问题通过DateCalendarLocalDateLocalTimeLocalDateTimeZoneDateTimeOffsetDateTimeOffsetTimeInstant等涵盖了广泛的主题(转换、格式化、加减、定义时段/持续时间、计算等)。到本章结束时,您将在确定日期和时间方面没有问题,同时符合您的应用的需要。本章介绍的基本问题将非常有助于了解日期-时间 API 的整体情况,并将像拼图中需要拼凑起来的部分一样解决涉及日期和时间的复杂挑战。

问题

使用以下问题来测试您的日期和时间编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 将字符串转换为日期和时间编写一个程序,演示字符串和日期/时间之间的转换。

  2. 格式化日期和时间:**解释日期和时间的格式模式。

  3. 获取当前日期/时间(不含日期/时间):编写程序,提取当前日期(不含时间或日期)。

  4. LocalDateLocalTimeLocalDateTime:编写一个程序,从LocalDate对象和LocalTime构建一个LocalDateTime。它将日期和时间组合在一个LocalDateTime对象中。

  5. 通过Instant类获取机器时间:解释并举例说明InstantAPI。

  6. 定义使用基于日期的值的时间段(Period)和使用基于时间的值的时间段(Duration):解释并举例说明PeriodDurationAPI 的用法。

  7. 获取日期和时间单位:编写一个程序,从表示日期时间的对象中提取日期和时间单位(例如,从日期中提取年、月、分钟等)。

  8. 对日期时间的加减:编写一个程序,对日期时间对象加减一定的时间(如年、日、分等)(如对日期加 1 小时,对LocalDateTime减 2 天等)。

  9. 获取 UTC 和 GMT 的所有时区:编写一个程序,显示 UTC 和 GMT 的所有可用时区。

  10. 获取所有可用时区的本地日期时间:编写一个程序,显示所有可用时区的本地时间。68. 显示航班日期时间信息:编写程序,显示 15 小时 30 分钟的航班时刻信息。更确切地说,是从澳大利亚珀斯飞往欧洲布加勒斯特的航班。

  11. 将 Unix 时间戳转换为日期时间:编写将 Unix 时间戳转换为java.util.Datejava.time.LocalDateTime的程序。

  12. 查找月份的第一天/最后一天:编写一个程序,通过 JDK8,TemporalAdjusters查找月份的第一天/最后一天。

  13. 定义/提取区域偏移:编写一个程序,展示定义和提取区域偏移的不同技术。

  14. DateTemporal之间的转换:编写DateInstantLocalDateLocalDateTime等之间的转换程序。

  15. 迭代一系列日期:编写一个程序,逐日(以一天的步长)迭代一系列给定日期。

  16. 计算年龄:编写一个计算一个人年龄的程序。

  17. 一天的开始和结束:编写一个程序,返回一天的开始和结束时间。

  18. 两个日期之间的差异:编写一个程序,计算两个日期之间的时间量(以天为单位)。

  19. 实现象棋时钟:编写实现象棋时钟的程序。

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释仅包括解决问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多详细信息,并在这个页面中试用程序。

58 将字符串转换为日期和时间

String转换或解析为日期和时间可以通过一组parse()方法来完成。从日期和时间到String的转换可以通过toString()format()方法完成。

JDK8 之前

在 JDK8 之前,这个问题的典型解决方案依赖于抽象的DateFormat类的主扩展,名为SimpleDateFormat(这不是线程安全类)。在本书附带的代码中,有几个示例说明了如何使用此类。

从 JDK8 开始

从 JDK8 开始,SimpleDateFormat可以替换为一个新类—DateTimeFormatter。这是一个不可变(因此是线程安全的)类,用于打印和解析日期时间对象。这个类支持从预定义的格式化程序(表示为常量,如 ISO 本地时间2011-12-03,是ISO_LOCAL_DATE)到用户定义的格式化程序(依赖于一组用于编写自定义格式模式的符号)。

此外,除了Date类之外,JDK8 还提供了几个新类,它们专门用于处理日期和时间。其中一些类显示在下面的列表中(这些类也被称为临时类,因为它们实现了Temporal接口):

  • LocalDate(ISO-8601 日历系统中没有时区的日期)
  • LocalTime(ISO-8601 日历系统中无时区的时间)
  • LocalDateTime(ISO-8601 日历系统中无时区的日期时间)
  • ZonedDateTime(ISO-8601 日历系统中带时区的日期时间),依此类推
  • OffsetDateTime(在 ISO-8601 日历系统中,有 UTC/GMT 偏移的日期时间)
  • OffsetTime(在 ISO-8601 日历系统中与 UTC/GMT 有偏移的时间)

为了通过预定义的格式化程序将String转换为LocalDate,它应该遵循DateTimeFormatter.ISO_LOCAL_DATE模式,例如2020-06-01LocalDate提供了一种parse()方法,可以如下使用:

// 06 is the month, 01 is the day
LocalDate localDate = LocalDate.parse("2020-06-01");

类似地,在LocalTime的情况下,字符串应该遵循DateTimeFormatter.ISO_LOCAL_TIME模式;例如,10:15:30,如下面的代码片段所示:

LocalTime localTime = LocalTime.parse("12:23:44");

LocalDateTime的情况下,字符串应该遵循DateTimeFormatter.ISO_LOCAL_DATE_TIME模式,例如2020-06-01T11:20:15,如下代码片段所示:

LocalDateTime localDateTime 
  = LocalDateTime.parse("2020-06-01T11:20:15");

ZonedDateTime的情况下,字符串必须遵循DateTimeFormatter.ISO_ZONED_DATE_TIME模式,例如2020-06-01T10:15:30+09:00[Asia/Tokyo],如下代码片段所示:

ZonedDateTime zonedDateTime 
  = ZonedDateTime.parse("2020-06-01T10:15:30+09:00[Asia/Tokyo]");

OffsetDateTime的情况下,字符串必须遵循DateTimeFormatter.ISO_OFFSET_DATE_TIME模式,例如2007-12-03T10:15:30+01:00,如下代码片段所示:

OffsetDateTime offsetDateTime 
  = OffsetDateTime.parse("2007-12-03T10:15:30+01:00");

最后,在OffsetTime的情况下,字符串必须遵循DateTimeFormatter.ISO_OFFSET_TIME模式,例如10:15:30+01:00,如下代码片段所示:

OffsetTime offsetTime = OffsetTime.parse("10:15:30+01:00");

如果字符串不符合任何预定义的格式化程序,则是时候通过自定义格式模式使用用户定义的格式化程序了;例如,字符串01.06.2020表示需要用户定义格式化程序的日期,如下所示:

DateTimeFormatter dateFormatter 
  = DateTimeFormatter.ofPattern("dd.MM.yyyy");
LocalDate localDateFormatted 
  = LocalDate.parse("01.06.2020", dateFormatter);

但是,像12|23|44这样的字符串需要如下用户定义的格式化程序:

DateTimeFormatter timeFormatter 
  = DateTimeFormatter.ofPattern("HH|mm|ss");
LocalTime localTimeFormatted 
  = LocalTime.parse("12|23|44", timeFormatter);

01.06.2020, 11:20:15这样的字符串需要一个用户定义的格式化程序,如下所示:

DateTimeFormatter dateTimeFormatter 
  = DateTimeFormatter.ofPattern("dd.MM.yyyy, HH:mm:ss");
LocalDateTime localDateTimeFormatted 
  = LocalDateTime.parse("01.06.2020, 11:20:15", dateTimeFormatter);

01.06.2020, 11:20:15+09:00 [Asia/Tokyo]这样的字符串需要一个用户定义的格式化程序,如下所示:

DateTimeFormatter zonedDateTimeFormatter 
  = DateTimeFormatter.ofPattern("dd.MM.yyyy, HH:mm:ssXXXXX '['VV']'");
ZonedDateTime zonedDateTimeFormatted 
  = ZonedDateTime.parse("01.06.2020, 11:20:15+09:00 [Asia/Tokyo]", 
    zonedDateTimeFormatter);

2007.12.03, 10:15:30, +01:00这样的字符串需要一个用户定义的格式化程序,如下所示:

DateTimeFormatter offsetDateTimeFormatter 
  = DateTimeFormatter.ofPattern("yyyy.MM.dd, HH:mm:ss, XXXXX");
OffsetDateTime offsetDateTimeFormatted 
  = OffsetDateTime.parse("2007.12.03, 10:15:30, +01:00", 
    offsetDateTimeFormatter);

最后,像10 15 30 +01:00这样的字符串需要一个用户定义的格式化程序,如下所示:

DateTimeFormatter offsetTimeFormatter 
  = DateTimeFormatter.ofPattern("HH mm ss XXXXX");
OffsetTime offsetTimeFormatted 
  = OffsetTime.parse("10 15 30 +01:00", offsetTimeFormatter);

前面示例中的每个ofPattern()方法也支持Locale

LocalDateLocalDateTimeZonedDateTimeString的转换至少可以通过两种方式完成:

  • 依赖于LocalDateLocalDateTimeZonedDateTime.toString()方法(自动或显式)。请注意,依赖于toString()将始终通过相应的预定义格式化程序打印日期:
// 2020-06-01 results in ISO_LOCAL_DATE, 2020-06-01
String localDateAsString = localDate.toString();

// 01.06.2020 results in ISO_LOCAL_DATE, 2020-06-01
String localDateAsString = localDateFormatted.toString();

// 2020-06-01T11:20:15 results 
// in ISO_LOCAL_DATE_TIME, 2020-06-01T11:20:15
String localDateTimeAsString = localDateTime.toString();

// 01.06.2020, 11:20:15 results in 
// ISO_LOCAL_DATE_TIME, 2020-06-01T11:20:15
String localDateTimeAsString 
  = localDateTimeFormatted.toString();

// 2020-06-01T10:15:30+09:00[Asia/Tokyo] 
// results in ISO_ZONED_DATE_TIME,
// 2020-06-01T11:20:15+09:00[Asia/Tokyo]
String zonedDateTimeAsString = zonedDateTime.toString();

// 01.06.2020, 11:20:15+09:00 [Asia/Tokyo] 
// results in ISO_ZONED_DATE_TIME,
// 2020-06-01T11:20:15+09:00[Asia/Tokyo]
String zonedDateTimeAsString 
  = zonedDateTimeFormatted.toString();
  • 依靠DateTimeFormatter.format()方法。请注意,依赖于DateTimeFormatter.format()将始终使用指定的格式化程序打印日期/时间(默认情况下,时区将为null),如下所示:
// 01.06.2020
String localDateAsFormattedString 
  = dateFormatter.format(localDateFormatted);

// 01.06.2020, 11:20:15
String localDateTimeAsFormattedString 
  = dateTimeFormatter.format(localDateTimeFormatted);

// 01.06.2020, 11:20:15+09:00 [Asia/Tokyo]
String zonedDateTimeAsFormattedString 
  = zonedDateTimeFormatted.format(zonedDateTimeFormatter);

在讨论中添加一个明确的时区可以如下所示:

DateTimeFormatter zonedDateTimeFormatter 
  = DateTimeFormatter.ofPattern("dd.MM.yyyy, HH:mm:ssXXXXX '['VV']'")
    .withZone(ZoneId.of("Europe/Paris"));
ZonedDateTime zonedDateTimeFormatted 
  = ZonedDateTime.parse("01.06.2020, 11:20:15+09:00 [Asia/Tokyo]", 
    zonedDateTimeFormatter);

这次,字符串表示欧洲/巴黎时区中的日期/时间:

// 01.06.2020, 04:20:15+02:00 [Europe/Paris]
String zonedDateTimeAsFormattedString 
  = zonedDateTimeFormatted.format(zonedDateTimeFormatter);

59 格式化日期和时间

前面的问题包含一些通过SimpleDateFormat.format()DateTimeFormatter.format()格式化日期和时间的风格。为了定义格式模式,开发人员必须了解格式模式语法。换句话说,开发人员必须知道 Java 日期时间 API 使用的一组符号,以便识别有效的格式模式。

大多数符号与SimpleDateFormat(JDK8 之前)和DateTimeFormatter(从 JDK8 开始)通用。下表列出了 JDK 文档中提供的最常见符号的完整列表:

字母 含义 演示 示例
y 1994; 94
M 数字/文本 7; 07; Jul; July; J
W 每月的一周 数字 4
E 星期几 文本 Tue; Tuesday; T
d 日期 数字 15
H 小时 数字 22
m 分钟 数字 34
s 数字 55
S 秒的分数 数字 345
z 时区名称 时区名称 Pacific Standard Time; PST
Z 时区偏移 时区偏移 -0800
V 时区 ID(JDK8) 时区 ID America/Los_Angeles; Z; -08:30

下表提供了一些格式模式示例:

模式 示例
yyyy-MM-dd 2019-02-24
MM-dd-yyyy 02-24-2019
MMM-dd-yyyy Feb-24-2019
dd-MM-yy 24-02-19
dd.MM.yyyy 24.02.2019
yyyy-MM-dd HH:mm:ss 2019-02-24 11:26:26
yyyy-MM-dd HH:mm:ssSSS 2019-02-24 11:36:32743
yyyy-MM-dd HH:mm:ssZ 2019-02-24 11:40:35+0200
yyyy-MM-dd HH:mm:ss z 2019-02-24 11:45:03 EET
E MMM yyyy HH:mm:ss.SSSZ Sun Feb 2019 11:46:32.393+0200
yyyy-MM-dd HH:MM:ss VV(JDK8) 2019-02-24 11:45:41 Europe/Athens

在 JDK8 之前,可以通过SimpleDateFormat应用格式模式:

// yyyy-MM-dd
Date date = new Date();
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");
String stringDate = formatter.format(date);

从 JDK8 开始,可以通过DateTimeFormatter应用格式模式:

  • 对于LocalDate(ISO-8601 日历系统中没有时区的日期):
// yyyy-MM-dd
LocalDate localDate = LocalDate.now();
DateTimeFormatter formatterLocalDate 
  = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String stringLD = formatterLocalDate.format(localDate);

// or shortly
String stringLD = LocalDate.now()
  .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
  • 对于LocalTime(ISO-8601 日历系统中没有时区的时间):
// HH:mm:ss
LocalTime localTime = LocalTime.now();
DateTimeFormatter formatterLocalTime 
  = DateTimeFormatter.ofPattern("HH:mm:ss");
String stringLT 
  = formatterLocalTime.format(localTime);

// or shortly
String stringLT = LocalTime.now()
  .format(DateTimeFormatter.ofPattern("HH:mm:ss"));
  • 对于LocalDateTime(ISO-8601 日历系统中没有时区的日期时间):
// yyyy-MM-dd HH:mm:ss
LocalDateTime localDateTime = LocalDateTime.now();
DateTimeFormatter formatterLocalDateTime 
  = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String stringLDT 
  = formatterLocalDateTime.format(localDateTime);

// or shortly
String stringLDT = LocalDateTime.now()
  .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
  • 对于ZonedDateTime(ISO-8601 日历系统中带时区的日期时间):
// E MMM yyyy HH:mm:ss.SSSZ
ZonedDateTime zonedDateTime = ZonedDateTime.now();
DateTimeFormatter formatterZonedDateTime 
  = DateTimeFormatter.ofPattern("E MMM yyyy HH:mm:ss.SSSZ");
String stringZDT 
  = formatterZonedDateTime.format(zonedDateTime);

// or shortly
String stringZDT = ZonedDateTime.now()
  .format(DateTimeFormatter
    .ofPattern("E MMM yyyy HH:mm:ss.SSSZ"));
  • 对于OffsetDateTime(在 ISO-8601 日历系统中,与 UTC/GMT 有偏移的日期时间):
// E MMM yyyy HH:mm:ss.SSSZ
OffsetDateTime offsetDateTime = OffsetDateTime.now();
DateTimeFormatter formatterOffsetDateTime 
  = DateTimeFormatter.ofPattern("E MMM yyyy HH:mm:ss.SSSZ");
String odt1 = formatterOffsetDateTime.format(offsetDateTime);

// or shortly
String odt2 = OffsetDateTime.now()
  .format(DateTimeFormatter
    .ofPattern("E MMM yyyy HH:mm:ss.SSSZ"));
  • 对于OffsetTime(在 ISO-8601 日历系统中与 UTC/GMT 有偏移的时间):
// HH:mm:ss,Z
OffsetTime offsetTime = OffsetTime.now();
DateTimeFormatter formatterOffsetTime 
  = DateTimeFormatter.ofPattern("HH:mm:ss,Z");
String ot1 = formatterOffsetTime.format(offsetTime);

// or shortly
String ot2 = OffsetTime.now()
  .format(DateTimeFormatter.ofPattern("HH:mm:ss,Z"));

60 获取没有时间/日期的当前日期/时间

在 JDK8 之前,解决方案必须集中在java.util.Date类上。绑定到本书的代码包含此解决方案。

从 JDK8 开始,日期和时间可以通过专用类LocalDateLocalTimejava.time包中获得:

// 2019-02-24
LocalDate onlyDate = LocalDate.now();

// 12:53:28.812637300
LocalTime onlyTime = LocalTime.now();

61 LocalDateLocalTime中的LocalDateTime

LocalDateTime类公开了一系列of()方法,这些方法可用于获取LocalDateTime的不同类型的实例。例如,从年、月、日、时、分、秒或纳秒获得的LocalDateTime类如下所示:

LocalDateTime ldt = LocalDateTime.of​(2020, 4, 1, 12, 33, 21, 675);

因此,前面的代码将日期和时间组合为of()方法的参数。为了将日期和时间组合为对象,解决方案可以利用以下of()方法:

public static LocalDateTime of​(LocalDate date, LocalTime time)

这导致LocalDateLocalTime,如下所示:

LocalDate localDate = LocalDate.now(); // 2019-Feb-24
LocalTime localTime = LocalTime.now(); // 02:08:10 PM

它们可以组合在一个对象LocalDateTime中,如下所示:

LocalDateTime localDateTime = LocalDateTime.of(localDate, localTime);

格式化LocalDateTime显示日期和时间如下:

// 2019-Feb-24 02:08:10 PM
String localDateTimeAsString = localDateTime
  .format(DateTimeFormatter.ofPattern("yyyy-MMM-dd hh:mm:ss a"));

62 通过Instant类的机器时间

JDK8 附带了一个新类,名为java.time.Instant。主要地,Instant类表示时间线上的一个瞬时点,从 1970 年 1 月 1 日(纪元)的第一秒开始,在 UTC 时区,分辨率为纳秒。

Java8Instant类在概念上类似于java.util.Date。两者都代表 UTC 时间线上的一个时刻。当Instant的分辨率高达纳秒时,java.util.Date的分辨率为毫秒。

这个类对于生成机器时间的时间戳非常方便。为了获得这样的时间戳,只需调用如下的now()方法:

// 2019-02-24T15:05:21.781049600Z
Instant timestamp = Instant.now();

使用以下代码段可以获得类似的输出:

OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);

或者,使用以下代码段:

Clock clock = Clock.systemUTC();

调用Instant.toString()产生一个输出,该输出遵循 ISO-8601 标准来表示日期和时间。

将字符串转换为Instant

遵循 ISO-8601 标准表示日期和时间的字符串可以通过Instant.parse()方法轻松转换为Instant,如下例所示:

// 2019-02-24T14:31:33.197021300Z
Instant timestampFromString =
  Instant.parse("2019-02-24T14:31:33.197021300Z");

Instant添加/减去时间

对于添加时间,Instant有一套方法。例如,向当前时间戳添加 2 小时可以如下完成:

Instant twoHourLater = Instant.now().plus(2, ChronoUnit.HOURS);

在减去时间方面,例如 10 分钟,请使用以下代码段:

Instant tenMinutesEarlier = Instant.now()
  .minus(10, ChronoUnit.MINUTES);

plus()方法外,Instant还包含plusNanos()plusMillis()plusSeconds()。此外,除了minus()方法外,Instant还包含minusNanos()minusMillis()minusSeconds()

比较Instant对象

比较两个Instant对象可以通过Instant.isAfter()Instant.isBefore()方法来完成。例如,让我们看看以下两个Instant对象:

Instant timestamp1 = Instant.now();
Instant timestamp2 = timestamp1.plusSeconds(10);

检查timestamp1是否在timestamp2之后:

boolean isAfter = timestamp1.isAfter(timestamp2); // false

检查timestamp1是否在timestamp2之前:

boolean isBefore = timestamp1.isBefore(timestamp2); // true

两个Instant对象之间的时差可以通过Instant.until()方法计算:

// 10 seconds
long difference = timestamp1.until(timestamp2, ChronoUnit.SECONDS);

InstantLocalDateTimeZonedDateTimeOffsetDateTime之间转换

这些常见的转换可以在以下示例中完成:

  • InstantLocalDateTime之间转换-因为LocalDateTime不知道时区,所以使用零偏移 UTC+0:
// 2019-02-24T15:27:13.990103700
LocalDateTime ldt = LocalDateTime.ofInstant(
  Instant.now(), ZoneOffset.UTC);

// 2019-02-24T17:27:14.013105Z
Instant instantLDT = LocalDateTime.now().toInstant(ZoneOffset.UTC);
  • InstantZonedDateTime之间转换—将InstantUTC+0 转换为巴黎ZonedDateTimeUTC+1:
// 2019-02-24T16:34:36.138393100+01:00[Europe/Paris]
ZonedDateTime zdt = Instant.now().atZone(ZoneId.of("Europe/Paris"));

// 2019-02-24T16:34:36.150393800Z
Instant instantZDT = LocalDateTime.now()
  .atZone(ZoneId.of("Europe/Paris")).toInstant();
  • InstantOffsetDateTime之间转换-指定 2 小时的偏移量:
// 2019-02-24T17:34:36.151393900+02:00
OffsetDateTime odt = Instant.now().atOffset(ZoneOffset.of("+02:00"));

// 2019-02-24T15:34:36.153394Z
Instant instantODT = LocalDateTime.now()
  .atOffset(ZoneOffset.of("+02:00")).toInstant();

63 使用基于日期的值定义时段,使用基于时间的值定义持续时间

JDK8 附带了两个新类,分别命名为java.time.Periodjava.time.Duration。让我们在下一节中详细了解它们。

使用基于日期的值的时间段

Period类意味着使用基于日期的值(年、月、周和天)来表示时间量。这段时间可以用不同的方法获得。例如,120 天的周期可以如下获得:

Period fromDays = Period.ofDays(120); // P120D

ofDays()方法旁边,Period类还有ofMonths()ofWeeks()ofYears()

或者,通过of()方法可以得到 2000 年 11 个月 24 天的期限,如下所示:

Period periodFromUnits = Period.of(2000, 11, 24); // P2000Y11M24D

Period也可以从LocalDate中得到:

LocalDate localDate = LocalDate.now();
Period periodFromLocalDate = Period.of(localDate.getYear(),
  localDate.getMonthValue(), localDate.getDayOfMonth());

最后,可以从遵循 ISO-8601 周期格式PnYnMnDPnWString对象获得Period。例如,P2019Y2M25D字符串表示 2019 年、2 个月和 25 天:

Period periodFromString = Period.parse("P2019Y2M25D");

调用Period.toString()将返回时间段,同时也遵循 ISO-8601 时间段格式,PnYnMnDPnW(例如P120DP2000Y11M24D)。

但是,当Period被用来表示两个日期之间的一段时间(例如LocalDate时,Period的真实力量就显现出来了。2018 年 3 月 12 日至 2019 年 7 月 20 日期间可表示为:

LocalDate startLocalDate = LocalDate.of(2018, 3, 12);
LocalDate endLocalDate = LocalDate.of(2019, 7, 20);
Period periodBetween = Period.between(startLocalDate, endLocalDate);

年、月、日的时间量可以通过Period.getYears()Period.getMonths()Period.getDays()获得。例如,以下辅助方法使用这些方法将时间量输出为字符串:

public static String periodToYMD(Period period) {

  StringBuilder sb = new StringBuilder();

  sb.append(period.getYears())
   .append("y:")
   .append(period.getMonths())
   .append("m:")
   .append(period.getDays())
   .append("d");

 return sb.toString();
}

我们将此方法称为periodBetween(差值为 1 年 4 个月 8 天):

periodToYMD(periodBetween); // 1y:4m:8d

当确定某个日期是否早于另一个日期时,Period类也很有用。有一个标志方法,名为isNegative()。有一个A周期和一个B周期,如果BA之前,应用Period.between(A, B)的结果可以是负的,如果AB之前,应用isNegative()的结果可以是正的,如果BA之前,falseA之前,则isNegative()返回true B,如我们的例子所示(基本上,如果年、月或日为负数,此方法返回false):

// returns false, since 12 March 2018 is earlier than 20 July 2019
periodBetween.isNegative();

最后,Period可以通过加上或减去一段时间来修改。方法有plusYears()plusMonths()plusDays()minusYears()minusMonths()minusDays()等。例如,在periodBetween上加 1 年可以如下操作:

Period periodBetweenPlus1Year = periodBetween.plusYears(1L);

添加两个Period类可以通过Period.plus()方法完成,如下所示:

Period p1 = Period.ofDays(5);
Period p2 = Period.ofDays(20);
Period p1p2 = p1.plus(p2); // P25D

使用基于时间的值的持续时间

Duration类意味着使用基于时间的值(小时、分钟、秒或纳秒)来表示时间量。这种持续时间可以通过不同的方式获得。例如,可以如下获得 10 小时的持续时间:

Duration fromHours = Duration.ofHours(10); // PT10H

ofHours()方法旁边,Duration类还有ofDays()ofMillis()ofMinutes()ofSeconds()ofNanos()

或者,可以通过of()方法获得 3 分钟的持续时间,如下所示:

Duration fromMinutes = Duration.of(3, ChronoUnit.MINUTES); // PT3M

Duration也可以从LocalDateTime中得到:

LocalDateTime localDateTime 
  = LocalDateTime.of(2018, 3, 12, 4, 14, 20, 670);

// PT14M
Duration fromLocalDateTime 
  = Duration.ofMinutes(localDateTime.getMinute());

也可从LocalTime中获得:

LocalTime localTime = LocalTime.of(4, 14, 20, 670);

// PT0.00000067S
Duration fromLocalTime = Duration.ofNanos(localTime.getNano());

最后,可以从遵循 ISO-8601 持续时间格式PnDTnHnMn.nSString对象获得Duration,其中天被认为正好是 24 小时。例如,P2DT3H4M字符串有 2 天 3 小时 4 分钟:

Duration durationFromString = Duration.parse("P2DT3H4M");

调用Duration.toString()将返回符合 ISO-8601 持续时间格式的持续时间PnDTnHnMn.nS(例如,PT10HPT3MPT51H4M)。

但是,与Period的情况一样,当Duration用于表示两次之间的时间段(例如,Instant时,揭示了它的真实功率。从 2015 年 11 月 3 日 12:11:30 到 2016 年 12 月 6 日 15:17:10 之间的持续时间可以表示为两个Instant类之间的差异,如下所示:

Instant startInstant = Instant.parse("2015-11-03T12:11:30.00Z");
Instant endInstant = Instant.parse("2016-12-06T15:17:10.00Z");

// PT10059H5M40S
Duration durationBetweenInstant 
  = Duration.between(startInstant, endInstant);

以秒为单位,可通过Duration.getSeconds()方法获得该差值:

durationBetweenInstant.getSeconds(); // 36212740 seconds

或者,从 2018 年 3 月 12 日 04:14:20.000000670 到 2019 年 7 月 20 日 06:10:10.000000720 之间的持续时间可以表示为两个LocalDateTime对象之间的差异,如下所示:

LocalDateTime startLocalDateTime 
  = LocalDateTime.of(2018, 3, 12, 4, 14, 20, 670);
LocalDateTime endLocalDateTime 
  = LocalDateTime.of(2019, 7, 20, 6, 10, 10, 720);
// PT11881H55M50.00000005S, or 42774950 seconds
Duration durationBetweenLDT 
  = Duration.between(startLocalDateTime, endLocalDateTime);

最后,04:14:20.000000670 和 06:10:10.000000720 之间的持续时间可以表示为两个LocalTime对象之间的差异,如下所示:

LocalTime startLocalTime = LocalTime.of(4, 14, 20, 670);
LocalTime endLocalTime = LocalTime.of(6, 10, 10, 720);

// PT1H55M50.00000005S, or 6950 seconds
Duration durationBetweenLT 
  = Duration.between(startLocalTime, endLocalTime);

在前面的例子中,Duration通过Duration.getSeconds()方法以秒表示,这是Duration类中的秒数。然而,Duration类包含一组方法,这些方法专用于通过toDays()以天为单位、通过toHours()以小时为单位、通过toMinutes()以分钟为单位、通过toMillis()以毫秒为单位、通过toNanos()以纳秒为单位来表达Duration

从一个时间单位转换到另一个时间单位可能会产生残余。例如,从秒转换为分钟可能导致秒的剩余(例如,65 秒是 1 分钟,5 秒是剩余)。残差可以通过以下一组方法获得:天残差通过toDaysPart(),小时残差通过toHoursPart(),分钟残差通过toMinutesPart()等等。

假设差异应该显示为天:小时:分:秒:纳秒(例如,9d:2h:15m:20s:230n)。将toFoo()toFooPart()方法的力结合在一个辅助方法中将产生以下代码:

public static String durationToDHMSN(Duration duration) {

  StringBuilder sb = new StringBuilder();
  sb.append(duration.toDays())
    .append("d:")
    .append(duration.toHoursPart())
    .append("h:")
    .append(duration.toMinutesPart())
    .append("m:")
    .append(duration.toSecondsPart())
    .append("s:")
    .append(duration.toNanosPart())
    .append("n");

  return sb.toString();
}

让我们调用这个方法durationBetweenLDT(差别是 495 天 1 小时 55 分 50 秒 50 纳秒):

// 495d:1h:55m:50s:50n
durationToDHMSN(durationBetweenLDT);

Period类相同,Duration类有一个名为isNegative()的标志方法。当确定某个特定时间是否早于另一个时间时,此方法很有用。有持续时间A和持续时间B,如果BA之前,应用Duration.between(A, B)的结果可以是负的,如果AB之前,应用Duration.between(A, B)的结果可以是正的,进一步逻辑,isNegative()如果BA之前,则返回true,如果AB之前,则返回false,如以下情况:

durationBetweenLT.isNegative(); // false

最后,Duration可以通过增加或减少持续时间来修改。有plusDays()plusHours()plusMinutes()plusMillis()plusNanos()minusDays()minusHours()minusMinutes()minusMillis()minusNanos()等方法来执行此操作。例如,向durationBetweenLT添加 5 小时可以如下所示:

Duration durationBetweenPlus5Hours = durationBetweenLT.plusHours(5);

添加两个Duration类可以通过Duration.plus()方法完成,如下所示:

Duration d1 = Duration.ofMinutes(20);
Duration d2 = Duration.ofHours(2);

Duration d1d2 = d1.plus(d2);

System.out.println(d1 + "+" + d2 + "=" + d1d2); // PT2H20M

64 获取日期和时间单位

对于Date对象,解决方案可能依赖于Calendar实例。绑定到本书的代码包含此解决方案。

对于 JDK8 类,Java 提供了专用的getFoo()方法和get​(TemporalField field)方法。例如,假设下面的LocalDateTime对象:

LocalDateTime ldt = LocalDateTime.now();

依靠getFoo()方法,我们得到如下代码:

int year = ldt.getYear();
int month = ldt.getMonthValue();
int day = ldt.getDayOfMonth();
int hour = ldt.getHour();
int minute = ldt.getMinute();
int second = ldt.getSecond();
int nano = ldt.getNano();

或者,依赖于get​(TemporalField field)结果如下:

int yearLDT = ldt.get(ChronoField.YEAR);
int monthLDT = ldt.get(ChronoField.MONTH_OF_YEAR);
int dayLDT = ldt.get(ChronoField.DAY_OF_MONTH);
int hourLDT = ldt.get(ChronoField.HOUR_OF_DAY);
int minuteLDT = ldt.get(ChronoField.MINUTE_OF_HOUR);
int secondLDT = ldt.get(ChronoField.SECOND_OF_MINUTE);
int nanoLDT = ldt.get(ChronoField.NANO_OF_SECOND);

请注意,月份是从 1 开始计算的,即 1 月。

例如,2019-02-25T12:58:13.109389100LocalDateTime对象可以被切割成日期时间单位,结果如下:

Year: 2019 Month: 2 Day: 25 Hour: 12 Minute: 58 Second: 13 Nano: 109389100

通过一点直觉和文档,很容易将此示例改编为LocalDateLocalTimeZonedDateTime和其他示例。

65 日期时间的加减

这个问题的解决方案依赖于专用于处理日期和时间的 Java API。让我们在下一节中看看它们。

使用Date

对于Date对象,解决方案可能依赖于Calendar实例。绑定到本书的代码包含此解决方案。

使用LocalDateTime

跳转到 JDK8,重点是LocalDateLocalTimeLocalDateTimeInstant等等。新的 Java 日期时间 API 提供了专门用于加减时间量的方法。LocalDateLocalTimeLocalDateTimeZonedDateTimeOffsetDateTimeInstantPeriodDuration以及许多其他方法,如plusFoo()minusFoo(),其中Foo可以用单位替换时间(例如,plusYears()plusMinutes()minusHours()minusSeconds()等等)。

假设如下LocalDateTime

// 2019-02-25T14:55:06.651155500
LocalDateTime ldt = LocalDateTime.now();

加 10 分钟和调用LocalDateTime.plusMinutes(long minutes)一样简单,减 10 分钟和调用LocalDateTime.minusMinutes(long minutes)一样简单:

LocalDateTime ldtAfterAddingMinutes = ldt.plusMinutes(10);
LocalDateTime ldtAfterSubtractingMinutes = ldt.minusMinutes(10);

输出将显示以下日期:

After adding 10 minutes: 2019-02-25T15:05:06.651155500
After subtracting 10 minutes: 2019-02-25T14:45:06.651155500

除了每个时间单位专用的方法外,这些类还支持plus/minus(TemporalAmount amountToAdd)plus/minus(long amountToAdd, TemporalUnit unit)

现在,让我们关注Instant类。除了plus/minusSeconds()plus/minusMillis()plus/minusNanos()之外,Instant类还提供了plus/minus(TemporalAmount amountToAdd)方法。

为了举例说明这个方法,我们假设如下Instant

// 2019-02-25T12:55:06.654155700Z
Instant timestamp = Instant.now();

现在,让我们加减 5 个小时:

Instant timestampAfterAddingHours 
  = timestamp.plus(5, ChronoUnit.HOURS);
Instant timestampAfterSubtractingHours 
  = timestamp.minus(5, ChronoUnit.HOURS);

输出将显示以下Instant

After adding 5 hours: 2019-02-25T17:55:06.654155700Z
After subtracting 5 hours: 2019-02-25T07:55:06.654155700Z

66 使用 UTC 和 GMT 获取所有时区

UTC 和 GMT 被认为是处理日期和时间的标准参考。今天,UTC 是首选的方法,但是 UTC 和 GMT 在大多数情况下应该返回相同的结果。

为了获得 UTC 和 GMT 的所有时区,解决方案应该关注 JDK8 前后的实现。所以,让我们从 JDK8 之前有用的解决方案开始。

JDK8 之前

解决方案需要提取可用的时区 ID(非洲/巴马科、欧洲/贝尔格莱德等)。此外,每个时区 ID 都应该用来创建一个TimeZone对象。最后,解决方案需要提取特定于每个时区的偏移量,并考虑到夏令时。绑定到本书的代码包含此解决方案。

从 JDK8 开始

新的 Java 日期时间 API 为解决这个问题提供了新的工具。

在第一步,可用的时区 id 可以通过ZoneId类获得,如下所示:

Set<String> zoneIds = ZoneId.getAvailableZoneIds();

在第二步,每个时区 ID 都应该用来创建一个ZoneId实例。这可以通过ZoneId.of(String zoneId)方法实现:

ZoneId zoneid = ZoneId.of(current_zone_Id);

在第三步,每个ZoneId可用于获得特定于所识别区域的时间。这意味着需要一个“实验室老鼠”参考日期时间。此参考日期时间(无时区,LocalDateTime.now())通过LocalDateTime.atZone()与给定时区(ZoneId)组合,以获得ZoneDateTime(可识别时区的日期时间):

LocalDateTime now = LocalDateTime.now();
ZonedDateTime zdt = now.atZone(ZoneId.of(zone_id_instance));

atZone()方法尽可能地匹配日期时间,同时考虑时区规则,例如夏令时。

在第四步,代码可以利用ZonedDateTime来提取 UTC 偏移量(例如,对于欧洲/布加勒斯特,UTC 偏移量为+02:00):

String utcOffset = zdt.getOffset().getId().replace("Z", "+00:00");

getId()方法返回规范化区域偏移 ID,+00:00偏移作为Z字符返回;因此代码需要快速将Z替换为+00:00,以便与其他偏移对齐,这些偏移遵循+hh:mm+hh:mm:ss格式。

现在,让我们将这些步骤合并到一个辅助方法中:

public static List<String> fetchTimeZones(OffsetType type) {

  List<String> timezones = new ArrayList<>();
  Set<String> zoneIds = ZoneId.getAvailableZoneIds();
  LocalDateTime now = LocalDateTime.now();

  zoneIds.forEach((zoneId) -> {
    timezones.add("(" + type + now.atZone(ZoneId.of(zoneId))
      .getOffset().getId().replace("Z", "+00:00") + ") " + zoneId);
  });

  return timezones;
}

假设此方法存在于DateTimes类中,则获得以下代码:

List<String> timezones 
  = DateTimes.fetchTimeZones(DateTimes.OffsetType.GMT);
Collections.sort(timezones); // optional sort
timezones.forEach(System.out::println);

此外,还显示了一个输出快照,如下所示:

(GMT+00:00) Africa/Abidjan
(GMT+00:00) Africa/Accra
(GMT+00:00) Africa/Bamako
...
(GMT+11:00) Australia/Tasmania
(GMT+11:00) Australia/Victoria
...

67 获取所有可用时区中的本地日期时间

可通过以下步骤获得此问题的解决方案:

  1. 获取本地日期和时间。
  2. 获取可用时区。
  3. 在 JDK8 之前,使用SimpleDateFormatsetTimeZone()方法。
  4. 从 JDK8 开始,使用ZonedDateTime

JDK8 之前

在 JDK8 之前,获取当前本地日期时间的快速解决方案是调用Date空构造器。此外,还可以使用Date在所有可用的时区中显示,这些时区可以通过TimeZone类获得。绑定到本书的代码包含此解决方案。

从 JDK8 开始

从 JDK8 开始,获取默认时区中当前本地日期时间的一个方便解决方案是调用ZonedDateTime.now()方法:

ZonedDateTime zlt = ZonedDateTime.now();

所以,这是默认时区中的当前日期。此外,该日期应显示在通过ZoneId类获得的所有可用时区中:

Set<String> zoneIds = ZoneId.getAvailableZoneIds();

最后,代码可以循环zoneIds,对于每个区域 ID,可以调用ZonedDateTime.withZoneSameInstant(ZoneId zone)方法。此方法返回具有不同时区的此日期时间的副本,并保留以下瞬间:

public static List<String> localTimeToAllTimeZones() {

  List<String> result = new ArrayList<>();
  Set<String> zoneIds = ZoneId.getAvailableZoneIds();
  DateTimeFormatter formatter 
    = DateTimeFormatter.ofPattern("yyyy-MMM-dd'T'HH:mm:ss a Z");
  ZonedDateTime zlt = ZonedDateTime.now();

  zoneIds.forEach((zoneId) -> {
    result.add(zlt.format(formatter) + " in " + zoneId + " is "
      + zlt.withZoneSameInstant(ZoneId.of(zoneId))
        .format(formatter));
  });

  return result;
}

此方法的输出快照可以如下所示:

2019-Feb-26T14:26:30 PM +0200 in Africa/Nairobi 
  is 2019-Feb-26T15:26:30 PM +0300
2019-Feb-26T14:26:30 PM +0200 in America/Marigot 
  is 2019-Feb-26T08:26:30 AM -0400
...
2019-Feb-26T14:26:30 PM +0200 in Pacific/Samoa 
  is 2019-Feb-26T01:26:30 AM -1100

68 显示航班的日期时间信息

本节提供的解决方案将显示有关从澳大利亚珀斯到欧洲布加勒斯特的 15 小时 30 分钟航班的以下信息:

  • UTC 出发和到达日期时间
  • 离开珀斯的日期时间和到达布加勒斯特的日期时间
  • 离开和到达布加勒斯特的日期时间

假设从珀斯出发的参考日期时间为 2019 年 2 月 26 日 16:00(或下午 4:00):

LocalDateTime ldt = LocalDateTime.of(
  2019, Month.FEBRUARY, 26, 16, 00);

首先,让我们将这个日期时间与澳大利亚/珀斯(+08:00)的时区结合起来。这将产生一个特定于澳大利亚/珀斯的ZonedDateTime对象(这是出发时珀斯的时钟日期和时间):

// 04:00 PM, Feb 26, 2019 +0800 Australia/Perth
ZonedDateTime auPerthDepart 
  = ldt.atZone(ZoneId.of("Australia/Perth"));

此外,让我们在ZonedDateTime中加上 15 小时 30 分钟。结果ZonedDateTime表示珀斯的日期时间(这是抵达布加勒斯特时珀斯的时钟日期和时间):

// 07:30 AM, Feb 27, 2019 +0800 Australia/Perth
ZonedDateTime auPerthArrive 
  = auPerthDepart.plusHours(15).plusMinutes(30);

现在,让我们计算一下布加勒斯特的日期时间和珀斯的出发日期时间。基本上,以下代码表示从布加勒斯特时区的珀斯时区出发的日期和时间:

// 10:00 AM, Feb 26, 2019 +0200 Europe/Bucharest
ZonedDateTime euBucharestDepart 
  = auPerthDepart.withZoneSameInstant(ZoneId.of("Europe/Bucharest"));

最后,让我们计算一下到达布加勒斯特的日期和时间。以下代码表示布加勒斯特时区珀斯时区的到达日期时间:

// 01:30 AM, Feb 27, 2019 +0200 Europe/Bucharest
ZonedDateTime euBucharestArrive 
  = auPerthArrive.withZoneSameInstant(ZoneId.of("Europe/Bucharest"));

如下图所示,从珀斯出发的 UTC 时间是上午 8:00,而到达布加勒斯特的 UTC 时间是晚上 11:30:

这些时间可以很容易地提取为OffsetDateTime,如下所示:

// 08:00 AM, Feb 26, 2019
OffsetDateTime utcAtDepart = auPerthDepart.withZoneSameInstant(
  ZoneId.of("UTC")).toOffsetDateTime();

// 11:30 PM, Feb 26, 2019
OffsetDateTime utcAtArrive = auPerthArrive.withZoneSameInstant(
  ZoneId.of("UTC")).toOffsetDateTime();

69 将 Unix 时间戳转换为日期时间

对于这个解决方案,假设下面的 Unix 时间戳是 1573768800。此时间戳等效于以下内容:

  • 11/14/2019 @ 10:00pm (UTC)
  • ISO-8601 中的2019-11-14T22:00:00+00:00
  • Thu, 14 Nov 2019 22:00:00 +0000,RFC 822、1036、1123、2822
  • Thursday, 14-Nov-19 22:00:00 UTC,RFC 2822
  • 2019-11-14T22:00:00+00:00在 RFC 3339 中

为了将 Unix 时间戳转换为日期时间,必须知道 Unix 时间戳的分辨率以秒为单位,而java.util.Date需要毫秒。因此,从 Unix 时间戳获取Date对象的解决方案需要将 Unix 时间戳乘以 1000,从秒转换为毫秒,如下两个示例所示:

long unixTimestamp = 1573768800;

// Fri Nov 15 00:00:00 EET 2019 - in the default time zone
Date date = new Date(unixTimestamp * 1000L);

// Fri Nov 15 00:00:00 EET 2019 - in the default time zone
Date date = new Date(TimeUnit.MILLISECONDS
  .convert(unixTimestamp, TimeUnit.SECONDS));

从 JDK8 开始,Date类使用from(Instant instant)方法。此外,Instant类附带了ofEpochSecond(long epochSecond)方法,该方法使用1970-01-01T00:00:00Z的纪元的给定秒数返回Instant的实例:

// 2019-11-14T22:00:00Z in UTC
Instant instant = Instant.ofEpochSecond(unixTimestamp);

// Fri Nov 15 00:00:00 EET 2019 - in the default time zone
Date date = Date.from(instant);

上一示例中获得的瞬间可用于创建LocalDateTimeZonedDateTime,如下所示:

// 2019-11-15T06:00
LocalDateTime date = LocalDateTime
  .ofInstant(instant, ZoneId.of("Australia/Perth"));

// 2019-Nov-15 00:00:00 +0200 Europe/Bucharest
ZonedDateTime date = ZonedDateTime
  .ofInstant(instant, ZoneId.of("Europe/Bucharest"));

70 查找每月的第一天/最后一天

这个问题的正确解决将依赖于 JDK8、TemporalTemporalAdjuster接口。

Temporal接口位于日期和时间的表示后面。换句话说,表示日期和/或时间的类实现了这个接口。例如,以下类只是实现此接口的几个类:

  • LocalDate(ISO-8601 日历系统中没有时区的日期)
  • LocalTime(ISO-8601 日历系统中无时区的时间)
  • LocalDateTime(ISO-8601 日历系统中无时区的日期时间)
  • ZonedDateTime(ISO-8601 日历系统中带时区的日期时间),依此类推
  • OffsetDateTime(在 ISO-8601 日历系统中,从 UTC/格林威治时间偏移的日期时间)
  • HijrahDate(希吉拉历法系统中的日期)

TemporalAdjuster类是一个函数式接口,它定义了可用于调整Temporal对象的策略。除了可以定义自定义策略外,TemporalAdjuster类还提供了几个预定义的策略,如下所示(文档包含了整个列表,非常令人印象深刻):

  • firstDayOfMonth()(返回当月第一天)
  • lastDayOfMonth()(返回当月最后一天)
  • firstDayOfNextMonth()(次月 1 日返回)
  • firstDayOfNextYear()(次年第一天返回)

注意,前面列表中的前两个调整器正是这个问题所需要的。

考虑一个修正-LocalDate

LocalDate date = LocalDate.of(2019, Month.FEBRUARY, 27);

让我们看看二月的第一天/最后一天是什么时候:

// 2019-02-01
LocalDate firstDayOfFeb 
  = date.with(TemporalAdjusters.firstDayOfMonth());

// 2019-02-28
LocalDate lastDayOfFeb 
  = date.with(TemporalAdjusters.lastDayOfMonth());

看起来依赖预定义的策略非常简单。但是,假设问题要求您查找 2019 年 2 月 27 日之后的 21 天,也就是 2019 年 3 月 20 日。对于这个问题,没有预定义的策略,因此需要自定义策略。此问题的解决方案可以依赖 Lambda 表达式,如以下辅助方法中所示:

public static LocalDate getDayAfterDays(
    LocalDate startDate, int days) {

  Period period = Period.ofDays(days);
  TemporalAdjuster ta = p -> p.plus(period);
  LocalDate endDate = startDate.with(ta);

  return endDate;
}

如果此方法存在于名为DateTimes的类中,则以下调用将返回预期结果:

// 2019-03-20
LocalDate datePlus21Days = DateTimes.getDayAfterDays(date, 21);

遵循相同的技术,但依赖于static工厂方法ofDateAdjuster(),下面的代码片段定义了一个静态调整器,返回下一个星期六的日期:

static TemporalAdjuster NEXT_SATURDAY 
    = TemporalAdjusters.ofDateAdjuster(today -> {

  DayOfWeek dayOfWeek = today.getDayOfWeek();

  if (dayOfWeek == DayOfWeek.SATURDAY) {
    return today;
  }

  if (dayOfWeek == DayOfWeek.SUNDAY) {
    return today.plusDays(6);
  }

  return today.plusDays(6 - dayOfWeek.getValue());
});

我们将此方法称为 2019 年 2 月 27 日(下一个星期六是 2019 年 3 月 2 日):

// 2019-03-02
LocalDate nextSaturday = date.with(NEXT_SATURDAY);

最后,这个函数式接口定义了一个名为adjustInto()abstract方法。在自定义实现中,可以通过向该方法传递一个Temporal对象来覆盖该方法,如下所示:

public class NextSaturdayAdjuster implements TemporalAdjuster {

  @Override
  public Temporal adjustInto(Temporal temporal) {

    DayOfWeek dayOfWeek = DayOfWeek
      .of(temporal.get(ChronoField.DAY_OF_WEEK));

    if (dayOfWeek == DayOfWeek.SATURDAY) {
      return temporal;
    }

    if (dayOfWeek == DayOfWeek.SUNDAY) {
      return temporal.plus(6, ChronoUnit.DAYS);
    }

    return temporal.plus(6 - dayOfWeek.getValue(), ChronoUnit.DAYS);
  }
}

下面是用法示例:

NextSaturdayAdjuster nsa = new NextSaturdayAdjuster();

// 2019-03-02
LocalDate nextSaturday = date.with(nsa);

71 定义/提取区域偏移

通过区域偏移,我们了解需要从 GMT/UTC 时间中添加/减去的时间量,以便获得全球特定区域(例如,澳大利亚珀斯)的日期时间。通常,区域偏移以固定的小时和分钟数打印:+02:00-08:30+0400UTC+01:00,依此类推。

因此,简而言之,时区偏移量是指时区与 GMT/UTC 之间的时间差。

JDK8 之前

在 JDK8 之前,可以通过java.util.TimeZone定义一个时区,有了这个时区,代码就可以通过TimeZone.getRawOffset()方法得到时区偏移量(原始部分来源于这个方法不考虑夏令时)。绑定到本书的代码包含此解决方案。

从 JDK8 开始

从 JDK8 开始,有两个类负责处理时区表示。首先是java.time.ZoneId,表示欧洲雅典等时区;其次是java.time.ZoneOffset(扩展ZoneId),表示指定时区的固定时间(偏移量),以 GMT/UTC 表示。

新的 Java 日期时间 API 默认处理夏令时;因此,使用夏令时的夏-冬周期区域将有两个ZoneOffset类。

UTC 区域偏移量可以很容易地获得,如下所示(这是+00:00,在 Java 中用Z字符表示):

// Z
ZoneOffset zoneOffsetUTC = ZoneOffset.UTC;

系统默认时区也可以通过ZoneOffset类获取:

// Europe/Athens
ZoneId defaultZoneId = ZoneOffset.systemDefault();

为了使用夏令时进行分区偏移,代码需要将日期时间与其关联。例如,关联一个LocalDateTime类(也可以使用Instant),如下所示:

// by default it deals with the Daylight Saving Times
LocalDateTime ldt = LocalDateTime.of(2019, 6, 15, 0, 0);
ZoneId zoneId = ZoneId.of("Europe/Bucharest");

// +03:00
ZoneOffset zoneOffset = zoneId.getRules().getOffset(ldt);

区域偏移量也可以从字符串中获得。例如,以下代码获得+02:00的分区偏移:

ZoneOffset zoneOffsetFromString = ZoneOffset.of("+02:00");

这是一种非常方便的方法,可以将区域偏移快速添加到支持区域偏移的Temporal对象。例如,使用它将区域偏移添加到OffsetTimeOffsetDateTime(用于在数据库中存储日期或通过电线发送的方便方法):

OffsetTime offsetTime = OffsetTime.now(zoneOffsetFromString);
OffsetDateTime offsetDateTime 
  = OffsetDateTime.now(zoneOffsetFromString);

我们问题的另一个解决方法是依赖于从小时、分钟和秒来定义ZoneOffsetZoneOffset的一个助手方法专门用于:

// +08:30 (this was obtained from 8 hours and 30 minutes)
ZoneOffset zoneOffsetFromHoursMinutes 
  = ZoneOffset.ofHoursMinutes(8, 30);

ZoneOffset.ofHoursMinutes()旁边有ZoneOffset.ofHours()ofHoursMinutesSeconds()ofTotalSeconds()

最后,每个支持区域偏移的Temporal对象都提供了一个方便的getOffset()方法。例如,下面的代码从前面的offsetDateTime对象获取区域偏移:

// +02:00
ZoneOffset zoneOffsetFromOdt = offsetDateTime.getOffset();

72 在日期和时间之间转换

这里给出的解决方案将涵盖以下Temporal类—InstantLocalDateLocalDateTimeZonedDateTimeOffsetDateTimeLocalTimeOffsetTime

Date-Instant

为了从Date转换到Instant,可采用Date.toInstant()方法求解。可通过Date.from(Instant instant)方法实现反转:

  • DateInstant可以这样完成:
Date date = new Date();

// e.g., 2019-02-27T12:02:49.369Z, UTC
Instant instantFromDate = date.toInstant();
  • InstantDate可以这样完成:
Instant instant = Instant.now();

// Wed Feb 27 14:02:49 EET 2019, default system time zone
Date dateFromInstant = Date.from(instant);

请记住,Date不是时区感知的,但它显示在系统默认时区中(例如,通过toString())。Instant是 UTC 时区。

让我们快速地将这些代码片段包装在两个工具方法中,它们在一个工具类DateConverters中定义:

public static Instant dateToInstant(Date date) {

  return date.toInstant();
}

public static Date instantToDate(Instant instant) {

  return Date.from(instant);
}

此外,让我们使用以下屏幕截图中的方法来丰富此类:

屏幕截图中的常量DEFAULT_TIME_ZONE是系统默认时区:

public static final ZoneId DEFAULT_TIME_ZONE = ZoneId.systemDefault();

DateLocalDate

Date对象可以通过Instant对象转换为LocalDate。一旦我们从给定的Date对象中获得Instant对象,解决方案就可以应用于它系统默认时区,并调用toLocaleDate()方法:

// e.g., 2019-03-01
public static LocalDate dateToLocalDate(Date date) {

  return dateToInstant(date).atZone(DEFAULT_TIME_ZONE).toLocalDate();
}

LocalDateDate的转换应该考虑到LocalDate不包含Date这样的时间成分,所以解决方案必须提供一个时间成分作为一天的开始(关于这个问题的更多细节可以在“一天的开始和结束”问题中找到):

// e.g., Fri Mar 01 00:00:00 EET 2019
public static Date localDateToDate(LocalDate localDate) {

  return Date.from(localDate.atStartOfDay(
    DEFAULT_TIME_ZONE).toInstant());
}

DateLocalDateTime

DateDateLocalTime的转换与从DateLocalDate的转换是一样的,只是溶液应该调用toLocalDateTime()方法如下:

// e.g., 2019-03-01T07:25:25.624
public static LocalDateTime dateToLocalDateTime(Date date) {

  return dateToInstant(date).atZone(
    DEFAULT_TIME_ZONE).toLocalDateTime();
}

LocalDateTimeDate的转换非常简单。只需应用系统默认时区并调用toInstant()

// e.g., Fri Mar 01 07:25:25 EET 2019
public static Date localDateTimeToDate(LocalDateTime localDateTime) {

  return Date.from(localDateTime.atZone(
    DEFAULT_TIME_ZONE).toInstant());
}

DateZonedDateTime

DateZonedDateTime的转换可以通过从给定Date对象获取Instant对象和系统默认时区来完成:

// e.g., 2019-03-01T07:25:25.624+02:00[Europe/Athens]
public static ZonedDateTime dateToZonedDateTime(Date date) {

  return dateToInstant(date).atZone(DEFAULT_TIME_ZONE);
}

ZonedDateTime转换为Date就是将ZonedDateTime转换为Instant

// e.g., Fri Mar 01 07:25:25 EET 2019
public static Date zonedDateTimeToDate(ZonedDateTime zonedDateTime) {

  return Date.from(zonedDateTime.toInstant());
}

DateOffsetDateTime

DateOffsetDateTime的转换依赖于toOffsetDateTime()方法:

// e.g., 2019-03-01T07:25:25.624+02:00
public static OffsetDateTime dateToOffsetDateTime(Date date) {

  return dateToInstant(date).atZone(
    DEFAULT_TIME_ZONE).toOffsetDateTime();
}

OffsetDateTimeDate的转换方法需要两个步骤。首先将OffsetDateTime转换为LocalDateTime;其次将LocalDateTime转换为Instant,对应偏移量:

// e.g., Fri Mar 01 07:55:49 EET 2019
public static Date offsetDateTimeToDate(
    OffsetDateTime offsetDateTime) {

  return Date.from(offsetDateTime.toLocalDateTime()
    .toInstant(ZoneOffset.of(offsetDateTime.getOffset().getId())));
}

DateLocalTime

Date转换为LocalTime可以依赖LocalTime.toInstant()方法,如下所示:

// e.g., 08:03:20.336
public static LocalTime dateToLocalTime(Date date) {

  return LocalTime.ofInstant(dateToInstant(date), DEFAULT_TIME_ZONE);
}

LocalTime转换为Date应该考虑到LocalTime没有日期组件。这意味着解决方案应将日期设置为 1970 年 1 月 1 日,即纪元:

// e.g., Thu Jan 01 08:03:20 EET 1970
public static Date localTimeToDate(LocalTime localTime) {

  return Date.from(localTime.atDate(LocalDate.EPOCH)
    .toInstant(DEFAULT_TIME_ZONE.getRules()
      .getOffset(Instant.now())));
}

Date-OffsetTime

Date转换为OffsetTime可以依赖OffsetTime.toInstant()方法,如下所示:

// e.g., 08:03:20.336+02:00
public static OffsetTime dateToOffsetTime(Date date) {

  return OffsetTime.ofInstant(dateToInstant(date), DEFAULT_TIME_ZONE);
}

OffsetTime转换为Date应该考虑到OffsetTime没有日期组件。这意味着解决方案应将日期设置为 1970 年 1 月 1 日,即纪元:

// e.g., Thu Jan 01 08:03:20 EET 1970
public static Date offsetTimeToDate(OffsetTime offsetTime) {

  return Date.from(offsetTime.atDate(LocalDate.EPOCH).toInstant());
}

73 迭代一系列日期

假设范围是由开始日期 2019 年 2 月 1 日和结束日期 2019 年 2 月 21 日界定的。这个问题的解决方案应该循环【2019 年 2 月 1 日,2019 年 2 月 21 日】间隔一天,并在屏幕上打印每个日期。基本上要解决两个主要问题:

  • 一旦开始日期和结束日期相等,就停止循环。
  • 每天增加开始日期直到结束日期。

JDK8 之前

在 JDK8 之前,解决方案可以依赖于Calendar工具类。绑定到本书的代码包含此解决方案。

从 JDK8 开始

首先,从 JDK8 开始,可以很容易地将日期定义为LocalDate,而不需要Calendar的帮助:

LocalDate startLocalDate = LocalDate.of(2019, 2, 1);
LocalDate endLocalDate = LocalDate.of(2019, 2, 21);

一旦开始日期和结束日期相等,我们就通过LocalDate.isBefore(ChronoLocalDate other)方法停止循环。此标志方法检查此日期是否早于给定日期。

使用LocalDate.plusDays(long daysToAdd)方法逐日增加开始日期直到结束日期。在for循环中使用这两种方法会产生以下代码:

for (LocalDate date = startLocalDate; 
       date.isBefore(endLocalDate); date = date.plusDays(1)) {

  // do something with this day
  System.out.println(date);
}

输出的快照应如下所示:

2019-02-01
2019-02-02
2019-02-03
...
2019-02-20

从 JDK9 开始

JDK9 可以用一行代码解决这个问题。由于新的LocalDate.datesUntil(LocalDate endExclusive)方法,这是可能的。此方法返回Stream<LocalDate>,增量步长为一天:

startLocalDate.datesUntil(endLocalDate).forEach(System.out::println);

如果增量步骤应以天、周、月或年表示,则依赖于LocalDate.datesUntil(LocalDate endExclusive, Period step)。例如,1 周的增量步骤可以指定如下:

startLocalDate.datesUntil(endLocalDate, Period.ofWeeks(1)).forEach(System.out::println);

输出应为(第 1-8 周,第 8-15 周),如下所示:

2019-02-01
2019-02-08
2019-02-15

74 计算年龄

可能最常用的两个日期之间的差异是关于计算一个人的年龄。通常,一个人的年龄以年表示,但有时应提供月,甚至天。

JDK8 之前

在 JDK8 之前,试图提供一个好的解决方案可以依赖于Calendar和/或SimpleDateFormat。绑定到本书的代码包含这样一个解决方案。

从 JDK8 开始

更好的方法是升级到 JDK8,并依赖以下简单的代码片段:

LocalDate startLocalDate = LocalDate.of(1977, 11, 2);
LocalDate endLocalDate = LocalDate.now();

long years = ChronoUnit.YEARS.between(startLocalDate, endLocalDate);

由于Period类的原因,将月和日添加到结果中也很容易实现:

Period periodBetween = Period.between(startLocalDate, endLocalDate);

现在,可以通过periodBetween.getYears()periodBetween.getMonths()periodBetween.getDays()获得以年、月、日为单位的年龄。

例如,在当前日期 2019 年 2 月 28 日和 1977 年 11 月 2 日之间,我们有 41 年 3 个月 26 天。

75 一天的开始和结束

在 JDK8 中,可以通过几种方法来找到一天的开始/结束。

让我们考虑一下通过LocalDate表达的一天:

LocalDate localDate = LocalDate.of(2019, 2, 28);

找到 2019 年 2 月 28 日一天的开始的解决方案依赖于一个名为atStartOfDay()的方法。此方法从该日期午夜 00:00 返回LocalDateTime

// 2019-02-28T00:00
LocalDateTime ldDayStart = localDate.atStartOfDay();

或者,该溶液可以使用of(LocalDate date, LocalTime time)方法。该方法将给定的日期和时间组合成LocalDateTime。因此,如果经过的时间是LocalTime.MIN(一天开始时的午夜时间),则结果如下:

// 2019-02-28T00:00
LocalDateTime ldDayStart = LocalDateTime.of(localDate, LocalTime.MIN);

一个LocalDate物体的一天结束时间至少可以用两种方法得到。一种解决方案是依靠LocalDate.atTime(LocalTime time)。得到的LocalDateTime可以表示该日期与一天结束时的组合,如果解决方案作为参数传递,LocalTime.MAX(一天结束时午夜前的时间):

// 2019-02-28T23:59:59.999999999
LocalDateTime ldDayEnd = localDate.atTime(LocalTime.MAX);

或者,该解决方案可以通过atDate(LocalDate date)方法将LocalTime.MAX与给定日期结合:

// 2019-02-28T23:59:59.999999999
LocalDateTime ldDayEnd = LocalTime.MAX.atDate(localDate);

由于LocalDate没有时区的概念,前面的例子容易出现由不同的角落情况引起的问题,例如夏令时。有些夏令时会在午夜(00:00 变为 01:00 AM)更改时间,这意味着一天的开始时间是 01:00:00,而不是 00:00:00。为了缓解这些问题,请考虑以下示例,这些示例将前面的示例扩展为使用夏令时感知的ZonedDateTime

// 2019-02-28T00:00+08:00[Australia/Perth]
ZonedDateTime ldDayStartZone 
  = localDate.atStartOfDay(ZoneId.of("Australia/Perth"));

// 2019-02-28T00:00+08:00[Australia/Perth]
ZonedDateTime ldDayStartZone = LocalDateTime
  .of(localDate, LocalTime.MIN).atZone(ZoneId.of("Australia/Perth"));

// 2019-02-28T23:59:59.999999999+08:00[Australia/Perth]
ZonedDateTime ldDayEndZone = localDate.atTime(LocalTime.MAX)
  .atZone(ZoneId.of("Australia/Perth"));

// 2019-02-28T23:59:59.999999999+08:00[Australia/Perth]
ZonedDateTime ldDayEndZone = LocalTime.MAX.atDate(localDate)
  .atZone(ZoneId.of("Australia/Perth"));

现在,我们来考虑一下-LocalDateTime,2019 年 2 月 28 日,18:00:00:

LocalDateTime localDateTime = LocalDateTime.of(2019, 2, 28, 18, 0, 0);

显而易见的解决方案是从LocalDateTime中提取LocalDate,并应用前面的方法。另一个解决方案依赖于这样一个事实,Temporal接口的每个实现(包括LocalDate)都可以利用with(TemporalField field, long newValue)方法。主要是,with()方法返回这个日期的一个副本,其中指定的字段ChronoField设置为newValue。因此,如果解决方案将ChronoField.NANO_OF_DAY(一天的纳秒)设置为LocalTime.MIN,那么结果将是一天的开始。这里的技巧是通过toNanoOfDay()LocalTime.MIN转换为纳秒,如下所示:

// 2019-02-28T00:00
LocalDateTime ldtDayStart = localDateTime
  .with(ChronoField.NANO_OF_DAY, LocalTime.MIN.toNanoOfDay());

这相当于:

LocalDateTime ldtDayStart 
   = localDateTime.with(ChronoField.HOUR_OF_DAY, 0);

一天的结束是非常相似的。只需通过LocalTime.MAX而不是MIN

// 2019-02-28T23:59:59.999999999
LocalDateTime ldtDayEnd = localDateTime
  .with(ChronoField.NANO_OF_DAY, LocalTime.MAX.toNanoOfDay());

这相当于:

LocalDateTime ldtDayEnd = localDateTime.with(
  ChronoField.NANO_OF_DAY, 86399999999999L);

LocalDate一样,LocalDateTime对象不知道时区。在这种情况下,ZonedDateTime可以帮助:

// 2019-02-28T00:00+08:00[Australia/Perth]
ZonedDateTime ldtDayStartZone = localDateTime
  .with(ChronoField.NANO_OF_DAY, LocalTime.MIN.toNanoOfDay())
  .atZone(ZoneId.of("Australia/Perth"));

// 2019-02-28T23:59:59.999999999+08:00[Australia/Perth]
ZonedDateTime ldtDayEndZone = localDateTime
  .with(ChronoField.NANO_OF_DAY, LocalTime.MAX.toNanoOfDay())
  .atZone(ZoneId.of("Australia/Perth"));

作为奖励,让我们看看 UTC 一天的开始/结束。除了依赖于with()方法的解决方案外,另一个解决方案可以依赖于toLocalDate(),如下所示:

// e.g., 2019-02-28T09:23:10.603572Z
ZonedDateTime zdt = ZonedDateTime.now(ZoneOffset.UTC);

// 2019-02-28T00:00Z
ZonedDateTime dayStartZdt 
  = zdt.toLocalDate().atStartOfDay(zdt.getZone());

// 2019-02-28T23:59:59.999999999Z
ZonedDateTime dayEndZdt = zdt.toLocalDate()
  .atTime(LocalTime.MAX).atZone(zdt.getZone());

由于java.util.DateCalendar存在许多问题,因此建议避免尝试用它们实现此问题的解决方案。

76 两个日期之间的差异

计算两个日期之间的差值是一项非常常见的任务(例如,请参阅“计算年龄”部分)。让我们看看其他方法的集合,这些方法可以用来获得以毫秒、秒、小时等为单位的两个日期之间的差异。

JDK8 之前

建议通过java.util.DateCalendar类来表示日期时间信息。最容易计算的差异用毫秒表示。绑定到本书的代码包含这样一个解决方案。

从 JDK8 开始

从 JDK8 开始,建议通过Temporal(例如,DateTimeDateLocalTimeZonedDateTime等)来表示日期时间信息。

假设两个LocalDate对象,2018 年 1 月 1 日和 2019 年 3 月 1 日:

LocalDate ld1 = LocalDate.of(2018, 1, 1);
LocalDate ld2 = LocalDate.of(2019, 3, 1);

计算这两个Temporal对象之间差异的最简单方法是通过ChronoUnit类。除了表示一组标准的日期周期单位外,ChronoUnit还提供了几种简便的方法,包括between(Temporal t1Inclusive, Temporal t2Exclusive)。顾名思义,between()方法计算两个Temporal对象之间的时间量。让我们看看计算ld1ld2之间的差值的工作原理,以天、月和年为单位:

// 424
long betweenInDays = Math.abs(ChronoUnit.DAYS.between(ld1, ld2));

// 14
long betweenInMonths = Math.abs(ChronoUnit.MONTHS.between(ld1, ld2));

// 1
long betweenInYears = Math.abs(ChronoUnit.YEARS.between(ld1, ld2));

或者,每个Temporal公开一个名为until()的方法。实际上,LocalDate有两个,一个返回Period作为两个日期之间的差,另一个返回long作为指定时间单位中两个日期之间的差。使用返回Period的方法如下:

Period period = ld1.until(ld2);

// Difference as Period: 1y2m0d
System.out.println("Difference as Period: " + period.getYears() + "y" 
  + period.getMonths() + "m" + period.getDays() + "d");

使用允许我们指定时间单位的方法如下:

// 424
long untilInDays = Math.abs(ld1.until(ld2, ChronoUnit.DAYS));

// 14
long untilInMonths = Math.abs(ld1.until(ld2, ChronoUnit.MONTHS));

// 1
long untilInYears = Math.abs(ld1.until(ld2, ChronoUnit.YEARS));

ChronoUnit.convert()方法也适用于LocalDateTime的情况。让我们考虑以下两个LocalDateTime对象:2018 年 1 月 1 日 22:15:15 和 2019 年 3 月 1 日 23:15:15:

LocalDateTime ldt1 = LocalDateTime.of(2018, 1, 1, 22, 15, 15);
LocalDateTime ldt2 = LocalDateTime.of(2018, 1, 1, 23, 15, 15);

现在,让我们看看ldt1ldt2之间的区别,用分钟表示:

// 60
long betweenInMinutesWithoutZone 
  = Math.abs(ChronoUnit.MINUTES.between(ldt1, ldt2));

并且,通过LocalDateTime.until()方法以小时表示的差异:

// 1
long untilInMinutesWithoutZone 
  = Math.abs(ldt1.until(ldt2, ChronoUnit.HOURS));

但是,ChronoUnit.between()until()有一个非常棒的地方,那就是它们与ZonedDateTime一起工作。例如,让我们考虑欧洲/布加勒斯特时区和澳大利亚/珀斯时区的ldt1,加上一小时:

ZonedDateTime zdt1 = ldt1.atZone(ZoneId.of("Europe/Bucharest"));
ZonedDateTime zdt2 = zdt1.withZoneSameInstant(
  ZoneId.of("Australia/Perth")).plusHours(1);

现在,我们用ChronoUnit.between()来表示zdt1zdt2之间的差分,用ZonedDateTime.until()来表示zdt1zdt2之间的差分,用小时表示:

// 60
long betweenInMinutesWithZone 
  = Math.abs(ChronoUnit.MINUTES.between(zdt1, zdt2));

// 1
long untilInHoursWithZone 
  = Math.abs(zdt1.until(zdt2, ChronoUnit.HOURS));

最后,让我们重复这个技巧,但是对于两个独立的ZonedDateTime对象:一个为ldt1获得,一个为ldt2获得:

ZonedDateTime zdt1 = ldt1.atZone(ZoneId.of("Europe/Bucharest"));
ZonedDateTime zdt2 = ldt2.atZone(ZoneId.of("Australia/Perth"));

// 300
long betweenInMinutesWithZone 
  = Math.abs(ChronoUnit.MINUTES.between(zdt1, zdt2));

// 5
long untilInHoursWithZone 
  = Math.abs(zdt1.until(zdt2, ChronoUnit.HOURS));

77 实现象棋时钟

从 JDK8 开始,java.time包有一个名为Clock的抽象类。这个类的主要目的是允许我们在需要时插入不同的时钟(例如,出于测试目的)。默认情况下,Java 有四种实现:SystemClockOffsetClockTickClockFixedClock。对于每个实现,Clock类中都有static方法。例如,下面的代码创建了FixedClock(一个总是返回相同Instant的时钟):

Clock fixedClock = Clock.fixed(Instant.now(), ZoneOffset.UTC);

还有一个TickClock,它返回给定时区整秒的当前Instant滴答声:

Clock tickClock = Clock.tickSeconds(ZoneId.of("Europe/Bucharest"));

还有一种方法可以用来在整分钟内打勾tickMinutes(),还有一种通用方法tick(),它允许我们指定Duration

Clock类也可以支持时区和偏移量,但是Clock类最重要的方法是instant()。此方法返回Clock的瞬间:

// 2019-03-01T13:29:34Z
System.out.println(tickClock.instant());

还有一个millis()方法,它以毫秒为单位返回时钟的当前时刻。

假设我们要实现一个时钟,它充当象棋时钟:

为了实现一个Clock类,需要遵循以下几个步骤:

  1. 扩展Clock类。
  2. 执行Serializable
  3. 至少覆盖从Clock继承的抽象方法。

Clock类的框架如下:

public class ChessClock extends Clock implements Serializable {

  @Override
  public ZoneId getZone() {
    ...
  }

  @Override
  public Clock withZone(ZoneId zone) {
    ...
  }

  @Override
  public Instant instant() {
    ...
  }
}

我们的ChessClock将只与 UTC 一起工作;不支持其他时区。这意味着getZone()withZone()方法可以实现如下(当然,将来可以修改):

@Override
public ZoneId getZone() {
  return ZoneOffset.UTC;
}

@Override
public Clock withZone(ZoneId zone) {
  throw new UnsupportedOperationException(
    "The ChessClock works only in UTC time zone");
}

我们实现的高潮是instant()方法。难度在于管理两个Instant,一个是左边的玩家(instantLeft),一个是右边的玩家(instantRight)。我们可以将instant()方法的每一次调用与当前玩家已经执行了一个移动的事实相关联,现在轮到另一个玩家了。所以,基本上,这个逻辑是说同一个玩家不能调用instant()两次。实现这个逻辑,instant()方法如下:

public class ChessClock extends Clock implements Serializable {

  public enum Player {
    LEFT,
    RIGHT
  }

  private static final long serialVersionUID = 1L;

  private Instant instantStart;
  private Instant instantLeft;
  private Instant instantRight;
  private long timeLeft;
  private long timeRight;
  private Player player;

  public ChessClock(Player player) {
    this.player = player;
  }

  public Instant gameStart() {

    if (this.instantStart == null) {
      this.timeLeft = 0;
      this.timeRight = 0;
      this.instantStart = Instant.now();
      this.instantLeft = instantStart;
      this.instantRight = instantStart;
      return instantStart;
    }

    throw new IllegalStateException(
      "Game already started. Stop it and try again.");
  }

  public Instant gameEnd() {

    if (this.instantStart != null) {
      instantStart = null;
      return Instant.now();
    }

    throw new IllegalStateException("Game was not started.");
  }

  @Override
  public ZoneId getZone() {
    return ZoneOffset.UTC;
  }

  @Override
  public Clock withZone(ZoneId zone) {
    throw new UnsupportedOperationException(
      "The ChessClock works only in UTC time zone");
  }

  @Override
  public Instant instant() {

    if (this.instantStart != null) {
      if (player == Player.LEFT) {
        player = Player.RIGHT;

        long secondsLeft = Instant.now().getEpochSecond() 
          - instantRight.getEpochSecond();
        instantLeft = instantLeft.plusSeconds(
          secondsLeft - timeLeft);
        timeLeft = secondsLeft;

        return instantLeft;
      } else {
        player = Player.LEFT;

        long secondsRight = Instant.now().getEpochSecond() 
          - instantLeft.getEpochSecond();
        instantRight = instantRight.plusSeconds(
          secondsRight - timeRight);
        timeRight = secondsRight;

        return instantRight;
      }
    }

    throw new IllegalStateException("Game was not started.");
  }
}

因此,根据哪个玩家调用了instant()方法,代码计算出该玩家在执行移动之前思考所需的秒数。此外,代码会切换播放器,因此下一次调用instant()将处理另一个播放器。

让我们考虑一个从2019-03-01T14:02:46.309459Z开始的国际象棋游戏:

ChessClock chessClock = new ChessClock(Player.LEFT);

// 2019-03-01T14:02:46.309459Z
Instant start = chessClock.gameStart();

此外,玩家执行以下一系列动作,直到右边的玩家赢得游戏:

Left moved first after 2 seconds: 2019-03-01T14:02:48.309459Z
Right moved after 5 seconds: 2019-03-01T14:02:51.309459Z
Left moved after 6 seconds: 2019-03-01T14:02:54.309459Z
Right moved after 1 second: 2019-03-01T14:02:52.309459Z
Left moved after 2 second: 2019-03-01T14:02:56.309459Z
Right moved after 3 seconds: 2019-03-01T14:02:55.309459Z
Left moved after 10 seconds: 2019-03-01T14:03:06.309459Z
Right moved after 11 seconds and win: 2019-03-01T14:03:06.309459Z

看来时钟正确地记录了运动员的动作。

最后,比赛在 40 秒后结束:

Game ended:2019-03-01T14:03:26.350749300Z
Instant end = chessClock.gameEnd();

Game duration: 40 seconds
// Duration.between(start, end).getSeconds();

总结

任务完成了!本章提供了使用日期和时间信息的全面概述。广泛的应用必须处理这类信息。因此,将这些问题的解决方案放在你的工具带下不是可选的。从DateCalendarLocalDateLocalTimeLocalDateTimeZoneDateTimeOffsetDateTimeOffsetTimeInstant——它们在涉及日期和时间的日常任务中都是非常重要和有用的。

从本章下载应用以查看结果和其他详细信息。******

四、类型推断

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括 21 个涉及 JEP286 或 Java 局部变量类型推断LVTI)的问题,也称为var类型。这些问题经过精心设计,以揭示最佳实践和使用var时所涉及的常见错误。到本章结束时,您将了解到将var推向生产所需的所有知识。

问题

使用以下问题来测试您的类型推断编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 简单var示例:编写一个程序,举例说明类型推断(var)在代码可读性方面的正确用法。

  2. var与原始类型结合使用:编写一个程序,举例说明将var与 Java 原始类型(intlongfloatdouble结合使用。

  3. 使用var和隐式类型转换来维持代码的可维护性:编写一个程序,举例说明var隐式类型转换如何维持代码的可维护性。

  4. 显式向下转换或更好地避免var:编写一个程序,举例说明var和显式向下转换的组合,并解释为什么要避免var

  5. 如果被调用的名称没有包含足够的人性化类型信息,请避免使用var:请举例说明应避免使用var,因为它与被调用的名称的组合会导致人性化信息的丢失。

  6. 结合 LVTI 和面向接口编程技术:编写一个程序,通过面向接口编程技术来举例说明var的用法。

  7. 结合 LVTI 和菱形运算符:编写一个程序,举例说明var菱形运算符的用法。

  8. 使用var分配数组:编写一个将数组分配给var的程序。

  9. 在复合声明中使用 LVTI:解释并举例说明 LVTI 在复合声明中的用法。

  10. LVTI 和变量范围:解释并举例说明为什么 LVTI 应该尽可能地缩小变量的范围。

  11. LVTI 和三元运算符:编写几个代码片段,举例说明 LVTI 和三元运算符组合的优点。

  12. LVTI 和for循环:写几个例子来举例说明 LVTI 在for循环中的用法。

  13. LVTI 和流:编写几个代码片段,举例说明 LVTI 和 Java 流的用法。

  14. 使用 LVTI 分解嵌套的/大的表达式链:编写一个程序,举例说明如何使用 LVTI 分解嵌套的/大的表达式链。

  15. LVTI 和方法返回和参数类型:编写几个代码片段,举例说明 LVTI 和 Java 方法在返回和参数类型方面的用法。

  16. LVTI 和匿名类:编写几个代码片段,举例说明 LVTI 在匿名类中的用法。

  17. LVTI 可以是final和有效的final:写几个代码片段,举例说明 LVTI 如何用于final和有效的final变量。

  18. LVTI 和 Lambda:通过几个代码片段解释如何将 LVTI 与 Lambda 表达式结合使用。

  19. LVTI 和null初始化器、实例变量和catch块变量:举例说明如何将 LVTI 与null初始化器、实例变量和catch块结合使用。

  20. LVTI 和泛型类型T:编写几个代码片段,举例说明如何将 LVTI 与泛型类型结合使用。

  21. LVTI、通配符、协变和逆变:编写几个代码片段,举例说明如何将 LVTI 与通配符、协变和逆变结合使用。

解决方案

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释仅包括解决问题所需的最有趣和最重要的细节。您可以下载示例解决方案以查看更多详细信息并尝试程序

78 简单var示例

从版本 10 开始,Java 附带了 JEP286 或 JavaLVTI,也称为var类型。

var标识符不是 Java 关键字,而是保留类型名

这是一个 100% 编译特性,在字节码、运行时或性能方面没有任何副作用。简而言之,LVTI 应用于局部变量,其工作方式如下:编译器检查右侧并推断出实类型(如果右侧是一个初始化器,则使用该类型)。

此功能可确保编译时安全。这意味着我们不能编译一个试图实现错误赋值的应用。如果编译器已经推断出var的具体/实际类型,我们只能赋值该类型的值。

LVTI 有很多好处;例如,它减少了代码的冗长,减少了冗余和样板代码。此外,LVTI 可以减少编写代码所花的时间,特别是在涉及大量声明的情况下,如下所示:

// without var
Map<Boolean, List<Integer>> evenAndOddMap...

// with var
var evenAndOddMap = ...

一个有争议的优点是代码可读性。一些声音支持使用var会降低代码可读性,而另一些声音则支持相反的观点。根据用例的不同,它可能需要在可读性上进行权衡,但事实是,通常情况下,我们非常关注字段(实例变量)的有意义的名称,而忽略了局部变量的名称。例如,让我们考虑以下方法:

public Object fetchTransferableData(String data)
    throws UnsupportedFlavorException, IOException {

  StringSelection ss = new StringSelection(data);
  DataFlavor[] df = ss.getTransferDataFlavors();
  Object obj = ss.getTransferData(df[0]);

  return obj;
}

这是一个简短的方法;它有一个有意义的名称和一个干净的实现。但是检查局部变量的名称。它们的名称大大减少(它们只是快捷方式),但这不是问题,因为左侧提供了足够的信息,我们可以很容易地理解每个局部变量的类型。现在,让我们使用 LVTI 编写以下代码:

public Object fetchTransferableData(String data)
    throws UnsupportedFlavorException, IOException {

  var ss = new StringSelection(data);
  var df = ss.getTransferDataFlavors();
  var obj = ss.getTransferData(df[0]);

  return obj;
}

显然,代码的可读性降低了,因为现在很难推断出局部变量的类型。如下面的屏幕截图所示,编译器在推断正确的类型方面没有问题,但是对于人类来说,这要困难得多:

这个问题的解决方案是在依赖 LVTI 时为局部变量提供一个有意义的名称。例如,如果提供了局部变量的名称,代码可以恢复可读性,如下所示:

public Object fetchTransferableData(String data)
    throws UnsupportedFlavorException, IOException {

  var stringSelection = new StringSelection(data);
  var dataFlavorsArray = stringSelection.getTransferDataFlavors();
  var obj = stringSelection.getTransferData(dataFlavorsArray[0]);

  return obj;
}

然而,可读性问题也是由这样一个事实引起的:通常,我们倾向于将类型视为主要信息,将变量名视为次要信息,而这应该是相反的。

让我们再看两个例子来执行上述语句。使用集合(例如,List)的方法如下:

// Avoid
public List<Player> fetchPlayersByTournament(String tournament) {

  var t = tournamentRepository.findByName(tournament);
  var p = t.getPlayers();

  return p;
}

// Prefer
public List<Player> fetchPlayersByTournament(String tournament) {

  var tournamentName = tournamentRepository.findByName(tournament);
  var playerList = tournamentName.getPlayers();

  return playerList;
}

为局部变量提供有意义的名称并不意味着陷入过度命名技术。

例如,通过简单地重复类型名来避免命名变量:

// Avoid
var fileCacheImageOutputStream​ 
  = new FileCacheImageOutputStream​(..., ...);

// Prefer
var outputStream​ = new FileCacheImageOutputStream​(..., ...);

// Or
var outputStreamOfFoo​ = new FileCacheImageOutputStream​(..., ...);

79 对原始类型使用var

将 LVTI 与原始类型(intlongfloatdouble)一起使用的问题是,预期类型和推断类型可能不同。显然,这会导致代码中的混乱和意外行为。

这种情况下的犯罪方是var类型使用的隐式类型转换

例如,让我们考虑以下两个依赖显式原始类型的声明:

boolean valid = true; // this is of type boolean
char c = 'c';         // this is of type char

现在,让我们用 LVTI 替换显式原始类型:

var valid = true; // inferred as boolean
var c = 'c';      // inferred as char

很好!到目前为止没有问题!现在,让我们看看另一组基于显式原始类型的声明:

int intNumber = 10;       // this is of type int
long longNumber = 10;     // this is of type long
float floatNumber = 10;   // this is of type float, 10.0
double doubleNumber = 10; // this is of type double, 10.0

让我们按照第一个示例中的逻辑,用 LVTI 替换显式原始类型:

// Avoid
var intNumber = 10;    // inferred as int
var longNumber = 10;   // inferred as int
var floatNumber = 10;  // inferred as int
var doubleNumber = 10; // inferred as int

根据以下屏幕截图,所有四个变量都被推断为整数:

这个问题的解决方案包括使用显式 Java 字面值

// Prefer
var intNumber = 10;     // inferred as int
var longNumber = 10L;   // inferred as long
var floatNumber = 10F;  // inferred as float, 10.0
var doubleNumber = 10D; // inferred as double, 10.0

最后,让我们考虑一个带小数的数字的情况,如下所示:

var floatNumber = 10.5; // inferred as double

变量名表明10.5float,但实际上是推断为double。因此,即使是带小数的数字(尤其是带小数的数字),也建议使用字面值

var floatNumber = 10.5F; // inferred as float

80 使用var和隐式类型转换来维持代码的可维护性

在上一节中,“将var与原始类型结合使用”,我们看到将var隐式类型转换结合使用会产生实际问题。但在某些情况下,这种组合可能是有利的,并维持代码的可维护性。

让我们考虑以下场景,我们需要编写一个方法,该方法位于名为ShoppingAddicted的外部 API 的两个现有方法之间(通过推断,这些方法可以是两个 Web 服务、端点等)。有一种方法专门用于返回给定购物车的最佳价格。基本上,这种方法需要一堆产品,并查询不同的在线商店,以获取最佳价格。

结果价格返回为int。此方法的存根如下所示:

public static int fetchBestPrice(String[] products) {

  float realprice = 399.99F; // code to query the prices in stores
  int price = (int) realprice;

  return price;
}

另一种方法将价格作为int接收并执行支付。如果支付成功,则返回true

public static boolean debitCard(int amount) {

  return true;
}

现在,通过对该代码进行编程,我们的方法将充当客户端,如下所示(客户可以决定购买哪些商品,我们的代码将为他们返回最佳价格并相应地借记卡):

// Avoid
public static boolean purchaseCart(long customerId) {

  int price = ShoppingAddicted.fetchBestPrice(new String[0]);
  boolean paid = ShoppingAddicted.debitCard(price);

  return paid;
}

但是过了一段时间,ShoppingAddictedAPI 的拥有者意识到他们通过将实际价格转换成int来赔钱(例如,实际价格是 399.99,但在int形式中,它是 399.0,这意味着损失 99 美分)。因此,他们决定放弃这种做法,将实际价格返回为float

public static float fetchBestPrice(String[] products) {

  float realprice = 399.99F; // code to query the prices in stores

  return realprice;
}

因为返回的价格是float,所以debitCard()也会更新:

public static boolean debitCard(float amount) {

  return true;
}

但是,一旦我们升级到新版本的ShoppingAddictedAPI,代码将失败,并有可能从floatint异常的有损转换。这是正常的,因为我们的代码需要int。由于我们的代码不能很好地容忍这些修改,因此需要相应地修改代码。

然而,如果我们已经预见到这种情况,并且使用了var而不是int,那么由于隐式类型转换,代码将不会出现问题:

// Prefer
public static boolean purchaseCart(long customerId) {

  var price = ShoppingAddicted.fetchBestPrice(new String[0]);
  var paid = ShoppingAddicted.debitCard(price);

  return paid;
}

81 显式向下转换或更好地避免var

在“将var与原始类型结合使用”一节中,我们讨论了将字面值与原始类型结合使用(intlongfloatdouble来避免隐式类型转换带来的问题。但并非所有 Java 原始类型都可以利用字面值。在这种情况下,最好的方法是避免使用var。但让我们看看为什么!

检查以下关于byteshort变量的声明:

byte byteNumber = 25;     // this is of type byte
short shortNumber = 1463; // this is of type short

如果我们用var替换显式类型,那么推断的类型将是int

var byteNumber = 25;    // inferred as int
var shortNumber = 1463; // inferred as int

不幸的是,这两种基本类型没有可用的字面值。帮助编译器推断正确类型的唯一方法是依赖显式向下转换:

var byteNumber = (byte) 25;     // inferred as byte
var shortNumber = (short) 1463; // inferred as short

虽然这段代码编译成功并按预期工作,但我们不能说使用var比使用显式类型带来了任何价值。因此,在这种情况下,最好避免var和显式的向下转型。

82 如果被调用的名称没有包含足够的类型信息,请避免使用var

好吧,var不是一颗银弹,这个问题将再次凸显这一点。以下代码片段可以使用显式类型或var编写,而不会丢失信息:

// using explicit types
MemoryCacheImageInputStream is =
  new MemoryCacheImageInputStream(...);
JavaCompiler jc = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fm = compiler.getStandardFileManager(...);

因此,将前面的代码片段迁移到var将产生以下代码(通过从右侧目视检查被调用的名称来选择变量名称):

// using var
var inputStream = new MemoryCacheImageInputStream(...);
var compiler = ToolProvider.getSystemJavaCompiler();
var fileManager = compiler.getStandardFileManager(...);

同样的情况也会发生在过度命名的边界上:

// using var
var inputStreamOfCachedImages = new MemoryCacheImageInputStream(...);
var javaCompiler = ToolProvider.getSystemJavaCompiler();
var standardFileManager = compiler.getStandardFileManager(...);

因此,前面的代码在选择变量的名称和可读性时不会引起任何问题。所谓的名称包含了足够的信息,让人类对var感到舒服。

但让我们考虑以下代码片段:

// Avoid
public File fetchBinContent() {
  return new File(...);
}

// called from another place
// notice the variable name, bin
var bin = fetchBinContent();

对于人类来说,如果不检查名称fetchBinContent()的返回类型,就很难推断出名称返回的类型。根据经验,在这种情况下,解决方案应该避免var并依赖显式类型,因为右侧没有足够的信息让我们为变量选择合适的名称并获得可读性很高的代码:

// called from another place
// now the left-hand side contains enough information
File bin = fetchBinContent();

因此,如果将var与被调用的名称组合使用导致清晰度损失,则最好避免使用var。忽略此语句可能会导致混淆,并会增加理解和/或扩展代码所需的时间。

考虑另一个基于java.nio.channels.Selector类的例子。此类公开了一个名为open()static方法,该方法返回一个新打开的Selector。但是,如果我们在一个用var声明的变量中捕获这个返回值,我们很可能会认为这个方法可能返回一个boolean,表示打开当前选择器的成功。使用var而不考虑可能的清晰度损失会产生这些问题。像这样的一些问题和代码将成为一个真正的痛苦。

83 LVTI 与面向接口编程技术相结合

Java 最佳实践鼓励我们将代码绑定到抽象。换句话说,我们需要依赖于面向接口编程的技术。

这种技术非常适合于集合声明。例如,建议声明ArrayList如下:

List<String> players = new ArrayList<>();

我们也应该避免这样的事情:

ArrayList<String> players = new ArrayList<>();

通过遵循第一个示例,代码实例化了ArrayList类(或HashSetHashMap等),但声明了一个List类型的变量(或SetMap等)。由于ListSetMap以及更多的都是接口(或契约),因此很容易用ListSetMap的其他实现来替换实例化,而无需对代码进行后续修改。

不幸的是,LVTI 不能利用面向接口编程技术。换句话说,当我们使用var时,推断的类型是具体的实现,而不是合同。例如,将List<String>替换为var将导致推断类型ArrayList<String>

// inferred as ArrayList<String>
var playerList = new ArrayList<String>();

然而,有一些解释支持这种行为:

  • LVTI 在局部级别(局部变量)起作用,其中面向接口编程技术的的使用少于方法参数/返回类型或字段类型。
  • 由于局部变量的作用域很小,因此切换到另一个实现所引起的修改也应该很小。切换实现对检测和修复代码的影响应该很小。
  • LVTI 将右侧的代码视为一个用于推断实际类型的初始化器。如果将来要修改这个初始化器,那么推断的类型可能不同,这将导致使用此变量的代码出现问题。

84 LVTI 和菱形运算符相结合

根据经验,如果右侧不存在推断预期类型所需的信息,则 LVTI 与菱形运算符结合可能会导致意外的推断类型。

在 JDK7 之前,即 Coin 项目,List<String>将声明如下:

List<String> players = new ArrayList<String>();

基本上,前面的示例显式指定泛型类的实例化参数类型。从 JDK7 开始,Coin 项目引入了菱形操作符,可以推断泛型类实例化参数类型,如下所示:

List<String> players = new ArrayList<>();

现在,如果我们从 LVTI 的角度来考虑这个例子,我们将得到以下结果:

var playerList = new ArrayList<>();

但是现在推断出的类型是什么呢?好吧,我们有一个问题,因为推断的类型将是ArrayList<Object>,而不是ArrayList<String>。解释很明显:推断预期类型(String所需的信息不存在(注意,右侧没有提到String类型)。这指示 LVTI 推断出最广泛适用的类型,在本例中是Object

但是如果ArrayList<Object>不是我们的意图,那么我们需要一个解决这个问题的方法。解决方案是提供推断预期类型所需的信息,如下所示:

var playerList = new ArrayList<String>();

现在,推断的类型是ArrayList<String>。也可以间接推断类型。请参见以下示例:

var playerStack = new ArrayDeque<String>();

// inferred as ArrayList<String>
var playerList = new ArrayList<>(playerStack);

也可以通过以下方式间接推断:

Player p1 = new Player();
Player p2 = new Player();
var listOfPlayer = List.of(p1, p2); // inferred as List<Player>

// Don't do this!
var listOfPlayer = new ArrayList<>(); // inferred as ArrayList<Object>
listOfPlayer.add(p1);
listOfPlayer.add(p2);

85 将数组赋给var

根据经验,将数组分配给var不需要括号[]。通过相应的显式类型定义一个int数组可以如下所示:

int[] numbers = new int[10];

// or, less preferred
int numbers[] = new int[10];

现在,尝试直觉地使用var代替int可能会导致以下尝试:

var[] numberArray = new int[10];
var numberArray[] = new int[10];

不幸的是,这两种方法都无法编译。解决方案要求我们从左侧拆下支架:

// Prefer
var numberArray = new int[10]; // inferred as array of int, int[]
numberArray[0] = 3;            // works
numberArray[0] = 3.2;          // doesn't work
numbers[0] = "3";              // doesn't work

通常的做法是在声明时初始化数组,如下所示:

// explicit type work as expected
int[] numbers = {1, 2, 3};

但是,尝试使用var将不起作用(不会编译):

// Does not compile
var numberArray = {1, 2, 3};
var numberArray[] = {1, 2, 3};
var[] numberArray = {1, 2, 3};

此代码无法编译,因为右侧没有自己的类型。

86 在复合声明中使用 LVTI

复合声明允许我们声明一组相同类型的变量,而无需重复该类型。类型只指定一次,变量用逗号分隔:

// using explicit type
String pending = "pending", processed = "processed", 
       deleted = "deleted";

String替换为var将导致无法编译的代码:

// Does not compile
var pending = "pending", processed = "processed", deleted = "deleted";

此问题的解决方案是将复合声明转换为每行一个声明:

// using var, the inferred type is String
var pending = "pending";
var processed = "processed";
var deleted = "deleted";

因此,根据经验,LVTI 不能用在复合声明中。

87 LVTI 和变量范围

干净的代码最佳实践包括为所有局部变量保留一个小范围。这是在 LVTI 存在之前就遵循的干净代码黄金规则之一。

此规则支持可读性和调试阶段。它可以加快查找错误和编写修复程序的过程。请考虑以下打破此规则的示例:

// Avoid
...
var stack = new Stack<String>();
stack.push("John");
stack.push("Martin");
stack.push("Anghel");
stack.push("Christian");

// 50 lines of code that doesn't use stack

// John, Martin, Anghel, Christian
stack.forEach(...);

因此,前面的代码声明了一个具有四个名称的栈,包含 50 行不使用此栈的代码,并通过forEach()方法完成此栈的循环。此方法继承自java.util.Vector,将栈作为任意向量(JohnMartinAnghelChristian循环。这是我们想要的遍历顺序。

但后来,我们决定从栈切换到ArrayDeque(原因无关紧要)。这次,forEach()方法将是由ArrayDeque类提供的方法。此方法的行为不同于Vector.forEach(),即循环将遍历后进先出LIFO)遍历(ChristianAnghelMartinJohn之后的条目:

// Avoid
...
var stack = new ArrayDeque<String>();
stack.push("John");
stack.push("Martin");
stack.push("Anghel");
stack.push("Christian");

// 50 lines of code that doesn't use stack

// Christian, Anghel, Martin, John
stack.forEach(...);

这不是我们的本意!我们切换到ArrayDeque是为了其他目的,而不是为了影响循环顺序。但是很难看出代码中有 bug,因为包含forEach()部分的代码部分不在我们完成修改的代码附近(代码行下面 50 行)。我们有责任提出一个解决方案,最大限度地提高快速修复这个 bug 的机会,避免一堆上下滚动来了解正在发生的事情。解决方案包括遵循我们之前调用的干净代码规则,并使用小范围的stack变量编写此代码:

// Prefer
...
var stack = new Stack<String>();
stack.push("John");
stack.push("Martin");
stack.push("Anghel");
stack.push("Christian");

// John, Martin, Anghel, Christian
stack.forEach(...);

// 50 lines of code that doesn't use stack

现在,当我们从Stack切换到ArrayQueue时,我们应该更快地注意到错误并能够修复它。

88 LVTI 与三元运算符

只要写入正确,三元运算符允许我们在右侧使用不同类型的操作数。例如,以下代码将不会编译:

// Does not compile
List evensOrOdds = containsEven ?
  List.of(10, 2, 12) : Set.of(13, 1, 11);

// Does not compile
Set evensOrOdds = containsEven ?
  List.of(10, 2, 12) : Set.of(13, 1, 11);

但是,可以通过使用正确/支持的显式类型重写代码来修复此代码:

Collection evensOrOdds = containsEven ?
  List.of(10, 2, 12) : Set.of(13, 1, 11);

Object evensOrOdds = containsEven ?
  List.of(10, 2, 12) : Set.of(13, 1, 11);

对于以下代码片段,类似的尝试将失败:

// Does not compile
int numberOrText = intOrString ? 2234 : "2234";

// Does not compile
String numberOrText = intOrString ? 2234 : "2234";

但是,可以这样修复:

Serializable numberOrText = intOrString ? 2234 : "2234";

Object numberOrText = intOrString ? 2234 : "2234";

因此,为了在右侧有一个具有不同类型操作数的三元运算符,开发人员必须匹配支持两个条件分支的正确类型。或者,开发人员可以依赖 LVTI,如下所示(当然,这也适用于相同类型的操作数):

// inferred type, Collection<Integer>
var evensOrOddsCollection = containsEven ?
  List.of(10, 2, 12) : Set.of(13, 1, 11);

// inferred type, Serializable
var numberOrText = intOrString ? 2234 : "2234";

不要从这些例子中得出结论,var类型是在运行时推断出来的!不是的!

89 LVTI 和for循环

使用显式类型声明简单的for循环是一项琐碎的任务,如下所示:

// explicit type
for (int i = 0; i < 5; i++) {
  ...
}

或者,我们可以使用增强的for循环:

List<Player> players = List.of(
  new Player(), new Player(), new Player());
for (Player player: players) {
  ...
}

从 JDK10 开始,我们可以将变量的显式类型iplayer替换为var,如下所示:

for (var i = 0; i < 5; i++) { // i is inferred of type int
  ...
}

for (var player: players) { // i is inferred of type Player
  ...
}

当循环数组、集合等的类型发生更改时,使用var可能会有所帮助。例如,通过使用var,可以在不指定显式类型的情况下循环以下array的两个版本:

// a variable 'array' representing an int[]
int[] array = { 1, 2, 3 };

// or the same variable, 'array', but representing a String[]
String[] array = {
  "1", "2", "3"
};

// depending on how 'array' is defined 
// 'i' will be inferred as int or as String
for (var i: array) {
  System.out.println(i);
}

90 LVTI 和流

让我们考虑以下Stream<Integer>流:

// explicit type
Stream<Integer> numbers = Stream.of(1, 2, 3, 4, 5);
numbers.filter(t -> t % 2 == 0).forEach(System.out::println);

使用 LVTI 代替Stream<Integer>非常简单。只需将Stream<Integer>替换为var,如下所示:

// using var, inferred as Stream<Integer>
var numberStream = Stream.of(1, 2, 3, 4, 5);
numberStream.filter(t -> t % 2 == 0).forEach(System.out::println);

下面是另一个例子:

// explicit types
Stream<String> paths = Files.lines(Path.of("..."));
List<File> files = paths.map(p -> new File(p)).collect(toList());

// using var
// inferred as Stream<String>
var pathStream = Files.lines(Path.of(""));

// inferred as List<File>
var fileList = pathStream.map(p -> new File(p)).collect(toList());

看起来 Java10、LVTI、Java8 和StreamAPI 是一个很好的团队。

91 使用 LVTI 分解嵌套/大型表达式链

大型/嵌套表达式通常是一些代码片段,它们看起来非常令人印象深刻,令人生畏。它们通常被视为智能智慧代码的片段。关于这是好是坏是有争议的,但最有可能的是,这种平衡倾向于有利于那些声称应该避免这种代码的人。例如,检查以下表达式:

List<Integer> ints = List.of(1, 1, 2, 3, 4, 4, 6, 2, 1, 5, 4, 5);

// Avoid
int result = ints.stream()
  .collect(Collectors.partitioningBy(i -> i % 2 == 0))
  .values()
  .stream()
  .max(Comparator.comparing(List::size))
  .orElse(Collections.emptyList())
  .stream()
  .mapToInt(Integer::intValue)
  .sum();

这样的表达式可以是有意编写的,也可以表示一个增量过程的最终结果,该过程在时间上丰富了一个最初很小的表达式。然而,当这些表达式开始成为可读性的空白时,它们必须通过局部变量被分解成碎片。但这并不有趣,可以被认为是我们想要避免的令人筋疲力尽的工作:

List<Integer> ints = List.of(1, 1, 2, 3, 4, 4, 6, 2, 1, 5, 4, 5);

// Prefer
Collection<List<Integer>> evenAndOdd = ints.stream()
  .collect(Collectors.partitioningBy(i -> i % 2 == 0))
  .values();

List<Integer> evenOrOdd = evenAndOdd.stream()
  .max(Comparator.comparing(List::size))
  .orElse(Collections.emptyList());

int sumEvenOrOdd = evenOrOdd.stream()
  .mapToInt(Integer::intValue)
  .sum();

检查前面代码中局部变量的类型。我们有Collection<List<Integer>>List<Integer>int。很明显,这些显式类型需要一些时间来获取和写入。这可能是避免将此表达式拆分为碎片的一个很好的理由。然而,如果我们希望采用局部变量的样式,那么使用var类型而不是显式类型的琐碎性是很诱人的,因为它节省了通常用于获取显式类型的时间:

var intList = List.of(1, 1, 2, 3, 4, 4, 6, 2, 1, 5, 4, 5);

// Prefer
var evenAndOdd = intList.stream()
  .collect(Collectors.partitioningBy(i -> i % 2 == 0))
  .values();

var evenOrOdd = evenAndOdd.stream()
  .max(Comparator.comparing(List::size))
  .orElse(Collections.emptyList());

var sumEvenOrOdd = evenOrOdd.stream()
  .mapToInt(Integer::intValue)
  .sum();

令人惊叹的!现在,编译器的任务是推断这些局部变量的类型。我们只选择打破表达的点,用var来划分。

92 LVTI 和方法返回值和参数类型

根据经验,LVTI 不能用作return方法类型或参数方法类型;相反,var类型的变量可以作为方法参数传递或存储return方法。让我们通过几个例子来迭代这些语句:

  • LVTI 不能用作以下代码不编译的方法返回类型:
// Does not compile
public var fetchReport(Player player, Date timestamp) {

  return new Report();
}
  • LVTI 不能用作方法参数类型以下代码不编译:
public Report fetchReport(var player, var timestamp) {

  return new Report();
}
  • var类型的变量可以作为方法参数传递,也可以存储一个返回方法。下面的代码编译成功并且可以工作:
public Report checkPlayer() {

  var player = new Player();
  var timestamp = new Date();
  var report = fetchReport(player, timestamp);

  return report;
}

public Report fetchReport(Player player, Date timestamp) {

  return new Report();
}

93 LVTI 和匿名类

LVTI 可以用于匿名类。下面是一个匿名类的示例,该类对weighter变量使用显式类型:

public interface Weighter {
  int getWeight(Player player);
}

Weighter weighter = new Weighter() {
  @Override
  public int getWeight(Player player) {
    return ...;
  }
};

Player player = ...;
int weight = weighter.getWeight(player);

现在,看看如果我们使用 LVTI 会发生什么:

var weighter = new Weighter() {
  @Override
  public int getWeight(Player player) {
    return ...;
  }
};

94 LVTI 可以是最终的,也可以是有效最终的

作为一个快速提醒,从 JavaSE8 开始,一个局部类可以访问封闭块的局部变量和参数,这些变量和参数是final或实际上是final。一个变量或参数,其值在初始化后从未改变,实际上是最终的

下面的代码片段表示一个有效最终变量(尝试重新分配ratio变量将导致错误,这意味着该变量是有效最终)和两个final变量(尝试重新分配limitbmi变量将导致错误)的用例在一个错误中,这意味着这些变量是final

public interface Weighter {
  float getMarginOfError();
}

float ratio = fetchRatio(); // this is effectively final

var weighter = new Weighter() {
  @Override
  public float getMarginOfError() {
    return ratio * ...;
  }
};

ratio = fetchRatio(); // this reassignment will cause error

public float fetchRatio() {

  final float limit = new Random().nextFloat(); // this is final
  final float bmi = 0.00023f;                   // this is final

  limit = 0.002f; // this reassignment will cause error
  bmi = 0.25f;    // this reassignment will cause error

  return limit * bmi / 100.12f;
}

现在,让我们用var替换显式类型。编译器将推断出这些变量(ratiolimitbmi的正确类型并保持它们的状态-ratio将是有效最终,而limitbmifinal。尝试重新分配其中任何一个将导致特定错误:

var ratio = fetchRatio(); // this is effectively final 
var weighter = new Weighter() {
  @Override
  public float getMarginOfError() {
    return ratio * ...;
  }
};

ratio = fetchRatio(); // this reassignment will cause error 
public float fetchRatio() {

  final var limit = new Random().nextFloat(); // this is final
 final var bmi = 0.00023f; // this is final
 limit = 0.002f; // this reassignment will cause error
 bmi = 0.25f; // this reassignment will cause error
  return limit * bmi / 100.12f;
}

95 LVTI 和 Lambda

使用 LVTI 和 Lambda 的问题是无法推断具体类型。不允许使用 Lambda 和方法引用初始化器。此语句是var限制的一部分;因此,Lambda 表达式和方法引用需要显式的目标类型。

例如,以下代码片段将不会编译:

// Does not compile
// lambda expression needs an explicit target-type
var incrementX = x -> x + 1;

// method reference needs an explicit target-type
var exceptionIAE = IllegalArgumentException::new;

由于var不能使用,所以这两段代码需要编写如下:

Function<Integer, Integer> incrementX = x -> x + 1;
Supplier<IllegalArgumentException> exceptionIAE 
  = IllegalArgumentException::new;

但是在 Lambda 的上下文中,Java11 允许我们在 Lambda 参数中使用var。例如,下面的代码在 Java11 中工作(更多详细信息可以在《JEP323:Lambda 参数的局部变量语法》中找到:

@FunctionalInterface
public interface Square {
  int calculate(int x);
}

Square square = (var x) -> x * x;

但是,请记住,以下操作不起作用:

var square = (var x) -> x * x; // cannot infer

96 LVTI 和null初始化器、实例变量和catch块变量

LVTI 与null初始化器、实例变量和catch块变量有什么共同点?嗯,LVTI 不能和它们一起使用。以下尝试将失败:

  • LVTI 不能与null初始化器一起使用:
// result in an error of type: variable initializer is 'null'
var message = null;

// result in: cannot use 'var' on variable without initializer
var message;
  • LVTI 不能与实例变量(字段)一起使用:
public class Player {

  private var age; // error: 'var' is not allowed here
  private var name; // error: 'var' is not allowed here
  ...
}
  • LVTI 不能用于catch块变量:
try {
  TimeUnit.NANOSECONDS.sleep(1000);
} catch (var ex) {  ... }

资源尝试使用

另一方面,var类型非常适合资源尝试使用,如下例所示:

// explicit type
try (PrintWriter writer = new PrintWriter(new File("welcome.txt"))) {
  writer.println("Welcome message");
}
// using var
try (var writer = new PrintWriter(new File("welcome.txt"))) {
  writer.println("Welcome message");
}

97 LVTI 和泛型类型,T

为了理解 LVTI 如何与泛型类型相结合,让我们从一个示例开始。以下方法是泛型类型T的经典用例:

public static <T extends Number> T add(T t) {
  T temp = t;
  ...
  return temp;
}

在这种情况下,我们可以将T替换为var,代码将正常工作:

public static <T extends Number> T add(T t) {
  var temp = t;
  ...
  return temp;
}

因此,具有泛型类型的局部变量可以利用 LVTI。让我们看看其他一些示例,首先使用泛型类型T

public <T extends Number> T add(T t) {

  List<T> numberList = new ArrayList<T>();
  numberList.add(t);
  numberList.add((T) Integer.valueOf(3));
  numberList.add((T) Double.valueOf(3.9));

  // error: incompatible types: String cannot be converted to T
  // numbers.add("5");

  return numberList.get(0);
}

现在,我们将List<T>替换为var

public <T extends Number> T add(T t) {

  var numberList = new ArrayList<T>();
  numberList.add(t);
  numberList.add((T) Integer.valueOf(3));
  numberList.add((T) Double.valueOf(3.9));

  // error: incompatible types: String cannot be converted to T
  // numbers.add("5");

  return numberList.get(0);
}

注意并仔细检查ArrayList实例化是否存在T。不要这样做(这将被推断为ArrayList<Object>,并将忽略泛型类型T后面的实际类型):

var numberList = new ArrayList<>();

98 LVTI、通配符、协变和逆变

用 LVTI 替换通配符、协变和逆变是一项微妙的工作,应该在充分意识到后果的情况下完成。

LVTI 和通配符

首先,我们来讨论 LVTI 和通配符(?。通常的做法是将通配符与Class关联,并编写如下内容:

// explicit type
Class<?> clazz = Long.class;

在这种情况下,使用var代替Class<?>没有问题。根据右边的类型,编译器将推断出正确的类型。在本例中,编译器将推断出Class<Long>

但是请注意,用 LVTI 替换通配符应该小心,并且您应该意识到其后果(或副作用)。让我们看一个例子,用var替换通配符是一个错误的选择。考虑以下代码:

Collection<?> stuff = new ArrayList<>();
stuff.add("hello"); // compile time error
stuff.add("world"); // compile time error

由于类型不兼容,此代码无法编译。一种非常糟糕的方法是用var替换通配符来修复此代码,如下所示:

var stuff = new ArrayList<>();
strings.add("hello"); // no error
strings.add("world"); // no error

通过使用var,错误将消失,但这不是我们在编写前面的代码(存在类型不兼容错误的代码)时想到的。所以,根据经验,不要仅仅因为一些恼人的错误会神奇地消失,就用var代替Foo<?>!试着思考一下预期的任务是什么,并相应地采取行动。例如,可能在前面的代码片段中,我们试图定义ArrayList<String>,但由于错误,最终得到了Collection<?>

LVTI 和协变/逆变

用 LVTI 替换协变(Foo<? extends T>)或逆变(Foo<? super T>)是一种危险的方法,应该避免。

请查看以下代码片段:

// explicit types
Class<? extends Number> intNumber = Integer.class;
Class<? super FilterReader> fileReader = Reader.class;

在协变中,我们有一个上界,由Number类表示,而在逆变中,我们有一个下界,由FilterReader类表示。有了这些边界(或约束),以下代码将触发特定的编译时错误:

// Does not compile
// error: Class<Reader> cannot be converted 
//        to Class<? extends Number>
Class<? extends Number> intNumber = Reader.class;

// error: Class<Integer> cannot be converted 
//        to Class<? super FilterReader>
Class<? super FilterReader> fileReader = Integer.class;

现在,让我们用var代替前面的协变和逆变:

// using var
var intNumber = Integer.class;
var fileReader = Reader.class;

此代码不会导致任何问题。现在,我们可以将任何类赋给这些变量,这样我们的边界/约束就消失了。这不是我们打算做的:

// this will compile just fine
var intNumber = Reader.class;
var fileReader = Integer.class;

所以,用var代替协变和逆变是个错误的选择!

总结

这是本章的最后一个问题。请看《JEP323:Lambda 参数的局部变量语法》《JEP301:增强枚举》了解更多信息。只要您熟悉本章介绍的问题,采用这些特性应该是相当顺利的。

从本章下载应用以查看结果和其他详细信息。

五、数组、集合和数据结构

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括 30 个问题,涉及数组、集合和几个数据结构。其目的是为在广泛的应用中遇到的一类问题提供解决方案,包括排序、查找、比较、排序、反转、填充、合并、复制和替换。提供的解决方案是用 Java8-12 实现的,它们也可以作为解决其他相关问题的基础。在本章的最后,您将掌握广泛的知识,这些知识对于解决涉及数组、集合和数据结构的各种问题非常有用。

问题

使用以下问题测试基于数组、集合和数据结构的编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 数组排序:编写几个程序,举例说明数组的不同排序算法。另外,编写一个数组洗牌程序。

  2. 寻找数组中的元素:编写几个程序,举例说明如何在给定的数组中找到给定的元素(原始类型和对象)。查找索引和/或简单地检查值是否在数组中。

  3. 检查两个数组是否相等或不匹配:编写一个程序,检查给定的两个数组是否相等或不匹配。

  4. 按字典比较两个数组:编写一个程序,按字典法比较给定的数组。

  5. 从数组创建流:编写从给定数组创建流的程序。

  6. 数组的最小值、最大值和平均值:编写一个程序,计算给定数组的最大值、最小值和平均值。

  7. 反转数组:写一个程序反转给定的数组。

  8. 填充和设置数组:写几个填充数组和基于生成函数设置所有元素的例子来计算每个元素。

  9. 下一个较大的元素NGE):编写一个程序,返回数组中每个元素的 NGE。

  10. 改变数组大小:编写一个程序,通过将数组的大小增加一个元素来增加数组的大小。另外,编写一个程序,用给定的长度增加数组的大小。

  11. 创建不可修改/不可变集合:编写几个创建不可修改和不可变集合的示例。

  12. 映射的默认值:编写一个程序,从Map获取一个值或一个默认值。

  13. 计算Map中的键是否缺失/存在:编写一个程序,计算缺失键的值或当前键的新值。

  14. Map中删除条目:编写一个程序,用给定的键从Map删除。

  15. 替换Map中的条目:编写一个程序来替换Map中给定的条目。

  16. 比较两个映射:编写一个比较两幅映射的程序。

  17. 合并两个映射:编写一个程序,合并两个给定的映射。

  18. 复制HashMap:编写一个程序,执行HashMap的浅复制和深复制。

  19. 排序Map:编写一个程序对Map进行排序。

  20. 删除集合中与谓词匹配的所有元素:编写一个程序,删除集合中与给定谓词匹配的所有元素。

  21. 将集合转换成数组:编写一个程序,将集合转换成数组。

  22. 过滤List集合:写几个List过滤集合的方案。揭示最好的方法。

  23. 替换List的元素:编写一个程序,将List的每个元素替换为对其应用给定运算符的结果。

  24. 线程安全集合、栈和队列:编写几个程序来举例说明 Java 线程安全集合的用法。

  25. 广度优先搜索BFS):编写实现 BFS 算法的程序。

  26. Trie:编写一个实现 Trie 数据结构的程序。

  27. 元组:编写实现元组数据结构的程序。

  28. 并查:编写实现并查算法的程序。

  29. Fenwick 树或二叉索引树:编写一个实现 Fenwick 树算法的程序。

  30. 布隆过滤器:编写实现布隆过滤器算法的程序。

**# 解决方案

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释仅包括解决问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多详细信息,并在这个页面中试用程序。

99 排序数组

排序数组是许多域/应用中遇到的常见任务。Java 提供了一个内置的解决方案,使用比较器对原始类型和对象的数组进行排序,这一点非常常见。这种解决方案效果很好,在大多数情况下都是比较可取的方法。让我们在下一节中看看不同的解决方案。

JDK 内置解决方案

内置的解决方案名为sort(),它在java.util.Arrays类中有许多不同的风格(15 种以上的风格)。

sort()方法的背后,有一个性能良好的快速排序类型的排序算法,称为双轴快速排序。

假设我们需要按自然顺序对整数数组进行排序(原始类型int。为此,我们可以依赖于Arrays.sort(int[] a),如下例所示:

int[] integers = new int[]{...};
Arrays.sort(integers);

有时,我们需要对一个对象数组进行排序。假设我们有一个类Melon

public class Melon {

  private final String type;
  private final int weight;

  public Melon(String type, int weight) {
    this.type = type;
    this.weight = weight;
  }

  // getters omitted for brevity
}

Melon的数组可以通过适当的Comparator按升序权重排序:

Melon[] melons = new Melon[] { ... };

Arrays.sort(melons, new Comparator<Melon>() {
  @Override
  public int compare(Melon melon1, Melon melon2) {
    return Integer.compare(melon1.getWeight(), melon2.getWeight());
  }
});

通过 Lambda 表达式重写前面的代码可以获得相同的结果:

Arrays.sort(melons, (Melon melon1, Melon melon2) 
  -> Integer.compare(melon1.getWeight(), melon2.getWeight()));

此外,数组提供了一种并行排序元素的方法parallelSort()。幕后使用的排序算法是一种基于ForkJoinPool的并行排序合并,它将数组分解为子数组,子数组本身进行排序,然后进行合并。举个例子:

Arrays.parallelSort(melons, new Comparator<Melon>() {
  @Override
  public int compare(Melon melon1, Melon melon2) {
    return Integer.compare(melon1.getWeight(), melon2.getWeight());
  }
});

或者,通过 Lambda 表达式,我们有以下示例:

Arrays.parallelSort(melons, (Melon melon1, Melon melon2) 
  -> Integer.compare(melon1.getWeight(), melon2.getWeight()));

前面的示例按升序对数组排序,但有时需要按降序对其排序。当我们对一个Object数组进行排序并依赖于一个Comparator时,我们可以简单地将返回的结果乘以Integer.compare()再乘以 -1:

Arrays.sort(melons, new Comparator<Melon>() {
  @Override
  public int compare(Melon melon1, Melon melon2) {
    return (-1) * Integer.compare(melon1.getWeight(), 
      melon2.getWeight());
  }
});

或者,我们可以简单地在compare()方法中切换参数。

对于装箱原始类型的数组,解决方案可以依赖于Collections.reverse()方法,如下例所示:

Integer[] integers = new Integer[] {3, 1, 5};

// 1, 3, 5
Arrays.sort(integers);

// 5, 3, 1
Arrays.sort(integers, Collections.reverseOrder());

不幸的是,没有内置的解决方案来按降序排列原始类型数组。最常见的情况是,如果我们仍然要依赖于Arrays.sort(),那么这个问题的解决方案是在数组按升序排序后反转数组(O(n)):

// sort ascending
Arrays.sort(integers);

// reverse array to obtain it in descending order
for (int leftHead = 0, rightHead = integers.length - 1;
       leftHead < rightHead; leftHead++, rightHead--) {

  int elem = integers[leftHead];
  integers[leftHead] = integers[rightHead];
  integers[rightHead] = elem;
}

另一个解决方案可以依赖于 Java8 函数式风格和装箱(请注意装箱是一个非常耗时的操作):

int[] descIntegers = Arrays.stream(integers)
  .boxed() //or .mapToObj(i -> i)
  .sorted((i1, i2) -> Integer.compare(i2, i1))
  .mapToInt(Integer::intValue)
  .toArray();

其他排序算法

嗯,还有很多其他的排序算法。每种方法都有优缺点,最好的选择方法是对应用特定的情况进行基准测试。

让我们研究其中的一些,如下一节中强调的,从一个非常慢的算法开始。

冒泡排序

冒泡排序是一个简单的算法,基本上气泡数组的元素。这意味着它会多次遍历数组,并在相邻元素顺序错误时交换它们,如下图所示:

时间复杂度情况如下:最佳情况O(n)、平均情况O(n<sup>2</sup>)、最坏情况O(n<sup>2</sup>)

空间复杂度情况如下:最坏情况O(1)

实现冒泡排序的实用方法如下:

public static void bubbleSort(int[] arr) {

  int n = arr.length;

  for (int i = 0; i < n - 1; i++) {
    for (int j = 0; j < n - i - 1; j++) {

      if (arr[j] > arr[j + 1]) {
        int temp = arr[j];
        arr[j] = arr[j + 1];
        arr[j + 1] = temp;
      }
    }
  }
}

还有一个依赖于while循环的优化版本。你可以在捆绑到这本书的代码中找到它,名字是bubbleSortOptimized()

作为时间执行的性能比较,对于 100000 个整数的随机数组,优化后的版本将快 2 秒左右。

前面的实现可以很好地对原始类型数组进行排序,但是,要对Object数组进行排序,我们需要在代码中引入Comparator,如下所示:

public static <T> void bubbleSortWithComparator(
    T arr[], Comparator<? super T> c) {

  int n = arr.length;

  for (int i = 0; i < n - 1; i++) {
    for (int j = 0; j < n - i - 1; j++) {

      if (c.compare(arr[j], arr[j + 1]) > 0) {
        T temp = arr[j];
        arr[j] = arr[j + 1];
        arr[j + 1] = temp;
      }
    }
  }
}

还记得以前的类吗?好吧,我们可以通过实现Comparator接口为它写一个Comparator

public class MelonComparator implements Comparator<Melon> {

  @Override
  public int compare(Melon o1, Melon o2) {
    return o1.getType().compareTo(o2.getType());
  }
}

或者,在 Java8 函数式风格中,我们有以下内容:

// Ascending
Comparator<Melon> byType = Comparator.comparing(Melon::getType);

// Descending
Comparator<Melon> byType 
  = Comparator.comparing(Melon::getType).reversed();

在一个名为ArraySorts的工具类中,有一个Melon数组、前面的Comparator数组和bubbleSortWithComparator()方法,我们可以按照下面的思路编写一些东西:

Melon[] melons = {...};
ArraySorts.bubbleSortWithComparator(melons, byType);

为简洁起见,跳过了带有Comparator的冒泡排序优化版本,但它在绑定到本书的代码中可用。

当数组几乎已排序时,冒泡排序速度很快。此外,它还非常适合对兔子(接近数组开头的大元素)和海龟(接近数组结尾的小元素)进行排序。但总的来说,这是一个缓慢的算法。

插入排序

插入排序算法依赖于一个简单的流。它从第二个元素开始,并将其与前面的元素进行比较。如果前面的元素大于当前元素,则算法将交换这些元素。此过程将继续,直到前面的元素小于当前元素。

在这种情况下,算法将传递到数组中的下一个元素并重复该流,如下图所示:

时间复杂度情况如下:最佳情况O(n)、平均情况O(n<sup>2</sup>)、最坏情况O(n<sup>2</sup>)

空间复杂度情况如下:最坏情况O(1)

基于此流程,原始类型的实现如下所示:

public static void insertionSort(int arr[]) {

  int n = arr.length;

  for (int i = 1; i < n; ++i) {

    int key = arr[i];
    int j = i - 1;

    while (j >= 0 && arr[j] > key) {
      arr[j + 1] = arr[j];
      j = j - 1;
    }

    arr[j + 1] = key;
  }
}

为了比较一个Melon数组,我们需要在实现中引入一个Comparator,如下所示:

public static <T> void insertionSortWithComparator(
  T arr[], Comparator<? super T> c) {

  int n = arr.length;

  for (int i = 1; i < n; ++i) {

    T key = arr[i];
    int j = i - 1;

    while (j >= 0 && c.compare(arr[j], key) > 0) {
      arr[j + 1] = arr[j];
      j = j - 1;
    }

    arr[j + 1] = key;
  }
}

在这里,我们有一个Comparator,它使用thenComparing()方法,按照 Java8 函数式编写的类型和重量对西瓜进行排序:

Comparator<Melon> byType = Comparator.comparing(Melon::getType)
  .thenComparing(Melon::getWeight);

在一个名为ArraySorts的实用类中,有一个Melon数组、前面的Comparator数组和insertionSortWithComparator()方法,我们可以编写如下内容:

Melon[] melons = {...};
ArraySorts.insertionSortWithComparator(melons, byType);

对于较小且大部分排序的数组,这可能会很快。此外,在向数组中添加新元素时,它的性能也很好。它也是非常有效的内存,因为一个单一的元素是移动。

计数排序

计数排序流从计算数组中的最小和最大元素开始。该算法根据计算出的最小值和最大值定义一个新的数组,该数组将使用元素作为索引对未排序的元素进行计数。此外,以这样的方式修改这个新数组,使得每个索引处的每个元素存储先前计数的总和。最后,从这个新的数组中得到排序后的数组。

时间复杂度情况如下:最佳情况O(n + k)、平均情况O(n + k)、最坏情况O(n + k)

空间复杂度情况如下:最坏情况O(k)

k is the number of possible values in the range.
n is the number of elements to be sorted.

让我们考虑一个简单的例子。初始数组包含以下元素,arr4262685

最小元件为2,最大元件为8。新数组counts的大小等于最大值减去最小值+1=8-2+1=7

对每个元素进行计数将产生以下数组(counts[arr[i] - min]++):

counts[2] = 1 (4); counts[0] = 2 (2); counts[4] = 2 (6);
counts[6] = 1 (8); counts[3] = 1 (5);

现在,我们必须循环此数组,并使用它重建排序后的数组,如下所示:

public static void countingSort(int[] arr) {

  int min = arr[0];
  int max = arr[0];

  for (int i = 1; i < arr.length; i++) {
    if (arr[i] < min) {
      min = arr[i];
    } else if (arr[i] > max) {
      max = arr[i];
    }
  }

  int[] counts = new int[max - min + 1];

  for (int i = 0; i < arr.length; i++) {
    counts[arr[i] - min]++;
  }

  int sortedIndex = 0;

  for (int i = 0; i < counts.length; i++) {
    while (counts[i] > 0) {
      arr[sortedIndex++] = i + min;
      counts[i]--;
    }
  }
}

这是一个非常快速的算法。

堆排序

堆排序是一种依赖于二进制堆(完全二叉树)的算法。

时间复杂度情况如下:最佳情况O(n log n)、平均情况O(n log n)、最坏情况O(n log n)

空间复杂度情况如下:最坏情况O(1)

可以通过最大堆(父节点总是大于或等于子节点)按升序排序元素,通过最小堆(父节点总是小于或等于子节点)按降序排序元素。

在第一步,该算法使用提供的数组来构建这个堆,并将其转换为一个最大堆(该堆由另一个数组表示)。因为这是一个最大堆,所以最大的元素是堆的根。在下一步中,根与堆中的最后一个元素交换,堆大小减少 1(从堆中删除最后一个节点)。堆顶部的元素按顺序排列。最后一步由建堆(以自顶向下的方式构建堆的递归过程)和堆的根(重构最大堆)组成。重复这三个步骤,直到堆大小大于 1:

例如,假设上图中的数组-45271

  1. 因此,在第一步,我们构建堆:45271
  2. 我们构建了最大堆75241(我们将544757进行了交换)。
  3. 接下来,我们将根(7)与最后一个元素(1)交换并删除7。结果:15247
  4. 进一步,我们再次构建最大堆5421(我们将51进行了交换,将14进行了交换)。
  5. 我们将根(5)与最后一个元素(1)交换,并删除5。结果:14257
  6. 接下来,我们再次构建最大堆412(我们将14进行了交换)。
  7. 我们将根(4)与最后一个元素(2)交换,并删除4。结果:21
  8. 这是一个最大堆,因此将根(2)与最后一个元素(1)交换并移除212457
  9. 完成!堆中只剩下一个元素(1)。

在代码行中,前面的示例可以概括如下:

public static void heapSort(int[] arr) {
  int n = arr.length;

  buildHeap(arr, n);

  while (n > 1) {
    swap(arr, 0, n - 1);
    n--;
    heapify(arr, n, 0);
  }
}

private static void buildHeap(int[] arr, int n) {
  for (int i = arr.length / 2; i >= 0; i--) {
    heapify(arr, n, i);
  }
}

private static void heapify(int[] arr, int n, int i) {
  int left = i * 2 + 1;
  int right = i * 2 + 2;
  int greater;

  if (left < n && arr[left] > arr[i]) {
    greater = left;
  } else {
    greater = i;
  }

  if (right < n && arr[right] > arr[greater]) {
    greater = right;
  }

  if (greater != i) {
    swap(arr, i, greater);
    heapify(arr, n, greater);
  }
}

private static void swap(int[] arr, int x, int y) {
  int temp = arr[x];
  arr[x] = arr[y];
  arr[y] = temp;
}

如果我们想要比较对象,那么我们必须在实现中引入一个Comparator。此解决方案在捆绑到本书的代码中以heapSortWithComparator()的名称提供。

这里是一个用 Java8 函数式编写的Comparator,它使用thenComparing()reversed()方法按类型和重量降序排列瓜类:

Comparator<Melon> byType = Comparator.comparing(Melon::getType)
  .thenComparing(Melon::getWeight).reversed();                                                                                                                                                                                                                                                                                                                        

在一个名为ArraySorts的实用类中,有一个Melon数组、前面的Comparator数组和heapSortWithComparator()方法,我们可以编写如下内容:

Melon[] melons = {...};
ArraySorts.heapSortWithComparator(melons, byType);

堆排序相当快,但不稳定。例如,对已排序的数组进行排序可能会使其保持不同的顺序。

我们将在这里停止关于排序数组的论文,但是,在本书附带的代码中,还有一些排序算法可用:

还有许多其他算法专门用于排序数组。其中一些是建立在这里介绍的基础上的(例如,梳排序、鸡尾酒排序和奇偶排序是冒泡排序的风格,桶排序是通常依赖于插入排序的分布排序,基数排序(LSD)是类似于桶排序的稳定分布,Gnome 排序是插入排序的变体)。

其他则是不同的方法(例如,Arrays.sort()方法实现的快速排序,Arrays.parallelSort()方法实现的合并排序)。

作为对本节的奖励,让我们看看如何洗牌一个数组。实现这一点的有效方法依赖于 Fisher-Yates 洗牌(称为 Knuth 洗牌)。基本上,我们以相反的顺序循环数组,然后随机交换元素。对于原始类型(例如,int),实现如下:

public static void shuffleInt(int[] arr) {

  int index;

  Random random = new Random();

  for (int i = arr.length - 1; i > 0; i--) {

    index = random.nextInt(i + 1);
    swap(arr, index, i);
  }
}

在绑定到本书的代码中,还有一个实现,用于对Object的数组进行洗牌。

通过Collections.shuffle(List<?> list)洗牌列表非常简单。

100 在数组中查找元素

当我们在数组中搜索一个元素时,我们可能感兴趣的是找出这个元素出现的索引,或者只找出它是否存在于数组中。本节介绍的解决方案具体化为以下屏幕截图中的方法:

让我们在下一节中看看不同的解决方案。

只检查是否存在

假设以下整数数组:

int[] numbers = {4, 5, 1, 3, 7, 4, 1};

由于这是一个原始类型数组,解决方案可以简单地循环数组并返回给定整数的第一个匹配项,如下所示:

public static boolean containsElement(int[] arr, int toContain) {

  for (int elem: arr) {
    if (elem == toContain) {
      return true;
    }
  }

  return false;
}

这个问题的另一个解决方法可以依赖于Arrays.binarySearch()方法。这种方法有几种风格,但在这种情况下,我们需要这个:int binarySearch​(int[] a, int key)。该方法将搜索给定数组中的给定键,并返回相应的索引或负值。唯一的问题是,此方法仅适用于已排序的数组;因此,我们需要事先对数组排序:

public static boolean containsElement(int[] arr, int toContain) {

  Arrays.sort(arr);
  int index = Arrays.binarySearch(arr, toContain);

  return (index >= 0);
}

如果数组已经排序,那么可以通过删除排序步骤来优化前面的方法。此外,如果数组被排序,前面的方法可以返回数组中元素出现的索引,而不是一个boolean。但是,如果数组没有排序,请记住返回的索引对应于排序的数组,而不是未排序的(初始)数组。如果不想对初始数组进行排序,则建议将数组的克隆传递给此方法。另一种方法是在这个辅助方法中克隆数组。

在 Java8 中,解决方案可以依赖于函数式方法。这里一个很好的候选者是anyMatch()方法。此方法返回流中是否有元素与提供的谓词匹配。因此,我们需要做的就是将数组转换为流,如下所示:

public static boolean containsElement(int[] arr, int toContain) {

  return Arrays.stream(arr)
    .anyMatch(e -> e == toContain);
}

对于任何其他原始类型,改编或概括前面的示例都非常简单。

现在,让我们集中精力在数组中寻找Object。让我们考虑一下Melon类:

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals() and hashCode() skipped for brevity
}

接下来,让我们考虑一个Melon数组:

Melon[] melons = new Melon[] {new Melon("Crenshaw", 2000),
  new Melon("Gac", 1200), new Melon("Bitter", 2200)
};

现在,假设我们要在这个数组中找到 1200 克的木瓜。一个解决方案可以依赖于equals()方法,该方法用于确定两个对象的相等性:

public static <T> boolean 
    containsElementObject(T[] arr, T toContain) {

  for (T elem: arr) {
    if (elem.equals(toContain)) {
      return true;
    }
  }

  return false;
}

同样,我们可以依赖于Arrays.asList(arr).contains(find)。基本上,将数组转换为一个List并调用contains()方法。在幕后,这种方法使用的是equals()合同。

如果此方法存在于名为ArraySearch的工具类中,则以下调用将返回true

// true
boolean found = ArraySearch.containsElementObject(
  melons, new Melon("Gac", 1200));

只要我们想依赖equals()合同,这个解决方案就行。但是我们可以认为,如果甜瓜的名字出现(Gac),或者它的重量出现(1200),那么我们的甜瓜就存在于数组中。对于这种情况,更实际的做法是依赖于Comparator

public static <T> boolean containsElementObject(
    T[] arr, T toContain, Comparator<? super T> c) {

  for (T elem: arr) {
    if (c.compare(elem, toContain) == 0) {
      return true;
    }
  }

  return false;
}

现在,一个只考虑瓜的类型的Comparator可以写为:

Comparator<Melon> byType = Comparator.comparing(Melon::getType);

由于Comparator忽略了瓜的重量(没有 1205 克的瓜),下面的调用将返回true

// true
boolean found = ArraySearch.containsElementObject(
  melons, new Melon("Gac", 1205), byType);

另一种方法依赖于binarySearch()的另一种风格。Arrays类提供了一个binarySearch()方法,该方法获取一个Comparator<T> int binarySearch(T[] a, T key, Comparator<? super T> c)。这意味着我们可以如下使用它:

public static <T> boolean containsElementObject(
    T[] arr, T toContain, Comparator<? super T> c) {

  Arrays.sort(arr, c);
  int index = Arrays.binarySearch(arr, toContain, c);

  return (index >= 0);
}

如果初始数组状态应保持不变,则建议将数组的克隆传递给此方法。另一种方法是在这个辅助方法中克隆数组。

现在,一个只考虑瓜重的Comparator可以写为:

Comparator<Melon> byWeight = Comparator.comparing(Melon::getWeight);

由于Comparator忽略了甜瓜的类型(没有蜜瓜类型的甜瓜),下面的调用将返回true

// true
boolean found = ArraySearch.containsElementObject(
  melons, new Melon("Honeydew", 1200), byWeight);

只检查第一个索引

对于一组原始类型,最简单的实现就说明了这一点:

public static int findIndexOfElement(int[] arr, int toFind) {

  for (int i = 0; i < arr.length; i++) {
    if (arr[i] == toFind) {
      return i;
    }
  }

  return -1;
}

依靠 Java8 函数风格,我们可以尝试循环数组并过滤与给定元素匹配的元素。最后,只需返回找到的第一个元素:

public static int findIndexOfElement(int[] arr, int toFind) {

  return IntStream.range(0, arr.length)
    .filter(i -> toFind == arr[i])
    .findFirst()
    .orElse(-1);
}

对于Object数组,至少有三种方法。首先,我们可以依据equals()合同:

public static <T> int findIndexOfElementObject(T[] arr, T toFind) {

  for (int i = 0; i < arr.length; i++) {
    if (arr[i].equals(toFind)) {
      return i;
    }
  }

  return -1;
}

同样,我们可以依赖于Arrays.asList(arr).indexOf(find)。基本上,将数组转换为一个List并调用indexOf()方法。在幕后,这种方法使用的是equals()合同。

其次,我们可以依赖于Comparator

public static <T> int findIndexOfElementObject(
    T[] arr, T toFind, Comparator<? super T> c) {

  for (int i = 0; i < arr.length; i++) {
    if (c.compare(arr[i], toFind) == 0) {
      return i;
    }
  }

  return -1;
}

第三,我们可以依赖 Java8 函数式风格和一个Comparator

public static <T> int findIndexOfElementObject(
    T[] arr, T toFind, Comparator<? super T> c) {

  return IntStream.range(0, arr.length)
    .filter(i -> c.compare(toFind, arr[i]) == 0)
    .findFirst()
    .orElse(-1);
}

101 检查两个数组是否相等或不匹配

如果两个原始数组包含相同数量的元素,则它们相等,并且两个数组中所有对应的元素对都相等

这两个问题的解决依赖于Arrays实用类。下面几节给出了解决这些问题的方法。

检查两个数组是否相等

通过Arrays.equals()方法可以很容易地检查两个数组是否相等。对于基本类型、Object和泛型,这个标志方法有很多种风格。它还支持比较器。

让我们考虑以下三个整数数组:

int[] integers1 = {3, 4, 5, 6, 1, 5};
int[] integers2 = {3, 4, 5, 6, 1, 5};
int[] integers3 = {3, 4, 5, 6, 1, 3};

现在,让我们检查一下integers1是否等于integers2,以及integers1是否等于integers3。这很简单:

boolean i12 = Arrays.equals(integers1, integers2); // true
boolean i13 = Arrays.equals(integers1, integers3); // false

前面的例子检查两个数组是否相等,但是我们也可以通过boolean equals(int[] a, int aFromIndex, int aToIndex, int[] b, int bFromIndex, int bToIndex)方法检查数组的两个段(或范围)是否相等。因此,我们通过范围[aFromIndex, aToIndex)来划分第一个数组的段,通过范围[bFromIndex, bToIndex)来划分第二个数组的段:

// true
boolean is13 = Arrays.equals(integers1, 1, 4, integers3, 1, 4);

现在,让我们假设Melon的三个数组:

public class Melon {

  private final String type;
  private final int weight;

  public Melon(String type, int weight) {
    this.type = type;
    this.weight = weight;
  }

  // getters, equals() and hashCode() omitted for brevity
}

Melon[] melons1 = {
  new Melon("Horned", 1500), new Melon("Gac", 1000)
};

Melon[] melons2 = {
  new Melon("Horned", 1500), new Melon("Gac", 1000)
};

Melon[] melons3 = {
  new Melon("Hami", 1500), new Melon("Gac", 1000)
};

基于equals()合同或基于指定的Comparator,两个Object数组被视为相等。我们可以很容易地检查melons1是否等于melons2,以及melons1是否等于melons3,如下所示:

boolean m12 = Arrays.equals(melons1, melons2); // true
boolean m13 = Arrays.equals(melons1, melons3); // false

在明确的范围内,使用boolean equals(Object[] a, int aFromIndex, int aToIndex, Object[] b, int bFromIndex, int bToIndex)

boolean ms13 = Arrays.equals(melons1, 1, 2, melons3, 1, 2); // false

虽然这些示例依赖于Melon.equals()实现,但以下两个示例依赖于以下两个Comparator

Comparator<Melon> byType = Comparator.comparing(Melon::getType);
Comparator<Melon> byWeight = Comparator.comparing(Melon::getWeight);

使用布尔值equals(T[] a, T[] a2, Comparator<? super T> cmp),我们得到以下结果:

boolean mw13 = Arrays.equals(melons1, melons3, byWeight); // true
boolean mt13 = Arrays.equals(melons1, melons3, byType);   // false

并且,在显式范围内,使用Comparator<T> boolean equals(T[] a, int aFromIndex, int aToIndex, T[] b, int bFromIndex, int bToIndex, Comparator<? super T> cmp),我们得到:

// true
boolean mrt13 = Arrays.equals(melons1, 1, 2, melons3, 1, 2, byType);

检查两个数组是否包含不匹配项

如果两个数组相等,则不匹配应返回 -1。但是如果两个数组不相等,那么不匹配应该返回两个给定数组之间第一个不匹配的索引。为了解决这个问题,我们可以依赖 JDK9Arrays.mismatch()方法。

例如,我们可以检查integers1integers2之间的不匹配,如下所示:

int mi12 = Arrays.mismatch(integers1, integers2); // -1

结果是 -1,因为integers1integers2相等。但是如果我们检查integers1integers3,我们会得到值 5,这是这两个值之间第一个不匹配的索引:

int mi13 = Arrays.mismatch(integers1, integers3); // 5

如果给定的数组有不同的长度,而较小的数组是较大数组的前缀,那么返回的不匹配就是较小数组的长度。

对于Object的数组,也有专用的mismatch()方法。这些方法依赖于equals()合同或给定的Comparator。我们可以检查melons1melons2之间是否存在不匹配,如下所示:

int mm12 = Arrays.mismatch(melons1, melons2); // -1

如果第一个索引发生不匹配,则返回值为 0。这在melons1melons3的情况下发生:

int mm13 = Arrays.mismatch(melons1, melons3); // 0

Arrays.equals()的情况下,我们可以使用Comparator检查显式范围内的不匹配:

// range [1, 2), return -1
int mms13 = Arrays.mismatch(melons1, 1, 2, melons3, 1, 2);

// Comparator by melon's weights, return -1
int mmw13 = Arrays.mismatch(melons1, melons3, byWeight);

// Comparator by melon's types, return 0
int mmt13 = Arrays.mismatch(melons1, melons3, byType);

// range [1,2) and Comparator by melon's types, return -1
int mmrt13 = Arrays.mismatch(melons1, 1, 2, melons3, 1, 2, byType);

102 按字典顺序比较两个数组

从 JDK9 开始,我们可以通过Arrays.compare()方法按字典顺序比较两个数组。既然不需要重新发明轮子,那么就升级到 JDK9,让我们深入研究一下。

两个数组的词典比较可能返回以下结果:

  • 0,如果给定数组相等并且包含相同顺序的相同元素
  • 如果第一个数组按字典顺序小于第二个数组,则值小于 0
  • 如果第一个数组按字典顺序大于第二个数组,则该值大于 0

如果第一个数组的长度小于第二个数组的长度,则第一个数组在词典上小于第二个数组。如果数组具有相同的长度,包含原始类型,并且共享一个共同的前缀,那么字典比较就是比较两个元素的结果,精确地说就是Integer.compare(int, int)Boolean.compare(boolean, boolean)Byte.compare(byte, byte)等等。如果数组包含Object,那么字典比较依赖于给定的ComparatorComparable实现。

首先,让我们考虑以下原始类型数组:

int[] integers1 = {3, 4, 5, 6, 1, 5};
int[] integers2 = {3, 4, 5, 6, 1, 5};
int[] integers3 = {3, 4, 5, 6, 1, 3};

现在,integers1在词典上等于integers2,因为它们相等并且包含相同顺序的相同元素,int compare(int[] a, int[] b)

int i12 = Arrays.compare(integers1, integers2); // 0

但是,integers1在字典上大于integers3,因为它们共享相同的前缀(3,4,5,6,1),但是对于最后一个元素,Integer.compare(5,3)返回一个大于 0 的值,因为 5 大于 3:

int i13 = Arrays.compare(integers1, integers3); // 1

可以在不同的数组范围内进行词典比较。例如,下面的示例通过int compare(int[] a, int aFromIndex, int aToIndex, int[] b, int bFromIndex, int bToIndex)方法比较范围[3, 6]中的integers1integers3

int is13 = Arrays.compare(integers1, 3, 6, integers3, 3, 6); // 1

对于Object的数组,Arrays类还提供了一组专用的compare()方法。还记得Melon类吗?好吧,为了比较两个没有显式ComparatorMelon数组,我们需要实现Comparable接口和compareTo()方法。假设我们依赖于瓜的重量,如下所示:

public class Melon implements Comparable {

  private final String type;
  private final int weight;

  @Override
  public int compareTo(Object o) {
    Melon m = (Melon) o;

    return Integer.compare(this.getWeight(), m.getWeight());
  }

  // constructor, getters, equals() and hashCode() omitted for brevity
}

注意,Object数组的词典比较不依赖于equals()。它需要显式的ComparatorComparable元素。

假设Melon的以下数组:

Melon[] melons1 = {new Melon("Horned", 1500), new Melon("Gac", 1000)};
Melon[] melons2 = {new Melon("Horned", 1500), new Melon("Gac", 1000)};
Melon[] melons3 = {new Melon("Hami", 1600), new Melon("Gac", 800)};

让我们通过<T extends Comparable<? super T>> int compare(T[] a, T[] b)melons1melons2进行词汇对比:

int m12 = Arrays.compare(melons1, melons2); // 0

因为melons1melons2是相同的,所以结果是 0。

现在,让我们对melons1melons3做同样的事情。这一次,结果将是否定的,这意味着在词典中,melons1小于melons3。这是真的,因为在指数 0 时,角瓜的重量是 1500 克,比哈密瓜的重量要轻,哈密瓜的重量是 1600 克:

int m13 = Arrays.compare(melons1, melons3); // -1

我们可以通过<T extends Comparable<? super T>> int compare(T[] a, int aFromIndex, int aToIndex, T[] b, int bFromIndex, int bToIndex)方法在数组的不同范围内进行比较。例如,在公共范围[1, 2]中,melons1在字典上大于melons2,因为 Gac 的重量在melons1中为 1000g,在melons3中为 800g:

int ms13 = Arrays.compare(melons1, 1, 2, melons3, 1, 2); // 1

如果我们不想依赖Comparable元素(实现Comparable,我们可以通过<T> int compare(T[] a, T[] b, Comparator<? super T> cmp)方法传入一个Comparator

Comparator<Melon> byType = Comparator.comparing(Melon::getType);
int mt13 = Arrays.compare(melons1, melons3, byType); // 14

也可以通过<T> int compare(T[] a, int aFromIndex, int aToIndex, T[] b, int bFromIndex, int bToIndex, Comparator<? super T> cmp)使用范围:

int mrt13 = Arrays.compare(melons1, 1, 2, melons3, 1, 2, byType); // 0

如果数字数组应该被无符号处理,那么依赖于一堆Arrays.compareUnsigned​()方法,这些方法可用于byteshortintlong

根据String.compareTo()int compareTo(String anotherString)按字典顺序比较两个字符串。

103 从数组创建流

一旦我们从一个数组中创建了一个Stream,我们就可以访问所有流 API。因此,这是一个方便的操作,这是很重要的,在我们的工具带。

让我们从字符串数组开始(也可以是其他对象):

String[] arr = {"One", "Two", "Three", "Four", "Five"};

从这个String[]数组创建Stream最简单的方法是依赖于从 JDK8 开始的Arrays.stream()方法:

Stream<String> stream = Arrays.stream(arr);

或者,如果我们需要来自子数组的流,那么只需添加范围作为参数。例如,让我们从(0, 2)之间的元素创建一个Stream,即 1 到 2:

Stream<String> stream = Arrays.stream(arr, 0, 2);

同样的情况,但通过一个List可以写为:

Stream<String> stream = Arrays.asList(arr).stream();
Stream<String> stream = Arrays.asList(arr).subList(0, 2).stream();

另一种解决方案依赖于Stream.of()方法,如以下简单示例所示:

Stream<String> stream = Stream.of(arr);
Stream<String> stream = Stream.of("One", "Two", "Three");

Stream创建数组可以通过Stream.toArray()方法完成。例如,一个简单的方法如下所示:

String[] array = stream.toArray(String[]::new);

另外,让我们考虑一个原始数组:

int[] integers = {2, 3, 4, 1};

在这种情况下,Arrays.stream()方法可以再次提供帮助,唯一的区别是返回的结果是IntStream类型(这是Streamint原始类型特化):

IntStream intStream = Arrays.stream(integers);

但是IntStream类还提供了一个of()方法,可以如下使用:

IntStream intStream = IntStream.of(integers);

有时,我们需要定义一个增量步长为 1 的有序整数的Stream。此外,Stream的大小应该等于数组的大小。特别是对于这种情况,IntStream方法提供了两种方法range(int inclusive, int exclusive)rangeClosed(int startInclusive, int endInclusive)

IntStream intStream = IntStream.range(0, integers.length);
IntStream intStream = IntStream.rangeClosed(0, integers.length);

从整数的Stream创建数组可以通过Stream.toArray()方法完成。例如,一个简单的方法如下所示:

int[] intArray = intStream.toArray();

// for boxed integers
int[] intArray = intStream.mapToInt(i -> i).toArray();

除了流的IntStream特化之外,JDK8 还提供longLongStream)和doubleDoubleStream)的特化。

104 数组的最小值、最大值和平均值

计算数组的最小值、最大值和平均值是一项常见的任务。让我们看看在函数式和命令式编程中解决这个问题的几种方法。

计算最大值和最小值

计算数字数组的最大值可以通过循环数组并通过与数组的每个元素进行比较来跟踪最大值来实现。就代码行而言,可以编写如下:

public static int max(int[] arr) {

  int max = arr[0];

  for (int elem: arr) {
    if (elem > max) {
      max = elem;
    }
  }

  return max;
}

在可读性方面,可能需要使用Math.max()方法而不是if语句:

...
max = Math.max(max, elem);
...

假设我们有以下整数数组和一个名为MathArrays的工具类,其中包含前面的方法:

int[] integers = {2, 3, 4, 1, -4, 6, 2};

该数组的最大值可以容易地获得如下:

int maxInt = MathArrays.max(integers); // 6

在 Java8 函数式风格中,此问题的解决方案需要一行代码:

int maxInt = Arrays.stream(integers).max().getAsInt();

在函数式方法中,max()方法返回一个OptionalInt。同样,我们有OptionalLongOptionalDouble

此外,我们假设一个对象数组,在本例中是一个Melon数组:

Melon[] melons = {
  new Melon("Horned", 1500), new Melon("Gac", 2200),
  new Melon("Hami", 1600), new Melon("Gac", 2100)
};

public class Melon implements Comparable {

  private final String type;
  private final int weight;

  @Override
  public int compareTo(Object o) {
    Melon m = (Melon) o;

    return Integer.compare(this.getWeight(), m.getWeight());
  }

  // constructor, getters, equals() and hashCode() omitted for brevity
}

很明显,我们前面定义的max()方法不能用于这种情况,但逻辑原理保持不变。这一次,实现应该依赖于ComparableComparator。基于Comparable的实现可以如下:

public static <T extends Comparable<T>> T max(T[] arr) {

  T max = arr[0];

  for (T elem : arr) {
    if (elem.compareTo(max) > 0) {
      max = elem;
   }
  }

  return max;
}

检查Melon.compareTo()方法,注意我们的实现将比较瓜的重量。因此,我们可以很容易地从我们的数组中找到最重的瓜,如下所示:

Melon maxMelon = MathArrays.max(melons); // Gac(2200g)

依赖于Comparator的实现可以写为:

public static <T> T max(T[] arr, Comparator<? super T> c) {

  T max = arr[0];

  for (T elem: arr) {
    if (c.compare(elem, max) > 0) {
      max = elem;
    }
  }

  return max;
}

并且,如果我们根据甜瓜的类型定义一个Comparator,我们有以下结果:

Comparator<Melon> byType = Comparator.comparing(Melon::getType);

然后,我们得到与字符串的词典比较相一致的最大值:

Melon maxMelon = MathArrays.max(melons, byType); // Horned(1500g)

在 Java8 函数式风格中,此问题的解决方案需要一行代码:

Melon maxMelon = Arrays.stream(melons).max(byType).orElseThrow();

计算平均值

计算一组数字(在本例中为整数)的平均值可以通过两个简单的步骤实现:

  1. 计算数组中元素的和。
  2. 将此总和除以数组的长度。

在代码行中,我们有以下内容:

public static double average(int[] arr) {

  return sum(arr) / arr.length;
}

public static double sum(int[] arr) {

  double sum = 0;

  for (int elem: arr) {
    sum += elem;
  }

  return sum;
}

整数数组的平均值为 2.0:

double avg = MathArrays.average(integers);

在 Java8 函数式风格中,此问题的解决方案需要一行代码:

double avg = Arrays.stream(integers).average().getAsDouble();

对于第三方库支持,请考虑 Apache Common Lang(ArrayUtil)和 Guava 的CharsIntsLongs以及其他类。

105 反转数组

这个问题有几种解决办法。它们中的一些改变了初始数组,而另一些只是返回一个新数组

假设以下整数数组:

int[] integers = {-1, 2, 3, 1, 4, 5, 3, 2, 22};

让我们从一个简单的实现开始,它将数组的第一个元素与最后一个元素交换,第二个元素与倒数第二个元素交换,依此类推:

public static void reverse(int[] arr) {

  for (int leftHead = 0, rightHead = arr.length - 1; 
      leftHead < rightHead; leftHead++, rightHead--) {

    int elem = arr[leftHead];
    arr[leftHead] = arr[rightHead];
    arr[rightHead] = elem;
  }
}

前面的解决方案改变了给定的数组,这并不总是期望的行为。当然,我们可以修改它以返回一个新的数组,也可以依赖 Java8 函数样式,如下所示:

// 22, 2, 3, 5, 4, 1, 3, 2, -1
int[] reversed = IntStream.rangeClosed(1, integers.length)
  .map(i -> integers[integers.length - i]).toArray();

现在,让我们反转一个对象数组。为此,让我们考虑一下Melon类:

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode() omitted for brevity
}

另外,让我们考虑一个Melon数组:

Melon[] melons = {
  new Melon("Crenshaw", 2000), 
  new Melon("Gac", 1200),
  new Melon("Bitter", 2200)
};

第一种解决方案需要使用泛型来塑造实现,该实现将数组的第一个元素与最后一个元素交换,将第二个元素与最后一个元素交换,依此类推:

public static <T> void reverse(T[] arr) {

  for (int leftHead = 0, rightHead = arr.length - 1; 
      leftHead < rightHead; leftHead++, rightHead--) {

    T elem = arr[leftHead];
    arr[leftHead] = arr[rightHead];
    arr[rightHead] = elem;
  }
}

因为我们的数组包含对象,所以我们也可以依赖于Collections.reverse()。我们只需要通过Arrays.asList()方法将数组转换成List

// Bitter(2200g), Gac(1200g), Crenshaw(2000g)
Collections.reverse(Arrays.asList(melons));

前面的两个解决方案改变了数组的元素。Java8 函数式风格可以帮助我们避免这种变异:

// Bitter(2200g), Gac(1200g), Crenshaw(2000g)
Melon[] reversed = IntStream.rangeClosed(1, melons.length)
  .mapToObj(i -> melons[melons.length - i])
  .toArray(Melon[]:new);

对于第三方库支持,请考虑 Apache Common Lang(ArrayUtils.reverse()和 Guava 的Lists类。

106 填充和设置数组

有时,我们需要用一个固定值填充数组。例如,我们可能希望用值1填充整数数组。实现这一点的最简单方法依赖于一个for语句,如下所示:

int[] arr = new int[10];

// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
for (int i = 0; i < arr.length; i++) {
  arr[i] = 1;
}

但我们可以通过Arrays.fill()方法将此代码简化为一行代码。对于基本体和对象,此方法有不同的风格。前面的代码可以通过Arrays.fill(int[] a, int val)重写如下:

// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
Arrays.fill(arr, 1);

Arrays.fill() also come with flavors for filling up just a segment/range of an array. For integers, this method is fill​(int[] a, int fromIndexInclusive, int toIndexExclusive, int val).

现在,应用一个生成函数来计算数组的每个元素怎么样?例如,假设我们要将每个元素计算为前一个元素加 1。最简单的方法将再次依赖于for语句,如下所示:

// 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
for (int i = 1; i < arr.length; i++) {
  arr[i] = arr[i - 1] + 1;
}

根据需要应用于每个元素的计算,必须相应地修改前面的代码。

对于这样的任务,JDK8 附带了一系列的Arrays.setAll()Arrays.parallelSetAll()方法。例如,前面的代码片段可以通过setAll​(int[] array, IntUnaryOperator generator)重写如下:

// 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
Arrays.setAll(arr, t -> {
  if (t == 0) {
    return arr[t];
  } else {
    return arr[t - 1] + 1;
  }
});

除此之外,我们还有setAll​(double[] array, IntToDoubleFunction generator)setAll​(long[] array, IntToLongFunction generator)setAll​(T[] array, IntFunction<? extends T> generator)

根据生成器的功能,此任务可以并行完成,也可以不并行完成。例如,前面的生成器函数不能并行应用,因为每个元素都依赖于前面元素的值。尝试并行应用此生成器函数将导致不正确和不稳定的结果。

但是假设我们要取前面的数组(1,2,3,4,5,6,7,8,9,10),然后将每个偶数值乘以它本身,将每个奇数值减去 1。因为每个元素都可以单独计算,所以在这种情况下我们可以授权一个并行进程。这是Arrays.parallelSetAll()方法的完美工作。基本上,这些方法是用来并行化Arrays.setAll()方法的。

现在我们将parallelSetAll​(int[] array, IntUnaryOperator generator)应用于这个数组:

// 0, 4, 2, 16, 4, 36, 6, 64, 8, 100
Arrays.parallelSetAll(arr, t -> {
  if (arr[t] % 2 == 0) {
    return arr[t] * arr[t];
  } else {
    return arr[t] - 1;
  }
});

对于每个Arrays.setAll()方法,都有一个Arrays.parallelSetAll()方法。

作为奖励,Arrays附带了一组名为parallelPrefix()的方法。这些方法对于将数学函数应用于数组的元素(累积和并发)非常有用。

例如,如果我们要将数组中的每个元素计算为前面元素的和,那么我们可以如下所示:

// 0, 4, 6, 22, 26, 62, 68, 132, 140, 240
Arrays.parallelPrefix(arr, (t, q) -> t + q);

107 下一个更大的元素

NGE 是一个涉及数组的经典问题。

基本上,有一个数组和它的一个元素e,我们要获取下一个(右侧)大于e的元素。例如,假设以下数组:

int[] integers = {1, 2, 3, 4, 12, 2, 1, 4};

获取每个元素的 NGE 将产生以下对(-1 被解释为右侧的元素不大于当前元素):

1 : 2   2 : 3   3 : 4   4 : 12   12 : -1   2 : 4   1 : 4   4 : -1

这个问题的一个简单解决方案是循环每个元素的数组,直到找到一个更大的元素或者没有更多的元素要检查。如果我们只想在屏幕上打印对,那么我们可以编写一个简单的代码,如下所示:

public static void println(int[] arr) {

  int nge;
  int n = arr.length;

  for (int i = 0; i < n; i++) {
    nge = -1;
    for (int j = i + 1; j < n; j++) {
      if (arr[i] < arr[j]) {
        nge = arr[j];
        break;
      }
    }

    System.out.println(arr[i] + " : " + nge);
  }
}

另一个解决方案依赖于栈。主要是,我们在栈中推送元素,直到当前处理的元素大于栈中的顶部元素。当这种情况发生时,我们弹出那个元素。本书附带的代码中提供了解决方案。

108 更改数组大小

增加数组的大小并不简单。这是因为 Java 数组的大小是固定的,我们不能修改它们的大小。这个问题的解决方案需要创建一个具有所需大小的新数组,并将所有值从原始数组复制到这个数组中。这可以通过Arrays.copyOf()方法或System.arraycopy()(由Arrays.copyOf()内部使用)完成。

对于一个原始数组(例如,int),我们可以将数组的大小增加 1 后将值添加到数组中,如下所示:

public static int[] add(int[] arr, int item) {

  int[] newArr = Arrays.copyOf(arr, arr.length + 1);
  newArr[newArr.length - 1] = item;

  return newArr;
}

或者,我们可以删除最后一个值,如下所示:

public static int[] remove(int[] arr) {

  int[] newArr = Arrays.copyOf(arr, arr.length - 1);

  return newArr;
}

或者,我们可以按如下所示调整给定长度数组的大小:

public static int[] resize(int[] arr, int length) {

  int[] newArr = Arrays.copyOf(arr, arr.length + length);

  return newArr;
}

捆绑到本书中的代码还包含了System.arraycopy()备选方案。此外,它还包含泛型数组的实现。签名如下:

public static <T> T[] addObject(T[] arr, T item);
public static <T> T[] removeObject(T[] arr);
public static <T> T[] resize(T[] arr, int length);

在有利的背景下,让我们将一个相关的主题引入讨论:如何在 Java 中创建泛型数组。以下操作无效:

T[] arr = new T[arr_size]; // causes generic array creation error

有几种方法,但 Java 在copyOf(T[] original, int newLength)中使用以下代码:

// newType is original.getClass()
T[] copy = ((Object) newType == (Object) Object[].class) ?
  (T[]) new Object[newLength] :
  (T[]) Array.newInstance(newType.getComponentType(), newLength);

109 创建不可修改/不可变的集合

在 Java 中创建不可修改/不可变的集合可以很容易地通过Collections.unmodifiableFoo()方法(例如,unmodifiableList())完成,并且从 JDK9 开始,通过来自ListSetMap和其他接口的一组of()方法完成。

此外,我们将在一组示例中使用这些方法来获得不可修改/不可变的集合。主要目标是确定每个定义的集合是不可修改的还是不可变的。

在阅读本节之前,建议先阅读第 2 章、“对象、不变性和switch表达式”中有关不变性的问题。

好吧。对于原始类型来说,这非常简单。例如,我们可以创建一个不可变的整数List,如下所示:

private static final List<Integer> LIST 
  = Collections.unmodifiableList(Arrays.asList(1, 2, 3, 4, 5));

private static final List<Integer> LIST = List.of(1, 2, 3, 4, 5);

对于下一个示例,让我们考虑以下可变类:

public class MutableMelon {

  private String type;
  private int weight;

  // constructor omitted for brevity

  public void setType(String type) {
    this.type = type;
  }

  public void setWeight(int weight) {
    this.weight = weight;
  }

  // getters, equals() and hashCode() omitted for brevity
}

问题 1 (Collections.unmodifiableList())

让我们通过Collections.unmodifiableList()方法创建MutableMelon列表:

// Crenshaw(2000g), Gac(1200g)
private final MutableMelon melon1 
  = new MutableMelon("Crenshaw", 2000);
private final MutableMelon melon2 
  = new MutableMelon("Gac", 1200);

private final List<MutableMelon> list 
  = Collections.unmodifiableList(Arrays.asList(melon1, melon2));

那么,list是不可修改的还是不变的?答案是不可更改的。虽然增变器方法会抛出UnsupportedOperationException,但底层的melon1melon2是可变的。例如,我们把西瓜的重量设为0

melon1.setWeight(0);
melon2.setWeight(0);

现在,列表将显示以下西瓜(因此列表发生了变异):

Crenshaw(0g), Gac(0g)

问题 2 (Arrays.asList())

我们直接在Arrays.asList()中硬编码实例,创建MutableMelon列表:

private final List<MutableMelon> list 
  = Collections.unmodifiableList(Arrays.asList(
    new MutableMelon("Crenshaw", 2000), 
    new MutableMelon("Gac", 1200)));

那么,这个列表是不可修改的还是不变的?答案是不可更改的。当增变器方法抛出UnsupportedOperationException时,硬编码实例可以通过List.get()方法访问。一旦可以访问它们,它们就可以变异:

MutableMelon melon1 = list.get(0);
MutableMelon melon2 = list.get(1);

melon1.setWeight(0);
melon2.setWeight(0);

现在,列表将显示以下西瓜(因此列表发生了变异):

Crenshaw(0g), Gac(0g)

问题 3 (Collections.unmodifiableList()和静态块)

让我们通过Collections.unmodifiableList()方法和static块创建MutableMelon列表:

private static final List<MutableMelon> list;
static {
  final MutableMelon melon1 = new MutableMelon("Crenshaw", 2000);
  final MutableMelon melon2 = new MutableMelon("Gac", 1200);

  list = Collections.unmodifiableList(Arrays.asList(melon1, melon2));
}

那么,这个列表是不可修改的还是不变的?答案是不可更改的。虽然增变器方法会抛出UnsupportedOperationException,但是硬编码的实例仍然可以通过List.get()方法访问。一旦可以访问它们,它们就可以变异:

MutableMelon melon1l = list.get(0);
MutableMelon melon2l = list.get(1);

melon1l.setWeight(0);
melon2l.setWeight(0);

现在,列表将显示以下西瓜(因此列表发生了变异):

Crenshaw(0g), Gac(0g)

问题 4 (List.of())

让我们通过List.of()创建MutableMelon的列表:

private final MutableMelon melon1 
  = new MutableMelon("Crenshaw", 2000);
private final MutableMelon melon2 
  = new MutableMelon("Gac", 1200);

private final List<MutableMelon> list = List.of(melon1, melon2);

那么,这个列表是不可修改的还是不变的?答案是不可更改的。虽然增变器方法会抛出UnsupportedOperationException,但是硬编码的实例仍然可以通过List.get()方法访问。一旦可以访问它们,它们就可以变异:

MutableMelon melon1l = list.get(0);
MutableMelon melon2l = list.get(1);

melon1l.setWeight(0);
melon2l.setWeight(0);

现在,列表将显示以下西瓜(因此列表发生了变异):

Crenshaw(0g), Gac(0g)

对于下一个示例,让我们考虑以下不可变类:

public final class ImmutableMelon {

  private final String type;
  private final int weight;

  // constructor, getters, equals() and hashCode() omitted for brevity
}

问题 5(不可变)

现在我们通过Collections.unmodifiableList()List.of()方法创建ImmutableMelon列表:

private static final ImmutableMelon MELON_1 
  = new ImmutableMelon("Crenshaw", 2000);
private static final ImmutableMelon MELON_2 
  = new ImmutableMelon("Gac", 1200);

private static final List<ImmutableMelon> LIST 
  = Collections.unmodifiableList(Arrays.asList(MELON_1, MELON_2));
private static final List<ImmutableMelon> LIST 
  = List.of(MELON_1, MELON_2);

那么,这个列表是不可修改的还是不变的?答案是不变的。增变器方法会抛出UnsupportedOperationException,我们不能对ImmutableMelon的实例进行变异。

根据经验,如果集合是通过unmodifiableFoo()of()方法定义的,并且包含可变数据,则集合是不可修改的;如果集合是不可修改的,并且包含可变数据(包括原始类型),则集合是不可修改的。

需要注意的是,不可穿透的不变性应该考虑 Java 反射 API 和类似的 API,它们在操作代码时具有辅助功能。

对于第三方库支持,请考虑 Apache Common Collection、UnmodifiableList(和同伴)和 Guava 的ImmutableList(和同伴)。

Map的情况下,我们可以通过unmodifiableMap()Map.of()方法创建一个不可修改/不可修改的Map

但我们也可以通过Collections.emptyMap()创建一个不可变的空Map

Map<Integer, MutableMelon> emptyMap = Collections.emptyMap();

emptyMap()类似,我们有Collections.emptyList()Collections.emptySet()。在返回一个MapListSet的方法中,这些方法作为返回非常方便,我们希望避免返回null

或者,我们可以通过Collections.singletonMap(K key, V value)用单个元素创建一个不可修改/不可变的Map

// unmodifiable
Map<Integer, MutableMelon> mapOfSingleMelon 
  = Collections.singletonMap(1, new MutableMelon("Gac", 1200));

// immutable
Map<Integer, ImmutableMelon> mapOfSingleMelon 
  = Collections.singletonMap(1, new ImmutableMelon("Gac", 1200));

类似于singletonMap(),我们有singletonList()singleton()。后者用于Set

此外,从 JDK9 开始,我们可以通过一个名为ofEntries()的方法创建一个不可修改的Map。此方法以Map.Entry为参数,如下例所示:

// unmodifiable Map.Entry containing the given key and value
import static java.util.Map.entry;
...
Map<Integer, MutableMelon> mapOfMelon = Map.ofEntries(
  entry(1, new MutableMelon("Apollo", 3000)),
  entry(2, new MutableMelon("Jade Dew", 3500)),
  entry(3, new MutableMelon("Cantaloupe", 1500))
);

或者,不可变的Map是另一种选择:

Map<Integer, ImmutableMelon> mapOfMelon = Map.ofEntries(
  entry(1, new ImmutableMelon("Apollo", 3000)),
  entry(2, new ImmutableMelon("Jade Dew", 3500)),
  entry(3, new ImmutableMelon("Cantaloupe", 1500))
);

另外,可以通过 JDK10 从可修改/可变的Map中获得不可修改/不可变的MapMap.copyOf​(Map<? extends K,​? extends V> map)方法:

Map<Integer, ImmutableMelon> mapOfMelon = new HashMap<>();
mapOfMelon.put(1, new ImmutableMelon("Apollo", 3000));
mapOfMelon.put(2, new ImmutableMelon("Jade Dew", 3500));
mapOfMelon.put(3, new ImmutableMelon("Cantaloupe", 1500));

Map<Integer, ImmutableMelon> immutableMapOfMelon 
  = Map.copyOf(mapOfMelon);

作为这一节的奖励,让我们来讨论一个不可变数组。

问题:我能用 Java 创建一个不可变数组吗?

答案:不可以。或者。。。有一种方法可以在 Java 中生成不可变数组:

static final String[] immutable = new String[0];

因此,Java 中所有有用的数组都是可变的。但是我们可以在Arrays.copyOf()的基础上创建一个辅助类来创建不可变数组,它复制元素并创建一个新数组(在幕后,这个方法依赖于System.arraycopy()

因此,我们的辅助类如下所示:

import java.util.Arrays;

public final class ImmutableArray<T> {

  private final T[] array;

  private ImmutableArray(T[] a) {
    array = Arrays.copyOf(a, a.length);
  }

  public static <T> ImmutableArray<T> from(T[] a) {
    return new ImmutableArray<>(a);
  }

  public T get(int index) {
    return array[index];
  }

  // equals(), hashCode() and toString() omitted for brevity
}

用法示例如下:

ImmutableArray<String> sample =
  ImmutableArray.from(new String[] {
    "a", "b", "c"
  });

110 映射的默认值

在 JDK8 之前,这个问题的解决方案依赖于辅助方法,它基本上检查Map中给定键的存在,并返回相应的值或默认值。这种方法可以在工具类中编写,也可以通过扩展Map接口来编写。通过返回默认值,我们可以避免在Map中找不到给定键时返回null。此外,这是依赖默认设置或配置的方便方法。

从 JDK8 开始,这个问题的解决方案包括简单地调用Map.getOrDefault()方法。此方法获取两个参数,分别表示要在Map方法中查找的键和默认值。当找不到给定的键时,默认值充当应该返回的备份值。

例如,假设下面的Map封装了多个数据库及其默认的host:port

Map<String, String> map = new HashMap<>();
map.put("postgresql", "127.0.0.1:5432");
map.put("mysql", "192.168.0.50:3306");
map.put("cassandra", "192.168.1.5:9042");

我们来看看这个Map是否也包含 Derby DB 的默认host:port

map.get("derby"); // null

由于映射中没有 Derby DB,因此结果将是null。这不是我们想要的。实际上,当搜索到的数据库不在映射上时,我们可以在69:89.31.226:27017上使用 MongoDB,它总是可用的。现在,我们可以很容易地将此行为塑造为:

// 69:89.31.226:27017
String hp1 = map.getOrDefault("derby", "69:89.31.226:27017");

// 192.168.0.50:3306
String hp2 = map.getOrDefault("mysql", "69:89.31.226:27017");

这种方法可以方便地建立流利的表达式,避免中断代码进行null检查。请注意,返回默认值并不意味着该值将被添加到MapMap保持不变。

111 计算映射中是否不存在/存在

有时,Map并不包含我们需要的准确的开箱即用条目。此外,当条目不存在时,返回默认条目也不是一个选项。基本上,有些情况下我们需要计算我们的入口。

对于这种情况,JDK8 提供了一系列方法:compute()computeIfAbsent()computeIfPresent()merge()。在这些方法之间进行选择是一个非常了解每种方法的问题。

现在让我们通过示例来看看这些方法的实现。

示例 1(computeIfPresent()

假设我们有以下Map

Map<String, String> map = new HashMap<>();
map.put("postgresql", "127.0.0.1");
map.put("mysql", "192.168.0.50");

我们使用这个映射为不同的数据库类型构建 JDBC URL。

假设我们要为 MySQL 构建 JDBC URL。如果映射中存在mysql键,则应根据相应的值jdbc:mysql://192.168.0.50/customers_db计算 JDBC URL。但是如果不存在mysql键,那么 JDBC URL 应该是null。除此之外,如果我们的计算结果是null(无法计算 JDBC URL),那么我们希望从映射中删除这个条目。

这是V computeIfPresent​(K key, BiFunction<? super K,​? super V,​? extends V> remappingFunction)的工作。

在我们的例子中,用于计算新值的BiFunction如下所示(k是映射中的键,v是与键关联的值):

BiFunction<String, String, String> jdbcUrl 
  = (k, v) -> "jdbc:" + k + "://" + v + "/customers_db";

一旦我们有了这个函数,我们就可以计算出mysql键的新值,如下所示:

// jdbc:mysql://192.168.0.50/customers_db
String mySqlJdbcUrl = map.computeIfPresent("mysql", jdbcUrl);

由于映射中存在mysql键,结果将是jdbc:mysql://192.168.0.50/customers_db,新映射包含以下条目:

postgresql=127.0.0.1, mysql=jdbc:mysql://192.168.0.50/customers_db

再次调用computeIfPresent()将重新计算值,这意味着它将导致类似mysql= jdbc:mysql://jdbc:mysql://....的结果。显然,这是不可以的,所以请注意这方面。

另一方面,如果我们对一个不存在的条目进行相同的计算(例如,voltdb),那么返回的值将是null,映射保持不变:

// null
String voldDbJdbcUrl = map.computeIfPresent("voltdb", jdbcUrl);

示例 2(computeIfAbsent()

假设我们有以下Map

Map<String, String> map = new HashMap<>();
map.put("postgresql", "jdbc:postgresql://127.0.0.1/customers_db");
map.put("mysql", "jdbc:mysql://192.168.0.50/customers_db");

我们使用这个映射为不同的数据库构建 JDBC URL。

假设我们要为 MongoDB 构建 JDBC URL。这一次,如果映射中存在mongodb键,则应返回相应的值,而无需进一步计算。但是如果这个键不存在(或者与一个null值相关联),那么它应该基于这个键和当前 IP 进行计算并添加到映射中。如果计算值为null,则返回结果为null,映射保持不变。

嗯,这是V computeIfAbsent​(K key, Function<? super K,​? extends V> mappingFunction)的工作。

在我们的例子中,用于计算值的Function将如下所示(第一个String是映射中的键(k),而第二个String是为该键计算的值):

String address = InetAddress.getLocalHost().getHostAddress();

Function<String, String> jdbcUrl 
  = k -> k + "://" + address + "/customers_db";

基于此函数,我们可以尝试通过mongodb键获取 MongoDB 的 JDBC URL,如下所示:

// mongodb://192.168.100.10/customers_db
String mongodbJdbcUrl = map.computeIfAbsent("mongodb", jdbcUrl);

因为我们的映射不包含mongodb键,它将被计算并添加到映射中。

如果我们的Function被求值为null,那么映射保持不变,返回值为null

再次调用computeIfAbsent()不会重新计算值。这次,由于mongodb在映射中(在上一次调用中添加),所以返回的值将是mongodb://192.168.100.10/customers_db。这与尝试获取mysql的 JDBC URL 是一样的,它将返回jdbc:mysql://192.168.0.50/customers_db,而无需进一步计算。

示例 3(compute()

假设我们有以下Map

Map<String, String> map = new HashMap<>();
map.put("postgresql", "127.0.0.1");
map.put("mysql", "192.168.0.50");

我们使用这个映射为不同的数据库类型构建 JDBC URL。

假设我们要为 MySQL 和 Derby DB 构建 JDBC URL。在这种情况下,不管键(mysql还是derby存在于映射中,JDBC URL 都应该基于相应的键和值(可以是null)来计算。另外,如果键存在于映射中,并且我们的计算结果是null(无法计算 JDBC URL),那么我们希望从映射中删除这个条目。基本上,这是computeIfPresent()computeIfAbsent()的组合。

这是V compute​(K key, BiFunction<? super K,​? super V,​? extends V> remappingFunction)的工作。

此时,应写入BiFunction以覆盖搜索键的值为null时的情况:

String address = InetAddress.getLocalHost().getHostAddress();
BiFunction<String, String, String> jdbcUrl = (k, v) 
  -> "jdbc:" + k + "://" + ((v == null) ? address : v) 
    + "/customers_db";

现在,让我们计算 MySQL 的 JDBC URL。因为mysql键存在于映射中,所以计算将依赖于相应的值192.168.0.50。结果将更新映射中mysql键的值:

// jdbc:mysql://192.168.0.50/customers_db
String mysqlJdbcUrl = map.compute("mysql", jdbcUrl);

另外,让我们计算 Derby DB 的 JDBC URL。由于映射中不存在derby键,因此计算将依赖于当前 IP。结果将被添加到映射的derby键下:

// jdbc:derby://192.168.100.10/customers_db
String derbyJdbcUrl = map.compute("derby", jdbcUrl);

在这两次计算之后,映射将包含以下三个条目:

  • postgresql=127.0.0.1
  • derby=jdbc:derby://192.168.100.10/customers_db
  • mysql=jdbc:mysql://192.168.0.50/customers_db

请注意,再次调用compute()将重新计算值。这可能导致不需要的结果,如jdbc:derby://jdbc:derby://...
如果计算的结果是null(例如 JDBC URL 无法计算),并且映射中存在键(例如mysql),那么这个条目将从映射中删除,返回的结果是null

示例 4(merge()

假设我们有以下Map

Map<String, String> map = new HashMap<>();
map.put("postgresql", "9.6.1 ");
map.put("mysql", "5.1 5.2 5.6 ");

我们使用这个映射来存储每个数据库类型的版本,这些版本之间用空格隔开。

现在,假设每次发布数据库类型的新版本时,我们都希望将其添加到对应键下的映射中。如果键(例如,mysql)存在于映射中,那么我们只需将新版本连接到当前值的末尾。如果键(例如,derby)不在映射中,那么我们现在只想添加它。

这是V merge​(K key, V value, BiFunction<? super V,​? super V,​? extends V> remappingFunction)的完美工作。

如果给定的键(K与某个值没有关联或与null关联,那么新的值将是V。如果给定键(K与非null值相关联,则基于给定的BiFunction计算新值。如果此BiFunction的结果是null,并且该键存在于映射中,则此条目将从映射中删除。

在我们的例子中,我们希望将当前值与新版本连接起来,因此我们的BiFunction可以写为:

BiFunction<String, String, String> jdbcUrl = String::concat;

我们在以下方面也有类似的情况:

BiFunction<String, String, String> jdbcUrl 
  = (vold, vnew) -> vold.concat(vnew);

例如,假设我们希望在 MySQL 的映射版本 8.0 中连接。这可以通过以下方式实现:

// 5.1 5.2 5.6 8.0
String mySqlVersion = map.merge("mysql", "8.0 ", jdbcUrl);

稍后,我们还将连接 9.0 版:

// 5.1 5.2 5.6 8.0 9.0
String mySqlVersion = map.merge("mysql", "9.0 ", jdbcUrl);

或者,我们添加 Derby DB 的版本10.11.1.1。这将导致映射中出现一个新条目,因为不存在derby键:

// 10.11.1.1
String derbyVersion = map.merge("derby", "10.11.1.1 ", jdbcUrl);

在这三个操作结束时,映射条目如下所示:

postgresql=9.6.1, derby=10.11.1.1, mysql=5.1 5.2 5.6 8.0 9.0

示例 5(putIfAbsent()

假设我们有以下Map

Map<Integer, String> map = new HashMap<>();
map.put(1, "postgresql");
map.put(2, "mysql");
map.put(3, null);

我们使用这个映射来存储一些数据库类型的名称。

现在,假设我们希望基于以下约束在该映射中包含更多数据库类型:

  • 如果给定的键存在于映射中,那么只需返回相应的值并保持映射不变。
  • 如果给定的键不在映射中(或者与一个null值相关联),则将给定的值放入映射并返回null

嗯,这是putIfAbsent​(K key, V value)的工作。

以下三种尝试不言自明:

String v1 = map.putIfAbsent(1, "derby");     // postgresql
String v2 = map.putIfAbsent(3, "derby");     // null
String v3 = map.putIfAbsent(4, "cassandra"); // null

映射内容如下:

1=postgresql, 2=mysql, 3=derby, 4=cassandra

112 从映射中删除

Map中删除可以通过一个键或者一个键和值来完成。

例如,假设我们有以下Map

Map<Integer, String> map = new HashMap<>();
map.put(1, "postgresql");
map.put(2, "mysql");
map.put(3, "derby");

通过键删除就像调用V Map.remove(Object key)方法一样简单。如果给定键对应的条目删除成功,则返回关联值,否则返回null

检查以下示例:

String r1 = map.remove(1); // postgresql
String r2 = map.remove(4); // null

现在,映射包含以下条目(已删除键 1 中的条目):

2=mysql, 3=derby

从 JDK8 开始,Map接口被一个新的remove()标志方法所丰富,该方法具有以下签名:boolean remove​(Object key, Object value)。使用这种方法,只有在给定的键和值之间存在完美匹配时,才能从映射中删除条目。基本上,这种方法是以下复合条件的捷径:map.containsKey(key) && Objects.equals(map.get(key), value)

让我们举两个简单的例子:

// true
boolean r1 = map.remove(2, "mysql");

// false (the key is present, but the values don't match)
boolean r2 = map.remove(3, "mysql");

结果映射包含一个剩余条目3=derby

迭代和从Map中移除至少可以通过两种方式来完成:第一,通过Iterator(捆绑代码中存在的解决方案),第二,从 JDK8 开始,我们可以通过removeIf​(Predicate<? super E> filter)来完成:

map.entrySet().removeIf(e -> e.getValue().equals("mysql"));

有关从集合中删除的更多详细信息,请参见“删除集合中与谓词匹配的所有元素”。

113 替换映射中的条目

Map替换条目是一个在很多情况下都会遇到的问题。要实现这一点并避免在辅助方法中编写一段意大利面条代码,方便的解决方案依赖于 JDK8replace()方法。

假设我们有下面的Melon类和Melon的映射:

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

Map<Integer, Melon> mapOfMelon = new HashMap<>();
mapOfMelon.put(1, new Melon("Apollo", 3000));
mapOfMelon.put(2, new Melon("Jade Dew", 3500));
mapOfMelon.put(3, new Melon("Cantaloupe", 1500));

通过V replace​(K key, V value)可以完成按键 2 对应的甜瓜的更换。如果替换成功,则此方法将返回初始的Melon

// Jade Dew(3500g) was replaced
Melon melon = mapOfMelon.replace(2, new Melon("Gac", 1000));

现在,映射包含以下条目:

1=Apollo(3000g), 2=Gac(1000g), 3=Cantaloupe(1500g)

此外,假设我们想用键 1 和阿波罗甜瓜(3000g)替换条目。所以,甜瓜应该是同一个,才能获得成功的替代品。这可以通过布尔值replace​(K key, V oldValue, V newValue)实现。此方法依赖于equals()合同来比较给定的值,因此Melon需要执行equals()方法,否则结果不可预知:

// true
boolean melon = mapOfMelon.replace(
  1, new Melon("Apollo", 3000), new Melon("Bitter", 4300));

现在,映射包含以下条目:

1=Bitter(4300g), 2=Gac(1000g), 3=Cantaloupe(1500g)

最后,假设我们要根据给定的函数替换Map中的所有条目。这可以通过void replaceAll​(BiFunction<? super K,​? super V,​? extends V> function)完成。

例如,将所有重量超过 1000g 的瓜替换为重量等于 1000g 的瓜,下面的BiFunction形成了这个函数(k是键,vMap中每个条目的值):

BiFunction<Integer, Melon, Melon> function = (k, v) 
  -> v.getWeight() > 1000 ? new Melon(v.getType(), 1000) : v;

接下来,replaceAll()出现在现场:

mapOfMelon.replaceAll(function);

现在,映射包含以下条目:

1=Bitter(1000g), 2=Gac(1000g), 3=Cantaloupe(1000g)

114 比较两个映射

只要我们依赖于Map.equals()方法,比较两个映射是很简单的。在比较两个映射时,该方法使用Object.equals()方法比较它们的键和值。

例如,让我们考虑两个具有相同条目的瓜映射(在Melon类中必须存在equals()hashCode()

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

Map<Integer, Melon> melons1Map = new HashMap<>();
Map<Integer, Melon> melons2Map = new HashMap<>();
melons1Map.put(1, new Melon("Apollo", 3000));
melons1Map.put(2, new Melon("Jade Dew", 3500));
melons1Map.put(3, new Melon("Cantaloupe", 1500));
melons2Map.put(1, new Melon("Apollo", 3000));
melons2Map.put(2, new Melon("Jade Dew", 3500));
melons2Map.put(3, new Melon("Cantaloupe", 1500));

现在,如果我们测试melons1Mapmelons2Map是否相等,那么我们得到true

boolean equals12Map = melons1Map.equals(melons2Map); // true

但如果我们使用数组,这将不起作用。例如,考虑下面两个映射:

Melon[] melons1Array = {
  new Melon("Apollo", 3000),
  new Melon("Jade Dew", 3500), new Melon("Cantaloupe", 1500)
};
Melon[] melons2Array = {
  new Melon("Apollo", 3000),
  new Melon("Jade Dew", 3500), new Melon("Cantaloupe", 1500)
};

Map<Integer, Melon[]> melons1ArrayMap = new HashMap<>();
melons1ArrayMap.put(1, melons1Array);
Map<Integer, Melon[]> melons2ArrayMap = new HashMap<>();
melons2ArrayMap.put(1, melons2Array);

即使melons1ArrayMapmelons2ArrayMap相等,Map.equals()也会返回false

boolean equals12ArrayMap = melons1ArrayMap.equals(melons2ArrayMap);

这个问题源于这样一个事实:数组的equals()方法比较的是标识,而不是数组的内容。为了解决这个问题,我们可以编写一个辅助方法如下(这次依赖于Arrays.equals(),它比较数组的内容):

public static <A, B> boolean equalsWithArrays(
    Map<A, B[]> first, Map<A, B[]> second) {

  if (first.size() != second.size()) {
    return false;
  }

  return first.entrySet().stream()
    .allMatch(e -> Arrays.equals(e.getValue(), 
      second.get(e.getKey())));
}

115 对映射排序

排序一个Map有几种解决方案。首先,假设Melon中的Map

public class Melon implements Comparable {

  private final String type;
  private final int weight;

  @Override
  public int compareTo(Object o) {
    return Integer.compare(this.getWeight(), ((Melon) o).getWeight());
  }

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

Map<String, Melon> melons = new HashMap<>();
melons.put("delicious", new Melon("Apollo", 3000));
melons.put("refreshing", new Melon("Jade Dew", 3500));
melons.put("famous", new Melon("Cantaloupe", 1500));

现在,让我们来研究几种排序这个Map的解决方案。基本上,我们的目标是通过一个名为Maps的工具类公开以下屏幕截图中的方法:

让我们在下一节中看看不同的解决方案。

通过TreeMap和自然排序按键排序

Map进行排序的快速解决方案依赖于TreeMap。根据定义,TreeMap中的键按其自然顺序排序。此外,TreeMap还有一个TreeMap​(Map<? extends K,​? extends V> m)类型的构造器:

public static <K, V> TreeMap<K, V> sortByKeyTreeMap(Map<K, V> map) {

  return new TreeMap<>(map);
}

调用它将按键对映射进行排序:

// {delicious=Apollo(3000g), 
// famous=Cantaloupe(1500g), refreshing=Jade Dew(3500g)}
TreeMap<String, Melon> sortedMap = Maps.sortByKeyTreeMap(melons);

通过流和比较器按键和值排序

一旦我们为映射创建了一个Stream,我们就可以很容易地用Stream.sorted()方法对它进行排序,不管有没有Comparator。这一次,让我们使用一个Comparator

public static <K, V> Map<K, V> sortByKeyStream(
    Map<K, V> map, Comparator<? super K> c) {

  return map.entrySet()
    .stream()
    .sorted(Map.Entry.comparingByKey(c))
    .collect(toMap(Map.Entry::getKey, Map.Entry::getValue,
      (v1, v2) -> v1, LinkedHashMap::new));
}

public static <K, V> Map<K, V> sortByValueStream(
    Map<K, V> map, Comparator<? super V> c) {

  return map.entrySet()
    .stream()
    .sorted(Map.Entry.comparingByValue(c))
    .collect(toMap(Map.Entry::getKey, Map.Entry::getValue,
      (v1, v2) -> v1, LinkedHashMap::new));
}

我们需要依赖LinkedHashMap而不是HashMap。否则,我们就不能保持迭代顺序。

让我们把映射分类如下:

// {delicious=Apollo(3000g), 
//  famous=Cantaloupe(1500g), 
//  refreshing=Jade Dew(3500g)}
Comparator<String> byInt = Comparator.naturalOrder();
Map<String, Melon> sortedMap = Maps.sortByKeyStream(melons, byInt);

// {famous=Cantaloupe(1500g), 
//  delicious=Apollo(3000g), 
//  refreshing=Jade Dew(3500g)}
Comparator<Melon> byWeight = Comparator.comparing(Melon::getWeight);
Map<String, Melon> sortedMap 
  = Maps.sortByValueStream(melons, byWeight);

通过列表按键和值排序

前面的示例对给定的映射进行排序,结果也是一个映射。如果我们只需要排序的键(我们不关心值),反之亦然,那么我们可以依赖于通过Map.keySet()创建的List作为键,通过Map.values()创建的List作为值:

public static <K extends Comparable, V> List<K>
    sortByKeyList(Map<K, V> map) {

  List<K> list = new ArrayList<>(map.keySet());
  Collections.sort(list);

  return list;
}

public static <K, V extends Comparable> List<V>
    sortByValueList(Map<K, V> map) {

  List<V> list = new ArrayList<>(map.values());
  Collections.sort(list);

  return list;
}

现在,让我们对映射进行排序:

// [delicious, famous, refreshing]
List<String> sortedKeys = Maps.sortByKeyList(melons);

// [Cantaloupe(1500g), Apollo(3000g), Jade Dew(3500g)]
List<Melon> sortedValues = Maps.sortByValueList(melons);

如果不允许重复值,则必须依赖于使用SortedSet的实现:

SortedSet<String> sortedKeys = new TreeSet<>(melons.keySet());
SortedSet<Melon> sortedValues = new TreeSet<>(melons.values());

116 复制哈希映射

执行HashMap的浅拷贝的简便解决方案依赖于HashMap构造器HashMap​(Map<? extends K,​? extends V> m)。以下代码是不言自明的:

Map<K, V> mapToCopy = new HashMap<>();
Map<K, V> shallowCopy = new HashMap<>(mapToCopy);

另一种解决方案可能依赖于putAll​(Map<? extends K,​? extends V> m)方法。此方法将指定映射中的所有映射复制到此映射,如以下助手方法所示:

@SuppressWarnings("unchecked")
public static <K, V> HashMap<K, V> shallowCopy(Map<K, V> map) {

  HashMap<K, V> copy = new HashMap<>();
  copy.putAll(map);

  return copy;
}

我们还可以用 Java8 函数式风格编写一个辅助方法,如下所示:

@SuppressWarnings("unchecked")
public static <K, V> HashMap<K, V> shallowCopy(Map<K, V> map) {

  Set<Entry<K, V>> entries = map.entrySet();
  HashMap<K, V> copy = (HashMap<K, V>) entries.stream()
    .collect(Collectors.toMap(
       Map.Entry::getKey, Map.Entry::getValue));

  return copy;
}

然而,这三种解决方案只提供了映射的浅显副本。获取深度拷贝的解决方案可以依赖于克隆库在第 2 章中介绍,“对象、不变性和switch表达式”。将使用克隆的助手方法可以编写如下:

@SuppressWarnings("unchecked") 
public static <K, V> HashMap<K, V> deepCopy(Map<K, V> map) {
  Cloner cloner = new Cloner();
  HashMap<K, V> copy = (HashMap<K, V>) cloner.deepClone(map);

  return copy;
}

117 合并两个映射

合并两个映射是将两个映射合并为一个包含两个映射的元素的映射的过程。此外,对于键碰撞,我们将属于第二个映射的值合并到最终映射中。但这是一个设计决定。

让我们考虑以下两个映射(我们特意为键 3 添加了一个冲突):

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

Map<Integer, Melon> melons1 = new HashMap<>();
Map<Integer, Melon> melons2 = new HashMap<>();
melons1.put(1, new Melon("Apollo", 3000));
melons1.put(2, new Melon("Jade Dew", 3500));
melons1.put(3, new Melon("Cantaloupe", 1500));
melons2.put(3, new Melon("Apollo", 3000));
melons2.put(4, new Melon("Jade Dew", 3500));
melons2.put(5, new Melon("Cantaloupe", 1500));

从 JDK8 开始,我们在Map: V merge​(K key, V value, BiFunction<? super V,​? super V,​? extends V> remappingFunction)中有以下方法。

如果给定的键(K与值没有关联,或者与null关联,那么新的值将是V。如果给定键(K与非null值相关联,则基于给定的BiFunction计算新值。如果此BiFunction的结果是null,并且该键存在于映射中,则此条目将从映射中删除。

基于这个定义,我们可以编写一个辅助方法来合并两个映射,如下所示:

public static <K, V> Map<K, V> mergeMaps(
    Map<K, V> map1, Map<K, V> map2) {  

  Map<K, V> map = new HashMap<>(map1);

  map2.forEach(
    (key, value) -> map.merge(key, value, (v1, v2) -> v2));

  return map;
}

请注意,我们不会修改原始映射。我们更希望返回一个包含第一个映射的元素与第二个映射的元素合并的新映射。在键冲突的情况下,我们用第二个映射(v2中的值替换现有值。

基于Stream.concat()可以编写另一个解决方案。基本上,这种方法将两个流连接成一个Stream。为了从一个Map创建一个Stream,我们称之为Map.entrySet().stream()。在连接从给定映射创建的两个流之后,我们只需通过toMap()收集器收集结果:

public static <K, V> Map<K, V> mergeMaps(
    Map<K, V> map1, Map<K, V> map2) {

  Stream<Map.Entry<K, V>> combined 
    = Stream.concat(map1.entrySet().stream(), 
      map2.entrySet().stream());

  Map<K, V> map = combined.collect(
    Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue,
      (v1, v2) -> v2));

  return map;
}

作为奖励,Set(例如,整数的Set可以按如下方式排序:

List<Integer> sortedList = someSetOfIntegers.stream()
  .sorted().collect(Collectors.toList());

对于对象,依赖于sorted(Comparator<? super T>

118 删除集合中与谓词匹配的所有元素

我们的集合将收集一堆Melon

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), 
  // hashCode(), toString() omitted for brevity
}

让我们在整个示例中假设以下集合(ArrayList,以演示如何从集合中移除与给定谓词匹配的元素:

List<Melon> melons = new ArrayList<>();
melons.add(new Melon("Apollo", 3000));
melons.add(new Melon("Jade Dew", 3500));
melons.add(new Melon("Cantaloupe", 1500));
melons.add(new Melon("Gac", 1600));
melons.add(new Melon("Hami", 1400));

让我们看看下面几节给出的不同解决方案。

通过迭代器删除

通过Iterator删除是 Java 中最古老的方法。主要地,Iterator允许我们迭代(或遍历)集合并删除某些元素。最古老的方法也有一些缺点。首先,根据集合类型的不同,如果多个线程修改集合,那么通过一个Iterator删除很容易发生ConcurrentModificationException。此外,移除并不是所有集合的行为都相同(例如,从LinkedList移除要比从ArrayList移除快,因为前者只是将指针移动到下一个元素,而后者则需要移动元素)。不过,解决方案在捆绑代码中是可用的。

如果您所需要的只是Iterable的大小,那么请考虑以下方法之一:

// for any Iterable
StreamSupport.stream(iterable.spliterator(), false).count();

// for collections
((Collection<?>) iterable).size()

移除通孔集合.removeIf()

从 JDK8 开始,我们可以通过Collection.removeIf()方法将前面的代码缩减为一行代码。此方法依赖于Predicate,如下例所示:

melons.removeIf(t -> t.getWeight() < 3000);

这一次,ArrayList迭代列表并标记为删除那些满足我们的Predicate的元素。此外,ArrayList再次迭代以移除标记的元素并移动剩余的元素。

使用这种方法,LinkedListArrayList以几乎相同的方式执行。

通过流删除

从 JDK8 开始,我们可以从集合(Collection.stream()中创建一个Stream,并通过filter(Predicate p)过滤它的元素。过滤器将只保留满足给定Predicate的元件。

最后,我们通过合适的收集器收集这些元素:

List<Melon> filteredMelons = melons.stream()
  .filter(t -> t.getWeight() >= 3000)
  .collect(Collectors.toList());

与其他两个解决方案不同,这个解决方案不会改变原始集合,但它可能会更慢,占用更多内存。

通过Collectors.partitioningBy()

有时,我们不想删除与谓词不匹配的元素。我们实际上想要的是基于谓词来分离元素。好吧,这是可以通过Collectors.partitioningBy(Predicate p)实现的。

基本上,Collectors.partitioningBy()将把元素分成两个列表。这两个列表作为值添加到Map。此Map的两个键是truefalse

Map<Boolean, List<Melon>> separatedMelons = melons.stream()
  .collect(Collectors.partitioningBy(
    (Melon t) -> t.getWeight() >= 3000));

List<Melon> weightLessThan3000 = separatedMelons.get(false);
List<Melon> weightGreaterThan3000 = separatedMelons.get(true);

因此,true键用于检索包含与谓词匹配的元素的List,而false键用于检索包含与谓词不匹配的元素的List

作为奖励,如果我们想检查List的所有元素是否相同,那么我们可以依赖Collections.frequency(Collection c, Object obj)。此方法返回指定集合中等于指定对象的元素数:

boolean allTheSame = Collections.frequency(
  melons, melons.get(0)) == melons.size());

如果allTheSametrue,那么所有元素都是相同的。注意,List中的对象的equals()hashCode()必须相应地实现。

119 将集合转换为数组

为了将集合转换为数组,我们可以依赖于Collection.toArray()方法。如果没有参数,此方法会将给定集合转换为一个Object[],如下例所示:

List<String> names = Arrays.asList("ana", "mario", "vio");
Object[] namesArrayAsObjects = names.toArray();

显然,这并不完全有用,因为我们期望的是一个String[]而不是Object[]。这可以通过Collection.toArray​(T[] a)实现,如下所示:

String[] namesArraysAsStrings = names.toArray(new String[names.size()]);
String[] namesArraysAsStrings = names.toArray(new String[0]);

从这两种解决方案中,第二种方案更可取,因为我们避免计算集合大小。

但从 JDK11 开始,还有一种方法专门用于此任务,Collection.toArray​(IntFunction<T[]> generator)。此方法返回一个包含此集合中所有元素的数组,使用提供的生成器函数分配返回的数组:

String[] namesArraysAsStrings = names.toArray(String[]::new);

除了固定大小可修改的Arrays.asList()之外,我们可以通过of()方法从数组中构建一个不可修改的List/Set

String[] namesArray = {"ana", "mario", "vio"};

List<String> namesArrayAsList = List.of(namesArray);
Set<String> namesArrayAsSet = Set.of(namesArray);

120 按列表过滤集合

我们在应用中遇到的一个常见问题是用一个List来过滤一个Collection。主要是从一个巨大的Collection开始,我们想从中提取与List元素匹配的元素。

在下面的例子中,让我们考虑一下Melon类:

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

这里,我们有一个巨大的Collection(在本例中,是一个ArrayList Melon

List<Melon> melons = new ArrayList<>();
melons.add(new Melon("Apollo", 3000));
melons.add(new Melon("Jade Dew", 3500));
melons.add(new Melon("Cantaloupe", 1500));
melons.add(new Melon("Gac", 1600));
melons.add(new Melon("Hami", 1400));
...

我们还有一个List,包含我们想从前面ArrayList中提取的瓜的类型:

List<String> melonsByType 
  = Arrays.asList("Apollo", "Gac", "Crenshaw", "Hami");

这个问题的一个解决方案可能涉及循环收集和比较瓜的类型,但是生成的代码会非常慢。这个问题的另一个解决方案可能涉及到List.contains()方法和 Lambda 表达式:

List<Melon> results = melons.stream()
  .filter(t -> melonsByType.contains(t.getType()))
  .collect(Collectors.toList());

代码紧凑,速度快。在幕后,List.contains()依赖于以下检查:

// size - the size of melonsByType
// o - the current element to search from melons
// elementData - melonsByType
for (int i = 0; i < size; i++)
  if (o.equals(elementData[i])) {
    return i;
  }
}

然而,我们可以通过依赖于HashSet.contains()而不是List.contains()的解决方案来提高性能。当List.contains()使用前面的for语句来匹配元素时,HashSet.contains()使用Map.containsKey()Set主要是基于Map实现的,每个增加的元素映射为elementPRESENT类型的键值。所以,element是这个Map中的一个键,PRESENT只是一个伪值。

当我们调用HashSet.contains(element)时,实际上我们调用Map.containsKey(element)。该方法基于给定元素的hashCode(),将给定元素与映射中的适当键进行匹配,比equals()快得多。

一旦我们将初始的ArrayList转换成HashSet,我们就可以开始了:

Set<String> melonsSetByType = melonsByType.stream()
  .collect(Collectors.toSet());

List<Melon> results = melons.stream()
  .filter(t -> melonsSetByType.contains(t.getType()))
  .collect(Collectors.toList());

嗯,这个解决方案比上一个快。它的运行时间应该是上一个解决方案所需时间的一半。

121 替换列表的元素

我们在应用中遇到的另一个常见问题是替换符合特定条件的List元素。

在下面的示例中,让我们考虑一下Melon类:

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

然后,让我们考虑一下MelonList

List<Melon> melons = new ArrayList<>();

melons.add(new Melon("Apollo", 3000));
melons.add(new Melon("Jade Dew", 3500));
melons.add(new Melon("Cantaloupe", 1500));
melons.add(new Melon("Gac", 1600));
melons.add(new Melon("Hami", 1400));

让我们假设我们想把所有重量在 3000 克以下的西瓜换成其他同类型、重 3000 克的西瓜。

解决这个问题的方法是迭代List,然后使用List.set(int index, E element)相应地替换瓜。

下面是一段意大利面代码:

for (int i = 0; i < melons.size(); i++) {

  if (melons.get(i).getWeight() < 3000) {

    melons.set(i, new Melon(melons.get(i).getType(), 3000));
  }
}

另一种解决方案依赖于 Java8 函数式风格,或者更准确地说,依赖于UnaryOperator函数式接口。

基于此函数式接口,我们可以编写以下运算符:

UnaryOperator<Melon> operator = t 
  -> (t.getWeight() < 3000) ? new Melon(t.getType(), 3000) : t;

现在,我们可以使用 JDK8,List.replaceAll(UnaryOperator<E> operator),如下所示:

melons.replaceAll(operator);

两种方法的性能应该几乎相同。

122 线程安全的集合、栈和队列

每当集合/栈/队列容易被多个线程访问时,它也容易出现特定于并发的异常(例如,java.util.ConcurrentModificationException。现在,让我们简要地概述一下 Java 内置的并发集合,并对其进行介绍。

并行集合

幸运的是,Java 为非线程安全集合(包括栈和队列)提供了线程安全(并发)的替代方案,如下所示。

线程安全列表

ArrayList的线程安全版本是CopyOnWriteArrayList。下表列出了 Java 内置的单线程和多线程列表:

单线程 多线程
ArrayList LinkedList CopyOnWriteArrayList(经常读取,很少更新)Vector

CopyOnWriteArrayList实现保存数组中的元素。每次我们调用一个改变列表的方法(例如,add()set()remove(),Java 都会对这个数组的一个副本进行操作。

此集合上的Iterator将对集合的不可变副本进行操作。因此,可以修改原始集合而不会出现问题。在Iterator中看不到原始集合的潜在修改:

List<Integer> list = new CopyOnWriteArrayList<>();

当读取频繁而更改很少时,请使用此集合。

线程安全集合

Set的线程安全版本是CopyOnWriteArraySet。下表列举了 Java 内置的单线程和多线程集:

单线程 多线程
HashSet TreeSet(排序集)LinkedHashSet(维护插入顺序)BitSet EnumSet ConcurrentSkipListSet(排序集)CopyOnWriteArraySet(经常读取,很少更新)

这是一个Set,它的所有操作都使用一个内部CopyOnWriteArrayList。创建这样一个Set可以如下所示:

Set<Integer> set = new CopyOnWriteArraySet<>();

当读取频繁而更改很少时,请使用此集合。

NavigableSet的线程安全版本是ConcurrentSkipListSet(并发SortedSet实现,最基本的操作在O(log n)中)。

线程安全映射

Map的线程安全版本是ConcurrentHashMap

下表列举了 Java 内置的单线程和多线程映射:

单线程 多线程
HashMap TreeMap(排序键)LinkedHashMap(维护插入顺序)IdentityHashMap(通过==比较按键)WeakHashMap EnumMap ConcurrentHashMap ConcurrentSkipListMap(排序图)Hashtable

ConcurrentHashMap允许无阻塞的检索操作(例如,get())。这意味着检索操作可能与更新操作重叠(包括put()remove()

创建ConcurrentHashMap的步骤如下:

ConcurrentMap<Integer, Integer> map = new ConcurrentHashMap<>();

当需要线程安全和高性能时,您可以依赖线程安全版本的Map,即ConcurrentHashMap

避免HashtableCollections.synchronizedMap(),因为它们的性能较差。

对于支持NavigableMapConcurrentMap,操作依赖ConcurrentSkipListMap

ConcurrentNavigableMap<Integer, Integer> map 
  = new ConcurrentSkipListMap<>();

由数组支持的线程安全队列

Java 提供了一个先进先出FIFO)的线程安全队列,由一个数组通过ArrayBlockingQueue支持。下表列出了由数组支持的单线程和多线程 Java 内置队列:

单线程 多线程
ArrayDeque PriorityQueue(排序检索) ArrayBlockingQueue(有界)ConcurrentLinkedQueue(无界)ConcurrentLinkedDeque(无界)LinkedBlockingQueue(可选有界)LinkedBlockingDeque(可选有界)LinkedTransferQueue PriorityBlockingQueue SynchronousQueue DelayQueue Stack

ArrayBlockingQueue的容量在创建后不能更改。尝试将一个元素放入一个完整的队列将导致操作阻塞;尝试从一个空队列中获取一个元素也将导致类似的阻塞。

创建ArrayBlockingQueue很容易,如下所示:

BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(QUEUE_MAX_SIZE);

Java 还提供了两个线程安全的、可选的有界阻塞队列,它们基于通过LinkedBlockingQueueLinkedBlockingDeque链接的节点(双向队列是一个线性集合,支持在两端插入和删除元素)。

基于链接节点的线程安全队列

Java 通过ConcurrentLinkedDeque/ConcurrentLinkedQueue提供了一个由链接节点支持的无边界线程安全队列/队列。这里是ConcurrentLinkedDeque

Deque<Integer> queue = new ConcurrentLinkedDeque<>();

线程安全优先级队列

Java 通过PriorityBlockingQueue提供了一个基于优先级堆的无边界线程安全优先级阻塞队列。

创建PriorityBlockingQueue很容易,如下所示:

BlockingQueue<Integer> queue = new PriorityBlockingQueue<>();

非线程安全版本名为PriorityQueue

线程安全延迟队列

Java 提供了一个线程安全的无界阻塞队列,在该队列中,只有当元素的延迟通过DelayQueue过期时,才能获取该元素。创建一个DelayQueue如下所示:

BlockingQueue<TrainDelay> queue = new DelayQueue<>();

线程安全传输队列

Java 通过LinkedTransferQueue提供了基于链接节点的线程安全的无界传输队列。

这是一个 FIFO 队列,是某个生产者在队列中停留时间最长的元素。队列的是某个生产者在队列中停留时间最短的元素。

创建此类队列的一种方法如下:

TransferQueue<String> queue = new LinkedTransferQueue<>();

线程安全同步队列

Java 提供了一个阻塞队列,其中每个插入操作必须等待另一个线程执行相应的移除操作,反之亦然,通过SynchronousQueue

BlockingQueue<String> queue = new SynchronousQueue<>();

线程安全栈

栈的线程安全实现是StackConcurrentLinkedDeque

Stack类表示对象的后进先出LIFO)栈。它通过几个操作扩展了Vector类,这些操作允许将向量视为栈。Stack的每一种方法都是同步的。创建一个Stack如下所示:

Stack<Integer> stack = new Stack<>();

ConcurrentLinkedDeque实现可以通过其push()pop()方法用作Stack(后进先出):

Deque<Integer> stack = new ConcurrentLinkedDeque<>();

为了获得更好的性能,请选择ConcurrentLinkedDeque而不是Stack

绑定到本书中的代码为前面的每个集合提供了一个应用,用于跨越多个线程,以显示它们的线程安全特性。

同步的集合

除了并行集合,我们还有synchronized集合。Java 提供了一套包装器,将集合公开为线程安全的集合。这些包装在Collections中提供。最常见的有:

  • synchronizedCollection​(Collection<T> c):返回由指定集合支持的同步(线程安全)集合
  • synchronizedList​(List<T> list):返回指定列表支持的同步(线程安全)列表:
List<Integer> syncList 
  = Collections.synchronizedList(new ArrayList<>());
  • synchronizedMap​(Map<K,​V> m):返回指定映射支持的同步(线程安全)映射:
Map<Integer, Integer> syncMap 
  = Collections.synchronizedMap(new HashMap<>());
  • synchronizedSet​(Set<T> s):返回指定集支持的同步(线程安全)集:
Set<Integer> syncSet 
  = Collections.synchronizedSet(new HashSet<>());

并发集合与同步集合

显而易见的问题是“并发集合和同步集合的区别是什么?”好吧,主要区别在于它们实现线程安全的方式。并发集合通过将数据划分为段来实现线程安全。线程可以并发地访问这些段,并且只能在所使用的段上获得锁。另一方面,同步集合通过内部锁定锁定整个集合(调用同步方法的线程将自动获取该方法对象的内在锁,并在方法返回时释放它)。

迭代同步的集合需要手动同步,如下所示:

List syncList = Collections.synchronizedList(new ArrayList());
...
synchronized(syncList) {
  Iterator i = syncList.iterator();
  while (i.hasNext()) {
    // do_something_with i.next();
  }
}

由于并发集合允许线程的并发访问,因此它们的性能比同步集合高得多。

123 广度优先搜索

BFS 是遍历(访问)图或树的所有节点的经典算法。

理解这个算法最简单的方法是通过伪代码和一个例子。BFS 的伪码如下:

  1. 创建队列Q
  2. v标记为已访问,并将v放入Q
  3. Q为非空
  4. 取下Q的头部h
  5. 标记h的所有(未访问的)邻居并入队

假设下图中的图,步骤 0

在第一步(步骤 1),我们访问顶点0。我们把它放在visited列表中,它的所有相邻顶点放在queue(3,1)中。此外,在步骤 2 中,我们访问queue3前面的元素。顶点3步骤 2 中有一个未访问的相邻顶点,所以我们将其添加到queue的后面。接下来,在步骤 3 中,我们访问queue 1前面的元素。该顶点有一个相邻的顶点(0),但该顶点已被访问。最后,我们访问顶点2,最后一个来自queue。这个有一个已经访问过的相邻顶点(3)。

在代码行中,BFS 算法可以实现如下:

public class Graph {

  private final int v;
  private final LinkedList<Integer>[] adjacents;

  public Graph(int v) {

    this.v = v;
    adjacents = new LinkedList[v];

    for (int i = 0; i < v; ++i) {
      adjacents[i] = new LinkedList();
    }
  }

  public void addEdge(int v, int e) {
    adjacents[v].add(e);
  }

  public void BFS(int start) {

    boolean visited[] = new boolean[v];
    LinkedList<Integer> queue = new LinkedList<>();
    visited[start] = true;

    queue.add(start);

    while (!queue.isEmpty()) {
      start = queue.poll();
      System.out.print(start + " ");

      Iterator<Integer> i = adjacents[start].listIterator();
      while (i.hasNext()) {
        int n = i.next();
        if (!visited[n]) {
          visited[n] = true;
          queue.add(n);
        }
      }
    }
  }
}

并且,如果我们引入以下图表(从前面的图表),我们有如下:

Graph graph = new Graph(4);
graph.addEdge(0, 3);
graph.addEdge(0, 1);
graph.addEdge(1, 0);
graph.addEdge(2, 3);
graph.addEdge(3, 0);
graph.addEdge(3, 2);
graph.addEdge(3, 3);

输出将为0 3 1 2

124 Trie

Trie(也称为数字树)是一种有序的树结构,通常用于存储字符串。它的名字来源于 Trie 是 reTrieval数据结构。它的性能优于二叉树。

除 Trie 的根外,Trie 的每个节点都包含一个字符(例如,单词hey将有三个节点)。Trie 的每个节点主要包含以下内容:

  • 值(字符或数字)
  • 指向子节点的指针
  • 如果当前节点完成一个字,则为true的标志
  • 用于分支节点的单个根

下图表示构建包含单词catcaretbye的 Trie 的步骤顺序:

因此,在代码行中,Trie 节点的形状可以如下所示:

public class Node {

  private final Map<Character, Node> children = new HashMap<>();
  private boolean word;

  Map<Character, Node> getChildren() {
    return children;
  }

  public boolean isWord() {
    return word;
  }

  public void setWord(boolean word) {
    this.word = word;
  }
}

基于这个类,我们可以定义一个 Trie 基本结构如下:

class Trie {

  private final Node root;

  public Trie() {
    root = new Node();
  }

  public void insert(String word) {
    ...
  }

  public boolean contains(String word) {
    ...
  }

  public boolean delete(String word) {
    ...
  }
}

插入 Trie

现在,让我们关注在 Trie 中插入单词的算法:

  1. 将当前节点视为根节点。
  2. 从第一个字符开始,逐字符循环给定的单词。
  3. 如果当前节点(Map<Character, Node>)为当前字符映射一个值(Node),那么只需前进到该节点。否则,新建一个Node,将其字符设置为当前字符,并前进到此节点。
  4. 重复步骤 2(传递到下一个字符),直到单词的结尾。
  5. 将当前节点标记为完成单词的节点。

在代码行方面,我们有以下内容:

public void insert(String word) {

  Node node = root;

  for (int i = 0; i < word.length(); i++) {
    char ch = word.charAt(i);
    Function function = k -> new Node();

    node = node.getChildren().computeIfAbsent(ch, function);
  }

  node.setWord(true);
}

插入的复杂度为O(n),其中n表示字长。

搜索 Trie

现在,让我们在 Trie 中搜索一个单词:

  1. 将当前节点视为根节点。
  2. 逐字符循环给定的单词(从第一个字符开始)。
  3. 对于每个字符,检查其在 Trie 中的存在性(在Map<Character, Node>中)。
  4. 如果字符不存在,则返回false
  5. 从第 2 步开始重复,直到单词结束。
  6. 如果是单词,则在单词末尾返回true,如果只是前缀,则返回false

在代码行方面,我们有以下内容:

public boolean contains(String word) {

  Node node = root;

  for (int i = 0; i < word.length(); i++) {
    char ch = word.charAt(i);
    node = node.getChildren().get(ch);

    if (node == null) {
      return false;
    }
  }

  return node.isWord();
}

查找的复杂度为O(n),其中n表示字长。

从 Trie 中删除

最后,让我们尝试从 Trie 中删除:

  1. 验证给定的单词是否是 Trie 的一部分。
  2. 如果它是 Trie 的一部分,那么只需移除它。

使用递归并遵循以下规则,以自下而上的方式进行删除:

  • 如果给定的单词不在 Trie 中,那么什么也不会发生(返回false
  • 如果给定的单词是唯一的(不是另一个单词的一部分),则删除所有相应的节点(返回true
  • 如果给定的单词是 Trie 中另一个长单词的前缀,则将叶节点标志设置为false(返回false
  • 如果给定的单词至少有另一个单词作为前缀,则从给定单词的末尾删除相应的节点,直到最长前缀单词的第一个叶节点(返回false

在代码行方面,我们有以下内容:

public boolean delete(String word) {
  return delete(root, word, 0);
}

private boolean delete(Node node, String word, int position) {

  if (word.length() == position) {
    if (!node.isWord()) {
      return false;
    }

    node.setWord(false);

    return node.getChildren().isEmpty();
  }

  char ch = word.charAt(position);
  Node children = node.getChildren().get(ch);

  if (children == null) {
    return false;
  }

  boolean deleteChildren = delete(children, word, position + 1);

  if (deleteChildren && !children.isWord()) {
    node.getChildren().remove(ch);

    return node.getChildren().isEmpty();
  }

  return false;
}

查找的复杂度为O(n),其中n表示字长。

现在,我们可以构建一个 Trie,如下所示:

Trie trie = new Trie();
trie.insert/contains/delete(...);

125 元组

基本上,元组是由多个部分组成的数据结构。通常,元组有两到三个部分。通常,当需要三个以上的部分时,一个专用类是更好的选择。

元组是不可变的,每当我们需要从一个方法返回多个结果时就使用元组。例如,假设有一个方法返回数组的最小值和最大值。通常,一个方法不能同时返回这两个值,使用元组是一个方便的解决方案。

不幸的是,Java 不提供内置元组支持。然而,Java 附带了Map.Entry<K,​V>,用于表示来自Map的条目。此外,从 JDK9 开始,Map接口被一个名为entry(K k, V v)的方法丰富,该方法返回一个包含给定键和值的不可修改的Map.Entry<K, V>

对于一个由两部分组成的元组,我们可以编写如下方法:

public static <T> Map.Entry<T, T> array(
    T[] arr, Comparator<? super T> c) {

  T min = arr[0];
  T max = arr[0];

  for (T elem: arr) {
    if (c.compare(min, elem) > 0) {
      min = elem;
    } else if (c.compare(max, elem)<0) {
      max = elem;
    }
  }

  return entry(min, max);
}

如果这个方法存在于一个名为Bounds的类中,那么我们可以如下调用它:

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

Melon[] melons = {
  new Melon("Crenshaw", 2000), new Melon("Gac", 1200),
  new Melon("Bitter", 2200), new Melon("Hami", 800)
};

Comparator<Melon> byWeight = Comparator.comparing(Melon::getWeight);
Map.Entry<Melon, Melon> minmax = Bounds.array(melons, byWeight);

System.out.println("Min: " + minmax1.getKey());   // Hami(800g)
System.out.println("Max: " + minmax1.getValue()); // Bitter(2200g)

但我们也可以编写一个实现。一个由两部分组成的元组通常被称为一个;因此,一个直观的实现可以如下所示:

public final class Pair<L, R> {

  final L left;
  final R right;

  public Pair(L left, R right) {
    this.left = left;
    this.right = right;
  }

  static <L, R> Pair<L, R> of (L left, R right) {

    return new Pair<>(left, right);
  }

  // equals() and hashCode() omitted for brevity
}

现在,我们可以重写计算最小值和最大值的方法,如下所示:

public static <T> Pair<T, T> array(T[] arr, Comparator<? super T> c) {
  ...
  return Pair.of(min, max);
}

126 并查集

并查算法在不相交集数据结构上运行。

不相交的集合数据结构定义了在某些不相交的子集中分离的元素集合,这些子集是不重叠的。从图形上看,我们可以用三个子集表示不相交集,如下图所示:

在代码中,不相交集表示为:

  • n是元素的总数(例如,在上图中,n是 11)。
  • rank是一个用 0 初始化的数组,用于决定如何合并两个具有多个元素的子集(具有较低rank的子集成为具有较高rank的子集的子子集)。
  • parent是允许我们构建基于数组的并查的数组(最初为parent[0] = 0; parent[1] = 1; ... parent[10] = 10;):
public DisjointSet(int n) {

  this.n = n;
  rank = new int[n];
  parent = new int[n];

  initializeDisjointSet();
}

并查算法主要应具备以下功能:

  • 将两个子集合并为一个子集
  • 返回给定元素的子集(这对于查找同一子集中的元素很有用)

为了在内存中存储不相交的集合数据结构,我们可以将它表示为一个数组。最初,在数组的每个索引处,我们存储该索引(x[i] = i。每个索引可以映射到一段对我们有意义的信息,但这不是强制性的。例如,这样一个数组的形状可以如下图所示(最初,我们有 11 个子集,每个元素都是它自己的父元素):

或者,如果我们使用数字,我们可以用下图来表示:

在代码行方面,我们有以下内容:

private void initializeDisjointSet() {

  for (int i = 0; i < n; i++) {
    parent[i] = i;
  }
}

此外,我们需要通过并集操作来定义我们的子集。我们可以通过(对)序列来定义子集。例如,让我们定义以下三对-union(0,1);union(4, 9);union(6, 5);。每次一个元素(子集)成为另一个元素(子集)的子元素时,它都会修改其值以反映其父元素的值,如下图所示:

这个过程一直持续到我们定义了所有的子集。例如,我们可以添加更多的联合-union(0, 7);union(4, 3);union(4, 2);union(6, 10);union(4, 5);。这将产生以下图形表示:

根据经验,建议将较小的子集合并为较大的子集,反之亦然。例如,检查包含4的子集与包含5的子集相统一的时刻。此时,4是子集的父项,它有三个子项(239),而5紧挨着106的两个子项。因此,包含5的子集有三个节点(6510),而包含4的子集有四个节点(4239)。因此,4成为6的父,并且隐含地成为5的父。

在代码行中,这是rank[]数组的工作:

现在让我们看看如何实现findunion操作

实现查找操作

查找给定元素的子集是一个递归过程,通过跟随父元素遍历子集,直到当前元素是其自身的父元素(根元素):

public int find(int x) {

  if (parent[x] == x) {
    return x;
  } else {
    return find(parent[x]);
  }
}

实现并集操作

并集操作首先获取给定子集的根元素。此外,如果这两个根是不同的,它们需要依赖于它们的秩来决定哪一个将成为另一个的父(较大的秩将成为父)。如果它们的等级相同,则选择其中一个并将其等级增加 1:

public void union(int x, int y) {

  int xRoot = find(x);
  int yRoot = find(y);

  if (xRoot == yRoot) {
    return;
  }

  if (rank[xRoot] < rank[yRoot]) {
    parent[xRoot] = yRoot;
  } else if (rank[yRoot] < rank[xRoot]) {
    parent[yRoot] = xRoot;
  } else {
    parent[yRoot] = xRoot;
    rank[xRoot]++;
  }
}

好吧。现在让我们定义一个不相交集:

DisjointSet set = new DisjointSet(11);
set.union(0, 1);
set.union(4, 9);
set.union(6, 5);
set.union(0, 7);
set.union(4, 3);
set.union(4, 2);
set.union(6, 10);
set.union(4, 5);

现在让我们来玩玩它:

// is 4 and 0 friends => false
System.out.println("Is 4 and 0 friends: " 
  + (set.find(0) == set.find(4)));

// is 4 and 5 friends => true
System.out.println("Is 4 and 5 friends: " 
  + (set.find(4) == set.find(5)));

该算法可以通过压缩元素间的路径来优化。例如,检查下图:

在左侧,在寻找5的父代时,必须经过6直到4。同样,在寻找10的父代时,必须经过6,直到4为止。然而,在右侧,我们通过直接链接到4来压缩510的路径。这一次,我们不需要通过中间元素就可以找到510的父代。

路径压缩可以针对find()操作进行,如下所示:

public int find(int x) {

  if (parent[x] != x) {
    return parent[x] = find(parent[x]);
  }

  return parent[x];
}

捆绑到本书中的代码包含两个应用,有路径压缩和没有路径压缩。

127 Fenwick 树或二叉索引树

芬威克树FT)或二叉索引树BIT)是为存储对应于另一给定数组的和而构建的数组。构建数组的大小与给定数组的大小相同,并且构建数组的每个位置(或节点)都存储给定数组中某些元素的总和。由于 BIT 存储给定数组的部分和,因此通过避免索引之间的循环和计算和,它是计算给定数组中两个给定索引(范围和/查询)之间的元素和的非常有效的解决方案。

位可以在线性时间或O(n log n)中构造。显然,我们更喜欢线性时间,所以让我们看看如何做到这一点。我们从给定的(原始)数组开始,该数组可以是(下标表示数组中的索引):

3(1), 1(2), 5(3), 8(4), 12(5), 9(6), 7(7), 13(8), 0(9), 3(10), 1(11), 4(12), 9(13), 0(14), 11(15), 5(16)

构建位的想法依赖于最低有效位LSB)概念。更准确地说,假设我们正在处理索引中的元素,a。那么,紧靠我们上方的值必须位于索引b,其中b = a + LSB(a)。为了应用该算法,索引 0 的值必须是 0;因此,我们操作的数组如下:

0(0), 3(1), 1(2), 5(3), 8(4), 12(5), 9(6), 7(7), 13(8), 0(9), 3(10), 1(11), 4(12), 9(13), 0(14), 11(15), 5(16)

现在,让我们应用算法的几个步骤,用和填充位。在位的索引 0 处,我们有 0。此外,我们使用b = a + LSB(a)公式计算剩余和,如下所示:

  1. a=1:如果a=1=0b00001,则b=0b00001+0b00001=1+1=2=0b00010。我们说 2 负责a(也就是 1)。因此,在位中,在索引 1 处,我们存储值 3,在索引 2 处,我们存储值的和,3+1=4
  2. a=2:如果a=2=0b00010,则b=0b00010+0b00010=2+2=4=0b00100。我们说 4 负责a(即 2)。因此,在索引 4 处,我们以位的形式存储值的和,8+4=12
  3. a=3:如果a=3=0b00011,则b=0b00011+0b00001=3+1=4=0b00100。我们说 4 负责a(也就是 3)。因此,在位中,在索引 4 处,我们存储值的和,12+5=17
  4. a=4。如果a=4=0b00100,则b=0b00100+0b00100=4+4=8=0b01000。我们说 8 负责a(也就是 4)。因此,在位中,在索引 8 处,我们存储值的和,13+17=30

算法将以相同的方式继续,直到位完成。在图形表示中,我们的案例可以如下所示:

如果索引的计算点超出了界限,那么只需忽略它。

在代码行中,前面的流的形状可以如下所示(值是给定的数组):

public class FenwickTree {

  private final int n;
  private long[] tree;
  ...

  public FenwickTree(long[] values) {

    values[0] = 0 L;
    this.n = values.length;
    tree = values.clone();

    for (int i = 1; i < n; i++) {

      int parent = i + lsb(i);
      if (parent < n) {
        tree[parent] += tree[i];
      }
    }
  }

  private static int lsb(int i) {

      return i & -i;

      // or
      // return Integer.lowestOneBit(i);
    }

    ...
}

现在,位准备好了,我们可以执行更新和范围查询。

例如,为了执行范围求和,我们必须获取相应的范围并将它们相加。请考虑下图右侧的几个示例,以快速了解此过程:

就代码行而言,这可以很容易地形成如下形状:

public long sum(int left, int right) {

  return prefixSum(right) - prefixSum(left - 1);
}

private long prefixSum(int i) {

  long sum = 0L;

  while (i != 0) {
    sum += tree[i];
    i &= ~lsb(i); // or, i -= lsb(i);
  }

  return sum;
}

此外,我们还可以增加一个新的值:

public void add(int i, long v) {

  while (i < n) {
    tree[i] += v;
    i += lsb(i);
  }
}

我们还可以为某个索引设置一个新值:

public void set(int i, long v) {
  add(i, v - sum(i, i));
}

具备所有这些功能后,我们可以按如下方式为数组创建位:

FenwickTree tree = new FenwickTree(new long[] {
  0, 3, 1, 5, 8, 12, 9, 7, 13, 0, 3, 1, 4, 9, 0, 11, 5
});

然后我们可以玩它:

long sum29 = tree.sum(2, 9); // 55
tree.set(4, 3);
tree.add(4, 5);

128 布隆过滤器

布隆过滤器是一种快速高效的数据结构,能够提供问题的概率答案“值 X 在给定的集合中吗?”

通常情况下,当集合很大且大多数搜索算法都面临内存和速度问题时,此算法非常有用。

布隆过滤器的速度和内存效率来自这样一个事实,即该数据结构依赖于位数组(例如,java.util.BitSet)。最初,该数组的位被设置为0false

比特数组是布隆过滤器的第一个主要组成部分。第二个主要成分由一个或多个哈希函数组成。理想情况下,这些是成对独立的均匀分布的散列函数。另外,非常重要的是要非常快。murrur、fnv系列和HashMix是一些散列函数,它们在布鲁姆过滤器可以接受的范围内遵守这些约束。

现在,当我们向布隆过滤器添加一个元素时,我们需要对这个元素进行散列(通过每个可用的散列函数传递它),并将这些散列的索引处的位数组中的位设置为1true

下面的代码片段应该可以阐明主要思想:

private BitSet bitset; // the array of bits
private static final Charset CHARSET = StandardCharsets.UTF_8;
...
public void add(T element) {

  add(element.toString().getBytes(CHARSET));
}

public void add(byte[] bytes) {

  int[] hashes = hash(bytes, numberOfHashFunctions);

  for (int hash: hashes) {
    bitset.set(Math.abs(hash % bitSetSize), true);
  }

  numberOfAddedElements++;
}

现在,当我们搜索一个元素时,我们通过相同的散列函数传递这个元素。此外,我们检查结果值是否在位数组中标记为1true。如果不是,那么元素肯定不在集合中。但如果它们是,那么我们就以一定的概率知道元素在集合中。这不是 100% 确定的,因为另一个元素或元素的组合可能已经翻转了这些位。错误答案称为假正例

在代码行方面,我们有以下内容:

private BitSet bitset; // the array of bits
private static final Charset CHARSET = StandardCharsets.UTF_8;
...

public boolean contains(T element) {

  return contains(element.toString().getBytes(CHARSET));
}

public boolean contains(byte[] bytes) {

  int[] hashes = hash(bytes, numberOfHashFunctions);

  for (int hash: hashes) {
    if (!bitset.get(Math.abs(hash % bitSetSize))) {

      return false;
    }
  }

  return true;
}

在图形表示中,我们可以用大小为 11 的位数组和三个哈希函数来表示布隆过滤器,如下所示(我们添加了两个元素):

显然,我们希望尽可能减少假正例的数量。虽然我们不能完全消除它们,但我们仍然可以通过调整位数组的大小、哈希函数的数量和集合中元素的数量来影响它们的速率。

以下数学公式可用于塑造最佳布隆过滤器:

  • 过滤器中的项数(可根据mkp估计):

n = ceil(m / (-k / log(1 - exp(log(p) / k))));

  • 假正例的概率,介于 0 和 1 之间的分数,或表示p中的 1 的数量:

p = pow(1 - exp(-k / (m / n)), k);

  • 过滤器中的位数(或按 KB、KiB、MB、MB、GiB 等表示的大小):

m = ceil((n * log(p)) / log(1 / pow(2, log(2))));

  • 散列函数个数(可根据mn估计):

k = round((m / n) * log(2));

根据经验,一个较大的过滤器比一个较小的过滤器具有更少的假正例。此外,通过增加散列函数的数量,我们可以获得较少的假正例,但我们会减慢过滤器的速度,并将其快速填充。布隆过滤器的性能为O(h),其中h是散列函数的个数。

在本书附带的代码中,有一个布隆过滤器的实现,它使用基于 SHA-256 和 murrur 的散列函数。由于这段代码太大,无法在本书中列出,因此请考虑将Main类中的示例作为起点。

总结

本章涵盖了涉及数组、集合和几个数据结构的 30 个问题。虽然涉及数组和集合的问题是日常工作的一部分,但涉及数据结构的问题引入了一些不太知名(但功能强大)的数据结构,如并查集和 Trie。

从本章下载应用以查看结果并检查其他详细信息。**

六、Java I/O 路径、文件、缓冲区、扫描和格式化

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括 20 个涉及文件 Java I/O 的问题。从操作、行走和观察流文件的路径,以及读/写文本和二进制文件的有效方法,我们将介绍 Java 开发人员可能面临的日常问题。

通过本章所学到的技能,您将能够解决大多数涉及 Java I/O 文件的常见问题。本章中的广泛主题将提供大量有关 Java 如何处理 I/O 任务的信息

问题

为了测试您的 Java I/O 编程能力,请看下面的问题。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 创建文件路径:写几个创建几种文件路径的例子(如绝对路径、相对路径等)。
  2. 转换文件路径:写几个转换文件路径的例子(例如,将文件路径转换成字符串、URI、文件等)。
  3. 连接文件路径:写几个连接(组合)文件路径的例子。定义一个固定路径并向其附加其他不同的路径(或用其他路径替换其中的一部分)。
  4. 在两个位置之间构造路径:写出几个例子,在两个给定路径之间(从一条路径到另一条路径)之间构造相对路径。
  5. 比较文件路径:写几个比较给定文件路径的例子。
  6. 遍历路径:编写一个程序,访问一个目录下的所有文件,包括子目录。此外,编写一个程序,按名称搜索文件、删除目录、移动目录和复制目录。
  7. 监视路径:编写多个程序,监视某条路径上发生的变化(如创建、删除、修改)。
  8. “流式传输文件内容”:编写一个流式传输给定文件内容的程序。
  9. 在文件树中搜索文件/文件夹:编写一个程序,在给定的文件树中搜索给定的文件/文件夹。
  10. “高效读写文本文件”:编写几个程序,举例说明高效读写文本文件的不同方法。
  11. 高效读写二进制文件:编写几个程序,举例说明高效读写二进制文件的不同方法。
  12. 在大文件中搜索:编写一个程序,在大文件中高效地搜索给定的字符串。
  13. 将 JSON/CSV 文件作为对象读取:编写一个程序,将给定的 JSON/CSV 文件作为对象读取(POJO)。
  14. 使用临时文件/文件夹:编写几个使用临时文件/文件夹的程序。
  15. 过滤文件:为文件编写多个自定义过滤器。
  16. 发现两个文件之间的不匹配:编写一个程序,在字节级发现两个文件之间的不匹配。
  17. 循环字节缓冲区:编写一个表示循环字节缓冲区实现的程序。
  18. 分词文件:写几个代码片段来举例说明分词文件内容的不同技术。
  19. 将格式化输出直接写入文件:编写一个程序,将给定的数字(整数和双精度)格式化并输出到文件中。
  20. 使用Scanner:写几个代码片段来展示Scanner的功能。

解决方案

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释只包括解决问题所需的最有趣和最重要的细节。您可以从这个页面下载示例解决方案以查看更多详细信息并尝试程序。

129 创建文件路径

从 JDK7 开始,我们可以通过 NIO.2API 创建一个文件路径。更准确地说,可以通过PathPathsAPI 轻松定义文件路径。

Path类是文件系统中路径的编程表示。路径字符串包含以下信息:

  • 文件名
  • 目录列表
  • 依赖于操作系统的文件分隔符(例如,在 Solaris 和 Linux 上为正斜杠/,在 Microsoft Windows 上为反斜杠\
  • 其他允许的字符,例如,.(当前目录)和..(父目录)符号

Path类处理不同文件系统(FileSystem)中的文件,这些文件系统可以使用不同的存储位置(FileStore是底层存储)。

定义Path的一个常见解决方案是调用Paths辅助类的get()方法之一。另一种解决方案依赖于FileSystems.getDefault().getPath()方法。

Path驻留在文件系统中—文件系统存储和组织文件或某种形式的媒体,通常在一个或多个硬盘驱动器上,以便于检索。文件系统可以通过java.nio.file.FileSystemsfinal类获取,用于获取java.nio.file.FileSystem的实例。JVM 的默认FileSystem(俗称操作系统的默认文件系统)可以通过FileSystems().getDefault()方法获得。一旦我们知道文件系统和文件(或目录/文件夹)的位置,我们就可以为它创建一个Path对象。

另一种方法包括从统一资源标识符URI)创建Path。Java 通过URI类包装一个URI,然后我们可以通过URI.create(String uri)方法从一个String获得一个URI。此外,Paths类提供了一个get()方法,该方法将URI对象作为参数并返回相应的Path

从 JDK11 开始,我们可以通过两个方法创建一个Path。其中一个将URI转换为Path,而另一个将路径字符串或字符串序列转换为路径字符串。

在接下来的部分中,我们将了解创建路径的各种方法。

创建相对于文件存储根目录的路径

相对于当前文件存储根目录的路径(例如,C:/)必须以文件分隔符开头。在下面的例子中,如果当前文件存储根为C,则绝对路径为C:\learning\packt\JavaModernChallenge.pdf

Path path = Paths.get("/learning/packt/JavaModernChallenge.pdf");
Path path = Paths.get("/learning", "packt/JavaModernChallenge.pdf");

Path path = Path.of("/learning/packt/JavaModernChallenge.pdf");
Path path = Path.of("/learning", "packt/JavaModernChallenge.pdf");

Path path = FileSystems.getDefault()
  .getPath("/learning/packt", "JavaModernChallenge.pdf");
Path path = FileSystems.getDefault()
  .getPath("/learning/packt/JavaModernChallenge.pdf");

Path path = Paths.get(
  URI.create("file:///learning/packt/JavaModernChallenge.pdf"));
Path path = Path.of(
  URI.create("file:///learning/packt/JavaModernChallenge.pdf"));

创建相对于当前文件夹的路径

当我们创建一个相对于当前工作文件夹的路径时,路径应该以文件分隔符开头。如果当前文件夹名为books并且在C根目录下,那么下面代码段返回的绝对路径将是C:\books\learning\packt\JavaModernChallenge.pdf

Path path = Paths.get("learning/packt/JavaModernChallenge.pdf");
Path path = Paths.get("learning", "packt/JavaModernChallenge.pdf");

Path path = Path.of("learning/packt/JavaModernChallenge.pdf");
Path path = Path.of("learning", "packt/JavaModernChallenge.pdf");

Path path = FileSystems.getDefault()
  .getPath("learning/packt", "JavaModernChallenge.pdf");
Path path = FileSystems.getDefault()
  .getPath("learning/packt/JavaModernChallenge.pdf");

创建绝对路径

创建绝对路径可以通过显式指定根目录和包含文件或文件夹的所有其他子目录来完成,如以下示例(C:\learning\packt\JavaModernChallenge.pdf)所示:

Path path = Paths.get("C:/learning/packt", "JavaModernChallenge.pdf");
Path path = Paths.get(
  "C:", "learning/packt", "JavaModernChallenge.pdf");
Path path = Paths.get(
  "C:", "learning", "packt", "JavaModernChallenge.pdf");
Path path = Paths.get("C:/learning/packt/JavaModernChallenge.pdf");
Path path = Paths.get(
  System.getProperty("user.home"), "downloads", "chess.exe");

Path path = Path.of(
  "C:", "learning/packt", "JavaModernChallenge.pdf");
Path path = Path.of(
  System.getProperty("user.home"), "downloads", "chess.exe");

Path path = Paths.get(URI.create(
  "file:///C:/learning/packt/JavaModernChallenge.pdf"));
Path path = Path.of(URI.create(
  "file:///C:/learning/packt/JavaModernChallenge.pdf"));

使用快捷方式创建路径

我们理解快捷方式是.(当前目录)和..(父目录)符号。这种路径可以通过normalize()方法进行归一化。此方法消除了冗余,例如.directory/..

Path path = Paths.get(
  "C:/learning/packt/chapters/../JavaModernChallenge.pdf")
    .normalize();
Path path = Paths.get(
  "C:/learning/./packt/chapters/../JavaModernChallenge.pdf")
    .normalize();

Path path = FileSystems.getDefault()
  .getPath("/learning/./packt", "JavaModernChallenge.pdf")
    .normalize();

Path path = Path.of(
  "C:/learning/packt/chapters/../JavaModernChallenge.pdf")
    .normalize();
Path path = Path.of(
  "C:/learning/./packt/chapters/../JavaModernChallenge.pdf")
    .normalize();

如果不规范化,路径的冗余部分将不会被删除。

为了创建与当前操作系统 100% 兼容的路径,我们可以依赖于FileSystems.getDefault().getPath(),或者是File.separator(依赖于系统的默认名称分隔符)和File.listRoots()(可用的文件系统根)的组合。对于相对路径,我们可以依赖以下示例:

private static final String FILE_SEPARATOR = File.separator;

或者,我们可以依赖getSeparator()


private static final String FILE_SEPARATOR
  = FileSystems.getDefault().getSeparator();

// relative to current working folder
Path path = Paths.get("learning",
  "packt", "JavaModernChallenge.pdf");
Path path = Path.of("learning",
  "packt", "JavaModernChallenge.pdf");
Path path = Paths.get(String.join(FILE_SEPARATOR, "learning",
  "packt", "JavaModernChallenge.pdf"));
Path path = Path.of(String.join(FILE_SEPARATOR, "learning",
  "packt", "JavaModernChallenge.pdf"));

// relative to the file store root
Path path = Paths.get(FILE_SEPARATOR + "learning",
  "packt", "JavaModernChallenge.pdf");
Path path = Path.of(FILE_SEPARATOR + "learning",
  "packt", "JavaModernChallenge.pdf");

我们也可以对绝对路径执行相同的操作:

Path path = Paths.get(File.listRoots()[0] + "learning",
  "packt", "JavaModernChallenge.pdf");
Path path = Path.of(File.listRoots()[0] + "learning",
  "packt", "JavaModernChallenge.pdf");

根目录列表也可以通过FileSystems获得:

FileSystems.getDefault().getRootDirectories()

130 转换文件路径

将文件路径转换为StringURIFile等是一项常见任务,可以在广泛的应用中发生。让我们考虑以下文件路径:

Path path = Paths.get("/learning/packt", "JavaModernChallenge.pdf");

现在,基于 JDK7 和 NIO.2 API,让我们看看如何将一个Path转换成一个String、一个URI、一个绝对路径、一个路径和一个文件:

  • Path转换为String非常简单,只需(显式地或自动地)调用Path.toString()方法。注意,如果路径是通过FileSystem.getPath()方法获得的,那么toString()返回的路径字符串可能与用于创建路径的初始String不同:
// \learning\packt\JavaModernChallenge.pdf
String pathToString = path.toString();
  • 可以通过Path.toURI()方法将Path转换为URI(浏览器格式)。返回的URI包装了一个可在 Web 浏览器地址栏中使用的路径字符串:
// file:///D:/learning/packt/JavaModernChallenge.pdf
URI pathToURI = path.toUri();

假设我们想要将URI/URL中的文件名提取为Path(这是常见的场景)。在这种情况下,我们可以依赖以下代码片段:

// JavaModernChallenge.pdf
URI uri = URI.create(
  "https://www.learning.com/packt/JavaModernChallenge.pdf");
Path URIToPath = Paths.get(uri.getPath()).getFileName();

// JavaModernChallenge.pdf
URL url = new URL(
  "https://www.learning.com/packt/JavaModernChallenge.pdf");
Path URLToPath = Paths.get(url.getPath()).getFileName();

路径转换可按以下方式进行:

  • 可以通过Path.toAbsolutePath()方法将相对Path转换为绝对Path。如果Path已经是绝对值,则返回相同的结果:
// D:\learning\packt\JavaModernChallenge.pdf
Path pathToAbsolutePath = path.toAbsolutePath();
  • 通过Path.toRealPath()方法可以将Path转换为实际Path,其结果取决于实现。如果所指向的文件不存在,则此方法将抛出一个IOException。但是,根据经验,调用此方法的结果是没有冗余元素的绝对路径(标准化)。此方法获取一个参数,该参数指示应如何处理符号链接。默认情况下,如果文件系统支持符号链接,则此方法将尝试解析它们。如果您想忽略符号链接,只需将LinkOption.NOFOLLOW_LINKS常量传递给方法即可。此外,路径名元素将表示目录和文件的实际名称。

例如,让我们考虑下面的Path和调用此方法的结果(注意,我们故意添加了几个冗余元素并将PACKT文件夹大写):

Path path = Paths.get(
  "/learning/books/../PACKT/./", "JavaModernChallenge.pdf");

// D:\learning\packt\JavaModernChallenge.pdf
Path realPath = path.toRealPath(LinkOption.NOFOLLOW_LINKS);
  • 可通过Path.toFile()方法将Path转换为文件。将一个文件转换成一个Path,我们可以依赖File.toPath()方法:
File pathToFile = path.toFile();
Path fileToPath = pathToFile.toPath();

131 连接文件路径

连接(或组合)文件路径意味着定义一个固定的根路径,并附加一个部分路径或替换其中的一部分(例如,一个文件名需要替换为另一个文件名)。基本上,当我们想要创建共享公共固定部分的新路径时,这是一种方便的技术。

这可以通过 NIO.2 和Path.resolve()Path.resolveSibling()方法来实现。

让我们考虑以下固定根路径:

Path base = Paths.get("D:/learning/packt");

我们还假设我们想要得到两本不同书籍的Path

// D:\learning\packt\JBossTools3.pdf
Path path = base.resolve("JBossTools3.pdf");

// D:\learning\packt\MasteringJSF22.pdf
Path path = base.resolve("MasteringJSF22.pdf");

我们可以使用此函数循环一组文件;例如,让我们循环一String[]本书:

Path basePath = Paths.get("D:/learning/packt");
String[] books = {
  "Book1.pdf", "Book2.pdf", "Book3.pdf"
};

for (String book: books) {
  Path nextBook = basePath.resolve(book);
  System.out.println(nextBook);
}

有时,固定根路径也包含文件名:

Path base = Paths.get("D:/learning/packt/JavaModernChallenge.pdf");

这一次,我们可以通过resolveSibling()方法将文件名(JavaModernChallenge.pdf替换为另一个名称。此方法根据此路径的父路径解析给定路径,如下例所示:

// D:\learning\packt\MasteringJSF22.pdf
Path path = base.resolveSibling("MasteringJSF22.pdf");

如果我们将Path.getParent()方法引入讨论,并将resolve()resolveSibling()方法链接起来,那么我们可以创建更复杂的路径,如下例所示:

// D:\learning\publisher\MyBook.pdf
Path path = base.getParent().resolveSibling("publisher")
  .resolve("MyBook.pdf");

resolve()/resolveSibling()方法分为两种,分别是resolve​(String other)/resolveSibling​(String other)resolve​(Path other)/resolveSibling​(Path other)

132 在两个位置之间构建路径

在两个位置之间构建相对路径是Path.relativize()方法的工作。

基本上,得到的相对路径(由Path.relativize()返回)从一条路径开始,在另一条路径上结束。这是一个强大的功能,它允许我们使用相对路径在不同的位置之间导航,相对路径是根据前面的路径解析的。

让我们考虑以下两种途径:

Path path1 = Paths.get("JBossTools3.pdf");
Path path2 = Paths.get("JavaModernChallenge.pdf");

注意,JBossTools3.pdfJavaModernChallenge.pdf是兄弟姐妹。这意味着我们可以通过上一级然后下一级从一个导航到另一个。以下示例也揭示了这种导航情况:

// ..\JavaModernChallenge.pdf
Path path1ToPath2 = path1.relativize(path2);

// ..\JBossTools3.pdf
Path path2ToPath1 = path2.relativize(path1);

另一种常见情况涉及公共根元素:

Path path3 = Paths.get("/learning/packt/2003/JBossTools3.pdf");
Path path4 = Paths.get("/learning/packt/2019");

所以,path3path4共享相同的根元素/learning。从path3path4需要上两层下一层。另外,从path4path3的航行,需要上一级,下两级。查看以下代码:

// ..\..\2019
Path path3ToPath4 = path3.relativize(path4);

// ..\2003\JBossTools3.pdf
Path path4ToPath3 = path4.relativize(path3);

两个路径都必须包含根元素。完成这个需求并不能保证成功,因为相对路径的构建依赖于实现。

133 比较文件路径

根据我们如何看待两个文件路径之间的相等性,有几种解决方案。主要来说,平等性可以通过不同的方式为不同的目标进行验证。

假设我们有以下三种路径(考虑在您的计算机上复制C:\learning\packt\JavaModernChallenge.pdf

Path path1 = Paths.get("/learning/packt/JavaModernChallenge.pdf");
Path path2 = Paths.get("/LEARNING/PACKT/JavaModernChallenge.pdf");
Path path3 = Paths.get("D:/learning/packt/JavaModernChallenge.pdf");

在下面的部分中,我们将研究用于比较文件路径的不同方法。

Path.equals()

path1等于path2吗?或者,path2等于path3吗?好吧,如果我们通过Path.equals()进行这些测试,那么可能的结果将显示path1等于path2,但path2不等于path3

boolean path1EqualsPath2 = path1.equals(path2); // true
boolean path2EqualsPath3 = path2.equals(path3); // false

Path.equals()方法遵循Object.equals()规范。虽然此方法不访问文件系统,但相等性取决于文件系统实现。例如,一些文件系统实现可能会以区分大小写的方式比较路径,而其他文件系统实现可能会忽略大小写。

表示相同文件/文件夹的路径

然而,这可能不是我们想要的那种比较。如果两条路径是相同的文件或文件夹,那么说它们相等就更有意义了。这可以通过Files.isSameFile()方法来实现。此方法分为两个步骤:

  1. 首先,它调用Path.equals(),如果此方法返回true,则路径相等,无需进一步操作。

  2. 其次,如果Path.equals()返回false,则检查两个路径是否代表同一个文件/文件夹(根据实现,此操作可能需要打开/访问两个文件,因此文件必须存在,以避免出现IOException)。

//true
boolean path1IsSameFilePath2 = Files.isSameFile(path1, path2);
//true
boolean path1IsSameFilePath3 = Files.isSameFile(path1, path3);
//true
boolean path2IsSameFilePath3 = Files.isSameFile(path2, path3);

词典比较

如果我们只需要对路径进行词典比较,那么我们可以依赖于Path.compareTo()方法(这对于排序很有用)。

此方法返回以下信息:

  • 如果路径相等,则为 0
  • 如果第一条路径在词典上小于参数路径,则该值小于零
  • 如果第一条路径在词典中大于参数路径,则该值大于零:
int path1compareToPath2 = path1.compareTo(path2); // 0
int path1compareToPath3 = path1.compareTo(path3); // 24
int path2compareToPath3 = path2.compareTo(path3); // 24

请注意,您可能会获得与上一示例不同的值。此外,在您的业务逻辑中,重要的是依赖于它们的含义,而不是依赖于它们的值(例如,说if(path1compareToPath3 > 0) { ... },避免使用if(path1compareToPath3 == 24) { ... })。

部分比较

部分比较可通过Path.startsWith()Path.endsWith()方法实现。使用这些方法,我们可以测试当前路径是否以给定路径开始/结束:

boolean sw = path1.startsWith("/learning/packt");       // true
boolean ew = path1.endsWith("JavaModernChallenge.pdf"); // true

134 遍历

对于遍历(或访问)路径有不同的解决方案,其中一种由 NIO.2API 通过FileVisitor接口提供。

此接口公开了一组方法,这些方法表示访问给定路径的递归过程中的检查点。通过覆盖这些检查点,我们可以干预这个过程。我们可以处理当前访问的文件/文件夹,并通过FileVisitResult枚举决定应该进一步执行的操作,该枚举包含以下常量:

  • CONTINUE:遍历过程应该继续(访问下一个文件、文件夹、跳过失败等)
  • SKIP_SIBLINGS:遍历过程应继续,而不访问当前文件/文件夹的同级
  • SKIP_SUBTREE:遍历过程应继续,而不访问当前文件夹中的条目
  • TERMINATE:遍历应该残酷地终止

FileVisitor公开的方法如下:

  • FileVisitResult visitFile​(T file, BasicFileAttributes attrs) throws IOException:对每个访问的文件/文件夹自动调用
  • FileVisitResult preVisitDirectory​(T dir, BasicFileAttributes attrs) throws IOException:在访问文件夹内容前自动调用文件夹
  • FileVisitResult postVisitDirectory​(T dir, IOException exc) throws IOException:在目录(包括子目录)中的内容被访问后,或在文件夹的迭代过程中,发生 I/O 错误或访问被编程中止后自动调用
  • FileVisitResult visitFileFailed​(T file, IOException exc) throws IOException:由于不同原因(如文件属性无法读取或文件夹无法打开)无法访问(访问)文件时自动调用

好的,到目前为止,很好!让我们继续几个实际的例子。

琐碎的文件夹遍历

实现FileVisitor接口需要覆盖它的四个方法。然而,NIO.2 附带了这个接口的一个内置的简单实现,称为SimpleFileVisitor。对于简单的情况,扩展这个类比实现FileVisitor更方便,因为它只允许我们覆盖必要的方法。

例如,假设我们将电子课程存储在D:/learning文件夹的子文件夹中,我们希望通过FileVisitorAPI 访问每个子文件夹。如果在子文件夹的迭代过程中出现问题,我们只会抛出报告的异常。

为了塑造这种行为,我们需要覆盖postVisitDirectory()方法,如下所示:

class PathVisitor extends SimpleFileVisitor<Path> {

  @Override
  public FileVisitResult postVisitDirectory(
      Path dir, IOException ioe) throws IOException {

    if (ioe != null) {
      throw ioe;
    }

    System.out.println("Visited directory: " + dir);

    return FileVisitResult.CONTINUE;
  }
}

为了使用PathVisitor类,我们只需要设置路径并调用其中一个Files.walkFileTree()方法,如下所示(这里使用的walkFileTree()的风格得到起始文件/文件夹和相应的FileVisitor

Path path = Paths.get("D:/learning");
PathVisitor visitor = new PathVisitor();

Files.walkFileTree(path, visitor);

通过使用前面的代码,我们将收到以下输出:

Visited directory: D:\learning\books\ajax
Visited directory: D:\learning\books\angular
...

按名称搜索文件

在计算机上搜索某个文件是一项常见的任务。通常,我们依赖于操作系统提供的工具或其他工具,但如果我们想通过编程实现这一点(例如,我们可能想编写一个具有特殊功能的文件搜索工具),那么FileVisitor可以帮助我们以非常简单的方式实现这一点。本申请存根如下:

public class SearchFileVisitor implements FileVisitor {

  private final Path fileNameToSearch;
  private boolean fileFound;
  ...

  private boolean search(Path file) throws IOException {

    Path fileName = file.getFileName();

    if (fileNameToSearch.equals(fileName)) {
      System.out.println("Searched file was found: " +
        fileNameToSearch + " in " + file.toRealPath().toString());

      return true;
    }

    return false;
  }
}

让我们看看主要检查点和按名称搜索文件的实现:

  • visitFile()是我们的主要检查站。一旦有了控制权,就可以查询当前访问的文件的名称、扩展名、属性等。需要此信息才能与搜索文件上的相同信息进行比较。例如,我们比较名字,在第一次匹配时,我们TERMINATE搜索。但是如果我们搜索更多这样的文件(如果我们知道不止一个),那么我们可以返回CONTINUE
@Override
public FileVisitResult visitFile(
  Object file, BasicFileAttributes attrs) throws IOException {

  fileFound = search((Path) file);

  if (!fileFound) {
    return FileVisitResult.CONTINUE;
  } else {
    return FileVisitResult.TERMINATE;
  }
}

visitFile()方法不能用于查找文件夹。改用preVisitDirectory()postVisitDirectory()方法。

  • visitFileFailed()是第二个重要关卡。调用此方法时,我们知道在访问当前文件时出现了问题。我们宁愿忽略任何这样的问题和搜索。停止搜索过程毫无意义:
@Override
public FileVisitResult visitFileFailed(
  Object file, IOException ioe) throws IOException {
  return FileVisitResult.CONTINUE;
}

preVisitDirectory()postVisitDirectory()方法没有任何重要的任务,因此为了简洁起见,我们可以跳过它们。

为了开始搜索,我们依赖另一种风格的Files.walkFileTree()方法。这一次,我们指定搜索的起点(例如,所有根)、搜索期间使用的选项(例如,跟随符号链接)、要访问的最大目录级别数(例如,Integer.MAX_VALUE)和FileVisitor(例如,SearchFileVisitor):

Path searchFile = Paths.get("JavaModernChallenge.pdf");

SearchFileVisitor searchFileVisitor 
  = new SearchFileVisitor(searchFile);

EnumSet opts = EnumSet.of(FileVisitOption.FOLLOW_LINKS);
Iterable<Path> roots = FileSystems.getDefault().getRootDirectories();

for (Path root: roots) {
  if (!searchFileVisitor.isFileFound()) {
    Files.walkFileTree(root, opts,
      Integer.MAX_VALUE, searchFileVisitor);
  }
}

如果您查看本书附带的代码,前面的搜索将以递归方式遍历计算机的所有根(目录)。前面的例子可以很容易地通过扩展名、模式进行搜索,或者从一些文本中查看文件内部。

删除文件夹

在试图删除文件夹之前,我们必须删除其中的所有文件。这个语句非常重要,因为它不允许我们对包含文件的文件夹简单地调用delete()/deleteIfExists()方法。此问题的优雅解决方案依赖于从以下存根开始的FileVisitor实现:

public class DeleteFileVisitor implements FileVisitor {
  ...
  private static boolean delete(Path file) throws IOException {

    return Files.deleteIfExists(file);
  }
}

让我们看看主要检查点和删除文件夹的实现:

  • visitFile()是从给定文件夹或子文件夹删除每个文件的理想位置(如果文件不能删除,则我们只需将其传递到下一个文件,但可以随意调整代码以满足您的需要):
@Override
public FileVisitResult visitFile(
  Object file, BasicFileAttributes attrs) throws IOException {

  delete((Path) file);

  return FileVisitResult.CONTINUE;
}
  • 只有当文件夹为空时,才可以删除它,因此postVisitDirectory()是执行此操作的最佳位置(我们忽略任何潜在的IOException,但可以随意调整代码以满足您的需要(例如,记录无法删除的文件夹的名称或引发异常以停止进程)):
@Override
public FileVisitResult postVisitDirectory(
    Object dir, IOException ioe) throws IOException {

  delete((Path) dir);

  return FileVisitResult.CONTINUE;
}

visitFileFailed()preVisitDirectory()中,我们只返回CONTINUE

删除文件夹时,在D:/learning中,我们可以调用DeleteFileVisitor,如下所示:

Path directory = Paths.get("D:/learning");
DeleteFileVisitor deleteFileVisitor = new DeleteFileVisitor();
EnumSet opts = EnumSet.of(FileVisitOption.FOLLOW_LINKS);

Files.walkFileTree(directory, opts, 
  Integer.MAX_VALUE, deleteFileVisitor);

通过将SearchFileVisitorDeleteFileVisitor组合,我们可以得到一个搜索删除应用。

复制文件夹

为了复制一个文件,我们可以使用Path copy​(Path source, Path target, CopyOption options) throws IOException方法。此方法使用指定如何执行复制的参数options将文件复制到目标文件。

通过将copy()方法与自定义FileVisitor相结合,我们可以复制整个文件夹(包括其所有内容)。本次定制FileVisitor存根代码如下:

public class CopyFileVisitor implements FileVisitor {

  private final Path copyFrom;
  private final Path copyTo;
  ...

  private static void copySubTree(
      Path copyFrom, Path copyTo) throws IOException {

    Files.copy(copyFrom, copyTo, 
      REPLACE_EXISTING, COPY_ATTRIBUTES);
  }
}

让我们看一下主要的检查点和复制文件夹的实现(注意,我们将通过复制任何可以复制的内容来进行操作,并避免抛出异常,但可以随意调整代码以满足您的需要):

  • 在从源文件夹复制任何文件之前,我们需要复制源文件夹本身。复制源文件夹(空或不空)将导致目标文件夹为空。这是用preVisitDirectory()方法完成的完美任务:
@Override
public FileVisitResult preVisitDirectory(
  Object dir, BasicFileAttributes attrs) throws IOException {

  Path newDir = copyTo.resolve(
    copyFrom.relativize((Path) dir));

  try {
    Files.copy((Path) dir, newDir, 
      REPLACE_EXISTING, COPY_ATTRIBUTES);
  } catch (IOException e) {
    System.err.println("Unable to create "
      + newDir + " [" + e + "]");

    return FileVisitResult.SKIP_SUBTREE;
  }

  return FileVisitResult.CONTINUE;
}
  • visitFile()方法是复制每个文件的最佳场所:
@Override
public FileVisitResult visitFile(
  Object file, BasicFileAttributes attrs) throws IOException {

  try {
    copySubTree((Path) file, copyTo.resolve(
      copyFrom.relativize((Path) file)));
  } catch (IOException e) {
    System.err.println("Unable to copy " 
      + copyFrom + " [" + e + "]");
  }

  return FileVisitResult.CONTINUE;
}
  • 或者,我们可以保留源目录的属性。只有将文件复制到postVisitDirectory()方法后才能完成(例如,保留上次修改的时间):
@Override
public FileVisitResult postVisitDirectory(
    Object dir, IOException ioe) throws IOException {

  Path newDir = copyTo.resolve(
    copyFrom.relativize((Path) dir));

  try {
    FileTime time = Files.getLastModifiedTime((Path) dir);
    Files.setLastModifiedTime(newDir, time);
  } catch (IOException e) {
    System.err.println("Unable to preserve 
      the time attribute to: " + newDir + " [" + e + "]");
  }

  return FileVisitResult.CONTINUE;
}
  • 如果无法访问文件,则调用visitFileFailed()。现在是检测循环链接并报告它们的好时机。通过以下链接(FOLLOW_LINKS),我们可以遇到文件树与父文件夹有循环链接的情况。这些病例通过visitFileFailed()中的FileSystemLoopException异常报告:
@Override
public FileVisitResult visitFileFailed(
    Object file, IOException ioe) throws IOException {

  if (ioe instanceof FileSystemLoopException) {
    System.err.println("Cycle was detected: " + (Path) file);
  } else {
    System.err.println("Error occured, unable to copy:"
      + (Path) file + " [" + ioe + "]");
  }

  return FileVisitResult.CONTINUE;
}

D:/learning/packt文件夹复制到D:/e-courses

Path copyFrom = Paths.get("D:/learning/packt");
Path copyTo = Paths.get("D:/e-courses");

CopyFileVisitor copyFileVisitor 
  = new CopyFileVisitor(copyFrom, copyTo);

EnumSet opts = EnumSet.of(FileVisitOption.FOLLOW_LINKS);

Files.walkFileTree(copyFrom, opts, Integer.MAX_VALUE, copyFileVisitor);

通过组合CopyFileVisitorDeleteFileVisitor,我们可以很容易地形成一个移动文件夹的应用。在本书附带的代码中,还有一个移动文件夹的完整示例。基于我们迄今为止积累的专业知识,代码应该是非常容易访问的,没有更多的细节。

在记录有关文件的信息(例如,处理异常的情况)时要注意,因为文件(例如,它们的名称、路径和属性)可能包含敏感信息,这些信息可能会被恶意利用。

JDK8,Files.walk()

从 JDK8 开始,Files类用两个walk()方法进行了丰富。这些方法返回一个由Path惰性填充的Stream。它通过使用给定的最大深度和选项遍历以给定起始文件为根的文件树来实现这一点:

public static Stream<Path> walk​(
  Path start, FileVisitOption...options) 
    throws IOException

public static Stream<Path> walk​(
  Path start, int maxDepth, FileVisitOption...options) 
    throws IOException

例如,让我们显示从D:/learning开始并以D:/learning/books/cdi开头的所有路径:

Path directory = Paths.get("D:/learning");

Stream<Path> streamOfPath = Files.walk(
  directory, FileVisitOption.FOLLOW_LINKS);

streamOfPath.filter(e -> e.startsWith("D:/learning/books/cdi"))
  .forEach(System.out::println);

现在,让我们计算一个文件夹的字节大小(例如,D:/learning):

long folderSize = Files.walk(directory)
  .filter(f -> f.toFile().isFile())
  .mapToLong(f -> f.toFile().length())
  .sum();

此方法为弱一致。它不会在迭代过程中冻结文件树。对文件树的潜在更新可能会反映出来,也可能不会反映出来。

135 监视路径

监视路径的变化只是可以通过 JDK7nio.2(低级的WatchServiceAPI)实现的线程安全目标之一。

简而言之,可以通过以下两个主要步骤来观察路径的变化:

  1. 为不同类型的事件类型注册要监视的文件夹。
  2. WatchService检测到注册的事件类型时,它在单独的线程中处理,因此监视服务不会被阻塞。

在 API 级别,起点是WatchService接口。对于不同的文件/操作系统,这个接口有不同的风格。

这个接口与两个主要类一起工作。它们一起提供了一种方便的方法,您可以实现这种方法来将监视功能添加到特定上下文(例如,文件系统):

  • Watchable:实现此接口的任何对象都是可观察对象,因此可以观察其变化(例如Path

  • StandardWatchEventKinds:这个类定义了标准的事件类型(这些是我们可以注册通知的事件类型:

    • ENTRY_CREATE:创建目录条目
    • ENTRY_DELETE:删除目录条目
    • ENTRY_MODIFY:目录条目已修改;被认为是修改的内容在某种程度上是特定于平台的,但实际上修改文件的内容应该始终触发此事件类型
    • OVERFLOW:一种特殊事件,表示事件可能已经丢失或丢弃

WatchService被称为观察者,我们说观察者观察可观察对象。在下面的示例中,WatchService将通过FileSystem类创建,并监视已注册的Path

监视文件夹的更改

让我们从一个桩方法开始,该方法获取应该监视其更改的文件夹的Path作为参数:

public void watchFolder(Path path) 
    throws IOException, InterruptedException {
  ...
}

当给定文件夹中出现ENTRY_CREATEENTRY_DELETEENTRY_MODIFY事件类型时,WatchService将通知我们。为此,我们需要遵循以下几个步骤:

  1. 创建WatchService以便我们可以监视文件系统这是通过FileSystem.newWatchService()完成的,如下所示:
WatchService watchService 
  = FileSystems.getDefault().newWatchService();
  1. 注册应通知的事件类型这是通过Watchable.register()完成的:
path.register(watchService, StandardWatchEventKinds.ENTRY_CREATE,
  StandardWatchEventKinds.ENTRY_MODIFY,
  StandardWatchEventKinds.ENTRY_DELETE);

对于每个可监视对象,我们接收一个注册令牌作为WatchKey实例(监视键)。我们在注册时收到这个监视键,但是每次触发事件时WatchService都返回相关的WatchKey

  1. 现在,我们需要等待传入事件。这是在无限循环中完成的(当事件发生时,观察者负责将相应的观察键排队等待以后检索,并将其状态更改为已发射
while (true) {
  // process the incoming event types
}
  1. 现在,我们需要检索监视键——检索监视键的方法至少有三种:
    • poll():返回队列中的下一个键并将其移除(或者,如果没有键,则返回null)。
    • poll​(long timeout, TimeUnit unit):返回队列中的下一个键并将其删除;如果没有键,则等待指定的超时并重试。如果键仍然不可用,则返回null
    • take():返回队列中的下一个键并将其删除;如果没有键,则等待某个键排队或无限循环停止:
WatchKey key = watchService.take();
  1. 接下来,我们需要检索监视键的未决事件。一个已发射状态的监视键至少有一个挂起事件,我们可以通过WatchKey.pollEvents()方法检索并删除某个监视键的所有事件(每个事件由一个WatchEvent实例表示):
for (WatchEvent<?> watchEvent : key.pollEvents()) {
  ...
}
  1. 然后,我们检索关于事件类型的信息。对于每个事件,我们可以获得不同的信息(例如,事件类型、出现次数和上下文特定的信息(例如,导致事件的文件名),这对于处理事件很有用):
Kind<?> kind = watchEvent.kind();
WatchEvent<Path> watchEventPath = (WatchEvent<Path>) watchEvent;
Path filename = watchEventPath.context();
  1. 接下来,我们重置监视键监视键的状态可以是就绪(创建时的初始状态)、已发射无效。一旦发出信号,一个监视键就会保持这样,直到我们调用reset()方法,尝试将其放回就绪状态,接受事件的状态。如果已发射就绪(恢复等待事件)转换成功,则reset()方法返回true;否则返回false,表示监视键可能无效。一个监视键如果不再处于活动状态,可能会处于无效状态(显式调用监视键close()方法、关闭监视程序、删除目录等都会导致不活动):
boolean valid = key.reset();

if (!valid) {
  break;
}

当有一个监视键处于无效状态时,就没有理由停留在无限循环中。只需调用break即可跳出循环。

  1. 最后,我们关闭监视器。具体调用WatchServiceclose()方法或依赖资源尝试,可实现如下:
try (WatchService watchService
    = FileSystems.getDefault().newWatchService()) {
  ...
}

本书附带的代码将所有这些代码片段粘在一个名为FolderWatcher的类中。结果将是一个观察者,它能够报告在指定路径上发生的创建、删除和修改事件。

为了观察路径,即D:/learning/packt,我们只调用watchFolder()方法:

Path path = Paths.get("D:/learning/packt");

FolderWatcher watcher = new FolderWatcher();
watcher.watchFolder(path);

运行应用将显示以下消息:

Watching: D:\learning\packt

现在,我们可以直接在此文件夹下创建、删除或修改文件,并检查通知。例如,如果我们简单地复制粘贴一个名为resources.txt的文件,那么输出如下:

ENTRY_CREATE -> resources.txt
ENTRY_MODIFY -> resources.txt

最后,不要忘记停止应用,因为它将无限期地运行(理论上)。

从这个应用开始,本书附带的源代码附带了另外两个应用。其中一个是视频捕获系统的模拟,而另一个是打印机托盘观察器的模拟。依靠我们在本节中积累的知识,理解这两个应用应该非常简单,无需进一步的细节。

136 流式传输文件内容

流式传输文件内容是一个可以通过 JDK8 使用Files.lines()BufferedReader.lines()方法解决的问题。

Stream<String> Files.lines​(Path path, Charset cs)将文件中的所有行读取为Stream。当流被消耗时,这种情况会缓慢发生。在终端流操作的执行过程中,不应该修改文件的内容;否则,结果是未定义的。

让我们看一个读取D:/learning/packt/resources.txt文件内容并将其显示在屏幕上的示例(注意,我们使用资源尝试运行代码,因此通过关闭流来关闭文件):

private static final String FILE_PATH 
  = "D:/learning/packt/resources.txt";
...
try (Stream<String> filesStream = Files.lines(
  Paths.get(FILE_PATH), StandardCharsets.UTF_8)) {

  filesStream.forEach(System.out::println);
} catch (IOException e) {
  // handle IOException if needed, otherwise remove the catch block
}

BufferedReader类中也有类似的无参数方法:

try (BufferedReader brStream = Files.newBufferedReader(
  Paths.get(FILE_PATH), StandardCharsets.UTF_8)) {

  brStream.lines().forEach(System.out::println);
} catch (IOException e) {
  // handle IOException if needed, otherwise remove the catch block
}

137 在文件树中搜索文件/文件夹

在文件树中搜索文件或文件夹是一项常见的任务,在很多情况下都需要这样做。多亏了 JDK8 和新的Files.find()方法,我们可以很容易地完成这个任务。

Files.find()方法返回一个Stream<Path>,其中惰性地填充了与提供的查找约束匹配的路径:

public static Stream<Path> find​(
  Path start,
  int maxDepth,
  BiPredicate<Path, ​BasicFileAttributes > matcher,
  FileVisitOption...options
) throws IOException

此方法作为walk()方法,因此它遍历当前文件树,从给定路径(start)开始,到达最大给定深度(maxDepth)。在当前文件树的迭代过程中,此方法应用给定的谓词(matcher)。通过这个谓词,我们指定最终流中的每个文件必须匹配的约束。或者,我们可以指定一组访问选项(options)。

Path startPath = Paths.get("D:/learning");

让我们看看一些示例,这些示例旨在阐明此方法的用法:

  • 找到以.properties扩展名结尾的所有文件,并遵循符号链接
Stream<Path> resultAsStream = Files.find(
  startPath,
  Integer.MAX_VALUE,
  (path, attr) -> path.toString().endsWith(".properties"),
  FileVisitOption.FOLLOW_LINKS
);
  • 查找所有以application开头的常规文件:
Stream<Path> resultAsStream = Files.find(
  startPath,
  Integer.MAX_VALUE,
  (path, attr) -> attr.isRegularFile() &amp;&amp;
  path.getFileName().toString().startsWith("application")
);
  • 查找 2019 年 3 月 16 日之后创建的所有目录:
Stream<Path> resultAsStream = Files.find(
  startPath,
  Integer.MAX_VALUE,
  (path, attr) -> attr.isDirectory() &amp;&amp;
    attr.creationTime().toInstant()
      .isAfter(LocalDate.of(2019, 3, 16).atStartOfDay()
        .toInstant(ZoneOffset.UTC))
);

如果我们喜欢将约束表示为表达式(例如,正则表达式),那么我们可以使用PathMatcher接口。这个接口附带了一个名为matches(Path path)的方法,它可以判断给定的路径是否匹配这个匹配器的模式。

FileSystem实现通过FileSystem.getPathMatcher(String syntaxPattern)支持 globregex 语法(也可能支持其他语法)。约束采用syntax:pattern的形式。

基于PathMatcher,我们可以编写能够覆盖广泛约束的辅助方法。例如,下面的辅助方法仅获取与给定约束相关的文件作为syntax:pattern

public static Stream<Path> fetchFilesMatching(Path root,
    String syntaxPattern) throws IOException {

  final PathMatcher matcher
    = root.getFileSystem().getPathMatcher(syntaxPattern);

  return Files.find(root, Integer.MAX_VALUE, (path, attr)
    -> matcher.matches(path) &amp;&amp; !attr.isDirectory());
}

通过 glob 语法查找所有 Java 文件可以实现如下:

Stream<Path> resultAsStream 
  = fetchFilesMatching(startPath, "glob:**/*.java");

如果我们只想列出当前文件夹中的文件(没有任何约束,只有一层深),那么我们可以使用Files.list()方法,如下例所示:

try (Stream<Path> allfiles = Files.list(startPath)) {
  ...
}

138 高效读写文本文件

在 Java 中,高效地读取文件需要选择正确的方法。为了更好地理解下面的示例,我们假设平台的默认字符集是 UTF-8。通过编程,可以通过Charset.defaultCharset()获取平台的默认字符集。

首先,我们需要从 Java 的角度区分原始二进制数据和文本文件。处理原始二进制数据是两个abstract类的工作,即InputStreamOutputStream。对于原始二进制数据的流文件,我们关注于一次读/写一个字节(8 位)的FileInputStreamFileOutputStream类。对于著名的二进制数据类型,我们也有专门的类(例如,音频文件应该通过AudioInputStream而不是FileInputStream进行处理)。

虽然这些类在处理原始二进制数据方面做得非常出色,但它们不适合处理文本文件,因为它们速度慢并且可能产生错误的输出。如果我们认为通过这些类流式传输文本文件意味着从文本文件中读取并处理每个字节(写入一个字节需要相同的繁琐流程),那么这一点就非常清楚了。此外,如果一个字符有超过 1 个字节,那么可能会看到一些奇怪的字符。换句话说,独立于字符集(例如,拉丁语、汉语等)对 8 位进行解码和编码可能产生意外的输出。

例如,假设我们有一首保存在 UTF-16 中的中国诗:

Path chineseFile = Paths.get("chinese.txt");

...

以下代码将不会按预期显示:

try (InputStream is = new FileInputStream(chineseFile.toString())) {

  int i;
  while ((i = is.read()) != -1) {
    System.out.print((char) i);
  }
}

所以,为了解决这个问题,我们应该指定适当的字符集。虽然InputStream对此没有支持,但我们可以依赖InputStreamReader(或OutputStreamReader)。此类是从原始字节流到字符流的桥梁,允许我们指定字符集:

try (InputStreamReader isr = new InputStreamReader(
    new FileInputStream(chineseFile.toFile()), 
      StandardCharsets.UTF_16)) {

  int i;
  while ((i = isr.read()) != -1) {
    System.out.print((char) i);
  }
}

事情已经回到正轨,但仍然很慢!现在,应用可以一次读取多个单字节(取决于字符集),并使用指定的字符集将它们解码为字符。但再多几个字节仍然很慢。

InputStreamReader是射线二进制数据流和字符流之间的桥梁。但是 Java 也提供了FileReader类。它的目标是消除由字符文件表示的字符流的桥接。

对于文本文件,我们有一个称为FileReader类(或FileWriter类)的专用类。这个类一次读取 2 或 4 个字节(取决于使用的字符集)。实际上,在 JDK11 之前,FileReader不支持显式字符集。它只是使用了平台的默认字符集。这对我们不利,因为以下代码不会产生预期的输出:

try (FileReader fr = new FileReader(chineseFile.toFile())) {

  int i;
  while ((i = fr.read()) != -1) {
    System.out.print((char) i);
  }
}

但从 JDK11 开始,FileReader类又增加了两个支持显式字符集的构造器:

  • FileReader​(File file, Charset charset)
  • FileReader​(String fileName, Charset charset)

这一次,我们可以覆盖前面的代码片段并获得预期的输出:

try (FileReader frch = new FileReader(
    chineseFile.toFile(), StandardCharsets.UTF_16)) {

  int i;
  while ((i = frch.read()) != -1) {
    System.out.print((char) i);
  }
}

一次读取 2 或 4 个字节仍然比读取 1 个字节好,但仍然很慢。此外,请注意,前面的解决方案使用一个int来存储检索到的char,我们需要显式地将其转换为char以显示它。基本上,从输入文件中检索到的char被转换成int,然后我们将其转换回char

这就是缓冲流进入场景的地方。想想当我们在线观看视频时会发生什么。当我们观看视频时,浏览器正在提前缓冲传入的字节。这样,我们就有了一个平稳的体验,因为我们可以看到缓冲区中的字节,避免了在网络传输过程中看到字节可能造成的中断:

同样的原理也用于类,例如用于原始二进制流的BufferedInputStreamBufferedOutputStream和用于字符流的BufferedReaderBufferedWriter。其主要思想是在处理之前对数据进行缓冲。这一次,FileReader将数据返回到BufferedReader直到它到达行的末尾(例如,\n\n\r)。BufferedReader使用 RAM 存储缓冲数据:

try (BufferedReader br = new BufferedReader(
    new FileReader(chineseFile.toFile(), StandardCharsets.UTF_16))) {

  String line;
  // keep buffering and print
  while ((line = br.readLine()) != null) {
    System.out.println(line);
  }
}

因此,我们不是一次读取 2 个字节,而是读取一整行,这要快得多。这是一种非常有效的读取文本文件的方法。

为了进一步优化,我们可以通过专用构造器设置缓冲区的大小。

注意,BufferedReader类知道如何在传入数据的上下文中创建和处理缓冲区,但与数据源无关。在我们的例子中,数据的来源是FileReader,它是一个文件,但是相同的BufferedReader可以缓冲来自不同来源的数据(例如,网络、文件、控制台、打印机、传感器等等)。最后,我们读取缓冲的内容。

前面的例子代表了在 Java 中读取文本文件的主要方法。从 JDK8 开始,添加了一组新的方法,使我们的生活更轻松。为了创建一个BufferedReader,我们也可以依赖Files.newBufferedReader​(Path path, Charset cs)

try (BufferedReader br = Files.newBufferedReader(
    chineseFile, StandardCharsets.UTF_16)) {

  String line;
  while ((line = br.readLine()) != null) {
    System.out.println(line);
  }
}

对于BufferedWriter,我们有Files.newBufferedWriter()。这些方法的优点是直接支持Path

要将文本文件的内容提取为Stream<T>,请查看“流式传输文件内容”部分中的问题。

另一种可能导致眼睛疲劳的有效解决方案如下:

try (BufferedReader br = new BufferedReader(new InputStreamReader(
    new FileInputStream(chineseFile.toFile()), 
      StandardCharsets.UTF_16))) {

  String line;
  while ((line = br.readLine()) != null) {
    System.out.println(line);
  }
}

现在,我们来谈谈如何将文本文件直接读入内存。

读取内存中的文本文件

Files类提供了两个方法,可以读取内存中的整个文本文件。其中之一是List<String> readAllLines​(Path path, Charset cs)

List<String> lines = Files.readAllLines(
  chineseFile, StandardCharsets.UTF_16);

此外,我们可以通过Files.readString​(Path path, Charset cs)阅读String中的全部内容:

String content = Files.readString(chineseFile, 
  StandardCharsets.UTF_16);

虽然这些方法对于相对较小的文件非常方便,但对于较大的文件来说并不是一个好的选择。试图在内存中获取大文件很容易导致OutOfMemoryError,显然,这会消耗大量内存。或者,对于大文件(例如 200GB),我们可以关注内存映射文件(MappedByteBufferMappedByteBuffer允许我们创建和修改巨大的文件,并将它们视为非常大的数组。它们看起来像是在记忆中,即使它们不是。一切都发生在本机级别:

// or use, Files.newByteChannel()
try (FileChannel fileChannel = (FileChannel.open(chineseFile,
    EnumSet.of(StandardOpenOption.READ)))) {

  MappedByteBuffer mbBuffer = fileChannel.map(
    FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());

  if (mbBuffer != null) {
    String bufferContent 
      = StandardCharsets.UTF_16.decode(mbBuffer).toString();

    System.out.println(bufferContent);
    mbBuffer.clear();
  }
}

对于大文件,建议使用固定大小遍历缓冲区,如下所示:

private static final int MAP_SIZE = 5242880; // 5 MB in bytes

try (FileChannel fileChannel = (FileChannel.open(chineseFile,
    EnumSet.of(StandardOpenOption.READ)))) {

  int position = 0;
  long length = fileChannel.size();

  while (position < length) {
    long remaining = length - position;
    int bytestomap = (int) Math.min(MAP_SIZE, remaining);

    MappedByteBuffer mbBuffer = fileChannel.map(
      MapMode.READ_ONLY, position, bytestomap);

    ... // do something with the current buffer

    position += bytestomap;
  }
}

JDK13 准备发布非易失性MappedByteBuffer。敬请期待!

写入文本文件

对于每个专用于读取文本文件的类/方法(例如,BufferedReaderreadString()),Java 提供其对应的用于写入文本文件的类/方法(例如,BufferedWriterwriteString())。下面是通过BufferedWriter写入文本文件的示例:

Path textFile = Paths.get("sample.txt");

try (BufferedWriter bw = Files.newBufferedWriter(
    textFile, StandardCharsets.UTF_8, StandardOpenOption.CREATE, 
      StandardOpenOption.WRITE)) {
  bw.write("Lorem ipsum dolor sit amet, ... ");
  bw.newLine();
  bw.write("sed do eiusmod tempor incididunt ...");
}

Iterable写入文本文件的一种非常方便的方法是Files.write​(Path path, Iterable<? extends CharSequence> lines, Charset cs, OpenOption... options)。例如,让我们将列表的内容写入文本文件(列表中的每个元素都写在文件中的一行上):

List<String> linesToWrite = Arrays.asList("abc", "def", "ghi");
Path textFile = Paths.get("sample.txt");
Files.write(textFile, linesToWrite, StandardCharsets.UTF_8,
  StandardOpenOption.CREATE, StandardOpenOption.WRITE);

最后,要将一个String写入一个文件,我们可以使用Files.writeString​(Path path, CharSequence csq, OpenOption... options)方法:

Path textFile = Paths.get("sample.txt");

String lineToWrite = "Lorem ipsum dolor sit amet, ...";
Files.writeString(textFile, lineToWrite, StandardCharsets.UTF_8,
  StandardOpenOption.CREATE, StandardOpenOption.WRITE);

通过StandardOpenOption可以控制文件的打开方式。在前面的示例中,如果文件不存在(CREATE),则创建这些文件,并打开这些文件进行写访问(WRITE)。有许多其他选项可用(例如,APPENDDELETE_ON_CLOSE等)。

最后,通过MappedByteBuffer编写文本文件可以如下完成(这对于编写大型文本文件非常有用):

Path textFile = Paths.get("sample.txt");
CharBuffer cb = CharBuffer.wrap("Lorem ipsum dolor sit amet, ...");

try (FileChannel fileChannel = (FileChannel) Files.newByteChannel(
    textFile, EnumSet.of(StandardOpenOption.CREATE,
      StandardOpenOption.READ, StandardOpenOption.WRITE))) {

  MappedByteBuffer mbBuffer = fileChannel
    .map(FileChannel.MapMode.READ_WRITE, 0, cb.length());

  if (mbBuffer != null) {
    mbBuffer.put(StandardCharsets.UTF_8.encode(cb));
  }
}

139 高效读写二进制文件

在上一个问题“高效读写文本文件”中,我们讨论了缓冲流(为了清晰起见,请考虑在本问题之前读取该问题)。对于二进制文件也一样,因此我们可以直接跳到一些示例中。

让我们考虑以下二进制文件及其字节大小:

Path binaryFile = Paths.get(
  "build/classes/modern/challenge/Main.class");

int fileSize = (int) Files.readAttributes(
  binaryFile, BasicFileAttributes.class).size();

我们可以通过FileInputStream读取byte[]中的文件内容(这不使用缓冲):

final byte[] buffer = new byte[fileSize];
try (InputStream is = new FileInputStream(binaryFile.toString())) {

  int i;
  while ((i = is.read(buffer)) != -1) {
    System.out.print("\nReading ... ");
  }
}

然而,前面的例子不是很有效。当从该输入流将buffer.length字节读入字节数组时,可以通过BufferedInputStream实现高效率,如下所示:

final byte[] buffer = new byte[fileSize];

try (BufferedInputStream bis = new BufferedInputStream(
    new FileInputStream(binaryFile.toFile()))) {

  int i;
  while ((i = bis.read(buffer)) != -1) {
    System.out.print("\nReading ... " + i);
  }
}

也可通过Files.newInputStream()方法获得FileInputStream。这种方法的优点在于它直接支持Path

final byte[] buffer = new byte[fileSize];

try (BufferedInputStream bis = new BufferedInputStream(
    Files.newInputStream(binaryFile))) {

  int i;
  while ((i = bis.read(buffer)) != -1) {
    System.out.print("\nReading ... " + i);
  }
}

如果文件太大,无法放入文件大小的缓冲区,则最好通过具有固定大小(例如 512 字节)的较小缓冲区和read()样式来读取文件,如下所示:

  • read​(byte[] b)
  • read​(byte[] b, int off, int len)
  • readNBytes​(byte[] b, int off, int len)
  • readNBytes​(int len)

没有参数的read()方法将逐字节读取输入流。这是最低效的方法,尤其是在不使用缓冲的情况下。

或者,如果我们的目标是将输入流读取为字节数组,我们可以依赖于ByteArrayInputStream(它使用内部缓冲区,因此不需要使用BufferedInputStream):

final byte[] buffer = new byte[fileSize];

try (ByteArrayInputStream bais = new ByteArrayInputStream(buffer)) {

  int i;
  while ((i = bais.read(buffer)) != -1) {
    System.out.print("\nReading ... ");
  }
}

前面的方法非常适合原始二进制数据,但有时二进制文件包含某些数据(例如,intfloat等)。在这种情况下,DataInputStreamDataOutputStream为读写某些数据类型提供了方便的方法。假设我们有一个文件,data.bin,它包含float个数字。我们可以有效地阅读如下:

Path dataFile = Paths.get("data.bin");

try (DataInputStream dis = new DataInputStream(
    new BufferedInputStream(Files.newInputStream(dataFile)))) {

  while (dis.available() > 0) {
    float nr = dis.readFloat();
    System.out.println("Read: " + nr);
  }
}

这两个类只是 Java 提供的数据过滤器中的两个。有关所有受支持的数据过滤器的概述,请查看FilterInputStream的子类。此外,Scanner类是读取某些类型数据的好选择。有关更多信息,请查看“使用扫描器”部分中的问题。

现在,让我们看看如何将二进制文件直接读入内存。

将二进制文件读入内存

可以通过Files.readAllBytes()将整个二进制文件读入内存:

byte[] bytes = Files.readAllBytes(binaryFile);

类似的方法也存在于InputStream类中。

虽然这些方法对于相对较小的文件非常方便,但对于较大的文件来说并不是一个好的选择。尝试将大文件提取到内存中很容易出现 OOM 错误,而且显然会消耗大量内存。或者,对于大文件(例如 200GB),我们可以关注内存映射文件(MappedByteBufferMappedByteBuffer允许我们创建和修改巨大的文件,并将它们视为一个非常大的数组。他们看起来像是在记忆中,即使他们不是。一切都发生在本机级别:

try (FileChannel fileChannel = (FileChannel.open(binaryFile,
    EnumSet.of(StandardOpenOption.READ)))) {

  MappedByteBuffer mbBuffer = fileChannel.map(
    FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());

  System.out.println("\nRead: " + mbBuffer.limit() + " bytes");
}

对于大型文件,建议在缓冲区内遍历固定大小的缓冲区,如下所示:

private static final int MAP_SIZE = 5242880; // 5 MB in bytes

try (FileChannel fileChannel = FileChannel.open(
    binaryFile, StandardOpenOption.READ)) {

  int position = 0;
  long length = fileChannel.size();

  while (position < length) {
    long remaining = length - position;
    int bytestomap = (int) Math.min(MAP_SIZE, remaining);

    MappedByteBuffer mbBuffer = fileChannel.map(
      MapMode.READ_ONLY, position, bytestomap);

    ... // do something with the current buffer

    position += bytestomap;
  }
}

写入二进制文件

写入二进制文件的一种有效方法是使用BufferedOutputStream。例如,将byte[]写入文件可以如下完成:

final byte[] buffer...;
Path classFile = Paths.get(
  "build/classes/modern/challenge/Main.class");

try (BufferedOutputStream bos = newBufferedOutputStream(
    Files.newOutputStream(classFile, StandardOpenOption.CREATE,
      StandardOpenOption.WRITE))) {

  bos.write(buffer);
}

如果您正在逐字节写入数据,请使用write(int b)方法,如果您正在写入数据块,请使用write​(byte[] b, int off, int len)方法。

向文件写入byte[]的一种非常方便的方法是Files.write​(Path path, byte[] bytes, OpenOption... options)。例如,让我们编写前面缓冲区的内容:

Path classFile = Paths.get(
  "build/classes/modern/challenge/Main.class");

Files.write(classFile, buffer,
  StandardOpenOption.CREATE, StandardOpenOption.WRITE);

通过MappedByteBuffer写入二进制文件可以如下完成(这对于写入大型文本文件非常有用):

Path classFile = Paths.get(
  "build/classes/modern/challenge/Main.class");
try (FileChannel fileChannel = (FileChannel) Files.newByteChannel(
    classFile, EnumSet.of(StandardOpenOption.CREATE,
      StandardOpenOption.READ, StandardOpenOption.WRITE))) {

  MappedByteBuffer mbBuffer = fileChannel
    .map(FileChannel.MapMode.READ_WRITE, 0, buffer.length);

  if (mbBuffer != null) {
    mbBuffer.put(buffer);
  }
}

最后,如果我们正在写一段数据(不是原始的二进制数据),那么我们可以依赖于DataOutputStream。这个类为不同类型的数据提供了writeFoo()方法。例如,让我们将几个浮点值写入一个文件:

Path floatFile = Paths.get("float.bin");

try (DataOutputStream dis = new DataOutputStream(
    new BufferedOutputStream(Files.newOutputStream(floatFile)))) {
  dis.writeFloat(23.56f);
  dis.writeFloat(2.516f);
  dis.writeFloat(56.123f);
}

140 在大文件中搜索

搜索和计算文件中某个字符串的出现次数是一项常见的任务。尽可能快地实现这一点是一项强制性要求,尤其是当文件很大(例如 200GB)时。

注意,以下实现假设字符串11111中只出现一次,而不是两次。此外,前三个实现依赖于第 1 章“字符串、数字和数学”节的“在另一个字符串中对字符串进行计数”的帮助方法:

private static int countStringInString(String string, String tofind) {
  return string.split(Pattern.quote(tofind), -1).length - 1;
}

既然如此,让我们来看看解决这个问题的几种方法。

基于BufferedReader的解决方案

从前面的问题中我们已经知道,BufferedReader对于读取文本文件是非常有效的。因此,我们也可以用它来读取一个大文件。在读取时,对于通过BufferedReader.readLine()获得的每一行,我们需要通过countStringInString()计算所搜索字符串的出现次数:

public static int countOccurrences(Path path, String text, Charset ch)
    throws IOException {

  int count = 0;

  try (BufferedReader br = Files.newBufferedReader(path, ch)) {
    String line;
    while ((line = br.readLine()) != null) {
      count += countStringInString(line, text);
    }
  }

  return count;
}

基于Files.readAllLines()的解决方案

如果内存(RAM)对我们来说不是问题,那么我们可以尝试将整个文件读入内存(通过Files.readAllLines()并从那里处理它。将整个文件放在内存中支持并行处理。因此,如果我们的硬件可以通过并行处理突出显示,那么我们可以尝试依赖parallelStream(),如下所示:

public static int countOccurrences(Path path, String text, Charset ch)
    throws IOException {

  return Files.readAllLines(path, ch).parallelStream()
    .mapToInt((p) -> countStringInString(p, text))
    .sum();
}

如果parallelStream()没有任何好处,那么我们可以简单地切换到stream()。这只是一个基准问题。

基于Files.lines()的解决方案

我们也可以尝试通过Files.lines()利用流。这一次,我们将文件作为一个懒惰的Stream<String>来获取。如果我们可以利用并行处理(基准测试显示出更好的性能),那么通过调用parallel()方法来并行化Stream<String>就非常简单了:

public static int countOccurrences(Path path, String text, Charset ch)
    throws IOException {

  return Files.lines(path, ch).parallel()
    .mapToInt((p) -> countStringInString(p, text))
    .sum();
}

基于扫描器的解决方案

从 JDK9 开始,Scanner类附带了一个方法,该方法返回分隔符分隔的标记流Stream<String> tokens()。如果我们将要搜索的文本作为Scanner的分隔符,并对tokens()返回的Stream的条目进行计数,则得到正确的结果:

public static long countOccurrences(
  Path path, String text, Charset ch) throws IOException {

  long count;

  try (Scanner scanner = new Scanner(path, ch)
      .useDelimiter(Pattern.quote(text))) {

    count = scanner.tokens().count() - 1;
  }

  return count;
}

JDK10 中添加了支持显式字符集的扫描器构造器。

基于MappedByteBuffer的解决方案

我们将在这里讨论的最后一个解决方案是基于 JavaNIO.2、MappedByteBufferFileChannel的。此解决方案从给定文件上的一个FileChannel打开一个内存映射字节缓冲区(MappedByteBuffer)。我们遍历提取的字节缓冲区并查找与搜索字符串的匹配(该字符串被转换为一个byte[]并逐字节进行搜索)。

对于小文件,将整个文件加载到内存中会更快。对于大型/大型文件,以块(例如,5 MB 的块)的形式加载和处理文件会更快。一旦我们加载了一个块,我们就必须计算所搜索字符串的出现次数。我们存储结果并将其传递给下一个数据块。我们重复这个过程,直到遍历了整个文件。

让我们看一下这个实现的核心行(看一下与本书捆绑在一起的源代码以获得完整的代码):

private static final int MAP_SIZE = 5242880; // 5 MB in bytes

public static int countOccurrences(Path path, String text)
                                          throws IOException {

  final byte[] texttofind = text.getBytes(StandardCharsets.UTF_8);
  int count = 0;

  try (FileChannel fileChannel = FileChannel.open(path,
                                   StandardOpenOption.READ)) {
    int position = 0;
    long length = fileChannel.size();

    while (position < length) {
      long remaining = length - position;
      int bytestomap = (int) Math.min(MAP_SIZE, remaining);

      MappedByteBuffer mbBuffer = fileChannel.map(
        MapMode.READ_ONLY, position, bytestomap);

      int limit = mbBuffer.limit();
      int lastSpace = -1;
      int firstChar = -1;

      while (mbBuffer.hasRemaining()) {        
        // spaghetti code omitted for brevity
        ...
      }
    }
  }

  return count;
}

这个解决方案非常快,因为文件直接从操作系统的内存中读取,而不必加载到 JVM 中。这些操作在本机级别进行,称为操作系统级别。请注意,此实现仅适用于 UTF-8 字符集,但也可以适用于其他字符集。

141 将 JSON/CSV 文件读取为对象

如今,JSON 和 CSV 文件无处不在。读取(反序列化)JSON/CSV 文件可能是一项日常任务,通常位于业务逻辑之前。编写(序列化)JSON/CSV 文件也是一项常见的任务,通常发生在业务逻辑的末尾。在读写这些文件之间,应用将数据用作对象。

将 JSON 文件读/写为对象

让我们从三个文本文件开始,它们代表典型的类似 JSON 的映射:

melons_raw.json中,每行有一个 JSON 条目。每一行都是一段独立于前一行的 JSON,但具有相同的模式。在melons_array.json中,我们有一个 JSON 数组,而在melons_map.json中,我们有一个非常适合 JavaMap的 JSON 数组。

对于这些文件中的每一个,我们都有一个Path,如下所示:

Path pathArray = Paths.get("melons_array.json");
Path pathMap = Paths.get("melons_map.json");
Path pathRaw = Paths.get("melons_raw.json");

现在,让我们看看三个专用库,它们将这些文件的内容作为Melon实例读取:

public class Melon {

  private String type;
  private int weight;

  // getters and setters omitted for brevity
}

使用 JSON-B

JavaEE8 附带了一个类似 JAXB 的声明性 JSON 绑定,称为 JSON-B(JSR-367)。JSON-B 与 JAXB 和其他 JavaEE/SE API 保持一致。JakartaEE 将 JavaEE8 JSON-P/B 提升到了一个新的层次。其 API 通过javax.json.bind.Jsonbjavax.json.bind.JsonbBuilder类公开:

Jsonb jsonb = JsonbBuilder.create();

对于反序列化,我们使用Jsonb.fromJson(),而对于序列化,我们使用Jsonb.toJson()

  • 让我们把melons_array.json读作MelonArray
Melon[] melonsArray = jsonb.fromJson(Files.newBufferedReader(
  pathArray, StandardCharsets.UTF_8), Melon[].class);
  • 让我们把melons_array.json读作MelonList
List<Melon> melonsList 
  = jsonb.fromJson(Files.newBufferedReader(
    pathArray, StandardCharsets.UTF_8), ArrayList.class);
  • 让我们把melons_map.json读作MelonMap
Map<String, Melon> melonsMap 
  = jsonb.fromJson(Files.newBufferedReader(
    pathMap, StandardCharsets.UTF_8), HashMap.class);
  • 让我们把melons_raw.json逐行读成Map
Map<String, String> stringMap = new HashMap<>();

try (BufferedReader br = Files.newBufferedReader(
    pathRaw, StandardCharsets.UTF_8)) {

  String line;

  while ((line = br.readLine()) != null) {
    stringMap = jsonb.fromJson(line, HashMap.class);
    System.out.println("Current map is: " + stringMap);
  }
}
  • 让我们把melons_raw.json逐行读成Melon
try (BufferedReader br = Files.newBufferedReader(
    pathRaw, StandardCharsets.UTF_8)) {

  String line;

  while ((line = br.readLine()) != null) {
    Melon melon = jsonb.fromJson(line, Melon.class);
    System.out.println("Current melon is: " + melon);
  }
}
  • 让我们将一个对象写入一个 JSON 文件(melons_output.json):
Path path = Paths.get("melons_output.json");

jsonb.toJson(melonsMap, Files.newBufferedWriter(path,
  StandardCharsets.UTF_8, StandardOpenOption.CREATE, 
    StandardOpenOption.WRITE));

使用 Jackson

Jackson 是一个流行且快速的库,专门用于处理(序列化/反序列化)JSON 数据。Jackson API 依赖于com.fasterxml.jackson.databind.ObjectMapper。让我们再看一次前面的例子,但这次使用的是 Jackson:

ObjectMapper mapper = new ObjectMapper();

反序列化使用ObjectMapper.readValue(),序列化使用ObjectMapper.writeValue()

  • 让我们把melons_array.json读作MelonArray
Melon[] melonsArray 
  = mapper.readValue(Files.newBufferedReader(
    pathArray, StandardCharsets.UTF_8), Melon[].class);
  • 让我们把melons_array.json读作MelonList
List<Melon> melonsList 
  = mapper.readValue(Files.newBufferedReader(
    pathArray, StandardCharsets.UTF_8), ArrayList.class);
  • 让我们把melons_map.json读作MelonMap
Map<String, Melon> melonsMap 
  = mapper.readValue(Files.newBufferedReader(
    pathMap, StandardCharsets.UTF_8), HashMap.class);
  • 让我们把melons_raw.json逐行读成Map
Map<String, String> stringMap = new HashMap<>();

try (BufferedReader br = Files.newBufferedReader(
    pathRaw, StandardCharsets.UTF_8)) {

  String line;

  while ((line = br.readLine()) != null) {
    stringMap = mapper.readValue(line, HashMap.class);
    System.out.println("Current map is: " + stringMap);
  }
}
  • 让我们把melons_raw.json逐行读成Melon
try (BufferedReader br = Files.newBufferedReader(
    pathRaw, StandardCharsets.UTF_8)) {

  String line;

  while ((line = br.readLine()) != null) {
    Melon melon = mapper.readValue(line, Melon.class);
    System.out.println("Current melon is: " + melon);
  }
}
  • 让我们将一个对象写入一个 JSON 文件(melons_output.json):
Path path = Paths.get("melons_output.json");

mapper.writeValue(Files.newBufferedWriter(path, 
  StandardCharsets.UTF_8, StandardOpenOption.CREATE, 
    StandardOpenOption.WRITE), melonsMap);

使用 Gson

Gson 是另一个专门用于处理(序列化/反序列化)JSON 数据的简单库。在 Maven 项目中,它可以作为依赖项添加到pom.xml。它的 API 依赖于类名com.google.gson.Gson。本书附带的代码提供了一组示例。

将 CSV 文件作为对象读取

最简单的 CSV 文件类似于下图中的文件(用逗号分隔的数据行):

反序列化这种 CSV 文件的简单而有效的解决方案依赖于BufferedReaderString.split()方法。我们可以通过BufferedReader.readLine()读取文件中的每一行,并通过Spring.split()用逗号分隔符将其拆分。结果(每行内容)可以存储在List<String>中。最终结果为List<List<String>>,如下所示:

public static List<List<String>> readAsObject(
    Path path, Charset cs, String delimiter) throws IOException {

  List<List<String>> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;

    while ((line = br.readLine()) != null) {
      String[] values = line.split(delimiter);
      content.add(Arrays.asList(values));
    }
  }

  return content;
}

如果 CSV 数据有 POJO 对应项(例如,我们的 CSV 是序列化一堆Melon实例的结果),那么它可以反序列化,如下例所示:

public static List<Melon> readAsMelon(
    Path path, Charset cs, String delimiter) throws IOException {

  List<Melon> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;

    while ((line = br.readLine()) != null) {
      String[] values = line.split(Pattern.quote(delimiter));
      content.add(new Melon(values[0], Integer.valueOf(values[1])));
    }
  }

  return content;
}

对于复杂的 CSV 文件,建议使用专用库(例如 OpenCSV、ApacheCommons CSV、Super CSV 等)。

142 使用临时文件/文件夹

JavaNIO.2API 支持使用临时文件夹/文件。例如,我们可以很容易地找到临时文件夹/文件的默认位置,如下所示:

String defaultBaseDir = System.getProperty("java.io.tmpdir");

通常,在 Windows 中,默认的临时文件夹是C:\Temp, %Windows%\Temp,或者是Local Settings\Temp中每个用户的临时目录(这个位置通常通过TEMP环境变量控制)。在 Linux/Unix 中,全局临时目录是/tmp/var/tmp。前一行代码将返回默认位置,具体取决于操作系统。

在下一节中,我们将学习如何创建临时文件夹/文件。

创建临时文件夹/文件

使用Path createTempDirectory​(Path dir, String prefix, FileAttribute<?>... attrs)可以创建临时文件夹。这是Files类中的static方法,可以按如下方式使用:

  • 让我们在操作系统的默认位置创建一个没有前缀的临时文件夹:
// C:\Users\Anghel\AppData\Local\Temp\8083202661590940905
Path tmpNoPrefix = Files.createTempDirectory(null);
  • 让我们在操作系统的默认位置创建一个带有自定义前缀的临时文件夹:
// C:\Users\Anghel\AppData\Local\Temp\logs_5825861687219258744
String customDirPrefix = "logs_";
Path tmpCustomPrefix 
  = Files.createTempDirectory(customDirPrefix);
  • 让我们在自定义位置创建一个带有自定义前缀的临时文件夹:
// D:\tmp\logs_10153083118282372419
Path customBaseDir 
  = FileSystems.getDefault().getPath("D:/tmp");
String customDirPrefix = "logs_";
Path tmpCustomLocationAndPrefix 
  = Files.createTempDirectory(customBaseDir, customDirPrefix);

创建临时文件可以通过Path createTempFile​(Path dir, String prefix, String suffix, FileAttribute<?>... attrs)完成。这是Files类中的static方法,可以按如下方式使用:

  • 让我们在操作系统的默认位置创建一个没有前缀和后缀的临时文件:
// C:\Users\Anghel\AppData\Local\Temp\16106384687161465188.tmp
Path tmpNoPrefixSuffix = Files.createTempFile(null, null);
  • 让我们在操作系统的默认位置创建一个带有自定义前缀和后缀的临时文件:
// C:\Users\Anghel\AppData\Local\Temp\log_402507375350226.txt
String customFilePrefix = "log_";
String customFileSuffix = ".txt";
Path tmpCustomPrefixAndSuffix 
  = Files.createTempFile(customFilePrefix, customFileSuffix);
  • 让我们在自定义位置创建一个带有自定义前缀和后缀的临时文件:
// D:\tmp\log_13299365648984256372.txt
Path customBaseDir 
  = FileSystems.getDefault().getPath("D:/tmp");
String customFilePrefix = "log_";
String customFileSuffix = ".txt";
Path tmpCustomLocationPrefixSuffix = Files.createTempFile(
  customBaseDir, customFilePrefix, customFileSuffix);

在下面的部分中,我们将研究删除临时文件夹/文件的不同方法。

通过关闭挂钩删除临时文件夹/文件

删除临时文件夹/文件是一项可以由操作系统或专用工具完成的任务。然而,有时,我们需要通过编程来控制这一点,并基于不同的设计考虑删除一个文件夹/文件。

这个问题的解决依赖于关闭挂钩机制,可以通过Runtime.getRuntime().addShutdownHook()方法来实现。当我们需要在 JVM 关闭之前完成某些任务(例如,清理任务)时,这种机制非常有用。它被实现为一个 Java 线程,当 JVM 在关闭时执行关闭挂钩时调用其run()方法。如下代码所示:

Path customBaseDir = FileSystems.getDefault().getPath("D:/tmp");
String customDirPrefix = "logs_";
String customFilePrefix = "log_";
String customFileSuffix = ".txt";

try {
  Path tmpDir = Files.createTempDirectory(
    customBaseDir, customDirPrefix);
  Path tmpFile1 = Files.createTempFile(
    tmpDir, customFilePrefix, customFileSuffix);
  Path tmpFile2 = Files.createTempFile(
    tmpDir, customFilePrefix, customFileSuffix);

  Runtime.getRuntime().addShutdownHook(new Thread() {
    @Override
    public void run() {
      try (DirectoryStream<Path> ds 
          = Files.newDirectoryStream(tmpDir)) {
        for (Path file: ds) {
          Files.delete(file);
        }

        Files.delete(tmpDir);
      } catch (IOException e) {
        ...
      }
    }
  });

  //simulate some operations with temp file until delete it
  Thread.sleep(10000);
} catch (IOException | InterruptedException e) {
  ...
}

在异常/强制终止的情况下(例如 JVM 崩溃、触发终端操作等),将不执行关闭挂钩。当所有线程完成或调用System.exit(0)时,它运行。建议快速运行,因为如果出现问题(例如,操作系统关闭),可以在完成之前强制停止它们。通过编程,关闭挂钩只能由Runtime.halt()停止。

通过deleteOnExit()删除临时文件夹/文件

另一个删除临时文件夹/文件的解决方案依赖于File.deleteOnExit()方法。通过调用此方法,我们可以注册删除文件夹/文件。删除操作在 JVM 关闭时发生:

Path customBaseDir = FileSystems.getDefault().getPath("D:/tmp");
String customDirPrefix = "logs_";
String customFilePrefix = "log_";
String customFileSuffix = ".txt";

try {
  Path tmpDir = Files.createTempDirectory(
    customBaseDir, customDirPrefix);
  System.out.println("Created temp folder as: " + tmpDir);
  Path tmpFile1 = Files.createTempFile(
    tmpDir, customFilePrefix, customFileSuffix);
  Path tmpFile2 = Files.createTempFile(
    tmpDir, customFilePrefix, customFileSuffix);

  try (DirectoryStream<Path> ds = Files.newDirectoryStream(tmpDir)) {
    tmpDir.toFile().deleteOnExit();

    for (Path file: ds) {
      file.toFile().deleteOnExit();
    }
  } catch (IOException e) {
    ...
  }

  // simulate some operations with temp file until delete it
  Thread.sleep(10000);
} catch (IOException | InterruptedException e) {
  ...
}

当应用管理少量临时文件夹/文件时,建议仅依赖此方法(deleteOnExit())。此方法可能会消耗大量内存(它会为每个注册删除的临时资源消耗内存),并且在 JVM 终止之前,此内存可能不会被释放。请注意,因为需要调用此方法才能注册每个临时资源,而删除的顺序与注册的顺序相反(例如,我们必须先注册临时文件夹,然后再注册其内容)。

通过DELETE_ON_CLOSE删除临时文件

当涉及到删除临时文件时,另一个解决方案依赖于StandardOpenOption.DELETE_ON_CLOSE(这会在流关闭时删除文件)。例如,下面的代码通过createTempFile()方法创建一个临时文件,并为其打开一个缓冲的写入器流,其中明确指定了DELETE_ON_CLOSE

Path customBaseDir = FileSystems.getDefault().getPath("D:/tmp");
String customFilePrefix = "log_";
String customFileSuffix = ".txt";
Path tmpFile = null;

try {
  tmpFile = Files.createTempFile(
    customBaseDir, customFilePrefix, customFileSuffix);
} catch (IOException e) {
  ...
}

try (BufferedWriter bw = Files.newBufferedWriter(tmpFile,
    StandardCharsets.UTF_8, StandardOpenOption.DELETE_ON_CLOSE)) {

  //simulate some operations with temp file until delete it
  Thread.sleep(10000);
} catch (IOException | InterruptedException e) {
  ...
}

此解决方案可用于任何文件。它不是针对临时资源的。

143 过滤文件

Path中过滤文件是一项非常常见的任务。例如,我们可能只需要特定类型的文件、具有特定名称模式的文件、今天修改的文件等等。

通过Files.newDirectoryStream()过滤

不需要任何类型的过滤器,我们可以通过Files.newDirectoryStream(Path dir)方法轻松地循环文件夹的内容(一层深)。此方法返回一个DirectoryStream<Path>,这是一个对象,我们可以使用它来迭代目录中的条目:

Path path = Paths.get("D:/learning/books/spring");

try (DirectoryStream<Path> ds = Files.newDirectoryStream(path)) {

  for (Path file: ds) {
    System.out.println(file.getFileName());
  }
}

如果我们想用过滤器丰富这段代码,那么我们至少有两种解决方案。一种解决方案依赖于另一种口味的newDirectoryStream()方法newDirectoryStream​(Path dir, String glob)。除了Path之外,该方法还使用 glob 语法接收一个过滤器。例如,我们可以在D:/learning/books/spring文件夹中过滤 PNG、JPG 和 BMP 类型的文件:

try (DirectoryStream<Path> ds =
    Files.newDirectoryStream(path, "*.{png,jpg,bmp}")) {

  for (Path file: ds) {
    System.out.println(file.getFileName());
  }
}

glob 语法不能再帮助我们时,是时候使用另一种风格的newDirectoryStream()来获得Filter,即newDirectoryStream​(Path dir, DirectoryStream.Filter<? super Path> filter)。首先,让我们为大于 10 MB 的文件定义一个过滤器:

DirectoryStream.Filter<Path> sizeFilter 
    = new DirectoryStream.Filter<>() {

  @Override
  public boolean accept(Path path) throws IOException {
    return (Files.size(path) > 1024 * 1024 * 10);
  }
};

我们也可以在函数式风格上做到这一点:

DirectoryStream.Filter<Path> sizeFilter 
  = p -> (Files.size(p) > 1024 * 1024 * 10);

现在,我们可以这样应用这个过滤器:

try (DirectoryStream<Path> ds =
    Files.newDirectoryStream(path, sizeFilter)) {

  for (Path file: ds) {
    System.out.println(file.getFileName() + " " +
      Files.readAttributes(file, BasicFileAttributes.class).size() 
        + " bytes");
  }
}

让我们再看几个可以用于此技术的过滤器:

  • 以下是文件夹的用户定义过滤器:
DirectoryStream.Filter<Path> folderFilter 
    = new DirectoryStream.Filter<>() {

  @Override
  public boolean accept(Path path) throws IOException {
    return (Files.isDirectory(path, NOFOLLOW_LINKS));
  }
};
  • 以下是今天修改的文件的用户定义过滤器:
DirectoryStream.Filter<Path> todayFilter 
    = new DirectoryStream.Filter<>() {

  @Override
  public boolean accept(Path path) throws IOException {
    FileTime lastModified = Files.readAttributes(path,
      BasicFileAttributes.class).lastModifiedTime();

    LocalDate lastModifiedDate = lastModified.toInstant()
      .atOffset(ZoneOffset.UTC).toLocalDate();
    LocalDate todayDate = Instant.now()
      .atOffset(ZoneOffset.UTC).toLocalDate();

    return lastModifiedDate.equals(todayDate);
  }
};
  • 以下是隐藏文件/文件夹的用户定义过滤器:
DirectoryStream.Filter<Path> hiddenFilter 
    = new DirectoryStream.Filter<>() {

  @Override
  public boolean accept(Path path) throws IOException {
    return (Files.isHidden(path));
  }
};

在下面的部分中,我们将研究过滤文件的不同方法。

144 发现两个文件之间的不匹配

此问题的解决方案是比较两个文件的内容(逐字节比较),直到发现第一个不匹配或达到 EOF。

让我们考虑以下四个文本文件:

只有前两个文件(file1.txtfile2.txt相同。任何其他比较都应显示至少存在一个不匹配。

一种解决方法是使用MappedByteBuffer。这个解决方案是超级快速和易于实现。我们只需打开两个FileChannels(每个文件一个),然后逐字节进行比较,直到找到第一个不匹配或 EOF。如果文件的字节长度不同,则我们假设文件不相同,并立即返回:

private static final int MAP_SIZE = 5242880; // 5 MB in bytes

public static boolean haveMismatches(Path p1, Path p2) 
    throws IOException {

  try (FileChannel channel1 = (FileChannel.open(p1,
      EnumSet.of(StandardOpenOption.READ)))) {

    try (FileChannel channel2 = (FileChannel.open(p2,
        EnumSet.of(StandardOpenOption.READ)))) {

      long length1 = channel1.size();
      long length2 = channel2.size();

      if (length1 != length2) {
        return true;
      }

      int position = 0;
      while (position < length1) {
        long remaining = length1 - position;
        int bytestomap = (int) Math.min(MAP_SIZE, remaining);

        MappedByteBuffer mbBuffer1 = channel1.map(
          MapMode.READ_ONLY, position, bytestomap);
        MappedByteBuffer mbBuffer2 = channel2.map(
          MapMode.READ_ONLY, position, bytestomap);

        while (mbBuffer1.hasRemaining()) {
          if (mbBuffer1.get() != mbBuffer2.get()) {
            return true;
          }
        }

        position += bytestomap;
      }
    }
  }

  return false;
}

JDK-13 准备发布非易失性MappedByteBuffers。敬请期待!

从 JDK12 开始,Files类通过一种新方法得到了丰富,该方法专门用于指出两个文件之间的不匹配。此方法具有以下签名:

public static long mismatch​(Path path, Path path2) throws IOException

此方法查找并返回两个文件内容中第一个不匹配字节的位置。如果没有错配,则返回-1

long mismatches12 = Files.mismatch(file1, file2); // -1
long mismatches13 = Files.mismatch(file1, file3); // 51
long mismatches14 = Files.mismatch(file1, file4); // 60

通过FilenameFilter过滤

FilenameFilter函数式接口也可以用来过滤文件夹中的文件。首先,我们需要定义一个过滤器(例如,下面是 PDF 类型文件的过滤器):

String[] files = path.toFile().list(new FilenameFilter() {

  @Override
  public boolean accept(File folder, String fileName) {
    return fileName.endsWith(".pdf");
  }
});

我们可以在函数式风格上做同样的事情:

FilenameFilter filter = (File folder, String fileName) 
  -> fileName.endsWith(".pdf");

让我们更简洁一点:

FilenameFilter filter = (f, n) -> n.endsWith(".pdf");

为了使用这个过滤器,我们需要将它传递给过载的File.list​(FilenameFilter filter)File.listFiles​(FilenameFilter filter)方法:

String[] files = path.toFile().list(filter);

文件数组将只包含 PDF 文件的名称。

为了将结果取为File[],我们应该调用listFiles()而不是list()

通过FileFilter过滤

FileFilter是另一个可以用来过滤文件和文件夹的函数式接口。例如,让我们只过滤文件夹:

File[] folders = path.toFile().listFiles(new FileFilter() {

  @Override
  public boolean accept(File file) {
    return file.isDirectory();
  }
});

我们可以在函数式风格上做同样的事情:

File[] folders = path.toFile().listFiles((File file) 
  -> file.isDirectory());

让我们更简洁一点:

File[] folders = path.toFile().listFiles(f -> f.isDirectory());

最后,我们可以通过成员引用:

File[] folders = path.toFile().listFiles(File::isDirectory);

145 循环字节缓冲区

JavaNIO.2API 附带了一个名为java.nio.ByteBuffer的字节缓冲区的实现。基本上,这是一个字节数组(byte[]),由一组专门用于操作该数组的方法包装(例如,get()put()等等)。循环缓冲区(循环缓冲区、环形缓冲区或循环队列)是端到端连接的固定大小的缓冲区。下图显示了循环队列的外观:

循环缓冲区依赖于预先分配的数组(预先分配的容量),但某些实现可能也需要调整大小的功能。元素写入/添加到后面(尾部),从前面删除/读取(头部),如下图所示:

对于主操作,即读(获取)和写(设置),循环缓冲区维护一个指针(读指针和写指针)。两个指针都围绕着缓冲区容量。我们可以找出有多少元素可以读取,有多少空闲的插槽可以随时写入。此操作发生在O(1)

循环字节缓冲区是字节的循环缓冲区;它可以是字符或其他类型。这正是我们要在这里实现的。我们可以从编写实现的存根开始,如下所示:

public class CircularByteBuffer {

  private int capacity;
  private byte[] buffer;
  private int readPointer;
  private int writePointer;
  private int available;

  CircularByteBuffer(int capacity) {
    this.capacity = capacity;
    buffer = new byte[capacity];
  }

  public synchronized int available() {
    return available;
  }

  public synchronized int capacity() {
    return capacity;
  }

  public synchronized int slots() {
    return capacity - available;
  }

  public synchronized void clear() {
    readPointer = 0;
    writePointer = 0;
    available = 0;
  }
  ...
}

现在,让我们集中精力放置(写入)新字节和读取(获取)现有字节。例如,容量为 8 的循环字节缓冲区可以表示为:

让我们看看每一步都发生了什么:

  1. 循环字节缓冲区为空,两个指针都指向插槽 0(第一个插槽)。
  2. 我们将hello对应的 5 个字节放在缓冲区中,readPointer保持不变,而writePointer指向插槽 5。
  3. 我们得到与h对应的字节,所以readPointer移动到插槽 1。
  4. 最后,我们尝试将world的字节放入缓冲区。这个字由 5 个字节组成,但在达到缓冲区容量之前,我们只有 4 个空闲插槽。这意味着我们只能写与world对应的字节。

现在,让我们看一下下图中的场景:

从左到右,步骤如下:

  1. 前两个步骤与前一个场景中的步骤相同。
  2. 我们得到了hell的字节。这将把readPointer移动到位置 4。
  3. 最后,我们将world的字节放入缓冲区。这一次,字放入缓冲区,writePointer移动到槽 2。

基于此流程,我们可以轻松实现一种方法,将一个字节放入缓冲区,另一个从缓冲区获取一个字节,如下所示:

public synchronized boolean put(int value) {
  if (available == capacity) {
    return false;
  }

  buffer[writePointer] = (byte) value;
  writePointer = (writePointer + 1) % capacity;
  available++;

  return true;
}

public synchronized int get() {
  if (available == 0) {
    return -1;
  }

  byte value = buffer[readPointer];
  readPointer = (readPointer + 1) % capacity;
  available--;

  return value;
}

如果我们检查 JavaNIO.2ByteBufferAPI,我们会注意到它公开了get()put()方法的几种风格。例如,我们应该能够将一个byte[]传递给get()方法,这个方法应该将一系列元素从缓冲区复制到这个byte[]中。从当前的readPointer开始从缓冲区读取元素,从指定的offset开始在给定的byte[]中写入元素。

下图显示了writePointer大于readPointer的情况:

在左边,我们正在读 3 个字节。这将readPointer从其初始插槽 1 移动到插槽 4。在右边,我们正在读取 4 个(或超过 4 个)字节。由于只有 4 个字节可用,readPointer从其初始插槽移动到与writePointer相同的插槽(插槽 5)。

现在,我们来分析一个例子,writePointer小于readPointer

在左边,我们正在读 3 个字节。这将readPointer从其初始插槽 6 移动到插槽 1。在右边,我们正在读取 4 个(或超过 4 个)字节。这会将readPointer从其初始插槽 6 移动到插槽 2(与writePointer相同的插槽)。

既然我们已经考虑到了这两个用例,我们可以编写一个get()方法,以便将一系列字节从缓冲区复制到给定的byte[],如下所示(该方法尝试从缓冲区读取len字节,然后将它们写入给定的byte[],从给定的offset开始):

public synchronized int get(byte[] dest, int offset, int len) {

  if (available == 0) {
    return 0;
  }

  int maxPointer = capacity;

  if (readPointer < writePointer) {
    maxPointer = writePointer;
  }

  int countBytes = Math.min(maxPointer - readPointer, len);
  System.arraycopy(buffer, readPointer, dest, offset, countBytes);
  readPointer = readPointer + countBytes;

  if (readPointer == capacity) {
    int remainingBytes = Math.min(len - countBytes, writePointer);

    if (remainingBytes > 0) {
      System.arraycopy(buffer, 0, dest,
        offset + countBytes, remainingBytes);
      readPointer = remainingBytes;
      countBytes = countBytes + remainingBytes;
    } else {
      readPointer = 0;
    }
  }

  available = available - countBytes;

  return countBytes;
}

现在,让我们集中精力将给定的byte[]放入缓冲区。从指定的offset开始从给定的byte[]读取元素,并从当前的writePointer开始写入缓冲区。下图显示了writePointer大于readPointer的情况:

在左边,我们有缓冲区的初始状态。所以,readPointer指向插槽 2,writePointer指向插槽 5。在写入 4 个字节(右侧)之后,我们可以看到,readPointer没有受到影响,writePointer指向插槽 1。

另一个用例假设readPointer大于writePointer

在左边,我们有缓冲区的初始状态。所以,readPointer指向插槽 4,writePointer指向插槽 2。在写入 4 个字节(右侧)之后,我们可以看到,readPointer没有受到影响,writePointer指向插槽 4。请注意,只有两个字节被成功写入。这是因为我们在写入所有 4 个字节之前已经达到了缓冲区的最大容量。

既然我们已经考虑到了这两个用例,我们可以编写一个put()方法,以便将给定的byte[]中的一系列字节复制到缓冲区中,如下(该方法尝试从给定的offset开始从给定的byte[]读取len字节,并尝试从当前的writePointer开始将其写入缓冲区):

public synchronized int put(byte[] source, int offset, int len) {

  if (available == capacity) {
    return 0;
  }

  int maxPointer = capacity;

  if (writePointer < readPointer) {
    maxPointer = readPointer;
  }

  int countBytes = Math.min(maxPointer - writePointer, len);
  System.arraycopy(source, offset, buffer, writePointer, countBytes);
  writePointer = writePointer + countBytes;

  if (writePointer == capacity) {
    int remainingBytes = Math.min(len - countBytes, readPointer);

    if (remainingBytes > 0) {
      System.arraycopy(source, offset + countBytes,
        buffer, 0, remainingBytes);
      writePointer = remainingBytes;
      countBytes = countBytes + remainingBytes;
    } else {
      writePointer = 0;
    }
  }

  available = available + countBytes;

  return countBytes;
}

如前所述,有时需要调整缓冲区的大小。例如,我们可能希望通过简单地调用resize()方法将其大小增加一倍。基本上,这意味着将所有可用字节(元素)复制到一个容量加倍的新缓冲区中:

public synchronized void resize() {

  byte[] newBuffer = new byte[capacity * 2];

  if (readPointer < writePointer) {
    System.arraycopy(buffer, readPointer, newBuffer, 0, available);
  } else {
    int bytesToCopy = capacity - readPointer;
    System.arraycopy(buffer, readPointer, newBuffer, 0, bytesToCopy);
    System.arraycopy(buffer, 0, newBuffer, bytesToCopy, writePointer);
  }

  buffer = newBuffer;
  capacity = buffer.length;
  readPointer = 0;
  writePointer = available;
}

查看本书附带的源代码,看看它是如何完整工作的。

146 分词文件

文件中的内容并不总是以可以立即处理的方式接收,并且需要一些额外的步骤,以便为处理做好准备。通常,我们需要对文件进行标记,并从不同的数据结构(数组、列表、映射等)中提取信息。

例如,让我们考虑一个文件,clothes.txt

Path path = Paths.get("clothes.txt");

其内容如下:

Top|white\10/XXL&amp;Swimsuit|black\5/L
Coat|red\11/M&amp;Golden Jacket|yellow\12/XLDenim|Blue\22/M

此文件包含一些服装物品及其详细信息,这些物品以&amp;字符分隔。单一条款如下:

article name | color \ no. available items / size

这里,我们有几个分隔符(&amp;|\/)和一个非常具体的格式。

现在,让我们来看看几个解决方案,它们将从这个文件中提取信息并将其标记为一个List。我们将在工具类FileTokenizer中收集这些信息。

List中获取物品的一种解决方案依赖于String.split()方法。基本上,我们必须逐行读取文件并对每行应用String.split()。每行分词的结果通过List.addAll()方法收集在List中:

public static List<String> get(Path path, 
    Charset cs, String delimiter) throws IOException {

  String delimiterStr = Pattern.quote(delimiter);
  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      String[] values = line.split(delimiterStr);
      content.addAll(Arrays.asList(values));
    }
  }

  return content;
}

使用&amp;分隔符调用此方法将产生以下输出:

[Top|white\10/XXL, Swimsuit|black\5/L, Coat|red\11/M, Golden Jacket|yellow\12/XL, Denim|Blue\22/M]

上述溶液的另一种风味可以依赖于Collectors.toList()而不是Arrays.asList()

public static List<String> get(Path path, 
    Charset cs, String delimiter) throws IOException {

  String delimiterStr = Pattern.quote(delimiter);
  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      content.addAll(Stream.of(line.split(delimiterStr))
        .collect(Collectors.toList()));
    }
  }

  return content;
}

或者,我们可以通过Files.lines()以惰性方式处理内容:

public static List<String> get(Path path, 
    Charset cs, String delimiter) throws IOException {

  try (Stream<String> lines = Files.lines(path, cs)) {

    return lines.map(l -> l.split(Pattern.quote(delimiter)))
      .flatMap(Arrays::stream)
      .collect(Collectors.toList());
  }
}

对于相对较小的文件,我们可以将其加载到内存中并进行相应的处理:

Files.readAllLines(path, cs).stream()
  .map(l -> l.split(Pattern.quote(delimiter)))
  .flatMap(Arrays::stream)
  .collect(Collectors.toList());

另一种解决方案可以依赖于 JDK8 的Pattern.splitAsStream()方法。此方法从给定的输入序列创建流。为了便于修改,这次我们通过Collectors.joining(";")收集结果列表:

public static List<String> get(Path path, 
    Charset cs, String delimiter) throws IOException {

  Pattern pattern = Pattern.compile(Pattern.quote(delimiter));
  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      content.add(pattern.splitAsStream(line)
        .collect(Collectors.joining(";")));
    }
  }
  return content;
}

我们用&amp;分隔符调用这个方法:

List<String> tokens = FileTokenizer.get(
  path, StandardCharsets.UTF_8, "&amp;");

结果如下:

[Top|white\10/XXL;Swimsuit|black\5/L, Coat|red\11/M;Golden Jacket|yellow\12/XL, Denim|Blue\22/M]

到目前为止,提出的解决方案通过应用一个分隔符来获得文章列表。但有时,我们需要应用更多的分隔符。例如,假设我们希望获得以下输出(列表):

[Top, white, 10, XXL, Swimsuit, black, 5, L, Coat, red, 11, M, Golden Jacket, yellow, 12, XL, Denim, Blue, 22, M]

为了获得这个列表,我们必须应用几个分隔符(&amp;|\/。这可以通过使用String.split()并将基于逻辑OR运算符(x|y)的正则表达式传递给它来实现:

public static List<String> getWithMultipleDelimiters(
    Path path, Charset cs, String...delimiters) throws IOException {

  String[] escapedDelimiters = new String[delimiters.length];
  Arrays.setAll(escapedDelimiters, t -> Pattern.quote(delimiters[t]));
  String delimiterStr = String.join("|", escapedDelimiters);

  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      String[] values = line.split(delimiterStr);
      content.addAll(Arrays.asList(values));
    }
  }

  return content;
}

让我们用分隔符(&amp;|\/调用此方法以获得所需的结果:

List<String> tokens = FileTokenizer.getWithMultipleDelimiters(
  path, StandardCharsets.UTF_8, 
    new String[] {"&amp;", "|", "\\", "/"});

好的,到目前为止,很好!所有这些解决方案都基于String.split()Pattern.splitAsStream()。另一组解决方案可以依赖于StringTokenizer类(它在性能方面并不出色,所以请小心使用它)。此类可以对给定的字符串应用一个(或多个)分隔符,并公开控制它的两个主要方法,即hasMoreElements()nextToken()

public static List<String> get(Path path,
    Charset cs, String delimiter) throws IOException {

  StringTokenizer st;
  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      st = new StringTokenizer(line, delimiter);
      while (st.hasMoreElements()) {
        content.add(st.nextToken());
      }
    }
  }

  return content;
}

也可与Collectors配合使用:

public static List<String> get(Path path, 
    Charset cs, String delimiter) throws IOException {

  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      content.addAll(Collections.list(
          new StringTokenizer(line, delimiter)).stream()
        .map(t -> (String) t)
        .collect(Collectors.toList()));
    }
  }

  return content;
}

如果我们使用//分隔多个分隔符,则可以使用多个分隔符:

public static List<String> getWithMultipleDelimiters(
    Path path, Charset cs, String...delimiters) throws IOException {

  String delimiterStr = String.join("//", delimiters);
  StringTokenizer st;
  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      st = new StringTokenizer(line, delimiterStr);
      while (st.hasMoreElements()) {
        content.add(st.nextToken());
      }
    }
  }

  return content;
}

为了获得更好的性能和正则表达式支持(即高灵活性),建议使用String.split()而不是StringTokenizer。从同一类别中,也考虑“使用扫描器”部分。

147 将格式化输出直接写入文件

假设我们有 10 个数字(整数和双精度)并且我们希望它们在一个文件中被很好地格式化(有缩进、对齐和一些小数,以保持可读性和有用性)。

在我们的第一次尝试中,我们像这样将它们写入文件(没有应用格式):

Path path = Paths.get("noformatter.txt");

try (BufferedWriter bw = Files.newBufferedWriter(path,
    StandardCharsets.UTF_8, StandardOpenOption.CREATE,
      StandardOpenOption.WRITE)) {

  for (int i = 0; i < 10; i++) {
    bw.write("| " + intValues[i] + " | " + doubleValues[i] + " | ");
    bw.newLine();
  }
}

前面代码的输出类似于下图左侧所示:

但是,我们希望得到上图右侧所示的结果。为了解决这个问题,我们需要使用String.format()方法。此方法允许我们将格式规则指定为符合以下模式的字符串:

%[flags][width][.precision]conversion-character

现在,让我们看看这个模式的每个组成部分是什么:

  • [flags]是可选的,包括修改输出的标准方法。通常,它们用于格式化整数和浮点数。
  • [width]是可选的,并设置输出的字段宽度(写入输出的最小字符数)。
  • [.precision]可选,指定浮点值的精度位数(或从String中提取的子串长度)。
  • conversion-character是强制的,它告诉我们参数的格式。最常用的转换字符如下:
    • s:用于格式化字符串
    • d:用于格式化十进制整数
    • f:用于格式化浮点数
    • t:用于格式化日期/时间值

作为行分隔符,我们可以使用%n

有了这些格式化规则的知识,我们可以得到如下所示(%6s用于整数,%.3f用于双精度):

Path path = Paths.get("withformatter.txt");

try (BufferedWriter bw = Files.newBufferedWriter(path,
    StandardCharsets.UTF_8, StandardOpenOption.CREATE, 
      StandardOpenOption.WRITE)) {

  for (int i = 0; i<10; i++) {
    bw.write(String.format("| %6s | %.3f |",
      intValues[i], doubleValues[i]));
    bw.newLine();
  }
}

可以通过Formatter类提供另一种解决方案。此类专用于格式化字符串,并使用与String.format()相同的格式化规则。它有一个format()方法,我们可以用它覆盖前面的代码片段:

Path path = Paths.get("withformatter.txt");

try (Formatter output = new Formatter(path.toFile())) {

  for (int i = 0; i < 10; i++) {
    output.format("| %6s | %.3f |%n", intValues[i], doubleValues[i]);
  }
}

只格式化整数的数字怎么样?

好吧,我们可以通过应用一个DecimalFormat和一个字符串格式化程序来获得它,如下所示:

Path path = Paths.get("withformatter.txt");
DecimalFormat formatter = new DecimalFormat("###,### bytes");

try (Formatter output = new Formatter(path.toFile())) {

 for (int i = 0; i < 10; i++) {
   output.format("%12s%n", formatter.format(intValues[i]));
 }
}

148 使用扫描器

Scanner公开了一个 API,用于解析字符串、文件、控制台等中的文本。解析是将给定的输入分词并根据需要返回它的过程(例如,整数、浮点、双精度等)。默认情况下,Scanner使用空格(默认分隔符)解析给定的输入,并通过一组nextFoo()方法(例如,next()nextLine()nextInt()nextDouble()等)公开令牌。

从同一类问题出发,也考虑“分词文件”部分。

例如,假设我们有一个文件(doubles.txt),其中包含由空格分隔的双数,如下图所示:

如果我们想获得这个文本作为双精度文本,那么我们可以读取它并依赖于一段意大利面代码来标记并将其转换为双精度文本。或者,我们可以依赖于Scanner及其nextDouble()方法,如下所示:

try (Scanner scanDoubles = new Scanner(
    Path.of("doubles.txt"), StandardCharsets.UTF_8)) {

  while (scanDoubles.hasNextDouble()) {
    double number = scanDoubles.nextDouble();
    System.out.println(number);
  }
}

上述代码的输出如下:

23.4556
1.23
...

但是,文件可能包含不同类型的混合信息。例如,下图中的文件(people.txt)包含由不同分隔符(逗号和分号)分隔的字符串和整数:

Scanner公开了一个名为useDelimiter()的方法。此方法采用StringPattern类型的参数,以指定应用作正则表达式的分隔符:

try (Scanner scanPeople = new Scanner(Path.of("people.txt"),
    StandardCharsets.UTF_8).useDelimiter(";|,")) {

  while (scanPeople.hasNextLine()) {
    System.out.println("Name: " + scanPeople.next().trim());
    System.out.println("Surname: " + scanPeople.next());
    System.out.println("Age: " + scanPeople.nextInt());
    System.out.println("City: " + scanPeople.next());
  }
}

使用此方法的输出如下:

Name: Matt
Surname: Kyle
Age: 23
City: San Francisco
...

从 JDK9 开始,Scanner公开了一个名为tokens()的新方法。此方法返回来自Scanner的分隔符分隔的令牌流。例如,我们可以用它来解析people.txt文件并在控制台上打印出来,如下所示:

try (Scanner scanPeople = new Scanner(Path.of("people.txt"),
    StandardCharsets.UTF_8).useDelimiter(";|,")) {

  scanPeople.tokens().forEach(t -> System.out.println(t.trim()));
}

使用上述方法的输出如下:

Matt
Kyle
23
San Francisco
...

或者,我们可以通过空格连接令牌:

try (Scanner scanPeople = new Scanner(Path.of("people.txt"),
    StandardCharsets.UTF_8).useDelimiter(";|,")) {

  String result = scanPeople.tokens()
    .map(t -> t.trim())
    .collect(Collectors.joining(" "));
}

在“大文件搜索”部分中,有一个示例说明如何使用此方法搜索文件中的某一段文本。

使用上述方法的输出如下:

Matt Kyle 23 San Francisco Darel Der 50 New York ...

tokens()方法方面,JDK9 还附带了一个名为findAll()的方法。这是一种非常方便的方法,用于查找所有与某个正则表达式相关的标记(以StringPattern形式提供)。此方法返回一个Stream<MatchResult>,可以这样使用:

try (Scanner sc = new Scanner(Path.of("people.txt"))) {

  Pattern pattern = Pattern.compile("4[0-9]");

  List<String> ages = sc.findAll(pattern)
    .map(MatchResult::group)
    .collect(Collectors.toList());

  System.out.println("Ages: " + ages);
}

前面的代码选择了所有表示 40-49 岁年龄的标记,即 40、43 和 43。

如果我们希望解析控制台中提供的输入,Scanner是一种方便的方法:

Scanner scanConsole = new Scanner(System.in);

String name = scanConsole.nextLine();
String surname = scanConsole.nextLine();
int age = scanConsole.nextInt();
// an int cannot include "\n" so we need
//the next line just to consume the "\n"
scanConsole.nextLine();
String city = scanConsole.nextLine();

注意,对于数字输入(通过nextInt()nextFloat()等读取),我们也需要使用换行符(当我们点击Enter时会出现这种情况)。基本上,Scanner在解析一个数字时不会获取这个字符,因此它将进入下一个标记。如果我们不通过添加一个nextLine()代码行来消耗它,那么从这一点开始,输入将变得不对齐,并导致InputMismatchException类型的异常或过早结束。
JDK10 中引入了支持字符集的Scanner构造器。

我们来看看ScannerBufferedReader的区别。

扫描器与BufferedReader

那么,我们应该使用Scanner还是BufferedReader?好吧,如果我们需要解析这个文件,那么Scanner就是最好的方法,否则BufferedReader更合适,对它们进行一个正面比较,会发现:

  • BufferedReaderScanner快,因为它不执行任何解析操作。
  • BufferedReader在阅读方面优于Scanner在句法分析方面优于BufferedReader
  • 默认情况下,BufferedReader使用 8KB 的缓冲区,Scanner使用 1KB 的缓冲区。
  • BufferedReader非常适合读取长字符串,而Scanner更适合于短输入。
  • BufferedReader同步,但Scanner不同步。
  • Scanner可以使用BufferedReader,反之则不行。如下代码所示:
try (Scanner scanDoubles = new Scanner(Files.newBufferedReader(
    Path.of("doubles.txt"), StandardCharsets.UTF_8))) { 
  ...
}

总结

我们已经到了本章的结尾,在这里我们讨论了各种特定于 I/O 的问题,从操作、行走和监视路径到流文件以及读/写文本和二进制文件的有效方法,我们已经讨论了很多。

从本章下载应用以查看结果和其他详细信息。

七、Java 反射类、接口、构造器、方法和字段

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括涉及 Java 反射 API 的 17 个问题。从经典主题,如检查和实例化 Java 工件(例如,模块、包、类、接口、超类、构造器、方法、注解和数组),到合成桥接构造或基于嵌套的访问控制(JDK11),本章详细介绍了 Java 反射 API。在本章结束时,Java 反射 API 将不会有任何秘密未被发现,您将准备好向您的同事展示反射可以做什么。

问题

使用以下问题来测试您的 Java 反射 API 编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 检查包:编写几个检查 Java 包的示例(例如名称、类列表等)。

  2. 检查类和超类:写几个检查类和超类的例子(例如,通过类名、修饰符、实现的接口、构造器、方法和字段获取Class)。

  3. 通过反射构造器来实例化:编写通过反射创建实例的程序。

  4. 获取接收器类型的注解:编写获取接收器类型注解的程序。

  5. 获得合成和桥接结构:编写一个程序,通过反射获得合成桥接结构。

  6. 检查变量个数:编写一个程序,检查一个方法是否获得变量个数。

  7. 检查默认方法:编写程序检查方法是否为default

  8. 基于嵌套的反射访问控制:编写一个程序,通过反射提供对基于嵌套的结构的访问。

  9. 获取器和设置器的反射:写几个例子,通过反射调用获取器和设置器。另外,编写一个程序,通过反射生成获取器和设置器。

  10. 反射注解:写几个通过反射获取不同种类注解的例子。

  11. 调用实例方法:编写一个程序,通过反射调用实例方法。

  12. 获取static方法:编写一个程序,对给定类的static方法进行分组,并通过反射调用其中一个方法。

  13. 获取方法、字段和异常的泛型类型:编写一个程序,通过反射获取给定方法、字段和异常的泛型类型。

  14. 获取公共和私有字段:编写一个程序,通过反射获取给定类的publicprivate字段。

  15. 使用数组:写几个通过反射使用数组的例子。

  16. 检查模块:写几个通过反射检查 Java9 模块的例子。

  17. 动态代理:编写依赖动态代理的程序,统计给定接口的方法调用次数。

解决方案

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释只包括解决问题所需的最有趣和最重要的细节。您可以从这个页面下载示例解决方案以查看更多详细信息并尝试程序。

149 检查包

当我们需要获取有关特定包的信息时,java.lang.Package类是我们的主要关注点。使用这个类,我们可以找到包的名称、实现这个包的供应商、它的标题、包的版本等等。

此类通常用于查找包含特定类的包的名称。例如,Integer类的包名可以容易地获得如下:

Class clazz = Class.forName("java.lang.Integer");
Package packageOfClazz = clazz.getPackage();

// java.lang
String packageNameOfClazz = packageOfClazz.getName();

现在,我们来查找File类的包名:

File file = new File(".");
Package packageOfFile = file.getClass().getPackage();

// java.io
String packageNameOfFile = packageOfFile.getName();

如果我们试图找到当前类的包名,那么我们可以依赖于this.getClass().getPackage().getName()。这在非静态环境中工作。

但是如果我们只想快速列出当前类装入器的所有包,那么我们可以依赖getPackages()方法,如下所示:

Package[] packages = Package.getPackages();

基于getPackages()方法,我们可以列出调用者的类装入器定义的所有包,以及以给定前缀开头的祖先包,如下所示:

public static List<String> fetchPackagesByPrefix(String prefix) {

  return Arrays.stream(Package.getPackages())
    .map(Package::getName)
    .filter(n -> n.startsWith(prefix))
    .collect(Collectors.toList());
}

如果这个方法存在于一个名为Packages的实用类中,那么我们可以如下调用它:

List<String> packagesSamePrefix 
  = Packages.fetchPackagesByPrefix("java.util");

您将看到类似于以下内容的输出:

java.util.function, java.util.jar, java.util.concurrent.locks,
java.util.spi, java.util.logging, ...

有时,我们只想在系统类加载器中列出一个包的所有类。让我们看看怎么做。

获取包的类

例如,我们可能希望列出当前应用的一个包中的类(例如,modern.challenge包)或编译时库中的一个包中的类(例如,commons-lang-2.4.jar

类被包装在可以在 Jar 中存档的包中,尽管它们不必这样。为了涵盖这两种情况,我们需要发现给定的包是否存在于 JAR 中。我们可以通过ClassLoader.getSystemClassLoader().getResource(package_path)加载资源并检查返回的资源 URL 来完成。如果包不在 JAR 中,那么资源将是以file:方案开始的 URL,如下面的示例(我们使用的是modern.challenge):

file:/D:/Java%20Modern%20Challenge/Code/Chapter%207/Inspect%20packages/build/classes/modern/challenge

但是如果包在 JAR 中(例如,org.apache.commons.lang3.builder,那么 URL 将以jar:方案开始,如下例所示:

jar:file:/D:/.../commons-lang3-3.9.jar!/org/apache/commons/lang3/builder

如果我们考虑到来自 JAR 的包的资源以jar:前缀开头,那么我们可以编写一个方法来区分它们,如下所示:

private static final String JAR_PREFIX = "jar:";

public static List<Class<?>> fetchClassesFromPackage(
    String packageName) throws URISyntaxException, IOException {

  List<Class<?>> classes = new ArrayList<>();
  String packagePath = packageName.replace('.', '/');

  URL resource = ClassLoader
    .getSystemClassLoader().getResource(packagePath);

  if (resource != null) {
    if (resource.toString().startsWith(JAR_PREFIX)) {
      classes.addAll(fetchClassesFromJar(resource, packageName));
    } else {
      File file = new File(resource.toURI());
      classes.addAll(fetchClassesFromDirectory(file, packageName));
    }
  } else {
    throw new RuntimeException("Resource not found for package: " 
      + packageName);
  }

  return classes;
}

因此,如果给定的包在 JAR 中,那么我们调用另一个辅助方法fetchClassesFromJar();否则,我们调用这个辅助方法fetchClassesFromDirectory()。顾名思义,这些助手知道如何从 JAR 或目录中提取给定包的类。

主要来说,这两种方法只是一些用来识别具有.class扩展名的文件的意大利面代码片段。每个类都通过Class.forName()来确保返回的是Class,而不是String。这两种方法在本书附带的代码中都可用。

如何列出不在系统类加载器中的包中的类,例如,外部 JAR 中的包?实现这一点的便捷方法依赖于URLClassLoader。此类用于从引用 JAR 文件和目录的 URL 搜索路径加载类和资源。我们将只处理 Jar,但对目录也这样做非常简单。

因此,根据给定的路径,我们需要获取所有 Jar 并将它们返回为URL[](这个数组需要定义URLClassLoader。例如,我们可以依赖于Files.find()方法遍历给定的路径并提取所有 Jar,如下所示:

public static URL[] fetchJarsUrlsFromClasspath(Path classpath)
    throws IOException {

  List<URL> urlsOfJars = new ArrayList<>();
  List<File> jarFiles = Files.find(
      classpath,
      Integer.MAX_VALUE,
      (path, attr) -> !attr.isDirectory() &&
        path.toString().toLowerCase().endsWith(JAR_EXTENSION))
      .map(Path::toFile)
      .collect(Collectors.toList());

  for (File jarFile: jarFiles) {

    try {
      urlsOfJars.add(jarFile.toURI().toURL());
    } catch (MalformedURLException e) {
      logger.log(Level.SEVERE, "Bad URL for{0} {1}",
        new Object[] {
          jarFile, e
        });
    }
  }

  return urlsOfJars.toArray(URL[]::new);
}

注意,我们正在扫描所有子目录,从给定的路径开始。当然,这是一个设计决策,很容易参数化搜索深度。现在,让我们从tomcat8/lib文件夹中获取 Jar(不需要为此安装 Tomcat;只需使用 Jar 的任何其他本地目录并进行适当的修改):

URL[] urls = Packages.fetchJarsUrlsFromClasspath(
  Path.of("D:/tomcat8/lib"));

现在,我们可以实例化URLClassLoader

URLClassLoader urlClassLoader = new URLClassLoader(
  urls, Thread.currentThread().getContextClassLoader());

这将为给定的 URL 构造一个新的URLClassLoader对象,并使用当前的类加载器进行委托(第二个参数也可以是null)。我们的URL[]只指向 JAR,但根据经验,假设任何jar:方案 URL 都引用 JAR 文件,而任何以/结尾的file:方案 URL 都引用目录。

tomcat8/lib文件夹中的一个 Jar 称为tomcat-jdbc.jar。在这个 JAR 中,有一个名为org.apache.tomcat.jdbc.pool的包。让我们列出这个包的类:

List<Class<?>> classes = Packages.fetchClassesFromPackage(
  "org.apache.tomcat.jdbc.pool", urlClassLoader);

fetchClassesFromPackage()方法是一个助手,它只扫描URLClassLoaderURL[]数组并获取给定包中的类。它的源代码与本书附带的代码一起提供。

检查模块内的包

如果我们使用 Java9 模块化,那么我们的包将生活在模块中。例如,如果我们在一个名为org.tournament的模块中的一个名为com.management的包中有一个名为Manager的类,那么我们可以这样获取该模块的所有包:

Manager mgt = new Manager();
Set<String> packages = mgt.getClass().getModule().getPackages();

另外,如果我们想创建一个类,那么我们需要以下的Class.forName()风格:

Class<?> clazz = Class.forName(mgt.getClass()
  .getModule(), "com.management.Manager");

请记住,每个模块在磁盘上都表示为具有相同名称的目录。例如,org.tournament模块在磁盘上有一个同名文件夹。此外,每个模块被映射为一个具有此名称的单独 JAR(例如,org.tournament.jar)。通过记住这些坐标,很容易修改本节中的代码,从而列出给定模块的给定包的所有类。

150 检查类

通过使用 Java 反射 API,我们可以检查类的详细信息,对象的类名、修饰符、构造器、方法、字段、实现接口等。

假设我们有以下Pair类:

public final class Pair<L, R> extends Tuple implements Comparable {

  final L left;
  final R right;

  public Pair(L left, R right) {
    this.left = left;
    this.right = right;
  }

  public class Entry<L, R> {}
    ...
}

我们还假设有一个实例:

Pair pair = new Pair(1, 1);

现在,让我们使用反射来获取Pair类的名称。

通过实例获取Pair类的名称

通过拥有Pair的实例(对象),我们可以通过调用getClass()方法,以及Class.getName()getSimpleName()getCanonicalName()找到其类的名称,如下例所示:

Class<?> clazz = pair.getClass();

// modern.challenge.Pair
System.out.println("Name: " + clazz.getName());

// Pair
System.out.println("Simple name: " + clazz.getSimpleName());

// modern.challenge.Pair
System.out.println("Canonical name: " + clazz.getCanonicalName());

匿名类没有简单的和规范的名称。

注意,getSimpleName()返回非限定类名。或者,我们可以获得如下类:

Class<Pair> clazz = Pair.class;
Class<?> clazz = Class.forName("modern.challenge.Pair");

获取Pair类修饰符

为了得到类的修饰符(publicprotectedprivatefinalstaticabstractinterface,我们可以调用Class.getModifiers()方法。此方法返回一个int值,该值将每个修饰符表示为标志位。为了解码结果,我们依赖于Modifier类,如下所示:

int modifiers = clazz.getModifiers();

System.out.println("Is public? " 
  + Modifier.isPublic(modifiers)); // true
System.out.println("Is final? " 
  + Modifier.isFinal(modifiers)); // true
System.out.println("Is abstract? " 
  + Modifier.isAbstract(modifiers)); // false

获取Pair类实现的接口

为了获得由类或对象表示的接口直接实现的接口,我们只需调用Class.getInterfaces()。此方法返回一个数组。因为Pair类实现了一个接口(Comparable,所以返回的数组将包含一个元素:

Class<?>[] interfaces = clazz.getInterfaces();

// interface java.lang.Comparable
System.out.println("Interfaces: " + Arrays.toString(interfaces));

// Comparable
System.out.println("Interface simple name: " 
  + interfaces[0].getSimpleName());

获取Pair类构造器

类的public构造器可以通过Class.getConstructors()类获得。返回结果为Constructor<?>[]

Constructor<?>[] constructors = clazz.getConstructors();

// public modern.challenge.Pair(java.lang.Object,java.lang.Object)
System.out.println("Constructors: " + Arrays.toString(constructors));

要获取所有声明的构造器(例如,privateprotected构造器),请调用getDeclaredConstructors()。搜索某个构造器时,调用getConstructor​(Class<?>... parameterTypes)getDeclaredConstructor​(Class<?>... parameterTypes)

获取Pair类字段

类的所有字段都可以通过Class.getDeclaredFields()方法访问。此方法返回一个数组Field

Field[] fields = clazz.getDeclaredFields();

// final java.lang.Object modern.challenge.Pair.left
// final java.lang.Object modern.challenge.Pair.right
System.out.println("Fields: " + Arrays.toString(fields));

为了获取字段的实际名称,我们可以很容易地提供一个辅助方法:

public static List<String> getFieldNames(Field[] fields) {

  return Arrays.stream(fields)
    .map(Field::getName)
    .collect(Collectors.toList());
}

现在,我们只收到字段的名称:

List<String> fieldsName = getFieldNames(fields);

// left, right
System.out.println("Fields names: " + fieldsName);

获取字段的值可以通过一个名为Object get(Object obj)的通用方法和一组getFoo()方法来完成(有关详细信息,请参阅文档)。obj表示static或实例字段。例如,假设ProcedureOutputs类有一个名为callableStatementprivate字段,其类型为CallableStatement。让我们用Field.get()方法访问此字段,检查CallableStatement是否关闭:

ProcedureOutputs procedureOutputs 
  = storedProcedure.unwrap(ProcedureOutputs.class);

Field csField = procedureOutputs.getClass()
  .getDeclaredField("callableStatement"); 
csField.setAccessible(true);

CallableStatement cs 
  = (CallableStatement) csField.get(procedureOutputs);

System.out.println("Is closed? " + cs.isClosed());

如果只获取public字段,请调用getFields()。要搜索某个字段,请调用getField​(String fieldName)getDeclaredField​(String name)

获取Pair类方法

类的public方法可以通过Class.getMethods()方法访问。此方法返回一个数组Method

Method[] methods = clazz.getMethods();
// public boolean modern.challenge.Pair.equals(java.lang.Object)
// public int modern.challenge.Pair.hashCode()
// public int modern.challenge.Pair.compareTo(java.lang.Object)
// ...
System.out.println("Methods: " + Arrays.toString(methods));

为了获取方法的实际名称,我们可以快速提供一个辅助方法:

public static List<String> getMethodNames(Method[] methods) {

  return Arrays.stream(methods)
    .map(Method::getName)
    .collect(Collectors.toList());
}

现在,我们只检索方法的名称:

List<String> methodsName = getMethodNames(methods);

// equals, hashCode, compareTo, wait, wait,
// wait, toString, getClass, notify, notifyAll
System.out.println("Methods names: " + methodsName);

获取所有声明的方法(例如,privateprotected),调用getDeclaredMethods()。要搜索某个方法,请调用getMethod​(String name, Class<?>... parameterTypes)getDeclaredMethod​(String name, Class<?>... parameterTypes)

获取Pair类模块

如果我们使用 JDK9 模块化,那么我们的类将生活在模块中。Pair类不在模块中,但是我们可以通过 JDK9 的Class.getModule()方法很容易得到类的模块(如果类不在模块中,那么这个方法返回null):

// null, since Pair is not in a Module
Module module = clazz.getModule();

获取Pair类超类

Pair类扩展了Tuple类,因此Tuple类是Pair的超类。我们可以通过Class.getSuperclass()方法得到,如下所示:

Class<?> superClass = clazz.getSuperclass();
// modern.challenge.Tuple
System.out.println("Superclass: " + superClass.getName());

获取某个类型的名称

从 JDK8 开始,我们可以获得特定类型名称的信息字符串。

此方法返回与getName()getSimpleName()getCanonicalName()中的一个或多个相同的字符串:

  • 对于原始类型,它会为所有三个方法返回相同的结果:
System.out.println("Type: " + int.class.getTypeName()); // int
  • 对于Pair,返回与getName()getCanonicalName()相同的东西:
// modern.challenge.Pair
System.out.println("Type name: " + clazz.getTypeName());
  • 对于内部类(比如Entry代表Pair,它返回与getName()相同的东西:
// modern.challenge.Pair$Entry
System.out.println("Type name: " 
  + Pair.Entry.class.getTypeName());
  • 对于匿名类,它返回与getName()相同的内容:
Thread thread = new Thread() {
  public void run() {
    System.out.println("Child Thread");
  }
};

// modern.challenge.Main$1
System.out.println("Anonymous class type name: "
  + thread.getClass().getTypeName());
  • 对于数组,它返回与getCanonicalName()相同的内容:
Pair[] pairs = new Pair[10];
// modern.challenge.Pair[]
System.out.println("Array type name: " 
  + pairs.getClass().getTypeName());

获取描述类的字符串

从 JDK8 开始,我们可以通过Class.toGenericString()方法获得类的快速描述(包含修饰符、名称、类型参数等)。

我们来看几个例子:

// public final class modern.challenge.Pair<L,R>
System.out.println("Description of Pair: " 
  + clazz.toGenericString());

// public abstract interface java.lang.Runnable
System.out.println("Description of Runnable: " 
  + Runnable.class.toGenericString());

// public abstract interface java.util.Map<K,V>
System.out.println("Description of Map: " 
  + Map.class.toGenericString());

获取类的类型描述符字符串

从 JDK12 开始,我们可以通过Class.descriptorString()方法获取类的类型描述符作为String对象:

// Lmodern/challenge/Pair;
System.out.println("Type descriptor of Pair: " 
  + clazz.descriptorString());

// Ljava/lang/String;
System.out.println("Type descriptor of String: " 
  + String.class.descriptorString());

获取数组的组件类型

JDK12 只为数组提供了Class<?> componentType()方法。此方法返回数组的组件类型,如下两个示例所示:

Pair[] pairs = new Pair[10];
String[] strings = new String[] {"1", "2", "3"};

// class modern.challenge.Pair
System.out.println("Component type of Pair[]: " 
  + pairs.getClass().componentType());

// class java.lang.String
System.out.println("Component type of String[]: " 
  + strings.getClass().componentType());

为数组类型获取类,其组件类型由Pair描述

从 JDK12 开始,我们可以得到一个数组类型的Class,该数组类型的组件类型由给定的类通过Class.arrayType()来描述:

Class<?> arrayClazz = clazz.arrayType();

// modern.challenge.Pair<L,R>[]
System.out.println("Array type: " + arrayClazz.toGenericString());

151 通过反射构造器的实例化

我们可以使用 Java 反射 API 通过Constructor.newInstance()实例化一个类。

让我们考虑以下类,它有四个构造器:

public class Car {

  private int id;
  private String name;
  private Color color;

  public Car() {}

  public Car(int id, String name) {
    this.id = id;
    this.name = name;
  }

  public Car(int id, Color color) {
    this.id = id;
    this.color = color;
  }

  public Car(int id, String name, Color color) {
    this.id = id;
    this.name = name;
    this.color = color;
  }

  // getters and setters omitted for brevity
}

一个Car实例可以通过这四个构造器中的一个来创建。Constructor类公开了一个方法,该方法接受构造器的参数类型,并返回反映匹配构造器的Constructor对象。这种方法称为getConstructor​(Class<?>... parameterTypes)

让我们调用前面的每个构造器:

Class<Car> clazz = Car.class;

Constructor<Car> emptyCnstr 
  = clazz.getConstructor();

Constructor<Car> idNameCnstr 
  = clazz.getConstructor(int.class, String.class);

Constructor<Car> idColorCnstr 
  = clazz.getConstructor(int.class, Color.class);

Constructor<Car> idNameColorCnstr 
  = clazz.getConstructor(int.class, String.class, Color.class);

此外,Constructor.newInstance​(Object... initargs)可以返回Car的实例,该实例对应于被调用的构造器:

Car carViaEmptyCnstr = emptyCnstr.newInstance();

Car carViaIdNameCnstr = idNameCnstr.newInstance(1, "Dacia");

Car carViaIdColorCnstr = idColorCnstr
  .newInstance(1, new Color(0, 0, 0));

Car carViaIdNameColorCnstr = idNameColorCnstr
  .newInstance(1, "Dacia", new Color(0, 0, 0));

现在,我们来看看如何通过反射实例化一个private构造器。

通过私有构造器实例化类

Java 反射 API 也可以通过其private构造器来实例化类。例如,假设我们有一个名为Cars的工具类。按照最佳实践,我们将此类定义为final,并使用private构造器来禁止实例:

public final class Cars {

  private Cars() {}
    // static members
}

取这个构造器可以通过Class.getDeclaredConstructor()完成,如下:

Class<Cars> carsClass = Cars.class;
Constructor<Cars> emptyCarsCnstr = carsClass.getDeclaredConstructor();

在这个实例中调用newInstance()会抛出IllegalAccessException,因为被调用的构造器有private访问权限。但是,Java 反射允许我们通过标志方法Constructor.setAccessible()修改访问级别。这一次,实例化按预期工作:

emptyCarsCnstr.setAccessible(true);
Cars carsViaEmptyCnstr = emptyCarsCnstr.newInstance();

为了阻止这种方法,建议抛出一个来自private构造器的错误,如下所示:

public final class Cars {

  private Cars() {
    throw new AssertionError("Cannot be instantiated");
  }

  // static members
}

这一次,实例化尝试将以AssertionError失败。

从 JAR 实例化类

假设我们在D:/Java Modern Challenge/Code/lib/文件夹中有一个 Guava JAR,我们想创建一个CountingInputStream的实例并从一个文件中读取一个字节。

首先,我们为番石榴罐子定义一个URL[]数组,如下所示:

URL[] classLoaderUrls = new URL[] {
  new URL(
    "file:///D:/Java Modern Challenge/Code/lib/guava-16.0.1.jar")
};

然后,我们将为这个URL[]数组定义URLClassLoader

URLClassLoader urlClassLoader = new URLClassLoader(classLoaderUrls);

接下来,我们将加载目标类(CountingInputStream是一个计算从InputStream读取的字节数的类):

Class<?> cisClass = urlClassLoader.loadClass(
  "com.google.common.io.CountingInputStream");

一旦目标类被加载,我们就可以获取它的构造器(CountingInputStream有一个单独的构造器包装给定的InputStream

Constructor<?> constructor 
  = cisClass.getConstructor(InputStream.class);

此外,我们还可以通过这个构造器创建一个CountingInputStream的实例:

Object instance = constructor.newInstance(
  new FileInputStream​(Path.of("test.txt").toFile()));

为了确保返回的实例是可操作的,我们调用它的两个方法(read()方法一次读取一个字节,而getCount()方法返回读取的字节数):

Method readMethod = cisClass.getMethod("read");
Method countMethod = cisClass.getMethod("getCount");

接下来,让我们读一个字节,看看getCount()返回什么:

readMethod.invoke(instance);
Object readBytes = countMethod.invoke(instance);
System.out.println("Read bytes (should be 1): " + readBytes); // 1

有用的代码片段

作为奖励,让我们看看在使用反射和构造器时通常需要的几个代码片段。

首先,让我们获取可用构造器的数量:

Class<Car> clazz = Car.class;
Constructor<?>[] cnstrs = clazz.getConstructors();
System.out.println("Car class has " 
  + cnstrs.length + " constructors"); // 4

现在,让我们看看这四个构造器中有多少个参数:

for (Constructor<?> cnstr : cnstrs) {
  int paramCount = cnstr.getParameterCount();
  System.out.println("\nConstructor with " 
    + paramCount + " parameters");
}

为了获取构造器的每个参数的详细信息,我们可以调用Constructor.getParameters()。该方法返回Parameter数组(JDK8 中添加了该类,提供了解剖参数的综合方法列表):

for (Constructor<?> cnstr : cnstrs) {
  Parameter[] params = cnstr.getParameters();
  ...
}

如果我们只需要知道参数的类型,那么Constructor.getParameterTypes()将完成以下工作:

for (Constructor<?> cnstr : cnstrs) {
  Class<?>[] typesOfParams = cnstr.getParameterTypes();
  ...
}

152 获取接收器类型的注解

从 JDK8 开始,我们可以使用显式的接收器参数。这主要意味着我们可以声明一个实例方法,该实例方法使用thisJava 关键字获取封闭类型的参数。

通过显式的接收器参数,我们可以将类型注解附加到this。例如,假设我们有以下注解:

@Target({ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Ripe {}

我们用它来注解Melon类的eat()方法中的this

public class Melon {
  ...
  public void eat(@Ripe Melon this) {}
  ...
}

也就是说,只有当Melon的实例代表一个成熟的瓜时,我们才能调用eat()方法:

Melon melon = new Melon("Gac", 2000);

// works only if the melon is ripe
melon.eat();

通过 JDK8,采用java.lang.reflect.Executable.getAnnotatedReceiverType()方法,可以在显式接收器参数上进行反射注解。该方法在ConstructorMethod类中也有,因此可以这样使用:

Class<Melon> clazz = Melon.class;
Method eatMethod = clazz.getDeclaredMethod("eat");

AnnotatedType annotatedType = eatMethod.getAnnotatedReceiverType();

// modern.challenge.Melon
System.out.println("Type: " + annotatedType.getType().getTypeName());

// [@modern.challenge.Ripe()]
System.out.println("Annotations: " 
  + Arrays.toString(annotatedType.getAnnotations()));

// [interface java.lang.reflect.AnnotatedType]
System.out.println("Class implementing interfaces: " 
  + Arrays.toString(annotatedType.getClass().getInterfaces()));

AnnotatedType annotatedOwnerType 
  = annotatedType.getAnnotatedOwnerType();

// null
System.out.println("\nAnnotated owner type: " + annotatedOwnerType);

153 获得合成和桥接构造

通过使用合成构造,我们几乎可以理解编译器添加的任何构造。更确切地说,符合 Java 语言规范:Java 编译器引入的任何构造,如果在源代码中没有对应的构造,则必须标记为合成,除了默认构造器、类初始化方法以及Enum类的valueOf()方法和values

有不同种类的合成构造(例如,字段、方法和构造器),但是让我们看一个合成字段的示例。假设我们有以下类:

public class Melon {
  ...
  public class Slice {}
  ...
}

注意,我们有一个名为Slice的内部类。在编译代码时,编译器将通过添加一个用于引用顶级类的合成字段来更改此类。这个合成字段提供了从嵌套类访问封闭类成员的便利。

为了检查这个合成字段的存在,让我们获取所有声明的字段并对它们进行计数:

Class<Melon.Slice> clazzSlice = Melon.Slice.class;
Field[] fields = clazzSlice.getDeclaredFields();

// 1
System.out.println("Number of fields: " + fields.length);

即使我们没有显式声明任何字段,也要注意报告了一个字段。让我们看看它是否是合成,看看它的名字:

// true
System.out.println("Is synthetic: " + fields[0].isSynthetic());

// this$0
System.out.println("Name: " + fields[0].getName());

与本例类似,我们可以通过Method.isSynthetic()Constructor.isSynthetic()方法检查方法或构造器是否是合成的

现在,我们来谈谈桥接方法。这些方法也是合成,它们的目标是处理泛型的类型擦除

考虑以下Melon类:

public class Melon implements Comparator<Melon> {

  @Override
  public int compare(Melon m1, Melon m2) {
    return Integer.compare(m1.getWeight(), m2.getWeight());
  }
  ...
}

在这里,我们实现Comparator接口并覆盖compare()方法。此外,我们明确规定了compare()方法需要两个Melon实例。编译器将继续执行类型擦除,并创建一个包含两个对象的新方法,如下所示:

public int compare(Object m1, Object m2) {
  return compare((Melon) m1, (Melon) m2);
}

这种方法被称为桥接方法。我们看不到,但是 Java 反射 API 可以:

Class<Melon> clazz = Melon.class;
Method[] methods = clazz.getDeclaredMethods();
Method compareBridge = Arrays.asList(methods).stream()
  .filter(m -> m.isSynthetic() && m.isBridge())
  .findFirst()
  .orElseThrow();

// public int modern.challenge.Melon.compare(
// java.lang.Object, java.lang.Object)
System.out.println(compareBridge);

154 检查参数的可变数量

在 Java 中,如果一个方法的签名包含一个varargs类型的参数,那么该方法可以接收数量可变的参数。

例如,plantation()方法采用可变数量的参数,例如,Seed... seeds

public class Melon {
  ...
  public void plantation(String type, Seed...seeds) {}
  ...
}

现在,Java 反射 API 可以通过Method.isVarArgs()方法判断这个方法是否支持可变数量的参数,如下所示:

Class<Melon> clazz = Melon.class;
Method[] methods = clazz.getDeclaredMethods();

for (Method method: methods) {
  System.out.println("Method name: " + method.getName() 
    + " varargs? " + method.isVarArgs());
}

您将收到类似以下内容的输出:

Method name: plantation, varargs? true
Method name: getWeight, varargs? false
Method name: toString, varargs? false
Method name: getType, varargs? false

155 检查默认方法

Java8 用default方法丰富了接口的概念。这些方法编写在接口内部,并有一个默认实现。例如,Slicer接口有一个默认方法,叫做slice()

public interface Slicer {

  public void type();

  default void slice() {
    System.out.println("slice");
  }
}

现在,Slicer的任何实现都必须实现type()方法,并且可以选择性地覆盖slice()方法或依赖于默认实现。

Java 反射 API 可以通过Method.isDefault()标志方法识别default方法:

Class<Slicer> clazz = Slicer.class;
Method[] methods = clazz.getDeclaredMethods();

for (Method method: methods) {
  System.out.println("Method name: " + method.getName() 
    + ", is default? " + method.isDefault());
}

我们将收到以下输出:

Method name: type, is default? false
Method name: slice, is default? true

156 基于反射的嵌套访问控制

在 JDK11 的特性中,我们有几个热点(字节码级别的变化)。其中一个热点被称为 JEP181,或者基于嵌套的访问控制NESTS)。基本上,NEST 术语定义了一个新的访问控制上下文,允许逻辑上属于同一代码实体的类,但是用不同的类文件编译的类,访问彼此的私有成员,而不需要编译器插入可访问性方法(第 11 页)

因此,换句话说,嵌套允许将嵌套类编译为属于同一封闭类的不同类文件。然后允许它们访问彼此的私有类,而无需使用合成/桥接方法。

让我们考虑以下代码:

public class Car {

  private String type = "Dacia";

  public class Engine {

    private String power = "80 hp";

    public void addEngine() {
      System.out.println("Add engine of " + power 
        + " to car of type " + type);
    }
  }
}

让我们在 JDK10 中为Car.class运行javap(Java 类文件反汇编工具,它允许我们分析字节码)。以下屏幕截图突出显示了此代码的重要部分:

我们可以看到,为了从Engine.addEngine()方法访问封闭类字段Car.type,Java 修改了代码并添加了一个桥接package-private方法,称为access$000()。主要是综合生成的,可以通过Method.isSynthetic()Method.isBridge()方法反射看到。

即使我们看到(或感知到)Car(外部)和Engine(嵌套)类在同一个类中,它们也被编译到不同的文件(Car.classCar$Engine.class)。与此一致,我们的期望意味着外部类和嵌套类可以访问彼此的private成员。

但是在不同的文件中,这是不可能的。为了维持我们的期望,Java 增加了桥接packageprivate方法access$000()

然而,Java11 引入了嵌套访问控制上下文,它为外部类和嵌套类中的private访问提供支持。这一次,外部类和嵌套类被链接到两个属性,它们形成了一个嵌套(我们说它们是嵌套伙伴)。嵌套类主要链接到NestMembers属性,而外部类链接到NestHost属性。不产生额外的合成方法。

在下面的屏幕截图中,我们可以看到在 JDK11 中为Car.class执行javap(注意NestMembers属性):

下面的屏幕截图显示了 JDK11 中针对Car$Engine.classjavap输出(注意NestHost属性):

通过反射 API 的访问

如果没有基于嵌套的访问控制,反射功能也会受到限制。例如,在 JDK11 之前,下面的代码片段将抛出IllegalAccessException

Car newCar = new Car();
Engine engine = newCar.new Engine();

Field powerField = Engine.class.getDeclaredField("power");
powerField.set(engine, power);

我们可以通过显式调用powerField.setAccessible(true)来允许访问:

...
Field powerField = Engine.class.getDeclaredField("power");
powerField.setAccessible(true);
powerField.set(engine, power);
...

从 JDK11 开始,不需要调用setAccessible()

此外,JDK11 还提供了三种方法,它们通过支持嵌套来丰富 Java 反射 API。这些方法是Class.getNestHost()Class.getNestMembers()Class.isNestmateOf()

让我们考虑下面的Melon类,其中包含几个嵌套类(SlicePeelerJuicer):

public class Melon {
  ...
  public class Slice {
    public class Peeler {}
  }

  public class Juicer {}
  ...
}

现在,让我们为它们中的每一个定义一个Class

Class<Melon> clazzMelon = Melon.class;
Class<Melon.Slice> clazzSlice = Melon.Slice.class;
Class<Melon.Juicer> clazzJuicer = Melon.Juicer.class;
Class<Melon.Slice.Peeler> clazzPeeler = Melon.Slice.Peeler.class;

为了查看每个类的NestHost,我们需要调用Class.getNestHost()

// class modern.challenge.Melon
Class<?> nestClazzOfMelon = clazzMelon.getNestHost();

// class modern.challenge.Melon
Class<?> nestClazzOfSlice = clazzSlice.getNestHost();

// class modern.challenge.Melon
Class<?> nestClazzOfPeeler = clazzPeeler.getNestHost();

// class modern.challenge.Melon
Class<?> nestClazzOfJuicer = clazzJuicer.getNestHost();

这里应该强调两点。首先,注意MelonNestHostMelon本身。第二,注意PeelerNestHostMelon,而不是Slice。由于PeelerSlice的一个内部类,我们可以认为它的NestHostSlice,但这个假设是不成立的。

现在,让我们列出每个类的NestMembers

Class<?>[] nestMembersOfMelon = clazzMelon.getNestMembers();
Class<?>[] nestMembersOfSlice = clazzSlice.getNestMembers();
Class<?>[] nestMembersOfJuicer = clazzJuicer.getNestMembers();
Class<?>[] nestMembersOfPeeler = clazzPeeler.getNestMembers();

它们将返回相同的NestMembers

[class modern.challenge.Melon, class modern.challenge.Melon$Juicer, class modern.challenge.Melon$Slice, class modern.challenge.Melon$Slice$Peeler]

最后,让我们检查一下嵌套伙伴

boolean melonIsNestmateOfSlice 
  = clazzMelon.isNestmateOf(clazzSlice);  // true

boolean melonIsNestmateOfJuicer 
  = clazzMelon.isNestmateOf(clazzJuicer); // true

boolean melonIsNestmateOfPeeler 
  = clazzMelon.isNestmateOf(clazzPeeler); // true

boolean sliceIsNestmateOfJuicer 
  = clazzSlice.isNestmateOf(clazzJuicer); // true

boolean sliceIsNestmateOfPeeler 
  = clazzSlice.isNestmateOf(clazzPeeler); // true

boolean juicerIsNestmateOfPeeler 
  = clazzJuicer.isNestmateOf(clazzPeeler); // true

157 读写器的反射

简单提醒一下,获取器和设置器是用于访问类的字段(例如,private字段)的方法(也称为访问器)。

首先,让我们看看如何获取现有的获取器和设置器。稍后,我们将尝试通过反射生成缺少的获取器和设置器。

获取获取器和设置器

主要有几种通过反射获得类的获取器和设置器的解决方案。假设我们要获取以下Melon类的获取器和设置器:

public class Melon {

  private String type;
  private int weight;
  private boolean ripe;
  ...

  public String getType() {
    return type;
  }

  public void setType(String type) {
    this.type = type;
  }

  public int getWeight() {
    return weight;
  }

  public void setWeight(int weight) {
    this.weight = weight;
  }

  public boolean isRipe() {
    return ripe;
  }

  public void setRipe(boolean ripe) {
    this.ripe = ripe;
  }
  ...
}

让我们从一个通过反射(例如,通过Class.getDeclaredMethods())获取类的所有声明方法的解决方案开始。现在,循环Method[]并通过特定于获取器和设置器的约束对其进行过滤(例如,从get/set前缀开始,返回void或某个类型,等等)。

另一种解决方案是通过反射(例如,通过Class.getDeclaredFields())获取类的所有声明字段。现在,循环Field[]并尝试通过Class.getDeclaredMethod()将字段的名称(前缀为get/set/is和第一个大写字母)和字段的类型(对于设置器)传递给它来获得获取器和设置器。

最后,一个更优雅的解决方案将依赖于PropertyDescriptorIntrospectorapi。这些 API 在java.beans.*包中提供,专门用于处理 JavaBeans。

这两个类暴露的许多特征依赖于场景背后的反射。

PropertyDescriptor类可以通过getReadMethod()返回用于读取 JavaBean 属性的方法。此外,它还可以通过getWriteMethod()返回用于编写 JavaBean 属性的方法。依靠这两种方法,我们可以获取Melon类的获取器和设置器,如下所示:

for (PropertyDescriptor pd:
    Introspector.getBeanInfo(Melon.class).getPropertyDescriptors()) {

  if (pd.getReadMethod() != null && !"class".equals(pd.getName())) {
    System.out.println(pd.getReadMethod());
  }

  if (pd.getWriteMethod() != null && !"class".equals(pd.getName())) {
    System.out.println(pd.getWriteMethod());
  }
}

输出如下:

public boolean modern.challenge.Melon.isRipe()
public void modern.challenge.Melon.setRipe(boolean)
public java.lang.String modern.challenge.Melon.getType()
public void modern.challenge.Melon.setType(java.lang.String)
public int modern.challenge.Melon.getWeight()
public void modern.challenge.Melon.setWeight(int)

现在,假设我们有以下Melon实例:

Melon melon = new Melon("Gac", 1000);

在这里,我们要称之为getType()获取器:

// the returned type is Gac
Object type = new PropertyDescriptor("type",
  Melon.class).getReadMethod().invoke(melon);

现在,让我们称之为setWeight()设定者:

// set weight of Gac to 2000
new PropertyDescriptor("weight", Melon.class)
  .getWriteMethod().invoke(melon, 2000);

调用不存在的属性将导致IntrospectionException

try {
  Object shape = new PropertyDescriptor("shape",
      Melon.class).getReadMethod().invoke(melon);
  System.out.println("Melon shape: " + shape);
} catch (IntrospectionException e) {
  System.out.println("Property not found: " + e);
}

生成获取器和设置器

假设Melon有三个字段(typeweightripe),只定义type的获取器和ripe的设置器:

public class Melon {

  private String type;
  private int weight;
  private boolean ripe;
  ...

  public String getType() {
    return type;
  }

  public void setRipe(boolean ripe) {
    this.ripe = ripe;
  }
  ...
}

为了生成丢失的获取器和设置器,我们首先识别它们。下面的解决方案循环给定类的声明字段,并假设foo字段没有获取器,如果以下情况适用:

  • 没有get/isFoo()方法
  • 返回类型与字段类型不同
  • 参数的数目不是 0

对于每个缺少的获取器,此解决方案在映射中添加一个包含字段名和类型的条目:

private static Map<String, Class<?>> 
    fetchMissingGetters(Class<?> clazz) {

  Map<String, Class<?>> getters = new HashMap<>();
  Field[] fields = clazz.getDeclaredFields();
  String[] names = new String[fields.length];
  Class<?>[] types = new Class<?>[fields.length];

  Arrays.setAll(names, i -> fields[i].getName());
  Arrays.setAll(types, i -> fields[i].getType());

  for (int i = 0; i < names.length; i++) {
    String getterAccessor = fetchIsOrGet(names[i], types[i]);

    try {
      Method getter = clazz.getDeclaredMethod(getterAccessor);
      Class<?> returnType = getter.getReturnType();

      if (!returnType.equals(types[i]) ||
          getter.getParameterCount() != 0) {
        getters.put(names[i], types[i]);
      }
    } catch (NoSuchMethodException ex) {
      getters.put(names[i], types[i]);
      // log exception
    }
  }

  return getters;
}

此外,解决方案循环给定类的声明字段,并假设foo字段没有设置器,如果以下情况适用:

  • 字段不是final
  • 没有setFoo()方法
  • 方法返回void
  • 该方法只有一个参数
  • 参数类型与字段类型相同
  • 如果参数名存在,则应与字段名相同

对于每个缺少的设置器,此解决方案在映射中添加一个包含字段名和类型的条目:

private static Map<String, Class<?>> 
    fetchMissingSetters(Class<?> clazz) {

  Map<String, Class<?>> setters = new HashMap<>();
  Field[] fields = clazz.getDeclaredFields();
  String[] names = new String[fields.length];
  Class<?>[] types = new Class<?>[fields.length];

  Arrays.setAll(names, i -> fields[i].getName());
  Arrays.setAll(types, i -> fields[i].getType());

  for (int i = 0; i < names.length; i++) {
    Field field = fields[i];
    boolean finalField = !Modifier.isFinal(field.getModifiers());

    if (finalField) {
      String setterAccessor = fetchSet(names[i]);

      try {
        Method setter = clazz.getDeclaredMethod(
            setterAccessor, types[i]);

        if (setter.getParameterCount() != 1 ||
            !setter.getReturnType().equals(void.class)) {

          setters.put(names[i], types[i]);
          continue;
        }

        Parameter parameter = setter.getParameters()[0];
        if ((parameter.isNamePresent() &&
              !parameter.getName().equals(names[i])) ||
                !parameter.getType().equals(types[i])) {
          setters.put(names[i], types[i]);
        }
      } catch (NoSuchMethodException ex) {
        setters.put(names[i], types[i]);
        // log exception
      }
    }
  }

  return setters;
}

到目前为止,我们知道哪些字段没有获取器和设置器。它们的名称和类型存储在映射中。让我们循环映射并生成获取器:

public static StringBuilder generateGetters(Class<?> clazz) {

  StringBuilder getterBuilder = new StringBuilder();
  Map<String, Class<?>> accessors = fetchMissingGetters(clazz);

  for (Entry<String, Class<?>> accessor: accessors.entrySet()) {
    Class<?> type = accessor.getValue();
    String field = accessor.getKey();
    String getter = fetchIsOrGet(field, type);

    getterBuilder.append("\npublic ")
      .append(type.getSimpleName()).append(" ")
      .append(getter)
      .append("() {\n")
      .append("\treturn ")
      .append(field)
      .append(";\n")
      .append("}\n");
  }

  return getterBuilder;
}

让我们生成设置器:

public static StringBuilder generateSetters(Class<?> clazz) {

  StringBuilder setterBuilder = new StringBuilder();
  Map<String, Class<?>> accessors = fetchMissingSetters(clazz);

  for (Entry<String, Class<?>> accessor: accessors.entrySet()) {
    Class<?> type = accessor.getValue();
    String field = accessor.getKey();
    String setter = fetchSet(field);

    setterBuilder.append("\npublic void ")
      .append(setter)
      .append("(").append(type.getSimpleName()).append(" ")
      .append(field).append(") {\n")
      .append("\tthis.")
      .append(field).append(" = ")
      .append(field)
      .append(";\n")
      .append("}\n");
  }

  return setterBuilder;
}

前面的解决方案依赖于下面列出的三个简单助手。代码很简单:

private static String fetchIsOrGet(String name, Class<?> type) {
  return "boolean".equalsIgnoreCase(type.getSimpleName()) ?
    "is" + uppercase(name) : "get" + uppercase(name);
}

private static String fetchSet(String name) {
  return "set" + uppercase(name);
}

private static String uppercase(String name) {
  return name.substring(0, 1).toUpperCase() + name.substring(1);
}

现在,我们把它命名为Melon类:

Class<?> clazz = Melon.class;
StringBuilder getters = generateGetters(clazz);
StringBuilder setters = generateSetters(clazz);

输出将显示以下生成的获取器和设置器:

public int getWeight() {
  return weight;
}

public boolean isRipe() {
  return ripe;
}

public void setWeight(int weight) {
  this.weight = weight;
}

public void setType(String type) {
  this.type = type;
}

158 反射注解

Java 注解从 Java 反射 API 得到了很多关注。让我们看看几种用于检查几种注解(例如,包、类和方法)的解决方案。

主要地,表示支持注解的工件的所有主要反射 API 类(例如,PackageConstructorClassMethodField揭示了一组处理注解的常用方法。常用方法包括:

  • getAnnotations():返回特定于某个工件的所有注解
  • getDeclaredAnnotations():返回直接声明给某个工件的所有注解
  • getAnnotation():按类型返回注解
  • getDeclaredAnnotation():通过直接声明给某个工件的类型返回注解(JDK1.8)
  • getDeclaredAnnotationsByType():按类型返回直接声明给某个工件的所有注解(JDK1.8)
  • isAnnotationPresent():如果在给定工件上找到指定类型的注解,则返回true

getAnnotatedReceiverType()在前面“在接收器类型上获取注解”部分中进行了讨论。

在下一节中,我们将讨论如何检查包、类、方法等的注解。

检查包注解

package-info.java中添加了特定于包的注解,如下面的屏幕截图所示。在这里,modern.challenge包被注解为@Packt注解:

检查包的注解的一个方便的解决方案是从它的一个类开始的。例如,如果在这个包(modern.challenge中,我们有Melon类,那么我们可以得到这个包的所有注解,如下所示:

Class<Melon> clazz = Melon.class;
Annotation[] pckgAnnotations = clazz.getPackage().getAnnotations();

通过Arrays.toString()打印的Annotation[]显示一个结果:

[@modern.challenge.Packt()]

检查类注解

Melon类有一个注解@Fruit

但我们可以通过getAnnotations()将它们全部取出来:

Class<Melon> clazz = Melon.class;
Annotation[] clazzAnnotations = clazz.getAnnotations();

通过Arrays.toString()打印的返回数组显示一个结果:

[@modern.challenge.Fruit(name="melon", value="delicious")]

为了访问注解的名称和值属性,我们可以按如下方式强制转换它:

Fruit fruitAnnotation = (Fruit) clazzAnnotations[0];
System.out.println("@Fruit name: " + fruitAnnotation.name());
System.out.println("@Fruit value: " + fruitAnnotation.value());

或者我们可以使用getDeclaredAnnotation()方法直接获取正确的类型:

Fruit fruitAnnotation = clazz.getDeclaredAnnotation(Fruit.class);

检查方法注解

我们来看看Melon类中eat()方法的@Ripe注解:

首先,让我们获取所有声明的注解,然后,让我们继续到@Ripe

Class<Melon> clazz = Melon.class;
Method methodEat = clazz.getDeclaredMethod("eat");
Annotation[] methodAnnotations = methodEat.getDeclaredAnnotations();

通过Arrays.toString()打印的返回数组显示一个结果:

[@modern.challenge.Ripe(value=true)]

让我们把methodAnnotations[0]转换成Ripe

Ripe ripeAnnotation = (Ripe) methodAnnotations[0];
System.out.println("@Ripe value: " + ripeAnnotation.value());

或者我们可以使用getDeclaredAnnotation()方法直接获取正确的类型:

Ripe ripeAnnotation = methodEat.getDeclaredAnnotation(Ripe.class);

检查抛出异常的注解

为了检查抛出异常的注解,我们需要调用getAnnotatedExceptionTypes()方法:

此方法返回抛出的异常类型,包括注解的异常类型:

Class<Melon> clazz = Melon.class;
Method methodEat = clazz.getDeclaredMethod("eat");
AnnotatedType[] exceptionsTypes 
  = methodEat.getAnnotatedExceptionTypes();

通过Arrays.toString()打印的返回数组显示一个结果:

[@modern.challenge.Runtime() java.lang.IllegalStateException]

提取第一个异常类型的步骤如下:

// class java.lang.IllegalStateException
System.out.println("First exception type: "
  + exceptionsTypes[0].getType());

提取第一个异常类型的注解可以按如下方式进行:

// [@modern.challenge.Runtime()]
System.out.println("Annotations of the first exception type: " 
  + Arrays.toString(exceptionsTypes[0].getAnnotations()));

检查返回类型的注解

为了检查方法返回的注解,我们需要调用getAnnotatedReturnType()方法:

此方法返回给定方法的带注解的返回类型:

Class<Melon> clazz = Melon.class;
Method methodSeeds = clazz.getDeclaredMethod("seeds");
AnnotatedType returnType = methodSeeds.getAnnotatedReturnType();

// java.util.List<modern.challenge.Seed>
System.out.println("Return type: " 
  + returnType.getType().getTypeName());

// [@modern.challenge.Shape(value="oval")]
System.out.println("Annotations of the return type: " 
  + Arrays.toString(returnType.getAnnotations()));

检查方法参数的注解

有方法,可以调用getParameterAnnotations()来检查其参数的注解:

此方法返回一个矩阵(数组数组),其中包含形式参数上的注解,顺序如下:

Class<Melon> clazz = Melon.class;
Method methodSlice = clazz.getDeclaredMethod("slice", int.class);
Annotation[][] paramAnnotations 
  = methodSlice.getParameterAnnotations();

获取每个参数类型及其注解(在本例中,我们有一个带有两个注解的int参数)可以通过getParameterTypes()完成。由于此方法也维护了声明顺序,因此我们可以提取一些信息,如下所示:

Class<?>[] parameterTypes = methodSlice.getParameterTypes();

int i = 0;
for (Annotation[] annotations: paramAnnotations) {
  Class parameterType = parameterTypes[i++];
  System.out.println("Parameter: " + parameterType.getName());

  for (Annotation annotation: annotations) {
    System.out.println("Annotation: " + annotation);
    System.out.println("Annotation name: " 
      + annotation.annotationType().getSimpleName());
  }
}

并且,输出应如下所示:

Parameter type: int
Annotation: @modern.challenge.Ripe(value=true)
Annotation name: Ripe
Annotation: @modern.challenge.Shape(value="square")
Annotation name: Shape

检查字段注解

有一个字段,我们可以通过getDeclaredAnnotations()获取它的注解:

代码如下:

Class<Melon> clazz = Melon.class;
Field weightField = clazz.getDeclaredField("weight");
Annotation[] fieldAnnotations = weightField.getDeclaredAnnotations();

获取@Unit注解的值可以如下所示:

Unit unitFieldAnnotation = (Unit) fieldAnnotations[0];
System.out.println("@Unit value: " + unitFieldAnnotation.value());

或者,使用getDeclaredAnnotation()方法直接获取正确的类型:

Unit unitFieldAnnotation 
  = weightField.getDeclaredAnnotation(Unit.class);

检查超类的注解

为了检查超类的注解,我们需要调用getAnnotatedSuperclass()方法:

此方法返回带注解的超类类型:

Class<Melon> clazz = Melon.class;
AnnotatedType superclassType = clazz.getAnnotatedSuperclass();

我们也来了解一下:

// modern.challenge.Cucurbitaceae
 System.out.println("Superclass type: " 
   + superclassType.getType().getTypeName());

 // [@modern.challenge.Family()]
 System.out.println("Annotations: " 
   + Arrays.toString(superclassType.getDeclaredAnnotations()));

 System.out.println("@Family annotation present: " 
   + superclassType.isAnnotationPresent(Family.class)); // true

检查接口注解

为了检查实现接口的注解,我们需要调用getAnnotatedInterfaces()方法:

此方法返回带注解的接口类型:

Class<Melon> clazz = Melon.class;
AnnotatedType[] interfacesTypes = clazz.getAnnotatedInterfaces();

通过Arrays.toString()打印的返回数组显示一个结果:

[@modern.challenge.ByWeight() java.lang.Comparable]

提取第一个接口类型可以如下完成:

// interface java.lang.Comparable
System.out.println("First interface type: " 
  + interfacesTypes[0].getType());

此外,提取第一接口类型的注解可以如下进行:

// [@modern.challenge.ByWeight()]
System.out.println("Annotations of the first exception type: " 
  + Arrays.toString(interfacesTypes[0].getAnnotations()));

按类型获取注解

在某些组件上有多个相同类型的注解,我们可以通过getAnnotationsByType()获取所有注解。对于一个类,我们可以按如下方式进行:

Class<Melon> clazz = Melon.class;
Fruit[] clazzFruitAnnotations 
  = clazz.getAnnotationsByType(Fruit.class);

获取声明的注解

尝试按类型获取直接在某个工件上声明的单个注解可以按以下示例所示进行:

Class<Melon> clazz = Melon.class;
Method methodEat = clazz.getDeclaredMethod("eat");
Ripe methodRipeAnnotation 
  = methodEat.getDeclaredAnnotation(Ripe.class);

159 调用实例方法

假设我们有以下Melon类:

public class Melon {
  ...
  public Melon() {}

  public List<Melon> cultivate(
      String type, Seed seed, int noOfSeeds) {

    System.out.println("The cultivate() method was invoked ...");

    return Collections.nCopies(noOfSeeds, new Melon("Gac", 5));
  }
  ...
}

我们的目标是调用cultivate()方法并通过 Java 反射 API 获得返回。

首先,让我们通过Method.getDeclaredMethod()获取cultivate()方法作为Method。我们所要做的就是将方法的名称(在本例中为cultivate())和正确类型的参数(StringSeedint传递给getDeclaredMethod()getDeclaredMethod()的第二个参数是Class<?>类型的varargs,因此对于没有参数的方法可以为空,也可以包含参数类型列表,如下例所示:

Method cultivateMethod = Melon.class.getDeclaredMethod(
  "cultivate", String.class, Seed.class, int.class);

然后,获取一个Melon类的实例。我们想要调用一个实例方法;因此,我们需要一个实例。依靠Melon的空构造器和 Java 反射 API,我们可以做到:

Melon instanceMelon = Melon.class
  .getDeclaredConstructor().newInstance();

最后,我们重点讨论了Method.invoke()方法。主要是给这个方法传递调用cultivate()方法的实例和一些参数值:

List<Melon> cultivatedMelons = (List<Melon>) cultivateMethod.invoke(
  instanceMelon, "Gac", new Seed(), 10);

以下消息显示调用成功:

The cultivate() method was invoked ...

另外,如果我们通过System.out.println()打印调用返回,则得到如下结果:

[Gac(5g), Gac(5g), Gac(5g), ...]

我们刚刚通过反射培养了 10 个Gac

160 获取静态方法

假设我们有以下Melon类:

public class Melon {
  ...
  public void eat() {}

  public void weighsIn() {}

  public static void cultivate(Seed seeds) {
    System.out.println("The cultivate() method was invoked ...");
  }

  public static void peel(Slice slice) {
    System.out.println("The peel() method was invoked ...");
  }

  // getters, setters, toString() omitted for brevity
}

这个类有两个static方法-cultivate()peel()。让我们在List<Method>中获取这两种方法。

这个问题的解决方案有两个主要步骤:

  1. 获取给定类的所有可用方法
  2. 通过Modifier.isStatic()方法过滤包含static修饰符的

在代码中,如下所示:

List<Method> staticMethods = new ArrayList<>();

Class<Melon> clazz = Melon.class;
Method[] methods = clazz.getDeclaredMethods();

for (Method method: methods) {

  if (Modifier.isStatic(method.getModifiers())) {
    staticMethods.add(method);
  }
}

通过System.out.println()打印列表的结果如下:

[public static void 
  modern.challenge.Melon.peel(modern.challenge.Slice),

 public static void 
  modern.challenge.Melon.cultivate(modern.challenge.Seed)]

再往前一步,我们可能想调用这两个方法中的一个。

例如,我们调用peel()方法(注意我们传递的是null而不是Melon的实例,因为static方法不需要实例):

Method method = clazz.getMethod("peel", Slice.class);
method.invoke(null, new Slice());

成功调用peel()方法的输出信号:

The peel() method was invoked ...

161 获取方法、字段和异常的泛型

假设我们有以下Melon类(列出的只是与这个问题相关的部分):

public class Melon<E extends Exception>
    extends Fruit<String, Seed> implements Comparable<Integer> {

  ...
  private List<Slice> slices;
  ...

  public List<Slice> slice() throws E {
    ...
  }

  public Map<String, Integer> asMap(List<Melon> melons) {
    ...
  }
  ...
}

Melon类包含几个与不同工件相关联的泛型类型。超类、接口、类、方法和字段的泛型类型主要是ParameterizedType实例。对于每个ParameterizedType,我们需要通过ParameterizedType.getActualTypeArguments()获取参数的实际类型。此方法返回的Type[]可以迭代提取每个参数的信息,如下所示:

public static void printGenerics(Type genericType) {

  if (genericType instanceof ParameterizedType) {
    ParameterizedType type = (ParameterizedType) genericType;
    Type[] typeOfArguments = type.getActualTypeArguments();

    for (Type typeOfArgument: typeOfArguments) {
      Class classTypeOfArgument = (Class) typeOfArgument;
      System.out.println("Class of type argument: " 
        + classTypeOfArgument);

      System.out.println("Simple name of type argument: " 
        + classTypeOfArgument.getSimpleName());
    }
  }
}

现在,让我们看看如何处理方法的泛型。

方法的泛型

例如,让我们获取slice()asMap()方法的通用返回类型。这可以通过Method.getGenericReturnType()方法实现,如下所示:

Class<Melon> clazz = Melon.class;

Method sliceMethod = clazz.getDeclaredMethod("slice");
Method asMapMethod = clazz.getDeclaredMethod("asMap", List.class);

Type sliceReturnType = sliceMethod.getGenericReturnType();
Type asMapReturnType = asMapMethod.getGenericReturnType();

现在,调用printGenerics(sliceReturnType)将输出以下内容:

Class of type argument: class modern.challenge.Slice
Simple name of type argument: Slice

并且,调用printGenerics(asMapReturnType)将输出以下内容:

Class of type argument: class java.lang.String
Simple name of type argument: String

Class of type argument: class java.lang.Integer
Simple name of type argument: Integer

方法的通用参数可通过Method.getGenericParameterTypes()获得,如下所示:

Type[] asMapParamTypes = asMapMethod.getGenericParameterTypes();

此外,我们为每个Type(每个泛型参数)调用printGenerics()

for (Type paramType: asMapParamTypes) {
  printGenerics(paramType);
}

以下是输出(只有一个通用参数,List<Melon>):

Class of type argument: class modern.challenge.Melon
Simple name of type argument: Melon

字段的泛型

对于字段(例如,slices),可以通过Field.getGenericType()获取泛型,如下所示:

Field slicesField = clazz.getDeclaredField("slices");
Type slicesType = slicesField.getGenericType();

调用printGenerics(slicesType)将输出以下内容:

Class of type argument: class modern.challenge.Slice
Simple name of type argument: Slice

超类的泛型

获取超类的泛型可以通过调用当前类的getGenericSuperclass()方法来完成:

Type superclassType = clazz.getGenericSuperclass();

调用printGenerics(superclassType)将输出以下内容:

Class of type argument: class java.lang.String
Simple name of type argument: String

Class of type argument: class modern.challenge.Seed
Simple name of type argument: Seed

接口泛型

通过调用当前类的getGenericInterfaces()方法,可以得到实现接口的泛型:

Type[] interfacesTypes = clazz.getGenericInterfaces();

此外,我们为每个Type调用printGenerics()。输出如下(有单一接口,Comparable<Integer>

Class of type argument: class java.lang.Integer
Simple name of type argument: Integer

异常的泛型

异常的泛型类型在TypeVariableParameterizedType实例中具体化。这一次,基于TypeVariable的泛型信息提取和打印的助手方法可以写为:

public static void printGenericsOfExceptions(Type genericType) {

  if (genericType instanceof TypeVariable) {
    TypeVariable typeVariable = (TypeVariable) genericType;
    GenericDeclaration genericDeclaration
      = typeVariable.getGenericDeclaration();

    System.out.println("Generic declaration: " + genericDeclaration);

    System.out.println("Bounds: ");
    for (Type type: typeVariable.getBounds()) {
      System.out.println(type);
    }
  }
}

有了这个助手,我们可以通过getGenericExceptionTypes()将方法抛出的异常传递给它。如果异常类型是类型变量(TypeVariable)或参数化类型(ParameterizedType),则创建它。否则,将解决:

Type[] exceptionsTypes = sliceMethod.getGenericExceptionTypes();

此外,我们为每个Type调用printGenerics()

for (Type paramType: exceptionsTypes) {
  printGenericsOfExceptions(paramType);
}

输出如下:

Generic declaration: class modern.challenge.Melon
Bounds: class java.lang.Exception

最可能的情况是,打印有关泛型的提取信息将没有用处,因此,可以根据您的需要随意调整前面的帮助程序。例如,收集信息并以ListMap等形式返回。

162 获取公共和私有字段

这个问题的解决依赖于Modifier.isPublic()Modifier.isPrivate()方法。

假设下面的Melon类有两个public字段和两个private字段:

public class Melon {

  private String type;
  private int weight;

  public Peeler peeler;
  public Juicer juicer;
  ...
}

首先需要通过getDeclaredFields()方法获取该类对应的Field[]数组:

Class<Melon> clazz = Melon.class;
Field[] fields = clazz.getDeclaredFields();

Field[]包含前面的四个字段。此外,让我们迭代这个数组,并对每个Field应用Modifier.isPublic()Modifier.isPrivate()标志方法:

List<Field> publicFields = new ArrayList<>();
List<Field> privateFields = new ArrayList<>();

for (Field field: fields) {
  if (Modifier.isPublic(field.getModifiers())) {
    publicFields.add(field);
  }

  if (Modifier.isPrivate(field.getModifiers())) {
    privateFields.add(field);
  }
}

publicFields列表只包含public字段,privateFields列表只包含private字段。如果我们通过System.out.println()快速打印这两个列表,那么输出如下:

Public fields:
[public modern.challenge.Peeler modern.challenge.Melon.peeler,
public modern.challenge.Juicer modern.challenge.Melon.juicer]

Private fields:
[private java.lang.String modern.challenge.Melon.type,
private int modern.challenge.Melon.weight]

163 使用数组

Java 反射 API 附带了一个专用于处理数组的类。这个类被命名为java.lang.reflect.Array

例如,下面的代码片段创建了一个数组int。第一个参数告诉数组中每个元素的类型。第二个参数表示数组的长度。因此,10 个整数的数组可以通过Array.newInstance()定义如下:

int[] arrayOfInt = (int[]) Array.newInstance(int.class, 10);

使用 Java 反射,我们可以改变数组的内容。有一个通用的set()方法和一堆set*Foo*()方法(例如setInt()setFloat())。将索引 0 处的值设置为 100 可以按以下方式进行:

Array.setInt(arrayOfInt, 0, 100);

从数组中获取值可以通过get()getFoo()方法完成(这些方法将数组和索引作为参数,并从指定的索引返回值):

int valueIndex0 = Array.getInt(arrayOfInt, 0);

获取一个数组的Class可以如下操作:

Class<?> stringClass = String[].class;
Class<?> clazz = arrayOfInt.getClass();

我们可以通过getComponentType()提取数组的类型:

// int
Class<?> typeInt = clazz.getComponentType();

// java.lang.String
Class<?> typeString = stringClass.getComponentType();

164 检查模块

Java9 通过 Java 平台模块系统增加了模块的概念。基本上,模块是由该模块管理的一组包(例如,模块决定哪些包在模块外部可见)。

具有两个模块的应用的形状可以如以下屏幕截图所示:

有两个模块-org.playerorg.tournamentorg.player模块需要org.tournament模块,org.tournament模块导出com.management包。

Java 反射 API 通过java.lang.Module类(在java.base module中)表示一个模块。通过 Java 反射 API,我们可以提取信息或修改模块。

最开始,我们可以得到一个Module实例,如下两个例子所示:

Module playerModule = Player.class.getModule();
Module managerModule = Manager.class.getModule();

模块名称可以通过Module.getName()方法获得:

// org.player
System.out.println("Class 'Player' is in module: " 
  + playerModule.getName());

// org.tournament
System.out.println("Class 'Manager' is in module: " 
  + managerModule.getName());

有一个Module实例,我们可以调用几种方法来获取不同的信息。例如,我们可以确定某个模块是否已命名,或者是否已导出或打开某个包:

boolean playerModuleIsNamed = playerModule.isNamed();   // true
boolean managerModuleIsNamed = managerModule.isNamed(); // true

boolean playerModulePnExported 
  = playerModule.isExported("com.members");     // false
boolean managerModulePnExported 
  = managerModule.isExported("com.management"); // true

boolean playerModulePnOpen 
  = playerModule.isOpen("com.members");     // false
boolean managerModulePnOpen 
  = managerModule.isOpen("com.management"); // false

除了获取信息外,Module类还允许我们修改模块。例如,org.player模块没有将com.members包导出到org.tournament模块。我们可以快速检查:

boolean before = playerModule.isExported(
  "com.members", managerModule); // false

但我们可以通过反射来改变这一点。我们可以通过Module.addExports()方法进行导出(同一类别中我们有addOpens()addReads()addUses()

playerModule.addExports("com.members", managerModule);

现在,让我们再次检查:

boolean after = playerModule.isExported(
  "com.members", managerModule); // true

模块还利用了自己的描述符。ModuleDescriptor类可用作处理模块的起点:

ModuleDescriptor descriptorPlayerModule 
  = playerModule.getDescriptor();

例如,我们可以按如下方式获取模块的包:

Set<String> pcks = descriptorPlayerModule.packages();

165 动态代理

动态代理可用于支持不同功能的实现,这些功能属于交叉切入点CCC)类别。CCC 是那些表示核心功能的辅助功能的关注点,例如数据库连接管理、事务管理(例如 Spring@Transactional)、安全性和日志记录。

更确切地说,Java 反射附带了一个名为java.lang.reflect.Proxy的类,其主要目的是为在运行时创建接口的动态实现提供支持。Proxy反映了具体接口在运行时的实现。

我们可以将Proxy看作是前包装器,它将我们的调用传递给正确的方法。可选地,Proxy可以在委托调用之前干预该过程。

动态代理依赖于单个类(InvocationHandler)和单个方法(invoke()),如下图所示:

如果我们从这个图中描述流程,那么我们得到以下步骤:

  1. 参与者通过公开的动态代理调用所需的方法(例如,如果我们要调用List.add()方法,我们将通过动态代理,而不是直接调用)

  2. 动态代理将调用分派给一个InvocationHandler实现的实例(每个代理实例都有一个关联的调用处理器)

  3. 分派的调用将以包含代理对象、要调用的方法(作为Method实例)和此方法的参数数组的三元组的形式命中invoke()方法

  4. InvocationHandler将运行额外的可选功能(例如,CCC)并调用相应的方法

  5. InvocationHandler将调用结果作为对象返回

如果我们尝试恢复此流,那么可以说动态代理通过单个类(InvocationHandler)和单个方法(invoke())支持对任意类的多个方法的调用。

实现动态代理

例如,让我们编写一个动态代理来统计List方法的调用次数。

通过Proxy.newProxyInstance()方法创建动态代理。newProxyInstance()方法有三个参数:

  • ClassLoader:用于加载动态代理类
  • Class<?>[]:这是要实现的接口数组
  • InvocationHandler:这是将方法调用分派到的调用处理器

看看这个例子:

List<String> listProxy = (List<String>) Proxy.newProxyInstance(
  List.class.getClassLoader(), new Class[] {
    List.class}, invocationHandler);

这段代码返回List接口的动态实现。此外,通过该代理的所有调用都将被调度到invocationHandler实例。

主要地,InvocationHandler实现的框架如下所示:

public class DummyInvocationHandler implements InvocationHandler {

  @Override
  public Object invoke(Object proxy, Method method, Object[] args)
      throws Throwable {
    ...
  }
}

因为我们要计算List的方法的调用次数,所以我们应该存储所有的方法签名以及每个方法的调用次数。这可以通过在CountingInvocationHandler的构造器中初始化Map来实现(这是我们的InvocationHandler实现,invocationHandler是它的一个实例):

public class CountingInvocationHandler implements InvocationHandler {

  private final Map<String, Integer> counter = new HashMap<>();
  private final Object targetObject;

  public CountingInvocationHandler(Object targetObject) {
    this.targetObject = targetObject;

    for (Method method:targetObject.getClass().getDeclaredMethods()) {
      this.counter.put(method.getName() 
        + Arrays.toString(method.getParameterTypes()), 0);
    }
  }
  ...
}

targetObject字段保存List接口的实现(在本例中为ArrayList)。

我们创建一个CountingInvocationHandler实例如下:

CountingInvocationHandler invocationHandler 
  = new CountingInvocationHandler(new ArrayList<>());

invoke()方法只是对调用进行计数,并使用指定的参数调用Method

@Override
public Object invoke(Object proxy, Method method, Object[] args)
    throws Throwable {

  Object resultOfInvocation = method.invoke(targetObject, args);
  counter.computeIfPresent(method.getName() 
    + Arrays.toString(method.getParameterTypes()), (k, v) -> ++v);

  return resultOfInvocation;
}

最后,我们公开了一个方法,该方法返回给定方法的调用次数:

public Map<String, Integer> countOf(String methodName) {

  Map<String, Integer> result = counter.entrySet().stream()
    .filter(e -> e.getKey().startsWith(methodName + "["))
    .filter(e -> e.getValue() != 0)
    .collect(Collectors.toMap(Entry::getKey, Entry::getValue));

  return result;
}

绑定到本书的代码将这些代码片段粘在一个名为CountingInvocationHandler的类中。

此时我们可以使用listProxy调用几个方法,如下所示:

listProxy.add("Adda");
listProxy.add("Mark");
listProxy.add("John");
listProxy.remove("Adda");
listProxy.add("Marcel");
listProxy.remove("Mark");
listProxy.add(0, "Akiuy");

让我们看看我们调用了多少次add()remove()方法:

// {add[class java.lang.Object]=4, add[int, class java.lang.Object]=1}
invocationHandler.countOf("add");

// {remove[class java.lang.Object]=2}
invocationHandler.countOf("remove");

因为add()方法是通过它的两个签名调用的,所以得到的Map包含两个条目。

总结

这是本章的最后一个问题。希望我们已经完成了对 Java 反射 API 的全面遍历。我们已经详细讨论了有关类、接口、构造器、方法、字段、注解等的问题

从本章下载应用以查看结果和其他详细信息。

八、函数式编程-基础和设计模式

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括 11 个涉及 Java 函数式编程的问题。我们将从一个问题开始,这个问题旨在提供从 0 到函数式接口的完整过程。然后,我们将继续研究 GoF 中的一套设计模式,我们将用 Java 函数风格来解释这些模式。

在本章结束时,您应该熟悉函数式编程,并准备好继续处理一组问题,这些问题允许我们深入研究这个主题。您应该能够使用一堆以函数式风格编写的常用设计模式,并且非常了解如何开发代码以利用函数式接口。

问题

使用以下问题来测试您的函数式编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. “编写函数式接口”:编写一个程序,通过一组有意义的例子定义从 0 到函数式接口的路径。
  2. Lambda 概述:解释什么是 Lambda 表达式。
  3. 实现环绕执行模式:基于 Lambda 编写实现环绕执行模式的程序。
  4. 实现工厂模式:基于 Lambda 编写一个实现工厂模式的程序。
  5. 实现策略模式:基于 Lambda 编写一个实现策略模式的程序。
  6. 实现模板方法模式:基于 Lambda 编写一个实现模板方法模式的程序。
  7. 实现观察者模式:基于 Lambda 编写一个实现观察者模式的程序。
  8. 实现借贷模式:基于 Lambda 编写实现借贷模式的程序。
  9. 实现装饰器模式:基于 Lambda 编写一个实现装饰器模式的程序。
  10. 实现级联生成器模式:基于 Lambda 编写一个实现级联生成器模式的程序。
  11. 实现命令模式:基于 Lambda 编写一个实现命令模式的程序。

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释仅包括解决这些问题所需的最有趣和最重要的细节。您可以下载示例解决方案以查看更多详细信息并尝试程序

166 编写函数式接口

在这个解决方案中,我们将强调函数式接口的用途和可用性,并与几种替代方案进行比较。我们将研究如何将代码从基本的、严格的实现发展到基于函数式接口的灵活实现。为此,让我们考虑以下Melon类:

public class Melon {

  private final String type;
  private final int weight;
  private final String origin;

  public Melon(String type, int weight, String origin) {
    this.type = type;
    this.weight = weight;
    this.origin = origin;
  }

  // getters, toString(), and so on omitted for brevity
}

假设我们有一个客户——我们叫他马克——他想开一家卖瓜的公司。我们根据他的描述塑造了前面的类。他的主要目标是拥有一个库存应用来支持他的想法和决策,因此需要创建一个必须基于业务需求和发展的应用。我们将在下面几节中查看每天开发此应用所需的时间。

第 1 天(按瓜的类型过滤)

有一天,马克让我们提供一个功能,可以按瓜的类型过滤瓜。因此,我们创建了一个名为Filters的工具类,并实现了一个static方法,该方法将瓜列表和要过滤的类型作为参数。

得到的方法非常简单:

public static List<Melon> filterByType(
    List<Melon> melons, String type) {

  List<Melon> result = new ArrayList<>();

  for (Melon melon: melons) {
    if (melon != null && type.equalsIgnoreCase(melon.getType())) {
      result.add(melon);
    }
  }

  return result;
}

完成!现在,我们可以很容易地按类型过滤西瓜,如下例所示:

List<Melon> bailans = Filters.filterByType(melons, "Bailan");

第 2 天(过滤一定重量的瓜)

虽然马克对结果很满意,但他要求另一个过滤器来获得一定重量的瓜(例如,所有 1200 克的瓜)。我们刚刚对甜瓜类型实现了这样一个过滤器,因此我们可以为一定重量的甜瓜提出一个新的static方法,如下所示:

public static List<Melon> filterByWeight(
    List<Melon> melons, int weight) {

  List<Melon> result = new ArrayList<>();

  for (Melon melon: melons) {
    if (melon != null && melon.getWeight() == weight) {
      result.add(melon);
    }
  }

  return result;
}

这与filterByType()类似,只是它有不同的条件/过滤器。作为开发人员,我们开始明白,如果我们继续这样做,Filters类最终会有很多方法,这些方法只是重复代码并使用不同的条件。我们非常接近一个样板代码案例。

第 3 天(按类型和重量过滤瓜)

事情变得更糟了。马克现在要求我们添加一个新的过滤器,按类型和重量过滤西瓜,他需要这个很快。然而,最快的实现是最丑陋的。过来看:

public static List<Melon> filterByTypeAndWeight(
    List<Melon> melons, String type, int weight) {
  List<Melon> result = new ArrayList<>();

  for (Melon melon: melons) {
    if (melon != null && type.equalsIgnoreCase(melon.getType()) 
        && melon.getWeight() == weight) {
      result.add(melon);
    }
  }

  return result;
}

在我们的情况下,这是不可接受的。如果我们在这里添加一个新的过滤条件,代码将变得很难维护并且容易出错。

第 4 天(将行为作为参数)

会议时间到了!我们不能继续像这样添加更多的过滤器;我们能想到的每一个属性的过滤器最终都会出现在一个巨大的Filters类中,这个类有大量复杂的方法,其中包含太多的参数和大量的样板代码。

主要的问题是我们在样板代码中有不同的行为。因此,只编写一次样板代码并将行为作为一个参数来推送是很好的。这样,我们就可以将任何选择条件/标准塑造成行为,并根据需要对它们进行处理。代码将变得更加清晰、灵活、易于维护,并且具有更少的参数。

这被称为行为参数化,如下图所示(左侧显示我们现在拥有的;右侧显示我们想要的):

如果我们将每个选择条件/标准看作一种行为,那么将每个行为看作一个接口的实现是非常直观的。基本上,所有这些行为都有一个共同点——选择条件/标准和返回boolean类型(这被称为谓词)。在接口的上下文中,这是一个可以按如下方式编写的合同:

public interface MelonPredicate {
  boolean test(Melon melon);
}

此外,我们可以编写MelonPredicate的不同实现。例如,过滤Gac瓜可以这样写:

public class GacMelonPredicate implements MelonPredicate {
  @Override
  public boolean test(Melon melon) {
    return "gac".equalsIgnoreCase(melon.getType());
  }
}

或者,过滤所有重量超过 5000 克的西瓜可以写:

public class HugeMelonPredicate implements MelonPredicate {
  @Override
  public boolean test(Melon melon) {
    return melon.getWeight() > 5000;
  }
}

这种技术有一个名字——策略设计模式。根据 GoF 的说法,这可以“定义一系列算法,封装每个算法,并使它们可以互换。策略模式允许算法在客户端之间独立变化”。

因此,主要思想是在运行时动态选择算法的行为。MelonPredicate接口统一了所有用于选择西瓜的算法,每个实现都是一个策略。

目前,我们有策略,但没有任何方法接收到一个MelonPredicate参数。我们需要一个filterMelons()方法,如下图所示:

所以,我们需要一个参数和多个行为。让我们看看filterMelons()的源代码:

public static List<Melon> filterMelons(
    List<Melon> melons, MelonPredicate predicate) {

  List<Melon> result = new ArrayList<>();

  for (Melon melon: melons) {
    if (melon != null && predicate.test(melon)) {
      result.add(melon);
    }
  }

  return result;
}

这样好多了!我们可以通过以下不同的行为重用此方法(这里,我们传递GacMelonPredicateHugeMelonPredicate

List<Melon> gacs = Filters.filterMelons(
  melons, new GacMelonPredicate());

List<Melon> huge = Filters.filterMelons(
  melons, new HugeMelonPredicate());

第 5 天(实现另外 100 个过滤器)

马克要求我们再安装 100 个过滤器。这一次,我们有足够的灵活性和支持来完成这项任务,但是我们仍然需要为每个选择标准编写 100 个实现MelonPredicate的策略或类。此外,我们必须创建这些策略的实例,并将它们传递给filterMelons()方法。

这意味着大量的代码和时间。为了保存这两者,我们可以依赖 Java 匿名类。换句话说,同时声明和实例化没有名称的类将导致如下结果:

List<Melon> europeans = Filters.filterMelons(
    melons, new MelonPredicate() {
    @Override
    public boolean test(Melon melon) {
      return "europe".equalsIgnoreCase(melon.getOrigin());
    }
});

在这方面取得了一些进展,但这并不是很重要,因为我们仍然需要编写大量代码。检查下图中突出显示的代码(此代码对每个实现的行为重复):

在这里,代码不友好。匿名类看起来很复杂,而且它们看起来有些不完整和奇怪,特别是对新手来说。

第 6 天(匿名类可以写成 Lambda)

新的一天,新的想法!任何智能 IDE 都可以为我们指明前进的道路。例如,NetbeansIDE 将不连续地警告我们,这个匿名类可以作为 Lambda 表达式编写。

如以下屏幕截图所示:

这个消息非常清楚——这个匿名的内部类创建可以转换成 Lambda 表达式。在这里,手工进行转换,或者让 IDE 为我们做。

结果如下:

List<Melon> europeansLambda = Filters.filterMelons(
  melons, m -> "europe".equalsIgnoreCase(m.getOrigin()));

这样好多了!Java8Lambda 表达式这次做得很好。现在,我们可以以更灵活、快速、干净、可读和可维护的方式编写马克的过滤器。

第 7 天(抽象列表类型)

马克第二天带来了一些好消息——他将扩展业务,销售其他水果和瓜类。这很酷,但是我们的谓词只支持Melon实例。

那么,我们应该如何继续支持其他水果呢?还有多少水果?如果马克决定开始销售另一类产品,如蔬菜,该怎么办?我们不能简单地为它们中的每一个创建谓词。这将带我们回到起点。

显而易见的解决方案是抽象List类型。我们首先定义一个新接口,这次将其命名为Predicate(从名称中删除Melon):

@FunctionalInterface
public interface Predicate<T> {
  boolean test(T t);
}

接下来,我们覆盖filterMelons()方法并将其重命名为filter()

public static <T> List<T> filter(
    List<T> list, Predicate<T> predicate) {

  List<T> result = new ArrayList<>();

  for (T t: list) {
    if (t != null && predicate.test(t)) {
      result.add(t);
    }
  }

  return result;
}

现在,我们可以为Melon编写过滤器:

List<Melon> watermelons = Filters.filter(
  melons, (Melon m) -> "Watermelon".equalsIgnoreCase(m.getType()));

我们也可以对数字做同样的处理:

List<Integer> numbers = Arrays.asList(1, 13, 15, 2, 67);
List<Integer> smallThan10 = Filters
  .filter(numbers, (Integer i) -> i < 10);

退后一步,看看我们的起点和现在。由于 Java8 函数式接口和 Lambda 表达式,这种差异是巨大的。你注意到Predicate接口上的@FunctionalInterface注解了吗?好吧,这是一个信息注释类型,用于标记函数式接口。如果标记的接口不起作用,则发生错误是很有用的。

从概念上讲,函数式接口只有一个抽象方法。此外,我们定义的Predicate接口已经作为java.util.function.Predicate接口存在于 Java8 中。java.util.function包包含 40 多个这样的接口。因此,在定义一个新的包之前,最好检查这个包的内容。大多数情况下,六个标准的内置函数式接口就可以完成这项工作。具体如下:

  • Predicate<T>
  • Consumer<T>
  • Supplier<T>
  • Function<T, R>
  • UnaryOperator<T>
  • BinaryOperator<T>

函数式接口和 Lambda 表达式是一个很好的团队。Lambda 表达式支持直接内联实现函数式接口的抽象方法。基本上,整个表达式被视为函数式接口的具体实现的实例,如以下代码所示:

Predicate<Melon> predicate = (Melon m) 
  -> "Watermelon".equalsIgnoreCase(m.getType());

167 Lambda 简述

剖析 Lambda 表达式将显示三个主要部分,如下图所示:

以下是 Lambda 表达式每个部分的说明:

  • 在箭头的左侧,我们有 Lambda 主体中使用的参数。这些是FilenameFilter.accept​(File folder, String fileName)方法的参数。
  • 在箭头的右侧,我们有 Lambda 主体,在本例中,它检查找到文件的文件夹是否可以读取,以及文件名是否以.pdf后缀结尾。
  • 箭头只是 Lambda 参数和主体的分隔符。

此 Lambda 的匿名类版本如下所示:

FilenameFilter filter = new FilenameFilter() {
  @Override
  public boolean accept(File folder, String fileName) {
    return folder.canRead() && fileName.endsWith(".pdf");
  }
};

现在,如果我们看 Lambda 和它的匿名版本,那么我们可以得出结论,Lambda 表达式是一个简明的匿名函数,可以作为参数传递给方法或保存在变量中。我们可以得出结论,Lambda 表达式可以根据下图中所示的四个单词来描述:

Lambda 支持行为参数化,这是一个很大的优点(查看前面的问题以获得对此的详细解释)。最后,请记住 Lambda 只能在函数式接口的上下文中使用。

168 实现环绕执行模式

环绕执行模式试图消除围绕特定任务的样板代码。例如,为了打开和关闭文件,特定于文件的任务需要被代码包围。

主要地,环绕执行模式在暗示在资源的开-关生命周期内发生的任务的场景中很有用。例如,假设我们有一个Scanner,我们的第一个任务是从文件中读取一个double值:

try (Scanner scanner = new Scanner(
    Path.of("doubles.txt"), StandardCharsets.UTF_8)) {

  if (scanner.hasNextDouble()) {
    double value = scanner.nextDouble();
  }
}

稍后,另一项任务包括打印所有double值:

try (Scanner scanner = new Scanner(
    Path.of("doubles.txt"), StandardCharsets.UTF_8)) {
  while (scanner.hasNextDouble()) {
    System.out.println(scanner.nextDouble());
  }
}

下图突出了围绕这两项任务的样板代码:

为了避免这个样板代码,环绕执行模式依赖于行为参数化(在“编写函数式接口”一节中进一步详细说明)。实现这一点所需的步骤如下:

  1. 第一步是定义一个与Scanner -> double签名匹配的函数式接口,该接口可能抛出一个IOException
@FunctionalInterface
public interface ScannerDoubleFunction {
  double readDouble(Scanner scanner) throws IOException;
}

声明函数式接口只是解决方案的一半。

  1. 到目前为止,我们可以编写一个Scanner -> double类型的 Lambda,但是我们需要一个接收并执行它的方法。为此,让我们考虑一下Doubles工具类中的以下方法:
public static double read(ScannerDoubleFunction snf)
    throws IOException {

  try (Scanner scanner = new Scanner(
      Path.of("doubles.txt"), StandardCharsets.UTF_8)) {

    return snf.readDouble(scanner);
  }
}

传递给read()方法的 Lambda 在这个方法的主体中执行。当我们传递 Lambda 时,我们提供了一个称为直接内联的abstract方法的实现。主要是作为函数式接口ScannerDoubleFunction的一个实例,因此我们可以调用readDouble()方法来获得期望的结果。

  1. 现在,我们可以简单地将任务作为 Lambda 传递并重用read()方法。例如,我们的任务可以包装在两个static方法中,如图所示(这种做法是为了获得干净的代码并避免大 Lambda):
private static double getFirst(Scanner scanner) {
  if (scanner.hasNextDouble()) {
    return scanner.nextDouble();
  }

  return Double.NaN;
}

private static double sumAll(Scanner scanner) {
  double sum = 0.0d;
  while (scanner.hasNextDouble()) {

    sum += scanner.nextDouble();
  }

  return sum;
}
  1. 以这两个任务为例,我们还可以编写其他任务。让我们把它们传递给read()方法:
double singleDouble 
  = Doubles.read((Scanner sc) -> getFirst(sc));
double sumAllDoubles 
  = Doubles.read((Scanner sc) -> sumAll(sc));

环绕执行模式对于消除特定于打开和关闭资源(I/O 操作)的样板代码非常有用。

169 实现工厂模式

简而言之,工厂模式允许我们创建多种对象,而无需向调用者公开实例化过程。通过这种方式,我们可以隐藏创建对象的复杂和/或敏感过程,并向调用者公开直观且易于使用的对象工厂

在经典实现中,工厂模式依赖于实习生switch(),如下例所示:

public static Fruit newInstance(Class<?> clazz) {
  switch (clazz.getSimpleName()) {
    case "Gac":
      return new Gac();
    case "Hemi":
      return new Hemi();
    case "Cantaloupe":
      return new Cantaloupe();
    default:
      throw new IllegalArgumentException(
        "Invalid clazz argument: " + clazz);
  }
}

这里,GacHemiCantaloupe实现相同的Fruit接口,并有空构造器。如果该方法生活在名为MelonFactory的实用类中,则可以调用如下:

Gac gac = (Gac) MelonFactory.newInstance(Gac.class);

但是,Java8 函数样式允许我们使用方法引用技术引用构造器。这意味着我们可以定义一个Supplier<Fruit>来引用Gac空构造器,如下所示:

Supplier<Fruit> gac = Gac::new;

那么HemiCantaloupe等呢?好吧,我们可以简单地把它们都放在一个Map中(注意这里没有实例化甜瓜类型;它们只是懒惰的方法引用):

private static final Map<String, Supplier<Fruit>> MELONS 
  = Map.of("Gac", Gac::new, "Hemi", Hemi::new,
     "Cantaloupe", Cantaloupe::new);

此外,我们可以覆盖newInstance()方法来使用这个映射:

public static Fruit newInstance(Class<?> clazz) {

    Supplier<Fruit> supplier = MELONS.get(clazz.getSimpleName());

    if (supplier == null) {
      throw new IllegalArgumentException(
        "Invalid clazz argument: " + clazz);
    }

    return supplier.get();
  }

调用方代码不需要进一步修改:

Gac gac = (Gac) MelonFactory.newInstance(Gac.class);

然而,很明显,构造器并不总是空的。例如,下面的Melon类公开了一个具有三个参数的构造器:

public class Melon implements Fruit {

  private final String type;
  private final int weight;
  private final String color;

  public Melon(String type, int weight, String color) {
    this.type = type;
    this.weight = weight;
    this.color = color;
  }
}

无法通过空构造器获取创建此类的实例。但如果我们定义了一个支持三个参数和一个返回的函数式接口,那么我们就回到了正轨:

@FunctionalInterface
public interface TriFunction<T, U, V, R> {
  R apply(T t, U u, V v);
}

这一次,下面的语句将尝试获取具有三个参数的构造器,这三个参数分别是StringIntegerString类型:

private static final
  TriFunction<String, Integer, String, Melon> MELON = Melon::new;

专门为Melon类制作的newInstance()方法是:

public static Fruit newInstance(
    String name, int weight, String color) {
  return MELON.apply(name, weight, name);
}

一个Melon实例可以创建如下:

Melon melon = (Melon) MelonFactory.newInstance("Gac", 2000, "red");

完成!现在,我们有一个工厂的Melon通过函数式接口。

170 实现策略模式

经典的策略模式非常简单。它由一个表示一系列算法(策略)的接口和该接口的几个实现(每个实现都是一个策略)组成。

例如,以下接口统一了从给定字符串中删除字符的策略:

public interface RemoveStrategy {
  String execute(String s);
}

首先,我们将定义从字符串中删除数值的策略:

public class NumberRemover implements RemoveStrategy {
  @Override
  public String execute(String s) {
    return s.replaceAll("\\d", "");
  }
}

然后,我们将定义一种从字符串中删除空格的策略:

public class WhitespacesRemover implements RemoveStrategy {
  @Override
  public String execute(String s) {
    return s.replaceAll("\\s", "");
  }
}

最后,让我们定义一个工具类作为策略的入口点:

public final class Remover {

  private Remover() {
    throw new AssertionError("Cannot be instantiated");
  }

  public static String remove(String s, RemoveStrategy strategy) {
    return strategy.execute(s);
  }
}

这是一个简单而经典的策略模式实现。如果要从字符串中删除数值,可以按以下操作:

String text = "This is a text from 20 April 2050";
String noNr = Remover.remove(text, new NumberRemover());

但是我们真的需要NumberRemoverWhitespacesRemover类吗?我们是否需要为进一步的策略编写类似的类?显然,答案是否定的。

再次查看我们的接口:

@FunctionalInterface
public interface RemoveStrategy {
  String execute(String s);
}

我们刚刚添加了@FunctionalInterface提示,因为RemoveStrategy接口定义了一个抽象方法,所以它是一个函数式接口。

我们可以在函数式接口的上下文中使用什么?嗯,显而易见的答案是兰巴斯。此外,在这种情况下,Lambda 能为我们做些什么?它可以删除样板文件代码(在本例中是表示策略的类),并将策略封装在其主体中:

String noNr = Remover.remove(text, s -> s.replaceAll("\\d", ""));
String noWs = Remover.remove(text, s -> s.replaceAll("\\s", ""));

所以,这就是通过 Lambda 的策略模式。

171 实现模板方法模式

模板方法是 GoF 的一个经典设计模式,它允许我们在方法中编写一个算法的框架,并将该算法的某些步骤推迟到客户端子类。

例如,做比萨饼需要三个主要步骤——准备面团、添加配料和烘烤比萨饼。虽然第一步和最后一步对于所有比萨饼来说都是相同的(固定步骤),但是对于每种比萨饼来说,第二步是不同的(可变步骤)。

如果我们通过模板方法模式将其放入代码中,那么我们会得到如下结果(方法make()表示模板方法,并以明确定义的顺序包含固定和可变的步骤):

public abstract class PizzaMaker {

  public void make(Pizza pizza) {
    makeDough(pizza);
    addTopIngredients(pizza);
    bake(pizza);
  }

  private void makeDough(Pizza pizza) {
    System.out.println("Make dough");
  }

  private void bake(Pizza pizza) {
    System.out.println("Bake the pizza");
  }

  public abstract void addTopIngredients(Pizza pizza);
}

固定步骤有默认实现,而可变步骤由一个名为addTopIngredients()abstract方法表示。这个方法是由这个类的子类实现的。例如,那不勒斯比萨饼的抽象形式如下:

public class NeapolitanPizza extends PizzaMaker {

  @Override
  public void addTopIngredients(Pizza p) {
    System.out.println("Add: fresh mozzarella, tomatoes,
      basil leaves, oregano, and olive oil ");
  }
}

另一方面,希腊披萨将如下:

public class GreekPizza extends PizzaMaker {

  @Override
  public void addTopIngredients(Pizza p) {
    System.out.println("Add: sauce and cheese");
  }
}

因此,每种类型的披萨都需要一个新类来覆盖addTopIngredients()方法。最后,我们可以这样做比萨饼:

Pizza nPizza = new Pizza();
PizzaMaker nMaker = new NeapolitanPizza();
nMaker.make(nPizza);

这种方法的缺点在于样板文件代码和冗长。但是,我们可以通过 Lambda 解决这个缺点。我们可以将模板方法的可变步骤表示为 Lambda 表达式。根据具体情况,我们必须选择合适的函数式接口。在我们的情况下,我们可以依赖于Consumer,如下所示:

public class PizzaLambda {

  public void make(Pizza pizza, Consumer<Pizza> addTopIngredients) {
    makeDough(pizza);
    addTopIngredients.accept(pizza);
    bake(pizza);
  }

  private void makeDough(Pizza p) {
    System.out.println("Make dough");
  }

  private void bake(Pizza p) {
    System.out.println("Bake the pizza");
  }
}

这一次,不需要定义子类(不需要有NeapolitanPizzaGreekPizza或其他)。我们只是通过 Lambda 表达式传递变量step。让我们做一个西西里比萨饼:

Pizza sPizza = new Pizza();
new PizzaLambda().make(sPizza, (Pizza p) 
    -> System.out.println("Add: bits of tomato, onion,
      anchovies, and herbs "));

完成!不再需要样板代码。Lambda 解决方案大大改进了解决方案。

172 实现观察者模式

简言之,观察者模式依赖于一个对象(称为主体),当某些事件发生时,该对象会自动通知其订户(称为观察者)。

例如,消防站总部可以是主体,地方消防站可以是观察者。火灾发生后,消防局总部通知所有当地消防局,并向他们发送火灾发生的地址。每个观察者分析接收到的地址,并根据不同的标准决定是否灭火。

所有本地消防站通过一个名为FireObserver的接口进行分组。此方法定义一个由消防站指挥部调用的抽象方法(主体):

public interface FireObserver {
  void fire(String address);
}

各地方消防站(观察者)实现此接口,并在fire()实现中决定是否灭火。在这里,我们有三个本地站(BrookhavenViningsDecatur):

public class BrookhavenFireStation implements FireObserver {

  @Override
  public void fire(String address) {
    if (address.contains("Brookhaven")) {
      System.out.println(
        "Brookhaven fire station will go to this fire");
    }
  }
}

public class ViningsFireStation implements FireObserver {
  // same code as above for ViningsFireStation
}

public class DecaturFireStation implements FireObserver {
  // same code as above for DecaturFireStation
}

一半的工作完成了!现在,我们需要注册这些观察者,由接收器通知。也就是说,每个地方消防站都需要注册为消防站总部的观察者主体)。为此,我们声明了另一个接口,它定义了主体合同,用于注册和通知其观察者

public interface FireStationRegister {
  void registerFireStation(FireObserver fo);
  void notifyFireStations(String address);
}

最后,我们可以写消防站指挥部(主体):

public class FireStation implements FireStationRegister {

  private final List<FireObserver> fireObservers = new ArrayList<>();

  @Override
  public void registerFireStation(FireObserver fo) {
    if (fo != null) {
      fireObservers.add(fo);
    }
  }

  @Override
  public void notifyFireStations(String address) {
    if (address != null) {
      for (FireObserver fireObserver: fireObservers) {
        fireObserver.fire(address);
      }
    }
  }
}

现在,让我们把我们的三个本地站(观察者)登记到消防站总部(主体):

FireStation fireStation = new FireStation();
fireStation.registerFireStation(new BrookhavenFireStation());
fireStation.registerFireStation(new DecaturFireStation());
fireStation.registerFireStation(new ViningsFireStation());

现在,当发生火灾时,消防局总部将通知所有注册的当地消防局:

fireStation.notifyFireStations(
  "Fire alert: WestHaven At Vinings 5901 Suffex Green Ln Atlanta");

观察者模式在那里成功实现。

这是样板代码的另一个经典案例。每个地方消防站都需要一个新的类和实现fire()方法。

不过,兰博达斯可以再次帮助我们!查看FireObserver接口。它只有一个抽象方法;因此,这是一个函数式接口:

@FunctionalInterface
public interface FireObserver {
  void fire(String address);
}

这个函数式接口是Fire.registerFireStation()方法的一个参数。在此上下文中,我们可以将 Lambda 传递给此方法,而不是本地消防站的新实例。Lambda 将在其主体中包含行为;因此,我们可以删除本地站类并依赖 Lambda,如下所示:

fireStation.registerFireStation((String address) -> {
  if (address.contains("Brookhaven")) {
    System.out.println(
      "Brookhaven fire station will go to this fire");
  }
});

fireStation.registerFireStation((String address) -> {
  if (address.contains("Vinings")) {
    System.out.println("Vinings fire station will go to this fire");
  }
});

fireStation.registerFireStation((String address) -> {
  if (address.contains("Decatur")) {
    System.out.println("Decatur fire station will go to this fire");
  }
});

完成!不再有样板代码。

173 实现借贷模式

在这个问题上,我们将讨论如何实现借贷模式。假设我们有一个包含三个数字的文件(比如说,double),每个数字都是一个公式的系数。例如,数字xyz是以下两个公式的系数:x + y - zx - y * sqrt(z)。同样的,我们也可以写出其他的公式。

在这一点上,我们有足够的经验来认识到这个场景听起来很适合行为参数化。这一次,我们没有定义自定义函数式接口,而是使用一个名为Function<T, R>的内置函数式接口。此函数式接口表示接受一个参数并生成结果的函数。其抽象方法的签名为R apply(T t)

这个函数式接口成为一个static方法的参数,该方法旨在实现借贷模式。让我们把这个方法放在一个名为Formula的类中:

public class Formula {
  ...
  public static double compute(
      Function<Formula, Double> f) throws IOException {
    ...
  }
}

注意,compute()方法在Formula类中声明时接受Formula -> Double类型的 Lambda。让我们来展示一下compute()的全部源代码:

public static double compute(
    Function<Formula, Double> f) throws IOException {

  Formula formula = new Formula();
  double result = 0.0 d;

  try {
    result = f.apply(formula);
  } finally {
    formula.close();
  }

  return result;
}

这里应该强调三点。首先,当我们创建一个新的Formula实例时,我们实际上在我们的文件中打开了一个新的Scanner(检查这个类的private构造器):

public class Formula {

  private final Scanner scanner;
  private double result;

  private Formula() throws IOException {
    result = 0.0 d;

    scanner = new Scanner(
      Path.of("doubles.txt"), StandardCharsets.UTF_8);
  }
  ...
}

第二,当我们执行 Lambda 时,我们实际上是在调用Formula的实例链方法来执行计算(应用公式)。每个方法都返回当前实例。应该调用的实例方法在 Lambda 表达式的主体中定义。

我们只需要以下计算,但可以添加更多计算:

public Formula add() {
  if (scanner.hasNextDouble()) {
    result += scanner.nextDouble();
  }

  return this;
}

public Formula minus() {
  if (scanner.hasNextDouble()) {
    result -= scanner.nextDouble();
  }

  return this;
}

public Formula multiplyWithSqrt() {
  if (scanner.hasNextDouble()) {
    result *= Math.sqrt(scanner.nextDouble());
  }

  return this;
}

由于计算结果(公式)是一个double,我们需要提供一个终端方法,返回最终结果:

public double result() {
  return result;
}

最后,我们关闭Scanner并重置结果。这在private close()方法中发生:

private void close() {
  try (scanner) {
    result = 0.0 d;
  }
}

这些片段已经粘在一个名为Formula的类下与本书捆绑在一起的代码中。

你还记得我们的公式吗?我们有x + y - zx - y * sqrt(z)。第一个可以写如下:

double xPlusYMinusZ = Formula.compute((sc)
  -> sc.add().add().minus().result());

第二个公式如下:

double xMinusYMultiplySqrtZ = Formula.compute((sc)
  -> sc.add().minus().multiplyWithSqrt().result());

注意,我们可以专注于我们的公式,而不必费心打开和关闭文件。此外,流利的 API 允许我们形成任何公式,并且很容易用更多的操作来丰富它。

174 实现装饰器模式

装饰器模式更喜欢组合而不是继承;因此,它是子类化技术的优雅替代方案。因此,我们主要从基本对象开始,以动态方式添加其他特性。

例如,我们可以用这个图案来装饰蛋糕。装饰过程并没有改变蛋糕本身——它只是添加了一些坚果、奶油、水果等。

下图说明了我们将实现的功能:

首先,我们创建一个名为Cake的接口:

public interface Cake {
 String decorate();
}

然后,我们通过BaseCake实现这个接口:

public class BaseCake implements Cake {

  @Override
  public String decorate() {
    return "Base cake ";
  }
}

之后,我们为这个Cake创建一个抽象的CakeDecorator类。这个类的主要目标是调用给定的Cakedecorate()方法:

public class CakeDecorator implements Cake {

  private final Cake cake;

  public CakeDecorator(Cake cake) {
    this.cake = cake;
  }

  @Override
  public String decorate() {
    return cake.decorate();
  }
}

下一步,我们重点写我们的装饰。

每个装饰器扩展CakeDecorator并修改decorate()方法来添加相应的装饰。

例如,Nuts装饰器如下所示:

public class Nuts extends CakeDecorator {

  public Nuts(Cake cake) {
    super(cake);
  }

  @Override
  public String decorate() {
    return super.decorate() + decorateWithNuts();
  }

  private String decorateWithNuts() {
    return "with Nuts ";
  }
}

为了简洁起见,我们跳过了Cream修饰符。然而,凭直觉很容易看出这个装饰器与Nuts基本相同。

同样,我们有一些样板代码。

现在,我们可以创建一个用坚果和奶油装饰的Cake,如下所示:

Cake cake = new Nuts(new Cream(new BaseCake()));
// Base cake with Cream with Nuts

System.out.println(cake.decorate());

因此,这是装饰器模式的一个经典实现。现在,让我们来看一看基于 Lambda 的实现,它大大减少了代码。尤其是当我们有大量的装饰师时。

这次我们将Cake接口转换成一个类,如下所示:

public class Cake {

  private final String decorations;

  public Cake(String decorations) {
    this.decorations = decorations;
  }

  public Cake decorate(String decoration) {
    return new Cake(getDecorations() + decoration);
  }

  public String getDecorations() {
    return decorations;
  }
}

这里的高潮是decorate()方法。这种方法主要是将给定的装饰应用到现有装饰的旁边,并返回一个新的Cake

作为另一个例子,让我们考虑一下java.awt.Color类,它有一个名为brighter()的方法。这个方法创建了一个新的Color,它是当前Color的一个更亮的版本。类似地,decorate()方法创建了一个新的Cake,它是当前Cake的一个更加修饰的版本。

此外,不需要将装饰器作为单独的类来编写。我们将依靠 Lambda 将装饰人员传递给CakeDecorator

public class CakeDecorator {

  private Function<Cake, Cake> decorator;

  public CakeDecorator(Function<Cake, Cake>... decorations) {
    reduceDecorations(decorations);
  }

  public Cake decorate(Cake cake) {
    return decorator.apply(cake);
  }

  private void reduceDecorations(
      Function<Cake, Cake>... decorations) {

    decorator = Stream.of(decorations)
      .reduce(Function.identity(), Function::andThen);
  }
}

这个类主要完成两件事:

  • 在构造器中,它调用reduceDecorations()方法。此方法将通过Stream.reduce()Function.andThen()方法链接传递的Function数组。结果是由给定的Function数组组成的单个Function
  • 当组合的Functionapply()方法从decorate()方法调用时,它将逐一应用给定函数的链。由于给定数组中的每一个Function都是一个修饰符,因此合成的Function将逐个应用每个修饰符。

让我们创建一个Cake用坚果和奶油装饰:

CakeDecorator nutsAndCream = new CakeDecorator(
  (Cake c) -> c.decorate(" with Nuts"),
  (Cake c) -> c.decorate(" with Cream"));

Cake cake = nutsAndCream.decorate(new Cake("Base cake"));

// Base cake with Nuts with Cream
System.out.println(cake.getDecorations());

完成!考虑运行本书附带的代码来检查输出。

175 实现级联生成器模式

我们已经在第 2 章、“对象、不变性和switch表达式”中讨论过这个模式,“通过构建器模式编写一个不可变类”部分。处理这个问题是明智的,就像快速提醒构建器模式一样。

在我们的工具带下面有一个经典的生成器,假设我们想编写一个传递包裹的类。主要是设置收件人的名字、姓氏、地址和包裹内容,然后交付包裹。

我们可以通过构建器模式和 Lambda 实现这一点,如下所示:

public final class Delivery {

  public Delivery firstname(String firstname) {
    System.out.println(firstname);

    return this;
  }

  //similar for lastname, address and content

  public static void deliver(Consumer<Delivery> parcel) {
    Delivery delivery = new Delivery();
    parcel.accept(delivery);

    System.out.println("\nDone ...");
  }
}

对于递送包裹,我们只需使用 Lambda:

Delivery.deliver(d -> d.firstname("Mark")
  .lastname("Kyilt")
  .address("25 Street, New York")
  .content("10 books"));

显然,Consumer<Delivery>参数有助于使用 Lambda。

176 实现命令模式

简而言之,命令模式用于将命令包装在对象中的场景。可以在不知道命令本身或命令接收器的情况下传递此对象。

此模式的经典实现由几个类组成。在我们的场景中,我们有以下内容:

  • Command接口负责执行某个动作(在这种情况下,可能的动作是移动、复制和删除)。该接口的具体实现为CopyCommandMoveCommandDeleteCommand
  • IODevice接口定义支持的动作(move()copy()delete())。HardDisk类是IODevice的具体实现,代表接收器
  • Sequence类是命令的调用方,它知道如何执行给定的命令。调用方可以以不同的方式进行操作,但是在这种情况下,我们只需记录命令,并在调用runSequence()时成批执行它们。

命令模式可以用下图表示:

因此,HardDisk实现了在IODevice接口中给出的动作。作为接收器HardDisk负责在调用某个命令的execute()方法时运行实际动作。IODevice的源代码如下:

public interface IODevice {
  void copy();
  void delete();
  void move();
}

HardDiskIODevice的具体实现:

public class HardDisk implements IODevice {

  @Override
  public void copy() {
    System.out.println("Copying ...");
  }

  @Override
  public void delete() {
    System.out.println("Deleting ...");
  }

  @Override
  public void move() {
    System.out.println("Moving ...");
  }
}

所有具体的命令类都实现了Command接口:

public interface Command {
  public void execute();
}

public class DeleteCommand implements Command {

  private final IODevice action;

  public DeleteCommand(IODevice action) {
    this.action = action;
  }

  @Override
  public void execute() {
    action.delete()
  }
}

同样,为了简洁起见,我们实现了CopyCommandMoveCommand,并跳过了它们。

此外,Sequence类充当调用方类。调用方知道如何执行给定的命令,但对命令的实现没有任何线索(它只知道命令的接口)。在这里,我们将命令记录在一个List中,并在调用runSequence()方法时批量执行这些命令:

public class Sequence {

  private final List<Command> commands = new ArrayList<>();

  public void recordSequence(Command cmd) {
    commands.add(cmd);
  }

  public void runSequence() {
    commands.forEach(Command::execute);
  }

  public void clearSequence() {
    commands.clear();
  }
}

现在,让我们看看它的工作。让我们在HardDisk上执行一批操作:

HardDisk hd = new HardDisk();
Sequence sequence = new Sequence();
sequence.recordSequence(new CopyCommand(hd));
sequence.recordSequence(new DeleteCommand(hd));
sequence.recordSequence(new MoveCommand(hd));
sequence.recordSequence(new DeleteCommand(hd));
sequence.runSequence();

显然,我们这里有很多样板代码。查看命令类。我们真的需要所有这些类吗?好吧,如果我们意识到Command接口实际上是一个函数式接口,那么我们可以删除它的实现并通过 Lambda 提供行为(命令类只是行为块,因此它们可以通过 Lambda 表示),如下所示:

HardDisk hd = new HardDisk();
Sequence sequence = new Sequence();
sequence.recordSequence(hd::copy);
sequence.recordSequence(hd::delete);
sequence.recordSequence(hd::move);
sequence.recordSequence(hd::delete);
sequence.runSequence();

总结

我们现在已经到了这一章的结尾。使用 Lambda 来减少甚至消除样板代码是一种技术,也可以用于其他设计模式和场景。拥有迄今为止积累的知识应该为你相应地调整案例提供坚实的基础。

从本章下载应用以查看结果和其他详细信息。******

九、函数式编程——深入研究

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括 22 个涉及 Java 函数式编程的问题。这里,我们将重点讨论在流中遇到的涉及经典操作的几个问题(例如,filtermap),并讨论无限流、空安全流和缺省方法。这个问题的综合列表将涵盖分组、分区和收集器,包括 JDK12teeing()收集器和编写自定义收集器。此外,还将讨论takeWhile()dropWhile()、组合函数、谓词和比较器、Lambda 测试和调试以及其他一些很酷的话题。

一旦您涵盖了本章和上一章,您就可以在生产应用上释放函数式编程了。下面的问题将为您准备各种各样的用例,包括角落用例或陷阱。

问题

使用以下问题来测试您的函数式编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 测试高阶函数:编写几个单元测试来测试所谓的高阶函数。
  2. 使用 Lambda 的测试方法:为使用 Lambda 的测试方法编写几个单元测试。
  3. 调试 Lambda:提供一种调试 Lambda 的技术。
  4. 过滤流中的非零元素:编写流管道,过滤流中的非零元素。
  5. 无限流的takeWhile()dropWhile():编写几个处理无限流的代码片段。另外,写几个使用takeWhile()dropWhile()API 的例子。
  6. 流的映射:写几个通过map()flatMap()映射流的例子。
  7. 查找流中的不同元素:编写查找流中不同元素的程序。
  8. 匹配流中不同元素:编写一个匹配流中不同元素的程序。
  9. 流的总和、最大、最小:通过StreamStream.reduce()的原始类型特化编写计算给定流的总和、最大、最小的程序。
  10. 收集流的结果:编写一些代码片段,用于收集列表、映射和集合中的流的结果。
  11. 连接流的结果:写几个代码片段,将流的结果连接到String中。
  12. 摘要收集器:写几个代码片段来展示摘要收集器的用法。
  13. 分组:编写用于处理groupingBy()收集器的代码片段。
  14. 分区:编写几个代码片段,用于使用partitioningBy()收集器。
  15. 过滤、展开和映射收集器:编写几段代码,举例说明过滤、展开和映射收集器的用法。
  16. Teeing:编写几个合并两个收集器(JDK12 和Collectors.teeing()的结果的示例。
  17. 编写自定义收集器:编写一个表示自定义收集器的程序。
  18. 方法引用:写一个方法引用的例子。
  19. 流的并行处理:简要介绍流的并行处理。分别为parallelStream()parallel()spliterator()提供至少一个示例。
  20. 空安全流:编写一个程序,从元素或元素集合返回空安全流。
  21. 组合函数、谓词和比较器:编写几个组合函数、谓词和比较器的示例。
  22. 默认方法:编写一个包含default方法的接口。

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释只包括解决问题所需的最有趣和最重要的细节。您可以从下载示例解决方案以查看更多详细信息并尝试程序

177 测试高阶函数

高阶函数是用来描述返回函数或将函数作为参数的函数的术语。

基于此语句,在 Lambda 上下文中测试高阶函数应包括两种主要情况:

  • 测试以 Lambda 作为参数的方法
  • 测试返回函数式接口的方法

我们将在接下来的部分中了解这两个测试。

测试以 Lambda 作为参数的方法

将 Lambda 作为参数的方法的测试可以通过向该方法传递不同的 Lambda 来完成。例如,假设我们有以下函数式接口:

@FunctionalInterface
public interface Replacer<String> {
  String replace(String s);
}

我们还假设我们有一个方法,该方法接受String -> String类型的 Lambda,如下所示:

public static List<String> replace(
    List<String> list, Replacer<String> r) {

  List<String> result = new ArrayList<>();
  for (String s: list) {
    result.add(r.replace(s));
  }

  return result;
}

现在,让我们使用两个 Lambda 为这个方法编写一个 JUnit 测试:

@Test
public void testReplacer() throws Exception {

  List<String> names = Arrays.asList(
    "Ann a 15", "Mir el 28", "D oru 33");

  List<String> resultWs = replace(
    names, (String s) -> s.replaceAll("\\s", ""));
  List<String> resultNr = replace(
    names, (String s) -> s.replaceAll("\\d", ""));

  assertEquals(Arrays.asList(
    "Anna15", "Mirel28", "Doru33"), resultWs);
  assertEquals(Arrays.asList(
    "Ann a ", "Mir el ", "D oru "), resultNr);
}

测试返回函数式接口的方法

另一方面,测试返回函数式接口的方法可以解释为测试该函数式接口的行为。让我们考虑以下方法:

public static Function<String, String> reduceStrings(
    Function<String, String> ...functions) {

  Function<String, String> function = Stream.of(functions)
    .reduce(Function.identity(), Function::andThen);

  return function;
}

现在,我们可以测试返回的Function<String, String>的行为,如下所示:

@Test
public void testReduceStrings() throws Exception {

  Function<String, String> f1 = (String s) -> s.toUpperCase();
  Function<String, String> f2 = (String s) -> s.concat(" DONE");

  Function<String, String> f = reduceStrings(f1, f2);

  assertEquals("TEST DONE", f.apply("test"));
}

178 测试使用 Lambda 的方法

让我们从测试一个没有包装在方法中的 Lambda 开始。例如,以下 Lambda 与一个字段关联(用于重用),我们要测试其逻辑:

public static final Function<String, String> firstAndLastChar
  = (String s) -> String.valueOf(s.charAt(0))
    + String.valueOf(s.charAt(s.length() - 1));

让我们考虑到 Lambda 生成函数式接口实例;然后,我们可以测试该实例的行为,如下所示:

@Test
public void testFirstAndLastChar() throws Exception {

  String text = "Lambda";
  String result = firstAndLastChar.apply(text);
  assertEquals("La", result);
}

另一种解决方案是将 Lambda 包装在方法调用中,并为方法调用编写单元测试。

通常,Lambda 用于方法内部。对于大多数情况,测试包含 Lambda 的方法是可以接受的,但是在有些情况下,我们需要测试 Lambda 本身。这个问题的解决方案包括三个主要步骤:

  1. static方法提取 Lambda
  2. 方法引用替换 Lambda
  3. 测试这个static方法

例如,让我们考虑以下方法:

public List<String> rndStringFromStrings(List<String> strs) {

  return strs.stream()
    .map(str -> {
      Random rnd = new Random();
      int nr = rnd.nextInt(str.length());
      String ch = String.valueOf(str.charAt(nr));

      return ch;
    })
    .collect(Collectors.toList());
}

我们的目标是通过此方法测试 Lambda:

str -> {
  Random rnd = new Random();
  int nr = rnd.nextInt(str.length());
  String ch = String.valueOf(str.charAt(nr));

  return ch;
})

那么,让我们应用前面的三个步骤:

  1. 让我们用static方法提取这个 Lambda:
public static String extractCharacter(String str) {

  Random rnd = new Random();
  int nr = rnd.nextInt(str.length());
  String chAsStr = String.valueOf(str.charAt(nr));

  return chAsStr;
}
  1. 让我们用相应的方法引用替换 Lambda:
public List<String> rndStringFromStrings(List<String> strs) {

  return strs.stream()
    .map(StringOperations::extractCharacter)
    .collect(Collectors.toList());
}
  1. 让我们测试一下static方法(即 Lambda):
@Test
public void testRndStringFromStrings() throws Exception {

  String str1 = "Some";
  String str2 = "random";
  String str3 = "text";

  String result1 = extractCharacter(str1);
  String result2 = extractCharacter(str2);
  String result3 = extractCharacter(str3);

  assertEquals(result1.length(), 1);
  assertEquals(result2.length(), 1);
  assertEquals(result3.length(), 1);
  assertThat(str1, containsString(result1));
  assertThat(str2, containsString(result2));
  assertThat(str3, containsString(result3));
}

建议避免使用具有多行代码的 Lambda。因此,通过遵循前面的技术,Lambda 变得易于测试。

179 调试 Lambda

在调试 Lambda 时,至少有三种解决方案:

  • 检查栈跟踪
  • 日志
  • 依赖 IDE 支持(例如,NetBeans、Eclipse 和 IntelliJ IDEA 支持调试 Lambda,开箱即用或为其提供插件)

让我们把重点放在前两个方面,因为依赖 IDE 是一个非常大和具体的主题,不在本书的范围内。

检查 Lambda 或流管道中发生的故障的栈跟踪可能非常令人费解。让我们考虑以下代码片段:

List<String> names = Arrays.asList("anna", "bob", null, "mary");

names.stream()
  .map(s -> s.toUpperCase())
  .collect(Collectors.toList());

因为这个列表中的第三个元素是null,所以我们将得到一个NullPointerException,并且定义流管道的整个调用序列都被公开,如下面的屏幕截图所示:

突出显示的行告诉我们这个NullPointerException发生在一个名为lambda$main$5的 Lambda 表达式中。由于 Lambda 没有名称,因此此名称是由编译器编写的。此外,我们不知道哪个元素是null

因此,我们可以得出结论,报告 Lambda 或流管道内部故障的栈跟踪不是很直观。

或者,我们可以尝试记录输出。这将帮助我们调试流中的操作管道。这可以通过forEach()方法实现:

List<String> list = List.of("anna", "bob",
  "christian", "carmen", "rick", "carla");

list.stream()
  .filter(s -> s.startsWith("c"))
  .map(String::toUpperCase)
  .sorted()
  .forEach(System.out::println);

这将为我们提供以下输出:

CARLA
CARMEN
CHRISTIAN

在某些情况下,这种技术可能很有用。当然,我们必须记住,forEach()是一个终端操作,因此流将被消耗。因为一个流只能被消费一次,所以这可能是一个问题。

而且,如果我们在列表中添加一个null值,那么输出将再次变得混乱。

一个更好的选择是依靠peek()方法。这是一个中间操作,它对当前元素执行某个操作,并将该元素转发到管道中的下一个操作。下图显示了工作中的peek()操作:

让我们看看代码形式:

System.out.println("After:");

names.stream()
  .peek(p -> System.out.println("\tstream(): " + p))
  .filter(s -> s.startsWith("c"))
  .peek(p -> System.out.println("\tfilter(): " + p))
  .map(String::toUpperCase)
  .peek(p -> System.out.println("\tmap(): " + p))
  .sorted()
  .peek(p -> System.out.println("\tsorted(): " + p))
  .collect(Collectors.toList());

以下是我们可能收到的输出示例:

现在,我们故意在列表中添加一个null值,然后再次运行:

List<String> names = Arrays.asList("anna", "bob", 
  "christian", null, "carmen", "rick", "carla");

在向列表中添加一个null值后获得以下输出:

这一次,我们可以看到在应用了stream()之后出现了null值。因为stream()是第一个操作,所以我们可以很容易地发现错误存在于列表内容中。

180 过滤流中的非零元素

在第 8 章、“函数式编程——基础与设计模式”中,在“编写函数式接口”部分,我们定义了一个基于函数式接口Predicatefilter()方法。Java 流 API 已经有了这样的方法,函数式接口称为java.util.function.Predicate

假设我们有以下List个整数:

List<Integer> ints = Arrays.asList(1, 2, -4, 0, 2, 0, -1, 14, 0, -1);

流式传输此列表并仅提取非零元素可以按如下方式完成:

List<Integer> result = ints.stream()
  .filter(i -> i != 0)
  .collect(Collectors.toList());

结果列表将包含以下元素:1、2、-4、2、-1、14、-1

下图显示了filter()如何在内部工作:

注意,对于几个常见的操作,Java 流 API 已经提供了现成的中间操作。因此,不需要提供Predicate。其中一些操作如下:

  • distinct():从流中删除重复项
  • skip(n):丢弃前n个元素
  • limit(s):截断流长度不超过s
  • sorted():根据自然顺序对河流进行排序
  • sorted(Comparator<? super T> comparator):根据给定的Comparator对流进行排序

让我们将这些操作和一个filter()添加到一个示例中。我们将过滤零,过滤重复项,跳过 1 个值,将剩余的流截断为两个元素,并按其自然顺序排序:

List<Integer> result = ints.stream()
  .filter(i -> i != 0)
  .distinct()
  .skip(1)
  .limit(2)
  .sorted()
  .collect(Collectors.toList());

结果列表将包含以下两个元素:-42

下图显示了此流管道如何在内部工作:

filter()操作需要复杂/复合或长期条件时,建议采用辅助static方法提取,并依赖方法引用。因此,避免这样的事情:

List<Integer> result = ints.stream()
  .filter(value -> value > 0 && value < 10 && value % 2 == 0)
  .collect(Collectors.toList());

您应该更喜欢这样的内容(Numbers是包含辅助方法的类):

List<Integer> result = ints.stream()
  .filter(Numbers::evenBetween0And10)
  .collect(Collectors.toList());

private static boolean evenBetween0And10(int value) {
  return value > 0 && value < 10 && value % 2 == 0;
}

181 无限流、takeWhile()dropWhile()

在这个问题的第一部分,我们将讨论无限流。在第二部分中,我们将讨论takeWhile()dropWhile()api。

无限流是无限期地创建数据的流。因为流是懒惰的,它们可以是无限的。更准确地说,创建无限流是作为中间操作完成的,因此在执行管道的终端操作之前,不会创建任何数据。

例如,下面的代码理论上将永远运行。此行为由forEach()终端操作触发,并由缺少约束或限制引起:

Stream.iterate(1, i -> i + 1)
  .forEach(System.out::println);

Java 流 API 允许我们以多种方式创建和操作无限流,您很快就会看到。

此外,根据定义的相遇顺序,可以有序无序。流是否有相遇顺序取决于数据源和中间操作。例如,StreamList作为其源,因为List具有内在顺序,所以对其进行排序。另一方面,StreamSet作为其来源是无序的,因为Set不保证有序。一些中间操作(例如,sorted())可以向无序的Stream施加命令,而一些终端操作(例如,forEach())可以忽略遭遇命令。

通常,顺序流的性能不受排序的显著影响,但是取决于所应用的操作,并行流的性能可能会受到顺序Stream的存在的显著影响。

不要把Collection.stream().forEach()Collection.forEach()混为一谈。虽然Collection.forEach()可以依靠集合的迭代器(如果有的话)来保持顺序,Collection.stream().forEach()的顺序没有定义。例如,通过list.forEach()多次迭代List将按插入顺序处理元素,而list.parallelStream().forEach()在每次运行时产生不同的结果。根据经验,如果不需要流,则通过Collection.forEach()对集合进行迭代。

我们可以通过BaseStream.unordered()将有序流转化为无序流,如下例所示:

List<Integer> list 
  = Arrays.asList(1, 4, 20, 15, 2, 17, 5, 22, 31, 16);

Stream<Integer> unorderedStream = list.stream()
  .unordered();

无限有序流

通过Stream.iterate​(T seed, UnaryOperator<T> f)可以得到无限的有序流。结果流从指定的种子开始,并通过将f函数应用于前一个元素(例如,n元素是f(n-1)来继续)。

例如,类型 1、2、3、…、n 的整数流可以如下创建:

Stream<Integer> infStream = Stream.iterate(1, i -> i + 1);

此外,我们可以将此流用于各种目的。例如,让我们使用它来获取前 10 个偶数整数的列表:

List<Integer> result = infStream
  .filter(i -> i % 2 == 0)
  .limit(10)
  .collect(Collectors.toList());

List内容如下(注意无限流将创建元素 1、2、3、…、20,但只有以下元素与我们的过滤器匹配,直到达到 10 个元素的限制):

2, 4, 6, 8, 10, 12, 14, 16, 18, 20

注意limit()中间操作的存在。它的存在是强制的;否则,代码将无限期运行。我们必须显式地丢弃流;换句话说,我们必须显式地指定在最终列表中应该收集多少与我们的过滤器匹配的元素。一旦达到极限,无限流就会被丢弃。

但是假设我们不想要前 10 个偶数整数的列表,实际上我们希望直到 10(或任何其他限制)的偶数的列表。从 JDK9 开始,我们可以通过一种新的味道Stream.iterate()来塑造这种行为。这种味道让我们可以直接将hasNext谓词嵌入流声明(iterate​(T seed, Predicate<? super T> hasNext, UnaryOperator<T> next)。当hasNext谓词返回false后,流即终止:

Stream<Integer> infStream = Stream.iterate(
  1, i -> i <= 10, i -> i + 1);

这一次,我们可以删除limit()中间操作,因为我们的hasNext谓词施加了 10 个元素的限制:

List<Integer> result = infStream
  .filter(i -> i % 2 == 0)
  .collect(Collectors.toList());

结果List如下(与我们的hasNext谓词一致,无限流创建元素 1、2、3、…、10,但只有以下五个元素与我们的流过滤器匹配):

2, 4, 6, 8, 10

当然,我们可以将Stream.iterate()limit()的味道结合起来形成更复杂的场景。例如,下面的流将创建新元素,直到下一个谓词i -> i <= 10。因为我们使用的是随机值,hasNext谓词返回false的时刻是不确定的:

Stream<Integer> infStream = Stream.iterate(
  1, i -> i <= 10, i -> i + i % 2 == 0 
    ? new Random().nextInt(20) : -1 * new Random().nextInt(10));

此流的一个可能输出如下:

1, -5, -4, -7, -4, -2, -8, -8, ..., 3, 0, 4, -7, -6, 10, ...

现在,下面的管道将收集最多 25 个通过infStream创建的数字:

List<Integer> result = infStream
  .limit(25)
  .collect(Collectors.toList());

现在,无限流可以从两个地方丢弃。如果hasNext谓词返回false,直到我们收集了 25 个元素,那么此时我们仍然保留收集的元素(少于 25 个)。如果直到我们收集了 25 个元素,hasNext谓词才返回false,那么limit()操作将丢弃流的其余部分。

无限伪随机值流

如果我们想要创建无限的伪随机值流,我们可以依赖于Random的方法,例如ints()longs()doubles()。例如,伪随机整数值的无限流可以声明如下(生成的整数将在[1100]范围内):

IntStream rndInfStream = new Random().ints(1, 100);

尝试获取 10 个偶数伪随机整数值的列表可以依赖于此流:

List<Integer> result = rndInfStream
  .filter(i -> i % 2 == 0)
  .limit(10)
  .boxed()
  .collect(Collectors.toList());

一种可能的输出如下:

8, 24, 82, 42, 90, 18, 26, 96, 86, 86

这一次,在收集到上述列表之前,很难说实际生成了多少个数字。

ints()的另一种味道是ints​(long streamSize, int randomNumberOrigin, int randomNumberBound)。第一个参数允许我们指定应该生成多少伪随机值。例如,下面的流将在[1100]范围内正好生成 10 个值:

IntStream rndInfStream = new Random().ints(10, 1, 100);

我们可以从这 10 中取偶数值,如下所示:

List<Integer> result = rndInfStream
  .filter(i -> i % 2 == 0)
  .boxed()
  .collect(Collectors.toList());

一种可能的输出如下:

80, 28, 60, 54

我们可以使用此示例作为生成固定长度随机字符串的基础,如下所示:

IntStream rndInfStream = new Random().ints(20, 48, 126);
String result = rndInfStream
  .mapToObj(n -> String.valueOf((char) n))
  .collect(Collectors.joining());

一种可能的输出如下:

AIW?F1obl3KPKMItqY8>

Stream.ints() comes with two more flavors: one that doesn't take any argument (an unlimited stream of integers) and another that takes a single argument representing the number of values that should be generated, that is, ints​(long streamSize).

无限连续无序流

为了创建一个无限连续的无序流,我们可以依赖于Stream.generate​(Supplier<? extends T> s)。在这种情况下,每个元素由提供的Supplier生成。这适用于生成恒定流、随机元素流等。

例如,假设我们有一个简单的助手,它生成八个字符的密码:

private static String randomPassword() {

  String chars = "abcd0123!@#$";

  return new SecureRandom().ints(8, 0, chars.length())
    .mapToObj(i -> String.valueOf(chars.charAt(i)))
    .collect(Collectors.joining());
}

此外,我们要定义一个无限顺序无序流,它返回随机密码(Main是包含前面助手的类):

Supplier<String> passwordSupplier = Main::randomPassword;
Stream<String> passwordStream = Stream.generate(passwordSupplier);

此时,passwordStream可以无限期地创建密码。但是让我们创建 10 个这样的密码:

List<String> result = passwordStream
  .limit(10)
  .collect(Collectors.toList());

一种可能的输出如下:

213c1b1c, 2badc$21, d33321d$, @a0dc323, 3!1aa!dc, 0a3##@3!, $!b2#1d@, 0@0#dd$#, cb$12d2@, d2@@cc@d

谓词返回true时执行

从 JDK9 开始,添加到Stream类中最有用的方法之一是takeWhile​(Predicate<? super T> predicate)。此方法具有两种不同的行为,如下所示:

  • 如果流是有序的,它将返回一个流,该流包含从该流中获取的、与给定谓词匹配的元素的最长前缀。
  • 如果流是无序的,并且此流的某些(但不是全部)元素与给定谓词匹配,则此操作的行为是不确定的;它可以自由获取匹配元素的任何子集(包括空集)。

对于有序的Stream,元素的最长前缀是流中与给定谓词匹配的连续元素序列。

注意,takeWhile()将在给定谓词返回false后丢弃剩余的流。

例如,获取 10 个整数的列表可以按如下方式进行:

List<Integer> result = IntStream
  .iterate(1, i -> i + 1)
  .takeWhile(i -> i <= 10)
  .boxed()
  .collect(Collectors.toList());

这将为我们提供以下输出:

1, 2, 3, 4, 5, 6, 7, 8, 9, 10

或者,我们可以获取随机偶数整数的List,直到第一个生成的值小于 50:

List<Integer> result = new Random().ints(1, 100)
  .filter(i -> i % 2 == 0)
  .takeWhile(i -> i >= 50)
  .boxed()
  .collect(Collectors.toList());

我们甚至可以连接takeWhile()中的谓词:

List<Integer> result = new Random().ints(1, 100)
  .takeWhile(i -> i % 2 == 0 && i >= 50)
  .boxed()
  .collect(Collectors.toList());

一个可能的输出可以如下获得(也可以为空):

64, 76, 54, 68

在第一个生成的密码不包含!字符之前,取一个随机密码的List怎么样?

根据前面列出的助手,我们可以这样做:

List<String> result = Stream.generate(Main::randomPassword)
  .takeWhile(s -> s.contains("!"))
  .collect(Collectors.toList());

一个可能的输出可以如下获得(也可以为空):

0!dac!3c, 2!$!b2ac, 1d12ba1!

现在,假设我们有一个无序的整数流。以下代码片段采用小于或等于 10 的元素子集:

Set<Integer> setOfInts = new HashSet<>(
  Arrays.asList(1, 4, 3, 52, 9, 40, 5, 2, 31, 8));

List<Integer> result = setOfInts.stream()
  .takeWhile(i -> i<= 10)
  .collect(Collectors.toList());

一种可能的输出如下(请记住,对于无序流,结果是不确定的):

1, 3, 4

谓词返回true时删除

从 JDK9 开始,我们还有Stream.dropWhile​(Predicate<? super T> predicate)方法。此方法与takeWhile()相反。此方法不在给定谓词返回false之前获取元素,而是在给定元素返回false之前删除元素,并在返回流中包含其余元素:

  • 如果流是有序的,则在删除与给定谓词匹配的元素的最长前缀之后,它返回一个由该流的其余元素组成的流。
  • 如果流是无序的,并且此流的某些(但不是全部)元素与给定谓词匹配,则此操作的行为是不确定的;可以随意删除匹配元素的任何子集(包括空集)。

对于有序的Stream,元素的最长前缀是流中与给定谓词匹配的连续元素序列。

例如,让我们在删除前 10 个整数后收集 5 个整数:

List<Integer> result = IntStream
  .iterate(1, i -> i + 1)
  .dropWhile(i -> i <= 10)
  .limit(5)
  .boxed()
  .collect(Collectors.toList());

这将始终提供以下输出:

11, 12, 13, 14, 15

或者,我们可以获取五个大于 50 的随机偶数整数的List(至少,这是我们认为代码所做的):

List<Integer> result = new Random().ints(1, 100)
  .filter(i -> i % 2 == 0)
  .dropWhile(i -> i < 50)
  .limit(5)
  .boxed()
  .collect(Collectors.toList());

一种可能的输出如下:

78, 16, 4, 94, 26

但为什么是 16 和 4 呢?它们是偶数,但不超过 50!它们之所以存在,是因为它们位于第一个元素之后,而第一个元素没有通过谓词。主要是当值小于 50(dropWhile(i -> i < 50)时,我们会降低值。78 值将使该谓词失败,因此dropWhile结束其作业。此外,所有生成的元素都包含在结果中,直到limit(5)采取行动。

让我们看看另一个类似的陷阱。让我们获取一个由五个随机密码组成的List,其中包含!字符(至少,我们可能认为代码就是这样做的):

List<String> result = Stream.generate(Main::randomPassword)
  .dropWhile(s -> !s.contains("!"))
  .limit(5)
  .collect(Collectors.toList());

一种可能的输出如下:

bab2!3dd, c2@$1acc, $c1c@cb@, !b21$cdc, #b103c21

同样,我们可以看到不包含!字符的密码。bab2!3dd密码将使我们的谓词失败,并最终得到最终结果(List。生成的四个密码被添加到结果中,而不受dropWhile()的影响。

现在,假设我们有一个无序的整数流。以下代码片段将删除小于或等于 10 的元素子集,并保留其余元素:

Set<Integer> setOfInts = new HashSet<>(
  Arrays.asList(5, 42, 3, 2, 11, 1, 6, 55, 9, 7));

List<Integer> result = setOfInts.stream()
  .dropWhile(i -> i <= 10)
  .collect(Collectors.toList());

一种可能的输出如下(请记住,对于无序流,结果是不确定的):

55, 7, 9, 42, 11

如果所有元素都匹配给定的谓词,那么takeWhile()接受并dropWhile()删除所有元素(不管流是有序的还是无序的)。另一方面,如果没有一个元素与给定的谓词匹配,那么takeWhile()什么也不取(返回一个空流)dropWhile()什么也不掉(返回流)。

避免在并行流的上下文中使用take/dropWhile(),因为它们是昂贵的操作,特别是对于有序流。如果适合这种情况,那么只需通过BaseStream.unordered()移除排序约束即可。

182 映射流的元素

映射一个流的元素是一个中间操作,用于将这些元素转换成一个新的版本,方法是将给定的函数应用于每个元素,并将结果累加到一个新的Stream(例如,将Stream<String>转换成Stream<Integer>,或将Stream<String>转换成另一个Stream<String>等)。

使用Stream.map()

基本上,我们调用Stream.map​(Function<? super T,​? extends R> mapper)对流的每个元素应用mapper函数。结果是一个新的Stream。不修改源Stream

假设我们有以下Melon类:

public class Melon {

  private String type;
  private int weight;

  // constructors, getters, setters, equals(),
  // hashCode(), toString() omitted for brevity
}

我们还需要假设我们有List<Melon>

List<Melon> melons = Arrays.asList(new Melon("Gac", 2000),
  new Melon("Hemi", 1600), new Melon("Gac", 3000),
  new Melon("Apollo", 2000), new Melon("Horned", 1700));

此外,我们只想提取另一个列表中的瓜名,List<String>

对于这个任务,我们可以依赖于map(),如下所示:

List<String> melonNames = melons.stream()
  .map(Melon::getType)
  .collect(Collectors.toList());

输出将包含以下类型的瓜:

Gac, Hemi, Gac, Apollo, Horned

下图描述了map()在本例中的工作方式:

因此,map()方法得到一个Stream<Melon>,并输出一个Stream<String>。每个Melon经过map()方法,该方法提取瓜的类型(即一个String),并存储在另一个Stream中。

同样,我们可以提取西瓜的重量。由于权重是整数,map()方法将返回一个Stream<Integer>

List<Integer> melonWeights = melons.stream()
  .map(Melon::getWeight)
  .collect(Collectors.toList());

输出将包含以下权重:

2000, 1600, 3000, 2000, 1700

map()之外,Stream类还为mapToInt()mapToLong()mapToDouble()等原始类型提供口味。这些方法返回StreamIntStream)的int原初特化、StreamLongStream)的long原初特化和StreamStreamDouble)的double原初特化。

虽然map()可以通过FunctionStream的元素映射到新的Stream,但不能得出这样的结论:

List<Melon> lighterMelons = melons.stream()
  .map(m -> m.setWeight(m.getWeight() - 500))
  .collect(Collectors.toList());

这将无法工作/编译,因为setWeight()方法返回void。为了使它工作,我们需要返回Melon,但这意味着我们必须添加一些敷衍代码(例如,return):

List<Melon> lighterMelons = melons.stream()
  .map(m -> {
    m.setWeight(m.getWeight() - 500);

    return m;
  })
  .collect(Collectors.toList());

你觉得诱惑怎么样?好吧,peek()代表看但不要碰,但它可以用来改变状态,如下所示:

List<Melon> lighterMelons = melons.stream()
  .peek(m -> m.setWeight(m.getWeight() - 500))
  .collect(Collectors.toList());

输出将包含以下西瓜(这看起来很好):

Gac(1500g), Hemi(1100g), Gac(2500g), Apollo(1500g), Horned(1200g)

这比使用map()更清楚。调用setWeight()是一个明确的信号,表明我们计划改变状态,但是文档中指定传递给peek()Consumer应该是一个非干扰动作(不修改流的数据源)。

对于连续流(如前一个流),打破这一预期可以得到控制,而不会产生副作用;然而,对于并行流管道,问题可能会变得更复杂。

可以在上游操作使元素可用的任何时间和线程中调用该操作,因此如果该操作修改共享状态,它将负责提供所需的同步。

根据经验,在使用peek()改变状态之前,要三思而后行。另外,请注意,这种做法是一种辩论,属于不良做法,甚至反模式的保护伞。

使用Stream.flatMap()

正如我们刚才看到的,map()知道如何在Stream中包装一系列元素。

这意味着map()可以产生诸如Stream<String[]>Stream<List<String>>Stream<Set<String>>甚至Stream<Stream<R>>的流。

但问题是,这些类型的流不能被成功地操作(或者,正如我们所预期的那样),比如流操作,比如sum()distinct()filter()等等。

例如,让我们考虑下面的Melon数组:

Melon[][] melonsArray = {
  {new Melon("Gac", 2000), new Melon("Hemi", 1600)}, 
  {new Melon("Gac", 2000), new Melon("Apollo", 2000)}, 
  {new Melon("Horned", 1700), new Melon("Hemi", 1600)}
};

我们可以通过Arrays.stream()将这个数组包装成一个流,如下代码片段所示:

Stream<Melon[]> streamOfMelonsArray = Arrays.stream(melonsArray);

有许多其他方法可以获得数组的Stream。例如,如果我们有一个字符串,s,那么map(s -> s.split(""))将返回一个Stream<String[]>

现在,我们可以认为,获得不同的Melon实例就足够调用distinct(),如下所示:

streamOfMelonsArray
  .distinct()
  .collect(Collectors.toList());

但这是行不通的,因为distinct()不会寻找一个不同的Melon;相反,它会寻找一个不同的数组Melon[],因为这是我们在流中拥有的。

此外,在本例中返回的结果是Stream<Melon[]>类型,而不是Stream<Melon>类型。最终结果将在List<Melon[]>中收集Stream<Melon[]>

我们怎样才能解决这个问题?

我们可以考虑应用Arrays.stream()Melon[]转换为Stream<Melon>

streamOfMelonsArray
  .map(Arrays::stream) // Stream<Stream<Melon>>
  .distinct()
  .collect(Collectors.toList());

再说一遍,map()不会做我们认为它会做的事。

首先,调用Arrays.stream()将从每个给定的Melon[]返回一个Stream<Melon>。但是,map()返回元素的Stream,因此它将把应用Arrays.stream()的结果包装成Stream。它将在Stream<Stream<Melon>>中结束。

所以,这一次,distinct()试图检测不同的Stream<Melon>元素:

为了解决这个问题,我们必须依赖于flatMap(),下图描述了flatMap()是如何在内部工作的:

map()不同,该方法通过展开所有分离的流来返回流。因此,所有数组都将在同一个流中结束:

streamOfMelonsArray
  .flatMap(Arrays::stream) // Stream<Melon>
  .distinct()
  .collect(Collectors.toList());

根据Melon.equals()实现,输出将包含不同的瓜:

Gac(2000g), Hemi(1600g), Apollo(2000g), Horned(1700g)

现在,让我们尝试另一个问题,从一个List<List<String>>开始,如下所示:

List<List<String>> melonLists = Arrays.asList(
  Arrays.asList("Gac", "Cantaloupe"),
  Arrays.asList("Hemi", "Gac", "Apollo"),
  Arrays.asList("Gac", "Hemi", "Cantaloupe"),
  Arrays.asList("Apollo"),
  Arrays.asList("Horned", "Hemi"),
  Arrays.asList("Hemi"));

我们试图从这张单子上找出不同的瓜名。如果可以通过Arrays.stream()将数组包装成流,那么对于集合,我们有Collection.stream()。因此,第一次尝试可能如下所示:

melonLists.stream()
  .map(Collection::stream)
  .distinct();

但是基于前面的问题,我们已经知道这将不起作用,因为map()将返回Stream<Stream<String>>

flatMap()提供的解决方案如下:

List<String> distinctNames = melonLists.stream()
  .flatMap(Collection::stream)
  .distinct()
  .collect(Collectors.toList());

输出如下:

Gac, Cantaloupe, Hemi, Apollo, Horned

flatMap()之外,Stream类还为flatMapToInt()flatMapToLong()flatMapToDouble()等原始类型提供口味。这些方法返回StreamIntStream)的int原特化、StreamLongStream)的long原特化和StreamStreamDoubledouble原特化。

183 在流中查找元素

除了使用filter()允许我们通过谓词过滤流中的元素外,我们还可以通过anyFirst()findFirst()在流中找到元素。

假设我们将以下列表包装在流中:

List<String> melons = Arrays.asList(
  "Gac", "Cantaloupe", "Hemi", "Gac", "Gac", 
    "Hemi", "Cantaloupe", "Horned", "Hemi", "Hemi");

findAny()

findAny()方法从流中返回任意(不确定)元素。例如,以下代码片段将返回前面列表中的元素:

Optional<String> anyMelon = melons.stream()
  .findAny();

if (!anyMelon.isEmpty()) {
  System.out.println("Any melon: " + anyMelon.get());
} else {
  System.out.println("No melon was found");
}

注意,不能保证每次执行时都返回相同的元素。这种说法是正确的,尤其是在并行流的情况下。

我们也可以将findAny()与其他操作结合起来。举个例子:

String anyApollo = melons.stream()
  .filter(m -> m.equals("Apollo"))
  .findAny()
  .orElse("nope");

这一次,结果将是nope。列表中没有Apollo,因此filter()操作将产生一个空流。此外,findAny()还将返回一个空流,因此orElse()将返回最终结果作为指定的字符串nope

findFirst()

如果findAny()返回任何元素,findFirst()返回流中的第一个元素。显然,当我们只对流的第一个元素感兴趣时(例如,竞赛的获胜者应该是竞争对手排序列表中的第一个元素),这种方法很有用。

然而,如果流没有相遇顺序,则可以返回任何元素。根据文档,流可能有也可能没有定义的相遇顺序。这取决于源和中间操作。同样的规则也适用于并行性。

现在,假设我们想要列表中的第一个瓜:

Optional<String> firstMelon = melons.stream()
  .findFirst();

if (!firstMelon.isEmpty()) {
  System.out.println("First melon: " + firstMelon.get());
} else {
  System.out.println("No melon was found");
}

输出如下:

First melon: Gac

我们也可以将findFirst()与其他操作结合起来。举个例子:

String firstApollo = melons.stream()
  .filter(m -> m.equals("Apollo"))
  .findFirst()
  .orElse("nope");

这一次,结果将是nope,因为filter()将产生一个空流。

下面是整数的另一个问题(只需按照右侧的注释快速发现流):

List<Integer> ints = Arrays.asList(4, 8, 4, 5, 5, 7);

int result = ints.stream()
  .map(x -> x * x - 1)     // 23, 63, 23, 24, 24, 48
  .filter(x -> x % 2 == 0) // 24, 24, 48
  .findFirst()             // 24
  .orElse(-1);

184 匹配流中的元素

为了匹配Stream中的某些元素,我们可以采用以下方法:

  • anyMatch()
  • noneMatch()
  • allMatch()

所有这些方法都以一个Predicate作为参数,并针对它获取一个boolean结果。

这三种操作依赖于短路技术。换句话说,在我们处理整个流之前,这些方法可能会返回。例如,如果allMatch()匹配false(将给定的Predicate求值为false,则没有理由继续。最终结果为false

假设我们将以下列表包装在流中:

List<String> melons = Arrays.asList(
  "Gac", "Cantaloupe", "Hemi", "Gac", "Gac", "Hemi", 
    "Cantaloupe", "Horned", "Hemi", "Hemi");

现在,让我们试着回答以下问题:

  • 元素是否与Gac字符串匹配?让我们看看下面的代码:
boolean isAnyGac = melons.stream()
  .anyMatch(m -> m.equals("Gac")); // true
  • 元素是否与Apollo字符串匹配?让我们看看下面的代码:
boolean isAnyApollo = melons.stream()
  .anyMatch(m -> m.equals("Apollo")); // false

作为一般性问题,流中是否有与给定谓词匹配的元素?

  • 没有与Gac字符串匹配的元素吗?让我们看看下面的代码:
boolean isNoneGac = melons.stream()
  .noneMatch(m -> m.equals("Gac")); // false
  • 没有与Apollo字符串匹配的元素吗?让我们看看下面的代码:
boolean isNoneApollo = melons.stream()
  .noneMatch(m -> m.equals("Apollo")); // true

一般来说,流中没有与给定谓词匹配的元素吗?

  • 所有元素都与Gac字符串匹配吗?让我们看看下面的代码:
boolean areAllGac = melons.stream()
  .allMatch(m -> m.equals("Gac")); // false
  • 所有元素都大于 2 吗?让我们看看下面的代码:
boolean areAllLargerThan2 = melons.stream()
  .allMatch(m -> m.length() > 2);

作为一般问题,流中的所有元素是否都与给定的谓词匹配?

185 流中的总和,最大和最小

假设我们有以下Melon类:

public class Melon {

  private String type;
  private int weight;

  // constructors, getters, setters, equals(),
  // hashCode(), toString() omitted for brevity
}

我们还假设在一个流中包装了下面的Melon列表:

List<Melon> melons = Arrays.asList(new Melon("Gac", 2000),
  new Melon("Hemi", 1600), new Melon("Gac", 3000),
  new Melon("Apollo", 2000), new Melon("Horned", 1700));

让我们使用sum()min()max()终端操作来处理Melon类。

sum()min()max()终端操作

现在,让我们结合此流的元素来表示以下查询:

  • 如何计算瓜的总重量(sum())?
  • 最重的瓜是什么?
  • 最轻的瓜是什么?

为了计算西瓜的总重量,我们需要把所有的重量加起来。对于StreamIntStreamLongStream等)的原始特化,Java 流 API 公开了一个名为sum()的终端操作。顾名思义,这个方法总结了流的元素:

int total = melons.stream()
  .mapToInt(Melon::getWeight)
  .sum();

sum()之后,我们还有max()min()终端操作。显然,max()返回流的最大值,min()则相反:

int max = melons.stream()
  .mapToInt(Melon::getWeight)
  .max()
  .orElse(-1);

int min = melons.stream()
  .mapToInt(Melon::getWeight)
  .min()
  .orElse(-1);

max()min()操作返回一个OptionalInt(例如OptionalLong)。如果无法计算最大值或最小值(例如,在空流的情况下),则我们选择返回-1。既然我们是在处理权值,以及正数的性质,返回-1是有意义的。但不要把这当成一个规则。根据情况,应该返回另一个值,或者使用orElseGet()/orElseThrow()更好。

对于非原始特化,请查看本章的“摘要收集器”部分。

让我们在下一节学习如何减少。

归约

sum()max()min()被称为归约的特例。我们所说的归约,是指基于两个主要语句的抽象:

  • 取初始值(T
  • 取一个BinaryOperator<T>将两个元素结合起来,产生一个新的值

缩减可以通过名为reduce()的终端操作来完成,该操作遵循此抽象并定义两个签名(第二个签名不使用初始值):

  • T reduce​(T identity, BinaryOperator<T> accumulator)
  • Optional<T> reduce​(BinaryOperator<T> accumulator)

也就是说,我们可以依赖于reduce()终端运算来计算元素的和,如下所示(初始值为 0,λ为(m1, m2) -> m1 + m2)):

int total = melons.stream()
  .map(Melon::getWeight)
  .reduce(0, (m1, m2) -> m1 + m2);

下图描述了reduce()操作的工作原理:

那么,reduce()操作是如何工作的呢?

让我们看一下以下步骤来解决这个问题:

  1. 首先,0 被用作 Lambda 的第一个参数(m1),2000 被从流中消耗并用作第二个参数(m2)0+2000产生 2000,这成为新的累计值。
  2. 然后,用累积值和流的下一个元素 1600 再次调用 Lambda,其产生新的累积值 3600。
  3. 向前看,Lambda 被再次调用,并使用累计值和下一个元素 3000 生成 6600。
  4. 如果我们再向前一步,Lambda 会被再次调用,并使用累计值和下一个元素 2000 生成 8600。
  5. 最后,用 8600 调用 Lambda,流的最后一个元素 1700 产生最终值 10300。

也可以计算最大值和最小值:

int max = melons.stream()
  .map(Melon::getWeight)
  .reduce(Integer::max)
  .orElse(-1);

int min = melons.stream()
  .map(Melon::getWeight)
  .reduce(Integer::min)
  .orElse(-1);

使用reduce()的优点是,我们可以通过简单地传递另一个 Lambda 来更改计算。例如,我们可以快速地用乘积替换总和,如下例所示:

List<Double> numbers = Arrays.asList(1.0d, 5.0d, 8.0d, 10.0d);

double total = numbers.stream()
  .reduce(1.0 d, (x1, x2) -> x1 * x2);

然而,要注意那些可能导致不想要的结果的案例。例如,如果我们要计算给定数字的调和平均数,那么就没有一个开箱即用的归约特例,因此我们只能依赖reduce(),如下所示:

List<Double> numbers = Arrays.asList(1.0d, 5.0d, 8.0d, 10.0d);

调和平均公式如下:

在我们的例子中,n是列表的大小,H是 2.80701。使用简单的reduce()函数将如下所示:

double hm = numbers.size() / numbers.stream()
  .reduce((x1, x2) -> (1.0d / x1 + 1.0d / x2))
  .orElseThrow();

这将产生 3.49809。

这个解释依赖于我们如何表达计算。在第一步中,我们计算1.0/1.0+1.0/5.0=1.2。那么,我们可以期望做1.2+1.0/1.8,但实际上,计算是1.0/1.2+1.0/1.8。显然,这不是我们想要的。

我们可以使用mapToDouble()来解决这个问题,如下所示:

double hm = numbers.size() / numbers.stream()
  .mapToDouble(x -> 1.0d / x)
  .reduce((x1, x2) -> (x1 + x2))
  .orElseThrow();

这将产生预期结果,即 2.80701。

186 收集流的结果

假设我们有以下Melon类:

public class Melon {

  private String type;
  private int weight;

  // constructors, getters, setters, equals(),
  // hashCode(), toString() omitted for brevity
}

我们还假设有MelonList

List<Melon> melons = Arrays.asList(new Melon("Crenshaw", 2000),
  new Melon("Hemi", 1600), new Melon("Gac", 3000),
  new Melon("Apollo", 2000), new Melon("Horned", 1700),
  new Melon("Gac", 3000), new Melon("Cantaloupe", 2600));

通常,流管道以流中元素的摘要结束。换句话说,我们需要在数据结构中收集结果,例如ListSetMap(以及它们的同伴)。

为了完成这项任务,我们可以依靠Stream.collect​(Collector<? super T,​A,​R> collector)方法。此方法获取一个表示java.util.stream.Collector或用户定义的Collector的参数。

最著名的收集器包括:

  • toList()
  • toSet()
  • toMap()
  • toCollection()

他们的名字不言自明。我们来看几个例子:

  • 过滤重量超过 1000g 的瓜,通过toList()toCollection()将结果收集到List中:
List<Integer> resultToList = melons.stream()
  .map(Melon::getWeight)
  .filter(x -> x >= 1000)
  .collect(Collectors.toList());

List<Integer> resultToList = melons.stream()
  .map(Melon::getWeight)
  .filter(x -> x >= 1000)
  .collect(Collectors.toCollection(ArrayList::new));

toCollection()方法的参数为Supplier,提供了一个新的空Collection,结果将插入其中。

  • 过滤重量超过 1000g 的瓜,通过toSet()toCollection()Set中收集无重复的结果:
Set<Integer> resultToSet = melons.stream()
  .map(Melon::getWeight)
  .filter(x -> x >= 1000)
  .collect(Collectors.toSet());

Set<Integer> resultToSet = melons.stream()
  .map(Melon::getWeight)
  .filter(x -> x >= 1000)
  .collect(Collectors.toCollection(HashSet::new));
  • 过滤重量超过 1000 克的瓜,收集无重复的结果,通过toCollection()Set升序排序:
Set<Integer> resultToSet = melons.stream()
  .map(Melon::getWeight)
  .filter(x -> x >= 1000)
  .collect(Collectors.toCollection(TreeSet::new));
  • 过滤不同的Melon,通过toMap()将结果收集到Map<String, Integer>中:
Map<String, Integer> resultToMap = melons.stream()
  .distinct()
  .collect(Collectors.toMap(Melon::getType, 
    Melon::getWeight));

toMap()方法的两个参数表示一个映射函数,用于生成键及其各自的值(如果两个Melon具有相同的键,则容易出现java.lang.IllegalStateException重复键异常)。

  • 过滤一个不同的Melon并使用随机键通过toMap()将结果收集到Map<Integer, Integer>中(如果生成两个相同的键,则容易产生java.lang.IllegalStateException重复键):
Map<Integer, Integer> resultToMap = melons.stream()
  .distinct()
  .map(x -> Map.entry(
    new Random().nextInt(Integer.MAX_VALUE), x.getWeight()))
  .collect(Collectors.toMap(Entry::getKey, Entry::getValue));
  • 通过toMap()采集映射中的Melon,并通过选择现有(旧)值避免可能的java.lang.IllegalStateException重复键,以防发生键冲突:
Map<String, Integer> resultToMap = melons.stream()
  .collect(Collectors.toMap(Melon::getType, Melon::getWeight,
    (oldValue, newValue) -> oldValue));

toMap()方法的最后一个参数是一个merge函数,用于解决提供给Map.merge(Object, Object, BiFunction)的与同一个键相关的值之间的冲突。

显然,可以通过(oldValue, newValue) -> newValue选择新值:

  • 将上述示例放入排序后的Map(例如,按重量):
Map<String, Integer> resultToMap = melons.stream()
 .sorted(Comparator.comparingInt(Melon::getWeight))
 .collect(Collectors.toMap(Melon::getType, Melon::getWeight,
   (oldValue, newValue) -> oldValue,
     LinkedHashMap::new));

这个toMap()风格的最后一个参数表示一个Supplier,它提供了一个新的空Map,结果将被插入其中。在本例中,需要这个Supplier来保存排序后的顺序。因为HashMap不能保证插入的顺序,所以我们需要依赖LinkedHashMap

  • 通过toMap()采集词频计数:
String str = "Lorem Ipsum is simply 
              Ipsum Lorem not simply Ipsum";

Map<String, Integer> mapOfWords = Stream.of(str)
  .map(w -> w.split("\\s+"))
  .flatMap(Arrays::stream)
  .collect(Collectors.toMap(
    w -> w.toLowerCase(), w -> 1, Integer::sum));

除了toList()toMap()toSet()之外,Collectors类还将收集器公开给不可修改的并发集合,例如toUnmodifiableList()toConcurrentMap()等等。

187 连接流的结果

假设我们有以下Melon类:

public class Melon {

  private String type;
  private int weight;

  // constructors, getters, setters, equals(),
  // hashCode(), toString() omitted for brevity
}

我们还假设有MelonList

List<Melon> melons = Arrays.asList(new Melon("Crenshaw", 2000),
  new Melon("Hemi", 1600), new Melon("Gac", 3000),
  new Melon("Apollo", 2000), new Melon("Horned", 1700),
  new Melon("Gac", 3000), new Melon("Cantaloupe", 2600));

在上一个问题中,我们讨论了内置于Collectors中的StreamAPI。在这个类别中,我们还有Collectors.joining()。这些收集器的目标是将流中的元素连接成一个按相遇顺序String。或者,这些收集器可以使用分隔符、前缀和后缀,因此最全面的joining()风格是String joining​(CharSequence delimiter, CharSequence prefix, CharSequence suffix)

但是,如果我们只想在不使用分隔符的情况下连接西瓜的名称,那么这就是一种方法(只是为了好玩,让我们排序并删除重复的名称):

String melonNames = melons.stream()
  .map(Melon::getType)
  .distinct()
  .sorted()
  .collect(Collectors.joining());

我们将收到以下输出:

ApolloCantaloupeCrenshawGacHemiHorned

更好的解决方案是添加分隔符,例如逗号和空格:

String melonNames = melons.stream()
  ...
  .collect(Collectors.joining(", "));

我们将收到以下输出:

Apollo, Cantaloupe, Crenshaw, Gac, Hemi, Horned

我们还可以使用前缀和后缀来丰富输出:

String melonNames = melons.stream()
  ...
  .collect(Collectors.joining(", ", 
    "Available melons: ", " Thank you!"));

我们将收到以下输出:

Available melons: Apollo, Cantaloupe, Crenshaw, Gac, Hemi, Horned Thank you!

188 摘要收集器

假设我们有著名的Melon类(使用typeweight以及MelonList

List<Melon> melons = Arrays.asList(new Melon("Crenshaw", 2000),
  new Melon("Hemi", 1600), new Melon("Gac", 3000),
  new Melon("Apollo", 2000), new Melon("Horned", 1700),
  new Melon("Gac", 3000), new Melon("Cantaloupe", 2600));

JavaStreamAPI 将计数、总和、最小、平均和最大操作分组在术语摘要下。用于执行摘要操作的方法可以在Collectors类中找到。

我们将在下面的部分中查看所有这些操作。

求和

假设我们要把所有的西瓜重量加起来。我们通过Stream的原始特化在流部分的“总和最小和最大”中实现了这一点。现在,让我们通过summingInt​(ToIntFunction<? super T> mapper)收集器来完成:

int sumWeightsGrams = melons.stream()
  .collect(Collectors.summingInt(Melon::getWeight));

所以,Collectors.summingInt()是一个工厂方法,它接受一个函数,这个函数能够将一个对象映射成一个int,这个函数必须作为一个参数求和。返回一个收集器,该收集器通过collect()方法执行摘要。下图描述了summingInt()的工作原理:

当遍历流时,每个权重(Melon::getWeight)被映射到它的数字,并且这个数字被添加到累加器中,从初始值开始,即 0。

summingInt()之后,我们有summingLong()summingDouble()。我们怎样用公斤来计算西瓜的重量?这可以通过summingDouble()实现,如下所示:

double sumWeightsKg = melons.stream()
  .collect(Collectors.summingDouble(
    m -> (double) m.getWeight() / 1000.0d));

如果我们只需要以千克为单位的结果,我们仍然可以以克为单位求和,如下所示:

double sumWeightsKg = melons.stream()
  .collect(Collectors.summingInt(Melon::getWeight)) / 1000.0d;

因为摘要实际上是归约,所以Collectors类也提供了reducing()方法。显然,这种方法有更广泛的用途,允许我们通过其三种口味提供各种 Lambda:

  • reducing​(BinaryOperator<T> op)
  • reducing​(T identity, BinaryOperator<T> op)
  • reducing​(U identity, Function<? super T,​? extends U> mapper, BinaryOperator<U> op)

reducing()的参数很直截了当。我们有用于减少的identity值(以及没有输入元素时返回的值)、应用于每个输入值的映射函数和用于减少映射值的函数。

例如,让我们通过reducing()重写前面的代码片段。请注意,我们从 0 开始求和,通过映射函数将其从克转换为千克,并通过 Lambda 减少值(结果千克):

double sumWeightsKg = melons.stream()
  .collect(Collectors.reducing(0.0,
    m -> (double) m.getWeight() / 1000.0d, (m1, m2) -> m1 + m2));

或者,我们可以简单地在末尾转换为千克:

double sumWeightsKg = melons.stream()
  .collect(Collectors.reducing(0,
    m -> m.getWeight(), (m1, m2) -> m1 + m2)) / 1000.0d;

当没有合适的内置解决方案时,依赖reducing()。把reducing()想象成摘要

平均

计算一个瓜的平均重量怎么样?

为此,我们有Collectors.averagingInt()averagingLong()averagingDouble()

double avgWeights = melons.stream()
  .collect(Collectors.averagingInt(Melon::getWeight));

计数

计算一段文字的字数是一个常见的问题,可以通过count()来解决:

String str = "Lorem Ipsum is simply dummy text ...";

long numberOfWords = Stream.of(str)
  .map(w -> w.split("\\s+"))
  .flatMap(Arrays::stream)
  .filter(w -> w.trim().length() != 0)
  .count();

但是让我们看看我们的流里有多少重 3000 磅的Melon

long nrOfMelon = melons.stream()
  .filter(m -> m.getWeight() == 3000)
  .count();

我们可以使用counting()工厂方法返回的收集器:

long nrOfMelon = melons.stream()
  .filter(m -> m.getWeight() == 3000)
  .collect(Collectors.counting());

我们也可以使用笨拙的方法使用reducing()

long nrOfMelon = melons.stream()
  .filter(m -> m.getWeight() == 3000)
  .collect(Collectors.reducing(0L, m -> 1L, Long::sum));

最大值和最小值

在“流的求和、最大、最小”部分,我们已经通过min()max()方法计算了最小值和最大值。这次,让我们通过Collectors.maxBy()Collectors.minBy()收集器来计算最重和最轻的Melon。这些收集器以一个Comparator作为参数来比较流中的元素,并返回一个Optional(如果流为空,则该Optional将为空):

Comparator<Melon> byWeight = Comparator.comparing(Melon::getWeight);

Melon heaviestMelon = melons.stream()
  .collect(Collectors.maxBy(byWeight))
  .orElseThrow();

Melon lightestMelon = melons.stream()
  .collect(Collectors.minBy(byWeight))
  .orElseThrow();

在这种情况下,如果流是空的,我们只抛出NoSuchElementException

获取全部

有没有办法在一次幺正运算中获得计数、和、平均值、最小值和最大值?

是的,有!当我们需要两个或更多这样的操作时,我们可以依赖于Collectors.summarizingInt​()summarizingLong()summarizingDouble()。这些方法将这些操作分别包装在IntSummaryStatisticsLongSummaryStatisticsDoubleSummaryStatistics中,如下所示:

IntSummaryStatistics melonWeightsStatistics = melons
  .stream().collect(Collectors.summarizingInt(Melon::getWeight));

打印此对象会产生以下输出:

IntSummaryStatistics{count=7, sum=15900, min=1600, average=2271.428571, max=3000}

对于每个操作,我们都有专门的获取器:

int max = melonWeightsStatistics.getMax()

我们都完了!现在,让我们来讨论如何对流的元素进行分组。

189 分组

假设我们有以下Melon类和MelonList

public class Melon {

  enum Sugar {
    LOW, MEDIUM, HIGH, UNKNOWN
  }

  private final String type;
  private final int weight;
  private final Sugar sugar;

  // constructors, getters, setters, equals(),
  // hashCode(), toString() omitted for brevity
}

List<Melon> melons = Arrays.asList(
  new Melon("Crenshaw", 1200),
  new Melon("Gac", 3000), new Melon("Hemi", 2600),
  new Melon("Hemi", 1600), new Melon("Gac", 1200),
  new Melon("Apollo", 2600), new Melon("Horned", 1700),
  new Melon("Gac", 3000), new Melon("Hemi", 2600)
);

JavaStreamAPI 通过Collectors.groupingBy()公开了与 SQLGROUP BY子句相同的功能。

当 SQLGROUP BY子句作用于数据库表时,Collectors.groupingBy()作用于流的元素。

换句话说,groupingBy()方法能够对具有特定区别特征的元素进行分组。在流和函数式编程(java8)之前,这样的任务是通过一堆繁琐、冗长且容易出错的意大利面代码应用于集合的。从 Java8 开始,我们有分组收集器

在下一节中,我们来看看单级分组和多级分组。我们将从单级分组开始。

单级分组

所有分组收集器都有一个分类函数(将流中的元素分为不同组的函数),主要是Function<T, R>函数式接口的一个实例。

流的每个元素(属于T类型)都通过这个函数,返回的将是分类器对象(属于R类型)。所有返回的R类型代表一个Map<K, V>的键(K,每组都是这个Map<K, V>中的一个值。

换句话说,关键字(K是分类函数返回的值,值(V是流中具有该分类值的元素的列表(K)。所以,最终的结果是Map<K, List<T>>类型。

让我们看一个例子,为这个大脑的逗逗解释带来一些启示。本例依赖于groupingBy()最简单的味道,即groupingBy​(Function<? super T,​? extends K> classifier)

那么,让我们按类型将Melon分组:

Map<String, List<Melon>> byTypeInList = melons.stream()
  .collect(groupingBy(Melon::getType));

输出如下:

{
  Crenshaw = [Crenshaw(1200 g)],
  Apollo = [Apollo(2600 g)],
  Gac = [Gac(3000 g), Gac(1200 g), Gac(3000 g)],
  Hemi = [Hemi(2600 g), Hemi(1600 g), Hemi(2600 g)],
  Horned = [Horned(1700 g)]
}

我们也可以将Melon按重量分组:

Map<Integer, List<Melon>> byWeightInList = melons.stream()
  .collect(groupingBy(Melon::getWeight));

输出如下:

{
  1600 = [Hemi(1600 g)],
  1200 = [Crenshaw(1200 g), Gac(1200 g)],
  1700 = [Horned(1700 g)],
  2600 = [Hemi(2600 g), Apollo(2600 g), Hemi(2600 g)],
  3000 = [Gac(3000 g), Gac(3000 g)]
}

此分组如下图所示。更准确地说,这是Gac(1200 g)通过分类函数(Melon::getWeight的瞬间的快照:

因此,在甜瓜分类示例中,一个键是Melon的权重,它的值是包含该权重的所有Melon对象的列表。

分类函数可以是方法引用或任何其他 Lambda。

上述方法的一个问题是存在不需要的重复项。这是因为这些值是在一个List中收集的(例如,3000=[Gac(3000g), Gac(3000g)。但我们可以依靠另一种口味的groupingBy(),即groupingBy​(Function<? super T,​? extends K> classifier, Collector<? super T,​A,​D> downstream),来解决这个问题。

这一次,我们可以指定所需的下游收集器作为第二个参数。所以,除了分类函数,我们还有一个下游收集器。

如果我们想拒绝复制品,我们可以使用Collectors.toSet(),如下所示:

Map<String, Set<Melon>> byTypeInSet = melons.stream()
  .collect(groupingBy(Melon::getType, toSet()));

输出如下:

{
  Crenshaw = [Crenshaw(1200 g)],
  Apollo = [Apollo(2600 g)],
  Gac = [Gac(1200 g), Gac(3000 g)],
  Hemi = [Hemi(2600 g), Hemi(1600 g)],
  Horned = [Horned(1700 g)]
}

我们也可以按重量计算:

Map<Integer, Set<Melon>> byWeightInSet = melons.stream()
  .collect(groupingBy(Melon::getWeight, toSet()));

输出如下:

{
  1600 = [Hemi(1600 g)],
  1200 = [Gac(1200 g), Crenshaw(1200 g)],
  1700 = [Horned(1700 g)],
  2600 = [Hemi(2600 g), Apollo(2600 g)],
  3000 = [Gac(3000 g)]
}

当然,在这种情况下,也可以使用distinct()

Map<String, List<Melon>> byTypeInList = melons.stream()
  .distinct()
  .collect(groupingBy(Melon::getType));

按重量计算也是如此:

Map<Integer, List<Melon>> byWeightInList = melons.stream()
  .distinct()
  .collect(groupingBy(Melon::getWeight));

好吧,没有重复的了,但是结果不是有序的。这个映射最好按键排序,所以默认的HashMap不是很有用。如果我们可以指定一个TreeMap而不是默认的HashMap,那么问题就解决了。我们可以通过另一种口味的groupingBy(),也就是groupingBy​(Function<? super T,​? extends K> classifier, Supplier<M> mapFactory, Collector<? super T,​A,​D> downstream)

这个风格的第二个参数允许我们提供一个Supplier对象,它提供一个新的空Map,结果将被插入其中:

Map<Integer, Set<Melon>> byWeightInSetOrdered = melons.stream()
  .collect(groupingBy(Melon::getWeight, TreeMap::new, toSet()));

现在,输出是有序的:

{
  1200 = [Gac(1200 g), Crenshaw(1200 g)],
  1600 = [Hemi(1600 g)],
  1700 = [Horned(1700 g)],
  2600 = [Hemi(2600 g), Apollo(2600 g)],
  3000 = [Gac(3000 g)]
}

我们也可以有一个List<Integer>包含 100 个瓜的重量:

List<Integer> allWeights = new ArrayList<>(100);

我们想把这个列表分成 10 个列表,每个列表有 10 个权重。基本上,我们可以通过分组得到,如下(我们也可以应用parallelStream()

final AtomicInteger count = new AtomicInteger();
Collection<List<Integer>> chunkWeights = allWeights.stream()
  .collect(Collectors.groupingBy(c -> count.getAndIncrement() / 10))
  .values();

现在,让我们来解决另一个问题。默认情况下,Stream<Melon>被分成一组List<Melon>。但是我们怎样才能将Stream<Melon>划分成一组List<String>,每个列表只包含瓜的类型,而不是Melon实例?

嗯,转化一个流的元素通常是map()的工作。但是在groupingBy()中,这是Collectors.mapping()的工作(更多细节可以在本章的“过滤、展开和映射收集器”部分找到):

Map<Integer, Set<String>> byWeightInSetOrdered = melons.stream()
  .collect(groupingBy(Melon::getWeight, TreeMap::new,
    mapping(Melon::getType, toSet())));

这一次,输出正是我们想要的:

{
  1200 = [Crenshaw, Gac],
  1600 = [Hemi],
  1700 = [Horned],
  2600 = [Apollo, Hemi],
  3000 = [Gac]
}

好的,到目前为止,很好!现在,让我们关注一个事实,groupingBy()的三种风格中有两种接受收集器作为参数(例如,toSet()。这可以是任何收集器。例如,我们可能需要按类型对西瓜进行分组并计数。为此,Collectors.counting()很有帮助(更多细节可以在“摘要收集器”部分找到):

Map<String, Long> typesCount = melons.stream()
  .collect(groupingBy(Melon::getType, counting()));

输出如下:

{Crenshaw=1, Apollo=1, Gac=3, Hemi=3, Horned=1}

我们也可以按重量计算:

Map<Integer, Long> weightsCount = melons.stream()
  .collect(groupingBy(Melon::getWeight, counting()));

输出如下:

{1600=1, 1200=2, 1700=1, 2600=3, 3000=2}

我们能把最轻和最重的瓜按种类分类吗?我们当然可以!我们可以通过Collectors.minBy()maxBy()来实现这一点,这在摘要收集器部分有介绍:

Map<String, Optional<Melon>> minMelonByType = melons.stream()
  .collect(groupingBy(Melon::getType,
    minBy(comparingInt(Melon::getWeight))));

输出如下(注意,minBy()返回一个Optional

{
  Crenshaw = Optional[Crenshaw(1200 g)],
  Apollo = Optional[Apollo(2600 g)],
  Gac = Optional[Gac(1200 g)],
  Hemi = Optional[Hemi(1600 g)],
  Horned = Optional[Horned(1700 g)]
}

我们也可以通过maxMelonByType()实现:

Map<String, Optional<Melon>> maxMelonByType = melons.stream()
  .collect(groupingBy(Melon::getType,
    maxBy(comparingInt(Melon::getWeight))));

输出如下(注意,maxBy()返回一个Optional

{
  Crenshaw = Optional[Crenshaw(1200 g)],
  Apollo = Optional[Apollo(2600 g)],
  Gac = Optional[Gac(3000 g)],
  Hemi = Optional[Hemi(2600 g)],
  Horned = Optional[Horned(1700 g)]
}

minBy()maxBy()收集器采用Comparator作为参数。在这些示例中,我们使用了内置的Comparator.comparingInt​()函数。从 JDK8 开始,java.util.Comparator类增加了几个新的比较器,包括用于链接比较器的thenComparing()口味。

此处的问题由应删除的选项表示。更一般地说,这类问题将继续使收集器返回的结果适应不同的类型。

嗯,特别是对于这类任务,我们有collectingAndThen​(Collector<T,​A,​R> downstream, Function<R,​RR> finisher)工厂方法。此方法采用的函数将应用于下游收集器(分页装订器)的最终结果。可按如下方式使用:

Map<String, Integer> minMelonByType = melons.stream()
  .collect(groupingBy(Melon::getType,
    collectingAndThen(minBy(comparingInt(Melon::getWeight)),
      m -> m.orElseThrow().getWeight())));

输出如下:

{Crenshaw=1200, Apollo=2600, Gac=1200, Hemi=1600, Horned=1700}

我们也可以使用maxMelonByType()

Map<String, Integer> maxMelonByType = melons.stream()
  .collect(groupingBy(Melon::getType, 
    collectingAndThen(maxBy(comparingInt(Melon::getWeight)),
      m -> m.orElseThrow().getWeight())));

输出如下:

{Crenshaw=1200, Apollo=2600, Gac=3000, Hemi=2600, Horned=1700}

我们还可以在Map<String, Melon[]>中按类型对瓜进行分组。同样,我们可以依赖collectingAndThen()来实现这一点,如下所示:

Map<String, Melon[]> byTypeArray = melons.stream()
  .collect(groupingBy(Melon::getType, collectingAndThen(
    Collectors.toList(), l -> l.toArray(Melon[]::new))));

或者,我们可以创建一个通用收集器并调用它,如下所示:

private static <T> Collector<T, ? , T[]> 
    toArray(IntFunction<T[]> func) {

  return Collectors.collectingAndThen(
    Collectors.toList(), l -> l.toArray(func.apply(l.size())));
}

Map<String, Melon[]> byTypeArray = melons.stream()
  .collect(groupingBy(Melon::getType, toArray(Melon[]::new)));

多级分组

前面我们提到过三种口味的groupingBy()中有两种以另一个收集器为论据。此外,我们说,这可以是任何收集器。任何一个收集器,我们也指groupingBy()

通过将groupingBy()传递到groupingBy(),我们可以实现n——层次分组或多层次分组。主要有n级分类函数。

让我们考虑一下Melon的以下列表:

List<Melon> melonsSugar = Arrays.asList(
  new Melon("Crenshaw", 1200, HIGH),
  new Melon("Gac", 3000, LOW), new Melon("Hemi", 2600, HIGH),
  new Melon("Hemi", 1600), new Melon("Gac", 1200, LOW),
  new Melon("Cantaloupe", 2600, MEDIUM),
  new Melon("Cantaloupe", 3600, MEDIUM),
  new Melon("Apollo", 2600, MEDIUM), new Melon("Horned", 1200, HIGH),
  new Melon("Gac", 3000, LOW), new Melon("Hemi", 2600, HIGH));

因此,每个Melon都有一个类型、一个重量和一个糖分水平指示器。首先,我们要根据糖分指标(LOWMEDIUMHIGHUNKNOWN(默认值))对西瓜进行分组。此外,我们想把西瓜按重量分组。这可以通过两个级别的分组来实现,如下所示:

Map<Sugar, Map<Integer, Set<String>>> bySugarAndWeight = melonsSugar.stream()
  .collect(groupingBy(Melon::getSugar,
    groupingBy(Melon::getWeight, TreeMap::new,
      mapping(Melon::getType, toSet()))));

输出如下:

{
  MEDIUM = {
    2600 = [Apollo, Cantaloupe], 3600 = [Cantaloupe]
  },
  HIGH = {
    1200 = [Crenshaw, Horned], 2600 = [Hemi]
  },
  UNKNOWN = {
    1600 = [Hemi]
  },
  LOW = {
    1200 = [Gac], 3000 = [Gac]
  }
}

我们现在可以说,克伦肖和角重 1200 克,含糖量高。我们还有 2600 克的高含糖量半胱胺。

我们甚至可以在一个表中表示数据,如下图所示:

现在,让我们学习分区。

190 分区

分区是一种分组类型,它依赖于一个Predicate将一个流分成两组(一组用于true和一组用于false)。true的组存储流中已通过谓词的元素,false的组存储其余元素(未通过谓词的元素)。

Predicate代表划分的分类函数,称为划分函数。因为Predicate被求值为boolean值,所以分区操作返回Map<Boolean, V>

假设我们有以下Melon类和MelonList

public class Melon {

  private final String type;
  private int weight;

  // constructors, getters, setters, equals(),
  // hashCode(), toString() omitted for brevity
}

List<Melon> melons = Arrays.asList(new Melon("Crenshaw", 1200),
  new Melon("Gac", 3000), new Melon("Hemi", 2600),
  new Melon("Hemi", 1600), new Melon("Gac", 1200),
  new Melon("Apollo", 2600), new Melon("Horned", 1700),
  new Melon("Gac", 3000), new Melon("Hemi", 2600));

分区通过Collectors.partitioningBy​()完成。这个方法有两种风格,其中一种只接收一个参数,即partitioningBy​(Predicate<? super T> predicate)

例如,按 2000 克的重量将西瓜分成两份,可按以下步骤进行:

Map<Boolean, List<Melon>> byWeight = melons.stream()
  .collect(partitioningBy(m -> m.getWeight() > 2000));

输出如下:

{
  false=[Crenshaw(1200g),Hemi(1600g), Gac(1200g),Horned(1700g)],
  true=[Gac(3000g),Hemi(2600g),Apollo(2600g), Gac(3000g),Hemi(2600g)]
}

分区优于过滤的优点在于分区保留了流元素的两个列表。

下图描述了partitioningBy()如何在内部工作:

如果我们想拒绝重复,那么我们可以依赖于其他口味的partitioningBy(),比如partitioningBy​(Predicate<? super T> predicate, Collector<? super T,​A,​D> downstream)。第二个参数允许我们指定另一个Collector来实现下游还原:

Map<Boolean, Set<Melon>> byWeight = melons.stream()
  .collect(partitioningBy(m -> m.getWeight() > 2000, toSet()));

输出将不包含重复项:

{
  false=[Horned(1700g), Gac(1200g), Crenshaw(1200g), Hemi(1600g)], 
  true=[Gac(3000g), Hemi(2600g), Apollo(2600g)]
}

当然,在这种情况下,distinct()也会起作用:

Map<Boolean, List<Melon>> byWeight = melons.stream()
  .distinct()
  .collect(partitioningBy(m -> m.getWeight() > 2000));

也可以使用其他收集器。例如,我们可以通过counting()对这两组中的每一组元素进行计数:

Map<Boolean, Long> byWeightAndCount = melons.stream()
  .collect(partitioningBy(m -> m.getWeight() > 2000, counting()));

输出如下:

{false=4, true=5}

我们还可以计算没有重复的元素:

Map<Boolean, Long> byWeight = melons.stream()
  .distinct()
  .collect(partitioningBy(m -> m.getWeight() > 2000, counting()));

这一次,输出如下:

{false=4, true=3}

最后,partitioningBy()可以与collectingAndThen()结合,我们在分组段中介绍了这一点。例如,让我们按 2000 g 的重量对西瓜进行分区,并将每个分区中的西瓜保持最重的部分:

Map<Boolean, Melon> byWeightMax = melons.stream()
  .collect(partitioningBy(m -> m.getWeight() > 2000,      
    collectingAndThen(maxBy(comparingInt(Melon::getWeight)),
      Optional::get)));

输出如下:

{false=Horned(1700g), true=Gac(3000g)}

191 过滤、展开和映射收集器

假设我们有以下Melon类和MelonList

public class Melon {

  private final String type;
  private final int weight;
  private final List<String> pests;

  // constructors, getters, setters, equals(),
  // hashCode(), toString() omitted for brevity
}

List<Melon> melons = Arrays.asList(new Melon("Crenshaw", 2000),
  new Melon("Hemi", 1600), new Melon("Gac", 3000),
  new Melon("Hemi", 2000), new Melon("Crenshaw", 1700),
  new Melon("Gac", 3000), new Melon("Hemi", 2600));

JavaStreamAPI 提供了filtering()flatMapping()mapping(),特别是用于多级降阶(如groupingBy()partitioningBy()的下游)。

在概念上,filtering()的目标与filter()相同,flatMapping()的目标与flatMap()相同,mapping()的目标与map()相同。

filtering​()

用户问题:我想把所有重 2000 克以上的西瓜都按种类分类。对于每种类型,将它们添加到适当的容器中(每种类型都有一个容器—只需检查容器的标签即可)

通过使用filtering​(Predicate<? super T> predicate, Collector<? super T,​A,​R> downstream),我们对当前收集器的每个元素应用谓词,并在下游收集器中累积输出。

因此,要将重量超过 2000 克的西瓜按类型分组,我们可以编写以下流管道:

Map<String, Set<Melon>> melonsFiltering = melons.stream()
  .collect(groupingBy(Melon::getType,
    filtering(m -> m.getWeight() > 2000, toSet())));

输出如下(每个Set<Melon>是一个容器):

{Crenshaw=[], Gac=[Gac(3000g)], Hemi=[Hemi(2600g)]}

请注意,没有比 2000g 重的 Crenshaw,因此filtering()已将此类型映射到一个空集(容器)。现在,让我们通过filter()重写这个:

Map<String, Set<Melon>> melonsFiltering = melons.stream()
  .filter(m -> m.getWeight() > 2000)
  .collect(groupingBy(Melon::getType, toSet()));

因为filter()不会对其谓词失败的元素执行映射,所以输出将如下所示:

{Gac=[Gac(3000g)], Hemi=[Hemi(2600g)]}

用户问题:这次我只对哈密瓜感兴趣。有两个容器:一个用于装重量小于(或等于)2000 克的哈密瓜,另一个用于装重量大于 2000 克的哈密瓜

过滤也可以与partitioningBy()一起使用。要将重量超过 2000 克的西瓜进行分区,并按某种类型(在本例中为哈密瓜)进行过滤,我们有以下几点:

Map<Boolean, Set<Melon>> melonsFiltering = melons.stream()
  .collect(partitioningBy(m -> m.getWeight() > 2000,
    filtering(m -> m.getType().equals("Hemi"), toSet())));

输出如下:

{false=[Hemi(1600g), Hemi(2000g)], true=[Hemi(2600g)]}

应用filter()将导致相同的结果:

Map<Boolean, Set<Melon>> melonsFiltering = melons.stream()
  .filter(m -> m.getType().equals("Hemi"))
  .collect(partitioningBy(m -> m.getWeight() > 2000, toSet()));

输出如下:

{false=[Hemi(1600g), Hemi(2000g)], true=[Hemi(2600g)]}

mapping​()

用户问题:对于每种类型的甜瓜,我都需要按升序排列的权重列表

通过使用mapping​(Function<? super T,​? extends U> mapper, Collector<? super U,​A,​R> downstream),我们可以对电流收集器的每个元件应用映射函数,并在下游收集器中累积输出。

例如,要按类型对西瓜的重量进行分组,我们可以编写以下代码片段:

Map<String, TreeSet<Integer>> melonsMapping = melons.stream()
  .collect(groupingBy(Melon::getType,
    mapping(Melon::getWeight, toCollection(TreeSet::new))));

输出如下:

{Crenshaw=[1700, 2000], Gac=[3000], Hemi=[1600, 2000, 2600]}

用户问题:我想要两个列表。一个应包含重量小于(或等于)2000 克的甜瓜类型,另一个应包含其余类型

对重达 2000 克以上的西瓜进行分区,只收集其类型,可按以下步骤进行:

Map<Boolean, Set<String>> melonsMapping = melons.stream()
  .collect(partitioningBy(m -> m.getWeight() > 2000,
    mapping(Melon::getType, toSet())));

输出如下:

{false=[Crenshaw, Hemi], true=[Gac, Hemi]}

flatMapping()

要快速提醒您如何展开流,建议阅读“映射”部分。

现在,假设我们有下面的Melon列表(注意,我们还添加了有害生物的名称):

List<Melon> melonsGrown = Arrays.asList(
  new Melon("Honeydew", 5600,
    Arrays.asList("Spider Mites", "Melon Aphids", "Squash Bugs")),
  new Melon("Crenshaw", 2000,
    Arrays.asList("Pickleworms")),
  new Melon("Crenshaw", 1000,
    Arrays.asList("Cucumber Beetles", "Melon Aphids")),
  new Melon("Gac", 4000,
    Arrays.asList("Spider Mites", "Cucumber Beetles")),
  new Melon("Gac", 1000,
    Arrays.asList("Squash Bugs", "Squash Vine Borers")));

用户问题:对于每种类型的甜瓜,我想要一份它们的害虫清单

所以,让我们把西瓜按种类分类,收集它们的害虫。每个甜瓜都没有、一个或多个害虫,因此我们预计产量为Map<String, List<String>>型。第一次尝试将依赖于mapping()

Map<String, List<List<String>>> pests = melonsGrown.stream()
  .collect(groupingBy(Melon::getType, 
    mapping(m -> m.getPests(), toList())));

显然,这不是一个好方法,因为返回的类型是Map<String, List<List<String>>>

另一种依赖于映射的简单方法如下:

Map<String, List<List<String>>> pests = melonsGrown.stream()
  .collect(groupingBy(Melon::getType, 
    mapping(m -> m.getPests().stream(), toList())));

显然,这也不是一个好方法,因为返回的类型是Map<String, List<Stream<String>>>

是时候介绍flatMapping()了。通过使用flatMapping​(Function<? super T,​? extends Stream<? extends U>> mapper, Collector<? super U,​A,​R> downstream),我们将flatMapping函数应用于电流收集器的每个元件,并在下游收集器中累积输出:

Map<String, Set<String>> pestsFlatMapping = melonsGrown.stream()
  .collect(groupingBy(Melon::getType, 
    flatMapping(m -> m.getPests().stream(), toSet())));

这一次,类型看起来很好,输出如下:

{
  Crenshaw = [Cucumber Beetles, Pickleworms, Melon Aphids],
  Gac = [Cucumber Beetles, Squash Bugs, Spider Mites, 
         Squash Vine Borers],
  Honeydew = [Squash Bugs, Spider Mites, Melon Aphids]
}

用户问题:我想要两个列表。一种应含有重量小于 2000 克的瓜类害虫,另一种应含有其余瓜类害虫

对重达 2000 克以上的瓜类进行分区并收集害虫可按以下步骤进行:

Map<Boolean, Set<String>> pestsFlatMapping = melonsGrown.stream()
  .collect(partitioningBy(m -> m.getWeight() > 2000, 
    flatMapping(m -> m.getPests().stream(), toSet())));

输出如下:

{
  false = [Cucumber Beetles, Squash Bugs, Pickleworms, Melon Aphids,
           Squash Vine Borers],
  true = [Squash Bugs, Cucumber Beetles, Spider Mites, Melon Aphids]
}

192 teeing()

从 JDK12 开始,我们可以通过Collectors.teeing()合并两个收集器的结果:

  • public static <T,​R1,​R2,​R> Collector<T,​?,​R> teeing​(Collector<? super T,​?,​R1> downstream1, Collector<? super T,​?,​R2> downstream2, BiFunction<? super R1,​? super R2,​R> merger)

结果是一个Collector,它是两个经过下游收集器的组合。传递给结果收集器的每个元素都由两个下游收集器处理,然后使用指定的BiFunction将它们的结果合并到最终结果中。

让我们看一个经典问题。下面的类仅存储整数流中的元素数及其和:

public class CountSum {

  private final Long count;
  private final Integer sum;

  public CountSum(Long count, Integer sum) {
      this.count = count;
      this.sum = sum;
    }
    ...
}

我们可以通过teeing()获得此信息,如下所示:

CountSum countsum = Stream.of(2, 11, 1, 5, 7, 8, 12)
  .collect(Collectors.teeing(
    counting(),
    summingInt(e -> e),
    CountSum::new));

这里,我们将两个收集器应用于流中的每个元素(counting()summingInt()),结果已合并到CountSum的实例中:

CountSum{count=7, sum=46}

让我们看看另一个问题。这次,MinMax类存储整数流的最小值和最大值:

public class MinMax {

  private final Integer min;
  private final Integer max;

  public MinMax(Integer min, Integer max) {
      this.min = min;
      this.max = max;
    }
    ...
}

现在,我们可以得到这样的信息:

MinMax minmax = Stream.of(2, 11, 1, 5, 7, 8, 12)
  .collect(Collectors.teeing(
    minBy(Comparator.naturalOrder()),
    maxBy(Comparator.naturalOrder()),
    (Optional<Integer> a, Optional<Integer> b) 
      -> new MinMax(a.orElse(Integer.MIN_VALUE),
        b.orElse(Integer.MAX_VALUE))));

这里,我们将两个收集器应用于流中的每个元素(minBy()maxBy()),结果已合并到MinMax的实例中:

MinMax{min=1, max=12}

最后,考虑MelonMelon类和List

public class Melon {

  private final String type;
  private final int weight;

  public Melon(String type, int weight) {
    this.type = type;
    this.weight = weight;
  }
  ...
}

List<Melon> melons = Arrays.asList(new Melon("Crenshaw", 1200),
  new Melon("Gac", 3000), new Melon("Hemi", 2600),
  new Melon("Hemi", 1600), new Melon("Gac", 1200),
  new Melon("Apollo", 2600), new Melon("Horned", 1700),
  new Melon("Gac", 3000), new Melon("Hemi", 2600));

这里的目的是计算这些西瓜的总重量并列出它们的重量。我们可以将其映射如下:

public class WeightsAndTotal {

  private final int totalWeight;
  private final List<Integer> weights;

  public WeightsAndTotal(int totalWeight, List<Integer> weights) {
    this.totalWeight = totalWeight;
    this.weights = weights;
  }
  ...
}

这个问题的解决依赖于Collectors.teeing(),如下所示:

WeightsAndTotal weightsAndTotal = melons.stream()
  .collect(Collectors.teeing(
    summingInt(Melon::getWeight),
    mapping(m -> m.getWeight(), toList()),
    WeightsAndTotal::new));

这一次,我们应用了summingInt()mapping()收集器。输出如下:

WeightsAndTotal {
  totalWeight = 19500,
  weights = [1200, 3000, 2600, 1600, 1200, 2600, 1700, 3000, 2600]
}

193 编写自定义收集器

假设我们有以下Melon类和MelonList

public class Melon {

  private final String type;
  private final int weight;
  private final List<String> grown;

  // constructors, getters, setters, equals(),
  // hashCode(), toString() omitted for brevity
}

List<Melon> melons = Arrays.asList(new Melon("Crenshaw", 1200),
  new Melon("Gac", 3000), new Melon("Hemi", 2600),
  new Melon("Hemi", 1600), new Melon("Gac", 1200),
  new Melon("Apollo", 2600), new Melon("Horned", 1700),
  new Melon("Gac", 3000), new Melon("Hemi", 2600));

在“分割”部分,我们看到了如何使用partitioningBy()收集器对重达 2000 克的西瓜进行分割:

Map<Boolean, List<Melon>> byWeight = melons.stream()
  .collect(partitioningBy(m -> m.getWeight() > 2000));

现在,让我们看看是否可以通过专用的定制收集器实现相同的结果。

首先,让我们说编写自定义收集器不是一项日常任务,但是知道如何做可能会很有用。内置 JavaCollector接口如下:

public interface Collector<T, A, R> {
  Supplier<A> supplier();
  BiConsumer<A, T> accumulator();
  BinaryOperator<A> combiner();
  Function<A, R> finisher();
  Set<Characteristics> characteristics();
  ...
}

要编写自定义收集器,非常重要的一点是要知道,TAR表示以下内容:

  • T表示Stream中的元素类型(将被收集的元素)。
  • A表示收集过程中使用的对象类型,称为累加器,用于将流元素累加到可变结果容器中。
  • R表示采集过程(最终结果)后的对象类型。

收集器可以返回累加器本身作为最终结果,或者可以对累加器执行可选转换以获得最终结果(执行从中间累加器类型A到最终结果类型R的可选最终转换)。

就我们的问题而言,我们知道TMelonAMap<Boolean, List<Melon>>RMap<Boolean, List<Melon>>。此收集器通过Function.identity()返回累加器本身作为最终结果。也就是说,我们可以按如下方式启动自定义收集器:

public class MelonCollector implements
  Collector<Melon, Map<Boolean, List<Melon>>,
    Map<Boolean, List<Melon>>> {
  ...
}

因此,Collector由四个函数指定。这些函数一起工作,将条目累积到可变的结果容器中,并可以选择对结果执行最终转换。具体如下:

  • 新建空的可变结果容器(supplier()
  • 将新的数据元素合并到可变结果容器中(accumulator()
  • 将两个可变结果容器合并为一个(combiner()
  • 对可变结果容器执行可选的最终转换以获得最终结果(finisher()

此外,收集器的行为在最后一种方法characteristics()中定义。Set<Characteristics>可以包含以下四个值:

  • UNORDERED:元素积累/收集的顺序对最终结果并不重要。
  • CONCURRENT:流中的元素可以由多个线程并发地累加(最终收集器可以对流进行并行归约)。流的并行处理产生的容器组合在单个结果容器中。数据源的性质应该是无序的,或者应该有UNORDERED标志。
  • IDENTITY_FINISH:表示累加器本身就是最终结果(基本上我们可以将A强制转换为R),此时不调用finisher()

供应者——Supplier<A> supplier()

supplier()的任务是(在每次调用时)返回一个空的可变结果容器的Supplier

在我们的例子中,结果容器是Map<Boolean, List<Melon>>类型,因此supplier()可以实现如下:

@Override
public Supplier<Map<Boolean, List<Melon>>> supplier() {

  return () -> {
    return new HashMap<Boolean, List<Melon>> () {
      {
        put(true, new ArrayList<>());
        put(false, new ArrayList<>());
      }
    };
  };
}

在并行执行中,可以多次调用此方法。

累积元素——BiConsumer<A, T> accumulator()

accumulator()方法返回执行归约操作的函数。这是BiConsumer,这是一个接受两个输入参数但不返回结果的操作。第一个输入参数是当前结果容器(到目前为止是归约的结果),第二个输入参数是流中的当前元素。此函数通过累积遍历的元素或遍历此元素的效果来修改结果容器本身。在我们的例子中,accumulator()将当前遍历的元素添加到两个ArrayList之一:

@Override
public BiConsumer<Map<Boolean, List<Melon>>, Melon> accumulator() {

  return (var acc, var melon) -> {
    acc.get(melon.getWeight() > 2000).add(melon);
  };
}

应用最终转换——Function<A, R> finisher()

finisher()方法返回在累积过程结束时应用的函数。调用此方法时,没有更多的流元素可遍历。所有元素将从中间累积类型A累积到最终结果类型R。如果不需要转换,那么我们可以返回中间结果(累加器本身):

@Override
public Function<Map<Boolean, List<Melon>>,
    Map<Boolean, List<Melon>>> finisher() {

  return Function.identity();
}

并行化收集器——BinaryOperator<A> combiner()

如果流是并行处理的,那么不同的线程(累加器)将生成部分结果容器。最后,这些部分结果必须合并成一个单独的结果。这正是combiner()所做的。在这种情况下,combiner()方法需要合并两个映射,将第二个Map的两个列表中的所有值加到第一个Map中相应的列表中:

@Override
public BinaryOperator<Map<Boolean, List<Melon>>> combiner() {

  return (var map, var addMap) -> {
    map.get(true).addAll(addMap.get(true));
    map.get(false).addAll(addMap.get(false));

    return map;
  };
}

返回最终结果–Function<A, R> finisher()

最终结果用finisher()方法计算。在这种情况下,我们只返回Function.identity(),因为累加器不需要任何进一步的转换:

@Override
public Function<Map<Boolean, List<Melon>>,
    Map<Boolean, List<Melon>>> finisher() {

  return Function.identity();
}

特征——Set<Characteristics> characteristics()

最后,我们指出我们的收集器是IDENTITY_FINISHCONCURRENT

@Override
public Set<Characteristics> characteristics() {
  return Set.of(IDENTITY_FINISH, CONCURRENT);
}

本书附带的代码将拼图的所有部分粘在一个名为MelonCollector的类中。

测试时间

MelonCollector可以通过new关键字使用,如下所示:

Map<Boolean, List<Melon>> melons2000 = melons.stream()
  .collect(new MelonCollector());

我们将收到以下输出:

{
  false = [Crenshaw(1200 g),Hemi(1600 g),Gac(1200 g),Horned(1700 g)],
  true = [Gac(3000 g),Hemi(2600 g),Apollo(2600 g),
          Gac(3000 g),Hemi(2600 g)]
}

我们也可以通过parallelStream()使用:

Map<Boolean, List<Melon>> melons2000 = melons.parallelStream()
  .collect(new MelonCollector());

如果我们使用combiner()方法,那么输出可能如下所示:

{false = [], true = [Hemi(2600g)]} 
    ForkJoinPool.commonPool - worker - 7
...
{false = [Horned(1700g)], true = []} 
    ForkJoinPool.commonPool - worker - 15 
{false = [Crenshaw(1200g)], true = [Gac(3000g)]} 
    ForkJoinPool.commonPool - worker - 9
...
{false = [Crenshaw(1200g), Hemi(1600g), Gac(1200g), Horned(1700g)], 
true = [Gac(3000g), Hemi(2600g), Apollo(2600g), 
        Gac(3000g), Hemi(2600g)]}

通过collect()自定义收集

IDENTITY_FINISH收集操作的情况下,存在至少一个以上用于获得定制收集器的解决方案。此解决方案通过以下方法实现:

<R> R collect​(Supplier<R> supplier, BiConsumer<R,​? super T> accumulator, BiConsumer<R,​R> combiner)

这种口味的collect()非常适合,只要我们处理IDENTITY_FINISH采集操作,我们可以提供供应器、累加器和合路器。

我们来看看一些例子:

List<String> numbersList = Stream.of("One", "Two", "Three")
  .collect(ArrayList::new, ArrayList::add,
    ArrayList::addAll);

Deque<String> numbersDeque = Stream.of("One", "Two", "Three")
  .collect(ArrayDeque::new, ArrayDeque::add,
    ArrayDeque::addAll);

String numbersString = Stream.of("One", "Two", "Three")
  .collect(StringBuilder::new, StringBuilder::append,
    StringBuilder::append).toString();

您可以使用这些示例来识别更多的 JDK 类,这些类的签名非常适合与作为collect()参数的方法引用一起使用。

194 方法引用

假设我们有以下Melon类和MelonList

public class Melon {

  private final String type;
  private int weight;

  public static int growing100g(Melon melon) {
    melon.setWeight(melon.getWeight() + 100);

    return melon.getWeight();
  }

  // constructors, getters, setters, equals(),
  // hashCode(), toString() omitted for brevity
}

List<Melon> melons = Arrays.asList(
  new Melon("Crenshaw", 1200), new Melon("Gac", 3000),
  new Melon("Hemi", 2600), new Melon("Hemi", 1600));

简而言之,方法引用是 Lambda 表达式的快捷方式。

方法引用主要是一种通过名称而不是通过描述如何调用方法来调用方法的技术。主要的好处是可读性。

方法引用是通过将目标引用放在分隔符::之前来编写的,方法的名称在它之后提供。

在接下来的小节中,我们将查看所有四种方法引用。

静态方法的方法引用

我们可以通过名为growing100g()static方法将上述 100 克的列表中的每一个Melon分组:

  • 无方法引用:
melons.forEach(m -> Melon.growing100g(m));
  • 方法引用:
melons.forEach(Melon::growing100g);

实例方法的方法引用

假设我们为Melon定义了以下Comparator

public class MelonComparator implements Comparator {

  @Override
  public int compare(Object m1, Object m2) {
    return Integer.compare(((Melon) m1).getWeight(),
      ((Melon) m2).getWeight());
  }
}

现在,我们可以引用一下:

  • 无方法引用:
MelonComparator mc = new MelonComparator();

List<Melon> sorted = melons.stream()
  .sorted((Melon m1, Melon m2) -> mc.compare(m1, m2))
  .collect(Collectors.toList());
  • 方法引用:
List<Melon> sorted = melons.stream()
  .sorted(mc::compare)
  .collect(Collectors.toList());

当然,我们也可以直接调用Integer.compare()

  • 无方法引用:
List<Integer> sorted = melons.stream()
  .map(m -> m.getWeight())
  .sorted((m1, m2) -> Integer.compare(m1, m2))
  .collect(Collectors.toList());
  • 方法引用:
List<Integer> sorted = melons.stream()
  .map(m -> m.getWeight())
  .sorted(Integer::compare)
  .collect(Collectors.toList());

构造器的方法引用

可以通过new关键字引用构造器,如下所示:

BiFunction<String, Integer, Melon> melonFactory = Melon::new;
Melon hemi1300 = melonFactory.apply("Hemi", 1300);

在上一章的“实现工厂模式”一节中提供了更多关于方法引用构造器的细节和示例。

195 流的并行处理

简言之,并行处理流指的是由三个步骤组成的过程:

  1. 将流的元素拆分为多个块
  2. 在单独的线程中处理每个块
  3. 将处理结果合并到单个结果中

这三个步骤通过默认的ForkJoinPool方法在幕后进行,正如我们在第 10 章、“并发-线程池、可调用对象和同步器”和第 11 章、“并发-深入”中所讨论的。

根据经验,并行处理只能应用于无状态(一个元素的状态不影响另一个元素)、无干扰(数据源不受影响)和关联(结果不受操作数顺序影响)操作。

假设我们的问题是求和双倍列表的元素:

Random rnd = new Random();
List<Double> numbers = new ArrayList<>();

for (int i = 0; i < 1 _000_000; i++) {
  numbers.add(rnd.nextDouble());
}

我们也可以直接以流的形式执行此操作:

DoubleStream.generate(() -> rnd.nextDouble()).limit(1_000_000)

在顺序方法中,我们可以如下所示:

double result = numbers.stream()
  .reduce((a, b) -> a + b).orElse(-1d);

此操作可能会在幕后的单个内核上进行(即使我们的机器有更多内核),如下图所示:

这个问题是杠杆并行化的一个很好的候选者,因此我们可以调用parallelStream()而不是stream(),如下所示:

double result = numbers.parallelStream()
  .reduce((a, b) -> a + b).orElse(-1d);

一旦我们调用了parallelStream(),Java 将采取行动并使用多个线程处理流。并行化也可以通过parallel()方法实现:

double result = numbers.stream()
  .parallel()
  .reduce((a, b) -> a + b).orElse(-1d);

这一次,处理通过 Fork/Join 进行,如下图所示(每个可用核心有一个线程):

reduce()的上下文中,并行化可以描述如下:

默认情况下,JavaForkJoinPool将尝试获取尽可能多的可用处理器线程,如下所示:

int noOfProcessors = Runtime.getRuntime().availableProcessors();

我们可以全局地影响线程数(所有并行流都将使用它),如下所示:

System.setProperty(
  "java.util.concurrent.ForkJoinPool.common.parallelism", "10");

或者,我们可以按如下方式影响单个并行流的线程数:

ForkJoinPool customThreadPool = new ForkJoinPool(5);

double result = customThreadPool.submit(
  () -> numbers.parallelStream()
    .reduce((a, b) -> a + b)).get().orElse(-1d);

影响线程数是一个重要的决定。根据环境确定最佳线程数不是一件容易的任务,在大多数情况下,默认设置(线程数等于处理器数量)最合适。

即使这个问题是利用并行化的一个很好的候选者,也不意味着并行处理是一个银弹。决定是否使用并行处理应该是在基准测试和比较顺序处理和并行处理之后做出的决定。最常见的情况是,在大数据集的情况下,并行处理的效果更好。

不要陷入这样的思维陷阱:线程数量越多,处理速度就越快。避免以下情况(这些数字只是 8 核机器的指标):

5 threads (~40 ms)
20 threads (~50 ms)
100 threads (~70 ms)
1000 threads (~ 250 ms)

拆分器

JavaSpliterator接口(也称为可拆分迭代器)是用于并行遍历源元素(例如,集合或流)的接口。此接口定义以下方法:

public interface Spliterator<T> {
  boolean tryAdvance(Consumer<? super T> action);
  Spliterator<T> trySplit();
  long estimateSize();
  int characteristics();
}

让我们考虑一个由 10 个整数组成的简单列表:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

我们可以这样获得这个列表的Spliterator接口:

Spliterator<Integer> s1 = numbers.spliterator();

我们也可以从流中执行相同的操作:

Spliterator<Integer> s1 = numbers.stream().spliterator();

为了前进到(遍历)第一个元素,我们需要调用tryAdvance()方法,如下所示:

s1.tryAdvance(e 
  -> System.out.println("Advancing to the 
       first element of s1: " + e));

我们将收到以下输出:

Advancing to the first element of s1: 1

Spliterator可通过estimateSize()方法估算剩余导线的元件数量,具体如下:

System.out.println("\nEstimated size of s1: " + s1.estimateSize());

我们将收到以下输出(我们已经遍历了一个元素;还有九个元素):

Estimated size of s1: 9

我们可以使用trySplit()方法通过Spliterator接口将其分成两部分。结果将是另一个Spliterator接口:

Spliterator<Integer> s2 = s1.trySplit();

检查元素的数量可以发现trySplit()的效果:

System.out.println("Estimated size s1: " + s1.estimateSize());
System.out.println("Estimated size s2: " + s2.estimateSize());

我们将收到以下输出:

Estimated size s1: 5
Estimated size s2: 4

使用forEachRemaining()可以尝试打印s1s2中的所有元素,如下所示:

s1.forEachRemaining(System.out::println); // 6, 7, 8, 9, 10
s2.forEachRemaining(System.out::println); // 2, 3, 4, 5

Spliterator接口为其特性定义了一组常量–CONCURRENT4096)、DISTINCT1)、IMMUTABLE1024)、NONNULL256)、ORDERED16)、SIZED64)、SORTED4)和SUBSIZED16384

我们可以通过characteristics()方法打印特征,如下所示:

System.out.println(s1.characteristics()); // 16464
System.out.println(s2.characteristics()); // 16464

使用hasCharacteristics()测试是否呈现某一特性更简单:

if (s1.hasCharacteristics(Spliterator.ORDERED)) {
  System.out.println("ORDERED");
}

if (s1.hasCharacteristics(Spliterator.SIZED)) {
  System.out.println("SIZED");
}

编写自定义拆分器

显然,编写一个自定义的Spliterator不是一项日常任务,但是假设我们正在进行一个项目,由于某种原因,需要我们处理包含表意字符的字符串(CJKV)(简写为中日韩越)和非表意字符。我们要并行处理这些字符串。这就要求我们只能在代表表意字符的位置将它们拆分为字符。

显然,默认的Spliterator将无法按我们的意愿执行,因此我们可能需要编写自定义Spliterator。为此,我们必须实现Spliterator接口,并提供一些方法的实现。实现在与本书捆绑的代码中可用。考虑打开IdeographicSpliterator源代码,并在阅读本节其余部分时保持其接近。

实现的高潮在trySplit()方法中。在这里,我们试图将当前字符串一分为二,并继续遍历它,直到找到一个表意字符。为了便于检查,我们刚刚添加了以下行:

System.out.println("Split successfully at character: " 
  + str.charAt(splitPosition));

现在,让我们考虑一个包含表意字符的字符串:

String str = "Character Information  Development and Maintenance " 
  + "Project  for e-Government MojiJoho-Kiban  Project";

现在,让我们为这个字符串创建一个并行流,并强制IdeographicSpliterator完成它的工作:

Spliterator<Character> spliterator = new IdeographicSpliterator(str);
Stream<Character> stream = StreamSupport.stream(spliterator, true);

// force spliterator to do its job
stream.collect(Collectors.toList());

一个可能的输出将显示拆分仅发生在包含表意字符的位置:

Split successfully at character: 
Split successfully at character: 

196 空安全流

创建元素的Stream的问题可能是null,也可能不是null,可以使用Optional.ofNullable()来解决,或者更好地通过 JDK9 来解决Stream.ofNullable()

  • static <T> Stream<T> ofNullable​(T t)

此方法获取单个元素(T,并返回包含此单个元素的顺序StreamStream<T>),否则,如果不是null,则返回空的Stream

例如,我们可以编写一个辅助方法来包装对Stream.ofNullable()的调用,如下所示:

public static <T> Stream<T> elementAsStream(T element) {
  return Stream.ofNullable(element);
}

如果这个方法存在于名为AsStreams的工具类中,那么我们可以执行几个调用,如下所示:

// 0
System.out.println("Null element: " 
  + AsStreams.elementAsStream(null).count());

// 1
System.out.println("Non null element: " 
  + AsStreams.elementAsStream("Hello world").count());

注意,当我们通过null时,我们得到一个空流(count()方法返回 0)!

如果我们的元素是一个集合,那么事情就会变得更有趣。例如,假设我们有以下列表(请注意,此列表包含一些null值):

List<Integer> ints = Arrays.asList(5, null, 6, null, 1, 2);

现在,让我们编写一个辅助方法,返回一个Stream<T>,其中T是一个集合:

public static <T> Stream<T> collectionAsStreamWithNulls(
    Collection<T> element) {
  return Stream.ofNullable(element).flatMap(Collection::stream);
}

如果我们用null调用此方法,则得到一个空流:

// 0
System.out.println("Null collection: " 
  + AsStreams.collectionAsStreamWithNulls(null).count());

现在,如果我们用我们的列表来调用它,ints,那么我们得到一个Stream<Integer>

// 6
System.out.println("Non-null collection with nulls: "
  + AsStreams.collectionAsStreamWithNulls(ints).count());

注意,流有六个元素(底层列表中的所有元素)-5、null、6、null、1 和 2。

如果我们知道集合本身不是null,但可能包含null值,则可以编写另一个辅助方法,如下所示:

public static <T> Stream<T> collectionAsStreamWithoutNulls(
    Collection<T> collection) {

  return collection.stream().flatMap(e -> Stream.ofNullable(e));
}

这次,如果集合本身是null,那么代码将抛出一个NullPointerException。但是,如果我们将我们的列表传递给它,那么结果将是一个不带null值的Stream<Integer>

// 4
System.out.println("Non-null collection without nulls: " 
 + AsStreams.collectionAsStreamWithoutNulls(ints).count());

返回的流只有四个元素 -5、6、1 和 2。

最后,如果集合本身可能是null并且可能包含null值,那么下面的助手将执行此任务并返回空安全流:

public static <T> Stream<T> collectionAsStream(
    Collection<T> collection) {

  return Stream.ofNullable(collection)
    .flatMap(Collection::stream)
    .flatMap(Stream::ofNullable);
}

如果我们通过null,那么我们得到一个空流:

// 0
System.out.println(
  "Null collection or non-null collection with nulls: " 
    + AsStreams.collectionAsStream(null).count());

如果我们通过我们的列表,我们会得到一个没有null值的Stream<Integer>流:

// 4
System.out.println(
  "Null collection or non-null collection with nulls: " 
    + AsStreams.collectionAsStream(ints).count());

197 组合函数、谓词和比较器

组合(或链接)函数、谓词和比较器允许我们编写应该统一应用的复合标准。

组合谓词

假设我们有以下Melon类和MelonList

public class Melon {

  private final String type;
  private final int weight;

  // constructors, getters, setters, equals(),
  // hashCode(), toString() omitted for brevity
}

List<Melon> melons = Arrays.asList(new Melon("Gac", 2000),
  new Melon("Horned", 1600), new Melon("Apollo", 3000),
  new Melon("Gac", 3000), new Melon("Hemi", 1600));

Predicate接口提供了三种方法,它们接受一个Predicate,并使用它来获得一个丰富的Predicate。这些方法是and()or()negate()

例如,假设我们要过滤重量超过 2000 克的西瓜。为此,我们可以写一个Predicate,如下所示:

Predicate<Melon> p2000 = m -> m.getWeight() > 2000;

现在,让我们假设我们想要丰富这个Predicate,只过滤符合p2000的瓜,并且是GacApollo类型的瓜。为此,我们可以使用and()or()方法,如下所示:

Predicate<Melon> p2000GacApollo 
  = p2000.and(m -> m.getType().equals("Gac"))
    .or(m -> m.getType().equals("Apollo"));

这从左到右被解释为a && (b || c),其中我们有以下内容:

  • am -> m.getWeight() > 2000
  • bm -> m.getType().equals("Gac")
  • cm -> m.getType().equals("Apollo")

显然,我们可以以同样的方式添加更多的标准。

我们把这个Predicate传给filter()

// Apollo(3000g), Gac(3000g)
List<Melon> result = melons.stream()
  .filter(p2000GacApollo)
  .collect(Collectors.toList());

现在,假设我们的问题要求我们得到上述复合谓词的否定。将这个谓词重写为!a && !b && !c或任何其他对应的表达式是很麻烦的。更好的解决方案是调用negate()方法,如下所示:

Predicate<Melon> restOf = p2000GacApollo.negate();

我们把它传给filter()

// Gac(2000g), Horned(1600g), Hemi(1600g)
List<Melon> result = melons.stream()
  .filter(restOf)
  .collect(Collectors.toList());

从 JDK11 开始,我们可以否定作为参数传递给not()方法的Predicate。例如,让我们使用not()过滤所有重量小于(或等于)2000 克的西瓜:

Predicate<Melon> pNot2000 = Predicate.not(m -> m.getWeight() > 2000);

// Gac(2000g), Horned(1600g), Hemi(1600g)
List<Melon> result = melons.stream()
  .filter(pNot2000)
  .collect(Collectors.toList());

组合比较器

让我们考虑上一节中相同的Melon类和MelonList

现在,让我们使用Comparator.comparing()按重量对Melon中的List进行排序:

Comparator<Melon> byWeight = Comparator.comparing(Melon::getWeight);

// Horned(1600g), Hemi(1600g), Gac(2000g), Apollo(3000g), Gac(3000g)
List<Melon> sortedMelons = melons.stream()
  .sorted(byWeight)
  .collect(Collectors.toList());

我们也可以按类型对列表进行排序:

Comparator<Melon> byType = Comparator.comparing(Melon::getType);

// Apollo(3000g), Gac(2000g), Gac(3000g), Hemi(1600g), Horned(1600g)
List<Melon> sortedMelons = melons.stream()
  .sorted(byType)
  .collect(Collectors.toList());

要反转排序顺序,只需调用reversed()

Comparator<Melon> byWeight 
  = Comparator.comparing(Melon::getWeight).reversed();

到目前为止,一切都很好!

现在,假设我们想按重量和类型对列表进行排序。换言之,当两个瓜的重量相同时(例如,Horned (1600g)Hemi(1600g),它们应该按类型分类(例如,Hemi(1600g)Horned(1600g))。朴素的方法如下所示:

// Apollo(3000g), Gac(2000g), Gac(3000g), Hemi(1600g), Horned(1600g)
List<Melon> sortedMelons = melons.stream()
  .sorted(byWeight)
  .sorted(byType)
  .collect(Collectors.toList());

显然,结果不是我们所期望的。这是因为比较器没有应用于同一个列表。byWeight比较器应用于原始列表,而byType比较器应用于byWeight的输出。基本上,byType取消了byWeight的影响。

解决方案来自Comparator.thenComparing()方法。此方法允许我们链接比较器:

Comparator<Melon> byWeightAndType 
  = Comparator.comparing(Melon::getWeight)
    .thenComparing(Melon::getType);

// Hemi(1600g), Horned(1600g), Gac(2000g), Apollo(3000g), Gac(3000g)
List<Melon> sortedMelons = melons.stream()
  .sorted(byWeightAndType)
  .collect(Collectors.toList());

这种口味的thenComparing()Function为参数。此Function用于提取Comparable排序键。返回的Comparator只有在前面的Comparator找到两个相等的对象时才应用。

另一种口味的thenComparing()得到了Comparator

Comparator<Melon> byWeightAndType = Comparator.comparing(Melon::getWeight)
  .thenComparing(Comparator.comparing(Melon::getType));

最后,我们来考虑一下Melon的以下List

List<Melon> melons = Arrays.asList(new Melon("Gac", 2000),
  new Melon("Horned", 1600), new Melon("Apollo", 3000),
  new Melon("Gac", 3000), new Melon("hemi", 1600));

我们故意在最后一个Melon上加了一个错误。它的类型这次是小写的。如果我们使用byWeightAndType比较器,则输出如下:

Horned(1600g), hemi(1600g), ...

作为一个字典顺序比较器,byWeightAndType将把Horned放在hemi之前。因此,以不区分大小写的方式按类型排序将非常有用。这个问题的优雅解决方案将依赖于另一种风格的thenComparing(),它允许我们传递一个FunctionComparator作为参数。传递的Function提取Comparable排序键,给定的Comparator用于比较该排序键:

Comparator<Melon> byWeightAndType = Comparator.comparing(Melon::getWeight)
  .thenComparing(Melon::getType, String.CASE_INSENSITIVE_ORDER);

这一次,结果如下(我们回到正轨):

hemi(1600g), Horned(1600g),...

对于intlongdouble,我们有comparingInt()comparingLong()comparingDouble()thenComparingInt()thenComparingLong()thenComparingDouble()comparing()thenComparing()方法有相同的味道。

组合函数

通过Function接口表示的 Lambda 表达式可以通过Function.andThen()Function.compose()方法组合。

andThen​(Function<? super R,​? extends V> after)返回一个组合的Function,它执行以下操作:

  • 将此函数应用于其输入
  • after函数应用于结果

我们来看一个例子:

Function<Double, Double> f = x -> x * 2;
Function<Double, Double> g = x -> Math.pow(x, 2);
Function<Double, Double> gf = f.andThen(g);
double resultgf = gf.apply(4d); // 64.0

在本例中,将f函数应用于其输入(4)。f的应用结果为 8(f(4) = 4 * 2。此结果是第二个函数g的输入。g申请结果为 64(g(8) = Math.pow(8, 2)。下图描述了四个输入的流程—1234

所以,这就像g(f(x))。相反的f(g(x))可以用Function.compose()来塑造。返回的合成函数将函数之前的应用于其输入,然后将此函数应用于结果:

double resultfg = fg.apply(4d); // 32.0

在本例中,g函数应用于其输入(4)。应用g的结果是 16(g(4) = Math.pow(4, 2)。这个结果是第二个函数f的输入。应用f的结果为 32(f(16) = 16 * 2)。下图描述了四个输入的流程–1234

基于同样的原则,我们可以通过组合addIntroduction()addBody()addConclusion()方法来开发编辑文章的应用。请看一下与本书捆绑在一起的代码,看看这本书的实现。

我们也可以编写其他管道,只需将其与合成过程结合起来。

198 默认方法

默认方法被添加到 Java8 中。它们的主要目标是为接口提供支持,以便它们能够超越抽象契约(仅包含抽象方法)而发展。对于编写库并希望以兼容的方式发展 API 的人来说,这个工具非常有用。通过默认方法,可以在不中断现有实现的情况下丰富接口。

接口直接实现默认方法,并通过default关键字进行识别。

例如,以下接口定义了一个抽象方法area(),默认方法称为perimeter()

public interface Polygon {

  public double area();

  default double perimeter(double...segments) {
    return Arrays.stream(segments)
      .sum();
  }
}

因为所有公共多边形(例如,正方形)的周长都是边的总和,所以我们可以在这里实现它。另一方面,面积公式因多边形而异,因此默认实现将不太有用。

现在,我们定义一个实现PolygonSquare类。其目标是通过周长表示正方形的面积:

public class Square implements Polygon {

  private final double edge;

  public Square(double edge) {
    this.edge = edge;
  }

  @Override
  public double area() {
    return Math.pow(perimeter(edge, edge, edge, edge) / 4, 2);
  }
}

其他多边形(例如矩形和三角形)可以实现Polygon,并基于通过默认实现计算的周长来表示面积。

但是,在某些情况下,我们可能需要覆盖默认方法的默认实现。例如,Square类可以覆盖perimeter()方法,如下所示:

@Override
public double perimeter(double...segments) {
  return segments[0] * 4;
}

我们可以称之为:

@Override
public double area() {
  return Math.pow(perimeter(edge) / 4, 2);
}

总结

我们的任务完成了!本章介绍无限流、空安全流和默认方法。一系列问题涵盖了分组、分区和收集器,包括 JDK12 teeing()收集器和编写自定义收集器。此外,takeWhile()dropWhile()、组合函数、谓词和比较器、Lambda 的测试和调试,以及其他一些很酷的话题。

从本章下载应用以查看结果和其他详细信息。

十、并发-线程池、可调用对象和同步器

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括涉及 Java 并发的 14 个问题。我们将从线程生命周期以及对象级和类级锁定的几个基本问题开始。然后我们继续讨论 Java 中线程池的一系列问题,包括 JDK8 工作线程池。在那之后,我们有关于CallableFuture的问题。然后,我们将几个问题专门讨论 Java 同步器(例如,屏障、信号量和交换器)。在本章结束时,您应该熟悉 Java 并发的主要坐标,并准备好继续处理一组高级问题。

问题

使用以下问题来测试您的并发编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 线程生命周期状态:编写多个程序,捕捉线程的每个生命周期状态。

  2. 对象级与类级的锁定:写几个例子来举例说明通过线程同步实现对象级与类级的锁定。

  3. Java 中的线程池:简要概述 Java 中的线程池。

  4. 单线程线程池:编写一个程序,模拟一条装配线,用两个工作器来检查和打包灯泡。

  5. 固定线程数的线程池:编写一个程序,模拟一条装配线,使用多个工作器检查和打包灯泡。

  6. 缓存和调度线程池:编写一个程序,模拟装配线,根据需要使用工作器检查和打包灯泡(例如,调整打包机的数量(增加或减少)以吸收检查器产生的传入流量)。

  7. 偷工线程池:编写依赖偷工线程池的程序。更准确地说,编写一个程序,模拟一条装配线来检查和打包灯泡,如下所示:检查在白天进行,打包在晚上进行。检查过程导致每天有 1500 万只灯泡排队。

  8. CallableFuture:用CallableFuture编写模拟灯泡检查打包流水线的程序。

  9. 调用多个Callable任务:编写一个模拟装配线的程序,对灯泡进行检查和打包,如下所示:检查在白天进行,打包在晚上进行。检查过程导致每天有 100 个灯泡排队。包装过程应一次包装并归还所有灯泡。也就是说,我们应该提交所有的Callable任务,等待它们全部完成。

  10. 锁存器:编写一个依赖CountDownLatch的程序来模拟服务器的启动过程。服务器在其内部服务启动后被视为已启动。服务可以同时启动并且相互独立。

  11. 屏障:编写一个依赖CyclicBarrier来模拟服务器启动过程的程序。服务器在其内部服务启动后被视为已启动。服务可以同时启动(这很费时),但它们是相互依赖的—因此,一旦准备好启动,就必须一次启动所有服务。

  12. 交换器:编写一个程序,模拟使用Exchanger,一条由两名工作器组成的灯泡检查打包流水线。一个工作器(检查人员)正在检查灯泡,并把它们放进篮子里。当篮子装满时,工作器将篮子交给另一个工作器(包装工),他们从另一个工作器那里得到一个空篮子。这个过程不断重复,直到装配线停止。

  13. 信号量:编写一个程序,模拟每天在理发店使用一个Semaphore。我们的理发店一次最多只能接待三个人(只有三个座位)。当一个人到达理发店时,他们试着坐下。理发师为他们服务后,这个人就把座位打开。如果一个人在三个座位都坐满的时候到达理发店,他们必须等待一定的时间。如果这段时间过去了,没有座位被释放,他们将离开理发店。

  14. 移相器:编写一个依赖Phaser的程序,分三个阶段模拟服务器的启动过程。服务器在其五个内部服务启动后被视为已启动。在第一阶段,我们需要同时启动三个服务。在第二阶段,我们需要同时启动另外两个服务(只有在前三个服务已经运行的情况下才能启动)。在第三阶段,服务器执行最后一次签入,并被视为已启动。

解决方案

以下各节介绍上述问题的解决方案。记住,解决一个特定问题通常不是只有一种正确的方法。另外,请记住,这里显示的解释仅包括解决问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多详细信息,并在这个页面中试用程序。

199 线程生命周期状态

Java 线程的状态通过Thread.State枚举表示。Java 线程的可能状态如下图所示:

不同的生命周期状态如下:

  • 新建状态
  • 可运行状态
  • 阻塞状态
  • 等待状态
  • 定时等待状态
  • 终止状态

让我们在下面的部分学习所有不同的状态。

新建状态

如果 Java 线程已创建但未启动,则该线程处于新建状态(线程构造器以新建状态创建线程)。这是它的状态,直到start()方法被调用。本书附带的代码包含几个代码片段,这些代码片段通过不同的构造技术(包括 Lambda)揭示了这种状态。为简洁起见,以下只是其中一种结构:

public class NewThread {

  public void newThread() {
    Thread t = new Thread(() -> {});
    System.out.println("NewThread: " + t.getState()); // NEW
  }
}

NewThread nt = new NewThread();
nt.newThread();

可运行状态

通过调用start()方法获得从新建可运行的转换。在此状态下,线程可以正在运行或准备运行。当它准备好运行时,线程正在等待 JVM 线程调度器为它分配运行所需的资源和时间。一旦处理器可用,线程调度器就会运行线程。

下面的代码片段应该打印RUNNABLE,因为我们在调用start()之后打印线程的状态。但由于线程调度器的内部机制,这一点无法保证:

public class RunnableThread {

  public void runnableThread() {
    Thread t = new Thread(() -> {});
    t.start();

    // RUNNABLE
    System.out.println("RunnableThread : " + t.getState()); 
 }
}

RunnableThread rt = new RunnableThread();
rt.runnableThread();

阻塞状态

当线程试图执行 I/O 任务或同步块时,它可能会进入阻塞状态。例如,如果一个线程t1试图进入另一个线程t2正在访问的同步代码块,那么t1将保持在阻塞状态,直到它能够获得锁为止。

此场景在以下代码片段中形成:

  1. 创建两个线程:t1t2
  2. 通过start()方法启动t1
    1. t1将执行run()方法并获取同步方法syncMethod()的锁。
    2. 因为syncMethod()有一个无限循环,所以t1将永远留在里面。
  3. 2 秒(任意时间)后,通过start()方法启动t2
    1. t2将执行run()代码,由于无法获取syncMethod()的锁,最终进入阻塞状态。

代码段如下:

public class BlockedThread {

  public void blockedThread() {

    Thread t1 = new Thread(new SyncCode());
    Thread t2 = new Thread(new SyncCode());

    t1.start();
    Thread.sleep(2000);
    t2.start();
    Thread.sleep(2000);

    System.out.println("BlockedThread t1: " 
      + t1.getState() + "(" + t1.getName() + ")");
    System.out.println("BlockedThread t2: " 
      + t2.getState() + "(" + t2.getName() + ")");

    System.exit(0);
  }

  private static class SyncCode implements Runnable {

    @Override
    public void run() {
      System.out.println("Thread " + Thread.currentThread().getName() 
        + " is in run() method");
      syncMethod();
    }

    public static synchronized void syncMethod() {
      System.out.println("Thread " + Thread.currentThread().getName() 
        + " is in syncMethod() method");

      while (true) {
        // t1 will stay here forever, therefore t2 is blocked
      }
    }
  }
}

BlockedThread bt = new BlockedThread();
bt.blockedThread();

下面是一个可能的输出(线程的名称可能与此处不同):

Thread Thread-0 is in run() method
Thread Thread-0 is in syncMethod() method
Thread Thread-1 is in run() method
BlockedThread t1: RUNNABLE(Thread-0)
BlockedThread t2: BLOCKED(Thread-1)

等待状态

等待另一个线程t2完成的线程t1处于等待状态。

此场景在以下代码片段中形成:

  1. 创建线程:t1
  2. 通过start()方法启动t1
  3. t1run()方法中:
    1. 创建另一个线程:t2
    2. 通过start()方法启动t2
    3. t2运行时,调用t2.join()——由于t2需要加入t1(也就是说t1需要等待t2死亡),t1处于等待状态。
  4. t2run()方法中t2打印t1的状态,应该是等待(打印t1状态时t2正在运行,所以t1正在等待)。

代码段如下:

public class WaitingThread {

  public void waitingThread() {
    new Thread(() -> {
      Thread t1 = Thread.currentThread();
      Thread t2 = new Thread(() -> {

        Thread.sleep(2000);
        System.out.println("WaitingThread t1: " 
          + t1.getState()); // WAITING
      });

      t2.start();

      t2.join();

    }).start();
  }
}

WaitingThread wt = new WaitingThread();
wt.waitingThread();

定时等待状态

等待另一个线程t2完成显式时间段的线程t1处于定时等待状态。

此场景在以下代码片段中形成:

  1. 创建线程:t1
  2. 通过start()方法启动t1
  3. t1run()方法中,增加 2 秒的睡眠时间(任意时间)。
  4. t1运行时,主线程打印t1状态,该状态应为定时等待,因为t1处于两秒后过期的sleep()中。

代码段如下:

public class TimedWaitingThread {

  public void timedWaitingThread() {
    Thread t = new Thread(() -> {
      Thread.sleep(2000);
    });

    t.start();

    Thread.sleep(500);

    System.out.println("TimedWaitingThread t: " 
      + t.getState()); // TIMED_WAITING
  }
}

TimedWaitingThread twt = new TimedWaitingThread();
twt.timedWaitingThread();

终止状态

成功完成任务或异常中断的线程处于终止状态。模拟起来非常简单,如下面的代码片段(应用的主线程打印线程的状态,t——发生这种情况时,线程t已经完成了它的工作):

public class TerminatedThread {

  public void terminatedThread() {
    Thread t = new Thread(() -> {});
    t.start();

    Thread.sleep(1000);

    System.out.println("TerminatedThread t: " 
      + t.getState()); // TERMINATED
  }
}

TerminatedThread tt = new TerminatedThread();
tt.terminatedThread();

为了编写线程安全类,我们可以考虑以下技术:

  • 没有状态(类没有实例和static变量)
  • 状态,但不共享(例如,通过RunnableThreadLocal等使用实例变量)
  • 状态,但状态不可变
  • 使用消息传递(例如,作为 Akka 框架)
  • 使用synchronized
  • 使用volatile变量
  • 使用java.util.concurrent包中的数据结构
  • 使用同步器(例如,CountDownLatchBarrier
  • 使用java.util.concurrent.locks包中的锁

200 对象级与类级锁定

在 Java 中,标记为synchronized的代码块一次可以由一个线程执行。由于 Java 是一个多线程环境(它支持并发),因此它需要一个同步机制来避免并发环境特有的问题(例如死锁和内存一致性)。

线程可以在对象级或类级实现锁。

对象级别的锁定

对象级的锁定可以通过在非static代码块或非static方法(该方法的对象的锁定对象)上标记synchronized来实现。在以下示例中,一次只允许一个线程在类的给定实例上执行synchronized方法/块:

  • 同步方法案例:
public class ClassOll {
  public synchronized void methodOll() {
    ...
  }
}
  • 同步代码块:
public class ClassOll {
  public void methodOll() {
    synchronized(this) {
      ...
    }
  }
}
  • 另一个同步代码块:
public class ClassOll {

  private final Object ollLock = new Object();
  public void methodOll() {
    synchronized(ollLock) {
      ...
    }
  }
}

类级别的锁定

为了保护static数据,可以通过标记static方法/块或用synchronized获取.class引用上的锁来实现类级锁定。在以下示例中,一次只允许运行时可用实例之一的一个线程执行synchronized块:

  • synchronized static方法:
public class ClassCll {

  public synchronized static void methodCll() {
    ...
  }
}
  • .class同步块:
public class ClassCll {

  public void method() {
    synchronized(ClassCll.class) {
      ...
    }
  }
}
  • 同步的代码块和其他static对象的锁定:
public class ClassCll {

  private final static Object aLock = new Object();

  public void method() {
    synchronized(aLock) {
      ...
    }
  }
}

很高兴知道

以下是一些暗示同步的常见情况:

  • 两个线程可以同时执行同一类的synchronized static方法和非static方法(参见P200_ObjectVsClassLevelLockingApp 的OllAndCll类)。这是因为线程在不同的对象上获取锁。

  • 两个线程不能同时执行同一类的两个不同的synchronized static方法(或同一synchronized static方法)(检查P200_ObjectVsClassLevelLocking应用的TwoCll类)。这不起作用,因为第一个线程获得了类级锁。以下组合将输出staticMethod1(): Thread-0,因此,只有一个线程执行一个static synchronized方法:

TwoCll instance1 = new TwoCll();
TwoCll instance2 = new TwoCll();
  • 两个线程,两个实例:
new Thread(() -> {
  instance1.staticMethod1();
}).start();

new Thread(() -> {
  instance2.staticMethod2();
}).start();
  • 两个线程,一个实例:
new Thread(() -> {
  instance1.staticMethod1();
}).start();

new Thread(() -> {
  instance1.staticMethod2();
}).start();
  • 两个线程可以同时执行非synchronizedsynchronized staticsynchronizedstatic方法(检查P200_ObjectVsClassLevelLocking应用的OllCllAndNoLock类)。

  • 从需要相同锁的同一类的另一个synchronized方法调用synchronized方法是安全的,因为synchronized可重入的(只要是相同的锁,第一个方法获取的锁也会用于第二个方法)。检查P200_ObjectVsClassLevelLocking应用的TwoSyncs类。

根据经验,synchronized关键字只能用于static/非static方法(不是构造器)/代码块。避免同步非final字段和String文本(通过new创建的String实例是可以的)。

201 Java 中的线程池

线程池是可用于执行任务的线程的集合。线程池负责管理其线程的创建、分配和生命周期,并有助于提高性能。现在,我们来谈谈遗嘱执行人。

Executor

java.util.concurrent包中,有一堆专用于执行任务的接口。最简单的一个叫做Executor。这个接口公开了一个名为execute​(Runnable command)的方法。下面是使用此方法执行单个任务的示例:

public class SimpleExecutor implements Executor {

  @Override
  public void execute(Runnable r) {
    (new Thread(r)).start();
  }
}

SimpleExecutor se = new SimpleExecutor();

se.execute(() -> {
  System.out.println("Simple task executed via Executor interface");
});

ExecutorService

一个更复杂、更全面的接口提供了许多附加方法,它是ExecutorService。这是Executor的丰富版本。Java 附带了一个成熟的实现ExecutorService,名为ThreadPoolExecutor。这是一个线程池,可以用一组参数实例化,如下所示:

ThreadPoolExecutor​(
  int corePoolSize,
  int maximumPoolSize,
  long keepAliveTime,
  TimeUnit unit,
  BlockingQueue<Runnable> workQueue,
  ThreadFactory threadFactory,
  RejectedExecutionHandler handler)

下面是对前面代码中实例化的每个参数的简短描述:

  • corePoolSize:池中要保留的线程数,即使它们是空闲的(除非设置了allowCoreThreadTimeOut
  • maximumPoolSize:允许的最大线程数
  • keepAliveTime:当这个时间过去后,空闲线程将从池中移除(这些是超过corePoolSize的空闲线程)
  • unitkeepAliveTime参数的时间单位
  • workQueue:在Runnable的实例(只有execute()方法提交的Runnable任务)执行之前,用来存放这些实例的队列
  • threadFactory:执行器创建新线程时使用此工厂
  • handler:当ThreadPoolExecutor由于饱和而无法执行Runnable时,即线程边界和队列容量已满(例如,workQueue大小固定,同时设置了maximumPoolSize),它将控制和决策交给这个处理器

为了优化池大小,我们需要收集以下信息:

  • CPU 数量(Runtime.getRuntime().availableProcessors()

  • 目标 CPU 利用率(在范围内,[0, 1]

  • 等待时间(W)

  • 计算时间(C)

以下公式有助于我们确定池的最佳大小:

Number of threads 
  = Number of CPUs * Target CPU utilization * (1 + W/C)

根据经验,对于计算密集型任务(通常是小型任务),最好使用线程数等于处理器数或处理器数 +1(以防止潜在的暂停)来对线程池进行基准测试。对于耗时且阻塞的任务(例如,I/O),更大的池更好,因为线程将无法以高速率进行调度。另外,还要注意与其他池(例如,数据库连接池和套接字连接池)的干扰。

让我们看一个ThreadPoolExecutor的例子:

public class SimpleThreadPoolExecutor implements Runnable {

  private final int taskId;

  public SimpleThreadPoolExecutor(int taskId) {
    this.taskId = taskId;
  }

  @Override
  public void run() {
    Thread.sleep(2000);
    System.out.println("Executing task " + taskId 
      + " via " + Thread.currentThread().getName());
  }

  public static void main(String[] args) {

    BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(5);
    final AtomicInteger counter = new AtomicInteger();

    ThreadFactory threadFactory = (Runnable r) -> {
      System.out.println("Creating a new Cool-Thread-" 
        + counter.incrementAndGet());

      return new Thread(r, "Cool-Thread-" + counter.get());
    };

    RejectedExecutionHandler rejectedHandler
      = (Runnable r, ThreadPoolExecutor executor) -> {
        if (r instanceof SimpleThreadPoolExecutor) {
          SimpleThreadPoolExecutor task=(SimpleThreadPoolExecutor) r;
          System.out.println("Rejecting task " + task.taskId);
        }
    };

    ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 20, 1,
      TimeUnit.SECONDS, queue, threadFactory, rejectedHandler);

    for (int i = 0; i < 50; i++) {
      executor.execute(new SimpleThreadPoolExecutor(i));
    }

    executor.shutdown();
    executor.awaitTermination(
      Integer.MAX_VALUE, TimeUnit.MILLISECONDS);
  }
}

main()方法触发Runnable50 个实例。每个Runnable睡两秒钟,打印一条消息。工作队列限制为Runnable的 5 个实例——核心线程为 10,线程数最多为 20 个,空闲超时为 1 秒。可能的输出如下:

Creating a new Cool-Thread-1
...
Creating a new Cool-Thread-20
Rejecting task 25
...
Rejecting task 49
Executing task 22 via Cool-Thread-18
...
Executing task 12 via Cool-Thread-2

ScheduledExecutorService

ScheduledExecutorService是一个ExecutorService,它可以安排任务在给定的延迟后执行,或者定期执行。这里,我们有schedule()scheduleAtFixedRate()scheduleWithFixedDelay​()等方法。schedule()用于一次性任务,scheduleAtFixedRate()scheduleWithFixedDelay()用于周期性任务。

通过执行器的线程池

更进一步,我们将介绍辅助类Executors。此类使用以下方法公开几种类型的线程池:

  • newSingleThreadExecutor():这是一个线程池,只管理一个线程,队列无限,一次只执行一个任务:
ExecutorService executor 
  = Executors.newSingleThreadExecutor();
  • newCachedThreadPool():这是一个线程池,根据需要创建新线程并删除空闲线程(60 秒后);核心池大小为 0,最大池大小为Integer.MAX_VALUE(此线程池在需求增加时扩展,在需求减少时收缩):
ExecutorService executor = Executors.newCachedThreadPool();
  • newFixedThreadPool():这是一个线程数固定、队列无限的线程池,产生无限超时的效果(核心池大小和最大池大小等于指定的大小):
ExecutorService executor = Executors.newFixedThreadPool(5);
  • newWorkStealingThreadPool():这是一个基于工作窃取算法的线程池(它充当 Fork/Join 框架上的一层):
ExecutorService executor = Executors.newWorkStealingPool();
  • newScheduledThreadPool():一个线程池,可以安排命令在给定的延迟后运行,或者定期执行(我们可以指定核心池的大小):
ScheduledExecutorService executor 
  = Executors.newScheduledThreadPool(5);

202 具有单个线程的线程池

为了演示单线程线程池的工作原理,假设我们想编写一个程序,模拟装配线(或输送机),用两个工作器检查和包装灯泡。

通过检查,我们了解到工作器测试灯泡是否亮起。通过包装,我们了解到,工作器将经过验证的灯泡拿到盒子里。这种工艺在几乎所有工厂都很常见。

两名工作器如下:

  • 一种所谓的生产者(或检查者),负责测试每个灯泡,看灯泡是否亮起
  • 一个所谓的消费者(或包装商),负责将每个检查过的灯泡包装到一个盒子里

这种问题完全适合于下图所示的生产者消费者设计模式:

最常见的是,在这种模式中,生产者和消费者通过队列(生产者排队数据,消费者排队数据)进行通信。这个队列称为数据缓冲区。当然,根据流程设计,其他数据结构也可以起到数据缓冲的作用。

现在,让我们看看如果生产者等待消费者可用,我们如何实现这个模式。

稍后,我们将为不等待消费者的生产者实现此模式。

生产者等待消费者出现

装配线启动时,生产者将逐个检查进线灯泡,而消费者将将其打包(每个盒子中有一个灯泡)。此流重复,直到装配线停止。

下图是生产者消费者之间的流程图:

我们可以将装配线视为我们工厂的助手,因此它可以实现为助手或工具类(当然,它也可以很容易地切换到非static实现,因此如果对您的情况更有意义,请随意切换):

public final class AssemblyLine {

  private AssemblyLine() {
    throw new AssertionError("There is a single assembly line!");
  }
  ...
}

当然,实现这个场景的方法很多,但是我们对使用 JavaExecutorService感兴趣,更准确地说是Executors.newSingleThreadExecutor()。使用一个工作线程来操作未绑定队列的Executor通过此方法创建。

我们只有两个工作器,所以我们可以使用两个实例Executor(一个Executor将启动生产者,另一个将启动消费者)。因此,生产者将是一个线程,消费者将是另一个线程:

private static ExecutorService producerService;
private static ExecutorService consumerService;

由于生产者和消费者是好朋友,他们决定根据一个简单的场景来工作:

  • 只有消费者不忙时,生产者才会检查灯泡并将其传递给消费者(如果消费者忙,生产者会等待一段时间,直到消费者有空)
  • 生产者在将当前灯泡传递给用户之前不会检查下一个灯泡
  • 消费者将尽快包装每个进入的灯泡

这个场景适用于TransferQueueSynchronousQueue,它执行的过程与前面提到的场景非常相似。让我们使用TransferQueue。这是一个BlockingQueue,其中生产者可以等待消费者接收元素。BlockingQueue实现是线程安全的:

private static final TransferQueue<String> queue 
  = new LinkedTransferQueue<>();

生产者和消费者之间的工作流程是先进先出FIFO)类型:第一个检查的灯泡是第一个打包的灯泡),因此LinkedTransferQueue是一个不错的选择。

一旦装配线启动,生产者将持续检查灯泡,因此我们可以将其作为一个类来实现,如下所示:

private static final int MAX_PROD_TIME_MS = 5 * 1000;
private static final int MAX_CONS_TIME_MS = 7 * 1000;
private static final int TIMEOUT_MS = MAX_CONS_TIME_MS + 1000;
private static final Random rnd = new Random();
private static volatile boolean runningProducer;
...
private static class Producer implements Runnable {

  @Override
  public void run() {
    while (runningProducer) {
      try {
        String bulb = "bulb-" + rnd.nextInt(1000);

        Thread.sleep(rnd.nextInt(MAX_PROD_TIME_MS));

        boolean transfered = queue.tryTransfer(bulb,
          TIMEOUT_MS, TimeUnit.MILLISECONDS);

        if (transfered) {
          logger.info(() -> "Checked: " + bulb);
        }
      } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
        logger.severe(() -> "Exception: " + ex);
        break;
      }
    }
  }
}

因此,生产者通过tryTransfer()方法将检查过的灯泡转移给消费者。如果可以在超时时间过去之前将元素传输到使用者,则此方法将执行此操作。

避免使用transfer()方法,这可能会无限期地堵塞螺纹。

为了模拟生产者检查灯泡所花的时间,相应的线程将在 0 到 5 之间随机休眠几秒(5 秒是检查灯泡所需的最长时间)。如果消费者在此时间之后不可用,则会花费更多时间(在tryTransfer()中),直到消费者可用或超时结束。

另一方面,使用另一个类实现使用者,如下所示:

private static volatile boolean runningConsumer;
...
private static class Consumer implements Runnable {

  @Override
  public void run() {
    while (runningConsumer) {
      try {
        String bulb = queue.poll(
          MAX_PROD_TIME_MS, TimeUnit.MILLISECONDS);

        if (bulb != null) {
          Thread.sleep(rnd.nextInt(MAX_CONS_TIME_MS));
          logger.info(() -> "Packed: " + bulb);
        }
      } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
        logger.severe(() -> "Exception: " + ex);
        break;
      }
    }
  }
}

消费者可以通过queue.take()方法从生产者处取灯泡。此方法检索并删除此队列的头,如有必要,请等待,直到灯泡可用。也可以调用poll()方法,在该方法中检索并移除队列的头,或者如果该队列为空,则返回null。但这两个都不适合我们。如果生产者不在,消费者可能仍然停留在take()方法中。另一方面,如果队列是空的(生产者现在正在检查当前灯泡),poll()方法将很快被一次又一次地调用,导致伪重复。解决这个问题的方法是poll​(long timeout, TimeUnit unit)。此方法检索并删除此队列的头,并在指定的等待时间内(如果需要)等待灯泡变为可用。仅当等待时间过后队列为空时,才会返回null

为了模拟耗电元件包装灯泡所需的时间,相应的线程将在 0 到 7 之间随机休眠几秒(7 秒是包装灯泡所需的最长时间)。

启动生产者和消费者是一项非常简单的任务,它是通过一种名为startAssemblyLine()的方法完成的,如下所示:

public static void startAssemblyLine() {

  if (runningProducer || runningConsumer) {
    logger.info("Assembly line is already running ...");
    return;
  }

  logger.info("\n\nStarting assembly line ...");
  logger.info(() -> "Remaining bulbs from previous run: \n"
    + queue + "\n\n");

  runningProducer = true;
  producerService = Executors.newSingleThreadExecutor();
  producerService.execute(producer);

  runningConsumer = true;
  consumerService = Executors.newSingleThreadExecutor();
  consumerService.execute(consumer);
}

停止装配线是一个微妙的过程,可以通过不同的场景来解决。主要是,当装配线停止时,生产者应检查当前灯泡作为最后一个灯泡,消费者必须包装它。生产者可能需要等待消费者包装好当前灯泡,然后才能转移最后一个灯泡;此外,消费者也必须包装好这个灯泡。

为了遵循此场景,我们首先停止生产者,然后停止消费者:

public static void stopAssemblyLine() {

  logger.info("Stopping assembly line ...");

  boolean isProducerDown = shutdownProducer();
  boolean isConsumerDown = shutdownConsumer();

  if (!isProducerDown || !isConsumerDown) {
    logger.severe("Something abnormal happened during
      shutting down the assembling line!");

    System.exit(0);
  }

  logger.info("Assembling line was successfully stopped!");
}

private static boolean shutdownProducer() {
  runningProducer = false;
  return shutdownExecutor(producerService);
}

private static boolean shutdownConsumer() {
  runningConsumer = false;
  return shutdownExecutor(consumerService);
}

最后,我们给生产者和消费者足够的时间来正常停止(不中断线程)。这在shutdownExecutor()方法中发生,如下所示:

private static boolean shutdownExecutor(ExecutorService executor) {

  executor.shutdown();

  try {
    if (!executor.awaitTermination(TIMEOUT_MS * 2,
        TimeUnit.MILLISECONDS)) {
      executor.shutdownNow();
      return executor.awaitTermination(TIMEOUT_MS * 2,
        TimeUnit.MILLISECONDS);
    }

    return true;
  } catch (InterruptedException ex) {
    executor.shutdownNow();
    Thread.currentThread().interrupt();
    logger.severe(() -> "Exception: " + ex);
  }

  return false;
}

我们要做的第一件事是将runningProducer static变量设置为false。这将破坏while(runningProducer),因此这将是最后一次检查灯泡。此外,我们启动生产者的关闭程序。

对于消费者,我们要做的第一件事是将runningConsumer static变量设置为false。这将打破while(runningConsumer),因此这将是最后一个灯泡包装。此外,我们启动耗电元件的关闭程序。

让我们看看装配线的可能执行(运行 10 秒):

AssemblyLine.startAssemblyLine();
Thread.sleep(10 * 1000);
AssemblyLine.stopAssemblyLine();

可能的输出如下:

Starting assembly line ...
...
[2019-04-14 07:39:40] [INFO] Checked: bulb-89
[2019-04-14 07:39:43] [INFO] Packed: bulb-89
...
Stopping assembly line ...
...
[2019-04-14 07:39:53] [INFO] Packed: bulb-322
Assembling line was successfully stopped!

一般来说,如果停产需要很长时间(就好像停产一样),那么生产者和消费者的数量和/或生产和消费时间之间可能存在不平衡率。您可能需要添加或减去生产者或消费者。

生产者不等待消费者出现

如果生产者检查灯泡的速度比消费者包装灯泡的速度快,那么他们很可能会决定采用以下工作流程:

  • 生产者将逐一检查灯泡,并将其推入队列
  • 消费者将从队列中轮询并打包灯泡

由于消费者比生产者慢,队列将容纳已检查但未包装的灯泡(我们可以假设有空队列的可能性很低)。在下图中,我们有生产者、消费者和用于存储已检查但未包装灯泡的队列:

为了形成这种情况,我们可以依赖于ConcurrentLinkedQueue(或LinkedBlockingQueue)。这是一个基于链接节点的无限线程安全队列:

private static final Queue<String> queue 
  = new ConcurrentLinkedQueue<>();

为了在队列中推一个灯泡,生产者调用offer()方法:

queue.offer(bulb);

另一方面,消费者使用poll()方法处理队列中的灯泡(因为消费者比生产者慢,所以当poll()返回null时应该是罕见的情况):

String bulb = queue.poll();

让我们第一次启动装配线 10 秒钟。这将输出以下内容:

Starting assembly line ...
...
[2019-04-14 07:44:58] [INFO] Checked: bulb-827
[2019-04-14 07:44:59] [INFO] Checked: bulb-257
[2019-04-14 07:44:59] [INFO] Packed: bulb-827
...
Stopping assembly line ...
...
[2019-04-14 07:45:08] [INFO] Checked: bulb-369
[2019-04-14 07:45:09] [INFO] Packed: bulb-690
...
Assembling line was successfully stopped!

此时,装配线停止,在队列中,我们有以下内容(这些灯泡已检查,但未包装):

[bulb-968, bulb-782, bulb-627, bulb-886, ...]

我们重新启动装配线并检查突出显示的行,这表明消费者从停止的位置恢复其工作:

Starting assembly line ...
[2019-04-14 07:45:12] [INFO ] Packed: bulb-968 [2019-04-14 07:45:12] [INFO ] Checked: bulb-812
[2019-04-14 07:45:12] [INFO ] Checked: bulb-470
[2019-04-14 07:45:14] [INFO ] Packed: bulb-782 [2019-04-14 07:45:15] [INFO ] Checked: bulb-601
[2019-04-14 07:45:16] [INFO ] Packed: bulb-627 ...

203 具有固定线程数的线程池

这个问题重复了“线程池中具有单个线程”部分的场景。这一次,装配线使用了三个生产者和两个消费者,如下图所示:

我们可以依靠Executors.newFixedThreadPool​(int nThreads)来模拟固定数量的生产者和消费者。我们为每个生产者(分别为消费者)分配一个线程,因此代码非常简单:

private static final int PRODUCERS = 3;
private static final int CONSUMERS = 2;
private static final Producer producer = new Producer();
private static final Consumer consumer = new Consumer();
private static ExecutorService producerService;
private static ExecutorService consumerService;
...
producerService = Executors.newFixedThreadPool(PRODUCERS);
for (int i = 0; i < PRODUCERS; i++) {
  producerService.execute(producer);
}

consumerService = Executors.newFixedThreadPool(CONSUMERS);
for (int i = 0; i < CONSUMERS; i++) {
  consumerService.execute(consumer);
}

生产者可以在其中添加已检查灯泡的队列可以是LinkedTransferQueueConcurrentLinkedQueue类型,依此类推。

基于LinkedTransferQueueConcurrentLinkedQueue的完整源代码可以在本书附带的代码中找到。

204 带有缓存和调度的线程池

这个问题重复了“线程池中具有单个线程”部分的场景。这一次,我们假设生产者(也可以使用多个生产者)在不超过 1 秒的时间内检查一个灯泡。此外,一个耗电元件(包装器)最多需要 10 秒来包装一个灯泡。生产器和耗电元件的时间可以如下所示:

private static final int MAX_PROD_TIME_MS = 1 * 1000;
private static final int MAX_CONS_TIME_MS = 10 * 1000;

显然,在这种情况下,一个消费者无法面对即将到来的流量。用于存储灯泡的队列将不断增加,直到它们被打包。生产者添加到此队列的速度比消费者可以轮询的速度快得多。因此,需要更多的消费者,如下图所示:

由于只有一个生产者,我们可以依赖Executors.newSingleThreadExecutor()

private static volatile boolean runningProducer;
private static ExecutorService producerService;
private static final Producer producer = new Producer();
...
public static void startAssemblyLine() {
  ...
  runningProducer = true;
  producerService = Executors.newSingleThreadExecutor();
  producerService.execute(producer);
  ...
}

除了extraProdTime变量外,Producer与前面的问题几乎相同:

private static int extraProdTime;
private static final Random rnd = new Random();
...
private static class Producer implements Runnable {

  @Override
  public void run() {
    while (runningProducer) {
      try {
        String bulb = "bulb-" + rnd.nextInt(1000);
        Thread.sleep(rnd.nextInt(MAX_PROD_TIME_MS) + extraProdTime);
        queue.offer(bulb);

        logger.info(() -> "Checked: " + bulb);
      } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
        logger.severe(() -> "Exception: " + ex);
        break;
      }
    }
  }
}

extraProdTime变量最初为 0。当我们放慢生产速度时,需要这样做:

Thread.sleep(rnd.nextInt(MAX_PROD_TIME_MS) + extraProdTime);

在高速运行一段时间后,生产者会感到疲倦,需要更多的时间来检查每个灯泡。如果生产者放慢生产速度,消费者的数量也应该减少。

当生产者高速运转时,我们将需要更多的消费者(包装商)。但是有多少?使用固定数量的消费者(newFixedThreadPool()会带来至少两个缺点:

  • 如果生产者在某个时候放慢速度,一些消费者将继续失业,只会继续留在那里
  • 如果生产者变得更有效率,就需要更多的消费者来面对即将到来的流量

基本上,我们应该能够根据生产者的效率来改变消费者的数量。

对于这类工作,我们有Executors.newCachedThreadPool​()。缓存的线程池将重用现有的线程,并根据需要创建新的线程(我们可以添加更多的使用者)。如果线程在 60 秒内未被使用,那么线程将被终止并从缓存中删除(我们可以删除使用者)。

让我们从一个活动消费者开始:

private static volatile boolean runningConsumer;
private static final AtomicInteger 
  nrOfConsumers = new AtomicInteger();
private static final ThreadGroup threadGroup 
  = new ThreadGroup("consumers");
private static final Consumer consumer = new Consumer();
private static ExecutorService consumerService;
...
public static void startAssemblyLine() {
  ...
  runningConsumer = true;
  consumerService = Executors
    .newCachedThreadPool((Runnable r) -> new Thread(threadGroup, r));
  nrOfConsumers.incrementAndGet();
  consumerService.execute(consumer);
  ...
}

因为我们希望能够看到一个时刻有多少线程(使用者)处于活动状态,所以我们通过一个自定义的ThreadFactory将它们添加到ThreadGroup中:

consumerService = Executors
  .newCachedThreadPool((Runnable r) -> new Thread(threadGroup, r));

稍后,我们将能够使用以下代码获取活动消费者的数量:

threadGroup.activeCount();

了解活动消费者的数量是一个很好的指标,可以与灯泡队列的当前大小相结合,以确定是否需要更多消费者。

使用者实现如下所示:

private static class Consumer implements Runnable {

  @Override
  public void run() {

    while (runningConsumer && queue.size() > 0
                           || nrOfConsumers.get() == 1) {
      try {
        String bulb = queue.poll(MAX_PROD_TIME_MS 
           + extraProdTime, TimeUnit.MILLISECONDS);

        if (bulb != null) {
          Thread.sleep(rnd.nextInt(MAX_CONS_TIME_MS));
          logger.info(() -> "Packed: " + bulb + " by consumer: " 
            + Thread.currentThread().getName());
        }
      } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
        logger.severe(() -> "Exception: " + ex);
        break;
      }
    }

    nrOfConsumers.decrementAndGet();
    logger.warning(() -> "### Thread " +
      Thread.currentThread().getName() 
        + " is going back to the pool in 60 seconds for now!");
  }
}

假设装配线正在运行,只要队列不是空的或者他们是剩下的唯一消费者(我们不能有 0 个消费者),消费者就会继续打包灯泡。我们可以解释为,空队列意味着有太多的消费者。因此,当使用者看到队列为空并且他们不是唯一的工作使用者时,他们将变为空闲(60 秒后,他们将自动从缓存的线程池中删除)。

不要混淆nrOfConsumersthreadGroup.activeCount()nrOfConsumers变量存储当前打包灯泡的使用者(线程)的数量,而threadGroup.activeCount()表示所有活动使用者(线程),包括那些当前不工作(空闲)并且正等待从缓存中重用或调度的使用者(线程)。

现在,在一个真实的案例中,一个主管将监控装配线,当他们注意到当前数量的消费者无法面对即将到来的涌入时,他们将调用更多的消费者加入(最多允许 50 个消费者)。此外,当他们注意到一些消费者只是停留在附近,他们会派遣他们到其他工作。下图是此场景的图形表示:

出于测试目的,我们的监管者newSingleThreadScheduledExecutor()将是一个单线程执行器,可以调度给定的命令在指定的延迟后运行。它还可以定期执行命令:

private static final int MAX_NUMBER_OF_CONSUMERS = 50;
private static final int MAX_QUEUE_SIZE_ALLOWED = 5;
private static final int MONITOR_QUEUE_INITIAL_DELAY_MS = 5000;
private static final int MONITOR_QUEUE_RATE_MS = 3000;
private static ScheduledExecutorService monitorService;
...
private static void monitorQueueSize() {

  monitorService = Executors.newSingleThreadScheduledExecutor();

  monitorService.scheduleAtFixedRate(() -> {
    if (queue.size() > MAX_QUEUE_SIZE_ALLOWED 
        && threadGroup.activeCount() < MAX_NUMBER_OF_CONSUMERS) {
      logger.warning("### Adding a new consumer (command) ...");

      nrOfConsumers.incrementAndGet();
      consumerService.execute(consumer);
    }

    logger.warning(() -> "### Bulbs in queue: " + queue.size() 
      + " | Active threads: " + threadGroup.activeCount() 
      + " | Consumers: " + nrOfConsumers.get() 
      + " | Idle: " + (threadGroup.activeCount() 
        - nrOfConsumers.get()));
  }, MONITOR_QUEUE_INITIAL_DELAY_MS, MONITOR_QUEUE_RATE_MS,
        TimeUnit.MILLISECONDS);
}

我们依靠scheduleAtFixedRate()每 3 秒监控一次装配线,初始延迟 5 秒。因此,每三秒,主管检查一次灯泡队列大小。如果排队的灯泡超过 5 个,消费者少于 50 个,主管会要求新的消费者加入装配线。如果队列包含 5 个或更少的灯泡,或者已经有 50 个消费者,则主管不会采取任何行动。

如果我们现在开始装配线,我们可以看到消费者的数量是如何增加的,直到队列大小小于 6。可能的快照如下所示:

Starting assembly line ...
[11:53:20] [INFO] Checked: bulb-488
...
[11:53:24] [WARNING] ### Adding a new consumer (command) ...
[11:53:24] [WARNING] ### Bulbs in queue: 7 
                       | Active threads: 2 
                       | Consumers: 2 
                       | Idle: 0
[11:53:25] [INFO] Checked: bulb-738
...
[11:53:36] [WARNING] ### Bulbs in queue: 23 
                       | Active threads: 6
                       | Consumers: 6
                       | Idle: 0
...

当线程数超过需要时,其中一些线程将变为空闲线程。如果在 60 秒内没有收到作业,则会将其从缓存中删除。如果作业在没有空闲线程时发生,则将创建一个新线程。这个过程不断重复,直到我们注意到装配线上的平衡。过了一段时间,事情开始平静下来,适当数量的消费者会在一个小范围内(小波动)。这是因为生产者输出的速度是随机的,最大值为 1 秒。

一段时间后(例如,20 秒后),让我们将生产者的速度降低 4 秒(这样,灯泡现在最多可以在 5 秒钟内检查):

private static final int SLOW_DOWN_PRODUCER_MS = 20 * 1000;
private static final int EXTRA_TIME_MS = 4 * 1000;

这可以使用另一个newSingleThreadScheduledExecutor()来完成,如下所示:

private static void slowdownProducer() {

  slowdownerService = Executors.newSingleThreadScheduledExecutor();

  slowdownerService.schedule(() -> {
    logger.warning("### Slow down producer ...");
    extraProdTime = EXTRA_TIME_MS;
  }, SLOW_DOWN_PRODUCER_MS, TimeUnit.MILLISECONDS);
}

这只会发生一次,在装配线启动 20 秒后。由于生产者的速度降低了 4 秒,因此不需要有相同数量的消费者来维持最多 5 个灯泡的队列。

输出中显示了这一点,如图所示(请注意,有时只需要一个使用者来处理队列):

...
[11:53:36] [WARNING] ### Bulbs in queue: 23 
                       | Active threads: 6
                       | Consumers: 6
                       | Idle: 0
...
[11:53:39] [WARNING] ### Slow down producer ...
...
[11:53:56] [WARNING] ### Thread Thread-5 is going
                         back to the pool in 60 seconds for now!
[11:53:56] [INFO] Packed: bulb-346 by consumer: Thread-2
...
[11:54:36] [WARNING] ### Bulbs in queue: 1 
                       | Active threads: 12
                       | Consumers: 1
                       | Idle: 11
...
[11:55:48] [WARNING] ### Bulbs in queue: 3 
                       | Active threads: 1
                       | Consumers: 1 
                       | Idle: 0
...
Assembling line was successfully stopped!

在启动装配线后启动主管:

public static void startAssemblyLine() {
  ...
  monitorQueueSize();
  slowdownProducer();
}

完整的应用可以在与本书捆绑的代码中使用。

使用缓存线程池时,请注意为容纳提交的任务而创建的线程数。对于单线程池和固定线程池,我们控制创建的线程数,而缓存池可以决定创建太多的线程。基本上,不可控地创建线程可能会很快耗尽资源。因此,在容易过载的系统中,最好依赖固定线程池。

205 偷工线程池

让我们关注打包过程,它应该通过一个窃取工作的线程池来实现。首先,让我们讨论什么是偷工作线程池,并通过与经典线程池的比较来实现。下图描述了经典线程池的工作原理:

因此,线程池依赖于内部入站队列来存储任务。每个线程必须将一个任务出列并执行它。这适用于任务耗时且数量相对较少的情况。另一方面,如果这些任务多而小(它们需要很少的时间来执行),也会有很多争论。这是不好的,即使这是一个无锁队列,问题也没有完全解决。

为了减少争用并提高性能,线程池可以依赖于工作窃取算法和每个线程的队列。在这种情况下,所有任务都有一个中心入站队列,每个线程(工作线程)都有一个额外的队列(称为本地任务队列),如下图所示:

因此,每个线程都会将任务从中心队列中出列,并将它们放入自己的队列中。每个线程都有自己的本地任务队列。此外,当一个线程想要处理一个任务时,它只是将一个任务从它自己的本地队列中取出。只要它的本地队列不是空的,线程就将继续处理来自它的任务,而不会影响其他线程(与其他线程没有冲突)。当其本地队列为空时(如上图中的线程 2 的情况),它尝试从属于其他线程的本地队列中窃取(通过工作窃取算法)任务(例如,线程 2线程 3 窃取任务)。如果找不到任何可窃取的内容,它将访问共享的中心入站队列。

每个本地队列实际上是一个 Deque(简称双向队列),因此可以从两端高效访问。线程将其双向队列视为一个栈,这意味着它将只从一端排队(添加新任务)和出列(获取要处理的任务)。另一方面,当一个线程试图从另一个线程的队列中窃取时,它将访问另一端(例如,线程 2 从另一端从线程 3 队列中窃取)。因此,任务从一端处理,从另一端窃取。

如果两个线程试图从同一个本地队列中窃取数据,那么就存在争用,但通常情况下这应该是无关紧要的。

我们刚才描述的是 JDK7 中引入的 Fork/Join 框架,“Fork/Join 框架”部分举例说明。从 JDK8 开始,Executors类通过使用可用处理器的数量作为其目标并行级别的工作窃取线程池进行了丰富。可通过Executors.newWorkStealingPool()Executors.newWorkStealingPool​(int parallelism)获取。

让我们看看这个线程池的源代码:

public static ExecutorService newWorkStealingPool() {

  return new ForkJoinPool(Runtime.getRuntime().availableProcessors(),
    ForkJoinPool.defaultForkJoinWorkerThreadFactory,
      null, true);
}

因此,在内部,这个线程池通过以下构造器实例化ForkJoinPool

public ForkJoinPool​(int parallelism,
  ForkJoinPool.ForkJoinWorkerThreadFactory factory,
  Thread.UncaughtExceptionHandler handler,
  boolean asyncMode)

我们将并行级别设置为availableProcessors(),返回新线程的默认线程工厂Thread.UncaughtExceptionHandler,作为null传递,asyncMode设置为true。将asyncMode设置为true意味着它授权本地先进先出FIFO)调度模式,用于分叉且从未连接的任务。在依赖工作线程仅处理事件样式异步任务的程序中,此模式可能比默认模式(基于本地栈)更合适。

不过,不要忘记,只有当工作线程在自己的本地队列中调度新任务时,本地任务队列和工作窃取算法才被授权。否则,ForkJoinPool只是一个额外开销的ThreadPoolExecutor

当我们直接使用ForkJoinPool时,我们可以使用ForkJoinTask(通常通过RecursiveTaskRecursiveAction指示任务在执行期间显式地调度新任务。

但是由于newWorkStealingPool()ForkJoinPool的更高抽象级别,我们不能指示任务在执行期间显式地调度新任务。因此,newWorkStealingPool()将根据我们通过的任务在内部决定如何工作。我们可以尝试比较一下newWorkStealingPool()newCachedThreadPool()newFixedThreadPool(),看看它们在两种情况下的表现:

  • 对于大量的小任务
  • 对于少量耗时的任务

在下一节中,我们来看看这两种场景的解决方案。

大量的小任务

由于生产者(检查器)和消费者(打包器)不同时工作,我们可以通过一个简单的for循环(我们对装配线的这部分不太感兴趣)轻松地用 15000000 个灯泡填满一个队列。这在以下代码段中显示:

private static final Random rnd = new Random();
private static final int MAX_PROD_BULBS = 15_000_000;
private static final BlockingQueue<String> queue 
  = new LinkedBlockingQueue<>();
...
private static void simulatingProducers() {
  logger.info("Simulating the job of the producers overnight ...");
  logger.info(() -> "The producers checked " 
    + MAX_PROD_BULBS + " bulbs ...");

  for (int i = 0; i < MAX_PROD_BULBS; i++) {
    queue.offer("bulb-" + rnd.nextInt(1000));
  }
}

此外,让我们创建一个默认的工作线程池:

private static ExecutorService consumerService 
  = Executors.newWorkStealingPool();

为了进行比较,我们还将使用以下线程池:

  • 缓存的线程池:
private static ExecutorService consumerService 
  = Executors.newCachedThreadPool();
  • 使用可用处理器数作为线程数的固定线程池(默认工作线程池使用处理器数作为并行级别):
private static final Consumer consumer = new Consumer();
private static final int PROCESSORS 
  = Runtime.getRuntime().availableProcessors();
private static ExecutorService consumerService 
  = Executors.newFixedThreadPool(PROCESSORS);

让我们开始 15000000 个小任务:

for (int i = 0; i < queueSize; i++) {
  consumerService.execute(consumer);
}

Consumer包装了一个简单的queue.poll()操作,因此它应该运行得非常快,如下面的代码片段所示:

private static class Consumer implements Runnable {

  @Override
  public void run() {
    String bulb = queue.poll();

    if (bulb != null) {
      // nothing
    }
  }
}

下图显示了 10 次运行的收集数据:

即使这不是一个专业的基准测试,我们也可以看到工作线程池获得了最好的结果,而缓存线程轮询的结果更差。

少量的耗时任务

与其让一个队列装满 15000000 个灯泡,不如让我们让 15 个队列装满 1000000 个灯泡:

private static final int MAX_PROD_BULBS = 15 _000_000;
private static final int CHUNK_BULBS = 1 _000_000;
private static final Random rnd = new Random();
private static final Queue<BlockingQueue<String>> chunks 
  = new LinkedBlockingQueue<>();
...
private static Queue<BlockingQueue<String>> simulatingProducers() {
  logger.info("Simulating the job of the producers overnight ...");
  logger.info(() -> "The producers checked " 
    + MAX_PROD_BULBS + " bulbs ...");

  int counter = 0;
  while (counter < MAX_PROD_BULBS) {
    BlockingQueue chunk = new LinkedBlockingQueue<>(CHUNK_BULBS);

    for (int i = 0; i < CHUNK_BULBS; i++) {
      chunk.offer("bulb-" + rnd.nextInt(1000));
    }

    chunks.offer(chunk);
    counter += CHUNK_BULBS;
  }

  return chunks;
}

让我们使用以下代码启动 15 个任务:

while (!chunks.isEmpty()) {
  Consumer consumer = new Consumer(chunks.poll());
  consumerService.execute(consumer);
}

每个Consumer循环 1000000 个灯泡,使用此代码:

private static class Consumer implements Runnable {

  private final BlockingQueue<String> bulbs;

  public Consumer(BlockingQueue<String> bulbs) {
    this.bulbs = bulbs;
  }

  @Override
  public void run() {
    while (!bulbs.isEmpty()) {
      String bulb = bulbs.poll();

      if (bulb != null) {}
    }
  }
}

下图显示了 10 次运行的收集数据:

这一次,工作线程池看起来像一个常规线程池。

206 CallableFuture

这个问题重复了“线程池中具有单个线程”部分的场景。我们需要一个单一的生产者和消费者遵循以下场景:

  1. 一个自动系统向生产者发出一个请求,说,检查这个灯泡,如果没有问题,就把它还给我,否则告诉我这个灯泡出了什么问题

  2. 自动系统等待生产者检查灯泡。

  3. 当自动系统接收到检查过的灯泡时,它会进一步传递给耗电元件(打包机)并重复此过程。

  4. 如果灯泡有缺陷,生产者抛出异常(DefectBulbException),自动系统将检查问题的原因。

该场景如下图所示:

为了形成这个场景,生产者应该能够返回一个结果并抛出一个异常。因为我们的制作人是Runnable,所以这两个都做不到。但是 Java 定义了一个名为Callable的接口。这是一个函数式接口,其方法名为call()。与Runnablerun()方法相比,call()方法可以返回结果,甚至抛出异常V call() throws Exception

这意味着生产者(检查者)可以写为:

private static volatile boolean runningProducer;
private static final int MAX_PROD_TIME_MS = 5 * 1000;
private static final Random rnd = new Random();
...
private static class Producer implements Callable {

  private final String bulb;

  private Producer(String bulb) {
    this.bulb = bulb;
  }

  @Override
  public String call() 
      throws DefectBulbException, InterruptedException {

    if (runningProducer) {
      Thread.sleep(rnd.nextInt(MAX_PROD_TIME_MS));

      if (rnd.nextInt(100) < 5) {
        throw new DefectBulbException("Defect: " + bulb);
      } else {
        logger.info(() -> "Checked: " + bulb);
      }

      return bulb;
    }

    return "";
  }
}

执行者服务可以通过submit()方法向Callable提交任务,但不知道提交任务的结果何时可用。因此,Callable立即返回一个特殊类型,名为Future。异步计算的结果由Future表示,通过Future可以在任务可用时获取任务结果。从概念上讲,我们可以将Future看作 JavaScript Promise,或者是在稍后时间点进行的计算的结果。现在,我们创建一个Producer提交给Callable

String bulb = "bulb-" + rnd.nextInt(1000);
Producer producer = new Producer(bulb);

Future<String> bulbFuture = producerService.submit(producer);
// this line executes immediately

由于Callable会立即返回一个Future,所以我们可以在等待提交任务结果的同时执行其他任务(如果该任务完成,isDone()标志方法返回true):

while (!future.isDone()) {
  System.out.println("Do something else ...");
}

检索Future的结果可以使用阻塞方法Future.get()来完成。此方法将阻塞,直到结果可用或指定的超时已过(如果在超时之前结果不可用,则抛出一个TimeoutException

String checkedBulb = bulbFuture.get(
  MAX_PROD_TIME_MS + 1000, TimeUnit.MILLISECONDS);

// this line executes only after the result is available

一旦得到结果,我们就可以将其传递给Consumer,并向Producer提交另一个任务。只要消费者和生产者都在运行,这个循环就会重复。其代码如下:

private static void automaticSystem() {

  while (runningProducer &amp;&amp; runningConsumer) {
    String bulb = "bulb-" + rnd.nextInt(1000);

    Producer producer = new Producer(bulb);
    Future<String> bulbFuture = producerService.submit(producer);
    ...
    String checkedBulb = bulbFuture.get(
      MAX_PROD_TIME_MS + 1000, TimeUnit.MILLISECONDS);

    Consumer consumer = new Consumer(checkedBulb);
    if (runningConsumer) {
      consumerService.execute(consumer);
    }
  }
  ...
}

Consumer仍然是Runnable,因此不能返回结果或抛出异常:

private static final int MAX_CONS_TIME_MS = 3 * 1000;
...
private static class Consumer implements Runnable {

  private final String bulb;

  private Consumer(String bulb) {
    this.bulb = bulb;
  }

  @Override
  public void run() {
    if (runningConsumer) {
      try {
        Thread.sleep(rnd.nextInt(MAX_CONS_TIME_MS));
        logger.info(() -> "Packed: " + bulb);
      } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
        logger.severe(() -> "Exception: " + ex);
      }
    }
  }
}

最后,我们需要启动自动系统。其代码如下:

public static void startAssemblyLine() {
  ...
  runningProducer = true;
  consumerService = Executors.newSingleThreadExecutor();

  runningConsumer = true;
  producerService = Executors.newSingleThreadExecutor();

  new Thread(() -> {
    automaticSystem();
  }).start();
}

注意,我们不想阻塞主线程,因此我们在一个新线程中启动自动系统。这样主线程就可以控制装配线的启停过程。

让我们运行装配线几分钟来收集一些输出:

Starting assembly line ...
[08:38:41] [INFO ] Checked: bulb-879
...
[08:38:52] [SEVERE ] Exception: DefectBulbException: Defect: bulb-553
[08:38:53] [INFO ] Packed: bulb-305
...

好了,任务完成了!让我们来讨论最后一个话题。

取消Future

Future可以取消。这是使用cancel​(boolean mayInterruptIfRunning)方法完成的。如果我们将其作为true传递,则执行该任务的线程被中断,否则,该线程可以完成该任务。如果任务取消成功,则返回true,否则返回false(通常是因为任务已经正常完成)。下面是一个简单的示例,用于在运行任务所需时间超过 1 秒时取消该任务:

long startTime = System.currentTimeMillis();

Future<String> future = executorService.submit(() -> {
  Thread.sleep(3000);

  return "Task completed";
});

while (!future.isDone()) {
  System.out.println("Task is in progress ...");
  Thread.sleep(100);

  long elapsedTime = (System.currentTimeMillis() - startTime);

  if (elapsedTime > 1000) {
    future.cancel(true);
  }
}

如果任务在正常完成前被取消,isCancelled()方法返回true

System.out.println("Task was cancelled: " + future.isCancelled() 
  + "\nTask is done: " + future.isDone());

输出如下:

Task is in progress ...
Task is in progress ...
...
Task was cancelled: true
Task is done: true

以下是一些额外的例子:

  • 使用Callable和 Lambda:
Future<String> future = executorService.submit(() -> {
  return "Hello to you!";
});
  • 获取通过Executors.callable​(Runnable task)返回nullCallable
Callable<Object> callable = Executors.callable(() -> {
  System.out.println("Hello to you!");
});

Future<Object> future = executorService.submit(callable);
  • 通过Executors.callable​(Runnable task, T result)获取返回结果(TCallable
Callable<String> callable = Executors.callable(() -> {
  System.out.println("Hello to you!");
}, "Hi");

Future<String> future = executorService.submit(callable);

207 调用多个可调用任务

由于生产者(检查器)不与消费者(打包器)同时工作,我们可以通过一个for来模拟他们的工作,这个for在一个队列中添加 100 个选中的灯泡:

private static final BlockingQueue<String> queue 
  = new LinkedBlockingQueue<>();
...
private static void simulatingProducers() {

  for (int i = 0; i < MAX_PROD_BULBS; i++) {
    queue.offer("bulb-" + rnd.nextInt(1000));
  }
}

现在,消费者必须将每个灯泡打包并退回。这意味着ConsumerCallable

private static class Consumer implements Callable {

  @Override
  public String call() throws InterruptedException {
    String bulb = queue.poll();

    Thread.sleep(100);

    if (bulb != null) {
      logger.info(() -> "Packed: " + bulb + " by consumer: " 
        + Thread.currentThread().getName());

      return bulb;
    }

    return "";
  }
}

但是请记住,我们应该提交所有的任务并等待它们全部完成。这可以通过ExecutorService.invokeAll()方法实现。此方法接受任务集合(Collection<? extends Callable<T>>),并返回FutureList<Future<T>>的实例列表作为参数。对Future.get()的任何调用都将被阻止,直到Future的所有实例都完成。

因此,首先我们创建一个包含 100 个任务的列表:

private static final Consumer consumer = new Consumer();
...
List<Callable<String>> tasks = new ArrayList<>();
for (int i = 0; i < queue.size(); i++) {
  tasks.add(consumer);
}

进一步,我们执行所有这些任务并得到Future的列表:

private static ExecutorService consumerService
  = Executors.newWorkStealingPool();
...
List<Future<String>> futures = consumerService.invokeAll(tasks);

最后,我们处理(在本例中,显示)结果:

for (Future<String> future: futures) {
  String bulb = future.get();
  logger.info(() -> "Future done: " + bulb);
}

请注意,对future.get()语句的第一次调用将阻塞直到所有的Future实例都完成。这将导致以下输出:

[12:06:41] [INFO] Packed: bulb-595 by consumer: ForkJoinPool-1-worker-9
...
[12:06:42] [INFO] Packed: bulb-478 by consumer: ForkJoinPool-1-worker-15
[12:06:43] [INFO] Future done: bulb-595
...

有时,我们需要提交几个任务,然后等待其中任何一个任务完成。这可以通过ExecutorService.invokeAny()实现。与invokeAll()完全一样,此方法获取一组任务(Collection<? extends Callable<T>>作为参数)。但它返回最快任务的结果(不是一个Future),并取消所有其他尚未完成的任务,例如:

String bulb = consumerService.invokeAny(tasks);

如果您不想等待所有Future完成,请按以下步骤进行:

int queueSize = queue.size();
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < queueSize; i++) {
  futures.add(consumerService.submit(consumer));
}

for (Future<String> future: futures) {
  String bulb = future.get();
  logger.info(() -> "Future done: " + bulb);
}

在所有任务完成之前,这不会阻塞。请看以下输出示例:

[12:08:56] [INFO ] Packed: bulb-894 by consumer: ForkJoinPool-1-worker-7
[12:08:56] [INFO ] Future done: bulb-894
[12:08:56] [INFO ] Packed: bulb-953 by consumer: ForkJoinPool-1-worker-5
...

208 锁存器

锁存器是一个 Java 同步器,它允许一个或多个线程等待其他线程中的一组事件完成。它从给定的计数器开始(通常表示应该等待的事件数),完成的每个事件负责递减计数器。当计数器达到零时,所有等待的线程都可以通过。这是锁存器的终端状态。锁存器不能重置或重用,因此等待的事件只能发生一次。下图分四个步骤显示了具有三个线程的锁存器的工作原理:

在 API 术语中,锁存器是使用java.util.concurrent.CountDownLatch实现的。

初始计数器在CountDownLatch构造器中设置为整数。例如,计数器等于3CountDownLatch可以定义为:

CountDownLatch latch = new CountDownLatch(3);

所有调用await()方法的线程都将被阻塞,直到计数器达到零。因此,一个线程要被阻塞直到锁存器达到终端状态,它将调用await()。每个完成的事件都可以调用countDown()方法。此方法用一个值递减计数器。在计数器变为零之前,调用await()的线程仍然被阻塞。

锁存器可用于各种各样的问题。现在,让我们集中讨论应该模拟启动服务器过程的问题。服务器在其内部服务启动后被视为已启动。服务可以同时启动并且相互独立。启动服务器需要一段时间,需要我们启动该服务器的所有底层服务。因此,完成并验证服务器启动的线程应该等到其他线程中的所有服务器服务(事件)都已启动。如果我们假设我们有三个服务,我们可以编写一个ServerService类,如下所示:

public class ServerInstance implements Runnable {

  private static final Logger logger =
    Logger.getLogger(ServerInstance.class.getName());

  private final CountDownLatch latch = new CountDownLatch(3);

  @Override
  public void run() {
    logger.info("The server is getting ready to start ");
    logger.info("Starting services ...\n");

    long starting = System.currentTimeMillis();

    Thread service1 = new Thread(
      new ServerService(latch, "HTTP Listeners"));
    Thread service2 = new Thread(
      new ServerService(latch, "JMX"));
    Thread service3 = new Thread(
      new ServerService(latch, "Connectors"));

    service1.start();
    service2.start();
    service3.start();

    try {
      latch.await();
      logger.info(() -> "Server has successfully started in " 
        + (System.currentTimeMillis() - starting) / 1000 
        + " seconds");
    } catch (InterruptedException ex) {
      Thread.currentThread().interrupt();
      // log ex
    }
  }
}

首先,我们定义一个计数器为 3 的CountDownLatch。其次,我们在三个不同的线程中启动服务。最后,我们通过await()阻塞这个线程。现在,下面的类通过随机睡眠模拟服务的启动过程:

public class ServerService implements Runnable {

  private static final Logger logger =
    Logger.getLogger(ServerService.class.getName());

  private final String serviceName;
  private final CountDownLatch latch;
  private final Random rnd = new Random();

  public ServerService(CountDownLatch latch, String serviceName) {
    this.latch = latch;
    this.serviceName = serviceName;
  }

  @Override
  public void run() {

    int startingIn = rnd.nextInt(10) * 1000;

    try {
      logger.info(() -> "Starting service '" + serviceName + "' ...");

      Thread.sleep(startingIn);

      logger.info(() -> "Service '" + serviceName 
        + "' has successfully started in " 
        + startingIn / 1000 + " seconds");

    } catch (InterruptedException ex) {
      Thread.currentThread().interrupt();
      // log ex
    } finally {
      latch.countDown();

      logger.info(() -> "Service '" + serviceName + "' running ...");
    }
  }
}

每个启动成功(或失败)的服务将通过countDown()减少锁存。一旦计数器达到零,服务器就被认为已启动。我们称之为:

Thread server = new Thread(new ServerInstance());
server.start();

以下是可能的输出:

[08:49:17] [INFO] The server is getting ready to start

[08:49:17] [INFO] Starting services ...
[08:49:17] [INFO] Starting service 'JMX' ...
[08:49:17] [INFO] Starting service 'Connectors' ...
[08:49:17] [INFO] Starting service 'HTTP Listeners' ...

[08:49:22] [INFO] Service 'HTTP Listeners' started in 5 seconds
[08:49:22] [INFO] Service 'HTTP Listeners' running ...
[08:49:25] [INFO] Service 'JMX' started in 8 seconds
[08:49:25] [INFO] Service 'JMX' running ...
[08:49:26] [INFO] Service 'Connectors' started in 9 seconds
[08:49:26] [INFO] Service 'Connectors' running ...

[08:49:26] [INFO] Server has successfully started in 9 seconds

为了避免不确定的等待,CountDownLatch类具有接受超时的await()风格await​(long timeout, TimeUnit unit)。如果在计数为零之前等待时间已过,则此方法返回false

209 屏障

屏障是一种 Java 同步器,它允许一组线程(称为到达共同的屏障点。基本上,一组线程在屏障处等待彼此相遇。就像一帮朋友决定一个会议点,当他们都明白这一点时,他们会走得更远。他们不会离开会议地点,直到他们所有人都到了,或者直到他们觉得他们已经等了太久。

对于依赖于可划分为子任务的任务的问题,此同步器工作得很好。每个子任务在不同的线程中运行,并等待其余的线程。当所有线程完成时,它们将结果合并为一个结果。

下图显示了具有三个线程的屏障流的示例:

在 API 术语中,屏障是使用java.util.concurrent.CyclicBarrier实现的。

一个CyclicBarrier可以通过两个构造器来构造:

  • 其中一个允许我们指定参与方的数量(这是一个整数)
  • 另一个允许我们添加一个动作,该动作应该在各方都到达障碍后发生(这是一个Runnable

此操作在参与方中的所有线程到达时发生,但在释放任何线程之前发生。

当线程准备在屏障处等待时,它只调用await()方法。此方法可以无限期地等待或直到指定的超时(如果指定的超时已过或线程被中断,则用一个TimeoutException释放此线程;屏障被认为已损坏,屏障处所有等待的线程都用一个BrokenBarrierException释放)。我们可以通过getParties()方法找出需要多少方跳过此障碍,以及目前有多少方通过getNumberWaiting()方法在障碍处等待。

await()方法返回一个整数,表示当前线程的到达索引,其中索引getParties()-1 或 0 分别表示第一个或最后一个到达的线程。

假设我们要启动一个服务器。服务器在其内部服务启动后被视为已启动。服务可以同时启动(这很耗时),但它们是相互依赖的,因此,一旦准备好启动,就必须一次启动所有服务。

因此,每个服务都可以准备在单独的线程中启动。一旦准备好启动,线程将在屏障处等待其余的服务。当他们都准备好出发时,他们就越过障碍开始奔跑。让我们考虑三种服务,CyclicBarrier可以定义如下:

Runnable barrierAction
  = () -> logger.info("Services are ready to start ...");

CyclicBarrier barrier = new CyclicBarrier(3, barrierAction);

让我们通过三个线程来准备服务:

public class ServerInstance implements Runnable {

  private static final Logger logger
    = Logger.getLogger(ServerInstance.class.getName());

  private final Runnable barrierAction
    = () -> logger.info("Services are ready to start ...");

  private final CyclicBarrier barrier 
    = new CyclicBarrier(3, barrierAction);

  @Override
  public void run() {
    logger.info("The server is getting ready to start ");
    logger.info("Starting services ...\n");

    long starting = System.currentTimeMillis();

    Thread service1 = new Thread(
      new ServerService(barrier, "HTTP Listeners"));
    Thread service2 = new Thread(
      new ServerService(barrier, "JMX"));
    Thread service3 = new Thread(
      new ServerService(barrier, "Connectors"));

    service1.start();
    service2.start();
    service3.start();

    try {
      service1.join();
      service2.join();
      service3.join();

      logger.info(() -> "Server has successfully started in " 
        + (System.currentTimeMillis() - starting) / 1000 
        + " seconds");
    } catch (InterruptedException ex) {
      Thread.currentThread().interrupt();
      logger.severe(() -> "Exception: " + ex);
    }
  }
}

ServerService负责准备每一项服务启动,并通过await()将其阻塞在屏障上:

public class ServerService implements Runnable {

  private static final Logger logger =
    Logger.getLogger(ServerService.class.getName());

  private final String serviceName;
  private final CyclicBarrier barrier;
  private final Random rnd = new Random();

  public ServerService(CyclicBarrier barrier, String serviceName) {
    this.barrier = barrier;
    this.serviceName = serviceName;
  }

  @Override
  public void run() {

    int startingIn = rnd.nextInt(10) * 1000;

    try {
      logger.info(() -> "Preparing service '" 
        + serviceName + "' ...");

      Thread.sleep(startingIn);
      logger.info(() -> "Service '" + serviceName 
        + "' was prepared in " + startingIn / 1000 
        + " seconds (waiting for remaining services)");

      barrier.await();

      logger.info(() -> "The service '" + serviceName 
        + "' is running ...");
    } catch (InterruptedException ex) {
      Thread.currentThread().interrupt();
      logger.severe(() -> "Exception: " + ex);
    } catch (BrokenBarrierException ex) {
      logger.severe(() -> "Exception ... barrier is broken! " + ex);
    }
  }
}

现在,让我们运行它:

Thread server = new Thread(new ServerInstance());
server.start();

下面是一个可能的输出(请注意线程是如何被释放以跨越屏障的):

[10:38:34] [INFO] The server is getting ready to start

[10:38:34] [INFO] Starting services ...
[10:38:34] [INFO] Preparing service 'Connectors' ...
[10:38:34] [INFO] Preparing service 'JMX' ...
[10:38:34] [INFO] Preparing service 'HTTP Listeners' ...

[10:38:35] [INFO] Service 'HTTP Listeners' was prepared in 1 seconds
                  (waiting for remaining services)
[10:38:36] [INFO] Service 'JMX' was prepared in 2 seconds
                  (waiting for remaining services)
[10:38:38] [INFO] Service 'Connectors' was prepared in 4 seconds
                  (waiting for remaining services)

[10:38:38] [INFO] Services are ready to start ...

[10:38:38] [INFO] The service 'Connectors' is running ...
[10:38:38] [INFO] The service 'HTTP Listeners' is running ...
[10:38:38] [INFO] The service 'JMX' is running ...

[10:38:38] [INFO] Server has successfully started in 4 seconds

CyclicBarrier是循环的,因为它可以重置和重用。为此,请在释放所有等待屏障的线程后调用reset()方法,否则会抛出BrokenBarrierException

处于已损坏状态的屏障将导致isBroken()标志方法返回true

210 交换器

交换器是一个 Java 同步器,它允许两个线程在一个交换点或同步点交换对象。

主要是这种同步器起到了屏障作用。两个线程在一个屏障处互相等待。他们交换一个对象,并在两个到达时继续他们通常的任务。

下图分四个步骤描述了交换器的流量:

在 API 术语中,这个同步器是由java.util.concurrent.Exchanger公开的。

一个Exchanger可以通过一个空构造器创建,并公开了两个exchange()方法:

  • 只得到它将提供的对象的人
  • 获得超时的线程(在另一个线程进入交换之前,如果经过指定的等待时间,将抛出一个TimeoutException)。

还记得我们的灯泡装配线吗?好吧,假设生产者(检查者)将检查过的灯泡添加到篮子中(例如,List<String>。当篮子满了,生产者将其与消费者(包装机)交换为空篮子(例如,另一个List<String>。只要装配线正在运行,该过程就会重复。

下图表示此流程:

所以,首先我们需要Exchanger

private static final int BASKET_CAPACITY = 5;
...
private static final Exchanger<List<String>> exchanger 
  = new Exchanger<>();

生产者装满篮子,在交换点等待消费者:

private static final int MAX_PROD_TIME_MS = 2 * 1000;
private static final Random rnd = new Random();
private static volatile boolean runningProducer;
...
private static class Producer implements Runnable {

  private List<String> basket = new ArrayList<>(BASKET_CAPACITY);

  @Override
  public void run() {

    while (runningProducer) {
      try {
        for (int i = 0; i < BASKET_CAPACITY; i++) {

          String bulb = "bulb-" + rnd.nextInt(1000);
          Thread.sleep(rnd.nextInt(MAX_PROD_TIME_MS));
          basket.add(bulb);

          logger.info(() -> "Checked and added in the basket: " 
            + bulb);
        }

        logger.info("Producer: Waiting to exchange baskets ...");

        basket = exchanger.exchange(basket);
      } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
        logger.severe(() -> "Exception: " + ex);
        break;
      }
    }
  }
}

另一方面,消费者在交换点等待从生产者那里收到装满灯泡的篮子,然后给出一个空的篮子作为交换。此外,当生产者再次装满篮子时,消费者从收到的篮子中包装灯泡。完成后,他们将再次前往兑换点等待另一个满满的篮子。因此,Consumer可以写成:

private static final int MAX_CONS_TIME_MS = 5 * 1000;
private static final Random rnd = new Random();
private static volatile boolean runningConsumer;
...
private static class Consumer implements Runnable {

  private List<String> basket = new ArrayList<>(BASKET_CAPACITY);

  @Override
  public void run() {

    while (runningConsumer) {
      try {
        logger.info("Consumer: Waiting to exchange baskets ...");
        basket = exchanger.exchange(basket);
        logger.info(() -> "Consumer: Received the following bulbs: " 
          + basket);

        for (String bulb: basket) {
          if (bulb != null) {
            Thread.sleep(rnd.nextInt(MAX_CONS_TIME_MS));
            logger.info(() -> "Packed from basket: " + bulb);
          }
        }

        basket.clear();
      } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
        logger.severe(() -> "Exception: " + ex);
        break;
      }
    }
  }
}

为了简洁起见,代码的其余部分被省略了。

现在,让我们看看可能的输出:

Starting assembly line ...
[13:23:13] [INFO] Consumer: Waiting to exchange baskets ...
[13:23:15] [INFO] Checked and added in the basket: bulb-606
...
[13:23:18] [INFO] Producer: Waiting to exchange baskets ...
[13:23:18] [INFO] Consumer: Received the following bulbs:
[bulb-606, bulb-251, bulb-102, bulb-454, bulb-280]
[13:23:19] [INFO] Checked and added in the basket: bulb-16
...
[13:23:21] [INFO] Packed from basket: bulb-606
...

211 信号量

信号量是一个 Java 同步器,它允许我们控制在任何时候可以访问资源的线程数。从概念上讲,这个同步器管理一组许可(例如,类似于令牌)。需要访问资源的线程必须从同步器获得许可。在线程使用资源完成其工作之后,它必须通过将许可返回给信号量来释放它,以便另一个线程可以获取它。线程可以立即获取许可证(如果许可证是空闲的),可以等待一定的时间,或者可以等待直到许可证变为空闲。此外,一个线程一次可以获取和释放多个许可证,一个线程即使没有获取许可证也可以释放许可证。这将向信号量添加一个许可证;因此信号量可以从一个许可证数开始,然后从另一个许可证数结束。

在 API 术语中,这个同步器用java.util.concurrent.Semaphore表示。

创建一个Semaphore就像调用它的两个构造器中的一个一样简单:

  • public Semaphore​(int permits)
  • public Semaphore​(int permits, boolean fair)

一个公平的Semaphore保证 FIFO 在争议中授予许可。

可使用acquire()方法获得许可证。该过程可以用以下项目符号表示:

  • 如果没有参数,这个方法将从这个信号量获取一个许可,阻塞直到一个可用,或者线程被中断
  • 要获得多个许可证,请使用acquire​(int permits)
  • 要尝试获取许可证并立即返回标志值,请使用tryAcquire()tryAcquire​(int permits)
  • 要在给定的等待时间内等待一个线程变为可用(并且当前线程未被中断),请使用tryAcquire​(int permits, long timeout, TimeUnit unit)
  • 为了从这个信号机获得许可,可以通过acquireUninterruptibly()acquireUninterruptibly(int permits)获得阻塞直到一个可用
  • 要发布许可证,请使用release()

现在,在我们的场景中,理发店有三个座位,并以先进先出的方式为顾客服务。一位顾客试了五秒钟才坐下。最后,它释放了获得的座位。查看以下代码以了解如何获取和释放座椅:

public class Barbershop {

  private static final Logger logger =
    Logger.getLogger(Barbershop.class.getName());

  private final Semaphore seats;

  public Barbershop(int seatsCount) {
    this.seats = new Semaphore(seatsCount, true);
  }

  public boolean acquireSeat(int customerId) {
    logger.info(() -> "Customer #" + customerId 
      + " is trying to get a seat");

    try {
      boolean acquired = seats.tryAcquire(
        5 * 1000, TimeUnit.MILLISECONDS);

      if (!acquired) {
        logger.info(() -> "Customer #" + customerId 
          + " has left the barbershop");

        return false;
      }

      logger.info(() -> "Customer #" + customerId + " got a seat");

      return true;
    } catch (InterruptedException ex) {
      Thread.currentThread().interrupt();
      logger.severe(() -> "Exception: " + ex);
    }

    return false;
  }

  public void releaseSeat(int customerId) {
    logger.info(() -> "Customer #" + customerId 
      + " has released a seat");
    seats.release();
  }
}

如果在这五秒钟内没有座位被释放,那么这个人就离开理发店。另一方面,成功入座的顾客由理发师服务(这将需要 0 到 10 之间的随机秒数)。最后,客户松开座椅。在代码行中,可以按以下方式编写:

public class BarbershopCustomer implements Runnable {

  private static final Logger logger =
    Logger.getLogger(BarbershopCustomer.class.getName());
  private static final Random rnd = new Random();

  private final Barbershop barbershop;
  private final int customerId;

  public BarbershopCustomer(Barbershop barbershop, int customerId) {
    this.barbershop = barbershop;
    this.customerId = customerId;
  }

  @Override
  public void run() {

    boolean acquired = barbershop.acquireSeat(customerId);

    if (acquired) {
      try {
        Thread.sleep(rnd.nextInt(10 * 1000));
      } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
        logger.severe(() -> "Exception: " + ex);
      } finally {
        barbershop.releaseSeat(customerId);
      }
    } else {
      Thread.currentThread().interrupt();
    }
  }
}

让我们带 10 位顾客来我们的理发店:

Barbershop bs = new Barbershop(3);

for (int i = 1; i <= 10; i++) {
  BarbershopCustomer bc = new BarbershopCustomer(bs, i);
  new Thread(bc).start();
}

以下是可能输出的快照:

[16:36:17] [INFO] Customer #10 is trying to get a seat
[16:36:17] [INFO] Customer #5 is trying to get a seat
[16:36:17] [INFO] Customer #7 is trying to get a seat
[16:36:17] [INFO] Customer #5 got a seat
[16:36:17] [INFO] Customer #10 got a seat
[16:36:19] [INFO] Customer #10 has released a seat
...

许可证不是在线程级别获取的。

这意味着T1线程可以从Semaphore获得许可,而T2线程可以释放它。 当然,开发人员负责管理过程。

212 移相器

移相器是一种灵活的 Java 同步器,结合了CyclicBarrierCountDownLatch在以下上下文中的功能:

  • 一个移相器由一个或多个相位组成,这些相位充当动态数量的参与方(线程)的屏障。
  • 在移相器寿命期间,可以动态修改同步方(线程)的数量。我们可以注册/注销当事人。
  • 当前注册方必须在当前阶段(障碍)中等待,然后才能进入下一个执行步骤(下一阶段)-如CyclicBarrier的情况。
  • 移相器的每个相位可以通过从 0 开始的相关数字/索引来识别。第一阶段为 0,下一阶段为 1,下一阶段为 2,等至Integer.MAX_VALUE
  • 一个移相器的任何一个阶段都可以有三种类型的参与方:注册到达(这些是在当前阶段/关卡等待的注册方)和未到达(这些是在前往当前阶段的途中的注册方)。
  • 缔约方的动态计数器有三种类型:登记缔约方计数器、到达缔约方计数器和未完结缔约方计数器。当所有参与方到达当前阶段(注册参与方的数量等于到达参与方的数量)时,阶段器将进入下一阶段。
  • 或者,我们可以在进入下一个阶段之前(当所有各方到达阶段/关卡时)执行一个操作(代码片段)。
  • 移相器具有终止状态。注册方的计数不受终止的影响,但是在终止之后,所有同步方法立即返回,而不必等待进入另一个阶段。同样,在终止时尝试注册也没有效果。

在下图中,我们可以看到一个移相器,在相位 0 中有四个注册方,在相位 1 中有三个注册方。我们还将进一步讨论一些 API 风格:

通常,通过参与方,我们理解线程(一方=一个线程),但是移相器不执行参与方和特定线程之间的关联。一个移相器只是统计和管理注册方和注销方的数量。

在 API 术语中,这个同步器用java.util.concurrent.Phaser表示。

一个Phaser可以由零个参与方、一个通过空构造器的显式参与方数或一个采用整数参数Phaser​(int parties)的构造器创建。Phaser还可以通过Phaser​(Phaser parent)Phaser​(Phaser parent, int parties)指定父级。通常由一方启动Phaser,称为控制器或控制方。通常,这个聚会在Phaser寿命期内寿命最长。

一方可以通过register()方式随时注册(在上图中,在 0 期和 1 期之间,我们注册T5T6)。我们也可以通过bulkRegister​(int parties)注册一大批当事人。注册方可以通过arriveAndDeregister()取消注册,无需等待其他方。此方法允许一方到达当前屏障(Phaser)并取消注册,而无需等待其他方到达(在上图中,T4T3T2方逐一取消注册)。每个注销方减少一个注册方的数量。

为了达到当前阶段(障碍),等待其他方到达,需要调用arriveAndAwaitAdvance()方法。这种方法将阻止所有登记方到达当前阶段。一旦最后一注册方到达本阶段,各方将进入Phaser的下一阶段。

或者,当所有注册方到达当前阶段时,我们可以通过覆盖onAdvance()方法onAdvance​(int phase, int registeredParties)来运行特定操作。如果要触发Phaser的终止,则此方法返回一个boolean值,即true。另外,我们可以通过forceTermination()强制终止,也可以通过isTerminated()的标志方法进行测试。覆盖onAdvance()方法需要我们扩展Phaser类(通常通过匿名类)。

现在,我们应该有足够的细节来解决我们的问题。因此,我们必须在Phaser的三个阶段中模拟服务器的启动过程。服务器被认为是在其五个内部服务启动之后启动并运行的。在第一阶段,我们需要同时启动三个服务。在第二阶段,我们需要同时启动另外两个服务(只有在前三个服务已经运行的情况下才能启动)。在第三阶段,服务器执行最后一次签入,并被视为已启动并正在运行。

因此,管理服务器启动进程的线程(参与方)可以被视为控制其余线程(参与方)的线程。这意味着我们可以创建Phaser并通过Phaser构造器注册这个控制线程(或,控制器):

public class ServerInstance implements Runnable {

  private static final Logger logger =
    Logger.getLogger(ServerInstance.class.getName());

  private final Phaser phaser = new Phaser(1) {

    @Override
    protected boolean onAdvance(int phase, int registeredParties) {
      logger.warning(() -> "Phase:" + phase 
        + " Registered parties: " + registeredParties);

      return registeredParties == 0;
    }
  };
  ...
}

使用匿名类,我们创建这个Phaser对象并覆盖其onAdvance()方法来定义一个有两个主要目的的操作:

  • 打印当前阶段的快速状态和注册方的数量
  • 如果没有注册方,触发Phaser终止

当所有当前注册方到达当前屏障(当前阶段)时,将为每个阶段调用此方法。

管理服务器服务的线程需要启动这些服务并从Phaser注销它们自己。因此,每个服务在一个单独的线程中启动,该线程将在其作业结束时通过arriveAndDeregister()取消注册。为此,我们可以使用以下Runnable

public class ServerService implements Runnable {

  private static final Logger logger =
    Logger.getLogger(ServerService.class.getName());

  private final String serviceName;
  private final Phaser phaser;
  private final Random rnd = new Random();

  public ServerService(Phaser phaser, String serviceName) {
    this.phaser = phaser;
    this.serviceName = serviceName;
    this.phaser.register();
  }

  @Override
  public void run() {

    int startingIn = rnd.nextInt(10) * 1000;

    try {
      logger.info(() -> "Starting service '" + serviceName + "' ...");
      Thread.sleep(startingIn);
      logger.info(() -> "Service '" + serviceName 
        + "' was started in " + startingIn / 1000 
        + " seconds (waiting for remaining services)");
    } catch (InterruptedException ex) {
      Thread.currentThread().interrupt();
      logger.severe(() -> "Exception: " + ex);
    } finally {
      phaser.arriveAndDeregister();
    }
  }
}

现在,控制线程可以触发service1service2service3的启动进程。此过程按以下方法成形:

private void startFirstThreeServices() {

  Thread service1 = new Thread(
    new ServerService(phaser, "HTTP Listeners"));
  Thread service2 = new Thread(
    new ServerService(phaser, "JMX"));
  Thread service3 = new Thread(
    new ServerService(phaser, "Connectors"));

  service1.start();
  service2.start();
  service3.start();

  phaser.arriveAndAwaitAdvance(); // phase 0
}

注意,在这个方法的末尾,我们调用了phaser.arriveAndAwaitAdvance()。这是等待其他注册方到达的控制方。其余注册方(service1service2service3逐一注销,直至Phaser中只剩下控制方。此时,是时候进入下一阶段了。所以,控制方是唯一进入下一阶段的。

与此实现类似,控制线程可以触发service4service5的启动进程。此过程按以下方法成形:

private void startNextTwoServices() {

  Thread service4 = new Thread(
    new ServerService(phaser, "Virtual Hosts"));
  Thread service5 = new Thread(
    new ServerService(phaser, "Ports"));

  service4.start();
  service5.start();

  phaser.arriveAndAwaitAdvance(); // phase 1
}

最后,在这五个服务启动之后,控制线程执行最后一个检查,该检查在下面的方法中作为虚拟的Thread.sleep()实现。注意,在这个操作结束时,启动服务器的控制线程从Phaser注销了自己。当发生这种情况时,意味着不再有注册方,并且由于从onAdvance()方法返回true而终止Phaser

private void finalCheckIn() {

  try {
    logger.info("Finalizing process (should take 2 seconds) ...");
    Thread.sleep(2000);
  } catch (InterruptedException ex) {
    Thread.currentThread().interrupt();
    logger.severe(() -> "Exception: " + ex);
  } finally {
    phaser.arriveAndDeregister(); // phase 2
  }
}

控制线程的任务是按正确的顺序调用前面的三个方法。代码的其余部分由一些日志组成;因此为了简洁起见,跳过了它。本书附带了这个问题的完整源代码。

在任何时候,我们都可以通过getRegisteredParties()查询到注册方的数量,通过getArrivedParties()查询到到达方的数量,通过getUnarrivedParties()查询到未到达方的数量。您可能还需要检查arrive()awaitAdvance​(int phase)awaitAdvanceInterruptibly​(int phase)方法。

总结

本章概述了 Java 并发的主要坐标,应该为下一章做好准备。我们讨论了线程生命周期、对象级和类级锁定、线程池以及CallableFuture等几个基本问题

下载本章中的应用以查看结果并查看一些其他详细信息。

十一、并发-深入探索

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括涉及 Java 并发的 13 个问题,涉及 Fork/Join 框架、CompletableFutureReentrantLockReentrantReadWriteLockStampedLock、原子变量、任务取消、可中断方法、线程局部、死锁等方面。对于任何开发人员来说,并发性都是必需的主题之一,在工作面试中不能被忽视。这就是为什么这一章和最后一章如此重要。读完本章,您将对并发性有相当的了解,这是每个 Java 开发人员都需要的。

问题

使用以下问题来测试您的并发编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 可中断方法:编写一个程序,举例说明处理可中断方法的最佳方法。
  2. Fork/Join 框架:编写一个依赖 Fork/Join 框架对列表元素求和的程序。编写一个依赖 Fork/Join 框架的程序来计算给定位置的斐波那契数(例如,F(12) = 144)。另外,编写一个程序来举例说明CountedCompleter的用法。
  3. Fork/Join 和compareAndSetForkJoinTaskTag():编写一个程序,将 Fork/Join 框架应用到一组相互依存的任务,只需执行一次(例如任务 D 依赖于任务 C任务 B,但任务 C 依赖于任务 B 也一样,因此任务 B 只能执行一次,不能执行两次。
  4. CompletableFuture:通过CompletableFuture写几个代码片段来举例说明异步代码。
  5. 组合多个CompletableFuture对象:写几段代码举例说明组合多个CompletableFuture对象的不同解决方案。
  6. 优化忙等待:写一个概念证明来举例说明通过onSpinWait()优化忙等待技术。
  7. 任务取消:写一个概念证明,举例说明如何使用volatile变量来保存进程的取消状态。
  8. ThreadLocal:写一个概念证明,举例说明ThreadLocal的用法。
  9. 原子变量:使用多线程应用(Runnable编写一个从 1 到 1000000 的整数计数程序。
  10. ReentrantLock:编写一个程序,使用ReentrantLock将整数从 1 递增到 1000000。
  11. ReentrantReadWriteLock:通过ReentrantReadWriteLock编写模拟读写过程编排的程序。
  12. StampedLock:通过StampedLock编写模拟读写过程编排的程序。
  13. 死锁(哲学家就餐):编写一个程序,揭示并解决著名餐饮哲学家问题中可能出现的死锁(循环等待致命拥抱)。

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释仅包括解决问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多详细信息,并在这个页面中试用程序。

213 可中断方法

所谓可中断方法,是指可以抛出InterruptedException的阻塞方法,例如Thread.sleep()BlockingQueue.take()BlockingQueue.poll(long timeout, TimeUnit unit)等。阻塞线程通常处于阻塞等待定时等待状态,如果被中断,则该方法尝试尽快抛出InterruptedException

因为InterruptedException是一个检查过的异常,所以我们必须捕获它和/或抛出它。换句话说,如果我们的方法调用了抛出InterruptedException的方法,那么我们必须准备好处理这个异常。如果我们可以抛出它(将异常传播给调用方),那么它就不再是我们的工作了。打电话的人必须进一步处理。所以,当我们必须抓住它的时候,让我们把注意力集中在这个案子上。当我们的代码在Runnable内运行时,就会出现这种情况,因为它不能抛出异常。

让我们从一个简单的例子开始。试图通过poll(long timeout, TimeUnit unit)BlockingQueue获取元素可以写为:

try {
  queue.poll(3000, TimeUnit.MILLISECONDS);
} catch (InterruptedException ex) {
  ...
  logger.info(() -> "Thread is interrupted? "
    + Thread.currentThread().isInterrupted());
}

尝试轮询队列中的元素可能会导致InterruptedException。有一个 3000 毫秒的窗口可以中断线程。在中断的情况下(例如,Thread.interrupt()),我们可能会认为调用catch块中的Thread.currentThread().isInterrupted()将返回true。毕竟,我们处在一个InterruptedException catch街区,所以相信这一点是有道理的。实际上,它会返回false,答案在poll(long timeout, TimeUnit unit)方法的源代码中,如下所示:

1: public E poll(long timeout, TimeUnit unit) 
       throws InterruptedException {
2:   E e = xfer(null, false, TIMED, unit.toNanos(timeout));
3:   if (e != null || !Thread.interrupted())
4:     return e;
5:   throw new InterruptedException();
6: }

更准确地说,答案在第 3 行。如果线程被中断,那么Thread.interrupted()将返回true,并将导致第 5 行(throw new InterruptedException()。但是除了测试之外,如果当前线程被中断,Thread.interrupted()清除线程的中断状态。请查看以下连续调用中断线程:

Thread.currentThread().isInterrupted(); // true
Thread.interrupted() // true
Thread.currentThread().isInterrupted(); // false
Thread.interrupted() // false

注意,Thread.currentThread().isInterrupted()测试这个线程是否被中断,而不影响中断状态。

现在,让我们回到我们的案子。所以,我们知道线程在捕捉到InterruptedException后就中断了,但是中断状态被Thread.interrupted()清除了。这也意味着我们代码的调用者不会意识到中断。

我们有责任成为好公民,通过调用interrupt()方法恢复中断。这样,我们代码的调用者就可以看到发出了中断,并相应地采取行动。正确的代码如下:

try {
  queue.poll(3000, TimeUnit.MILLISECONDS);
} catch (InterruptedException ex) {
  ...
  Thread.currentThread().interrupt(); // restore interrupt
}

根据经验,在捕捉到InterruptedException之后,不要忘记通过调用Thread.currentThread().interrupt()来恢复中断。

让我们来解决一个突出显示忘记恢复中断的问题。假设一个Runnable只要当前线程没有中断就可以运行(例如,while (!Thread.currentThread().isInterrupted()) { ... }

在每次迭代中,如果当前线程中断状态为false,那么我们尝试从BlockingQueue中获取一个元素。

实现代码如下:

Thread thread = new Thread(() -> {

  // some dummy queue
  TransferQueue<String> queue = new LinkedTransferQueue<>();

  while (!Thread.currentThread().isInterrupted()) {
    try {
      logger.info(() -> "For 3 seconds the thread " 
        + Thread.currentThread().getName() 
        + " will try to poll an element from queue ...");

      queue.poll(3000, TimeUnit.MILLISECONDS);
    } catch (InterruptedException ex) {
      logger.severe(() -> "InterruptedException! The thread "
        + Thread.currentThread().getName() + " was interrupted!");
      Thread.currentThread().interrupt();
    }
  }

  logger.info(() -> "The execution was stopped!");
});

作为调用者(另一个线程),我们启动上面的线程,睡眠 1.5 秒,只是给这个线程时间进入poll()方法,然后我们中断它。如下代码所示:

thread.start();
Thread.sleep(1500);
thread.interrupt();

这将导致InterruptedException

记录异常并恢复中断。

下一步,while计算Thread.currentThread().isInterrupted()false并退出。

因此,输出如下:

[18:02:43] [INFO] For 3 seconds the thread Thread-0
                  will try to poll an element from queue ...

[18:02:44] [SEVERE] InterruptedException!
                    The thread Thread-0 was interrupted!

[18:02:45] [INFO] The execution was stopped!

现在,让我们对恢复中断的行进行注释:

...
} catch (InterruptedException ex) {
  logger.severe(() -> "InterruptedException! The thread " 
    + Thread.currentThread().getName() + " was interrupted!");

  // notice that the below line is commented
  // Thread.currentThread().interrupt();
}
...

这一次,while块将永远运行,因为它的保护条件总是被求值为true

代码不能作用于中断,因此输出如下:

[18:05:47] [INFO] For 3 seconds the thread Thread-0
                  will try to poll an element from queue ...

[18:05:48] [SEVERE] InterruptedException!
                    The thread Thread-0 was interrupted!

[18:05:48] [INFO] For 3 seconds the thread Thread-0
                  will try to poll an element from queue ...
...

根据经验,当我们可以接受中断(而不是恢复中断)时,唯一可以接受的情况是我们可以控制整个调用栈(例如,extend Thread)。

否则,捕获的InterruptedException也应该包含Thread.currentThread().interrupt()

214 Fork/Join 框架

我们已经在“工作线程池”一节中介绍了 Fork/Join 框架。

Fork/Join 框架主要用于处理一个大任务(通常,通过大,我们可以理解大量的数据)并递归地将其拆分为可以并行执行的小任务(子任务)。最后,在完成所有子任务后,它们的结果将合并(合并)为一个结果。

下图是 Fork/Join 流的可视化表示:

在 API 方面,可以通过java.util.concurrent.ForkJoinPool创建叉/连接。

JDK8 之前,推荐的方法依赖于public static变量,如下所示:

public static ForkJoinPool forkJoinPool = new ForkJoinPool();

从 JDK8 开始,我们可以按如下方式进行:

ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();

这两种方法都避免了在单个 JVM 上有太多池线程这一令人不快的情况,这是由创建它们自己的池的并行操作造成的。

对于自定义的ForkJoinPool,依赖于此类的构造器。JDK9 添加了迄今为止最全面的一个(详细信息见文档)。

AForkJoinPool对象操作任务。ForkJoinPool中执行的任务的基本类型为ForkJoinTask<V>。更确切地说,执行以下任务:

  • RecursiveAction对于void任务
  • RecursiveTask<V>对于返回值的任务
  • CountedCompleter<T>对于需要记住挂起任务计数的任务

这三种类型的任务都有一个名为compute()的抽象方法,在这个方法中任务的逻辑是成形的。

ForkJoinPool提交任务可以通过以下方式完成:

  • execute()submit()
  • invoke()派生任务并等待结果
  • invokeAll()用于分叉一堆任务(例如,集合)
  • fork()用于安排在池中异步执行此任务,join()用于在完成时返回计算结果

让我们从一个通过RecursiveTask解决的问题开始。

通过RecursiveTask计算总和

为了演示框架的分叉行为,我们假设我们有一个数字列表,并且我们要计算这些数字的总和。为此,我们使用createSubtasks()方法递归地拆分(派生)这个列表,只要它大于指定的THRESHOLD。每个任务都被添加到List<SumRecursiveTask>中。最后通过invokeAll​(Collection<T> tasks)方式将该列表提交给ForkJoinPool。这是使用以下代码完成的:

public class SumRecursiveTask extends RecursiveTask<Integer> {

  private static final Logger logger 
    = Logger.getLogger(SumRecursiveTask.class.getName());
  private static final int THRESHOLD = 10;

  private final List<Integer> worklist;

  public SumRecursiveTask(List<Integer> worklist) {
    this.worklist = worklist;
  }

  @Override
  protected Integer compute() {
    if (worklist.size() <= THRESHOLD) {
      return partialSum(worklist);
    }

    return ForkJoinTask.invokeAll(createSubtasks())
      .stream()
      .mapToInt(ForkJoinTask::join)
      .sum();
  }

  private List<SumRecursiveTask> createSubtasks() {

    List<SumRecursiveTask> subtasks = new ArrayList<>();
    int size = worklist.size();

    List<Integer> worklistLeft 
      = worklist.subList(0, (size + 1) / 2);
    List<Integer> worklistRight 
      = worklist.subList((size + 1) / 2, size);

    subtasks.add(new SumRecursiveTask(worklistLeft));
    subtasks.add(new SumRecursiveTask(worklistRight));

    return subtasks;
  }

  private Integer partialSum(List<Integer> worklist) {

    int sum = worklist.stream()
      .mapToInt(e -> e)
      .sum();

    logger.info(() -> "Partial sum: " + worklist + " = "
      + sum + "\tThread: " + Thread.currentThread().getName());

    return sum;
  }
}

为了测试它,我们需要一个列表和ForkJoinPool如下:

ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();

Random rnd = new Random();
List<Integer> list = new ArrayList<>();

for (int i = 0; i < 200; i++) {
  list.add(1 + rnd.nextInt(10));
}

SumRecursiveTask sumRecursiveTask = new SumRecursiveTask(list);
Integer sumAll = forkJoinPool.invoke(sumRecursiveTask);

logger.info(() -> "Final sum: " + sumAll);

可能的输出如下:

...
[15:17:06] Partial sum: [1, 3, 6, 6, 2, 5, 9] = 32
ForkJoinPool.commonPool-worker-9
...
[15:17:06] Partial sum: [1, 9, 9, 8, 9, 5] = 41
ForkJoinPool.commonPool-worker-7
[15:17:06] Final sum: 1084

用递归运算计算斐波那契函数

斐波那契数通常表示为F(n),是一个遵循以下公式的序列:

F(0) = 0, 
F(1) = 1, 
..., 
F(n) = F(n-1) + F(n-2), n > 1

斐波那契数的快照是:

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, ...

通过RecursiveAction实现斐波那契数可以如下完成:

public class FibonacciRecursiveAction extends RecursiveAction {

  private static final Logger logger =
    Logger.getLogger(FibonacciRecursiveAction.class.getName());
  private static final long THRESHOLD = 5;

  private long nr;

  public FibonacciRecursiveAction(long nr) {
    this.nr = nr;
  }

  @Override
  protected void compute() {

    final long n = nr;

    if (n <= THRESHOLD) {
      nr = fibonacci(n);
    } else {
      nr = ForkJoinTask.invokeAll(createSubtasks(n))
        .stream()
        .mapToLong(x -> x.fibonacciNumber())
        .sum();
    }
  }

  private List<FibonacciRecursiveAction> createSubtasks(long n) {

    List<FibonacciRecursiveAction> subtasks = new ArrayList<>();

    FibonacciRecursiveAction fibonacciMinusOne
      = new FibonacciRecursiveAction(n - 1);
    FibonacciRecursiveAction fibonacciMinusTwo
      = new FibonacciRecursiveAction(n - 2);

    subtasks.add(fibonacciMinusOne);
    subtasks.add(fibonacciMinusTwo);

    return subtasks;
  }

  private long fibonacci(long n) {
    logger.info(() -> "Number: " + n 
      + " Thread: " + Thread.currentThread().getName());

    if (n <= 1) {
      return n;
    }

    return fibonacci(n - 1) + fibonacci(n - 2);
  }

  public long fibonacciNumber() {
    return nr;
  }
}

为了测试它,我们需要以下ForkJoinPool对象:

ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();

FibonacciRecursiveAction fibonacciRecursiveAction
  = new FibonacciRecursiveAction(12);
forkJoinPool.invoke(fibonacciRecursiveAction);

logger.info(() -> "Fibonacci: "
  + fibonacciRecursiveAction.fibonacciNumber());

F(12)的输出如下:

[15:40:46] Number: 5 Thread: ForkJoinPool.commonPool-worker-3
[15:40:46] Number: 5 Thread: ForkJoinPool.commonPool-worker-13
[15:40:46] Number: 4 Thread: ForkJoinPool.commonPool-worker-3
[15:40:46] Number: 4 Thread: ForkJoinPool.commonPool-worker-9
...
[15:40:49] Number: 0 Thread: ForkJoinPool.commonPool-worker-7
[15:40:49] Fibonacci: 144

使用CountedCompleter

CountedCompleter是 JDK8 中增加的ForkJoinTask类型。

CountedCompleter的任务是记住挂起的任务计数(不能少,不能多)。我们可以通过setPendingCount()设置挂起计数,也可以通过addToPendingCount​(int delta)用显式的delta递增。通常,我们在分叉之前调用这些方法(例如,如果我们分叉两次,则根据具体情况调用addToPendingCount(2)setPendingCount(2))。

compute()方法中,我们通过tryComplete()propagateCompletion()减少挂起计数。当调用挂起计数为零的tryComplete()方法或调用无条件complete()方法时,调用onCompletion()方法。propagateCompletion()方法与tryComplete()类似,但不调用onCompletion()

CountedCompleter可以选择返回计算值。为此,我们必须重写getRawResult()方法来返回一个值。

下面的代码通过CountedCompleter对列表的所有值进行汇总:

public class SumCountedCompleter extends CountedCompleter<Long> {

  private static final Logger logger 
    = Logger.getLogger(SumCountedCompleter.class.getName());
  private static final int THRESHOLD = 10;
  private static final LongAdder sumAll = new LongAdder();

  private final List<Integer> worklist;

  public SumCountedCompleter(
    CountedCompleter<Long> c, List<Integer> worklist) {
    super(c);
    this.worklist = worklist;
  }

  @Override
  public void compute() {
    if (worklist.size() <= THRESHOLD) {
      partialSum(worklist);
    } else {
      int size = worklist.size();

      List<Integer> worklistLeft 
        = worklist.subList(0, (size + 1) / 2);
      List<Integer> worklistRight 
        = worklist.subList((size + 1) / 2, size);

      addToPendingCount(2);
      SumCountedCompleter leftTask
        = new SumCountedCompleter(this, worklistLeft);
      SumCountedCompleter rightTask
        = new SumCountedCompleter(this, worklistRight);

      leftTask.fork();
      rightTask.fork();
    }

    tryComplete();
  }

  @Override
  public void onCompletion(CountedCompleter<?> caller) {
    logger.info(() -> "Thread complete: " 
      + Thread.currentThread().getName());
  }

  @Override
  public Long getRawResult() {
    return sumAll.sum();
  }

  private Integer partialSum(List<Integer> worklist) {
    int sum = worklist.stream()
      .mapToInt(e -> e)
      .sum();

    sumAll.add(sum);

    logger.info(() -> "Partial sum: " + worklist + " = "
      + sum + "\tThread: " + Thread.currentThread().getName());

    return sum;
  }
}

现在,让我们看看一个潜在的调用和输出:

ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();
Random rnd = new Random();
List<Integer> list = new ArrayList<>();

for (int i = 0; i < 200; i++) {
  list.add(1 + rnd.nextInt(10));
}

SumCountedCompleter sumCountedCompleter
  = new SumCountedCompleter(null, list);
forkJoinPool.invoke(sumCountedCompleter);

logger.info(() -> "Done! Result: "
  + sumCountedCompleter.getRawResult());

输出如下:

[11:11:07] Partial sum: [7, 7, 8, 5, 6, 10] = 43
  ForkJoinPool.commonPool-worker-7
[11:11:07] Partial sum: [9, 1, 1, 6, 1, 2] = 20
  ForkJoinPool.commonPool-worker-3
...
[11:11:07] Thread complete: ForkJoinPool.commonPool-worker-15
[11:11:07] Done! Result: 1159

215 Fork/Join 框架和compareAndSetForkJoinTaskTag()

现在,我们已经熟悉了 Fork/Join 框架,让我们看看另一个问题。这次让我们假设我们有一组相互依赖的对象。下图可以被视为一个用例:

以下是前面图表的说明:

  • TaskD有三个依赖项:TaskATaskBTaskC
  • TaskC有两个依赖项:TaskATaskB
  • TaskB有一个依赖关系:TaskA
  • TaskA没有依赖关系。

在代码行中,我们将对其进行如下塑造:

ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();

Task taskA = new Task("Task-A", new Adder(1));

Task taskB = new Task("Task-B", new Adder(2), taskA);

Task taskC = new Task("Task-C", new Adder(3), taskA, taskB);

Task taskD = new Task("Task-D", new Adder(4), taskA, taskB, taskC);

forkJoinPool.invoke(taskD);

Adder是一个简单的Callable,每个任务只能执行一次(因此,对于TaskDTaskCTaskBTaskA执行一次)。Adder由以下代码启动:

private static class Adder implements Callable {

  private static final AtomicInteger result = new AtomicInteger();

  private Integer nr;

  public Adder(Integer nr) {
    this.nr = nr;
  }

  @Override
  public Integer call() {
    logger.info(() -> "Adding number: " + nr
      + " by thread:" + Thread.currentThread().getName());

    return result.addAndGet(nr);
  }
}

我们已经知道如何将 Fork/Join 框架用于具有非循环和/或不可重复(或者我们不关心它们是否重复)完成依赖关系的任务。但是如果我们用这种方式实现,那么每个任务都会多次调用Callable。例如,TaskA作为其他三个任务的依赖项出现,因此Callable将被调用三次。我们只想要一次。

JDK8 中添加的一个非常方便的特性ForkJoinPool是用short值进行原子标记:

  • short getForkJoinTaskTag():返回该任务的标签。
  • short setForkJoinTaskTag​(short newValue):自动设置此任务的标记值,并返回旧值。
  • boolean compareAndSetForkJoinTaskTag​(short expect, short update):如果当前值等于expect并且更改为update,则返回true

换句话说,compareAndSetForkJoinTaskTag()允许我们将任务标记为VISITED。一旦标记为VISITED,则不执行。让我们在以下代码行中看到它:

public class Task<Integer> extends RecursiveTask<Integer> {

  private static final Logger logger 
    = Logger.getLogger(Task.class.getName());
  private static final short UNVISITED = 0;
  private static final short VISITED = 1;

  private Set<Task<Integer>> dependencies = new HashSet<>();

  private final String name;
  private final Callable<Integer> callable;

  public Task(String name, Callable<Integer> callable,
      Task<Integer> ...dependencies) {
    this.name = name;
    this.callable = callable;
    this.dependencies = Set.of(dependencies);
  }

  @Override
  protected Integer compute() {
    dependencies.stream()
      .filter((task) -> (task.updateTaskAsVisited()))
      .forEachOrdered((task) -> {
        logger.info(() -> "Tagged: " + task + "("
          + task.getForkJoinTaskTag() + ")");

        task.fork();
      });

    for (Task task: dependencies) {
      task.join();
    }

    try {
      return callable.call();
    } catch (Exception ex) {
      logger.severe(() -> "Exception: " + ex);
    }

    return null;
  }

  public boolean updateTaskAsVisited() {
    return compareAndSetForkJoinTaskTag(UNVISITED, VISITED);
  }

  @Override
  public String toString() {
    return name + " | dependencies=" + dependencies + "}";
  }
}

可能的输出如下:

[10:30:53] [INFO] Tagged: Task-B(1)
[10:30:53] [INFO] Tagged: Task-C(1)
[10:30:53] [INFO] Tagged: Task-A(1)
[10:30:53] [INFO] Adding number: 1 
                   by thread:ForkJoinPool.commonPool-worker-3
[10:30:53] [INFO] Adding number: 2 
                   by thread:ForkJoinPool.commonPool-worker-3
[10:30:53] [INFO] Adding number: 3 
                   by thread:ForkJoinPool.commonPool-worker-5
[10:30:53] [INFO] Adding number: 4 
                   by thread:main
[10:30:53] [INFO] Result: 10

216 CompletableFuture

JDK8 通过用CompletableFuture增强Future,在异步编程领域迈出了重要的一步。Future的主要限制是:

  • 它不能显式地完成。
  • 它不支持对结果执行操作的回调。
  • 它们不能链接或组合以获得复杂的异步管道。
  • 它不提供异常处理。

CompletableFuture没有这些限制。一个简单但无用的CompletableFuture可以写如下:

CompletableFuture<Integer> completableFuture 
  = new CompletableFuture<>();

通过阻断get()方法可以得到结果:

completableFuture.get();

除此之外,让我们看几个在电子商务平台上下文中运行异步任务的示例。我们将这些示例添加到名为CustomerAsyncs的助手类中。

运行异步任务并返回void

用户问题:打印某个客户订单

因为打印是一个不需要返回结果的过程,所以这是一个针对runAsync()的作业。此方法可以异步运行任务,并且不返回结果。换句话说,它接受一个Runnable对象并返回CompletableFuture<Void>,如下代码所示:

public static void printOrder() {

  CompletableFuture<Void> cfPrintOrder 
      = CompletableFuture.runAsync(new Runnable() {

    @Override
    public void run() {
      logger.info(() -> "Order is printed by: "
        + Thread.currentThread().getName());
      Thread.sleep(500);
    }
  });

  cfPrintOrder.get(); // block until the order is printed
  logger.info("Customer order was printed ...\n");
}

或者,我们可以用 Lambda 来写:

public static void printOrder() {

  CompletableFuture<Void> cfPrintOrder 
      = CompletableFuture.runAsync(() -> {

    logger.info(() -> "Order is printed by: "
      + Thread.currentThread().getName());
    Thread.sleep(500);
  });

  cfPrintOrder.get(); // block until the order is printed
  logger.info("Customer order was printed ...\n");
}

运行异步任务并返回结果

用户问题:获取某客户的订单汇总

这一次,异步任务必须返回一个结果,因此runAsync()没有用处。这是supplyAsync()的工作。取Supplier<T>返回CompletableFuture<T>T是通过get()方法从该供应器处获得的结果类型。在代码行中,我们可以如下解决此问题:

public static void fetchOrderSummary() {

  CompletableFuture<String> cfOrderSummary 
      = CompletableFuture.supplyAsync(() -> {

    logger.info(() -> "Fetch order summary by: "
      + Thread.currentThread().getName());
    Thread.sleep(500);

    return "Order Summary #93443";
  });

  // wait for summary to be available, this is blocking
  String summary = cfOrderSummary.get();
  logger.info(() -> "Order summary: " + summary + "\n");
}

运行异步任务并通过显式线程池返回结果

用户问题:取某客户的订单摘要

默认情况下,与前面的示例一样,异步任务在从全局ForkJoinPool.commonPool()获取的线程中执行。通过简单地记录Thread.currentThread().getName(),我们可以看到ForkJoinPool.commonPool-worker-3

但是我们也可以使用显式的Executor自定义线程池。所有能够运行异步任务的CompletableFuture方法都提供了一种采用Executor的风格。

下面是使用单线程池的示例:

public static void fetchOrderSummaryExecutor() {

  ExecutorService executor = Executors.newSingleThreadExecutor();

  CompletableFuture<String> cfOrderSummary 
      = CompletableFuture.supplyAsync(() -> {

    logger.info(() -> "Fetch order summary by: "
      + Thread.currentThread().getName());
    Thread.sleep(500);

    return "Order Summary #91022";
  }, executor);

  // wait for summary to be available, this is blocking
  String summary = cfOrderSummary.get();
  logger.info(() -> "Order summary: " + summary + "\n");
  executor.shutdownNow();
}

附加处理异步任务结果并返回结果的回调

用户问题:取某客户的订单发票,然后计算总金额并签字

依赖阻塞get()对此类问题不是很有用。我们需要的是一个回调方法,当CompletableFuture的结果可用时,该方法将被自动调用。

所以,我们不想等待结果。当发票准备就绪时(这是CompletableFuture的结果),回调方法应该计算总值,然后,另一个回调应该对其签名。这可以通过thenApply()方法实现。

thenApply()方法可用于CompletableFuture结果到达时的处理和转换。它以Function<T, R>为参数。让我们在工作中看看:

public static void fetchInvoiceTotalSign() {

  CompletableFuture<String> cfFetchInvoice 
      = CompletableFuture.supplyAsync(() -> {

    logger.info(() -> "Fetch invoice by: "
      + Thread.currentThread().getName());
    Thread.sleep(500);

    return "Invoice #3344";
  });

  CompletableFuture<String> cfTotalSign = cfFetchInvoice
    .thenApply(o -> o + " Total: $145")
    .thenApply(o -> o + " Signed");

  String result = cfTotalSign.get();
  logger.info(() -> "Invoice: " + result + "\n");
}

或者,我们可以将其链接如下:

public static void fetchInvoiceTotalSign() {

  CompletableFuture<String> cfTotalSign 
      = CompletableFuture.supplyAsync(() -> {

    logger.info(() -> "Fetch invoice by: "
      + Thread.currentThread().getName());
    Thread.sleep(500);

    return "Invoice #3344";
  }).thenApply(o -> o + " Total: $145")
    .thenApply(o -> o + " Signed");

  String result = cfTotalSign.get();
  logger.info(() -> "Invoice: " + result + "\n");
}

同时检查applyToEither()applyToEitherAsync()。当这个或另一个给定的阶段以正常方式完成时,这两个方法将返回一个新的完成阶段,并将结果作为提供函数的参数执行。

附加处理异步任务结果并返回void的回调

用户问题:取某客户订单打印

通常,不返回结果的回调充当异步管道的终端操作。

这种行为可以通过thenAccept()方法获得。取Consumer<T>返回CompletableFuture<Void>。此方法可以对CompletableFuture的结果进行处理和转换,但不返回结果。因此,它可以接受一个订单,它是CompletableFuture的结果,并按下面的代码片段打印出来:

public static void fetchAndPrintOrder() {

  CompletableFuture<String> cfFetchOrder 
      = CompletableFuture.supplyAsync(() -> {

    logger.info(() -> "Fetch order by: "
      + Thread.currentThread().getName());
    Thread.sleep(500);

    return "Order #1024";
  });

  CompletableFuture<Void> cfPrintOrder = cfFetchOrder.thenAccept(
    o -> logger.info(() -> "Printing order " + o +
      " by: " + Thread.currentThread().getName()));

  cfPrintOrder.get();
  logger.info("Order was fetched and printed \n");
}

或者,它可以更紧凑,如下所示:

public static void fetchAndPrintOrder() {

  CompletableFuture<Void> cfFetchAndPrintOrder 
      = CompletableFuture.supplyAsync(() -> {

    logger.info(() -> "Fetch order by: "
      + Thread.currentThread().getName());
    Thread.sleep(500);

    return "Order #1024";
  }).thenAccept(
      o -> logger.info(() -> "Printing order " + o + " by: "
        + Thread.currentThread().getName()));

  cfFetchAndPrintOrder.get();
  logger.info("Order was fetched and printed \n");
}

同时检查acceptEither()acceptEitherAsync()

附加在异步任务之后运行并返回void的回调

用户问题:下订单通知客户

通知客户应在交付订单后完成。这只是一条亲爱的客户,您的订单已经在今天送达之类的短信,所以通知任务不需要知道任何关于订单的信息。这类任务可以通过thenRun()来完成。此方法取Runnable,返回CompletableFuture<Void>。让我们在工作中看看:

public static void deliverOrderNotifyCustomer() {

  CompletableFuture<Void> cfDeliverOrder 
      = CompletableFuture.runAsync(() -> {

    logger.info(() -> "Order was delivered by: "
      + Thread.currentThread().getName());
    Thread.sleep(500);
  });

  CompletableFuture<Void> cfNotifyCustomer 
      = cfDeliverOrder.thenRun(() -> logger.info(
        () -> "Dear customer, your order has been delivered today by:"
          + Thread.currentThread().getName()));

  cfNotifyCustomer.get();
  logger.info(() -> "Order was delivered 
                       and customer was notified \n");
}

为了进一步的并行化,thenApply()thenAccept()thenRun()伴随着thenApplyAsync()thenAcceptAsync()thenRunAsync()。其中每一个都可以依赖于全局ForkJoinPool.commonPool()或自定义线程池(Executor。当thenApply/Accept/Run()在与之前执行的CompletableFuture任务相同的线程中执行时(或在主线程中),可以在不同的线程中执行thenApplyAsync/AcceptAsync/RunAsync()(来自ForkJoinPool.commonPool()或自定义线程池(Executor)。

通过exceptionally()处理异步任务的异常

用户问题:计算订单总数。如果出了问题,就抛出IllegalStateException

以下屏幕截图举例说明了异常是如何在异步管道中传播的;在某个点发生异常时,不会执行矩形中的代码:

以下截图显示了thenApply()thenAccept()中的异常:

因此,在supplyAsync()中,如果发生异常,则不会调用以下回调。此外,Future将得到解决,但这一异常除外。相同的规则适用于每个回调。如果第一个thenApply()出现异常,则不调用以下thenApply()thenAccept()

如果我们试图计算订单总数的结果是一个IllegalStateException,那么我们可以依赖exceptionally()回调,这给了我们一个恢复的机会。此方法接受一个Function<Throwable,​? extends T>,并返回一个CompletionStage<T>,因此返回一个CompletableFuture。让我们在工作中看看:

public static void fetchOrderTotalException() {

  CompletableFuture<Integer> cfTotalOrder 
      = CompletableFuture.supplyAsync(() -> {

    logger.info(() -> "Compute total: "
      + Thread.currentThread().getName());

    int surrogate = new Random().nextInt(1000);
    if (surrogate < 500) {
      throw new IllegalStateException(
        "Invoice service is not responding");
    }

    return 1000;
  }).exceptionally(ex -> {
    logger.severe(() -> "Exception: " + ex
      + " Thread: " + Thread.currentThread().getName());

    return 0;
  });

  int result = cfTotalOrder.get();
  logger.info(() -> "Total: " + result + "\n");
}

异常情况下,输出如下:

Compute total: ForkJoinPool.commonPool-worker-3
Exception: java.lang.IllegalStateException: Invoice service
           is not responding Thread: ForkJoinPool.commonPool-worker-3
Total: 0

让我们看看另一个问题。

用户问题:取发票,计算合计,签字。如有问题,则抛出IllegalStateException,停止处理

如果我们用supplyAsync()取发票,用thenApply()计算合计,用另一个thenApply()签字,那么我们可以认为正确的实现如下:

public static void fetchInvoiceTotalSignChainOfException()
throws InterruptedException, ExecutionException {

  CompletableFuture<String> cfFetchInvoice 
      = CompletableFuture.supplyAsync(() -> {

    logger.info(() -> "Fetch invoice by: "
      + Thread.currentThread().getName());

    int surrogate = new Random().nextInt(1000);
    if (surrogate < 500) {
      throw new IllegalStateException(
        "Invoice service is not responding");
    }

    return "Invoice #3344";
  }).exceptionally(ex -> {
    logger.severe(() -> "Exception: " + ex
      + " Thread: " + Thread.currentThread().getName());

    return "[Invoice-Exception]";
  }).thenApply(o -> {
      logger.info(() -> "Compute total by: "
        + Thread.currentThread().getName());

    int surrogate = new Random().nextInt(1000);
    if (surrogate < 500) {
      throw new IllegalStateException(
        "Total service is not responding");
    }

    return o + " Total: $145";
  }).exceptionally(ex -> {
    logger.severe(() -> "Exception: " + ex
      + " Thread: " + Thread.currentThread().getName());

    return "[Total-Exception]";
  }).thenApply(o -> {
    logger.info(() -> "Sign invoice by: "
      + Thread.currentThread().getName());

    int surrogate = new Random().nextInt(1000);
    if (surrogate < 500) {
      throw new IllegalStateException(
        "Signing service is not responding");
    }

    return o + " Signed";
  }).exceptionally(ex -> {
    logger.severe(() -> "Exception: " + ex
      + " Thread: " + Thread.currentThread().getName());

    return "[Sign-Exception]";
  });

  String result = cfFetchInvoice.get();
  logger.info(() -> "Result: " + result + "\n");
}

好吧,这里的问题是,我们可能面临如下输出:

[INFO] Fetch invoice by: ForkJoinPool.commonPool-worker-3
[SEVERE] Exception: java.lang.IllegalStateException: Invoice service
         is not responding Thread: ForkJoinPool.commonPool-worker-3
[INFO] Compute total by: ForkJoinPool.commonPool-worker-3
[INFO] Sign invoice by: ForkJoinPool.commonPool-worker-3
[SEVERE] Exception: java.lang.IllegalStateException: Signing service
         is not responding Thread: ForkJoinPool.commonPool-worker-3
[INFO] Result: [Sign-Exception]

即使发票拿不到,我们也会继续计算总数并签字。显然,这没有道理。如果无法提取发票,或者无法计算总额,则我们希望中止该过程。当我们可以恢复并继续时,这个实现可能是一个很好的选择,但它绝对不适合我们的场景。对于我们的场景,需要以下实现:

public static void fetchInvoiceTotalSignException()
throws InterruptedException, ExecutionException {

  CompletableFuture<String> cfFetchInvoice 
      = CompletableFuture.supplyAsync(() -> {

    logger.info(() -> "Fetch invoice by: "
      + Thread.currentThread().getName());

    int surrogate = new Random().nextInt(1000);
    if (surrogate < 500) {
      throw new IllegalStateException(
        "Invoice service is not responding");
    }

    return "Invoice #3344";
  }).thenApply(o -> {
      logger.info(() -> "Compute total by: "
        + Thread.currentThread().getName());

    int surrogate = new Random().nextInt(1000);
    if (surrogate < 500) {
      throw new IllegalStateException(
        "Total service is not responding");
    }

    return o + " Total: $145";
  }).thenApply(o -> {
      logger.info(() -> "Sign invoice by: "
        + Thread.currentThread().getName());

    int surrogate = new Random().nextInt(1000);
    if (surrogate < 500) {
      throw new IllegalStateException(
        "Signing service is not responding");
    }

    return o + " Signed";
  }).exceptionally(ex -> {
    logger.severe(() -> "Exception: " + ex
      + " Thread: " + Thread.currentThread().getName());

    return "[No-Invoice-Exception]";
  });

  String result = cfFetchInvoice.get();
  logger.info(() -> "Result: " + result + "\n");
}

这一次,在任何隐含的CompletableFuture中发生的异常将停止该过程。以下是可能的输出:

[INFO ] Fetch invoice by: ForkJoinPool.commonPool-worker-3
[SEVERE] Exception: java.lang.IllegalStateException: Invoice service
         is not responding Thread: ForkJoinPool.commonPool-worker-3
[INFO ] Result: [No-Invoice-Exception]

从 JDK12 开始,异常情况可以通过exceptionallyAsync()进一步并行化,它可以使用与引起异常的代码相同的线程或给定线程池(Executor中的线程)。举个例子:

public static void fetchOrderTotalExceptionAsync() {

  ExecutorService executor = Executors.newSingleThreadExecutor();

  CompletableFuture<Integer> totalOrder 
      = CompletableFuture.supplyAsync(() -> {

    logger.info(() -> "Compute total by: "
      + Thread.currentThread().getName());

    int surrogate = new Random().nextInt(1000);
    if (surrogate < 500) {
      throw new IllegalStateException(
        "Computing service is not responding");
    }

    return 1000;
  }).exceptionallyAsync(ex -> {
    logger.severe(() -> "Exception: " + ex 
      + " Thread: " + Thread.currentThread().getName());

    return 0;
  }, executor);

  int result = totalOrder.get();
  logger.info(() -> "Total: " + result + "\n");
  executor.shutdownNow();
}

输出显示导致异常的代码是由名为ForkJoinPool.commonPool-worker-3的线程执行的,而异常代码是由给定线程池中名为pool-1-thread-1的线程执行的:

Compute total by: ForkJoinPool.commonPool-worker-3
Exception: java.lang.IllegalStateException: Computing service is
           not responding Thread: pool-1-thread-1
Total: 0

JDK12 exceptionallyCompose()

用户问题:通过打印服务获取打印机 IP 或回退到备份打印机 IP。或者,一般来说,当**这个阶段异常完成时,应该使用应用于这个阶段异常的所提供函数的结果来合成。

我们有CompletableFuture获取打印服务管理的打印机的 IP。如果服务没有响应,则抛出如下异常:

CompletableFuture<String> cfServicePrinterIp 
    = CompletableFuture.supplyAsync(() -> {

  int surrogate = new Random().nextInt(1000);
  if (surrogate < 500) {
    throw new IllegalStateException(
      "Printing service is not responding");
  }

  return "192.168.1.0";
});

我们还有获取备份打印机 IP 的CompletableFuture

CompletableFuture<String> cfBackupPrinterIp 
    = CompletableFuture.supplyAsync(() -> {

  return "192.192.192.192";
});

现在,如果没有打印服务,那么我们应该依靠备份打印机。这可以通过 JDK12exceptionallyCompose()实现,如下所示:

CompletableFuture<Void> printInvoice 
    = cfServicePrinterIp.exceptionallyCompose(th -> {

  logger.severe(() -> "Exception: " + th
    + " Thread: " + Thread.currentThread().getName());

  return cfBackupPrinterIp;
}).thenAccept((ip) -> logger.info(() -> "Printing at: " + ip));

调用printInvoice.get()可能会显示以下结果之一:

  • 如果打印服务可用:
[INFO] Printing at: 192.168.1.0
  • 如果打印服务不可用:
[SEVERE] Exception: java.util.concurrent.CompletionException ...
[INFO] Printing at: 192.192.192.192

对于进一步的并行化,我们可以依赖于exceptionallyComposeAsync()

通过handle()处理异步任务的异常

用户问题:计算订单总数。如果出现问题,则抛出一个IllegalStateException

有时我们希望执行一个异常代码块,即使没有发生异常。类似于try-catch块的finally子句。这可以使用handle()回调。无论是否发生异常,都会调用此方法,它类似于一个catch+finally。它使用一个函数来计算返回的CompletionStage, BiFunction<? super T,​Throwable,​? extends U>的值并返回CompletionStage<U>U是函数的返回类型)。

让我们在工作中看看:

public static void fetchOrderTotalHandle() {

  CompletableFuture<Integer> totalOrder 
      = CompletableFuture.supplyAsync(() -> {

    logger.info(() -> "Compute total by: "
      + Thread.currentThread().getName());

    int surrogate = new Random().nextInt(1000);
    if (surrogate < 500) {
      throw new IllegalStateException(
        "Computing service is not responding");
    }

    return 1000;
  }).handle((res, ex) -> {
    if (ex != null) {
      logger.severe(() -> "Exception: " + ex
        + " Thread: " + Thread.currentThread().getName());

      return 0;
    }

    if (res != null) {
      int vat = res * 24 / 100;
      res += vat;
    }

    return res;
  });

  int result = totalOrder.get();
  logger.info(() -> "Total: " + result + "\n");
}

注意,res将是null;否则,如果发生异常,ex将是null

如果我们需要在异常下完成,那么我们可以通过completeExceptionally()继续,如下例所示:

CompletableFuture<Integer> cf = new CompletableFuture<>();
...
cf.completeExceptionally(new RuntimeException("Ops!"));
...
cf.get(); // ExecutionException : RuntimeException

取消执行并抛出CancellationException可以通过cancel()方法完成:

CompletableFuture<Integer> cf = new CompletableFuture<>();
...
// is not important if the argument is set to true or false
cf.cancel(true/false);
...
cf.get(); // CancellationException

显式完成CompletableFuture

CompletableFuture可以使用complete​(T value)completeAsync​(Supplier<? extends T> supplier)completeAsync​(Supplier<? extends T> supplier, Executor executor)显式完成。Tget()返回的值。这里是一个创建CompletableFuture并立即返回它的方法。另一个线程负责执行一些税务计算并用相应的结果完成CompletableFuture

public static CompletableFuture<Integer> taxes() {

  CompletableFuture<Integer> completableFuture 
    = new CompletableFuture<>();

  new Thread(() -> {
    int result = new Random().nextInt(100);
    Thread.sleep(10);

    completableFuture.complete(result);
  }).start();

  return completableFuture;
}

我们称这个方法为:

logger.info("Computing taxes ...");

CompletableFuture<Integer> cfTaxes = CustomerAsyncs.taxes();

while (!cfTaxes.isDone()) {
  logger.info("Still computing ...");
}

int result = cfTaxes.get();
logger.info(() -> "Result: " + result);

可能的输出如下:

[14:09:40] [INFO ] Computing taxes ...
[14:09:40] [INFO ] Still computing ...
[14:09:40] [INFO ] Still computing ...
...
[14:09:40] [INFO ] Still computing ...
[14:09:40] [INFO ] Result: 17

如果我们已经知道了CompletableFuture的结果,那么我们可以调用completedFuture​(U value),如下例所示:

CompletableFuture<String> completableFuture 
  = CompletableFuture.completedFuture("How are you?");

String result = completableFuture.get();
logger.info(() -> "Result: " + result); // Result: How are you?

同时检查whenComplete()whenCompleteAsync()的文件。

217 组合多个CompletableFuture实例

在大多数情况下,组合CompletableFuture实例可以使用以下方法完成:

  • thenCompose()
  • thenCombine()
  • allOf()
  • anyOf()

通过结合CompletableFuture实例,我们可以形成复杂的异步解决方案。这样,多个CompletableFuture实例就可以合并它们的能力来达到一个共同的目标。

通过thenCompose()的组合

假设在名为CustomerAsyncs的助手类中有以下两个CompletableFuture实例:

private static CompletableFuture<String> 
    fetchOrder(String customerId) {

  return CompletableFuture.supplyAsync(() -> {
    return "Order of " + customerId;
  });
}

private static CompletableFuture<Integer> computeTotal(String order) {

  return CompletableFuture.supplyAsync(() -> {
    return order.length() + new Random().nextInt(1000);
  });
}

现在,我们要获取某个客户的订单,一旦订单可用,我们就要计算这个订单的总数。这意味着我们需要调用fetchOrder(),然后调用computeTotal()。我们可以通过thenApply()实现:

CompletableFuture<CompletableFuture<Integer>> cfTotal 
  = fetchOrder(customerId).thenApply(o -> computeTotal(o));

int total = cfTotal.get().get();

显然,这不是一个方便的解决方案,因为结果是CompletableFuture<CompletableFuture<Integer>>类型的。为了避免嵌套CompletableFuture实例,我们可以依赖thenCompose()如下:

CompletableFuture<Integer> cfTotal 
  = fetchOrder(customerId).thenCompose(o -> computeTotal(o));

int total = cfTotal.get();

// e.g., Total: 734
logger.info(() -> "Total: " + total);

当我们需要从一系列的CompletableFuture实例中获得一个平坦的结果时,我们可以使用thenCompose()。这样我们就避免了嵌套的CompletableFuture实例。

使用thenComposeAsync()可以获得进一步的并行化。

通过thenCombine()的合并

thenCompose()用于链接两个依赖的CompletableFuture实例,thenCombine()用于链接两个独立的CompletableFuture实例。当两个CompletableFuture实例都完成时,我们可以继续。

假设我们有以下两个CompletableFuture实例:

private static CompletableFuture<Integer> computeTotal(String order) {

  return CompletableFuture.supplyAsync(() -> {
    return order.length() + new Random().nextInt(1000);
  });
}

private static CompletableFuture<String> packProducts(String order) {

  return CompletableFuture.supplyAsync(() -> {
    return "Order: " + order 
      + " | Product 1, Product 2, Product 3, ... ";
  });
}

为了交付客户订单,我们需要计算总金额(用于发出发票),并打包订购的产品。这两个动作可以并行完成。最后,我们把包裹寄了,里面有订购的产品和发票。通过thenCombine()实现此目的,可如下:

CompletableFuture<String> cfParcel = computeTotal(order)
  .thenCombine(packProducts(order), (total, products) -> {
    return "Parcel-[" + products + " Invoice: $" + total + "]";
  });

String parcel = cfParcel.get();

// e.g. Delivering: Parcel-[Order: #332 | Product 1, Product 2,
// Product 3, ... Invoice: $314]
logger.info(() -> "Delivering: " + parcel);

thenCombine()的回调函数将在两个CompletableFuture实例完成后调用。

如果我们只需要在两个CompletableFuture实例正常完成时做一些事情(这个和另一个),那么我们可以依赖thenAcceptBoth()。此方法返回一个新的CompletableFuture,将这两个结果作为所提供操作的参数来执行。这两个结果是此阶段和另一个给定阶段(它们必须正常完成)。下面是一个示例:

CompletableFuture<Void> voidResult = CompletableFuture
  .supplyAsync(() -> "Pick")
  .thenAcceptBoth(CompletableFuture.supplyAsync(() -> " me"),
    (pick, me) -> System.out.println(pick + me));

如果不需要这两个CompletableFuture实例的结果,则runAfterBoth()更为可取。

通过allOf()的组合

假设我们要下载以下发票列表:

List<String> invoices = Arrays.asList("#2334", "#122", "#55");

这可以看作是一堆可以并行完成的独立任务,所以我们可以使用CompletableFuture来完成,如下所示:

public static CompletableFuture<String> 
    downloadInvoices(String invoice) {

  return CompletableFuture.supplyAsync(() -> {
    logger.info(() -> "Downloading invoice: " + invoice);

    return "Downloaded invoice: " + invoice;
  });
}

CompletableFuture<String> [] cfInvoices = invoices.stream()
  .map(CustomerAsyncs::downloadInvoices)
  .toArray(CompletableFuture[]::new);

此时,我们有一个CompletableFuture实例数组,因此,还有一个异步计算数组。此外,我们希望并行运行它们。这可以通过allOf​(CompletableFuture<?>... cfs)方法实现。结果由一个CompletableFuture<Void>组成,如下所示:

CompletableFuture<Void> cfDownloaded 
  = CompletableFuture.allOf(cfInvoices);
cfDownloaded.get();

显然,allOf()的结果不是很有用。我们能用CompletableFuture<Void>做什么?在这个并行化过程中,当我们需要每次计算的结果时,肯定会遇到很多问题,因此我们需要一个获取结果的解决方案,而不是依赖于CompletableFuture<Void>

我们可以通过thenApply()来解决这个问题,如下所示:

List<String> results = cfDownloaded.thenApply(e -> {
  List<String> downloaded = new ArrayList<>();

  for (CompletableFuture<String> cfInvoice: cfInvoices) {
    downloaded.add(cfInvoice.join());
  }

  return downloaded;
}).get();

join()方法类似于get(),但是,如果基础CompletableFuture异常完成,则抛出非受检异常。

由于我们在所有相关CompletableFuture完成后调用join(),因此没有阻塞点。

返回的List<String>包含调用downloadInvoices()方法得到的结果,如下所示:

Downloaded invoice: #2334

Downloaded invoice: #122

Downloaded invoice: #55

通过anyOf()的组合

假设我们想为客户组织一次抽奖活动:

List<String> customers = Arrays.asList(
  "#1", "#4", "#2", "#7", "#6", "#5"
);

我们可以通过定义以下琐碎的方法开始解决这个问题:

public static CompletableFuture<String> raffle(String customerId) {

  return CompletableFuture.supplyAsync(() -> {
    Thread.sleep(new Random().nextInt(5000));

    return customerId;
  });
}

现在,我们可以创建一个CompletableFuture<String>实例数组,如下所示:

CompletableFuture<String>[] cfCustomers = customers.stream()
  .map(CustomerAsyncs::raffle)
  .toArray(CompletableFuture[]::new);

为了找到抽奖的赢家,我们要并行运行cfCustomers,第一个完成的CompletableFuture就是赢家。因为raffle()方法阻塞随机数秒,所以将随机选择获胜者。我们对其余的CompletableFuture实例不感兴趣,所以应该在选出获胜者后立即完成。

这是anyOf​(CompletableFuture<?>... cfs)的工作。它返回一个新的CompletableFuture,当涉及的任何CompletableFuture实例完成时,这个新的CompletableFuture就完成了。让我们在工作中看看:

CompletableFuture<Object> cfWinner 
  = CompletableFuture.anyOf(cfCustomers);

Object winner = cfWinner.get();

// e.g., Winner: #2
logger.info(() -> "Winner: " + winner);

注意依赖于返回不同类型结果的CompletableFuture的场景。因为anyOf()返回CompletableFuture<Object>,所以很难知道先完成的CompletableFuture类型。

218 优化忙等待

忙等待技术(也称为忙循环旋转)由检查条件(通常,标志条件)的循环组成。例如,以下循环等待服务启动:

private volatile boolean serviceAvailable;
...
while (!serviceAvailable) {}

Java9 介绍了Thread.onSpinWait()方法。这是一个热点,它向 JVM 提示以下代码处于自旋循环中:

while (!serviceAvailable) {
  Thread.onSpinWait();
}

英特尔 SSE2 暂停指令正是出于这个原因提供的。有关详细信息,请参阅英特尔官方文档。也看看这个链接

如果我们在上下文中添加这个while循环,那么我们得到以下类:

public class StartService implements Runnable {

  private volatile boolean serviceAvailable;

  @Override
  public void run() {
    System.out.println("Wait for service to be available ...");

    while (!serviceAvailable) {
      // Use a spin-wait hint (ask the processor to
      // optimize the resource)
      // This should perform better if the underlying
      // hardware supports the hint
      Thread.onSpinWait();
    }

    serviceRun();
  }

  public void serviceRun() {
    System.out.println("Service is running ...");
  }

  public void setServiceAvailable(boolean serviceAvailable) {
    this.serviceAvailable = serviceAvailable;
  }
}

而且,我们可以很容易地测试它(不要期望看到onSpinWait()的效果):

StartService startService = new StartService();
new Thread(startService).start();

Thread.sleep(5000);

startService.setServiceAvailable(true);

219 任务取消

取消是一种常用的技术,用于强制停止或完成当前正在运行的任务。取消的任务不会自然完成。取消对已完成的任务没有影响。可以将其视为 GUI 的取消按钮。

Java 没有提供一种抢先停止线程的方法。因此,对于取消任务,通常的做法是依赖于使用标志条件的循环。任务的职责是定期检查这个标志,当它找到设置的标志时,它应该尽快停止。下面的代码就是一个例子:

public class RandomList implements Runnable {
  private volatile boolean cancelled;
  private final List<Integer> randoms = new CopyOnWriteArrayList<>();
  private final Random rnd = new Random();

  @Override
  public void run() {
    while (!cancelled) {
      randoms.add(rnd.nextInt(100));
    }
  }

  public void cancel() {
    cancelled = true;
  }

  public List<Integer> getRandoms() {
    return randoms;
  }
}

这里的重点是canceled变量。注意,这个变量被声明为volatile(也称为轻量级同步机制)。作为一个volatile变量,它不会被线程缓存,对它的操作也不会在内存中重新排序;因此,线程看不到旧值。任何读取volatile字段的线程都将看到最近写入的值。这正是我们所需要的,以便将取消操作传递给对该操作感兴趣的所有正在运行的线程。下图描述了volatile和非volatile的工作原理:

注意,volatile变量不适合读-修改-写场景。对于这样的场景,我们将依赖于原子变量(例如,AtomicBooleanAtomicIntegerAtomicReference等等)。

现在,让我们提供一个简单的代码片段,用于取消在RandomList中实现的任务:

RandomList rl = new RandomList();

ExecutorService executor = Executors.newFixedThreadPool(10);

for (int i = 0; i < 100; i++) {
  executor.execute(rl);
}

Thread.sleep(100);

rl.cancel();

System.out.println(rl.getRandoms());

220 ThreadLocal

Java 线程共享相同的内存,但有时我们需要为每个线程提供专用内存。Java 提供ThreadLocal作为一种方法,分别存储和检索每个线程的值。ThreadLocal的一个实例可以存储和检索多个线程的值。如果线程A存储x值,线程BThreadLocal的同一实例中存储y值,那么稍后,线程A检索x值,线程B检索y值。

JavaThreadLocal通常用于以下两种场景:

  • 用于提供线程级别的实例(线程安全和内存效率)

  • 用于提供线程级别的上下文

让我们在下一节中看看每个场景的问题。

线程级别的实例

假设我们有一个使用StringBuilder类型的全局变量的单线程应用。为了在多线程应用中转换应用,我们必须处理StringBuilder,它不是线程安全的。基本上,我们有几种方法,例如同步和StringBuffer或其他方法。不过,我们也可以使用ThreadLocal。这里的主要思想是为每个线程提供一个单独的StringBuilder。使用ThreadLocal,我们可以做如下操作:

private static final ThreadLocal<StringBuilder> 
    threadLocal = new ThreadLocal<>() {

  @Override
  protected StringBuilder initialValue() {
    return new StringBuilder("ThreadSafe ");
  }
};

此线程局部变量的当前线程的初始值通过initialValue()方法设置。在 Java8 中,可以通过withInitial()重写如下:

private static final ThreadLocal<StringBuilder> threadLocal 
    = ThreadLocal.<StringBuilder> withInitial(() -> {

  return new StringBuilder("Thread-safe ");
});

使用get()set()ThreadLocal进行操作。set()的每次调用都将给定的值存储在只有当前线程才能访问的内存区域中。稍后,调用get()将从该区域检索值。另外,一旦工作完成,建议通过调用ThreadLocal实例上的remove()set(null)方法来避免内存泄漏。

让我们看看ThreadLocal在工作中使用Runnable

public class ThreadSafeStringBuilder implements Runnable {

  private static final Logger logger =
    Logger.getLogger(ThreadSafeStringBuilder.class.getName());
  private static final Random rnd = new Random();

  private static final ThreadLocal<StringBuilder> threadLocal 
      = ThreadLocal.<StringBuilder> withInitial(() -> {

    return new StringBuilder("Thread-safe ");
  });

  @Override
  public void run() {
    logger.info(() -> "-> " + Thread.currentThread().getName() 
      + " [" + threadLocal.get() + "]");

    Thread.sleep(rnd.nextInt(2000));

    // threadLocal.set(new StringBuilder(
    // Thread.currentThread().getName()));
    threadLocal.get().append(Thread.currentThread().getName());

    logger.info(() -> "-> " + Thread.currentThread().getName() 
      + " [" + threadLocal.get() + "]");

    threadLocal.set(null);
    // threadLocal.remove();

    logger.info(() -> "-> " + Thread.currentThread().getName() 
      + " [" + threadLocal.get() + "]");
  }
}

让我们用几个线程来测试它:

ThreadSafeStringBuilder threadSafe = new ThreadSafeStringBuilder();

for (int i = 0; i < 3; i++) {
  new Thread(threadSafe, "thread-" + i).start();
}

输出显示每个线程访问自己的StringBuilder

[14:26:39] [INFO] -> thread-1 [Thread-safe ]
[14:26:39] [INFO] -> thread-0 [Thread-safe ]
[14:26:39] [INFO] -> thread-2 [Thread-safe ]
[14:26:40] [INFO] -> thread-0 [Thread-safe thread-0]
[14:26:40] [INFO] -> thread-0 [null]
[14:26:41] [INFO] -> thread-1 [Thread-safe thread-1]
[14:26:41] [INFO] -> thread-1 [null]
[14:26:41] [INFO] -> thread-2 [Thread-safe thread-2]
[14:26:41] [INFO] -> thread-2 [null]

在上述场景中,也可以使用ExecutorService

下面是为每个线程提供 JDBCConnection的另一段代码:

private static final ThreadLocal<Connection> connections 
    = ThreadLocal.<Connection> withInitial(() -> {

  try {
    return DriverManager.getConnection("jdbc:mysql://...");
  } catch (SQLException ex) {
    throw new RuntimeException("Connection acquisition failed!", ex);
  }
});

public static Connection getConnection() {
  return connections.get();
}

线程级别的上下文

假设我们有以下Order类:

public class Order {

  private final int customerId;

  public Order(int customerId) {
    this.customerId = customerId;
  }

  // getter and toString() omitted for brevity
}

我们写下CustomerOrder如下:

public class CustomerOrder implements Runnable {

  private static final Logger logger
    = Logger.getLogger(CustomerOrder.class.getName());
  private static final Random rnd = new Random();

  private static final ThreadLocal<Order> 
    customerOrder = new ThreadLocal<>();

  private final int customerId;

  public CustomerOrder(int customerId) {
    this.customerId = customerId;
  }

  @Override
  public void run() {
    logger.info(() -> "Given customer id: " + customerId 
      + " | " + customerOrder.get() 
      + " | " + Thread.currentThread().getName());

    customerOrder.set(new Order(customerId));

    try {
      Thread.sleep(rnd.nextInt(2000));
    } catch (InterruptedException ex) {
      Thread.currentThread().interrupt();
      logger.severe(() -> "Exception: " + ex);
    }

    logger.info(() -> "Given customer id: " + customerId 
      + " | " + customerOrder.get() 
      + " | " + Thread.currentThread().getName());

    customerOrder.remove();
  }
}

对于每一个customerId,我们都有一个我们控制的专用线程:

CustomerOrder co1 = new CustomerOrder(1);
CustomerOrder co2 = new CustomerOrder(2);
CustomerOrder co3 = new CustomerOrder(3);

new Thread(co1).start();
new Thread(co2).start();
new Thread(co3).start();

因此,每个线程修改CustomerOrder的某个实例(每个实例都有一个特定的线程)。

run()方法取给定customerId的顺序,并用set()方法存储在ThreadLocal变量中。

可能的输出如下:

[14:48:20] [INFO] 
  Given customer id: 3 | null | Thread-2
[14:48:20] [INFO] 
  Given customer id: 2 | null | Thread-1
[14:48:20] [INFO] 
  Given customer id: 1 | null | Thread-0

[14:48:20] [INFO] 
  Given customer id: 2 | Order{customerId=2} | Thread-1
[14:48:21] [INFO] 
  Given customer id: 3 | Order{customerId=3} | Thread-2
[14:48:21] [INFO] 
  Given customer id: 1 | Order{customerId=1} | Thread-0

在前一种情况下,避免使用ExecutorService。无法保证(给定的customerId的)每个Runnable在每次执行时都会被同一个线程处理。这可能会导致奇怪的结果。

221 原子变量

通过Runnable计算从 1 到 1000000 的所有数字的简单方法如下所示:

public class Incrementator implements Runnable {

  public [static] int count = 0;

  @Override
  public void run() {
    count++;
  }

  public int getCount() {
    return count;
  }
}

让我们旋转五个线程,同时递增count变量:

Incrementator nonAtomicInc = new Incrementator();
ExecutorService executor = Executors.newFixedThreadPool(5);

for (int i = 0; i < 1 _000_000; i++) {
  executor.execute(nonAtomicInc);
}

但是,如果我们多次运行此代码,会得到不同的结果,如下所示:

997776, 997122, 997681 ...

所以,为什么我们不能得到预期的结果,1000000?原因是count++不是原子操作/动作。它由三个原子字节码指令组成:

iload_1
iinc 1, 1
istore_1

在一个线程中,读取count值并逐个递增,另一个线程读取较旧的值,导致错误的结果。在多线程应用中,调度器可以停止在这些字节码指令之间执行当前线程,并启动一个新线程,该线程在同一个变量上工作。我们可以通过同步来修复问题,或者通过原子变量来更好地解决问题。

原子变量类在java.util.concurrent.atomic中可用。它们是将争用范围限制为单个变量的包装类;它们比 Java 同步轻量级得多,基于 CAS(简称比较交换):现代 CPU 支持这种技术,它将给定内存位置的内容与给定值进行比较,如果当前值等于预期值,则更新为新值。主要是以类似于volatile的无锁方式影响单个值的原子复合作用。最常用的原子变量是标量:

  • AtomicInteger
  • AtomicLong
  • AtomicBoolean
  • AtomicReference

并且,以下是针对数组的:

  • AtomicIntegerArray
  • AtomicLongArray
  • AtomicReferenceArray

让我们通过AtomicInteger重写我们的示例:

public class AtomicIncrementator implements Runnable {

  public static AtomicInteger count = new AtomicInteger();

  @Override
  public void run() {
    count.incrementAndGet();
  }

  public int getCount() {
    return count.get();
  }
}

注意,我们写的不是count++,而是count.incrementAndGet()。这只是AtomicInteger提供的方法之一。此方法以原子方式递增变量并返回新值。这一次,count将是 1000000。

下表列出了几种常用的AtomicInteger方法。左栏包含方法,右栏包含非原子含义:

AtomicInteger ai = new AtomicInteger(0); // atomic
int i = 0; // non-atomic

// and
int q = 5;
int r;

// and
int e = 0;
boolean b;
原子操作 非原子对应物
r = ai.get(); r = i;
ai.set(q); i = q;
r = ai.incrementAndGet(); r = ++i;
r = ai.getAndIncrement(); r = i++;
r = ai.decrementAndGet(); r = --i;
r = ai.getAndDecrement(); r = i--;
r = ai.addAndGet(q); i = i + q; r = i;
r = ai.getAndAdd(q); r = i; i = i + q;
r = ai.getAndSet(q); r = i; i = q;
b = ai.compareAndSet(e, q); if (i == e) { i = q; return true; } else { return false; }

让我们通过原子操作解决几个问题:

  • 通过updateAndGet​(IntUnaryOperator updateFunction)更新数组元素:
// [9, 16, 4, 25]
AtomicIntegerArray atomicArray
  = new AtomicIntegerArray(new int[] {3, 4, 2, 5});

for (int i = 0; i < atomicArray.length(); i++) {
  atomicArray.updateAndGet(i, elem -> elem * elem);
}
  • 通过updateAndGet​(IntUnaryOperator updateFunction)更新单个整数:
// 15
AtomicInteger nr = new AtomicInteger(3);
int result = nr.updateAndGet(x -> 5 * x);
  • 通过accumulateAndGet​(int x, IntBinaryOperator accumulatorFunction)更新单个整数:
// 15
AtomicInteger nr = new AtomicInteger(3);
// x = 3, y = 5
int result = nr.accumulateAndGet(5, (x, y) -> x * y);
  • 通过addAndGet​(int delta)更新单个整数:
// 7
AtomicInteger nr = new AtomicInteger(3);
int result = nr.addAndGet(4);
  • 通过compareAndSet​(int expectedValue, int newValue)更新单个整数:
// 5, true
AtomicInteger nr = new AtomicInteger(3);
boolean wasSet = nr.compareAndSet(3, 5);

从 JDK9 开始,原子变量类被多种方法所丰富,如get/setPlain()get/setOpaque()getAcquire()以及它们的同伴。要了解这些方法,请看一下 Doug Lea 的《使用 JDK9 内存顺序模式》

加法器和累加器

在 JavaAPI 文档之后,如果多线程应用更新频繁,但读取频率较低,建议使用LongAdderDoubleAdderLongAccumulatorDoubleAccumulator,而不是AtomicFoo类。对于这种情况,这些类的设计是为了优化线程的使用。

这意味着,我们不需要使用AtomicInteger来计算从 1 到 1000000 的整数,而可以使用LongAdder如下:

public class AtomicAdder implements Runnable {

  public static LongAdder count = new LongAdder();

  @Override
  public void run() {

    count.add(1);
  }

  public long getCount() {

    return count.sum();
  }
}

或者,我们可以使用LongAccumulator如下:

public class AtomicAccumulator implements Runnable {

  public static LongAccumulator count
    = new LongAccumulator(Long::sum, 0);

  @Override
  public void run() {

    count.accumulate(1);
  }

  public long getCount() {

    return count.get();
  }
}

LongAdderDoubleAdder适用于暗示加法的场景(特定于加法的操作),而LongAccumulatorDoubleAccumulator适用于依赖给定函数组合值的场景。

222 重入锁

Lock接口包含一组锁定操作,可以显式地用于微调锁定过程(它提供比内在锁定更多的控制)。其中,我们有轮询、无条件、定时和可中断的锁获取。基本上,Lock用附加功能公开了synchronized关键字的FutureLock接口如下图所示:

public interface Lock {
  void lock();
  void lockInterruptibly() throws InterruptedException;
  boolean tryLock();
  boolean tryLock(long timeout, TimeUnit unit)
  throws InterruptedException;
  void unlock();
  Condition newCondition();
}

Lock的实现之一是ReentrantLock可重入锁的作用如下:当线程第一次进入锁时,保持计数设置为 1。在解锁之前,线程可以重新进入锁,从而使每个条目的保持计数增加一。每个解锁请求将保留计数减一,当保留计数为零时,将打开锁定的资源。

synchronized关键字具有相同的坐标,ReentrantLock遵循以下实现习惯用法:

Lock / ReentrantLock lock = new ReentrantLock();
...
lock.lock();

try {
  ...
} finally {
  lock.unlock();
}

对于非公平锁,未指定线程被授予访问权限的顺序。如果锁应该是公平的(优先于等待时间最长的线程),那么使用ReentrantLock​(boolean fair)构造器。

通过ReentrantLock将 1 到 1000000 之间的整数求和可以如下完成:

public class CounterWithLock {

  private static final Lock lock = new ReentrantLock();

  private static int count;

  public void counter() {
    lock.lock();

    try {
      count++;
    } finally {
      lock.unlock();
    }
  }
}

让我们通过几个线程来使用它:

CounterWithLock counterWithLock = new CounterWithLock();
Runnable task = () -> {
  counterWithLock.counter();
};

ExecutorService executor = Executors.newFixedThreadPool(8);
for (int i = 0; i < 1 _000_000; i++) {
  executor.execute(task);
}

完成!

另外,下面的代码表示一种基于ReentrantLock.lockInterruptibly()解决问题的习惯用法。绑定到本书的代码附带了一个使用lockInterruptibly()的示例:

Lock / ReentrantLock lock = new ReentrantLock();
public void execute() throws InterruptedException {
  lock.lockInterruptibly();

  try {
    // do something
  } finally {
    lock.unlock();
  }
}

如果持有此锁的线程被中断,则抛出InterruptedException。用lock()代替lockInterruptibly()不接受中断。

另外,下面的代码表示使用ReentrantLock.tryLock(long timeout, TimeUnit unit) throws InterruptedException的习惯用法。本书附带的代码还有一个示例:

Lock / ReentrantLock lock = new ReentrantLock();

public boolean execute() throws InterruptedException {

  if (!lock.tryLock(n, TimeUnit.SECONDS)) {
    return false;
  }

  try {
    // do something
  } finally {
    lock.unlock();
  }

  return true;
}

注意,tryLock()尝试获取指定时间的锁。如果这段时间过去了,那么线程将不会获得锁。它不会自动重试。如果线程在获取锁的过程中被中断,则抛出InterruptedException

最后,绑定到本书的代码附带了一个使用ReentrantLock.newCondition()的示例。下一个屏幕截图显示了这个成语:

223 重入读写锁

通常,读写连接(例如,读写文件)应基于两个语句完成:

  • 只要没有编写器(共享悲观锁),读者就可以同时阅读。

  • 单个写入程序一次可以写入(独占/悲观锁定)。

下图显示了左侧的读取器和右侧的写入器:

主要由ReentrantReadWriteLock实现以下行为:

  • 为两个锁(读锁和写锁)提供悲观锁语义。

  • 如果某些读取器持有读锁,而某个写入器需要写锁,则在写入器释放写锁之前,不允许更多的读取器获取读锁。

  • 写入程序可以获得读锁,但读取器不能获得写锁。

对于非公平锁,未指定线程被授予访问权限的顺序。如果锁应该是公平的(优先于等待时间最长的线程),那么使用ReentrantReadWriteLock​(boolean fair)构造器。

ReentrantReadWriteLock的用法如下:

ReadWriteLock / ReentrantReadWriteLock lock 
  = new ReentrantReadWriteLock();
...
lock.readLock() / writeLock().lock();
try {
  ...
} finally {
  lock.readLock() / writeLock().unlock();
}

下面的代码表示一个ReentrantReadWriteLock用例,它读取和写入一个整数量变量:

public class ReadWriteWithLock {

  private static final Logger logger
    = Logger.getLogger(ReadWriteWithLock.class.getName());
  private static final Random rnd = new Random();

  private static final ReentrantReadWriteLock lock
    = new ReentrantReadWriteLock(true);

  private static final Reader reader = new Reader();
  private static final Writer writer = new Writer();

  private static int amount;

  private static class Reader implements Runnable {

    @Override
    public void run() {
      if (lock.isWriteLocked()) {
        logger.warning(() -> Thread.currentThread().getName() 
          + " reports that the lock is hold by a writer ...");
      }

      lock.readLock().lock();

      try {
        logger.info(() -> "Read amount: " + amount 
          + " by " + Thread.currentThread().getName());
      } finally {
        lock.readLock().unlock();
      }
    }
  }

  private static class Writer implements Runnable {

    @Override
    public void run() {
        lock.writeLock().lock();
        try {
          Thread.sleep(rnd.nextInt(2000));
          logger.info(() -> "Increase amount with 10 by " 
            + Thread.currentThread().getName());

          amount += 10;
        } catch (InterruptedException ex) {
          Thread.currentThread().interrupt();
          logger.severe(() -> "Exception: " + ex);
        } finally {
          lock.writeLock().unlock();
        }
      }
      ...
  }

让我们用两个读取器和四个写入器执行 10 次读卡和 10 次写卡:

ExecutorService readerService = Executors.newFixedThreadPool(2);
ExecutorService writerService = Executors.newFixedThreadPool(4);

for (int i = 0; i < 10; i++) {
  readerService.execute(reader);
  writerService.execute(writer);
}

可能的输出如下:

[09:09:25] [INFO] Read amount: 0 by pool-1-thread-1
[09:09:25] [INFO] Read amount: 0 by pool-1-thread-2
[09:09:26] [INFO] Increase amount with 10 by pool-2-thread-1
[09:09:27] [INFO] Increase amount with 10 by pool-2-thread-2
[09:09:28] [INFO] Increase amount with 10 by pool-2-thread-4
[09:09:29] [INFO] Increase amount with 10 by pool-2-thread-3
[09:09:29] [INFO] Read amount: 40 by pool-1-thread-2
[09:09:29] [INFO] Read amount: 40 by pool-1-thread-1
[09:09:31] [INFO] Increase amount with 10 by pool-2-thread-1
...

在决定依赖ReentrantReadWriteLock之前,请考虑它可能会挨饿(例如,当作家被优先考虑时,读者可能会挨饿)。此外,我们无法将读锁升级为写锁(可以从写入器降级为读取器),并且不支持乐观读。如果其中任何一个问题对您来说很重要,那么请考虑StampedLock,我们将在下一个问题中研究它。

224 冲压锁

简言之,StampedLock的性能优于ReentrantReadWriteLock,支持乐观读取。它不像可重入的;因此容易死锁。主要地,锁获取返回一个戳记(一个long值),它在finally块中用于解锁。每次尝试获取一个锁都会产生一个新的戳,如果没有可用的锁,那么它可能会阻塞,直到可用为止。换句话说,如果当前线程持有锁,并且再次尝试获取锁,则可能导致死锁。

StampedLock读/写编排过程通过以下几种方法实现:

  • readLock():非独占获取锁,必要时阻塞,直到可用。对于获取读锁的非阻塞尝试,我们必须tryReadLock()。对于超时阻塞,我们有tryReadLock​(long time, TimeUnit unit)。退回的印章用于unlockRead()

  • writeLock():独占获取锁,必要时阻塞直到可用。对于获取写锁的非阻塞尝试,我们有tryWriteLock()。对于超时阻塞,我们有tryWriteLock​(long time, TimeUnit unit)。退回的印章用于unlockWrite()

  • tryOptimisticRead():这是给StampedLock增加一个大加号的方法。此方法返回一个应通过validate​()标志方法验证的戳记。如果锁当前未处于写入模式,则返回的戳记仅为非零。

readLock()writeLock()的成语非常简单:

StampedLock lock = new StampedLock();
...
long stamp = lock.readLock() / writeLock();

try {
  ...
} finally {
  lock.unlockRead(stamp) / unlockWrite(stamp);
}

试图给tryOptimisticRead()一个成语可能会导致以下结果:

StampedLock lock = new StampedLock();

int x; // a writer-thread can modify x
...
long stamp = lock.tryOptimisticRead();
int thex = x;

if (!lock.validate(stamp)) {
  stamp = lock.readLock();

  try {
    thex = x;
  } finally {
    lock.unlockRead(stamp);
  }
}

return thex;

在这个习惯用法中,注意初始值(x)是在获得乐观读锁之后分配给thex变量的。然后利用validate()标志法验证了自给定戳的发射度以来,戳锁没有被独占获取。如果validate()返回false(相当于在获得乐观锁之后由线程获取写锁),则通过阻塞readLock()获取读锁,并再次赋值(x。请记住,如果有任何写锁,读锁可能会阻塞。获取乐观锁允许我们读取值,然后验证这些值是否有任何更改。只有在存在的情况下,我们才能通过阻塞读锁。

下面的代码表示一个StampedLock用例,它读取和写入一个整数量变量。基本上,我们通过乐观的方式重申了前一个问题的解决方案:

public class ReadWriteWithStampedLock {

  private static final Logger logger
    = Logger.getLogger(ReadWriteWithStampedLock.class.getName());
  private static final Random rnd = new Random();

  private static final StampedLock lock = new StampedLock();

  private static final OptimisticReader optimisticReader
    = new OptimisticReader();
  private static final Writer writer = new Writer();

  private static int amount;

  private static class OptimisticReader implements Runnable {

    @Override
    public void run() {
      long stamp = lock.tryOptimisticRead();

      // if the stamp for tryOptimisticRead() is not valid
      // then the thread attempts to acquire a read lock
      if (!lock.validate(stamp)) {
        stamp = lock.readLock();
        try {
          logger.info(() -> "Read amount (read lock): " + amount 
            + " by " + Thread.currentThread().getName());
        } finally {
          lock.unlockRead(stamp);
        }
      } else {
        logger.info(() -> "Read amount (optimistic read): " + amount 
          + " by " + Thread.currentThread().getName());
      }
    }
  }

  private static class Writer implements Runnable {

    @Override
    public void run() {

      long stamp = lock.writeLock();

      try {
        Thread.sleep(rnd.nextInt(2000));
        logger.info(() -> "Increase amount with 10 by " 
          + Thread.currentThread().getName());

        amount += 10;
      } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
        logger.severe(() -> "Exception: " + ex);
      } finally {
        lock.unlockWrite(stamp);
      }
    }
  }
  ...
}

让我们用两个读取器和四个写入器执行 10 次读卡和 10 次写卡:

ExecutorService readerService = Executors.newFixedThreadPool(2);
ExecutorService writerService = Executors.newFixedThreadPool(4);

for (int i = 0; i < 10; i++) {
  readerService.execute(optimisticReader);
  writerService.execute(writer);
}

可能的输出如下:

...
[12:12:07] [INFO] Increase amount with 10 by pool-2-thread-4
[12:12:07] [INFO] Read amount (read lock): 90 by pool-1-thread-2
[12:12:07] [INFO] Read amount (optimistic read): 90 by pool-1-thread-2
[12:12:07] [INFO] Increase amount with 10 by pool-2-thread-1
...

从 JDK10 开始,我们可以使用isWriteLockStamp()isReadLockStamp()isLockStamp()isOptimisticReadStamp()查询戳记的类型。根据类型,我们可以决定合适的解锁方法,例如:

if (StampedLock.isReadLockStamp(stamp))
  lock.unlockRead(stamp);
}

在捆绑到本书的代码中,还有一个应用,用于举例说明tryConvertToWriteLock​()方法。此外,您可能对开发使用tryConvertToReadLock​()tryConvertToOptimisticRead()的应用感兴趣。

225 死锁(哲学家聚餐)

什么是僵局?网上一个著名的笑话解释如下:

面试官:向我们解释一下,我们会雇佣你的!

:雇佣我,我会向你解释...

简单的死锁可以解释为一个持有L锁并试图获取锁的A线程,同时,还有一个持有P锁并试图获取L锁的B线程。这种死锁称为循环等待。Java 没有死锁检测和解决机制(就像数据库一样),因此死锁对于应用来说非常尴尬。死锁可能会完全或部分阻塞应用,会导致严重的性能损失、奇怪的行为等等。通常情况下,死锁很难调试,解决死锁的唯一方法是重新启动应用,并希望取得最好的结果。

哲学家吃饭是一个著名的问题,用来说明僵局。这个问题说五位哲学家围坐在一张桌子旁。他们每个人轮流思考和吃饭。为了吃饭,哲学家需要双手叉子左手叉子右手叉子。困难是因为只有五个叉子。吃完后,哲学家把两个叉子放回桌上,然后由另一个重复同样循环的哲学家拿起。当一个哲学家不吃饭时,他/她在思考。下图说明了这种情况:

主要任务是找到解决这个问题的办法,让哲学家们思考和进食,以避免饿死。

在《法典》中,我们可以把每个哲学家看作一个实例。作为Runnable实例,我们可以在不同的线程中执行它们。每个哲学家都能拿起两个叉子放在他左右两侧。如果我们将叉表示为String,则可以使用以下代码:

public class Philosopher implements Runnable {

  private final String leftFork;
  private final String rightFork;

  public Philosopher(String leftFork, String rightFork) {
    this.leftFork = leftFork;
    this.rightFork = rightFork;
  }

  @Override
  public void run() {
    // implemented below
  }
}

所以,哲学家可以拿起leftForkrightFork。但是,由于哲学家们共用这些叉子,哲学家必须在这两个叉子上获得唯一的锁。leftFork上有专用锁,且rightFork上有专用锁,等于手中有两个叉子。leftForkrightFork上有专属锁,相当于哲学家的饮食。释放两个专属锁就等于哲学家不吃不思。

通过synchronized关键字可以实现锁定,如下run()方法:

@Override
public void run() {

  while (true) {
    logger.info(() -> Thread.currentThread().getName() 
      + ": thinking");
    doIt();

    synchronized(leftFork) {
      logger.info(() -> Thread.currentThread().getName() 
        + ": took the left fork (" + leftFork + ")");
      doIt();

      synchronized(rightFork) {
        logger.info(() -> Thread.currentThread().getName() 
          + ": took the right fork (" + rightFork + ") and eating");
        doIt();

        logger.info(() -> Thread.currentThread().getName() 
          + ": put the right fork ( " + rightFork 
          + ") on the table");
        doIt();
      }

      logger.info(() -> Thread.currentThread().getName() 
        + ": put the left fork (" + leftFork 
        + ") on the table and thinking");
      doIt();
    }
  }
}

哲学家从思考开始。过了一会儿他饿了,所以他试着拿起左叉子和右叉子。如果成功,他会吃一会儿。后来,他把叉子放在桌子上,继续思考,直到他又饿了。同时,另一个哲学家会吃东西。

doIt()方法通过随机睡眠模拟所涉及的动作(思考、进食、采摘和放叉)。代码中可以看到以下内容:

private static void doIt() {
  try {
    Thread.sleep(rnd.nextInt(2000));
  } catch (InterruptedException ex) {
    Thread.currentThread().interrupt();
    logger.severe(() -> "Exception: " + ex);
  }
}

最后,我们需要福克斯和哲学家,请参见以下代码:

String[] forks = {
  "Fork-1", "Fork-2", "Fork-3", "Fork-4", "Fork-5"
};

Philosopher[] philosophers = {
  new Philosopher(forks[0], forks[1]),
  new Philosopher(forks[1], forks[2]),
  new Philosopher(forks[2], forks[3]),
  new Philosopher(forks[3], forks[4]),
  new Philosopher(forks[4], forks[0])
};

每个哲学家都将在一个线程中运行,如下所示:

Thread threadPhilosopher1 
  = new Thread(philosophers[0], "Philosopher-1");
...
Thread threadPhilosopher5 
  = new Thread(philosophers[4], "Philosopher-5");

threadPhilosopher1.start();
...
threadPhilosopher5.start();

这个实现似乎还可以,甚至可以正常工作一段时间。但是,此实现迟早会以如下方式阻止输出:

[17:29:21] [INFO] Philosopher-5: took the left fork (Fork-5)
...
// nothing happens

这是僵局!每个哲学家都有左手叉(锁在上面),等待右手叉放在桌子上(锁要放了)。显然,这种期望是不能满足的,因为只有五个叉子,每个哲学家手里都有一个叉子。

为了避免这种死锁,有一个非常简单的解决方案。我们只是强迫其中一个哲学家先拿起正确的叉子。在成功地选择了右叉子之后,他可以试着选择左叉子。在代码中,这是对以下行的快速修改:

// the original line
new Philosopher(forks[4], forks[0])

// the modified line that eliminates the deadlock
new Philosopher(forks[0], forks[4])

这一次我们可以在没有死锁的情况下运行应用。

总结

好吧,就这些!本章讨论了 Fork/Join 框架、CompletableFutureReentrantLockReentrantReadWriteLockStampedLock、原子变量、任务取消、可中断方法、线程局部和死锁等问题

从本章下载应用以查看结果和其他详细信息。**

十二、Optional

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括 24 个问题,旨在提请您注意使用Optional的几个规则。本节介绍的问题和解决方案基于 Java 语言架构师 Brian Goetz 的定义:

Optional旨在为库方法返回类型提供一种有限的机制,在这种情况下,需要有一种明确的方式来表示无结果,并且使用null表示这种结果极有可能导致错误。”

但有规则的地方也有例外。因此,不要认为应该不惜一切代价遵守(或避免)这里提出的规则(或实践)。一如既往,这取决于问题,你必须评估形势,权衡利弊。

您还可以检查 CDI 插件。这是一个利用Optional模式的 Jakarta EE/JavaEE 容错保护。它的力量在于它的简单。

问题

使用以下问题来测试你的Optional编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 初始化Optional:编写一个程序,说明初始化Optional的正确和错误方法。

  2. Optional.get()和缺失值:编写一个程序,举例说明Optional.get()的正确用法和错误用法。

  3. 返回一个已经构造好的默认值:编写一个程序,当没有值时,通过Optional.orElse()方法设置(或返回)一个已经构造好的默认值。

  4. 返回一个不存在的默认值:编写一个程序,当没有值时,通过Optional.orElseGet()方法设置(或返回)一个不存在的默认值。

  5. 抛出NoSuchElementException:编写一个程序,当没有值时抛出NoSuchElementException类型的异常或另一个异常。

  6. Optionalnull引用:编写一个例示Optional.orElse(null)正确用法的程序。

  7. 消耗当前Optional:通过ifPresent()ifPresentElse()编写消耗当前Optional类的程序。

  8. 返回当前Optional类或另一个:假设我们有Optional。编写一个依赖于Optional.or()返回这个Optional(如果它的值存在)或另一个Optional类(如果它的值不存在)的程序。

  9. 通过orElseFoo()链接 Lambda:编写一个程序,举例说明orElse()orElseFoo()的用法,以避免破坏 Lambda 链。

  10. 不要仅仅为了得到一个值而使用Optional:举例说明将Optional方法链接起来的坏做法,目的只是为了得到一些值。

  11. 不要将Optional用于字段:举例说明声明Optional类型字段的不良做法。

  12. 在构造器参数中不要使用Optional:说明在构造器参数中使用Optional的不良做法。

  13. 不要在设置器参数中使用Optional:举例说明在设置器参数中使用Optional的不良做法。

  14. 不要在方法参数中使用Optional:举例说明在方法参数中使用Optional的不良做法。

  15. 不要使用Optional返回空的或null集合或数组:举例说明使用Optional返回空的/null集合或数组的不良做法。

  16. 在集合中避免Optional:在集合中使用Optional可能是一种设计气味。举例说明一个典型的用例和避免集合中的Optional的可能替代方案。

  17. 混淆of()ofNullable():举例说明混淆Optional.of()ofNullable()的潜在后果。

  18. Optional<T>OptionalInt:举例说明非泛型OptionalInt代替Optional<T>的用法。

  19. 断言Optional类的相等:举例说明断言Optional类的相等。

  20. 通过map()flatMap()转换值:写几个代码片段来举例说明Optional.map()flatMap()的用法。

  21. 通过Optional.filter()过滤值:举例说明Optional.filter()基于预定义规则拒绝包装值的用法。

  22. 链接OptionalStreamAPI:举例说明Optional.stream()用于链接OptionalAPI 和StreamAPI。

  23. Optional和身份敏感操作:编写一段代码,支持在Optional的情况下应避免身份敏感的操作。

  24. 返回Optional是否为空的boolean:写两段代码举例说明给定Optional类为空时返回boolean的两种解决方案。

解决方案

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释仅包括解决问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多详细信息,并在这个页面中试用程序。

226 初始化Optional

初始化Optional应该通过Optional.empty()而不是null进行:

// Avoid
Optional<Book> book = null;

// Prefer
Optional<Book> book = Optional.empty();

因为Optional是一个容器(盒子),所以用null初始化它是没有意义的。

227 Optional.get()和缺失值

因此,如果我们决定调用Optional.get()来获取Optional中包含的值,那么我们不应该按如下方式进行:

Optional<Book> book = ...; // this is prone to be empty

// Avoid
// if "book" is empty then the following code will
// throw a java.util.NoSuchElementException
Book theBook = book.get();

换句话说,在通过Optional.get()获取值之前,我们需要证明值是存在的。解决方案是先调用isPresent(),再调用get()。这样,我们添加了一个检查,允许我们处理缺少值的情况:

Optional<Book> book = ...; // this is prone to be empty

// Prefer
if (book.isPresent()) {
  Book theBook = book.get();
  ... // do something with "theBook"
} else {
  ... // do something that does not call book.get()
}

不过,要记住isPresent()-get()团队信誉不好,所以谨慎使用。考虑检查下一个问题,这些问题为这个团队提供了替代方案。而且,在某个时刻,Optional.get()很可能被否决。

228 返回已构造的默认值

假设我们有一个基于Optional返回结果的方法。如果Optional为空,则该方法返回默认值。如果我们考虑前面的问题,那么一个可能的解决方案可以写成如下:

public static final String BOOK_STATUS = "UNKNOWN";
...
// Avoid
public String findStatus() {
  Optional<String> status = ...; // this is prone to be empty

  if (status.isPresent()) {
    return status.get();
  } else {
    return BOOK_STATUS;
  }
}

嗯,这不是一个坏的解决方案,但不是很优雅。一个更简洁和优雅的解决方案将依赖于Optional.orElse()方法。在Optional类为空的情况下,当我们想要设置或返回默认值时,这个方法对于替换isPresent()-get()对非常有用。前面的代码片段可以重写如下:

public static final String BOOK_STATUS = "UNKNOWN";
...
// Prefer
public String findStatus() {
  Optional<String> status = ...; // this is prone to be empty

  return status.orElse(BOOK_STATUS);
}

但要记住,即使涉及的Optional类不是空的,也要对orElse()进行求值。换句话说,orElse()即使不使用它的值也会被求值。既然如此,最好只在其参数是已经构造的值时才依赖orElse()。这样,我们就可以减轻潜在的性能惩罚。下一个问题是orElse()不是正确的选择时解决的。

229 返回不存在的默认值

假设我们有一个方法,它基于Optional类返回结果。如果该Optional类为空,则该方法返回计算值。computeStatus()方法计算此值:

private String computeStatus() {
  // some code used to compute status
}

现在,一个笨拙的解决方案将依赖于isPresent()-get()对,如下所示:

// Avoid
public String findStatus() {
  Optional<String> status = ...; // this is prone to be empty

  if (status.isPresent()) {
    return status.get();
  } else {
    return computeStatus();
  }
}

即使这种解决方法很笨拙,也比依赖orElse()方法要好,如下所示:

// Avoid
public String findStatus() {
  Optional<String> status = ...; // this is prone to be empty

  // computeStatus() is called even if "status" is not empty
  return status.orElse(computeStatus());
}

在这种情况下,首选解决方案依赖于Optional.orElseGet()方法。此方法的参数是Supplier;,因此只有在Optional值不存在时才执行。这比orElse()好得多,因为它避免了我们执行当Optional值存在时不应该执行的额外代码。因此,优选方案如下:

// Prefer
public String findStatus() {
  Optional<String> status = ...; // this is prone to be empty

  // computeStatus() is called only if "status" is empty
  return status.orElseGet(this::computeStatus);
}

230 抛出NoSuchElementException

有时,如果Optional为空,我们希望抛出一个异常(例如,NoSuchElementException)。这个问题的笨拙解决方法如下:

// Avoid
public String findStatus() {

  Optional<String> status = ...; // this is prone to be empty

  if (status.isPresent()) {
    return status.get();
  } else {
    throw new NoSuchElementException("Status cannot be found");
  }
}

但一个更优雅的解决方案将依赖于Optional.orElseThrow()方法。此方法的签名orElseThrow(Supplier<? extends X> exceptionSupplier)允许我们给出如下异常(如果存在值,orElseThrow()将返回该值):

// Prefer
public String findStatus() {

  Optional<String> status = ...; // this is prone to be empty

  return status.orElseThrow(
    () -> new NoSuchElementException("Status cannot be found"));
}

或者,另一个异常是,例如,IllegalStateException

// Prefer
public String findStatus() {

  Optional<String> status = ...; // this is prone to be empty

  return status.orElseThrow(
    () -> new IllegalStateException("Status cannot be found"));
}

从 JDK10 开始,Optional富含orElseThrow()风味,没有任何争议。此方法隐式抛出NoSuchElementException

// Prefer (JDK10+)
public String findStatus() {

  Optional<String> status = ...; // this is prone to be empty

  return status.orElseThrow();
}

然而,要注意,在生产中抛出非受检的异常而不带有意义的消息是不好的做法。

231 Optional和空引用

在某些情况下,可以使用接受null引用的方法来利用orElse(null)

本场景的候选对象是 Java 反射 APIMethod.invoke()(见第 7 章、“Java 反射类、接口、构造器、方法、字段”。

Method.invoke()的第一个参数表示要调用此特定方法的对象实例。如果方法是static,那么第一个参数应该是null,因此不需要对象的实例。

假设我们有一个名为Book的类和辅助方法,如下所示。

此方法返回空的Optional类(如果给定方法是static)或包含Book实例的Optional类(如果给定方法是非static):

private static Optional<Book> fetchBookInstance(Method method) {

  if (Modifier.isStatic(method.getModifiers())) {
    return Optional.empty();
  }

  return Optional.of(new Book());
}

调用此方法非常简单:

Method method = Book.class.getDeclaredMethod(...);

Optional<Book> bookInstance = fetchBookInstance(method);

另外,如果Optional为空(即方法为static,则需要将null传递给Method.invoke(),否则传递Book实例。笨拙的解决方案可能依赖于isPresent()-get()对,如下所示:

// Avoid
if (bookInstance.isPresent()) {
  method.invoke(bookInstance.get());
} else {
  method.invoke(null);
}

但这非常适合Optional.orElse(null)。以下代码将解决方案简化为一行代码:

// Prefer
method.invoke(bookInstance.orElse(null));

根据经验,只有当我们有Optional并且需要null引用时,才应该使用orElse(null)。否则,请避开orElse(null)

232 使用当前Optional

有时候,我们想要的只是消费一个类。如果Optional不存在,则无需进行任何操作。不熟练的解决方案将依赖于isPresent()-get()对,如下所示:

// Avoid
public void displayStatus() {
  Optional<String> status = ...; // this is prone to be empty

  if (status.isPresent()) {
    System.out.println(status.get());
  }
}

更好的解决方案依赖于ifPresent(),它以Consumer为参数。当我们只需要消耗现值时,这是一个替代isPresent()-get()对的方法。代码可以重写如下:

// Prefer
public void displayStatus() {
  Optional<String> status = ...; // this is prone to be empty

  status.ifPresent(System.out::println);
}

但在其他情况下,如果Optional不存在,那么我们希望执行一个基于空的操作。基于isPresent()get()对的解决方案如下:

// Avoid
public void displayStatus() {
  Optional<String> status = ...; // this is prone to be empty

  if (status.isPresent()) {
    System.out.println(status.get());
  } else {
    System.out.println("Status not found ...");
  }
}

再说一次,这不是最好的选择。或者,我们可以指望ifPresentOrElse()。这种方法从 JDK9 开始就有了,与ifPresent()方法类似,唯一的区别是它也涵盖了else分支:

// Prefer
public void displayStatus() {
  Optional<String> status = ...; // this is prone to be empty

  status.ifPresentOrElse(System.out::println,
    () -> System.out.println("Status not found ..."));
}

233 返回当前Optional类或其他类

让我们考虑一个返回Optional类的方法。主要地,这个方法计算一个Optional类,如果它不是空的,那么它只返回这个Optional类。否则,如果计算出的Optional类为空,那么我们执行一些其他操作,该操作也返回Optional类。

isPresent()-get()对可以按如下方式进行(应避免这样做):

private final static String BOOK_STATUS = "UNKNOWN";
...
// Avoid
public Optional<String> findStatus() {
  Optional<String> status = ...; // this is prone to be empty

  if (status.isPresent()) {
    return status;
  } else {
    return Optional.of(BOOK_STATUS);
  }
}

或者,我们应该避免以下构造:

return Optional.of(status.orElse(BOOK_STATUS));
return Optional.of(status.orElseGet(() -> (BOOK_STATUS)));

从 JDK9 开始就有了最佳的解决方案,它由Optional.or()方法组成。此方法能够返回描述值的Optional。否则,返回给定的Supplier函数产生的Optional(产生要返回的Optional的供给函数):

private final static String BOOK_STATUS = "UNKNOWN";
...
// Prefer
public Optional<String> findStatus() {
  Optional<String> status = ...; // this is prone to be empty

  return status.or(() -> Optional.of(BOOK_STATUS));
}

234 通过orElseFoo()链接 Lambda

一些特定于 Lambda 表达式的操作返回Optional(例如,findFirst()findAny()reduce()等)。试图通过isPresent()-get()对来处理这些Optional类是一个麻烦的解决方案,因为我们必须打破 Lambda 链,通过if-else块添加一些条件代码,并考虑恢复该链。

以下代码片段显示了这种做法:

private static final String NOT_FOUND = "NOT FOUND";

List<Book> books...;
...
// Avoid
public String findFirstCheaperBook(int price) {

  Optional<Book> book = books.stream()
    .filter(b -> b.getPrice()<price)
    .findFirst();

  if (book.isPresent()) {
    return book.get().getName();
  } else {
    return NOT_FOUND;
  }
}

再往前一步,我们可能会得到如下结果:

// Avoid
public String findFirstCheaperBook(int price) {

  Optional<Book> book = books.stream()
    .filter(b -> b.getPrice()<price)
    .findFirst();

  return book.map(Book::getName)
    .orElse(NOT_FOUND);
}

orElse()代替isPresent()-get()对更为理想。但如果我们直接在 Lambda 链中使用orElse()(和orElseFoo(),避免代码中断,则效果会更好:

private static final String NOT_FOUND = "NOT FOUND";
...
// Prefer
public String findFirstCheaperBook(int price) {

  return books.stream()
    .filter(b -> b.getPrice()<price)
    .findFirst()
    .map(Book::getName)
    .orElse(NOT_FOUND);
}

我们再来一个问题。

这一次,我们有几本书的作者,我们要检查某一本书是否是作者写的。如果我们的作者没有写给定的书,那么我们想抛出NoSuchElementException

一个非常糟糕的解决方案如下:

// Avoid
public void validateAuthorOfBook(Book book) {
  if (!author.isPresent() ||
    !author.get().getBooks().contains(book)) {
    throw new NoSuchElementException();
  }
}

另一方面,使用orElseThrow()可以非常优雅地解决问题:

// Prefer
public void validateAuthorOfBook(Book book) {
  author.filter(a -> a.getBooks().contains(book))
    .orElseThrow();
}

235 不要仅为获取值而使用Optional

这个问题从不要使用类别的一系列问题开始。不要使用类别试图防止过度使用,并给出了一些可以避免我们很多麻烦的规则。然而,规则也有例外。因此,不要认为应不惜一切代价避免所提出的规则。一如既往,这取决于问题。

Optional的情况下,一个常见的场景是为了获得一些值而链接其方法。

避免这种做法,并依赖简单和简单的代码。换句话说,避免做类似以下代码片段的操作:

public static final String BOOK_STATUS = "UNKNOWN";
...
// Avoid
public String findStatus() {
  // fetch a status prone to be null
  String status = ...;

  return Optional.ofNullable(status).orElse(BOOK_STATUS);
}

并使用简单的if-else块或三元运算符(对于简单情况):

// Prefer
public String findStatus() {
  // fetch a status prone to be null
  String status = null;

  return status == null ? BOOK_STATUS : status;
}

236 不要为字段使用Optional

不要使用类别继续下面的语句-Optional不打算用于字段,它不实现Serializable

Optional类肯定不打算用作 JavaBean 的字段。所以,不要这样做:

// Avoid
public class Book {

  [access_modifier][static][final]
    Optional<String> title;
  [access_modifier][static][final]
    Optional<String> subtitle = Optional.empty();
  ...
}

但要做到:

// Prefer
public class Book {

  [access_modifier][static][final] String title;
  [access_modifier][static][final] String subtitle = "";
  ...
}

237 不要在构造器参数中使用Optional

不要使用类别继续使用另一种与使用Optional的意图相反的场景。请记住,Optional表示对象的容器;因此,Optional添加了另一个抽象级别。换句话说,Optional的不当使用只是增加了额外的样板代码。

检查Optional的以下用例,可以看出这一点(此代码违反了前面的“不要对字段使用Optional”一节):

// Avoid
public class Book {

  // cannot be null
  private final String title; 

  // optional field, cannot be null
  private final Optional<String> isbn;

  public Book(String title, Optional<String> isbn) {
    this.title = Objects.requireNonNull(title,
      () -> "Title cannot be null");

    if (isbn == null) {
      this.isbn = Optional.empty();
    } else {
      this.isbn = isbn;
    }

    // or
    this.isbn = Objects.requireNonNullElse(isbn, Optional.empty());
  }

  public String getTitle() {
    return title;
  }

  public Optional<String> getIsbn() {
    return isbn;
  }
}

我们可以通过从字段和构造器参数中删除Optional来修复此代码,如下所示:

// Prefer
public class Book {

  private final String title; // cannot be null
  private final String isbn; // can be null

  public Book(String title, String isbn) {
    this.title = Objects.requireNonNull(title,
      () -> "Title cannot be null");
    this.isbn = isbn;
  }

  public String getTitle() {
    return title;
  }

  public Optional<String> getIsbn() {
    return Optional.ofNullable(isbn);
  }
}

isbn的获取器返回Optional。但是不要将此示例视为以这种方式转换所有获取器的规则。有些获取器返回集合或数组,在这种情况下,他们更喜欢返回空的集合/数组,而不是返回Optional。使用此技术并记住 BrianGoetz(Java 语言架构师)的声明:

“我认为它肯定会被常规地过度用作获取器的返回值。” ——布赖恩·格茨(Brian Goetz)

238 不要在设置器参数中使用Optional

不要使用类别继续使用一个非常诱人的场景,包括在设置器参数中使用Optional。应避免使用以下代码,因为它添加了额外的样板代码,并且违反了“请勿将Optional用于字段”部分(请检查setIsbn()方法):

// Avoid
public class Book {

  private Optional<String> isbn;

  public Optional<String> getIsbn() {
    return isbn;
  }

  public void setIsbn(Optional<String> isbn) {
    if (isbn == null) {
      this.isbn = Optional.empty();
    } else {
      this.isbn = isbn;
    }

    // or
    this.isbn = Objects.requireNonNullElse(isbn, Optional.empty());
  }
}

我们可以通过从字段和设置器的参数中删除Optional来修复此代码,如下所示:

// Prefer
public class Book {

  private String isbn;

  public Optional<String> getIsbn() {
    return Optional.ofNullable(isbn);
  }

  public void setIsbn(String isbn) {
    this.isbn = isbn;
  }
}

通常,这种糟糕的做法在 JPA 实体中用于持久属性(将实体属性映射为Optional)。然而,在域模型实体中使用Optional是可能的。

239 不要在方法参数中使用Optional

不要使用类别继续使用Optional的另一个常见错误。这次让我们讨论一下方法参数中Optional的用法。

在方法参数中使用Optional只是另一个用例,可能会导致代码变得不必要的复杂。主要是建议承担null检查参数的责任,而不是相信调用方会创建Optional类,尤其是空Optional类。这种糟糕的做法会使代码变得混乱,而且仍然容易出现NullPointerException。调用者仍可通过null。所以你刚才又开始检查null参数了。

请记住,Optional只是另一个物体(容器),并不便宜。Optional消耗裸引用内存的四倍!

作为结论,在执行以下操作之前,请三思而后行:

// Avoid
public void renderBook(Format format,
  Optional<Renderer> renderer, Optional<String> size) {

  Objects.requireNonNull(format, "Format cannot be null");

  Renderer bookRenderer = renderer.orElseThrow(
    () -> new IllegalArgumentException("Renderer cannot be empty")
  );

  String bookSize = size.orElseGet(() -> "125 x 200");
  ...
}

检查创建所需Optional类的此方法的以下调用。但是,很明显,通过null也是可能的,会导致NullPointerException,但这意味着你故意挫败了Optional——不要想通过null参数的检查来污染前面的代码,这真是个坏主意:

Book book = new Book();

// Avoid
book.renderBook(new Format(),
  Optional.of(new CoolRenderer()), Optional.empty());

// Avoid
// lead to NPE
book.renderBook(new Format(),
  Optional.of(new CoolRenderer()), null);

我们可以通过删除Optional类来修复此代码,如下所示:

// Prefer
public void renderBook(Format format, 
    Renderer renderer, String size) {

  Objects.requireNonNull(format, "Format cannot be null");
  Objects.requireNonNull(renderer, "Renderer cannot be null");

  String bookSize = Objects.requireNonNullElseGet(
    size, () -> "125 x 200");
  ...
}

这次,这个方法的调用不强制创建Optional

Book book = new Book();

// Prefer
book.renderBook(new Format(), new CoolRenderer(), null);

当一个方法可以接受可选参数时,依赖于旧式方法重载,而不是依赖于Optional

240 不要使用Optional返回空的或null集合或数组

此外,在不要使用类别中,让我们来讨论如何使用Optional作为包装空集合或null集合或数组的返回类型。

返回包装空集合/数组的Optionalnull集合/数组可能由干净的轻量级代码组成。查看以下代码:

// Avoid
public Optional<List<Book>> fetchBooksByYear(int year) {
  // fetching the books may return null
  List<Book> books = ...;

  return Optional.ofNullable(books);
}

Optional<List<Book>> books = author.fetchBooksByYear(2021);

// Avoid
public Optional<Book[]> fetchBooksByYear(int year) {
  // fetching the books may return null
  Book[] books = ...;

  return Optional.ofNullable(books);
}

Optional<Book[]> books = author.fetchBooksByYear(2021);

我们可以通过删除不必要的Optional来清除此代码,然后依赖空集合(例如,Collections.emptyList()emptyMap()emptySet())和数组(例如,new String[0])。这是更好的解决方案:

// Prefer
public List<Book> fetchBooksByYear(int year) {
  // fetching the books may return null
  List<Book> books = ...;

  return books == null ? Collections.emptyList() : books;
}

List<Book> books = author.fetchBooksByYear(2021);

// Prefer
public Book[] fetchBooksByYear(int year) {
  // fetching the books may return null
  Book[] books = ...;

  return books == null ? new Book[0] : books;
}

Book[] books = author.fetchBooksByYear(2021);

如果需要区分缺少的集合/数组和空集合/数组,则为缺少的集合/数组抛出异常。

241 避免集合中的Optional

依靠集合中的Optional可能是一种设计的味道。再花 30 分钟重新评估问题并找到更好的解决方案。

前面的语句尤其在Map的情况下是有效的,当这个决定背后的原因听起来像这样时,Map返回null如果一个键没有映射或者null映射到了这个键,那么我无法判断这个键是不存在还是缺少值。我将通过Optional.ofNullable()包装数值并完成!

但是,如果Optional<Foo>Map填充了null值,缺少Optional值,甚至Optional对象包含了其他内容,而不是Foo,我们将进一步决定什么呢?我们不是把最初的问题嵌套到另一层吗?表演罚怎么样?Optional不是免费的;它只是另一个消耗内存的对象,需要收集。

所以,让我们考虑一个应该避免的解决方案:

private static final String NOT_FOUND = "NOT FOUND";
...
// Avoid
Map<String, Optional<String>> isbns = new HashMap<>();
isbns.put("Book1", Optional.ofNullable(null));
isbns.put("Book2", Optional.ofNullable("123-456-789"));
...
Optional<String> isbn = isbns.get("Book1");

if (isbn == null) {
  System.out.println("This key cannot be found");
} else {
  String unwrappedIsbn = isbn.orElse(NOT_FOUND);
  System.out.println("Key found, Value: " + unwrappedIsbn);
}

更好更优雅的解决方案可以依赖于 JDK8,getOrDefault()如下:

private static String get(Map<String, String> map, String key) {
  return map.getOrDefault(key, NOT_FOUND);
}

Map<String, String> isbns = new HashMap<>();
isbns.put("Book1", null);
isbns.put("Book2", "123-456-789");
...
String isbn1 = get(isbns, "Book1"); // null
String isbn2 = get(isbns, "Book2"); // 123-456-789
String isbn3 = get(isbns, "Book3"); // NOT FOUND

其他解决方案可依赖于以下内容:

  • containsKey()方法
  • 通过扩展HashMap来实现琐碎的实现
  • JDK8computeIfAbsent()方法
  • ApacheCommon DefaultedMap

我们可以得出结论,总有比在集合中使用Optional更好的解决方案。

但是前面讨论的用例并不是最坏的场景。这里还有两个必须避免的问题:

Map<Optional<String>, String> items = new HashMap<>();
Map<Optional<String>, Optional<String>> items = new HashMap<>();

242 将of()ofNullable()混淆

混淆或误用Optional.of()代替Optional.ofNullable(),反之亦然,会导致怪异行为,甚至NullPointerException

Optional.of(null)会抛出NullPointerException,但是Optional.ofNullable(null)会产生Optional.empty

请检查以下失败的尝试,以编写一段代码来避免NullPointerException

// Avoid
public Optional<String> isbn(String bookId) {
  // the fetched "isbn" can be null for the given "bookId"
  String isbn = ...;

  return Optional.of(isbn); // this throws NPE if "isbn" is null :(
}

但是,最有可能的是,我们实际上想要使用ofNullable(),如下所示:

// Prefer
public Optional<String> isbn(String bookId) {
  // the fetched "isbn" can be null for the given "bookId"
  String isbn = ...;

  return Optional.ofNullable(isbn);
}

ofNullable()代替of()不是灾难,但可能会造成一些混乱,没有任何价值。检查以下代码:

// Avoid
// ofNullable() doesn't add any value
return Optional.ofNullable("123-456-789");

// Prefer
return Optional.of("123-456-789"); // no risk to NPE

这是另一个问题。假设我们想把一个空的String对象转换成一个空的Optional。我们可以认为适当的解决方案将依赖于of(),如下所示:

// Avoid
Optional<String> result = Optional.of(str)
 .filter(not(String::isEmpty));

但是请记住,String可以是null。此解决方案适用于空字符串或非空字符串,但不适用于null字符串。因此,ofNullable()给出了合适的解决方案,如下:

// Prefer
Optional<String> result = Optional.ofNullable(str)
  .filter(not(String::isEmpty));

243 Optional<T>OptionalInt

如果没有使用装箱基本类型的具体原因,则宜避免Optional<T>并依赖非通用OptionalIntOptionalLongOptionalDouble型。

装箱和拆箱是昂贵的操作,容易导致性能损失。为了消除这种风险,我们可以依赖于OptionalIntOptionalLongOptionalDouble。这些是intlongdouble原始类型的包装器。

因此,请避免使用以下(及类似)解决方案:

// Avoid
Optional<Integer> priceInt = Optional.of(50);
Optional<Long> priceLong = Optional.of(50L);
Optional<Double> priceDouble = Optional.of(49.99d);

更喜欢以下解决方案:

// Prefer
// unwrap via getAsInt()
OptionalInt priceInt = OptionalInt.of(50);

// unwrap via getAsLong()
OptionalLong priceLong = OptionalLong.of(50L);

// unwrap via getAsDouble()
OptionalDouble priceDouble = OptionalDouble.of(49.99d);

244 Optional.assertEquals()

assertEquals()中有两个Optional对象不需要展开值。这是适用的,因为Optional.equals()比较包裹值,而不是Optional对象。这是Optional.equals()的源代码:

@Override
public boolean equals(Object obj) {

  if (this == obj) {
    return true;
  }

  if (!(obj instanceof Optional)) {
    return false;
  }

  Optional<?> other = (Optional<?>) obj;

  return Objects.equals(value, other.value);
}

假设我们有两个Optional对象:

Optional<String> actual = ...;
Optional<String> expected = ...;

// or
Optional actual = ...;
Optional expected = ...; 

建议避免进行以下测试:

// Avoid
@Test
public void givenOptionalsWhenTestEqualityThenTrue() 
    throws Exception {

  assertEquals(expected.get(), actual.get());
}

如果预期和/或实际为空,get()方法将导致NoSuchElementException类型的异常。

最好使用以下测试:

// Prefer
@Test
public void givenOptionalsWhenTestEqualityThenTrue() 
    throws Exception {

  assertEquals(expected, actual);
}

245 通过map()flatMap()转换值

Optional.map()flatMap()方法便于转换Optional值。

map()方法将函数参数应用于值,然后返回包装在Optional对象中的结果。flatMap()方法将函数参数应用于值,然后直接返回结果。

假设我们有Optional<String>,我们想把这个String从小写转换成大写。一个没有灵感的解决方案可以写如下:

Optional<String> lowername = ...; // may be empty as well

// Avoid
Optional<String> uppername;

if (lowername.isPresent()) {
  uppername = Optional.of(lowername.get().toUpperCase());
} else {
  uppername = Optional.empty();
}

更具启发性的解决方案(在一行代码中)将依赖于Optional.map(),如下所示:

// Prefer
Optional<String> uppername = lowername.map(String::toUpperCase);

map()方法也可以用来避免破坏 Lambda 链。让我们考虑一下List<Book>,我们想找到第一本便宜 50 美元的书,如果有这样一本书,就把它的书名改成大写。同样,没有灵感的解决方案如下:

private static final String NOT_FOUND = "NOT FOUND";
List<Book> books = Arrays.asList();
...
// Avoid
Optional<Book> book = books.stream()
  .filter(b -> b.getPrice()<50)
  .findFirst();

String title;
if (book.isPresent()) {
  title = book.get().getTitle().toUpperCase();
} else {
  title = NOT_FOUND;
}

依靠map(),我们可以通过以下 Lambda 链来实现:

// Prefer
String title = books.stream()
  .filter(b -> b.getPrice()<50)
  .findFirst()
  .map(Book::getTitle)
  .map(String::toUpperCase)
  .orElse(NOT_FOUND);

在前面的示例中,getTitle()方法是一个经典的获取器,它将书名返回为String。但是让我们修改这个获取器以返回Optional

public Optional<String> getTitle() {
  return ...;
}

这次我们不能使用map(),因为map(Book::getTitle)会返回Optional<Optional<String>>而不是Optional<String>。但是如果我们依赖于flatMap(),那么它的返回将不会被包装在一个额外的Optional对象中:

// Prefer
String title = books.stream()
  .filter(b -> b.getPrice()<50)
  .findFirst()
  .flatMap(Book::getTitle)
  .map(String::toUpperCase)
  .orElse(NOT_FOUND);

所以,Optional.map()将变换的结果包装在Optional对象中。如果这个结果是Optional本身,那么我们就得到Optional<Optional<...>>。另一方面,flatMap()不会将结果包装在另一个Optional对象中。

246 通过Optional.filter()过滤值

使用Optional.filter()接受或拒绝包装值是一种非常方便的方法,因为它可以在不显式展开值的情况下完成。我们只需传递谓词(条件)作为参数,如果满足条件,则得到一个Optional对象(初始Optional对象,如果条件不满足则得到空的Optional对象)。

让我们考虑以下未经启发的方法来验证书的长度 ISBN:

// Avoid
public boolean validateIsbnLength(Book book) {

  Optional<String> isbn = book.getIsbn();

  if (isbn.isPresent()) {
    return isbn.get().length() > 10;
  }

  return false;
}

前面的解决方案依赖于显式地展开Optional值。但是如果我们依赖于Optional.filter(),我们可以不使用这种显式展开,如下所示:

// Prefer
public boolean validateIsbnLength(Book book) {

  Optional<String> isbn = book.getIsbn();

  return isbn.filter((i) -> i.length() > 10)
    .isPresent();
}

Optional.filter() is also useful for avoiding breaking lambda chains.

247 链接Optional和流 API

从 JDK9 开始,我们可以通过应用Optional.stream()方法将Optional实例引用为Stream

当我们必须链接OptionalStreamAPI 时,这非常有用。Optional.stream()方法返回一个元素的StreamOptional的值)或空的Stream(如果Optional没有值)。此外,我们可以使用StreamAPI 中提供的所有方法。

假设我们有一个按 ISBN 取书的方法(如果没有书与给定的 ISBN 匹配,那么这个方法返回一个空的Optional对象):

public Optional<Book> fetchBookByIsbn(String isbn) {
  // fetching book by the given "isbn" can return null
  Book book = ...;

  return Optional.ofNullable(book);
}

除此之外,我们循环一个 ISBN 的List,返回BookList如下(每个 ISBN 通过fetchBookByIsbn()方法传递):

// Avoid
public List<Book> fetchBooks(List<String> isbns) {

  return isbns.stream()
    .map(this::fetchBookByIsbn)
    .filter(Optional::isPresent)
    .map(Optional::get)
    .collect(toList());
}

这里的重点是以下两行代码:

.filter(Optional::isPresent)
.map(Optional::get)

因为fetchBookByIsbn()方法可以返回空的Optional类,所以我们必须确保从最终结果中消除它们。为此,我们调用Stream.filter()并将Optional.isPresent()函数应用于fetchBookByIsbn()返回的每个Optional对象。所以,在过滤之后,我们只有Optional类具有当前值。此外,我们应用Stream.map()方法将这些Optional类解包到Book。最后,我们收集List中的Book对象。

但我们可以更优雅地使用Optional.stream()来完成同样的事情,具体如下:

// Prefer
public List<Book> fetchBooksPrefer(List<String> isbns) {

  return isbns.stream()
    .map(this::fetchBookByIsbn)
    .flatMap(Optional::stream)
    .collect(toList());
}

实际上,在这样的情况下,我们可以用Optional.stream()来替换filter()map()flatMap()

fetchBookByIsbn()返回的Optional<Book>每回Optional.stream()将导致Stream<Book>中包含单个Book对象或无物(空流)。如果Optional<Book>不包含值(为空),则Stream<Book>也为空。依靠flatMap()代替map()将避免Stream<Stream<Book>>型的结果。

作为奖励,我们可以将Optional转换为List,如下所示:

public static<T> List<T> optionalToList(Optional<T> optional) {
  return optional.stream().collect(toList());
}

248 Optional和身份敏感操作

身份敏感操作包括引用相等(==)、基于身份哈希或同步。

Optional类是基于值的类,如LocalDateTime,因此应该避免身份敏感操作。

例如,让我们通过==测试两个Optional类的相等性:

Book book = new Book();
Optional<Book> op1 = Optional.of(book);
Optional<Book> op2 = Optional.of(book);

// Avoid
// op1 == op2 => false, expected true
if (op1 == op2) {
  System.out.println("op1 is equal with op2, (via ==)");
} else {
  System.out.println("op1 is not equal with op2, (via ==)");
}

这将产生以下输出:

op1 is not equal with op2, (via ==)

因为op1op2不是对同一个对象的引用,所以它们不相等,所以不符合==的实现。

为了比较这些值,我们需要依赖于equals(),如下所示:

// Prefer
if (op1.equals(op2)) {
  System.out.println("op1 is equal with op2, (via equals())");
} else {
  System.out.println("op1 is not equal with op2, (via equals())");
}

这将产生以下输出:

op1 is equal with op2, (via equals())

identity-sensitive操作的上下文中,千万不要这样做(认为Optional是一个基于值的类,这样的类不应该用于锁定更多细节,请参见这个页面

Optional<Book> book = Optional.of(new Book());
synchronized(book) {
  ...
}

249 如果Optional类为空,则返回布尔值

假设我们有以下简单的方法:

public static Optional<Cart> fetchCart(long userId) {
  // the shopping cart of the given "userId" can be null
  Cart cart = ...;

  return Optional.ofNullable(cart);
}

现在,我们要编写一个名为cartIsEmpty()的方法来调用fetchCart()方法,如果获取的购物车为空,则返回一个标志,即true。在 JDK11 之前,我们可以基于Optional.isPresent()实现这个方法,如下所示:

// Avoid (after JDK11)
public static boolean cartIsEmpty(long id) {
  Optional<Cart> cart = fetchCart(id);

  return !cart.isPresent();
}

这个解决方案可以很好地工作,但不是很有表现力。我们通过存在来检查空虚,我们必须否定isPresent()的结果。

自 JDK11 以来,Optional类被一个名为isEmpty()的新方法所丰富。顾名思义,这是一个标志方法,如果测试的Optional类为空,它将返回true。因此,我们可以通过以下方式提高解决方案的表达能力:

// Prefer (after JDK11)
public static boolean cartIsEmpty(long id) {
  Optional<Cart> cart = fetchCart(id);

  return cart.isEmpty();
}

总结

完成!这是本章的最后一个问题。此时,您应该拥有正确使用Optional所需的所有参数

从本章下载应用以查看结果和其他详细信息。

十三、HTTP 客户端和 WebSocket API

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括 20 个问题,旨在介绍 HTTP 客户端和 WebSocket API。

你还记得HttpUrlConnection吗?好吧,JDK11 附带了 HTTP 客户端 API,它是对HttpUrlConnection的重新发明。HTTP 客户端 API 易于使用,支持 HTTP/2(默认)和 HTTP/1.1。为了向后兼容,当服务器不支持 HTTP/2 时,HTTP 客户端 API 将自动从 HTTP/2 降级到 HTTP 1.1。此外,HTTP 客户端 API 支持同步和异步编程模型,并依赖流来传输数据(反应流)。它还支持 WebSocket 协议,该协议用于实时 Web 应用,以较低的消息开销提供客户端-服务器通信。

问题

使用以下问题来测试您的 HTTP 客户端和 WebSocketAPI 编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. HTTP/2:简要介绍 HTTP/2 协议

  2. 触发异步GET请求:编写一个程序,使用 HTTP 客户端 API 触发异步GET请求,并显示响应代码和正文。

  3. 设置代理:编写一个使用 HTTP 客户端 API 通过代理建立连接的程序。

  4. 设置/获取标头:编写一个程序,在请求中添加额外的标头,获取响应的标头。

  5. 指定 HTTP 方法:编写指定请求的 HTTP 方法的程序(例如GETPOSTPUTDELETE)。

  6. 设置请求体:编写一个程序,使用 HTTP 客户端 API 为请求添加正文。

  7. 设置连接认证:编写一个程序,使用 HTTP 客户端 API 通过用户名和密码设置连接认证。

  8. 设置超时:编写一个程序,使用 HTTP 客户端 API 设置我们要等待响应的时间量(超时)。

  9. 设置重定向策略:编写一个程序,根据需要使用 HTTP 客户端 API 自动重定向。

  10. 发送同步和异步请求:编写一个程序,在同步和异步模式下发送相同的请求。

  11. 处理 Cookie:编写一个程序,使用 HTTP 客户端 API 设置 Cookie 处理器。

  12. 获取响应信息:编写一个程序,使用 HTTP 客户端 API 获取响应信息(如 URI、版本、头、状态码、正文等)。

  13. 处理响应体类型:写几段代码举例说明如何通过HttpResponse.BodyHandlers处理常见的响应体类型。

  14. 获取、更新和保存 JSON:编写一个程序,使用 HTTP 客户端 API 获取、更新和保存 JSON。

  15. 压缩:编写处理压缩响应的程序(如.gzip

  16. 处理表单数据:编写一个使用 HTTP 客户端 API 提交数据表单的程序(application/x-www-form-urlencoded

  17. 下载资源:编写使用 HTTP 客户端 API 下载资源的程序。

  18. 分块上传:编写一个使用 HTTP 客户端 API 上传资源的程序。

  19. HTTP/2 服务器推送:编写一个程序,通过 HTTP 客户端 API 演示 HTTP/2 服务器推送特性。

  20. WebSocket:编写一个程序,打开到 WebSocket 端点的连接,收集数据 10 秒,然后关闭连接。

解决方案

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释只包括解决问题所需的最有趣和最重要的细节。您可以下载示例解决方案以查看更多详细信息并尝试程序

250 HTTP/2

HTTP/2 是一种有效的协议,它对 HTTP/1.1 协议进行了显著的改进。

作为大局的一部分,HTTP/2 有两部分:

  • 帧层:这是 HTTP/2 复用核心能力
  • 数据层:它包含数据(我们通常称之为 HTTP)

下图描述了 HTTP/1.1(顶部)和 HTTP/2(底部)中的通信:

HTTP/2 被服务器和浏览器广泛采用,与 HTTP/1.1 相比有如下改进:

  • 二进制协议HTTP/2帧层是一种二进制分帧协议,不易被人类读取,但更易于机器操作。
  • 复用:请求和响应交织在一起。在同一连接上同时运行多个请求。
  • 服务器推送:服务器可以决定向客户端发送额外的资源。
  • 到服务器的单一连接HTTP/2 对每个源(域)使用单一通信线路(TCP 连接)。
  • 标头压缩HTTP/2 依靠 HPACK 压缩来减少标头。这对冗余字节有很大影响。
  • 加密:通过电线传输的大部分数据都是加密的。

251 触发异步 GET 请求

触发异步GET请求是一个三步工作,如下:

  1. 新建HttpClient对象(java.net.http.HttpClient):
HttpClient client = HttpClient.newHttpClient();
  1. 构建HttpRequest对象(java.net.http.HttpRequest并指定请求(默认为GET请求):
HttpRequest request = HttpRequest.newBuilder()
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();

为了设置 URI,我们可以调用HttpRequest.newBuilder(URI)构造器,或者在Builder实例上调用uri(URI)方法(就像我们以前做的那样)。

  1. 触发请求并等待响应(java.net.http.HttpResponse。作为同步请求,应用将阻止,直到响应可用:
HttpResponse<String> response 
  = client.send(request, BodyHandlers.ofString());

如果我们将这三个步骤分组,并添加用于在控制台上显示响应代码和正文的行,那么我们将获得以下代码:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();

HttpResponse<String> response 
  = client.send(request, BodyHandlers.ofString());

System.out.println("Status code: " + response.statusCode());
System.out.println("\n Body: " + response.body());

上述代码的一个可能输出如下:

Status code: 200
Body:
{
  "data": {
    "id": 2,
    "email": "janet.weaver@reqres.in",
    "first_name": "Janet",
    "last_name": "Weaver",
    "avatar": "https://s3.amazonaws.com/..."
 }
}

默认情况下,此请求使用 HTTP/2 进行。但是,我们也可以通过HttpRequest.Builder.version()显式设置版本。此方法获取一个参数,其类型为HttpClient.Version,是一个enum数据类型,它公开了两个常量:HTTP_2HTTP_1_1。以下是显式降级到 HTTP/1.1 的示例:

HttpRequest request = HttpRequest.newBuilder()
  .version(HttpClient.Version.HTTP_1_1)
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();

HttpClient的默认设置如下:

  • HTTP/2 协议
  • 没有验证器
  • 无连接超时
  • 没有 Cookie 处理器
  • 默认线程池执行器
  • NEVER的重定向策略
  • 默认代理选择器
  • 默认 SSL 上下文

我们将在下一节中查看查询参数生成器。

查询参数生成器

使用包含查询参数的 URI 意味着对这些参数进行编码。完成此任务的 Java 内置方法是URLEncoder.encode()。但将多个查询参数连接起来并对其进行编码会导致类似以下情况:

URI uri = URI.create("http://localhost:8080/books?name=" +
  URLEncoder.encode("Games & Fun!", StandardCharsets.UTF_8) +
  "&no=" + URLEncoder.encode("124#442#000", StandardCharsets.UTF_8) +
  "&price=" + URLEncoder.encode("$23.99", StandardCharsets.UTF_8)
);

当我们必须处理大量的查询参数时,这种解决方案不是很方便。但是,我们可以尝试编写一个辅助方法,将URLEncoder.encode()方法隐藏在查询参数集合的循环中,也可以依赖 URI 生成器。

在 Spring 中,URI 生成器是org.springframework.web.util.UriComponentsBuilder。以下代码是不言自明的:

URI uri = UriComponentsBuilder.newInstance()
  .scheme("http")
  .host("localhost")
  .port(8080)
  .path("books")
  .queryParam("name", "Games & Fun!")
  .queryParam("no", "124#442#000")
  .queryParam("price", "$23.99")
  .build()
  .toUri();

在非 Spring 应用中,我们可以依赖 URI 生成器,比如urlbuilder。这本书附带的代码包含了一个使用这个的例子。

252 设置代理

为了建立代理,我们依赖于Builder方法的HttpClient.proxy()方法。proxy()方法获取一个ProxySelector类型的参数,它可以是系统范围的代理选择器(通过getDefault())或通过其地址指向的代理选择器(通过InetSocketAddress)。

假设我们在proxy.host:80地址有代理。我们可以按以下方式设置此代理:

HttpClient client = HttpClient.newBuilder()
  .proxy(ProxySelector.of(new InetSocketAddress("proxy.host", 80)))
  .build();

或者,我们可以设置系统范围的代理选择器,如下所示:

HttpClient client = HttpClient.newBuilder()
  .proxy(ProxySelector.getDefault())
  .build();

253 设置/获取标头

HttpRequestHttpResponse公开了一套处理头文件的方法。我们将在接下来的章节中学习这些方法。

设置请求头

HttpRequest.Builder类使用三种方法来设置附加头:

  • header​(String name, String value)setHeader​(String name, String value):用于逐个添加表头,如下代码所示:
HttpRequest request = HttpRequest.newBuilder()
  .uri(...)
  ...
  .header("key_1", "value_1")
  .header("key_2", "value_2")
  ...
  .build();

HttpRequest request = HttpRequest.newBuilder()
  .uri(...)
  ...
  .setHeader("key_1", "value_1")
  .setHeader("key_2", "value_2")
  ...
  .build();

header()setHeader()的区别在于前者添加指定的头,后者设置指定的头。换句话说,header()将给定值添加到该名称/键的值列表中,而setHeader()覆盖该名称/键先前设置的任何值。

  • headers​(String... headers):用于添加以逗号分隔的表头,如下代码所示:
HttpRequest request = HttpRequest.newBuilder()
  .uri(...)
  ...
  .headers("key_1", "value_1", "key_2",
    "value_2", "key_3", "value_3", ...)
  ...
  .build();

例如,Content-Type: application/jsonReferer: https://reqres.in/头可以添加到由https://reqres.in/api/users/2URI 触发的请求中,如下所示:

HttpRequest request = HttpRequest.newBuilder()
  .header("Content-Type", "application/json")
  .header("Referer", "https://reqres.in/")
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();

您还可以执行以下操作:

HttpRequest request = HttpRequest.newBuilder()
  .setHeader("Content-Type", "application/json")
  .setHeader("Referer", "https://reqres.in/")
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();

最后,你可以这样做:

HttpRequest request = HttpRequest.newBuilder()
  .headers("Content-Type", "application/json",
    "Referer", "https://reqres.in/")
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();

根据目标的不同,可以将这三种方法结合起来以指定请求头。

获取请求/响应头

可以使用HttpRequest.headers()方法获取请求头。HttpResponse中也存在类似的方法来获取响应的头。两个方法都返回一个HttpHeaders对象。

这两种方法可以以相同的方式使用,因此让我们集中精力获取响应头。我们可以得到这样的标头:

HttpResponse<...> response ...
HttpHeaders allHeaders = response.headers();

可以使用HttpHeaders.allValues()获取头的所有值,如下所示:

List<String> allValuesOfCacheControl
  = response.headers().allValues("Cache-Control");

使用HttpHeaders.firstValue()只能获取头的第一个值,如下所示:

Optional<String> firstValueOfCacheControl
  = response.headers().firstValue("Cache-Control");

如果表头返回值为Long,则依赖HttpHeaders.firstValueAsLong()。此方法获取一个表示标头名称的参数并返回Optional<Long>。如果指定头的值不能解析为Long,则抛出NumberFormatException

254 指定 HTTP 方法

我们可以使用HttpRequest.Builder中的以下方法指示请求使用的 HTTP 方法:

  • GET():此方法使用 HTTPGET方法发送请求,如下例所示:
HttpRequest requestGet = HttpRequest.newBuilder()
  .GET() // can be omitted since it is default
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();
  • POST():此方法使用 HTTPPOST方法发送请求,如下例所示:
HttpRequest requestPost = HttpRequest.newBuilder()
  .header("Content-Type", "application/json")
  .POST(HttpRequest.BodyPublishers.ofString(
    "{\"name\": \"morpheus\",\"job\": \"leader\"}"))
  .uri(URI.create("https://reqres.in/api/users"))
  .build();
  • PUT():此方法使用 HTTPPUT方法发送请求,如下例所示:
HttpRequest requestPut = HttpRequest.newBuilder()
  .header("Content-Type", "application/json")
  .PUT(HttpRequest.BodyPublishers.ofString(
    "{\"name\": \"morpheus\",\"job\": \"zion resident\"}"))
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();
  • DELETE():此方法使用 HTTPDELETE方法发送请求,如下例所示:
HttpRequest requestDelete = HttpRequest.newBuilder()
  .DELETE()
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();

客户端可以处理所有类型的 HTTP 方法,不仅仅是预定义的方法(GETPOSTPUTDELETE)。要使用不同的 HTTP 方法创建请求,只需调用method()

以下解决方案触发 HTTPPATCH请求:

HttpRequest requestPatch = HttpRequest.newBuilder()
  .header("Content-Type", "application/json")
  .method("PATCH", HttpRequest.BodyPublishers.ofString(
    "{\"name\": \"morpheus\",\"job\": \"zion resident\"}"))
  .uri(URI.create("https://reqres.in/api/users/1"))
  .build();

当不需要请求体时,我们可以依赖于BodyPublishers.noBody()。以下解决方案使用noBody()方法触发 HTTPHEAD请求:

HttpRequest requestHead = HttpRequest.newBuilder()
  .method("HEAD", HttpRequest.BodyPublishers.noBody())
  .uri(URI.create("https://reqres.in/api/users/1"))
  .build();

如果有多个类似的请求,我们可以依赖copy()方法来复制生成器,如下代码片段所示:

HttpRequest.Builder builder = HttpRequest.newBuilder()
  .uri(URI.create("..."));

HttpRequest request1 = builder.copy().setHeader("...", "...").build();
HttpRequest request2 = builder.copy().setHeader("...", "...").build();

255 设置请求体

请求体的设置可以通过HttpRequest.Builder.POST()HttpRequest.Builder.PUT()来完成,也可以通过method()来完成(例如method("PATCH", HttpRequest.BodyPublisher)POST()PUT()采用HttpRequest.BodyPublisher类型的参数。API 在HttpRequest.BodyPublishers类中附带了此接口(BodyPublisher的几个实现,如下所示:

  • BodyPublishers.ofString()
  • BodyPublishers.ofFile()
  • BodyPublishers.ofByteArray()
  • BodyPublishers.ofInputStream()

我们将在下面几节中查看这些实现。

从字符串创建标头

使用BodyPublishers.ofString()可以从字符串创建正文,如下代码片段所示:

HttpRequest requestBody = HttpRequest.newBuilder()
  .header("Content-Type", "application/json")
  .POST(HttpRequest.BodyPublishers.ofString(
    "{\"name\": \"morpheus\",\"job\": \"leader\"}"))
  .uri(URI.create("https://reqres.in/api/users"))
  .build();

要指定charset调用,请使用ofString(String s, Charset charset)

InputStream创建正文

InputStream创建正文可以使用BodyPublishers.ofInputStream()来完成,如下面的代码片段所示(这里,我们依赖于ByteArrayInputStream,当然,任何其他InputStream都是合适的):

HttpRequest requestBodyOfInputStream = HttpRequest.newBuilder()
  .header("Content-Type", "application/json")
  .POST(HttpRequest.BodyPublishers.ofInputStream(()
    -> inputStream("user.json")))
  .uri(URI.create("https://reqres.in/api/users"))
  .build();

private static ByteArrayInputStream inputStream(String fileName) {

  try (ByteArrayInputStream inputStream = new ByteArrayInputStream(
    Files.readAllBytes(Path.of(fileName)))) {

    return inputStream;
  } catch (IOException ex) {
    throw new RuntimeException("File could not be read", ex);
  }
}

为了利用延迟创建,InputStream必须作为Supplier传递。

从字节数组创建正文

从字节数组创建正文可以使用BodyPublishers.ofByteArray()完成,如下代码片段所示:

HttpRequest requestBodyOfByteArray = HttpRequest.newBuilder()
  .header("Content-Type", "application/json")
  .POST(HttpRequest.BodyPublishers.ofByteArray(
    Files.readAllBytes(Path.of("user.json"))))
  .uri(URI.create("https://reqres.in/api/users"))
  .build();

我们也可以使用ofByteArray(byte[] buf, int offset, int length)发送字节数组的一部分。此外,我们还可以使用ofByteArrays(Iterable<byte[]> iter)提供字节数组的Iterable数据。

从文件创建正文

从文件创建正文可以使用BodyPublishers.ofFile()完成,如下代码片段所示:

HttpRequest requestBodyOfFile = HttpRequest.newBuilder()
  .header("Content-Type", "application/json")
  .POST(HttpRequest.BodyPublishers.ofFile(Path.of("user.json")))
  .uri(URI.create("https://reqres.in/api/users"))
  .build();

256 设置连接认证

通常,对服务器的认证是使用用户名和密码完成的。在代码形式下,可以使用Authenticator类(此协商 HTTP 认证凭证)和PasswordAuthentication类(用户名和密码的持有者)一起完成,如下:

HttpClient client = HttpClient.newBuilder()
  .authenticator(new Authenticator() {

    @Override
    protected PasswordAuthentication getPasswordAuthentication() {

      return new PasswordAuthentication(
        "username",
        "password".toCharArray());
    }
  })
  .build();

此外,客户端可用于发送请求:

HttpRequest request = HttpRequest.newBuilder()
  ...
  .build();

HttpResponse<String> response
  = client.send(request, HttpResponse.BodyHandlers.ofString());

Authenticator支持不同的认证方案(例如,“基本”或“摘要”认证)。

另一种解决方案是在标头中添加凭据,如下所示:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
  .header("Authorization", basicAuth("username", "password"))
  ...
  .build();

HttpResponse<String> response 
  = client.send(request, HttpResponse.BodyHandlers.ofString());

private static String basicAuth(String username, String password) {
  return "Basic " + Base64.getEncoder().encodeToString(
    (username + ":" + password).getBytes());
}

Bearer认证(HTTP 承载令牌)的情况下,我们执行以下操作:

HttpRequest request = HttpRequest.newBuilder()
  .header("Authorization", 
          "Bearer mT8JNMyWCG0D7waCHkyxo0Hm80YBqelv5SBL")
  .uri(URI.create("https://gorest.co.in/public-api/users"))
  .build();

我们也可以在POST请求的正文中这样做:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
  .header("Content-Type", "application/json")
  .POST(BodyPublishers.ofString("{\"email\":\"eve.holt@reqres.in\",
    \"password\":\"cityslicka\"}"))
  .uri(URI.create("https://reqres.in/api/login"))
  .build();

HttpResponse<String> response 
  = client.send(request, HttpResponse.BodyHandlers.ofString());

不同的请求可以使用不同的凭据。此外,Authenticator提供了一套方法(例如,getRequestingSite()),如果我们希望找出应该提供什么值,这些方法非常有用。在生产环境中,应用不应该像在这些示例中那样以明文形式提供凭据。

257 设置超时

默认情况下,请求没有超时(无限超时)。要设置等待响应的时间量(超时),可以调用HttpRequest.Builder.timeout()方法。此方法获取一个Duration类型的参数,可以这样使用:

HttpRequest request = HttpRequest.newBuilder()
  .uri(URI.create("https://reqres.in/api/users/2"))
  .timeout(Duration.of(5, ChronoUnit.MILLIS))
  .build();

如果指定的超时已过,则抛出java.net.http.HttpConnectTimeoutException

258 设置重定向策略

当我们尝试访问移动到其他 URI 的资源时,服务器将返回一个范围为3xx的 HTTP 状态码,以及有关新 URI 的信息。当浏览器收到重定向响应(301302303307308时,它们能够自动向新位置发送另一个请求。

如果我们通过followRedirects()显式设置重定向策略,HTTP 客户端 API 可以自动重定向到这个新 URI,如下所示:

HttpClient client = HttpClient.newBuilder()
  .followRedirects(HttpClient.Redirect.ALWAYS)
  .build();

为了不重定向,只需将HttpClient.Redirect.NEVER常量赋给followRedirects()(这是默认值)。

要始终重定向,除了从 HTTPS URL 到 HTTP URL,只需将HttpClient.Redirect.NORMAL常量指定给followRedirects()

当重定向策略未设置为ALWAYS时,应用负责处理重定向。通常,这是通过从 HTTPLocation头读取新地址来完成的,如下所示(如果返回的状态码是301(永久移动)或308(永久重定向),则以下代码只对重定向感兴趣):

int sc = response.statusCode();

if (sc == 301 || sc == 308) { // use an enum for HTTP response codes
  String newLocation = response.headers()
    .firstValue("Location").orElse("");

  // handle the redirection to newLocation
}

通过比较请求 URI 和响应 URI,可以很容易地检测到重定向。如果它们不相同,则会发生重定向:

if (!request.uri().equals(response.uri())) {
  System.out.println("The request was redirected to: " 
    + response.uri());
}

259 发送同步和异步请求

通过HttpClient中的两种方式,可以完成向服务器发送请求:

  • send():此方法同步发送请求(这将阻塞,直到响应可用或发生超时)
  • sendAsync():此方法异步发送请求(非阻塞)

我们将在下一节解释发送请求的不同方式。

发送同步请求

我们已经在前面的问题中完成了这一点,因此我们将为您提供一个简短的剩余部分,如下所示:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();

HttpResponse<String> response 
  = client.send(request, HttpResponse.BodyHandlers.ofString());

发送异步请求

为了异步发送请求,HTTP 客户端 API 依赖于CompletableFeature,如第 11 章、“并发-深入了解”和sendAsync()方法所述,如下所示:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();

client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
  .thenApply(HttpResponse::body)
  .exceptionally(e -> "Exception: " + e)
  .thenAccept(System.out::println)
  .get(30, TimeUnit.SECONDS); // or join()

或者,假设在等待响应的同时,我们还希望执行其他任务:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();

CompletableFuture<String> response
    = client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
  .thenApply(HttpResponse::body)
  .exceptionally(e -> "Exception: " + e);

while (!response.isDone()) {
  Thread.sleep(50);
  System.out.println("Perform other tasks 
    while waiting for the response ...");
}

String body = response.get(30, TimeUnit.SECONDS); // or join()
System.out.println("Body: " + body);

同时发送多个请求

如何同时发送多个请求并等待所有响应可用?

据我们所知,CompletableFuture附带了allOf()方法(详见第 11 章、“并发-深入了解”),可以并行执行任务,等待所有任务完成,返回CompletableFuture<Void>

以下代码等待对四个请求的响应:

List<URI> uris = Arrays.asList(
  new URI("https://reqres.in/api/users/2"),      // one user
  new URI("https://reqres.in/api/users?page=2"), // list of users
  new URI("https://reqres.in/api/unknown/2"),    // list of resources
  new URI("https://reqres.in/api/users/23"));    // user not found

HttpClient client = HttpClient.newHttpClient();

List<HttpRequest> requests = uris.stream()
  .map(HttpRequest::newBuilder)
  .map(reqBuilder -> reqBuilder.build())
  .collect(Collectors.toList());

CompletableFuture.allOf(requests.stream()
  .map(req -> client.sendAsync(
     req, HttpResponse.BodyHandlers.ofString())
  .thenApply((res) -> res.uri() + " | " + res.body() + "\n")
  .exceptionally(e -> "Exception: " + e)
  .thenAccept(System.out::println))
  .toArray(CompletableFuture<?>[]::new))
  .join();

要收集响应的正文(例如,在List<String>中),请考虑WaitAllResponsesFetchBodiesInList类,该类在本书附带的代码中提供。

使用定制的Executor对象可以如下完成:

ExecutorService executor = Executors.newFixedThreadPool(5);

HttpClient client = HttpClient.newBuilder()
  .executor(executor)
  .build();

260 处理 Cookie

默认情况下,JDK11 的 HTTP 客户端支持 Cookie,但也有一些实例禁用了内置支持。我们可以按以下方式启用它:

HttpClient client = HttpClient.newBuilder()
  .cookieHandler(new CookieManager())
  .build();

因此,HTTP 客户端 API 允许我们使用HttpClient.Builder.cookieHandler()方法设置 Cookie 处理器。此方法获取一个CookieManager类型的参数。

以下解决方案设置不接受 Cookie 的CookieManager

HttpClient client = HttpClient.newBuilder()
  .cookieHandler(new CookieManager(null, CookiePolicy.ACCEPT_NONE))
  .build();

要接受 Cookie,请将CookiePolicy设置为ALL(接受所有 Cookie)或ACCEPT_ORIGINAL_SERVER(只接受来自原始服务器的 Cookie)。

以下解决方案接受所有 Cookie 并在控制台中显示它们(如果有任何凭据被报告为无效,则考虑从这个页面获取新令牌):

CookieManager cm = new CookieManager();
cm.setCookiePolicy(CookiePolicy.ACCEPT_ALL);

HttpClient client = HttpClient.newBuilder()
  .cookieHandler(cm)
  .build();

HttpRequest request = HttpRequest.newBuilder()
  .header("Authorization", 
          "Bearer mT8JNMyWCG0D7waCHkyxo0Hm80YBqelv5SBL")
  .uri(URI.create("https://gorest.co.in/public-api/users/1"))
  .build();

HttpResponse<String> response
  = client.send(request, HttpResponse.BodyHandlers.ofString());

System.out.println("Status code: " + response.statusCode());
System.out.println("\n Body: " + response.body());

CookieStore cookieStore = cm.getCookieStore();
System.out.println("\nCookies: " + cookieStore.getCookies());

检查set-cookie收割台可按以下步骤进行:

Optional<String> setcookie 
  = response.headers().firstValue("set-cookie");

261 获取响应信息

为了获得关于响应的信息,我们可以依赖于HttpResponse类中的方法。这些方法的名称非常直观;因此,下面的代码片段是不言自明的:

...
HttpResponse<String> response
  = client.send(request, HttpResponse.BodyHandlers.ofString());

System.out.println("Version: " + response.version());
System.out.println("\nURI: " + response.uri());
System.out.println("\nStatus code: " + response.statusCode());
System.out.println("\nHeaders: " + response.headers());
System.out.println("\n Body: " + response.body());

考虑浏览文档以找到更有用的方法。

262 处理响应体类型

处理响应体类型可以使用HttpResponse.BodyHandler完成。API 在HttpResponse.BodyHandlers类中附带了此接口(BodyHandler的几个实现,如下所示:

  • BodyHandlers.ofByteArray()
  • BodyHandlers.ofFile()
  • BodyHandlers.ofString()
  • BodyHandlers.ofInputStream()
  • BodyHandlers.ofLines()

考虑到以下请求,让我们看看处理响应体的几种解决方案:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();

下面的部分将介绍如何处理不同类型的响应体

将响应体作为字符串处理

将正文响应作为字符串处理可以使用BodyHandlers.ofString()完成,如下面的代码片段所示:

HttpResponse<String> responseOfString
  = client.send(request, HttpResponse.BodyHandlers.ofString());

System.out.println("Status code: " + responseOfString.statusCode());
System.out.println("Body: " + responseOfString.body());

要指定一个charset,请调用ofString(String s, Charset charset)

将响应体作为文件处理

将正文响应作为文件处理可以使用BodyHandlers.ofFile()完成,如下面的代码片段所示:

HttpResponse<Path> responseOfFile = client.send(
  request, HttpResponse.BodyHandlers.ofFile(
    Path.of("response.json")));

System.out.println("Status code: " + responseOfFile.statusCode());
System.out.println("Body: " + responseOfFile.body());

如需指定开放式选项,请调用ofFile(Path file, OpenOption... openOptions)

将响应体作为字节数组处理

将正文响应作为字节数组处理可以使用BodyHandlers.ofByteArray()完成,如下代码片段所示:

HttpResponse<byte[]> responseOfByteArray = client.send(
  request, HttpResponse.BodyHandlers.ofByteArray());

System.out.println("Status code: " 
  + responseOfByteArray.statusCode());
System.out.println("Body: "
  + new String(responseOfByteArray.body()));

要使用字节数组,请调用ofByteArrayConsumer(Consumer<Optional<byte[]>> consumer)

将响应体作为输入流处理

可以使用BodyHandlers.ofInputStream()来处理作为InputStream的正文响应,如下面的代码片段所示:

HttpResponse<InputStream> responseOfInputStream = client.send(
  request, HttpResponse.BodyHandlers.ofInputStream());

System.out.println("\nHttpResponse.BodyHandlers.ofInputStream():");
System.out.println("Status code: " 
  + responseOfInputStream.statusCode());

byte[] allBytes;
try (InputStream fromIs = responseOfInputStream.body()) {
  allBytes = fromIs.readAllBytes();
}

System.out.println("Body: "
  + new String(allBytes, StandardCharsets.UTF_8));

将响应体作为字符串流处理

将正文响应作为字符串流处理可以使用BodyHandlers.ofLines()完成,如下面的代码片段所示:

HttpResponse<Stream<String>> responseOfLines = client.send(
  request, HttpResponse.BodyHandlers.ofLines());

System.out.println("Status code: " + responseOfLines.statusCode());
System.out.println("Body: " 
  + responseOfLines.body().collect(toList()));

263 获取、更新和保存 JSON

在前面的问题中,我们将 JSON 数据作为纯文本(字符串)处理。HTTP 客户端 API 不提供对 JSON 数据的特殊或专用支持,而是将此类数据视为任何其他字符串。

然而,我们习惯于将 JSON 数据表示为 Java 对象(POJO),并在需要时依赖于 JSON 和 Java 之间的转换。我们可以为我们的问题编写一个解决方案,而不涉及 HTTP 客户端 API。但是,我们也可以使用HttpResponse.BodyHandler的自定义实现编写一个解决方案,该实现依赖于 JSON 解析器将响应转换为 Java 对象。例如,我们可以依赖 JSON-B(在第 6 章中介绍,“Java I/O 路径、文件、缓冲区、扫描和格式化”中)。

实现HttpResponse.BodyHandler接口意味着覆盖apply(HttpResponse.ResponseInfo responseInfo)方法。使用这种方法,我们可以从响应中获取字节,并将它们转换为 Java 对象。代码如下:

public class JsonBodyHandler<T> 
    implements HttpResponse.BodyHandler<T> {

  private final Jsonb jsonb;
  private final Class<T> type;

  private JsonBodyHandler(Jsonb jsonb, Class<T> type) {
    this.jsonb = jsonb;
    this.type = type;
  }

  public static <T> JsonBodyHandler<T> 
      jsonBodyHandler(Class<T> type) {
    return jsonBodyHandler(JsonbBuilder.create(), type);
  }

  public static <T> JsonBodyHandler<T> jsonBodyHandler(
      Jsonb jsonb, Class<T> type) {
    return new JsonBodyHandler<>(jsonb, type);
  }

  @Override
  public HttpResponse.BodySubscriber<T> apply(
    HttpResponse.ResponseInfo responseInfo) {

    return BodySubscribers.mapping(BodySubscribers.ofByteArray(),
      byteArray -> this.jsonb.fromJson(
        new ByteArrayInputStream(byteArray), this.type));
  }
}

假设我们要处理的 JSON 如下所示(这是来自服务器的响应):

{
  "data": {
    "id": 2,
    "email": "janet.weaver@reqres.in",
    "first_name": "Janet",
    "last_name": "Weaver",
    "avatar": "https://s3.amazonaws.com/..."
  }
}

表示此 JSON 的 Java 对象如下:

public class User {

  private Data data;
  private String updatedAt;

  // getters, setters and toString()
}

public class Data {

  private Integer id;
  private String email;

  @JsonbProperty("first_name")
  private String firstName;

  @JsonbProperty("last_name")
  private String lastName;

  private String avatar;

  // getters, setters and toString()
}

现在,让我们看看如何在请求和响应中操作 JSON。

JSON 响应到用户

以下解决方案触发GET请求,并将返回的 JSON 响应转换为User

Jsonb jsonb = JsonbBuilder.create();
HttpClient client = HttpClient.newHttpClient();

HttpRequest requestGet = HttpRequest.newBuilder()
  .uri(URI.create("https://reqres.in/api/users/2"))
  .build();

HttpResponse<User> responseGet = client.send(
  requestGet, JsonBodyHandler.jsonBodyHandler(jsonb, User.class));

User user = responseGet.body();

将用户更新为 JSON 请求

以下解决方案更新我们在上一小节中获取的用户的电子邮件地址:

user.getData().setEmail("newemail@gmail.com");

HttpRequest requestPut = HttpRequest.newBuilder()
  .header("Content-Type", "application/json")
  .uri(URI.create("https://reqres.in/api/users"))
  .PUT(HttpRequest.BodyPublishers.ofString(jsonb.toJson(user)))
  .build();

HttpResponse<User> responsePut = client.send(
  requestPut, JsonBodyHandler.jsonBodyHandler(jsonb, User.class));

User updatedUser = responsePut.body();

新用户到 JSON 请求

以下解决方案创建一个新用户(响应状态码应为201):

Data data = new Data();
data.setId(10);
data.setFirstName("John");
data.setLastName("Year");
data.setAvatar("https://johnyear.com/jy.png");

User newUser = new User();
newUser.setData(data);

HttpRequest requestPost = HttpRequest.newBuilder()
  .header("Content-Type", "application/json")
  .uri(URI.create("https://reqres.in/api/users"))
  .POST(HttpRequest.BodyPublishers.ofString(jsonb.toJson(user)))
  .build();

HttpResponse<Void> responsePost = client.send(
  requestPost, HttpResponse.BodyHandlers.discarding());

int sc = responsePost.statusCode(); // 201

注意,我们忽略了通过HttpResponse.BodyHandlers.discarding()的任何响应体。

264 压缩

在服务器上启用.gzip压缩是一种常见的做法,这意味着可以显著提高站点的加载时间。但是 JDK11 的 HTTP 客户端 API 没有利用.gzip压缩。换句话说,HTTP 客户端 API 不需要压缩响应,也不知道如何处理这些响应。

为了请求压缩响应,我们必须发送带有.gzip值的Accept-Encoding头。此标头不是由 HTTP 客户端 API 添加的,因此我们将按如下方式添加它:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
  .header("Accept-Encoding", "gzip")
  .uri(URI.create("https://davidwalsh.name"))
  .build();

这只是工作的一半。到目前为止,如果在服务器上启用了gzip编码,那么我们将收到一个压缩响应。为了检测响应是否被压缩,我们必须检查Encoding头,如下所示:

HttpResponse<InputStream> response = client.send(
  request, HttpResponse.BodyHandlers.ofInputStream());

String encoding = response.headers()
  .firstValue("Content-Encoding").orElse("");

if ("gzip".equals(encoding)) {
  String gzipAsString = gZipToString(response.body());
  System.out.println(gzipAsString);
} else {
  String isAsString = isToString(response.body());
  System.out.println(isAsString);
}

gZipToString()方法是一种辅助方法,它接受InputStream并将其视为GZIPInputStream。换句话说,此方法从给定的输入流中读取字节并使用它们创建字符串:

public static String gzipToString(InputStream gzip) 
    throws IOException {

  byte[] allBytes;
  try (InputStream fromIs = new GZIPInputStream(gzip)) {
    allBytes = fromIs.readAllBytes();
  }

  return new String(allBytes, StandardCharsets.UTF_8);
}

如果响应没有被压缩,那么isToString()就是我们需要的辅助方法:

public static String isToString(InputStream is) throws IOException {

  byte[] allBytes;
  try (InputStream fromIs = is) {
    allBytes = fromIs.readAllBytes();
  }

  return new String(allBytes, StandardCharsets.UTF_8);
}

265 处理表单数据

JDK11 的 HTTP 客户端 API 没有内置支持用x-www-form-urlencoded触发POST请求。这个问题的解决方案是依赖于一个定制的BodyPublisher类。

如果我们考虑以下几点,那么编写一个定制的BodyPublisher类非常简单:

  • 数据表示为键值对
  • 每对为key = value格式
  • 每对通过&字符分开
  • 键和值应正确编码

由于数据是用键值对表示的,所以存储在Map中非常方便。此外,我们只是循环这个Map并应用前面的信息,如下所示:

public class FormBodyPublisher {

  public static HttpRequest.BodyPublisher ofForm(
      Map<Object, Object> data) {

    StringBuilder body = new StringBuilder();

    for (Object dataKey: data.keySet()) {
      if (body.length() > 0) {
        body.append("&");
      }

      body.append(encode(dataKey))
        .append("=")
        .append(encode(data.get(dataKey)));
    }

    return HttpRequest.BodyPublishers.ofString(body.toString());
  }

  private static String encode(Object obj) {
    return URLEncoder.encode(obj.toString(), StandardCharsets.UTF_8);
  }
}

依赖此解决方案,可以触发如下的POSTx-www-form-urlencoded)请求:

Map<Object, Object> data = new HashMap<>();
data.put("firstname", "John");
data.put("lastname", "Year");
data.put("age", 54);
data.put("avatar", "https://avatars.com/johnyear");

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
  .header("Content-Type", "application/x-www-form-urlencoded")
  .uri(URI.create("http://jkorpela.fi/cgi-bin/echo.cgi"))
  .POST(FormBodyPublisher.ofForm(data))
  .build();

HttpResponse<String> response = client.send(
  request, HttpResponse.BodyHandlers.ofString());

在这种情况下,响应只是发送数据的回音。根据服务器的响应,应用需要处理它,如“处理响应体类型”部分所示。

266 下载资源

正如我们在“设置请求体”和“处理响应体类型”部分中看到的,HTTP 客户端 API 可以发送和接收文本和二进制数据(例如,图像、视频等)。

下载文件依赖于以下两个坐标:

  • 发送GET请求
  • 处理接收到的字节(例如,通过BodyHandlers.ofFile()

以下代码从项目类路径中的 Maven 存储库下载hibernate-core-5.4.2.Final.jar

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
  .uri(URI.create("http://.../hibernate-core-5.4.2.Final.jar"))
  .build();

HttpResponse<Path> response 
  = client.send(request, HttpResponse.BodyHandlers.ofFile(
    Path.of("hibernate-core-5.4.2.Final.jar")));

如果要下载的资源是通过Content-DispositionHTTP 头传递的,属于Content-Disposition attachment; filename="..."类型,那么我们可以依赖BodyHandlers.ofFileDownload(),如下例:

import static java.nio.file.StandardOpenOption.CREATE;
...
HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
  .uri(URI.create("http://...downloadfile.php
    ?file=Hello.txt&cd=attachment+filename"))
  .build();

HttpResponse<Path> response = client.send(request,
  HttpResponse.BodyHandlers.ofFileDownload(Path.of(
    System.getProperty("user.dir")), CREATE));

更多可以测试的文件可以在这里找到

267 使用多部分的上传

正如我们在“设置请求体”部分所看到的,我们可以通过BodyPublishers.ofFile()POST请求向服务器发送一个文件(文本或二进制文件)。

但是发送一个经典的上传请求可能涉及多部分形式POST,其中Content-Typemultipart/form-data

在这种情况下,请求体由边界分隔的部分组成,如下图所示(--779d334bbfa...是边界):

然而,JDK11 的 HTTP 客户端 API 并没有为构建这种请求体提供内置支持。尽管如此,按照前面的屏幕截图,我们可以定义一个定制的BodyPublisher,如下所示:

public class MultipartBodyPublisher {

  private static final String LINE_SEPARATOR = System.lineSeparator();

  public static HttpRequest.BodyPublisher ofMultipart(
    Map<Object, Object> data, String boundary) throws IOException {

    final byte[] separator = ("--" + boundary +
      LINE_SEPARATOR + "Content-Disposition: form-data;
      name = ").getBytes(StandardCharsets.UTF_8);

      final List<byte[] > body = new ArrayList<>();

      for (Object dataKey: data.keySet()) {

        body.add(separator);
        Object dataValue = data.get(dataKey);

        if (dataValue instanceof Path) {
          Path path = (Path) dataValue;
          String mimeType = fetchMimeType(path);

          body.add(("\"" + dataKey + "\"; filename=\"" +
            path.getFileName() + "\"" + LINE_SEPARATOR +
            "Content-Type: " + mimeType + LINE_SEPARATOR +
            LINE_SEPARATOR).getBytes(StandardCharsets.UTF_8));

          body.add(Files.readAllBytes(path));
          body.add(LINE_SEPARATOR.getBytes(StandardCharsets.UTF_8));
        } else {
          body.add(("\"" + dataKey + "\"" + LINE_SEPARATOR +
              LINE_SEPARATOR + dataValue + LINE_SEPARATOR)
                .getBytes(StandardCharsets.UTF_8));
        }
      }

      body.add(("--" + boundary 
        + "--").getBytes(StandardCharsets.UTF_8));

      return HttpRequest.BodyPublishers.ofByteArrays(body);
    }

    private static String fetchMimeType(
      Path filenamePath) throws IOException {

      String mimeType = Files.probeContentType(filenamePath);

      if (mimeType == null) {
        throw new IOException("Mime type could not be fetched");
      }

      return mimeType;
    }
  }

现在,我们可以创建一个multipart请求,如下所示(我们尝试将一个名为LoremIpsum.txt的文本文件上传到一个只发送原始表单数据的服务器):

Map<Object, Object> data = new LinkedHashMap<>();
data.put("author", "Lorem Ipsum Generator");
data.put("filefield", Path.of("LoremIpsum.txt"));

String boundary = UUID.randomUUID().toString().replaceAll("-", "");

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
  .header("Content-Type", "multipart/form-data;boundary=" + boundary)
  .POST(MultipartBodyPublisher.ofMultipart(data, boundary))
  .uri(URI.create("http://jkorpela.fi/cgi-bin/echoraw.cgi"))
  .build();

HttpResponse<String> response = client.send(
  request, HttpResponse.BodyHandlers.ofString());

响应应类似于以下(边界只是一个随机的UUID

--7ea7a8311ada4804ab11d29bcdedcc55
Content-Disposition: form-data; name="author"
Lorem Ipsum Generator
--7ea7a8311ada4804ab11d29bcdedcc55
Content-Disposition: form-data; name="filefield"; filename="LoremIpsum.txt"
Content-Type: text/plain
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do 
eiusmod tempor incididunt ut labore et dolore magna aliqua.
--7ea7a8311ada4804ab11d29bcdedcc55--

268 HTTP/2 服务器推送

除了复用之外,HTTP/2 的另一个强大特性是它的服务器推送功能。

主要地,在传统方法(HTTP/1.1)中,浏览器触发获取 HTML 页面的请求,并解析接收到的标记以识别引用的资源(例如 JS、CSS、图像等)。为了获取这些资源,浏览器发送额外的请求(每个引用的资源一个请求)。另一方面,HTTP/2 发送 HTML 页面和引用的资源,而不需要来自浏览器的显式请求。因此,浏览器请求 HTML 页面并接收该页面以及显示该页面所需的所有其他内容。

HTTP 客户端 API 通过PushPromiseHandler接口支持此 HTTP/2 特性。此接口的实现必须作为send()sendAsync()方法的第三个参数给出。

PushPromiseHandler依赖于三个坐标,如下所示:

  • 发起客户端发送请求(initiatingRequest
  • 合成推送请求(pushPromiseRequest
  • 接受函数,必须成功调用它才能接受推送承诺(接受方)

通过调用给定的接受者函数来接受推送承诺。接受函数必须传递一个非空的BodyHandler,用于处理承诺的响应体。acceptor函数将返回一个完成承诺响应的CompletableFuture实例。

基于这些信息,我们来看一下PushPromiseHandler的实现:

private static final List<CompletableFuture<Void>>
  asyncPushRequests = new CopyOnWriteArrayList<>();
...
private static HttpResponse.PushPromiseHandler<String>
  pushPromiseHandler() {

    return (HttpRequest initiatingRequest, 
      HttpRequest pushPromiseRequest,
      Function<HttpResponse.BodyHandler<String> ,
      CompletableFuture<HttpResponse<String>>> acceptor) -> {
      CompletableFuture<Void> pushcf =
      acceptor.apply(HttpResponse.BodyHandlers.ofString())
      .thenApply(HttpResponse::body)
      .thenAccept((b) -> System.out.println(
        "\nPushed resource body:\n " + b));

      asyncPushRequests.add(pushcf);

      System.out.println("\nJust got promise push number: " +
        asyncPushRequests.size());
      System.out.println("\nInitial push request: " +
        initiatingRequest.uri());
      System.out.println("Initial push headers: " +
        initiatingRequest.headers());
      System.out.println("Promise push request: " +
        pushPromiseRequest.uri());
      System.out.println("Promise push headers: " +
        pushPromiseRequest.headers());
    };
  }

现在,让我们触发一个请求并将这个PushPromiseHandler传递给sendAsync()

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
  .uri(URI.create("https://http2.golang.org/serverpush"))
  .build();

client.sendAsync(request,
    HttpResponse.BodyHandlers.ofString(), pushPromiseHandler())
  .thenApply(HttpResponse::body)
  .thenAccept((b) -> System.out.println("\nMain resource:\n" + b))
  .join();

asyncPushRequests.forEach(CompletableFuture::join);

System.out.println("\nFetched a total of " +
  asyncPushRequests.size() + " push requests");

如果我们想返回一个 push-promise 处理器,该处理器将 push-promise 及其响应累积到给定的映射中,那么我们可以使用PushPromiseHandler.of()方法,如下所示:

private static final ConcurrentMap<HttpRequest,
  CompletableFuture<HttpResponse<String>>> promisesMap 
    = new ConcurrentHashMap<>();

private static final Function<HttpRequest,
  HttpResponse.BodyHandler<String>> promiseHandler 
    = (HttpRequest req) -> HttpResponse.BodyHandlers.ofString();

public static void main(String[] args)
    throws IOException, InterruptedException {

  HttpClient client = HttpClient.newHttpClient();

  HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://http2.golang.org/serverpush"))
    .build();

  client.sendAsync(request,
      HttpResponse.BodyHandlers.ofString(), pushPromiseHandler())
    .thenApply(HttpResponse::body)
    .thenAccept((b) -> System.out.println("\nMain resource:\n" + b))
    .join();

  System.out.println("\nPush promises map size: " +
    promisesMap.size() + "\n");

  promisesMap.entrySet().forEach((entry) -> {
    System.out.println("Request = " + entry.getKey() +
      ", \nResponse = " + entry.getValue().join().body());
  });
}

private static HttpResponse.PushPromiseHandler<String>
  pushPromiseHandler() {

    return HttpResponse.PushPromiseHandler
      .of(promiseHandler, promisesMap);
  }

在上述两种解决方案中,我们通过ofString()使用了String类型的BodyHandler。如果服务器也推送二进制数据(例如,图像),则这不是很有用。所以,如果我们处理二进制数据,我们需要通过ofByteArray()切换到byte[]类型的BodyHandler。或者,我们可以通过ofFile()将推送的资源发送到磁盘,如下面的解决方案所示,这是前面解决方案的一个改编版本:

private static final ConcurrentMap<HttpRequest,
  CompletableFuture<HttpResponse<Path>>>
    promisesMap = new ConcurrentHashMap<>();

private static final Function<HttpRequest,
  HttpResponse.BodyHandler<Path>> promiseHandler 
    = (HttpRequest req) -> HttpResponse.BodyHandlers.ofFile(
      Paths.get(req.uri().getPath()).getFileName());

public static void main(String[] args)
    throws IOException, InterruptedException {

  HttpClient client = HttpClient.newHttpClient();

  HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://http2.golang.org/serverpush"))
    .build();

  client.sendAsync(request, HttpResponse.BodyHandlers.ofFile(
      Path.of("index.html")), pushPromiseHandler())
    .thenApply(HttpResponse::body)
    .thenAccept((b) -> System.out.println("\nMain resource:\n" + b))
    .join();

  System.out.println("\nPush promises map size: " +
    promisesMap.size() + "\n");

  promisesMap.entrySet().forEach((entry) -> {
    System.out.println("Request = " + entry.getKey() +
      ", \nResponse = " + entry.getValue().join().body());
  });
}

private static HttpResponse.PushPromiseHandler<Path>
  pushPromiseHandler() {

    return HttpResponse.PushPromiseHandler
      .of(promiseHandler, promisesMap);
  }

前面的代码应该将推送的资源保存在应用类路径中,如下面的屏幕截图所示:

269 WebSocket

HTTP 客户端支持 WebSocket 协议。在 API 方面,实现的核心是java.net.http.WebSocket接口。这个接口公开了一套处理 WebSocket 通信的方法。

异步构建WebSocket实例可以通过HttpClient.newWebSocketBuilder().buildAsync()完成。

例如,我们可以连接到众所周知的 Meetup RSVP WebSocket 端点(ws://stream.meetup.com/2/rsvps),如下所示:

HttpClient client = HttpClient.newHttpClient();

WebSocket webSocket = client.newWebSocketBuilder()
  .buildAsync(URI.create("ws://stream.meetup.com/2/rsvps"), 
    wsListener).get(10, TimeUnit.SECONDS);

就其性质而言,WebSocket 协议是双向的。为了发送数据,我们可以依赖于sendText()sendBinary()sendPing()sendPong()。Meetup RSVP 不会处理我们发送的消息,但为了好玩,我们可以发送一条文本消息,如下所示:

webSocket.sendText("I am an Meetup RSVP fan", true);

boolean参数用于标记消息的结束。如果此调用未完成,则消息将通过false

要关闭连接,我们需要使用sendClose(),如下所示:

webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");

最后,我们需要编写处理传入消息的WebSocket.Listener。这是一个包含一系列具有默认实现的方法的接口。以下代码简单覆盖onOpen()onText()onClose()。粘贴 WebSocket 监听器和前面的代码将导致以下应用:

public class Main {

  public static void main(String[] args) throws 
      InterruptedException, ExecutionException, TimeoutException {

    Listener wsListener = new Listener() {

      @Override
      public CompletionStage<?> onText(WebSocket webSocket,
          CharSequence data, boolean last) {
        System.out.println("Received data: " + data);

        return Listener.super.onText(webSocket, data, last);
      }

      @Override
      public void onOpen(WebSocket webSocket) {
        System.out.println("Connection is open ...");
        Listener.super.onOpen(webSocket);
      }

      @Override
      public CompletionStage<? > onClose(WebSocket webSocket,
          int statusCode, String reason) {
        System.out.println("Closing connection: " +
          statusCode + " " + reason);

        return Listener.super.onClose(webSocket, statusCode, reason);
      }
    };

    HttpClient client = HttpClient.newHttpClient();

    WebSocket webSocket = client.newWebSocketBuilder()
      .buildAsync(URI.create(
        "ws://stream.meetup.com/2/rsvps"), wsListener)
      .get(10, TimeUnit.SECONDS);

    TimeUnit.SECONDS.sleep(10);

    webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
  }
}

此应用将运行 10 秒,并将产生类似于以下内容的输出:

Connection is open ...

Received data: {"visibility":"public","response":"yes","guests":0,"member":{"member_id":267133566,"photo":"https:\/\/secure.meetupstatic.com\/photos\/member\/8\/7\/8\/a\/thumb_282154698.jpeg","member_name":"SANDRA MARTINEZ"},"rsvp_id":1781366945...

Received data: {"visibility":"public","response":"yes","guests":1,"member":{"member_id":51797722,...
...

10 秒后,应用与 WebSocket 端点断开连接。

总结

我们的任务完成了!这是本章的最后一个问题。现在,我们已经到了这本书的结尾。看起来新的 HTTP 客户端和 WebSocketAPI 非常酷。它们具有很大的灵活性和多功能性,非常直观,并且成功地隐藏了许多我们不想在开发过程中处理的令人痛苦的细节。

从本章下载应用以查看结果和其他详细信息。

posted @ 2025-09-10 15:11  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报