探討緩存行與僞共享

點擊上方“中間件興趣圈”選擇“設爲星標”

做積極的人,越努力越幸運!

最近項目中有個需求,需要用到有界隊列對訪問請求量進行流量削峯請求,同時作爲一個緩衝層對請求處理進行後續處理,Java 內置有界隊列 ArrayBlockingQueue 可以滿足這方面的需求,但是性能上並不滿足,於是使用了 Disruptor,它是英國外匯交易公司 LMAX 開發的一個高性能隊列,瞭解到它內部解決僞共享問題,今天就和大家一起學習緩存行與僞共享相關的知識。

緩存行(Cache line)

對計算機組成原理相對熟悉的小夥伴都知道,CPU 的速度比內存的速度高了幾個數量級,爲了 CPU 更快從內存中讀取數據,設置了多級緩存機制,如下圖所示:

當 CPU 運算時,首先會從 L1 緩存查找所需要的數據,如果沒有找到,再去 L2 緩存中去找,以此類推,直到從內存中獲取數據,這也就意味着,越長的調用鏈,所耗費的執行時間也越長。那是不是可以從主內存拿數據的時候,順便多拿一些呢?這樣就可以避免頻繁從主內存中獲取數據了。聰明的計算機科學家已經想到了這個法子,這就是緩存行的由來。緩存是由多個緩存行組成的,而每個緩存行大小通常來說,大小爲 64 字節,並且每個緩存行有效地引用主內存中的一塊兒地址,CPU 每次從主內存中獲取數據時,會將相鄰的數據也一同拉取到緩存行中,這樣當 CPU 執行運算時,就大大減少了與主內存的交互。

下面我用一個例子讓大家體會一下用緩存行和不用緩存行在性能上的差異:

// 以下源碼例子來源:https://tech.meituan.com/2016/11/18/disruptor.html
public class CacheLineEffect {

  //考慮一般緩存行大小是64字節,一個 long 類型佔8字節
  static long[][] arr;

  public static void main(String[] args) {

    int size = 1024 * 1024;

    arr = new long[size][];
    for (int i = 0; i < size; i++) {
      arr[i] = new long[8];
      for (int j = 0; j < 8; j++) {
        arr[i][j] = 0L;
      }
    }
    long sum = 0L;
    long marked = System.currentTimeMillis();
    for (int i = 0; i < size; i++) {
      for (int j = 0; j < 8; j++) {
        sum = arr[i][j];
      }
    }
    System.out.println("[cache line]Loop times:" + (System.currentTimeMillis() - marked) + "ms");

    marked = System.currentTimeMillis();
    for (int i = 0; i < 8; i += 1) {
      for (int j = 0; j < size; j++) {
        sum = arr[j][i];
      }
    }
    System.out.println("[no cache line]Loop times:" + (System.currentTimeMillis() - marked) + "ms");
  }

}

我使用的測試運行環境配置如下:

運行後結果如下:

可以看到,使用緩存行比沒有使用緩存行的性能提升了將近 4 倍。

僞共享問題

當 CPU 執行完後,還需要將數據回寫到內存上,以便於別的線程可以從主內存中獲取最新的數據。假設兩個線程都加載了相同的 Cache line 數據,會產生什麼樣的影響呢?下面我用一張圖解釋:

數據 A、B、C 被加載到同一個 Cache line,假設線程 1 在 core1 中修改 A,線程 2 在 core2 中修改 B。

線程 1 首先對 A 進行修改,這時 core1 會告知其它 CPU 核,當前引用同一地址的 Cache line 已經無效,隨後 core2 發起修改 B,會導致 core1 將數據回寫到主內存中,core2 這時會重新從主內存中讀取該 Cache line 數據。

可見,如果同一個 Cache line 的內容被多個線程讀取,就會產生相互競爭,頻繁回寫主內存,降低了性能。

如何解決僞共享問題

要解決僞共享這個問題最簡單的做法就是將線程間共享元素分開到不同的 Cache line 中,這種做法叫用空間換取時間,具體做法如下:

public final static class ValuePadding {
  // 前置填充對象
  protected long p1, p2, p3, p4, p5, p6, p7;
  // value 值
  protected volatile long value = 0L;
  // 後置填充對象
  protected long p9, p10, p11, p12, p13, p14, p15;
}

