《Java 编程入门》10.3 遍历、排序与比较器

把 library 的图书列表按标题、年份、作者排序:讲清 for-each 背后的 Iterator、遍历中删除的正确写法、Comparable 与 Comparator 的分工、中文 Collator 排序,以及比较器溢出和不一致导致的 TreeSet 丢元素,并给出可复现的真实输出。

本节目标:能安全地遍历并删除集合元素,能用 Comparable 和 Comparator 表达单字段、多字段和中文排序,能识别比较器溢出、不一致这两个会让结果静默出错的坑。
适用版本:JDK 25 LTS(Temurin 25.0.5);List.sort、Comparator 默认方法在 JDK 21 上一致。

列表有了,顺序还没定

10.1 的仓储用 LinkedHashMap 保存图书,findAll() 按插入顺序返回。这对 list 命令够用,但用户很快会提新要求:

  • 按书名排序,方便找书。
  • 按出版年份倒序,看看新书。
  • 先按作者、再按书名排序,同一作者的书放在一起。
  • 中文书名要按拼音排,不是按 Unicode 码点排。

这些需求都落在两件事上:怎么遍历集合,以及怎么定义「谁在前」。遍历看起来简单,但在遍历中删除元素是 ConcurrentModificationException 的高发区;排序看起来只是调用 sort,但比较器写错会让结果错得很安静。

for-each 背后是 Iterator

增强 for 循环是编译器提供的语法糖。下面两段代码等价:

for (Book book : books) {
    System.out.println(book.title());
}
Iterator<Book> iterator = books.iterator();
while (iterator.hasNext()) {
    Book book = iterator.next();
    System.out.println(book.title());
}

for-each 隐藏了 Iterator,也隐藏了「遍历期间集合被修改」的检查。ArrayList 的迭代器在创建时记录 modCount,每次 next() 都会检查集合的修改次数有没有变;变了就抛 ConcurrentModificationException。

在 for-each 里直接删元素:

try {
    for (Book book : books) {
        if (book.publishedYear() < 1900) {
            books.remove(book);
        }
    }
} catch (ConcurrentModificationException e) {
    System.out.println("for-each remove: " + e.getClass().getSimpleName());
}

本机 JDK 25 输出:

for-each remove: ConcurrentModificationException

注意:不是每次都会抛。如果删除的是最后一个元素,循环可能刚好结束,检查没被触发;这种「有时抛、有时不抛」正是它危险的地方。正确写法有两种:

books.removeIf(book -> book.publishedYear() < 1900);

或者显式用迭代器:

Iterator<Book> iterator = books.iterator();
while (iterator.hasNext()) {
    if (iterator.next().publishedYear() < 1900) {
        iterator.remove();
    }
}

本机继续执行,输出:

after iterator.remove: 2

Iterator.remove() 会同步更新 modCount 和迭代器的预期值,所以不会抛异常。removeIf 内部也是迭代器删除,语义更清楚,优先用它。

还有两个边界:

  • for-each 里修改元素内容通常没问题,比如 book.setXxx(...);修改的是元素,不是集合结构。但 Book 是 record,不可变,所以 library 里不会遇到。
  • Map 的遍历删除用 entrySet().removeIf(...) 或 Iterator<Map.Entry<...>>,不要直接 map.remove(key)。

Comparable 与 Comparator

排序需要回答「谁在前」。Java 有两套接口:

  • Comparable<T>:对象自己知道怎么和自己比较,方法叫 compareTo,是「自然顺序」。
  • Comparator<T>:外部比较器,方法叫 compare,可以为同一个类型定义多种顺序。

Book 的天然标识是 ISBN。在 9.1 的 Book record 上补 Comparable<Book>,只新增 compareTo:

public record Book(String isbn, String title, String author, int publishedYear)
        implements Comparable<Book> {

    // 9.1 的紧凑构造器、UNKNOWN_YEAR 和 describe() 保持不变

    @Override
    public int compareTo(Book other) {
        return isbn.compareTo(other.isbn());
    }
}

这样 Collections.sort(books) 或 books.sort(null) 会按 ISBN 升序。compareTo 的返回值约定是:负数表示 this 在前,0 表示相等,正数表示 this 在后。String.compareTo 已经满足这个约定,直接返回即可。

但业务更常按标题、年份、作者排序,这些顺序不该写死在 Book 里,用 Comparator:

