☰
Java面试高频考点全复盘:集合、并发、JVM、Redis一网打尽
2026/9/29 18:02:10 网站建设 项目流程

我有个朋友叫谢飞机,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)。谢飞机恍然大悟,这就是为什么实际项目中,大多数顺次追加的业务场景都用ArrayList,ArrayList还省内存,每个元素就一个对象引用,而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); CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> callApi1(), pool); CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> callApi2(), pool); CompletableFuture<String> 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/unlock;ReentrantLock支持公平锁、可中断、可多个条件队列。

面试官问:“那你给我讲讲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默认的序列化器是JdkSerializationRedisSerializer,key被反序列化后跟原来的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 CGLIB,Spring到底用哪个?

谢飞机知道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循环改成Stream:

List<Order> list = orderService.list(); Map<Long, 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里有bin
NoClassDefFoundError: java/applet/Applet旧项目升级JDKJDK 9移除Applet模块。要么降级JDK要么换掉相关依赖。排查时先java -version确认版本
Annotation处理器NPELombok或MapStruct编译失败多版本JDK混用、依赖冲突。统一JDK版本,mvn clean install清缓存,必要时升级插件
RedisTemplate.increment报错计数器/库存扣减key/value序列化方式不一致,统一为StringRedisSerializer或直接用StringRedisTemplate
IDEA社区版无法使用部分企业功能想用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报错,一个个亲手敲一遍代码、亲自踩一遍坑。技术这东西,看过不算会,练过才算稳,讲得出来才算真掌握。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询