天天看點

Java并發程式設計的藝術——Java并發機制的底層實作原理

目錄:

一.volatile的實作原理

二.synchronized的實作原理與應用

二.原子操作的實作原理

一.volatile的定義與實作

Java語言提供了一種稍弱的同步機制,即volatile變量,用來確定将變量的更新操作通知到其他線程。當把變量聲明為volatile類型後,編譯器與運作時都會注意到這個變量是共享的,是以不會将該變量上的操作與其他記憶體操作一起重排序。volatile變量不會被緩存在寄存器或者對其他處理器不可見的地方,是以在讀取volatile類型的變量時總會傳回最新寫入的值。

在通路volatile變量時不會執行加鎖操作,是以也就不會使執行線程阻塞,是以volatile變量是一種比sychronized關鍵字更輕量級的同步機制。

Java并發程式設計的藝術——Java并發機制的底層實作原理
Java并發程式設計的藝術——Java并發機制的底層實作原理

當對非 volatile 變量進行讀寫的時候,每個線程先從記憶體拷貝變量到CPU緩存中。如果計算機有多個CPU,每個線程可能在不同的CPU上被處理,這意味着每個線程可以拷貝到不同的 CPU cache 中。

而聲明變量是 volatile 時,JVM 保證了每次讀變量都從記憶體中讀,跳過 CPU cache 這一步。

1.1 volatile 定義

1.1.1、volatite特性

· 可見性

能夠保證線程可見性,當一個線程修改共享變量時,能夠保證對另外一個線程可見性。

· 順序性

程式執行程式按照代碼的先後順序執行。

· 防止指令重排序

通過插入記憶體屏障在cpu層面防止亂序執行。

有volatile修飾的變量,指派後多執行了一個“load addl $0x0, (%esp)”操作,這個操作相當于一個記憶體屏障(指令重排序時不能把後面的指令重排序到記憶體屏障之前的位置),隻有一個CPU通路記憶體時,并不需要記憶體屏障;(什麼是指令重排序:是指CPU采用了允許将多條指令不按程式規定的順序分開發送給各相應電路單元處理)。

1.1.2、volatile可見性

public class VolatileTest extends Thread {

    /**
     * volatile關鍵字底層通過 彙編 lock指令字首 強制修改值,
     * 并立即重新整理到主記憶體中,另外一個線程可以馬上看到重新整理的主記憶體資料
     */
    private static volatile boolean FLAG = true;

    @Override
    public void run() {
        while (FLAG) {
            try {
                TimeUnit.MILLISECONDS.sleep(300);
                System.out.println("==== test volatile ====");
            } catch (InterruptedException ignore) {
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new VolatileTest().start();
        TimeUnit.SECONDS.sleep(1);
        FLAG = false;
    }
}           

1.1.3、volatile 的實作原理

在 Java 中我們可以直接使用 volatile 關鍵字,被 volatile 變量修飾的共享變量進行寫操作的時候會多生成一行彙編代碼,這行代碼使用了 Lock 指令。Lock 指令在多核處理器下會引發兩件事情:

· 1、将目前處理器緩存行的資料寫回到系統記憶體。

· 2、這個寫回記憶體的操作會使在其他 CPU 裡緩存了該記憶體位址的資料無效。

為了提高處理速度,處理器不直接和記憶體進行通信,而是先将系統記憶體的資料讀到内部緩存後再進行操作,但操作完後不知道何時會寫到記憶體。如果對聲明了 volatile 的變量進行寫操作,JVM 就會向處理器發送一條 Lock 字首的指令,将這個變量所在緩存行的資料寫回到系統記憶體。但其他處理器的緩存還是舊值,為了保證各個處理器的緩存是一緻的,每個處理器會通過嗅探在總線上傳播的資料來檢查自己緩存的值是不是過期了。當處理器發現自己緩存行對應的記憶體位址被修改,就會将目前處理器的緩存行設定為無效狀态,當處理器對這個資料進行修改操作時,會重新從系統記憶體中把資料讀到處理器緩存裡。

1.1.4、volatile 性能

volatile 的讀性能消耗與普通變量幾乎相同,但是寫操作稍慢,因為它需要在本地代碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。

1.1.5、volatile 的應用

volatile 在多處理器開發中保證了共享變量的可見性。可見性的意思是當一個線程修改一個共享變量時,另外一個線程能立即讀取到修改過後的值。

1.2、volatile原理分析

1.2.1、CPU多核硬體架構剖析

CPU的運作速度非常快,而對磁盤的讀寫IO速度卻很慢,為了解決這個問題,有了記憶體的誕生;而CPU的速度與記憶體的讀寫速度之比仍然有着100 : 1的差距,為了解決這個問題,CPU又在記憶體與CPU之間建立了多級别緩存:寄存器、L1、L2、L3三級緩存。

Java并發程式設計的藝術——Java并發機制的底層實作原理

1.2.2、産生可見性的原因

因為我們CPU讀取主記憶體共享變量的資料時候,效率是非常低,是以對每個CPU設定對應的高速緩存 L1、L2、L3 緩存我們共享變量主記憶體中的副本。

相當于每個CPU對應共享變量的副本,副本與副本之間可能會存在一個資料不一緻性的問題。比如線程B修改的某個副本值,線程A的副本可能不可見,導緻可見性問題。

1.2.3、JMM記憶體模型

Java記憶體模型定義的是一種抽象的概念,定義屏蔽java程式對不同的作業系統的記憶體通路差異。

主記憶體:存放我們共享變量的資料

工作記憶體:每個CPU對共享變量(主記憶體)的副本。堆+方法區

Java并發程式設計的藝術——Java并發機制的底層實作原理

1.2.4、JMM八大同步規範

· 1、lock(鎖定):作用于 主記憶體的變量,把一個變量标記為一條線程獨占狀态。

· 2、unlock(解鎖):作用于 主記憶體的變量,把一個處于鎖定狀态的變量釋放出來,釋放後的變量才可以被其他線程鎖定。

· 3、read(讀取):作用于 主記憶體的變量,把一個變量值從主記憶體傳輸到線程的 工作記憶體中,以便随後的load動作使用。

· 4、load(載入):作用于 工作記憶體的變量,它把read操作從主記憶體中得到的變量值放入工作記憶體的變量副本中。

· 5、use(使用):作用于 工作記憶體的變量,把工作記憶體中的一個變量值傳遞給執行引擎。

· 6、assign(指派):作用于 工作記憶體的變量,它把一個從執行引擎接收到的值賦給工作記憶體的變量。

· 7、store(存儲):作用于 工作記憶體的變量,把工作記憶體中的一個變量的值傳送到 主記憶體中,以便随後的write的操作。

· 8、write(寫入):作用于 工作記憶體的變量,它把store操作從工作記憶體中的一個變量的值傳送到 主記憶體的變量中。

Java并發程式設計的藝術——Java并發機制的底層實作原理

JMM對這八種指令的使用,制定了如下規則:

· 不允許read和load、store和write操作之一單獨出現。即使用了read必須load,使用了store必須write。

· 不允許線程丢棄他最近的assign操作,即工作變量的資料改變了之後,必須告知主存。

· 不允許一個線程将沒有assign的資料從工作記憶體同步回主記憶體。

· 一個新的變量必須在主記憶體中誕生,不允許工作記憶體直接使用一個未被初始化的變量。就是對變量實施use、store操作之前,必須經過assign和load操作。

· 一個變量同一時間隻有一個線程能對其進行lock。多次lock後,必須執行相同次數的unlock才能解鎖。

· 如果對一個變量進行lock操作,會清空所有工作記憶體中此變量的值,在執行引擎使用這個變量前,必須重新load或assign操作初始化變量的值。

· 如果一個變量沒有被lock,就不能對其進行unlock操作。也不能unlock一個被其他線程鎖住的變量。

· 對一個變量進行unlock操作之前,必須把此變量同步回主記憶體。

JMM對這八種操作規則和對volatile的一些特殊規則,就能确定哪裡操作是線程安全,哪些操作是線程不安全的了。但是這些規則實在複雜,很難在實踐中直接分析。是以一般我們也不會通過上述規則進行分析。更多的時候,使用java的happen-before規則來進行分析。

Happen-Before(先行發生規則)

在正常的開發中,如果我們通過上述規則來分析一個并發程式是否安全,估計腦殼會很疼。因為更多時候,我們是分析一個并發程式是否安全,其實都依賴Happen-Before原則進行分析。Happen-Before被翻譯成先行發生原則,意思就是當A操作先行發生于B操作,則在發生B操作的時候,操作A産生的影響能被B觀察到,“影響”包括修改了記憶體中的共享變量的值、發送了消息、調用了方法等。

Happen-Before的規則有以下幾條

· 程式次序規則(Program Order Rule):在一個線程内,程式的執行規則跟程式的書寫規則是一緻的,從上往下執行。

· 管程鎖定規則(Monitor Lock Rule):一個Unlock的操作肯定先于下一次Lock的操作。這裡必須是同一個鎖。同理我們可以認為在synchronized同步同一個鎖的時候,鎖内先行執行的代碼,對後續同步該鎖的線程來說是完全可見的。

· volatile變量規則(volatile Variable Rule):對同一個volatile的變量,先行發生的寫操作,肯定早于後續發生的讀操作。

· 線程啟動規則(Thread Start Rule):Thread對象的start()方法先行發生于此線程的每一個動作。

· 線程中止規則(Thread Termination Rule):Thread對象的中止檢測(如:Thread.join(),Thread.isAlive()等)操作,必須晚于線程中所有操作。

· 線程中斷規則(Thread Interruption Rule):對線程的interruption()調用,先于被調用的線程檢測中斷事件(Thread.interrupted())的發生。

· 對象中止規則(Finalizer Rule):一個對象的初始化方法先于一個方法執行Finalizer()方法。

· 傳遞性(Transitivity):如果操作A先于操作B、操作B先于操作C,則操作A先于操作C。

以上就是Happen-Before中的規則。通過這些條件的判定,仍然很難判斷一個線程是否能安全執行,畢竟在我們的時候線程安全多數依賴于工具類的安全性來保證。想提高自己對線程是否安全的判斷能力,必然需要了解所使用的架構或者工具的實作,并積累線程安全的經驗。

1.2.5、volatile彙編lock指令字首

· 1、将目前處理器緩存行資料立刻寫入主記憶體中。

· 2、寫的操作會觸發總線嗅探機制,同步更新主記憶體的值。

1.2.6、通過Idea工具檢視java彙編指令

1. jdk安裝包\jre\bin\server 放入 hsdis-amd64.dll

2. idea 配置 VM options, 最後一個參數 xxxxx. 就是一個我們的需要檢視彙編的class類

-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileTest.*

3 .檢視結果 ,會發現在 volatile 關鍵字 修飾的變量,在寫操作時,對應的彙編指令,都有一個lock指令字首

Java并發程式設計的藝術——Java并發機制的底層實作原理
Java并發程式設計的藝術——Java并發機制的底層實作原理

1.3、volatile的底層實作原理

通過彙編lock字首指令觸發底層鎖的機制,鎖的機制兩種:總線鎖/MESI緩存一緻性協定,主要幫助我們解決多個不同cpu之間緩存之間資料同步的問題。

1.3.1、總線鎖

當一個cpu(線程)通路到我們主記憶體中的資料時候,往總線總發出一個Lock鎖的信号,其他的線程不能夠對該主記憶體做任何操作,變為阻塞狀态。該模式,存在非常大的缺陷,就是将并行的程式,變為串行,沒有真正發揮出cpu多核的好處。

1.3.2、MESI協定

· 1、M 修改 (Modified) 這行資料有效,資料被修改了,和主記憶體中的資料不一緻,資料隻存在于本Cache中。

· 2、E 獨享、互斥 (Exclusive) 這行資料有效,資料和主記憶體中的資料一緻,資料隻存在于本Cache中。

· 3、S 共享 (Shared) 這行資料有效,資料和主記憶體中的資料一緻,資料存在于很多Cache中。

· 4、I 無效 (Invalid) 這行資料無效。

E: 獨享:

當隻有一個cpu線程的情況下,cpu副本資料與主記憶體資料如果,保持一緻的情況下,則該cpu狀态為E狀态 獨享。

Java并發程式設計的藝術——Java并發機制的底層實作原理

S: 共享:

在多個cpu線程的情況了下,每個cpu副本之間資料如果保持一緻的情況下,則目前cpu狀态為S。

Java并發程式設計的藝術——Java并發機制的底層實作原理

M: 修改:

如果目前cpu副本資料如果與主記憶體中的資料不一緻的情況下,則目前cpu狀态為M。

I: 無效:

總線嗅探機制發現 狀态為m的情況下,則會将該cpu改為i狀态 無效。

如果狀态是M的情況下,則使用嗅探機制通知其他的CPU工作記憶體副本狀态為I無效狀态,則重新整理主記憶體資料到本地中,進而多核cpu資料的一緻性。

Java并發程式設計的藝術——Java并發機制的底層實作原理

該cpu緩存主動擷取主記憶體的資料同步更新。

Java并發程式設計的藝術——Java并發機制的底層實作原理

總線:維護解決cpu高速緩存副本資料之間一緻性問題。

1.4、volatile不能保證原子性原因

public class VolatileTest extends Thread {

    private static volatile int count = 0;
    public static void add() {
        count++;
    }

    public static void main(String[] args) throws InterruptedException {
        ArrayList<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            Thread test = new Thread(() -> {
                for (int k = 0; k < 1000; k++) {
                    add();
                }
            });

            threads.add(test);

            test.start();
        }

        threads.forEach(v -> {
            try {
                v.join();
            } catch (InterruptedException ignore) {
            }
        });
        System.out.println("<><><><> count: " + count);
    }
}           

volatile為了能夠保證資料的可見性,但是不能夠保證原子性,及時的将工作記憶體的資料重新整理主記憶體中,導緻其他的工作記憶體的資料變為無效狀态,其他工作記憶體做的count++操作等于就是無效丢失了,這是為什麼我們加上Volatile count結果在小于100000以内。

1.5、volatile存在的僞共享的問題

CPU會以緩存行的形式讀取主記憶體中資料,緩存行的大小為2的幂次數位元組,一般的情況下是為64個位元組。如果該變量共享到同一個緩存行,就會影響到整理性能。

例如:線程1修改了long類型變量A,long類型定義變量占用8個位元組,在由于緩存一緻性協定,線程2的變量A副本會失效,線程2在讀取主記憶體中的資料的時候,以緩存行的形式讀取,無意間将主記憶體中的共享變量B也讀取到記憶體中,而該主記憶體中的變量B沒有發生變化。

解決緩存行僞共享問題 ,使用緩存行填充方案避免僞共享。

@sun.misc.Contended

可以直接在類上加上該注解@sun.misc.Contended,啟動的時候需要加上該參數-XX:-RestrictContended,該方案在JDK8有效,JDK12中被優化掉了。

例如 ConcurrentHashMap中的CounterCell,就是使用了緩存行填充方案避免為共享

Java并發程式設計的藝術——Java并發機制的底層實作原理

1.6、JMM中的重排序及記憶體屏障

public class ReorderThread {
    private static int a, b, x, y;
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while (true) {
            i++;
            a = 0;
            b = 0;
            x = 0;
            y = 0;
            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    x = b;
                }
            });

            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            System.out.println("第" + i + "次(" + x + "," + y + ")");

            if (x == 0 & y == 0) {
                break;
            }
        }
    }
}           
Java并發程式設計的藝術——Java并發機制的底層實作原理

當我們的CPU寫入緩存的時候發現緩存區正在被其他cpu站有的情況下,為了能夠提高CPU處理的性能可能将後面的讀緩存指令優先執行。注意:不是随便重排序,需要遵循as-ifserial語義。

as-ifserial:不管怎麼重排序(編譯器和處理器為了提高并行的效率)單線程程式執行結果不會發生改變的。也就是我們編譯器與處理器不會對存在資料依賴的關系操作做重排序。

Java并發程式設計的藝術——Java并發機制的底層實作原理

CPU指令重排序優化的過程存在問題

as-ifserial 單線程程式執行結果不會發生改變的,但是在多核多線程的情況下,指令邏輯無法分辨因果關系,可能會存在一個亂序中心問題,導緻程式執行結果錯誤。

Java并發程式設計的藝術——Java并發機制的底層實作原理

如同上面圖,所示會出現會有機會兩個線程中,A線程執行順序1邏輯,而B線程執行順序2邏輯。

1.6.1、記憶體屏障解決重排序

處理器提供了兩個記憶體屏蔽指令,解決以上存在的問題

· 1.寫記憶體屏障:在指令後插入Stroe Barrier,能夠讓寫入緩存中的最新資料更新寫入主記憶體中,讓其他線程可見。這種強制寫入主記憶體,這種現實調用CPU就不會因為性能的考慮對指令重排序。

· 2.讀記憶體屏障:在指令前插入load Barrier ,可以讓告訴緩存中的資料失效,強制從新主記憶體加載資料強制讀取主記憶體,讓CPU緩存與主記憶體保持一緻,避免緩存導緻的一緻性問題。

1.6.2、手動插入記憶體屏障

public class ReorderThread {
    private static int a, b, x, y;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while (true) {
            i++;
            a = 0;
            b = 0;
            x = 0;
            y = 0;

            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    // 添加寫屏障
                    ReorderThread.getUnsafe().storeFence();
                    x = b;
                }
            });
            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    // 添加寫屏障
                    ReorderThread.getUnsafe().storeFence();
                    y = a;
                }
            });
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            System.out.println("第" + i + "次(" + x + "," + y + ")");
            if (x == 0 & y == 0) {
                break;
            }
        }
    }

    /**
     * 通過Unsafe 插入記憶體屏障
     *
     * @return
     */
    public static Unsafe getUnsafe() {
        try {
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            return (Unsafe) theUnsafe.get(null);
        } catch (Exception e) {
            return null;
        }

    }
}           

1.7、雙重檢驗鎖為什麼需要加上volatile

public class LazyDoubleCheckSingleton {

    public volatile static LazyDoubleCheckSingleton singleton = null;

    private LazyDoubleCheckSingleton() {

    }

    public static LazyDoubleCheckSingleton getInstance() {
        //先判斷是否存在,不存在再加鎖處理
        if (singleton == null) {
            //在同一個時刻加了鎖的那部分程式隻有一個線程可以進入
            synchronized (LazyDoubleCheckSingleton.class) {
                if (singleton == null) {
                    singleton = new LazyDoubleCheckSingleton();
                    //1、配置設定記憶體給這個對象
                    //2、初始化對象
                    //3、設定singleton指向剛配置設定的記憶體位址
                    //singleton利用volatile關鍵字防止指令重排序
                }
            }
        }
        return singleton;
    }
}           

注意:在聲明public volatile static LazyDoubleCheckSingleton singleton = null;中 ,如果去掉volatile關鍵字,我們在new操作存在重排序的問題。

getInstance() 擷取對象過程精簡為3步如下

1.配置設定對象的記憶體空間

2.調用構造函數初始化

3.将對象複制給變量

如果沒有volatile關鍵字修飾 singleton 變量,則有可能先執行将對象複制給變量,再執行調用構造函數初始化,導緻另外一個線程擷取到該對象不為空,但是該構造函數沒有初始化的半初始化對象,會導緻報錯 。就是另外一個線程拿到的是一個不完整的對象。

二. synchronized的實作原理與應用

2.1. 概述

在jdk1.6之前,synchronized是基于底層作業系統的Mutex Lock實作的,每次擷取和釋放鎖都會帶來使用者态和核心态的切換,進而增加系統的性能開銷。在鎖競争激烈的情況下,synchronized同步鎖的性能很糟糕。JDK 1.6,Java對synchronized同步鎖做了充分的優化,甚至在某些場景下,它的性能已經超越了Lock同步鎖

2.2. synchronized實作原理

synchronized 在 JVM 的實作原理是基于進入和退出管程(Monitor)對象來實作同步。但 synchronized 關鍵字實作同步代碼塊和同步方法的細節不一樣,代碼塊同步是使用 monitorenter 和 monitorexit 指令實作的,方法同步通過調用指令讀取運作時常量池中方法的 ACC_SYNCHRONIZED 标志來隐式實作的。

2.2.1 Java對象頭

在JVM中,對象在記憶體中的布局分為三塊區域:對象頭、執行個體資料和對齊填充。

Java并發程式設計的藝術——Java并發機制的底層實作原理

· 執行個體變量:存放類的屬性資料資訊,包括父類的屬性資訊,如果是數組的執行個體部分還包括數組的長度,這部分記憶體按4位元組對齊。

· 填充資料:由于虛拟機要求對象起始位址必須是8位元組的整數倍。填充資料不是必須存在的,僅僅是為了位元組對齊。

Java對象頭是synchronized實作的關鍵,synchronized用的鎖是存在Java對象頭裡的。

synchronized使用的鎖對象是存儲在Java對象頭裡的,jvm中采用2個字寬(一個字寬代表4個位元組,一個位元組8bit)來存儲對象頭(如果對象是數組則會配置設定3個字寬,多出來的1個字寬記錄的是數組長度)。其主要結構是由 Mark Word 和 Class Metadata Address 組成。

Java并發程式設計的藝術——Java并發機制的底層實作原理

其中 Mark Word 在預設情況下存儲着對象的 HashCode、分代年齡、鎖标記位等。Mark Word在不同的鎖狀态下存儲的内容不同,在32位JVM中預設狀态為下:

Java并發程式設計的藝術——Java并發機制的底層實作原理

運作期間,Mark Word裡存儲的資料随鎖标志位的變化而變化,可能存在如下4種資料。

Java并發程式設計的藝術——Java并發機制的底層實作原理

2.2.2 synchronized同步的底層實作

上面說到 JVM 基于進入和退出 Monitor 對象來實作方法同步和代碼塊同步。

Monitor(螢幕鎖)本質是依賴于底層的作業系統的 Mutex Lock(互斥鎖)來實作的。Mutex Lock 的切換需要從使用者态轉換到核心态中,是以狀态轉換需要耗費很多的處理器時間。是以synchronized是Java語言中的一個重量級操作。

下面講解 synchronized 同步代碼塊的過程。

public class SynTest{
	public int i;

    public void syncTask(){
		synchronized (this){
			i++;
		}
	}
}           

反編譯後結果如下:

D:\Desktop>javap SynTest.class
Compiled from "SynTest.java"public class SynTest {
  public int i;
  public SynTest();
  public void syncTask();
}

D:\Desktop>javap -c SynTest.class
Compiled from "SynTest.java"public class SynTest {
  public int i;

  public SynTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void syncTask();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter
       4: aload_0
       5: dup
       6: getfield      #7                  // Field i:I
       9: iconst_1
      10: iadd
      11: putfield      #7                  // Field i:I
      14: aload_1
      15: monitorexit
      16: goto          24
      19: astore_2
      20: aload_1
      21: monitorexit
      22: aload_2
      23: athrow
      24: return
    Exception table:
       from    to  target type
           4    16    19   any
          19    22    19   any
}           

關注 monitorenter 和 monitorexit:

3: monitorenter

//省略

15: monitorexit

16: goto 24

//省略

21: monitorexit

從位元組碼中可知同步語句塊的實作使用的是 monitorenter 和 monitorexit 指令

我們再來看看同步方法的過程:

public class SynTest{
	public int i;

    public synchronized void syncTask(){
		i++;
	}
}           

反編譯:javap -verbose -p SynTest

Classfile /D:/Desktop/SynTest.class
  Last modified 2020年4月2日; size 278 bytes
  SHA-256 checksum 0e7a02cd496bdaaa6865d5c7eb0b9f4bfc08a5922f13a585b5e1f91053bb6572
  Compiled from "SynTest.java"
public class SynTest
  minor version: 0
  major version: 57
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #8                          // SynTest
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // SynTest.i:I
   #8 = Class              #10            // SynTest
   #9 = NameAndType        #11:#12        // i:I
  #10 = Utf8               SynTest
  #11 = Utf8               i
  #12 = Utf8               I
  #13 = Utf8               Code
  #14 = Utf8               LineNumberTable
  #15 = Utf8               syncTask
  #16 = Utf8               SourceFile
  #17 = Utf8               SynTest.java
{
  public int i;
    descriptor: I
    flags: (0x0001) ACC_PUBLIC

  public SynTest();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public synchronized void syncTask();
    descriptor: ()V
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #7                  // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #7                  // Field i:I
        10: return
      LineNumberTable:
        line 5: 0
        line 6: 10
}
SourceFile: "SynTest.java"           

注意: flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED

JVM可以從方法常量池中的方法表結構(method_info Structure) 中的 ACC_SYNCHRONIZED 通路标志區分一個方法是否同步方法。當方法調用時,調用指令将會檢查方法的 ACC_SYNCHRONIZED 通路标志是否被設定,如果設定了,執行線程将先持有 monitor, 然後再執行方法,最後再方法完成(無論是正常完成還是非正常完成)時釋放 monitor。

2.3. 同步過程(鎖更新過程)

上面講解了,synchronized 在開始的時候是依靠作業系統的互斥鎖來實作的,是個重量級操作,為了減少獲得鎖和釋放鎖帶來的性能消耗,在 JDK 1.6中,引入了偏向鎖和輕量級鎖。鎖一共有4中狀态:無鎖狀态、偏向鎖狀态、輕量級鎖狀态和重量級鎖狀态,這幾種狀态會随着競争情況逐漸更新,但不能降級,目的是為了提高鎖和釋放鎖的效率。

2.3.1 偏向鎖

大部分情況下,鎖不存在多線程競争,偏向鎖就是為了在隻有一個線程執行同步塊時提高性能。

偏向鎖的核心思想是,如果一個線程獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變為偏向鎖結構,當這個線程再次請求鎖時,無需再做任何同步操作,即擷取鎖的過程,這樣就省去了大量有關鎖申請的操作,進而也就提供程式的性能。

獲得過程:

1. 通路Mark Word中偏向鎖的辨別是否設定成1,鎖标志位是否為 01——确認為可偏向狀态。

2. 如果為可偏向狀态,則測試 Thread ID 是否指向目前線程,如果是,執行同步代碼。

3. 如果不是指向目前線程,使用 CAS 競争鎖,如果競争成功,則将 Mark Word 中 Thread ID 設定為目前線程ID,并在棧幀中鎖記錄(Lock Record)裡存儲目前線程ID。

4. 如果CAS擷取偏向鎖失敗,則表示有競争。當到達全局安全點(safepoint,在這個時間點上沒有正在執行的位元組碼)時,會首先暫停擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否活着(因為可能持有偏向鎖的線程已經執行完畢,但是該線程并不會主動去釋放偏向鎖)。

5. 如果線程不處于活動狀态,則将對象頭設定成無鎖狀态(标志位為“01”),然後重新偏向新的線程;如果線程仍然活着,撤銷偏向鎖後更新到輕量級鎖狀态(标志位為“00”),此時輕量級鎖由原持有偏向鎖的線程持有,繼續執行其同步代碼,而正在競争的線程會進入自旋等待獲得該輕量級鎖。

鎖釋放過程:

其實就是上面鎖獲得過程的四五步。

偏向鎖使用了一種等到競争出現才釋放鎖的機制,是以當其他線程嘗試競争偏向鎖時,持有偏向鎖的線程才會釋放鎖。

偏向鎖的撤銷,需要等待全局安全點(這個時間點沒有正在執行的位元組碼)。

1.到全局安全點後,先暫停擁有偏向鎖的線程,檢查該線程是否或者。

2.不活動或已經退出代碼塊,則對象頭設定為無鎖狀态,然後重新偏向新的線程。

3.如果仍然活着,則周遊線程棧中所有的 Lock Record,如果能找到對應的 Lock Recor d 說明偏向的線程還在執行同步代碼塊中的代碼。需要更新為輕量級鎖,直接修改偏向線程棧中的Lock Record。

4.此時輕量級鎖由原持有偏向鎖的線程持有,繼續執行其同步代碼,而正在競争的線程會進入自旋等待獲得該輕量級鎖。

Java并發程式設計的藝術——Java并發機制的底層實作原理

在書中這一部分是這樣說的:

當一個線程通路同步塊并獲得鎖時,會在對象頭和棧幀中鎖記錄存儲鎖偏向的線程ID,以後該線程在進入退出同步塊時不需要進行CAS操作來加鎖和解鎖,隻需要簡單測試一下對象頭的 Mark Word 裡是否存儲着指向目前線程的偏向鎖。如果成功,表示線程已經獲得了鎖。如果失敗,則需要再測試 Mark Word 中偏向鎖的辨別是否設定為 1(表示目前是偏向鎖),如果沒有設定,則使用 CAS 競争鎖,如果設定了,則嘗試 CAS 将對象頭的偏向鎖指向目前線程。

個人覺得這一部分書中似乎稍微有點出入,我檢視了很多部落格,正常按邏輯分析的話,應該也是先判斷鎖标志位,判斷出現在鎖的狀态,而不是先判斷鎖的線程ID是否指向自己。

2.3.2 輕量級鎖

鎖獲得過程:

1. 如果鎖對象不是偏向模式或已經偏向其他線程,這時候會建構一個無鎖狀态的mark word設定到Lock Record中去,我們稱Lock Record中存儲對象mark word的字段叫 Displaced Mark Word。

2. 拷貝對象頭中的Mark Word複制到鎖記錄中。然後虛拟機将使用 CAS 操作嘗試将對象的 Mark Word 更新為指向 Lock Record 的指針。

3. 如果更新成功,目前線程獲得鎖,執行同步代碼。如果更新失敗,目前線程便嘗試使用自旋來擷取鎖。

4. 當自旋超過一定的次數,或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖膨脹為重量級鎖,重量級鎖使除了擁有鎖的線程以外的線程都阻塞。

鎖釋放過程:

1. 通過CAS操作嘗試把線程中複制的Displaced Mark Word對象替換目前的Mark Word。

2. 如果替換成功,整個同步過程就完成了。

3.如果替換失敗,說明有其他線程嘗試過擷取該鎖(此時鎖已膨脹),那就要在釋放鎖的同時,喚醒被挂起的線程。

Java并發程式設計的藝術——Java并發機制的底層實作原理

2.3.3 重量級鎖

重量級鎖的上鎖過程參考上面步驟 4 ,輕量級鎖膨脹為重量級鎖,Mark Word的鎖标記位更新為10,Mark Word 指向互斥量(重量級鎖)。

Synchronized 的重量級鎖是通過對象内部的一個叫做螢幕鎖(monitor)來實作的,螢幕鎖本質又是依賴于底層的作業系統的 Mutex Lock(互斥鎖)來實作的,文章開頭有講解。而作業系統實作線程之間的切換需要從使用者态轉換到核心态,這個成本非常高,狀态之間的轉換需要相對比較長的時間,這就是為什麼Synchronized效率低的原因。

2.4. synchronized的三種應用方式

Java中每一個對象都可以作為鎖,具體表現為如下三種形式:

1. 對于執行個體方法,也就是普通同步方法,鎖是目前執行個體對象。

2. 對于靜态同步方法,鎖是目前類的 class 對象。

3. 對于同步方法塊,鎖是 synchronized 括号裡配置的對象。

簡單地說,synchronized修飾,表現為兩種鎖,一種是對調用該方法的對象加鎖,俗稱對象鎖或執行個體鎖,另一種是對該類對象加鎖,俗稱類鎖。

2.4.1 對象鎖

形象的了解:

Java 中每個對象都有一個鎖,并且是唯一的。假設配置設定的一個對象空間,裡面有多個方法,相當于空間裡面有多個小房間,如果我們把所有的小房間都加鎖,因為這個對象隻有一把鑰匙,是以同一時間隻能有一個人打開一個小房間,然後用完了還回去,再由 JVM 去配置設定下一個獲得鑰匙的人。

這樣的話,對于一些面試問題就好解決了。

1.同一個對象在兩個線程中分别通路該對象的兩個同步方法

2.不同對象在兩個線程中調用同一個同步方法

第一個問題,因為鎖針對的是對象,當對象調用一個synchronized方法時,其他同步方法需要等待其執行結束并釋放鎖後才能執行。

第二個問題,因為是兩個對象,鎖針對的是對象,并不是方法,是以可以并發執行,不會互斥。形象的來說就是因為我們每個線程在調用方法的時候都是new 一個對象,那麼就會出現兩個空間,兩把鑰匙。

2.4.2 類鎖

類對象隻有一個,可以了解為任何時候都隻有一個空間,裡面有N個房間,一把鎖,一把鑰匙。

問題:

1. 用類直接在兩個線程中調用兩個不同的同步方法

2. 用一個類的靜态對象在兩個線程中調用靜态方法或非靜态方法

3. 一個對象在兩個線程中分别調用一個靜态同步方法和一個非靜态同步方法

因為對靜态對象加鎖實際上對類(.class)加鎖,類對象隻有一個,可以了解為任何時候都隻有一個空間,裡面有N個房間,一把鎖,是以房間(同步方法)之間一定是互斥的。因為是一個對象調用,是以,1、2都會互斥。

第三個問題,因為雖然是一個對象調用,但是兩個方法的鎖類型不同,調用的靜态方法實際上是類對象在調用,即這兩個方法産生的并不是同一個對象鎖,是以不會互斥,會并發執行。

2.5. synchronized 其他鎖優化

2.5.1 鎖消除

鎖消除即删除不必要的加鎖操作。虛拟機即時編輯器在運作時,對一些“代碼上要求同步,但是被檢測到不可能存在共享資料競争”的鎖進行消除。

2.5.2 鎖粗化

如果一系列的連續操作都對同一個對象反複加鎖和解鎖,甚至加鎖操作是出現在循環體中的,那即使沒有出現線程競争,頻繁地進行互斥同步操作也會導緻不必要的性能損耗。

如果虛拟機檢測到有一串零碎的操作都是對同一對象的加鎖,将會把加鎖同步的範圍擴充(粗化)到整個操作序列的外部。

2.5.3 自旋鎖與自适應自旋鎖

引入自旋鎖的原因:

互斥同步對性能最大的影響是阻塞的實作,因為挂起線程和恢複線程的操作都需要轉入核心态中完成,這些操作給系統的并發性能帶來很大的壓力。同時虛拟機的開發團隊也注意到在許多應用上面,共享資料的鎖定狀态隻會持續很短一段時間,為了這一段很短的時間頻繁地阻塞和喚醒線程是非常不值得的。

自旋鎖:

讓該線程執行一段無意義的忙循環(自旋)等待一段時間,不會被立即挂起(自旋不放棄處理器額執行時間),看持有鎖的線程是否會很快釋放鎖。自旋鎖在JDK 1.4.2中引入,預設關閉,但是可以使用 -XX:+UseSpinning 開啟;在JDK1.6中預設開啟。

自旋鎖的缺點:

自旋等待不能替代阻塞,雖然它可以避免線程切換帶來的開銷,但是它占用了處理器的時間。如果持有鎖的線程很快就釋放了鎖,那麼自旋的效率就非常好;反之,自旋的線程就會白白消耗掉處理器的資源,它不會做任何有意義的工作,這樣反而會帶來性能上的浪費。

自适應的自旋鎖:

JDK1.6 引入自适應的自旋鎖,自适應就意味着自旋的次數不再是固定的,它是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀态來決定:如果在同一個鎖的對象上,自旋等待剛剛成功獲得過鎖,并且持有鎖的線程正在運作中,那麼虛拟機就會認為這次自旋也很有可能再次成功,進而它将允許自旋等待持續相對更長的時間。如果對于某個鎖,自旋很少成功獲得過,那在以後要擷取這個鎖時将可能省略掉自旋過程,以避免浪費處理器資源。簡單來說,就是線程如果自旋成功了,則下次自旋的次數會更多,如果自旋失敗了,則自旋的次數就會減少。

2.6. synchronized 的可重入性

從互斥鎖的設計上來說,當一個線程試圖操作一個由其他線程持有的對象鎖的臨界資源時,将會處于阻塞狀态,但當一個線程再次請求自己持有對象鎖的臨界資源時,這種情況屬于重入鎖,請求将會成功,在 java 中 synchronized 是基于原子性的内部鎖機制,是可重入的,是以在一個線程調用 synchronized 方法的同時在其方法體内部調用該對象另一個 synchronized 方法,也就是說一個線程得到一個對象鎖後再次請求該對象鎖,是允許的,這就是 synchronized 的可重入性。

7.synchronized總結

synchronized特點: 保證記憶體可見性、操作原子性。在經過jdk6的優化,synchronized 的性能其實不必 JVM 實作的 Reentrantlock 差,甚至有的時候比它更優秀,這也是 Java concurrent 包下很多類的原理都是基于 synchronized 實作的原因。

Java并發程式設計的藝術——Java并發機制的底層實作原理

三.原子操作的實作原理

原子(atomic)本意是“不能被進一步分割的最小粒子”,而原子操作(atomic operation)意為“不可被中斷的一個或一系列操作”。在多處理器上實作原子操作就變得有點複雜。讓我們一起來聊一聊在Intel處理器和Java裡是如何實作原子操作的。

3.1 術語定義

在了解原子操作的實作原理前,先要了解一下相關的術語,如表2-7所示。

表2-7 CPU術語定義

Java并發程式設計的藝術——Java并發機制的底層實作原理

3.2 處理器如何實作原子操作

32位IA-32處理器使用基于對緩存加鎖或總線加鎖的方式來實作多處理器之間的原子操作。首先處理器會自動保證基本的記憶體操作的原子性。處理器保證從系統記憶體中讀取或者寫入一個位元組是原子的,意思是當一個處理器讀取一個位元組時,其他處理器不能通路這個位元組的記憶體位址。Pentium 6和最新的處理器能自動保證單處理器對同一個緩存行裡進行16/32/64位的操作是原子的,但是複雜的記憶體操作處理器是不能自動保證其原子性的,比如跨總線寬度、

跨多個緩存行和跨頁表的通路。但是,處理器提供總線鎖定和緩存鎖定兩個機制來保證複雜記憶體操作的原子性。

(1) 使用總線鎖保證原子

第一個機制是通過總線鎖保證原子性。如果多個處理器同時對共享變量進行讀改寫操作

(i++就是經典的讀改寫操作),那麼共享變量就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之後共享變量的值會和期望的不一緻。舉個例子,如果i=1,我們進行兩次i++操作,我們期望的結果是3,但是有可能結果是2,如圖2-3所示。

Java并發程式設計的藝術——Java并發機制的底層實作原理

圖2-3 結果對比

原因可能是多個處理器同時從各自的緩存中讀取變量i,分别進行加1操作,然後分别寫入系統記憶體中。那麼,想要保證讀改寫共享變量的操作是原子的,就必須保證CPU1讀改寫共享變量的時候,CPU2不能操作緩存了該共享變量記憶體位址的緩存。

處理器使用總線鎖就是來解決這個問題的。所謂總線鎖就是使用處理器提供的一個LOCK#信号,當一個處理器在總線上輸出此信号時,其他處理器的請求将被阻塞住,那麼該處理器可以獨占共享記憶體。

(2) 使用緩存鎖保證原子性

第二個機制是通過緩存鎖定來保證原子性。在同一時刻,我們隻需保證對某個記憶體位址的操作是原子性即可,但總線鎖定把CPU和記憶體之間的通信鎖住了,這使得鎖定期間,其他處理器不能操作其他記憶體位址的資料,是以總線鎖定的開銷比較大,目前處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優化。

頻繁使用的記憶體會緩存在處理器的L1、L2和L3高速緩存裡,那麼原子操作就可以直接在處理器内部緩存中進行,并不需要聲明總線鎖,在Pentium 6和目前的處理器中可以使用“緩存鎖定”的方式來實作複雜的原子性。所謂“緩存鎖定”是指記憶體區域如果被緩存在處理器的緩存行中,并且在Lock操作期間被鎖定,那麼當它執行鎖操作回寫到記憶體時,處理器不在總線上聲言LOCK#信号,而是修改内部的記憶體位址,并允許它的緩存一緻性機制來保證操作的原子 性,因為緩存一緻性機制會阻止同時修改由兩個以上處理器緩存的記憶體區域資料,當其他處理器回寫已被鎖定的緩存行的資料時,會使緩存行無效,在如圖2-3所示的例子中,當CPU1修改緩存行中的i時使用了緩存鎖定,那麼CPU2就不能同時緩存i的緩存行。

但是有兩種情況下處理器不會使用緩存鎖定。

第一種情況是:當操作的資料不能被緩存在處理器内部,或操作的資料跨多個緩存行

(cache line)時,則處理器會調用總線鎖定。

第二種情況是:有些處理器不支援緩存鎖定。對于Intel 486和Pentium處理器,就算鎖定的記憶體區域在處理器的緩存行中也會調用總線鎖定。

針對以上兩個機制,我們通過Intel處理器提供了很多Lock字首的指令來實作。例如,位測試和修改指令:BTS、BTR、BTC;交換指令XADD、CMPXCHG,以及其他一些操作數和邏輯指令(如ADD、OR)等,被這些指令操作的記憶體區域就會加鎖,導緻其他處理器不能同時通路它。

3.3 Java如何實作原子操作

在Java中可以通過鎖和循環CAS的方式來實作原子操作。

3.3.1 使用循環CAS實作原子操作

JVM中的CAS操作正是利用了處理器提供的CMPXCHG指令實作的。自旋CAS實作的基本思路就是循環進行CAS操作直到成功為止,以下代碼實作了一個基于CAS線程安全的計數器方法safeCount和一個非線程安全的計數器count。

private AtomicInteger atomicI = new AtomicInteger(0);
    private int i = 0;

    public static void main(String[] args) {
        final Counter cas = new Counter();
        List<Thread> ts = new ArrayList<Thread>(600);
        long start = System.currentTimeMillis();
        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        cas.count();
                        cas.safeCount();
                    }
                }
            });
            ts.add(t);
        }
        for (Thread t : ts) {
            t.start();
        }
// 等待所有線程執行完成
        for (Thread t : ts) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(cas.i);
        System.out.println(cas.atomicI.get());
        System.out.println(System.currentTimeMillis() - start);
    }

    /**
     * 使用CAS實作線程安全計數器
     */
    private void safeCount() {
        for (; ; ) {
            int i = atomicI.get();
            boolean suc = atomicI.compareAndSet(i, ++i);
            if (suc) {
                break;
            }
        }
    }

    /**
     * 非線程安全計數器
     */
    private void count() {
        i++;
    }
}           

從Java 1.5開始,JDK的并發包裡提供了一些類來支援原子操作,如AtomicBoolean(用原子方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。這些原子包裝類還提供了有用的工具方法,比如以原子的方式将目前值自增1和自減1。

3.3.2 CAS實作原子操作的三大問題

在Java并發包中有一些并發架構也使用了自旋CAS的方式來實作原子操作,比如LinkedTransferQueue類的Xfer方法。CAS雖然很高效地解決了原子操作,但是CAS仍然存在三大問題。ABA問題,循環時間長開銷大,以及隻能保證一個共享變量的原子操作。

1) ABA問題。因為CAS需要在操作值的時候,檢查值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本号。在變量前面追加上版本号,每次變量更新的時候把版本号加1,那麼A→B→A就會變成1A→2B→3A。從Java 1.5開始,JDK的Atomic包裡提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法的作用是首先檢查目前引用是否等于預期引用,并且檢查目前标志是否等于預期标志,如果全部相等,則以原子方式将該引用和該标志的值設定為給定的更新值。

public boolean compareAndSet(
            V	expectedReference,	// 預期引用
            V	newReference,	// 更新後的引用
            int	expectedStamp,	// 預期标志
            int	newStamp	// 更新後的标志
    )           

2) 循環時間長開銷大。自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支援處理器提供的pause指令,那麼效率會有一定的提升。pause指令有兩個作用:第一,它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決于具體實作的版本,在一些處理器上延遲時間是零;第二,它可以避免在退出循環的時候因記憶體順序沖突(Memory Order Violation)而引起CPU流水線被清空(CPU Pipeline Flush),進而提高CPU的執行效率。

3) 隻能保證一個共享變量的原子操作。當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖。還有一個取巧的辦法,就是把多個共享變量合并成一個共享變量來操作。比如,有兩個共享變量i=2,j=a,合并一下ij=2a,然後用CAS來操作ij。從Java 1.5開始, JDK提供了AtomicReference類來保證引用對象之間的原子性,就可以把多個變量放在一個對象裡來進行CAS操作。

3.3.3 使用鎖機制實作原子操作

鎖機制保證了隻有獲得鎖的線程才能夠操作鎖定的記憶體區域。JVM内部實作了很多種鎖機制,有偏向鎖、輕量級鎖和互斥鎖。有意思的是除了偏向鎖,JVM實作鎖的方式都用了循環CAS,即當一個線程想進入同步塊的時候使用循環CAS的方式來擷取鎖,當它退出同步塊的時候使用循環CAS釋放鎖。

繼續閱讀