Comparator<Book> byTitle = Comparator.comparing(Book::title);
Comparator<Book> byYearDesc = Comparator.comparingInt(Book::publishedYear)
        .reversed()
        .thenComparing(Book::title);
Comparator<Book> byAuthorThenTitle = Comparator.comparing(Book::author)
        .thenComparing(Book::title);

Comparator.comparing 接收一个提取 key 的函数,自动用 key 的自然顺序比较;comparingInt 避免把 int 装箱成 Integer;reversed() 反转顺序;thenComparing 在前一个比较器返回 0 时继续比较。多个字段的排序就这样链起来。

把三种顺序跑一遍:

for (Book book : service.listBooks(LibraryService.SortKey.TITLE)) {
    System.out.println(book.title() + " | " + book.publishedYear());
}

本机 JDK 25 输出(TITLE 与 YEAR_DESC 两段):

Java 编程思想 | 2007
三体 | 2008
明朝那些事儿 | 2006
活着 | 1993
百年孤独 | 1967
红楼梦 | 1791
2008 | 三体
2007 | Java 编程思想
2006 | 明朝那些事儿
1993 | 活着
1967 | 百年孤独
1791 | 红楼梦

TITLE 的输出顺序是 String 的自然顺序:J 的码点最小,中文按 UTF-16 码元排。它不是拼音顺序,所以「明朝」排在「活着」前面,「百年孤独」排在「红楼梦」前面——看起来像随机,其实是码点顺序。

中文排序不是 Unicode 排序

要让中文按拼音排,用 java.text.Collator:

Collator collator = Collator.getInstance(Locale.CHINA);
Comparator<Book> byChineseTitle = Comparator.comparing(Book::title, collator::compare);
List<Book> sorted = new ArrayList<>(books);
sorted.sort(byChineseTitle);

本机 JDK 25 输出:

Java 编程思想
百年孤独
红楼梦
活着
明朝那些事儿
三体

这次是拼音顺序:百(bai)、红(hong)、活(huo)、明(ming)、三(san)。Java 作为拉丁字符串排在最前,符合中文环境的常见预期。

几个注意点:

  • Collator 不是线程安全的。 不要在多个线程间共享一个实例;每次排序创建一个,或者用 ThreadLocal。卷三讲并发时会再遇到这类「有状态工具类」。
  • Collator 的排序规则依赖 locale 和 JDK 的 CLDR 数据。 不同 JDK 版本、不同 locale 下结果可能有细微差异;对排序结果有严格要求的场景要写测试固定预期。
  • Collator 比较的是字符串,不是对象。 Comparator.comparing(Book::title, collator::compare) 里,collator::compare 被适配成 Comparator<String>;直接传 collator 会因为泛型不匹配编译失败。

如果书名可能为 null,用 Comparator.nullsLast(collator::compare) 把空值放到最后,避免 NullPointerException。library 的 Book 在紧凑构造器里禁止空标题,所以这里不需要,但读外部数据时要考虑。

比较器的契约

Comparator 有三个隐含契约:自反、传递、一致。违反它们,sort 可能抛 IllegalArgumentException: Comparison method violates its general contract!,TreeSet/TreeMap 则可能静默丢元素。

最常见的错误是用减法实现比较:

Comparator<Integer> bad = (a, b) -> a - b;
System.out.println("bad compare: " + bad.compare(Integer.MAX_VALUE, -1));
System.out.println("Integer.compare: " + Integer.compare(Integer.MAX_VALUE, -1));

本机 JDK 25 输出:

bad compare: -2147483648
Integer.compare: 1

Integer.MAX_VALUE - (-1) 溢出成 Integer.MIN_VALUE,返回值变成负数,排序会把「最大值」判成「小于 -1」。对 Book 的出版年做减法同样有风险,虽然年份不会到 int 边界,但养成用 Integer.compare / Comparator.comparingInt 的习惯更安全。

第二个坑是「比较器与 equals 不一致」。TreeSet 用比较器判断元素是否重复,不调用 equals:

Book first = new Book("111", "书 A", "同一作者", 2000);
Book second = new Book("222", "书 B", "同一作者", 2001);
Set<Book> byAuthor = new TreeSet<>(Comparator.comparing(Book::author));
byAuthor.add(first);
byAuthor.add(second);
System.out.println("TreeSet by author size: " + byAuthor.size());

Set<Book> natural = new TreeSet<>();
natural.add(first);
natural.add(second);
System.out.println("TreeSet natural size: " + natural.size());

