主题 02Java 基础
这一章不讲「Java 语法有什么」,只讲五个会当场露怯的点:值传递、String 不可变、 equals 与 hashCode、泛型擦除、自动装箱。它们有个共同特征——你天天在写,但一问「为什么」就说不清。 更关键的是,后面所有主题都建在这五块地基上:集合的 key 靠 equals/hashCode,并发的可见性靠 final 语义, Spring 的泛型注入要靠擦除之后的类型信息。
2.1为什么第二站是 Java 基础
集合排第一是因为它和算法重合最多;Java 基础排第二,是因为它是所有追问的公共底盘。 面试官不会直接问「说说 Java 基础」,但下面这些话都是它的变体:
| 你听到的问题 | 它其实在问 | 说错的代价 |
|---|---|---|
| 「为什么方法里改了对象,外面也变了?」 | 值传递 | 答成「对象是引用传递」,等于承认概念没搞清 |
| 「String 为什么设计成 final?」 | 不可变性的收益 | 只说「安全」会被追到说不出所以然 |
| 「你重写 equals 了吗?」 | equals/hashCode 契约 | 只写 equals 的代码放进 HashMap 会静默失效 |
| 「泛型在运行时还在吗?」 | 类型擦除 | 答「在」,后面所有反射问题都会跟着错 |
| 「这两行为什么输出 false?」 | 装箱缓存 | 拿 == 比 Integer 是线上事故常客 |
所以这一章的写法是:每个点先给一段能跑出反例的代码,再给结论。 下面所有输出都不是我编的,是把示例程序在 JDK 17 上跑出来的原样结果——你也可以照着敲一遍验证。
BasicsDemo,六段演示分别放在
六个静态方法里。读完一章后把它整体跑一遍,输出对得上,说明这五个点你是真看过而不是背过。
2.2值传递:为什么 swap 改不动实参
Java 只有一种传参方式:值传递。基本类型传的是数值的副本,对象传的是引用的副本—— 注意是「引用这个地址值的拷贝」,不是「引用本身」。这两句话差一个字,面试就差一整档。
static void changeInt(int x) { x = 100; } // 改副本,实参不变
static void changeFirst(int[] a) { a[0] = 100; } // 通过副本找到同一个数组,改里面的元素
static void reassign(int[] a) { a = new int[]{9, 9}; } // 让副本指向新数组,实参不受影响
static void swap(int x, int y) { int t = x; x = y; y = t; }
把它跑起来,四行输出就足以区分三种情况:
[1] 传 int : 1
[1] 改数组元素 : 100
[1] 重新赋值形参 : 1
[1] swap 之后 : a=1, b=2
逐行对照:
- 传 int 还是 1:
changeInt改的是副本x,方法结束副本就扔了。 - 数组元素变成 100:形参
a是引用的副本,但它和实参指向同一个数组对象, 所以改元素双方都看得见。这是「看起来像引用传递」的全部原因。 - 重新赋值形参没影响外面:
a = new int[]{9,9}只让副本改指新对象, 实参还指着老数组。如果真是引用传递,这里外面就该变成 9。 - swap 无效:两个 int 副本互换,方法返回就丢了。Java 没法写出真正交换基本类型的 swap 方法。
AtomicInteger,
或者返回一个持有两个字段的对象。」
三个必被追问的点
| 追问 | 回答要点 |
|---|---|
| 为什么 C++ 能写 swap,Java 不能? | C++ 有真正的引用类型(int&),形参是实参的别名;Java 没有别名,只有副本。 |
| 那 String 传进方法里为什么改不了? | 不是传参的问题,是 String 本身不可变——方法里任何「修改」都在造新对象。 |
| 数组和对象有区别吗? | 没有,数组也是对象。区别只在于是「改它指向的内容」还是「让它指向别处」。 |
2.3String:不可变、常量池与 intern
String 是八股里出场率最高的类,因为它同时踩中三件事:不可变、常量池、+ 的编译期优化。
这三件事互相解释,抓住「不可变」这一个根,其余都是推论。
先看一组 == 的结果
String a = "abc"; // 字面量 -> 常量池
String b = "abc"; // 复用同一个对象
String c = new String("abc"); // 堆上新对象
String d = "ab" + "c"; // 编译期折叠成 "abc"
String e = new StringBuilder("ab").append("c").toString();
System.out.println("a == b : " + (a == b));
System.out.println("a == c : " + (a == c));
System.out.println("a == d : " + (a == d));
System.out.println("a == e : " + (a == e));
System.out.println("c.intern()==a : " + (c.intern() == a));
a == b : true
a == c : false
a == d : true
a == e : false
c.intern()==a : true
把这五行的原因按顺序记住,String 的题就通关了一半:
| 比较 | 结果 | 原因 |
|---|---|---|
a == b | true | 两个字面量指向常量池里同一个对象 |
a == c | false | new 强制在堆上新建,哪怕内容一样 |
a == d | true | "ab" + "c" 两边都是常量,编译期就折成 "abc" |
a == e | false | 运行期拼出来的新对象,在堆上 |
c.intern() == a | true | intern() 把它「登记」进常量池,返回池里的那个 |
为什么换个写法就变了
关键在「参与拼接的是不是常量」。"ab" + "c" 两个操作数都是字面量,javac 直接算出结果写进 class 文件;
但只要有一个是变量,就必须等到运行时:
String s = "ab";
String f = s + "c";
System.out.println("a == (s+\"c\") : " + (a == f)); // false
System.out.println("a.equals(f) : " + a.equals(f)); // true
编译器会把 s + "c" 改写成 new StringBuilder().append(s).append("c").toString()
(JDK 9 之后是 invokedynamic + StringConcatFactory,语义一样)。
所以「循环里用 + 拼字符串慢」的真正原因不是「+ 慢」,而是每次循环都在造新的 StringBuilder、新的 String。
四槽速记 · Java 基础 #1
| 题 | 滑动窗口里收集字符,为什么不用 result += c? |
| 八股 | String 为什么设计成不可变?拼接的性能代价在哪? |
| 一句话 | 不可变才能安全共享常量池、缓存 hashCode;每次「修改」都是新建对象,循环里拼 n 次就是 n 个临时对象。 |
| 追问 | 那 StringBuilder 和 StringBuffer 差在哪?→ 后者方法全加 synchronized,单线程下纯属浪费。 为什么 String 适合当 HashMap 的 key?→ 不可变 + hashCode 只算一次并缓存。 |
不可变带来的三个实际收益
- 常量池才成立。如果 String 可以被改,a 和 b 共享同一块内存时,改 a 就等于偷偷改了 b—— 共享的前提就是谁都改不了。
- hashCode 可以缓存。内容不变,hash 就不会变,所以 String 把第一次算出的 hash 存进字段
hash, 之后直接返回。这就是「String 做 key 特别快」的真实原因。 - 可以放心跨线程传。不可变对象天生线程安全,不需要任何同步。
new String("abc") 创建了几个对象?
答「一个」或「两个」都对一半——取决于常量池里有没有 "abc":
没有就是 2 个(池里一个 + 堆上一个),已经有了就是 1 个(只在堆上)。
面试时把这句补充说出来,比硬答一个数字稳。
2.4equals 与 hashCode 的契约
这一节是全章最「工程」的一节:写错了不会报错,只会静默失效—— 放进 HashMap 取不出来、放进 Set 出现重复元素,跑一天都发现不了。
先看反例
static class PointBad { // 什么都没重写
int x, y;
PointBad(int x, int y) { this.x = x; this.y = y; }
}
static class Point { // 两个都重写
final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
if (this == o) return true; // 同一个对象,短路
if (!(o instanceof Point p)) return false; // 类型不对,直接 false(含 null)
return x == p.x && y == p.y;
}
@Override public int hashCode() { return 31 * x + y; }
}
未重写 equals : false
重写后 equals : true
重写后 hashCode : true
HashMap 取得到吗 : 命中
第一行 false 说明:不重写 equals 时,Object 的实现是比地址,
两个内容相同的对象永远不相等。最后一行 命中 才是重点——只有两个方法都重写,HashMap 才认得它是同一个 key。
契约四条,只记两条就够用
| 约定 | 含义 | 违反后的现象 |
|---|---|---|
equals 相等 → hashCode 必须相等 | 契约的核心,必须同时重写 | HashMap 里同一份数据存进去取不出 |
hashCode 相等 → equals 不一定相等 | 哈希冲突是允许的,靠 equals 再确认一次 | 无(正常现象,桶内链表就是干这个的) |
| 自反 / 对称 / 传递 | 自己等于自己;a=b 则 b=a;a=b、b=c 则 a=c | Set 里出现「重复」元素、contains 行为诡异 |
| 一致性 | 对象没变,多次调用结果必须一样 | 用可变字段当 hashCode 依据,对象放进 Set 后再改字段就找不到了 |
为什么系数是 31
三个原因叠在一起:31 是奇质数(乘出来不容易撞)、 31 * i 能被编译器优化成 (i << 5) - i(移位比乘法快,早期 JVM 很在意这个)、 以及 String.hashCode 用的也是 31,属于 JDK 里的经验值。你写别的质数(比如 17)也不算错, 但面试问「为什么是 31」时要能说出前两条。
public boolean equals(Point p) —— 参数写成具体类型,这是重载不是重写,
集合框架根本不会调用它(编译器加了 @Override 才会报错,所以务必加注解);
② 重写 equals 却不重写 hashCode;③ 用可变字段(比如自增 id 之外的状态)算 hashCode。
四槽速记 · Java 基础 #2
| 题 | 自定义对象当 HashMap 的 key,为什么取不回来? |
| 八股 | 为什么重写 equals 必须重写 hashCode? |
| 一句话 | HashMap 先用 hashCode 定位桶、再用 equals 在桶里认人;只重写一个,两步里必有一歩对不上。 |
| 追问 | 两个对象 hashCode 相同会怎样?→ 落在同一桶,退化成链表比较,性能下降但结果正确。 为什么参数必须是 Object?→ 否则是重载,集合调用的是 Object 版。 |
2.5泛型与类型擦除
泛型是「编译期的语法糖」——这句话要能顺手给出证据。最硬的证据是三行代码:
List<String> ls = new ArrayList<>();
List<Integer> li = new ArrayList<>();
System.out.println(ls.getClass() == li.getClass()); // true
System.out.println(ls.getClass().getName()); // java.util.ArrayList
两个泛型类的 Class 相同: true
运行时类名: java.util.ArrayList
字段的泛型描述: java.util.List<T>
运行时它们是同一个类,尖括号里的东西已经被擦掉了。擦除规则很简单:
<T> 擦成 Object,有上界(<T extends Number>)就擦成上界。
擦除带来的四个限制
| 写不出来的代码 | 原因 |
|---|---|
T[] arr = new T[10]; | 擦除后是 new Object[10],运行时不知道该造什么类型 |
if (obj instanceof List<String>) | 运行时没有 <String> 这个信息,只能判 List |
static T instance; | 静态成员属于类本身,而 T 是每个实例各自的,擦除后无从区分 |
两个方法只差泛型参数f(List<String>) / f(List<Integer>) | 擦除后签名完全一样,算重复定义,编译报错 |
那为什么还要这么设计?为了向后兼容。泛型是 JDK 5 才加的, 擦除保证老版本编译出来的 class 文件、老的第三方库不需要改一行就能在新 JVM 上跑。
怎么绕过「不能 new T[]」
@SuppressWarnings("unchecked")
static <T> T[] newArray(int n) {
return (T[]) new Object[n]; // 先造 Object[],再强转,责任自负
}
这是 JDK 自己也在用的写法(ArrayList 的 elementData 就是这么干的)。
代价是:运行时它其实是 Object[],往里面塞错类型不会当场报错,而是等到取出来赋值时才抛 ClassCastException——
这就是「泛型不安全」这个说法的来源。
通配符:PECS 一句话
Producer Extends, Consumer Super:只读用它产出的数据,用 ? extends T;
只往里写数据,用 ? super T。记不住规则就记这个场景:
List<? extends Number> 你能 get 出 Number,但 add 不进去(因为不知道具体是哪个子类);
List<? super Integer> 你能 add Integer,但 get 出来只能是 Object。
ParameterizedTypeReference)靠的是
把类型参数写在父类的泛型签名里,再从 getGenericSuperclass() 读回来——
匿名内部类 new TypeReference<List<User>>(){} 就是靠这一手。」
四槽速记 · Java 基础 #3
| 题 | 手写链表时 Node<T>[] table = new Node[16]; 为什么要加 @SuppressWarnings? |
| 八股 | 什么是类型擦除?它带来哪些限制? |
| 一句话 | 泛型只活在编译期,编译后擦到上界(默认 Object),所以运行时没有 T,也就不能 new T[]、不能 instanceof 具体泛型。 |
| 追问 | 为什么这么设计?→ 兼容 JDK 5 之前的老字节码。 那 Spring 怎么拿到泛型类型的?→ 泛型签名留在 class 文件的 Signature 属性里,通过反射读 ParameterizedType。 |
2.6自动装箱与 Integer 缓存
这一段只有一个小知识点,却是线上 bug 的常客:两个 Integer 用 == 比,有时 true 有时 false。
Integer i1 = 127, i2 = 127;
Integer i3 = 128, i4 = 128;
System.out.println("127 == 127 : " + (i1 == i2)); // true
System.out.println("128 == 128 : " + (i3 == i4)); // false
System.out.println("128 equals : " + i3.equals(i4)); // true
原因在 Integer.valueOf(int):它会先看值在不在 -128 ~ 127 这个缓存区间里,
在就直接返回缓存数组里的同一个对象,不在才 new。127 两次拿到同一个对象,所以 == 为 true;
128 两次各自 new,地址不同。
| 包装类 | 默认缓存范围 | 说明 |
|---|---|---|
Integer | -128 ~ 127 | 上界可用 -XX:AutoBoxCacheMax 调大 |
Long / Short / Byte | -128 ~ 127 | 没有可调参数 |
Character | 0 ~ 127 | 注意是 0 起步,不是 -128 |
Boolean | true / false | 只有两个常量 |
Double / Float | 没有缓存 | 浮点值太密,缓存没有意义 |
== 更阴的一个坑:三元表达式混类型导致拆箱 NPE。
Integer i = null; int j = flag ? i : 0;——只要有一支是基本类型 0,
整个表达式就会被当成 int,i 要拆箱,于是抛 NullPointerException。
现象是「明明判断了非空还是报 NPE」,很难查。
结论只有一句:包装类比较值,一律用 equals;需要算术运算前先确认非空。
至于性能,别在百万次循环里对基本类型反复装箱——List<Integer> 装 100 万个 int,
内存开销大约是 int[] 的 4 倍以上(对象头 + 引用)。
2.7初始化顺序:谁先谁后
这是唯一一节「不用理解、只要背对顺序」的内容,但答错的人出乎意料地多。看输出比看文字快:
static class Parent {
static { System.out.println("1 父类静态块"); }
{ System.out.println("3 父类实例块"); }
Parent() { System.out.println("4 父类构造器"); }
}
static class Child extends Parent {
static { System.out.println("2 子类静态块"); }
{ System.out.println("5 子类实例块"); }
Child() { System.out.println("6 子类构造器"); }
}
new Child();
1 父类静态块
2 子类静态块
3 父类实例块
4 父类构造器
5 子类实例块
6 子类构造器
把它拆成两段记,就不会混:
- 第一段是「类初始化」(1、2):父类先、子类后,而且整个程序生命周期只跑一次——
再次
new Child()时这两行不会再打印。 - 第二段是「对象初始化」(3–6):父类实例块 → 父类构造器 → 子类实例块 → 子类构造器,
每次
new都完整跑一遍。父类先于子类,是因为子类构造器第一句隐式调了super()。
static final int MAX = 100; 这种「编译期常量」
会被直接内联到调用方,读它不会加载那个类,静态块自然也不执行。
只有「运行期才能确定」的 static final(比如 System.currentTimeMillis() 或 new 出来的对象)才会触发。
final 的三种用法,别混在一起
| 修饰 | 保证什么 | 不保证什么 |
|---|---|---|
| final 变量 | 引用/值只能赋值一次 | 对象内部状态可以变:final List<Integer> l = new ArrayList<>() 照样 add |
| final 方法 | 子类不能重写 | 不影响重载 |
| final 类 | 不能被继承(String 就是) | 不影响它的字段是否可变 |
顺带记住 final 在并发里的作用:被 final 修饰的字段,在构造器里写完就有安全发布的保证,
别的线程看到这个对象时一定看得到它的 final 字段值——这也是后面并发章要展开的 volatile /
内存屏障的起点。
2.8本章速记卡与自测
这一章的五个点都属于「一句话能说完、但必须说准」的类型。下面这张表就是面试前一晚要过的东西。
| # | 八股问题 | 一句话答案 | 最可能的追问 |
|---|---|---|---|
| 1 | Java 是值传递还是引用传递? | 只有值传递;对象传的是引用的副本,能改内部状态、不能让实参改指向。 | C++ 为什么能 swap?→ 它有真正的引用别名。 |
| 2 | String 为什么不可变? | 为了常量池能共享、hashCode 能缓存、能安全跨线程传递。 | new String("abc") 几个对象?→ 池里没有就 2 个,有就 1 个。 |
| 3 | 重写 equals 为什么必须重写 hashCode? | HashMap 先按 hashCode 找桶、再按 equals 认人,少一个就取不回来。 | 参数为什么是 Object?→ 否则是重载不是重写。 |
| 4 | 什么是类型擦除? | 泛型只在编译期生效,编译后擦到上界,运行时不保留具体类型。 | Spring 怎么拿到泛型?→ 读 class 的 Signature 属性。 |
| 5 | Integer 128 == 128 为什么是 false? |
只有 -128~127 走缓存,超出范围各自 new,比的是地址。 | 包装类怎么比值?→ equals,运算前先判空。 |
| 6 | 类初始化的顺序? | 父静态 → 子静态(只一次)→ 父实例块 → 父构造 → 子实例块 → 子构造。 | 静态常量会触发初始化吗?→ 编译期常量不会,被内联了。 |
自测:能答上来再离开本章
- 不看书,说出
a == b/a == c/a == d三行为什么是 true、true、false。 - 写出一个「重写 equals 但忘了 hashCode」的类,并解释它放进 HashSet 后会出什么现象。
- 解释为什么
new T[10]编译不过,说出 JDK 自己的两种绕法。 - 把
127 / 128那段改成Long版,预测输出并说明理由。 - 为什么
final int[] a = {1,2}; a[0] = 9;是合法的?它违反了 final 的哪条想象?