“请说说你对String的理解。”面试官语气平淡,却像一枚探针,试图撬开你的知识体系。很多候选人心里一喜:这题我背过。于是流利地吐出“不可变、final、常量池”几个关键词。但接着面试官追问一句:“既然不可变,为什么String str = a + b可以拼接?”如果此刻你愣住,前面所有背诵都成了笑话。基础题为何高频?因为它们根本不是送分题,而是筛选项——用来分辨你是“背过定义”还是“真正做过”。
其实这个问题背后,藏着对“不变性”(immutability)的深层理解。String的可变与不可变,不是一道语法题,而是一道权衡题。不可变带来了安全性(参数传递不担心被修改)、可缓存性(哈希值可以安全地缓存)和线程安全(多线程读不需要同步)。但代价是任何修改都会创建新对象,循环拼接字符串会浪费大量内存。为此,JVM在编译期做了若干优化,但循环内的拼接依然逃不脱创建StringBuilder的宿命。面试官想听到的,不是结论,而是你对“牺牲了什么,换来了什么”的思辨。
String的“不可变”,是一道哲学题
如果你追问“为什么String类用final的byte[]数组(JDK9)存储,而不是char[]”,很多人会卡住。JDK8以前是char[],JDK9起改为byte[],并且新增coder字段区分Latin1和UTF-16编码。这看似是性能优化,其实是JVM在内存占用与处理复杂度之间做的又一次权衡。面试中考到这种细节,说明已经进入“版本演进”的深水区。String不可变,还意味着它可以被安全地缓存和共享,这就是字符串常量池存在的基石。但请你再想一步:如果每次拼接都产生新对象,那常量池岂不是垃圾满屋?所以有了intern()主动入池,有了G1垃圾回收的字符串去重。
到这里你会发现,一个“不可变”得先回答安全、混分配、常量池、垃圾回收、编码优化五层问题。而大多数候选人只停留在第一层。“不可变”是一粒种子,能长出一棵名为“JVM与设计”的大树,这才是面试官想要你展示的东西。
equals与hashCode,一对被拆散的冤家
如果说String是Java小学题,那equals和hashCode就是中学题。原因在于,它们俩一旦没配合好,HashMap和HashSet会死得很惨。我知道很多人背过“重写equals必须重写hashCode”,但为什么?因为HashMap在找key时,先计算hashCode定位桶,再用equals检查桶内元素。假设两个对象equals比较为true,但hashCode不同,它们就会被放在两个桶里。你往HashSet里添加了一个“等值”对象,结果集合里出现了两个“重复”元素。更隐蔽的是,你在HashMap中用“等值”对象去get,返回的却是null——你的对象被彻底弄丢了。不遵守约定的hashCode,就像一把永远打不开旧锁的新钥匙,钥匙和锁明明配套,却因为编号不同被拒之门外。
为什么重写hashCode时推荐使用31?因为它是一个奇素数,可以降低碰撞;又因为JVM底层可以用移位和减法运算(即31 i == (i << 5) - i),性能相当高。但更重要的是,你要理解hashCode的“稳定性”约束:在对象作为一个键被放进HashMap后,绝不能让影响hashCode计算的字段发生改变。否则它就成了一个“游动的键”,再也找不回来。很多人栽在这个坑里,却从未想过这是基础题反复出现的原因——它考察的是你对“约定与契约”的敬畏。
HashMap:不只是数组加链表,而是“对抗最坏情况”
HashMap大概是Java面试中“最卷”的基础题。从数据结构到扩容,从红黑树到并发问题,随便一个点都能衍生出连环追问。第一层是结构:数组+链表,当链表长度超过8且数组长度达到64时,链表转红黑树。为什么选8?因为泊松分布下链表元素个数达到8的概率已经低于千万分之一,几乎不可能。换句话说,红黑树不是常态,而是为了应对“某个桶被恶意填充”的极端防线。面试官最喜欢问“为什么树化阈值是8”,如果你能答出“要基于泊松分布解释”,瞬间就拉开差距。
再看扩容:为什么初始容量是16,负载因子是0.75?因为2的幂方便位运算取模,0.75是空间和时间的折中。JDK1.7采用头插法,扩容时存在逆序和并发死循环问题;JDK1.8改为尾插法,长度大于8才树化。这些改动背后,是让HashMap在多线程并发下不至于直接挂掉,但请注意,它依旧不是线程安全的。并发下的HashMap,不是你帮我拦一下,而是一言不合就死循环给你看。
volatile:你“看见”的,只是冰山一角
volatile是并发领域最言简意赅的关键字:保证可见性,禁止指令重排序,但不保证原子性。很多人背出来很容易,可当面试官问“它和synchronized有区别”时,很多人的回答只能到“一个锁一个不锁”为止。你需要理解JMM:每个线程都有自己的工作内存,定期与主内存同步。如果没有volatile,一个线程修改了变量,另一个线程可能很久看不见。但volatile做了更多事——它在读写时插入内存屏障,禁止编译器或CPU对相关指令重排。
这解释了单例模式中的双重检查锁为什么必须用volatile修饰instance。因为看起来是“先赋值再返回”,但CPU可能重排序为“先返回再赋值”,另一个线程就会拿到一个未初始化完成的对象。这是一个经典的可见性+有序性问题。更残酷的是,volatile对i++无能为力,因为i++是“读-写-改”三步,volatile只约束了中间的内存阻隔,并未把三步锁成原子。volatile不是锁,它只是让你看见撕裂现场,但无法阻止撕裂。
synchronized:从“重量级”到“轻量级”的进化史
提起synchronized,很多人想到的就是“锁”。但如果你知道它从JDK1.6开始经历了锁升级的过程——无锁、偏向锁、轻量级锁、重量级锁——就能明白JVM为了“高效并发”有多努力。刚创建对象时没有竞争,只标记偏向锁;一旦第二个线程尝试竞争,偏向锁撤销,升级为轻量级锁(CAS自旋);若自旋失败或竞争激烈,才会膨大为重量级锁,等待内核的信号量。这是一种“乐观到悲观”的演进:默认世界是美好的,出问题才走重流程。锁升级是JVM对并发压力的“量子态”,不观测时一切从简,一旦坍缩便成为重量级。
但有趣的是,JDK15起偏向锁被废弃。原因很简单:维护偏向锁带来的性能收益已经跟不上现代应用场景的复杂性。这说明JVM本身也在不断“复盘”自己的设计。基础题之所以值得反复琢磨,是因为连底层实现都在迭代,而你如果只记住一个静态结论,很快就会被淘汰。
ThreadLocal:弱引用是避风港,还是暗礁?
ThreadLocal常用来保存线程私有数据,但它的实现机制很“拧巴”:ThreadLocalMap的Entry继承WeakReference<ThreadLocal<?>>,key是弱引用,而value是强引用。为什么key要用弱引用?如果key是强引用,那么当外部ThreadLocal对象被置为null时,ThreadLocalMap里还强引用着它,导致它永远无法被GC回收;用弱引用,key会在下一轮GC被回收。但矛盾来了:key被回收后,value成了“无主”对象,却依然被Entry强引用着,于是出现了“脏条目”内存泄漏。ThreadLocal的弱引用设计,看似灵巧,实则开启了另一场与内存泄漏的持久战。
解决之道只有一个:用完必调remove()。特别是在线程池场景中,线程被复用,如果不清理,上次任务存进去的变量会被下一次任务读到,造成严重的业务数据串线。很多生产事故就是“一个没清理的ThreadLocal”引发的。所以面试官考你ThreadLocal,往往接着问“你在项目中怎么用?有没有遇到过OOM?”其实就是想听你如何避雷。基础题之所以不能被忽视,是因为每一个坑都有人用线上故障帮你试过了。
类加载与双亲委派:打破国境线,才能做框架
双亲委派模型的逻辑是:一个类加载器收到加载请求,先让父加载器尝试,父加载器加载不了,子加载器才自己上。这样做有两大好处:防止核心API被篡改,避免同一个类被加载两次。但很多人不知道,“双亲委派”不是继承关系,而是组合委托。更关键的是,它并不是不可违反的“铁律”。JDBC就是一个典型:JDBC驱动管理者在启动时,需要加载第三方厂商驱动,但jdbc.Driver类在rt.jar中启动类加载器已经加载了,而各厂商的驱动实现却在classpath里,启动类加载器根本看不到。于是,JVM引入了线程上下文类加载器,让启动类加载器“跨过边境”去加载实现类。Tomcat更是“叛逆”,每个WebApp有独立的类加载器,优先加载自己应用下的类,为的是让不同应用能用不同版本的库。双亲委派的“国境线”上,框架们开拓了一个个“经济特区”。
这类基础题的意义在于,它教会你避免僵化思维。任何规则都是在特定背景下诞生的,当背景变化,规则也必须被打破。面试官想听你承认双亲委派的局限性,而不是把它奉为教条。
JVM内存区域:记住“户口本”不是目的,拼出“水流动线”才是
最后一道高频基础题,是JVM运行时数据区。虚拟机栈、本地方法栈、程序计数器是线程私有的;堆、方法区(元空间)是线程共享的。栈里装栈帧,栈帧装局部变量表、操作数栈、动态链接、返回地址。这些背得滚瓜烂熟并不难,难的是搞清楚运行时的“水流动线”:new出的对象优先分配在Eden区,经过Minor GC进入Survivor,年龄够大再到Old区。对象也不一定都在堆上——通过逃逸分析,如果对象不会被外部引用,JIT会将其栈上分配,甚至标量替换掉。如果你只把内存区域当成七张表格来背,那你一定没有经历过大对象频繁GC时的头皮发麻。
OOM是另一个经典追问。堆溢出是对象太多;栈溢出是递归无底洞;方法区溢出是动态生成太多类。排查OOM需要日志、MAT、jstat等工具,但这已经超出“基础”的范畴。而面试官之所以频繁问内存区域,是用它来检测你对“对象的一生”是否有整体感。了解一块地区是什么功能,远不如知道对象在这块区域中如何诞生、移动和死亡,来得重要。
面试结束,你走出大楼,那道关于String的问题还在耳边回响。你突然意识到,所有高频基础题,都像一把钥匙,打开的不是某个“标准答案”,而是一扇扇通往更深世界的大门。基础题的最大价值,在于它们每一次出现,都能让你用新的经验重新审视旧的知识。当你开始琢磨“为什么可变性如此重要”“为什么哈希约束不能违背”“为什么锁要不断进化”,你已经不是在备考,而是在构建自己的计算机世界观。这才是“反复琢磨”的全部意义。