从缓存行出发理解volatile变量、伪共享False sharing、disruptor

volatilekeyword

当变量被某个线程A改动值之后。其他线程比方B若读取此变量的话,立马能够看到原来线程A改动后的值

注:普通变量与volatile变量的差别是volatile的特殊规则保证了新值能马上同步到主内存,以及每次使用前能够马上从内存刷新,即一个线程改动了某个变量的值,其他线程读取的话肯定能看到新的值。

普通变量:

写命中:当处理器将操作数写回到一个内存缓存的区域时。它首先会检查这个缓存的内存地址是否在缓存行中,假设不存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中。


术语


英文单词


描写叙述


共享变量

 
在多个线程之间可以被共享的变量被称为共享变量。

共享变量包含全部的实例变量。静态变量和数组元素。

他们都被存放在堆内存中。Volatile仅仅作用于共享变量。


内存屏障


Memory Barriers


是一组处理器指令。用于实现对内存操作的顺序限制。

备注: In the Java Memory Model a volatile field has a store barrier inserted after a write to it and a load barrier inserted
before a read of it.


缓冲行


Cache line


缓存中能够分配的最小存储单位。处理器填写缓存线时会载入整个缓存线。须要使用多个主内存读周期。


原子操作


Atomic operations


不可中断的一个或一系列操作。


缓存行填充


cache line fill


当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1。L2,L3的或全部)


缓存命中


cache hit


假设进行快速缓存行填充操作的内存位置仍然是下次处理器訪问的地址时。处理器从缓存中读取操作数。而不是从内存。


写命中


write hit


当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中。假设不存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中。


写缺失


write misses the cache


一个有效的缓存行被写入到不存在的内存区域。

单核CPU缓存结构

单核CPU缓存

多核CPU缓存

所谓缓存航就是缓存中能够分配的最小存储单位。

处理器填写缓存线时会载入整个缓存线。须要使用多个主内存读周期。

以下降到伪缓存时会介绍,多核CPU、内存的缓存系统;

Information transfer between the cache and the memory is in terms of complete cache lines, rather than individual bytes. Thus if the program needs a particular
byte, the entire cache line containing that byte is obtained from the memory.
 For example, suppose that the cache of Figure 2 was being used and the program fetches the word (two bytes) at location 0004736. If none of the cache lines contain
the 16 bytes stored in addresses 0004730 through 000473F, then these 16 bytes are transferred from the memory to one of the cache lines. Because of the spatial locality of the program, we expect that other values in the cache line thus loaded will be referenced
in the near future.

Volatile的实现原理

那么Volatile是怎样来保证可见性的呢?在x86处理器下通过工具获取JIT编译器生成的汇编指令来看看对Volatile进行写操作CPU会做什么事情。


Java代码:


instance = new Singleton();//instance是volatile变量


汇编代码:


0x01a3de1d: movb $0x0,0x1104800(%esi);

0x01a3de24: lock addl $0x0,(%esp);

有volatile变量修饰的共享变量进行写操作的时候会多第二行汇编代码,通过查IA-32架构软件开发人员手冊可知。lock前缀的指令在多核处理器下会引发了两件事情

  • 将当前处理器缓存行的数据会写回到系统内存。
  • 这个写回内存的操作会引起在其它CPU里缓存了该内存地址的数据无效。

关键点:事实上相当于线程 像缓存行写数据的时候,会锁住缓存行,是其它线程不能读。写完后失效缓存行,其它线程便能够从内存读到共享变量的最新值了;

深度解析:

处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2或其它)后再进行操作。但操作完之后不知道何时会写到内存;假设对声明了Volatile变量进行写操作。JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。可是就算写回到内存。假设其它处理器缓存的值还是旧的。再运行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。每一个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行相应的内存地址被改动,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行改动操作的时候,会强制又一次从系统内存里把数据读到处理器缓存里。

Lock前缀指令会引起处理器缓存回写到内存。Lock前缀指令导致在运行指令期间。声言处理器的 LOCK# 信号。在多处理器环境中,LOCK# 信号确保在声言该信号期间。处理器能够独占使用不论什么共享内存。

