Java面試題彙總(持續更新)

Java面試題彙總(歡迎各位補充)

String 相關面試題目

問:String、StringBuffer、StringBuilder 的區別是什麼?

答:String 是字符串常量,StringBuffer 和 StringBuilder 都是字符串變量,後兩者的字符內容可變,而前者創建後內容不可變;StringBuffer 是線程安全的,而 StringBuilder 是非線程安全的,線程安全會帶來額外的系統開銷,所以 StringBuilder 的效率比 StringBuffer 高;String 的每次修改操作都是在內存中重新 new 一個對象出來,而 StringBuffer、StringBuilder 則不用,且提供了一定的緩存功能,默認 16 個字節數組的大小,超過默認的數組長度時擴容爲原來字節數組的長度 * 2 + 2,所以使用 StringBuffer 和 StringBuilder 時可以適當考慮下初始化大小,以便通過減少擴容次數來提高代碼的高效性。

問:String 爲什麼是不可變的?

答:String 不可變是因爲在 JDK 中 String 類被聲明爲一個 final 類,且類內部的 value 字節數組也是 final 的,只有當字符串是不可變時字符串池纔有可能實現,字符串池的實現可以在運行時節約很多 heap 空間,因爲不同的字符串變量都指向池中的同一個字符串;如果字符串是可變的則會引起很嚴重的安全問題,譬如數據庫的用戶名密碼都是以字符串的形式傳入來獲得數據庫的連接,或者在 socket 編程中主機名和端口都是以字符串的形式傳入,因爲字符串是不可變的,所以它的值是不可改變的,否則黑客們可以鑽到空子改變字符串指向的對象的值造成安全漏洞;因爲字符串是不可變的,所以是多線程安全的,同一個字符串實例可以被多個線程共享,這樣便不用因爲線程安全問題而使用同步,字符串自己便是線程安全的;因爲字符串是不可變的所以在它創建的時候 hashcode 就被緩存了,不變性也保證了 hash 碼的唯一性,不需要重新計算,這就使得字符串很適合作爲 Map 的鍵,字符串的處理速度要快過其它的鍵對象,這就是 HashMap 中的鍵往往都使用字符串的原因。

問:說說 String str = “hello world”; 和 String str = new String(“hello world”); 的區別?

答:在 java 的 class 文件中有專門的部分用來存儲編譯期間生成的字面常量和符號引用,這部分叫做 class 文件常量池,在運行期間對應着方法區的運行時常量池,所以 String str = “hello world”; 在編譯期間生成了 字面常量和符號引用,運行期間字面常量 “hello world” 被存儲在運行時常量池(只保存了一份),而通過 new 關鍵字來生成對象是在堆區進行的,堆區進行對象生成的過程是不會去檢測該對象是否已經存在的,所以通過 new 來創建的一定是不同的對象,即使字符串的內容是相同的。例子如下

	public static void main(String[] args) {
		String str1 = "hello";
		String str2 = "hello";
		String str3 = new String("hello");
		
		System.out.println("str1 =" +  System.identityHashCode(str1));
		System.out.println("str2 =" +  System.identityHashCode(str2));
		System.out.println("str3 =" +  System.identityHashCode(str3));
	}

輸出日誌如下:

str1 =2018699554
str2 =2018699554
str3 =1311053135

問:語句 String str = new String(“abc”); 一共創建了多少個對象?

答:這個問題其實有歧義,但是很多公司還特麼愛在筆試題裏面考察,非要是遇到了就答兩個吧(一個是 “xyz”,一個是指向 “xyz” 的引用對象 str);之所以說有歧義是因爲該語句在運行期間只創建了一個對象(堆上的 “abc” 對象),而在類加載過程中在運行時常量池中先創建了一個 “abc” 對象,運行期和類加載又是有區別的,所以這個題目的問法是有些不嚴謹的。因此這個問題如果換成 String str = new String(“abc”); 涉及到幾個 String 對象,則答案就是 2 個了。關於這道題可以參考文章《請別再拿“String s = new String(“xyz”); 創建了多少個 String 實例”來面試了吧》,鏈接:http://rednaxelafx.iteye.com/blog/774673/

集合相關

HashMap相關

問:HashMap 中的 key 如果是 Object 則需要實現哪些方法?

答:hashCode 方法和 equals 方法。
因爲 hashCode 方法用來計算 Entry 在數組中的 index 索引位置,equals 方法用來比較數組指定 index 索引位置上鍊表的節點 Entry 元素是否相等。否則由於 hashCode 方法實現不恰當會導致嚴重的 hash 碰撞,從而使 HashMap 會退化成鏈表結構而影響性能。

問:爲什麼 HashMap 中 String、Integer 這樣的包裝類適合作爲 key 鍵,即爲什麼使用它們可以減少哈希碰撞?

答:因爲 String、Integer 等包裝類是 final 類型的,具有不可變性,而且已經重寫了 equals() 和 hashCode() 方法。不可變性保證了計算 hashCode() 後鍵值的唯一性和緩存特性,不會出現放入和獲取時哈希碼不同的情況且讀取哈希值的高效性,此外官方實現的 equals() 和 hashCode() 都是嚴格遵守相關規範的,不會出現錯誤。

問:下面程序的輸出結果是什麼?

import java.util.HashMap;
import java.util.Map;

class Item {
		public String name;
		public int age;
		
		@Override
		public int hashCode() {
			return this.name.hashCode() + this.age;
		}
		
		public Item(String name, int age) {
			this.name = name;
			this.age = age;
		}
	} 
	
	
	public class Demo {
		public static void main(String[] args) {
			Item item1 = new Item("java", 19);
			Item item2 = new Item("Android", 12);
			Map<Item, Item> map = new HashMap<Item, Item>();
			map.put(item1, item1);
			map.put(item2, item2);
			
			item2.name = "Kotlin";
			Item value = map.get(item2);
			System.out.println("value = " + value);
		}
	}

答:輸出結果爲 value=null。

因爲 key 更新後 hashCode 也更新了,而 HashMap 裏面的對象是我們原來哈希值的對象,在 get 時由於哈希值已經變了,原來的對象不會被索引到了,所以結果爲 null,因此當把對象放到 HashMap 後就不要嘗試對 key 進行修改操作,謹防出現哈希值變化或者 equals 比較不等的情況導致無法索引。

問:簡單說說 HashMap 的底層原理?

答:當我們往 HashMap 中 put 元素時,先根據 key 的 hash 值得到這個 Entry 元素在數組中的位置(即下標),然後把這個 Entry 元素放到對應的位置中,如果這個 Entry 元素所在的位子上已經存放有其他元素就在同一個位子上的 Entry 元素以鏈表的形式存放,新加入的放在鏈頭,從 HashMap 中 get Entry 元素時先計算 key 的 hashcode,找到數組中對應位置的某一 Entry 元素,然後通過 key 的 equals 方法在對應位置的鏈表中找到需要的 Entry 元素,所以 HashMap 的數據結構是數組和鏈表的結合,此外 HashMap 中 key 和 value 都允許爲 null,key 爲 null 的鍵值對永遠都放在以 table[0] 爲頭結點的鏈表中。

