1. 程式人生 > >多線程和虛擬機的宏觀理解

多線程和虛擬機的宏觀理解

調用 邏輯 難受 需要 變量聲明 isp 你會 jdk1 定制

作者:賀拔達奚
鏈接:https://www.zhihu.com/question/59725713/answer/168709945
來源:知乎
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請註明出處。

###
多線程和虛擬機。實際工作中,大部分程序員可能幾乎不用,但這兩項技能是你面試所謂高級工程師的敲門磚,也是你在機會到來的時候能否頂上去的彈藥庫。很多人,把這兩部看的太高深,望而卻步,我覺得一個重要原因就是大部分博客和書籍寫的太差,只講結果不談背景。比如,講到虛擬機,上來就以hotspot為例,內存模型,各種分區、回收算法;講到多線程,上來就各種synchronized關鍵字、各種鎖、線程池怎麽用。新手看到就蒙了。要知道,一切技術的出現都是有背景的。所有技術的出現都是基於計算機原理和體系結構的。為了解決特定問題,人們基於計算機理解的語言才創造了各種解決問題的方法,也就是說這些解決方案不過是踐行某種思想的一種體現罷了。

先說虛擬機,我們都知道Java程序運行在虛擬機上,虛擬機又和操作系統打交道,最終通過二進制指令操縱電子電路運行。完成數據的讀取,存儲,運算和輸出。
虛擬機在加載.class文件的時候,會在內存開辟一塊區域“方法區”,專門用來存儲類的基本信息,同時在“堆”區為這些類生成一個Class對象,作為類的“鏡像”或“模具”,為反射提供基礎。程序運行過程中,對象不斷的生成和死亡,有的朝生暮死(大多數對象都這樣,最常見的是方法內部生成的臨時對象),有的壯年而亡,有的長命百歲,有的長生不死除非世界毀滅(虛擬機關閉,典型的如servlet)。對象生要吃喝,死了得埋,所以虛擬機就不停的申請內存、回收內存。對象的生成方法很多,new、反射等,對象回收的方法也有很多,這就是GC,標記-清除、復制、標記-整理等等。

垃圾回收,顧名思義,得確定垃圾是什麽、在那裏、如何回收。對象的生命周期不同,回收的方法不一樣。假如讓你設計垃圾回收,你該怎麽做?大多數人都會想到,後臺啟動一個線程,隔一段時間(或達到某種狀態,去堆用掉了80%),掃描垃圾對象,然後清除,然後繼續執行原來的程序(串行收集器)。恭喜你,你也可以設計虛擬機了。但不幸的是,情況往往比你想象的復雜。效率、安全性、對原程序的影響,都是你要考慮的。人們最先發現,對象生命周期不同,用同一種GC方法,實在是效率差,怎麽辦?就如hotspot的方案,堆區根據對象生命周期不同,分成了Eden、Survivor0、Survivor1和Old區。每個區采用了不同的清理算法。多核的出現,自然人們會想到並行收集器,即多個回收線程一起跑;為了將對原程序影響降到最低(STW),又出現了並發收集器。這些,本質上,就是抽象分層思想的體現。類似於,重構代碼中的,抽離屬性和抽離方法。這種思想,我認為是計算機最重要的思想。可以講三天三夜。如分布式服務中,根據業務模型,分拆用戶服務、商品服務、訂單服務。

到此為止,虛擬機優化就涉及到兩大方面,各個區的大小怎麽劃分最優、垃圾回收算法怎麽選擇最優。直接點,就是JVM參數調整。但關鍵在於,給你一個系統(可能是一個陌生的系統,我說的陌生可能就是你開放的系統,只是每個人負責的只是一個模塊,對系統整體不熟悉),你怎麽樣能恰當估算系統業務情況,進而有針對性的收集系統數據,根據場景,確定優化的方向點,然後找到這個點對應的虛擬機參數,調整參數,或者,優化代碼。註意,一切優化必須基於業務模型。不同業務系統、甚至同一套系統不同用戶基數調整的方向都不一樣。平時,我遇到的情況大概分為兩種,一種是堆的問題,比如代碼問題導致List或map越來越大,或者是string使用不當,造成頻繁old gc;某個外部組件調用,生成大量代理類無法銷毀。還有一種是線程棧,線程阻塞甚至死鎖的問題。多線程使用不當,比不使用還坑爹。

多線程,任何一個程序員都知道,但實際工作中,大部分程序員每天面對的基本是業務問題的CRUD和Bug定位,貌似沒有直接接觸多線程的機會。

大家知道程序運行的時候,最關鍵的是內存和cpu,而cpu運算的時候,是要從內存取值,當然很多時候是從緩存取值的,然後放入寄存器,參與運算,得到結果,先放入寄存器,然後放入內存。程序執行的指令也放在寄存器,它記錄了當前程序執行的地址。用一句話概括:程序=數據結構+算法。CPU運算需要知道,我要執行什麽程序、我的程序數據怎麽獲取。

大家應該看出問題來了吧?首先,線程執行是語言指令寄存器的,也就是當你切換線程的時候,得從虛擬機的程序計數器(PC)把該線程的執行指令放到指令寄存器,當然線程涉及的其他資源也要切換,比如IO設備。這些都是需要耗費資源的,這就是所謂的線程上下文切換。大學時候,記得很清楚的一句話:線程是CPU執行的最小單位。當時沒怎麽理解,後來想CPU執行程序,總得知道執行什麽吧,那得準備指令寄存器的值,原材料得有吧,就可能涉及文件系統、網絡資源吧,運算結果得輸出到內存、文件或者網絡吧。這些都是資源啊。所以,線程創建是一筆很大的開銷。當然,如果你就一個線程,那就無所謂了,反正資源都是我的,想怎麽用就怎麽用。所以,很多時候,單線程比多線程快。

很多面試寶典,有這麽一道題:Java線程的start和run方法有什麽區別?通過我上面關於線程執行的分析,應該一目了然。我用一個做飯的例子說明,start需要你買菜、準備鍋碗瓢盆油鹽醬醋、洗菜切肉,而run則是往鍋裏放油放菜炒。大家可以看到,Thread源碼的start0是個native方法,也就是資源準備是虛擬機幫你做了。你不用管我菜是怎麽買的、價錢多少。當然了,如果菜市場很遠,一直沒買到,或者排隊很長,甚至被別人插隊,那你這頓飯就一直做不上。這就是所謂的線程阻塞了。如果兩個廚師都在做飯,一個拿著醬油想要醋,一個拿著醋想要醬油,互不相讓,就出現所謂的死鎖。不好意思,扯遠了。關於start和run,如果把方法名改為:applyResourceAndPerformAction和doConcreteActions,是不是很容易理解?很多人面試的時候,背一下寶典,原理根本不清楚。你能指望他處理復雜問題?線程必須的資源虛擬機幫你做了,你需要的就是告訴線程你具體做什麽,所以實現線程的幾種方式就有了,1、繼承Thread目的重寫run方法;2、實現Runnable接口,實現run方法;3實現Callable接口,回調獲取線程結果。1使用了繼承,2和3使用了組合,內部持有了你所實現的類,更加靈活。你看,多用組合少用繼承的原則就這麽體現了。

第二點,上面說到了,一個數值,進入CPU運算,經過了內存、多級緩存、寄存器,也就是說,當多線程運算同一個值的時候,是需要把值從主內存拿到該線程工作內存(寄存器)中的,當一個線程計算完畢(CPU首先把運算結果放到寄存器),還沒刷新到主內存的時候,另一個線程從主內存取到的是舊值。JVM運行的每個線程都有自己的線程棧,不同線程運行的時候,都要復制主內存的一份副本到工作內存。怎麽保證每個線程拿到的數據是最新的,這就是同步機制。volatile和synchronized,就是為了解決這個問題的。

首先,誰都能想到的最直接的辦法就是:共享變量同一時刻只允許有一個線程操作。這樣就保證了所有線程要麽拿不到值,要麽拿到的值是“純粹”的。於是有了synchronized,用來告訴虛擬機:這個地方是聖地,不允許多個人同時涉足。這裏有一把鎖,必須拿到鎖才能進入,其他人要想進來必須等待。Java中的鎖,可以是this對象、方法、類,也可以是聲明的某個變量。鎖的範圍,可以是小塊代碼段,可以是整個方法區,甚至是所有方法。一定要註意鎖和鎖的範圍,這是兩個維度的事情。虛擬機會在鎖對象和線程之間建立聯系,其他線程跑到鎖對象的時候,會看到:哦,其他哥們已經來了,我先等著吧。特別註意,不要以為對象和類的定義一樣,不過是屬性和方法的集合,類和對象是兩回事。類似模具和產品的關系。虛擬機生成一個對象,這個對象有很多額外信息,起碼有對象內存地址你是知道的吧?所以,要標識這個對象當前被哪個線程占有,是一件很容易的事情。感興趣的同學,可以去看看對象在內存中的布局。

我們很快發現,上面的方法有點粗暴,也不夠靈活。很多時候,我們不關心共享值在被誰操作,我只關當前這個值“到底”是什麽。所以,就有了volatile,大部分博客提到volatile,就一句話:保證可見性,不保證原子性。這什麽鬼?實際上,如果一個共享變量聲明為volatile,等於告訴虛擬機控制的所有線程:這個變量有點帥,要請他出山必須親自去他老家——主內存去請,回來的時候也要盡快送回老家。所以,CPU計算的時候要從主內存取值,計算完畢,直接就寫入主內存,不會寫到高速緩存了。這就是所謂的“可見性”,也就是當前這個值是什麽,你是完全知道的。至於不保證原子性,就很明顯了,這個值誰都可以取來運算,從計算機角度來講,跟普通變量的區別就在於:效率差了。因為寫入和讀取高速緩存,效率遠遠高於內存。一路題外話,不要以為數據庫插入數據就直接到磁盤了,其實寫入的也是緩存,由後臺線程刷到磁盤的。這樣既可以起到緩沖的作用,又可以提高效率。不然你以為怎麽能那麽快。其實,從底層到高層,從硬件到軟件,很多原理都是相通的。

————————————
感謝朋友們的認可和指正。本文是有感而發,因為看過了太多坑人的博客和書籍,感慨自己走過的彎路,不希望其他初學者被網上互相抄襲的博客和東拼西湊的書籍浪費時間,想以一個相對宏觀的視野來描述一個概念,力求通俗易懂,所以沒有深入太多細節,簡化了很多模型,給部分朋友造成了疑惑,說聲抱歉。也沒有配圖,都是抽時間手機碼字,打個分割線都費勁,圖呢,其實網上都有。

記得我在另外一篇答案中提到,計算機程序(不僅僅各種語言的代碼,一切能向計算機發出指令的序列都是程序,當然包括Java虛擬機)的努力方向:最大化利用計算機資源。多線程就是如此,一個CPU密集型的任務在跑,你讓IO幹等著,這不是浪費嗎?所以,這時候你啟動一個IO密集型的任務,資源利用率就提升了。當然,這是一種簡化模型,實際上一個人任務的不同階段,需要的計算機資源是不同的,如果你能合理安排多個任務的執行邏輯,資源利用率就會很大提升。

我們學習程序語言,一定不要被束縛到語言細節和規範上去,而要從計算機邏輯執行層面思考問題。因為細節和規範都是人為設定的,是大牛抽象計算機邏輯後的加工品,你囿於此,其實是在理解別人的思想,而不是理解計算機。我們常說的高層依賴於抽象而不依賴於底層,是一樣的意思。說了這麽多,想表達的就是,對技術問題,要有思考的深度,要尋根溯源,要高屋建瓴。

回到多線程。上面提到synchronized,必須多說幾句,這對理解鎖的本質至關重要。多線程和鎖,首先請大家記住一個場景:多人上廁所。

多線程和鎖,一個是線程,一個是對象。一個在私有的線程棧中,一個在共享的堆中。如何標識某個線程持有某個鎖對象?如何如何標誌某個對象被某個線程鎖定?很顯然,線程棧中開啟一片區域“棧幀”存儲對象鎖記錄,堆中對象有對象頭(對象頭主要保存了對象的類元數據,以及對象的運行時狀態,其中就包括了鎖線程和GC分代等信息。)可以標識被哪個線程鎖定。實際上,虛擬機就是利用對象頭和monitor(後面講)來實現鎖的。

回到多人上廁所,人比做線程,廁所比做共享對象,鎖比做對象頭,monitor比做鑰匙。

synchronized鎖的是一個對象,或者是類的某個實例,或者是類本身(即常量池的Class)。 synchronized內部原理是通過對象內部的一個叫做監視器(monitor)來實現的。本質又是依賴於底層的操作系統的Mutex Lock來實現的。而操作系統實現線程之間的切換需要從用戶態轉換到核心態,這個成本非常高,這就是為什麽synchronized效率低的原因。比如Hashtable(再次吐槽小寫t,渾身難受)和用Collections.synchronizedMap裝飾的HashMap,內部都使用了 synchronized,所以性能差,不是因為“它性能差”,而是因為“它使用的同步方式”性能差,那天人家底層重寫了性能高了你怎麽辦?很多時候,點下鼠標進入源碼看幾眼就知道的東西,沒必要死記硬背。

synchronized這種依賴於操作系統所實現的鎖我們稱之為“重量級鎖”。JDK中對Synchronized做的種種優化,其核心都是為了減少這種重量級鎖的使用。JDK1.6以後,為了減少獲得鎖和釋放鎖所帶來的性能消耗,提高性能,引入了大量的優化,如自旋鎖、適應性自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術來減少鎖操作的開銷。別被這些名詞砸暈了,這些鎖的名字很有誤導性,其實是對獲取鎖的方式的優化,不是鎖。

所謂鎖的優化,主要方向是優化獲取鎖的方式和加鎖(釋放)的方式。我不想一一解釋枯燥名詞。還是用上廁所舉例。重量級鎖可以認為是,你去上廁所,得先去管理處(人或者機器)登記並拿到鑰匙上廁所,這個過程可以認為存在一次“用戶態”到“內核態”的切換。是非常重量級的。

這裏我必須強調一下,你的目標是上廁所,不是加鎖,加鎖只是為了你更好的上廁所。線程也一樣,目的是為了完成某項任務。加鎖是不得以為之的。

假如一層樓就你一個人,一個廁所, 你覺得還有必要去登記嗎?要什麽自行車?直接上啊。這就是無鎖狀態;如果這層樓還有一個哥們,但他尿泡比較強悍,一天不上廁所。廁所門上有個顯示器,能顯示上次上廁所的是誰、期間有沒有其他人上廁所,那你上的時候,只要看下顯示器就知道:沒別人上過,還是我,照片都沒變,不用刷臉,此廁可直接上。這就是偏向鎖,因為“偏向你”;假如這個哥們偶爾也上一次,這次你發現廁所有別人上過,因為顯示器上有他照片,那你就得重新刷臉,好吧,那我再刷了上吧,大部分時候,裏面都沒這哥們,你可順利上廁所,這叫輕量級鎖;如果某天這哥們腹瀉(我一同事吃湖南蒸菜有過一次),那你悲劇了,你每次上的時候,不僅顯示器不是你,你想刷臉進入,發展裏面還有人。沒辦法,只能去管理處登記等待了,變成了重量級鎖。鎖升級是不會降級的。這裏,重量級鎖涉及操作系統的處理,而偏向鎖和輕量級鎖涉及CAS,硬件可以搞定,效率更高。

上述鎖狀態轉移和加鎖(解鎖不講了)是由虛擬機(配合操作系統)完成的,我們不可見,既然是虛擬機控制,當然就有相關參數,如是否啟用偏向鎖,我忘了參數名字,但我知道肯定有這樣的參數。如果面試我的面試官因為我不知道參數名字鄙視我,我能反懟死他。記個別人定的名字很自豪?

上面講到重量級鎖的時候,其實就是鎖競爭很激烈的時候。比如早上高峰期,廁所坑位緊缺,排隊的人很多,如果你一直等,等待的狀態就叫“自旋”,當然你可以自旋十分鐘左右後離開(虛擬機自旋也有參數控制),因為你覺得裏面的哥們玩手機不知道啥時候結束,你有更重要的事情要幹,還不如去外面登記等通知。顯然,自旋的前提是你知道上一個哥們不會很久。多次之後,你會摸清這些人上廁所的時間後,你自旋起來就更有針對性了,這叫“適應性自旋”。

還有,鎖消除,鎖粗化,比如基本沒人用的StringBuffer、Vector,你用在某個方法中,其實根本沒必要加鎖,或者說比如連續的append,沒必要每次都加鎖,虛擬機就會進行鎖消除或者鎖粗化處理。

上面講了這麽多,主體是線程和鎖對象,核心是獲取鎖的方式和鎖定的方式,還有,不加鎖或者“偽加鎖”是不是能搞定?再次強調一遍,線程生來是為了完成任務的,不是為了和鎖糾纏的。

多線程競爭鎖的時候,肯定涉及到線程的排隊,新來的線程怎麽處理,是去競爭鎖還是直接排隊?排隊中的線程,那些有資格競爭鎖?有資格的線程,那個拿到鎖(只是拿到鎖,還未執行共享區)?不管怎麽實現,這些東西是必須要考慮的。你在synchronized沒見到,是因為虛擬機幫你處理了,涉及的隊列也是虛擬機在維護。重量級鎖的時候,又涉及和操作系統信號的交互。當然,要是你不用和操作系統進行如Mutex Lock這樣“重量級的”交互也能更好、更快、更好的處理同步,那你就是大牛了。

大牛當然是存在,比如李老頭。下面會開始講更加靈活的、細粒度、可定制的Lock鎖。可以認為是把synchronized加鎖的過程、鎖定的方式等流程中細節拆分出來,用靈巧的實現方式實現線程同步。再後面會講對象的wait、notify,線程的sleep,主體不一樣,思考的角度不一樣

多線程和虛擬機的宏觀理解