多年来,使用Java进行多范例编程已经成为可能,它支持面向服务,面向对象和面向方面的编程的混合。 带有lambda和java.util.stream.Stream
类的Java 8是个好消息,因为它使我们可以将功能性编程范例添加到混合中。 确实,lambda周围有很多炒作。 但是,改变我们的习惯和编写代码的方式是明智的选择,而无需先了解可能隐患的危险吗?
Java 8的Stream
类很简洁,因为它使您可以收集数据并将该数据上的多个功能调用链接在一起,从而使代码整洁。 映射/归约算法是一个很好的例子,您可以通过首先从复杂域中选择或修改数据并对其进行简化(“映射”部分),然后将其缩减为一个有用的值来收集数据并进行汇总。
以以下数据类为例(用Groovy编写,这样我就可以免费生成构造函数,访问器,哈希/等于和toString方法的代码!):
//Groovy
@Immutable
class City {String nameList<Temperature> temperatures
}
@Immutable
class Temperature {Date dateBigDecimal 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