Java併發程式設計:效能、擴充套件性和響應

2016-02-15    分類:JAVA開發、推薦閱讀、程式設計開發、首頁精華1人評論發表於2016-02-15

本文由碼農網 – Sandbox Wang原創翻譯,轉載請看清文末的轉載要求,歡迎參與我們的付費投稿計劃

1、介紹

本文討論的重點在於多執行緒應用程式的效能問題。我們會先給效能和擴充套件性下一個定義,然後再仔細學習一下Amdahl法則。下面的內容我們會考察一下如何用不同的技術方法來減少鎖競爭,以及如何用程式碼來實現。

2、效能

我們都知道,多執行緒可以用來提高程式的效能,背後的原因在於我們有多核的CPU或多個CPU。每個CPU的核心都可以自己完成任務,因此把一個大的任務分解成一系列的可彼此獨立執行的小任務就可以提高程式的整體效能了。可以舉個例子,比如有個程式用來將硬碟上某個資料夾下的所有圖片的尺寸進行修改,應用多執行緒技術就可以提高它的效能。使用單執行緒的方式只能依次遍歷所有圖片檔案並且執行修改,如果我們的CPU有多個核心的話,毫無疑問,它只能利用其中的一個核。使用多執行緒的方式的話,我們可以讓一個生產者執行緒掃描檔案系統把每個圖片都新增到一個佇列中,然後用多個工作執行緒來執行這些任務。如果我們的工作執行緒的數量和CPU總的核心數一樣的話,我們就能保證每個CPU核心都有活可幹,直到任務被全部執行完成。

對於另外一種需要較多IO等待的程式來說,利用多執行緒技術也能提高整體效能。假設我們要寫這樣一個程式,需要抓取某個網站的所有HTML檔案,並且將它們儲存到本地磁碟上。程式可以從某一個網頁開始,然後解析這個網頁中所有指向本網站的連結,然後依次抓取這些連結,這樣周而復始。因為從我們對遠端網站發起請求到接收到所有的網頁資料需要等待一段時間,所以我們可以將此任務交給多個執行緒來執行。讓一個或稍微更多一點的執行緒來解析已經收到的HTML網頁以及將找到的連結放入佇列中,讓其他所有的執行緒負責請求獲取頁面。與上一個例子不同的是,在這個例子中,你即便使用多於CPU核心數量的執行緒也仍然能夠獲得效能提升。

上面這兩個例子告訴我們,高效能就是在短的時間視窗內做盡量多的事情。這個當然是對效能一詞的最經典解釋了。但是同時,使用執行緒也能很好地提升我們程式的響應速度。想象我們有這樣一個圖形介面的應用程式,上方有一個輸入框,輸入框下面有一個名字叫“處理”的按鈕。當使用者按下這個按鈕的時候,應用程式需要重新對按鈕的狀態進行渲染(按鈕看起來被按下了,當鬆開滑鼠左鍵時又恢復原狀),並且開始對使用者的輸入進行處理。如果處理使用者輸入的這個任務比較耗時的話,單執行緒的程式就無法繼續響應使用者其他的輸入動作了,比如,來自作業系統傳送過來的使用者單擊滑鼠事件或滑鼠指標移動事件等等,這些事件的響應需要有獨立的執行緒來響應。

可擴充套件性(Scalability)的意思是程式具備這樣的能力:通過新增計算資源就可以獲得更高的效能。想象我們需要調整很多圖片的大小,因為我們機器的CPU核心數是有限的,所以增加執行緒數量並不總能相應提高效能。相反,因為排程器需要負責更多執行緒的建立和關閉,也會佔用CPU資源,反而有可能降低效能。

2.1 Amdahl法則

上一段提到了在某些情形下,新增額外的運算資源可以提高程式的整體效能。為了能夠計算出當我們新增了額外的資源的時候到底能獲得多少效能提升,我們有必要來檢查一下程式有哪些部分是序列執行(或同步執行),有哪些部分是並行執行的。如果我們把需要同步執行的程式碼佔比量化為B(例如,需要同步執行的程式碼的行數),把CPU的總核心數記為n,那麼,根據Amdahl法則,我們可以獲得的效能提升的上限是:

Figure 1

如果n趨於無窮大的話,(1-B)/n就收斂於0。因此,我們可以忽略這個表示式的值,因此效能提升位數收斂於1/B,這裡面的B代表是那些必須同步執行的程式碼比例。如果B等於0.5的話,那意味著程式的一半程式碼無法並行執行,0.5的倒數是2,因此,即使我們新增無數個CPU核心,我們獲得的效能提升也最多是2倍。假設我們現在把程式修改了一下,修改之後只有0.25的程式碼必須同步執行,現在1/0.25=4,表示我們的程式如果在具有大量CPU的硬體上執行時速度將會比在單核的硬體上快大概4倍。

另一方面,通過Amdahl法則,我們也能根據我們想獲得的提速的目標計算出程式應該的同步程式碼的比例。如果我們想要達到100倍的提速,而1/100=0.01,意味著,我們程式同步執行的程式碼的數量最多不能超過1%。

總結Amdahl法則我們可以看出,我們通過新增額外CPU來獲得效能提升的最大值取決於程式同步執行部分程式碼所佔的比例有多小。雖然在實際中,想要計算出這個比例並不總是那麼容易,更別說面對一些大型的商業系統應用了,但是Amdahl法則給了我們很重要的啟示,那就是,我們必須非常仔細地去考慮那些必須同步執行的程式碼,並且力圖減少這部分程式碼。

2.2 對效能的影響

文章寫到這裡,我們已經表明這樣一個觀點:增加更多的執行緒可以提高程式的效能和響應速度。但是另一方面,想要取得這些好處卻並非輕而易舉,也需要付出一些代價。執行緒的使用對效能的提升也會有所影響。

首先,第一個影響來自執行緒建立的時候。執行緒的建立過程中,JVM需要從底層作業系統申請相應的資源,並且在排程器中初始化資料結構,以便決定執行執行緒的順序。

如果你的執行緒的數量和CPU的核心數量一樣的話,每個執行緒都會執行在一個核心上,這樣或許他們就不會經常被打斷了。但是事實上,在你的程式執行的時候,作業系統也會有些自己的運算需要CPU去處理。所以,即使這種情形下,你的執行緒也會被打斷並且等待作業系統來重新恢復它的執行。當你的執行緒數量超過CPU的核心數量的時候,情況有可能變得更壞。在這種情況下,JVM的程式排程器會打斷某些執行緒以便讓其他執行緒執行,執行緒切換的時候,剛才正在執行的執行緒的當前狀態需要被儲存下來,以便等下次執行的時候可以恢復資料狀態。不僅如此,排程器也會對它自己內部的資料結構進行更新,而這也需要消耗CPU週期。所有這些都意味著,執行緒之間的上下文切換會消耗CPU計算資源,因此帶來相比單執行緒情況下沒有的效能開銷。

多執行緒程式所帶來的另外一個開銷來自對共享資料的同步訪問保護。我們可以使用synchronized關鍵字來進行同步保護,也可以使用Volatile關鍵字來在多個執行緒之間共享資料。如果多於一個執行緒想要去訪問某一個共享資料結構的話,就發生了爭用的情形,這時,JVM需要決定哪個程式先,哪個程式後。如果決定該要執行的執行緒不是當前正在執行的執行緒,那麼就會發生執行緒切換。當前執行緒需要等待,直到它成功獲得了鎖物件。JVM可以自己決定如何來執行這種“等待”,假如JVM預計離成功獲得鎖物件的時間比較短,那JVM可以使用激進等待方法,比如,不停地嘗試獲得鎖物件,直到成功,在這種情況下這種方式可能會更高效,因為比較程式上下文切換來說,還是這種方式更快速一些。把一個等待狀態的執行緒挪回到執行佇列也會帶來額外的開銷。

因此,我們要盡力避免由於鎖競爭而帶來的上下文切換。下面一節將闡述兩種降低這種競爭發生的方法。

2.3 鎖競爭

像上一節所說的那樣,兩個或更多執行緒對鎖的競爭訪問會帶來額外的運算開銷,因為競爭的發生逼迫排程器來讓一個執行緒進入激進等待狀態,或者讓它進行等待狀態而引發兩次上下文切換。有某些情況下,鎖競爭的惡果可以通過以下方法來減輕:

1,減少鎖的作用域;

