聊聊併發(一)——初始JUC

L發表於2021-09-30

一、volatile

1、介紹

  JDK 5.0 提供了java.util.concurrent包,在此包中增加了併發程式設計中很常用的使用工具類,用於定義類似於執行緒的自定義子系統,包括執行緒池、非同步IO和輕量級任務框架。提供可調的、靈活的執行緒池。還提供了設計用於多執行緒上下文的Collection實現等。

2、記憶體可見性

  記憶體可見性是指當某個執行緒正在使用物件狀態而另一執行緒在同時修改該狀態,需要確保當一個執行緒修改了物件狀態後,其他執行緒能夠看到發生的狀態變化。
  可見性錯誤是指當讀操作與寫操作在不同的執行緒中執行時,我們無法確保執行讀操作的執行緒能實時的看到其他執行緒寫入之後的值,有時甚至是根本不可能的事情。
  我們可以通過同步來保證物件被安全的釋出。除此之外我們也可以使用一種更加輕量級的volatile變數。
  記憶體可見性問題:當多個執行緒同時操作共享資料時,對共享資料的操作彼此是不可見的。
  程式碼示例:記憶體可見性問題

 1 public class VolatileDemo implements Runnable {
 2 
 3     private boolean flag = false;
 4 
 5     @Override
 6     public void run() {
 7 
 8         try {
 9             Thread.sleep(200);
10         } catch (InterruptedException e) {
11         }
12 
13         flag = true;
14         System.out.println("flag = " + isFlag());
15     }
16 
17     public boolean isFlag() {
18         return flag;
19     }
20 
21     public void setFlag(boolean flag) {
22         this.flag = flag;
23     }
24 
25 }
 1 // 測試類
 2 public class Main {
 3     public static void main(String[] args) {
 4         VolatileDemo demo = new VolatileDemo();
 5         new Thread(demo).start();
 6 
 7         while (true) {
 8             if (demo.isFlag()) {
 9                 System.out.println("this is main");
10                 break;
11             }
12         }
13     }
14 }
15 
16 // 結果1
17 flag = true
18 this is main
19 // 程式結束
20 
21 // 結果2
22 flag = true
23 // 程式死迴圈

  問題:結果1不難理解,當執行緒執行完畢後,主執行緒才開始執行while。為什麼結果2是死迴圈呢?
  原因:JVM為每一個執行任務的執行緒,它都會分配一個獨立的工作記憶體用於提高效率。每次都會從主存中讀取變數的副本到各自的工作記憶體中,修改後,再寫回主存中。
  那麼,不難理解結果2:主執行緒從主存讀取flag = false,因為用的while迴圈,while屬於底層的東西,執行速度非常快,沒有再讀主存的機會,一直讀取的是自己的工作記憶體(flag = false)。而當執行緒1讀到flag並修改為true,回寫到主存時,主執行緒並不知道,所以死迴圈。

  解決:知道問題原因了,如何解決呢?
  程式碼示例:方式一、加鎖

 1 // 方式一
 2 public class Main {
 3     public static void main(String[] args) {
 4         VolatileDemo demo = new VolatileDemo();
 5         new Thread(demo).start();
 6 
 7         while (true) {
 8             synchronized (demo) {
 9                 if (demo.isFlag()) {
10                     System.out.println("this is main");
11                     break;
12                 }
13             }
14         }
15     }
16 }

  分析:synchronize加鎖可以解決。加了鎖,就可以讓while迴圈每次都從主存中去讀取資料,這樣就能讀取到true了。但是加鎖效率極低。每次只能有一個執行緒訪問,當一個執行緒持有鎖時,其他執行緒就會阻塞,效率就非常低了。不想加鎖,又要解決記憶體可見性問題,那麼就可以使用volatile關鍵字。
  程式碼示例:方式二、用volatile修飾

1 private volatile boolean flag = false;

3、volatile關鍵字

  Java提供了一種稍弱的同步機制——volatile關鍵字,當多個執行緒訪問共享資料時,可以保證記憶體可見性,即記憶體中的資料可見。用這個關鍵字修飾共享資料,就會及時的把執行緒工作記憶體中的資料重新整理到主存中去,也可以理解為,就是直接操作主存中的資料。
  可以將volatile看做一個輕量級的鎖,相較於synchronized是一種輕量級的同步策略。與鎖(synchronize)的區別:
  volatile不具備互斥性。即一個執行緒訪問共享資料,另一個執行緒依然可以訪問。所有的訪問都在主存中完成,保證記憶體可見性。
  synchronized具備互斥性。即一個執行緒搶到鎖,另一個執行緒進不來,必須等待。
  volatile不能保證變數的原子性。

二、i++問題

1、原子性

  所謂原子性就是一組操作不可再細分。要麼全都做,要麼全都不做。前面提到volatile不能保證變數的原子性,具體表現如下:
  程式碼示例:原子性問題

 1 public class AtomicDemo implements Runnable {
 2 
 3     // 此時是加了volatile語義的
 4     private volatile int i = 0;
 5 
 6     @Override
 7     public void run() {
 8 
 9         try {
10             Thread.sleep(1000);
11         } catch (InterruptedException e) {
12         }
13 
14         System.out.println(getI());
15     }
16 
17     public int getI() {
18         return i++;
19     }
20 
21 }
 1 // 測試類
 2 public class Main {
 3     public static void main(String[] args) {
 4         AtomicDemo atomicDemo = new AtomicDemo();
 5 
 6         // 開啟 10 個執行緒對共享資料進行自增後列印。
 7         for (int i = 0; i < 10; i++) {
 8             new Thread(atomicDemo).start();
 9         }
10     }
11 }
12 
13 // 可能的一種結果
14 0
15 5
16 4
17 3
18 2
19 1
20 0
21 6
22 6
23 7

  問題:期望結果應該每個執行緒對 i 自增一次,最終 i 的值為10。實際結果如上(有重複資料)。
  原因:i++操作不是一個原子性操作,實際分為讀改寫三步,如下:

  int temp = i; // 從主存中讀
  i = i + 1; // cpu 對 i 進行+1運算
  i = temp; // 寫回主存

  而volatile不能保證變數的原子性。volatile,只是相當於所有執行緒都是在主存中運算元據而已,並不具備互斥性。比如兩個執行緒同時讀取主存中的0,然後又同時自增,同時寫入主存,結果還是會出現重複資料。volatile的不具備互斥性也導致了它不具備原子性。
  解決:知道問題原因了,如何解決呢?
  程式碼示例:方式一、加鎖

1 public synchronized int getI() {
2     return i++;
3 }

  程式碼示例:方式二、原子變數

1 private AtomicInteger i = new AtomicInteger();
2 public int getI() {
3     return i.getAndIncrement();
4 }

2、原子變數

  JDK 1.5 以後java.util.concurrent.atomic包下提供了常用的原子變數。這些原子變數具備以下特點:volatile的記憶體可見性;CAS演算法保證資料的原子性。
  類的小工具包,支援在單個變數上解除鎖的執行緒安全程式設計。事實上,此包中的類可將volatile值、欄位和陣列元素的概念擴充套件到那些也提供原子條件更新操作的類。
  類AtomicBoolean、AtomicInteger、AtomicLong和AtomicReference的例項各自提供對相應型別單個變數的訪問和更新。每個類也為該型別提供適當的實用工具方法。
  AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray類進一步擴充套件了原子操作,對這些型別的陣列提供了支援。這些類在為其陣列元素提供volatile訪問語義方面也引人注目,這對於普通陣列來說是不受支援的。
  核心方法:boolean compareAndSet(int expectedValue, int updateValue)
  java.util.concurrent.atomic包下提供了一些原子操作的常用類:

  AtomicBoolean、AtomicInteger、AtomicLong、AtomicReference<V>
  AtomicIntegerArray、AtomicLongArray
  AtomicMarkableReference<V>
  AtomicReferenceArray<E>
  AtomicStampedReference<V>

3、CAS演算法

  CAS(Compare and Swap)是一種硬體對併發的支援,針對多處理器操作而設計的處理器中的一種特殊指令,用於管理對共享資料的併發訪問,是硬體對於併發操作共享資料的支援。
  CAS是一種無鎖的非阻塞演算法的實現。不存在上下文切換的問題。
  CAS包含了3個運算元:記憶體值V,比較值A,更新值B。當且僅當V == A時,V = B,否則不執行任何操作。
  CAS演算法:當多個執行緒併發的對主存中的資料進行修改的時候。有且只有一個執行緒會成功,其他的都會失敗。(同時操作,只是會失敗而已,並不會被鎖之類的)。
  CAS比普通同步鎖效率高,原因:CAS演算法當這一次不成功的時候,它下一次不會阻塞,也就是它不會放棄CPU的執行權,它可以立即再次嘗試,再去更新。
  程式碼示例:模擬CAS演算法

 1 // 模擬CAS演算法
 2 public class CompareAndSwap {
 3     private int value;
 4 
 5     // 獲取記憶體值
 6     public synchronized int get() {
 7         return value;
 8     }
 9 
10     // 比較
11     public synchronized int compareAndSwap(int expectedValue, int newValue) {
12         int oldValue = value;
13 
14         if (oldValue == expectedValue) {
15             this.value = newValue;
16         }
17 
18         return oldValue;
19     }
20 
21     // 設定
22     public synchronized boolean compareAndSet(int expectedValue, int newValue) {
23         return expectedValue == compareAndSwap(expectedValue, newValue);
24     }
25 }
 1 // 測試類
 2 public class Main {
 3     public static void main(String[] args) {
 4         final CompareAndSwap cas = new CompareAndSwap();
 5 
 6         for (int i = 0; i < 10; i++) {
 7             new Thread(() -> {
 8 
 9                 int expectedValue = cas.get();
10                 boolean b = cas.compareAndSet(expectedValue, (int) (Math.random() * 101));
11 
12                 System.out.println(b);
13 
14             }).start();
15         }
16  
17     }
18 }

三、鎖分段機制

1、concurrentHashMap

  JDK 1.5之後,在java.util.concurrent包中提供了多種併發容器類來改進同步容器類的效能。其中最主要的就是ConcurrentHashMap,採用"鎖分段"機制。
  HashMap是執行緒不安全的;Hashtable 加了鎖,是執行緒安全的,因此它效率低。Hashtable 加鎖就是將整個hash表鎖起來,當有多個執行緒訪問時,同一時間只能有一個執行緒訪問,並行變成序列,因此效率低。
  ConcurrentHashMap是一個執行緒安全的hash表。對於多執行緒的操作,介於 HashMap 與 Hashtable 之間。內部採用"鎖分段"機制替代 Hashtable 的獨佔鎖,進而提高效能。

  每個段都是一個獨立的鎖。JDK 1.8 以後concurrentHashMap的鎖分段被取消了。採用的是CAS演算法。
  此包還提供了設計用於多執行緒上下文中的 Collection 實現:

  ConcurrentHashMap
  ConcurrentSkipListMap
  ConcurrentSkipListSet
  CopyOnWriteArrayList
  CopyOnWriteArraySet

  當期望多執行緒訪問一個給定 collection 時,ConcurrentHashMap 通常優於同步的 HashMap,ConcurrentSkipListMap 通常優於同步的 TreeMap。當期望的讀數和遍歷遠遠大於列表的更新數時,CopyOnWriteArrayList 優於同步的 ArrayList。

2、CopyOnWriteArrayList

  程式碼示例:CopyOnWriteArrayList

 1 // 不寫註釋也能看懂的程式碼
 2 public class CopyOnWriteArrayListDemo implements Runnable {
 3 
 4     private static final List<String> list = Collections.synchronizedList(new ArrayList<>());
 5     //private static final CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
 6 
 7     static {
 8         list.add("AA");
 9         list.add("BB");
10         list.add("CC");
11     }
12 
13     @Override
14     public void run() {
15         Iterator<String> it = list.iterator();
16         while (it.hasNext()) {
17             System.out.println(it.next());
18 
19             list.add("AA");
20         }
21     }
22 }
 1 // 測試類
 2 public class Main {
 3     public static void main(String[] args) {
 4         CopyOnWriteArrayListDemo ht = new CopyOnWriteArrayListDemo();
 5 
 6         new Thread(ht).start();
 7     }
 8 }
 9 
10 // 結果(有併發修改異常)
11 AA
12 Exception in thread "Thread-0" java.util.ConcurrentModificationException

  如果用CopyOnWriteArrayList,則不會有異常。
  CopyOnWriteArrayList:寫入並複製,新增操作多時,效率低,因為每次新增時都會進行復制,開銷非常的大。併發迭代操作多時可以選擇。

四、CountDownLatch(閉鎖)

1、介紹

  java.util.concurrent包中提供了多種併發容器類來改進同步容器的效能。CountDownLatch是一個同步輔助類,在完成某些運算時,只有其他所有執行緒的運算全部完成,當前運算才繼續執行,這就叫閉鎖。

2、案例

  程式碼示例:計算10個執行緒列印偶數的時間

 1 // 不寫註釋也能看懂的程式碼
 2 public class CountDownLatchDemo implements Runnable {
 3 
 4     private final CountDownLatch latch;
 5 
 6     public CountDownLatchDemo(CountDownLatch latch) {
 7         this.latch = latch;
 8     }
 9 
10     @Override
11     public void run() {
12         try {
13             for (int i = 0; i < 50000; i++) {
14                 if (i % 2 == 0) {
15                     System.out.println(i);
16                 }
17             }
18         } finally {
19             // 完成一個執行緒,計數 -1
20             latch.countDown();
21         }
22     }
23 
24 }
 1 // 測試類
 2 public class Main {
 3 
 4     public static void main(String[] args) {
 5         final CountDownLatch latch = new CountDownLatch(10);
 6         CountDownLatchDemo ld = new CountDownLatchDemo(latch);
 7 
 8         long start = System.currentTimeMillis();
 9 
10         for (int i = 0; i < 10; i++) {
11             new Thread(ld).start();
12         }
13 
14         try {
15             // 等待10個執行緒都執行完
16             latch.await();
17         } catch (InterruptedException e) {
18         }
19 
20         long end = System.currentTimeMillis();
21 
22         System.out.println("耗費時間為:" + (end - start));
23     }
24 
25 }

五、Callable

  Callable和Runable的區別是,Callable帶泛型,其call方法有返回值。使用的時候,需要用FutureTask來接收返回值。而且它也要等到執行緒執行完呼叫get方法才會執行,也可以用於閉鎖操作。
  程式碼示例:

 1 // 不寫註釋也能看懂的程式碼
 2 public class CallableDemo implements Callable<Integer> {
 3 
 4     @Override
 5     public Integer call() throws Exception {
 6         int sum = 0;
 7 
 8         for (int i = 0; i <= 100; i++) {
 9             sum += i;
10         }
11 
12         return sum;
13     }
14 }
 1 // 測試類
 2 public class Main {
 3     public static void main(String[] args) {
 4         CallableDemo demo = new CallableDemo();
 5 
 6         //執行 Callable 方式,需要 FutureTask 實現類的支援,用於接收運算結果。
 7         FutureTask<Integer> result = new FutureTask<>(demo);
 8         new Thread(result).start();
 9 
10         try {
11             // get()方法是阻塞的
12             Integer sum = result.get();
13             System.out.println(sum);
14 
15             System.out.println("--------表明 get()方法是阻塞的-------------");
16         } catch (InterruptedException | ExecutionException e) {
17             e.printStackTrace();
18         }
19     }
20 }

