每日一道面試題(第6期)---如何實現多線程中的同步

零零碎碎的東西老是記不長久,僅僅學習別人的文章也只是他人咀嚼後留下的殘渣。無心中發現了這個每日一道面試題,想了想若是隻是簡單地去思考,那麼不只會收效甚微,甚至難一點的題目本身可能都懶得去想,堅持不下來。因此不如把每一次的思考、理解以及別人的看法記錄下來。不只加深本身的理解,更要激勵本身堅持下去。java

在介紹多線程中的同步以前,咱們先來了解下併發編程。git

併發編程

併發編程存在的意義

  • 資源利用率:程序的進行,無非就是cpu對於一條條指令執行的操做。cpu對於指令進行處理的速度是很是快的,若是是串行的話,好比IO操做的速度是遠遠小於cpu操做的速度,這樣cpu就會存在大量的空閒時間,浪費cpu資源。
  • 時間:有時多個任務之間並無什麼關聯,在資源充足的狀況下,他們徹底能夠併發的進行。串行的結果只能是延長完成總任務的時間,下降效率。
  • 公平性:同時申請須要進行的任務,隨意的串行進行執行會打破任務之間的公平性。併發操做使任務能夠同時進行,獲得一樣的資源,保證公平性。

併發編程的缺陷

  • 安全性:併發編程的安全性缺陷是衆所周知的。不一樣的工做線程對同一資源同時進行處理會產生髒數據。典型的例子就是銀行轉帳問題,在同一時間A向B的帳戶、B向A的帳戶轉帳,兩個工做線程同時對餘額進行操做,一定會致使操做事後對不上帳的狀況。
  • 開銷:在單個cpu的狀況下,併發操做是經過cpu頻繁的切換線程達到併發的目的,線程的切換涉及到寄存器的數據保存、更新等操做,須要必定的開銷。在高併發的狀況下,須要考慮到併發操做節省的資源與時間是否能夠彌補線程切換間的開銷。
  • 複雜性:單個線程用到的資源能夠從線程對應的棧、寄存器以及進程中的內存中獲取,在執行時也不會與其餘線程產生交集。多線程編程,勢必會涉及到線程間的通訊、數據同步等問題,一整套的併發機制設計起來很複雜。

併發編程的三大要素

  • 原子性:一個操做或者多個操做,要麼所有執行,要麼都不執行,執行過程當中不可被任何因素打斷。在java中對基本數據類型的賦值與讀取是原子性操做。
  • 可見性:多個線程訪問同一個變量時,一個線程改變了這個變量,其餘線程都要對此值進行同步更新。
  • 有序性:即程序的執行順序按照代碼的前後順序執行。這是由於處理器在執行程序時,爲了優化程序執行效率,會對代碼進行不一樣程度上的指令重排序,固然這種重排序不會改變程序最後運行的結果,由於不會對存在數據依賴的代碼進行重排序。也就是下一行代碼須要用到上一行代碼的數據。在單線程中指令重排序沒有任何問題,可是在多線程中就會出現問題。
//線程1:
context = loadContext();   //語句1
inited = true;             //語句2

 //線程2:
while(!inited ){
   sleep()
}
doSomethingwithconfig(context);
複製代碼

線程1中語句1和語句2沒有數據依懶性,inited僅是一個標記變量,因此這兩個語句可能發生指令重排序。當語句2在語句1以前執行時,這是剛好線程2啓動,標記變量init爲true則線程2認爲初始化已經完成,而此時語句1並無執行,就會形成問題。github

因此說,併發編程中保證原子性、可見性、有序性,是同步的基本要素面試

同步操做

volatile

volatile是java的一個關鍵字,一旦一個共享變量(類的成員變量、靜態變量)被volatile關鍵字修飾,就具有有兩層含義算法

  • 當一個線程對此變量的值進行了更新操做,那麼對其餘線程是可見的。這裏的更新操做,指寫入工做內存。
  • 禁止指令重排序。具體爲volatile變量以前的代碼不會被指令重排序到此變量以後,在程序執行到volatile變量時,以前的代碼必定已經所有執行,以後的代碼必定尚未執行。

這就保證了可見性與有序性,可是volatile並不保證可見性。看下面一段代碼編程

public class Main {
    private volatile static int test = 0;
    private volatile static int count = 10;
    
    public static void main(String[] args) {
        for(int i=0;i<10;i++){
            Main mm = new Main();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j=0;j<10000;j++){
                        mm.increase();
                    }
                    count--;
                }
            }).start();
        }
        while (count > 0){}//全部線程執行完畢
        System.out.println("最後的數據爲" + test);
    }

    private void increase(){test++;}
}
複製代碼

運行後你會發現,每一次的結果都小於100000。這是由於test++這個操做,它不是原子性的,與test自己這個變量無關。數組

test++ 通過三個原子操做,讀取test變量值、test變量進行加一操做、將操做後的變量值寫入工做內存。當線程1執行到前兩步時,線程2開始讀取test變量值,當線程1三個步驟執行完畢時,雖然此時test的值會立馬更新到線程2,可是線程2已經在此以前進行了讀取變量值的操做,因此實際上兩個線程只讓test加了一次。緩存

因此說,volatile只進行一些簡單的同步操做,好比上面提到的標記變量安全

volatile boolean inited = false;
//線程1:
context = loadContext();   //語句1
inited = true;             //語句2

 //線程2:
while(!inited ){
   sleep()
}
doSomethingwithconfig(context);
複製代碼

併發編程中的單例模式性能優化

class Singleton {
    private volatile static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}
複製代碼

這個雖然有synchronized關鍵字來保證單線程訪問,可是這裏面實際上是instance=new Singleton()指令重排序的問題,這一步有三個原子性操做

  • 爲instance分配內存
  • 調用構造參數初始化成員變量
  • 將instance對象指向分配好的內存空間 其中第二步與第三步是會發生指令重排序的,這兩部之間並無數據依賴。若是第三步在第二步以前執行(此時instance已經非空了),此時另外一個線程發現instance非null,就直接拿去使用了,這時第二步初始化變量操做尚未執行,就會發生錯誤。

synchronized

synchronized一樣是java中的一個關鍵字。它經過鎖機制實現同步,要執行代碼,則必需要得到鎖,只有得到鎖對象的線程才能執行鎖住的代碼,其餘線程沒有得到鎖只能阻塞。鎖有對象鎖類鎖。同步有兩種表現形式:同步代碼塊同步方法

  • 對象鎖:僅針對該類的一個實例在不一樣線程中的應用,若是是同一個類的多個對象在不一樣的線程中,則不會有影響。
  • 類鎖:針對該類的全部實例。

同步代碼塊

對象鎖

class Test{
    public void testMethod(){
        synchronized(this){
            ...
        }
    }
}
複製代碼

類鎖

class Test{
    public void testMethod(){
        synchronized(Test.class){
            ...
        }
    }
}
複製代碼

對象鎖。這裏的o表明任意一個object對象或者數組,誰擁有這個對象誰就能夠執行該程序塊代碼

class Test{
    public void testMethod(){
        synchronized(o){
            ...
        }
    }
}
複製代碼

同步方法

類鎖

class Test{
    public synchronized static void testMethod(){
        ...
    }
}
複製代碼

對象鎖

class Test{
    public synchronized void testMethod(){
        ...
    }
}
複製代碼

ReentrantLock

ReentrantLock是一個類,它的同步方法與synchronized大體相同。

基本用法

ReentrantLock lock = new ReentrantLock(); //參數默認false,不公平鎖
.....................
lock.lock(); //若是被其它資源鎖定,會在此等待鎖釋放,達到暫停的效果
try {
    //操做
} finally {
    lock.unlock();  //釋放鎖
}
複製代碼

ReentrantLock經過lock方法與unlock方法顯式的獲取鎖與釋放鎖,與synchronized隱式的獲取鎖不一樣。當線程執行到lock.lock()方法時,會嘗試獲取鎖,獲取到鎖則執行下去,獲取不到則會阻塞。unlock()方法則會釋放當前線程所持有的鎖,若是沒有鎖能夠釋放可能會發生異常。

顯式的獲取鎖雖然比隱式的自動獲取鎖麻煩了很多,但多了許多可控制的狀況。咱們能夠中斷獲取鎖、延遲獲取鎖等一些操做。

公平鎖

當許多線程在隊列中等待鎖時,cpu會隨機挑選一個線程得到鎖。這樣就會出現飢餓現象,即優先級低的線程不斷被優先級高的線程搶佔鎖資源,以致於很長時間得到不到鎖,這就是不公平鎖。RenntrantLock可使用公平鎖,即cpu調度按照線程前後等待的順序得到鎖,避免飢餓現象。可是執行效率會比較低,由於須要維護一個有序隊列。synchronized是不公平鎖。

ReentrantLock lock = new ReentrantLock(true);
複製代碼

經過在建立對象時傳入boolean對象表示使用什麼鎖,默認爲false不公平鎖。

synchronized與reentrantLock比較

  • ReentrantLock實現Lock接口,是一個類,synchronized是java關鍵字,是內置的語言實現
  • synchron在發生異常時,會自動釋放鎖,幾乎不會形成死鎖。ReentrantLock須要手動的釋放鎖(unLock),因此在使用時應在finally代碼塊中釋放鎖。
  • ReentrantLock能夠中斷鎖的獲取,即獲取不到鎖時繼續執行下去,而synchronized卻不能夠,會一直阻塞。
  • 經過lock能夠知道有沒有成功獲取鎖,synchronized不能夠

能夠看出,ReentrantLock實現了許多更高級的功能,不過卻多了點複雜性。在性能上來講,競爭不激烈時,二者的性能是差很少的,不過當競爭激烈時,即有大量線程等待獲取鎖,ReentrantLock的性能要更好一些,具體的使用看狀況進行。

jdk1.6之前synchronized的性能是不好的,jdk1.6之後對synchronized的性能優化了很多,和ReentrantLock性能差不了多少。官方也表示更支持synchronized,之後還有優化的餘地,因此在都能符合需求的狀況下,推薦使用synchronized。

CAS原子操做

樂觀鎖與悲觀鎖:

cpu調度線程,經過將時間片分配給不一樣的線程進行調度。時間片的切換也就是線程的切換,須要清除寄存器、緩存數據,切換後加載線程須要的數據,須要耗費必定的時間。線程阻塞後,經過notify、notifyAll喚醒。假如線程1在嘗試獲取鎖,獲取失敗,掛起。這時鎖被釋放,線程1被喚醒,嘗試獲取鎖,結果又被其餘線程搶佔鎖,線程1繼續掛起,獲取鎖的線程只佔用鎖很短的時間,釋放鎖,線程1又被喚醒。。。就這樣,線程1反覆的掛起、喚醒,線程1認爲其餘線程獲取鎖就必定會對鎖內的資源進行更新等操做,因此不斷等待,這就是悲觀鎖。synchronized這種獨佔鎖就是悲觀鎖。

樂觀鎖並不加鎖,首先會認爲在本身修改資源以前其餘線程不會對資源進行更新等操做,它會嘗試用鎖內資源進行本身的操做,若是修改後的數據發生衝突,就會放棄以前的操做。就這樣一直循環,知道操做成功。

CAS就是一種樂觀鎖的概念,內有三個操做數---內存原值(C)、預期舊值(A)、新值(B),當且只當內存原值與預期舊值的結果同樣時,才更新新值。否則就是不斷地循環嘗試。Java中java.util.concurrent.atomic包相關類就是 CAS的實現.

類名 說明
AtomicBoolean 能夠用原子方式更新的 boolean 值。
AtomicInteger 能夠用原子方式更新的 int 值。
AtomicIntegerArray 能夠用原子方式更新其元素的 int 數組。
AtomicIntegerFieldUpdater 基於反射的實用工具,能夠對指定類的指定 volatile int 字段進行原子更新。
AtomicLong 能夠用原子方式更新的 long 值。
AtomicLongArray 能夠用原子方式更新其元素的 long 數組。
AtomicLongFieldUpdater 基於反射的實用工具,能夠對指定類的指定 volatile long 字段進行原子更新。
AtomicMarkableReference AtomicMarkableReference 維護帶有標記位的對象引用,能夠原子方式對其進行更新。
AtomicReference 能夠用原子方式更新的對象引用。
AtomicReferenceArray 能夠用原子方式更新其元素的對象引用數組。
AtomicReferenceFieldUpdater 基於反射的實用工具,能夠對指定類的指定 volatile 字段進行原子更新。
AtomicStampedReference AtomicStampedReference 維護帶有整數「標誌」的對象引用,能夠用原子方式對其進行更新。

這種不須要鎖的非阻塞算法,在性能上是要優於阻塞算法。通常使用以下,實現自增i++的同步操做

public class Test {
    public AtomicInteger i;
    public void add() {
        i.getAndIncrement();
    }
}
複製代碼

CAS的問題

  • ABA問題。一個值從A變爲B又變爲A,它的值其實是變化了,但是CAS卻識別不出來。這種問題解決思路就是在變量前面加上版本號,即1A-2B-3A。從Java1.5開始JDK的atomic包裏提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法做用是首先檢查當前引用是否等於預期引用,而且當前標誌是否等於預期標誌,若是所有相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。
  • CAS操做若是長時間不成功,會給cpu帶來巨大的開銷。
相關文章
相關標籤/搜索