《深入計算機組成原理》對Java開發的啓發

浮點數的表示和運算


1. 浮點數的表示

浮點數以float爲例,單精度的 32 個比特可以分成三部分:
在這裏插入圖片描述

  • 第一部分是一個符號位,用來表示是正數還是負數。我們一般用 s 來表示。在浮點數裏,我們不像正數分符號數還是無符號數,所有的浮點數都是有符號的。
  • 接下來是一個 8 個比特組成的指數位。我們一般用 e 來表示。8 個比特能夠表示的整數空間,就是 0~255。我們在這裏用 1~254 映射到 -126~127 這 254 個有正有負的數上。因爲我們的浮點數,不僅僅想要表示很大的數,還希望能夠表示很小的數,所以指數位也會有負數。
  • 最後,是一個 23 個比特組成的有效數位

綜合科學計數法,我們的浮點數就可以表示成下面這樣:

在這裏插入圖片描述

因爲這種機制,浮點數在定義時就可能會丟失一定的精度,比如 float f = 0.9 , 但使用科學計數法得到的值卻是 0.8999999999999999


2. 浮點數的運算

浮點數的運算遵循 先對齊、再計算 的原則。 兩個浮點數的指數位可能是不一樣的,所以我們要把兩個的指數位,變成一樣的,然後只去計算有效位的加法就好了。

在進行指數對齊的過程中,需要對較小的數的有效位進行右移,每右移一位指數位增加爲原來的兩倍。在右移的過程中,較小的數會丟失精度,當右移的位數超過23次時,那麼較小數的有效數爲就爲0了,也就是當兩個浮點數之間相差超過2^24倍,也就是1600 萬倍時,較小數會被忽略,那這兩個數相加之後,結果完全不會變化。


CPU多級流水線,指令預讀,亂序執行

一顆CPU內有多級流水線,甚至有多條流水線,一條流水線有N級組成,每一級在同一時間能運行一條指令的一個階段,所以一顆CPU在同一時間能運行多條指令。

當多條指令之間沒有依賴關係時,那麼CPU就能並行執行這多條指令,提高程序運行速度。也就是多條沒有關聯的指令間執行是沒有順序的,但最終輸出結果前會對結果進行重排序。在外界看來指令執行是按順序的,但實際內部是多條指令並行執行的,哪條指令的依賴來得早哪條就能先執行,但輸出結果前還是要根據指令順序對結果排序,也就是輸出程序運行結果是要按照指令順序來的,輸出順序靠後的指令結果前還是要等待靠前的指令執行完。


## CPU多核緩存一致性機制 CPU在操作內存數據時,會將內存中的數據拷貝到CPU的高速緩存(L1 L2)中,在某些時間點間將數據從緩存同步回主內存。每個CPU都有自己的高速緩存,這就導致同一個數據再多個緩存中存在一致性問題,當某個數據 X 在CPU A 內被修改時,但其他CPU不知道 X 被修改了,緩存中的還是舊的數據,這就可能導致程序運行出錯。如果要避免這種情況而在修改完某個數據時立即同步回主內存,一方面會導致程序運行效率降低,畢竟CPU高速緩存的讀寫效率比內存的讀寫效率高出至少幾十上百。而且不加鎖的併發操作總歸是不安全的,加鎖也會加劇性能問題。

爲了解決CPU高速緩存一致性問題,CPU內部實現了了總線嗅探機制和針對Cache Line 的MESI協議


1.總線嗅探

所謂總線嗅探,就是每個CPU對每個Cache Line的加載,修改,寫回 都會發布相應的事件到CPU總線上,每個CPU會向總線註冊多種事件監聽器,監聽自己感興趣的事件,然後做相應處理,典型的處理就是將Cache Line 的狀態在 MESI 4種狀態間流轉。


2.MESI 協議

MESI 協議的由來呢,來自於我們對 Cache Line 的四個不同的標記,分別是:

  • M:代表已修改(Modified)
  • E:代表獨佔(Exclusive)
  • S:代表共享(Shared)
  • I:代表已失效(Invalidated)

每個內存數據(其所在的Cache Line) 在CPU緩存中都處於上述4種狀態之一,通過上述4中狀態的輪轉,來實現以下準則:

  1. 寫傳播(Write Propagation)。寫傳播是說,在一個 CPU 核心裏,我們的 Cache 數據更新,必須能夠傳播到其他的對應節點的 Cache Line 裏
  2. 事務的串行化(Transaction Serialization),事務串行化是說,我們在一個 CPU 核心裏面的讀取和寫入,在其他的節點看起來,順序是一樣的

通過上述機制,解決了緩存一致性問題: CPU讀取數據時都能保證讀取到最新的數據,多個CPU操作同一個數據時,保證是串行的,且外界看到的數據變化順序保證和CPU操作的順序一致


Volatile關鍵字的作用


1.修飾複雜類型屬性

計算CPU能保證緩存一致性,那麼Java裏還需要Volatile這個關鍵字嗎,或者說這個關鍵字附加了CPU緩存一致性之外的什麼功能 ?

對一個變量賦值的操作時一個原子操作時,有沒有Volatile修飾是沒有什麼區別的,因爲不同線程對同一個內存做操作會遵循MESI協議,保證緩存一致性。但當變量的賦值操作不是一個原子性操作時,那麼就有區別了,比如以下代碼:

private User user
void init(){
	user = new User();  // 此行編譯後會有3條指令
}

以上代碼執行時實際分三步,也就是實際編譯過後會生成3條指令:

  1. 爲User對象分配內存空間
  2. 執行User對象的構造函數,初始化對象
  3. 將user變量指向User對象所在的內存地址

因爲CPU能同時執行多條指令,所以會有一個指令並行執行的過程。因爲指令2 ,3 依賴指令 1 的分配內存,所以指令1執行結束後才能執行指令2,3。但 2, 3 指令之間是沒有依賴關係的,所以指令 2 ,3 可能會同時執行,但不確定那條先執行完成。

假設在多線程下,執行順序爲1 > 3 > 2, 且在執行完指令3時CPU分片時間到了,當前線程停頓,CPU切換到了其他線程。其他線程獲取到了成員變量user , 此時 user 已經賦值了,但是沒有初始化,也及時其他線程拿到的是一個沒有初始化過的 User 對象,是一個半成品,這會給程序造成隱患。

使用Volatile修飾成員變量,能夠在 user = new User();前後添加內存屏障,禁止內部的3條指令進行重排序,確保user對象被其他線程獲取到時是初始化過的。


2.修飾簡單類型屬性

當Volatile 修飾簡單類型的成員變量是,代碼如下:

private int a;
private int b;

void init(){
	a = 1;
	b = 1;
	notifyOtherThread();  // 喚醒其他線程
	Thread.sleep(10);
	a ++;
}

void compare(){
	// 當前線程阻塞,可被 init() 方法喚醒
	if(a == b){
		........
	}
}

init () 方法中,指令經過重排序後可能導致 a++ 指令在 notifyOtherThread() 之前就運行,那麼此時 a = 2, 這樣在 compare() 方法內 a 和 b 就不相等了,而我們希望的邏輯是相等的。因爲a 和 b 的依賴關係是在另一個線程裏,所以不能阻止 a ++ 的重排序。而通過Volatile修飾 a b,這樣能夠禁止 a b 之前的指令排序到 a b 行之後執行,同時也能禁止 a b 之後的指令排序到 a b 行 之前執行。

上述代碼中就能禁止 notifyOtherThread() 指令排序到 a ++ 之後執行,這樣就能保證業務邏輯的正確性。


CPU分支預測機制


1.機制說明

程序編譯過後的指令是有序的,但是實際執行時就不一定是按照指令順序來的,比如程序有 if-else 判斷,while for i do while 循環,方法調用等等。

java 堆裏有個程序計數器,對應CPU裏是PC寄存器,存放的是當前CPU下一條待執行的指令的地址。程序在當前指令運行結束時會將下一條指令的地址放入程序計數器,不斷的更新程序計數器裏的指令地址這樣程序就能正常的運行下去。

