線程與線程池的那些事之線程篇


本文關鍵字:

線程線程池單線程多線程線程池的好處線程回收創建方式核心參數底層機制拒絕策略,參數設置,動態監控線程隔離

線程和線程池相關的知識,是Java學習或者面試中一定會遇到的知識點,本篇我們會從線程和進程,並行與並發,單線程和多線程等,一直講解到線程池,線程池的好處,創建方式,重要的核心參數,幾個重要的方法,底層實現,拒絕策略,參數設置,動態調整,線程隔離等等。主要的大綱如下(本文只涉及線程部分,線程池下篇講):

進程和線程

從線程到進程

要說線程池,就不得不先講講線程,什么是線程?

線程(英語:thread)是操作系統能夠進行運算調度的最小單位。它被包含在進程之中,是進程中的實際運作單位。

那么問題來了,進程又是什么?

進程是操作系統中進行保護和資源分配的基本單位。

是不是有點懵,進程摸得着看得見么?具體怎么表現?打開Windows的任務管理器或者Mac的活動監視器,就可以看到,基本每一個打開的App就是一個進程,但是並不是一定的,一個應用程序可能存在多個進程

比如下面的Typora就顯示了兩個進程,每個進程后面有一個PID是唯一的標識,也是由系統分配的。除此之外,每個進程都可以看到有多少個線程在執行,比如微信有32個線程在執行。重要的一句話:一個程序運行之后至少有一個進程,一個進程可以包含多個線程。

image-20210508225417275

為什么需要進程?

程序,就是指令的集合,指令的集合說白了就是文件,讓程序跑起來,在執行的程序,才是進程。程序是靜態的描述文本,而進程是程序的一次執行活動,是動態的。進程是擁有計算機分配的資源的運行程序。

我們不可能一個計算機只有一個進程,就跟我們全國不可能只有一個市或者一個部門,計算機是一個龐然大物,里面的運轉需要有條理,就需要按照功能划分出比較獨立的單位,分開管理。每個進程有自己的職責,也有自己的獨立內存空間,不可能混着使用,要是所有的程序共用一個進程就會亂套。

每個進程,都有各自獨立的內存,進程之間內存地址隔離,進程的資源,比如:代碼段,數據集,堆等等,還可能包括一些打開的文件或者信號量,這都是每個進程自己的數據。同時,由於進程的隔離性,即使有一個程序的進程出現問題了,一般不會影響到其他的進程的使用。

進程在Linux系統中,進程有一個比較重要的東西,叫進程控制塊(PCB),僅做了解:

PCB是進程的唯一標識,由鏈表實現,是為了動態的插入以及刪除,創建進程的時候,生成一個PCB,進程結束的時候,回收這個PCBPCB主要包括以下的信息:

  • 進程狀態
  • 進程標識信息
  • 定時器
  • 用戶可見的寄存器,控制狀態寄存區,棧指針等等。

進程怎么切換的呢?

先明白計算機里面的一個事實:CPU運轉得超級無敵快,快到其他的只有寄存器差不多能匹配它的速度,但是很多時候我們需要從磁盤或者內存讀或者寫數據,這些設備的速度太慢了,與之相差太遠。(如果不特殊說明,默認是單核的CPU

假設一個程序/進程的任務執行一段時間,要寫磁盤,寫磁盤不需要CUP進行計算,那CPU就空出來了,但是其他的程序也不能用,CPU就干等着,等到寫完磁盤再接着執行。這多浪費,CPU又不是這個程序一家的,其他的應用也要使用。CPU你不用的時候,總有別人需要用。

所以CPU資源需要調度,程序A不用的時候,可以切出來,讓程序B去使用,但是程序A切回來的時候怎么保證它能夠接着之前的位置繼續執行呢?這時候不得不提上下文的事。

當程序A(假設為單進程)放棄CPU的時候,需要保存當前的上下文,何為上下文?也就是除了CPU之外,寄存器或者其他的狀態,就跟犯罪現場一樣,需要拍個照,要不到時候別的程序執行完之后,怎么知道接下來怎么執行程序A,之前執行到哪一步了。總結一句話:保存當前程序的執行狀態。

上下文切換一般還涉及緩存的開銷,也就是緩存會失效,一般執行的時候,CPU會緩存一些數據方便下次更快的執行,一旦進行上下文切換,原來的緩存就失效了,需要重新緩存。

調度一般有兩種(一般是按照線程維度來調度),CPU的時間被分為特別小的時間片:

  • 分時調度:每個線程或者進程輪流的使用CPU,平均時間分配到每個線程或者進程。
  • 搶占式調度:優先級高的線程/進程立即搶占下一個時間片,如果優先級相同,那么隨機選擇一個進程。

時間片超級短,CPU超級快,給我們無比絲滑的感覺,就像是多個任務在同時進行

我們現在操作系統或者其他的系統,基本都是搶占式調度,為什么?

因為如果使用分時調度,很難做到實時響應,當后台的聊天程序在進行網絡傳輸的時候,分配予它的時間片還沒有使用完,那我點擊瀏覽器,是沒有辦法實時響應的。除此之外,如果前面的進程掛了,但是一直占有CPU,那么后面的任務將永遠得不到執行。

由於CPU的處理能力超級快,就算是單核的CPU,運行着多個程序,多個進程,經過搶占式的調度,每一個程序使用的時候都像是獨享了CPU一樣順滑。進程有效的提高了CPU的使用率,但是進程在上下文切換的時候是存在着一定的成本的。

線程和進程什么關系?

前面說了進程,那有了進程,為啥還要線程,多個應用程序,假設我們每個應用程序要做n件事,就用n個進程不行么?

可以,但是沒必要。

進程一般由程序,數據集合和進程控制塊組成,同一個應用程序一般是需要使用同一個數據空間的,要是一個應用程序搞很多個進程,就算有能力做到數據空間共享,進程的上下文切換都會消耗很多資源。(一般一個應用程序不會有很多進程,大多數一個,少數有幾個)

進程的顆粒度比較大,每次執行都需要上下文切換,如果同一個程序里面的代碼段ABC,做不一樣的東西,如果分給多個進程去處理,那么每次執行都有切換進程上下文。這太慘了。一個應用程序的任務是一家人,住在同一個屋子下(同一個內存空間),有必要每個房間都當成每一戶,去派出所登記成一個戶口么?

進程缺點:

  • 信息共享難,空間獨立
  • 切換需要fork(),切換上下文,開銷大
  • 只能在一個時間點做一件事
  • 如果進程阻塞了,要等待網絡傳過來數據,那么其他不依賴這個數據的任務也做不了

但是有人會說,那我一個應用程序有很多事情要做,總不能只用一個進程,所有事情都等着它來處理啊?那不是會阻塞住么?

確實啊,單獨一個進程處理不了問題,那么我們把進程分得更小,里面分成很多線程,一家人,每個人都有自己的事情做,那我們每個人就是一個線程,一家人就是一個進程,這樣豈不是更好么?

進程是描述CPU時間片調度的時間片段,但是線程是更細小的時間片段,兩者的顆粒度不一樣。線程可以稱為輕量級的進程。其實,線程也不是一開始就有的概念,而是隨着計算機發展,對多個任務上下文切換要求越來越高,隨之抽象出來的概念。
$$進程時間段 = CPU加載程序上下文的時間 + CPU執行時間 + CPU保存程序上下文的時間$$

$$
線程時間段 = CPU加載線程上下文的時間 + CPU執行時間 + CPU保存線程上下文的時間$$
最重要的是,進程切換上下文的時間遠比線程切換上下文的時間成本要高,如果是同一個進程的不同線程之間搶占到CPU,切換成本會比較低,因為他們共享了進程的地址空間,線程間的通信容易很多,通過共享進程級全局變量即可實現。

況且,現在多核的處理器,讓不同進程在不同核上跑,進程內的線程在同個核上做切換,盡量減少(不可以避免)進程的上下文切換,或者讓不同線程跑在不同的處理器上,進一步提高效率。

進程和線程的模型如下:

image-20210509163642149

線程和進程的區別或者優點

  • 線程是程序執行的最小單位,進程是操作系統分配資源的最小單位。
  • 一個應用可能多個進程,一個進程由一個或者多個線程組成
  • 進程相互獨立,通信或者溝通成本高,在同一個進程下的線程共享進程的內存等,相互之間溝通或者協作成本低。
  • 線程切換上下文比進程切換上下文要快得多。

線程有哪些狀態

現在我們所說的是Java中的線程Thread,一個線程在一個給定的時間點,只能處於一種狀態,這些狀態都是虛擬機的狀態,不能反映任何操作系統的線程狀態,一共有六種/七種狀態:

  • NEW:創建了線程對象,但是還沒有調用Start()方法,還沒有啟動的線程處於這種狀態。

  • Running:運行狀態,其實包含了兩種狀態,但是Java線程將就緒和運行中統稱為可運行

    • Runnable:就緒狀態:創建對象后,調用了start()方法,該狀態的線程還位於可運行線程池中,等待調度,獲取CPU的使用權
      • 只是有資格執行,不一定會執行
      • start()之后進入就緒狀態,sleep()結束或者join()結束,線程獲得對象鎖等都會進入該狀態。
      • CPU時間片結束或者主動調用yield()方法,也會進入該狀態
    • Running :獲取到CPU的使用權(獲得CPU時間片),變成運行中
  • BLOCKED :阻塞,線程阻塞於鎖,等待監視器鎖,一般是Synchronize關鍵字修飾的方法或者代碼塊

  • WAITING :進入該狀態,需要等待其他線程通知(notify)或者中斷,一個線程無限期地等待另一個線程。

  • TIMED_WAITING :超時等待,在指定時間后自動喚醒,返回,不會一直等待

  • TERMINATED :線程執行完畢,已經退出。如果已終止再調用start(),將會拋出java.lang.IllegalThreadStateException異常。

image-20210509224848865

可以看到Thread.java里面有一個State枚舉類,枚舉了線程的各種狀態(Java線程將就緒運行中統稱為可運行):


public enum State {
    /**
     * 尚未啟動的線程的線程狀態。
     */
    NEW,

    /**
     * 可運行線程的線程狀態,一個處於可運行狀態的線程正在Java虛擬機中執行,但它可能正在等待來自操作系統(如處理器)的其他資源。
     */
    RUNNABLE,

    /**
     * 等待監視器鎖而阻塞的線程的線程狀態。
     * 處於阻塞狀態的線程正在等待一個監視器鎖進入一個同步的塊/方法,或者在調用Oject.wait()方法之后重新進入一個同步代碼塊
     */
    BLOCKED,

    /**
     * 等待線程的線程狀態,線程由於調用其中一個線程而處於等待狀態
     */
    WAITING,

    /**
     * 具有指定等待時間的等待線程的線程狀態,線程由於調用其中一個線程而處於定時等待狀態。
     */
    TIMED_WAITING,

    /**
     * 終止線程的線程狀態,線程已經完成執行。
     */
    TERMINATED;
}

除此之外,Thread類還有一些屬性是和線程對象有關的:

  • long tid:線程序號
  • char name[]:線程名稱
  • int priority:線程優先級
  • boolean daemon:是否守護線程
  • Runnable target:線程需要執行的方法

介紹一下上面圖中講解到線程的幾個重要方法,它們都會導致線程的狀態發生一些變化:

  • Thread.sleep(long):調用之后,線程進入TIMED_WAITING狀態,但是不會釋放對象鎖,到時間蘇醒后進入Runnable就緒狀態
  • Thread.yield():線程調用該方法,表示放棄獲取的CPU時間片,但是不會釋放鎖資源,同樣變成就緒狀態,等待重新調度,不會阻塞,但是也不能保證一定會讓出CPU,很可能又被重新選中。
  • thread.join(long):當前線程調用其他線程threadjoin()方法,當前線程不會釋放鎖,會進入WAITING或者TIMED_WAITING狀態,等待thread執行完畢或者時間到,當前線程進入就緒狀態。
  • object.wait(long):當前線程調用對象的wait()方法,當前線程會釋放獲得的對象鎖,進入等待隊列,WAITING,等到時間到或者被喚醒。
  • object.notify():喚醒在該對象監視器上等待的線程,隨機挑一個
  • object.notifyAll():喚醒在該對象監視器上等待的所有線程

單線程和多線程

單線程,就是只有一條線程在執行任務,串行的執行,而多線程,則是多條線程同時執行任務,所謂同時,並不是一定真的同時,如果在單核的機器上,就是假同時,只是看起來同時,實際上是輪流占據CPU時間片。

下面的每一個格子是一個時間片(每一個時間片實際上超級無敵短),不同的線程其實可以搶占不同的時間片,獲得執行權。時間片分配的單位是線程,而不是進程,進程只是容器

image-20210511002923132

如何啟動一個線程

其實Javamain()方法本質上就啟動了一個線程,但是本質上不是只有一個線程,看結果的 5 就大致知道,其實一共有 5 個線程,主線程是第 5 個,大多是后台線程

public class Test {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().toString());
    }
}

