Java - 基础篇

0.0 为什么这篇是第 0 篇

集合篇从 ArrayList 讲起,多线程篇从 synchronized 讲起——它们都默认你已经懂 == 和 equals 的区别、懂值传递、懂泛型擦除。这篇就是把那些「默认」补上。

版本坐标(2026-10):

JDK发布定位
82014-03存量项目基线,面试语法默认口径
112018-09LTS
172021-09LTS,Spring Boot 3 / 4 的基线
212023-09LTS,虚拟线程 GA
252025-09当前 LTS
26 / 272026-03 / 2026-09非 LTS,尝鲜

面试口径:语法按 JDK 8 答,特性按 17 / 21 补。被问到「你们用的 JDK 多少」,答 17 或 21 都很常见。


0.1 基本类型与包装类

八种基本类型

类型字节范围默认值包装类
byte1-128 ~ 1270Byte
short2-32768 ~ 327670Short
int4-2³¹ ~ 2³¹-10Integer
long8-2⁶³ ~ 2⁶³-10LLong
float4±3.4e-38 ~ 3.4e380.0fFloat
double8±4.9e-324 ~ 1.8e3080.0dDouble
char20 ~ 65535(UTF-16 码元)'\u0000'Character
boolean1(JVM 实现相关)true / falsefalseBoolean

自动装箱与拆箱

Integer a = 127, b = 127;
System.out.println(a == b);      // true  —— 命中缓存
Integer c = 128, d = 128;
System.out.println(c == d);      // false —— 各自 new 了一个
System.out.println(c.equals(d)); // true
  • Integer 内部有 IntegerCache,默认缓存 -128 ~ 127(下界可调,上界可用 -XX:AutoBoxCacheMax 改)
  • 比较值永远用 equals,== 比的是引用
  • Integer 与 int 比较时,Integer 会自动拆箱,所以 c == 128 为 true

空指针陷阱:

Integer x = null;
int y = x;   // NullPointerException:自动拆箱时调用 x.intValue()

金额为什么不能用 double

float / double 用 IEEE 754 二进制表示,0.1 无法精确表示:

System.out.println(0.1 + 0.2);           // 0.30000000000000004
System.out.println(new BigDecimal(0.1)); // 0.1000000000000000055511151231257827...

金额一律用 BigDecimal,并且必须用 String 构造(new BigDecimal("0.1")),用 double 构造会把误差原样带进来。比较用 compareTo(),不要用 equals()(后者连精度 1.0 vs 1.00 都不一样)。

为什么泛型不能用基本类型

List<int> list = new ArrayList<>();   // ❌ 编译错误
List<Integer> list = new ArrayList<>(); // ✅

泛型在编译期会被擦除成 Object,基本类型不是对象,无法参与。需要装箱——这也是 List<Integer> 存大量小整数时内存开销远大于 int[] 的原因。


0.2 String 与字符串池

为什么 String 不可变

public final class String {
    private final byte[] value;   // JDK 9+ Compact Strings:Latin-1 用 1 字节/字符
    private int hash;             // 懒计算并缓存
}

不可变带来三个好处:

  1. 线程安全:无状态可变,天然可共享
  2. 可缓存 hash:HashMap 的 key 不用每次重算
  3. 字符串常量池:同一字面量可以安全复用

JDK 8 及以前是 private final char[] value(2 字节/字符),JDK 9 的 Compact Strings(JEP 254) 改成 byte[] + coder,纯 ASCII 字符串内存直接减半。

字符串常量池

String s1 = "abc";              // 字面量 → 池
String s2 = "abc";              // 复用池中同一个对象
String s3 = new String("abc");  // 强制在堆上新建
String s4 = s3.intern();        // 手动入池,返回池中引用

s1 == s2;  // true
s1 == s3;  // false
s1 == s4;  // true
问题答案
new String("abc") 创建几个对象?池里没有:2 个(池中字面量 + 堆对象);池里已有:1 个
String s = "a" + "b" 编译后是什么?编译期常量折叠,直接是 "ab"
池在哪儿?JDK 7 起从永久代移到堆(避免 PermGen OOM)

