【问题标题】:Why Stream<T> collect method returns different key order?为什么 Stream<T> collect 方法返回不同的键顺序?
【发布时间】:2017-05-19 09:32:01
【问题描述】:

我有这个代码:

public enum Continent {ASIA, EUROPE}

public class Country {      
   private String name;
   private Continent region;

    public Country (String na, Continent reg) { 
        this.name = na;
        this.region = reg;
    }
    public String getName () {return name;} 
    public Continent getRegion () {return region;}
    @Override
    public String toString() {
        return "Country [name=" + name + ", region=" + region + "]";
    }
}

在主类中:

public static void main(String[] args) throws IOException {
        List<Country> couList = Arrays.asList(
            new Country ("Japan", Continent.ASIA), 
            new Country ("Sweden", Continent.EUROPE), 
            new Country ("Norway", Continent.EUROPE));
        Map<Continent, List<String>> regionNames = couList
                .stream()
                //.peek(System.out::println)
                .collect(Collectors.groupingBy(Country::getRegion, Collectors.mapping(Country::getName, Collectors.toList())));
        System.out.println(regionNames);
}

如果我运行这段代码,我会得到这个输出:

{EUROPE=[Sweden, Norway], ASIA=[Japan]}

但如果我取消注释 peek 函数,我会得到以下输出:

Country [name=Japan, region=ASIA]
Country [name=Sweden, region=EUROPE]
Country [name=Norway, region=EUROPE]
{ASIA=[Japan], EUROPE=[Sweden, Norway]}

我的问题是,谁能告诉我,当peek 函数到位时,为什么地图regionNames 中的键顺序不同?

【问题讨论】:

  • 这确实应该包括一个hashCodeequals 用于Country 以确保正确性,但添加它们似乎不会影响问题。
  • 是的,我同意hashcodeequals
  • 我的错,你使用Continent作为密钥,不需要hashCodeequals
  • 是的 :) 如果我使用Continent 作为键,那么当我需要这些方法时。不过,没关系。它并没有解决这个主要问题。

标签: java lambda java-8 java-stream


【解决方案1】:

hashCodeenum 实现使用Object 提供的默认实现。 The documentation 该方法提到:

只要在 Java 应用程序执行期间对同一个对象多次调用它,hashCode 方法必须始终返回相同的整数,前提是没有修改对象上相等比较中使用的信息。 此整数需要在应用程序的一次执行与同一应用程序的另一次执行之间保持一致。

由于哈希码决定了HashMap(这是groupingBy 使用的)中存储桶的顺序,因此当哈希码改变时,顺序也会改变。如何生成此哈希码是 VM 的实现细节(正如 Eugene 所指出的)。通过使用 peek 注释和取消注释该行,您已经找到了一种影响(可靠或不可靠)此实现的方法。


由于这个问题得到了赏金,似乎人们对我的回答并不满意。我会再深入一点,看看hashCodeopen-jdk8 实现(因为它是开源的)。 免责声明:我将再次声明身份哈希码算法的实现未指定,并且对于不同的 VM 或同一 VM 的不同版本之间可能完全不同。 由于 OP 正在观察此行为,我假设他使用的虚拟机是 Hotspot(Oracle 虚拟机,afaik 使用与 opendjk 相同的哈希码实现)。但是这样做的主要目的是表明注释或取消注释看似无关的代码行可以更改HashMap 中的存储桶顺序。这是这也是您应该从不依赖未指定集合的​​迭代顺序的原因之一(例如HashMap)。

现在,openjdk8 的实际哈希算法在synchronizer.cpp 中定义:

 // Marsaglia's xor-shift scheme with thread-specific state
 // This is probably the best overall implementation -- we'll
 // likely make this the default in future releases.
 unsigned t = Self->_hashStateX ;
 t ^= (t << 11) ;
 Self->_hashStateX = Self->_hashStateY ;
 Self->_hashStateY = Self->_hashStateZ ;
 Self->_hashStateZ = Self->_hashStateW ;
 unsigned v = Self->_hashStateW ;
 v = (v ^ (v >> 19)) ^ (t ^ (t >> 8)) ;
 Self->_hashStateW = v ;
 value = v ;

如您所见,哈希码基于Thread 对象的这些_hashState 字段,并且输出从一个调用更改为下一个调用,因为变量值是“混洗”的。

这些变量在 Thread 构造函数中初始化,如下所示:

_hashStateX = os::random() ;
_hashStateY = 842502087 ;
_hashStateZ = 0x8767 ;    // (int)(3579807591LL & 0xffff) ;
_hashStateW = 273326509 ;

这里唯一移动的部分是os::random,它在os.cpp 中定义,其中有一条注释将算法描述为:

next_rand = (16807*seed) mod (2**31-1)

