【问题标题】:HashMap and visibilityHashMap 和可见性
【发布时间】:2013-01-16 16:31:25
【问题描述】:

HashMap's javadoc 状态:

如果在迭代器创建后的任何时候对映射进行结构修改,除了通过迭代器自己的 remove 方法之外的任何方式,迭代器都会抛出 ConcurrentModificationException。

我构建了一个示例代码,根据规范,它应该几乎立即失败并抛出 ConcurrentModificationException;

  • Java 7 确实会立即失败
  • 但它(似乎)总是与 Java 6 一起工作(即它不会抛出承诺的异常)。

注意:Java 7 有时不会失败(比如 20 次中有 1 次)——我猜这与线程调度有关(即 2 个可运行对象没有交错)。

我错过了什么吗?为什么运行 Java 6 的版本不会抛出 ConcurrentModificationException?

实质上,有 2 个 Runnable 任务并行运行(使用倒计时闩锁使它们大致同时启动):

  • 一个正在向地图添加项目
  • 另一个是遍历地图,读取键并将它们放入数组中

然后主线程检查有多少键被添加到数组中。

Java 7 典型输出(迭代立即失败):

java.util.ConcurrentModificationException
MAX i = 0

Java 6 典型输出(整个迭代经过,数组包含所有添加的键):

最大 i = 99

使用的代码

public class Test1 {

    public static void main(String[] args) throws InterruptedException {
        final int SIZE = 100;
        final Map<Integer, Integer> map = new HashMap<Integer, Integer>();
        map.put(1, 1);
        map.put(2, 2);
        map.put(3, 3);
        final int[] list = new int[SIZE];
        final CountDownLatch start = new CountDownLatch(1);
        Runnable put = new Runnable() {
            @Override
            public void run() {
                try {
                    start.await();
                    for (int i = 4; i < SIZE; i++) {
                        map.put(i, i);
                    }
                } catch (Exception ex) {
                }
            }
        };

        Runnable iterate = new Runnable() {
            @Override
            public void run() {
                try {
                    start.await();
                    int i = 0;
                    for (Map.Entry<Integer, Integer> e : map.entrySet()) {
                        list[i++] = e.getKey();
                        Thread.sleep(1);
                    }
                } catch (Exception ex) {
                    ex.printStackTrace();
                }
            }
        };
        ExecutorService e = Executors.newFixedThreadPool(2);
        e.submit(put);
        e.submit(iterate);
        e.shutdown();

        start.countDown();
        Thread.sleep(100);
        for (int i = 0; i < SIZE; i++) {
            if (list[i] == 0) {
                System.out.println("MAX i = " + i);
                break;
            }
        }
    }
}

注意:在 x86 机器上使用 JDK 7u11 和 JDK 6u38(64 位版本)。

【问题讨论】:

  • 我会在 put 循环中添加一个睡眠,以确保它们是并发的,并且在另一个开始之前没有运行完成。
  • @PeterLawrey 它确实使它失败得更快。但我不明白为什么我展示的代码在不同版本中表现不同。我很想知道其他观察者是否相同。
  • @assylias 在 Java 6 上是否会因额外的睡眠而失败?
  • 我怀疑 await() 在 Java 6 上唤醒线程的时间不像在 Java 7 中那样多。
  • 文档规定:“请注意,不能保证迭代器的快速失败行为,因为一般来说,在存在不同步的并发修改的情况下,不可能做出任何硬保证。快速失败迭代器会尽最大努力抛出 ConcurrentModificationException。”

标签: java multithreading hashmap


【解决方案1】:

如果我们查看HashMap 源并将它们在 Java 6 和 Java 7 之间进行比较,我们会看到如此有趣的差异:

Java6 中的transient volatile int modCount; 和Java7 中的transient int modCount;

我确信这是导致上述代码行为不同的原因:

        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();

UPD:在我看来,这是一个已知的 Java 6/7 错误:http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6625725,已在最新的 Java7 中修复。

UPD-2: @Renjith 先生说,他刚刚测试并没有发现 HashMaps 实现的行为有任何差异。但我也刚刚测试过。

我的测试是:

1) 我创建了HashMap2 类,它绝对是HashMap 来自Java 6的副本。

重要的是我们需要在这里引入2个新字段:

transient volatile Set<K>        keySet = null;

