您好,登錄后才能下訂單哦!
一、介紹
本文重點討論多線程應用程序的性能問題。如用何種技術方法來減少鎖競爭,以及如何用代碼來實現。
二、性能
我們都知道,多線程可以提高線程的性能。性能提升的根本原因在于我們有多核的CPU或多個CPU。每個CPU的內核都可以自己完成任務,因此把一個大的任務分解成一系列的可彼此獨立運行的小任務就可以提高程序的整體性能了。可以舉個例子,比如有個程序用來將硬盤上某個文件夾下的所有圖片的尺寸進行修改,應用多線程技術就可以提高它的性能。使用單線程的方式只能依次遍歷所有圖片文件并且執行修改,如果我們的CPU有多個核心的話,毫無疑問,它只能利用其中的一個核。使用多線程的方式的話,我們可以讓一個生產者線程掃描文件系統把每個圖片都添加到一個隊列中,然后用多個工作線程來執行這些任務。如果我們的工作線程的數量和CPU總的核心數一樣的話,我們就能保證每個CPU核心都有活可干,直到任務被全部執行完成。
對于另外一種需要較多IO等待的程序來說,利用多線程技術也能提高整體性能。假設我們要寫這樣一個程序,需要抓取某個網站的所有HTML文件,并且將它們存儲到本地磁盤上。程序可以從某一個網頁開始,然后解析這個網頁中所有指向本網站的鏈接,然后依次抓取這些鏈接,這樣周而復始。因為從我們對遠程網站發起請求到接收到所有的網頁數據需要等待一段時間,所以我們可以將此任務交給多個線程來執行。讓一個或稍微更多一點的線程來解析已經收到的HTML網頁以及將找到的鏈接放入隊列中,讓其他所有的線程負責請求獲取頁面。
高性能就是在短的時間窗口內做盡量多的事情。這個當然是對性能一詞的最經典解釋了。但是同時,使用線程也能很好地提升我們程序的響應速度。想象我們有這樣一個圖形界面的應用程序,上方有一個輸入框,輸入框下面有一個名字叫“處理”的按鈕。當用戶按下這個按鈕的時候,應用程序需要重新對按鈕的狀態進行渲染(按鈕看起來被按下了,當松開鼠標左鍵時又恢復原狀),并且開始對用戶的輸入進行處理。如果處理用戶輸入的這個任務比較耗時的話,單線程的程序就無法繼續響應用戶其他的輸入動作了,
可擴展性(Scalability)的意思是程序具備這樣的能力:通過添加計算資源就可以獲得更高的性能。想象我們需要調整很多圖片的大小,因為我們機器的CPU核心數是有限的,所以增加線程數量并不總能相應提高性能。相反,因為調度器需要負責更多線程的創建和關閉,也會占用CPU資源,反而有可能降低性能。
1、對性能的影響
寫到這里,我們已經取得這樣一個觀點:增加更多的線程可以提高程序的性能和響應速度。但是另一方面,想要取得這些好處卻并非輕而易舉,也需要付出一些代價。線程的使用對性能的提升也會有所影響。
首先,第一個影響來自線程創建的時候。線程的創建過程中,JVM需要從底層操作系統申請相應的資源,并且在調度器中初始化數據結構,以便決定執行線程的順序。
如果你的線程的數量和CPU的核心數量一樣的話,每個線程都會運行在一個核心上,這樣或許他們就不會經常被打斷了。但是事實上,在你的程序運行的時候,操作系統也會有些自己的運算需要CPU去處理。所以,即使這種情形下,你的線程也會被打斷并且等待操作系統來重新恢復它的運行。當你的線程數量超過CPU的核心數量的時候,情況有可能變得更壞。在這種情況下,JVM的進程調度器會打斷某些線程以便讓其他線程執行,線程切換的時候,剛才正在運行的線程的當前狀態需要被保存下來,以便等下次運行的時候可以恢復數據狀態。不僅如此,調度器也會對它自己內部的數據結構進行更新,而這也需要消耗CPU周期。所有這些都意味著,線程之間的上下文切換會消耗CPU計算資源,因此帶來相比單線程情況下沒有的性能開銷。
多線程程序所帶來的另外一個開銷來自對共享數據的同步訪問保護。我們可以使用synchronized關鍵字來進行同步保護,也可以使用Volatile關鍵字來在多個線程之間共享數據。如果多于一個線程想要去訪問某一個共享數據結構的話,就發生了爭用的情形,這時,JVM需要決定哪個進程先,哪個進程后。如果決定該要執行的線程不是當前正在運行的線程,那么就會發生線程切換。當前線程需要等待,直到它成功獲得了鎖對象。JVM可以自己決定如何來執行這種“等待”,假如JVM預計離成功獲得鎖對象的時間比較短,那JVM可以使用激進等待方法,比如,不停地嘗試獲得鎖對象,直到成功,在這種情況下這種方式可能會更高效,因為比較進程上下文切換來說,還是這種方式更快速一些。把一個等待狀態的線程挪回到執行隊列也會帶來額外的開銷。
因此,我們要盡力避免由于鎖競爭而帶來的上下文切換。
下面將具體闡述兩種降低這種競爭發生的方法。
2、鎖競爭
兩個或更多線程對鎖的競爭訪問會帶來額外的運算開銷,因為競爭的發生逼迫調度器來讓一個線程進入激進等待狀態,或者讓它進行等待狀態而引發兩次上下文切換。有某些情況下,鎖競爭的惡果可以通過以下方法來減輕:
1.少鎖的作用域;
2.少需要獲取鎖的頻率;
3.量使用由硬件支持的樂觀鎖操作,而不是synchronized;
4.量少用synchronized;
5.少使用對象緩存
2.1 縮減同步域
如果代碼持有鎖超過必要的時間,那么可以應用這第一種方法。通常我們可以將一行或多行代碼移出同步區域來降低當前線程持有鎖的時間。在同步區域里運行的代碼數量越少,當前線程就會越早地釋放鎖,從而讓其他線程更早地獲得鎖。這與Amdahl法則相一致的,因為這樣做減少了需要同步執行的代碼量。
2.2 分拆鎖
另外一種減少鎖競爭的方法是將一塊被鎖定保護的代碼分散到多個更小的保護塊中。如果你的程序中使用了一個鎖來保護多個不同對象的話,這種方式會有用武之地。假設我們想要通過程序來統計一些數據,并且實現了一個簡單的計數類來持有多個不同的統計指標,并且分別用一個基本計數變量來表示(long類型)。因為我們的程序是多線程的,所以我們需要對訪問這些變量的操作進行同步保護,因為這些操作動作來自不同的線程。要達到這個目的,最簡單的方式就是對每個訪問了這些變量的函數添加synchronized關鍵字。
2.3 分離鎖
上面一個例子展示了如何將一個單獨的鎖分開為多個單獨的鎖,這樣使得各線程僅僅獲得他們將要修改的對象的鎖就可以了。但是另一方面,這種方式也增加了程序的復雜度,如果實現不恰當的話也可能造成死鎖。
分離鎖是與分拆鎖類似的一種方法,但是分拆鎖是增加鎖來保護不同的代碼片段或對象,而分離鎖是使用不同的鎖來保護不同范圍的數值。JDK的java.util.concurrent包里的ConcurrentHashMap即使用了這種思想來提高那些嚴重依賴HashMap的程序的性能。在實現上,ConcurrentHashMap內部使用了16個不同的鎖,而不是封裝一個同步保護的HashMap。16個鎖每一個負責保護其中16分之一的桶位(bucket)的同步訪問。這樣一來,不同的線程想要向不同的段插入鍵的時候,相應的操作會受到不同的鎖來保護。但是反過來也會帶來一些不好的問題,比如,某些操作的完成現在需要獲取多個鎖而不是一個鎖。如果你想要復制整個Map的話,這16個鎖都需要獲得才能完成。
2.4 原子操作
另外一種減少鎖競爭的方法是使用原子操作。java.util.concurrent包對一些常用基礎數據類型提供了原子操作封裝的類。原子操作類的實現基于處理器提供的“比較置換”功能(CAS),CAS操作只在當前寄存器的值跟操作提供的舊的值一樣的時候才會執行更新操作。
這個原理可以用來以樂觀的方式來增加一個變量的值。如果我們的線程知道當前的值的話,就會嘗試使用CAS操作來執行增加操作。如果期間別的線程已經修改了變量的值,那么線程提供的所謂的當前值已經跟真實的值不一樣了,這時JVM來嘗試重新獲得當前值,并且再嘗試一次,反反復復直到成功為止。雖然循環操作會浪費一些CPU周期,但是這樣做的好處是,我們不需要任何形式的同步控制。
2.5 避免熱點代碼段
一個典型的LIST實現通過會在內容維護一個變量來記錄LIST自身所包含的元素個數,每一次從列表里刪除或增加元素的時候,這個變量的值都會改變。如果LIST在單線程應用中使用的話,這種方式無可厚非,每次調用size()時直接返回上一次計算之后的數值就行了。如果LIST內部不維護這個計數變量的話,每次調用size()操作都會引發LIST重新遍歷計算元素個數。
這種很多數據結構都使用了的優化方式,到了多線程環境下時卻會成為一個問題。假設我們在多個線程之間共享一個LIST,多個線程同時地去向LIST里面增加或刪除元素,同時去查詢大的長度。這時,LIST內部的計數變量成為一個共享資源,因此所有對它的訪問都必須進行同步處理。因此,計數變量成為整個LIST實現中的一個熱點。
本文所講述的這些優化方案再一次的表明,每一種優化方式在真正應用的時候一定需要多多仔細觀測。不成熟的優化方案表面看起來好像很有道理,但是事實上很有可能會反過來成為性能的瓶頸。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。