Java高性能編程論述

高性能編程

前言

首先說一下我爲什麼要寫這篇博客。因爲面試有提到這個,我當時直接說不懂(一方面當時心態很差,另一方面面試官的詢問方式令我很反感。所以直接refuse了。小夥伴們千萬別學我)。

所以,打算談一談我對Java高性能編程方面的認識與總結。

首先,高性能編程不涉及架構層次。所以打算通過這篇文章,來了解架構提升系統性能的小夥伴要失望了。我將Java高性能編程主要分爲編碼與網絡兩個部分(說白了,只關注編碼,不提其它)。

其次,我們需要了解何爲高性能。性能往往與系統的吞吐量,響應時間,併發量等息息相關。只有瞭解到這點,我們纔可以對症下藥。

網絡部分:BIO,NIO,Netty等,這部分在之前的《從BIO到Netty的演變》有所提及,這裏不再贅述。

而編碼部分,也是最多人關注的部分。我將它按層次分爲:

  • 數據結構(如String,StringBuffer,StringBuilder)
  • 語言特性(如for循環的JIT優化,並行流等)
  • 算法(如分治算法,貪心算法等)
  • 設計模式(如原型模式)
  • 多線程(包括線程池,鎖等)
  • 擴展-特定機制(其實就是一些成熟的方案)

併發容器被歸類到多線程中,而Fork/Join框架被歸類到特定機制(當然,也可以歸類到算法,多線程等。取決於看待它的角度)。

由於這其中每個分支,拆分出來都是很大的一塊內容。所以這篇文章的目標只是給個方向而已,不會寫得非常深入。

一,數據結構

這裏的數據結構指的是,類中屬性的數據類型,可以是Java自帶的數據類型,也可以是自定義的數據類型。

那麼數據結構是如何影響性能的呢?這可以從我之前提到的高性能編碼的六個層次去分析。這裏不在展開,否則就是無限地遞歸分析了。

說得再多,不如舉一些例子(好吧,其實這邊的總結,我感覺不夠好。劃分的維度,我自己不滿意而已)。

1.Integer與int:

比如Integer與int,在大量數據處理時,內部結構最好採用int類型,從而避免基本數據類型裝箱拆箱帶來的性能損耗。而這一點在《阿里開發手冊》中也有提及,所以在不知道怎麼做時,跟着規範走,也不失爲一種選擇。

PS:記得當時,我回答面試官高性能編程問題時,就是從《阿里開發手冊》規範談起。然後面試官打斷說,我不是問你規範,我就問你怎麼高性能編程。當時心態本來就不好,我想引經據典,都不讓我說。所以我直接一句,我不懂。不過不推薦大家學我,該回答還是要回答滴。

2.String,StringBuffer,StringBuilder:

比如String,StringBuffer,StringBuilder(這三個應該鼎鼎有名了)。

其中最常用的時String,其底層實現是一種常量字符串(也就是不可變字符串)。其底層就是final修飾的,所謂的String更新,其實是返回了一個新的字符串。由於是常量,所以也就沒有什麼線程安全問題了,故線程安全。

其次就是我目前用得最多的,就是StringBuiilder,其內部是維持一個可變長度的char[]。字符串更新,也是採用底層的native方法,所以效率非常高,是三個字符串類型中效率最高的。但沒有做線程安全處理,所以線程不安全。

最後就是在一些高併發框架,中間件底層中常看到的StringBuffer,其內部基本與StringBuilder類似。不過不同的是其中多數方法採用了Synchronized修飾,作爲線程安全的處理,所以線程安全。

所以,就有了以下的使用場景:

  • String:針對於字符串幾乎沒有變動的操作,在字符串變動方面操作是三個中最慢的(除非兩個產生擴容)。但固定字符串方面,確實是調用最快,佔用空間最少的。
  • StringBuilder:在單線程下,存在字符串變動的情況下,速度最快。
  • StringBuffer:在多線程下,存在字符串變動的情況下,速度最快。

如果表示上面的記不住。那就這樣記:微量使用,用String;單線程最快,是StringBuilder;多線程安全,是StringBuffer。

其實數據結構,還有NIO的ByteBuffer,Netty的ByteBuf等,可以聊。不過這裏不再贅述。

二,語言特性:

這裏的語言特點,指的是Java語言的一些高級特性(我也不知道算不算高級,不過確實有不少人不清楚,所以就這樣寫吧)。

那麼語言特性是如何影響性能的呢?其實,其中涉及到語言的底層實現(這個實現,其實也可以按照六個層次分析)。

繼續舉例子。

1.for循環JIT優化:

這個部分,涉及JVM的JIT編譯。簡單說一下,JIT是一種即時編譯手段。Java代碼一般採用解釋,但在遇到重複執行代碼塊等,會進行編譯,從而提升速度。其中又涉及到無數人深惡痛絕的三大重排序(編譯器,指令,內存)之一-JIT重排序。

扯遠了,收回來。那麼for循環中的循環體,當然屬於重複執行的代碼塊嘍。所以for循環會被JIT優化,提升運行速度。所以在有些場景下,別覺得for循環很low,覺得迭代器(瞭解迭代器模式,應該知道迭代器模式本身,不存在循環體這樣的代碼塊),Stream看着高級。在所有場景下,for循環效率高於迭代器,在多數場景下,for循環效率高於Stream。

當然,由於性能並不是系統的唯一質量屬性,所以站在擴展性等方面,我們也需要對iterator等正確看待。沒有最好的技術,只有更合適的技術。

2.Stream:

Stream作爲Java8新引入的特性,不少人對它瞭解甚少。看到別人處理集合的Stream代碼,簡直看得口水都下來了,想着自己什麼時候才能寫出這樣優雅的代碼。

遙想當初,我也是看着大佬處理訂單集合的代碼,流口水,果斷去學習這個,然後。。。扯遠了,拉回來。

前面已經提到了,在多數場景下for循環的效率高於Stream。只不過for採用的是外部迭代,而Stream採用的是內部迭代。但是Stream和迭代器一樣,沒有類似循環體的代碼塊,所以沒有這方面的JIT優化。所以在正常應用場景下,兩者存在一定的差距(數據量大了後,兩者的差距也在10%之內)。

但是,Stream有一個方法-parallel()。這個方法就厲害了,它是一個並行流。說白了,就是多線程處理當前流,通過多線程方法,將當前流拆分爲多個流,來並行處理。

是不是感覺追根揭底,還是能歸類到之前提到的六個層次中。

既然並行流效率這麼高,爲什麼還用for循環呢?

因爲並行流有着諸多限制。首先它內部是採用了多線程的處理手段,所以多線程的約束它都有(如CPU核心數帶來的限制等)。其次它對流的處理必須是可以並行的,如過濾,轉變,提交等。但是如排序,findFirst這樣需要整個流的操作,就無法實現(或者說無法獲得正確結果),或者實現效率極低。

所以,在一些可以並行操作的情況下,並且數據量較大,那麼推薦使用並行流處理。優雅而高效。

3.迭代器:

這裏提一下迭代器,否則大家就拋棄這個了。

迭代器的好處:自定義迭代邏輯,無返回函數的集合處理等。

其實迭代器,我用得並不多,一方面是一開始看到有前輩這樣寫,所以學習了一下這種寫法。不過後來大多用for,以及Stream了。另一方面,就是面試問到如何通過無返回方法,修改集合元素。我當時只想到了Java方法參數不是引用傳遞這個點,解決還整沒想到。面試官後來告訴我,可以通過迭代器實現。囧

三,算法:

算法這部分內容就海了去了。而且以我現在的算法積累,感覺並不能很好地解釋這個問題。所以簡單舉些例子吧,以後有機會,再深入闡述算法(等我深入學習後,計劃在明後年深入)。

比如排序算法,這也算是算法實現比較多的。感興趣的,可以看我在博客園C語言算法部分總結的八大排序算法。

1.紅黑樹:

面試集合必問的HashMap與ConcurrentHashMap按不同版本(Java8及Java8以前)可以分爲四個。其中不同版本之間最大的區別就是Java8之後,兩者底層增加了紅黑樹(再單個鏈表達到閾值後,就會樹化)。

那爲什麼紅黑樹可以提高性能呢?

