執行緒安全性

紅色迷宮發表於2018-06-19

執行緒安全性

要編寫執行緒安全的程式碼,其核心在於要對狀態訪問操作進行管理,特別是對共享的可變的狀態的訪問。

“共享”意味著變數可以由多個執行緒同時訪問,而可變則意味著變數的值在其生命週期內可以發生變化。

一個物件是否需要是執行緒安全的,取決於它是否被多個執行緒訪問,這指的是程式中訪問物件的方式,而不是對想要實現的功能,要使得物件是執行緒安全的,需要採用同步機制來協同對物件可變狀態的訪問。如果無法實現協同,那麼可能導致資料破壞以及其他不該出現的結果。

當多個執行緒訪問某個狀態變數並且其中有一個執行緒執行寫入操作時,必須採用同步機制來協同這些執行緒對變數的訪問。Java中的主要同步機制是關鍵字synchronized,它提供了一種獨佔的加鎖方式,但是”同步”這個術語還包括volatile型別的變數,顯示鎖以及原子變數。

如果當多個執行緒訪問同一個可變的狀態變數時沒有使用合適的同步,那麼程式就會出現錯誤。有三種方式可以修復這個問題

  • 不線上程之間共享該狀態變數
  • 將狀態變數修改為不可變的變數
  • 在訪問狀態變數時使用同步

什麼是執行緒安全?

線上程安全性的定義中,最核心的概念就是正確性。如果對執行緒安全性的定義是模糊的,那麼就是因為缺乏對正確性的清晰定義。

執行緒安全概念

當多個執行緒訪問某個類時,不管執行時環境採用何種排程方式或者這些執行緒將如何交替執行,並且在主調程式碼中不需要任何額外的同步或協同,這個類都能表現出正確的行為,那麼就稱這個類是執行緒安全的。

原子性

我自己寫的一篇部落格:淺分析volatile關鍵字

public class UnsafeCountingFactorizer implements Servlet {

  private long count = 0;

  public long getCount() {
     return count;
  }

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

上面的示例是在沒有同步的情況下統計已處理請求數量的Servlet,儘管該Servlet在單執行緒環境中能正確執行。++count看似是一個原子性的操作,可這看上去緊湊的操作,實際上要分為三步來完成,多執行緒情況下,每條執行緒的工作記憶體①從主存中讀取count的值②為本執行緒中的count副本+1③寫回主存,並且其結果依賴於之前的狀態。也正是在這看似是原子性的自增操作的情況下,多執行緒的環境下,這個程式就會出現錯誤

在併發程式設計中,這種由於不恰當的執行時序而出現不正確的結果是一種非常重要的情況,他有一個正式的名字:競態條件(Race Condition)

競態條件

當某個計算的正確性取決於多個執行緒的交替執行的時序的時候,那麼就會發生競態條件。 最常見的競態條件就是“先檢查後執行”操作,即通過一個可能失效的觀測結果來決定下一步的動作

這種觀察結果的失效就是大多數競態條件的本質,——基於一種可能失效的觀察結果來做出判斷或者執行某個計算,這種型別的競態條件稱為“先檢查後執行”:首先觀察到某個條件為真(例如檔案X不存在),然後根據這個觀察結果採用相應的動作(建立檔案X),但是事實上,在你觀察到這個結果以及開始建立檔案之間,觀察結果可能變得無效(另一個執行緒在這期間建立了檔案X),從而導致各種問題(未檢查的異常,資料被覆蓋,檔案被破壞等)。

延遲初始化

延遲初始化的目的就是將物件的初始化操作退出到實際被使用時才進行,同時要確保只被初始化一次。比如下面這一段程式碼

public class Singleton {

    private Singleton singleton = null;

    public synchronized Singleton getSingleton() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

在上述的類中就存在一個競態條件,它可能會破壞這個類的正確性。假定執行緒A和執行緒B同時執行getObject這個方法。此時A執行緒看到object為null,因而會建立一個新的Object例項,B同樣需要判斷object是不是為null。這個時候的object是否為null,取決於不可預測的時序(時序在這裡可以簡單地理解為一個匯流排週期內,CPU在各個時鐘週期完成的操作 ),包括執行緒的排程方式,以及A執行緒需要花多長時間來初始化Object並設定object。如果當B執行緒檢查object也為null,那麼在兩次呼叫getObject時可能會出現不同的結果,即使getObject通常被認為是返回相同的例項。

與大多數併發錯誤一樣,競態條件並不總是會產生錯誤,還需要某種不恰當的執行時序。

解決問題的方法也同樣很簡單,使用synchronized關鍵字

public class Singleton {

    private Singleton singleton = null;

    public synchronized Singleton getSingleton() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

這樣解決了多執行緒併發的問題,但是卻帶來了效率的問題,我們的目的只是去建立一個例項,即只有例項化使用new關鍵字的語句需要被同步,後面建立了例項之後,singleton非空就會直接返回單例物件的引用,而不用每次都在同步程式碼塊中進行非空驗證,那麼這樣可以考慮只對new關鍵字例項化物件的時候進行同步

public class Singleton {

    private Singleton singleton = null;
            public Singleton getSingleton() {
     if (singleton == null) {
         synchronized (Singleton.class) {
             singleton = new Singleton();
         }
     }
     return singleton;
    }
}

這樣會帶來與第一種一樣的問題,即多個執行緒同時執行到條件判斷語句時,會建立多個例項。問題在於當一個執行緒建立一個例項之後,singleton就不再為空了,但是後續的執行緒並沒有做第二次非空檢查。那麼很明顯,在同步程式碼塊中應該再次做檢查,也就是所謂的雙重檢測

雙重檢測:

public class Singleton {

    private Singleton singleton = null;

    public Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

到這裡真的可以說是很完美了,但是因為Java的無序寫入,在JDK1.5之前都是有問題的,在下面的例項化一個物件的過程中會敘述這個問題。 JDK1.5之後,可以使用volatile關鍵字修飾變數來解決無序寫入產生的問題,因為volatile關鍵字的一個重要作用是禁止指令重排序,即保證不會出現記憶體分配、返回物件引用、初始化這樣的順序,從而使得雙重檢測真正發揮作用。

例項化一個物件的過程

object = new Object();

簡單地來說上面的程式碼中簡簡單單的一句例項化一個物件,看似是一種原子性的操作,但其實不是的,就如同

++count;

同樣的++count;這種對變數的基本自增賦值也是一種非原子性的操作,這類對一個變數執行自增的操作一般也分為三個步驟 ①將主存中的變數值讀取至該執行緒的工作記憶體中②對變數進行自增操作③將對變數的自增後改變的值寫回主存,也就是這看似簡簡單單自增操作實際上分成了三步去實現,也正是因為這個非原子性的操作,可能會導致併發問題。按我的理解,一切存線上程安全的問題一定會在某一個時刻出現併發問題。

例項化一個物件簡單地來說也會分成三步去實現

  1. 在例項化一個物件的時候,首先會去堆開闢空間,分配地址
  2. 呼叫對應的建構函式進行初始化,並且對物件中的屬性進行預設初始化
  3. 初始化完畢中,將堆記憶體中的地址值賦給引用變數

一般來講,當初始化一個物件的時候,會經歷記憶體分配、初始化、返回物件在堆上的引用等一系列操作,這種方式產生的物件是一個完整的物件,可以正常使用。

但是JAVA的無序寫入可能會造成順序的顛倒,即記憶體分配、返回物件引用、初始化的順序 ,這種情況下對應到程式碼中的例項化物件,就是singleton已經不是null,而是指向了堆上的一個物件,但是該物件卻還沒有完成初始化動作。 當後續的執行緒發現singleton不是null而直接使用的時候,就會出現意料之外的問題。(就是說在Java1.5之前允許無序寫入的時候,一旦初始化物件和返回對堆上物件的引用兩條指令被亂序執行,有可能出現執行緒安全問題)

指令重排

什麼是指令重排序,一般來說,處理器為了提高程式執行效率,可能會對輸入程式碼進行優化,它不保證程式中各個語句的執行先後順序同程式碼中的順序一致,但是它會保證程式最終執行結果和程式碼順序執行的結果是一致的。指令重排序不會影響單個執行緒的執行,但是會影響到執行緒併發執行的正確性。也就是說,要想併發程式正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程式執行不正確。在Java記憶體模型中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單執行緒程式的執行,卻會影響到多執行緒併發執行的正確性。在Java裡面,可以通過volatile關鍵字來保證一定的“有序性”。另外可以通過synchronized和Lock來保證有序性,很顯然,synchronized和Lock保證每個時刻是有一個執行緒執行同步程式碼,相當於是讓執行緒順序執行同步程式碼,自然就保證了有序性。

加鎖機制

線上程安全性的定義中要求,多個執行緒之間的操作無論採用何種執行時序或是交替方式,都要保證不變性條件不被破壞。

當在不變性條件中涉及多個變數時,各個變數之間並不是彼此獨立的,而是某個變數的值會對其他變數的值產生約束。因此,當更新某一個變數時,需要在同一個原子操作中對其他變數同時進行更新

內建鎖

線上程安全性的定義中要求,多個執行緒之間的操作無論採用何種執行時序或交替方式,都要保證不變性條件不被破壞。

當在不變性條件中涉及多個變數時,各個變數之間並不是彼此獨立的,而是某個變數的值會對其他變數的值產生約束,因此,當更新某一個變數的時候,需要在同一個原子操作中對其他變數同時進行更新。

Java提供了一種內建的鎖機制來支援原子性:同步程式碼塊(Synchronized Block)。同步程式碼塊包括兩部分:一個是作為鎖的物件的引用,一個作為有這個鎖保護的程式碼塊。以關鍵字synchronized來修飾的方法就是一種橫跨整個方法體的同步程式碼塊,其中該同步程式碼塊的鎖就是方法呼叫所在的物件。靜態的synchronized方法以Class物件作為鎖。

synchronized (lock) {
 //訪問或修改由鎖保護的共享狀態
}

每個Java物件都可以用作一個實現同步的鎖,這些所被稱為內建鎖或者監視器鎖。執行緒在進入同步程式碼塊之前會自動獲得鎖,並且在退出同步程式碼塊時自動釋放鎖,而無論是通過正常的控制路徑退出還是通過程式碼塊中丟擲異常退出。獲得內建鎖的唯一途徑就是進入有這個所保護的同步程式碼塊或方法。

Java的內建鎖相當於一種互斥體(或者叫互斥鎖)這意味著最多隻有一個執行緒能持有這種鎖當執行緒A嘗試獲取一個由執行緒B持有的鎖時,執行緒A必須等待或者阻塞,指導執行緒B釋放這個鎖。如果B永遠不釋放鎖,那麼A也將永遠等下去由於被保護的程式碼塊或者被保護的同步方法同時只能被一條執行緒訪問,也就相當於這個同步程式碼塊或者同步方法是一種原子性操作,這種同步是通過加鎖保證的原子性操作進而保證的執行緒安全

併發環境中的原子性與事務應用程式中的原子性有著相同的含義——一組語句作為一個不可分割的單元被執行。任何一個執行同步程式碼塊的執行緒,都不可能看到有其他執行緒正在執行由同一個鎖保護的同步程式碼塊

重入

當某個執行緒請求一個由其他執行緒持有的鎖時,發出的請求執行緒就會阻塞。然而,由於內建鎖是可以重入的,因此如果某個執行緒試圖獲取一個已經由它自己持有的鎖時,那麼這個請求就會成功。”重入”意味著獲取所的操作粒度是”執行緒”而不是”呼叫”

重入的一種實現方式是,為每個鎖關聯一個獲取計數值和一個所有者執行緒。當計數值為0時,這個鎖就被認為是沒有被人和執行緒持有。當執行緒請求一個未被持有的鎖的時候,JVM將記下所得持有者,並且將獲取計數值置為1 如果同一個執行緒再次獲取這個鎖,計數值將遞增,而當執行緒退出同步程式碼塊時,計數器會相應的遞減。當計數值為0的時候,這個鎖將被釋放

class Father {

    public synchronized void doSomething() {
        
    } 
}

class Son extends Father{
    
    @Override
    public synchronized void doSomething() {
        System.out.println(toString());
        super.doSomething();
    }
}

上述程式碼子類Son繼承父類Father並且重寫父類doSomething方法,然後呼叫父類中的方法,這個時候如果沒有可重入的鎖,那麼上述程式碼將會出現死鎖。

由於Father和Son中的doSomething方法都是同步方法(synchronized修飾),因此每個doSomething方法在執行前都會獲取Father上的鎖。

然而,如果內建鎖不是可重入的,那麼在呼叫super.doSomething()時將無法獲取Father上的鎖,因為這個鎖已經被持有,從而執行緒將永遠停頓下去,等待一個永遠也無法獲取的鎖,重入則避免了這類死鎖的情況發生

對於上述程式碼還有對重入的理解可能有些複雜,我同樣對理解這個結論有些困難,到底這個重入的情況是鎖住了誰,搜尋了許久發現一篇帖子的討論,跟大家分享一下

public class Test {

    public static void main(String[] args) throws InterruptedException {
        final TestChild t = new TestChild();
    
        new Thread(new Runnable() {
          @Override
          public void run() {
            t.doSomething();
          }
        }).start();
        Thread.sleep(100);
        t.doSomethingElse();
    }

    public synchronized void doSomething() {
         System.out.println("something sleepy!");
         try {
           Thread.sleep(1000);
           System.out.println("woke up!");
         }
         catch (InterruptedException e) {
           e.printStackTrace();
         }
    }

    private static class TestChild extends Test {
        public void doSomething() {
          super.doSomething();
        }

