Blog

equals、hashCode 与如何选择 Java 集合

Java 的 HashMap 靠 hashCode 和 equals 查找键,约定被破坏或键在 put 之后被修改,条目就会“消失”。本文讲清这些规则、TreeSet 为什么改用 compareTo,以及该选哪种集合。

HashMapHashSet 对每个键都信任两个方法:hashCode 决定去哪里找,equals 决定什么算匹配。只要其中一个写错,或者键存进去之后又被修改,集合就会悄无声息地找不到仍然在里面的东西。没有异常,没有警告,只有一个 null

本文讲 equalshashCode 的约定、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 的 xyz

  • 自反性: 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.javajava 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

ab 相等,但它们用的仍然是 Object 的哈希码,而这个哈希码基于对象的标识。HashSet 去错误的地方找 b,然后说它不在。接着它心安理得地加入了一个“重复”元素,于是一个 set 里装着两个相等的元素。ArrayList 从不使用哈希码,所以它能找到 b。这正是这个 bug 藏得深的原因:用 list 的代码一切正常,同一个类一放进 set 或者当成 map 的键,就出问题了。

修复方法是用 equals 比较的那些字段来构建 hashCodeObjects.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 个桶,这个桶就会变成一棵小树,这样即使哈希函数很差,查找也不必顺着一条长链走下去。

这个比喻的局限: 真正的服务员会注意到新标签,然后去找。HashMapput 之后从不重新读取键。它相信自己存下的哈希值,所以被修改的键不会被移动、标记或重新哈希。另外,挂杆也不是根据整个标签选的。只有混合后哈希值的低位决定桶,所以两个相差很大的哈希码也可能共用一个桶。

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

CoatequalshashCode 都写对了。bug 在于两者都依赖 tag,而 tag 在外套充当键的期间被改了。把结果成对来看:

  • 修改之后的同一个对象: 它的新哈希值把 get 带到另一个桶,所以什么也找不到。containsKeyremove 也以同样的方式失败。
  • 新建的 Coat("blue-17") 它的哈希值和存储的一致,所以 get 能到达正确的桶。然后 equals 拿它和存储的键比较,而那个键的标签现在是 red-42,两者不匹配。
  • sizevalues Ana 还在里面。遍历 map 能找到她,只是查找找不到。

把旧标签改回来,条目又能访问了,这证明什么都没被删除。在真实代码里,没有人记得旧值,所以条目就一直卡在那里,长期存在的 map 就这样泄漏内存。

修复方法是使用不会改变的键:组件不可变的 record、StringInteger,或者字段都是 final 的类。如果键真的必须改,就先把它移除,修改之后再放回去。

看一次查找,再看一个丢失的键

这个动画是简化示意,数字都是虚构的:8 个桶,哈希码是编的。它先展示 owners.get(coat) 找到 Ana,再展示标签改变之后同样的调用:

简化示意:8 个桶,哈希值为虚构 0 1 2 3 4 5 6 7 pear-05 → Ben blue-17 → Ana red-42 → Ana equals:否 equals:是 存储的哈希 1283 6 号桶为空 tag = blue-17 tag = red-42 hashCode() 1283 hashCode() 5078 选中 3 号桶 选中 6 号桶 get 返回 "Ana" get 返回 null owners.get(coat),coat 的 tag 是 blue-17 hashCode() 得到 1283,选中 8 个桶中的 3 号桶 3 号桶里,第一个条目的 equals 为 false,继续 第二个条目的 equals 为 true:就是这个键 get 返回这个条目的值 "Ana" tag 改成 red-42:新哈希指向 6 号桶,是空的,返回 null

一个简化的 HashMap,有 8 个桶,哈希码都是虚构的。键的哈希值选中 3 号桶,equals 拒绝第一个条目、接受第二个,get 返回 Ana。标签改变之后,新的哈希值选中空的 6 号桶,于是 get 返回 null,而条目仍然待在 3 号桶里。

如果动画没有播放,下面用文字把这几步再说一遍:

  1. owners.get(coat) 开始时,外套的标签是 blue-17
  2. get 调用 coat.hashCode()。在这个示意里结果是 1283,而 1283 选中 8 个桶中的 3 号桶。
  3. 3 号桶里有两个条目。get 对第一个条目的键 pear-05 调用 equals,结果为 false,于是继续往下。
  4. 对第二个条目的键 blue-17 调用 equals,结果为 true。就是这个条目。
  5. get 返回和它存在一起的值 "Ana"
  6. 现在外套的标签改成了 red-42。条目不会移动:它带着存储的哈希值 1283 留在 3 号桶里。下一次 get(coat) 算出新的哈希值 5078,选中 6 号桶。6 号桶是空的,所以 get 返回 null

TreeSetTreeMapcompareTo,而不是 equals

TreeSetTreeMap 让键保持有序,它判断两个键是否相同的方式是比较它们,而不是调用 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

pearplum 都是四个字母,和 kiwi 一样,所以 set 拒绝了它们。更糟的是,contains("lime") 对一个从没加进去的单词也说 true。有四个字母就够了。

BigDecimal 在 JDK 内部展示了同样的问题。1.01.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

pushpop 在前端操作,所以最后压入的最先出来。offer 在尾部添加,poll 从前端取出,所以队列是先进先出。对空的 deque 调用 poll 会返回 null,而不是抛出异常。ArrayDeque 不接受 null 元素,所以 poll 返回 null 就一定表示 deque 是空的。

选择 map

三种通用 map 的区别在于一件你看得见的事:遍历时拿到的顺序。按你的需要来选:

你需要 使用 遍历顺序
快速查找,不在乎顺序 HashMap 没有可以依赖的顺序
快速查找,按键加入的顺序 LinkedHashMap 插入顺序
键有序,或者要取范围,比如“Lagos 之前的所有键” TreeMap compareTo 或比较器排序

假设哈希码还算像样,HashMapLinkedHashMap 查找键的平均时间是常数。TreeMap 保证 log(n) 的时间。set 也有同样的三种选择:HashSetLinkedHashSetTreeSet

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.ofSet.ofMap.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

ofcopyOf 系列方法在任何位置都拒绝 nullMap.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

getOrDefaultmergecomputeIfAbsent

三个 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 会替你把两者都写好。
  • HashMaphashCode 选桶,再在桶里用 equals。对象充当键期间,绝不要修改 hashCode 用到的字段,否则条目就再也访问不到了。
  • TreeSetTreeMapcompare(a, b) == 0 当作同一个键。加上 thenComparing 次级比较,让顺序和 equals 保持一致。
  • list 用 ArrayList,栈和队列用 ArrayDeque,并根据需要的遍历顺序在 HashMapLinkedHashMapTreeMap 之间选择。
  • List.ofList.copyOf 不能修改。Collections.unmodifiableList 是一个视图,会反映它背后那个 list 的变化。
  • 不要在 for-each 循环里从 list 删除元素。用 removeIfIterator.remove

基于哈希的集合只有在键的 equalshashCode 彼此一致、并且键在集合里期间不变时,才能正常工作。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。