news 2026/10/5 4:05:00

Java Stream 从底层逻辑到实战:创建、中间操作、终止操作与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java Stream 从底层逻辑到实战:创建、中间操作、终止操作与避坑指南

遇到挺多人第一次看到 Stream 代码时,第一反应是:这代码怎么读起来像英文句子?过滤、映射、收集,一气呵成,确实和传统 for 循环写集合完全不是一个画风。可是真到自己上手写的时候,又容易懵——Stream 到底怎么创建?中间操作和终止操作为什么不能乱换顺序?groupingBy 的分组结果到底是什么类型?这些问题不搞清楚,代码能跑但心里没底。

这篇就结合我实际写业务代码的踩坑经验,把 Stream 从底层逻辑到使用步骤完整梳理一遍。不讲虚的,核心就是把“怎么用”讲透:怎么建流、怎么玩中间操作、怎么收尾拿到结果,以及我在真实项目里遇到过哪些坑。适合刚接触 Stream 的初学者,也适合想系统性梳理一次 Stream 知识体系的开发老手。

1. Stream 先搞懂核心思想,代码自然就顺了

1.1 Stream 不是数据结构,而是一条“数据流水线”

很多人第一次学 Stream 都会犯一个认知错误:把 Stream 当成一种新的集合。其实不是。Stream 不存储数据,它更像工厂流水线——数据从传送带一头进来,经过一道道工序加工,最后在另一头打包成你需要的东西。原材料(集合数据)始终躺在原处没动,动的只是流水线上的数据影像。

这个比喻对应到 Stream 的三个核心组成部分:

  • 数据源(Source):流水线的进货口,可以是一个 List、Set、数组,甚至是生成器函数。
  • 中间操作(Intermediate Operations):流水线上的加工工序,负责过滤、转换、排序、去重。这些工序可以串很多道,而且它们是“懒”的,不给下游施压就不干活。
  • 终止操作(Terminal Operations):流水线的打包工位,执行完这一步,整条流水线才真正开动,数据经过所有工序后产出最终结果。

我见过不少刚上手的人写类似这样的代码:

List<String> list = Arrays.asList("banana", "apple", "orange"); list.stream().filter(s -> s.length() > 5);

然后发现 list 没变,filter 好像没生效。其实不是没生效,而是你根本没给流水线接上“打包工位”——没有终止操作,中间操作永远不会执行。这就是 Stream 最反直觉的一个特性:惰性求值。理解了这一点,后面所有步骤就都好懂了。

1.2 为什么用 Stream?对比一下传统写法才看得出价值

不吹不黑,我早期也觉得 for 循环写习惯了,Stream 只是语法糖而已。直到项目里遇到一个需求:从一个订单列表里,筛选出金额大于 100 的订单,按用户分组,统计每个用户的订单总额,最后按金额降序取前 5 个。用传统写法大概是:

// 传统方式 Map<String, Double> grouped = new HashMap<>(); for (Order order : allOrders) { if (order.getAmount() > 100) { grouped.merge(order.getUserName(), order.getAmount(), Double::sum); } } List<Map.Entry<String, Double>> sortedList = new ArrayList<>(grouped.entrySet()); sortedList.sort((e1, e2) -> Double.compare(e2.getValue(), e1.getValue())); List<Map.Entry<String, Double>> top5 = sortedList.subList(0, Math.min(5, sortedList.size()));

差不多 8 行,而且可读性一般,得一行行去理逻辑。换成 Stream:

Map<String, Double> result = allOrders.stream() .filter(o -> o.getAmount() > 100) .collect(Collectors.groupingBy(Order::getUserName, Collectors.summingDouble(Order::getAmount))); List<Map.Entry<String, Double>> top5 = result.entrySet().stream() .sorted(Map.Entry.<String, Double>comparingByValue().reversed()) .limit(5) .toList();

声明式写法的优势在于:你告诉程序“我要什么”,而不是“怎么一步步做”。代码量少一半,而且 filter、分组、聚合、排序、截断这些意图直接写在方法名上,读起来像读自然语言。当然,Stream 不是要完全取代 for 循环,复杂嵌套循环、需要中途 break 跳出、需要操作索引的场景,传统写法依然更合适。这个我后面会细说。

