你寫的java代碼是怎麼在操做系統底層執行的?看完這篇你就知道了

結合 CPU 理解一行 Java 代碼是怎麼執行的linux

根據馮·諾依曼思想,計算機採用二進制做爲數制基礎,必須包含:運算器、控制器、存儲設備,以及輸入輸出設備,以下圖所示. 編程

能夠在業餘時間接app套殼上架的速速加我,長期合做,酬金豐厚,不容錯過!363920551windows

(該圖來源於百度)api

咱們先來分析 CPU 的工做原理,現代 CPU 芯片中大都集成了,控制單元、運算單元、存儲單元,控制單元是 CPU 的控制中心,CPU 須要經過它才知道下一步作什麼,也就是執行什麼指令,控制單元又包含:指令寄存器( IR ),指令譯碼器( ID )和操做控制器( OC )。數組

當程序被加載進內存後,指令就在內存中了,這個時候說的內存是獨立於 CPU 外的主存設備,也就是 PC 機中的內存條,指令指針寄存器IP 指向內存中下一條待執行指令的地址,控制單元根據 IP寄存器的指向,將主存中的指令裝載到指令寄存器,這個指令寄存器也是一個存儲設備,不過他集成在 CPU 內部,指令從主存到達 CPU 後只是一串 010101 的二進制串,還須要經過譯碼器解碼,分析出操做碼是什麼,操做數在哪,以後就是具體的運算單元進行算術運算(加減乘除),邏輯運算(比較,位移)。而 CPU 指令執行過程大體爲:取址(去主存獲取指令放到寄存器),譯碼(從主存獲取操做數放入高速緩存 L1 ),執行(運算)。緩存

這裏解釋下:上圖中 CPU 內部集成的存儲單元 SRAM ,正好和主存中的 DRAM 對應, RAM 是隨機訪問內存,就是給一個地址就能訪問到數據,而磁盤這種存儲媒介必須順序訪問,而 RAM 又分爲動態和靜態兩種:靜態 RAM 因爲集成度較低,通常容量小,速度快,而動態 RAM 集成度較高,主要經過給電容充電和放電實現,速度沒有靜態 RAM 快。因此通常將動態 RAM 作爲主存,而靜態 RAM 做爲 CPU 和主存之間的高速緩存(cache),用來屏蔽 CPU 和主存速度上的差別,也就是咱們常常看到的 L1 , L2 緩存。每一級別緩存速度變低,容量變大。下圖展現了存儲器的層次化架構,以及 CPU 訪問主存的過程,這裏有兩個知識點,一個是多級緩存之間爲保證數據的一致性,而推出的緩存一致性協議,具體能夠參考這篇文章,另一個知識點是, cache 和主存的映射,首先要明確的是 cahce 緩存的單位是緩存行,對應主存中的一個內存塊,並非一個變量,這個主要是由於** CPU 訪問的空間侷限性:被訪問的某個存儲單元,在一個較短期內,頗有可能再次被訪問到,以及空間侷限性:被訪問的某個存儲單元,在較短期內,他的相鄰存儲單元也會被訪問到。**而映射方式有不少種,相似於 cache 行號 = 主存塊號 mod cache總行數,這樣每次獲取到一個主存地址,根據這個地址計算出在主存中的塊號就能夠計算出在 cache 中的行號。安全

下面咱們接着聊 CPU 的指令執行、取址、譯碼、執行,這是一個指令的執行過程,全部指令都會嚴格按照這個順序執行,可是多個指令之間實際上是能夠並行的,對於單核 CPU 來講,同一時刻只能有一條指令可以佔有執行單元運行。這裏說的執行是 CPU 指令處理(取指、譯碼、執行)三步驟中的第三步,也就是運算單元的計算任務,因此爲了提高 CPU 的指令處理速度,因此須要保證運算單元在執行前的準備工做都完成,這樣運算單元就能夠一直處於運算中,而剛剛的串行流程中,取指,解碼的時候運算單元是空閒的,並且取指和解碼若是沒有命中高速緩存還須要從主存取,,而主存的速度和 CPU 不在一個級別上,因此指令流水線 能夠大大提升 CPU 的處理速度,下圖是一個3級流水線的示例圖,而如今的奔騰 CPU 都是32級流水線,具體作法就是將上面三個流程拆分的更細。網絡

除了指令流水線, CPU 還有分支預測,亂序執行等優化速度的手段。好了,咱們回到正題,一行 Java 代碼是怎麼執行的.多線程