字符串拼接的真实实现

String s = "a" + b + "c";   // 普通表达式
  • JDK 8:反编译看到 new StringBuilder().append(a).append(b).append(c).toString()
  • JDK 9+:改用 invokedynamic + StringConcatFactory,由 JVM 在运行期选择最优策略

但循环里仍然是坑——每次迭代都会新建对象:

// ❌ O(n²)
String s = "";
for (String part : parts) s += part;

// ✅ O(n)
StringBuilder sb = new StringBuilder();
for (String part : parts) sb.append(part);

String / StringBuilder / StringBuffer

StringStringBuilderStringBuffer
可变性不可变可变可变
线程安全安全(不可变)❌✅(方法加 synchronized)
性能拼接纳差最快比 StringBuilder 慢
场景常量、key单线程拼接多线程共享拼接(很少见)

实际项目里 StringBuffer 基本被淘汰:需要线程安全时,通常用局部 StringBuilder 就够了。


0.3 == / equals / hashCode

三者关系

作用默认实现
==基本类型比值,引用类型比地址—
equals逻辑相等Object.equals 就是 ==
hashCode散列值,用于哈希表定位对象地址衍生的整数(HotSpot 实现)

equals 的契约

重写 equals 必须满足:

  1. 自反:x.equals(x) 为 true
  2. 对称:x.equals(y) 与 y.equals(x) 一致
  3. 传递:x=y、y=z ⇒ x=z
  4. 一致:只要参与比较的状态没变,结果不变
  5. 非空:x.equals(null) 为 false

为什么重写 equals 必须重写 hashCode

HashMap 的查找逻辑是:

先比 hash → 落到桶 → 桶内再比 equals

如果两个对象 equals 相等但 hashCode 不同,它们会被分到不同的桶,map.get() 永远找不到——HashMap 直接坏了。

@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 && Objects.equals(name, user.name);
}

@Override
public int hashCode() {
    return Objects.hash(id, name);
}

这是双向的:只重写 hashCode 不重写 equals 同样会让集合行为错乱,规范要求两者一起改。

可变对象不能当 HashMap 的 key

List<String> key = new ArrayList<>(List.of("a"));
map.put(key, 1);
key.add("b");            // hashCode 变了
map.get(key);            // null —— 按新 hash 找不到旧桶了

用 String、Integer、record(不可变)当 key 最稳。


0.4 面向对象

重载 vs 重写

重载 Overload重写 Override
发生位置同一个类父子类
方法签名方法名同,参数列表不同方法名 + 参数列表完全相同
绑定时机编译期(静态绑定)运行期(动态绑定)
返回值可不同必须相同或是子类(协变返回)
访问修饰符可不同不能更严格
异常可不同不能抛更宽的检查异常
static / private / final—不能重写(static 是隐藏,不是重写)

抽象类 vs 接口

维度抽象类接口
继承单继承多实现
成员变量任意public static final 常量
方法抽象 + 具体抽象 + default + static(JDK 8)+ private(JDK 9)
构造器有无
设计语义is-a,共享状态与骨架can-do,定义能力契约

选择标准:需要复用状态/代码 → 抽象类;只需要约束行为 → 接口。现代 Java 里,接口 + default 已经能覆盖大部分”模板方法”的用法。

多态与动态绑定

Animal a = new Dog();
a.speak();   // 调用 Dog.speak() —— 运行期根据实际类型决定
  • 向上转型自动完成,向下转型需要 instanceof 检查
  • 字段没有多态:a.name 取的是 Animal 里声明的那个(编译期绑定)
  • static 方法也没有多态,只有隐藏
// JDK 16+ 模式匹配,省掉强转
if (a instanceof Dog dog) {
    dog.fetch();
}

Java 只有值传递

public static void swap(int a, int b) {
    int t = a; a = b; b = t;   // 只改了副本,外面纹丝不动
}

对象看起来”被修改了”,是因为传的是引用的副本——副本和原引用指向同一个对象,所以能改对象内容,但改不了引用本身。

