Java中的volatile
在多线程并发编程中Synchronized 和 Volatile都扮演者重要的角色,volatile是轻量级的Synchronized,它在多处理开发中保证了共享变量的“可见性”。可见性就意味着当一个线程去修改共享变量的时候,另外一个线程还可以读到这个修改的值。
这样来说,可以使用volatile变量修饰符来替换synchronized的使用,降级执行成本,因为操作的时候无需线程上下文的切换和调度。
volatile的定义与实现的原理
在Java语言规范的里面对volatile的定义如下:
8.3.1.4. volatile Fields
The Java programming language allows threads to access shared variables (§17.1). As a rule, to ensure that shared variables are consistently and reliably updated, a thread should ensure that it has exclusive use of such variables by obtaining a lock that, conventionally, enforces mutual exclusion for those shared variables.
The Java programming language provides a second mechanism, volatile fields, that is more convenient than locking for some purposes.
A field may be declared volatile, in which case the Java Memory Model ensures that all threads see a consistent value for the variable (§17.4).
翻译:
Java编程语言允许线程访问共享变量(第17.1节)。通常,为了确保共享变量的一致性和可靠性得到更新,一个线程应该通过获取一个锁来确保它独占使用这些变量,通常这些锁会强制这些共享变量的互斥。
Java编程语言提供了第二种机制,volatile字段,这比锁定用于某些目的更方便。
可以声明一个字段volatile,在这种情况下,Java内存模型确保所有线程都为变量看到一致的值(第17.4节)。
在对volatile属性的字段进行写操作的时候,在汇编语言的级别会加入一个lock指令,加上这个指令后会在多核处理器上引发如下的操作
- 当前处理器缓存行的数据直接写回到系统内存中
- 写回系统内存的操作会使得其他CPU里缓存的该内存地址的数据无效。
但是为了提高处理速度,处理器不直接和内存进行通讯,而是先将系统内存的数据读取到内部的缓存(L1,L2或其他)后再进行操作,但是操作完缺不知道何时会写入到内存了。这样就算写回到内存中,但是如果其他处理器的缓存还是旧的,再执行运算还是会有问题。
所以在多处理下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存航设置为无效状态。当处理器对这个数据进行操作的时候,会重新从系统内存中把数据读取到处理器的缓存中。
volatile的两条实现原则
- Lock前缀指令会引起处理器缓存写回到内存。
Lock前缀指令在执行命令期间,声明处理的LOCK# 信号,在多处理器环境中,LOCK信号确保在声明该信号期间,处理器可以独占任何共享内存(LOCK命令会锁住总线,导致其他CPU不能访问总线,不能访问总线也就意味着不能访问系统内存)。但是在最近的处理器中。LOCK#信号一般不锁总线,而是锁缓存,因为锁总线的开销比较大。对于Intel486和Pentium处理器,在锁操作时候,是在总线上声明LOCK信号。但是在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声明LOCK#信号。相反,他会锁定这块内存区域的缓存并回写到内存中,并使用缓存一致性协议机制来确保修改的原子性。此操作为缓存锁定,缓存一致性机制会组织同时修改由两个以上处理器缓存的内存区域的数据。
- 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。
IA-32处理器和Intel 64处理器使用的MESI(修改,独占,共享,无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能嗅探其他处理器访问系统内存和他们的内部缓存。处理器使用嗅探技术保证他的内部缓存、系统内存和其他处理器的缓存的数据在总线上面保持一致。例如,在Pentium和P6处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将是它的缓存行设置为无效,在下次访问相同内存地址的时候,强制执行缓存行填充。
volatile的使用优化
干了什么
著名的Java并发编程大事Doug Lea在JDK7的并发包里新增一个队列集合类LinkedTransQueue,她在使用volatile变量的时候,可以使用一种追加字节的方式来优化队列的出队操作和入队操作。

LinkedTransQueue 类使用 PaddedAtomicReference 来定义队列的头结点(head)和尾结点(tail),而这个内部类PaddedAtomicReference相对于父类只做了一个事情,就是将共享变量增加到64字节。一个对象引用占用4个字节,它追加了15个变量(60个字节),再加上父类本身的value变量,一共就是64个字节。
为什么要把对象增加到64个字节呢
对于Intel i7,酷睿等处理器,的L1,L2,L3缓存的告诉缓存行是64个字节宽,不支持部分填充缓存行,这就意味着,如果队列的头结点和尾节点不足64个字节,处理器会将他们都读到同一个告诉缓存行中,在多处理器下每个处理器都会缓存相同的头尾节点,当一个处理器试图修改头结点的时候。会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己的高速缓存中的尾节点,而队列的入队操作和出队操作则需要不停地修改头、尾节点。所以在多处理器的情况下将会严重影响到队列的入队和出队的效率。
增加到64字节的方式来填满高速缓存行,避免头节点和尾节点加载到同一个缓存行中,这样修改头、尾节点的时候就不会相互锁定。
什么时候需要追加到64字节
-
缓存行为64字节宽的处理器
例如Intel I7,Pentium M等处理器都是64位的处理器。而例如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个字节宽。 -
共享变量需要被频繁的写
追加字节的方式需要处理器读取更多的字节到告诉缓存区中,这本身就会带来一定的性能消耗,如果共享变量不被频繁的写的话,锁的几率也就非常的小,也就没有必要通过追加字节的方式来避免相互锁定了。
需要注意
Java7更加的智慧,它会淘汰或者重新排列无用的字段,需要采用其他的追加字节的方式。
Java语言规范的volatile
链接:https://docs.oracle.com/javase/specs/jls/se7/html/jls-8.html#jls-8.3.1.4
网友评论