JUC-併發編程的藝術

本文轉自:https://my.oschina.net/tjt/blog/726522

Java併發容器和框架

ConcurrentHashMap的實現原理與使用

  • 在併發編程中使用HashMap可能導致程序死循環。而使用線程安全的HashTable效率又非 常低下,基於以上兩個原因,便有了ConcurrentHashMap
  • 多線程會導致HashMap的Entry鏈表 形成環形數據結構,一旦形成環形數據結構,Entry的next節點永遠不爲空,就會產生死循環獲 取Entry。
  • HashTable容器使用synchronized來保證線程安全,但在線程競爭激烈的情況下HashTable 的效率非常低下。因爲當一個線程訪問HashTable的同步方法,其他線程也訪問HashTable的同 步方法時,會進入阻塞或輪詢狀態。如線程1使用put進行元素添加,線程2不但不能使用put方 法添加元素,也不能使用get方法來獲取元素,所以競爭越激烈效率越低。
  • HashTable容器在競爭激烈的併發環境下表現出效率低下的原因是所有訪問HashTable的 線程都必須競爭同一把鎖
  • ConcurrentHashMap所使用的鎖分段技術。首先將數據分成一段一段地存 儲,然後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據的時候,其他段的數 據也能被其他線程訪問。
  • ConcurrentHashMap是由Segment數組結構和HashEntry數組結構組成。Segment是一種可重 入鎖(ReentrantLock),在ConcurrentHashMap裏扮演鎖的角色;HashEntry則用於存儲鍵值對數 據。一個ConcurrentHashMap裏包含一個Segment數組。Segment的結構和HashMap類似,是一種 數組和鏈表結構。一個Segment裏包含一個HashEntry數組,每個HashEntry是一個鏈表結構的元 素,每個Segment守護着一個HashEntry數組裏的元素,當對HashEntry數組的數據進行修改時, 必須首先獲得與它對應的Segment鎖
  • 1.初始 segments數組的源代碼
    if ( concurrencyLevel > MAX_SEGMENTS ) {
              concurrencyLevel = MAX_SEGMENTS;
          }
          int sshift = 0;
          int ssize = 1;
          while ( ssize < concurrencyLevel ) {
              ++sshift;
              ssize <<= 1;
          }
          segmentShift = 32 - sshift;
          segmentMask = ssize - 1;
          this.segments = Segment.newArray( ssize );
    
    • 由上面的代碼可知,segments數組的長度ssize是通過concurrencyLevel計算得出的。爲了能 通過按位與的散列算法來定位segments數組的索引,必須保證segments數組的長度是2的N次方 (power-of-two size),所以必須計算出一個大於或等於concurrencyLevel的最小的2的N次方值 來作爲segments數組的長度。假如concurrencyLevel等於14、15或16,ssize都會等於16,即容器裏 鎖的個數也是16。
  • 2.初始化segmentShift和segmentMask 這兩個全局變量需要在定位segment時的散列算法裏使用,sshift等於ssize從1向左移位的 次數,在默認情況下concurrencyLevel等於16,1需要向左移位移動4次,所以sshift等於4。 segmentShift用於定位參與散列運算的位數,segmentShift等於32減sshift,所以等於28,這裏之所 以用32是因爲ConcurrentHashMap裏的hash()方法輸出的最大數是32位的,後面的測試中我們 可以看到這點。segmentMask是散列運算的掩碼,等於ssize減1,即15,掩碼的二進制各個位的 值都是1。因爲ssize的最大長度是65536,所以segmentShift最大值是16,segmentMask最大值是 65535,對應的二進制是16位,每個位都是1。
  • 3.初始化每個segment
    • 注意前面是初始化整個segment數組
  • initialCapacity是ConcurrentHashMap的初始化容量(默認16),loadfactor是每個segment的負 載因子(默認0.75)

    if (initialCapacity > MAXIMUM_CAPACITY)
             initialCapacity = MAXIMUM_CAPACITY;
         int c = initialCapacity / ssize;
         if (c * ssize < initialCapacity)
             ++c;
          // 變量cap就是segment裏HashEntry數組的長度, 不是1,就是2的N次方。
    
         int cap = 1;
         while (cap < c)
             cap <<= 1;
         for (int i = 0; i < this.segments.length; ++i)
             this.segments[i] = new Segment<K,V>(cap, loadFactor);
    
  • 定位Segment
  • ConcurrentHashMap使用分段鎖Segment來保護不同段的數據,那麼在插入和獲取元素 的時候,必須先通過散列算法定位到Segment ConcurrentHashMap會首先使用 Wang/Jenkins hash的變種算法對元素的hashCode進行一次再散列。 目的是減少散列衝突,使元素能夠均勻地分佈在不同的Segment上, 從而提高容器的存取效率。假如散列的質量差到極點,那麼所有的元素都在一個Segment中, 不僅存取元素緩慢,分段鎖也會失去意義。
    private static int hash(int h) {
         h += (h << 15) ^ 0xffffcd7d;
         h ^= (h >>> 10);
         h += (h << 3);
         h ^= (h >>> 6);
         h += (h << 2) + (h << 14);
         return h ^ (h >>> 16);
     }
    
  • ConcurrentHashMap通過以下散列算法定位segment。
    final Segment<K,V> segmentFor(int hash) {
       return segments[(hash >>> segmentShift) & segmentMask];
    }
    

    ConcurrentHashMap的操作

  • 1.get操作
    • 先經過一次再散列,然後使用這個散列值通過散 列運算定位到Segment,再通過散列算法定位到元素, 整個get過程不需要加鎖,除非讀到的值是空纔會加鎖重讀( 它的get方法裏將要使用的共享變量都定義成volatile類型, 能夠在線 程之間保持可見性,能夠被多線程同時讀,並且保證不會讀到過期的值,但是隻能被單線程寫,但是get方法不需要寫 ),所以高效
    • 之所以不會讀到過期的值,是因爲根據Java內存模 型的happen before原則,對volatile字段的寫入操作先於讀操作,即使兩個線程同時修改和獲取 volatile變量,get操作也能拿到最新的值,這是用volatile替換鎖的經典應用場景。
      public V get(Object key) {
         int hash = hash(key.hashCode());
         return segmentFor(hash).get(key, hash);
      }
      
  • 2.put操作
    • 爲了線程安全,在操作共享變量時必 須加鎖。put方法首先定位到Segment,然後在Segment裏進行插入操作。插入操作需要經歷兩個 步驟,第一步判斷是否需要對Segment裏的HashEntry數組進行擴容( 爲了高效,ConcurrentHashMap不會對整個容器進行擴容,而只 對某個segment進行擴容。 ),第二步定位添加元素的位 置,然後將其放在HashEntry數組裏。

