使用volatile long有意义吗?

60

我偶尔使用volatile实例变量,在有两个线程从中读取/写入并且不想承担获取锁的开销(或潜在死锁风险)的情况下;例如,一个定时器线程周期性地更新一个int ID,该ID作为某个类上getter方法的公开属性:

public class MyClass {
  private volatile int id;

  public MyClass() {
    ScheduledExecutorService execService = Executors.newScheduledThreadPool(1);
    execService.scheduleAtFixedRate(new Runnable() {
      public void run() {
        ++id;
      }
    }, 0L, 30L, TimeUnit.SECONDS);
  }

  public int getId() {
    return id;
  }
}

我的问题是:由于JLS仅保证32位读取是原子性的,因此是否有必要始终使用volatile long?(即64位)。

注意:请不要回复说使用volatile而非synchronized是预先优化的情况;我很清楚何时如何使用synchronized,但在某些情况下,volatile更可取。例如,在为单线程应用程序定义Spring bean时,我倾向于使用volatile实例变量,因为不能保证Spring上下文会在主线程中初始化每个bean的属性。


11
注意,++ 不是原子操作。我建议使用 java.util.concurrent.atomic.AtomicInteger 代替。 - Tom Hawtin - tackline
谢谢Tom - 这只是一个微不足道的例子。 - Adamski
4
顺便提一下,你不需要在Spring中使用volatile。Spring在初始化你的类时会使用锁,并且 happens-before关系会为你处理好顺序问题。 - oxbow_lakes
3个回答

138
我不确定我是否正确理解了您的问题,但是JLS 8.3.1.4. volatile Fields指出:

字段可以被声明为volatile,这样Java内存模型就能保证所有线程看到的变量值都是一致的 (§17.4)。

而且,也许更重要的是JLS 17.7 Non-atomic Treatment of double and long

17.7 double和long的非原子性处理
[...]
针对Java编程语言内存模型,对于非volatile的long或double值的单次写入将被视为两次分开的写入:每个32位的一次。这可能导致一个线程从一个写入中看到64位值的前32位,从另一个写入中看到64位值的后32位。 对volatile long和double值的写入和读取总是原子的。 对引用的写入和读取总是原子的,无论它们是否被实现为32位或64位值。

那就是说,“整个”变量都受volatile修饰符的保护,而不仅仅是两个部分。这使我认为使用volatile来保护long比保护int更加重要,因为即使对于非volatile的long/double,甚至连读操作也不是原子的。

float 值怎么样,它也是 16 字节吗? - shaoyihe
我无法想象是什么原因导致一个线程从一次写入中看到了64位值的前32位,而从另一次写入中看到了后32位。你能告诉我吗? - MsA
一个线程向变量写入64位值。由于这在所有硬件上都不是原子操作,因此实际上可以分成两个写操作。另一个线程可能会在这两个写操作之间尝试读取该值。然后它将看到基于旧值的32位和新值的32位的值。请参见下面的Adam的答案。 - aioobe
这行代码的意思是什么 - “无论实现为32位还是64位值,对引用的写入和读取始终是原子性的。”?我对“引用”的使用感到困惑... @aioobe,如果您能澄清一下就好了... - hagrawal7777
当运行例如 Object o = "hello"; o = "world"; 时,o 变量将始终引用 "hello" 对象或 "world" 对象。如果对 "hello" 的引用是 0x11111111,对 "world" 对象的引用是 0x22222222,那么 o 永远不会(即使是临时的)等于像 0x11112222 这样的东西。这被称为 _tearing_,如果 olong 类型,则 can 实际发生。(在此注释中进行了一些简化,例如 o 是局部变量,从未受到竞争等) - aioobe
2
@shaoyihe你从哪里得到了那16个字节?在Java中没有这个大小的原始类型。float是一个32位(==4个字节)的数量,就像int一样。 - undefined

18

这可以通过示例来证明

  • 不断地在一个标记为volatile的字段和一个未标记为volatile的字段之间切换,使所有位都设置为1或清除为0
  • 在另一个线程上读取字段值
  • 观察foo字段(没有使用volatile保护)可能以不一致的状态被读取,而bar字段(使用volatile保护)永远不会出现这种情况

代码

public class VolatileTest {
    private long foo;
    private volatile long bar;
    private static final long A = 0xffffffffffffffffl;
    private static final long B = 0;
    private int clock;
    public VolatileTest() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    foo = clock % 2 == 0 ? A : B;
                    bar = clock % 2 == 0 ? A : B;
                    clock++;
                }
            }

        }).start();
        while (true) {
            long fooRead = foo;
            if (fooRead != A && fooRead != B) {
                System.err.println("foo incomplete write " + Long.toHexString(fooRead));
            }
            long barRead = bar;
            if (barRead != A && barRead != B) {
                System.err.println("bar incomplete write " + Long.toHexString(barRead));
            }
        }
    }

    public static void main(String[] args) {
        new VolatileTest();
    }
}

输出

foo incomplete write ffffffff00000000
foo incomplete write ffffffff00000000
foo incomplete write ffffffff
foo incomplete write ffffffff00000000

请注意,这仅在我在32位虚拟机上运行时发生,而在64位虚拟机上,在几分钟内我无法得到任何错误。


2
你还可以添加为什么会得到那个输出,因为它在两个步骤中写入长整型,在第一步中写入前32位,在第二步中写入另外的32位。 - jmattheis

6
"volatile"有多个用途:
  • 保证对double/long的原子写入
  • 保证当线程A看到由线程B对易失变量进行的更改时,线程A还可以看到线程B在更改易失变量之前所做的所有其他更改(例如,在设置单元格本身之后设置数组中使用单元格的数量)。
  • 防止编译器基于只有一个线程可以更改变量的假设进行优化(例如,紧密循环while (l != 0) {})。
是否还有其他内容?

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