Java 面試知識點【背誦版 240題 約7w字】

—— 轉載自牛客網 是瑤瑤公主吖

Java 基礎 40

語言特性 12

Q1:Java 語言的優點?

① 平台無關性,擺脫硬件束縛,”一次編寫,到處運行”。

② 相對安全的內存管理和訪問機制,避免大部分內存泄漏和指針越界。

③ 熱點代碼檢測和運行時編譯及優化,使程序隨運行時間增長獲得更高性能。

④ 完善的應用程序接口,支持第三方類庫。

Q2:Java 如何實現平台無關?

JVM: Java 編譯器可生成與計算機體系結構無關的位元組碼指令,位元組碼文件不僅可以輕易地在任何機器上解釋執行,還可以動態地轉換成本地機器代碼,轉換是由 JVM 實現的,JVM 是平台相關的,屏蔽了不同操作系統的差異。

語言規範: 基本數據類型大小有明確規定,例如 int 永遠為 32 位,而 C/C++ 中可能是 16 位、32 位,也可能是編譯器開發商指定的其他大小。Java 中數值類型有固定位元組數,二進制數據以固定格式存儲和傳輸,字符串採用標準的 Unicode 格式存儲。

Q3:JDK 和 JRE 的區別?

JDK: Java Development Kit,開發工具包。提供了編譯運行 Java 程序的各種工具,包括編譯器、JRE 及常用類庫,是 JAVA 核心。

JRE: Java Runtime Environment,運行時環境,運行 Java 程序的必要環境,包括 JVM、核心類庫、核心配置工具。

Q4:Java 按值調用還是引用調用?

按值調用指方法接收調用者提供的值,按引用調用指方法接收調用者提供的變量地址。

Java 總是按值調用,方法得到的是所有參數值的副本,傳遞對象時實際上方法接收的是對象引用的副本。方法不能修改基本數據類型的參數,如果傳遞了一個 int 值 ,改變值不會影響實參,因為改變的是值的一個副本。

可以改變對象參數的狀態,但不能讓對象參數引用一個新的對象。如果傳遞了一個 int 數組,改變數組的內容會影響實參,而改變這個參數的引用並不會讓實參引用新的數組對象。

Q5:淺拷貝和深拷貝的區別?

淺拷貝: 只複製當前對象的基本數據類型及引用變量,沒有複製引用變量指向的實際對象。修改克隆對象可能影響原對象,不安全。

深拷貝: 完全拷貝基本數據類型和引用數據類型,安全。

Q6:什麼是反射?

在運行狀態中,對於任意一個類都能知道它的所有屬性和方法,對於任意一個對象都能調用它的任意方法和屬性,這種動態獲取信息及調用對象方法的功能稱為反射。缺點是破壞了封裝性以及泛型約束。反射是框架的核心,Spring 大量使用反射。

Q7:Class 類的作用?如何獲取一個 Class 對象?

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

獲取 Class 對象:① 類名.class 。②對象的 getClass方法。③ Class.forName(類的全限定名)

Q8:什麼是註解?什麼是元註解?

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

元註解是自定義註解的註解,例如:

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

@Rentention:約束生命周期,值是 RetentionPolicy 枚舉常量,包括 SOURCE 源碼、CLASS 位元組碼和 RUNTIME 運行時。

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

Q9:什麼是泛型,有什麼作用?

泛型本質是參數化類型,解決不確定對象具體類型的問題。泛型在定義處只具備執行 Object 方法的能力。

泛型的好處:① 類型安全,放置什麼出來就是什麼,不存在 ClassCastException。② 提升可讀性,編碼階段就顯式知道泛型集合、泛型方法等處理的對象類型。③ 代碼重用,合併了同類型的處理代碼。

Q10:泛型擦除是什麼?

泛型用於編譯階段,編譯後的位元組碼文件不包含泛型類型信息,因為虛擬機沒有泛型類型對象,所有對象都屬於普通類。例如定義 List<Object>List<String>,在編譯後都會變成 List

定義一個泛型類型,會自動提供一個對應原始類型,類型變量會被擦除。如果沒有限定類型就會替換為 Object,如果有限定類型就會替換為第一個限定類型,例如 <T extends A & B> 會使用 A 類型替換 T。

Q11:JDK8 新特性有哪些?

lambda 表達式:允許把函數作為參數傳遞到方法,簡化匿名內部類代碼。

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

方法引用:可以引用已有類或對象的方法和構造方法,進一步簡化 lambda 表達式。

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

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

類型推測:加強了類型推測機制,使代碼更加簡潔。

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

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

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

JavaScript:提供了一個新的 JavaScript 引擎,允許在 JVM上運行特定 JavaScript 應用。

Q12:異常有哪些分類?

所有異常都是 Throwable 的子類,分為 Error 和 Exception。Error 是 Java 運行時系統的內部錯誤和資源耗盡錯誤,例如 StackOverFlowError 和 OutOfMemoryError,這種異常程序無法處理。

Exception 分為受檢異常和非受檢異常,受檢異常需要在代碼中顯式處理,否則會編譯出錯,非受檢異常是運行時異常,繼承自 RuntimeException。

受檢異常:① 無能為力型,如字段超長導致的 SQLException。② 力所能及型,如未授權異常 UnAuthorizedException,程序可跳轉權限申請頁面。常見受檢異常還有 FileNotFoundException、ClassNotFoundException、IOException等。

非受檢異常:① 可預測異常,例如 IndexOutOfBoundsException、NullPointerException、ClassCastException 等,這類異常應該提前處理。② 需捕捉異常,例如進行 RPC 調用時的遠程服務超時,這類異常客戶端必須顯式處理。③ 可透出異常,指框架或系統產生的且會自行處理的異常,例如 Spring 的 NoSuchRequestHandingMethodException,Spring 會自動完成異常處理,將異常自動映射到合適的狀態碼。

數據類型 5

Q1:Java 有哪些基本數據類型?

數據類型 內存大小 默認值 取值範圍
byte 1 B (byte)0 -128 ~ 127
short 2 B (short)0 -215 ~ 215-1
int 4 B 0 -231 ~ 231-1
long 8 B 0L -263 ~ 263-1
float 4 B 0.0F ±3.4E+38(有效位數 6~7 位)
double 8 B 0.0D ±1.7E+308(有效位數 15 位)
char 英文 1B,中文 UTF-8 占 3B,GBK 占 2B。 ‘\u0000’ ‘\u0000’ ~ ‘\uFFFF’
boolean 單個變量 4B / 數組 1B false true、false

JVM 沒有 boolean 賦值的專用位元組碼指令,boolean f = false 就是使用 ICONST_0 即常數 0 賦值。單個 boolean 變量用 int 代替,boolean 數組會編碼成 byte 數組。

Q2:自動裝箱/拆箱是什麼?

每個基本數據類型都對應一個包裝類,除了 int 和 char 對應 Integer 和 Character 外,其餘基本數據類型的包裝類都是首字母大寫即可。

自動裝箱: 將基本數據類型包裝為一個包裝類對象,例如向一個泛型為 Integer 的集合添加 int 元素。

自動拆箱: 將一個包裝類對象轉換為一個基本數據類型,例如將一個包裝類對象賦值給一個基本數據類型的變量。

比較兩個包裝類數值要用 equals ,而不能用 ==

Q3:String 是不可變類為什麼值可以修改?

String 類和其存儲數據的成員變量 value 位元組數組都是 final 修飾的。對一個 String 對象的任何修改實際上都是創建一個新 String 對象,再引用該對象。只是修改 String 變量引用的對象,沒有修改原 String 對象的內容。

Q4:字符串拼接的方式有哪些?

① 直接用 + ,底層用 StringBuilder 實現。只適用小數量,如果在循環中使用 + 拼接,相當於不斷創建新的 StringBuilder 對象再轉換成 String 對象,效率極差。

② 使用 String 的 concat 方法,該方法中使用 Arrays.copyOf 創建一個新的字符數組 buf 並將當前字符串 value 數組的值拷貝到 buf 中,buf 長度 = 當前字符串長度 + 拼接字符串長度。之後調用 getChars 方法使用 System.arraycopy 將拼接字符串的值也拷貝到 buf 數組,最後用 buf 作為構造參數 new 一個新的 String 對象返回。效率稍高於直接使用 +

③ 使用 StringBuilder 或 StringBuffer,兩者的 append 方法都繼承自 AbstractStringBuilder,該方法首先使用 Arrays.copyOf 確定新的字符數組容量,再調用 getChars 方法使用 System.arraycopy 將新的值追加到數組中。StringBuilder 是 JDK5 引入的,效率高但線程不安全。StringBuffer 使用 synchronized 保證線程安全。

Q5:String a = “a” + new String(“b”) 創建了幾個對象?

常量和常量拼接仍是常量,結果在常量池,只要有變量參與拼接結果就是變量,存在堆。

使用字面量時只創建一個常量池中的常量,使用 new 時如果常量池中沒有該值就會在常量池中新創建,再在堆中創建一個對象引用常量池中常量。因此 String a = "a" + new String("b") 會創建四個對象,常量池中的 a 和 b,堆中的 b 和堆中的 ab。

面向對象 10

Q1:談一談你對面向對象的理解

面向過程讓計算機有步驟地順序做一件事,是過程化思維,使用面向過程語言開發大型項目,軟件復用和維護存在很大問題,模塊之間耦合嚴重。面向對象相對面向過程更適合解決規模較大的問題,可以拆解問題複雜度,對現實事物進行抽象並映射為開發對象,更接近人的思維。

例如開門這個動作,面向過程是 open(Door door),動賓結構,door 作為操作對象的參數傳入方法,方法內定義開門的具體步驟。面向對象的方式首先會定義一個類 Door,抽象出門的屬性(如尺寸、顏色)和行為(如 open 和 close),主謂結構。

面向過程代碼鬆散,強調流程化解決問題。面向對象代碼強調高內聚、低耦合,先抽象模型定義共性行為,再解決實際問題。

Q2:面向對象的三大特性?

封裝是對象功能內聚的表現形式,在抽象基礎上決定信息是否公開及公開等級,核心問題是以什麼方式暴漏哪些信息。主要任務是對屬性、數據、敏感行為實現隱藏,對屬性的訪問和修改必須通過公共接口實現。封裝使對象關係變得簡單,降低了代碼耦合度,方便維護。

迪米特原則就是對封裝的要求,即 A 模塊使用 B 模塊的某接口行為,對 B 模塊中除此行為外的其他信息知道得應儘可能少。不直接對 public 屬性進行讀取和修改而使用 getter/setter 方法是因為假設想在修改屬性時進行權限控制、日誌記錄等操作,在直接訪問屬性的情況下無法實現。如果將 public 的屬性和行為修改為 private 一般依賴模塊都會報錯,因此不知道使用哪種權限時應優先使用 private。

繼承用來擴展一個類,子類可繼承父類的部分屬性和行為使模塊具有復用性。繼承是”is-a”關係,可使用里氏替換原則判斷是否滿足”is-a”關係,即任何父類出現的地方子類都可以出現。如果父類引用直接使用子類引用來代替且可以正確編譯並執行,輸出結果符合子類場景預期,那麼說明兩個類符合里氏替換原則。

多態以封裝和繼承為基礎,根據運行時對象實際類型使同一行為具有不同表現形式。多態指在編譯層面無法確定最終調用的方法體,在運行期由 JVM 動態綁定,調用合適的重寫方法。由於重載屬於靜態綁定,本質上重載結果是完全不同的方法,因此多態一般專指重寫。

Q3:重載和重寫的區別?

重載指方法名稱相同,但參數類型個數不同,是行為水平方向不同實現。對編譯器來說,方法名稱和參數列表組成了一個唯一鍵,稱為方法簽名,JVM 通過方法簽名決定調用哪種重載方法。不管繼承關係如何複雜,重載在編譯時可以根據規則知道調用哪種目標方法,因此屬於靜態綁定。

JVM 在重載方法中選擇合適方法的順序:① 精確匹配。② 基本數據類型自動轉換成更大表示範圍。③ 自動拆箱與裝箱。④ 子類向上轉型。⑤ 可變參數。

重寫指子類實現接口或繼承父類時,保持方法簽名完全相同,實現不同方法體,是行為垂直方向不同實現。

元空間有一個方法表保存方法信息,如果子類重寫了父類的方法,則方法表中的方法引用會指向子類實現。父類引用執行子類方法時無法調用子類存在而父類不存在的方法。

重寫方法訪問權限不能變小,返回類型和拋出的異常類型不能變大,必須加 @Override

Q4:類之間有哪些關係?

類關係 描述 權力強側 舉例
繼承 父子類之間的關係:is-a 父類 小狗繼承於動物
實現 接口和實現類之間的關係:can-do 接口 小狗實現了狗叫接口
組合 比聚合更強的關係:contains-a 整體 頭是身體的一部分
聚合 暫時組裝的關係:has-a 組裝方 小狗和繩子是暫時的聚合關係
依賴 一個類用到另一個:depends-a 被依賴方 人養小狗,人依賴於小狗
關聯 平等的使用關係:links-a 平等 人使用卡消費,卡可以提取人的信息

Q5:Object 類有哪些方法?

equals:檢測對象是否相等,默認使用 == 比較對象引用,可以重寫 equals 方法自定義比較規則。equals 方法規範:自反性、對稱性、傳遞性、一致性、對於任何非空引用 x,x.equals(null) 返回 false。

hashCode:散列碼是由對象導出的一個整型值,沒有規律,每個對象都有默認散列碼,值由對象存儲地址得出。字符串散列碼由內容導出,值可能相同。為了在集合中正確使用,一般需要同時重寫 equals 和 hashCode,要求 equals 相同 hashCode 必須相同,hashCode 相同 equals 未必相同,因此 hashCode 是對象相等的必要不充分條件。

toString:打印對象時默認的方法,如果沒有重寫打印的是表示對象值的一個字符串。

clone:clone 方法聲明為 protected,類只能通過該方法克隆它自己的對象,如果希望其他類也能調用該方法必須定義該方法為 public。如果一個對象的類沒有實現 Cloneable 接口,該對象調用 clone 方***拋出一個 CloneNotSupport 異常。默認的 clone 方法是淺拷貝,一般重寫 clone 方法需要實現 Cloneable 接口並指定訪問修飾符為 public。

finalize:確定一個對象死亡至少要經過兩次標記,如果對象在可達性分析後發現沒有與 GC Roots 連接的引用鏈會被第一次標記,隨後進行一次篩選,條件是對象是否有必要執行 finalize 方法。假如對象沒有重寫該方法或方法已被虛擬機調用,都視為沒有必要執行。如果有必要執行,對象會被放置在 F-Queue 隊列,由一條低調度優先級的 Finalizer 線程去執行。虛擬機會觸發該方法但不保證會結束,這是為了防止某個對象的 finalize 方法執行緩慢或發生死循環。只要對象在 finalize 方法中重新與引用鏈上的對象建立關聯就會在第二次標記時被移出回收集合。由於運行代價高昂且無法保證調用順序,在 JDK 9 被標記為過時方法,並不適合釋放資源。

getClass:返回包含對象信息的類對象。

wait / notify / notifyAll:阻塞或喚醒持有該對象鎖的線程。

Q6:內部類的作用是什麼,有哪些分類?

內部類可對同一包中其他類隱藏,內部類方法可以訪問定義這個內部類的作用域中的數據,包括 private 數據。

內部類是一個編譯器現象,與虛擬機無關。編譯器會把內部類轉換成常規的類文件,用 $ 分隔外部類名與內部類名,其中匿名內部類使用數字編號,虛擬機對此一無所知。

靜態內部類: 屬於外部類,只加載一次。作用域僅在包內,可通過 外部類名.內部類名 直接訪問,類內只能訪問外部類所有靜態屬性和方法。HashMap 的 Node 節點,ReentrantLock 中的 Sync 類,ArrayList 的 SubList 都是靜態內部類。內部類中還可以定義內部類,如 ThreadLoacl 靜態內部類 ThreadLoaclMap 中定義了內部類 Entry。

成員內部類: 屬於外部類的每個對象,隨對象一起加載。不可以定義靜態成員和方法,可訪問外部類的所有內容。

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

匿名內部類: 只用一次的沒有名字的類,可以簡化代碼,創建的對象類型相當於 new 的類的子類類型。用於實現事件監聽和其他回調。

Q7:訪問權限控制符有哪些?

訪問權限控制符 本類 包內 包外子類 任何地方
public
protected ×
× ×
private × × ×

Q8:接口和抽象類的異同?

接口和抽象類對實體類進行更高層次的抽象,僅定義公共行為和特徵。

語法維度 抽象類 接口
成員變量 無特殊要求 默認 public static final 常量
構造方法 有構造方法,不能實例化 沒有構造方法,不能實例化
方法 抽象類可以沒有抽象方法,但有抽象方法一定是抽象類。 默認 public abstract,JDK8 支持默認/靜態方法,JDK9 支持私有方法。
繼承 單繼承 多繼承

Q9:接口和抽象類應該怎麼選擇?

抽象類體現 is-a 關係,接口體現 can-do 關係。與接口相比,抽象類通常是對同類事物相對具體的抽象。

抽象類是模板式設計,包含一組具體特徵,例如某汽車,底盤、控制電路等是抽象出來的共同特徵,但內飾、顯示屏、座椅材質可以根據不同級別配置存在不同實現。

接口是契約式設計,是開放的,定義了方法名、參數、返回值、拋出的異常類型,誰都可以實現它,但必須遵守接口的約定。例如所有車輛都必須實現剎車這種強制規範。

接口是頂級類,抽象類在接口下面的第二層,對接口進行了組合,然後實現部分接口。當糾結定義接口和抽象類時,推薦定義為接口,遵循接口隔離原則,按維度劃分成多個接口,再利用抽象類去實現這些,方便後續的擴展和重構。

例如 Plane 和 Bird 都有 fly 方法,應把 fly 定義為接口,而不是抽象類的抽象方法再繼承,因為除了 fly 行為外 Plane 和 Bird 間很難再找到其他共同特徵。

Q10:子類初始化的順序

① 父類靜態代碼塊和靜態變量。② 子類靜態代碼塊和靜態變量。③ 父類普通代碼塊和普通變量。④ 父類構造方法。⑤ 子類普通代碼塊和普通變量。⑥ 子類構造方法。

集合 7

Q1:說一說 ArrayList

ArrayList 是容量可變的非線程安全列表,使用數組實現,集合擴容時會創建更大的數組,把原有數組複製到新數組。支持對元素的快速隨機訪問,但插入與刪除速度很慢。ArrayList 實現了 RandomAcess 標記接口,如果一個類實現了該接口,那麼表示使用索引遍歷比迭代器更快。

elementData是 ArrayList 的數據域,被 transient 修飾,序列化時會調用 writeObject 寫入流,反序列化時調用 readObject 重新賦值到新對象的 elementData。原因是 elementData 容量通常大於實際存儲元素的數量,所以只需發送真正有實際值的數組元素。

size 是當前實際大小,elementData 大小大於等於 size。

**modCount **記錄了 ArrayList 結構性變化的次數,繼承自 AbstractList。所有涉及結構變化的方法都會增加該值。expectedModCount 是迭代器初始化時記錄的 modCount 值,每次訪問新元素時都會檢查 modCount 和 expectedModCount 是否相等,不相等就會拋出異常。這種機制叫做 fail-fast,所有集合類都有這種機制。

Q2:說一說 LinkedList

LinkedList 本質是雙向鏈表,與 ArrayList 相比插入和刪除速度更快,但隨機訪問元素很慢。除繼承 AbstractList 外還實現了 Deque 接口,這個接口具有隊列和棧的性質。成員變量被 transient 修飾,原理和 ArrayList 類似。

LinkedList 包含三個重要的成員:size、first 和 last。size 是雙向鏈表中節點的個數,first 和 last 分別指向首尾節點的引用。

LinkedList 的優點在於可以將零散的內存單元通過附加引用的方式關聯起來,形成按鏈路順序查找的線性結構,內存利用率較高。

Q3:Set 有什麼特點,有哪些實現?

Set 不允許元素重複且無序,常用實現有 HashSet、LinkedHashSet 和 TreeSet。

HashSet 通過 HashMap 實現,HashMap 的 Key 即 HashSet 存儲的元素,所有 Key 都使用相同的 Value ,一個名為 PRESENT 的 Object 類型常量。使用 Key 保證元素唯一性,但不保證有序性。由於 HashSet 是 HashMap 實現的,因此線程不安全。

HashSet 判斷元素是否相同時,對於包裝類型直接按值比較。對於引用類型先比較 hashCode 是否相同,不同則代表不是同一個對象,相同則繼續比較 equals,都相同才是同一個對象。

LinkedHashSet 繼承自 HashSet,通過 LinkedHashMap 實現,使用雙向鏈表維護元素插入順序。

TreeSet 通過 TreeMap 實現的,添加元素到集合時按照比較規則將其插入合適的位置,保證插入後的集合仍然有序。

Q4:TreeMap 有什麼特點?

TreeMap 基於紅黑樹實現,增刪改查的平均和最差時間複雜度均為 O(logn) ,最大特點是 Key 有序。Key 必須實現 Comparable 接口或提供的 Comparator 比較器,所以 Key 不允許為 null。

HashMap 依靠 hashCodeequals 去重,而 TreeMap 依靠 Comparable 或 Comparator。 TreeMap 排序時,如果比較器不為空就會優先使用比較器的 compare 方法,否則使用 Key 實現的 Comparable 的 compareTo 方法,兩者都不滿足會拋出異常。

TreeMap 通過 putdeleteEntry 實現增加和刪除樹節點。插入新節點的規則有三個:① 需要調整的新節點總是紅色的。② 如果插入新節點的父節點是黑色的,不需要調整。③ 如果插入新節點的父節點是紅色的,由於紅黑樹不能出現相鄰紅色,進入循環判斷,通過重新着色或左右旋轉來調整。TreeMap 的插入操作就是按照 Key 的對比往下遍歷,大於節點值向右查找,小於向左查找,先按照二叉查找樹的特性操作,後續會重新着色和旋轉,保持紅黑樹的特性。

Q5:HashMap 有什麼特點?

JDK8 之前底層實現是數組 + 鏈表,JDK8 改為數組 + 鏈表/紅黑樹,節點類型從Entry 變更為 Node。主要成員變量包括存儲數據的 table 數組、元素數量 size、加載因子 loadFactor。

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

HashMap 中數據以鍵值對的形式存在,鍵對應的 hash 值用來計算數組下標,如果兩個元素 key 的 hash 值一樣,就會發生哈希衝突,被放到同一個鏈表上,為使查詢效率儘可能高,鍵的 hash 值要儘可能分散。

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

Q6:HashMap 相關方法的源碼?

JDK8 之前

hash:計算元素 key 的散列值

① 處理 String 類型時,調用 stringHash32 方法獲取 hash 值。

② 處理其他類型數據時,提供一個相對於 HashMap 實例唯一不變的隨機值 hashSeed 作為計算初始量。

③ 執行異或和無符號右移使 hash 值更加離散,減小哈希衝突概率。

indexFor:計算元素下標

將 hash 值和數組長度-1 進行與操作,保證結果不會超過 table 數組範圍。

get:獲取元素的 value 值

① 如果 key 為 null,調用 getForNullKey 方法,如果 size 為 0 表示鏈表為空,返回 null。如果 size 不為 0 說明存在鏈表,遍歷 table[0] 鏈表,如果找到了 key 為 null 的節點則返回其 value,否則返回 null。

② 如果 key 為 不為 null,調用 getEntry 方法,如果 size 為 0 表示鏈表為空,返回 null 值。如果 size 不為 0,首先計算 key 的 hash 值,然後遍歷該鏈表的所有節點,如果節點的 key 和 hash 值都和要查找的元素相同則返回其 Entry 節點。

③ 如果找到了對應的 Entry 節點,調用 getValue 方法獲取其 value 並返回,否則返回 null。

put:添加元素

① 如果 key 為 null,直接存入 table[0]。

② 如果 key 不為 null,計算 key 的 hash 值。

③ 調用 indexFor 計算元素存放的下標 i。

④ 遍歷 table[i] 對應的鏈表,如果 key 已存在,就更新 value 然後返回舊 value。

⑤ 如果 key 不存在,將 modCount 值加 1,使用 addEntry 方法增加一個節點並返回 null。

resize:擴容數組

① 如果當前容量達到了最大容量,將閾值設置為 Integer 最大值,之後擴容不再觸發。

② 否則計算新的容量,將閾值設為 newCapacity x loadFactor最大容量 + 1 的較小值。

③ 創建一個容量為 newCapacity 的 Entry 數組,調用 transfer 方法將舊數組的元素轉移到新數組。

transfer:轉移元素

① 遍歷舊數組的所有元素,調用 rehash 方法判斷是否需要哈希重構,如果需要就重新計算元素 key 的 hash 值。

② 調用 indexFor 方法計算元素存放的下標 i,利用頭插法將舊數組的元素轉移到新數組。

JDK8

hash:計算元素 key 的散列值

如果 key 為 null 返回 0,否則就將 key 的 hashCode 方法返回值高低16位異或,讓儘可能多的位參與運算,讓結果的 0 和 1 分佈更加均勻,降低哈希衝突概率。

put:添加元素

① 調用 putVal 方法添加元素。

② 如果 table 為空或長度為 0 就進行擴容,否則計算元素下標位置,不存在就調用 newNode 創建一個節點。

③ 如果存在且是鏈表,如果首節點和待插入元素的 hash 和 key 都一樣,更新節點的 value。

④ 如果首節點是 TreeNode 類型,調用 putTreeVal 方法增加一個樹節點,每一次都比較插入節點和當前節點的大小,待插入節點小就往左子樹查找,否則往右子樹查找,找到空位後執行兩個方法:balanceInsert 方法,插入節點並調整平衡、moveRootToFront 方法,由於調整平衡後根節點可能變化,需要重置根節點。

⑤ 如果都不滿足,遍歷鏈表,根據 hash 和 key 判斷是否重複,決定更新 value 還是新增節點。如果遍歷到了鏈表末尾則添加節點,如果達到建樹閾值 7,還需要調用 treeifyBin 把鏈表重構為紅黑樹。

⑥ 存放元素後將 modCount 加 1,如果 ++size > threshold ,調用 resize 擴容。

get :獲取元素的 value 值

① 調用 getNode 方法獲取 Node 節點,如果不是 null 就返回其 value 值,否則返回 null。

getNode 方法中如果數組不為空且存在元素,先比較第一個節點和要查找元素的 hash 和 key ,如果都相同則直接返回。

③ 如果第二個節點是 TreeNode 類型則調用 getTreeNode 方法進行查找,否則遍歷鏈表根據 hash 和 key 查找,如果沒有找到就返回 null。

resize:擴容數組

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

重新規劃長度

① 如果當前容量 oldCap > 0 且達到最大容量,將閾值設為 Integer 最大值,return 終止擴容。

② 如果未達到最大容量,當 oldCap << 1 不超過最大容量就擴大為 2 倍。

③ 如果都不滿足且當前擴容閾值 oldThr > 0,使用當前擴容閾值作為新容量。

④ 否則將新容量置為默認初始容量 16,新擴容閾值置為 12。

重新排列數據節點

① 如果節點為 null 不進行處理。

② 如果節點不為 null 且沒有next節點,那麼通過節點的 hash 值和 新容量-1 進行與運算計算下標存入新的 table 數組。

③ 如果節點為 TreeNode 類型,調用 split 方法處理,如果節點數 hc 達到6 會調用 untreeify 方法轉回鏈表。

④ 如果是鏈表節點,需要將鏈表拆分為 hash 值超出舊容量的鏈表和未超出容量的鏈表。對於hash & oldCap == 0 的部分不需要做處理,否則需要放到新的下標位置上,新下標 = 舊下標 + 舊容量。

Q7:HashMap 為什麼線程不安全?

JDK7 存在死循環和數據丟失問題。

數據丟失:

  • 並發賦值被覆蓋:createEntry 方法中,新添加的元素直接放在頭部,使元素之後可以被更快訪問,但如果兩個線程同時執行到此處,會導致其中一個線程的賦值被覆蓋。
  • 已遍歷區間新增元素丟失: 當某個線程在 transfer 方法遷移時,其他線程新增的元素可能落在已遍歷過的哈希槽上。遍歷完成後,table 數組引用指向了 newTable,新增元素丟失。
  • 新表被覆蓋: 如果 resize 完成,執行了 table = newTable,則後續元素就可以在新表上進行插入。但如果多線程同時 resize ,每個線程都會 new 一個數組,這是線程內的局部對象,線程之間不可見。遷移完成後resize 的線程會賦值給 table 線程共享變量,可能會覆蓋其他線程的操作,在新表中插入的對象都會被丟棄。

死循環: 擴容時 resize 調用 transfer 使用頭插法遷移元素,雖然 newTable 是局部變量,但原先 table 中的 Entry 鏈表是共享的,問題根源是 Entry 的 next 指針並發修改,某線程還沒有將 table 設為 newTable 時用完了 CPU 時間片,導致數據丟失或死循環。

JDK8 在 resize 方法中完成擴容,並改用尾插法,不會產生死循環,但並發下仍可能丟失數據。可用 ConcurrentHashMap 或 Collections.synchronizedMap 包裝成同步集合。

IO 流 6

Q1:同步/異步/阻塞/非阻塞 IO 的區別?

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

同步 IO 是用戶線程發起 IO 請求後需要等待或輪詢內核 IO 操作完成後才能繼續執行。異步 IO 是用戶線程發起 IO 請求後可以繼續執行,當內核 IO 操作完成後會通知用戶線程,或調用用戶線程註冊的回調函數。

阻塞 IO 是 IO 操作需要徹底完成後才能返回用戶空間 。非阻塞 IO 是 IO 操作調用後立即返回一個狀態值,無需等 IO 操作徹底完成。

Q2:什麼是 BIO?

BIO 是同步阻塞式 IO,JDK1.4 之前的 IO 模型。服務器實現模式為一個連接請求對應一個線程,服務器需要為每一個客戶端請求創建一個線程,如果這個連接不做任何事會造成不必要的線程開銷。可以通過線程池改善,這種 IO 稱為偽異步 IO。適用連接數目少且服務器資源多的場景。

Q3:什麼是 NIO?

NIO 是 JDK1.4 引入的同步非阻塞 IO。服務器實現模式為多個連接請求對應一個線程,客戶端連接請求會註冊到一個多路復用器 Selector ,Selector 輪詢到連接有 IO 請求時才啟動一個線程處理。適用連接數目多且連接時間短的場景。

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

核心組件:

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

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

  • Buffer: 緩衝區,本質是一塊可讀寫數據的內存,用來簡化數據讀寫。Buffer 三個重要屬性:position 下次讀寫數據的位置,limit 本次讀寫的極限位置,capacity 最大容量。

    • flip 將寫轉為讀,底層實現原理把 position 置 0,並把 limit 設為當前的 position 值。
    • clear 將讀轉為寫模式(用於讀完全部數據的情況,把 position 置 0,limit 設為 capacity)。
    • compact 將讀轉為寫模式(用於存在未讀數據的情況,讓 position 指向未讀數據的下一個)。
    • 通道方向和 Buffer 方向相反,讀數據相當於向 Buffer 寫,寫數據相當於從 Buffer 讀。
  • 使用步驟:向 Buffer 寫數據,調用 flip 方法轉為讀模式,從 Buffer 中讀數據,調用 clear 或 compact 方法清空緩衝區。

Q4:什麼是 AIO?

AIO 是 JDK7 引入的異步非阻塞 IO。服務器實現模式為一個有效請求對應一個線程,客戶端的 IO 請求都是由操作系統先完成 IO 操作後再通知服務器應用來直接使用準備好的數據。適用連接數目多且連接時間長的場景。

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

實現方式包括通過 Future 的 get 方法進行阻塞式調用以及實現 CompletionHandler 接口,重寫請求成功的回調方法 completed 和請求失敗回調方法 failed

Q5:java.io 包下有哪些流?

主要分為字符流和位元組流,字符流一般用於文本文件,位元組流一般用於圖像或其他文件。

字符流包括了字符輸入流 Reader 和字符輸出流 Writer,位元組流包括了位元組輸入流 InputStream 和位元組輸出流 OutputStream。字符流和位元組流都有對應的緩衝流,位元組流也可以包裝為字符流,緩衝流帶有一個 8KB 的緩衝數組,可以提高流的讀寫效率。除了緩衝流外還有過濾流 FilterReader、字符數組流 CharArrayReader、位元組數組流 ByteArrayInputStream、文件流 FileInputStream 等。

Q6:序列化和反序列化是什麼?

Java 對象 JVM 退出時會全部銷毀,如果需要將對象及狀態持久化,就要通過序列化實現,將內存中的對象保存在二進制流中,需要時再將二進制流反序列化為對象。對象序列化保存的是對象的狀態,因此屬於類屬性的靜態變量不會被序列化。

常見的序列化有三種:

  • Java 原生序列化
    實現 Serializabale 標記接口,Java 序列化保留了對象類的元數據(如類、成員變量、繼承類信息)以及對象數據,兼容性最好,但不支持跨語言,性能一般。序列化和反序列化必須保持序列化 ID 的一致,一般使用 private static final long serialVersionUID 定義序列化 ID,如果不設置編譯器會根據類的內部實現自動生成該值。如果是兼容升級不應該修改序列化 ID,防止出錯,如果是不兼容升級則需要修改。
  • Hessian 序列化
    Hessian 序列化是一種支持動態類型、跨語言、基於對象傳輸的網絡協議。Java 對象序列化的二進制流可以被其它語言反序列化。Hessian 協議的特性:① 自描述序列化類型,不依賴外部描述文件,用一個位元組表示常用基礎類型,極大縮短二進制流。② 語言無關,支持腳本語言。③ 協議簡單,比 Java 原生序列化高效。Hessian 會把複雜對象所有屬性存儲在一個 Map 中序列化,當父類和子類存在同名成員變量時會先序列化子類再序列化父類,因此子類值會被父類覆蓋。
  • JSON 序列化
    JSON 序列化就是將數據對象轉換為 JSON 字符串,在序列化過程中拋棄了類型信息,所以反序列化時只有提供類型信息才能準確進行。相比前兩種方式可讀性更好,方便調試。

序列化通常會使用網絡傳輸對象,而對象中往往有敏感數據,容易遭受攻擊,Jackson 和 fastjson 等都出現過反序列化漏洞,因此不需要進行序列化的敏感屬性傳輸時應加上 transient 關鍵字。transient 的作用就是把變量生命周期僅限於內存而不會寫到磁盤裡持久化,變量會被設為對應數據類型的零值。

JVM 32

內存區域劃分 8

Q1:運行時數據區是什麼?

虛擬機在執行 Java 程序的過程中會把它所管理的內存劃分為若干不同的數據區,這些區域有各自的用途、創建和銷毀時間。

線程私有:程序計數器、Java 虛擬機棧、本地方法棧。

線程共享:Java 堆、方法區。

Q2:程序計數器是什麼?

程序計數器是一塊較小的內存空間,可以看作當前線程所執行位元組碼的行號指示器。位元組碼解釋器工作時通過改變計數器的值選取下一條執行指令。分支、循環、跳轉、線程恢復等功能都需要依賴計數器完成。是唯一在虛擬機規範中沒有規定內存溢出情況的區域。

如果線程正在執行 Java 方法,計數器記錄正在執行的虛擬機位元組碼指令地址。如果是本地方法,計數器值為 Undefined。

Q3:Java 虛擬機棧的作用?

Java 虛擬機棧來描述 Java 方法的內存模型。每當有新線程創建時就會分配一個棧空間,線程結束後棧空間被回收,棧與線程擁有相同的生命周期。棧中元素用於支持虛擬機進行方法調用,每個方法在執行時都會創建一個棧幀存儲方法的局部變量表、操作棧、動態鏈接和方法出口等信息。每個方法從調用到執行完成,就是棧幀從入棧到出棧的過程。

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

Q4:本地方法棧的作用?

本地方法棧與虛擬機棧作用相似,不同的是虛擬機棧為虛擬機執行 Java 方法服務,本地方法棧為虛本地方法服務。調用本地方法時虛擬機棧保持不變,動態鏈接並直接調用指定本地方法。

虛擬機規範對本地方法棧中方法的語言與數據結構無強制規定,虛擬機可自由實現,例如 HotSpot 將虛擬機棧和本地方法棧合二為一。

本地方法棧在棧深度異常和棧擴展失敗時分別拋出 StackOverflowError 和 OutOfMemoryError。

Q5:堆的作用是什麼?

是虛擬機所管理的內存中最大的一塊,被所有線程共享的,在虛擬機啟動時創建。堆用來存放對象實例,Java 里幾乎所有對象實例都在堆分配內存。堆可以處於物理上不連續的內存空間,邏輯上應該連續,但對於例如數組這樣的大對象,多數虛擬機實現出於簡單、存儲高效的考慮會要求連續的內存空間。

堆既可以被實現成固定大小,也可以是可擴展的,可通過 -Xms-Xmx 設置堆的最小和最大容量,當前主流 JVM 都按照可擴展實現。如果堆沒有內存完成實例分配也無法擴展,拋出 OutOfMemoryError。

Q6:方法區的作用是什麼?

方法區用於存儲被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯後的代碼緩存等數據。

JDK8 之前使用永久代實現方法區,容易內存溢出,因為永久代有 -XX:MaxPermSize 上限,即使不設置也有默認大小。JDK7 把放在永久代的字符串常量池、靜態變量等移出,JDK8 中永久代完全廢棄,改用在本地內存中實現的元空間代替,把 JDK 7 中永久代剩餘內容(主要是類型信息)全部移到元空間。

虛擬機規範對方法區的約束寬鬆,除和堆一樣不需要連續內存和可選擇固定大小/可擴展外,還可以不實現垃圾回收。垃圾回收在方法區出現較少,主要目標針對常量池和類型卸載。如果方法區無法滿足新的內存分配需求,將拋出 OutOfMemoryError。

Q7:運行時常量池的作用是什麼?

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

運行時常量池相對於 Class 文件常量池的一個重要特徵是動態性,Java 不要求常量只有編譯期才能產生,運行期間也可以將新的常量放入池中,這種特性利用較多的是 String 的 intern 方法。

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

Q8:直接內存是什麼?

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

JDK1.4 中新加入了 NIO 這種基於通道與緩衝區的 IO,它可以使用 Native 函數庫直接分配堆外內存,通過一個堆里的 DirectByteBuffer 對象作為內存的引用進行操作,避免了在 Java 堆和 Native堆來回複製數據。

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

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

內存溢出 5

Q1:內存溢出和內存泄漏的區別?

內存溢出 OutOfMemory,指程序在申請內存時,沒有足夠的內存空間供其使用。

內存泄露 Memory Leak,指程序在申請內存後,無法釋放已申請的內存空間,內存泄漏最終將導致內存溢出。

Q2:堆溢出的原因?

堆用於存儲對象實例,只要不斷創建對象並保證 GC Roots 到對象有可達路徑避免垃圾回收,隨着對象數量的增加,總容量觸及最大堆容量後就會 OOM,例如在 while 死循環中一直 new 創建實例。

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

如果是內存泄漏,通過工具查看泄漏對象到 GC Roots 的引用鏈,找到泄露對象是通過怎樣的引用路徑、與哪些 GC Roots 關聯才導致無法回收,一般可以準確定位到產生內存泄漏代碼的具***置。

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

Q3:棧溢出的原因?

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

StackOverflowError: 如果線程請求的棧深度大於虛擬機所允許的深度,將拋出 StackOverflowError,例如一個遞歸方法不斷調用自己。該異常有明確錯誤堆棧可供分析,容易定位到問題所在。

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

Q4:運行時常量池溢出的原因?

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

在 JDK6 及之前常量池分配在永久代,因此可以通過 -XX:PermSize-XX:MaxPermSize 限制永久代大小,間接限制常量池。在 while 死循環中調用 intern 方法導致運行時常量池溢出。在 JDK7 後不會出現該問題,因為存放在永久代的字符串常量池已經被移至堆中。

Q5:方法區溢出的原因?

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

JDK8 使用元空間取代永久代,HotSpot 提供了一些參數作為元空間防禦措施,例如 -XX:MetaspaceSize 指定元空間初始大小,達到該值會觸發 GC 進行類型卸載,同時收集器會對該值進行調整,如果釋放大量空間就適當降低該值,如果釋放很少空間就適當提高。

創建對象 5

Q1:創建對象的過程是什麼?

位元組碼角度

  • NEW: 如果找不到 Class 對象則進行類加載。加載成功後在堆中分配內存,從 Object 到本類路徑上的所有屬性都要分配。分配完畢後進行零值設置。最後將指向實例對象的引用變量壓入虛擬機棧頂。
  • **DUP: ** 在棧頂複製引用變量,這時棧頂有兩個指向堆內實例的引用變量。兩個引用變量的目的不同,棧底的引用用於賦值或保存局部變量表,棧頂的引用作為句柄調用相關方法。
  • INVOKESPECIAL: 通過棧頂的引用變量調用 init 方法。

執行角度

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

② 在類加載檢查通過後虛擬機將為新生對象分配內存。

③ 內存分配完成後虛擬機將成員變量設為零值,保證對象的實例字段可以不賦初值就使用。

④ 設置對象頭,包括哈希碼、GC 信息、鎖信息、對象所屬類的類元信息等。

⑤ 執行 init 方法,初始化成員變量,執行實例化代碼塊,調用類的構造方法,並把堆內對象的首地址賦值給引用變量。

Q2:對象分配內存的方式有哪些?

對象所需內存大小在類加載完成後便可完全確定,分配空間的任務實際上等於把一塊確定大小的內存塊從 Java 堆中劃分出來。

指針碰撞: 假設 Java 堆內存規整,被使用過的內存放在一邊,空閑的放在另一邊,中間放着一個指針作為分界指示器,分配內存就是把指針向空閑方向挪動一段與對象大小相等的距離。

空閑列表: 如果 Java 堆內存不規整,虛擬機必須維護一個列表記錄哪些內存可用,在分配時從列表中找到一塊足夠大的空間劃分給對象並更新列表記錄。

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

Q3:對象分配內存是否線程安全?

對象創建十分頻繁,即使修改一個指針的位置在並發下也不是線程安全的,可能正給對象 A 分配內存,指針還沒來得及修改,對象 B 又使用了指針來分配內存。

解決方法:① CAS 加失敗重試保證更新原子性。② 把內存分配按線程劃分在不同空間,即每個線程在 Java 堆中預先分配一小塊內存,叫做本地線程分配緩衝 TLAB,哪個線程要分配內存就在對應的 TLAB 分配,TLAB 用完了再進行同步。

Q4:對象的內存布局了解嗎?

對象在堆內存的存儲布局可分為對象頭、實例數據和對齊填充。

對象頭占 12B,包括對象標記和類型指針。對象標記存儲對象自身的運行時數據,如哈希碼、GC 分代年齡、鎖標誌、偏向線程 ID 等,這部分佔 8B,稱為 Mark Word。Mark Word 被設計為動態數據結構,以便在極小的空間存儲更多數據,根據對象狀態復用存儲空間。

類型指針是對象指向它的類型元數據的指針,占 4B。JVM 通過該指針來確定對象是哪個類的實例。

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

對齊填充不是必然存在的,僅起佔位符作用。虛擬機的自動內存管理系統要求任何對象的大小必須是 8B 的倍數,對象頭已被設為 8B 的 1 或 2 倍,如果對象實例數據部分沒有對齊,需要對齊填充補全。

Q5:對象的訪問方式有哪些?

Java 程序會通過棧上的 reference 引用操作堆對象,訪問方式由虛擬機決定,主流訪問方式主要有句柄和直接指針。

句柄: 堆會劃分出一塊內存作為句柄池,reference 中存儲對象的句柄地址,句柄包含對象實例數據與類型數據的地址信息。優點是 reference 中存儲的是穩定句柄地址,在 GC 過程中對象被移動時只會改變句柄的實例數據指針,而 reference 本身不需要修改。

直接指針: 堆中對象的內存布局就必須考慮如何放置訪問類型數據的相關信息,reference 存儲對象地址,如果只是訪問對象本身就不需要多一次間接訪問的開銷。優點是速度更快,節省了一次指針定位的時間開銷,HotSpot 主要使用直接指針進行對象訪問。

垃圾回收 7

Q1:如何判斷對象是否是垃圾?

引用計數:在對象中添加一個引用計數器,如果被引用計數器加 1,引用失效時計數器減 1,如果計數器為 0 則被標記為垃圾。原理簡單,效率高,但是在 Java 中很少使用,因為存在對象間循環引用的問題,導致計數器無法清零。

可達性分析:主流語言的內存管理都使用可達性分析判斷對象是否存活。基本思路是通過一系列稱為 GC Roots 的根對象作為起始節點集,從這些節點開始,根據引用關係向下搜索,搜索過程走過的路徑稱為引用鏈,如果某個對象到 GC Roots 沒有任何引用鏈相連,則會被標記為垃圾。可作為 GC Roots 的對象包括虛擬機棧和本地方法棧中引用的對象、類靜態屬性引用的對象、常量引用的對象。

Q2:Java 的引用有哪些類型?

JDK1.2 後對引用進行了擴充,按強度分為四種:

強引用: 最常見的引用,例如 Object obj = new Object() 就屬於強引用。只要對象有強引用指向且 GC Roots 可達,在內存回收時即使瀕臨內存耗盡也不會被回收。

軟引用: 弱於強引用,描述非必需對象。在系統將發生內存溢出前,會把軟引用關聯的對象加入回收範圍以獲得更多內存空間。用來緩存服務器中間計算結果及不需要實時保存的用戶行為等。

弱引用: 弱於軟引用,描述非必需對象。弱引用關聯的對象只能生存到下次 YGC 前,當垃圾收集器開始工作時無論當前內存是否足夠都會回收只被弱引用關聯的對象。由於 YGC 具有不確定性,因此弱引用何時被回收也不確定。

虛引用: 最弱的引用,定義完成後無法通過該引用獲取對象。唯一目的就是為了能在對象被回收時收到一個系統通知。虛引用必須與引用隊列聯合使用,垃圾回收時如果出現虛引用,就會在回收對象前把這個虛引用加入引用隊列。

Q3:有哪些 GC 算法?

標記-清除算法

分為標記和清除階段,首先從每個 GC Roots 出發依次標記有引用關係的對象,最後清除沒有標記的對象。

執行效率不穩定,如果堆包含大量對象且大部分需要回收,必須進行大量標記清除,導致效率隨對象數量增長而降低。

存在內存空間碎片化問題,會產生大量不連續的內存碎片,導致以後需要分配大對象時容易觸發 Full GC。

標記-複製算法

為了解決內存碎片問題,將可用內存按容量劃分為大小相等的兩塊,每次只使用其中一塊。當使用的這塊空間用完了,就將存活對象複製到另一塊,再把已使用過的內存空間一次清理掉。主要用於進行新生代。

實現簡單、運行高效,解決了內存碎片問題。 代價是可用內存縮小為原來的一半,浪費空間。

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

標記-整理算法

標記-複製算法在對象存活率高時要進行較多複製操作,效率低。如果不想浪費空間,就需要有額外空間分配擔保,應對被使用內存中所有對象都存活的極端情況,所以老年代一般不使用此算法。

老年代使用標記-整理算法,標記過程與標記-清除算法一樣,但不直接清理可回收對象,而是讓所有存活對象都向內存空間一端移動,然後清理掉邊界以外的內存。

標記-清除與標記-整理的差異在於前者是一種非移動式算法而後者是移動式的。如果移動存活對象,尤其是在老年代這種每次回收都有大量對象存活的區域,是一種極為負重的操作,而且移動必須全程暫停用戶線程。如果不移動對象就會導致空間碎片問題,只能依賴更複雜的內存分配器和訪問器解決。

Q4:你知道哪些垃圾收集器?

Serial

最基礎的收集器,使用複製算法、單線程工作,只用一個處理器或一條線程完成垃圾收集,進行垃圾收集時必須暫停其他所有工作線程。

Serial 是虛擬機在客戶端模式的默認新生代收集器,簡單高效,對於內存受限的環境它是所有收集器中額外內存消耗最小的,對於處理器核心較少的環境,Serial 由於沒有線程交互開銷,可獲得最高的單線程收集效率。

ParNew

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

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

Parallel Scavenge

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

特點是它的關注點與其他收集器不同,Parallel Scavenge 的目標是達到一個可控制的吞吐量,吞吐量就是處理器用於運行用戶代碼的時間與處理器消耗總時間的比值。

Serial Old

Serial 的老年代版本,單線程工作,使用標記-整理算法。

Serial Old 是虛擬機在客戶端模式的默認老年代收集器,用於服務端有兩種用途:① JDK5 及之前與 Parallel Scavenge 搭配。② 作為CMS 失敗預案。

Parellel Old

Parallel Scavenge 的老年代版本,支持多線程,基於標記-整理算法。JDK6 提供,注重吞吐量可考慮 Parallel Scavenge 加 Parallel Old。

CMS

以獲取最短回收停頓時間為目標,基於標記-清除算法,過程相對複雜,分為四個步驟:初始標記、並發標記、重新標記、並發清除。

初始標記和重新標記需要 STW(Stop The World,系統停頓),初始標記僅是標記 GC Roots 能直接關聯的對象,速度很快。並發標記從 GC Roots 的直接關聯對象開始遍歷整個對象圖,耗時較長但不需要停頓用戶線程。重新標記則是為了修正並發標記期間因用戶程序運作而導致標記產生變動的那部分記錄。並發清除清理標記階段判斷的已死亡對象,不需要移動存活對象,該階段也可與用戶線程並發。

缺點:① 對處理器資源敏感,並發階段雖然不會導致用戶線程暫停,但會降低吞吐量。② 無法處理浮動垃圾,有可能出現並發失敗而導致 Full GC。③ 基於標記-清除算法,產生空間碎片。

G1

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

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

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

G1 運作過程:

  • 初始標記:標記 GC Roots 能直接關聯到的對象,讓下一階段用戶線程並發運行時能正確地在可用 Region 中分配新對象。需要 STW 但耗時很短,在 Minor GC 時同步完成。
  • 並發標記:從 GC Roots 開始對堆中對象進行可達性分析,遞歸掃描整個堆的對象圖。耗時長但可與用戶線程並發,掃描完成後要重新處理 SATB 記錄的在並發時有變動的對象。
  • 最終標記:對用戶線程做短暫暫停,處理並發階段結束後仍遺留下來的少量 SATB 記錄。
  • 篩選回收:對各 Region 的回收價值排序,根據用戶期望停頓時間制定回收計劃。必須暫停用戶線程,由多條收集線程並行完成。

可由用戶指定期望停頓時間是 G1 的一個強大功能,但該值不能設得太低,一般設置為100~300 ms。

Q5:ZGC 了解嗎?

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

基於 Region 內存布局,不設分代,使用了讀屏障、染色指針和內存多重映射等技術實現可並發的標記-整理,以低延遲為首要目標。

ZGC 的 Region 具有動態性,是動態創建和銷毀的,並且容量大小也是動態變化的。

Q6:你知道哪些內存分配與回收策略?

對象優先在 Eden 區分配

大多數情況下對象在新生代 Eden 區分配,當 Eden 沒有足夠空間時將發起一次 Minor GC。

大對象直接進入老年代

大對象指需要大量連續內存空間的對象,典型是很長的字符串或數量龐大的數組。大對象容易導致內存還有不少空間就提前觸發垃圾收集以獲得足夠的連續空間。

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

長期存活對象進入老年代

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

動態對象年齡判定

為了適應不同內存狀況,虛擬機不要求對象年齡達到閾值才能晉陞老年代,如果在 Survivor 中相同年齡所有對象大小的總和大於 Survivor 的一半,年齡不小於該年齡的對象就可以直接進入老年代。

空間分配擔保

MinorGC 前虛擬機必須檢查老年代最大可用連續空間是否大於新生代對象總空間,如果滿足則說明這次 Minor GC 確定安全。

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

冒險是因為新生代使用複製算法,為了內存利用率只使用一個 Survivor,大量對象在 Minor GC 後仍然存活時,需要老年代進行分配擔保,接收 Survivor 無法容納的對象。

Q7:你知道哪些故障處理工具?

jps:虛擬機進程狀況工具

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

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

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

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

jinfo:Java 配置信息工具

實時查看和調整虛擬機各項參數,使用 jps 的 -v 參數可以查看虛擬機啟動時顯式指定的參數,但如果想知道未顯式指定的參數值只能使用 jinfo 的 -flag 查詢。

jmap:Java 內存映像工具

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

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

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

jstack:Java 堆棧跟蹤工具

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

類加載機制 7

Q1:Java 程序是怎樣運行的?

  • 首先通過 Javac 編譯器將 .java 轉為 JVM 可加載的 .class 位元組碼文件。
    Javac 是由 Java 編寫的程序,編譯過程可以分為: ① 詞法解析,通過空格分割出單詞、操作符、控制符等信息,形成 token 信息流,傳遞給語法解析器。② 語法解析,把 token 信息流按照 Java 語法規則組裝成語法樹。③ 語義分析,檢查關鍵字使用是否合理、類型是否匹配、作用域是否正確等。④ 位元組碼生成,將前面各個步驟的信息轉換為位元組碼。
    位元組碼必須通過類加載過程加載到 JVM 後才可以執行,執行有三種模式,解釋執行、JIT 編譯執行、JIT 編譯與解釋器混合執行(主流 JVM 默認執行的方式)。混合模式的優勢在於解釋器在啟動時先解釋執行,省去編譯時間。
  • 之後通過即時編譯器 JIT 把位元組碼文件編譯成本地機器碼。
    Java 程序最初都是通過解釋器進行解釋執行的,當虛擬機發現某個方法或代碼塊的運行特別頻繁,就會認定其為”熱點代碼”,熱點代碼的檢測主要有基於採樣和基於計數器兩種方式,為了提高熱點代碼的執行效率,虛擬機會把它們編譯成本地機器碼,儘可能對代碼優化,在運行時完成這個任務的後端編譯器被稱為即時編譯器。
  • 還可以通過靜態的提前編譯器 AOT 直接把程序編譯成與目標機器指令集相關的二進制代碼。

Q2:類加載是什麼?

Class 文件中描述的各類信息都需要加載到虛擬機後才能使用。JVM 把描述類的數據從 Class 文件加載到內存,並對數據進行校驗、解析和初始化,最終形成可以被虛擬機直接使用的 Java 類型,這個過程稱為虛擬機的類加載機制。

與編譯時需要連接的語言不同,Java 中類型的加載、連接和初始化都是在運行期間完成的,這增加了性能開銷,但卻提供了極高的擴展性,Java 動態擴展的語言特性就是依賴運行期動態加載和連接實現的。

一個類型從被加載到虛擬機內存開始,到卸載出內存為止,整個生命周期經歷加載、驗證、準備、解析、初始化、使用和卸載七個階段,其中驗證、解析和初始化三個部分稱為連接。加載、驗證、準備、初始化階段的順序是確定的,解析則不一定:可能在初始化之後再開始,這是為了支持 Java 的動態綁定。

Q3:類初始化的情況有哪些?

① 遇到 newgetstaticputstaticinvokestatic 位元組碼指令時,還未初始化。典型場景包括 new 實例化對象、讀取或設置靜態字段、調用靜態方法。

② 對類反射調用時,還未初始化。

③ 初始化類時,父類還未初始化。

④ 虛擬機啟動時,會先初始化包含 main 方法的主類。

⑤ 使用 JDK7 的動態語言支持時,如果 MethodHandle 實例的解析結果為指定類型的方法句柄且句柄對應的類還未初始化。

⑥ 接口定義了默認方法,如果接口的實現類初始化,接口要在其之前初始化。

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

接口和類加載過程的區別:初始化類時如果父類沒有初始化需要初始化父類,但接口初始化時不要求父接口初始化,只有在真正使用父接口時(如引用接口中定義的常量)才會初始化。

Q4:類加載的過程是什麼?

加載

該階段虛擬機需要完成三件事:① 通過一個類的全限定類名獲取定義類的二進制位元組流。② 將位元組流所代表的靜態存儲結構轉化為方法區的運行時數據區。③ 在內存中生成對應該類的 Class 實例,作為方法區這個類的數據訪問入口。

驗證

確保 Class 文件的位元組流符合約束。如果虛擬機不檢查輸入的位元組流,可能因為載入有錯誤或惡意企圖的位元組流而導致系統受攻擊。驗證主要包含四個階段:文件格式驗證、元數據驗證、位元組碼驗證、符號引用驗證。

驗證重要但非必需,因為只有通過與否的區別,通過後對程序運行期沒有任何影響。如果代碼已被反覆使用和驗證過,在生產環境就可以考慮關閉大部分驗證縮短類加載時間。

準備

為類靜態變量分配內存並設置零值,該階段進行的內存分配僅包括類變量,不包括實例變量。如果變量被 final 修飾,編譯時 Javac 會為變量生成 ConstantValue 屬性,準備階段虛擬機會將變量值設為代碼值。

解析

將常量池內的符號引用替換為直接引用。

符號引用以一組符號描述引用目標,可以是任何形式的字面量,只要使用時能無歧義地定位目標即可。與虛擬機內存布局無關,引用目標不一定已經加載到虛擬機內存。

直接引用是可以直接指向目標的指針、相對偏移量或能間接定位到目標的句柄。和虛擬機的內存布局相關,引用目標必須已在虛擬機的內存中存在。

初始化

直到該階段 JVM 才開始執行類中編寫的代碼。準備階段時變量賦過零值,初始化階段會根據程序員的編碼去初始化類變量和其他資源。初始化階段就是執行類構造方法中的 <client> 方法,該方法是 Javac 自動生成的。

Q5:有哪些類加載器?

自 JDK1.2 起 Java 一直保持三層類加載器:

  • 啟動類加載器
    在 JVM 啟動時創建,負責加載最核心的類,例如 Object、System 等。無法被程序直接引用,如果需要把加載委派給啟動類加載器,直接使用 null 代替即可,因為啟動類加載器通常由操作系統實現,並不存在於 JVM 體系。
  • 平台類加載器
    從 JDK9 開始從擴展類加載器更換為平台類加載器,負載加載一些擴展的系統類,比如 XML、加密、壓縮相關的功能類等。
  • 應用類加載器
    也稱系統類加載器,負責加載用戶類路徑上的類庫,可以直接在代碼中使用。如果沒有自定義類加載器,一般情況下應用類加載器就是默認的類加載器。自定義類加載器通過繼承 ClassLoader 並重寫 findClass 方法實現。

Q6:雙親委派模型是什麼?

類加載器具有等級制度但非繼承關係,以組合的方式復用父加載器的功能。雙親委派模型要求除了頂層的啟動類加載器外,其餘類加載器都應該有自己的父加載器。

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

類跟隨它的加載器一起具備了有優先級的層次關係,確保某個類在各個類加載器環境中都是同一個,保證程序的穩定性。

Q7:如何判斷兩個類是否相等?

任意一個類都必須由類加載器和這個類本身共同確立其在虛擬機中的唯一性。

兩個類只有由同一類加載器加載才有比較意義,否則即使兩個類來源於同一個 Class 文件,被同一個 JVM 加載,只要類加載器不同,這兩個類就必定不相等。

並發 39

JMM 8

Q1:JMM 的作用是什麼?

Java 線程的通信由 JMM 控制,JMM 的主要目的是定義程序中各種變量的訪問規則。變量包括實例字段、靜態字段,但不包括局部變量與方法參數,因為它們是線程私有的,不存在多線程競爭。JMM 遵循一個基本原則:只要不改變程序執行結果,編譯器和處理器怎麼優化都行。例如編譯器分析某個鎖只會單線程訪問就消除鎖,某個 volatile 變量只會單線程訪問就把它當作普通變量。

JMM 規定所有變量都存儲在主內存,每條線程有自己的工作內存,工作內存中保存被該線程使用的變量的主內存副本,線程對變量的所有操作都必須在工作空間進行,不能直接讀寫主內存數據。不同線程間無法直接訪問對方工作內存中的變量,線程通信必須經過主內存。

關於主內存與工作內存的交互,即變量如何從主內存拷貝到工作內存、從工作內存同步回主內存,JMM 定義了 8 種原子操作:

操作 作用變量範圍 作用
lock 主內存 把變量標識為線程獨佔狀態
unlock 主內存 釋放處於鎖定狀態的變量
read 主內存 把變量值從主內存傳到工作內存
load 工作內存 把 read 得到的值放入工作內存的變量副本
user 工作內存 把工作內存中的變量值傳給執行引擎
assign 工作內存 把從執行引擎接收的值賦給工作內存變量
store 工作內存 把工作內存的變量值傳到主內存
write 主內存 把 store 取到的變量值放入主內存變量中

Q2:as-if-serial 是什麼?

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

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

as-if-serial 把單線程程序保護起來,給程序員一種幻覺:單線程程序是按程序的順序執行的。

Q3:happens-before 是什麼?

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

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

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

  • 程序次序規則:一個線程內寫在前面的操作先行發生於後面的。
  • 管程鎖定規則: unlock 操作先行發生於後面對同一個鎖的 lock 操作。
  • volatile 規則:對 volatile 變量的寫操作先行發生於後面的讀操作。
  • 線程啟動規則:線程的 start 方法先行發生於線程的每個動作。
  • 線程終止規則:線程中所有操作先行發生於對線程的終止檢測。
  • 對象終結規則:對象的初始化先行發生於 finalize 方法。
  • 傳遞性:如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,那麼操作 A 先行發生於操作 C 。

Q4:as-if-serial 和 happens-before 有什麼區別?

as-if-serial 保證單線程程序的執行結果不變,happens-before 保證正確同步的多線程程序的執行結果不變。

這兩種語義的目的都是為了在不改變程序執行結果的前提下儘可能提高程序執行並行度。

Q5:什麼是指令重排序?

為了提高性能,編譯器和處理器通常會對指令進行重排序,重排序指從源代碼到指令序列的重排序,分為三種:① 編譯器優化的重排序,編譯器在不改變單線程程序語義的前提下可以重排語句的執行順序。② 指令級並行的重排序,如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。③ 內存系統的重排序。

Q6:原子性、可見性、有序性分別是什麼?

原子性

基本數據類型的訪問都具備原子性,例外就是 long 和 double,虛擬機將沒有被 volatile 修飾的 64 位數據操作劃分為兩次 32 位操作。

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

可見性

可見性指當一個線程修改了共享變量時,其他線程能夠立即得知修改。JMM 通過在變量修改後將值同步回主內存,在變量讀取前從主內存刷新的方式實現可見性,無論普通變量還是 volatile 變量都是如此,區別是 volatile 保證新值能立即同步到主內存以及每次使用前立即從主內存刷新。

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

有序性

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

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

Q7:談一談 volatile

JMM 為 volatile 定義了一些特殊訪問規則,當變量被定義為 volatile 後具備兩種特性:

  • 保證變量對所有線程可見
    當一條線程修改了變量值,新值對於其他線程來說是立即可以得知的。volatile 變量在各個線程的工作內存中不存在一致性問題,但 Java 的運算操作符並非原子操作,導致 volatile 變量運算在並發下仍不安全。
  • 禁止指令重排序優化
    使用 volatile 變量進行寫操作,彙編指令帶有 lock 前綴,相當於一個內存屏障,後面的指令不能重排到內存屏障之前。
    使用 lock 前綴引發兩件事:① 將當前處理器緩存行的數據寫回系統內存。②使其他處理器的緩存無效。相當於對緩存變量做了一次 store 和 write 操作,讓 volatile 變量的修改對其他處理器立即可見。

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

自增語句由 4 條位元組碼指令構成的,依次為 getstaticiconst_1iaddputstatic,當 getstatic 把 i 的值取到操作棧頂時,volatile 保證了 i 值在此刻正確,但在執行 iconst_1iadd 時,其他線程可能已經改變了 i 值,操作棧頂的值就變成了過期數據,所以 putstatic 執行後就可能把較小的 i 值同步回了主內存。

適用場景

① 運算結果並不依賴變量的當前值。② 一寫多讀,只有單一的線程修改變量值。

內存語義

寫一個 volatile 變量時,把該線程工作內存中的值刷新到主內存。

讀一個 volatile 變量時,把該線程工作內存值置為無效,從主內存讀取。

指令重排序特點

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

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

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

JSR-133 增強 volatile 語義的原因

在舊的內存模型中,雖然不允許 volatile 變量間重排序,但允許 volatile 變量與普通變量重排序,可能導致內存不可見問題。JSR-133 嚴格限制編譯器和處理器對 volatile 變量與普通變量的重排序,確保 volatile 的寫-讀和鎖的釋放-獲取具有相同的內存語義。

Q8:final 可以保證可見性嗎?

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

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

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

寫 final 域重排序規則

禁止把 final 域的寫重排序到構造方法之外,編譯器會在 final 域的寫後,構造方法的 return 前,插入一個 Store Store 屏障。確保在對象引用為任意線程可見之前,對象的 final 域已經初始化過。

讀 final 域重排序規則

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

鎖 17

Q1:談一談 synchronized

每個 Java 對象都有一個關聯的 monitor,使用 synchronized 時 JVM 會根據使用環境找到對象的 monitor,根據 monitor 的狀態進行加解鎖的判斷。如果成功加鎖就成為該 monitor 的唯一持有者,monitor 在被釋放前不能再被其他線程獲取。

同步代碼塊使用 monitorenter 和 monitorexit 這兩個位元組碼指令獲取和釋放 monitor。這兩個位元組碼指令都需要一個引用類型的參數指明要鎖定和解鎖的對象,對於同步普通方法,鎖是當前實例對象;對於靜態同步方法,鎖是當前類的 Class 對象;對於同步方法塊,鎖是 synchronized 括號里的對象。

執行 monitorenter 指令時,首先嘗試獲取對象鎖。如果這個對象沒有被鎖定,或當前線程已經持有鎖,就把鎖的計數器加 1,執行 monitorexit 指令時會將鎖計數器減 1。一旦計數器為 0 鎖隨即就被釋放。

例如有兩個線程 A、B 競爭 monitor,當 A 競爭到鎖時會將 monitor 中的 owner 設置為 A,把 B 阻塞並放到等待資源的 ContentionList 隊列。ContentionList 中的部分線程會進入 EntryList,EntryList 中的線程會被指定為 OnDeck 競爭候選者,如果獲得了鎖資源將進入 Owner 狀態,釋放鎖後進入 !Owner 狀態。被阻塞的線程會進入 WaitSet。

被 synchronized 修飾的同步塊對一條線程來說是可重入的,並且同步塊在持有鎖的線程釋放鎖前會阻塞其他線程進入。從執行成本的角度看,持有鎖是一個重量級的操作。Java 線程是映射到操作系統的內核線程上的,如果要阻塞或喚醒一條線程,需要操作系統幫忙完成,不可避免用戶態到核心態的轉換。

不公平的原因

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

為了防止 ContentionList 尾部的元素被大量線程進行 CAS 訪問影響性能,Owner 線程會在釋放鎖時將 ContentionList 的部分線程移動到 EntryList 並指定某個線程為 OnDeck 線程,該行為叫做競爭切換,犧牲了公平性但提高了性能。

Q2:鎖優化有哪些策略?

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

Q3:自旋鎖是什麼?

同步對性能最大的影響是阻塞,掛起和恢複線程的操作都需要轉入內核態完成。許多應用上共享數據的鎖定只會持續很短的時間,為了這段時間去掛起和恢複線程並不值得。如果機器有多個處理器核心,我們可以讓後面請求鎖的線程稍等一會,但不放棄處理器的執行時間,看看持有鎖的線程是否很快會釋放鎖。為了讓線程等待只需讓線程執行一個忙循環,這項技術就是自旋鎖。

自旋鎖在 JDK1.4 就已引入,默認關閉,在 JDK6 中改為默認開啟。自旋不能代替阻塞,雖然避免了線程切換開銷,但要佔用處理器時間,如果鎖被佔用的時間很短,自旋的效果就會非常好,反之只會白白消耗處理器資源。如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應掛起線程,自旋默認限定次數是 10。

Q4:什麼是自適應自旋?

JDK6 對自旋鎖進行了優化,自旋時間不再固定,而是由前一次的自旋時間及鎖擁有者的狀態決定。

如果在同一個鎖上,自旋剛剛成功獲得過鎖且持有鎖的線程正在運行,虛擬機會認為這次自旋也很可能成功,進而允許自旋持續更久。如果自旋很少成功,以後獲取鎖時將可能直接省略掉自旋,避免浪費處理器資源。

有了自適應自旋,隨着程序運行時間的增長,虛擬機對程序鎖的狀況預測就會越來越精準。

Q5:鎖消除是什麼?

鎖消除指即時編譯器對檢測到不可能存在共享數據競爭的鎖進行消除。

主要判定依據來源於逃逸分析,如果判斷一段代碼中堆上的所有數據都只被一個線程訪問,就可以當作棧上的數據對待,認為它們是線程私有的而無須同步。

Q6:鎖粗化是什麼?

原則需要將同步塊的作用範圍限制得盡量小,只在共享數據的實際作用域中進行同步,這是為了使等待鎖的線程儘快拿到鎖。

但如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,甚至加鎖操作是出現在循環體之外的,即使沒有線程競爭也會導致不必要的性能消耗。因此如果虛擬機探測到有一串零碎的操作都對同一個對象加鎖,將會把同步的範圍擴展到整個操作序列的外部。

Q7:偏向鎖是什麼?

偏向鎖是為了在沒有競爭的情況下減少鎖開銷,鎖會偏向於第一個獲得它的線程,如果在執行過程中鎖一直沒有被其他線程獲取,則持有偏向鎖的線程將不需要進行同步。

當鎖對象第一次被線程獲取時,虛擬機會將對象頭中的偏向模式設為 1,同時使用 CAS 把獲取到鎖的線程 ID 記錄在對象的 Mark Word 中。如果 CAS 成功,持有偏向鎖的線程以後每次進入鎖相關的同步塊都不再進行任何同步操作。

一旦有其他線程嘗試獲取鎖,偏向模式立即結束,根據鎖對象是否處於鎖定狀態決定是否撤銷偏向,後續同步按照輕量級鎖那樣執行。

Q8:輕量級鎖是什麼?

輕量級鎖是為了在沒有競爭的前提下減少重量級鎖使用操作系統互斥量產生的性能消耗。

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

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

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

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

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

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

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

Q10:Lock 和 synchronized 有什麼區別?

Lock 接是 juc 包的頂層接口,基於Lock 接口,用戶能夠以非塊結構來實現互斥同步,擺脫了語言特性束縛,在類庫層面實現同步。Lock 並未用到 synchronized,而是利用了 volatile 的可見性。

重入鎖 ReentrantLock 是 Lock 最常見的實現,與 synchronized 一樣可重入,不過它增加了一些高級功能:

  • **等待可中斷: **持有鎖的線程長期不釋放鎖時,正在等待的線程可以選擇放棄等待而處理其他事情。
  • 公平鎖: 公平鎖指多個線程在等待同一個鎖時,必須按照申請鎖的順序來依次獲得鎖,而非公平鎖不保證這一點,在鎖被釋放時,任何線程都有機會獲得鎖。synchronized 是非公平的,ReentrantLock 在默認情況下是非公平的,可以通過構造方法指定公平鎖。一旦使用了公平鎖,性能會急劇下降,影響吞吐量。
  • 鎖綁定多個條件: 一個 ReentrantLock 可以同時綁定多個 Condition。synchronized 中鎖對象的 waitnotify 可以實現一個隱含條件,如果要和多個條件關聯就不得不額外添加鎖,而 ReentrantLock 可以多次調用 newCondition 創建多個條件。

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

Q11:ReentrantLock 的可重入是怎麼實現的?

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

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

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

Q12:什麼是讀寫鎖?

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

讀寫鎖依賴 AQS 來實現同步功能,讀寫狀態就是其同步器的同步狀態。讀寫鎖的自定義同步器需要在同步狀態,即一個 int 變量上維護多個讀線程和一個寫線程的狀態。讀寫鎖將變量切分成了兩個部分,高 16 位表示讀,低 16 位表示寫。

寫鎖是可重入排他鎖,如果當前線程已經獲得了寫鎖則增加寫狀態,如果當前線程在獲取寫鎖時,讀鎖已經被獲取或者該線程不是已經獲得寫鎖的線程則進入等待。寫鎖的釋放與 ReentrantLock 的釋放類似,每次釋放減少寫狀態,當寫狀態為 0 時表示寫鎖已被釋放。

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

鎖降級指把持住當前擁有的寫鎖,再獲取讀鎖,隨後釋放先前擁有的寫鎖。

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

Q13:AQS 了解嗎?

AQS 隊列同步器是用來構建鎖或其他同步組件的基礎框架,它使用一個 volatile int state 變量作為共享資源,如果線程獲取資源失敗,則進入同步隊列等待;如果獲取成功就執行臨界區代碼,釋放資源時會通知同步隊列中的等待線程。

同步器的主要使用方式是繼承,子類通過繼承同步器並實現它的抽象方法來管理同步狀態,對同步狀態進行更改需要使用同步器提供的 3個方法 getStatesetStatecompareAndSetState ,它們保證狀態改變是安全的。子類推薦被定義為自定義同步組件的靜態內部類,同步器自身沒有實現任何同步接口,它僅僅定義若干同步狀態獲取和釋放的方法,同步器既支持獨佔式也支持共享式。

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

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

Q14:AQS 有哪兩種模式?

獨佔模式表示鎖只會被一個線程佔用,其他線程必須等到持有鎖的線程釋放鎖後才能獲取鎖,同一時間只能有一個線程獲取到鎖。

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

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

Q15:AQS 獨佔式獲取/釋放鎖的原理?

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

釋放同步狀態時,同步器調用 tryRelease 方法釋放同步狀態,然後調用 unparkSuccessor 方法喚醒頭節點的後繼節點,使後繼節點重新嘗試獲取同步狀態。

Q16:為什麼只有前驅節點是頭節點時才能嘗試獲取同步狀態?

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

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

Q17:AQS 共享式式獲取/釋放鎖的原理?

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

釋放同步狀態時,調用 releaseShared 方法,釋放後會喚醒後續處於等待狀態的節點。它和獨佔式的區別在於 tryReleaseShared 方法必須確保同步狀態安全釋放,通過循環 CAS 保證,因為釋放同步狀態的操作會同時來自多個線程。

線程 13

Q1:線程的生命周期有哪些狀態?

NEW:新建狀態,線程被創建且未啟動,此時還未調用 start 方法。

RUNNABLE:Java 將操作系統中的就緒和運行兩種狀態統稱為 RUNNABLE,此時線程有可能在等待時間片,也有可能在執行。

BLOCKED:阻塞狀態,可能由於鎖被其他線程佔用、調用了 sleepjoin 方法、執行了 wait方法等。

WAITING:等待狀態,該狀態線程不會被分配 CPU 時間片,需要其他線程通知或中斷。可能由於調用了無參的 waitjoin 方法。

TIME_WAITING:限期等待狀態,可以在指定時間內自行返回。導可能由於調用了帶參的 waitjoin 方法。

TERMINATED:終止狀態,表示當前線程已執行完畢或異常退出。

Q2:線程的創建方式有哪些?

① 繼承 Thread 類並重寫 run 方法。實現簡單,但不符合里氏替換原則,不可以繼承其他類。

② 實現 Runnable 接口並重寫 run 方法。避免了單繼承局限性,編程更加靈活,實現解耦。

③實現 Callable 接口並重寫 call 方法。可以獲取線程執行結果的返回值,並且可以拋出異常。

Q3:線程有哪些方法?

sleep 方***導致當前線程進入休眠狀態,與 wait 不同的是該方法不會釋放鎖資源,進入的是 TIMED-WAITING 狀態。

yiled 方法使當前線程讓出 CPU 時間片給優先級相同或更高的線程,回到 RUNNABLE 狀態,與其他線程一起重新競爭CPU時間片。

join 方法用於等待其他線程運行終止,如果當前線程調用了另一個線程的 join 方法,則當前線程進入阻塞狀態,當另一個線程結束時當前線程才能從阻塞狀態轉為就緒態,等待獲取CPU時間片。底層使用的是wait,也會釋放鎖。

Q4:什麼是守護線程?

守護線程是一種支持型線程,可以通過 setDaemon(true) 將線程設置為守護線程,但必須在線程啟動前設置。

守護線程被用於完成支持性工作,但在 JVM 退出時守護線程中的 finally 塊不一定執行,因為 JVM 中沒有非守護線程時需要立即退出,所有守護線程都將立即終止,不能靠在守護線程使用 finally 確保關閉資源。

Q5:線程通信的方式有哪些?

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

volatile 告知程序任何對變量的讀需要從主內存中獲取,寫必須同步刷新回主內存,保證所有線程對變量訪問的可見性。

synchronized 確保多個線程在同一時刻只能有一個處於方法或同步塊中,保證線程對變量訪問的原子性、可見性和有序性。

等待通知機制指一個線程 A 調用了對象的 wait 方法進入等待狀態,另一線程 B 調用了對象的 notify/notifyAll 方法,線程 A 收到通知後結束阻塞並執行後序操作。對象上的 waitnotify/notifyAll 如同開關信號,完成等待方和通知方的交互。

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

管道 IO 流用於線程間數據傳輸,媒介為內存。PipedOutputStream 和 PipedWriter 是輸出流,相當於生產者,PipedInputStream 和 PipedReader 是輸入流,相當於消費者。管道流使用一個默認大小為 1KB 的循環緩衝數組。輸入流從緩衝數組讀數據,輸出流往緩衝數組中寫數據。當數組已滿時,輸出流所在線程阻塞;當數組首次為空時,輸入流所在線程阻塞。

ThreadLocal 是線程共享變量,但它可以為每個線程創建單獨的副本,副本值是線程私有的,互相之間不影響。

