java多線程基礎學習1

java多線程基礎學習1

pre 準備知識

什麼是線程

程序中負責執行的哪個東東就叫做線程(執行路線,進程內部的執行序列),或者說是進程的子任務。

線程有什麼特點

1、線程是進程的一個實體,是系統獨立調度和分派的基本單位。
2、線程有不同的狀態,系統提供了多種線程的控制原語,如創建線程、取消線程、銷燬線程等。
3、線程之間的不需要類似於IPC的機制進行數據交換,因爲他之間是共享的,但新的問題是如何解決同時訪問時的衝突。
4、每個線程有自己獨立的線程ID、寄存器信息、函數線、錯誤碼。
5、一個進程可以多個線程它們是併發執行(感覺上是),它們可以執行相同的代碼也可以執行不同的代碼。
6、當需要同時解決多個任務,而這些任務量不大,相互之間可能需要有大量的數據交換,這種情況就不太適合多進程了,而多線程會比較合適,相比於進程線程的系統開銷小,任務切換快。

什麼是多線程

首先要先了解倆個概念串行和並行

  • 串行

    串行其實是相對於單條線程來執行多個任務來說的,我們就拿下載文件來舉個例子,我們下載多個文件,在串行中它 是按照一定的順序去進行下載的,也就是說必須等下載完A之後,才能開始下載B,它們在時間上是不可能發生重疊的。

  • 並行

    下載多個文件,開啓多條線程,多個文件同時進行下載,這裏是嚴格意義上的在同一時刻發生的,並行在時間上是重疊的。

POSIX線程

早期的UNIX、Linux系統是不支持線程,而隨着計算機技術發展,windows系統中實現的線程的使用,隨後UNIX、Linux也都可以使用線程了。
剛開始時都是各個計算機廠商自己私有提供的線程庫(用進程模擬的),接口和實現的差異非常大,不易於移植。
於1995年IEEE協會制定IEEEPOSIX1003.1c標準,規定了統一的線程編程接口,遵循該標準的線程實現方式被統稱爲POSIX線程,即pthread。
線程是以庫的形式提供的,編譯時需要添加 -lpthread 參數。

線程的狀態和生命週期

1、新建狀態
2、就緒狀態

處於就緒狀態的線程已經具備了運行條件,但還沒有分配到CPU,處於線程就緒隊列(就緒池),等待系統爲其分配CPU。

3、運行狀態

處於運行狀態的線程最爲複雜,它可以變爲阻塞狀態、就緒狀態和死亡狀態。
處於就緒狀態的線程,如果獲得了cpu的調度,就會從就緒狀態變爲運行狀態,執行run()方法中的任務。
如果該線程失去了cpu資源,就會又從運行狀態變爲就緒狀態,重新等待系統分配資源。
也可以對在運行狀態的線程調用yield()方法,它就會讓出cpu資源,再次變爲就緒狀態。

4、阻塞狀態
5、死亡狀態

併發編程的問題

提到併發就一定會想到幾個特性:原子性問題,可見性問題和有序性問題

其實,原子性問題,可見性問題和有序性問題是人們抽象定義出來的。而這個抽象的底層問題就是前面提到的緩存一致性問題、處理器優化問題和指令重排問題等。

回顧一下爲了保證數據的安全,需要滿足以下三個特性:

  • 原子性,是指在一個操作中,CPU 不可以在中途暫停然後再調度,即不被中斷操作,要不執行完成,要不就不執行。
  • 可見性,是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
  • 有序性,即程序執行的順序按照代碼的先後順序執行。

緩存一致性問題其實就是可見性問題。而處理器優化是可以導致原子性問題的。指令重排即會導致有序性問題。

1、線程涉及的關鍵字

1.1 synchronized

先看下下面的程序和運行結果

package com.prc.ThreadTest;

public class TestThread {

    private int value = 20; // 20張票

