知

模块一 · 语言与集合

第 1 / 11 节22 分钟阅读型

Java 基础:类型、对象与集合

从基本类型与包装类型讲到 equals/hashCode、String 与集合选型,把面试最常问的"语言底座"一次讲透,并给出高频追问的答法。

读完这一节,你能做到

  1. 说清基本类型与包装类型的差异,以及自动装箱的缓存边界。
  2. 解释 equals 与 hashCode 的契约,知道为什么重写要成对。
  3. 说清 String 为什么不可变,以及字符串拼接的正确写法。
  4. 在 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 的契约

契约有四条:

  1. 自反:x.equals(x) 为 true。
  2. 对称:x.equals(y) 与 y.equals(x) 一致。
  3. 传递:x=y、y=z 则 x=z。
  4. 一致:只要参与比较的字段没变,多次调用结果一致。

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 的代价与收益都要重新评估。

四、集合选型

接口常用实现特点适用
ListArrayList数组,随机访问 O(1),中间插入 O(n)读多写少
ListLinkedList双向链表,头尾操作 O(1),随机访问 O(n)频繁头尾插入
SetHashSet基于 HashMap,无序去重
SetTreeSet红黑树,有序需要排序
SetLinkedHashSet维护插入顺序去重且要顺序
MapHashMap数组+链表+红黑树通用
MapTreeMap红黑树,按 key 排序范围查询
MapLinkedHashMap维护插入/访问顺序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:哪些特性改变了写法

面试常问"你用的版本有什么特性",重点不是背清单,而是说清这些特性解决了什么问题。

版本特性解决什么现在还用吗
8Lambda、Stream、Optional、接口默认方法把"怎么做"换成"做什么",减少样板代码必用
10var 局部变量推断省掉冗长的泛型声明可用,别滥用
14 / 16record 记录类不可变数据载体,自动生成 equals/hashCode/toStringDTO 与值对象推荐
15 / 17sealed 密封类与接口限制谁能继承,配合模式匹配做穷尽判断领域模型好用
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"没有任何信息量。

七、常见坑

  1. 用 == 比较包装类型,超出缓存范围就出错。
  2. 重写 equals 不重写 hashCode。
  3. 循环里用 += 拼接字符串。
  4. 在 for-each 里删除集合元素。
  5. 把可变对象作为 HashMap 的 key,之后修改字段。

八、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 写一个 User 类,正确重写 equals/hashCode,并放进 HashSet 验证去重。
  2. 对比 String += 与 StringBuilder 在 10 万次拼接下的耗时。
  3. 用 LinkedHashMap 实现一个最简单的 LRU(重写 removeEldestEntry)。

② 让 AI 写同一题

把第 2 题(集合与流)交给 AI,补一句 Java 17,不引第三方库,注意装箱与线程安全。

③ 对比要盯的点

  • 它用 == 比较过 Integer 吗?缓存范围 -128~127 之外的比较结果不可信。
  • 它在 IO 或短集合上用了 parallelStream 吗?那只会更慢。
  • 集合初始化它给了容量吗?大批量 add 时不给容量会反复扩容。

④ 一句话取舍

它那版在数据量涨十倍后,哪一处会先出问题?

九、自测

  1. Integer 缓存的范围是多少?为什么比较要用 equals?
  2. equals 与 hashCode 的契约是什么?
  3. String 不可变带来哪些好处?
  4. ArrayList 与 LinkedList 的复杂度差异?
  5. 泛型擦除导致哪些限制?
  6. 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)能减少用户态与内核态的数据拷贝。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解