以线程安全的方式延迟初始化Java Map

16

我需要对一个map及其内容进行惰性初始化。到目前为止,我有以下代码:

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
    }
}

如果一个线程在someMap为空的时候进来,然后把字段初始化为new HashMap,并且在加载映射中的数据时,另一个线程执行了getValue操作,那么它可能无法获取数据(即使数据可能已经存在)。

我该如何确保只有第一次调用getValue时才加载映射中的数据。

请注意,初始化后key可能不存在于映射中。另外,也可能在初始化后映射为空。


13
此外,在 synchronized(someMap)这一行会收到NullPointerException异常,因为someMap是空的。 - Idolon
3
在这个帖子中,有些人没有意识到并发编程是多么困难。 - John Vint
1
@JohnVint 在开玩笑吗? - CLOVIS
3个回答

27

双重检查锁定

为了正确地使用双重检查锁定,需要完成几个步骤,你缺少其中两个。

首先,你需要将someMap变成一个volatile变量。这样其他线程在进行更改时会看到更改的内容,但必须等待更改完成后才能看到。

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

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

    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...这一行,那么someMap就不再是null。这意味着其他对get的调用将会看到它,并且永远不会尝试进入synchronized块。它们将尝试从映射中获取值,而不等待数据库调用完成。

确保将someMap的赋值作为同步块中的最后一步可以防止这种情况发生。

unmodifiableMap

正如在评论中所讨论的,出于安全考虑,最好将结果保存在一个unmodifiableMap中,因为未来的修改将不是线程安全的。虽然这并不是绝对必要的,因为这是一个私有变量,永远不会暴露给外部,但这样做可以提高代码的安全性,以防止日后有人改变代码而没有意识到这一点。

            someMap = Collections.unmodifiableMap(tmpMap);

为什么不使用ConcurrentMap?

ConcurrentMap 可以保证单个操作(例如putIfAbsent)的线程安全性,但它无法满足此处的基本要求,即在允许从地图中读取数据之前等待地图完全填充数据。

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

为什么要在 this 上同步?

没有理由 :) 这只是展示一个有效答案的最简单方式。

更好的做法当然是在私有内部对象上同步。您已经改进了封装性,以换取略微增加的内存使用和对象创建时间。在 this 上同步的主要风险是它允许其他程序员访问您的锁对象,并可能尝试在其自己的代码中同步该对象。这将导致他们的更新和您的更新之间产生不必要的竞争,因此,使用内部锁对象会更安全。

不过,在许多情况下,单独的锁对象太过复杂。这是基于您的类的复杂性和其使用频率相对于仅在 this 上锁定的简单性而定的抉择。如果不确定,您应该使用内部锁对象并采取最安全的路线。

在类中:

private final Object lock = new Object();

在这个方法中:

synchronized(lock) {

java.util.concurrent.locks对象而言,在这种情况下它们没有任何有用的添加(尽管在其他情况下它们非常有用)。我们始终希望等待数据可用,因此标准同步块为我们提供了我们需要的确切行为。


1
这是最常见的习语。推荐使用。如果数据不会改变,我会使用Guava的不可变映射。 - Giovanni Botta
如果guava不可用,我最常见的是使用Collections.unmodifiableMap - John Vint
@JohnVint 实际上,在这种情况下,如果地图是私有的并且值永远不会改变它并不重要 - Giovanni Botta
什么?我的意思是,如果类路径上没有Guava,则最好的替代方法是Collections.unmodifiableMap。它在不允许内容更改方面与ImmutableMap具有相同的作用。 - John Vint
我想要补充的是,双重检查仅在第二个检查发生在同步块内部时才有效。 - jpaugh

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
    }
}

1

你想要懒加载你的映射表,因为值的生成是资源密集型的。通常情况下,你可以区分两种用例:

  1. 每个值的生成/存储成本相同
  2. 值的生成很昂贵,但如果你生成了一个,生成其余的并不那么昂贵(例如,你需要查询数据库)

Guava库针对这两种情况都有解决方案。使用Cache实时生成值或使用CacheLoader + loadAll批量生成值。由于初始化空缓存几乎是免费的,所以没有必要使用double check idiom:只需将Cache实例分配给private final字段即可。


据我了解,从第一眼看到缓存的情况来看,这可以帮助将项目添加到缓存中,但不能在首次创建缓存时懒惰地进行。 - Silly Freak
1
他询问了关于地图的惰性初始化。由于创建一个空缓存/映射基本上是免费的,因此没有理由不始终初始化该字段。这样,c.get(key)将永远不会抛出NPE,但内容将被惰性初始化。 - ooxi
1
我其实很喜欢这个想法,但它可能不适合使用情况。如果给定键的数据可以懒加载,那么缓存是一个很好的选择。然而,如果整个映射必须懒加载,那么我不确定这将像其他解决方案一样轻松地工作。 - Giovanni Botta
1
@GiovanniBotta 你说得对,这正是CacheLoader.loadAll的作用。由于在大多数用例中生成值的规模是线性的,所以我将我的回答限制在了常见情况下 :) - ooxi
2
@ooxi,您的回答可以从这些评论信息中受益。目前,回答本身有点简洁。 - Duncan Jones
显示剩余3条评论

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