        public synchronized void doSomethingElse() {
          System.out.println("something else");
        }
    }
}

上述程式碼,作為一個實驗,可以證明上面的重入情況鎖住子類物件和父類物件是一個鎖

如果super鎖住了父類物件,沒有鎖住子類物件,那麼另一個執行緒仍然可以獲得子類物件的鎖。按照這個假設,上述程式應該輸出

  • something sleepy!
  • something else
  • woke up!

但輸出的是

  • something sleepy!
  • woke up!
  • something else

現在我們一起來分析一下上述程式

  • 上述程式在main方法中開啟了一個新執行緒去執行子類物件t的doSomething()方法
  • 子類物件的doSomething()通過super關鍵字呼叫父類的doSomething()方法,因為父類的doSomething()方法被synchronized關鍵字修飾,所以這個時候程式對某一個物件上了鎖
  • 如果呼叫父類方法的時候鎖住了父類的物件,那麼另一個執行緒仍然可以獲得子類t物件的鎖,我們看一下父類的doSomething()方法,方法塊中有讓這條執行緒sleep 1s的操作,並且在main方法中新執行緒之後也有一步讓當前執行緒sleep 0.1s的這個操作,那麼按理說,如果鎖住的是父類的隱式物件,這個時候新執行緒sleep之後,按理說子類物件t可以去執行doSomethingElse()這個方法,可是根據執行結果來看,並不是這樣的
  • 所以通過上面的結論以及一個示例的程式碼,我們不難看出,整個內建鎖的重入其實只是鎖住了子類物件,這樣的話在上述的例子中,在新執行緒中呼叫父類方法鎖住的是子類物件t,這樣即使是在父類執行緒休眠之後,也不會使得子類物件去呼叫自己的doSomethingElse()方法成功,因為這個時候,子類物件的鎖的持有還是在那條新的執行緒,所以程式會按照上述的輸出執行

用鎖來保護狀態

由於鎖能使其保護的程式碼路徑以序列形式來訪問,因此可以通過鎖來構造一些協議來實現對共享狀態的獨佔訪問,如果在符合操作的執行過程中持有一個鎖,那麼會使複合操作成為原子操作。然而,僅僅將複合操作封裝到一個同步程式碼塊中是不夠的

如果通過同步來協調對某個變數的訪問,那麼在訪問這個變數的所有位置上都需要使用同步。而且,當使用鎖來協調對某個變數的訪問時,在訪問變數的所有位置上都要使用同一個鎖

對於可能被多個執行緒同時訪問的可變狀態的變數,在訪問它時都需要持有同一個鎖,在這種情況下,我們稱狀態變數是由這個鎖保護的

當獲取與物件關聯的鎖時,並不能阻止其他執行緒訪問該物件,某個執行緒在獲得物件的鎖之後,只能阻止其他執行緒獲得同一個鎖

每個共享的和可變的變數都應該只由一個鎖來保護,從而使維護人員知道是哪一個鎖

當類的不變性條件涉及多個狀態變數時,那麼還有另一個需求:在不變形條件中的每個變數都必須由同一個鎖保護。如果同步可以避免競態條件問題,那麼為什麼不在每個方法宣告時都使用關鍵字synchronized?事實上,如果不加區分地濫用synchronized,可能導致程式中出現過多的同步,此外,如果只是將每個方法都作為同步方法還可能會導致活躍性問題或者效能問題

活躍性與效能

如果現在處理的是一個Servlet,相對其進行併發處理,直接對service方法上鎖新增synchronized關鍵字,雖然這種簡單且粗粒度的方法能夠確保執行緒安全性,但是代價卻很高

由於service是一個synchronized方法,因此每次只能有一個執行緒可以執行。這就背離了Servlet框架的初衷,即Servlet需要能同時處理多個請求,這在負載過高的情況下將給使用者帶來糟糕的體驗,如果處理一條請求耗時較長,那麼其餘使用者的請求就將一直等待,直到Servlet處理完當前的請求,才能開始另一個新的因數分解運算。如果在系統中有多個CPU系統,那麼當負載較高時,仍然會有處理器處於空閒狀態。即使一些執行時間很短的請求,仍然需要很長時間,這些請求必須等待前一個請求處理完成,我們將這種Web應用程式稱之為不良併發(Poor Concurrency)應用程式,可同時呼叫的數量,不僅受到可用的處理資源的限制,還受到應用程式本身結構的限制。幸運的是,通過縮小同步程式碼塊的作用範圍,就會很容易做到Servlet的併發性,同時又維護執行緒安全性。

要確保同步程式碼塊不要過小,並且不要將本應是原子的操作拆分到多個同步程式碼塊中去,應該儘量不影響共享狀態且執行時間較長的操作,從同步程式碼塊中分離出去,從而在這些操作的執行過程中,其他執行緒可以訪問共享狀態


相關文章