初始化顺序

父类静态变量 / 静态块
  → 子类静态变量 / 静态块
    → 父类实例变量 / 实例块 → 父类构造器
      → 子类实例变量 / 实例块 → 子类构造器

静态部分只执行一次,在类加载的初始化阶段;实例部分每次 new 都走一遍。

final / static / this / super

关键字作用
final 变量只能赋值一次(引用不可变,对象内容可变)
final 方法不可重写
final 类不可继承(String、Integer)
static属于类,所有实例共享;静态方法里不能用 this
this当前对象引用;this() 调本类构造器
super父类引用;super() 调父类构造器,必须第一行

注意:final 字段的可见性依赖 JMM 的 final 语义——正确构造的对象,其它线程能看到构造器里赋的 final 值,不需要额外同步(详见多线程篇)。


0.5 异常体系

体系结构

Throwable
├── Error                     // 程序无法处理,别 catch
│   ├── OutOfMemoryError
│   ├── StackOverflowError
│   └── NoClassDefFoundError
└── Exception
    ├── RuntimeException      // 非受检(unchecked)
    │   ├── NullPointerException
    │   ├── IllegalArgumentException
    │   ├── ClassCastException
    │   └── IndexOutOfBoundsException
    └── 其它                    // 受检(checked)
        ├── IOException
        ├── SQLException
        └── InterruptedException
受检异常非受检异常
编译器强制处理✅ try-catch 或 throws❌
代表IOExceptionNullPointerException
语义可预期的外部故障,调用方应恢复编程错误,通常不该被捕获

现代实践:框架层(Spring)大量把受检异常包装成 RuntimeException(如 DataAccessException),因为受检异常会污染整条调用链的签名。

try-with-resources

try (Connection conn = ds.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql)) {
    // 自动关闭,按声明的**逆序**关闭
} catch (SQLException e) {
    // 关闭时抛的异常会作为 suppressed 挂到主异常上
}
  • 资源类必须实现 AutoCloseable(或 Closeable)
  • 关闭时抛出的异常不会覆盖业务异常,而是通过 e.getSuppressed() 取
  • 手写 finally { close(); } 的版本里,close() 抛异常会吞掉原异常

finally 的三个坑

// 坑 1:finally 里 return,覆盖 try 的返回值
try { return 1; } finally { return 2; }   // 返回 2

// 坑 2:finally 里抛异常,吞掉原异常
try { throw new A(); } finally { throw new B(); }  // 只看到 B

// 坑 3:finally 修改基本类型返回值无效(副本已保存)
int x = 1;
try { return x; } finally { x = 2; }      // 返回 1

异常的性能

fillInStackTrace() 会遍历调用栈,每 new 一个异常都是一次栈采集。所以:

  • 不要用异常做流程控制(比如 for 循环里 catch (NumberFormatException))
  • 频繁抛的异常可以复用(没有栈的异常)
  • JIT 对反复抛出的隐式异常会省略栈信息(OmitStackTraceInFastThrow),日志里出现无栈的 NPE 就是这个原因

0.6 泛型与类型擦除

什么是类型擦除

List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
a.getClass() == b.getClass();   // true,都是 java.util.ArrayList

编译后泛型参数被擦成上界(默认 Object),运行期不存在泛型信息。

擦除带来的限制

写法结果原因
new T()❌运行期不知道 T 是什么
new T[]❌同上
instanceof List<String>❌运行期没有泛型参数
catch (MyException<String> e)❌同上
重载 f(List<String>) / f(List<Integer>)❌擦除后签名相同
List<int>❌基本类型不是 Object

需要运行期类型时,用 Class<T> 参数或 TypeToken(Gson)/ ParameterizedTypeReference(Spring)这类技巧。

通配符与 PECS

PECS:Producer Extends, Consumer Super。

// 只读(生产):能读成 Number,不能写
void readAll(List<? extends Number> list) {
    Number n = list.get(0);   // ✅
    // list.add(1);           // ❌ 不知道实际是 List<Integer> 还是 List<Double>
}

