Java并发编程之Volatile原理分析

引子

以前的文章,咱们讲到volatile的一些做用数组

  • 可见性,保证此变量对全部线程是可见的。
  • 原子性,只对任意单个volatile变量的读/写具备原子性
  • 有序性,被volatile声明过的变量会禁止指令重排序优化

今天咱们来分析一下volatile的具体用法和内存可见性/读写原子性的实现原理缓存

用法

咱们先来看看volatile的使用场景安全

可使用volatile的状况包括:并发

  • 对变量的操做不依赖当前值
  • 该变量没有包含在具备其它变量的不变式中

咱们来经过2个例子说明这些状况性能

class VolatileFeatures {
    long vl = 0L;               // 64位的long型普通变量

    //对单个的普通 变量的写用同一个锁同步
    public synchronized void set(long l) {             
       vl = l;
    }

    public void getAndIncrement () { //普通方法调用
        long temp = get();           //调用已同步的读方法
        temp += 1L;                  //普通写操做
        set(temp);                   //调用已同步的写方法
    }
    public synchronized long get() { 
        //对单个的普通变量的读用同一个锁同步
        return vl;
    }
}

上面的例子中对值的修改须要依赖当前值,可是当前值可能会同时修改,从而出错测试

public class NumberRange {
    private volatile int lower = 0;
     private volatile int upper = 10;

    public int getLower() { return lower; }
    public int getUpper() { return upper; }

    public void setLower(int value) { 
        if (value > upper) 
            throw new IllegalArgumentException(...);
        lower = value;
    }

    public void setUpper(int value) { 
        if (value < lower) 
            throw new IllegalArgumentException(...);
        upper = value;
    }
}

上述代码中,上下界初始化分别为0和10,假设线程A和B在某一时刻同时执行了setLower(8)和setUpper(5), 且都经过了不变式的检查,设置了一个无效范围(8, 5),优化

因此在这种场景下是没法保证线程安全的,须要经过sychronize保证方法setLower和setUpper在每一时刻只有一个线程可以执行。this

简单的来讲写入volatile 变量的这些有效值须要独立于任何程序的状态,包括变量的当前状态.net

常见的使用场景线程

public class ServerHandler {
    private volatile isopen;
    public void run() {
        if (isopen) {
        } else {
        }
    }
    public void setIsopen(boolean isopen) {
        this.isopen = isopen
    }
}

在并发场景中经过volatile来控制isopen在控制线程的执行逻辑

当只有一个线程能够修改字段的值,其它线程能够随时读取,那么把字段声明为volatile也是合理的。

须要注意的是声明一个引用变量为volatile,不能保证经过该引用变量访问到的非volatile变量的可见性。同理,声明一个数组变量为volatile不能确保数组内元素的可见性。volatile的特性不能在数组内传递,由于数组里的元素不能被声明为volatile

原理

下面咱们来分析一下volatile的实现原理,如何保证内存的可见性和读写的原子性

咱们经过观察volatile变量和普通变量所生成的汇编代码能够发现,操做volatile变量会多出一个lock前缀指令:

Java代码:
private volatile Singleton instance = new Singleton();

汇编代码:
0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: **lock** addl $0x0,(%esp);

在这里这个lock前缀指令,提供了如下保证:

  1. 保证多个CPU同一时间不能操做相同的缓存
  2. 将当前CPU缓存行的数据写回到主内存;
  3. 这个写回内存的操做会致使在其它CPU里缓存了该内存地址的数据无效。

CPU为了提升处理性能,并不直接和内存进行通讯,而是将内存的数据读取到内部缓存再进行操做,但操做完并不能肯定什么时候写回到内存,但对volatile变量进行写操做,当CPU执行到Lock前缀指令时,会将这个变量所在缓存行的数据写回到内存,但其它CPU缓存的仍是旧值,因此为了保证各个CPU的缓存一致性,每一个CPU经过检测在总线上传播的数据来检查本身缓存的数据有效性,当发现本身缓存行对应的内存地址的数据被修改,就会将该缓存行设置成无效状态,当CPU读取该变量时,发现所在的缓存行被设置为无效,就会从新从内存中读取数据到缓存中。

这里能够参考咱们以前的文章对cpu的原子性实现的分析

伪共享问题

在使用volatile的时候咱们还会遇到伪共享的问题

那么什么是伪共享问题

咱们首先要知道,cpu缓存加载的时候一次性最少会加载64个字节(64位处理器),这意味着若是一个队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每一个处理器都会缓存一样的头尾节点,当一个处理器试图修改头接点时会将整个缓存行锁定,那么在缓存一致性机制的做用下,会致使其余处理器要从新加载缓存,而队列的入队和出队操做是须要不停修改头接点和尾节点,因此在多处理器的状况下将会严重影响到队列的入队和出队效率。咱们可使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头接点和尾节点加载到同一个缓存行,使得头尾节点在修改时不会互相锁定,从而解决伪共享的问题。

下面咱们经过一个例子来讲明这个问题

/**
 * 伪共享优化
 *
 */
public final class FalseSharing implements Runnable {
    public static int NUM_THREADS = 4; // change
	public final static long ITERATIONS = 500L * 1000L * 1000L;
	private final int arrayIndex;
	private static VolatileLong[] longs;

	public FalseSharing(final int arrayIndex) {
		this.arrayIndex = arrayIndex;
	}

	public static void main(final String[] args) throws Exception {
		Thread.sleep(1000);
		System.out.println("starting....");
		if (args.length == 1) {
			NUM_THREADS = Integer.parseInt(args[0]);
		}

		longs = new VolatileLong[NUM_THREADS];
		for (int i = 0; i < longs.length; i++) {
			longs[i] = new VolatileLong();
		}
		final long start = System.currentTimeMillis();
		runTest();
		System.out.println("duration = " + (System.currentTimeMillis() - start));
	}

	private static void runTest() throws InterruptedException {
		Thread[] threads = new Thread[NUM_THREADS];
		for (int i = 0; i < threads.length; i++) {
			threads[i] = new Thread(new FalseSharing(i));
		}
		for (Thread t : threads) {
			t.start();
		}
		for (Thread t : threads) {
			t.join();
		}
	}

	public void run() {
		long i = ITERATIONS + 1;
		while (0 != --i) {
			longs[arrayIndex].value = i;
		}
	}
   
    @Contended  //JDK8 默认支持自动填充加上这个注解 而且加上虚拟机参数-XX:-RestrictContended
	public final static class VolatileLong {
		public volatile long value = 0L;

		//64位系统默认对象头12字节(开启压缩) 补充10个字节的无用对象让缓存行共享失效
		public long p1, p2, p3, p4, p5, p6,p7,p8,p9,p10; // 这行代码注释掉速度就慢很多
	}
}

分别测试添加一些无用字节来填充缓存行,和不填充,发现速度上差了很多,这就是伪共享带来的问题

那么是否是在使用Volatile变量时都应该追加到64字节呢?在两种场景下不该该使用这种方式。

第一:缓存行非64字节宽的处理器,如P6系列和奔腾处理器高速缓存行是32个字节宽。

第二:共享变量不会被频繁的写。由于使用追加字节的方式须要处理器读取更多的字节到高速缓冲区,这自己就会带来必定的性能消耗,共享变量若是不被频繁写的话,锁的概率也很是小,就不必追加字节

总结

本文咱们探讨了volatile的具体用法和 volatile经过CPU的Lock指令来保证内存可见性/读写原子性的实现原理 咱们还讨论了缓存行引发的伪共享问题和解决方案,

其中省略了volatile引发的重排序内容等咱们将在后面的JVM内存模型文章中继续探讨

相关文章
相关标签/搜索