繁体   English   中英

初始化对象的 Java 线程安全

[英]Java Thread Safety of Initialized Objects

考虑以下类:

public class MyClass
{
    private MyObject obj;

    public MyClass()
    {
        obj = new MyObject();
    }

    public void methodCalledByOtherThreads()
    {
        obj.doStuff();
    }
}

由于 obj 是在一个线程上创建并从另一个线程访问的,因此在调用 methodCalledByOtherThread 时 obj 可以为 null 吗? 如果是这样,将 obj 声明为 volatile 是解决此问题的最佳方法吗? 将 obj 声明为 final 会有什么不同吗?

编辑:为清楚起见,我认为我的主要问题是:其他线程是否可以看到 obj 已被某个主线程初始化,或者 obj 是否过时(空)?

对于由另一个线程调用并导致问题的methodCalledByOtherThreads ,该线程必须获取对obj字段未初始化的MyClass对象的引用,即。 构造函数尚未返回的地方。

如果您从构造函数中泄露了this引用,这将是可能的。 例如

public MyClass()
{
    SomeClass.leak(this); 
    obj = new MyObject();
}

如果SomeClass.leak()方法启动一个单独的线程,该线程在this引用上调用methodCalledByOtherThreads() ,那么您会遇到问题,但无论volatile是什么,这都是正确的。

由于您没有我上面描述的内容,因此您的代码很好。

这取决于参考文献是否“不安全”地发布。 通过写入共享变量来“发布”引用; 另一个线程读取变量以获取引用。 如果没有happens-before(write, read) ,则该发布被称为不安全的。 不安全发布的一个例子是通过非易失性静态字段。

@chrylis 对“不安全发布”的解释不准确。 在构造函数退出之前泄漏this与不安全发布的概念是正交的。

通过不安全发布,另一个线程可能会观察处于不确定状态的对象(因此得名); 在您的情况下,字段obj可能对另一个线程显示为空。 除非objfinal ,否则即使宿主对象被不安全地发布,它也不会显示为空。

这太技术性了,需要进一步阅读才能理解。 好消息是,您不需要掌握“不安全发布”,因为无论如何这是一种不鼓励的做法。 最佳实践很简单:永远不要做不安全的发布; 永远不要进行数据竞争; 始终通过适当的同步读取/写入共享数据,使用synchronized, volatilejava.util.concurrent

如果我们总是避免不安全的发布,我们还需要final字段吗? 答案是不。 那么为什么某些对象(例如String )通过使用 final 字段被设计为“线程安全不可变的”? 因为假设它们可用于试图通过故意不安全发布来创建不确定状态的恶意代码。 我认为这是一个过分的担忧。 这在服务器环境中没有多大意义——如果应用程序嵌入了恶意代码,那么服务器就会受到威胁。 在 JVM 运行来自未知来源的不受信任代码的 Applet 环境中,这可能有点意义——即使如此,这也是一个不太可能的攻击向量; 这种攻击没有先例; 显然,还有许多其他更容易被利用的安全漏洞。

这段代码很好,因为在构造函数返回之前,任何其他线程都无法看到对MyClass实例的引用。

具体来说, happens-before 关系要求动作的可见效果按照它们在程序代码中列出的顺序发生,因此在构造MyClass的线程中,必须在构造函数返回之前明确分配obj ,并且实例化线程直接从没有对MyClass对象的引用的状态变为对完全构造的MyClass对象的引用。

然后,该线程可以将对该对象的引用传递给另一个线程,但所有构造都将传递发生 - 在第二个线程可以调用其上的任何方法之前。 这可能通过构造线程启动第二个线程、 synchronized方法、 volatile字段或其他并发机制发生,但所有这些都将确保在实例化线程中发生的所有操作在内存屏障之前完成通过。

请注意,如果对this的引用从构造函数内部的类中传递出来,则该引用可能会在构造函数完成之前四处浮动并被使用。 这就是所谓的对象的不安全发布,但是像您这样的代码不从构造函数调用非final方法(或直接传递对this引用)是可以的。

您的另一个线程可能会看到一个空对象。 volatile 对象可能会有所帮助,但显式锁定机制(或 Builder)可能是更好的解决方案。

查看Java 并发实践 - 示例 14.12

此类(如果按原样使用)不是线程安全的。 简而言之:Java 中的指令重新排序(Java 中的指令重新排序和发生在关系之前)并且在您的代码中实例化 MyClass 时,在某些情况下您可能会得到以下指令集:

    • 为 MyClass 的新实例分配内存;
    • 返回此内存块的链接;
    • 链接到此未完全初始化的 MyClass 可用于其他线程,它们可以调用“methodCalledByOtherThreads()”并获得 NullPointerException;
    • 初始化 MyClass 的内部结构。

    为了防止这种情况并使您的 MyClass 真正线程安全 - 您必须在“obj”字段中添加“final”或“volatile”。 在这种情况下,Java 的内存模型(从 Java 5 开始)将保证在 MyClass 的初始化过程中,只有在初始化所有内部组件时才会返回对为其分配的内存块的引用。

    有关更多详细信息,我强烈建议您阅读好书“Java Concurrency in Practice”。 第 50-51 页(第 3.5.1 节)描述了您的情况。 我什至会说 - 您无需阅读那本书就可以编写正确的多线程代码! :)

    @Sotirios Delimanolis 最初选择的答案是错误的。 @ZhongYu 的回答是正确的。

    这里存在关注的可见性问题。 因此,如果 MyClass 发布不安全,任何事情都可能发生。

    评论中有人要求提供证据 - 可以查看Java Concurrency in Practice一书中的清单 3.15:

    public class Holder { 
        private int n;
        
        // Initialize in thread A
        public Holder(int n) { this.n = n; }
        
        // Called in thread B
        public void assertSanity() { 
            if (n != n) throw new AssertionError("This statement is false."); 
        }
    
    }
    

    有人拿出一个例子来验证这段代码:

    为潜在的并发问题编写证明

    至于这篇文章的具体例子:

    public class MyClass{
        private MyObject obj;
        
        // Initialize in thread A
        public MyClass(){
            obj = new MyObject();
        }
        
        // Called in thread B
        public void methodCalledByOtherThreads(){
            obj.doStuff();
        }
    }
    

    如果 MyClass 在线程 A 中初始化,则不能保证线程 B 会看到此初始化(因为更改可能保留在线程 A 运行的 CPU 的缓存中,并且尚未传播到主内存中)。

    正如@ZhongYu 指出的那样,因为写和读发生在 2 个独立的线程上,所以没有happens-before(write, read)关系。

    为了解决这个问题,正如原作者所提到的,我们可以将私有 MyObject obj 声明为 volatile,这将确保引用本身对其他线程及时可见( https://www.logicbig.com/tutorials/core -java-tutorial/java-multi-threading/volatile-ref-object.html ) 。

    暂无
    暂无

    声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

     
    粤ICP备18138465号  © 2020-2024 STACKOOM.COM