1.3 流是一次性的,不能反复消费

Stream 还有一个特别容易踩的坑:流只能消费一次。就像流水线启动一次只处理当前这批货,你想让同一批货再走一遍流水线?不行,得重新进货。

Stream<String> stream = list.stream(); stream.forEach(System.out::println); // 这里会抛 IllegalStateException: stream has already been operated upon or closed stream.forEach(System.out::println);

这个我最早踩过,当时是在一个方法里把 Stream 当参数传来传去,第二次遍历直接报错,排查了很久。解决方案很简单:别复用流对象,需要遍历几遍就list.stream()重新创建。或者收集成集合后再遍历。

2. 完整使用步骤,每一步怎么操作、底层在做什么

2.1 第一步:拿到流——多种创建方式

万事开头难,建流的方式其实有五六种,但平时够用的就几种,我直接列个表对比:

数据源类型方式示例
集合(Collection)collection.stream()list.stream()
集合(并行)collection.parallelStream()list.parallelStream()
数组Arrays.stream(array)或Stream.of(array)Arrays.stream(strArray)
一组值Stream.of(T... values)或Stream.of(a, b, c)Stream.of("a", "b", "c")
文件行BufferedReader.lines()/Files.lines()Files.lines(Paths.get("a.txt"))
无限流Stream.iterate()/Stream.generate()Stream.iterate(0, n -> n + 1).limit(10)

实际项目里用得最多的是集合创建。有一点要注意:Stream.of(array)和Arrays.stream(array)对于基本类型数组行为不一样。如果你传一个int[]给Stream.of,得到的会是Stream<int[]>而不是IntStream,因为它把整个数组当成了一个元素。数据统计时踩过这个坑,算出来的结果完全不对。正确做法:

int[] numbers = {1, 2, 3, 4, 5}; Arrays.stream(numbers) // 得到 IntStream .filter(n -> n % 2 == 0) .sum(); // 结果是 6

2.2 第二步:串中间操作——加工工序怎么选

中间操作是整个 Stream 的灵魂,也是最值得花时间掌握的部分。我按实际使用频率排序,把最常用的几个讲透。

filter(过滤):最简单但最常用,接收一个 Predicate 函数式接口,保留返回 true 的元素。

list.stream().filter(s -> s.startsWith("A")).forEach(System.out::println);

map(映射):把元素从一种形态转成另一种形态。需要注意 map 之后的流类型会跟着变化,Stream<String>转成Stream<Integer>很常见。

List<String> words = Arrays.asList("hello", "world"); words.stream().map(String::length).forEach(System.out::println); // 输出 5 5

flatMap(扁平化映射):处理“流里套流”的场景,把多个流的元素合并成一个流。比如一个 List 存了多个订单,每个订单里有多个商品,你想把商品全部聚到一起:

List<List<String>> listOfLists = Arrays.asList( Arrays.asList("a", "b"), Arrays.asList("c", "d") ); List<String> flatList = listOfLists.stream() .flatMap(List::stream) .collect(Collectors.toList()); // [a, b, c, d]

distinct(去重)、sorted(排序)、limit(截取)、skip(跳过)这四兄弟是“裁剪类”操作。其中 sorted 支持两种写法:无参的按自然顺序排序,或者传入 Comparator 自定义排序。多字段排序我后面实操章节会专门演示。limit 和 skip 合起来可以做分页,虽然性能上不如数据库分页,但一些小场景也够用。

peek(偷看一眼):中间操作里的异类,它不改变元素,只对每个元素执行一个 Consumer 操作,常用于调试——在中间某个环节打印日志看看数据长什么样。

list.stream() .peek(x -> System.out.println("filter前: " + x)) .filter(x -> x.startsWith("A")) .peek(x -> System.out.println("filter后: " + x)) .forEach(System.out::println);

中间操作可以链式无限串,但不必担心每一道工序都立即执行。因为 Stream 的惰性机制,这些操作会先被记录下来,直到终止操作出现才按顺序统一执行。这就好比你给流水线布置了十道工序,但只有按下启动按钮(终止操作),工人们才会开始干活。

2.3 第三步:终止操作——把流水线开起来

终止操作执行完,流就结束了。它是 Stream 真正“干活”的触发点,也是拿结果的地方。按输出形式分三类:

遍历类:forEach。对每个元素执行操作,没有返回值。

list.stream().forEach(System.out::println);

统计类:count / max / min / sum / average。这些在 IntStream、LongStream、DoubleStream 这些基本类型流里最常用。关于基本类型流多说一句:如果你需要做数字聚合(sum、average、max、min),优先用 IntStream 而不是 Stream ,少一层装箱拆箱,性能更好。

匹配类:anyMatch / allMatch / noneMatch。检查流中元素是否满足条件,返回 boolean。

boolean hasNegative = numbers.stream().anyMatch(n -> n < 0);

查找类:findFirst / findAny。找到符合条件的第一个或任意一个元素,返回 Optional。这里有个并发细节:并行流中 findFirst 能保证“第一个”,但为了保证顺序会牺牲性能;findAny 不保证顺序,但在并行流中效率更高。不要求“最早出现的那个”时,优先用 findAny。

规约类:reduce。把流中所有元素组合成一个值,核心是反复执行二元操作。比如求总和,reduce(0, Integer::sum)相当于从 0 开始,依次把每个元素加进去。三个参数的 reduce 还能在并行场景下指定合并方式,不过日常工作里两个参数的版本就够用了。

收集类:collect。这是全项目里出场率最高的终止操作,数据从流里“收”到集合或者更复杂结果的核心手段。我用一个表格列出最常用的收集器:

收集器作用示例
Collectors.toList()收集为 Liststream.collect(Collectors.toList())
Collectors.toSet()收集为 Set(自动去重)stream.collect(Collectors.toSet())
Collectors.toMap()收集为 Mapstream.collect(Collectors.toMap(User::getId, u -> u))
Collectors.joining()拼接字符串stream.collect(Collectors.joining(", "))
Collectors.groupingBy()分组stream.collect(Collectors.groupingBy(User::getCity))
Collectors.partitioningBy()按 true/false 分两组stream.collect(Collectors.partitioningBy(u -> u.getAge() > 18))
Collectors.summarizingInt()一次性拿统计信息stream.collect(Collectors.summarizingInt(User::getAge))

其中toMap最需要留神:如果映射的 key 有重复,会直接抛IllegalStateException。解决办法是传第三个参数指定合并策略,比如(v1, v2) -> v2表示冲突时取后者。

3. 实际项目里的高频操作场景,直接上能用的代码

3.1 场景一:过滤 + 映射 + 收集,最常见的三板斧

这是最经典的组合,就像炒菜先放油、再下姜蒜一样——从用户列表里找出活跃用户,只取他们的姓名,放到新的列表里。

List<User> users = userService.listAll(); List<String> activeUserNames = users.stream() .filter(User::isActive) // 过滤出活跃用户 .map(User::getName) // 只取名字 .collect(Collectors.toList()); // 收集成 List

可能有人对User::isActive这种写法不习惯,其实就是(User u) -> u.isActive()的方法引用简写。Java 8 之后能用方法引用尽量用方法引用,代码会干净很多。

这个场景里值得注意的细节是:filter 的条件复杂时,可以抽方法。不要在 lambda 里写一大坨逻辑,否则可读性会崩。比如filter(u -> u.getStatus() == 1 && u.getAge() > 18 && u.getCity() != null)这种,建议抽成一个isEligible(User u)方法,然后filter(User::isEligible)。

3.2 场景二:groupingBy 分组聚合,写完你就离不开它

分组是 Stream 最让人“哇”的功能。拿一个订单列表,按用户分组、按城市分组,一行搞定。

// 按用户名分组 Map<String, List<Order>> ordersByUser = orderList.stream() .collect(Collectors.groupingBy(Order::getUserName));

groupingBy 还有一个重载版本,可以在分组后继续做“下游收集器”——比如分组后统计每个组的人数、求和、取最大值。这个能力才是它的杀招:

// 按城市分组,统计每个城市的用户总数 Map<String, Long> userCountByCity = users.stream() .collect(Collectors.groupingBy(User::getCity, Collectors.counting())); // 按用户分组,求每个用户的订单总额 Map<String, Double> totalAmountByUser = orderList.stream() .collect(Collectors.groupingBy(Order::getUserName, Collectors.summingDouble(Order::getAmount))); // 按产品分类分组,取每个分类下最贵的商品 Map<String, Optional<Product>> maxPriceByCategory = products.stream() .collect(Collectors.groupingBy(Product::getCategory, Collectors.maxBy(Comparator.comparingDouble(Product::getPrice))));

