news 2026/10/13 1:21:40

Java 8 Stream groupingBy分组乱序原因与保序方案详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java 8 Stream groupingBy分组乱序原因与保序方案详解

1. 从一次线上告警说起:分组结果怎么突然乱了

先讲个我踩过的坑。某天业务反馈,某个交易报表里“按渠道分组统计”的明细顺序,在页面上渲染出来跟数据库里查出来的顺序完全对不上。第一眼我心想,HashMap 本来就是无序的,这不是常识吗?但问题在于,数据源是从 MySQL 按某个索引顺序查出来的,中间没有做过任何排序操作,只是用 Java 8 的Collectors.groupingBy分了一下组,再按分组结果逐组输出,顺序就乱了。用户问了一句“我也没要求你排序,但至少别乱吧”,一句话把我噎住了。

那段时间我排查了很久,翻了不少源码,也写了几个最小复现的测试类,才把groupingBy的“乱序”问题彻底吃透。其实这个问题说难不难,说简单也不简单。它背后牵扯到Collectors的容器工厂选择、HashMap的存储机制、并行流的分组实现差异,甚至还有 Java 版本升级后带来的行为变化。很多人写代码时根本没注意到groupingBy默认返回的是HashMap,更没注意到第三个参数可以换成LinkedHashMap来保持顺序。这个不起眼的签名,恰恰是解决乱序问题的钥匙。

这篇文章我会从现象入手,把groupingBy的底层层层拆开,列出几种真正能落地的保序方案,再补上组内排序、并行流分组、多级分组这几个高频场景的注意事项。最后还会分享我排查这类问题时的固定套路和几个容易误判的细节。适合正在被“分组结果顺序对不上”折磨的开发者,也适合刚接触 Stream 想避坑的新手。

2. 乱序的根源:groupingBy 默认集合的“贪快”机制

2.1 先理解 HashMap 为什么“假装”有序

很多人对HashMap的无序性其实只有一个模糊的印象,知道它不保证顺序,但不知道为什么。这里我用一个生活化的比喻讲清楚。

HashMap内部是一个数组加链表(红黑树)的结构。存放元素时,它先根据 key 的hashCode()计算出一个哈希值,再把这个值映射到数组的某个下标。你可以把这个数组想象成一栋酒店的楼层,hashCode就是门牌号计算规则。你入住的时候不是按照“先来后到”从 1 楼往上排,而是根据你的身份证号算出你应该去哪一层哪一间。这样一来,遍历酒店房间时,看到的顺序自然跟入住顺序毫无关系。

更麻烦的是,数组还要扩容。默认容量是 16,当元素个数超过阈值(容量 × 0.75)时,数组会翻倍扩容,所有元素重新计算下标位置。这就好比酒店扩建了楼层,原本住 3 楼的人可能被重新分配到 7 楼,遍历顺序又一次被打乱。所以HashMap的顺序不仅不是插入序,连“偶然稳定”都做不到,同样的数据换个初始容量,遍历结果就可能不同。

2.2 groupingBy 的默认工厂选的是谁

看Collectors.groupingBy的源码(这里说的是 OpenJDK 的实现),核心方法有三个参数:

public static <T, K, A, D> Collector<T, ?, Map<K, D>> groupingBy( Function<? super T, ? extends K> classifier, Supplier<Map<K, ? extends Map>> mapFactory, Collector<T, A, D> downstream)

它内部实际上委托给了这个三参重载。重点是 1.8 里两参版本groupingBy(classifier)和groupingBy(classifier, downstream)的默认mapFactory是什么。点进去看,代码大致是:

return groupingBy(classifier, HashMap::new, downstream);

看到没,它默认指定了HashMap::new。没有特殊说明的情况下,你得到的分组容器就是一个HashMap,意味着所有分组键的存储位置完全由哈希值决定,跟你原始数据的出现顺序没有半点关系。

