使用 Java 構造高可擴充應用

來源:互聯網
上載者:User

http://www.ibm.com/developerworks/cn/java/j-lo-scalbility/?S_TACT=105AGX52&S_CMP=tec-csdn#resources

 

使用 Java 構造高可擴充應用

如何?一個高效且多安全執行緒的隊列

文檔選項
<tr
valign="top"><td width="8"><img alt="" height="1" width="8"
src="//www.ibm.com/i/c.gif"/></td><td width="16"><img alt="" width="16"
height="16" src="//www.ibm.com/i/c.gif"/></td><td class="small"
width="122"><p><span class="ast">未顯示需要 JavaScript
的文檔選項</span></p></td></tr>


列印本頁

將此頁作為電子郵件發送

範例代碼

層級: 進階

戴 曉君
(daixiaoj@cn.ibm.com
), 軟體工程師, IBM 中國軟體開發中心
甘 志
(ganzhi@cn.ibm.com
), 進階軟體工程師, IBM 中國軟體開發中心
齊 堯
(qiyaoj@cn.ibm.com
), 軟體工程師, IBM 中國軟體開發中心
羅 志達
(luozd@cn.ibm.com
), 軟體工程師, IBM 中國軟體開發中心

2008 年 10 月 10 日


CPU 進入多核時代之後,軟體的效能調優就不再是一件簡單的事情。沒有並行化的程式在新的硬體上可能會運行得比從前更慢。當 CPU
數目增加的時候,晶片製造商為了取得最佳的效能/功耗比,降低 CPU 的運行頻率是一件非常明智的事情。相比 C/C++ 程式員而言 , 利用
Java 編寫多線程應用已經簡單了很多。然而,多線程程式想要達到高效能仍然不是一件容易的事情。對於軟體開發人員而言,
如果在測試時發現並行程式並不比串列程式快,那不是一件值得驚訝的事情,畢竟,在多核時代之前, 受到廣泛認可的並行軟體開發準則通常過於簡單和武斷。

在本文中,我們將介紹提高 Java 多線程應用效能的一般步驟。 通過運用本文提供的一些簡單規則,我們就能獲得具有高效能的可擴充的應用程式。


為什麼效能沒有增長?

多核能帶來效能的大幅增長,這很容易通過簡單的一些測試來觀察到。如果我們寫一個多線程程式,並在每個線程中對一個本地變數進行累加,我們可以很容易的看到多核和並行帶來的成倍的效能提升。這非常容易做到,不是嗎?在 參考資源
裡我們給出了一個例子。然而,與我們的測試相反,我們很少在實際軟體應用中看到這樣完美的可擴充性。阻礙我們獲得完美的可擴充性有兩方面的因素存在。首先,我們面臨著理論上的限制,其次軟體開發過程中也經常出現實現上的問題。讓我們看看 圖 1 中的三條效能曲線:

圖 1. 效能曲線



為追求完美的軟體工程師,我們希望看到隨著線程數目的增長程式的效能獲得線性增長,也就是圖 1
中的藍色直線。而我們最不希望看到的是綠色的曲線,不管投入多少新的 CPU,效能也沒有絲毫增長。(隨著 CPU
增長而效能下降的曲線在實際項目中也存在)。而圖中的紅色線條則說明通常的 90-10 法則並不適用於可擴充性方面。假設程式中有 10%
的計算只能串列進行,那麼其擴充性曲線如紅線所示。由圖可見,當 90% 的代碼可以完美的並行時,在 10 個 CPU
存在的情況下,我們也只能獲得大約 5 倍的效能。如果任務中具有無法並行的部分,那麼在現實世界,我們的效能曲線大致上會位於圖 1 中的灰色地區。

在這篇文章中,我們不會試圖挑戰理論極限。我們希望能解釋一個 Java 程式員如何能夠儘可能的接近極限,這已經不是一個容易的任務。

是什麼造成了糟糕的可擴充性?


擴充性糟糕的原因有很多,其中最為顯著的是鎖的濫用。這沒有辦法,我們就是這樣被教育的:“想要多安全執行緒嗎?那就加一個鎖吧”。想想 Python
中臭名昭著的 Global Intepreter Lock,還有 Java 中的 Collections.synchronizedXXXX()
系列方法,跟隨巨人的做法有什麼不好嗎?是的,用鎖來保護關鍵地區非常方便,也較容易保證正確性,然而鎖也意味著只有一個進程能進入關鍵地區,而其他的進
程都在等待!如果觀察到 CPU 空閑而軟體執行緩慢,那麼檢察一下鎖的使用是一個明智的做法。

對於 Java 程式而言,Performance Inspector 中的 Java Lock Monitor 是一個不錯的開源工具。

對一個多線程應用進行調優

下面,我們將提供一個例子程式並示範如何在多核平台上獲得更好的可擴充性。這個例子程式示範了一個假想的Log Service器。它接收來自多個源的日誌資訊並將其統一儲存到檔案系統中。為了簡單起見,我們的例子代碼中不包含任何的網路相關代碼,Main()
函數將啟動多個線程來發送日誌資訊到Log Service器中。對於性急的讀者,讓我們先看看調優的結果:

