天天看點

Java與CPU緩存是如何親密接觸的!

Java與CPU緩存是如何親密接觸的!

在解釋【僞共享】這個概念之前,我們先來運作一段代碼,小編的電腦上有4個core。

Java與CPU緩存是如何親密接觸的!

這個程式的邏輯是4個線程共享同一個數組讀寫不同下标的變量。每個線程循環1億次讀寫,也就是+1操作。然後統計4個線程同時跑完總共花的時間。

Java與CPU緩存是如何親密接觸的!

下面我們來看看在小編的電腦上運作的結果:

Java與CPU緩存是如何親密接觸的!

然後我把SharingLong裡面的注釋代碼去掉,再跑了一下:

Java與CPU緩存是如何親密接觸的!

在性能上注釋前後差别高達5比1,為什麼會在性能上會産生如此大的差别呢?

這就是本篇要講的主題【僞共享】,英文名叫False Sharing。而SharingLong裡面的注釋行一般稱之為【緩存行填充】,英文名叫Cache Line Padding。

首先我們來計算一下SharingLong對象占用的記憶體空間,我們不考慮64位的情景,Java的對象都有一個2個word的頭部,第一個word存儲對象的hashcode和一些特殊的位标志,如GC的分代年齡、偏向鎖标記等,第二個word存儲對象的指針位址,一個word就是32位。然後加上v和6個p變量,總共就是8個long的長度,也就是64位元組。

接下來我們要引入CPU緩存的概念。

Java與CPU緩存是如何親密接觸的!

現代的處理器一般都有3級緩存結構,L1、L2和L3,CPU直接通路主存是一個相對比較慢的操作,是以通過3級緩存來提升訪存性能。我們将3個緩存當成一個整體來看待,它就是CPU緩存。緩存的制造成本非常昂貴,它一般要比主存空間小的多。

CPU在讀主存的時候,會先将主存的一塊資料加載到緩存上,然後在緩存上讀取。當CPU寫主存的時候,它會首先寫緩存,在未來的某個時間點再一次性将緩存的資料全部刷回主存,這樣就可以提高寫操作的性能。因為計算機程式資料操作的局部性,CPU連續的指令傾向于通路相鄰位址空間的資料,是以後續的讀寫操作有很大的機率可以直接在緩存上拿到資料。如果緩存上不存在,那就再去主存上加載進來。

緩存雖然小,但是也不是太小,CPU在加載主存資料時,如果一次性将整個Cache填滿,但是接下來的指令通路的資料又不在緩存上,就會導緻讀浪費。另外如果隻修改了其中幾個位元組的資料,但是得回寫整個Cache到記憶體,這又會導緻寫浪費。

是以現代的CPU緩存一般是分行存儲的,最小處理機關是一個行,這個行的長度一般來說就是上文提到的64位元組,我們稱之為【緩存行】。

Java與CPU緩存是如何親密接觸的!

SharingLong對象中v的值是volatile類型的,意味着CPU要保證v變量在不同線程之間的讀寫可見行。當CPU對v變量進行修改的時候會将資料立即回寫至主存并将相應的緩存行置為失效。這樣後續對v變量進行的讀寫操作都需要重新從記憶體中加載緩存行,這樣就保證了其它線程讀到的資料是最新的。

這點跟我們平常在Java基礎教科書裡提到的有點不一樣。教科書裡面為了便于新手了解,不會提及緩存,一般隻會說volatile變量直接讀寫記憶體。

如果記憶體裡有兩個volatile變量在相鄰的位址,兩個cpu分别對v1和v2進行讀和寫操作,會發生什麼情況呢?首先我們分解執行動作。圖中的h表示對象頭。

Java與CPU緩存是如何親密接觸的!

1、CPU1對v1進行讀操作,将記憶體裡的v1加載到緩存行裡。

2、CPU2對v2進行讀操作,将記憶體裡的v2加載到緩存行裡。

3、CPU1對v1進行寫操作,将緩存裡的v1修改,然後回寫到主存再将緩存行置為失效。

4、CPU2對v2進行寫操作,将緩存裡的v2修改,然後回寫到主存再将緩存行置為失效。

步驟1肯定先于步驟3,步驟2肯定先于步驟4。它們發生的順序可能是 1->2->3->4 ,相當于兩個CPU交疊運作,步驟1加載緩存行,步驟2發現資料就在緩存行裡還是最新的,就省去了加載緩存行操作了,這時讀操作做到了【共享】。緊接着步驟3正常進行寫操作,然後步驟4來了,CPU2發現緩存行失效了,是以還得重新加載緩存行,然後再回寫到主存再将緩存行置為失效。這裡就發生了重複加載緩存行的現象,也即【寫競争】。如果不是volatile變量,步驟3的寫操作是不會立即回寫記憶體的,緩存行也就不會立即置為失效,這個時候步驟4來了CPU可以直接對緩存進行寫操作,而不會出現浪費現象。我們稱這種現象為【僞共享】,就是說這兩個變量雖然共享同一個緩存行,但是它們之間會發生寫競争。

如果順序是1->3->2->4,步驟1和步驟3的讀操作這時就沒能實作共享,還是會有浪費。

當系統的線程數越多時,寫競争越激烈,這種浪費就越多。

現在我們能明白為什麼去掉注釋後,程式會變慢,因為存在寫競争現象,數組中相鄰的SharingLong.v共享了同一個緩存行。

那加上p1~p6這6個變量的意義是什麼呢?我們看圖。

Java與CPU緩存是如何親密接觸的!

我們發現加上6個long變量後,v1和v2将分别占用自己的緩存行,互不幹擾,是以寫競争也就不存在了,效率自然就提升了。

不過缺點也是有的,就是緩存的使用率降低了,一個緩存行的空間才使用了1/4。這就是典型的空間換時間的場景。

例子中我們使用了volatile變量,那如果改成普通變量呢?我們運作一下,結果如下。

Java與CPU緩存是如何親密接觸的!

相當驚人,耗時上居然少了3個量級,這就是volatile在性能上的代價。普通變量不需要保證線程之間的讀寫的可見性,CPU對緩存修改後不需要立即回寫記憶體,不存在寫操作緩存穿透現象。而讀操作也不需要總是重新從記憶體加載,那這個效率幾乎完全就是緩存通路的效率,而對volatile變量的讀寫操作則接近記憶體通路的效率,差距自然如此明顯。

你也許會問,知道這些有什麼蛋用!

确是沒什麼蛋用,因為在現實世界,大部分操作都涉及到IO操作。根據水桶效應,其它環節優化到了極緻,也無法提升整體的品質。

但是也不完全所有的應用都是IO操作型的,有一些場景下那是純粹的記憶體操作。那麼對于純記憶體操作來說,了解【僞共享】知識可以幫你從性能上提升幾倍甚至是幾個數量級。

著名的disruptor架構正是使用了緩存行填充技術,才使得它的環形數組隊列能如此高效。看wiki上的性能報告,disruptor的RingBuffer相比Java内置的ArrayBlockingQueue在OPS上高出近一個數量級,在隊列延遲上則低了接近3個數量級。

原文釋出時間為:2018-08-09

本文作者:老錢

本文來自雲栖社群合作夥伴“

Java後端技術

”,了解相關資訊可以關注“