用Java這麼多年,這些祕密你知道嗎?

認真期待發表於2018-05-11

摘要:

如果您是Java開發人員,那麼這些問題可能會讓您在某個時刻頭痛不已。繼續閱讀以瞭解如何處理這5個棘手的祕密。

Java是一個擁有悠久歷史的大型語言。在二十多年的時間裡,語言中蘊含著許多功能,其中一些功能對其改進有很大貢獻,另一些功能卻極大地簡化了它。在後一種情況下,這些功能中的很多功能都滯留在這裡,並且為了向後相容而留在這裡。在本系列的前一篇文章中,我們探討了該語言的一些奇怪特徵,這些特徵在日常實踐中可能不會使用。在這篇文章中,我們將介紹一些有用但常常被忽略的語言功能,以及一些有趣的特性,如果忽略,可能會引起嚴重的騷動。

對於這些祕密中的每一個,重要的是要注意它們中的一些,例如數字下劃線和快取自動裝箱在應用程式中可能是有用的,但是其他的(如單個Java檔案中的多個類)已被降級到backburner一個原因。因此,僅僅因為語言中存在的功能並不 意味著它應該被使用(即使它不被棄用)。相反,判斷應該用於何時應用這些隱藏功能。在研究好的,壞的和醜陋的之前,我們先從語言的特性開始,如果忽略這些語言會導致一些嚴重的錯誤:指令重新排序。

1.指令可以重新排序

由於多處理晶片數十年前進入計算環境,多執行緒已成為大多數不平凡的Java應用程式中不可或缺的部分。無論是在多執行緒超文字傳輸協議(HTTP)伺服器還是大資料應用程式中,執行緒都允許使用強大的中央處理單元(CPU)提供的處理預算同時執行工作。儘管執行緒是CPU使用率的重要組成部分,但它們可能會非常棘手,而且它們的錯誤使用可能會在應用程式中注入一些不合適且難以除錯的錯誤。

例如,如果我們建立一個列印變數值的單執行緒應用程式,我們可以假設我們在原始碼中提供的程式碼行(更準確地說,每條指令)都是按順序執行的,從第一行開始並以最後一行結束。遵循這個假設,下面的程式碼片段將導致4 列印到標準輸出中應該不令人驚訝 :

用Java這麼多年,這些祕密你知道嗎?

雖然它可能會出現的值 1 被分配給變數 x 第一和然後的值 3 被分配到 y,這可能不總是這樣的情況。仔細檢查後,前兩行的順序不會影響應用程式的輸出:如果 y 首先分配哪個位置,那麼 x系統的行為不會改變。我們仍然會看到 4 列印到標準輸出。使用這種觀察,編譯器可以 根據需要安全地重新排序這兩個指令,因為它們的重新排序不會改變系統的整體行為。當我們編譯我們的程式碼時,編譯器會這樣做:只要它不改變系統的可觀察行為,就可以自由地對上述指令進行重新排序。

儘管對上述命令重新排序似乎是徒勞無功,但在許多情況下,重新排序可能允許編譯器進行一些非常明顯的效能優化。例如,假設我們有下面的程式碼片段,其中我們 x 和 y 變數在交錯的方式遞增兩次:

用Java這麼多年,這些祕密你知道嗎?

8無論編譯器執行的任何優化如何,都應列印此片段 ,但請留下上述說明以便進行有效的優化。如果編譯器將增量重新排列為非交錯方式,則可以完全刪除它們:

用Java這麼多年,這些祕密你知道嗎?

實際上,編譯器可能會更進一步,並簡單地內聯 print語句中的值, x 並 y刪除將每個值儲存在變數中的開銷,但為了演示的目的,只需說重新排序指令就可以了編譯器會對效能做出一些重大改進。在單執行緒環境中,這種重新排序對應用程式的可觀察行為沒有任何影響,因為當前執行緒是唯一一個可以看到x 和 y,但在多執行緒環境中,這是不是 這種情況。由於有一個連貫的記憶體檢視,通常是不需要的,因此需要CPU的大量開銷(請參閱快取一致性),CPU通常會放棄一致性,除非被指示這樣做。同樣,Java編譯器可以自由地優化程式碼,以便重排序可以發生,即使多執行緒讀取或寫入相同的資料,除非另有指示。

在Java中,這強加了指令的偏序排序,由發生前 關係表示,其中hb(x,y) 表示指令x在y之前發生。在這種情況下,發生 -事實上並不意味著沒有發生指令的重新排序,而是說,x在y之前達到了一致狀態 (即 在執行y之前執行了所有對x的修改並且可見)。例如,在上面的程式碼片段中,變數 並且 必須達到它們的終端值(在 和上執行的所有計算的結果) xy x y)在執行印刷宣告之前。在單執行緒和多執行緒環境中,每個執行緒 中的所有指令都是以先發生的 方式執行的,因此當資料不是 從一個執行緒釋出到另一個執行緒時,我們從不會遇到重新排序問題。在釋出釋出(例如在兩個執行緒之間共享資料)時,可能會出現非常潛在的問題。

例如,如果我們執行以下程式碼(來自Java Concurrency in Practice,第340頁),那麼對具有併發經驗的開發人員來說,執行緒交織可能會導致(0,1),(1,0)或(1,1)列印到標準輸出; 但是,(0,0)由於重新排序也不是不可能的。

用Java這麼多年,這些祕密你知道嗎?

由於每個執行緒中的指令就不會有之前發生 彼此之間的關係,他們可以自由地重新排序。例如,執行緒 one 可能在執行x = b 之前 a = 1 和之後實際執行 ,執行緒 other 可能會在y = a 之前 執行 b = 1 (因為在每個執行緒的上下文中,這些指令的執行順序無關緊要)。如果這兩種再排序發生,結果可能是(0,0)。請注意,這不同於交錯,其中執行緒搶佔和執行緒執行順序會影響應用程式的輸出。交錯只能導致(0,1),(1,0)或(1,1)被列印:(0,0)是重新排序的唯一結果。

為了強制 兩個執行緒之間發生之前發生的關係,我們需要強制同步。例如,下面的程式碼片段刪除了導致(0,0)結果的重新排序的可能性,因為它 在兩個執行緒之間施加了一個before-before關係。但請注意,(0,1)和(1,0)是此程式碼段中唯一可能的兩種結果,具體取決於每個執行緒的執行順序。例如,如果執行緒 one 首先啟動,結果將為(0,1),但如果執行緒 other 先執行,結果將為(1,0)。

用Java這麼多年,這些祕密你知道嗎?

一般來說,有幾種明確的方式來強加一種先發生的 關係,包括(從 包文件中引用): java.util.concurrent

執行緒中的每個動作都發生在該執行緒中的每個動作之前,該動作稍後會按程式的順序進行。

監視器的解鎖(同步塊或方法退出)發生在相同監視器的每個後續鎖定(同步塊或方法輸入)之前。並且因為發生之前的關係是可傳遞的,所以在解鎖之前的執行緒的所有動作發生 - 在任何監視的執行緒鎖定之後的所有動作之前。

在相同欄位的每次後續讀取之前發生對volatile 欄位的寫入 。寫入和讀取 欄位具有與進入和退出監視器類似的記憶體一致性效果,但不需要互斥鎖定。volatile

在啟動執行緒中的任何操作之前,都會發生線上程上啟動的呼叫。

在 任何其他執行緒從該執行緒上的連線成功返回之前,執行緒中的所有操作都會發生。

在之前發生 偏序關係是一個複雜的話題,但我只想說,交織不是可以在併發程式導致錯誤偷偷摸摸唯一的困境。在任何情況下,在兩個或多個執行緒之間共享資料或資源的情況下,volatile必須使用某些同步機制(無論是synchronized,鎖定,原子變數等)來確保資料正確共享。有關更多資訊,請參見Java語言規範(JLS)的第17.4.5節和實踐中的Java併發。

下劃線可用於數字

無論是在計算機還是在紙筆數學中,大量的數字都很難讀懂。例如,試圖辨別1183548876845實際上是“1萬億1,853億548萬876萬845”可能是非常乏味的。值得慶幸的是,英語數學包括逗號分隔符,它允許一次將三位數字分組在一起。例如,現在更明顯的是,1,183,548,876,845代表超過一萬億美元的數字(通過計算逗號的數量)。

不幸的是,在Java中表示這麼大的數字往往是件麻煩事。例如,在程式中將這些大數字表示為常量並不罕見,如下面的程式碼片段所示,該程式碼片段顯示了上面的數字:

用Java這麼多年,這些祕密你知道嗎?

雖然這足以實現我們列印大量資料的目標,但不言而喻,我們創造的常量缺乏美感。值得慶幸的是,自從Java開發工具包(Java Development Kit,JDK)7以來,Java已經引入了一個與逗號分隔符相同的內容:下劃線。可以按照與逗號完全相同的方式使用下劃線,將數字組分開以提高大數值的可讀性。例如,我們可以重寫上面的程式,如下所示:

用Java這麼多年,這些祕密你知道嗎?

同樣,由於逗號使得我們的原始數學值更易於閱讀,現在我們可以更容易地讀取Java程式中的大數值。下劃線也可用於浮點值,如以下常數所示:

用Java這麼多年,這些祕密你知道嗎?

還應該注意的是,下劃線可以放置在一個數字中的任意點(不僅僅是分隔三個數字的組),只要它不是字首,字尾,與浮點值中的小數點相鄰,或者與x 十六進位制值相鄰 。例如,以下所有內容都是Java中的無效數字:

用Java這麼多年,這些祕密你知道嗎?

儘管這種用於分隔數字的技術不應該被過度使用,理想情況下,它只能以與英語數學中的逗號相同的方式使用,或者在小數位後以浮點值分隔三組數字 - 它可以幫助辨別以前不可讀的數字。有關數字下劃線的更多資訊,請參閱Oracle的數字文字文件中的Underscore。

3. Autoboxed整數快取

由於原始值不能用作物件引用和作為正式泛型型別引數,因此Java引入了原始值的盒裝物件的概念。這些裝箱值重要的是包裝原始值 - 從基元建立物件 - 允許它們用作物件引用和正式泛型型別。例如,我們可以按以下方式填充一個整數值:

用Java這麼多年,這些祕密你知道嗎?

實際上,原始int 500 被轉換為一個型別的物件 Integer 並儲存在其中 myInt。該處理稱為自動裝箱,因為自動執行轉換以將原始整數值 500 轉換為型別的物件Integer。實際上,這種轉換相當於以下內容(請參閱自動裝箱和取消裝箱以獲取更多關於原始程式碼段和下面程式碼段等效性的資訊):

用Java這麼多年,這些祕密你知道嗎?

既然 myInt 是一個型別的物件Integer,我們會期望將它的相等性與Integer 包含500 使用 == 操作符的另一個物件 進行比較 應該會導致false,因為兩個物件不是同一個物件(== 操作符的標準含義 ); 但是呼叫 equals 這兩個物件應該會導致true,因為這兩個 Integer 物件是代表相同整數(即, )的值物件500:

用Java這麼多年,這些祕密你知道嗎?

此時,自動裝箱操作完全按照我們預期任何價值物件的行為。如果我們用較小的數字嘗試這種情況會發生什麼?例如,25:

用Java這麼多年,這些祕密你知道嗎?

令人驚訝的是,如果我們嘗試這樣做 25,我們Integer 可以通過identity(==)和value來看到這兩個 物件是相等的。這意味著這兩個 Integer 物件實際上是同一個 物件。這種奇怪的行為實際上不是一個疏忽或錯誤,而是一個有意識的決定,如第5.1.7節所述。的JLS。由於許多自動裝箱操作都是在小數字(下127)下執行的,因此JLS指定 快取了 和 Integer 之間的值 (包括)。這反映在包含此快取的JDK 9原始碼中 :-128127Integer.valueOf

用Java這麼多年,這些祕密你知道嗎?

如果我們檢查原始碼IntegerCache,我們收集一些非常有趣的資訊:

用Java這麼多年,這些祕密你知道嗎?

雖然這段程式碼看起來很複雜,但實際上很簡單。根據JLS的第5.1.7節,快取Integer 值的包含下限 始終設定為-128,但包含的上限可由Java虛擬機器(JVM)配置。預設情況下,上限被設定為 (根據JLS 5.1.7),但它可以被配置成任何數量的更大的 比 大於最大整數值或更小。理論上,我們可以設定上限。127 127500

在實踐中,我們可以使用java.lang.Integer.IntegerCache.high VM屬性完成此操作 。例如,如果我們基於500 with 的自動裝箱重新執行原始程式 -Djava.lang.Integer.IntegerCache.high=500,則程式行為會發生變化:

用Java這麼多年,這些祕密你知道嗎?

一般情況下,除非有嚴重的效能需求,否則不應將VM調整為更高的 Integer 快取值。此外,的盒裝形式 boolean,char,short,和 long (即Boolean,Char, Short和 Long)也被快取,但通常都不會 有VM設定來改變它們的快取上限。相反,這些界限通常是固定的。例如, Short.valueOf 在JDK 9中定義如下:

用Java這麼多年,這些祕密你知道嗎?

有關自動裝箱和快取對話的更多資訊,請參閱JLS的5.1.7節,以及 為什麼128 == 128返回false但127 == 127在轉換為Integer包裝時返回true?

4. Java檔案可以包含多個非巢狀類

一個普遍接受的規則是 .java 檔案必須只包含一個非巢狀類,並且該類的名稱必須與該檔案的名稱相匹配。例如, Foo.java 只能包含一個名為的非巢狀類Foo。雖然這是一項重要的做法和公認的慣例,但這並非完全正確。特別是,它實際上作為Java編譯器的實現決策留下,不管是否強制限制檔案的公共類必須與檔名相匹配。根據JLS第7.6節的規定:

用Java這麼多年,這些祕密你知道嗎?

在實踐中,大多數編譯器實現強制執行此限制,但在此定義中也有一個限定條件:該型別被宣告為public。因此,通過嚴格的定義,這允許Java檔案包含多個類,只要最多一個類是公共的。換句話說,實際上,所有的Java編譯器都強制限制頂級公共類必須匹配檔案的名稱(不考慮 .java 副檔名),這限制了Java檔案擁有多個公共頂級類(因為只有一個這些類可以匹配檔案的名稱)。由於此語句僅限於公共類,所以只要最多隻有一個類是公共的,就可以將多個類放置在Java原始碼檔案中。

