程序員:一篇文章弄懂Java多線程基礎和Java內存模型

前言

做為一個java程序員,多線程開發是避免不了的,多線程是基礎技術,但同時也是一個比較重要、比較難的知識點。

今天通過這邊文章帶大家瞭解一下java多線程的基礎知識和Java內存模型。


程序員:一篇文章弄懂Java多線程基礎和Java內存模型


多線程的生命週期及五種基本狀態

Java多線程生命週期,首先看下面這張經典的圖,圖中基本上囊括了Java中多線程重要知識點。


程序員:一篇文章弄懂Java多線程基礎和Java內存模型

Java線程具有五種基本狀態

  • 新建狀態(New):當線程對象對創建後,即進入了新建狀態,如:Thread t = new MyThread();
  • 就緒狀態(Runnable):當調用線程對象的start()方法(t.start();),線程即進入就緒狀態。處於就緒狀態的線程,只是說明此線程已經做好了準備,隨時等待CPU調度執行,並不是說執行了t.start()此線程立即就會執行;
  • 運行狀態(Running):當CPU開始調度處於就緒狀態的線程時,此時線程才得以真正執行,即進入到運行狀態。注:就緒狀態是進入到運行狀態的唯一入口,也就是說,線程要想進入運行狀態執行,首先必須處於就緒狀態中;
  • 阻塞狀態(Blocked):處於運行狀態中的線程由於某種原因,暫時放棄對CPU的使用權,停止執行,此時進入阻塞狀態,直到其進入到就緒狀態,才有機會再次被CPU調用以進入到運行狀態。根據阻塞產生的原因不同,阻塞狀態又可以分為三種:

1.等待阻塞:運行狀態中的線程執行wait()方法,使本線程進入到等待阻塞狀態;

2.同步阻塞 – 線程在獲取synchronized同步鎖失敗(因為鎖被其它線程所佔用),它會進入同步阻塞狀態;

3.其他阻塞 – 通過調用線程的sleep()或join()或發出了I/O請求時,線程會進入到阻塞狀態。當sleep()狀態超時、join()等待線程終止或者超時、或者I/O處理完畢時,線程重新轉入就緒狀態。

  • 死亡狀態(Dead):線程執行完了或者因異常退出了run()方法,該線程結束生命週期。

多線程的創建

多線程有三種創建方式:

  1. 繼承Thread類,重寫該類的run()方法
  2. 通過實現Runnable接口創建線程類
  3. 通過Callable和Future接口創建線程

前兩種大家應該都很熟悉,也是我們經常用到的兩種創建線程的方法。

下面我們用代碼來看一下這三種方法的實現。

繼承Thread類,重寫該類的run()方法

<code>public class MyThread extends Thread{
@Override
public void run() {
for (int i = 0 ;i < 50;i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
public static void main(String[] args) {
for (int i = 0;i<50;i++) {
//調用Thread類的currentThread()方法獲取當前線程
System.out.println(Thread.currentThread().getName() + " " + i);
if (i == 10) {
new MyThread().start();
new MyThread().start();
}
}
}
}/<code>

運行結果:

<code>main 48 
main 49
Thread-0:0
Thread-0:1
Thread-0:2
Thread-0:3
Thread-0:4
Thread-1:0/<code>

從上面運行結果可以看出:

  • 有三個線程:main、Thread-0 、Thread-1
  • Thread-0 、Thread-1兩個線程輸出的成員變量 i 的值不連續(這裡的 i 是實例變量而不是局部變量)。因為:通過繼承Thread類實現多線程時,每個線程的創建都要創建不同的子類對象,導致Thread-0 、Thread-1兩個線程不能共享成員變量 i ;
  • 線程的執行是搶佔式,並沒有說Thread-0 或者Thread-1一直佔用CPU(這也與線程優先級有關,這裡Thread-0 、Thread-1線程優先級相同,關於線程優先級的知識這裡不做展開)

通過實現Runnable接口創建線程類

<code>public class MyRunnable implements Runnable {
@Override
public void run() {
for (int i = 0 ;i < 50 ;i++) {
System.out.println(Thread.currentThread().getName()+":" +i);
}
}
public static void main(String[] args) {
for (int i = 0;i < 50;i++) {
System.out.println(Thread.currentThread().getName() + ":" +i);
if (i == 10) {
MyRunnable myRunnable = new MyRunnable();
new Thread(myRunnable).start();
new Thread(myRunnable).start();
}
}
//java8 labdam方式
new Thread(() -> {
System.out.println(Thread.currentThread().getName());
},"線程3").start();
}
}/<code>

運行結果:

<code>main:46
main:47
main:48
main:49
Thread-0:28
Thread-0:29
Thread-0:30

Thread-1:30/<code>


  • 線程1和線程2輸出的成員變量i是連續的,也就是說通過這種方式創建線程,可以使多線程共享線程類的實例變量,因為這裡的多個線程都使用了同一個target實例變量。但是,當你使用我上述的代碼運行的時候,你會發現,其實結果有些並不連續,這是因為多個線程訪問同一資源時,如果資源沒有加鎖,那麼會出現線程安全問題(這是線程同步的知識,這裡不展開);
  • java8 可以使用lambda方式創建多線程。

通過Callable和Future接口創建線程

這種方式是我們不經常用的。

創建Callable接口實現類,並實現call()方法,該方法將作為線程執行體,且該方法有返回值,再創建Callable實現類的實例;使用FutureTask類來包裝Callable對象,該FutureTask對象封裝了該Callable對象的call()方法的返回值;使用FutureTask對象作為Thread對象的target創建並啟動新線程;調用FutureTask對象的get()方法來獲得子線程執行結束後的返回值

<code>public class MyCallable implements Callable<integer> { 

private int i = 0;
@Override
public Integer call() throws Exception {
int sum = 0;
for (; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + " " + i);
sum += i;
}
return sum;
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 創建MyCallable對象
Callable<integer> myCallable = new MyCallable();
//使用FutureTask來包裝MyCallable對象
FutureTask<integer> ft = new FutureTask<integer>(myCallable);
for (int i = 0;i<50;i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
if (i == 30) {
Thread thread = new Thread(ft);
thread.start();
}
}
System.out.println("主線程for循環執行完畢..");
Integer integer = ft.get();
System.out.println("sum = "+ integer);
}
}
/<integer>/<integer>/<integer>/<integer>/<code>

call()方法的返回值類型與創建FutureTask對象時<>裡的類型一致。

Java內存模型概念

在併發編程中,我們需要處理兩個關鍵問題:線程之間如何通信及線程之間如何同步(這裡的線程是指併發執行的活動實體)。通信是指線程之間以何種機制來交換信息。在命令式編程中,線程之間的通信機制有兩種:共享內存和消息傳遞。

在共享內存的併發模型裡,線程之間共享程序的公共狀態,線程之間通過寫-讀內存中的公共狀態來隱式進行通信。在消息傳遞的併發模型裡,線程之間沒有公共狀態,線程之間必須通過明確的發送消息來顯式進行通信。

堆內存在線程之間共享(本文使用“共享變量”這個術語代指實例域,靜態域和數組元素)。局部變量(Local variables),方法定義參數(java語言規範稱之為formal method parameters)和異常處理器參數(exception handler parameters)不會在線程之間共享,它們不會有內存可見性問題,也不受內存模型的影響。

主內存和工作內存解釋

主內存(main memory): 類的實例所存在的區域,所有的實例都存在主存儲器內,並且實例的字段也位於這裡。主存儲器為所有的線程所共享,主內存主要對應於Java堆中對象的實例數據部分。

工作內存(working memory): 每個線程各自獨立所擁有的作業區,在working memory中,存有main memory中的部分拷貝,稱之為工作拷貝(working copy)。

Java線程之間的通信由Java內存模型(本文簡稱為JMM)控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見。

從抽象的角度來看,JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存(main memory)中,每個線程都有一個私有的本地內存(local memory),本地內存中存儲了該線程以讀/寫共享變量的副本。

本地內存是JMM的一個抽象概念,並不真實存在。

它涵蓋了緩存,寫緩衝區,寄存器以及其他的硬件和編譯器優化。Java內存模型的抽象示意圖如下:

程序員:一篇文章弄懂Java多線程基礎和Java內存模型

從上圖來看,線程A與線程B之間如要通信的話,必須要經歷下面2個步驟:

  1. 首先,線程A把本地內存A中更新過的共享變量刷新到主內存中去。
  2. 然後,線程B到主內存中去讀取線程A之前已更新過的共享變量。

下面通過示意圖來說明這兩個步驟:

程序員:一篇文章弄懂Java多線程基礎和Java內存模型

如上圖所示,本地內存A和B有主內存中共享變量x的副本。假設初始時,這三個內存中的x值都為0。

  • 線程A在執行時,把更新後的x值(假設值為1)臨時存放在自己的本地內存A中。當線程A和線程B需要通信時,線程A首先會把自己本地內存中修改後的x值刷新到主內存中,此時主內存中的x值變為了1。
  • 線程B到主內存中去讀取線程A更新後的x值,此時線程B的本地內存的x值也變為了1。

從整體來看,這兩個步驟實質上是線程A在向線程B發送消息,而且這個通信過程必須要經過主內存。JMM通過控制主內存與每個線程的本地內存之間的交互,來為java程序員提供內存可見性保證。

內存間的交互操作

主內存與工作內存之間的交互操作定義了8種原子性操作。具體如下:

  • lock(鎖定):作用於主內存的變量,將一個變量標識為一條線程獨佔狀態
  • unlock(解鎖):作用於主內存的變量,將一個處於鎖定狀態的變量釋放出來
  • read(讀取):作用於主內存的變量,把一個變量的值從主內存傳輸到線程的工作內存中
  • load(載入):作用於工作內存的變量,把read傳輸的變量值放入或者拷貝到工作內存的變量副本
  • use(使用):作用於工作內存的變量,表示線程引用工作內存中的變量值,將工作內存中的一個變量的值傳遞給執行引擎
  • assign(賦值):作用於工作內存的變量,表示線程將指定的值賦值給工作內存中的某個變量。
  • store(存儲):作用於工作內存的變量,把工作內存中的一個變量的值傳送給主內存中
  • write(寫入):作用於主內存的變量,將store傳遞的變量值放入到主內存中對應的變量裡

下面圖片能幫我們加深印象

程序員:一篇文章弄懂Java多線程基礎和Java內存模型

volatile和synchronized的區別

首先需要理解線程安全的兩個方面:執行控制和內存可見。

執行控制的目的是控制代碼執行(順序)及是否可以併發執行。

內存可見控制的是線程執行結果在內存中對其它線程的可見性。根據Java內存模型的實現,線程在具體執行時,會先拷貝主存數據到線程本地(CPU緩存),操作完成後再把結果從線程本地刷到主存。

synchronized關鍵字解決的是執行控制的問題,它會阻止其它線程獲取當前對象的監控鎖,這樣就使得當前對象中被synchronized關鍵字保護的代碼塊無法被其它線程訪問,也就無法併發執行。更重要的是,synchronized還會創建一個內存屏障,內存屏障指令保證了所有CPU操作結果都會直接刷到主存中,從而保證了操作的內存可見性,同時也使得先獲得這個鎖的線程的所有操作,都happens-before於隨後獲得這個鎖的線程的操作。

volatile關鍵字解決的是內存可見性的問題,會使得所有對volatile變量的讀寫都會直接刷到主存,即保證了變量的可見性。這樣就能滿足一些對變量可見性有要求而對讀取順序沒有要求的需求。

使用volatile關鍵字僅能實現對原始變量(如boolen、 short 、int 、long等)操作的原子性,但需要特別注意, volatile不能保證複合操作的原子性。

對於volatile關鍵字,當且僅當滿足以下所有條件時可使用:

  • 對變量的寫入操作不依賴變量的當前值,或者你能確保只有單個線程更新變量的值。
  • 該變量沒有包含在具有其他變量的不變式中

volatile和synchronized的區別

  • volatile本質是在告訴jvm當前變量在寄存器(工作內存)中的值是不確定的,需要從主存中讀取; synchronized則是鎖定當前變量,只有當前線程可以訪問該變量,其他線程被阻塞住。
  • volatile僅能使用在變量級別;synchronized則可以使用在變量、方法、和類級別的
  • volatile僅能實現變量的修改可見性,不能保證原子性;而synchronized則可以保證變量的修改可見性和原子性
  • volatile不會造成線程的阻塞;synchronized可能會造成線程的阻塞。
  • volatile標記的變量不會被編譯器優化;synchronized標記的變量可以被編譯器優化。



好了,又到了分割線,今天的文章就要結束 了。

今天給大家推薦一個java視頻課件,比較實惠(適合入門),有需要的程序員可以去看看:


分享到:


相關文章: