以线程安全的方式懒惰地初始化 Java 映射

我需要懒惰地初始化地图及其内容。到目前为止,我有以下代码:

class SomeClass {
    private Map<String, String> someMap = null;

    public String getValue(String key) {
        if (someMap == null) {
            synchronized(someMap) {
                someMap = new HashMap<String, String>();
                // initialize the map contents by loading some data from the database.
                // possible for the map to be empty after this.
            }
        }
        return someMap.get(key);  // the key might not exist even after initialization
    }
}

这显然不是线程安全的,因为如果一个线程在为空时出现,继续将字段初始化为并且当它仍在映射中加载数据时,另一个线程执行一个线程,并且在可能存在数据时不获取数据。someMapnew HashMapgetValue

如何确保在第一次调用发生时,数据仅在映射中加载一次。getValue

请注意,在所有初始化后,映射中可能不存在 。此外,在所有初始化后,映射可能只是空的。key


答案 1

双重检查锁定

双重检查锁定需要几个步骤才能完成才能正常工作,您缺少其中两个步骤。

首先,您需要制作一个变量。这样,其他线程在进行更改时(但在更改完成后)将看到对它所做的更改。someMapvolatile

private volatile Map<String, String> someMap = null;

您还需要对块内部进行第二次检查,以确保在您等待进入同步区域时,另一个线程尚未为您初始化它。nullsynchronized

    if (someMap == null) {
        synchronized(this) {
            if (someMap == null) {

在准备使用之前不要分配

在生成地图时,在临时变量中构造它,然后在末尾分配它。

                Map<String, String> tmpMap = new HashMap<String, String>();
                // initialize the map contents by loading some data from the database.
                // possible for the map to be empty after this.
                someMap = tmpMap;
            }
        }
    }
    return someMap.get(key); 

解释为什么需要临时地图。一旦你完成了这条线,那么它就不再是空的。这意味着其他调用将看到它,并且永远不会尝试进入块。然后,他们将尝试从映射中获取,而无需等待数据库调用完成。someMap = new HashMap...someMapgetsynchronized

通过确保分配到是同步块中防止这种情况发生的最后一步。someMap

不可修改的地图

正如评论中所讨论的,为了安全起见,最好将结果保存在一个中,因为将来的修改不会是线程安全的。对于从不公开的私有变量来说,这并不是严格要求的,但是对于未来来说,它仍然更安全,因为它可以阻止人们稍后进入并在没有意识到的情况下更改代码。unmodifiableMap

            someMap = Collections.unmodifiableMap(tmpMap);

为什么不使用 ConcurrentMap?

ConcurrentMap使单个操作(即)线程安全,但它不符合这里的基本要求,即等到映射完全填充数据后才允许从中读取。putIfAbsent

此外,在这种情况下,惰性初始化后的 Map 不会再次被修改。这将为在此特定用例中不需要同步的操作增加同步开销。ConcurrentMap

为什么要在此上进行同步?

没有理由。:)这只是为这个问题提供有效答案的最简单方法。

在私有内部对象上进行同步肯定是更好的做法。您已经改进了封装,但代价是内存使用量和对象创建时间略有增加。同步的主要风险是它允许其他程序员访问您的锁定对象,并可能尝试自己进行同步。这会导致他们的更新和你的更新之间不必要的争用,因此内部锁定对象更安全。this

实际上,尽管在许多情况下,单独的锁定对象是过度的。这是一个基于类的复杂性以及使用范围与锁定的简单性相比的判断调用。如果有疑问,您应该使用内部锁定对象并采取最安全的途径。this

在课堂上:

private final Object lock = new Object();

在方法中:

synchronized(lock) {

至于对象,在这种情况下,它们不会添加任何有用的东西(尽管在其他情况下它们非常有用)。我们总是希望等到数据可用,因此标准的同步块为我们提供了所需的行为。java.util.concurrent.locks


答案 2

我认为 TimB 很好地解释了大多数选项,但我认为最快、最明显的答案是在实例化类实例时创建它。

class SomeClass {
    private final Map<String, String> someMap = new HashMap<String, String>();

    public String getValue(String key) {
        return someMap.get(key);  // the key might not exist even after initialization
    }
}