让我们来探讨一下使用 filter() 和 map() 的代码在某些用例中是否可能效率低下,以及为什么 mapMulti() 可能是更好的替代方案。
什么是 mapMulti()?
1) 这是一个一对多的中间操作。每个元素可以转换为 0 个或多个元素。这意味着它可以用来过滤元素并对其进行转换。这就是为什么我们可以选择它,而不是使用 filter()
和 map()
链接在一起。
2) 它与 flatMap()
方法不同,因为它不需要返回 Stream。这就是为什么在我们还需要过滤元素时,它比 flatMap()
更高效。
现在我们将尝试使用 mapMulti()
为问题提供替代解决方案。然后我们将使用 JMH 比较这些解决方案的性能。
![null 图片[1]-不要使用 Stream 的 filter().map(),试试 mapMulti()-Java专区论坛-技术-SpringForAll社区](https://miro.medium.com/v2/resize:fit:1400/0*cjfA3EXGWFdwmoWR.jpg)
问题定义
给定一个表示数字的字符串列表,
从列表中仅提取偶数。
一些例子:
[“1”, “error”, “42”, “3”, “banana”, “4”] -> [42, 4]
[“1”, “error”, “3”, “banana”] -> []
解决方案 1:filter()和 map()
每个人都会写的标准代码可能看起来像这样使用 filter()
:
var evenNumbers = lines.stream()
.filter(line -> {
try {
return Integer.parseInt(line) % 2 == 0;
} catch (NumberFormatException e) {
return false;
}
})
.map(Integer::parseInt)
.toList();
第一个 filter()
解析字符串以检查它是否为偶数。然后 map()
再次解析相同的字符串以将其转换为整数。这导致了冗余工作。我们为每个有效元素调用了两次 parseInt()方法。
解决方案 2:flatMap()
flatMap()
方法避免了冗余解析,但引入了另一种低效性:
var evenNumbers = lines.stream()
.flatMap(line -> {
try {
if(Integer.parseInt(line) % 2 == 0) {
return Stream.of(Integer.parseInt(line));
}
return Stream.empty();
} catch (NumberFormatException e) {
return Stream.empty();
}
})
.toList();
虽然它解决了双重解析问题,但它为每一行创建了一个新的流,即使是无效条目也是如此。由于重复分配流对象和对数据的额外迭代,这增加了开销。
解决方案 3:mapMulti()
使用 mapMulti()
,我们可以通过避免双重解析和无用的流创建来解决这两个问题:
var evenNumbers = lines.stream()
.<Integer>mapMulti((line, consumer) -> {
try {
int number = Integer.parseInt(line);
if (number % 2 == 0) {
consumer.accept(number);
}
} catch (NumberFormatException ignored) { }
})
.toList();
这段代码相当复杂,但却是最高效的解决方案。它避免了为每一行创建新的流,并消除了双重解析的需求。
性能比较:
我们将使用 JMH 来比较这三种解决方案的性能。
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 3)
@Measurement(iterations = 5)
@Fork(1)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
public class ProblemBenchmark {
private List<String> lines;
@Setup
public void prepare() {
Random random = new Random();
lines = Stream.generate(() -> {
if (random.nextInt(10) == 0) {
return "error";
} else {
return String.valueOf(random.nextInt(1000));
}
}).limit(10000).toList();
}
@Benchmark
public List<Integer> filterMap() {
return lines.stream()
.filter(line -> {
try {
return Integer.parseInt(line) % 2 == 0;
} catch (NumberFormatException e) {
return false;
}
})
.map(Integer::parseInt)
.toList();
}
@Benchmark
public List<Integer> flatMap() {
return lines.stream()
.flatMap(line -> {
try {
int number = Integer.parseInt(line);
if (number % 2 == 0) {
return Stream.of(number);
}
return Stream.empty();
} catch (NumberFormatException e) {
return Stream.empty();
}
})
.toList();
}
@Benchmark
public List<Integer> mapMulti() {
return lines.stream()
.<Integer>mapMulti((line, consumer) -> {
try {
int number = Integer.parseInt(line);
if (number % 2 == 0) {
consumer.accept(number);
}
} catch (NumberFormatException ignored) { }
})
.toList();
}
}
正如你所想象的,结果显示 mapMulti()
比 filter().map()
和 flatMap()
更快,而且更不容易出错。
| Benchmark | Mode | Cnt | Score | Error | Units |
|-----------|------|------|-------|---------|--------|
| mapMulti | avgt | 5 | 0,690 | ± 0,017 | ms/op |
| flatMap | avgt | 5 | 0,722 | ± 0,018 | ms/op |
| filterMap | avgt | 5 | 0,758 | ± 0,037 | ms/op |
Filter() + Map() 固有的(但较小的)低效性
当你使用 .filter().map()
时,操作会作为单流管道的一部分延迟应用。流本身不会创建新流,而是将操作链接起来。然而,每个操作都会增加一层处理,可能会引入开销。
- • 每个
.filter()
和.map()
调用都会在管道中添加一个单独的步骤。 - • 每个元素流经所有中间操作,增加了一层间接性。
- • 每一步都会增加函数调用的开销,即使函数很简单。
即使有 JIT 优化,开销虽小,但仍然存在。
如果我们尝试运行一个基准测试来比较 filter().map()
与 mapMulti()
的性能,在没有执行相同操作的额外逻辑的情况下,我们可以看到 mapMulti()
更快。
基准测试:
filterMap: Average Time = 143.193 microseconds ± 2.278
mapMulti: Average Time = 129.308 microseconds ± 0.796
在这种情况下, mapMulti()
比 filter().map()
快约 10%。在大型数据集上,性能差异可能非常显著。此外, mapMulti()
的误差范围更小。这是因为 mapMulti()
避免了链接多个步骤的开销,并减少了函数调用的次数。
结论
虽然 filter()
+ map()
和 flatMap()
很有用,但 mapMulti()
提供了一种更高效、更简化的方式来过滤和转换 Java Streams 中的数据。
下次在处理过滤和映射重叠的转换时,考虑使用 mapMulti()
来编写更简洁、更高效的代码。它减少了冗余操作,避免了额外的流创建,并简化了转换逻辑。
何时使用 filter()呢?
1) 当你只需要过滤元素而不进行转换或添加额外逻辑时
2) 当流数据集较小时(绝大多数标准使用场景)
3) 当您希望确保代码更具可读性和可维护性时。mapMulti 代码很糟糕。
4) 当你不关心性能差异时(谁会在意呢?)
5) 当你不想使用一个你还不知道的新方法时
替代且更快的解决方案
在这段代码中,我们仅考虑了 filter()
和 mapMulti()
,但还有其他方法可以解决这个问题。这并非本文的范围,但这里有一些例子:
var evenNumbers = lines.stream()
.filter(line -> line.matches("\\d+") && Integer.parseInt(line) % 2 == 0)
.map(Integer::parseInt)
.toList();
普通的旧式基本 For 循环:
var result = new java.util.ArrayList<Integer>();
for(String line: lines) {
try {
int number = Integer.parseInt(line);
if (number % 2 == 0) {
result.add(number);
}
} catch (NumberFormatException ignored) { }
}
return result;
为了完整性,我也对这些解决方案进行了基准测试。以下是结果:
| Benchmark | Mode | Cnt | Score | Error | Units |
|-----------|------|-----|--------|---------|-------|
| regex | avgt | 5 | 0.513 | ± 0.109 | ms/op |
| forLoop | avgt | 5 | 0.610 | ± 0.015 | ms/op |
并行流如何
使用并行流时, filter().map()
的开销减少了,性能差异比以前小得多。当我们拥有多个核心时,链式操作的开销可以忽略不计。
| Benchmark | Mode | Cnt | Score | Error | Units |
|-----------------------|------|-----|-------|---------|-------|
| mapMultiParallel | avgt | 5 | 0.132 | ± 0.235 | ms/op |
| filterMapParallel | avgt | 5 | 0.138 | ± 0.004 | ms/op |
| flatMapParallel | avgt | 5 | 0.139 | ± 0.002 | ms/op |
| regexSolutionParallel | avgt | 5 | 0.151 | ± 0.008 | ms/op |
没有回复内容