本机 JDK 25 输出:

TreeSet by author size: 1
TreeSet natural size: 2

按作者比较时,两本书的 compare 返回 0,TreeSet 认为它们是重复元素,只保留第一本;按 ISBN 的自然顺序比较时,两本书不同,size 是 2。如果你用 TreeSet 去重,比较器的「相等」必须和业务上的「相等」一致;否则会丢数据。 只想排序、不想去重时,用 List.sort,不要用 TreeSet。

List.sort 是稳定排序(TimSort):比较结果相等的元素保持原有相对顺序。这意味着先用插入顺序读入,再按作者排序,同一作者内部仍然保持插入顺序;需要更细的顺序时,用 thenComparing 明确写出来,不要依赖稳定性「碰巧」得到结果。

在 library 里落地

把排序选项收进一个枚举,服务层只暴露一个方法:

public List<Book> listBooks(SortKey key) {
    List<Book> books = new ArrayList<>(repository.findAll());
    Comparator<Book> comparator = switch (key) {
        case TITLE -> Comparator.comparing(Book::title);
        case YEAR_DESC -> Comparator.comparingInt(Book::publishedYear)
                .reversed()
                .thenComparing(Book::title);
        case AUTHOR_THEN_TITLE -> Comparator.comparing(Book::author)
                .thenComparing(Book::title);
    };
    books.sort(comparator);
    return List.copyOf(books);
}

三个细节:

  • 先复制再排序。 repository.findAll() 返回 List.copyOf 的不可变列表,直接 sort 会抛 UnsupportedOperationException;new ArrayList<>(...) 得到可变副本。
  • 返回 List.copyOf。 排序结果不暴露内部可变列表,调用方改不动。
  • SortKey 用枚举。 新增排序方式时,switch 的穷尽检查会提醒你补分支;用字符串的话,拼错就落到默认顺序。

本机跑 6 本书的目录,三种排序的输出都在前面展示过。YEAR_DESC 先按年份倒序,年份相同再按标题升序;AUTHOR_THEN_TITLE 先按作者,再按标题。这个组合顺序是可以预测的,不依赖排序算法的稳定性。

CLI 可以把 SortKey 暴露成 list --sort title 这样的参数,解析时用 SortKey.valueOf 并捕获非法值;第 9.3 节的 CommandParser 已经示范了怎么做参数校验。

常见错误与边界

在 for-each 里删除元素。 删除第一个元素后继续 next() 会抛 ConcurrentModificationException;删除最后一个元素可能不抛,导致「测试通过、线上偶发」。统一用 removeIf 或 Iterator.remove。

用减法写比较器。 a - b 会溢出,返回错误的符号。用 Integer.compare(a, b)、Long.compare 或 Comparator.comparingInt。

比较器与 equals 不一致却用 TreeSet 去重。 TreeSet 只看 compare 是否为 0,不看 equals;按作者比较会把同作者的不同书当成重复。需要去重用 HashSet,需要排序用 List.sort。

对不可变列表排序。 List.of(...)、List.copyOf(...) 返回的列表不能 sort,会抛 UnsupportedOperationException。先 new ArrayList<>(...)。

忽略 null。 字符串比较遇到 null 会抛 NullPointerException;用 Comparator.nullsFirst/nullsLast 显式处理,或者在数据入口就禁止 null。

小结

  • for-each 是 Iterator 的语法糖;遍历中删除用 removeIf 或 Iterator.remove,不要直接 collection.remove。
  • Comparable 定义自然顺序,Comparator 定义外部顺序;多字段用 thenComparing,倒序用 reversed。
  • String 的自然顺序是 UTF-16 码元顺序,不是拼音;中文排序用 Collator.getInstance(Locale.CHINA)。
  • 比较器不能用减法,a - b 会溢出;用 Integer.compare 或 comparingInt。
  • TreeSet 用比较器判断重复,比较器与 equals 不一致会静默丢元素;排序用 List.sort,去重用 HashSet。
  • List.sort 是稳定排序;不可变列表要先复制再排序。

第 10 章把容器、复杂度和排序都过了一遍。下一章开始泛型:LibraryRepository 现在只能存 Book 和 Loan,如果还要存 Member、Loan 的历史记录,难道要再写一个接口?Repository<T, ID> 会把这类重复收掉。

阅读导航:上一节:10.2 复杂度与内存 · 下一节:11.1 泛型类与方法 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发