13-偽共享

黑夜中的小迷途發表於2021-10-06

偽共享

什麼是偽共享

​ 為了解決計算機系統中主存與CPU之間的執行速度差問題,會在CPU與主存之間新增一級或者多級高速緩衝儲存器(Cache),這個Cache一般集中於CPU內部當中,所以也叫CPU Cache,圖中是兩級Cache結構

image

​ 在cache中,其中的每一行稱為一個cache行,cache行是cache與主存進行資料交換的單位,cache行一般為2的冪次數字節。

image

​ 當CPU訪問某一個變數的時候,首先會檢視CPU cache中是否有該變數,如果有,則直接從快取中拿,否則就去快取中獲取該變數。然後把該變數所在的記憶體區域的一個大cache行大小複製到caceh中,由於存放到cache行的是記憶體塊而不是單個變數。所以可能會把多個變數放到一個cache行中。當多個執行緒同時修改一個快取行裡面的多個變數時同時只能有一個執行緒操作快取行,所以相比每個變數放到一個快取行,效能過會有所下降,這就是偽共享,如圖: !image

​ 在該圖中,變數X和變數Y同時被放到CPU的一級快取和二級快取,當執行緒1使用CPU1對變數X進行更新的時候,首先會修改CPU1的一級快取X所在的快取行,這時候在快取一致性的協議下,CPU2中變數X對應的快取行失效。那麼執行緒2在寫入變數X的時候就只能去二級快取裡找,這就破壞了一級快取,而一級快取更新比二級塊,這也說明了多個執行緒不能同時去修改自己所使用的CPU中相同快取行裡面的數量,更壞的情況是,如果CPU中只有一級快取,則會頻繁的訪問主存。

為何會出現偽共享

​ 偽共享的產生是因為多個變數同時的被放入到一個快取行中,並且過個執行緒同時去寫入記憶體快取行中不同的變數。那麼為什麼多個變數會被放到一個快取行中呢?其實是因為快取與記憶體交換的單位就是快取行。當CPU要訪問的變數沒有在快取中找到的時候,根據程式執行的區域性性原理,會把該變數所在記憶體中大小為快取行的記憶體存入快取行。

​ 當單執行緒下多個變數被放到同一個快取行對效能影響嗎?其實在正常情況下單執行緒訪問時將陣列元素放到一個或者多個快取行對程式碼執行是有利的。因為資料都在快取中,程式碼執行會更快,比較以下程式碼:

public class TestForContent {
    static final int LINE_NUM=1024;
    static final int COLUMN_NUM=1024;

    public static void main(String[] args) {
        long[][] array=new long[LINE_NUM][COLUMN_NUM];
        long startTime=System.currentTimeMillis();
        for (int i = 0; i < LINE_NUM; i++) {
            for (int j = 0; j < COLUMN_NUM; j++) {
                array[i][j]=i*2+j;
            }
        }

        long endTime=System.currentTimeMillis();
        System.out.println("cache time:"+(endTime-startTime));
    }
}

執行結果

cache time:9

Process finished with exit code 0
public class TestForContent {
    static final int LINE_NUM=1024;
    static final int COLUMN_NUM=1024;

    public static void main(String[] args) {
        long[][] array=new long[LINE_NUM][COLUMN_NUM];
        long startTime=System.currentTimeMillis();
        for (int i = 0; i < COLUMN_NUM; i++) {
            for (int j = 0; j < LINE_NUM; j++) {
                array[j][i]=i*2+j;
            }
        }

        long endTime=System.currentTimeMillis();
        System.out.println("not cache time:"+(endTime-startTime));
    }
}

執行結果

not cache time:14

Process finished with exit code 0

​ 經過多次測試,有快取所需的時間要少於沒有快取執行的時間。這是因為陣列內元素地址是連續的,當訪問陣列第一個元素的時候,就會把第一個元素後的若干元素一塊放入快取行內,這樣順序訪問數字元素就會在快取中直接命中,因為就交少了去主存中取資料的時間,後續訪問也是這樣,一次記憶體訪問可以讓後面訪問直接在快取命中。

​ 而沒有快取程式碼則是跳躍式訪問陣列元素,不是順序的,這破壞了程式訪問的區域性性原則,並且快取是有限容量的,當快取滿後會根據一定的淘汰演算法替換快取行,這會導致從內建過來的快取行還沒等到被讀取就被替換掉了。

​ 所以在單執行緒下順序修改一個快取行中的多個變數,會充分利用程式執行的區域性性原理,從而加快程式的執行,而在多執行緒下修改一個快取中的多個變數時會競爭快取行,從而降低執行效能。

如何避免偽共享

​ 在JDK8以前一般都是通過位元組填充 方式來避免該問題,也就是建立變數的時候使用填充欄位填充該變數所在的快取行,這樣就避免了將多個變數放到同一個緩衝行中,例如如下程式碼:

public final static class FilledLong{
    public volatile long value=0L;
    public long p1,p2,p3,p4,p5,p6;
}

​ 假如快取行為64位元組,那麼我們在FilledLong類裡面填充了6個long型別的變數,每個long型別佔用8位元組,加上value變數總共56位元組,另外,這裡FilledLong是一個類物件,而類物件的位元組碼的物件頭佔用8個位元組,因此一個FilledLong物件就會佔用64個位元組,就剛好是放入到一個快取行中。

JDK8提供了一個sum.mis.Contended註解,用來解決偽共享問題,將上面的程式碼修改如下。

@sum.mis.Contended
public final static class FilledLong{
    public volatile long value=0L;
} 

在這裡註解可以修飾類,當然註解也可用於修飾變數,例如:

@sum.mis.Contented("tlr")
long threadLongLoalRandomeSeed;

​ 在預設情況下,@Contended註解只用於Java核心類,比如rt下包的類,如果使用者下的類需要使用這個註解,則需要新增JVM引數:-XX:-RestrictContented。填充的位元組預設寬度為128位元組,如果需要手動修改則可以設定:-XX:CntendedPaddingWidth引數。

小結

​ 該部分主要講了偽共享是怎麼產生的,以及如何避免,在單執行緒下訪問一個快取行裡面的多個變數反而會對程式執行起加速作用,但是多執行緒同時訪問同一個快取行裡面的多個變數才會出現偽共享。

相關文章