为什么Stream <t> collect方法返回不同的键顺序?

时间:2017-05-19 09:32:01

标签: java lambda java-8 java-stream

我有这段代码:

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]}

我的问题是,当regionNames功能到位时,有人可以告诉我为什么地图peek中的键顺序不同?

1 个答案:

答案 0 :(得分:9)

enum的{​​{1}}实施使用hashCode提供的默认值。该方法The documentation提到:

  

每当在执行Java应用程序期间多次在同一对象上调用它时,hashCode方法必须始终返回相同的整数,前提是不修改对象的equals比较中使用的信息。 此整数需要从应用程序的一次执行到同一应用程序的另一次执行保持一致。

由于哈希码决定了Object内的桶的顺序(这是HashMap使用的),因此当哈希码发生变化时,顺序会发生变化。如何生成此哈希代码是VM的实现细节(如Eugene所指出的)。通过评论和取消评论groupingBy行,您只需找到一种方法来影响(可靠或不可靠)此实现。

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

现在,openjdk8的实际哈希算法在HashMap

中定义
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 ; 对象的这些_hashState字段,并且输出从一次调用更改为下一次调用,因为变量值是&#39;混洗&#39;

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

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

看起来初始种子是我测试(windows)平台上的常量。

由此,我得出结论,生成的身份哈希码(在openjdk-8中)根据在它之前的同一线程上生成了多少身份哈希码以及多少次{{1}来更改在生成哈希代码的线程被实例化之前被调用,这对于示例程序保持不变。我们已经可以看到这一点,因为如果程序保持不变,则键的顺序不会从程序的运行变为运行。但另一种看待它的方法是将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 放在os::random方法的开头,如果运行该程序几次,则看到输出总是相同的。

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

现在,让我们回到Java示例。如果枚举常量的标识哈希码基于在其之前生成了多少身份哈希码而发生更改,则逻辑结论将是在System.out.println(new Object().hashCode());调用的某处,生成身份哈希码,这会更改之后在main行上为枚举常量生成的哈希码:

peek

您可以使用普通的Java调试器来查看。我在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 的行调用它。 (如果你自己尝试一下,我会注意到VM本身使用Object#hashCode,并且会在peek方法之前多次调用HashMap。所以要注意这一点)

Et瞧:

hashCode

main的{​​{1}}行调用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类的类加载器使用hashCode对象ProtectionDomain 1}}你看,我可以从调试器中获取值。在整个MethodHandle框架中,对于LambdaMetafactory的行,Class<?>方法实际上被称为一堆次数(可能是几百?)。

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

确认的最后一种方法是通过添加:

,在Object#hashCode行之前生成常量的哈希码。
peek

peek方法的开头。

现在您将看到使用Continent.ASIA.hashCode(); Continent.EUROPE.hashCode(); 对该行进行评论或取消评论不会影响存储分区的顺序。