java并发编程之美-阅读记录2
2.1什么是多線程并發(fā)編程
并發(fā):是指在同一時間段內(nèi),多個任務(wù)同時在執(zhí)行,并且執(zhí)行沒有結(jié)束(同一時間段又包括多個單位時間,也就是說一個cpu執(zhí)行多個任務(wù))
并行:是指在單位時間內(nèi)多個任務(wù)在同時執(zhí)行(也就是多個cpu同時執(zhí)行任務(wù))
而在多線程編程實(shí)踐中,線程的個數(shù)一般是多于cpu的個數(shù)的
2.2為什么要多線程并發(fā)編程
多個cpu同時執(zhí)行多個任務(wù),減少了線程上下文切換的開銷
2.3線程安全問題
共享資源:就是說該資源可以被多個線程持有,或者說能夠被多個線程訪問。
? 對共享資源的修改會造成線程安全問題。
2.4共享變量的內(nèi)存可見性問題
java內(nèi)存模型(JMM)規(guī)定,所有的變量都存儲在主內(nèi)存中,當(dāng)線程使用變量時,會將主內(nèi)存中的變量復(fù)制一份到自己的工作內(nèi)存,之后線程操作的變量都是自己工作內(nèi)存(L1緩存或者L2緩存或者寄存器)中的變量。
這樣對于內(nèi)存不可見(沒有使用volatile修改的變量)的變量來說,在不同線程中就可能存在不同的值。就那下圖一個雙核cpu系統(tǒng)來說,當(dāng)操作一個共享變量X時,線程A就會獲取當(dāng)前內(nèi)存中的變量X,由于線程A是第一次操作,當(dāng)前工作內(nèi)存中沒有該變量,此時,線程A就會將主內(nèi)存中的變量X復(fù)制一份到自己的工作內(nèi)存(L1/L2緩存),線程A給變量X重新賦值(假設(shè)主內(nèi)存中默認(rèn)值為1,線程A修改為2),修改后,線程A會將修改后的值重新刷會主內(nèi)存,此時線程A是正常工作的。然后線程B也要操作變量X,同樣的也會將主內(nèi)存中的變量X復(fù)制一份到自己的工作內(nèi)存(此時變量X的值為2),此時獲取的變量X(值為2)就是線程A操作后的值,那么線程B同樣修改該變量,改為3,修改后線程B也會將變量重新刷回到主內(nèi)存,此時,主內(nèi)存中的變量X的值為3,線程A緩存中的值為2,線程B緩存中的值為3,那么線程A再要操作變量X的時候,就會直接操作緩存中的數(shù)據(jù)2,此時該值就不是正確的值了,出現(xiàn)內(nèi)存可見性的問題。
解決內(nèi)存可見性,就是講共享變量X使用volatile或synchronized關(guān)鍵字。
2.5synchronized關(guān)鍵字
synchronized也能夠解決共享變量的內(nèi)存可見性問題,通常是用來解決原子性問題。
synchronized內(nèi)存語義:就是在進(jìn)入synchrinize代碼塊時,把塊內(nèi)使用到的變量從線程的工作內(nèi)存中清除,直接使用主內(nèi)存中的變量數(shù)據(jù),同樣在退出synchronize塊時,將對共享變量的操作刷新到主內(nèi)存。(也是枷鎖和解鎖的語義,加鎖是清空線程工作緩存中共享變量的值,在使用的時候直接加載主內(nèi)存中的數(shù)據(jù),釋放鎖的時候,將線程內(nèi)共享變量的數(shù)據(jù)刷回到主內(nèi)存中)
2.6volatile關(guān)鍵字
volatile可以保證在對共享變量操作時對其他線程是可見的。volatile能夠保證可見性,但是不能保證原子性(synchronized能夠保證可見性和原子性)
2.7原子操作
原子操作:指的就是一系列操作要么都執(zhí)行成功,要么都失敗。
例如程序計數(shù)器 ++count; 操作就不是一個原子操作,因?yàn)樗鼉?nèi)部設(shè)計到讀-改-寫三個操作。
2.8CAS操作
CAS即campare and swap操作是jdk提供的非阻塞的原子操作,它通過硬件來保證“比較-更新”操作的原子性。
CAS中的一個經(jīng)典問題ABA問題,該問題產(chǎn)生的原因就是變量產(chǎn)生了環(huán)形轉(zhuǎn)換,也就是變量A->B->A
AtomicStampedReference能夠解決ABA問題(通過給每一個變量加了一個時間戳)
2.9Unsafe類
提供了硬件級別的原子操作方法(不建議在代碼中使用該類)。
unsafe.objectFieldOffset(Field field):返回偏移量,理解為內(nèi)存里java對象的各個部分放在內(nèi)存的不同位置,而該方法則會返回指定字段相對于java對象的“起始地址”的偏移量,后續(xù)可以通過unsafe的getint、getlong等方法,通過偏移量直接獲取java對象的某個字段
2.10指令重拍
java內(nèi)存模型允許編譯器和處理器對指令進(jìn)行重排序以提供性能,并且只會對不存在數(shù)據(jù)依賴行的指令重排序。
在單線程下指令重排序?qū)ψ罱K結(jié)果沒有影響,但是在多線程下就會存在問題。
2.11偽共享
要理解偽共享需要先理解cpu緩存(1級緩存,2級緩存,3級緩存)、緩存行等
CPU 是計算機(jī)的心臟,所有運(yùn)算和程序最終都要由它來執(zhí)行。為了解決cpu和主內(nèi)存運(yùn)行速度差的問題,會在CPU 和主內(nèi)存之間設(shè)置好幾級緩存,因?yàn)榧词怪苯釉L問主內(nèi)存也是非常慢的。
如果對一塊數(shù)據(jù)做相同的運(yùn)算多次,那么在執(zhí)行運(yùn)算的時候把它加載到離 CPU 很近的地方就有意義了(離cpu遠(yuǎn)近的緩存處理速度越快,其大小也就越小),比如一個循環(huán)計數(shù),你不想每次循環(huán)都跑到主內(nèi)存去取這個數(shù)據(jù)來增長它吧。
?
越靠近 CPU 的緩存越快也越小,所以 L1 緩存很小但很快,并且緊靠著在使用它的 CPU 內(nèi)核。
L2 大一些,也慢一些,并且仍然只能被一個單獨(dú)的 CPU 核使用。L3 在現(xiàn)代多核機(jī)器中更普遍,仍然更大,更慢,并且被單個插槽上的所有 CPU 核共享。
最后,主存保存著程序運(yùn)行的所有數(shù)據(jù),它更大,更慢,由全部插槽上的所有 CPU 核共享。
當(dāng) CPU 執(zhí)行運(yùn)算的時候,它先去 L1 查找所需的數(shù)據(jù),再去 L2,然后是 L3,最后如果這些緩存中都沒有,所需的數(shù)據(jù)就要去主內(nèi)存拿,走得越遠(yuǎn),運(yùn)算耗費(fèi)的時間就越長,所以如果進(jìn)行一些很頻繁的運(yùn)算,要確保數(shù)據(jù)在 L1 緩存中。
cpu緩存行
緩存是由緩存行組成的,通常是 2的冪次數(shù)字節(jié),例如64 字節(jié)(常用處理器的緩存行是 64 字節(jié)的,比較舊的處理器緩存行是 32 字節(jié)),并且它有效地引用主內(nèi)存中的一塊地址。
一個 Java 的 long 類型是 8 字節(jié),因此在一個緩存行中可以存 8 個 long 類型的變量。
在程序運(yùn)行的過程中,緩存每次更新都從主內(nèi)存中加載連續(xù)的 64 個字節(jié)。因此,如果訪問一個 long 類型的數(shù)組時,當(dāng)數(shù)組中的一個值被加載到緩存中時,另外 7 個元素也會被加載到緩存中。
但是,如果使用的數(shù)據(jù)結(jié)構(gòu)中的項(xiàng)在內(nèi)存中不是彼此相鄰的,比如鏈表,那么將得不到免費(fèi)緩存加載帶來的好處。
不過,這種免費(fèi)加載也有一個壞處。設(shè)想如果我們有個 long 類型的變量 a,它不是數(shù)組的一部分,而是一個單獨(dú)的變量,并且還有另外一個 long 類型的變量 b 緊挨著它,那么當(dāng)加載 a 的時候?qū)⒚赓M(fèi)加載 b(前提這兩個變量都是volatile修飾的)。
看起來似乎沒有什么毛病,但是如果一個 CPU 核心的線程在對 a 進(jìn)行修改,另一個 CPU 核心的線程卻在對 b 進(jìn)行讀取。
當(dāng)前者修改 a 時,會把 a 和 b 同時加載到前者核心的緩存行中,更新完 a 后其它所有包含 a 的緩存行都將失效,因?yàn)槠渌彺嬷械?a 不是最新值了。而當(dāng)后者讀取 b 時,發(fā)現(xiàn)這個緩存行已經(jīng)失效了,需要從主內(nèi)存中重新加載。
請記住,我們的緩存都是以緩存行作為一個單位來處理的,所以失效 a 的緩存的同時,也會把 b 失效,反之亦然。
?
這樣就出現(xiàn)了一個問題,b 和 a 完全不相干,每次卻要因?yàn)?a 的更新需要從主內(nèi)存重新讀取,它被緩存未命中給拖慢了。這就是偽共享。
當(dāng)多線程修改互相獨(dú)立的變量時,如果這些變量共享同一個緩存行,就會無意中影響彼此的性能,這就是偽共享。
我們來看看下面這個例子,充分說明了偽共享是怎么回事。
public class FalseSharingTest {public static void main(String[] args) throws InterruptedException {testPointer(new Pointer());}private static void testPointer(Pointer pointer) throws InterruptedException {long start = System.currentTimeMillis();Thread t1 = new Thread(() -> {for (int i = 0; i < 100000000; i++) {pointer.x++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 100000000; i++) {pointer.y++;}});t1.start();t2.start();t1.join();t2.join();System.out.println(System.currentTimeMillis() - start);System.out.println(pointer);} }class Pointer {volatile long x;volatile long y; }這個例子中,我們聲明了一個 Pointer 的類,它包含 x 和 y 兩個變量(必須聲明為volatile,保證可見性,關(guān)于內(nèi)存屏障的東西我們后面再講),一個線程對 x 進(jìn)行自增1億次,一個線程對 y 進(jìn)行自增1億次。
可以看到,x 和 y 完全沒有任何關(guān)系,但是更新 x 的時候會把其它包含 x 的緩存行失效,同時也就失效了 y,運(yùn)行這段程序輸出的時間為3890ms。
偽共享的原理我們知道了,一個緩存行是 64 個字節(jié),一個 long 類型是 8 個字節(jié),所以避免偽共享也很簡單,大概有以下三種方式:
(1)在兩個 long 類型的變量之間再加 7 個 long 類型
我們把上面的Pointer改成下面這個結(jié)構(gòu):
class Pointer {volatile long x;long p1, p2, p3, p4, p5, p6, p7; // 添加這7個變量的原因就是讓x和y不在一個緩存行中,這樣修改x的時候,就不會影響到對y的操作volatile long y; }再次運(yùn)行程序,會發(fā)現(xiàn)輸出時間神奇的縮短為了695ms。
(2)重新創(chuàng)建自己的 long 類型,而不是 java 自帶的 long
修改Pointer如下:
class Pointer {MyLong x = new MyLong();MyLong y = new MyLong(); }class MyLong {volatile long value;long p1, p2, p3, p4, p5, p6, p7; // 同樣是占用緩沖行的位置 }同時把 pointer.x++; 修改為 pointer.x.value++;,把 pointer.y++; 修改為 pointer.y.value++;,再次運(yùn)行程序發(fā)現(xiàn)時間是724ms。
(3)使用 @sun.misc.Contended 注解(java8)
修改 MyLong 如下:
@sun.misc.Contended class MyLong {volatile long value; }默認(rèn)使用這個注解是無效的,需要在JVM啟動參數(shù)加上-XX:-RestrictContended才會生效,,再次運(yùn)行程序發(fā)現(xiàn)時間是718ms。
注意,以上三種方式中的前兩種是通過加字段的形式實(shí)現(xiàn)的,加的字段又沒有地方使用,可能會被jvm優(yōu)化掉,所以建議使用第三種方式。
(1)CPU具有多級緩存,越接近CPU的緩存越小也越快;
(2)CPU緩存中的數(shù)據(jù)是以緩存行為單位處理的;
(3)CPU緩存行能帶來免費(fèi)加載數(shù)據(jù)的好處,所以處理數(shù)組性能非常高;
(4)CPU緩存行也帶來了弊端,多線程處理不相干的變量時會相互影響,也就是偽共享;
(5)避免偽共享的主要思路就是讓不相干的變量不要出現(xiàn)在同一個緩存行中;
(6)一是每兩個變量之間加七個 long 類型;
(7)二是創(chuàng)建自己的 long 類型,而不是用原生的;
(8)三是使用 java8 提供的注解;
?
2.12鎖
樂觀鎖和悲觀鎖:事務(wù)性
公平鎖和非公平鎖:獲取所的機(jī)制(先到先得就是公平,隨機(jī)搶占就是非公平的)
獨(dú)占所和共享鎖:能夠被多個線程共同持有
可重入鎖:持有鎖的對象是自己時,不會被阻塞
自旋鎖:當(dāng)線程在獲取鎖的時候,發(fā)現(xiàn)鎖已被其他線程占用,此時該線程并不會立刻阻塞,而是循環(huán)多次獲取(默認(rèn)次數(shù)為10次),扔獲取不到時,才會阻塞線程。其中阻塞此時可以設(shè)置-XX:PreBlockSpinsh
?
?
?
參考:偽共享相關(guān):https://www.jianshu.com/p/7758bb277985
與50位技術(shù)專家面對面20年技術(shù)見證,附贈技術(shù)全景圖總結(jié)
以上是生活随笔為你收集整理的java并发编程之美-阅读记录2的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: java并发编程之美-阅读记录1
- 下一篇: java并发编程之美-阅读记录3