1.聊聊并发中的可见性和原子性

         在java里面有许多关键字为并发变成提供服务,这几年工作也频繁使用到这些关键字,但使用没有对其原理进行深究,查阅了很多资料后,今天主要聊聊Volatile关键字的实现和在并发变成中起到的作用。Volatile主要提供了多线程并发时对某个变量的提供线程间的可见性,被Volatile关键字修饰的属性每一个线程访问时都会去内存中取到最新的值,(这里指的是ThreadA线程修改了内存中的变量后ThreadB立马就能看到,而不会取到一个修改前废弃的值,因为CPU和三级缓存区中间还有一个loadbuffer和storebuffer下面会讲到),这里讲起来可能有些抽象,但是需要注意的是可见性并不等于原子性例如:

volatile int num = 0;
num++;

        这段代码在并发访问时就是不安全的,因为虽然volatile 保证了变量num在线程间的可见性,但是num++,却并不是一个原子性的操作,原子性指的是cpu在执行一段代码时保证其不会在中途切换后在进行调度,这时我们认为这是一个原子性的操作,简单来说要么彻底执行完,要么不执行。

2.Volatile的官方定义和如何提供线程可见性

        Java语言规范第三版中对volatile的定义如下: java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。----引自官方

        在java中我们要实现线程间的变量可见性无外乎三种:

        第一种:volatile

        第二种:synchronized,synchronized关键字通过对代码块加锁,保证同一时间只有一个线程对其进行访问,结束后(unlock)立刻刷新storebuffer写入主存来实现可见性。

        第三种:final关键字的可见性是指:被final修饰的字段是构造器一旦初始化完成,并且构造器没有把“this”引用传递出去,那么在其它线程中就能看见final字段的值。

        对比三者性能,因为final关键字修饰的变量独立于类存在,jvm会维护他的生命周期,这可能使跟jvm的内存模型有关,具体final变量存放在哪,希望有高手指教。在变成中合理的使用volatile关键字会比直接使用锁synchronized性能要好一点。因为加锁CPU会在线程的上下文切换这是一笔不小的性能开销。

3.CPU设计

       在这里我们讲到Volatile关键字的实现原理就不得不说下CPU的设计模型,cpu有着至少一个核心CORE、(一级缓存)L1Cache、、(二级缓存)L2Cache、(三级缓存)L3Cache、寄存器regist,读缓存loadbuffer、storebuffer写缓存,这两个缓存介于寄存器和一级缓存L1Cache之间,CPU在读的时候会先把读指令读入loadbuffer,cpu在遇到load指令时先把这个指令存入loadbuffer,然后进行下面的操作,稍后loadbuffer刷新拿着这个内存地址去L1Cache去找,如果CacheHit命中,加载进来。如果L1Cache未命中,就去更高一级的缓存去查找,如果还没有在查找更高一级的缓存直到到主存中。Storebuffer是介于寄存器和L1cache之间的写缓存,CPU的写指令会写入StoreBuffer中去,待到一个合适的时机刷新缓存区写入到主存,刷新时间并不是可以严格控制的,因为这两个缓存区的存在,所有的读写操作都不会立即表现在内存上,因为读写缓存区是异步的,所以这些指令并没有严格的顺序。以上这些就是CPU的指令重排序。

4.CPU的MESI协议

        为了保证一级缓存二级缓存的一致性而产生的协议,因为CPU每个CORE核心的L1L2缓存是相对独立的。

M(Modified):这个数据有效,但是已经被修改,与内存中的不一致,只存在于当前Cache中。

E(Exclusive):这个数据有效,和内存中的一样,没有被修改过,只存在于当前缓存中。

S(Shared):这行数据有效,和内存中的一样,存在于多个缓存中。

I(InvalidI):此行数据无效。

        每个缓存的控制器不仅知道自己的读写操作,同时也监听着其他缓存区的读写操作。

        建设现在有4个核心(COREA\B\C\D)

        当A从主存中加载了变量X=0;此时变量X的标记为E。

        当B也从主存中加载了变量x=0;此时变量X的标记都变为S。

        当C也从主存中加载了变量x,修改了变量x=1;此时A缓存中的变量和B缓存中的变量标记为I,C缓存中的变量编辑为M。

5.Volatile关键字如何实现可见性

        被Volatile关键字声明的变量在转为汇编语言的时候会出现如下内容:

0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0x0,(%esp);

        Lock指令主要做了一件事使缓存区的内容立刻刷新写入主存中,这时候其他cpu中的变量会遵从MESI协议,将缓存标志位变为I,这时候其他核心CORE想要使用这个变量都必须去内存中重新得到,因为如果没有LOCK指令的话,cpu的写入指令会存入storebuffer缓存区,从而不能立刻写入内存,这时候如果其他CORE核心去使用这个变量的话使用的依然会是对应核心CORE下缓存中的旧值。

        以下扩展内容引自http://ifeve.com/volatile/

        Volatile的使用优化

        著名的Java并发编程大师Doug lea在JDK7的并发包里新增一个队列集合类LinkedTransferQueue,他在使用Volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。

追加字节能优化性能?这种方式看起来很神奇,但如果深入理解处理器架构就能理解其中的奥秘。让我们先来看看LinkedTransferQueue这个类,它使用一个内部类类型来定义队列的头队列(Head)和尾节点(tail),而这个内部类PaddedAtomicReference相对于父类AtomicReference只做了一件事情,就将共享变量追加到64字节。我们可以来计算下,一个对象的引用占4个字节,它追加了15个变量共占60个字节,再加上父类的Value变量,一共64个字节。

/** head of the queue */
private transient final PaddedAtomicReference<QNode> head;
/** tail of the queue */
private transient final PaddedAtomicReference<QNode> tail;
static final class PaddedAtomicReference <T> extends AtomicReference <T> {
  // enough padding for 64bytes with 4byte refs
  Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
  PaddedAtomicReference(T r) {
    super(r);
  }
}
public class AtomicReference <V> implements java.io.Serializable {
  private volatile V value;
  //省略其他代码
}

        为什么追加64字节能够提高并发编程的效率呢? 因为对于英特尔酷睿i7,酷睿, Atom和NetBurst, Core Solo和Pentium M处理器的L1,L2或L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行,这意味着如果队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头尾节点,当一个处理器试图修改头接点时会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作是需要不停修改头接点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。Doug lea使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头接点和尾节点加载到同一个缓存行,使得头尾节点在修改时不会互相锁定。

        那么是不是在使用Volatile变量时都应该追加到64字节呢?不是的。在两种场景下不应该使用这种方式。第一:缓存行非64字节宽的处理器,如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个字节宽。第二:共享变量不会被频繁的写。因为使用追加字节的方式需要处理器读取更多的字节到高速缓冲区,这本身就会带来一定的性能消耗,共享变量如果不被频繁写的话,锁的几率也非常小,就没必要通过追加字节的方式来避免相互锁定。