Java的阻塞隊列

  • 阻塞隊列(BlockingQueue)是一個支持兩個附加操作的隊列。這兩個附加的操作支持阻塞 的插入和移除方法。
    • 1)支持阻塞的插入方法:意思是當隊列滿時,隊列會阻塞插入元素的線程,直到隊列不 滿。
    • 2)支持阻塞的移除方法:意思是在隊列爲空時,獲取元素的線程會等待隊列 變爲非空。
  • 阻塞隊列常用於生產者和消費者的場景,生產者是向隊列裏添加元素的線程,消費者是 從隊列裏取元素的線程。阻塞隊列就是生產者用來存放元素、消費者用來獲取元素的容器。
  • 在阻塞隊列不可用時,這兩個附加操作提供了4種處理方式
  • ·拋出異常:當隊列滿時,如果再往隊列裏插入元素,會拋出IllegalStateException("Queue full")異常。當隊列空時,從隊列裏獲取元素會拋出NoSuchElementException異常。
  • ·返回特殊值:當往隊列插入元素時,會返回元素是否插入成功,成功返回true。如果是移 除方法,則是從隊列裏取出一個元素,如果沒有則返回null。
  • ·一直阻塞:當阻塞隊列滿時,如果生產者線程往隊列裏put元素,隊列會一直阻塞生產者 線程,直到隊列可用或者響應中斷退出。當隊列空時,如果消費者線程從隊列裏take元素,隊 列會阻塞住消費者線程,直到隊列不爲空。
  • ·超時退出:當阻塞隊列滿時,如果生產者線程往隊列裏插入元素,隊列會阻塞生產者線程 一段時間,如果超過了指定的時間,生產者線程就會退出。
  • JDK 7提供了7個阻塞隊列,如下。
  • ·ArrayBlockingQueue:一個由數組結構組成的有界阻塞隊列。 默認情況下不保證線程公平的訪問隊列,所謂公平訪問隊列是指阻塞的線程,可以按照 阻塞的先後順序訪問隊列,即先阻塞線程先訪問隊列。非公平性是對先等待的線程是非公平 的,當隊列可用時,阻塞的線程都可以爭奪訪問隊列的資格,有可能先阻塞的線程最後才訪問 隊列。爲了保證公平性,通常會降低吞吐量。
  • ·LinkedBlockingQueue:一個由鏈表結構組成的有界阻塞隊列。此隊列按照先進先出的原則對元素進行排序。
  • ·PriorityBlockingQueue:一個支持優先級排序的無界阻塞隊列。 PriorityBlockingQueue是一個支持優先級的無界阻塞隊列。默認情況下元素採取自然順序 升序排列。也可以自定義類實現compareTo()方法來指定元素排序規則,或者初始 化 PriorityBlockingQueue時,指定構造參數Comparator來對元素進行排序。需要注意的是不能保證 同優先級元素的順序。
  • ·DelayQueue:一個使用優先級隊列實現的無界阻塞隊列。 DelayQueue是一個支持延時獲取元素的無界阻塞隊列。隊列使用PriorityQueue來實現。隊 列中的元素必須實現Delayed接口,在創建元素時可以指定多久才能從隊列中獲取當前元素。 只有在延遲期滿時才能從隊列中提取元素。
    • DelayQueue非常有用,可以將DelayQueue運用在以下應用場景。
    • ·緩存系統的設計:可以用DelayQueue保存緩存元素的有效期,使用一個線程循環查詢 DelayQueue,一旦能從DelayQueue中獲取元素時,表示緩存有效期到了。
    • ·定時任務調度:使用DelayQueue保存當天將會執行的任務和執行時間,一旦從 DelayQueue中獲取到任務就開始執行,比如TimerQueue就是使用DelayQueue實現的。
  • ·SynchronousQueue:一個不存儲元素的阻塞隊列。 SynchronousQueue是一個不存儲元素的阻塞隊列。每一個put操作必須等待一個take操作, 否則不能繼續添加元素. SynchronousQueue可以看成是一個傳球手,負責把生產者線程處理的數據直接傳遞給消費 者線程。隊列本身並不存儲任何元素,非常適合傳遞性場景。SynchronousQueue的吞吐量高於 LinkedBlockingQueue和ArrayBlockingQueue。
  • ·LinkedTransferQueue:一個由鏈表結構組成的無界阻塞隊列。
  • ·LinkedBlockingDeque:一個由鏈表結構組成的雙向阻塞隊列。