(由于它会锁住总线。导致其它CPU不能訪问总线,不能訪问总线就意味着不能訪问系统内存),可是在近期的处理器里,LOCK#信号一般不锁总线,而是锁缓存。毕竟锁总线开销比較大。在8.1.4章节有具体说明锁定操作对处理器缓存的影响。对于Intel486和Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和近期的处理器中,假设訪问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反地,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保改动的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同一时候改动被两个以上处理器缓存的内存区域数据

一个处理器的缓存回写到内存会导致其它处理器的缓存无效

IA-32处理器和Intel 64处理器使用MESI(改动,独占,共享,无效)控制协议去维护内部缓存和其它处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32 和Intel 64处理器能嗅探其它处理器訪问系统内存和它们的内部缓存。它们使用嗅探技术保证它的内部缓存,系统内存和其它处理器的缓存的数据在总线上保持一致。

比如在Pentium和P6
family处理器中。假设通过嗅探一个处理器来检測其它处理器打算写内存地址。而这个地址当前处理共享状态,那么正在嗅探的处理器将无效它的缓存行,在下次訪问同样内存地址时,强制运行缓存行填充。

Volatile的使用优化

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

追加字节能优化性能?这样的方式看起来非常奇妙,但假设深入理解处理器架构就能理解当中的奥秘。

让我们先来看看LinkedTransferQueue这个类。它使用一个内部类类型来定义队列的头队列(Head)和尾节点(tail)。而这个内部类PaddedAtomicReference相对于父类AtomicReference仅仅做了一件事情,就将共享变量追加到64字节。

我们能够来计算下,一个对象的引用占4个字节。它追加了15个变量共占60个字节,再加上父类的Value变量,一共64个字节。


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

那么是不是在使用Volatile变量时都应该追加到64字节呢?不是的。在两种场景下不应该使用这样的方式。第一:缓存行非64字节宽的处理器,如P6系列和奔腾处理器,它们的L1和L2快速缓存行是32个字节宽。第二:共享变量不会被频繁的写

由于使用追加字节的方式须要处理器读取很多其它的字节到快速缓冲区,这本身就会带来一定的性能消耗,共享变量假设不被频繁写的话。锁的几率也很小,就不是必需通过追加字节的方式来避免相互锁定。