    public static void main(String[] args) {

        TestThread t =  new TestThread();

        new Thread(() -> {
            while (true) {
                System.out.println("用戶" + Thread.currentThread().getName() + "買了票還剩餘" + t.increamentAndGet() + "張票");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "張三").start();
        new Thread(() -> {
            while (true) {
                System.out.println("用戶" + Thread.currentThread().getName() + "買了票還剩餘" + t.increamentAndGet() + "張票");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "王五").start();
        new Thread(() -> {
            while (true) {
                System.out.println("用戶" + Thread.currentThread().getName() + "買了票還剩餘" + t.increamentAndGet() + "張票");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "李四").start();
    }

    //synchronized
    public int increamentAndGet() {
        if (value == 0){
            return 0;
        }
        return value--;
    }
}

// 運行結果
用戶張三買了票還剩餘20張票
用戶王五買了票還剩餘19張票
用戶李四買了票還剩餘18張票
用戶張三買了票還剩餘17張票
用戶王五買了票還剩餘16張票
用戶李四買了票還剩餘15張票
用戶王五買了票還剩餘14張票
用戶張三買了票還剩餘13張票
用戶李四買了票還剩餘13張票
用戶王五買了票還剩餘12張票
用戶張三買了票還剩餘11張票
用戶李四買了票還剩餘11張票
用戶張三買了票還剩餘10張票
用戶王五買了票還剩餘8張票
用戶李四買了票還剩餘9張票
用戶王五買了票還剩餘7張票
用戶李四買了票還剩餘6張票
用戶張三買了票還剩餘6張票
用戶張三買了票還剩餘5張票
用戶李四買了票還剩餘4張票
用戶王五買了票還剩餘4張票
用戶王五買了票還剩餘3張票
用戶張三買了票還剩餘2張票
用戶李四買了票還剩餘3張票
用戶李四買了票還剩餘1張票
用戶王五買了票還剩餘0張票
用戶張三買了票還剩餘0張票
用戶王五買了票還剩餘0張票
用戶李四買了票還剩餘0張票

可以發現存在倆個人都買了票但是票數只減了一張的情況,當多個線程搶奪同一有限資源的情況下,就會產生這樣的場景。經典的模型如:火車購票、多消費者消費庫存等。

那如何解決這個問題呢??帶着這個問題我們來學習下synchronized關鍵字

在java中,synchronized鎖大家又通俗的稱爲:方法鎖,對象鎖 和 類鎖 三種.

1.1.1 方法鎖

方法鎖其實屬於對象鎖的一種

  • 修飾方法

synchronized修飾普通方法,鎖定的是當前對象.一次只能有一個線程進入同一個對象實例method()方法.

public synchronized int increamentAndGet() {
    if (value == 0){
        return 0;
    }
    return value--;
}

1.1.2 對象鎖

  • 修飾代碼塊,鎖實例對象

public synchronized int increamentAndGet() {
    synchronized (this){
        if (value == 0){
            return 0;
        }
        return value--;
    }
}

1.1.3 類鎖

  • 修飾靜態方法

public synchronized static int increamentAndGet() {
        
}
  • 修飾代碼塊,鎖類對象

    public synchronized  int increamentAndGet() {
        synchronized (TestThread.class){
            if (value == 0){
                return 0;
            }
            return value--;
        }
    }

那麼最開始的代碼可以使用這三種鎖進行修改來保證線程安全。下面只貼出關鍵代碼。

// 方法鎖方式,將調用的方法設置爲synchronized
public synchronized  int increamentAndGet() {
    if (value == 0){
        return 0;
    }
    return value--;
}

// 對象鎖
public int increamentAndGet() {
    synchronized (this){
        if (value == 0){
            return 0;
        }
        return value--;
    }
}

// 類鎖
public  int increamentAndGet() {
    synchronized (TestThread.class){
        if (value == 0){
            return 0;
        }
        return value--;
    }
}

1.1.4 synchronized的底層原理

下面一段話是轉載,原文出處不明

一段synchronized的代碼被一個線程執行之前,他要先拿到執行這段代碼的權限,
在Java裏邊就是拿到某個同步對象的鎖(一個對象只有一把鎖);
如果這個時候同步對象的鎖被其他線程拿走了,他(這個線程)就只能等了(線程阻塞在鎖池等待隊列中)。
取到鎖後,他就開始執行同步代碼(被synchronized修飾的代碼);
線程執行完同步代碼後馬上就把鎖還給同步對象,其他在鎖池中等待的某個線程就可以拿到鎖執行同步代碼了。
這樣就保證了同步代碼在統一時刻只有一個線程在執行。

在Java程序運行時環境中,JVM需要對兩類線程共享的數據進行協調:
1)保存在堆中的實例變量
2)保存在方法區中的類變量

這兩類數據是被所有線程共享的。

Java 虛擬機中的同步(Synchronization)基於進入和退出Monitor對象實現, 無論是顯式同步(有明確的 monitorenter 和 monitorexit 指令,即同步代碼塊)還是隱式同步都是如此。在 Java 語言中,同步用的最多的地方可能是被 synchronized 修飾的同步方法。同步方法 並不是由 monitorenter 和 monitorexit 指令來實現同步的,而是由方法調用指令讀取運行時常量池中方法表結構的 ACC_SYNCHRONIZED 標誌來隱式實現的,關於這點,稍後詳細分析。

同步代碼塊:monitorenter指令插入到同步代碼塊的開始位置,monitorexit指令插入到同步代碼塊的結束位置,JVM需要保證每一個monitorenter都有一個monitorexit與之相對應。任何對象都有一個monitor與之相關聯,當且一個monitor被持有之後,他將處於鎖定狀態。線程執行到monitorenter指令時,將會嘗試獲取對象所對應的monitor所有權,即嘗試獲取對象的鎖;

在JVM中,對象在內存中的佈局分爲三塊區域:對象頭、實例變量和填充數據。

  • 實例變量:存放類的屬性數據信息,包括父類的屬性信息,如果是數組的實例部分還包括數組的長度,這部分內存按4字節對齊。

  • 填充數據:由於虛擬機要求對象起始地址必須是8字節的整數倍。填充數據不是必須存在的,僅僅是爲了字節對齊,這點了解即可。

  • 對象頭:Hotspot虛擬機的對象頭主要包括兩部分數據:Mark Word(標記字段)、Klass Pointer(類型指針)。其中Klass Point是是對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例,Mark Word用於存儲對象自身的運行時數據,它是實現輕量級鎖和偏向鎖的關鍵。

    Mark Word:用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID、偏向時間戳等等。Java對象頭一般佔有兩個機器碼(在32位虛擬機中,1個機器碼等於4字節,也就是32bit),但是如果對象是數組類型,則需要三個機器碼,因爲JVM虛擬機可以通過Java對象的元數據信息確定Java對象的大小,但是無法從數組的元數據來確認數組的大小,所以用一塊來記錄數組長度。

  • 監視器(monitor):可以把它理解爲一個同步工具,也可以描述爲一種同步機制,它通常被描述爲一個對象。與一切皆對象一樣,所有的Java對象是天生的Monitor,每一個Java對象都有成爲Monitor的潛質,因爲在Java的設計中 ,每一個Java對象自打孃胎裏出來就帶了一把看不見的鎖,它叫做內部鎖或者Monitor鎖。Monitor 是線程私有的數據結構,每一個線程都有一個可用monitor record列表,同時還有一個全局的可用列表。每一個被鎖住的對象都會和一個monitor關聯(對象頭的MarkWord中的LockWord指向monitor的起始地址),同時monitor中有一個Owner字段存放擁有該鎖的線程的唯一標識,表示該鎖被這個線程佔用。結構和每個組成的含義

    Owner:初始時爲NULL表示當前沒有任何線程擁有該monitor record,當線程成功擁有該鎖後保存線程唯一標識,當鎖被釋放時又設置爲NULL;
    EntryQ:關聯一個系統互斥鎖(semaphore),阻塞所有試圖鎖住monitor record失敗的線程。
    RcThis:表示blocked或waiting在該monitor record上的所有線程的個數。
    Nest:用來實現重入鎖的計數。
    HashCode:保存從對象頭拷貝過來的HashCode值(可能還包含GC age)。
    Candidate:用來避免不必要的阻塞或等待線程喚醒,因爲每一次只有一個線程能夠成功擁有鎖,如果每次前一個釋放鎖的線程喚醒所有正在阻塞或等待的線程,會引起不必要的上下文切換(從阻塞到就緒然後因爲競爭鎖失敗又被阻塞)從而導致性能嚴重下降。Candidate只有兩種可能的值0表示沒有需要喚醒的線程1表示要喚醒一個繼任線程來競爭鎖

四種鎖

無鎖狀態、偏向鎖、輕量級鎖和重量級鎖

隨着鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級。

  • 重量級鎖

  • 偏向鎖

    在大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,因此爲了減少同一線程獲取鎖(會涉及到一些CAS操作,耗時)的代價而引入偏向鎖。偏向鎖的核心思想是,如果一個線程獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變爲偏向鎖結構,當這個線程再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程,這樣就省去了大量有關鎖申請的操作,從而也就提供程序的性能。所以,對於沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續多次是同一個線程申請相同的鎖。但是對於鎖競爭比較激烈的場合,偏向鎖就失效了,因爲這樣場合極有可能每次申請鎖的線程都是不相同的,因此這種場合下不應該使用偏向鎖,否則會得不償失,需要注意的是,偏向鎖失敗後,並不會立即膨脹爲重量級鎖,而是先升級爲輕量級鎖。

  • 輕量級鎖

    倘若偏向鎖失敗,虛擬機並不會立即升級爲重量級鎖,它還會嘗試使用一種稱爲輕量級鎖的優化手段(1.6之後加入的),此時Mark Word 的結構也變爲輕量級鎖的結構。輕量級鎖能夠提升程序性能的依據是“對絕大部分的鎖,在整個同步週期內都不存在競爭”,注意這是經驗數據。需要了解的是,輕量級鎖所適應的場景是線程交替執行同步塊的場合,如果存在同一時間訪問同一鎖的場合,就會導致輕量級鎖膨脹爲重量級鎖。

  • 自旋鎖

    輕量級鎖失敗後,虛擬機爲了避免線程真實地在操作系統層面掛起,還會進行一項稱爲自旋鎖的優化手段。這是基於在大多數情況下,線程持有鎖的時間都不會太長,如果直接掛起操作系統層面的線程可能會得不償失,畢竟操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,因此自旋鎖會假設在不久將來,當前的線程可以獲得鎖,因此虛擬機會讓當前想要獲取鎖的線程做幾個空循環(這也是稱爲自旋的原因),一般不會太久,可能是50個循環或100循環,在經過若干次循環後,如果得到鎖,就順利進入臨界區。如果還不能獲得鎖,那就會將線程在操作系統層面掛起,這就是自旋鎖的優化方式,這種方式確實也是可以提升效率的。最後沒辦法也就只能升級爲重量級鎖了。

  • 鎖消除

    消除鎖是虛擬機另外一種鎖的優化,這種優化更徹底,Java虛擬機在JIT編譯時(可以簡單理解爲當某段代碼即將第一次被執行時進行編譯,又稱即時編譯),通過對運行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間,如下StringBuffer的append是一個同步方法,但是在add方法中的StringBuffer屬於一個局部變量,並且不會被其他線程所使用,因此StringBuffer不可能存在共享資源競爭的情景,JVM會自動將其鎖消除。

    public class StringBufferRemoveSync {
    
        public void add(String str1, String str2) {
            //StringBuffer是線程安全,由於sb只會在append方法中使用,不可能被其他線程引用
            //因此sb屬於不可能共享的資源,JVM會自動消除內部的鎖
            StringBuffer sb = new StringBuffer();
            sb.append(str1).append(str2);
        }
    
        public static void main(String[] args) {
            StringBufferRemoveSync rmsync = new StringBufferRemoveSync();
            for (int i = 0; i < 10000000; i++) {
                rmsync.add("abc", "123");
            }
        }
    }

synchronize的可重入性:

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

public class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync();
    static int i=0;
    static int j=0;
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            //this,當前實例對象鎖
            synchronized(this){
                i++;
                increase();//synchronized的可重入性
            }
        }
    }
 
    public synchronized void increase(){
        j++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(i);
    }
}

Tip:

1.synchronized關鍵字不能繼承。也就是說子類重寫了父類中用synchronized修飾的方法,子類的方法仍然不是同步的。

2.定義接口方法時,不能使用synchronized關鍵字。

3.構造方法不能使用synchronized關鍵字,但是可以使用synchronized代碼塊。

1.2 volatile

volatile這個關鍵字可能很多朋友都聽說過,或許也都用過。在Java 5之前,它是一個備受爭議的關鍵字,因爲在程序中使用它往往會導致出人意料的結果。在Java 5之後,volatile關鍵字才得以重獲生機。

同樣可以參考前面的鏈接進行查看java內存機制。

在理解java內存的基礎上,我們來進行後續的學習

下面例子中展示了原子操作和非原子操作

x = 10;         //語句1  直接將數值10賦值給x,也就是說線程執行這個語句的會直接將數值10寫入到工作內存中。
y = x;         //語句2 它先要去讀取x的值,再將x的值寫入工作內存,雖然讀取x的值以及 將x的值寫入工作內存 這2個操作都是原子性操作,但是合起來就不是原子性操作了。
x++;           //語句3 讀取x的值,進行加1操作,寫入新的值。非原子操作
x = x + 1;     //語句4 讀取x的值,進行加1操作,寫入新的值。非原子操作

1.2.1 volatile關鍵字含義

一旦一個共享變量(類的成員變量、類的靜態成員變量)被volatile修飾之後,那麼就具備了兩層語義:

1)保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。

