什么实现细节使这个代码很容易失败?

这个问题不是关于HashMap不是线程安全的,而是关于它在HotSpot和JDK代码上的特定失败模式的众所周知和logging的事实。 我很惊讶这个代码在NPE中失败了:

 public static void main(String[] args) { Map<Integer, Integer> m = new HashMap<>(0, 0.75f); IntStream.range(0, 5).parallel().peek(i -> m.put(i, i)).map(m::get).count(); } 

关于NPE来自哪里,没有什么神秘之处:在.map(m::get)步骤中,尝试解开null 。 5次运行中约有4次失败。

在我的机器上, Runtime#availableProcessors()报告8,所以推测5的长度范围被分成5个子任务,每个只有一个成员。 我也假设我的代码在解释模式下运行。 它可能会调用JIT编译的HashMapStream方法,但顶层被解释,因此排除了HashMap状态被加载到线程局部内存(寄存器/堆栈)的任何变化,从而推迟了由另一个线程观察更新。 如果五个put操作中的某些操作在同一时间不在同一个内核上执行,我不希望它破坏HashMap的内部结构。 考虑到工作量不大,个人任务的时间安排必须非常精确。

是否真的是准确的时间( commonPool的线程必须被commonPool ),还是有其他的途径导致Oracle / OpenJDK HotSpot失败? 我目前的版本是

 java version "1.8.0_72" Java(TM) SE Runtime Environment (build 1.8.0_72-b15) Java HotSpot(TM) 64-Bit Server VM (build 25.72-b15, mixed mode) 

更新:我发现,即使只有两个插入有一个类似的高故障率:

 IntStream.range(0, 2).parallel().peek(i -> m.put(i, i)).map(m::get).count(); 

首先,这不是可靠的。 我设法有一些没有发生exception的运行。 但是,这并不意味着结果图是正确的。 也有可能每个线程都看到自己的价值被成功放置,而最终的地图会遗漏几个映射。

但事实上, NullPointerException失败经常发生。 我创build了下面的debugging代码来说明HashMap的工作:

 static <K,V> void debugPut(HashMap<K,V> m, K k, V v) { if(m.isEmpty()) debug(m); m.put(k, v); debug(m); } private static <K, V> void debug(HashMap<K, V> m) { for(Field f: FIELDS) try { System.out.println(f.getName()+": "+f.get(m)); } catch(ReflectiveOperationException ex) { throw new AssertionError(ex); } System.out.println(); } static final Field[] FIELDS; static { String[] name={ "table", "size", "threshold" }; Field[] f=new Field[name.length]; for (int ix = 0; ix < name.length; ix++) try { f[ix]=HashMap.class.getDeclaredField(name[ix]); } catch (NoSuchFieldException ex) { throw new ExceptionInInitializerError(ex); } AccessibleObject.setAccessible(f, true); FIELDS=f; } 

for(int i=0; i<5; i++) debugPut(m, i, i); 印刷:

 table: null size: 0 threshold: 1 table: [Ljava.util.HashMap$Node;@70dea4e size: 1 threshold: 1 table: [Ljava.util.HashMap$Node;@5c647e05 size: 2 threshold: 3 table: [Ljava.util.HashMap$Node;@5c647e05 size: 3 threshold: 3 table: [Ljava.util.HashMap$Node;@33909752 size: 4 threshold: 6 table: [Ljava.util.HashMap$Node;@33909752 size: 5 threshold: 6 

正如您所看到的,由于初始容量为0 ,即使在顺序操作期间也会创build三种不同的后备arrays。 每次容量增加,活跃并发的机会错过数组更新并创build自己的数组的机会更大。

这与空映射的初始状态以及多个线程试图放置第一个键的情况尤其相关,因为所有线程都可能遇到null表的初始状态并创build它们自己的状态。 而且,即使读完第一个put的状态,也会为第二个put创build一个新的arrays。

但是逐步的debugging揭示了更多的突破机会:

putVal方法putVal ,我们看到最后

 ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; 

换句话说,成功插入新密钥后,如果新大小超过threshold ,表格将被resize。 所以在第一次putresize()在开始时被调用,因为表是null并且由于您指定的初始容量是0 ,即太低而不能存储一个映射,所以新的容量将是1 ,并且新的threshold将是1 * loadFactor == 1 * 0.75f == 0.75f ,舍入为0 。 因此,在第一次put结束时,超过了新的threshold ,并触发了另一个resize()操作。 因此,在初始容量为0 ,如果多个线程同时执行此操作,所有遇到初始状态,则第一个put已经创build并填充了两个数组,这给出了更高的中断几率。

还有一点。 看一下resize()操作,我们看到这些行 :

  @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; if (oldTab != null) { … (transfer old contents to new array) 

换句话说,新的数组引用被填充到旧的条目之前被存储到堆中,所以即使没有读取和写入的重新sorting,另一个线程也有可能读取该引用而没有看到旧的条目,包括一个它以前写过。 实际上,减less堆访问的优化可以降低线程在紧随其后的查询中没有看到自己的更新的机会。

尽pipe如此,它还必须指出,这里所有解释的假设都没有成立。 由于HashMap内部也使用HashMap ,因此即使在应用程序启动之前,使用HashMap时也有可能遇到已编译的代码。