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

时间:2016-12-02 08:41:15

标签: java java-8 java-stream

这个问题不是关于HashMap不是线程安全的,而是关于它在HotSpot和JDK代码上的特定故障模式的众所周知和记录的事实。我很惊讶这个代码在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的线程必须取消停放),还是有另一条路由导致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();

1 个答案:

答案 0 :(得分:42)

首先,它并没有可靠地失败。我设法进行了一些没有发生异常的运行。然而,这并不意味着得到的地图是正确的。每个线程也可能会成功放置自己的值,而生成的映射会错过几个映射。

但事实上,NullPointerException的失败经常发生。我创建了以下调试代码来说明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的初始容量,即使在顺序操作期间也会创建三个不同的后备阵列。每次容量增加,生成并发put错过数组更新并创建自己的数组的可能性更高。

这对于空映射的初始状态和几个尝试放置第一个键的线程尤其相关,因为所有线程可能会遇到null表的初始状态并创建自己的状态。此外,即使在阅读完成的第一个put的状态时,也会为第二个put创建一个新数组。

但是一步一步的调试显示出更多的破坏机会:

Inside the method putVal,我们在最后看到

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

换句话说,成功插入新密钥后,如果新大小超过threshold,表格将会调整大小。因此,在第一个put上,resize()在开始时被调用,因为表格为null,并且由于您指定的初始容量为0,即太低而无法存储一个映射,新容量为1,新threshold1 * loadFactor == 1 * 0.75f == 0.75f,四舍五入为0。因此,在第一个put的末尾,超出了新的threshold,并触发了另一个resize()操作。因此,如果初始容量为0,则第一个put已创建并填充两个数组,如果多个线程同时执行此操作,则会提供更高的中断机会,所有遇到初始状态。

还有另外一点。看into the resize() operation,我们看到the lines

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

换句话说,在使用旧条目填充之前,新的数组引用存储在堆中,因此即使不重新排序读取和写入,也有可能另一个线程读取没有看到旧条目的引用,包括它之前编写的条目。实际上,减少堆访问的优化可能会降低线程在紧接着的查询中看不到自己的更新的可能性。

尽管如此,它还必须注意到这里假设所有运行都是在这里解释的。由于JRE内部也使用HashMap,因此即使在应用程序启动之前,使用HashMap时也有可能遇到已编译的代码。