ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java进阶刷题详解:并发、集合、JVM与动态代理核心考点

Java进阶刷题详解:并发、集合、JVM与动态代理核心考点 刷题这件事很多Java开发者一开始是抵触的总觉得“刷题”就是背八股文。但如果你真在Java这条路上走到进阶阶段会发现刷题其实是最快建立知识网络的方式。我这个系列是“Java进阶刷题与详解”这一篇是第二部分会继续用“题目拆解原理深挖避坑心得”的方式把Java面试里高频出现的并发、集合、JVM、动态代理、Stream等内容掰开揉碎讲清楚。写这篇文章之前我翻了大量近期Java相关的搜索热词发现大家最关心的还是几个方向Java面试题、线程等待完成、HashMap底层、动态代理、Stream.toArray、环境变量与乱码问题等。这些题目看起来零散但背后其实是同一套Java核心知识体系。不管你是准备跳槽面试还是在校生想在学期项目里把Java学扎实这篇文章都能帮你省下不少到处找答案的时间。很多人以为刷题是简单重复其实不是。刷题是一个把零散知识串起来的过程尤其是Java这种语言知识点之间的关联特别紧密。举一个最常见的例子面试官问一句“HashMap怎么工作的”你至少要被牵扯到数组、链表、红黑树、哈希函数、扰动函数、扩容机制、线程安全、ConcurrentHashMap的锁粒度等一长串知识点。如果你没有刷过这类综合性题目你对HashMap的认知可能永远停在“哈希表能O(1)查找”这个层面直到线上出现CPU飙高、死循环或者数据丢失时才意识到自己其实并没有真正理解它。所以我把这类题叫做“钩子题”一个题目能钩出整套知识网。刷题刷的从来不是答案本身而是答案背后那棵知识树。1. 刷题只是表象真正要练的是这三件事1.1 为什么要刷Java题面试与工程能力如何相互成就先聊一句直接的话Java岗位面试考察的无非三样东西——基础功底、项目经验、工程思维。其中基础功底这块刷题是效率最高的提升方式。一个真实的项目里你可能很少会从零手写一个HashMap但你必须在并发场景下解释清楚为什么不能用HashMap做缓存或者为什么ConcurrentHashMap更适合多线程写入。这些能力靠看视频、翻文档很难建立因为视频是别人讲给你听刷题才是你自己把知识重新组装一遍。我在面试候选人的时候最怕听到的答案是“我项目里用过HashMap”然后问他put一个key的时候底层发生了什么他支支吾吾说不上来。不是说他能力不行而是他的知识没有“结构化”。刷题的本质就是强行逼你把知识点结构化题目是骨架你回答问题时需要依次调用记忆里的数组、链表、哈希、扩容这些模块每一次调用都是一次知识路径的强化。刷到一定量之后你会发现遇到新问题也能很快定位到它涉及的知识域这在做技术方案设计时非常有用。再者刷题能让你快速补齐业务开发中的盲区。比如平时写代码用的都是业务逻辑很少关心JVM堆栈、类加载、对象头这些底层细节但一旦线上出现OOM或者GC频繁就需要这些底层的知识来救命。刷题相当于提前把这些盲区照亮让你在风险发生之前就有感知能力。这个价值不是用“面试通过率”能衡量的。1.2 刷题的正确姿势别在“八股文”里迷失很多人在准备Java面试时直接去背推荐列表里的“八股文”一段一段像背课文一样流利但面试官换个问法就卡住。这是典型的“用战术上的勤奋掩盖战略上的懒惰”。八股文不是不能看而是不能只背。我自己刷题的习惯是“三遍法”第一遍不看任何资料独立回忆这个题目涉及的所有知识点能写多少写多少能画图就画图。这一遍的目的不是答对而是暴露自己的知识缺口。第二遍拿答案和资料逐条核对重点看“为什么”部分。比如题目问“为什么HashMap扩容后要重新计算位置”答案里会涉及到位运算、rehash你需要理解的不是结论而是那个数学推导。第三遍合上资料用口头复述的方式把这个题目讲一遍最好讲给另外一个人听。如果讲不清楚说明还没真正掌握。这个方法听上去慢实际上是最快的路径。因为每次复习你不是在重新学习新内容而是在修正旧认知。学习效率的差异很大程度取决于认知修正的速度。另外我强烈建议在刷题过程中动手写代码验证。很多知识你以为自己“懂了”一运行才知道完全不是那么回事。比如后面要讲到的HashMap死循环问题只有真正在JDK7环境里跑过高并发put你才能体会那种CPU直接飙满的恐惧。2. Java进阶高频考点详解五类必刷题目拆解2.1 并发编程线程等待与完成时机的细节先看一道特别经典的Java面试题“有10个线程执行任务主线程需要等待全部线程执行完成后再继续怎么做”这个问题几乎每个面Java的都遇到过但能答全的人真的不多。最基本的回答是用Thread.join()在主线程里循环对所有线程调用join。这个思路没错但有两个问题一是join必须在你已经持有所有线程引用的时候才能用二是如果某个线程执行时间特别长或者线程是在循环里动态创建的join就不好管理。所以面试官更希望听到的是用CountDownLatch或ForkJoinPool等工具类。CountDownLatch是JUC包里专门解决“一个或多个线程等待其他线程完成”问题的工具。核心用法是这样的import java.util.concurrent.CountDownLatch; public class CountDownLatchDemo { public static void main(String[] args) throws InterruptedException { int threadCount 10; CountDownLatch latch new CountDownLatch(threadCount); for (int i 0; i threadCount; i) { final int taskId i; new Thread(() - { try { // 模拟任务执行 Thread.sleep(200); System.out.println(任务 taskId 执行完成); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { // 无论正常还是异常都必须countDown latch.countDown(); } }).start(); } // 主线程阻塞等待所有任务完成 latch.await(); System.out.println(所有任务执行完毕主线程继续); } }这段代码有两点特别值得注意第一CountDownLatch的初始计数必须和线程数匹配如果有个线程没执行到countDown()主线程就会永远等在那里第二countDown()一定要放在finally块里否则一旦任务执行过程中抛异常计数不会被扣减程序直接卡死。这是实际项目里踩过无数次的坑。从思路延伸来看如果等待的任务是有返回值的CountDownLatch就不好用了这时候优先考虑CompletableFuture。CompletableFuture.allOf()可以聚合多个异步任务并在全部完成后触发回调import java.util.concurrent.CompletableFuture; public class CompletableFutureDemo { public static void main(String[] args) { CompletableFutureString task1 CompletableFuture.supplyAsync(() - { try { Thread.sleep(300); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return 任务1结果; }); CompletableFutureString task2 CompletableFuture.supplyAsync(() - { try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return 任务2结果; }); CompletableFuture.allOf(task1, task2).join(); System.out.println(task1.join() , task2.join()); } }这里有个隐藏考点CompletableFuture默认使用ForkJoinPool.commonPool()作为执行线程池线程数等于CPU核心数减一。如果你的项目里同时跑几十个supplyAsync任务又不指定线程池就可能出现线程饥饿导致任务迟迟不执行。所以进阶开发者会习惯显式传入自定义线程池例如CompletableFuture.supplyAsync(task, executor)。面试时能想到这一层说明你不是只会API而是真的理解线程模型。再看CyclicBarrier它和CountDownLatch最大的区别是CyclicBarrier是“循环使用”的多个线程互相等待到达某个屏障点后一起放行适合分阶段并行计算的场景。面试时把这几个工具对比搞清楚基本就能应付大部分并发等待类问题了。2.2 集合框架从ArrayList到HashMap的底层逻辑集合框架是Java面试的“必考大户”而且一考就是连环问。最常见的开篇题目是“ArrayList和LinkedList有什么区别”。很多人会回答“ArrayList底层是数组LinkedList底层是双向链表”但这只是表面。面试官真正想听的是数组的随机访问效率高但中间插入/删除需要移动元素适合读多写少的场景链表相反插入/删除只需要调整指针但随机访问需要遍历适合写多读少或对头尾操作频繁的场景。另外LinkedList在Java里还实现了Deque接口所以既能当队列又能当栈这是很多人在面试时容易忽略的加分点。紧接着会问“ArrayList扩容机制”。默认数组容量是10当元素个数超过容量时会扩容为原来的1.5倍也就是新容量等于旧容量加上右移一位的结果。这个过程涉及数组复制频繁扩容会导致性能和内存浪费所以如果你提前知道元素数量最好在构造ArrayList时指定初始容量。这个点平时写业务代码可能注意不到但在批量导入、报表生成等大集合场景下扩容带来的性能影响非常明显。再往里走就到了Java集合面试的顶峰问题“HashMap的put流程是什么”。这一题足够讲十分钟。简单概括先对key做哈希再通过扰动函数减少碰撞根据哈希值定位数组下标若该位置为空直接放入Node若不为空遍历链表或红黑树找相同key找到了就覆盖value找不到就追加链表长度超过8且数组长度大于等于64时链表会升级成红黑树当HashMap中元素数量超过容量乘以负载因子默认0.75时触发扩容扩容后容量翻倍元素会重新计算位置。面试官通常还会追问“为什么要用0.75作为负载因子”这个值的取舍是在空间利用率和查询效率之间做平衡。太低了浪费空间太高了哈希冲突变多链表或红黑树变深查询性能下降。0.75是官方在大量性能测试后认为比较合适的一个默认值。还有一些人喜欢问“为什么链表转红黑树的阈值选8”这跟泊松分布有关官方注释里写过在随机哈希码场景下链表长度到8的概率已经非常低属于一种兜底优化。HashMap的线程安全问题也是一个经典考点。JDK7的HashMap在并发扩容时可能产生环状链表导致get时CPU被打满这个问题在JDK8里通过头插法改尾插法修复了但并发put仍然可能丢失数据。所以在多线程场景下要么直接用ConcurrentHashMap要么用Collections.synchronizedMap做强制同步但性能上ConcurrentHashMap的分段锁或CAS锁优化要明显更好。这里我建议自己写个小Demo验证一下开几十个线程往同一个HashMap里put不同的key最后打印map.size()你会看到结果大概率比预期少这就是“纸上得来终觉浅”。2.3 JVM与内存管理会用参数更要看得懂对象JVM这块经常被视为Java面试的分水岭初级岗位问“说说JVM内存区域”中高级岗位会追着问“对象在内存里是怎么创建的”“如何排查OOM”。先做一个生活化类比如果Java程序是一家餐厅那么JVM内存就是餐厅的后厨。堆是食材仓库放着所有要处理的食材对象虚拟机栈是灶台区域每个正在做菜的方法都有自己的操作台栈帧方法区/元空间是菜谱墙存放类信息和常量程序计数器则是厨师手里的订单条记着当前做到哪一步了。这样一理解后面那些抽象概念就顺很多。一个Java对象从new出来到被回收标准流程大致是类加载检查确认这个类已经被加载分配内存在堆里划出一块空间分配方式有指针碰撞和空闲列表内存空间初始化为零值设置对象头里面包含哈希码、GC分代年龄、锁状态标志等信息最后执行init方法也就是构造器真正把对象初始化成我们想要的状态。面试时如果能把这五步按顺序讲出来再补充说明“对象头在64位JVM里通常占12字节包含Mark Word和Klass Pointer”面试官就会觉得你基础很扎实。再往下是垃圾收集。Minor GC发生在新生代Major GC发生在老年代Full GC则是整个堆的回收。年轻代对象存活时间短适合用复制算法老年代对象存活时间长一般用标记整理或标记清除。JVM默认的垃圾收集器在不同的JDK版本里也不一样比如JDK8默认的Parallel Scavenge加Parallel OldJDK11之后默认G1JDK17里ZGC也越来越常出现。面试如果被问到“怎么选垃圾收集器”可以从应用场景、停顿时间目标、吞吐量需求几个维度回答而不是死记参数。排查OOM的实操能力在面试中非常加分的。一般路径是先看日志里的异常类型是Java heap space还是Metaspace溢出再用jmap或jstat查看内存占用接着导出dump文件用MAT或VisualVM分析对象占用找出是哪些对象把堆占满了。我这里给一个简易的排查模板表格方便你以后直接套用异常类型可能原因初步排查方向java.lang.OutOfMemoryError: Java heap space堆内存不足检查-Xmx设置是否合理分析是否有内存泄漏java.lang.OutOfMemoryError: Metaspace类元数据过多检查动态类生成、反射使用场景考虑增加-XX:MaxMetaspaceSizejava.lang.OutOfMemoryError: GC overhead limit exceededGC回收效率极低先dump堆找出占用大头对象JVM这块不是背几个参数就能搞定的最有效的方式是本地起一个小服务用jmap、jconsole、VisualVM多爆几次内存亲眼看看OOM长什么样下次遇到线上问题时不慌。2.4 动态代理与反射框架设计背后的基石先看一道常见面试题“说说JDK动态代理和CGLIB动态代理的区别”。这题表面问代理背后其实问的是你对Spring AOP、MyBatis等主流框架底层原理的理解。JDK动态代理基于接口核心是java.lang.reflect.Proxy和InvocationHandlerCGLIB基于继承通过生成目标类的子类来实现代理所以不需要接口。简单写一个JDK动态代理的Demo。假设我们有一个任务接口和实现类public interface TaskService { void execute(String taskName); } public class TaskServiceImpl implements TaskService { Override public void execute(String taskName) { System.out.println(执行任务: taskName); } }再写一个日志增强的InvocationHandlerimport java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; public class LogProxy implements InvocationHandler { private final Object target; public LogProxy(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(调用方法前: method.getName()); Object result method.invoke(target, args); System.out.println(调用方法后: method.getName()); return result; } public static Object wrap(Object target) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new LogProxy(target) ); } }使用的时候把TaskService实现类包装成代理public class ProxyDemo { public static void main(String[] args) { TaskService taskService new TaskServiceImpl(); TaskService proxy (TaskService) LogProxy.wrap(taskService); proxy.execute(数据清洗); } }跑起来后你会发现在不修改TaskServiceImpl源码的前提下方法的执行前后被插入了日志逻辑。这就是AOP里最经典的“横切逻辑”实现思路。Spring AOP默认情况下如果目标类实现了接口就用JDK动态代理如果没有实现接口就要用CGLIB改用字节码生成的方式来生成代理子类。很多人只知道JDK动态代理“必须要有接口”但不知道为什么。原因很简单Proxy.newProxyInstance()生成的代理类本身是Proxy的子类Java是单继承的所以代理类只能通过实现接口来扩展行为。如果想代理没有接口的类只能依靠CGLIB这种通过ASM生成子类的方案。补充一个小坑CGLIB代理final方法是无法被覆盖的被代理的类也不能是final类这就是为什么Spring官方文档里会建议不要对final类使用代理。谈到这里可以顺带提一下“为什么MyBatis的Mapper接口能直接注入使用”。因为MyBatis在启动时会扫描到Mapper接口然后通过动态代理生成一个实现接口的代理对象当你调用selectById这类方法时代理对象会在invoke方法里根据方法名和参数动态拼接SQL并执行。理解了动态代理你会发现很多框架的“魔法”都没有那么神秘。2.5 函数式编程与Stream代码优雅了性能掉了吗Java 8引入的Stream是进阶开发者必须熟练使用的工具。面试题里比较常见的是“List.stream().toArray()和普通for循环哪个快”“parallelStream一定更快吗”。答案是性能取决于数据量和操作复杂度没有绝对的谁快谁慢。先看一个典型代码import java.util.ArrayList; import java.util.Arrays; import java.util.List; public class StreamDemo { public static void main(String[] args) { ListString names Arrays.asList(Java, Python, Go, Rust, C); // 使用Stream过滤并以数组形式收集 String[] resultArray names.stream() .filter(name - name.length() 2) .map(String::toUpperCase) .toArray(String[]::new); System.out.println(Arrays.toString(resultArray)); } }这里的关键在于toArray(String[]::new)它指定了生成数组的类型。很多人会用.collect(Collectors.toList()).toArray(new String[0])虽然也能实现功能但会多一次列表创建和元素拷贝性能上不如直接toArray代码也更啰嗦。再谈parallelStream。并行Stream确实利用了ForkJoinPool的多线程能力在小数据量场景下线程创建和任务分配的开销反而比单线程更大。我实测过对10万元素做简单的filtermap操作parallelStream可能比串行慢2到3倍对百万级元素才可能体现出优势。所以不要一上来就无脑用并行流要结合任务量、IO密集还是CPU密集来判断。Stream使用中还有一个经典坑不要在Stream的forEach或map里修改外部可变状态。比如在forEach里向一个ArrayList添加元素在多线程并行流下会出现线程安全问题。正确做法是使用Collectors.toList()这类终端收集器或者提前把结果设计成不可变对象。用一句易懂的话说Stream是“流水线”你要的是流水线上出产的结果而不是在流水线中间伸手去动零件。顺带科普一个容易被忽略的Java知识点枚举类型。面试时如果只把枚举当常量类用就太亏了。枚举其实可以提供行为比如抽象方法public enum Operation { ADD { Override public double apply(double x, double y) { return x y; } }, SUBTRACT { Override public double apply(double x, double y) { return x - y; } }; public abstract double apply(double x, double y); }使用枚举将业务行为和固定类型绑定比用if-else判断一个字符串要优雅得多也天然线程安全。你在刷题时多想想“除了这样写还有什么写法”就是在锻炼重构能力这对Java进阶帮助很大。3. 刷题实战从“看题”到“写对”的完整流程3.1 一题多解以排序算法为例排序算法是Java笔试里很经典的“手感题”很多面试官会让你现场手写一个冒泡排序或者快速排序。冒泡排序人人会写但能写出优化版本的并不多。最基础写法是双层循环挨个比较交换。优化点是加一个标志位如果某一轮循环没有发生任何交换说明序列已经有序可以提前退出public static void bubbleSort(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { boolean swapped false; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped true; } } if (!swapped) { break; } } }这个很小的改动就可以把接近有序序列的时间复杂度从O(n^2)优化到O(n)。面试时能写出这一步说明你不是只会背模板真的理解算法的时间复杂度与序列初始状态有关。再进一步手写快速排序时要注意递归边界和基准值选择。如果基准值选得不好快排会退化到O(n^2)。很多标准答案都是取最右元素但面试时可以主动说“我可以随机选择基准值来避免最坏情况”这就是加分点。归并排序则重点考察对“分治”的理解以及合并两个有序数组的写法。刷题时不要只追求“写出来”要养成“分析复杂度边界条件”的习惯这种思维习惯放在Java业务代码里同样受用。3.2 用一个面试题走完整个流程说说HashMap我来演示一套完整的“拆题”流程就用“说说HashMap”这个题目。准备面试时我不会直接背答案而是先画一张知识地图HashMap涉及的核心概念有哈希表、哈希函数、碰撞处理、扩容机制、红黑树、线程安全每个概念往下再分。比如碰撞处理可以拆成“链地址法”“开放地址法”Java用的是链地址法。然后我会写几行关键代码来验证记忆。比如手动实现一个简化版的HashMap put逻辑不需要真的那么复杂但至少要把哈希定位、数组链表结构、扩容思路写出来。这比单纯看源码印象深十倍。第三步是口头练习。我会对着镜子或者在房间里独自讲一遍从“HashMap底层是数组加链表”开始讲到“当链表长度超过8且数组长度大于等于64时转红黑树”再讲到“扩容时元素重新散列”最后点出“HashMap线程不安全并发场景用ConcurrentHashMap”。讲的过程中如果卡住了就说明那个知识点还不熟赶紧回去翻源码。最后一步是沉淀笔记。我通常用四栏笔记法来记题目名称、核心原理、代码示例、我的易错点。比如HashMap的“我的易错点”会写链表转红黑树的阈值是8但还有一个隐性条件是数组长度大于等于64很多人会漏掉这个条件负载因子默认值是0.75不是0.75fHashMap允许null key和null valueHashtable不允许。这些细节如果没有记录过两周再看题还是会忘。3.3 刷题笔记怎么写才有用刷题笔记不是抄题更不是往收藏夹里堆文章。我见过太多人Markdown文件存了一堆真正需要时根本找不到。我会把笔记分成四类区分标准是“这个知识点以后在什么场景下用”。第一类是错题本记录那些“以为自己会了一做就错”的题目。比如这道题“以下代码能否正常运行ListInteger list new ArrayList(); list.add(1); list.add(2); list.remove(1); System.out.println(list);”很多初学者以为remove(1)删的是数字1实际上ArrayList的remove(int index)执行的是按索引删除list里剩下的是数字1。这种题目就是典型的易错题我会记下来并在旁边标注“读源码时要看方法签名不能想当然”。第二类是原理图偏向记忆型知识点。比如JVM内存布局、HashMap数据结构的演进过程、线程状态转换流程等。这类内容用文字写一大段反而不利于回忆我习惯手绘或用表格整理复习时只看图。第三类是代码模板专治手写题。比如手写单例模式、手写线程安全的懒加载、手写一个简单的LRU缓存。代码模板的价值在于考试时能尽快进入状态不用现场推演。第四类是面试话术专治表达问题。同一个知识点写出来和说出来的感觉完全不一样。我会把“红黑树到底好在哪”整理成一句口头禅红黑树是一种自平衡二叉查找树能在O(log n)时间内完成查找比链表O(n)快得多适合解决哈希碰撞极端场景。背熟这些话术面试现场就不容易卡壳。4. 刷题路上的常见坑与排查技巧4.1 环境问题JDK版本与编译输出乱码刷题过程中遇到的大坑往往不是题目本身而是环境。尤其是新手刚配好JDK写第一段代码就遇到乱码或者版本不兼容非常打击信心。这里把最高频的三个环境问题统一说一下。第一个是vscode运行Java报错乱码。这个问题多半是编码不一致造成的。Windows系统默认的编码可能是GBK但你的Java代码保存成了UTF-8编译时javac如果没有指定编码参数就会把UTF-8文件按GBK解码中文变成乱码。解决办法是在settings.json里把文件编码配置成UTF-8并且编译时加上-encoding UTF-8参数。如果你用Maven或Gradle也需要在pom.xml或build.gradle里配置项目编码。第二个是JDK版本不匹配。Java 8编译的class文件在Java 17环境里通常可以运行但反过来Java 17编译的class文件在Java 8环境里会报UnsupportedClassVersionError。如果你本地装了多个JDK非常容易出现这种情况。解决方案是检查java -version、javac -version是否一致在IDEA里检查Project Structure的SDK设置再确认没有环境变量干扰。给一个通用排查表现象可能原因解决思路java命令找不到JAVA_HOME未配置或配置错误检查环境变量JAVA_HOME和PATHUnsupportedClassVersionError编译版本高于运行版本统一JDK版本或降低编译targetjavac乱码编码不统一javac -encoding UTF-8文件保存为UTF-8idea、vscode里运行正常命令行报错命令行用了不同JDK确认命令行里的java路径来自哪个JDK第三个是环境变量配置。JAVA_HOME指向JDK安装根目录PATH里需要包含%JAVA_HOME%\bin这样命令行才能识别java和javac。有些教程让你把多个JDK的bin目录都写进PATH这种做法容易引起版本混乱。我建议只保留一份JDK路径切换版本时直接改JAVA_HOME即可。4.2 代码细节坑equals/hashCode、空指针、自动拆箱刷题阶段最容易忽略的是那些“看着简单、背地里全是坑”的基础细节。先说equals和hashCode。面试题常问“为什么重写equals必须重写hashCode”标准解释是如果两个对象equals相等它们的hashCode必须相等反过来hashCode相等不代表equals相等。如果不重写hashCode你往HashSet或HashMap里放入两个“逻辑相同”的对象时它们会被当成两个key直接破坏集合的不重复语义。实践中更要命的是重写equals时如果只比较了部分字段而重写hashCode时又用了全部字段那么两个equals相等的对象hashCode可能不同集合就会出诡异问题。所以最佳实践是equals和hashCode要基于同一组关键字段来计算。再说空指针Java开发的头号杀手。常见的空指针场景包括自动拆箱导致NPE、集合里的null元素、链式调用时中间某个方法返回null、数组越界后访问对象字段。比如下面这段代码非常容易踩坑MapString, Integer map new HashMap(); Integer count map.get(key); int total count 1; // 这里会NPE因为count是null自动拆箱时抛异常这段代码报错的根本原因不是map.get返回null而是null赋值给Integer没问题但参与int运算时自动拆箱触发了空指针。解决思路是避免让null参与数值计算可以用Optional或者提前判空。还有泛型擦除的问题。Java泛型是类型擦除的运行时你拿不到List 里的String类型。这个知识点经常在“如何通过反射往List 里塞字符串”这种题里考。如果你看到面试官问这个他其实是想考察你是否理解泛型是编译期约束而不是运行时约束。理解了这一点你在写反射工具类时就不会假设泛型参数在运行时还保留。4.3 背题与理解的边界刷题刷到一定量会产生一种幻觉觉得“这个题目我熟”。但真正到了项目里一多线程就乱、一调优就懵这就是典型的“背题与理解之间缺了一座桥”。这座桥就是实践。我建议每一个经典题都对应写一个小Demo跑一遍。不是光看答案而是要亲手制造问题再亲手解决它。比如验证HashMap线程不安全可以开几十个线程同时put不同的key最后看size是否等于预期。验证CountDownLatch卡死可以故意让一个子线程抛出异常而不countDown主线程await后永远不返回。验证Stream并行流慢可以对小数据集分别跑串行和并行统计耗时差异。这些实验成本很低但效果惊人一旦你在某个问题里真正“栽过跟头”下次面试提起这个问题时你讲出来的就不是课本答案而是带着现场感的经验。背题本身没有错错的是把它当成终点。真正的Java进阶是背下来的框架知识最终都能在你写的代码里找到呼应。这个呼应建立得越深你面对未知问题时越从容。5. 进阶路线与资源推荐5.1 从基础到源码的阅读路径我把Java进阶的学习路径分成五个阶段刷题可以穿插在每个阶段里做验证。第一阶段是JavaSE基础包括语法、集合、异常、IO等这个阶段刷题重点看基础语法题和集合框架题。第二阶段是JUC并发编程重点学习线程池、锁、并发容器刷题重点是线程安全与工具类。第三阶段是JVM包括内存模型、类加载、垃圾回收刷题重点是内存和OOM问题。第四阶段是主流框架Spring、Spring Boot、MyBatis等刷题重点是IoC、AOP、自动装配、代理原理。第五阶段是中间件与分布式Redis、消息队列、分库分表等刷题重点是使用场景和原理。现在网上有很多免费优质的Java学习路线图像“狂神说”等博主做的系列教程也覆盖到了Redis、Spring等常用技术。我看这些视频时有个习惯视频里讲到的知识点看完必须自己动手敲一遍再配合题目做检测。很多人喜欢“刷完视频”的感觉但真相是看视频只是输入刷题和写代码才是输出没有输出的学习等于白学。源码阅读在这个阶段很关键。如果你有余力我强烈建议读一下这几个类的源码ArrayList、LinkedList、HashMap、ConcurrentHashMap、ThreadPoolExecutor。读源码不是逐行背书而是带着问题去读。比如读HashMap源码时你就带着“为什么负载因子是0.75”“为什么链表长度到8才转红黑树”“为什么扩容后元素位置要么不变要么加旧容量”这三个问题去读。读完后你会有一种“原来如此”的通透感。5.2 把刷过的题用进项目实践刷题与项目实践并不矛盾反而是最好的互补。我见过不少开发者刷了很多题但代码里还是在用if-else堆业务完全没有用到进阶知识。我觉得进阶学习一定要给自己布置“改造任务”。比如你现在的项目里有三个接口需要串行调用可以把它们改成CompletableFuture并行调用再对比一下响应时间。项目里有一个状态字段用字符串表示的可以改成枚举看看代码可读性提升多少。报表模块里大量使用for循环拼接数据可以考虑用Stream重构并留意性能变化。还可以给自己设定一些小工具类项目比如“自动生成数据库设计文档”。这个项目看上去很偏门但做起来几乎能把Java进阶知识全部串一遍用JDBC读取表结构用集合存储元数据用Stream或循环解析字段用模板引擎生成Markdown文档再配合反射或注解完成一些扩展功能。做完这样一个小项目你会发现自己对集合、IO、反射、设计模式的理解都上了一个台阶。我也推荐大家在GitHub上找一些高质量的开源项目不用太复杂主要观察别人是怎么组织包结构、怎么处理异常、怎么写单元测试的。刷题给的是“点”项目实践是把“点”连成“线”再到“面”。这个过程急不来但只要坚持Java进阶的速度会远超你的预期。这块内容前前后后我攒了不少经验最想跟大家说的一句话是刷题不要只追求数量而要追求“每做一题都长出新知识”。我自己的习惯是每周集中刷两到三次每次刷完用表格记录题目涉及的知识点、我的错误点、源码中对应的类和方法。时间久了这个表格本身就是一本定制的Java进阶手册。希望这篇“Java进阶刷题与详解-2”能在你准备面试或系统提升Java能力的路上帮上忙。下一阶段我打算继续整理那些高错误率题目的源码级解读如果你有特别想问的题也可以自己先按“三遍法”过一遍再来对照思路效果会好很多。
返回列表