Fork/Join框架

  • 工作竊取算法: 幹完活的線程去幫其他線程幹活,就去其他線程的隊列 裏竊取一個任務來執行。而在這時它們會訪問同一個隊列,所以爲了減少竊取任務線程和被 竊取任務線程之間的競爭,通常會使用雙端隊列,被竊取任務線程永遠從雙端隊列的頭部拿 任務執行,而竊取任務的線程永遠從雙端隊列的尾部拿任務執行。
  • Fork/Join使用兩個類來完成上述任務
    • ①ForkJoinTask:我們要使用ForkJoin框架,必須首先創建一個ForkJoin任務。它提供在任務 中執行fork()和join()操作的機制。通常情況下,我們不需要直接繼承ForkJoinTask類,只需要繼 承它的子類,Fork/Join框架提供了以下兩個子類。
    • ·RecursiveAction:用於沒有返回結果的任務。
    • ·RecursiveTask:用於有返回結果的任務。
    • ②ForkJoinPool:ForkJoinTask需要通過ForkJoinPool來執行。 任務分割出的子任務會添加到當前工作線程所維護的雙端隊列中,進入隊列的頭部。當 一個工作線程的隊列裏暫時沒有任務時,它會隨機從其他工作線程的隊列的尾部獲取一個任 務。

Java中的13個原子操作類

  • Atomic包原子更新基本類型類
    • ·AtomicBoolean:原子更新布爾類型。
    • ·AtomicInteger:原子更新整型。
    • ·AtomicLong:原子更新長整型。
    • 以AtomicInteger爲例 舉例一些api
      • 1 ·int addAndGet(int delta):以原子方式將輸入的數值與實例中的值(AtomicInteger裏的 value)相加,並返回結果。
      • 2·boolean compareAndSet(int expect,int update):如果輸入的數值等於預期值,則以原子方 式將該值設置爲輸入的值。
      • 3·int getAndIncrement():以原子方式將當前值加1,注意,這裏返回的是自增前的值。
      • 4·void lazySet(int newValue):最終會設置成newValue,使用lazySet設置值後,可能導致其他 線程在之後的一小段時間內還是可以讀到舊的值。關於該方法的更多信息可以參考併發編程 網翻譯的一篇文章《AtomicLong.lazySet是如何工作的》
      • 5·int getAndSet(int newValue):以原子方式設置爲newValue的值,並返回舊值。
  • Atomic包 原子更新數組
    • AtomicIntegerArray:原子更新整型數組裏的元素。
    • 1 ·int addAndGet(int i,int delta):以原子方式將輸入值與數組中索引i的元素相加。
    • 2·boolean compareAndSet(int i,int expect,int update):如果當前值等於預期值,則以原子 方式將數組位置i的元素設置成update值。
    • ·AtomicLongArray:原子更新長整型數組裏的元素。
    • ·AtomicReferenceArray:原子更新引用類型數組裏的元素。
  • 原子更新引用類型(還是compareAndSet方法)
    • ·AtomicReference:原子更新引用類型。
    • ·AtomicReferenceFieldUpdater:原子更新引用類型裏的字段。
    • ·AtomicMarkableReference:原子更新帶有標記位的引用類型。可以原子更新一個布爾類 型的標記位和引用類型。構造方法是AtomicMarkableReference(V initialRef,boolean initialMark)。

java中的線程池

  • 線程池的實現原理
    • 1)線程池判斷核心線程池裏的線程是否都在執行任務。如果不是,則創建一個新的工作線程來執行任務。如果核心線程池裏的線程都在執行任務,則進入下個流程。
    • 2)線程池判斷工作隊列是否已經滿。如果工作隊列沒有滿,則將新提交的任務存儲在這個工作隊列裏。如果工作隊列滿了,則進入下個流程。
    • 3)線程池判斷線程池的線程是否都處於工作狀態。如果沒有,則創建一個新的工作線程來執行任務。如果已經滿了,則交給飽和策略來處理這個任務。 -ThreadPoolExecutor執行execute()方法的示意圖
  • 1)如果當前運行的線程少於corePoolSize,則創建新線程來執行任務(注意,執行這一步驟 需要獲取全局鎖)。
  • 2)如果運行的線程等於或多於corePoolSize,則將任務加入BlockingQueue。
  • 3)如果無法將任務加入BlockingQueue(隊列已滿),則創建新的線程來處理任務(注意,執 行這一步驟需要獲取全局鎖)。
  • 4)如果創建新線程將使當前運行的線程超出maximumPoolSize,任務將被拒絕,並調用 RejectedExecutionHandler.rejectedExecution()方法。
    • ThreadPoolExecutor採取上述步驟的總體設計思路,是爲了在執行execute()方法時,儘可能 地避免獲取全局鎖(那將會是一個嚴重的可伸縮瓶頸)。在ThreadPoolExecutor完成預熱之後 (當前運行的線程數大於等於corePoolSize),幾乎所有的execute()方法調用都是執行步驟2,而 步驟2不需要獲取全局鎖。
  • 源碼分析
    public void execute ( Runnable command ) {
          if ( command == null ) {
              throw new NullPointerException();
          }
          // 如果線程數小於基本線程數,則創建線程並執行當前任務
          if ( poolSize >= corePoolSize || !addIfUnderCorePoolSize( command ) ) {
              // 如線程數大於等於基本線程數或線程創建失敗,則將當前任務放到工作隊列中。
              if ( runState == RUNNING && workQueue.offer( command ) ) {
                  if ( runState != RUNNING || poolSize == 0 ) {
                      ensureQueuedTaskHandled( command );
                  }
              }
              // 如果線程池不處於運行中或任務無法放入隊列,並且當前線程數量小於最大允許的線程數量,則創建一個線程執行任務。
              else if ( !addIfUnderMaximumPoolSize( command ) ){
              // 拋出RejectedExecutionException異常
                  reject( command ); // is shutdown or saturated
              }
          }
      }
    
  • 工作線程:線程池創建線程時,會將線程封裝成工作線程Worker,Worker在執行完任務 後,還會循環獲取工作隊列裏的任務來執行。我們可以從Worker類的run()方法裏看到這點。
    public void run() {
         try {
             Runnable task = firstTask;
             firstTask = null;
             while (task != null || (task = getTask()) != null) {
                 runTask(task);
                 task = null;
             }
         } finally {
             workerDone(this);
         }
    }
    

    線程池的使用

    • 線程池的創建: new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, milliseconds,runnableTaskQueue, handler);

      參數說明:

      1. runnableTaskQueue(任務隊列):用於保存等待執行的任務的阻塞隊列。可以選擇 ( ArrayBlockingQueue,LinkedBlockingQueue(靜態工廠方法Executors.newFixedThreadPool()使用了這個隊列),SynchronousQueue(Executors.newCachedThreadPool使用了這個隊列。),PriorityBlockingQueue )。
      1. ThreadFactory:用於設置創建線程的工廠,可以通過線程工廠給每個創建出來的線程設 置更有意義的名字。使用開源框架guava提供的ThreadFactoryBuilder可以快速給線程池裏的線 程設置有意義的名字 代碼如下: new ThreadFactoryBuilder().setNameFormat("XX-task-%d").build();
      1. RejectedExecutionHandler (飽和策略):當隊列和線程池都滿了,說明線程池處於飽和狀 態,那麼必須採取一種策略處理提交的新任務。這個策略默認情況下是AbortPolicy,表示無法 處理新任務時拋出異常。( AbortPolicy:直接拋出異常。 ·CallerRunsPolicy:只用調用者所在線程來運行任務。 ·DiscardOldestPolicy:丟棄隊列裏最近的一個任務,並執行當前任務。 ·DiscardPolicy:不處理,丟棄掉。)

        合理配置線程池

  • 可以從以下幾個角度分析:
    • ·任務的性質:CPU密集型任務、IO密集型任務和混合型任務。
    • ·任務的優先級:高、中和低。
    • ·任務的執行時間:長、中和短。
    • ·任務的依賴性:是否依賴其他系統資源,如數據庫連接。
  • 1.性質不同的任務可以用不同規模的線程池分開處理。CPU密集型任務應配置儘可能小的 線程,如配置N cpu +1個線程的線程池。由於IO密集型任務線程並不是一直在執行任務,則應配 置儘可能多的線程,如2*N cpu 。混合型的任務,如果可以拆分,將其拆分成一個CPU密集型任務 和一個IO密集型任務,只要這兩個任務執行的時間相差不是太大,那麼分解後執行的吞吐量 將高於串行執行的吞吐量。如果這兩個任務執行時間相差太大,則沒必要進行分解。可以通過 Runtime.getRuntime().availableProcessors()方法獲得當前設備的CPU個數。
  • 2.優先級不同的任務可以使用優先級隊列PriorityBlockingQueue來處理。它可以讓優先級高 的任務先執行。( 如果一直有優先級高的任務提交到隊列裏,那麼優先級低的任務可能永遠不能 執行。 )
    1. 執行時間不同的任務可以交給不同規模的線程池來處理,或者可以使用優先級隊列,讓 執行時間短的任務先執行。
  • 4.依賴數據庫連接池的任務,因爲線程提交SQL後需要等待數據庫返回結果,等待的時間越 長,則CPU空閒時間就越長,那麼線程數應該設置得越大,這樣才能更好地利用CPU。
  • 建議使用有界隊列。有界隊列能增加系統的穩定性和預警能力,可以根據需要設大一點 兒,比如幾千。
原文地址:https://my.oschina.net/tjt/blog/726522
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章