在原本的HashMap(以HashMap爲代表)中,最初數據操作的時間複雜度平均爲O(logn)。但是存在數據集中於數組特定位置的情況(HashMap底層數據結構是數組+鏈表,鏈表作爲數組的元素),最糟糕情況就是數據集中在數組一個下標下,即所有元素在同一個鏈表。那麼鏈表的數據操作的時間複雜度就變成了O(n)。

爲了避免這一情況,新版本的HashMap增加了紅黑樹。在單個鏈表的數據量達到閾值後,則會將鏈表轉爲紅黑樹,從而確保在數據多的時候(即n較大時),時間複雜度依舊爲O(logn)。

時間複雜度降低了,說明CPU佔用時間降低了,即性能提升了。

2.分治算法:

分治算法作爲一種及其重要的算法思想,可以說程序中很多地方都體現了這點。甚至有人總結分佈式系統的高併發就是限流(非俠義的限流,保護下游服務)與分流(提高吞吐量),而分流也是分治思想的一種體現。

那爲什麼分治算法可以提高性能呢?其實分治算法也是一種空間換時間的體現。更深入的闡述,其實就是均衡。

這裏簡單闡述一下我對均衡的認識。早在架構相關的博客中,我就提到,架構設計就是一個權衡的過程。提高性能,可能就需要降低安全性。提高系統擴展性,可能就會帶來系統複雜度的提升。這點可用於各個層次,包括設計模式也是這樣的。以後有機會深入闡述這種思想。

舉一個分治算法的體現例子。如Fork/Join框架,通過對現有任務的拆分,分開進行計算,最終彙總。其實本質所需要的算力並沒有改變,但是可以充分利用多線程,或者CPU多核的特性(其實還涉及CPU資源的競爭,不再深入)。

再舉一個分治算法的體現例子,正好也是我在阿里面試時遇到的一個問題,如何實現淘寶雙十一交易額的實時統計(延遲不得高於1s)。我的實現思路,就是一方面利用Redis內存數據庫的高速特性(單臺Redis實例正常每秒都可以有幾十萬的讀寫)。另一方面就是利用分治算法,將統計任務進行拆分。

這裏說一下我的思路吧。首先排除大數據,因爲我不熟悉大數據,而且我面試的也不是大數據,最重要的是大數據底層也需要實現(不能面試時,就扔個名詞)。其次通過redis來提供一個分佈式系統的存儲(分佈式系統的存儲無非緩存,數據庫,文件系統,消息隊列算半個吧)。這個時候,需要思考的是,雖然redsi可以提供足夠的讀寫,但是在數據處理時,程序爲了確保數據不被別的程序修改,一定需要加分佈式鎖。那麼時間消耗就體現在了這裏。參照ConcurrentHash分段鎖,及其體現的分治算法。可以將交易額按照業務,地域,或者單純的數字拆分爲多個字段(如1000g個),然後分別計算,最後通過一個服務,按照交易額刷新時間進行統計(這裏統計還可以使用fork/join框架)。當然,這並不是一個完整的程序,其中還有許多的細節,如動態管理等。但是這樣的思路是可行的。

對算法感興趣的,可以看看相關算法書籍,刷刷leetcode等。

四,設計模式

設計模式部分,就是利用設計模式的特點,來達成提高程序性能的目的。

其實設計模式本身是面向對象設計原則(單一職責原則,里氏替換原則,開閉原則,依賴倒置原則,接口隔離原則,組合複用原則,迪米特原則)的落實,而這七大原則並沒有性能方面的要求。所以大部分設計模式並沒有性能提升的能力。但是部分設計模式由於其特定機制,以及語言特性,有了性能方面的提升。

1.單例模式

單例模式,由於其構造方法私有,所以無法通過構造器新建。這點透出其本質,單例模式保證了一個類只有一個實例,該實例只需要創建一次,並不會銷燬。

這裏簡單說一下,由於是單例,所以線程安全。另外Spring的bean默認是單例的。某些情況下,可能需要通過註解,實現多例,此時就需要注意線程安全問題了。

由於單例的延遲加載,以及實例只創建一次,不會銷燬的特性。在某些對象重複使用的情況下,會帶來性能的提升。甚至在某些情況下,可能需要建立容器單例(一個容器,管理多個單例,如Spring)。

2.原型模式

原型模式,就是指定目標對象類型,通過拷貝來生成對象,而不需要調用構造器。而通過對象拷貝生成對象的效率遠高於構造器生成對象。

所以需要大量相同對象時,可以通過原型模式,來實現相同對象的高效生成。

但是,這裏提醒一下,原型模式需要注意複雜對象的拷貝問題。複雜對象的拷貝容易產生問題,這其中設計淺拷貝與深拷貝問題。感興趣的朋友,可以查閱相關資料。

3.享元模式

享元模式,通過減少對象數量,從而改善應用所需的對象結構。既然減少了對象的創建,那麼也就減少了內存中對象數量,從而降低了系統內存佔用與對象創建的資源消耗。

所以需要大量類似對象時,如緩存池等,可以採用享元模式,來提高系統性能。

享元模式,貌似在系統底層應用得比較多。

以上,通過三個設計模式,簡單闡述了設計模式帶來的編程性能。更多的應用,需要各位朋友自行探討。

五,多線程

這個部分,應該是多數人在高性能編程時優先考慮到的點。也是諸多面試的重點,所以這裏我就簡單說一下。

多線程是如何提高編程性能的呢?這其中有多種原因。

  • 有的是由於單個線程的操作存在資源阻塞的情況(如數據庫請求,等待數據庫響應)
  • 有的是爲了充分利用多核CPU的多核特性(如Nginx的worker默認是auto,也就是系統CPU核心數)
  • 其實還有一種比較難以理解,是爲了平滑CPU計算(如大當量的計算任務進行拆分等,具體後面會闡述)

這裏還有一點,需要進行說明。那就是線程數量的增加,帶來了局部程序的性能提升,但其實降低了系統整體性能。因爲每個線程都有競爭CPU時間片的資格。在達到一定的總線數量後,某個程序的線程數增加,只是增加了其獲得CPU時間片的時間。而整體CPU時間是固定的,也就是系統整體性能並沒有帶來提升,並且系統整體性能反而因爲線程的上下文切換等原因,降低了性能。當然這個總線程數量的“一定”是不好把握的。並且在某些情況下,多個線程會帶來一定的平滑效果。

爲了更好的說明,下面舉一些例子。

1.資源阻塞:

這個應該是一個比較好理解的例子。

正如之前提到的數據庫的例子。單個服務所需總時間爲1s,其中數據庫資源訪問花了0.9s,程序運行花費0.1s。如果是單線程,這個服務的吞吐量就只有1/s。那如果採用多個線程,並且線程數量設置爲10,那麼該服務的吞吐量就有10/s。甚至在數據庫等條件允許的情況下,這個服務的吞吐量可以無限大,只要線程數量足夠(當然實際是不可能的,總會有新的性能瓶頸出來的)。

這個應用,請大家牢牢記住,因爲這是一個非常常用的應用。

2.CPU核心數

大家都知道,一個線程會競爭一個CPU。而如今很多服務器都是多核的。那麼爲了充分利用服務器的多核特性,面對計算密集型任務,我們往往將其線程數量設置爲CPU核心數。

舉個例子,八核服務器上有這麼一個服務,需要進行挖礦計算(其採用POW算法,是非常消耗算力的)。如果單線程的話,那就是傳說中的“一核有難,七核圍觀”(一個CPU核心瘋狂工作,其餘七個CPU核心基本空閒)。那麼算力的產出就只有1。那麼如果是多線程,並且設置線程數爲8,那麼服務器八個核心都會瘋狂工作。算力的產出有8。如果採用多線程,並且線程數爲9(大於8)。那麼就是服務器八個核心瘋狂工作,8個線程在執行,還有1個線程在瘋狂插隊。從而導致服務器八個CPU核心,是不是還得停下來,切換上下文,從而換一個線程執行。最終算力產出低於8。

這種情況下,一定要考慮上下文切換的資源消耗。另一方面,一定要清楚總體算力,算力有多少是真正應用到目標程序中,最終算力產出是多少。

3.平滑CPU計算

這個比較難以理解,也基本看不到人提及,實際應用價值也不大(一般用不到)。而且沒有在實際應用中,真的應用,只能說從理論方面探尋。

之前提到的上下文切換,說白了就是CPU將數據從內存中讀取到CPU中,再將之前線程的數據保存到內存中。

那麼如果服務執行的數據量很大呢?