JDK1.8 有專門的註解 @Contended 來避免僞共享,爲了更加直觀,我使用了對象填充的方法,其中 protected long p1, p2, p3, p4, p5, p6, p7 作爲前置填充對象,protected long p9, p10, p11, p12, p13, p14, p15作爲後置填充對象,這樣任意線程訪問 ValuePadding 時,value 都處於不同的 Cache line 中,不會產生僞共享問題。

下面的例子用來演示僞共享與解決僞共享後的性能差異:

public class MyFalseSharing {

  public static void main(String[] args) throws InterruptedException {
    for (int i = 1; i < 10; i++) {
      System.gc();
      final long start = System.currentTimeMillis();
      runTest(Type.PADDING, i);
      System.out.println("[PADDING]Thread num " + i + " duration = " + (System.currentTimeMillis() - start));
    }
    for (int i = 1; i < 10; i++) {
      System.gc();
      final long start = System.currentTimeMillis();
      runTest(Type.NO_PADDING, i);
      System.out.println("[NO_PADDING] Thread num " + i + " duration = " + (System.currentTimeMillis() - start));
    }
  }

  private static void runTest(Type type, int NUM_THREADS) throws InterruptedException {
    Thread[] threads = new Thread[NUM_THREADS];

    switch (type) {
      case PADDING:
        DataPadding.longs = new ValuePadding[NUM_THREADS];
        for (int i = 0; i < DataPadding.longs.length; i++) {
          DataPadding.longs[i] = new ValuePadding();
        }
        break;
      case NO_PADDING:
        Data.longs = new ValueNoPadding[NUM_THREADS];
        for (int i = 0; i < Data.longs.length; i++) {
          Data.longs[i] = new ValueNoPadding();
        }
        break;
    }


    for (int i = 0; i < threads.length; i++) {
      threads[i] = new Thread(new FalseSharing(type, i));
    }
    for (Thread t : threads) {
      t.start();
    }
    for (Thread t : threads) {
      t.join();
    }
  }

  // 線程執行單元
  static class FalseSharing implements Runnable {
    public final static long ITERATIONS = 500L * 1000L * 100L;
    private int arrayIndex;
    private Type type;

    public FalseSharing(Type type, final int arrayIndex) {
      this.arrayIndex = arrayIndex;
      this.type = type;
    }

    public void run() {
      long i = ITERATIONS + 1;
      // 讀取共享變量中指定的下標對象,並對其value變量不斷修改
      // 由於每次讀取數據都會寫入緩存行,如果線程間有共享的緩存行數據,就會導致僞共享問題發生
      // 如果對象已填充,那麼線程每次讀取到緩存行中的對象就不會產生僞共享問題
      switch (type) {
        case NO_PADDING:
          while (0 != --i) {
            Data.longs[arrayIndex].value = 0L;
          }
          break;
        case PADDING:
          while (0 != --i) {
            DataPadding.longs[arrayIndex].value = 0L;
          }
          break;
      }
    }
  }

  // 線程間貢獻的數據
  public final static class Data {
    public static ValueNoPadding[] longs;
  }

  public final static class DataPadding {
    public static ValuePadding[] longs;
  }

  // 使用填充對象
  public final static class ValuePadding {
    // 前置填充對象
    protected long p1, p2, p3, p4, p5, p6;
    // value 值
    protected volatile long value = 0L;
    // 後置填充對象
    protected long p9, p10, p11, p12, p13, p14, p15;
  }

  // 不填充對象
  //    @sun.misc.Contended
  public final static class ValueNoPadding {
    protected volatile long value = 0L;
  }

  enum Type {
    NO_PADDING,
    PADDING
  }
}

運行程序,測試結果如下:

可見,當有多個線程同時操作同一個 Cache line 的數據時,僞共享問題會影響 CPU 性能。

作者介紹:作者張乘輝,擅長消息中間件技能,負責公司百萬 TPS 級別 Kafka 集羣的維護,公號不定期分享 Kafka、RocketMQ 系列不講概念直接真刀真槍的實戰總結以及細節上的源碼分析;同時作者也是阿里開源分佈式事務框架 Seata Contributor,因此也會不定期分享關於 Seata 的相關知識;當然公號也會不定期發表 WEB 相關知識比如 Spring 全家桶等。不一定面面俱到,但一定讓你感受到作者對於技術的追求是認真的!


本文分享自微信公衆號 - 中間件興趣圈(dingwpmz_zjj)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

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