这个seed是唯一移动的部分,它是由_rand_seed定义的,并通过一个名为init_random的函数进行初始化,在函数结束时,返回值作为种子下一个电话。通过回购的grep 显示了这一点:

PS $> grep -r init_random
os/bsd/vm/os_bsd.cpp:  init_random(1234567);
os/linux/vm/os_linux.cpp:  init_random(1234567);
os/solaris/vm/os_solaris.cpp:  init_random(1234567);
os/windows/vm/os_windows.cpp:  init_random(1234567);
... test methods

看起来初始种子在我正在测试的平台(Windows)上是一个常量。


由此,我得出的结论是,生成的身份哈希码(在 openjdk-8 中)会根据之前在同一线程上生成的身份哈希码的数量以及os::random 的次数而变化在实例化生成哈希码的线程之前调用,这对于示例程序保持不变。我们已经可以看到这一点,因为如果程序保持不变,键的顺序不会随着程序的运行而改变。不过换个角度看,就是把System.out.println(new Object().hashCode());放在main方法的开头,运行几次就可以看到输出总是一样的。

您还会注意到,在流调用之前生成身份哈希码也会改变枚举常量的哈希码,因此可以改变地图中桶的顺序。

现在,让我们回到 Java 示例。如果枚举常量的身份哈希码根据之前生成的身份哈希码的数量而变化,那么合乎逻辑的结论是,在对peek 的调用中的某处,生成了身份哈希码,这会更改哈希码之后为 collect 行上的枚举常量生成:

Map<Continent, List<String>> regionNames = couList
        .stream()
        //.peek(System.out::println) // Does this call Object.hashCode?
        .collect(Collectors.groupingBy(Country::getRegion,
            Collectors.mapping(Country::getName, Collectors.toList()))); // hash code for constant generated here

您可以使用普通的 Java 调试器看到这一点。我在Object#hashCode 上放置了一个断点,并等待查看peek 的行是否调用它。 (如果您自己尝试,我会注意到 VM 本身使用 HashMap,并且会在 main 方法之前多次调用 hashCode。所以请注意这一点)

瞧:

Object.hashCode() line: not available [native method]   
HashMap<K,V>.hash(Object) line: 338 
HashMap<K,V>.put(K, V) line: 611    
HashSet<E>.add(E) line: 219 
Collections$SynchronizedSet<E>(Collections$SynchronizedCollection<E>).add(E) line: 2035 
Launcher$AppClassLoader(ClassLoader).checkPackageAccess(Class<?>, ProtectionDomain) line: 508   
Main.main(String...) line: 19   

带有peek 的行在ProtectionDomain 对象上调用hashCode,该对象由加载LambdaMetafactory 类的类加载器使用(即您看到的Class&lt;?&gt;,我可以从我的调试器)。在整个 MethodHandle 框架中,hashCode 方法实际上被调用了很多次(可能几百次?),与 peek 一致。

因此,由于peek 的行调用Object#hashCode,在生成枚举常量的哈希码之前(也通过调用Object#hashCode),常量的哈希码会发生变化。因此添加或删除带有peek 的行会改变常量的哈希码,从而改变映射中桶的顺序。

最后一种确认方法是在peek之前生成常量的哈希码,并添加:

Continent.ASIA.hashCode();
Continent.EUROPE.hashCode();

main 方法的开头。

您现在将看到使用peek 注释或取消注释该行对存储桶的顺序没有影响。

【讨论】:

  • since the hash code is based on where the object is in memory 不正确。有不同的哈希码策略,在jdk-8中默认是Marsaglia-XOR-Shift,它使用对象在内存中存在的位置。
  • @Eugene 嗯,谢谢。至少我能够确定这些对象的哈希码可以改变(我认为是基于内存使用情况)。我将编辑答案。
  • @polis 我猜这行得通,但你真的不应该依赖流的迭代顺序,因为它是未定义的。如果您需要某种订购,我建议您使用SortedMap
  • @polis 你真的不应该依赖HashMap 的迭代顺序,因为没有任何保证。例如,它在 Java 8 中发生了变化——我们写得很糟糕的单元测试因此而失败。请改用SortedMapLinkedHashMap
  • @polis:但是当基于哈希的数据结构仍然不能保证可预测的顺序时,创建可重现的哈希码是没有意义的。 HashMap 的顺序甚至可能在同一个 JVM 中发生变化。如果你想要一个可重现的顺序,你可以使用Collectors.groupingBy(Country::getRegion, ()-&gt;new EnumMap&lt;&gt;(Continent.class), Collectors.mapping(Country::getName, Collectors.toList())),那么你总是会得到enum常量的声明顺序。
猜你喜欢
  • 1970-01-01
  • 2014-05-31
  • 2015-03-14
  • 2015-05-05
  • 2021-04-19
  • 2016-10-31
  • 2022-01-17
相关资源
最近更新 更多