Java併發編程之Java內存模型

1. 底層原理

1.1 JVM內存結構 VS JMM內存模型

  1. JVM內存結構和JVM的運行區域有關,包括堆、方法區、虛擬機棧、本地方法棧、程序計數器
    1. 堆:線程共享,new出來的實例對象;
    2. 虛擬機棧:線程私有,基本數據類型以及對象的引用地址;
    3. 方法區:線程共享,static靜態變量,類信息(方法代碼,變量名,方法名,訪問權限,返回值),常量,永久引用(static修飾的類);
    4. 本地方法棧:native方法;
    5. 程序計數器:程序的位置,行號數;
  2. JMM內存模型
    1. JMM是一種規範,防止在不同的虛擬機上運行結果不一樣,可以更方便地開發出多線程程序
    2. volatile、synchronized、lock等的原理都是JMM,如果沒有JMM,必須手動指定什麼時候同步

2. 重排序

public class ReOrder {

    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                a = 1;
                x = b;
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                b = 1;
                y = a;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

如上代碼所示,正常情況下,x,y有以下三種結果:

  1. t1執行完t2再執行:x=0,y=1
  2. t2執行完t1再執行:x=1,y=0
  3. t1和t2各執行一半:x=1,y=1

那麼會不會出現x=0,y=0的結果呢?只有x=b在a=1之前,或者y=a在b=1之前執行纔會發生這種情況,這種情況一旦發生,就說明發生了指令重排序。

  • 重排序的定義:當指令的執行順序和Java代碼的執行順序不一樣,就說明發生了指令重排序。
  • 重排序的好處:提升處理速度。
  • 重排序發生的2種情況:
    1. 編譯器優化(JVM優化),尤其發生數據沒有依賴關係的情況,更有可能會發生指令重排序;
    2. CPU指令重排序:就算編譯器不重排序,CPU也可能會發生指令重排序;

2. 可見性

2.1 可見性問題演示

代碼演示:

public class Visibility {

    private static int a = 1, b = 2;

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                a = 3;
                b = a;
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(b+ "," + a);
            }
        });
        t1.start();
        t2.start();
    }
}

正常情況下,會發生一下三種情況:

  1. t2先執行:a = 1, b = 2
  2. t1先執行:a = 3, b = 3
  3. t1執行一半給t2執行:a = 3, b = 2

但是由於內存可見性問題,也可能出現第四種情況:a = 1, b = 3,爲什麼會發生這種情況?
由於t1線程執行a=3,b=a後,b被寫回到主內存,而a還沒來得及寫回到主內存,此時,t2已經在主內存中讀取了a和b的值, 就造成了a=1,b=3的情況。t2線程沒“看完整”t1線程的操作,只看到了b的賦值情況,而沒看到a的賦值情況。

當使用volatile關鍵字之後,a的值改變,立刻刷回到主內存,t2讀取到的一定是改變的值。

2.1 爲什麼發生可見性問題?

在這裏插入圖片描述

  • 如圖所示,數據從主內存到CPU過程中有多層緩存,分別是L3、L2、L1、寄存器。由於多層緩存的存在,可以大幅提升CPU的處理效率;
  • 每個核心將自己需要的數據讀到私有的緩存中,然後將修改後的值寫回到緩存中,最後等待刷到內存中,由於這個等待的過程,當核心1更新某共享數據後,核心2還沒有等到核心1將緩存刷回主內存就讀取數據了,導致髒數據;

2.2 JMM如何解決可見性問題?

  • JMM定義了一套讀寫規範,我們不用關心寄存器、一級緩存、二級緩存等,JMM抽象出主內存和本地內存的概念。

  • 本地內存包括寄存器、一級緩存、二級緩存;

  • 主內存包括三級緩存和內存;

  • 主內存和本地內存的關係:

    • 所有的變量都存儲在主內存中,同時每個線程都有自己的工作內存,工作內存中的變量是主內存中的拷貝;
    • 線程不能直接讀寫主內存中的變量,而是隻能操作自己工作內存中的變量,然後同步到主內存中;
    • 主內存是多個線程共享的,但是線程不共享工作內存,如果線程之間需要通信,必須藉助於主內存中轉完成
    • 正是因爲需要主內存來交換才導致了可見性問題;