// 只写(消费):能写 Integer,读出来只能是 Object
void writeAll(List<? super Integer> list) {
    list.add(1);              // ✅
    Object o = list.get(0);   // ✅ 只能当 Object
}

经典应用:Collections.copy(List<? super T> dest, List<? extends T> src)。

桥方法

class Node { public Object get() { return null; } }
class IntNode extends Node {
    @Override public Integer get() { return 1; }   // 协变返回
}

编译器会生成一个合成桥方法 Object get() 来转发到 Integer get(),保证多态正确。反编译能看到 ACC_BRIDGE 标志。


0.7 反射与动态代理

反射

Class<?> clazz = Class.forName("com.example.User");  // 会触发类初始化
Object obj = clazz.getDeclaredConstructor().newInstance();
Method m = clazz.getDeclaredMethod("setName", String.class);
m.setAccessible(true);
m.invoke(obj, "Tom");
获取 Class 的方式是否初始化
Class.forName("X")✅ 会
X.class❌ 不会
obj.getClass()❌ 不会
ClassLoader.loadClass("X")❌ 不会

性能:Method.invoke 涉及参数装箱、访问检查,比直接调用慢一个量级。优化手段:

  • 缓存 Method 对象(别在循环里 getDeclaredMethod)
  • MethodHandle / LambdaMetafactory(框架常用,接近直接调用)
  • JDK 18+ 的 MethodHandles 与 invokedynamic 组合

JDK 17 起强封装(JEP 403):反射访问 JDK 内部 API(如 sun.misc.Unsafe 之外的大量 java.base 私有成员)会直接抛 InaccessibleObjectException,需要启动参数 --add-opens。老框架升级到 17+ 报这个错,原因就在这里。

JDK 动态代理 vs CGLIB

JDK 动态代理CGLIB
前提目标必须实现接口继承目标类,无需接口
生成方式Proxy.newProxyInstance + InvocationHandlerASM 生成子类字节码
限制只能代理接口方法不能代理 final 类 / final 方法 / private 方法
性能调用略慢(反射)生成慢,调用快(子类直接调用)
Object proxy = Proxy.newProxyInstance(
    target.getClass().getClassLoader(),
    target.getClass().getInterfaces(),
    (p, method, args) -> {
        System.out.println("before " + method.getName());
        return method.invoke(target, args);
    });

Spring AOP 的选择:目标有接口时可用 JDK 代理;Spring Boot 2.x 起默认 proxyTargetClass = true,统一走 CGLIB。这也是为什么在 Spring 里对 final 方法加 @Transactional 不生效。

动态代理的经典用途

  • Spring AOP / 事务 / 缓存
  • MyBatis Mapper 接口(没有实现类,靠代理生成)
  • RPC 框架的客户端存根
  • @Async、@Retryable 等注解的运行时增强

0.8 注解与 SPI

注解

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface MyAnno {
    String value() default "";
}
元注解含义
@Target能标注在哪儿(类 / 方法 / 字段 / 参数…)
@Retention保留到哪一阶段:SOURCE / CLASS / RUNTIME
@Documented生成 Javadoc
@Inherited子类是否继承父类上的注解

只有 RUNTIME 的注解才能被反射读到。Spring 的「组合注解」(如 @SpringBootApplication = @Configuration + @EnableAutoConfiguration + @ComponentScan)本质是注解上的注解 + @AliasFor。

SPI(Service Provider Interface)

JDK 原生 SPI:接口方定义规范,实现方在 META-INF/services/<接口全限定名> 文件里写实现类名,ServiceLoader 负责发现。

ServiceLoader<Driver> drivers = ServiceLoader.load(Driver.class);
for (Driver d : drivers) { /* ... */ }
  • JDBC 4.0 起,Class.forName("com.mysql.cj.jdbc.Driver") 可以省略,就是 SPI 在起作用
  • 缺点:一次性实例化所有实现、无法按名称获取、没有依赖注入

现代变体:

