什么情况下需要原子引用?

9

Java 中的原子引用分配是什么意思?

  • 我理解 long 和 double 原子分配的含义:线程可以看到部分构造的数字,
  • 但是对于对象,我不理解,因为分配并不意味着复制,只是指向内存中的地址。

如果 Java 中的引用分配不是原子性的,会有什么问题呢?

3个回答

13

这意味着您将永远不会收到损坏的引用。假设您有以下类:

class MyClass {
    Object obj = null;
}

在内存中,obj 是一个空指针,通常它是一个类似于 0x00000000 的整数。 然后假设你有一个赋值语句在一个线程中:

this.obj = new Object();

假设在内存中为new Object()分配了一个指针,如0x12345678。引用原子性确保当您从另一个线程检查obj时,您将获得一个空指针(0x00000000)或指向新对象的指针(0x12345678)。但是在任何情况下,您都无法获得部分分配的引用(例如指向不存在的0x12340000)。

这可能看起来很明显,但是在CPU架构和内存对齐方面,此类问题可能会出现在低级语言(如C)。例如,如果指针未对齐并且跨越缓存行,则可能无法同步更新它。为避免这种情况,Java虚拟机始终对齐指针,因此它们永远不会跨越缓存行。

因此,如果Java引用非原子性,那么从另一个线程写入的引用进行解引用时,可能会得到之前或之后引用的对象而不是随机内存位置(这可能导致分段错误、堆损坏或任何其他灾难)。


部分构建的引用。我认为这行有点不正确。 - Chetan Kinger
1
我想要表达的是我们需要原子引用的原因是为了避免线程使用部分构造的对象的引用。你的回答应该是“部分构造的对象的引用”,而不是“部分构造的引用”。(在我看来) - Chetan Kinger
1
据我所知,64位系统上的指针通常是长整型(8字节),而不是整型。无论如何,感谢您提供清晰的解释,并且我发现以下答案对于理解背景非常有帮助:https://dev59.com/42fWa4cB1Zd3GeqPfklX#11964034 和 https://dev59.com/A2Up5IYBdhLWcg3wj4Lu#15201349 - vanOekel
@vanOekel,我只是用32位作为举例。实际上,Hotspot JVM在64位系统上通常也使用32位引用:如果您将-Xmx设置为低于4Gb,则所有引用都将适合32位,如果您将-Xmx设置为低于32Gb,则有一个“压缩oops”功能,也允许您将指针适合32位。指针的确切长度未在JVM规范中指定,取决于实现(我想知道是否有48位指针有用?..) - Tagir Valeev
2
如果你能够获得一个已损坏的引用,那将是一个巨大的安全漏洞。 - user253751
显示剩余2条评论

10

让我们考虑经典的双重检查锁定示例,以理解为什么引用需要是原子的:

class Foo {
    private Helper result;
    public static Helper getHelper() {
        if (result == null) {//1
            synchronized(Foo.class) {//2
               if (result == null) {//3
                    result = new Helper();//4
                }
            }
        }
        return result//5;
    }

    // other functions and members...
}

考虑两个线程将调用getHelper方法:

  1. Thread-1执行第1行并发现resultnull
  2. Thread-1在第2行上获取了一个类级别的锁
  3. Thread-1在第3行发现resultnull
  4. Thread-1开始实例化新的Helper
  5. 当Thread-1仍在第4行实例化新的Helper时,Thread-2执行第1行。

步骤4和5是不一致可能出现的地方。有可能在第4步中,对象尚未完全实例化,但是result变量已经具有部分创建的Helper对象的地址。如果第5步在Helper对象完全初始化之前执行,即使只有一纳秒,Thread-2也会看到result引用不为null,并可能返回对部分创建的对象的引用。

解决此问题的一种方法是将result标记为volatile或使用AtomicReference。话虽如此,上述情况在实际世界中高度不太可能发生,并且有更好的方法来实现Singleton而不是使用双重检查锁定。

这里提供了使用AtomicReference实现双重检查锁定的示例:

private static AtomicReference instance = new AtomicReference();

public static AtomicReferenceSingleton getDefault() {
     AtomicReferenceSingleton ars = instance.get();
     if (ars == null) {
         instance.compareAndSet(null,new AtomicReferenceSingleton());
         ars = instance.get();
     }
     return ars;
}

如果您想知道为什么第5步会导致内存不一致,请看这个答案(如评论中由pwes建议)


@ChetanKinger - 解决问题的方法是将结果标记为volatile或使用AtomicReference,但是volatile只与变量本身的修改相关,而不涉及它所引用的对象,对吗? - Nirmal
1
@user3320018 AtomicReference的代码在内部使用volatile来引用对象。这与对象的内容无关,必须在需要时执行相同操作,或者使用适当的同步。 - pwes
好的,很酷。另一个问题 - 所以引用(不是堆上的具体对象)赋值本身需要两个操作,我是对的吗? - Nirmal
据我所知,创建内存位置并进行初始化肯定是一个多步骤的过程,这也是不一致性可能出现的地方。 - Chetan Kinger

5
我假设您问的是AtomicReference<V>
该概念是,如果两个或多个线程读取或更新引用类型变量的值,则可能会得到意外结果。例如,假设每个线程都检查某个引用类型变量是否为null,如果为null,则创建该类型的实例并更新该引用变量。
如果两个线程同时看到该变量为null,则可能导致创建两个实例。如果您的代码依赖于所有线程使用该变量引用的相同实例,则会遇到麻烦。
现在,如果您使用AtomicReference<V>,则可以通过使用compareAndSet(V expect, V update)方法来解决此问题。因此,仅当其他线程没有比它更快时,线程才会更新变量。
例如:
static AtomicReference<MyClass> ref = new AtomicReference<> ();

... 
// code of some thread
MyClass obj = ref.get();
if (obj == null) {
    obj = new MyClass();
    if (!ref.compareAndSet (null, obj)) // try to set the atomic reference to a new value
                                        // only if it's still null
        obj = ref.get(); // if some other thread managed to set it before the current thread,
                         // get the instance created by that other thread
}

1
这并不完美,因为你可能会创建两个MyClass对象,这可能是不希望的。在内部使用普通的同步机制会有所帮助。 - pwes
事实上,经典的单例示例并不是展示这个问题的好方法。 - Kayaman
@pwes 是的,这段代码可能会创建一个多余的 MyClass 实例,但它不会被分配给原子引用,并且所有线程都将使用相同的实例。我同意这不是一个完美的例子。 - Eran

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