2,減少需要獲取鎖的頻率;

3,儘量使用由硬體支援的樂觀鎖操作,而不是synchronized;

4,儘量少用synchronized;

5,減少使用物件快取

2.3.1 縮減同步域

如果程式碼持有鎖超過必要的時間,那麼可以應用這第一種方法。通常我們可以將一行或多行程式碼移出同步區域來降低當前執行緒持有鎖的時間。在同步區域裡執行的程式碼數量越少,當前執行緒就會越早地釋放鎖,從而讓其他執行緒更早地獲得鎖。這與Amdahl法則相一致的,因為這樣做減少了需要同步執行的程式碼量。

為了更好地理解,看下面的原始碼:

public class ReduceLockDuration implements Runnable {
	private static final int NUMBER_OF_THREADS = 5;
	private static final Map<String, Integer> map = new HashMap<String, Integer>();

	public void run() {
		for (int i = 0; i < 10000; i++) {
			synchronized (map) {
				UUID randomUUID = UUID.randomUUID();
				Integer value = Integer.valueOf(42);
				String key = randomUUID.toString();
				map.put(key, value);
			}
			Thread.yield();
		}
	}

	public static void main(String[] args) throws InterruptedException {
		Thread[] threads = new Thread[NUMBER_OF_THREADS];
		for (int i = 0; i < NUMBER_OF_THREADS; i++) {
			threads[i] = new Thread(new ReduceLockDuration());
		}
		long startMillis = System.currentTimeMillis();
		for (int i = 0; i < NUMBER_OF_THREADS; i++) {
			threads[i].start();
		}
		for (int i = 0; i < NUMBER_OF_THREADS; i++) {
			threads[i].join();
		}
		System.out.println((System.currentTimeMillis()-startMillis)+"ms");
	}
}

在上面的例子中,我們讓五個執行緒來競爭訪問共享的Map例項,為了在同一時刻只有一個執行緒可以訪問到Map例項,我們將向Map中新增Key/Value的操作放到了synchronized保護的程式碼塊中。當我們仔細察看這段程式碼的時候,我們可以看到,計算key和value的幾句程式碼並不需要同步執行,key和value只屬於當前執行這段程式碼的執行緒,僅僅對當前執行緒有意義,並且不會被其他執行緒所修改。因此,我們可以把這幾句移出同步保護。如下:

public void run() {
	for (int i = 0; i < 10000; i++) {
		UUID randomUUID = UUID.randomUUID();
		Integer value = Integer.valueOf(42);
		String key = randomUUID.toString();
		synchronized (map) {
			map.put(key, value);
		}
		Thread.yield();
	}
}

降低同步程式碼所帶來的效果是可以測量的。在我的機器上,整個程式的執行時間從420ms降低到了370ms。看看吧,僅僅把三行程式碼移出同步保護塊就可以將程式執行時間減少11%。Thread.yield()這句程式碼是為了誘發執行緒上下文切換的,因為這句程式碼會告訴JVM當前執行緒想要交出當前使用的計算資源,以便讓其他等待執行的執行緒執行。這樣也會帶來更多的鎖競爭的發生,因為,如果不如此的話某一個執行緒就會更久地佔用某個核心繼而減少了執行緒上下文切換。

2.3.2 分拆鎖

另外一種減少鎖競爭的方法是將一塊被鎖定保護的程式碼分散到多個更小的保護塊中。如果你的程式中使用了一個鎖來保護多個不同物件的話,這種方式會有用武之地。假設我們想要通過程式來統計一些資料,並且實現了一個簡單的計數類來持有多個不同的統計指標,並且分別用一個基本計數變數來表示(long型別)。因為我們的程式是多執行緒的,所以我們需要對訪問這些變數的操作進行同步保護,因為這些操作動作來自不同的執行緒。要達到這個目的,最簡單的方式就是對每個訪問了這些變數的函式新增synchronized關鍵字。

public static class CounterOneLock implements Counter {
	private long customerCount = 0;
	private long shippingCount = 0;

	public synchronized void incrementCustomer() {
		customerCount++;
	}

	public synchronized void incrementShipping() {
		shippingCount++;
	}

	public synchronized long getCustomerCount() {
		return customerCount;
	}

