鎖的優化和注意事項

摘要: 本系列基於煉數成金課程,爲了更好的學習,做了系列的記錄。 本文主要介紹: 1. 鎖優化的思路和方法 2. 虛擬機內的鎖優化 3. 一個錯誤使用鎖的案例 4. ThreadLocal及其源碼分析

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這樣用鏈表來解決衝突,而是是將索引++,放到下一個索引處來解決衝突。

https://my.oschina.net/hosee/blog/615865

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章