这个设计其实很好理解。groupingBy的设计目标里,聚合统计功能是第一位,顺序保证是次要的。HashMap的读写效率在大多数场景下最高,用它做默认容器,是为了让绝大多数用户拿到最好的性能。但这也就埋下了乱序的雷:只要你的业务对分组后的遍历顺序有要求,默认行为一定会出问题。

2.3 为什么有时候“碰巧”有序

我在排查时还发现一个迷惑现象:有些时候groupingBy的结果看起来是有序的,特别是在数据量小、分组键恰好是连续小整数时。比如 key 是 1、2、3、4,HashMap 的哈希映射刚好命中顺序递增的桶位置,遍历出来就“碰巧”有序。

但这完全是巧合。只要数据量上去,或者 key 的哈希值分布不均匀,顺序立刻就会变。更隐蔽的是那些 key 是字符串的场景:字符串的hashCode经过均匀性优化,分散程度很大,表面上每次运行结果可能都一样乱,但跟原始顺序之间没有任何规律。这种“薛定谔的有序”最容易误导人,让新手以为groupingBy本来就保序,直到某天换了一批数据才发现翻车。

所以,判断一个集合是否保序,不要看“跑出来结果什么样”,要看“文档和源码怎么承诺的”。HashMap的文档白纸黑字写着不保证顺序,LinkedHashMap的文档写着按插入序或访问序遍历。一切以契约为准。

3. 保持分组顺序的三种主流方案

3.1 方案一:传入 LinkedHashMap 作为结果容器

最直接、最常用的保序手段,就是给groupingBy传入第三个参数。两参版本不够用,三参版本才是完全体:

Map<String, List<Order>> grouped = orders.stream() .collect(Collectors.groupingBy( Order::getChannel, LinkedHashMap::new, Collectors.toList() ));

这里的关键在于LinkedHashMap::new。LinkedHashMap在HashMap的基础上额外维护了一条双向链表,专门记录元素的插入顺序。遍历时它按照这条链表走,所以你插入分组的顺序是什么,读出来的顺序就是什么。

而你插入分组的顺序,取决于Stream中元素的出现顺序。只要数据源本身有序(比如ArrayList或 SQL 查询按某个字段排列后的结果),分组后的遍历顺序就会严格保持这个顺序。注意,这里保持的是“首次遇见某个 key”的顺序,不是整个元素流的顺序。这句话我后面还会细讲。

我在实际项目中用的就是这种写法,改动量最小,只加一个参数,行为立刻改变。它的代价是比HashMap多了一点内存和维护链表的开销,但对绝大多数业务场景来说完全可以忽略。

3.2 方案二:用 TreeMap 按 key 的自然顺序排序

如果你不仅想保序,还想让分组键本身排个序,比如按渠道编号升序、按时间先后排,那么TreeMap是更好的选择。

Map<Integer, List<User>> grouped = users.stream() .collect(Collectors.groupingBy( User::getLevel, TreeMap::new, Collectors.toList() ));

TreeMap基于红黑树实现,key 会按照Comparable自然顺序或者你传入的Comparator排序。比如 key 是Integer,遍历时就会从小到大的顺序输出。这里要注意,如果 key 是自定义对象,你需要实现Comparable或者在构造TreeMap时传入比较器,否则运行时直接抛ClassCastException。

这个方案的适用场景很明确:分组后你希望输出结果天然有序,而不是依赖原始输入顺序。比如报表按月份分组,你永远希望 1 月、2 月、3 月……顺序出现,而不是依赖数据库返回顺序。这种情况下用TreeMap比LinkedHashMap更符合直觉,也更稳。

不过我要给一个提示:TreeMap的比较器一定要跟分组键的语义匹配。曾经遇到过有人传了一个忽略大小写的比较器给TreeMap,结果 “a” 和 “A” 被认为是同一个 key,直接覆盖了数据。这种隐蔽问题比乱序还难排查。

3.3 方案三:从源头保证数据有序 + 顺序收集

第三种方案看起来最“笨”,但有时候反而最灵活:先排序,再分组,分组时用LinkedHashMap。

List<Order> sorted = orders.stream() .sorted(Comparator.comparing(Order::getCreateTime)) .collect(Collectors.toList()); Map<String, List<Order>> grouped = sorted.stream() .collect(Collectors.groupingBy(Order::getChannel, LinkedHashMap::new, Collectors.toList()));

注意这跟方案一并不重复。方案一默认输入流的顺序就是“业务想要的顺序”,所以它适合那些数据源本身已经排好序的场景。但如果数据源是无序的,而你又希望分组键按照某个字段第一次出现的位置排列,那么你需要主动给 Stream 排序,再进入分组流程。

这个方案有一个非常关键的注意点:Stream.sorted()是稳定排序,也就是说值相等的元素会保持它们原有的相对顺序。这个特性在“先按某字段排序,再分组”时特别重要。比如先按用户 ID 排序,再按城市分组,每个城市分组内的用户 ID 顺序仍然是对的。

我的建议是:能用两行代码解决的事不要复杂化。如果你的需求只是“别乱”,方案一足够;如果你的需求是“按某种规则有序”,优先考虑方案二;如果两种需求叠加,那就排序 + 方案一组合。

4. 分组之后的组内排序:最容易忽略的细节

4.1 默认情况下组内顺序是什么

很多人只关注分组键的顺序,却忘了组内元素也有顺序需求。groupingBy默认的downstream是toList(),它会把组内元素按照它们在原始流中出现的顺序收集到一个ArrayList里。因为ArrayList本身保序,所以组内顺序等于原始输入顺序。

看到这里你可能觉得没问题,但实际业务往往要求组内也排序。比如“按部门分组,组内按工资降序”“按日期分组,组内按时间戳升序”,这些需求如果用默认的toList(),你拿到的组内顺序就是进组顺序,完全取决于上游排序。

要解决组内排序,一种常见做法是先整体排序再分组。比如:

Map<String, List<Employee>> grouped = employees.stream() .sorted(Comparator.comparing(Employee::getSalary).reversed()) .collect(Collectors.groupingBy(Employee::getDept, LinkedHashMap::new, Collectors.toList()));

这样每个分组内部的元素都是按工资降序排列的,因为sorted是稳定排序,分组时元素进入列表的先后顺序保留了这个全局顺序。这种方式简单直接,但全局排序的开销比局部排序大。如果数据量百万级以上,可以先分组、后对每个组单独排序,性能更优。

4.2 组内排序的正确姿势

如果你不想全局排序,可以靠collectingAndThen在收集完成后统一排序:

Map<String, List<Employee>> grouped = employees.stream() .collect(Collectors.groupingBy( Employee::getDept, LinkedHashMap::new, Collectors.collectingAndThen( Collectors.toList(), list -> list.stream() .sorted(Comparator.comparing(Employee::getSalary).reversed()) .collect(Collectors.toList()) ) ));

这个写法稍微绕一点,但逻辑很清晰:先按部门分组,组内收集成List,再利用collectingAndThen的 finisher 对每个分组单独排序。每个分组的排序互不干扰,很适合“组内规则跟分组键无关”的场景。

还有一种思路是构造自定义下游收集器,实现Supplier、Accumulator、Combiner、Finisher四个方法。这种写法灵活,但代码量明显增加,如果不是被逼到某个特殊场景,我不建议常规业务去用。

这里我要补充一个很容易踩的坑:Comparator.comparing(...).reversed()的陷阱。很多人以为Comparator.comparing(Employee::getSalary).reversed()是按工资降序,结果写出来之后发现排序结果完全反了。原因是reversed()作用于整个Comparator对象,如果把它用在sorted里,排序逻辑会变成“工资升序后逆序”,效果通常是对的;但一旦混合thenComparing,reversed()会对整个比较链取反,跟预期的“单个字段降序”完全不同。建议写成:

Comparator.comparing(Employee::getSalary, Comparator.reverseOrder())

或者:

Comparator.comparing(Employee::getSalary).reversed()

两者在单一字段下等价,但复合比较时最好显式传入Comparator.reverseOrder(),避免歧义。

4.3 组内排序时的 null 值问题

组内排序还有一个隐藏雷区:排序字段为null。Comparator.comparing默认遇到null会抛NullPointerException,因为它在尝试调用keyExtractor拿到比较键时就直接撞上空指针。

比如员工对象里salary字段可能是空的,这时上面的排序代码就会在运行时崩掉。解决方案是用Comparator.nullsLast或者nullsFirst包裹:

Comparator.comparing( Employee::getSalary, Comparator.nullsLast(Comparator.naturalOrder()) )

这样 null 值会被排到列表末尾,不会影响正常数据的排序。具体是nullsLast还是nullsFirst,看业务语义。比如工资字段为 null 表示“未定”,排最后比较合理;如果字段是“优先级”,null 可能是“默认最低”,那nullsFirst反而不合适。这种细节,踩一次坑就记住了。

5. 并行流与 groupingBy:顺序问题的深水区

5.1 并行流下会发生什么

Java 8 之后很多人喜欢无脑用parallelStream()提升性能,却忽略了并行流对groupingBy的额外影响。先说结论:并行流下,groupingBy的乱序问题不只是“无序”,而是“并发合并导致的顺序不可预测”,问题更严重。

在并行流中,原始 Stream 会被切分成多个子任务,每个子任务独立进行分组操作,最后再合并结果。对于HashMap::new这种容器,多个子任务分别建立局部的HashMap,然后通过Map.merge合并。由于分组的合并顺序不是按原始元素顺序来的,而是按任务完成顺序来的,所以最终HashMap里的迭代顺序完全不可控。

更值得警惕的是,groupingBy的并行特性和ConcurrentHashMap的关系。如果你用两参groupingBy并且打开并行流,实际上 JVM 不会自动给你ConcurrentHashMap,它仍然按HashMap::new走,然后用底层公共的Map.merge来合并。在 OpenJDK 的实现里,groupingBy的combiner会返回一个GroupByMap之类的中间结构,本质上跟并发容器没有关系,只要你最后结果是Map,遍历顺序依然无保证。

5.2 数据量多大才值得用并行流分组

并行流不是免费的午餐。它涉及线程池调度、任务切分、结果合并,开销不小。我见过的很多“并行流反而更慢”的例子,基本都是因为数据量太小,任务切分和合并的开销超过了多线程带来的收益。我的经验是:集合元素在十万级以下时,串行流的性能往往更好;到了百万级、单条记录的处理又比较重,才值得认真测试并行方案。

如果你确实需要并行分组,同时又要保持顺序,那只能接受“局部有序”的方案:每个子任务内保序,整体分组键顺序无法完全等同于原始输入顺序。如果业务真的要求全序,那就不要用并行流,老老实实串行 +LinkedHashMap。

这里有一个折中思路:先把 Stream 按某个 key 做batch切分,每个 batch 内部串行分组并收集成有序 Map,最后合并时按 batch 顺序 putAll。这种方法在复杂业务里偶尔用到,但代码量不小,非得性能瓶颈压到头上,我一般不推荐。否则排查并发顺序问题的时间成本,比你省下的那几秒钟高出几个数量级。

5.3 并行流与自定义并行度

再补充一个并行流的细节。parallelStream()默认使用ForkJoinPool.commonPool(),它的并行度等于 CPU 核数减一。如果你的服务是部署在共享容器里,commonPool 的线程数可能会被其他任务挤占。调整并行度有两种常用手段:一是通过系统属性-Djava.util.concurrent.ForkJoinPool.common.parallelism=N,二是自定义线程池跑在CompletableFuture里。这两个手段都有副作用,特别是改全局 commonPool 会影响整个 JVM 里的所有并行流,风险很大。我的建议是:轻易别动,动之前先确认这是当前服务的唯一瓶颈。