	public synchronized long getShippingCount() {
		return shippingCount;
	}
}

這種方式也就意味著,對這些變數的每次修改都會引發對其他Counter例項的鎖定。其他執行緒如果想要對另外一個不同的變數呼叫increment方法,那也只能等待前一個執行緒釋放了鎖控制之後才能有機會去完成。在此種情況下,對每個不同的變數使用單獨的synchronized保護將會提高執行效率。

public static class CounterSeparateLock implements Counter {
	private static final Object customerLock = new Object();
	private static final Object shippingLock = new Object();
	private long customerCount = 0;
	private long shippingCount = 0;

	public void incrementCustomer() {
		synchronized (customerLock) {
			customerCount++;
		}
	}

	public void incrementShipping() {
		synchronized (shippingLock) {
			shippingCount++;
		}
	}

	public long getCustomerCount() {
		synchronized (customerLock) {
			return customerCount;
		}
	}

	public long getShippingCount() {
		synchronized (shippingLock) {
			return shippingCount;
		}
	}
}

這種實現為每個計數指標引入了一個單獨synchronized物件,因此,一個執行緒想要增加Customer計數的時候,它必須等待另一個正在增加Customer計數的執行緒完成,而並不用等待另一個正在增加Shipping計數的執行緒完成。

使用下面的類,我們可以非常容易地計算分拆鎖所帶來的效能提升。

public class LockSplitting implements Runnable {
	private static final int NUMBER_OF_THREADS = 5;
	private Counter counter;

	public interface Counter {
		void incrementCustomer();

		void incrementShipping();

		long getCustomerCount();

		long getShippingCount();
	}

	public static class CounterOneLock implements Counter { ...	}

	public static class CounterSeparateLock implements Counter { ... }

	public LockSplitting(Counter counter) {
		this.counter = counter;
	}

	public void run() {
		for (int i = 0; i < 100000; i++) {
			if (ThreadLocalRandom.current().nextBoolean()) {
				counter.incrementCustomer();
			} else {
				counter.incrementShipping();
			}
		}
	}

	public static void main(String[] args) throws InterruptedException {
		Thread[] threads = new Thread[NUMBER_OF_THREADS];
		Counter counter = new CounterOneLock();
		for (int i = 0; i < NUMBER_OF_THREADS; i++) {
			threads[i] = new Thread(new LockSplitting(counter));
		}
		long startMillis = System.currentTimeMillis();
		for (int i = 0; i < NUMBER_OF_THREADS; i++) {
			threads[i].start();
		}
		for (int i = 0; i < NUMBER_OF_THREADS; i++) {
			threads[i].join();
		}
		System.out.println((System.currentTimeMillis() - startMillis) + "ms");
	}
}

在我的機器上,單一鎖的實現方法平均花費56ms,兩個單獨鎖的實現是38ms。耗時大約降低了大概32%。

另外一種提升方式是,我們甚至可以更進一步地將讀寫分開用不同的鎖來保護。原來的Counter類提供了對計數指標分別提供了讀和寫的方法,但是事實上,讀操作並不需要同步保護,我們可以放心讓多個執行緒並行讀取當前指標的數值,同時,寫操作必須得到同步保護。java.util.concurrent包裡提供了有對ReadWriteLock介面的實現,可以方便地實現這種區分。

ReentrantReadWriteLock實現維護了兩個不同的鎖,一個保護讀操作,一個保護寫操作。這兩個鎖都有獲取鎖和釋放鎖的操作。僅僅當在沒有人獲取讀鎖的時候,寫鎖才能成功獲得。反過來,只要寫鎖沒有被獲取,讀鎖可以被多個執行緒同時獲取。為了演示這種方法,下面的Counter類使用了ReadWriteLock,如下:

public static class CounterReadWriteLock implements Counter {
	private final ReentrantReadWriteLock customerLock = new ReentrantReadWriteLock();
	private final Lock customerWriteLock = customerLock.writeLock();
	private final Lock customerReadLock = customerLock.readLock();
	private final ReentrantReadWriteLock shippingLock = new ReentrantReadWriteLock();
	private final Lock shippingWriteLock = shippingLock.writeLock();
	private final Lock shippingReadLock = shippingLock.readLock();
	private long customerCount = 0;
	private long shippingCount = 0;

	public void incrementCustomer() {
		customerWriteLock.lock();
		customerCount++;
		customerWriteLock.unlock();
	}

	public void incrementShipping() {
		shippingWriteLock.lock();
		shippingCount++;
		shippingWriteLock.unlock();
	}

	public long getCustomerCount() {
		customerReadLock.lock();
		long count = customerCount;
		customerReadLock.unlock();
		return count;
	}

	public long getShippingCount() {
		shippingReadLock.lock();
		long count = shippingCount;
		shippingReadLock.unlock();
		return count;
	}
}

所有的讀操作都被讀鎖保護,同時,所有的寫操作都被寫鎖所保護。如果程式中執行的讀操作要遠大於寫操作的話,這種實現可以帶來比前一節的方式更大的效能提升,因為讀操作可以併發進行。

2.3.3 分離鎖

上面一個例子展示瞭如何將一個單獨的鎖分開為多個單獨的鎖,這樣使得各執行緒僅僅獲得他們將要修改的物件的鎖就可以了。但是另一方面,這種方式也增加了程式的複雜度,如果實現不恰當的話也可能造成死鎖。

分離鎖是與分拆鎖類似的一種方法,但是分拆鎖是增加鎖來保護不同的程式碼片段或物件,而分離鎖是使用不同的鎖來保護不同範圍的數值。JDK的java.util.concurrent包裡的ConcurrentHashMap即使用了這種思想來提高那些嚴重依賴HashMap的程式的效能。在實現上,ConcurrentHashMap內部使用了16個不同的鎖,而不是封裝一個同步保護的HashMap。16個鎖每一個負責保護其中16分之一的桶位(bucket)的同步訪問。這樣一來,不同的執行緒想要向不同的段插入鍵的時候,相應的操作會受到不同的鎖來保護。但是反過來也會帶來一些不好的問題,比如,某些操作的完成現在需要獲取多個鎖而不是一個鎖。如果你想要複製整個Map的話,這16個鎖都需要獲得才能完成。

2.3.4 原子操作

另外一種減少鎖競爭的方法是使用原子操作,這種方式會在其他文章中詳細闡述原理。java.util.concurrent包對一些常用基礎資料型別提供了原子操作封裝的類。原子操作類的實現基於處理器提供的“比較置換”功能(CAS),CAS操作只在當前暫存器的值跟操作提供的舊的值一樣的時候才會執行更新操作。

這個原理可以用來以樂觀的方式來增加一個變數的值。如果我們的執行緒知道當前的值的話,就會嘗試使用CAS操作來執行增加操作。如果期間別的執行緒已經修改了變數的值,那麼執行緒提供的所謂的當前值已經跟真實的值不一樣了,這時JVM來嘗試重新獲得當前值,並且再嘗試一次,反反覆覆直到成功為止。雖然迴圈操作會浪費一些CPU週期,但是這樣做的好處是,我們不需要任何形式的同步控制。

下面的Counter類的實現就利用了原子操作的方式,你可以看到,並沒有使用任何synchronized的程式碼。

public static class CounterAtomic implements Counter {
	private AtomicLong customerCount = new AtomicLong();
	private AtomicLong shippingCount = new AtomicLong();

	public void incrementCustomer() {
		customerCount.incrementAndGet();
	}

	public void incrementShipping() {
		shippingCount.incrementAndGet();
	}

	public long getCustomerCount() {
		return customerCount.get();
	}

	public long getShippingCount() {
		return shippingCount.get();
	}
}

與CounterSeparateLock類相比,平均執行時間從39ms降低到了16ms,大約降低了58%。

2.3.5 避免熱點程式碼段

一個典型的LIST實現通過會在內容維護一個變數來記錄LIST自身所包含的元素個數,每一次從列表裡刪除或增加元素的時候,這個變數的值都會改變。如果LIST在單執行緒應用中使用的話,這種方式無可厚非,每次呼叫size()時直接返回上一次計算之後的數值就行了。如果LIST內部不維護這個計數變數的話,每次呼叫size()操作都會引發LIST重新遍歷計算元素個數。

這種很多資料結構都使用了的優化方式,當到了多執行緒環境下時卻會成為一個問題。假設我們在多個執行緒之間共享一個LIST,多個執行緒同時地去向LIST裡面增加或刪除元素,同時去查詢大的長度。這時,LIST內部的計數變數成為一個共享資源,因此所有對它的訪問都必須進行同步處理。因此,計數變數成為整個LIST實現中的一個熱點。

下面的程式碼片段展示了這個問題:

public static class CarRepositoryWithCounter implements CarRepository {
	private Map<String, Car> cars = new HashMap<String, Car>();
	private Map<String, Car> trucks = new HashMap<String, Car>();
	private Object carCountSync = new Object();
	private int carCount = 0;

	public void addCar(Car car) {
		if (car.getLicencePlate().startsWith("C")) {
			synchronized (cars) {
				Car foundCar = cars.get(car.getLicencePlate());
				if (foundCar == null) {
					cars.put(car.getLicencePlate(), car);
					synchronized (carCountSync) {
						carCount++;
					}
				}
			}
		} else {
			synchronized (trucks) {
				Car foundCar = trucks.get(car.getLicencePlate());
				if (foundCar == null) {
					trucks.put(car.getLicencePlate(), car);
					synchronized (carCountSync) {
						carCount++;
					}
				}
			}
		}
	}

	public int getCarCount() {
		synchronized (carCountSync) {
			return carCount;
		}
	}
}

上面這個CarRepository的實現內部有兩個LIST變數,一個用來放洗車元素,一個用來放卡車元素,同時,提供了查詢這兩個LIST總共的大小的方法。採用的優化方式是,每次新增一個Car元素的時候,都會增加內部的計數變數的值,同時增加的操作受synchronized保護,返回計數值的方法也是一樣。

為了避免帶來這種額外的程式碼同步開銷,看下面另外一種CarRepository的實現:它不再使用一個內部的計數變數,而是在返回汽車總數的方法裡實時計數這個數值。如下:

public static class CarRepositoryWithoutCounter implements CarRepository {
	private Map<String, Car> cars = new HashMap<String, Car>();
	private Map<String, Car> trucks = new HashMap<String, Car>();

	public void addCar(Car car) {
		if (car.getLicencePlate().startsWith("C")) {
			synchronized (cars) {
				Car foundCar = cars.get(car.getLicencePlate());
				if (foundCar == null) {
					cars.put(car.getLicencePlate(), car);
				}
			}
		} else {
			synchronized (trucks) {
				Car foundCar = trucks.get(car.getLicencePlate());
				if (foundCar == null) {
					trucks.put(car.getLicencePlate(), car);
				}
			}
		}
	}

	public int getCarCount() {
		synchronized (cars) {
			synchronized (trucks) {
				return cars.size() + trucks.size();
			}
		}
	}
}

現在,僅僅在getCarCount()方法裡,兩個LIST的訪問需要同步保護,像上一種實現那樣每次新增新元素時的同步開銷已經不存在了。

2.3.6 避免物件快取複用

在JAVA VM的第一版裡,使用new關鍵字來建立新物件的開銷比較大,因此,很多開發人員習慣了使用物件複用模式。為了避免一次又一次重複建立物件,開發人員維護一個緩衝池,每次建立完物件例項之後可以把它們儲存在緩衝池裡,下次其他執行緒再需要使用的時候就可以直接從緩衝池裡去取。

乍一看,這種方式是很合理的,但是這種模式在多執行緒應用程式裡會出現問題。因為物件的緩衝池在多個執行緒之間共享,因此所有執行緒在訪問其中的物件時的操作需要同步保護。而這種同步所帶來的開銷已經大過了建立物件本身了。當然了,建立過多的物件會加重垃圾回收的負擔,但是即便把這個考慮在內,避免同步程式碼所帶來的效能提升仍然要好過使用物件快取池的方式。

本文所講述的這些優化方案再一次的表明,每一種可能的優化方式在真正應用的時候一定需要多多仔細評測。不成熟的優化方案表面看起來好像很有道理,但是事實上很有可能會反過來成為效能的瓶頸。

譯文連結:http://www.codeceo.com/article/java-performance-scalability.html
英文原文:Performance, Scalability and Liveness
翻譯作者:碼農網 – Sandbox Wang
轉載必須在正文中標註並保留原文連結、譯文連結和譯者等資訊。]

相關文章