事實證明,要發揮多核硬件所帶來的收益是很困難和有風險的。當使用並發正確和安全地編寫Java軟件時,我們需要很仔細地進行思考。因為錯誤使用並發會導致偶爾才出現的缺陷,這些缺陷甚至能夠躲過最嚴格的測試環境。
靜態分析工具提供了一種方式,可以在代碼執行之前探查並修正並發錯誤。它能夠在代碼執行之前分析程序的源碼或編譯形成的字節碼,進而發現隱藏在代碼之中的缺陷。
Contemplate的ThreadSafe Solo是一個商用的Eclipse靜態分析插件,其目的就是專門用來發現並診斷隱藏在Java程序之中的缺陷。因為專注於並發方面的缺陷,所以ThreadSafe能夠發現其他商用或免費靜態分析工具無法發現的缺陷,這些工具通常會忽視這種缺陷或者根本就不是為了查找這種缺陷而設計的。就目前我們所能確定的,其他的Java靜態分析工具都不能捕獲以下樣例中的任何缺陷。
在本文中,我會通過一系列並發缺陷來介紹ThreadSafe,這些都是具體的樣例和實際的OSS代碼,這裡展現了ThreadSafe的高級靜態分析以及與Eclipse的緊密集成,這樣我們就能在代碼產品化之前,及早發現並診斷這些缺陷。如果想在你的代碼上體驗ThreadSafe的話,可以在Contemplate站點上下載免費試用版本。
在本文中作為樣例所使用的並發缺陷都是由於開發人員沒有正確地同步對共享數據的訪問所引起的。這類缺陷同時是Java代碼中最常見的並發缺陷形式,也是在代碼檢查和測試中最難探查的缺陷之一。ThreadSafe能夠探測出眾多沒有正確使用同步的場景,如下文所述,它同時還能為開發人員提供至關重要的上下文信息,從而有助於對問題做出診斷。
如果一個類的實例會被多個線程並發調用,那麼在設計的時候,開發人員必須要仔細考慮如何對同一個實例進行並發訪問,以保證能夠正確地進行處理。即便找到了好的設計方案,也很難保證這個經過仔細設計的同步協議在將來添加代碼時能夠得到充分的尊重。當新編寫的代碼違反已有的並發設計時,ThreadSafe能夠幫助指出這些場景。
對於簡單的同步任務,Java提供了多種不同的基礎設施,包括synchronized關鍵字以及更為靈活的java.util.concurrent.locks包。
作為一個簡單示例,我們使用Java內置的同步設施來安全並發地訪問共享資源,考慮如下的代碼片段,實現了模擬的“銀行賬戶”類。
public class BankAccount { protected final Object lock = new Object(); private int balance; protected int readBalance() { return balance; } protected void adjustBalance(int adjustment) { balance = balance + adjustment; } // ... methods that synchronize on "lock" while calling // readBalance() or adjustBalance(..) }
這個類的開發人員決定通過兩個內部的API方法,即readBalance()和adjustBalance(),來對balance域提供訪問功能。這些方法給定了protected級別的可見性,所以它們可能會被BankAccount的子類訪問。鑒於在BankAccount實例上任何對外暴露的特定操作都會涉及到對這些方法進行一系列復雜的調用,這些方法應該作為一個原子的步驟來執行,而內部的API方法本身並不進行任何的同步。相反,這些方法的調用者要同步lock域中所存儲的對象,以保證互斥性以及對balance域更新的原子性。
在程序規模很小的時候,程序的設計可以裝在某個開發人員的腦子中,出現並發相關問題的風險相對來講會比較小。但是,在實際的項目中,最初精心設計的程序需要進行擴展以適應新的功能,而這通常是由項目的新工程師來完成的。
現在,假設在最初的代碼編寫一段時間之後,另外一個開發人員編寫了BankAccount的子類來添加一些新的可選功能。令人遺憾的是,這個新的開發人員並不一定了解之前的開發人員所設計好的同步機制,他並沒有意識到如果沒有預先同步保存在lock域中的對象,是不能調用readBalance()和adjustBalance(..)的。
新工程師所編寫的BankAccount子類代碼可能會如下所示:
public class BonusBankAccount extends BankAccount { private final int bonus; public BonusBankAccount(int initialBalance, int bonus) { super(initialBalance); if (bonus < 0) throw new IllegalArgumentException("bonus must be >= 0"); this.bonus = bonus; } public void applyBonus() { adjustBalance(bonus); } }
在applyBonus()的實現中存在著問題。為了正確地遵循BankAccount類的同步策略,applyBonus()在調用adjustBalance()時應該同步lock。不過,這裡沒有執行同步,所以BonusBankAccount的作者在這裡引入了一個嚴重的並發缺陷。
盡管這個缺陷很嚴重,但是在測試甚至生產階段要探測到它卻是很困難的。這個缺陷的表現形式為不一致的賬戶余額,這是由於缺少同步會導致某個線程對balance域的更新對其他線程是不可見的。這個缺陷不會導致程序崩潰,但是會以難以跟蹤的方式,默默地產生不一致的結果。在四核的硬件上,嘗試以四個線程並發地對同一個賬戶進行返現和貸出操作,在40,000個事務中會有11個是失效的。
ThreadSafe可以用來識別類似於BonusBankAccount類所引入的並發缺陷。在上面提到的兩個類上運行ThreadSafe的Eclipse插件,會產生如下的輸出:
查看本欄目
在Eclipse中,ThreadSafe視圖的截屏
這個截屏顯示ThreadSafe已經發現balance域沒有進行一致的同步。
要獲取更多的上下文信息,可以讓ThreadSafe顯示對balance域的訪問,它還會為我們展現每次訪問所持有的鎖:
ThreadSafe Accesses視圖的截屏
通過這個視圖,我們可以清楚地看到在adjustBalance()方法中對balance域沒有進行一致性的同步。使用Eclipse的調用層級(call hierarchy)視圖(在這裡可以通過右鍵點擊視圖中adjustBalance()這一行快速訪問),我們可以看到這個討厭的代碼路徑是怎樣產生的。
Eclipse調用層級的截屏,展現了BonusBankAccount對adjustBalance方法的調用
上面提到的BankAccount類是一個很簡單的例子,展現了訪問域時沒有進行正確的同步。當然,大多數Java對象都是由其他對象組成的,常見的表現形式就是對象集合。Java提供了種類繁多的集合類,當對集合進行並發訪問時,每一個集合類都有其是否需要進行同步的需求。
對集合的不一致同步可能會對程序的行為帶來特別嚴重的影響。當對一個域的訪問沒有正確的同步時,可能“只是”丟失更新或使用過期數據,而有些集合原本並沒有設計成支持並發使用,對這些集合的不一致同步則可能會違反集合內部的不變形(invariants)。如果違反了集合的內部不變形可能並不會馬上出現可見性的問題,但是可能會導致很詭異的行為,比如在程序的後續執行中會出現無限循環或數據損壞。
當訪問共享的集合時,不一致地使用同步的樣例出現在Apache JMeter之中,這是一個很流行的測試應用在負載下性能的開源工具。在2.1.0版本的Apache JMeter上運行ThreadSafe會產生如下的警告:
存儲在RespTimeGraphVisualizer.internalList : List<RespTimeGraphDataBean>域中的集合因為不一致同步而產生的警告截屏
像前面一樣,我們可以要求ThreadSafe展現這個報告的更多信息,包括對這個域的訪問以及它所持有的鎖:
探查internalList的ThreadSafe Accesses視圖的截屏
現在我們可以看到有三個方法訪問存儲在internalList域中的集合。其中有一個方法是actionPerformed,它將會由Swing Gui框架在UI線程上調用。
另外一個訪問internalList所存儲集合的方法是add()。同樣的,探查這個方法可能的調用者,我們會發現它確實會由一個線程的run()來調用,而這個線程並不是應用的UI線程,這表明應該要使用同步。
Eclipse的調用層級結構截屏,展現了run()方法
查看本欄目
應用程序運行時所在的並發環境通常並不在應用開發人員的控制之下。框架會調用各個部分來響應用戶、網絡或其他的外部事件,通常來講某個方法能被哪條線程來調用都有內在的需求。
未正確使用框架的一個樣例可以在Git版本的Android email客戶端K9Mail上找到(在本文的結尾處,我們提供了所測試版本的鏈接)。在K9Mail上運行ThreadSafe會得到如下的警告,表明mDraftId域會被Android的後台進程以及另外一個進程所訪問,但是沒有進行同步。
針對異步回調方法的未同步訪問,ThreadSafe所產生的報告
使用ThreadSafe的Accesses視圖,我們可以看到mDraftId域會被名為doInBackground的方法所訪問。
ThreadSafe的Accesses視圖展現了對mDraftId的每個訪問
doInBackground方法是Android框架AsyncTask基礎設施的一部分,它用來在後台執行耗時的任務,這是與主UI線程相分離的。正確使用AsyncTask.doInBackground(..)能夠保證對用戶的輸入保持響應,但是必須要注意的是後台線程與主UI線程之間的交互必須要正確地同步。
進一步進行探查,使用Eclipse的調用層級結構視圖,我們會發現onDiscard()方法,這個方法也訪問了mDraftId域,這個方法是被onBackPressed()所調用的。而這個方法通常是由Android框架在主線程中調用的,並不是運行AsyncTasks的後台線程,這就表明這裡會有一個潛在的並發缺陷。
對於相對簡單場景,Java內置的同步集合就提供了合適的線程安全性功能,無需我們費太多功夫。
同步集合對原有的集合類進行了包裝,提供了與底層集合相同的接口,但是對同步集合實例的所有訪問都進行了同步。同步集合要通過調用特定的靜態方法來獲得,類似的調用方式如下所示:
private List<X> threadSafeList = Collections.synchronizedList(new LinkedList<X>());
相對於其他線程安全的數據結構,同步集合使用起來很容易,但是在它們的使用中也有很微妙的陷阱。在使用同步集合時,一個常見的錯誤就是在沒有同步集合本身的情況下,對它們進行遍歷。鑒於沒有強制要求對集合進行排他性的訪問,所以在迭代其元素的時候,集合可能會被其他的線程修改。這可能會導致間歇性地拋出ConcurrentModificationException,或者出現無法預知的行為,這取決於線程的具體調度。同步的需求明確記錄在JDK API文檔之中:
查看本欄目
盡管如此,當迭代一個同步集合時,還是很容易忘記進行同步的,尤其是它們與常規的非同步集合有著相同的接口。
在2.10版本的Apache JMeter之中,可以看到這種錯誤的樣例。ThreadSafe報告了如下“對同步集合的不安全遍歷”場景:
ThreadSafe所產生的報告不安全遍歷的截屏
ThreadSafe報告的那一行中包含了如下的代碼:
Iterator<Map.Entry<String, JMeterProperty>> iter = propMap.entrySet().iterator();
在這裡,迭代是基於一個同步集合的視圖(view)進行的,它是通過調用entrySet()得到的。因為集合的視圖是“活躍的(live)”,因此這段代碼同樣可能產生上文所述的無法預知行為或ConcurrentModificationException。
我展現了一小部分並發相關的缺陷,這些都是在實際的Java程序中很常見的,並且演示了Contemplate ThreadSafe能夠如何幫助我們發現並診斷它們。
總體而言,不管是已有的還是新編寫的Java代碼,靜態分析工具都能有助於發現隱藏在代碼之中的缺陷。靜態分析能夠對傳統的軟件質量技術形成補充,這些傳統的技術包括測試和代碼審查,靜態分析提供了一種快捷且可重復的方式來掃描代碼,目的在於發現一些為大家所熟知但是比較難以發現且嚴重的缺陷。並發的缺陷尤其難以在測試中很可靠的發現,因為它們依賴於不確定的並發線程調度。
ThreadSafe還能發現其他一系列的並發缺陷,包括因為不正確地使用並發集合框架所引起的原子性錯誤以及錯誤使用阻塞方法可能引起的死鎖。ThreadSafe的技術資料以及樣例視頻中展示了ThreadSafe能夠發現的更多缺陷樣例,這些缺陷難以被發現,但很可能是災難性的。
當對返回的list進行遍歷的時候,用戶必須手動地對其進行同步:
List list = Collections.synchronizedList(new ArrayList()); ... synchronized (list) { Iterator i = list.iterator(); // Must be in synchronized block while (i.hasNext()) foo(i.next()); }
不遵循該建議的話可能會導致無法預知的行為