上文說了,CPU有指令預讀的功能,也就是當前指令拿到了,然後就可以馬上去拿下一條指令了,甚至一次性拿取多條指令,可以讓多條指令近似並行的執行。那如果當前指令後是一個多分支的指令集合,從中選擇一條指令運行,那如何做出選擇?

如果等上條指令執行完再從程序計數器裏選擇的話那就不能發揮CPU的指令並行的功能了,如果隨機取一個分支的指令或者默認取第一個分支的指令,那麼可能實際運行的結果和取得指令不一致,那麼就需要撤回已取的指令,然後重新加載正確的指令,這也會多出額外的開銷,甚至比等待上條指令結果後再取的情形更差。

指令並行還是需要並行的,但是不能盲目的或者靜態的取某個分支,需要根據指令的歷史運行結果來做一個預判。CPU引入了一個雙模態預測器,也就是記錄上兩次的分支的運行結果,當連續兩次都選擇指定分支時,那麼下次運行時就會預加載制定分支的指令。在大量的分支判斷情況下,這種預測機制能夠有極高的準確率,往往達到90%以上。

比如下列代碼:


public class BranchPrediction {

    public static void main(String args[]) {       
        for (int i = 0; i < 100; i++) {
            .......
        }
    }
}

循環變量 i < 100 在前面99次循環都是正確的,也就是正確率高達99%。


2.實戰案列


public class BranchPrediction {

	// int數組,內含100個數字,分別是 0-99, 不確定是否有序
	private int[] ints = new int[]{.....};

    public static void main(String args[]) {       
        for (int i = 0; i < 100; i++) {
            if(ints [i] > 50){
            	.......
            }else{
            	......
            }
        }
    }
}

上述代碼對 ints 數組遍歷,是排序過的遍歷性能好,還是沒排序過遍歷性能好呢 ? 瞭解了CPU 分支預測機制後,確定排序過的性能好


public class BranchPrediction {
    public static void main(String args[]) {        
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100; i++) {
            for (int j = 0; j <1000; j ++) {
                for (int k = 0; k < 10000; k++) {
                }
            }
        }
        long end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end - start));
                
        start = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            for (int j = 0; j <1000; j ++) {
                for (int k = 0; k < 100; k++) {
                }
            }
        }
        end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end - start) + "ms");
    }
}

上述兩種多層循環的寫法,哪個性能好?循環的分支預測只有在最後一次不滿足條件時纔會發生指令預讀錯誤的情況,上述兩種循環的預測錯誤情況如下圖:
在這裏插入圖片描述

所以外層循環越小的寫法,預測錯誤的次數越少,所以性能越高


CPU Cache Line 機制,內存邊界對齊,Java對象要求8字節的整數倍


1.CPU Cache Line

CPU在得到一個內存地址後,從主內存加載數據,加載多大的數據,內存地址上是不會記錄數據長度的,如果一次加載太多的話,可能需要的數據很小而造成性能浪費;而且緩存數據使用LRU策略來管理,可能會導致淘汰掉大量剛剛使用過的緩存信息,造成接下來的運算緩存命中率降低;如果加載太少比如幾個字節的話,可能每次需要的數據都要多次加載,性能低下,所以每次加載多大的數據是一個很重要的問題。

CPU定了一個緩存單位大小,Cache Line Size,默認64字節,也就是通過一個內存地址每次加載到CPU緩存裏的數據是64個字節,根據緩存的空間局部性原理,當需要一份數據時,有很大可能同時需要此數據的後幾份數據,所以CPU Cache Line 機制能在很大程度上提高程序的運行性能,比如有序遍歷數組,有序集合;或者需要的數據很小,在64個字節以內,那麼很可能一次加載就能緩存需要的所有數據。

緩存機制看起來很美好,但還有不少問題要解決,比如 緩存數據是剛好以內存地址開始的64個字節的數據嗎?如果是的話,那多個Cache Line有交錯時要怎麼處理,比如是否要重複加載(Cache Line 能交錯則100個字節的內存地址最多能在37個Line中),一個Cache Line 修改了是否要讓交錯的Cache Line失效等等一些列問題。這些都會極大的降低CPU的性能,所以CPU規定了Cache Line是不能有交錯的,也就是每個內存地址都在唯一的Cache Line中

CPU將內存以4K爲大小來分頁,每個Cache Line 大小64字節,所以一頁剛好是64個緩存行,內存地址通過頁表能很快確定對應的Cache Line是否在CPU緩存中。接下來還有一個問題,如果CPU從內存取數據是按規定單位取得,但是如果存數據的時候有沒有按這個規定單位存放,比如分配一個16字節的數據,如果前8個字節分配在一個Cache Line, 另外8個字節分配在後一個Cache Line上,那你怎麼樣都要取兩次。內存分配數據時是否要考慮按Cache Line Size 大小,這個問題在下面的說明。


2.內存邊界對齊

假設有一段128個字節的內存,CPU第一次分配60個字節在0-59的位置上得到一個內存地址A,表示的是內存地址0,然後再分配8個字節在60-67上生成內存地址B,表示的是內存地址60。當我需要加載內存地址B表示的數據時,第一次加載 0-63 地址的數據,發現不夠,還需要再加載 64- 127 的數據,也就是加載8個字節的數據需要兩次,Cache Line機制沒有很好的發揮作用。內存空間沒有浪費,但是加載性能上有所降低,這是時間換空間的典型。

假設有一段128個字節的內存,CPU第一次分配16個字節在0-15的位置上得到一個內存地址A,表示的是內存地址0,然後再分配8個字節在64-67上生成內存地址B,表示的是內存地址60。當我需要加載內存地址B表示的數據時,第一次加載 64-127 地址的數據,也就是一次就能完美的加載小於64字節的數據,能極大的提高效率,這就是典型的空間換時間。但是這樣的話會極大的浪費內存空間,每次分配的內存空間都是64字節的整數倍,如果應用每次需要的內存空間很小時,會有非常多的內存碎片,很不可取。

那有沒有一些折中的策略,既能兼顧內存使用率,也能發揮Cache Line的作用呢?有的,只需要定一個合適的分配內存的最小單位,類似於Line Size這樣。CPU規定了每次分配的內存空間都要是8字節的整數倍,比如應用申請12個字節,那麼會分配16個字節的內存空間,下次分配時從第17字節開始。因爲 8 字節剛好是基本數據類型的大小上限,這樣分配內存時能兼顧所有基本數據類型而且浪費的空間比較少;64字節又是8字節的整數倍,所以一次分配的N(N<=8)個8字節的內存地址是有可能在一個Cache Line 上的。現在常見的內存分配器都默認使用這種策略,每次分配的內存地址是8字節的整數倍,比如 jemalloc ,這就是內存邊界對齊的概念。


3.Java裏的內存邊界對齊

Java裏面也默認延續了內存邊界對齊的思想:對象的大小必須是8字節的整數倍,這樣一方面能支持CPU Cache Line機制,另一方面能夠壓縮內存地址的大小,本來在超過4G的內存空間上表示一個內存地址需要8個字節,而Java在小於32G的堆裏可以用4個字節表示任意一個對象地址,詳見:淺析JVM內存指針壓縮


CPU緩存的空間局部性 對數組,List的影響


在上文中我們分享了CPU Cache Line機制,那我們在遍歷數組,有序集合時是否能應用到這個機制來提高遍歷性能。


1.數組

數組內的元素在內存中是按順序分佈的,如果數據類型是基本數據類型,那麼存放的就是基本類型的數據;如果是複雜類型,那麼存放的是指向對象的指針,每個指針默認4字節。

當數據類型是基本類型時,性能最好;當數據類型時複雜類型時,每次最多能加載16個對象指針,實際使用時還需要額外根據指針的內存地址加載實際的對象,會有額外的一步損耗。


2.List

有序集合的典型,ArrayList,內部使用數組存儲元素,但是List對象的類型不支持簡單數據類型,如果是簡單數據類型需要對齊做裝箱,比如 : List<Integer>,

List<Integer\> ints = new ArrayList();
ints.add(1);