2)禁止進行指令重排序。

先看一段代碼,假如線程1先執行,線程2後執行

//線程1
boolean stop = false;
while(!stop){
    doSomething();
}
  
//線程2
stop = true;

這段代碼是很典型的一段代碼,很多人在中斷線程時可能都會採用這種標記辦法。但是事實上,這段代碼會完全運行正確麼?即一定會將線程中斷麼?不一定,也許在大多數時候,這個代碼能夠把線程中斷,但是也有可能會導致無法中斷線程(雖然這個可能性很小,但是隻要一旦發生這種情況就會造成死循環了)。

下面解釋一下這段代碼爲何有可能導致無法中斷線程。在前面已經解釋過,每個線程在運行過程中都有自己的工作內存,那麼線程1在運行的時候,會將stop變量的值拷貝一份放在自己的工作內存當中。

那麼當線程2更改了stop變量的值之後,但是還沒來得及寫入主存當中,線程2轉去做其他事情了,那麼線程1由於不知道線程2對stop變量的更改,因此還會一直循環下去。

但是用volatile修飾之後就變得不一樣了:

第一:使用volatile關鍵字會強制將修改的值立即寫入主存;

第二:使用volatile關鍵字的話,當線程2進行修改時,會導致線程1的工作內存中緩存變量stop的緩存行無效(反映到硬件層的話,就是CPU的L1或者L2緩存中對應的緩存行無效);

