Java開發這麼久,這些線程的基礎知識你確定都會了?

熟悉Java開發的同學都知道,Java天生支持多線程編程的。這篇文章我們主要來學習下Java線程的基礎知識,從線程的啓動到不同線程間的通信方式,目的是更系統的掌握Java線程基礎。

本文的講解主要從以下幾個點展開:

  1. 什麼是線程
  2. 線程都有哪幾種狀態
  3. 線程的啓動和終止
  4. 線程間的通信
  5. 利用本文講解的線程知識,實現一個簡單的線程池

如果上面列出的這幾個點,你都已經熟練的掌握了,那麼可能本文就無法給你帶來幫助了。選擇性的往下看哦。

ok,開始步入今天的正題

什麼是線程

下面是針對操作系統中進程和線程的概念:

現代操作系統在運行一個程序時,會爲其創建一個進程。例如,啓動一個Java程序,操作系統就會創建一個Java進程。現代操作系統調度的最小單元是線程,也叫輕量級進程(Light Weight Process),在一個進程裏可以創建多個線程,這些線程都擁有各自的計數器、堆棧和局部變量等屬性,並且能夠訪問共享的內存變量。處理器在這些線程上高速切換,讓使用者感覺到這些線程在同時執行。

那麼什麼是Java中的線程呢?

Java中的線程

在Java中,“線程”指兩件不同的事情:

  1. java.lang.Thread類的一個實例;
  2. 線程的執行;

使用java.lang.Thread類或者java.lang.Runnable接口編寫代碼來定義、實例化和啓動新線程。一個Thread類實例只是一個對象,像Java中的任何其他對象一樣,具有變量和方法,生死於堆上。

一個Java應用總是從main()方法開始運行,mian()方法運行在一個線程內,它被稱爲主線程。一旦創建一個新的線程,就產生一個新的調用棧。

需要注意的是Java中有一個特殊的線程,那就是Daemon線程

Daemon線程是一種支持型線程,因爲它主要被用作程序中後臺調度以及支持性工作。這意味着,當一個Java虛擬機中不存在非Daemon線程的時候,Java虛擬機將會退出。可以通過調用Thread.setDaemon(true) 線程設置爲Daemon線程。Daemon屬性需要在啓動線程之前設置,不能在啓動線程之後設置。

線程的狀態

Java線程在整個生命週期中一共分爲6中狀態,在給定的任一時刻,線程只能處於一種狀態。

狀態名稱 說明
NEW 顧名思義,初始狀態,被創建後還沒有調用start方法
RUNNABLE 運行狀態,Java線程將操作系統中的就緒和運行統稱爲 “運行中”
BLOCKED 阻塞狀態,表示線程阻塞於鎖,如synchronized生命的互斥資源
WAITING 等待狀態,進入該狀態表示需要其他線程做出一些特定操作(如通知或中斷
TIME_WAITING 超時等待狀態,不同於WAITING的點在於它可以在指定時間自行返回
TERMINATED 終止狀態。表示當前線程已經執行完畢

感興趣的同學,可以動手實踐下,利用jstack工具查看運行中的代碼的線程信息,這樣比只看有用多了,能夠更加深入的理解線程的各種狀態。

這裏提供示例代碼如下,同時示例中的代碼都已經上傳到 github,需要的同學可以從這個地址獲取 https://github.com/coderluojust/java-study/

這裏寫一下使用 jstack 查看的步驟:

  1. 運行上面的示例,打開終端或者命令提示符,輸入 jps,找到對應的進程號;
11024
13376 ThreadState
12644 Launcher
9652 KotlinCompileDaemon
5032 Jps
  1. 從上一步得到示例對應的進程號是13376,接着輸入 jstack 13376,就可以得到當前程序的線程堆棧,觀察狀態; 部分輸出如下:

// BlockedThread-2線程阻塞在獲取Blocked.class示例的鎖上
"BlockedThread-2" prio=5 tid=0x00007feacb05d000 nid=0x5d03 waiting for monitor
entry [0x000000010fd58000]
java.lang.Thread.State: BLOCKED (on object monitor)
// BlockedThread-1線程獲取到了Blocked.class的鎖
"BlockedThread-1" prio=5 tid=0x00007feacb05a000 nid=0x5b03 waiting on condition
[0x000000010fc55000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
// WaitingThread線程在Waiting實例上等待
"WaitingThread" prio=5 tid=0x00007feacb059800 nid=0x5903 in Object.wait()
[0x000000010fb52000]
java.lang.Thread.State: WAITING (on object monitor)
// TimeWaitingThread線程處於超時等待
"TimeWaitingThread" prio=5 tid=0x00007feacb058800 nid=0x5703 waiting on condition
[0x000000010fa4f000]
java.lang.Thread.State: TIMED_WAITING (sleeping)

線程在自身的生命週期中,並不是固定地處於某個狀態,而是隨着代碼的執行在不同的狀態之間進行切換,Java線程狀態變遷參考下圖:

啓動和終止線程

在運行一個線程之前,首先要構建一個線程對象。程對象在構造的時候需要提供線程所需要的屬性,如線程所屬的線程組、線程優先級、是否是Daemon線程等信息。 這部分信息我們可以查看Thread類的init() 方法,下面截取了java.lang.Thread 中初始化方法init的部分內容:

啓動線程

線程對象在初始化完成之後,調用start()方法就可以啓動這個線程。線程start()方法的含義是:當前線程(即parent線程)同步告知Java虛擬機,只要線程規劃器空閒,應立即啓動調用start()方法的線程。

注意:啓動一個線程前,最好爲這個線程設置線程名稱,因爲這樣在使用jstack分析程序或者進行問題排查時,就會給我們提供一些提示,自定義的線程最好能夠起個名字。

安全的終止線程

這裏爲什麼說安全的終止線程呢,是因爲 最早的 暫定、恢復、停止線程使用的是Thread 的API:suspend()、resume()、stop()。但是這些API是已經過期的,不建議使用。具體的不建議使用原因主要是: 以suspend()方法爲例,在調用後,線程不會釋放已經佔有的資源(比如鎖),而是佔有着資源進入睡眠狀態,這樣容易引發死鎖問題。同樣,stop()方法在終結一個線程時不會保證線程的資源正常釋放,通常是沒有給予線程完成資源釋放工作的機會,因此會導致程序可能工作在不確定狀態下。

上面只說了不建議使用的線程暫停、恢復、停止相關的API,那麼如何安全的 終止線程 和進行 等待恢復操作呢?

  1. 採用線程中斷,也就是調用線程的interrupt()方法,改變中斷狀態來實現線程間交互,從而停止任務;
  2. 利用一個boolean變量來控制是否需要停止任務終止線程;

示例代碼如下,同樣的已經上傳到 github上,需要的小夥去點擊文末 閱讀原文 去獲取:

示例中,main線程通過中斷操作和 cancel() 方法均可使CountThread 停止,這兩種方式能否使線程在終止時有機會去清理資源,而不是武斷的將線程停止,因此這種終止線程的做法顯得更加優雅和安全。

聰明的你可能會問,線程的終止問題是解決了,那等待和恢復的方法呢 。。。

強大的Java肯定是有解決辦法的,具體的線程等待和恢復我們可以採用等待/通知機制。接着往下看吧,在線程間通信機制講解時會提到這塊。

線程間通信

線程開始運行,擁有自己的棧空間,就如同一個腳本一樣,按照既定的代碼一步一步地執行,直到終止。但是,每個運行中的線程,如果僅僅是孤立地運行,那麼沒有一點兒價值,或者說價值很少,如果多個線程能夠相互配合完成工作,這將會帶來巨大的價值。

既然線程間需要相互配合完成工作,那麼線程間的通信就是首先要解決的問題,下面我們來學習線程間通信一共有哪些方式?

volatile和synchronized關鍵字

前面幾篇關於 java內存模型的文章,我們深入學習了 volatile和synchronized 關鍵字,它們是jvm層面提供的用來解決導致併發bug的三個源頭(可見性、有序性、原子性)。

我們都知道 Java 支持多個線程同時訪問一個對象或者對象的成員變量,所以它是支持線程之間的通信的。但是由於現代多核處理器,爲了解決cpu和內存之間的性能差異每個線程都有一份變量的拷貝存儲在cpu的高速緩存中,所以程序的執行過程中,一個線程看到的不一定是最新的內容,這就影響了線程間的通信。

關鍵字 volatile 可以用來修飾字段,就是告知程序對該變量的訪問需要從共享內存獲取,而對它的改變必須同步刷新回共享內存(實現原理就是通過一個lock前綴指令,相當於一個內存屏障,它的功能是可以將當前處理器緩存行的數據立即寫回內存, 寫回操作經過總線傳播數據,其它處理器通過嗅探在總線上傳播的數據,發現對應內存地址的數據變更將各自緩存失效),確保所有線程對變量訪問的可見性。

關鍵字 synchronized 可以修飾方法或者以同步塊的形式來進行使用,它主要確保多個線程在同一個時刻,只能有一個線程處於方法或者同步塊中,它保證了線程對變量訪問的可見性和排他性。

等待/通知機制

等待/通知機制簡單點說,就是生產者、消費者模式。一個線程的修改了一個對象的值,而另一個線程感知到了變化,進而執行相應的操作。那麼如何實現這種功能呢?

最簡單的方案就是消費者線程寫一個死循環不斷的檢查變量是否符合預期,可以參考如下僞代碼:

while (value != desire) {
Thread.sleep(1000);
}
doSomething();

這種方式雖然實現了需要的功能,但是確存在如下問題:

  1. 不夠及時。在睡眠時基本不消耗cpu資源,但是睡眠過久,無法及時發現變化;
  2. 難以降低開銷。如果要保證及時性,就要降低睡眠時間,這樣會消耗更多的CPU資源;

上面兩個問題,你可以發現是互斥的,難道就沒有更合理的方案? 這就需要Java內置的等待通知機制來解決這個矛盾。

等待/通知的相關方法是任意Java對象都具備的,因爲這些方法被定義在所有對象的鼻祖 java.lang.Object 中,相關方法具體如下:

方法名稱 描述
notify() 通知一個在對象上等待的線程,使其從wait()方法返回,而返回的前提是該線程獲取到了對象的鎖
notifyAll() 通知所有等待在該對象上的線程,從WAITING狀態變爲BLOCKED狀態
wait() 調用該方法的線程進入WAITING狀態,只有等待被另外的線程中斷或者通知纔會返回,需要注意調用wait()方法,會釋放對象的鎖
wait(long) 超時等待一段時間,單位是毫秒,到期後沒有通知就返回
wait(long,int) 對於超時時間更細粒度的控制,可以達到納秒

等待/通知機制,是指一個線程A調用了對象O的wait()方法進入等待狀態,而另一個線程B調用了對象O的notify()或者notifyAll()方法,線程A收到通知後從對象O的wait()方法返回,進而執行後續操作。上述兩個線程通過對象O來完成交互,而對象上的wait()和notify/notifyAll()的關係就如同開關信號一樣,用來完成等待方和通知方之間的交互工作。

這裏也有對應的示例代碼,由於篇幅原因,就不展示代碼了,如果你想動手實踐加深理解,一樣的,本文涉及的示例都已經上傳到github,點擊文末 閱讀原文 查看,尋找 WaitNotify 這個類。

在使用wait()、notify()、notifyAll()時有幾個細節需要掌握:

  1. 調用wait()、notify()、notifyAll() 需要先對調用對象加鎖;
  2. 調用wait()方法後,線程狀態有RUNNING 變爲 WAITING ,並釋放當前對象鎖,將當前線程放入到對象的等待隊列中;
  3. notify()和notifyAll() 方法調用後,等待線程依舊不會從wait()返回,需要等待調用notify()、notifyAll() 的線程釋放鎖之後,等待線程才能去競爭鎖,獲取到鎖才能從wait()返回;
  4. notify()方法將等待隊列中的一個等待線程從等待隊列中移到同步隊列中,而notifyAll()方法則是將等待隊列中所有的線程全部移到同步隊列,被移動的線程狀態由WAITING變爲BLOCKED。
  5. 從wait()方法返回的前提是獲得了調用對象的鎖。

管道輸入/輸出流

管道輸入/輸出流和普通的文件輸入/輸出流或者網絡輸入/輸出流不同之處在於,它主要用於線程之間的數據傳輸,而傳輸的媒介爲內存。

主要實現有如下四種:PipedOutputStream、PipedInputStream、PipedReader和PipedWriter,前兩種面向字節,而後兩種面向字符;

用法如下:

PipedWriter out = new PipedWriter();
PipedReader in = new PipedReader();
// 將輸出流和輸入流進行連接,否則在使用時會拋出IOException
out.connect(in);

Thread.join()

如果一個線程A調用了線程B的join()方法,那麼當前線程A 等待線程B終止之後纔會從B.join()返回。線程Thread除了提供join()方法之外還提供了等待超時方法join(long)和join(long,int)。

這裏其實也是用到了等待/通知機制,即A線程同步調用B線程的join()方法,進入循環等待,等到B線程結束之後,接收通知從B.join()方法返回,執行後續邏輯;

下面是JDK中Thread.join() 方法的核心源碼,和我們上面描述的意思是一樣的(部分調整後):


// 加鎖當前線程對象
public final synchronized void join(long millis)throws InterruptedException {    
    // 條件不滿足,繼續等待
    while (isAlive()) {           
        wait(0);        
     }    
     // 條件滿足,返回

ThreadLocal 的使用

ThreadLocal 即線程變量,是以當前線程爲參數先獲取當前線程的ThreadLocal.ThreadLocalMap屬性,即 ThreadLocalMap 是綁定在當前線程上的。這個ThreadLocalMap 中又是以 ThreadLocal 對象爲key,任意對象爲值的map數據結構。也就是說一個線程可以根據一個 ThreadLocal 對象查詢到綁定到這個線程上的一個值。

應用實踐

俗話說學而不思等於白學,接下來我們就結合今天學習的線程基礎基礎,包括線程狀態以及等待通知等線程基礎方法,來實現一個簡單的線程池,鞏固提高下。

主要功能爲預先創建若干數量的線程,並且用戶不用直接對線程的創建進行控制,用戶只需要將需要執行的任務提交給線程池,線程池重複利用數目固定的線程來完成任務。好處在於減少頻繁的創建和銷燬線程的開銷,避免一個任務一個線程導致系統頻繁的進行上下文切換,增加系統的負載。

線程池接口定義如下:

具體實現這裏不展示了,感興趣可以去我的 github 上找對應的實現。

總結

本文主要闡述了Java併發編程的基礎知識,從線程是什麼開始,講述了線程的各種狀態,以及如何優雅安全的開啓和終止。詳細學習了線程之間的各種通信方法,以及經典範式 等待/通知 機制,最後通過一個線程池的示例,鞏固了Java多線程的這些基礎知識,加深理解。

相信如果你能按照文章講述運行對應的代碼示例,一定會對Java多線程的基礎知識有一個更深刻的理解和掌握。

2020.04.12 fighting!


最近面試 字節、BAT,整理一份面試資料《Java 面試 BAT 通關手冊》,覆蓋了 Java 核心技術、JVM、Java 併發、SSM、微服務、數據庫、數據結構等等。獲取方式:點贊關注來一波,關注公衆號並回復 666 領取,更多內容陸續奉上

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章