Java 八股文查漏补缺系列写到第 26 期我越来越确定一件事大家背得最熟的东西往往最经不起追问。比如问你 String 到底创建了几个对象、HashMap 为什么要用红黑树、线程怎么才能等全部结束后再继续、Redis 的 increment() 为什么会报 not integer or out of range单拎出来都见过可组合到一起很多工作三五年的朋友也会卡壳。这篇笔记不做入门教学而是把最近高频出现的考点按基础语法、集合框架、排序算法、动态代理与 Lambda、线程协作、Redis 原子操作、JDK 环境异常七个方向串一遍。适合准备 Java 面试的初级和中级工程师也适合日常写代码时做对照自查。1. 字符串与基础语法送分题里的连环坑1.1 多行字符串从“”拼接到 Text Block在 Java 15 以前写多行字符串是一件痛苦的事每行末尾要手动加 \n或者用 号逐行拼接还要小心双引号转义。网上流传的各类“字符串多行写法”帖子其实是各种 DIY 方案直到 Java 15 正式推出文本块这个问题才被语言层面解决。文本块用三引号包起来里面的换行、缩进、引号都可以按原文保留写 JSON、SQL、HTML 非常舒服。String json { name: hello, list: [java, spring] } ;面试里关于文本块的追问通常有两个方向。第一个前导空白怎么算编译器会根据文本块内容中最少的前导空白数统一去除缩进所以你在 IDE 里把代码缩进成几层生成的字符串不会包含这些多余的缩进。第二个三引号里还能不能正常写包含引号的内容能文本块的设计目标就是嵌入 SQL、HTML、JSON不需要像老写法那样疯狂转义。老实说我现在看到项目里还有人用 号拼接大段 SQL改起来非常痛苦换文本块之后肉眼可读性高很多也省了纠结 \n 和转义符的时间。1.2 、equals 和 Integer 缓存输出题想考察什么这一节几乎每次面试都会出现。先看一个经典输出题Integer a 100, b 100; Integer c 200, d 200; System.out.println(a b); // true System.out.println(c d); // false为什么因为 Integer 内部有一个 IntegerCache默认缓存 -128~127 之间的对象。在这个范围内valueOf 会直接返回缓存对象超出范围就会 new 新对象。而 比较的是对象引用两个对象值相同但引用不同结果自然是 false。要把这种题做对核心不是背 true/false而是理解自动装箱调用的是 Integer.valueOf()。同样的例子还有 Short、Long、Byte它们都有缓存Boolean 只有 true/false 两个对象Float、Double 没有缓存。面试官看到你答出了缓存还会继续延伸到 String 的 和 equals。equals 看的是值 看的是引用。但如果两个字符串都指向常量池里的同一个对象 也会是 true。这里真正想考的还是“对象引用 vs 内容相等”以及字符串常量池的机制。1.3 new String(hello) 到底创建了几个对象这是最经典的字符串八股。直接说结论如果常量池里没有“hello”那么这一步会创建两个对象——new 出来的堆对象以及常量池里的字面量对象如果常量池已经有“hello”那只创建一个堆对象。面试官往往还会接着问 intern() 是干什么的。JDK 7 之前 intern 会把字符串复制到永久代JDK 7 之后字符串常量池移到了堆里intern 如果发现字符串不在常量池里有机会直接加入引用而不是复制。这里有一个容易被忽略的细节代码里写new String(hello)时字面量“hello”其实早在类加载阶段就已经被放入常量池了并不是在 new 那一刻才创建。所以更严谨的说法是如果这个类的字面量之前从未出现过类加载时会先创建常量池对象然后 new 再创建一个对象。我见过不少人在这个点上给出模棱两可的答案建议面试前自己写几行代码验证一下引用关系把“背结论”变成“理解过程”。2. 集合框架HashMap 与 ArrayList 的底层逻辑2.1 HashMap 为什么是“数组链表红黑树”一句话回答数组实现 O(1) 定位桶链表解决哈希冲突红黑树防止冲突严重时查询退化成 O(n)。但这不够面试官会追问为什么是红黑树而不是 AVL 平衡二叉树。红黑树是一种近似平衡的二叉查找树插入、删除、查找都是 O(log n)比起严格平衡的 AVL 树调整次数更少工程实现更省性能所以 JDK 选择了红黑树。链表什么时候转树链表长度达到 8且桶数组长度达到 64否则会先扩容而不是树化。树化阈值 8 不是随便拍的源码注释里给出了泊松分布推导在负载因子 0.75 且哈希分布均匀的情况下链表长度达到 8 的概率已经接近千万分之六普通业务场景几乎不会出现。出现超过 8 的情况基本可以认定是有人恶意构造了同哈希值的 key或者哈希函数质量太差这时候用红黑树保证最坏情况下系统不崩。被问到“为什么树化阈值是 8”能答出这层原因比背结论要加分得多。2.2 负载因子 0.75 和 2 倍扩容背后的取舍HashMap 默认负载因子是 0.75。所谓负载因子就是“桶里已经装了多少比例时触发扩容”。0.75 是工程上时间和空间的折中太低比如 0.5能减少哈希冲突但内存浪费严重太高比如 1省内存但冲突增加、查找变慢。扩容为什么是 2 倍因为 HashMap 拿到 key 的 hash 后用(n - 1) hash计算桶下标n 保持 2 的幂才能让高位也参与计算并且扩容后元素要么留在原位置要么跑到“原位置 oldCap”。JDK 1.8 里的优化就是利用这个特性按节点 hash 与 oldCap 的与运算结果把链表拆成 lo 和 hi 两条省去重新计算 hashCode 的开销。这个点经常被拿来区分“背过底层”和“真正懂底层”。顺带提醒HashMap 在多线程下扩容旧的头插法会造成死循环1.8 改为尾插之后避免了这个死循环但并发 put 仍然会有脏数据多线程场景请一律使用 ConcurrentHashMap。2.3 ArrayList 扩容、fail-fast 与 Arrays.asList 陷阱ArrayList 默认容量是 10扩容时每次增长为原来的 1.5 倍。为什么是 1.5 而不是 2这是 Java 官方在“均摊复杂度”和“空间利用”之间平衡出来的策略每次扩容都要数组拷贝扩太少会导致频繁拷贝扩太大又浪费内存。面试常考的手写模拟扩容核心就是oldCapacity (oldCapacity 1)。说到 fail-fast它并不是集合为了并发安全做的强一致保证而是通过 modCount 字段快速暴露并发修改问题。迭代器在 next 的时候会检查 modCount 是否变化变了就抛 ConcurrentModificationException。典型踩坑迭代集合时直接用 list.remove() 删除元素应该使用 Iterator.remove()。另一个高频坑是Arrays.asList()返回的集合底层是一个定长数组不能调用 add 和 remove一调就是 UnsupportedOperationException。它虽然是 Arrays 内部类看着像 ArrayList本质上还是数组包装。如果有人把这个集合传给后续需要增删的逻辑代码直接崩。看清这一点排查问题时能省很多时间。3. 手撕排序快速排序与冒泡排序的高频追问3.1 冒泡排序从入门到优化冒泡排序在面试中更多是热身题但写得好不好能看出基础扎不扎实。最无脑的写法是双重循环相邻元素比较交换每轮把当前最大值冒到最后。一个常见优化是如果某一轮没有任何交换说明数组已经有序可以提前终止。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; } }面试官经常会追加一句最优时间复杂度是多少答案是 O(n)对应已经有序的数组因为第一轮跑完 swapped 始终为 false循环提前终止平均和最坏是 O(n^2)。虽然冒泡排序实际项目很少用但它是理解“稳定排序”的入门例子相邻元素比较交换相等元素不会发生相对位置变化所以是稳定排序。如果面试要求一个稳定又简单的手写方案冒泡永远是最稳妥的兜底。3.2 快速排序partition 的正确写法快速排序是“手撕算法”环节的必考题。思路一句话选定基准把数组分成小于等于基准和大于基准的两部分再递归。最容易写对的是这种 Lomuto 分区int partition(int[] arr, int low, int high) { int pivot arr[high]; int i low; for (int j low; j high; j) { if (arr[j] pivot) { swap(arr, i, j); i; } } swap(arr, i, high); return i; }Lomuto 分区优点是逻辑简单不容易写错缺点是在处理大量重复元素的数组时效率相对差一点。如果面试官要求优化可以说随机选基准、三数取中避免最坏 O(n^2)或者用双指针的 Hoare 分区减少不必要的交换次数。被追问快排为什么平均 O(n log n) 时可以这样解释每次 partition 都能把数组分成接近一半的两部分递归深度是 log n每一层都要扫描一遍 n 个元素所以总耗时 O(n log n)最坏情况每次只把基准放到最终位置递归深度变成 n性能就退化成 O(n^2)。3.3 排序现场常见的三个追加问题手撕完排序面试官总爱追加几个问题提前准备能省不少时间。第一个快排是不是稳定排序不是。partition 过程会发生远距离交换相同元素的相对顺序可能被打乱所以快排不稳定。想要稳定的 O(n log n) 排序一般回答归并排序。第二个Arrays.sort() 底层用了什么基本类型用的是 DualPivotQuicksort也就是双轴快排对象类型用的是 TimSort这是归并和插入排序的混合版本因为对象排序要求稳定。第三个实际项目里怎么选排序数据量小且基本有序插入排序、冒泡都很合适数据量大用快排或归并对稳定性有要求就选归并。把这三个问题答好手撕环节基本不会冷场。4. 动态代理与 Lambda从“会用”到“能讲清”4.1 JDK 动态代理 vs CGLIB面试官为什么总问接口动态代理是 Spring AOP、MyBatis Mapper、RPC 框架的基石所以八股必问。JDK 动态代理只能代理接口因为它在运行时生成一个实现了指定接口的代理类所有方法调用统一交给 InvocationHandler 的 invoke 处理CGLIB 走的是生成被代理类子类的路线通过继承和重写方法实现代理所以不要求接口但 final 类、final 方法、private 方法都没法代理。Spring 的默认策略早期版本是“有接口用 JDK 代理无接口才用 CGLIB”但 Spring Boot 2.x 之后默认已经改为强制 CGLIB也就是 spring.aop.proxy-target-classtrue。这个点经常有同学答成旧版本建议看一下自己项目的配置。还有一个容易忽略的坑代理对象在内部调用同类里的另一个方法时第二个方法不会走代理因为内部调用是 this.method()不是通过代理对象调用。要让 Transactional、Async 这类注解生效只能注入代理对象自身再调用或者把方法拆到另一个 Bean 里。这个细节在实际调试 AOP 失效时特别有用我在项目里排查过一次事务不生效的问题最后就是自调用导致的。4.2 Lambda 不是匿名内部类那么简单很多人以为 Lambda 就是匿名内部类的语法糖这个理解在大多数情况下没毛病但在面试中被追问 JVM 层面时就站不住了。Lambda 使用的是 invokedynamic 指令编译时不会为每个 Lambda 表达式生成一个独立的 class 文件而是在运行时通过 LambdaMetafactory 生成函数式接口的实现匿名内部类编译后会生成 xxx$1.class每次 new 都会加载类。所以 Lambda 更轻量第一次调用时有一次引导方法开销后续调用基本就是普通方法调用。另外Lambda 能捕获的局部变量必须满足 effectively final也就是变量在初始化后没被修改过。为什么因为被捕获的变量会变成入参传入如果允许修改容易出现“改的不是原变量”的语义混乱。下面这段代码是编译不过的int count 0; list.forEach(x - count); // 编译报错要计数可以用 AtomicInteger 或先收集再统计。但这里有一个很多人理解错的地方AtomicInteger 只是让这段代码能编译通过、保证单个自增的原子性它并不能保证整个“读取-累加-使用”流程的线程安全。如果是多线程并发累加还要考虑 LongAdder 或加锁。这个点面试官问到并发时经常顺带挖坑。4.3 方法引用与 Stream 的几个顺手坑方法引用本质是 Lambda 更简短的写法面试常问 Class::method 和 obj::method 的区别前者可以直接引用静态方法也可以当成“给对象调实例方法”的入口比如 String::length 等价于 s - s.length()。方法引用的好处是让意图更直接被问到底层时它仍然会创建一个函数式接口实例和 Lambda 属于同一个体系。Stream 这块我踩过的坑第一个是惰性求值中间操作如 filter、map 不会立即执行只有终止操作如 collect、forEach 出现后才会真正开始跑。很多人写了一大串 filter、map以为数据已经过滤完了结果半天没反应就是漏了终止操作。第二个坑是并行流 parallelStream 默认用公共线程池 ForkJoinPool.commonPool开发机上跑可能没事真实项目中多个并行流同时执行或者与框架共用线程池很容易出现线程饥饿。生产环境建议自己传入线程池CompletableFuture 同理。另外并行流内部如果操作的是共享的 HashMap 或 ArrayList还会引发数据竞争和并发修改异常这也是为什么我建议对并行流保持谨慎。面试时能主动提这一句比干背 API 更能体现项目经验。5. 线程协作等所有线程都完成再继续的四种写法5.1 join()最朴素但容易忘的两件事多个线程都执行完了主线程再继续最原始的实现是 Thread.join()。join 的语义就是当前线程等待目标线程终止。我遇到过不少人面试时只记得函数名忘了它底层是 wait/notify 机制join() 源码里会while (isAlive()) wait(0)目标线程死亡后由 JVM 的 notifyAll 唤醒。这里要注意调用 wait 意味着当前线程会释放目标线程对象上的监视器锁并不是无条件阻塞。用 join 做聚合并拿子线程计算结果需要自己设计共享容器交接数据代码容易绕所以现在工程上很少直接用 join 聚合多个任务。但在面试里它是一道很好的引导题能从它自然引出更高级的协作工具。5.2 CountDownLatch 与 CyclicBarrier一张表讲清区别CountDownLatch 和 CyclicBarrier 是线程协作题的常客。我直接把区别拉个表对比点CountDownLatchCyclicBarrier核心语义一个或多个线程等待其他线程完成各自工作一组线程互相等待全部到达后才一起继续计数方向倒计数countDown() 每调用一次减 1正计数等待线程数量达到 parties是否可以复用不能计数归零后失效可以重置后继续下一轮使用场景主线程等所有子任务完成后汇总多线程并行处理并把结果合并一轮轮栅栏同步CountDownLatch 的真实使用场景很常见一个线程池派发 10 个任务每个任务在 finally 块里执行 countDown()主线程用 latch.await() 等所有任务结束。这里有个强烈建议countDown() 一定要放 finally否则只要有一个任务抛异常主线程就永远等下去轻则流程卡死重则把连接池占满。CyclicBarrier 的场景更像跑步比赛所有选手到齐才能开枪而且还可以在 barrier 上设置一个到达后的聚合动作。另外Semaphore 控制同时访问的线程数本质是限流器可以结合后面 Redis 限流的思路一起理解。5.3 CompletableFuture.allOf写起来最爽坑也最隐蔽现代 Java 项目里多线程聚合我推荐 CompletableFuture。想等多个任务全部完成再继续用 allOfCompletableFutureString f1 CompletableFuture.supplyAsync(() - callServiceA()); CompletableFutureString f2 CompletableFuture.supplyAsync(() - callServiceB()); CompletableFuture.allOf(f1, f2).join();allOf 返回 CompletableFuture 只表示所有任务完成不关心返回值。要拿结果可以再接 thenApply或者等 join 后单独 get f1、f2。我真实踩过的坑有三个。第一个坑allOf 只是把任务组织起来不会自动等待如果不调用 join 或 get主流程可能很快就跑完了任务结果根本没拿到。第二个坑默认线程池是 ForkJoinPool.commonPool线程数默认是 CPU 核数减 1如果任务是阻塞型 I/O很容易把所有线程占满互相等死。正确做法是统一传入自定义线程池ExecutorService pool new ThreadPoolExecutor(8, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(100), new ThreadPoolExecutor.CallerRunsPolicy()); CompletableFuture.supplyAsync(() - doSomething(), pool);第三个坑是异常处理。allOf().join() 里如果任何一个任务抛异常join 直接抛 CompletionException没 catch 的话整个调用方跟着崩。通常我会在每个 future 后面加 exceptionally 或 whenComplete 做兜底保证单个任务失败不影响整体流程。能把这三个坑讲清楚说明你确实在项目里写过并发聚合而不是只背了 API。6. Redis 的 increment() 与库存减一原子操作实战6.1 RedisTemplate.increment() 报错排查实录“使用 RedisTemplate 的 increment() 报错不是 integer or out of range”是一个很真实的场景。报错底层一般长这样ERR value is not an integer or out of range。原因几乎都是key 对应的 value 已经不是合法整数或者超出了 64 位有符号整数范围。Redis 的 INCR 命令只能对整数字符串做自增不能对 abc、1.5 使用注意 INCR 也不支持浮点浮点要使用 INCRBYFLOAT。实际开发里我用 RedisTemplate 时习惯统一用 StringRedisTemplate或者给 RedisTemplate 设置 StringRedisSerializer。原因很简单默认的 JdkSerializationRedisSerializer 会把 key 存成带序列化表头的二进制排查问题时看到的一堆 \xac\xed\x00 开头特别费劲。increment 的正确写法Long val stringRedisTemplate.opsForValue().increment(stock:sku:10001, 1);increment 第二个参数可以是负数返回的是自增后的新值。这里还有一个容易忽略的细节increment 是原子操作Redis 单线程执行一条命令不会存在竞争但如果你先 get 再 set就不是原子的并发下一定会出问题。这也是为什么扣库存推荐用 increment(-1)。6.2 扣库存为什么要用原子自减再讲一个最经典的超卖问题。假如库存还有 1 件两个请求同时 get 到库存都是 1然后都执行 set 成 0最后卖出 2 件。解决思路很简单把“判断库存 0”和“扣减库存”合并成一条原子命令。increment(key, -1) 就能一步完成扣减它返回扣减后的剩余值我们再判断剩余值是否小于 0如果小于 0说明扣超了要反向补回来。但这里有一个细节判断剩余 0 并回补是两个命令中间依然存在并发窗口。严谨的做法是用 Lua 脚本把“判断库存 0 再扣减”包成一个原子操作。下面这段脚本可以放进 DefaultRedisScript 里执行local stock tonumber(redis.call(get, KEYS[1])); if stock nil or stock 0 then return -1; end return redis.call(decr, KEYS[1]);这个脚本逻辑是库存为空或已扣完直接返回 -1否则执行 decr。因为整个脚本在 Redis 中是原子执行的不会插入其他客户端命令所以并发扣减不会超卖。Lua 脚本虽然能解决这条命令的原子性但要注意 KEYS 和 ARGV 的传参规范不要把 key 写死在脚本字符串里否则集群模式下会报 key 不在同一个 slot 的错误。6.3 从 increment 展开的三个面试追问第一个追问increment 能用来做分布式 ID 吗可以但要注意 ID 会带上步长间隙和容量上限如果量极大要结合时间戳或分段方案另外 Redis 重启后持久化策略也影响 ID 的连续性。第二个追问基于 INCR 怎么实现接口限流常见方案是固定窗口每次请求对同一个 key 做 INCR第一次请求时给 key 设置过期时间随后判断返回值是否超过阈值。这就能当一个最简单的固定窗口限流器。第三个追问为什么 Redis 的命令是原子性的因为 Redis 是单线程处理命令每个命令在事件循环里被完整执行执行期间不会有其他命令插入。这个“单线程 事件循环”模型也是后续理解分布式锁、Lua、缓存一致性等话题的基础。把这些顺下来Redis 八股至少能多撑十分钟。7. JDK 环境与运行时异常差点被一个报错卡死7.1 安装与环境变量配置JAVA_HOME、PATH、CLASSPATH很多新手在安装 JDK 后卡在环境变量上。Windows 下常见报错“java 不是内部或外部命令”十有八九是 PATH 没配好。我推荐按下面三步检查。第一步确认安装的是 JDK 而不是 JRE项目开发一般需要 JDK。第二步新建系统变量 JAVA_HOME值指向 JDK 安装根目录例如 C:\Program Files\Java\jdk-17。第三步在 PATH 中新增 %JAVA_HOME%\bin注意不要写死绝对路径这样以后切换 JDK 版本只需要改 JAVA_HOME 一个变量。配置完成后一定要新开一个终端窗口因为环境变量在配置前打开的终端里不会自动生效然后再运行 java -version 验证。那 CLASSPATH 呢很多人被旧教程误导去配一个全局 CLASSPATH其实从 JDK 1.5 开始基本不需要了javac 和 java 会按 classpath 参数或当前目录查找类乱配全局 CLASSPATH 反而会导致各种 ClassNotFound。多个 JDK 版本并存时可以同时装 JDK8 和 JDK17靠切换 JAVA_HOME 来切换版本但 PATH 别把多个 JDK 的 bin 都放进去否则到底用哪个 JDK 由 PATH 顺序决定非常混乱。7.2 NoClassDefFoundError 与 ClassNotFoundException别傻傻分不清这两个名字很像但一个是 Error一个是 Exception面试常拿来做区分题。ClassNotFoundException 是类加载器在加载类时找不到对应的 class 文件常出现在 Class.forName、Spring 容器实例化 Bean、反射等场景属于受检异常需要 catch。NoClassDefFoundError 则发生在类之前加载过、现在加载器在内存里找不到定义或依赖的类初始化失败通常是因为运行环境的依赖缺失、jar 包版本冲突、类的静态初始化抛错。举个例子一个项目用 JDK8 编译运行环境只有 JRE第三方 jar 没打进去运行时就可能先报 ClassNotFoundException再演变成 NoClassDefFoundError。排查思路不要死抠异常名而是看完整堆栈是加载不到外部类还是某个类的初始化失败。前者检查 classpath 和依赖 jar后者检查静态块和日志中隐藏的 ExceptionInInitializerError。能把这个区分讲清楚说明你是真遇到过线上环境问题不是只看了异常名。7.3 高版本 JDK 移除 API 引起的启动异常Applet、Logisim 这类旧软件的坑最近很多人遇到NoClassDefFoundError: java/applet/Applet以及 Logisim 启动时提示需要 Java 1.5.0。这不是你环境坏了而是高版本 JDK 移除了 Applet API 或其他旧模块。Java 9 引入模块化之后很多以前 JDK 自带的 API比如 java.applet、CORBA、JAXB被逐步移除或标记废弃JDK 11 之后尤其明显。旧软件如果是在 Java 5/8 时代编译的硬跑在 JDK 17 上出现 NoClassDefFoundError 再正常不过。解决办法也很朴素给这类旧软件单独装一个匹配的 JDK/JRE最好装在默认目录之外然后用启动脚本里的 JAVA_HOME 或 java 绝对路径显式指定。比如export JAVA_HOME/path/to/jdk1.8.0_202 export PATH$JAVA_HOME/bin:$PATH java -jar logisim.jar不要为了装新版去强行改系统全局 JAVA_HOME容易影响其他项目的版本要求。另外如果是 Maven 项目报java.lang.NullPointerException出现在 annotation processor 或 mapping processor 里通常是 Lombok、MapStruct 这类注解处理器和 JDK 版本不匹配优先统一 JDK 版本、升级依赖、检查 annotationProcessorPaths 配置。这类问题本质都是编译期工具版本错配和业务代码关系不大。我自己的习惯是遇到这种环境类报错先打开启动日志看实际使用的 JDK 路径再决定改环境还是加依赖很多时候问题已经写在日志里了。