第三:由於線程1的工作內存中緩存變量stop的緩存行無效,所以線程1再次讀取變量stop的值時會去主存讀取。

那麼在線程2修改stop值時(當然這裏包括2個操作,修改線程2工作內存中的值,然後將修改後的值寫入內存),會使得線程1的工作內存中緩存變量stop的緩存行無效,然後線程1讀取時,發現自己的緩存行無效,它會等待緩存行對應的主存地址被更新之後,然後去對應的主存讀取最新的值。那麼線程1讀取到的就是最新的正確的值。

1.2.2 volatile保證原子性?

看下下面的例子

public class Test {
    public volatile int inc = 0;
      
    public void increase() {
        inc++;
    }
      
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
          
        while(Thread.activeCount()>1)  //保證前面的線程都執行完
            Thread.yield();
        System.out.println(test.inc);
    }
}
// 每次輸出結果不一致。

爲什麼每次輸出結果不一致呢???

這裏面就有一個誤區了,volatile關鍵字能保證可見性沒有錯,但是上面的程序錯在沒能保證原子性。可見性只能保證每次讀取的是最新的值,但是volatile沒辦法保證對變量的操作的原子性。自增操作是不具備原子性的,它包括讀取變量的原始值、進行加1操作、寫入工作內存。那麼就是說自增操作的三個子操作可能會分割開執行,就有可能導致下面這種情況出現:

假如某個時刻變量inc的值爲10,

線程1對變量進行自增操作,線程1先讀取了變量inc的原始值,然後線程1被阻塞了;

然後線程2對變量進行自增操作,線程2也去讀取變量inc的原始值,由於線程1只是對變量inc進行讀取操作,而沒有對變量進行修改操作,所以不會導致線程2的工作內存中緩存變量inc的緩存行無效,所以線程2會直接去主存讀取inc的值,發現inc的值時10,然後進行加1操作,並把11寫入工作內存,最後寫入主存。

然後線程1接着進行加1操作,由於已經讀取了inc的值,注意此時在線程1的工作內存中inc的值仍然爲10,所以線程1對inc進行加1操作後inc的值爲11,然後將11寫入工作內存,最後寫入主存。

那麼兩個線程分別進行了一次自增操作後,inc只增加了1。

要注意,線程1對變量進行讀取操作之後,被阻塞了的話,並沒有對inc值進行修改。然後雖然volatile能保證線程2對變量inc的值讀取是從內存中讀取的,但是線程1沒有進行修改,所以線程2根本就不會看到修改的值。

那這種情況該怎麼辦才能保證原子性呢?

  • synchronized改寫,使用方法鎖,將inc方法設置爲同步方法即可
  • lock方式,這個在下一章節學習後在進行改寫

