“两个对象 equals 相等,那么它们的 hashCode 必须相等吗?”面试官抛出这个问题时,往往不是要一个简单的“是”,而是想看你能否在一秒内联想到 HashMap 的坑。基础问题之所以高频,恰恰因为它们能瞬间折射出你的知识体系是否牢固。下面这十个问题,几乎每一场 Java 面试都逃不掉,但很多人只是背了答案,却从未真正理解背后的设计哲学。
一、== 与 equals:比的不是同一个“相等”
很多人张口就说“==比较地址,equals比较内容”,这句话对一半,错一半。== 在比较引用类型时确实比的是堆内存地址,但 equals 的默认行为其实跟 == 一模一样——因为 Object.equals 就是用的 ==。只有当子类重写 equals 后,它才可能比较内容。
真正要命的是 String。String 有两个创建方式:String a = "hello"和String b = new String("hello")。前者走常量池,后者在堆中新建对象。所以a == b是 false,而a.equals(b)是 true。面试官接着问“那String c = "hello"和 a 相等吗?”这就是陷阱:字符串字面量会先在常量池中找,找到就直接复用,所以 a == c 是 true。
深入一层,你该理解equals 重写必须同时重写 hashCode。因为所有基于哈希的集合(HashMap、HashSet)都依赖 hashCode 先定位桶,再用 equals 精确比较。如果你只重写 equals 不重写 hashCode,就会导致两个逻辑相等的对象散落在不同桶里,get 时永远查不到。这不仅是规范,更是哈希表的底层逻辑决定的。
更进一步,面试官常问“为什么 String 重写了 equals?”因为字符串在业务中大量用作唯一标识。你重写equals时,要遵循自反性、对称性、传递性、一致性,以及“非 null 对象 equals null 必须返回 false”。这些看似枯燥的细则,恰恰是你在团队里写领域对象时最容易踩的坑。
二、final 关键字:修饰的不是“不能变”,而是“引用不变”
“final 数组可以修改吗?”这个问题能过滤掉一大半只会背口诀的人。final 修饰基本类型意味着值不可变,修饰引用类型意味着引用指向不可变,但引用对象的内部状态完全可变。所以 final int[] arr 里,你可以修改 arr[0],但不能再让 arr 指向新数组。
final 真正的作用是“约束变化”。修饰类时禁止继承,修饰方法时禁止重写,修饰变量时保证初始化后不再重新赋值。但很多人忽略了 final 与并发内存语义的关系:final 字段在构造器内初始化后,其他线程无需同步就能看到该对象的安全发布。因为 JMM 对 final 提供了特殊的“冻结”语义。
更实际的场景是:String类本身被 final 修饰,这保证了它的不可变性。不可变性是 String 可以安全作为 HashMap 键的基石——如果 String 可变,哈希值会随内容变化,HashMap 的查找就彻底乱套。所以面试官问你“为什么 String 被设计成 final?”你不仅要回答安全、常量池复用、缓存哈希,还要点出它与集合框架的协同关系。
三、String / StringBuilder / StringBuffer:别只看线程安全
大多数人回答“StringBuffer 线程安全,StringBuilder 线程不安全,String 不可变”,但这是教科书式的平庸答案。面试官想听到的差异在于“拼接字符串时到底发生了什么”。
String s = "a" + "b" + "c"在编译期会被优化为常量折叠,直接生成 "abc"。但循环内拼接s += i,每次循环都会 new 一个 StringBuilder 执行 append 再 toString,产生大量中间对象。性能灾难的根源不是 StringBuilder 比 StringBuffer 快,而是循环拼接本身在反复创建对象。
StringBuffer 的线程安全来自每个方法加 synchronized 锁,但这意味着任何拼接操作都要走锁的获取与释放,单线程下性能反而下降。所以现代 Java 面试中,一个更锋利的回答是:单线程环境坚决用 StringBuilder,多线程环境下如果只是各自拼接各自的字符串,仍然用 StringBuilder——因为局部变量天然线程隔离,只有多个线程共享同一个 StringBuilder 对象时才需要 StringBuffer。
另一个冷门考点是String.join和StringBuilder.append的区别:前者要求所有元素是 String,后者可以直接 append int、long 等基本类型。面试官可能追问“为什么 String 拼接 + 操作在 JDK9 后不是用 StringBuilder 了?”答案是 JDK9 引入了 invokedynamic 动态拼接,生成的字节码更智能。能讲到这个层次,说明你关注语言演进而不是死背书。
四、HashMap 底层:从数组到红黑树的进化
这是一个“逢面必问”的问题,也是能区分初级和高级的分水岭。HashMap 的核心是“数组 + 链表 + 红黑树”,但很多人说不清为什么链表转红黑树的阈值是 8。
数组用来做哈希桶,hash 后定位到桶下标;当多个 key 哈希碰撞,放到同一个桶里形成链表。链表长了之后查找效率降为 O(n),所以当链表长度达到 8 且数组容量不小于 64 时,链表会转为红黑树,查找效率升为 O(log n)。为什么是 8?官方注释里解释过:遵循泊松分布,在随机哈希下,链表长度达到 8 的概率约为千万分之六,这是“空间与时间的权衡”。
更深入一层,HashMap 的扩容机制是“按位与”而不是取模。因为容量总是 2 的幂次方,(n - 1) & hash等价于hash % n,但位运算更快。扩容时旧元素要么留在原索引,要么移动到“原位置 + 旧容量”的新索引,这个设计巧妙利用了容量翻倍后最高位的变化。
面试官还会问“为什么 HashMap 允许 null 键,而 Hashtable 不允许?”因为 HashMap 对 null 做了特殊处理——null 键的 hash 值定为 0,所以它总是放在数组第一个桶里。Hashtable 的设计是线程安全的旧类,它不允许 null 是因为其contains方法语义模糊。可以说HashMap 对 null 的支持体现了“实用主义”,而 Hashtable 的严格只是历史包袱。
最后,初始容量和负载因子直接影响性能。默认负载因子 0.75 是时间和空间的折中,过高会降低空间浪费但增加查找成本,过低则相反。如果你能预估数据量,应该显式设置初始容量,避免扩容时反复拷贝数组。面试官最反感的是那种“知道默认值是 0.75 但不知道为什么”的回答。
五、重载和重写:看起来是语法,其实是多态的两种实现
“重载是编译期多态,重写是运行期多态”——这句话可以背,但你要能解释“为什么”。重载发生在同一个类中,多个方法同名但参数列表不同,JVM 在编译时就能根据传入参数的类型确定调哪个方法,这叫静态分派。重写发生在子类和父类之间,方法签名完全一样,JVM 运行时才根据对象的实际类型调用对应方法,这叫动态分派。
一个非常容易掉进去的坑是“重载时 null 参数调用哪个方法”。假设有两个方法f(String s)和f(Integer i),调用f(null)会编译报错吗?不会。因为 null 可以匹配任何引用类型,编译器会优先选择“最具体”的类型——String 和 Integer 都是 Object 的子类,它们之间没有继承关系,所以编译报歧义错误。这题考察的是你对“类型精度”的理解。
重写需要注意的细节更多:重写方法不能抛出比父类方法更宽泛的受检异常,但可以抛出更窄的异常或运行时异常;重写方法的访问权限不能比父类更严格——父类是 protected,子类不能降级为 private。这些限制的本质都是为了保证“里氏替换”——子类对象必须能完全替代父类对象出现在任何代码中。
面试官常问“重写是否必须保持返回类型一致?”Java 5 以后引入了协变返回类型,子类可以返回父类返回类型的子类型。比如父类返回 Animal,子类可以返回 Dog。这也是符合里氏替换的。真正体现功底的是你说出“重写是运行时多态的实现基础,而运行时多态又是设计模式(如策略模式、模板方法)的基石”——这会把问题从语法层面拉高到设计层面。
六、接口和抽象类:别再说“接口不能有实现”了
“接口只能定义常量,不能有方法体”——这句话在 Java 8 之后就已经过时了。默认方法和静态方法让接口可以携带具体实现。但面试官问这个问题,核心不是考语法更新,而是考“你如何做出设计选择”。
抽象类捕捉的是“是什么”,接口定义的是“能做什么”。抽象类代表的是类层次中的共性,它有构造器、有属性,可以维护状态;接口则完全强调行为契约,不能有实例字段(只能有常量),即便有默认方法也无法使用非静态字段。所以当你需要在一组类之间共享代码、且它们有明确的继承关系时,用抽象类;当你需要让完全不相关的类都能“做某件事”时,用接口。
另一个关键差异是“单继承与多实现”。Java 类只能继承一个抽象类,但可以实现多个接口。接口的多实现让 Java 在保留类型安全的前提下实现了“多重继承”的能力,这也是组合优于继承的一种体现。面试官可能追问“接口里的 default 方法会不会导致菱形问题?”答案是:如果一个类实现了两个接口且两个接口都有相同签名的方法,那个类必须重写该方法消除歧义,否则编译报错。
更进阶的思考是:抽象类很难在没有破坏封装的情况下演进,而接口默认方法可以向后兼容。比如 JDK 在 Collection 接口中增加removeIf默认方法,所有现有实现类无需修改就能获得新行为。这告诉我们在 API 设计时,接口的演进策略比抽象类更灵活——但代价是接口不能持有状态,许多需要状态的方法实现起来很别扭。
七、线程创建的几种方式:你以为四种,其实只有一种
教科书说线程创建有四种:继承 Thread、实现 Runnable、实现 Callable(配合 FutureTask)、用线程池。面试官现在更爱问“你说这些,底层都是什么?”实际上,所有方式的终点都是 new Thread(),然后调用 start() 让内部的一个 Runnable target 启动。即使你用 ExecutorService,它内部仍然是用 Thread 包装你的任务。
继承 Thread 的坏处是:Java 单继承,一旦继承 Thread 就不能继承其他类,且重写 run 方法把任务代码和线程生命周期耦合了。实现 Runnable 接口是更规范的方式,因为它把“任务”和“执行”分离。Callable 的区别是它有返回值、能抛受检异常,配合 FutureTask 可以获取异步计算结果。
线程池作为“高级创建方式”,本质是复用一堆 Thread。面试官常问“线程池内部是怎么触发的?”ThreadPoolExecutor 的 execute 流程:先检查核心线程数,不足则新建核心线程;满了进入阻塞队列;队列也满了则创建非核心线程;都满了执行拒绝策略。核心问题在于你是否理解“线程池里任务真正执行者仍是线程,而池只是管理器的外壳”。
更进一步,聊聊虚拟线程:JDK 21 正式落地了虚拟线程,它不再绑定平台线程,而是由 JVM 调度在载体线程上。那时候“创建线程”的成本变得极低,但 IO 密集型的并发模型会被彻底颠覆。如果面试官问“你会怎么创建线程”,你从传统方式讲到虚拟线程,就说明你在持续跟踪 Java 的演进。
八、synchronized 与 Lock:锁的“重量”与“灵活”之争
从 JDK 1.0 就有 synchronized,而 Lock 是 JDK 5 引入的。一个常见的误区是“synchronized 是重量级锁,Lock 是轻量级锁”。其实 synchronized 经过多次优化,已经从不公平的悲观锁演进为偏向锁、轻量级锁、重量级锁的多级膨胀过程。而无竞争时,synchronized 的开销可能比 Lock 还低。
区别核心在于:synchronized 是 JVM 级别的关键字,退出同步块(方法)时自动释放锁,线程等锁时不可中断、不可超时,也不支持多条件队列。而 Lock(指 ReentrantLock)是 API 级别的锁,必须手动 lock/unlock,还要在 finally 中释放,但带来了可中断等待、可设置公平锁、支持多个 Condition 和 tryLock 尝试非阻塞获取锁的能力。
一个细节是 synchronized 是非公平锁,ReentrantLock 默认也是非公平锁,但可以构造时传 true 设为公平锁。公平锁保证线程按请求顺序获取锁,避免了“线程饥饿”,但也引入了上下文切换和排队开销。面试官可能追问“公平锁一定比非公平锁慢吗?”不一定,如果临界区极短,非公平锁的“插队”反而减少了线程挂起的浪费。锁的选择永远是对并发强度、临界区长度和业务公平性需求的权衡。
另一个深水区是“锁消除、锁粗化、偏向锁的机制”。比如 JIT 编译器发现局部对象锁不可能被其他线程访问,就会自动去掉锁;连续对同一个对象加锁解锁,会合并成一次大锁。能说出这些名词,说明你理解 synchronized 不是死的,而是可以优化的活代码。
九、JVM 内存区域:哪些是线程私有,哪些共享?
这个问题考的是你对运行时数据区的划分。JVM 内存分为线程私有和线程共享两大类。私有区域:程序计数器、Java 虚拟机栈、本地方法栈。共享区域:堆、方法区(在 HotSpot 中通常称为元空间)、运行时常量池(逻辑上属于方法区)。
每个区域的“异常”也很关键:虚拟机栈溢出会抛 StackOverflowError,堆里无法再分配且无法扩展时抛 OutOfMemoryError。程序计数器是唯一不会 OOM 的区域。理解栈和堆的分工,才能真正理解值传递与引用传递的悖论——方法参数是基本类型和引用变量的副本,但引用所指向的对象住在堆里,所以“改对象属性”在方法内生效,而“换引用”不会传出方法。
更有深度的点是“堆内对象的逃逸分析”。如果 JVM 确认一个局部对象没有逃逸出方法,它会把这个对象分配到栈上,随栈帧销毁而消失,避免 GC。所以“new 的对象一定在堆上”这句话在技术上并不严谨。现代 JVM 在“空间分配”上越来越智能,你需要知道这种优化存在,才能在讨论性能时提到逃逸分析与标量替换。
方法区在新版 JDK 中变成了元空间,它不再使用堆内存,而是使用本地内存。这意味着 PermGen 时代常见的“永久代 OOM”变成了可能的内存泄漏风险,但调整参数也从-XX:MaxPermSize变成了-XX:MaxMetaspaceSize。面试官常问“常量池在哪?”字符串常量池在堆中,而类文件常量池(静态常量池)在方法区。这两个概念混淆是高频失误点。
十、类加载过程:从加载到初始化,每一步都不是“刚刚好”
类加载分为五个阶段:加载、验证、准备、解析、初始化。很多人能背出顺序,但说不清“准备”和“初始化”的区别。准备阶段为类变量分配内存并设置零值,而初始化阶段才执行类构造器<clinit>方法,此时才把显式赋值的常量和静态块真正放进去。
一个经典考题:private static int a = 10;在准备阶段 a 是多少?答案是 0,不是 10。因为准备阶段只负责“零值”,真正的 10 要等初始化阶段执行 putstatic 指令。除非变量被 static final 修饰,而且类型是基本类型或 String,那它会在准备阶段就变成常量,直接写入类文件常量池——这也解释了为什么常量不需要等待初始化就能访问。
双亲委派模型是本问题的重头戏。类加载器收到加载请求时,先让父加载器尝试加载,只有父加载器加载不到才自己加载。这样保证了核心 API 不会被用户自定义的类所篡改,比如你自己写一个java.lang.String,无法在应用层替换掉 JDK 的 String。破坏双亲委派的典型场景是 SPI(服务提供者接口),比如 JDBC 驱动管理器需要由根类加载器加载,却要调用厂商提供的驱动类,只能通过线程上下文类加载器反向委派。
更深一层是“类在什么时候被加载?”不是一编译就加载,而是“主动使用”时触发初始化。主动使用包括 new 对象、访问静态字段、调用静态方法、反射、初始化子类导致父类初始化等。被动使用(比如通过数组定义、引用父类静态变量但通过子类名引用)不会触发子类初始化。面试官如果问“final 常量时子类会不会初始化?”答案是不会,因为常量被内联到了 Main 类里。
如果你把类加载过程讲成“加载、验证、准备、解析、初始化”五个步骤,那是及格;讲出“准备阶段给零值,初始化阶段给真值”,那是良好;再讲到“双亲委派如何防篡改、如何被 SPI 破坏”,那就是优秀。面试官看重的不是你记住的名词,而是你能不能把每个阶段的行为和后果串联起来。
十道题目、十个层面,真正的分水岭不在深度,而在“每个点能否继续向下挖三层”。如果能在基础问题上主动暴露细节、主动连接上下文、主动给出反例,你已经不是一个背答案的候选人,而是一个有系统思维的程序员。下一次当面试官再问“String 的 equals 和 hashCode 有什么关系”,试着从常量池讲到 HashMap,再讲到重写约束——你会看到对方的眼神从平淡变成亮光。