Java面试高频考点全复盘:集合、并发、JVM、Redis一网打尽
我有个朋友叫谢飞机Java后端干了三年天天刷八股文刷到凌晨结果自己项目里Redis的increment()都能干出类型不匹配的报错。最近他经历了一场互联网大厂的Java面试三轮下来说搞笑是真搞笑说硬核也是真硬核。我把他这趟面试过程完整复盘了一遍里面埋的考点全是高频题集合、并发、JVM、Redis、Spring动态代理、手撕快排一个都没落下。这篇就借谢飞机的三轮搞笑之旅把Java面试那些拦路虎一个个拆开揉碎顺便附上能直接用的代码和排查思路。不管你是准备跳槽的老手还是刚开始啃Java基础的新人这文章应该能帮你少走不少弯路。1. 第一轮面试Java基础与集合框架的“送分题”谢飞机第一轮遇到的是个戴黑框眼镜的面试官看起来温和开口就是经典问题“String为什么设计成final你平时用过哪些必须注意的地方”谢飞机一听乐了这题他背过脱口而出String不可变才能保证字符串常量池可以缓存、线程安全适合做HashMap的key还不怕hash值变来变去。面试官点点头但又追问一句“如果String可变HashMap里作为key会发生什么”谢飞机愣了一下老实回答hashCode会变put进去之后再get可能直接找不到原来的值甚至可能因为桶位变化导致数据“丢失”这跟哈希表依赖key的hashCode定位的逻辑冲突了。面试官这才露出“算你明白”的表情。1.1 HashMap的put过程连问三连这轮第二个问题是HashMap的put全过程。谢飞机以为就是“算hash、找桶、放进去”这么简单结果面试官追了三层第一层key的hashCode是怎么处理的谢飞机说HashMap会先调hash(K)做一次扰动把高16位和低16位异或一下再和table.length - 1做与运算得到桶下标。为什么要扰动因为如果不扰动当数组长度不大时只有低几位参与哈希计算很容易碰撞异或能尽量把高位信息混进来。第二层什么时候链表转红黑树这个谢飞机知道链表长度超过8且数组长度大于等于64就会转红黑树。面试官问为什么是8谢飞机说这是基于泊松分布算出来的在负载因子0.75的前提下链表长度达到8的概率已经低到千万分之六属于“极端情况下的兜底方案”。第三层1.7和1.8扩容有什么区别谢飞机记得1.7是头插法多线程下扩容可能形成环形链表死循环。1.8改成尾插法而且扩容后元素要么在原位置要么在原位置加旧容量通过e.hash oldCap判断。面试官点头说这题很多人只会背结论你能说出“为什么改成尾插”已经超过一半候选人了。1.2 ArrayList和LinkedList谁更快谢飞机一听这题就来劲了“ArrayList随机访问快LinkedList插入删除快。”结果面试官笑了“你这回答是标准八股文但实际情况呢”面试官提示他ArrayList插入慢不慢要看插入位置如果在末尾add(E e)就是直接往后丢非常快如果每次都插到头部那所有元素都要往后挪O(n)。LinkedList插入快的前提是已经拿到了对应节点如果你直接add(int index, E e)它内部还是要先遍历找到那个位置同样是O(n)。谢飞机恍然大悟这就是为什么实际项目中大多数顺次追加的业务场景都用ArrayListArrayList还省内存每个元素就一个对象引用而LinkedList每个节点还要维护前驱后继两个引用。这一节总结下来面试官其实在考“你理解数据结构是为什么存在而不是背结论”。谢飞机后来跟我说他以前一直以为LinkedList天下无敌现在才知道ArrayList才是真正的“万金油”。1.3 equals和hashCode“不重写会怎样”的冷笑话这轮最后一个问题重写equals一定要重写hashCode吗谢飞机一开始说“规范要求”面试官不满意非要他举一个实际翻车的例子。谢飞机想了想比如你写一个User类ID相同就认为是同一个用户。如果你只重写equals不重写hashCode那new User(1)和new User(1)equals成立但hashCode不同。把它们都放进HashSet集合会认为是两个不同元素因为HashSet判断重复是先算hashCode定位桶再在桶里用equals比较。你说两个用户一样HashSet说“他们hash不同不是同一个”这不就翻车了吗同理如果你用User做HashMap的key第一次put进去和第二次get用同一个ID但不同对象hashCode不一致get直接返回null。面试官这次终于满意地点了点头。谢飞机出了这轮面试感慨一句集合全家桶这种东西不能光记“是什么”真得理解“为什么这样设计”。HashMap为什么负载因子0.75、为什么转红黑树、为什么用尾插全是基于对性能和概率的权衡面试官想听的就是这些权衡逻辑。2. 第二轮面试并发、JVM与Redis“连环坑”第二轮面试官是个看上去很干练的女生上来就问“你的服务里要并发查三个外部接口等三个都返回再聚合结果你会怎么写”谢飞机心想这不是送分题吗用CountDownLatch啊每个线程跑完一个接口就countDown()主线程await()等计数器归零。2.1 线程等待都完成CountDownLatch还是FutureTask面试官继续问CountDownLatch用完之后能复用吗谢飞机说不能计数器是一次性的你要是想复用得用CyclicBarrier。CyclicBarrier的计数器可以重置而且它更偏“所有线程互相等待到齐后再一起出发”的场景CountDownLatch则是“主线程等一堆子线程干完活”。接着面试官问他如果其中某个接口调用超时了你怎么办谢飞机说最简单就是await(3, TimeUnit.SECONDS)等三秒没结束就先往下走但这样聚合结果会缺数据得给个默认值或者记录告警日志。面试官又追问那用FutureTask呢谢飞机说FutureTask.get()也能阻塞拿结果但不好做“多个任务同时等待”的编排更推荐CompletableFuture.allOf(...)链式写法优雅得多。这里我帮谢飞机补了一段实际可用的代码这也是他后来过了面试复盘时觉得最值得分享的点ExecutorService pool Executors.newFixedThreadPool(3); CompletableFutureString f1 CompletableFuture.supplyAsync(() - callApi1(), pool); CompletableFutureString f2 CompletableFuture.supplyAsync(() - callApi2(), pool); CompletableFutureString f3 CompletableFuture.supplyAsync(() - callApi3(), pool); CompletableFuture.allOf(f1, f2, f3).join(); String r1 f1.getNow(default); String r2 f2.getNow(default); String r3 f3.getNow(default);用getNow(default)比直接get()稳因为allOf().join()已经确保所有任务执行完不管成功还是异常再get()不会无限阻塞如果某个任务挂了还能拿到兜底值。谢飞机说这题他以前背了CountDownLatch就以为天下无敌实际生产里CompletableFuture好用得多还能配合自定义线程池控制并发度。2.2 synchronized与ReentrantLock的“锁”事这轮第二个问题是synchronized和ReentrantLock有什么区别谢飞机开始背synchronized是JVM层面的关键字ReentrantLock是JDK提供的一个类synchronized自动释放锁ReentrantLock需要手动lock/unlockReentrantLock支持公平锁、可中断、可多个条件队列。面试官问“那你给我讲讲synchronized的锁升级过程。”谢飞机知道这是在问无锁→偏向锁→轻量级锁→重量级锁他尽量把自己的理解讲得像人话刚创建对象时是无锁状态第一个线程来抢锁通过CAS把对象头里的Mark Word记录成这个线程的ID这就是偏向锁意思是“这个锁偏向于第一个线程”它再来就不用竞争了。如果第二个线程也来抢偏向模式就撤销升级成轻量级锁。轻量级锁的原理是线程在自己的栈帧里分配一个Lock Record然后尝试CAS把对象头Mark Word指向这个Lock Record抢不到就自旋。自旋会消耗CPU抢太多轮还失败就升级成重量级锁依赖操作系统mutex线程会进入阻塞上下文切换成本高。谢飞机说这套东西背起来容易但面试官问的目的不是让你背名词而是看你能不能解释清楚“为什么自旋一定次数就要升级”他答到“因为自旋是忙等一直占着CPU不干活多线程争抢激烈的时候不如直接阻塞划算”时面试官明显眼神亮了一下。接着面试官问ReentrantLock的公平锁和非公平锁底层区别。谢飞机说非公平锁一进来就CAS抢一次抢不到再进队列公平锁呢除非队列为空或自己是队头否则老老实实排队。面试官追问“非公平锁为什么性能更好”谢飞机说因为一次CAS就能抢到锁的话省去了线程挂起和唤醒的上下文切换开销虽然可能出现“插队”现象但吞吐量往往更高。2.3 JVM类加载与NoClassDefFoundError的现场这里面试官抛了个实际报错uncaught exception java.lang.noclassdeffounderror: java/applet/applet问谢飞机怎么排查。谢飞机一开始有点蒙Java Applet都是上古时代的东西了怎么还有这错面试官提醒他Applet类在JDK 8还在但JDK 9之后模块化把applet模块移除了。你如果把旧项目跑在高版本JDK上或者编译时引用了一个高版本JDK已经不存在的类就可能出现NoClassDefFoundError。面试官借机考他NoClassDefFoundError和ClassNotFoundException有什么区别谢飞机说ClassNotFoundException是类加载器根本找不到那个类一般发生在类加载阶段比如Class.forName()显式加载时类路径里没有NoClassDefFoundError则更阴间它通常是类在编译期存在但运行期缺依赖、初始化失败、或静态代码块抛异常导致类加载失败后再次使用时抛这个错。排查思路也很明确先看编译期和运行期JDK版本是否一致再看依赖是否打包进去了尤其是用fat jar时某些jar被排除然后用-verbose:class或者arthas的sc命令确认某个类到底有没有被加载最后检查静态代码块和类初始化如果初始化抛了异常后续引用这个类就会变成NoClassDefFoundError谢飞机说这题真是“看似冷门实际生产里一旦遇到就恨不得把头发薅光”。他之前自己遇到过mapping processor里java.lang.NullPointerException其实就是编译期某个注解处理器环境不对最后通过统一JDK版本、清理maven本地仓库旧依赖解决。2.4 RedisTemplate.increment()报错不是integer or out of range第二轮最后面试官问了一个让谢飞机脸红的问题“你做过Redis计数吧有没有碰到过increment()报错”谢飞机心里咯噔一下这不就是他前两天刚踩的坑嘛。他说业务上要做一个库存扣减最开始用redisTemplate.opsForValue().increment(key, -1)结果报错ERR value is not an integer or out of range。排查到最后原因是同一个key之前是用StringRedisTemplate写入的StringRedisTemplate用的是String序列化器Redis里存的是普通字符串而后来改用RedisTemplate去读RedisTemplate默认的序列化器是JdkSerializationRedisSerializerkey被反序列化后跟原来的key对不上或者value被反序列化成了二进制带前缀的内容Redis自然认为它不是integer。面试官问他怎么解决谢飞机说有两个方案一是全项目统一序列化方式比如都用StringRedisTemplate或者给RedisTemplate设置成StringRedisSerializer二是如果必须用RedisTemplate可以配合execute(RedisCallback)直接操作原生连接执行DECR命令避免序列化干扰。谢飞机还给了一段实际可用的推荐写法// 推荐统一使用StringRedisTemplate做计数器 stringRedisTemplate.opsForValue().increment(stock:1001, -1); // 如果一定要用RedisTemplate把key和value的序列化器都换成String redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(new StringRedisSerializer()); redisTemplate.opsForValue().increment(stock:1001, -1);面试官又追问DECR是原子性的吗谢飞机说Redis是单线程执行命令INCR和DECR天然原子不需要额外加锁。如果要做更复杂的“检查库存够不够再扣减”才需要考虑用Lua脚本一次搞定避免并发超卖。谢飞机说自己实际项目里后来就是Lua脚本RedisTemplate.execute把“查库存、扣库存、回滚中间态”放在一个脚本里执行彻底治好了超卖焦虑。3. 第三轮面试框架、算法与设计模式的“灵魂拷问”三轮面试官看起来像个技术负责人一上来就让谢飞机别紧张然后问了个开放题“Spring里那个动态代理JDK代理和CGLIB代理到底什么时候用如果目标类没有接口Spring怎么代理”3.1 JDK动态代理 vs CGLIBSpring到底用哪个谢飞机知道JDK动态代理是Java反射机制在运行时为接口生成代理类所以目标对象必须实现接口。CGLIB是通过继承目标类、重写目标方法实现的所以目标类不能是final方法也不能是final。Spring默认行为是如果Bean有接口默认用JDK动态代理如果没有接口用CGLIB。Spring Boot 2.x之后默认使用CGLIB实现AOP代理不管有没有接口。面试官追问“CGLIB是生成子类那Spring管理的Bean一般是单例的CGLIB代理对象和目标对象是两个不同的对象有没有坑”谢飞机想了想说有比如当你用this调用内部方法时方法上的Transactional注解是不生效的因为this是原始对象不是代理对象。解决办法是注入自己或从ApplicationContext里拿代理对象。面试官接着问动态代理除了Spring AOP还有什么应用场景谢飞机说凡是“想在方法执行前后统一做点事”的地方都能用比如统一日志、鉴权、耗时统计、分布式锁、多数据源切换。他还补了一句如果要代理的对象没有接口又不想引入CGLIB依赖可以自己用ByteBuddy或者ASM生成字节码但成本高一般没这必要。3.2 手撕快速排序三分钟写出能跑的代码这轮面试官让人在共享文档里手写快速排序谢飞机先掏出了他背熟的“挖坑法”public void quickSort(int[] arr, int left, int right) { if (left right) { return; } int pivot arr[left]; int i left, j right; while (i j) { while (i j arr[j] pivot) { j--; } if (i j) { arr[i] arr[j]; } while (i j arr[i] pivot) { i; } if (i j) { arr[j--] arr[i]; } } arr[i] pivot; quickSort(arr, left, i - 1); quickSort(arr, i 1, right); }面试官看了看问时间复杂度、稳定性、最坏情况。谢飞机说平均O(n log n)最坏O(n²)发生在每次基准值都选到最大或最小值的时候比如数组已经有序并且每次选第一个元素当基准。快速排序不稳定因为交换可能跨越相等元素。面试官问怎么优化最坏情况谢飞机说可以三数取中、随机选基准、在数据规模小的时候切到插入排序。面试官又拿出冒泡排序问“你知道为什么冒泡排序面试时也总被问吗”谢飞机说因为它是理解“相邻交换”和“时间复杂度”最好的入门例子而且可以加一个swapped标记优化有序数组让最好情况变成O(n)。他顺口写了优化版面试官满意点头。这里也提醒各位手撕算法不是只背代码一定要能说出为什么否则面试官问一个“最坏情况”就把你打回原形。3.3 MySQL搜索语句与ER图别让like毁索引这轮第三个问题关于数据库一个商品搜索功能用户输入关键字要去商品表里搜名称和描述SQL怎么写谢飞机随手写SELECT * FROM product WHERE name LIKE % #{keyword} % OR description LIKE % #{keyword} %;面试官说这写法很简单但性能呢谢飞机说前导通配符%keyword%会导致索引失效全表扫描。如果必须模糊搜索不要求全模糊时可以考虑keyword%做前缀匹配能走普通B树索引如果数据量大要上Elasticsearch或者MySQL全文索引。面试官问全文索引有没有坑谢飞机说MySQL的全文索引在InnoDB里是倒排索引中文分词默认不支持实际项目中中文搜索基本都交给ES或者专业搜索引擎MySQL全文索引适合英文或者短文本场景。面试官又让他把这几个表之间的关系画个ER图——用户表、订单表、商品表、订单明细表。谢飞机说他平时项目里用工具自动生成数据库设计文档但面试官想考的是能不能说清主外键关系、为什么订单明细要存商品快照信息。这里他答得还可以订单快照要冗余商品名称和价格因为商品价格可能变订单金额不能跟着变。ER图的本质就是梳理实体和关系设计好外键和索引比工具生成更重要。3.4 Java 8以来的常用新特性Lambda、Stream、文本块最后一个问题面试官问他项目里Java版本是多少平时有没有用Lambda和Stream。谢飞机说公司还在Java 8但代码里已经有大量Lambda和Stream。面试官让他现场把一段for循环改成StreamListOrder list orderService.list(); MapLong, BigDecimal totalMap list.stream() .filter(o - o.getStatus() 1) .collect(Collectors.groupingBy( Order::getUserId, Collectors.reducing(BigDecimal.ZERO, Order::getAmount, BigDecimal::add) ));谢飞机解释groupingBy按用户分组reducing对金额求和BigDecimal::add避免Double精度问题。面试官问他Stream是不是就一定比for循环快谢飞机说大多数时候不是Stream的优势是声明式和可读性并行流需要谨慎数据量小的时候并行流性能反而不如串行。面试官又问String多行写法谢飞机说Java 13之后有文本块用三个双引号包起来不用再写一堆换行转义。这里面试官说要是你们还在Java 8可以考虑String.join(\n, ...)或者用Java 11的stripIndent思路虽然没那么优雅但也能用。4. 面试翻车现场与避坑清单三轮回合一结束谢飞机瘫在椅子上跟我说他这趟面试最大的收获不是拿到offer而是发现自己以前遇到的很多日常报错其实都是基础不牢导致的“表面搞笑、内核扎心”。我让他把这些年遇到过的Java相关报错整理成一个避坑清单他说这张表值得所有Java工程师收藏。4.1 那些让谢飞机当场“社死”的环境问题报错/问题场景原因与解决方案java 环境变量配置不生效新电脑装JDKjavac无论如何都不认八成是PATH没配到bin目录或者配置完没重开终端Windows下还可能被系统变量和用户变量覆盖logisim requires java 1.5.0教学工具Logisim打不开装了新版本JDK但Logisim不识别需要装对应版本的JRE或者把系统JAVA_HOME指到匹配版本drozer找不到Java移动安全测试环境drozer安装脚本依赖Java环境变量JAVA_HOME必须指向JDK根目录而不是jre目录同时确认PATH里有binNoClassDefFoundError: java/applet/Applet旧项目升级JDKJDK 9移除Applet模块。要么降级JDK要么换掉相关依赖。排查时先java -version确认版本Annotation处理器NPELombok或MapStruct编译失败多版本JDK混用、依赖冲突。统一JDK版本mvn clean install清缓存必要时升级插件RedisTemplate.increment报错计数器/库存扣减key/value序列化方式不一致统一为StringRedisSerializer或直接用StringRedisTemplateIDEA社区版无法使用部分企业功能想用Spring Initializr部分组件不建议使用任何破解渠道。直接用IntelliJ IDEA Community版配合Maven骨架功能完全够用谢飞机一直跟我强调一句话环境问题占了Java开发日常报错的三成以上别看它“不是技术”面试时被问到排查思路如果你能条理清晰地答出“版本、环境变量、序列化、依赖冲突”这四个排查维度面试官会觉得你实战经验扎实。4.2 八股文这样学才不算死背很多同学问谢飞机Java面试八股文到底要不要背。他的回答是要背但背的是“逻辑链”不是“结论”。比如你背HashMap的负载因子0.75至少要连问自己三个问题为什么不是0.5为什么不是1为什么是0.75答上来“空间利用率和查询性能的折中”这才叫真懂。谢飞机建议的学习路线是基础语法和Java集合 → JVM内存结构和类加载 → 并发编程和锁 → Spring核心和动态代理 → MySQL和Redis → 项目实战与调优。每一层都不要跳过否则就像盖楼缺了地基。他特别推荐一种“费曼学习法”的变种把当天学到的知识点用五句话讲给自己听能讲明白就说明真懂了讲不明白就回去翻书。他面试前把synchronized锁升级过程给家里的猫讲了不下十遍讲到最后猫都跑了但他自己也彻底熟了。关于八股文的“背”他认为最高效的方式是把知识点串成几个大故事比如“一个线程从创建到执行完经历了哪些状态锁怎么介入JVM怎么分配内存Redis怎么加速”。能把跨模块的知识点串起来面试官问任何一环你都能接得住。4.3 再分享一个压箱底的准备技巧谢飞机最后跟我说了一个他压箱底的心得面试前一定要做三到五次模拟面试找朋友或者自己对着录音设备讲。怎么讲把你准备好的项目经历和技术点像讲段子一样讲出来背景是什么遇到什么问题怎么排查最后怎么解决有没有更好的方案。这个“讲故事”能力特别重要。他说他第三轮面试讲Redis库存扣减时把“RedisTemplate序列化器不一致导致increment报错”这个坑讲成了一个带情节的小故事深夜上线、库存莫名其妙变成负数、查日志发现同一个key用了两种模板去读写、最后用Lua脚本一劳永逸。面试官一听就笑了说“这是真踩过坑的人才能讲出来的细节”。这种表达方式远比“我会Redis用过RedisTemplate”有说服力因为面试官想听到的不是名词是你面对问题时的思考过程。我个人在实际操作中的体会是面试这件事心态越放松越容易发挥出真实水平。谢飞机之所以这趟面试“搞笑中带着硬核”是因为他把每个答得不好的问题都在当天复盘整理成了文章两周下来积累了满满一本笔记。如果你也是下一个准备Java面试的“谢飞机”不妨把本文提到的HashMap扩容、synchronized锁升级、NoClassDefFoundError排查、RedisTemplate的increment报错一个个亲手敲一遍代码、亲自踩一遍坑。技术这东西看过不算会练过才算稳讲得出来才算真掌握。