Java併發的背景

在操作系統中,併發是指一個時間段中有幾個程序都處於已啓動運行到運行完畢之間,且這幾個程序都是在同一個處理機上運行,但任一個時刻點上只有一個程序在處理機上運行。

併發與操作系統的生命歷程息息相關。進程的出現,使得程序狀態的保存變爲現實,爲進程間的切換提供了可能,實現了操作系統的併發,大大提高資源利用率。雖然進程的出現解決了操作系統的併發問題,但人們對實時性又有了更高的要求。由於一個進程由若干個子任務組成,所以人們就發明了線程,讓每個線程負責一個獨立的子任務,提高程序的響應靈敏度。一個進程雖然包括多個線程,但是這些線程是共同享有進程佔有的資源和地址空間的。因此,雖然多線程提高了資源利用率,保證了實時性,但同時也帶來了包括安全性、活躍性和性能等問題。總的來說,進程讓操作系統的併發性成爲可能,而線程讓進程的內部併發成爲可能。

一. 進程和線程的由來

 (1). 操作系統中爲什麼會出現進程?

  說起進程的由來,我們需要從操作系統的發展歷史談起。
  也許在今天,我們無法想象在很多年以前計算機是什麼樣子。我們現在可以用計算機來做很多事情:辦公、娛樂、上網,但是在 計算機剛出現的時候,是爲了解決數學計算的問題,因爲很多大量的計算通過人力去完成是很耗時間和人力成本的。 在最初的時候,計算機只能接受一些特定的指令,用戶輸入一個指令,計算機就做一個操作。當用戶在思考或者輸入數據時,計算機就在等待。顯然,這樣效率會很低下,因爲很多時候,計算機處於等待用戶輸入的狀態。

  那麼,能不能把一系列需要操作的指令預先寫下來,形成一個清單,然後一次性交給計算機,計算機不斷地去讀取指令來進行相應的操作?就這樣, 批處理操作系統 誕生了。用戶可以將需要執行的多個程序寫在磁帶上,然後交由計算機去讀取並逐個地執行這些程序,並將輸出結果寫到另一個磁帶上。

  雖然批處理操作系統的誕生極大地提高了任務處理的便捷性,但是仍然存在一個很大的問題:

  假如有兩個任務 A 和 B,任務A 在執行到一半的過程中,需要讀取大量的數據輸入(I/O操作),而此時CPU只能靜靜地等待任務A讀取完數據才能繼續執行,這樣就白白浪費了CPU資源。人們於是想,能否在 任務A 讀取數據的過程中,讓 任務B 去執行,當 任務A 讀取完數據之後,讓 任務B 暫停,然後讓 任務A 繼續執行?

  但是這樣就有一個問題,原來每次都是一個程序在計算機裏面運行,也就說內存中始終只有一個程序的運行數據。而如果想要 任務A 執行 I/O操作 的時候,讓 任務B 去執行,必然內存中要裝入多個程序,那麼如何處理呢?多個程序使用的數據如何進行辨別呢?並且,當一個程序運行暫停後,後面如何恢復到它之前執行的狀態呢?

  這個時候,人們就發明了進程,用進程來對應一個程序,每個進程對應一定的內存地址空間,並且只能使用它自己的內存空間,各個進程間互不干擾。並且,進程保存了程序每個時刻的運行狀態,這樣就爲進程切換提供了可能。當進程暫停時,它會保存當前進程的狀態(比如進程標識、進程的使用的資源等),在下一次重新切換回來時,便根據之前保存的狀態進行恢復,然後繼續執行。

  這就是併發,能夠讓操作系統從宏觀上看起來同一個時間段有多個任務在執行。換句話說,進程讓操作系統的併發成爲了可能。注意,雖然併發從宏觀上看有多個任務在執行,但是事實上,任一個具體的時刻,只有一個任務在佔用CPU資源(當然是對於單核CPU來說的)。

(2). 爲什麼會出現線程?

  在出現了進程以後,操作系統的性能得到了大大的提升。雖然進程的出現解決了操作系統的併發問題,但是人們仍然不滿足,人們逐漸對 實時性 有了要求。因爲一個進程在一個時間段內只能做一件事情,如果一個進程有多個子任務,只能逐個地去執行這些子任務。比如,對於一個監控系統來說,它不僅要把圖像數據顯示在畫面上,還要與服務端進行通信獲取圖像數據,還要處理人們的交互操作。如果某一個時刻該系統正在與服務器通信獲取圖像數據,而用戶又在監控系統上點擊了某個按鈕,那麼該系統就要等待獲取完圖像數據之後才能處理用戶的操作,如果獲取圖像數據需要耗費 10s,那麼用戶就只有一直等待。顯然,對於這樣的系統,人們是無法滿足的。

  那麼,可不可以將這些子任務分開執行呢?即,在系統獲取圖像數據的同時,如果用戶點擊了某個按鈕,則會暫停獲取圖像數據,而先去響應用戶的操作(因爲用戶的操作往往執行時間很短),在處理完用戶操作之後,再繼續獲取圖像數據。人們就發明了線程,讓一個線程去執行一個子任務,這樣一個進程就包括了多個線程,每個線程負責一個獨立的子任務。這樣,在用戶點擊按鈕的時候,就可以暫停獲取圖像數據的線程,讓 UI線程 響應用戶的操作,響應完之後再切換回來,讓獲取圖像的線程得到 CPU資源 。從而,讓用戶感覺系統是同時在做多件事情的,滿足了用戶對實時性的要求。

  換句話說,進程讓操作系統的併發性成爲可能,而線程讓進程的內部併發成爲可能。但是要注意,一個進程雖然包括多個線程,但是這些線程是共同享有進程佔有的資源和地址空間的。進程 是操作系統進行資源分配的基本單位,而 線程 是操作系統進行調度的基本單位。

 (3). 多線程併發

  由於多個線程是共同佔有所屬進程的資源和地址空間的,那麼就會存在一個問題:如果多個線程要同時訪問某個資源,怎麼處理? 這個問題就是併發安全性問題。

  此外,可能有朋友會問,現在很多時候都採用多線程編程,那麼是不是多線程的性能一定就由於單線程呢?答案是不一定,要看具體的任務以及計算機的配置。比如說:對於單核CPU,如果是 CPU密集型任務,如解壓文件,多線程的性能反而不如單線程性能,因爲解壓文件需要一直佔用 CPU資源,如果採用多線程,線程切換導致的開銷反而會讓性能下降。但是對於比如交互類型的任務,肯定是需要使用多線程的。而對於多核CPU,對於解壓文件來說,多線程肯定優於單線程,因爲多個線程能夠更加充分利用每個核的資源。

  雖然多線程能夠提升程序性能,但是相對於單線程來說,它的編程要複雜地多,要考慮線程安全問題。因此,在實際編程過程中,要根據實際情況具體選擇。

二. 併發簡史總結

早期的計算機不包含操作系統,它們從頭到尾只執行一個程序,並且這個程序能夠訪問計算機中的所有資源。這對於昂貴且稀有的計算機資源來說是一種浪費;

操作系統的出現使得計算機能同時運行多個程序,不同的程序都在單獨的進程中運行,並且操作系統爲各個獨立執行的進程分配資源( eg: 通過粗粒度時間分片使程序共享資源,如 CPU 等 )。這無疑提高了計算機資源的利用率;

在早期的分時系統中,每個進程的執行都是串行的。串行編程模型的優勢在於其簡單性和直觀性,因爲它每次只做一件事情,做完之後再做另一件。這種串行編程模型仍然存在着計算機資源利用率不高的問題;

促使進程出現的因素同樣也促使着線程的出現。線程允許在同一個進程中同時存在多個程序控制流。線程會共享進程範圍內的資源,但每個線程都有各自的 程序計數器 、 棧 以及 局部變量 等等;

線程也被成爲輕量級進程。在大多數現代操作系統中,都是以線程爲基本的調度單位,而不是進程。如果沒有明確的協同機制,那麼線程將彼此獨立執行。由於同一個進程的所有線程都將共享進程的內存地址空間,因此這些線程都能訪問相同的變量,這就需要實現一種比進程間共享數據粒度更細的數據共享機制。如果沒有明確的同步機制來協同對共享數據的訪問,將造成不可預測的結果。

三. 線程的優勢

  • 解耦、簡化程序開發

  在程序中,如果我們爲每種類型的任務都分配一個專門的線程,那麼可以形成一種串行執行的假象,並將程序的執行邏輯與調度機制的細節,交替執行的操作,異步 I/O 以及資源等待等問題分離開來。通過使用線程,可以將複雜並且異步的工作流進一步分解爲一組簡單並且同步的工作流,每個工作流在一個單獨的線程中運行,並在特定的同步位置進行交互。
  
  Servlet 框架就是一個很好的例子。框架負責解決一些細節問題,包括請求管理、線程創建、負載平衡等,並在正確的時刻將請求分發給正確的應用程序組件(對應的一個具體Servlet)。編寫 Servlet 的開發人員不需要了解有多少請求在同一時刻被處理,也不需要了解套接字的輸入(出)流是否被阻塞。當調用 Servlet 的 service 方法來響應 Web請求時,可以以同步方式來處理這個請求,就好像它是一個單線程的程序。這種方式簡化了組件的開發,大大降低框架學習門檻。

  多線程還有助於用戶界面的靈敏響應。例如,在 Android 開發中,我們常常將網路請求或 I/O 等耗時操作單獨放到一個線程中,以提高響應的靈敏度。

  • 提高資源利用率

  多處理器系統的出現,使得同一個程序的多個線程可以被同時調度到多個 CPU 上運行。因此,多線程程序可以通過提高處理器資源的利用率來提升系統的吞吐率。其實,多線程程序也有助於在單處理器系統上獲得更高的吞吐率(如果程序的一個線程在等待 I/O 操作的完成,另一個線程可以繼續運行,使程序能夠在 I/O 阻塞期間繼續運行)。

四. 線程帶來的風險

  • 安全性問題

  在線程安全性的定義中,最核心的概念就是正確性。當多個線程訪問某個類時,不管運行時環境採用何種調度方式或者這些線程將如何交替執行,並且在主調代碼中不需要任何額外的同步或協同,這個類都能表現出正確的行爲,那麼這個類就是線程安全的。

線程不安全類示例:

@NotThreadSafe 
public class UnsafeSequence { 
    private int value;

    /** Returns a unique value. */
    public int getNext() { 
        return value++; 
    } 
} 

雖然 遞增運算 “value++” 看上去是單個操作,但實際上它包含三個獨立的操作:讀取 value, 將 value 加 1,並將計算結果寫入 value。由於運行時各個線程執行順序的不確定性,可能這段代碼在不同線程的調用中返回相同的數值

  • 活躍性問題

  活躍性問題關注的是:某件正確的事情最終會發生。導致活躍性的問題包括死鎖、飢餓等。

  • 性能問題

  性能問題關注的是:正確的事情能夠儘快發生。性能問題包括多個方面,例如響應不靈敏,吞吐率過低,資源消耗過高等。在多線程程序中,當線程調度器臨時掛起活躍線程並轉而運行另一個線程時,就會頻繁出現上下文切換操作(Context Switch),這種操作會導致 CPU 時間更多的花在線程調度上而非線程的運行上。

五. 線程無處不在

  在 Java 中,一個應用程序對應着一個JVM實例(JVM進程)。Java採用的是 單線程編程模型 ,即在我們自己的程序中如果沒有主動創建線程的話,只會創建一個線程,通常稱爲主線程。但是要注意,雖然只有一個線程來執行任務,不代表JVM中只有一個線程,JVM實例在創建的時候,同時會創建很多其他的線程(比如垃圾收集器線程)。由於Java採用的是單線程編程模型,因此在進行UI編程時要注意將耗時的操作放在子線程中進行,以避免阻塞主線程(在UI編程時,主線程即UI線程,用來處理用戶的交互事件)。

public class Test {
    public static void main(String[] args) {
        // 獲取運行當前代碼的線程的名字
        String curThreadName = Thread.currentThread().getName(); 
        System.out.println(curThreadName);
    }
}/* Output:
        main
 */

總結;

  • 進程是對運行時程序的封裝,可以保存程序的運行狀態,實現操作系統的併發;

  • 線程是進程的子任務,保證程序的實時性;

  • 進程是操作系統資源的分配單位,線程是CPU調度的基本單位;

  • 進程讓操作系統的併發性成爲可能,而線程讓進程的內部併發成爲可能。

 

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