科技

【Java和作業系統互動細節】

根據馮·諾依曼思想,計算機採用二進位制作為數制基礎,必須包含:運算器、控制器、儲存裝置,以及輸入輸出裝置,如下圖所示。

我們先來分析 CPU 的工作原理,現代 CPU 晶片中大都集成了,控制單元,運算單元,儲存單元。控制單元是 CPU 的控制中心, CPU 需要通過它才知道下一步做什麼,也就是執行什麼指令,控制單元又包含:指令暫存器(IR ),指令譯碼器( ID )和操作控制器( OC )。 當程式被載入進記憶體後,指令就在記憶體中了,這個時候說的記憶體是獨立於 CPU 外的主存裝置,也就是 PC 機中的記憶體條,指令指標暫存器IP 指向記憶體中下一條待執行指令的地址,控制單元根據 IP暫存器的指向,將主存中的指令裝載到指令暫存器,這個指令暫存器也是一個儲存裝置,不過他整合在 CPU 內部,指令從主存到達 CPU 後只是一串 010101 的二進位制串,還需要通過譯碼器解碼,分析出 操作碼是什麼,運算元在哪,之後就是具體的運算單元進行算術運算(加減乘除),邏輯運算(比較,位移)。而 CPU 指令執行過程大致為:取址(去主存獲取指令放到暫存器),譯碼(從主存獲取運算元放入快取記憶體 L1 ),執行(運算)。

下面我們接著聊 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") 編譯後的位元組碼

最左列是偏移;中間列是給虛擬機器讀的位元組碼;最右列是高階語言的程式碼,下面是通過組合語言轉換成的機器指令,中間是機器碼,第三列為對應的機器指令,最後一列是對應的彙編程式碼

JVM 通過類載入器載入 class 檔案裡的位元組碼後,會通過直譯器解釋成彙編指令,最終再轉譯成 CPU 可以識別的機器指令,直譯器是軟體來實現的,主要是為了實現同一份 Java 位元組碼可以在不同的硬體平臺上執行,而將彙編指令轉換成機器指令由硬體直接實現,這一步速度是很快的,當然 JVM 為了提高執行效率也可以將某些熱點程式碼(一個方法內的程式碼)一次全部編譯成機器指令後然後在執行,也就是和解釋執行對應的即時編譯(JIT), JVM 啟動的時候可以通過 -Xint 和 -Xcomp 來控制執行模式。

從軟體層面上, class 檔案被載入進虛擬機器後,類資訊會存放在方法區,在實際執行的時候會執行方法區中的程式碼,在 JVM 中所有的執行緒共享堆記憶體和方法區,而每個執行緒有自己獨立的 Java 方法棧,本地方法棧(面向 native 方法),PC暫存器(存放執行緒執行位置),當呼叫一個方法的時候, Java 虛擬機器會在當前執行緒對應的方法棧中壓入一個棧幀,用來存放 Java 位元組碼運算元以及區域性變數,這個方法執行完會彈出棧幀,一個執行緒會連續執行多個方法,對應不同的棧幀的壓入和彈出,壓入棧幀後就是 JVM 解釋執行的過程了。

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

中斷的種類,我們這裡就不做細分了,中斷有點類似於我們經常說的事件驅動程式設計,而這個事件通知機制是怎麼實現的呢,硬體中斷的實現通過一個導線和 CPU 相連來傳輸中斷訊號,軟體上會有特定的指令,例如執行系統呼叫建立執行緒的指令,而 CPU 每執行完一個指令,就會檢查中斷暫存器中是否有中斷,如果有就取出然後執行該中斷對應的處理程式。

我們可以將程式理解為一段可執行的指令集合,而這個程式啟動後,作業系統就會為他分配 CPU ,記憶體等資源,而這個正在執行的程式就是我們說的程序,程序是作業系統對處理器中執行的程式的一種抽象,而為程序分配的記憶體以及 CPU 資源就是這個程序的上下文,儲存了當前執行的指令,以及變數值,而 JVM 啟動後也是linux上的一個普通程序,程序的物理實體和支援程序執行的環境合稱為上下文,而上下文切換就是將當前正在執行的程序換下,換一個新的程序到處理器執行,以此來讓多個程序併發的執行,上下文切換可能來自作業系統排程,也有可能來自程式內部,例如讀取IO的時候,會讓使用者程式碼和作業系統程式碼之間進行切換。

當我們同時啟動多個 JVM 執行: System.out.println(new Object); 將會列印這個物件的 hashcode ,hashcode 預設為記憶體地址,最後發現他們列印的都是 Java [email protected] ,也就是多個程序返回的記憶體地址竟然是一樣的。

通過上面的例子我們可以證明,linux中每個程序有單獨的地址空間,在此之前,我們先了解下 CPU 是如何訪問記憶體的?

假設我們現在還沒有虛擬地址,只有實體地址,編譯器在編譯程式的時候,需要將高階語言轉換成機器指令,那麼 CPU 訪問記憶體的時候必須指定一個地址,這個地址如果是一個絕對的實體地址,那麼程式就必須放在記憶體中的一個固定的地方,而且這個地址需要在編譯的時候就要確認,大家應該想到這樣有多坑了吧, 如果我要同時執行兩個 office word 程式,那麼他們將操作同一塊記憶體,那就亂套了,偉大的計算機前輩設計出,讓 CPU 採用 段基址 + 段內偏移地址 的方式訪問記憶體,其中段基地址在程式啟動的時候確認,儘管這個段基地址還是絕對的實體地址,但終究可以同時執行多個程式了, CPU 採用這種方式訪問記憶體,就需要段基址暫存器和段內偏移地址暫存器來儲存地址,最終將兩個地址相加送上地址匯流排。而記憶體分段,相當於每個程序都會分配一個記憶體段,而且這個記憶體段需要是一塊連續的空間,主存裡維護著多個記憶體段,當某個程序需要更多記憶體,並且超出實體記憶體的時候,就需要將某個不常用的記憶體段換到硬碟上,等有充足記憶體的時候在從硬碟載入進來,也就是 swap 。每次交換都需要操作整個段的資料。

首先連續的地址空間是很寶貴的,例如一個 50M 的記憶體,在記憶體段之間有空隙的情況下,將無法支援 5 個需要 10M 記憶體才能執行的程式,如何才能讓段內地址不連續呢? 答案是記憶體分頁。

在保護模式下,每一個程序都有自己獨立的地址空間,所以段基地址是固定的,只需要給出段內偏移地址就可以了,而這個偏移地址稱為線性地址,線性地址是連續的,而記憶體分頁將連續的線性地址和和分頁後的實體地址相關聯,這樣邏輯上的連續線性地址可以對應不連續的實體地址。實體地址空間可以被多個程序共享,而這個對映關係將通過頁表( page table)進行維護。 標準頁的尺寸一般為 4KB ,分頁後,實體記憶體被分成若干個 4KB 的資料頁,程序申請記憶體的時候,可以對映為多個 4KB 大小的實體記憶體,而應用程式讀取資料的時候會以頁為最小單位,當需要和硬碟發生交換的時候也是以頁為單位。

現代計算機多采用虛擬儲存技術,虛擬儲存讓每個程序以為自己獨佔整個記憶體空間,其實這個虛擬空間是主存和磁碟的抽象,這樣的好處是,每個程序擁有一致的虛擬地址空間,簡化了記憶體管理,程序不需要和其他程序競爭記憶體空間,因為他是獨佔的,也保護了各自程序不被其他程序破壞,另外,他把主存看成磁碟的一個快取,主存中僅儲存活動的程式段和資料段,當主存中不存在資料的時候發生缺頁中斷,然後從磁碟載入進來,當實體記憶體不足的時候會發生 swap 到磁碟。頁表儲存了虛擬地址和實體地址的對映,頁表是一個數組,每個元素為一個頁的對映關係,這個對映關係可能是和主存地址,也可能和磁碟,頁表儲存在主存,我們將儲存在高速緩衝區 cache 中的頁表稱為快表 TLAB 。

正常情況下,我們讀取檔案的流程為,先通過系統呼叫從磁碟讀取資料,存入作業系統的核心緩衝區,然後在從核心緩衝區拷貝到使用者空間,而記憶體對映,是將磁碟檔案直接對映到使用者的虛擬儲存空間中,通過頁表維護虛擬地址到磁碟的對映,通過記憶體對映的方式讀取檔案的好處有,因為減少了從核心緩衝區到使用者空間的拷貝,直接從磁碟讀取資料到記憶體,減少了系統呼叫的開銷,對使用者而言,彷彿直接操作的磁碟上的檔案,另外由於使用了虛擬儲存,所以不需要連續的主存空間來儲存資料。

