Java線程機制學習


  前面的文章中總結過Java中用來解決共享資源競爭導致線程不安全的幾種常用方式:

  • synchronized;
  • ReentrantLock;
  • ThreadLocal;

  這些都是在簡單介紹了基本用法的基礎上再側重於對底層原理的探討,由於這些知識點涉及到方方面面,短時間之內完全弄懂並非易事。而寫博客的初衷其實是驅動自己在學習的過程中及時總結,用自己的語言再將所學復述一遍以強化對知識的理解程度。所以在這篇文章里,我會從Java中最基本的一些並發概念開始,到Java的基本線程機制,梳理一個相對完整的基礎知識脈絡,盡量讓知識形成體系。所謂勿以浮沙築高台,如是說。

  本文會從如下幾個方面來闡述:

  關於並發

  基本線程機制

  線程狀態

  線程常用方法

  線程中斷

  終止線程

  總結

 

1. 關於並發

  雖然編程問題中相當大的一部分都可以通過使用順序編程來解決,但是由於cpu的運算速度比計算機系統中存儲及通信等子系統的速度要快幾個量級,相對而言在計算過程中,大部分時間會花費在磁盤I/O、網絡通信上面,這樣處理器在大部分時間里面就都需要等待其他資源,為了不浪費處理器的強大計算能力,讓計算機“同時”處理幾項任務則是簡單而有效的一個“壓榨”手段。

  除了充分利用cpu的計算能力,在后端開發中,服務端往往也需要同時對多個客戶端提供服務,這是一個更具體的並發應用場景。衡量一個服務性能的好壞,每秒事物處理數(Transactions Per Second,TPS)是一個重要指標,代表着一秒內服務端平均能響應的請求總數,而TPS值與程序的並發能力又有非常密切的關系,程序並發協調得越有條不紊,效率自然越高;反之,線程之間頻繁阻塞甚至死鎖,則會大大降低程序的並發能力。

  Java支持多線程編程,而且服務端是其最擅長的領域之一,不過對於如何寫好並發應用程序卻又是服務端開發的難點之一。學習並發編程就像進入了一個全新的領域,如果你花點兒工夫,就能明白其基本機制,但要想真正地掌握它的實質,就需要深入的學習和理解。

  說到並發,需要和並行進行區別:

  • 所謂並發,其實是按順序執行的,cpu在任一時間只執行一個線程,通過給不同線程分配時間段的形式來進行調度,只是看起來好像多個任務是同時執行的;
  • 並行,就是多個任務同時在進行着的;

 

2. 基本線程機制

   並發編程使我們可以將程序划分為多個分離的、獨立運行的任務。通過使用多線程機制,這些獨立任務(也被稱為子任務)中的每一個都將通過執行線程來驅動。一個線程就是在進程中的一個單一的順序控制流,單個進程可以擁有多個並發執行的任務。

  線程模型為編程帶來了便利,它簡化了在單一程序中同時交織在一起的多個操作的處理。在使用線程時,CPU將輪流給每個任務分配其占用的時間。每個任務都覺得自己在一直占用CPU,但事實上CPU時間是划分成片段分配給了所有的任務(例外情況是程序確實運行在多個CPU之上)。線程的一大好處是可以使你從這個層次抽身出來,即代碼不必知道它是運行在具有一個還是多個CPU的機器上,所以,使用線程機制是一種建立透明的、可擴展的程序的方法。多任務和多線程往往是使用多處理器系統的最合理方式。

  在JDK1.2之后,Java中的線程模型是基於操作系統原生線程模型來實現,但這和Java程序的編碼來說是沒有影響的。因為Java語言提供了在不同硬件和操作系統平台下對線程操作的統一處理,每個已經執行start()且還未結束的java.lang.Thread類的實例就代表了一個線程。

  我們可以通過三種傳統的方式來通過線程驅動任務:

  • new一個Thread類,並重寫run方法(也可以通過匿名類的方式);
  • 實現Runnable接口,傳入Thread的構造器中;
  • 直接在main函數中new一個實現了Runnable接口的類,實例化,直接調用其run方法,其實是由main線程來驅動的; 

  通過一個例子來體會一下:

public class DefineRunnable {
    
   // 獲取一個線程唯一標識
public static AtomicInteger a = new AtomicInteger(); public static int getThreadId() { return a.getAndIncrement(); } public static void main(String[] args) { // 驅動任務,方式1,通過重寫Thread中run方法,直接由Thread類驅動 new Thread() { @Override public void run() { System.out.println("lightsOff ! doing-Thread: " + DefineRunnable.getThreadId()); } }.start(); // 驅動任務,方式2,通過將實現了Runnable的類作為構造參數傳入Thread的構造器中,通過Thread類來驅動 new Thread(new LightsOff()).start(); // 驅動任務,方式3,通過主線程直接驅動Runnable任務 LightsOff lightsOff = new LightsOff(); lightsOff.run(); } } class LightsOff implements Runnable{ @Override public void run() { System.out.println("lightsOff ! doing-Thread: " + DefineRunnable.getThreadId()); } } /** * 輸出 **/ lightsOff ! doing-Thread: 0 lightsOff ! doing-Thread: 1 lightsOff ! doing-Thread: 2

  如上是一些基本的驅動任務的方式,當然還有更好的方式,通過交給線程池處理,這在后面會專門撰文詳述。

  調用Thread對象的start()方法為線程執行必需的初始化操作,然后會自動去調用Runnable的run()方法。調用start()方法之后會迅速返回,即使run()方法沒有執行完,這是因為run()是由不同的線程執行的,你仍舊可以執行main中的其他后續操作,程序會同時執行多個方法,main()和多個Runnable中的run()方法。這一點可能會讓初次接觸線程Thread這一概念的同學覺得莫名困惑,至少我當年就困惑過。

  當我們將任務交給線程來驅動之后,任務是否被執行則要取決於線程調度器的調度了。雖然Java的線程調度是由系統自動完成的,但我們還是可以“建議”系統給某些線程多分配一點執行時間或少一點,這項操作可以通過設置線程優先級來完成。Java中一共設置了10個線程優先級(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),在兩個線程同時處於Ready狀態時,優先級越高的線程越容易被系統選擇執行。

  但是,線程優先級並不是很靠譜,前面也說到過,Java的線程是通過映射到操作系統的原生線程上來實現的,所以線程調度最終取決於操作系統,不同操作系統的優先級概念是不同的。所以,我們不能在程序中通過優先級來完全准確地判斷一組狀態都為Ready的線程將會先執行哪一個。

 

3. 線程狀態

   Java語言定義了5種線程狀態,在任一時間點,一個線程只能有且只有其中的一種狀態,分別是新建、運行、等待、阻塞、結束。

3.1 新建(New)

  創建后尚未啟動的線程就處於這種狀態。

3.2 運行(Runable)

  Runable包括了操作系統線程狀態中的 Running和 Ready,也就是處於此狀態的線程有可能正在執行,也有可能正在等待着CPU為它分配執行時間。

3.3 無限期等待(Waiting)

  處於這種狀態的線程不會被分配CPU執行時間,它們要等待被其他線程顯式地喚醒。以下方法會讓線程陷入無限期的等待狀態:

  • 沒有設置 Timeout參數的Object.wait()方法;
  • 沒有設置 Timeout參數的Thread.join()方法;
  • LockSupport park()方法;

3.4 有限期等待(Timed Waiting)

 處於這種狀態的線程也不會被分配CPU執行時間,不過無須等待被其他線程顯式地喚醒,在一定時間之后它們會由系統自動喚醒。以下方法會讓線程進入限期等待狀態:

  • Thread.sleep()方法;
  • 設置了Timeout參數的Object.wait()方法;
  • 設置了Timeout參數的Thread.join()方法;
  • LockSupport.parkNanos()方法;
  • LockSupport.parkUntil()方法;

3.5 阻塞(Blocked)

  線程被阻塞了,“阻塞狀態”在等待着獲取到一個排他鎖(synchronized中獲取的monitor),這個事件將在另外一個線程放棄這個鎖的時候發生,在程序等待進入同步區域的時候,線程將進入這種狀態。

3.6 結束(Terminated)

  已終止線程的線程狀態,線程已經結束執行。

 

  上述5種狀態在遇到特定事件發生的時候會互相轉換,他們的轉換關系如下圖:

 

4. 線程常用方法

  在線程運行的過程中,我們需要通過各種方式來操縱線程(比如暫停,中斷線程)或者協調多個線程(比如通知別的線程)。常用的方式有sleep、join、yield、wait、notify/notifyAll。

4.1 休眠(sleep)

  調用某個線程的sleep()方法可以使其休眠給定的時間。

  sleep()方法不會釋放“鎖標志”,也就是說如果有synchronized同步塊,其他線程仍然不能訪問共享數據。而join()方法會釋放"鎖標志"。

4.2 加入一個線程(join)

  一個線程可以在其他線程之上調用join()方法,其效果是等待一段時間直到第另一個線程結束才繼續執行。如果線程A在另一個線程B上調用B.join(),則線程A將被掛起,直到目標線程B結束才恢復(即B.isAlive()返回為假)。

  也可以在調用join()時帶上一個超時參數(單位可以是毫秒,或者毫秒和納秒),這樣如果目標線程在這段時間到期時還沒有結束的話, join方法總能返回。對join()方法的調用可以被中斷,做法是在調用線程上調用interrupt方法,這時需要用到try- -catch子句,與sleep類似。

class Sleeper extends Thread{
    private int duration;
    public Sleeper(String name,int sleepTime){
        super(name);
        duration = sleepTime;
        start();
    }
    public void run(){
        try{
            sleep(duration);
        }catch(InterruptedException e){
            System.out.println(getName() + " was interrupted. " + "isInterrupted(): " + isInterrupted());
        }
        System.out.println(getName() + " has awakened");
    }
}

class Joiner extends Thread{
    private Sleeper sleeper;
    public Joiner(String name,Sleeper sleeper){
        super(name);
        this.sleeper = sleeper;
        start();
    }
    public void run(){
        try{
            sleeper.join();
        }catch(InterruptedException e){
            System.out.println("Interrupted");
        }
        System.out.println(getName() + " join completed");
    }
}

public class Joining{
    public static void main(String[] args){
        Sleeper sleepy = new Sleeper("Sleepy",1500);
        Sleeper grumpy = new Sleeper("Grumpy",1500);
        Joiner dopey = new Joiner("Dopey",sleepy);
        Joiner doc = new Joiner("Doc",grumpy);
        grumpy.interrupt();
    }
}

/**
 * 輸出結果
 **/
Grumpywas interrupted. isInterrupted(): false
Grumpy has awakened
Doc join completed
Sleepy has awakened
Dopey join completed

  在上面的demo中,主線程會啟動4個子線程,分別是sleepy、grumpy、doc、dopey。

  • sleepy和grumpy啟動之后會進入休眠狀態,doc和dopey啟動之后會調用相應sleep和grumpy的join方法,意味着要等sleepy執行完才會再執行dopey,doc也一樣;
  • 這時主線程調用grumpy的interrupt()方法,因為grumpy處於休眠狀態所以拋出InterruptedException異常

4.3 讓步(yield)

 這是Thread類的一個靜態方法,當在線程中調用這個方法之后,當前線程將放棄cpu使用,進入ready狀態,等待系統重新調度,有可能會重新進入running狀態也有可能不會,相當於給其他線程一個機會了。

  如果知道已經完成了在run()方法的循環的一次迭代過程中所需的工作,就可以給線程調度機制一個暗示:你的工作已經做得差不多了,可以讓別的線程使用CPU了。這個暗示將通過調用 yield方法來作出(不過這只是一個暗示,沒有任何機制保證它將會被采納)。當調用yield()時,你也是在建議具有相同優先級的其他線程可以運行。所以,對於任何重要的控制或在調整應用時,都不能依賴於yield()。

4.4 wait、notify/notifyAll

  這三個方法比較特殊,它們不屬於Thread類,而是定義在Object中的,雖然不在Thread中,但是又和線程相關。這三個方法的調用方式是通過同步對象鎖來調用的,而且必須在同步塊中調用。

  • wait表示阻塞,調用此方法時當前線程會阻塞,同時釋放對象鎖;
  • notify、notifyAll表示通知,調用該方法之后會釋放一個或多個因等待同步鎖而阻塞的線程,被釋放的線程會去競爭同步鎖(synchronized),獲取鎖了才會繼續執行,否則還是處於阻塞狀態;

 

public class ThreadDemo{
    static String content;
    static String LOCK = "lock";
    public static void main(String[] args){
        new Thread(){
            @Override
            public void run(){
                synchronized(LOCK){
                    content = "hello world";
                    LOCK.notifyAll();
                }
            } 
        }.start();

        synchronized(LOCK){
            while(content == null){
                try{
                    LOCK.wait();
                }catch(InterruptedException e){
                    e.printStackTrace();
                }
                System.out.println(content.toUpperCase());
            }
        }
    }
}

// 輸出
HELLO WORLD

  如上面的例子中所示,主線程會啟動一個子線程,主線程會判斷成員變量content為null時則調用LOCK的wait進入無限等待,然后釋放同步鎖,子線程獲取到鎖之后,給content賦值,然后通過調用LOCK的notifyAll()來通知主線程,使得主線程可以解除等待狀態,進入到阻塞狀態,當子線程執行完畢之后會釋放鎖,這時主線會獲取鎖然后繼續執行,輸出大寫的hello world。

 

5. 線程中斷

  線程中斷僅僅是置線程的中斷狀態位,並不會停止線程(至於如何停止,本文后面會詳述)。支持線程中斷的方法(也就是線程中斷后會拋出interruptedException的方法)就是在監視線程的中斷狀態,比如sleep、join等,一旦線程的中斷狀態被置為“中斷狀態”,就會拋出中斷異常,並且將中斷標志重新置為false。所以在Java中設置線程的中斷狀態位並不會產生對線程運行的實際影響,而是通過監視線程的狀態位並做相應處理,或者通過拋出中斷異常(InterruptedException)來通知用戶進行處理。

  和線程中斷狀態位有直接關系的方法主要有:interrupt()、interrupted()、isInterrupted(),其使用介紹如下:

5.1 interrupt()

  interrupt()是Thread的實例方法,用於中斷線程。調用該方法的線程的狀態為將被置為"中斷"狀態。 

5.2 interrupted()

  interrupted()方法為Thread的靜態方法,該方法就是直接調用當前線程的isInterrupted(true)的方法,是作用於當前線程,並且會重置當前線程的中斷狀態。

public static boolean interrupted(){
    return currentThread().isInterrupted(true);
}

5.3 isInterrupted()

  isInterrupted()方法是Thread的實例方法,是作用於調用該方法的線程對象所對應的線程,是直接調用對應線程的isInterrupted(false)的方法,不會重置對應線程的中斷狀態。

public boolean isInterrupted () {
    return isInterrupted( false);
}

  為了更清楚其中的區別,我自己寫了一個例子:

public class InterruptTest {
    
    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(new LightsOff());
        threadA.start();

        System.out.println("ThreadA isInterruptd --> " + threadA.isInterrupted());
        Thread.sleep(500);
        threadA.interrupt();
    
        System.out.println("ThreadA isInterruptd --> " + threadA.isInterrupted());
        Thread.sleep(100);

        System.out.println("ThreadA isInterruptd --> " + threadA.isInterrupted());
        
    }
    
    static class LightsOff implements Runnable{

        @Override
        public void run() {
            System.out.println("ThreadA start");
            while(!Thread.currentThread().isInterrupted()) {
                
            }
            System.out.println("ThreadA continue");
            System.out.println("threadA is interrupted? --> " + Thread.interrupted());            
        }        
    }
}