transient volatile Collection<V> values = null;

2) 然后我在测试这个问题时使用了这个HashMap2 并在 Java 7 下运行它

结果:类似Java 6下的测试,即没有ConcurentModificationException

这一切都证明了我的猜想。 Q.E.D.

【讨论】:

  • 有趣 - 但如果有的话,由于 volatile 的可见性保证,它应该会使 java 6 更快地失败,不是吗?
  • @assylias 的问题是不同的线程将因为volatile 同步modCount。因此,他们都不会看到同时发生的变化。
  • @assylias 我后来发现它是已知的 Jaba 6/7 错误:bugs.sun.com/bugdatabase/view_bug.do?bug_id=6625725
  • @Andremoniy,是的,HashMap 确实使用了内在函数(你称之为“本机”)。
  • @assylias,如果你有共享/争用,写入并不便宜,因为会有一致性流量。缓存行如何更新(或逐出)取决于缓存一致性协议和缓存行状态。实际的一点是,易失性负载与 x86 上的正常负载没有什么不同,它可以防止提升和内存重新排序,但它们都是编译器障碍,而不是硬件障碍。但是,HashMap 不应由另一个线程更新,因为该结构不是线程安全/并发的。
【解决方案2】:

附带说明,ConcurrentModificationException(尽管名字很不幸)旨在检测修改跨多个线程。它旨在捕获单个线程中的修改。无论使用迭代器或其他任何东西,都保证会破坏跨多个线程(没有正确同步)修改共享 HashMap 的效果。

简而言之,无论 jvm 版本如何,您的测试都是虚假的,它只是“运气”,它做任何不同的事情。例如,这个测试可能会抛出 NPE 或其他一些“不可能的”异常,因为 HashMap 内部在查看跨线程时处于不一致的状态。

【讨论】:

  • 无论 jvm 版本如何,你的测试都是假的,它完全不同只是“运气” - 我不同意。请阅读我的回答; HashMap 的实现在 Java 6 和 Java 7 中不同
  • @jtahlborn 我知道我的测试中存在可见性问题 - 问题是关于不同版本的行为。
  • @assylias - 请重新阅读我的答案。该行为完全未定义。 CME 永远不会跨线程工作。未定义的行为改变是否跨 jvm 版本大多是没有意义的。
  • @Andremoniy - 我并不是在争论 jvm 版本之间是否存在差异,我是在解释为什么这种差异无关紧要。
  • @jtahlborn 我理解你的意思——这是一个好奇/实际问题,而不是一个理论问题。我们同意代码从根本上被破坏并且其行为未定义。
【解决方案3】:

我的理论是,在 Java 6 和 7 上,在读取器线程中创建迭代器比在写入器线程中放入 100 个条目需要更长的时间,主要是因为必须加载和初始化新类(即EntrySet, AbstractSet, AbstractCollection, Set, EntryIterator, HashIterator, Iterator

因此,在阅读器线程上执行此行时,编写器线程已经完成

    HashIterator() {
        expectedModCount = modCount;
        if (size > 0) { // advance to first entry

在 Java 6 中,由于 modCountvolatile,迭代器会看到最新的 modCount and size,因此其余的迭代顺利进行。

在 Java 7 中,modCount 不是 volatile,迭代器可能会看到陈旧的 modCount=3, size=3。在sleep(1) 之后,迭代器看到更新的modCount,并立即失败。

这个理论的一些缺陷:

  1. 理论应该在 java 7 上预测 MAX i=1
  2. 在 main() 执行之前,HashMap 可能已被其他代码迭代,因此可能已经加载了上述类。
  3. 阅读器线程看到陈旧的modCount 是可能的,但不太可能,因为它是该线程上变量的第一次读取;没有先前的缓存值。

我们可以通过在 Hashmap 中植入日志代码来找出阅读器线程正在查看的内容。

【讨论】:

  • 有趣的理论。我曾尝试添加日志记录,但它引入了一些改变结果的同步。
  • 你不能把日志保存在一个普通数组中,然后再转储它吗? expectedModCount=log[i++]=modCount
猜你喜欢
  • 2012-09-24
  • 2021-05-09
  • 1970-01-01
  • 2012-01-28
  • 1970-01-01
  • 1970-01-01
  • 2016-05-31
  • 2013-02-04
相关资源
最近更新 更多