學妹問我,并發問題的根源到底是什么?
并發編程是 java 高級程序員的必備的基礎技能之一。但是想要寫好并發程序并非易事。
那究竟是什么原因導致大把的“格子衫”朋友無法寫出優質和性能穩定的并發程序呢?根本原因就是大家對并發編程的核心理論的模糊和不理解。想要運用好一項技術。理論知識和核心概念是一定要理解透徹的。
今天我們就來一起看下并發編程三大核心基礎理論:原子性、可見性、有序性
1、原子性
先來看下什么叫原子性
第一種理解:原子(atomic)本意是“不能被進一步分割的最小粒子”,而原子操作(atomic operation)意 為“不可被中斷的一個或一系列操作”
第二種理解:原子性,即一個操作或多個操作,要么全部執行并且在執行的過程中不被打斷,要么全部不執行。(提供了互斥訪問,在同一時刻只有一個線程進行訪問)
原子,在物理學中定義是組成物體的不可分割的最小的單位。在 java 并發編程中我們可以將其理解為:一組要么成功要么失敗的操作。
1.1、原子性問題的產生的原因
原子性問題產生的根本原因是什么?我們只要知道了癥狀才能準確的對癥下藥,本小節,我們就來一起探討下原子性問題的由來。
我們都知道,程序在執行的時候,一定是以線程為單位在執行的,因為線程是 CPU 進行任務調度的基本單位。
電腦的 CPU 會根據不同的任務調度算法去執行線程的調度,將時間分片并派分給各個線程。
當某個線程獲得CPU的時間片之后就獲取了CPU的執行權,就可以執行任務,當時間片耗盡之后,就會失去CPU使用權。
進而本任務會暫時的停止執行。多線程場景下,由于時間片在線程間輪換,就會發生原子性問題。
看完理論似乎并不能直觀的理解原子性問題。下面我們就通過代碼的方式來具體闡述下原子性問題的產生原因。
1.2、案例分析
我們以常見的 i++ 為例,這是一個老生常談的原子性問題了,先來看下代碼
- public class AtomicDemo {
- private int count = 0;
- public void add() {
- count++;
- }
- public int get() {
- return count;
- }
- public static void main(String[] args) throws InterruptedException {
- CountDownLatch countDownLatch = new CountDownLatch(100);
- AtomicDemo atomicDemo = new AtomicDemo();
- IntStream.rangeClosed(0, 100).forEach(item -> {
- new Thread(() -> {
- IntStream.rangeClosed(1, 100).forEach(i -> {
- atomicDemo.add();
- });
- }).start();
- countDownLatch.countDown();
- });
- countDownLatch.await();
- System.out.println(atomicDemo.get());
- }
- }
上面 代碼的作用是將初始值為0的 count 變量,通過100線程每個線程累加100次的方式來累加。想要得到一個結果為 10000 的值。但是實際上結果很難達到10000。
產生這個問題的原因:
count++ 的執行實際上這個操作不是原子性的,因為 count++ 會被拆分成以下三個步驟執行(這樣的步驟不是虛擬的,而是真實情況就是這么執行的)
第一步:讀取 count 的值;
第二步:計算 +1 的結果;
第三步:將 +1 的結果賦值給 count變量
那問題又來了。分三步又咋樣?讓他執行完不就行了?
理論上是這樣子的,大家都很友好,你執行完我執行,我執行完你繼續。你想象的可能是這樣的”烏托邦圖“
image-20210430131612018
但是實際上這些線程已經”黑化”了。他們絕不可能互相謙讓。CPU或者是程序的世界觀里面。大家做任何事情都是在”爭搶“。我們來看下面這張圖:
上圖詳細分析:
第一步:A線程從主內存中讀取 count 的值 0;
第二步:A線程開始對 count 值進行累加;
第三步:B線程從主內存中讀取 count 的值 0(PS:具體第三步從哪里開始都不是重點,重點是:A線程將 count 值寫入主內存之前 B 線程就開始讀取 count 并執行。此時 B線程 讀取到的 count 值依舊是還未被操作過的原始值);
第四步:(PS:到這里其實已經不重要了。因為不管 A線程和B線程現在怎么操作。結果已經不可逆轉,已經錯了)B線程開始對 count 值進行累加;
第五步:A 線程將累加后的結果賦值給 count 結果為 1;
第六步:B 線程將累加后的結果賦值給 count 結果為 1;
第七步:A 線程將結果 count =1 刷回到主內存;
第八步:B 線程將結果 count =1 刷回到主內存;
相信大家此時已經非常清晰地分析出了原子性產生的根本原因了。
至于解決方案可以通過鎖或者是 CAS 的方式。具體方案就不再這里贅述了。
2、可見性
萬丈高樓平地起,再復雜的技術我們也需要從基本的概念看起來:
可見性:一個線程對共享變量的修改,另外一個線程能夠立刻看到,我們稱為可見性。
2.1、可見性問題產生的原因
在很多年前,那個嫁妝只需要一個手電筒的年代你或許還不會出現可見性這樣的問題,因為大家都是單核處理器,不存在并發的情況。
而對于現在“視金錢如糞土”的年代。多核處理器已經是現代超級計算機的基礎硬件。高速的CPU處理器和緩慢的內存之前數據的通信成了矛盾。
所以為了解決和緩和這樣的情況,每個CPU和線程都有自己的本地緩存,所謂本地緩存即該緩存僅僅對它所在的處理器可見,CPU緩存與內存的數據不容易保證一致。
為了避免這種因為寫數據速度不一致而導致 CPU 的性能浪費的情況,處理器通過使用寫緩沖區來臨時保存待寫入主內存的數據。寫緩沖區合并對同一內存地址的多次寫,并以批處理的方式刷新,也就是說寫緩沖區不會立即將數據刷新到主內存中。
緩存不能及時刷新到主內存就是導致可見性問題產生的根本原因。
2.2、案例分析
- public class AtomicDemo {
- private int count = 0;
- public void add() {
- count++;
- }
- public int get() {
- return count;
- }
- public static void main(String[] args) throws InterruptedException {
- CountDownLatch countDownLatch = new CountDownLatch(100);
- AtomicDemo atomicDemo = new AtomicDemo();
- IntStream.rangeClosed(0, 100).forEach(item -> {
- new Thread(() -> {
- IntStream.rangeClosed(1, 100).forEach(i -> {
- atomicDemo.add();
- });
- }).start();
- countDownLatch.countDown();
- });
- countDownLatch.await();
- System.out.println(atomicDemo.get());
- }
- }
“what * *”,怎么和上面代碼一樣。。。結果就不截圖了,必然不是10000。
我們來看下執行的流程圖(PS:不要糾結于為什么和上面的不一樣,特定問題特定分析。在闡述一種問題的時候,一定會在某些層面上屏蔽另外一種問題的干擾)
假設 A 線程和 B 線程同時開始執行,首先 A 線程和 B 線程會將主內存中的 count 的值加載/緩存到自己的本地內存中。然后會讀取各自的內存中的值去執行操作,也就是說此時 A 線程和 B 線程就好像是兩個世界的人,彼此不會產生任何關聯。
操作完之后 A 線程將結果寫回到自己的本地內存中,同樣 B 線程將結果寫回到自己的本地內存中。然后回來某個時機各自將結果刷回到主內存。那最終必然是一方的數據被另一方覆蓋。這就是緩存的可見性問題。
3、有序性
不積跬步無以至千里,我們還是先來看概念
有序性:程序執行的順序按照代碼的先后順序執行。
這有啥的,程序老老實實按照程序員寫的代碼執行就完事了,這還會有什么問題嗎?
3.1、有序性問題產生的原因
實際上編譯器為了提高程序執行的性能。會改變我們代碼的執行順序的。即你寫在前面的代碼不一定是先被執行完的。
例如:int a = 1;int b =4;從表面和常規角度來看,程序的執行應該是先初始化 a ,然后初始化 b 。但是實際上非常有可能是先初始化 b,然后初始化 a。因為在編譯器看了來,先初始化誰對這兩個變量不會有任何影響。即這兩個變量之間沒有任何的數據依賴。
指令重排序有三種類型,分別為:
① 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
② 指令級并行的重排序?,F代處理器采用了指令級并行技術(Instruction-Level Parallelism,ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應 機器指令的執行順序。
③ 內存系統的重排序。由于處理器使用緩存和讀/寫緩沖區,這使得加載和存儲操作看上 去可能是在亂序執行。
3.2、案例分析
有序性的案例最常見的就是 DCL了(double check lock)就是單例模式中的雙重檢查鎖功能。先來看下代碼
- public class SingletonDclDemo {
- private SingletonDclDemo(){}
- private static SingletonDclDemo instance;
- public static SingletonDclDemo getInstance(){
- if (Objects.isNull(instance)) {
- synchronized (SingletonDclDemo.class) {
- if (Objects.isNull(instance)) {
- instance = new SingletonDclDemo();
- }
- }
- }
- return instance;
- }
- public static void main(String[] args) {
- IntStream.rangeClosed(0,100).forEach(item->{
- new Thread(SingletonDclDemo::getInstance).start();
- });
- }
- }
這個代碼還是比較簡單的。
在獲取對象實例的方法中,程序首先判斷 instance 對象是否為空,如果為空,則鎖定SingletonDclDemo.class 并再次檢查instance是否為空,如果還為空則創建 Singleton的一個實例??此坪芡昝?,既保證了線程完全的初始化單例,又經過判斷 instance 為 null 時再用 synchronized 同步加鎖。但是還有問題!
instance = new SingletonDclDemo(); 創建對象的代碼,分為三步:① 分配內存空間;② 初始化對象SingletonDclDemo;③ 將內存空間的地址賦值給instance;
但是這三步經過重排之后:① 分配內存空間 ② 將內存空間的地址賦值給instance ③ 初始化對象SingletonDclDemo
會導致什么結果呢?
線程 A 先執行 getInstance() 方法,當執行完指令②時恰好發生了線程切換,切換到了線程B上;如果此時線程B也執行 getInstance() 方法,那么線程B在執行第一個判斷時會發現instance!=null,所以直接返回instance,而此時的instance是沒有初始化過的,如果我們這個時候訪問instance的成員變量就可能觸發空指針異常。
繼續來張圖來更直觀的理解下:
具體的執行流程在上面已經分析了。相信這張圖片一定能讓你徹底理解。
4、本文小結
并發編程的學習和使用并非一朝一夕的事情,也并非會幾個理論就能寫好優質的并發程序。這需要長時間的實踐和總結。好的代碼很少是寫出來的,都是迭代和優化的。