Java 高并發(fā)九:鎖的優(yōu)化和注意事項詳解
摘要
本系列基于煉數(shù)成金課程,為了更好的學(xué)習(xí),做了系列的記錄。 本文主要介紹: 1. 鎖優(yōu)化的思路和方法 2. 虛擬機內(nèi)的鎖優(yōu)化 3. 一個錯誤使用鎖的案例 4. ThreadLocal及其源碼分析
1. 鎖優(yōu)化的思路和方法
在[高并發(fā)Java 一] 前言中有提到并發(fā)的級別。
一旦用到鎖,就說明這是阻塞式的,所以在并發(fā)度上一般來說都會比無鎖的情況低一點。
這里提到的鎖優(yōu)化,是指在阻塞式的情況下,如何讓性能不要變得太差。但是再怎么優(yōu)化,一般來說性能都會比無鎖的情況差一點。
這里要注意的是,在[高并發(fā)Java 五] JDK并發(fā)包1中提到的ReentrantLock中的tryLock,偏向于一種無鎖的方式,因為在tryLock判斷時,并不會把自己掛起。
鎖優(yōu)化的思路和方法總結(jié)一下,有以下幾種。
- 減少鎖持有時間
- 減小鎖粒度
- 鎖分離
- 鎖粗化
- 鎖消除
1.1 減少鎖持有時間
public synchronized void syncMethod(){ othercode1(); mutextMethod(); othercode2(); }
像上述代碼這樣,在進入方法前就要得到鎖,其他線程就要在外面等待。
這里優(yōu)化的一點在于,要減少其他線程等待的時間,所以,只用在有線程安全要求的程序上加鎖
public void syncMethod(){ othercode1(); synchronized(this) { mutextMethod(); } othercode2(); }
1.2 減小鎖粒度
將大對象(這個對象可能會被很多線程訪問),拆成小對象,大大增加并行度,降低鎖競爭。降低了鎖的競爭,偏向鎖,輕量級鎖成功率才會提高。
最最典型的減小鎖粒度的案例就是ConcurrentHashMap。這個在[高并發(fā)Java 五] JDK并發(fā)包1有提到。
1.3 鎖分離
最常見的鎖分離就是讀寫鎖ReadWriteLock,根據(jù)功能進行分離成讀鎖和寫鎖,這樣讀讀不互斥,讀寫互斥,寫寫互斥,即保證了線程安全,又提高了性能,具體也請查看[高并發(fā)Java 五] JDK并發(fā)包1。
讀寫分離思想可以延伸,只要操作互不影響,鎖就可以分離。
比如LinkedBlockingQueue
從頭部取出,從尾部放數(shù)據(jù)。當(dāng)然也類似于[高并發(fā)Java 六] JDK并發(fā)包2中提到的ForkJoinPool中的工作竊取。
1.4 鎖粗化
通常情況下,為了保證多線程間的有效并發(fā),會要求每個線程持有鎖的時間盡量短,即在使用完公共資源后,應(yīng)該立即釋放鎖。只有這樣,等待在這個鎖上的其他線程才能盡早的獲得資源執(zhí)行任務(wù)。但是,凡事都有一個度,如果對同一個鎖不停的進行請求、同步和釋放,其本身也會消耗系統(tǒng)寶貴的資源,反而不利于性能的優(yōu)化 。
舉個例子:
public void demoMethod(){ synchronized(lock){ //do sth. } //做其他不需要的同步的工作,但能很快執(zhí)行完畢 synchronized(lock){ //do sth. } }
這種情況,根據(jù)鎖粗化的思想,應(yīng)該合并
public void demoMethod(){ //整合成一次鎖請求 synchronized(lock){ //do sth. //做其他不需要的同步的工作,但能很快執(zhí)行完畢 } }
當(dāng)然這是有前提的,前提就是中間的那些不需要同步的工作是很快執(zhí)行完成的。
再舉一個極端的例子:
for(int i=0;i<CIRCLE;i++){ synchronized(lock){ } }
在一個循環(huán)內(nèi)不同得獲得鎖。雖然JDK內(nèi)部會對這個代碼做些優(yōu)化,但是還不如直接寫成
synchronized(lock){ for(int i=0;i<CIRCLE;i++){ } }
當(dāng)然如果有需求說,這樣的循環(huán)太久,需要給其他線程不要等待太久,那只能寫成上面那種。如果沒有這樣類似的需求,還是直接寫成下面那種比較好。
1.5 鎖消除
鎖消除是在編譯器級別的事情。
在即時編譯器時,如果發(fā)現(xiàn)不可能被共享的對象,則可以消除這些對象的鎖操作。
也許你會覺得奇怪,既然有些對象不可能被多線程訪問,那為什么要加鎖呢?寫代碼時直接不加鎖不就好了。
但是有時,這些鎖并不是程序員所寫的,有的是JDK實現(xiàn)中就有鎖的,比如Vector和StringBuffer這樣的類,它們中的很多方法都是有鎖的。當(dāng)我們在一些不會有線程安全的情況下使用這些類的方法時,達到某些條件時,編譯器會將鎖消除來提高性能。
比如:
public static void main(String args[]) throws InterruptedException { long start = System.currentTimeMillis(); for (int i = 0; i < 2000000; i++) { createStringBuffer("JVM", "Diagnosis"); } long bufferCost = System.currentTimeMillis() - start; System.out.println("craeteStringBuffer: " + bufferCost + " ms"); } public static String createStringBuffer(String s1, String s2) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); return sb.toString(); }
上述代碼中的StringBuffer.append是一個同步操作,但是StringBuffer卻是一個局部變量,并且方法也并沒有把StringBuffer返回,所以不可能會有多線程去訪問它。
那么此時StringBuffer中的同步操作就是沒有意義的。
開啟鎖消除是在JVM參數(shù)上設(shè)置的,當(dāng)然需要在server模式下:
-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
并且要開啟逃逸分析。 逃逸分析的作用呢,就是看看變量是否有可能逃出作用域的范圍。
比如上述的StringBuffer,上述代碼中craeteStringBuffer的返回是一個String,所以這個局部變量StringBuffer在其他地方都不會被使用。如果將craeteStringBuffer改成
public static StringBuffer craeteStringBuffer(String s1, String s2) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); return sb; }
那么這個 StringBuffer被返回后,是有可能被任何其他地方所使用的(譬如被主函數(shù)將返回結(jié)果put進map啊等等)。那么JVM的逃逸分析可以分析出,這個局部變量 StringBuffer逃出了它的作用域。
所以基于逃逸分析,JVM可以判斷,如果這個局部變量StringBuffer并沒有逃出它的作用域,那么可以確定這個StringBuffer并不會被多線程所訪問,那么就可以把這些多余的鎖給去掉來提高性能。
當(dāng)JVM參數(shù)為:
-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
輸出:
craeteStringBuffer: 302 ms
JVM參數(shù)為:
-server -XX:+DoEscapeAnalysis -XX:-EliminateLocks
輸出:
craeteStringBuffer: 660 ms
顯然,鎖消除的效果還是很明顯的。
2. 虛擬機內(nèi)的鎖優(yōu)化
首先要介紹下對象頭,在JVM中,每個對象都有一個對象頭。
Mark Word,對象頭的標(biāo)記,32位(32位系統(tǒng))。
描述對象的hash、鎖信息,垃圾回收標(biāo)記,年齡
還會保存指向鎖記錄的指針,指向monitor的指針,偏向鎖線程ID等。
簡單來說,對象頭就是要保存一些系統(tǒng)性的信息。
2.1 偏向鎖
所謂的偏向,就是偏心,即鎖會偏向于當(dāng)前已經(jīng)占有鎖的線程 。
大部分情況是沒有競爭的(某個同步塊大多數(shù)情況都不會出現(xiàn)多線程同時競爭鎖),所以可以通過偏向來提高性能。即在無競爭時,之前獲得鎖的線程再次獲得鎖時,會判斷是否偏向鎖指向我,那么該線程將不用再次獲得鎖,直接就可以進入同步塊。
偏向鎖的實施就是將對象頭Mark的標(biāo)記設(shè)置為偏向,并將線程ID寫入對象頭Mark
當(dāng)其他線程請求相同的鎖時,偏向模式結(jié)束
JVM默認(rèn)啟用偏向鎖 -XX:+UseBiasedLocking
在競爭激烈的場合,偏向鎖會增加系統(tǒng)負(fù)擔(dān)(每次都要加一次是否偏向的判斷)
偏向鎖的例子:
package test; import java.util.List; import java.util.Vector; public class Test { public static List<Integer> numberList = new Vector<Integer>(); public static void main(String[] args) throws InterruptedException { long begin = System.currentTimeMillis(); int count = 0; int startnum = 0; while (count < 10000000) { numberList.add(startnum); startnum += 2; count++; } long end = System.currentTimeMillis(); System.out.println(end - begin); } }
Vector是一個線程安全的類,內(nèi)部使用了鎖機制。每次add都會進行鎖請求。上述代碼只有main一個線程再反復(fù)add請求鎖。
使用如下的JVM參數(shù)來設(shè)置偏向鎖:
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
BiasedLockingStartupDelay表示系統(tǒng)啟動幾秒鐘后啟用偏向鎖。默認(rèn)為4秒,原因在于,系統(tǒng)剛啟動時,一般數(shù)據(jù)競爭是比較激烈的,此時啟用偏向鎖會降低性能。
由于這里為了測試偏向鎖的性能,所以把延遲偏向鎖的時間設(shè)置為0。
此時輸出為9209
下面關(guān)閉偏向鎖:
-XX:-UseBiasedLocking
輸出為9627
一般在無競爭時,啟用偏向鎖性能會提高5%左右。
2.2 輕量級鎖
Java的多線程安全是基于Lock機制實現(xiàn)的,而Lock的性能往往不如人意。
原因是,monitorenter與monitorexit這兩個控制多線程同步的bytecode原語,是JVM依賴操作系統(tǒng)互斥(mutex)來實現(xiàn)的。
互斥是一種會導(dǎo)致線程掛起,并在較短的時間內(nèi)又需要重新調(diào)度回原線程的,較為消耗資源的操作。
為了優(yōu)化Java的Lock機制,從Java6開始引入了輕量級鎖的概念。
輕量級鎖(Lightweight Locking)本意是為了減少多線程進入互斥的幾率,并不是要替代互斥。
它利用了CPU原語Compare-And-Swap(CAS,匯編指令CMPXCHG),嘗試在進入互斥前,進行補救。
如果偏向鎖失敗,那么系統(tǒng)會進行輕量級鎖的操作。它存在的目的是盡可能不用動用操作系統(tǒng)層面的互斥,因為那個性能會比較差。因為JVM本身就是一個應(yīng)用,所以希望在應(yīng)用層面上就解決線程同步問題。
總結(jié)一下就是輕量級鎖是一種快速的鎖定方法,在進入互斥之前,使用CAS操作來嘗試加鎖,盡量不要用操作系統(tǒng)層面的互斥,提高了性能。
那么當(dāng)偏向鎖失敗時,輕量級鎖的步驟:
1.將對象頭的Mark指針保存到鎖對象中(這里的對象指的就是鎖住的對象,比如synchronized (this){},this就是這里的對象)。
lock->set_displaced_header(mark);
2.將對象頭設(shè)置為指向鎖的指針(在線程??臻g中)。
if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(),mark)) { TEVENT (slow_enter: release stacklock) ; return ; }
lock位于線程棧中。所以判斷一個線程是否持有這把鎖,只要判斷這個對象頭指向的空間是否在這個線程棧的地址空間當(dāng)中。
如果輕量級鎖失敗,表示存在競爭,升級為重量級鎖(常規(guī)鎖),就是操作系統(tǒng)層面的同步方法。在沒有鎖競爭的情況,輕量級鎖減少傳統(tǒng)鎖使用OS互斥量產(chǎn)生的性能損耗。在競爭非常激烈時(輕量級鎖總是失敗),輕量級鎖會多做很多額外操作,導(dǎo)致性能下降。
2.3 自旋鎖
當(dāng)競爭存在時,因為輕量級鎖嘗試失敗,之后有可能會直接升級成重量級鎖動用操作系統(tǒng)層面的互斥。也有可能再嘗試一下自旋鎖。
如果線程可以很快獲得鎖,那么可以不在OS層掛起線程,讓線程做幾個空操作(自旋),并且不停地嘗試拿到這個鎖(類似tryLock),當(dāng)然循環(huán)的次數(shù)是有限制的,當(dāng)循環(huán)次數(shù)達到以后,仍然升級成重量級鎖。所以在每個線程對于鎖的持有時間很少時,自旋鎖能夠盡量避免線程在OS層被掛起。
JDK1.6中-XX:+UseSpinning開啟
JDK1.7中,去掉此參數(shù),改為內(nèi)置實現(xiàn)
如果同步塊很長,自旋失敗,會降低系統(tǒng)性能。如果同步塊很短,自旋成功,節(jié)省線程掛起切換時間,提升系統(tǒng)性能。
2.4 偏向鎖,輕量級鎖,自旋鎖總結(jié)
上述的鎖不是Java語言層面的鎖優(yōu)化方法,是內(nèi)置在JVM當(dāng)中的。
首先偏向鎖是為了避免某個線程反復(fù)獲得/釋放同一把鎖時的性能消耗,如果仍然是同個線程去獲得這個鎖,嘗試偏向鎖時會直接進入同步塊,不需要再次獲得鎖。
而輕量級鎖和自旋鎖都是為了避免直接調(diào)用操作系統(tǒng)層面的互斥操作,因為掛起線程是一個很耗資源的操作。
為了盡量避免使用重量級鎖(操作系統(tǒng)層面的互斥),首先會嘗試輕量級鎖,輕量級鎖會嘗試使用CAS操作來獲得鎖,如果輕量級鎖獲得失敗,說明存在競爭。但是也許很快就能獲得鎖,就會嘗試自旋鎖,將線程做幾個空循環(huán),每次循環(huán)時都不斷嘗試獲得鎖。如果自旋鎖也失敗,那么只能升級成重量級鎖。
可見偏向鎖,輕量級鎖,自旋鎖都是樂觀鎖。
3. 一個錯誤使用鎖的案例
public class IntegerLock { static Integer i = 0; public static class AddThread extends Thread { public void run() { for (int k = 0; k < 100000; k++) { synchronized (i) { i++; } } } } public static void main(String[] args) throws InterruptedException { AddThread t1 = new AddThread(); AddThread t2 = new AddThread(); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(i); } }
一個很初級的錯誤在于,在 [高并發(fā)Java 七] 并發(fā)設(shè)計模式提到,Interger是final不變的,每次++后,會產(chǎn)生一個新的 Interger再賦給i,所以兩個線程爭奪的鎖是不同的。所以并不是線程安全的。
4. ThreadLocal及其源碼分析
這里來提ThreadLocal可能有點不合適,但是ThreadLocal是可以把鎖代替的方式。所以還是有必要提一下。
基本的思想就是,在一個多線程當(dāng)中需要把有數(shù)據(jù)沖突的數(shù)據(jù)加鎖,使用ThreadLocal的話,為每一個線程都提供一個對象實例。不同的線程只訪問自己的對象,而不訪問其他的對象。這樣鎖就沒有必要存在了。
package test; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class Test { private static final SimpleDateFormat sdf = new SimpleDateFormat( "yyyy-MM-dd HH:mm:ss"); public static class ParseDate implements Runnable { int i = 0; public ParseDate(int i) { this.i = i; } public void run() { try { Date t = sdf.parse("2016-02-16 17:00:" + i % 60); System.out.println(i + ":" + t); } catch (ParseException e) { e.printStackTrace(); } } } public static void main(String[] args) { ExecutorService es = Executors.newFixedThreadPool(10); for (int i = 0; i < 1000; i++) { es.execute(new ParseDate(i)); } } }
由于SimpleDateFormat并不線程安全的,所以上述代碼是錯誤的使用。最簡單的方式就是,自己定義一個類去用synchronized包裝(類似于Collections.synchronizedMap)。這樣做在高并發(fā)時會有問題,對 synchronized的爭用導(dǎo)致每一次只能進去一個線程,并發(fā)量很低。
這里使用ThreadLocal去封裝SimpleDateFormat就解決了這個問題
package test; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class Test { static ThreadLocal<SimpleDateFormat> tl = new ThreadLocal<SimpleDateFormat>(); public static class ParseDate implements Runnable { int i = 0; public ParseDate(int i) { this.i = i; } public void run() { try { if (tl.get() == null) { tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); } Date t = tl.get().parse("2016-02-16 17:00:" + i % 60); System.out.println(i + ":" + t); } catch (ParseException e) { e.printStackTrace(); } } } public static void main(String[] args) { ExecutorService es = Executors.newFixedThreadPool(10); for (int i = 0; i < 1000; i++) { es.execute(new ParseDate(i)); } } }
每個線程在運行時,會判斷是否當(dāng)前線程有SimpleDateFormat對象
if (tl.get() == null)
如果沒有的話,就new個 SimpleDateFormat與當(dāng)前線程綁定
tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
然后用當(dāng)前線程的 SimpleDateFormat去解析
tl.get().parse("2016-02-16 17:00:" + i % 60);
一開始的代碼中,只有一個 SimpleDateFormat,使用了 ThreadLocal,為每一個線程都new了一個 SimpleDateFormat。
需要注意的是,這里不要把公共的一個SimpleDateFormat設(shè)置給每一個ThreadLocal,這樣是沒用的。需要給每一個都new一個SimpleDateFormat。
在hibernate中,對ThreadLocal有典型的應(yīng)用。
下面來看一下ThreadLocal的源碼實現(xiàn)
首先Thread類中有一個成員變量:
ThreadLocal.ThreadLocalMap threadLocals = null;
而這個Map就是ThreadLocal的實現(xiàn)關(guān)鍵
public void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); }
根據(jù) ThreadLocal可以set和get相對應(yīng)的value。
這里的ThreadLocalMap實現(xiàn)和HashMap差不多,但是在hash沖突的處理上有區(qū)別。
ThreadLocalMap中發(fā)生hash沖突時,不是像HashMap這樣用鏈表來解決沖突,而是是將索引++,放到下一個索引處來解決沖突。
相關(guān)文章
Java存儲過程調(diào)用CallableStatement的方法
這篇文章主要介紹了Java存儲過程調(diào)用CallableStatement的方法,幫助大家更好的理解和學(xué)習(xí)Java,感興趣的朋友可以了解下2020-11-11使用JavaConfig代替xml實現(xiàn)Spring配置操作
這篇文章主要介紹了使用JavaConfig代替xml實現(xiàn)Spring配置操作,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-09-09解決mybatis 中collection嵌套collection引發(fā)的bug
這篇文章主要介紹了解決mybatis 中collection嵌套collection引發(fā)的bug,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-12-12Java中jqGrid 學(xué)習(xí)筆記整理——進階篇(二)
這篇文章主要介紹了Java中jqGrid 學(xué)習(xí)筆記整理——進階篇(二)的相關(guān)資料,需要的朋友可以參考下2016-04-04