分组 + 下游收集器这套组合,在不少报表类需求里可以直接替代写七八行的循环统计代码。有一点要注意:分组结果是Map,不保证顺序。如果想分组结果有顺序,用TreeMap作为 map 工厂传入(groupingBy 第三个参数可以指定 map 类型,或者直接new TreeMap<>(groupingBy(...))),或者用LinkedHashMap保持相遇顺序。

3.3 场景三:多字段排序 + 去重 + 截断

排序这块,单字段用sorted(Comparator.comparing(X::getField)),多字段就比较讲究了。我项目里的经验是:要么用thenComparing链式写法,要么用Comparator.comparing的键提取器组合。

List<Product> sortedProducts = products.stream() .sorted(Comparator.comparing(Product::getCategory) .thenComparingDouble(Product::getPrice).reversed()) // 注意,整体反转了 .collect(Collectors.toList());

顺序上有个经典坑:Comparator.comparing(...).reversed()与Comparator.comparing(..., Comparator.reverseOrder())的效果可能不一样。前者的 reversed 作用于整个比较器链,后者只对当前键生效区。先分类,再按价格倒序这样的需求,正确写法是:

Comparator<Product> byCategory = Comparator.comparing(Product::getCategory); Comparator<Product> byPriceDesc = Comparator.comparingDouble(Product::getPrice).reversed(); List<Product> result = products.stream() .sorted(byCategory.thenComparing(byPriceDesc)) .collect(Collectors.toList());

简单说:如果想“整体规律里局部倒序”,把比较器拆开定义再组合,别在链式调用里随手甩一个 reversed,否则很容易出现“我明明按价格倒序了,怎么结果全反了”的困惑。

去重加截断也常和排序配合,比如取销量前 10 的商品且不重复:

List<String> top10SoldName = products.stream() .map(Product::getName) .distinct() .limit(10) .collect(Collectors.toList());

3.4 场景四:map 转 list、list 转 map,双向操作

实际项目里,map 和 list 互转非常高频。比如从 Map 里筛选过滤再转回 Map,或者把 List 转成以 id 为 key 的 Map 方便后续 lookup。

// List 转 Map:key 是 id,value 是对象本身 Map<Long, User> userMap = users.stream() .collect(Collectors.toMap(User::getId, Function.identity())); // Map<K, V> 从值反查或过滤 Map<Long, User> adultUserMap = userMap.entrySet().stream() .filter(e -> e.getValue().getAge() >= 18) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

Function.identity()等价于u -> u,表示元素本身作为映射值。这里如果 id 有重复,务必用三参数 toMap 指定冲突策略,我看到有不少新人在这一步直接踩IllegalStateException。项目里常见稳妥写法:

Map<Long, User> userMap = users.stream() .collect(Collectors.toMap(User::getId, u -> u, (existing, replacement) -> existing));

3.5 场景五:数值统计,一行拿总值/均值/最大/最小

如果用传统的写法计算一个列表的总额、平均数、最大值、最小值,至少得写四行循环。用IntStream或收集器可以一步到位。

List<Integer> scores = Arrays.asList(60, 80, 95, 70); // 平均值 double avg = scores.stream().mapToInt(Integer::intValue).average().orElse(0); // 一次性拿全部统计量 IntSummaryStatistics stats = scores.stream().mapToInt(Integer::intValue).summaryStatistics(); long count = stats.getCount(); double average = stats.getAverage(); int max = stats.getMax(); int min = stats.getMin(); long sum = stats.getSum();

IntSummaryStatistics这个类相当实用,列表只要遍历一遍就能同时拿到 5 个统计量。对性能敏感的场景来说,比分别调用 5 次终止操作强得多,因为你每次终止操作都会把整个流重跑一遍。

4. 避坑指南与性能优化,全都是实战换来的经验

4.1 坑一:流只能消费一次,别再用了又用

前面讲过,Stream被终止操作消费后就不能再使用了,否则抛IllegalStateException。这个坑在代码里要以防为主:不要在方法间传 Stream 当参数传来传去。实在要传递,就传集合类型,等真正需要时再创建流。

我记得有一次写一个工具方法,参数是Stream<User>,内部调用用了两次:一次 filter,一次 collect,第二处直接崩。后来改成传List<User>,需要流的两次各自list.stream(),问题就消失了。这个教训可以记一下:能用集合就用集合,Stream 只做局部流水线,不要逃逸出方法作用域。

4.2 坑二:并行流不是万能加速器

parallelStream()并行流确实很香,底层用的是 Fork/Join 池,能多线程并行处理数据。但它有几个硬伤决定了它不能被无脑使用:

  • 线程安全问题:如果你在并行流里用共享可变状态(比如往一个 ArrayList 里 add),结果大概率是错的。
  • 性能开销:数据量不大时,切分任务、合并结果的开销可能超过并行带来的收益。我实测过,几万条以下的数据,parallelStream 往往比串行还慢。
  • 公共 Fork/Join 池:并行流默认使用全局共享池,如果你的应用里多个任务同时用并行流,会互相争抢线程资源,导致整体性能下降。

我的经验法则很简单:只有数据量大(至少十万级以上)、元素处理耗时长(涉及 IO 或复杂计算)、且无共享可变状态时,才考虑用并行流。否则老实写串行,代码简单还不出幺蛾子。

4.3 坑三:惰性求值带来的“假性能”和“真意外”

惰性求值虽然能避免不必要的中间计算,但也带来一个典型的陷阱:如果你在流水线中间用了限制数量(limit),那么 limit 之前的所有工序只会执行到“凑够数量”为止,后面的数据根本不会被处理。

Stream.generate(() -> random.nextInt()) .filter(n -> n > 0) .limit(10) .forEach(System.out::println);

Stream.generate是个无限流,如果没有limit限制,这个代码永远不会结束。正因为惰性求值,limit 让整个流水线“见好就收”,凑够 10 个正数就停了。理解这一点能避免很多性能问题——比如在一个百万级数据流里,你先 limit(5) 再 sorted,sorted 可能只对 5 个元素排序,省钱;但你如果先 sorted 再 limit(5),就是百万个元素全排序之后才截断,白白烧 CPU。

顺序很重要。先过滤/截断,再做重操作(排序、map 复杂转换),能省则省。

4.4 调试技巧:IDE 的流调试和 peek 的妙用

Stream 的链式调用对调试不太友好,断点进去经常是一堆内部状态。我摸索出来的两个实用办法:

办法一:用 peek 打印中间结果。前面提到过,就不重复代码了。注意 peek 是中间操作,如果没有终止操作,peek 的打印是不会发生的。

办法二:用 IntelliJ IDEA 的 Stream Trace 功能。IDEA 的调试工具栏里有一个 “Trace Current Stream Chain” 按钮,点开可以直接看到每个步骤输入输出了什么数据。对于复杂的多步链式操作,这个功能比打印日志好用十倍。Eclipse 里也有类似的流调试插件,平时可以装一个备用。

4.5 性能调优:几个平时容易忽略的小地方

关于 Stream 的性能,我根据实际测试和踩坑经验总结几点:

能用基本类型流就别用对象流。Stream<Integer>在处理数字计算时会有大量的装箱拆箱损耗。数据量大时换成IntStream、LongStream、DoubleStream,性能提升立竿见影。写法也很简单,list.stream().mapToInt(Integer::intValue)就能转过去。

注意收集器的初始化开销。Collectors.toList()默认返回ArrayList,toSet()默认返回HashSet。如果明确不需要去重,直接 toList 就行,不要用 toSet 去“顺便去重”,HashSet 的哈希计算在大数据量下有明显开销。

流内不要写太复杂的操作。每个中间操作都会创建一条新的流水线段,虽然大部分情况下这个开销可以忽略,但如果你在一个流里写了十几个 map、filter,倒不如拆成两段流处理,可读性也更好。

注意findFirst和limit短路带来的优化空间。短路操作能提前终止计算,是对大集合进行“只要前几个结果”场景的核心优化手段。比如判断集合里是否存在满足条件的元素,anyMatch在找到第一个匹配项时就立刻返回,不需要遍历全集,这点比经典的“先把所有符合条件的过滤出来再判断非空”的写法高效得多,推荐顺手用起来。

