多線程安全(synchronized、三大特性、生命周期以及優缺點)


一、線程安全

一個對象是否安全取決於它是否被多個線程訪問(訪問是訪問對象的方式)。要使對象線程安全,name需要采用同步的機制來協同對對象可變狀態的訪問。(java這邊采用synchronized,其他還有volatile類型的變量,顯式鎖以及原子變量)

 

當某個多線程訪問同一個可變狀態時候沒有同步,則會出現錯誤,解決方法:

1、不在線程之間共享該變量

2、將該變量修改為不可變變量

3、訪問狀態時候使用同步

 

安全性的解釋:當多線程訪問某個類時,這個類始終能表現出正確的行為(不管運行時采用何種跳讀方式或者這些線程將如何交替執行,並且在主調代碼中不需要任何額外的同步或協同),那么這個類是安全的。

  • 無狀態對象一定是線程安全的。

  • 在實際情況下,盡可能使用戶現有的線程安全對象,比如說用vector 而不是 ArrayList。

要想並發程序正確地執行,必須要保證“原子性”,“可見性”和"有序性"。只要有一個沒有被保證,就有可能導致程序運行不正確。

二、並發三大特性

先說說並發,並發是指 在操作系統中,一個時間段中有幾個程序都處於已啟動運行到運行完畢之間,且這幾個程序都是在同一個處理機上運行,擔任一個時刻點上只有一個程序在處理機上運行。

 

1、原子性

原子性的意思代表着————“不可分割”,很多操作都是非原子性。

 

 

a.競態條件

當某個計算的正確性取決於多個線程的交替執行順序時,那么就會發生競態條件,也就是說,正確的結果取決於運氣。競態條件和原子性相關,或者說,之所以代碼會發生競態條件,就是因為代碼不是以院子方式操作的,而是一種復合操作。

在多線程沒有同步的情況下,多種操作序列的執行時序發生變化導致錯誤(是指設備或系統出現不恰當的執行時序,而得到不正確的結果)。即類型為先檢查后執行操作。


Class NotSafeThread{
    int i = 0;
    public void Nst(){
        if(i==0){
            //這邊就會出現競態,如果兩個線程AB都運行這邊,A判斷 i == 0, i++操作 
            //那么i為1, 但是B線程也在A判斷的時候判斷為True, 又進行一次 i++, i=2了
            i++;
        }
    }
}

 

b.復合操作

我們將 “先檢查后執行” 和 “讀取+修改+寫入” 等操作的院子形式成為復合操作:包含一組必須以院子方式執行的操作以確保線程安全。

 

c.原子操作(atomic operations)

原子操作指的是在一步之內就完成而且不能被中斷

//比如 多線程中 int i = 0; i++; 多個線程的時候會出現問題。
++count 看起來是一個操作,但這個操作並非原子性的,因為它可以被分成三個獨立的步驟:①讀取count的值 ②值+1 ③將計算結果寫入count 這是一個“讀取-修改-寫入” 的操作序列,並且結果狀態依賴於之前的狀態。


public void service(ServletRequest req,ServletResponse resq){
    BigInteger i = extractFromRequest(req);
    BigInteger[] factor= factor(i);
    ++count;
    encodeIntoResponse(resp,factors);

}

 

 x = 10;        //語句1 
 y = x;         //語句2
 x++;           //語句3
 x = x + 1;     //語句4

只有語句1是原子性操作,其他三個語句都不是原子性操作。

語句1是直接將數值10賦值給 x,也就是說線程執行這個語句的會直接將數值10寫入到工作內存中。

語句2實際上包含2個操作,它先要去讀取x的值,在將x的值寫入工作內存,雖然讀取 x 的值以及將 x 的值寫入到工作內存,這兩個操作都是原子性操作,但是合起來就不是原子性操作了。

同樣的,x++ 和 x = x + 1 包括三個操作: 讀取 x 的值,進行 +1 操作,寫入新的值。

也就是說,只有簡單的讀取、賦值(而且必須是將數字賦值給某個變量,變量之間的相互賦值不是原子操作)才是原子操作。

 

 

2、可見性

synchronized 關鍵值,開始時會從內存中讀取,結束時會將變化刷新到內存中,所以是可見的。

volatile 關鍵值,通過添加lock指令,也是可見的。

