Java中的synchronized与volatile关键字

原文出处:http://hukai.me/android-training-course-in-chinese/performance/smp/index.html

Java中的”synchronized”与”volatile”关键字

“synchronized”关键字提供了Java一种内置的锁机制。每一个对象都有一个相对应的“monitor”,这个监听器可以提供互斥的访问。

“synchronized”代码段的实现机制与自旋锁(spin lock)有着相同的基础结构: 他们都是从获取到CAS开始,以释放CAS结束。这意味着编译器(compilers)与代码优化器(code optimizers)可以轻松的迁移代码到“synchronized”代码段中。一个实践结果是:你不能判定synchronized代码段是执行在这段代码下面一部分的前面,还是这段代码上面一部分的后面。更进一步,如果一个方法有两个synchronized代码段并且锁住的是同一个对象,那么在这两个操作的中间代码都无法被其他的线程所检测到,编译器可能会执行“锁粗化lock coarsening”并且把这两者绑定到同一个代码块上。

另外一个相关的关键字是“volatile”。在Java 1.4以及之前的文档中是这样定义的:volatile声明和对应的C语言中的一样可不靠。从Java 1.5开始,提供了更有力的保障,甚至和synchronization一样具备强同步的机制。

volatile的访问效果可以用下面这个例子来说明。如果线程1给volatile字段做了赋值操作,线程2紧接着读取那个字段的值,那么线程2是被确保能够查看到之前线程1的任何写操作。更通常的情况是,任何线程对那个字段的写操作对于线程2来说都是可见的。实际上,写volatile就像是释放监听器,读volatile就像是获取监听器。

非volatile的访问有可能因为照顾volatile的访问而需要做顺序的调整。例如编译器可能会往上移动一个非volatile加载操作,但是不会往下移动。Volatile之间的访问不会因为彼此而做出顺序的调整。虚拟机会注意处理如何的内存栅栏(memory barriers)。

当加载与保存大多数的基础数据类型,他们都是原子的atomic, 对于long以及double类型的数据则不具备原子型,除非他们被声明为volatile。即使是在单核处理器上,并发多线程更新非volatile字段值也还是不确定的。

Examples

下面是一个错误实现的单步计数器(monotonic counter)的示例: (Java theory and practice: Managing volatility).

class Counter {
    private int mValue;

    public int get() {
        return mValue;
    }
    public void incr() {
        mValue++;
    }
}

假设get()与incr()方法是被多线程调用的。然后我们想确保当get()方法被调用时,每一个线程都能够看到当前的数量。最引人注目的问题是mValue++实际上包含了下面三个操作。

reg = mValue
reg = reg + 1
mValue = reg

如果两个线程同时在执行incr()方法,其中的一个更新操作会丢失。为了确保正确的执行++的操作,我们需要把incr()方法声明为“synchronized”。这样修改之后,这段代码才能够在单核多线程的环境中正确的执行。

然而,在SMP的系统下还是会执行失败。不同的线程通过get()方法获取到得值可能是不一样的。因为我们是使用通常的加载方式来读取这个值的。我们可以通过声明get()方法为synchronized的方式来修正这个错误。通过这些修改,这样的代码才是正确的了。

不幸的是,我们有介绍过有可能发生的锁竞争(lock contention),这有可能会伤害到程序的性能。除了声明get()方法为synchronized之外,我们可以声明mValue为“volatile”. (请注意incr()必须使用synchronize) 现在我们知道volatile的mValue的写操作对于后续的读操作都是可见的。incr()将会稍稍有点变慢,但是get()方法将会变得更加快速。因此读操作多于写操作时,这会是一个比较好的方案。(请参考AtomicInteger.)

下面是另外一个示例,和之前的C示例有点类似:

class MyGoodies {
    public int x, y;
}
class MyClass {
    static MyGoodies sGoodies;

    void initGoodies() {    // runs in thread 1
        MyGoodies goods = new MyGoodies();
        goods.x = 5;
        goods.y = 10;
        sGoodies = goods;
    }

    void useGoodies() {    // runs in thread 2
        if (sGoodies != null) {
            int i = sGoodies.x;    // could be 5 or 0
            ....
        }
    }
}

