高併發Java 九 鎖的優化和注意事項
1. 鎖優化的思路和方法
在[高併發Java 一] 前言中有提到併發的級別。
一旦用到鎖,就說明這是阻塞式的,所以在併發度上一般來說都會比無鎖的情況低一點。
這裡提到的鎖優化,是指在阻塞式的情況下,如何讓效能不要變得太差。但是再怎麼優化,一般來說效能都會比無鎖的情況差一點。
這裡要注意的是,在[高併發Java 五] JDK併發包1中提到的ReentrantLock中的tryLock,偏向於一種無鎖的方式,因為在tryLock判斷時,並不會把自己掛起。
鎖優化的思路和方法總結一下,有以下幾種。
減少鎖持有時間
減小鎖粒度
鎖分離
鎖粗化
鎖消除
1.1 減少鎖持有時間
public synchronized void syncMethod(){
othercode1();
mutextMethod();
othercode2();
}
像上述程式碼這樣,在進入方法前就要得到鎖,其他執行緒就要在外面等待。
這裡優化的一點在於,要減少其他執行緒等待的時間,所以,只用在有執行緒安全要求的程式上加鎖
public void syncMethod(){
othercode1();
synchronized(this)
{
mutextMethod();
}
othercode2();
}
1.2 減小鎖粒度
將大物件(這個物件可能會被很多執行緒訪問),拆成小物件,大大增加並行度,降低鎖競爭。降低了鎖的競爭,偏向鎖,輕量級鎖成功率才會提高。
最最典型的減小鎖粒度的案例就是ConcurrentHashMap。這個在[高併發Java 五] JDK併發包1有提到。
1.3 鎖分離
最常見的鎖分離就是讀寫鎖ReadWriteLock,根據功能進行分離成讀鎖和寫鎖,這樣讀讀不互斥,讀寫互斥,寫寫互斥,即保證了執行緒安全,又提高了效能,具體也請檢視[高併發Java 五] JDK併發包1。
讀寫分離思想可以延伸,只要操作互不影響,鎖就可以分離。
比如LinkedBlockingQueue
從頭部取出,從尾部放資料。當然也類似於[高併發Java 六] JDK併發包2中提到的ForkJoinPool中的工作竊取。
1.4 鎖粗化
通常情況下,為了保證多執行緒間的有效併發,會要求每個執行緒持有鎖的時間儘量短,即在使用完公共資源後,應該立即釋放鎖。只有這樣,等待在這個鎖上的其他執行緒才能儘早的獲得資源執行任務。但是,凡事都有一個度,如果對同一個鎖不停的進行請求、同步和釋放,其本身也會消耗系統寶貴的資源,反而不利於效能的優化 。
舉個例子:
public void demoMethod(){
synchronized(lock){
//do sth.
}
//做其他不需要的同步的工作,但能很快執行完畢
synchronized(lock){
//do sth.
}
}
這種情況,根據鎖粗化的思想,應該合併
public void demoMethod(){
//整合成一次鎖請求
synchronized(lock){
//do sth.
//做其他不需要的同步的工作,但能很快執行完畢
}
}
當然這是有前提的,前提就是中間的那些不需要同步的工作是很快執行完成的。
再舉一個極端的例子:
for(int i=0;i<CIRCLE;i++){
synchronized(lock){
}
}
在一個迴圈內不同得獲得鎖。雖然JDK內部會對這個程式碼做些優化,但是還不如直接寫成
synchronized(lock){
for(int i=0;i<CIRCLE;i++){
}
}
當然如果有需求說,這樣的迴圈太久,需要給其他執行緒不要等待太久,那隻能寫成上面那種。如果沒有這樣類似的需求,還是直接寫成下面那種比較好。
1.5 鎖消除
鎖消除是在編譯器級別的事情。
在即時編譯器時,如果發現不可能被共享的物件,則可以消除這些物件的鎖操作。
也許你會覺得奇怪,既然有些物件不可能被多執行緒訪問,那為什麼要加鎖呢?寫程式碼時直接不加鎖不就好了。
但是有時,這些鎖並不是程式設計師所寫的,有的是JDK實現中就有鎖的,比如Vector和StringBuffer這樣的類,它們中的很多方法都是有鎖的。當我們在一些不會有執行緒安全的情況下使用這些類的方法時,達到某些條件時,編譯器會將鎖消除來提高效能。
比如:
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引數上設定的,當然需要在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被返回後,是有可能被任何其他地方所使用的(譬如被主函式將返回結果put進map啊等等)。那麼JVM的逃逸分析可以分析出,這個區域性變數 StringBuffer逃出了它的作用域。
所以基於逃逸分析,JVM可以判斷,如果這個區域性變數StringBuffer並沒有逃出它的作用域,那麼可以確定這個StringBuffer並不會被多執行緒所訪問,那麼就可以把這些多餘的鎖給去掉來提高效能。
當JVM引數為:
-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
輸出:
craeteStringBuffer: 302 ms
JVM引數為:
-server -XX:+DoEscapeAnalysis -XX:-EliminateLocks
輸出:
craeteStringBuffer: 660 ms
顯然,鎖消除的效果還是很明顯的。
2. 虛擬機器內的鎖優化
首先要介紹下物件頭,在JVM中,每個物件都有一個物件頭。
Mark Word,物件頭的標記,32位(32位系統)。
描述物件的hash、鎖資訊,垃圾回收標記,年齡
還會儲存指向鎖記錄的指標,指向monitor的指標,偏向鎖執行緒ID等。
簡單來說,物件頭就是要儲存一些系統性的資訊。
2.1 偏向鎖
所謂的偏向,就是偏心,即鎖會偏向於當前已經佔有鎖的執行緒 。
大部分情況是沒有競爭的(某個同步塊大多數情況都不會出現多執行緒同時競爭鎖),所以可以通過偏向來提高效能。即在無競爭時,之前獲得鎖的執行緒再次獲得鎖時,會判斷是否偏向鎖指向我,那麼該執行緒將不用再次獲得鎖,直接就可以進入同步塊。
偏向鎖的實施就是將物件頭Mark的標記設定為偏向,並將執行緒ID寫入物件頭Mark
當其他執行緒請求相同的鎖時,偏向模式結束
JVM預設啟用偏向鎖 -XX:+UseBiasedLocking
在競爭激烈的場合,偏向鎖會增加系統負擔(每次都要加一次是否偏向的判斷)
偏向鎖的例子:
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是一個執行緒安全的類,內部使用了鎖機制。每次add都會進行鎖請求。上述程式碼只有main一個執行緒再反覆add請求鎖。
使用如下的JVM引數來設定偏向鎖:
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
BiasedLockingStartupDelay表示系統啟動幾秒鐘後啟用偏向鎖。預設為4秒,原因在於,系統剛啟動時,一般資料競爭是比較激烈的,此時啟用偏向鎖會降低效能。
由於這裡為了測試偏向鎖的效能,所以把延遲偏向鎖的時間設定為0。
此時輸出為9209
下面關閉偏向鎖:
-XX:-UseBiasedLocking
輸出為9627
一般在無競爭時,啟用偏向鎖效能會提高5%左右。
2.2 輕量級鎖
Java的多執行緒安全是基於Lock機制實現的,而Lock的效能往往不如人意。
原因是,monitorenter與monitorexit這兩個控制多執行緒同步的bytecode原語,是JVM依賴作業系統互斥(mutex)來實現的。
互斥是一種會導致執行緒掛起,並在較短的時間內又需要重新排程回原執行緒的,較為消耗資源的操作。
為了優化Java的Lock機制,從Java6開始引入了輕量級鎖的概念。
輕量級鎖(Lightweight Locking)本意是為了減少多執行緒進入互斥的機率,並不是要替代互斥。
它利用了CPU原語Compare-And-Swap(CAS,彙編指令CMPXCHG),嘗試在進入互斥前,進行補救。
如果偏向鎖失敗,那麼系統會進行輕量級鎖的操作。它存在的目的是儘可能不用動用作業系統層面的互斥,因為那個效能會比較差。因為JVM本身就是一個應用,所以希望在應用層面上就解決執行緒同步問題。
總結一下就是輕量級鎖是一種快速的鎖定方法,在進入互斥之前,使用CAS操作來嘗試加鎖,儘量不要用作業系統層面的互斥,提高了效能。
那麼當偏向鎖失敗時,輕量級鎖的步驟:
1.將物件頭的Mark指標儲存到鎖物件中(這裡的物件指的就是鎖住的物件,比如synchronized (this){},this就是這裡的物件)。
lock->set_displaced_header(mark);
2.將物件頭設定為指向鎖的指標(線上程棧空間中)。
if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(),mark))
{
TEVENT (slow_enter: release stacklock) ;
return ;
}
lock位於執行緒棧中。所以判斷一個執行緒是否持有這把鎖,只要判斷這個物件頭指向的空間是否在這個執行緒棧的地址空間當中。
如果輕量級鎖失敗,表示存在競爭,升級為重量級鎖(常規鎖),就是作業系統層面的同步方法。在沒有鎖競爭的情況,輕量級鎖減少傳統鎖使用OS互斥量產生的效能損耗。在競爭非常激烈時(輕量級鎖總是失敗),輕量級鎖會多做很多額外操作,導致效能下降。
2.3 自旋鎖
當競爭存在時,因為輕量級鎖嘗試失敗,之後有可能會直接升級成重量級鎖動用作業系統層面的互斥。也有可能再嘗試一下自旋鎖。
如果執行緒可以很快獲得鎖,那麼可以不在OS層掛起執行緒,讓執行緒做幾個空操作(自旋),並且不停地嘗試拿到這個鎖(類似tryLock),當然迴圈的次數是有限制的,當迴圈次數達到以後,仍然升級成重量級鎖。所以在每個執行緒對於鎖的持有時間很少時,自旋鎖能夠儘量避免執行緒在OS層被掛起。
JDK1.6中-XX:+UseSpinning開啟
JDK1.7中,去掉此引數,改為內建實現
如果同步塊很長,自旋失敗,會降低系統性能。如果同步塊很短,自旋成功,節省執行緒掛起切換時間,提升系統性能。
2.4 偏向鎖,輕量級鎖,自旋鎖總結
上述的鎖不是Java語言層面的鎖優化方法,是內建在JVM當中的。
首先偏向鎖是為了避免某個執行緒反覆獲得/釋放同一把鎖時的效能消耗,如果仍然是同個執行緒去獲得這個鎖,嘗試偏向鎖時會直接進入同步塊,不需要再次獲得鎖。
而輕量級鎖和自旋鎖都是為了避免直接呼叫作業系統層面的互斥操作,因為掛起執行緒是一個很耗資源的操作。
為了儘量避免使用重量級鎖(作業系統層面的互斥),首先會嘗試輕量級鎖,輕量級鎖會嘗試使用CAS操作來獲得鎖,如果輕量級鎖獲得失敗,說明存在競爭。但是也許很快就能獲得鎖,就會嘗試自旋鎖,將執行緒做幾個空迴圈,每次迴圈時都不斷嘗試獲得鎖。如果自旋鎖也失敗,那麼只能升級成重量級鎖。
可見偏向鎖,輕量級鎖,自旋鎖都是樂觀鎖。
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);
}
}
一個很初級的錯誤在於,在 [高併發Java 七] 併發設計模式提到,Interger是final不變的,每次++後,會產生一個新的 Interger再賦給i,所以兩個執行緒爭奪的鎖是不同的。所以並不是執行緒安全的。
4. ThreadLocal及其原始碼分析
這裡來提ThreadLocal可能有點不合適,但是ThreadLocal是可以把鎖代替的方式。所以還是有必要提一下。
基本的思想就是,在一個多執行緒當中需要把有資料衝突的資料加鎖,使用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)。這樣做在高併發時會有問題,對 synchronized的爭用導致每一次只能進去一個執行緒,併發量很低。
這裡使用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));
}
}
}
每個執行緒在執行時,會判斷是否當前執行緒有SimpleDateFormat物件
if (tl.get() == null)
如果沒有的話,就new個 SimpleDateFormat與當前執行緒繫結
tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
然後用當前執行緒的 SimpleDateFormat去解析
tl.get().parse("2016-02-16 17:00:" + i % 60);
一開始的程式碼中,只有一個 SimpleDateFormat,使用了 ThreadLocal,為每一個執行緒都new了一個 SimpleDateFormat。
需要注意的是,這裡不要把公共的一個SimpleDateFormat設定給每一個ThreadLocal,這樣是沒用的。需要給每一個都new一個SimpleDateFormat。
在hibernate中,對ThreadLocal有典型的應用。
下面來看一下ThreadLocal的原始碼實現
首先Thread類中有一個成員變數:
ThreadLocal.ThreadLocalMap threadLocals = null;
而這個Map就是ThreadLocal的實現關鍵
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
根據 ThreadLocal可以set和get相對應的value。
這裡的ThreadLocalMap實現和HashMap差不多,但是在hash衝突的處理上有區別。
ThreadLocalMap中發生hash衝突時,不是像HashMap這樣用連結串列來解決衝突,而是是將索引++,放到下一個索引處來解決衝突。