2.3 happens-before原則

  • 什麼是happens-before:該原則是用來解決可見性問題的,在時間上,動作A發生在動作B之前,B保證能看見A;
  • 另一種解釋:如果一個操作happens-before另一個操作,那麼我們說第一個操作對於第二個操作是可見的;
  • 什麼不是happens-before:兩個線程沒有相互配合的機制,所以代碼A和B的執行結果不能保證總是被對象看到的,這就不具備happens-before;
  • 只要符合了happens-before原則,就不會產生可見性問題;
  • 符合happens-before原則的常見場景:
    1. 單線程原則:在一個線程之內,後面的語句一定能看到前面的語句做了什麼 ,因爲每個線程都有自己的工作內存,自己工作內存的變量都是可見的(如果數據之間沒有依賴,單線程下會發生指令重排序,但是不影響結果,所以單線程原則不影響重排序);
    2. 鎖操作(synchronized和lock):如果t1線程對a對象解鎖了,緊接着t2線程對a對象加鎖了,那麼t2線程能看到t1線程的所有操作,無論t1做了什麼修改,做了什麼邏輯,t2都可以看到,不會發生髒數據的情況;
    3. volatile:volatile修飾的變量發生的讀寫操作,當t1線程發生寫操作,t2線程進行讀操作時一定能看到這個寫操作;
    4. 線程啓動:子線程一定能看到主線程在執行start()之前的操作;
    5. 線程join:主線程join()後面的語句一定能看到子線程運行的所有的語句;
    6. 傳遞性:如果a happens-before b, b happens-before c,那麼a 一定happens-before c;
    7. 中斷:一個線程被其他線程中斷時,那麼檢測中斷的線程一定能看到並拋出異常;
    8. 符合happens-before原則的工具類:ConcurrentHashMap、CountDownLatch、線程池、FutureTask、CyclicBarrier;
  • 輕量級同步:給b加了volatile,不僅b被影響,還可以實現輕量級的同步,b = a 之前的代碼對讀取打印b後的代碼可見,所以在寫入線程裏對a的賦值,一定會對讀取線程可見,所以這裏的a即使不加volatile,只要b讀取到是3,就可以保證a讀取到的都是3而不可能是1,所以只給b加volatile,b賦值操作執行之前的其他變量的賦值操作也具有可見性。

2.4 volatile關鍵字詳解

  • 定義:volatile是一種同步機制,比synchronized或Lock鎖等更輕量,因爲volatile僅僅是控制把緩存中的數據立刻刷回到主內存中,不會被線程緩存,不會給對象上鎖,所以不會發生上下文切換等開銷很大的行爲;
  • 如果一個變量被volatile修飾,那麼JVM就知道這個變量有併發可能,就會禁止重排序;
  • volatile無法保證原子性,且只能作用於屬性,讀寫操作都是無鎖的,不能替代synchronized,場景有限;
  • 不適用場景:a++
  • 適用場景1:boolean flag(作爲一個標記位),如果一個共享變量自始至終只被各個線程賦值,而沒有其他的操作(修改,取反,對比),就可以用volatile來代替synchronized,因爲賦值本身是有原子性的,而volatile又保證了可見性,所以就足以保證線程安全。
  • 適用場景2:作爲刷新之前變量的觸發器,只要volatile變量被賦值,那麼在其執行之前的賦值操作都可見;
  • volatile的兩點作用:
    1. 可見性:讀一個volatile變量之前,需要先使相應的本地緩存失效,這樣就必須到主內存讀取最新值,寫一個volatile屬性會立即刷入到主內存。
    2. 禁止指令重排序優化:解決單例雙重鎖亂序問題
  • 保證可見性的措施:synchronized、Lock、併發集合、join、start都保證可見性;
  • synchronized可見性:不僅保證了原子性,還保證了可見性;凡是被synchronized修飾的代碼,上一個線程的操作可以被下一個線程所看到;
  • synchronized近朱者赤:在單個線程中synchronized修飾的代碼塊,在其之前的賦值操作也是對另一個線程可見的;

3. 原子性

3.1 什麼是原子性?

  • 定義:一系列的操作要麼全部都成功,要麼全部都不成功,是不可分割的。
  • i++不是原子性的。
  • 用synchronized實現原子性:保證同一時刻只有一個線程執行這段代碼
  • 原子操作 + 原子操作 != 原子操作。
  • Java中的原子操作有哪些:
    1. 除了long和double外的基本數據類型的賦值操作,在32位的JVM上,long和double的操作不是原子性的,在64位上是原子性的,在商用JVM中不會出現這種問題;
    2. 所有引用的賦值操作;
    3. Atomic包中的所有類的原子操作;

3.2 synchronized關鍵字詳解

3.2.1 synchronized基本用法

  • 定義:如果一個對象對多個線程可見,synchronized能夠保證在同一時刻最多隻有一個線程操作這個對象,以達到保證併發安全的效果。
  • 作用:保證可見性和原子性,可以避免線程安全問題:運行結果錯誤
  • 兩種使用方法:
    1. 對象鎖:

      1. 方法鎖,默認鎖對象爲this當前實例對象

        public class ObjectLock3 implements Runnable {
            @Override
            public void run() {
                method();
            }
        
            public synchronized void method() {
                System.out.println(Thread.currentThread().getName() + "進入同步方法");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        
            public static void main(String[] args) {
                ObjectLock3 objectLock3 = new ObjectLock3();
                Thread t1 = new Thread(objectLock3);
                Thread t2 = new Thread(objectLock3);
                t1.start();
                t2.start();
            }
        }
        
      2. 同步代碼塊鎖,自己指定鎖對象

        public class ObjectLock1 implements Runnable {
        
            @Override
            public void run() {
                synchronized (this) {
                    System.out.println(Thread.currentThread().getName() + "進入同步代碼塊");
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "退出同步代碼塊");
                }
            }
        
            public static void main(String[] args) {
                ObjectLock1 objectLock1 = new ObjectLock1();
                new Thread(objectLock1).start();
                new Thread(objectLock1).start();
            }
        }
        Thread-0進入同步代碼塊
        Thread-0退出同步代碼塊
        Thread-1進入同步代碼塊
        Thread-1退出同步代碼塊
        
        public class ObjectLock2 implements Runnable {
        
            private static final Object lock1 = new Object();
            private static final Object lock2 = new Object();
        
            @Override
            public void run() {
                synchronized (lock1) {
                    System.out.println(Thread.currentThread().getName() + "進入同步代碼塊1");
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "退出同步代碼塊1");
                }
                synchronized (lock2) {
                    System.out.println(Thread.currentThread().getName() + "進入同步代碼塊2");
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "退出同步代碼塊2");
                }
            }
            public static void main(String[] args) {
                ObjectLock2 objectLock2 = new ObjectLock2();
                new Thread(objectLock2).start();
                new Thread(objectLock2).start();
            }
        }
        Thread-0進入同步代碼塊1
        Thread-0退出同步代碼塊1
        Thread-0進入同步代碼塊2
        Thread-1進入同步代碼塊1
        Thread-1退出同步代碼塊1
        Thread-0退出同步代碼塊2
        Thread-1進入同步代碼塊2
        Thread-1退出同步代碼塊2
        
    2. 類鎖:

      1. 靜態方法鎖,synchronized加在static方法上,鎖對象爲當前類

        public class ObjectStaticLock1 implements Runnable {
        
            @Override
            public void run() {
                method();
            }
        
            public static synchronized void method() {
                System.out.println(Thread.currentThread().getName() + "進入到同步靜態方法中");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "退出同步靜態方法");
            }
        
            public static void main(String[] args) {
                ObjectStaticLock1 objectStaticLock1 = new ObjectStaticLock1();
                ObjectStaticLock1 objectStaticLock2 = new ObjectStaticLock1();
                Thread t1 = new Thread(objectStaticLock1);
                Thread t2 = new Thread(objectStaticLock2);
                t1.start();
                t2.start();
            }
        }
        
      2. 同步代碼塊鎖,synchronized(*.class)代碼塊,指定鎖對象爲class對象,所謂的類鎖,不過是Class對象的鎖而已

        public class ObjectStaticLock2 implements Runnable {
            @Override
            public void run() {
                synchronized (ObjectStaticLock2.class) {
                    System.out.println(Thread.currentThread().getName() + "進入到同步代碼塊");
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "退出同步代碼塊");
                }
            }
        
            public static void main(String[] args) {
                ObjectStaticLock2 objectStaticLock1 = new ObjectStaticLock2();
                ObjectStaticLock2 objectStaticLock2 = new ObjectStaticLock2();
                Thread t1 = new Thread(objectStaticLock1);
                Thread t2 = new Thread(objectStaticLock2);
                t1.start();
                t2.start();
            }
        }
        

