使用Java 8 Streams进行编程对算法性能的影响

多年来,使用Java进行多范例编程已经成为可能,它支持面向服务,面向对象和面向方面的编程的混合。 带有lambda和java.util.stream.Stream类的Java 8是个好消息,因为它使我们可以将功能性编程范例添加到混合中。 确实,lambda周围有很多炒作。 但是,改变我们的习惯和编写代码的方式是明智的选择,而无需先了解可能隐患的危险吗?

Java 8的Stream类很简洁,因为它使您可以收集数据并将该数据上的多个功能调用链接在一起,从而使代码整洁。 映射/归约算法是一个很好的例子,您可以通过首先从复杂域中选择或修改数据并对其进行简化(“映射”部分),然后将其缩减为一个有用的值来收集数据并进行汇总。

以以下数据类为例(用Groovy编写,这样我就可以免费生成构造函数,访问器,哈希/等于和toString方法的代码!):

//Groovy
@Immutable
class City {
    String name
    List<Temperature> temperatures
}
@Immutable
class Temperature {
    Date date
    BigDecimal reading
}

我可以使用这些类在“ City对象列表中构造一些随机天气数据,例如:

private static final long ONE_DAY_MS = 1000*60*60*24;
private static final Random RANDOM = new Random();

public static List<City> prepareData(
                      int numCities, int numTemps) {
    List<City> cities = new ArrayList<>();
    IntStream.range(0, numCities).forEach( i ->
        cities.add(
            new City(
                generateName(), 
                generateTemperatures(numTemps)
            )
        )
    );
    return cities;
}

private static List<Temperature> generateTemperatures(
                                         int numTemps) {
    List<Temperature> temps = new ArrayList<>();
    for(int i = 0; i < numTemps; i++){
        long when = System.currentTimeMillis();
        when += ONE_DAY_MS*RANDOM.nextInt(365);
        Date d = new Date(when);
        Temperature t = new Temperature(
                             d, 
                             new BigDecimal(
                                RANDOM.nextDouble()
                             )
                         );
        temps.add(t);
    }
    return temps;
}

private static String generateName() {
    char[] chars = new char[RANDOM.nextInt(5)+5];
    for(int i = 0; i < chars.length; i++){
        chars[i] = (char)(RANDOM.nextInt(26) + 65);
    }
    return new String(chars);
}

第7行使用同样来自Java 8的IntStream类来构造第8-13行进行迭代的范围,从而将新的城市添加到第6行中构建的列表中。第22-30行在随机日期生成随机温度。

如果要计算所有城市8月的平均气温,可以编写以下函数算法:

Instant start = Instant.now();
Double averageTemperature = cities.stream().flatMap(c ->
    c.getTemperatures().stream()
).filter(t -> {
    LocalDate ld = LocalDateTime.ofEpochSecond(
                       t.getDate().getTime(), 
                       0, 
                       ZoneOffset.UTC
                    ).toLocalDate();
    return ld.getMonth() == Month.AUGUST;
}).map(t ->
    t.getReading()
).collect(
    Collectors.averagingDouble(
        TestFilterMapReducePerformance::toDouble
    )
);

Instant end = Instant.now();
System.out.println(
    "functional calculated in " + 
    Duration.between(start, end) + 
    ": " + averageTemperature);

第1行用于启动时钟。 然后,代码在第2行上从城市列表中创建一个流。然后,我使用flatMap方法(也在第2行)通过创建所有温度的单个长列表来对数据进行扁平化,并在第3行上将其传递给lambda,从而返回每个以流的形式列出温度, flatMap方法可以将其附加在一起。 完成此操作后,我将在第4行使用filter方法丢弃所有非8月份以来的数据。 然后,我在第11行调用map方法,将每个Temperature对象转换为一个
BigDecimal以及生成的流,我在第13行使用了collect方法以及一个计算平均值的收集器。 第15行需要一个辅助函数来将BigDecimal实例转换为double ,因为第14行使用double而不是 BigDecimal

/** method to convert to double */
public static Double toDouble(BigDecimal a) {
    return a.doubleValue();
}

上面清单中的数字运算部分可以替代地以命令式方式编写,如下所示:

BigDecimal total = BigDecimal.ZERO;
int count = 0;
for(City c : cities){
    for(Temperature t : c.getTemperatures()){
        LocalDate ld = LocalDateTime.ofEpochSecond(
                          t.getDate().getTime(), 
                          0, 
                          ZoneOffset.UTC).toLocalDate();
        if(ld.getMonth() == Month.AUGUST){
            total = total.add(t.getReading());
            count++;
        }
    }
}
double averageTemperature = total.doubleValue() / count;

在命令式的命令式版本中,我以不同的顺序进行映射,过滤和归约,但是结果是相同的。 您认为哪种风格(功能性或命令性)更快,并且提高了多少?

为了更准确地读取性能数据,我需要多次运行算法,以便热点编译器有时间进行预热。 我以伪随机顺序多次运行算法,我能够测量出以功能样式编写的代码平均大约需要0.93秒(使用一千个城市,每个城市的温度为一千;使用英特尔笔记本电脑进行计算i5 2.40GHz 64位处理器(4核)。 以命令式方式编写的代码花费了0.70秒,速度提高了25%。

所以我问自己,命令式代码是否总是比功能代码更快。 让我们尝试简单地计算8月记录的温度数。 功能代码如下所示:

long count = cities.stream().flatMap(c ->
    c.getTemperatures().stream()
).filter(t -> {
    LocalDate ld = LocalDateTime.ofEpochSecond(
                       t.getDate().getTime(), 
                       0, 
                       ZoneOffset.UTC).toLocalDate();
    return ld.getMonth() == Month.AUGUST;
}).count();

功能代码涉及过滤,然后调用count方法。 另外,等效的命令性代码可能如下所示:

long count = 0;
for(City c : cities){
    for(Temperature t : c.getTemperatures()){
        LocalDate ld = LocalDateTime.ofEpochSecond(
                       t.getDate().getTime(), 
                       0, 
                       ZoneOffset.UTC).toLocalDate();
        if(ld.getMonth() == Month.AUGUST){
            count++;
        }
    }
}

在此示例中,运行的数据集与用于计算平均8月温度的数据集不同,命令性代码的平均时间为1.80秒,而功能代码的平均时间略短。 因此,我们无法推断出功能性代码比命令性代码更快或更慢。 这实际上取决于用例。 有趣的是,我们可以使用parallelStream()方法而不是stream()方法来使计算并行运行。 在计算平均温度的情况下,使用并行流意味着计算平均时间为0.46秒而不是0.93秒。 并行计算温度需要0.90秒,而不是连续1.80秒。 尝试编写命令式代码,该命令式命令将数据分割,在内核之间分布计算并将结果汇​​总为一个平均温度,这将需要大量工作! 正是这是想要向Java 8中添加函数式编程的主要原因之一。它如何工作? 拆分器和完成器用于在默认的ForkJoinPool中分发工作,默认情况下,该ForkJoinPool已优化为使用与内核一样多的线程。 从理论上讲,仅使用与内核一样多的线程就意味着不会浪费时间进行上下文切换,但这取决于所完成的工作是否包含任何阻塞的I / O –这就是我在有关Scala的书中所讨论的。

在使用Java EE应用程序服务器时,生成线程是一个有趣的主题,因为严格来说,不允许您生成线程。 但是由于创建并行流不会产生任何线程,因此无需担心! 在Java EE环境中,使用并行流完全合法!

您也可以使用地图/减少算法来计算8月的温度总数:

int count = cities.stream().map(c ->
    c.getTemperatures().size()
).reduce(
    Integer::sum
).get();

第1行从列表中创建流,并使用第2行的lambda将城市映射(转换)为城市的温度数量。第3行通过使用总和将“温度数量”流减少为单个值第4行上的Integer类的method。由于流可能不包含任何元素, reduce方法返回Optional ,我们调用get方法来获取总数。 我们可以安全地这样做,因为我们知道城市中包含数据。 如果您正在使用可能为空的数据,则可以调用orElse(T)方法,该方法允许您指定默认值(如果没有可用结果时使用)。

就编写功能代码而言,还有另一种编写此算法的方法:

long count = cities.stream().map(c ->
    c.getTemperatures().stream().count()
).reduce(
    Long::sum
).get();

使用上述方法,第2行上的lambda通过将温度列表转换为蒸汽并调用count方法来count温度列表的大小。 就性能而言, 这是获取列表大小的一种不好的方法。 在每个城市有1000个城市和1000个温度的情况下,使用第一种算法在160毫秒内计算了总数。 第二种算法将时间增加到280ms! 原因是ArrayList知道其大小,因为它在添加或删除元素时对其进行跟踪。 另一方面,流首先通过将每个元素映射到值1L ,然后使用Long::sum方法减少1L的流来计算大小。 在较长的数据列表上,与仅从列表中的属性查找大小相比,这是相当大的开销。

将功能代码所需的时间与以下命令代码所需的时间进行比较,可以看出该功能代码的运行速度慢了一倍–命令代码计算的平均温度总数仅为80ms。

long count = 0;
for(City c : cities){
    count += c.getTemperatures().size();
}

通过使用并行流而不是顺序流,再次通过在上面的三个清单中简单地在第1行上调用parallelStream()方法而不是stream()方法,导致该算法平均需要90毫秒,即比命令性代码略长。

计算温度的第三种方法是使用收集器 。 在这里,我使用了一百万个城市,每个城市只有两个温度。 该算法是:

int count = cities.stream().collect(
    Collectors.summingInt(c -> 
        c.getTemperatures().size()
    )
);

等效的命令性代码为:

long count = 0;
for(City c : cities){
    count += c.getTemperatures().size();
}

平均而言,功能性列表花费了100毫秒,这与命令性列表花费的时间相同。 另一方面,使用并行流将计算时间减少了一半,仅为50ms。

我问自己的下一个问题是,是否有可能确定需要处理多少数据,因此使用并行流值得吗? 拆分数据,将其提交给ForkJoinPool类的ExecutorService并在计算后将结果汇总在一起并不是免费的-它会降低性能。 当可以并行处理数据时,当然可以进行计算,答案通常是取决于用例。

在此实验中,我计算了一个数字列表的平均值。 我NUM_RUNS地重复工作( NUM_RUNS次),以获得可测量的值,因为计算三个数字的平均值太快了,无法可靠地进行测量。 我将列表的大小从3个数字更改为3百万个,以确定列表需要多大才能使用并行流计算平均值才能得到回报。

使用的算法是:

double avg = -1.0;
for(int i = 0; i < NUM_RUNS; i++){
    avg = numbers.stream().collect(
        Collectors.averagingInt(n->n)
    );
}

只是为了好玩,这是另一种计算方法:

double avg = -1.0;
for(int i = 0; i < NUM_RUNS; i++){
    avg = numbers.stream().
            mapToInt(n->n).
            average().
            getAsDouble();
}

结果如下。 仅使用列表中的三个数字,我就进行了100,000次计算。 多次运行测试表明,平均而言,串行计算花费了20ms,而并行计算则花费了370ms。 因此,在这种情况下,使用少量数据样本,不值得使用并行流。

另一方面,列表中有300万个数字,串行计算花费了1.58秒,而并行计算仅花费了0.93秒。 因此,在这种情况下,对于大量数据样本,值得使用并行流。 请注意,随着数据集大小的增加,运行次数减少了,因此我不必等待很长的时间(我不喝咖啡!)。

列表中的#个数字 平均 时间序列 平均 时间平行 NUM_RUNS
3 0.02秒 0.37秒 100,000
30 0.02秒 0.46秒 100,000
300 0.07秒 0.53秒 100,000
3,000 1.98秒 2.76秒 100,000
30,000 0.67秒 1.90秒 10,000
30万 1.71秒 1.98秒 1,000
3,000,000 1.58秒 0.93秒 100

这是否意味着并行流仅对大型数据集有用? 没有! 这完全取决于手头的计算强度。 下面的无效算法只是加热CPU,但演示了复杂的计算。

private void doIntensiveWork() {
    double a = Math.PI;
    for(int i = 0; i < 100; i++){
        for(int j = 0; j < 1000; j++){
            for(int k = 0; k < 100; k++){
                a = Math.sqrt(a+1);
                a *= a;
            }
        }
    }
    System.out.println(a);
}

我们可以使用以下清单生成两个可运行对象的列表,它们将完成这项繁重的工作:

private List<Runnable> generateRunnables() {
    Runnable r = () -> {
        doIntensiveWork();
    };
    return Arrays.asList(r, r);
}

最后,我们可以测量运行两个可运行对象所花费的时间,例如,并行运行(请参见第3行对parallelStream()方法的调用):

List<Runnable> runnables = generateRunnables();
Instant start = Instant.now();
runnables.parallelStream().forEach(r -> r.run());
Instant end = Instant.now();
System.out.println(
    "functional parallel calculated in " + 
    Duration.between(start, end));

使用并行流平均要花费260毫秒来完成两次密集工作。 使用串行流,平均耗时460毫秒,即几乎翻倍。

从所有这些实验中我们可以得出什么结论? 好吧,不可能最终说出功能代码比命令性代码慢,也不能说使用并行流比使用串行流快。 我们可以得出的结论是,程序员在编写对性能至关重要的代码时,需要尝试不同的解决方案并测量编码风格对性能的影响。 但是说实话,这不是什么新鲜事! 对我来说,阅读这篇文章后,您应该带走的是,总是有很多方法可以编写算法,并且选择正确的方法很重要。 知道哪种方法是对的,这是经验的结合,但更重要的是,尝试使用代码并尝试不同的解决方案。 最后,尽管如此,还是不​​要过早地进行优化!

翻译自: https://www.javacodegeeks.com/2014/05/the-effects-of-programming-with-java-8-streams-on-algorithm-performance.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值