併發編程學習筆記

併發編程學習

[TOC]java

併發理論基礎

可見性、原子性、有序性問題。併發編程BUG源頭

可見性

一個線程對共享變量的修改,另一個線程可以馬上看到,咱們稱爲可見性。算法

在單核的時代,不會出現問題。編程

clipboard.png

多核時代,就會出現問題了。緩存

線程 A 操做的是 CPU-1 上的緩存,而線程 B 操做的是CPU-2上的緩存。線程A對變量V的操做對線程B不具有可見性了。這個就屬於硬件程序猿給軟件程序猿挖的坑。安全

clipboard.png

如下代碼calc獲得的結果不會是20000。驗證了多核場景下的可見性問題。數據結構

public class Test {
  private long count = 0;
  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
  public static long calc() {
    final Test test = new Test();
    // 建立兩個線程,執行 add() 操做
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 啓動兩個線程
    th1.start();
    th2.start();
    // 等待兩個線程執行結束
    th1.join();
    th2.join();
    return count;
  }
}

原子性

咱們把一個或者多個操做在 CPU 執行的過程當中不被中斷的特性稱爲原子性併發

count+1,這個簡單的指令至少須要三條CPU指令。app

  1. 須要將變量count從內存加載到CPU寄存器中
  2. 在寄存器中執行+1操做
  3. 將結果寫入內存(緩存的機制致使可能寫入的是CPU緩存而不是內存)

操做系統在任務切換,是能夠發生在任何一條CPU指令中間的,而不是在高級語言中的一句語句。函數

以下圖,線程A和線程B從CPU寄存器中讀出來的值都是0,並都是將1寫入到內存中。因此不會是咱們期待的2。性能

clipboard.png

有序性

編譯器爲了優化性能,有時候會改變程序中語句的前後順序。

一個經典的案例,案例的雙重檢查鎖。

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

當兩個線程同時執行這段代碼時,其中一個線程是有可能獲取到一個null的對象,訪問instance成員變量就會觸發空指針異常了。

問題出如今new這個動做上

咱們認爲的new動做應該是

  1. 分配一塊內存M
  2. 在內存M上初始化Singleton對象
  3. 而後M的地址賦值給instance變量

實際上通過編譯後的優化,順序變成

  1. 分配一塊M
  2. 將M的地址賦值給instance變量
  3. 最後在內存M上初始化Singleton對象。

clipboard.png

思考

在 32 位的機器上對 long 型變量進行加減操做存在併發。

確實會的,覺得long類型變量佔8位字節,也就是64位的,32位機器須要把變量拆分紅兩個32位操做。官方推薦把long/double變量聲明爲volatile或者使用同步鎖。

Java是如何解決可見性和原子性問題的

Java 內存模型規範了 JVM 如何提供按需禁用緩存和編譯優化的方法。

具體來講,這些方法包括volatile、synchronized、final三個關鍵字。以及六項Happens-before規則。

Happens-before規則

程序的順序性規則

程序前面對某個變量的修改必定是對後續操做可見的。

volatile變量規則

對一個volatile變量的寫操做,Happens-before於後續對這個volatile變量的讀操做。

傳遞性

若是A Happens-before B,且B happens-before C,那麼A Happens-before C。

管程中的鎖規則

指對一個鎖的解鎖Happens before 於後續對這個鎖的加鎖。

也就是說必定要有解鎖動做,才能對這個鎖進行再次加鎖。

synchronized是java對管程的實現。

線程start()規則

它指的是主線程A啓動子線程B後,子線程B可以看到主線程在啓動子線程B前的操做。

Thread B = new Thread(()->{
  // 主線程調用 B.start() 以前
  // 全部對共享變量的修改,此處皆可見
  // 此例中,var==77
});
// 此處對共享變量 var 修改
var = 77;
// 主線程啓動子線程
B.start();
線程join()原則

它指的是主線程A等待子線程B完成,當子線程B完成後,主線程可以看到子線程的操做。

Thread B = new Thread(()->{
  // 此處對共享變量 var 修改
  var = 66;
});
// 例如此處對共享變量修改,
// 則這個修改結果對線程 B 可見
// 主線程啓動子線程
B.start();
B.join()
// 子線程全部對共享變量的修改
// 在主線程調用 B.join() 以後皆可見
// 此例中,var==66
1,2,3規則舉例說明

使用如下例子說明一下順序性、volatile變量規則,傳遞性。

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 這裏 x 會是多少呢?
    }
  }
}

clipboard.png

  1. x=42 Happens-before寫變量 v=true,這屬於順序規則
  2. 寫變量v=true Happens-before 讀變量v=true,這個是volatile變量規則
  3. 那麼根據傳遞性規則,x=42 Happens-before 讀變量v=true。

不可忽視的final

final修飾變量時,初衷是告訴編譯器:這個變量生而不變,可使勁兒優化。

使用final的時候,只要提供正確的構造函數沒有「逸出」,就不會出問題。

一個關於逸出的例子,例子中經過global.obj讀取變量x是有可能讀取到0的。

final int x;
// 錯誤的構造函數
public FinalFieldExample() { 
  x = 3;
  y = 4;
  // 此處就是講 this 逸出,
  global.obj = this;
}

解決原子性問題

簡易鎖模型

clipboard.png

改進後的鎖機制

clipboard.png

synchronized關鍵字使用

class X {
  // 修飾非靜態方法
  synchronized void foo() {
    // 臨界區
  }
  // 修飾靜態方法
  synchronized static void bar() {
    // 臨界區
  }
  // 修飾代碼塊
  Object obj = new Object();
  void baz() {
    synchronized(obj) {
      // 臨界區
    }
  }
}

如何用一把鎖保護多個資源

保護沒有關聯關係的多個資源

不一樣的資源用不一樣的鎖保護,各自管各自的。

用不一樣的鎖對受保護資源進行精細化管理,可以提高系能,這種鎖還有個名字,叫細粒度鎖。

class Account {
  // 鎖:保護帳戶餘額
  private final Object balLock
    = new Object();
  // 帳戶餘額  
  private Integer balance;
  // 鎖:保護帳戶密碼
  private final Object pwLock
    = new Object();
  // 帳戶密碼
  private String password;

  // 取款
  void withdraw(Integer amt) {
    synchronized(balLock) {
      if (this.balance > amt){
        this.balance -= amt;
      }
    }
  } 
  // 查看餘額
  Integer getBalance() {
    synchronized(balLock) {
      return balance;
    }
  }

  // 更改密碼
  void updatePassword(String pw){
    synchronized(pwLock) {
      this.password = pw;
    }
  } 
  // 查看密碼
  String getPassword() {
    synchronized(pwLock) {
      return password;
    }
  }
}

保護有關聯關係的多個資源

若是多個資源是有關聯關係的,那這個問題就比較複雜了。

class Account {
  private int balance;
  // 轉帳
  synchronized void transfer(
      Account target, int amt){
    if (this.balance > amt) {
      this.balance -= amt;
      target.balance += amt;
    }
  } 
}

上面的代碼是錯誤的示例。

由於this這把鎖根本鎖不住target,也就是別人的帳戶。

clipboard.png

會形成什麼問題呢?假設這樣的一個場景,ABC三個帳戶均爲200,一個線程執行A轉帳100給B,一個線程執行B轉帳100給C。最終致使的結果多是B爲300,或者B爲100。而咱們指望的結果B的餘額應該是200纔是正確的。

clipboard.png

那麼其實解決方案也很是簡單,就是把鎖的對象this改爲Account.class

class Account {
  private int balance;
  // 轉帳
  void transfer(Account target, int amt){
    synchronized(Account.class) {
      if (this.balance > amt) {
        this.balance -= amt;
        target.balance += amt;
      }
    }
  } 
}

clipboard.png

死鎖

在上面一章中,咱們使用了Account.class鎖住了轉帳的動做,也就是說每筆轉帳動做,都是串行的動做了,性能降低嚴重,如何提高性能呢?

