什么实现细节使此代码如此容易失败?

2022-09-01 03:55:09

这个问题不是关于众所周知的和有记录的不是线程安全的事实,而是关于它在HotSpot和JDK代码上的特定故障模式。我对这个代码在NPE中失败的程度感到惊讶:HashMap

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来自哪里并不神秘:在尝试拆箱的步骤中。它在 5 次运行中大约 4 次失败。.map(m::get)null

在我的机器上报告 8,因此假定长度 5 的范围被拆分为 5 个子任务,每个子任务只有一个成员。我还假设我的代码在解释模式下运行。它可能调用 JIT 编译或方法,但顶层被解释,因此排除了状态加载到线程本地内存(寄存器/堆栈)中的任何变化,从而延迟了另一个线程对更新的观察。如果五个操作中的一些没有在同一时间在不同的内核上执行,我不希望它破坏内部结构。鉴于工作量很少,单个任务的时间安排必须非常精确。Runtime#availableProcessors()HashMapStreamHashMapputHashMap

它真的是精确的计时(线程必须取消停放),还是有另一种途径导致Oracle / OpenJDK HotSpot失败?我当前的版本是commonPool

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

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

但事实上,失败的发生率很高。我创建了以下调试代码来说明 的工作原理:NullPointerExceptionHashMap

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

如您所见,由于 的初始容量,即使在顺序操作期间也创建了三个不同的后备阵列。每次增加容量时,不规则并发错过阵列更新并创建自己的阵列的可能性就越高。0put

这对于空映射的初始状态和尝试放置其第一个键的多个线程尤其重要,因为所有线程都可能遇到表的初始状态并创建自己的表。此外,即使读取已完成的第一个的状态,也会为第二个数组创建一个新数组。nullputput

但是,逐步调试揭示了更多的中断机会:

putVal 方法内部,我们看到最后

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

换句话说,在成功插入新键,如果新大小超过 .因此,在第一个 ,在开始时调用,因为该表是,并且由于您指定的初始容量是,即太低而无法存储一个映射,因此新容量将是,而新容量将是,四舍五入为 。因此,在第一个操作结束时,新的操作被超越并触发。因此,初始容量为 ,第一个已经创建并填充了两个数组,如果多个线程同时执行此操作,则中断的机会要高得多,并且所有线程都遇到初始状态。thresholdputresize()null01threshold1 * loadFactor == 1 * 0.75f == 0.75f0putthresholdresize()0put

还有一点。查看 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)

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

尽管如此,还必须指出,在这里解释所有内容的假设是没有根据的。由于 JRE 也在内部使用,甚至在应用程序启动之前,在使用 时也有可能遇到已编译的代码。HashMapHashMap


答案 2

推荐