/**
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个字节宽。第二:共享变量不会被频繁的写。由于使用追加字节的方式须要处理器读取很多其它的字节到快速缓冲区。这本身就会带来一定的性能消耗,共享变量假设不被频繁写的话。锁的几率也很小,就不是必需通过追加字节的方式来避免相互锁定。

Java并发编程实践写道:“一个理解volatile变量好的方法:想想它们的行为与SynchrosizedInteger类相似,仅仅只是用get和set方法代替了对volatile变量的读写操作。然而訪问volatile变量的操作不会加锁。也就不会引起线程的堵塞,这使volatile相对于synchronized而言,仅仅是轻量级的同步机制”

false-sharing

Memory is stored within the cache system in units know as cache lines.  Cache lines are a power of 2 of contiguous bytes which are typically 32-256 in size.  The most common cache line size is 64 bytes.   False
sharing is a term which applies when threads unwittingly impact the performance of each other while modifying independent variables sharing the same cache line.  Write contention on cache lines is the single most limiting factor on achieving scalability for
parallel threads of execution in an SMP system.  I’ve heard false sharing described as the silent performance killer because it is far from obvious when looking at code.

class {int x ,int y}  x和y被放在同一个快速缓存区,假设一个线程改动x;那么另外一个线程改动y,必须等待x改动完毕后才干实施。

L1 Cache(一级缓存)是CPU第一层快速缓存,分为数据缓存和指令缓存。内置的L1快速缓存的容量和结构对CPU的性能影响较大。只是快速缓冲存储器均由静态RAM组成,结构较复杂,在CPU管芯面积不能太大的情况下,L1级快速缓存的容量不可能做得太大。

一般serverCPU的L1缓存的容量通常在32—4096KB。

L2 Cache 都在CPU中

L3 Cache(三级缓存),分为两种。早期的是外置。如今的都是内置的。而它的实际作用即是,L3缓存的应用能够进一步减少内存延迟,同一时候提升大数据量计算时处理器的性能。减少内存延迟和提升大数据量计算能力对游戏都非常有帮助。

而在server领域添加L3缓存在性能方面仍然有显著的提升。例如具有较大L3缓存的配置利用物理内存会更有效,故它比較慢的磁盘I/O子系统能够处理很多其它的数据请求。具有较大L3缓存的处理器提供更有效的文件系统缓存行为及较短消息和处理器队列长度。

从图中能够看出为多核共享的

  

To achieve linear scalability with number of threads, we must ensure no two threads write to the same variable or cache line.  Two threads writing to the same variable can be tracked down at a code level.   To
be able to know if independent variables share the same cache line we need to know the memory layout, or we can get a tool to tell us.  Intel VTune is such a profiling tool.  In this article I’ll explain how memory is laid out for Java objects and how we can
pad out our cache lines to avoid false sharing.

可见每一个cpu核心或者线程都会可能往同一个缓存行写数据;而且对共享变量,同一时候cpu核心会有各自的缓存行

尽管两个线程改动各种独立变量。可是由于这些独立变量被放在同一个快速缓存区,性能就影响了。

測试结果。

当多核CPU线程同一时候改动在同一个快速缓存行各自独立的变量时。会不自不觉地影响性能。这就发生了伪共享False sharing,伪共享是性能的无声杀手。

当多核CPU线程同一时候改动在同一个快速缓存行各自独立的变量时,会不自不觉地影响性能。这就发生了伪共享False sharing。伪共享是性能的无声杀手。

这里强调多核。是由于单核CPU模拟出的多线程不会严格意义上同一时候訪问缓存行。所以性能影响不大

解决方便是将快速缓存剩余的字节填充填满(pad),确保不发生多个字段被挤入一个快速缓存区,以下測试结果图就是和填充后性能比較。

实现字节填充的框架有 Disruptor。在RingBuffer中实现填充。关于Disruptor可见infoQ这个视频。用1毫秒的延时得到100K+ TPS吞吐量。

JDK的ArrayQueue并行环境不见得是最快的,该视频后面讨论非常多,让人大跌眼镜啊,开放源代码多有优点啊,别人能发现你不能发现的漏洞。

Disruptor

Disruptor没有像JDK的LinkedBlockQueue等那样使用锁,针对CPU快速缓存进行了优化。

原来我们以为多个线程同一时候写一个类的字段会发生争夺,这是多线程基本原理,所以使用了锁机制,保证这个共用字段(资源)可以某个时刻仅仅能一个线程写,可是这样做的坏处是:有可能发生死锁。

比方1号线程先后訪问共享资源A和B。而2号线程先后訪问共享资源B和A,由于在资源A和资源B都有锁,那么1号在訪问资源A时,资源A上锁了。准备訪问资源B,可是无法訪问,由于与此同一时候;而2号线程在訪问资源B,资源B锁着呢,正准备訪问资源A。发现资源A被1号线程锁着呢,结果彼此无限等待彼此下去,死锁类似逻辑上自指悖论。

所以,锁是坏的,破坏性能。锁是并发计算的大敌。

我们回到队列上,一把一个队列有至少两个线程:生产者和消费者,这就具备了资源争夺的前提,这两个线程一般彼此守在队列的进出两端,表面上好像没有訪问共享资源,实际上队列存在两个共享资源:队列大小或指针.

除了共享资源写操作上存在资源争夺问题外,Disruptor的LMAX团队发现Java在多核CPU情况下有伪共享问题:

CPU会把数据从内存载入到快速缓存中 ,这样能够获得更好的性能,快速缓存默认大小是64 Byte为一个区域,CPU机制限制仅仅能一个CPU的一个线程訪问(写)这个快速缓存区。

CPU在将主内存中数据载入到快速缓冲时,假设发现被载入的数据不足64字节,那么就会载入多个数据,以填满自己的64字节,悲催就发生了,恰恰hotspot JVM中对象指针等大小都不会超过64字节。这样一个快速缓冲中可能载入了两个对象指针,一个CPU一个快速缓冲。双核就是两个CPU各自一个快速缓冲,那么两个快速缓冲中各有两个对象指针,都是指向同样的两个对象。

由于一个CPU仅仅能訪问(写)自己快速缓存区中数据。相当于给这个数据加锁。那么另外一个CPU同一时候訪问自己快速缓冲行中相同数据时将会被锁定不能訪问。

这就发生与锁机制类似的性能陷进。Disruptor的解决的方法是填满快速缓冲的64字节。不是对象指针等数据不够64字节吗?那么加一些字节填满64字节,这样CPU将数据载入到快速缓冲时,就仅仅能载入一个了,刚刚好啊。

所以,虽然两个线程是在写两个不同的字段值,也会由于双核CPU底层机制发生伪装的共享。并没有真正共享,事实上还是排他性的独享。

如今我们大概知道RingBuffer是个什么东东了:

1.ring buffer是一个大的数组.

2.RingBuffer里全部指针都是Java longs (64字节) 不断永远向前计数。如后面图,不断在圆环中循环。

3.RingBuffer仅仅有当前序列号,没有终点序列号,当中数据不会被取出后消除,这样以便实现从过去某个序列号到当前序列号的重放。这样当消费者说没有接受到生产者发送的消息。生产者还能够再次发送,这点是一种原子性的“事务”机制。

This new pattern is an ideal foundation for any asynchronous event processing architecture where high-throughput and low-latency is required.

Concurrent execution of code is about two things, mutual exclusion and visibility of change.

Read and write operations require that all changes are made visible to other threads. However only contended write operations require the mutual exclusion of the changes.

Locks provide mutual exclusion and ensure that the visibility of change occurs in an ordered manner. Locks are incredibly expensive because they require arbitration when contended.

We will illustrate the cost of locks with a simple demonstration. The focus of this experiment is to call a function which increments a 64-bit counter in a loop 500 million
times. This can be executed by a single thread on a 2.4Ghz Intel Westmere EP in just 300ms if written in Java. The language is unimportant to this experiment and results will be similar across all languages with the same basic primitives.

Once a lock is introduced to provide mutual exclusion, even when the lock is as yet un-contended, the cost goes up significantly. The cost increases again, by orders of magnitude,
when two or more threads begin to contend. The results of this simple experiment are shown in the table below

However CAS operations are not free of cost. The processor must lock its instruction pipeline to ensure atomicity and employ a memory barrier to make the changes visible to
other threads. CAS operations are available in Java by using the java.util.concurrent.Atomic* classes.

The processors need only guarantee that program logic produces the same results regardless of execution order.

barriter:make the memory state within a processor visible to other processors.

disruptor 是为了解决消费者大于生产者的问题

參考

伪共享 :http://mechanical-sympathy.blogspot.com/2011/07/false-sharing.html

http://www.jdon.com/jivejdon/thread/42451#23138636

volatile:http://www.infoq.com/cn/articles/ftf-java-volatile

缓存行:http://ecee.colorado.edu/~ecen2120/Manual/caches/cache.html

disruptor:http://mechanitis.blogspot.com/2011/07/dissecting-disruptor-why-its-so-fast_22.html

源代码:http://code.google.com/p/disruptor/

memory barrier 屏障:http://mechanical-sympathy.blogspot.com/2011/07/memory-barriersfences.html

时间: 2024-10-29 10:46:34

从缓存行出发理解volatile变量、伪共享False sharing、disruptor的相关文章

Java8使用@sun.misc.Contended避免伪共享(False Sharing)

伪共享(False Sharing) Java8中用sun.misc.Contended避免伪共享(false sharing) Java8使用@sun.misc.Contended避免伪共享 原文地址:https://www.cnblogs.com/gotodsp/p/8831182.html

多线程中的volatile和伪共享

  伪共享 false sharing,顾名思义,“伪共享”就是“其实不是共享”.那什么是“共享”?多CPU同时访问同一块内存区域就是“共享”,就会产生冲突,需要控制协议来协调访问.会引起“共享”的最小内存区域大小就是一个cache line.因此,当两个以上CPU都要访问同一个cache line大小的内存区域时,就会引起冲突,这种情况就叫“共享”.但是,这种情况里面又包含了“其实不是共享”的“伪共享”情况.比如,两个处理器各要访问一个word,这两个word却存在于同一个cache line

Disruptor的伪共享解决方案

1.术语 术语 英文单词 描述 内存屏障 Memory Barriers 是一组处理器指令,用于实现对内存操作的顺序限制. In the Java Memory Model a volatile field has a store barrier inserted after a write to it and a load barrier inserted before a read of it. 缓存行 Cache line 缓存中可以分配的最小存储单位.处理器填写缓存线时会加载整个缓存线,

伪共享(false sharing),并发编程无声的性能杀手

在并发编程过程中,我们大部分的焦点都放在如何控制共享变量的访问控制上(代码层面),但是很少人会关注系统硬件及 JVM 底层相关的影响因素.前段时间学习了一个牛X的高性能异步处理框架 Disruptor,它被誉为“最快的消息框架”,其 LMAX 架构能够在一个线程里每秒处理 6百万 订单!在讲到 Disruptor 为什么这么快时,接触到了一个概念——伪共享( false sharing ),其中提到:缓存行上的写竞争是运行在 SMP 系统中并行线程实现可伸缩性最重要的限制因素.由于从代码中很难看

java 伪共享

MESI协议及RFO请求典型的CPU微架构有3级缓存, 每个核都有自己私有的L1, L2缓存. 那么多线程编程时, 另外一个核的线程想要访问当前核内L1, L2 缓存行的数据, 该怎么办呢?有人说可以通过第2个核直接访问第1个核的缓存行. 这是可行的, 但这种方法不够快. 跨核访问需要通过Memory Controller(见上一篇的示意图), 典型的情况是第2个核经常访问第1个核的这条数据, 那么每次都有跨核的消耗. 更糟的情况是, 有可能第2个核与第1个核不在一个插槽内.况且Memory C

伪共享和缓存行填充,从Java 6, Java 7 到Java 8

关于伪共享的文章已经很多了,对于多线程编程来说,特别是多线程处理列表和数组的时候,要非常注意伪共享的问题.否则不仅无法发挥多线程的优势,还可能比单线程性能还差.随着JAVA版本的更新,再各个版本上减少伪共享的做法都有区别,一不小心代码可能就失效了,要注意进行测试.这篇文章总结一下. 什么是伪共享 关于伪共享讲解最清楚的是这篇文章<剖析Disruptor:为什么会这么快?(三)伪共享>,我这里就直接摘抄其对伪共享的解释: 缓存系统中是以缓存行(cache line)为单位存储的.缓存行是2的整数

volatile变量理解 via《Java并发编程实战》

第3章:对象的共享 volatile关键字的理解 volatile变量,用来确保将变量的更行操作通知到其他线程.当变量申明为volatile类型后,编译器与运行时都会注意带这个变量时共享的,因此不会将该变量上的操作与其他内存操作一起重新排序.volatile变量不会被缓存到寄存器或者其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新的写入值.可以把volatile关键字理解为java的弱同步机制(相比于synchronized).volatile还能保证64位读/写操作的

深入理解Java虚拟机笔记---volatile变量的特殊规则

当一个变量定义成volatile之后,它将具备两种特性:第一是保证此变量对所有线程的可见性,这里的"可见性"是指当一条线程修改了这个变量的值,新值对于其它线程是可以立即得知的,变量值在线程间传递均需要通过主内存来完成,如:线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量的值才会对线程B可见. 关于volatile变量的可见性,很多人误以为以下描述成立:"volatile对所有线程是立即可见的,对volatil

《深入理解Java虚拟机》笔记 第十二章 volatile变量

当一个变量定义成volatile之后,它将具备两种特性: 1.第一是保证此变量对所有线程的可见性,这里的"可见性"是指当一条线程修改了这个变量的值,新值对于其它线程是可以立即得知的,变量值在线程间传递均需要通过主内存来完成,如:线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量的值才会对线程B可见. 2.使用volatile变量的第二个语义是禁止指令重排序优化,变通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地