递归ConcurrentHashMap.computeIfAbsent()调用永远不会终止。错误还是“功能”?


76

前段时间,我写了一篇关于Java 8函数式递归计算斐波纳契数的方法,其中包括ConcurrentHashMap缓存和新的有用computeIfAbsent()方法:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class Test {
    static Map<Integer, Integer> cache = new ConcurrentHashMap<>();

    public static void main(String[] args) {
        System.out.println(
            "f(" + 8 + ") = " + fibonacci(8));
    }

    static int fibonacci(int i) {
        if (i == 0)
            return i;

        if (i == 1)
            return 1;

        return cache.computeIfAbsent(i, (key) -> {
            System.out.println(
                "Slow calculation of " + key);

            return fibonacci(i - 2) + fibonacci(i - 1);
        });
    }
}

我之所以选择它,ConcurrentHashMap是因为我正在考虑通过引入并行性来使这个示例更加复杂(我最后没有提到)。

现在,让我们增加从8到的数量25并观察会发生什么:

        System.out.println(
            "f(" + 25 + ") = " + fibonacci(25));

该程序永远不会停止。在方法内部,有一个循环会永远运行:

for (Node<K,V>[] tab = table;;) {
    // ...
}

我正在使用:

C:\Users\Lukas>java -version
java version "1.8.0_40-ea"
Java(TM) SE Runtime Environment (build 1.8.0_40-ea-b23)
Java HotSpot(TM) 64-Bit Server VM (build 25.40-b25, mixed mode)

该博客文章的读者Matthias也确认了这个问题(他实际上找到了)

真奇怪 我本来预期以下两个:

  • 有用
  • 它抛出一个 ConcurrentModificationException

但是,永不停止?那似乎很危险。是虫子吗?还是我误会了一些合同?

Answers:


58

这在JDK-8062841中已解决

2011年的提案中,我在代码审查过程中发现了这个问题。JavaDoc已更新,并添加了临时修复程序。由于性能问题,在进一步重写中将其删除。

2014年的讨论中,我们探索了更好地检测和失败的方法。请注意,一些讨论已脱机到私人电子邮件中,以考虑低级更改。尽管并非每种情况都可以涵盖,但常见的情况不会自动生效。这些修复程序位于Doug的存储库中,但尚未纳入JDK版本。


5
非常有趣,谢谢分享!我的报告也被接受为JDK-8074374
Lukas Eder

1
@Ben对offtop感到抱歉,但是尽管知识库在无锁(和接近无锁)并发这样复杂的事情上做出了巨大贡献,但我对您的SO评级如此之低感到非常惊讶。
Alex Salauyou

@SashaSalauyou谢谢。我经常在评论中提供一个快速的答案,而不是花时间在更长的完整答案上。不过,评论分数不会增加声誉。不过,我可能对追逐销售代表的兴趣不足。
本·马内斯

@本不是很固定:繁荣
斯科特(Scott)

@ScottMcKinney不错!似乎可以应用相同的断言transfer,这是一个遗漏的情况。您能发电子邮件concurrency-interest@cs.oswego.edu引起道格的注意吗?
Ben Manes

56

这当然是一个“功能”。该ConcurrentHashMap.computeIfAbsent()Javadoc中写道:

如果指定的键尚未与某个值关联,则尝试使用给定的映射函数计算其值,并将其输入此映射中,除非为null。整个方法调用是原子执行的,因此每个键最多可应用一次该功能。在计算进行期间,可能会阻止其他线程在此映射上进行的某些尝试的更新操作,因此计算应简短而简单,并且不得尝试更新此映射的任何其他映射

“绝不能”字眼是一个明确的合同,我的算法侵犯,尽管不是相同的并发原因。

仍然有趣的是,没有ConcurrentModificationException。相反,该程序永远不会停止-在我看来,这仍然是一个相当危险的错误(即无限循环。或者:可能会出错的任何事情都会发生)。

注意:

HashMap.computeIfAbsent()Map.computeIfAbsent()的Javadoc不禁止这种循环的计算,这当然是可笑的高速缓存的类型Map<Integer, Integer>,不是ConcurrentHashMap<Integer, Integer>。对于子类型来说,彻底重新定义超级类型合同是非常危险的(SetSortedSet对于问候)。因此,在超级类型中也应禁止执行此类递归。


3
好发现。我建议针对JDK的错误报告/ RFE。
Axel

4
完成,让我们看看它是否被接受。如果有,我将更新一个链接。
卢卡斯·埃德

3
在我看来,ConcurrentHashMap由于其合同的另一个重要部分,不允许对其他映射进行这种类型的递归修改:“整个方法调用是原子执行的,因此每个键最多只能应用一次功能。 ”您的程序很可能违反了“无递归修改”约定,试图获取它已经持有的锁,并且自身陷入死锁状态,而不是在无限循环中运行。
murgatroid99 2015年

3
从JavaDoc:IllegalStateException - if the computation detectably attempts a recursive update to this map that would otherwise never complete
Ben Manes

2
@LukasEder即使HashMap这样也不行。bugs.openjdk.java.net/browse/JDK-8172951
Piotr

4

这与错误非常相似。因为,如果创建容量为32的缓存,程序将运行到49。有趣的是,该参数si​​zeCtl = 32 +(32 >>> 1)+ 1)= 49!可能是调整大小的原因?


1
我没有时间深入研究源代码,但我认为您是对的。我们正在不断地在生产中实现这一目标。一旦我们将CHM初始容量提高到很高的水平,这种情况就消失了。在我们重构代码之前,这就是我们要做的...
Eugene
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.