執行結果:

Thread[main,5,main]

可以看出上面的線程是main線程,但是要想創建出有別於main線程的方式,有四種:

  • 自定義類去實現Runnable接口
  • 繼承Thread類,重寫run()方法
  • 通過CallableFutureTask創建線程
  • 線程池直接啟動(本質上不算是)

實現Runnable接口

class MyThread implements Runnable{
    @Override
    public void run(){
        System.out.println("Hello world");
    }
}
public class Test {
    public static void main(String[] args) {
        Thread thread = new Thread(new MyThread());
        thread.start();
        System.out.println("Main Thread");
    }
}

運行結果:

Main Thread
Hello world

如果看底層就可以看到,構造函數的時候,我們將Runnable的實現類對象傳遞進入,會將Runnable實現類對象保存下來:

    public Thread(Runnable target) {
        this(null, target, "Thread-" + nextThreadNum(), 0);
    }

然后再調用start()方法的時候,會調用原生的start0()方法,原生方法是由c或者c++寫的,這里看不到具體的實現:

    public synchronized void start() {
        if (threadStatus != 0)
            throw new IllegalThreadStateException();
        group.add(this);
        boolean started = false;
        try {
          	// 正式的調用native原生方法
            start0();
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
            }
        }
    }

Start0()在底層確實調用了run()方法,並且不是直接調用的,而是啟用了另外一個線程進行調用的,這一點在代碼注釋里面寫得比較清楚,在這里我們就不展開講,我們將關注點放到run()方法上,調用的就是剛剛那個Runnable實現類的對象的run()方法:

    @Override
    public void run() {
        if (target != null) {
            target.run();
        }
    }

繼承Thread類

由於Thread類本身就實現了Runnable接口,所以我們只要繼承它就可以了:

class Thread implements Runnable {
}

繼承之后重寫run()方法即可:

class MyThread extends Thread{
    @Override
    public void run(){
        System.out.println("Hello world");
    }
}
public class Test {
    public static void main(String[] args) {
        Thread thread = new Thread(new MyThread());
        thread.start();
        System.out.println("Main Thread");
    }
}

執行結果和上面的一樣,其實兩種方式本質上都是一樣的,一個是實現了Runnable接口,另外一個是繼承了實現了Runnable接口的Thread類。兩種都沒有返回值,因為run()方法的返回值是void

Callable和FutureTask創建線程

要使用該方式,按照以下步驟:

  • 創建Callable接口的實現類,實現call()方法
  • 創建Callable實現類的對象實例,用FutureTask包裝Callable的實現類實例,包裝成FutureTask的實例,FutureTask的實例封裝了Callable對象的Call()方法的返回值
  • 使用FutureTask對象作為Thread對象的target創建並啟動線程,FutureTask實現了RunnableFutureRunnableFuture繼承了Runnable
  • 調用FutureTask對象的get()來獲取子線程執行結束的返回值
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;

public class CallableTest {
    public static void main(String[] args) throws Exception{

        Callable<String> callable = new MyCallable<String>();
        FutureTask<String> task = new FutureTask<String>(callable);

        Thread thread = new Thread(task);
        thread.start();

        System.out.println(Thread.currentThread().getName());
        System.out.println(task.get());

    }
}

class MyCallable<String> implements Callable<String> {
    @Override
    public String call() throws Exception {
        System.out.println(
                Thread.currentThread().getName() +
                        " Callable Thread");
        return (String) "Hello";
    }
}

執行結果:

main
Thread-0 Callable Thread
Hello

其實這種方式本質上也是Runnable接口來實現的,只不過做了一系列的封裝,但是不同的是,它可以實現返回值,如果我們期待一件事情可以通過另外一個線程來獲取結果,但是可能需要消耗一些時間,比如異步網絡請求,其實可以考慮這種方式。

CallableFutureTask是后面才加入的功能,是為了適應多種並發場景,CallableRunnable的區別如下:

  • Callable 定義方法是call()Runnable定義的方法是run()
  • Callablecall()方法有返回值,Runnablerun()方法沒有返回值
  • Callablecall()方法可以拋出異常,Runnablerun()方法不能拋出異常

線程池啟動線程

本質上也是通過實現Runnable接口,然后放到線程池中進行執行:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " : hello world");
    }
}

public class Test {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 10; i++) {
            MyThread thread = new MyThread();
            executorService.execute(thread);
        }
        executorService.shutdown();
    }
}

執行結果如下,可以看到五個核心線程一直在執行,沒有規律,循環十次,但是並沒有創建出十個線程,這和線程池的設計以及參數有關,后面會講解:

pool-1-thread-5 : hello world
pool-1-thread-4 : hello world
pool-1-thread-5 : hello world
pool-1-thread-3 : hello world
pool-1-thread-2 : hello world
pool-1-thread-1 : hello world
pool-1-thread-2 : hello world
pool-1-thread-3 : hello world
pool-1-thread-5 : hello world
pool-1-thread-4 : hello world

總結一下,啟動一個線程,其實本質上都離不開Runnable接口,不管是繼承還是實現接口。

多線程可能帶來的問題

  • 消耗資源:上下文切換,或者創建以及銷毀線程,都是比較消耗資源的。
  • 競態條件:多線程訪問或者修改同一個對象,假設自增操作num++,操作分為三步,讀取numnum加1,寫回num,並非原子操作,那么多個線程之間交叉運行,就會產生不如預期的結果。
  • 內存的可見性:每個線程都有自己的內存(緩存),一般修改的值都放在自己線程的緩存上,到刷新至主內存有一定的時間,所以可能一個線程更新了,但是另外一個線程獲取到的還是久的值,這就是不可見的問題。
  • 執行順序難預知:線程先start()不一定先執行,是由系統決定的,會導致共享的變量或者執行結果錯亂

並發與並行

並發是指兩個或多個事件在同一時間間隔發生,比如在同1s中內計算機不僅計算數據1,同時也計算了數據2。但是兩件事情可能在某一個時刻,不是真的同時進行,很可能是搶占時間片就執行,搶不到就別人執行,但是由於時間片很短,所以在1s中內,看似是同時執行完成了。當然前面說的是單核的機器,並發不是真的同時執行,但是多核的機器上,並發也可能是真的在同時執行,只是有可能,這個時候的並發也叫做並行。

image-20210511012516227

並行是指在同一時刻,有多條指令在多個處理器上同時執行,真正的在同時執行。

image-20210511012723433

如果是單核的機器,最多只能並發,不可能並行處理,只能把CPU運行時間分片,分配給各個線程執行,執行不同的線程任務的時候需要上下文切換。而多核機器,可以做到真的並行,同時在多個核上計算,運行。並行操作一定是並發的,但是並發的操作不一定是並行的。

關於作者

秦懷,公眾號【秦懷雜貨店】作者,技術之路不在一時,山高水長,縱使緩慢,馳而不息。個人寫作方向:Java源碼解析,JDBC,Mybatis,Spring,redis,分布式,劍指Offer,LeetCode等,認真寫好每一篇文章,不喜歡標題黨,不喜歡花里胡哨,大多寫系列文章,不能保證我寫的都完全正確,但是我保證所寫的均經過實踐或者查找資料。遺漏或者錯誤之處,還望指正。

2020年我寫了什么?

開源編程筆記

150頁的劍指Offer PDF領取


免責聲明!

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



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