
本來這個系列的更新節奏被我拖了很久。前兩篇把Java基礎語法、面向對象和常用API過了一遍這次趁著項目告一段落把集合框架、函數式編程、并發編程、JVM內存模型以及反射動態代理這五塊硬骨頭又快速擼了一遍。說實話每次復習都能發現幾個之前“以為自己知道其實沒吃透”的點尤其是HashMap底層和線程池參數真到了面試或者排查線上問題的時候才知道基礎扎實有多重要。這篇就當我的復習筆記加面試前自檢清單如果你也在準備Java面試或者想系統性地鞏固一遍核心基礎建議跟著過一遍遇到不熟的地方再往深挖。1. 先把“地基”踩實從環境變量到編譯運行1.1 環境變量到底在配什么很多初學者在環境變量這一步就卡住了網上教程一大堆照著配完仍然java -version報錯。其實環境變量的核心就三個JAVA_HOME、PATH、CLASSPATH。JAVA_HOME告訴系統和各種構建工具JDK 到底裝在哪里。后續 Maven、Tomcat、IDEA 找 JDK都是優先讀這個變量。PATH讓命令行能在任意目錄直接執行java、javac原理就是把%JAVA_HOME%\bin這個目錄塞進系統搜索路徑。CLASSPATH指定類加載器去哪兒找第三方類庫。JDK 1.5 之后不配 CLASSPATH 也能編譯運行基本程序因為編譯器默認會找當前目錄所以新手階段不用被它嚇住。配置時最容易被忽視的一個細節是改完環境變量后已經打開的命令行窗口不會自動生效必須重新開一個窗口。另一個高頻坑是多個 JDK 版本殘留PATH里前面的 JDK 8 把后面的 JDK 17 擋住了導致java -version顯示的版本和JAVA_HOME對不上。排查方法很簡單在命令行執行where java把實際命中的路徑找出來然后去PATH里調整順序。1.2 從 NoClassDefFoundError 聊到類路徑問題熱詞里有個非常典型的報錯uncaught exception java.lang.noclassdeffounderror: java/applet/applet in thr。看到NoClassDefFoundError很多人的第一反應是“類找不到”但嚴格來說它和ClassNotFoundException是兩回事。ClassNotFoundException運行時通過Class.forName()或ClassLoader.loadClass()動態加載類結果類路徑下壓根沒有這個類拋的是受檢異常是“主動找類沒找到”。NoClassDefFoundError類在編譯期存在但運行期類路徑變了或者某個靜態初始化塊拋了異常導致 JVM 無法完成這個類的定義。它是 Error不是 Exception說明 JVM 本身已經處在不穩定的狀態。出現java/applet/applet這種老掉牙的類找不到多半是項目里引用了某些老庫或者編譯目標版本設置得太老運行時卻用了高版本 JDK高版本 JDK 已經移除了 Applet 相關模塊。解決辦法不是去網上隨便找個 jar 塞進去而是要檢查依賴樹看看誰傳遞依賴引入了這個老類再考慮排除或者升級依賴版本。順帶提一句Lombok 的you arent using a compiler supported by lombok報錯也屬于“編譯期工具鏈不匹配”問題本質是 JDK 版本和 Lombok 版本不兼容優先升級 Lombok 版本而不是去改編譯器參數。2. 集合框架不是背 API而是理解數據結構2.1 集合整體結構梳理Java 集合框架的核心接口就兩個方向Collection和Map。Collection下面分了List、Set、QueueMap則獨立一條線。理解這個體系不是為了面試時背出類名而是為了選型時腦子里有圖景。日常開發中最常用的選型大致可以按三條標準來判斷場景推薦實現原因需要有序、可重復、按下標隨機訪問ArrayList底層數組隨機訪問 O(1)需要頻繁在中間插入/刪除、不關心隨機訪問LinkedList底層雙向鏈表增刪只改指針需要去重且不要求順序HashSet / LinkedHashSet基于 HashMap去重 O(1)需要按 key 快速查找 valueHashMap哈希表平均 O(1)并發環境下需要線程安全的 MapConcurrentHashMapCAS synchronized鎖粒度細2.2 ArrayList 擴容與 LinkedList 的真相ArrayList 底層的擴容機制是面試必問點。它默認初始容量是 10每次擴容變成原來的 1.5 倍oldCapacity (oldCapacity 1)。這里有個值得注意的細節如果一次性 addAll 添加大量元素按 1.5 倍逐步擴會頻繁拷貝數組所以源碼里做了grow時判斷“實際需要的最小容量”來避免無意義的擴容。LinkedList 的“增刪快”其實有很大前提。它確實在頭部和中間插入時不需要搬移元素但由于鏈表節點是分散在堆內存的CPU 緩存命中率低再加上每次插入都要 new 一個 Node 對象實際性能在絕大多數場景下反而不如 ArrayList。我做過一次簡單的基準測試在 100 萬元素規模下中部插入 LinkedList 確實比 ArrayList 快但頭部插入因為 LinkedList 有頭指針勉強有優勢尾部和隨機訪問則全面落后。結論很簡單能用 ArrayList 就用 ArrayListLinkedList 更多時候是退路而不是首選。2.3 HashMap 底層原理從數組鏈表到紅黑樹HashMap 是集合框架里邊最值得深挖的一個類。它的底層結構在 JDK 1.8 之后是“數組 鏈表 紅黑樹”。put 一個 key-value 時先對 key 的 hashCode 做一次擾動運算(h key.hashCode()) ^ (h 16)目的是讓高 16 位也參與尋址降低哈希沖突概率然后(n - 1) hash計算出數組下標。鏈表什么時候轉紅黑樹這里有個容易記混的閾值鏈表長度超過 8并且數組長度大于等于 64才轉紅黑樹如果數組長度不足 64優先擴容而不是轉樹。為什么是 8因為作者在源碼注釋里說了遵循泊松分布在負載因子 0.75 的情況下鏈表長度達到 8 的概率已經降到千萬分之六以下這時候轉紅黑樹是為了防止極端哈希沖突下查詢退化成 O(n)。理解這個概率之后就不會再死記硬背“鏈表長度大于 8 就轉樹”這種片面的說法了。負載因子 0.75 也是個很經典的設計。它是時間成本減少擴容次數和空間成本避免數組太稀疏的折中。JDK 1.7 里 HashMap 的頭插法在并發擴容時會形成環形鏈表導致 get 死循環1.8 改成尾插法之后死循環問題解決了但 HashMap 在并發下仍然會丟數據所以并發場景永遠不要用 HashMap直接用ConcurrentHashMap。2.4 ConcurrentHashMap并發場景下的正解ConcurrentHashMap在 JDK 1.7 里用的是分段鎖Segment 繼承 ReentrantLock1.8 之后廢棄了分段鎖改成了CAS synchronized鎖住數組的每個桶節點。put 時先 CAS 判斷當前桶是否為空為空就直接 CAS 插入不為空才 synchronized 鎖住頭節點再走鏈表或者紅黑樹的插入邏輯。這個設計把鎖粒度從“一段區域”縮小到“單個桶”并發度提升非常明顯而且能兼容之前用 HashMap 的絕大多數代碼。有朋友經常把ConcurrentHashMap和Hashtable搞混。Hashtable是給整個表加一把重量級鎖并發讀都要串行早就該淘汰了。還有Collections.synchronizedMap()它本質是包裝類把所有方法都加上 synchronized鎖粒度同樣粗。所以面試里被問到“線程安全的 Map 有哪些”最優答案就是ConcurrentHashMap并且要說清楚它為什么比另外兩個更優秀。3. Lambda 與 Stream切換到函數式思維3.1 Lambda 語法速記Lambda 表達式的本質是“函數式接口的匿名實現”。所謂函數式接口就是只包含一個抽象方法的接口比如Runnable、Comparator、Function。語法結構可以拆成三部分參數列表、箭頭、函數體。// 無參Runnable Runnable task () - System.out.println(run); // 單參Consumer ConsumerString consumer s - System.out.println(s); // 多參Comparator ComparatorInteger comparator (a, b) - a - b;剛學 Lambda 時最容易蒙的是“什么時候能省略參數類型”。答案是只要編譯器能從上下文推斷出類型就可以省略。比如stream.map(s - s.length())編譯器知道s是String就不需要寫(String s) -。但如果是自己定義泛型方法推斷不出來的時候就必須顯式寫類型。3.2 Stream 常用操作與實戰Stream 的核心思想是“流水線”數據源經過一系列中間操作最后由終端操作觸發計算。中間操作只做聲明不會真的計算這就是“惰性求值”。最常用的幾個操作filter按條件過濾map一對一轉換flatMap把一個元素展開成多個元素sorted排序可以傳 Comparatordistinct去重collect把流收集成 List/Set/Mapreduce把整個流歸約為一個值代碼示例統計訂單列表中金額超過 100 的訂單總額ListOrder orderList ...; double total orderList.stream() .filter(o - o.getAmount() 100) .mapToDouble(Order::getAmount) .sum();這里面有個很實用的點處理基本數值流的時候優先用IntStream、LongStream、DoubleStream尤其是 sum、max、min 這類聚合操作比自己reduce再拆箱裝箱省心很多。熱搜詞里提到的“Java 字符串多行寫法”其實也適合放在這里說。JDK 15 正式引入了文本塊配合 Stream 處理多行文本非常舒服String json { name: Java, age: 27 } ;文本塊不需要寫一堆\n拼接代碼可讀性提升明顯但要注意它保留縮進的方式以及后面不能直接跟內容。3.3 Lambda 與 Stream 的坑第一個坑流只能消費一次。一個Stream對象調用過終端操作之后就被關閉了再調用會拋IllegalStateException: stream has already been operated upon or closed。解決辦法就是用的時候現場生成新流不要反復用同一個變量。第二個坑Lambda 捕獲的局部變量必須是 effectively final。也就是說變量沒被顯式聲明為 final但后續沒有被重新賦值才能被 Lambda 引用。如果你在 Lambda 內部嘗試修改外部變量的值編譯直接報錯。想累加計數怎么辦用AtomicInteger或者干脆改成流里的收集器。第三個坑并行流parallelStream()在共享可變狀態時會出大問題。比如在并行流里往同一個 ArrayList 里 add 數據不僅線程不安全而且結果隨機。真要并行處理數據優先用collect做歸約保證每段線程處理獨立的數據塊最后合并結果。4. 并發編程線程、鎖與線程池4.1 創建線程的三種方式及選擇創建線程傳統上有三種方式繼承Thread、實現Runnable、實現Callable。繼承Thread的問題在于 Java 是單繼承繼承了 Thread 就不能繼承別的類了擴展性太差實現Runnable沒有返回值也拿不到執行結果Callable配合FutureTask可以拿到返回值并且能拋異常。ExecutorService executor Executors.newFixedThreadPool(4); FutureInteger future executor.submit(() - { Thread.sleep(2000); return 1 1; }); Integer result future.get(); // 這里會阻塞等待任務完成熱搜詞“java線程等待都完成”對應的就是這種場景。除了Future.get()逐個等待更優雅的是用CountDownLatch或者CompletableFuture.allOf()。CountDownLatch 適合“等 N 個線程都做完再繼續主線程”的場景比如批量導出報表等所有分片查詢線程結束后統一生成 ZIP 文件。4.2 synchronized 與 JUC 鎖到底怎么選synchronized從 JDK 1.6 開始經歷了鎖升級過程無鎖 - 偏向鎖 - 輕量級鎖 - 重量級鎖。鎖只能升級不能降級這是為了在不同競爭程度下尋找性能平衡。重量級鎖依賴操作系統的互斥量線程掛起喚醒涉及內核態切換成本很高所以 JVM 才費盡心思設計偏向鎖和輕量級鎖來減少這種切換。JUC包下的ReentrantLock則更靈活支持公平鎖、非公平鎖支持tryLock()超時控制支持多個 Condition 條件變量。但偏偏這兩個鎖的選擇很多面試者說反了。簡單地說JDK 1.6 之后 synchronized 性能已經和 ReentrantLock 差距不大能用 synchronized 就優先用 synchronized因為它是 JVM 原生支持的使用簡單、不會因為忘記解鎖導致死鎖。需要公平鎖、超時中斷、多條件隊列這種高級特性時才考慮ReentrantLock。4.3 線程池參數面試常考生產常踩坑ThreadPoolExecutor有七個核心參數corePoolSize核心線程數maximumPoolSize最大線程數keepAliveTime非核心線程的空閑存活時間unit時間單位workQueue任務隊列threadFactory線程工廠handler拒絕策略執行流程一句話概括先讓核心線程跑核心線程滿了就進隊列隊列滿了才創建非核心線程到最大線程數再滿就觸發拒絕策略。這個順序非常關鍵面試經常搞混的是“隊列和最大線程數的先后關系”——很多人誤以為核心線程滿后直接創建新線程其實中間還隔著一個隊列。生產環境大忌就是用Executors.newFixedThreadPool()和newCachedThreadPool()。前者隊列是無界的LinkedBlockingQueue當任務激增時隊列無限堆積內存直接飆升后者最大線程數是Integer.MAX_VALUE任務一多線程瘋狂創建開銷極大。正確做法是用ThreadPoolExecutor的構造函數顯式指定有界隊列和自定義拒絕策略。拒絕策略的四種實現也要掌握策略行為AbortPolicy直接拋 RejectedExecutionException默認策略CallerRunsPolicy不用線程池由提交任務的線程自己執行DiscardPolicy靜默丟棄任務DiscardOldestPolicy丟棄隊列中最早的任務再重試提交我個人偏愛CallerRunsPolicy因為在流量高峰時它會把壓力傳回調用方起到天然背壓的效果不會靜默丟消息。5. JVM 內存與異常從 StackOverflow 到 OOM 的排查思路5.1 運行時數據區快速瀏覽JVM 運行時數據區可以按“線程共享”和“線程私有”來分線程私有虛擬機棧、本地方法棧、程序計數器線程共享堆、方法區JDK 8 之后是元空間 Metaspace、運行時常量池虛擬機棧里每個方法對應一個棧幀棧幀里有局部變量表、操作數棧、動態鏈接、方法出口。方法調用層級太深比如遞歸沒有出口就會拋StackOverflowError。堆里放對象實例絕大多數 OOM 都發生在這里。程序計數器是唯一不會 OOM 的區域因為它的作用只是記錄當前線程執行到哪一條字節碼指令空間幾乎可以忽略。5.2 高頻 OOM 類型與排查思路熱詞里有一個java: outofmemoryerror: insufficient memory這是比較典型的“內存不足”提示。但在真實 JVM 里OOM 還要細分成幾種類型原因排查方向Java heap space堆內存不足對象太多或內存泄漏抓 heap dump用 MAT 找大對象、查引用鏈GC overhead limit exceededGC 時間過長但回收效果極差優先檢查堆大小配置再查泄漏Metaspace類元數據過多常由動態生成類導致查 CGLIB/反射動態代理生成類是否過量unable to create new native thread操作系統線程數達到上限ulimit、線程池參數、減少線程數排查堆內存 OOM第一步不是改-Xmx而是抓現場。可以加 JVM 參數-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/path/to/dump讓 JVM 在 OOM 時自動導出堆轉儲文件。然后用jstat -gcutil pid看 GC 情況用jmap -histo pid看對象分布再用 MAT 分析 dump 文件的支配樹基本能定位到泄漏源。這里有個容易被忽略的經驗很多號稱“內存泄漏”的問題其實是ThreadLocal沒清理或者是static集合一直持有對象引用。特別是ThreadLocal在線程池場景下如果線程不復用還好一旦線程長期存活且沒有調用remove()ThreadLocalMap 里的 Entry 會一直被線程持有形成內存泄漏。5.3 異常體系與 try-with-resourcesJava 異常分兩大類Error和Exception。Error是 JVM 層面的嚴重問題比如StackOverflowError、OutOfMemoryError應用程序通常不應該去捕獲。Exception又分受檢異常IOException、SQLException和非受檢異常NullPointerException、IllegalArgumentException。受檢異常強制處理非受檢異常不強制。這個設計本意是好的但實際項目里經常被濫用。我的習慣是外部環境導致的、可恢復的異常用受檢異常程序 bug 導致的、修代碼才能解決的問題用 RuntimeException。處理 IO 流時JDK 7 之后一定要用try-with-resources寫法自動關閉資源。它有兩個好處第一不用在 finally 里寫一堆 null 判斷第二當主體的異常和 close 的異常同時發生時close 的異常會被壓制suppressed主體異常優先拋出。try (BufferedReader reader Files.newBufferedReader(path)) { String line reader.readLine(); } catch (IOException e) { log.error(read failed, e); }6. 反射與動態代理框架底層的秘密6.1 反射核心 API反射是框架設計的基石Spring 的依賴注入、MyBatis 的 mapper 映射、Retrofit 的動態接口實現底層全是反射和動態代理。反射的入口是Class對象有三種獲取方式類名.class、對象.getClass()、Class.forName(全限定類名)。拿到Class之后可以獲取構造器、方法、字段并繞過訪問控制去調用或修改。Class? clazz Class.forName(com.example.User); Constructor? constructor clazz.getDeclaredConstructor(String.class); constructor.setAccessible(true); // 繞過 private 構造器檢查 Object user constructor.newInstance(張三);反射的性能天然比直接調用慢因為它要解析類型、檢查訪問權限、走 JNI 后續的一系列動態邏輯。不過現代 JVM 有針對反射的緩存優化平時業務代碼里不需要過度擔心那點性能差但如果在一個高頻調用鏈路上反復用反射就該考慮在啟動階段緩存Method對象而不是每次調用都getMethod()重新查找。6.2 JDK 動態代理與 CGLIB 的區別動態代理的面試考點集中在這兩種實現方式的原理和區別上。JDK 動態代理基于接口。代理類在運行時通過Proxy.newProxyInstance()生成要求目標類必須實現接口。它本質是構造了一個實現了同樣接口的代理對象然后在InvocationHandler.invoke()方法里做增強邏輯。CGLIB基于繼承。通過生成目標類的子類來覆蓋方法所以目標類不能是 final 的被代理的方法也不能是 final。Spring 默認策略是目標類實現了接口就用 JDK 動態代理沒有實現接口就用 CGLIB。// JDK 動態代理示例 UserService userService new UserServiceImpl(); UserService proxy (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class[]{UserService.class}, (proxyObj, method, args) - { System.out.println(before method: method.getName()); Object result method.invoke(userService, args); System.out.println(after method: method.getName()); return result; });6.3 動態代理的實際應用場景Spring AOP 的事務管理、日志切面、權限校驗都是動態代理的經典應用。MyBatis 里面更是把 JDK 動態代理玩出了花我們用SqlSession.getMapper(UserMapper.class)拿到的 mapper 對象其實是個 JDK 代理對象真正的邏輯在MapperProxy里通過解析接口上的注解動態生成 SQL 并執行。自己在項目里也經常可以用動態代理做“無侵入增強”。比如統一打印方法入參出參、統一做重試機制、統一做接口耗時監控。要注意的是Spring 的Transactional之所以失效一部分原因就是動態代理失效——比如同一個類內部調用this.method()走的不是代理對象自然不經過事務增強邏輯。解決辦法是注入自身的代理對象或者把方法拆分到另一個 Spring Bean 里。7. 高頻面試題與手撕算法沖刺7.1 八股文速記表格復習到最后把面試最高頻的幾道題整理成表格方便每天翻一遍。問題核心答案要點String 為什么不可變不可變保證字符串常量池和 hashCode 緩存的安全性避免被篡改底層用 final char[] 或 byte[] 保存 和 equals 區別 比較引用地址equals 默認也是地址String 重寫后比較字符內容HashMap 為什么線程不安全并發 put 可能覆蓋數據、擴容時可能丟數據JDK 7 甚至有死循環問題volatile 有什么用保證可見性、禁止指令重排但不保證原子性適合狀態標志位synchronized 鎖升級過程無鎖 - 偏向鎖 - 輕量級鎖 - 重量級鎖鎖只能升級不能降級深拷貝和淺拷貝區別淺拷貝只復制引用深拷貝要復制對象內部所有引用對象可序列化或逐字段 newJava 中有幾種這里說的是運算符基礎類型比數值引用類型比地址為什么重寫 equals 要重寫 hashCode保證對象放進 HashSet/HashMap 時hashCode 一致才能找到對應的桶有一個經常考但很多人說不太清楚的細節String的不可變性具體表現是任何修改操作都會返回一個新的String對象原來那個對象內容不變。這不僅是設計選擇還是解決多線程共享字符串安全問題的最樸素方案。7.2 手寫冒泡排序與快速排序算法手撕里排序屬于必考項。冒泡排序是入門重點在優化——加一個 swap 標志位如果某一輪沒有任何交換說明數組已經有序直接 break。public void bubbleSort(int[] arr) { for (int i 0; i arr.length - 1; i) { boolean swapped false; for (int j 0; j arr.length - 1 - i; j) { if (arr[j] arr[j 1]) { swap(arr, j, j 1); swapped true; } } if (!swapped) break; } }快速排序則是分治思想選擇基準值把小于基準的放左邊、大于基準的放右邊然后遞歸處理左右子區間。面試時最重要的是寫對“分區”邏輯尤其是邊界條件。public void quickSort(int[] arr, int left, int right) { if (left right) return; int pivot partition(arr, left, right); quickSort(arr, left, pivot - 1); quickSort(arr, pivot 1, right); } private int partition(int[] arr, int left, int right) { int pivot arr[left]; while (left right) { while (left right arr[right] pivot) right--; arr[left] arr[right]; while (left right arr[left] pivot) left; arr[right] arr[left]; } arr[left] pivot; return left; }這里有一個高頻追問快速排序在最壞情況下比如數組已經有序時間復雜度退化為 O(n^2)為什么還會被廣泛應用答案是只要基準值選取得當期望復雜度是 O(n log n)工程實現里常用“三數取中”或者隨機選基準來避免最壞情況而且它是原地排序空間復雜度只有 O(log n)緩存友好度比歸并排序更好。能把這個邏輯講清楚比單純背代碼更有說服力。復習這一步最大的收益從來不是“背會了什么”而是“發現哪里其實不會”。我這次重新過完集合和并發這兩塊最大的感受就是很多知識在項目里已經用了無數遍但真要開口講清楚“為什么這樣設計”還是會卡殼。如果你也是在準備面試建議不要只刷題拿我這份清單當目錄每個點都試著合上電腦自己講一遍講不順的地方就是你應該再花時間的地方。最后再分享一個小技巧每天早上花十分鐘在紙上默寫一遍 HashMap 的 put 流程和 ThreadPoolExecutor 的七個參數堅持一周你上考場絕對比“看了一遍”的狀態穩得多。