别把“基础”当成背答案
面试官问“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默认值是0,boolean默认是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:AutoBoxCacheMax=200就能扩大。更反直觉的是:缓存池里的对象是在类加载时创建的,而不是懒加载。也就是说,哪怕你代码里一个Integer都没用,只要Integer类被加载(比如用了int类型),那256个对象就白白躺在堆里了。这不是大问题,但它说明“基础”里的每个细节都可能关联着性能。
volatile不保证原子性,但更能说明问题是“可见性”
“volatile保证可见性和有序性,不保证原子性”——背得很顺。但面试官问:“可见性到底由什么机制实现?”多数人答不上来。可见性的核心是绕过CPU缓存,强制让变量读写直接走主内存。在Java内存模型中,每个线程有自己的工作内存(CPU缓存抽象),普通变量的读写可能先在工作内存操作,再同步到主内存。而volatile变量的写操作会立即刷新到主内存,读操作会直接从主内存读取。但注意,这个保证的粒度是“单个volatile变量的读写”,不是“复合操作”。
经典例子:多个线程执行i++,i是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在时间和空间上取得平衡——这是从统计学角度算出来的,使得哈希桶中出现空闲位置的概率符合泊松分布。
但更容易被忽略的是:如果你指定初始容量为15,HashMap不会直接用15,而是变成16(2的幂)。这个“找最近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返回true,hashCode必须相同;两个对象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。这就是数组协变带来的陷阱。而泛型则通过类型擦除避免了这个问题:List<String>不是List<Object>的子类型,所以List<Object> list = new ArrayList<String>()根本编译不过。为什么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扩容细节的人,设计系统时不会选错数据结构。越是底层,越能区分熟练工和工程师。你不需要背下所有源码,但你需要养成一种习惯:见到一个简单概念,往深里问一句“为什么”。这就是面试的本质,也是编程的本质。