5. 最后再分享一个我实际项目里的体会

做了这么多年 Java 开发,我对 Stream 的态度经历了从抗拒到依赖的转变。早期写代码总觉得 for 循环最稳,Stream 花里胡哨的。后来接手一个报表统计模块,每次一循环套一循环的代码改起来人都麻了,用 Stream 重写之后才彻底被它折服——代码量少一半,逻辑还清楚了。

但我现在也不会处处用 Stream。比如多层的嵌套循环里需要内部循环访问外部循环变量、或者循环体内逻辑复杂且带有较多流程控制(break、continue、异常捕获),这种场景传统写法反而更清晰。Stream 适合的是数据处理转换链路,不是万能银弹。

如果让我给一个最简单实用的建议:先把 filter、map、collect、groupingBy 这四件套练熟,覆盖大约七成日常需求,再逐步去掌握 flatMap、reduce、并行流这些进阶能力。不要一上来就想用 parallelStream 炫技,扎实的基础才是关键。Stream 的使用步骤本质就三句话——创建流、串好中间操作、执行终止操作。把这三步每一步都搞清楚,写的时候就不会心慌了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 4:04:19

富文本编辑器选型与配置实战:从CMS到嵌入式设备

做Web开发这些年&#xff0c;富文本编辑器几乎成了每个项目都绕不开的组件。从最初在后台管理系统里塞一个TinyMCE应付内容发布&#xff0c;到后来在移动端、ERP系统、甚至嵌入式设备的Web管理页面里处理富文本&#xff0c;我踩过的坑比看过的文档还多。很多人觉得富文本编辑器…

作者头像 李华
网站建设 2026/10/5 4:04:18

Asterisk安装配置实战:从SIP分机到拨号方案排障指南

简介&#xff1a;这是一份面向通信系统初学者、网络运维人员及通信技术爱好者的Asterisk安装配置指南&#xff0c;以PDF文档形式呈现。内容围绕开源PBX电话系统Asterisk的完整部署流程展开&#xff0c;从基础依赖套件安装讲起&#xff0c;逐步演示zaptel、libpri、Asterisk三个…

作者头像 李华
网站建设 2026/10/5 4:03:52

pi coding agent CLI 架构解析:agent loop、TUI 与 subagent 实战

1. 从“pi”这个标题说起&#xff1a;一个极简命名背后的技术野心第一次看到“pi”这个项目标题&#xff0c;很多人会愣一下——是那个圆周率&#xff1f;是树莓派&#xff1f;还是某个数学库&#xff1f;但如果你最近在开发者社区里泡过&#xff0c;尤其是关注 LLM 应用和 cod…

作者头像 李华
网站建设 2026/10/5 4:03:43

Linux进程间通信实战:管道、共享内存与信号量的选型与陷阱

先说一个我早年间遇到的真实场景&#xff1a;一台采集服务器上跑了四个分析进程&#xff0c;每隔几秒就要从主进程手里取一批日志数据。最开始我图省事&#xff0c;直接用文件落地加轮询&#xff0c;结果不仅因为文件锁搞得调度顺序乱&#xff0c;还白白多了很多磁盘IO。后来老…

作者头像 李华
网站建设 2026/10/5 4:03:14

事业单位计算机考试常考知识点与大学计算机基础PDF复习攻略

简介&#xff1a;面向事业单位计算机考试备考人群与高校计算机基础课程学习者&#xff0c;这份PDF整合了两类实用资料&#xff1a;一是事业单位计算机考试常考知识点总结&#xff0c;涵盖CPU、存储器、总线、I/O接口等高频考点&#xff0c;以试题解析形式帮助考生吃透选择题&am…

作者头像 李华
网站建设 2026/10/5 4:02:49

低惯量电力系统频率稳定分析与控制策略整定

简介&#xff1a;《低惯量电力系统频率稳定分析与控制研究综述及展望》是一篇发表于《电力自动化设备》的综述性学术文献&#xff0c;面向电力系统规划、运行与控制方向的研究人员、工程师及高校师生。随着新能源大规模并网与直流输电技术发展&#xff0c;系统惯量下降引发的频…

作者头像 李华