單線程情況下,一方面上下文切換時數據量較大(可以將上下文理解爲程序快照),另一方面由於高速緩存無法承載如此大的數據量,就會頻繁與內存發生數據交互(這涉及內存管理,這裏不再贅述,詳見軟考-架構師系列博客)。

多線程情況下,一方面上下文切換時數據量較小(但是總體不變,甚至更大,但是合理的程序設計,有可能上下文切換佔用內存更低),另一方面,避免了高速緩存無法承載過多數據的問題。

多線程提高性能的情況,多數可以歸於上述三類(或者說前兩類吧)。有不同看法,或者有無法歸類上述類型的,歡迎提出,共同進步。

六,特定機制

特定機制,就類似於一些取巧的方法,大多是採用系統資源交換來實現(如空間換時間等)。

常見的有:

  • Fork/Join框架
  • CopyOnWriteArray
  • Netty的零拷貝

1.Fork/Join框架

這個框架已經在前面的算法部分提及,這裏只是站在機制角度多說兩句。

一方面,多數的機制,也都可以按照高性能六個層次來分,只不過成爲了較爲成熟的方案而已。另一方面,多數機制都是採用分治,資源交換等來實現的。

2.CopyOnWriteArry

這是JDK中的一個數組,用於針對讀多寫少的場景。

最早,解決數組數據的讀寫問題,我們都是單線程操作,除了效率低,沒什麼問題。

然後,爲了提高性能,我們採用了多線程,而線程不安全的數組會給我們帶來線程安全問題。

緊接着,爲了解決線程安全問題,最早採用簡單的獨佔鎖來解決這個問題。

後來,人們發現讀的操作並不會帶來線程安全問題(因爲沒有數據修改,等同於操作常量)。所以人們採用讀寫鎖來解決線程安全問題(讀寫鎖,即同時只能一個寫操作,但可以有多個讀操作。這裏可以複習一下鎖降級概念)。

最後,人們給出了更好的解決方案-CopyOnWriteArry。同時有兩個數組,一個進行讀操作,一個進行寫操作。但是就像JVM運行時數據區中的Survive區一樣,在一個寫操作完成後,寫數組與數組就進行切換(這個切換,只需要修改數組的指向Reference即可)。這樣就保證了最高效的讀操作。這和數據庫的讀寫分離有着異曲同工之妙(只不過,一般不直接進行切換而已)。

通過,雙倍的內存空間消耗,換來了讀操作的大幅提升,即程序性能提升。

3.Netty的零拷貝:

Netty的零拷貝機制,來源於Netty的ByteBuf。而ByteBuf有着諸多優點,這裏不再贅述,感興趣的可以等待我的Netty源碼分析的博客。

這裏只簡單說一下其中的零拷貝機制。

Netty的零拷貝機制,體現在兩點。第一個是多個實際數據的虛擬邏輯組合。另一個是避免數據在內存中的用戶空間與內核空間之間的拷貝。

前者就是通過CompositeByteBuf,將多個ByteBuf合併爲一個** 邏輯** 上的ByteBuf,避免了各個ByteBuf之間的拷貝。舉個栗子,現有數組A與數組B,你需要兩個數組的合併數組C。正常就是生成一個新的數組,將兩者copy過來。而零拷貝,則是數組C中包含數組A和數組B的指向。

後者則涉及內存的用戶空間與內核空間的知識,這裏不再解釋兩者,感興趣的可以查看我早期Windows內核編程的相關部分。而ByteBuf的零拷貝用到了Java的FileChannel.transferTo方法,直接將文件緩衝區的數據,直接發送到目標Channel(網卡接口Buffer)。

ByteBuf對直接內存的使用,算是一種支撐。但是其獨立出來,我認爲並不能體現零拷貝特性。

當然,零拷貝的應用還有很多,包括Kafka等,也有相關應用。

七,總結

最終,我想說的是,高性能,其實也和架構設計一樣,伴隨着均衡,即有得有失。只不過有些東西,在系統的目標中權重並不高,或者說並沒有那麼高的要求而已。我們要做的是找到性能的瓶頸,去處理它。

如果上述觀點,有任何不當或不足的,可以直接私信或@我。願與諸君共進步。

發佈了14 篇原創文章 · 獲贊 1 · 訪問量 1247
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章