六、Lock同步鎖

  在JDK1.5之前,解決多執行緒安全問題用sychronized隱式鎖:同步程式碼塊;同步方法。
  在JDK1.5之後,出現了更加靈活的方式Lock顯式鎖:同步鎖。
  Lock需要通過lock()方法上鎖,通過unlock()方法釋放鎖。為了保證鎖能釋放,所有unlock方法一般放在finally中去執行。
  程式碼示例:賣票問題

 1 // 不寫註釋也能看懂的程式碼
 2 public class LockDemo implements Runnable {
 3 
 4     private int tick = 100;
 5 
 6     private final Lock lock = new ReentrantLock();
 7 
 8     @Override
 9     public void run() {
10         while (true) {
11             //上鎖
12             lock.lock();
13 
14             try {
15                 if (tick > 0) {
16 
17                     try {
18                         Thread.sleep(200);
19                     } catch (InterruptedException e) {
20                     }
21 
22                     System.out.println(Thread.currentThread().getName() + " 完成售票,餘票為:" + --tick);
23                 } else {
24                     break;
25                 }
26             } finally {
27                 //釋放鎖
28                 lock.unlock();
29             }
30         }
31     }
32 }
 1 // 測試類
 2 public class Main {
 3     public static void main(String[] args) {
 4         LockDemo ticket = new LockDemo();
 5 
 6         // 開了3個視窗賣票
 7         new Thread(ticket, "1號視窗").start();
 8         new Thread(ticket, "2號視窗").start();
 9         new Thread(ticket, "3號視窗").start();
10     }
11 }
12 
13 // 可能的結果.這裡只出最後10張票的結果值
14 2號視窗 完成售票,餘票為:9
15 2號視窗 完成售票,餘票為:8
16 3號視窗 完成售票,餘票為:7
17 3號視窗 完成售票,餘票為:6
18 3號視窗 完成售票,餘票為:5
19 3號視窗 完成售票,餘票為:4
20 3號視窗 完成售票,餘票為:3
21 3號視窗 完成售票,餘票為:2
22 1號視窗 完成售票,餘票為:1
23 2號視窗 完成售票,餘票為:0

七、ReadWriterLock讀寫鎖

  多個執行緒併發讀資料,是不會出現問題。但是,多個執行緒併發寫資料,到底是寫入哪個執行緒的資料呢?所以,寫寫/讀寫需要互斥,讀讀不需要互斥。這個時候可以用讀寫鎖來提高效率。
  ReadWriteLock 維護了一對相關的鎖,一個用於只讀操作,另一個用於寫入操作。只要沒有 writer,讀取鎖可以由多個 reader 執行緒同時保持。
  讀鎖,可以多個執行緒併發的持有。
  寫鎖,是獨佔的。
  原始碼示例:讀寫鎖

1 public interface ReadWriteLock {
2     // 返回一個讀鎖
3     Lock readLock();
4 
5     // 返回一個寫鎖
6     Lock writeLock();
7 }

  程式碼示例:

 1 public class ReadWriteLockDemo {
 2 
 3     private int number = 0;
 4 
 5     private final ReadWriteLock lock = new ReentrantReadWriteLock();
 6 
 7     // 讀.可以多個執行緒併發讀
 8     public void read() {
 9         // 上讀鎖
10         lock.readLock().lock();
11 
12         try {
13             System.out.println(Thread.currentThread().getName() + " : " + number);
14         } finally {
15             // 釋放讀鎖
16             lock.readLock().unlock();
17         }
18     }
19 
20     // 寫.一次只能有一個執行緒操作
21     public void write(int number) {
22         // 上寫鎖
23         lock.writeLock().lock();
24 
25         try {
26             System.out.println(Thread.currentThread().getName());
27             this.number = number;
28         } finally {
29             // 釋放寫鎖
30             lock.writeLock().unlock();
31         }
32     }
33 }
 1 // 測試類
 2 public class Main {
 3     public static void main(String[] args) {
 4         ReadWriteLockDemo rw = new ReadWriteLockDemo();
 5 
 6         // 開啟 1 個執行緒寫
 7         new Thread(new Runnable() {
 8             @Override
 9             public void run() {
10                 rw.write((int) (Math.random() * 101));
11             }
12         }, "Write:").start();
13 
14         // 開啟 100 個執行緒讀
15         for (int i = 0; i < 100; i++) {
16             new Thread(new Runnable() {
17 
18                 @Override
19                 public void run() {
20                     rw.read();
21                 }
22             }).start();
23         }
24     }
25 }

相關文章