Java这块我从大二开始接触,到现在带过几十个实习生,发现一个特别有意思的现象:很多人基础语法玩得很溜,SpringBoot项目也能跑起来,但一提到JVM、并发、动态代理这些进阶话题,立刻眼神涣散。倒不是因为上班用不到,而是这些知识埋在框架下面,平时看一眼代码没问题,出了诡异Bug就抓瞎。
这个“无奖问答挑战”最开始是我在团队内部搞的周五小活动,把平时踩过的坑、面试被问过的题、代码评审时揪出来的低级错误,混在一起做成题目,让大家抢答。后来发现效果意外地好,就想着整理出来,配上完整解析发在平台上。做这个挑战的目的很直接:不让你背八股,而是用问题逼你把知识点串起来,真正搞懂底层在干什么。
1. 进阶阶段最迷茫的是什么
很多人学到Java进阶这个阶段,手头攒了一堆资料,有讲并发编程的,有讲JVM调优的,还有讲各种框架源码的,但真正学起来总觉得碎。今天看个volatile,明天瞄一眼垃圾回收器,后天刷几道LeetCode——知识全是一块一块的,没有一个能把它们串起来的场景。这个“无奖问答挑战1”要做的头一件事,就是帮你把这堆碎片拼一拼。
我设计题目的思路和大部分面试题不太一样。面试题往往只考一个孤立的知识点,比如“synchronized和ReentrantLock有什么区别”,这种题背一遍答案就能过。但实际干活的时候,你面对的问题是“这段代码并发量上来之后,用synchronized还是ConcurrentHashMap合适?”“为什么Redis里counter老是莫名其妙变成字符串?”这些场景混杂了多个知识点,需要一个能主动思考的人才处理得掉。
所以这套挑战里的每一道题,我尽量做到三层。第一层是送分题,考的是你记没记住某个概念;第二层是辨析题,考的是两个相似概念之间的边界;第三层是场景题,考的是换一个真实环境,你会不会用。
后面我会按轮次把题目和解析完整放出来,每道题都给出答案、踩坑点,以及面试官或者需求方真正想看到的思考过程。如果看完你还觉得这些太简单,那说明你已经过了这个阶段,可以等我在挑战2里上更硬核的内容,比如类加载机制、内存屏障、无锁队列这些。
2. 第一轮挑战:JVM内存与并发基础
2.1 题1:new出来的对象到底住在哪里
题目:在JVM中,执行new Object()后,这个对象在大多数情况下分布于哪个内存区域?
A. 虚拟机栈的局部变量表 B. 堆内存中的新生代 C. 元空间 D. 直接内存
先别急着看答案,想一想这个问题考察的是哪一层知识。如果你只背过“栈管运行,堆管存储”,很容易在A和B之间犹豫。实际上A说的是局部变量,这个东西是一个引用,真正的主角是引用指向的那个对象。
正确答案是B。绝大多数Java对象在创建之后,首先进入堆内存的新生代,更准确地说,是新生代里的Eden区。如果JVM启用了逃逸分析,并且判断这个对象不会逃出方法作用域,那它可能被分配到栈上,但这属于编译器优化,不是通用情况。日常业务代码中,你new一个对象,它基本就是住Eden区。
这道题背后真正想让你理解的是JVM分代模型的必要性。对象有生命周期,绝大多数对象活不过几次Young GC,你把这个“短命对象”放在一个独立区域里统一回收,就不会频繁触发Full GC,整个JVM的吞吐量自然会上去。很多人在生产环境遇到频繁GC,第一反应是堆太小,其实问题往往是短命对象过多,垃圾回收器累得够呛。
实操心得:调整堆参数的时候,别只盯着-Xmx调大。如果单次GC之后存活对象极少,但GC频率很高,可以考虑加大新生代比例,让Eden区能扛住更大的分配压力。
2.2 题2:大对象直接进了老年代,真的是好事吗
题目:JVM中通过参数-XX:PretenureSizeThreshold=1M,让大于1MB的对象直接进入老年代。下面说法正确的是?
A. 会降低Minor GC的频率 B. 大对象进入老年代后能更快被回收 C. 可能增加Full GC的频率 D. 对大对象的分配效率有明显提高
这道题几乎是我在面试候选人时的保留题目,因为它的答案和直觉经常反着来。既然是“大对象”,你可能会想,把它放到老年代就不会反复在新生代之间拷贝了,多省事。
正确答案是C。大对象直接进老年代,看似躲过了Minor GC的复制过程,实际上老年代GC并不频繁,但每次都耗时较长,用的是标记-复制或标记-整理算法,代价比新生代的复制算法高得多。大量大对象进入老年代之后,老年代剩余空间会迅速变小,接着系统就开始琢磨:这要不要触发Full GC?结果就是Full GC次数上来,应用出现明显的卡顿。
至于A和B,属于典型想当然。Minor GC触发条件主要看Eden区够不够装,分区调大了自然频率降低,但这和PretenureSizeThreshold没有必然关系。老年代的回收效率本来就不高,特别是CMS和G1这种并发收集器,大对象不但没啥优势,反而容易产生碎片化问题。
避坑建议:生产环境中PretenureSizeThreshold这个参数要慎用。很多资料推荐它来减少新生代的复制开销,但实际上开了之后,如果系统存在大量中等大小临时对象,你会亲眼看到老年代GC频率直线上升。别问我是怎么知道的,我为此背过好几次事故。
2.3 题3:CAP理论下,注册中心的取舍实战
题目:微服务架构中,常见的注册中心包括Eureka、Nacos、Consul等,面对以下几种描述,哪一项是正确的?
A. 注册中心应当优先保证CP,因为一致性的优先级永远高于可用性 B. Eureka基于AP设计,允许各个节点间出现短暂的数据不一致 C. 客户端缓存注册表信息后,如果注册中心短暂不可用,服务间无法通信 D. Nacos在服务注册发现场景中强制采用CP协议
这道题的核心,是CP和AP两种协议在注册中心场景下的取舍。你要是只背了“CAP理论”,却没和实际组件对应起来,很容易翻车。
正确答案是B。Eureka的设计目标就是可用性优先,它不要求各个Eureka节点在某一瞬间拥有完全一致的注册信息,只要最终能收敛就行。客户端还会缓存一份注册表,注册中心短暂挂掉,服务之间已经建立的长连接照跑,服务发现调用走缓存就行了。
C错在忽略了客户端缓存机制,D错在Nacos其实同时支持AP和CP两种模式,服务注册发现走的是AP,配置中心的强一致性走的是CP,不能一刀切说“强制采用CP”。
这道题映射到真实项目里,本质上是在考你“注册中心挂了对系统的影响有多大”。如果你把服务调用搞成每次都要问注册中心要地址,那你迟早会被全链路雪崩上一课。
实操心得:实践项目中,服务注册中心在AP模式下偶尔出现实例列表不一致并不可怕,真正可怕的是客户端把“获取服务列表失败”当成致命异常处理。我一般建议大家给服务发现加缓存,并在调用端做本地故障转移,而不是过度依赖注册中心实时返回。
3. 第二轮挑战:集合、动态代理与框架源码
3.1 题4:HashMap的链表转红黑树,为什么非得是8
题目:HashMap在链表长度达到多少时,会选择将链表转换为红黑树?为什么这个阈值常常被设计成8?
A. 2,为了节省内存 B. 6,因为红黑树查询效率最高 C. 8,泊松分布下概率极低,兼顾查询效率与插入性能 D. 16,为了减少树化带来的频繁调整
这是Java集合框架里被问烂的问题,但很多人只记住了“8”这个数字,却说不清为什么。
正确答案是C。HashMap作者在源码注释里给了很清晰的说明:在随机哈希码下,桶中节点数量遵循泊松分布,链表长度达到8的概率已经小于千万分之一。也就是说,正常业务中几乎不可能出现真正的树化场景,除非你的hashCode实现得稀烂。
为什么不是更大的数?因为一旦树化真的发生,说明hash冲突已经很严重了,此时插入、删除、查询都必须有更好的“兜底”。红黑树查询复杂度O(log n),链表是O(n),长度到8的时候,链表查询最多8次,红黑树只需要3次左右。为了这个差值去做树化转换,值。选6的呢?那是“树退化为链表”的另一个阈值,元素数量降到6及以下时,红黑树会重新变回链表,避免维护树结构的高昂成本。中间留的7,是给两个阈值之间的反复横跳留缓冲。
实用建议:别看HashMap能自动树化就乱写hashCode。真实项目里,我见过把对象的hashCode固定返回1的情况,结果所有元素全挂在一个桶上,HashMap直接退化成链表,接口响应时间从5毫秒变成2秒。这种问题排查起来特别隐蔽,你看代码逻辑完全没错,就是性能不对。
3.2 题5:动态代理到底解决了什么问题
题目:Spring AOP中,当目标类没有实现任何接口时,默认采用哪种动态代理方式?它的工作原理是什么?
A. JDK动态代理,通过反射生成一个和目标接口相同的新类 B. CGLIB动态代理,通过生成目标类的子类来拦截方法调用 C. 编译时织入,直接修改字节码 D. 装饰器模式,用包装类提供增强
这道题考的是你对Spring AOP实现机制的熟悉程度,而不是单纯记忆名词。如果你平时只写Service层,没怎么手动创建过代理,确实容易懵。
正确答案是B。目标类没有接口的情况下,JDK动态代理走不通,因为Proxy.newProxyInstance要求必须有接口。Spring此时会退而求其次,选择CGLIB来生成目标类的子类,并在子类中覆写父类方法,从而在调用Target方法前后执行增强逻辑。
CGLIB基于ASM字节码操作库,运行期动态生成子类,所以目标类不可以是final,目标方法也不能是final的,否则代理根本生成不出来。这就是为什么很多人把某个Service方法改成final之后,发现AOP切面突然不生效了。
D看着像那么回事,实际上装饰器模式是静态组合,AOP追求的是运行时增强,两者目标不同,写法也不同。让你搞清楚这一点后,以后再遇到“为什么我的切面没生效”的社区提问帖,就不会跟着瞎猜了,直接去查目标类有没有接口、目标方法是不是final、对象有没有被代理替换。
踩坑记录:检查AOP是否生效时,第一件事就是看注入的对象实际类型是什么。IDEA调试的时候,你会发现对象类型不是原本的Service类,而是$$EnhancerByCGLIB$$这种名字。如果没有这层子类,说明代理没生成成功,后续所有“为什么事务不生效”的问题都会接踵而至。
3.3 题6:Redis的increment报错,问题出在哪
题目:在Spring Boot项目中,使用RedisTemplate调用increment()对某个key执行自增操作,结果报错“ERR value is not an integer or out of range”。以下哪项最可能是原因?
A. Redis服务器版本低于4.0 B. key对应的value是字符串类型,且无法解析为整数 C. key不存在 D. 网络超时导致命令重复执行
这道题是从真实生产环境抽出来的,看到这个报错,很多人第一反应是上网搜“increment 报错”,然后得到一堆没营养的答案,实际上问题大概率出在数据格式上。
正确答案是B。Redis的INCR命令在底层调用incrDecrCommand,它会对当前value做字符串到long的解析。如果value本身不是纯数字字符串(比如“100abc”、JSON片段、空字符串),或者数字已经超出了64位有符号整数的范围,Redis直接拒绝并返回这个错误。
这里特别容易忽略的场景是:同一个key先用set存了一个字符串,后面又想increment,Redis可不管你“在Spring里设置了什么类型”,它只认底层value的字符串。RedisTemplate序列化器默认用的是JDK序列化,存进去的value可能带了一堆类型前缀,根本不是纯净的数字字符串。这种SerializationException转译过来就是这个ERR。
避坑建议:用Redis做计数器时,强烈建议用一个固定的字符串序列化器,比如StringRedisSerializer,并且保证这个key只被计数器逻辑使用,不要再塞别的类型的数据进去。还有一个细节:排查的时候用redis-cli直接看key的原始值最靠谱,别只盯着应用日志猜。
4. 第三轮挑战:编程实践与算法实现
4.1 题7:主线程如何优雅地等待多个子线程完成
题目:有多个线程分别执行不同任务,我想等待它们全部执行完再汇总结果,以下哪种方案最不符合要求?(要求:不阻塞主线程太久,支持动态新增任务)
A. Thread.join() B. ExecutorService的invokeAll() C. CountDownLatch配合线程池 D. CompletableFuture.allOf()加上回调
这道题很接地气,日常开发中导出报表、批量拉取接口、并发处理数据,全是这种场景。答案选A,原因在于join会让主线程一直等,等待期间既拿不到任务的异常和结果,又没法很好地控制超时,动态添加任务更是不可能。
B虽然能等全部任务完成,但invokeAll在等待所有任务结束后才返回,超出超时时间的任务会怎样?它依然占用线程池,没有真正的灵活取消。C是传统方案,用计数器控制,CountDownLatch设计之初就明确了只能用一次,等待结束后无法重置,动态新增任务这件事它是做不到的。D最优雅,CompletableFuture.allOf()返回一个所有future都完成的阶段,你可以用whenComplete注册回调,让“等全部干完”这件事变成异步链式回调,不阻塞主线程,还方便并行处理异常。
这道题想考核的终极能力是并发编程中的“协作思维”。很多初学者第一反应是我在这边循环join,等完一个等下一个,逻辑上是通的,但它不是工程上最优解。真实项目里,频繁出现的是“多个下游接口并行调用,取最快结果”“一批任务谁先完成就处理谁”,这种需求如果你只会join,会非常拧巴。
实操心得:条件允许时优先用CompletableFuture,因为它的编排表达能力远胜于手工维护Thread和CountDownLatch。我见过太多人写几十行代码做并发汇总,实际换成allOf加thenApply,代码量直接砍掉一半。要注意的是,如果线程池核心线程数设置得太小,异步任务会排队执行,看起来用了并发框架,实际还在串行。建议单独为异步任务创建专用线程池,别和接口线程池混在一起。
4.2 题8:快速排序中的魔法数字,基准值怎么选
题目:手写快速排序时,以下关于基准值选取的说法,正确的是?
A. 固定取最后一个元素最稳定,不受输入顺序影响 B. 随机选取平均性能最好,能规避大部分有序输入的最坏情况 C. 取中间元素一定能让递归深度保持log n D. 基准值选取不影响递归深度
这道题不单是考排序算法,它背后是“算法在真实环境中的数据敏感性”。你以为算法题写对了就完事了,实际上生产环境里的输入顺序千奇百怪,一个固定选末尾的快速排序可能会在你某天突然拿到近似有序数据时,性能直接退化到O(n²),然后接口超时。
正确答案是B。随机选取基准值虽然无法保证每一次都完美分割,但它能摊薄最坏情况出现的概率。对于数组中位数这种“绝对完美”的基准,你反而要先做中位数查找,那已经脱离快排的初衷了。固定取中间元素也不保证“一定平衡”,比如输入是[1,2,3,4,5,6,7],中间值4作为基准可以实现不错的划分,但如果输入是[2,2,1,2,2,2,2],中间元素还是2,依然可能退化。
真正高效的主流做法是三分取中或者随机取样。JDK里面Arrays.sort对基本类型用的是DualPivotQuicksort,它的核心思路就是双基准值加随机或者三分取中,从而大幅降低最坏情况概率。你要是手写快排又特别在意性能,可以加一条规则:当递归深度超过某个阈值,就切换到堆排序兜底,这就是典型的“混合排序”策略。
面试加分项:写完快排后,主动补一句“如果输入接近有序,这个实现会退化,我会在基准选择上做随机化或者引入阈值切换排序策略”,这比默写100行代码都管用。面试官要的不是一个能在IDE里跑通的排序函数,而是你对自己代码在最坏情况下的表现有预判能力。
4.3 题9:JDBC批量插入,为什么越插越慢
题目:使用JDBC进行大批量数据插入时,开启事务批量提交,但插入速度越来越慢。最可能的原因是?
A. 数据库连接未关闭 B. 没使用批量接口,一条一条重新执行 C. 查询索引失效 D. 事务不提交导致Undo日志膨胀,同时锁和索引维护成本变高
这道题是真实操作中容易踩的坑。很多人的第一反应是连接没关闭,但连接没关闭通常直接报“连接池耗尽”,而非“越来越慢”。
正确答案是D。长事务不提交,意味着数据库为了支持MVCC,需要把每一次修改前的数据快照扔进Undo日志,日志不断膨胀。同时,已修改行上的行锁会一直持有,其他会话想改这些行就得等着。索引也要持续维护,更新量越大,B+树的节点分裂和写入放大越明显。
最典型的就是程序里把事务开在循环外面,本想提高性能,结果一跑就是几十分钟不提交,最后整个库都被拖累。
工程建议:批量插入时,单次事务控制在几百到几千条即可,不要攒上百万条才提交。可以用一个计数器,每满500条就提交一次。这样内存、锁、Undo日志都能快速释放。另外,如果插入的表有大量二级索引,批量写入速度会被索引维护拖慢,可以评估一下导入期间先删索引再重建这个方案,但前提是明确业务允许短暂牺牲查询能力。
5. 这些“看似会了其实不会”的知识点怎么排查
在整理的这轮问答挑战里,有不少知识点属于“代码能跑但不知道原理”的类型。这里挑几个高频问题,把对应的排查思路放出来,方便你在自己的项目里遇上了能立刻上手。
| 异常或现象 | 排查思路 | 常见原因 |
|---|---|---|
| RedisTemplate.increment()报“not an integer” | redis-cli查看原始value,确认是否为纯数字字符串 | 序列化器不一致或key存过非数字数据 |
| 事务不生效 | 检查目标方法是否为public,是否通过代理对象调用 | 自调用绕过了AOP代理 |
| HashMap莫名OOM或效率低 | 检查hashCode是否均匀,可用jmap打印堆查看对象分布 | 自定义对象hashCode冲突严重 |
| 应用频繁Full GC | 打开GC日志,查看老年代增长曲线,用MAT分析大对象 | 大对象直接进入老年代、未及时释放引用 |
| 线程池任务不执行 | 看队列长度和活跃线程数,排查核心线程配置 | 核心线程数过小,任务排队等待 |
这里单独说一下事务不生效的排查路径。很多人问我“加了@Transactional为什么没回滚”,我一般让他先看方法签名,是public吗?如果方法写成private或者包内方法,Spring根本没法代理。再看调用方式,同类中的方法相互调用会不会绕过代理?这不是玄学问题,而是Spring AOP基于代理,代理要生效必须从外部走进去。理解了这一点,很多不生效的问题不用猜也能定位到方向。
至于GC问题的排查,我建议把GC日志打开放到生产环境观察几天。jstat、jmap配合一个内存分析工具,正常情况下能帮你找到是哪个对象占了大头。如果年轻代频繁GC但Eden区总被瞬间填满,八成是你有大量循环里new对象、且对象逃逸分析没兜住的问题。检查代码时,重点不是找哪个对象最大,而是找哪个方法分配频率最高,因为GC频率是“分配速率”催出来的,不是单个对象尺寸单挑的。
6. 给进阶者的一句话:别只背题,去追底层逻辑
很多学习者一听到“进阶”两个字,就一头扎进题海里,觉得今天掌握了多少道题,明天面试就能多几分底气。我自己带过的团队成员也好,收到的读者反馈也好,很多人都是从“背了一堆结论”开始的,但走到后来能真正拉开差距的,是那几个把“为什么”问到底的人。
这道题里的每一个选项都不是随便拼出来的。比如HashMap的8和6,对应的是“概率”和“树结构维护成本”的平衡;Redis的increment得知道底层存的是字符串,操作之前得先确认数据形态;动态代理要理解“代理类代替目标类让外部调用”这一层抽象,往后看Spring事务、MyBatis、Feign这些框架,思路都是相通的。
如果第一轮挑战里你已经能把每道题的“正确项为什么对”和“错误项为什么错”都解释清楚,恭喜你,你确实有进阶的底子了。后面我出挑战2、挑战3的时候,会围绕设计模式的实际应用、并发工具的一次完整落地、还有中间件异常根因排查这类更硬核的内容来出题。保持住这个思考习惯,自己多动手模拟几个场景,比看十篇“万字总结”都管用。