之所以 HashMap 這麼設計的實質是由於數組存儲區間是連續的,佔用內存嚴重,故空間複雜度大,但二分查找時間複雜度小(O(1)),所以尋址容易而插入和刪除困難;而鏈表存儲區間離散,佔用內存比較寬鬆,故空間複雜度小,但時間複雜度大(O(N)),所以尋址困難而插入和刪除容易;所以就產生了一種新的數據結構叫做哈希表,哈希表既滿足數據的查找方便,同時不佔用太多的內容空間,使用也十分方便,哈希表有多種不同的實現方法,HashMap 採用的是鏈表的數組實現方式。

特別說明,對於 JDK 1.8 開始 HashMap 實現原理變成了數組+鏈表+紅黑樹的結構,數組鏈表部分基本不變,紅黑樹是爲了解決哈希碰撞後鏈表索引效率的問題,所以在 JDK 1.8 中當鏈表的節點大於 8 個時就會將鏈表變爲紅黑樹。區別是 JDK 1.8 以前碰撞節點會在鏈表頭部插入,而 JDK 1.8 開始碰撞節點會在鏈表尾部插入,對於擴容操作後的節點轉移 JDK 1.8 以前轉移前後鏈表順序會倒置,而 JDK 1.8 中依然保持原序。

問:HashMap 默認的初始化長度是多少?爲什麼默認長度和擴容後的長度都必須是 2 的冪?

答:在 JDK 中默認長度是 16(在 Android SDK 中的 HashMap 默認長度爲 4),並且默認長度和擴容後的長度都必須是 2 的冪。因爲我們可以先看下 HashMap 的 put 方法核心,如下

public V put(K key, V value) {
    ......
    int hash = hash(key);
    // 通過key的hash值和當前動態數組的長度求出當前key的Entry在數組中的下標
    int i = indexFor(hash, table.length);
    .......
}

static int indexFor(int h, int length) {
    return h & (length -1);
}

可以看到獲取數組索引的計算方式爲 key 的 hash 值按位與運算數組長度減一,爲了說明長度儘量是 2 的冪的作用我們假設執行了 put(“android”, 123); 語句且 “android” 的 hash 值爲 234567,二進制爲 111001010001000111,然後由於 HashMap 默認長度爲 16,所以減一後的二進制爲 1111,接着兩數做按位與操作二進制結果爲 111,即十進制的 7,所以 index 爲 7,可以看出這種按位操作比直接取模效率要高。

如果假設 HashMap 默認長度不是 2 的冪,譬如數組長度爲 6,減一的二進制爲 101,與 111001010001000111 按位與操作二進制 101,此時假設我們通過 put 再放一個 key-value 進來,其 hash 爲 111001010001000101,其與 101 按位與操作後的二進制也爲 101,很容易發生哈希碰撞,這就不符合 index 的均勻分佈了。

通過上面兩個假設例子可以看出 HashMap 的長度爲 2 的冪時減一的值的二進制位數一定全爲 1,這樣數組下標 index 的值完全取決於 key 的 hash 值的後幾位,因此只要存入 HashMap 的 Entry 的 key 的 hashCode 值分佈均勻,HashMap 中數組 Entry 元素的分部也就儘可能是均勻的(也就避免了 hash 碰撞帶來的性能問題),所以當長度爲 2 的冪時不同的 hash 值發生碰撞的概率比較小,這樣就會使得數據在 table 數組中分佈較均勻,查詢速度也較快。不過即使負載因子和 hash 算法設計的再合理也免不了哈希衝突碰撞的情況,一旦出現過多就會影響 HashMap 的性能,所以在 JDK 1.8 中官方對數據結構引入了紅黑樹,當鏈表長度太長(默認超過 8)時鏈表就轉爲了紅黑樹,而紅黑樹的增刪改查都比較高效,從而解決了哈希碰撞帶來的性能問題。

問:簡單說說你對 HashMap 構造方法中 initialCapacity(初始容量)、loadFactor(加載因子)的理解?

答:這兩個參數對於 HashMap 來說很重要,直接從一定程度決定了 HashMap 的性能問題。

initialCapacity 初始容量代表了哈希表中桶的初始數量,即 Entry< K,V>[] table 數組的初始長度,不過特別注意,table 數組的長度雖然依賴 initialCapacity,但是每次都會通過 roundUpToPowerOf2(initialCapacity) 方法來保證爲 2 的冪次。

loadFactor 加載因子是哈希表在其容量自動增加之前可以達到多滿的一種飽和度百分比,其衡量了一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。散列當前飽和度的計算爲當前 HashMap 中 Entry 的存儲個數除以當前 table 數組桶長度,因此當哈希表中 Entry 的數量超過了 loadFactor 加載因子乘以當前 table 數組桶長度時就會觸發擴容操作。對於使用鏈表法的散列表來說,查找一個元素的平均時間是O(1+a),因此如果負載因子越大則對空間的利用更充分,從而導致查找效率的降低,如果負載因子太小則散列表的數據將過於稀疏,從而對空間造成浪費。系統默認負載因子爲 0.75,一般情況下無需修改。

因此如果哈希桶數組很大則較差的 Hash 算法分部也會比較分散,如果哈希桶數組很小則即使好的 Hash 算法也會出現較多碰撞,所以就需要權衡好的 Hash 算法和擴容機制,也就是上面兩個參數的作用。

問:簡單說說 JDK1.7 中 HashMap 什麼情況下會發生擴容?如何擴容?

答:HashMap 中默認的負載因子爲 0.75,默認情況下第一次擴容閥值是 12(16 * 0.75),故第一次存儲第 13 個鍵值對時就會觸發擴容機制變爲原來數組長度的二倍,以後每次擴容都類似計算機制;這也就是爲什麼 HashMap 在數組大小不變的情況下存放鍵值對越多查找的效率就會變低(因爲 hash 碰撞會使數組變鏈表),而通過擴容就可以一定程度的平衡查找效率(儘量散列數組化)的原因所在。

具體的擴容方式對於 JDK 1.8 前後的實現是有一點區別的,不過大體思路不變(感興趣可以先參閱 Java HashMap 基礎面試常見問題 和 Java HashMap 實現概況及容量相關面試問題 瞭解基礎知識),下面給出 JDK 1.7 的具體擴容流程:

void transfer(Entry[] newTable) {  
    Entry[] src = table;                   //src引用了舊的Entry數組  
    int newCapacity = newTable.length;  
    for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組  
        Entry<K, V> e = src[j];             //取得舊Entry數組的每個元素  
        if (e != null) {  
            src[j] = null;//釋放舊Entry數組的對象引用(for循環後,舊的Entry數組不再引用任何對象)  
            do {  
                Entry<K, V> next = e.next;  
                int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在數組中的位置  
                e.next = newTable[i]; //標記[1]  
                newTable[i] = e;      //將元素放在數組上  
                e = next;             //訪問下一個Entry鏈上的元素  
            } while (e != null);  
        }  
    }  
} 

可以看到,整個擴容過程就是一個取出數組元素(實際數組索引位置上的每個元素是每個獨立單向鏈表的頭部,也就是發生 Hash 衝突後最後放入的衝突元素)然後遍歷以該元素爲頭的單向鏈表元素,依據每個被遍歷元素的 hash 值計算其在新數組中的下標然後進行交換(即原來 hash 衝突的單向鏈表尾部變成了擴容後單向鏈表的頭部)。下面給出圖解流程:

問:簡單說說 HashMap 和 LinkedHashMap 的區別?

答:HashMap 可以允許一條鍵爲 Null 的鍵值對,允許多條值爲 Null 的鍵值對,其併發不安全,如果想併發安全操作可以使用 Collections.synchronizedMap() 方法或 ConcurrentHashMap 來代替。

LinkedHashMap 是 HashMap 的一個子類,其特殊實現的僅僅是保存了記錄的插入順序,所以在 Iterator 迭代器遍歷 LinkedHashMap 時先得到的鍵值對是先插入的(也可以在構造時用帶參數構造方法來改變順序爲按照使用進行排序),由於其存儲沿用了 HashMap 結構外還多了一個雙向順序鏈表,所以在一般場景下遍歷時會比 HashMap 慢,此外具備 HashMap 的所有特性和缺點。

所以一般情況下,我們用的最多的是 HashMap,如果需要按照插入或者讀取順序來排列時就使用 LinkedHashMap。

問:簡單說說什麼是 LRU?

答:這算是一道純概念題。LRU 是一種流行的替換算法,它的全稱是 Least Recently Used,最近最少使用,常常在緩存設計的場景中充當一種策略,它的核心思路是最近剛被使用的很快再次被用的可能性最高,而最久沒被訪問的很快再次被用的可能性最低,所以被優先清理

問:請使用 Java 集合實現一個簡約優雅的 LRU 容器?

答:這個題思路其實很多,咋一眼看起來是一道很難的題目,其實靜下來你會發現想考察的其實就是 LRU 的原理和 LinkedHashMap 容器知識,當然,你要是厲害不依賴 LinkedHashMap 自己純手寫擼一個也不介意。

由於 LinkedHashMap 天生支持插入順序或者訪問順序的 key-value 對,而 LRU 算法的核心恰巧用到它的訪問順序特性,即對一個鍵執行 get、put 操作後其對應的鍵值對會移到鏈表末尾,所以最末尾的是最近訪問的,最開始的是最久沒被訪問的。恰巧我們之前推送 LinkedHashMap 原理相關題目時說過 LinkedHashMap 有一個 boolean 類型的 accessOrder 參數,當該參數爲 true 時則按照元素最後訪問時間在雙向鏈表中排序,爲 false 則按照插入順序排序,默認爲 false,所以這裏需要的操作就是 accessOrder 爲 true 的情況。

所以基於 LinkedHashMap 的特性實現的 LRU 容器如下:

public class LRU<K, V> extends LinkedHashMap<K, V> {
    private int maxEntries;
    
    public LRUI(int maxEntries) {
        super(16, 0.75f, true);
        this.maxEntries = maxEntries;
    }
    
    @override
    protected boolean removeEldestEntry(entry<K, V> eldest) {
        return size() > maxEntries;
    }
}

HashSet相關

問:簡單談談你對 HashSet 原理的認識?

答:HashSet 在存元素時會調用對象的 hashCode 方法計算出存儲索引位置,如果其索引位置已經存在元素(哈希碰撞)則和該索引位置上所有的元素進行 equals 比較,如果該位置沒有其他元素或者比較的結果都爲 false 就存進去,否則就不存。所以可以看見元素是按照哈希值來找位置的,故而無序且可以保證無重複元素,因此我們在往 HashSet 集合中存儲元素時,元素對象應該正確重寫 Object 類的 hashCode 和 equals 方法,否則會出現不可預知的錯誤。

問:說說 HashSet 與 HashMap 的區別?

答:從實質上說 HashSet 的實現實質就是一個 Map 對象的包裝,只是 Map 的 value 爲 Object 固定對象,Set 只利用了 Map 的 key 而已。具體區別來說如下:

  1. HashMap 實現了 Map 接口,而 HashSet 實現了 Set 接口。
  2. HashMap 儲存鍵值對,而 HashSet 僅僅存儲對象。
  3. HashMap 使用 put 方法將元素放入 Map 中,而 HashSet 使用 add 方法將元素放入 Set 中。
  4. HashMap 中使用鍵對象來計算 hashcode 值,而 HashSet 使用成員對象來計算 hashcode 值。

問:談談你理解的 LinkedList 工作原理和實現?

答:LinkedList 是以雙向鏈表實現,鏈表無容量限制(但是雙向鏈表本身需要消耗額外的鏈表指針空間來操作),其內部主要成員爲 first 和 last 兩個 Node 節點,在每次修改列表時用來指引當前雙向鏈表的首尾部位,所以 LinkedList 不僅僅實現了 List 接口,還實現了 Deque 雙端隊列接口(該接口是 Queue 隊列的子接口),故 LinkedList 自動具備雙端隊列的特性,當我們使用下標方式調用列表的 get(index)、set(index, e) 方法時需要遍歷鏈表將指針移動到位進行訪問(會判斷 index 是否大於鏈表長度的一半決定是首部遍歷還是尾部遍歷,訪問的複雜度爲 O(N/2)),無法像 ArrayList 那樣進行隨機訪問。(如果i>數組大小的一半,會從末尾移起),只有在鏈表兩頭的操作(譬如 add()、addFirst()、removeLast() 或用在 iterator() 上的 remove() 操作)纔不需要進行遍歷尋找定位。

問:簡單談談 ArrayList、LinkedList 的區別?

List 是集合列表接口,ArrayList 和 LinkedList 都是 List 接口的實現類(都允許添加重複元素)。ArrayList 是動態數組順序表,順序表的存儲地址是連續的,所以查找比較快,但是插入和刪除時由於需要把其它的元素順序移動,所以比較耗時。LinkedList 是雙向鏈表的數據結構,同時實現了雙端隊列 Deque 接口,鏈表節點的存儲地址是不連續的,每個存儲地址通過指針關聯,在查找時需要進行指針遍歷節點,所以查找比較慢,而在插入和刪除時比較快。

Java clone 相關面試題

問:爲什麼需要克隆?

答:因爲在編程中會遇到一種情況,有一個 DemoBean 的對象實例 demo1 在某一時刻已經包含了一些有效值,此時可能會需要一個和 demo1 完全相同的新對象 demo2 且此後對 demo1 的任何改動都不影響到 demo1 中的值,在 Java 中用簡單的賦值語句是不能滿足這種需求的,所以我們需要使用其他的途徑來保證 demo1 與 demo2 是兩個獨立的對象且 demo2 的初始值是由 demo1 對象確定的,而克隆就是其官方提供的一種接口定義(至少比主動 new 對象然後取值賦值方便)。

問:淺度克隆(淺拷貝)和深度克隆(深拷貝)的區別是什麼?

答:

淺度克隆:被複制對象(一個新的對象實例)的所有變量都含有與原來的對象相同的值,對於基本數據類型的屬性複製一份給新產生的對象,對於非基本數據類型的屬性僅僅複製一份引用給新產生的對象(新實例中引用類型屬性還是指向原來對象引用類型屬性)。

深度克隆:被複制對象(一個新的對象實例)的所有變量都含有與原來的對象相同的值,而那些引用其他對象的變量將指向被複制過的新對象(新內存空間),而不再是原有的那些被引用的對象,換言之深度克隆把要複製的對象所引用的對象都複製了一遍,也就是在淺度克隆的基礎上,對於要克隆的對象中的非基本數據類型的屬性對應的類也實現克隆,這樣對於非基本數據類型的屬性複製的不是一份引用。

問:String 克隆的特殊性在哪裏?StringBuffer 和 StringBuilder 呢?

答:由於基本數據類型都能自動實現深度 clone,引用類型默認實現的是淺度 clone,而 String 是引用類型的一個特例,我們可以和操作基本數據類型一樣認爲其實現了深度 clone(實質是淺克隆,切記只是一個假象),由於 String 是不可變類,對於 String 類中的很多修改操作都是通過新 new 對象複製處理的,所以當我們修改 clone 前後對象裏面 String 屬性的值時其實都是屬性引用的重新指向操作,自然對 clone 前後對象裏 String 屬性是沒有相互影響的,類似於深度克隆;所以雖然他是引用類型而且我們在深度克隆時無法調用其 clone 方法,但是其不影響我們深度克隆的使用。

如果要實現深度克隆則 StringBuffer 和 StringBuilder 是需要主動特殊處理的,否則就是真正的對象淺克隆,所以處理的辦法就是在類的 clone 方法中對 StringBuffer 或者 StringBuilder 屬性進行如下主動拷貝操作。

再次強調,這個區別非常重要,即便不是面試題自己開發中也要注意這個坑。

內存相關

問:說說你對java虛擬機的內存分配與回收機制的理解

分爲4個方面來介紹內存分配與回收,分別是內存是如何分配的、哪些內存需要回收、在什麼情況下執行回收、如何監控和優化GC機制。
java GC(Garbage Collction)垃圾回收機制,是java與C/C++的主要區別之一。通過對jvm中內存進行標記,自主回收一些無用的內存。目前使用的最多的是sun公司jdk中的HotSpot,所以本文也以該jvm作爲介紹的根本。
 1.Java內存區域
 在java運行時的數據取裏,由jvm管理的內存區域分爲多個部分:
在這裏插入圖片描述
  程序計數器(program counter register):程序計數器是一個比較校的內存單元,用來表示當前程序運行哪裏的一個指示器。由於每個線程都由自己的執行順序,所以程序計數器是線程私有的,每個線程都要由一個自己的程序計數器來指示自己(線程)下一步要執行哪條指令。

如果程序執行的是一個java方法,那麼計數器記錄的是正在執行的虛擬機字節碼指令地址;如果正在執行的是一個本地方法(native方法),那麼計數器的值爲Undefined。由於程序計數器記錄的只是當前指令地址,所以不存在內存泄漏的情況,也是jvm內存區域中唯一一個沒有OOME(out of memory error)定義的區域。

虛擬機棧(JVM stack):當線程的每個方法在執行的時候都會創建一個棧幀(Stack Frame)用來存儲方法中的局部變量、方法出口等,同時會將這個棧幀放入JVM棧中,方法調用完成時,這個棧幀出棧。每個線程都要一個自己的虛擬機棧來保存自己的方法調用時候的數據,因此虛擬機棧也是線程私有的。

虛擬機棧中定義了兩種異常,如果線程調用的棧深度大於虛擬機允許的最大深度,拋出StackOverFlowError,不過虛擬機基本上都允許動態擴展虛擬機棧的大小。這樣的話線程可以一直申請棧,直到內存不足的時候,會拋出OOME(out of memory error)內存溢出。

本地方法棧(Native Method Stack):本地方法棧與虛擬機棧類似,只是本地方法棧存放的棧幀是在native方法調用的時候產生的。有的虛擬機中會將本地方法棧和虛擬棧放在一起,因此本地方法棧也是線程私有的。

堆(Heap):堆是java GC機制中最重要的區域。堆是爲了放置“對象的實例”,對象都是在堆區上分配內存的,堆在邏輯上連續,在物理上不一定連續。所有的線程共用一個堆,堆的大小是可擴展的,如果在執行GC之後,仍沒有足夠的內存可以分配且堆大小不可再擴展,將會拋出OOME。

方法區(Method Area):又叫靜態區,用於存儲類的信息、常量池等,邏輯上是堆的一部分,是各個線程共享的區域,爲了與堆區分,又叫非堆。在永久代還存在時,方法區被用作永久代。方法區可以選擇是否開啓垃圾回收。jvm內存不足時會拋出OOME。

直接內存(Direct Memory):直接內存指的是非jvm管理的內存,是機器剩餘的內存。用基於通道(Channel)和緩衝區(Buffer)的方式來進行內存分配,用存儲在JVM中的DirectByteBuffer來引用,當機器本身內存不足時,也會拋出OOME。

舉例說明:

Object obj = new Object();

obj表示一個本地引用,存儲在jvm棧的本地變量表中,new Object()作爲一個對象放在堆中,Object類的類型信息(接口,方法,對象類型等)放在堆中,而這些類型信息的地址放在方法區中。

這裏需要知道如何通過引用訪問到具體對象,也就是通過obj引用如何找到new出來的這個Object()對象,主要有兩種方法,通過句柄和通過直接指針訪問。
  通過句柄: 在java堆中會專門有一塊區域被劃分爲句柄池,一個引用的背後是一個對象實例數據(java堆中)的指針和對象類型信息(方法區中)的指針,而這兩個指針都是在java堆上的。這種方法是優勢是較爲穩定,但是速度不是很快
在這裏插入圖片描述
  通過直接指針: 一個引用背後是一個對象的實例數據,這個實例數據裏面包含了“到對象類型信息的指針”。這種方式的優勢是速度快,在HotSpot中用的就是這種方式。
在這裏插入圖片描述

問:Java內存是如何分配和回收的?

答:內存分配主要是在堆上的分配,如前面new出來的對象,放在堆上,但是現代技術也支持在棧上分配,較爲少見,本文不考慮。分配內存與回收內存的標準是八個字:分代分配,分代回收。那麼這個代是什麼呢?

jvm中將對象根據存活的時間劃分爲三代:年輕代(Young Generation)、年老代(Old Generation)和永久代(Permannent Generation)。在jdk1.8中已經不再使用永久代,因此這裏不再介紹。
在這裏插入圖片描述
  年輕代:又叫新生代,所有新生成的對象都是先放在年輕代。年輕代分三個區,一個Eden區,兩個Survivor區,一個叫From,一個叫To(這個名字是動態變化的)。當Eden中滿時,執行Minor GC將消亡的對象清理掉,仍存活的對象將被複制到Survivor中的From區,清空Eden。當這個From區滿的時候,仍存活的對象將被複制到To區,清空From區,並且原From區變爲To區,原To區變爲From區,這樣的目的是保證To區一直爲空。當From區滿無對象可清理或者From-To區交換的次數超過設定(HotSpot默認爲15,通過-XX:MaxTenuringThreashold控制)的時候,仍存活的對象進入老年代。年輕代中Eden和Servivor的比例通過-XX:SerivorRation參數來配置,默認爲8,也就時說Eden:From:To=8:1:1。年輕代的回收方式叫做Minor GC,又叫停止-複製清理法。這種方法在回收的時候,需要暫停其他所有線程的執行,導致效率很低,現在雖然有優化,但是僅僅是將停止的時間變短,並沒有徹底取消這個停止。

年老代:年老代的空間較大,當年老代內存不足時,將執行Major GC也叫Full GC。如果對象比較大,可能會直接分配到老年代上而不經過年輕代。用-XX:pertenureSizeThreashold來設定這個值,大於這個的對象會直接分配到老年代上。

問:垃圾收集器工作原理

答:  在GC機制中,起作用的是垃圾收集器。HotSpot1.6中使用的垃圾收集器如下(有連線表示有聯繫):
在這裏插入圖片描述

Serial收集器:新生代(年輕代)收集器,使用停止-複製算法,使用一個線程進行GC,其他工作線程暫停。

ParNew收起:新生代收集器,使用停止-複製算法,Serial收集器的多線程版,用多個線程進行GC,其他工作線程暫停,關注縮短垃圾收集時間。

Parallel Scavenge收集器:新生代收集器,使用停止-複製算法,關注CPU吞吐量,即運行用戶代碼的時間/總時間。

Serial Old收集器:年老代收集器,單線程收集器,使用標記-整理算法(整理的方法包括sweep清理和compact壓縮,標記-清理是先標記需要回收的對象,在標記完成後統一清楚標記的對象,這樣清理之後空閒的內存是不連續的;標記-壓縮是先標記需要回收的對象,把存活的對象都向一端移動,然後直接清理掉端邊界以外的內存,這樣清理之後空閒的內存是連續的)。

Parallel Old收集器:老年代收集器,多線程收集器,使用標記-整理算法(整理的方法包括summary彙總和compact壓縮,標記-壓縮與Serial Old一樣,標記-彙總是將倖存的對象複製到預先準備好的區域,再清理之前的對象)。

CMS(Concurrent Mark Sweep)收集器:老年老代收集器,多線程收集器,關注最短回收時間停頓,使用標記-清除算法,用戶線程可以和GC線程同時工作。

問:簡單說說 Java 中內存泄漏與內存溢出的區別?

答:這內存溢出(OutOfMemory)是指程序在申請內存時沒有足夠的內存空間供其使用。內存溢出根據申請不同的內存區域又可以分爲heapsize OOM,PerGen OOM,DirecBuffer OOM和StackOverflowError . 內存泄露(MemoryLeak)是指程序在申請內存後無法釋放已申請的內存空間,一次內存泄露危害可以忽略,但內存泄露堆積後果很嚴重,無論多少內存遲早會被消耗盡,所以內存泄漏最終可能會導致內存溢出。

內存泄漏本身一般對業務邏輯不會產生什麼危害,作爲一般的用戶在頻次不高的情況下根本感覺不到內存泄漏的存在,真正有危害的是內存泄漏的堆積,這會最終消耗盡系統所有的內存,所以頻次不高和佔用內存不大的泄露一般都比較難以發現定位,如果需要定位分析內存泄漏可以採用一些第三方工具輔助,譬如 MAT 等。

內存溢出出現的原因一般比較多,譬如內存中一次加載的數據量過於龐大,啓動參數內存值設定的過小,內存持續泄漏導致內存用光等。解決內存溢出可以通過修改 JVM 啓動參數(-Xms/-Xmx 等,不過一般不建議),檢查分析代碼找出龐大數據或者泄漏點。

問:Java 對象使用後設置爲 null 會減少內存佔用嗎?

答:不會,設置爲 null 只是棧中指向的引用爲 null,但是 new 出來的對象還是存在於堆裏面的,按照目前的 GC 算法,要等 survior1 or survior2 滿的時候 JVM 纔會調用 GC 命令清除對應 survior 區的對象,將沒有棧指向的對象給回收掉。所以回收內存不是實時的,要看 survior區的大小和應用中創建對象的速度來看。所以,可以認爲用完的變量設爲 null 有助於 java 的 gc 更早的將無用的內存收回,僅此而已。

Java類加載機制

什麼是類的加載?

答:類的加載機制在整個java程序運行期間處於一個什麼環節,下面使用一張圖來表示:
在這裏插入圖片描述
從上圖可以看,java文件通過編譯器變成了.class文件,接下來類加載器又將這些.class文件加載到JVM中。其中類裝載器的作用其實就是類的加載。今天我們要討論的就是這個環節。有了這個印象之後我們再來看類的加載的概念:
其實可以一句話來解釋:類的加載指的是將類的.class文件中的二進制數據讀入到內存中,將其放在運行時數據區的方法區內,然後在堆區創建一個 java.lang.Class對象,用來封裝類在方法區內的數據結構。
到現在爲止,我們基本上對類加載機制處於整個程序運行的環節位置,還有類加載機制的概念有了基本的印象。在類加載.class文件之前,還有兩個問題需要我們去弄清楚:
1、在什麼時候纔會啓動類加載器?

其實,類加載器並不需要等到某個類被“首次主動使用”時再加載它,JVM規範允許類加載器在預料某個類將要被使用時就預先加載它,如果在預先加載的過程中遇到了.class文件缺失或存在錯誤,類加載器必須在程序首次主動使用該類時才報告錯誤(LinkageError錯誤)如果這個類一直沒有被程序主動使用,那麼類加載器就不會報告錯誤。
2、從哪個地方去加載.class文件

在這裏進行一個簡單的分類。例舉了5個來源

(1)本地磁盤

(2)網上加載.class文件(Applet)

(3)從數據庫中

(4)壓縮文件中(ZAR,jar等)

(5)從其他文件生成的(JSP應用)

類加載的過程

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載、驗證、準備、解析、初始化、使用和卸載七個階段。它們的順序如下圖所示:
在這裏插入圖片描述

其中類加載的過程包括了加載、驗證、準備、解析、初始化五個階段。在這五個階段中,加載、驗證、準備和初始化這四個階段發生的順序是確定的,而解析階段則不一定,它在某些情況下可以在初始化階段之後開始。另外注意這裏的幾個階段是按順序開始,而不是按順序進行或完成,因爲這些階段通常都是互相交叉地混合進行的,通常在一個階段執行的過程中調用或激活另一個階段。
1、加載

”加載“是”類加機制”的第一個過程,在加載階段,虛擬機主要完成三件事:

(1)通過一個類的全限定名來獲取其定義的二進制字節流

(2)將這個字節流所代表的的靜態存儲結構轉化爲方法區的運行時數據結構

(3)在堆中生成一個代表這個類的Class對象,作爲方法區中這些數據的訪問入口。

相對於類加載的其他階段而言,加載階段是可控性最強的階段,因爲程序員可以使用系統的類加載器加載,還可以使用自己的類加載器加載。我們在最後一部分會詳細介紹這個類加載器。在這裏我們只需要知道類加載器的作用就是上面虛擬機需要完成的三件事,僅此而已就好了。

2、驗證

驗證的主要作用就是確保被加載的類的正確性。也是連接階段的第一步。說白了也就是我們加載好的.class文件不能對我們的虛擬機有危害,所以先檢測驗證一下。他主要是完成四個階段的驗證:

(1)文件格式的驗證:驗證.class文件字節流是否符合class文件的格式的規範,並且能夠被當前版本的虛擬機處理。這裏面主要對魔數、主版本號、常量池等等的校驗(魔數、主版本號都是.class文件裏面包含的數據信息、在這裏可以不用理解)。

(2)元數據驗證:主要是對字節碼描述的信息進行語義分析,以保證其描述的信息符合java語言規範的要求,比如說驗證這個類是不是有父類,類中的字段方法是不是和父類衝突等等。

(3)字節碼驗證:這是整個驗證過程最複雜的階段,主要是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。在元數據驗證階段對數據類型做出驗證後,這個階段主要對類的方法做出分析,保證類的方法在運行時不會做出威海虛擬機安全的事。

(4)符號引用驗證:它是驗證的最後一個階段,發生在虛擬機將符號引用轉化爲直接引用的時候。主要是對類自身以外的信息進行校驗。目的是確保解析動作能夠完成。

對整個類加載機制而言,驗證階段是一個很重要但是非必需的階段,如果我們的代碼能夠確保沒有問題,那麼我們就沒有必要去驗證,畢竟驗證需要花費一定的的時間。當然我們可以使用-Xverfity:none來關閉大部分的驗證。

3、準備

準備階段主要爲類變量分配內存並設置初始值。這些內存都在方法區分配。在這個階段我們只需要注意兩點就好了,也就是類變量和初始值兩個關鍵詞:

(1)類變量(static)會分配內存,但是實例變量不會,實例變量主要隨着對象的實例化一塊分配到java堆中,

(2)這裏的初始值指的是數據類型默認值,而不是代碼中被顯示賦予的值。比如

public static int value = 1; //在這裏準備階段過後的value值爲0,而不是1。賦值爲1的動作在初始化階段。

當然還有其他的默認值。
在這裏插入圖片描述

注意,在上面value是被static所修飾的準備階段之後是0,但是如果同時被final和static修飾準備階段之後就是1了。我們可以理解爲static final在編譯器就將結果放入調用它的類的常量池中了。

4、解析

解析階段主要是虛擬機將常量池中的符號引用轉化爲直接引用的過程。什麼是符號應用和直接引用呢?

符號引用:以一組符號來描述所引用的目標,可以是任何形式的字面量,只要是能無歧義的定位到目標就好,就好比在班級中,老師可以用張三來代表你,也可以用你的學號來代表你,但無論任何方式這些都只是一個代號(符號),這個代號指向你(符號引用)
直接引用:直接引用是可以指向目標的指針、相對偏移量或者是一個能直接或間接定位到目標的句柄。和虛擬機實現的內存有關,不同的虛擬機直接引用一般不同。
解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行。

5、初始化

這是類加載機制的最後一步,在這個階段,java程序代碼纔開始真正執行。我們知道,在準備階段已經爲類變量賦過一次值。在初始化階端,程序員可以根據自己的需求來賦值了。一句話描述這個階段就是執行類構造器< clinit >()方法的過程。

在初始化階段,主要爲類的靜態變量賦予正確的初始值,JVM負責對類進行初始化,主要對類變量進行初始化。在Java中對類變量進行初始值設定有兩種方式:

①聲明類變量是指定初始值

②使用靜態代碼塊爲類變量指定初始值

JVM初始化步驟

1、假如這個類還沒有被加載和連接,則程序先加載並連接該類

2、假如該類的直接父類還沒有被初始化,則先初始化其直接父類

3、假如類中有初始化語句,則系統依次執行這些初始化語句

類初始化時機:只有當對類的主動使用的時候纔會導致類的初始化,類的主動使用包括以下六種:

創建類的實例,也就是new的方式
訪問某個類或接口的靜態變量,或者對該靜態變量賦值
調用類的靜態方法
反射(如 Class.forName(“com.dong.Test”))
初始化某個類的子類,則其父類也會被初始化
Java虛擬機啓動時被標明爲啓動類的類( JavaTest),直接使用 java.exe命令來運行某個主類.

問:簡單說說你對 ClassLoader 的理解?

1、Java語言系統自帶有三個類加載器: Bootstrap ClassLoader
:最頂層的加載類,主要加載核心類庫,也就是我們環境變量下面%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等。另外需要注意的是可以通過啓動jvm時指定-Xbootclasspath和路徑來改變Bootstrap ClassLoader的加載目錄。比如java -Xbootclasspath/a:path被指定的文件追加到默認的bootstrap路徑中。我們可以打開我的電腦,在上面的目錄下查看,看看這些jar包是不是存在於這個目錄。
Extention ClassLoader \ :擴展的類加載器,加載目錄%JRE_HOME%\lib\ext目錄下的jar包和class文件。還可以加載-D java.ext.dirs選項指定的目錄。
Appclass Loader:也稱爲SystemAppClass。 加載當前應用的classpath的所有類。
我們看到java爲我們提供了三個類加載器,應用程序都是由這三種類加載器互相配合進行加載的,如果有必要,我們還可以加入自定義的類加載器。這三種類加載器的加載順序是什麼呢?

Bootstrap ClassLoader > Extention ClassLoader > Appclass Loader
在這裏插入圖片描述

一張圖來看一下他們的層次關係

問:Java 虛擬機是如何判斷兩個 Class 類是相同的?

答:Java 虛擬機不僅要看類的全名是否相同(含包名路徑),還要看加載此類的類加載器是否一樣,只有兩者都相同的情況下才認爲兩個類是相同的。即便是同樣的字節代碼,被不同的類加載器加載之後所得到的類也是不同的,譬如一個 Java 類 cn.dong.Test 在編譯後生成了字節碼文件 Test.class,兩個不同的類加載器 ClassLoaderA 和 ClassLoaderB 分別讀取了這個 Test.class 文件,然後各自定義出一個 java.lang.Class 類的實例來表示這個類,這兩個實例是不相同的,因爲對於 Java 虛擬機來說它們是不同的類,這時候如果試圖對這兩個類的對象進行相互賦值則會拋出 ClassCastException 運行時異常。這在做插件化動態加載中要尤其注意.

線程相關面試題

問:線程有多少種狀態,以及他們之間是如何切換的?

Java中的線程的生命週期大體可分爲5種狀態,如下圖所示:
在這裏插入圖片描述

線程狀態切換如下圖所示:
在這裏插入圖片描述

  1. 新建(NEW):新創建了一個線程對象,並沒有調用start()方法之前。

  2. 可運行(RUNNABLE):也就是就緒狀態,調用start()方法之後線程就進入就緒狀態, 但是並不是說只要調用start()方法線程就馬上變爲當前線程,在變爲當前線程之前都是爲就緒狀態。值得一提的是,線程在睡眠和掛起中恢復的時候也會進入就緒狀態。線程對象創建後,其他線程(比如main線程)調用了該對象的start()方法。該狀態的線程位於可運行線程池中,等待被線程調度選中,獲取cpu 的使用權 。

  3. 運行(RUNNING):可運行狀態(runnable)的線程獲得了cpu 時間片(timeslice) ,執行程序代碼。:線程被設置爲當前線程,開始執行run()方法。就是線程進入運行狀態

  4. 阻塞(BLOCKED):阻塞狀態是指線程因爲某種原因放棄了cpu 使用權,也即讓出了cpu timeslice,暫時停止運行。直到線程進入可運行(runnable)狀態,纔有機會再次獲得cpu timeslice 轉到運行(running)狀態。線程被暫停,比如說調用sleep()方法後線程就進入阻塞狀態。

