本节目标:能安全地遍历并删除集合元素,能用
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 泛型类与方法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。