这段代码同样存在着问题,sGoodies = goods的赋值操作有可能在goods成员变量赋值之前被察觉到。如果你使用volatile声明sGoodies变量,你可以认为load操作为atomic_acquire_load(),并且把store操作认为是atomic_release_store()。

(请注意仅仅是sGoodies的引用本身为volatile,访问它的内部字段并不是这样的。赋值语句z = sGoodies.x会执行一个volatile load MyClass.sGoodies的操作,其后会伴随一个non-volatile的load操作::sGoodies.x。如果你设置了一个本地引用MyGoodies localGoods = sGoodies, z = localGoods.x,这将不会执行任何volatile loads.)

另外一个在Java程序中更加常用的范式就是臭名昭著的“double-checked locking”:

class MyClass {
    private Helper helper = null;

    public Helper getHelper() {
        if (helper == null) {
            synchronized (this) {
                if (helper == null) {
                    helper = new Helper();
                }
            }
        }
        return helper;
    }
}

上面的写法是为了获得一个MyClass的单例。我们只需要创建一次这个实例,通过getHelper()这个方法。为了避免两个线程会同时创建这个实例。我们需要对创建的操作加synchronize机制。然而,我们不想要为了每次执行这段代码的时候都为“synchronized”付出额外的代价,因此我们仅仅在helper对象为空的时候加锁。

在单核系统上,这是不能正常工作的。JIT编译器会破坏这件事情。请查看4)Appendix的“‘Double Checked Locking is Broken’ Declaration”获取更多的信息, 或者是Josh Bloch’s Effective Java书中的Item 71 (“Use lazy initialization judiciously”)。

在SMP系统上执行这段代码,引入了一个额外的方式会导致失败。把上面那段代码换成C的语言实现如下:

if (helper == null) {
    // acquire monitor using spinlock
    while (atomic_acquire_cas(&this.lock, 0, 1) != success)
        ;
    if (helper == null) {
        newHelper = malloc(sizeof(Helper));
        newHelper->x = 5;
        newHelper->y = 10;
        helper = newHelper;
    }
    atomic_release_store(&this.lock, 0);
}

此时问题就更加明显了: helper的store操作发生在memory barrier之前,这意味着其他的线程能够在store x/y之前观察到非空的值。

你应该尝试确保store helper执行在atomic_release_store()方法之后。通过重新排序代码进行加锁,但是这是无效的,因为往上移动的代码,编译器可以把它移动回原来的位置:在atomic_release_store()前面。 (这里没有读懂,下次再回读)

有2个方法可以解决这个问题:

  • 删除外层的检查。这确保了我们不会在synchronized代码段之外做任何的检查。
  • 声明helper为volatile。仅仅这样一个小小的修改,在前面示例中的代码就能够在Java 1.5及其以后的版本中正常工作。

下面的示例演示了使用volatile的2各重要问题:

class MyClass {
    int data1, data2;
    volatile int vol1, vol2;

    void setValues() {    // runs in thread 1
        data1 = 1;
        vol1 = 2;
        data2 = 3;
    }

    void useValues1() {    // runs in thread 2
        if (vol1 == 2) {
            int l1 = data1;    // okay
            int l2 = data2;    // wrong
        }
    }
    void useValues2() {    // runs in thread 2
        int dummy = vol2;
        int l1 = data1;    // wrong
        int l2 = data2;    // wrong
    }

请注意useValues1(),如果thread 2还没有察觉到vol1的更新操作,那么它也无法知道data1或者data2被设置的操作。一旦它观察到了vol1的更新操作,那么它也能够知道data1的更新操作。然而,对于data2则无法做任何猜测,因为store操作是在volatile store之后发生的。

useValues2()使用了第2个volatile字段:vol2,这会强制VM生成一个memory barrier。这通常不会发生。为了建立一个恰当的“happens-before”关系,2个线程都需要使用同一个volatile字段。在thread 1中你需要知道vol2是在data1/data2之后被设置的。(The fact that this doesn’t work is probably obvious from looking at the code; the caution here is against trying to cleverly “cause” a memory barrier instead of creating an ordered series of accesses.)

展开阅读全文

没有更多推荐了,返回首页