面试官抛出“String是引用类型吗”这种问题,你以为能敷衍过去,对方却在下一秒追问“那String str = new String('abc')和String str = 'abc'创建了几个对象?”——很多人当场翻车。这类看似基础的Java题,恰恰是面试官最喜欢的深挖点,因为它们能瞬间测出你是背答案还是真理解底层。
常量池陷阱:String的不可变性只是个幌子
面试官从来不问String能不能变,他们问的是“intern()方法到底干了什么”。默认情况下,String s1 = "abc"会先去字符串常量池里找,找不到才创建。而String s2 = new String("abc")直接堆里新建,不管常量池有没有。更恶心的变体是“String s3 = s1 + s2”和“String s4 = "a" + "b" + "c"”的区别:前者运行时拼接,调用StringBuilder;后者编译期直接折叠成常量“abc”。
关键点在于:常量池里只保存字面量,new出来的对象哪怕内容相同,也是独立堆内存。而intern()方法能做到“手动入池”——如果池中有同值字符串,返回池中引用;否则在池中创建并返回引用。这个机制直接关联到JVM的元空间或永久代,不仅考语法,还考内存分区。
面试官常会继续追问“String、StringBuilder、StringBuffer的适用场景”。很多人答“StringBuffer线程安全”,但紧接着就被问“为什么StringBuffer安全而StringBuilder不安全?具体哪一行代码不安全?”——最经典的答案是,StringBuffer的每个append方法都加了synchronized,但如果你连续调用两次append,中间没有锁,线程依然可能被插队。所谓的“线程安全”仅指单个方法的原子性,而非复合操作的原子性。
Integer缓存:那个-128到127的坑
Integer的面试题是“经典送命”题:Integer a = 127; Integer b = 127; a == b 是 true 还是 false?改成128就变false。关键在于Integer类内部维护了一个IntegerCache,默认缓存-128到127的Integer对象。自动装箱(Integer.valueOf())会返回缓存对象,所以==比较引用时相同;超过范围则new新对象,引用不同。
面试官会接着问“那你觉得Integer a = 1; Integer b = 1; a == b 的结果跟编译器版本有关系吗?”其实跟版本关系不大,跟缓存上限有关。可以通过JVM参数-XX:AutoBoxCacheMax=<size>增大上限,但建议不要乱改。更深一层,面试官可能考“Long、Short、Character有没有缓存?”——LongCache范围也是-128~127,CharacterCache是0~127,Double和Float没有缓存(因为不是一个整数范围概念)。
还有一个变体:Integer.valueOf(128) == Integer.valueOf(128)是false,但int a = 128; Integer b = 128; a == b?答案是true,因为自动拆箱后int比较值。这些细节翻来覆去考的就是你对自动装箱/拆箱和对象缓存的理解深度。
集合类:HashMap的扩容死链只是入门
“HashMap是线程安全的吗?”——当然不。但面试官要的不是“不安全”,而是“为什么并发下会形成死循环”。JDK 1.7的HashMap在rehash时采用头插法,多线程同时put触发扩容,可能导致环形链表,get时死循环。JDK 1.8改用尾插法解决死循环,但依然有数据覆盖问题:多线程put时可能互相覆盖,导致数据丢失。
更深层的问题是“HashMap扩容阈值为什么是0.75?不是0.5或1.0?”这涉及到泊松分布与哈希冲突的概率权衡:0.75时,桶中链表长度超过8的概率极低(约千万分之一),空间与时间达到平衡。0.5太浪费空间,1.0又会导致高冲突。
面试官还喜欢问“ConcurrentHashMap怎么实现线程安全?”JDK 1.7用Segment分段锁,每段一个ReentrantLock;JDK 1.8改用CAS + synchronized锁住链表或红黑树的头节点。注意JDK 1.8的ConcurrentHashMap的size()方法不再需要加锁,而是通过CounterCell和LongAdder累加器实现高并发计数。没有理解这个演化过程,就会觉得“synchronized比ReentrantLock更简单”是倒退,实际上分段锁在大量线程竞争下反而会导致竞争概率上升。
ArrayList vs LinkedList:你背的结论可能是错的
很多人被教育“ArrayList适合随机访问,LinkedList适合插入删除”。面试官会反问:“那在头部插入100万条数据,谁更快?”答案是ArrayList更慢?不对,实际测试中ArrayList的批量插入(比如使用addAll)可能比LinkedList快,因为LinkedList每个节点都要创建Entry对象且频繁修改指针,而ArrayList只是随机移动一次内存。真正决定性能的是插入位置和元素个数,而非数据类型。
还有一个细节:ArrayList的ensureCapacity方法。如果你预先知道要插入大量数据,先调用ensureCapacity可以避免多次扩容复制。面试官可能让你手写一个“动态扩容数组”,看你能不能写出正确的grow策略——通常是1.5倍,且要处理溢出情况。
异常体系:checked exception是糟粕还是精华?
“RuntimeException和Exception有什么区别?”很多人回答“运行时异常不需要try-catch”。面试官会追问:“那你觉得写代码时应该尽量用检查异常、自定义异常还是只在运行时抛?”经典误区是认为检查异常增加了代码健壮性,但大量实践表明,过度使用checked exception会让业务代码被try-catch淹没,降低可读性。比如Spring、Hibernate等框架普遍倾向于抛非检查异常(如DataAccessException、BeanCreationException),因为调用方同样无法理性处理每一个SQL异常。
面试官还会深问“try-with-resources的底层原理”。你如果只知道自动关闭是因为实现了AutoCloseable,那不够。真正原理是编译器会将try-with-resources编译成字节码,用try-finally嵌套,并且捕获的异常被标记为“被抑制的异常(suppressed)”。了解suppressed异常对于调试多资源关闭场景非常重要,比如两个资源都抛出异常,如果不处理suppressed异常,只会得到最后一个异常,前一个异常被丢弃。
线程基础:wait和sleep的区别不止一个
“Object.wait()和Thread.sleep()有什么不同?”最浅的回答是“wait释放锁,sleep不释放”。但面试官会深究“notify之后,wait线程马上能获得锁吗?”——notify只是把线程从等待集移到锁竞争队列,真正获得锁还需要等到持有锁的线程退出同步块。这也是为什么在synchronized块里notify之后,当前线程仍可能继续执行后续代码,因为锁还没有释放。
另一个经典问题是“为什么wait、notify必须在synchronized块里调用?”因为wait需要先检查条件(比如队列满),而条件的变化可能被另一个线程同时修改,如果不加锁,会出现“虚假唤醒”(spurious wakeup)。Java官方文档中明确要求wait在循环中调用(while(condition) wait()),而不是if,就是因为虚假唤醒的存在。
JVM内存模型:堆栈溢出问题不是靠调参数解决
“栈溢出和堆溢出怎么定位?”面试官喜欢问具体的工具链。栈溢出通常是因为递归调用过深或线程栈空间太小(可以通过-Xss调整)。但更深入的问题是“一个Java线程的栈默认多大?”通常是1MB,但在32位系统只有256KB。面试官会让你估算“一个递归调用大概消耗多少栈帧”:每个方法调用会压入局部变量表、操作数栈、动态链接、方法出口等,大概几百字节到几K字节不等。
堆溢出通常用分析heap dump(jmap + jhat或MAT)。但很多人不知道如何触发堆溢出——用-Xmx设置很小的堆,然后不断创建大对象并保持引用。面试官会追问“如果堆内存被占满但GC一直没有触发,是什么情况?”可能是因为对象都属于不可回收的(如static集合),GC无法回收,导致频繁Full GC反而性能下降。真正的深度在于理解GC算法和内存泄漏的常见模式,比如ThreadLocal使用不当导致内存泄漏、ClassLoader未卸载导致元空间泄漏等。
反射和代理:动态代理和CGLIB的底层差异
“JDK动态代理为什么只能代理接口?”因为JDK动态代理在运行时生成一个继承自Proxy的类,该类实现了你指定的接口,但如果目标类没有接口(就是普通类),就无法实现。而CGLIB直接通过ASM字节码生成目标类的子类,所以能代理普通类。区别在于:CGLIB对final方法无法代理(因为不能覆盖),而JDK动态代理只能用接口。面试官会继续问“Spring如何选择使用哪种代理?”如果类实现了至少一个接口,默认使用JDK动态代理;否则用CGLIB。但可以强制启用CGLIB。还要注意,CGLIB生成的代理类会多出一个MethodProxy对象,比JDK动态代理的性能稍高,但创建过程较慢。
另一个深挖点是“反射调用的性能为什么差?”因为反射需要检查可见性、参数类型,并且每次调用都要进行方法查找。JVM对反射有一定的内联优化(如sun.reflect.MethodAccessor接口),但超过一定次数后会自动生成字节码来提升性能(阈值默认是15次)。这些细节如果不清楚,就无法解释为什么在极热路径上应避免使用反射。
并发工具类:CountDownLatch和CyclicBarrier哪个是“一次性”?
“CountDownLatch和CyclicBarrier有什么区别?”标准答案是:CountDownLatch是一个线程等待多个线程完成,只能使用一次;CyclicBarrier是多个线程互相等待,可以用reset()重置。面试官追问的重点是:CyclicBarrier的reset()会导致正在等待的线程抛出BrokenBarrierException,如果你没处理这个异常,程序可能挂掉。再有就是CountDownLatch的await()可以带超时,CyclicBarrier也可以带超时但会破坏屏障。
另一个常考的是Semaphore,“acquire()和release()一定要成对使用吗?”——如果release()调用次数多于acquire(),会导致许可数量增加,下个线程不需要获取许可也能通过。这种许可泄漏在生产环境中是一个非常隐蔽的bug。
序列化:transient和static修饰的字段会怎样?
“实现Serializable接口的类,如果某个字段用transient修饰,序列化时会怎样?”理所当然是不被序列化。但面试官会接着问“那如何自定义序列化?”你可以实现writeObject和readObject方法,或者扩展Externalizable接口。更深的问题是序列化版本号——serialVersionUID。如果没有显式声明,编译器会自动生成,但类结构一旦变化(比如新增字段),生成的值会变,导致序列化兼容性问题。所以最佳实践是显式声明一个固定的UID。
一个更冷门的知识:枚举类型的序列化。Java枚举默认实现了Serializable,但反序列化时不会创建新的对象,而是使用枚举常量本身(通过valueOf方法查找)。这保证了枚举的单例性,即使有多个实例也是同一个引用。而普通的单例类如果不做特殊处理(比如readResolve方法),反序列化时会打破单例,创建新对象。
结尾:面试官为什么就爱深究?
因为基础题最能体现一个人对语言的敬畏程度。比如“equals和hashCode的约定”,很多人知道要重写两者,但没考虑过“如果一个对象参与HashMap,且之后该对象的某些字段变了,会导致哈希码改变,而HashMap使用原哈希码存放,用新哈希码查找时找不到对象”。这就是内存泄漏的典型场景——把一个可变对象作为HashMap的key,然后在外部修改它的字段。面试官通过设问这些“小细节”,实际上是在考察你的工程直觉是否养成。
如果你能把上述每个点都讲清楚、举出实例,甚至能说出JDK不同版本的细微差异,那面试官就不会再在基础环节追问下去。相反,若你停留在“String是不可变的”或“HashMap是数组+链表”的层面,下一个问题就是:“那红黑树什么时候开始退化?”——大多数人又卡住了。所以,真正的深度不在多复杂的框架,而在于你对自己写下的每一行代码都抱有底层好奇。