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

kafka streams

kafka 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行在随机的日期生成随机温度。

如果要计算所有城市在八月记录的平均温度,可以编写以下函数算法:

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环境中,使用并行流完全合法!

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

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个数字更改为300万个,以确定列表需要多大才能使用并行流计算平均值才能得到回报。

使用的算法是:

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

kafka streams

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值