是否有必要将一个原始的实例变量声明为 volatile?

4

为了尝试多线程概念,我正在实现自己的AtomicInteger版本,使用悲观锁定。它看起来像这样:

public class ThreadSafeInt {
    public int i; // Should this be volatile?

    public ThreadSafeInt(int i) {
        this.i = i;
    }

    public synchronized int get() {
        return i;
    }

    public synchronized int getAndIncrement() {
        return this.i++;
    }
    // other synchronized methods for incrementAndGet(), etc...
}

我写了一个测试,它需要一个ThreadSafeInt实例,并将其分配给数百个线程。每个线程都会调用getAndIncrement 100,000次。我看到的是所有增量都正确发生,并且整数值恰好为 (线程数) * (每个线程的增量数),即使我没有在基本实例变量i上使用volatile。我本来期望如果我没有将i设置为volatile,则会出现许多可见性问题,例如线程1将i从0增加到1,但线程2仍然看到0的值并仅将其增加到1,导致最终值小于正确值。
我知道可见性问题是随机发生的,并且可能取决于环境属性,因此尽管存在固有的可见性问题,我的测试似乎可以正常工作。因此,我倾向于认为volatile关键字仍然是必要的。
但这是否正确呢?还是我的代码存在某些属性(例如仅仅是原始变量等),可以令我相信不需要使用volatile关键字呢?
1个回答

5
尽管在原始实例变量i上没有使用volatile,但我希望如果我不将i设为volatile,则会出现许多可见性问题
通过使您的“getAndIncrement()”和“get()”方法同步化,修改i的所有线程都可以正确地锁定它以进行值的更新和检索。同步块使得i不需要是volatile,因为它们还确保内存同步。
话虽如此,您应该使用AtomicInteger来代替,它包装了一个volatile int字段。AtomicInteger的getAndIncrement()方法更新值而无需采用同步块,这样既快速又安全。
public final AtomicInteger i = new AtomicInteger();
...
// no need for synchronized here
public int get() {
    return i.get();
}
// nor here
public int getAndIncrement() {
    return i.getAndIncrement();
}

我经常遇到可见性问题,比如线程1将i从0增加到1,但线程2仍然看到值为0并只把它增加到1,导致最终值小于正确值。
如果你的get()方法没有同步,则你的增量可能处理正确,但其他线程不会正确地发布i的值。但是,当这两种方法都是同步的时,它确保了对读写内存的同步。同步还执行锁定,以便您可以进行i++。再次强调,AtomicInteger更有效地处理内存同步和增量竞争条件。
更具体地说,当进入synchronized块时,它会跨越读取内存屏障,这与从volatile字段读取相同。当退出synchronized块时,它会跨越写入内存屏障,这与写入volatile字段相同。与synchronized块的区别在于,还有锁定来确保一个人一次只能锁定特定的对象。

你是说synchronized不仅保证代码路径一次只能由一个线程执行,而且还保证在synchronized块内部触及的任何变量都被视为volatile?我认为即使使用synchronize,两个线程观察到的变量值可能也不同 - 所以我认为你的意思是这种情况不会发生。 - russell
@russell 这实际上意味着 i++,通常不是原子操作,因为 synchronized 被赋予了原子性。 - Kedar Mhaswade
1
@russel 当编写和读取方法都被同步时,就像你的代码一样,普通字段变量被视为易失性。学习“Java内存模型”以开发有意义的测试程序。 - Alexei Kaigorodov
1
我在我的问题末尾添加了一些内容,@russell解释了内存屏障以及volatilesynchronized之间的相似之处(和区别)。 - Gray

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