小白,坐在這間屬于華夏國超一流互聯(lián)網(wǎng)公司企鵝巴巴的小會議室里,等著技術面試官的到來。
小伙子我看你簡歷上什么也沒寫,這次也是第一面,那我們就隨便問點簡單的多線程問題吧。先說說什么是Java的多線程吧,使用多線程有什么好處?有什么壞處?
媽媽說專家的話不能信!果然,問個多線程還問好處壞處?我不想用不會用能進企鵝巴巴么?
但是作為打工人,我認真的回答道:
Java的多線程是指程序中包含多個執(zhí)行流,即在一個程序中可以同時運行多個不同的線程來執(zhí)行不同的任務。
而使用多線程的好處是可以提高 CPU 的利用率。在多線程程序中,一個線程必須等待的時候,CPU 可以運行其它的線程而不是等待,這樣就大大提高了程序的效率。也就是說允許單個程序創(chuàng)建多個并行執(zhí)行的線程來完成各自的任務。
至于多線程的壞處么,主要有三點。第一點是線程也是程序,所以線程需要占用內(nèi)存,線程越多占用內(nèi)存也越多;第二點是多線程需要協(xié)調(diào)和管理,所以需要 CPU 時間跟蹤線程;最后是線程之間對共享資源的訪問會相互影響,必須解決競用共享資源的問題。
你剛才講了“并行”這個詞,那你說說并行和并發(fā)有什么區(qū)別?
并發(fā),英文單詞是concurrency,就是多個任務在同一個 CPU 核上,按細分的時間片輪流(交替)執(zhí)行,從邏輯上來看那些任務是同時執(zhí)行。
并行,英文單詞是parallelism,就是單位時間內(nèi),多個處理器或多核處理器同時處理多個任務,是真正意義上的“同時進行”。
這兩句話,我相信99%的同學都知道!但是,如果想進企鵝巴巴,如果想應付P20的科學家!我就一定要自行的結合業(yè)務回答并發(fā)并行的優(yōu)勢!
現(xiàn)在的系統(tǒng)動不動就要求百萬級甚至千萬級的并發(fā)量,而多線程并發(fā)編程正是開發(fā)高并發(fā)系統(tǒng)的基礎,利用好多線程機制可以大大提高系統(tǒng)整體的并發(fā)能力以及性能。面對復雜業(yè)務模型,并行程序會比串行程序更適應業(yè)務需求,而并發(fā)編程更能吻合這種業(yè)務拆分 。
路人S和路人B果然都露出了滿意的笑容。
那你說說看,在操作系統(tǒng)中用戶級線程和內(nèi)核級線程是什么?這兩個線程在多核CPU的計算機上是否都能并行?
在操作系統(tǒng)的設計中,為了防止用戶操作敏感指令而對OS帶來安全隱患,我們把OS分成了用戶空間(user space)和內(nèi)核空間(kernel space)。
通過用戶空間的庫類實現(xiàn)的線程,就是用戶級線程(user-level threads,ULT)。這種線程不依賴于操作系統(tǒng)核心,進程利用線程庫提供創(chuàng)建、同步、調(diào)度和管理線程的函數(shù)來控制用戶線程。
說著,我拿了一支筆,畫了這么一張圖:
在圖里,我們可以清楚的看到,線程表(管理線程的數(shù)據(jù)結構)是處于進程內(nèi)部的,完全處于用戶空間層面,內(nèi)核空間對此一無所知!當然,用戶線程也可以沒有線程表!
相應的,由OS內(nèi)核空間直接掌控的線程,稱為內(nèi)核級線程(kernel-level threads,KLT)。其依賴于操作系統(tǒng)核心,由內(nèi)核的內(nèi)部需求進行創(chuàng)建和撤銷。
接著,我畫下了這張圖:
同樣的,在圖中,我們看到內(nèi)核線程的線程表(thread table)位于內(nèi)核中,包括了線程控制塊(TCB),一旦線程阻塞,內(nèi)核會從當前或者其他進程(process)中重新選擇一個線程保證程序的執(zhí)行。
對于用戶級線程來說,其線程的切換發(fā)生在用戶空間,這樣的線程切換至少比陷入內(nèi)核要快一個數(shù)量級。但是該種線程有個嚴重的缺點:如果一個線程開始運行,那么該進程中其他線程就不能運行,除非第一個線程自動放棄CPU。因為在一個單獨的進程內(nèi)部,沒有時鐘中斷,所以不能用輪轉調(diào)度(輪流)的方式調(diào)度線程。
也就是說,同一進程中的用戶級線程,在不考慮調(diào)起多個內(nèi)核級線程的基礎上,是沒有辦法利用多核CPU的,其實質是并發(fā)而非并行。
對于內(nèi)核級線程來說,其線程在內(nèi)核中創(chuàng)建和撤銷線程的開銷比較大,需要考慮上下文切換的開銷。
但是,內(nèi)核級線程是可以利用多核CPU的,即可以并行!
這回答的累死我了,不過為了能進企鵝巴巴,走向人生巔峰,一切都值了!
嗯,小伙子基礎還是比較牢靠的!那你說說Java里的多線程是用戶級線程還是內(nèi)核級線程呢?
是...當我要脫口而出的時候,發(fā)現(xiàn)不對,這面試官在套路我!堂堂科學家,套路還沒入職的孩子么?
Java里的多線程,既不是用戶級線程,也不是內(nèi)核級線程!
首先,Java是跨操作平臺的語言,是使用JVM去運行編譯文件的。不同的JVM對線程的實現(xiàn)不同,相同的JVM對不同操作平臺的線程實現(xiàn)方式也有區(qū)別!
其次,要講明白程序級別實現(xiàn)多線程,就必須先說一下多線程模型。
裂開!怎么感覺這又是一道大題啊!B是操作系統(tǒng)的科學家吧!感覺問的都是很底層的東西了啊,現(xiàn)在程序員內(nèi)卷成這樣了么?實習生都問這么底層的問題了?雖然百般不爽,但是為了拿下美女HR,不!是橫掃offer。我要給路人B講明白這個線程模型!
上面我說過OS上的線程分為ULT和KLT,我們寫程序的代碼只能是在用戶空間里寫代碼!而程序運行中,基本上都會進入內(nèi)核運行,所以我們在實現(xiàn)程序級別多線程的時候,必須讓ULT映射到KLT上去。在程序級別的多線程設計里,有以下三種多線程模型。
多對1模型:在多對一模型中,多個ULT映射到1個KLT上去,此時ULT的進程表處于進程之中。
1對1模型:在一對一模型中,1個ULT對應1個KLT。自己不在進程中創(chuàng)建線程表來管理,幾行代碼之后直接通過系統(tǒng)調(diào)用調(diào)起KLT就能實現(xiàn)。
多對多模型:在多對多模型中,N個ULT對應小于等于N個的KLT。這種模型結合了1對1和多對1的優(yōu)點,用戶創(chuàng)建線程沒有限制,阻塞內(nèi)核系統(tǒng)的命令不會阻塞整個進程。
最后,就拿最熱門的HotSpot VM來說吧,他在Solaris上就有兩種線程實現(xiàn)方式,可以讓用戶選擇一對一或多對多這兩種模型;而在Windows和Linux下,使用的都是一對一的多線程模型,Java的線程通過一一映射到Light Weight Process(輕量級進程,LWP)從而實現(xiàn)了和KLT的一一對應。
ULT如何映射到KLT?怎么調(diào)起的?
ULT在執(zhí)行的過程中,如果執(zhí)行的指令需要進入內(nèi)核態(tài),則ULT會通過系統(tǒng)調(diào)用調(diào)起一個KLT!
所謂系統(tǒng)調(diào)度,就是在OS中分割用戶空間和內(nèi)核空間的API。
ULT的執(zhí)行過程中可以不調(diào)起KLT么?舉個例子。
可以不調(diào)起,比如ULT中就只有sleep這個指令,就不會進入內(nèi)核態(tài)執(zhí)行,更不會調(diào)起KLT。
問到這里,我有點吐血了都!看著B對我的回答很滿意,我心中卻把B已經(jīng)問候了一百遍!
看來同學對于底層的知識理解還湊合,那你有沒有看過HotSpot的源碼?能不能簡單說說看Java的線程是怎么運行的?
這問的還上癮了?P20的問題咋這么“簡單”呢!說實話,自從前幾天發(fā)生了靈異事件之后,我確實技術突飛猛進,這個源代碼我好像還真的瞄了一眼,不過我不能暴露自己擁有金手指的秘密?。?/span>
于是我撓了撓頭,思考了1分鐘,然后說道:
源碼以前看過,只能記得一個大概。
1、在Java中,使用java.lang.Thread的構造方法來構建一個java.lang.Thread對象,此時只是對這個對象的部分字段(例如線程名,優(yōu)先級等)進行初始化;
2、調(diào)用java.lang.Thread對象的start()方法,開始此線程。此時,在start()方法內(nèi)部,調(diào)用start0() 本地方法來開始此線程;
3、start0()在VM中對應的是JVM_StartThread,也就是,在VM中,實際運行的是JVM_StartThread方法(宏),在這個方法中,創(chuàng)建了一個JavaThread對象;
4、在JavaThread對象的創(chuàng)建過程中,會根據(jù)運行平臺創(chuàng)建一個對應的OSThread對象,且JavaThread保持這個OSThread對象的引用;
5、在OSThread對象的創(chuàng)建過程中,創(chuàng)建一個平臺相關的底層級線程,如果這個底層級線程失敗,那么就拋出異常;
6、在正常情況下,這個底層級的線程開始運行,并執(zhí)行java.lang.Thread對象的run方法;
7、當java.lang.Thread生成的Object的run()方法執(zhí)行完畢返回后,或者拋出異常終止后,終止native thread;
8、最后就是釋放相關的資源(包括內(nèi)存、鎖等)
大概就是以上這么個步驟吧。
回答完這個,我要跪謝我的金手指了!我看見路人S在電腦上敲著什么,估計他也比較懵,沒想到我居然能答得上來吧!
那你說說什么是上下文切換吧。
多線程編程中一般線程的個數(shù)都大于 CPU 核心的個數(shù),而一個 CPU 核心在任意時刻只能被一個線程使用,為了讓這些線程都能得到有效執(zhí)行,CPU 采取的策略是為每個線程分配時間片并輪轉的形式。
時間片是CPU分配給各個線程的時間,因為時間非常短,所以CPU不斷通過切換線程,讓我們覺得多個線程是同時執(zhí)行的,時間片一般是幾十毫秒。
當一個線程的時間片用完的時候就會重新處于就緒狀態(tài)讓給其他線程使用,這個過程就屬于一次上下文切換。
概括來說就是:當前任務在執(zhí)行完 CPU 時間片切換到另一個任務之前會先保存自己的狀態(tài),以便下次再切換回這個任務時,可以再加載這個任務的狀態(tài)。任務從保存到再加載的過程就是一次上下文切換。
頻繁切換上下文會有什么問題?
上下文切換通常是計算密集型的,每次切換時,需要保存當前的狀態(tài)起來,以便能夠進行恢復先前狀態(tài),而這個切換時非常損耗性能。
也就是說,它需要相當可觀的處理器時間,在每秒幾十上百次的切換中,每次切換都需要納秒量級的時間。所以,上下文切換對系統(tǒng)來說意味著消耗大量的 CPU 時間,事實上,可能是操作系統(tǒng)中時間消耗最大的操作。
Linux 相比與其他操作系統(tǒng)(包括其他類 Unix 系統(tǒng))有很多的優(yōu)點,其中有一項就是,其上下文切換和模式切換的時間消耗非常少。
減少上下文切換的方式有哪些?
通常減少上下文切換的方式有:
1、無鎖并發(fā)編程:可以參照concurrentHashMap鎖分段的思想,不同的線程處理不同段的數(shù)據(jù),這樣在多線程競爭的條件下,可以減少上下文切換的時間。
2、CAS算法:利用Atomic下使用CAS算法來更新數(shù)據(jù),使用了樂觀鎖,可以有效的減少一部分不必要的鎖競爭帶來的上下文切換。
3、使用最少線程:避免創(chuàng)建不需要的線程,比如任務很少,但是創(chuàng)建了很多的線程,這樣會造成大量的線程都處于等待狀態(tài)。
4、協(xié)程:在單線程里實現(xiàn)多任務的調(diào)度,并在單線程里維持多個任務間的切換。
協(xié)程是什么?和用戶線程有什么區(qū)別?
我聽了真想抽自己幾個嘴巴子,怎么又來了!B是只會OS吧!
協(xié)程的英文單詞是Coroutine,這是一個程序組件,它既不是線程也不是進程。它的執(zhí)行過程更類似于一個方法,或者說不帶返回值的函數(shù)調(diào)用。
我看到過stack overflow和很多博客里,都認為這兩者是一個東西。但是,在我的理解中,這兩者還是有區(qū)別的。
不可否認的是,協(xié)程和ULT做的是同一個事情。所以從某種角度上講,他們確實是等價的!
但是,ULT這個概念被提出的時候,其背后的思想本質是講ULT是個本機線程,也就是使用了OS的用戶空間內(nèi)提供的庫類直接創(chuàng)建的線程。這個時候,你不需要在OS上面添加一些其他第三方的庫類。
而協(xié)程這個概念是康威定律的提出者Melvin Edward Conway在1958年提出的一個概念,其背后的思想是不直接使用OS本身的庫類,自己做一些庫類去實現(xiàn)并發(fā)。在那個年代,OS上面的第三方庫類并不像現(xiàn)在這么流行,OS本身的庫類和其他第三方庫類的結合也并不像今天這么容易。所以協(xié)程并不是本機線程,他是需要借助一些其他不屬于OS的第三方庫類調(diào)用OS用戶空間的庫類來實現(xiàn)達到ULT的效果。
當然,這個概念在今天來看,就會顯得很讓人混淆了。因為到底哪些庫類算是OS本機的庫類,哪些算是第三方庫類?這和1960年的時候已經(jīng)有絕大的區(qū)別了!所以大家認為這兩者是一個東西,其實也不能說他說的不對,只能說可能對這個思想本身背后代表的東西不明白。
那你知道fiber么?這個和上面兩個名詞有什么區(qū)別?
fiber也是一種本機線程,其本質是一種特殊的ULT,即更輕量級的ULT。說白了就是這種ULT的線程表一定存于進程之中。
而我們在構建一對一多線程模型的時候,ULT的線程表其實還是交給內(nèi)核了!這是兩者之間最直接的差別。所以我們經(jīng)常稱fiber就是協(xié)同調(diào)度的ULT,在win32中可以調(diào)用fiber來構建多對多的多線程模型。
其實,fiber、coroutine和ULT在用戶層面能看到的效果是基本等價的。
其中ULT是描述OS庫本身提供的功能;fiber描述的是OS提供的協(xié)同調(diào)度的ULT;coroutine描述的是第三方實現(xiàn)的并發(fā)并行功能。
這些名詞很多都是歷史原因的問題,同時也是深入研究需要了解的事情,我們普通程序員在使用的時候,更多的關心的是應用層方面的東西。而這些名詞的理解已經(jīng)深入到源碼層了。
還是講講看在 Java 程序中怎么保證多線程的運行安全吧。
Java的線程安全在三個方面體現(xiàn):
原子性:提供互斥訪問,同一時刻只能有一個線程對數(shù)據(jù)進行操作,在Java中使用了atomic和synchronized這兩個關鍵字來確保原子性;
可見性:一個線程對主內(nèi)存的修改可以及時地被其他線程看到,在Java中使用了synchronized和volatile這兩個關鍵字確??梢娦裕?/span>
有序性:一個線程觀察其他線程中的指令執(zhí)行順序,由于指令重排序,該觀察結果一般雜亂無序,在Java中使用了happens-before原則來確保有序性。
你剛才講了有序性,那你說說代碼為什么會重排序?
在執(zhí)行程序時,為了提高性能,處理器和編譯器常常會對指令進行重排序。
重排序是想怎么重排就重排么?
這面試官也很難纏啊,怎么一直在追問,是需要我給他孝敬一根華子么?要不是看著旁邊有個美女HR,我早就孝敬S他老人家了!
當然不是!不能隨意重排序,不是你想怎么排序就怎么排序,它需要滿足以下兩個條件:
1、在單線程環(huán)境下不能改變程序運行的結果;
2、存在數(shù)據(jù)依賴關系的不允許重排序。
所以重排序不會對單線程有影響,只會破壞多線程的執(zhí)行語義。
那你講講看在Java中如何保障重排序不影響單線程的吧。
保障這一結果是因為在編譯器,runtime 和處理器都必須遵守as-if-serial語義規(guī)則。
為了遵守as-if-serial語義,編譯器和處理器不會對存在數(shù)據(jù)依賴關系的操作做重排序,因為這種重排序會改變執(zhí)行結果。但是,如果操作之間不存在數(shù)據(jù)依賴關系,這些操作可能被編譯器和處理器重排序。
我來舉個例子吧
說著我拿著筆在紙上寫了三行簡單的代碼:
我們看這個例子,A和C之間存在數(shù)據(jù)依賴關系,同時B和C之間也存在數(shù)據(jù)依賴關系。因此在最終執(zhí)行的指令序列中,C不能被重排序到A和B的前面,如果C排到A和B的前面,那么程序的結果將會被改變。但A和B之間沒有數(shù)據(jù)依賴關系,編譯器和處理器可以重排序A和B之間的執(zhí)行順序。
這就是as-if-serial語義。
那你說說看你剛才講的happens-before原則吧。
happens-before說白了就是誰在誰前面發(fā)生的一個關系。
HB規(guī)則是Java內(nèi)存模型(JMM)向程序員提供的跨線程內(nèi)存可見性保證。
說的直白一點,就是如果A線程的寫操作a與B線程的讀操作b之間存在happens-before關系,盡管a操作和b操作在不同的線程中執(zhí)行,但JMM向程序員保證a操作將對b操作可見。
具體的定義為:
1、如果一個操作happens-before另一個操作,那么第一個操作的執(zhí)行結果將對第二個操作可見,而且第一個操作的執(zhí)行順序排在第二個操作之前。
2、兩個操作之間存在happens-before關系,并不意味著Java平臺的具體實現(xiàn)必須要按照happens-before關系指定的順序來執(zhí)行。如果重排序之后的執(zhí)行結果,與按happens-before關系來執(zhí)行的結果一致,那么這種重排序并不非法。
具體的規(guī)則有8條:
1、程序順序規(guī)則:一個線程中的每個操作,happens-before于該線程中的任意后續(xù)操作。
2、監(jiān)視器鎖規(guī)則:對一個鎖的解鎖,happens-before于隨后對這個鎖的加鎖。
3、volatile變量規(guī)則:對一個volatile域的寫,happens-before于任意后續(xù)對這個volatile域的讀。
4、傳遞性:如果A happens-before B,且B happens-before C,那么A happens-before C。
5、start()規(guī)則:如果線程A執(zhí)行操作ThreadB.start()(啟動線程B),那么A線程的ThreadB.start()操作happens-before于線程B中的任意操作。
6、Join()規(guī)則:如果線程A執(zhí)行操作ThreadB.join()并成功返回,那么線程B中的任意操作happens-before于線程A從ThreadB.join()操作成功返回。
7、程序中斷規(guī)則:對線程interrupted()方法的調(diào)用先行于被中斷線程的代碼檢測到中斷時間的發(fā)生。
8、對象finalize規(guī)則:一個對象的初始化完成(構造函數(shù)執(zhí)行結束)先行于發(fā)生它的finalize()方法的開始。
你剛才說HB規(guī)則不代表最終的執(zhí)行順序,能不能舉個例子。
就拿講as-if-serial提到的例子舉例吧,例子很簡單就是面積=寬*高。
利用HB的程序順序規(guī)則,存在三個happens-before關系:
1、A happens-before B;
2、B happens-before C;
3、A happens-before C。
這里的第三個關系是利用傳遞性進行推論的。這里的第三個關系是利用傳遞性進行推論的。
A happens-before B,定義1要求A執(zhí)行結果對B可見,并且A操作的執(zhí)行順序在B操作之前;但與此同時利用HB定義中的第二條,A、B操作彼此不存在數(shù)據(jù)依賴性,兩個操作的執(zhí)行順序對最終結果都不會產(chǎn)生影響。
在不改變最終結果的前提下,允許A,B兩個操作重排序,即happens-before關系并不代表了最終的執(zhí)行順序。
哈嘍,我是小林,就愛圖解計算機基礎,如果覺得文章對你有幫助,歡迎分享給你的朋友,也給小林點個「在看」,這對小林非常重要,謝謝你們,給各位小姐姐小哥哥們抱拳了,我們下次見!
推薦閱讀
你不好奇 Linux 是如何收發(fā)網(wǎng)絡包的?
小小的 float,藏著大大的學問
免責聲明:本文內(nèi)容由21ic獲得授權后發(fā)布,版權歸原作者所有,本平臺僅提供信息存儲服務。文章僅代表作者個人觀點,不代表本平臺立場,如有問題,請聯(lián)系我們,謝謝!