  輸出結果為:

ThreadA isInterruptd --> false
ThreadA start
ThreadA continue
ThreadA isInterruptd --> true
threadA is interrupted? --> true
ThreadA isInterruptd --> false

  我們看一下整個過程:

  • 首先主線程啟動線程A;
  • 主線程這時候通過實例對象threadA的isInterrupted()獲取線程A的中斷狀態標志位,此時為默認的false;
  • 主線程休眠500ms;
  • 線程A啟動后輸出ThreadA start,然后進入while循環,只要線程的中斷標志位為false,則一直循環;
  • 主線程休眠結束后,調用threadA的interrupted方法,設置線程A的中斷狀態標志位為true,此時主線程獲取線程A的中斷標志位為true;
  • 線程A跳出循環,輸出Thread continue,然后調用線程的靜態方法interrrupted,返回true,並且將線程A的中斷標志復原為false;
  • 主線程休眠100ms,確保線程A已經調用了interrupted方法,此時獲取到線程A的中斷標志位為false; 

 

6. 終止線程

  當調用線程的start方法之后,線程會開始驅動任務,當任務執行完畢之后(也就是run方法執行結束)線程將終止,但是如果因為線程阻塞或者線程長時間執行而不能結束,所以我們希望能夠通過某種途徑可以終止線程以達到想要的效果,常用的方式有兩種:中斷、檢查中斷。

6.1 中斷

  Thread類包含interrupt()方法,因此你可以終止被阻塞的任務,這個方法將設置線程的中斷狀態。如果一個線程已經被阻塞,或者試圖執行一個阻塞操作,那么設置這個線程的中斷狀態將導致線程拋出InterruptedException。當拋出該異常或者該任務調用Thread.interrupted()時,中斷狀態將被復位。

  因為這種方式是在任務的run()方法中間打斷,更像是拋出的異常,所以在Java線程的中斷中用到了異常。而為了在以這種方式終止任務時,返回眾所周知的良好狀態,必須仔細考慮代碼的執行路徑,並仔細編寫catch子句以正確清除所有事物。

  如何調用interrupt?

  • 為了調用interrupt(),你必須持有Thread對象。

  • 如果你在Executor上調用shutdownNow(),那么它將發送一個interrupt()調用給它啟動的所有線程。

  • 如果希望只中斷某個單一任務,那么可以通過調用submit()而不是executor()來啟動任務,就可以持有該任務的上下文。submit()將返回一個泛型Future<?>,持有這種Future的關鍵在於你可以在其上調用cancel(),並因此可以使用它來中斷某個特定任務。如果你將true傳遞給cancel(),那么它就會擁有在該線程上調用interrupt()以停止這個線程的權限,因此,cancel()是一種中斷由Executor啟動的單個線程的方式。

