4種解決線程安全問題的方式

前言

線程安全問題,在做高併發的系統的時候,是程序員經常需要考慮的地方。怎麼有效的防止線程安全問題,保證數據的準確性?怎麼合理的最大化的利用系統資源等,這些問題都需要充分的理解並運行線程。當然關於多線程的問題在面試的時候也是出現頻率比較高的。下面就來學習一下吧!

線程

先來看看什麼是進程和線程?

進程是資源(CPU、內存等)分配的基本單位,它是程序執行時的一個實例。程序運行時系統就會創建一個進程,併爲它分配資源,然後把該進程放入進程就緒隊列,進程調度器選中它的時候就會爲它分配CPU時間,程序開始真正運行。就比如說,我們開發的一個單體項目,運行它,就會產生一個進程。

線程是程序執行時的最小單位,它是進程的一個執行流,是CPU調度和分派的基本單位,一個進程可以由很多個線程組成,線程間共享進程的所有資源,每個線程有自己的堆棧和局部變量。線程由CPU獨立調度執行,在多CPU環境下就允許多個線程同時運行。同樣多線程也可以實現併發操作,每個請求分配一個線程來處理。在這裏強調一點就是:計算機中的線程和應用程序中的線程不是同一個概念。

總之一句話描述就是:進程是資源分配的最小單位,線程是程序執行的最小單位。

什麼是線程安全

什麼是線程安全呢?什麼樣的情況會造成線程安全問題呢?怎麼解決線程安全呢?這些問題都是在下文中所要講述的。

**線程安全:**當多個線程訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方進行任何其他的協調操作,調用這個對象的行爲都可以獲得正確的結果,那這個對象就是線程安全的。

那什麼時候會造成線程安全問題呢?當多個線程同時去訪問一個對象時,就可能會出現線程安全問題。那麼怎麼解決呢?請往下看!

解決線程安全

在這裏提供4種方法來解決線程安全問題,也是最常用的4種方法。前提是項目在一個服務器中,如果是分佈式項目可能就會用到分佈鎖了,這個就放到後面文章來詳談了。

講4種方法前,還是先來了解一下悲觀鎖和樂觀鎖吧!

悲觀鎖,顧名思義它是悲觀的。講得通俗點就是,認爲自己在使用數據的時候,一定有別的線程來修改數據,因此在獲取數據的時候先加鎖,確保數據不會被線程修改。形象理解就是總覺得有刁民想害朕。

而樂觀鎖就比較樂觀了,認爲在使用數據時,不會有別的線程來修改數據,就不會加鎖,只是在更新數據的時候去判斷之前有沒有別的線程來更新了數據。具體用法在下面講解。

