天天看點

解讀《阿裡巴巴 Java 開發手冊》背後的思考

《阿裡巴巴 Java 開發手冊》是阿裡巴巴集團技術團隊的集體智慧結晶和經驗總結,經曆了多次大規模一線實戰的檢驗及不斷的完善,系統化地整理成冊,回報給廣大開發者。現代軟體行業的高速發展對開發者的綜合素質要求越來越高,因為不僅是程式設計知識點,其它次元的知識點也會影響到軟體的最終傳遞品質。

他凝聚了阿裡集團很多同學的知識智慧和經驗,這些經驗甚至是用血淋淋的故障換來的,希望前車之鑒,後車之師,能夠幫助更多的開發者少踩坑,杜絕踩重複的坑。

手冊中很多規約其實都有很多背後的思考的,但是需要聲明一點:筆者并非手冊的編寫者,也是一位使用者,本場 Chat 将站在使用者的角度,試圖揣測一下手冊中部分約定背後的思考。

另外,2018 年 9 月 22 日,在 2018 杭州雲栖大會上,召開《碼出高效:Java 開發手冊》新書釋出會,并宣布将圖書所有收益均捐贈于技術公益項目“83 行代碼計劃”。該書也推薦大家閱讀。

豆瓣介紹:https://book.douban.com/subject/30333948/ 京東位址:https://item.jd.com/31288905323.html 當當位址:http://product.dangdang.com/25346848.html

下面開始展開正文。

為什麼禁止工程師直接使用日志系統(Log4j、Logback)中的 API

解讀《阿裡巴巴 Java 開發手冊》背後的思考

作為 Java 程式員,我想很多人都知道日志對于一個程式的重要性,尤其是 Web 應用。很多時候,日志可能是我們了解應用程式如何執行的唯一方式。

是以,日志在 Java Web 應用中至關重要,但是,很多人卻以為日志輸出隻是一件簡單的事情,是以會經常忽略和日志相關的問題。在接下來的幾篇文章中,我會來介紹介紹這個容易被大家忽視,但同時也容易導緻故障的知識點。

Java 語言之是以強大,就是因為他很成熟的生态體系。包括日志這一功能,就有很多成熟的開源架構可以被直接使用。

首先,我們先來看一下目前有哪些架構被廣泛的使用。

常用日志架構

j.u.l

解讀《阿裡巴巴 Java 開發手冊》背後的思考

 j.u.l 是 java.util.logging 包的簡稱,是 JDK 在 1.4 版本中引入的 Java 原生日志架構。Java Logging API 提供了七個日志級别用來控制輸出。這七個級别分别是:SEVERE、WARNING、INFO、CONFIG、FINE、FINER、FINEST。

Log4j

解讀《阿裡巴巴 Java 開發手冊》背後的思考

Log4j 是 Apache 的一個開源項目,通過使用 Log4j,我們可以控制日志資訊輸送的目的地是控制台、檔案、GUI 元件,甚至是套接口伺服器、NT 的事件記錄器、UNIX Syslog 守護程序等;我們也可以控制每一條日志的輸出格式;通過定義每一條日志資訊的級别,我們能夠更加細緻地控制日志的生成過程。最令人感興趣的就是,這些可以通過一個配置檔案來靈活地進行配置,而不需要修改應用的代碼。

Log4 也有七種日志級别:OFF、FATAL、ERROR、WARN、INFO、DEBUG 和 TRACE。

LogBack

解讀《阿裡巴巴 Java 開發手冊》背後的思考

LogBack 也是一個很成熟的日志架構,其實 LogBack 和 Log4j 出自一個人之手,這個人就是 Ceki Gülcü。

logback 目前分成三個子產品:logback-core,logback- classic 和 logback-access。logback-core 是其它兩個子產品的基礎子產品。logback-classic 是 Log4j 的一個改良版本。此外 logback-classic 完整實作 SLF4J API 使你可以很友善地更換成其它日記系統如 Log4j 或 j.u.l。logback-access 通路子產品與 Servlet 容器內建提供通過 Http 來通路日記的功能。

Log4j2

前面介紹過 Log4j,這裡要單獨介紹一下 Log4j2,之是以要單獨拿出來說,而沒有和 Log4j 放在一起介紹,是因為作者認為,Log4j2 已經不僅僅是 Log4j 的一個更新版本了,而是從頭到尾被重寫的,這可以認為這其實就是完全不同的兩個架構。

關于 Log4j2 解決了 Log4j 的哪些問題,Log4j2 相比較于 Log4j、j.u.l 和 logback 有哪些優勢,我們在後續的文章中介紹。

前面介紹了四種日志架構,也就是說,我們想要在應用中列印日志的時候,可以使用以上四種類庫中的任意一種。比如想要使用 Log4j,那麼隻要依賴 Log4j 的 jar 包,配置好配置檔案并且在代碼中使用其 API 列印日志就可以了。

不知道有多少人看過《阿裡巴巴 Java 開發手冊》,其中有一條規範做了『強制』要求:

解讀《阿裡巴巴 Java 開發手冊》背後的思考

說好了以上四種常用的日志架構是給 Java 應用提供的友善進行記錄日志的,那為什麼又不讓在應用中直接使用其 API 呢?這裡面推崇使用的 SLF4J 是什麼呢?所謂的門面模式又是什麼東西呢?

什麼是日志門面

日志門面,是門面模式的一個典型的應用。

門面模式(Facade Pattern),也稱之為外觀模式,其核心為:外部與一個子系統的通信必須通過一個統一的外觀對象進行,使得子系統更易于使用。

解讀《阿裡巴巴 Java 開發手冊》背後的思考

就像前面介紹的幾種日志架構一樣,每一種日志架構都有自己單獨的 API,要使用對應的架構就要使用其對應的 API,這就大大的增加應用程式代碼對于日志架構的耦合性。

為了解決這個問題,就是在日志架構和應用程式之間架設一個溝通的橋梁,對于應用程式來說,無論底層的日志架構如何變,都不需要有任何感覺。隻要門面服務做的足夠好,随意換另外一個日志架構,應用程式不需要修改任意一行代碼,就可以直接上線。

在軟體開發領域有這樣一句話:計算機科學領域的任何問題都可以通過增加一個間接的中間層來解決。而門面模式就是對于這句話的典型實踐。

為什麼需要日志門面

前面提到過一個重要的原因,就是為了在應用中屏蔽掉底層日志架構的具體實作。這樣的話,即使有一天要更換代碼的日志架構,隻需要修改 jar 包,最多再改改日志輸出相關的配置檔案就可以了。這就是解除了應用和日志架構之間的耦合。

有人或許會問了,如果我換了日志架構了,應用是不需要改了,那日志門面不還是需要改的嗎?

要回答這個問題,我們先來舉一個例子,再把門面模式揉碎了重新解釋一遍。

日志門面就像飯店的服務員,而日志架構就像是後廚的廚師。對于顧客這個應用來說,我到飯店點菜,我隻需要告訴服務員我要一盤番茄炒蛋即可,我不關心後廚的所有事情。因為雖然主廚從把這道菜稱之為『番茄炒蛋』A廚師換成了把這道菜稱之為『蕃茄炒雞蛋』的 B 廚師。但是,顧客不需要關心,他隻要下達『番茄炒蛋』的指令給到服務員,由服務員再去翻譯給廚師就可以了。

是以,對于一個了解了"番茄炒蛋的多種叫法"的服務員來說,無論後廚如何換廚師,他都能準确的幫使用者下單。

同理,對于一個設計的全面、完善的日志門面來說,他也應該是天然就相容了多種日志架構的。是以,底層架構的更換,日志門面幾乎不需要改動。

以上,就是日志門面的一個比較重要的好處——解耦。

常用日志門面

介紹過了日志門面的概念和好處之後,我們看看 Java 生态體系中有哪些好的日志門面的實作可供選擇。

SLF4J

解讀《阿裡巴巴 Java 開發手冊》背後的思考

Java 簡易日志門面(Simple Logging Facade for Java,縮寫 SLF4J),是一套包裝 Logging 架構的界面程式,以外觀模式實作。可以在軟體部署的時候決定要使用的 Logging 架構,目前主要支援的有 Java Logging API、Log4j 及 logback 等架構。以MIT 授權方式釋出。

SLF4J 的作者就是 Log4j 的作者 Ceki Gülcü,他宣稱 SLF4J 比 Log4j 更有效率,而且比 Apache Commons Logging (JCL) 簡單、穩定。

其實,SLF4J 其實隻是一個門面服務而已,他并不是真正的日志架構,真正的日志的輸出相關的實作還是要依賴 Log4j、logback 等日志架構的。

由于 SLF4J 比較常用,這裡多用一些篇幅,再來簡單分析一下 SLF4J,主要和 Log4J 做一下對比。相比較于 Log4J 的 API,SLF4J 有以下幾點優勢:

  • Log4j 提供 TRACE、DEBUG、INFO、WARN、ERROR 及 FATAL 六種紀錄等級,但是 SLF4J 認為 ERROR 與 FATAL 并沒有實質上的差别,是以拿掉了 FATAL 等級,隻剩下其他五種。
  • 大部分人在程式裡面會去寫 logger.error(exception),其實這個時候 Log4j 會去把這個exception tostring。真正的寫法應該是logger(message.exception);而 SLF4J 就不會使得程式員犯這個錯誤。
  • Log4j 間接的在鼓勵程式員使用 string 相加的寫法(這種寫法是有性能問題的),而 SLF4J 就不會有這個問題,你可以使用 logger.error("{} is+serviceid",serviceid);
  • 使用 SLF4J 可以友善的使用其提供的各種集體的實作的 jar。(類似commons-logger)
  • 從 commons--logger 和 Log4j merge 非常友善,SLF4J 也提供了一個 swing 的 tools 來幫助大家完成這個 merge。
  • 提供字串内容替換的功能,會比較有效率,說明如下:
// 傳統的字元串産生方式,如果沒有要記錄Debug等級的資訊,就會浪費時間在産生不必要的資訊上
        logger.debug("There are now " + count + " user accounts: " + userAccountList);

        // 為了避免上述問題,我們可以先檢查是不是開啟了Debug資訊記錄功能,隻是程式的編碼會比較複雜
        if (logger.isDebugEnabled()) {
            logger.debug("There are now " + count + " user accounts: " + userAccountList);
        }

        // 如果Debug等級沒有開啟,則不會産生不必要的字元串,同時也能保持程式編碼的簡潔
        logger.debug("There are now {} user accounts: {}", count, userAccountList); 
           
  • SLF4J 隻支援 MDC,不支援 NDC。

commons-logging

解讀《阿裡巴巴 Java 開發手冊》背後的思考

Apache Commons Logging 是一個基于 Java 的日志記錄實用程式,是用于日志記錄和其他工具包的程式設計模型。它通過其他一些工具提供 API,日志實作和包裝器實作。

commons-logging 和 SLF4J 的功能是類似的,主要是用來做日志門面的。提供更加好友的 API 工具。

小結

在 Java 生态體系中,圍繞着日志,有很多成熟的解決方案。關于日志輸出,主要有兩類工具。

一類是日志架構,主要用來進行日志的輸出的,比如輸出到哪個檔案,日志格式如何等。 另外一類是日志門面,主要一套通用的 API,用來屏蔽各個日志架構之間的差異的。

是以,對于 Java 工程師來說,關于日志工具的使用,最佳實踐就是在應用中使用如 Log4j + SLF4J 這樣的組合來進行日志輸出。

這樣做的最大好處,就是業務層的開發不需要關心底層日志架構的實作及細節,在編碼的時候也不需要考慮日後更換架構所帶來的成本。這也是門面模式所帶來的好處。

綜上,請不要在你的 Java 代碼中出現任何 Log4j 等日志架構的 API 的使用,而是應該直接使用 SLF4J 這種日志門面。

為什麼禁止開發人員使用 isSuccess 作為變量名

解讀《阿裡巴巴 Java 開發手冊》背後的思考

在日常開發中,我們會經常要在類中定義布爾類型的變量,比如在給外部系統提供一個 RPC 接口的時候,我們一般會定義一個字段表示本次請求是否成功的。

關于這個"本次請求是否成功"的字段的定義,其實是有很多種講究和坑的,稍有不慎就會掉入坑裡,作者在很久之前就遇到過類似的問題,本文就來圍繞這個簡單分析一下。到底該如何定一個布爾類型的成員變量。

一般情況下,我們可以有以下四種方式來定義一個布爾類型的成員變量:

boolean success
boolean isSuccess
Boolean success
Boolean isSuccess
           

以上四種定義形式,你日常開發中最常用的是哪種呢?到底哪一種才是正确的使用姿勢呢?

通過觀察我們可以發現,前兩種和後兩種的主要差別是變量的類型不同,前者使用的是 boolean,後者使用的是 Boolean。

另外,第一種和第三種在定義變量的時候,變量命名是 success,而另外兩種使用 isSuccess 來命名的。

首先,我們來分析一下,到底應該是用 success 來命名,還是使用 isSuccess 更好一點。

success 還是 isSuccess

到底應該是用 success 還是 isSuccess 來給變量命名呢?從語義上面來講,兩種命名方式都可以講的通,并且也都沒有歧義。那麼還有什麼原則可以參考來讓我們做選擇呢。

在阿裡巴巴 Java 開發手冊中關于這一點,有過一個『強制性』規定:

解讀《阿裡巴巴 Java 開發手冊》背後的思考

那麼,為什麼會有這樣的規定呢?我們看一下 POJO 中布爾類型變量不同的命名有什麼差別吧。

class Model1  {
        private Boolean isSuccess;
        public void setSuccess(Boolean success) {
            isSuccess = success;
        }
        public Boolean getSuccess() {
            return isSuccess;
        }
     }

    class Model2 {
        private Boolean success;
        public Boolean getSuccess() {
            return success;
        }
        public void setSuccess(Boolean success) {
            this.success = success;
        }
    }

    class Model3 {
        private boolean isSuccess;
        public boolean isSuccess() {
            return isSuccess;
        }
        public void setSuccess(boolean success) {
            isSuccess = success;
        }
    }

    class Model4 {
        private boolean success;
        public boolean isSuccess() {
            return success;
        }
        public void setSuccess(boolean success) {
            this.success = success;
        }
    }
           

以上代碼的 setter/getter 是使用 Intellij IDEA 自動生成的,仔細觀察以上代碼,你會發現以下規律:

  • 基本類型自動生成的 getter 和 setter 方法,名稱都是

    isXXX()

    setXXX()

    形式的。
  • 包裝類型自動生成的 getter 和 setter 方法,名稱都是

    getXXX()

    setXXX()

    形式的。

既然,我們已經達成一緻共識使用基本類型 boolean 來定義成員變量了,那麼我們再來具體看下 Model3 和 Model4 中的 setter/getter 有何差別。

我們可以發現,雖然 Model3 和 Model4 中的成員變量的名稱不同,一個是 success,另外一個是 isSuccess,但是他們自動生成的 getter 和 setter 方法名稱都是

isSuccess

setSuccess

Java Bean 中關于 setter/getter 的規範

關于 Java Bean 中的 getter/setter 方法的定義其實是有明确的規定的,根據JavaBeans(TM) Specification規定,如果是普通的參數 propertyName,要以以下方式定義其 setter/getter:

public <PropertyType> get<PropertyName>();
public void set<PropertyName>(<PropertyType> a);
           

但是,布爾類型的變量 propertyName 則是單獨定義的:

public boolean is<PropertyName>();
public void set<PropertyName>(boolean m);
           
解讀《阿裡巴巴 Java 開發手冊》背後的思考

通過對照這份 JavaBeans 規範,我們發現,在 Model4 中,變量名為 isSuccess,如果嚴格按照規範定義的話,他的 getter 方法應該叫 isIsSuccess。但是很多 IDE 都會預設生成為 isSuccess。

那這樣做會帶來什麼問題呢。

在一般情況下,其實是沒有影響的。但是有一種特殊情況就會有問題,那就是發生序列化的時候。

序列化帶來的影響

關于序列化和反序列化請參考Java 對象的序列化與反序列化。我們這裡拿比較常用的 JSON 序列化來舉例,看看看常用的 fastJson、jackson和 Gson 之間有何差別:

public class BooleanMainTest {

        public static void main(String[] args) throws IOException {
            //定一個Model3類型
            Model3 model3 = new Model3();
            model3.setSuccess(true);

            //使用fastjson(1.2.16)序列化model3成字元串并輸出
            System.out.println("Serializable Result With fastjson :" + JSON.toJSONString(model3));

            //使用Gson(2.8.5)序列化model3成字元串并輸出
            Gson gson =new Gson();
            System.out.println("Serializable Result With Gson :" +gson.toJson(model3));

            //使用jackson(2.9.7)序列化model3成字元串并輸出
            ObjectMapper om = new ObjectMapper();
            System.out.println("Serializable Result With jackson :" +om.writeValueAsString(model3));
        }

    }

    class Model3 implements Serializable {

        private static final long serialVersionUID = 1836697963736227954L;
        private boolean isSuccess;
        public boolean isSuccess() {
            return isSuccess;
        }
        public void setSuccess(boolean success) {
            isSuccess = success;
        }
        public String getHollis(){
            return "hollischuang";
        }
    }
           

以上代碼的 Model3 中,隻有一個成員變量即 isSuccess,三個方法,分别是 IDE 幫我們自動生成的 isSuccess 和 setSuccess,另外一個是作者自己增加的一個符合 getter 命名規範的方法。

以上代碼輸出結果:

Serializable Result With fastjson :{"hollis":"hollischuang","success":true}
    Serializable Result With Gson :{"isSuccess":true}
    Serializable Result With jackson :{"success":true,"hollis":"hollischuang"}
           

在 fastjson 和 Jackson 的結果中,原來類中的 isSuccess 字段被序列化成 success,并且其中還包含 hollis 值。而 Gson 中隻有 isSuccess 字段。

我們可以得出結論:fastjson 和 Jackson 在把對象序列化成 json 字元串的時候,是通過反射周遊出該類中的所有 getter 方法,得到 getHollis 和 isSuccess,然後根據 JavaBeans 規則,他會認為這是兩個屬性 hollis 和 success 的值。直接序列化成 json:{"hollis":"hollischuang","success":true}

但是 Gson 并不是這麼做的,他是通過反射周遊該類中的所有屬性,并把其值序列化成 json:{"isSuccess":true}。

可以看到,由于不同的序列化工具,在進行序列化的時候使用到的政策是不一樣的,是以,對于同一個類的同一個對象的序列化結果可能是不同的。

前面提到的關于對 getHollis 的序列化隻是為了說明 fastjson、jackson 和 Gson 之間的序列化政策的不同,我們暫且把他放到一邊,我們把他從 Model3 中删除後,重新執行下以上代碼,得到結果:

Serializable Result With fastjson :{"success":true}
Serializable Result With Gson :{"isSuccess":true}
Serializable Result With jackson :{"success":true}
           

現在,不同的序列化架構得到的 json 内容并不相同,如果對于同一個對象,我使用 fastjson 進行序列化,再使用 Gson 反序列化會發生什麼?

public class BooleanMainTest {
        public static void main(String[] args) throws IOException {
            Model3 model3 = new Model3();
            model3.setSuccess(true);
            Gson gson =new Gson();
            System.out.println(gson.fromJson(JSON.toJSONString(model3),Model3.class));
        }
    }


    class Model3 implements Serializable {
        private static final long serialVersionUID = 1836697963736227954L;
        private boolean isSuccess;
        public boolean isSuccess() {
            return isSuccess;
        }
        public void setSuccess(boolean success) {
            isSuccess = success;
        }
        @Override
        public String toString() {
            return new StringJoiner(", ", Model3.class.getSimpleName() + "[", "]")
                .add("isSuccess=" + isSuccess)
                .toString();
        }
    }
           

以上代碼,輸出結果:

Model3[isSuccess=false]
           

這和我們預期的結果完全相反,原因是因為 JSON 架構通過掃描所有的getter後發現有一個 isSuccess 方法,然後根據 JavaBeans 的規範,解析出變量名為 success,把 model 對象序列化城字元串後内容為

{"success":true}

根據

{"success":true}

這個 json 串,Gson 架構在通過解析後,通過反射尋找 Model 類中的 success 屬性,但是 Model 類中隻有 isSuccess 屬性,是以,最終反序列化後的 Model 類的對象中,isSuccess 則會使用預設值 false。

但是,一旦以上代碼發生在生産環境,這絕對是一個緻命的問題。

是以,作為開發者,我們應該想辦法盡量避免這種問題的發生,對于 POJO 的設計者來說,隻需要做簡單的一件事就可以解決這個問題了,那就是把 isSuccess 改為 success。這樣,該類裡面的成員變量時 success, getter 方法是 isSuccess,這是完全符合 JavaBeans 規範的。無論哪種序列化架構,執行結果都一樣。就從源頭避免了這個問題。

引用以下 R 大關于阿裡巴巴 Java 開發手冊這條規定的評價(https://www.zhihu.com/question/55642203):

解讀《阿裡巴巴 Java 開發手冊》背後的思考

是以,在定義 POJO 中的布爾類型的變量時,不要使用 isSuccess 這種形式,而要直接使用 success!

Boolean 還是 boolean?

前面我們介紹完了在 success 和 isSuccess 之間如何選擇,那麼排除錯誤答案後,備選項還剩下:

boolean success
Boolean success
           

那麼,到底應該是用 Boolean 還是 boolean 來給定一個布爾類型的變量呢?

我們知道,boolean 是基本資料類型,而 Boolean 是包裝類型。關于基本資料類型和包裝類之間的關系和差別請參考一文讀懂什麼是Java中的自動拆裝箱

那麼,在定義一個成員變量的時候到底是使用包裝類型更好還是使用基本資料類型呢?

我們來看一段簡單的代碼

/**
     * @author Hollis
     */
    public class BooleanMainTest {
        public static void main(String[] args) {
            Model model1 = new Model();
            System.out.println("default model : " + model1);
        }
    }

    class Model {
        /**
         * 定一個Boolean類型的success成員變量
         */
        private Boolean success;
        /**
         * 定一個boolean類型的failure成員變量
         */
        private boolean failure;

        /**
         * 覆寫toString方法,使用Java 8 的StringJoiner
         */
        @Override
        public String toString() {
            return new StringJoiner(", ", Model.class.getSimpleName() + "[", "]")
                .add("success=" + success)
                .add("failure=" + failure)
                .toString();
        }
    }
           

以上代碼輸出結果為:

default model : Model[success=null, failure=false]
           

可以看到,當我們沒有設定 Model 對象的字段的值的時候,Boolean 類型的變量會設定預設值為

null

,而 boolean 類型的變量會設定預設值為

false

即對象的預設值是

null

,boolean 基本資料類型的預設值是

false

在阿裡巴巴 Java 開發手冊中,對于 POJO 中如何選擇變量的類型也有着一些規定:

解讀《阿裡巴巴 Java 開發手冊》背後的思考

這裡建議我們使用包裝類型,原因是什麼呢?

舉一個扣費的例子,我們做一個扣費系統,扣費時需要從外部的定價系統中讀取一個費率的值,我們預期該接口的傳回值中會包含一個浮點型的費率字段。當我們取到這個值得時候就使用公式:金額*費率=費用 進行計算,計算結果進行劃扣。

如果由于計費系統異常,他可能會傳回個預設值,如果這個字段是 Double 類型的話,該預設值為 null,如果該字段是 double 類型的話,該預設值為 0.0。

如果扣費系統對于該費率傳回值沒做特殊處理的話,拿到 null 值進行計算會直接報錯,阻斷程式。拿到 0.0 可能就直接進行計算,得出接口為 0 後進行扣費了。這種異常情況就無法被感覺。

這種使用包裝類型定義變量的方式,通過異常來阻斷程式,進而可以被識别到這種線上問題。如果使用基本資料類型的話,系統可能不會報錯,進而認為無異常。

以上,就是建議在 POJO 和 RPC 的傳回值中使用包裝類型的原因。

但是關于這一點,作者之前也有過不同的看法:對于布爾類型的變量,我認為可以和其他類型區分開來,作者并不認為使用 null 進而導緻 NPE 是一種最好的實踐。因為布爾類型隻有 true/false 兩種值,我們完全可以和外部調用方約定好當傳回值為 false 時的明确語義。

後來,作者單獨和《阿裡巴巴 Java 開發手冊》、《碼出高效》的作者——孤盡 單獨 1V1(qing) Battle(jiao)了一下。最終達成共識,還是盡量使用包裝類型。

但是,作者還是想強調一個我的觀點,盡量避免在你的代碼中出現不确定的 null 值。

null 何罪之有?

關于 null 值的使用,我在使用 Optional 避免NullPointerException、9 Things about Null in Java等文中就介紹過。

null

是很模棱兩可的,很多時候會導緻令人疑惑的的錯誤,很難去判斷傳回一個

null

代表着什麼意思。

圖靈獎得主 Tony Hoare 曾經公開表達過

null

是一個糟糕的設計。

解讀《阿裡巴巴 Java 開發手冊》背後的思考

我把 null 引用稱為自己的十億美元錯誤。它的發明是在 1965 年,那時我用一個面向對象語言( ALGOL W )設計了第一個全面的引用類型系統。我的目的是確定所有引用的使用都是絕對安全的,編譯器會自動進行檢查。但是我未能抵禦住誘惑,加入了 Null 引用,僅僅是因為實作起來非常容易。它導緻了數不清的錯誤、漏洞和系統崩潰,可能在之後 40 年中造成了十億美元的損失。

當我們在設計一個接口的時候,對于接口的傳回值的定義,盡量避免使用 Boolean 類型來定義。大多數情況下,别人使用我們的接口傳回值時可能用

if(response.isSuccess){}else{}

的方式,如果我們由于忽略沒有設定

success

字段的值,就可能導緻 NPE(java.lang.NullPointerException),這明顯是我們不希望看到的。

是以,當我們要定義一個布爾類型的成員變量時,盡量選擇 boolean,而不是 Boolean。當然,程式設計中并沒有絕對。

小結

本文圍繞布爾類型的變量定義的類型和命名展開了介紹,最終我們可以得出結論,在定義一個布爾類型的變量,尤其是一個給外部提供的接口傳回值時,要使用 success 來命名,阿裡巴巴 Java 開發手冊建議使用封裝類來定義 POJO 和 RPC 傳回值中的變量。但是這不意味着可以随意的使用 null,我們還是要盡量避免出現對 null 的處理的。

為什麼禁止開發人員修改 serialVersionUID 字段的值

解讀《阿裡巴巴 Java 開發手冊》背後的思考

序列化是一種對象持久化的手段。普遍應用在網絡傳輸、RMI 等場景中。類通過實作 

java.io.Serializable

 接口以啟用其序列化功能。

在我的部落格中,其實已經有多篇文章介紹過序列化了,對序列化的基礎知識不夠了解的朋友可以參考以下幾篇文章:

Java 對象的序列化與反序列化 深入分析 Java 的序列化與反序列化 單例與序列化的那些事兒

在這幾篇文章中,我分别介紹過了序列化涉及到的類和接口、如何自定義序列化政策、transient 關鍵字和序列化的關系等,還通過學習 ArrayList 對序列化的實作源碼深入學習了序列化。并且還拓展分析了一下序列化對單例的影響等。

但是,還有一個知識點并未展開介紹,那就是關于

serialVersionUID

 。這個字段到底有什麼用?如果不設定會怎麼樣?為什麼《阿裡巴巴 Java 開發手冊》中有以下規定:

解讀《阿裡巴巴 Java 開發手冊》背後的思考

背景知識

Serializable 和 Externalizable

類通過實作 

java.io.Serializable

 接口以啟用其序列化功能。未實作此接口的類将無法進行序列化或反序列化。可序列化類的所有子類型本身都是可序列化的。

如果讀者看過

Serializable

的源碼,就會發現,他隻是一個空的接口,裡面什麼東西都沒有。Serializable 接口沒有方法或字段,僅用于辨別可序列化的語義。但是,如果一個類沒有實作這個接口,想要被序列化的話,就會抛出

java.io.NotSerializableException

異常。

它是怎麼保證隻有實作了該接口的方法才能進行序列化與反序列化的呢?

原因是在執行序列化的過程中,會執行到以下代碼:

if (obj instanceof String) {
        writeString((String) obj, unshared);
    } else if (cl.isArray()) {
        writeArray(obj, desc, unshared);
    } else if (obj instanceof Enum) {
        writeEnum((Enum<?>) obj, desc, unshared);
    } else if (obj instanceof Serializable) {
        writeOrdinaryObject(obj, desc, unshared);
    } else {
        if (extendedDebugInfo) {
            throw new NotSerializableException(
                cl.getName() + "\n" + debugInfoStack.toString());
        } else {
            throw new NotSerializableException(cl.getName());
        }
    }
           

在進行序列化操作時,會判斷要被序列化的類是否是

Enum

Array

Serializable

類型,如果都不是則直接抛出

NotSerializableException

Java中還提供了

Externalizable

接口,也可以實作它來提供序列化能力。

Externalizable

繼承自

Serializable

,該接口中定義了兩個抽象方法:

writeExternal()

readExternal()

當使用

Externalizable

接口來進行序列化與反序列化的時候需要開發人員重寫

writeExternal()

readExternal()

方法。否則所有變量的值都會變成預設值。

transient

transient

 關鍵字的作用是控制變量的序列化,在變量聲明前加上該關鍵字,可以阻止該變量被序列化到檔案中,在被反序列化後,

transient

 變量的值被設為初始值,如 int 型的是 0,對象型的是 null。

自定義序列化政策

在序列化過程中,如果被序列化的類中定義了

writeObject

 和 

readObject

 方法,虛拟機會試圖調用對象類裡的 

writeObject

 和 

readObject

 方法,進行使用者自定義的序列化和反序列化。

如果沒有這樣的方法,則預設調用是 

ObjectOutputStream

 的 

defaultWriteObject

 方法以及 

ObjectInputStream

 的 

defaultReadObject

 方法。

使用者自定義的 

writeObject

 和 

readObject

 方法可以允許使用者控制序列化的過程,比如可以在序列化的過程中動态改變序列化的數值。

是以,對于一些特殊字段需要定義序列化的政策的時候,可以考慮使用transient修飾,并自己重寫

writeObject

 和 

readObject

 方法,如

java.util.ArrayList

中就有這樣的實作。

我們随便找幾個Java中實作了序列化接口的類,如String、Integer等,我們可以發現一個細節,那就是這些類除了實作了

Serializable

外,還定義了一個

serialVersionUID

解讀《阿裡巴巴 Java 開發手冊》背後的思考

那麼,到底什麼是

serialVersionUID

呢?為什麼要設定這樣一個字段呢?

什麼是 serialVersionUID

序列化是将對象的狀态資訊轉換為可存儲或傳輸的形式的過程。我們都知道,Java 對象是儲存在 JVM 的堆記憶體中的,也就是說,如果 JVM 堆不存在了,那麼對象也就跟着消失了。

而序列化提供了一種方案,可以讓你在即使 JVM 停機的情況下也能把對象儲存下來的方案。就像我們平時用的 U 盤一樣。把 Java 對象序列化成可存儲或傳輸的形式(如二進制流),比如儲存在檔案中。這樣,當再次需要這個對象的時候,從檔案中讀取出二進制流,再從二進制流中反序列化出對象。

虛拟機是否允許反序列化,不僅取決于類路徑和功能代碼是否一緻,一個非常重要的一點是兩個類的序列化 ID 是否一緻,這個所謂的序列化 ID,就是我們在代碼中定義的

serialVersionUID

如果 serialVersionUID 變了會怎樣

我們舉個例子吧,看看如果

serialVersionUID

被修改了會發生什麼?

public class SerializableDemo1 {
        public static void main(String[] args) {
            //Initializes The Object
            User1 user = new User1();
            user.setName("hollis");
            //Write Obj to File
            ObjectOutputStream oos = null;
            try {
                oos = new ObjectOutputStream(new FileOutputStream("tempFile"));
                oos.writeObject(user);
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                IOUtils.closeQuietly(oos);
            }
        }
    }

    class User1 implements Serializable {
        private static final long serialVersionUID = 1L;
        private String name;
        public String getName() {
            return name;
        }
        public void setName(String name) {
            this.name = name;
        }
     }
           

我們先執行以上代碼,把一個 User1 對象寫入到檔案中。然後我們修改一下 User1 類,把

serialVersionUID

的值改為

2L

class User1 implements Serializable {
        private static final long serialVersionUID = 2L;
        private String name;
        public String getName() {
            return name;
        }
        public void setName(String name) {
            this.name = name;
        }
    }
           

然後執行以下代碼,把檔案中的對象反序列化出來:

public class SerializableDemo2 {
        public static void main(String[] args) {
            //Read Obj from File
            File file = new File("tempFile");
            ObjectInputStream ois = null;
            try {
                ois = new ObjectInputStream(new FileInputStream(file));
                User1 newUser = (User1) ois.readObject();
                System.out.println(newUser);
            } catch (IOException e) {
                e.printStackTrace();
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            } finally {
                IOUtils.closeQuietly(ois);
                try {
                    FileUtils.forceDelete(file);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
           

執行結果如下:

java.io.InvalidClassException: com.hollis.User1; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2
           

可以發現,以上代碼抛出了一個

java.io.InvalidClassException

,并且指出

serialVersionUID

不一緻。

這是因為,在進行反序列化時,JVM 會把傳來的位元組流中的

serialVersionUID

與本地相應實體類的

serialVersionUID

進行比較,如果相同就認為是一緻的,可以進行反序列化,否則就會出現序列化版本不一緻的異常,即是

InvalidCastException

這也是《阿裡巴巴 Java 開發手冊》中規定,在相容性更新中,在修改類的時候,不要修改

serialVersionUID

的原因。除非是完全不相容的兩個版本。是以,

serialVersionUID

其實是驗證版本一緻性的。

如果讀者感興趣,可以把各個版本的 JDK 代碼都拿出來看一下,那些向下相容的類的

serialVersionUID

是沒有變化過的。比如 String 類的

serialVersionUID

一直都是

-6849794470754667710L

但是,作者認為,這個規範其實還可以再嚴格一些,那就是規定:

如果一個類實作了

Serializable

接口,就必須手動添加一個

private static final long serialVersionUID

變量,并且設定初始值。

為什麼要明确定一個 serialVersionUID

如果我們沒有在類中明确的定義一個

serialVersionUID

的話,看看會發生什麼。

嘗試修改上面的 demo 代碼,先使用以下類定義一個對象,該類中不定義

serialVersionUID

,将其寫入檔案。

class User1 implements Serializable {
        private String name;
        public String getName() {
            return name;
        }
        public void setName(String name) {
            this.name = name;
        }
     }
           

然後我們修改 User1 類,向其中增加一個屬性。在嘗試将其從檔案中讀取出來,并進行反序列化。

class User1 implements Serializable {
        private String name;
        private int age;
        public String getName() {
            return name;
        }
        public void setName(String name) {
            this.name = name;
        }
        public int getAge() {
            return age;
        }
        public void setAge(int age) {
            this.age = age;
        }
     }
           

執行結果: 

java.io.InvalidClassException: com.hollis.User1; local class incompatible: stream classdesc serialVersionUID = -2986778152837257883, local class serialVersionUID = 7961728318907695402

同樣,抛出了

InvalidClassException

,并且指出兩個

serialVersionUID

不同,分别是

-2986778152837257883

7961728318907695402

從這裡可以看出,系統自己添加了一個

serialVersionUID

是以,一旦類實作了

Serializable

,就建議明确的定義一個

serialVersionUID

。不然在修改類的時候,就會發生異常。

serialVersionUID

有兩種顯示的生成方式:

一是預設的 1L,比如:

private static final long serialVersionUID = 1L;

二是根據類名、接口名、成員方法及屬性等來生成一個64位的哈希字段,比如:

private static final long serialVersionUID = xxxxL;

後面這種方式,可以借助 IDE 生成,後面會介紹。

背後原理

知其然,要知其是以然,我們再來看看源碼,分析一下為什麼

serialVersionUID

改變的時候會抛異常?在沒有明确定義的情況下,預設的

serialVersionUID

是怎麼來的?

為了簡化代碼量,反序列化的調用鍊如下:

ObjectInputStream.readObject -> readObject0 -> readOrdinaryObject -> readClassDesc -> readNonProxyDesc -> ObjectStreamClass.initNonProxy

initNonProxy

中 ,關鍵代碼如下:

解讀《阿裡巴巴 Java 開發手冊》背後的思考

在反序列化過程中,對

serialVersionUID

做了比較,如果發現不相等,則直接抛出異常。

深入看一下

getSerialVersionUID

方法:

public long getSerialVersionUID() {
        // REMIND: synchronize instead of relying on volatile?
        if (suid == null) {
            suid = AccessController.doPrivileged(
                new PrivilegedAction<Long>() {
                    public Long run() {
                        return computeDefaultSUID(cl);
                    }
                }
            );
        }
        return suid.longValue();
    }
           

在沒有定義

serialVersionUID

的時候,會調用

computeDefaultSUID

 方法,生成一個預設的

serialVersionUID

這也就找到了以上兩個問題的根源,其實是代碼中做了嚴格的校驗。

IDEA 提示

為了確定我們不會忘記定義

serialVersionUID

,可以調節一下 Intellij IDEA 的配置,在實作

Serializable

接口後,如果沒定義

serialVersionUID

的話,IDEA(eclipse 一樣)會進行提示: 

解讀《阿裡巴巴 Java 開發手冊》背後的思考

并且可以一鍵生成一個:

解讀《阿裡巴巴 Java 開發手冊》背後的思考

當然,這個配置并不是預設生效的,需要手動到 IDEA 中設定一下:

解讀《阿裡巴巴 Java 開發手冊》背後的思考

在圖中标号 3 的地方(Serializable class without serialVersionUID的配置),打上勾,儲存即可。

小結

serialVersionUID

是用來驗證版本一緻性的。是以在做相容性更新的時候,不要改變類中

serialVersionUID

的值。

如果一個類實作了 Serializable 接口,一定要記得定義

serialVersionUID

,否則會發生異常。可以在 IDE 中通過設定,讓他幫忙提示,并且可以一鍵快速生成一個

serialVersionUID

之是以會發生異常,是因為反序列化過程中做了校驗,并且如果沒有明确定義的話,會根據類的屬性自動生成一個。

為什麼不建議在 for 循環中使用"+"進行字元串拼接

解讀《阿裡巴巴 Java 開發手冊》背後的思考

字元串,是 Java 中最常用的一個資料類型了。關于字元串的知識,作者已經發表過幾篇文章介紹過很多,如:

Java 7 源碼學習系列(一)——String

該如何建立字元串,使用” “還是構造函數?

我終于搞清楚了和String有關的那點事兒

三張圖徹底了解Java中字元串的不變性

為什麼Java要把字元串設計成不可變的

三張圖徹底了解JDK 6和JDK 7中substring的原理及差別

Java中的Switch對整型、字元型、字元串型的具體實作細節

本文,也是對于 Java 中字元串相關知識的一個補充,主要來介紹一下字元串拼接相關的知識。本文基于 jdk1.8.0_181。

字元串拼接

字元串拼接是我們在 Java 代碼中比較經常要做的事情,就是把多個字元串拼接到一起。

我們都知道,String 是 Java 中一個不可變的類,是以他一旦被執行個體化就無法被修改。

不可變類的執行個體一旦建立,其成員變量的值就不能被修改。這樣設計有很多好處,比如可以緩存 hashcode、使用更加便利以及更加安全等。

但是,既然字元串是不可變的,那麼字元串拼接又是怎麼回事呢?

字元串不變性與字元串拼接

其實,所有的所謂字元串拼接,都是重新生成了一個新的字元串。下面一段字元串拼接代碼:

<pre><code class="language-text">String s = "abcd";
s = s.concat("ef");
</code></pre>
           

其實最後我們得到的s已經是一個新的字元串了。如下圖

解讀《阿裡巴巴 Java 開發手冊》背後的思考

s 中儲存的是一個重新建立出來的 String 對象的引用。

那麼,在 Java 中到底如何進行字元串拼接呢?字元串拼接有很多種方式,這裡簡單介紹幾種比較常用的。

使用

+

拼接字元串

在 Java 中,拼接字元串最簡單的方式就是直接使用符号

+

來拼接。如:

String wechat = "Hollis";
String introduce = "每日更新Java相關技術文章";
String hollis = wechat + "," + introduce;
           

這裡要特别說明一點,有人把 Java 中使用

+

拼接字元串的功能了解為運算符重載。其實并不是,Java 是不支援運算符重載的。這其實隻是 Java 提供的一個文法糖。後面再詳細介紹。

運算符重載:在計算機程式設計中,運算符重載(英語:operator overloading)是多态的一種。運算符重載,就是對已有的運算符重新進行定義,賦予其另一種功能,以适應不同的資料類型。

文法糖:文法糖(Syntactic sugar),也譯為糖衣文法,是由英國計算機科學家彼得·蘭丁發明的一個術語,指計算機語言中添加的某種文法,這種文法對語言的功能沒有影響,但是更友善程式員使用。文法糖讓程式更加簡潔,有更高的可讀性。

concat

除了使用

+

拼接字元串之外,還可以使用 String 類中的方法 concat 方法來拼接字元串。如:

String wechat = "Hollis";
String introduce = "每日更新Java相關技術文章";
String hollis = wechat.concat(",").concat(introduce);
           

StringBuffer

關于字元串,Java 中除了定義了一個可以用來定義字元串常量的

String

類以外,還提供了可以用來定義字元串變量的

StringBuffer

類,它的對象是可以擴充和修改的。

使用

StringBuffer

可以友善的對字元串進行拼接。如:

StringBuffer wechat = new StringBuffer("Hollis");
String introduce = "每日更新Java相關技術文章";
StringBuffer hollis = wechat.append(",").append(introduce);
           

StringBuilder

除了

StringBuffer

以外,還有一個類

StringBuilder

也可以使用,其用法和

StringBuffer

類似。如:

StringBuilder wechat = new StringBuilder("Hollis");
String introduce = "每日更新Java相關技術文章";
StringBuilder hollis = wechat.append(",").append(introduce);
           

StringUtils.join

除了 JDK 中内置的字元串拼接方法,還可以使用一些開源類庫中提供的字元串拼接方法名,如

apache.commons中

提供的

StringUtils

類,其中的

join

方法可以拼接字元串。

String wechat = "Hollis";
String introduce = "每日更新Java相關技術文章";
System.out.println(StringUtils.join(wechat, ",", introduce));
           

這裡簡單說一下,StringUtils 中提供的 join 方法,最主要的功能是:将數組或集合以某拼接符拼接到一起形成新的字元串,如:

String []list  ={"Hollis","每日更新Java相關技術文章"};
String result= StringUtils.join(list,",");
System.out.println(result);
//結果:Hollis,每日更新Java相關技術文章
           

并且,Java8 中的 String 類中也提供了一個靜态的 join 方法,用法和 StringUtils.join 類似。

以上就是比較常用的五種在 Java 種拼接字元串的方式,那麼到底哪種更好用呢?為什麼阿裡巴巴 Java 開發手冊中不建議在循環體中使用

+

進行字元串拼接呢?

解讀《阿裡巴巴 Java 開發手冊》背後的思考

(阿裡巴巴 Java 開發手冊中關于字元串拼接的規約)

使用

+

拼接字元串的實作原理

前面提到過,使用

+

拼接字元串,其實隻是 Java 提供的一個文法糖, 那麼,我們就來解一解這個文法糖,看看他的内部原理到底是如何實作的。

還是這樣一段代碼。我們把他生成的位元組碼進行反編譯,看看結果。

String wechat = "Hollis";
String introduce = "每日更新Java相關技術文章";
String hollis = wechat + "," + introduce;
           

反編譯後的内容如下,反編譯工具為jad。

String wechat = "Hollis";
String introduce = "\u6BCF\u65E5\u66F4\u65B0Java\u76F8\u5173\u6280\u672F\u6587\u7AE0";//每日更新Java相關技術文章
String hollis = (new StringBuilder()).append(wechat).append(",").append(introduce).toString();
           

通過檢視反編譯以後的代碼,我們可以發現,原來字元串常量在拼接過程中,是将 String 轉成了 StringBuilder 後,使用其 append 方法進行處理的。

那麼也就是說,Java 中的

+

對字元串的拼接,其實作原理是使用

StringBuilder.append

concat 是如何實作的

我們再來看一下 concat 方法的源代碼,看一下這個方法又是如何實作的。

public String concat(String str) {
    int otherLen = str.length();
    if (otherLen == 0) {
        return this;
    }
    int len = value.length;
    char buf[] = Arrays.copyOf(value, len + otherLen);
    str.getChars(buf, len);
    return new String(buf, true);
}
           

這段代碼首先建立了一個字元數組,長度是已有字元串和待拼接字元串的長度之和,再把兩個字元串的值複制到新的字元數組中,并使用這個字元數組建立一個新的 String 對象并傳回。

通過源碼我們也可以看到,經過 concat 方法,其實是 new 了一個新的 String,這也就呼應到前面我們說的字元串的不變性問題上了。

StringBuffer 和 StringBuilder

接下來我們看看

StringBuffer

StringBuilder

的實作原理。

String

類類似,

StringBuilder

類也封裝了一個字元數組,定義如下:

char[] value;
           

String

不同的是,它并不是

final

的,是以他是可以修改的。另外,與

String

不同,字元數組中不一定所有位置都已經被使用,它有一個執行個體變量,表示數組中已經使用的字元個數,定義如下:

int count;
           

其 append 源碼如下:

public StringBuilder append(String str) {
    super.append(str);
    return this;
}
           

該類繼承了

AbstractStringBuilder

類,看下其

append

方法:

public AbstractStringBuilder append(String str) {
    if (str == null)
        return appendNull();
    int len = str.length();
    ensureCapacityInternal(count + len);
    str.getChars(0, len, value, count);
    count += len;
    return this;
}
           

append 會直接拷貝字元到内部的字元數組中,如果字元數組長度不夠,會進行擴充。

StringBuffer

StringBuilder

類似,最大的差別就是

StringBuffer

是線程安全的,看一下

StringBuffer

append

方法。

public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}
           

該方法使用

synchronized

進行聲明,說明是一個線程安全的方法。而

StringBuilder

則不是線程安全的。

StringUtils.join 是如何實作的

通過檢視

StringUtils.join

的源代碼,我們可以發現,其實他也是通過

StringBuilder

來實作的。

public static String join(final Object[] array, String separator, final int startIndex, final int endIndex) {
    if (array == null) {
        return null;
    }
    if (separator == null) {
        separator = EMPTY;
    }

    // endIndex - startIndex &gt; 0:   Len = NofStrings *(len(firstString) + len(separator))
    //           (Assuming that all Strings are roughly equally long)
    final int noOfItems = endIndex - startIndex;
    if (noOfItems &lt;= 0) {
        return EMPTY;
    }

    final StringBuilder buf = new StringBuilder(noOfItems * 16);

    for (int i = startIndex; i &lt; endIndex; i++) {
        if (i &gt; startIndex) {
            buf.append(separator);
        }
        if (array[i] != null) {
            buf.append(array[i]);
        }
    }
    return buf.toString();
}
           

效率比較

既然有這麼多種字元串拼接的方法,那麼到底哪一種效率最高呢?我們來簡單對比一下。

long t1 = System.currentTimeMillis();
//這裡是初始字元串定義
for (int i = 0; i &lt; 50000; i++) {
    //這裡是字元串拼接代碼
}
long t2 = System.currentTimeMillis();
System.out.println("cost:" + (t2 - t1));
           

我們使用形如以上形式的代碼,分别測試下五種字元串拼接代碼的運作時間。得到結果如下:

+ cost:5119
StringBuilder cost:3
StringBuffer cost:4
concat cost:3623
StringUtils.join cost:25726
           

從結果可以看出,用時從短到長的對比是:

StringBuilder

<

StringBuffer

<

concat

<

+

<

StringUtils.join

StringBuffer

StringBuilder

的基礎上,做了同步處理,是以在耗時上會相對多一些。

StringUtils.join 也是使用了 StringBuilder,并且其中還是有很多其他操作,是以耗時較長,這個也容易了解。其實 StringUtils.join 更擅長處理字元串數組或者清單的拼接。

那麼問題來了,前面我們分析過,其實使用

+

拼接字元串的實作原理也是使用的

StringBuilder

,那為什麼結果相差這麼多,高達 1000 多倍呢?

我們再把以下代碼反編譯下:

long t1 = System.currentTimeMillis();
String str = "hollis";
for (int i = 0; i &lt; 50000; i++) {
    String s = String.valueOf(i);
    str += s;
}
long t2 = System.currentTimeMillis();
System.out.println("+ cost:" + (t2 - t1));
           

反編譯後代碼如下:

long t1 = System.currentTimeMillis();
String str = "hollis";
for(int i = 0; i &lt; 50000; i++)
{
    String s = String.valueOf(i);
    str = (new StringBuilder()).append(str).append(s).toString();
}

long t2 = System.currentTimeMillis();
System.out.println((new StringBuilder()).append("+ cost:").append(t2 - t1).toString());
           

我們可以看到,反編譯後的代碼,在

for

循環中,每次都是

new

了一個

StringBuilder

,然後再把

String

轉成

StringBuilder

,再進行

append

而頻繁的建立對象當然要耗費很多時間了,不僅僅會耗費時間,頻繁的建立對象,還會造成記憶體資源的浪費。

是以,阿裡巴巴 Java 開發手冊建議:循環體内,字元串的連接配接方式,使用 

StringBuilder

 的 

append

 方法進行擴充。而不要使用

+

小結

本文介紹了什麼是字元串拼接,雖然字元串是不可變的,但是還是可以通過建立字元串的方式來進行字元串的拼接。

常用的字元串拼接方式有五種,分别是使用

+

、使用

concat

、使用

StringBuilder

、使用

StringBuffer

以及使用

StringUtils.join

由于字元串拼接過程中會建立新的對象,是以如果要在一個循環體中進行字元串拼接,就要考慮記憶體問題和效率問題。

是以,經過對比,我們發現,直接使用

StringBuilder

的方式是效率最高的。因為

StringBuilder

天生就是設計來定義可變字元串和字元串的變化操作的。

但是,還要強調的是:

  1. 如果不是在循環體中進行字元串拼接的話,直接使用

    +

    就好了。
  2. 如果在并發場景中進行字元串拼接的話,要使用

    StringBuffer

    來代替

    StringBuilder

為什麼禁止在 foreach 循環裡進行元素的 remove/add 操作

解讀《阿裡巴巴 Java 開發手冊》背後的思考

foreach 循環

Foreach 循環(Foreach loop)是計算機程式設計語言中的一種控制流程語句,通常用來循環周遊數組或集合中的元素。

Java 語言從 JDK 1.5.0 開始引入 foreach 循環。在周遊數組、集合方面, foreach 為開發人員提供了極大的友善。

foreach 文法格式如下:

for(元素類型t 元素變量x : 周遊對象obj){ 
     引用了x的java語句; 
} 
           

以下執行個體示範了普通 for 循環和 foreach 循環使用:

public static void main(String[] args) {
        // 使用ImmutableList初始化一個List
        List<String> userNames = ImmutableList.of("Hollis", "hollis", "HollisChuang", "H");

        System.out.println("使用for循環周遊List");
        for (int i = 0; i < userNames.size(); i++) {
            System.out.println(userNames.get(i));
        }

        System.out.println("使用foreach周遊List");
        for (String userName : userNames) {
            System.out.println(userName);
        }
    }
           

以上代碼運作輸出結果為:

使用for循環周遊List
Hollis
hollis
HollisChuang
H
使用foreach周遊List
Hollis
hollis
HollisChuang
H
           

可以看到,使用 foreach 文法周遊集合或者數組的時候,可以起到和普通 for 循環同樣的效果,并且代碼更加簡潔。是以,foreach 循環也通常也被稱為增強 for 循環。

但是,作為一個合格的程式員,我們不僅要知道什麼是增強for循環,還需要知道增強 for 循環的原理是什麼?

其實,增強 for 循環也是 Java 給我們提供的一個文法糖,如果将以上代碼編譯後的 class 檔案進行反編譯(使用 jad 工具)的話,可以得到以下代碼:

Iterator iterator = userNames.iterator();
    do
    {
        if(!iterator.hasNext())
            break;
        String userName = (String)iterator.next();
        if(userName.equals("Hollis"))
            userNames.remove(userName);
    } while(true);
    System.out.println(userNames);
           

可以發現,原本的增強 for 循環,其實是依賴了 while 循環和 Iterator 實作的。(請記住這種實作方式,後面會用到!)

問題重制

規範中指出不讓我們在 foreach 循環中對集合元素做 add/remove 操作,那麼,我們嘗試着做一下看看會發生什麼問題。

// 使用雙括弧文法(double-brace syntax)建立并初始化一個List
    List<String> userNames = new ArrayList<String>() {{
        add("Hollis");
        add("hollis");
        add("HollisChuang");
        add("H");
    }};

    for (int i = 0; i < userNames.size(); i++) {
        if (userNames.get(i).equals("Hollis")) {
            userNames.remove(i);
        }
    }

    System.out.println(userNames);
           

以上代碼,首先使用雙括弧文法(double-brace syntax)建立并初始化一個 List,其中包含四個字元串,分别是 Hollis、hollis、HollisChuang 和 H。

然後使用普通 for 循環對 List 進行周遊,删除 List 中元素内容等于 Hollis 的元素。然後輸出 List,輸出結果如下:

[hollis, HollisChuang, H]
           

以上是哪使用普通的 for 循環在周遊的同時進行删除,那麼,我們再看下,如果使用增強 for 循環的話會發生什麼:

List<String> userNames = new ArrayList<String>() {{
        add("Hollis");
        add("hollis");
        add("HollisChuang");
        add("H");
    }};

    for (String userName : userNames) {
        if (userName.equals("Hollis")) {
            userNames.remove(userName);
        }
    }

    System.out.println(userNames);
           

以上代碼,使用增強 for 循環周遊元素,并嘗試删除其中的 Hollis 字元串元素。運作以上代碼,會抛出以下異常:

java.util.ConcurrentModificationException
           

同樣的,讀者可以嘗試下在增強 for 循環中使用 add 方法添加元素,結果也會同樣抛出該異常。

之是以會出現這個異常,是因為觸發了一個 Java 集合的錯誤檢測機制——fail-fast 。

fail-fast

接下來,我們就來分析下在增強 for 循環中 add/remove 元素的時候會抛出 java.util.ConcurrentModificationException 的原因,即解釋下到底什麼是 fail-fast 進制,fail-fast 的原理等。

fail-fast,即快速失敗,它是 Java 集合的一種錯誤檢測機制。當多個線程對集合(非f ail-safe 的集合類)進行結構上的改變的操作時,有可能會産生 fail-fast 機制,這個時候就會抛出ConcurrentModificationException(當方法檢測到對象的并發修改,但不允許這種修改時就抛出該異常)。

同時需要注意的是,即使不是多線程環境,如果單線程違反了規則,同樣也有可能會抛出改異常。

那麼,在增強 for 循環進行元素删除,是如何違反了規則的呢?

要分析這個問題,我們先将增強 for 循環這個文法糖進行解糖,得到以下代碼:

public static void main(String[] args) {
        // 使用ImmutableList初始化一個List
        List<String> userNames = new ArrayList<String>() {{
            add("Hollis");
            add("hollis");
            add("HollisChuang");
            add("H");
        }};

        Iterator iterator = userNames.iterator();
        do
        {
            if(!iterator.hasNext())
                break;
            String userName = (String)iterator.next();
            if(userName.equals("Hollis"))
                userNames.remove(userName);
        } while(true);
        System.out.println(userNames);
    }
           

然後運作以上代碼,同樣會抛出異常。我們來看一下 ConcurrentModificationException 的完整堆棧:

解讀《阿裡巴巴 Java 開發手冊》背後的思考

通過異常堆棧我們可以到,異常發生的調用鍊 ForEachDemo 的第 23 行,

Iterator.next

 調用了 

Iterator.checkForComodification

方法 ,而異常就是 checkForComodification 方法中抛出的。

其實,經過 debug 後,我們可以發現,如果 remove 代碼沒有被執行過,iterator.next 這一行是一直沒報錯的。抛異常的時機也正是 remove 執行之後的的那一次 next 方法的調用。

我們直接看下 checkForComodification 方法的代碼,看下抛出異常的原因:

final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
           

代碼比較簡單,

modCount != expectedModCount

的時候,就會抛出

ConcurrentModificationException

那麼,就來看一下,remove/add 操作室如何導緻 modCount 和 expectedModCount 不相等的吧。

remove/add 做了什麼

首先,我們要搞清楚的是,到底 modCount 和 expectedModCount 這兩個變量都是個什麼東西。

通過翻源碼,我們可以發現:

  • modCount 是 ArrayList 中的一個成員變量。它表示該集合實際被修改的次數。
  • expectedModCount 是 ArrayList 中的一個内部類——Itr 中的成員變量。expectedModCount 表示這個疊代器期望該集合被修改的次數。其值是在ArrayList.iterator 方法被調用的時候初始化的。隻有通過疊代器對集合進行操作,該值才會改變。
  • Itr 是一個 Iterator 的實作,使用 ArrayList.iterator 方法可以擷取到的疊代器就是 Itr 類的執行個體。

他們之間的關系如下:

class ArrayList{
        private int modCount;
        public void add();
        public void remove();
        private class Itr implements Iterator<E> {
            int expectedModCount = modCount;
        }
        public Iterator<E> iterator() {
            return new Itr();
        }
    }
           

其實,看到這裡,大概很多人都能猜到為什麼 remove/add 操作之後,會導緻 expectedModCount 和 modCount 不想等了。

通過翻閱代碼,我們也可以發現,remove 方法核心邏輯如下:

解讀《阿裡巴巴 Java 開發手冊》背後的思考

可以看到,它隻修改了 modCount,并沒有對 expectedModCount 做任何操作。

簡單總結一下,之是以會抛出 ConcurrentModificationException 異常,是因為我們的代碼中使用了增強 for 循環,而在增強 for 循環中,集合周遊是通過 iterator 進行的,但是元素的 add/remove 卻是直接使用的集合類自己的方法。這就導緻 iterator 在周遊的時候,會發現有一個元素在自己不知不覺的情況下就被删除/添加了,就會抛出一個異常,用來提示使用者,可能發生了并發修改!

正确姿勢

至此,我們介紹清楚了不能在 foreach 循環體中直接對集合進行 add/remove 操作的原因。

但是,很多時候,我們是有需求需要過濾集合的,比如删除其中一部分元素,那麼應該如何做呢?有幾種方法可供參考:

1、直接使用普通 for 循環進行操作

我們說不能在 foreach 中進行,但是使用普通的 for 循環還是可以的,因為普通 for 循環并沒有用到 Iterator 的周遊,是以壓根就沒有進行 fail-fast 的檢驗。

List<String> userNames = new ArrayList<String>() {{
            add("Hollis");
            add("hollis");
            add("HollisChuang");
            add("H");
        }};

        for (int i = 0; i < 1; i++) {
            if (userNames.get(i).equals("Hollis")) {
                userNames.remove(i);
            }
        }
        System.out.println(userNames);
           

這種方案其實存在一個問題,那就是 remove 操作會改變 List 中元素的下标,可能存在漏删的情況。

2、直接使用 Iterator 進行操作

除了直接使用普通 for 循環以外,我們還可以直接使用 Iterator 提供的 remove 方法。

List<String> userNames = new ArrayList<String>() {{
            add("Hollis");
            add("hollis");
            add("HollisChuang");
            add("H");
        }};

        Iterator iterator = userNames.iterator();

        while (iterator.hasNext()) {
            if (iterator.next().equals("Hollis")) {
                iterator.remove();
            }
        }
        System.out.println(userNames);
           

如果直接使用 Iterator 提供的 remove 方法,那麼就可以修改到 expectedModCount 的值。那麼就不會再抛出異常了。其實作代碼如下:

解讀《阿裡巴巴 Java 開發手冊》背後的思考

3、使用 Java 8 中提供的 filter 過濾

Java 8 中可以把集合轉換成流,對于流有一種 filter 操作, 可以對原始 Stream 進行某項測試,通過測試的元素被留下來生成一個新 Stream。

List<String> userNames = new ArrayList<String>() {{
            add("Hollis");
            add("hollis");
            add("HollisChuang");
            add("H");
        }};

        userNames = userNames.stream().filter(userName -> !userName.equals("Hollis")).collect(Collectors.toList());
        System.out.println(userNames);
           

4、使用增強 for 循環其實也可以

如果,我們非常确定在一個集合中,某個即将删除的元素隻包含一個的話, 比如對 Set 進行操作,那麼其實也是可以使用增強 for 循環的,隻要在删除之後,立刻結束循環體,不要再繼續進行周遊就可以了,也就是說不讓代碼執行到下一次的 next 方法。

List<String> userNames = new ArrayList<String>() {{
            add("Hollis");
            add("hollis");
            add("HollisChuang");
            add("H");
        }};

        for (String userName : userNames) {
            if (userName.equals("Hollis")) {
                userNames.remove(userName);
                break;
            }
        }
        System.out.println(userNames);
           

5、直接使用 fail-safe 的集合類

在 Java 中,除了一些普通的集合類以外,還有一些采用了 fail-safe 機制的集合類。這樣的集合容器在周遊時不是直接在集合内容上通路的,而是先複制原有集合内容,在拷貝的集合上進行周遊。

由于疊代時是對原集合的拷貝進行周遊,是以在周遊過程中對原集合所作的修改并不能被疊代器檢測到,是以不會觸發ConcurrentModificationException。

ConcurrentLinkedDeque<String> userNames = new ConcurrentLinkedDeque<String>() {{
        add("Hollis");
        add("hollis");
        add("HollisChuang");
        add("H");
    }};

    for (String userName : userNames) {
        if (userName.equals("Hollis")) {
            userNames.remove();
        }
    }
           

基于拷貝内容的優點是避免了ConcurrentModificationException,但同樣地,疊代器并不能通路到修改後的内容,即:疊代器周遊的是開始周遊那一刻拿到的集合拷貝,在周遊期間原集合發生的修改疊代器是不知道的。

java.util.concurrent 包下的容器都是安全失敗,可以在多線程下并發使用,并發修改。

小結

我們使用的增強 for 循環,其實是 Java 提供的文法糖,其實作原理是借助 Iterator 進行元素的周遊。

但是如果在周遊過程中,不通過 Iterator,而是通過集合類自身的方法對集合進行添加/删除操作。那麼在 Iterator 進行下一次的周遊時,經檢測發現有一次集合的修改操作并未通過自身進行,那麼可能是發生了并發被其他線程執行的,這時候就會抛出異常,來提示使用者可能發生了并發修改,這就是所謂的 fail-fast 機制。

當然還是有很多種方法可以解決這類問題的。比如使用普通 for 循環、使用 Iterator 進行元素删除、使用 Stream 的 filter、使用 fail-safe 的類等。

為什麼建議集合初始化時,指定集合容量大小

解讀《阿裡巴巴 Java 開發手冊》背後的思考

集合是 Java 開發日常開發中經常會使用到的。在之前的一些文章中,我們介紹過一些關于使用集合類應該注意的事項,如《為什麼阿裡巴巴禁止在 foreach 循環裡進行元素的 remove/add 操作》。

關于集合類,還有很多地方需要注意,本文就來分析下問什麼建議集合初始化時,指定集合容量大小?如果一定要設定初始容量的話,設定多少比較合适?

為什麼要設定 HashMap 的初始化容量

我們先來寫一段代碼在 JDK 1.7 (jdk1.7.0_79)下面來分别測試下,在不指定初始化容量和指定初始化容量的情況下性能情況如何。(jdk 8 結果會有所不同,我會在後面的文章中分析)

public static void main(String[] args) {
        int aHundredMillion = 10000000;

        Map<Integer, Integer> map = new HashMap<>();

        long s1 = System.currentTimeMillis();
        for (int i = 0; i < aHundredMillion; i++) {
            map.put(i, i);
        }
        long s2 = System.currentTimeMillis();

        System.out.println("未初始化容量,耗時 : " + (s2 - s1));


        Map<Integer, Integer> map1 = new HashMap<>(aHundredMillion / 2);

        long s5 = System.currentTimeMillis();
        for (int i = 0; i < aHundredMillion; i++) {
            map1.put(i, i);
        }
        long s6 = System.currentTimeMillis();

        System.out.println("初始化容量5000000,耗時 : " + (s6 - s5));


        Map<Integer, Integer> map2 = new HashMap<>(aHundredMillion);

        long s3 = System.currentTimeMillis();
        for (int i = 0; i < aHundredMillion; i++) {
            map2.put(i, i);
        }
        long s4 = System.currentTimeMillis();

        System.out.println("初始化容量為10000000,耗時 : " + (s4 - s3));
    }
           

以上代碼不難了解,我們建立了 3 個 HashMap,分别使用預設的容量(16)、使用元素個數的一半(5千萬)作為初始容量、使用元素個數(一億)作為初始容量進行初始化。然後分别向其中 put 一億個 KV。

輸出結果:

未初始化容量,耗時 : 14419
初始化容量5000000,耗時 : 11916
初始化容量為10000000,耗時 : 7984
           

從結果中,我們可以知道,在已知 HashMap 中将要存放的 KV 個數的時候,設定一個合理的初始化容量可以有效的提高性能。

當然,以上結論也是有理論支撐的。我們HashMap 中傻傻分不清楚的那些概念文章介紹過,HashMap 有擴容機制,就是當達到擴容條件時會進行擴容。HashMap 的擴容條件就是當 HashMap 中的元素個數(size)超過臨界值(threshold)時就會自動擴容。在 HashMap 中,

threshold = loadFactor * capacity

是以,如果我們沒有設定初始容量大小,随着元素的不斷增加,HashMap 會發生多次擴容,而 HashMap 中的擴容機制決定了每次擴容都需要重建 hash 表,是非常影響性能的。

從上面的代碼示例中,我們還發現,同樣是設定初始化容量,設定的數值不同也會影響性能,那麼當我們已知 HashMap 中即将存放的 KV 個數的時候,容量設定成多少為好呢?

HashMap 中容量的初始化

預設情況下,當我們設定 HashMap 的初始化容量時,實際上 HashMap 會采用第一個大于該數值的 2 的幂作為初始化容量。

如以下示例代碼:

Map<String, String> map = new HashMap<String, String>(1);
    map.put("hahaha", "hollischuang");

    Class<?> mapType = map.getClass();
    Method capacity = mapType.getDeclaredMethod("capacity");
    capacity.setAccessible(true);
    System.out.println("capacity : " + capacity.invoke(map));
           

在 jdk1.7 中,初始化容量設定成 1 的時候,輸出結果是 2。在 jdk1.8 中,如果我們傳入的初始化容量為 1,實際上設定的結果也為 1,上面代碼輸出結果為 2 的原因是代碼中 map.put("hahaha", "hollischuang");導緻了擴容,容量從 1 擴容到 2。

那麼,話題再說回來,當我們通過 HashMap(int initialCapacity)設定初始容量的時候,HashMap 并不一定會直接采用我們傳入的數值,而是經過計算,得到一個新值,目的是提高 hash 的效率。(1->1、3->4、7->8、9->16)

在 Jdk 1.7 和 Jdk 1.8 中,HashMap 初始化這個容量的時機不同。jdk1.8 中,在調用 HashMap 的構造函數定義 HashMap 的時候,就會進行容量的設定。而在 Jdk 1.7 中,要等到第一次 put 操作時才進行這一操作。

不管是 Jdk 1.7 還是 Jdk 1.8,計算初始化容量的算法其實是如出一轍的,主要代碼如下:

int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
           

上面的代碼挺有意思的,一個簡單的容量初始化,Java 的工程師也有很多考慮在裡面。

上面的算法目的挺簡單,就是:根據使用者傳入的容量值(代碼中的cap),通過計算,得到第一個比他大的 2 的幂并傳回。

聰明的讀者們,如果讓你設計這個算法你準備如何計算?如果你想到二進制的話,那就很簡單了。舉幾個例子看一下:

解讀《阿裡巴巴 Java 開發手冊》背後的思考

請關注上面的幾個例子中,藍色字型部分的變化情況,或許你會發現些規律。5->8、9->16、19->32、37->64 都是主要經過了兩個階段。

Step 1,5->7

Step 2,7->8

Step 1,9->15

Step 2,15->16

Step 1,19->31

Step 2,31->32

對應到以上代碼中,Step1:

n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
           

對應到以上代碼中,Step2:

return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
           

Step 2 比較簡單,就是做一下極限值的判斷,然後把 Step 1 得到的數值 +1。

Step 1 怎麼了解呢?其實是對一個二進制數依次向右移位,然後與原值取或。其目的對于一個數字的二進制,從第一個不為 0 的位開始,把後面的所有位都設定成 1。

随便拿一個二進制數,套一遍上面的公式就發現其目的了:

1100 1100 1100 >>>1 = 0110 0110 0110
1100 1100 1100 | 0110 0110 0110 = 1110 1110 1110
1110 1110 1110 >>>2 = 0011 1011 1011
1110 1110 1110 | 0011 1011 1011 = 1111 1111 1111
1111 1111 1111 >>>4 = 1111 1111 1111
1111 1111 1111 | 1111 1111 1111 = 1111 1111 1111
           

通過幾次

無符号右移

按位或

運算,我們把 1100 1100 1100 轉換成了1111 1111 1111 ,再把 1111 1111 1111 加 1,就得到了 1 0000 0000 0000,這就是大于 1100 1100 1100 的第一個 2 的幂。

好了,我們現在解釋清楚了 Step 1 和 Step 2 的代碼。就是可以把一個數轉化成第一個比他自身大的 2 的幂。(可以開始佩服 Java 的工程師們了,使用

無符号右移

按位或

運算大大提升了效率。)

但是還有一種特殊情況套用以上公式不行,這些數字就是 2 的幂自身。如果數字 4 套用公式的話。得到的會是 8 :

Step 1: 
    0100 >>>1 = 0010
    0100 | 0010 = 0110
    0110 >>>1 = 0011
    0110 | 0011 = 0111
    Step 2:
    0111 + 0001 = 1000
           

為了解決這個問題,JDK 的工程師把所有使用者傳進來的數在進行計算之前先 -1,就是源碼中的第一行:

int n = cap - 1;
           

至此,再來回過頭看看這個設定初始容量的代碼,目的是不是一目了然了:

int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
           

HashMap 中初始容量的合理值

當我們使用

HashMap(int initialCapacity)

來初始化容量的時候,jdk 會預設幫我們計算一個相對合理的值當做初始容量。那麼,是不是我們隻需要把已知的 HashMap 中即将存放的元素個數直接傳給 initialCapacity 就可以了呢?

關于這個值的設定,在《阿裡巴巴 Java 開發手冊》有以下建議:

解讀《阿裡巴巴 Java 開發手冊》背後的思考

這個值,并不是阿裡巴巴的工程師原創的,在 guava(21.0 版本)中也使用的是這個值。

public static <K, V> HashMap<K, V> newHashMapWithExpectedSize(int expectedSize) {
        return new HashMap<K, V>(capacity(expectedSize));
    }

    /**
    * Returns a capacity that is sufficient to keep the map from being resized as long as it grows no
    * larger than expectedSize and the load factor is ≥ its default (0.75).
    */
    static int capacity(int expectedSize) {
        if (expectedSize < 3) {
          checkNonnegative(expectedSize, "expectedSize");
          return expectedSize + 1;
        }
        if (expectedSize < Ints.MAX_POWER_OF_TWO) {
          // This is the calculation used in JDK8 to resize when a putAll
          // happens; it seems to be the most conservative calculation we
          // can make.  0.75 is the default load factor.
          return (int) ((float) expectedSize / 0.75F + 1.0F);
        }
        return Integer.MAX_VALUE; // any large value
    }
           

return (int) ((float) expectedSize / 0.75F + 1.0F);

上面有一行注釋,說明了這個公式也不是 guava 原創,參考的是 JDK8 中 putAll 方法中的實作的。感興趣的讀者可以去看下 putAll 方法的實作,也是以上的這個公式。

雖然,當我們使用

HashMap(int initialCapacity)

來初始化容量的時候,jdk 會預設幫我們計算一個相對合理的值當做初始容量。但是這個值并沒有參考 loadFactor 的值。

也就是說,如果我們設定的預設值是 7,經過 JDK 處理之後,會被設定成 8,但是,這個 HashMap 在元素個數達到 8*0.75 = 6 的時候就會進行一次擴容,這明顯是我們不希望見到的。

如果我們通過expectedSize / 0.75F + 1.0F計算,7/0.75 + 1 = 10 ,10 經過 JDK 處理之後,會被設定成 16,這就大大的減少了擴容的幾率。

當 HashMap 内部維護的哈希表的容量達到 75% 時(預設情況下),會觸發 rehash,而 rehash 的過程是比較耗費時間的。是以初始化容量要設定成 expectedSize/0.75 + 1 的話,可以有效的減少沖突也可以減小誤差。

是以,我可以認為,當我們明确知道 HashMap 中元素的個數的時候,把預設容量設定成 expectedSize / 0.75F + 1.0F 是一個在性能上相對好的選擇,但是,同時也會犧牲些記憶體。

小結

當我們想要在代碼中建立一個 HashMap 的時候,如果我們已知這個 Map 中即将存放的元素個數,給 HashMap 設定初始容量可以在一定程度上提升效率。

但是,JDK 并不會直接拿使用者傳進來的數字當做預設容量,而是會進行一番運算,最終得到一個 2 的幂。原因在《全網把Map中的hash()分析的最透徹的文章,别無二家。》介紹過,得到這個數字的算法其實是使用了使用無符号右移和按位或運算來提升效率。

但是,為了最大程度的避免擴容帶來的性能消耗,我們建議可以把預設容量的數字設定成 expectedSize / 0.75F + 1.0F 。在日常開發中,可以使用

Map<String, String> map = Maps.newHashMapWithExpectedSize(10);
           

來建立一個 HashMap,計算的過程 guava 會幫我們完成。

但是,以上的操作是一種用記憶體換性能的做法,真正使用的時候,要考慮到記憶體的影響。

好啦,以上就是本次 Chat 的全部内容,由于篇幅有限,無法把這個系列的内容全部都寫出來,本文主要挑選了部分知識點進行講解。希望讀者可以學會在使用規約的同時,去洞悉其背後的思考。

來自

https://gitbook.cn/books/5ca2da9a1763103ff10b0975/index.html

繼續閱讀