  對於互斥導致阻塞的中斷:

  • 在ReentrantLock上阻塞的任務具備可以被中斷的能力(即interrupt()可以打斷被ReentrantLock互斥所阻塞的調用),而在synchronized方法或臨界區上阻塞的任務則不能被中斷;
  • 不能中斷正在試圖獲取synchronized鎖或者試圖執行I/O操作的線程;

6.2 檢查中斷

  當你在線程上調用interrupt()時,中斷發生的唯一時刻是在任務要進入到阻塞操作中,或者已經在阻塞操作內部時。但是如果根據程序運行的環境,你已經編寫了可能會產生這種阻塞調用的代碼,那又該怎么辦呢?如果你只能通過在阻塞調用上拋出異常來退出,那么你就無法總是可以離開run()循環。因為如果你調用interrupt()以停止某個任務,那么在run循環碰巧沒有產生任何阻塞調用的情況下這種方式就不起作用了,需要另一種方式來退出。Thread.interrupted()提供了離開run()循環而不拋出異常的第二種方式。

  這種機會是由中斷狀態來表示的,其狀態可以通過調用interrupt()來設置。你可以通過調用interrupted()來檢查中斷狀態,這不僅可以告訴你interrupt()是否被調用過,而且還可以清除中斷狀態。清除中斷狀態可以確保並發結構不會就某個任務被中斷這個問題通知你兩次,你可以經由單一的InterruptedException或單一的成功的Thread.interrupted()測試來得到這種通知。如果想要再次檢查以了解是否被中斷,則可以在調用Thread.interrupted()時將結果存儲起來。

