在Java中,使用final Map进行双重检查锁定是否有效?

6

我正在尝试实现一个线程安全的Map缓存,我希望缓存的Strings能够进行延迟初始化。这是我第一次尝试实现:

public class ExampleClass {

    private static final Map<String, String> CACHED_STRINGS = new HashMap<String, String>();

    public String getText(String key) {

        String string = CACHED_STRINGS.get(key);

        if (string == null) {

            synchronized (CACHED_STRINGS) {

                string = CACHED_STRINGS.get(key);

                if (string == null) {
                    string = createString();
                    CACHED_STRINGS.put(key, string);
                }
            }
        }

        return string;
    }
}

在编写这段代码后,Netbeans警告我关于“双重检查锁定”,所以我开始研究它。我发现了“双重检查锁定已经过时”声明并阅读了它,但我不确定我的实现是否会遭受它所提到的问题。看起来文章中提到的所有问题都与在synchronized块内使用new运算符进行对象实例化有关。我没有使用new运算符,并且字符串是不可变的,因此我不确定该文章是否与此情况相关。这是一种在线程安全的方式在HashMap中缓存字符串吗?线程安全是否取决于createString()方法中采取的操作?

7
дЄЇдїАдєИдЄНзЫіжО•дљњзФ®ConcurrentHashMapпЉЯ - Andy Turner
1
回答你的问题:是的,只要地图的所有其他访问也同步。 - Andy Turner
"final" 和你所尝试的操作无任何关联! - user177800
@JarrodRoberson,所以在这种情况下,它是final还是不是对线程安全没有影响? - stiemannkj1
1
在这种情况下,看起来JVM无法重新排序赋值和将字符串放入映射中。问题归结为,您是否通过映射公开了未初始化的字符串?如果是这样,我怀疑volatile修复程序是否会解决此问题,因为只有映射是volatile的。那么,有没有保证字符串在放入映射中时已经完成初始化呢? - matt
显示剩余9条评论
4个回答

5

不正确,因为第一次访问是在同步块外进行的。

这在一定程度上取决于getput的实现方式。必须记住它们不是原子操作。

例如,如果它们是这样实现的:

public T get(string key){
    Entry e = findEntry(key);
    return e.value;
}

public void put(string key, string value){
    Entry e = addNewEntry(key);
    //danger for get while in-between these lines
    e.value = value;
}

private Entry addNewEntry(key){
   Entry entry = new Entry(key, ""); //a new entry starts with empty string not null!
   addToBuckets(entry); //now it's findable by get
   return entry; 
}

现在,当“put”操作仍在进行时,“get”可能不会返回“null”,而整个“getText”方法可能返回错误的值。
这个例子有点复杂,但你可以看到你的代码的正确行为依赖于映射类的内部工作。这不好。
虽然你可以查找那段代码,但你不能考虑编译器、JIT和处理器优化和内联,这些优化实际上可以改变操作顺序,就像我选择编写那个奇怪但正确的映射实现方式一样。

3

考虑使用并发哈希映射和方法Map.computeIfAbsent(),该方法会调用一个函数来计算默认值(如果键不存在于映射中)。

Map<String, String> cache = new ConcurrentHashMap<>(  );
cache.computeIfAbsent( "key", key -> "ComputedDefaultValue" );

如果指定的键还没有关联到值,则尝试使用给定的映射函数计算其值并将其输入到此映射中,除非为null。整个方法调用是原子性的,因此该函数最多只能应用于每个键一次。在计算正在进行时,其他线程对该映射的某些尝试更新操作可能会被阻止,因此计算应该简短而简单,并且不能尝试更新该映射的任何其他映射。

好主意,但是我被困在Java 1.6中 :/ - stiemannkj1

0

非平凡的问题领域:

并发编程很容易做,但正确性难以保证。

缓存很容易做,但正确性难以保证。

这两个问题与加密一样,都属于需要对问题领域及其许多微妙的副作用和行为有深入了解才能正确解决的难题。

将它们结合起来,你会得到一个比任何一个都难的问题。

这是一个非平凡的问题,你的天真实现不能以无错误的方式解决。你正在使用的 HashMap 如果没有检查和序列化任何访问,就不会是线程安全的,它不会高效,并且会导致大量争用,这将根据使用情况导致大量阻塞和延迟。

实现惰性加载缓存的正确方法是使用类似 Guava CacheCache Loader 的东西,它会透明地为您处理所有并发和缓存竞争条件。快速浏览源代码可以看出他们是如何做到的。


3
你能举个例子说明为什么这个实现会产生错误吗? - Austin
谢谢,+1 这可能要求太多了,但你能否给出一个简单的例子来说明这种情况会出错吗?就像我提到的那篇文章中的例子一样。 - stiemannkj1
阅读Guava缓存的源代码,你会明白为什么你的解决方案行不通。Google不会编写与解决问题挑战相应的代码,如果这个问题并不难解决的话。@Nathan解释了为什么你的解决方案是有问题的。 - user177800

0

不会的,ConcurrentHashMap 也无法解决这个问题。

回顾一下:双重检查习惯用于将新实例分配给变量/字段;它存在缺陷,因为编译器可以重新排序指令,这意味着该字段可以被赋值为部分构造的对象。

对于您的设置,存在一个独特的问题:map.get() 对于可能正在发生的 put() 不安全,因此可能会重新散列表。使用 Concurrent HashMap 只能解决这个问题,但不能消除虚假阳性的风险(即您认为 map 中没有条目,但实际上正在创建)。这个问题与部分构造的对象并不相关,而是涉及工作的重复执行。

至于可避免的 Guava CacheLoader:这只是一个延迟初始化回调函数,您将其提供给地图,以便在缺失时创建对象。本质上,这与将所有“如果为 null”代码放入锁中完全相同,而这肯定不会比直接同步更快。 (使用 cacheloader 的唯一合理情况是为缺失对象的工厂插入一个插件,同时将 map 传递给不知道如何创建缺失对象并且不想要告诉如何创建的类)。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接