圖 2. 日至伺服器調優結果



中,藍色的曲線是一個基於 Lock 的老式Log Service器,而綠色的曲線是我們進行了效能調優之後的Log Service器。可以看到,LogServerBad
的效能隨線程數目的增加變化很小,而 LogServerGood 的效能則隨著線程數目的增加而線性增長。如果不介意使用第三方的庫的話,那麼來自
Project KunMing 的 LockFreeQueue 可以進一步提供更好的可擴充性:

圖 3. 使用 Lock-free 的資料結構



中,第三條曲線表示用 LockFreeQueue 替換標準庫中的 ConcurrentLinkedQueue
之後的效能曲線。可以看到,如果線程數目較少時,兩條曲線差別不大,但是單線程數目增大到一定程度之後,Lock-Free 的資料結構具有明顯的優勢。

在下文中,將介紹在上述例子中使用的可以協助我們建立高可擴充 Java 應用的工具和技巧。

使用 JLM 分析應用程式

JLM 提供了 Java 應用和 JVM 中鎖持有時間和衝突統計。具體提供以下功能:

  • 對衝突的鎖進行計數

    • 成功獲得鎖的次數
    • 遞迴鎖的次數
    • 申請鎖的線程被阻塞等待的次數
    • 鎖被持有的累計時間。對於支援 3 Tier Spin Locking 的平台 , 還可以獲得以下資訊 :
      • 請求線程在內層(spin loop)請求鎖的次數
      • 請求線程在外層(thread yield loop)請求鎖的次數
  • 使用 rtdriver 工具收集更詳細的資訊
    • jlmlitestart:僅收集計數器
    • jlmstart:僅收集計數器和持有時間統計
    • jlmstop:停止資料收集
    • jlmdump:列印資料收集並繼續收集過程
  • 從鎖持有時間中去除垃圾收集(Garbage Collection,GC)的時間
    • GC 時間從 GC 周期中所有被持有的鎖的持有時間中去除

使用 AtomicInteger 進行計數

通常,在我們實現多線程使用的計數器或隨機數產生器時,會使用鎖來保護共用變數。這樣做的弊端是如果鎖競爭的太厲害,會損害輸送量,因為競爭的同步非常昂貴。

volatile 變數雖然可以使用比同步更低的成本儲存共用變數,但它只可以保證其他線程能夠立即看到對 volatile 變數的寫入,無法保證讀 - 修改 - 寫的原子性。因此,volatile 變數無法用來實現正確的計數器和隨機數產生器。

從 JDK 5 開始,java.util.concurrent.atomic
包中引入了原子變數,包括 AtomicInteger、AtomicLong、AtomicBoolean 以及數組 AtomicIntergerArray、AtomicLongArray 。原子變數保證了 ++
--
+=
-=
等操作的原子性。利用這些資料結構,您可以實現更高效的計數器和隨機數產生器。

加入輕量級的線程池—— Executor


多數並發應用程式是以執行任務(task)為基本單位進行管理的。通常情況下,我們會為每個任務單獨建立一個線程來執行。這樣會帶來兩個問題:一,大量的
線程(>100)會消耗系統資源,使線程調度的開銷變大,引起效能下降;二,對於生命週期短暫的任務,頻繁地建立和消亡線程並不是明智的選擇。因為
建立和消亡線程的開銷可能會大於使用多線程帶來的效能好處。

一種更加合理的使用多線程的方法是使用線程池(Thread
Pool)。 java.util.concurrent 提供了一個靈活的線程池實現:Executor
架構。這個架構可以用於非同步任務執行,而且支援很多不同類型的任務執行策略。它還為任務提交和任務執行之間的解耦提供了標準的方法,為使用
Runnable 描述任務提供了通用的方式。 Executor 的實現還提供了對生命週期的支援和 hook
函數,可以添加如統計收集、應用程式管理機制和監視器等擴充。

線上程池中執行任務線程,可以重用已存在的線程,免除建立新的線
程。這樣可以在處理多個任務時減少線程建立、消亡的開銷。同時,在任務到達時,背景工作執行緒通常已經存在,用於建立線程的等待時間不會延遲任務的執行,因此提
高了響應性。通過適當的調整線程池的大小,在得到足夠多的線程以保持處理器忙碌的同時,還可以防止過多的線程相互競爭資源,導致應用程式線上程管理上耗費
過多的資源。

Executor 預設提供了一些有用的預設線程池,可以通過調用 Executors 的靜態Factory 方法來建立。

  • newFixedThreadPool:提供一個具有最大線程個數限制的線程池。
  • newCachedThreadPool:提供一個沒有最大線程個數限制的線程池。
  • newSingleThreadExecutor:提供一個單線程的線程池。保證任務按照任務隊列說規定的順序(FIFO,LIFO,優先順序)執行。
  • newScheduledThreadPool:提供一個具有最大線程個數限制線程池,並支援定時以及周期性的任務執行。

使用並發資料結構

Collection
架構曾為 Java 程式員帶來了很多方便,但在多核時代,Collection
架構變得有些不大適應。多線程之間的共用資料總是存放在資料結構之中,如 Map、Stack、Queue、List、Set 等。
Collection 架構中的這些資料結構在預設情況下並不是多安全執行緒的,也就是說這些資料結構並不能安全地被多個線程同時訪問。 JDK
通過提供 SynchronizedCollection 為這些類提供一層安全執行緒的介面,它是用 synchronized
關鍵字實現的,相當於為整個資料結構加上一把全域鎖保證安全執行緒。

java.util.concurrent
中提供了更加高效 collection,如 ConcurrentHashMap/Set, ConcurrentLinkedQueue,
ConcurrentSkipListMap/Set, CopyOnWriteArrayList/Set
。這些資料結構是為多線程並發訪問而設計的,使用了細粒度的鎖和新的 Lock-free 演算法。除了在多線程條件下具有更高的效能,還提供了如
put-if-absent 這樣適合并發應用的原子函數。

其他一些需要考慮的因素

不要給記憶體系統太大的壓力


果線程執行過程中需要分配記憶體,這在 Java 中通常不會造成問題。現代的 JVM 是高度最佳化的,它通常為每個線程保留一塊
Buffer,這樣在分配記憶體時,只要 buffer 沒有用光,那麼就不需要和全域的堆打交道。而本地 buffer 分配完畢之後 , JVM
將不得不到全域堆中分配記憶體,這樣通常會帶來嚴重的可擴充性的降低。另外,給 GC 帶來的壓力也會進一步降低程式的可擴充性。儘管我們有並行的
GC,但其可擴充性通常並不理想。如果一個迴圈執行的程式在每次執行中都需要分配臨時對象,那麼我們可以考慮利用 ThreadLocal 和
SoftReference 這樣的技術來減少記憶體的分配。

使用 ThreadLocal

ThreadLocal
類能夠被用來儲存線程私人的狀態資訊,對於某些應用非常方便。通常來講,它對可擴充性有正面的影響。它能為各個線程提供一個線程私人的變數,因而多個線程
之間無須同步。需要注意的是在 JDK 1.6 之前,ThreadLocal 有著相當低效的實現,如果需要在 JDK 1.5 或更老的版本上使用
ThreadLocal,需要謹慎評估其對效能的影響。類似的,目前 JDK 6 中的 ReentrantReadWriteLock
的實現也相當低效,如果想利用讀鎖之間不互斥的特性來提高可擴充性,同樣需要進行 profile 來確認其適用程度。

鎖的粒度很重要


粒度的全域鎖在保證安全執行緒的同時,也會損害應用的效能。仔細考慮鎖的粒度在構建高可擴充 Java 應用時非常重要。當 CPU
個數和線程數較少時,全域鎖並不會引起激烈的競爭,因此獲得一個鎖的代價很小(JVM 對這種情況進行了最佳化)。隨著 CPU
個數和線程數增多,對全域鎖的競爭越來越激烈。除了一個獲得鎖的 CPU 可以繼續工作外,其他試圖獲得該鎖的 CPU
都只能閑置等待,導致整個系統的 CPU
利用率過低,系統效能不能得到充分利用。當我們遇到一個競爭激烈的全域鎖時,可以嘗試將鎖劃分為多個細部鎖定,每一個細部鎖定保護一部分共用資源。通過減
小鎖的粒度,可以降低該鎖的競爭程度。 java.util.concurrent.ConcurrentHashMap 就通過使用細部鎖定,提高
HashMap 在多線程應用中的效能。在 ConcurrentHashMap 中,預設建構函式使用 16 個鎖保護整個 Hash Map
。使用者可以通過參數設定使用上千個鎖,這樣相當於將整個 Hash Map 劃分為上千個片段,每個片段使用一個鎖進行保護。

結論

通過選擇一種合適的 profile 工具,檢查 profile 結果中的作用區。使用適合多線程訪問的資料結構,線程池,細部鎖定減小作用區。並重複此過程不斷提高應用的可擴充性。

構建在多核上具有高可擴充性的 Java 應用並不是一件容易的事。減少各個線程之間的衝突和同步是提高可擴充性的關鍵。本文中介紹的一些通用工具和技巧可以給程式員提供一些協助,但更多的情況要依賴於具體的應用。



回頁首

下載

描述 名字 大小 下載方法
本文用到的 Java 程式樣本 javascale.zip 10 KB HTTP
關於下載方法的資訊

參考資料

  • 在 Project KunMing 開源項目
    網站下載全部項目原始碼。
  • 在 作者的 Blog
    上有一個簡單的測量多核計算效能的程式,儘管這個例子基於 C++,但其結論對於 Java 程式來說一樣適用。
  • 查看 Python 的文檔
    獲得 Global Intepreter Lock 的更多資訊以及它至今存在的理由。
  • 下載 開源工具 Performance Inspector
    來觀察鎖的利用情況。
  • 參考 Brian 的 Java 理論與實踐:流行的原子
    獲得更多 AtomicInteger 的資訊。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.