52

Eclipse Collections:让Java Streams更上一层楼

 5 years ago
source link: http://www.infoq.com/cn/articles/Refactoring-to-Eclipse-Collections?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

关键要点

  • Eclipse Collections是一个高性能的Java集合框架,为原生JDK集合增加了丰富的功能。
  • Streams是JDK的一个非常受欢迎的功能,但它缺少了一些特性,严重依赖旧版的集合实现和冗长的API。
  • Eclipse Collections为传统JDK数据结构提供了替代品,并支持Bag和Multimap等数据结构。
  • 将Streams重构为Eclipse Collections有助于提高代码可读性并减少内存占用。
  • 最重要的是,使用Eclipse Collections来重构Streams非常简单!

在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的历史

Eclipse Collections最初是由高盛公司创建的,他们的应用平台有一个大型的分布式缓存组件。该系统将数百GB的数据存储在内存中(现在仍在生产环境运行)。

事实上,缓存就是一个Map,我们在Map里保存和读取对象。这些对象可以包含其他Map和集合。最初,缓存基于java.util.*包中的标准数据结构而构建。但很明显,这些集合有两个明显的缺点:内存使用效率低下,而且接口非常有限(导致重复且难以阅读的代码)。由于问题源于集合的实现,因此无法通过额外的代码库来解决这些问题。为了同时解决这两个问题,高盛公司决定从头开始创建一个新的集合框架。

在当时,它似乎是一个激进的解决方案,不过它确实可行。现在,这个框架托管给了Eclipse基金会。

在文章的最后,我们分享了一些链接,这些链接将帮助你了解有关这个项目本身的更多信息、学习如何使用Eclipse Collections以及如何成为这个项目的代码贡献者。

为什么要重构为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中不存在相应的接口)。

更丰富的API

实现了java.util.List、Set和Map接口的Eclipse Collections具有更丰富的API,我们将在后面的代码示例中探讨这些API。JDK中缺少了一些类型,例如Bag、Multimap和BiMap。Bag是一种多重集,可以包含重复元素。从逻辑上讲,我们可以将其视为元素到它们出现次数的映射。BiMap是一种“倒置”的Map,不仅可以通过按键来查找值,也可以通过值来查找键。Multimap是一种Map,它的值就是集合(如Key->List、Key->Set等)。

eager还是lazy?

在使用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”方法

什么是“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接口的工厂方法类似,而且提供了更多选项!

1921-1529592962535.jpg

部分按类别分组的方法

集合类型本身就提供了丰富的API,可直接使用。这些集合类型继承了RichIterable接口(或PrimitiveIterable)。我们将在接下来的例子中看到部分这样的API。

5622-1529592962803.png

更多方法

词云——这也不是什么新东西了,不是吗?不过,这并不是完全没有道理的——它表达了一些重要的观点。首先,方法太多了,涵盖了每个可以想象得到的迭代模式,可直接在集合类型上使用。其次,词云中的单词数量与方法的数量成正比。针对特定类型而优化的不同集合类型上有多种方法实现。

4723-1529592961698.png

示例:字数统计

让我们从简单的事情开始。

给定一个文本(在本例中是一首童谣),计算文本中每个单词的出现次数,输出结果是单词集合和每个单词相应的出现次数。

@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.*集合之间的内存使用情况比较。

1524-1529592963421.jpg

横轴表示存储在集合中的元素的数量,纵轴表示以千字节为单位的存储开销。这里的开销表示减去集合有效载荷之后所使用的内存(因此我们只显示数据结构本身占用的内存)。在调用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集合,那么结果肯定会得到改进,但是它们对内存整体使用的影响程度取决于具体情况。

JMH基准测试结果

在本节中,我们将分析之前那些示例的运行速度,对比改用Eclipse Collections重写之前和之后代码的性能差别。该图显示了每个示例中Eclipse Collections和JDK的每秒操作数量。较长的条表示更好的结果。正如你所看到的,速度的提升是非常明显的:

725-1529592963155.jpg

有必要强调的是,我们展示的结果仅适用于上述的具体示例。具体结果将取决于你们的特定情况,因此请务必针对你们的真实场景进行测试。

结论

Eclipse Collections在过去的10多年中一直在演化,用以优化Java代码和应用程​​序。它简单易用——现成的数据结构,并提供了比传统流式代码更流畅的API。还有我们没有解决的用例?我们希望你们能够加入到贡献者行列中!欢迎从GitHub上拉取我们的代码,一起分享你们的结果!我们很乐意看到你们分享使用Eclipse Collections的体验以及它如何影响你们的应用程序。祝编码愉快!

有用的链接

关于作者

1kristen-oleary-1528755326944.png Kristen O'Leary 是高盛服务工程小组的高级副总裁。她为Eclipse Collections带来了多个容器、API和性能增强功能,并且还在公司内部和外部教授有关该框架的课程。

1vladimir-1528755326531.png Vladimir Zakharov 在软件开发方面有超过二十年的经验。他目前是高盛平台业务部门的董事总经理。他在过去的18年中一直使用Java进行开发,在此之前他还使用了Smalltalk和其他一些比较晦涩的编程语言。

查看英文原文: Refactoring to Eclipse Collections: Making Your Java Streams Leaner, Meaner, and Cleaner


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK