在Java 8中引入的Java Streams非常棒——让我们可以充分利用lambda表达式来替换循环迭代代码,让代码更加接近于函数式编程风格。
然而,尽管Streams带来了改进,但它最终只是对现有集合框架的扩展,仍然背着很多包袱。
我们可以进一步改进吗?我们能否拥有更丰富的接口和更清晰、更易读的代码?与传统的集合相比,我们能否节省更多内存?我们能否更好、更无缝地支持函数式编程?
答案是肯定的! Eclipse Collections (以前叫作GS Collections)是Java Collections框架的一个替代品,我们可以用它来实现我们的目的。
在本文中,我们将演示几个例子,将标准的Java代码重构成Eclipse Collections数据结构和API,以及如何节省内存。
这里将会有很多代码示例,它们将展示如何将使用标准Java集合和Streams的代码改为使用Eclipse Collection框架的代码。
在深入研究代码之前,我们将花一些时间来了解Eclipse Collections是什么、我们为什么需要它,以及为什么需要将惯用的Java重构成Eclipse Collections。
Eclipse Collections最初是由高盛公司创建的,他们的应用平台有一个大型的分布式缓存组件。该系统将数百GB的数据存储在内存中(现在仍在生产环境运行)。
事实上,缓存就是一个Map,我们在Map里保存和读取对象。这些对象可以包含其他Map和集合。最初,缓存基于java.util.*包中的标准数据结构而构建。但很明显,这些集合有两个明显的缺点:内存使用效率低下,而且接口非常有限(导致重复且难以阅读的代码)。由于问题源于集合的实现,因此无法通过额外的代码库来解决这些问题。为了同时解决这两个问题,高盛公司决定从头开始创建一个新的集合框架。
在当时,它似乎是一个激进的解决方案,不过它确实可行。现在,这个框架托管给了Eclipse基金会。
在文章的最后,我们分享了一些链接,这些链接将帮助你了解有关这个项目本身的更多信息、学习如何使用Eclipse Collections以及如何成为这个项目的代码贡献者。
Eclipse Collections有什么好处?因为它提供了更丰富的API、高效的内存使用以及更好的性能。在我们看来,Eclipse Collections是Java生态圈中最为丰富的集合库。而且它与JDK中的集合完全兼容。
在深入了解这些好处之前,请务必注意,迁移到Eclipse Collections非常容易,不一定要一次性完成所有工作。Eclipse Collections完全兼容JDK的java.util.* List、Set和Map接口。它也与JDK中的其他库兼容,比如Collectors。我们的数据结构继承了JDK的这些接口,所以它们可以作为JDK对应的替代品(不过Stack接口是不兼容的,还有新的不可变集合也不兼容,因为在JDK中不存在相应的接口)。
实现了java.util.List、Set和Map接口的Eclipse Collections具有更丰富的API,我们将在后面的代码示例中探讨这些API。JDK中缺少了一些类型,例如Bag、Multimap和BiMap。Bag是一种多重集,可以包含重复元素。从逻辑上讲,我们可以将其视为元素到它们出现次数的映射。BiMap是一种“倒置”的Map,不仅可以通过按键来查找值,也可以通过值来查找键。Multimap是一种Map,它的值就是集合(如Key->List、Key->Set等)。
在使用Eclipse Collections时,我们可以非常容易地在lazy和eager两种实现模式间切换,有助于编写、理解和调试函数式代码。与Streams API不同的是,eager是默认的模式。如果你想要使用lazy模式,只需要在开始你的逻辑代码之前,在你数据结构上调用.asLazy()。
有了不可变集合,你可以在API层面通过不可变性写出更加正确的代码。在这种情况下,程序的正确性将由编译器来保证,避免在执行过程中出现意外。借助不可变集合和更丰富的接口,你可以在Java中写出纯函数式代码。
Eclipse Collections也提供了原始类型的容器,所有原始集合类型都有不可变的对等物。值得一提的是,JDK的Streams支持int、long和double,而Eclipse Collections支持所有八个原始类型,并且可以定义用于直接保存原始值的集合(与它们的装箱对象不同,例如Eclipse Collections IntList是一个int列表,而JDK中的List<Integer>是一个装箱的原始值列表)。
什么是“bun”方法?这是由Oracle Java首席设计师Brian Goetz发明的一个比喻说法。一个汉堡包(两片圆面包中间夹着肉)代表典型的流式代码结构。在使用Java Streams时,如果你想做点什么,必须把你的方法放在两块“面包”之间——前面是stream()(或parallelStream())方法,后面是collect()方法。这些面包其实没有什么营养,但如果没有它们,你就无法吃到肉。在Eclipse Collections中,这些方法不是必需的。下面的例子演示了JDK中的bun方法:假设我们有一个名单,上面有他们的姓名和年龄,我们想要取出年龄超过21岁的人的姓名:
var people = List.of(new Person("Alice", 19), new Person("Bob", 52), new Person("Carol", 35)); var namesOver21 = people.stream() // Bun .filter(person -> person.getAge() > 21) // Meat .map(Person::getName) // Meat .collect(Collectors.toList()); // Bun namesOver21.forEach(System.out::println);
下面是Eclipse Collections的代码——不需要bun方法!
var people = Lists.immutable.of(new Person(“Alice”, 19), new Person(“Bob”, 52), new Person(“Carol”, 35)); var namesOver21 = people .select(person -> person.getAge() > 21) // Meat, no buns .collect(Person::getName); // Meat namesOver21.forEach(System.out::println);
在Eclipse Collections中,每种情况都有相应的类型和方法,你可以根据你的需求找到它们。没有必要记住它们的名字——只要想想你需要什么样的数据结构。你需要一个可变或不可变的集合吗?排序的?你想要在集合中存储什么类型的数据——原始值还是对象?你需要什么样的结合?lazy的、eager的还是parallel的?后面将给出一张图表,按照这张图表中所列的方法,就可以轻松构建我们所需的数据结构。
这与Java 9中List、Set和Map接口的工厂方法类似,而且提供了更多选项!
集合类型本身就提供了丰富的API,可直接使用。这些集合类型继承了RichIterable接口(或PrimitiveIterable)。我们将在接下来的例子中看到部分这样的API。
词云——这也不是什么新东西了,不是吗?不过,这并不是完全没有道理的——它表达了一些重要的观点。首先,方法太多了,涵盖了每个可以想象得到的迭代模式,可直接在集合类型上使用。其次,词云中的单词数量与方法的数量成正比。针对特定类型而优化的不同集合类型上有多种方法实现。
让我们从简单的事情开始。
给定一个文本(在本例中是一首童谣),计算文本中每个单词的出现次数,输出结果是单词集合和每个单词相应的出现次数。
@BeforeClass static public void loadData() { words = Lists.mutable.of(( "Bah, Bah, black sheep,/n" + "Have you any wool?/n").split("[ ,/n?]+") ); }
请注意,我们将使用Eclipse Collections工厂方法来计算单词。这相当于JDK中的Arrays.asList(…)方法,不过它返回的是MutableList的一个实例。由于MutableList接口与JDK的List完全兼容,因此我们可以在下面的JDK和Eclipse Collections示例中使用此类型。
首先,让我们来看看一个不使用Streams的实现:
@Test public void countJdkNaive() { Map<String, Integer> wordCount = new HashMap<>(); words.forEach(w -> { int count = wordCount.getOrDefault(w, 0); count++; wordCount.put(w, count); }); System.out.println(wordCount); Assert.assertEquals(2, wordCount.get(“Bah”).intValue()); Assert.assertEquals(1, wordCount.get(“sheep”).intValue()); }
可以看到,我们创建了一个String到Integer的HashMap(将每个单词映射到它的出现次数),遍历每个单词,并从Map中获得它的出现次数,如果单词不存在则默认为零。然后,我们增加该值并将其存回Map中。这不是一个很好的实现,因为我们关注的是“如何”而不是“什么”,并且性能也不是很好。让我们尝试使用Streams来重写它:
@Test public void countJdkStream() { Map<String, Long> wordCounts = words.stream() .collect(Collectors.groupingBy(w -> w, Collectors.counting())); Assert.assertEquals(2, wordCounts.get(“Bah”).intValue()); Assert.assertEquals(1, wordCounts.get(“sheep”).intValue()); }
在这种情况下,代码具有更好的可读性,但效率仍然不是很高。你还需要了解如何使用Collectors类的方法——这些方法不容易被找到,因为它们不属于Streams API。
高效的实现方法是引入一个单独的计数器类,并将其作为值保存在Map中。比方说,我们有一个名为Counter的类,用于保存一个整数值,并提供increment()方法,用于将该值递增1。然后,我们可以将上面的代码重写为:
@Test public void countJdkEfficient() { Map<String, Counter> wordCounts = new HashMap<>(); words.forEach( w -> { Counter counter = wordCounts.computeIfAbsent(w, x -> new Counter()); counter.increment(); } ); Assert.assertEquals(2, wordCounts.get(“Bah”).intValue()); Assert.assertEquals(1, wordCounts.get(“sheep”).intValue()); }
这实际上是一个非常高效的解决方案,但我们必须编写一个全新的类(Counter)。
Eclipse Collection Bag提供了为这种问题量身定做的解决方案,并进行了优化。
@Test public void countEc() { Bag<String> bagOfWords = wordList.toBag(); // toBag() is a method on MutableList Assert.assertEquals(2, bagOfWords.occurrencesOf(“Bah”)); Assert.assertEquals(1, bagOfWords.occurrencesOf(“sheep”)); Assert.assertEquals(0, bagOfWords.occurrencesOf(“Cheburashka”)); // null safe - returns a zero instead of throwing an NPE }
我们所要做的就是调用集合的toBag()方法。而且,我们还可以不直接调用对象的intValue()方法来避免可能抛出的NPE。
假设我们有一个动物园。在动物园里,我们饲养着各种以不同食物为食的动物。
我们想查询一些有关动物和它们所吃食物的信息:
这些代码片段已经使用Java Microbenchmark Harness(JMH)框架进行了测试。我们将过一遍代码,然后对它们进行比较。具体的性能比较结果,请参阅下面的“JMH基准测试结果”部分。
这些是动物和它们喜欢吃的食物(每种食物都有名称、种类和数量)。
private static final Food BANANA = new Food(“Banana”, FoodType.FRUIT, 50); private static final Food APPLE = new Food(“Apple”, FoodType.FRUIT, 30); private static final Food CAKE = new Food(“Cake”, FoodType.DESSERT, 22); private static final Food CEREAL = new Food(“Cereal”, FoodType.DESSERT, 80); private static final Food SPINACH = new Food(“Spinach”, FoodType.VEGETABLE, 26); private static final Food CARROT = new Food(“Carrot”, FoodType.VEGETABLE, 27); private static final Food HAMBURGER = new Food(“Hamburger”, FoodType.MEAT, 3); private static MutableList<Animal> zooAnimals = Lists.mutable.with( new Animal(“ZigZag”, AnimalType.ZEBRA, Lists.mutable.with(BANANA, APPLE)), new Animal(“Tony”, AnimalType.TIGER, Lists.mutable.with(CEREAL, HAMBURGER)), new Animal(“Phil”, AnimalType.GIRAFFE, Lists.mutable.with(CAKE, CARROT)), new Animal(“Lil”, AnimalType.GIRAFFE, Lists.mutable.with(SPINACH)),
示例1——最受欢迎的食物。
@Benchmark public List<Map.Entry<Food, Long>> mostPopularFoodItemJdk() { //output: [Hamburger=2] return zooAnimals.stream() .flatMap(animals -> animals.getFavoriteFoods().stream()) .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())) .entrySet() .stream() .sorted(Map.Entry.<Food, Long>comparingByValue().reversed()) .limit(1) .collect(Collectors.toList()); }
我们首先对zooAnimals进行流式化,并将每只动物flatMap()到它最喜欢的食物,返回一个流。接下来,我们使用食物的标识作为关键字、数量作为值对食物进行分组,这样就可以确定每个食物对应的动物的数量。这是Collectors.counting()要做的工作。为了对它进行排序,我们调用Map的entrySet()方法,对它进行流式化,并通过反向值对它进行排序(这个值是每种食物的计数,如果我们想知道最受欢迎的食物,就需要按照逆序排序),然后调用limit(1)返回最大值,最后,我们将它收集到一个List中。
结果最受欢迎的食物是[Hamburger = 2]。
接下来,让我们来看看如何使用Eclipse Collections实现同样的功能。
@Benchmark public MutableList<ObjectIntPair<Food>> mostPopularFoodItemEc() { //output: [Hamburger:2] MutableList<ObjectIntPair<Food>> intIntPairs = zooAnimals.asLazy() .flatCollect(Animal::getFavoriteFoods) .toBag() .topOccurrences(1); return intIntPairs; }
我们也从将每只动物flatMap到它最喜欢的食物开始。因为我们真正想要的是食物到数量的Map,所以Bag可以完美解决我们的问题。我们先调用toBag(),再调用topOccurrences(),它返回最频繁出现的食物项目。topOccurrences(1)返回最受欢迎的食物,并作为ObjectIntPairs列表返回(注意int是原始类型),结果是[Hamberger:2]。
示例2——动物喜欢的食物的数量:有多少动物只吃一种食物?有多少动物吃两种食物?
首先是JDK的实现:
@Benchmark public Map<Integer, String> printNumberOfFavoriteFoodItemsToAnimalsJdk() { //output: {1=[Lil, GIRAFFE],[Simba, LION], 2=[ZigZag, ZEBRA], // [Tony, TIGER],[Phil, GIRAFFE]} return zooAnimals.stream() .collect(Collectors.groupingBy( Animal::getNumberOfFavoriteFoods, Collectors.mapping( Object::toString, // Animal.toString() returns [name, type] Collectors.joining(“,”)))); // Concatenate the list of animals for // each count into a string }
然后是使用Eclipse Collections:
@Benchmark public Map<Integer, String> printNumberOfFavoriteFoodItemsToAnimalsEc() { //output: {1=[Lil, GIRAFFE], [Simba, LION], 2=[ZigZag, ZEBRA], // [Tony, TIGER], [Phil, GIRAFFE]} return zooAnimals .stream() .collect(Collectors.groupingBy( Animal::getNumberOfFavoriteFoods, Collectors2.makeString())); }
本示例重点介绍了如何结合使用原生Java Collectors和Eclipse Collections Collector2,两者并不相互排斥。在这个例子中,我们想要获得每只动物的食物数量。那么如何实现这一目的?在原生Java中,我们首先使用Collectors.groupingBy将每只动物按照其最喜欢的食物数量分组。然后,我们使用Collectors.mapping函数将每个对象映射到它的toString方法,最后调用Collectors.joining将字符串连接起来,并用逗号分隔。
在Eclipse Collections中,我们也可以使用Collectors.groupingBy方法,不过也会调用更简洁的Collectors2.makeString来获得相同的结果(makeString将一个流变成一个以逗号分隔的字符串)。
示例3——食物单品:有多少种不同类型的食物,它们分别是什么?
@Benchmark public Set<Food> uniqueFoodsJdk() { return zooAnimals.stream() .flatMap(each -> each.getFavoriteFoods().stream()) .collect(Collectors.toSet()); } @Benchmark public Set<Food> uniqueFoodsEcWithoutTargetCollection() { return zooAnimals.flatCollect(Animal::getFavoriteFoods).toSet(); } @Benchmark public Set<Food> uniqueFoodsEcWithTargetCollection() { return zooAnimals.flatCollect(Animal::getFavoriteFoods, Sets.mutable.empty()); }
我们有几种方法可用来解决这个问题!如果使用JDK,我们对zooAnimals进行流式化,然后对它们最喜欢的食物进行flatMap,最后将它们收集到一个集合中。如果使用Eclipse Collections,我们有两种处理方式。第一种与JDK版本大致相同,flat食物,然后调用toSet()将它们放入一个集合中。第二种方式很有趣,因为它使用了目标集合的概念。flatCollect是一个重载的方法,所以提供了几种不同的使用方式。如果传入一个集合作为第二个参数,意味着我们将直接将食物flat到集合中,并跳过第一个示例中使用的中间列表。我们可以调用asLazy()来避免这种中间结果,运算会一直等待最终操作结束,从而避免出现中间状态。不过,如果你喜欢较少的API调用,或者需要将结果累加到现有的集合中,那么在从一种类型转换为另一种类型时请考虑使用目标集合。
示例4——肉食和非肉食动物:有多少肉食动物?多少非肉食动物?
请注意,在以下的两个示例中,我们选择在顶部显式(而不是通过内联的方式)声明Predicate lambda,用以强调JDK Predicate和Eclipse Collections Predicate之间的区别。 Eclipse Collections早在Java 8的java.util.function包出现之前,就已经有了Function、Predicate和其他函数类型的定义。现在,Eclipse Collections中的函数类型扩展了JDK中的等价类型,从而可以与依赖JDK库进行互操作。
@Benchmark public Map<Boolean, List<Animal>> getMeatAndNonMeatEatersJdk() { java.util.function.Predicate<Animal> eatsMeat = animal -> animal.getFavoriteFoods().stream().anyMatch( food -> food.getFoodType()== FoodType.MEAT); Map<Boolean, List<Animal>> meatAndNonMeatEaters = zooAnimals .stream() .collect(Collectors.partitioningBy(eatsMeat)); //returns{false=[[ZigZag, ZEBRA], [Phil, GIRAFFE], [Lil, GIRAFFE]], true=[[Tony, TIGER], [Simba, LION]]} return meatAndNonMeatEaters; } @Benchmark public PartitionMutableList<Animal> getMeatAndNonMeatEatersEc() { org.eclipse.collections.api.block.predicate.Predicate<Animal> eatsMeat = animal ->animal.getFavoriteFoods() .anySatisfy(food -> food.getFoodType() == FoodType.MEAT); PartitionMutableList<Animal> meatAndNonMeatEaters = zooAnimals.partition(eatsMeat); // meatAndNonMeatEaters.getSelected() = [[Tony, TIGER], [Simba, LION]] // meatAndNonMeatEaters.getRejected() = [[ZigZag, ZEBRA], [Phil, GIRAFFE], // [Lil, GIRAFFE]] return meatAndNonMeatEaters; }
我们想要通过肉食和非肉食动物来分隔元素。我们构建了一个Predicate “eatsMeat”,它检查每只动物喜欢的食物,看看是否anyMatch(JDK)或anySatisfy(Eclipse Collections),条件为食物类型为FoodType.MEAT。
在JDK示例中,我们对动物进行stream(),并调用partitioningBy(),传入eatsMeat Predicate。返回的是一个带有true或false作为键的Map。“true”将返回肉食动物,而“false”则返回非肉食动物。
在Eclipse Collections中,我们在zooAnimals上调用partition(),同时传入Predicate。我们会得到一个PartitionMutableList,它提供了两个方法——getSelected()和getRejected(),它们都返回MutableLists。被选定的元素就是肉食动物,被拒绝的元素就是非肉食动物。
在上面的例子中,重点主要集中在集合的类型和接口上。我们在开始的时候提到了使用Eclipse Collections将会带来内存方面的优化。效果可能会非常显着,具体取决于特定应用程序中使用了多大的集合以及什么类型的集合。
从图中可以看到Eclipse Collections和java.util.*集合之间的内存使用情况比较。
横轴表示存储在集合中的元素的数量,纵轴表示以千字节为单位的存储开销。这里的开销表示减去集合有效载荷之后所使用的内存(因此我们只显示数据结构本身占用的内存)。在调用System.gc()之后,我们使用totalMemory()-freeMemory()来得出内存使用量。我们观察到的结果是稳定的,并且与Java 8使用 jdk.nashorn.internal.ir.debug.ObjectSizeCalculator 的示例获得的结果是一致的(这个程序可以精确计算对象大小,可惜的是与Java 9及更高版本不兼容)。
第一张图显示了Eclipse Collections int列表与JDK Integer列表对比的优势。该图显示,对于一百万个值,java.util.*中的列表将多用15MB内存(对于JDK约为20MB的内存开销,对于Eclipse Collections约为5MB)。
Java中的Map效率非常低,因为需要用到Map.Entry对象,这会扩大内存使用量。
如果说Map内存效率不高,那么Set的效率就是糟糕透顶,因为Set的底层实现使用了Map,这太浪费内存了。Map.Entry没有多大用处,因为它只有一个属性是有用的——键,也就是集合的元素。因此,你会发现,Java中的Set和Map使用相同数量的内存,但Set可以变得更加紧凑,Eclipse Collections就做到了这一点。它最终使用的内存比JDK集合少得多,如上图所示。
最后,第四张图显示了特定结合类型的优点。如前所述,Bag只是一个集合,它允许每个元素存在多个实例,并且可以将元素与其出现的次数映射起来。你可以使用Bag来统计元素的出现次数。java.util.*中的等效数据结构是元素到其次数的Map,开发人员需要负责更新元素出现的次数。可以看到,特定数据结构(Bag)已经被优化到可以最大限度地减少内存使用和垃圾收集。
当然,我们建议对每个个案进行测试。如果用Eclipse Collections替换标准Java集合,那么结果肯定会得到改进,但是它们对内存整体使用的影响程度取决于具体情况。
在本节中,我们将分析之前那些示例的运行速度,对比改用Eclipse Collections重写之前和之后代码的性能差别。该图显示了每个示例中Eclipse Collections和JDK的每秒操作数量。较长的条表示更好的结果。正如你所看到的,速度的提升是非常明显的:
有必要强调的是,我们展示的结果仅适用于上述的具体示例。具体结果将取决于你们的特定情况,因此请务必针对你们的真实场景进行测试。
Eclipse Collections在过去的10多年中一直在演化,用以优化Java代码和应用程序。它简单易用——现成的数据结构,并提供了比传统流式代码更流畅的API。还有我们没有解决的用例?我们希望你们能够加入到贡献者行列中!欢迎从GitHub上拉取我们的代码,一起分享你们的结果!我们很乐意看到你们分享使用Eclipse Collections的体验以及它如何影响你们的应用程序。祝编码愉快!
Kristen O'Leary 是高盛服务工程小组的高级副总裁。她为Eclipse Collections带来了多个容器、API和性能增强功能,并且还在公司内部和外部教授有关该框架的课程。
Vladimir Zakharov 在软件开发方面有超过二十年的经验。他目前是高盛平台业务部门的董事总经理。他在过去的18年中一直使用Java进行开发,在此之前他还使用了Smalltalk和其他一些比较晦涩的编程语言。
查看英文原文: Refactoring to Eclipse Collections: Making Your Java Streams Leaner, Meaner, and Cleaner