在 Java 中,我們使用 MappedByteBuffer 來實現記憶體對映,這是一個堆外記憶體,在對映完之後,並沒有立即佔有實體記憶體,而是訪問資料頁的時候,先查頁表,發現還沒載入,發起缺頁異常,然後在從磁碟將資料載入進記憶體,所以一些對實時性要求很高的中介軟體,例如rocketmq,訊息儲存在一個大小為1G的檔案中,為了加快讀寫速度,會將這個檔案對映到記憶體後,在每個頁寫一位元資料,這樣就可以把整個1G檔案都載入進記憶體,在實際讀寫的時候就不會發生缺頁了,這個在rocketmq內部叫做檔案預熱。

下面我們貼一段 rocketmq 訊息儲存模組的程式碼,位於 MappedFile 類中,這個類是 rocketMq 訊息儲存的核心類感興趣的可以自行研究,下面兩個方法一個是建立檔案對映,一個是預熱檔案,每預熱 1000 個數據頁,就讓出 CPU 許可權。

在linux中只要知道一個變數的起始地址就可以讀出這個變數的值,因為從這個起始地址起前8位記錄了變數的大小,也就是可以定位到結束地址,在 Java 中我們可以通過 Field.get(object) 的方式獲取變數的值,也就是反射,最終是通過 UnSafe 類來實現的。我們可以分析下具體程式碼

通過上面的程式碼我們可以通過屬性相對物件起始地址的偏移量,來讀取和寫入屬性的值,這也是 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 引數,其實也是用空間換取時間。

按照教科書的定義,程序是資源管理的最小單位,而執行緒是 CPU 排程執行的最小單位,執行緒的出現是為了減少程序的上下文切換(執行緒的上下文切換比程序小很多),以及更好適配多核心 CPU 環境,例如一個程序下多個執行緒可以分別在不同的 CPU 上執行,而多執行緒的支援,既可以放在Linux核心實現,也可以在核外實現,如果放在核外,只需要完成執行棧的切換,排程開銷小,但是這種方式無法適應多 CPU 環境,底層的程序還是執行在一個 CPU 上,另外由於對使用者程式設計要求高,所以目前主流的作業系統都是在核心支援執行緒,而在Linux中,執行緒是一個輕量級程序,只是優化了執行緒排程的開銷。而在 JVM 中的執行緒和核心執行緒是一一對應的,執行緒的排程完全交給了核心,當呼叫 Thread.run 的時候,就會通過系統呼叫 fork 建立一個核心執行緒,這個方法會在使用者態和核心態之間進行切換,效能沒有在使用者態實現執行緒高,當然由於直接使用核心執行緒,所以能夠建立的最大執行緒數也受核心控制。目前 Linux上 的執行緒模型為 NPTL ( Native POSIX Thread Library),他使用一對一模式,相容 POSIX 標準,沒有使用管理執行緒,可以更好地在多核 CPU 上執行。

對程序而言,就三種狀態,就緒,執行,阻塞,而在 JVM 中,阻塞有四種類型,我們可以通過 jstack 生成 dump 檔案檢視執行緒的狀態。

而在 POSIX 標準中,thread_block 接受一個引數 stat ,這個引數也有三種類型,TASK_BLOCKED, TASK_WAITING, TASK_HANGING,而排程器只會對執行緒狀態為 READY 的執行緒執行排程,另外一點是執行緒的阻塞是執行緒自己操作的,相當於是執行緒主動讓出 CPU 時間片,所以等執行緒被喚醒後,他的剩餘時間片不會變,該執行緒只能在剩下的時間片執行,如果該時間片到期後執行緒還沒結束,該執行緒狀態會由 RUNNING 轉換為 READY ,等待排程器的下一次排程。

好了,關於執行緒就分析到這,關於 Java 併發包,核心都在 AQS 裡,底層是通過 UnSafe類的 cas 方法,以及 park 方法實現,後面我們在找時間單獨分析,現在我們在看看 Linux 的程序同步方案。

執行緒同步出現的根本原因是訪問公共資源需要多個操作,而這多個操作的執行過程不具備原子性,被任務排程器分開了,而其他執行緒會破壞共享資源,所以需要在臨界區做執行緒的同步,這裡我們先明確一個概念,就是臨界區,他是指多個任務訪問共享資源如記憶體或檔案時候的指令,他是指令並不是受訪問的資源。

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 適用於讀多寫少得場景,最好只有個執行緒寫多個執行緒讀,如果頻繁寫入導致不停重新整理快取會影響效能。

定時器已經是現代軟體中不可缺少的一部分,例如每隔5秒去查詢一下狀態,是否有新郵件,實現一個鬧鐘等, Java 中已經有現成的 api 供使用,但是如果你想設計更高效,更精準的定時器任務,就需要了解底層的硬體知識,比如實現一個分散式任務排程中介軟體,你可能要考慮到各個應用間時鐘同步的問題。

Java 中我們要實現定時任務,有兩種方式,一種通過 timer 類, 另外一種是 JUC 中的 ScheduledExecutorService ,不知道大家有沒有好奇 JVM 是如何實現定時任務的,難道一直輪詢時間,看是否時間到了,如果到了就呼叫對應的處理任務,但是這種一直輪詢不釋放 CPU 肯定是不可取的,要麼就是執行緒阻塞,等到時間到了在來喚醒執行緒,那麼 JVM 怎麼知道時間到了,如何喚醒呢?

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

這個方法是想提供一個可以支援納秒級的超時時間,然而只是粗暴的加 1 毫秒。

好了,api 瞭解完了,我們來看下定時器的底層是怎麼實現的,現代PC機中有三種硬體時鐘的實現,他們都是通過晶體振動產生的方波訊號輸入來完成時鐘訊號同步的。

Linux啟動的時候,先通過 RTC 獲取初始時間,之後核心通過 PIT 中的定時器的時鐘滴答來維護日期,並且會定時將該日期寫入 RTC,而應用程式的定時器主要是通過設定 PIT 的初始值設定的,當初始值減到0的時候,就表示要執行回撥函數了,這裡大家會不會有疑問,這樣同一時刻只能有一個定時器程式了,而我們在應用程式中,以及多個應用程式之間, 肯定有好多定時器任務,其實我們可以參考 ScheduledExecutorService 的實現,只需要將這些定時任務按照時間做一個排序,越靠前待執行的任務放在前面,第一個任務到了在設定第二個任務相對當前時間的值,畢竟 CPU 同一時刻也只能執行一個任務,關於時間的精度問題,我們無法在軟體層面做的完全精準,畢竟 CPU 的排程不完全受使用者程式控制,當然更大的依賴是硬體的時鐘週期頻率,目前 TSC 可以提高更高的精度。

現在我們知道了,Java 中的超時時間,是通過可程式設計間隔定時器設定一個初始值然後等待中斷訊號實現的,精度上受硬體時鐘週期的影響,一般為毫秒級別,畢竟1納秒光速也只有3米,所以 JDK 中帶納秒引數的實現都是粗暴做法,預留著等待精度更高的定時器出現,而獲取當前時間 System.currentTimeMillis 效率會更高,但他是毫秒級精度,他讀取的 Linux 核心維護的日期,而 System.nanoTime 會優先使用 TSC ,效能稍微低一點,但他是納秒級,Random 類為了防止衝突就用nanoTime生成種子。

計算機的外部裝置有滑鼠、鍵盤、印表機、網絡卡等,通常我們將外部裝置和和主存之間的資訊傳遞稱為 I/O 操作 , 按操作特性可以分為,輸出型裝置,輸入型裝置,儲存裝置。現代裝置都採用通道方式和主存進行互動,通道是一個專門用來處理IO任務的裝置, CPU 在處理主程式時遇到I/O請求,啟動指定通道上選址的裝置,一旦啟動成功,通道開始控制裝置進行操作 ,而 CPU 可以繼續執行其他任務,I/O 操作完成後,通道發出 I/O 操作結束的中斷,處理器轉而處理 IO 結束後的事件。其他處理 IO 的方式,例如輪詢、中斷、DMA,在效能上都不見通道,這裡就不介紹了。當然 Java 程式和外部裝置通訊也是通過系統呼叫完成,這裡也不在繼續深入了。

Reference:科技日報

看更多!請加入我們的粉絲團

轉載請附文章網址

不可錯過的話題