阿里《Java開發手冊》中的 1 個bug!

這是我的第 210 期分享

作者 | 王磊

來源 | Java中文社羣(ID:javacn666)

轉載請聯繫授權(微信ID:GG_Stone)

本來打算寫一篇《阿里巴巴爲什麼不允許日誌輸出時,使用字符串拼接?》的文章,主要是想從性能方面來說此問題,可在文章寫到一半進行性能測試時,卻發現了一個異常問題,實際測試的結果和手冊上描述的結果是截然相反的!

天擼了,怎麼會發生這種事情?此時我的內心是拒絕的,因爲文章已經寫了一半了啊,這讓我瞬間陷入了尷尬的境地。

阿里巴巴的《Java開發手冊》泰山版(最新版)是這樣描述的,它在第二章第三小節的第 4 條規範中指出:

【強制】在日誌輸出時,字符串變量之間的拼接使用佔位符的方式。

說明:因爲 String 字符串的拼接會使用 StringBuilder 的 append() 方式,有一定的性能損耗。使用佔位符僅 是替換動作,可以有效提升性能

正例:logger.debug("Processing trade with id: {} and symbol: {}", id, symbol);

從上述描述中可以看出,阿里強制要求在日誌輸出時必須使用佔位符的方式進行字符串拼接,因爲這樣可以有效的提高程序的性能。

然而當我們使用 Oracle 官方提供的 JMH(Java Microbenchmark Harness,JAVA 微基準測試套件)框架來測試時,卻發現結果和手冊上描述的完全不一樣。

PS:對 JMH 不熟悉的朋友,可以看我發佈的另一篇文章《Oracle官方推薦的性能測試工具!簡單、精準又直觀!

性能測試

本文我們藉助 Spring Boot 2.2.6 來完成測試,首先我們先在 Spring Boot 的 pom.xml 中添加 JMH 框架的依賴:

<!-- https://mvnrepository.com/artifact/org.openjdk.jmh/jmh-core -->
<dependency>
  <groupId>org.openjdk.jmh</groupId>
  <artifactId>jmh-core</artifactId>
  <version>1.23</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.openjdk.jmh/jmh-generator-annprocess -->
<dependency>
  <groupId>org.openjdk.jmh</groupId>
  <artifactId>jmh-generator-annprocess</artifactId>
  <version>1.23</version>
  <scope>provided</scope>
</dependency>

這裏需要注意一下,一般的項目我們只需要添加 jmh-core 的依賴包就可以了,但如果是 Spring Boot 項目的話,我們還必須添加 jmh-generator-annprocess 包依賴,並且要把 scope 設置爲 provided 類型,如果使用它的默認值 test 就會導致程序報錯 Unable to find the resource: /META-INF/BenchmarkList

scope 值說明

  • compile:默認值,它表示被依賴項目需要參與當前項目的編譯、測試和運行等階段,在打包時通常也需要添加進去;

  • test:表示依賴項目僅僅參與測試相關的工作,在編譯和運行環境下都不會被使用,更別說打包了;

  • provided:適用於編譯和測試的階段,他不會被打包到 lib 目錄下;

  • runntime:僅僅適用於運行環境,在編譯和測試環境下都不會被使用。

緊接着,我們編寫了完整的測試代碼:

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.TimeUnit;


@BenchmarkMode(Mode.AverageTime) // 測試完成時間
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS) // 預熱 2 輪,每次 1s
@Measurement(iterations = 5, time = 3, timeUnit = TimeUnit.SECONDS) // 測試 5 輪,每次 3s
@Fork(1) // fork 1 個線程
@State(Scope.Thread) // 每個測試線程一個實例
@RestController
@RequestMapping("/log")
public class LogPrint {

    private final Logger log = LoggerFactory.getLogger(LogPrint.class);
    private final static int MAX_FOR_COUNT = 100; // for 循環次數

    public static void main(String[] args) throws RunnerException {
        // 啓動基準測試
        Options opt = new OptionsBuilder()
                .include(LogPrint.class.getName() + ".*") // 要導入的測試類
                .build();
        new Runner(opt).run(); // 執行測試
    }

    @Benchmark
    public void appendLogPrint() {
        for (int i = 0; i < MAX_FOR_COUNT; i++) { // 循環的意圖是爲了放大性能測試效果
            StringBuilder sb = new StringBuilder();
            sb.append("Hello, ");
            sb.append("Java");
            sb.append(".");
            sb.append("Hello, ");
            sb.append("Redis");
            sb.append(".");
            sb.append("Hello, ");
            sb.append("MySQL");
            sb.append(".");
            log.info(sb.toString());
        }
    }

    @Benchmark
    public void logPrint() {
        for (int i = 0; i < MAX_FOR_COUNT; i++) { // 循環的意圖是爲了放大性能測試效果
            log.info("Hello, {}.Hello, {}.Hello, {}.", "Java", "Redis", "MySQL");
        }
    }
}

測試結果如下:


從上述結果可以看出直接使用 StringBuilder 拼接的方式顯然要比使用佔位符的方式性能要高,難道是我搞錯了?

備註:測試環境爲 Spring Boot 2.2.6 RELEASE、JDK 8(JDK 1.8.0_10)、MacOS(MacMini 2018)

源碼分析

抱着懷疑的態度,我們打開了 slf4j 的源碼,看看佔位符的底層方法到底是如何實現的,於是我就順着 log.info 方法找到了佔位符最終的實現源碼:

final public static FormattingTuple arrayFormat(final String messagePattern, final Object[] argArray, Throwable throwable) {
    if (messagePattern == null) {
        return new FormattingTuple(null, argArray, throwable);
    }
    if (argArray == null) {
        return new FormattingTuple(messagePattern);
    }
    int i = 0;
    int j;
    // use string builder for better multicore performance
    StringBuilder sbuf = new StringBuilder(messagePattern.length() + 50);
    int L;
    // for 循環替換佔位符
    for (L = 0; L < argArray.length; L++) {
        j = messagePattern.indexOf(DELIM_STR, i);
        if (j == -1) {
            // no more variables
            if (i == 0) { // this is a simple string
                return new FormattingTuple(messagePattern, argArray, throwable);
            } else { // add the tail string which contains no variables and return
                // the result.
                sbuf.append(messagePattern, i, messagePattern.length());
                return new FormattingTuple(sbuf.toString(), argArray, throwable);
            }
        } else {
            if (isEscapedDelimeter(messagePattern, j)) {
                if (!isDoubleEscaped(messagePattern, j)) {
                    L--; // DELIM_START was escaped, thus should not be incremented
                    sbuf.append(messagePattern, i, j - 1);
                    sbuf.append(DELIM_START);
                    i = j + 1;
                } else {
                    // The escape character preceding the delimiter start is
                    // itself escaped: "abc x:\\{}"
                    // we have to consume one backward slash
                    sbuf.append(messagePattern, i, j - 1);
                    deeplyAppendParameter(sbuf, argArray[L], new HashMap<Object[], Object>());
                    i = j + 2;
                }
            } else {
                // normal case
                sbuf.append(messagePattern, i, j);
                deeplyAppendParameter(sbuf, argArray[L], new HashMap<Object[], Object>());
                i = j + 2;
            }
        }
    }
    // append the characters following the last {} pair.
    sbuf.append(messagePattern, i, messagePattern.length());
    return new FormattingTuple(sbuf.toString(), argArray, throwable);
}

從上述源碼可以看出,所謂的佔位符其實底層也是使用 StringBuilder 來實現的,怪不得性能不如直接使用 StringBuilder。因爲在進行佔位符替換的時候,還經過了一些列的驗證才進行替換的,而直接使用 StringBuilder 則可以省去這部分效驗的工作。

爲了保證我沒有搞錯,於是我使用 Idea 開啓了調試模式,調試的結果如下圖所示:

從上圖可以看出,此方法就是佔位符的實際執行方法,那也就是說,手冊上寫的性能問題確實是錯的

於是我就隨手發了一個朋友圈:


卻發現在審紙質書的編輯也恰好是我的好友:

這樣就可以避免這個問題,會直接出現在未來的紙質書中,也算是功勞一件了。

擴展篇

我們在 Spring Boot 中使用日誌通常會這樣寫:

private final Logger log = LoggerFactory.getLogger(LogPrint.class);

並且每個類中都要寫這樣一行代碼,未免有些麻煩。

此時我們可以使用 @Slf4j 註解來替代上面的那行 Logger 對象創建的代碼,完整使用示例如下:

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
class LogTest {
    @Test
    void show() {
        log.debug("Hello, {}.", "Debug");
        log.info("Hello, {}.", "Info");
        log.error("Hello, {}.", "Error");
    }
}

程序的執行結果:

14:33:18.377 [main] DEBUG com.example.demo.LogTestTest - Hello, Debug.

14:33:18.382 [main] INFO com.example.demo.LogTestTest - Hello, Info.

14:33:18.382 [main] ERROR com.example.demo.LogTestTest - Hello, Error.

從上述結果可以看出,日誌已經正常輸出到控制檯了。

注意:@Slf4j 註解屬於 lombok,因此要想在項目中使用 @Slf4j 註解,需要保證項目中已經添加了 lombok 的依賴。

總結

在進行日誌輸出時,字符串變量之間的拼接要使用佔位符的方式,因爲這樣寫會比較優雅。我們查了 slf4j 的源碼發現,佔位符的底層也是通過 StringBuilder 拼接來實現的。

最後的話

原創不易,點擊「在看」下次帶你一塊擺地攤????。

舊文:

爲了響應國家“低碳生活,綠色出行”的號召,老王決定以後要騎自行車上下班,不但省錢而且還不堵車,最主要的是能爲國家出一份力。爲了達成這個目標,老王 happy 的掏出了他的諾基亞老年機,一頓操作在某寶買了一輛 8 成新的二八自行車,幻想着從此能過上了幸福生活。

但車子買回來之後卻發現,村東頭已經放滿了 2 元錢就能騎一個月的共享自行車,你說氣人不?不但如此,老王還發現自己買回來的車子不僅不好騎,還不支持七天無理由退貨,這給老王氣的,回手就是一個五星差評。

在看賣家這邊,反而一點都不着急,只是冷冷的給回了一句:小子,我還怕你?這月幹完我就全職擺地攤了,網店反正也不開了,差評就差評唄。

這個故事深刻告訴我們一個道理:如果別人已經給我們提供好了“自行車”,那我們就不用自己花錢再賣一個了。

看到這裏有人可能會問了:小夥子,你這是正經的技術文章不?你說的這些和技術有毛關係啊?

這個故事和今天的主題還真有關係,下面我們正式開始...



往期推薦

阿里新版《Java 開發手冊(泰山版)》內容解讀(附下載地址)

阿里巴巴爲什麼讓初始化集合時必須指定大小?

關注下方二維碼,訂閱更多精彩內容

覺得有用,請點擊“在看”!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章