例如,Foo.java即使它包含多個類(但只有一個類是public類,並且公共類與該檔案的名稱相匹配),以下檔案(named )仍然有效:

用Java這麼多年,這些祕密你知道嗎?

如果我們執行這個檔案,我們會看到10 列印到標準輸出的值 ,這表明我們可以例項化並與Bar 類(第二個但在我們的Foo.java 檔案中非公共類 )進行互動, 就像我們任何其他類一樣。我們也可以Bar 從另一個Java檔案(Baz.java)中與類進行互動, 只要它包含在同一個包中,因為 Bar 類是包私有的。因此,以下 Baz.java 檔案列印 20 到標準輸出:

用Java這麼多年,這些祕密你知道嗎?

儘管Java檔案中可能有多個類,但這不是 一個好主意。在每個Java檔案中只有一個類是常見的約定,違反這個約定會給其他開發人員讀取檔案帶來一些困難和挫折。如果檔案中需要多個類,則應使用巢狀類。例如,我們可以Foo.java 通過巢狀Bar 類輕鬆地將檔案減少 到單個頂級 類(因為Bar 類不依賴於特定的例項,因此使用靜態巢狀 Foo):

用Java這麼多年,這些祕密你知道嗎?

一般而言,應避免單個Java檔案中的多個非巢狀類。有關更多資訊,請參閱 Java檔案是否可以有多個類?

5. StringBuilder用於字串連線

字串連線是幾乎所有程式語言的常見部分,允許將多個字串(或物件和不同型別的基元)合併為一個字串。Java中字串連線複雜化的一個警告是 不可變性Strings。這意味著我們不能簡單地建立一個 String 例項並不斷追加到它。相反,每個附加產生一個新的 String 物件。舉例來說,如果我們看看 concat(String) 米的ethod String,我們看到一個新的 String 例項製作:

用Java這麼多年,這些祕密你知道嗎?

如果將這種技術用於字串連線,String 則會產生大量中間 例項。例如,以下兩行在功能上是等同的-第二行生成兩個 String 例項只是為了執行級聯:

用Java這麼多年,這些祕密你知道嗎?

雖然這可能看起來像是一個很小的代價來支付字串連線,但這種技術在大規模使用時會變得難以維繫。例如,以下內容會浪費地建立1,000個 String 永遠不會使用的String 例項(建立1,001個 例項並僅使用最後一個例項):

用Java這麼多年,這些祕密你知道嗎?

為了減少String 為連線建立的浪費例項 的數量, JLS的第15.18.1節提供了有關如何實現字串連線的強烈建議:

用Java這麼多年,這些祕密你知道嗎?

a不是建立中間String 例項,而是將 a StringBuilder 用作緩衝區,從而允許新增無數個 String 值直到String 需要結果為止 。這確保只String 建立一個 例項:結果。此外,StringBuilder 建立了一個 例項,但是這種組合開銷要比String 建立的個別例項要少得多 。例如,我們上面寫的迴圈串聯在語義上等同於以下內容:

用Java這麼多年,這些祕密你知道嗎?

而不是建立1,000個浪費的 String 例項,只 建立一個 StringBuilder 和一個 String例項。儘管我們可以手動編寫上面的程式碼片段而不是使用串聯(通過 += 運算子),但兩者都是等價的,並且沒有效能增益。在許多情況下,使用字串連線而不是明確的 StringBuilder,在語法上更容易,因此是首選。不管選擇如何,重要的是要知道Java編譯器會盡可能提高效能,因此,試圖過早地優化程式碼(例如通過使用顯式 StringBuilder 例項)可能會以犧牲可讀性為代價提供很少的收益。

結論

Java是具有歷史傳奇的大型語言。多年來,這門語言有數不清的增加,可能還有很多錯誤的增加。這兩個因素的結合導致了語言的一些非常奇特的特徵:一些好,一些壞。其中一些方面(如數字下劃線,快取自動裝箱和字串級聯優化)對於任何Java開發人員來說都是很重要的,而單個Java檔案中諸如類的多樣性等功能已被降級到已棄用的架構。其他的,比如不同步指令的重新排序,如果處理不當,可能會導致一些非常繁瑣的除錯。


在此我向大家推薦一個架構學習交流群。交流學習群號: 744642380, 裡面會分享一些資深架構師錄製的視訊錄影:有Spring,MyBatis,Netty原始碼分析,高併發、高效能、分散式、微服務架構的原理,JVM效能優化、分散式架構等這些成為架構師必備的知識體系。還能領取免費的學習資源

相關文章