6. 实操排查指南:遇到乱序时我这样定位

6.1 先分清是“分组键乱序”还是“组内乱序”

遇到分组结果跟预期不一致时,第一步不是急着改代码,而是明确到底哪个环节乱了。

我一般先打印分组后的 Map,肉眼观察 key 的遍历顺序是否跟原始数据中 key 首次出现的顺序一致。然后再取某个分组内的 List,看看元素顺序是否符合预期。这两件事是独立的问题,原因和解决方案完全不同。

如果 key 的遍历顺序乱,多半是HashMap默认容器导致,方案就是换LinkedHashMap或TreeMap。如果组内元素顺序乱,可能是上游数据源顺序不稳,也可能是你在分组后又做了removeIf、sort之类的操作。把问题定性清楚,再动手改,效率至少翻一倍。

6.2 三段式检查法

我总结了一套“三段式检查法”,按下面三步来,可以快速定位大多数顺序问题。

第一段:检查数据源。确认你传给stream()的集合是不是有序的。HashSet、HashMap.keySet()本身就是无序集合,就算后面用LinkedHashMap分组也救不回来。如果源就是无序的,第一件事是把它改成List或LinkedHashSet再进流。

第二段:检查中间操作。看看是否存在filter、map、distinct、sorted等中间操作。distinct在并行流下会破坏顺序;sorted会改变原有顺序(尽管稳定);map本身不改变顺序,但如果映射后的对象里携带了来源字段,可能会掩盖原始顺序问题。

第三段:检查终止操作。确认你用的确实是groupingBy,而不是groupingByConcurrent。groupingByConcurrent是专为并行流设计的,它默认使用ConcurrentHashMap,不仅不保序,连put顺序都是并发的,串行流里没有理由用它。

这个方法我用了很多年,九成以上的顺序类问题都能在十分钟内定位。剩下的一成,往往是因为数据源里有重复 key,导致分组后的组内列表混合了多段来源,顺序看起来怪怪的。

6.3 一个魔鬼细节:groupingBy 不保证遇见顺序

这里必须说透一个很多人都会误解的点。groupingBy即使配上LinkedHashMap,它保证的也是“key 首次出现的顺序”,而不是“整个元素流的顺序”。

什么意思?举个例子,原始数据是[A1, B1, A2, C1, B2],按键分组后用LinkedHashMap收集,你会得到{A=[A1, A2], B=[B1, B2], C=[C1]}。遍历 Map 时 key 的顺序是 A、B、C,它们按第一次出现的先后排列。但如果你期望的是“最后一次出现的顺序”或者“某个 key 的某种统计顺序”,那LinkedHashMap就不够用了,你得先对数据源做排序,或者分组后单独调整 Map 的遍历顺序。

这类需求在实际业务里很常见。比如“按用户分组,取每个用户最后一次登录时间”,如果你只是groupingBy(userId, LinkedHashMap::new, ...),你得到的 key 顺序是用户第一次出现的顺序,但你可能希望按“最后一次登录时间”排序。这时候正确的做法是分组后对Map.Entry排序,而不是纠结分组的 key 顺序。

7. 一个完整的实战参考:按渠道分组并保持展示顺序

7.1 需求场景

某报表系统需要展示“各渠道销售情况”,渠道列表来自一个固定的配置表,顺序为:自营App、第三方平台、线下门店、合作渠道。页面要求严格按这个顺序展示,不随数据库返回顺序变动。数据模型大致如下:

