多線程與高併發基礎概念與synchronized

線程基礎概念與synchronized
1線程、進程、纖程的基本概念
(1)進程
 
進程是具有一定獨立功能的程序關於某個數據集合上的一次運行活動,進程是系統進行資源分配和調度的一個獨立單位。每個進程都有自己的獨立內存空間,不同進程通過進程間通信來通信。由於進程比較重量,佔據獨立的內存,所以上下文進程間的切換開銷(棧、寄存器、虛擬內存、文件句柄等)比較大,但相對比較穩定安全。
 
(2)線程
 
線程是進程的一個實體,是CPU調度和分派的基本單位,它是比進程更小的能獨立運行的基本單位.線程自己基本上不擁有系統資源,只擁有一點在運行中必不可少的資源(如程序計數器,一組寄存器和棧),但是它可與同屬一個進程的其他的線程共享進程所擁有的全部資源。線程間通信主要通過共享內存,上下文切換很快,資源開銷較少,但相比進程不夠穩定容易丟失數據。
 
(3)協程
 
協程是一種用戶態的輕量級線程,協程的調度完全由用戶控制。協程擁有自己的寄存器上下文和棧。協程調度切換時,將寄存器上下文和棧保存到其他地方,在切回來的時候,恢復先前保存的寄存器上下文和棧,直接操作棧則基本沒有內核切換的開銷,可以不加鎖的訪問全局變量,所以上下文的切換非常快。
 
 
 2線程的基本方法
sleep():在指定的毫秒數內讓當前正在執行的線程休眠(暫停執行)。休眠的線程進入阻塞狀態。
 
yield():調用yield方法的線程,會禮讓其他線程先運行。(大概率其他線程先運行,小概率自己還會運行)
 
join():調用join方法的線程強制執行,其他線程處於阻塞狀態,等該線程執行完後,其他線程再執行。有可能被外界中斷產生InterruptedException 中斷異常。
 
interrupt():中斷線程
 
wait():導致線程等待,進入堵塞狀態。該方法要在同步方法或者同步代碼塊中才使用的
  
notify():喚醒當前線程,進入運行狀態。該方法要在同步方法或者同步代碼塊中才使用的
  
notifyAll():喚醒所有等待的線程。該方法要在同步方法或者同步代碼塊中才使用的
3線程狀態轉換圖
 (1)新建狀態(New): 
     
 (1)新建狀態(New): 
        當用new操作符創建一個線程時, 例如new Thread(r),線程還沒有開始運行,此時線程處在新建狀態。 當一個線程處於新生狀態時,程序還沒有開始運行線程中的代碼
 
     (2)就緒狀態(Runnable)
        一個新創建的線程並不自動開始運行,要執行線程,必須調用線程的start()方法。當線程對象調用start()方法即啓動了線程,start()方法創建線程運行的系統資源,並調度線程運行run()方法。當start()方法返回後,線程就處於就緒狀態。
        處於就緒狀態的線程並不一定立即運行run()方法,線程還必須同其他線程競爭CPU時間,只有獲得CPU時間纔可以運行線程。因爲在單CPU的計算機系統中,不可能同時運行多個線程,一個時刻僅有一個線程處於運行狀態。因此此時可能有多個線程處於就緒狀態。對多個處於就緒狀態的線程是由Java運行時系統的線程調度程序(thread scheduler)來調度的。

 

 
    (3)運行狀態(Running)

 

        當線程獲得CPU時間後,它才進入運行狀態,真正開始執行run()方法.
 
    (4) 阻塞狀態(Blocked)

 

        線程運行過程中,可能由於各種原因進入阻塞狀態:
        1>線程通過調用sleep方法進入睡眠狀態;
        2>線程調用一個在I/O上被阻塞的操作,即該操作在輸入輸出操作完成之前不會返回到它的調用者;
        3>線程試圖得到一個鎖,而該鎖正被其他線程持有;
        4>線程在等待某個觸發條件;
          
        所謂阻塞狀態是正在運行的線程沒有運行結束,暫時讓出CPU,這時其他處於就緒狀態的線程就可以獲得CPU時間,進入運行狀態。
 
    (5)死亡狀態(Dead)

 

        有兩個原因會導致線程死亡:
         1) run方法正常退出而自然死亡,
         2) 一個未捕獲的異常終止了run方法而使線程猝死。
        爲了確定線程在當前是否存活着(就是要麼是可運行的,要麼是被阻塞了),需要使用isAlive方法。如果是可運行或被阻塞,這個方法返回true; 如果線程仍舊是new狀態且不是可運行的, 或者線程死亡了,則返回false.
 
package juc.c_000;
 
public class T04_ThreadState {
 
    static class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println(this.getState());
 
            for(int i=0; i<10; i++) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
 
                System.out.println(i);
            }
        }
    }
 
    public static void main(String[] args) {
        Thread t = new MyThread();
 
        System.out.println(t.getState());
 
        t.start();
 
        try {
            t.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
 
        System.out.println(t.getState());
 
    }
}
 
 
4synchronized的講解
synchronized關鍵字最主要有以下3種應用方式,下面分別介紹
修飾實例方法,作用於當前實例加鎖,進入同步代碼前要獲得當前實例的鎖
修飾靜態方法,作用於當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖
修飾代碼塊,指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖。
所謂的實例對象鎖就是用synchronized修飾實例對象中的實例方法,注意是實例方法不包括靜態方法,如下
 
public class AccountingSync implements Runnable{
    //共享資源(臨界資源)
    static int i=0;
    /**
     * synchronized 修飾實例方法
     */
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        AccountingSync instance=new AccountingSync();
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
    /**
     * 輸出結果:
     * 2000000
     */
}
 
上述代碼中,我們開啓兩個線程操作同一個共享資源即變量i,由於i++;操作並不具備原子性,該操作是先讀取值,然後寫回一個新值,相當於原來的值加上1,分兩步完成,如果第二個線程在第一個線程讀取舊值和寫回新值期間讀取i的域值,那麼第二個線程就會與第一個線程一起看到同一個值,並執行相同值的加1操作,這也就造成了線程安全失敗,因此對於increase方法必須使用synchronized修飾,以便保證線程安全。此時我們應該注意到synchronized修飾的是實例方法increase,在這樣的情況下,當前線程的鎖便是實例對象instance,注意Java中的線程同步鎖可以是任意對象。從代碼執行結果來看確實是正確的,倘若我們沒有使用synchronized關鍵字,其最終輸出結果就很可能小於2000000,這便是synchronized關鍵字的作用。這裏我們還需要意識到,當一個線程正在訪問一個對象的 synchronized 實例方法,那麼其他線程不能訪問該對象的其他 synchronized 方法,畢竟一個對象只有一把鎖,當一個線程獲取了該對象的鎖之後,其他線程無法獲取該對象的鎖,所以無法訪問該對象的其他synchronized實例方法,但是其他線程還是可以訪問該實例對象的其他非synchronized方法,當然如果是一個線程 A 需要訪問實例對象 obj1 的 synchronized 方法 f1(當前對象鎖是obj1),另一個線程 B 需要訪問實例對象 obj2 的 synchronized 方法 f2(當前對象鎖是obj2),這樣是允許的,因爲兩個實例對象鎖並不同相同,此時如果兩個線程操作數據並非共享的,線程安全是有保障的,遺憾的是如果兩個線程操作的是共享數據,那麼線程安全就有可能無法保證了,如下代碼將演示出該現象
 
public class AccountingSyncBad implements Runnable{
    static int i=0;
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //new新實例
        Thread t1=new Thread(new AccountingSyncBad());
        //new新實例
        Thread t2=new Thread(new AccountingSyncBad());
        t1.start();
        t2.start();
        //join含義:當前線程A等待thread線程終止之後才能從thread.join()返回
        t1.join();
        t2.join();
        System.out.println(i);
    }
}
 
上述代碼與前面不同的是我們同時創建了兩個新實例AccountingSyncBad,然後啓動兩個不同的線程對共享變量i進行操作,但很遺憾操作結果是1452317而不是期望結果2000000,因爲上述代碼犯了嚴重的錯誤,雖然我們使用synchronized修飾了increase方法,但卻new了兩個不同的實例對象,這也就意味着存在着兩個不同的實例對象鎖,因此t1和t2都會進入各自的對象鎖,也就是說t1和t2線程使用的是不同的鎖,因此線程安全是無法保證的。解決這種困境的的方式是將synchronized作用於靜態的increase方法,這樣的話,對象鎖就當前類對象,由於無論創建多少個實例對象,但對於的類對象擁有隻有一個,所有在這樣的情況下對象鎖就是唯一的。下面我們看看如何使用將synchronized作用於靜態的increase方法。
 
public class AccountingSyncClass implements Runnable{
    static int i=0;
    /**
     * 作用於靜態方法,鎖是當前class對象,也就是
     * AccountingSyncClass類對應的class對象
     */
    public static synchronized void increase(){
        i++;
    }
    /**
     * 非靜態,訪問時鎖不一樣不會發生互斥
     */
    public synchronized void increase4Obj(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //new新實例
        Thread t1=new Thread(new AccountingSyncClass());
        //new新實例
        Thread t2=new Thread(new AccountingSyncClass());
        //啓動線程
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(i);
    }
}
 
上述代碼與前面不同的是我們同時創建了兩個新實例AccountingSyncBad,然後啓動兩個不同的線程對共享變量i進行操作,但很遺憾操作結果是1452317而不是期望結果2000000,因爲上述代碼犯了嚴重的錯誤,雖然我們使用synchronized修飾了increase方法,但卻new了兩個不同的實例對象,這也就意味着存在着兩個不同的實例對象鎖,因此t1和t2都會進入各自的對象鎖,也就是說t1和t2線程使用的是不同的鎖,因此線程安全是無法保證的。解決這種困境的的方式是將synchronized作用於靜態的increase方法,這樣的話,對象鎖就當前類對象,由於無論創建多少個實例對象,但對於的類對象擁有隻有一個,所有在這樣的情況下對象鎖就是唯一的。下面我們看看如何使用將synchronized作用於靜態的increase方法。
 
public class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync();
    static int i=0;
    @Override
    public void run() {
        //省略其他耗時操作....
        //使用同步代碼塊對變量i進行同步操作,鎖對象爲instance
        synchronized(instance){
            for(int j=0;j<1000000;j++){
                    i++;
              }
        }
    }
    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);
    }
}
 
 
從代碼看出,將synchronized作用於一個給定的實例對象instance,即當前實例對象就是鎖對象,每次當線程進入synchronized包裹的代碼塊時就會要求當前線程持有instance實例對象鎖,如果當前有其他線程正持有該對象鎖,那麼新到的線程就必須等待,這樣也就保證了每次只有一個線程執行i++;操作。當然除了instance作爲對象外,我們還可以使用this對象(代表當前實例)或者當前類的class對象作爲鎖,如下代碼:
 
//this,當前實例對象鎖
synchronized(this){
    for(int j=0;j<1000000;j++){
        i++;
    }
}
//class對象鎖
synchronized(AccountingSync.class){
    for(int j=0;j<1000000;j++){
        i++;
    }
}
 
synchronized的可重入性
從互斥鎖的設計上來說,當一個線程試圖操作一個由其他線程持有的對象鎖的臨界資源時,將會處於阻塞狀態,但當一個線程再次請求自己持有對象鎖的臨界資源時,這種情況屬於重入鎖,請求將會成功,在java中synchronized是基於原子性的內部鎖機制,是可重入的,因此在一個線程調用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);
    }
}
 
 * 程序在執行過程中,如果出現異常,默認情況鎖會被釋放
 * 所以,在併發處理的過程中,有異常要多加小心,不然可能會發生不一致的情況。
 * 比如,在一個web app處理過程中,多個servlet線程共同訪問同一個資源,這時如果異常處理不合適,
 * 在第一個線程中拋出異常,其他線程就會進入同步代碼區,有可能會訪問到異常產生時的數據。
 * 因此要非常小心的處理同步業務邏輯中的異常
 
5鎖升級

1、Java 對象頭

我們以 Hotspot 虛擬機爲例,Hotspot 的對象頭主要包括兩部分數據:Mark Word(標記字段)、Klass Pointer(類型指針)。
Mark Word:默認存儲對象的 HashCode,分代年齡和鎖標誌位信息。這些信息都是與對象自身定義無關的數據,所以 Mark Word 被設計成一個非固定的數據結構以便在極小的空間內存存儲儘量多的數據。它會根據對象的狀態複用自己的存儲空間,也就是說在運行期間 Mark Word 裏存儲的數據會隨着鎖標誌位的變化而變化。
Klass Point:對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。

2、Monitor

Monitor 可以理解爲一個同步工具或一種同步機制,通常被描述爲一個對象。每一個 Java 對象就有一把看不見的鎖,稱爲內部鎖或者 Monitor 鎖。
Monitor 是線程私有的數據結構,每一個線程都有一個可用 monitor record 列表,同時還有一個全局的可用列表。每一個被鎖住的對象都會和一個 monitor 關聯,同時 monitor 中有一個 Owner 字段存放擁有該鎖的線程的唯一標識,表示該鎖被這個線程佔用。

三、無鎖

無鎖是指沒有對資源進行鎖定,所有的線程都能訪問並修改同一個資源,但同時只有一個線程能修改成功。
無鎖的特點是修改操作會在循環內進行,線程會不斷的嘗試修改共享資源。如果沒有衝突就修改成功並退出,否則就會繼續循環嘗試。如果有多個線程修改同一個值,必定會有一個線程能修改成功,而其他修改失敗的線程會不斷重試直到修改成功。

四、偏向鎖

偏向鎖是指當一段同步代碼一直被同一個線程所訪問時,即不存在多個線程的競爭時,那麼該線程在後續訪問時便會自動獲得鎖,從而降低獲取鎖帶來的消耗,即提高性能。
當一個線程訪問同步代碼塊並獲取鎖時,會在 Mark Word 裏存儲鎖偏向的線程 ID。在線程進入和退出同步塊時不再通過 CAS 操作來加鎖和解鎖,而是檢測 Mark Word 裏是否存儲着指向當前線程的偏向鎖。輕量級鎖的獲取及釋放依賴多次 CAS 原子指令,而偏向鎖只需要在置換 ThreadID 的時候依賴一次 CAS 原子指令即可。
偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖,線程是不會主動釋放偏向鎖的。
關於偏向鎖的撤銷,需要等待全局安全點,即在某個時間點上沒有字節碼正在執行時,它會先暫停擁有偏向鎖的線程,然後判斷鎖對象是否處於被鎖定狀態。如果線程不處於活動狀態,則將對象頭設置成無鎖狀態,並撤銷偏向鎖,恢復到無鎖(標誌位爲01)或輕量級鎖(標誌位爲00)的狀態。
偏向鎖在 JDK 6 及之後版本的 JVM 裏是默認啓用的。可以通過 JVM 參數關閉偏向鎖:-XX:-UseBiasedLocking=false,關閉之後程序默認會進入輕量級鎖狀態。

五、輕量級鎖

輕量級鎖是指當鎖是偏向鎖的時候,卻被另外的線程所訪問,此時偏向鎖就會升級爲輕量級鎖,其他線程會通過自旋(關於自旋的介紹見文末)的形式嘗試獲取鎖,線程不會阻塞,從而提高性能。
輕量級鎖的獲取主要由兩種情況:① 當關閉偏向鎖功能時;② 由於多個線程競爭偏向鎖導致偏向鎖升級爲輕量級鎖。
在代碼進入同步塊的時候,如果同步對象鎖狀態爲無鎖狀態,虛擬機將首先在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的 Mark Word 的拷貝,然後將對象頭中的 Mark Word 複製到鎖記錄中。
拷貝成功後,虛擬機將使用 CAS 操作嘗試將對象的 Mark Word 更新爲指向 Lock Record 的指針,並將 Lock Record 裏的 owner 指針指向對象的 Mark Word。
如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象 Mark Word 的鎖標誌位設置爲“00”,表示此對象處於輕量級鎖定狀態。
如果輕量級鎖的更新操作失敗了,虛擬機首先會檢查對象的 Mark Word 是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行,否則說明多個線程競爭鎖。
若當前只有一個等待線程,則該線程將通過自旋進行等待。但是當自旋超過一定的次數時,輕量級鎖便會升級爲重量級鎖(鎖膨脹)。
另外,當一個線程已持有鎖,另一個線程在自旋,而此時又有第三個線程來訪時,輕量級鎖也會升級爲重量級鎖(鎖膨脹)。

六、重量級鎖

重量級鎖是指當有一個線程獲取鎖之後,其餘所有等待獲取該鎖的線程都會處於阻塞狀態。
重量級鎖通過對象內部的監視器(monitor)實現,而其中 monitor 的本質是依賴於底層操作系統的 Mutex Lock 實現,操作系統實現線程之間的切換需要從用戶態切換到內核態,切換成本非常高。
簡言之,就是所有的控制權都交給了操作系統,由操作系統來負責線程間的調度和線程的狀態變更。而這樣會出現頻繁地對線程運行狀態的切換,線程的掛起和喚醒,從而消耗大量的系統資源,導致性能低下。

七、關於自旋

關於自旋,簡言之就是讓線程喝杯咖啡小憩一下,用代碼解釋就是:
do  {
    // do something}  while  (自旋的規則,或者說自旋的次數)
引入自旋這一規則的原因其實也很簡單,因爲阻塞或喚醒一個 Java 線程需要操作系統切換 CPU 狀態來完成,這種狀態轉換需要耗費處理器時間。如果同步代碼塊中的內容過於簡單,狀態轉換消耗的時間有可能比用戶代碼執行的時間還要長。並且在許多場景中,同步資源的鎖定時間很短,爲了這一小段時間去切換線程,這部分操作的開銷其實是得不償失的。
所以,在物理機器有多個處理器的情況下,當兩個或以上的線程同時並行執行時,我們就可以讓後面那個請求鎖的線程不放棄 CPU 的執行時間,看看持有鎖的線程是否很快就會釋放鎖。而爲了讓當前線程“稍等一下”,我們需讓當前線程進行自旋。如果在自旋完成後前面鎖定同步資源的線程已經釋放了鎖,那麼當前線程就可以不必阻塞而是直接獲取同步資源,從而避免切換線程的開銷。
自旋鎖本身是有缺點的,它不能代替阻塞。自旋等待雖然避免了線程切換的開銷,但它要佔用處理器時間。如果鎖被佔用的時間很短,自旋等待的效果就會非常好。反之,如果鎖被佔用的時間很長,那麼自旋的線程只會白浪費處理器資源。
所以,自旋等待的時間必須要有一定的限度,如果自旋超過了限定次數(默認是10次,可以使用 -XX:PreBlockSpin 來更改)沒有成功獲得鎖,就應當掛起線程。
自旋鎖在 JDK1.4.2 中引入,使用 -XX:+UseSpinning 來開啓。JDK 6 中變爲默認開啓,並且引入了自適應的自旋鎖(適應性自旋鎖)。
自適應自旋鎖意味着自旋的時間(次數)不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞線程,避免浪費處理器資源。
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章