面试间里,空气凝滞。我递过一张纸,上面写着一行字:“请你说说HashMap的原理。”候选人愣了一下,从数组讲到链表,从链表讲到红黑树,却在我追问“为什么阈值是8而不是9”时卡住了。这场面试的走向,从此变得清晰。
HashMap:从数组到红黑树
HashMap的底层结构,看起来只是数组加链表,但细节比想象中残酷得多。哈希函数如何计算桶位置?为什么用位运算代替取模?链表什么时候树化?容量为什么总是2的幂?这些问题每一个都能撕开一道口子。HashMap的树化不是炫耀技术,而是在极端哈希碰撞下保护性能的最后一道防线。候选人若只记得“链表转红黑树”,却说不清树化阈值的设计逻辑——负载因子0.75对空间和时间的折中,哈希码分布不均时链表如何退化,扩容时如何重新分配节点——那这个知识点就是假的。真正的理解,应该能徒手推演一个key从插入到定位的完整路径,包括扰动函数的作用:高16位与低16位异或,正是为了让高位信息也参与到低位的桶索引计算中。
ConcurrentHashMap:锁的舞蹈
如果HashMap是单线程下的宝藏,那并发环境就是它的坟场。于是ConcurrentHashMap站了出来。JDK7里它用分段锁,将容器分成16个Segment,每个Segment独立加锁;JDK8干脆抛弃分段锁,改用CAS配合synchronized,只锁住每个桶的头结点。JDK8的ConcurrentHashMap抛弃分段锁,本质是对“锁粒度”的极致追求。面试官最想听的,不是你能背出两种版本的差异,而是你能不能说出为什么JDK8能这么做:synchronized在JDK6之后已经足够快,CAS在竞争不激烈时近乎无锁,加上volatile的读写可见性,让锁的粒度从一段缩小到一桶。这背后是对并发性能的精准取舍——锁越细,并行度越高,但管理锁的复杂度也越高。
volatile:最轻量的同步
顺着并发的话题往深挖,volatile是绕不过去的坎。很多候选人能说出“保证可见性”“禁止指令重排”,但被问到“为什么不能保证原子性”时就含糊了。volatile修饰的变量,每次读取都从主内存拉取最新值,每次写入都立即刷新回主内存,它禁用了编译器缓存和CPU重排序。但原子性是“读-改-写”三步操作的整体性,volatile管不了i++这种非原子操作。volatile解决的是“可见性”和“有序性”,但它永远包办不了“原子性”。一个经典反问是:两个线程同时执行volatile int x的x++,最终结果会怎样?答案是可能小于20000。因为x++包含读取、加1、写回三个动作,线程A和B可能同时读到旧值,各自加1写回,导致一次更新丢失。这个陷阱,恰恰是理解并发底层的分水岭。
synchronized:从重量级到CAS的进化
说到原子性,synchronized倒是天生的管工。但它的进化之路,比多数人想象得曲折。JDK早期,synchronized是纯重量级锁,线程阻塞会涉及用户态和内核态的切换,代价高昂。JDK6之后,虚拟机对其做了大幅优化:无锁→偏向锁→轻量级锁→重量级锁。偏向锁认为“当前只有一个线程占用”,连CAS都省了,直接打上偏向标记;一旦出现竞争,升级为轻量级锁,用自旋CAS尝试获取;自旋超过阈值或等待线程数过多,才膨胀为重量级锁,阻塞其他线程。锁升级的路径,本质上是对线程竞争环境的实时妥协。面试时,与其机械背诵锁状态,不如想想为什么要有这种设计:如果锁大多数时间被单一线程持有,偏向上锁能省掉原子操;如果竞争偶尔发生,自旋比阻塞划算;如果竞争激烈,自旋浪费CPU,必须阻塞。这才叫搞懂。
线程池:不要乱设参数
并发任务多了,线程池就是Java并发体系里的调度中枢。核心线程数、最大线程数、工作队列、线程工厂、拒绝策略,五个参数各有各的坑。线程池最大的问题不是线程不够,而是任务队列设计得不合理。比如用无界队列的LinkedBlockingQueue,当任务提交速度快于消费速度,队列无限膨胀,内存迟早被打爆。此时核心线程数永远不会被突破,最大线程数形同虚设。拒绝策略也有讲究:CallerRunsPolicy让提交任务的线程自己去跑,虽然慢了,但不会丢任务;DiscardOldestPolicy会丢弃最老未执行的任务,你可能丢的是关键数据。面试官问“请问核心线程数设为多大?”,不是要你背公式,而是看你有没有认识到CPU密集型与IO密集型的区别,以及对于混合型任务,可能需要动态调整。没有一招鲜的参数配置,只有对任务特性和系统资源的清醒认知。
JVM内存:哪里是你管理的盲区
线程池里的线程从哪来?它们又踩着谁的空间运行?这个问题自然落到JVM运行时数据区。堆、栈、方法区、程序计数器、本地方法栈,每个区域都有明确的职责和异常边界。堆放对象和数组,栈放栈帧和局部变量表,方法区曾存放类元信息和常量池(JDK8之后迁移到元空间),程序计数器记录当前线程执行的字节码行号。Java程序员最应该害怕的不是内存溢出,而是内存泄漏在不知不觉中发生。面试时,一个典型的考察方式是:给一段代码,问哪些对象在何时被回收。很多人盯着堆里的对象,却忘了栈帧中的局部变量表也持有对堆内存的引用,当函数返回后引用才消失。还有一个高端考点:方法区的元空间用的是本地内存,不受堆大小限制,所以类加载器如果加载太多类,同样会OOM。这些不只是背区域,而要能画出完整的对象生命周期。
垃圾回收:谈可达性
对象生命周期走到尽头,垃圾回收器登场。判断对象是否该死,主流的不是引用计数(它解决不了循环引用),而是可达性分析。从GC Roots出发,沿着引用链搜索,凡是没被引到的一律当垃圾。GC Roots包含当前线程栈中的引用、静态变量、JNI引用等。判断对象死亡的唯一标准不是引用计数,而是“不可达”。回收算法上,标记-清除会造成内存碎片,标记-整理有移动成本,复制算法适合存活率低的年轻代。JVM分代收集:年轻代用复制,老年代用标记-整理或并发标记清除,基于“大部分对象朝生夕死”的假设。面试官如果让你比较G1和CMS,你要能说出G1把堆分成Region,可以设定任意的停顿时间目标,以及它的Remembered Set如何记录跨区域引用。垃圾回收不只是回收,更是对内存布局和性能指标的深度权衡。
双亲委派:打破它需要勇气
类不是凭空加载进JVM的,类加载器有条不紊地工作,遵循双亲委派模型:收到加载请求,先让父加载器尝试,父加载器不行才自己来。这个机制保证了Java核心类库(如String、Object)不会被自定义类覆盖,也避免类被重复加载。双亲委派保证了核心类不被篡改,但真正的高手会知道什么时候该打破它。Tomcat就打破了,因为每个Web应用应该有自己的类加载器,隔离不同应用的依赖;JDBC也用ServiceProvider机制打破,因为DriverManager在启动类加载器中,却要加载实现厂商驱动的应用类加载器。面试时,你要能举出打破双亲委派的场景,并解释为什么那样做是合理而必要的。只会背“自下而上搜索,自上而下加载”远远不够,你得知道这个模型的天花板在哪里,以及在实际容器中如何被扩展。
反射:代价与魔法
如果说类加载是激活,那反射就是窥探。通过Class对象,你能在运行时获取类的方法、字段、构造器,甚至调用私有成员。几乎所有的框架(Spring、MyBatis)都是反射的受益者。反射是框架的基石,也是性能的暗礁。每次反射调用都要经过权限检查、解析参数、生成代理对象等步骤,比直接调用慢几个数量级。但现代JVM有MethodHandle和invokedynamic指令,加上JIT的优化,反射性能差距在缩小。面试官可能会问“动态代理和反射有什么关系?”——动态代理在运行时生成一个实现指定接口的代理类,本质上是利用反射去调用InvocationHandler中的处理方法。你还得明白,反射为什么会破坏封装性?因为setAccessible(true)可以绕过Java语言访问检查,但这在安全管理器下可能被禁止。这些都是真实工程里会遇到的问题。
泛型擦除:你看到的不是真的
最后一个核心问题,往往被当成语法糖忽略掉。Java泛型在编译期做类型校验,但在运行期,泛型信息会被擦除。比如List 和List 在运行时都是同一份字节码,类型参数被替换为Object或边界类型。Java泛型是编译期的约束,运行期它只是一段被擦除的代码。这带来一个诡异现象:你无法用instanceof去判断一个List中元素的类型,也无法在反射中获取泛型的具体类型,除非通过TypeToken之类的手段保存参数化类型。更常见的坑是:重载方法中,List 和List 不能作为区分方法签名的方式,因为两者擦除后都是List。理解泛型擦除,才能理解为什么有些类型转换会强制插入,为什么静态方法不能访问类级别的泛型参数,为什么泛型不能用于创建一个确切类型的数组。这不是钻牛角尖,而是读懂泛型在字节码层面的生存规则。
十个问题走完,面试室的空调声才重新清晰起来。候选人最终没有通过,他背住了所有名词,却在每个追问的缝隙里露出苍白。Java面试前必须搞懂的这些核心问题,其实从来不是要你背答案,而是要你在面对“为什么”时,能够沿着代码的纹理,推演出设计者当年的困境与顿悟。面试官真正在意的,不是你知道多少答案,而是你能否在每一个答案后面,再问出一个更深刻的问题。