框架机制特点
Spring Boot 3+META-INF/spring/...AutoConfiguration.imports取代了 2.x 的 spring.factories
DubboMETA-INF/dubbo/<接口名>支持按名称获取、@Adaptive、@Activate
Java 9+ 模块provides ... with ...模块化声明式 SPI

0.9 Java 8:函数式与 Stream

Lambda 与函数式接口

函数式接口 = 只有一个抽象方法的接口(@FunctionalInterface 只是编译期校验)。

接口签名用途
Function<T,R>R apply(T)转换
Consumer<T>void accept(T)消费
Supplier<T>T get()生产
Predicate<T>boolean test(T)判断
BiFunction<T,U,R>R apply(T,U)双参转换
UnaryOperator<T>T apply(T)同类型转换

底层:Lambda 不是匿名内部类,编译后没有独立的 .class,而是 invokedynamic + LambdaMetafactory 在运行期生成(首次调用时)。所以 Lambda 的 getClass() 是合成类,this 指向外层对象。

Stream

List<String> names = users.stream()
    .filter(u -> u.getAge() > 18)
    .map(User::getName)
    .distinct()
    .sorted()
    .collect(Collectors.toList());
特性说明
惰性求值中间操作(filter/map/sorted)不执行,遇到终止操作(collect/forEach/count)才跑
短路findFirst、anyMatch、limit 可以提前结束
不可复用一个 Stream 只能消费一次,重复用抛 IllegalStateException
并行流底层是 ForkJoinPool.commonPool

⚠️ 并行流不是万能的:它的公共线程池大小 = CPU 核数 - 1,阻塞型任务(IO、数据库、HTTP)会把公共池占满,拖垮整个 JVM 里所有并行流。有阻塞就用线程池,JDK 21+ 更推荐虚拟线程(见多线程篇)。

Optional

// ✅ 推荐
user.map(User::getAddress).map(Address::getCity).orElse("未知");
user.orElseThrow(() -> new NotFoundException(id));

// ❌ 不推荐
if (opt.isPresent()) { ... }   // 又回到了 if 判断
opt.get();                     // 没值直接抛,等于没防住
  • 不要用 Optional 做字段类型和方法参数(不可序列化,且调用方被迫包装)
  • orElse(x) 无论有没有值都会执行 x;需要懒加载用 orElseGet(() -> x)

默认方法

interface Flyable {
    default void fly() { System.out.println("fly"); }
    static Flyable create() { return () -> {}; }
}

目的:给已有接口加方法而不破坏所有实现类(Collection.stream() 就是这么加的)。JDK 9 又加了 private 接口方法,用来给多个 default 方法抽公共逻辑。


0.10 JDK 9 → 25:现在的 Java 长什么样

语法特性年表