現在來看有那4種方法吧!

  • 方法一:使用synchronized關鍵字,一個表現爲原生語法層面的互斥鎖,它是一種悲觀鎖,使用它的時候我們一般需要一個監聽對象 並且監聽對象必須是唯一的,通常就是當前類的字節碼對象。它是JVM級別的,不會造成死鎖的情況。使用synchronized可以拿來修飾類,靜態方法,普通方法和代碼塊。比如:Hashtable類就是使用synchronized來修飾方法的。put方法部分源碼:

     public synchronized V put(K key, V value) {
            // Make sure the value is not null
            if (value == null) {
                throw new NullPointerException();
            }
    

    而ConcurrentHashMap類中就是使用synchronized來鎖代碼塊的。putVal方法部分源碼:

     			else {
                    V oldVal = null;
                    synchronized (f) {
                        if (tabAt(tab, i) == f) {
                            if (fh >= 0) {
                                binCount = 1;
    

    synchronized關鍵字底層實現主要是通過monitorenter 與monitorexit計數 ,如果計數器不爲0,說明資源被佔用,其他線程就不能訪問了,但是可重入的除外。說到這,就來講講什麼是可重入的。這裏其實就是指的可重入鎖:指的是同一線程外層函數獲得鎖之後,內層遞歸函數仍然有獲取該鎖的代碼,但不受影響,執行對象中所有同步方法不用再次獲得鎖。避免了頻繁的持有釋放操作,這樣既提升了效率,又避免了死鎖。

    其實在使用synchronized時,存在一個鎖升級原理。它是指在鎖對象的對象頭裏面有一個 threadid 字段,在第一次訪問的時候 threadid 爲空,jvm 讓其持有偏向鎖,並將 threadid 設置爲其線程 id,再次進入的時候會先判斷 threadid 是否與其線程 id 一致,如果一致則可以直接使用此對象,如果不一致,則升級偏向鎖爲輕量級鎖,通過自旋循環一定次數來獲取鎖,執行一定次數之後,如果還沒有正常獲取到要使用的對象,此時就會把鎖從輕量級升級爲重量級鎖,此過程就構成了 synchronized 鎖的升級。鎖升級的目的是爲了減低了鎖帶來的性能消耗。在 Java 6 之後優化 synchronized 的實現方式,使用了偏向鎖升級爲輕量級鎖再升級到重量級鎖的方式,從而減低了鎖帶來的性能消耗。可能你又會問什麼是偏向鎖?什麼是輕量級鎖?什麼是重量級鎖?這裏就簡單描述一下吧,能夠幫你更好的理解synchronized。

    偏向鎖(無鎖):大多數情況下鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得。偏向鎖的目的是在某個線程獲得鎖之後(線程的id會記錄在對象的Mark Word中),消除這個線程鎖重入(CAS)的開銷,看起來讓這個線程得到了偏護。

    輕量級鎖(CAS):就是由偏向鎖升級來的,偏向鎖運行在一個線程進入同步塊的情況下,當第二個線程加入鎖爭用的時候,偏向鎖就會升級爲輕量級鎖;輕量級鎖的意圖是在沒有多線程競爭的情況下,通過CAS操作嘗試將MarkWord更新爲指向LockRecord的指針,減少了使用重量級鎖的系統互斥量產生的性能消耗。

    重量級鎖:虛擬機使用CAS操作嘗試將MarkWord更新爲指向LockRecord的指針,如果更新成功表示線程就擁有該對象的鎖;如果失敗,會檢查MarkWord是否指向當前線程的棧幀,如果是,表示當前線程已經擁有這個鎖;如果不是,說明這個鎖被其他線程搶佔,此時膨脹爲重量級鎖。

  • 方法二:使用Lock接口下的實現類。Lock是juc(java.util.concurrent)包下面的一個接口。常用的實現類就是ReentrantLock 類,它其實也是一種悲觀鎖。一種表現爲 API 層面的互斥鎖。通過lock() 和 unlock() 方法配合使用。因此也可以說是一種手動鎖,使用比較靈活。但是使用這個鎖時一定要注意要釋放鎖,不然就會造成死鎖。一般配合try/finally 語句塊來完成。比如:

    public class TicketThreadSafe extends Thread{
          private static int num = 5000;
          ReentrantLock lock = new ReentrantLock();
          @Override
          public void run() {
            while(num>0){
                 try {
                   lock.lock();
                   if(num>0){
                     System.out.println(Thread.currentThread().getName()+"你的票號是"+num--);
                   }
                  } catch (Exception e) {
                     e.printStackTrace();
                  }finally {
                     lock.unlock();
                  }
                }
          }
    }
    

    相比 synchronized,ReentrantLock 增加了一些高級功能,主要有以下 3 項:等待可中斷、可實現公平鎖,以及鎖可以綁定多個條件。

    等待可中斷是指:當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,改爲處理其他事情,可中斷特性對處理執行時間非常長的同步塊很有幫助。

    公平鎖是指:多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的線程都有機會獲得鎖。synchronized 中的鎖是非公平的,ReentrantLock 默認情況下也是非公平的,但可以通過帶布爾值的構造函數要求使用公平鎖。

    public ReentrantLock(boolean fair) {
            sync = fair ? new FairSync() : new NonfairSync();
        }
    

    鎖綁定多個條件是指:一個 ReentrantLock 對象可以同時綁定多個 Condition 對象,而在 synchronized 中,鎖對象的 wait() 和 notify() 或 notifyAll() 方法可以實現一個隱含的條件,如果要和多於一個的條件關聯的時候,就不得不額外地添加一個鎖,而 ReentrantLock 則無須這樣做,只需要多次調用 newCondition() 方法即可。

    final ConditionObject newCondition() { //ConditionObject是Condition的實現類
                return new ConditionObject();
        }
    
  • 方法三:使用線程本地存儲ThreadLocal。當多個線程操作同一個變量且互不干擾的場景下,可以使用ThreadLocal來解決。它會在每個線程中對該變量創建一個副本,即每個線程內部都會有一個該變量,且在線程內部任何地方都可以使用,線程之間互不影響,這樣一來就不存在線程安全問題,也不會嚴重影響程序執行性能。在很多情況下,ThreadLocal比直接使用synchronized同步機制解決線程安全問題更簡單,更方便,且結果程序擁有更高的併發性。通過set(T value)方法給線程的局部變量設置值;get()獲取線程局部變量中的值。當給線程綁定一個 Object 內容後,只要線程不變,就可以隨時取出;改變線程,就無法取出內容.。這裏提供一個用法示例:

    public class ThreadLocalTest {
          private static int a = 500;
          public static void main(String[] args) {
                new Thread(()->{
                      ThreadLocal<Integer> local = new ThreadLocal<Integer>();
                      while(true){
                            local.set(++a);   //子線程對a的操作不會影響主線程中的a
                            try {
                                  Thread.sleep(1000);
                            } catch (InterruptedException e) {
                                  e.printStackTrace();
                            }
                            System.out.println("子線程:"+local.get());
                      }
                }).start();
                a = 22;
                ThreadLocal<Integer> local = new ThreadLocal<Integer>();
                local.set(a);
                while(true){
                      try {
                            Thread.sleep(1000);
                      } catch (InterruptedException e) {
                            e.printStackTrace();
                      }
                      System.out.println("主線程:"+local.get());
                }
          }
    }
    

    ThreadLocal線程容器保存變量時,底層其實是通過ThreadLocalMap來實現的。它是以當前ThreadLocal變量爲key ,要存的變量爲value。獲取的時候就是以當前ThreadLocal變量去找到對應的key,然後獲取到對應的值。源碼參考如下:

        public void set(T value) {
            Thread t = Thread.currentThread();
            ThreadLocalMap map = getMap(t);
            if (map != null)
                map.set(this, value);
            else
                createMap(t, value);
        }
     	ThreadLocalMap getMap(Thread t) {
            return t.threadLocals; //ThreadLocal.ThreadLocalMap threadLocals = null;Thread類中聲明的
        }
        void createMap(Thread t, T firstValue) {
            t.threadLocals = new ThreadLocalMap(this, firstValue);
        }
    

    觀察源碼就會發現,其實每個線程Thread內部有一個ThreadLocal.ThreadLocalMap類型的成員變量threadLocals,這個threadLocals就是用來存儲實際的變量副本的,鍵值爲當前ThreadLocal變量,value爲變量副本(即T類型的變量)。

    初始時,在Thread裏面,threadLocals爲空,當通過ThreadLocal變量調用get()方法或者set()方法,就會對Thread類中的threadLocals進行初始化,並且以當前ThreadLocal變量爲鍵值,以ThreadLocal要保存的副本變量爲value,存到threadLocals。

    然後在當前線程裏面,如果要使用副本變量,就可以通過get方法在threadLocals裏面查找即可。

  • 方法四:使用樂觀鎖機制。前面已經講述了什麼是樂觀鎖。這裏就來描述哈在java開發中怎麼使用的。

    其實在表設計的時候,我們通常就需要往表裏加一個version字段。每次查詢時,查出帶有version的數據記錄,更新數據時,判斷數據庫裏對應id的記錄的version是否和查出的version相同。若相同,則更新數據並把版本號+1;若不同,則說明,該數據發生了併發,被別的線程使用了,進行遞歸操作,再次執行遞歸方法,直到成功更新數據爲止。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章