java多執行緒8.效能與活躍性問題
死鎖——鎖順序死鎖
兩個執行緒試圖以不同的順序來獲得相同的鎖。如果按照相同的順序來請求鎖,那麼就不會出現迴圈的加鎖依賴,因此也就不會產生死鎖。
public class LeftRightDeadlock { private final Object left = new Object(); private final Object right = new Object(); public void leftRight(){ synchronized(left){ synchronized(right){ dosomething(); } } }public void rightLeft(){ synchronized(right){ synchronized(left){ dosomething(); } } } }
動態的鎖順序死鎖
考慮資金轉賬問題,將資金從一個賬戶轉入另一個賬戶。在開始轉賬之前,首先要獲得這兩個Account物件的鎖,以確保通過原子的方式來更新兩個賬戶中的餘額。
// 事實上鎖的順序取決於傳遞給transferMoney的引數順序,而這些引數又取決於外部輸入。這種情況下,外部呼叫很容易發生死鎖。public void transferMoney(Account fromAccount,Account toAccount.DollarAmount amount) throws InsufficientFundsException{ synchronized(fromAccount){ synchronized(toAccount){ if(fromAccount.getBalance().compareTo(amount) < 0){ thrownew InsufficientFundsException(); }else{ fromAccount.debit(amount); toAccount.credit(amount); } } } }
private static final Object tieLock = new Object(); /** * 在指定鎖的順序時,可以使用System.identityHashCode。在極少數情況下,兩個物件可能擁有相同的雜湊值,此時再在外面加一層鎖, * 保證每次只有一個執行緒以一致的順序獲得兩個鎖,從而消除死鎖的可能性。 * 如果Account中包含一個唯一的、不可變的,並且具備可比性的鍵值,如賬號那麼要制定鎖的順序就更加容易了,也就不需要再另外加鎖了。 * * @param fromAccount * @param toAccount * @param amount * @throws InsufficientFundsException */ public void transferMoney2(final Account fromAccount,final Account toAccount,final DollarAmount amount) throws InsufficientFundsException{ class Helper{ public void transfer() throws InsufficientFundsException{ if(fromAccount.getBalance().compareTo(amount) < 0){ throw new InsufficientFundsException(); }else{ fromAccount.debit(amount); toAccount.credit(amount); } } } int fromHash = System.identityHashCode(fromAccount); int toHash = System.identityHashCode(toAccount); if(fromHash < toHash){ synchronized(fromAccount){ synchronized(toAccount){ new Helper().transfer(); } } }else if(fromHash > toHash){ synchronized(toAccount){ synchronized(fromAccount){ new Helper().transfer(); } } }else{ synchronized(tieLock){ synchronized(fromAccount){ synchronized(toAccount){ new Helper().transfer(); } } } } }
如果在持有鎖的情況下呼叫某個外部方法,那麼就需要警惕死鎖。
/** * 儘管沒有任何方法會顯示地獲取兩個鎖,但setLocation和getImage等方法的呼叫都會獲得兩個鎖。 * 如果一個執行緒收到GPS接收器的更新事件時呼叫setLocation,那麼它將首先更新出租車的位置,然後判斷它是否到達了目的地。 * 如果已經到達,它會通知Dispatcher:它需要一個新的目的地。 * setLocation與norifyAvailable將先後獲得Taxi和Dispatcher的鎖而getImage與getLocation將先後獲得Dispatcher與Taxi的鎖。因此就可能產生死鎖。 */ class Taxi{ private Point location; private Point destination; private final Dispatcher dispather; public Taxi(Dispatcher dispather){ this.dispather = dispather; } public synchronized Point getLocation(){ return location; } public synchronized void setLocation(Point destination){ this.location = location; if(location.equals(destination)){ dispather.norifyAvailable(this); } } } class Dispatcher{ private final Set<Taxi> taxis; private final Set<Taxi> availableTaxis; public Dispatcher(){ taxis = new HashSet<Taxi>(); availableTaxis = new HashSet<Taxi>(); } public synchronized void norifyAvailable(Taxi taxi){ availableTaxis.add(taxi); } public synchronized Image getImage(){ Image image = new Image(); for(Taxi t : taxis){ image.drawMarker(t.getLocation()); } return image; } }
如果在呼叫某個方法時不需要持有鎖,那麼這種呼叫稱為開放呼叫。
/** * 將Taxi與Dispatcher修改為開放呼叫,從而消除發生死鎖的風險。使用同步程式碼塊僅保護哪些設計共享狀態的操作。在同步操作中不會去獲得另外的鎖。 */ class Taxi1{ private Point location; private Point destination; private final Dispatcher1 dispather; public synchronized Point getLocation(){ return location; } public void setLocation(Point location){ boolean reachedDestination; synchronized(this){ this.location = location; reachedDestination = location.equals(destination); } if(reachedDestination){ dispather.norifyAvailable(this); } } } class Dispatcher1{ private final Set<Taxi1> taxis; private final Set<Taxi1> availableTaxis; public synchronized void norifyAvailable(Taxi1 taxi){ availableTaxis.add(taxi); } public Image getImage(){ Set<Taxi1> copy; synchronized(this){ copy = new HashSet<Taxi1>(taxis); } Image image = new Image(); for(Taxi1 t : copy){ image.drawMarker(t.getLocation()); } return image; } }
活鎖
執行緒不會阻塞,而是不斷重複嘗試相同的操作,但總是失敗。
當多個相互協作的執行緒都對彼此進行響應從而修改各自的狀態,並使得任何一個執行緒都無法繼續執行時,就發生了活鎖。
就像兩個過於禮貌的人在半路上面對面地相遇,彼此為對方讓路,然而又再次相遇,就這樣反覆的避讓下去。
執行緒引入的開銷
上下文切換
切換上下文需要一定的開銷,而線上程排程過程中需要訪問由作業系統和JVM共享的資料結構。應用程式、作業系統以及JVM都使用一組相同的CPU。在JVM和作業系統地程式碼中消耗越多的CPU時鐘週期,應用程式的可用CPU時鐘週期就越少。但上下文切換的開銷並不是只包含JVM和作業系統地開銷。當一個新的執行緒被切換進來時,它所需要的資料可能不在當前處理器的本地快取中,因此上下文切換將導致一些快取缺失。因而執行緒在首次排程執行時會更加緩慢。這就是為什麼排程器會為每個可執行的執行緒分配一個最小執行時間,即使有許多其他的執行緒正在等待執行。它將上下文切換的開銷反彈到更多不會中斷的執行時間上,以損失響應性為代價而提高整體的吞吐量。
unix的vmstat命令和windows的perfmon工具都能報告上下文切換次數以及在核心中執行時間所佔比例等資訊。如果核心佔用率超過10%,那麼通常表示排程活動發生得很頻繁,這可能使由I/O或競爭鎖導致的阻塞引起的。
記憶體同步
同步操作的效能開銷包括多個方面。在synchronized和volatitle提供的可見性保證中可能會使用一些特殊命令,即記憶體柵欄。記憶體柵欄可以重新整理快取,使快取無效,重新整理硬體的寫緩衝,以及停止執行管道。記憶體柵欄可能同樣會對效能帶來間接的影響,因為它們將抑制一些編譯器優化操作。在記憶體柵欄中,大多數操作都是不能重排序的。
減少鎖的競爭
- 1.縮小鎖的範圍,即減少鎖的持有時間
儘管縮小同步程式碼塊能提高可伸縮性,但同步程式碼塊也不能過小,當把一個同步程式碼塊分解為多個同步程式碼塊時反而會對效能產生負面影響
事實上,如果JVM執行鎖粗化操作,那麼可能會將分解的同步塊又重新合併起來。
- 2.減小鎖的粒度
為了降低執行緒請求鎖的頻率,可以通過鎖分解技術來實現。
即採用多個相互獨立的鎖來保護獨立的狀態變數,從而改變這些變數之前由單個鎖保護的情況,也就降低的鎖的競爭。
- 3.鎖分段
把一個競爭激烈的鎖分解為兩個鎖時,這兩個鎖可能都存在著激烈的競爭。雖然採用兩個執行緒併發執行能提高一部分可伸縮性,但在一個擁有多處理器的系統中,仍然無法給可伸縮性帶來極大的提高。在某些情況下,可以將鎖分解技術進一步擴充套件為對一組獨立物件上的鎖進行分解,這種情況稱為鎖分段。
例如,在ConcurrentHashMap的實現中預設使用了一個包含16個鎖的陣列,每個鎖保護所有雜湊桶的1/16。
使得ConcurrentHashMap能夠支援多達16個併發的寫入器。
鎖分段的劣勢在於:與採用單個鎖來實現獨佔訪問相比,要獲得多個鎖來實現獨佔訪問將更加困難並且開銷更高。通常,在執行一個操作時最多隻需獲取一個鎖,但在某些情況下需要加鎖整個容器。
例如當ConcurrentHashMap需要擴充套件對映範圍,以及重新計算鍵值的三列值要分佈到更大的桶集合中時,就需要獲取分段鎖集合中所有的鎖。
/** * StripedMap中給出了基於雜湊的Map實現,其中使用了鎖分段技術。它擁有N_LOCKS個鎖,並且每個鎖保護雜湊桶的一個子集。 * 例如get都只需要獲得一個鎖,而有些方法則需要獲得所有的鎖,但並不要求同時獲得,例如clear */ public class StripedMap { private static final int N_LOCKS = 16; private final Node[] buckets; private final Object[] locks; private static class Node{ } public StripedMap(int numBuckets){ buckets = new Node[numBuckets]; locks = new Object[N_LOCKS]; for(int i = 0;i < N_LOCKS; i++){ locks[i] = new Object(); } } private final int hash(Object key){ return Math.abs(key.hashCode() % buckets.length); } public Object get(Object key){ int hash = hash(key); synchronized(locks[hash % N_LOCKS]){ for(Node m = buckets[hash]; m != null; m = m.next()){ if(m.key.equals(key)){ return m.value; } } } return null; } public void clear(){ for(int i = 0; i < buckets.length; i++){ synchronized(locks[i % N_LOCKS]){ buckets[i] = null; } } } }
這種清除map的方式並不是原子操作,因此可能當StripedMap為空時其他的執行緒正在併發地向其中新增元素。如果要使該操作成為一個原子操作,那麼需要同時獲得所有的鎖。然而,如果客戶程式碼不加鎖併發容器來實現獨佔訪問,那麼像size或isEmpty這樣的方法的計算結果在返回時可能會變得無效,因此,儘管這種行為有些奇怪,但通常是可以接受的。
當每個操作都請求多個變數時,鎖的粒度將很難降低。
當實現HashMap時,需要考慮如何在size方法中計算Map中的元素數量。最簡單的方法就是,在每次呼叫時都統計一次元素的數量。一種常見的優化措施是,在插入和移除元素時更新一個計數器,雖然這在put和remove等方法中略微增加了一些開銷,以確保計數器是最新的值,但這將把size方法的開銷從O(n)降低到O(1)。
但是對計數器的訪問需要進行同步,這又會重新到在使用獨佔鎖時存在的問題。
為了避免這個問題,ConcurrentHashMap中的size將對每個分段進行列舉並將每個分段中的元素數量相加,而不是維護一個全域性計數。為了避免列舉每個元素,ConcurrentHashMap為每個分段都維護了一個獨立的計數,並通過每個分段的鎖來維護這個值。
#筆記內容來自 《java併發程式設計實戰》