是啥导致 java.util.HashSet 和 HashMap.keySet() 类的 iterator() 排序稍微不可预测?

Posted

技术标签:

【中文标题】是啥导致 java.util.HashSet 和 HashMap.keySet() 类的 iterator() 排序稍微不可预测?【英文标题】:What causes the slightly unpredictable ordering of the iterator() for the java.util.HashSet and HashMap.keySet() classes?是什么导致 java.util.HashSet 和 HashMap.keySet() 类的 iterator() 排序稍微不可预测? 【发布时间】:2011-05-24 01:32:57 【问题描述】:

六年前,我花了几天时间试图找出我完全确定的框架在哪里随机响应。在仔细追踪整个框架以确保它都使用相同的 Random 实例之后,我继续通过单步代码进行追踪。这是高度重复的迭代自调用代码。更糟糕的是,该死的效果只有在完成大量迭代后才会出现。 +6 小时后,当我在 javadoc 中发现 HashSet.iterator() 的一行时,我终于束手无策了,这表明它不能保证返回元素的顺序。然后,我检查了整个代码库,并用 LinkedHashSet 替换了所有 HashSet 实例。低调地看,我的框架正好适合确定性生活!啊!

我现在再次经历了同样的 FREAKIN 影响(至少这次只有 3 小时)。无论出于何种原因,我都错过了 HashMap 的 keySet() 的行为方式相同的小细节。

这是一个关于这个主题的 SO 主题,尽管讨论从未完全回答我的问题:Iteration order of HashSet

所以,我很好奇为什么会发生这种情况。鉴于这两次我都有一个巨大的单线程 java 应用程序在同一台计算机上以完全相同的 JVM 参数(从同一个批处理文件多次运行)爬过完全相同的实例化/插入空间,几乎没有其他东西在运行,这可能会扰乱JVM 这样 HashSet 和 HashMap 在经过大量迭代后会出现不可预测的行为(并非像 javadoc 所说的不依赖于顺序那样不一致)?

从源代码(这些类在 java.util 中的实现)或您对 JVM 的了解(也许某些 GC 会影响内部 java 类在分配内部内存空间时获得非零内存的位置)有什么想法?

【问题讨论】:

【参考方案1】:

我之前遇到过这种情况,顺序并不重要,但确实影响了结果。

Java 的多线程特性意味着使用完全相同的输入重复运行可能会受到细微的时间差异的影响(例如)分配新内存块所需的时间,这有时可能需要分页到磁盘以前的内容,在其他时候不需要。不使用该页面的其他线程可能会继续执行,当考虑到系统对象时,您可能会以不同的对象创建顺序结束。

这可能会影响 JVM 不同运行中等效对象的 Object.hashCode() 结果。

对我来说,我决定增加使用 LinkedHashMap 的小开销,以便能够重现我正在运行的测试的结果。

【讨论】:

是的,真的没有单线程 Java 应用程序这样的东西。这里可能重要的是垃圾收集线程发生在稍微不同的时间。这可能会给出不同的内存地址,因此(可能)会给出不同的身份哈希码。也可以是终结器、HotSpot 编译器或其他线程。 令人着迷! Tyvm 为您解答。我没有考虑多线程交互。由于我的代码库仍然足够小,我将继续推进并为我的每个将成为 Set 成员的类定义一个 equals/hashcode 对。然后看看是否/何时发生不可预测性。 我现在已经更改了我的代码库以覆盖存储在 HashSet 和 HashMap 中的所有类中的 hashCode()(作为键)。我还用自定义实现替换了两个枚举(因为 hashCode() 是最终的),以确保我在运行之间具有稳定一致的值。并且所有实例都是使用默认构造函数(无参数)创建的。我的最后一步是用 Hash* 替换 LinkedHash*(使用 Eclipse,这很简单)。而我运行的结果,在 HashSet/HashMap 的某处发生了一些不可预测性。啊!在查看了 HashSet/HashMap/AbstractMap 的来源之后,我没有任何线索。建议?【参考方案2】:

简答

有一个权衡。如果您想要对元素进行摊销的常数时间O(1) 访问,那么迄今为止的技术依赖于像散列这样的随机方案。如果您想要对元素进行有序访问,最好的工程折衷方案只为您提供 O(ln(n)) 性能。对于您的情况,也许这无关紧要,但是即使从相对较小的结构开始,恒定时间和对数时间之间的差异也会产生很大的差异。

所以是的,你可以去看看代码并仔细检查,但这归结为一个相当实用的理论事实。现在是清除支撑你房子地基下垂角落的Cormen(或Googly Bookiness here)副本的好时机,看看第11章(哈希表)和第13章(红黑树) )。这些将分别让您了解 JDK 的 HashMap 和 TreeMap 实现。

长答案

您不希望 MapSet 返回键/成员的有序列表。那不是他们的目的。 Maps 和 Sets 结构不像基础数学概念那样有序,它们提供不同的性能。这些数据结构的目标(正如@thejh 指出的那样)是有效地摊销insertcontainsget 时间,而不是保持排序。您可以查看如何维护散列数据结构以了解权衡是什么。查看 Hash Functions 和 Hash Tables 上的 Wikipedia 条目(具有讽刺意味的是,请注意“无序地图”的 Wiki 条目重定向到后者)或计算机科学/数据结构文本。

请记住:除非您仔细查看合约是什么,否则不要依赖 ADT(尤其是集合)的属性,例如排序、不变性、线程安全或其他任何东西。请注意,对于 Map,Javadoc 明确说明:

地图的顺序定义为 上的迭代器的顺序 地图的集合视图返回他们的 元素。一些地图实现, 像 TreeMap 类,做具体的 保证他们的订单;其他, 像 HashMap 类,不要。

Set.iterator()有相似之处:

返回元素的迭代器 在这一套。返回的元素 没有特别的顺序(除非这个 set 是某个类的实例 提供保证)。

如果您想要这些的有序视图,请使用以下方法之一:

如果它只是一个Set,也许你真的想要一个SortedSet,比如TreeSet 使用TreeMap,它允许键的自然排序或通过Comparator 的特定排序 抽象你的数据结构,如果这是你想要的行为,它可能是一个特定于应用程序的东西,并同时维护一个SortedSet 的键以及一个Map,这将在摊销时间内表现得更好。 获取Map.keySet()(或只是您感兴趣的Set)并将其放入SortedSet,例如TreeSet,使用自然排序或特定的Comparator。 在排序后使用Map.entrySet().iterator() 遍历Map.Entry<K,V>。例如。 for (final Map.Entry<K,V> entry : new TreeSet(map.entrySet())) 有效地访问键和值。 如果您只是偶尔执行此操作,则可以从结构中获取一组值并使用 Arrays.sort(),它具有不同的性能配置文件(空间和时间)。

链接到源代码

如果您想查看 j.u.HashSet 和 j.u.HashMap 的源代码,可以在 GrepCode 上找到它们。请注意,HashSet 只是 HashMap 的糖。为什么不总是使用排序版本?好吧,正如我上面提到的,性能不同,这在某些应用程序中很重要。请参阅related SO question here。您还可以看到一些具体的性能数据at the bottom here(我没有仔细查看这些数据是否准确,但它们恰好证实了我的观点,所以我会愉快地传递链接。:-)

【讨论】:

感谢您如此详细的回复。这不是我想要的答案。我知道大部分。但是,我想了解在 Java 这样的环境中,在创建实例时内存会自动清零,现在可能会出现不可预测性。【参考方案3】:

你永远不应该依赖哈希映射的顺序。

如果您想要一个具有确定性排序的 Map,我建议您使用类似 TreeMap/TreeSet 的 SortedMap/SortedSet 或使用 LinkedHashMap/LinkedHashSet。我经常使用后者,不是因为程序需要排序,而是因为它更容易阅读日志/调试地图的状态。即当你添加一个键时,它每次都会结束。

您可以创建两个具有相同元素的 HashMap/HashSet,但根据集合的容量获得不同的顺序。代码运行方式的细微差异可能会触发不同的最终存储桶大小并因此产生不同的顺序。

例如

public static void main(String... args) throws IOException 
    printInts(new HashSet<Integer>(8,2));
    printInts(new HashSet<Integer>(16,1));
    printInts(new HashSet<Integer>(32,1));
    printInts(new HashSet<Integer>(64,1));


private static void printInts(HashSet<Integer> integers) 
    integers.addAll(Arrays.asList(0,10,20,30,40,50,60,70,80,90,100));
    System.out.println(integers);

打印

[0, 50, 100, 70, 40, 10, 80, 20, 90, 60, 30]
[0, 50, 100, 70, 80, 20, 40, 10, 90, 60, 30]
[0, 100, 70, 40, 10, 50, 80, 20, 90, 60, 30]
[0, 70, 10, 80, 20, 90, 30, 100, 40, 50, 60]

在这里,您有 HashSet(s),它们以相同的顺序添加相同的值,从而导致不同的迭代器顺序。您可能没有使用构造函数,但您的应用程序可能会间接导致不同的存储桶大小。

【讨论】:

是的。我知道这件事。但是,我总是使用默认构造函数(无参数)。并且代码是单线程完全确定的。所以,我想知道这些“细微差异”的根源可能是什么导致了这种变化。表明它可能是哈希码函数的默认实现如何工作的响应。我将重新编写我的代码库,为任何将成为 Set 成员的类定义一个 equals/hashcode。然后使用 HashSet/HashMap 重新运行我的测试,看看是否/何时出现不可预测性。 我的返工失败了,因为每个相关类都覆盖了 hashCode(),使用了默认实例化,但仍然导致不可预测性。而且我真的不想单步进入 HashSet、HashMap 和/或 AbstractMap 的代码来尝试找到引入不可预测性的特定点。【参考方案4】:

http://download.oracle.com/javase/1.4.2/docs/api/java/lang/Object.html#hashCode() 说:

在合理可行的情况下, 类定义的 hashCode 方法 对象确实返回不同的整数 对于不同的对象。 (这是 通常通过转换实现 对象的内部地址 成一个整数,但这 实现技术不是 JavaTM 编程要求 语言。)

所以内部地址可能会改变?

这也意味着您可以通过为应该充当密钥的所有内容编写自己的 hashCode() 方法来适当地修复它而不会放弃速度。

【讨论】:

+1,这是我的第一个想法,因为您没有指定要存储在 HashMap/HashSet 中的对象类型。 有趣。 Tyvm 试图直接回答我的问题。根据 Bloch 在“Effective Java 2nd Edition”中的建议,一些正在存储的对象具有 equals 和 hashcode 覆盖。我想知道即使所有作为 Set 成员的 my 类都具有其哈希码和 equals 覆盖,我是否仍然可以获得不可预测性。我可能会做一个实验,看看会发生什么。鉴于我在其他地方读到的内容,它仍然可能作为 Java 平台实现层的副作用发生。 即使我在所有相关类中都覆盖了 hashCode(),但我仍然无法预测。在查看了 HashSet、HashMap 和 AbstractMap 的源代码后,我找不到任何可以想象它被引入的地方。在这一点上,单步听起来很不受欢迎。

以上是关于是啥导致 java.util.HashSet 和 HashMap.keySet() 类的 iterator() 排序稍微不可预测?的主要内容,如果未能解决你的问题,请参考以下文章

JAVA-基础(Set~HashSet)

Java 之 HashSet 集合

类HashSet

java.util.HashSet

HashSet集合

JDK1.8源码——java.util.HashSet 类