java內存模型和線程

併發不必定依賴多線程,可是在java裏面談論併發,大多與線程脫不開關係。java

線程是大可能是面試都會問到的問題。咱們都知道,線程是比進程更輕量級的調度單位,線程之間能夠共享內存。以前面試的時候,也是這樣回答,迷迷糊糊,沒有一個清晰的概念。node

大學的學習的時候,寫C和C++,本身都沒有用過多線程,看過一個Windows編程的書,裏面講多線程的時候,一大堆大寫的字母,看着一點都不爽,也是慚愧。後來的實習,寫unity,unity的C#使用的是協程。只有在作了java後端以後,才知道線程究竟是怎麼用的。瞭解了java內存模型以後,仔細看了一些資料,對java線程有了更深刻的認識,整理寫成這篇文章,用來之後參考。python

1 Java內存模型

Java虛擬機規範試圖定義一種java內存模型來屏蔽掉各類硬件和操做系統的內存訪問差別,以實現讓java程序在各類平臺下都能達到一致性內存訪問的效果。linux

java內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量的底層細節。(這裏所說的變量包括了實例字段、靜態字段和數組等,但不包括局部變量與方法參數,由於這些是線程私有的,不被共享。)golang

1.1 主內存和工做內存

java規定全部的變量都存儲在主內存。每條線程有本身的工做內存面試

線程的工做內存中的變量是主內存中該變量的副本,線程對變量的全部操做都必須在工做內存中進行,而不能直接讀寫主內存中的變量。不一樣線程間也沒法直接訪問對方工做內存中的變量,線程間變量值的傳遞須要經過主內存來完成。編程

1.2 內存之間的交互

關於主內存和工做內存之間的具體交互協議,java內存模型定義了8中操做來完成,虛擬機實現的時候必須保證每一個操做都是原子的,不可分割的(對於long和double有例外)windows

  • lock鎖定:做用於主內存變量,表明一個變量是一條線程獨佔。
  • unlock解鎖:做用於主內存變量,把鎖定的變量解鎖。
  • read讀取:做用於主內存變量,把變量值從主內存傳到線程的工做內存中,供load使用。
  • load載入:做用工做內存變量,把上一個read到的值放入到工做內存中的變量中。
  • use使用:做用於工做內存變量,把工做內存中的一個變量的值傳遞給執行引擎。
  • assign:做用於工做內存變量,把執行引擎執行過的值賦給工做內存中的變量。
  • store存儲:做用於工做內存變量,把工做內存中的變量值傳給主內存,供write使用。

這些操做要知足必定的規則。後端

1.3 volatile

volatile能夠說是java的最輕量級的同步機制。數組

當一個變量被定義爲volatile以後,他就具有兩種特性:

  • 保證此變量對全部線程都是可見的

    這裏的可見性是指當一個線程修改了某變量的值,新值對於其餘線程來說是當即得知的。而普通變量作不到,由於普通變量須要傳遞到主內存中才能夠作到這點。

  • 禁止指令重排

    對於普通變量來講,僅僅會保證在該方法的執行過程當中全部依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操做的順序與程序代碼中的執性順序一致。

    若用volatile修飾變量,在編譯時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。

volatile對於單個的共享變量的讀/寫具備原子性,可是像num++這種複合操做,volatile沒法保證其原子性。

1.4 long和double

long和double是一個64位的數據類型。

虛擬機容許將沒有被volatile修飾的64位變量的讀寫操做分爲兩次32位的操做來進行。所以當多個線程操做一個沒有聲明爲volatile的long或者double變量,可能出現操做半個變量的狀況。

可是這種狀況是罕見的,通常商用的虛擬機都是講long和double的讀寫當成原子操做進行的,因此在寫代碼時不須要將long和double專門聲明爲volatile。

1.5 原子性、可見性和有序性

java的內存模型是圍繞着在併發過程當中如何處理原子性、可見性和有序性。

原子性

基本數據類型的訪問讀寫是劇本原子性的。

若是須要一個更大範圍的原子性保證,java提供了lock和unlock操做,對應於寫代碼時就是synchronized關鍵字,所以在synchronized塊之間的操做也是具有原子性的。

可見性

可見性是指當一個線程修改到了一個共享變量的值,其餘的線程可以當即得知這個修改。共享變量的讀寫都是經過主內存做爲媒介來處理可見性的。

volatile的特殊規則保證了新值能夠當即同步到主內存,每次使用前當即從主內存刷新。

synchronized同步塊的可見性是由」對於一個變量unlock操做以前,必須先把此變量同步回內存中「來實現的。

final的可見性是指被final修飾的字段在構造器中一旦初始化完成,而且構造器沒有把this的引用傳遞出去,那麼在其餘線程中就能看見final字段的值。

有序性

若是在本線程內觀察,全部的操做都是有序的;若是在一個線程內觀察另外一個線程,全部的操做都是無序的。
volatile關鍵字自己就包含了禁止指令重排的語義,而synchronized則是由「一個變量在同一時刻只容許一條線程對其進行lock操做」這條規則來實現有序性的。

1.6 先行發生原則

若是java內存模型中的全部有序性都是靠着volatile和synchronized來完成,那有些操做將會變得很繁瑣,可是咱們在寫java併發代碼的時候沒有感覺到這一點,都是由於java有一個「先行發生」原則。

先行發生是java內存模型中定義的兩項操做之間的偏序關係,若是說操做A先發生於操做B,其實就是說在發生B以前,A產生的影響都能被B觀察到,這裏的影響包括修改了內存中共享變量的值、發送了消息、調用了方法等等。

  • 程序次序規則

    在一個線程內,按程序代碼控制流順序執行。

  • 管程鎖定規則

    unlock發生在後面時間同一個鎖的lock操做。

  • volatile變量規則

    volatile變量的寫操做發生在後面時間的讀操做。

  • 線程啓動規則
  • 線程終止規則
  • 線程中斷規則
  • 對象終結規則

    一個對象的初始化完成在finalize方法以前。

  • 傳遞性

    若是A先行發生B,B先行發生C,那麼A先行發生C。

因爲指令重排的緣由,因此一個操做的時間上的先發生,不表明這個操做就是先行發生;一樣一個操做的先行發生,也不表明這個操做一定在時間上先發生。

2 Java線程

2.1 線程的實現

主流的操做系統都提供了線程的實現,java則是在不一樣的硬件和操做系統的平臺下,對線程的操做提供了統一的處理,一個Thread類的實例就表明了一個線程。Thread類的關鍵方法都是native的,因此java的線程實現也都是依賴於平臺相關的技術手段來實現的。

實現線程主要有3種方式:使用內核線程實現,使用用戶線程實現和使用用戶線程加輕量級進程實現。

2.1.1 使用內核線程實現

內核線程就是直接由操做系統內核支持的線程,這種線程由內核來完成線程的切換,內核經過操縱調度器對線程進行調度,並負責將線程的任務映射到各個處理器上。

程序通常不會直接去調用內核線程,而是使用內核線程的一個高級接口——輕量級進程(Light Weigh Process),LWP就是咱們一般意義上所說的線程。

因爲每一個輕量級進程都由一個內核線程支持,這種輕量級進程與內核線程之間1:1的關係成爲一對一線程模型。

侷限性

雖然因爲內核線程的支持,每一個輕量級進程都成爲了一個獨立的調度單元,即便有一個阻塞,也不影響整個進程的工做,可是仍是有必定的侷限性:

  • 系統調用代價較高

    因爲基於內核線程實現,因此各類線程的操做都要進行系統調用。而系統調用的代價比較高,須要在用戶態和內核態來回切換。

  • 系統支持數量有限

    每一個輕量級進程都須要一個內核線程支持,須要消耗必定的內核資源,因此支持的線程數量是有限的。

2.1.2 使用用戶線程實現

指的是徹底創建在用戶空間的線程庫上,系統內核不能感知線程存在的實現。用戶線程的創建、同布、銷燬和調度徹底在用戶態中完成,不須要內核幫助。

若是程序實現得當,則這些線程都不須要切換到內核態,操做很是快速消耗低,能夠支持大規模線程數量。這種進程和用戶線程之間1:N的關係成爲一對多線程模型。

侷限性

不須要系統內核的,既是優點也是劣勢。因爲沒有系統內核支援,全部的操做都須要程序去處理,因爲操做系統只是把處理器資源分給進程,那「阻塞如何處理」、「多處理器系統如何將線程映射到其餘處理器上」這類問題的解決十分困難,因此如今使用用戶線程的愈來愈少了。

2.1.3 使用用戶線程加輕量級進程混合實現

在這種混合模式下,既存在用戶線程,也存在輕量級進程。

用戶線程仍是徹底創建在用戶空間中,所以用戶線程的建立、切換、析構等操做依然廉價,並且支持大規模用戶線程併發、而操做系統提供支持的輕量級進程則做爲用戶線程和內核線程之間的橋樑,這樣可使用內核提供的線程調度和處理器映射,而且用戶線程的系統調用要經過輕量級進程來完成,大大下降了整個進程被徹底阻塞的風險。

在這種模式下,用戶線程和輕量級進程數量比不固定N:M,這種模式就是多對多線程模型。

2.1.4 java線程的實現

目前的jdk版本中,操做系統支持怎樣的線程模型,很大程度上就決定了jvm的線程是怎麼映射的,這點在不一樣的平臺沒辦法打成一致。線程模型只對線程的併發規模和操做成本產生影響,對編碼和運行都沒什麼差別。

windows和linux都是一對一的線程模型。

2.2 線程調度

線程的調度是指系統爲線程分配處理器使用權的過程,主要的調度方式有兩種:協同式線程調度和搶佔式線程調度。

2.2.1 協同式線程調度

線程的執性時間由線程自己來控制,線程把本身的工做執性完了以後,要主動通知系統切換到另一個線程上。Lua的協程就是這樣。

好處

協同式多線程最大的好處就是實現簡單。

因爲線程要把本身的事情幹完以後才進行線程切換,切換操做對線程是剋制的,因此沒有什麼線程同步的問題。

壞處

壞處也很明顯,線程執行時間不可控。甚至若是一個線程寫的問題,一直不告訴系統切換,那程序就會一直阻塞。

2.2.2 搶佔式線程調度

每一個線程由系統分配執行時間,線程的切換不是又線程自己來決定。

使用yield方法是可讓出執行時間,可是要獲取執行時間,線程自己是沒有什麼辦法的。

在這種調度模式下,線程的執行時間是系統可控的,也就不會出現一個線程致使整個進程阻塞。

2.2.3 java線程調度

java使用的是搶佔式線程調度。

雖然java的線程調度是系統來控制的,可是能夠經過設置線程優先級的方式,讓某些線程多分配一些時間,某些線程少分配一些時間。

不過線程優先級仍是不太靠譜,緣由就是java的線程是經過映射到系統的原生線程來實現的,因此線程的調度仍是取決於操做系統,操做系統的線程優先級不必定和java的線程優先級一一對應。並且優先級還可能被系統自行改變。因此咱們不能在程序中經過優先級來準確的判斷先執行哪個線程。

2.3 線程的狀態轉換

看到網上有好多種說法,不過大體也都是說5種狀態:新建(new)、可運行(runnable)、運行(running)、阻塞(blocked)和死亡(dead)。

而深刻理解jvm虛擬機中說java定義了5種線程狀態,在任一時間點,一個線程只能有其中的一種狀態:

  • 新建new
  • 運行runnable

    包括了操做系統線程狀態的running和ready,也就是說處於此狀態的線程可能正在執行,也可能正在等待cpu給分配執行時間。

  • 無限期等待waiting

    處於這種狀態的線程不會被cpu分配執行時間,須要被其餘線程顯示喚醒,可以致使線程陷入無限期等待的方法有:

    • 沒有設置timeout參數的wait方法。
    • 沒有設置timeout參數的join方法。
    • LockSupport.park方法。
  • 限期等待timed waiting

    處於這種狀態的線程也不會被cpu分配執行時間,不過不須要被其餘線程顯示喚醒,是通過一段時間以後,被操做系統自動喚醒。可以致使線程陷入限期等待的方法有:

    • sleep方法。
    • 設置timeout參數的wait方法。
    • 設置參數的join方法。
    • LockSupport.parkNanos方法。
    • LockSupport.parkUntil方法。
  • 阻塞blocked

    線程被阻塞了。在線程等待進入同步區域的時候是這個狀態。

    阻塞和等待的區別是:阻塞是排隊等待獲取一個排他鎖,而等待是指等一段時間或者一個喚醒動做。

  • 結束terminated

    已經終止的線程。

3 寫在最後

併發處理的普遍應用是使得Amdahl定律代替摩爾定律成爲計算機性能發展源動力的根本緣由,也是人類壓榨計算機運算能力的最有力武器。有些問題使用越多的資源就能越快地解決——越多的工人蔘與收割莊稼,那麼就能越快地完成收穫。可是另外一些任務根本就是串行化的——增長更多的工人根本不可能提升收割速度。

咱們使用線程的重要緣由之一是爲了支配多處理器的能力,咱們必須保證問題被恰當地進行了並行化的分解,而且咱們的程序有效地使用了這種並行的潛能。有時候良好的設計原則不得不向現實作出一些讓步,咱們必須讓計算機正確無誤的運行,首先保證併發的正確性,纔可以在此基礎上談高效,因此線程的安全問題是一個很值得考慮的問題。

雖然一直說java很差,可是java帶給個人影響確實最大的,從java這個平臺裏學到了不少有用的東西。如今golang,nodejs,python等語言,每一個都是在一方面能秒java,但是java生態和java對軟件行業的影響,是沒法被超越的,java這種語言,從出生到如今幾十年了,基本上每次軟件技術的革命都沒有落下,每次都以爲要死的時候,突然間柳暗花明,枯木逢春。咳咳,扯遠了。

相關文章
相關標籤/搜索