public class SalesRecord { private String channel; // 渠道名称 private BigDecimal amount; // 销售额 // 省略 getter/setter }

数据库查询出的List<SalesRecord>是按交易时间倒序的,但我们希望的展示顺序以渠道配置顺序为准,组内则按交易时间正序排列。

7.2 实现代码与执行结果

第一版代码直接用了两参groupingBy,结果页面上渠道顺序随机飘。我改成三参LinkedHashMap,并手动设置了渠道优先级。

渠道顺序的优先级可以通过一个排序索引表控制:

Map<String, Integer> channelPriority = new HashMap<>(); channelPriority.put("自营App", 1); channelPriority.put("第三方平台", 2); channelPriority.put("线下门店", 3); channelPriority.put("合作渠道", 4);

然后先按渠道优先级排序,再按交易时间排序,最后分组:

List<SalesRecord> sorted = records.stream() .sorted( Comparator.comparing((SalesRecord r) -> channelPriority.getOrDefault(r.getChannel(), Integer.MAX_VALUE)) .thenComparing(SalesRecord::getTradeTime) ) .collect(Collectors.toList()); Map<String, List<SalesRecord>> grouped = sorted.stream() .collect(Collectors.groupingBy( SalesRecord::getChannel, LinkedHashMap::new, Collectors.toList() ));

执行结果:

自营App -> [记录A(10:01), 记录B(10:05)] 第三方平台 -> [记录C(09:30)] 线下门店 -> [记录D(09:15), 记录E(09:20)] 合作渠道 -> []

顺序完全符合预期。这里的关键是:先sorted再分组,利用稳定排序保证每个渠道内部的时间排序不丢;再用LinkedHashMap保证渠道之间的顺序由优先级表中首次遇到的顺序决定。

7.3 多级分组怎么保持顺序

如果需求变成“按大区、城市两级分组”,并且两级都要保持顺序,我的做法是嵌套两层groupingBy,每层都用LinkedHashMap::new。

Map<String, Map<String, List<SalesRecord>>> grouped = records.stream() .collect(Collectors.groupingBy( SalesRecord::getRegion, LinkedHashMap::new, Collectors.groupingBy( SalesRecord::getCity, LinkedHashMap::new, Collectors.toList() ) ));

只有每一层的groupingBy都显式传入LinkedHashMap,两级顺序才能同时保住。很多人只在最外层加了LinkedHashMap,内层仍然是默认HashMap,结果城市顺序照样乱。这种嵌套场景,建议直接写在代码注释里,提醒后来维护的人别删参数。

8. 复盘与个人体会

groupingBy乱序这个问题,本质上是“默认容器为性能牺牲了顺序承诺”带来的预期错位。它不难解决,但如果你不理解底层是HashMap,你可能会写出各种奇怪的“补救代码”:比如遍历后重新排序、用TreeMap排序、或者把结果转成JSONObject再强排。这些方案绕了一大圈,效果还不一定理想,远不如一开始就选对容器。

我个人的习惯是:只要分组结果要用于展示、导出、或者与其他有序数据结构对接,一律显式传LinkedHashMap::new。如果还要按键排序,就换成TreeMap。这两个选择成本极低,却能让行为契约变得清晰明确,减少后续维护者的困惑。至于并行流,我在分组场景里用得相当克制,不是性能瓶颈不会碰,毕竟并发场景下的顺序问题,比串行场景复杂得多。

最后再分享一个小技巧:写测试用例时,别只用一组固定数据验证。试着把数据源顺序打乱、插入重复 key、加入 null 值,用同一个分组逻辑跑一遍,你会发现很多平时注意不到的边界情况。把这些情况在测试阶段暴露出来,远比在上线后听业务方反馈要舒服得多。

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

链表实现一元多项式运算:稀疏结构与动态操作的完美匹配

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:20:38

机器人嵌入式岗位重新定价:STM32与RTOS能力如何影响薪资

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:20:28

AI芯片软硬协同优化实战:从回片到量产的系统级调优手记

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:20:15

多源矢量数据套合卫星影像:坐标系统与仿射变换校准实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:19:48

Python多进程圆周率计算:轻量级CPU压力测试与性能监控实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:19:45

EasyExcel实战:解决Apache POI内存溢出,高效读写Excel

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华