上述ints 對象添加了一個元素1,但是實際內部會對 1 做自動裝箱 : ints.add(new Integer(1));,也就是說集合對象不能直接存儲基本類型數據,存儲的是對象的指針。這樣在遍歷時性能會查 int[] 至少一個量級。 而且自動裝箱就會多出額外的數據,比如一個 int 佔用4字節,而一個Integer佔用 16個字節,而且還有指針4字節,用有序集合比數組每個元素至少多16字節。所以在操作有序的基本類型數據集時,如果要壓縮佔用內存,使用數組是最優的選擇


Java對象的內存分佈,Unsafe,緩存填充


1.Java對象的內存分佈

假設有如下一個Class:

public class User{
	private int id;
	private long telephone;
	private Integer studentId;
}

上述User類型的實例化對象在內存中是如何分佈的?我們用一張圖來說明


在這裏插入圖片描述

一個Java對象在實例化之前就能確定對象大小,根據Object Head,屬性類型 ,屬性個數,就能計算出來字節數,最後不滿8字節的整數倍需要加上對齊填充。所以在分配對象內存空間時,直接分配一塊連續的內存空間即可,不會存在空間增大和縮小的情況。


2.Unsafe

可以通過Unsafe來驗證對象在內存中的分佈情況。比如我想確定屬性 telephone 在對象中的排列位置:

	private static Unsafe unsafe = null;
	
    static {
        try {
            Field getUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            getUnsafe.setAccessible(true);
            unsafe = (Unsafe)getUnsafe.get(null);
        } catch (Exception ex) { throw new Error(ex); }
    }
	
	public void getLongOffset(){
		long telephoneOffset = unsafe.objectFieldOffset(RootUser.class.getDeclaredField("telephone"));
		System.out.println(telephoneOffset)    // 輸出16
	}
	

通過Unsafe能夠確定對象的屬性相對於當前對象的起始地址的偏移量,對象在GC時內存地址會改變,但內部的結構不會改變,屬性的內存位置的偏移量也不會改變,所以Unsafe能夠直接通過修改內存的形式來修改對象的屬性

Useruser = (User)unsafe.allocateInstance(User.class);
unsafe.putLong(user, unsafe.objectFieldOffset(User.class.getDeclaredField("telephone")), 1L);

但是這種修改是不安全的,它繞過了Java的編譯校驗,所以可能會出現以爲指向一頭牛,實際指向一匹馬的情況,所以Java 把它命名叫Unsafe,不推薦開發時直接使用。


3.緩存填充

我們在上文分享了CPU 的 MESI協議,然後又分享了Java對象的內存分佈,因爲對象內數據是分佈在一起的,當有多線程修改了某個屬性後,其他線程的此屬性對應的Cache Line失效,每次使用時需要重新加載。那這個過程是否存在性能問題,比如有如下結構的Class:

private final int age
private final String name;
private volatile long count;
private final long time;

有上述4個屬性,一個標識了volatile,多個線程會修改此變量值,其他的變量在對象初始化時就固定了,且不會變化。

在多線程情形下,某個線程修改了 count這個變量,有很大機率導致整個對象所在的Cache Line失效,每次訪問其他final修飾的變量時也需要重新從主內存中加載,這就有很嚴重的性能問題,在 count 修改併發越高時越明顯。

有沒有什麼好的解決方法,能夠提高其他不會被修改的屬性的使用效率 ?我們可以借鑑 高併發框架 Disruptor 的處理方式:

private final int age
private final String name;
private long p1,p2,p3,p4,p5,p6,p7;  // 填充屬性
private volatile long count;		// 頻繁修改屬性
private long q1,q2,q3,q4,q5,q6,q7;  // 填充屬性
private final long time;

被頻繁修改的屬性前後插入額外7個long類型的變量,使得count所在的Cache Line 僅僅包含插入的填充屬性,這樣想修改count屬性時,因爲其他有意義的屬性和count不在同一個Cache Line上,所以其他的Cache Line能常駐CPU 緩存,提高程序的運行效率,這就是緩存填充機制。


磁盤,內存,CPU緩存的讀取延時


存儲器 硬件介質 單位成本(美元/MB) 隨機訪問延時 延時時鐘週期
L1 Cache SRAM 7 1ns 3-4
L2 Cache SRAM 7 4ns 12
L3 Cache SRAM 15ns 30
Memory DRAM 0.015 60ns 120
Disk SSD(NAND) 0.0004 150us
Disk HDD 0.00004 10ms

Java直接內存


1.爲什麼要有直接內存


1.讀取磁盤數據的臨時存放空間

當java程序使用到磁盤的數據時,在讀取磁盤時默認從指定地址向後順序讀取1M的數到內存,但是很多時候程序在讀取時是逐行或者讀取到一個Buffer裏,讀取了數據纔算是將數據加載到堆裏,那剩下的數據存放在哪裏這是個問題。當前進程讀取的數據,沒有加載到堆裏,那麼就需要在堆外開闢一塊空間用來存放這些讀到的數據,這個就是堆外內存,也叫直接內存。

2.寫入IO數據的臨時存放空間

當Java程序需要將數據通過網絡傳輸到遠方,那麼需要將數據寫入到Socket的緩衝區,在這個過程中CPU會切換到內核空間,有內核態的CPU通過一個字節數組起始地址,寫入長度參數將制定內存地址的數據拷貝到Socket緩衝區。

因爲此時CPU運行在內核態,而不是Java程序的用戶空間,所以CPU只關注的是數據內存地址,而不是對象的內存地址。如果在拷貝數據的過程中,JVM的堆內觸發了GC,導致對象的內存地址移動了,也就說數據的內存地址移動了,那麼此時CPU是不知道新的數據地址的,此時就會觸發拷貝失敗的Error。

如果從安全拷貝數據的角度來看的話,最好就是在寫入IO數據時不能有GC,但這種設計明顯不合理,如果要傳輸很大的文件,可能耗費時間很長,應用不可能長時間不進行GC,這會導致OOM的Error。所以折中方案就是在堆外開闢一個空間臨時存放要寫入IO的數據,先將數據拷貝到堆外內存,以爲堆外內存不是由JVM掌管,不會有GC,不存在內存地址移動的問題。

3.文件管理空間

很多中間件在持久化數據時需要持續的寫入文件,或者隨機的讀取文件的某一段數據,如果用傳統的IO那隻能順序的讀取,順序的寫入,功能上不能實現,所以Java針對隨機讀寫情況增加了文件的映射功能。將文件映射到虛擬內存,當讀或者想寫時如果當前頁不存在,觸發缺頁異常,CPU將頁從磁盤加載到內存,然後有操作系統來維護這些頁表。如果由堆來管理這些文件映射的空間,那麼一反面可能文件非常大,佔用空間非常多,導致堆需要頻發GC,或者GC時移動數據很吃力,GC時間過長;另一方面當文件的頁加載過多時,需要通過淘汰算法刷新部分頁,那麼需要將髒頁寫會磁盤,上文說了在寫磁盤時數據的內存地址是不能移動的,所以需要堆外空間來存放待刷新數據,所以用堆外內存來管理文件更好點。

2.直接內存使用注意點

直接內存在分配時需要切換到Java內置的C進程,所以內存在分配和釋放時耗費比堆內高,而且內存空間在堆外,不歸JVM管,所以不會有GC,也就是不會自動釋放內存,需要手動釋放或者在分配內存時關聯上Java內的 sun.misc.Cleaner,當Java對象被回收時,通過其 finalize() 方法觸發 Cleaner去釋放此對象對應的堆外內存空間。

直接內存分配和釋放不容易,所以一般用池化來管理直接內存,而且不需要主動釋放,覆蓋性的重新分配即可。

當在直接內存上分配空間存放數據時,如果還需要在堆內使用這些數據時,就相當於將數據重新又讀取到了堆內,這樣就相當於額外多了一步數據拷貝的過程。如果在堆外分配了數據後,直接寫入到Socket緩衝池中,那比分配在堆內然後拷貝至直接內存,在拷貝到Socket緩衝區少了一步拷貝,性能要高不少。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章