行业资讯
📅 2026/8/10 7:26:33
Java面试中那些容易被忽略的基础问题
别把“基础”当成背答案面试官问“String为什么不可变”你脱口而出“因为用final修饰了”。可再追问一句“final修饰的数组里的元素还能不能变”你突然卡壳。这种场景每天都在上演。所谓“基础”不是你知道某个结论而是你能否沿着结论往下走三步。越是简单的问题越能暴露你理解系统的深度。今天我们不聊高并发、不聊JVM调优专挑那些你觉得自己会、但一深问就露馅的基础问题把它们拆开揉碎。String的“不可变”其实是个谎言先说那个最经典的。String用final修饰char数组所以不可变错。final修饰的是引用不是数组内容——你依然可以通过反射修改value数组里的元素。真正让String不可变的是设计者没有提供任何修改数组内容的方法并且把数组设为private。更关键的是不可变性的价值在于缓存hashCode、支持字符串常量池、保证线程安全而不是因为一个final关键字。如果面试官换个问法“StringBuilder为什么可变它内部也是char数组啊”你会发现答案就是同一个它提供了append方法去改数组内容。还有个比这更隐蔽的坑String s new String(abc)创建了几个对象答案不只是“一个或两个”。如果常量池里已有“abc”那只创建一个堆对象如果没则创建两个——常量池一个、堆一个。但很多人忽略了new String(abc)中的“abc”本身就是一个字符串字面量它要求常量池中存在该对象。也就是说即使你用new也绕不开常量池。这背后的本质是Java字符串字面量在编译期就被固化进class文件常量池运行期加载到内存常量池。重载与重写别只背定义“重载是编译期多态重写是运行期多态”——这句话人人在说但能解释清楚的寥寥无几。问你一个方法同时满足重载和重写的条件吗不重载要求在同一个类中重写要求继承关系两者互斥。可要是问“子类中写一个与父类方法同名但参数不同的方法这是重载还是重写”答案这是两个方法子类里的那个只是新方法跟父类没半毛钱关系。真正的坑在于调用重载方法时选择哪个版本是编译期根据“静态类型”决定的。看这段代码class A { void test(String s) {} } class B extends A { void test(Object o) {} } A a new B(); a.test(hello);猜猜调用的是哪个答案是A的test(String)。因为编译期a的静态类型是A编译器只在A里找test(String)找到了就用。B里的test(Object)根本没机会参与。这就是“重载是静态绑定”的直观体现。很多人把“重写”和“重载”都归结为“多态”却不知道重载连运行期间都不看实际对象类型。基本类型的默认值是个陷阱“int默认值是0boolean默认是false”——这没错。可追问一句“局部变量没有默认值那成员变量为什么有”你能答上来吗JVM在创建对象时会对实例变量进行零值初始化——这是虚拟机规范强制规定的。而局部变量存在于栈帧中编译器要求程序员必须显式赋值否则编译都过不去。为什么因为栈中的垃圾数据是随机的JVM不会帮你清零如果不强制赋值用起来就是未定义行为。但对象的内存堆会被GC回收后复用JVM需要保证一个“干净”的初始状态。更深一层数组元素呢new int[10]里面到底是0还是随机值也是0。数组是对象数组元素也是被零值初始化的。但如果数组是int[][]呢第二维未初始化时是null。很多人栽在这声明int[][] arr new int[3][];然后直接arr[0][0] 1空指针。默认值只覆盖“被分配的内存空间”不覆盖“引用指向的对象”。Integer的缓存比你想象的更脆Integer a 127; Integer b 127; a b是true。Integer c 128; Integer d 128; c d是false。这个题几乎人人会背但别得意。面试官换个角度“Integer i new Integer(100);i 100 是什么结果”这是true因为遇到基本类型时Integer会自动拆箱。再换“Integer i 100; long l 100; i l是”还是true因为i先拆箱成int再转成long比较。但要注意Integer i 100; Integer j 100; i ! j会怎样在1.4之前没有自动装箱这代码编译不过。在1.5之后Integer.valueOf有缓存范围。重点在于Integer的缓存范围是-128到127这个范围可以被JVM启动参数调整——-XX:AutoBoxCacheMax200就能扩大。更反直觉的是缓存池里的对象是在类加载时创建的而不是懒加载。也就是说哪怕你代码里一个Integer都没用只要Integer类被加载比如用了int类型那256个对象就白白躺在堆里了。这不是大问题但它说明“基础”里的每个细节都可能关联着性能。volatile不保证原子性但更能说明问题是“可见性”“volatile保证可见性和有序性不保证原子性”——背得很顺。但面试官问“可见性到底由什么机制实现”多数人答不上来。可见性的核心是绕过CPU缓存强制让变量读写直接走主内存。在Java内存模型中每个线程有自己的工作内存CPU缓存抽象普通变量的读写可能先在工作内存操作再同步到主内存。而volatile变量的写操作会立即刷新到主内存读操作会直接从主内存读取。但注意这个保证的粒度是“单个volatile变量的读写”不是“复合操作”。经典例子多个线程执行ii是volatile结果依然不准。因为i拆成三步读i、加1、写回i。volatile保证第二步写回后立即可见但第一步读和第三步写之间别的线程可能已经改了i。volatile解决不了“读-改-写”的竞态但这恰恰说明锁的本质是“原子性”而非“可见性”。更进一步volatile还有“禁止指令重排序”的语义而这一步在双重检查锁单例中才真正体现。不少人在单例里写了volatile却解释不清为什么——因为instance new Singleton()不是原子操作它可能先分配内存、再赋引用、最后构造对象。如果没volatile另一个线程可能看到“引用非空”但“对象还没构造完”于是用了一个半成品。这就是可见性和有序性共同挖的坑。HashMap的初始容量不是简单的16“HashMap默认初始容量16负载因子0.75”——说得对。但为什么是16和0.75不是拍脑袋想的。16是2的整数次幂这让“取模”可以用位运算hash (n-1)代替速度远快于%。而0.75是个权衡太高比如1会导致哈希冲突加剧链表变长查询退化太低比如0.5会频繁扩容浪费空间又耗时。0.75在时间和空间上取得平衡——这是从统计学角度算出来的使得哈希桶中出现空闲位置的概率符合泊松分布。但更容易被忽略的是如果你指定初始容量为15HashMap不会直接用15而是变成162的幂。这个“找最近2的幂”的操作是通过一系列无符号右移和或运算完成的。有人以为这是简单“向上取整”实际没那么简单。还有一个细节扩容是每次增加“原来的一倍”而不是加固定值。也就是说容量始终是2的幂。至于为什么原因还是为了位运算。一旦容量不是2的幂hash (n-1)就失去意义必须用取模性能骤降。数组和ArrayList的“大小”为何不同“数组有length属性ArrayList有size()方法”——这种区别背后是概念差异。数组是Java语言内置的固定长度容器length是属性不可变。ArrayList是集合框架里的动态数组size()是方法返回当前元素个数。面试官稍微拐个弯“ArrayList的默认容量是多少”答案是10。但注意new ArrayList()时底层数组其实是一个空数组直到第一次add时才扩容为10。这是懒加载策略为了节省内存。再深问“扩容时复制数组用的是什么方法”是Arrays.copyOf底层是System.arraycopy这是native方法。很多人以为ArrayList扩容就是“新建一个数组然后把旧元素搬过去”没错但没意识到如果一次性add很多元素扩容策略是“计算所需容量如果当前容量不够就扩大到当前容量的1.5倍”。这个1.5倍不是固定的如果1.5倍还不够就直接扩到所需容量。这背后的逻辑是尽量减少扩容次数又避免一次性申请过大空间。equals和hashCode约定大于实现“重写equals必须重写hashCode”——这是法律吗不是这是约定。如果不重写hashCode在HashSet、HashMap里就可能出现逻辑相等的两个对象被放进不同的桶导致Set中出现重复。但如果你的对象永远不进哈希表那重写equals不重写hashCode也不会出错。这不是语法错误而是“违反约定”的逻辑缺陷。面试官常出这道题两个对象equals返回truehashCode必须相同两个对象hashCode相同equals可以不相等。这句话要背下来吗要理解hashCode的作用是“缩小检索范围”只要equals相等的对象拿到相同的哈希值就能保证它们落在同一个桶里后续用equals去比较时就是重头戏。更反直觉的是String的hashCode计算方式是31 上一个字符的hashCode 当前字符。为什么用31因为它是奇数且是质数用质数做乘数能减少哈希碰撞而31可以被JVM优化成(i 5) - i速度比乘法快。这个细节很多人背了但没想过“为什么是31而不是32”——32是2的幂乘法会退化成移位导致低位数丢失碰撞率激增。31则妙在既不丢失信息又能加速。对象创建的几个阶段不只是new“创建一个对象有哪几个步骤”多数人回答加载类、分配内存、初始化、构造。但更精确的JVM视角是new指令在堆中分配内存并清零——这意味着所有字段已经是零值然后调用构造函数里面包含隐式赋值和显式赋值。但还有个关键点对象的引用是何时被发布出去的如果在构造函数中把this传出去就可能出现“对象未完成构造就被别的线程使用”的问题。这在普通代码里很少见但在并发环境中是致命的。这引出一个基础中的基础不要在构造函数中启动线程。因为线程启动后可能会立即访问这个对象的字段而构造函数还没执行完。这个知识算不上多深但它把基础知识串起来了——内存可见性、重排、构造安全。异常体系别只记住“Checked/Unchecked”“Error是JVM错误Exception是程序错误”——这太粗糙。RuntimeException非受检异常是编程错误比如除零、空指针、数组越界受检异常IOException等是外部条件导致的错误比如文件不存在、网络断开。但更原则性的问题是受检异常被设计成“必须捕获或声明抛出”为什么因为它强制程序员处理“可恢复的失败条件”。而RuntimeException不需要声明因为程序员应该通过检查代码避免它们。可实际开发中很多人滥用受检异常把接口里每个方法都throws Exception这就违背了设计的初衷。一个良好的异常设计应该区分“可恢复的”和“不可恢复的”。如果请求参数错误导致校验失败那属于调用方bug应该用RuntimeException而不是强制调用方catch一个“参数非法”的受检异常。更进一步面试题经常问“try-catch-finally中finally里return会怎样”如果在finally里写了return它会覆盖try里的return。这是语法允许的但极其危险。因为finally中return会吞噬被catch捕获的异常导致异常无法向上传递。更隐蔽的是如果try里return一个对象finally里修改该对象的属性返回的还是那个对象但属性值被改了。finally主要是用来释放资源不是用来做“最终赋值”的。类型转换的坑父类子类没那么简单“子类对象可以向上转型为父类引用父类引用可以强制向下转型”——这话没错但忽略了一个核心前提只有实际类型是子类的对象才能向下转型。所以A a new A(); B b (B) a; // 编译通过运行期ClassCastException为什么编译器允许因为Java的类型系统是“名义类型”编译器无法在编译期知道a的实际类型。这个基础题背后是动态类型和静态类型的区别。但还有个更隐蔽的数组是协变的泛型不是。String[]是Object[]的子类型所以Object[] objs new String[10];合法。但如果把objs[0] 1编译不报错运行期ArrayStoreException。这就是数组协变带来的陷阱。而泛型则通过类型擦除避免了这个问题ListString不是ListObject的子类型所以ListObject list new ArrayListString()根本编译不过。为什么Java要这么设计因为数组是运行时判别泛型是编译期判别——为了保持类型安全泛型牺牲了协变而数组保留了协变但付出运行时检查的代价。关于String、锁、集合的“底层”标准最后再说一个常被忽略的String的intern()方法到底做了什么很多人知道“字符串常量池”但不知道这个池在JDK7之后被移到了堆中之前是永久代。intern()方法会去常量池查找是否有相同内容的字符串如果有就返回引用没有就把当前字符串对象加入到常量池并返回引用。但这带来了一个实际问题大量调用intern()会导致堆内存中的常量池膨胀造成OOM。所以JDK7之后intern不再是“把对象复制进永久代”而是“在堆中记录对象的引用”这让常量池可以引用堆中的字符串对象。这个细节如果是老版本知识会满盘皆输。很多基础问题考的不是“结论”而是“结论背后的权衡”。比如为什么ArrayList用数组LinkedList用链表——数组连续内存、随机访问快链表分散内存、插入删除快。但问到“ArrayList在头部插入一个元素时间复杂度和LinkedList相同吗”答案是ArrayList需要移动所有元素O(n)LinkedList需要找到头结点再插入O(1)。可如果你不知道LinkedList的实现是“双向链表”你没法回答“尾插也是O(1)因为head结点保存了尾引用”这种细节。基础从来不是背答案而是理解每个数据结构在JVM里长的什么样子每个关键字在指令层面干了什么事。写到最后面试官问这些“基础”不是想刁难你而是想知道你写代码时是机械地调用API还是能预见到每个操作背后的内存布局和时间成本。能解释String不可变和Integer缓存的人写出的并发代码不会乱加volatile能说清HashMap扩容细节的人设计系统时不会选错数据结构。越是底层越能区分熟练工和工程师。你不需要背下所有源码但你需要养成一种习惯见到一个简单概念往深里问一句“为什么”。这就是面试的本质也是编程的本质。