Java Review - 并发编程_伪共享
文章目錄
- what's 偽共享
- 為何會出現偽共享
- 如何避免偽共享
- 小結
what’s 偽共享
為了解決計算機系統中主內存與CPU之間運行速度差問題,會在CPU與主內存之間添加一級或者多級高速緩沖存儲器(Cache)。這個Cache一般是被集成到CPU內部的,所以也叫 CPU Cache .
下圖所示是兩級Cache結構
在Cache內部是按行存儲的,其中每一行稱為一個Cache行。Cache行(如下圖所示)是Cache與主內存進行數據交換的單位,Cache行的大小一般為2的冪次數字節。
當CPU訪問某個變量時,首先會去看CPU Cache內是否有該變量,如果有則直接從中獲取,否則就去主內存里面獲取該變量,然后把該變量所在內存區域的一個Cache行大小的內存復制到Cache中。
由于存放到Cache行的是內存塊而不是單個變量,所以可能會把多個變量存放到一個Cache行中。當多個線程同時修改一個緩存行里面的多個變量時,由于同時只能有一個線程操作緩存行,所以相比將每個變量放到一個緩存行,性能會有所下降,這就是偽共享 .
如下所示。
變量x和y同時被放到了CPU的一級和二級緩存,當線程1使用CPU1對變量x進行更新時,首先會修改CPU1的一級緩存變量x所在的緩存行,這時候在緩存一致性協議下,CPU2中變量x對應的緩存行失效。
那么線程2在寫入變量x時就只能去二級緩存里查找,這就破壞了一級緩存。而一級緩存比二級緩存更快,這也說明了多個線程不可能同時去修改自己所使用的CPU中相同緩存行里面的變量。更壞的情況是,如果CPU只有一級緩存,則會導致頻繁地訪問主內存。
為何會出現偽共享
偽共享的產生是因為多個變量被放入了一個緩存行中,并且多個線程同時去寫入緩存行中不同的變量。
那么為何多個變量會被放入一個緩存行呢?其實是因為緩存與內存交換數據的單位就是緩存行,當CPU要訪問的變量沒有在緩存中找到時,根據程序運行的局部性原理,會把該變量所在內存中大小為緩存行的內存放入緩存行。
long a ; long b ; long c ; long d ;如上代碼聲明了四個long變量,假設緩存行的大小為32字節,那么當CPU訪問變量a時,發現該變量沒有在緩存中,就會去主內存把變量a以及內存地址附近的b、c、d放入緩存行。也就是地址連續的多個變量才有可能會被放到一個緩存行中。
當創建數組時,數組里面的多個元素就會被放入同一個緩存行。那么在單線程下多個變量被放入同一個緩存行對性能有影響嗎?其實在正常情況下單線程訪問時將數組元素放入一個或者多個緩存行對代碼執行是有利的,因為數據都在緩存中,代碼執行會更快。
來看個代碼
【Code1】
/*** @author 小工匠* @version 1.0* @description: TODO* @date 2021/11/28 21:44* @mark: show me the code , change the world*/ public class TestWGX {static final int LINE_NUM = 1024;static final int COLUM_NUM = 1024;public static void main(String[] args) {long[][] array = new long[LINE_NUM][COLUM_NUM];long startTime = System.currentTimeMillis();for (int i = 0; i < LINE_NUM; ++i) {for (int j = 0; j < COLUM_NUM; ++j) {array[i][j] = i * 2 + j;}}long endTime = System.currentTimeMillis();long cacheTime = endTime - startTime;System.out.println("cache time:" + cacheTime);}}【Code2】
/*** @author 小工匠* @version 1.0* @description: TODO* @date 2021/11/28 22:05* @mark: show me the code , change the world*/ public class TestWGX2 {static final int LINE_NUM = 1024;static final int COLUM_NUM = 1024;public static void main(String[] args) {long[][] array = new long[LINE_NUM][COLUM_NUM];long startTime = System.currentTimeMillis();for (int i = 0; i < COLUM_NUM; ++i) {for (int j = 0; j < LINE_NUM; ++j) {array[j][i] = i * 2 + j;}}long endTime = System.currentTimeMillis();System.out.println("no cache time:" + (endTime - startTime));} }Code1 5ms以內
Code2 5ms以上
代碼(1)比代碼(2)執行得快,這是因為數組內數組元素的內存地址是連續的,當訪問數組第一個元素時,會把第一個元素后的若干元素一塊放入緩存行,這樣順序訪問數組元素時會在緩存中直接命中,因而就不會去主內存讀取了,后續訪問也是這樣。
也就是說,當順序訪問數組里面元素時,如果當前元素在緩存沒有命中,那么會從主內存一下子讀取后續若干個元素到緩存,也就是一次內存訪問可以讓后面多次訪問直接在緩存中命中。而代碼(2)是跳躍式訪問數組元素的,不是順序的,這破壞了程序訪問的局部性原則,并且緩存是有容量控制的,當緩存滿了時會根據一定淘汰算法替換緩存行,這會導致從內存置換過來的緩存行的元素還沒等到被讀取就被替換掉了。
所以在單個線程下順序修改一個緩存行中的多個變量,會充分利用程序運行的局部性原則,從而加速了程序的運行。而在多線程下并發修改一個緩存行中的多個變量時就會競爭緩存行,從而降低程序運行性能。
如何避免偽共享
在JDK 8之前一般都是通過字節填充的方式來避免該問題,也就是創建一個變量時使用填充字段填充該變量所在的緩存行,這樣就避免了將多個變量存放在同一個緩存行中。
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變量的8字節總共56字節。另外,這里FilledLong是一個類對象,而類對象的字節碼的對象頭占用8字節,所以一個FilledLong對象實際會占用64字節的內存,這正好可以放入一個緩存行。
JDK 8提供了一個sun.misc.Contended注解,用來解決偽共享問題。將上面代碼修改為如下。
@sun.misc.Contendedpublic final static class FilledLong {public volatile long value = 0L;}在這里注解用來修飾類,當然也可以修飾變量,比如在Thread類中。
/** The current seed for a ThreadLocalRandom */@sun.misc.Contended("tlr")long threadLocalRandomSeed;/** Probe hash value; nonzero if threadLocalRandomSeed initialized */@sun.misc.Contended("tlr")int threadLocalRandomProbe;/** Secondary seed isolated from public ThreadLocalRandom sequence */@sun.misc.Contended("tlr")int threadLocalRandomSecondarySeed;Thread類里面這三個變量默認被初始化為0,這三個變量會在ThreadLocalRandom類中使用,后面章節會專門講解ThreadLocalRandom的實現原理。
在默認情況下,@Contended注解只用于Java核心類,比如rt包下的類。如果用戶類路徑下的類需要使用這個注解,則需要添加JVM參數:-XX:-RestrictContended。填充的寬度默認為128,要自定義寬度則可以設置-XX:ContendedPaddingWidth參數。
小結
我們這里主要講述了偽共享是如何產生的,以及如何避免,并證明在多線程下訪問同一個緩存行的多個變量時才會出現偽共享,在單線程下訪問一個緩存行里面的多個變量反而會對程序運行起到加速作用。理解這些知識有助于理解LongAdder的實現原理。
總結
以上是生活随笔為你收集整理的Java Review - 并发编程_伪共享的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java Review - 并发编程_U
- 下一篇: Java Review - 并发编程_T