Java 的 HashMap 靠 hashCode 和 equals 查找键,约定被破坏或键在 put 之后被修改,条目就会“消失”。本文讲清这些规则、TreeSet 为什么改用 compareTo,以及该选哪种集合。
HashMap 和 HashSet 对每个键都信任两个方法:hashCode 决定去哪里找,equals 决定什么算匹配。只要其中一个写错,或者键存进去之后又被修改,集合就会悄无声息地找不到仍然在里面的东西。没有异常,没有警告,只有一个 null。
本文讲 equals 和 hashCode 的约定、HashMap 如何找到一个键、键被修改后为什么会丢失、TreeSet 为什么改用 compareTo,以及怎样选择 list、deque、map 和不可变集合。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存成 Main.java,再执行 java Main.java。
equals 的约定,以及一个对称性 bug
equals 方法必须表现得像真正的相等,否则集合给出的答案会取决于由哪一边发起比较。讲值与引用的那一篇介绍了 == 和 equals 的区别,讲 record 的那一篇展示了 record 替你写好的 equals。这一节讲的是手写 equals。
下面这个包装类想帮点忙。它和其他包装对象比较时不区分大小写,和普通字符串比较时也一样:
final class CaseInsensitive {
private final String key;
CaseInsensitive(String text) {
this.key = text.toLowerCase(Locale.ROOT);
}
@Override
public boolean equals(Object other) {
if (other instanceof CaseInsensitive c) {
return key.equals(c.key);
}
if (other instanceof String s) {
return key.equals(s.toLowerCase(Locale.ROOT));
}
return false;
}
@Override
public int hashCode() {
return key.hashCode();
}
}
void main() {
var name = new CaseInsensitive("Ana");
IO.println("name.equals(\"ana\"): " + name.equals("ana"));
IO.println("\"ana\".equals(name): " + "ana".equals(name));
var wrappers = new ArrayList<Object>();
wrappers.add(name);
IO.println("wrappers contains \"ana\": " + wrappers.contains("ana"));
var strings = new ArrayList<Object>();
strings.add("ana");
IO.println("strings contains name: " + strings.contains(name));
}
输出:
name.equals("ana"): true
"ana".equals(name): false
wrappers contains "ana": false
strings contains name: true
CaseInsensitive 认识 String,但 String 从没听说过 CaseInsensitive。所以 a.equals(b) 和 b.equals(a) 的结果不一致。ArrayList.contains(x) 调用的是 x.equals(element),这意味着答案会随着“哪个对象在列表里、你拿哪个去找”而翻转。
这就破坏了对称性规则。Object 文档里的 equals 约定一共有五条,对任意非 null 的 x、y 和 z:
- 自反性:
x.equals(x)为 true。 - 对称性: 当且仅当
y.equals(x)为 true 时,x.equals(y)才为 true。 - 传递性: 如果
x.equals(y)且y.equals(z),那么x.equals(z)。 - 一致性: 只要两个对象都没变,再调用一次得到的答案相同。
- null:
x.equals(null)为 false,而不是抛出异常。
修复方法是只和自己的类型比较。这里 final 类很重要:如果子类可以添加字段并写自己的 equals,同样的对称性问题就会在父类和子类之间重新出现。
final class CaseInsensitive {
private final String key;
CaseInsensitive(String text) {
this.key = text.toLowerCase(Locale.ROOT);
}
@Override
public boolean equals(Object other) {
return other instanceof CaseInsensitive c && key.equals(c.key);
}
@Override
public int hashCode() {
return key.hashCode();
}
}
void main() {
var a = new CaseInsensitive("Ana");
var b = new CaseInsensitive("ANA");
var c = new CaseInsensitive("ana");
IO.println("reflexive: " + a.equals(a));
IO.println("symmetric: " + (a.equals(b) == b.equals(a)));
IO.println("transitive: " + (a.equals(b) && b.equals(c) && a.equals(c)));
IO.println("null: " + a.equals(null));
IO.println("vs String: " + a.equals("ana") + " " + "ana".equals(a));
}
输出:
reflexive: true
symmetric: true
transitive: true
null: false
vs String: false false
现在无论从哪一边比较,包装对象和字符串都永远不相等。没那么聪明,但这是对的。instanceof 还顺带处理了 null,因为 null instanceof CaseInsensitive 为 false。
相等的对象必须有相等的哈希码
hashCode 的约定里最要紧的一条是:如果 a.equals(b) 为 true,那么 a.hashCode() 必须等于 b.hashCode()。不相等的对象可以共用一个哈希码,相等的对象却不能不同。
经典的 bug 是重写了 equals,却忘了 hashCode。本系列使用的构建命令 javac -Xlint:all -Werror 能抓住它:
class Email {
private final String address;
Email(String address) {
this.address = address;
}
@Override
public boolean equals(Object other) {
return other instanceof Email e && address.equals(e.address);
}
}
void main() {
var subscribers = new HashSet<Email>();
subscribers.add(new Email("ana@example.com"));
IO.println(subscribers.contains(new Email("ana@example.com")));
}
构建失败,报错:
Main.java:1: warning: [overrides] Class Main.Email overrides equals, but neither it nor any superclass overrides hashCode method
error: warnings found and -Werror specified
这一点让我们有些意外:这项检查确实存在,但默认是关闭的。直接运行 javac Main.java 和 java Main.java,都会一声不吭地接受这个文件。只有用 -Xlint 要求时,overrides 检查才会运行。信息里写的是 Main.Email,因为紧凑源文件会把其中的类包进一个隐藏的 Main 类。
那么没人要求检查时会发生什么?@SuppressWarnings("overrides") 关掉了这项检查,这样你就能看到这个 bug 跑起来的样子:
@SuppressWarnings("overrides")
class Email {
private final String address;
Email(String address) {
this.address = address;
}
@Override
public boolean equals(Object other) {
return other instanceof Email e && address.equals(e.address);
}
}
void main() {
var a = new Email("ana@example.com");
var b = new Email("ana@example.com");
IO.println("a.equals(b): " + a.equals(b));
IO.println("same hashCode: " + (a.hashCode() == b.hashCode()));
var subscribers = new HashSet<Email>();
subscribers.add(a);
IO.println("contains b: " + subscribers.contains(b));
subscribers.add(b);
IO.println("size: " + subscribers.size());
var list = new ArrayList<Email>();
list.add(a);
IO.println("list has b: " + list.contains(b));
}
输出:
a.equals(b): true
same hashCode: false
contains b: false
size: 2
list has b: true
a 和 b 相等,但它们用的仍然是 Object 的哈希码,而这个哈希码基于对象的标识。HashSet 去错误的地方找 b,然后说它不在。接着它心安理得地加入了一个“重复”元素,于是一个 set 里装着两个相等的元素。ArrayList 从不使用哈希码,所以它能找到 b。这正是这个 bug 藏得深的原因:用 list 的代码一切正常,同一个类一放进 set 或者当成 map 的键,就出问题了。
修复方法是用 equals 比较的那些字段来构建 hashCode。Objects.hash 一行就能做到:
class Email {
private final String user;
private final String domain;
Email(String user, String domain) {
this.user = user;
this.domain = domain;
}
@Override
public boolean equals(Object other) {
return other instanceof Email e && user.equals(e.user) && domain.equals(e.domain);
}
@Override
public int hashCode() {
return Objects.hash(user, domain);
}
}
void main() {
var a = new Email("ana", "example.com");
var b = new Email("ana", "example.com");
IO.println("same hashCode: " + (a.hashCode() == b.hashCode()));
var subscribers = new HashSet<Email>();
subscribers.add(a);
subscribers.add(b);
IO.println("contains b: " + subscribers.contains(b));
IO.println("size: " + subscribers.size());
}
输出:
same hashCode: true
contains b: true
size: 1
equals 用到的每个字段都要用上,它忽略的字段一个都不要用。record 会根据组件替你把这些全做好,讲 record 的那一篇演示过。如果 Email 写成 record Email(String user, String domain) {},就没有什么可忘的了。
HashMap 如何找到一个键
HashMap 把条目存在一个桶数组里,每个键的哈希码决定它住在哪个桶。查找时不会搜索整个 map。它先算出键的哈希码,直接去对应的那一个桶,然后只用 equals 检查这个桶里的条目。
两个键可能落进同一个桶,甚至可能有相同的哈希码。这时由 equals 来区分:
void main() {
IO.println("\"Aa\".hashCode() = " + "Aa".hashCode());
IO.println("\"BB\".hashCode() = " + "BB".hashCode());
var map = new HashMap<String, Integer>();
map.put("Aa", 1);
map.put("BB", 2);
IO.println("get Aa: " + map.get("Aa"));
IO.println("get BB: " + map.get("BB"));
IO.println("size: " + map.size());
}
输出:
"Aa".hashCode() = 2112
"BB".hashCode() = 2112
get Aa: 1
get BB: 2
size: 2
"Aa" 和 "BB" 的哈希码完全冲突,所以它们共用一个桶。map 仍然能把它们分开,因为 "Aa".equals("BB") 为 false。冲突会多花一点时间,但绝不会影响正确性。
用十岁孩子能懂的话说
HashMap 就像衣帽间。你把外套交进去时,服务员看一眼上面的标签,根据标签选一根挂杆。这就是 hashCode。服务员把你的外套挂在那根杆上,旁边还有几件别人的外套。
你回来取时,出示同一个标签。服务员走到那一根杆前,逐件核对挂在上面的外套的牌子,直到对上为止。这就是 equals。没有人去翻整个房间。
现在假设你交了外套之后,偷偷溜回去改了外套上的标签。等你来取时,新标签会把服务员带到另一根杆前。你的外套不在那里。它还在房间里,挂在原来那根杆上,但没有人会去那里找。
准确的说法
HashMap 持有一个桶数组,长度是 2 的幂,放入第一个条目后默认是 16。选桶时,它取键的 hashCode(),用 h ^ (h >>> 16) 把高位混进低位,再用 (n - 1) & hash 保留低位,其中 n 是桶的数量。每个条目都会存下放入时的哈希值。
get(key) 重新计算哈希值,去对应的桶,对每个条目检查:存储的哈希值是否相等,键是否 == 或 equals。第一个通过的条目就是结果。map 的填充超过 75% 时,数组容量翻倍,条目被分散到新的桶里。如果某个桶里的条目太多(JDK 的阈值常量是 8),并且表至少有 64 个桶,这个桶就会变成一棵小树,这样即使哈希函数很差,查找也不必顺着一条长链走下去。
这个比喻的局限: 真正的服务员会注意到新标签,然后去找。HashMap 在 put 之后从不重新读取键。它相信自己存下的哈希值,所以被修改的键不会被移动、标记或重新哈希。另外,挂杆也不是根据整个标签选的。只有混合后哈希值的低位决定桶,所以两个相差很大的哈希码也可能共用一个桶。
在 put 之后修改键,条目就会丢失
键存进去之后又被修改,是 HashMap“丢失”数据最常见的原因。条目还在 map 里,只是你再也够不着它了。
class Coat {
String tag;
Coat(String tag) {
this.tag = tag;
}
@Override
public boolean equals(Object other) {
return other instanceof Coat c && tag.equals(c.tag);
}
@Override
public int hashCode() {
return tag.hashCode();
}
}
void main() {
var owners = new HashMap<Coat, String>();
var coat = new Coat("blue-17");
owners.put(coat, "Ana");
IO.println("before: " + owners.get(coat));
coat.tag = "red-42";
IO.println("after, same object: " + owners.get(coat));
IO.println("after, old tag: " + owners.get(new Coat("blue-17")));
IO.println("containsKey: " + owners.containsKey(coat));
IO.println("remove: " + owners.remove(coat));
IO.println("size: " + owners.size());
IO.println("values: " + owners.values());
coat.tag = "blue-17";
IO.println("tag put back: " + owners.get(coat));
}
输出:
before: Ana
after, same object: null
after, old tag: null
containsKey: false
remove: null
size: 1
values: [Ana]
tag put back: Ana
Coat 的 equals 和 hashCode 都写对了。bug 在于两者都依赖 tag,而 tag 在外套充当键的期间被改了。把结果成对来看:
- 修改之后的同一个对象: 它的新哈希值把
get带到另一个桶,所以什么也找不到。containsKey和remove也以同样的方式失败。 - 新建的
Coat("blue-17"): 它的哈希值和存储的一致,所以get能到达正确的桶。然后equals拿它和存储的键比较,而那个键的标签现在是red-42,两者不匹配。 size和values: Ana 还在里面。遍历 map 能找到她,只是查找找不到。
把旧标签改回来,条目又能访问了,这证明什么都没被删除。在真实代码里,没有人记得旧值,所以条目就一直卡在那里,长期存在的 map 就这样泄漏内存。
修复方法是使用不会改变的键:组件不可变的 record、String、Integer,或者字段都是 final 的类。如果键真的必须改,就先把它移除,修改之后再放回去。
看一次查找,再看一个丢失的键
这个动画是简化示意,数字都是虚构的:8 个桶,哈希码是编的。它先展示 owners.get(coat) 找到 Ana,再展示标签改变之后同样的调用:
一个简化的 HashMap,有 8 个桶,哈希码都是虚构的。键的哈希值选中 3 号桶,equals 拒绝第一个条目、接受第二个,get 返回 Ana。标签改变之后,新的哈希值选中空的 6 号桶,于是 get 返回 null,而条目仍然待在 3 号桶里。
如果动画没有播放,下面用文字把这几步再说一遍:
owners.get(coat)开始时,外套的标签是blue-17。get调用coat.hashCode()。在这个示意里结果是 1283,而 1283 选中 8 个桶中的 3 号桶。- 3 号桶里有两个条目。
get对第一个条目的键pear-05调用equals,结果为 false,于是继续往下。 - 对第二个条目的键
blue-17调用equals,结果为 true。就是这个条目。 get返回和它存在一起的值"Ana"。- 现在外套的标签改成了
red-42。条目不会移动:它带着存储的哈希值 1283 留在 3 号桶里。下一次get(coat)算出新的哈希值 5078,选中 6 号桶。6 号桶是空的,所以get返回null。
TreeSet 和 TreeMap 用 compareTo,而不是 equals
TreeSet 或 TreeMap 让键保持有序,它判断两个键是否相同的方式是比较它们,而不是调用 equals。如果 compare 返回 0,树就把两者当成同一个键。一个对不相等的东西也返回 0 的比较器,会让 set 把它们丢掉:
void main() {
var byLength = new TreeSet<String>(Comparator.comparingInt(String::length));
for (var fruit : List.of("fig", "kiwi", "pear", "plum", "apple")) {
IO.println("add " + fruit + ": " + byLength.add(fruit));
}
IO.println(byLength);
IO.println("contains pear: " + byLength.contains("pear"));
IO.println("contains lime: " + byLength.contains("lime"));
var prices = List.of(new BigDecimal("1.0"), new BigDecimal("1.00"));
IO.println("equals: " + prices.get(0).equals(prices.get(1)));
IO.println("compareTo: " + prices.get(0).compareTo(prices.get(1)));
IO.println("HashSet size: " + new HashSet<>(prices).size());
IO.println("TreeSet size: " + new TreeSet<>(prices).size());
}
输出:
add fig: true
add kiwi: true
add pear: false
add plum: false
add apple: true
[fig, kiwi, apple]
contains pear: true
contains lime: true
equals: false
compareTo: 0
HashSet size: 2
TreeSet size: 1
pear 和 plum 都是四个字母,和 kiwi 一样,所以 set 拒绝了它们。更糟的是,contains("lime") 对一个从没加进去的单词也说 true。有四个字母就够了。
BigDecimal 在 JDK 内部展示了同样的问题。1.0 和 1.00 的标度不同,所以 equals 认为它们不同,HashSet 两个都留下。它们的 compareTo 是 0,所以 TreeSet 只留一个。当一个类的自然顺序和 equals 一致时,文档称之为与 equals 一致(consistent with equals),而键需要的正是这一点。
一个类通过实现 Comparable 获得自然顺序。Comparator 则从类的外部给出一种顺序。Comparator.comparing(...).thenComparing(...) 逐个字段地构建比较器,而正是这些用来打破平局的次级比较,让它和 equals 保持一致:
record Version(int major, int minor) implements Comparable<Version> {
private static final Comparator<Version> ORDER =
Comparator.comparingInt(Version::major).thenComparingInt(Version::minor);
@Override
public int compareTo(Version other) {
return ORDER.compare(this, other);
}
}
record Person(String last, String first, int age) {}
void main() {
var versions = new TreeSet<>(
List.of(new Version(21, 0), new Version(8, 2), new Version(17, 1)));
IO.println(versions);
var people = new ArrayList<>(List.of(
new Person("Silva", "Rui", 41),
new Person("Okafor", "Ada", 29),
new Person("Silva", "Ana", 35),
new Person("Okafor", "Ada", 52)));
people.sort(Comparator.comparing(Person::last)
.thenComparing(Person::first)
.thenComparing(Comparator.comparingInt(Person::age).reversed()));
people.forEach(IO::println);
}
输出:
[Version[major=8, minor=2], Version[major=17, minor=1], Version[major=21, minor=0]]
Person[last=Okafor, first=Ada, age=52]
Person[last=Okafor, first=Ada, age=29]
Person[last=Silva, first=Ana, age=35]
Person[last=Silva, first=Rui, age=41]
Version 8.2 排在 17.1 前面,因为比较的是数字。如果按字符串比较,"17" 会排在前面。people 先按姓排序,再按名,最后年长的在前。两个 Ada Okafor 只有年龄不同,最后那个 thenComparing 给它们排好了顺序。没有它,使用这个比较器的 TreeSet 只会留下其中一个。
选择 list、栈还是队列
几乎任何时候,ArrayList 都是正确的 list,原因在于两种 list 存放元素的方式。ArrayList 把引用存在一个数组里。get(i) 直接去第 i 个位置。在末尾添加元素时写入下一个空位,偶尔还要把所有内容复制到一个更大的数组里。
LinkedList 把每个元素包进一个单独的节点对象,节点上有指向相邻节点的链接。get(i) 必须从一端出发,一个节点一个节点地走过去,而且每个元素都要多花一个对象。只有当迭代器已经走到中间位置时,在中间插入才便宜,而这很少能弥补其他方面的代价。
栈或队列用 ArrayDeque。老的 Stack 类继承自 Vector,每次调用都要加锁,它自己的文档也建议改用 Deque。
void main() {
var stack = new ArrayDeque<String>();
stack.push("open file");
stack.push("read line");
stack.push("parse number");
IO.println("stack pop: " + stack.pop());
IO.println("stack peek: " + stack.peek());
var queue = new ArrayDeque<String>();
queue.offer("Ana");
queue.offer("Ben");
queue.offer("Cy");
IO.println("queue poll: " + queue.poll());
IO.println("queue now: " + queue);
IO.println("empty poll: " + new ArrayDeque<String>().poll());
}
输出:
stack pop: parse number
stack peek: read line
queue poll: Ana
queue now: [Ben, Cy]
empty poll: null
push 和 pop 在前端操作,所以最后压入的最先出来。offer 在尾部添加,poll 从前端取出,所以队列是先进先出。对空的 deque 调用 poll 会返回 null,而不是抛出异常。ArrayDeque 不接受 null 元素,所以 poll 返回 null 就一定表示 deque 是空的。
选择 map
三种通用 map 的区别在于一件你看得见的事:遍历时拿到的顺序。按你的需要来选:
| 你需要 | 使用 | 遍历顺序 |
|---|---|---|
| 快速查找,不在乎顺序 | HashMap |
没有可以依赖的顺序 |
| 快速查找,按键加入的顺序 | LinkedHashMap |
插入顺序 |
| 键有序,或者要取范围,比如“Lagos 之前的所有键” | TreeMap |
按 compareTo 或比较器排序 |
假设哈希码还算像样,HashMap 和 LinkedHashMap 查找键的平均时间是常数。TreeMap 保证 log(n) 的时间。set 也有同样的三种选择:HashSet、LinkedHashSet 和 TreeSet。
void main() {
var cities = List.of("Porto", "Lagos", "Delhi", "Accra");
var inserted = new LinkedHashMap<String, Integer>();
var sorted = new TreeMap<String, Integer>();
for (var i = 0; i < cities.size(); i++) {
inserted.put(cities.get(i), i + 1);
sorted.put(cities.get(i), i + 1);
}
IO.println("LinkedHashMap: " + inserted);
IO.println("TreeMap: " + sorted);
IO.println("first key: " + sorted.firstKey());
IO.println("before Lagos: " + sorted.headMap("Lagos"));
}
输出:
LinkedHashMap: {Porto=1, Lagos=2, Delhi=3, Accra=4}
TreeMap: {Accra=4, Delhi=3, Lagos=2, Porto=1}
first key: Accra
before Lagos: {Accra=4, Delhi=3}
这里故意没有打印 HashMap。它的顺序来自桶,所以 map 扩容时、或者换一个 JDK 运行时,顺序都可能变。如果输出顺序重要,就选另外两种之一。当键是枚举时,EnumMap 是最好的选择:它把值存在一个以枚举序号为下标的数组里,并按声明顺序遍历。
不可变集合与不可修改视图
List.of、Set.of 和 Map.of 创建的集合完全不能修改,List.copyOf 则为现有集合制作一个不能修改的副本。Collections.unmodifiableList 不一样。它是一扇只读窗口,窗口后面的 list 仍然可以变:
void main() {
var names = new ArrayList<String>(List.of("Ana", "Ben"));
List<String> view = Collections.unmodifiableList(names);
List<String> copy = List.copyOf(names);
names.add("Cy");
IO.println("names: " + names);
IO.println("view: " + view);
IO.println("copy: " + copy);
try {
view.add("Dee");
} catch (UnsupportedOperationException e) {
IO.println("view.add threw " + e.getClass().getSimpleName());
}
try {
Map.of("Ana", 31, "Ben", null);
} catch (NullPointerException e) {
IO.println("Map.of with a null value threw " + e.getClass().getSimpleName());
}
}
输出:
names: [Ana, Ben, Cy]
view: [Ana, Ben, Cy]
copy: [Ana, Ben]
view.add threw UnsupportedOperationException
Map.of with a null value threw NullPointerException
Cy 被加进了 names,视图也显示了它,因为视图自己没有元素。副本是在那之前复制的,所以没有它。你不能通过视图修改 list,但任何持有 names 的人都可以。想让调用方看到一个固定的 list,就交给它 List.copyOf。
of 和 copyOf 系列方法在任何位置都拒绝 null。Map.of 还拒绝重复的键,这样在构建 map 的那一刻就能抓住复制粘贴的错误:
void main() {
var ok = Map.of("Ana", 31, "Ben", 27);
IO.println("size: " + ok.size());
IO.println("Ana: " + ok.get("Ana"));
var typo = Map.of("Ana", 31, "Ben", 27, "Ana", 40);
IO.println(typo.size());
}
输出后停止:
size: 2
Ana: 31
Exception in thread "main" java.lang.IllegalArgumentException: duplicate key: Ana
换成 HashMap,它会毫无怨言地留下第二个值。Set.of 遇到重复元素时,也会以同样的方式抛出异常。
遍历时删除元素
对 ArrayList 的 for-each 循环用的是迭代器,而如果 list 在它背后被修改,这个迭代器就会失败。在循环里删除一个元素,下一步就会抛出异常:
void main() {
var names = new ArrayList<>(List.of("Ana", "Ben", "Cy", "Dee"));
for (var name : names) {
IO.println("checking " + name);
if (name.startsWith("B")) {
names.remove(name);
}
}
IO.println(names);
}
输出后停止:
checking Ana
checking Ben
Exception in thread "main" java.util.ConcurrentModificationException
这个名字有误导性:这里只有一个线程。“Concurrent”的意思是,迭代器正走到一半时,list 被修改了。
这个异常并不保证一定抛出,而那是更糟的情况。下面是同一个循环,换成三个元素的 list:
void main() {
var names = new ArrayList<>(List.of("Ana", "Ben", "Cy"));
for (var name : names) {
IO.println("checking " + name);
if (name.startsWith("B")) {
names.remove(name);
}
}
IO.println(names);
}
输出:
checking Ana
checking Ben
[Ana, Cy]
没有异常,而 Cy 从没被检查过。删掉 Ben 后 list 缩成两个元素,迭代器已经交出了两个元素,于是它在检查修改之前就认定自己遍历完了。如果 Cy 也以“B”开头,它就还会留在 list 里。文档把这项检查称为“尽力而为”(best-effort),说的就是这种情况。
正确的做法有两种。removeIf 一次调用就做完整件事。显式的 Iterator 让你用 it.remove() 删除当前元素,迭代器知道这次删除:
void main() {
var names = new ArrayList<>(List.of("Ana", "Ben", "Cy", "Bea", "Dee"));
names.removeIf(name -> name.startsWith("B"));
IO.println("removeIf: " + names);
var guests = new ArrayList<>(List.of("Ana", "Ben", "Cy", "Bea", "Dee"));
var it = guests.iterator();
while (it.hasNext()) {
var name = it.next();
if (name.startsWith("B")) {
it.remove();
IO.println("removed " + name);
}
}
IO.println("iterator: " + guests);
}
输出:
removeIf: [Ana, Cy, Dee]
removed Ben
removed Bea
iterator: [Ana, Cy, Dee]
除非你需要对每个被删除的元素做点什么,就像这里的迭代器循环那样,否则用 removeIf。
getOrDefault、merge 和 computeIfAbsent
三个 Map 方法取代了人们过去写的大部分“先检查、再 put”的代码。getOrDefault 为不存在的键返回一个备用值。merge 把新值和已有的值合并。computeIfAbsent 在某个键第一次出现时创建一个值。
void main() {
var text = "the quick fox saw the lazy dog and the dog saw the fox";
var counts = new TreeMap<String, Integer>();
for (var word : text.split(" ")) {
counts.merge(word, 1, Integer::sum);
}
IO.println(counts);
IO.println("the: " + counts.getOrDefault("the", 0));
IO.println("cat: " + counts.getOrDefault("cat", 0));
var byLength = new TreeMap<Integer, List<String>>();
for (var word : counts.keySet()) {
byLength.computeIfAbsent(word.length(), k -> new ArrayList<>()).add(word);
}
IO.println(byLength);
}
输出:
{and=1, dog=2, fox=2, lazy=1, quick=1, saw=2, the=4}
the: 4
cat: 0
{3=[and, dog, fox, saw, the], 4=[lazy], 5=[quick]}
counts.merge(word, 1, Integer::sum) 对新单词放入 1,对已有的单词加 1。computeIfAbsent 第一次见到某个长度时创建一个空 list,无论哪种情况都返回 map 里的那个 list,再由 add 把单词放进去。两个 map 都是 TreeMap,所以打印出来的顺序是排好序的,每次运行都一样。
要点
equals必须满足自反性、对称性、传递性、一致性,并且对null返回 false。只和自己的类型比较,否则答案取决于由哪一边发起比较。- 相等的对象必须有相等的哈希码。重写
equals时一并重写hashCode,用同样的字段,比如用Objects.hash。忘了的话-Xlint会警告,但前提是你打开了它。record 会替你把两者都写好。 HashMap用hashCode选桶,再在桶里用equals。对象充当键期间,绝不要修改hashCode用到的字段,否则条目就再也访问不到了。TreeSet和TreeMap把compare(a, b) == 0当作同一个键。加上thenComparing次级比较,让顺序和equals保持一致。- list 用
ArrayList,栈和队列用ArrayDeque,并根据需要的遍历顺序在HashMap、LinkedHashMap和TreeMap之间选择。 List.of和List.copyOf不能修改。Collections.unmodifiableList是一个视图,会反映它背后那个 list 的变化。- 不要在 for-each 循环里从 list 删除元素。用
removeIf或Iterator.remove。
基于哈希的集合只有在键的
equals和hashCode彼此一致、并且键在集合里期间不变时,才能正常工作。