面试 #Java
面试官:工作中用过 Stream 流吗?
大树:没有。
面试官:没有你也要给我介绍一下 Java stream 流的概念和作用!
大树:好的老板,那我也可以用过一些,我介绍一下~ 😜
Java 中的流(Stream)概念,可是 Java 8 中的明星特性呢!
Java 流(Stream)是一种高级迭代器,它允许我们以 声明式的方式 处理数据集合。与传统的迭代器不同,流不存储数据,而是代表了一个计算的过程,这个过程可以对数据集合进行操作,比如过滤、排序、聚合等。流就像是一个管道,数据在这个管道中按照一定的规则流动,最终得到处理结果。
流的作用主要体现在以下几个方面:
-
函数式编程风格:流 API 提供了一套丰富的函数式编程特性,使得代码更加简洁、可读性更强。我们可以用非常少的代码表达复杂的数据处理逻辑。
-
并行处理:流可以很容易地转换为 并行流(parallel stream),这样可以利用多核处理器进行高效计算。就像一个音乐指挥家找到了一支默契十足的团队,那他可以可以同时让多个不同的角色处理多个任务。
-
延迟执行:流的部分操作是惰性求值的,这意味着它们不会立即执行,而是在需要结果的时候才执行。这就像是您手中的指挥棒,只有在合适的时刻挥下,音乐家们才开始演奏。
-
无状态操作:流的操作不会改变原始数据集合,它们是无状态的。这就像是音乐家们的乐器,演奏结束后,乐器仍然是原始的状态,没有被改变。
-
易于组合:流操作可以链式调用,使得数据处理逻辑可以轻松地组合在一起。就像是一首曲子中的各个乐章,可以按照一定的顺序组合起来,形成完整的音乐作品。
面试官:你刚刚提到了个 惰性求值;那流的哪些操作是惰性求值,哪些是非惰性求值?他们的区别是什么?
大树:
在 Java 流(Stream)API 中,惰性求值(Lazy Evaluation)和非惰性求值(Eager Evaluation)是两种不同的数据处理策略。下面我解释这两种策略,并指出哪些流操作是惰性求值,哪些是非惰性求值,以及它们之间的区别。
- 惰性求值(Lazy Evaluation)的操作
惰性求值意味着操作不会立即执行,而是等到真正需要结果的时候才会执行。在流中,大多数的中间操作(如filter
、map
、flatMap
、sorted
等)都是惰性求值的。这些操作只是定义了一个处理流程,并不立即执行,只有在最终操作(如collect
、forEach
、limit
等)被调用时,中间操作才会实际执行。
例子
Stream<String> stream = people.stream()
.filter(p -> p.age > 18)
.map(Person::getName)
.sorted();
在这个例子中,filter
、map
和sorted
都是惰性求值操作,它们不会立即执行,而是等待最终操作。
- 非惰性求值(Eager Evaluation)的操作
非惰性求值意味着操作会立即执行,并且通常会立即产生结果。在流 API 中,最终操作(如collect
、forEach
、findFirst
、findAny
、limit
、count
、min
、max
、reduce
等)通常是非惰性求值的。当这些操作被调用时,它们会触发前面定义的所有中间操作,并生成最终结果。
例子
List<String> names = people.stream()
.filter(p -> p.age > 18)
.map(Person::getName)
.sorted()
.collect(Collectors.toList());
在这个例子中,collect
是一个非惰性求值操作,它会立即执行,并触发前面的filter
、map
和sorted
操作,最终生成一个包含结果的列表。
-
区别
-
执行时机:惰性求值操作不会立即执行,而是等到最终操作被调用时才执行;非惰性求值操作会立即执行。
-
性能:惰性求值可以延迟计算,直到真正需要结果时,这有助于提高性能,尤其是在处理无限流或大数据集时。非惰性求值则立即执行计算,可能会更快地得到结果,但也可能会浪费资源,如果结果实际上并不需要。
-
资源使用:惰性求值可能会导致更多的资源使用,因为中间操作可能会在最终操作中多次执行(尤其是在使用无限流时)。非惰性求值通常在一次操作中完成所有计算,可能更节省资源。
-
行为:惰性求值允许操作的组合和链式调用,因为它们不会改变原始数据。非惰性求值操作一旦执行,就会产生结果,可能会改变数据状态或产生副作用。
-
面试官:那流通常用于什么场景下?最好写个代码举例子哦
大树:humm,那我就写下我日常会用到的吧。
Java 流(Stream)API 在多种场景下都非常有用武之地,它能够简化集合(Collection)的操作,提高代码的可读性和效率。下面是一些典型的使用场景:
-
数据过滤(Filtering):当你需要从大量数据中筛选出符合特定条件的元素时,使用流的
filter
方法可以轻松实现。就像是在一群音乐家中挑选出只会演奏特定乐器的人,比如只选择会弹钢琴的音乐家。List<String> instruments = Arrays.asList("piano", "guitar", "flute", "cello", "trumpet"); List<String> stringInstruments = instruments.stream() .filter(instrument -> instrument.startsWith("g")) .collect(Collectors.toList()); // stringInstruments 将包含 "guitar" // 这段代码过滤出了以 "g" 开头的乐器名称。就像是从乐器中挑选出吉他手一样。
-
数据转换(Mapping):如果需要将集合中的每个元素转换成另一种形式或类型,可以使用流的
map
方法。这就像是将每个音乐家的乐谱翻译成另一种语言,修改音乐家名字为其他别名,以便其他音乐家理解。List<String> musicians = Arrays.asList("Yo-Yo Ma", "Lang Lang", "Itzhak Perlman"); List<String> fullNames = musicians.stream() .map(musician -> musician + " (Cellist)") .collect(Collectors.toList()); // fullNames 将包含 "Yo-Yo Ma (Cellist)" 等 // 这里我们将音乐家的名字转换成带有乐器标识的全名。就像是给每个音乐家定制一张名片,上面写着他们的名字和擅长的乐器。
-
数据聚合(Aggregating):当你需要将集合中的所有元素组合起来,形成一个更大的对象或是计算一个总和时,流的
collect
、reduce
等方法就派上用场了。List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5); String concatenated = numbers.stream() .collect(Collectors.joining(", ", "The sum is: ", ""))); // concatenated 将是 "The sum is: 1, 2, 3, 4, 5"
-
排序(Sorting):流 API 提供了
sorted
方法,可以对数据集合进行排序。这就像是在音乐会开始前,根据音乐家们的座位安排,将他们按照一定的顺序排列在舞台上。List<String> cities = Arrays.asList("New York", "Los Angeles", "Chicago", "Houston"); cities.stream() .sorted() .forEach(System.out::println); // 输出将会是按字母顺序排列的城市名
-
分组(Grouping):有时我们需要根据元素的某些属性将它们分组。流的
collect
方法结合Collectors.groupingBy
可以轻松实现这一点。就好比将乐器按照类别分组,比如弦乐、木管乐、铜管乐等。Map<String, List<String>> musicGroups
= instruments.stream()
.collect(Collectors.groupingBy(instrument -> {
if ("piano".equals(instrument)) {
return "Keyboard";
} else if (instrument.startsWith("c")) {
return "Strings";
} else {
return "Others";
}
}));
// musicGroups 将乐器按类别分组
6. **统计和数值计算(Statistical and Numerical Computations)**:流 API 提供了各种统计和数值计算的方法,如<code>min</code>、<code>max</code>、<code>average</code>等。这就像是在音乐会结束后,统计观众的掌声次数,或者计算平均分贝数。
```java
List<Integer> scores = Arrays.asList(90, 85, 95, 100, 80);
Optional<Integer> maxScore = scores.stream()
.max(Integer::compare);
// maxScore 将包含分数中的最大值,这里是 100
-
并行处理(Parallel Processing):对于大规模数据处理,使用并行流(parallel stream)可以显著提高性能。这就像是在大型音乐会中,多个音乐团队同时演奏不同的部分,共同完成一场精彩的演出。
List<String> largeDataSet = // 一个非常大的数据集 long count = largeDataSet.parallelStream() .filter(element -> someCondition(element)) .count(); // count 将是满足条件的元素数量
-
复杂查询(Complex Queries):在处理复杂的查询逻辑时,流 API 可以简化代码,使得逻辑更加清晰。就像是在寻找最佳音乐作品时,需要考虑多个因素,如旋律、和声、节奏等,流 API 可以帮助我们一步步构建这个查询过程。
List<Musician> musicians = // 音乐家列表 musicians.stream() .filter(m -> m.getInstrument().equals("piano")) .map(Musician::getName) .sorted() .collect(Collectors.toList()); // 得到按姓氏排序的钢琴家名单
面试官:多级排序怎么做?
大树:
List<Musician> musicians = Arrays.asList(
new Musician("Yo-Yo Ma", "Cello", 65),
new Musician("Lang Lang", "Piano", 38),
new Musician("Itzhak Perlman", "Violin", 76),
new Musician("Chick Corea", "Piano", 71),
new Musician("Hilary Hahn", "Violin", 45)
);
// 首先根据乐器类型排序,如果乐器类型相同,则根据年龄排序
List<Musician> sortedMusicians = musicians.stream()
// 一级排序:乐器类型
.sorted(Comparator.comparing(Musician::getInstrument)
// 二级排序:年龄
.thenComparing(Comparator.comparing(Musician::getAge))
)
.collect(Collectors.toList());
面试官:并行流适用于什么场景,使用时要注意些什么?
大树:并行流(Parallel Stream)是 Java 8 中引入的一种流操作,它利用多线程来并行处理数据,适用于需要快速处理大量数据的场景,特别是当计算非常密集时。但是,使用并行流时需要考虑到线程安全、数据处理顺序、性能调优、内存消耗、异常处理和调试难度等问题。
-
大数据集处理:当处理的数据集非常大时,使用并行流可以显著减少处理时间,因为并行流会利用多核处理器同时执行多个任务。
-
计算密集型任务:对于需要大量计算的操作,如复杂的数学运算、图形渲染等,并行流可以将任务分配到多个处理器核心上,加快计算速度。
-
I/O 密集型任务:虽然并行流主要优化计算密集型任务,但如果你的 I/O 操作可以并行化(例如,同时从多个源读取数据),并行流也可以提高效率。
然而,并行流并不是万能的,使用时需要注意以下几点:
-
线程安全:并行流会自动创建多个线程来并行处理数据,因此需要确保你的操作是线程安全的,避免共享状态和不一致的更新。
-
数据处理顺序:并行流不保证数据处理的顺序,如果你的计算依赖于元素的特定顺序,那么并行流可能不是最佳选择。
-
性能调优:并行流虽然可以提高速度,但并不是总是比顺序流更快。对于小数据集或者某些操作,创建和管理多个线程的开销可能会超过并行处理的效率提升。
-
内存消耗:并行流可能会消耗更多的内存,因为它需要为每个任务分配线程栈空间。如果内存资源有限,需要谨慎使用。
-
异常处理:并行流中的异常不容易被捕获和处理,因为它们可能发生在不同的线程中。需要确保你的代码能够妥善处理并发异常。
-
无状态操作:尽量使用无状态的操作,因为并行流可能会在不同的线程中执行不同的操作,共享状态可能导致不可预测的结果。
-
调试难度:并行程序的调试通常比单线程程序更复杂,因为需要考虑线程间的交互和竞争条件。
面试官:在并行流操作中如何处理并发异常?
大树:处理并行流中的并发异常需要采取一些预防措施和策略,因为并行流会在多个线程中执行操作,这增加了程序的复杂性和潜在的错误点。以下是一些处理并发异常的方法和建议:
-
使用无锁数据结构:在并行流中,如果需要更新共享状态,尽量使用并发 API 提供的无锁数据结构,如
ConcurrentHashMap
,以避免锁竞争和死锁。 -
减少共享状态:尽量避免在并行流中使用共享可变状态。如果必须使用共享状态,确保适当地同步访问,比如使用
synchronized
块或java.util.concurrent
包中的工具类。 -
使用线程局部变量:对于每个线程使用的数据,可以采用线程局部变量(
ThreadLocal
),这样可以保证每个线程有自己的数据副本,避免了并发问题。 -
异常捕获和处理:在并行流的终端操作中,可以使用
try-catch
块来捕获并处理可能抛出的异常。由于并行流中的操作可能在不同的线程中执行,所以异常可能不会像在单个线程中那样直接抛出。可以考虑使用reduce
操作来收集所有的异常,然后统一处理。 ``` -
使用
collect
的并行版本:如果你需要收集并行流的结果,可以使用Collectors
中的并行收集器,如Collectors.toConcurrentMap
,这样可以安全地将结果合并到一个共享集合中。 -
限制并行度:如果发现并行流操作导致过多的并发异常,可以尝试减少并行度,或者使用
sequential
方法将并行流转换为顺序流,以简化问题。 -
代码审查和测试:并行代码往往比顺序代码更难理解,因此在编写并行流代码时应该进行仔细的代码审查。此外,编写并行程序的单元测试和集成测试也非常重要,以确保程序的正确性和稳定性。
-
使用并发异常处理工具:Java 提供了一些并发异常处理工具,如
Executors
和Future
,可以帮助你更好地管理和处理并发任务中的异常。
处理并发异常没有通用的解决方案,通常需要根据具体的应用场景和需求来设计合适的策略。
面试官:Java 流(Stream)API 在多线程环境下如何保证线程安全?
大树:
Java 流(Stream)API 在多线程环境下提供了几种机制来帮助保证线程安全:
-
内部并行化:
当使用并行流(parallel Stream)时,流的内部操作(如filter
、map
、reduce
等)是并行执行的。流 API 内部处理了线程创建、任务分配和结果合并等细节,而且通常会使用ForkJoinPool
这样的工作窃取(work-stealing)策略来高效地利用多核处理器。这种内部并行化设计的足够健壮,可以在多线程环境下安全地使用。 -
无状态操作:
流操作(如filter
、map
等)通常是无状态的,即它们不依赖于外部的可变状态。这意味着每个操作都是独立的,不会改变共享状态,从而避免了并发问题。 -
使用线程安全的数据结构:
当需要在流操作中维护状态时,应使用线程安全的数据结构,如ConcurrentHashMap
、AtomicInteger
等。这些数据结构提供了同步机制,可以安全地在多线程环境下更新和访问。 -
使用并发收集器:
在收集流的结果时,可以使用专门为并发设计的收集器,如Collectors.toConcurrentMap
、Collectors.toConcurrentList
等。这些收集器能够安全地将并行流的结果合并到线程安全的目标容器中。 -
避免共享可变状态:
在流操作中避免使用共享的可变状态。如果必须使用,确保对状态的访问是线程安全的,比如通过使用同步块(synchronized
)或并发原子类(Atomic
系列)。 -
顺序执行:
如果流操作涉及到共享可变状态,或者使用的是线程不安全的操作,可以通过调用流的sequential()
方法来切换到顺序流。顺序流会按照元素的插入顺序依次处理,避免了并发问题。 -
异常处理:
并行流中的操作可能会抛出并发异常,如ConcurrentModificationException
。在设计流操作时,应考虑异常处理策略,确保程序的健壮性。 -
限制并行度:
可以通过parallelStream.parallelism()
方法来获取或设置并行流的并行度。在某些情况下,限制并行度可以减少线程间的竞争,从而降低并发问题的可能性。
面试官 :今天面试就先到这里吧,回去等通知吧