從kernel層面分析synchronized、volatile,進大廠必備硬核小伎倆(上)

	synchronized、volatile對於java程序員來說再熟悉不過了,但
是你知道這兩個關鍵字底層是如何實現的嗎(甚至在操作系層面是
通過什麼指令來實現的)?以及與其相關的術語:諸如用戶態與內核
態、cas、鎖升級、內存一致性協議、內存屏障都是什麼,下面我來一
一揭祕。本專題將分爲兩篇文章進行講解,此篇主要介紹關於
synchronized和volatile在kernel層面涉及的一些核心概念,下一篇
會詳細說明synchronized和volatile實現原理,包括內存屏障、
鎖升級過程(偏向、輕量、重量)、重入鎖、線程可見性、指令重排等
核心原理,其中也不乏DCL單例是否需要volatile修飾等有趣問題。

用戶態和內核態

一般的操作系統對操作指令進行了權限控制,在intel x86 cpu中將級別分爲0-3,0爲最高執行權限,3爲最低執行權限,0和3分別代表內核態和用戶態,簡單來說就是需要與操作系統,比如操作系統硬件打交道時,就需要調用內核態來完成,而jvm是運行在用戶態的,或者說運行在用戶空間的。

運行在用戶空間的是幹活的,權利小,內核空間纔是操作系統老大,做
大事需要向內核空間申請權限。

CAS

compare and swap(比較並交換),即有2個線程A和B,同時對int i = N進行加1操作,當線程A將i+1後的結果寫入主內存賦值給i前,首先會比較當前主內存中i的值是否爲N,如果爲N就執行賦值操作,否則有可能是B線程執行了對i的修改操作(如目前i=N+1),A線程就不執行賦值操作,A線程再次從主內存中讀取最新的i的值,然後再執行i+1操作,然後再次將結果賦值給i,在賦值之前同樣比較當前主內存中的i的值是否爲N+1,如果爲N+1,就將線程A修改的i的值賦值給i,否則再次執行上述操作,直至修改成功爲止。我們可以把這個操作成爲自旋操作。

AtomicInteger就是cas的實現:

public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

也就是最後會調用一個native方法compareAndSwapInt函數。這個方法依次會調用操作系統代碼unsafe.cpp,atomic.cpp和atomic_windows_x86.inline.hpp/atomic_linux_x86.inline.hpp:

// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0  \
                       __asm je L0      \
                       __asm _emit 0xF0 \
                       __asm L0:

inline jint     Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value) {
  // alternative for InterlockedCompareExchange
  int mp = os::is_MP();
  __asm {
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value
    LOCK_IF_MP(mp)
    cmpxchg dword ptr [edx], ecx
  }
}

上面的代碼就是操作系統級別執行cas操作執行的指令代碼,簡單來說就是會執行lock_if_mp cmpxchg來完成cas操作的。其中cmpxchg就是執行cas操作的,lock_if_mp是指如果是多核cpu,就將cmpxchg指令進行加鎖執行,否則就不加鎖。從而可以看出cmpxchg並不是原子操作,需要加lock後纔是原子操作,其實鎖住的是北橋信號。上面就是cas在操作系統層面的執行原理。

緩存行


上面是最常見的緩存簡圖。main cache爲主內存,L1 cache、L2 cache、L3 cache分別爲1級緩存、2級緩存、3級緩存。cahe line爲緩存行。那麼緩存行是什麼?緩存行就是保存了我們需要執行的指令代碼,大多數一個緩存行爲64個字節,cpu一次也會讀取64個字節指令到cpu中進行使用。

下面我們來看一個比較有意思的兩段代碼片段:
代碼片段1:

public class TestCacheLingOne {

    static volatile Long[] l = new Long[2];

    public static void main(String[] args) throws InterruptedException {

        Thread threadOne = new Thread(){
            @Override
            public void run() {
                for (long i=0;i<1000_000_000L;i++) {
                    l[0] = 1L;
                }
            }
        };
        Thread threadTwo = new Thread(){
            @Override
            public void run() {
                for (long i=0;i<1000_000_000L;i++) {
                    l[1] = 2L;
                }
            }
        };
        long time = System.currentTimeMillis();
        threadOne.start();
        threadTwo.start();
        threadOne.join();
        threadTwo.join();

        System.out.println((System.currentTimeMillis() - time) /1000d);
    }
}

在這裏插入圖片描述
代碼片段2:

public class TestCacheLineTwo {

   static volatile Long[] l = new Long[9];

   public static void main(String[] args) throws InterruptedException {

       Thread threadOne = new Thread(){
           @Override
           public void run() {
               for (long i=0;i<1000_000_000L;i++) {
                   l[0] = 1L;
               }
           }
       };
       Thread threadTwo = new Thread(){
           @Override
           public void run() {
               for (long i=0;i<1000_000_000L;i++) {
                   l[8] = 2L;
               }
           }
       };
       long time = System.currentTimeMillis();
       threadOne.start();
       threadTwo.start();
       threadOne.join();
       threadTwo.join();

       System.out.println((System.currentTimeMillis() - time) /1000d);
   }
}

在這裏插入圖片描述
上面的兩段程序都是2個線程對同一個volatile修飾的Long類型數組進行10億次值修改操作,那麼執行用時差別還是很明顯的,這是爲什麼呢?這是由於cpu讀取緩存行字節數和下面我要說的緩存一致性有關,在下一篇內容中會進行具體說明。

緩存一致性協議

其實緩存一致性協議和synchronized、volatile本身沒有關係,緩存一致性是操作系統層面的概念。目前一致性協議有MESI、MSI、Dragon等等,我們常說的MESI其實是intnel x86的緩存一致性協議,MESI即:Modified(修改)、Exclusive(獨享)、Shared(共享)、Invalid(失效),指的就是緩存行的4種狀態。

在這裏插入圖片描述
上圖說明了2個線程對主內存中同一個緩存行的操作過程。

  • cpu1即線程1,讀取主內存中的數據x,將x寫入當前線程緩存,緩存行狀態爲E(共享),同時cpu1監聽總線(嗅探)。
  • 此時cpu2即線程2讀取主內存中的數據x,此時cpu1監聽器發現線程2讀取了主內存中的x緩存行,則將cpu1中的x緩存行置爲S(共享)
  • 線程2將x緩存行從主內存讀取到線程2緩存中,將x緩存行狀態置爲S(共享),並監聽總線。
  • 此時線程1修改了x值,通知總線,此時將線程2中的x緩存行置爲I(無效),將線程1中的x緩存行置爲E(獨享)。
  • 此時線程2如果想操作x,那麼需要再次從主內存中讀取x緩存行。

CPU指令亂序

cpu指令亂序是指cpu爲了提高其執行效率,在操作系統層面會將指令亂序執行(不是按照程序編寫順序執行)。但是這種cpu指令亂序會導致在多線程情況下,產生問題。看如下代碼,證明cpu存在亂序執行:

public class Test {

    static int a,b,c,d;
    public static void main(String[] args) throws InterruptedException {
        int x = 0;
        for(;;) {
            x++;
            a = 0;
            b = 0;
            c = 0;
            d = 0;

            Thread threadOne = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    c = b;
                }
            });
            Thread threadTwo = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    d = a;
                }
            });

            threadOne.start();
            threadTwo.start();
            threadOne.join();
            threadTwo.join();

            if (c == 0 && d == 0) {
                System.out.println("執行第" + x + "次,證明cpu是可以亂序執行的");
                break;
            }

        }
    }
}

在這裏插入圖片描述
如果cpu沒有指令亂序執行存在,那麼不可能打印出同時滿足c=0,d=0,所以說cpu存在亂序問題。

最後

下一篇將接着本篇講解,會介紹內存屏障問題,及鎖升級過程、線程可見性、指令重排等相關技術。如果本篇對你有用,歡迎點贊、關注、轉載,由於水平有限,如有問題請留言。

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