02-Java併發編程之JVM&Lock&Tools

CPU

在瞭解鎖之前我們先需要知道CPU是如何工作的,爲什麼我們使用多線程時會出現不同步的問題?如下圖是單CPU和CPU多級緩存示意圖
在這裏插入圖片描述

單CPU和CPU多級緩存示意圖

在這裏插入圖片描述

CPU到硬盤粗略講是需要經過 一級二級三級緩存=>內存=>硬盤,爲什麼CPU不能直接從硬盤讀取數據,卻要先經過內存呢?

  • CPU靠指令集工作,隨着CPU的主頻越來越高,處理速度越來越快,CPU的處理能力和信息吞吐能力遠大於硬盤。
  • 硬盤只是一個存儲器,已巨型機爲例,計算結果和運行速度最重要,只要在硬盤中讀取足夠的信息就開始計算了,這樣的機器硬盤不如內存重要。
  • 內存比硬盤數據吞吐量大,速度快。在加載系統後(不論是Windows、LINUX,包括DOS),主要使用的數據(80/20定律)都已經加載進了內存中。這樣可以加快系統的速度,CPU是火箭的話,緩存就像飛機,內存是火車,硬盤像輪船。簡而言之存儲的容積越大速度越慢。
  • CPU對數據會有一個預判,這個預判是和程序有關的,每天,甚至每個程序所需的預判數據都不同,如果忽略內存,直接寫入硬盤中,硬盤是掉電不復原的,只能刪除,這樣實際增加了系統開銷(是指資源,不是價格)。也包括一次性的其他數據。

緩存 cache 的作用:

CPU 的頻率很快,主內存跟不上 cpu 的頻率,cpu 需要等待主存,浪費資源。所以 cache 的出現是解決 cpu和內存之間的頻率不匹配的問題。

緩存 cache 帶來的問題:

併發處理的不同步,例如核心1從內存拿到了一個int i=0運算後+1,但是核心2又去內存取但是這時int i=1還在緩存中,然後核心2拿出來又是0他又+1,你會發現這中間存在一個很大的問題整個運算過程中int不能保證一致,那我核心1做操作核心2又做操作,這樣程序就會有很大的問題
解決方案: CPU廠家intel和amd提出協議:總線鎖、緩存一致性的解決方案.

狀態 描述 監聽任務
M修 改(Modified) 緩存一致性MESI協議緩存狀態 緩存行必須時刻監聽所有試圖讀該緩存行相對就主存的操作,這種操作必須在緩存將該緩存行寫回主存並將狀態變成 S(共享)狀態之前被延遲執行。
E 獨享、互斥(Exclusive) 該 Cache line 有效,數據和內存中的數據一致,數據只存在於本Cache 中。 緩存行也必須監聽其它緩存讀主存中該緩存行的操作,一旦有這種操作,該緩存行需要變成 S(共享)狀態。
S 共 享(Shared) 該 Cache line 有效,數據和內存中的數據一致,數據存在於很多Cache 中。 緩存行也必須監聽其它緩存使該緩存行無效或者獨享該緩存行的請求,並將該緩存行變成無效(Invalid)。
I 無 效(Invalid) 該 Cache line 無效。

Java內存模型 java memory model(JVM)

java線程內存模型根cpu緩存模型類型,是基於cpu緩存模型來建立的。java線程內存模型是標準化的,屏蔽掉了底層不同計算機的區別,使得Java語言編譯程序只需生成在Java虛擬機上運行的目標代碼(字節碼),就可以在多種平臺上不加修改地運行。

Heap(堆):java 裏的堆是一個運行時的數據區,堆是由垃圾回收來負責的, 堆的優勢是可以動態的分配內存大小,生存期也不必事先告訴編譯器,因爲他是在運行時動態分配內存的,java 的垃圾回收器會定時收走不用的數據,缺點是由於要在運行時動態分配,所有存取速度可能會慢一些(存在擴容導致)
Stack(棧):棧的優勢是存取速度比堆要快,僅次於計算機裏的寄存器,棧的數據是可以共享的,缺點是存在棧中的數據的大小與生存期必須是確定的,缺乏一些靈活性
棧中主要存放一些基本類型的變量,比如 int,short,long,byte,double,float,boolean,char,對象句柄
在這裏插入圖片描述
我們的方法都會放到棧裏面,每一個棧都會對應一個對象,假如我們在Object1調用了Object2的方法我們的Object裏面就會有一個Object2的副本。下圖爲java併發線程數據同步過程
在這裏插入圖片描述
加入我現在有2個線程,thread1和thread2,主內存有一個int=0,那threadA和threadB需要運算他們會先從主內存中拷貝一個副本int=0到工作內存中,對工作內存的int運算完畢後會覆蓋內存的int。

tips: 這裏之所以要提出JVM是因爲保證我們java併發編程的3個核心概念。

原子性

即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行

可見性

可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值也就是上面所說的工作內存中的副本,其他線程能夠立即看得到修改的值

有序性

程序執行的順序按照代碼的先後順序執行,程序順序和我們的編譯運行的執行不一定是一樣,因爲CPU會做編譯優化和指令重排提高運行速度,這時可能就會出現我編譯後和我們寫的順序不一致,CPU做編譯優化會遵循一些原則保證程序優化後結果不會出錯如:Happens-before: 傳遞原則:lock unlock A>B>C A>C的原則

java內存模型的同步過程

在這裏插入圖片描述
java內存模型的同步分爲8個步驟,加鎖=>讀取主內存數據==>加載到工作內存==>運行==>賦值==>存儲回工作內存==>寫入主內存==>釋放鎖。

Volatile

  • Java語言規範第3版中對volatile的定義如下:Java編程語言允許線程訪問共享變量,爲了確保共享變量能被準確和一致地更新,線程應該確保通過排他鎖單獨獲得這個變量。Java語言 提供了volatile,在某些情況下比鎖要更加方便。如果一個字段被聲明成volatile,Java線程內存模型確保所有線程看到這個變量的值是一致的。
  • 關鍵字 volatile 可以說是 Java 虛擬機提供的最輕量級的同步機制,當一個變量定義爲 volatile,它具有內存可見性以及禁止指令重排序兩大特性。加上與去除volatile分別運行如下代碼查看結果
public class VolidateVisiableTest {

  // private static volatile boolean flag = false;
  private static boolean flag = false;

  public static void main(String[] args) throws InterruptedException {
    new Thread(new Runnable() {
      @Override
      public void run() {
        System.out.println("waiting data...");
        while (!flag) {

        }
        System.out.println("======success====");
      }
    }).start();

    Thread.sleep(2000);

    new Thread(new Runnable() {
      @Override
      public void run() {
        prepareData();
      }
    }).start();

  }

  private static void prepareData() {
    System.out.println("=======prepare data========");
    flag = true;
    System.out.println("=======prepare end========");
  }

}

不加volatile運行結果

waiting data...
=======prepare data========
=======prepare end========

如果我們把volatile去處這個程序永遠也不會停止,但是通過輸出知道線程2已經走完;當我們加上volatile後flag這個值就具有內存可見性,如果有一個線程修改了他另外一個線程也能看到。

Volatile關鍵字介紹

  • 保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對 其他線程來說是立即可見的,可見性的意思是當一個線程修改一個共享變量時,另外一個線程能讀到這個修改的值
  • 禁止進行指令重排序,程序執行的順序按照代碼的先後順序執行
  • Volatile 只可以保證可見性與有序性; 單次操作可以保證原子性,但是不能保證複合原子性。比如: i++
Volatile原理
  • volatile 變量進行寫操作時,JVM 會向處理器發送一條 Lock 前綴的指令,將這個變量所在緩存行的數據寫會到系統內存。
  • Lock 前綴指令實際上相當於一個內存屏障(也成內存柵欄),它確保指令重排序時不會把其後面的指令排
  • 到內存屏障之前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成
Volatile爲什麼不能保證原子性
public class VolatileAtoDemo implements Runnable {
    //原子性測試
    static volatile int i =1;
    @Override
    public void run() {
        /***
         * i++; 操作並非爲原子性操作。
         什麼是原子性操作?簡單來說就是一個操作不能再分解。i++ 操作實際上分爲 3 步:
         讀取 i 變量的值。
         增加 i 變量的值。
         把新的值寫到內存中。
         */
        System.out.println(Thread.currentThread().getName() + ": 當前i值: " + i + ", ++後i值: "
                + (++i));
    }
    public static void main(String[] args) {
        Thread t1 = new Thread(new VolatileAtoDemo(), "A");
        Thread t2 = new Thread(new VolatileAtoDemo(), "B");
        Thread t3 = new Thread(new VolatileAtoDemo(), "C");
        Thread t4 = new Thread(new VolatileAtoDemo(), "D");
        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }
}

多次運行後會發現結果並不是按順序來的且各次運行情況不盡相同,因爲有可能在我們改變值i需要回寫到內存在我回寫我最新的值之前已經有幾個線程同時進入並且做加法操作,所以我們會發現有的線程拿到的還是之前的值。如果需要實現原子性需要運用到鎖(synchronized、CAS)

Synchronized

  • 一句話解釋synchronized:JVM會自動通過使用monitor來加鎖與解鎖,能保證在同一時刻最多隻有一個線程執行指定代碼,以達到併發安全的效果,同時具有可重如與不可中斷的性質。
  • Synchronized是一個 重量級鎖、重入鎖、jvm 級別鎖 ,他可以保證複合原子性,在方法他是使用:ACC_SYNCHRONIZED修飾方法,代碼塊:是在代碼塊前後加上monitorenter\monitorexit
Synchronized原理
public class SynchronizedDemo {
    public static void main(String[] args) {
        //使用方法1 對象鎖
        synchronized (SynchronizedDemo.class){
        }
        //調用代碼塊
        m();
    }
    //使用方法2 定義靜態代碼塊
    public static synchronized void m(){
    }
}

在這裏插入圖片描述
sysnchronized底層是使用了一個JVM監聽器,監聽到一個線程後會別別的線程全部放到同步隊列中先,執行監聽的那個線程,但監聽的線程執行完後會通知隊列裏面的線程可以出隊繼續執行

方法和代碼塊(對象鎖和類鎖):

⚫ 對於普通同步方法,鎖是當前實例對象。(同步方法即加上了synchronized修飾)
⚫ 對於靜態同步方法,鎖是當前類的 Class 對象。
⚫ 對於同步方法塊,鎖是 Synchonized 括號裏配置的對象。

synchronized方法各種使用場景
1.兩個線程同時訪問一個對象實例的同步方法:相互等待,鎖生效。
2.兩個線程訪問的兩個對象實例的同步方法:相互沒有影響,並行執行。
3.兩個線程訪問的是synchronized的靜態方法:即使實例不同,鎖也生效。
4. 同時訪問同步方法和非同步方法:非同步方法不受同步方法影響。
5. 訪問同一個對象實例的不同的普通(static)同步方法:因爲默認鎖對象是this,所以鎖生效,會並行執行。
6.同時訪問靜態synchronized方法和非靜態synchronized方法:可以並行執行。因爲static synchronized的鎖是*.class,
  而non-static synchronized的鎖是this,所以並不相互衝突,可以並行執行。
7.方法拋出異常後,會釋放鎖(RuntimeException()不用強制捕獲)
8.方法method1()synchronized修飾,method1()中調用了method2(),
  method2()未被synchronized修飾,method2()還是線程安全的嗎?不是,可以被多個線程同時訪問
  總結:
a、一把鎖只能同時被一個線程獲取,沒有拿到鎖的線程必須等待(對應1,5情況)
b、每個實例都對應有自己的一把鎖,不同實例之間互不影響;例外:鎖對象是*.class以及
 synchronized修飾的是static方法的時候,所有對象共用同一把類鎖(對應第2346種情況);
c、無論是方法正常執行完畢或者方法拋出異常,都會釋放鎖(對應第7種情況)

Synchronized缺陷與注意點

synchronized缺陷:
1、效率低:鎖的釋放情況少、試圖獲得鎖時不能設定超時、不能中斷一個正在試圖獲得鎖的線程
2、不夠靈活:加鎖和釋放的時機單一,每個鎖僅有單一的條件(某個對象),可能是不夠的
3、無法知道是否成功獲取到鎖

synchronized使用注意點:鎖對象不能爲空、作用域不宜過大、避免死鎖
1)鎖對象不能爲空:必須是一個實例對象,被new過,或者使用其他方法創建好,而不是空對象。
這是因爲,鎖的信息保存在對象頭中,對象都沒有,更沒有對象頭,所以這個鎖不能工作
2)作用域不宜過大:將盡可能多的代碼使用synchronized包裹,會降低出併發問題的可能性,
因爲大部分線程都是串行工作,沒有達到多線程編程的目的,影響程序執行的效率

在這裏插入圖片描述

Lock和ReentrantLock

相比於Synchronized(jvm級別的鎖),還有一些輕量級別的鎖Lock和ReentrantLock
實現:

try {
  lock.lock();
  // TODO ***
}finally{
  lock.unlock();
}
// ReentrantLock 基本用法
public static ReentrantLock reentrantLock = new ReentrantLock();
try {
    // 用法 1.reentrantLock.tryLock 先嚐試過獲取鎖 獲取不到就直接跳過
    if (reentrantLock.tryLock(5, TimeUnit.SECONDS)) { //讓線程等待5秒看能不能那到鎖 拿不到就取else
        System.out.println("獲取");
    } else {
        System.out.println("獲取失敗");
    }
    // 用法 2.reentrantLock.lock 線程進入後直接加鎖(強行獲取)
    reentrantLock.lock();
    System.out.println("獲取");
    // 用法 3.reentrantLock.lockInterruptibly 線程進入獲取不到鎖後直接中斷
    reentrantLock.lockInterruptibly();
    System.out.println("獲取");
} catch (InterruptedException e) {
    e.printStackTrace();
} finally {
    // 不加這個條件會報錯 getHoldCount()方法來檢查當前線程是否擁有該鎖
    if(reentrantLock.isHeldByCurrentThread()) {
        reentrantLock.unlock(); //如果沒有鎖 解鎖會報錯
    }
}

一個ReentrantLock例子如下

public class ReentrantLockDemo implements Runnable{
    public static ReentrantLock reentrantLock = new ReentrantLock();

    @Override
    public void run() {
        try {
            if (reentrantLock.tryLock(5, TimeUnit.SECONDS)) { //讓線程等待5秒看能不能那到鎖 拿不到就取else
                Thread.sleep(3000); //模擬進來的線程都要執行3秒才釋放鎖
                System.out.println("獲取");
            } else {
                System.out.println("獲取失敗");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            if(reentrantLock.isHeldByCurrentThread()) {         // 不加這個條件會報錯 getHoldCount()方法來檢查當前線程是否擁有該鎖
                reentrantLock.unlock(); //如果沒有鎖 解鎖會報錯(可查看源碼)
            }
        }
    }

    public static void main(String[] args) {
        ReentrantLockDemo myReentrantLock = new ReentrantLockDemo();
        IntStream.range(0,2).forEach(i->new Thread(ReentrantLockDemo){
        }.start());

    }
}

Lock與Synchronized的區別以及如何選擇
1、Synchronized:jvm 層級的鎖 自動加鎖自動釋放鎖
  Lock:依賴特殊的 cpu 指令,代碼實現、手動加鎖和釋放鎖、Condition(生產消費模式)
2、如何選擇Lock和synchronized關鍵字
1)建議都不使用,可以使用java.util.concurrent包中的Automic類、countDown等類
2)優先使用現成工具,如果沒有就優先使用synchronized關鍵字,好處是寫儘量少的代碼就能實現
功能。如果需要靈活的加解鎖機制,則使用Lock接口

AbstractQueuedSynchronizer

AbstractQueuedSynchronizer是java併發編程的核心類,是JUC的一個標準,java中鎖的實現都用到了AbstractQueuedSynchronizer

隊列同步器 AbstractQueuedSynchronizer(以下簡稱同步器) 
java.util.concurrent.locks.AbstractQueuedSynchronizer#acquire 獨佔式獲取同步狀態
java.util.concurrent.locks.AbstractQueuedSynchronizer#acquireInterruptibly 獨佔式獲取同步狀態,未獲取可以 中斷
java.util.concurrent.locks.AbstractQueuedSynchronizer#acquireShared 共享式獲取同步狀態 
java.util.concurrent.locks.AbstractQueuedSynchronizer#acquireSharedInterruptibly 共享式獲取同步狀態,未獲 取可以中斷 
java.util.concurrent.locks.AbstractQueuedSynchronizer#release 獨佔釋放鎖
java.util.concurrent.locks.AbstractQueuedSynchronizer#releaseShared 共享式釋放鎖 

實現原理使用的是 隊列+雙向鏈表

CountDownLatch

CountDownLatch(同步工具類)允許一個或多個線程等待其他線程完成操作
CountDownLatch 時,需要指定一個整數值,此值是線程將要等待的操作數。當某個線程爲了要執行這些操 作而等待時,需要調用 await 方法。await 方法讓線程進入休眠狀態直到所有等待的操作完成爲止。當等待 的某個操作執行完成,它使用 countDown 方法來減少 CountDownLatch 類的內部計數器。當內部計數器遞 減爲 0 時,CountDownLatch 會喚醒所有調用 await 方法而休眠的線程們。

//CountDownLatch Demo
public class CountDownLatchDemo {
    private final static int threadCount = 100;
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newCachedThreadPool();
        CountDownLatch countDownLatch = new CountDownLatch(threadCount); //初始化一個數量
        for (int i =0;i< threadCount;i++){
            final int threadNum = i;
            executorService.execute(()->{
                try {
                    test(threadNum);
                } catch (Exception e) {
                    e.printStackTrace();
                }finally {
                    countDownLatch.countDown(); //每次執行完減一
                }
            });
        }
        countDownLatch.await(50, TimeUnit.MILLISECONDS);
        //等待50毫秒就增加執行如下代碼,不管別的線程有沒有跑完
        System.out.println("結束");
        executorService.shutdown();
    }

    private static void test(int threadNum) throws Exception {
        Thread.sleep(50);
        System.out.println(threadNum);
        Thread.sleep(50);
    }
}

在這裏插入圖片描述
使用:

⚫ java.util.concurrent.CountDownLatch#await()
⚫ java.util.concurrent.CountDownLatch#countDown()
⚫ java.util.concurrent.CountDownLatch#getCount()

CountDownLatch原理:

CountDownLatch 的構造函數接收一個 int 類型的參數作爲計數器,如果你想等待 N 個點完成,這裏就傳入 N。當我們調用 CountDownLatch 的 countDown 方法時,N 就會減 1,CountDownLatch 的 await 方法會阻塞當前線程,直到 N 變成零。由於 countDown 方法可以用在任何地方,所以這裏說的 N 個點,可以是 N 個線程,也可以是 1 個線程裏的 N 個執行步驟。用在多個線程時,只需要把這個CountDownLatch 的引用傳遞到線程裏即可。

CountDownLatch場景:

  • 並行計算
  • 依賴啓動
  • CountDownLatch 是一次性的,只能通過構造方法設置初始計數量,計數完了無法進行復位,不能達到複用。可以實現類似於:FutureTask 和 Join 等功能

Semaphore

Semaphore(信號量)是用來控制同時訪問特定資源的線程數量,它通過協調各個線程,以保證合理的使用公共資源。控制一組線程同時執行,限流效果好於(redis+lua)實現的分佈式限流

public class Semaphore01 {
    //創建5個許可證
    private static Semaphore semaphore=new Semaphore(5);

    public static void main(String[] args) {
        //創建二十個線程同時進行秒殺
        for (int i=0;i<20;i++){
            final int j=i;
            new Thread(()->{
                try {
                    action(j);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }

    public static void action(int i) throws InterruptedException {
        //每次進入許可-1,最大5個許可
        semaphore.acquire();
        System.out.println(i+"在京東秒殺iphonex");
        System.out.println(i+"秒殺成功");
        semaphore.release();
        //每次結束許可+1,最大5個許可
    }
}

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