特性版本状态说明
var 局部变量推断10正式只是语法糖,不是动态类型
文本块 """15正式多行字符串,自动处理缩进
record16正式不可变数据载体
instanceof 模式匹配16正式if (o instanceof User u)
sealed 密封类17正式限定谁能继承
switch 表达式14正式-> 语法 + 返回值
switch 模式匹配21正式按类型/守卫条件分支
记录模式21正式case Point(int x, int y)
有序集合21正式SequencedCollection(getFirst / getLast)
虚拟线程21正式见多线程篇
结构化并发25预览StructuredTaskScope,仍是第 5 轮预览
ScopedValue25预览ThreadLocal 的现代替代
灵活构造器体25正式super() 前可以写语句
紧凑源文件 / 实例 main25正式单文件跑脚本风格
字符串模板—已撤回21/22 预览,JDK 23 正式撤回(JEP 459)

record

public record Point(int x, int y) {
    public Point {                       // 紧凑构造器,做校验
        if (x < 0) throw new IllegalArgumentException();
    }
    public double distance() { return Math.hypot(x, y); }
}

编译器自动生成:private final 字段、全参构造器、访问器 x() / y()、equals / hashCode / toString。

record 是浅不可变:字段 final,但字段如果是 List,里面的元素照样能改。

sealed + 模式匹配:穷尽性检查

sealed interface Shape permits Circle, Rect {}
record Circle(double r) implements Shape {}
record Rect(double w, double h) implements Shape {}

double area = switch (shape) {
    case Circle c -> Math.PI * c.r() * c.r();
    case Rect r   -> r.w() * r.h();
    // 没有 default 也能编译——sealed 保证了穷尽
};

这是 Java 近年最有价值的组合:代数数据类型 + 穷尽性检查,写业务分支时编译器帮你兜底。

两个”已死”的东西

  • Security Manager:JDK 24 起永久禁用(JEP 486),System.setSecurityManager() 直接抛异常
  • sun.misc.Unsafe 的内存访问方法:JDK 23 标记废弃、JDK 24 起使用会告警,替代品是 VarHandle 和 MemorySegment(FFM API)

面试怎么答”你了解新版本 Java 吗”

一句话版:JDK 17 是 Spring Boot 的基线,JDK 21 带来虚拟线程,JDK 25 是当前 LTS。语法上 record / sealed / 模式匹配已经成熟;并发上虚拟线程 GA、结构化并发还在预览;字符串模板已经撤回,不要再用。


0.11 序列化

Java 原生序列化

public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private transient String password;   // 不参与序列化
}
要点说明
serialVersionUID不显式声明时,编译器按类结构生成;改一个字段就会变,导致 InvalidClassException
transient跳过该字段
自定义逻辑writeObject / readObject / readResolve(单例防破坏)
record自动支持,通过组件字段序列化

为什么现代项目不用它

  1. 安全问题:反序列化会执行 readObject 里的任意逻辑,大量 RCE 漏洞(gadget chain)由此而来。JDK 9 加了 ObjectInputFilter(JEP 290),但只是缓解
  2. 性能与体积:比 JSON / Protobuf 都差
  3. 跨语言:只有 Java 能读
场景选择
HTTP APIJSON(Jackson)
内部 RPC / 高频Protobuf / Thrift
消息队列JSON 或 Avro(带 schema 演进)
缓存JSON 或 JDK 序列化(Redis 默认)

如果非要用 Java 序列化(比如 Dubbo 的 hessian2、Redis 的 JDK 序列化),至少配上白名单过滤器,别反序列化不可信来源的数据。


0.12 面试速查

问题一句话答案
== 和 equals 区别== 比值/地址,equals 比逻辑相等,默认实现就是 ==
为什么重写 equals 要重写 hashCode否则哈希表里 equals 相等的对象可能落在不同桶,get 不到
Integer a=127,b=127; a==btrue,命中 IntegerCache(-128~127);128 就是 false
String 为什么不可变final class + final byte[],换来线程安全、hash 缓存、常量池复用
new String("a") 几个对象池中没有则 2 个,已有则 1 个
String 拼接为什么用 StringBuilder+ 在循环里每次新建对象,O(n²)
Java 是值传递吗是,对象传的是引用的副本
重载和重写的区别编译期静态绑定 / 运行期动态绑定
抽象类和接口怎么选共享状态和代码用抽象类,约束行为用接口
受检异常和非受检异常编译器是否强制处理;前者是可预期外部故障,后者是编程错误
try-with-resources 好处逆序自动关闭,关闭异常作为 suppressed 不覆盖主异常
什么是类型擦除泛型编译后擦成上界,运行期无泛型信息
PECS 是什么Producer Extends, Consumer Super
JDK 动态代理和 CGLIB接口代理 / 子类代理;Spring Boot 默认 CGLIB
SPI 是什么META-INF/services + ServiceLoader,JDBC 驱动就是这么加载的
Lambda 是匿名内部类吗不是,invokedynamic + LambdaMetafactory
并行流有什么坑公共 ForkJoinPool 被阻塞任务占满,阻塞 IO 别用
record 和普通类区别不可变数据载体,自动生成构造器/访问器/equals/hashCode/toString
sealed 有什么用限定子类,配合 switch 模式匹配做穷尽性检查
虚拟线程解决什么阻塞 IO 场景下用少量平台线程承载海量任务(详见多线程篇)

下一篇:JVM 篇——内存区域、垃圾回收、类加载与双亲委派、线上问题排查。