这是处理 Java 8 中出现的东西的另一种方法:
private void countWords(final Path file) throws IOException {
Arrays.stream(new String(Files.readAllBytes(file), StandardCharsets.UTF_8).split("\\W+"))
.collect(Collectors.groupingBy(Function.<String>identity(), TreeMap::new, counting())).entrySet()
.forEach(System.out::println);
}
那么它在做什么呢?
- 它将一个文本文件完全读入内存,更准确地说是读入一个字节数组:
Files.readAllBytes(file)。这种方法出现在 Java 7 中,并且允许非常快速地加载文件的方法,但代价是文件将完全在内存中,会占用大量内存。然而,对于速度来说,这是一个很好的方法。
- byte[] 被转换为字符串:
new String(Files.readAllBytes(file), StandardCharsets.UTF_8),同时假设文件是 UTF8 编码的。根据自己的需要进行更改。价格是内存中已经巨大的数据的完整内存副本。使用内存映射文件可能会更快。
- 字符串在非单词字符处拆分:
...split("\\W+"),它会创建一个包含所有单词的字符串数组。
- 我们从该数组创建一个流:
Arrays.stream(...)。这本身并不能做很多事情,但我们可以用流做很多有趣的事情
- 我们将所有单词组合在一起:
Collectors.groupingBy(Function.<String>identity(), TreeMap::new, counting())。这表示:
- 我们希望按单词本身对单词进行分组 (
identity())。我们也可以例如如果您希望分组不区分大小写,请先将此处的字符串小写。这最终将成为地图中的关键。
- 作为存储分组值的结果,我们需要一个 TreeMap (
TreeMap::new)。 TreeMaps 是按它们的键排序的,所以我们可以很容易地在最后按字母顺序输出。如果您不需要排序,也可以在此处使用 HashMap。
- 作为每个组的值,我们希望获得每个单词的出现次数 (
counting())。在后台,这意味着对于我们添加到组中的每个单词,我们都会将计数器加一。
- 从第 5 步开始,我们留下了一个映射,将单词映射到它们的计数。现在我们只想打印它们。因此,我们使用此映射 (
.entrySet()) 中的所有键/值对访问一个集合。
- 最后是实际打印。我们说每个元素都应该传递给 println 方法:
.forEach(System.out::println)。现在,您得到了一份不错的清单。
那么这个答案有多好?好处是它很短,因此很有表现力。它也仅与隐藏在Files.readAllBytes 后面的单个系统调用(或至少一个固定的数字,我不确定这是否真的适用于单个系统调用)一起使用,并且系统调用可能是一个瓶颈。例如。如果您正在从流中读取文件,则每次调用 read 都可能触发系统调用。这通过使用顾名思义缓冲区的 BufferedReader 显着减少。但静止的readAllBytes 应该是最快的。这样做的代价是消耗大量内存。然而,维基百科声称一本典型的英文书有500 pages with 2,000 characters per page which mean roughly 1 Megabyte,即使您使用的是智能手机、树莓派或非常旧的计算机,这在内存消耗方面也不成问题。
此解决方案确实涉及一些在 Java 8 之前不可能实现的优化。例如,成语 map.put(word, map.get(word) + 1) 需要在地图中两次查找“单词”,这是不必要的浪费。
但一个简单的循环也可能更容易为编译器优化,并且可能会节省大量的方法调用。所以我想知道并对此进行测试。我使用以下方法生成了一个文件:
[ -f /tmp/random.txt ] && rm /tmp/random.txt; for i in {1..15}; do head -n 10000 /usr/share/dict/american-english >> /tmp/random.txt; done; perl -MList::Util -e 'print List::Util::shuffle <>' /tmp/random.txt > /tmp/random.tmp; mv /tmp/random.tmp /tmp/random.txt
这给了我一个大约 1.3MB 的文件,所以对于一本大多数单词重复 15 次的书来说并不是不典型的,而是以随机顺序来规避这最终成为一个分支预测测试。然后我进行了以下测试:
public class WordCountTest {
@Test(dataProvider = "provide_description_testMethod")
public void test(String description, TestMethod testMethod) throws Exception {
long start = System.currentTimeMillis();
for (int i = 0; i < 100_000; i++) {
testMethod.run();
}
System.out.println(description + " took " + (System.currentTimeMillis() - start) / 1000d + "s");
}
@DataProvider
public Object[][] provide_description_testMethod() {
Path path = Paths.get("/tmp/random.txt");
return new Object[][]{
{"classic", (TestMethod)() -> countWordsClassic(path)},
{"mixed", (TestMethod)() -> countWordsMixed(path)},
{"mixed2", (TestMethod)() -> countWordsMixed2(path)},
{"stream", (TestMethod)() -> countWordsStream(path)},
{"stream2", (TestMethod)() -> countWordsStream2(path)},
};
}
private void countWordsClassic(final Path path) throws IOException {
final Map<String, Integer> wordCounts = new HashMap<>();
for (String word : new String(readAllBytes(path), StandardCharsets.UTF_8).split("\\W+")) {
Integer oldCount = wordCounts.get(word);
if (oldCount == null) {
wordCounts.put(word, 1);
} else {
wordCounts.put(word, oldCount + 1);
}
}
}
private void countWordsMixed(final Path path) throws IOException {
final Map<String, Integer> wordCounts = new HashMap<>();
for (String word : new String(readAllBytes(path), StandardCharsets.UTF_8).split("\\W+")) {
wordCounts.merge(word, 1, (key, oldCount) -> oldCount + 1);
}
}
private void countWordsMixed2(final Path path) throws IOException {
final Map<String, Integer> wordCounts = new HashMap<>();
Pattern.compile("\\W+")
.splitAsStream(new String(readAllBytes(path), StandardCharsets.UTF_8))
.forEach(word -> wordCounts.merge(word, 1, (key, oldCount) -> oldCount + 1));
}
private void countWordsStream2(final Path tmpFile) throws IOException {
Pattern.compile("\\W+").splitAsStream(new String(readAllBytes(tmpFile), StandardCharsets.UTF_8))
.collect(Collectors.groupingBy(Function.<String>identity(), HashMap::new, counting()));
}
private void countWordsStream(final Path tmpFile) throws IOException {
Arrays.stream(new String(readAllBytes(tmpFile), StandardCharsets.UTF_8).split("\\W+"))
.collect(Collectors.groupingBy(Function.<String>identity(), HashMap::new, counting()));
}
interface TestMethod {
void run() throws Exception;
}
}
结果是:
type length diff
classic 4665s +9%
mixed 4273s +0%
mixed2 4833s +13%
stream 4868s +14%
stream2 5070s +19%
请注意,我之前也使用 TreeMaps 进行了测试,但发现 HashMaps 更快,即使我之后对输出进行了排序。此外,在 Tagir Valeev 在下面的 cmets 中告诉我关于 Pattern.splitAsStream() 方法之后,我更改了上面的测试。由于我得到了差异很大的结果,我让测试运行了很长一段时间,正如您从上面以秒为单位的长度看到的那样,以获得有意义的结果。
我如何判断结果:
完全不使用流的“混合”方法,而是使用 Java 8 中引入的带有回调的“合并”方法确实提高了性能。这是我所期望的,因为经典的 get/put 方法需要在 HashMap 中查找两次密钥,而“合并”方法不再需要。
令我惊讶的是,Pattern.splitAsStream() 方法实际上比Arrays.asStream(....split()) 慢。我确实查看了这两种实现的源代码,我注意到split() 调用将结果保存在一个 ArrayList 中,该 ArrayList 的大小从零开始,并根据需要放大。这需要许多复制操作,最后需要另一个复制操作将 ArrayList 复制到数组。但是“splitAsStream”实际上创建了一个迭代器,我认为可以根据需要对其进行查询,从而完全避免这些复制操作。我没有仔细查看将迭代器转换为流对象的所有源代码,但它似乎很慢,我不知道为什么。最后,理论上它可能与 CPU 内存缓存有关:如果一遍又一遍地执行完全相同的代码,则代码更有可能在缓存中,然后实际运行在大型函数链上,但这是一个非常疯狂的猜测我这边。它也可能是完全不同的东西。但是splitAsStream可能有更好的内存占用,也许没有,我没有分析过。
一般来说,流式方法很慢。这并不完全出乎意料,因为发生了相当多的方法调用,例如像Function.identity 这样毫无意义的东西。但是我没想到会有这么大的差异。
作为一个有趣的旁注,我发现混合方法是最快的阅读和理解。对“合并”的调用对我来说没有最明显的效果,但如果你知道这个方法在做什么,它对我来说似乎最易读,同时groupingBy 命令对我来说更难理解。我想有人可能会说这个groupingBy 是如此特殊且高度优化,因此使用它来提高性能是有意义的,但正如这里所展示的那样,情况并非如此。