【问题标题】:Is it thread-safe to lazily initialize references if the written value is always the same?如果写入的值始终相同,那么延迟初始化引用是否是线程安全的?
【发布时间】:2018-05-23 18:27:41
【问题描述】:

在我的应用程序中,我需要延迟设置一个变量,因为在类初始化期间我无法访问必要的方法,但我还需要可以跨多个线程访问该值。我知道我可以使用double-checked locking 来解决这个问题,但这似乎有点矫枉过正。 我需要调用的获取值的方法是幂等的,返回值永远不会改变。我想像在单线程环境中一样懒惰地初始化引用。看起来这应该可以工作,因为对引用的读写是原子的。[1][2]

这是我正在做的一些示例代码。

// views should only be accessed in getViews() since it is
// lazily initialized. Call getViews() to get the value of views.
private List<String> views;

/* ... */

private List<String> getViews(ServletContext servletContext) {

    List<String> views = this.views;

    if (views == null) {

        // Servlet Context context and init parameters cannot change after
        // ServletContext initialization:
        // https://docs.oracle.com/javaee/6/api/javax/servlet/ServletContext.html#setInitParameter(java.lang.String,%20java.lang.String)
        String viewsListString = servletContext.getInitParameter(
                "my.views.list.VIEWS_LIST");
        views = ListUtil.toUnmodifiableList(viewsListString);
        this.views = views;
    }

    return views;
}

This question about 32-bit primitives is similar,但我想确认对 Strings 和 Lists 等对象的引用行为是相同的。

看起来这应该可以正常工作,因为每个线程要么会看到null 并重新计算值(这不是问题,因为值永远不会改变)或看到已经计算的值。我在这里错过任何陷阱吗?这段代码是线程安全的吗?

【问题讨论】:

  • 如果没有同步(同步块或易失性),您最终可能会导致每个线程都有自己的列表实例(每个线程都可以看到 views == null 并初始化变量并使用自己的列表副本)
  • 对引用和原语的访问是原子的。 Java 5.0 解决了对 64 位值的原子访问问题
  • 在此实现下,每个线程可能会以不同的views 实例结束。可以吗?
  • @erickson,啊,我并没有真正考虑过,从技术上讲,所有这些实例都将包含等效数据,但我想这可能会导致潜在的内存问题。为了这个问题,让我们假设每个线程都获得它自己的实例是可以的,但我很高兴考虑到这个陷阱。谢谢!
  • @stiemannkj1 字符串的散列是一个惰性初始化的 32 位值,没有易变的语义,因为赛车计算具有相同的结果。然而,除非隔离到极其安全、性能关键的代码中,否则通常不值得这样的技巧。

标签: java multithreading concurrency volatile


【解决方案1】:

您的代码不一定是线程安全的。尽管“[w] 对引用的写入和读取始终是原子的......”[1] Java 内存模型不保证对象在被其他线程引用时将被完全初始化。 Java 内存模型只保证对象的 final 字段将在任何线程看到对它的引用之前被初始化:

只有在对象完全初始化后才能看到对该对象的引用的线程,可以保证看到该对象的最终字段的正确初始化值。

JSR-133: Java Memory Model and Thread Specification

因此,如果ListUtil.toUnmodifiableList(viewsListString); 的实现返回一个包含任何非final 字段的List 对象,那么其他线程可能会在非final 字段初始化之前看到List 引用。


例如,假设toUnmodifiableList() 方法的实现类似于:

public static List<String> toUnmodifiableList(final String viewsString) {
    return new AbstractList<String>() {
        String[] viewsArray = viewsString.split(",");
        @Override
        public String get(final int index) {
            return viewsArray[index];
        }
    };
}

线程 A 调用 getViews(servletContext) 并发现 viewsnull,因此它尝试初始化 views

在调用toUnmodifiableList() 期间,JVM 执行优化并重新排序指令,以便发生以下执行:

views = /* Reference to AbstractList<String> prior to initialization */
this.views = views;
/* new AbstractList<String>() occurs and viewsString.split(",") completes */

当线程 A 正在执行时,线程 B 在线程 A 执行 this.views = views; 之后但在 viewsString.split(",") 完成之前调用 getViews(servletContext)

现在线程 B 有一个对 this.views 的引用,其中 this.views.viewsArraynull,因此任何对 this.views.get(index) 的调用都将导致 NullPointerException


为了确保线程安全,getViews() 返回的任何对象都需要确保它只有 final 字段,以保证没有线程看到部分初始化的对象(或者您可以确保未初始化的对象)值在对象中得到正确处理,但这可能是不可能的)。我相信您需要确保getViews() 返回的对象中的所有Object 引用也只有final 字段。因此,如果您返回了包含 finalMyClass 的引用的 List,则需要确保 MyClass 的所有成员也是 final

欲了解更多信息,请查看:Partial constructed objects in the Java Memory Model

【讨论】:

    【解决方案2】:

    这个关于 32 位原语的问题类似,但我想确认对对象(如字符串和列表)的引用行为是相同的。

    是的,因为编写引用总是原子的per the JLS

    对引用的写入和读取始终是原子的,无论它们是作为 32 位还是 64 位值实现的。

    Peter Lawrey notes 从 Java 5 开始有效。

    但请注意Ivan's observation

    如果没有同步(同步块或易失性),您最终可能会导致每个线程都有自己的列表实例(每个线程都可以看到 views == null 并初始化变量并使用自己的列表副本)

    ...和erickson's question:

    在此实现下,每个线程都可能以不同的views 实例结束。可以吗?

    【讨论】:

    • Java 5.0 的最后一条语句是正确的
    • @PeterLawrey - 谢谢!我不知道这不是真的。我确实使用过 Java pre 5(实际上是 Java 1.0),但我第一次知道这一定是在 Java 5 发布之后。 :-)
    • Pre Java 5.0 它依赖于 64 位原语的实现,但是 x64 版本恰好是原子的,这意味着您遇到问题的可能性更小。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2012-03-16
    • 2011-11-17
    • 1970-01-01
    • 2014-08-23
    • 1970-01-01
    • 2016-07-18
    • 2014-07-11
    相关资源
    最近更新 更多