为什么Java 8和Java 9+中的HashSets表现不同?

5
尝试移除迭代器内包含的对象时,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

为什么会发生这种情况?


15
注意:一旦将对象添加到集合中,修改其哈希键几乎肯定会导致某种故障。即使是调整HashMap中默认哈希桶数量这样微不足道的事情也可能会导致问题。 - chrylis -cautiouslyoptimistic-
6
他们很可能改变了某些东西。但是保证这种变化只会在正确使用时产生一致的结果,而不是在错误使用时保证一致的行为。 - WJS
2
比较Java 8和Java 9中类的源代码并找出差异!正如Chrylis所指出的,哈希码的更改可能会导致问题,而Java 9似乎与Java 8处理方式不同,这意味着通过迭代器进行删除不再对哈希码更改敏感,或者其他一些变化。但是,您基本上是依赖于未定义的行为。 - Mark Rotteveel
3
从set方法的Javadoc中可以得知:如果使用可变对象作为set的元素,就必须非常小心。如果在对象作为set元素时更改了其值,以一种影响equals比较的方式,那么set的行为将不能保证。 这里违反了这个原则。 - Ashutosh
顺便问一下,你使用的是哪个 Java 版本:Oracle 还是 OpenJdk?有一个针对 OpenJdk 的 bug 提出了关于 set 的 remove 方法的类似问题,但最终被认为不是问题而关闭了:https://bugs.openjdk.java.net/browse/JDK-8154740。 - Ashutosh
1
@Ashutosh:我几乎可以确定这是一个领域,Oracle JDK绝对会做OpenJDK所做的事情。他们没有理由在这个领域分道扬镳,这样做实际上会适得其反。编辑:请参见我的答案底部的链接,其中讨论了一个错误。 - Joachim Sauer
1个回答

15
在Java 8和Java 9之间,HashSet发生了一些变化,但具体细节并不重要,因为您使用的Set的方式已经指定为错误(强调我的):

注意:如果将可变对象用作集合元素,则必须非常小心。 如果更改对象的值以影响等于比较,而该对象是集合中的元素,则不会指定集合的行为

由于Date.equals()取决于Date表示的时间,所以您确实做到了这一点。

既然如此,集合的行为就不再被指定了。

这意味着它可能以任何方式/形式出现问题,仍然是符合规范的实现。

您可以尝试找出Java 9为什么会有不同的行为(我自己也不知道),但这并不改变任何JVM都可以在任何时候再次以错误的方式使用集合而导致不同的行为。

编辑:出于好奇,我确实调查了到底有何不同,并找到了一个相关变化:在OpenJDK 8和9中,HashSet基于HashMap实现,因此这一切都集中在HashMap上。

在Java 8中相关Iterator实现的remove()方法包含以下行:

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

这会重新计算(即获取)key的当前哈希值,并尝试从Map中删除它。由于该新哈希值从未添加到Map中(当添加该键时,它有不同的哈希值),因此不会找到节点,也就无法删除任何东西。

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

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

这将简单地将先前计算和记忆的哈希值p.hash传递给removeNode方法,从而能够找到并删除相关节点。

引入此更改的变更集中提到了此OpenJDK错误

那里的注释(特别是Doug Lea的注释)似乎都同意“修复”被误用集合的行为不是目标,但不重新计算哈希可能会更快。换句话说:该更改是出于性能原因而不是正确性原因。

因此,总结和重申一下两种行为都是可接受的实现,因为通过更改您的集合条目的equals()行为,您已经违反了契约。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接