大厂Java面试中容易忽视的基础问题
“你这段代码,线上一定会出问题。”面试官轻描淡写的一句话,往往就能让很多自诩“熟练使用Java”的候选人当场破防。能说清楚Spring的IOC容器,能背出JVM的GC算法,却在最基本的String比较、Integer缓存、异常处理流程上栽了跟头。大厂面试从来不缺难题,但真正拉开差距的,恰恰是那些看似简单、实则暗藏陷阱的基础问题。很多候选人在准备时热衷于刷算法、啃源码,却忽略了语言最底层的细节,而这些细节,正是面试官用来检验“你是否真的理解Java”的试金石。
一、String的“不可变性”真的那么绝对吗?
几乎所有人都知道String是final类,内部用char数组(JDK9后是byte数组)存储,并且没有提供修改数组内容的方法,所以String是不可变的。但面试官会追问:“那反射能修改吗?”接着,他会让你写出以下代码的输出:
String s = "Hello"; Field field = String.class.getDeclaredField("value"); field.setAccessible(true); char[] value = (char[]) field.get(s); value[0] = 'M'; System.out.println(s);
答案是“Mello”。String的“不可变性”是一种设计约定,并非绝对的物理限制。反射可以绕过访问控制修改私有字段,从而导致字符串内容变化。这就引出了更深的问题:为什么JVM还要坚持字符串常量池和不可变性?因为安全、线程安全、缓存哈希码等优势。面试官真正想考察的,是你是否理解不可变性设计背后的权衡,以及是否知道反射这种“暴力手段”的存在。另一个高频陷阱是“+”拼接字符串的效率:循环中使用+会创建大量StringBuilder对象,但编译期优化到底能优化到什么程度?JDK9之后引入了invokedynamic和StringConcatFactory,不再是简单的new StringBuilder()。很多候选人在循环中拼接字符串时依然认为编译器会自动优化,实际上循环外常量折叠可以,循环内每次都生成新对象。
二、Integer缓存池:你以为的“值比较”其实是在比地址
Integer a = 100, b = 100; Integer c = 200, d = 200; System.out.println(a == b); // true System.out.println(c == d); // false
这个经典题目几乎已经被说烂了,但仍有大量候选人只记得“-128到127缓存在常量池”,却说不清底层实现。Integer.valueOf()方法里有一个内部类IntegerCache,默认缓存范围是-128到127,可以通过JVM参数-XX:AutoBoxCacheMax调整上限。自动装箱本质上是调用valueOf(),所以Integer a = 100等价于Integer a = Integer.valueOf(100)。面试官可能会接着问:“那new Integer(100) == 100呢?”答案是true,因为当包装类与基本类型比较时,包装类会自动拆箱。而new Integer(100) == new Integer(100)返回false,因为两个不同的堆对象用==比较的是地址。真正容易忽视的是:Long也有缓存,范围也是-128到127;Short、Byte同样有;Boolean直接缓存两个单例true和false;但Float和Double没有缓存,因为它们没有常用的整数范围。另外,Integer与int比较时拆箱,但Integer与Long比较呢?编译会报错,因为不同类型不能直接用==。
三、equals和hashCode:不仅仅是“必须重写”那么简单
“如果重写equals不重写hashCode会怎样?”很多候选人回答:“会导致HashSet、HashMap无法正常工作。”但面试官紧接着问:“具体怎么无法工作?”典型的场景是:你有一个类Person,根据ID判断相等,但只重写了equals,没重写hashCode。然后你把两个ID相同的Person对象放入HashSet,结果两个对象都成功添加了,因为它们的hashCode不同(默认是内存地址映射的整数),导致它们被分配到不同的桶里。HashSet首先通过hashCode定位桶,如果桶不同,则不会调用equals。所以,equals相等的两个对象,hashCode必须相等;但hashCode相等,equals不一定相等(哈希碰撞)。这背后的契约来自Object类的规范文档。更深入的问题是:为什么HashMap先用hashCode,再用equals?为了效率。如果所有对象都落到同一个桶里,HashMap就退化为链表,性能从O(1)降到O(n)。面试官还可能问到“为什么String的equals方法会先比较引用,再比较长度,最后逐字符比较?”这是为了性能优化——引用相等直接true,长度不同直接false。还有一个容易被忽略的点:数组的equals方法并没有被重写,所以数组比较元素相等应该用Arrays.equals()。同样,Collection的equals方法在ArrayList和LinkedList中的实现也值得深究。
四、ArrayList与LinkedList:增删效率的真相被误解多年
教科书上说“ArrayList随机访问快,LinkedList插入删除快”。但这句话在特定场景下是错的。例如,在LinkedList尾部插入大量元素时,每次都要list.last()找尾节点,但LinkedList有last指针,所以尾部插入是O(1)。但在中间插入,需要遍历找到指定位置,复杂度是O(n);而ArrayList在中间插入需要挪动元素,也是O(n)。真正决定选择的关键是:ArrayList的批量尾部添加(addAll)利用System.arraycopy,性能极高;而LinkedList每次插入都要创建Node对象,内存消耗更大。另外,ArrayList在扩容时会损失一部分性能,但扩容次数有限(扩容1.5倍,随着容量增大,触发频率下降)。很多候选人不知道ArrayList的默认初始容量是10,JDK7之前是懒加载,JDK8之后改为懒加载(第一次add时创建数组)。还有一个隐藏点:ArrayList的subList()返回的是视图,而不是新列表。修改视图会影响原列表,反之亦然。ConcurrentModificationException也常被忽视:使用for-each遍历时删除元素会抛出该异常,但使用Iterator.remove()则安全。这个异常是fail-fast机制,通过modCount实现,但CopyOnWriteArrayList是弱一致性,不会抛出。
五、HashMap的扩容与红黑树阈值:不是你想的那样
“HashMap什么时候转红黑树?”大多数人的回答是“链表长度超过8”。但是,还有一个前提:HashMap的数组长度必须大于等于64,否则会优先扩容数组到64,而不是转红黑树。因为如果桶数组太小,即使链表长,通过扩容让元素分散到更多桶中可能更高效。而树化的阈值TREEIFY_THRESHOLD=8,反树化阈值UNTREEIFY_THRESHOLD=6,中间有缓冲。为什么是8?官方注释提到:理想情况下,随机哈希码下,桶中元素数量服从泊松分布,装载因子0.75时,链表长度达到8的概率非常小(约0.00000006),所以用8作为警戒线。但如果你自定义了糟糕的hashCode,所有对象都落到同一个桶,那么链表就会很长,转红黑树可以避免性能退化。还有一个容易被忽略的点:HashMap的容量总是2的幂次方(通过tableSizeFor方法找到大于等于指定容量的最小2次幂)。为什么?因为取模运算hash & (n-1)效率高,且能均匀分布。另外,HashMap在JDK8中引入红黑树后,resize()时的高低位迁移(利用hash & oldCap判断元素是留在原位置还是移动到原位置+oldCap)也是一个考点。如果候选人不知道扩容时元素如何重新分布,很难通过面试。
六、并发中的可见性与指令重排序:volatile只是“可见性”吗?
“volatile关键字能保证原子性吗?”不能。volatile只能保证可见性和有序性(禁止指令重排序)。典型例子:volatile int count,多个线程执行count++,结果依然错误,因为自增操作不是原子操作(读-改-写)。可见性指一个线程修改了volatile变量,其他线程能立即看到最新值。实现原理是:加入内存屏障,强制将工作内存的修改刷新到主内存。而有序性是通过禁止指令重排序实现的,比如DCL单例模式中,需要volatile防止instance = new Singleton()的指令重排序导致未完全初始化对象被其他线程使用。但有一个更隐蔽的问题:volatile对于64位的long/double的读写是原子的(JVM规范要求),但在某些32位JVM上,long/double的读写可能不是原子操作,volatile可以保证原子性。此外,面试官常问“synchronized和volatile的区别”,很多人只回答“synchronized保证原子性,volatile不能”,却忽略了synchronized也能保证可见性和有序性,只是通过锁实现的。而且,final关键字也能保证可见性:一个对象的final字段在构造器中初始化后,其他线程看到的是正确的值(前提是构造函数没有把this逸出)。
七、异常处理中的finally与return:谁先执行?
try { return 1; } finally { return 2; }
这个代码返回值是2。因为finally中的return会覆盖try中的return!更诡异的是,如果finally中没有return,但有类似System.out.println的语句,try中的return值会在finally执行前被保存到局部变量表中,然后执行finally,最后返回保存的值。但是,如果finally中修改了返回的变量(比如基本类型或引用类型),结果如何?如果是基本类型,修改不影响已保存的值;如果是引用类型,修改引用指向的对象内容,则会影响返回的对象。还有一个常考的点:try中如果关闭资源时在finally中又抛出异常,会覆盖try中的异常。从Java 7开始,最好使用try-with-resources,这样资源关闭时抛出的异常会被抑制(添加在原始异常的suppressed列表中),不会覆盖。你还会看到面试官问“System.exit(0)在try块中执行,finally还会执行吗?”答案是不会,因为System.exit会立即终止JVM。很多候选人对异常的分类也模糊:Checked Exception必须处理或声明,Unchecked Exception(RuntimeException及其子类)可处理可不处理。Error类通常是JVM内部错误,程序不需要也不能处理。
八、类加载双亲委派:破坏双亲委派的理由是什么?
双亲委派模型:一个类加载器收到类加载请求,不会自己先加载,而是委派给父类加载器,只有父类无法加载时才自己加载。这样保证了核心类(如java.lang.String)只会被Bootstrap ClassLoader加载,避免用户自定义的虚假String篡改核心API。但是,有些场景需要破坏双亲委派,比如SPI(Service Provider Interface)机制,如JDBC驱动加载。DriverManager在static块中通过ServiceLoader加载驱动,而ServiceLoader位于启动类加载器无法加载到的路径(比如MySQL驱动是第三方jar),所以需要线程上下文类加载器来打破双亲委派:让顶层类加载器(Bootstrap)可以请求子加载器加载具体实现。另一个典型破坏是Tomcat等Web容器,为了隔离不同应用的类,每个WebApp有自己的类加载器,先加载自己WEB-INF/classes下的类,如果找不到才委派给父加载器(这样允许WebApp覆盖容器自身的类)。面试官可能会问:“Class.forName()和ClassLoader.loadClass()有什么区别?”前者默认执行类的静态初始化块(如果指定initialize=true),后者不会初始化。很多人在写JDBC时只记得Class.forName("com.mysql.jdbc.Driver"),其实从JDBC 4.0开始,驱动已经通过SPI自动注册,不需要显式写这一行,但很多候选人依然在项目中保留那段代码。
九、泛型擦除:为什么不能重载List<String>和List<Integer>?
Java的泛型是伪泛型,编译时进行类型擦除,运行时所有泛型信息都不存在。所以List<String>和List<Integer>在运行时都是裸类型List,如果两个方法参数类型分别是List<String>和List<Integer>,它们在字节码层面是相同的签名(擦除后都是List),无法通过编译。但是,你可以通过返回值不同来重载吗?不能,Java方法签名只包含方法名和参数类型(包括顺序),返回值不参与。不过,使用List<?>作为参数时,由于通配符在编译期会保留边界,可以产生不同的桥方法,但依然不能基于参数类型参数化不同来重载。另外,泛型擦除导致List<int>不合法,因为基本类型不能作为类型参数,必须使用包装类。但数组协变与泛型不变性也常被考察:String[]是Object[]的子类,但List<String>不是List<Object>的子类。面试官还会问:“如何获取泛型类型?”通过反射获取TypeVariable或ParameterizedType,但仅当类定义中保留了泛型信息(如匿名内部类或子类继承父类时,类型参数会被编译器写入字节码)。很多候选人对@SuppressWarnings("unchecked")的用法不理解,只知道加上去消除警告,但不明白为什么需要强制类型转换——因为擦除后返回的是Object。
十、反射与动态代理:性能差在哪里?
“反射为什么慢?”一是因为方法调用时需要动态查找方法(Method.invoke内部会做权限检查、参数解析、调用Native方法等),二是因为反射调用无法被JIT内联优化(在JIT编译时反射调用被视为黑盒)。JDK8之后,MethodHandle和invokedynamic提供了更高效的替代方案,但反射的慢是相对的——在微秒级别,对于大多数应用来说不是瓶颈。真正需要警惕的是滥用反射动态调用次数极大的场景(比如每秒百万次)。动态代理有两种实现:JDK动态代理(基于接口)和CGLIB(基于类实现,子类化)。JDK代理只能代理接口,CGLIB可以代理普通类(final类除外)。CGLIB通过ASM字节码框架动态生成目标类的子类,覆盖非final方法,所以不能代理final方法和final类。Spring AOP默认如果目标类实现了接口,就用JDK代理,否则用CGLIB。还有一个很多人不知道的点:JDK动态代理生成的代理对象内部包含一个InvocationHandler,调用任何方法都会被转发到invoke方法中。如果代理类继承了其他类或实现了其他接口,这些方法的调用也会被拦截,但Object中的某些方法(如hashCode、equals、toString)会被特殊处理。面试官可能问:“JDK动态代理为什么需要接口?”因为java.lang.reflect.Proxy继承了Proxy类,而Java是单继承,所以只能通过实现接口来扩展行为。
写在最后
这些基础问题看似琐碎,却像地基一样支撑着你对Java这座大厦的理解。如果你在面试中能对这些常见误区信手拈来,并且给出清晰、深入的解释,面试官会认为你的基础十分扎实,即使后续的高并发、分布式问题答得不够完美,也能得到一个正向的评价。每次面对这些“简单”的问题,不妨多问一句:为什么这样设计?边界情况是什么?性能影响如何?当你不再满足于背诵答案,而是从设计者的视角去审视Java的每一个特性时,你才真正拥有了“专家”级的洞察力。下一次面试,别再让最基础的问题绊倒你。