  下面的示例展示了典型的慣用法,你應該在run()方法中使用它來處理在中斷狀態被設置時,被阻塞和不被阻塞的各種可能:

class NeedsCleanup{
    private final int id;
    public NeedsCleanup(int ident){
        id = ident;
        System.out.println("NeedsCleanup " + id);
    }
    public void cleanup(){
        System.out.println("Cleaning up " + id);
    }
}

class Blocked implements Runnable{
    private volatile double d = 0.0;
    public void run(){
        try{
       // 第2中方式,檢查中斷的方式
while(!Thread.interrupted()){ // point1 NeedsCleanup n1 = new NeedsCleanup(1); try{ System.out.println("Sleeping"); TimeUnit.SECONDS.sleep(1); // point2 NeedsCleanup n2 = new NeedsCleanup(2); try{ System.out.println("Calculating"); // 復雜浮點運算,耗時但是不會導致阻塞 for(int i = 1; i<2500000; i++) d = d + (Math.PI + Math.E)/d; System.out.println("Finished time-consuming operation"); }finally{
               // 保證即使被中斷結束了, 依然能夠完成n2清理工作 n2.cleanup(); } }
finally{
            // 保證即使被中斷結束了,依然能夠完成n1的清理工作 n1.cleanup(); } } System.out.println(
"Exiting via while() test"); }catch(InterruptedException e){ System.out.println("Exiting via InterruptedException"); } } } public class InterruptingIdiom{ public static void main(String[] args)throws Exception{ if(args.length != 1){ System.out.println("usage: java InterruptingIdiom delay-in-mS"); System.exit(1); } Thread t = new Thread(new Blocked()); t.start(); TimeUnit.MILLISECONDS.sleep(new Integer(args[0]));
     // 第1中方式,直接中斷 t.interrupt(); } }
/** 輸出 NeedsCleanup 1 Sleeping NeedsCleanup 2 Calculating Finished time-consuming operation Cleaning up 2 Cleaning up 1 NeedsCleanup 1 Sleeping Cleaning up 1 Exiting via InterruptedException */

   如上演示了兩種中斷線程的方法:

  • 在主線程中,經過一段時間的休眠之后,調用線程t的interrupt()方法將其中斷,此為中斷
  • 在線程t的run()方法中,將所有邏輯都放在一個while循環中,判斷條間就是Thread.isInterrupted()的返回值,即使線程t沒有進入阻塞狀態,但是每一次循環都會檢查中斷狀態,一旦發現中斷狀態被設置則會退出循環,此為檢查中斷

 

7. 總結

  • 關於並發,是為了充分利用cpu的計算能力而產生的;
  • Java中的多線程機制是將程序划分為多個分離的、獨立的運行任務,每個任務靠單獨線程來驅動;
  • Java中對線程定義了5種狀態:新建、運行、等待、阻塞、結束;
  • 線程常用到的方法:sleep、join、yield、wait、notify/notifyAll;
  • 線程中斷:interrupt()、interrupted()、isInterrupted();
  • 終止線程有2種常用的方式:中斷、檢查中斷;

   本文重點在最基礎的Java線程機制,雖然這部分比較基礎,也正是因為如此,往往容易被忽視。但是基礎不代表不重要,本文的很多概念還是費了一點時間來搞懂的,如果有不對的地方還請指正,如果你覺得對你有幫助的話,請點個贊吧 ^_^ !


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM