操作系统角度下的 Volatile 变量(下)

243 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第24天,点击查看活动详情

3 JVM的具体实现

现代JVM基本都按这表格实现:

这表格描述连续的两个读写动作,JVM应如何处理。最左列代表第一个动作,第一行代表第二个动作。表格内容使用LoadLoad、LoadStore、StoreStore、StoreLoad四种内存屏障,分别表示第一个动作和第二个动作之间应该插入什么类型的内存屏障。

不同体系结构下,这四类barrier实际含义不同:

  • x86采用TSO模型,所以它根本没有定义LoadLoad、LoadStore和StoreStore这三种barrier
  • x86上只有StoreLoad barrier有意义

而Arm由于存在单向barrier,所以:

  • LoadLoad、LoadStore barrier可使用acquire load代替
  • LoadStore和StoreStore barrier也可使用release store代替
  • StoreLoad barrier就只能使用dmb代替

表格第三行刚好就对应arm的acquire load barrier,所以arm JVM实现volatile读就会使用acquire load代替。表格第四列则刚好对应arm的release store barrier,同时,arm上的JVM在实现volatile写的时候,就可以使用release store来代替。

回首案例:

只要将变量 y 改成 volatile,就相当于在第8行、第9行之前增加了StoreStore barrier,同时在第15行、第16行处增加LoadLoad barrier。

只要JVM遵守JMM,不管在什么平台,最后运行结果都一样。案例一把变量 y 修改成volatile修饰的,就不会再出现在x86上不会打印Error,而在Arm有机率打印Error的情况。所有平台运行结果的一致性由JVM遵守JMM来保证的。

HB是种理论模型,它没有规定控制流依赖和数据流依赖。但实际CPU中,这两种依赖都存在,这是JVM实现的基础。所以JVM实现中,主要参考Doug Lea所写的Cookbook中的建议。实用角度,Java程序员就可从Doug Lea给的表格去理解volatile,而不必参考JSR 133。

4 AQS

有很多使用volatile变量的读写来保证代码执行顺序的例子,以CountDownLatch为例,它有个内部类Sync,定义如下:

private static final class Sync extends AbstractQueuedSynchronizer {
    Sync(int count) {
        setState(count);
    }
​
    int getCount() {
        return getState();
    }
​
    protected int tryAcquireShared(int acquires) {
        return (getState() == 0) ? 1 : -1;
    }
​
    protected boolean tryReleaseShared(int releases) {
        // Decrement count; signal when transition to zero
        for (;;) {
            int c = getState();
            if (c == 0)
                return false;
            int nextc = c-1;
            if (compareAndSetState(c, nextc))
                return nextc == 0;
        }
    }
}
  • tryAcquireShared代表这方法有acquire语义

  • tryReleaseShared则代表了这个方法具有release语义

    可推测getState里面应有acquire语义

继续看AbstractQueuedSynchronizer的代码

public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer
    implements java.io.Serializable {
​
    /**
     * The synchronization state.
     */
    private volatile int state;
​
    /**
     * Returns the current value of synchronization state.
     * This operation has memory semantics of a {@code volatile} read.
     * @return current state value
     */
    protected final int getState() {
        return state;
    }
​
    /**
     * Sets the value of synchronization state.
     * This operation has memory semantics of a {@code volatile} write.
     * @param newState the new state value
     */
    protected final void setState(int newState) {
        state = newState;
    }
​
    /**
     * Atomically sets synchronization state to the given updated
     * value if the current state value equals the expected value.
     * This operation has memory semantics of a {@code volatile} read
     * and write.
     *
     * @param expect the expected value
     * @param update the new value
     * @return {@code true} if successful. False return indicates that the actual
     *         value was not equal to the expected value.
     */
    protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

state是个volatile变量,根据JMM模型,可知getState方法是种带有acquire语义的读。

在为state变量赋值时,AQS提供两个方法:

  • setState
  • compareAndSetState

setState是带有release语义的写。为何还提供comareAndSet?因为compareAndSetState,不仅强调release语义,还有原子性语义。这操作包含取值,比较和赋值三动作,若比较操作不成功,则赋值操作不会发生。

因此,内存屏障与原子操作是两个不同概念:

  • 内存屏障强调可见性
  • 原子操作强调多个步骤要么都完成,要么都不做。即一个操作中的多个步骤是不能存在有些完成了,有些没完成的状态的

5 线程安全单例模式

全局只能生成唯一的对象。如何才能写出线程安全单例模式代码?

单线程最基本的单例模式:

class Singleton {
    private static Singleton instance;
    
    public int a;
    
    private Singleton() {
      a = 1;
    }
    
    public static Singleton getInstance() {
        if (instance == null)
            instance = new Singleton();
        return instance;
    }
}

构造函数私有。除了在getInstance这个静态方法可使用“new Singleton”的方式进行对象的创建,整个工程中的其他任意位置都不能再使用这种方法创建。

要想得到Singleton的实例就只能用getInstance静态方法。而这个方法每一次都会返回同一个对象。所以这就保证了全局只能产生一个Singleton实例。

但这写法不是线程安全:

  • 假设第一个线程调用getInstance时,看到instance变量的值为null,它就会创建一个新的Singleton对象,然后将其赋值给instance变量
  • 第二个线程随后调用getInstance时,它仍可能看到instance变量的值为null,然后也创建一个新的Singleton对象

为解决问题,可将getInstance方法改为同步方法,为调用这个方法加锁:

class Singleton {
    private static Singleton instance;
    public int a;
    private Singleton() {
        a = 1;
    }
    public synchronized static Singleton getInstance() {
        if (instance == null)
            instance = new Singleton();
        return instance;
    }
}

线程安全了。线程1还未执行完getInstance,线程2就开始执行的情况,在加锁以后就不会再出现了。但访问加锁的方法很低效。

所以:

class Singleton {
    private static Singleton instance = new Singleton();
    
    public int a;
    private Singleton() {
        a = 1;
    }
    
    public static Singleton getInstance() {
        return instance;
    }
}

第二行是在类加载的时候执行的,而类加载过程是线程安全的,所以不管有多少线程调用getInstance方法,它的返回值都是第二行所创建的对象。

这种创建方式有别于第一种:

  • 第一种单例模式的实现是在第一次调用getInstance时,它是在不得不创建的时候才去创建新的对象,所以这种方式被称为懒汉式
  • 第二种实现则是在类加载时就将对象创建好了,所以这种方式被称为饿汉式

还有的人既想使用懒汉式进行创建,又希望程序的效率比较好,所以提出双重检查(Double Check):

class Singleton {
    // 非核心代码略
    
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

大多数情况下,instance变量的值都不为null,所以这个方法大多数时候都不会走到加锁的分支里。如果instance变量值为null,则通过在Singleton.class对象上进行加锁,来保证对象创建的正确性,看上去这个实现非常好。

但是经过我们这节课的讲解,你就能理解这个写法在多核体系结构上还是会出现问题的。假设线程1执行到第8行,在创建Singleton变量的时候,由于没有Happens-Before的约束,所以instance变量和instance.a变量的赋值的先后顺序就不能保证了。

如果这时线程2调用了getInstance,它可能看到instance的值不是null,但是instance.a的值仍然是一个未初始化的值,也就是说线程2看到了instance和instance.a这两个变量的赋值乱序执行了。

这显然是一个写后写的乱序执行,所以修改的办法很简单:只需要将instance变量加上volatile关键字,即可把这个变量的读变成acquire读,写变成release写。这样,我们才真正地正确实现了饿汉式和懒汉式的单例模式。

6 总结

JSR 133描述的Java内存模型是理论模型,规则少,以至于连控制流依赖和数据流依赖都没有规定,这导致JSR133文档讨论了很多在现实中根本不存在的情况。

volatile是为变量的读写增加happens before关系,结合具体的CPU实现,就相当于是为变量的读增加acquire语义,为变量的写增加release语义。

参考