集合的聚合操作

本文深入探讨Java中的Lambda表达式、方法引用、流和聚合操作。Lambda表达式简化了函数式接口的实现,方法引用提供了更简洁的语法。流和聚合操作允许高效的数据处理,包括过滤、映射和减少,支持并行处理。文章还讨论了惰性求值、并行流的副作用及有状态Lambda表达式的注意事项。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

相关知识

lambda表达式(Lambda Expression)

方法引用(Method References)

lambda表达式(Lambda Expression)

也叫匿名函数,lambda表示式的句法:

  • 括号包裹的,由逗号分割的参数列表。参数类型可以省略,如果只有一个参数,括号也可以省略
  • 箭头->
  • 表达体,由一个表达式或者语句组成。如果是语句的话,必须用花括号包起来{},java运行时会计算表达式并返回它的值,或者,你也可以使用return语句
// example 1
IntegerMath addition = (a, b) -> a + b;

// example 2
p -> {
    return p.getGender() == Person.Sex.MALE
        && p.getAge() >= 18
        && p.getAge() <= 25;
}

方法引用(Method References)

如果lambda表达式只是调用一个现成的方法,那么可以考虑使用方法引用,它更紧凑易读。比如,对某个数组中的成员根据年龄排序:

lambda表示是:

Arrays.sort(rosterAsArray,
    (a, b) -> Person.compareByAge(a, b)
);

方法饮用:

Arrays.sort(rosterAsArray, Person::compareByAge);

这两种用法在语义上是相同的:

  • 参数列表都是(Person, Person)
  • 方法体都调用了Person.compareByAge

聚合

管道和流

聚合操作通过管道和流完成。管道的组成:

  • 源:可以是集合、数组、生成器函数、I/O通道,通过调用源的.stream()方法可以产生一个流
  • 零个或多个中间操作:每个操作产出一个新的流(stream),比如
    • filter
    • mapToInt
    • map
  • 一个终点操作:产出一个非流的结果,可以是基本数据类型,集合,或者不产出任何值
    • forEach(不产出任何值)
    • average,合并流中的元素返回一个结果,这类操作也叫做聚合(reduction),其他还有sum, min, max, count 等
    • reduce方法,(类似于python中的reduce)

聚合操作和迭代器的区别

聚合操作,比如forEeach,和迭代器(或增强的for语句)很像,但是有些本质不同:

  • 聚合操作使用内部委托,而迭代器使用外部迭代。聚合操作没有next方法来指示它们该处理集合的下一个元素,而是使用内部委托。通过这样方式,你的程序只决定要迭代什么集合,至于怎么迭代,由JDK决定。而外部迭代是,你要决定迭代什么,以及如何迭代,并且只能串行迭代,这样无法利用并行计算的优势。
  • 聚合操作从流中处理元素
  • 聚合操作支持参数,可以将lambda表达式作为大部分聚合操作的参数,这使你能自定义聚合操作的行为。

reduce方法

reduce方法接收两个参数:

  • 初始值(或者默认值)
  • 累计器,一个函数,接收两个参数,一个是目前累计计算的结果,一个是流中的下一个元素

示例:

Integer totalAgeReduce = roster
   .stream()
   .map(Person::getAge)
   .reduce(
       0,
       (a, b) -> a + b);

collect方法

reduce方法每处理一个元素都会创建一个新的值,collect方法是修改现有的值。通过自定义一种容器类型来跟踪值的变化(类似于python中利用字典的可变性来缓存值):

// 定义容器类型
class Averager implements IntConsumer
{
    private int total = 0;
    private int count = 0;
        
    public double average() {
        return count > 0 ? ((double) total)/count : 0;
    }
        
    public void accept(int i) { total += i; count++; }
    public void combine(Averager other) {
        total += other.total;
        count += other.count;
    }
}

// 使用容器来收集结果
Averager averageCollect = roster.stream()
    .filter(p -> p.getGender() == Person.Sex.MALE)
    .map(Person::getAge)
    .collect(Averager::new, Averager::accept, Averager::combine);
                   
System.out.println("Average age of male members: " +
    averageCollect.average());

collect方法包含三个参数:

  • 供应器:一个工厂函数,创建一个新的结果容器
  • 累计器:将元素累加进
  • 合并器:接收两个结果容器,并合并它们的结果。在这个例子中,它修改一个Averager容器,将另一个Averager容器的count和total值加过来。

尽管已经提供了average等操作,但如果你需要从流中的元素计算多个值,你可以使用collect操作和自定义类。

collect方法接收Collector类型的参数

