Java Review - 并发编程_前置知识二
文章目錄
- What's 多線程并發編程
- 線程安全問題
- 共享變量的內存可見性問題
- synchronized
- synchronized的內存語義
- volatile - 解決內存可見性
- 一般在什么時候才使用volatile關鍵字
- 原子性操作
- CAS操作
- ABA --->解決辦法AtomicStampedReference
What’s 多線程并發編程
首先要澄清并發和并行的概念
- 并發是指同一個時間段內多個任務同時都在執行,并且都沒有執行結束
- 并行是說在單位時間內多個任務同時在執行
并發任務強調在一個時間段內同時執行,而一個時間段由多個單位時間累積而成,所以說并發的多個任務在單位時間內不一定同時在執行。
在單CPU的時代多個任務都是并發執行的,這是因為單個CPU同時只能執行一個任務。在單CPU時代多任務是共享一個CPU的,當一個任務占用CPU運行時,其他任務就會被掛起,當占用CPU的任務時間片用完后,會把CPU讓給其他任務來使用,所以在單CPU時代多線程編程是沒有太大意義的,并且線程間頻繁的上下文切換還會帶來額外開銷。
在單個CPU上運行兩個線程,線程A和線程B是輪流使用CPU進行任務處理的,也就是在某個時間內單個CPU只執行一個線程上面的任務。當線程A的時間片用完后會進行線程上下文切換,也就是保存當前線程A的執行上下文,然后切換到線程B來占用CPU運行任務。
雙CPU配置,線程A和線程B各自在自己的CPU上執行任務,實現了真正的并行運行。
而在多線程編程實踐中,線程的個數往往多于CPU的個數,所以一般都稱多線程并發編程而不是多線程并行編程。
多核CPU時代的到來打破了單核CPU對多線程效能的限制。多個CPU意味著每個線程可以使用自己的CPU運行,這減少了線程上下文切換的開銷,但隨著對應用系統性能和吞吐量要求的提高,出現了處理海量數據和請求的要求,這些都對高并發編程有著迫切的需求。
線程安全問題
我們先說說什么是共享資源。所謂共享資源,就是說該資源被多個線程所持有或者說多個線程都可以去訪問該資源。
線程安全問題是指當多個線程同時讀寫一個共享資源并且沒有任何同步措施時,導致出現臟數據或者其他不可預見的結果的問題,如下圖所示。
線程A和線程B可以同時操作主內存中的共享變量,那么線程安全問題和共享資源之間是什么關系呢?
是不是說多個線程共享了資源,當它們都去訪問這個共享資源時就會產生線程安全問題呢?答案是否定的,如果多個線程都只是讀取共享資源,而不去修改,那么就不會存在線程安全問題,只有當至少一個線程修改共享資源時才會存在線程安全問題。
舉個計數器的例子
假如當前count=0
這里先不考慮內存可見性問題,明明是兩次計數,為何最后結果是1而不是2呢?其實這就是共享變量的線程安全問題。
這就需要在線程訪問共享變量時進行適當的同步,在Java中最常見的是使用關鍵字synchronized進行同步
共享變量的內存可見性問題
談到內存可見性,我們首先來看看在多線程下處理共享變量時Java的內存模型
Java內存模型規定,將所有的變量都存放在主內存中,當線程使用變量時,會把主內存里面的變量復制到自己的工作空間或者叫作工作內存,線程讀寫變量時操作的是自己工作內存中的變量。
Java內存模型是一個抽象的概念,那么在實際實現中線程的工作內存是什么呢?如下圖
上中所示是一個雙核CPU系統架構,每個核有自己的控制器和運算器,其中控制器包含一組寄存器和操作控制器,運算器執行算術邏輯運算。每個核都有自己的一級緩存,在有些架構里面還有一個所有CPU都共享的二級緩存。
那么Java內存模型里面的工作內存,就對應這里的L1或者L2緩存或者CPU的寄存器。
當一個線程操作共享變量時,它首先從主內存復制共享變量到自己的工作內存,然后對工作內存里的變量進行處理 ,處理完后將變量值更新到主內存。
那么假如線程A和線程B同時處理一個共享變量,會出現什么情況?我們使用剛才的CPU架構,假設線程A和線程B使用不同CPU執行,并且當前兩級Cache都為空,那么這時候由于Cache的存在,將會導致內存不可見問題,具體看下面的分析。
-
線程A首先獲取共享變量X的值,由于兩級Cache都沒有命中,所以加載主內存中X的值,假如為0。然后把X=0的值緩存到兩級緩存,線程A修改X的值為1,然后將其寫入兩級Cache,并且刷新到主內存。線程A操作完畢后,線程A所在的CPU的兩級Cache內和主內存里面的X的值都是1。
-
線程B獲取X的值,首先一級緩存沒有命中,然后看二級緩存,二級緩存命中了,所以返回X= 1;到這里一切都是正常的,因為這時候主內存中也是X=1。然后線程B修改X的值為2,并將其存放到線程2所在的一級Cache和共享二級Cache中,最后更新主內存中X的值為2;到這里一切都是好的。
-
線程A這次又需要修改X的值,獲取時一級緩存命中,并且X=1,到這里問題就出現了,明明線程B已經把X的值修改為了2,為何線程A獲取的還是1呢?這就是共享變量的內存不可見問題,也就是線程B寫入的值對線程A不可見。
那么如何解決共享變量內存不可見問題?使用Java中的volatile關鍵字就可以解決這個問題.
synchronized
synchronized塊是Java提供的一種原子性內置鎖,Java中的每個對象都可以把它當作一個同步鎖來使用,這些Java內置的使用者看不到的鎖被稱為內部鎖,也叫作監視器鎖。
線程的執行代碼在進入synchronized代碼塊前會自動獲取內部鎖,這時候其他線程訪問該同步代碼塊時會被阻塞掛起。拿到內部鎖的線程會在正常退出同步代碼塊或者拋出異常后或者在同步塊內調用了該內置鎖資源的wait 系列方法時釋放該內置鎖。內置鎖是排它鎖,也就是當一個線程獲取這個鎖后,其他線程必須等待該線程釋放鎖后才能獲取該鎖。
另外,由于Java中的線程是與操作系統的原生線程一一對應的,所以當阻塞一個線程時,需要從用戶態切換到內核態執行阻塞操作,這是很耗時的操作,而synchronized的使用就會導致上下文切換。
synchronized的內存語義
共享變量內存可見性問題主要是由于線程的工作內存導致的,下面我們來看下synchronized的一個內存語義,這個內存語義就可以解決共享變量內存可見性問題
進入synchronized塊的內存語義是把在synchronized塊內使用到的變量從線程的工作內存中清除,這樣在synchronized塊內使用到該變量時就不會從線程的工作內存中獲取,而是直接從主內存中獲取。退出synchronized塊的內存語義是把在synchronized塊內對共享變量的修改刷新到主內存。
其實這也是加鎖和釋放鎖的語義,當獲取鎖后會清空鎖塊內本地內存中將會被用到的共享變量,在使用這些共享變量時從主內存進行加載,在釋放鎖時將本地內存中修改的共享變量刷新到主內存。
除可以解決共享變量內存可見性問題外,synchronized經常被用來實現原子性操作。另外請注意,synchronized關鍵字會引起線程上下文切換并帶來線程調度開銷。
volatile - 解決內存可見性
上面介紹了使用鎖的方式可以解決共享變量內存可見性問題,但是使用鎖太笨重,因為它會帶來線程上下文的切換開銷。
對于解決內存可見性問題,Java還提供了一種弱形式的同步,也就是使用volatile關鍵字。
該關鍵字可以確保對一個變量的更新對其他線程馬上可見。當一個變量被聲明為volatile時,線程在寫入變量時不會把值緩存在寄存器或者其他地方,而是會把值刷新回主內存。當其他線程讀取該共享變量時,會從主內存重新獲取最新值,而不是使用當前線程的工作內存中的值。
volatile的內存語義和synchronized有相似之處,具體來說就是,當線程寫入了volatile變量值時就等價于線程退出synchronized同步塊(把寫入工作內存的變量值同步到主內存),讀取volatile變量值時就相當于進入同步塊(先清空本地內存變量值,再從主內存獲取最新值)。
下面看一個使用volatile關鍵字解決內存可見性問題的例子。如下代碼中的共享變量value是線程不安全的,因為這里沒有使用適當的同步措施
/*** @author 小工匠* @version 1.0* @description: TODO* @date 2021/11/27 10:23* @mark: show me the code , change the world*/ public class ShareVariableTest {private int count ;public int getCount() {return count;}public void setCount(int count) {this.count = count;} }首先來看使用synchronized關鍵字進行同步的方式。
public class ShareVariableTest {private int count ;public synchronized int getCount() {return count;}public synchronized void setCount(int count) {this.count = count;}}然后是使用volatile進行同步。
public class ShareVariableTest {private volatile int count ;public int getCount() {return count;}public void setCount(int count) {this.count = count;}}在這里使用synchronized和使用volatile是等價的,都解決了共享變量value的內存可見性問題,
- 但是synchronized是獨占鎖,同時只能有一個線程調用get()方法,其他調用線程會被阻塞,同時會存在線程上下文切換和線程重新調度的開銷,這也是使用鎖方式不好的地方。
- 而volatile是非阻塞算法,不會造成線程上下文切換的開銷。
但并非在所有情況下使用它們都是等價的,volatile雖然提供了可見性保證,但并不保證操作的原子性。
一般在什么時候才使用volatile關鍵字
-
寫入變量值不依賴變量的當前值時。因為如果依賴當前值,將是獲取—計算—寫入三步操作,這三步操作不是原子性的,而volatile不保證原子性。
-
讀寫變量值時沒有加鎖。因為加鎖本身已經保證了內存可見性,這時候不需要把變量聲明為volatile的。
原子性操作
所謂原子性操作,是指執行一系列操作時,這些操作要么全部執行,要么全部不執行,不存在只執行其中一部分的情況。
舉個例子 在設計計數器時一般都先讀取當前值,然后+1,再更新。這個過程是讀—改—寫的過程,如果不能保證這個過程是原子性的,那么就會出現線程安全問題。如下代碼是線程不安全的,因為不能保證++value是原子性操作。
public class ShareVariableTest {private int count;public int getCount() {return count;}public void add() {count++;} }Javap -c 命令查看匯編代碼
或者直接借助IDEA
由此可見,簡單的++value由2、5、6、7四步組成,
- 其中第2步是獲取當前value的值并放入棧頂,
- 第5步把常量1放入棧頂,
- 第6步把當前棧頂中兩個值相加并把結果放入棧頂,
- 第7步則把棧頂的結果賦給value變量。
因此,Java中簡單的一句++value被轉換為匯編后就不具有原子性了。
那么如何才能保證多個操作的原子性呢?最簡單的方法就是使用synchronized關鍵字進行同步,修改代碼如下
public class ShareVariableTest {private int count;public synchronized int getCount() {return count;}public synchronized void add() {count++;} }使用synchronized關鍵字的確可以實現線程安全性,即內存可見性和原子性,但是synchronized是獨占鎖,沒有獲取內部鎖的線程會被阻塞掉,而這里的getCount方法只是讀操作,多個線程同時調用不會存在線程安全問題。但是加了關鍵字synchronized后,同一時間就只能有一個線程可以調用,這顯然大大降低了并發性。
既然getCount是只讀操作,那為何不去掉getCount方法上的synchronized關鍵字呢?
其實是不能去掉的,別忘了這里要靠synchronized來實現value的內存可見性。
那么有沒有更好的實現呢?答案是肯定的,下面將講到的在內部使用非阻塞CAS算法實現的原子性操作類AtomicInteger就是一個不錯的選擇。
CAS操作
在Java中,鎖在并發處理中占據了一席之地,但是使用鎖有一個不好的地方,就是當一個線程沒有獲取到鎖時會被阻塞掛起,這會導致線程上下文的切換和重新調度開銷。
Java提供了非阻塞的volatile關鍵字來解決共享變量的可見性問題,這在一定程度上彌補了鎖帶來的開銷問題,但是volatile只能保證共享變量的可見性,不能解決讀—改—寫等的原子性問題。
CAS 即Compare and Swap,其是JDK提供的非阻塞原子性操作,它通過硬件保證了比較—更新操作的原子性。JDK里面的Unsafe類提供了一系列的compareAndSwap*方法
下面以compareAndSwapLong方法為例進行簡單介紹
public final native boolean compareAndSwapLong(Object obj,long valueOffset,long expect, long update);compareAndSwap的意思是比較并交換
CAS有四個操作數,分別為:對象內存位置、對象中的變量的偏移量、變量預期值和新的值。
其操作含義是,如果對象obj中內存偏移量為valueOffset的變量值為expect,則使用新的值update替換舊的值expect。這是處理器提供的一個原子性指令。
ABA —>解決辦法AtomicStampedReference
關于CAS操作有個經典的ABA問題,具體如下
假如線程I使用CAS修改初始值為A的變量X,那么線程I會首先去獲取當前變量X的值(為A), 然后使用CAS操作嘗試修改X的值為B,如果使用CAS操作成功了,那么程序運行一定是正確的嗎?其實未必,這是因為有可能在線程I獲取變量X的值A后,在執行CAS前,線程II使用CAS修改了變量X的值為B,然后又使用CAS修改了變量X 的值為A。所以雖然線程I執行CAS時X的值是A,但是這個A已經不是線程I獲取時的A了。這就是ABA問題。
ABA問題的產生是因為變量的狀態值產生了環形轉換,就是變量的值可以從A到B,然后再從B到A。如果變量的值只能朝著一個方向轉換,比如A到B,B到C,不構成環形,就不會存在問題。JDK中的AtomicStampedReference類給每個變量的狀態值都配備了一個時間戳,從而避免了ABA問題的產生。
總結
以上是生活随笔為你收集整理的Java Review - 并发编程_前置知识二的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java Review - 使用Time
- 下一篇: Java Review - 并发编程_锁