Java 后端八股精讲一问一答,背得下也答得出 速查 追问链路 ⌂ 全部教程

主题 02Java 基础

这一章不讲「Java 语法有什么」,只讲五个会当场露怯的点:值传递、String 不可变、 equals 与 hashCode、泛型擦除、自动装箱。它们有个共同特征——你天天在写,但一问「为什么」就说不清。 更关键的是,后面所有主题都建在这五块地基上:集合的 key 靠 equals/hashCode,并发的可见性靠 final 语义, Spring 的泛型注入要靠擦除之后的类型信息。

学习目标
五个点都能「一句话结论 + 一个反例」地说完
前置要求
会写泛型代码、用过 HashMap 当 key
本章产出
4 张四槽速记卡 + 1 份能跑出全部结论的验证程序
建议学时
2–3 小时

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

逐行对照:

面试时的正确说法:「Java 只有值传递。对象传的是引用的副本,所以能改对象内部状态, 但不能让实参指向新对象。要交换两个值只能包一层——用数组、AtomicInteger, 或者返回一个持有两个字段的对象。」

三个必被追问的点

追问回答要点
为什么 C++ 能写 swap,Java 不能? C++ 有真正的引用类型(int&),形参是实参的别名;Java 没有别名,只有副本。
那 String 传进方法里为什么改不了? 不是传参的问题,是 String 本身不可变——方法里任何「修改」都在造新对象。
数组和对象有区别吗? 没有,数组也是对象。区别只在于是「改它指向的内容」还是「让它指向别处」。
最容易被扣分的一句:「基本类型是值传递,对象是引用传递。」 前半句对、后半句错,而且错得很典型——Java 里没有引用传递这种东西。 正确说法只有一句:传的都是值的副本,只是对象的值本身就是一个引用。

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 == btrue两个字面量指向常量池里同一个对象
a == cfalsenew 强制在堆上新建,哪怕内容一样
a == dtrue"ab" + "c" 两边都是常量,编译期就折成 "abc"
a == efalse运行期拼出来的新对象,在堆上
c.intern() == atrueintern() 把它「登记」进常量池,返回池里的那个

为什么换个写法就变了

关键在「参与拼接的是不是常量」。"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 只算一次并缓存。

不可变带来的三个实际收益

  1. 常量池才成立。如果 String 可以被改,a 和 b 共享同一块内存时,改 a 就等于偷偷改了 b—— 共享的前提就是谁都改不了。
  2. hashCode 可以缓存。内容不变,hash 就不会变,所以 String 把第一次算出的 hash 存进字段 hash, 之后直接返回。这就是「String 做 key 特别快」的真实原因。
  3. 可以放心跨线程传。不可变对象天生线程安全,不需要任何同步。
高频判断题: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=cSet 里出现「重复」元素、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。

面试时的正确说法:「泛型是编译期的类型检查,编译后擦除到上界,所以运行时拿不到具体类型参数。 需要保留类型信息的场景(JSON 反序列化、Spring 的 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没有可调参数
Character0 ~ 127注意是 0 起步,不是 -128
Booleantrue / 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 子类构造器

把它拆成两段记,就不会混:

经典坑:静态常量不触发类初始化。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本章速记卡与自测

这一章的五个点都属于「一句话能说完、但必须说准」的类型。下面这张表就是面试前一晚要过的东西。

#八股问题一句话答案最可能的追问
1Java 是值传递还是引用传递? 只有值传递;对象传的是引用的副本,能改内部状态、不能让实参改指向。 C++ 为什么能 swap?→ 它有真正的引用别名。
2String 为什么不可变? 为了常量池能共享、hashCode 能缓存、能安全跨线程传递。 new String("abc") 几个对象?→ 池里没有就 2 个,有就 1 个。
3重写 equals 为什么必须重写 hashCode? HashMap 先按 hashCode 找桶、再按 equals 认人,少一个就取不回来。 参数为什么是 Object?→ 否则是重载不是重写。
4什么是类型擦除? 泛型只在编译期生效,编译后擦到上界,运行时不保留具体类型。 Spring 怎么拿到泛型?→ 读 class 的 Signature 属性。
5Integer 128 == 128 为什么是 false? 只有 -128~127 走缓存,超出范围各自 new,比的是地址。 包装类怎么比值?→ equals,运算前先判空。
6类初始化的顺序? 父静态 → 子静态(只一次)→ 父实例块 → 父构造 → 子实例块 → 子构造。 静态常量会触发初始化吗?→ 编译期常量不会,被内联了。

自测:能答上来再离开本章

  1. 不看书,说出 a == b / a == c / a == d 三行为什么是 true、true、false。
  2. 写出一个「重写 equals 但忘了 hashCode」的类,并解释它放进 HashSet 后会出什么现象。
  3. 解释为什么 new T[10] 编译不过,说出 JDK 自己的两种绕法。
  4. 把 127 / 128 那段改成 Long 版,预测输出并说明理由。
  5. 为什么 final int[] a = {1,2}; a[0] = 9; 是合法的?它违反了 final 的哪条想象?
一句话记忆(主题 02):Java 基础的所有坑都来自同一个原因—— 「你以为在改对象,其实在改副本;你以为运行时还留着类型,其实早被擦掉了」。 抓住「值传递」和「擦除」这两条,剩下三个(String 不可变、equals 契约、装箱缓存)都是它们的推论。