将所有男性成员放入集合中:

List<String> namesOfMaleMembersCollect = roster
    .stream()
    .filter(p -> p.getGender() == Person.Sex.MALE)
    .map(p -> p.getName())
    .collect(Collectors.toList());

我们知道,collect方法接收3个参数,而Collectors类封装了一些函数,可以用作collect方法的参数。Collectors类的大部分操作返回一个Collector实例,而不是集合,toList也不例外。

根据性别分组:

Map<Person.Sex, List<Person>> byGender =
    roster
        .stream()
        .collect(
            Collectors.groupingBy(Person::getGender));

groupingBy操作返回返回一个映射,健是性别,值是该性别下Person的列表。

这个操作也可以接受两个参数,比如,根据性别分组,并且只要每组下成员的名字:

Map<Person.Sex, List<String>> namesByGender =
    roster
        .stream()
        .collect(
            Collectors.groupingBy(
                Person::getGender,                      
                Collectors.mapping(
                    Person::getName,
                    Collectors.toList())));

这里接收两个参数,一个是分类函数,一个是Collector实例,后者被称作下游收集器。一个管道如果包含一个或多个下游收集器,就称之为多层聚合。

统计每组中年龄的总和:

Map<Person.Sex, Integer> totalAgeByGender =
    roster
        .stream()
        .collect(
            Collectors.groupingBy(
                Person::getGender,                      
                Collectors.reducing(
                    0,
                    Person::getAge,
                    Integer::sum)));

这里的reducing操作接收三个参数:

  • 初始值,和Stream.reduce操作类似
  • 映射函数
  • 聚合操作

并行

集合不是线程安全的,但是聚合操作和并行流允许你使用集合实现并行计算,前提是你在操作集合时不要修改它。注意,并行不一定比串行更快,除非你有足够的数据和处理器核心。尽管聚合操作使你更容易实现并行,但你还是要评估下你的程序是否适合并行。

并行流

创建的流默认是串行的,要创建一个并行流,调用Collection.parallelStream方法即可。并行执行一个流时,java运行时将流切分为多个子流,聚合操作以并行的方式遍历并处理子流,然后合并结果。

double average = roster
    // 创建并行流
    .parallelStream()
    .filter(p -> p.getGender() == Person.Sex.MALE)
    .mapToInt(Person::getAge)
    .average()
    .getAsDouble();

并发聚合

使用并发聚合,根据性别分组:

ConcurrentMap<Person.Sex, List<Person>> byGender =
    roster
        .parallelStream()
        .collect(
            Collectors.groupingByConcurrent(Person::getGender));

注意例子中包含的关键字:Concurrent

顺序

Integer[] intArray = {1, 2, 3, 4, 5, 6, 7, 8};
List<Integer> listOfIntegers =
        new ArrayList<>(Arrays.asList(intArray));

System.out.println("listOfIntegers: ");
listOfIntegers
        .stream()
        .forEach(e -> System.out.print(e + " "));
System.out.println("");

System.out.println("listOfIntegers sorted in reverse order:");
Comparator<Integer> normal = Integer::compare;
Comparator<Integer> reversed = normal.reversed();
// 对列表进行倒序排列
Collections.sort(listOfIntegers, reversed);
listOfIntegers
        .stream()
        .forEach(e -> System.out.print(e + " "));
System.out.println("");

System.out.println("parallel stream");
listOfIntegers
        .parallelStream()
        .forEach(e -> System.out.print(e + " "));
System.out.println("");

System.out.println("Another parallel stream:");
listOfIntegers
        .parallelStream()
        .forEach(e -> System.out.print(e + " "));
System.out.println("");

System.out.println("With forEachOrdered:");
listOfIntegers
        .parallelStream()
        .forEachOrdered(e -> System.out.print(e + " "));
System.out.println("");

执行结果如下:

listOfIntegers: 
1 2 3 4 5 6 7 8 
listOfIntegers sorted in reverse order:
8 7 6 5 4 3 2 1 
parallel stream
3 4 6 2 5 8 7 1 
Another parallel stream:
3 1 2 7 6 5 4 8 
With forEachOrdered:
8 7 6 5 4 3 2 1 

第三个和第四个管道打印的元素顺序是随机的。记住,处理流中的元素时,这些操作使用的是内部遍历。结果就是,除非由流操作另行指定,否则在并行执行一个流时,java编译器和运行时决定以什么顺序来处理流中的元素,以最大程度地发挥并行计算的优势。

第五个管道使用forEachOrdered方法,不论流是串行还是并行执行,都以源中指定的顺序来处理流中的元素。需要注意的是,在并行流中使用这种操作可能会无法利用并行计算的优势。

副作用

如果方法或者表达式除了返回或者产出值之外,还修改了计算机的状态,则它具有副作用。比如可变聚合,或者调用系统的打印方法。一个返回空的lambda表达式,比如它只是调用System.out.println方法,除了有副作用外什么也做不了。尽管JDK能很好地处管道中某些副作用,像forEach和peek这样的方法在设计时也考虑了副作用,但是在并行流中使用这些操作也要小心,因为java运行时会从多个线程并发地调用你指定为参数的lambda表达式。切勿将lambda表达式作为参数传递,它会对filter, map等操作产生副作用。下面讨论干扰和有状态的lambda表达式,这二者是副作用的来源,尤其是在并行流中,可能返回不一致或不可预测的结果。首先需要了解惰性这个概念,因为它直接影响干扰。

惰性

如果一个表达式、方法或者算法的值只有在需要时才求值,那么它们就是惰性的。所有的中间操作都是惰性的,在终点操作开始前,中间操作不会开始处理流中的内容。这种惰性允许java编译器和运行时优化对流的处理。比如管道:filter-mapToInt-average,其中filter、mapToInt是中间操作,average是终点操作。average操作可以从mapToInt操作创建的流中获取前几个元素,而mapToInt则从filer操作创建的流中获取元素。average操作一直重复这一过程,直到它获取了流中所有的元素,然后再计算平均值。(个人理解:average需要几个值,mapToInt就从filter中拿几个,而filter就从源中拿几个)

干扰

如果一个管道正在处理流时,流的源被修改了,就会发生干扰。

try {
    List<String> listOfStrings = new ArrayList<>(Arrays.asList("one", "two"));

    String concatenatedString = listOfStrings
            .stream()
            .peek(s -> listOfStrings.add("three"))
            .reduce((a, b) -> a + " " + b)
            .get();

    System.out.println("concatenatedString: " + concatenatedString);
} catch (Exception e) {
    // ConcurrentModificationException
    System.out.println(e);
}

这个示例用reduce操作(这是一个终点操作)拼接列表中的字符串。但是管道调用了中间操作peek,来尝试往列表中添加一个新的元素。所有的中间操作都是惰性的,当get被调用时,管道才开始执行。当get操作完成后,管道结束执行。peek操作的参数试图在管道执行期间修改流的源,导致异常抛出。

有状态的的lambda表达式

在流的操作中,避免使用有状态的lambda表达式作为参数。这种表达式的结果依赖于某种状态,而这种状态在管道的执行过程中可能会改变。这回导致结果的不可确定性。

Integer[] arrOfIntegers = new Integer[] {3, 2, 1, 4, 5};
List<Integer> listOfIntegers = new ArrayList<>(Arrays.asList(arrOfIntegers));
List<Integer> serialStorage = new ArrayList<>();

System.out.println("serial stream: ");
listOfIntegers
        .stream()
        .map( e -> {serialStorage.add(e); return e;})
        .forEachOrdered(e -> System.out.print(e + " "));
System.out.println("");

serialStorage
        .stream()
        .forEachOrdered(e -> System.out.print(e + " "));
System.out.println("");

System.out.println("parallel stream: ");
List<Integer> parallelStorage = Collections.synchronizedList(new ArrayList<>());
listOfIntegers
        .parallelStream()
        // 有状态的lambda表达式:
        // ForEachOrdered操作按流指定的顺序处理元素,不论流是串行还是并行执行。
        // 但是,当并行执行一个流时,map操作处理由java运行时和编译器指定的流中的元素,
        // 结果就是,每次运行时,当前lambda表达式添加元素的顺序都会不一样
        .map(e -> {parallelStorage.add(e); return e;})
        .forEachOrdered(e -> System.out.print(e + " "));
System.out.println("");

parallelStorage
        .stream()
        .forEachOrdered(e -> System.out.print(e + " "));
System.out.println("");

运行以上示例执行结果如下:

serial stream: 
3 2 1 4 5 
3 2 1 4 5 
parallel stream: 
3 2 1 4 5 
3 4 2 1 5 // 这里的结果每次不一样

注意,上面调用synchronizedList方法是为了确保列表是线程安全的。因为集合不是线程安全的,这意味多个线程不应该同时访问一个集合。假如我们不这么做,比如改为下面这样子:

List<Integer> parallelStorage = new ArrayList<>();

那么执行结果可能是这样的:

//parallel stream:
//3 2 1 4 5
//3 2 4 5
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值