偽共享(False Sharing)
目錄
快取系統中是以快取行(cache line)為單位儲存的,當多執行緒修改互相獨立的變數時,如果這些變數共享同一個快取行,就會無意中影響彼此的效能,這就是偽共享。
一、計算機的基本結構
下圖是計算的基本結構。L1、L2、L3分別表示一級快取、二級快取、三級快取,越靠近CPU的快取,速度越快,容量也越小。所以L1快取很小但很快,並且緊靠著在使用它的CPU核心;L2大一些,也慢一些,並且仍然只能被一個單獨的CPU核使用;L3更大、更慢,並且被單個插槽上的所有CPU核共享;最後是主存,由全部插槽上的所有CPU核共享。
當CPU執行運算的時候,它先去L1查詢所需的資料、再去L2、然後是L3,如果最後這些快取中都沒有,所需的資料就要去主記憶體拿。走得越遠,運算耗費的時間就越長。所以如果你在做一些很頻繁的事,你要儘量確保資料在L1快取中。另外,執行緒之間共享一份資料的時候,需要一個執行緒把資料寫回主存,而另一個執行緒訪問主存中相應的資料。
下面是從CPU訪問不同層級資料的時間概念:
從CPU到 | 大約需要的CPU週期 | 大約需要的時間 |
---|---|---|
主存 | 約60-80ns | |
QPI 匯流排傳輸(between sockets, not drawn) | 約20ns | |
L3 cache | 約40-45 cycles | 約15ns |
L2 cache | 約10 cycles | 約3ns |
L1 cache | 約3-4 cycles | 約1ns |
暫存器 | 1cycle |
二、快取行
Cache是由很多個cache line組成的。每個cache line通常是64位元組,並且它有效地引用主記憶體中的一塊兒地址。一個Java的long型別變數是8位元組,因此在一個快取行中可以存8個long型別的變數。
CPU每次從主存中拉取資料時,會把相鄰的資料也存入同一個cache line。
在訪問一個long陣列的時候,如果陣列中的一個值被載入到快取中,它會自動載入另外7個。因此能非常快的遍歷這個陣列。事實上,可以非常快速的遍歷在連續記憶體塊中分配的任意資料結構。
示例:
package com.thread.falsesharing;
/**
* @Author: 98050
* @Time: 2018-12-19 23:25
* @Feature: cache line特性
*/
public class CacheLineEffect {
private static long[][] result;
public static void main(String[] args) {
int row =1024 * 1024;
int col = 8;
result = new long[row][];
for (int i = 0; i < row; i++) {
result[i] = new long[col];
for (int j = 0; j < col; j++) {
result[i][j] = i+j;
}
}
long start = System.currentTimeMillis();
for (int i = 0; i < row; i++) {
for (int j = 0; j < col; j++) {
result[i][j] = 0;
}
}
System.out.println("使用cache line特性,迴圈時間:" + (System.currentTimeMillis() - start));
long start2 = System.currentTimeMillis();
for (int i = 0; i < col; i++) {
for (int j = 0; j < row; j++) {
result[j][i] = 1;
}
}
System.out.println("沒有使用cache line特性,迴圈時間:" + (System.currentTimeMillis() - start2));
}
}
結果:
三、偽共享
如上圖變數x,y同時被放到了CPU的一級和二級快取,當執行緒1使用CPU1對變數x進行更新時候,首先會修改cpu1的一級快取變數x所在快取行,這時候快取一致性協議會導致cpu2中變數x對應的快取行失效,那麼執行緒2寫入變數x的時候就只能去二級快取去查詢,這就破壞了一級快取,而一級快取比二級快取更快。更壞的情況下如果cpu只有一級快取,那麼會導致頻繁的直接訪問主記憶體。
示例:
package com.thread.falsesharing;
/**
* @Author: 98050
* @Time: 2018-12-20 12:06
* @Feature: 偽共享
*/
public class FalseSharing implements Runnable {
/**
* 執行緒數
*/
public static int NUM_THREADS = 4;
/**
* 迭代次數
*/
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
private static VolatileLong[] longs;
public static long SUM_TIME = 0L;
public FalseSharing(final int arrayIndex) {
this.arrayIndex = arrayIndex;
}
public void run() {
long i = ITERATIONS + 1;
while (0 != --i){
longs[arrayIndex].value = i;
}
}
public final static class VolatileLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6; //快取行填充
}
private static void runTest() throws InterruptedException {
Thread[] thread = new Thread[NUM_THREADS];
for (int i = 0; i < thread.length; i++) {
thread[i] = new Thread(new FalseSharing(i));
}
for (Thread t : thread){
t.start();
}
for (Thread t : thread){
t.join();
}
}
public static void main(String[] args) throws InterruptedException {
Thread.sleep(10000);
for (int i = 0; i < 10; i++) {
System.out.println(i);
if (args.length == 1){
NUM_THREADS = Integer.parseInt(args[0]);
}
longs = new VolatileLong[NUM_THREADS];
for (int j = 0; j < longs.length; j++) {
longs[j] = new VolatileLong();
}
final long start = System.nanoTime();
runTest();
final long end = System.nanoTime();
SUM_TIME += end - start;
}
System.out.println("平均耗時:" + SUM_TIME / 10);
}
}
四個執行緒修改一陣列不同元素的內容。元素的型別是 VolatileLong,只有一個長整型成員 value 和 6 個沒用到的長整型成員。value 設為 volatile 是為了讓 value 的修改對所有執行緒都可見。程式分兩種情況執行,第一種情況為不遮蔽快取行填充,第二種情況為遮蔽快取行填充。為了"保證"資料的相對可靠性,程式取 10 次執行的平均時間。執行情況如下:
兩個邏輯一模一樣的程式,前者的耗時大概是後者的 2倍。那麼這個時候,我們再用偽共享(False Sharing)的理論來分析一下,前者 longs 陣列的 4 個元素,由於 VolatileLong 只有 1 個長整型成員,所以一個數組單元就是16個位元組(long資料型別8個位元組+類物件的位元組碼的物件頭8個位元組),進而整個陣列都將被載入至同一快取行(16*4位元組),但有4個執行緒同時操作這條快取行,於是偽共享就悄悄地發生了。
偽共享在多核程式設計中很容易發生,而且非常隱蔽。例如, ArrayBlockingQueue 中有三個成員變數:
- takeIndex:需要被取走的元素下標
- putIndex:可被元素插入的位置的下標
- count:佇列中元素的數量
這三個變數很容易放到一個快取行中,但是之間修改沒有太多的關聯。所以每次修改,都會使之前快取的資料失效,從而不能完全達到共享的效果。
如上圖所示,當生產者執行緒put一個元素到ArrayBlockingQueue時,putIndex會修改,從而導致消費者執行緒的快取中的快取行無效,需要從主存中重新讀取。執行緒越多,核越多,對效能產生的負面效果就越大。
四、如何避免偽共享
快取行填充
一條快取行有 64 位元組,而 Java 程式的物件頭固定佔 8 位元組(32位系統)或 12 位元組( 64 位系統預設開啟壓縮, 不開壓縮為 16 位元組),所以只需要填 6 個無用的長整型補上6*8=48位元組,讓不同的 VolatileLong 物件處於不同的快取行,就避免了偽共享( 64 位系統超過快取行的 64 位元組也無所謂,只要保證不同執行緒不操作同一快取行就可以)。
Java8中已經提供了官方的解決方案,Java8中新增了一個註解:@sun.misc.Contended。加上這個註解的類會自動補齊快取行,需要注意的是此註解預設是無效的,需要在jvm啟動時設定-XX:-RestrictContended才會生效。