阻塞的情況分三種:

(一). 等待阻塞:運行(running)的線程執行o.wait()方法,JVM會把該線程放入等待隊列(waitting queue)中。
(二). 同步阻塞:運行(running)的線程在獲取對象的同步鎖時,若該同步鎖被別的線程佔用,則JVM會把該線程放入鎖池(lock pool)中。
(三). 其他阻塞:運行(running)的線程執行Thread.sleep(long ms)或t.join()方法,或者發出了I/O請求時,JVM會把該線程置爲阻塞狀態。當sleep()狀態超時、join()等待線程終止或者超時、或者I/O處理完畢時,線程重新轉入可運行(runnable)狀態。
5. 死亡(DEAD):線程執行結束,線程run()、main() 方法執行結束,或者因異常退出了run()方法,則該線程結束生命週期。死亡的線程不可再次復生。

說說什麼是原子性、可見性和有序性

1.原子性

原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。

一個很經典的例子就是銀行賬戶轉賬問題:

比如從賬戶A向賬戶B轉1000元,那麼必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。

試想一下,如果這2個操作不具備原子性,會造成什麼樣的後果。假如從賬戶A減去1000元之後,操作突然中止。然後又從B取出了500元,取出500元之後,再執行 往賬戶B加上1000元 的操作。這樣就會導致賬戶A雖然減去了1000元,但是賬戶B沒有收到這個轉過來的1000元。

所以這2個操作必須要具備原子性才能保證不出現一些意外的問題。

同樣地反映到併發編程中會出現什麼結果呢?

舉個最簡單的例子,大家想一下假如爲一個32位的變量賦值過程不具備原子性的話,會發生什麼後果?

i = 9;

假若一個線程執行到這個語句時,我暫且假設爲一個32位的變量賦值包括兩個過程:爲低16位賦值,爲高16位賦值。

那麼就可能發生一種情況:當將低16位數值寫入之後,突然被中斷,而此時又有一個線程去讀取i的值,那麼讀取到的就是錯誤的數據。

2.可見性

可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。

舉個簡單的例子,看下面這段代碼:

//線程1執行的代碼
int i = 0;
i = 10;
 
//線程2執行的代碼
j = i;

假若執行線程1的是CPU1,執行線程2的是CPU2。由上面的分析可知,當線程1執行 i =10這句時,會先把i的初始值加載到CPU1的高速緩存中,然後賦值爲10,那麼在CPU1的高速緩存當中i的值變爲10了,卻沒有立即寫入到主存當中。

此時線程2執行 j = i,它會先去主存讀取i的值並加載到CPU2的緩存當中,注意此時內存當中i的值還是0,那麼就會使得j的值爲0,而不是10.

這就是可見性問題,線程1對變量i修改了之後,線程2沒有立即看到線程1修改的值。
Java提供了volatile關鍵字來保證可見性。
當一個共享變量被volatile修飾時,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,它會去內存中讀取新值。

而普通的共享變量不能保證可見性,因爲普通共享變量被修改之後,什麼時候被寫入主存是不確定的,當其他線程去讀取時,此時內存中可能還是原來的舊值,因此無法保證可見性。

另外,通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時刻只有一個線程獲取鎖然後執行同步代碼,並且在釋放鎖之前會將對變量的修改刷新到主存當中。因此可以保證可見性。

3.有序性

有序性:即程序執行的順序按照代碼的先後順序執行。 計算機在執行程序時,爲了提高性能,編譯器和處理器的常常會對指令做重排(Instruction Reorder),一般分以下3種 \

編譯器優化的重排

編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。

指令並行的重排

現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性(即後一個執行的語句無需依賴前面執行的語句的結果),處理器可以改變語句對應的機器指令的執行順序

內存系統的重排

由於處理器使用緩存和讀寫緩存衝區,這使得加載(load)和存儲(store)操作看上去可能是在亂序執行,因爲三級緩存的存在,導致內存與緩存的數據同步存在時間差。
舉個簡單的例子,看下面這段代碼:

int i = 0;              
boolean flag = false;
i = 1;                //語句1  
flag = true;          //語句2

上面代碼定義了一個int型變量,定義了一個boolean類型變量,然後分別對兩個變量進行賦值操作。從代碼順序上看,語句1是在語句2前面的,那麼JVM在真正執行這段代碼的時候會保證語句1一定會在語句2前面執行嗎?不一定,爲什麼呢?這裏可能會發生指令重排。

下面解釋一下什麼是指令重排序,一般來說,處理器爲了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先後順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。

比如上面的代碼中,語句1和語句2誰先執行對最終的程序結果並沒有影響,那麼就有可能在執行過程中,語句2先執行而語句1後執行。

但是要注意,雖然處理器會對指令進行重排序,但是它會保證程序最終結果會和代碼順序執行結果相同,那麼它靠什麼保證的呢?再看下面一個例子:

int a = 10;    //語句1
int r = 2;    //語句2
a = a + 3;    //語句3
r = a*a;     //語句4

這段代碼有4個語句,那麼可能的一個執行順序是:

那麼可不可能是這個執行順序呢: 語句2 語句1 語句4 語句3

不可能,因爲處理器在進行重排序時是會考慮指令之間的數據依賴性,如果一個指令Instruction 2必須用到Instruction 1的結果,那麼處理器會保證Instruction 1會在Instruction 2之前執行。

雖然重排序不會影響單個線程內程序執行的結果,但是多線程呢?下面看一個例子:

//線程1:
context = loadContext();   //語句1
inited = true;             //語句2
 
//線程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

上面代碼中,由於語句1和語句2沒有數據依賴性,因此可能會被重排序。假如發生了重排序,在線程1執行過程中先執行語句2,而此是線程2會以爲初始化工作已經完成,那麼就會跳出while循環,去執行doSomethingwithconfig(context)方法,而此時context並沒有被初始化,就會導致程序出錯。

從上面可以看出,指令重排序不會影響單個線程的執行,但是會影響到線程併發執行的正確性。

也就是說,要想併發程序正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程序運行不正確。

說說你對Java內存模型的理解

Java內存模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念,並不真實存在,它描述的是一組規則或規範,通過這組規範定義了程序中各個變量(包括實例字段,靜態字段和構成數組對象的元素)的訪問方式。由於JVM運行程序的實體是線程,而每個線程創建時JVM都會爲其創建一個工作內存(有些地方稱爲棧空間),用於存儲線程私有的數據,而Java內存模型中規定所有變量都存儲在主內存,主內存是共享內存區域,所有線程都可以訪問,但線程對變量的操作(讀取賦值等)必須在工作內存中進行,首先要將變量從主內存拷貝的自己的工作內存空間,然後對變量進行操作,操作完成後再將變量寫回主內存,不能直接操作主內存中的變量,工作內存中存儲着主內存中的變量副本拷貝,前面說過,工作內存是每個線程的私有數據區域,因此不同的線程間無法訪問對方的工作內存,線程間的通信(傳值)必須通過主內存來完成,其簡要訪問過程如下圖
在這裏插入圖片描述

設計模式相關

