ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java核心基础复习笔记:集合、并发、JVM与动态代理面试要点

Java核心基础复习笔记:集合、并发、JVM与动态代理面试要点 本来这个系列的更新节奏被我拖了很久。前两篇把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 的七个参数坚持一周你上考场绝对比“看了一遍”的状态稳得多。
返回列表