拜託!別再問我多線程的這些問題了

文章來源於公衆號碼農田小齊 ,作者小齊本齊

很多同學面對多線程的問題都很頭大,因爲自己做項目很難用到,但是但凡高薪的職位面試都會問到。。畢竟現在大廠裏用的都是多線程高併發,所以這塊內容不喫透肯定是不行的。

今天這篇文章,作爲多線程的基礎篇,先來談談以下問題:

  1. 爲什麼要用多線程?
  2. 程序 vs 進程 vs 線程
  3. 創建線程的 4 種方式?

爲什麼要用多線程

任何一項技術的出現都是爲了解決現有問題。

之前的互聯網大多是單機服務,體量小;而現在的更多是集羣服務,同一時刻有多個用戶同時訪問服務器,那麼會有很多線程併發訪問。

比如在電商系統裏,同一時刻比如整點搶購時,大量用戶同時訪問服務器,所以現在公司裏開發的基本都是多線程的。

使用多線程確實提高了運行的效率,但與此同時,我們也需要特別注意數據的增刪改情況,這就是線程安全問題,比如之前說過的 HashMap vs HashTableVector vs ArrayList

要保證線程安全也有很多方式,比如說加鎖,但又可能會出現其他問題比如死鎖,所以多線程相關問題會比較麻煩。

因此,我們需要理解多線程的原理和它可能會產生的問題以及如何解決問題,才能拿下高薪職位。

進程 vs 線程

程序 program

說到進程,就不得不先說說程序。

程序,說白了就是代碼,或者說是一系列指令的集合。比如「微信.exe」這就是一個程序,這個文件最終是要拿到 CPU 裏面去執行的。

進程 process

當程序運行起來,它就是一個進程

所以程序是“死”的,進程是“活”的

比如在任務管理器裏的就是一個個進程,就是“動起來”的應用程序。

Q:這些進程是並行執行的嗎?

單核 CPU 一個時間片裏只能執行一個進程。但是因爲它切換速度很快,所以我們感受不到,就造成了一種多進程的假象。(多核 CPU 那真的就是並行執行的了。)

Q:那如果這個進程沒執行完呢?

當進程 A 執行完一個時間片,但是還沒執行完時,爲了方便下次接着執行,要保存剛剛執行完的這些數據信息,叫做「保存現場」。

然後等下次再搶到了資源執行的時候,先「恢復現場」,再開始繼續執行。

這樣循環往復。。

這樣反覆的保存啊、恢復啊,都是額外的開銷,也會讓程序執行變慢。

Q:有沒有更高效的方式呢?

如果兩個線程歸屬同一個進程,就不需要保存、恢復現場了。

這就是 NIO 模型的思路,也是 NIO 模型比 BIO 模型效率高很多的原因,我們之後再講。

線程 thread

線程,是一個進程裏的具體的執行路徑,就是真正幹活的。

在一個進程裏,一個時間片也只能有一個線程在執行,但因爲時間片的切換速度非常快,所以看起來就好像是同時進行的。

一個進程裏至少有一個線程。比如主線程,就是我們平時寫的 main() 函數,是用戶線程;還有 gc 線程是 JVM 生產的,負責垃圾回收,是守護線程

每個線程有自己的 stack,記錄該線程裏面的方法相互調用的關係;

但是一個進程裏的所有線程是共用堆 heap 的。

那麼不同的進程之間是不可以互相訪問內存的,每個進程有自己的內存空間 memeory space,也就是虛擬內存 virtual memory

通過這個虛擬內存,每一個進程都感覺自己擁有了整個內存空間。

虛擬內存的機制,就是屏蔽了物理內存的限制。

Q:那如果物理內存被用完了呢?

用硬盤,比如 windows 系統的分頁文件,就是把一部分虛擬內存放到了硬盤上。

相應的,此時程序運行會很慢,因爲硬盤的讀寫速度比內存慢很多,是我們可以感受到的慢,這就是爲什麼開多了程序電腦就會變卡的原因。

Q:那這個虛擬內存是有多大呢?

對於 64 位操作系統來說,每個程序可以用 64 個二進制位,也就是 2^64 這麼大的空間!

總結

總結一下,在一個時間片裏,一個 CPU 只能執行一個進程。

CPU 給某個進程分配資源後,這個進程開始運行;進程裏的線程去搶佔資源,一個時間片就只有一個線程能執行,誰先搶到就是誰的。

多進程 vs 多線程

每個進程是獨立的,進程 A 出問題不會影響到進程 B;

雖然線程也是獨立運行的,但是一個進程裏的線程是共用同一個堆,如果某個線程 out of memory,那麼這個進程裏所有的線程都完了。

所以多進程能夠提高系統的容錯性 fault tolerance ,而多線程最大的好處就是線程間的通信非常方便。

進程之間的通信需要藉助額外的機制,比如進程間通訊 interprocess communication - IPC,或者網絡傳遞等等。

如何創建線程

上面說了一堆概念,接下來我們看具體實現。

Java 中是通過 java.lang.Thread 這個類來實現多線程的功能的,那我們先來看看這個類。

從文檔中我們可以看到,Thread 類是直接繼承 Object 的,同時它也是實現了 Runnable 接口。

官方文檔裏也寫明瞭 2 種創建線程的方式:

一種方式是從 Thread 類繼承,並重寫 run()run() 方法裏寫的是這個線程要執行的代碼;

啓動時通過 new 這個 class 的一個實例,調用 start() 方法啓動線程。

二是實現 Runnable 接口,並實現 run()run() 方法裏同樣也寫的是這個線程要執行的代碼;

稍有不同的是啓動線程,需要 new 一個線程,並把剛剛創建的這個實現了 Runnable 接口的類的實例傳進去,再調用 start(),這其實是代理模式

如果面試官問你,還有沒有其他的,那還可以說:

  1. 實現 Callable 接口

  2. 通過線程池來啓動一個線程。

但其實,用線程池來啓動線程時也是用的前兩種方式之一創建的。

這兩種方式在這裏就不細說啦,我們具體來看前兩種方式。

繼承 Thread 類

<pre data-tool="mdnice編輯器" style="font-style: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-indent: 0px; text-transform: none; widows: auto; word-spacing: 0px; -webkit-text-size-adjust: auto; -webkit-text-stroke-width: 0px; text-decoration: none; margin: 10px 0px; padding: 0px; max-width: 100%; caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-size: 13.333333015441895px; text-align: left; border-top-left-radius: 5px; border-top-right-radius: 5px; border-bottom-right-radius: 5px; border-bottom-left-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px; box-sizing: border-box !important; word-wrap: break-word !important;">public class MyThread extends Thread { @Override public void run() { for (int i = 0; i < 100; i++) { System.out.println("小齊666:" + i); } } public static void main(String[] args) { MyThread myThread = new MyThread(); myThread.start(); for (int i = 0; i < 100; i++) { System.out.println("主線程" + i + ":齊姐666"); } } } </pre>

在這裏,

  • main 函數是主線程,是程序的入口,執行整個程序;

  • 程序開始執行後先啓動了一個新的線程 myThread,在這個線程裏輸出“小齊”;

  • 主線程並行執行,並輸出“主線程i:齊姐”。

來看下結果,就是兩個線程交替誇我嘛~

Q:爲啥和我運行的結果不一樣?

多線程中,每次運行的結果可能都會不一樣,因爲我們無法人爲控制哪條線程在什麼時刻先搶到資源。

當然了,我們可以給線程加上優先級 priority,但高優先級也無法保證這條線程一定能先被執行,只能說有更大的概率搶到資源先執行。

實現 Runnable 接口

這種方式用的更多。

<pre data-tool="mdnice編輯器" style="font-style: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: auto; text-indent: 0px; text-transform: none; widows: auto; word-spacing: 0px; -webkit-text-size-adjust: auto; -webkit-text-stroke-width: 0px; text-decoration: none; margin: 10px 0px; padding: 0px; max-width: 100%; caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-size: 13.333333015441895px; text-align: left; border-top-left-radius: 5px; border-top-right-radius: 5px; border-bottom-right-radius: 5px; border-bottom-left-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px; box-sizing: border-box !important; word-wrap: break-word !important;">`public class MyRunnable implements Runnable {
@Override
public void run() {
for(int i = 0; i < 100; i++) {
System.out.println("小齊666:" + i);
}
}

public static void main(String[] args) {
    new Thread(new MyRunnable()).start();

    for(int i = 0; i < 100; i++) {
        System.out.println("主線程" + i + ":齊姐666");
    }
}

}` </pre>

結果也差不多:

像前文所說,這裏線程啓動的方式和剛纔的稍有不同,因爲新建的的這個類只是實現了 Runnable 接口,所以還需要一個線程來“代理”執行它,所以需要把我們新建的這個類的實例傳入到一個線程裏,這裏其實是代理模式。這個設計模式之後再細講。

小結

那這兩種方式哪種好呢?

使用 Runnable 接口更好,主要原因是 Java 單繼承。

另外需要注意的是,在啓動線程的的時候用的是 start(),而不是 run()

調用 run() 僅僅是調用了這個方法,是普通的方法調用;而 start() 纔是啓動線程,然後由 JVM 去調用該線程的 run()

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