一行程式碼引發的效能暴跌 10 倍

2023-09-11 12:02:56

程式碼測試

import com.google.common.base.Stopwatch;
import java.util.concurrent.TimeUnit;
public class StackTest {
    public static void main(String[] args) {
        Stopwatch started = new Stopwatch();
        started.start();
        User user = null;
        for (long i = 0; i < 1000_000_000; i++) {
            user = new User();
        }
        started.stop();
        System.out.println(started.elapsed(TimeUnit.MILLISECONDS) + "ms");
        //不加列印 300ms
        //加了列印 3000ms
//        System.out.println(user);
    }
}

class User {
    private int age;
    private String userName;

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }
}

上面的一個簡單的程式碼是測試 Java 建立物件的效能,如果沒有 System.out.println(user); 輸出的時間是 300ms左右,如果加上效能是 3000ms 左右,整整慢了 10 倍左右。(具體需要時間根據電腦的設定決定)。

看似很簡單的程式碼,卻會帶來這樣的效能消耗,確實很讓人費解。為了弄清楚這個問題,我們需要討論下,java 程式碼分配的規則。

物件分配規則

在前面的部落格已經提過 Java 物件的分配過程,具體流程圖如下:

棧上分配

棧上分配是 Java 虛擬機器器提供的一項優化技術,將執行緒私有的物件打散分配在棧上,棧上分配的物件回收直接 POP 出站,不需要垃圾回收器的介入,效率很高。當然棧上分配也需要一些特殊的條件:

  1. 棧空間小,對於大物件無法實現棧上分配
  2. 物件不能出現逃逸(JVM 引數:-XX:+DoEscapeAnalysis
  3. 物件可以進行標量替換,即是使用欄位來表示物件(-XX:+EliminateAllocations)。

如 demo 所示,我們可以是用 age 和 username 兩個欄位來代替 User 物件。

TLAB 分配

TLAB Thread Local Allocation Buffer, 即:執行緒本地分配快取。這是一塊執行緒專用的記憶體分配區域。TLAB 佔用的是 eden 區的空間。在TLAB 啟用的情況下(預設開啟),JVM會為每一個執行緒分配一塊TLAB區域。

使用 TLAB 是為了加速物件的分配。由於物件一般分配在堆上,而堆是執行緒共用的,因此可能會有多個執行緒在堆上申請空間,而每一次的物件分配都必須執行緒同步,會使分配的效率下降。

考慮到物件分配幾乎是 Java中 最常用的操作,因此 JVM 使用了 TLAB 這樣的執行緒專有區域來避免多執行緒衝突,提高物件分配的效率。

同樣,TLAB 空間一般不會太大(佔用 eden 區),所以大物件無法進行 TLAB 分配,只能直接分配到堆上。

分配策略:

一個100KB的TLAB區域,如果已經使用了80KB,當需要分配一個30KB的物件時,TLAB是如何分配的呢?可以有兩種情況:

  1. 廢棄當前的 TLAB,重新申請;
  2. 將這個 30KB 的物件直接分配到堆上,保留當前 TLAB(當有小於 20KB 的物件請求 TLAB 分配時可以直接使用該 TLAB 區域)。

JVM選擇的策略是:在虛擬機器器內部維護一個叫 refill_waste 的值,當請求物件大於 refill_waste 時,會選擇在堆中分配,反之,則會廢棄當前 TLAB,新建 TLAB來分配新物件。【預設情況下,TLAB和refill_waste都是會在執行時不斷調整的,使系統的執行狀態達到最優。】

JVM引數解析

引數 作用 備註
-XX:+UseTLAB 啟用TLAB 預設啟用
-XX:TLABRefillWasteFraction 設定允許空間浪費的比例 預設值:64,即:使用1/64的TLAB空間大小作為refill_waste值
-XX:-ResizeTLAB 禁止系統自動調整TLAB大小
-XX:TLABSize 指定TLAB大小 單位:B

Demo 分析

通過上面的分析,可以剖析出原因了,在使用列印的時候導致了 user 物件的逃逸,所以導致在棧上分配條件不滿足,只能在堆上分配,這樣就會導致頻繁的 GC,效率低下。

如果我們再使用(-XX:+UseTLAB)關閉 TLAB分配原則,則會導致分配的速度又會降低一點(TLAB 一般會對多執行緒競爭分配的時候提升比較明顯,此處不再驗證)