面试时我最常做的一件事,是让候选人聊聊“Java基础”。不是说语法,不问API,就问那些每天都在写、但大多数人没认真想过“它底层到底怎么动”的事情。结果很有意思:很多人写了好几年Java,CRUD、框架、中间件玩得飞起,可一问到变量到底存在哪、HashMap为什么快、异常能不能随便抛,就开始打磕巴。而线上出了内存飙高、数据串位的问题,排查来排查去,根子往往就落在这几个最基础的节点上。
所以我一直有个观点——Java基础不是背出来的,是用出来的。框架是皮肤,基础是骨架。这篇文章我不讲Hello World,也不灌面试八股,只挑六个“看着简单、实则水很深”的基础议题,结合我这些年踩过的坑,从JVM字节码、集合源码、异常性能这些角度,把背后的逻辑说透。
1. 基础不牢的翻车现场:语法都会,跑起来就错
1.1 简历上的“精通Java基础”,面试官到底在看什么
说个常见场景。我问一个人“final关键字是干嘛的”,对方能流利回答:修饰类不可继承、修饰方法不可重写、修饰变量不可变。听起来没问题。但接着问“那finalize呢”,很多人会愣住——其实这个方法是Java 9开始就被标记废弃的,GC回收对象前可能会调用,但完全不能保证执行,早就该从脑子里删掉了。
这就是“语法都会”和“基础扎实”的差别。前者背下了规则表,后者知道规则的边界、代价和为什么存在。
再比如一个经典题目:
String s1 = new String("hello"); String s2 = "hello"; System.out.println(s1 == s2); // false System.out.println(s1.equals(s2)); // true能答对不难,难的是说清楚:String常量池在哪?new String到底创建了几个对象?s1指向的引用和s2指向的引用为什么不是同一个地址?这些问下去,基础扎不扎实立刻就清楚了。
1.2 三个让老手也翻车的小例子
不光是新人,写了好几年代码的人,基础照样可能漏风。我随手举三个我在实际代码review里见过的例子。
第一个是包装类比较:
Integer a = 127; Integer b = 127; Integer c = 128; Integer d = 128; System.out.println(a == b); // true System.out.println(c == d); // false原因是Integer内部有一个缓存,范围-128到127,这个范围内的值直接复用缓存对象,超过范围就new新对象。==比较的是引用地址,结果当然不一样。这个坑坑过多少人,不用我说。
第二个是循环里删集合元素:
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); for (int i = 0; i < list.size(); i++) { list.remove(i); }很多人以为这样能删干净,实际删完还剩一个"c"。因为每次remove之后,后面的元素往前挪,下标i对应的内容已经变了,可循环还在按原来的步进走。正确做法是倒序删,或者用迭代器的remove方法,再或者用集合的removeIf。
第三个是字符串循环拼接:
String result = ""; for (int i = 0; i < 10000; i++) { result += "x"; }这段代码在每次循环里都会new一个StringBuilder,再转成String。循环一万次,就创建了一万个临时对象。性能烂到爆,GC压力直接拉满。写成StringBuilder显式拼接,才是正解。
1.3 Java基础的两个层次:语法层与行为层
| 层次 | 包含内容 | 决定的东西 |
|---|---|---|
| 语法层 | 关键字、语句、基本结构、声明方式 | 代码能不能写出来 |
| API层 | 集合、IO、并发工具、字符串处理类 | 代码能不能写得快 |
| 行为层 | JVM内存布局、类加载、字节码、运行时语义 | 代码跑起来稳不稳、快不快 |
大多数人的学习停留在第一层和第二层,第三层基本靠“出了事故再补”。但真正决定代码质量上限的,恰恰是行为层。
举个例子:为什么局部变量必须初始化,而成员变量不初始化也有默认值?因为局部变量存在栈帧的局部变量表里,不初始化就是个脏数据,JVM为了避免读到垃圾值,强制要求赋值。而成员变量在堆里分配时,所有位都是0,自动就有了默认值。这种细节写在任何API文档里,但读懂了,你对“变量住在哪”这件事就有了底层认知。
2. 一个变量住在哪:数据类型与JVM存储的底层逻辑
2.1 基本类型和引用类型在虚拟机里的不同待遇
很多写了三年Java的人,问“对象和变量的区别”,依然会愣住。其实一句话就能概括:基本类型变量存的是值本身,引用类型变量存的是通往对象的地址,对象本体在堆里。
这句话听起来简单,实战里全是坑。
int a = 10; int b = a; b = 20; System.out.println(a); // 还是10 User u1 = new User(); User u2 = u1; u2.setName("张三"); System.out.println(u1.getName()); // 张三,u1也变了第一个人畜无害,第二个让多少人半夜改bug。因为u2 = u1拷贝的是引用,两个变量指向堆里的同一个User对象,改一个,另一个“看起来”也变了。理解了这张图,很多灵异现象就都不灵异了。
数组也是一种引用类型,所以数组变量赋给另一个变量,同样共享同一份数据。Arrays.copyOf和System.arraycopy为什么是复制?因为语言层面没有直接给数组提供“把内容全拷一份”的运算符。
2.2 方法参数传递:值传递还是引用传递
这个问题属于Java基础的世纪辩论。我的结论很明确:Java只有值传递。
方法参数是一个局部变量,传进来的东西会被拷贝一份放到新栈帧里。基本类型传的是数值副本,引用类型传的是引用地址的副本。
public static void swap(Integer a, Integer b) { Integer tmp = a; a = b; b = tmp; }这个swap无论如何都不生效。因为你在方法里交换的是两个局部引用的指向,跟外面的变量完全没关系。同理:
public static void change(String str) { str = "changed"; } String s = "original"; change(s); System.out.println(s); // original为什么?因为String是不可变对象,str = "changed"不是在原对象上修改,而是让局部变量str指向了一个新字符串。外面的s还指向原来的“original”。所以想写一个“修改字符串内容”的方法,这条路根本走不通,只能返回新字符串。
2.3 自动拆箱与Integer缓存:性能问题的隐藏源头
自动装箱拆箱是Java 5之后加进来的语法糖,写起来方便了,但底层行为很多人没搞懂。
Integer num = 100; // javac编译后其实是 Integer.valueOf(100) int n = num; // 其实是 num.intValue()valueOf方法内部就是Integer缓存的入口:-128到127范围内的值,直接从cache数组里取,不new对象。这个范围为什么这么定?因为-128到127正好是byte的完整范围,也是大多数业务场景里最常用的整数,把这个区间缓存起来,性价比最高。
它带来的直观影响是:Integer a = 100; Integer b = 100; a == b是true,但Integer a = 200; Integer b = 200; a == b是false。如果代码里不小心用了==比较两个超过127的Integer,恰好两边都是new创建的对象,那结果必然是false,逻辑就错了。
还有个隐藏性能杀手:在循环里频繁自动装箱拆箱,会平白产生大量临时包装对象,加重GC负担。比如用Long做计数器,循环百万次,堆里就多了百万个Long对象。排查线上GC问题时,我见过好几次这种写法贡献的“垃圾”。
2.4 字符串拼接的隐形成本
再深挖一下String拼接。很多人以为"a" + "b"在编译期就会拼好,那是指两个字面量相加,编译期确实折叠成了"ab"。但如果拼接里出现变量,情况就完全不同。
String s = "a"; for (int i = 0; i < 100; i++) { s = s + i; }javac编译后,循环体内部实际是new StringBuilder().append(s).append(i).toString()。StringBuilder本身创建在堆上,toString又生成一个新String对象,等于循环几次就多出几个临时对象。
提示:别在循环里用
+拼接字符串。显式在外层声明一个StringBuilder,循环里只append,最后toString一次。这是最简单的性能优化,收益却很明显。
String不可变带来的好处也顺便说一句:因为它不可变,所以字符串常量池才能安全地复用同一份字面量;因为不可变,所以String对象可以作为HashMap的key放心使用,不会因为内容被修改导致hashCode漂移。
3. 集合框架:从案发现场到源码原理
3.1 为什么一个List接口要有那么多实现
集合框架是Java基础里最庞大的部分,也是很多人“用得多、懂其然不懂其所以然”的重灾区。先问一个基础问题:ArrayList和LinkedList区别是什么?
标准答案是:ArrayList是数组实现,随机访问快,插入删除慢;LinkedList是双向链表实现,头部插入删除快,随机访问慢。但真实项目里,我用LinkedList的次数一只手数得过来。
为什么?因为LinkedList的“插入快”是有条件的——你得已经持有那个位置的节点引用。如果单纯想做“在list中间第n个位置插入”,LinkedList照样要先从头遍历到n去找节点,复杂度O(n),跟ArrayList的System.arraycopy在常数时间上没差多少。而且LinkedList每个节点还要多存两个引用(prev和next),内存开销远大于ArrayList里的连续数组。
所以我的选型经验很简单:业务里95%的场景用ArrayList。碰到“主要在头部频繁插入删除、且对随机访问不敏感”的场景,再考虑LinkedList。
3.2 ArrayList扩容:为什么频繁add会触发性能抖动
看ArrayList源码,有个细节:默认初始容量是10,每次扩容变成原来的1.5倍,也就是oldCapacity + (oldCapacity >> 1)。扩容时要做一次Arrays.copyOf,底层是System.arraycopy,把老数组所有元素搬进新数组。
这就意味着,如果你往ArrayList里不断添加元素,它会经历一个增长序列:10、15、22、33、49……每次扩容都是全量拷贝,数据量大时这个拷带动静不小。
实际经验是:能预估大小时,直接指定初始容量。
List<String> list = new ArrayList<>(1024);不要小看这一行,批量加载几万条数据时,它可能帮你省掉十几次数组拷贝,也就省掉了十几次大对象分配。
3.3 HashMap的定位逻辑:容量为什么必须是2的幂
HashMap是集合框架的“顶流”,也是线上问题高发区。它的定位逻辑基于一个操作:计算key的hash,然后通过(n - 1) & hash算出桶下标。n是数组长度。
注意这里用的是&位运算,不是%取模。位运算比取模快得多,但这要求n必须是2的幂,这样n-1的二进制全是低位1,按位与之后才能保证结果均匀分布在0到n-1之间。
JDK 8之后还有一步“扰动”:把hashCode高16位异或低16位。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }为什么要这样?因为默认容量只有16,桶下标只看低4位,如果多个对象的hashCode低位相同、高位不同,很容易撞到同一个桶。先让高低位混合一下,桶的分布就更均匀。这一眼看不起眼的小方法,是哈希碰撞概率的关键。
3.4 负载因子0.75和树化阈值8背后的工程权衡
HashMap默认负载因子是0.75。这是什么概念?容量16时,元素个数达到12(16 * 0.75 = 12)就开始扩容。为什么不设成1?因为越晚扩容,哈希碰撞越严重,桶里的链表越长,查询就越慢。为什么不是0.5?因为太早扩容,浪费空间。0.75是空间和时间各砍一半的甜点值。
还有个容易被忽略的点:链表长度超过8,且数组容量达到64时,链表转红黑树。为什么是8?源码注释里有一段基于泊松分布的计算——在随机哈希的假设下,桶内链表长度超过8的概率大约是千万分之六,小到可以忽略。引入树化,主要是为了防“哈希碰撞攻击”,也就是有人故意构造大量哈希值相同的key,让HashMap退化成链表,拖慢服务。8这个阈值就是“正常情况几乎到不了、异常情况下能撑住”的平衡点。
提示:如果自定义对象作为HashMap的key,却没正确重写hashCode和equals,那就是灾难。HashMap先算hash定位桶,再用equals比对同桶里的key,两边都要靠谱才找得到值。这个话题,下一章细说。
4. 异常处理:try-catch背后的性能账与设计观
4.1 受检异常与非受检异常的分工逻辑
Java的异常体系从设计上分为两类:受检异常(checked exception)和运行时异常(unchecked exception)。受检异常必须显式处理或抛出,编译器强制检查;运行时异常不需要。
为什么会有这种设计?受检异常的初衷是“可以预见的、可恢复的错误”——比如文件找不到、网络超时、数据格式错误。编译器逼你处理,是防止你把错误吞了。而运行时异常代表“程序员没写好导致的bug”——空指针、数组越界、类转换错误,这些错误恢复不过来,只能向上抛,记录日志,让开发去改代码。
但实际项目里,受检异常经常被滥用。有人喜欢把业务校验不通过也设计成受检异常,结果整个调用链每层都被迫声明throws,到处try-catch,噪声巨大。我的经验是:业务状态判断尽量用返回值或自定义结果对象表达,不要用异常表达正常流程分支。异常只用于“这里真的出错了”的情况。
4.2 try-with-resources到底帮你关了什么
很多人写IO代码还停留在:
InputStream in = null; try { in = new FileInputStream("file"); // ... } finally { if (in != null) { in.close(); } }这段代码有个隐患:如果try块里抛了异常A,finally里close又抛了异常B,那么异常A会被B覆盖,问题定位直接抓瞎。JDK 7开始的try-with-resources专门解决这个:
try (InputStream in = new FileInputStream("file")) { // ... }它会自动close,而且多个资源按照声明的逆序关闭,关键的是——如果close的时候又抛异常,这个异常会被记为“被抑制的异常”,附加在主异常后面,主异常照样能向上抛。你通过Throwable.getSuppressed()能看到它,排查时不会丢线索。
看字节码就明白,try-with-resources本质上编译成了一个带close()调用的try-finally块,只不过编译器帮你处理了异常叠加的细节。这是Java基础里“编译器替你做正确决策”的典型例子。
4.3 抛异常为什么慢:StackTrace的真实成本
有一个性能事实很多人不知道:创建一个异常的耗时,是普通对象创建的上百倍。原因在于异常对象构造时要填充线程栈的StackTrace,而填充栈轨迹需要遍历当前线程所有栈帧,提取类名、方法名、行号。
所以千万别用异常做流程控制。比如:
try { process(data); } catch (BusinessException e) { // 正常业务分支 }一旦这个分支触发的频率高,性能就会崩。正确做法是让方法返回一个状态码或结果对象,用if判断而不是try捕捉。
另外还要注意:就算不抛异常,仅仅new一个Exception的开销也不小。有些日志框架的debug开关没关,代码里每行都new一个异常打进logger,线上吞吐量直接腰斩——这不是危言耸听,我排查过真实的线上性能事故,根源就是日志里疯狂的异常堆栈打印。
4.4 日志中的异常:一个常见坏味道
再讲日志层面的异常处理,这里藏着大量“看起来没关系、线上出事才后悔”的写法。
第一种坏味道是空catch:
try { api.call(); } catch (Exception e) { // 忽略 }异常吞掉,等于事故现场被销毁。线上出问题,日志里干干净净,查无可查。这是我最不能接受的一种写法。
第二种坏味道是e.printStackTrace()。它把堆栈打到标准错误流,多线程环境下输出会互相穿插,很难看;而且拿不到上下文参数,不知道是哪个用户、哪条数据触发的;在有日志框架的项目里,这行还不会进日志文件。
正确写法是:
log.error("处理订单失败, orderId={}", orderId, e);一句话把关键上下文参数放进去,异常对象传在最后。日志框架用占位符{},只在真正要输出时才拼接字符串,比手动拼+高效得多,也避免日志量大时产生无谓的字符串对象。
5. equals与hashCode:一面镜子,照出你基础深浅
5.1 Object默认实现:地址比较
equals和hashCode是Java基础里最简单也最容易被看轻的两个方法。先说Object的默认实现:equals比较的是引用地址,也就是==;hashCode返回的是根据对象内存地址算出来的一个整数。两个对象只有是同一个对象时,equals才为true。
所以当你定义一个数据类,不重写equals,然后往HashSet里add两个字段值完全一样的对象,你会惊讶地发现:两个都加进去了。
5.2 只重写equals的连锁反应
Java有一条规范约定:两个对象equals相等,hashCode必须相等。反过来不成立——hashCode相等,equals可以不相等(这叫哈希碰撞,HashMap靠equals在同桶内区分)。
为什么必须有这个约定?看HashMap的查找逻辑:先算key的hashCode定位桶,再用equals在桶内逐个比对。如果两个equals相等的对象,hashCode却不同,那么它们会被分到不同的桶里,HashMap就永远找不到那个“应该存在”的值。
我见过一个实战事故:一个自定义类只重写了equals,没重写hashCode,放进HashMap做key,结果map.get(key)永远返回null。排查半天,最后发现hashCode用的是Object默认实现,每次new的key对象hashCode都不同,桶都定位错了,equals再怎么写都没用。
所以重写规则必须成对出现:
@Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return id == user.id; } @Override public int hashCode() { return Objects.hash(id); }这里有个细节:Objects.hash底层会调Arrays.hashCode,把所有字段拼进一个数组算散列值。字段越多,返回值分布越好,但计算成本也稍高。极致的做法是只用equals里参与比较的关键字段算hash,够用即可。
5.3 业务主键:两个对象什么时候算“相等”
说到equals,就不得不提一个基本问题:业务里两个对象怎么算相等?如果两个User对象的id都是10086,它们是不是同一个人?业务上当然是。但默认的equals会说不是,因为它们是堆里的两个对象。
这就是重写equals的意义——把“对象地址相等”升级成“业务含义相等”。凡是涉及集合去重、map查找、contains判断的地方,全靠这个定义是否准确。实际开发里,我建议数据模型类都明确设计equals和hashCode,用业务主键字段参与比较,而不是偷懒用IDE生成的全字段比较。全字段比较的问题是:只要任何一个字段变了,对象相等性就变了,如果这些对象还挂在某个Set里,你改了个无关字段,它在Set里的位置就找不到了。
5.4 开始动手前,过一遍检查清单
写完equals/hashCode,我会习惯性对照一遍清单:
- 自反性:x.equals(x)必须为true
- 对称性:x.equals(y)和y.equals(x)结果必须一致
- 传递性:x等于y、y等于z,则x必须等于z
- 一致性:对象不变时,多次调用equals结果必须一致
- 非空性:x.equals(null)必须为false
还有一个隐蔽的坑:equals里用到的字段必须是稳定不变的。如果用了可变字段,比如一个可set的count字段,那么对象在Set里待着的时候,别人改了这个字段,该对象的hashCode就变了,集合里的位置就“错位”了,后面contains和remove全都找不到它。这和HashMap扩容、内存泄漏一起,并称为集合使用三大暗坑。
6. 补基础的正确姿势:我的几个土办法
6.1 看字节码胜过背博客
学Java基础,我最大的心得是:别只看文章,去翻编译器产物。JDK装好后自带javap命令,配合-c参数,可以看到class文件的字节码。
比如你想搞懂String拼接为什么慢,就写个循环,javap -c一看,循环体里全是new StringBuilder。你想搞懂try-with-resources,反编译一下,finally块里的close和addSuppressed,都会原形毕露。很多博客里讲得云里雾里的东西,字节码一打开,一切都直观明了。
我不是说要学会手写字节码,而是要看懂它。看懂字节码,就等于站在编译器视角看Java,语法糖的下面是什么,异常怎么处理,装箱拆箱怎么发生的,全都透明了。
6.2 写一个“什么优化都不做”的Demo
遇到性能相关问题,我喜欢写一个最小可复现的Demo,跑起来看真实行为,而不是凭感觉猜。比如验证Integer缓存,就写一行Integer.valueOf(127) == Integer.valueOf(127),在循环里跑,观察对比结果。验证HashMap的扩容,就写个循环往里塞key,配合-XX:+PrintGCDetails看GC日志。
这种“亲手验证”的过程,比看十篇博客记得都牢。因为踩过坑之后,你对坑的形状是有肌肉记忆的。
6.3 用好调试器和对象内存探针
IDEA的调试器可以实时查看对象的引用地址和字段值,排查“两个变量是不是指向同一个对象”这类问题,直接看地址就行。
还有个小众工具值得一试:JOL(Java Object Layout)。它可以把一个对象在内存里的布局打印出来——对象头多大,字段怎么排列,总共占用多少字节。用它你就能深刻理解:为什么一个空的Integer对象比基本类型int多出十多个字节的开销;为什么数组除了数据本身,还要多一块长度字段。
理解了对象布局,很多性能优化决策就有了依据。比如缓存大量小对象时,能换成基本类型数组就换,结构性节省内存。
6.4 我给自己定的学习原则
说了这么多,总结成一条原则:学习Java基础,永远要追问“编译器做了什么”“JVM做了什么”“为什么这么做”。语法是表象,字节码是真相,源码是设计者留下的答案。
我在实际项目里的体会是,基础扎实的人写代码,往往不是写得多快,而是写出来的东西到了线上很少出幺蛾子。他们知道字符串拼接会带来多少临时对象,知道集合扩容会引发什么样的GC压力,知道异常不能拿来当if用。这些“知道”,不是背八股背来的,是啃源码、看字节码、踩线上坑踩出来的。
如果你觉得自己Java基础还有漏洞,不着急,从把javap用熟开始,从写一个HashMap的debug Demo开始,从给自己的数据类好好设计equals和hashCode开始。基础这件事,没有速成,只有一个个细节磨出来。等我下次面试,再把“finalize”这个问题抛给你,希望你能毫不犹豫说出:那玩意儿早就废弃了,别用它。