維基百科中對偽共享的定義如下:
In computer science, false sharing is a performance-degrading usage pattern that can arise in systems with distributed,
coherent caches at the size of the smallest resource block managed by the caching mechanism. When a system participant attempts
to periodically access data that will never be altered by another party, but those data shares a cache block with data that are
altered, the caching protocol may force the first participant to reload the whole unit despite a lack of logical necessity. The
caching system is unaware of activity within this block and forces the first participant to bear the caching system overhead
required by true shared access of a resource.
其大致意思是:
CPU的緩存是以緩存行(cache line)為單位進(jìn)行緩存的,當(dāng)多個(gè)線程修改不同變量,而這些變量又處于同一個(gè)緩存行時(shí)就會(huì)影響彼此的性能。例如:線程1和線程2共享一個(gè)緩存行,線程1只讀取緩存行中的變量1,線程2修改緩存行中的變量2,雖然線程1和線程2操作的是不同的變量,由于變量1和變量2同處于一個(gè)緩存行中,當(dāng)變量2被修改后,緩存行失效,線程1要重新從主存中讀取,因此導(dǎo)致緩存失效,從而產(chǎn)生性能問題。為了更深入一步理解偽共享,我們先看一下CPU緩存。
CPU三級(jí)緩存
CPU的速度要遠(yuǎn)遠(yuǎn)大于內(nèi)存的速度,為了解決這個(gè)問題,CPU引入了三級(jí)緩存:L1,L2和L3三個(gè)級(jí)別,L1最靠近CPU,L2次之,L3離CPU最遠(yuǎn),L3之后才是主存。速度是L1>L2>L3>主存。越靠近CPU的容量越小。CPU獲取數(shù)據(jù)會(huì)依次從三級(jí)緩存中查找,如果找不到再從主存中加載。示意圖如下:

當(dāng)CPU要讀取一個(gè)數(shù)據(jù)時(shí),首先從一級(jí)緩存中查找,如果沒有找到再從二級(jí)緩存中查找,如果還是沒有就從三級(jí)緩存或內(nèi)存中查找。一般來說,每級(jí)緩存的命中率大概都在80%左右,也就是說全部數(shù)據(jù)量的80%都可以在一級(jí)緩存中找到,只剩下20%的總數(shù)據(jù)量才需要從二級(jí)緩存、三級(jí)緩存或內(nèi)存中讀取,由此可見一級(jí)緩存是整個(gè)CPU緩存架構(gòu)中最為重要的部分。
下表是一些緩存未命中的消耗數(shù)據(jù):
| 從CPU到 | 大約需要的CPU周期 | 大約需要的時(shí)間 |
|---|---|---|
| 主存 | 約60-80ns | |
| QPI總線 | 約20ns | |
| L3 cache | 約40-45cycles | 約15ns |
| L2 cache | 約10cycles | 約3ns |
| L1 cache | 約3-4cycles | 約1ns |
| 寄存器 | 1cycle |
MESI協(xié)議
緩存行狀態(tài)
CPU的緩存是以緩存行(cache line)為單位的,MESI協(xié)議描述了多核處理器中一個(gè)緩存行的狀態(tài)。在MESI協(xié)議中,每個(gè)緩存行有4個(gè)狀態(tài),分別是:
- M(修改,Modified):本地處理器已經(jīng)修改緩存行,即是臟行,它的內(nèi)容與內(nèi)存中的內(nèi)容不一樣,并且此 cache 只有本地一個(gè)拷貝(專有);
- E(專有,Exclusive):緩存行內(nèi)容和內(nèi)存中的一樣,而且其它處理器都沒有這行數(shù)據(jù);
- S(共享,Shared):緩存行內(nèi)容和內(nèi)存中的一樣, 有可能其它處理器也存在此緩存行的拷貝;
- I(無效,Invalid):緩存行失效, 不能使用。
緩存行的E狀態(tài)如下圖:

此時(shí)只有core1訪問緩存行,它的緩存行的狀態(tài)為E,表示core1獨(dú)占。
緩存行的S狀態(tài)如下圖:

此時(shí)core1和core2都會(huì)訪問緩存行,他們的緩存行狀態(tài)為S,表示緩存行處于共享狀態(tài)。
緩存行的M和I狀態(tài)如下圖:

此時(shí)core1修改了緩存行,因此core1的緩存行狀態(tài)為M,代表已經(jīng)修改,而core2的緩存行狀態(tài)為I,代表已經(jīng)失效,需要從主存中讀取。
緩存行狀態(tài)轉(zhuǎn)換
在MESI協(xié)議中,每個(gè)Cache的Cache控制器不僅知道自己的讀寫操作,而且也監(jiān)聽(snoop)其它Cache的讀寫操作。每個(gè)Cache line所處的狀態(tài)根據(jù)本核和其它核的讀寫操作在4個(gè)狀態(tài)間進(jìn)行遷移。MESI協(xié)議狀態(tài)遷移圖如下:

- 初始:一開始時(shí),緩存行沒有加載任何數(shù)據(jù),所以它處于 I 狀態(tài)。
- 本地寫(Local Write):如果本地處理器寫數(shù)據(jù)至處于 I 狀態(tài)的緩存行,則緩存行的狀態(tài)變成 M。
- 本地讀(Local Read):如果本地處理器讀取處于 I 狀態(tài)的緩存行,很明顯此緩存沒有數(shù)據(jù)給它。此時(shí)分兩種情況:(1)其它處理器的緩存里也沒有此行數(shù)據(jù),則從內(nèi)存加載數(shù)據(jù)到此緩存行后,再將它設(shè)成 E 狀態(tài),表示只有我一家有這條數(shù)據(jù),其它處理器都沒有;(2)其它處理器的緩存有此行數(shù)據(jù),則將此緩存行的狀態(tài)設(shè)為 S 狀態(tài)。(備注:如果處于M狀態(tài)的緩存行,再由本地處理器寫入/讀出,狀態(tài)是不會(huì)改變的)
- 遠(yuǎn)程讀(Remote Read):假設(shè)我們有兩個(gè)處理器 c1 和 c2,如果 c2 需要讀另外一個(gè)處理器 c1 的緩存行內(nèi)容,c1 需要把它緩存行的內(nèi)容通過內(nèi)存控制器 (Memory Controller) 發(fā)送給 c2,c2 接到后將相應(yīng)的緩存行狀態(tài)設(shè)為 S。在設(shè)置之前,內(nèi)存也得從總線上得到這份數(shù)據(jù)并保存。
- 遠(yuǎn)程寫(Remote Write):其實(shí)確切地說不是遠(yuǎn)程寫,而是 c2 得到 c1 的數(shù)據(jù)后,不是為了讀,而是為了寫。也算是本地寫,只是 c1 也擁有這份數(shù)據(jù)的拷貝,這該怎么辦呢?c2 將發(fā)出一個(gè) RFO (Request For Owner) 請求,它需要擁有這行數(shù)據(jù)的權(quán)限,其它處理器的相應(yīng)緩存行設(shè)為 I,除了它自已,誰不能動(dòng)這行數(shù)據(jù)。這保證了數(shù)據(jù)的安全,同時(shí)處理 RFO 請求以及設(shè)置I的過程將給寫操作帶來很大的性能消耗。
緩存行
CPU緩存是以緩存行(cache line)為單位存儲(chǔ)的。緩存行通常是 64 字節(jié),并且它有效地引用主內(nèi)存中的一塊地址。一個(gè) Java 的 long 類型是 8 字節(jié),因此在一個(gè)緩存行中可以存 8 個(gè) long 類型的變量。所以,如果你訪問一個(gè) long 數(shù)組,當(dāng)數(shù)組中的一個(gè)值被加載到緩存中,它會(huì)額外加載另外 7 個(gè),以致你能非??斓乇闅v這個(gè)數(shù)組。事實(shí)上,你可以非??焖俚谋闅v在連續(xù)的內(nèi)存塊中分配的任意數(shù)據(jù)結(jié)構(gòu)。而如果你在數(shù)據(jù)結(jié)構(gòu)中的項(xiàng)在內(nèi)存中不是彼此相鄰的(如鏈表),你將得不到免費(fèi)緩存加載所帶來的優(yōu)勢,并且在這些數(shù)據(jù)結(jié)構(gòu)中的每一個(gè)項(xiàng)都可能會(huì)出現(xiàn)緩存未命中。下圖是一個(gè)CPU緩存行的示意圖:

上圖中,一個(gè)運(yùn)行在處理器 core1上的線程想要更新變量 X 的值,同時(shí)另外一個(gè)運(yùn)行在處理器 core2 上的線程想要更新變量 Y 的值。但是,這兩個(gè)頻繁改動(dòng)的變量都處于同一條緩存行。兩個(gè)線程就會(huì)輪番發(fā)送 RFO 消息,占得此緩存行的擁有權(quán)。當(dāng) core1 取得了擁有權(quán)開始更新 X,則 core2 對應(yīng)的緩存行需要設(shè)為 I 狀態(tài)。當(dāng) core2 取得了擁有權(quán)開始更新 Y,則 core1 對應(yīng)的緩存行需要設(shè)為 I 狀態(tài)(失效態(tài))。輪番奪取擁有權(quán)不但帶來大量的 RFO 消息,而且如果某個(gè)線程需要讀此行數(shù)據(jù)時(shí),L1 和 L2 緩存上都是失效數(shù)據(jù),只有 L3 緩存上是同步好的數(shù)據(jù)。從前一篇我們知道,讀 L3 的數(shù)據(jù)非常影響性能。更壞的情況是跨槽讀取,L3 都要 miss,只能從內(nèi)存上加載。
表面上 X 和 Y 都是被獨(dú)立線程操作的,而且兩操作之間也沒有任何關(guān)系。只不過它們共享了一個(gè)緩存行,但所有競爭沖突都是來源于共享。
Java中的偽共享
解決偽共享最直接的方法就是填充(padding),例如下面的VolatileLong,一個(gè)long占8個(gè)字節(jié),Java的對象頭占用8個(gè)字節(jié)(32位系統(tǒng))或者12字節(jié)(64位系統(tǒng),默認(rèn)開啟對象頭壓縮,不開啟占16字節(jié))。一個(gè)緩存行64字節(jié),那么我們可以填充6個(gè)long(6 * 8 = 48 個(gè)字節(jié))。這樣就能避免多個(gè)VolatileLong共享緩存行。
public class VolatileLong {
private volatile long v;
// private long v0, v1, v2, v3, v4, v5 // 去掉注釋,開啟填充,避免緩存行共享
}
這是最簡單直接的方法,Java 8中引入了一個(gè)更加簡單的解決方案:@Contended注解:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
String value() default "";
}
Contended注解可以用于類型上和屬性上,加上這個(gè)注解之后虛擬機(jī)會(huì)自動(dòng)進(jìn)行填充,從而避免偽共享。這個(gè)注解在Java8 ConcurrentHashMap、ForkJoinPool和Thread等類中都有應(yīng)用。我們來看一下Java8中ConcurrentHashMap中如何運(yùn)用Contended這個(gè)注解來解決偽共享問題。以下說的ConcurrentHashMap都是Java8版本。
ConcurrentHashMap中偽共享解決方案
ConcurrentHashMap的size操作通過CounterCell來計(jì)算,哈希表中的每個(gè)節(jié)點(diǎn)都對用了一個(gè)CounterCell,每個(gè)CounterCell記錄了對應(yīng)Node的鍵值對數(shù)目。這樣每次計(jì)算size時(shí)累加各個(gè)CounterCell就可以了。<font color="#FF0000">ConcurrentHashMap中CounterCell以數(shù)組形式保存,而數(shù)組在內(nèi)存中是連續(xù)存儲(chǔ)的,CounterCell中只有一個(gè)long類型的value屬性,這樣CPU會(huì)緩存CounterCell臨近的CounterCell,于是就形成了偽共享。</font>
ConcurrentHashMap中用Contended注解自動(dòng)對CounterCell來進(jìn)行填充:
/**
* Table of counter cells. When non-null, size is a power of 2.
*/
private transient volatile CounterCell[] counterCells; // CounterCell數(shù)組,CounterCell在內(nèi)存中連續(xù)
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
// 計(jì)算size時(shí)直接對各個(gè)CounterCell的value進(jìn)行累加
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
// 使用Contended注解自動(dòng)進(jìn)行填充避免偽共享
@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
<font color="##FF0000">需要注意的是@sun.misc.Contended注解在user classpath中是不起作用的,需要通過一個(gè)虛擬機(jī)參數(shù)來開啟:-XX:-RestrictContended。</font>
總結(jié)
- CPU緩存是以緩存行為單位進(jìn)行操作的。產(chǎn)生偽共享問題的根源在于不同的核同時(shí)操作同一個(gè)緩存行。
- 可以通過填充來解決偽共享問題,Java8 中引入了
@sun.misc.Contended注解來自動(dòng)填充。 - 并不是所有的場景都需要解決偽共享問題,因?yàn)镃PU緩存是有限的,填充會(huì)犧牲掉一部分緩存。