一行代碼可以執行,必需要有能夠執行的上下文環境,包括,指令寄存器,數據寄存器,棧空間等內存資源,而後這行代碼必須做爲一個執行流可以被操做系統的任務調度器識別,並給他分配 CPU 資源,固然這行代碼所表明的指令必須是 CPU 能夠解碼識別的,因此一行 Java 代碼必須被解釋成對應的 CPU 指令才能執行.下面咱們看下System.out.println("Hello world")這行代碼的轉譯過程.架構

Java 是一門高級語言,這類語言不能直接運行在硬件上,必須運行在可以識別 Java 語言特性的虛擬機上,而 Java 代碼必須經過 Java 編譯器將其轉換成虛擬機所能識別的指令序列,也稱爲 Java 字節碼,之因此稱爲字節碼是由於 Java 字節碼的操做指令(OpCode)被固定爲一個字節,如下爲 System.out.println("Hello world") 編譯後的字節碼

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
複製代碼

最左列是偏移;中間列是給虛擬機讀的字節碼;最右列是高級語言的代碼,下面是經過彙編語言轉換成的機器指令,中間是機器碼,第三列爲對應的機器指令,最後一列是對應的彙編代碼

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!
"
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
; 調用 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 文件裏的字節碼後,會經過解釋器解釋成彙編指令,最終再轉譯成 CPU 能夠識別的機器指令,解釋器是軟件來實現的,主要是爲了實現同一份 Java 字節碼能夠在不一樣的硬件平臺上運行,而將彙編指令轉換成機器指令由硬件直接實現,這一步速度是很快的,固然 JVM 爲了提升運行效率也能夠將某些熱點代碼(一個方法內的代碼)一次所有編譯成機器指令後而後在執行,也就是和解釋執行對應的即時編譯(JIT), JVM 啓動的時候能夠經過 -Xint 和 -Xcomp 來控制執行模式.

從軟件層面上, class 文件被加載進虛擬機後,類信息會存放在方法區,在實際運行的時候會執行方法區中的代碼,在 JVM 中全部的線程共享堆內存和方法區,而每一個線程有本身獨立的 Java 方法棧,本地方法棧(面向 native 方法),PC寄存器(存放線程執行位置),當調用一個方法的時候, Java 虛擬機會在當前線程對應的方法棧中壓入一個棧幀,用來存放 Java 字節碼操做數以及局部變量,這個方法執行完會彈出棧幀,一個線程會連續執行多個方法,對應不一樣的棧幀的壓入和彈出,壓入棧幀後就是 JVM 解釋執行的過程了.

能夠在業餘時間接app套殼上架的速速加我,長期合做,酬金豐厚,不容錯過!363920551

中斷

剛剛說到, CPU 只要一上電就像一個永動機, 不停的取指令,運算,周而復始,而中斷即是操做系統的靈魂,故名思議,中斷就是打斷 CPU 的執行過程,轉而去作點別的,例如系統執行期間發生了致命錯誤,須要結束執行,例如用戶程序調用了一個系統調用的方法,例如mmp等,就會經過中斷讓 CPU 切換上下文,轉到內核空間,例如一個等待用戶輸入的程序正在阻塞,而當用戶經過鍵盤完成輸入,內核數據已經準備好後,就會發一箇中斷信號,喚醒用戶程序把數據從內核取走,否則內核可能會數據溢出,當磁盤報了一個致命異常,也會經過中斷通知 CPU ,定時器完成時鐘滴答也會發時鐘中斷通知 CPU .

中斷的種類,咱們這裏就不作細分了,中斷有點相似於咱們常常說的事件驅動編程,而這個事件通知機制是怎麼實現的呢,硬件中斷的實現經過一個導線和 CPU 相連來傳輸中斷信號,軟件上會有特定的指令,例如執行系統調用建立線程的指令,而 CPU 每執行完一個指令,就會檢查中斷寄存器中是否有中斷,若是有就取出而後執行該中斷對應的處理程序.

陷入內核 : 咱們在設計軟件的時候,會考慮程序上下文切換的頻率,頻率過高確定會影響程序執行性能,而陷入內核是針對 CPU 而言的, CPU 的執行從用戶態轉向內核態,之前是用戶程序在使用 CPU ,如今是內核程序在使用 CPU ,這種切換是經過系統調用產生的,系統調用是執行操做系統底層的程序,Linux的設計者,爲了保護操做系統,將進程的執行狀態用內核態和用戶態分開,同一個進程中,內核和用戶共享同一個地址空間,通常 4G 的虛擬地址,其中 1G 給內核態, 3G 給用戶態.在程序設計的時候咱們要儘可能減小用戶態到內核態的切換,例如建立線程是一個系統調用,因此咱們有了線程池的實現. 從 Linux 內存管理角度理解 JVM 內存模型

進程上下文

咱們能夠將程序理解爲一段可執行的指令集合,而這個程序啓動後,操做系統就會爲他分配 CPU ,內存等資源,而這個正在運行的程序就是咱們說的進程,進程是操做系統對處理器中運行的程序的一種抽象,而爲進程分配的內存以及 CPU 資源就是這個進程的上下文,保存了當前執行的指令,以及變量值,而 JVM 啓動後也是linux上的一個普通進程,進程的物理實體和支持進程運行的環境合稱爲上下文,而上下文切換就是將當前正在運行的進程換下,換一個新的進程處處理器運行,以此來讓多個進程併發的執行,上下文切換可能來自操做系統調度,也有可能來自程序內部,例如讀取IO的時候,會讓用戶代碼和操做系統代碼之間進行切換.

虛擬存儲

當咱們同時啓動多個 JVM 執行: System.out.println(new Object()); 將會打印這個對象的 hashcode ,hashcode 默認爲內存地址,最後發現他們打印的都是 Java .lang.Object@4fca772d ,也就是多個進程返回的內存地址居然是同樣的.

經過上面的例子咱們能夠證實,linux中每一個進程有單獨的地址空間,在此以前,咱們先了解下 CPU 是如何訪問內存的?

假設咱們如今尚未虛擬地址,只有物理地址,編譯器在編譯程序的時候,須要將高級語言轉換成機器指令,那麼 CPU 訪問內存的時候必須指定一個地址,這個地址若是是一個絕對的物理地址,那麼程序就必須放在內存中的一個固定的地方,並且這個地址須要在編譯的時候就要確認,你們應該想到這樣有多坑了吧, 若是我要同時運行兩個 office word 程序,那麼他們將操做同一塊內存,那就亂套了,偉大的計算機前輩設計出,讓 CPU

採用 段基址 + 段內偏移地址 的方式訪問內存,其中段基地址在程序啓動的時候確認,儘管這個段基地址仍是絕對的物理地址,但終究能夠同時運行多個程序了, CPU 採用這種方式訪問內存,就須要段基址寄存器和段內偏移地址寄存器來存儲地址,最終將兩個地址相加送上地址總線.而內存分段,至關於每一個進程都會分配一個內存段,並且這個內存段須要是一塊連續的空間,主存裏維護着多個內存段,當某個進程須要更多內存,而且超出物理內存的時候,就須要將某個不經常使用的內存段換到硬盤上,等有充足內存的時候在從硬盤加載進來,也就是 swap .每次交換都須要操做整個段的數據.

首先連續的地址空間是很寶貴的,例如一個 50M 的內存,在內存段之間有空隙的狀況下,將沒法支持 5 個須要 10M 內存才能運行的程序,如何才能讓段內地址不連續呢? 答案是內存分頁.

在保護模式下,每個進程都有本身獨立的地址空間,因此段基地址是固定的,只須要給出段內偏移地址就能夠了,而這個偏移地址稱爲線性地址,線性地址是連續的,而內存分頁將連續的線性地址和和分頁後的物理地址相關聯,這樣邏輯上的連續線性地址能夠對應不連續的物理地址.物理地址空間能夠被多個進程共享,而這個映射關係將經過頁表( page table)進行維護. 標準頁的尺寸通常爲 4KB ,分頁後,物理內存被分紅若干個 4KB 的數據頁,進程申請內存的時候,能夠映射爲多個 4KB 大小的物理內存,而應用程序讀取數據的時候會以頁爲最小單位,當須要和硬盤發生交換的時候也是以頁爲單位.

現代計算機多采用虛擬存儲技術,虛擬存儲讓每一個進程覺得本身獨佔整個內存空間,其實這個虛擬空間是主存和磁盤的抽象,這樣的好處是,每一個進程擁有一致的虛擬地址空間,簡化了內存管理,進程不須要和其餘進程競爭內存空間,由於他是獨佔的,也保護了各自進程不被其餘進程破壞,另外,他把主存當作磁盤的一個緩存,主存中僅保存活動的程序段和數據段,當主存中不存在數據的時候發生缺頁中斷,而後從磁盤加載進來,當物理內存不足的時候會發生 swap 到磁盤.頁表保存了虛擬地址和物理地址的映射,頁表是一個數組,每一個元素爲一個頁的映射關係,這個映射關係多是和主存地址,也可能和磁盤,頁表存儲在主存,咱們將存儲在高速緩衝區 cache 中的頁表稱爲快表 TLAB .

*裝入位 表示對於頁是否在主存,若是地址頁每頁表示,數據還在磁盤
*存放位置 創建虛擬頁和物理頁的映射,用於地址轉換,若是爲null表示是一個未分配頁
*修改位 用來存儲數據是否修改過
*權限位 用來控制是否有讀寫權限
*禁止緩存位 主要用來保證 cache 主存 磁盤的數據一致性
複製代碼

內存映射

正常狀況下,咱們讀取文件的流程爲,先經過系統調用從磁盤讀取數據,存入操做系統的內核緩衝區,而後在從內核緩衝區拷貝到用戶空間,而內存映射,是將磁盤文件直接映射到用戶的虛擬存儲空間中,經過頁表維護虛擬地址到磁盤的映射,經過內存映射的方式讀取文件的好處有,由於減小了從內核緩衝區到用戶空間的拷貝,直接從磁盤讀取數據到內存,減小了系統調用的開銷,對用戶而言,彷彿直接操做的磁盤上的文件,另外因爲使用了虛擬存儲,因此不須要連續的主存空間來存儲數據.

在 Java 中,咱們使用 MappedByteBuffer 來實現內存映射,這是一個堆外內存,在映射完以後,並無當即佔有物理內存,而是訪問數據頁的時候,先查頁表,發現還沒加載,發起缺頁異常,而後在從磁盤將數據加載進內存,因此一些對實時性要求很高的中間件,例如rocketmq,消息存儲在一個大小爲1G的文件中,爲了加快讀寫速度,會將這個文件映射到內存後,在每一個頁寫一比特數據,這樣就能夠把整個1G文件都加載進內存,在實際讀寫的時候就不會發生缺頁了,這個在rocketmq內部叫作文件預熱.

下面咱們貼一段 rocketmq 消息存儲模塊的代碼,位於 MappedFile 類中,這個類是 rocketMq 消息存儲的核心類感興趣的能夠自行研究,下面兩個方法一個是建立文件映射,一個是預熱文件,每預熱 1000 個數據頁,就讓出 CPU 權限.

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();
}
}
}
//文件預熱,OS_PAGE_SIZE = 4kb 至關於每 4kb 就寫一個 byte 0 ,將全部的頁都加載到內存,真正使用的時候就不會發生缺頁異常了
public void warmMappedFile(FlushDiskType type, int pages) {
long beginTime = System.currentTimeMillis();
ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
int flush = 0;
long time = System.currentTimeMillis();
for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
byteBuffer.put(i, (byte) 0);
// force flush when flush disk type is sync
if (type == FlushDiskType.SYNC_FLUSH) {
if ((i / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE) >= pages) {
flush = i;
mappedByteBuffer.force();
}
}
// prevent gc
if (j % 1000 == 0) {
log.info("j={}, costTime={}", j, System.currentTimeMillis() - time);
time = System.currentTimeMillis();
try {
// 這裏sleep(0),讓線程讓出 CPU 權限,供其餘更高優先級的線程執行,此線程從運行中轉換爲就緒
Thread.sleep(0);
} catch (InterruptedException e) {
log.error("Interrupted", e);
}
}
}
// force flush when prepare load finished
if (type == FlushDiskType.SYNC_FLUSH) {
log.info("mapped file warm-up done, force to disk, mappedFile={}, costTime={}",
this.getFileName(), System.currentTimeMillis() - beginTime);
mappedByteBuffer.force();
}
log.info("mapped file warm-up done. mappedFile={}, costTime={}", this.getFileName(),
System.currentTimeMillis() - beginTime);
this.mlock();
}
複製代碼

JVM 中對象的內存佈局

在linux中只要知道一個變量的起始地址就能夠讀出這個變量的值,由於從這個起始地址起前8位記錄了變量的大小,也就是能夠定位到結束地址,在 Java 中咱們能夠經過 Field.get(object) 的方式獲取變量的值,也就是反射,最終是經過 UnSafe 類來實現的.咱們能夠分析下具體代碼

Field 對象的 getInt方法 先安全檢查 ,而後調用 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 調用unsafe中的方法
public int getInt(Object var1) throws IllegalArgumentException {
return unsafe.getInt(this.base, this.fieldOffset);
}
複製代碼

經過上面的代碼咱們能夠經過屬性相對對象起始地址的偏移量,來讀取和寫入屬性的值,這也是 Java 反射的原理,這種模式在jdk中不少場景都有用到,例如LockSupport.park中設置阻塞對象. 那麼屬性的偏移量具體根據什麼規則來肯定的呢? 下面咱們藉此機會分析下 Java 對象的內存佈局

在 Java 虛擬機中,每一個 Java 對象都有一個對象頭 (object header) ,由標記字段和類型指針構成,標記字段用來存儲對象的哈希碼, GC 信息, 持有的鎖信息,而類型指針指向該對象的類 Class ,在 64 位操做系統中,標記字段佔有 64 位,而類型指針也佔 64 位,也就是說一個 Java 對象在

什麼屬性都沒有的狀況下要佔有 16 字節的空間,當前 JVM 中默認開啓了壓縮指針,這樣類型指針能夠只佔 32 位,因此對象頭佔 12 字節, 壓縮指針能夠做用於對象頭,以及引用類型的字段. JVM 爲了內存對齊,會對字段進行重排序,這裏的對齊主要指 Java 虛擬機堆中的對象的起始地址爲 8 的倍數,若是一個對象用不到 8N 個字節,那麼剩下的就會被填充,另外子類繼承的屬性的偏移量和父類一致,

以 Long 爲例,他只有一個非 static 屬性 value ,而儘管對象頭只佔有 12 字節,而屬性 value 的偏移量只能是 16, 其中 4 字節只能浪費掉,因此字段重排就是爲了不內存浪費, 因此咱們很難在 Java 字節碼被加載以前分析出這個 Java 對象佔有的實際空間有多大,咱們只能經過遞歸父類的全部屬性來預估對象大小,而真實佔用的大小能夠經過 Java agent 中的 Instrumentation獲取.

固然內存對齊另一個緣由是爲了讓字段只出如今同一個 CPU 的緩存行中,若是字段不對齊,就有可能出現一個字段的一部分在緩存行 1 中,而剩下的一半在 緩存行 2 中,這樣該字段的讀取須要替換兩個緩存行,而字段的寫入會致使兩個緩存行上緩存的其餘數據都無效,這樣會影響程序性能.

經過內存對齊能夠避免一個字段同時存在兩個緩存行裏的狀況,但仍是沒法徹底規避緩存僞共享的問題,也就是一個緩存行中存了多個變量,而這幾個變量在多核 CPU 並行的時候,會致使競爭緩存行的寫權限,當其中一個 CPU 寫入數據後,這個字段對應的緩存行將失效,致使這個緩存行的其餘字段也失效.

在 Disruptor 中,經過填充幾個無心義的字段,讓對象的大小恰好在 64 字節,一個緩存行的大小爲64字節,這樣這個緩存行就只會給這一個變量使用,從而避免緩存行僞共享,可是在 jdk7 中,因爲無效字段被清除致使該方法失效,只能經過繼承父類字段來避免填充字段被優化,而 jdk8 提供了註解

@Contended 來標示這個變量或對象將獨享一個緩存行,使用這個註解必須在 JVM 啓動的時候加上 -XX:-RestrictContended 參數,其實也是用空間換取時間.

jdk6 --- 32 位系統下
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 調度執行的最小單位,線程的出現是爲了減小進程的上下文切換(線程的上下文切換比進程小不少),以及更好適配多核心 CPU 環境,例如一個進程下多個線程能夠分別在不一樣的 CPU 上執行,而多線程的支持,既能夠放在Linux內核實現,也能夠在覈外實現,若是放在覈外,只須要完成運行棧的切換,調度開銷小,可是這種方式沒法適應多 CPU 環境,底層的進程仍是運行在一個 CPU 上,另外因爲對用戶編程要求高,因此目前主流的操做系統都是在內核支持線程,而在Linux中,線程是一個輕量級進程,只是優化了線程調度的開銷.而在 JVM 中的線程和內核線程是一一對應的,線程的調度徹底交給了內核,當調用

Thread.run 的時候,就會經過系統調用 fork() 建立一個內核線程,這個方法會在用戶態和內核態之間進行切換,性能沒有在用戶態實現線程高,固然因爲直接使用內核線程,因此可以建立的最大線程數也受內核控制.目前 Linux上 的線程模型爲 NPTL ( Native POSIX Thread Library),他使用一對一模式,兼容 POSIX 標準,沒有使用管理線程,能夠更好地在多核 CPU 上運行.

線程的狀態

對進程而言,就三種狀態,就緒,運行,阻塞,而在 JVM 中,阻塞有四種類型,咱們能夠經過 jstack 生成 dump 文件查看線程的狀態.

*BLOCKED (on object monitor) 經過 synchronized(obj) 同步塊獲取鎖的時候,等待其餘線程釋放對象鎖,dump 文件會顯示 waiting to lock <0x00000000e1c9f108>
*TIMED WAITING (on object monitor) 和 WAITING (on object monitor) 在獲取鎖後,調用了 object.wait() 等待其餘線程調用 object.notify(),二者區別是是否帶超時時間
*TIMED WAITING (sleeping) 程序調用了 thread.sleep(),這裏若是 sleep(0) 不會進入阻塞狀態,會直接從運行轉換爲就緒
* TIMED WAITING (parking) 和 WAITING (parking) 程序調用了 Unsafe.park(),線程被掛起,等待某個條件發生,waiting on condition
複製代碼

而在 POSIX 標準中,thread_block 接受一個參數 stat ,這個參數也有三種類型,TASK_BLOCKED, TASK_WAITING, TASK_HANGING,而調度器只會對線程狀態爲 READY 的線程執行調度,另一點是線程的阻塞是線程本身操做的,至關因而線程主動讓出 CPU 時間片,因此等線程被喚醒後,他的剩餘時間片不會變,該線程只能在剩下的時間片運行,若是該時間片到期後線程還沒結束,該線程狀態會由 RUNNING 轉換爲 READY ,等待調度器的下一次調度.

好了,關於線程就分析到這,關於 Java 併發包,核心都在 AQS 裏,底層是經過 UnSafe類的 cas 方法,以及 park 方法實現,後面咱們在找時間單獨分析,如今咱們在看看 Linux 的進程同步方案.

POSIX表示可移植操做系統接口(Portable Operating System Interface of UNIX,縮寫爲 POSIX ),POSIX標準定義了操做系統應該爲應用程序提供的接口標準。CAS 操做須要 CPU 支持,將比較 和 交換 做爲一條指令來執行, CAS 通常有三個參數,內存位置,預期原值,新值 ,因此UnSafe 類中的 compareAndSwap 用屬性相對對象初始地址的偏移量,來定位內存位置.
複製代碼

線程的同步

線程同步出現的根本緣由是訪問公共資源須要多個操做,而這多個操做的執行過程不具有原子性,被任務調度器分開了,而其餘線程會破壞共享資源,因此須要在臨界區作線程的同步,這裏咱們先明確一個概念,就是臨界區,他是指多個任務訪問共享資源如內存或文件時候的指令,他是指令並非受訪問的資源.

POSIX 定義了五種同步對象,互斥鎖,條件變量,自旋鎖,讀寫鎖,信號量,這些對象在 JVM 中也都有對應的實現,並無所有使用 POSIX 定義的 api,經過 Java 實現靈活性更高,也避免了調用native方法的性能開銷,固然底層最終都依賴於 pthread 的 互斥鎖 mutex 來實現,這是一個系統調用,開銷很大,因此 JVM 對鎖作了自動升降級,基於AQS的實現之後在分析,這裏主要說一下關鍵字 synchronized .

當聲明 synchronized 的代碼塊時,編譯而成的字節碼會包含一個 monitorenter 和 多個 monitorexit (多個退出路徑,正常和異常狀況),當執行 monitorenter 的時候會檢查目標鎖對象的計數器是否爲0,若是爲0則將鎖對象的持有線程設置爲本身,而後計數器加1,獲取到鎖,若是不爲0則檢查鎖對象的持有線程是否是本身,若是是本身就將計數器加1獲取鎖,若是不是則阻塞等待,退出的時候計數器減1,當減爲0的時候清楚鎖對象的持有線程標記,能夠看出 synchronized 是支持可重入的.

剛剛說到線程的阻塞是一個系統調用,開銷大,因此 JVM 設計了自適應自旋鎖,就是當沒有獲取到鎖的時候, CPU 回進入自旋狀態等待其餘線程釋放鎖,自旋的時間主要看上次等待多長時間獲取的鎖,例如上次自旋5毫秒沒有獲取鎖,此次就6毫秒,自旋會致使 CPU 空跑,另外一個副總用就是不公平的鎖機制,由於該線程自旋獲取到鎖,而其餘正在阻塞的線程還在等待.除了自旋鎖, JVM 還經過 CAS 實現了輕量級鎖和偏向鎖來分別針對多個線程在不一樣時間訪問鎖和鎖僅會被一個線程使用的狀況.後兩種鎖至關於並無調用底層的信號量實現(經過信號量來控制線程A釋放了鎖例如調用了 wait(),而線程B就能夠獲取鎖,這個只有內核才能實現,後面兩種因爲場景裏沒有競爭因此也就不須要經過底層信號量控制),只是本身在用戶空間維護了鎖的持有關係,因此更高效.

如上圖所示,若是線程進入 monitorenter 會將本身放入該 objectmonitor 的 entryset 隊列,而後阻塞,若是當前持有線程調用了 wait 方法,將會釋放鎖,而後將本身封裝成 objectwaiter 放入 objectmonitor 的 waitset 隊列,這時候 entryset 隊列裏的某個線程將會競爭到鎖,並進入 active 狀態,若是這個線程調用了 notify 方法,將會把 waitset 的第一個 objectwaiter 拿出來放入 entryset (這個時候根據策略可能會先自旋),當調用 notify 的那個線程執行 moniterexit 釋放鎖的時候, entryset 裏的線程就開始競爭鎖後進入 active 狀態.

爲了讓應用程序免於數據競爭的干擾, Java 內存模型中定義了 happen-before 來描述兩個操做的內存可見性,也就是 X 操做 happen-before 操做 Y , 那麼 X 操做結果 對 Y 可見. JVM 中針對 volatile 以及 鎖 的實現有 happen-before 規則, JVM 底層經過插入內存屏障來限制編譯器的重排序,以 volatile 爲例,內存屏障將不容許 在 volatile 字段寫操做以前的語句被重排序到寫操做後面 , 也不容許讀取 volatile 字段以後的語句被重排序帶讀取語句以前.插入內存屏障的指令,會根據指令類型不一樣有不一樣的效果,例如在 monitorexit 釋放鎖後會強制刷新緩存,而 volatile 對應的內存屏障會在每次寫入後強制刷新到主存,而且因爲 volatile 字段的特性,編譯器沒法將其分配到寄存器,因此每次都是從主存讀取,因此 volatile 適用於讀多寫少得場景,最好只有個線程寫多個線程讀,若是頻繁寫入致使不停刷新緩存會影響性能.

關於應用程序中設置多少線程數合適的問題,咱們通常的作法是設置 CPU 最大核心數 * 2 ,咱們編碼的時候可能不肯定運行在什麼樣的硬件環境中,能夠經過 Runtime.getRuntime().availableProcessors() 獲取 CPU 核心,可是具體設置多少線程數,主要和線程內運行的任務中的阻塞時間有關係,若是任務中所有是計算密集型,那麼只須要設置 CPU 核心數的線程就能夠達到 CPU 利用率最高,若是設置的太大,反而由於線程上下文切換影響性能,若是任務中有阻塞操做,而在阻塞的時間就可讓 CPU 去執行其餘線程裏的任務,咱們能夠經過 線程數量=內核數量 / (1 - 阻塞率)這個公式去計算最合適的線程數,阻塞率咱們能夠經過計算任務總的執行時間和阻塞的時間得到,目前微服務架構下有大量的RPC調用,因此利用多線程能夠大大提升執行效率,咱們能夠藉助分佈式鏈路監控來統計RPC調用所消耗的時間,而這部分時間就是任務中阻塞的時間,固然爲了作到極致的效率最大,咱們須要設置不一樣的值而後進行測試.
複製代碼

Java 中如何實現定時任務

定時器已是現代軟件中不可缺乏的一部分,例如每隔5秒去查詢一下狀態,是否有新郵件,實現一個鬧鐘等, Java 中已經有現成的 api 供使用,可是若是你想設計更高效,更精準的定時器任務,就須要瞭解底層的硬件知識,好比實現一個分佈式任務調度中間件,你可能要考慮到各個應用間時鐘同步的問題.

Java 中咱們要實現定時任務,有兩種方式,一種經過 timer 類, 另一種是 JUC 中的 ScheduledExecutorService ,不知道你們有沒有好奇 JVM 是如何實現定時任務的,難道一直輪詢時間,看是否時間到了,若是到了就調用對應的處理任務,可是這種一直輪詢不釋放 CPU 確定是不可取的,要麼就是線程阻塞,等到時間到了在來喚醒線程,那麼 JVM 怎麼知道時間到了,如何喚醒呢?

首先咱們翻一下 JDK ,發現和時間相關的 API 大概有3處,並且這 3 處還都對時間的精度作了區分:

