您好,登錄后才能下訂單哦!
這篇文章主要講解了“web程序設(shè)計的底層邏輯有哪些”,文中的講解內(nèi)容簡單清晰,易于學(xué)習(xí)與理解,下面請大家跟著小編的思路慢慢深入,一起來研究和學(xué)習(xí)“web程序設(shè)計的底層邏輯有哪些”吧!
結(jié)合 CPU 理解一行 Java 代碼是怎么執(zhí)行的
根據(jù)馮·諾依曼思想,計算機采用二進制作為數(shù)制基礎(chǔ),必須包含:運算器、控制器、存儲設(shè)備,以及輸入輸出設(shè)備,如下圖所示。
我們先來分析 CPU 的工作原理,現(xiàn)代 CPU 芯片中大都集成了,控制單元,運算單元,存儲單元??刂茊卧?CPU 的控制中心, CPU 需要通過它才知道下一步做什么,也就是執(zhí)行什么指令,控制單元又包含:指令寄存器(IR ),指令譯碼器( ID )和操作控制器( OC )。
當(dāng)程序被加載進內(nèi)存后,指令就在內(nèi)存中了,這個時候說的內(nèi)存是獨立于 CPU 外的主存設(shè)備,也就是 PC 機中的內(nèi)存條,指令指針寄存器IP 指向內(nèi)存中下一條待執(zhí)行指令的地址,控制單元根據(jù) IP寄存器的指向,將主存中的指令裝載到指令寄存器。
這個指令寄存器也是一個存儲設(shè)備,不過他集成在 CPU 內(nèi)部,指令從主存到達(dá) CPU 后只是一串 010101 的二進制串,還需要通過譯碼器解碼,分析出操作碼是什么,操作數(shù)在哪,之后就是具體的運算單元進行算術(shù)運算(加減乘除),邏輯運算(比較,位移)。而 CPU 指令執(zhí)行過程大致為:取址(去主存獲取指令放到寄存器),譯碼(從主存獲取操作數(shù)放入高速緩存 L1 ),執(zhí)行(運算)。
這里解釋下上圖中 CPU 內(nèi)部集成的存儲單元 SRAM ,正好和主存中的 DRAM 對應(yīng), RAM 是隨機訪問內(nèi)存,就是給一個地址就能訪問到數(shù)據(jù),而磁盤這種存儲媒介必須順序訪問,而 RAM 又分為動態(tài)和靜態(tài)兩種,靜態(tài) RAM 由于集成度較低,一般容量小,速度快,而動態(tài) RAM 集成度較高,主要通過給電容充電和放電實現(xiàn),速度沒有靜態(tài) RAM 快,所以一般將動態(tài) RAM 做為主存,而靜態(tài) RAM 作為 CPU 和主存之間的高速緩存 (cache),用來屏蔽 CPU 和主存速度上的差異,也就是我們經(jīng)??吹降?L1 , L2 緩存。每一級別緩存速度變低,容量變大。
下圖展示了存儲器的層次化架構(gòu),以及 CPU 訪問主存的過程,這里有兩個知識點,一個是多級緩存之間為保證數(shù)據(jù)的一致性,而推出的緩存一致性協(xié)議,具體可以參考這篇文章,另外一個知識點是, cache 和主存的映射,首先要明確的是 cahce 緩存的單位是緩存行,對應(yīng)主存中的一個內(nèi)存塊,并不是一個變量,這個主要是因為 CPU 訪問的空間局限性:被訪問的某個存儲單元,在一個較短時間內(nèi),很有可能再次被訪問到,以及空間局限性:被訪問的某個存儲單元,在較短時間內(nèi),他的相鄰存儲單元也會被訪問到。
而映射方式有很多種,類似于 cache 行號 = 主存塊號 mod cache總行數(shù) ,這樣每次獲取到一個主存地址,根據(jù)這個地址計算出在主存中的塊號就可以計算出在 cache 中的行號。
下面我們接著聊 CPU 的指令執(zhí)行。取址、譯碼、執(zhí)行,這是一個指令的執(zhí)行過程,所有指令都會嚴(yán)格按照這個順序執(zhí)行。但是多個指令之間其實是可以并行的,對于單核 CPU 來說,同一時刻只能有一條指令能夠占有執(zhí)行單元運行。這里說的執(zhí)行是 CPU 指令處理 (取指,譯碼,執(zhí)行) 三步驟中的第三步,也就是運算單元的計算任務(wù)。
所以為了提升 CPU 的指令處理速度,所以需要保證運算單元在執(zhí)行前的準(zhǔn)備工作都完成,這樣運算單元就可以一直處于運算中,而剛剛的串行流程中,取指,解碼的時候運算單元是空閑的,而且取指和解碼如果沒有命中高速緩存還需要從主存取,而主存的速度和 CPU 不在一個級別上,所以指令流水線 可以大大提高 CPU 的處理速度,下圖是一個3級流水線的示例圖,而現(xiàn)在的奔騰 CPU 都是32級流水線,具體做法就是將上面三個流程拆分的更細(xì)。
除了指令流水線, CPU 還有分支預(yù)測,亂序執(zhí)行等優(yōu)化速度的手段。好了,我們回到正題,一行 Java 代碼是怎么執(zhí)行的?
一行代碼能夠執(zhí)行,必須要有可以執(zhí)行的上下文環(huán)境,包括:指令寄存器、數(shù)據(jù)寄存器、??臻g等內(nèi)存資源,然后這行代碼必須作為一個執(zhí)行流能夠被操作系統(tǒng)的任務(wù)調(diào)度器識別,并給他分配 CPU 資源,當(dāng)然這行代碼所代表的指令必須是 CPU 可以解碼識別的,所以一行 Java 代碼必須被解釋成對應(yīng)的 CPU 指令才能執(zhí)行。下面我們看下System.out.println("Hello world")這行代碼的轉(zhuǎn)譯過程。
Java 是一門高級語言,這類語言不能直接運行在硬件上,必須運行在能夠識別 Java 語言特性的虛擬機上,而 Java 代碼必須通過 Java 編譯器將其轉(zhuǎn)換成虛擬機所能識別的指令序列,也稱為 Java 字節(jié)碼,之所以稱為字節(jié)碼是因為 Java 字節(jié)碼的操作指令(OpCode)被固定為一個字節(jié),以下為 System.out.println("Hello world") 編譯后的字節(jié)碼:
0x00: b2 00 02 getstatic Java .lang.System.out 0x03: 12 03 ldc "Hello, World!" 0x05: b6 00 04 invokevirtual Java .io.PrintStream.println 0x08: b1 return
最左列是偏移;中間列是給虛擬機讀的字節(jié)碼;最右列是高級語言的代碼,下面是通過匯編語言轉(zhuǎn)換成的機器指令,中間是機器碼,第三列為對應(yīng)的機器指令,最后一列是對應(yīng)的匯編代碼:
0x00: 55 push rbp 0x01: 48 89 e5 mov rbp,rsp 0x04: 48 83 ec 10 sub rsp,0x10 0x08: 48 8d 3d 3b 00 00 00 lea rdi,[rip+0x3b] ; 加載 "Hello, World!\n" 0x0f: c7 45 fc 00 00 00 00 mov DWORD PTR [rbp-0x4],0x0 0x16: b0 00 mov al,0x0 0x18: e8 0d 00 00 00 call 0x12 ; 調(diào)用 printf 方法 0x1d: 31 c9 xor ecx,ecx 0x1f: 89 45 f8 mov DWORD PTR [rbp-0x8],eax 0x22: 89 c8 mov eax,ecx 0x24: 48 83 c4 10 add rsp,0x10 0x28: 5d pop rbp 0x29: c3 ret
JVM 通過類加載器加載 class 文件里的字節(jié)碼后,會通過解釋器解釋成匯編指令,最終再轉(zhuǎn)譯成 CPU 可以識別的機器指令,解釋器是軟件來實現(xiàn)的,主要是為了實現(xiàn)同一份 Java 字節(jié)碼可以在不同的硬件平臺上運行,而將匯編指令轉(zhuǎn)換成機器指令由硬件直接實現(xiàn),這一步速度是很快的,當(dāng)然 JVM 為了提高運行效率也可以將某些熱點代碼(一個方法內(nèi)的代碼)一次全部編譯成機器指令后然后在執(zhí)行,也就是和解釋執(zhí)行對應(yīng)的即時編譯(JIT), JVM 啟動的時候可以通過 -Xint 和 -Xcomp 來控制執(zhí)行模式。
從軟件層面上, class 文件被加載進虛擬機后,類信息會存放在方法區(qū),在實際運行的時候會執(zhí)行方法區(qū)中的代碼,在 JVM 中所有的線程共享堆內(nèi)存和方法區(qū),而每個線程有自己獨立的 Java 方法棧,本地方法棧(面向 native 方法),PC寄存器(存放線程執(zhí)行位置),當(dāng)調(diào)用一個方法的時候, Java 虛擬機會在當(dāng)前線程對應(yīng)的方法棧中壓入一個棧幀,用來存放 Java 字節(jié)碼操作數(shù)以及局部變量,這個方法執(zhí)行完會彈出棧幀,一個線程會連續(xù)執(zhí)行多個方法,對應(yīng)不同的棧幀的壓入和彈出,壓入棧幀后就是 JVM 解釋執(zhí)行的過程了。
中斷
剛剛說到, CPU 只要一上電就像一個永動機, 不停的取指令,運算,周而復(fù)始,而中斷便是操作系統(tǒng)的靈魂,故名思議,中斷就是打斷 CPU 的執(zhí)行過程,轉(zhuǎn)而去做點別的。
例如系統(tǒng)執(zhí)行期間發(fā)生了致命錯誤,需要結(jié)束執(zhí)行,例如用戶程序調(diào)用了一個系統(tǒng)調(diào)用的方法,例如mmp等,就會通過中斷讓 CPU 切換上下文,轉(zhuǎn)到內(nèi)核空間,例如一個等待用戶輸入的程序正在阻塞,而當(dāng)用戶通過鍵盤完成輸入,內(nèi)核數(shù)據(jù)已經(jīng)準(zhǔn)備好后,就會發(fā)一個中斷信號,喚醒用戶程序把數(shù)據(jù)從內(nèi)核取走,不然內(nèi)核可能會數(shù)據(jù)溢出,當(dāng)磁盤報了一個致命異常,也會通過中斷通知 CPU ,定時器完成時鐘滴答也會發(fā)時鐘中斷通知 CPU 。
中斷的種類,我們這里就不做細(xì)分了,中斷有點類似于我們經(jīng)常說的事件驅(qū)動編程,而這個事件通知機制是怎么實現(xiàn)的呢,硬件中斷的實現(xiàn)通過一個導(dǎo)線和 CPU 相連來傳輸中斷信號,軟件上會有特定的指令,例如執(zhí)行系統(tǒng)調(diào)用創(chuàng)建線程的指令,而 CPU 每執(zhí)行完一個指令,就會檢查中斷寄存器中是否有中斷,如果有就取出然后執(zhí)行該中斷對應(yīng)的處理程序。
陷入內(nèi)核 : 我們在設(shè)計軟件的時候,會考慮程序上下文切換的頻率,頻率太高肯定會影響程序執(zhí)行性能,而陷入內(nèi)核是針對 CPU 而言的, CPU 的執(zhí)行從用戶態(tài)轉(zhuǎn)向內(nèi)核態(tài),以前是用戶程序在使用 CPU ,現(xiàn)在是內(nèi)核程序在使用 CPU ,這種切換是通過系統(tǒng)調(diào)用產(chǎn)生的。
系統(tǒng)調(diào)用是執(zhí)行操作系統(tǒng)底層的程序,Linux的設(shè)計者,為了保護操作系統(tǒng),將進程的執(zhí)行狀態(tài)用內(nèi)核態(tài)和用戶態(tài)分開,同一個進程中,內(nèi)核和用戶共享同一個地址空間,一般 4G 的虛擬地址,其中 1G 給內(nèi)核態(tài), 3G 給用戶態(tài)。在程序設(shè)計的時候我們要盡量減少用戶態(tài)到內(nèi)核態(tài)的切換,例如創(chuàng)建線程是一個系統(tǒng)調(diào)用,所以我們有了線程池的實現(xiàn)。
從 Linux 內(nèi)存管理角度理解 JVM 內(nèi)存模型
進程上下文
我們可以將程序理解為一段可執(zhí)行的指令集合,而這個程序啟動后,操作系統(tǒng)就會為他分配 CPU ,內(nèi)存等資源,而這個正在運行的程序就是我們說的進程,進程是操作系統(tǒng)對處理器中運行的程序的一種抽象。
而為進程分配的內(nèi)存以及 CPU 資源就是這個進程的上下文,保存了當(dāng)前執(zhí)行的指令,以及變量值,而 JVM 啟動后也是linux上的一個普通進程,進程的物理實體和支持進程運行的環(huán)境合稱為上下文,而上下文切換就是將當(dāng)前正在運行的進程換下,換一個新的進程到處理器運行,以此來讓多個進程并發(fā)的執(zhí)行,上下文切換可能來自操作系統(tǒng)調(diào)度,也有可能來自程序內(nèi)部,例如讀取IO的時候,會讓用戶代碼和操作系統(tǒng)代碼之間進行切換。
虛擬存儲
當(dāng)我們同時啟動多個 JVM 執(zhí)行:System.out.println(new Object()); 將會打印這個對象的 hashcode ,hashcode 默認(rèn)為內(nèi)存地址,最后發(fā)現(xiàn)他們打印的都是 Java .lang.Object@4fca772d ,也就是多個進程返回的內(nèi)存地址竟然是一樣的。
通過上面的例子我們可以證明,linux中每個進程有單獨的地址空間,在此之前,我們先了解下 CPU 是如何訪問內(nèi)存的?
假設(shè)我們現(xiàn)在還沒有虛擬地址,只有物理地址,編譯器在編譯程序的時候,需要將高級語言轉(zhuǎn)換成機器指令,那么 CPU 訪問內(nèi)存的時候必須指定一個地址,這個地址如果是一個絕對的物理地址,那么程序就必須放在內(nèi)存中的一個固定的地方,而且這個地址需要在編譯的時候就要確認(rèn),大家應(yīng)該想到這樣有多坑了吧。
如果我要同時運行兩個 office word 程序,那么他們將操作同一塊內(nèi)存,那就亂套了,偉大的計算機前輩設(shè)計出,讓 CPU 采用 段基址 + 段內(nèi)偏移地址 的方式訪問內(nèi)存,其中段基地址在程序啟動的時候確認(rèn),盡管這個段基地址還是絕對的物理地址,但終究可以同時運行多個程序了, CPU 采用這種方式訪問內(nèi)存,就需要段基址寄存器和段內(nèi)偏移地址寄存器來存儲地址,最終將兩個地址相加送上地址總線。
而內(nèi)存分段,相當(dāng)于每個進程都會分配一個內(nèi)存段,而且這個內(nèi)存段需要是一塊連續(xù)的空間,主存里維護著多個內(nèi)存段,當(dāng)某個進程需要更多內(nèi)存,并且超出物理內(nèi)存的時候,就需要將某個不常用的內(nèi)存段換到硬盤上,等有充足內(nèi)存的時候在從硬盤加載進來,也就是 swap 。每次交換都需要操作整個段的數(shù)據(jù)。
首先連續(xù)的地址空間是很寶貴的,例如一個 50M 的內(nèi)存,在內(nèi)存段之間有空隙的情況下,將無法支持 5 個需要 10M 內(nèi)存才能運行的程序,如何才能讓段內(nèi)地址不連續(xù)呢? 答案是內(nèi)存分頁。
在保護模式下,每一個進程都有自己獨立的地址空間,所以段基地址是固定的,只需要給出段內(nèi)偏移地址就可以了,而這個偏移地址稱為線性地址,線性地址是連續(xù)的,而內(nèi)存分頁將連續(xù)的線性地址和和分頁后的物理地址相關(guān)聯(lián),這樣邏輯上的連續(xù)線性地址可以對應(yīng)不連續(xù)的物理地址。
物理地址空間可以被多個進程共享,而這個映射關(guān)系將通過頁表( page table)進行維護。 標(biāo)準(zhǔn)頁的尺寸一般為 4KB ,分頁后,物理內(nèi)存被分成若干個 4KB 的數(shù)據(jù)頁,進程申請內(nèi)存的時候,可以映射為多個 4KB 大小的物理內(nèi)存,而應(yīng)用程序讀取數(shù)據(jù)的時候會以頁為最小單位,當(dāng)需要和硬盤發(fā)生交換的時候也是以頁為單位。
現(xiàn)代計算機多采用虛擬存儲技術(shù),虛擬存儲讓每個進程以為自己獨占整個內(nèi)存空間,其實這個虛擬空間是主存和磁盤的抽象,這樣的好處是,每個進程擁有一致的虛擬地址空間,簡化了內(nèi)存管理,進程不需要和其他進程競爭內(nèi)存空間。
因為他是獨占的,也保護了各自進程不被其他進程破壞,另外,他把主存看成磁盤的一個緩存,主存中僅保存活動的程序段和數(shù)據(jù)段,當(dāng)主存中不存在數(shù)據(jù)的時候發(fā)生缺頁中斷,然后從磁盤加載進來,當(dāng)物理內(nèi)存不足的時候會發(fā)生 swap 到磁盤。頁表保存了虛擬地址和物理地址的映射,頁表是一個數(shù)組,每個元素為一個頁的映射關(guān)系,這個映射關(guān)系可能是和主存地址,也可能和磁盤,頁表存儲在主存,我們將存儲在高速緩沖區(qū) cache 中的頁表稱為快表 TLAB 。
裝入位 表示對于頁是否在主存,如果地址頁每頁表示,數(shù)據(jù)還在磁盤
存放位置 建立虛擬頁和物理頁的映射,用于地址轉(zhuǎn)換,如果為null表示是一個未分配頁
修改位 用來存儲數(shù)據(jù)是否修改過
權(quán)限位 用來控制是否有讀寫權(quán)限
禁止緩存位 主要用來保證 cache 主存 磁盤的數(shù)據(jù)一致性
內(nèi)存映射
正常情況下,我們讀取文件的流程為,先通過系統(tǒng)調(diào)用從磁盤讀取數(shù)據(jù),存入操作系統(tǒng)的內(nèi)核緩沖區(qū),然后在從內(nèi)核緩沖區(qū)拷貝到用戶空間,而內(nèi)存映射,是將磁盤文件直接映射到用戶的虛擬存儲空間中,通過頁表維護虛擬地址到磁盤的映射,通過內(nèi)存映射的方式讀取文件的好處有,因為減少了從內(nèi)核緩沖區(qū)到用戶空間的拷貝,直接從磁盤讀取數(shù)據(jù)到內(nèi)存,減少了系統(tǒng)調(diào)用的開銷,對用戶而言,仿佛直接操作的磁盤上的文件,另外由于使用了虛擬存儲,所以不需要連續(xù)的主存空間來存儲數(shù)據(jù)。
在 Java 中,我們使用 MappedByteBuffer 來實現(xiàn)內(nèi)存映射,這是一個堆外內(nèi)存,在映射完之后,并沒有立即占有物理內(nèi)存,而是訪問數(shù)據(jù)頁的時候,先查頁表,發(fā)現(xiàn)還沒加載,發(fā)起缺頁異常,然后在從磁盤將數(shù)據(jù)加載進內(nèi)存,所以一些對實時性要求很高的中間件,例如rocketmq,消息存儲在一個大小為1G的文件中,為了加快讀寫速度,會將這個文件映射到內(nèi)存后,在每個頁寫一比特數(shù)據(jù),這樣就可以把整個1G文件都加載進內(nèi)存,在實際讀寫的時候就不會發(fā)生缺頁了,這個在rocketmq內(nèi)部叫做文件預(yù)熱。
下面我們貼一段 rocketmq 消息存儲模塊的代碼,位于 MappedFile 類中,這個類是 rocketMq 消息存儲的核心類感興趣的可以自行研究,下面兩個方法一個是創(chuàng)建文件映射,一個是預(yù)熱文件,每預(yù)熱 1000 個數(shù)據(jù)頁,就讓出 CPU 權(quán)限。
private void init(final String fileName, final int fileSize) throws IOException { this.fileName = fileName; this.fileSize = fileSize; this.file = new File(fileName); this.fileFromOffset = Long.parseLong(this.file.getName()); boolean ok = false; ensureDirOK(this.file.getParent()); try { this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel(); this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize); TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(fileSize); TOTAL_MAPPED_FILES.incrementAndGet(); ok = true; } catch (FileNotFoundException e) { log.error("create file channel " + this.fileName + " Failed. ", e); throw e; } catch (IOException e) { log.error("map file " + this.fileName + " Failed. ", e); throw e; } finally { if (!ok && this.fileChannel != null) { this.fileChannel.close(); } } }
JVM 中對象的內(nèi)存布局
在linux中只要知道一個變量的起始地址就可以讀出這個變量的值,因為從這個起始地址起前8位記錄了變量的大小,也就是可以定位到結(jié)束地址,在 Java 中我們可以通過 Field.get(object) 的方式獲取變量的值,也就是反射,最終是通過 UnSafe 類來實現(xiàn)的。我們可以分析下具體代碼。
Field 對象的 getInt方法 先安全檢查 ,然后調(diào)用 FieldAccessor @CallerSensitive public int getInt(Object obj) throws IllegalArgumentException, IllegalAccessException { if (!override) { if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) { Class<?> caller = Reflection.getCallerClass(); checkAccess(caller, clazz, obj, modifiers); } } return getFieldAccessor(obj).getInt(obj); } 獲取field在所在對象中的地址的偏移量 fieldoffset UnsafeFieldAccessorImpl(Field var1) { this.field = var1; if(Modifier.isStatic(var1.getModifiers())) { this.fieldOffset = unsafe.staticFieldOffset(var1); } else { this.fieldOffset = unsafe.objectFieldOffset(var1); } this.isFinal = Modifier.isFinal(var1.getModifiers()); } UnsafeStaticIntegerFieldAccessorImpl 調(diào)用unsafe中的方法 public int getInt(Object var1) throws IllegalArgumentException { return unsafe.getInt(this.base, this.fieldOffset); }
通過上面的代碼我們可以通過屬性相對對象起始地址的偏移量,來讀取和寫入屬性的值,這也是 Java 反射的原理,這種模式在jdk中很多場景都有用到,例如LockSupport.park中設(shè)置阻塞對象。 那么屬性的偏移量具體根據(jù)什么規(guī)則來確定的呢? 下面我們借此機會分析下 Java 對象的內(nèi)存布局。
在 Java 虛擬機中,每個 Java 對象都有一個對象頭 (object header) ,由標(biāo)記字段和類型指針構(gòu)成,標(biāo)記字段用來存儲對象的哈希碼, GC 信息, 持有的鎖信息,而類型指針指向該對象的類 Class ,在 64 位操作系統(tǒng)中,標(biāo)記字段占有 64 位,而類型指針也占 64 位,也就是說一個 Java 對象在什么屬性都沒有的情況下要占有 16 字節(jié)的空間,當(dāng)前 JVM 中默認(rèn)開啟了壓縮指針,這樣類型指針可以只占 32 位,所以對象頭占 12 字節(jié), 壓縮指針可以作用于對象頭,以及引用類型的字段。
JVM 為了內(nèi)存對齊,會對字段進行重排序,這里的對齊主要指 Java 虛擬機堆中的對象的起始地址為 8 的倍數(shù),如果一個對象用不到 8N 個字節(jié),那么剩下的就會被填充,另外子類繼承的屬性的偏移量和父類一致,以 Long 為例,他只有一個非 static 屬性 value ,而盡管對象頭只占有 12 字節(jié),而屬性 value 的偏移量只能是 16, 其中 4 字節(jié)只能浪費掉,所以字段重排就是為了避免內(nèi)存浪費, 所以我們很難在 Java 字節(jié)碼被加載之前分析出這個 Java 對象占有的實際空間有多大,我們只能通過遞歸父類的所有屬性來預(yù)估對象大小,而真實占用的大小可以通過 Java agent 中的 Instrumentation獲取。
當(dāng)然內(nèi)存對齊另外一個原因是為了讓字段只出現(xiàn)在同一個 CPU 的緩存行中,如果字段不對齊,就有可能出現(xiàn)一個字段的一部分在緩存行 1 中,而剩下的一半在 緩存行 2 中,這樣該字段的讀取需要替換兩個緩存行,而字段的寫入會導(dǎo)致兩個緩存行上緩存的其他數(shù)據(jù)都無效,這樣會影響程序性能。
通過內(nèi)存對齊可以避免一個字段同時存在兩個緩存行里的情況,但還是無法完全規(guī)避緩存?zhèn)喂蚕淼膯栴},也就是一個緩存行中存了多個變量,而這幾個變量在多核 CPU 并行的時候,會導(dǎo)致競爭緩存行的寫權(quán)限,當(dāng)其中一個 CPU 寫入數(shù)據(jù)后,這個字段對應(yīng)的緩存行將失效,導(dǎo)致這個緩存行的其他字段也失效。
在 Disruptor 中,通過填充幾個無意義的字段,讓對象的大小剛好在 64 字節(jié),一個緩存行的大小為64字節(jié),這樣這個緩存行就只會給這一個變量使用,從而避免緩存行偽共享,但是在 jdk7 中,由于無效字段被清除導(dǎo)致該方法失效,只能通過繼承父類字段來避免填充字段被優(yōu)化,而 jdk8 提供了注解@Contended 來標(biāo)示這個變量或?qū)ο髮ⅹ毾硪粋€緩存行,使用這個注解必須在 JVM 啟動的時候加上 -XX:-RestrictContended 參數(shù),其實也是用空間換取時間。
jdk6 --- 32 位系統(tǒng)下 public final static class VolatileLong { public volatile long value = 0L; public long p1, p2, p3, p4, p5, p6; // 填充字段 } jdk7 通過繼承 public class VolatileLongPadding { public volatile long p1, p2, p3, p4, p5, p6; // 填充字段 } public class VolatileLong extends VolatileLongPadding { public volatile long value = 0L; } jdk8 通過注解 @Contended public class VolatileLong { public volatile long value = 0L; }
NPTL和 Java 的線程模型
按照教科書的定義,進程是資源管理的最小單位,而線程是 CPU 調(diào)度執(zhí)行的最小單位,線程的出現(xiàn)是為了減少進程的上下文切換(線程的上下文切換比進程小很多),以及更好適配多核心 CPU 環(huán)境,例如一個進程下多個線程可以分別在不同的 CPU 上執(zhí)行,而多線程的支持,既可以放在Linux內(nèi)核實現(xiàn),也可以在核外實現(xiàn),如果放在核外,只需要完成運行棧的切換,調(diào)度開銷小,但是這種方式無法適應(yīng)多 CPU 環(huán)境,底層的進程還是運行在一個 CPU 上,另外由于對用戶編程要求高,所以目前主流的操作系統(tǒng)都是在內(nèi)核支持線程,而在Linux中,線程是一個輕量級進程,只是優(yōu)化了線程調(diào)度的開銷。
而在 JVM 中的線程和內(nèi)核線程是一一對應(yīng)的,線程的調(diào)度完全交給了內(nèi)核,當(dāng)調(diào)用Thread.run 的時候,就會通過系統(tǒng)調(diào)用 fork() 創(chuàng)建一個內(nèi)核線程,這個方法會在用戶態(tài)和內(nèi)核態(tài)之間進行切換,性能沒有在用戶態(tài)實現(xiàn)線程高,當(dāng)然由于直接使用內(nèi)核線程,所以能夠創(chuàng)建的最大線程數(shù)也受內(nèi)核控制。目前 Linux上 的線程模型為 NPTL ( Native POSIX Thread Library),他使用一對一模式,兼容 POSIX 標(biāo)準(zhǔn),沒有使用管理線程,可以更好地在多核 CPU 上運行。
線程的狀態(tài)
對進程而言,就三種狀態(tài),就緒,運行,阻塞,而在 JVM 中,阻塞有四種類型,我們可以通過 jstack 生成 dump 文件查看線程的狀態(tài)。
BLOCKED (on object monitor) 通過 synchronized(obj) 同步塊獲取鎖的時候,等待其他線程釋放對象鎖,dump 文件會顯示 waiting to lock <0x00000000e1c9f108>
TIMED WAITING (on object monitor) 和 WAITING (on object monitor) 在獲取鎖后,調(diào)用了 object.wait() 等待其他線程調(diào)用 object.notify(),兩者區(qū)別是是否帶超時時間
TIMED WAITING (sleeping) 程序調(diào)用了 thread.sleep(),這里如果 sleep(0) 不會進入阻塞狀態(tài),會直接從運行轉(zhuǎn)換為就緒
TIMED WAITING (parking) 和 WAITING (parking) 程序調(diào)用了 Unsafe.park(),線程被掛起,等待某個條件發(fā)生,waiting on condition
而在 POSIX 標(biāo)準(zhǔn)中,thread_block 接受一個參數(shù) stat ,這個參數(shù)也有三種類型,TASK_BLOCKED, TASK_WAITING, TASK_HANGING,而調(diào)度器只會對線程狀態(tài)為 READY 的線程執(zhí)行調(diào)度,另外一點是線程的阻塞是線程自己操作的,相當(dāng)于是線程主動讓出 CPU 時間片,所以等線程被喚醒后,他的剩余時間片不會變,該線程只能在剩下的時間片運行,如果該時間片到期后線程還沒結(jié)束,該線程狀態(tài)會由 RUNNING 轉(zhuǎn)換為 READY ,等待調(diào)度器的下一次調(diào)度。
好了,關(guān)于線程就分析到這,關(guān)于 Java 并發(fā)包,核心都在 AQS 里,底層是通過 UnSafe類的 cas 方法,以及 park 方法實現(xiàn),后面我們在找時間單獨分析,現(xiàn)在我們在看看 Linux 的進程同步方案。
POSIX表示可移植操作系統(tǒng)接口(Portable Operating System Interface of UNIX,縮寫為 POSIX ),POSIX標(biāo)準(zhǔn)定義了操作系統(tǒng)應(yīng)該為應(yīng)用程序提供的接口標(biāo)準(zhǔn)。
CAS 操作需要 CPU 支持,將比較 和 交換 作為一條指令來執(zhí)行, CAS 一般有三個參數(shù),內(nèi)存位置,預(yù)期原值,新值 ,所以UnSafe 類中的 compareAndSwap 用屬性相對對象初始地址的偏移量,來定位內(nèi)存位置。
線程的同步
線程同步出現(xiàn)的根本原因是訪問公共資源需要多個操作,而這多個操作的執(zhí)行過程不具備原子性,被任務(wù)調(diào)度器分開了,而其他線程會破壞共享資源,所以需要在臨界區(qū)做線程的同步,這里我們先明確一個概念,就是臨界區(qū),他是指多個任務(wù)訪問共享資源如內(nèi)存或文件時候的指令,他是指令并不是受訪問的資源。
POSIX 定義了五種同步對象,互斥鎖,條件變量,自旋鎖,讀寫鎖,信號量,這些對象在 JVM 中也都有對應(yīng)的實現(xiàn),并沒有全部使用 POSIX 定義的 api,通過 Java 實現(xiàn)靈活性更高,也避免了調(diào)用native方法的性能開銷,當(dāng)然底層最終都依賴于 pthread 的 互斥鎖 mutex 來實現(xiàn),這是一個系統(tǒng)調(diào)用,開銷很大,所以 JVM 對鎖做了自動升降級,基于AQS的實現(xiàn)以后在分析,這里主要說一下關(guān)鍵字 synchronized 。
當(dāng)聲明 synchronized 的代碼塊時,編譯而成的字節(jié)碼會包含一個 monitorenter 和 多個 monitorexit (多個退出路徑,正常和異常情況),當(dāng)執(zhí)行 monitorenter 的時候會檢查目標(biāo)鎖對象的計數(shù)器是否為0,如果為0則將鎖對象的持有線程設(shè)置為自己,然后計數(shù)器加1,獲取到鎖,如果不為0則檢查鎖對象的持有線程是不是自己,如果是自己就將計數(shù)器加1獲取鎖,如果不是則阻塞等待,退出的時候計數(shù)器減1,當(dāng)減為0的時候清楚鎖對象的持有線程標(biāo)記,可以看出 synchronized 是支持可重入的。
剛剛說到線程的阻塞是一個系統(tǒng)調(diào)用,開銷大,所以 JVM 設(shè)計了自適應(yīng)自旋鎖,就是當(dāng)沒有獲取到鎖的時候, CPU 回進入自旋狀態(tài)等待其他線程釋放鎖,自旋的時間主要看上次等待多長時間獲取的鎖,例如上次自旋5毫秒沒有獲取鎖,這次就6毫秒,自旋會導(dǎo)致 CPU 空跑,另一個副作用就是不公平的鎖機制,因為該線程自旋獲取到鎖,而其他正在阻塞的線程還在等待。除了自旋鎖, JVM 還通過 CAS 實現(xiàn)了輕量級鎖和偏向鎖來分別針對多個線程在不同時間訪問鎖和鎖僅會被一個線程使用的情況。后兩種鎖相當(dāng)于并沒有調(diào)用底層的信號量實現(xiàn)(通過信號量來控制線程A釋放了鎖例如調(diào)用了 wait(),而線程B就可以獲取鎖,這個只有內(nèi)核才能實現(xiàn),后面兩種由于場景里沒有競爭所以也就不需要通過底層信號量控制),只是自己在用戶空間維護了鎖的持有關(guān)系,所以更高效。
如上圖所示,如果線程進入 monitorenter 會將自己放入該 objectmonitor 的 entryset 隊列,然后阻塞,如果當(dāng)前持有線程調(diào)用了 wait 方法,將會釋放鎖,然后將自己封裝成 objectwaiter 放入 objectmonitor 的 waitset 隊列,這時候 entryset 隊列里的某個線程將會競爭到鎖,并進入 active 狀態(tài),如果這個線程調(diào)用了 notify 方法,將會把 waitset 的第一個 objectwaiter 拿出來放入 entryset (這個時候根據(jù)策略可能會先自旋),當(dāng)調(diào)用 notify 的那個線程執(zhí)行 moniterexit 釋放鎖的時候, entryset 里的線程就開始競爭鎖后進入 active 狀態(tài)。
為了讓應(yīng)用程序免于數(shù)據(jù)競爭的干擾, Java 內(nèi)存模型中定義了 happen-before 來描述兩個操作的內(nèi)存可見性,也就是 X 操作 happen-before 操作 Y , 那么 X 操作結(jié)果 對 Y 可見。
JVM 中針對 volatile 以及 鎖 的實現(xiàn)有 happen-before 規(guī)則, JVM 底層通過插入內(nèi)存屏障來限制編譯器的重排序,以 volatile 為例,內(nèi)存屏障將不允許 在 volatile 字段寫操作之前的語句被重排序到寫操作后面 , 也不允許讀取 volatile 字段之后的語句被重排序帶讀取語句之前。插入內(nèi)存屏障的指令,會根據(jù)指令類型不同有不同的效果,例如在 monitorexit 釋放鎖后會強制刷新緩存,而 volatile 對應(yīng)的內(nèi)存屏障會在每次寫入后強制刷新到主存,并且由于 volatile 字段的特性,編譯器無法將其分配到寄存器,所以每次都是從主存讀取,所以 volatile 適用于讀多寫少得場景,最好只有個線程寫多個線程讀,如果頻繁寫入導(dǎo)致不停刷新緩存會影響性能。
關(guān)于應(yīng)用程序中設(shè)置多少線程數(shù)合適的問題,我們一般的做法是設(shè)置 CPU 最大核心數(shù) * 2 ,我們編碼的時候可能不確定運行在什么樣的硬件環(huán)境中,可以通過 Runtime.getRuntime().availableProcessors() 獲取 CPU 核心。
但是具體設(shè)置多少線程數(shù),主要和線程內(nèi)運行的任務(wù)中的阻塞時間有關(guān)系,如果任務(wù)中全部是計算密集型,那么只需要設(shè)置 CPU 核心數(shù)的線程就可以達(dá)到 CPU 利用率最高,如果設(shè)置的太大,反而因為線程上下文切換影響性能,如果任務(wù)中有阻塞操作,而在阻塞的時間就可以讓 CPU 去執(zhí)行其他線程里的任務(wù),我們可以通過 線程數(shù)量=內(nèi)核數(shù)量 / (1 - 阻塞率)這個公式去計算最合適的線程數(shù),阻塞率我們可以通過計算任務(wù)總的執(zhí)行時間和阻塞的時間獲得。
目前微服務(wù)架構(gòu)下有大量的RPC調(diào)用,所以利用多線程可以大大提高執(zhí)行效率,我們可以借助分布式鏈路監(jiān)控來統(tǒng)計RPC調(diào)用所消耗的時間,而這部分時間就是任務(wù)中阻塞的時間,當(dāng)然為了做到極致的效率最大,我們需要設(shè)置不同的值然后進行測試。
Java 中如何實現(xiàn)定時任務(wù)
定時器已經(jīng)是現(xiàn)代軟件中不可缺少的一部分,例如每隔5秒去查詢一下狀態(tài),是否有新郵件,實現(xiàn)一個鬧鐘等, Java 中已經(jīng)有現(xiàn)成的 api 供使用,但是如果你想設(shè)計更高效,更精準(zhǔn)的定時器任務(wù),就需要了解底層的硬件知識,比如實現(xiàn)一個分布式任務(wù)調(diào)度中間件,你可能要考慮到各個應(yīng)用間時鐘同步的問題。
Java 中我們要實現(xiàn)定時任務(wù),有兩種方式,一種通過 timer 類, 另外一種是 JUC 中的 ScheduledExecutorService ,不知道大家有沒有好奇 JVM 是如何實現(xiàn)定時任務(wù)的,難道一直輪詢時間,看是否時間到了,如果到了就調(diào)用對應(yīng)的處理任務(wù),但是這種一直輪詢不釋放 CPU 肯定是不可取的,要么就是線程阻塞,等到時間到了在來喚醒線程,那么 JVM 怎么知道時間到了,如何喚醒呢?
首先我們翻一下 JDK ,發(fā)現(xiàn)和時間相關(guān)的 API 大概有3處,而且這 3 處還都對時間的精度做了區(qū)分:
object.wait(long millisecond) 參數(shù)是毫秒,必須大于等于 0 ,如果等于 0 ,就一直阻塞直到其他線程來喚醒 ,timer 類就是通過 wait() 方法來實現(xiàn),下面我們看一下wait的另外一個方法:
public final void wait(long timeout, int nanos) throws InterruptedException { if (timeout < 0) { throw new IllegalArgumentException("timeout value is negative"); } if (nanos < 0 || nanos > 999999) { throw new IllegalArgumentException( "nanosecond timeout value out of range"); } if (nanos > 0) { timeout++; } wait(timeout); }
這個方法是想提供一個可以支持納秒級的超時時間,然而只是粗暴的加 1 毫秒。
Thread.sleep(long millisecond) 目前一般通過這種方式釋放 CPU ,如果參數(shù)為 0 ,表示釋放 CPU 給更高優(yōu)先級的線程,自己從運行狀態(tài)轉(zhuǎn)換為可運行態(tài)等待 CPU 調(diào)度,他也提供了一個可以支持納秒級的方法實現(xiàn),跟 wait 額區(qū)別是它通過 500000 來分隔是否要加 1 毫秒。
public static void sleep(long millis, int nanos) throws InterruptedException { if (millis < 0) { throw new IllegalArgumentException("timeout value is negative"); } if (nanos < 0 || nanos > 999999) { throw new IllegalArgumentException( "nanosecond timeout value out of range"); } if (nanos >= 500000 || (nanos != 0 && millis == 0)) { millis++; } sleep(millis); }
LockSupport.park(long nans) Condition.await()調(diào)用的該方法, ScheduledExecutorService 用的 condition.await() 來實現(xiàn)阻塞一定的超時時間,其他帶超時參數(shù)的方法也都通過他來實現(xiàn),目前大多定時器都是通過這個方法來實現(xiàn)的,該方法也提供了一個布爾值來確定時間的精度。
System.currentTimeMillis() 以及 System.nanoTime() 這兩種方式都依賴于底層操作系統(tǒng),前者是毫秒級,經(jīng)測試 windows 平臺的頻率可能超過 10ms ,而后者是納秒級別,頻率在 100ns 左右,所以如果要獲取更精準(zhǔn)的時間建議用后者好了,api 了解完了,我們來看下定時器的底層是怎么實現(xiàn)的,現(xiàn)代PC機中有三種硬件時鐘的實現(xiàn),他們都是通過晶體振動產(chǎn)生的方波信號輸入來完成時鐘信號同步的。
實時時鐘 RTC ,用于長時間存放系統(tǒng)時間的設(shè)備,即使關(guān)機也可以依靠主板中的電池繼續(xù)計時。Linux 啟動的時候會從 RTC 中讀取時間和日期作為初始值,之后在運行期間通過其他計時器去維護系統(tǒng)時間。
可編程間隔定時器 PIT ,該計數(shù)器會有一個初始值,每過一個時鐘周期,該初始值會減1,當(dāng)該初始值被減到0時,就通過導(dǎo)線向 CPU 發(fā)送一個時鐘中斷, CPU 就可以執(zhí)行對應(yīng)的中斷程序,也就是回調(diào)對應(yīng)的任務(wù)
時間戳計數(shù)器 TSC , 所有的 Intel8086 CPU 中都包含一個時間戳計數(shù)器對應(yīng)的寄存器,該寄存器的值會在每次 CPU 收到一個時鐘周期的中斷信號后就會加 1 。他比 PIT 精度高,但是不能編程,只能讀取。
時鐘周期:硬件計時器在多長時間內(nèi)產(chǎn)生時鐘脈沖,而時鐘周期頻率為1秒內(nèi)產(chǎn)生時鐘脈沖的個數(shù)。目前通常為1193180。
時鐘滴答:當(dāng)PIT中的初始值減到0的時候,就會產(chǎn)生一次時鐘中斷,這個初始值由編程的時候指定。
Linux啟動的時候,先通過 RTC 獲取初始時間,之后內(nèi)核通過 PIT 中的定時器的時鐘滴答來維護日期,并且會定時將該日期寫入 RTC,而應(yīng)用程序的定時器主要是通過設(shè)置 PIT 的初始值設(shè)置的,當(dāng)初始值減到0的時候,就表示要執(zhí)行回調(diào)函數(shù)了,這里大家會不會有疑問,這樣同一時刻只能有一個定時器程序了,而我們在應(yīng)用程序中,以及多個應(yīng)用程序之間,肯定有好多定時器任務(wù),其實我們可以參考 ScheduledExecutorService 的實現(xiàn)。
只需要將這些定時任務(wù)按照時間做一個排序,越靠前待執(zhí)行的任務(wù)放在前面,第一個任務(wù)到了在設(shè)置第二個任務(wù)相對當(dāng)前時間的值,畢竟 CPU 同一時刻也只能運行一個任務(wù),關(guān)于時間的精度問題,我們無法在軟件層面做的完全精準(zhǔn),畢竟 CPU 的調(diào)度不完全受用戶程序控制,當(dāng)然更大的依賴是硬件的時鐘周期頻率,目前 TSC 可以提高更高的精度。
現(xiàn)在我們知道了,Java 中的超時時間,是通過可編程間隔定時器設(shè)置一個初始值然后等待中斷信號實現(xiàn)的,精度上受硬件時鐘周期的影響,一般為毫秒級別,畢竟1納秒光速也只有3米,所以 JDK 中帶納秒?yún)?shù)的實現(xiàn)都是粗暴做法,預(yù)留著等待精度更高的定時器出現(xiàn),而獲取當(dāng)前時間 System.currentTimeMillis() 效率會更高,但他是毫秒級精度,他讀取的 Linux 內(nèi)核維護的日期,而 System.nanoTime() 會優(yōu)先使用 TSC ,性能稍微低一點,但他是納秒級,Random 類為了防止沖突就用nanoTime生成種子。
Java 如何和外部設(shè)備通信
計算機的外部設(shè)備有鼠標(biāo)、鍵盤、打印機、網(wǎng)卡等,通常我們將外部設(shè)備和和主存之間的信息傳遞稱為 I/O 操作 , 按操作特性可以分為,輸出型設(shè)備,輸入型設(shè)備,存儲設(shè)備?,F(xiàn)代設(shè)備都采用通道方式和主存進行交互,通道是一個專門用來處理IO任務(wù)的設(shè)備, CPU 在處理主程序時遇到I/O請求,啟動指定通道上選址的設(shè)備,一旦啟動成功,通道開始控制設(shè)備進行操作,而 CPU 可以繼續(xù)執(zhí)行其他任務(wù),I/O 操作完成后,通道發(fā)出 I/O 操作結(jié)束的中斷,處理器轉(zhuǎn)而處理 IO 結(jié)束后的事件。其他處理 IO 的方式,例如輪詢、中斷、DMA,在性能上都不見通道,這里就不介紹了。當(dāng)然 Java 程序和外部設(shè)備通信也是通過系統(tǒng)調(diào)用完成,這里也不在繼續(xù)深入了。
感謝各位的閱讀,以上就是“web程序設(shè)計的底層邏輯有哪些”的內(nèi)容了,經(jīng)過本文的學(xué)習(xí)后,相信大家對web程序設(shè)計的底層邏輯有哪些這一問題有了更深刻的體會,具體使用情況還需要大家實踐驗證。這里是億速云,小編將為大家推送更多相關(guān)知識點的文章,歡迎關(guān)注!
免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進行舉報,并提供相關(guān)證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權(quán)內(nèi)容。