class Account {
  private int balance;
  // 轉帳
  void transfer(Account target, int amt){
    // 鎖定轉出帳戶
    synchronized(this) {              
      // 鎖定轉入帳戶
      synchronized(target) {           
        if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

clipboard.png

相比較於Account.class這個大鎖,咱們使用了兩個細粒度的鎖。

看起來很完美,可是極可能形成死鎖

死鎖的一個比較專業定義是:一組互相競爭資源的線程因互相等待,致使永久阻塞的現象。

clipboard.png

怎麼解決死鎖的問題

要解決死鎖,就要了解死鎖發生的條件。

Coffman牛人總結了爲四個條件:

1. 互斥,共享資源X和Y只能被一個線程佔用;
2. 佔有且等待,線程T1已經取得共享資源X,在等待共享資源Y的時候,不釋放共享資源X;
3. 不可搶佔,其餘線程不能強行搶佔線程T1佔有的資源;
4. 循環等待,線程T1等待線程T2佔有的資源,線程T2等待線程T1佔有的資源。

也就是說,咱們只要破壞掉其中一個條件,死鎖就迎刃而解了。

其中互斥性沒法破壞,由於咱們使用的就是互斥鎖。

破壞其他三個條件:

1. 破壞佔有等待,能夠一次性申請全部的資源
2. 破快不可搶佔,佔用部分資源的線程進一步申請其餘資源時,若是申請不到,主動釋放它佔有的資源
3. 破壞循環等待,能夠按序申請資源來預防。
破壞佔用且等待
class Allocator {
  private List<Object> als =
    new ArrayList<>();
  // 一次性申請全部資源
  synchronized boolean apply(
    Object from, Object to){
    if(als.contains(from) ||
         als.contains(to)){
      return false;  
    } else {
      als.add(from);
      als.add(to);  
    }
    return true;
  }
  // 歸還資源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
  }
}

class Account {
  // actr 應該爲單例
  private Allocator actr;
  private int balance;
  // 轉帳
  void transfer(Account target, int amt){
    // 一次性申請轉出帳戶和轉入帳戶,直到成功
    while(!actr.apply(this, target))
      ;
    try{
      // 鎖定轉出帳戶
      synchronized(this){              
        // 鎖定轉入帳戶
        synchronized(target){           
          if (this.balance > amt){
            this.balance -= amt;
            target.balance += amt;
          }
        }
      }
    } finally {
      actr.free(this, target)
    }
  } 
}

clipboard.png

破壞不可搶佔條件

使用Lock,synchronzied沒法解決,後續討論。

破壞循環等待條件

增長id屬性,做爲排序屬性,鎖定順序按照從小到大的順序鎖定。

class Account {
  private int id;
  private int balance;
  // 轉帳
  void transfer(Account target, int amt){
    Account left = this        ①
    Account right = target;    ②
    if (this.id > target.id) { ③
      left = target;           ④
      right = this;            ⑤
    }                          ⑥
    // 鎖定序號小的帳戶
    synchronized(left){
      // 鎖定序號大的帳戶
      synchronized(right){ 
        if (this.balance > amt){
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}
選擇合適的方案

既然解決死鎖有三個方法,那麼從三個方法中選擇出一個好的解決方法也顯得相當重要。

好比上面的例子,破壞佔用且等待條件成本就比破壞循環等待的成原本得高。

用 等待--通知 機制優化循環等待

用synchronized實現等待--通知

clipboard.png

class Allocator {
  private List<Object> als;
  // 一次性申請全部資源
  synchronized void apply(
    Object from, Object to){
    // 經典寫法
    while(als.contains(from) ||
         als.contains(to)){
      try{
        wait();
      }catch(Exception e){
      }   
    } 
    als.add(from);
    als.add(to);  
  }
  // 歸還資源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
    notifyAll();
  }
}

安全性、活躍性、性能問題

安全性

存在共享數據而且該數據會發生變化,通俗地說,就是多個線程會同時讀寫同一個數據。

若是多個線程同時寫同一個數據,這種狀況就稱之爲數據競爭。

活躍性

除了死鎖,還有兩種狀況,分別是 活鎖和飢餓。

活鎖,線程雖然沒有發生阻塞,但仍然執行不下去的狀況。比如現實中的禮讓問題。

活鎖解決,加入嘗試等待一個隨機時間。

飢餓,線程因沒法訪問所需資源而沒法執行下去。

飢餓解決,通常使用公平鎖。

性能問題

從方案層面,解決性能問題:

  1. 使用無鎖算法和數據結構,線程本地存儲(Thread Local)、寫入時複製(Copy on write)、樂觀鎖等;java的原子類;無鎖的內存隊列Disruptor
  2. 減小鎖的持有時間,使用細粒度鎖、讀寫鎖

性能的度量指標有不少,通常三個很是重要,吞吐量、延遲、併發量。

管程:併發編程的萬能鑰匙

clipboard.png

public class BlockedQueue<T>{
  final Lock lock =
    new ReentrantLock();
  // 條件變量:隊列不滿  
  final Condition notFull =
    lock.newCondition();
  // 條件變量:隊列不空  
  final Condition notEmpty =
    lock.newCondition();

  // 入隊
  void enq(T x) {
    lock.lock();
    try {
      while (隊列已滿){
        // 等待隊列不滿 
        notFull.await();
      }  
      // 省略入隊操做...
      // 入隊後, 通知可出隊
      notEmpty.signal();
    }finally {
      lock.unlock();
    }
  }
  // 出隊
  void deq(){
    lock.lock();
    try {
      while (隊列已空){
        // 等待隊列不空
        notEmpty.await();
      }
      // 省略出隊操做...
      // 出隊後,通知可入隊
      notFull.signal();
    }finally {
      lock.unlock();
    }  
  }
}

java線程

線程的生命週期

clipboard.png

Java 語言中線程共有六種狀態,分別是:

  1. NEW
  2. RUNNABLE
  3. BLOCKED
  4. WAITING
  5. TIMED_WAITING
  6. TERMINATED

看着比圖中多了幾個狀態,其實java中的BLOCED WAITING TIMED_WAITING都屬於休眠狀態,這個狀態下的線程沒有CPU的使用權。

clipboard.png

建立多少個線程纔是合適的?

最佳線程數 =CPU 核數 * [ 1 +(I/O 耗時 / CPU耗時)

爲何局部變量是安全的?

每一個線程都有本身的調用棧,局部變量保存在線程各自的調用棧裏面,不會共享,因此局部變量不會有併發問題。

clipboard.png

clipboard.png

如何用面向對象思想寫好併發程序

  1. 封裝共享變量
  2. 識別共享變量間的約束條件
  3. 制定併發訪問策略
相關文章
相關標籤/搜索