可見性

  • 多線程環境下,一個線程對於某個共享變量的更新,后續訪問該變量的線程可能無法立即讀取到這個更新的結果,這就是不可兼得情 況。

  • 可見性就是指一個線程對共享變量的更新的結果對於讀取相應共享變量的線程而言是否可見的問題。

  • 可見性和原子性的聯系和區別:

    • 原子性描述的是一個線程對共享變量的更新,從另一個線程的角度來看,它要么完成,要么尚未發生。

    • 可見性描述一個線程對共享變量的更新對於另一個線程而言是否可見。


//普通情況下,多線程不能保證可見性

bool stop = false;

new Thread(() -> { 
    System.out.println("Ordinary A is running..."); 
    while (!stop) ;
    System.out.println("Ordinary A is terminated."); 
}).start(); 

Thread.sleep(10); 
new Thread(() -> { 
    System.out.println("Ordinary B is running..."); 
    stop = true; 
    System.out.println("Ordinary B is terminated."); 
}).start();

某次運行結果: Ordinary A is running... Ordinary B is running... Ordinary B is terminated.


 

3、有序性(可見性是有序性的基礎)

被 synchronized 修飾的代碼只能被當前線程占用,避免由於其他線程的執行導致的無序。

volatile 關鍵字包含了禁止指令重排序的語義,使其具有有序性。

 

有序性:即程序執行的順序按照代碼的先后順序執行。舉個簡單的例子,看下面這段代碼:


int i = 0;
boolean flag = false;
i = 1;                // 語句1 
flag = true;          // 語句2


上面代碼定義了一個int型變量,定義了一個boolean類型變量,然后分別對兩個變量進行賦值操作。從代碼順序上看,語句1是在語句2前面的,nameJVM在真正執行這段代碼的時候會保證 語句1 一定會在 語句2 前面執行嗎? 不一定,為什么呢? 這里可能會發生指令重排序(Instruction Reorder)

 

a.重排序

下面解釋一下什么是指令重排序,一般來說,處理器為了提高程序運行效率,可能會輸入代碼進行優化,它不保證程序中哥哥語句的執行先后順序同代碼中的順序一致,但是他會保證程序最終執行結果和代碼順序執行的結果是一致的。比如上面的代碼中,語句1 和 語句2 誰限制性對最終的程序結果並沒有影響,那么就有可能在執行過程中,語句2 先執行而 語句1 后執行。

但是要之一,雖然處理器會對指令進行重新排序,但是它會保證程序最終結果會和代碼順序執行結果相同,那么它靠什么保證的呢?再看下面一個例子:


int a = 10;    //語句1 
int r = 2;     //語句2 
a = a + 3;     //語句3 
r = a*a;       //語句4

這段代碼有4個語句,那么可能的一個執行順序是:

那么可不可能是這個執行順序呢:語句2 語句1 語句4 語句3

不可能,因為處理器在進行重排序時是會考慮指令之間的數據依賴性,如果一個指令Instruction 2 必須用到 Instruction 1 的結果,那么處理器會保證 Instruction 1 會在 Instruction 2 之前執行。

雖然重排序不會影響單個線程內程序執行的結果,但是多線程呢,? 
下面看一個例子:

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

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

   上面代碼中,由於 語句1 和 語句2 ,沒有數據依賴性, 因此可能會被重排序。假如發生了重排序,在線程 1 執行過程中先執行 語句2,而此時線程2 會以為初始化工作已經完成,那么就會跳出 while循環,去執行doSomethingwithconfig(context)方法,而此時context並沒有被初始化,就會導致程序出錯。

所以:
  • 重排序可能導致線程安全問題

  • 重排序不是必然出現的

  • 指令重排序不會影響單個線程的執行,但是會影響線程並發執行的正確性。

 

b.先行發生原則

鎖的在臨界區"許進不許出"原則: (什么是臨界區?)

臨界區外的語句可以被編譯器重排序到臨界區之外,但是臨界區之內的語句不可以重排序到臨界區之外

多個臨界區的具體規則:

鎖申請和鎖釋放不能被重排序

兩個鎖申請操作不能被重排序

兩個鎖釋放操作不能被重排序

解釋:

Java虛擬機會在臨界區的開始之前和結束之后分別插入一個獲取屏障和釋放屏障,從而進制臨界區內的操作被排到臨界區之前和之后

Java內存模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為 happens-before 原則。
如果兩個操作的執行次序從happens-before原則推導出來,那么它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。

 

