別後不知君遠近。觸目淒涼多少悶。漸行漸遠漸無書,水闊魚沉何處問。
夜深風竹敲秋韻。萬葉千聲皆是恨。故欹單枕夢中尋,夢又不成燈又燼。《玉樓春·別後不知君遠近》 歐陽修
最近挺喜歡的一首詩。大學裏面學過的《操做系統》和《計算機組成原理》、JVM,在多線程這一點沒法造成一個總體,就只是簡單停留在會用,大概理解這個階段,我是很不喜歡這種感受,因而就打算重寫學習一下線程,讓本身的知識點成體系。html
該如何看待線程呢? 咱們仍是須要先看進程,咱們來回顧一下操做系統的歷史,在好久以前操做系統只能支持跑一個程序,也就是說你不能在聽歌的時候,看文檔。那個時候尚未進程這個概念,很快隨着科學技術的發展,咱們能夠在內存中加載更多的程序,這個時候再用程序這個概念去涵蓋運行中的程序就有點不合適了,由於有可能存在一個程序跑多份,所以咱們須要一個概念來描述運行中的程序,也就是進程。java
在沒有線程以前,進程是操做系統調度的基本單位,進程是一個具備必定的獨立功能的程序在一個數據集合上的一次動態執行過程, 涵蓋了程序執行所須要的的資源和執行流程。這麼說可能有點抽象,舉一個例子,我寫了一個求兩個數中最大值的程序,接收兩個數,而後輸出最大值,這個就是執行流程。在沒有線程以前,一個進程中只有一個執行流程。git
咱們這裏從兩個方面來理解進程:程序員
總結一下:github
線程是進程內部的一條執行流程(開銷比較小),再通俗一點就是幹活的最小單元。
線程也不是隻是徹底是執行流程,也須要必定的消耗,也須要有本身獨享的資源,Java平臺開啓一個線程大體須要消耗1M的內存。除此以外還有操做系統方面的開銷,也就是咱們說的上下文切換。咱們知道現代計算機上沒有線程可以獨佔CPU,線程佔用CPU的時間,咱們稱之爲時間片,每一個線程所能分到時間片是很是小的,那麼隨之而來的問題就是線程中的執行流程執行了一半,時間片耗盡了,CPU去執行另外一個線程了,那麼再輪到這個線程時,咱們確定是不但願再次重頭執行的,這個時候系統是保留了線程的執行進度的,咱們能夠理解爲存檔,再執行到這個線程的時候,就會讀檔,這個讀檔的過程咱們通常稱之爲上下文切換。總是說我大學上操做系統課程的時候,當時這個代碼量還比較少,我還以爲這個概念比較難以理解,後來寫的代碼多了,就忽然理解了,因此說程序是理解計算機的橋樑啊。算法
Java中任何一段代碼老是執行某個線程之中,執行當前代碼的線程就被稱爲當前線程,這和咱們上文討論的是一致的,即線程是進程內部的一條執行流程,幹活的最小單元。Thread.currentThread()能夠返回當前線程,Java程序員很是熟悉的main方法就是被main線程來執行。編程
public class ThreadDemo { public static void main(String[] args) { System.out.println(Thread.currentThread().getName()); } }
寫到這裏可能有同窗會問呢,上面你不是說,線程是進程內部的一條執行流程嘛,那這麼多線程都屬於哪個進程啊,當你啓動main方法的時候事實上是啓動了一個虛擬機進程。緩存
public class ThreadDemo { public static void main(String[] args) throws InterruptedException { // 讓main線程睡10秒,否則main方法執行完畢,JVM進程也結束了 // 這段代碼是當前代碼的執行線程沉睡10秒,main方法被main線程所執行 // 趁他沉睡,咱們用任務管理器去查看後臺的進程 TimeUnit.SECONDS.sleep(10); } }
有同窗可能會問,你不還沒啓動啊,爲何就會有兩個JVM進程了啊,我使用IDEA是一個java語言編寫的,他啓動固然是一個JVM進程,經過描述咱們能夠看到IDEA使用的是Open JDK,另外一個是被其餘服務所使用,像maven。
若是你不信的話,咱們上圖安全
咱們啓動了ThreadDemo以後,就會多出一個java.exe,多線程
Java平臺下咱們該如何建立一個線程呢,或者說main方法中此時有一個執行單元(就是一個方法)比較耗時,咱們但願將這個比較耗時的方法放入一個線程和main線程交替執行。通常來講Java平臺下建立線程有兩種方式:
public class ThreadDemo01 extends Thread{ // 咱們說的最小執行單元 @Override public void run() { System.out.println("繼承方式建立的線程"); System.out.println("繼承方式建立的線程"+"我是比較耗時的操做..... "); } }
public class ThreadDemo02 implements Runnable { @Override public void run() { System.out.println("接口方式建立的線程"); System.out.println("接口方式建立的線程"+"我是比較耗時的操做....."); } }
個人確建立了兩個線程,此時這兩個線程出於新建狀態,那咱們該如何啓動這兩個線程呢? 仍是經過Thread類,假設是繼承方式建立的線程,咱們直接在對應的代碼,調用start方法便可。若是是接口方式建立的線程,那麼就須要將這個執行單元當作參數傳遞給Thread的類,像下面這樣:
public static void main(String[] args) throws Exception { ThreadDemo01 thread01 = new ThreadDemo01(); thread01.start(); ThreadDemo02 threadDemo02 = new ThreadDemo02(); Thread thread02 = new Thread(threadDemo02); thread02.start(); }
經過start()方法, 咱們啓動了這個線程,可是啓動就未必表明這個線程能夠立刻被執行,這取決線程調度器的調度,由操做系統所決定。由此咱們引出線程的生命週期狀態,事實上咱們上文已經暗示過了,執行流程的開始到結束。
線程的狀態,都在圖裏了,一圖勝千言:
上面已經出現了一些線程類經常使用的API,也許你還不知道用處是什麼,不用擔憂,正是下面要介紹的。
演示:
ThreadDemo01 thread01 = new ThreadDemo01(); thread01.start(); // 禮讓 Thread.yield(); // 主線程沉睡10秒 TimeUnit.SECONDS.sleep(10); // 禮讓 //這是主線程調用線程thread01的join方法,那thread01運行完畢,主線程的代碼纔會繼續執行。 thread01.join();
從面向對象編程的角度來看: 建立Thread的子類是一種基於繼承的技術,以Runnable接口爲實例爲構造器參數直接經過new建立Thread實例是一種基於組合的技術。我記得在大學的《軟件工程導論》課程中好像講過慎用繼承,繼承破壞封裝來着,因此通常咱們推薦用過Runnable方式來建立線程,更爲書面化的描述是組合相對於繼承來講,其類與類之間的耦合性更低,所以它也更加靈活。通常咱們咱們認爲組合是優先選用的技術,也就是咱們常說的面向接口編程。
通俗的講你用繼承的方式建立線程,那這個類已經基本和線程綁定了,很差複用。
用Runnable方式建立線程,從對象共享的角度來講,多個線程就能夠同時執行這一個執行單元,而用第二種,假設你想多個線程多作這一件事,那你就得建多個類,這是很直接的好處。
你可能已經聽過一些關於線程的名詞了,父線程、子線程、垃圾回收線程等等,這裏咱們將對這些名詞進行統一的解釋,以方便後文的討論,
按照線程是否阻止Java虛擬機正常中止),咱們能夠將Java中的線程(Daemon Thread)和用戶線程(User Thread, 也稱爲非守護線程)。咱們討論的簡單些,JVM只有在其全部的用戶線程都運行結束才能正常中止, 即用戶線程不執行完,JVM不中止(咱們討論的是比較簡單的),JVM的垃圾回收線程就是一個守護線程,咱們這樣想假設你寫了一個簡單的算法,沒開線程,可是跑完了,垃圾回收線程還在跑,這不是很奇怪嗎?
Java 平臺中的線程不是孤立的,線程與線程之間重視存在一些聯繫。假設線程所執行的代碼建立了線程B,那麼習慣上咱們稱線程B爲線程A的子線程,相應的咱們稱線程A爲線程B的父線程。
咱們對線程的探討也要落到硬件上,既要理論也要聯繫實際。
可能一些人的眼裏,程序的執行是這樣的,程序加載進內存,CPU讀取內存的指令執行,這是一個至關粗糙的模型,雖然內存的讀寫速度已經很快了,但是相對於CPU來講仍是不夠看,若是CPU直接從內存中加載指令並執行,那麼計算機系統的效率相對於如今來講會慢上幾個量級,計算機的存儲系統採起了更聰明的設計,即在處理器和較大較慢的存儲設備(好比內存(有資料稱爲主存,這兩個是同義語))。實際上,每一個計算機系統中的存儲設備都被組織成了一個存儲器層次結構,以下圖所示。
在這個層次結構中,從上至下,設備的訪問速度愈來愈慢,容量愈來愈大,而且每字節的造價愈來愈便宜。存儲器的層次結構的主要思想就是上一層的存儲器做爲低一層存儲器的高速緩存。
通常來講現代CPU都是多核的,你很難找到單核的CPU,咱們研究的基本單位也是多核CPU,CPU一直在計算工做,咱們上面將線程視做一個執行單元,這個執行單元當被線程器調度器選中的時候,系統會將該執行單元所需的資源從內存逐步加載到CPU的寄存器中,通常來講典型的流程是根據存儲結構,CPU會先從寄存器找,找不到會從L1緩存,L1找不到從L2緩存....。
計算結果最終會被寫入到L3緩存,再由L3緩存移入內存中。假設是兩個線程呢,共享一個變量,這就可能會出現緩存不一致的狀況,由於可能第一個線程在完成計算以後,還來得及將計算結果刷新到主存,另外一個線程被分配到了另外一個核心上執行,讀取的仍是還未更新的變量,這就是緩存不一致問題。
有同窗可能會問單核CPU沒有緩存不一致問題,那是否是在單核CPU上,多線程就是沒問題的啊? 單核CPU的確不會出現緩存不一致的問題,CPU將某塊內存加載到緩存後,不一樣線程在訪問相同的物理地址的時候,都會映射到相同的緩存位置,這樣即便發生線程的切換,緩存仍然不會失效。可是回想一下咱們上面討論的上下文切換,線程A執行一半後,時間片耗盡,線程內部的寄存器會保留現場,也就是咱們上文說的存檔,結果還沒從緩存中刷新到主存,只是暫存於線程內部。而後B線程開始執行,CPU中依次從緩存中加載共享變量,咱們姑且假定線程A的計算結果還沒刷新至緩存,而後線程B接着計算,依然會存在問題。
上面提到的在CPU和主存之間增長緩存,在多核多線程的場景下會存在緩存一致性問題。除此以外爲了加速程序的執行,通常的高級語言的編譯器還會對程序對應的指令進行重排,編譯器重排以後,CPU爲了加速執行也不必定會按編譯器重排後的指令按序執行,也就是亂序執行。亂序執行和亂序執行會牽扯到操做系統和硬件執行的知識,不是本篇討論的重點,這兩個點咱們目前只作簡單介紹,後面會結合例子或者專門開一篇文章來說。
緩存不一致、亂序執行、指令重排序各位可能會感到有些陌生,可是若是我說原子性、有序性、可見性可能各位就會相對來講熟悉一點了,咱們將上面的緩存不一致、亂序執行、指令重排序抽象出來就是原子性、有序性、可見性。
可能你仍是有點懵,不過不用着急,這三個概念咱們會結合例子,進行一一介紹。
下面是一個用兩個線程買票的例子:
public class TicketSell implements Runnable { // 總共一百張票 private int total = 2000; @Override public void run() { while (total > 0) { System.out.println(Thread.currentThread().getName() + "正在售賣:" + total--); } } }
public static void main(String[] args) { TicketSell ticketSell = new TicketSell(); Thread a = new Thread(ticketSell, "a"); Thread b = new Thread(ticketSell, "b"); a.start(); b.start(); }
運行結果截圖:
先說一下,不一樣的操做系統調度機制不一樣,假如你運行和我同樣的代碼,跑不出和同樣的結果,也在情理之中。也許你的是出現a和b都賣了1998這張票,也許是其餘的。通常來講咱們都會認爲這是不正常的,由於咱們認爲兩個線程應該是協做幹活,不該該出現兩個線程同時賣一張票的結果,爲何咱們會有這種想固然呢?咱們先不落實的具體的計算機上,咱們先將這個賣票放在現實場景來分析,一樣作一個比較粗糙的假定,尚未偉大的程序員們爲他們作售票系統,是兩個賣票員在一個房間裏兩個售票口,有一張桌子上面放了一堆票,有人來賣票員去桌子上看,還有沒有票,有的話,將票遞給乘客。
這其中其實就暗含了售票員在取票的時候是不能夠被打斷的,不存在說拿了一半這種狀況,也就是原子性,還有就是售票員A在拿了一張票以後,售票員B再去拿票以後立馬能看售票員拿票以後的結果,也就是可見性。咱們潛意識裏面多線程共享一個變量的時候,拿票操做是原子性的,拿票以後,另外一個線程也能立刻可以看到上一個線程的操做結果。
上面的運行結果中出現兩個線程同時賣出第2000張票的緣由就在於,雖然是兩個線程共享兩千張票,可是拿票過程是能夠被打斷的,好比a線程剛進來,讀取到當前的票數,時間片耗盡了,輪到b線程了,b線程也進來開始讀取當前的票數。在好比一個拿票動做,在線程看來分紅三步,第一步讀取票數(從內存中將變量加載到緩存中)、第二步CPU執行遞增操做第三步將執行後的結果刷新到主存中,也是能夠打斷的。若是是多核CPU,線程a執行完計算,線程b能夠讀取到該線程的更新結果,那麼咱們就稱這個線程對該共享變量的更新對其餘線程可見。
Java語言規範規定,父線程在啓動子線程以前對共享變量的更新對於子線程來講是可見的。
相對於原子性、可見性來講,有序性相對來講稍微有點難以理解,由於有序性相對來講更面向機器,貼近硬件,難以被感知。
順序結構是結構化編程中的一種基本結構,它表示咱們但願某個操做必須先於另一個操做得以執行。另外兩個操做即使是能夠用任意一種順序執行,可是反應在代碼上兩個操做也老是有前後順序。可是在多核處理器的環境下,這種操做執行順序多是沒有保障的,編譯器可能改變兩個操做的前後順序;處理器可能不是徹底按照目標代碼所指定的順序執行指令,另外,一個處理器上執行的多個操做,從其餘處理器的角度來看其順序可能與目標代碼指定的順序不一致。這種現象,咱們稱之爲重排序。
一圖勝千言:
咱們知道咱們的程序最終仍是要被CPU執行的,一個java程序首先要變成字節碼,而後被在運行的時候由JIT(Just-In-Time)編譯器將字節碼翻譯成本地機器代碼,若是某段代碼被調用的次數過分,也就是熱點代碼,JIT編譯器就會將該段代碼翻譯成本地代碼並緩存起來,下次運行的時候就無須再翻譯。
關於有序性的一個經典例子:
TicketSell ticketSell = new TicketSell();
產生對象一般狀況下是三步:
用僞碼來表示就是 objRef = allocate(TicketSell.class);
JIT編譯器(JVM中負責將字節碼解釋成對應平臺字節碼的一個組件),並非每次都是按上述順序去生成對應的機器碼,在產生對象比較頻繁的狀況下,順序多是1,3,2。若是是這種狀況調用的由於調用對應對象的方法可能就會出現問題,覺得對象尚未徹底被初始化。
《Java多線程編程實戰指南》的JITReorderingDemo就跑出來了指令重排序,從設計上來看十分的精巧,用到了線程協做的知識,本篇咱們不講線程同步與線程協做,相識篇(我寫博客通常相同的主題大多都拆成三篇: 初遇、相識、甚歡)講,每一篇博客都有對應的主題,講完線程同步和線程協做,會專門開一篇博客講JITReorderingDemo的設計思想
下載地址以下:
http://www.broadview.com.cn/b...
事實上不只能跑出來,咱們也能夠經過一些工具看JIT編譯器生成的機器碼來證實,有一款工具叫hsdis,下載地址以下:
https://github.com/liuzhengya...
有興致的同窗能夠本身下載下來玩一玩。
我記得我以前在學習單例模式的時候,測試指令重排序,測不出來,緣由可能在於併發仍是有點小吧。
爲了解決線程安全問題,Java在引入線程的同時也引入了線程同步機制:
爲了讓線程們之間更好的配合工做,Java也引入一套相關類:
很多多線程入門可能會告訴你,引入多線程是爲了充分利用多核處理器的資源,我認爲這是一種錯誤的說法,由於在沒有多線程之間,多進程照樣是併發執行的,照樣也是充分的利用了多核處理器的資源,我認爲更直接的優點是相對於多進程,多線程共享變量更爲簡單,建立線程相對於進程更節省資源,這纔是更直接的緣由,我記得大學時期,學習操做系統的時候,也是將線程拎出來說的。
線程是一個執行單元,負責執行對應的任務,在沒有線程之間,進程是最小的執行單元,在引入線程以後,進程的地位發生了變化,進程原來的執行邏輯被移動到了線程身上,在沒有線程以前,進程就像只有老闆的公司,在有了線程以後,老闆就將活轉移到了打工人身上。
最基本的有兩種: 繼承Thread,重寫run方法,在Runable方法裏面寫你想委託線程作的事情。
實現Runnable接口,重寫run方法,而後將Runnable實現類的實例當作參數傳遞給Thread類的構造函數
參考資料: