Java 面試的 96 個高頻知識點[轉]

Java 面試高頻知識點整理


Java 面試的 96 個高頻知識點

博客地址 http://www.b2bchain.cn/

排序算法 9

P1:排序算法的分類

排序算法可以分爲內部排序和外部排序,在內存中進行的排序稱爲內部排序,當要排序的數據量很大時無法全部拷貝到內存,需要使用外存進行排序,這種排序稱爲外部排序。

內部排序包括比較排序和非比較排序,比較排序包括插入排序、選擇排序、交換排序和歸併排序,非比較排序包括計數排序、基數排序和桶排序。其中插入排序又包括直接插入排序和希爾排序,選擇排序包括直接選擇排序和堆排序,交換排序包括冒泡排序和快速排序。


P2:直接插入排序

直接插入排序屬於插入排序,是一種穩定的排序,平均時間複雜度和最差時間複雜度均爲 O(n²),當元素基本有序時的最好時間複雜度爲O(n),空間複雜度爲 O(1)。

基本原理是每一趟將一個待排序的記錄,按其關鍵字的大小插入到已經排好序的一組記錄的適當位置上,直到所有待排序記錄全部插入爲止。適用於待排序記錄較少或基本有序的情況。

public void insertionSort(int[] nums) {
    for (int i = 1; i < nums.length; i++) {
        int insertNum = nums[i];
        int insertIndex;
        for (insertIndex = i - 1; insertIndex >= 0 && nums[insertIndex] > insertNum; insertIndex--) {
            nums[insertIndex + 1] = nums[insertIndex];
        }
        nums[insertIndex + 1] = insertNum;
    }
}

**優化:**直接插入並沒有利用到要插入的序列已有序的特點,插入第 i 個元素時可以通過二分查找找到要插入的位置,再把第 i 個元素前 1位與插入位置之間的所有元素後移,把第 i 個元素放在目標位置上。

public void binaryInsertionSort(int[] nums) {
    for (int index = 1; index < nums.length; index++) {
        int insertNum = nums[index];
        int insertIndex = -1;
        int start = 0;
        int end = index - 1;
        while (start <= end) {
            int mid = start + (end - start) / 2;
            if (insertNum > nums[mid])
                start = mid + 1;
            else if (insertNum < nums[mid])
                end = mid - 1;
            else {
                insertIndex = mid + 1;
                break;
            }
        }
        if (insertIndex == -1)
            insertIndex = start;
        if (index - insertIndex >= 0)
            System.arraycopy(nums, insertIndex, nums, insertIndex + 1, index - insertIndex);
        nums[insertIndex] = insertNum;
    }
}

P3:希爾排序

希爾排序屬於插入排序,又稱縮小增量排序,是對直接插入排序的一種改進,並且是一種不穩定的排序,平均時間複雜度爲O(n1.3),最差時間複雜度爲 O(n²),最好時間複雜度爲 O(n),空間複雜度爲 O(1)。

基本原理是把記錄按下標的一定增量分組,對每組進行直接插入排序,每次排序後減小增量,當增量減至 1 時,排序完畢。適用於中等規模的數據量,對規模非常大的數據量不是最佳選擇。

public void shellSort(int[] nums) {
    for (int d = nums.length / 2; d > 0 ; d /= 2) {
        for (int i = d; i < nums.length; i++) {
            int insertNum = nums[i];
            int insertIndex;
            for (insertIndex = i - d; insertIndex >= 0 && nums[insertIndex] > insertNum; insertIndex -= d) {
                nums[insertIndex + d] = nums[insertIndex];
            }
            nums[insertIndex + d] = insertNum;
        }
    }
}

P4:直接選擇排序

直接選擇排序屬於選擇排序,是一種不穩定的排序,任何情況下時間複雜度都是 O(n²),空間複雜度爲 O(1)。基本原理是每次在未排序序列中找到最小元素,和未排序序列的第一個元素交換位置,再在剩餘的未排序序列重複該操作直到所有元素排序完畢。適用於數據量較小的情況,比直接插入排序稍快。

public void selectSort(int[] nums) {
    int minIndex;
    for (int index = 0; index < nums.length - 1; index++){
        minIndex = index;
        for (int i = index + 1;i < nums.length; i++){
            if(nums[i] < nums[minIndex]) 
                minIndex = i;
        }
        if (index != minIndex){
            swap(nums, index, minIndex);
        }
    }
}

P5:堆排序

堆排序屬於選擇排序,是對直接選擇排序的改進,並且是一種不穩定的排序,任何情況時間複雜度都爲 O(nlogn),空間複雜度爲 O(1)。

基本原理是將待排序記錄看作完全二叉樹,可以建立大根堆或小根堆,大根堆中每個節點的值都不小於它的子節點值,小根堆中每個節點的值都不大於它的子節點值。適用於數據量較大的情況。

以大根堆爲例,在建堆時首先將最後一個節點作爲當前節點,如果當前結點存在父節點且值大於父節點,就將當前節點和父節點交換。在移除時首先暫存根節點的值,然後用最後一個節點代替根節點並作爲當前節點,如果當前節點存在子節點且值小於子節點,就將其與值較大的子節點進行交換,調整完堆後返回暫存的值。

public void add(int[] nums, int i, int num){
    nums[i] = num;
    int curIndex = i;
    while (curIndex > 0) {
        int parentIndex = (curIndex - 1) / 2;
        if (nums[parentIndex] < nums[curIndex]) 
            swap(nums, parentIndex, curIndex);
        else break;
        curIndex =parentIndex;
    }
}

public int remove(int[] nums, int size){
    int result = nums[0];
    nums[0] = nums[size - 1];
    int curIndex = 0;
    while (true) {
        int leftIndex = curIndex * 2 + 1;
        int rightIndex = curIndex * 2 + 2;
        if (leftIndex >= size) break;
        int maxIndex = leftIndex;
        if (rightIndex < size && nums[maxIndex] < nums[rightIndex])
            maxIndex = rightIndex;
        if (nums[curIndex] < nums[maxIndex])
            swap(nums, curIndex, maxIndex);
        else break;
        curIndex = maxIndex;
    }
    return result;
}

P6:冒泡排序

冒泡排序屬於交換排序,是一種穩定的排序,平均時間複雜度和最壞時間複雜度均爲 O(n²),當元素基本有序時的最好時間複雜度爲O(n),空間複雜度爲 O(1)。

基本原理是比較相鄰的元素,如果第一個比第二個大就進行交換,對每一對相鄰元素做同樣的工作,從開始第一對到結尾的最後一對,每一輪排序後末尾元素都是有序的,針對 n 個元素重複以上步驟 n -1 次排序完畢。

public void bubbleSort(int[] nums) {
    for (int i = 0; i < nums.length - 1; i++) {
        for (int index = 0; index < nums.length - 1 - i; index++) {
            if (nums[index] > nums[index + 1]) 
                swap(nums, index, index + 1)
        }
    }
}

**優化:**當序列已經有序時仍會進行不必要的比較,可以設置一個標誌位記錄是否有元素交換,如果沒有直接結束比較。

public void betterBubbleSort(int[] nums) {
    boolean swap;
    for (int i = 0; i < nums.length - 1; i++) {
        swap = true;
        for (int index = 0; index < nums.length - 1 - i; index++) {
            if (nums[index] > nums[index + 1]) {
                swap(nums, index ,index + 1);
                swap = false;
            }
        }
        if (swap) break;
    }
}

P7:快速排序

快速排序屬於交換排序,是對冒泡排序的一種改進,並且是一種不穩定的排序,平均時間複雜度和最好時間複雜度均爲 O(nlogn),當元素基本有序時的最壞時間複雜度爲O(n²),空間複雜度爲 O(logn)。

基本原理是首先選擇一個基準元素,然後通過一趟排序將要排序的數據分割成獨立的兩部分,一部分全部小於等於基準元素,一部分全部大於等於基準元素,然後再按此方法遞歸對這兩部分數據分別進行快速排序。適用於數據量較大且元素基本無序的情況。

快速排序的一次劃分從兩頭交替搜索,直到 low 和 high 指針重合,因此時間複雜度是 O(n),而整個算法的時間複雜度與劃分趟數有關。最好情況是每次劃分選擇的中間數恰好將當前序列幾乎等分,經過 logn 趟劃分便可得到長度爲 1 的子表,這樣算法的時間複雜度爲O(nlogn)。最壞的情況是每次所選中間數是當前序列中的最大或最小元素,這使每次劃分所得的子表其中一個爲空表,另一個子表的長度爲原表的長度 - 1。這樣長度爲 n 的數據表的需要經過 n 趟劃分,整個排序算法的時間複雜度爲O(n²)。

從空間上看盡管快速排序只需要一個元素的輔助空間,但快速排序需要一個棧空間來實現遞歸。最好的情況下,即快速排序的每一趟排序都將元素序列均勻地分割成長度相近的兩個子表,所需棧的最大深度爲 log(n+1),最壞情況下棧的最大深度爲 n。

public void quickSort(int[] nums, int start, int end) {
    if (start < end) {
        int pivotIndex = getPivotIndex(nums, start, end);
        quickSort(nums, start, pivotIndex - 1);
        quickSort(nums, pivotIndex + 1, end);
    }
}

public int getPivotIndex(int[] nums, int start, int end) {
    int pivot = nums[start];
    int low = start;
    int high = end;
    while (low < high) {
        while (low <= high && nums[low] <= pivot) 
            low++;
        while (low <= high && nums[high] > pivot) 
            high--;
        if (low < high) 
            swap(nums, low, high);
    }
    swap(nums, start, high);
    return high;
}

**優化:**當規模足夠小時,例如 end - start < 10 時,採用直接插入排序。


P8:歸併排序

歸併排序是基於歸併操作的排序算法,是一種穩定的排序算法,任何情況時間複雜度都爲 O(nlogn),空間複雜度爲 O(n)。

基本原理是應用分治法將待排序序列分成兩部分,然後對兩部分分別遞歸排序,最後進行合併,使用一個輔助空間並設定兩個指針分別指向兩個有序序列的起始元素,將指針對應的較小元素添加到輔助空間,重複該步驟到某一序列到達末尾,然後將另一序列剩餘元素合併到輔助空間末尾。適用於數據量大且對穩定性有要求的情況。

int[] help;

public void mergeSort(int[] arr) {
    int[] help = new int[arr.length];
    sort(arr, 0, arr.length - 1);
}

public void sort(int[] arr, int start, int end) {
    if (start == end) return;
    int mid = start + (end - start) / 2;
    sort(arr, start, mid);
    sort(arr, mid + 1, end);
    merge(arr, start, mid, end);
}

public void merge(int[] arr, int start, int mid, int end) {
    if (end + 1 - start >= 0) System.arraycopy(arr, start, help, start, end + 1 - start);
    int p = start;
    int q = mid + 1;
    int index = start;
    while (p <= mid && q <= end) {
        if (help[p] < help[q]) 
            arr[index++] = help[p++];
        else 
            arr[index++] = help[q++];
    }
    while (p <= mid) arr[index++] = help[p++];
    while (q <= end) arr[index++] = help[q++];
}

P9:排序算法的選擇原則

當數據量規模較小時,可以考慮直接插入排序或直接選擇排序,當元素分佈有序時直接插入排序將大大減少比較次數和移動記錄的次數,如果不要求穩定性,可以使用直接選擇排序,效率略高於直接插入排序。

當數據量規模中等時,可以選擇希爾排序。

當數據量規模較大時,可以考慮堆排序、快速排序和歸併排序。如果對穩定性有要求可以採用歸併排序,如果元素分佈隨機可以採用快速排序,如果元素分佈接近正序或逆序可以採用堆排序。

一般不使用冒泡排序。


設計模式 7

P1:設計模式的原則

**開閉原則:**面向對象設計中最基礎的設計原則,指一個軟件實體(類、模塊、方法等)應該對擴展開放,對修改關閉。它強調用抽象構建框架,用實現擴展細節,提高代碼的可複用性和可維護性。例如在版本更新時儘量不修改源代碼,但可以增加新功能。

**單一職責原則:**一個類、接口或方法只負責一個職責,可以提高代碼可讀性和可維護性,降低代碼複雜度以及變更引起的風險。

**依賴倒置原則:**程序應該依賴於抽象類或接口,而不是具體的實現類。可以降低代碼的耦合度,提高系統的穩定性。

**接口隔離原則:**將不同功能定義在不同接口中實現接口隔離,避免了類依賴它不需要的接口,減少了接口之間依賴的冗餘性和複雜性。

**里氏替換原則:**對開閉原則的補充,規定了任何父類可以出現的地方子類都一定可以出現,可以約束繼承氾濫,加鍵程序健壯性。

**迪米特原則:**也叫最少知道原則,每個模塊對其他模塊都要儘可能少的瞭解和依賴,可以降低代碼耦合度。

**合成/聚合原則:**儘量使用組合(has a)或聚合(contains a)而不是繼承關係達到軟件複用的目的,可以使系統更加靈活,降低耦合度。


P2:設計模式的分類

**創建型模式:**提供了一種在創建對象的同時隱藏創建邏輯的方式,而不是使用 new 運算符直接實例化對象,這使得程序在判斷針對某個給定實例需要創建哪些對象時更加靈活。包括:工廠模式、抽象工廠模式、單例模式、建造者模式、原型模式。

**結構型模式:**通過類和接口之間的繼承和引用實現創建複雜結構對象的功能。包括:適配器模式、橋接模式、過濾器模式、組合模式、裝飾器模式、外觀模式、享元模式、代理模式。

**行爲型模式:**通過類之間不同的通信方式實現不同的行爲方式。包括:責任鏈模式、命名模式、解釋器模式、迭代器模式、中介者模式、備忘錄模式、觀察者模式、狀態模式、策略模式、模板模式、訪問者模式。


P3:工廠模式

工廠模式屬於創建型模式,分爲簡單工廠模式,工廠方法模式和抽象工廠模式。

簡單工廠模式指由一個工廠對象來創建實例,客戶端不需要關注創建的邏輯,只需要提供傳入工廠對象的參數。

工廠方法模式指定義一個創建對象的接口,讓接口的實現類來決定創建哪一種對象,工廠方法模式讓類的實例化推遲到子類中進行。工廠方法模式中客戶端只需關心對應的工廠而無需關心創建細節,主要解決了產品擴展的問題,在簡單工廠模式中如果產品種類變多,工廠的職責會越來越多,不便於維護。

抽象工廠模式指提供一個創建一系列相關或相互依賴對象的接口,無需指定它們的具體類。客戶端不依賴於產品類實例如何被創建和實現的細節,主要用於系統的產品有多於一個的產品族,而系統只消費其中某一個產品族產品的情況。

總結:

**簡單工廠:**一個工廠,一種抽象產品。例如一個麥當勞店,可以生產多種漢堡。

public class MacDonaldFactory {
    public Hamburger eatHamburger(String name) {
        if ("beef".equals(name))
            return new BeefHamburger();
        else if ("pig".equals(name))
            return new PigHamburger();
        return null;
    }
}

interface Hamburger {
    void eat();
}

class BeefHamburger implements Hamburger {
    @Override
    public void eat() {
        System.out.println("喫牛肉漢堡");
    }
}

class PigHamburger implements Hamburger {
    @Override
    public void eat() {
        System.out.println("喫豬肉漢堡");
    }
}

**工廠方法:**多個工廠,一種抽象產品。例如一個麥當勞店,可以生產多種漢堡,一個肯德基店,也可以生產多種漢堡。

public interface HamburgerFactory {
    Hamburger build();
}

class MCFactory implements HamburgerFactory {
    @Override
    public Hamburger build() {
        return new MCHamburger();
    }
}

class KFCFactory implements HamburgerFactory {
    @Override
    public Hamburger build() {
        return new KFCHamburger();
    }
}

interface Hamburger {
    void eat();
}

class MCHamburger implements Hamburger {
    @Override
    public void eat() {
        System.out.println("喫麥當勞漢堡");
    }
}

class KFCHamburger implements Hamburger {
    @Override
    public void eat() {
        System.out.println("喫肯德基漢堡");
    }
}

**抽象工廠:**多個工廠,多種抽象產品。例如一個麥當勞店和一個肯德基店都可以生產多種漢堡和可樂。

public interface FoodFactory {
    Hamburger buildHamburger();
    Drink buildDrink();
}

class MCFactory implements FoodFactory {
    @Override
    public Hamburger buildHamburger() {
        return new MCHamburger();
    }

    @Override
    public Drink buildDrink() {
        return new MCDrink();
    }
}

class KFCFactory implements FoodFactory {
    @Override
    public Hamburger buildHamburger() {
        return new KFCHamburger();
    }

    @Override
    public Drink buildDrink() {
        return new KFCDrink();
    }
}

interface Hamburger {
    void eat();
}

class MCHamburger implements Hamburger {
    @Override
    public void eat() {
        System.out.println("喫麥當勞漢堡");
    }
}

class KFCHamburger implements Hamburger {
    @Override
    public void eat() {
        System.out.println("喫肯德基漢堡");
    }
}

interface Drink {
    void drink();
}

class MCDrink implements Drink {
    @Override
    public void drink() {
        System.out.println("喝麥當勞飲料");
    }
}

class KFCDrink implements Drink {
    @Override
    public void drink() {
        System.out.println("喝肯德基飲料");
    }
}

P4:單例模式

單例模式屬於創建型模式,是指一個單例類在任何情況下都只存在一個實例,構造器必須是私有的並由自己創建一個靜態實例對象,並對外提供一個靜態公有的獲取實例方法。優點是在內存裏只有一個實例,減少了內存的開銷,尤其是頻繁的創建和銷燬實例的情況下,並且可以避免對資源的多重佔用。缺點是沒有抽象層,難以擴展,與單一職責原則衝突。

**餓漢式:**在類加載時就初始化創建單例對象,是線程安全的,但不管是否使用都會創建對象,可能會浪費內存。

public class HungrySingleton {
    private HungrySingleton(){}
    
    private static HungrySingleton instance = new HungrySingleton();
    
    public static HungrySingleton getInstance() {
        return instance;
    }
}

**懶漢式:**在外部調用時纔會加載,是線程不安全的,可以加鎖保證線程安全但效率低。

public class LazySingleton {
    private LazySingleton(){}
    
    private static LazySingleton instance;
    
    public static LazySingleton getInstance() {
        if(instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
}

**雙重檢查鎖:**使用 volatile 以及兩次檢查來減小 synchronized 鎖範圍,提升效率。

public class DoubleCheckSingleton {
    private DoubleCheckSingleton(){}
    
    private volatile static DoubleCheckSingleton instance;
    
    public static DoubleCheckSingleton getInstance() {
        if(instance == null) {
            synchronized (DoubleCheckSingleton.class) {
                if (instance == null) {
                    instance = new DoubleCheckSingleton();
                }
            }
        }
        return instance;
    }
}

**靜態內部類:**可以同時解決餓漢式的內存浪費問題和懶漢式的線程安全問題。

public class StaticSingleton {
    private StaticSingleton(){}
    
    public static StaticSingleton getInstance() {
        return StaticClass.instance;
    }
    
    private static class StaticClass {
        private static final StaticSingleton instance = new StaticSingleton();
    }
}

**枚舉:**這種方式是 Effective Java 作者提倡的方式,它不僅能避免多線程同步問題,還能防止反序列化重新創建新的對象,絕對防止多次實例化,也能防止反射破解單例的問題。

public enum EnumSingleton {
    INSTANCE;
}

P5:代理模式

代理模式屬於結構型模式,爲其他對象提供一種代理以控制對這個對象的訪問,可以增強目標對象的功能。優點是可以增強目標對象的功能,一定程度降低代碼耦合度,擴展性好。缺點是在客戶端和目標對象之間增加代理對象會導致請求處理速度變慢,同時也會增加系統複雜度。

**靜態代理:**代理對象持有真實對象的引用,調用代理對象方法時也會調用真實對象的方法,但是會在真實對象方法的前後增加一些其他邏輯。需要手動完成代理操作,在程序運行前就已經存在代理類的字節碼文件,代理類和被代理類的關係在運行前就已經確定了。 缺點是一個代理類只能爲一個目標類服務,如果要服務多種類型就會增加很大的工作量。

public interface Company {
    void findWorker();
}

public class Hr implements Company {
    @Override
    public void findWorker() {
        System.out.println("我需要找招聘一個員工");
    }
}

public class Proxy implements Company {
    private Hr hr;

    public Proxy(){
        this.hr = new Hr();
    }

    @Override
    public void findWorker() {
        hr.findWorker();
        System.out.println("找到了員工");
    }

}

**動態代理:**動態代理在程序運行時才通過反射創建具體的代理類,代理類和被代理類的關係在運行前是不確定的。動態代理的適用性更強,主要分爲 JDK 動態代理和 CGLib 動態代理。

  • **JDK 動態代理:**通過 Proxy類的 newInstance 方法獲取一個動態代理對象,需要傳入三個參數,被代理對象的類加載器、被代理對象實現的接口,以及一個 InvocationHandler 調用處理器實例來指明具體的邏輯,相比靜態代理最大的優勢是接口中聲明的所有方法都被轉移到 InvocationHandler 中的 invoke 方法集中處理。

    public static void main(String[] args) {
        Hr hr = new Hr();
        Company proxyHr = (Company) Proxy.newProxyInstance(hr.getClass().getClassLoader(), hr.getClass().getInterfaces(), (proxy, method, args1) -> {
            System.out.println("接收代理請求");
            Object obj = method.invoke(hr, args1);
            System.out.println("找到了員工,完成請求");
            return obj;
        });
        proxyHr.findWorker();
    }
    
  • **CGLib 動態代理:**與 JDK 動態代理不同的是,JDK 動態代理要求實現被代理對象的接口,而 CGLib 要求繼承被代理對象,如果一個類是 final 類則不能使用 CGLib 動態代理。兩種代理都是在運行期生成字節碼,JDK 動態代理直接寫字節碼,而 CGLib 動態代理使用 ASM 框架寫字節碼,ASM 作用於已編譯好的 Class 文件,其目的是生成、轉換和分析以字節數組表示的已編譯 Java 類。 JDK 動態代理調用代理方法是通過反射機制實現的,而 GCLib 動態代理是通過 FastClass 機制直接調用方法的,爲代理類和被代理類各生成一個類,該類爲代理類和被代理類的方法會分配一個 int 類型的參數,調用方法時可以直接定位而省去反射,因此調用方法的效率更高。


P6:裝飾器模式

指在不改變原有對象的基礎上,將功能附加到對象上,相比繼承可以更加靈活地擴展原有對象的功能,屬於結構型模式。這種模式創建了一個裝飾類,用來包裝原有的類,並在保持類方法簽名完整性的前提下提供了額外的功能。裝飾器模式適合的場景:在不想增加很多子類的前提下擴展一個類的功能或給一個類添加附加職責、動態地給一個類添加功能,這些功能可以再動態地撤銷。

**和動態代理的區別:**裝飾器模式的關注點在於給對象動態添加方法,而動態代理更注重對象的訪問控制。動態代理通常會在代理類中創建被代理對象的實例,而裝飾器模式會將裝飾者作爲構造器的參數。


P7:適配器模式

適配器模式屬於結構型模式,它作爲兩個不兼容的接口之間的橋樑,結合了兩個獨立接口的功能,將一個類的接口轉換成另外一個接口,這種模式涉及到一個單一的類,該類負責加入獨立的或不兼容的接口功能。優點是使得原本由於接口不兼容而不能一起工作的類可以一起工作。 缺點是過多使用適配器會讓系統非常零亂,不易整體進行把握。

**和裝飾器模式的區別:**適配器模式的是要將一個接口轉變成另一個接口,目的是通過改變接口來解決接口不兼容的問題。而裝飾器模式不是要改變被裝飾對象的接口,而是要增強原有對象的功能。例如 java.io 包中,適配器模式是將 InputStream 字節輸入流通過適配器 InputStreamReader 轉換爲 Reader 字符輸入流,而裝飾器模式是將 InputStream 通過裝飾器 BufferedInputStream 增強爲緩衝字節輸入流。


Java 基礎 20

P1:Java 語言的基本概念

優點:

  • 具有平臺無關性,擺脫了硬件平臺的束縛,實現了“一次編寫,到處運行”的理想。

  • 提供了一種相對安全的內存管理和訪問機制,避免了絕大部分內存泄漏和指針越界問題。

  • 實現了熱點代碼檢測和運行時編譯及優化,使得 Java 程序隨運行時間增長可以獲得更高的性能。

  • 有一套完善的應用程序接口,還支持很多第三方類庫。

Java 平臺無關性原理:

主要是通過 JVM 和 Java 語言規範實現。

  • 編譯器生成一個體繫結構中立的目標文件格式,這是一種編譯後的代碼,只要有 Java 運行時系統,這些編譯後的代碼可以在很多處理器上運行。Java 編譯器通過生成與特定計算機體系結構無關的字節碼指令來實現這一特性,字節碼文件不僅可以很容易地在任何機器上解釋執行,還可以動態地轉換成本地機器代碼,轉換是由 JVM 實現的,JVM 是平臺相關的,屏蔽了不同操作系統的差異。
  • Java 中基本數據類型的大小以及有關運算的行爲都有明確的說明,例如 Java 中的 int 類型永遠爲 32 位的整數,而在 C/C++ 中 int 可能是 16 位整數、32 位整數,也可能是編譯器開發商指定的其他任何大小。在 Java 中數值類型有固定的字節數,二進制數據以固定的格式進行存儲和傳輸,字符串則採用標準的 Unicode 格式存儲。

專業術語:

  • JDK:Java Development Kit,Java 開發工具包。它提供了編譯、運行 Java 程序所需的各種工具和資源,包括 Java 編譯器、JRE 以及常用的 Java 基礎類庫等,是 JAVA 的核心。JDK 是編寫 Java 程序的程序員使用的軟件。
  • JRE:Java Runtime Environment,Java 運行時環境,是運行基於 Java 語言編寫的程序所不可缺少的運行環境。JRE 是運行 Java 程序的用戶使用的軟件。
  • SE:Standard Edition,標準版,用於桌面或簡單服務器應用的 Java 平臺。
  • EE:Enterprise Edition,企業版,用於複雜服務器應用的 Java 平臺。
  • ME:Micro Edition,微型版,用於小型設備的 Java 平臺。

P2:Java 基本數據類型

數據類型 佔用內存大小 取值範圍
byte 1 字節 -27 ~ 27-1
short 2 字節 -215 ~ 215-1
int 4 字節 -231 ~ 231-1
long 8 字節 -263 ~ 263-1
float 4 字節 ±3.4E+38F(有效位數 6~7 位)
double 8 字節 ±1.7E+308(有效位數 15 位)
char 英文在 UTF-8 和 GBK 中均佔 1 字節,中文在 UTF-8 佔 3 字節,GBK 佔 2 字節。 /
boolean 單個變量用 int 代替,佔 4 字節,而數組會編碼成 byte 數組,佔 1 字節。 true、false

每個基本數據類型都對應一個自己的包裝類,除了 int 和 char 對應 Integer 和 Character 之外,其餘基本數據類型的包裝類都是首字母大寫即可。自動裝箱指的是將基本數據類型包裝爲一個包裝類對象,例如向一個泛型爲 Integer 類型的集合添加 int 類型的元素。自動拆箱指的是將一個包裝類對象轉換爲一個基本數據類型,例如將一個包裝類對象賦值給一個基本數據類型的變量。要比較兩個包裝類的數值需要使用 equals 方法,而不能使用 == 比較運算符。


P3:String

**不可變性:**String 是不可變類,並且存儲數據的 value 字符數組也是 final 修飾的不可變數組,因此當修改一個 String 變量的值時,並沒有真正修改它引用的 String 對象的字符數組中的值,而是重新創建了一個 String 對象賦值給了 String 變量進行引用。

**字符串拼接:**直接使用 + 進行字符串拼接,如果是字面量會自動拼接爲一個新的常量。要提升拼接效率可以使用 StringBuilder 或 StringBuffer 可變字符串,區別是 StringBuffer 使用了 synchronized 保證線程安全性,但一般字符串拼接都是單線程操作,所以使用 StringBuilder 較多。常量和常量的拼接,結果也在常量池中,且不存在兩個相同的常量。只要參與拼接的字符串裏有變量,結果就在堆中。

創建: 如果是通過字符串常量賦值的形式,例如 String s = “s”,字符串常量內容存於常量池,變量存於棧中並直接引用常量池中的字符串。如果是通過new 的形式,例如 String s = new String("s"),會先在堆中創建實例對象,然後再去常量池尋找需要的字符串常量,如果找到了則直接使用,沒找到則開闢新的空間並存儲內容,最後棧中變量引用堆中對象,對象再引用常量池中的字符串。


P4:值調用和引用調用

按值調用指的是方法接收的是調用者提供的值,而按引用調用指的是方法接收的是調用者提供的變量地址。Java 總是採用按值調用,也就是說方法得到的是所有參數值的一個副本,當傳遞對象時實際上方法接收的是這個對象引用的副本。方法不能修改基本數據類型的參數,可以改變對象參數的狀態,但不能讓對象參數引用一個新的對象。

舉例來說,如果傳遞了一個 int 類型的值 ,改變該值不會影響實參,因爲改變的是該值的一個副本。如果傳遞了一個 int[] 類型的數組,改變數組的內容會影響實參,而如果改變這個參數的引用,並不會讓實參引用新的數組對象。


P5:面向對象

**概念:**面向對象是一種程序設計思想,相對於面向過程而言更適合解決規模較大的問題。採用面向對象的開發方式可以對現實的事物進行抽象,把現實的事物映射爲開發對象,接近人的思維。並且可以通過繼承或組合的方式實現代碼的重用,因此開發效率高。並且面向對象的開發方式提高了代碼的可讀性,使代碼結構更加清晰,方便代碼的維護。

特性:

  • **封裝:**也稱數據隱藏,從形式上看就是將數據和行爲組合在一個包中,並對對象的使用者隱藏具體的實現方式。
  • **繼承:**可以通過繼承來擴展一個類,擴展的子類可以繼承父類的屬性和方法,並可以添加自己獨有的屬性和方法。Java 中類只可以單繼承,接口之間是可以多繼承的。繼承是一種"is-a"的關係,可以提高代碼的複用性。
  • **多態:**父類的變量可以引用一個子類的對象,在運行時通過動態綁定來決定調用的方法。
    • **重載:**是指同一個類中具有多個方法名相同而方法參數列表不同的方法,重載方法的返回值類型不做要求,但方法的參數列表必須不同。重載屬於一種編譯時多態。
    • **重寫:**是指子類具有和父類方法名和方法參數列表都相同的方法,要求返回值不大於父類方法的返回值,拋出的異常類型不大於父類方法拋出的異常類型,訪問修飾符可見性不小於父類方法的訪問修飾符可見性。重寫屬於一種運行時多態。

P6:方法修飾符

訪問修飾符 本類可見性 本包可見性 子類可見性 不同包可見性
public
protected ×
默認 × ×
private × × ×

P7:接口和抽象類

**成員變量:**接口中的成員變量默認是 public static final 修飾的常量,抽象類中的成員變量無特殊要求。

**構造器:**接口和抽象類都不能直接實例化,但接口沒有構造器,抽象類是有構造器的。

**方法:**接口中的方法默認是 public 修飾的,Java 8 開始支持默認方法和靜態方法,Java 9 開始支持私有方法。抽象類中的方法不做要求,抽象類可以不含抽象方法,但含有抽象方法的類一定是抽象類。

**繼承:**接口可以多繼承和多實現,而抽象類只能單繼承。

**選擇原則:**如果知道某個類應該成爲基類,那麼第一選擇應該是讓它成爲一個接口,只有在必須要有方法定義和成員變量的時候,才應該選擇抽象類。在接口和抽象類的選擇上,必須遵守這樣一個原則:行爲模型應該總是通過接口而不是抽象類定義。通過抽象類建立行爲模型會出現的問題:如果有一個產品類 A,有兩個子類 B 和 C 分別有自己的功能,如果出現一個既有 B 產品功能又有 C 產品功能的新產品需求,由於 Java 不允許多繼承就出現了問題,而如果是接口的話只需要同時實現兩個接口即可。


P8:Object 類

Object 的類是所有類的父類,Object 類的方法:

  • **equals:**用於檢測一個對象是否等於另一個對象,默認使用 == 比較兩個對象的引用,可以重寫 equals 方法自定義比較規則。equals 方法需要滿足以下規範:自反性、對稱性、傳遞性、一致性並對於任何非空引用 x,x.equals(null) 返回 false。
  • **hashCode:**散列碼是由對象導出的一個整型值,是沒有規律的,每個對象都有一個默認的散列碼,值由對象的存儲地址得出。字符串可能有相同的散列碼,因爲字符串的散列碼是由內容導出的。爲了在集合中正確使用對象,一般需要同時重寫 equals 和 hashCode 方法,要求是 equals 相同是 hashCode 必須相同,但 hashCode 相同時 equals 未必相同,因此 hashCode 是兩個對象相同的必要不充分條件。
  • toString:打印對象時默認會調用它的 toString 方法,如果沒有重寫該方法默認打印的是表示對象值的一個字符串,一般需要重寫該方法。打印數組時可以使用 Arrays.toString() 方法。
  • **clone:**clone 方法聲明爲 protected,類只能通過該方法克隆它自己的對象,如果希望其他類也能調用該方法必須定義該方法爲 public。如果一個對象的類沒有實現 Cloneable 接口,該對象調用 clone 方法會拋出一個 CloneNotSupport 異常。默認的 clone 方法是淺拷貝,一般重寫 clone 方法需要實現 Cloneable 接口並指定訪問修飾符爲 public。
    • **淺拷貝:**如果對象包含子對象的引用,拷貝字段就會得到相同子對象的另一個引用,如果共享的子對象是不可變的則是安全的,通常子對象都是可變的,因此淺拷貝是不安全的,拷貝對象的更改會影響原對象。
    • **深拷貝:**會完全拷貝基本數據類型和引用數據類型,深拷貝是安全的。
  • **finalize:**要確定一個對象死亡至少要經過兩次標記,如果對象在進行可達性分析後發現沒有與GC Roots 連接的引用鏈,會被第一次標記,隨後進行一次篩選,篩選的條件是此對象是否有必要執行 finalize 方法。假如對象沒有重寫 finalize 方法或者該方法已經被虛擬機調用過,那麼這兩種情況視爲沒有必要執行。如果這個對象被判斷爲有必要執行,就會被放置在一個叫做 F-Queue 的隊列中,由一條虛擬機自動建立的低調度優先級的 Finalizer 線程去執行它們的 finalize 方法。虛擬機會觸發該方法但不保證它會運行結束,這是爲了防止某個對象的 finalize 方法執行緩慢或發生死循環。只要對象在 finalize 方法中重新與引用鏈上的任何一個對象建立關聯關係就會在第二次標記時被移出即將回收集合,否則就會被真正回收。由於運行代價高昂,不確定性大,無法保證各個對象的調用順序,如今已被官方明確聲明爲不推薦使用的語法,因此它並不適合釋放資源,釋放資源完全可以使用 try-finally 代碼塊。
  • **getClass:**返回包含對象信息的類對象。
  • **wait / notify / notifyAll:**阻塞或喚醒持有該對象鎖的線程。

P9:內部類

使用內部類主要有兩個原因:內部類可以對同一個包中的其他類隱藏。內部類方法可以訪問定義這個內部類的作用域中的數據,包括原本私有的數據。內部類是一個編譯器現象,與虛擬機無關。編譯器會把內部類轉換成常規的類文件,用美元符號 $ 分隔外部類名與內部類名,而虛擬機對此一無所知。

**靜態內部類:**由static修飾,屬於外部類本身,只加載一次。類可以定義的成分靜態內部類都可以定義,可以訪問外部類的靜態變量和方法,通過 new 外部類.內部類構造器 來創建對象。只要內部類不需要訪問外部類對象,就應該使用靜態內部類。

**成員內部類:**屬於外部類的每個對象,隨對象一起加載。不可以定義靜態成員和方法,可以訪問外部類的所有內容,通過 new 外部類構造器.new 內部類構造器 來創建對象。

**局部內部類:**定義在方法、構造器、代碼塊、循環中。不能聲明訪問修飾符,只能定義實例成員變量和實例方法,作用範圍僅在聲明這個局部類的代碼塊中。

**匿名內部類:**沒有名字的局部內部類,可以簡化代碼,匿名內部類會立即創建一個匿名內部類的對象返回,對象類型相當於當前 new 的類的子類類型。匿名內部類一般用於實現事件監聽器和其他回調。

class OuterClass{

    static class StaticInnerClass {}

    class NormalInnerClass {}

    public void test() {
        
        class LocalClass {}

        // 靜態內部類創建對象
        new OuterClass.StaticInnerClass();
        
        // 成員內部類創建對象
        new OuterClass().new NormalInnerClass();
        
        // 局部內部類創建對象
        new LocalClass();
        
        // 匿名內部類創建對象
        Runnable runnable = () -> {};
    }
}

P10:static 關鍵字

static 關鍵字主要有兩個作用:(1)爲某特定數據類型或對象分配單一的存儲空間,而與創建對象的個數無關。(2)讓某個屬性或方法與類而不是對象關聯在一起,可以在不創建對象的情況下通過類名來訪問。

作用範圍:

static 修飾的變量稱爲靜態變量,也叫做類變量,可以直接通過類名來訪問,靜態變量存儲在 JVM 的方法區中。

static 修飾的方法稱爲靜態方法,也叫做類方法,可以直接通過類名來訪問,靜態方法只能訪問靜態變量或靜態方法。

static 修飾的代碼塊稱爲靜態代碼塊,只能定義在類下,會在類加載時執行,只會執行一次。

static 修飾的類稱爲靜態內部類,可以訪問外部類的靜態變量和方法。

static 也可以用來導入包下的靜態變量。

類初始化的順序:

(1)父類靜態代碼塊和靜態變量
(2)子類靜態代碼塊和靜態變量
(3)父類普通代碼塊和普通變量
(4)父類構造器
(5)子類普通代碼塊和普通變量
(6)子類構造器

其中代碼塊和變量的初始化順序按照類中聲明的順序執行。


P11:序列化和反序列化

Java 對象在 JVM 運行時被創建,當 JVM 退出時存活對象都會銷燬,如果需要將對象及其狀態持久化,就需要通過序列化來實現,將對象及其狀態信息保存在字節數組中,在需要時再將這些字節數組反序列化爲對象。對象序列化保存的是對象的狀態,因此類中的靜態變量不會被序列化,因爲靜態變量是類屬性。

要實現序列化功能需要實現 java.io.Serializabale 標記接口,序列化和反序列化必須保持序列化 ID 的一致,一般使用 private static final long serialVersionUID 定義序列化 ID,如果需要序列化父類的狀態,父類也需要實現該接口。

有許多序列化框架,例如 fastjson、thrift等,也可以使用 JDK 自帶的 ObjectOutputStream 類的 writeObject 方法實現序列化,將對象以流的方式寫入磁盤中,ObjectInputStream 類的 readObject 方法實現反序列化,以流的方式從磁盤讀取。

除了靜態變量外,transient 修飾的變量也不會被序列化。transient 的作用就是把這字段的生命週期僅限於內存中而不會寫到磁盤裏持久化,被 transient 修飾的變量會被設爲對應數據類型的默認初始值。

除了實現 Serializabale 接口外,另一種方法是實現 Exteranlizable 接口。 需要重寫 writeExternal 和 readExternal 方法,它的效率比Serializable 高一些,並且可以決定哪些屬性需要序列化(即使是 transient 修飾的變量),但是對大量對象或者重複對象則效率低。


P12:反射

在運行狀態中,對於任意一個類,都能夠知道這個類的所有屬性和方法,對於任意一個對象,都能夠調用它的任意一個方法和屬性;這種動態獲取的信息以及動態調用對象的方法的功能稱爲Java的反射機制。優點是運行時動態獲取類的全部信息,缺點是破壞了類的封裝性,泛型的約束性。反射是框架的核心靈魂,動態代理設計模式採用了反射機制,還有 Spring、Hibernate 等框架也大量使用到了反射機制。

在程序運行期間,Java 運行時系統始終爲所有對象維護一個運行時類型標識,這個信息會跟蹤每個對象所屬的類,虛擬機利用運行時類型信息選擇要執行的正確方法,保存這些信息的類名爲 Class。

獲取 Class 實例的方法有三種:(1)直接通過 類名.class 。②通過對象的 getClass()方法。③通過 Class.forName(類的全限定名)。Class 類中的 getFields、getMethods 和 getConstructors 方法分別返回這個類支持的公共字段、方法和構造器的數組,其中包括父類的公共成員。Class 類中的 getDeclaredFields、getDeclaredMethods 和 getDeclaredConstructors 方法分別返回這個類聲明的全部字段、方法和構造器的數組,其中包括私有成員、包成員和受保護成員,但不包括父類的成員。

Field、Method、Constructor 分別用於描述類的字段、方法和構造器。這三個類都有一個 getName 方法返回字段、方法或構造器的名稱。Field 類有一個 getType 方法用來返回描述字段類型的一個對象,這個對象的類型也是 Class。Method 和 Constructor 類有報告參數類型的方法,Method 類還有一個報告返回類型的方法。這三個類都有一個 getModifiers 方法,它返回一個整數,用不同的 0/1 位描述所使用的修飾符。


P13:註解

註解是一種標記,可以使類或接口附加額外的信息,是幫助編譯器和 JVM 完成一些特定功能的,例如常用註解 @Override 標識一個方法是重寫方法。

元註解就是自定義註解的註解,包括:

  • @Target:用來約束註解作用的位置,值是 ElementType 枚舉類實例,包括 METHOD 方法、VARIABLE 變量、TYPE 類/接口、PARAMETER 方法參數、CONSTRUCTORS 構造器和 LOACL_VARIABLE 局部變量等。

  • @Rentention:用來約束註解的生命週期,值是 RetentionPolicy 枚舉類實例,包括:SOURCE 源碼、CLASS 字節碼和 RUNTIME 運行時。

  • @Documented:表明這個註解應該被 javadoc 工具記錄。

  • @Inherited:表面某個被標註的類型是被繼承的。


P14:異常

所有的異常都派生於 Throwable 類的一個類實例,在下一層分爲 Error 和 Exception。

Error 類描述了 Java 運行時系統的內部錯誤和資源耗盡錯誤,如果出現了這種錯誤,一般無能爲力。

Exception 類又分爲 RuntimeException 和其他異常,一般規則是由編程錯誤導致的異常屬於 RuntimeException,如果程序本身沒有問題,但由於像 IO 錯誤這類問題導致的異常屬於其他異常。派生於 Error 和 RuntimeException 的異常屬於非檢查型異常,其餘異常都屬於檢查型異常。

常見的 RuntimeException 異常:

  • ClassCastException,錯誤的強制類型轉換。
  • ArrayIndexOutOfBoundsException,數組訪問越界。
  • NullPointerException,空指針異常。

常見的檢查型異常:

  • FileNotFoundException,試圖打開不存在的文件。
  • ClassNotFoundException,試圖根據指定字符串查找 Class 對象,而這個類並不存在。
  • IOException,試圖超越文件末尾繼續讀取數據。

異常處理:

**拋出異常:**遇到異常不進行具體處理,而是將異常拋出給調用者,由調用者根據情況處理。拋出異常有2種形式,一種是 throws 關鍵字聲明拋出的異常,作用在方法上,一種是使用throw 語句直接拋出異常,作用在方法內。

**捕獲異常:**使用 try/catch 進行異常的捕獲,try 中發生的異常會被 catch 代碼塊捕獲,根據情況進行處理,如果有 finally 代碼塊無論是否發生異常都會執行,一般用於釋放資源,Java 7 開始可以將資源定義在 try 代碼塊中自動釋放資源。


P15:泛型

泛型的本質是參數化類型,泛型提供了編譯時類型的安全檢測機制,該機制允許程序在編譯時檢測非法的類型。

類型擦除:

虛擬機沒有泛型類型對象,所有對象都屬於普通類。無論何時定義一個泛型類型,都會自動提供一個相應的原始類型,原始類型的名字就是去掉類型參數後的泛型類型名。類型變量會被擦除,如果沒有限定類型就會替換爲 Object,如果有限定類型就會替換爲第一個限定類型,例如 <T extends A & B> 會使用 A 類型替換 T。

泛型主要用於編譯階段,在編譯後生成的 Java 字節代碼文件中不包含泛型中的類型信息。

泛型規範:

泛型標記 說明
E(Element) 在集合中使用,表示在集合中存放的元素。
T(Type) 表示類,包括基本的類以及自定義類。
K(Key) 表示鍵,例如 Map 集合中的 Key。
V(Value) 表示值,例如 Map 集合中的 Value。
N(Number) 表示數值類型。
表示不確定的類型。

泛型限定:

對泛型上限的限定使用<? extends T>,它表示該通配符所代表的類型是 T 類的子類型或 T 接口的子接口。

對泛型下限的限定使用<? super T>,它表示該通配符所代表的類型是 T 類的父類型或 T 接口的父接口。


P16:Java 8 新特性

**lambda 表達式:**lambda 表達式允許把函數作爲一個方法的參數傳遞到方法中,主要用來簡化匿名內部類的代碼。

**函數式接口:**使用 @FunctionalInterface 註解標識,有且僅有一個抽象方法,可以被隱式轉換爲 lambda 表達式。

**方法引用:**可以直接引用已有類或對象的方法或構造器,進一步簡化 lambda 表達式。方法引用有四種形式:引用構造方法、引用類的靜態方法、引用特定類的任意對象方法、引用某個對象的方法。

**接口中的方法:**接口中可以定義 default 修飾的默認方法,降低了接口升級的複雜性,還可以定義靜態方法。

**註解:**Java 8 引入了重複註解機制,相同的註解在同一個地方可以聲明多次。註解的作用範圍也進行了擴展,可以作用於局部變量、泛型、方法異常等。

**類型推測:**加強了類型推測機制,可以使代碼更加簡潔,例如在定義泛型集合時可以省略對象中的泛型參數。

**Optional 類:**用來處理空指針異常,提高代碼可讀性。

**Stream 類:**把函數式編程風格引入 Java 語言,提供了很多功能,可以使代碼更加簡潔。方法包括forEach() 遍歷、count() 統計個數、filter() 按條件過濾、limit() 取前 n 個元素、skip() 跳過前 n 個元素、map() 映射加工、concat() 合併stream流等。

**日期:**增強了日期和時間的 API,新的 java.time 主要包含了處理日期、時間、日期/時間、時區、時刻和時鐘等操作。

**JavaScript:**Java 8 提供了一個新的 Nashorn JavaScript 引擎,它允許我們在 JVM上運行特定的 JavaScript 應用。


P17:Java IO

IO 模型 對應的 Java 版本
BIO(同步阻塞 IO) 1.4 之前
NIO(同步非阻塞 IO) 1.4
AIO(異步非阻塞 IO) 1.7

同步和異步是通信機制,阻塞和非阻塞是調用狀態。

  • 同步 IO 是用戶線程發起 I/O 請求後需要等待或者輪詢內核 I/O 操作完成後才能繼續執行。

  • 異步 IO 是用戶線程發起 I/O 請求後仍可以繼續執行,當內核 I/O 操作完成後會通知用戶線程,或者調用用戶線程註冊的回調函數。

  • 阻塞 IO 是指 I/O 操作需要徹底完成後才能返回用戶空間 。

  • 非阻塞 IO 是指 I/O 操作被調用後立即返回一個狀態值,無需等 I/O 操作徹底完成。

BIO:

同步阻塞式 IO,服務器實現模式爲一個連接請求對應一個線程,即客戶端有連接請求時服務器端就需要啓動一個線程進行處理,如果這個連接不做任何事情會造成不必要的線程開銷。可以通過線程池機制改善,這種 IO 稱爲僞異步 IO。

主要分爲字符流和字節流,字符流包括字符輸入流 Reader 和字符輸出流 Writer,字節流包括字節輸入流 InputStream 和 字節輸出流 OutputStream,字節流和字符流都有對應的緩衝流和過濾流,也可以將字節流包裝爲字符流。

**適用場景:**連接數目少、服務器資源多、開發難度低。


NIO:

同步非阻塞 IO,服務器實現模式爲多個連接請求對應一個線程,客戶端發送的連接請求都會註冊到一個多路複用器 Selector 上,多路複用器輪詢到連接有I/O請求時才啓動一個線程進行處理,有數據纔會開啓線程處理,性能比較好。

同步是指線程還是要不斷接收客戶端連接並處理數據,非阻塞是指如果一個管道沒有數據,不需要等待,可以輪詢下一個管道。

有三個核心組件:

  • Selector

    選擇器或多路複用器,主要作用是輪詢檢查多個 Channel 的狀態,判斷 Channel 註冊的事件是否發生,即判斷 Channel 是否處於可讀或可寫狀態。在使用之前需要將 Channel 註冊到 Selector 上,註冊之後會得到一個 SelectionKey,通過 SelectionKey 可以獲取 Channel 和 Selector 的相關信息。

  • Channel

    雙向通道,替換了 IO 中的 Stream,不能直接訪問數據,要通過 Buffer 來讀寫數據,也可以和其他 Channel 交互。

    **分類:**FileChannel 處理文件、DatagramChannel 處理 UDP 數據、SocketChannel 處理 TCP 數據,用作客戶端、ServerSocketChannel 處理 TCP 數據,用作服務器端。

  • Buffer

    緩衝區,本質是一塊可讀寫數據的內存,這塊內存被包裝成 NIO 的 Buffer 對象,用來簡化數據的讀寫。Buffer 的三個重要屬性:position 表示下一次讀寫數據的位置,limit 表示本次讀寫的極限位置,capacity 表示最大容量。

    • flip() 將寫轉爲讀,底層實現原理是把 position 置 0,並把 limit 設爲當前的 position 值。
    • 通過 clear() 將讀轉爲寫模式(用於讀完全部數據的情況,把 position 置 0,limit 設爲 capacity)。
    • 通過 compact() 將讀轉爲寫模式(用於沒有讀完全部數據,存在未讀數據的情況,讓 position 指向未讀數據的下一個)。
    • 通道的方向和 Buffer 的方向是相反的,讀取數據相當於向 Buffer 寫入,寫出數據相當於從 Buffer 讀取。

    **使用步驟:**向 Buffer 寫入數據,調用 flip 方法將 Buffer 從寫模式切換爲讀模式,從 Buffer 中讀取數據,調用 clear 或 compact 方法來清空 Buffer。

**適應場景:**連接數目多、連接時間短、開發難度高。


AIO:

異步非阻塞 IO,服務器實現模式爲一個有效請求對應一個線程,客戶端的 I/O 請求都是由操作系統先完成 IO 操作後再通知服務器應用來啓動線程直接使用數據。

異步是指服務端線程接收到客戶端管道後就交給底層處理IO通信,自己可以做其他事情,非阻塞是指客戶端有數據纔會處理,處理好再通知服務器。

AsynchronousServerSocketChannel 異步服務器端通道,通過靜態方法 open() 獲取實例,通過 accept 方法獲取客戶端連接通道。

AsynchronousSocketChannel 異步客戶端通道,通過靜態方法 open() 獲取實例,過 connect 方法連接服務器通道。

AsynchronousChannelGroup 異步通道分組管理器,它可以實現資源共享。創建時需要傳入一個ExecutorService,也就是綁定一個線程池,該線程池負責兩個任務:處理 IO 事件和觸發 CompletionHandler 回調接口。

實現方式:

通過 Future 的 get 方法進行阻塞式調用。

通過實現 CompletionHandler 接口,重寫請求成功的回調方法 completed() 和 請求失敗回調方法 failed()。

**適用場景:**連接數目多、連接時間長、開發難度高。


P18:List

List 是一種線性列表結構,元素是有序、可重複的。

**ArrayList **

底層由數組實現,隨機訪問效率高,讀快寫慢,由於寫操作涉及元素的移動,因此寫操作效率低。

ArrayList 實現了 RandomAcess 標記接口,如果一個類實現了該接口,那麼表示這個類使用索引遍歷比迭代器更快。

三個重要的成員變量:

transient Object[] elementData;

elementData 是 ArrayList 的數據域,transient 表示它不會被序列化,不使用 elementData 直接序列化是因爲這是一個緩存數組,出於性能考慮通常會預留一些容量,當容量不足時會擴充容量,因此可能會有大量空間沒有存儲元素,採用這樣的方式可以保證只序列化實際有值的那些元素而不需要序列化整個數組。

private int size;

size 表示當前 List 的實際大小,elementData 的大小是大於等於 size 的。

protected transient int modCount = 0;

該成員變量繼承自 AbstractList,記錄了ArrayList 結構性變化的次數。所有涉及結構變化的方法都會增加該值,包括add()、remove()、addAll()、removeRange() 及clear() 等。

在使用迭代器遍歷 ArrayList 時不能修改元素,modCount 統計 ArrayList 修改次數,expectedModCount 則是在迭代器初始化時記錄的modCount 值,每次訪問新元素時都會檢查 modCount 和 expectedModCount是否相等,如果不相等就會拋出異常。

LinkedList

底層由鏈表實現,與 ArrayList 相反,需要順序訪問元素,即使有索引也需要從頭遍歷,因此寫快讀慢。

LinkedList 實現了 Deque 接口,具有隊列的屬性,可在尾部增加元素,在頭部獲取元素,也能操作頭尾之間任意元素。

所有成員變量都被 transient 修飾,序列化原理和ArrayList類似。

Vector 和 Stack

Vector 的實現和 ArrayList 基本一致,底層使用的也是數組,它和 ArrayList 的區別主要在於:(1)Vector 的所有公有方法都使用了 synchronized 修飾保證線程安全性。(2)增長策略不同,Vector 多了一個成員變量 capacityIncrement 用於標明擴容的增量。

Stack 是 Vector 的子類,實現和 Vector基本一致,與之相比多提供了一些方法表達棧的含義。


P19:HashSet

HashSet 中的元素是無序、不重複的,最多隻能有一個 null 值。

HashSet 的底層是通過 HashMap 實現的,HashMap 的 key 值即 HashSet 存儲的元素,所有 key 都使用相同的 value ,一個static final 修飾的變量名爲 PRESENT 的 Object 類型的對象。

由於 HashSet 的底層是 HashMap 實現的,HashMap 是線程不安全的,因此 HashSet 也是線程不安全的。

去重:
對於基本類型的包裝類,直接按值進行比較。對於引用數據類型,會先比較 hashCode() 返回值是否相同,如果不同則代表不是同一個對象,如果相同則繼續比較equals()方法返回值是否相同,都相同說明是同一個對象。


P20:HashMap

JDK 8 之前

底層實現是數組 + 鏈表,主要成員變量包括:存儲數據的 table 數組、鍵值對數量 size、加載因子 loadFactor。

table 數組用於記錄 HashMap 的所有數據,它的每一個下標都對應一條鏈表,所有哈希衝突的數據都會被存放到同一條鏈表中,Entry 是鏈表的節點元素,包含四個成員變量:鍵 key、值 value、指向下一個節點的指針 next 和 元素的散列值 hash。

在 HashMap 中數據都是以鍵值對的形式存在的,鍵對應的 hash 值將會作爲其在數組裏的下標,如果兩個元素 key 的 hash 值一樣,就會發送哈希衝突,被放到同一個下標中的鏈表上,爲了使 HashMap 的查詢效率儘可能高,應該使鍵的 hash 值儘可能分散。

HashMap 默認初始化容量爲 16,擴容容量必須是 2 的冪次方、最大容量爲 1<< 30 、默認加載因子爲 0.75。

1.put 方法:添加元素

① 如果 key 爲 null 值,直接存入 table[0]。② 如果 key 不爲 null 值,先計算 key 對應的散列值。③ 調用 indexFor 方法根據 key 的散列值和數組的長度計算元素存放的下標 i。④ 遍歷 table[i] 對應的鏈表,如果 key 已經存在,就更新其 value 值然後返回舊的 value 值。⑤ 如果 key 不存在,就將 modCount 的值加 1,使用 addEntry 方法增加一個節點,並返回 null 值。

2.hash 方法:計算元素 key 對應的散列值

① 處理 String 類型的數據時,直接調用對應方法來獲取最終的hash值。② 處理其他類型數據時,提供一個相對於 HashMap 實例唯一不變的隨機值 hashSeed 作爲計算的初始量。③ 執行異或和無符號右移操作使 hash 值更加離散,減小哈希衝突的概率。

3.indexFor 方法:計算元素下標

直接將 hash 值和數組長度 - 1 進行與操作並返回,保證計算後的結果不會超過 table 數組的長度範圍。

4.resize 方法:根據newCapacity 來確定新的擴容閾值 threshold

① 如果當前容量已經達到了最大容量,就將閾值設置爲 Integer 的最大值,之後擴容就不會再觸發。② 創建一個新的容量爲 newCapacity 的 Entry 數組,並調用 transfer 方法將舊數組的元素轉移到新數組.③ 將閾值設爲(newCapacity 和加載因子 loadFactor 的積)和(最大容量 + 1 )的較小值。

5.transfer:轉移舊數組到新數組

① 遍歷舊數組的所有元素,調用 rehash 方法判斷是否需要哈希重構,如果需要就重新計算元素 key 的散列值。② 調用 indexFor 方法根據 key 的散列值和數組的長度計算元素存放的下標 i,利用頭插法將舊數組的元素轉移到新的數組。

6.get 方法:根據 key 獲取元素的 value 值

① 如果 key 爲 null 值,調用 getForNullKey 方法,如果 size 爲 0 表示鏈表爲空,返回 null 值。如果 size 不爲 0,說明存在鏈表,遍歷 table[0] 的鏈表,如果找到了 key 爲 null 的節點則返回其 value 值,否則返回 null 值。② 調用 getEntry 方法,如果 size 爲 0 表示鏈表爲空,返回 null 值。如果 size 不爲 0,首先計算 key 的散列值,然後遍歷該鏈表的所有節點,如果節點的 key 值和 hash 值都和要查找的元素相同則返回其 Entry 節點。③ 如果找到了對應的 Entry 節點,使用 getValue 方法獲取其 value 值並返回,否則返回 null 值。


**JDK 8 開始 **

使用的是數組 + 鏈表/紅黑樹的形式,table 數組的元素數據類型換成了 Entry 的靜態實現類 Node。

1.put 方法:添加元素

① 調用 putVal 方法添加元素。② 如果 table 爲空或沒有元素時就進行擴容,否則計算元素下標位置,如果不存在就新創建一個節點存入。③ 如果首節點和待插入元素的 hash值和 key 值都一樣,直接更新 value 值。④ 如果首節點是 TreeNode 類型,調用 putTreeVal 方法增加一個樹節點,每一次都比較插入節點和當前節點的大小,待插入節點小就往左子樹查找,否則往右子樹查找,找到空位後執行兩個方法:balanceInsert 方法,把節點插入紅黑樹並對紅黑樹進行調整使之平衡。moveRootToFront 方法,由於調整平衡後根節點可能變化,table 裏記錄的節點不再是根節點,需要重置根節點。⑤ 如果是鏈表節點,就遍歷鏈表,根據 hash 值和 key 值判斷是否重複,決定更新值還是新增節點。如果遍歷到了鏈表末尾,添加鏈表元素,如果達到了建樹閾值,還需要調用 treeifyBin 方法把鏈表重構爲紅黑樹。⑥ 存放元素後,將 modCount 值加 1,如果節點數 + 1大於擴容閾值,還需要進行擴容。

2.get 方法:根據 key 獲取元素的 value 值

① 調用 getNode 方法獲取 Node 節點,如果不是 null 值就返回 Node 節點的 value 值,否則返回 null。② 如果數組不爲空,先比較第一個節點和要查找元素的 hash 值和 key 值,如果都相同則直接返回。③ 如果第二個節點是 TreeNode 節點則調用 getTreeNode 方法進行查找,否則遍歷鏈表根據 hash 值和 key 值進行查找,如果沒有找到就返回 null。

3.hash 方法:計算元素 key 對應的散列值

Java 8 的計算過程簡單了許多,如果 key 非空就將 key 的 hashCode() 返回值的高低16位進行異或操作,這主要是爲了讓儘可能多的位參與運算,讓結果中的 0 和 1 分佈得更加均勻,從而降低哈希衝突的概率。

4.resize 方法:擴容數組

重新規劃長度和閾值,如果長度發生了變化,部分數據節點也要重新排列。

重新規劃長度

① 如果 size 超出擴容閾值,把 table 容量增加爲之前的2倍。② 如果新的 table 容量小於默認的初始化容量16,那麼將 table 容量重置爲16。③ 如果新的 table 容量大於等於最大容量,那麼將閾值設爲 Integer 的最大值,並且 return 終止擴容,由於 size 不可能超過該值因此之後不會再發生擴容。

重新排列數據節點

① 如果節點爲 null 值則不進行處理。② 如果節點不爲 null 值且沒有next節點,那麼重新計算其散列值然後存入新的 table 數組中。③ 如果節點爲 TreeNode 節點,那麼調用 split 方法進行處理,該方法用於對紅黑樹調整,如果太小會退化回鏈表。④ 如果節點是鏈表節點,需要將鏈表拆分爲 hashCode() 返回值超出舊容量的鏈表和未超出容量的鏈表。對於hash & oldCap == 0 的部分不需要做處理,反之需要放到新的下標位置上,新下標 = 舊下標 + 舊容量。

**線程不安全:**Java 7 擴容時 resize 方法調用的 transfer 方法中使用頭插法遷移元素,多線程會導致 Entry 鏈表形成環形數據結構,Entry 節點的 next 永遠不爲空,引起死循環。Java 8 在 resize 方法中完成擴容,並且改用了尾插法,不會產生死循環的問題,但是在多線程的情況下還是可能會導致數據覆蓋的問題,因此依舊線程不安全。


**紅黑樹:**紅黑樹是一種自平衡的二叉查找樹。

**特性:**紅黑樹的每個節點只能是紅色或者黑色、根節點是黑色的、每個葉子節點都是黑色的、如果一個葉子節點是紅色的,它的子結點必須是黑色的、從一個節點到該節點的葉子節點的所有路徑都包含相同數目的黑色節點。

**左旋:**對節點進行左旋,相當於把節點的右節點作爲其父節點,即將節點變成一個左節點。

**右旋:**對節點進行右旋,相當於把節點的左節點作爲其父節點,即將節點變成一個右節點。

**插入:**① 被插入的節點是根節點,直接將其塗爲黑色。② 被插入節點的父節點是黑色的,不做處理,節點插入後仍是紅黑樹。③ 被插入節點的父節點是紅色的,一定存在非空祖父節點,根據叔叔節點的顏色分類處理。

**刪除:**① 被刪除的節點沒有子節點,直接將其刪除。② 被刪除節點只有一個子節點,直接刪除該節點,並用其唯一子節點替換其位置。③ 被插入節點有兩個子節點,先找出該節點的替換節點,然後把替換節點的數值複製給該節點,刪除替換節點。

**調整平衡:**在插入和刪除節點後,通過左旋、右旋或變色使其重新成爲紅黑樹。① 如果當前節點的子節點是一紅一黑,直接將該節點設爲黑色。② 如果當前節點的子結點都是黑色,且當前節點是根節點,則不做處理。③ 如果當前節點的子節點都是黑色且當前節點不是根節點,根據兄弟節點的顏色分類處理。


JVM 15

P1:運行時數據區

程序計數器

程序計數器是一塊較小的內存空間,可以看作當前線程所執行字節碼的行號指示器。字節碼解釋器工作時通過改變這個計數器的值來選取下一條需要執行的字節碼指令,它是程序控制流的指示器,分支、循環、跳轉、線程恢復等功能都需要依賴計數器完成。程序計數器是線程私有的,各條線程之間互不影響,獨立存儲。

如果線程正在執行的是一個 Java 方法,計數器記錄的是正在執行的虛擬機字節碼指令的地址。如果正在執行的是本地(Native)方法,計數器值則應爲空(Undefined)。

此內存區域是唯一一個在《 Java 虛擬機規範》中沒有規定任何內存溢出情況的區域。

Java 虛擬機棧

Java 虛擬機棧是線程私有的,每當有新的線程創建時就會給它分配一個棧空間,當線程結束後棧空間就被回收,因此棧與線程擁有相同的生命週期。棧主要用來實現方法的調用與執行,每個方法在執行的時候都會創建一個棧幀用來存儲這個方法的局部變量、操作棧、動態鏈接和方法出口等信息。當一個方法被調用時,會壓入一個新的棧幀到這個線程的棧中,當方法調用結束後會彈出這個棧幀,回收掉調用這個方法使用的棧空間。

該區域有兩類異常情況:如果線程請求的棧深度大於虛擬機所允許的深度,將拋出 StackOverflowError 異常。如果 JVM 棧容量可以動態擴展,當棧擴展時無法申請到足夠的內存會拋出 OutOfMemoryError 異常(HotSpot 不可以動態擴展,不存在此問題)。

本地方法棧

本地方法棧與虛擬機棧的作用相似,不同的是虛擬機棧爲虛擬機執行 Java 方法(字節碼)服務,而本地方法棧是爲虛擬機棧用到的本地(Native)方法服務。調用本地方法時虛擬機棧保持不變,動態鏈接並直接調用指定的本地方法。

《 Java 虛擬機規範》對本地方法棧中方法所用語言、使用方式與數據結構無強制規定,具體的虛擬機可根據需要自由實現,例如 HotSpot 直接將虛擬機棧和本地方法棧合二爲一。

與虛擬機棧一樣,本地方法棧也會在棧深度異常和棧擴展失敗時分別拋出 StackOverflowError 和 OutOfMemoryError 異常。

Java 堆

Java 堆是虛擬機所管理的內存中最大的一塊。堆是被所有線程共享的一塊內存區域,在虛擬機啓動時創建。此區域的唯一目的就是存放對象實例,Java 裏幾乎所有的對象實例都在這裏分配內存。

Java 堆可以處於物理上不連續的內存空間中,但在邏輯上它應該被視爲連續的。但對於大對象(例如數組),多數虛擬機實現出於簡單、存儲高效的考慮會要求連續的內存空間。

Java 堆既可以被實現成固定大小的,也可以是可擴展的,不過當前主流的 JVM 都是按照可擴展來實現的。如果在堆中沒有內存完成實例分配,並且堆也無法再擴展時,虛擬機將拋出 OutOfMemoryError 異常。

方法區

方法區和 Java 堆一樣是各個線程共享的內存區域,它用於存儲被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯後的代碼緩存等數據。

JDK 8 之前使用永久代來實現方法區,這種設計導致了 Java 應用容易遇到內存溢出問題,因爲永久代有-XX:MaxPermSize的上限,即使不設置也有默認大小。JDK 6 時 HotSpot 的開發團隊就準備放棄永久代,改用本地內存來實現方法區,JDK 7 時已經把原本放在永久代的字符串常量池、靜態變量等移出,到了 JDK8 時永久代被完全廢棄,改用在本地內存中實現的元空間來代替,把 JDK 7 中永久代剩餘內容(主要是類型信息)全部移到元空間。

《 Java 虛擬機規範》對方法區的約束很寬鬆,除了和 Java 堆一樣不需要連續的內存和可以選擇固定大小或可擴展外,還可以選擇不實現垃圾回收。垃圾回收行爲在該區域出現較少,主要回收目標是針對常量池的回收和對類型的卸載,一般來說該區域的回收效果比較難令人滿意,尤其是類型的卸載,條件十分苛刻。如果方法區無法滿足新的內存分配需求時,將拋出 OutOfMemoryError 異常。

運行時常量池

運行時常量池是方法區的一部分,Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池表,用於存放編譯器生成的各種字面量與符號引用,這部分內容將在類加載後存放到方法區的運行時常量池中。一般來說,除了保存 Class 文件中描述的符號引用外,還會把符號引用翻譯出來的直接引用也存儲在運行時常量池中。

運行時常量池相對於 Class 文件常量池的另一個重要特徵是具備動態性,Java 語言並不要求常量一定只有編譯期才能產生,也就是說並非預置入 Class 文件中常量池的內容才能進入方法區運行時常量池,運行期間也可以將新的常量放入池中,這種特性被利用的較多的是String 類的 intern() 方法。

由於運行時常量池是方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 異常。

直接內存

直接內存不是 JVM 運行時數據區的一部分,也不是《 Java 虛擬機規範》中定義的內存區域,但是這部分內存也被頻繁使用,而且也可能導致內存溢出異常。

JDK 1.4 中新加入了 NIO 模型,引入了一種基於通道與緩衝區的 IO 方式,它可以使用 Native 函數庫直接分配堆外內存,然後通過一個存儲在 Java 堆裏的 DirectByteBuffer 對象作爲這塊內存的引用進行操作,這樣能在一些場景中顯著提高性能,避免了在 Java 堆和 Native堆中來回複製數據。

本機直接內存的分配不會收到 Java 堆大小的限制,但還是會受到本機總內存大小以及處理器尋址空間的限制,一般配置虛擬機參數時會根據實際內存去設置 -Xmx 等參數信息,但經常忽略掉直接內存,使得各個內存區域總和大於物理內存限制,從而導致動態擴展時出現OutOfMemoryError 異常。


P2:對象創建的過程

當 JVM 遇到一條字節碼 new 指令時,首先將檢查該指令的參數能否在常量池中定位到一個類的符號引用,並檢查這個引用代表的類是否已被加載、解析和初始化,如果沒有就必須先執行類加載過程。

在類加載檢查通過後虛擬機將爲新生對象分配內存。對象所需內存的大小在類加載完成後便可完全確定,分配空間的任務實際上等於把一塊確定大小的內存塊從 Java 堆中劃分出來。假設 Java 堆內存是絕對規整的,所有被使用過的內存都被放在一邊,空閒的內存被放在另一邊,中間放着一個指針作爲分界點指示器,分配內存就是把該指針向空閒方向挪動一段與對象大小相等的距離,這種方式叫"指針碰撞"。

如果 Java 堆中的內存不是規整的,那麼虛擬機就必須維護一個列表記錄哪些內存塊是可用的,在分配時從列表中找到一塊足夠大的空間劃分給對象實例並更新列表上的記錄,這種方式叫做"空閒列表"。

選擇哪種分配方式由堆是否規整決定,堆是否規整又由所用垃圾回收器是否帶有空間壓縮整理能力決定。因此使用 Serial、ParNew 等帶壓縮整理的收集器時,系統採用指針碰撞;當使用 CMS 這種基於清除算法的垃圾收集器時,理論上只能採用空間列表分配內存。

**分配內存的線程安全問題:**對象創建在虛擬機中十分頻繁,即使修改一個指針所指向的位置在併發情況下也不是線程安全的,可能出現正給對象 A 分配內存,指針還沒來得及修改,對象 B 又同時使用了原來的指針來分配內存的情況。解決該問題有兩個方法:① 虛擬機採用 CAS 加失敗重試的方式保證更新操作的原子性。② 把內存分配的動作按照線程劃分在不同空間進行,即每個線程在 Java 堆中預先分配一小塊內存,叫做本地線程分配緩衝 TLAB,哪個線程要分配內存就在對應的 TLAB 分配,只有 TLAB 用完了分配新緩衝區時才需要同步。

內存分配完成後虛擬機必須將分配到的內存空間(不包括對象頭)都初始化爲零值,保證對象的實例字段在 Java 代碼中可以不賦初始值就直接使用,使程序能訪問到這些字段的數據類型對應的零值。之後虛擬機還要對對象進行必要設置,例如對象是哪個類的實例、如何找到類的元數據信息等。

至此從虛擬機的視角來看一個新的對象已經產生了,但從程序的角度來說對象創建纔剛開始。此時構造方法,即 Class 文件中的 init 方法還沒有執行,所有字段都爲默認零值,對象需要的其他資源和狀態信息也還沒有按照預定的意圖構造好。一般來說 new 指令後會接着執行 init 方法,按照程序員的意願進行初始化,這樣一個真正可用的對象纔算完全被構造出來。


P3:對象的內存佈局

在 HotSpot 虛擬機中,對象在堆內存中的存儲佈局可分爲三個部分。

對象頭

對象頭包括兩類信息,第一類是用於存儲對象自身的運行時數據,如哈希碼、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID等,這部分數據叫做"Mark Word"。

對象頭的另一部分是類型指針,即對象指向它的類型元數據的指針,JVM 通過該指針來確定對象是哪個類的實例。並非所有虛擬機實現都必須在對象數據上保留類型指針,查找對象的元數據不一定要經過對象本身。此外如果對象是一個 Java 數組,在對象頭還必須有一塊用於記錄數組長度的數據。

實例數據

實例數據部分是對象真正存儲的有效信息,即程序員在代碼裏所定義的各種類型的字段內容。存儲順序會受到虛擬機分配策略參數和字段在源碼中定義順序的影響。相同寬度的字段總是被分配到一起存放,在滿足該前提條件的情況下父類中定義的變量會出現在子類之前。

對齊填充

這部分不是必然存在的,僅僅起佔位符的作用。由於 HotSpot 虛擬機的自動內存管理系統要求對象的起始地址必須是8字節的整數倍,而對象頭已經被設爲正好是 8 字節的整數倍,因此如果對象實例數據部分沒有對齊,就需要對齊填充來補全。


P4:對象的訪問定位

Java 程序會通過棧上的 reference 數據來操作堆上的具體對象,而具體對象訪問方式是由虛擬機決定的,主流的訪問方式主要有使用句柄和直接指針兩種。

使用句柄

如果使用句柄訪問,Java 堆中將可能會劃分出一塊內存作爲句柄池,reference 中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自具體的地址信息。

優點是 reference 中存儲的是穩定句柄地址,在對象被移動(處於垃圾收集過程中)時只會改變句柄中的實例數據指針,而 reference 本身不需要被修改。

直接指針

如果使用直接指針訪問的話,Java 堆中對象的內存佈局就必須考慮如何放置訪問類型數據的相關信息,reference中存儲的直接就是對象地址,如果只是訪問對象本身的話就不需要多一次間接訪問的開銷。

優點就是速度更快,節省了一次指針定位的時間開銷,HotSpot 主要使用的就是直接指針來進行對象訪問。


P5:內存溢出異常

Java 堆溢出

Java 堆用於存儲對象實例,我們只要不斷創建對象,並且保證GC Roots到對象有可達路徑來避免垃圾回收機制清除這些對象,那麼隨着對象數量的增加,總容量觸及最大堆容量的限制後就會產生OOM異常。例如在 while 死循環中一直 new 創建實例。

Java 堆內存的 OOM 是實際應用中最常見的 OOM 情況,常規的處理方法是先通過內存映像分析工具對 Dump 出來的堆轉儲快照進行分析,確認內存中導致 OOM 的對象是否是必要的,即分清楚到底是出現了內存泄漏還是內存溢出。

如果是內存泄漏,可進一步通過工具查看泄漏對象到 GC Roots 的引用鏈,找到泄露對象是通過怎樣的引用路徑、與哪些GC Roots相關聯才導致垃圾收集器無法回收它們,一般可以準確定位到對象創建的位置進而找出產生內存泄漏代碼的具體位置。

如果不是內存泄漏,即內存中的對象確實都是必須存活的那就應當檢查 JVM 的堆參數設置,與機器的內存相比是否還有向上調整的空間。再從代碼上檢查是否存在某些對象生命週期過長、持有狀態時間過長、存儲結構設計不合理等情況,儘量減少程序運行期的內存消耗。

虛擬機棧和本地方法棧溢出

由於HotSpot虛擬機不區分虛擬機和本地方法棧,因此設置本地方法棧大小的參數沒有意義,棧容量只能由 -Xss 參數來設定,存在兩種異常:

  • **StackOverflowError:**如果線程請求的棧深度大於虛擬機所允許的深度,將拋出StackOverflowError異常。例如一個遞歸方法不斷調用自己。

    該異常有明確錯誤堆棧可供分析,容易定位到問題所在。

  • **OutOfMemoryError:**如果 JVM 棧容量可以動態擴展,當棧擴展時無法申請到足夠的內存會拋出OutOfMemoryError異常。HotSpot 虛擬機不支持虛擬機棧的擴展,所以除非在創建線程申請內存時就因無法獲得足夠內存而出現OOM異常,否則在線程運行時是不會因爲擴展而導致內存溢出的,只會因爲棧容量無法容納新的棧幀而導致StackOverflowError異常。

運行時常量池溢出

String類的intern方法是一個本地方法,它的作用是如果字符串常量池中已經包含一個等於此 String 對象的字符串,則返回代表池中這個字符串的 String 對象的引用,否則會將此String對象包含的字符串添加到常量池中,並且返回此String對象的引用。

在 JDK6及之前常量池都分配在永久代,因此可以通過 -XX:PermSize-XX:MaxPermSize 限制永久代的大小,間接限制常量池的容量。在 while 死循環中不斷調用intern方法,之後將導致運行時常量池溢出。

在 JDK7 及之後版本不會導致該問題,因爲存放在永久代的字符串常量池已經被移至 Java 堆中。

方法區溢出

方法區的主要職責是用於存放類型的相關信息,如類名、訪問修飾符、常量池、字段描述、方法描述等。只要不斷在運行時產生大量的類去填滿方法區,就會導致溢出。例如使用 JDK 的反射或 CGLib 直接操作字節碼在運行時生成大量的類會導致溢出。當前的很多主流框架如Spring、Hibernate等對類增強是都會使用CGLib這類字節碼技術,增強的類越多,就需要越大的方法區保證動態生成的新類型可以載入內存,也就更容易導致方法區溢出。

JDK 8 之後永久代完全被廢棄,取而代之的是元空間,HotSpot 提供了一些參數作爲元空間的防禦措施:

-XX:MaxMetaspaceSize:設置元空間的最大值,默認 -1,表示不限制即只受限於本地內存大小。

-XX:MetaspaceSize:指定元空間的初始大小,以字節爲單位,達到該值就會觸發垃圾收集進行類型卸載,同時收集器會對該值進行調整:如果釋放了大量空間就適當降低該值,如果釋放了很少的空間就適當提高該值。

-XX:MinMetaspaceFreeRatio:作用是在垃圾收集之後控制最小的元空間剩餘容量百分比,可減少因爲元空間不足導致的垃圾收集的頻率。類似的還有-XX:MinMetaspaceFreeRatio,用於控制最大的元空間剩餘容量百分比。

本機直接內存溢出

直接內存的容量大小可通過 -XX:MaxDirectMemorySize 指定,如果不去指定則默認與 Java 堆的最大值一致。

由直接內存導致的內存溢出,一個明顯的特徵是在 Heap Dump 文件中不會看見有什麼明顯的異常情況,如果發現內存溢出後產生的Dump 文件很小,而程序中又直接或間接使用了直接內存(典型的間接使用就是 NIO),那麼就可以考慮檢查直接內存方面的原因。


P6:判斷對象是否是垃圾

在堆中存放着所有對象實例,垃圾收集器在對堆進行回收前,首先要判斷對象是否還存活着。

引用計數算法

在對象中添加一個引用計數器,如果有一個地方引用它計數器就加1,引用失效時計數器就減1,如果計數器爲0則該對象就是不再被使用的。該算法原理簡單,效率也高,但是在 Java中很少使用,因爲它存在對象之間互相循環引用的問題,導致計數器無法清零。

可達性分析算法

當前主流語言的內存管理子系統都是使用可達性分析算法來判斷對象是否存活的。這個算法的基本思路就是通過一系列稱爲 GC Roots 的根對象作爲起始節點集,從這些節點開始,根據引用關係向下搜索,搜索過程所走過的路徑稱爲引用鏈,如果某個對象到GC Roots之間沒有任何引用鏈相連,則此對象是不可能再被使用的。

可作爲GC Roots的對象:

  • 在虛擬機棧中引用的對象,如線程被調用的方法堆棧中的參數、局部變量等。
  • 在方法區中類靜態屬性引用的對象,如類的引用類型靜態變量。
  • 在方法區中常量引用的對象,如字符串常量池中的引用。
  • 在本地方法棧中 JNI 即 Native 方法引用的對象。
  • JVM 內部的引用,如基本數據類型對應的 Class 對象,一些常駐異常對象,系統類加載器等。
  • 所有被 synchronized 同步鎖持有的對象。

P7:引用類型

無論通過引用計數還是可達性分析判斷對象是否存活,都和引用離不開關係。在 JDK1.2 之前引用的定義是:如果 reference 類型數據存儲的數值代表另外一塊內存的起始地址,那麼就稱該 reference 數據是代表某塊內存、某個對象的引用。在 JDK 1.2之後 Java 對引用的概念進行了擴充,按強度分爲四種:

強引用:最傳統的引用定義,指代碼中普遍存在的引用賦值。任何情況下只要強引用存在,垃圾收集器就永遠不會回收被引用的對象。

軟引用:描述一些還有用但非必需的對象。只被軟引用關聯的對象,在系統將要發生內存溢出異常前,會把這些對象列進回收範圍中進行二次回收,如果這次回收還沒有足夠的內存纔會拋出 OOM 異常。

弱引用:描述非必需對象,引用強度比軟引用更弱,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器開始工作時無論當前內存是否足夠都會回收只被弱引用關聯的對象。

虛引用:也稱幽靈引用或幻影引用,是最弱的引用關係。一個對象是否有虛引用存在,完全不會對其生存時間造成影響,也無法通過虛引用來取得一個對象實例。該引用的唯一目的就是爲了能在這個對象被垃圾收集器回收時收到一個系統通知。


P8:GC 算法

標記-清除算法

  • **原理:**分爲標記和清除兩個階段,首先標記出所有需要回收的對象,在標記完成之後統一回收掉所有被標記的對象,或者標記存活的對象並統一回收所有未被標記的對象。標記過程就是判斷對象是否屬於垃圾的過程。

  • **特點:**① 執行效率不穩定,如果堆中包含大量對象且其中大部分是需要被回收的,這時必須進行大量標記和清除,導致效率隨對象數量增長而降低。② 內存空間碎片化問題,標記、清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致以後在程序運行中需要分配較大對象時無法找出足夠的連續內存而不得不提前觸發另一次垃圾收集。

標記-複製算法

  • **原理:**將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中一塊。當這一塊的空間用完了,就將還存活着的對象複製到另一塊,然後再把已使用過的內存空間一次清理掉。

  • **特點:**① 實現簡單、運行高效,解決了內存碎片問題。② 代價是將可用內存縮小爲原來的一半,浪費了過多空間。

  • HotSpot 的新生代劃分:

    把新生代劃分爲一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次分配內存只使用 Eden 和其中一塊 Survivor。發生垃圾收集時將 Eden 和 Survivor 中仍然存活的對象一次性複製到另一塊 Survivor 上,然後直接清理掉 Eden 和已用過的那塊 Survivor 空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8:1,即每次新生代中可用空間爲整個新生代的90%。

標記-整理算法

  • **原理:**標記-複製算法在對象存活率較高時要進行較多的複製操作,效率將會降低。並且如果不想浪費空間,就需要有額外空間進行分配擔保,應對被使用內存中所有對象都100%存活的極端情況,所以老年代一般不使用此算法。老年代使用標記-整理算法,標記過程與標記-清除算法一樣,只是後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向內存空間一端移動,然後直接清理掉邊界以外的內存。
  • **特點:**標記-清除與標記-整理的本質差異在於前者是一種非移動式回收算法而後者是移動式的。是否移動回收後的存活對象是一項優缺點並存的風險策略:① 如果移動存活對象,尤其是在老年代這種每次回收都有大量對象存活的區域,是一種極爲負重的操作,而且這種移動必須全程暫停用戶線程才能進行。② 如果不移動對象就會導致空間碎片問題,只能依賴更復雜的內存分配器和內存訪問器來解決。所以是否移動對象都存在弊端,移動則內存回收時更復雜,不移動則內存分配時更復雜。

P9:垃圾收集器

**經典垃圾收集器:**指 JDK 11之前的全部可用垃圾收集器。

Serial

最基礎、歷史最悠久的收集器,該收集器是一個使用複製算法的單線程工作收集器,單線程的意義不僅是說明它只會使用一個處理器或一條收集線程去完成垃圾收集工作,更重要的是強調它進行垃圾收集時必須暫停其他所有工作線程直到收集結束。

Serial 是虛擬機運行在客戶端模式下的默認新生代收集器,優點是簡單高效,對於內存受限的環境它是所有收集器中最小的;對於單核處理器或處理器核心較少的環境來說,Serial 收集器由於沒有線程交互開銷,因此可獲得最高的單線程收集效率。

ParNew

實質上是 Serial 的多線程版本,除了使用多線程進行垃圾收集外其餘行爲完全一致。

ParNew 是虛擬機運行在服務端模式下的默認新生代收集器,一個重要原因是除了 Serial 外只有它能與 CMS 配合。自從 JDK 9 開始,ParNew 加 CMS 收集器的組合就不再是官方推薦的服務端模式下的收集器解決方案了,官方希望他能被 G1 完全取代。

Parallel Scavenge

新生代收集器,基於標記-複製算法,是可以並行的多線程收集器,與 ParNew 類似。

特點是它的關注點與其他收集器不同,CMS 等收集器的關注點是儘可能縮短收集時用戶線程的停頓時間,而 Parallel Scavenge 的目標是達到一個可控制的吞吐量,吞吐量就是處理器用於運行用戶代碼的時間與處理器消耗總時間的比值。自適應調節策略也是它區別於 ParNew 的一個重要特性。

Serial Old

Serial 的老年代版本,同樣是一個單線程收集器,使用標記-整理算法。

Serial Old 是虛擬機在客戶端模式下的默認老年代收集器,用於服務端有兩種用途:一種是 JDK 5 及之前與 Parallel Scavenge 搭配使用,另一種是作爲CMS 發生失敗時的預案。

Parellel Old

Parallel Scavenge 的老年代版本,支持多線程收集,基於標記-整理算法實現。這個收集器直到 JDK 6 纔開始提供,在注重吞吐量優先的場景可以有效考慮Parallel Scavenge 加 Parallel Old 組合。

CMS

以獲取最短回收停頓時間爲目標的收集器,如果希望系統停頓時間儘可能短以給用戶帶來更好的體驗就可以使用 CMS。

基於標記-清除算法,過程相對複雜,分爲四個步驟:初始標記、併發標記、重新標記、併發清除。

其中初始標記和重新標記仍然需要 STW(Stop The World,表示系統停頓),初始標記僅是標記 GC Roots 能直接關聯到的對象,速度很快。併發標記就是從 GC Roots 的直接關聯對象開始遍歷整個對象圖的過程,耗時較長但不需要停頓用戶線程,可以與垃圾收集線程併發運行。重新標記則是爲了修正併發標記期間因用戶程序運作而導致標記產生變動的那一部分對象的標記記錄,該階段停頓時間比初始標記稍長,但遠比並發標記短。最後是併發清除,清理標記階段判斷的已死亡對象,由於不需要移動存活對象,因此該階段也可以與用戶線程併發。

由於整個過程中耗時最長的併發標記和併發清除階段中,垃圾收集器都可以和用戶線程一起工作,所以從總體上說CMS 的內存回收過程是與用戶線程併發執行的。

CMS 是 HotSpot 追求低停頓的第一次成功嘗試,但還存在三個明顯缺點:① 對處理器資源非常敏感,在併發階段雖然不會導致用戶線程暫停,但會降低總吞吐量。② 無法處理浮動垃圾,有可能出現併發失敗而導致另一次 FullGC。③ 由於基於標記-清除算法,因此會產生大量空間碎片,給大對象分配帶來麻煩。

G1

開創了收集器面向局部收集的設計思路和基於Region的內存佈局,是一款主要面向服務端的收集器,最初設計目標是替換CMS。

G1 之前的收集器,垃圾收集的目標要麼是整個新生代,要麼是整個老年代或整個堆。而 G1 可以面向堆內存任何部分來組成回收集進行回收,衡量標準不再是它屬於哪個分代,而是哪塊內存中存放的垃圾數量最多,回收受益最大,這就是 G1 的 MixedGC 模式。

不再堅持固定大小及數量的分代區域劃分,而是把 Java 堆劃分爲多個大小相等的獨立區域(Region),每一個 Region 都可以根據需要扮演新生代的 Eden 空間、Survivor 空間或老年代空間。收集器能夠對扮演不同角色的 Region 採用不同的策略處理,這樣無論是新創建的對象還是已經存活了一段時間、熬過多次收集的舊對象都能獲取很好的收集效果。

跟蹤各個 Region 裏面的垃圾堆積的價值大小,價值即回收所獲得的空間大小以及回收所需時間的經驗值,在後臺維護一個優先級列表,每次根據用戶設定允許的收集停頓時間優先處理回收價值收益最大的 Region。這種回收方式保證了 G1 在有限的時間內獲取儘可能高的收集效率。

G1的運作過程:

  • **初始標記:**標記 GC Roots 能直接關聯到的對象並修改 TAMS 指針的值,讓下一階段用戶線程併發運行時能正確地在可用 Region 中分配新對象。該階段需要 STW 但耗時很短,是借用進行 MinorGC 時同步完成的。
  • **併發標記:**從 GC Roots 開始對堆中對象進行可達性分析,遞歸掃描整個堆的對象圖,找出需要回收的對象。該階段耗時長,但可與用戶線程併發執行,當對掃描完成後還要重新處理 SATB 記錄的在併發時有引用變動的對象。
  • **最終標記:**對用戶線程做一個短暫暫停,用於處理併發階段結束後仍遺留下來的少量 SATB 記錄。
  • **篩選回收:**對各個 Region 的回收價值和成本排序,根據用戶期望的停頓時間指定回收計劃,可自由選擇任意多個 Region 構成回收集然後把決定回收的那一部分的存活對象複製到空的 Region 中,再清理掉整個舊的 Region 的全部空間。該操作必須暫停用戶線程,由多條收集器線程並行完成。

可以由用戶指定期望的停頓時間是 G1 的一個強大功能,但該值不能設得太低,一般設置爲100~300毫秒比較合適。G1不會存在內存空間碎片的問題,但 G1 爲了垃圾收集產生的內存佔用和程序運行時的額外執行負載都高於CMS。


**低延遲垃圾收集器:**指 Shenandoah 和 ZGC,這兩個收集器幾乎整個工作過程全都是併發的,只有初始標記、最終標記這些階段有短暫停頓,停頓的時間基本上是固定的。

Shenandoah

相比 G1 內存佈局同樣基於 Region,默認回收策略也是優先處理回收價值最大的 Region。但在管理堆內存方面,與 G1 有不同:① 支持併發整理,G1 的回收階段不能與用戶線程併發。②默認不使用分代收集,不會有專門的新生代 Region 或老年代 Region。③ 摒棄了在 G1 中耗費大量內存和計算資源去維護的記憶集,改用名爲連接矩陣的全局數據結構來記錄跨 Region 的引用關係。

ZGC

JDK11中新加入的具有實驗性質的低延遲垃圾收集器,和 Shenandoah 的目標高度相似,都希望在儘可能對吞吐量影響不大的前提下實現在任意堆大小下都可以把垃圾收集器的停頓時間限制在 10ms 以內的低延遲。

基於 Region 內存佈局,不設分代,使用了讀屏障、染色指針和內存多重映射等技術來實現可併發的標記-整理,以低延遲爲首要目標。內存佈局也採用基於 Region 的堆內存佈局,但不同的是 ZGC 的 Region 具有動態性,是動態創建和銷燬的,並且區容量大小也是動態變化的。


P10:內存分配與回收策略

以 Seial + Serial Old 客戶端默認收集器組合爲例:

對象優先在 Eden 區分配

大多數情況下對象在新生代 Eden 區分配,當 Eden 區沒有足夠空間進行分配時虛擬機將發起一次 MinorGC。

可通過 -XX:Xms-XX:Xmx 設置堆大小, -Xmn 設置新生代的大小, -XX:SurvivorRatio 設置新生代中 Eden 和 Survivor的比例。

大對象直接進入老年代

大對象是指需要大量連續內存空間的對象,最典型的是很長的字符串或者元素數量很龐大的數組。大對象容易導致內存明明還有不少空間時就提前觸發垃圾收集以獲得足夠的連續空間才能安置它們,當複製對象時大對象就意味着高額內存複製開銷。

HotSpot 提供了 -XX:PretenureSizeThreshold 參數,大於該值的對象直接在老年代分配,避免在 Eden 和 Survivor 之間來回複製產生大量內存複製操作。

長期存活對象進入老年代

虛擬機給每一個對象定義了一個對象年齡計數器,存儲在對象頭。對象通常在 Eden 誕生,如果經歷過第一次 MinorGC 仍然存活並且能被 Survivor 容納,該對象就會被移動到 Survivor 中並將年齡設置爲 1。對象在 Survivor 中每熬過一次 MinorGC 年齡就加 1 ,當增加到一定程度(默認15)就會被晉升到老年代。對象晉升老年代的年齡閾值可通過 -XX:MaxTenuringThreshold 設置。

動態對象年齡判定

爲了適應不同程序的內存狀況,虛擬機並不永遠要求對象年齡達到閾值才能晉升老年代,如果在 Survivor 中相同年齡所有對象大小的總和大於 Survivor 的一半,年齡不小於該年齡的對象就可以直接進入老年代,無需等到 -XX:MaxTenuringThreshold 參數設置的年齡。

空間分配擔保

發生 MinorGC 前,虛擬機必須先檢查老年代最大可用連續空間是否大於新生代所有對象總空間,如果這個條件成立,那這一次 MinorGC可以確定是安全的。

如果不成立,虛擬機會先查看 -XX:HandlePromotionFailure 參數的值是否允許擔保失敗,如果允許會繼續檢查老年代最大可用連續空間是否大於歷次晉升老年代對象的平均大小,如果滿足將冒險嘗試一次 MinorGC,如果不滿足或不允許擔保失敗就會改成一次 FullGC。

之所以說冒險是因爲新生代使用複製算法,爲了內存利用率只使用其中一個 Survivor 作爲備份,因此當出現大量對象在 MinorGC 後仍然存活的情況時需要老年代進行分配擔保,把 Survivor 無法容納的對象直接送入老年代。


P11:故障處理工具

jps:虛擬機進程狀況工具

jps 即 JVM Process Status,參考了 UNIX 命令的命名格式,功能和 ps 命令類似:可以列出正在運行的虛擬機進程,並顯示虛擬機執行主類名稱以及這些進程的本地虛擬機唯一 ID(LVMID)。LVMID 與操作系統的進程 ID(PID)是一致的,使用 Windows 的任務管理器或 UNIX 的 ps 命令也可以查詢到虛擬機進程的 LVMID,但如果同時啓動了多個虛擬機進程,無法根據進程名稱定位就必須依賴 jps 命令。

jps 還可以通過 RMI 協議查詢開啓了 RMI 服務的遠程虛擬機進程狀態,參數 hostid 爲 RMI 註冊表中註冊的主機名。

jstat:虛擬機統計信息監視工具

jstat 即 JVM Statistic Monitoring Tool,是用於監視虛擬機各種運行狀態信息的命令行工具。它可以顯示本地或者遠程虛擬機進程中的類加載、內存、垃圾收集、即時編譯器等運行時數據,在沒有 GUI 界面的服務器上是運行期定位虛擬機性能問題的常用工具。

一些參數的含義:S0 和 S1 表示兩個 Survivor 區,E 表示新生代,O 表示老年代,YGC 表示 Young GC 次數,YGCT 表示Young GC 耗時,FGC 表示 Full GC 次數,FGCT 表示 Full GC 耗時,GCT 表示所有 GC 總耗時。

jinfo:Java 配置信息工具

jinfo 表示 Configuration Info for Java,作用是實時查看和調整虛擬機各項參數,使用 jps 的 -v 參數可以查看虛擬機啓動時顯式指定的參數列表,但如果想知道未被顯式指定的參數的系統默認值就只能使用 jinfo 的 -flag 選項進行查詢。jinfo 還可以把虛擬機進程的 System.getProperties() 的內容打印出來。

jmap:Java 內存映像工具

jmap 表示 Memory Map for Java,jamp 命令用於生成堆轉儲快照,還可以查詢 finalize 執行隊列、Java 堆和方法區的詳細信息,如空間使用率,當前使用的是哪種收集器等。和 jinfo 命令一樣,有部分功能在 Windows 平臺下受限,除了生成堆轉儲快照的 -dump 選項和用於查看每個類實例的 -histo 選項外,其餘選項都只能在 Linux 使用。

jhat:虛擬機堆轉儲快照分析工具

jhat 表示 JVM Heap Analysis Tool,JDK 提供 jhat 命令與 jmap 搭配使用來分析 jamp 生成的堆轉儲快照。jhat 內置了一個微型的 HTTP/Web 服務器,生成堆轉儲快照的分析結果後可以在瀏覽器中查看。

jstack:Java 堆棧跟蹤工具

jstack 表示 Stack Trace for Java,jstack 命令用於生成虛擬機當前時刻的線程快照。線程快照就是當前虛擬機內每一條線程正在執行的方法堆棧的集合,生成線程快照的目的通常是定位線程出現長時間停頓的原因,如線程間死鎖、死循環、請求外部資源導致的長時間掛起等。線程出現停頓時通過 jstack 查看各個線程的調用堆棧,就可以獲知沒有響應的現場到底在後臺做什麼或等待什麼資源。

除了上述的基礎故障處理工具,還有一些可視化故障處理工具,例如 JHSDB 基於服務性代理的調試工具、JConsole Java 監視與管理控制檯、VisualVM 多合一故障處理工具、Java Mission Control 可持續在線監控工具。


P12:類加載機制和初始化時機

在 Class 文件中描述的各類信息最終都需要加載到虛擬機後才能運行和使用。JVM 把描述類的數據從 Class 文件加載到內存,並對數據進行校驗、解析和初始化,最終形成可以被虛擬機直接使用的 Java類型,這個過程被稱爲虛擬機的類加載機制。與其他在編譯時需要連接的語言不同,Java 中類型的加載、連接和初始化都是在程序運行期間完成的,這種策略讓 Java 進行類加載時增加了性能開銷,但卻爲 Java 應用提供了極高的擴展性和靈活性,Java 可以動態擴展的語言特性就是依賴運行期動態加載和動態連接這個特點實現的。

一個類型從被加載到虛擬機內存開始,到卸載出內存爲止,整個生命週期將會經歷加載、驗證、準備、解析、初始化、使用和卸載七個階段,其中驗證、解析和初始化三個部分統稱爲連接。加載、驗證、準備、初始化和卸載這五個階段的順序是確定的,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是爲了支持 Java 語言的動態綁定特性。

關於何時需要開始類加載的第一個階段"加載",《 Java 虛擬機規範》沒有強制約束,但對於初始化嚴格規定了有且只有6種情況:

  • 遇到 new、getstatic、putstatic 或 invokestatic 這四條字節碼指令時,如果類型沒有初始化則需要先觸發初始化。典型場景有:① 使用new關鍵字實例化對象。② 讀取或設置一個類型的靜態字段。③ 調用一個類型的靜態方法。

  • 對類型進行反射調用時,如果類型沒有初始化則需要先觸發初始化。

  • 當初始化類時,如果其父類沒有初始化則需要先觸發父類的初始化。

  • 當虛擬機啓動時,用戶需要指定一個要執行的主類即包含 main 方法的類,虛擬機會先初始化該類。

  • 當使用 JDK 7 新加入的動態語言支持時,如果 MethodHandle 實例的解析結果爲指定類型的方法句柄且這個句柄對應的類沒有進行過初始化,則需要先觸發其初始化。

  • 當一個接口定義了默認方法時,如果該接口的實現類發生初始化,那接口要在其之前初始化。

除了這六種情況外其餘所有引用類型的方式都不會觸發初始化,稱爲被動引用。被動引用的實例:① 子類使用父類的靜態字段時,只有直接定義這個字段的父類會被初始化。② 通過數組定義使用類。③ 常量在編譯期會存入調用類的常量池,不會初始化定義常量的類。

接口的加載過程和類真正有所區別的是當初始化類時,如果其父類沒有初始化則需要先觸發其父類的初始化,但在一個接口初始化時並不要求其父接口全部完成了初始化,只有在真正使用到父接口時(如引用接口中定義的常量)纔會初始化。


P13:類加載過程

加載

加載是類加載的第一個階段,在該階段虛擬機需要完成三件事:① 通過一個類的全限定類名來獲取定義此類的二進制字節流。② 將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據區結構。③ 在內存中生成一個代表這個類的 Class 對象,作爲方法區這個類的各種數據的訪問入口。

加載結束後,虛擬機外部的二進制字節流就按照虛擬機所設定的格式存儲在方法區中了,方法區中的數據存儲格式完全由虛擬機自定義實現。類型數據安置在方法區之後,會在 Java 堆中實例化一個 Class 對象,這個對象將作爲程序員訪問方法區中類型數據的外部接口。加載與連接的部分動作是交叉進行的,加載尚未完成時連接可能已經開始。

驗證

驗證是連接的第一步,目的是確保 Class 文件的字節流中包含的信息符合約束要求,保證這些信息不會危害虛擬機的安全。Java 語言本身是安全的,但如果虛擬機不檢查輸入的字節流,對其完全信任的話,很可能因爲載入了有錯誤或有惡意企圖的字節碼流而導致整個系統受攻擊甚至崩潰。

驗證主要包含了四個階段:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。

驗證對於虛擬機的類加載機制來說是一個非常重要但非必需的階段,因爲驗證只有通過與否的區別,只要通過了驗證其後就對程序運行期沒有任何影響了。如果程序運行的全部代碼都已被反覆使用和驗證過,在生產環境的就可以考慮關閉大部分類驗證措施縮短類加載時間。

準備

準備是正式爲類變量分配內存並設置零值的階段,該階段進行的內存分配僅包括類變量,而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在 Java 堆中。如果變量被final修飾,編譯時 Javac 會爲變量生成 ConstantValue 屬性,那麼在準備階段虛擬機就會將該變量的值設爲程序員指定的值。

解析

解析是將常量池內的符號引用替換爲直接引用的過程。

  • **符號引用:**符號引用以一組符號描述引用目標,可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。與虛擬機內存佈局無關,引用目標並不一定是已經加載到虛擬機內存中的內容。
  • **直接引用:**直接引用是可以直接指向目標的指針、相對偏移量或者能間接定位到目標的句柄。和虛擬機的內存佈局直接相關,引用目標必須已在虛擬機的內存中存在。

解析部分主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符這7類符合引用進行。

初始化

初始化是類加載過程的最後一步,直到該階段,JVM 才真正開始執行類中編寫的代碼。

準備階段時變量已經賦過一次系統零值,而在初始化階段會根據程序員的編碼去初始化類變量和其他資源。

初始化階段就是執行類構造器 <client> 方法的過程,該方法是 Javac 編譯器自動生成的。


P14:類加載器和雙親委派模型

類加載階段中"通過一個類的全限定名來獲取描述該類的二進制字節流"的動作被設計爲放到 JVM 外部實現,以便讓應用程序自己決定如何獲取所需的類,實現這個動作的代碼就是類加載器。

**比較兩個類是否相等:**對於任意一個類,都必須由加載它的類加載器和這個類本身一起共同確立其在虛擬機中的唯一性,每一個類加載器都擁有一個獨立的類名稱空間。只有在兩個類是由同一個類加載器加載的前提下才有意義,否則即使兩個類來源於同一個 Class 文件,被同一個 JVM 加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。

從 JVM 的角度看只存在兩種不同的類加載器:一種是啓動類加載器,由 C++ 語言實現,是虛擬機自身的一部分;另一種是其他所有類加載器,由 Java 語言實現,獨立存在於虛擬機外部且全部繼承自抽象類 java.lang.ClassLoader

自 JDK1.2 起 Java 一直保持着三層類加載器、雙親委派的類加載結構。

  • 啓動類加載器:負載加載存放在 JAVA_HOME/lib 目錄,或者指定路徑中存放的能夠被虛擬機識別的類庫加載到虛擬機內存中。啓動類加載器無法被 Java 程序直接引用,如果用戶需要把加載請求委派給啓動類加載器,直接使用 null 代替即可。
  • 擴展類加載器:負載加載 JAVA_HOME/lib/ext 目錄,或者系統變量所指定的路徑中的類庫。這種擴展機制在 JDK 9 後被模塊化所取代,由於擴展類加載器是由 Java 編寫的,開發者可以直接在程序中使用擴展類加載器來加載 Class 文件。
  • 應用程序類加載器:也稱系統類加載器,負載加載用戶類路徑上的所有類庫,同樣可以直接在代碼中使用。如果應用程序中沒有自定義類加載器,一般情況下該類加載器就是程序中默認的類加載器。

雙親委派模型

雙親委派模型要求除了頂層的啓動類加載器外,其餘的類加載器都應該有自己的父類加載器。不過這裏類加載器之間的父子關係一般不是以繼承關係來實現的,而通常使用組合關係來複用父加載器的代碼。

如果一個類加載器收到了類加載請求,它不會自己去嘗試加載這個類,而首先將該請求委派給自己的父加載器完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到最頂層的啓動類加載器中,只有當父加載器反饋自己無法完成請求時,子加載器纔會嘗試自己完成加載。

好處是 Java 中的類跟隨它的類加載器一起具備了一種帶有優先級的層次關係,可以保證某個類在程序的各個類加載器環境中都是同一個類,對於保證程序的穩定運行極爲重要。


P15:Java 程序運行的過程

通過 Javac 編譯器將 .java 代碼轉爲 JVM 可以加載的 .class 字節碼文件。

Javac 編譯器是由 Java 語言編寫的程序,從 Javac 代碼的總體結構看,編譯過程可以分爲 1 個準備過程和 3 個處理過程:① 準備過程:初始化插入式註解處理器。② 解析與填充符號表過程:進行詞法、語法分析,將源代碼的字符流轉爲標記集合,構造出抽象語法樹。填充符號表,產生符號地址和符號信息。③ 插入式註解處理器的註解處理過程。④ 分析與字節碼生成過程,包括標註檢查,對語法的靜態信息進行檢查;數據流及控制流分析,對程序動態運行過程進行檢查;解語法糖,將簡化代碼編寫的語法糖還原爲原有的形式;字節碼生成,將前面各個步驟的信息轉換爲字節碼。

Javac 屬於前端編譯器,完成了從程序到抽象語法樹或中間字節碼的生成,在此之後還有一組內置於 JVM 內部的後端編譯器,即即時編譯器或提前編譯器,來完成代碼優化以及從字節碼生成本地機器碼的過程。

通過即時編譯器 JIT 把字節碼文件編譯成本地機器碼。

Java 程序最初都是通過解釋器進行解釋執行的,當虛擬機發現某個方法或代碼塊的運行特別頻繁,就會把這些代碼認定爲"熱點代碼",熱點代碼的檢測主要有基於採樣和基於計數器兩種方式,爲了提高熱點代碼的執行效率,在運行時虛擬機會把這些代碼編譯成本地機器碼,並儘可能對代碼優化,在運行時完成這個任務的後端編譯器被稱爲即時編譯器。

客戶端編譯器的執行過程:① 平臺獨立的前端將字節碼構造成一種高級中間代碼表示 HIR。② 平臺相關的後端從 HIR 中產生低級中間代碼表示 LIR。③ 在平臺相關的後端使用線性掃描算法在 LIR 上分配寄存器,並在 LIR 上做窺孔優化,然後產生機器代碼。

服務端編譯器專門面向服務端的典型應用場景,併爲服務器的性能配置針對性調整過的編譯器,也是一個能容忍很高優化複雜度的高級編譯器,它會執行大部分經典的優化動作,如無用代碼消除、循環表達式外提、消除公共子表達式、基本塊重排序等,還會實施一些與 Java 語言特性相關的優化,如範圍檢查消除、空值檢查消除等,也可能根據解釋器或客戶端編譯器提供的性能監控信息進行一些不穩定的預測性激進優化。

還可以通過靜態的提前編譯器 AOT 直接把程序編譯成與目標機器指令集相關的二進制代碼。


併發 20

P1:Java 內存模型

Java 線程的通信由 JMM 控制,JMM 的主要目的是定義程序中各種變量的訪問規則,關注在虛擬機中把變量值存儲到內存和從內存中取出變量值這樣的底層細節。此處的變量包括實例字段、靜態字段和構成數組元素的對象,但不包括局部變量與方法參數,因爲它們是線程私有的,不存在多線程競爭問題。爲了獲得更好的執行效率,JMM 沒有限制執行引擎使用處理器的特定寄存器或緩存來和主內存進行交互,也沒有限制即時編譯器是否要進行調整代碼執行順序這類優化措施,JMM 遵循一個基本原則:只要不改變程序執行結果,編譯器和處理器怎麼優化都行。例如編譯器分析某個鎖只會單線程訪問就消除該鎖,某個 volatile 變量只會單線程訪問就把它當作普通變量。

JMM 規定了所有變量都存儲在主內存中,每條線程還有自己的工作內存,工作內存中保存了被該線程使用的變量的主內存副本,線程對變量的所有操作都必須在工作空間中進行,而不能直接讀寫主內存中的數據。不同線程之間也無法直接訪問對方工作內存中的變量,兩個線程之間的通信必須經過主內存,JMM 通過控制主內存與每個線程的工作內存之間的交互來提供內存可見性保證。

關於主內存與工作內存之間的交互,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存等實現細節,JMM 定義了 8 種原子操作:

  • **lock:**作用於主內存變量,把變量標識爲一條線程獨佔的狀態。
  • **unlock:**作用於主內存變量,把處於鎖定狀態的變量釋放出來,釋放後的變量才能被其他線程鎖定。
  • **read:**作用於主內存變量,把變量值從主內存傳到工作內存。
  • **load:**作用於工作內存變量,把 read 從主存中得到的值放入工作內存的變量副本。
  • **use:**作用於工作內存變量,把工作內存中的變量值傳給執行引擎,每當虛擬機遇到需要使用變量值的字節碼指令時執行該操作。
  • **assign:**作用於工作內存變量,把從執行引擎接收的值賦給工作內存變量,每當虛擬機遇到給變量賦值的字節碼指令時執行該操作。
  • **store:**作用於工作內存變量,把工作內存中的變量值傳送到主內存。
  • **write:**作用於主內存變量,把 store 從工作內存取到的變量值放入主內存變量中。

如果要把一個變量從主內存拷貝到工作內存,就要按順序執行 read 和 load ,如果要把變量從工作內存同步回主內存,就要按順序執行 store 和 write 。JMM 只要求這兩種操作必須按順序執行,但不要求連續,也就是說 read 和 load、store 和 write 之間可插入其他指令。這種定義十分嚴謹但過於複雜,之後 Java 將內存操作簡化爲 lock、unlock、read 和 write 四種,但這只是語言描述上的等價化簡。


P2:as-if-serial 和 happens-before 規則

as-if-serial

as-if-serial 的語義是:不管怎麼重排序,單線程程序的執行結果不能被改變,編譯器和處理器必須遵循 as-if-serial 語義。

爲了遵循 as-if-serial 語義,編譯器和處理器不會對存在數據依賴關係的操作重排序,因爲這種重排序會改變執行結果。但是如果操作之間不存在數據依賴關係,這些操作就可能被編譯器和處理器重排序。

as-if-serial 語義把單線程程序保護了起來,給了程序員一種幻覺:單線程程序是按程序的順序執行的,使程序員無需擔心重排序會干擾他們,也無需擔心內存可見性問題。

happens-before

先行發生原則,是 JMM 中定義的兩項操作之間的偏序關係,它是判斷數據是否存在競爭,線程是否安全的重要手段。

JMM 將 happens-before 要求禁止的重排序按是否會改變程序執行結果分爲兩類。對於會改變結果的重排序 JMM 要求編譯器和處理器必須禁止這種重排序,對於不會改變結果的重排序,JMM 對編譯器和處理器不做要求。

JMM 存在一些天然的 happens-before 關係,無需任何同步器協助就已經存在。如果兩個操作的關係不在此列,並且無法從這些規則推導出來,它們就沒有順序性保障,虛擬機可以對它們隨意進行重排序。

  • **程序次序規則:**在一個線程內,按照控制流順序,書寫在前面的操作先行發生於書寫在後面的操作。
  • **管程鎖定規則:**一個 unlock 操作先行發生於後面對同一個鎖的 lock 操作。
  • **volatile 規則:**對一個 volatile 變量的寫操作先行發生於後面對這個變量的讀操作。
  • **線程啓動規則:**線程對象的 start 方法先行發生於此線程的每一個動作。
  • **線程終止規則:**線程中的所有操作都先行發生於對此線程的終止檢測。
  • **線程中斷規則:**對線程 interrupt 方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生。
  • **對象終結規則:**一個對象的初始化完成先行發生於它的 finalize 方法的開始。
  • **傳遞性:**如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,那麼操作 A 先行發生於操作 C 。

區別

as-if-serial 保證單線程程序的執行結果不被改變,happens-before 保證正確同步的多線程程序的執行結果不被改變。這兩種語義的目的都是爲了在不改變程序執行結果的前提下儘可能提高程序執行的並行度。


P3:指令重排序

重排序指從源代碼到指令序列的重排序,在執行程序時爲了提高性能,編譯器和處理器通常會對指令進行重排序,分爲三種:

  • 編譯器優化的重排序:編譯器在不改變單線程程序語義的前提下可以重排語句的執行順序。

  • 指令級並行的重排序:如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。

  • 內存系統的重排序:由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作操作看上去可能是亂序執行。

從 Java 源代碼到最終實際執行的指令序列,會分別經歷編譯器優化重排序、指令級並行重排序和內存系統重排序,這些重排序可能會導致多線程程序出現內存可見性問題。

對於編譯器,JMM 的編譯器重排序規則會禁止特定類型的編譯器重排序。對於處理器重排序,JMM 的處理器重排序規則會要求 Java 編譯器在生成指令序列時,插入特定類型的內存屏障指令,即一組用於實現對內存操作順序限制的處理器指令,通過內存屏障指令來禁止特定類型的處理器重排序。JMM 屬於語言級的內存模型,它確保在不同的編譯器和處理器平臺上,通過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。


P4:原子性、可見性和有序性

原子性

由 JMM 直接保證的原子性變量操作包括 read、load、assign、use、store 和 write,基本數據類型的訪問都是具備原子性的,例外就是 long 和 double 的非原子性協定,允許虛擬機將沒有被 volatile 修飾的 64 位數據的操作劃分爲兩次 32 位的操作。

如果應用場景需要更大範圍的原子性保證,JMM 還提供了 lock 和 unlock 操作滿足這種需求,儘管 JVM 沒有把這兩種操作直接開放給用戶使用,但是卻提供了更高層次的字節碼指令 monitorenter 和 monitorexit 來隱式地使用這兩個操作,這兩個字節碼指令反映到 Java 代碼中就是 synchronized 關鍵字。

可見性

可見性就是指當一個線程修改了共享變量的值時,其他線程能夠立即得知修改。JMM 通過在變量修改後將值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作爲傳遞媒介的方式實現可見性,無論是普通變量還是volatile變量都是如此,區別是 volatile 保證新值能立即同步到主內存以及每次使用前立即從主內存刷新,因此說 volatile 保證了多線程操作變量的可見性,而普通變量則不能保證。

除了 volatile 之外,還有兩個關鍵字能實現可見性,分別是 synchronized 和 final,同步塊的可見性是由"對一個變量執行unlock 前必須先把此變量同步回主內存中,即先執行 store 和 write"這條規則獲得的。final 的可見性是指:被 final 修飾的字段在構造器中一旦被初始化完成,並且構造器沒有把"this"引用傳遞出去,那麼其他線程就能看到 final 字段的值。

有序性

有序性可以總結爲:在本線程內觀察所有操作是有序的,在一個線程內觀察另一個線程,所有操作都是無序的。前半句是指"as-if-serial 語義",後半句是指"指令重排序"和"工作內存與主內存同步延遲"現象。

Java 提供了 volatile 和 synchronized 保證線程間操作的有序性,volatile 本身就包含了禁止指令重排序的語義,而 synchronized 則是由"一個變量在同一個時刻只允許一條線程對其進行lock操作"這條規則獲得的,該規則決定了持有同一個鎖的兩個同步塊只能串行進入。


P5:volatile 關鍵字

JVM 提供的最輕量級的同步機制,JMM 爲 volatile 定義了一些特殊的訪問規則,當一個變量被定義爲 volatile 後具備兩種特性:

  • 保證此變量對所有線程的可見性

    可見性是指當一條線程修改了這個變量的值,新值對於其他線程來說是立即可以得知的。而普通變量並不能做到這一點,普通變量的值在線程間傳遞時均需要通過主內存來完成。

    volatile 變量在各個線程的工作內存中不存在一致性問題,但 Java 的運算操作符並非原子操作,這導致 volatile 變量運算在併發下仍是不安全的。

  • 禁止指令重排序優化

    使用 volatile 變量進行寫操作,彙編指令操作是帶有 lock 前綴的,相當於一個內存屏障,後面的指令不能重排到內存屏障之前的位置。只有一個處理器時不需要使用內存屏障,但如果有兩個或更多的處理器訪問同一塊內存,且其中有一個在觀測另一個,就需要使用內存屏障來保證一致性了。

    使用 lock 前綴的指令在多核處理器中會引發兩件事:① 將當前處理器緩存行的數據寫回到系統內存。② 這個寫回內存的操作會使其他在CPU裏緩存了該內存地址的數據無效。

    這種操作相當於對緩存中的變量做了一次 store 和 write 操作,可以讓 volatile 變量的修改對其他處理器立即可見。


靜態變量 i 執行多線程 i++ 的不安全問題

通過反編譯會發現一個自增語句是由 4 條字節碼指令構成的,依次爲getstatic、iconst_1、iadd、putstatic,當getstatic把 i 的值取到操作棧頂時,volatile保證了 i 的值在此刻是正確的,但是在執行iconst_1、iadd這些指令時,其他線程可能已經改變了i的值,而操作棧頂的值就變成了過期的數據,所以 putstatic 指令執行後就可能把較小的 i 值同步回了主內存。

即使編譯出來只有一條字節碼指令也不能意味着這條指令就是一個原子操作,一條字節碼指令在解釋執行時,解釋器要運行很多行代碼才能實現它的語義。如果是編譯執行,一條字節碼指令也可能轉化成若干條本地機器碼指令。


適用場景

運算結果並不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值。

變量不需要與其他狀態變量共同參與不變約束。


volatile的內存語義

從內存語義角度來說,volatile的寫-讀與鎖的釋放-獲取具有相同的內存效果。

  • **寫內存語義:**當寫一個volatile變量時,JMM 會把該線程對應的本地內存中的共享變量值刷新到主內存。

  • **讀內存語義:**當讀一個volatile變量時,JMM 會把該線程對應的本地內存置爲無效,線程接下來將從主內存中讀取共享變量。


volatile指令重排序的特點

當第二個操作是volatile 寫時,不管第一個操作是什麼都不能重排序,確保寫之前的操作不會被編譯器重排序到寫之後。

當第一個操作是volatile 讀時,不管第二個操作是什麼都不能重排序,確保讀之後的操作不會被編譯器重排序到讀之前。

當第一個操作是volatile 寫,第二個操作是 volatile 讀時不能重排序。


JSR-133 增強 volatile 語義的原因

在舊的內存模型中,雖然不允許 volatile 變量之間重排序,但允許 volatile 變量與普通變量重排序,可能導致內存不可見問題。爲了提供一種比鎖更輕量級的線程通信機制,嚴格限制了編譯器和處理器對 volatile 變量與普通變量的重排序,確保 volatile 的寫-讀和鎖的釋放-獲取具有相同的內存語義。


P6:final 關鍵字

final 可以保證可見性,被 final 修飾的字段在構造器中一旦被初始化完成,並且構造器沒有把 this 的引用傳遞出去,那麼在其他線程中就能看見 final 字段的值。

JSR-133 增強 final語義的原因

在舊的 JMM 中,一個嚴重的缺陷就是線程可能看到 final 域的值會改變。比如一個線程看到一個 int 類型 final 域的值爲0,此時該值是還未初始化之前的零值,過一段時間之後該值被某線程初始化後這個線程再去讀這個 final 域的值會發現值變爲1。

爲了修復該漏洞,JSR-133 通過爲 final 域增加寫和讀重排序規則,提供初始化安全保證:只要對象是正確構造的(被構造對象的引用在構造方法中沒有逸出),那麼不需要使用同步就可以保證任意線程都能看到這個final域在構造方法中被初始化之後的值。

寫 final 域重排序規則

禁止把 final 域的寫重排序到構造方法之外,編譯器會在final域的寫之後,構造方法的 return之前,插入一個Store Store屏障。該規則可以確保在對象引用爲任意線程可見之前,對象的 final 域已經被正確初始化過了,而普通域不具有這個保障。

對於引用類型,增加了約束:在構造方法內對一個 final 引用的對象的成員域的寫入,與隨後在構造方法外把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。

讀 final 域重排序規則

在一個線程中,初次讀對象引用和初次讀該對象包含的final域,JMM 禁止處理器重排序這兩個操作。編譯器會在讀final域操作的前面插入一個Load Load 屏障,該規則可以確保在讀一個對象的 final 域之前,一定會先讀包含這個 final 域的對象的引用。


P7:synchronized 關鍵字

最基本的互斥同步手段就是 synchronized 關鍵字,它是一種塊結構的同步語法。synchronized 經過 Javac 編譯後,會在同步塊的前後分別形成 monitorenter 和 monitorexit 兩個字節碼指令,這兩個字節碼指令都需要一個引用類型的參數來指明要鎖定和解鎖的對象,對於同步普通方法,鎖是當前實例對象;對於靜態同步方法,鎖是當前類的Class對象;對於同步方法塊,鎖是 synchronized 括號裏的對象。

根據《 Java 虛擬機規範》的要求,在執行 monitorenter 指令時,首先要去嘗試獲取對象的鎖。如果這個對象沒有被鎖定,或者當前線程已經持有了那個對象的鎖,那麼就把鎖的計數器的值增加 1,而在執行 monitorexit 指令時會將鎖計數器的值減 1。一旦計數器的值爲 0,鎖隨即就被釋放了。如果獲取鎖對象失敗,那當前線程就應該被阻塞等待,直到請求鎖定的對象被持有它的線程釋放爲止。

被 synchronized 修飾的同步塊對一條線程來說是可重入的,並且同步塊在持有鎖的線程執行完畢並釋放鎖之前,會無條件地阻塞後面其他線程的進入。從執行成本的角度看,持有鎖是一個重量級的操作。在主流 JVM 實現中,Java 的線程是映射到操作系統的原生內核線程之上的,如果要阻塞或喚醒一條線程,則需要操作系統幫忙完成,這就不可避免陷入用戶態到核心態的轉換中,進行這些狀態轉換需要耗費很多的處理器時間。

每個 Java 對象都有一個關聯的 monitor 監視器,當這個對象由同步塊或同步方法調用時,執行方法的線程必須先獲取到對象的監視器才能進入同步塊或同步方法。例如有兩個線程 A 和 B 競爭鎖資源對應的 monitor,當線程 A 競爭到鎖時,會將 monitor 中的 owner 設置爲 A,把線程 B 阻塞並放到等待競爭資源的 ContentionList 隊列。ContentionList 中的部分線程會進入 EntryList,EntryList 中的線程會被指定爲 OnDeck 競爭候選者線程,如果獲得了鎖資源將進入 Owner 狀態,釋放鎖資源後進入 !Owner 狀態。被阻塞的線程會進入 WaitSet。

不公平的原因

所有收到鎖請求的線程首先自旋,如果通過自旋也沒有獲取鎖資源將被放入 ContentionList 隊列,該做法對於已經進入隊列的線程是不公平的。

爲了防止 ContentionList 尾部的元素被大量線程進行 CAS 訪問影響性能,Owner 線程會在釋放鎖時將 ContentionList 的部分線程移動到 EntryList 並指定某個線程爲 OnDeck 線程,Owner 並沒有將鎖直接傳遞給 OnDeck 線程而是把鎖競爭的權利交給它,該行爲叫做競爭切換,犧牲了公平性但提高了性能。


P8:鎖優化

JDK 6 對 synchronized 做了很多優化,引入了適應自旋、鎖消除、鎖粗化、偏向鎖和輕量級鎖等提高鎖的效率,鎖一共有 4 個狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨着競爭情況逐漸升級。鎖可以升級但不能降級,這種只能升級不能降級的鎖策略是爲了提高獲得鎖和釋放鎖的效率。

自旋鎖與自適應自旋

互斥同步對性能最大的影響是阻塞的實現,掛起線程和恢復線程的操作都需要轉入內核態中完成,這些操作給 JVM 的併發性能帶來了很大壓力。同時虛擬機開發團隊也注意到了在許多應用上,共享數據的鎖定狀態只會持續很短的一段時間,爲了這段時間去掛機和恢復線程並不值得。現在絕大多數的個人電腦和服務器都是多核心處理器系統,如果物理機器有一個以上的處理器或者處理器核心,能讓兩個或以上的線程同時並行執行,我們就可以讓後面請求鎖的那個線程稍等一會,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。爲了讓線程等待,我們只需讓線程執行一個忙循環,這項技術就是所謂的自旋鎖。

自旋鎖在 JDK 1.4中就已經引入,只不過默認是關閉的,在 JDK 6中就已經改爲默認開啓了。自旋等待不能代替阻塞,自旋等待本身雖然避免了線程切換的開銷,但它要佔用處理器時間,所以如果鎖被佔用的時間很短,自旋的效果就會非常好,反之只會白白消耗處理器資源。因此自旋的時間必須有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起線程。自旋次數的默認次數是 10 次。

在 JDK 6 中對自旋鎖的優化,引入了自適應自旋。自旋的時間不再是固定的了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定的。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也很有可能再次成功,進而允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過鎖,那在以後要獲取這個鎖時將有可能之間省略掉自旋過程,以避免浪費處理器資源。有了自適應自旋,隨着程序運行時間的增長以及性能監控信息的不斷完善,虛擬機對程序鎖的狀況預測就會越來越精準。

鎖消除

鎖消除是指虛擬機即時編譯器在運行時,對一些代碼要求同步,但是對被檢測到不可能存在共享數據競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的數據支持,如果判斷到一段代碼中,在堆上的所有數據都不會逃逸出去被其他線程訪問到,那就可以把它們當作棧上的數據對待,認爲它們是線程私有的,同步加鎖自然就無須再進行。

鎖粗化

原則上我們在編寫代碼時,總是推薦將同步塊的作用範圍限制得儘量小,只在共享數據得實際作用域中才進行同步,這樣是爲了使得需要同步的操作數量儘可能變少,即使存在鎖競爭,等待鎖得線程也能儘可能快拿到鎖。

大多數情況下這種原則是正確的,但是如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,甚至加鎖操作是出現在循環體之外的,那麼即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能消耗。

如果虛擬機探測到有一串零碎的操作都對同一個對象加鎖,將會把加鎖同步的範圍擴展(粗化)到整個操作序列的外部。

偏向鎖

它的目的是消除數據在無競爭情況下的同步原語,進一步提高程序的運行性能。如果說輕量級鎖是在無競爭的情況下使用 CAS 操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都去掉,連 CAS 操作都不去做了。

偏向鎖的意思就是這個鎖會偏向於第一個獲得它的線程,如果在接下來的執行過程中,該鎖一直沒有被其他線程獲取,則持有偏向鎖的線程將永遠不需要再進行同步。

當一個線程訪問同步代碼塊並獲取鎖時,會在對象頭和幀棧中的鎖記錄裏存儲鎖偏向的線程 ID,以後該線程再進入和退出同步代碼塊不需要進行 CAS 操作來加鎖和解鎖,只需要簡單地測試一下對象頭的"Mark Word"裏是否存儲着指向當前線程的偏向鎖。如果測試成功表示線程已經獲得了鎖,如果失敗則需要再測試一下"Mark Word"中偏向鎖的標識是否設置成了 1 即表示當前使用偏向鎖,如果設置了就嘗試使用 CAS 將對象頭的偏向鎖指向當前線程,否則使用 CAS 方式競爭鎖。

偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖。偏向鎖的撤銷需要等待全局安全點即此時沒有正在執行的字節碼,它會首先暫停擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否活着,如果線程不處於活動狀態則將對象頭設爲無鎖狀態。如果線程還活着,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的"Mark Word"要麼重新偏向於其他線程,要麼恢復到無鎖或者標記對象不適合作爲偏向鎖,最後喚醒暫停的線程。

輕量級鎖

輕量級是相對於操作系統互斥量來實現的傳統鎖而言的,因此傳統的鎖機制就被稱爲重量級鎖。輕量級鎖並不是用來代替重量級鎖的,它設計的初衷是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。

在代碼即將進入同步塊的時候,如果此同步對象沒有被鎖定,虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄的空間,用於存儲鎖對象目前的Mark Word的拷貝。然後虛擬機將使用 CAS 操作嘗試把對象的 Mark Word 更新爲指向鎖記錄的指針,如果這個更新操作成功了,即代表該線程擁有了這個對象的鎖,並且鎖標誌位將轉變爲"00",表示此對象處於輕量級鎖定狀態。

如果這個更新操作失敗了,那就意味着至少存在一條線程與當前線程競爭獲取該對象的鎖。虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是則說明當前線程以及擁有了這個對象的鎖,直接進入同步塊繼續執行,否則說明這個鎖對象已經被其他線程搶佔了。如果出現兩條以上的線程爭用同一個鎖的情況,那輕量級鎖就不再有效,必須要膨脹爲重量級鎖,鎖標誌的狀態變爲"10",此時Mark Word中存儲的就是指向重量級鎖的指針,後面等待鎖的線程也必須進入阻塞狀態。

解鎖操作也同樣是通過 CAS 操作來進行,如果對象的 Mark Word 仍然指向線程的鎖記錄,那就用 CAS 操作把對象當前的 Mark Word 和線程複製的 Mark Word 替換回來。假如能夠替換成功,那整個同步過程就順利完成了,如果替換失敗,則說明有其他線程嘗試過獲取該鎖,就要在釋放鎖的同時喚醒被掛起的線程。

偏向鎖、輕量級鎖和重量級鎖的區別

偏向鎖的優點是加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級的差距,缺點是如果線程間存在鎖競爭會帶來額外鎖撤銷的消耗,適用於只有一個線程訪問同步代碼塊的場景。

輕量級鎖的優點是競爭的線程不會阻塞,提高了程序的響應速度,缺點是如果線程始終得不到鎖會自旋消耗CPU,適用於追求響應時間和同步代碼塊執行非常快的場景。

重量級鎖的優點是線程競爭不使用自旋不會消耗CPU,缺點是線程會被阻塞,響應時間很慢,適應於追求吞吐量、同步代碼塊執行較慢的場景。


P9:Lock 接口

自 JDK 5 起 Java類庫提供了 juc 併發包,其中的 Lock 接口成爲了一種全新的互斥同步手段。基於Lock 接口,用戶能夠以非塊結構來實現互斥同步,從而擺脫了語言特性的束縛,改爲在類庫層面去實現同步。

重入鎖 ReentrantLock 是 Lock 接口最常見的一種實現,它與 synchronized 一樣是可重入的,在基本用法上也很相似,不過它增加了一些高級功能,主要包括以下三項:

  • **等待可中斷:**是指持有鎖的線程長期不釋放鎖時,正在等待的線程可以選擇放棄等待而處理其他事情。可中斷特性對處理執行時間非常長的同步塊很有幫助。
  • **公平鎖:**是指多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖,而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的線程都有機會獲得鎖。synchronized中的鎖是非公平的,ReentrantLock在默認情況下也是非公平的,但可以通過帶有布爾值的構造器要求使用公平鎖。不過一旦使用了公平鎖,將會導致性能急劇下降,明顯影響吞吐量。
  • **鎖綁定多個條件:**是指一個 ReentrantLock 對象可以同時綁定多個 Condition 對象。在 synchronized中,鎖對象的 wait 跟它的notify/notifyAll 方法配合可以實現一個隱含的條件,如果要和多於一個的條件關聯時就不得不額外添加一個鎖,而 ReentrantLock 可以多次調用 newCondition 方法。

一般優先考慮使用synchronized:① synchronized 是 Java 語法層面的同步,足夠清晰和簡單。② Lock 應該確保在 finally 中釋放鎖,否則一旦受同步保護的代碼塊中拋出異常,則有可能永遠不會釋放持有的鎖。這一點必須由程序員自己來保證,而使用 synchronized 可以由 JVM 來確保即使出現異常鎖也能被正常釋放。③ 儘管在 JDK 5 時ReentrantLock 的性能領先於 synchronized,但在 JDK 6 進行鎖優化之後,二者的性能基本能夠持平。從長遠來看 JVM 更容易針對synchronized進行優化,因爲 JVM 可以在線程和對象的元數據中記錄 synchronized 中鎖的相關信息,而使用Lock的話 JVM 很難得知具體哪些鎖對象是由特定線程持有的。

ReentrantLock 的可重入實現

以非公平鎖爲例,通過 nonfairTryAcquire 方法獲取鎖,該方法增加了再次獲取同步狀態的處理邏輯:通過判斷當前線程是否爲獲取鎖的線程來決定獲取操作是否成功,如果是獲取鎖的線程再次請求則將同步狀態值進行增加並返回 true,表示獲取同步狀態成功。

成功獲取鎖的線程再次獲取鎖,只是增加了同步狀態值,這就要求 ReentrantLock 在釋放同步狀態時減少同步狀態值。如果該鎖被獲取了 n 次,那麼前 n-1 次 tryRelease 方法必須都返回fasle,只有同步狀態完全釋放了才能返回 true,該方法將同步狀態是否爲 0 作爲最終釋放的條件,當同步狀態爲 0 時,將佔有線程設置爲null,並返回 true 表示釋放成功。

對於非公平鎖只要 CAS 設置同步狀態成功則表示當前線程獲取了鎖,而公平鎖則不同。公平鎖使用 tryAcquire 方法,該方法與nonfairTryAcquire 的唯一區別就是判斷條件中多了對同步隊列中當前節點是否有前驅節點的判斷,如果該方法返回 true 表示有線程比當前線程更早地請求獲取鎖,因此需要等待前驅線程獲取並釋放鎖之後才能繼續獲取鎖。、


P10:讀寫鎖

ReentrantLock 是排他鎖,在同一時刻只允許一個線程進行訪問,而讀寫鎖在同一時刻可以允許多個讀線程訪問,但是在寫線程訪問時,所有的讀線程和其他寫線程均被阻塞。讀寫鎖維護了一個讀鎖和一個寫鎖,通過分離讀寫鎖使併發性相比一般的排他鎖有了很大提升。

除了保證寫操作對讀操作的可見性以及併發性的提升之外,讀寫鎖能夠簡化讀寫交互場景的編程方式。只需要在讀操作時獲取讀鎖,寫操作時獲取寫鎖即可,當寫鎖被獲取時後續的讀寫操作都會被阻塞,寫鎖釋放之後所有操作繼續執行,編程方式相對於使用等待/通知機制的實現方式變得簡單。

讀寫鎖同樣依賴自定義同步器來實現同步功能,而讀寫狀態就是其同步器的同步狀態。讀寫鎖的自定義同步器需要在同步狀態即一個整形變量上維護多個讀線程和一個寫線程的狀態。如果在一個 int 型變量上維護多種狀態,就一定要按位切割使用這個變量,讀寫鎖將變量切分成了兩個部分,高 16 位表示讀,低 16 位表示寫。

寫鎖是一個支持重入的排他鎖,如果當前線程已經獲得了寫鎖則增加寫狀態,如果當前線程在獲取寫鎖時,讀鎖已經被獲取或者該線程不是已經獲得寫鎖的線程則當前線程進入等待狀態。寫鎖的釋放與 ReentrantLock 的釋放過程類似,每次釋放均減少寫狀態,當寫狀態爲 0時表示寫鎖已被釋放,從而等待的讀寫線程能夠繼續訪問讀寫鎖,同時前次寫線程的修改對後續讀寫線程可見。

讀鎖是一個支持重入的共享鎖,它能夠被多個線程同時獲取,在沒有其他寫線程訪問時,讀鎖總會被成功地獲取,而所做的只是線程安全地增加讀狀態。如果當前線程已經獲取了讀鎖,則增加讀狀態。如果當前線程在獲取讀鎖時,寫鎖已被其他線程獲取則進入等待狀態。讀鎖的每次釋放均會減少讀狀態,減少的值是(1<<16),讀鎖的每次釋放是線程安全的。

鎖降級指的是寫鎖降級成爲讀鎖,如果當前線程擁有寫鎖,然後將其釋放,最後再獲取讀鎖,這種分段完成的過程不能稱之爲鎖降級。鎖降級指的是把持住當前擁有的寫鎖,再獲取到讀鎖,隨後釋放先前擁有的寫鎖的過程。

鎖降級中讀鎖的獲取是必要的,主要是爲了保證數據的可見性,如果當前線程不獲取讀鎖而是直接釋放寫鎖,假設此刻另一個線程 A 獲取了寫鎖修改了數據,那麼當前線程是無法感知線程 A 的數據更新的。如果當前線程獲取讀鎖,即遵循鎖降級的步驟,線程 A 將會被阻塞,直到當前線程使用數據並釋放讀鎖之後,線程 A 才能獲取寫鎖進行數據更新。


P11:AQS 隊列同步器

隊列同步器是用來構建鎖或者其他同步組件的基礎框架,它使用了一個 int 類型的成員變量表示同步狀態,通過內置的 FIFO 隊列來完成資源獲取線程的排隊工作。

使用方式

同步器的主要使用方式是繼承,子類通過繼承同步器並實現它的抽象方法來管理同步狀態,在抽象方法的實現過程中免不了要對同步狀態進行更改,這時就需要使用同步器提供的3個方法 getState、setState 和 compareAndSetState 來進行操作,因爲它們能夠保證狀態的改變是安全的。子類推薦被定義爲自定義同步組件的靜態內部類,同步器自身沒有實現任何同步接口,它僅僅是定義了若干同步狀態獲取和釋放的方法來供自定義同步組件使用,同步器既可以支持獨佔式地獲取同步狀態,也可以支持共享式地獲取同步狀態,這樣就可以方便實現不同類型地同步組件。

和鎖的關係

同步器是實現鎖的關鍵,在鎖的實現中聚合同步器,利用同步器實現鎖的語義。鎖是面向使用者的,它定義了使用者與鎖交互的接口,隱藏了實現細節;同步器面向的是鎖的實現者,它簡化了鎖的實現方式,屏蔽了同步狀態管理、線程的排隊、等待與喚醒等底層操作。鎖和同步器很好地隔離了使用者和實現者所關注的領域。

同步隊列

AQS 中每當有新的線程請求資源時,該線程都會進入一個等待隊列,只有當持有鎖的線程釋放鎖資源後該線程才能持有資源。等待隊列通過雙向鏈表實現,線程會被封裝在鏈表的 Node 節點中,Node 的等待狀態包括:CANCELLED 表示線程已取消、SIGNAL 表示線程需要喚醒、CONDITION 表示線程正在等待、PROPAGATE 表示後繼節點會傳播喚醒操作,只會在共享模式下起作用。

兩種模式

獨佔模式表示鎖會被一個線程佔用,其他線程必須等到持有鎖的線程釋放鎖後才能獲取到鎖繼續執行,在同一時間內只能有一個線程獲取到這個鎖,ReentrantLock 就採用的是獨佔模式。

共享模式表示多個線程獲取同一個鎖的時候有可能會成功,ReadLock 就採用的是共享模式。

獨佔模式通過 acquire 和 release 方法獲取和釋放鎖,共享模式通過 acquireShared 和 releaseShared 方法獲取和釋放鎖。

獨佔式的獲取和釋放流程

在獲取同步狀態時,同步器調用 acquire 方法,維護一個同步隊列,使用 tryAcquire 方法安全地獲取線程同步狀態,獲取狀態失敗的線程會構造同步節點並通過 addWaiter 方法被加入到同步隊列的尾部,並在隊列中進行自旋。之後會調用 acquireQueued 方法使得該節點以死循環的方式獲取同步狀態,如果獲取不到則阻塞節點中的線程,而被阻塞線程的喚醒主要依靠前驅節點的出隊或阻塞節點被中斷實現,移出隊列或停止自旋的條件是前驅節點是頭結點並且成功獲取了同步狀態。

在釋放同步狀態時,同步器調用 tryRelease 方法釋放同步狀態,然後調用 unparkSuccessor 方法(該方法使用 LockSupport 喚醒處於等待狀態的線程)喚醒頭節點的後繼節點,進而使後繼節點重新嘗試獲取同步狀態。

只有當前驅節點是頭節點時才能夠嘗試獲取同步狀態原因

頭節點是成功獲取到同步狀態的節點,而頭節點的線程釋放同步狀態之後,將會喚醒其後繼節點,後繼節點的線程被喚醒後需要檢查自己的前驅節點是否是頭節點。

維護同步隊列的FIFO原則,節點和節點在循環檢查的過程中基本不相互通信,而是簡單地判斷自己的前驅是否爲頭節點,這樣就使得節點的釋放規則符合FIFO,並且也便於對過早通知的處理(過早通知是指前驅節點不是頭結點的線程由於中斷而被喚醒)。

共享式的獲取和釋放流程

在獲取同步狀態時,同步器調用 acquireShared 方法,該方法調用 tryAcquireShared 方法嘗試獲取同步狀態,返回值爲 int 類型,當返回值大於等於 0 時表示能夠獲取到同步狀態。因此在共享式獲取鎖的自旋過程中,成功獲取到同步狀態並退出自旋的條件就是該方法的返回值大於等於0。

釋放同步狀態時,調用 releaseShared 方法,釋放同步狀態後會喚醒後續處於等待狀態的節點。對於能夠支持多線程同時訪問的併發組件,它和獨佔式的主要區別在於 tryReleaseShared 方法必須確保同步狀態安全釋放,一般通過循環和 CAS 來保證,因爲釋放同步狀態的操作會同時來自多個線程。


P12:線程

現代操作系統在運行一個程序時會爲其創建一個進程,而操作系統調度的最小單位是線程,線程也叫輕量級進程。在一個進程中可以創建多個線程,這些線程都擁有各自的計數器、堆棧和局部變量等屬性,並且能夠訪問共享的內存變量。處理器在這些線程上告訴切換,讓使用者感覺到這些線程在同時執行。

生命週期

①NEW:初始狀態,創建後還沒有啓動的線程處於這種狀態,此時還沒有調用 start 方法。②RUNNABLE:Java 線程將操作系統中的就緒和運行兩種狀態統稱爲 RUNNABLE,此時線程有可能正在等待操作系統分配CPU時間片,也有可能正在執行③BLOCKED:阻塞狀態,阻塞狀態與等待狀態的區別是阻塞狀態在等待一個排它鎖,在程序等待進入同步區域時線程將進入這種狀態。④WAITING:等待狀態,表示線程進入等待狀態,處於該狀態的線程不會被分配CPU時間片,進入該狀態表示當前線程需要等待其他線程通知或中斷。會導致線程陷入等待狀態的方法:沒有設置超時參數的wait 和 join方法、LockSupport的 park 方法。⑤TIME_WAITING:限期等待狀態,該狀態不同於 WAITING,可以在指定時間內自行返回。會導致線程陷入限期等待狀態的方法:設置了超時參數的 wait 和 join 方法、LockSupport 的 parkNanos 和 parkUntil 方法。⑥TERMINATED:終止狀態,表示當前線程已經執行完畢或異常退出。

實現方式

①繼承 Thread 類並重寫 run方法。優點是實現簡單,缺點是不能繼承其他類,功能單一。②實現 Runnable 接口並重寫 run 方法,並將該實現類作爲參數傳入 Thread 構造器。優點是避免了單繼承的侷限性,適合多個相同程序代碼的線程共享一個資源,實現解耦操作,代碼和線程獨立。③實現 Callable 接口並重寫 call 方法,包裝成 FutureTask 對象並作爲參數傳入Thread構造器。優點是可以獲取線程執行結果的返回值。④可以通過線程池創建。

方法

① wait是Object類的方法,調用wait方法的線程會進入WAITING狀態,只有等待其他線程的通知或被中斷後纔會解除阻塞,調用wait方法會釋放鎖資源。② sleep 是 Thread 類的方法,調用 sleep 方法會導致當前線程進入休眠狀態,與 wait 不同的是該方法不會釋放鎖資源,進入的是 TIMED-WAITING 狀態。③ yiled 方法會使當前線程讓出 CPU 時間片給優先級相同或更高的線程,回到 RUNNABLE 狀態,與其他線程一起重新競爭CPU時間片。④ join 方法用於等待其他線程運行終止,如果當前線程調用了另一個線程的 join 方法,則當前線程進入阻塞狀態,當另一個線程結束時當前線程才能從阻塞狀態轉爲就緒態,等待獲取CPU時間片。底層使用的是wait,也會釋放鎖。

守護線程

守護線程是一種支持型線程,因爲它主要被用作程序中後臺調度以及支持性工作,當 JVM 中不存在非守護線程時,JVM 將會退出,可以通過 setDaemon(true) 將線程設置爲daemon線程,但必須在線程啓動之前設置。守護線程被用於完成支持性工作,但是在 JVM 退出時守護線程中的 finally 塊並不一定會被執行,因爲當 JVM 中沒有非守護線程時需要立即退出,所有守護線程都將立即終止,因此不能依靠 finally 確保執行關閉或清理資源的邏輯。


P13:線程間通信

通信是指線程之間以何種機制來交換信息,在命令式編程中線程之間的通信機制有兩種,共享內存和消息傳遞。在共享內存的併發模型裏線程之間共享程序的公共狀態,通過寫-讀內存中的公共狀態進行隱式通信。在消息傳遞的併發模型裏線程之間沒有公共狀態,線程之間必須通過發送消息來顯示通信。Java 併發採用共享內存模型,線程之間的通信總是隱式進行,整個通信過程對程序員完全透明。

volatile 和 synchronized 關鍵字

volatile 可以修飾字段,告知程序任何對該變量的訪問均需要從共享內存中獲取,而對它的改變必須同步刷新回主內存,它能保證所有線程對變量訪問的可見性。

synchronized 可以修飾方法或以同步塊的形式使用,它主要確保多個線程在同一個時刻只能有一個線程處於方法或同步塊中,保證了線程對變量訪問的可見性和排他性。

等待/通知機制

等待通知機制是指一個線程 A 調用了對象 O 的 wait 方法進入等待狀態,而另一個線程 B 調用了對象 O 的 notify 或 notifyAll 方法,線程 A 收到通知後從 wait 方法返回,進而執行後序操作。兩個線程通過對象 O 完成交互,對象上的 wait 和 notify/notifyAll 就如同開關信號,用來完成等待方和通知方之間的交互工作。

管道 IO 流

管道 IO 流和普通文件IO 流或網絡 IO 流的不同之處在於它主要用於線程之間的數據傳輸,傳輸的媒介爲內存。管道流主要包括了四種具體實現:PipedOutputStream、PipedInputStream、PipedReader 和 PipedWriter,對於 Piped 類型的流必須要通過 connect 方法進行綁定。

Thread.join

如果一個線程執行了某個線程的 join 方法,這個線程就會阻塞等待執行了 join 方法的線程終止之後才返回,這裏涉及了等待/通知機制。join 方法的底層是通過 wait 方法實現的,當線程終止時會調用自身的 notifyAll 方法,通知所有等待在該線程對象上的線程。

ThreadLocal

ThreadLoacl 是線程變量,內部以 ThreadLocal 爲鍵,任意對象爲值的存儲結構實現。該結構綁定在每個線程上,存儲的值在每個線程中都是一個唯一副本,每個線程可以通過 ThreadLocal 對象訪問自己唯一的值。

這種存儲結構叫 ThreadLocalMap ,是 ThreadLocal 的一個靜態內部類,是一個弱引用集合,它的存值、取值實現類似於 HashMap,使用 set 設置值,使用 get 獲取值。使用弱引用的目的是爲了節約資源,如果執行過程中發生了 GC,ThreadLocal 是 null 但由於 ThreadLocalMap 生命週期和線程一樣,不會被回收,這時候就會導致 ThreadLocalMap 的 key 不存在而 value 還在的內存泄漏問題,解決辦法是使用完 ThreadLocal 後執行remove操作。


P14:ConcurrentHashMap

JDK 8 之前

ConcurrentHashMap 用於解決 HashMap 的線程不安全和 HashTable 的併發效率低下問題,HashTable 之所以效率低下是因爲所有線程都必須競爭同一把鎖,假如容器裏有多把鎖,每一把鎖用於鎖容器一部分數據,那麼多線程訪問容器不同數據段的數據時線程間就不會存在鎖競爭,從而有效提高併發效率,這就是 ConcurrentHashMap 的鎖分段技術。首先將數據分成 Segment 數據段,然後給每一個數據段配一把鎖,當一個線程佔用鎖訪問其中一個段的數據時,其他段的數據也能被其他線程訪問。

get 操作實現簡單高效,先經過一次再散列,然後使用這個散列值通過散列運算定位到 Segment,再通過散列算法定位到元素。get 的高效在於這個過程不需要加鎖,除非讀到空值纔會加鎖重讀。get 方法裏將要使用的共享變量都定義爲 volatile 類型,volatile 保證了多線程的可見性,可以多線程讀,但是隻能保證單線程寫,在 get 操作裏只需要讀所以不用加鎖。

put 操作必須加鎖,put 方法首先定位到 Segment,然後進行插入操作,第一步判斷是否需要對 Segment 裏的 HashEntry 數組進行擴容,第二步定位添加元素的位置,然後將其放入數組。

size 操作用於統計元素的數量,必須統計每個 Segment 的大小然後求和,在統計結果累加的過程中,之前累加過的 count 變化的機率很小,因此 ConcurrentHashMap 的做法是先嚐試兩次通過不加鎖的方式統計結果,如果統計過程中容器大小發生了變化則再通過加鎖的方式統計所有 Segment 的大小。判斷容器是否發生變化是根據 modCount 變量確定的。

JDK 8 開始

JDK 8 的實現摒棄了 Segment 分段概念,使用 Synchronized 和 CAS 來保證線程安全。

get 操作同樣不需要同步控制,put 操作時如果沒有出現哈希衝突,就使用 CAS 方式來添加元素,如果出現了哈希衝突就使用 synchronized 加鎖的方式添加元素。


P15:CAS 操作

CAS 表示 Compare And Swap,比較並交換,CAS 需要三個操作數,分別是內存位置 V、舊的預期值 A 和準備設置的新值 B。CAS 指令執行時,當且僅當 V 符合 A 時,處理器纔會用 B 更新 V 的值,否則它就不執行更新。但不管是否更新都會返回 V 的舊值,這些處理過程是原子操作,執行期間不會被其他線程打斷。

在 JDK 5 後,Java 類庫中才開始使用 CAS 操作,該操作由 Unsafe 類裏的 compareAndSwapInt 等幾個方法包裝提供。HotSpot 在內部對這些方法做了特殊處理,即時編譯的結果是一條平臺相關的處理器 CAS 指令。Unsafe 類不是給用戶程序調用的類,因此在 JDK 9 之前只有 Java 類庫可以使用 CAS,譬如 juc 包裏的 AtomicInteger類中 compareAndSet 等方法都使用了Unsafe 類的 CAS 操作來實現。

儘管 CAS 既簡單又高效,但這種操作無法涵蓋互斥同步的所有場景,並且 CAS 從語義上來說存在一個邏輯漏洞:如果 V 初次讀取的時候是 A,並且在準備賦值的時候檢查到它的值仍爲 A,這依舊不能說明它的值沒有被其他線程更改過,因爲這段時間內假設它的值先改爲了 B 又改回 A,那麼 CAS 操作就會誤認爲它從來沒有被改變過。這個漏洞稱爲 ABA 問題,juc 包提供了一個 AtomicStampedReference,原子更新帶有版本號的引用類型,它可以通過控制變量值的版本來解決 ABA 問題。這個類並不常用,大部分情況下 ABA 問題不會影響程序併發的正確性,如果需要解決該問題,改用傳統的互斥同步可能會比原子類更高效。


P16:原子操作類

Java 從 JDK 5 開始提供了 java.util.concurrent.atomic 包,這個包中的原子操作類提供了一種用法簡單、性能高效、線程安全地更新一個變量的方式。到 JDK 8 該包共有17個類,依據作用分爲四種:原子更新基本類型類、原子更新數組類、原子更新引用類以及原子更新字段類,atomic 包裏的類基本都是使用 Unsafe 實現的包裝類。

原子更新基本類型

AtomicInteger 原子更新整形、 AtomicLong 原子更新長整型、AtomicBoolean 原子更新布爾類型。

getAndIncrement 以原子方式將當前的值加 1,首先在 for 死循環中取得 AtomicInteger 裏存儲的數值,第二步對 AtomicInteger 當前的值進行加 1 操作,第三步調用 compareAndSet 方法進行原子更新,該操作先檢查當前數值是否等於 expect,如果等於則說明當前值沒有被其他線程修改,則將值更新爲 next,否則會更新失敗返回 false,程序會進入 for 循環重新進行 compareAndSet 操作。

atomic 包中只提供了 三種基本類型的原子更新,atomic 包裏的類基本都是使用 Unsafe 實現的,Unsafe 只提供三種 CAS 方法:compareAndSwapInt、compareAndSwapLong 和 compareAndSwapObject,例如原子更新 Boolean 時是先轉成整形再使用 compareAndSwapInt 進行 CAS,所以原子更新 char、float、double 也可以用類似思路實現。

原子更新數組

AtomicIntegerArray,原子更新整形數組裏的元素、 AtomicLongArray 原子更新長整型數組裏的元素、 AtomicReferenceArray 原子更新引用類型數組裏的元素。

原子更新引用

AtomicReference 原子更新引用類型、AtomicMarkableReference 原子更新帶有標記位的引用類型,可以綁定一個 boolean 類型的標記位、 AtomicStampedReference 原子更新帶有版本號的引用類型,關聯一個整數值用於原子更新數據和數據的版本號,可以解決 ABA 問題。

原子更新字段

AtomicIntegerFieldUpdater 原子更新整形字段的更新器、 AtomicLongFieldUpdater 原子更新長整形字段的更新器AtomicReferenceFieldUpdater 原子更新引用類型字段的更新器。

由於原子更新字段類都是抽象類,每次使用的時候必須使用靜態方法 newUpdater 創建一個更新器,並且需要設置想要更新的類和字段。並且更新類的字段必須使用 public volatile 修飾。

JDK 8 更新的類

DoubleAccumulator 、 LongAccumulator、DoubleAdder、LongAdder、Striped64。


P17:併發工具類

等待多線程完成的 CountDownLatch

CountDownLatch 允許一個或多個線程等待其他線程完成操作,構造器接收一個 int 類型的參數作爲計數器,如果要等待 n 個點就傳入 n。每次調用 countDown 方法時計數器減 1,await 方法會阻塞當前線程直到計數器變爲0,由於 countDown方法可用在任何地方,所以 n 個點既可以是 n 個線程也可以是一個線程裏的 n 個執行步驟。

同步屏障 CyclicBarrier

同步屏障的作用是讓一組線程到達一個屏障或同步點時被阻塞,直到最後一個線程到達屏障時,屏障纔會解除,所有被攔截的線程纔會繼續運行。構造器中的參數表示屏障攔截的線程數量,每個線程調用 await 方法告訴 CyclicBarrier 自己已到達屏障,然後當前線程被阻塞。還支持在構造器中傳入一個 Runable 類型的任務,當線程到達屏障時會優先執行該任務。適用於多線程計算數據,最後合併計算結果的應用場景。

CountDownLacth 的計數器只能用一次,而 CyclicBarrier 的計數器可使用 reset 方法重置,所以 CyclicBarrier 能處理更爲複雜的業務場景,例如計算錯誤時可用重置計數器重新計算。

控制併發線程數的 Semaphore

信號量用來控制同時訪問特定資源的線程數量,它通過協調各個線程以保證合理使用公共資源。信號量可以用於流量控制,特別是公共資源有限的應用場景,比如數據庫連接。Semaphore 的構造器參數接收一個 int 值,表示可用的許可數量即最大併發數。使用acquire 方法獲得一個許可證,使用 release 方法歸還許可,還可以用 tryAcquire 嘗試獲得許可。

線程間交換數據的 Exchanger

交換者是用於線程間協作的工具類,用於進行線程間的數據交換。它提供一個同步點,在這個同步點兩個線程可以交換彼此的數據。這兩個線程通過 exchange 方法交換數據,如果第一個線程先執行exchange方法它會阻塞等待第二個線程執行exchange方法,當兩個線程都到達同步點時這兩個線程就可以交換數據,將本線程生產出的數據傳遞給對方。應用場景包括遺傳算法、校對工作等。


P18:線程池

好處

① 降低資源消耗,複用已創建的線程降低線程創建和消耗的開銷。② 提高響應速度,當任務到達時,任務可以不需要等到線程創建就可以立即執行。③ 提高線程的可管理性,線程是稀缺資源,如果無限制地創建不僅會消耗系統資源,還會降低系統的穩定性,使用線程池可以進行統一分配、調優和監控。

當提交一個新任務到線程池時的處理流程

① 判斷核心線程池裏的線程是否都在執行任務,如果不是則創建一個新的工作線程來執行任務,此時 workCount < corePoolSize,這一步需要獲取全局鎖。② 如果核心線程池已滿,判斷工作隊列是否已滿,如果沒有就將新提交的任務存儲在工作隊列中,此時 workCount >= corePoolSize。③ 如果工作隊列已滿,判斷線程池的線程是否都處於工作狀態,如果沒有則創建一個新的工作線程來執行任務,此時 workCount < maximumPoolSize,這一步也需要獲取全局鎖。④ 如果線程池已滿,按照拒絕策略來處理任務,此時 workCount > maximumPoolSize。

線程池採取這種設計思路是爲了在執行 execute 方法時儘可能地避免獲取全局鎖,在線程池完成預熱之後,即當前運行的線程數大於等於corePoolSize 時,幾乎所有的 execute 方法都是執行步驟 2,不需要獲取全局鎖。

線程池創建線程時,會將線程封裝成工作線程 Worker,Worker 在執行完任務後還會循環獲取工作隊列中的任務來執行。線程池中的線程執行任務分爲兩種情況:①在 execute 方法中創建一個線程時會讓這個線程執行當前任務。②這個線程執行完任務之後,就會反覆從工作隊列中獲取任務並執行。

可以使用 execute 和 submit 方法向線程池提交任務。execute 用於提交不需要返回值的任務,所以無法判斷任務是否被線程池執行成功了。submit 用於提交需要返回值的任務,線程池會返回一個 Future 類型的對象,通過該對象可以判斷任務是否執行成功,並且可以通過該對象的 get 方法獲取返回值,get 會阻塞當前線程直到任務完成,帶超時參數的 get 方法會在阻塞當前線程一段時間後立即返回,這時任務可能還沒有完成。

創建線程池的參數

① **corePoolSize:**線程池的基本大小,當提交一個任務到線程池時,線程池會創建一個線程來執行任務,即使其他空閒的基本線程能夠執行新任務也會創建線程,等到需要執行的任務數大於線程池的基本大小時就不再創建。② **workQueue:**工作隊列,用於保存等待執行任務的阻塞隊列。③ **maximumPoolSize:**線程池最大數量,如果工作隊列已滿,並且創建的線程數小於最大線程數,則線程池還會創建新的線程執行任務,如果使用無界阻塞隊列該參數無意義。④ **threadFactory:**用於設置創建線程的工廠,可以通過線程工廠給每個創建出來的線程設置更有意義的名字。⑤ **handler:**拒絕策略,默認策略下使用 AbortPolicy 直接拋出異常,CallerRunsPolicy 表示重新嘗試提交該任務,DiscardOldestPolicy 表示拋棄隊列裏最近的一個任務並執行當前任務,DiscardPolicy 表示直接拋棄當前任務不處理。⑥ **keepAliveTime:**線程活動的保持時間,線程池工作線程空閒後保持存活的時間,所以如果任務很多,且每個任務的執行時間較短,可以調大時間提高線程的利用率。⑦ **unit:**線程活動保持時間的單位。

關閉線程池

可以通過調用 shutdown 或 shutdownNow 方法關閉線程池,原理是遍歷線程池中的工作線程,然後逐個調用線程的 interrupt 方法中斷線程,所以無法響應中斷的任務可能永遠無法終止。區別是 shutdownNow 首先將線程池的狀態設爲 STOP,然後嘗試停止所有正在執行或暫停任務的線程,並返回等待執行任務的列表,而 shutdown 只是將線程池的狀態設爲 SHUTDOWN,然後中斷所有沒有正在執行任務的線程。通常調用 shutdown 來關閉線程池,如果任務不一定要執行完則可以調用 shutdownNow。

合理設置線程池

首先可以從以下角度分析:①任務的性質:CPU密集型任務、IO密集型任務和混合型任務。②任務的優先級:高、中和低。③任務的執行時間:長、中和短。④任務的依賴性:是否依賴其他系統資源,如數據庫連接。

性質不同的任務可以用不同規模的線程池分開處理,CPU密集型任務應配置儘可能小的線程,如配置 Ncpu+1 個線程的線程池。由於IO密集型任務線程並不是一直在執行任務,則應配置儘可能多的線程,如 2 * Ncpu。混合型的任務,如果可以拆分,將其拆分爲一個 CPU 密集型任務和一個 IO 密集型任務,只要這兩個任務執行的時間相差不是太大那麼分解後的吞吐量將高於串行執行的吞吐量,如果相差太大則沒必要分解。

優先級不同的任務可以使用優先級隊列 PriorityBlockingQueue 處理。

執行時間不同的任務可以交給不同規模的線程池處理,或者使用優先級隊列讓執行時間短的任務先執行。

依賴數據庫連接池的任務,由於線程提交 SQL 後需要等待數據庫返回的結果,等待的時間越長 CPU 空閒的時間就越長,因此線程數應該儘可能地設置大一些提高CPU的利用率。

建議使用有界隊列,能增加系統的穩定性和預警能力,可以根據需要設置的稍微大一些。


P19:阻塞隊列

阻塞隊列支持阻塞的插入和移除,當隊列滿時,阻塞插入元素的線程直到隊列不滿。當隊列爲空時,獲取元素的線程會被阻塞直到隊列非空。阻塞隊列常用於生產者和消費者的場景,阻塞隊列就是生產者用來存放元素,消費者用來獲取元素的容器。

Java中的阻塞隊列

ArrayBlockingQueue,由數組組成的有界阻塞隊列,默認情況下不保證線程公平,有可能先阻塞的線程最後才訪問隊列。

LinkedBlockingQueue,由鏈表結構組成的有界阻塞隊列,隊列的默認和最大長度爲 Integer 的最大值。

PriorityBlockingQueue,支持優先級排序的無界阻塞隊列,默認情況下元素按照順序升序排序。可以自定義 compareTo 方法指定元素排序規則,或者初始化時指定 Comparator 對元素排序,不能保證同優先級元素的順序。

DelayQueue,支持延時獲取元素的無界阻塞隊列,使用優先級隊列實現。創建元素時可以指定多久才能從隊列中獲取當前元素,只有延遲期滿時才能從隊列中獲取元素,適用於緩存系統和定時任務調度。

SynchronousQueue,不存儲元素的阻塞隊列,每一個 put 操作必須等待一個 take 操作。默認使用非公平策略,也支持公平策略,適用於傳遞性場景,吞吐量高於 ArrayBlockingQueue 和 LinkedBlockingQueue。

LinkedTransferQueue,由鏈表組成的無界阻塞隊列,相對於其他阻塞隊列多了 tryTransfer 和 transfer 方法。transfe方法:如果當前有消費者正在等待接收元素,可以把生產者傳入的元素立刻傳輸給消費者,如果沒有消費者等待接收元素,會將元素放在隊列的尾節點並等到該元素被消費者消費了才返回。tryTransfer 方法用來試探生產者傳入的元素能否直接傳給消費者,如果沒有消費者等待接收元素則返回 false,和transfer 的區別是無論消費者是否消費都會立即返回。

LinkedBlockingDeque,由鏈表組成的雙向阻塞隊列,可以從隊列的兩端插入和移出元素,在多線程同時入隊時減少了競爭。

實現原理

使用通知模式實現,當生產者往滿的隊列裏添加元素時會阻塞生產者,當消費者消費了一個隊列中的元素後,會通知生產者當前隊列可用。例如 JDK 中的 ArrayBlockingQueue 使用了 Condition 實現。當往隊列裏插入一個元素,如果隊列不可用,那麼阻塞生產者主要通過LockSupport 的 park 方法實現,park 在不同的操作系統中使用不同的方式實現,在 Linux 下使用的是系統方法 pthread_cond_wait 實現。


P20:Executor 框架

Java 的線程既是工作單元,也是執行機制。從 JDK 5開始把工作單元與執行機制分離開來,工作單元包括 Runnable 和 Callable,而執行機制由 Exectuor 框架提供。

在 HotSpot 的線程模型中,Java 線程被一對一映射爲本地操作系統線程,Java 線程啓動時會創建一個本地操作系統線程,當該 Java線程終止時,這個操作系統線程也會被回收,操作系統會調度所有線程並將它們分配給可用的CPU。

在上層,Java 多線程程序通常把應用分解爲若干任務,然後使用用戶級的調度器即 Executor 框架將這些任務映射爲固定數量的線程;在底層,操作系統內核將這些線程映射到硬件處理器上。

Executor 框架主要由三部分組成:①任務,包括被執行任務需要實現的接口,Runnable 或 Callable。②任務的執行,包括任務執行機制的核心接口 Executor 以及繼承自 Executor 的 ExecutorService 。③異步計算的結果,包括接口 Future 和實現 Future 接口的 FutureTask 類。

ThreadPoolExecutor

ThreadPoolExecutor 是 Executor框架最核心的類,是線程池的實現類,主要有三種。

① FixedThreadPool,可重用固定線程數的線程池,corePoolSize 和 maximumPoolSize都被設置爲創建時的指定參數 nThreads,當線程池中的線程數大於 corePoolSize 時,keepAliveTime 爲多餘的空閒線程等待新任務的最長時間,超過這個時間後的多餘線程將被終止,將其設置爲 0L 時多餘空閒線程將被立即終止。該線程池使用的工作隊列是無界阻塞隊列 LinkedBlockingQueue,適用於爲了滿足資源管理的需求,而需要限制當前線程數量的應用場景,適用於負載比較重的服務器。

② SingleThreadExecutor,使用單個線程的線程池,corePoolSize 和 maximumPoolSize都被設置爲 1,其他參數和 FiexedThreadPool相同。適用於需要保證順序執行各個任務,並且在任意時間點不會有多個線程是活動的的應用場景。

③ CachedThreadPool,一個根據需要創建線程的線程池,corePoolSize 被設置爲0,maximumPoolSize 被設置爲 Integer 最大值。該線程池使用的工作隊列是沒有容量的 SynchronousQueue,但由於 maximumPoolSize 設爲 Integer最大值,如果主線程提交任務的速度高於線程處理的速度,線程池會不斷創建新線程,極端情況下會創建過多線程而耗盡CPU和內存資源。適用於執行很多短期異步任務的小程序,或者負載較輕的服務器。

ScheduledThreadPoolExecutor

繼承自 ThreadPoolExecutor,主要用來在給定的延遲之後運行任務,或者定期執行任務。其功能與 Timer 類似但更強大和靈活。Timer對應的是單個後臺線程,而 ScheduledThreadPoolExecutor 可以在構造器中指定多個對應的後臺線程數。

主要有兩種:① ScheduledThreadPool:創建固定線程個數的線程池,適用於需要多個後臺線程執行週期任務,同時需要限制後臺線程數量的應用場景。② SingleThreadScheduledExecutor:只包含一個線程的線程池,適用於單個後臺線程執行週期任務,同時需要保證順序執行任務的應用場景。

實現原理是將待調度任務放入一個DelayQueue 中,調度任務主要有三個 long 類型的參數,time 表示這個任務將要被執行的具體時間,sequenceNumber 表示這個任務被添加到線程池的序號,period 表示任務執行時間間隔。DelayQueue封裝了一個PriorityQueue,隊列按照 time 進行排序,如果相同則比較sequenceNumber,越小的排在前面,即如果兩個任務的執行時間相同,先提交的任務先被執行。


Spring 10

P1:Spring 框架

Spring 是分層的企業級應用輕量級開源框架,以 IoC 和 AOP爲內核。Spring 可以降低企業級應用開發的複雜性,對此主要採取了四個關鍵策略:基於 POJO 的輕量級和最小侵入性編程、通過依賴注入和麪向接口實現松耦合、基於切面和慣性進行聲明式編程、通過切面和模板減少樣板式代碼。

好處

降低代碼耦合度、簡化開發。通過 Spring 提供的 IoC 容器可以將對象間的依賴關係交由 Spring 進行控制,避免硬編碼所造成的過度程序耦合。用戶也不必再爲單例模式類、屬性文件解析等這些底層的需求編寫代碼,可以更專注於上層的應用。

AOP 編程以及聲明式事務的支持。通過 Spring 的 AOP 功能可以方便進行面向切面的編程,通過聲明式事務可以靈活進行事務管理,提高開發效率和質量。

方便程序的測試和集成各種框架。可以用非容器依賴的編程方式進行幾乎所有的測試工作,可以降低各種框架的使用難度,提供了對 Mybatis 和 Hibernate 等框架的直接支持。

降低了 JavaEE API 的使用難度。Spring 對 JDBC、JavaMail、遠程調用等 API 進行了封裝,使這些 API 的使用難度大幅降低。

核心容器

核心容器由 spring-beans、spring-core、spring-context 和 spring-expression 四個模塊組成。

spring-beans 和 spring-core 模塊是 Spring 的核心模塊,包括了控制反轉和依賴注入。BeanFactory 使用控制反轉對應用程序的配置和依賴性規範與實際的應用代碼進行分離,BeanFactory 實例化後並不會自動實例化 Bean,只有當 Bean 被使用時纔會對其進行實例化與依賴關係的裝配。

spring-context 模塊構架於核心模塊之上,擴展了 BeanFactory,爲它添加了 Bean 的生命週期控制、框架事件體系及資源透明化加載等功能。ApplicationConext 是該模塊的核心接口,它是 BeanFactory 的子接口,它實例化後會自動對所有單例 Bean 進行實例化與依賴關係的裝配,使之處於待用狀態。

spring-expression 是 EL 語言的擴展模塊,可以查詢、管理運行中的對象,同時也可以方便地調用對象方法,以及操作數組、集合等。


P2:IoC 控制反轉

IoC 即控制反轉,是一種給予應用程序中目標組件更多控制的設計範式,簡單來說就是把原來代碼裏需要實現的對象創建、依賴反轉給容器來幫忙實現,需要創建一個容器並且需要一種描述來讓容器知道要創建的對象之間的關係,在 Spring 框架中管理對象及其依賴關係是通過 Spring 的 IoC 容器實現的,IoC 的作用是降低代碼耦合度。

IoC 的實現方式有依賴注入和依賴查找,由於依賴查找使用的很少,因此 IoC 也叫做依賴注入。依賴注入指對象被動地接受依賴類而不用自己主動去找,對象不是從容器中查找它依賴的類,而是在容器實例化對象時主動將它依賴的類注入給它。假設一個 Car 類需要一個 Engine 的對象,那麼一般需要需要手動 new 一個 Engine,利用 IoC 就只需要定義一個私有的 Engine 類型的成員變量,容器會在運行時自動創建一個 Engine 的實例對象並將引用自動注入給成員變量。

基於 XML 的容器初始化

當創建一個 ClassPathXmlApplicationContext 時,構造器做了兩件事:首先調用父容器的構造器爲容器設置好 Bean 資源加載器,然後調用父類的 setConfigLocations 方法設置 Bean 配置信息的定位路徑。

ClassPathXmlApplicationContext 通過調用父類 AbstractApplicationContext 的 refresh 方法啓動整個 IoC 容器對 Bean 定義的載入過程,refresh 是一個模板方法,規定了 IoC 容器的啓動流程。refresh 方法的主要作用是:在創建 IoC 容器之前如果已有容器存在,需要把已有的容器銷燬和關閉,以保證在 refresh 方法之後使用的是新創建的 IoC 容器。

容器創建後通過 loadBeanDefinitions 方法加載 Bean 配置資源,該方法會做兩件事:首先調用資源加載器的方法獲取要加載的資源,其次真正執行加載功能,由子類 XmlBeanDefinitionReader 實現。在加載資源時,首先會解析配置文件路徑,讀取配置文件的內容,然後通過 XML 解析器將 Bean 配置信息轉換成文檔對象,之後再按照 Spring Bean 的定義規則對文檔對象進行解析。

Spring IoC 容器中註冊解析的 Bean 信息存放在一個 HashMap 集合中,key 是 String 字符串,值是 BeanDefinition,在註冊過程中需要使用 synchronized 同步塊保證線程安全。當 Bean 配置信息中配置的 Bean 被解析後且被註冊到 IoC 容器中,初始化就算真正完成了,Bean 定義信息已經可以使用,並且可以被檢索。Spring IoC 容器的作用就是對這些註冊的 Bean 定義信息進行處理和維護,註冊的 Bean 定義信息是控制反轉和依賴注入的基礎。

基於註解的容器初始化

Spring 對註解的處理分爲兩種方式:① 直接將註解 Bean 註冊到容器中,可以在初始化容器時註冊,也可以在容器創建之後手動註冊,然後刷新容器使其對註冊的註解 Bean 進行處理。② 通過掃描指定的包及其子包的所有類處理,在初始化註解容器時指定要自動掃描的路徑。


P3:DI 依賴注入

可注入的數據類型

基本數據類型和 String、集合類型、Bean 類型。

實現方式

構造器注入:IoC Service Provider 會檢查被注入對象的構造器,取得它所需要的依賴對象列表,進而爲其注入相應的對象。這種方法的優點是在對象構造完成後就處於就緒狀態,可以馬上使用。缺點是當依賴對象較多時,構造器的參數列表會比較長,構造器無法被繼承,無法設置默認值。對於非必需的依賴處理可能需要引入多個構造器,參數數量的變動可能會造成維護的困難。

setter 方法注入:當前對象只需要爲其依賴對象對應的屬性添加 setter 方法,就可以通過 setter 方法將依賴對象注入到被依賴對象中。setter 方法注入在描述性上要比構造器注入強,並且可以被繼承,允許設置默認值。缺點是無法在對象構造完成後馬上進入就緒狀態。

接口注入:必須實現某個接口,這個接口提供一個方法來爲其注入依賴對象。使用較少,因爲它強制要求被注入對象實現不必要的接口,侵入性強。

相關注解

@Autowired:自動按類型注入,如果有多個匹配則按照指定 Bean 的 id 查找,查找不到會報錯。

@Qualifier:在自動按照類型注入的基礎上再按照 Bean 的 id 注入,給變量注入時必須搭配 @Autowired,給方法注入時可單獨使用。

@Resource :直接按照 Bean 的 id 注入,只能注入 Bean 類型。

@Value :用於注入基本數據類型和 String 類型。

依賴注入的過程

getBean 方法是獲取 Bean 實例的方法,該方法會調用 doGetBean 方法,doGetBean 真正實現向 IoC 容器獲取 Bean 的功能,也是觸發依賴注入的地方。如果 Bean 定義爲單例模式,容器在創建之前先從緩存中查找以確保整個容器中只存在一個實例對象。如果 Bean 定義爲原型模式,則容器每次都會創建一個新的實例。

具體創建 Bean 實例對象的過程由 ObjectFactory 的 createBean 方法完成,該方法主要通過 createBeanInstance 方法生成 Bean 包含的 Java 對象實例和 populateBean 方法對 Bean 屬性的依賴注入進行處理。

在 createBeanInstance 方法中根據指定的初始化策略,通過簡單工廠、工廠方法或容器的自動裝配特性生成 Java 實例對象,對工廠方法和自動裝配特性的 Bean,調用相應的工廠方法或參數匹配的構造器即可完成實例化對象的工作,但最常用的默認無參構造器需要使用 JDK 的反射或 CGLib 來進行初始化。

在 populateBean 方法中,注入過程主要分爲兩種情況:① 屬性值類型不需要強制轉換時,不需要解析屬性值,直接進行依賴注入。② 屬性值類型需要強制轉換時,首先需要解析屬性值,然後對解析後的屬性值進行依賴注入。依賴注入的過程就是將 Bean 對象實例設置到它所依賴的 Bean 對象屬性上,真正的依賴注入是通過 setPropertyValues 方法實現的,該方法使用了委派模式。

BeanWrapperImpl 類負責對容器完成初始化的 Bean 實例對象進行屬性的依賴注入,對於非集合類型的屬性,大量使用 JDK 的反射機制,通過屬性的 getter 方法獲取指定屬性注入前的值,同時調用屬性的 setter 方法爲屬性設置注入後的值。對於集合類型的屬性,將屬性值解析爲目標類型的集合後直接賦值給屬性。

當 Spring IoC 容器對 Bean 定義資源的定位、載入、解析和依賴注入全部完成後,就不再需要我們手動創建所需的對象,Spring IoC 容器會自動爲我們創建對象並且注入好相關依賴。


P4:Bean 對象

生命週期

在 IoC 容器的初始化過程中會對 Bean 定義完成資源定位,加載讀取配置並解析,最後將解析的 Bean 信息放在一個 HashMap 集合中。當 IoC 容器初始化完成後,會進行對 Bean 實例的創建和依賴注入過程,注入對象依賴的各種屬性值,在初始化時可以指定自定義的初始化方法。經過這一系列初始化操作後 Bean 達到可用狀態,接下來就可以使用 Bean 了,當使用完成後會調用 destroy 方法進行銷燬,此時也可以指定自定義的銷燬方法,最終 Bean 被銷燬且從容器中移除。

指定 Bean 初始化和銷燬的方法:

XML 方式通過配置 bean 標籤中的 init-Method 和 destory-Method 指定自定義初始化和銷燬方法。

註解方式通過 @PreConstruct@PostConstruct 註解指定自定義初始化和銷燬方法。

作用範圍

通過 scope 屬性指定 bean 的作用範圍,包括:① singleton:單例模式,是默認作用域,不管收到多少 Bean 請求每個容器中只有一個唯一的 Bean 實例。② prototype:原型模式,和 singleton 相反,每次 Bean 請求都會創建一個新的實例。③ request:每次 HTTP 請求都會創建一個新的 Bean 並把它放到 request 域中,在請求完成後 Bean 會失效並被垃圾收集器回收。④ session:和 request 類似,確保每個 session 中有一個 Bean 實例,session 過期後 bean 會隨之失效。⑤ global session:當應用部署在 Portlet 容器中時,如果想讓所有 Portlet 共用全局存儲變量,那麼這個變量需要存儲在 global session 中。

創建方式

XML

通過默認無參構造器,只需要指明 bean 標籤中的 id 和 class 屬性,如果沒有無參構造器會報錯。

使用靜態工廠方法,通過 bean 標籤中的 class 屬性指明靜態工廠,factory-method 屬性指明靜態工廠方法。

使用實例工廠方法,通過 bean 標籤中的 factory-bean 屬性指明實例工廠,factory-method 屬性指明實例工廠方法。

註解

@Component 把當前類對象存入 Spring 容器中,相當於在 xml 中配置一個 bean 標籤。value 屬性指定 bean 的 id,默認使用當前類的首字母小寫的類名。

@Controller@Service@Repository 三個註解都是 @Component 的衍生註解,作用及屬性都是一模一樣的。只是提供了更加明確語義,@Controller 用於表現層,@Service用於業務層,@Repository用於持久層。如果註解中有且只有一個 value 屬性要賦值時可以省略 value。

如果想將第三方的類變成組件又沒有源代碼,也就沒辦法使用 @Component 進行自動配置,這種時候就要使用 @Bean 註解。被 @Bean 註解的方法返回值是一個對象,將會實例化,配置和初始化一個新對象並返回,這個對象由 Spring 的 IoC 容器管理。name 屬性用於給當前 @Bean 註解方法創建的對象指定一個名稱,即 bean 的 id。當使用註解配置方法時,如果方法有參數,Spring 會去容器查找是否有可用 bean對象,查找方式和 @Autowired 一樣。

@Configuration 用於指定當前類是一個 spring 配置類,當創建容器時會從該類上加載註解,value 屬性用於指定配置類的字節碼。

@ComponentScan 用於指定 Spring 在初始化容器時要掃描的包。basePackages 屬性用於指定要掃描的包。

@PropertySource 用於加載 .properties 文件中的配置。value 屬性用於指定文件位置,如果是在類路徑下需要加上 classpath。

@Import 用於導入其他配置類,在引入其他配置類時可以不用再寫 @Configuration 註解。有 @Import 的是父配置類,引入的是子配置類。value 屬性用於指定其他配置類的字節碼。

BeanFactory、FactoryBean 和 ApplicationContext 的區別

BeanFactory 是一個 Bean 工廠,實現了工廠模式,是 Spring IoC 容器最頂級的接口,可以理解爲含有 Bean 集合的工廠類,它的作用是管理 Bean,包括實例化、定位、配置應用程序中的對象及建立這些對象之間的依賴。BeanFactory 實例化後並不會自動實例化 Bean,只有當 Bean 被使用時纔會對其進行實例化與依賴關係的裝配,屬於延遲加載,適合多例模式。

FactoryBean 是一個工廠 Bean,作用是生產其他 Bean 實例,可以通過實現該接口,提供一個工廠方法來自定義實例化 Bean 的邏輯。

ApplicationConext 是 BeanFactory 的子接口,擴展了 BeanFactory 的功能,提供了支持國際化的文本消息,統一的資源文件讀取方式,事件傳播以及應用層的特別配置等。容器會在初始化時對配置的 Bean 進行預實例化,Bean 的依賴注入在容器初始化時就已經完成,屬於立即加載,適合單例模式,一般推薦使用 ApplicationContext。


P5:AOP 面向切面編程

概念和原理

AOP 即面向切面編程,簡單地說就是將代碼中重複的部分抽取出來,在需要執行的時候使用動態代理的技術,在不修改源碼的基礎上對方法進行增強。優點是可以減少代碼的冗餘,提高開發效率,維護方便。

Spring 會根據類是否實現了接口來判斷動態代理的方式,如果實現了接口會使用 JDK 的動態代理,核心是 InvocationHandler 接口和 Proxy 類,如果沒有實現接口會使用 CGLib 動態代理,CGLib 是在運行時動態生成某個類的子類,如果某一個類被標記爲 final,是不能使用 CGLib 動態代理的。

JDK 動態代理主要通過重組字節碼實現,首先獲得被代理對象的引用和所有接口,生成新的類必須實現被代理類的所有接口,動態生成Java 代碼後編譯新生成的 .class 文件並重新加載到 JVM 運行。JDK 代理直接寫 Class 字節碼,CGLib是採用ASM框架寫字節碼,生成代理類的效率低。但是CGLib調用方法的效率高,因爲 JDK 使用反射調用方法,CGLib 使用 FastClass 機制爲代理類和被代理類各生成一個類,這個類會爲代理類或被代理類的方法生成一個 index,這個 index 可以作爲參數直接定位要調用的方法。

常用場景包括權限認證、自動緩存、錯誤處理、日誌、調試和事務等。

相關注解

@Aspect:聲明被註解的類是一個切面 Bean。

@Before:前置通知,指在某個連接點之前執行的通知。

@After:後置通知,指某個連接點退出時執行的通知(不論正常返回還是異常退出)。

@AfterReturning:返回後通知,指某連接點正常完成之後執行的通知,返回值使用returning屬性接收。

@AfterThrowing:異常通知,指方法拋出異常導致退出時執行的通知,和@AfterReturning只會有一個執行,異常使用throwing屬性接收。

相關術語

Aspect:切面,一個關注點的模塊化,這個關注點可能會橫切多個對象。

Joinpoint:連接點,程序執行過程中的某一行爲,即業務層中的所有方法。。

Advice:通知,指切面對於某個連接點所產生的動作,包括前置通知、後置通知、返回後通知、異常通知和環繞通知。

Pointcut:切入點,指被攔截的連接點,切入點一定是連接點,但連接點不一定是切入點。

Proxy:代理,Spring AOP 中有 JDK 動態代理和 CGLib 代理,目標對象實現了接口時採用 JDK 動態代理,反之採用 CGLib 代理。

Target:代理的目標對象,指一個或多個切面所通知的對象。

Weaving :織入,指把增強應用到目標對象來創建代理對象的過程。

AOP 的過程

Spring AOP 是由 BeanPostProcessor 後置處理器開始的,這個後置處理器是一個監聽器,可以監聽容器觸發的 Bean 生命週期事件,向容器註冊後置處理器以後,容器中管理的 Bean 就具備了接收 IoC 容器回調事件的能力。BeanPostProcessor 的調用發生在 Spring IoC 容器完成 Bean 實例對象的創建和屬性的依賴注入之後,爲 Bean 對象添加後置處理器的入口是 initializeBean 方法。

Spring 中 JDK 動態代理生通過 JdkDynamicAopProxy 調用 Proxy 的 newInstance 方法來生成代理類,JdkDynamicAopProxy 也實現了 InvocationHandler 接口,invoke 方法的具體邏輯是先獲取應用到此方法上的攔截器鏈,如果有攔截器則創建 MethodInvocation 並調用其 proceed 方法,否則直接反射調用目標方法。因此 Spring AOP 對目標對象的增強是通過攔截器實現的。


P6:Spring MVC 核心組件

DispatcherServlet:SpringMVC 中的前端控制器,是整個流程控制的核心,負責接收請求並轉發給對應的處理組件。

Handler:處理器,完成具體業務邏輯,相當於 Servlet 或 Action。

HandlerMapping:完成URL 到 Controller映射的組件,DispatcherServlet 接收到請求之後,通過 HandlerMapping 將不同的請求映射到不同的 Handler。

HandlerInterceptor:處理器攔截器,是一個接口,如果需要完成一些攔截處理,可以實現該接口。

HandlerExecutionChain:處理器執行鏈,包括兩部分內容:Handler 和 HandlerInterceptor。

HandlerAdapter:處理器適配器,Handler執行業務方法前需要進行一系列操作,包括表單數據驗證、數據類型轉換、將表單數據封裝到JavaBean等,這些操作都由 HandlerAdapter 完成。DispatcherServlet 通過 HandlerAdapter 來執行不同的 Handler。

ModelAndView:裝載了模型數據和視圖信息,作爲 Handler 的處理結果返回給 DispatcherServlet。

ViewResolver:視圖解析器,DispatcherServlet 通過它將邏輯視圖解析爲物理視圖,最終將渲染的結果響應給客戶端。


P7:Spring MVC 處理流程

Web 容器啓動時會通知 Spring 初始化容器,加載 Bean 的定義信息並初始化所有單例 Bean,然後遍歷容器中的 Bean,獲取每一個 Controller 中的所有方法訪問的 URL,將 URL 和對應的 Controller 保存到一個 Map 集合中。

所有的請求會轉發給 DispatcherServlet 前端處理器處理,DispatcherServlet 會請求 HandlerMapping 找出容器中被 @Controler 註解修飾的 Bean 以及被 @RequestMapping 修飾的方法和類,生成 Handler 和 HandlerInterceptor 並以一個 HandlerExcutionChain 處理器執行鏈的形式返回。

之後 DispatcherServlet 使用 Handler 找到對應的 HandlerApapter,通過 HandlerApapter 調用 Handler 的方法,將請求參數綁定到方法的形參上,執行方法處理請求並得到 ModelAndView。

最後 DispatcherServlet 根據使用 ViewResolver 試圖解析器對得到的 ModelAndView 邏輯視圖進行解析得到 View 物理視圖,然後對視圖渲染,將數據填充到視圖中並返回給客戶端。

註解

@Controller:在類定義處添加,將類交給IoC容器管理。

@RequtestMapping:將URL請求和業務方法映射起來,在類和方法定義上都可以添加該註解。value 屬性指定URL請求的實際地址,是默認值。method 屬性限制請求的方法類型,包括GET、POST、PUT、DELETE等。如果沒有使用指定的請求方法請求URL,會報405 Method Not Allowed 錯誤。params 屬性限制必須提供的參數,如果沒有會報錯。

@RequestParam:如果 Controller 方法的形參和 URL 參數名一致可以不添加註解,如果不一致可以使用該註解綁定。value 屬性表示HTTP請求中的參數名。required 屬性設置參數是否必要,默認false。defaultValue 屬性指定沒有給參數賦值時的默認值。

@PathVariable:Spring MVC 也支持 RESTful 風格的 URL,通過 @PathVariable 完成請求參數與形參的綁定。


P8:Spring Data JPA 框架

Spring Data JPA 是 Spring 基於 ORM 框架、JPA 規範的基礎上封裝的一套 JPA 應用框架,可使開發者用極簡的代碼實現對數據庫的訪問和操作。它提供了包括增刪改查等在內的常用功能,且易於擴展,可以極大提高開發效率。

ORM 即 Object-Relational Mapping ,表示對象關係映射,映射的不只是對象的值還有對象之間的關係,通過 ORM 就可以把對象映射到關係型數據庫中。操作實體類就相當於操作數據庫表,可以不再重點關注 SQL 語句。

使用時只需要持久層接口繼承 JpaRepository 即可,泛型參數列表中第一個參數是實體類類型,第二個參數是主鍵類型。運行時通過 JdkDynamicAopProxyinvoke 方法創建了一個動態代理對象 SimpleJpaRepositorySimpleJpaRepository 中封裝了 JPA 的操作,通過 hibernate(封裝了JDBC)完成數據庫操作。

註解

@Entity:表明當前類是一個實體類。

@Table :關聯實體類和數據庫表。

@Column :關聯實體類屬性和數據庫表中字段。

@Id :聲明當前屬性爲數據庫表主鍵對應的屬性。

@GeneratedValue: 配置主鍵生成策略。

@OneToMany :配置一對多關係,mappedBy 屬性值爲主表實體類在從表實體類中對應的屬性名。

@ManyToOne :配置多對一關係,targetEntity 屬性值爲主表對應實體類的字節碼。

@JoinColumn:配置外鍵關係,name 屬性值爲外鍵名稱,referencedColumnName 屬性值爲主表主鍵名稱。

對象導航查詢

通過 get 方法查詢一個對象的同時,通過此對象可以查詢它的關聯對象。

對象導航查詢一到多默認使用延遲加載的形式, 關聯對象是集合,因此使用立即加載可能浪費資源。

對象導航查詢多到一默認使用立即加載的形式, 關聯對象是一個對象,因此使用立即加載。

如果要改變加載方式,在實體類註解配置加上 fetch 屬性即可,LAZY 表示延遲加載,EAGER 表示立即加載。


P9:Mybatis 框架

Mybatis 是一個實現了數據持久化的 ORM 框架,簡單理解就是對 JDBC 進行了封裝。

優點

相比 JDBC 減少了大量代碼量,減少冗餘代碼。

使用靈活,SQL 語句寫在 XML 裏,從程序代碼中徹底分離,降低了耦合度,便於管理。

提供 XML 標籤,支持編寫動態 SQL 語句。

提供映射標籤,支持對象與數據庫的 ORM 字段映射關係。

缺點

SQL 語句編寫工作量較大,尤其是字段和關聯表多時。

SQL 語句依賴於數據庫,導致數據庫移植性差,不能隨意更換數據庫。

映射文件標籤

selectinsertupdatedelete 標籤分別對應查詢、添加、更新、刪除操作。

parameterType 屬性表示參數的數據類型,包括基本數據類型和對應的包裝類型、String 和 Java Bean 類型,當有多個參數時可以使用 #{argn} 的形式表示第 n 個參數。除了基本數據類型都要以全限定類名的形式指定參數類型。

resultType 表示返回的結果類型,包括基本數據類型和對應的包裝類型、String 和 Java Bean 類型。還可以使用把返回結果封裝爲複雜類型的 resultMap

緩存

使用緩存可以減少程序和數據庫交互的次數,從而提高程序的運行效率。第一次查詢後會自動將結果保存到緩存中,下一次查詢時直接從緩存中返回結果無需再次查詢數據庫。

  • 一級緩存

    SqlSession 級別,默認開啓且不能關閉。

    操作數據庫時需要創建 SqlSession 對象,在對象中有一個 HashMap 用於存儲緩存數據,不同 SqlSession 之間緩存數據區域互不影響。

    一級緩存的作用域是 SqlSession 範圍的,在同一個 SqlSession 中執行兩次相同的 SQL 語句時,第一次執行完畢會將結果保存在緩存中,第二次查詢直接從緩存中獲取。

    如果 SqlSession 執行了 DML 操作(insert、update、delete),Mybatis 必須將緩存清空以保證數據的有效性。

  • 二級緩存

    Mapper 級別,默認關閉。

    使用二級緩存時多個 SqlSession 使用同一個 Mapper 的 SQL 語句操作數據庫,得到的數據會存在二級緩存區,同樣使用 HashMap 進行數據存儲,相比於一級緩存,二級緩存範圍更大,多個 SqlSession 可以共用二級緩存,作用域是 Mapper 的同一個 namespace,不同 SqlSession 兩次執行相同的 namespace 下的 SQL 語句,參數也相等,則第一次執行成功後會將數據保存在二級緩存中,第二次可直接從二級緩存中取出數據。

    要使用二級緩存,先在在全局配置文件中配置:

    <!-- 開啓二級緩存 -->
    <setting name="cacheEnabled" value="true"/>
    

    再在對應的映射文件中配置一個 cache 標籤即可。

    <cache/>
    

P10:Spring Cloud 框架

單體應用存在的問題

隨着業務發展,開發越來越複雜。

修改、新增某個功能,需要對整個系統進行測試、重新部署。

一個模塊出現問題,可能導致整個系統崩潰。

多個開發團隊同時對數據進行管理,容易產生安全漏洞。

各個模塊使用同一種技術開發,各個模塊很難根據實際情況選擇更合適的技術框架,侷限性很大。

分佈式和集羣的區別

集羣:一臺服務器無法負荷高併發的數據訪問量,就設置多臺服務器一起分擔壓力,是在物理層面解決問題。

分佈式:將一個複雜的問題拆分成若干簡單的小問題,將一個大型的項目架構拆分成若干個微服務來協同完成,在軟件設計層面解決問題。

微服務的優點

各個服務的開發、測試、部署都相互獨立,用戶服務可以拆分爲獨立服務,如果用戶量很大,可以很容易對其實現負載。

當新需求出現時,使用微服務不再需要考慮各方面的問題,例如兼容性、影響度等。

使用微服務拆分項目後,各個服務之間消除了很多限制,只需要保證對外提供的接口正常可用,而不限制語言和框架等選擇。

服務治理 Eureka

服務治理的核心由三部分組成:服務提供者服務消費者註冊中心

服務註冊:在分佈式系統架構中,每個微服務在啓動時,將自己的信息存儲在註冊中心。

服務發現:服務消費者從註冊中心獲取服務提供者的網絡信息,通過該信息調用服務。

Spring Cloud 的服務治理使用 Eureka 實現,Eureka 是 Netflix 開源的基於 REST 的服務治理解決方案,Spring Cloud 集成了 Eureka,提供服務註冊和服務發現的功能,可以和基於 Spring Boot 搭建的微服務應用輕鬆完成整合,將 Eureka 二次封裝爲 Spring Cloud Eureka。Eureka Server 是註冊中心,所有要進行註冊的微服務通過 Eureka Client 連接到 Eureka Server 完成註冊。

服務網關 Zuul

Spring Cloud 集成了 Zuul 組件,實現服務網關。Zuul 是 Netflix 提供的一個開源的 API 網關服務器,是客戶端和網站後端所有請求的中間層,對外開放一個 API,將所有請求導入統一的入口,屏蔽了服務端的具體實現邏輯,可以實現方向代理功能,在網關內部實現動態路由、身份認證、IP過濾、數據監控等。

負載均衡 Ribbon

Spring Cloud Ribbon 是一個負載均衡的解決方案,Ribbon 是 Netflix 發佈的均衡負載器,Spring Cloud Ribbon是基於 Netflix Ribbon 實現的,是一個用於對 HTTP 請求進行控制的負載均衡客戶端。

在註冊中心對 Ribbon 進行註冊之後,Ribbon 就可以基於某種負載均衡算法(輪循、隨機、加權輪詢、加權隨機等)自動幫助服務消費者調用接口,開發者也可以根據具體需求自定義 Ribbon 負載均衡算法。實際開發中 Spring Clooud Ribbon 需要結合 Spring Cloud Eureka 使用,Eureka 提供所有可以調用的服務提供者列表,Ribbon 基於特定的負載均衡算法從這些服務提供者中選擇要調用的具體實例。

聲明式接口調用 Feign

Feign 與 Ribbon 一樣也是 Netflix 提供的,Feign 是一個聲明式、模板化的 Web Service 客戶端,簡化了開發者編寫 Web 服務客戶端的操作,開發者可以通過簡單的接口和註解來調用 HTTP API,Spring Cloud Feign 整合了 Ribbon 和 Hystrix,具有可插拔、基於註解、負載均衡、服務熔斷等一系列功能。

相比於 Ribbon + RestTemplate 的方式,Feign 可以大大簡化代碼開發,支持多種註解,包括 Feign 註解、JAX-RS 註解、Spring MVC 註解等。RestTemplate 是 Spring 框架提供的基於 REST 的服務組件,底層是對 HTTP 請求及響應進行了封裝,提供了很多訪問 REST 服務的方法,可以簡化代碼開發。

服務熔斷 Hystrix

熔斷器的作用是在不改變各個微服務調用關係的前提下,針對錯誤情況進行預先處理。

設計原則:服務隔離機制、服務降級機制、熔斷機制、提供實時監控和報警功能和提供實時配置修改功能

Hystrix 數據監控需要結合 Spring Boot Actuator 使用,Actuator 提供了對服務的數據監控、數據統計,可以通過 hystirx-stream 節點獲取監控的請求數據,同時提供了可視化監控界面。

服務配置 Config

Spring Cloud Config 通過服務端可以爲多個客戶端提供配置服務,既可以將配置文件存儲在本地,也可以將配置文件存儲在遠程的 Git 倉庫,創建 Config Server,通過它管理所有的配置文件。

服務跟蹤 Zipkin

Spring Cloud Zipkin 是一個可以採集並跟蹤分佈式系統中請求數據的組件,讓開發者更直觀地監控到請求在各個微服務耗費的時間,Zipkin 包括兩部分 Zipkin Server 和 Zipkin Client。


MySQL 15

P1:邏輯架構

第一層是服務器層,主要提供連接處理、授權認證、安全等功能,該層的服務不是 MySQL 獨有的,大多數基於網絡的 C/S 服務都有類似架構。

第二層實現了 MySQL 核心服務功能,包括查詢解析、分析、優化、緩存以及日期和時間等所有內置函數,所有跨存儲引擎的功能都在這一層實現,例如存儲過程、觸發器、視圖等。

第三層是存儲引擎層,存儲引擎負責 MySQL 中數據的存儲和提取。服務器通過 API 與存儲引擎通信,這些接口屏蔽了不同存儲引擎的差異,使得差異對上層查詢過程透明。除了會解析外鍵定義的 InnoDB 外,存儲引擎不會解析 SQL,不同存儲引擎之間也不會相互通信,只是簡單響應上層服務器請求。


P2:鎖

當有多個查詢需要在同一時刻修改數據時就會產生併發控制的問題,MySQL 在兩個層面進行併發控制:服務器層與存儲引擎層。

讀寫鎖

在處理併發讀或寫時,可以通過實現一個由兩種類型組成的鎖系統來解決問題。這兩種類型的鎖通常被稱爲共享鎖和排它鎖,也叫讀鎖和寫鎖。讀鎖是共享的,相互不阻塞,多個客戶在同一時刻可以同時讀取同一個資源而不相互干擾。寫鎖則是排他的,也就是說一個寫鎖會阻塞其他的寫鎖和讀鎖,確保在給定時間內只有一個用戶能執行寫入並防止其他用戶讀取正在寫入的同一資源。

在實際的數據庫系統中,每時每刻都在發生鎖定,當某個用戶在修改某一部分數據時,MySQL 會通過鎖定防止其他用戶讀取同一數據。寫鎖比讀鎖有更高的優先級,一個寫鎖請求可能會被插入到讀鎖隊列的前面,但是讀鎖不能插入到寫鎖前面。

鎖策略

一種提高共享資源併發性的方法就是讓鎖定對象更有選擇性,儘量只鎖定需要修改的部分數據而不是所有資源,更理想的方式是隻對會修改的數據進行精確鎖定。任何時刻在給定的資源上,鎖定的數據量越少,系統的併發程度就越高,只要不發生衝突即可。

鎖策略就是在鎖的開銷和數據安全性之間尋求平衡,這種平衡也會影響性能。大多數商業數據庫系統沒有提供更多選擇,一般都是在表上加行鎖,而 MySQL 提供了多種選擇,每種MySQL存儲引擎都可以實現自己的鎖策略和鎖粒度。MySQL 最重要的兩種鎖策略:

  • 表鎖是MySQL中最基本的鎖策略,並且是開銷最小的策略。表鎖會鎖定整張表,一個用戶在對錶進行寫操作前需要先獲得寫鎖,這會阻塞其他用戶對該表的所有讀寫操作。只有沒有寫鎖時,其他讀取的用戶才能獲取讀鎖,讀鎖之間不相互阻塞。

  • 行鎖可以最大程度地支持併發處理,同時也帶來了最大的鎖開銷。InnoDB 和 XtraDB 以及一些其他存儲引擎實現了行鎖。行鎖只在存儲引擎層實現,而服務器層沒有實現。

死鎖

死鎖是指兩個或者多個事務在同一資源上相互佔用並請求鎖定對方佔用的資源,從而導致惡性循環的現象。當多個事務試圖以不同順序鎖定資源時就可能會產生死鎖,多個事務同時鎖定同一個資源時也會產生死鎖。

爲了解決死鎖問題,數據庫系統實現了各種死鎖檢測和死鎖超時機制。越複雜的系統,例如InnoDB 存儲引擎,越能檢測到死鎖的循環依賴,並立即返回一個錯誤。這種解決方式很有效,否則死鎖會導致出現非常慢的查詢。還有一種解決方法,就是當查詢的時間達到鎖等待超時的設定後放棄鎖請求,這種方式通常來說不太好。InnoDB 目前處理死鎖的方法是將持有最少行級排它鎖的事務進行回滾。

鎖的行爲與順序是和存儲引擎相關的,以同樣的順序執行語句,有些存儲引擎會產生死鎖有些則不會。死鎖的產生有雙重原因:有些是真正的數據衝突,這種情況很難避免,有些則完全是由於存儲引擎的實現方式導致的。

死鎖發生之後,只有部分或者完全回滾其中一個事務,才能打破死鎖。對於事務型系統這是無法避免的,所以應用程序在設計時必須考慮如何處理死鎖。大多數情況下只需要重新執行因死鎖回滾的事務即可。


P3:事務

事務就是一組原子性的 SQL 查詢,或者說一個獨立的工作單元。如果數據庫引擎能夠成功地對數據庫應用該組查詢的全部語句,那麼就執行該組查詢。如果其中有任何一條語句因爲崩潰或其他原因無法執行,那麼所有的語句都不會執行。也就是說事務內的語句要麼全部執行成功,要麼全部執行失敗。

ACID 特性

一個運行良好的事務處理系統必須具備 ACID 特性,實現了 ACID 的數據庫需要更強的CPU處理能力、更大的內存和磁盤空間。

  • 原子性 atomicity

    一個事務在邏輯上是必須不可分割的最小工作單元,整個事務中的所有操作要麼全部提交成功,要麼全部失敗回滾,對於一個事務來說不可能只執行其中的一部分。

  • 一致性 consistency

    數據庫總是從一個一致性的狀態轉換到另一個一致性的狀態。

  • 隔離性 isolation

    針對併發事務而言,隔離性就是要隔離併發運行的多個事務之間的相互影響,一般來說一個事務所做的修改在最終提交以前,對其他事務是不可見的。

  • 持久性 durability

    一旦事務提交成功,其修改就會永久保存到數據庫中,此時即使系統崩潰,修改的數據也不會丟失。

隔離級別

在 SQL 標準中定義了四種隔離級別,每一種隔離級別都規定了一個事務中所做的修改,哪些在事務內和事務間是可見的,哪些是不可見的。較低級別的隔離通常可以執行更高的併發,系統的開銷也更低。

  • 未提交讀 READ UNCOMMITTED

    在該級別事務中的修改即使沒有被提交,對其他事務也是可見的。事務可以讀取其他事務修改完但未提交的數據,這種問題稱爲髒讀。這個級別還會導致不可重複讀和幻讀,從性能上說也沒有比其他級別好很多,因此很少使用。

  • 提交讀 READ COMMITTED

    大多數數據庫系統默認的隔離級別就是提交讀,但 MySQL 不是。提交讀滿足了隔離性的簡單定義:一個事務開始時只能"看見"已經提交的事務所做的修改。換句話說,一個事務從開始直到提交之前的任何修改對其他事務都是不可見的。這個級別有時也叫不可重複讀,因爲兩次執行同樣的查詢可能會得到不同結果。提交讀存在不可重複讀和幻讀的問題。

  • 可重複讀 REPEATABLE READ(MySQL默認的隔離級別)

    可重複讀解決了不可重複讀的問題,該級別保證了在同一個事務中多次讀取同樣的記錄結果是一致的。但可重複讀隔離級別還是無法解決幻讀的問題,所謂幻讀,指的是當某個事務在讀取某個範圍內的記錄時,會產生幻行。InnoDB 存儲引擎通過多版本併發控制MVCC 解決幻讀的問題。

  • 可串行化 SERIALIZABLE

    該級別是最高的隔離級別,通過強制事務串行執行,避免了幻讀的問題。可串行化會在讀取的每一行數據上都加鎖,可能導致大量的超時和鎖爭用的問題。實際應用中很少用到這個隔離級別,只有非常需要確保數據一致性且可以接受沒有併發的情況下才考慮該級別。

MySQL 中的事務

MySQL 提供了兩種事務型的存儲引擎:InnoDB 和 NDB Cluster。

MySQL 事務默認採用自動提交模式,如果不是顯式地開始一個事務,則每個查詢都將被當作一個事務執行提交操作。在當前連接中,可以通過設置 AUTOCOMMIT 變量來啓用或禁用自動提交模式。

1 或 ON 表示啓用,0 或 OFF表示禁用,當禁用自動提交時,所有的查詢都是在一個事務中,直到顯式地執行 COMMIT 或 ROLLBACK 後該事務纔會結束,同時又開始了一個新事務。修改 AUTOCOMMIT 對非事務型表,例如 MyISAM 或內存表不會有任何影響,對這類表來說沒有 COMMIT 或 ROLLBACK 的概念,也可以理解爲一直處於啓用自動提交的模式

有一些命令在執行之前會強制執行提交當前的活動事務,例如ALTER TABLELOCK TABLES等。

MySQL能夠識別所有的 4個 ANSI 隔離級別,InnoDB 引擎也支持所有隔離級別。


P4:MVCC 多版本併發控制

可以認爲 MVCC 是行級鎖的一個變種,但它在很多情況下避免了加鎖操作,因此開銷更低。雖然實現機制有所不同,但大都實現了非阻塞的讀操作,寫操作也只鎖定必要的行。

MVCC 的實現,是通過保存數據在某個時間點的快照來實現的。也就是說不管需要執行多長時間,每個事務看到的數據都是一致的。根據事務開始的時間不同,每個事務對同一張表,同一時刻看到的數據可能是不一樣的。

不同的存儲引擎的 MVCC 實現是不同的,典型的有樂觀併發控制和悲觀併發控制。

InnoDB 的 MVCC 實現

InnoDB 的MVCC 通過在每行記錄後面保存兩個隱藏的列來實現,這兩個列一個保存了行的創建時間,一個保存行的過期時間間。不過存儲的不是實際的時間值而是系統版本號,每開始一個新的事務系統版本號都會自動遞增,事務開始時刻的系統版本號會作爲事務的版本號,用來和查詢到的每行記錄的版本號進行比較。

REPEATABLE READ 級別下 MVCC 的具體實現

SELECT:InnoDB 會根據以下兩個條件檢查每行記錄:

  • 只查找版本早於當前事務版本的數據行,可以確保事務讀取的行要麼是事務開始前已經存在的,要麼是事物自身插入或修改過的。

  • 行的刪除版本要麼未定義,要麼大於當前事務版本號,可以確保事務讀取到的行在事務開始前未被刪除。

INSERT :爲新插入的每一行保存當前系統版本號作爲行版本號。

DELETE:爲刪除的每一行保存當前系統版本號作爲行刪除標識。

UPDATE:爲插入的每一行新記錄保存當前系統版本號作爲行版本號,同時保存當前系統版本號到原來的行作爲行刪除標識。

保存這兩個額外系統版本號使大多數讀操作都可以不用加鎖。這樣設計使讀數據操作簡單且高效,並且能保證只會讀取到符合標準的行。不足之處是每行記錄都需要額外存儲空間,需要做更多行檢查工作以及一些額外維護工作。

MVCC 只能在 READ COMMITTEDREPEATABLE READ 兩個隔離級別下工作,因爲 READ UNCOMMITTED 總是讀取最新的數據行,而不是符合當前事務版本的數據行,而 SERIALIZABLE 則會對所有讀取的行都加鎖。


P5:InnoDB 存儲引擎

InnoDB 是 MySQL 的默認事務型引擎,它被設計用來處理大量的短期事務。InnoDB 的性能和自動崩潰恢復特性,使得它在非事務型存儲需求中也很流行,除非有特別原因否則應該優先考慮 InnoDB 引擎。

InnoDB 的數據存儲在表空間中,表空間由一系列數據文件組成。MySQL4.1 後 InnoDB 可以將每個表的數據和索引放在單獨的文件中。

InnoDB 採用 MVCC 來支持高併發,並且實現了四個標準的隔離級別。其默認級別是 REPEATABLE READ,並且通過間隙鎖策略防止幻讀,間隙鎖使 InnoDB 不僅僅鎖定查詢涉及的行,還會對索引中的間隙進行鎖定防止幻行的插入。

InnoDB 表是基於聚簇索引建立的,InnoDB 的索引結構和其他存儲引擎有很大不同,聚簇索引對主鍵查詢有很高的性能,不過它的二級索引中必須包含主鍵列,所以如果主鍵很大的話其他所有索引都會很大,因此如果表上索引較多的話主鍵應當儘可能小。

InnoDB 的存儲格式是平臺獨立的,可以將數據和索引文件從一個平臺複製到另一個平臺。

InnoDB 內部做了很多優化,包括從磁盤讀取數據時採用的可預測性預讀,能夠自動在內存中創建加速讀操作的自適應哈希索引,以及能夠加速插入操作的插入緩衝區等。

選擇合適的存儲引擎

MySQL5.5 將 InnoDB 作爲默認存儲引擎,除非需要用到某些 InnoDB 不具備的特性,並且沒有其他方法可以代替,否則都應該優先選用InnoDB。

如果應用需要事務支持,那麼 InnoDB 是目前最穩定並且經過驗證的選擇。如果不需要事務並且主要是 SELECT 和 INSERT 操作,那麼MyISAM 是不錯的選擇。相對而言,MyISAM 崩潰後發生損壞的概率要比 InnoDB 大很多而且恢復速度也要慢,因此即使不需要事務支持,也可以選擇InnoDB。

如果可以定期地關閉服務器來執行備份,那麼備份的因素可以忽略。反之如果需要在線熱備份,那麼 InnoDB 就是基本的要求。


P6:MyISAM 存儲引擎

在 MySQL5.1及之前,MyISAM 是默認的存儲引擎,MyISAM 提供了大量的特性,包括全文索引、壓縮、空間函數等,但不支持事務和行鎖,最大的缺陷就是崩潰後無法安全恢復。對於只讀的數據或者表比較小、可以忍受修復操作的情況仍然可以使用 MyISAM。

MyISAM 將表存儲在數據文件和索引文件中,分別以 .MYD.MYI 作爲擴展名。MyISAM 表可以包含動態或者靜態行,MySQL 會根據表的定義決定行格式。MyISAM 表可以存儲的行記錄數一般受限於可用磁盤空間或者操作系統中單個文件的最大尺寸。

MyISAM 對整張表進行加鎖,讀取時會對需要讀到的所有表加共享鎖,寫入時則對錶加排它鎖。但是在表有讀取查詢的同時,也支持併發往表中插入新的記錄。

對於MyISAM 表,MySQL 可以手動或自動執行檢查和修復操作,這裏的修復和事務恢復以及崩潰恢復的概念不同。執行表的修復可能導致一些數據丟失,而且修復操作很慢。

對於 MyISAM 表,即使是 BLOB 和 TEXT 等長字段,也可以基於其前 500 個字符創建索引。MyISAM 也支持全文索引,這是一種基於分詞創建的索引,可以支持複雜的查詢。

創建 MyISAM 表時如果指定了 DELAY_KEY_WRITE 選項,在每次修改執行完成時不會立刻將修改的索引數據寫入磁盤,而是會寫到內存中的鍵緩衝區,只有在清理緩衝區或關閉表的時候纔會將對應的索引庫寫入磁盤。這種方式可以極大提升寫性能,但在數據庫或主機崩潰時會造成索引損壞,需要執行修復。延遲更新索引鍵的特性可以在全局設置也可以單個表設置。

MyISAM 設計簡單,數據以緊密格式存儲,所以在某些場景下性能很好。MyISAM 最典型的性能問題還是表鎖問題,如果所有的查詢長期處於 Locked 狀態,那麼原因毫無疑問就是表鎖。

P7:Memory 存儲引擎

如果需要快速訪問數據,並且這些數據不會被修改,重啓以後丟失也沒有關係,那麼使用 Memory 表是非常有用的。Memory 表至少要比 MyISAM 錶快一個數量級,因爲所有的數據都保存在內存中,不需要進行磁盤 IO,Memory 表的結構在重啓以後還會保留,但數據會丟失。

Memory 表適合的場景:查找或者映射表、緩存週期性聚合數據的結果、保存數據分析中產生的中間數據。

Memory 表支持哈希索引,因此查找速度極快。雖然速度很快但還是無法取代傳統的基於磁盤的表,Memory 表使用表級鎖,因此併發寫入的性能較低。它不支持 BLOB 和 TEXT 類型的列,並且每行的長度是固定的,所以即使指定了 VARCHAR 列,實際存儲時也會轉換成CHAR,這可能導致部分內存的浪費。

如果 MySQL 在執行查詢的過程中需要使用臨時表來保持中間結果,內部使用的臨時表就是 Memory 表。如果中間結果太大超出了Memory 表的限制,或者含有 BLOB 或 TEXT 字段,臨時表會轉換成 MyISAM 表。


P8:數據類型

整數類型

如果存儲整數可以使用這幾種整數類型:TINYINT、SMALLINT、MEDIUMINT、INT,BIGINT,它們分別使用8、16、24、32、64 位存儲空間。

整數類型有可選的 UNSIGNED 屬性,表示不允許負值,可以使整數的上限提高一倍。有符號和無符號類型使用相同的存儲空間並具有相同的性能,可以根據實際情況選擇合適的類型。

MySQL 可以爲整數類型指定寬度,例如 INT(11),這對大多數應用沒有意義,不會限制值的範圍,只是規定了 MySQL 的交互工具顯示字符的個數,對於存儲和計算來說 INT(1) 和 INT(11) 是相同的。

實數類型

實數是帶有小數部分的數字,但它們不只是爲了存儲小數,也可以使用 DECIMAL 存儲比 BIGINT 還大的整數。MySQL既支持精確類型,也支持不精確類型。

FLOAT 和 DOUBLE 支持使用標準的浮點運算進行近似運算,DECIMAL 用於存儲精確的小數。

浮點類型在存儲同樣範圍的值時,通常比 DECIMAL 使用更少的空間。FLOAT 使用 4 字節存儲,DOUBLE 佔用8字節,MySQL 內部使用DOUBLE 作爲內部浮點計算的類型。

因爲需要額外空間和計算開銷,所以應當儘量只在對小數進行精確計算時才使用 DECIMAL。在數據量較大時可以考慮 BIGINT 代替DECIMAL,將需要存儲的貨幣單位根據小數的位數乘以相應的倍數即可。假設要存儲的數據精確到萬分之一分,則可以把所有金額乘以一百萬將結果存儲在 BIGINT 中,這樣可以同時避免浮點存儲計算不精確和 DECIMAL 精確計算代價高的問題。

VARCHAR

VARCHAR 用於存儲可變字符串,是最常見的字符串數據類型。它比定長字符串更節省空間,因爲它僅使用必要的空間。VARCHAR 需要 1或 2 個額外字節記錄字符串長度,如果列的最大長度不大於 255 字節則只需要1 字節。VARCHAR 不會刪除末尾空格。

VARCHAR 節省了存儲空間,但由於行是變長的,在 UPDATE 時可能使行變得比原來更長,這就導致需要做額外的工作。如果一個行佔用的空間增長並且頁內沒有更多的空間可以存儲,這種情況下不同存儲引擎處理不同,InnoDB 會分裂頁而 MyISAM 會將行拆分成不同片。

適用場景:字符串列的最大長度比平均長度大很多、列的更新很少、使用了 UTF8 這種複雜字符集,每個字符都使用不同的字節數存儲。

InnoDB 可以把過長的 VARCHAR 存儲爲 BLOB。

CHAR

CHAR 是定長的,根據定義的字符串長度分配足夠的空間。CHAR 會刪除末尾空格。

CHAR 適合存儲很短的字符串,或所有值都接近同一個長度,例如存儲密碼的 MD5 值。對於經常變更的數據,CHAR 也比 VARCHAR更好,因爲定長的 CHAR 不容易產生碎片。對於非常短的列,CHAR 在存儲空間上也更有效率,例如用 CHAR 來存儲只有 Y 和 N 的值只需要一個字節,但是 VARCHAR 需要兩個字節,因爲還有一個記錄長度的額外字節。

BLOB 和 TEXT 類型

BLOB 和TEXT 都是爲了存儲大數據而設計的字符串數據類型,分別採用二進制和字符串方式存儲。MySQL會把每個 BLOB 和 TEXT 值當作一個獨立的對象處理,存儲引擎在存儲時通常會做特殊處理。當值太大時,InnoDB 會使用專門的外部存儲區來進行存儲。BLOB 和TEXT 僅有的不同是 BLOB 存儲的是二進制數據,沒有排序規則或字符集,而 TEXT 有字符集和排序規則。

MySQL 對 BLOB 和TEXT 列進行排序與其他類型不同:它只對每個列最前 max_sort_length 字節而不是整個字符串做排序,如果只需要排序前面一小部分字符,則可以減小 max_sort_length 的配置。MySQL 不能將 BLOB 和 TEXT 列全部長度的字符串進行索引,也不能使用這些索引消除排序。

DATETIME

這個類型能保存大範圍的值,從 1001 年到 9999 年,精度爲秒。它把日期和時間封裝到了一個整數中,與時區無關,使用 8 字節的存儲空間。

TIMESTAMP

它和 UNIX 時間戳相同。TIMESTAMP 只使用 4 字節的存儲空間,因此它的範圍比DATETIME 小得多,只能表示1970年到2038年,並且依賴於時區。通常應該選擇 TIMESTAMP,因爲它比 DATETIME 空間效率更高。


P9:索引的分類

索引在也叫做鍵,是存儲引擎用於快速找到記錄的一種數據結構。索引對於良好的性能很關鍵,尤其是當表中數據量越來越大時,索引對性能的影響愈發重要。在數據量較小且負載較低時,不恰當的索引對性能的影響可能還不明顯,但數據量逐漸增大時,性能會急劇下降。

索引大大減少了服務器需要掃描的數據量、可以幫助服務器避免排序和臨時表、可以將隨機 IO 變成順序 IO。但索引並不總是最好的工具,對於非常小的表,大部分情況下會採用全表掃描。對於中到大型的表,索引就非常有效。但對於特大型的表,建立和使用索引的代價也隨之增長,這種情況下應該使用分區技術。

在MySQL中,首先在索引中找到對應的值,然後根據匹配的索引記錄找到對應的數據行。索引可以包括一個或多個列的值,如果索引包含多個列,那麼列的順序也十分重要,因爲 MySQL 只能高效地使用索引的最左前綴列。

B-Tree 索引

大多數 MySQL 引擎都支持這種索引,使用術語 B-Tree 是因爲 MySQL 在 CREATE TABLE 和其他語句中也使用該關鍵字。不過底層的存儲引擎可能使用不同的存儲結構,例如 NDB 集羣實際使用 T-Tree,而 InnoDB 則使用 B+Tree。

存儲引擎以不同方式使用 B-Tree 索引,性能也不同。例如 MyISAM 使用前綴壓縮技術使得索引更小,但 InnoDB 則按照原數據格式進行存儲。再例如 MyISAM 索引通過數據的物理位置引用被索引的行,而 InnoDB 則根據主鍵引用被索引的行。

B-Tree 通常意味着所有的值都是按順序存儲的,並且每個葉子頁到根的距離相同。B-Tree 索引能夠加快訪問數據的速度,因爲存儲引擎不再需要進行全表掃描來獲取需要的數據,取而代之的是從索引的根節點開始進行搜索。根節點的槽中存放了指向子節點的指針,存儲引擎根據這些指針向下層查找。通過比較節點頁的值和要查找的值可以找到合適的指針進入下層子節點,這些指針實際上定義了子節點頁中值的上限和下限。最終存儲引擎要麼找到對應的值,要麼該記錄不存在。葉子節點的指針指向的是被索引的數據,而不是其他的節點頁。

B-Tree索引適用於全鍵值、鍵值範圍或鍵前綴查找,其中鍵前綴查找只適用於最左前綴查找。索引對如下類型的查詢有效:

  • 全值匹配:全值匹配指的是和索引中的所有列進行匹配。
  • 匹配最左前綴:只使用索引的第一列。
  • 匹配列前綴:只匹配某一列的值的開頭部分。
  • 匹配範圍值:查找某兩個值之間的範圍。
  • 精確匹配某一列並範圍匹配另一列:有一列全匹配而另一列範圍匹配。
  • 只訪問索引的查詢:B-Tree 通常可以支持只訪問索引的查詢,即查詢只需要訪問索引而無需訪問數據行。

因爲索引樹中的節點有序,所以除了按值查找之外索引還可以用於查詢中的 ORDER BY 操作。一般如果 B-Tree 可以按照某種方式查找到值,那麼也可以按照這種方式排序。

B-Tree索引的限制:

  • 如果不是按照索引的最左列開始查找,則無法使用索引。
  • 不能跳過索引中的列,例如索引爲 (id,name,sex),不能只使用 id 和 sex 而跳過 name。
  • 如果查詢中有某個列的範圍查詢,則其右邊的所有列都無法使用索引。

哈希索引

哈希索引基於哈希表實現,只有精確匹配索引所有列的查詢纔有效。對於每一行數據,存儲引擎都會對所有的索引列計算一個哈希碼,哈希碼是一個較小的值,並且不同鍵值的行計算出的哈希碼也不一樣。哈希索引將所有的哈希碼存儲在索引中,同時在哈希表中保存指向每個數據行的指針。

只有 Memory 引擎顯式支持哈希索引,這也是 Memory 引擎的默認索引類型。

因爲索引自身只需存儲對應的哈希值,所以索引的結構十分緊湊,這讓哈希索引的速度非常快,但它也有一些限制:

  • 哈希索引只包含哈希值和行指針而不存儲字段值,所以不能使用索引中的值來避免讀取行。
  • 哈希索引數據並不是按照索引值順序存儲的,因此無法用於排序。
  • 哈希索引不支持部分索引列匹配查找,因爲哈希索引始終是使用索引列的全部內容來計算哈希值的。例如在數據列(a,b)上建立哈希索引,如果查詢的列只有a就無法使用該索引。
  • 哈希索引只支持等值比較查詢,不支持任何範圍查詢。
  • 訪問哈希索引的數據非常快,除非有很多哈希衝突。當出現哈希衝突時,存儲引擎必須遍歷鏈表中所有的行指針,逐行進行比較直到找到所有符合條件的行。
  • 如果哈希衝突很高的話,索引維護的代價也會很高。

自適應哈希索引是 InnoDB 引擎的一個特殊功能,當它注意到某些索引值被使用的非常頻繁時,會在內存中基於 B-Tree 索引之上再創鍵一個哈希索引,這樣就讓 B-Tree 索引也具有哈希索引的一些優點,比如快速哈希查找。這是一個完全自動的內部行爲,用戶無法控制或配置,但如果有必要可以關閉該功能。

如果存儲引擎不支持哈希索引,可以創建自定義哈希索引,在 B-Tree基礎 上創建一個僞哈希索引,它使用哈希值而不是鍵本身進行索引查找,需要在查詢的 WHERE 子句中手動指定哈希函數。當數據表非常大時,CRC32 會出現大量的哈希衝突,可以考慮自己實現 64 位哈希函數,或者使用 MD5 函數返回值的一部分作爲自定義哈希函數。

空間索引

MyISAM 表支持空間索引,可以用作地理數據存儲。和 B-Tree 索引不同,這類索引無需前綴查詢。空間索引會從所有維度來索引數據,查詢時可以有效地使用任意維度來組合查詢。必須使用 MySQL 的 GIS 即地理信息系統的相關函數來維護數據,但 MySQL 對 GIS 的支持並不完善,因此大部分人都不會使用這個特性。

全文索引

通過數值比較、範圍過濾等就可以完成絕大多數需要的查詢,但如果希望通過關鍵字的匹配進行查詢過濾,那麼就需要基於相似度的查詢,而不是精確的數值比較,全文索引就是爲這種場景設計的。全文索引有自己獨特的語法,沒有索引也可以工作,如果有索引效率會更高。

全文索引可以支持各種字符內容的搜索,包括 CHAR、VARCHAR 和 TEXT 類型,也支持自然語言搜索和布爾搜索。在 MySQL 中全文索引有很多限制,例如表鎖對性能的影響、數據文件的崩潰恢復等,這使得 MyISAM 的全文索引對很多應用場景並不合適。MyISAM 的全文索引作用對象是一個"全文集合",可能是某個數據表的一列,也可能是多個列。具體的對某一條記錄,MySQL 會將需要索引的列全部拼接成一個字符串然後進行索引。

MyISAM 的全文索引是一種特殊的 B-Tree 索引,一共有兩層。第一層是所有關鍵字,然後對於每一個關鍵字的第二層,包含的是一組相關的"文檔指針"。全文索引不會索引文檔對象中的所有詞語,它會根據規則過濾掉一些詞語,例如停用詞列表中的詞都不會被索引。

聚簇索引

聚簇索引並不是一種單獨的索引類型,而是一種數據存儲方式。InnoDB 的聚簇索引實際上在同一個結構中保存了 B-Tree 索引和數據行。當表有聚餐索引時,它的行數據實際上存放在索引的葉子頁中,因爲無法同時把數據行存放在兩個不同的地方,所以一個表只能有一個聚簇索引。

優點:① 可以把相關數據保存在一起,例如實現電子郵箱時可以根據用戶 ID 聚集數據,這樣只需要從磁盤讀取少數數據頁就能獲取某個用戶的全部郵件,如果沒有使用聚簇索引,每封郵件可能都導致一次磁盤 IO。② 數據訪問更快,聚簇索引將索引和數據保存在同一個 B-Tree 中,因此獲取數據比非聚簇索引要更快。③ 使用覆蓋索引掃描的查詢可以直接使用頁節點中的主鍵值。

缺點:① 聚簇索引最大限度提高了 IO 密集型應用的性能,如果數據全部在內存中將會失去優勢。② 插入速度驗證依賴於插入順序,按照主鍵的順序插入是加載數據到 InnoDB 引擎最快的方式。③ 更新聚簇索引列的代價很高,因爲會強制每個被更新的行移動到新位置。④ 基於聚簇索引的表插入新行或主鍵被更新導致行移動時,可能導致頁分裂,表會佔用更多磁盤空間。④ 當行稀疏或由於頁分裂導致數據存儲不連續時,全表掃描可能很慢。

覆蓋索引

覆蓋索引指一個索引包含或覆蓋了所有需要查詢的字段的值,不再需要根據索引回表查詢數據。覆蓋索引必須要存儲索引列的值,因此 MySQL 只能使用 B-Tree 索引做覆蓋索引。

優點:① 索引條目通常遠小於數據行大小,可以極大減少數據訪問量。② 因爲索引按照列值順序存儲,所以對於 IO 密集型防僞查詢迴避隨機從磁盤讀取每一行數據的 IO 少得多。③ 由於 InnoDB 使用聚簇索引,覆蓋索引對 InnoDB 很有幫助。InnoDB 的二級索引在葉子節點保存了行的主鍵值,如果二級主鍵能覆蓋查詢那麼可以避免對主鍵索引的二次查詢。


P10:索引使用原則

建立索引

對查詢頻次較高,且數據量比較大的表建立索引。索引字段的選擇,最佳候選列應當從 WHERE 子句的條件中提取,如果 WHERE 子句中的組合比較多,那麼應當挑選最常用、過濾效果最好的列的組合。

使用前綴索引

索引列開始的部分字符,索引創建後也是使用硬盤來存儲的,因此短索引可以提升索引訪問的 IO 效率。對於 BLOB、TEXT 或很長的 VARCHAR 列必須使用前綴索引,MySQL 不允許索引這些列的完整長度。前綴索引是一種能使索引更小更快的有效方法,但缺點是 MySQL 無法使用前綴索引做 ORDER BY 和 GROUP BY,也無法使用前綴索引做覆蓋掃描。

選擇合適的索引順序

當不需要考慮排序和分組時,將選擇性最高的列放在前面。索引的選擇性是指不重複的索引值和數據表的記錄總數之比,索引的選擇性越高則查詢效率越高,唯一索引的選擇性是 1,因此也可以使用唯一索引提升查詢效率。

刪除無用索引

MySQL 允許在相同列上創建多個索引,重複的索引需要單獨維護,並且優化器在優化查詢時也需要逐個考慮,這會影響性能。重複索引是指在相同的列上按照相同的順序創建的相同類型的索引,應該避免創建重複索引。如果創建了索引 (A,B) 再創建索引 (A) 就是冗餘索引,因爲這只是前一個索引的前綴索引,對於 B-Tree 索引來說是冗餘的。解決重複索引和冗餘索引的方法就是刪除這些索引。除了重複索引和冗餘索引,可能還會有一些服務器永遠不用的索引,也應該考慮刪除。

減少碎片

B-Tree 索引可能會碎片化,碎片化的索引可能會以很差或無序的方式存儲在磁盤上,這會降低查詢的效率。表的數據存儲也可能碎片化,包括行碎片、行間碎片、剩餘空間碎片,對於 MyISAM 這三類碎片化都有可能發生,對於 InnoDB 不會出現短小的行碎片,它會移動短小的行重寫到一個片段中。可以通過執行 OPTIMIZE TABLE 或者導出再導入的方式重新整理數據,對於 MyISAM 可以通過排序重建索引消除碎片。InnoDB 可以通過先刪除再重新創建索引的方式消除索引碎片。

索引失效情況

如果索引列出現了隱式類型轉換,則 MySQL 不會使用索引。常見的情況是在 SQL 的 WHERE 條件中字段類型爲字符串,其值爲數值,如果沒有加引號那麼 MySQL 不會使用索引。

如果 WHERE 條件中含有 OR,除非 OR 前使用了索引列而 OR 之後是非索引列,索引會失效。

MySQL 不能在索引中執行 LIKE 操作,這是底層存儲引擎 API 的限制,最左匹配的 LIKE 比較會被轉換爲簡單的比較操作,但如果是以通配符開頭的 LIKE 查詢,存儲引擎就無法做筆記。這種情況下 MySQL 服務器只能提取數據行的值而不是索引值來做比較。

如果查詢中的列不是獨立的,則 MySQL 不會使用索引。獨立的列是指索引列不能是表達式的一部分,也不能是函數的參數。

對於多個範圍條件查詢,MySQL 無法使用第一個範圍列後面的其他索引列,對於多個等值查詢則沒有這種限制。

如果 MySQL 判斷全表掃描比使用索引查詢更快,則不會使用索引。


P11:優化數據類型

更小的通常更好

一般情況下儘量使用可以正確存儲數據的最小數據類型,更小的數據類型通常也更快,因爲它們佔用更少的磁盤、內存和 CPU 緩存。

儘可能簡單

簡單數據類型的操作通常需要更少的 CPU 週期,例如整數比字符操作代價更低,因爲字符集和校對規則使字符相比整形更復雜。應該使用 MySQL 的內建類型 date、time 和 datetime 而不是字符串來存儲日期和時間,另一點是應該使用整形存儲 IP 地址。

儘量避免 NULL

通常情況下最好指定列爲 NOT NULL,除非需要存儲 NULL值。因爲如果查詢中包含可爲 NULL 的列對 MySQL 來說更難優化,可爲 NULL 的列使索引、索引統計和值比較都更復雜,並且會使用更多存儲空間。當可爲 NULL 的列被索引時,每個索引記錄需要一個額外字節,在MyISAM 中還可能導致固定大小的索引變成可變大小的索引。

通常把可爲 NULL 的列設置爲 NOT NULL 帶來的性能提升較小,因此調優時沒必要首先查找並修改這種情況。但如果計劃在列上建索引,就應該儘量避免設計成可爲 NULL 的列。

在爲列選擇數據類型時,第一步需要確定合適的大類型:數字、字符串、時間等。下一步是選擇具體類型,很多 MySQL 數據類型可以存儲相同類型的數據,只是存儲的長度和範圍不一樣,允許的精度不同或需要的物理空間不同。


P12:優化查詢概述

優化數據訪問

如果把查詢看作一個任務,那麼它由一系列子任務組成,每個子任務都會消耗一定時間。如果要優化查詢,要麼消除一些子任務,要麼減少子任務的執行次數。查詢性能低下最基本的原因是訪問的數據太多,大部分性能低下的查詢都可以通過減少訪問的數據量進行優化。可以通過以下兩個步驟分析。

是否向數據庫請求了不需要的數據:有些查詢會請求超過實際需要的數據,然後這些多餘的數據會被應用程序丟棄,這會給 MySQL 服務器造成額外負擔並增加網絡開銷,另外也會消耗應用服務器的 CPU 和內存資源。例如多表關聯時返回全部列,取出全部列會讓優化器無法完成索引覆蓋掃描這類優化,還會爲服務器帶來額外的 IO、內存和 CPU 的消耗,因此使用 SELECT * 時需要仔細考慮是否真的需要返回全部列。再例如總是重複查詢相同的數據,比較好的解決方案是初次查詢時將數據緩存起來,需要的時候從緩存中取出。

MySQL 是否在掃描額外的記錄:在確定查詢只返回需要的數據後,應該看看查詢爲了返回結果是否掃描了過多的數據,最簡單的三個衡量指標時響應時間、掃描的行數和返回的行數。如果發現查詢需要掃描大量數據但只返回少數的行,可以使用以下手動優化:① 使用覆蓋索引掃描,把所有需要用的列都放到索引中,這樣存儲引擎無需回表查詢對應行就可以返回結果。② 改變庫表結構。 ③ 重寫這個複雜的查詢,讓 MySQL 優化器能夠以更優化的方式執行這個查詢。

重構查詢方式

在優化有問題的查詢時,目標應該是找到一個更優的方法獲取實際需要的結果,而不一定總是需要從 MySQL 獲取一模一樣的結果集。

切分查詢:有時候對於一個大查詢可以將其切分成小查詢,每個查詢功能完全一樣,只完成一小部分,每次只返回一小部分查詢結果。例如刪除舊數據,定期清除大量數據時,如果用一個大的語句一次性完成的話可能需要一次鎖住很多數據、佔滿整個事務日誌、耗盡系統資源、阻塞很多小的但重要的查詢。將一個大的 DELETE 語句切分成多個較小的查詢可以儘可能小地影響 MySQL 的性能,同時還可以減少MySQL 複製的延遲。

分解關聯查詢:很多高性能應用都會對關聯查詢進行分解,可以對每一個表進行單表查詢,然後將結果在應用程序中進行關聯。分解關聯查詢可以讓緩存的效率更高、減少鎖的競爭、提升查詢效率、還可以減少冗餘記錄的查詢。


P13:查詢執行流程

簡單來說分爲五步:① 客戶端發送一條查詢給服務器。② 服務器先檢查查詢緩存,如果命中了緩存則立刻返回存儲在緩存中的結果,否則進入下一階段。③ 服務器端進行 SQL 解析、預處理,再由優化器生成對應的執行計劃。④ MySQL 根據優化器生成的執行計劃,調用存儲引擎的 API 來執行查詢。⑤ 將結果返回給客戶端。

查詢緩存

在解析一個查詢語句之前,如果查詢緩存是打開的,那麼 MySQL 會優先檢查這個查詢是否命中查詢緩存中的數據。這個檢查是通過一個對大小寫敏感的哈希查找實現的。查詢和緩存中的查詢即使只有一個字節不同,也不會匹配緩存結果,這種情況下會進行下一個階段的處理。如果當前的查詢恰好命中了查詢緩存,那麼在返回查詢結果之前 MySQL 會檢查一次用戶權限。如果權限沒有問題,MySQL 會跳過其他階段,直接從緩衝中拿到結果並返回給客戶端,這種情況下查詢不會被解析,不用生成執行計劃,不會被執行。

查詢優化處理

該階段包括多個子階段:解析 SQL、預處理、優化 SQL 執行計劃。首先 MySQL 通過關鍵字將 SQL 語句進行解析,並生成一顆對應的解析樹,MySQL 解析器將使用 MySQL 語法規則驗證和解析查詢。例如它將驗證是否使用了錯誤的關鍵字,或者使用關鍵字的順序是否正確等。預處理器則根據一些 MySQL 規則進一步檢查解析樹是否合法,例如檢查數據表和數據列是否存在,還會解析名字和別名看它們是否有歧義。下一步預處理器會驗證權限,這一步通常很快,除非服務器上有非常多的權限配置。

語法樹被認爲合法後,查詢優化器將其轉成執行計劃。一條查詢可以有多種查詢方式,最後都返回相同的結果,優化器的作用就是找到這其中最好的執行計劃。MySQL 使用基於成本的優化器,它將嘗試預測一個查詢使用某種執行計劃時的成本,並選擇其中成本最小的一個。優化策略可以簡單分爲兩種,一種是靜態優化,可以直接對解析樹分析並完成優化,不依賴於特別的數值,可以認爲是一種編譯時優化。另一種是動態優化,和查詢的上下文有關,每次查詢時都需要重新評估。

MySQL 可以處理的優化類型包括:重新定義表的關聯順序、將外連接轉化成內連接、使用等價變換規則、優化 COUNT() 和 MIN() 以及 MAX() 函數、預估並轉爲常數表達式、覆蓋索引掃描、子查詢優化等。

查詢執行引擎

在解析和優化階段,MySQL 將生成查詢對應的執行計劃,MySQL 的查詢執行引擎則根據這個計劃來完成整個查詢。執行計劃是一個數據結構,而不是其他關係型數據庫那樣會生成對應的字節碼。查詢執行階段並不複雜,MySQL 只是簡單的根據執行計劃給出的指令逐步執行,再根據執行計劃執行的過程中,有大量操作需要通過調用存儲引擎實現的接口來完成。

返回結果給客戶端

查詢執行的最後一個階段是將結果返回給客戶端,即使查詢不需要返回結果集,MySQL 仍然會返回這個查詢的一些信息,如該查詢影響到的行數。如果查詢可以被緩存,那麼 MySQL 會在這個階段將結果存放到查詢緩衝中。MySQL 將結果集返回客戶端是一個增量、逐步返回的過程,這樣做的好處是服務器無需存儲太多的結果,減少內存消耗,也可以讓客戶端第一時間獲得響應結果。結果集中的每一行給都會以一個滿足 MySQL 客戶端/服務器通信協議的包發送,再通過 TCP 協議進行傳輸,在 TCP 傳輸過程中可能對包進行緩存然後批量傳輸。


P14:優化 SQL

優化 COUNT 查詢

COUNT 是一個特殊的函數,它可以統計某個列值的數量,在統計列值時要求列值是非空的,不會統計 NULL 值。如果在 COUNT 中指定了列或列的表達式,則統計的就是這個表達式有值的結果數,而不是 NULL。

COUNT 的另一個作用是統計結果集的行數,當 MySQL 確定括號內的表達式不可能爲 NULL 時,實際上就是在統計行數。當使用 COUNT(*) 時,* 不會擴展成所有列,它會忽略所有的列而直接統計所有的行數。

某些業務場景並不要求完全精確的 COUNT 值,此時可以使用近似值來代替,EXPLAIN 出來的優化器估算的行數就是一個不錯的近似值,因爲執行 EXPLAIN 並不需要真正地執行查詢。

通常來說 COUNT 都需要掃描大量的行才能獲取精確的結果,因此很難優化。在 MySQL 層還能做的就只有覆蓋掃描了,如果還不夠就需要修改應用的架構,可以增加彙總表或者外部緩存系統。

優化關聯查詢

確保 ON 或 USING 子句中的列上有索引,在創建索引時就要考慮到關聯的順序。

確保任何 GROUP BY 和 ORDER BY 的表達式只涉及到一個表中的列,這樣 MySQL 纔有可能使用索引來優化這個過程。

在 MySQL 5.5 及以下版本儘量避免子查詢,可以用關聯查詢代替,因爲執行器會先執行外部的 SQL 再執行內部的 SQL。

優化 GROUP BY

如果沒有通過 ORDER BY 子句顯式指定要排序的列,當查詢使用 GROUP BY 子句的時候,結果集會自動按照分組的字段進行排序,如果不關心結果集的順序,可以使用 ORDER BY NULL 禁止排序。

優化 LIMIT 分頁

在偏移量非常大的時候,需要查詢很多條數據再捨棄,這樣的代價非常高。要優化這種查詢,要麼是在頁面中限制分頁的數量,要麼是優化大偏移量的性能。最簡單的辦法是儘可能地使用覆蓋索引掃描,而不是查詢所有的列,然後根據需要做一次關聯操作再返回所需的列。

還有一種方法是從上一次取數據的位置開始掃描,這樣就可以避免使用 OFFSET。其他優化方法還包括使用預先計算的彙總表,或者關聯到一個冗餘表,冗餘表只包含主鍵列和需要做排序的數據列。

優化 UNION 查詢

MySQL 通過創建並填充臨時表的方式來執行 UNION 查詢,除非確實需要服務器消除重複的行,否則一定要使用 UNION ALL,如果沒有 ALL 關鍵字,MySQL 會給臨時表加上 DISTINCT 選項,這會導致對整個臨時表的數據做唯一性檢查,這樣做的代價非常高。

使用用戶自定義變量

在查詢中混合使用過程化和關係化邏輯的時候,自定義變量可能會非常有用。用戶自定義變量是一個用來存儲內容的臨時容器,在連接 MySQL 的整個過程中都存在,可以在任何可以使用表達式的地方使用自定義變量。例如可以使用變量來避免重複查詢剛剛更新過的數據、統計更新和插入的數量等。

優化 INSERT

需要對一張表插入很多行數據時,應該儘量使用一次性插入多個值的 INSERT 語句,這種方式將縮減客戶端與數據庫之間的連接、關閉等消耗,效率比多條插入單個值的 INSERT 語句高。也可以關閉事務的自動提交,在插入完數據後提交。當插入的數據是按主鍵的順序插入時,效率更高。


P15:複製

複製解決的基本問題是讓一臺服務器的數據與其他服務器保持同步,一臺主庫的數據可以同步到多臺備庫上,備庫本身也可以被配置成另外一臺服務器的主庫。主庫和備庫之間可以有多種不同的組合方式。

MySQL 支持兩種複製方式:基於行的複製和基於語句的複製,基於語句的複製也稱爲邏輯複製,從 MySQL 3.23 版本就已存在,基於行的複製方式在 5.1 版本才被加進來。這兩種方式都是通過在主庫上記錄二進制日誌、在備庫重放日誌的方式來實現異步的數據複製。因此同一時刻備庫的數據可能與主庫存在不一致,並且無法包裝主備之間的延遲。

MySQL 複製大部分是向後兼容的,新版本的服務器可以作爲老版本服務器的備庫,但是老版本不能作爲新版本服務器的備庫,因爲它可能無法解析新版本所用的新特性或語法,另外所使用的二進制文件格式也可能不同。

複製解決的問題:數據分佈、負載均衡、備份、高可用性和故障切換、MySQL 升級測試。

複製步驟

概述:① 在主庫上把數據更改記錄到二進制日誌中。② 備庫將主庫的日誌複製到自己的中繼日誌中。 ③ 備庫讀取中繼日誌中的事件,將其重放到備庫數據之上。

第一步是在主庫上記錄二進制日誌,每次準備提交事務完成數據更新前,主庫將數據更新的事件記錄到二進制日誌中。MySQL 會按事務提交的順序而非每條語句的執行順序來記錄二進制日誌,在記錄二進制日誌後,主庫會告訴存儲引擎可以提交事務了。

下一步,備庫將主庫的二進制日誌複製到其本地的中繼日誌中。備庫首先會啓動一個工作的 IO 線程,IO 線程跟主庫建立一個普通的客戶端連接,然後在主庫上啓動一個特殊的二進制轉儲線程,這個線程會讀取主庫上二進制日誌中的事件。它不會對事件進行輪詢。如果該線程追趕上了主庫將進入睡眠狀態,直到主庫發送信號量通知其有新的事件產生時纔會被喚醒,備庫 IO 線程會將接收到的事件記錄到中繼日誌中。

備庫的 SQL 線程執行最後一步,該線程從中繼日誌中讀取事件並在備庫執行,從而實現備庫數據的更新。當 SQL 線程追趕上 IO 線程時,中繼日誌通常已經在系統緩存中,所以中繼日誌的開銷很低。SQL 線程執行的時間也可以通過配置選項來決定是否寫入其自己的二進制日誌中。

這種複製架構實現了獲取事件和重放事件的解耦,允許這兩個過程異步進行,也就是說 IO 線程能夠獨立於 SQL 線程工作。但這種架構也限制了複製的過程,在主庫上併發允許的查詢在備庫只能串行化執行,因爲只有一個 SQL 線程來重放中繼日誌中的事件。

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