object.wait(long millisecond) 參數是毫秒,必須大於等於 0 ,若是等於 0 ,就一直阻塞直到其餘線程來喚醒 ,timer 類就是經過 wait() 方法來實現,下面咱們看一下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 ,若是參數爲 0 ,表示釋放 CPU 給更高優先級的線程,本身從運行狀態轉換爲可運行態等待 CPU 調度,他也提供了一個能夠支持納秒級的方法實現,跟 wait 額區別是它經過 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()調用的該方法, ScheduledExecutorService 用的 condition.await() 來實現阻塞必定的超時時間,其餘帶超時參數的方法也都經過他來實現,目前大多定時器都是經過這個方法來實現的,該方法也提供了一個布爾值來肯定時間的精度 System.currentTimeMillis() 以及 System.nanoTime() 這兩種方式都依賴於底層操做系統,前者是毫秒級,經測試 windows 平臺的頻率可能超過 10ms ,然後者是納秒級別,頻率在 100ns 左右,因此若是要獲取更精準的時間建議用後者 好了,api 瞭解完了,咱們來看下定時器的底層是怎麼實現的,現代PC機中有三種硬件時鐘的實現,他們都是經過晶體振動產生的方波信號輸入來完成時鐘信號同步的.

實時時鐘 RTC ,用於長時間存放系統時間的設備,即便關機也能夠依靠主板中的電池繼續計時. Linux 啓動的時候會從 RTC 中讀取時間和日期做爲初始值,以後在運行期間經過其餘計時器去維護系統時間 可編程間隔定時器 PIT ,該計數器會有一個初始值,每過一個時鐘週期,該初始值會減1,當該初始值被減到0時,就經過導線向 CPU 發送一個時鐘中斷, CPU 就能夠執行對應的中斷程序,也就是回調對應的任務 時間戳計數器 TSC , 全部的 Intel8086 CPU 中都包含一個時間戳計數器對應的寄存器,該寄存器的值會在每次 CPU 收到一個時鐘週期的中斷信號後就會加 1 .他比 PIT 精度高,可是不能編程,只能讀取.

時鐘週期:硬件計時器在多長時間內產生時鐘脈衝,而時鐘週期頻率爲1秒內產生時鐘脈衝的個數.目前一般爲1193180.時鐘滴答:當PIT中的初始值減到0的時候,就會產生一次時鐘中斷,這個初始值由編程的時候指定.
複製代碼

Linux啓動的時候,先經過 RTC 獲取初始時間,以後內核經過 PIT 中的定時器的時鐘滴答來維護日期,而且會定時將該日期寫入 RTC,而應用程序的定時器主要是經過設置 PIT 的初始值設置的,當初始值減到0的時候,就表示要執行回調函數了,這裏你們會不會有疑問,這樣同一時刻只能有一個定時器程序了,而咱們在應用程序中,以及多個應用程序之間,

確定有好多定時器任務,其實咱們能夠參考 ScheduledExecutorService 的實現,只須要將這些定時任務按照時間作一個排序,越靠前待執行的任務放在前面,第一個任務到了在設置第二個任務相對當前時間的值,畢竟 CPU 同一時刻也只能運行一個任務,關於時間的精度問題,咱們沒法在軟件層面作的徹底精準,畢竟 CPU 的調度不徹底受用戶程序控制,固然更大的依賴是硬件的時鐘週期頻率,目前 TSC 能夠提升更高的精度.

如今咱們知道了, Java 中的超時時間,是經過可編程間隔定時器設置一個初始值而後等待中斷信號實現的,精度上受硬件時鐘週期的影響,通常爲毫秒級別,畢竟1納秒光速也只有3米,因此 JDK 中帶納秒參數的實現都是粗暴作法,預留着等待精度更高的定時器出現,而獲取當前時間 System.currentTimeMillis() 效率會更高,但他是毫秒級精度,他讀取的 Linux 內核維護的日期,而 System.nanoTime() 會優先使用 TSC ,性能稍微低一點,但他是納秒級,Random 類爲了防止衝突就用nanoTime生成種子.

Java 如何和外部設備通訊

計算機的外部設備有鼠標、鍵盤、打印機、網卡等,一般咱們將外部設備和和主存之間的信息傳遞稱爲 I/O 操做 , 按操做特性能夠分爲,輸出型設備,輸入型設備,存儲設備.現代設備都採用通道方式和主存進行交互,通道是一個專門用來處理IO任務的設備, CPU 在處理主程序時遇到I/O請求,啓動指定通道上選址的設備,一旦啓動成功,通道開始控制設備進行操做

,而 CPU 能夠繼續執行其餘任務,I/O 操做完成後,通道發出 I/O 操做結束的中斷,處理器轉而處理 IO 結束後的事件.其餘處理 IO 的方式,例如輪詢、中斷、DMA,在性能上都不見通道,這裏就不介紹了.固然 Java 程序和外部設備通訊也是經過系統調用完成,這裏也不在繼續深刻了.

來源網絡,侵權刪除

相關文章
相關標籤/搜索