當CPU 進入多核時代之後軟件的性能調優就不再是一件簡單的事情沒有並行化的程序在新的硬件上可能會運行得比從前更慢當 CPU 數目增加的時候芯片制造商為了取得最佳的性能/功耗比降低 CPU 的運行頻率是一件非常明智的事情相比 C/C++ 程序員而言 利用 Java 編寫多線程應用已經簡單了很多然而多線程程序想要達到高性能仍然不是一件容易的事情對於軟件開發人員而言 如果在測試時發現並行程序並不比串行程序快那不是一件值得驚訝的事情畢竟在多核時代之前 受到廣泛認可的並行軟件開發准則通常過於簡單和武斷
在本文中我們將介紹使提高Java 多線程應用性能的一般步驟 通過運用本文提供的一些簡單規則我們就能獲得具有高性能的可擴展的應用程序
為什麼性能沒有增長?
多核能帶來性能的大幅增長這很容易通過簡單的一些測試來觀察到如果我們寫一個多線程程序並在每個線程中對一個本地變量進行累加我們可以很容易的看到多核和並行帶來的成倍的性能提升這非常容易做到不是嗎?在 參考資源 裡我們給出了一個例子然而與我們的測試相反我們很少在實際軟件應用中看到這樣完美的可擴展性阻礙我們獲得完美的可擴展性有兩方面的因素存在首先我們面臨著理論上的限制其次軟件開發過程中也經常出現實現上的問題讓我們看看 圖 中的三條性能曲線
圖 性能曲線
![性能曲線](http://img.educity.cn/img_5/119/2013101916/26998162208.png)
作為追求完美的軟件工程師我們希望看到隨著線程數目的增長程序的性能獲得線性的增長也就是圖 中的藍色直線而我們最不希望看到的是綠色的曲線不管投入多少新的 CPU性能也沒有絲毫增長(隨著 CPU 增長而性能下降的曲線在實際項目中也存在)而圖中的紅色線條則說明通常的 法則並不適用於可擴展性方面假設程序中有 % 的計算只能串行進行那麼其擴展性曲線如紅線所示由圖可見當 % 的代碼可以完美的並行時在 個 CPU 存在的情況下我們也只能獲得大約 倍的性能如果任務中具有無法並行的部分那麼在現實世界我們的性能曲線大致上會位於圖 中的灰色區域
在這篇文章中我們不會試圖挑戰理論極限我們希望能解釋一個 Java 程序員如何能夠盡可能的接近極限這已經不是一個容易的任務
是什麼造成了糟糕的可擴展性?
可擴展性糟糕的原因有很多其中最為顯著的是鎖的濫用這沒有辦法我們就是這樣被教育的想要多線程安全嗎?那就加一個鎖吧想想 Python 中臭名昭著的 Global Intepreter Lock還有 Java 中的 CollectionssynchronizedXXXX() 系列方法跟隨巨人的做法有什麼不好嗎?是的用鎖來保護關鍵區域非常方便也較容易保證正確性然而鎖也意味著只有一個進程能進入關鍵區域而其他的進程都在等待!如果觀察到 CPU 空閒而軟件執行緩慢那麼檢察一下鎖的使用是一個明智的做法
對於 Java 程序而言Performance Inspector 中的 Java Lock Monitor 是一個不錯的開源工具
[NextPage]
對一個多線程應用進行調優
下面我們將提供一個例子程序並演示如何在多核平台上獲得更好的可擴展性這個例子程序演示了一個假想的日志服務器它接收來自多個源的日志信息並將其統一保存到文件系統中為了簡單起見我們的例子代碼中不包含任何的網絡相關代碼Main() 函數將啟動多個線程來發送日志信息到日志服務器中對於性急的讀者讓我們先看看調優的結果
圖 日至服務器調優結果
![日至服務器調優結果](http://img.educity.cn/img_5/119/2013101916/26999162209.png)
在上圖中藍色的曲線是一個基於 Lock 的老式日志服務器而綠色的曲線是我們進行了性能調優之後的日志服務器可以看到LogServerBad 的性能隨線程數目的增加變化很小而 LogServerGood 的性能則隨著線程數目的增加而線性增長如果不介意使用第三方的庫的話那麼來自 Project KunMing 的 LockFreeQueue 可以進一步提供更好的可擴展性
圖 使用 Lockfree 的數據結構
![使用 Lock-free 的數據結構](http://img.educity.cn/img_5/119/2013101916/27000162212.png)
在上圖中第三條曲線表示用 LockFreeQueue 替換標准庫中的 ConcurrentLinkedQueue 之後的性能曲線可以看到如果線程數目較少時兩條曲線差別不大但是單線程數目增大到一定程度之後LockFree 的數據結構具有明顯的優勢
在下文中將介紹在上述例子中使用的可以幫助我們創建高可擴展 Java 應用的工具和技巧
[NextPage]
使用 JLM 分析應用程序
JLM 提供了 Java 應用和 JVM 中鎖持有時間和沖突統計具體提供以下功能
對沖突的鎖進行計數
成功獲得鎖的次數
遞歸鎖的次數
申請鎖的線程被阻塞等待的次數
鎖被持有的累計時間對於支持 Tier Spin Locking 的平台 還可以獲得以下信息 :
請求線程在內層(spin loop)請求鎖的次數
請求線程在外層(thread yield loop)請求鎖的次數
使用 rtdriver 工具收集更詳細的信息
jlmlitestart僅收集計數器
jlmstart僅收集計數器和持有時間統計
jlmstop停止數據收集
jlmdump打印數據收集並繼續收集過程
從鎖持有時間中去除垃圾收集(Garbage CollectionGC)的時間
GC 時間從 GC 周期中所有被持有的鎖的持有時間中去除
使用 AtomicInteger 進行計數
通常在我們實現多線程使用的計數器或隨機數生成器時會使用鎖來保護共享變量這樣做的弊端是如果鎖競爭的太厲害會損害吞吐量因為競爭的同步非常昂貴
volatile 變量雖然可以使用比同步更低的成本存儲共享變量但它只可以保證其他線程能夠立即看到對 volatile 變量的寫入無法保證讀 修改 寫的原子性因此volatile 變量無法用來實現正確的計數器和隨機數生成器
從 JDK 開始ncurrentatomic 包中引入了原子變量包括 AtomicIntegerAtomicLongAtomicBoolean 以及數組 AtomicIntergerArrayAtomicLongArray 原子變量保證了 ++——+== 等操作的原子性利用這些數據結構您可以實現更高效的計數器和隨機數生成器
加入輕量級的線程池—— Executor
大多數並發應用程序是以執行任務(task)為基本單位進行管理的通常情況下我們會為每個任務單獨創建一個線程來執行這樣會帶來兩個問題一大量的線程(>)會消耗系統資源使線程調度的開銷變大引起性能下降二對於生命周期短暫的任務頻繁地創建和消亡線程並不是明智的選擇因為創建和消亡線程的開銷可能會大於使用多線程帶來的性能好處
一種更加合理的使用多線程的方法是使用線程池(Thread Pool) ncurrent 提供了一個靈活的線程池實現Executor 框架這個框架可以用於異步任務執行而且支持很多不同類型的任務執行策略它還為任務提交和任務執行之間的解耦提供了標准的方法為使用 Runnable 描述任務提供了通用的方式 Executor 的實現還提供了對生命周期的支持和 hook 函數可以添加如統計收集應用程序管理機制和監視器等擴展
在線程池中執行任務線程可以重用已存在的線程免除創建新的線程這樣可以在處理多個任務時減少線程創建消亡的開銷同時在任務到達時工作線程通常已經存在用於創建線程的等待時間不會延遲任務的執行因此提高了響應性通過適當的調整線程池的大小在得到足夠多的線程以保持處理器忙碌的同時還可以防止過多的線程相互競爭資源導致應用程序在線程管理上耗費過多的資源
Executor 默認提供了一些有用的預設線程池可以通過調用 Executors 的靜態工廠方法來創建
newFixedThreadPool
提供一個具有最大線程個數限制的線程池
newCachedThreadPool
提供一個沒有最大線程個數限制的線程池
newSingleThreadExecutor
提供一個單線程的線程池
保證任務按照任務隊列說規定的順序(FIFO
LIFO
優先級)執行
newScheduledThreadPool
提供一個具有最大線程個數限制線程池
並支持定時以及周期性的任務執行
使用並發數據結構
Collection 框架曾為 Java 程序員帶來了很多方便但在多核時代Collection 框架變得有些不大適應多線程之間的共享數據總是存放在數據結構之中如 MapStackQueueListSet 等 Collection 框架中的這些數據結構在默認情況下並不是多線程安全的也就是說這些數據結構並不能安全地被多個線程同時訪問 JDK 通過提供 SynchronizedCollection 為這些類提供一層線程安全的接口它是用 synchronized 關鍵字實現的相當於為整個數據結構加上一把全局鎖保證線程安全
ncurrent 中提供了更加高效 collection如 ConcurrentHashMap/Set ConcurrentLinkedQueue ConcurrentSkipListMap/Set CopyOnWriteArrayList/Set 這些數據結構是為多線程並發訪問而設計的使用了細粒度的鎖和新的 Lockfree 算法除了在多線程條件下具有更高的性能還提供了如 putifabsent 這樣適合並發應用的原子函數
[NextPage]
其他一些需要考慮的因素
不要給內存系統太大的壓力
如果線程執行過程中需要分配內存這在 Java 中通常不會造成問題現代的 JVM 是高度優化的它通常為每個線程保留一塊 Buffer這樣在分配內存時只要 buffer 沒有用光那麼就不需要和全局的堆打交道而本地 buffer 分配完畢之後 JVM 將不得不到全局堆中分配內存這樣通常會帶來嚴重的可擴展性的降低另外給 GC 帶來的壓力也會進一步降低程序的可擴展性盡管我們有並行的 GC但其可擴展性通常並不理想如果一個循環執行的程序在每次執行中都需要分配臨時對象那麼我們可以考慮利用 ThreadLocal 和 SoftReference 這樣的技術來減少內存的分配
使用 ThreadLocal
ThreadLocal 類能夠被用來保存線程私有的狀態信息對於某些應用非常方便通常來講它對可擴展性有正面的影響它能為各個線程提供一個線程私有的變量因而多個線程之間無須同步需要注意的是在 JDK 之前ThreadLocal 有著相當低效的實現如果需要在 JDK 或更老的版本上使用 ThreadLocal需要慎重評估其對性能的影響類似的目前 JDK 中的 ReentrantReadWriteLock 的實現也相當低效如果想利用讀鎖之間不互斥的特性來提高可擴展性同樣需要進行 profile 來確認其適用程度
鎖的粒度很重要
粗粒度的全局鎖在保證線程安全的同時也會損害應用的性能仔細考慮鎖的粒度在構建高可擴展 Java 應用時非常重要當 CPU 個數和線程數較少時全局鎖並不會引起激烈的競爭因此獲得一個鎖的代價很小(JVM 對這種情況進行了優化)隨著 CPU 個數和線程數增多對全局鎖的競爭越來越激烈除了一個獲得鎖的 CPU 可以繼續工作外其他試圖獲得該鎖的 CPU 都只能閒置等待導致整個系統的 CPU 利用率過低系統性能不能得到充分利用當我們遇到一個競爭激烈的全局鎖時可以嘗試將鎖劃分為多個細粒度鎖每一個細粒度鎖保護一部分共享資源通過減小鎖的粒度可以降低該鎖的競爭程度 ncurrentConcurrentHashMap 就通過使用細粒度鎖提高 HashMap 在多線程應用中的性能在 ConcurrentHashMap 中默認構造函數使用 個鎖保護整個 Hash Map 用戶可以通過參數設定使用上千個鎖這樣相當於將整個 Hash Map 劃分為上千個碎片每個碎片使用一個鎖進行保護
結論
通過選擇一種合適的 profile 工具檢查 profile 結果中的熱點區域使用適合多線程訪問的數據結構線程池細粒度鎖減小熱點區域並重復此過程不斷提高應用的可擴展性
構建在多核上具有高可擴展性的 Java 應用並不是一件容易的事減少各個線程之間的沖突和同步是提高可擴展性的關鍵本文中介紹的一些通用工具和技巧可以給程序員提供一些幫助但更多的情況要依賴於具體的應用
From:http://tw.wingwit.com/Article/program/Java/gj/201311/27639.html