天天看點

java 5 個底層程式設計邏輯

阿裡妹導讀:肉眼看計算機是由CPU、記憶體、顯示器這些硬體裝置組成,但大部分人從事的是軟體開發工作。計算機底層原理就是連通硬體和軟體的橋梁,了解計算機底層原理才能在程式設計這條路上越走越快,越走越輕松。從作業系統層面去了解進階程式設計語言的執行過程,會發現好多軟體設計都是同一種套路,很多語言特性都依賴于底層機制,今天董鵬為你一一揭秘。

文章目錄

    • 結合 CPU 了解一行 Java 代碼是怎麼執行的
        • 中斷
    • 從 Linux 記憶體管理角度了解 JVM 記憶體模型
        • 程序上下文
        • 虛拟存儲
        • 記憶體映射
        • JVM 中對象的記憶體布局
    • NPTL和 Java 的線程模型
        • 線程的狀态
        • 線程的同步
    • Java 中如何實作定時任務
    • Java 如何和外部裝置通信

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

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

java 5 個底層程式設計邏輯

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

當程式被加載進記憶體後,指令就在記憶體中了,這個時候說的記憶體是獨立于 CPU 外的主存裝置,也就是 PC 機中的記憶體條,指令指針寄存器IP 指向記憶體中下一條待執行指令的位址,控制單元根據 IP寄存器的指向,将主存中的指令裝載到指令寄存器。

這個指令寄存器也是一個儲存設備,不過他內建在 CPU 内部,指令從主存到達 CPU 後隻是一串 010101 的二進制串,還需要通過譯碼器解碼,分析出操作碼是什麼,操作數在哪,之後就是具體的運算單元進行算術運算(加減乘除),邏輯運算(比較,位移)。而 CPU 指令執行過程大緻為:取址(去主存擷取指令放到寄存器),譯碼(從主存擷取操作數放入高速緩存 L1 ),執行(運算)。

java 5 個底層程式設計邏輯

這裡解釋下上圖中 CPU 内部內建的存儲單元 SRAM ,正好和主存中的 DRAM 對應, RAM 是随機通路記憶體,就是給一個位址就能通路到資料,而磁盤這種存儲媒介必須順序通路,而 RAM 又分為動态和靜态兩種,靜态 RAM 由于內建度較低,一般容量小,速度快,而動态 RAM 內建度較高,主要通過給電容充電和放電實作,速度沒有靜态 RAM 快,是以一般将動态 RAM 做為主存,而靜态 RAM 作為 CPU 和主存之間的高速緩存 (cache),用來屏蔽 CPU 和主存速度上的差異,也就是我們經常看到的 L1 , L2 緩存。每一級别緩存速度變低,容量變大。

下圖展示了存儲器的階層化架構,以及 CPU 通路主存的過程,這裡有兩個知識點,一個是多級緩存之間為保證資料的一緻性,而推出的緩存一緻性協定,具體可以參考這篇文章,另外一個知識點是, cache 和主存的映射,首先要明确的是 cahce 緩存的機關是緩存行,對應主存中的一個記憶體塊,并不是一個變量,這個主要是因為 CPU 通路的空間局限性:被通路的某個存儲單元,在一個較短時間内,很有可能再次被通路到,以及空間局限性:被通路的某個存儲單元,在較短時間内,他的相鄰存儲單元也會被通路到。

而映射方式有很多種,類似于 cache 行号 = 主存塊号 mod cache總行數 ,這樣每次擷取到一個主存位址,根據這個位址計算出在主存中的塊号就可以計算出在 cache 中的行号。

java 5 個底層程式設計邏輯

下面我們接着聊 CPU 的指令執行。取址、譯碼、執行,這是一個指令的執行過程,所有指令都會嚴格按照這個順序執行。但是多個指令之間其實是可以并行的,對于單核 CPU 來說,同一時刻隻能有一條指令能夠占有執行單元運作。這裡說的執行是 CPU 指令處理 (取指,譯碼,執行) 三步驟中的第三步,也就是運算單元的計算任務。

是以為了提升 CPU 的指令處理速度,是以需要保證運算單元在執行前的準備工作都完成,這樣運算單元就可以一直處于運算中,而剛剛的串行流程中,取指,解碼的時候運算單元是空閑的,而且取指和解碼如果沒有命中高速緩存還需要從主存取,而主存的速度和 CPU 不在一個級别上,是以指令流水線 可以大大提高 CPU 的處理速度,下圖是一個3級流水線的示例圖,而現在的奔騰 CPU 都是32級流水線,具體做法就是将上面三個流程拆分的更細。

java 5 個底層程式設計邏輯

除了指令流水線, 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!\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
                                    ; 調用 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 解釋執行的過程了。

java 5 個底層程式設計邏輯

中斷

剛剛說到, CPU 隻要一上電就像一個永動機,不停的取指令,運算,周而複始,而中斷便是作業系統的靈魂,故名思議,中斷就是打斷 CPU 的執行過程,轉而去做點别的。

例如系統執行期間發生了緻命錯誤,需要結束執行,例如使用者程式調用了一個系統調用的方法,例如mmp等,就會通過中斷讓 CPU 切換上下文,轉到核心空間,例如一個等待使用者輸入的程式正在阻塞,而當使用者通過鍵盤完成輸入,核心資料已經準備好後,就會發一個中斷信号,喚醒使用者程式把資料從核心取走,不然核心可能會資料溢出,當磁盤報了一個緻命異常,也會通過中斷通知 CPU ,定時器完成時鐘滴答也會發時鐘中斷通知 CPU 。

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

陷入核心:我們在設計軟體的時候,會考慮程式上下文切換的頻率,頻率太高肯定會影響程式執行性能,而陷入核心是針對 CPU 而言的, CPU 的執行從使用者态轉向核心态,以前是使用者程式在使用 CPU ,現在是核心程式在使用 CPU ,這種切換是通過系統調用産生的。

系統調用是執行作業系統底層的程式,Linux的設計者,為了保護作業系統,将程序的執行狀态用核心态和使用者态分開,同一個程序中,核心和使用者共享同一個位址空間,一般 4G 的虛拟位址,其中 1G 給核心态, 3G 給使用者态。在程式設計的時候我們要盡量減少使用者态到核心态的切換,例如建立線程是一個系統調用,是以我們有了線程池的實作。

從 Linux 記憶體管理角度了解 JVM 記憶體模型

程序上下文

我們可以将程式了解為一段可執行的指令集合,而這個程式啟動後,作業系統就會為他配置設定 CPU ,記憶體等資源,而這個正在運作的程式就是我們說的程序,程序是作業系統對處理器中運作的程式的一種抽象。

而為程序配置設定的記憶體以及 CPU 資源就是這個程序的上下文,儲存了目前執行的指令,以及變量值,而 JVM 啟動後也是linux上的一個普通程序,程序的實體實體和支援程序運作的環境合稱為上下文,而上下文切換就是将目前正在運作的程序換下,換一個新的程序到處理器運作,以此來讓多個程序并發的執行,上下文切換可能來自作業系統排程,也有可能來自程式内部,例如讀取IO的時候,會讓使用者代碼和作業系統代碼之間進行切換。

java 5 個底層程式設計邏輯

虛拟存儲

當我們同時啟動多個 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 5 個底層程式設計邏輯
- 裝入位 表示對于頁是否在主存,如果位址頁每頁表示,資料還在磁盤
 - 存放位置 建立虛拟頁和實體頁的映射,用于位址轉換,如果為null表示是一個未配置設定頁
 - 修改位 用來存儲資料是否修改過
 - 權限位 用來控制是否有讀寫權限
 - 禁止緩存位 主要用來保證 cache 主存 磁盤的資料一緻性
           

記憶體映射

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

java 5 個底層程式設計邏輯

推薦閱讀:46張PPT弄懂JVM、GC算法和性能調優!

在 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 寫入資料後,這個字段對應的緩存行将失效,導緻這個緩存行的其他字段也失效。

java 5 個底層程式設計邏輯

在 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 有幾種用法?

當聲明 synchronized 的代碼塊時,編譯而成的位元組碼會包含一個 monitorenter 和多個 monitorexit (多個退出路徑,正常和異常情況),當執行 monitorenter 的時候會檢查目标鎖對象的計數器是否為0,如果為0則将鎖對象的持有線程設定為自己,然後計數器加1,擷取到鎖,如果不為0則檢查鎖對象的持有線程是不是自己,如果是自己就将計數器加1擷取鎖,如果不是則阻塞等待,退出的時候計數器減1,當減為0的時候清楚鎖對象的持有線程标記,可以看出 synchronized 是支援可重入的。

剛剛說到線程的阻塞是一個系統調用,開銷大,是以 JVM 設計了自适應自旋鎖,就是當沒有擷取到鎖的時候, CPU 回進入自旋狀态等待其他線程釋放鎖,自旋的時間主要看上次等待多長時間擷取的鎖,例如上次自旋5毫秒沒有擷取鎖,這次就6毫秒,自旋會導緻 CPU 空跑,另一個副作用就是不公平的鎖機制,因為該線程自旋擷取到鎖,而其他正在阻塞的線程還在等待。

除了自旋鎖, JVM 還通過 CAS 實作了輕量級鎖和偏向鎖來分别針對多個線程在不同時間通路鎖和鎖僅會被一個線程使用的情況。後兩種鎖相當于并沒有調用底層的信号量實作(通過信号量來控制線程A釋放了鎖例如調用了 wait(),而線程B就可以擷取鎖,這個隻有核心才能實作,後面兩種由于場景裡沒有競争是以也就不需要通過底層信号量控制),隻是自己在使用者空間維護了鎖的持有關系,是以更高效。

java 5 個底層程式設計邏輯

如上圖所示,如果線程進入 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 供使用,但是如果你想設計更高效,更精準的定時器任務,就需要了解底層的硬體知識,比如實作一個分布式任務排程中間件,你可能要考慮到各個應用間時鐘同步的問題。Spring Boot 實作定時任務的 4 種方式,這個學習下。

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 程式和外部裝置通信也是通過系統調用完成,這裡也不在繼續深入了。

本文結束