为什么 HashSet 在 Java 8 和 Java 9+ 中表现不同?

Why do HashSets behave differently in Java 8 and Java 9+?

当试图移除包裹在迭代器 Java 8 和 Java 9+ 中的对象时,行为不同。考虑以下示例:

import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;

class Scratch {
  public static void main(String[] args) {
    Set<Date> dates = new HashSet<>();
    dates.add(new Date(100));
    dates.add(new Date(200));

    for (Date date : dates) {
        System.out.println("Initial "+date.getTime()+":"+date.hashCode());
        date.setTime(date.getTime()+42);
        System.out.println("Mutated "+date.getTime()+":"+date.hashCode()+"\n");
    }

    System.out.println("Size before remove iteration: "+dates.size());
    Iterator<Date> iterator = dates.iterator();
    while (iterator.hasNext()) {
        Date date = iterator.next();
        System.out.println("In loop: "+date.getTime()+":"+date.hashCode());
        iterator.remove();
    }
    System.out.println("Size after remove iteration: "+dates.size());
  }
}

在 HashSet 中改变对象后 Java 8 拒绝使用迭代器删除它们,检查删除循环后的大小。 Java 8 输出:

Initial 100:100
Mutated 142:142

Initial 200:200
Mutated 242:242

Size before remove iteration: 2
In loop: 142:142
In loop: 242:242
Size after remove iteration: 2

Java 9+ 输出与上面相同但是:

Size after remove iteration: 0

为什么会这样?

在 Java 8 和 Java 9 之间 HashSet 发生了一些 的变化,但细节实际上并不那么有趣,因为方式您使用的 Set 已经是 specified to be wrong(强调我的):

Note: Great care must be exercised if mutable objects are used as set elements. The behavior of a set is not specified if the value of an object is changed in a manner that affects equals comparisons while the object is an element in the set.

由于 Date.equals() 取决于 Date 所代表的时间,因此您完全可以这样做。

既然你这样做了,集合的行为就不再指定了。

这意味着它可以在任何 way/shape/form 中表现不当并且仍然是一个符合规范的实现。

你可以尝试找出为什么 Java 9 现在具体表现不同(我自己也不知道),但这并没有改变根本问题任何 如果您以错误的方式使用集合,JVM 可能在任何时间点再次表现不同。

编辑:出于好奇,我确实调查了到底有什么不同,并发现了一个相关的变化:在 OpenJDK 8 和 9 HashSet是基于一个HashMap实现的,所以这一切都集中在HashMap.

在Java 8 the remove() method of the relevant Iterator implementation中包含这一行:

K key = p.key;
removeNode(hash(key), key, null, false, false);

这会重新散列(即获取当前散列)key(即您的 Date)并尝试将其从 Map 中删除。由于一开始就没有添加新的哈希值(添加该键时它有一个不同的哈希值),这将找不到节点,因此不会删除任何内容。

在 Java 9 that code 中看起来像这样:

removeNode(p.hash, p.key, null, false, false);

这将简单地将先前计算和记住的散列 p.hash 传递给 removeNode 方法,从而能够找到并删除有问题的节点。

changeset that introduced that change mentions this OpenJDK bug.

那里的评论(尤其是 Doug Lea 的评论)似乎同意“修复”面对滥用集的行为不是目标,但不重新计算哈希可能会更快。换句话说:该更改是出于性能原因而不是出于正确性原因。

总结和重申这些行为都是可以接受的实现,因为通过改变equals()你设置条目的行为,你已经违反了合同。