内部迭代和外部迭代
集合类库主要依赖于 外部迭代(external iteration)。Collection 实现 Iterable 接口,从而使得用户可以依次遍历集合的元素。比如我们需要把一个集合中的形状都设置成红色,那么可以这么写:
for (Shape shape : shapes) {
shape.setColor(RED);
}
这个例子演示了外部迭代:for-each 循环调用 shapes 的 iterator() 方法进行依次遍历。外部循环的代码非常直接,但它有如下问题:
- Java 的 for 循环是串行的,而且必须按照集合中元素的顺序进行依次处理;
- 集合框架无法对控制流进行优化,例如通过排序、并行、短路(short-circuiting)求值以及惰性求值改善性能。
我们可以使用 内部迭代(internal iteration)替代外部迭代,用户把对迭代的控制权交给类库,并向类库传递迭代时所需执行的代码。
下面是前例的内部迭代代码:
shapes.forEach(s -> s.setColor(RED));
尽管看起来只是一个小小的语法改动,但是它们的实际差别非常巨大。用户把对操作的控制权交还给类库,从而允许类库进行各种各样的优化(例如乱序执行、惰性求值和并行等等)。总的来说,内部迭代使得外部迭代中不可能实现的优化成为可能。
外部迭代同时承担了 做什么(把形状设为红色)和 怎么做(得到 Iterator 实例然后依次遍历)两项职责,而内部迭代只负责 做什么,而把 怎么做 留给类库。通过这样的职责转变:用户的代码会变得更加清晰,而类库则可以进行各种优化,从而使所有用户都从中受益。
什么是流?
流是Java8引入的全新概念,它用来处理集合中的数据,暂且可以把它理解为一种高级集合。
众所周知,集合操作非常麻烦,若要对集合进行筛选、投影,需要写大量的代码,而流是以声明的形式操作集合,它就像SQL语句,我们只需告诉流需要对集合进行什么操作,它就会自动进行操作,并将执行结果交给你,无需我们自己手写代码。
因此,流的集合操作对我们来说是透明的,我们只需向流下达命令,它就会自动把我们想要的结果给我们。由于操作过程完全由Java处理,因此它可以根据当前硬件环境选择最优的方法处理,我们也无需编写复杂又容易出错的多线程代码了。
流的特点
只能遍历一次
我们可以把流想象成一条流水线,流水线的源头是我们的数据源(一个集合),数据源中的元素依次被输送到流水线上,我们可以在流水线上对元素进行各种操作。一旦元素走到了流水线的另一头,那么这些元素就被“消费掉了”,我们无法再对这个流进行操作。当然,我们可以从数据源那里再获得一个新的流重新遍历一遍。采用内部迭代方式
若要对集合进行处理,则需我们手写处理代码,这就叫做外部迭代。而要对流进行处理,我们只需告诉流我们需要什么结果,处理过程由流自行完成,这就称为内部迭代。
流的操作种类
流的操作分为两种,分别为中间操作 和 终端操作。
中间操作
当数据源中的数据上了流水线后,这个过程对数据进行的所有操作都称为“中间操作”。
中间操作仍然会返回一个流对象,因此多个中间操作可以串连起来形成一个流水线。终端操作
当所有的中间操作完成后,若要将数据从流水线上拿下来,则需要执行终端操作。
终端操作将返回一个执行结果,这就是你想要的数据。
流和集合的关系
集合和流尽管在表面上看起来很相似,但它们的设计目标是不同的:集合主要用来对其元素进行有效(effective)的管理和访问(access),而流并不支持对其元素进行直接操作或直接访问,而只支持通过声明式操作在其上进行运算然后得到结果。除此之外,流和集合还有一些其它不同:
无存储:流并不存储值;流的元素源自数据源(可能是某个数据结构、生成函数或 I/O 通道等等),通过一系列计算步骤得到;
惰性求值:多数流操作(包括过滤、映射、排序以及去重)都可以以惰性方式实现。这使得我们可以用一遍遍历完成整个流水线操作,并可以用短路操作提供更高效的实现;
无需上界(Bounds optional):不少问题都可以被表达为无限流(infinite stream):用户不停地读取流直到满意的结果出现为止(比如说,枚举 完美数 这个操作可以被表达为在所有整数上进行过滤)。集合是有限的,但流不是(操作无限流时我们必需使用短路操作,以确保操作可以在有限时间内完成);
从API的角度来看,流和集合完全互相独立,不过我们可以既把集合作为流的数据源(Collection 拥有 stream() 和 parallelStream() 方法),也可以通过流产生一个集合(使用前例的 collect() 方法)。Collection 以外的类型也可以作为 stream 的数据源,比如JDK中的 BufferedReader、Random 和 BitSet 已经被改造可以用做流的数据源,Arrays.stream() 则产生给定数组的流视图。事实上,任何可以用 Iterator 描述的对象都可以成为流的数据源,如果有额外的信息(比如大小、是否有序等特性),库还可以进行进一步的优化。
惰性(Laziness)
过滤和映射这样的操作既可以被 急性求值(以 filter 为例,急性求值需要在方法返回前完成对所有元素的过滤),也可以被 惰性求值(用 Stream 代表过滤结果,当且仅当需要时才进行过滤操作)在实际中进行惰性运算可以带来很多好处。比如说,如果我们进行惰性过滤,我们就可以把过滤和流水线里的其它操作混合在一起,从而不需要对数据进行多遍遍历。相类似的,如果我们在一个大型集合里搜索第一个满足某个条件的元素,我们可以在找到后直接停止,而不是继续处理整个集合。(这一点对无限数据源是很重要,惰性求值对于有限数据源起到的是优化作用,但对无限数据源起到的是决定作用,没有惰性求值,对无限数据源的操作将无法终止)
对于过滤和映射这样的操作,我们很自然的会把它当成是惰性求值操作,不过它们是否真的是惰性取决于它们的具体实现。另外,像 sum() 这样生成值的操作和 forEach() 这样产生副作用的操作都是“天然急性求值”,因为它们必须要产生具体的结果。
以下面的流水线为例:
int sum =
shapes.stream()
.filter(s -> s.getColor() == BLUE)
.mapToInt(s -> s.getWeight())
.sum();
这里的过滤操作和映射操作是惰性的,这意味着在调用 sum() 之前,我们不会从数据源提取任何元素。在 sum 操作开始之后,我们把过滤、映射以及求和混合在对数据源的一遍遍历之中。这样可以大大减少维持中间结果所带来的开销。
大多数循环都可以用数据源(数组、集合、生成函数以及I/O管道)上的聚合操作来表示:进行一系列惰性操作(过滤和映射等操作),然后用一个急性求值操作(forEach,toArray 和 collect 等操作)得到最终结果——例如过滤—映射—累积,过滤—映射—排序—遍历等组合操作。惰性操作一般被用来计算中间结果,这在Streams API设计中得到了很好的体现——与其让 filter 和 map 返回一个集合,我们选择让它们返回一个新的流。在 Streams API 中,返回流对象的操作都是惰性操作,而返回非流对象的操作(或者无返回值的操作,例如 forEach())都是急性操作。绝大多数情况下,潜在的惰性操作会被用于聚合,这正是我们想要的——流水线中的每一轮操作都会接收输入流中的元素,进行转换,然后把转换结果传给下一轮操作。
在使用这种 数据源—惰性操作—惰性操作—急性操作 流水线时,流水线中的惰性几乎是不可见的,因为计算过程被夹在数据源和最终结果(或副作用操作)之间。这使得API的可用性和性能得到了改善。
对于 anyMatch(Predicate) 和 findFirst() 这些急性求值操作,我们可以使用短路(short-circuiting)来终止不必要的运算。以下面的流水线为例:
Optional<Shape> firstBlue =
shapes.stream()
.filter(s -> s.getColor() == BLUE)
.findFirst();
由于过滤这一步是惰性的,findFirst 在从其上游得到一个元素之后就会终止,这意味着我们只会处理这个元素及其之前的元素,而不是所有元素。findFirst() 方法返回 Optional 对象,因为集合中有可能不存在满足条件的元素。Optional 是一种用于描述可缺失值的类型。
在这种设计下,用户并不需要显式进行惰性求值,甚至他们都不需要了解惰性求值。类库自己会选择最优化的计算方式。
并行(Parallelism)
流水线既可以串行执行也可以并行执行,并行或串行是流的属性。除非你显式要求使用并行流,否则JDK总会返回串行流。(串行流可以通过 parallel() 方法被转化为并行流)
尽管并行是显式的,但它并不需要成为侵入式的。利用 parallelStream(),我们可以轻松的把之前重量求和的代码并行化:
int sum =
shapes.parallelStream()
.filter(s -> s.getColor = BLUE)
.mapToInt(s -> s.getWeight())
.sum();
并行化之后和之前的代码区别并不大,然而我们可以很容易看出它是并行的(此外我们并不需要自己去实现并行代码)。
因为流的数据源可能是一个可变集合,如果在遍历流时数据源被修改,就会产生干扰(interference)。所以在进行流操作时,流的数据源应保持不变(held constant)。这个条件并不难维持,如果集合只属于当前线程,只要 lambda 表达式不修改流的数据源就可以。(这个条件和遍历集合时所需的条件相似,如果集合在遍历时被修改,绝大多数的集合实现都会抛出ConcurrentModificationException)我们把这个条件称为无干扰性(non-interference)。
我们应避免在传递给流方法的 lambda 产生副作用。一般来说,打印调试语句这种输出变量的操作是安全的,然而在 lambda 表达式里访问可变变量就有可能造成数据竞争或是其它意想不到的问题,因为 lambda 在执行时可能会同时运行在多个线程上,因而它们所看到的元素有可能和正常的顺序不一致。无干扰性有两层含义:
- 不要干扰数据源;
- 不要干扰其它 lambda 表达式,当一个 lambda 在修改某个可变状态而另一个 lambda 在读取该状态时就会产生这种干扰。
只要满足无干扰性,我们就可以安全的进行并行操作并得到可预测的结果,即便对线程不安全的集合(例如 ArrayList)也是一样。