Java併發編程系列-(7) Java線程安全

7. 線程安全

7.1 線程安全的定義

若是多線程下使用這個類,不過多線程如何使用和調度這個類,這個類老是表示出正確的行爲,這個類就是線程安全的。git

類的線程安全表現爲:github

  • 操做的原子性
  • 內存的可見性

不作正確的同步,在多個線程之間共享狀態的時候,就會出現線程不安全。面試

7.2 如何保證線程安全

棧封閉

全部的變量都是在方法內部聲明的,這些變量都處於棧封閉狀態。算法

好比下面的例子,a和b都是在方法內部定義的,沒法被外部線程所訪問,當方法結束後,棧內存被回收,因此是線程安全的。後端

void fun(){
    int a = 1;
    int b= 2;
    // do something
}

無狀態

沒有任何成員變量的類,就叫無狀態的類,這種類不存在共享的資源,顯然是安全的。安全

public class StatelessClass {
    
    public int service(int a,int b) {
        return a*b;
    }
}

不可變的類

讓狀態不可變,兩種方式:性能優化

  1. 加final關鍵字。對於一個類,全部的成員變量應該是私有的,而且可能的狀況下,全部的成員變量應該加上final關鍵字。須要注意若是成員變量又是一個對象時,這個對象所對應的類也要是不可變,才能保證整個類是不可變的。
  2. 根本就不提供任何可供修改爲員變量的地方,同時成員變量也不做爲方法的返回值。

下面例子中的,成員變量都是final而且也沒有提供給外部修改變量的地方,所以是線程安全的。多線程

public class ImmutableFinal {
    
    private final int a;
    private final int b;
    
    public ImmutableFinal(int a, int b) {
        super();
        this.a = a;
        this.b = b;
    }

    public int getA() {
        return a;
    }

    public int getB() {
        return b;
    }
}

下面的例子中,雖然User成員變量是final的,沒法修改引用。可是外部依然能夠經過getUser獲取到User的引用以後,修改User對象。併發

public class ImmutableFinalRef {
    
    private final int a;
    private final int b;
    private final User user;//這裏就不能保證線程安全了
    
    public ImmutableFinalRef(int a, int b) {
        super();
        this.a = a;
        this.b = b;
        this.user = new User();
    }

    public int getA() {
        return a;
    }

    public int getB() {
        return b;
    }
    
    public User getUser() {
        return user;
    }

    public static class User{
        private int age;

        public User(int age) {
            super();
            this.age = age;
        }

        public int getAge() {
            return age;
        }

        public void setAge(int age) {
            this.age = age;
        }
        
    }
    
    public static void main(String[] args) {
        ImmutableFinalRef ref = new ImmutableFinalRef(12,23);
        User u = ref.getUser();
        //u.setAge(35);
    }
}

volatile

volitile在ConcurrentHashMap等併發容器中都有使用,用於保證變量的可見性。最適合一個線程寫,多個線程讀的情景。app

加鎖和CAS

加鎖能夠顯示地控制線程對類的訪問,使用正確能夠保證線程安全。

CAS操做經過不斷的循環對比,試圖對目標對象進行修改,也能保證線程安全。普遍用於JDK併發容器的實現中。

安全的發佈

類中持有的成員變量,特別是對象的引用,若是這個成員對象不是線程安全的,經過get等方法發佈出去,會形成這個成員對象自己持有的數據在多線程下不正確的修改,從而形成整個類線程不安全的問題。

ThreadLocal

這個類能使線程中的某個值與保存值的對象關聯起來。ThreadLocal提供了get與set等訪問接口與方法,這些方法爲使用該變量的每一個線程都存有一份獨立的副本,所以get老是返回由當前執行線程在調用set時設置的最新值。

當某個線程初次調用ThreadLocal.get方法時,就會調用initialValue來獲取初始值。從概念上講,你能夠將ThreadLocal 視爲包含了Map<Thread, T>對象,其中保存了特定於該線程的值,但ThreadLocal的實現並不是如此,這些特定的值保存在Thread對象中,當線程終止後,這些值會做爲垃圾回收。

7.3 死鎖

定義

死鎖是指兩個或兩個以上的進程在執行過程當中,因爲競爭資源或者因爲彼此通訊而形成的一種阻塞的現象,若無外力做用,它們都將沒法推動下去。此時稱系統處於死鎖狀態或系統產生了死鎖。

死鎖的根本成因:獲取鎖的順序不一致致使。

能夠利用下面的示意圖幫助理解:

Screen Shot 2019-12-12 at 9.47.49 PM.png

死鎖範例

下面的程序中,兩個線程分別獲取到了first和second,而後相互等待,產生了死鎖。

public class DeadLockSample extends Thread {
    private String first;
    private String second;
    public DeadLockSample(String name, String first, String second) {
        super(name);
        this.first = first;
        this.second = second;
    }
    public void run() {
        synchronized (first) {
            System.out.println(this.getName() + " obtained: " + first);
            try {
                Thread.sleep(1000L);
                synchronized(second) {
                    System.out.println(this.getName() + " obtained: " + second);
                }
            } catch (InterruptedException e) {
                // Do nothing
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        String lockA = "lockA";
        String lockB = "lockB";
        DeadLockSample t1 = new DeadLockSample("Thread1", lockA, lockB);
        DeadLockSample t2 = new DeadLockSample("Thread2", lockB, lockA);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

定位和解決死鎖

Debug時可使用 jps 或者系統的 ps 命令、任務管理器等工具,肯定進程 ID。其次,調用 jstack 獲取線程棧,jstack your_pid. jstack 自己也會把相似的簡單死鎖抽取出來,直接打印出來。

若是是開發本身的管理工具,須要用更加程序化的方式掃描服務進程、定位死鎖,能夠考慮使用 Java 提供的標準管理 API,ThreadMXBean,其直接就提供 findDeadlockedThreads() 方法用於定位,上面的例子中用到了這個方法。

怎麼預防死鎖?

  1. 若是可能的話,儘可能避免使用多個鎖,而且只有須要時才持有鎖。

  2. 若是必須使用多個鎖,儘可能設計好鎖的獲取順序。若是對於兩個線程的狀況,能夠參考以下的實現:

在實現轉帳的類時,爲了防止因爲相互轉帳致使的死鎖,下面的實現中,經過對比帳戶的hash值來肯定獲取鎖的順序。當二者的hash值相等時,雖然這種狀況很是少見,使用了單獨的鎖,來控制兩個線程的訪問順序。

注意System.identityHashCode()是JDK自帶的hash實現,在絕大部分狀況下,保證了對象hash值的惟一性。

public class SafeOperate implements ITransfer {
    private static Object tieLock = new Object();//加時賽鎖

    @Override
    public void transfer(UserAccount from, UserAccount to, int amount)
            throws InterruptedException {
        
        int fromHash = System.identityHashCode(from);
        int toHash = System.identityHashCode(to);
        //先鎖hash小的那個
        if(fromHash<toHash) {
            synchronized (from){
                synchronized (to){
                    System.out.println(Thread.currentThread().getName()
                            +" get"+to.getName());
                    from.flyMoney(amount);
                    to.addMoney(amount);
                }
            }           
        }else if(toHash<fromHash) {
            synchronized (to){
                Thread.sleep(100);
                synchronized (from){
                    from.flyMoney(amount);
                    to.addMoney(amount);
                }
            }           
        }else {//解決hash衝突的方法
            synchronized (tieLock) {
                synchronized (from) {
                    synchronized (to) {
                        from.flyMoney(amount);
                        to.addMoney(amount);                        
                    }
                }
            }
        }
        
    }
}
  1. 使用帶超時的方法,爲程序帶來更多可控性。

相似 Object.wait(…) 或者 CountDownLatch.await(…),都支持所謂的 timed_wait,咱們徹底能夠就不假定該鎖必定會得到,指定超時時間,併爲沒法獲得鎖時準備退出邏輯。

  1. 使用Lock實現(推薦)

併發 Lock 實現,如 ReentrantLock 還支持非阻塞式的獲取鎖操做 tryLock(),這是一個插隊行爲(barging),並不在意等待的公平性,若是執行時對象剛好沒有被獨佔,則直接獲取鎖。

標準的使用流程以下:

while(true) {
   if(A.getLock().tryLock()) {
    try {
        if(B.getLock().tryLock()) {
            try {
              //兩把鎖都拿到了,開始執行業務代碼
                   break;
            }finally {
              B.getLock().unlock();
            }
       }
    }finally {
        A.getLock().unlock();
    }
  }
  // 很是重要,sleep隨機的時間,以防兩個線程謙讓,產生長時間的等待,也就是活鎖
  SleepTools.ms(r.nextInt(10));
}

7.4 活鎖/線程飢餓/無鎖

活鎖

活鎖偏偏與死鎖相反,死鎖是你們都拿不到資源都佔用着對方的資源,而活鎖是拿到資源卻又相互釋放不執行。當多線程中出現了相互謙讓,都主動將資源釋放給別的線程使用,這樣這個資源在多個線程之間跳動而又得不到執行,這就是活鎖。

在上面解決死鎖的第四個方案中,爲了不活鎖,採用了隨機休眠的機制。

線程飢餓

線程執行中有線程優先級,優先級高的線程可以插隊並優先執行,這樣若是優先級高的線程一直搶佔優先級低線程的資源,致使低優先級線程沒法獲得執行,這就是飢餓。固然還有一種飢餓的狀況,一個線程一直佔着一個資源不放而致使其餘線程得不到執行,與死鎖不一樣的是飢餓在之後一段時間內仍是可以獲得執行的,如那個佔用資源的線程結束了並釋放了資源。

無鎖

對於併發控制而言,鎖是一種悲觀的策略,它老是假設每一次的臨界區操做會產生衝突,由此,若是有多個線程同時須要訪問臨界區資源,則寧肯犧牲資源讓線程進行等待。

無鎖是一種樂觀的策略,它假設對資源的訪問是沒有衝突的。既然沒有衝突,天然不須要等待,因此全部的線程均可以在不停頓地狀態下持續執行。當遇到衝突,則使用CAS來檢測線程衝突,若是發現衝突,則重試直到沒有衝突爲止。

CAS算法的過程是,它包含三個參數CAS(V,E,N),V表示要更新的變量,E表示預期值,N表示新值。僅當V值等於E值時,纔將V的值設置爲N,若是V值和E值不一樣,說明已經有其餘線程作了更新,則當前線程什麼都不作。使用CAS操做一個變量時,只有一個會勝出,併成功更新,其他均會失敗。

7.5 影響性能的因素

  • 上下文切換:通常花費5000-10000個時鐘週期,幾微秒
  • 內存同步:加鎖等操做,增長額外的指令執行時間
  • 阻塞:掛起線程,包括額外的上下文切換

7.6 鎖性能優化

減小鎖的持有時間

減小鎖的持有時間有助於下降鎖衝突的可能性,進而提高系統的併發能力。

減少鎖粒度

這種技術的典型使用場景就是ConcurrentHashMap。

對於HashMap來講,最重要的兩個方法就是get() 和put(),一種最天然的想法就是對整個HashMap加鎖,必然能夠獲得一個線程安全的對象.可是這樣作,咱們就認爲加鎖粒度太大.對於ConcurrentHashMap,它內部進一步細分了若干個小的hashMap,稱之爲段(SEGMENT).默認的狀況下,一個ConcurrentHashMap被進一步細分爲16個段

若是須要在ConcurrentHashMap中增長一個新的表項,並非整個HashMap加鎖,而是首先根據hashcode獲得該表項應該被存放到哪一個段中,而後對該段加鎖,並完成put()操做.在多線程環境中,若是多個線程同時進行put()操做,只要被加入的表項不存放在同一個段中,則線程間即可以作到真正的並行。

讀寫分離鎖來替換獨佔鎖

在讀多寫少的場合,使用讀寫鎖能夠有效提高系統的併發能力

鎖分離

若是將讀寫鎖的思想進一步的延伸,就是鎖分離.讀寫鎖根據讀寫鎖操做功能上的不一樣,進行了有效的鎖分離.使用相似的思想,也能夠對獨佔鎖進行分離.

以LinkedBlockingQueue爲例,take函數和put函數分別實現了衝隊列取和往隊列加數據,雖然兩個方法都對隊列進項了修改,可是LinkedBlockingQueue是基於鏈表的因此一個操做的是頭,一個是隊列尾端,從理論狀況下將並不衝突

若是使用獨佔鎖則take和put就不能完成真正的併發,因此jdk並無才用這種方式取而代之的是兩把不一樣的鎖分離了put和take的操做

鎖粗化

凡事都有一個度,若是對同一個鎖不停地進行請求,同步和釋放,其自己也會消耗系統寶貴的資源,反而不利於性能的優化。

爲此,虛擬機在遇到一連串連續地對同一鎖不斷進行請求和釋放的操做時,便會把全部的鎖操做整合成對鎖的一次請求,從而減小對鎖的請求同步次數,這個操做叫作鎖的粗化.

7.7 實現線程安全的單例模式

懶漢式

public static synchronized Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
    return instance;
}

線程安全,而且解決了多實例的問題,可是它並不高效。由於在任什麼時候候只能有一個線程調用 getInstance() 方法。

雙重檢驗鎖

public class Singleton {
    private static volatile Singleton singleton = null;
    private Singleton() {
    }
    public static Singleton getSingleton() {
        if (singleton == null) { // 儘可能避免重複進入同步塊
            synchronized (Singleton.class) { // 同步.class,意味着對同步類方法調用
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}
  • volatile 可以提供可見性,以及保證 getInstance 返回的是初始化徹底的對象。
  • 在同步以前進行 null 檢查,以儘可能避免進入相對昂貴的同步塊。
  • 直接在 class 級別進行同步,保證線程安全的類方法調用。

在這段代碼中,爭論較多的是 volatile 修飾靜態變量,當 Singleton 類自己有多個成員變量時,須要保證初始化過程完成後,才能被 get 到。 在現代 Java 中,內存排序模型(JMM)已經很是完善,經過 volatile 的 write 或者 read,能保證所謂的 happen-before,也就是避免常被提到的指令重排。換句話說,構造對象的 store 指令可以被保證必定在 volatile read 以前。

餓漢式

這種方法很是簡單,由於單例的實例被聲明成 static 和 final 變量了,在第一次加載類到內存中時就會初始化,因此建立實例自己是線程安全的。

public class Singleton{
    //類加載時就初始化
    private static final Singleton instance = new Singleton();
    
    private Singleton(){}

    public static Singleton getInstance(){
        return instance;
    }
}

靜態內部類(推薦)

public class Singleton {  
    private static class SingletonHolder {  
        private static final Singleton INSTANCE = new Singleton();  
    }  
    private Singleton (){}  
    public static final Singleton getInstance() {  
        return SingletonHolder.INSTANCE; 
    }  
}

靜態內部類是在被調用時纔會被加載,所以它是懶漢式的。


本文由『後端精進之路』原創,首發於博客 http://teckee.github.io/ , 轉載請註明出處

搜索『後端精進之路』關注公衆號,馬上獲取最新文章和價值2000元的BATJ精品面試課程

後端精進之路.png

相關文章
相關標籤/搜索