1.2.3 volatile保證有序性?

在前面提到volatile關鍵字能禁止指令重排序,所以volatile能在一定程度上保證有序性。

volatile關鍵字禁止指令重排序有兩層意思:

1)當程序執行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行;

2)在進行指令優化時,不能將在對volatile變量訪問的語句放在其後面執行,也不能把volatile變量後面的語句放到其前面執行。

舉例說明

//x、y爲非volatile變量
//flag爲volatile變量
x = 2;        //語句1
y = 0;        //語句2
flag = true;  //語句3
x = 4;         //語句4
y = -1;       //語句5
/*
由於flag變量爲volatile變量,那麼在進行指令重排序的過程的時候,不會將語句3放到語句1、語句2前面,也不會講語句3放到語句4、語句5後面。但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。
*/

1.2.4 volatile的原理和實現機制

摘自《深入理解Java虛擬機》:

“觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的彙編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令”

lock前綴指令實際上相當於一個內存屏障(也成內存柵欄),內存屏障會提供3個功能:

1)它確保指令重排序時不會把其後面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;

2)它會強制將對緩存的修改操作立即寫入主存;

3)如果是寫操作,它會導致其他CPU中對應的緩存行無效。

1.2.5 volatile關鍵字的場景

synchronized關鍵字是防止多個線程同時執行一段代碼,那麼就會很影響程序執行效率,而volatile關鍵字在某些情況下性能要優於synchronized,但是要注意volatile關鍵字是無法替代synchronized關鍵字的,因爲volatile關鍵字無法保證操作的原子性。通常來說,使用volatile必須具備以下2個條件:

1)對變量的寫操作不依賴於當前值

2)該變量沒有包含在具有其他變量的不變式中

實際上,這些條件表明,可以被寫入 volatile 變量的這些有效值獨立於任何程序的狀態,包括變量的當前狀態。

事實上,我的理解就是上面的2個條件需要保證操作是原子性操作,才能保證使用volatile關鍵字的程序在併發時能夠正確執行。

1.3 final

final修飾的常量會寫進常量池,作爲共享變量,而且無法修改指定的值,所以線程安全,具體可以看我之前總結的另外一篇 [java常見final類](文檔:java常見final類.md
鏈接:http://note.youdao.com/noteshare?id=61c46e0f232d0e208ea5ddac4656b73e&sub=274C6869F867425ABCD570CA01E37392)。

2、Java的Lock框架

因爲sychronized粒度有些大,在處理實際問題時存在諸多侷限性,比如響應中斷等。而Lock提供了更廣泛的鎖操作,它能以更優雅的方式處理線程同步問題。

下面我們來了解以下java中Lock框架部分。

2、java CAS

搬運資料:https://www.cnblogs.com/zhuawang/p/4196904.html

2.1 什麼是CAS

比較和交換(Conmpare And Swap)是用於實現多線程同步的原子指令。 它將內存位置的內容與給定值進行比較,只有在相同的情況下,將該內存位置的內容修改爲新的給定值。 這是作爲單個原子操作完成的。 原子性保證新值基於最新信息計算; 如果該值在同一時間被另一個線程更新,則寫入將失敗。 操作結果必須說明是否進行替換; 這可以通過一個簡單的布爾響應(這個變體通常稱爲比較和設置),或通過返回從內存位置讀取的值來完成。 JAVA1.5開始引入了CAS,主要代碼都放在JUC的atomic包下。是同步鎖的一種樂觀鎖。

CAS 操作包含三個操作數 —— 內存位置(V)、預期原值(A)和新值(B)。”我認爲位置 V 應該包含值 A;如果包含該值,則將 B 放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可。“

類似於 CAS 的指令允許算法執行讀-修改-寫操作,而無需害怕其他線程同時 修改變量,因爲如果其他線程修改變量,那麼 CAS 會檢測它(並失敗),算法 可以對該操作重新計算。

  • 非阻塞算法 (nonblocking algorithms)

    一個線程的失敗或者掛起不應該影響其他線程的失敗或掛起的算法。

現代的CPU提供了特殊的指令,可以自動更新共享數據,而且能夠檢測到其他線程的干擾,而 compareAndSet() 就用這些代替了鎖定。

拿出AtomicInteger來研究在沒有鎖的情況下是如何做到數據正確性的。

private volatile int value;	// 在沒有鎖的機制下可能需要藉助volatile關鍵字保證數據的可見性

/** 自增
採用了CAS操作,每次從內存中讀取數據然後將此數據和+1後的結果進行CAS操作,如果成功就返回結果,否則重試直到成功爲止。compareAndSet利用JNI來完成CPU指令的操作整體的過程就是這樣子的,利用CPU的CAS指令,同時藉助JNI來完成Java的非阻塞算法。其它原子操作都是利用類似的特性完成的。
*/
public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}

public final boolean compareAndSet(int expect, int update) {   
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

那麼問題就來了,成功過程中需要2個步驟:比較this == expect,替換this = update,compareAndSwapInt如何這兩個步驟的原子性呢?

2.2 CAS原理

更底層的原理其實不需要特別瞭解,主要就是CAS底層直接調用的CPU命令保證了原子性

CAS通過調用JNI的代碼實現的。JNI:Java Native Interface爲JAVA本地調用,允許java調用其他語言。

而compareAndSwapInt就是藉助C來調用CPU底層指令實現的。

下面從分析比較常用的CPU(intel x86)來解釋CAS的實現原理。

下面是sun.misc.Unsafe類的compareAndSwapInt()方法的源代碼:

public final native boolean compareAndSwapInt(Object o, long offset,
                                              int expected,
                                              int x);

可以看到這是個本地方法調用。這個本地方法在openjdk中依次調用的c++代碼爲:unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp。這個本地方法的最終實現在openjdk的如下位置:openjdk-7-fcs-src-b147-27jun2011\openjdk\hotspot\src\oscpu\windowsx86\vm\ atomicwindowsx86.inline.hpp(對應於windows操作系統,X86處理器)。下面是對應於intel x86處理器的源代碼的片段:

// 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
  }
}

如上面源代碼所示,程序會根據當前處理器的類型來決定是否爲cmpxchg指令添加lock前綴。如果程序是在多處理器上運行,就爲cmpxchg指令加上lock前綴(lock cmpxchg)。反之,如果程序是在單處理器上運行,就省略lock前綴(單處理器自身會維護單處理器內的順序一致性,不需要lock前綴提供的內存屏障效果)。

intel的手冊對lock前綴的說明如下:

  1. 確保對內存的讀-改-寫操作原子執行。在Pentium及Pentium之前的處理器中,帶有lock前綴的指令在執行期間會鎖住總線,使得其他處理器暫時無法通過總線訪問內存。很顯然,這會帶來昂貴的開銷。從Pentium 4,Intel Xeon及P6處理器開始,intel在原有總線鎖的基礎上做了一個很有意義的優化:如果要訪問的內存區域(area of memory)在lock前綴指令執行期間已經在處理器內部的緩存中被鎖定(即包含該內存區域的緩存行當前處於獨佔或以修改狀態),並且該內存區域被完全包含在單個緩存行(cache line)中,那麼處理器將直接執行該指令。由於在指令執行期間該緩存行會一直被鎖定,其它處理器無法讀/寫該指令要訪問的內存區域,因此能保證指令執行的原子性。這個操作過程叫做緩存鎖定(cache locking),緩存鎖定將大大降低lock前綴指令的執行開銷,但是當多處理器之間的競爭程度很高或者指令訪問的內存地址未對齊時,仍然會鎖住總線。
  2. 禁止該指令與之前和之後的讀和寫指令重排序。
  3. 把寫緩衝區中的所有數據刷新到內存中。

2.3 CAS的缺點

CAS雖然很高效的解決原子操作,但是CAS仍然存在三大問題。

  • ABA問題

  • 循環時間長開銷大

  • 只能保證一個共享變量的原子操作

2.3.1 ABA問題

因爲CAS需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A。

從Java1.5開始JDK的atomic包裏提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。

https://hesey.wang/2011/09/resolve-aba-by-atomicstampedreference.html

2.3.2 循環時間長開銷大

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

2.3.3 只能保證一個共享變量的原子操作

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

2.4 concurrent包的實現

由於java的CAS同時具有 volatile 讀和volatile寫的內存語義,因此Java線程之間的通信現在有了下面四種方式:

  1. A線程寫volatile變量,隨後B線程讀這個volatile變量。
  2. A線程寫volatile變量,隨後B線程用CAS更新這個volatile變量。
  3. A線程用CAS更新一個volatile變量,隨後B線程用CAS更新這個volatile變量。
  4. A線程用CAS更新一個volatile變量,隨後B線程讀這個volatile變量。

Java的CAS會使用現代處理器上提供的高效機器級別原子指令,這些原子指令以原子方式對內存執行讀-改-寫操作,這是在多處理器中實現同步的關鍵(從本質上來說,能夠支持原子性讀-改-寫指令的計算機器,是順序計算圖靈機的異步等價機器,因此任何現代的多處理器都會去支持某種能對內存執行原子性讀-改-寫操作的原子指令)。同時,volatile變量的讀/寫和CAS可以實現線程之間的通信。把這些特性整合在一起,就形成了整個concurrent包得以實現的基石。如果我們仔細分析concurrent包的源代碼實現,會發現一個通用化的實現模式:

  1. 首先,聲明共享變量爲volatile;
  2. 然後,使用CAS的原子條件更新來實現線程之間的同步;
  3. 同時,配合以volatile的讀/寫和CAS所具有的volatile讀和寫的內存語義來實現線程之間的通信。

AQS,非阻塞數據結構和原子變量類(java.util.concurrent.atomic包中的類),這些concurrent包中的基礎類都是使用這種模式來實現的,而concurrent包中的高層類又是依賴於這些基礎類來實現的。從整體來看,concurrent包的實現示意圖如下:

img

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