3.2.2 多線程訪問同步方法的7種情況

  1. 兩個線程同時訪問一個對象的同步方法:會發生同步,鎖對象都爲同一個實例對象;
  2. 兩個線程同時訪問兩個對象的同步方法:互不影響,鎖對象不同;
  3. 兩個線程訪問的是synchronized的靜態方法:會發生同步,鎖對象都爲Class對象,Class對象只有一個;
  4. 同時訪問同步方法和非同步方法:非同步方法不受影響,不發生同步;
  5. 訪問同一個對象不同的普通同步方法:會發生同步,鎖對象默認爲同一個實例對象;
  6. 同時訪問靜態synchronized和非靜態synchronized方法:互不影響,靜態syn方法的鎖對象爲Class對象,非靜態syn方法的鎖對象爲一個實例對象this,實例對象和Class對象不是同一個對象,實例對象在堆中,Class對象在方法區中;
  7. 方法拋出異常後,會釋放鎖;
    總結:
    1. 一把鎖只能同時被一個線程獲取,沒拿到鎖的線程必須等待,如1、5;
    2. 每個實例都有自己的一把鎖,不同實例互不影響,當使用Class對象以及synchonized修飾的static方法的時候,所有對象共用同一把類鎖,對應2、3、4、5;
    3. 遇到異常,會釋放鎖,對應7;

3.2.3 synchronized關鍵字的性質

3.2.3.1 可重入
  • 定義:一個線程已經獲取到鎖,想再次獲取到這把鎖時不需要釋放,直接可以用;
  • 什麼是不可重入:一個線程獲取到鎖之後,想再次使用這個鎖,必須釋放鎖之後還其他線程競爭;
  • 好處:避免死鎖:假如一個類有兩個synchronized方法,當一個線程執行了方法1獲得了默認的this對象鎖,這個時候要執行方法2,如果synchronized不具備可重入性,那麼這個線程就無法獲取到訪問方法2的鎖,又無法釋放鎖,就造成了死鎖。
  • 粒度:線程範圍,在一個線程中,只要這個線程拿到了這把鎖,在這個線程內部就可以一直使用
    1. 同一個方法是可重入的;
    2. 可重入不要求是同一個方法;
    3. 可重入不要求是同一個類中;
3.2.3.2 不可中斷

一旦這個鎖已經被別的線程獲得了,如果本線程還想獲得,該線程只能等待或阻塞,直到別的線程釋放這個鎖。如果別的線程永遠不釋放鎖,那麼本線程則永遠等待下去。
相比之下,Lock類,擁有可以中斷的能力:

  • 如果等的時間過長,可以中斷現在已經獲取的鎖的線程的執行;
  • 如果等待時間過長,也可以退出。

3.2.4 synchronized原理

3.2.4.1 加鎖和釋放鎖原理
  • 每個一個對象都有一個內置的monitor鎖,這個鎖存儲在對象頭中的,鎖的獲取和釋放實際上需要執行兩個指令:monitorenter和monitorexit,當線程執行到monitorenter的時候會嘗試獲取這個鎖;
  • 反編譯:先javac demo.java,然後javap -verbose demo.class文件;
  • monitorenter和monitorexit在執行的時候會讓對象鎖的計數+1或-1;
  • 獲取鎖的過程:首先一個線程要獲取一個對象鎖的時候會查看這個monitor鎖的計數器如果爲0,那麼就給他+1,這樣別的線程就進不來了,如果一個線程有了這把鎖,又重入了,在計數器再+1;如果monitor被其他線程持有了,直到計數器=0,纔會獲取這個鎖。
  • 釋放鎖的過程:將monitor的計數器-1,直到=0,表示不再擁有所有權了,如果不是0,說明剛纔是可重入進來的
3.2.4.1 可重入原理

一個線程拿到一把鎖之後,還想再次進入由這把鎖所控制的方法,則可以再次進入,原理是用了monitor鎖的計數器。

  • JVM負責跟蹤被加鎖的次數
  • 線程第一次給對象加鎖的時候,計數+1.每當這個相同的線程再次獲取該對象鎖的時候,計數器會遞增;
  • 每當任務離開的時候,計數遞減,當計數爲0的時候,鎖被完全釋放;
3.2.4.1 可見性原理

線程A和線程B通信:

  1. 本地內存A把修改後的內容放到主內存中;
  2. 本地內存B從主內存從讀取修改後的內容;

synchnized修飾的代碼塊對對象的任何修改,在釋放鎖之前都要將修改的內容先寫回到主內存中,所以從主內存中讀取的內容都是最新的。

3.2.5 synchronized的缺陷

  • 效率低:鎖的釋放情況少(只有代碼執行完和拋異常)、試圖獲得鎖時候不能設定超時、不能中斷一個正在試圖獲得鎖的線程
  • 不夠靈活:加鎖和釋放的時機單一,每個鎖僅僅有單一的條件,可能是不夠的。讀寫鎖更靈活。
  • 無法知道是否成功獲取到鎖,沒法去嘗試獲取,去判斷。Lock是可以通過tryLock方法嘗試獲取,返回true代表成功加鎖。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章