下面就來具體介紹下happens-before原則(先行發生原則):

  • 程序次序規則 : 一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在后面的操作(因為虛擬機可能對程序代碼進行了指令重排序。雖然進行了重排序,但是最終執行的結果是與程序順序執行的結果一致的,它只會對不存在數據依賴性的指令進行重排序。

  • 鎖定規則 : 一個unLock操作先行發生於后面對同一個鎖的lock操作

  • volatile 變量規則 : 對一個變量的寫操作先發生於后面對這個變量的操作

  • 傳遞規則 : 如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A發生於操作C

  • 線程啟動規則 : Thread對象的start()方法先行發生於此線程的每一個動作

  • 線程中斷規則 : 對線程interrupt()方法的調用先行發生於被中斷線程的艾瑪檢測到中斷事件發生

  • 線程終結規則 : 線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行

  • 對象終結規則 : 一個對象的初始化完成先行發生於它的finalize()方法的開始

 

 

三、線程的生命周期

 

新建狀態(New):當線程對象創建后,即進入了新建狀態,如:Thread t = new MyThread();

 

就緒狀態(Runable):當調用線程對象的start() 方法(t.start();),線程即進入就緒狀態。處於就緒狀態的線程,只是說明此線程已經做好了准備,及時等待CPU調用執行,並不是說執行了t.start() 此線程立即就會執行;

 

運行狀態(Running):當CPU開始調度處於就緒狀態的線程時,此時線程才得以真正執行,即進入到運行狀態。注:就緒狀態是進入到運行狀態的唯一入口,也就是說,線程要想進入運行狀態執行,首先必須處於就緒狀態中。

 

阻塞狀態(Blocked):處於運行狀態中的線程由於某種原因,暫時放棄對CPU的使用權,停止執行,此時進入阻塞狀態,直到其進入到就緒狀態,才有機會再次被CPU調用以進入到運行狀態。根據阻塞產生的原因不同,阻塞狀態又可以分為三種:

1.等待阻塞:運行狀態中的線程執行wait()方法,是本線程進入到等待阻塞狀態;

2.同步阻塞:線程在獲取synchronized 同步鎖失敗(因為鎖被其他線程所占用),它會進入同步阻塞狀態;

3.其他阻塞:通過調用線程的sleep()或join()或發出了I/O請求時,線程會進入到阻塞狀態。當sleep()狀態超時、join()等待線程終止或者超時、或者I/O處理完畢時,線程重新轉入就緒狀態。

 
死亡狀態(Dead):線程執行完或因異常退出run()方法,該線程結束生命周期。

 

 

四、多線程的優缺點

 

1、多線程編程的優勢:

  • 提高系統的吞吐率

  • 提高響應性

  • 充分利用多核處理器資源

  • 最小化對系統資源的使用

  • 簡化程序的結構

 

 
2、多線程編程的風險:

注意:因為在沒有充足的情況下,多個線程的執行順序是不可預測的,所以線程的安全需要注意。

 
1、上下文切換:時間片是CPU分配給各個線程的時間,因為時間非常短,所以CPU不斷通過切換線程,讓我們覺得多個線程是同時進行的,時間片一般是幾十毫秒。而每次切換時,需要保存當前的狀態,一遍能夠進行恢復先前狀態,而這個切換是非常損耗性能的,過於頻繁反而無法發揮出多線程編程的優勢。通常減少上下文切換可以采用無鎖並發編程,CAS算法,使用最少的線程和使用協程。

 
2、並發安全:多線程編程中最難以把握的就是臨界區線程安全問題,稍微不注意就會出現死鎖的情況,一旦產生死鎖就會造成系統功能不可用。

  • 線程活性

    • 死鎖

    • 活鎖:

    • 飢餓

    • 鎖死

  • 避免死鎖:

1.避免一個線程同時獲得多個鎖;    

2.避免一個線程在鎖內部占有多個資源,盡量保證每個鎖占用一個資源;

3.常識使用定時鎖,使用lock.tryLock(timeOut),當超時等待時當前線程不會阻塞;

4.對於數據庫鎖,加鎖和解鎖必須在一個數據庫連接里,否則會出現解鎖失敗的情況

 

3、線程安全

  • 原子性

  • 有序性

  • 可見性

 

 

五、多線程的作用:

 

挖掘出程序中的多並發點是KEY。

  • 並發分而治之(一個復雜任務分解成多個簡單任務 fork后join)

    • 按照任務的資源消耗屬性分割

      • 系統資源使用情況

      • CPU上限

      • 稀缺資源(數據庫連接等)

    • 按照步驟分割

  • 並發實現大量數據的拆解(如下載器就用了多並發)

  • 設置出合理的線程數

    • Amdahl定律(阿姆達爾定律)

    • 常見考慮原因

 

 
 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM