java併發編程實戰

紅色是面試點? 

如果多個線程訪問一個對象的狀態變量沒有做同步措施,程序就可能出現錯誤。可以彌補的措施有:

1、狀態變量不在線程之間共享

2、將狀態修改爲不可變的變量

3、訪問該狀態變量的時候使用同步(似乎和問題條件衝突)

當設計線程安全的類時,良好的面向對象技術、不可修改性一級明細的不變性規範都能起到作用
面向對象的抽象和封裝會降低性能
使用線程安全的類可以避免去糾結線程安全問題
線程安全的定義:當多個線程訪問某個類時,不管是什麼調度方式或者線程交替執行,在主調代碼中不需要額外的同步或協同,這個類都能表現出正確的行爲。這個類就是線程安全的。
無狀態的類一定是線程安全的。
count++到底做了什麼?

count++在指令層面做了 讀取-修改-寫入 三個步驟,這三個步驟是一個操作序列,在多線程中可能因爲多個線程讀取了初始值,A線程修改了值,但是B和C線程仍是在初始值的基礎上做修改,讀取修改寫入是競態條件的一種典型情況。

競態條件(raceCondition)

競態條件我覺得翻譯成競態現象更貼切一些。由於執行時序不同導致錯誤結果的現象。最常見的競態現象是 檢查——執行(CHECK-THEN-ACT),檢查是觀察值的情況,執行是採取動作,由於檢查的時候失去了時間片,其他線程對數據做了修改,此時在拿到時間片去執行,基於的數據就是錯誤的。即基於錯誤的觀察結果而執行動作。

讀取修改寫入和檢查執行是常見的競態現象,讀取修改寫入常用於修改已有的值,在賦值過程中讀取的值發生了變化導致原來基於的數據是錯誤的,而檢查執行就是基於錯誤的觀察結果去執行代碼。

競態現象的實例:延遲初始化

public class LazyInitRace{

    private ExpensiveObj instance = null;

    public ExpensiveObj getInstance(){

        if(instance==null){

            instance = new ExpensiveObj();

        }

        return instance;

    }

}

如果A B線程在該類沒有初始化的情況下同時去執行獲取實例方法的時候,兩個線程都走到了判空,下一個指令都是初始化,初始化了兩個實例,最終就會導致有一個實例被覆蓋掉,如果裏面包含的是用戶信息就會導致用戶數據丟失的情況。

原子性

原子性是併發程序正確執行的三大必要特性之一,其他兩個是可見性和有序性。

如果某個操作有多個線程要執行,那麼在B執行之前,A的操作要麼全部執行完,要麼還沒開始,B不能再A的操作過程中對數據進行操作。那麼這個操作就被稱爲是原子的。

count++本身操作不是原子的,但是通過synchronized修飾符可以讓操作變成原子的,我認爲 操作原子化 這個修飾比較貼切。

原子操作是原子的,但是原子操作的組合不是原子的。

如Vector.contains()方法和Vector.add()方法都是原子的,但是二者組合起來先判斷是否包含,再添加對象,就是一個典型的檢查——執行的競態現象。

併發中的原子性和事務中的原子性相似,事務中的原子性是一系列數據庫操作要麼都完成要麼還沒開始,不能在操作過程中有其他數據修改。

synchronized關鍵字

具體可以看博文  https://blog.csdn.net/a397525088/article/details/82317338

鎖所包含的代碼一定是原子操作,一個線程在執行synchronized代碼塊的時候其他線程是一定不能進入這個代碼塊的。

synchronized鎖被稱爲內置鎖或者監視器鎖。

內置鎖是一個互斥鎖,同一時間點只能有一個線程持有這個鎖,除了持有鎖的線程外其他線程不可以執行內置鎖包含的代碼。獲取內置鎖的唯一途徑是執行鎖中的代碼。

不當的使用可能會導致性能問題。

可重入鎖

可重入鎖是指一個線程獲取到鎖後,在釋放前仍可以再獲取同樣的鎖。可重入鎖的粒度是線程而不是調用,pthread的粒度是調用。

鎖的一種實現方法是爲鎖設置兩個參數,當前佔用個數0或1,當前線程,如果當前佔用爲0就是已釋放,任何線程都可以獲取這個鎖,如果當前佔用是1,那麼就比對來請求這個鎖的是否是記錄的當前線程,如果是,則可以執行。

可重入鎖的設計如果某個線程獲取了某個對象的鎖,那麼在他釋放之前他一定可以無限次的獲取當前這個鎖。

不可重入鎖

不可重入鎖與可重入鎖相對,假設某對象obj的兩個方法methodA和methodB是加鎖的,methodA中調用了methodB。

那麼在某線程執行methodA時,他將無法執行methodB,methodA方法也無法執行完成,造成死鎖。

因爲不可重入鎖記錄的是調用,他只記錄了當前鎖是否被佔用,當線程調用methodA時,這個對象的鎖被設置爲了佔用,此時再去執行methodB時,鎖判斷當前的狀態是佔用的,所以其他線程都無法進來,導致methodB無法執行,methodA也就無法執行完畢。

最終造成死鎖。

其他線程也無法進入A,因爲這個對象的鎖是佔用的。

servlet不是線程安全的,他的service方法沒有內置鎖,但是servlet設計的初衷就是可以多個線程執行
原子變量和原子操作不建議一起使用,容易造成混亂和性能問題,如要將一個servlet設置成線程安全的,可以選擇使用線程安全的類,也可以選擇在servlet中需要同步的地方加上鎖。
執行時間長的代碼不要持有鎖,如網絡IO,jdbc等
指令重排

指令重排是指在單線程環境中,多個指令順序調換並不影響最終的結果。

如int a = 1;

int b = 2;

這兩條執行先後順序暫時看來不影響最終結果。先初始化a和先初始化b沒有影響。

但是如果放入到多線程環境中,如下代碼,如果對number=42和ready=true進行重排序,先執行ready=true,且這個時候還沒有對number進行賦值,則代碼會進入到打印number,打印0。

(實際上在8G i5的環境下,好不容易復現了還不知道是不是復現的對的)

public class MyThread{
    private static boolean ready = false;
    private static int number = 0;
    private static class ReaderThread extends Thread{
        @Override
        public void run() {
            while(!ready) {
                Thread.yield();
            }
            System.out.println(number);;
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

64位數據的操作和最低安全條件

java中如果是對64位基本數據(long和double)進行操作的話,如果沒有加volatile修飾,在多線程環境中可能會造成讀取數據錯亂的現象。

所謂最低安全條件是指:即使在多線程環境中讀取數據出錯了,也是之前數據存放的有意義的曾經的值,這個是多線程中的最低安全條件。

64位數據操作不滿足這個條件,因爲在java中64位數據的操作是可以分解爲兩個對32數據操作的結果之和的,很可能讀到的值是兩個線程操作的兩部分值的和。所以除非用volatile或者同步鎖,否則64位的共享數據不滿足最低安全條件。

volatile變量
volatile修飾的變量在編譯和運行都不會被重排序
volatile修飾的變量不會別緩存在寄存器中,因此必須從主存中讀取,修改也是直接修改主存的值
操作volatile變量不會加鎖,所以相對synchronized是一個輕量級的鎖
volatile控制的是可見性,不能控制原子性,volatile修飾的count的count++操作仍然是非線程安全的,即某一時刻讀取到的count一定是主存中的,但是讀取修改寫入操作中的後兩步依然是基於第一步讀取到的數據的

加鎖機制既可以保證原子性和可見性,但是volatile只能保證可見性,通常用作某個操作完成、發生或者中斷的標誌

發佈 逸出

發佈一個對象是指使這個對象可以在作用域以外的地方使用。如將對象申明成一個public static對象,或者在一個非私有方法中返回這個對象的引用,或者將對象的引用交給其他類的方法。發佈某個對象的某個部分也是發佈這個對象。如一個List<Obj> list ;如果修改其中任一個obj,那麼也是發佈了這個對象,因爲你修改了obj就是修改了這個list

逸出就是發佈不該發佈的對象。

不要在構造函數的過程中發佈對象的this引用,如啓動一個線程,線程中引用了對象的this引用,此時對象還沒有構造完成。可以在構造函數中定義線程,但是不要start。可以定義工廠方法,將構造方法私有。

總結:

對象分配在堆中,變量裏保存了對對象的引用。如果某個局部變量的對象引用通過方法傳遞或者返回給其他地方,則是將引用交了出去,其他地方獲取了這個引用。就是發佈。

不該發佈的時候發佈了,就是逸出。

線程封閉

當訪問共享的可變數據時,通常情況下,是使用同步。但是如果將數據設置爲不共享數據,只在某個線程內訪問,就不需要同步。(又是一個和定義衝突的解決方案,書上這麼寫的,是不是翻譯問題,嘔)

這種將數據設置爲僅單線程內可訪問的計數被稱爲線程封閉。將數據封閉在某個線程裏,這個數據僅對這個線程可見。線程封閉的對象本身可以不是線程安全的。如雖然我的數據讀取修改寫入不是同步的,但是我這個數據同一時刻只有某個線程可見,那麼這個讀取修改寫入操作也具備線程安全性。

典型的有jdbc連接池,連接池在每次需要使用時去獲取一個連接,用完之後再返回,在返回之前其他線程看不到這個鏈接,所以這個連接對象是線程封閉的。(大多數請求如servlet執行過程中是同步的(啓動過程不是,是服務器多線程啓動多個線程用同一個 servlet去執行的,但是servlet內部是同步的),即不會在執行過程中去啓動另一個線程,並將連接發佈到這個線程中,所以connection可以認爲是線程封閉的)

局部變量和ThreadLocal類也是線程封閉的

Ad-hoc線程封閉

維護線程封閉完全由程序來實現。

棧封閉

棧封閉中只能通過局部變量才能訪問對象(局部變量是局部變量表中的變量,包括方法參數和方法中聲明的對象,this在局部變量表中也有,而且是第一個,但是應該不是這裏說的局部變量)。

如果局部變量是一個基本類型,那麼這個變量一定是棧封閉的,因爲在java中基本類型只能傳值(對象名義是按引用傳值,但是實際上也是按值傳遞,因爲對象本身存的就是引用地址的值,而傳值就是將他本身包含的引用地址的值傳過去),所以基本變量的局部變量一定是棧封閉的。

ThreadLocal封閉

ThradLocal確保每個線程中獲取到的值和其他線程是相互隔離的。

不變性

不可變的對象一定是線程安全的。

如果對象創建後狀態就不可更改,所有域都是final類型,對象創建過程中沒有逸出,那麼這個對象就是不可變的。

除非一個對象的某個域需要公開,否則就應該是private的,同理,除非某個對象是需要改變你的,否則也應該是final的,這是一個編程習慣。

監視器模式

監視器模式是把對象的所有可變狀態都封裝起來,並且用狀態對象自己的鎖去保護(狀態對象的,不是監視器對象的,如果狀態是基本數據類型,那麼就要使用監視器對象)。

public class PrivateLock{

    private  LockObj lockObj; // lockObj是私有對象,但是這個對象被方法封裝,且有內置鎖保護

    public void func(){

        synchronized(lockObj){ // 這裏鎖的lokObj

            ...

        }

    }

}

線程安全性委託
線程安全性委託是指一個類是由多個狀態變量組成的,這個類將自己的安全性委託給自己所包含的狀態變量。
但是組件安全不意味着類就安全。有時候可能要給其中多個變量的操作再套上一個安全層。
如某個監聽器類,包含鼠標監聽器和鍵盤監聽器,由於鼠標監聽和鍵盤監聽沒有直接關聯,所以監聽器類可以將線程安全性委託給這兩個組件。
舉一個多個狀態變量之間有關聯關係的例子。
一個類有兩個狀態變量,分別是minSize,一個是maxSize,前者必須小於後者。此時即便兩個變量是線程安全的,但是組合起來使用,就可能產生線程安全問題,如在設置最小值時還沒有讀取到最大值所以通過了,但是還沒有設值,在設置最大值時沒有讀取到最小值,也走到了設值的前一步,所以最終導致兩者關聯關係失效。此時就需要加鎖來完成線程安全。
一種特別的構造函數
設有個類,他的功能是包裝某個map,構造函數的參數是外部的map對象,即 
public Obj(Map map){};
這裏對map做的操作不是賦值,而是將map做一個深拷貝,拷貝的對象賦值到這個對象obj的成員域中。這樣外部對map的修改不會影響到obj
另外方法unmodifiablemap可以生成不可修改的map對象
一個加錯鎖導致的線程安全問題
public class Obj{
    public List<String> list = Collections.synchronizedList(new ArrayList<E>);
    public synchronized void modifyList(E x){
       boolean absent = !list.contains(x);
       if(absent){
            list.add(x);
       }
       return absent;
    }
}
這個鎖雖然在這個方法上加了鎖,並且list也是線程安全的對象,但是鎖是加載obj對象上的,假設此時判斷是否包含是不包含,執行完後釋放list鎖,後面其他地方獲取到list鎖對list做修改,當前obj的鎖並不能阻止他,這裏的list是public的,外界很容易獲取,其他形式的get方法和這種情況同理
實際上應該加如下鎖
public class Obj{
    public List<String> list = Collections.synchronizedList(new ArrayList<E>);;
    public void modifyList(){
        synchronized(list){
            boolean absent = !list.contains(x);
            if(absent){
                list.add(x);
            }
            return absent;
        }
    }
}

通過組合添加原子操作

public class ImprovedList{
    private List<String> list = Collections.synchronizedList(new ArrayList<E>);;

    public ImprovedList(List list){ this.list = list};

    public synchronized void clear(){

        .....

    }

   
    public synchronized void modifyList(){
          boolean absent = !list.contains(x);
          if(absent){
              list.add(x);
          }
          return absent;
    }

    //  其他list方法一樣

}

 由於這裏的list並沒有對外開放獲取,所有的操作必須通過improvedList去操作,所以只要保證了improvedList是線程安全的,那麼底層的list也一定是線程安全的

客戶端加鎖

如某個類Obj的get和set方法是原子的,但是組合起來不是原子的,通過在調用端(客戶端)給get和set一起加鎖,保證操作原子性就是客戶端加鎖

同步容器類和併發容器類
二者都是線程安全的,但是有時候需要客戶端加鎖。如vector就是同步容器類。
同步容器的壞處是同步鎖太多,嚴重影響性能。如concurrentHashMap就是併發容器,用於替代同步的散列map,CopyOnWriteList用於代替同步的list,併發容器可以極大提高伸縮性並降低風險。
ConcurrentLinkedQueue先進先出隊列,PriorityQueue非併發隊列但是可以設置優先級
BlockingQueue擴展了queue,增加了可阻塞的插入和獲取操作,如果隊列爲空,那麼獲取元素的操作一直阻塞,直到有可用的數據,如果元素滿了,那麼插入的操作一直阻塞,直到有位置。
隊列分爲有限長度的和無限長度的。無限長度的隊列永遠不會插入阻塞。

阻塞隊列和生產者消費者模式
生產者消費者模式是兩端代碼,A端負責生產任務,B端負責處理任務,通過可阻塞隊列可以簡化代碼。如A端生產了任務,防止到阻塞隊列中,B端通過輪詢去獲取,獲取到之後處理。阻塞隊列如果是優先隊列的話可以限制任務處理個數,讓待處理任務不要太多導致處理不完。
阻塞隊列提供了一個offer方法,offer方法也可以插入數據,但是如果插入失敗的話會返回false,這樣可以根據返回結果去執行處理策略,如將×××項寫入磁盤,或者抑制生產者線程。

queue有add remove方法是非阻塞的

put和take是可以阻塞的

offer有返回狀態

// 生產者類

public class Produce implements Runnable{
    public void run() {
        try {
            while(true) {
                if(MainFunc.queue.offer(String.valueOf(MainFunc.queue.size()+1))) {
                    Thread.sleep(1000);
                    System.out.println(new Date().toString()+"新的任務已添加,現在還有"+MainFunc.queue.size()+"個任務");
                }else {
                    Thread.sleep(1000);
                    System.out.println(new Date().toString()+"插入失敗,現在還有"+MainFunc.queue.size()+"個任務");
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

//消費者類

public class Consume implements Runnable{
    public void run() {
        try {
            while(true) {
                String task = MainFunc.queue.take();
                Thread.sleep(2000);
                System.out.println(new Date().toString()+"__"+task+"任務已處理,還剩"+MainFunc.queue.size());
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

// 主類
public class MainFunc {
    public static BlockingQueue<String> queue = new LinkedBlockingQueue<>(5);
    public static void main(String[] args) {
        Consume consume = new Consume();
        Produce produce = new Produce();
        new Thread(consume).start();
        new Thread(produce).start();
    }
}

// 執行後臺

Tue Sep 11 16:43:18 CST 2018新的任務已添加,現在還有0個任務
Tue Sep 11 16:43:19 CST 20181任務已處理,還剩1
Tue Sep 11 16:43:19 CST 2018新的任務已添加,現在還有0個任務
Tue Sep 11 16:43:20 CST 2018新的任務已添加,現在還有1個任務
Tue Sep 11 16:43:21 CST 2018
1任務已處理,還剩2
Tue Sep 11 16:43:21 CST 2018新的任務已添加,現在還有1個任務
Tue Sep 11 16:43:22 CST 2018新的任務已添加,現在還有2個任務
Tue Sep 11 16:43:23 CST 20181任務已處理,還剩3
Tue Sep 11 16:43:23 CST 2018新的任務已添加,現在還有2個任務
Tue Sep 11 16:43:24 CST 2018新的任務已添加,現在還有3個任務
Tue Sep 11 16:43:25 CST 2018
2任務已處理,還剩4
Tue Sep 11 16:43:25 CST 2018新的任務已添加,現在還有3個任務
Tue Sep 11 16:43:26 CST 2018新的任務已添加,現在還有4個任務
Tue Sep 11 16:43:27 CST 20182任務已處理,還剩5
Tue Sep 11 16:43:27 CST 2018新的任務已添加,現在還有4個任務
Tue Sep 11 16:43:28 CST 2018新的任務已添加,現在還有5個任務
Tue Sep 11 16:43:29 CST 2018
3任務已處理,還剩5
Tue Sep 11 16:43:29 CST 2018插入失敗,現在還有4個任務
Tue Sep 11 16:43:30 CST 2018新的任務已添加,現在還有5個任務
Tue Sep 11 16:43:31 CST 2018__3任務已處理,還剩5
Tue Sep 11 16:43:31 CST 2018插入失敗,現在還有4個任務

雙端隊列與工作密取

Deque是一個雙端隊列,可以在列頭和列尾進行插入和移除。

阻塞隊列適用於生產者消費者模式。

雙端隊列則適用於工作密取。工作密取中每個消費者都有各自的雙端隊列,如果一個消費者完成了自己的消費隊列,那麼他可以去另一個雙端隊列的列尾去取任務執行,而原來的消費者是從隊頭取數據。

這樣可以保證所有的消費者線程都在運行,且當自己隊列執行完後執行其他消費者隊列時減少大量競爭。

阻塞和中斷

阻塞是等待外部其他動作完成,是否能繼續進行下去由外部事件決定,如等待IO結果,等待鎖可用或者等待某項計算完成。

阻塞是一類方法,這些需要等待外部事件來決定是否繼續執行下去的方法叫阻塞方法,而中斷方法是阻塞方法獨有的。

線程在調用阻塞方法且還沒有完成的時候稱爲阻塞狀態

中斷方法可以選擇在阻塞方法等待結果時去做一些操作,這個操作需要開發人員自己去定義。而阻塞方法在接收到中斷通知後就會拋出中斷異常,開發人員可以決定後面執行的操作。

所以諸如sleep的方法還有阻塞隊列的put方法和take方法都會拋出阻塞異常。await()方法也是阻塞方法

下面是一個阻塞方法被中斷的例子。

這是中斷的常規使用方式,另外線程中還有判斷是否中斷的方法,如isInterrupt(),和interrupted()方法,通常中斷異常的處理方式就是直接拋出給上一層,或者簡單處理後再繼續拋出

同步工具類

同步工具類是可以根據自身的狀態來協調線程的控制流的類。

如阻塞隊列,可以根據put take是否阻塞來協調是否繼續執行下去。

閉鎖:根據是否到達結束狀態(如計數器變爲0)來控制當前線程是否繼續執行下去

其他還有信號量和柵欄

閉鎖 latch

閉鎖是這樣一種鎖,他必須等待某些事務執行完畢纔會執行。

常見的應用有如下:

確保某個計算所需要的資源都執行完畢

確保依賴的服務都啓動完畢

如下代碼,假設查詢結果依賴於其他兩個查詢

public class Demo {
    private int a,b;
    public int getA() {
        return a;
    }
    public void setA(int a) {
        this.a = a;
    }
    public int getB() {
        return b;
    }
    public void setB(int b) {
        this.b = b;
    }
    public static void main(String[] args) {
        System.out.println("這是一個需要到數據庫裏查詢兩條大數據量然後整合的程序");
        CountDownLatch countDownLatch = new CountDownLatch(2);
        Demo demo = new Demo();
        Thread thread1 = new Thread(()->{
            try {
                System.out.println(new Date().toString()+ "————線程1去數據庫裏查詢A數據");
                Thread.sleep(3000);
                System.out.println(new Date().toString()+ "————過了三秒後線程1的數據查詢完畢,a賦值3");
                demo.setA(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            countDownLatch.countDown();
        });
        Thread thread2 = new Thread(()->{
            try {
                System.out.println(new Date().toString()+"————線程2去數據庫裏查詢B數據");
                Thread.sleep(5000);
                System.out.println(new Date().toString()+"————過了五秒線程2的數據查詢完畢,a賦值5");
                demo.setB(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            countDownLatch.countDown();
        });
        Thread thread3 = new Thread(()->{
            System.out.println(new Date().toString()+"————線程3開始,然後等待線程1和線程2的結果執行完");
            try {
                countDownLatch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(new Date().toString()+"等待完畢,獲取A和B的和"+(demo.getA()+demo.getB()));
        });
        thread1.start();
        thread2.start();
        thread3.start();
    }
}

執行結果如下

這是一個需要到數據庫裏查詢兩條大數據量然後整合的程序
Wed Sep 12 10:25:55 CST 2018————線程3開始,然後等待線程1和線程2的結果執行完
Wed Sep 12 10:25:55 CST 2018————線程1去數據庫裏查詢A數據
Wed Sep 12 10:25:55 CST 2018————線程2去數據庫裏查詢B數據
Wed Sep 12 10:25:58 CST 2018————過了三秒後線程1的數據查詢完畢,a賦值3
Wed Sep 12 10:26:00 CST 2018————過了五秒線程2的數據查詢完畢,a賦值5
Wed Sep 12 10:26:00 CST 2018等待完畢,獲取A和B的和8

另一種代碼一起開始並且等待線程是主線程的demo
public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch startGate = new CountDownLatch(1);
        CountDownLatch endGate = new CountDownLatch(3);
        for(int i =0; i<3; i++) {
            Thread thread = new Thread(()->{
                try {
                    startGate.await();
                    System.out.println(Thread.currentThread().getName()+"任務開始");
                    Thread.sleep(3000);// 執行對應任務
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    System.out.println(Thread.currentThread().getName()+"任務結束");
                    endGate.countDown();
                }
            });
            thread.start();
        }
        startGate.countDown(); //  這樣可以保證多個線程是一起開始的
        endGate.await();  // 主線程等待其他線程執行完成
        System.out.println(333);
        
    }
}

Thread-0任務開始
Thread-1任務開始
Thread-2任務開始
Thread-1任務結束
Thread-0任務結束
Thread-2任務結束
333

FutureTask
也可以用作閉鎖,實現了Callable接口,相對於runable接口他可以有返回結果,而runable是沒有返回結果的,另外future還有一些狀態。當新建一個future時需要將需要執行的代碼傳入進去,相當於新建一個線程時傳入run方法。而獲取結果通過get方法去獲取。
代碼如下
public class FutureTaskDemo {
    private final FutureTask<String> future = new FutureTask<>(
        new Callable<String>() {
            public String call() throws Exception {
                Thread.sleep(3000);
                return "333";
            };
        }
    );  //  這裏新建了一個future類,這個類中放了call函數,相當於新建線程時傳入的run方法
    private final Thread thread = new Thread(future); // 將future作爲參數傳給一個線程,future既繼承了callable也繼承了runable
    public void start() {
        thread.start();
    }
    public String get() throws InterruptedException, ExecutionException {
        return future.get();
    }
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        System.out.println(new Date());
        FutureTaskDemo demo = new FutureTaskDemo();
        demo.start();  //  啓動線程
        System.out.println(demo.get());  // 獲取結果
        System.out.println(demo.get());  // 獲取結果 一旦計算完了後面就不需要計算了
        System.out.println(demo.get());  // 獲取結果 一個future中存儲一個計算結果
        System.out.println(demo.get());  // 獲取結果 計算完成之前獲取方法是阻塞的
        System.out.println(demo.get());  // 獲取結果 完成之後就用於存儲值
        System.out.println(demo.get());  // 獲取結果 但是代價是不是太大了畢竟一個對象挺大的
        System.out.println(new Date());
    }
}
結果如下
Wed Sep 12 15:30:58 CST 2018
333
333
333
333
333
333
Wed Sep 12 15:31:01 CST 2018

信號量 Semaphore
基於之前學習的兩個閉鎖類,我發現核心方法都是阻塞方法
如CountDownLatch,核心方法是await方法,是阻塞方法,countDown當然也是核心方法
如上面的FutureTask類,核心方法是get方法,也是阻塞方法,用於獲取結果,另外一個核心就是設置內部callable方法
信號量裏的方法也有很多,但是在我看來核心方法就是acquire()(獲取許可,阻塞方法)和release()(釋放許可,但是非阻塞方法)
我覺得信號量的作用就是限定同一時間最多有多少個任務可以併發執行
代碼如下:
public class SemaphoreDemo {
    private Semaphore semaphore = new Semaphore(3);
    public void acquire() throws InterruptedException {
        semaphore.acquire();
    }
    public void release() {
        semaphore.release();
    }
    public int availablePermits() {
        return semaphore.availablePermits();
    }
    public static void main(String[] args) throws InterruptedException {
        SemaphoreDemo demo = new SemaphoreDemo();
        for(;;) {
            Thread thread = new Thread(()->{
                try {
                    synchronized (demo) {
                        demo.acquire();
                        System.out.println(Thread.currentThread().getName()+"獲取到了許可,現在還有"+demo.availablePermits()+"個許可");
                    }
                        Thread.sleep(5000);
                    synchronized (demo) {
                        demo.release();
                        System.out.println(Thread.currentThread().getName()+"釋放了許可,現在還有"+demo.availablePermits()+"個許可");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            thread.start();
            Thread.sleep(2000);
        }
    }
}    
結果如下:由於時間差,所以會經常在獲取請求那裏阻塞,到後期通常只剩餘1個許可
Thread-0獲取到了許可,現在還有2個許可
Thread-1獲取到了許可,現在還有1個許可
Thread-2獲取到了許可,現在還有0個許可
Thread-0釋放了許可,現在還有1個許可
Thread-3獲取到了許可,現在還有0個許可
Thread-1釋放了許可,現在還有1個許可
Thread-4獲取到了許可,現在還有0個許可
Thread-2釋放了許可,現在還有1個許可
Thread-5獲取到了許可,現在還有0個許可
Thread-3釋放了許可,現在還有1個許可
Thread-6獲取到了許可,現在還有0個許可
Thread-4釋放了許可,現在還有1個許可

阻塞方法原理實現 這邊要看留存的疑慮太多
所有的阻塞似乎都和await有關
所有await都和無限循環等待釋放條件有關

任務執行

假設我們要寫一個服務器,服務器本質是socket通信,通過不斷獲取請求socket對IO流進行處理,現在從多線程的角度來分析服務器

串行或並行

每接受到一個客戶端請求,我們可以選擇使用串行的方式,執行完一個再執行下一個。這個顯然效率低下,CPU經常空閒用於等待阻塞方法完成,如IO等。

並行就是每次接受到一個請求都爲他分配一個線程去執行(無限制)。這個有如下問題

需要重複的創建線程銷燬線程,開銷比較高。尤其是只需要執行簡單任務時,創建銷燬線程的代價顯的更大。

資源消耗:假設CPU當前能處理的線程數爲10個,但是爲了處理任務一次性來了100個任務,我們就相應的創建了一百個線程,其中就是個線程會長期處於空閒狀態,佔用過多內存,且爭奪CPU也有性能開銷

穩定性:受服務器配置限制,過多的線程數量可能導致內存溢出。 

Executor框架

Executor是一個基於生產者消費者模式的框架,如果要實現生產者消費者模式,最簡單的方式就是用Executor框架。代碼如下

public class ExecutorDemo {
    private static final int NTHREADS = 100;
    private static final Executor executor = Executors.newFixedThreadPool(NTHREADS);
    @SuppressWarnings("resource")
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(8080);
        for(;;) {
            final Socket connection = socket.accept();
            Runnable task = ()->{
                handleRequest(connection);
            };
            executor.execute(task);
        }
    }
    public static void handleRequest(Socket connection) {
        System.out.println("處理一個請求");
        String string;
        try {
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
            string = bufferedReader.readLine();
            while(string!=null) {
                System.out.println(string);
                string = bufferedReader.readLine();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

請求localhost:8080/123時打印如下

處理一個請求
處理一個請求
GET /smartPlat/hello HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,/;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,zh-TW;q=0.8

要說的是這裏用了接口特性,如果要修改executor執行模式,只需要在域聲明時修改實現類即可,後面說一下接口的兩個作用

1方便使用的開發人員,開發人員在調用接口時一旦需要改其他使用方式只需要修改聲明時的類即可,如獲取一個map,本來對順序不做要求,後續照常處理,突然需求變更要求有序,只需要在對象聲明時將實現類改爲linkedMap即可,這裏executor框架也是一樣,一旦想換成其他執行模式只需要改聲明即可

2提供調用規範,對於使用的開發人員是便利,對於開發的開發人員就是規範,實現規範是爲了告訴上層調用者具體實現方法是這麼實現的,如線程的runable接口,實現就是爲了告訴線程你跑的時候跑這個方法,調度器裏的調度方法就是告訴調度器你時間到了執行我這個方法就可以

框架是生產者消費者模式,他將任務提交和任務執行解耦開,可以很輕鬆的修改任務執行策略。
框架提供了多種實現,甚至開發人員可以自行實現,可以將executor的方法寫爲一個線程一個任務的,或者串行執行的
看到Thread thread = new Thread(Runable)的時候儘量考試能不能使用Executor框架

執行策略
在執行策略(executer方法)中可以定義多種問題解決方案,如
在什麼線程中執行
執行順序是什麼(先進先出,後進先出還是優先級)
最多可以有多少個線程併發執行
如果過載要拒絕任務按照什麼策略去確定拒絕哪個任務
任務執行前後要執行什麼操作
線程池
線程池中的線程不釋放資源,也不需要重複創建資源,每次有任務需要執行的時候分配線程,分配出去的線程不可再用,直到該線程任務執行完畢返回到線程池,有任務到達時再分配出去
好處有:
不需要重複創建銷燬資源
線程池數量根據平臺而定,不會出現線程數量太多而導致的大量線程空閒,爭奪cpu消耗性能等問題
線程池的一些類庫
這些方法都來自類Executors,裏面提供了大量的靜態方法用於返回不同策略的執行器,屬於典型的靜態工廠
newFixedThreadPool:建立一個有固定長度的線程池,每有一個新的任務進來就創建一個線程,直到到達最大數量不再改變。如果有線程拋異常結束,會補充新的線程,這裏是書上的簡介,方法的註釋上有這段話:
If we cannot queue task, then we try to add a new thread.
我認爲這段註釋表示任務添加時會先判斷是否有線程可以去執行,而不是每個新任務都創建線程

newCachedThreadPool:有緩存的線程池,當前線程數量大於任務數時,會有部分線程空閒,執行器會回收這些空閒線程,線程池沒有大小限制
newSingleThreadExecutor:單線程執行器,串行執行
newScheduledThreadPool:創建固定長度線程池以延遲或定時的方式執行

關閉執行器
在jvm中只有當所有非守護線程都關閉後jvm纔會關閉(守護線程會在所有非守護線程關閉後自動關閉),而executor是非守護線程,所以只要executor不關閉那麼jvm用於不關閉。
關閉執行器有兩種方法,一種是平緩關閉一種是暴力關閉,暴力關閉就是不管三七二十一所有的都關了,也不管你是否在執行,不等你執行完直接結束。
平緩關閉就是等待當前所有任務執行完畢再關閉,而且過程中不會接受新的任務。
Executor接口的擴展接口ExecutorService(或者說ExecutorService繼承了接口Executor),規定了關閉方法,如shutDown平緩關閉,shutDownNow暴力關閉,isShutdown是否關閉,isTerminated是否結束可以用於輪詢。
isshutdown是是否開始關閉
isterminated是是否關閉完成
大多數執行器也實現了ExecutorService接口。
第一個版本的服務器是通過while(true)去不斷循環獲取socket請求,改版後的可以通過while(exec.isShutDown)來獲取請求,一旦關閉後就不會再接收新的請求
而如果想關閉服務器可以開發一個stop方法,執行的內容就是exec.shutdown(),可以根據請求信息來判斷是否是關閉請求
Timer和newScheduledThreadPool
Timer執行任務時只創建一個線程,如果創建了兩個任務,一個任務10ms執行一次,一個40ms執行一次,那麼如果限制性的是40ms的,10ms的任務執行就會有問題。而一旦某個任務拋出異常,整個線程都會停止,其他任務也不會執行。
如果構建自己的調度服務可以使用DealyQueue
取消與關閉

任務取消和關閉不可以直接用stop,會導致出現不一致的情況,原來的操作到一半突然截斷。java提供了中斷是一種協作機制,在如果方法願意停下的話就會停下。

任務取消的幾種情況

1、用戶點擊取消按鈕,需要取消之前進行中的任務

2、任務有時間限制,執行時間超時了需要取消

3、應用程序事件取消:本來是搜索多個區域通過多線程去搜索,如果某個區域找了結果,結果只有一個的話,其他線程就需要取消。

4、執行時發生錯誤或者異常:如寫入文件但是磁盤滿了

5、關閉程序

任務取消

一個可取消的任務一定要有取消策略,即需要定義怎麼取消,什麼時候取消,取消什麼。

簡單的取消機制可以通過volatile變量去取消,由於數據是及時可見的,所以一旦數據變更可以立刻通知到線程,通過while()中的判斷條件去取消下一次執行。

但是這種取消有一個缺點,如果某一個方法是阻塞方法,而方法永遠阻塞了,那麼將永遠無法執行到下一次循環判斷條件,也就無法取消任務。

如某個有界序列中的put方法。大小已經塞滿,此時取消,某個線程執行到put的時候由於阻塞了,永遠無法繼續執行到下一個循環,也就永遠無法取消

使用中斷可以解決,中斷也是取消的最好的方式

中斷

中斷是線程的一種狀態,他有兩個查詢方法,一個是實例方法isInterrupted,一個是類方法interruped

實例方法是獲取調用實例是否是中斷的,而類方法是獲取當前線程的中斷狀態且清除中斷狀態,兩個方法源碼如下:

public boolean isInterrupted() {
        return isInterrupted(false);   //實例方法
}

public static boolean interrupted() {
        return currentThread().isInterrupted(true);  // 類方法
}

private native boolean isInterrupted(boolean ClearInterrupted);  // 兩個方法共同調用的是本地方法,布爾值是是否清楚狀態

Thread.sleep和Object.wait方法都是阻塞的,在響應中斷的時候會清除中斷狀態,拋出InterruptException

恢復中斷狀態

目前理解就是通過實例方法去中斷,是爲了防止中斷後調用的是清除狀態的中斷使中斷狀態丟失。

如果不知道線程的內部執行整個過程不要去中斷線程。

恢復中斷的兩種方式是傳遞異常和恢復中斷狀態。傳遞異常即繼續向上層拋出去,

一定不能屏蔽異常(即在catch代碼體內是空的)

服務與線程

同南昌相互之間關係是服務包含線程,如線程池是一個服務,他包含其中管理的線程。

服務的生命週期一般比自己管理的線程生命週期長。

服務或應用程序不應該對自己沒有擁有權的線程做操作,如線程池包含十個線程,定時器任務包含另外五個線程,那麼線程池沒有對定時器擁有的五個線程做操作的權限。

雖然線程池是應用程序(如某個服務器)的一部分,但是應用程序不能繞過線程池對線程池管理的線程直接操作。

所以一般服務要對外提供對應的生命週期方法,如關閉服務(關閉其包含的所有線程)

日誌線程

日誌線程可以使用生產者消費模式,一端生產日誌後不直接寫入,而是存儲在隊列由日誌線程去讀取在寫入。

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