天天看點

線程安全性線程安全性原子性加鎖機制

線程安全性

要編寫線程安全的代碼,其核心在于要對狀态通路操作進行管理,特别是對共享的和可變的狀态的通路。

"共享"意味着變量可以由多個線程同時通路,而可變則意味着變量的值在其生命周期内可以發生變化。

一個對象是否需要是線程安全的,取決于它是否被多個線程通路,這指的是程式中通路對象的方式,而不是對想要實作的功能,要使得對象是線程安全的,需要采用同步機制來協同對對象可變狀态的通路。如果無法實作協同,那麼可能導緻資料破壞以及其他不該出現的結果。

當多個線程通路某個狀态變量并且其中有一個線程執行寫入操作時,必須采用同步機制來協同這些線程對變量的通路。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!

但輸出的是

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

  • 上述程式在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的并發性,同時又維護線程安全性。

要確定同步代碼塊不要過小,并且不要将本應是原子的操作拆分到多個同步代碼塊中去,應該盡量不影響共享狀态且執行時間較長的操作,從同步代碼塊中分離出去,進而在這些操作的執行過程中,其他線程可以通路共享狀态