請寫出一個懶漢模式的單例

public final class Singleton {

    /**
     * 對保存實例的變量添加volatile的修飾
     */
    private volatile static Singleton instance = null;

    private Singleton() {

    }
    
    public final static Singleton getInstance() {

         //先檢查實例是否存在,如果不存在才進入下面的同步塊
        if (instance == null) {
            //同步塊,線程安全的創建實例
            synchronized(Singleton.class) {
                //再次檢查實例是否存在,如果不存在才真的創建實例
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

問: 上面instance爲什麼需要聲名volatile?
原因在於某一個線程執行到第一次檢測,讀取到的instance不爲null時,instance的引用對象可能沒有完成初始化。因爲instance = new Singleton();可以分爲以下3步完成(僞代碼)

memory = allocate(); //1.分配對象內存空間
instance(memory);    //2.初始化對象
instance = memory;   //3.設置instance指向剛分配的內存地址,此時instance!=null

由於步驟1和步驟2間可能會重排序,如下:

memory = allocate(); //1.分配對象內存空間
instance = memory;   //3.設置instance指向剛分配的內存地址,此時instance!=null,但是對象還沒有初始化完成!
instance(memory);    //2.初始化對象

由於步驟2和步驟3不存在數據依賴關係,而且無論重排前還是重排後程序的執行結果在單線程中並沒有改變,因此這種重排優化是允許的。但是指令重排只會保證串行語義的執行的一致性(單線程),但並不會關心多線程間的語義一致性。所以當一條線程訪問instance不爲null時,由於instance實例未必已初始化完成,也就造成了線程安全問題.所以我們使用volatile禁止instance變量被執行指令重排優化即可。

問:說說 Condition 與傳統線程協作的區別?

答:Condition 可以說是傳統 Object.wait() 和 Object.natify() 的加強版,能夠更加精細的控制多線程的休眠與喚醒。對於同一個鎖我們可以創建多個 Condition(即多個監視器),從而在不同的情況下使用不同的 Condition。

舉個例子,假設我們要實現多線程讀寫同一個緩衝區,當向緩衝區中寫入數據之後喚醒讀線程,當從緩衝區讀出數據之後喚醒寫線程。如果使用傳統 Object.wait()/Object.notify()/Object.notifyAll() 實現該緩衝區,當向緩衝區寫入數據後喚醒讀線程時是沒法通過 Object.notify()/Object.notifyAll() 明確的指定喚醒讀線程,而只能通過 Object.notifyAll() 喚醒所有線程。但是通過 Condition 就可以明確的指定喚醒讀線程。
下面的程序就演示了 Condition 特有的能力,我們模擬日常上班,每個啓動線程都是一個崗位,上班以後 boss 發佈一個 plain,然後 manager 趕緊頂上去接鍋,然後 manager 把 boss 的 plain 發給 teamLeader,teamLeader 又把 plain 發給 coder,如下就是這麼一種流程:


public class Test {
    public static void main(String[] args) throws InterruptedException {
        final OfficeWork work = new OfficeWork();
        new Thread() {
            public void run() {
                work.coderCmd();
            }
        }.start();

        new Thread() {
            public void run() {
                work.teamLeaderCmd();
            }
        }.start();

        new Thread() {
            public void run() {
                work.managerCmd();
            }
        }.start();

        Thread.sleep(2000);
        work.bossCmd();
    }

    static class OfficeWork {
        private Lock lock = new ReentrantLock();
        private Condition cond1 = lock.newCondition();
        private Condition cond2 = lock.newCondition();
        private Condition cond3 = lock.newCondition();

        public void bossCmd() {
            try {
                lock.lock();
                cond1.signal();
                System.out.println("Boss have a plain!");
            } finally {
                lock.unlock();
            }
        }

        public void managerCmd() {
            try {
                lock.lock();
                cond1.await();
                Thread.sleep(500);
                System.out.println("Manager received Cmd!");
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                cond3.signal();
                lock.unlock();
            }
        }

        public void coderCmd() {
            try {
                lock.lock();
                cond2.await();
                Thread.sleep(500);
                System.out.println("Coder received Cmd!");
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                cond1.signal();
                lock.unlock();
            }
        }

        public void teamLeaderCmd() {
            try {
                lock.lock();
                cond3.await();
                Thread.sleep(500);
                System.out.println("TeamLeader received Cmd!");
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                cond2.signal();
                lock.unlock();
            }
        }
    }
}

問:Java 多線程文件讀寫操作怎麼保證併發安全?

答:多線程文件併發安全其實就是在考察線程併發安全,最銼的方式就是使用 wait/notify、Condition、synchronized、ReentrantLock 等方式,這些方式默認都是排它操作(排他鎖),也就是說默認情況下同一時刻只能有一個線程可以對文件進行操作,所以可以保證併發文件操作的安全性,但是在併發讀數量遠多於寫數量的情況下性能卻不那麼好。因此推薦使用 ReadWriteLock 的實現類 ReentrantReadWriteLock,它也是 Lock 的一種實現,允許多個讀線程同時訪問,但不允許寫線程和讀線程、寫線程和寫線程同時訪問。所以相對於排他鎖來說提高了併發效率。ReentrantReadWriteLock 讀寫鎖裏面維護了兩個繼承自 Lock 的鎖,一個用於讀操作(ReadLock),一個用於寫操作(WriteLock)。

下面給出高效讀寫操作的樣例:

class DataCache {
    private Map<String, String> cachedMap = new HashMap<>();

    private ReadWriteLock lock = new ReentrantReadWriteLock();
    private final Lock readLock = lock.readLock();
    private final Lock writeLock = lock.writeLock();

    public long readSize() {
        try {
            readLock.lock();
            mockTimeConsumingOpt();
            return cachedMap.size();
        } finally {
            readLock.unlock();
        }
    }

    public long write(String key, String value) {
        try {
            writeLock.lock();
            mockTimeConsumingOpt();
            cachedMap.put(key, value);
            return cachedMap.size();
        } finally {
            writeLock.unlock();
        }
    }

    private void mockTimeConsumingOpt() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

class Reader extends Thread {
    public DataCache dataCache;

    public Reader(String name, DataCache dataCache) {
        super(name);
        this.dataCache = dataCache;
    }

    @Override
    public void run() {
        String name = Thread.currentThread().getName();
        long result =  dataCache.readSize();
        System.out.println(name + " read current cache size is:" + result);
    }
}

class Writer extends Thread {

    public DataCache dataCache;

    public Writer(String str, DataCache dataCache) {
        super(str);
        this.dataCache = dataCache;
    }

    @Override
    public void run() {
        String name = Thread.currentThread().getName();
        String result = "" + dataCache.write(name, "DATA-"+name);
        System.out.println(name + " write to current cache!");
    }
}

public class Test {
    public static void main(String[] args) {
        final DataCache dataCache = new DataCache();

        ArrayList<Thread> worker = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            if (i % 2 == 0) {
                Writer writer = new Writer("Writer"+i, dataCache);
                worker.add(writer);
            } else {
                Reader reader = new Reader("Reader"+i, dataCache);
                worker.add(reader);
            }
        }

        for (int i = 0; i < worker.size(); i++) {
            worker.get(i).start();
        }
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章