模块一 · 语言与集合
Java 基础:类型、对象与集合
从基本类型与包装类型讲到 equals/hashCode、String 与集合选型,把面试最常问的"语言底座"一次讲透,并给出高频追问的答法。
读完这一节,你能做到
- 说清基本类型与包装类型的差异,以及自动装箱的缓存边界。
- 解释 equals 与 hashCode 的契约,知道为什么重写要成对。
- 说清 String 为什么不可变,以及字符串拼接的正确写法。
- 在 List/Set/Map 之间做出有依据的选择。
一、基本类型与包装类型
Java 有八种基本类型:byte、short、int、long、float、double、char、boolean。它们直接存值,不是对象,不能为 null,默认值分别是 0、0.0、false、'\u0000'。
包装类型是对象,可以为 null,能参与泛型与集合。两者之间的转换叫装箱/拆箱:
Integer a = 127; // 自动装箱,走 Integer.valueOf
Integer b = 127;
System.out.println(a == b); // true,命中缓存
Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false,超出缓存范围
Integer 缓存默认是 -128 到 127,可以用 -XX:AutoBoxCacheMax 调整上界。比较包装类型一律用 equals,不要用 ==。
拆箱的风险是空指针:Integer x = null; int y = x; 会抛 NPE。在 Map 取值、三元表达式混用包装类型时最容易出现。
二、equals 与 hashCode 的契约
契约有四条:
- 自反:
x.equals(x)为 true。 - 对称:
x.equals(y)与y.equals(x)一致。 - 传递:x=y、y=z 则 x=z。
- 一致:只要参与比较的字段没变,多次调用结果一致。
hashCode 的契约是:equals 相等的两个对象,hashCode 必须相等;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 && Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name);
}
只重写 equals 不重写 hashCode,对象放进 HashSet/HashMap 会"找不到":因为哈希桶位置不同。反过来只重写 hashCode 不重写 equals,会出现"哈希相同但被判不等"。
三、String 为什么不可变
String 内部是 final 的字符数组(Java 9 之后是 byte[] + coder),且不提供修改方法。不可变带来四个好处:
- 可以安全地共享与缓存(字符串常量池)。
- hashCode 可以缓存,作为 HashMap 的 key 更快。
- 天然线程安全。
- 作为参数传递时不会被意外修改。
String s = "a";
for (int i = 0; i < 1000; i++) s += i; // 每次新建对象
循环拼接要用 StringBuilder(单线程)或 StringBuffer(多线程)。编译期能确定的常量拼接会被优化,但循环里的 += 不会。
intern() 把字符串放进常量池并返回池中引用;JDK 7 之后常量池移到堆里,intern 的代价与收益都要重新评估。
四、集合选型
| 接口 | 常用实现 | 特点 | 适用 |
|---|---|---|---|
| List | ArrayList | 数组,随机访问 O(1),中间插入 O(n) | 读多写少 |
| List | LinkedList | 双向链表,头尾操作 O(1),随机访问 O(n) | 频繁头尾插入 |
| Set | HashSet | 基于 HashMap,无序 | 去重 |
| Set | TreeSet | 红黑树,有序 | 需要排序 |
| Set | LinkedHashSet | 维护插入顺序 | 去重且要顺序 |
| Map | HashMap | 数组+链表+红黑树 | 通用 |
| Map | TreeMap | 红黑树,按 key 排序 | 范围查询 |
| Map | LinkedHashMap | 维护插入/访问顺序 | LRU 缓存 |
ArrayList 的扩容是 1.5 倍,扩容时复制数组;已知大小时用 new ArrayList<>(size) 预分配。
fail-fast:迭代过程中结构被修改,会抛 ConcurrentModificationException。要删除元素用 Iterator.remove() 或 removeIf,不要用 for-each 里调 list.remove。
五、泛型与类型擦除
泛型在编译期检查,运行时擦除为 Object(或上界)。带来三个后果:
- 不能
new T()、不能new T[]。 - 不能对泛型做
instanceof。 - 静态成员不能使用类的泛型参数。
通配符:? extends T 用于读取(上界),? super T 用于写入(下界),即 PECS 原则(Producer Extends, Consumer Super)。
对象创建与初始化顺序
new 一个对象时,执行顺序是:父类静态块 → 子类静态块 → 父类实例块 → 父类构造 → 子类实例块 → 子类构造。静态块只执行一次,实例块每次创建都执行。
这个顺序解释了两件事:为什么子类构造里能读到已初始化的父类字段;为什么静态块里不能使用实例字段。
final、static 与不可变
final修饰变量:只能赋值一次;修饰方法:不能重写;修饰类:不能被继承。static属于类,不属于实例;静态方法不能访问实例成员,也没有 this。- 不可变对象要求:类 final、字段 final、不暴露可变引用、构造时完成初始化。
不可变对象天然线程安全,适合做 Map 的 key 与缓存的值;代价是每次修改都要新建对象。重写 equals 时用 getClass 还是 instanceof?用 getClass 能保证对称性;允许子类比较时再用 instanceof,但要谨慎。先契约,后实现;契约先行。
六、Java 8 到 21:哪些特性改变了写法
面试常问"你用的版本有什么特性",重点不是背清单,而是说清这些特性解决了什么问题。
| 版本 | 特性 | 解决什么 | 现在还用吗 |
|---|---|---|---|
| 8 | Lambda、Stream、Optional、接口默认方法 | 把"怎么做"换成"做什么",减少样板代码 | 必用 |
| 10 | var 局部变量推断 | 省掉冗长的泛型声明 | 可用,别滥用 |
| 14 / 16 | record 记录类 | 不可变数据载体,自动生成 equals/hashCode/toString | DTO 与值对象推荐 |
| 15 / 17 | sealed 密封类与接口 | 限制谁能继承,配合模式匹配做穷尽判断 | 领域模型好用 |
| 16 / 21 | 模式匹配(instanceof 模式、switch 模式) | 判断类型后直接拿到变量 | 21 起推荐 |
| 21 | 虚拟线程 | 用同步写法获得接近异步的吞吐 | IO 密集场景收益大 |
三个最该讲清楚的
① Stream 不是"更快的循环"
// 命令式:关心怎么做
List<String> names = new ArrayList<>();
for (User u : users) {
if (u.isActive()) names.add(u.getName());
}
// 函数式:关心做什么
List<String> names = users.stream()
.filter(User::isActive)
.map(User::getName)
.toList();
Stream 的价值是可组合与可读性,不是性能。简单循环用 for 更快也更好读,链式操作长的时候才划算。
② Optional 是用来做返回值的
它解决的是"方法可能返回 null"的契约问题,不是替代所有 null 判断。三个约定:
- 返回值用
Optional<T>,字段和参数不要用。 - 用
orElse/orElseThrow/ifPresent,不要isPresent()加get()。 - 集合返回值不用 Optional,返回空集合即可。
③ 虚拟线程改变的是并发模型
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> handle(request));
}
平台线程数受内存限制(每条约 1MB 栈),虚拟线程由 JVM 调度,可以开几十万个。它不提升 CPU 密集任务的性能,收益在 IO 等待多的场景;同时要避免在虚拟线程里长时间持有 synchronized(会钉住载体线程),改用 ReentrantLock。
回答这类问题的骨架:先说版本,再说你实际用了哪个特性,最后说它带来的取舍。只说"我用 Java 17"没有任何信息量。
七、常见坑
- 用 == 比较包装类型,超出缓存范围就出错。
- 重写 equals 不重写 hashCode。
- 循环里用 += 拼接字符串。
- 在 for-each 里删除集合元素。
- 把可变对象作为 HashMap 的 key,之后修改字段。
八、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 写一个 User 类,正确重写 equals/hashCode,并放进 HashSet 验证去重。
- 对比 String += 与 StringBuilder 在 10 万次拼接下的耗时。
- 用 LinkedHashMap 实现一个最简单的 LRU(重写 removeEldestEntry)。
② 让 AI 写同一题
把第 2 题(集合与流)交给 AI,补一句 Java 17,不引第三方库,注意装箱与线程安全。
③ 对比要盯的点
- 它用
==比较过Integer吗?缓存范围 -128~127 之外的比较结果不可信。 - 它在 IO 或短集合上用了 parallelStream 吗?那只会更慢。
- 集合初始化它给了容量吗?大批量 add 时不给容量会反复扩容。
④ 一句话取舍
它那版在数据量涨十倍后,哪一处会先出问题?
九、自测
- Integer 缓存的范围是多少?为什么比较要用 equals?
- equals 与 hashCode 的契约是什么?
- String 不可变带来哪些好处?
- ArrayList 与 LinkedList 的复杂度差异?
- 泛型擦除导致哪些限制?
- Record、sealed 与模式匹配分别解决什么问题?虚拟线程适合什么场景?
参考答案要点:-128~127;相等对象哈希必须相等;共享、缓存哈希、线程安全;随机访问 vs 头尾插入;不能 new T/instanceof;record 做不可变数据载体、sealed 限制继承配合穷尽判断、模式匹配省去强转;虚拟线程适合 IO 密集,不是 CPU 密集。
附录:高频追问
问:为什么 String 设计成不可变? 安全(作为参数、类加载、网络地址)、共享(常量池)、缓存哈希、线程安全;代价是每次修改都要新建对象。
问:String、StringBuilder、StringBuffer 的区别? 前者不可变;后两者可变,StringBuilder 非线程安全但更快,StringBuffer 方法加锁。
问:== 和 equals 的区别? == 比较基本类型的值或对象引用;equals 默认也是引用比较,重写后按内容比较。
问:为什么重写 equals 一定要重写 hashCode? 哈希容器先用 hashCode 定位桶,再用 equals 比较;哈希不一致会落到不同桶。
问:ArrayList 扩容机制? 默认 10,扩容为 1.5 倍(oldCapacity + (oldCapacity >> 1)),复制到新数组。
问:HashMap 的 key 可以用可变对象吗? 可以但不建议;字段变化会改变 hashCode,导致再也找不到。
问:什么是 fail-fast? 迭代器发现结构被修改就快速失败,抛 ConcurrentModificationException,用于尽早暴露 bug。
问:泛型擦除后 List<String> 与 List<Integer> 有什么区别? 运行时都是 List,无法通过反射区分;编译期才有类型检查。
问:基本类型一定比包装类型快吗? 不一定。局部变量里基本类型更快;集合里只能用包装类型,频繁装箱反而更慢。
问:Objects.equals 与直接 equals 的区别? 前者做了 null 安全处理,a == null 时不会 NPE。
补遗:反射、注解与动态代理
反射让程序在运行时检查类、方法、字段:Class.forName、getDeclaredMethods、setAccessible(true)。三个高频用途:框架扫描注解、动态代理、序列化。代价是性能与安全:反射绕过访问控制,频繁调用要缓存 Method 对象;JDK 9+ 模块化后还要处理 opens。
注解本身不做事,靠处理器生效:编译期注解处理器(APT)生成代码,运行期靠反射读取。@Retention 决定保留到源码/字节码/运行时,@Target 决定能标在哪。
动态代理有两种:JDK 代理基于接口,用 InvocationHandler 拦截方法;CGLIB 生成子类,能代理类但不能代理 final。Spring AOP 默认按有无接口选择,Boot 默认优先 CGLIB。
补遗:Lambda、Stream 与 Optional
Lambda 是函数式接口的语法糖,编译后是 invokedynamic + 方法句柄,不是匿名内部类;方法引用 Class::method 是更短的写法。
Stream 是声明式数据处理,惰性执行,遇到终止操作才跑。三个坑:流只能消费一次;parallelStream 默认用公共 ForkJoinPool,IO 密集会拖垮其他任务;Collectors.toMap 键重复会抛异常,要传合并函数。
Optional 适合表达“可能没有值”,适合返回值,不适合字段与参数。orElse 会立即求值,orElseGet 才惰性;get() 不如 orElseThrow() 安全。
补遗:异常与 IO 速览
异常分 Error(系统级,不该捕获)、RuntimeException(非受检)、受检异常。受检异常要在签名声明或捕获;工程上更推荐把可恢复错误转成业务异常,避免层层 throws。
IO 四件套:InputStream/OutputStream(字节)、Reader/Writer(字符)、Buffered*(缓冲)、Files/Paths(工具)。NIO 的核心是 Channel + Buffer + Selector:非阻塞、多路复用,Netty 就基于它。零拷贝(FileChannel.transferTo)能减少用户态与内核态的数据拷贝。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★★★ String 为什么设计成不可变?字符串常量池是怎么回事?
- ★★★★★ 为什么重写 equals 必须重写 hashCode?
- ★★★★☆ 泛型擦除是什么?它带来了哪些限制?
- ★★★★★ HashMap 的底层结构是什么?扩容过程是怎样的?
- ★★★★☆ 接口和抽象类有什么区别?实际开发中怎么选?
提示:读完可以顺手做几道对应题目,检验是否真的理解