使用原子引用在Effective Java示例中的应用

5
在《Effective Java》- 第74条中,Joshua Bloch展示了在以下代码片段中使用无参数构造函数和单独的初始化方法的安全使用方式。
abstract class AbstractFoo {
            private int x, y; // Our state
                    // This enum and field are used to track initialization

            private enum State {
                NEW, INITIALIZING, INITIALIZED
            };

            private final AtomicReference<State> init = new AtomicReference<State>(
                    State.NEW);

            public AbstractFoo(int x, int y) {
                initialize(x, y);
            }

            // This constructor and the following method allow
            // subclass's readObject method to initialize our state.
            protected AbstractFoo() {
            }

            protected final void initialize(int x, int y) {
                if (!init.compareAndSet(State.NEW, State.INITIALIZING))
                    throw new IllegalStateException("Already initialized");
                this.x = x;
                this.y = y;
                // ...Do anything else the original constructor did
                init.set(State.INITIALIZED);
            }

            // These methods provide access to internal state so it can
            // be manually serialized by subclass's writeObject method.
            protected final int getX() {
                checkInit();
                return x;
            }

            protected final int getY() {
                checkInit();
                return y;
            }

            // Must call from all public and protected instance methods
            private void checkInit() {
                if (init.get() != State.INITIALIZED)
                    throw new IllegalStateException("Uninitialized");
            }

        }

我困惑的是使用AtomicReference。他的解释是:
请注意,initialized字段是原子引用(java.util.concurrent.atomic.AtomicReference)。这是必要的,以确保在面对决心敌人时对象完整性。如果没有这个预防措施,如果一个线程在实例上调用initialize,而第二个线程试图使用它,则第二个线程可能会看到不一致状态的实例。
我不理解这如何增强对象安全性,以避免在不一致状态下使用它。在我的理解中,如果一个线程运行了initialize(),第二个线程运行任何访问器,那么在将初始化标记为完成之前,第二个线程不可能读取x或y字段的值。
我可能看到的另一个可能问题是AtomicReference应该是线程安全的(可能有volatile字段内部)。这将确保在init变量中的值更改与其他线程立即同步,从而防止在初始化已完成但执行访问器方法的线程无法看到它时获取IllegalStateException。但这是作者所说的吗?
我的推理正确吗?还是有其他解释?
3个回答

9
我是一名有用的助手,可以翻译文本。
这是一个较长的答案,听起来你已经对问题有了一些了解,所以我添加了标题,以便您可以快进跳过您已经知道的部分。
问题
多线程编程有点棘手,其中比较棘手的一点是在缺乏同步的情况下,编译器/JVM允许跨线程重新排序操作。也就是说,如果线程A执行以下操作:
field1 = "hello";
field2 = "world";

而线程B执行以下操作:

System.out.println(field2);
System.out.println(field1);

然后,线程B有可能会先打印出“world”,然后是“null”(假设这是field1的初始值)。这种情况“不应该”发生,因为您在代码中先设置了field1,再设置field2。所以如果field2已经被设置,那么field1肯定也被设置了,对吧?但并不是这样!编译器可以重新排列操作顺序,使得线程2看到的赋值顺序如下:
field2 = "world";
field1 = "hello";

(它甚至可以看到field2 = "world",而从未看到field1 = "hello",或者根本没有看到任何赋值,还有其他可能性。) 这种情况发生的原因有很多:由于编译器想要使用寄存器的方式更有效率,或者是一种更有效的方法来共享CPU核心之间的内存。重点是,这是允许的。

...即使有构造函数

这里比较难理解的一个概念是,构造函数通常不会为重排序提供任何特殊保证(除了对于final字段)。所以不要把构造函数看作是其他东西,只需把它看作是一个方法,把方法看作是一组操作,把对象状态看作是一组字段。似乎很明显,在构造函数中进行的赋值操作将被后面使用该对象的人看到(毕竟,在制作完对象之前,如何读取对象的状态?),但由于重排序,这种想法是不正确的。你所认为的foo = new ConcreteFoo()实际上是:

  • 为新的ConcreteFoo分配内存(称其为this);调用initialize,做一些事情...
  • this.x = x
  • this.y = y
  • foo = <the newly constructed object>
您可以看到,下面三个任务可以重新排序; 线程B可以将它们视为以各种方式发生,包括(但不限于):
  • foo = <the newly constructed object, with default values for all fields>
  • foo.getX(),返回0
  • this.x = x(可能是很长时间之后)
  • this.y = y未被线程B看到)

Happens-before关系

但是,有解决该问题的方法。 让我们暂时将AtomicReference放在一边......

解决问题的方法是使用 happens-before (HB)关系。 如果写入和读取之间存在HB关系,则CPU不能对上述重排序进行操作。

具体来说:

  • 如果线程A执行操作A
  • 并且线程B执行操作B
  • 并且操作A先行于操作B
  • 那么当线程B执行操作B时,它必须至少看到线程A在操作A时所看到的所有动作。换句话说,线程B至少以和线程A同样的“最新”状态看待世界。

这听起来很抽象,让我们更加具体一些。你可以用volatile字段建立happens-before边缘:一个线程写入该字段,另一个线程从中读取,则这两个线程之间存在HB关系。因此,如果线程A写入一个volatile字段,线程B从相同的字段读取,那么线程B必须在写入时看到线程A所看到的世界(呃,“至少”是这样的:线程B也可以看到一些后续操作)。

因此,假设field2volatile。在这种情况下:

Thread 1:
field1 = "hello";
field2 = "world"; // point 1

Thread 2:
System.out.println(field2); // point 2
System.out.println(field1); // point 3

在这里,点1“开始”了一个HB关系,该关系由点2“结束”。这意味着从点2开始,线程2必须看到线程1在点1看到的一切 - 具体来说,是赋值field1 =“hello”(以及field2 =“world”)。因此,线程2将按预期打印出“world\nhello”。

AtomicReferences

那么,所有这些与AtomicReference有什么关系呢?秘密在于java.util.concurrent.atomic包的javadoc中:

原子访问和更新的内存效果通常遵循volatile的规则,如Java™语言规范第17.4节所述。

换句话说,myAtomicRef.setmyAtomicRef.get之间存在HB关系。或者,如上例所示,在myAtomicRef.compareAndSetmyAtomicRef.get之间存在HB关系。

回到 AbstractFoo

如果没有AtomicReference操作,AbstractFoo中就没有建立HB关系。 如果一个线程为this.x赋值(如在构造函数中调用的initialize中所做的那样),而另一个线程读取this.x的值(如在getX期间所做的那样),则可能会出现上述重新排序问题,并且getX将返回x的默认值(即0)。

但是AbstractFoo确实采取了特定措施来建立HB关系:在分配this.x = x后,initialize还会调用init.set,而getX通过checkInit调用init.get(类似于y),在读取this.x之前确保HB关系的建立。这确保了线程2在调用getX时,在读取this.x之前能够看到和线程A在initialize结束时看到的世界一样,当时它调用了init.set。具体来说,线程2在执行操作return [this.]x之前,先看到了操作this.x = x

进一步阅读

还有其他几种建立happens-before关系的方法,但这超出了本回答的范围。它们在JLS 17.4.4中列出。

还有一个必要的参考资料JCIP,这是一本关于多线程问题的好书,特别适用于Java。


0
一方面,AtomicReference提供了happens-before机制,这就是为什么任何线程在一个线程调用init.set(State.INITIALIZED);并且查询访问器的线程调用init.get()之后都会得到完全初始化的对象。
另一方面,compareAndSet是原子性的,这就是为什么只有一个线程可以运行初始化,而且只能运行一次。作为奖励:Java原子原语是非阻塞的,这就不仅仅是synchronized。

-1
如果您尝试使用非参数构造函数创建实例,并从一个线程调用“initialize”方法,然后尝试从另一个线程使用对象的方法(如getX()),则会出现问题。如果没有AtomicReference,即使对象已正确初始化,也可能导致“checkInit”方法抛出异常,因为无法确保对“state”的更改在各个线程之间可见。 AtomicReference同步访问“state”,因此任何方法都可以获取适当的值。
因此,总之,如果不运行“initialize”,访问器无法工作,但仍有可能在不使用AtomicReference的情况下引发异常。

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