Q6:線程池有什麼好處?

降低資源消耗,復用已創建的線程,降低開銷、控制最大並發數。

隔離線程環境,可以配置獨立線程池,將較慢的線程與較快的隔離開,避免相互影響。

實現任務線程隊列緩衝策略和拒絕機制。

實現某些與時間相關的功能,如定時執行、周期執行等。

Q7:線程池處理任務的流程?

① 核心線程池未滿,創建一個新的線程執行任務,此時 workCount < corePoolSize。

② 如果核心線程池已滿,工作隊列未滿,將線程存儲在工作隊列,此時 workCount >= corePoolSize。

③ 如果工作隊列已滿,線程數小於最大線程數就創建一個新線程處理任務,此時 workCount < maximumPoolSize,這一步也需要獲取全局鎖。

④ 如果超過大小線程數,按照拒絕策略來處理任務,此時 workCount > maximumPoolSize。

線程池創建線程時,會將線程封裝成工作線程 Worker,Worker 在執行完任務後還會循環獲取工作隊列中的任務來執行。

Q8:有哪些創建線程池的方法?

可以通過 Executors 的靜態工廠方法創建線程池:

newFixedThreadPool,固定大小的線程池,核心線程數也是最大線程數,不存在空閑線程,keepAliveTime = 0。該線程池使用的工作隊列是無界阻塞隊列 LinkedBlockingQueue,適用於負載較重的服務器。

newSingleThreadExecutor,使用單線程,相當於單線程串行執行所有任務,適用於需要保證順序執行任務的場景。

newCachedThreadPool,maximumPoolSize 設置為 Integer 最大值,是高度可伸縮的線程池。該線程池使用的工作隊列是沒有容量的 SynchronousQueue,如果主線程提交任務的速度高於線程處理的速度,線程池會不斷創建新線程,極端情況下會創建過多線程而耗盡CPU 和內存資源。適用於執行很多短期異步任務的小程序或負載較輕的服務器。

newScheduledThreadPool:線程數最大為 Integer 最大值,存在 OOM 風險。支持定期及周期性任務執行,適用需要多個後台線程執行周期任務,同時需要限制線程數量的場景。相比 Timer 更安全,功能更強,與 newCachedThreadPool 的區別是不回收工作線程。

newWorkStealingPool:JDK8 引入,創建持有足夠線程的線程池支持給定的並行度,通過多個隊列減少競爭。

Q9:創建線程池有哪些參數?

① corePoolSize:常駐核心線程數,如果為 0,當執行完任務沒有任何請求時會消耗線程池;如果大於 0,即使本地任務執行完,核心線程也不會被銷毀。該值設置過大會浪費資源,過小會導致線程的頻繁創建與銷毀。

② maximumPoolSize:線程池能夠容納同時執行的線程最大數,必須大於等於 1,如果與核心線程數設置相同代表固定大小線程池。

③ keepAliveTime:線程空閑時間,線程空閑時間達到該值後會被銷毀,直到只剩下 corePoolSize 個線程為止,避免浪費內存資源。

④ unit:keepAliveTime 的時間單位。

⑤ workQueue:工作隊列,當線程請求數大於等於 corePoolSize 時線程會進入阻塞隊列。

⑥ threadFactory:線程工廠,用來生產一組相同任務的線程。可以給線程命名,有利於分析錯誤。

⑦ handler:拒絕策略,默認使用 AbortPolicy 丟棄任務並拋出異常,CallerRunsPolicy 表示重新嘗試提交該任務,DiscardOldestPolicy 表示拋棄隊列里等待最久的任務並把當前任務加入隊列,DiscardPolicy 表示直接拋棄當前任務但不拋出異常。

Q10:如何關閉線程池?

可以調用 shutdownshutdownNow 方法關閉線程池,原理是遍歷線程池中的工作線程,然後逐個調用線程的 interrupt 方法中斷線程,無法響應中斷的任務可能永遠無法終止。

區別是 shutdownNow 首先將線程池的狀態設為 STOP,然後嘗試停止正在執行或暫停任務的線程,並返回等待執行任務的列表。而 shutdown 只是將線程池的狀態設為 SHUTDOWN,然後中斷沒有正在執行任務的線程。

通常調用 shutdown 來關閉線程池,如果任務不一定要執行完可調用 shutdownNow

Q11:線程池的選擇策略有什麼?

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

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

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

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

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

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

Q12:阻塞隊列有哪些選擇?

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

Java 中的阻塞隊列

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

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

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

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

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

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

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

實現原理

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

Q13:談一談 ThreadLocal

ThreadLoacl 是線程共享變量,主要用於一個線程內跨類、方法傳遞數據。ThreadLoacl 有一個靜態內部類 ThreadLocalMap,其 Key 是 ThreadLocal 對象,值是 Entry 對象,Entry 中只有一個 Object 類的 vaule 值。ThreadLocal 是線程共享的,但 ThreadLocalMap 是每個線程私有的。ThreadLocal 主要有 set、get 和 remove 三個方法。

set 方法

首先獲取當前線程,然後再獲取當前線程對應的 ThreadLocalMap 類型的對象 map。如果 map 存在就直接設置值,key 是當前的 ThreadLocal 對象,value 是傳入的參數。

如果 map 不存在就通過 createMap 方法為當前線程創建一個 ThreadLocalMap 對象再設置值。

get 方法

首先獲取當前線程,然後再獲取當前線程對應的 ThreadLocalMap 類型的對象 map。如果 map 存在就以當前 ThreadLocal 對象作為 key 獲取 Entry 類型的對象 e,如果 e 存在就返回它的 value 屬性。

如果 e 不存在或者 map 不存在,就調用 setInitialValue 方法先為當前線程創建一個 ThreadLocalMap 對象然後返回默認的初始值 null。

remove 方法

首先通過當前線程獲取其對應的 ThreadLocalMap 類型的對象 m,如果 m 不為空,就解除 ThreadLocal 這個 key 及其對應的 value 值的聯繫。

存在的問題

線程復用會產生臟數據,由於線程池會重用 Thread 對象,因此與 Thread 綁定的 ThreadLocal 也會被重用。如果沒有調用 remove 清理與線程相關的 ThreadLocal 信息,那麼假如下一個線程沒有調用 set 設置初始值就可能 get 到重用的線程信息。

ThreadLocal 還存在內存泄漏的問題,由於 ThreadLocal 是弱引用,但 Entry 的 value 是強引用,因此當 ThreadLocal 被垃圾回收後,value 依舊不會被釋放。因此需要及時調用 remove 方法進行清理操作。

JUC 11

Q1:什麼是 CAS?

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

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

Q2:CAS 有什麼問題?

CAS 從語義上來說存在一個邏輯漏洞:如果 V 初次讀取時是 A,並且在準備賦值時仍為 A,這依舊不能說明它沒有被其他線程更改過,因為這段時間內假設它的值先改為 B 又改回 A,那麼 CAS 操作就會誤認為它從來沒有被改變過。

這個漏洞稱為 ABA 問題,juc 包提供了一個 AtomicStampedReference,原子更新帶有版本號的引用類型,通過控制變量值的版本來解決 ABA 問題。大部分情況下 ABA 不會影響程序並發的正確性,如果需要解決,傳統的互斥同步可能會比原子類更高效。

Q3:有哪些原子類?

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

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

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

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

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

Q4:AtomicIntger 實現原子更新的原理是什麼?

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

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

atomic 包中只提供了三種基本類型的原子更新,atomic 包里的類基本都是使用 Unsafe 實現的,Unsafe 只提供三種 CAS 方法:compareAndSwapIntcompareAndSwapLongcompareAndSwapObject,例如原子更新 Boolean 是先轉成整形再使用 compareAndSwapInt

Q5:CountDownLatch 是什麼?

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

Q6: CyclicBarrier 是什麼?

循環屏障是基於同步到達某個點的信號量觸發機制,作用是讓一組線程到達一個屏障時被阻塞,直到最後一個線程到達屏障才會解除。構造方法中的參數表示攔截線程數量,每個線程調用 await 方法告訴 CyclicBarrier 自己已到達屏障,然後被阻塞。還支持在構造方法中傳入一個 Runnable 任務,當線程到達屏障時會優先執行該任務。適用於多線程計算數據,最後合併計算結果的應用場景。

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

Q7:Semaphore 是什麼?

信號量用來控制同時訪問特定資源的線程數量,通過協調各個線程以保證合理使用公共資源。信號量可以用於流量控制,特別是公共資源有限的應用場景,比如數據庫連接。

Semaphore 的構造方法參數接收一個 int 值,表示可用的許可數量即最大並發數。使用 acquire 方法獲得一個許可證,使用 release 方法歸還許可,還可以用 tryAcquire 嘗試獲得許可。

Q8: Exchanger 是什麼?

交換者是用於線程間協作的工具類,用於進行線程間的數據交換。它提供一個同步點,在這個同步點兩個線程可以交換彼此的數據。

兩個線程通過 exchange 方法交換數據,第一個線程執行 exchange 方法後會阻塞等待第二個線程執行該方法,當兩個線程都到達同步點時這兩個線程就可以交換數據,將本線程生產出的數據傳遞給對方。應用場景包括遺傳算法、校對工作等。

P9:JDK7 的 ConcurrentHashMap 原理?

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

get 實現簡單高效,先經過一次再散列,再用這個散列值通過散列運算定位到 Segment,最後通過散列算法定位到元素。get 的高效在於不需要加鎖,除非讀到空值才會加鎖重讀。get 方法中將共享變量定義為 volatile,在 get 操作里只需要讀所以不用加鎖。

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

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

P10:JDK8 的 ConcurrentHashMap 原理?

主要對 JDK7 做了三點改造:① 取消分段鎖機制,進一步降低衝突概率。② 引入紅黑樹結構,同一個哈希槽上的元素個數超過一定閾值後,單向鏈表改為紅黑樹結構。③ 使用了更加優化的方式統計集合內的元素數量。具體優化表現在:在 put、resize 和 size 方法中設計元素總數的更新和計算都避免了鎖,使用 CAS 代替。

get 同樣不需要同步,put 操作時如果沒有出現哈希衝突,就使用 CAS 添加元素,否則使用 synchronized 加鎖添加元素。

當某個槽內的元素個數達到 7 且 table 容量不小於 64 時,鏈錶轉為紅黑樹。當某個槽內的元素減少到 6 時,由紅黑樹重新轉為鏈表。在轉化過程中,使用同步塊鎖住當前槽的首元素,防止其他線程對當前槽進行增刪改操作,轉化完成後利用 CAS 替換原有鏈表。由於 TreeNode 節點也存儲了 next 引用,因此紅黑樹轉為鏈表很簡單,只需從 first 元素開始遍歷所有節點,並把節點從 TreeNode 轉為 Node 類型即可,當構造好新鏈表後同樣用 CAS 替換紅黑樹。

P11:ArrayList 的線程安全集合是什麼?

可以使用 CopyOnWriteArrayList 代替 ArrayList,它實現了讀寫分離。寫操作複製一個新的集合,在新集合內添加或刪除元素,修改完成後再將原集合的引用指向新集合。這樣做的好處是可以高並發地進行讀寫操作而不需要加鎖,因為當前集合不會添加任何元素。使用時注意盡量設置容量初始值,並且可以使用批量添加或刪除,避免多次擴容,比如只增加一個元素卻複製整個集合。

適合讀多寫少,單個添加時效率極低。CopyOnWriteArrayList 是 fail-safe 的,並發包的集合都是這種機制,fail-safe 在安全的副本上遍歷,集合修改與副本遍歷沒有任何關係,缺點是無法讀取最新數據。這也是 CAP 理論中 C 和 A 的矛盾,即一致性與可用性的矛盾。

框架 27

Spring IoC 11

Q1:IoC 是什麼?

IoC 即控制反轉,簡單來說就是把原來代碼里需要實現的對象創建、依賴反轉給容器來幫忙實現,需要創建一個容器並且需要一種描述讓容器知道要創建的對象間的關係,在 Spring 中管理對象及其依賴關係是通過 Spring 的 IoC 容器實現的。

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

Q2:IoC 容器初始化過程?

基於 XML 的容器初始化

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

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

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

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

基於註解的容器初始化

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

Q3:依賴注入的實現方法有哪些?

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

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

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

Q4:依賴注入的相關註解?

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

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

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

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

Q5:依賴注入的過程?

getBean 方法獲取 Bean 實例,該方***調用 doGetBeandoGetBean 真正實現從 IoC 容器獲取 Bean 的功能,也是觸發依賴注入的地方。

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

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

BeanWrapperImpl 類負責對完成初始化的 Bean 對象進行依賴注入,對於非集合類型屬性,使用 JDK 反射,通過屬性的 setter 方法為屬性設置注入後的值。對於集合類型的屬性,將屬性值解析為目標類型的集合後直接賦值給屬性。

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

Q6:Bean 的生命周期?

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

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

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

Q7:Bean 的作用範圍?

通過 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 中。

Q8:如何通過 XML 方式創建 Bean?

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

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

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

Q9:如何通過註解創建 Bean?

@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 一樣。

Q10:如何通過註解配置文件?

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

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

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

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

Q11:BeanFactory、FactoryBean 和 ApplicationContext 的區別?

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

FactoryBean 是一個工廠 Bean,使用了工廠方法模式,作用是生產其他 Bean 實例,可以通過實現該接口,提供一個工廠方法來自定義實例化 Bean 的邏輯。FactoryBean 接口由 BeanFactory 中配置的對象實現,這些對象本身就是用於創建對象的工廠,如果一個 Bean 實現了這個接口,那麼它就是創建對象的工廠 Bean,而不是 Bean 實例本身。

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

Spring AOP 4

Q1:AOP 是什麼?

AOP 即面向切面編程,簡單地說就是將代碼中重複的部分抽取出來,在需要執行的時候使用動態代理技術,在不修改源碼的基礎上對方法進行增強。

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

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

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

Q2:AOP 的相關註解有哪些?

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

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

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

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

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

Q3:AOP 的相關術語有什麼?

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

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

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

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

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

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

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

Q4: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 對目標對象的增強是通過攔截器實現的。

Spring MVC 3

Q1: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 物理視圖,然後對視圖渲染,將數據填充到視圖中並返回給客戶端。

Q2: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 通過它將邏輯視圖解析為物理視圖,最終將渲染的結果響應給客戶端。

Q3:Spring MVC 的相關註解?

@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 完成請求參數與形參的綁定。

Spring Data JPA 4

Q1:ORM 是什麼?

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

Q2:JPA 如何使用?

只需要持久層接口繼承 JpaRepository 即可,泛型參數列表中第一個參數是實體類類型,第二個參數是主鍵類型。

運行時通過 JdkDynamicAopProxyinvoke 方法創建了一個動態代理對象 SimpleJpaRepositorySimpleJpaRepository 中封裝了 JPA 的操作,通過 hibernate(封裝了JDBC)完成數據庫操作。

Q3:JPA 實體類相關註解有哪些?

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

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

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

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

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

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

@ManyToOne :配置多對一關係,targetEntity 屬性值為主表對應實體類的位元組碼。

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

Q4:對象導航查詢是什麼?

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

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

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

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

Mybatis 5

Q1:Mybatis 的優缺點?

優點

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

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

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

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

缺點

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

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

Q2:Mybatis 的 XML 文件有哪些標籤屬性?

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

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

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

Q3:Mybatis 的一級緩存是什麼?

一級緩存是 SqlSession 級別,默認開啟且不能關閉。

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

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

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

Q4:Mybatis 的二級緩存是什麼?

二級緩存是Mapper 級別,默認關閉。

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

要使用二級緩存,需要在全局配置文件中配置 <setting name="cacheEnabled" value="true"/> ,再在對應的映射文件中配置一個 <cache/> 標籤。

Q5:Mybatis #{}${} 的區別?

使用 ${} 相當於使用字符串拼接,存在 SQL 注入的風險。

使用 #{} 相當於使用佔位符,可以防止 SQL 注入,不支持使用佔位符的地方就只能使用 ${} ,典型情況就是動態參數。

數據結構和算法 13

數據結構 4

Q1:什麼是 AVL 樹?

AVL 樹是平衡二叉查找樹,增加和刪除節點後通過樹形旋轉重新達到平衡。右旋是以某個節點為中心,將它沉入當前右子節點的位置,而讓當前的左子節點作為新樹的根節點,也稱為順時針旋轉。同理左旋是以某個節點為中心,將它沉入當前左子節點的位置,而讓當前的右子節點作為新樹的根節點,也稱為逆時針旋轉。

Q2:什麼是紅黑樹?

紅黑樹是 1972 年發明的,稱為對稱二叉 B 樹,1978 年正式命名紅黑樹。主要特徵是在每個節點上增加一個屬性表示節點顏色,可以紅色或黑色。紅黑樹和 AVL 樹類似,都是在進行插入和刪除時通過旋轉保持自身平衡,從而獲得較高的查找性能。與 AVL 樹相比,紅黑樹不追求所有遞歸子樹的高度差不超過 1,保證從根節點到葉尾的最長路徑不超過最短路徑的 2 倍,所以最差時間複雜度是 O(logn)。紅黑樹通過重新着色和左右旋轉,更加高效地完成了插入和刪除之後的自平衡調整。

紅黑樹在本質上還是二叉查找樹,它額外引入了 5 個約束條件:① 節點只能是紅色或黑色。② 根節點必須是黑色。③ 所有 NIL 節點都是黑色的。④ 一條路徑上不能出現相鄰的兩個紅色節點。⑤ 在任何遞歸子樹中,根節點到葉子節點的所有路徑上包含相同數目的黑色節點。這五個約束條件保證了紅黑樹的新增、刪除、查找的最壞時間複雜度均為 O(logn)。如果一個樹的左子節點或右子節點不存在,則均認定為黑色。紅黑樹的任何旋轉在 3 次之內均可完成。

Q3:AVL 樹和紅黑樹的區別?

紅黑樹的平衡性不如 AVL 樹,它維持的只是一種大致的平衡,不嚴格保證左右子樹的高度差不超過 1。這導致節點數相同的情況下,紅黑樹的高度可能更高,也就是說平均查找次數會高於相同情況的 AVL 樹。

在插入時,紅黑樹和 AVL 樹都能在至多兩次旋轉內恢復平衡,在刪除時由於紅黑樹只追求大致平衡,因此紅黑樹至多三次旋轉可以恢復平衡,而 AVL 樹最多需要 O(logn) 次。AVL 樹在插入和刪除時,將向上回溯確定是否需要旋轉,這個回溯的時間成本最差為 O(logn),而紅黑樹每次向上回溯的步長為 2,回溯成本低。因此面對頻繁地插入與刪除紅黑樹更加合適。

Q4:B 樹和B+ 樹的區別?

B 樹中每個節點同時存儲 key 和 data,而 B+ 樹中只有葉子節點才存儲 data,非葉子節點只存儲 key。InnoDB 對 B+ 樹進行了優化,在每個葉子節點上增加了一個指向相鄰葉子節點的鏈表指針,形成了帶有順序指針的 B+ 樹,提高區間訪問的性能。

B+ 樹的優點在於:① 由於 B+ 樹在非葉子節點上不含數據信息,因此在內存頁中能夠存放更多的 key,數據存放得更加緊密,具有更好的空間利用率,訪問葉子節點上關聯的數據也具有更好的緩存命中率。② B+樹的葉子結點都是相連的,因此對整棵樹的遍歷只需要一次線性遍歷葉子節點即可。而 B 樹則需要進行每一層的遞歸遍歷,相鄰的元素可能在內存中不相鄰,所以緩存命中性沒有 B+樹好。但是 B 樹也有優點,由於每個節點都包含 key 和 value,因此經常訪問的元素可能離根節點更近,訪問也更迅速。

排序 9

Q1:排序有哪些分類?

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

內部排序包括比較排序和非比較排序,比較排序包括插入/選擇/交換/歸併排序,非比較排序包括計數/基數/桶排序。

插入排序包括直接插入/希爾排序,選擇排序包括直接選擇/堆排序,交換排序包括冒泡/快速排序。

Q2:直接插入排序的原理?

穩定,平均/最差時間複雜度 O(n²),元素基本有序時最好時間複雜度 O(n),空間複雜度 O(1)。

每一趟將一個待排序記錄按其關鍵字的大小插入到已排好序的一組記錄的適當位置上,直到所有待排序記錄全部插入為止。

複製代碼

12345678910 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 個元素時可以通過二分查找找到插入位置 insertIndex,再把 i~insertIndex 之間的所有元素後移一位,把第 i 個元素放在插入位置上。

複製代碼

123456789101112131415161718192021222324 public void binaryInsertionSort(``int``[] nums) {`` ``for (``int i = ``1``; i < nums.length; i++) {`` ``int insertNum = nums[i];`` ``int insertIndex = -``1``;`` ``int start = ``0``;`` ``int end = i - ``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 (i - insertIndex >= ``0``)`` ``System.arraycopy(nums, insertIndex, nums, insertIndex + ``1``, i - insertIndex);`` ``nums[insertIndex] = insertNum;`` ``}``}

Q3:希爾排序的原理?

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

把記錄按下標的一定增量分組,對每組進行直接插入排序,每次排序後減小增量,當增量減至 1 時排序完畢。

複製代碼

123456789101112 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;`` ``}`` ``}``}

Q4:直接選擇排序的原理?

不穩定,時間複雜度 O(n²),空間複雜度 O(1)。

每次在未排序序列中找到最小元素,和未排序序列的第一個元素交換位置,再在剩餘未排序序列中重複該操作直到所有元素排序完畢。

複製代碼

12345678910111213 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);`` ``}`` ``}``}

Q5:堆排序的原理?

是對直接選擇排序的改進,不穩定,時間複雜度 O(nlogn),空間複雜度 O(1)。

將待排序記錄看作完全二叉樹,可以建立大根堆或小根堆,大根堆中每個節點的值都不小於它的子節點值,小根堆中每個節點的值都不大於它的子節點值。

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

複製代碼

123456789101112131415161718192021222324252627282930 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;``}

Q6:冒泡排序的原理?

穩定,平均/最壞時間複雜度 O(n²),元素基本有序時最好時間複雜度 O(n),空間複雜度 O(1)。

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

複製代碼

12345678 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``)`` ``}`` ``}``}

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

複製代碼

12345678910111213 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``;`` ``}``}

Q7:快速排序的原理?

是對冒泡排序的一種改進,不穩定,平均/最好時間複雜度 O(nlogn),元素基本有序時最壞時間複雜度 O(n²),空間複雜度 O(logn)。

首先選擇一個基準元素,通過一趟排序將要排序的數據分割成獨立的兩部分,一部分全部小於等於基準元素,一部分全部大於等於基準元素,再按此方法遞歸對這兩部分數據進行快速排序。

快速排序的一次劃分從兩頭交替搜索,直到 low 和 high 指針重合,一趟時間複雜度 O(n),整個算法的時間複雜度與劃分趟數有關。

最好情況是每次劃分選擇的中間數恰好將當前序列等分,經過 log(n) 趟劃分便可得到長度為 1 的子表,這樣時間複雜度 O(nlogn)。

最壞情況是每次所選中間數是當前序列中的最大或最小元素,這使每次劃分所得子表其中一個為空表 ,這樣長度為 n 的數據表需要 n 趟劃分,整個排序時間複雜度 O(n²)。

複製代碼

1234567891011121314151617181920212223 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 時,採用直接插入排序。

Q8:歸併排序的原理?

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

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

適用場景:數據量大且對穩定性有要求的情況。

複製代碼

1234567891011121314151617181920212223242526272829 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++];``}

Q9:排序算法怎麼選擇?

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

數據量規模中等,選擇希爾排序。

數據量規模較大,考慮堆排序(元素分佈接近正序或逆序)、快速排序(元素分佈隨機)和歸併排序(穩定性)。

一般不使用冒泡。

設計模式 15

Q1:設計模式有哪些原則?

開閉原則:OOP 中最基礎的原則,指一個軟件實體(類、模塊、方法等)應該對擴展開放,對修改關閉。強調用抽象構建框架,用實現擴展細節,提高代碼的可復用性和可維護性。

單一職責原則:一個類、接口或方法只負責一個職責,降低代碼複雜度以及變更引起的風險。

依賴倒置原則:程序應該依賴於抽象類或接口,而不是具體的實現類。

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

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

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

合成/聚合原則:盡量使用組合(has-a)/聚合(contains-a)而不是繼承(is-a)達到軟件復用的目的,避免濫用繼承帶來的方法污染和方法爆炸,方法污染指父類的行為通過繼承傳遞給子類,但子類並不具備執行此行為的能力;方法爆炸指繼承樹不斷擴大,底層類擁有的方法過於繁雜,導致很容易選擇錯誤。

Q2:設計模式的分類,你知道哪些設計模式?

創建型: 在創建對象的同時隱藏創建邏輯,不使用 new 直接實例化對象,程序在判斷需要創建哪些對象時更靈活。包括工廠/抽象工廠/單例/建造者/原型模式。

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

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

Q3:說一說簡單工廠模式

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

適用於工廠類負責創建對象較少的情況,缺點是如果要增加新產品,就需要修改工廠類的判斷邏輯,違背開閉原則,且產品多的話會使工廠類比較複雜。

Calendar 抽象類的 getInstance 方法,調用 createCalendar 方法根據不同的地區參數創建不同的日曆對象。

Spring 中的 BeanFactory 使用簡單工廠模式,根據傳入一個唯一的標識來獲得 Bean 對象。

Q4:說一說工廠方法模式

工廠方法模式指定義一個創建對象的接口,讓接口的實現類決定創建哪種對象,讓類的實例化推遲到子類中進行。

客戶端只需關心對應工廠而無需關心創建細節,主要解決了產品擴展的問題,在簡單工廠模式中如果產品種類變多,工廠的職責會越來越多,不便於維護。

Collection 接口這個抽象工廠中定義了一個抽象的 iterator 工廠方法,返回一個 Iterator 類的抽象產品。該方法通過 ArrayList 、HashMap 等具體工廠實現,返回 Itr、KeyIterator 等具體產品。

Spring 的 FactoryBean 接口的 getObject 方法也是工廠方法。

Q5:抽象工廠模式了解嗎?

抽象工廠模式指提供一個創建一系列相關或相互依賴對象的接口,無需指定它們的具體類。

客戶端不依賴於產品類實例如何被創建和實現的細節,主要用於系統的產品有多於一個的產品族,而系統只消費其中某一個產品族產品的情況。抽象工廠模式的缺點是不方便擴展產品族,並且增加了系統的抽象性和理解難度。

java.sql.Connection 接口就是一個抽象工廠,其中包括很多抽象產品如 Statement、Blob、Savepoint 等。

Q6:單例模式的特點是什麼?

單例模式屬於創建型模式,一個單例類在任何情況下都只存在一個實例,構造方法必須是私有的、由自己創建一個靜態變量存儲實例,對外提供一個靜態公有方法獲取實例。

優點是內存中只有一個實例,減少了開銷,尤其是頻繁創建和銷毀實例的情況下並且可以避免對資源的多重佔用。缺點是沒有抽象層,難以擴展,與單一職責原則衝突。

Spring 的 ApplicationContext 創建的 Bean 實例都是單例對象,還有 ServletContext、數據庫連接池等也都是單例模式。

Q7:單例模式有哪些實現?

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

複製代碼

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

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

複製代碼

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

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

複製代碼

12345678910111213141516 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;`` ``}``}

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

複製代碼

1234567891011 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》提倡的方式,不僅能避免線程安全問題,還能防止反序列化重新創建新的對象,絕對防止多次實例化,也能防止反射破解單例的問題。

複製代碼

123 public enum EnumSingleton {`` ``INSTANCE;``}

Q8:講一講代理模式

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

Spring 利用動態代理實現 AOP,如果 Bean 實現了接口就使用 JDK 代理,否則使用 CGLib 代理。

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

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

  • JDK 動態代理:通過 Proxy 類的 newInstance 方法獲取一個動態代理對象,需要傳入三個參數,被代理對象的類加載器、被代理對象實現的接口,以及一個 InvocationHandler 調用處理器來指明具體的邏輯,相比靜態代理的優勢是接口中聲明的所有方法都被轉移到 InvocationHandlerinvoke 方法集中處理。
  • CGLib 動態代理:JDK 動態代理要求實現被代理對象的接口,而 CGLib 要求繼承被代理對象,如果一個類是 final 類則不能使用 CGLib 代理。兩種代理都在運行期生成位元組碼,JDK 動態代理直接寫位元組碼,而 CGLib 動態代理使用 ASM 框架寫位元組碼,ASM 的目的是生成、轉換和分析以位元組數組表示的已編譯 Java 類。 JDK 動態代理調用代理方法通過反射機制實現,而 GCLib 動態代理通過 FastClass 機制直接調用方法,它為代理類和被代理類各生成一個類,該類為代理類和被代理類的方法分配一個 int 參數,調用方法時可以直接定位,因此調用效率更高。

Q9:講一講裝飾器模式

裝飾器模式屬於結構型模式,在不改變原有對象的基礎上將功能附加到對象,相比繼承可以更加靈活地擴展原有對象的功能。

裝飾器模式適合的場景:在不想增加很多子類的前提下擴展一個類的功能。

java.io 包中,InputStream 位元組輸入流通過裝飾器 BufferedInputStream 增強為緩衝位元組輸入流。

Q10:裝飾器模式和動態代理的區別?

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

Q11:講一講適配器模式

適配器模式屬於結構型模式,它作為兩個不兼容接口之間的橋樑,結合了兩個獨立接口的功能,將一個類的接口轉換成另外一個接口使得原本由於接口不兼容而不能一起工作的類可以一起工作。

缺點是過多使用適配器會讓系統非常混亂,不易整體把握。

java.io 包中,InputStream 位元組輸入流通過適配器 InputStreamReader 轉換為 Reader 字符輸入流。

Spring MVC 中的 HandlerAdapter,由於 handler 有很多種形式,包括 Controller、HttpRequestHandler、Servlet 等,但調用方式又是確定的,因此需要適配器來進行處理,根據適配規則調用 handle 方法。

Arrays.asList 方法,將數組轉換為對應的集合(注意不能使用修改集合的方法,因為返回的 ArrayList 是 Arrays 的一個內部類)。

Q12:適配器模式和和裝飾器模式以及代理模式的區別?

適配器模式沒有層級關係,適配器和被適配者沒有必然連續,滿足 has-a 的關係,解決不兼容的問題,是一種後置考慮。

裝飾器模式具有層級關係,裝飾器與被裝飾者實現同一個接口,滿足 is-a 的關係,注重覆蓋和擴展,是一種前置考慮。

適配器模式主要改變所考慮對象的接口,而代理模式不能改變所代理類的接口。

Q13:講一講策略模式

策略模式屬於行為型模式,定義了一系列算法並封裝起來,之間可以互相替換。策略模式主要解決在有多種算法相似的情況下,使用 if/else 所帶來的難以維護。

優點是算法可以自由切換,可以避免使用多重條件判斷並且擴展性良好,缺點是策略類會增多並且所有策略類都需要對外暴露。

在集合框架中,經常需要通過構造方法傳入一個比較器 Comparator 進行比較排序。Comparator 就是一個抽象策略,一個類通過實現該接口並重寫 compare 方法成為具體策略類。

創建線程池時,需要傳入拒絕策略,當創建新線程使當前運行的線程數超過 maximumPoolSize 時會使用相應的拒絕策略處理。

Q14:講一講模板模式

模板模式屬於行為型模式,使子類可以在不改變算法結構的情況下重新定義算法的某些步驟,適用於抽取子類重複代碼到公共父類。

優點是可以封裝固定不變的部分,擴展可變的部分。缺點是每一個不同實現都需要一個子類維護,會增加類的數量。

為防止惡意操作,一般模板方法都以 final 修飾。

HttpServlet 定義了一套處理 HTTP 請求的模板,service 方法為模板方法,定義了處理HTTP請求的基本流程,doXXX 等方法為基本方法,根據請求方法的類型做相應的處理,子類可重寫這些方法。

Q15:講一講觀察者模式

觀察者模式屬於行為型模式,也叫發佈訂閱模式,定義對象間的一種一對多的依賴關係,當一個對象的狀態發生改變時,所有依賴於它的對象都得到通知並被自動更新。主要解決一個對象狀態改變給其他對象通知的問題,缺點是如果被觀察者對象有很多的直接和間接觀察者的話通知很耗時, 如果存在循環依賴的話可能導致系統崩潰,另外觀察者無法知道目標對象具體是怎麼發生變化的。

ServletContextListener 能夠監聽 ServletContext 對象的生命周期,實際上就是監聽 Web 應用。當 Servlet 容器啟動 Web 應用時調用 contextInitialized 方法,終止時調用 contextDestroyed 方法。

MySQL 33

邏輯架構 13

Q1:MySQL 的邏輯架構了解嗎?

第一層是服務器層,主要提供連接處理、授權認證、安全等功能。

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

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

Q2:談一談 MySQL 的讀寫鎖

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

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

Q3:MySQL 的鎖策略有什麼?

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

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

Q4:數據庫死鎖如何解決?

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

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

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

Q5:事務是什麼?

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

Q6:事務有什麼特性?

原子性 atomicity

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

一致性 consistency

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

隔離性 isolation

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

持久性 durability

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

Q7:MySQL 的隔離級別有哪些?

未提交讀 READ UNCOMMITTED

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

提交讀 READ COMMITTED

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

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

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

可串行化 SERIALIZABLE

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

Q8:MVCC 是什麼?

MVCC 是多版本並發控制,在很多情況下避免加鎖,大都實現了非阻塞的讀操作,寫操作也只鎖定必要的行。

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

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

Q9:談一談 InnoDB

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

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

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

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

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

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

Q10:談一談 MyISAM

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

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

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

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

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

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

Q12:談一談 Memory

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

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

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

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

Q13:查詢執行流程是什麼?

簡單來說分為五步:① 客戶端發送一條查詢給服務器。② 服務器先檢查查詢緩存,如果命中了緩存則立刻返回存儲在緩存中的結果,否則進入下一階段。③ 服務器端進行 SQL 解析、預處理,再由優化器生成對應的執行計劃。④ MySQL 根據優化器生成的執行計劃,調用存儲引擎的 API 來執行查詢。⑤ 將結果返回給客戶端。

數據類型 3

Q1:VARCHAR 和 CHAR 的區別?

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

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

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

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

Q2:DATETIME 和 TIMESTAMP 的區別?

DATETIME 能保存大範圍的值,從 1001~9999 年,精度為秒。把日期和時間封裝到了一個整數中,與時區無關,使用 8 位元組存儲空間。

TIMESTAMP 和 UNIX 時間戳相同,只使用 4 位元組的存儲空間,範圍比 DATETIME 小得多,只能表示 1970 ~2038 年,並且依賴於時區。

Q3:數據類型有哪些優化策略?

更小的通常更好

一般情況下盡量使用可以正確存儲數據的最小數據類型,更小的數據類型通常也更快,因為它們佔用更少的磁盤、內存和 CPU 緩存。

儘可能簡單

簡單數據類型的操作通常需要更少的 CPU 周期,例如整數比字符操作代價更低,因為字符集和校對規則使字符相比整形更複雜。應該使用 MySQL 的內建類型 date、time 和 datetime 而不是字符串來存儲日期和時間,另一點是應該使用整形存儲 IP 地址。

盡量避免 NULL

通常情況下最好指定列為 NOT NULL,除非需要存儲 NULL值。因為如果查詢中包含可為 NULL 的列對 MySQL 來說更難優化,可為 NULL 的列使索引、索引統計和值比較都更複雜,並且會使用更多存儲空間。當可為 NULL 的列被索引時,每個索引記錄需要一個額外位元組,在MyISAM 中還可能導致固定大小的索引變成可變大小的索引。

如果計劃在列上建索引,就應該盡量避免設計成可為 NULL 的列。

索引 10

Q1:索引有什麼作用?

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

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

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

Q2:談一談 MySQL 的 B-Tree 索引

大多數 MySQL 引擎都支持這種索引,但底層的存儲引擎可能使用不同的存儲結構,例如 NDB 使用 T-Tree,而 InnoDB 使用 B+ Tree。

B-Tree 通常意味着所有的值都是按順序存儲的,並且每個葉子頁到根的距離相同。B-Tree 索引能夠加快訪問數據的速度,因為存儲引擎不再需要進行全表掃描來獲取需要的數據,取而代之的是從索引的根節點開始進行搜索。根節點的槽中存放了指向子節點的指針,存儲引擎根據這些指針向下層查找。通過比較節點頁的值和要查找的值可以找到合適的指針進入下層子節點,這些指針實際上定義了子節點頁中值的上限和下限。最終存儲引擎要麼找到對應的值,要麼該記錄不存在。葉子節點的指針指向的是被索引的數據,而不是其他的節點頁。

B-Tree索引的限制:

  • 如果不是按照索引的最左列開始查找,則無法使用索引。
  • 不能跳過索引中的列,例如索引為 (id,name,sex),不能只使用 id 和 sex 而跳過 name。
  • 如果查詢中有某個列的範圍查詢,則其右邊的所有列都無法使用索引。

Q3:了解 Hash 索引嗎?

哈希索引基於哈希表實現,只有精確匹配索引所有列的查詢才有效。對於每一行數據,存儲引擎都會對所有的索引列計算一個哈希碼,哈希碼是一個較小的值,並且不同鍵值的行計算出的哈希碼也不一樣。哈希索引將所有的哈希碼存儲在索引中,同時在哈希表中保存指向每個數據行的指針。

只有 Memory 引擎顯式支持哈希索引,這也是 Memory 引擎的默認索引類型。

因為索引自身只需存儲對應的哈希值,所以索引的結構十分緊湊,這讓哈希索引的速度非常快,但它也有一些限制:

  • 哈希索引數據不是按照索引值順序存儲的,無法用於排序。
  • 哈希索引不支持部分索引列匹配查找,因為哈希索引始終是使用索引列的全部內容來計算哈希值的。例如在數據列(a,b)上建立哈希索引,如果查詢的列只有a就無法使用該索引。
  • 哈希索引只支持等值比較查詢,不支持任何範圍查詢。

Q4:什麼是自適應哈希索引?

自適應哈希索引是 InnoDB 引擎的一個特殊功能,當它注意到某些索引值被使用的非常頻繁時,會在內存中基於 B-Tree 索引之上再創鍵一個哈希索引,這樣就讓 B-Tree 索引也具有哈希索引的一些優點,比如快速哈希查找。這是一個完全自動的內部行為,用戶無法控制或配置,但如果有必要可以關閉該功能。

Q5 :什麼是空間索引?

MyISAM 表支持空間索引,可以用作地理數據存儲。和 B-Tree 索引不同,這類索引無需前綴查詢。空間索引會從所有維度來索引數據,查詢時可以有效地使用任意維度來組合查詢。必須使用 MySQL 的 GIS 即地理信息系統的相關函數來維護數據,但 MySQL 對 GIS 的支持並不完善,因此大部分人都不會使用這個特性。

Q6:什麼是全文索引?

通過數值比較、範圍過濾等就可以完成絕大多數需要的查詢,但如果希望通過關鍵字匹配進行查詢,就需要基於相似度的查詢,而不是精確的數值比較,全文索引就是為這種場景設計的。

MyISAM 的全文索引是一種特殊的 B-Tree 索引,一共有兩層。第一層是所有關鍵字,然後對於每一個關鍵字的第二層,包含的是一組相關的”文檔指針”。全文索引不會索引文檔對象中的所有詞語,它會根據規則過濾掉一些詞語,例如停用詞列表中的詞都不會被索引。

Q7:什麼是聚簇索引?

聚簇索引不是一種索引類型,而是一種數據存儲方式。InnoDB 的聚簇索引實際上在同一個結構中保存了 B-Tree 索引和數據行。當表有聚餐索引時,它的行數據實際上存放在索引的葉子頁中,因為無法同時把數據行存放在兩個不同的地方,所以一個表只能有一個聚簇索引。

優點:① 可以把相關數據保存在一起。② 數據訪問更快,聚簇索引將索引和數據保存在同一個 B-Tree 中,因此獲取數據比非聚簇索引要更快。③ 使用覆蓋索引掃描的查詢可以直接使用頁節點中的主鍵值。

缺點:① 聚簇索引最大限度提高了 IO 密集型應用的性能,如果數據全部在內存中將會失去優勢。② 更新聚簇索引列的代價很高,因為會強制每個被更新的行移動到新位置。③ 基於聚簇索引的表插入新行或主鍵被更新導致行移動時,可能導致頁分裂,表會佔用更多磁盤空間。④ 當行稀疏或由於頁分裂導致數據存儲不連續時,全表掃描可能很慢。

Q8:什麼是覆蓋索引?

覆蓋索引指一個索引包含或覆蓋了所有需要查詢的字段的值,不再需要根據索引回表查詢數據。覆蓋索引必須要存儲索引列的值,因此 MySQL 只能使用 B-Tree 索引做覆蓋索引。

優點:① 索引條目通常遠小於數據行大小,可以極大減少數據訪問量。② 因為索引按照列值順序存儲,所以對於 IO 密集型防偽查詢迴避隨機從磁盤讀取每一行數據的 IO 少得多。③ 由於 InnoDB 使用聚簇索引,覆蓋索引對 InnoDB 很有幫助。InnoDB 的二級索引在葉子節點保存了行的主鍵值,如果二級主鍵能覆蓋查詢那麼可以避免對主鍵索引的二次查詢。

Q9:你知道哪些索引使用原則?

建立索引

對查詢頻次較高且數據量比較大的表建立索引。索引字段的選擇,最佳候選列應當從 WHERE 子句的條件中提取,如果 WHERE 子句中的組合比較多,應當挑選最常用、過濾效果最好的列的組合。業務上具有唯一特性的字段,即使是多個字段的組合,也必須建成唯一索引。

使用前綴索引

索引列開始的部分字符,索引創建後也是使用硬盤來存儲的,因此短索引可以提升索引訪問的 IO 效率。對於 BLOB、TEXT 或很長的 VARCHAR 列必須使用前綴索引,MySQL 不允許索引這些列的完整長度。前綴索引是一種能使索引更小更快的有效方法,但缺點是 MySQL 無法使用前綴索引做 ORDER BY 和 GROUP BY,也無法使用前綴索引做覆蓋掃描。

選擇合適的索引順序

當不需要考慮排序和分組時,將選擇性最高的列放在前面。索引的選擇性是指不重複的索引值和數據表的記錄總數之比,索引的選擇性越高則查詢效率越高,唯一索引的選擇性是 1,因此也可以使用唯一索引提升查詢效率。

刪除無用索引

MySQL 允許在相同列上創建多個索引,重複的索引需要單獨維護,並且優化器在優化查詢時也需要逐個考慮,這會影響性能。重複索引是指在相同的列上按照相同的順序創建的相同類型的索引,應該避免創建重複索引。如果創建了索引 (A,B) 再創建索引 (A) 就是冗餘索引,因為這只是前一個索引的前綴索引,對於 B-Tree 索引來說是冗餘的。解決重複索引和冗餘索引的方法就是刪除這些索引。除了重複索引和冗餘索引,可能還會有一些服務器永遠不用的索引,也應該考慮刪除。

Q10:索引失效的情況有哪些?

如果索引列出現了隱式類型轉換,則 MySQL 不會使用索引。常見的情況是在 SQL 的 WHERE 條件中字段類型為字符串,其值為數值,如果沒有加引號那麼 MySQL 不會使用索引。

如果 WHERE 條件中含有 OR,除非 OR 前使用了索引列而 OR 之後是非索引列,索引會失效。

MySQL 不能在索引中執行 LIKE 操作,這是底層存儲引擎 API 的限制,最左匹配的 LIKE 比較會被轉換為簡單的比較操作,但如果是以通配符開頭的 LIKE 查詢,存儲引擎就無法做比較。這種情況下 MySQL 只能提取數據行的值而不是索引值來做比較。

如果查詢中的列不是獨立的,則 MySQL 不會使用索引。獨立的列是指索引列不能是表達式的一部分,也不能是函數的參數。

對於多個範圍條件查詢,MySQL 無法使用第一個範圍列後面的其他索引列,對於多個等值查詢則沒有這種限制。

如果 MySQL 判斷全表掃描比使用索引查詢更快,則不會使用索引。

索引文件具有 B-Tree 的最左前綴匹配特性,如果左邊的值未確定,那麼無法使用此索引。

優化 5

Q1:如何定位低效 SQL?

可以通過兩種方式來定位執行效率較低的 SQL 語句。一種是通過慢查詢日誌定位,可以通過慢查詢日誌定位那些已經執行完畢的 SQL 語句。另一種是使用 SHOW PROCESSLIST 查詢,慢查詢日誌在查詢結束以後才記錄,所以在應用反應執行效率出現問題的時候查詢慢查詢日誌不能定位問題,此時可以使用 SHOW PROCESSLIST 命令查看當前 MySQL 正在進行的線程,包括線程的狀態、是否鎖表等,可以實時查看 SQL 的執行情況,同時對一些鎖表操作進行優化。找到執行效率低的 SQL 語句後,就可以通過 SHOW PROFILE、EXPLAIN 或 trace 等豐富來繼續優化語句。

Q2:SHOW PROFILE 的作用?

通過 SHOW PROFILE 可以分析 SQL 語句性能消耗,例如查詢到 SQL 會執行多少時間,並顯示 CPU、內存使用量,執行過程中系統鎖及表鎖的花費時間等信息。例如 SHOW PROFILE CPU/MEMORY/BLOCK IO FOR QUERY N 分別查詢 id 為 N 的 SQL 語句的 CPU、內存以及 IO 的消耗情況。

Q3:trace 是幹什麼的?

從 MySQL5.6 開始,可以通過 trace 文件進一步獲取優化器是是如何選擇執行計劃的,在使用時需要先打開設置,然後執行一次 SQL,最後查看 information_schema.optimizer_trace 表而都內容,該表為聯合i表,只能在當前會話進行查詢,每次查詢後返回的都是最近一次執行的 SQL 語句。

Q4:EXPLAIN 的字段有哪些,具有什麼含義?

執行計劃是 SQL 調優的一個重要依據,可以通過 EXPLAIN 命令查看 SQL 語句的執行計劃,如果作用在表上,那麼該命令相當於 DESC。EXPLAIN 的指標及含義如下:

指標名 含義
id 表示 SELECT 子句或操作表的順序,執行順序從大到小執行,當 id 一樣時,執行順序從上往下。
select_type 表示查詢中每個 SELECT 子句的類型,例如 SIMPLE 表示不包含子查詢、表連接或其他複雜語法的簡單查詢,PRIMARY 表示複雜查詢的最外層查詢,SUBQUERY 表示在 SELECT 或 WHERE 列表中包含了子查詢。
type 表示訪問類型,性能由差到好為:ALL 全表掃描、index 索引全掃描、range 索引範圍掃描、ref 返回匹配某個單獨值得所有行,常見於使用非唯一索引或唯一索引的非唯一前綴進行的查找,也經常出現在 join 操作中、eq_ref 唯一性索引掃描,對於每個索引鍵只有一條記錄與之匹配、const 當 MySQL 對查詢某部分進行優化,並轉為一個常量時,使用這些訪問類型,例如將主鍵或唯一索引置於 WHERE 列表就能將該查詢轉為一個 const、system 表中只有一行數據或空表,只能用於 MyISAM 和 Memory 表、NULL 執行時不用訪問表或索引就能得到結果。SQL 性能優化的目標:至少要達到 range 級別,要求是 ref 級別,如果可以是consts 最好。
possible_keys 表示查詢時可能用到的索引,但不一定使用。列出大量可能索引時意味着備選索引數量太多了。
key 顯示 MySQL 在查詢時實際使用的索引,如果沒有使用則顯示為 NULL。
key_len 表示使用到索引字段的長度,可通過該列計算查詢中使用的索引的長度,對於確認索引有效性以及多列索引中用到的列數目很重要。
ref 表示上述表的連接匹配條件,即哪些列或常量被用於查找索引列上的值。
rows 表示 MySQL 根據表統計信息及索引選用情況,估算找到所需記錄所需要讀取的行數。
Extra 表示額外信息,例如 Using temporary 表示需要使用臨時表存儲結果集,常見於排序和分組查詢。Using filesort 表示無法利用索引完成的文件排序,這是 ORDER BY 的結果,可以通過合適的索引改進性能。Using index 表示只需要使用索引就可以滿足查詢表得要求,說明表正在使用覆蓋索引。

Q5:有哪些優化 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 語句高。也可以關閉事務的自動提交,在插入完數據後提交。當插入的數據是按主鍵的順序插入時,效率更高。

複製 2

Q1:MySQL 主從複製的作用?

複製解決的基本問題是讓一台服務器的數據與其他服務器保持同步,一台主庫的數據可以同步到多台備庫上,備庫本身也可以被配置成另外一台服務器的主庫。主庫和備庫之間可以有多種不同的組合方式。

MySQL 支持兩種複製方式:基於行的複製和基於語句的複製,基於語句的複製也稱為邏輯複製,從 MySQL 3.23 版本就已存在,基於行的複製方式在 5.1 版本才被加進來。這兩種方式都是通過在主庫上記錄二進制日誌、在備庫重放日誌的方式來實現異步的數據複製。因此同一時刻備庫的數據可能與主庫存在不一致,並且無法包裝主備之間的延遲。

MySQL 複製大部分是向後兼容的,新版本的服務器可以作為老版本服務器的備庫,但是老版本不能作為新版本服務器的備庫,因為它可能無法解析新版本所用的新特性或語法,另外所使用的二進制文件格式也可能不同。

複製解決的問題:數據分佈、負載均衡、備份、高可用性和故障切換、MySQL 升級測試。

Q2:MySQL 主從複製的步驟?

① 在主庫上把數據更改記錄到二進制日誌中。② 備庫將主庫的日誌複製到自己的中繼日誌中。 ③ 備庫讀取中繼日誌中的事件,將其重放到備庫數據之上。

第一步是在主庫上記錄二進制日誌,每次準備提交事務完成數據更新前,主庫將數據更新的事件記錄到二進制日誌中。MySQL 會按事務提交的順序而非每條語句的執行順序來記錄二進制日誌,在記錄二進制日誌後,主庫會告訴存儲引擎可以提交事務了。

下一步,備庫將主庫的二進制日誌複製到其本地的中繼日誌中。備庫首先會啟動一個工作的 IO 線程,IO 線程跟主庫建立一個普通的客戶端連接,然後在主庫上啟動一個特殊的二進制轉儲線程,這個線程會讀取主庫上二進制日誌中的事件。它不會對事件進行輪詢。如果該線程追趕上了主庫將進入睡眠狀態,直到主庫發送信號量通知其有新的事件產生時才會被喚醒,備庫 IO 線程會將接收到的事件記錄到中繼日誌中。

備庫的 SQL 線程執行最後一步,該線程從中繼日誌中讀取事件並在備庫執行,從而實現備庫數據的更新。當 SQL 線程追趕上 IO 線程時,中繼日誌通常已經在系統緩存中,所以中繼日誌的開銷很低。SQL 線程執行的時間也可以通過配置選項來決定是否寫入其自己的二進制日誌中。

Redis 37

架構 3

Q1:Redis 有什麼特點?

基於鍵值對的數據結構服務器

Redis 中的值不僅可以是字符串,還可以是具體的數據結構,這樣不僅能應用於多種場景開發,也可以提高開發效率。它主要提供五種數據結構:字符串、哈希、列表、集合、有序集合,同時在字符串的基礎上演變出了 Bitmaps 和 HyperLogLog 兩種數據結構,Redis 3.2 還加入了有關 GEO 地理信息定位的功能。

豐富的功能

① 提供了鍵過期功能,可以實現緩存。② 提供了發佈訂閱功能,可以實現消息系統。③ 支持 Lua 腳本,可以創造新的 Redis 命令。④ 提供了簡單的事務功能,能在一定程度上保證事務特性。⑤ 提供了流水線功能,客戶端能將一批命令一次性傳到 Redis,減少網絡開銷。

簡單穩定

Redis 的簡單主要體現在三個方面:① 源碼很少,早期只有 2 萬行左右,在 3.0 版本由於添加了集群特性,增加到了 5 萬行左右,相對於很多 NoSQL 數據庫來說代碼量要少很多。② 採用單線程模型,使得服務端處理模型更簡單,也使客戶端開發更簡單。③ 不依賴底層操作系統的類庫,自己實現了事件處理的相關功能。雖然 Redis 比較簡單,但也很穩定。

客戶端語言多

Redis 提供了簡單的 TCP 通信協議,很多編程語言可以方便地接入 Redis,例如 Java、PHP、Python、C、C++ 等。

持久化

通常來說數據放在內存中是不安全的,一旦發生斷電或故障數據就可能丟失,因此 Redis 提供了兩種持久化方式 RDB 和 AOF 將內存的數據保存到硬盤中。

高性能

Redis 使用了單線程架構和 IO 多路復用模型來實現高性能的內存數據庫服務。

每次客戶端調用都經歷了發送命令、執行命令、返回結果三個過程,因為 Redis 是單線程處理命令的,所以一條命令從客戶端到達服務器不會立即執行,所有命令都會進入一個隊列中,然後逐個被執行。客戶端的執行順序可能不確定,但是可以確定不會有兩條命令被同時執行,不存在並發問題。

通常來說單線程處理能力要比多線程差,Redis 快的原因:① 純內存訪問,Redis 將所有數據放在內存中。② 非阻塞 IO,Redis 使用 epoll 作為 IO 多路復用技術的實現,再加上 Redis 本身的事件處理模型將 epoll 中的連接、讀寫、關閉都轉換為時間,不在網絡 IO 上浪費過多的時間。③ 單線程避免了線程切換和競爭產生的消耗。單線程的一個問題是對於每個命令的執行時間是有要求的,如果某個命令執行時間過長會造成其他命令的阻塞,對於 Redis 這種高性能服務來說是致命的,因此 Redis 是面向快速執行場景的數據庫。

Q2:Redis 的數據結構有哪些?

可以使用 type 命令查看當前鍵的數據類型結構,它們分別是:string、hash、list、set、zset,但這些只是 Redis 對外的數據結構。實際上每種數據結構都有自己底層的內部編碼實現,這樣 Redis 會在合適的場景選擇合適的內部編碼,string 包括了 raw、int 和 embstr,hash 包括了 hashtable 和 ziplist,list 包括了 linkedlist 和 ziplist,set 包括了 hashtable 和 intset,zset 包括了 skiplist 和 ziplist。可以使用 object encoding 查看內部編碼。

Q3:Redis 為什麼要使用內部編碼?

① 可以改進內部編碼,而對外的數據結構和命令沒有影響。

② 多種內部編碼實現可以在不同場景下發揮各自的優勢,例如 ziplist 比較節省內存,但在列表元素較多的情況下性能有所下降,這時 Redis 會根據配置選項將列表類型的內部實現轉換為 linkedlist。

string 4

Q1:簡單說一說 string 類型

字符串類型是 Redis 最基礎的數據結構,鍵都是字符串類型,而且其他幾種數據結構都是在字符串類型的基礎上構建的。字符串類型的值可以實際可以是字符串(簡單的字符串、複雜的字符串如 JSON、XML)、數字(整形、浮點數)、甚至二進制(圖片、音頻、視頻),但是值最大不能超過 512 MB。

Q2:你知道哪些 string 的命令?

設置值

set key value [ex seconds] [px millseconds] [nx|xx]
  • ex seconds:為鍵設置秒級過期時間,跟 setex 效果一樣
  • px millseconds:為鍵設置毫秒級過期時間
  • nx:鍵必須不存在才可以設置成功,用於添加,跟 setnx 效果一樣。由於 Redis 的單線程命令處理機制,如果多個客戶端同時執行,則只有一個客戶端能設置成功,可以用作分佈式鎖的一種實現。
  • xx:鍵必須存在才可以設置成功,用於更新

獲取值

get key,如果不存在返回 nil

批量設置值

mset key value [key value...]

批量獲取值

mget key [key...]

批量操作命令可以有效提高開發效率,假如沒有 mget,執行 n 次 get 命令需要 n 次網絡時間 + n 次命令時間,使用 mget 只需要 1 次網絡時間 + n 次命令時間。Redis 可以支持每秒數萬的讀寫操作,但這指的是 Redis 服務端的處理能力,對於客戶端來說一次命令處理命令時間還有網絡時間。因為 Redis 的處理能力已足夠高,對於開發者來說,網絡可能會成為性能瓶頸。

計數

incr key

incr 命令用於對值做自增操作,返回結果分為三種:① 值不是整數返回錯誤。② 值是整數,返回自增後的結果。③ 值不存在,按照值為 0 自增,返回結果 1。除了 incr 命令,還有自減 decr、自增指定數字 incrby、自減指定數組 decrby、自增浮點數 incrbyfloat。

Q3:string 的內部編碼是什麼?

  • int:8 個位元組的長整形
  • embstr:小於等於 39 個位元組的字符串
  • raw:大於 39 個位元組的字符串

Q4:string 的應用場景有什麼?

緩存功能

Redis 作為緩存層,MySQL 作為存儲層,首先從 Redis 獲取數據,如果失敗就從 MySQL 獲取並將結果寫回 Redis 並添加過期時間。

計數

Redis 可以實現快速計數功能,例如視頻每播放一次就用 incy 把播放數加 1。

共享 Session

一個分佈式 Web 服務將用戶的 Session 信息保存在各自服務器,但會造成一個問題,出於負載均衡的考慮,分佈式服務會將用戶的訪問負載到不同服務器上,用戶刷新一次可能會發現需要重新登陸。為解決該問題,可以使用 Redis 將用戶的 Session 進行集中管理,在這種模式下只要保證 Redis 是高可用和擴展性的,每次用戶更新或查詢登錄信息都直接從 Redis 集中獲取。

限速

例如為了短訊接口不被頻繁訪問會限制用戶每分鐘獲取驗證碼的次數或者網站限制一個 IP 地址不能在一秒內訪問超過 n 次。可以使用鍵過期策略和自增計數實現。

hash 4

Q1:簡單說一說 hash 類型

哈希類型指鍵值本身又是一個鍵值對結構,哈希類型中的映射關係叫 field-value,這裡的 value 是指 field 對於的值而不是鍵對於的值。

Q2:你知道哪些 hash 的命令?

設置值

hset key field value,如果設置成功會返回 1,反之會返回 0,此外還提供了 hsetnx 命令,作用和 setnx 類似,只是作用於由鍵變為 field。

獲取值

hget key field,如果不存在會返回 nil。

刪除 field

hdel key field [field...],會刪除一個或多個 field,返回結果為刪除成功 field 的個數。

計算 field 個數

hlen key

批量設置或獲取 field-value

複製代碼

12 hmget key field [field...]``hmset key field value [field value...]

判斷 field 是否存在

hexists key field,存在返回 1,否則返回 0。

獲取所有的 field

hkeys key,返回指定哈希鍵的所有 field。

獲取所有 value

hvals key,獲取指定鍵的所有 value。

獲取所有的 field-value

hgetall key,獲取指定鍵的所有 field-value。

Q3:hash 的內部編碼是什麼?

ziplist 壓縮列表:當哈希類型元素個數和值小於配置值(默認 512 個和 64 位元組)時會使用 ziplist 作為內部實現,使用更緊湊的結構實現多個元素的連續存儲,在節省內存方面比 hashtable 更優秀。

hashtable 哈希表:當哈希類型無法滿足 ziplist 的條件時會使用 hashtable 作為哈希的內部實現,因為此時 ziplist 的讀寫效率會下降,而 hashtable 的讀寫時間複雜度都為 O(1)。

Q4:hash 的應用場景有什麼?

緩存用戶信息,每個用戶屬性使用一對 field-value,但只用一個鍵保存。

優點:簡單直觀,如果合理使用可以減少內存空間使用。

缺點:要控制哈希在 ziplist 和 hashtable 兩種內部編碼的轉換,hashtable 會消耗更多內存。

list 4

Q1:簡單說一說 list 類型

list 是用來存儲多個有序的字符串,列表中的每個字符串稱為元素,一個列表最多可以存儲 232-1 個元素。可以對列表兩端插入(push)和彈出(pop),還可以獲取指定範圍的元素列表、獲取指定索引下標的元素等。列表是一種比較靈活的數據結構,它可以充當棧和隊列的角色,在實際開發中有很多應用場景。

list 有兩個特點:① 列表中的元素是有序的,可以通過索引下標獲取某個元素或者某個範圍內的元素列表。② 列表中的元素可以重複。

Q2:你知道哪些 list 的命令?

添加

從右邊插入元素:rpush key value [value...]

從左到右獲取列表的所有元素:lrange 0 -1

從左邊插入元素:lpush key value [value...]

向某個元素前或者後插入元素:linsert key before|after pivot value,會在列表中找到等於 pivot 的元素,在其前或後插入一個新的元素 value。

查找

獲取指定範圍內的元素列表:lrange key start end,索引從左到右的範圍是 0N-1,從右到左是 -1-N,lrange 中的 end 包含了自身。

獲取列表指定索引下標的元素:lindex key index,獲取最後一個元素可以使用 lindex key -1

獲取列表長度:llen key

刪除

從列表左側彈出元素:lpop key

從列表右側彈出元素:rpop key

刪除指定元素:lrem key count value,如果 count 大於 0,從左到右刪除最多 count 個元素,如果 count 小於 0,從右到左刪除最多個 count 絕對值個元素,如果 count 等於 0,刪除所有。

按照索引範圍修剪列表:ltrim key start end,只會保留 start ~ end 範圍的元素。

修改

修改指定索引下標的元素:lset key index newValue

阻塞操作

阻塞式彈出:blpop/brpop key [key...] timeout,timeout 表示阻塞時間。

當列表為空時,如果 timeout = 0,客戶端會一直阻塞,如果在此期間添加了元素,客戶端會立即返回。

如果是多個鍵,那麼brpop會從左至右遍歷鍵,一旦有一個鍵能彈出元素,客戶端立即返回。

如果多個客戶端對同一個鍵執行 brpop,那麼最先執行該命令的客戶端可以獲取彈出的值。

Q3:list 的內部編碼是什麼?

ziplist 壓縮列表:跟哈希的 zipilist 相同,元素個數和大小小於配置值(默認 512 個和 64 位元組)時使用。

linkedlist 鏈表:當列表類型無法滿足 ziplist 的條件時會使用linkedlist。

Redis 3.2 提供了 quicklist 內部編碼,它是以一個 ziplist 為節點的 linkedlist,它結合了兩者的優勢,為列表類提供了一種更為優秀的內部編碼實現。

Q4:list 的應用場景有什麼?

消息隊列

Redis 的 lpush + brpop 即可實現阻塞隊列,生產者客戶端使用 lpush 從列表左側插入元素,多個消費者客戶端使用 brpop 命令阻塞式地搶列表尾部的元素,多個客戶端保證了消費的負載均衡和高可用性。

文章列表

每個用戶有屬於自己的文章列表,現在需要分頁展示文章列表,就可以考慮使用列表。因為列表不但有序,同時支持按照索引範圍獲取元素。每篇文章使用哈希結構存儲。

lpush + lpop = 棧、lpush + rpop = 隊列、lpush + ltrim = 優先集合、lpush + brpop = 消息隊列。

set 4

Q1:簡單說一說 set 類型

集合類型也是用來保存多個字符串元素,和列表不同的是集合不允許有重複元素,並且集合中的元素是無序的,不能通過索引下標獲取元素。一個集合最多可以存儲 232-1 個元素。Redis 除了支持集合內的增刪改查,還支持多個集合取交集、並集、差集。

Q2:你知道哪些 set 的命令?

添加元素

sadd key element [element...],返回結果為添加成功的元素個數。

刪除元素

srem key element [element...],返回結果為成功刪除的元素個數。

計算元素個數

scard key,時間複雜度為 O(1),會直接使用 Redis 內部的遍歷。

判斷元素是否在集合中

sismember key element,如果存在返回 1,否則返回 0。

隨機從集合返回指定個數個元素

srandmember key [count],如果不指定 count 默認為 1。

從集合隨機彈出元素

spop key,可以從集合中隨機彈出一個元素。

獲取所有元素

smembers key

求多個集合的交集/並集/差集

sinter key [key...]
sunion key [key...]
sdiff key [key...]

保存交集、並集、差集的結果

sinterstore/sunionstore/sdiffstore destination key [key...]

集合間運算在元素較多情況下比較耗時,Redis 提供這三個指令將集合間交集、並集、差集的結果保存在 destination key 中。

Q3:set 的內部編碼是什麼?

intset 整數集合:當集合中的元素個數小於配置值(默認 512 個時),使用 intset。

hashtable 哈希表:當集合類型無法滿足 intset 條件時使用 hashtable。當某個元素不為整數時,也會使用 hashtable。

Q4:set 的應用場景有什麼?

set 比較典型的使用場景是標籤,例如一個用戶可能與娛樂、體育比較感興趣,另一個用戶可能對例時、新聞比較感興趣,這些興趣點就是標籤。這些數據對於用戶體驗以及增強用戶黏度比較重要。

sadd = 標籤、spop/srandmember = 生成隨機數,比如抽獎、sadd + sinter = 社交需求。

zset 4

Q1:簡單說一說 zset 類型

有序集合保留了集合不能有重複成員的特性,不同的是可以排序。但是它和列表使用索引下標作為排序依據不同的是,他給每個元素設置一個分數(score)作為排序的依據。有序集合提供了獲取指定分數和元素查詢範圍、計算成員排名等功能。

Q2:你知道哪些 zset 的命令?

添加成員

zadd key score member [score member...],返回結果是成功添加成員的個數

Redis 3.2 為 zadd 命令添加了 nx、xx、ch、incr 四個選項:

  • nx:member 必須不存在才可以設置成功,用於添加。
  • xx:member 必須存在才能設置成功,用於更新。
  • ch:返回此次操作後,有序集合元素和分數變化的個數。
  • incr:對 score 做增加,相當於 zincrby。

zadd 的時間複雜度為 O(logn),sadd 的時間複雜度為 O(1)。

計算成員個數

zcard key,時間複雜度為 O(1)。

計算某個成員的分數

zscore key member ,如果不存在則返回 nil。

計算成員排名

zrank key member,從低到高返回排名。

zrevrank key member,從高到低返回排名。

刪除成員

zrem key member [member...],返回結果是成功刪除的個數。

增加成員的分數

zincrby key increment member

返回指定排名範圍的成員

zrange key start end [withscores],從低到高返回

zrevrange key start end [withscores], 從高到底返回

返回指定分數範圍的成員

zrangebyscore key min max [withscores] [limit offset count],從低到高返回

zrevrangebyscore key min max [withscores] [limit offset count], 從高到底返回

返回指定分數範圍成員個數

zcount key min max

刪除指定分數範圍內的成員

zremrangebyscore key min max

交集和並集

zinterstore/zunionstore destination numkeys key [key...] [weights weight [weight...]] [aggregate sum|min|max]
  • destination:交集結果保存到這個鍵
  • numkeys:要做交集計算鍵的個數
  • key:需要做交集計算的鍵
  • weight:每個鍵的權重,默認 1
  • aggregate sum|min|max:計算交集後,分值可以按和、最小值、最大值匯總,默認 sum。

Q3:zset 的內部編碼是什麼?

ziplist 壓縮列表:當有序集合元素個數和值小於配置值(默認128 個和 64 位元組)時會使用 ziplist 作為內部實現。

skiplist 跳躍表:當 ziplist 不滿足條件時使用,因為此時 ziplist 的讀寫效率會下降。

Q4:zset 的應用場景有什麼?

有序集合的典型使用場景就是排行榜系統,例如用戶上傳了一個視頻並獲得了贊,可以使用 zadd 和 zincrby。如果需要將用戶從榜單刪除,可以使用 zrem。如果要展示獲取贊數最多的十個用戶,可以使用 zrange。

鍵和數據庫管理 5

Q1:如何對鍵重命名?

rename key newkey

如果 rename 前鍵已經存在,那麼它的值也會被覆蓋。為了防止強行覆蓋,Redis 提供了 renamenx 命令,確保只有 newkey 不存在時才被覆蓋。由於重命名鍵期間會執行 del 命令刪除舊的鍵,如果鍵對應值比較大會存在阻塞的可能。

Q2:如何設置鍵過期?

expire key seconds:鍵在 seconds 秒後過期。

如果過期時間為負值,鍵會被立即刪除,和 del 命令一樣。persist 命令可以將鍵的過期時間清除。

對於字符串類型鍵,執行 set 命令會去掉過期時間,set 命令對應的函數 setKey 最後執行了 removeExpire 函數去掉了過期時間。setex 命令作為 set + expire 的組合,不單是原子執行並且減少了一次網絡通信的時間。

Q3:如何進行鍵遷移?

  • move
    move 命令用於在 Redis 內部進行數據遷移,move key db 把指定的鍵從源數據庫移動到目標數據庫中。
  • dump + restore
    可以實現在不同的 Redis 實例之間進行數據遷移,分為兩步:
    dump key ,在源 Redis 上,dump 命令會將鍵值序列化,格式採用 RDB 格式。
    restore key ttl value,在目標 Redis 上,restore 命令將序列化的值進行復原,ttl 代表過期時間, ttl = 0 則沒有過期時間。
    整個遷移並非原子性的,而是通過客戶端分步完成,並且需要兩個客戶端。
  • migrate
    實際上 migrate 命令就是將 dump、restore、del 三個命令進行組合,從而簡化操作流程。migrate 具有原子性,支持多個鍵的遷移,有效提高了遷移效率。實現過程和 dump + restore 類似,有三點不同:
    ① 整個過程是原子執行,不需要在多個 Redis 實例開啟客戶端。
    ② 數據傳輸直接在源 Redis 和目標 Redis 完成。
    ③ 目標 Redis 完成 restore 後會發送 OK 給源 Redis,源 Redis 接收後根據 migrate 對應選項來決定是否在源 Redis 上刪除對應鍵。

Q4:如何切換數據庫?

select dbIndex,Redis 中默認配置有 16 個數據庫,例如 select 0 將切換到第一個數據庫,數據庫之間的數據是隔離的。

Q5:如何清除數據庫?

用於清除數據庫,flushdb 只清除當前數據庫,flushall 會清除所有數據庫。如果當前數據庫鍵值數量比較多,flushdb/flushall 存在阻塞 Redis 的可能性。

持久化 9

Q1:RDB 持久化的原理?

RDB 持久化是把當前進程數據生成快照保存到硬盤的過程,觸發 RDB 持久化過程分為手動觸發和自動觸發。

手動觸發分別對應 save 和 bgsave 命令:

  • save:阻塞當前 Redis 服務器,直到 RDB 過程完成為止,對於內存比較大的實例會造成長時間阻塞,線上環境不建議使用。
  • bgasve:Redis 進程執行 fork 操作創建子進程,RDB 持久化過程由子進程負責,完成後自動結束。阻塞只發生在 fork 階段,一般時間很短。bgsave 是針對 save 阻塞問題做的優化,因此 Redis 內部所有涉及 RDB 的操作都採用 bgsave 的方式,而 save 方式已經廢棄。

除了手動觸發外,Redis 內部還存在自動觸發 RDB 的持久化機制,例如:

  • 使用 save 相關配置,如 save m n,表示 m 秒內數據集存在 n 次修改時,自動觸發 bgsave。
  • 如果從節點執行全量複製操作,主節點自動執行 bgsave 生成 RDB 文件並發送給從節點。
  • 執行 debug reload 命令重新加載 Redis 時也會自動觸發 save 操作。
  • 默認情況下執行 shutdown 命令時,如果沒有開啟 AOF 持久化功能則自動執行 bgsave。

Q2:bgsave 的原理?

① 執行 bgsave 命令,Redis 父進程判斷當前是否存在正在執行的子進程,如 RDB/AOF 子進程,如果存在 bgsave 命令直接返回。

② 父進程執行 fork 操作創建子進程,fork 操作過程中父進程會阻塞。

③ 父進程 fork 完成後,bgsave 命令返回並不再阻塞父進程,可以繼續響應其他命令。

④ 子進程創建 RDB 文件,根據父進程內存生成臨時快照文件,完成後對原有文件進行原子替換。

⑤ 進程發送信號給父進程表示完成,父進程更新統計信息。

Q3:RDB 持久化的優點?

RDB 是一個緊湊壓縮的二進制文件,代表 Redis 在某個時間點上的數據快照。非常適合於備份,全量複製等場景。例如每 6 個消時執行 bgsave 備份,並把 RDB 文件拷貝到遠程機器或者文件系統中,用於災難恢復。

Redis 加載 RDB 恢複數據遠遠快於 AOF 的方式。

Q4:RDB 持久化的缺點?

RDB 方式數據無法做到實時持久化/秒級持久化,因為 bgsave 每次運行都要執行 fork 操作創建子進程,屬於重量級操作,頻繁執行成本過高。針對 RDB 不適合實時持久化的問題,Redis 提供了 AOF 持久化方式。

RDB 文件使用特定二進制格式保存,Redis 版本演進過程中有多個格式的 RDB 版本,存在老版本 Redis 服務無法兼容新版 RDB 格式的問題。

Q5:AOF 持久化的原理?

AOF 持久化以獨立日誌的方式記錄每次寫命令,重啟時再重新執行 AOF 文件中的命令達到恢複數據的目的。AOF 的主要作用是解決了數據持久化的實時性,目前是 Redis 持久化的主流方式。

開啟 AOF 功能需要設置:appendonly yes,默認不開啟。保存路徑同 RDB 方式一致,通過 dir 配置指定。

AOF 的工作流程操作:命令寫入 append、文件同步 sync、文件重寫 rewrite、重啟加載 load:

  • 所有的寫入命令會追加到 aof_buf 緩衝區中。
  • AOF 緩衝區根據對應的策略向硬盤做同步操作。
  • 隨着 AOF 文件越來越大,需要定期對 AOF 文件進行重寫,達到壓縮的目的。
  • 當服務器重啟時,可以加載 AOF 文件進行數據恢復。

Q6:AOF 命令寫入的原理?

AOF 命令寫入的內容直接是文本協議格式,採用文本協議格式的原因:

  • 文本協議具有很好的兼容性。
  • 開啟 AOF 後所有寫入命令都包含追加操作,直接採用協議格式避免了二次處理開銷。
  • 文本協議具有可讀性,方便直接修改和處理。

AOF 把命令追加到緩衝區的原因:

Redis 使用單線程響應命令,如果每次寫 AOF 文件命令都直接追加到硬盤,那麼性能完全取決於當前硬盤負載。先寫入緩衝區中還有另一個好處,Redis 可以提供多種緩衝區同步硬盤策略,在性能和安全性方面做出平衡。

Q7:AOF 文件同步的原理?

Redis 提供了多種 AOF 緩衝區文件同步策略,由參數 appendfsync 控制,不同值的含義如下:

  • always:命令寫入緩衝區後調用系統 fsync 操作同步到 AOF 文件,fsync 完成後線程返回。每次寫入都要同步 AOF,性能較低,不建議配置。
  • everysec:命令寫入緩衝區後調用系統 write 操作,write 完成後線程返回。fsync 同步文件操作由專門線程每秒調用一次。是建議的策略,也是默認配置,兼顧性能和數據安全。
  • no:命令寫入緩衝區後調用系統 write 操作,不對 AOF 文件做 fsync 同步,同步硬盤操作由操作系統負責,周期通常最長 30 秒。由於操作系統每次同步 AOF 文件的周期不可控,而且會加大每次同步硬盤的數據量,雖然提升了性能,但安全性無法保證。

Q8:AOF 文件重寫的原理?

文件重寫是把 Redis 進程內的數據轉化為寫命令同步到新 AOF 文件的過程,可以降低文件佔用空間,更小的文件可以更快地被加載。

重寫後 AOF 文件變小的原因:

  • 進程內已經超時的數據不再寫入文件。
  • 舊的 AOF 文件含有無效命令,重寫使用進程內數據直接生成,這樣新的 AOF 文件只保留最終數據寫入命令。
  • 多條寫命令可以合併為一個,為了防止單條命令過大造成客戶端緩衝區溢出,對於 list、set、hash、zset 等類型操作,以 64 個元素為界拆分為多條。

AOF 重寫分為手動觸發和自動觸發,手動觸發直接調用 bgrewriteaof 命令,自動觸髮根據 auto-aof-rewrite-min-sizeauto-aof-rewrite-percentage 參數確定自動觸發時機。

重寫流程:

① 執行 AOF 重寫請求,如果當前進程正在執行 AOF 重寫,請求不執行並返回,如果當前進程正在執行 bgsave 操作,重寫命令延遲到 bgsave 完成之後再執行。

② 父進程執行 fork 創建子進程,開銷等同於 bgsave 過程。

③ 父進程 fork 操作完成後繼續響應其他命令,所有修改命令依然寫入 AOF 緩衝區並同步到硬盤,保證原有 AOF 機制正確性。

④ 子進程根據內存快照,按命令合併規則寫入到新的 AOF 文件。每次批量寫入數據量默認為 32 MB,防止單次刷盤數據過多造成阻塞。

⑤ 新 AOF 文件寫入完成後,子進程發送信號給父進程,父進程更新統計信息。

⑥ 父進程把 AOF 重寫緩衝區的數據寫入到新的 AOF 文件並替換舊文件,完成重寫。

Q9:AOF 重啟加載的原理?

AOF 和 RDB 文件都可以用於服務器重啟時的數據恢復。Redis 持久化文件的加載流程:

① AOF 持久化開啟且存在 AOF 文件時,優先加載 AOF 文件。

② AOF 關閉時且存在 RDB 文件時,記載 RDB 文件。

③ 加載 AOF/RDB 文件成功後,Redis 啟動成功。

④ AOF/RDB 文件存在錯誤導致加載失敗時,Redis 啟動失敗並打印錯誤信息。