Java必知必會之(四)--多執行緒全揭祕(下)

假不理發表於2018-09-10

本文旨在用最通俗的語言講述最枯燥的基本知識。

全文提綱:
1.執行緒是什麼?(上)
2.執行緒和程式的區別和聯絡(上)
3.建立多執行緒的方法(上)
4.執行緒的生命週期(上)
5.執行緒的控制(上)
6.執行緒同步(下)
7.執行緒池(下)
8.執行緒通訊(下)
9.執行緒安全(下)
10.ThreadLocal的基本用法(下)


上集已經講述了Java執行緒的一些基本概念,本文接下來講述的是Java的一些高階應用。

6.執行緒同步

一開始接觸“執行緒同步”這個概念可以有點難以理解,我們來舉個栗子:

爸爸開了一張銀行卡存進去10000塊錢,是留給在山東讀大學的哥哥和在河南老家讀高中的妹妹用的。哥哥前天取了2000,變成8000,妹妹昨天取了1000,剩餘7000,今天他們同時到銀行同時取錢,哥哥開啟時ATM發現有7000餘額,妹妹開啟時也發現是7000餘額,他們同時按下確定取1000錢,當他們取完錢之後在檢視餘額發現只有5000塊錢,都在想我只取了1000啊怎麼扣了我2000呢?
這就是生活中的“同步”問題了。
我們把思維轉入到這個ATM的後臺程式,幸好後臺程式對取錢的操作做了同步動作的監聽器,能在多執行緒同時操作的過程中把取錢的動作給鎖定起來,如果程式沒有處理同步問題,那兩邊的ATM的算術都是:7000-1000,結果是剩餘6000.這樣子,銀行對賬就會出錯了。

因此可見,併發程式設計不合理使用也會帶來一些弊端,而針對多執行緒併發的問題,Java引入了同步監視器來解決問題:當執行緒要執行同步程式碼塊/方法之前,必須先獲得對同步監視器的鎖定。
Java中鎖用在的地方有:

  1. 程式碼塊
  2. 方法(構造器、成員變數除外)

1.程式碼塊同步

語法:

1synchronized (obj) {
2//同步內容(比如取錢的操作)    
3}
複製程式碼

其中obj就是同步監視器,也就是說任何執行緒要進入執行該程式碼塊之前,首先獲得對obj的鎖定,獲得之後,其它執行緒就無法獲取它,修改它,直到當前執行緒釋放位置。
比如:爸爸的銀行卡賬戶

1public BankCardAccount bankAccount;
2synchronized (bankAccount) {
3//對bankAccount的扣錢動作    
4}
複製程式碼

當哥哥和妹妹同時取錢時,就如同兩個執行緒在執行,當其中一個執行緒獲取到對bankAccount的鎖定時,另一個執行緒必須等待當前執行緒用完之後釋放bankAccount的鎖定,才可以獲得並且修改之

2.方法同步

語法:

1修飾符 synchronized 返回值 方法名(形參列表){
2}
複製程式碼

方法的同步不需要顯示指定同步監視器,因為它的同步監視器就是當前類的物件,也就是this。

3.鎖釋放

有鎖定就需要有釋放,同步監視器的鎖釋放的事件有以下情況:

  1. 執行緒的同步塊/方法執行結束
  2. 執行緒的同步塊/方法執行過程中丟擲異常或者出現ERROR
  3. 執行緒的同步塊/方法中執行到return、break之類的終止程式碼
  4. 執行緒的同步塊/方法中執行了同步監視器物件的wait()方法

而不釋放的事件也有如下:

  1. 執行緒的同步塊/方法中執行時,程式中執行了帶有sleep()\yield()等暫停操作。
  2. 執行緒的同步塊/方法中執行時,呼叫了suspend()掛起執行緒。

4. 同步鎖

對於基本的同步問題,synchronized就可以滿足,但是需要對執行緒的同步有更強大的操作,就需要到同步鎖Lock了
Lock是控制多執行緒對共享資源進行訪問的工具,通常,所提供了對共享資源的獨佔訪問,每次只能有一個執行緒對Lock物件加鎖,執行緒開始訪問共享資源之前首先要獲得Lock物件。
Lock針對不同的使用場景提供了多種類/介面,主要有以下:

  1. Lock
  2. ReentrantLock
  3. ReadWriteLock
  4. ReentrantReadWriteLock
1. Lock介面

Lock介面提供了幾個方法來操作鎖:

 1package java.util.concurrent.locks;
2import java.util.concurrent.TimeUnit;
3//Lock介面
4public interface Lock {
5    //獲取鎖。如果鎖已被其他執行緒獲取,則進行等待
6    void lock();
7    //獲取鎖,在等待過程中可以相應中斷等待狀態
8    void lockInterruptibly() throws InterruptedException;
9    //嘗試獲取鎖,返回true為獲得成功,返回false為獲取失敗
10    //它和lock()不一樣的是,它不會一直等待,而是嘗試獲取,立即返回
11    boolean tryLock();
12    //嘗試獲得鎖,如果獲取不到就等待time時間
13    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
14    //釋放鎖
15    void unlock();
16}
複製程式碼
2. ReentrantLock

可重入鎖。意思是同一個執行緒可以多次獲取同一個鎖,雖然synchronized也屬於可重入鎖,但是synchronized是在獲取鎖的過程中是不可中斷的,而ReentrantLock則可以。
ReentrantLock是唯一實現了Lock介面的類,因此我們在可以這樣建立一個Lock物件:

1Lock l=new ReentrantLock();
複製程式碼

ReentrantLock的預設狀態和synchronized獲得的屬於非公平鎖(搶佔式獲得鎖,先等待(呼叫lock())的執行緒不一定先獲得鎖,而公平鎖則是先獲得lock的執行緒現貨的鎖)。但是ReentrantLock可以設定為公平鎖,如:

1//公平鎖
2Lock l1=new ReentrantLock(true);
3//非公平鎖
4Lock l2=new ReentrantLock(false);
複製程式碼
3. ReadWriteLock

顧名思義,它叫做讀寫鎖,是一個介面,用來管理讀鎖和寫鎖,讀鎖也叫共享鎖,也就是說讀鎖可以被多個執行緒共享,寫鎖也稱排他鎖,意思是,當一個執行緒獲得了寫鎖,其它執行緒只能等待,不能共享。
前面我們說到:多執行緒併發帶來同步問題,而同步問題用同步監聽器來解決問題。
但我們發現有這樣的一個怪圈:

多執行緒為了提高程式執行效率,同步監聽器為了是多執行緒執行時有且只有其中一個執行緒能執行synchronized修飾的程式碼塊或者方法,這兩個東西有著此消彼長的關係.
那麼?怎麼樣才能讓多執行緒能愉快的行走,而同步問題有可以儘可能少的出現呢?

其實讀寫鎖在一定程度上能解決這個難題。它的特性是:

  1. 讀讀共享
  2. 讀寫互斥
  3. 寫寫互斥

也就是說,比如程式開多個執行緒對一個檔案進行讀寫操作時,如果用synchronized,則讀寫操作要互相等待,而有了ReadWriteLock之後
我們可以把讀寫的鎖操作分開,讀檔案操作用讀鎖,寫檔案操作用寫鎖,
這樣就可以快執行效率了。

我們來看它的原始碼:

1public interface ReadWriteLock {
2 //獲取讀鎖
3 Lock readLock();
4 //獲取寫鎖    
5 Lock writeLock();
6}
複製程式碼

只有一個獲取讀鎖和一個獲取寫鎖的介面方法,介面的存在得有有類實現它才有意義,我們看下一個類:

4. ReentrantReadWriteLock

ReentrantReadWriteLock是ReadWriteLock介面的實現類,當我們要建立一個ReadWriteLock的鎖時,通常:

1ReadWriteLock rl=new ReentrantReadWriteLock();
複製程式碼

前面說到ReentrantLock是Lock的實現類,ReentrantLock是一種排它鎖,也就是說某個時間內,只有允許一個執行緒訪問(但是這個執行緒可以同時訪問多次),而ReentrantLock是讀寫鎖,也就是說在同一時間內,允許多個執行緒同時獲取讀鎖進行操作(但不允許讀寫、寫寫同時操作),在某些業務場景(比如讀操作遠高於寫操作)下,ReentrantReadWriteLock會比ReentrantLock有更好的效能和併發。
ReentrantReadWriteLock主要有以下特效:

  1. 可以設定公平鎖和非公平鎖。
1//公平鎖
2ReadWriteLock rl=new ReentrantReadWriteLock(true);
3//非公平鎖
4ReadWriteLock rl=new ReentrantReadWriteLock();
複製程式碼
  1. 可重入鎖。
    2.1 同一個讀執行緒可多次獲得讀鎖
    2.2 同一個寫執行緒可以多次獲得寫鎖或者讀鎖
  2. 可中斷性:就是說可以在獲取鎖期間中斷操作
  3. 可以鎖降級:也就是寫鎖可降為讀鎖

7. 執行緒通訊

當執行緒在程式中執行時,執行緒的排程有一些不確定性,也就是在常規情況無法準確的控制執行緒之間的輪換執行時機,因此Java提供了一些機制來便於開發者控制執行緒的協調執行。

  1. synchronized修飾的方法/程式碼塊中使用wait()、notify()、notifyAll()來協調
  2. 使用condition控制
  3. 使用阻塞佇列控制
1. synchronized修飾方法/程式碼塊中使用wait()、notify()、notifyAll()協調

實際上,wait、notify、notifyAll是定義在Object類的例項方法他們只能在synchronized的程式碼塊/方法中使用,用來控制執行緒。

  1. wait: 持有鎖的執行緒準備釋放物件鎖許可權,釋放cpu資源並進入等待。
  2. notify:持有物件鎖的執行緒1即將釋放鎖,通知jvm喚醒某個競爭該鎖的執行緒2。執行緒在 synchronized 程式碼作用域結束後,執行緒2直接獲得鎖,其他競爭執行緒繼續等待(即使執行緒X同步完畢,釋放物件鎖,其他競爭執行緒仍然等待,直至有新的notify ,notifyAll被呼叫)。
  3. notifyAll:持有鎖的執行緒1準備釋放鎖,通知jvm喚醒所有競爭該鎖的執行緒,執行緒1在synchronized 程式碼作用域結束後,jvm通過演算法將物件鎖許可權指派給某個執行緒2,所有被喚醒的執行緒不再等待。執行緒1在synchronized 程式碼作用域結束後,之前所有被喚醒的執行緒都有可能獲得該物件鎖許可權,這個由JVM演算法決定。
2. 使用condition控制

對於用Lock來做同步工作的情況,Java提供了condition類來協助控制執行緒通訊。condition的例項是由Lock物件來建立的,

1//建立一個lock物件
2Lock l=new ReentrantLock();
3//建立一個condition例項
4Condition con=l.newCondition();
複製程式碼

Condition類有以下方法:

  1. await():類似於wait(),導致當前執行緒等待,知道其它執行緒代用該Condition的signal()或signalAll()來喚醒該執行緒
  2. signal():喚醒此Lock物件上等待的單個執行緒,如果所有執行緒都在該Lock物件上等待,則會選擇喚醒其中一個執行緒,選擇是任意的,只有當前執行緒放棄對該Lock物件的鎖定後才可以執行被喚醒的執行緒
  3. signalAll():喚醒在此Lock物件上等待的所有執行緒,只有當前執行緒放棄對該Lock物件的鎖定後,才可以執行被喚醒的執行緒。
3. 使用阻塞佇列控制

在Java5中提供了一個介面:BlockingQueue,它是作為執行緒同步的一個工具而產生,當生產者執行緒試圖向BlockingQueue中放入元素時,如果該佇列已滿,則執行緒被阻塞,當消費者執行緒試圖從BlockingQueue中取出元素時,如果佇列為空,則執行緒被阻塞。
BlockingQueue介面原始碼:

 1public interface BlockingQueue<Eextends Queue<E{
2    boolean add(E e);
3    boolean offer(E e);
4    void put(E e) throws InterruptedException;
5    boolean offer(E e, long timeout, TimeUnit unit)
6        throws InterruptedException
;
7    take() throws InterruptedException;
8    poll(long timeout, TimeUnit unit)
9        throws InterruptedException
;
10    int remainingCapacity();
11    boolean remove(Object o);
12    public boolean contains(Object o);
13    int drainTo(Collection<? super E> c);
14    int drainTo(Collection<? super E> c, int maxElements);
15}
複製程式碼

其中支援阻塞的有兩個:

  1. take():嘗試從BlockingQueue頭部獲取元素
  2. put(E e):嘗試把e放入BlockingQueue中

BlockingQueue介面的實現類有:

  1. ArrayBlockingQueue:陣列阻塞佇列
  2. LinkedBlockingQueue:連結串列阻塞佇列
  3. PriorityBlockingQueue:帶有排序性的非標準阻塞佇列
  4. SynchronousQueue:同步佇列,讀寫不能同時,只能交替執行
  5. DelayQueue:特殊的阻塞佇列,它要求集合元素都實現Dely介面

阻塞佇列平時用得少,就僅僅講述一些基本原理和使用方法,例子不再贅述。

8. 執行緒池

