提升Java的鎖性能
幾個月前我們介紹了如何通過Plumbr來進行線程鎖檢測,隨后便收到了很多類似的問題,“Hi,文章寫得不錯,現在我終于知道是什么引發的性能問題了,但是現在我該怎么做?”
為了在我們的產品中集成這個解決方案,我們付出了許多努力,不過在本文中,我想給大家分享幾個常用的優化技巧,而不一定非要使用我們這款鎖檢測的工具。包括分拆鎖,并發數據結構,保護數據而非代碼,以及縮小鎖的作用域。
鎖無罪,競爭其罪
如果你在多線程代碼中碰到了性能問題,你肯定會先抱怨鎖。畢竟,從“常識”來講,鎖的性能是很差的,并且還限制了程序的可伸縮性。如果你懷揣著這樣的想法去優化代碼并刪除鎖的話,最后你肯定會引入一些難纏的并發BUG。
因此分清楚競爭鎖與無競爭鎖的區別是很有必要的。如果一個線程嘗試進入另一個線程正在執行的同步塊或者方法時,便會出現鎖競爭。第二個線程就必須等待前一個線程執行完這個同步塊并釋放掉監視器(monitor)。如果只有一個線程在執行這段同步的代碼,這個鎖就是無競爭的。
事實上,JVM中的同步已經針對這種無競爭的情況進行了優化,對于絕大多數應用而言,無競爭的鎖幾乎是沒有任何額外的開銷的。因此,出了性能問題不能光怪鎖,你得怪競爭鎖。在明確了這點以后 ,我們來看下如何能減少鎖的競爭或者競爭的時間。
保護數據而非代碼
實現線程安全最快的方法就是直接將整個方法上鎖。比如說下面的這個例子,這是在線撲克游戲服務端的一個簡單的實現:
class GameServer { public Map<<String, List<Player>> tables = new HashMap<String, List<Player>>(); public synchronized void join(Player player, Table table) { if (player.getAccountBalance() > table.getLimit()) { List<Player> tablePlayers = tables.get(table.getId()); if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } public synchronized void leave(Player player, Table table) {/*body skipped for brevity*/} public synchronized void createTable() {/*body skipped for brevity*/} public synchronized void destroyTable(Table table) {/*body skipped for brevity*/} }
作者的想法是好的——就是當新的玩家加入的時候,必須得保證桌上的玩家的數量不能超過9個。
不過這個上鎖的方案更適合加到牌桌上,而不是玩家進入的時候——即便是在一個流量一般的撲克網站上,這樣的系統也肯定會由于線程等待鎖釋放而頻繁地觸發競爭事件。被鎖住的代碼塊包含了帳戶余額以及牌桌上限的檢查,這里面很可能會包括一些很昂貴的操作,這樣不僅會容易觸發競爭并且使得競爭的時間變長。
解決問題的第一步就是要確保你保護的是數據,而不是代碼,先將同步從方法聲明移到方法體里。在上面這個簡短的例子中,剛開始好像能修改的地方并不多。不過我們考慮的是整個GameServer類,而不只限于這個join()方法:
class GameServer { public Map<String, List<Player>> tables = new HashMap<String, List<Player>>(); public void join(Player player, Table table) { synchronized (tables) { if (player.getAccountBalance() > table.getLimit()) { List<Player> tablePlayers = tables.get(table.getId()); if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } } public void leave(Player player, Table table) {/* body skipped for brevity */} public void createTable() {/* body skipped for brevity */} public void destroyTable(Table table) {/* body skipped for brevity */} }
這看似一個很小的改動,卻會影響到整個類的行為。當玩家加入牌桌 時,前面那個同步的方法會鎖在GameServer的this實例上,并與同時想離開牌桌(leave)的玩家產生競爭行為。而將鎖從方法簽名移到方法內部以后,則將上鎖的時機往后推遲了,一定程度上減小了競爭的可能性。
縮小鎖的作用域
現在我們已經確保保護的是數據而不是代碼了,我們得再確認鎖住的部分都是必要的——比如說,代碼可以重寫成這樣 :
public class GameServer { public Map<String, List<Player>> tables = new HashMap<String, List<Player>>(); public void join(Player player, Table table) { if (player.getAccountBalance() > table.getLimit()) { synchronized (tables) { List<Player> tablePlayers = tables.get(table.getId()); if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } } //other methods skipped for brevity }
現在檢查玩家余額的這個耗時操作就在鎖作用域外邊了。注意到了吧,鎖的引入其實只是為了保護玩家數量不超過桌子的容量而已,檢查帳戶余額這個事情并不在要保護的范圍之內。
分拆鎖
再看下上面這段代碼,你會注意到整個數據結構都被同一個鎖保護起來了。考慮到這個數據結構中可能會存有上千張牌桌,出現競爭的概率還是非常高的,因此保護每張牌桌不超出容量的工作最好能分別來進行。
對于這個例子而言,為每張桌子分配一個獨立的鎖并非難事,代碼如下:
public class GameServer { public Map<String, List<Player>> tables = new HashMap<String, List<Player>>(); public void join(Player player, Table table) { if (player.getAccountBalance() > table.getLimit()) { List<Player> tablePlayers = tables.get(table.getId()); synchronized (tablePlayers) { if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } } //other methods skipped for brevity }
現在我們把對所有桌子同步的操作變成了只對同一張桌子進行同步,因此出現鎖競爭的概率就大大減小了。如果說桌子中有100張桌子的話,那么現在出現競爭的概率就小了100倍。
使用并發的數據結構
另一個可以改進的地方就是棄用傳統的單線程的數據結構,改為使用專門為并發所設計的數據結構。比如說,可以用ConcurrentHashMap來存儲所有的撲克桌,這樣代碼就會變成這樣:
public class GameServer { public Map<String, List<Player>> tables = new ConcurrentHashMap<String, List<Player>>(); public synchronized void join(Player player, Table table) {/*Method body skipped for brevity*/} public synchronized void leave(Player player, Table table) {/*Method body skipped for brevity*/} public synchronized void createTable() { Table table = new Table(); tables.put(table.getId(), table); } public synchronized void destroyTable(Table table) { tables.remove(table.getId()); } }
join()和leave()方法的同步操作變得更簡單了,因為我們現在不用再對tables進行加鎖了,這都多虧了 ConcurrentHashMap。然而,我們還是要保證每個tablePlayers的一致性。因此這個地方ConcurrentHashMap幫不上什么忙。同時我們還得在createTable()與destroyTable()方法中創建新的桌子以及銷毀桌子,這對 ConcurrentHashMap而言本身就是并發的,因此你可以并行地增加或者減少桌子的數量。
其它的技巧及方法
- 降低鎖的可見性。在上述例子中,鎖是聲明為public的,因此可以被別人所訪問到,你所精心設計的監視器可能會被別人鎖住,從而功虧一簣。
- 看一下java.util.concurrent.locks包下面有哪些鎖策略對你是有幫助的。
- 使用原子操作。上面這個例子中的簡單的計數器其實并不需要進行加鎖。將計數的Integer換成AtomicInteger對這個場景來說就綽綽有余了。
希望本文對你解決鎖競爭的問題能有所幫助,不管你有沒有使用我們的Plumbr所提供的自動化鎖檢測方案,還是自己手動從線程dump信息中提取信息。
原創文章轉載請注明出處:提升Java的鎖性能