把Java面试题拆开揉碎了看,无非是在检验一件事:你是否真的理解计算机底层在你写的每一行代码背后做了什么。面试官问“String和StringBuilder的区别”,不是听你背“不可变、可变”,而是想听到字符串常量池、char数组的复制、拼接时的性能损耗这些背后逻辑。真正的复习不是刷几十个题,而是一条主线贯穿下去:从基础语法到JVM内存模型,再到并发编程,其实都在追问同一件事——你写下的代码,运行时究竟怎么被处理?
形态各异的“基础”——从语法到思想
被问到面向对象三大特性时,几乎人人都能说出“封装、继承、多态”,但紧接着的追问往往挂掉一半人:“重写和重载有什么本质区别?”只会背答案的人说“方法名相同,参数不同,或子类覆盖了父类的方法”,而真正理解的人会回答——重写是运行时多态的体现,重载在编译期就已盖上印章。重写发生在运行时,只有到了执行那一刻才根据实际对象类型确定调用谁,它的底层是虚方法表分派。再往下问,“为什么构造方法不能重写?”因为构造方法在字节码层面对应invokespecial指令,不参与虚方法分派,何况它连返回值类型都没有,谈不上“覆盖”。基础面试,考的就是这种“语法背后的原因”。
Object类也是绕不开的暗礁。equals和hashCode的契约几乎每场都考,不重写hashCode的equals是一场事故:当对象放入HashMap时,哈希桶定位依赖hashCode,equals用来在同桶内查找真正匹配的key。如果两个equals相等的对象hashCode不一致,你永远找不到“对方”。更隐蔽的是写“覆写hashCode”时,不要用随机值,也不要直接返回对象内存地址,而必须保证相等对象有相同哈希。这背后不只是技术细节,更是你用实体对象做key时无法重现数据的血泪教训。另外,好多人分不清深拷贝与浅拷贝的底层差异,其实一句话就能看清:深拷贝是复制出独立的值,浅拷贝复制的是通往值的路。
集合框架——数据结构与工程妥协的集散地
语法基础揭开后,集合就变成迎面而来的硬菜,而HashMap拥有无可撼动的地位。HashMap的底细就是你对哈希表和红黑树的掌握程度。要能说清数组+链表+红黑树为何存在,为什么链表长度达到8且数组容量达到64才树化——一方面哈希碰撞符合泊松分布,链表长度到8时概率已经极低;另一方面树化是为了将最坏情况从O(n)降为O(logn)。还要知道扩容时的resize机制:旧数组里的节点要么原位不动,要么移动到“原索引+旧容量”的新位置,因为每次扩容都是翻倍,节点是否迁移取决于扩容新增的那一位是0还是1。
很多面试者背得出“JDK 8因为头插法会产生环形链表所以改为尾插”,却答不出更深的道理:并发下的HashMap没有安全可言,它只保证单线程内的语义。改成尾插只是让链表复制不再制造死循环,但多线程同时put时,数据丢失、size统计错乱依然存在。因此不要问“HashMap线程不安全是不是bug”,它设计之初就是单线程工具,硬要用在多线程环境下,那是你的选择问题。线程安全的Map,请找ConcurrentHashMap,它用CAS加synchronized锁桶,把竞争粒度压到极小的范围。
ArrayList和LinkedList的对比是另一个经典陷阱。许多人只答“数组连续内存、链表不连续”,听起来没错,却没有说出真正影响性能的是内存局部性与CPU缓存预取。ArrayList的扩容背后是数组复制,而LinkedList的每次get(index)都要从头遍历,所以在绝大多数真实业务场景里ArrayList完胜。LinkedList唯一有优势的场景是已知能在中间插入删除、且插入点已经通过迭代器定位到的时候。至于for-each循环里删除元素会抛ConcurrentModificationException,要知道那只是迭代器维护的modCount在报警:ModCount不是安全机制,它只是bug的报警器,告诉你结构被意外修改了。真要多线程遍历修改,要选弱一致性的CopyOnWriteArrayList或ConcurrentHashMap作为容器,它们放弃了对结构修改的“实时一致性”,换来了更宽松的并发遍历。
JVM——把内存摆上台面
集合再往下挖一层就撞见JVM。面试官问“堆、栈和方法区有什么区别”,不要背完定义就停。Java程序员不会谈JVM,就像司机不懂发动机一样危险。栈管程序如何执行,每个线程有自己的栈,里面有局部变量表、操作数栈、动态链接、返回地址;堆管对象实例,是垃圾回收的主战场;方法区存类型元数据、静态变量与常量池。到了Java 8,永久代被元空间取代,不再占用堆内存,而是使用直接内存——原因正是给字符串常量池等更灵活的空间,也避免了永久代OOM。还要想明白一条映射链:局部变量在线程栈上是私有的,所以基本类型不存在跨线程共享的问题;对象实例在堆上天然共享,所以它的成员变量成为并发访问的源头。
垃圾回收机制要有自己的一套推理。GC不是做完一次就完事,而是“分代”的艺术。新生代对象朝生夕死,适合复制算法,用Eden和两个Survivor分区周转;老年代对象存活率高,不适合复制,所以改用标记清除或标记整理。判断对象存活的核心算法是可达性分析,从GC Roots出发能找到的才算“活着”。这里常被追问:“哪些对象能当GC Roots?”答案是栈帧中局部变量引用的对象、静态变量引用的对象、被native方法引用的对象、JNI引用等。紧接着类加载就要出场——双亲委派不是规矩,是保护,它防止用户篡改JDK类库。当你试图定义java.lang.String并加载时,AppClassLoader会一路向上委派,Bootstrap拒绝加载非系统的“java.”开头的用户类并抛出SecurityException。Tomcat为什么能同时布多个不同版本的同名类?因为它自己实现了WebAppClassLoader,在特定范围打破了双亲委派。但记住,打破双亲委派是特定场景的妥协,不是日常炫技的理由。
并发编程——终极试金石
走到并发编程这里,很多候选人的差距就彻底拉开了。并发三特性——可见性、有序性、原子性,是所有并发知识的第一性原理。synchronized靠锁同时保证三者;volatile只能保证可见性和有序性,却无法保证复合操作的原子性。volatile不是锁,它只是让你“看见”别人改了什么——CPU层面的缓存一致性协议会让被修饰的变量更新穿透到主内存,还会禁止相关指令重排列。但“看见”和“抢到”是两码事:你看见i变成5,另一线程也看见i是5,然后同时执行i+1,最后写回仅加了一次。这就是为什么用volatile操作计数器是典型的并发错误。那么什么时候该用volatile?状态标志位、单例模式里的double-checked locking,这些写入后立刻被其他线程读取、且不依赖旧值的场景才合适。
说到锁,JVM对synchronized的优化值得细品。偏向锁假设只有一个线程进房间,于是不设真正锁,只在对象头里打标记;一旦竞争变多,升级为轻量级锁,用CAS自旋等待;自旋过头才升级为重量级锁,挂起线程。这套升级路径说明JVM在拼命地减少锁阻塞。而java.util.concurrent包下的Lock,其强大源自AQS。Lock的精髓在于AQS里那个state状态的争夺与等待。ReentrantLock每次lock/unlock都会加减一个state值,用它记录可重入次数;CAS成功就持有锁,失败则进入FIFO等待队列。只有懂得state、等待队列、条件变量之间的协作流程,才算是真懂Lock,也才能解释公平锁和非公平锁的实现差异——非公平锁一上来先抢一次state,抢不到才乖乖排队。
线程池作为压轴题,很少有人真讲透。七大参数背下来并不难,难的是理解任务提交的完整路径:线程数低于corePoolSize就新建核心线程,否则任务插入工作队列等待;队列满后继续把线程数提升到maximumPoolSize,如果队列仍然满且线程数已达上限,才触发拒绝策略。线程池不是越多越好,队列满了就拒绝的机制才是精髓,因为这保证你在背压之下不会无限制地消耗系统资源。不少人喜欢死记“CPU密集N+1、IO密集2N”的公式,但这只是僵化经验,真实世界还要看任务是否阻塞在外部依赖、锁竞争程度、GC频率等。如果追问ThreadLocal,你得马上意识到它在ThreadLocalMap中用的是弱引用key,可一旦线程存活时间长,比如线程池里的常驻线程,value就会一直沿着强引用链无法回收。合适的做法是每次用完调用remove,而不是依赖弱引用或手动置空来完成清理。
回顾这一整条路线,基础语法、集合框架、JVM、并发编程并不是四座孤岛,而是层层递进的因果链。你在某个环节脱口而出的“为什么”,往往能在另一个模块中找到更本质的答案。与其焦虑地背诵一长串清单,不如问问自己是否愿意沿着一个“为什么”深挖下去。从基础到并发是一条完整链路,没有一蹴而就的“清单”,只有持续挖深“为什么”的习惯。当你能把这些概念串成一个能自洽解释“代码如何运行”的故事,面试官问什么,其实已经不重要了。