執行緒池的產生和資料庫的連線池類似,系統啟動一個執行緒的代價是比較高昂的,如果在程式啟動的時候就初始化一定數量的執行緒,放入執行緒池中,在需要是使用時從池子中去,用完再放回池子裡,這樣能大大的提高程式效能,再者,執行緒池的一些初始化配置,也可以有效的控制系統併發的數量。
Java提供了一個Executors工廠類來建立執行緒池,要新建一個執行緒池,主要有以下幾個靜態方法:

  1. newFixedThreadPool:可重用、有固定執行緒數的池子
  2. newCachedThreadPool:帶有快取的池子
  3. newSingleThreadExecutor:只有一個執行緒的池子
  4. newScheduledThreadPool:可指定延後執行的池子

關於每個方法具體使用以及引數,再次就不贅述了,有興趣的筒子直接進入Executors類就可以看到了。

9. 執行緒安全

什麼是執行緒安全?
在多執行緒環境下,多個執行緒同時訪問共享資料時,某個執行緒訪問的被其它執行緒修改了,導致它使用了錯誤的資料而產生了錯誤,這就引發了執行緒的不安全問題。
而當多個執行緒訪問某個類時,不管執行時環境採用何種排程方式或者這些程式將如何交替執行,並且在主調程式碼中不需要任何額外的同步或協同,這個類都能表現出正確的行為,那麼就稱這個類是執行緒安全的。
大家是否記得,不管是老師的課後習題還是面試筆試題,經常都會出現“StringBuilder、StringBuffer是否執行緒安全”這樣的問題?
我們來檢視各自的原始碼看看究竟吧。
StringBuffer的append方法:

1@Override
2    public synchronized StringBuffer append(String str) {
3        toStringCache = null;
4        super.append(str);
5        return this;
6    }
複製程式碼

StringBuilder的append方法:

1 @Override
2    public StringBuilder append(String str) {
3        super.append(str);
4        return this;
5    }
複製程式碼

再看看它們的super.append原始碼:

1public AbstractStringBuilder append(String str) {
2        if (str == null)
3            return appendNull();
4        int len = str.length();
5        ensureCapacityInternal(count + len);
6        str.getChars(0, len, value, count);
7        count += len;
8        return this;
9    }
複製程式碼

可以看出,兩者的append方法區別就在於前者有synchronized修飾,這意味著多個執行緒可以同時訪問這個方法時,前者是阻塞執行的,而後者是可以同時執行並且同時訪問count,因此就有可能導致count錯亂。由此可見:

StringBuffer 是執行緒安全的,但是由於加了鎖,導致效率變低。
StringBuilder 是執行緒不安全的,在單執行緒環境下,效率非常高。

既然已經從根本知道了什麼是執行緒安全,那麼Java是如何解決執行緒安全問題的呢?
從Java5開始,增加一了些執行緒安全的類來處理執行緒安全的問題,如:

  1. ThreadLocal
  2. ConcurrentHashMap
  3. ConcurrentSkipListMap
  4. ConcurrentSkipListSet
  5. ConcurrentLinkedQueue
  6. ConcurrentLinkedDeque
  7. CopyOnWriteArrayList
  8. CopyOnWriteArrayList
  9. CopyOnWriteArraySet
  10. CopyOnWriteHashMap

10. ThreadLocal

ThreadLocal代表一個執行緒區域性變數,通過把資料放在ThreadLocal中就可以讓每個執行緒建立一個該變數的副本,從未避免併發訪問的執行緒安全問題。
維持執行緒封閉性的一種方法是使用ThreadLocal。它提供了set和get等訪問方法,這些方法為每個使用該變數的執行緒都存有一份獨立的副本,因此get方法總是返回由當前執行執行緒在呼叫set時設定的最新值。
它提供三個方法:

  1. T get():返回此執行緒區域性變數中當前執行緒副本中的值。
  2. remove():刪除此執行緒區域性變數中當前執行緒的值。
  3. set(T t):設定此執行緒區域性變數中當前執行緒副本中的值。

舉個栗子:建立一個帶有ThreadLocal的類:

 1public class TestThreadLocal  {
2    // 副本
3    private ThreadLocal<Integer> countLoacl = new ThreadLocal<Integer>();
4    public TestThreadLocal(Integer num) {
5        countLoacl.set(num);
6    }
7    public Integer getCount() {
8        return countLoacl.get();
9    }
10    public void setCount(Integer num) {
11        countLoacl.set(num);
12    }
13}
複製程式碼

這樣子建立的類帶有ThreadLocal的countLoacl,在多個執行緒同時消費這個物件時,ThreadLocal會為每個執行緒建立一個countLoacl副本,這樣就可以避免多執行緒之間的資源競爭而導致安全問題了。

覺得本文對你有幫助?請分享給更多人
關注「程式設計無界」,提升裝逼技能

Java必知必會之(四)--多執行緒全揭祕(下)

相關文章