JDK 8 之后是数组加链表加红黑树。数组是主体,哈希冲突的元素挂成链表,链表长到一定程度转成红黑树,把最坏查询从 O(n) 降到 O(log n)。
容量必须是 2 的幂,是为了用位运算代替取模。定位下标本来该用 hash % n,但当 n 是 2 的幂时,hash & (n - 1) 和取模等价,而位运算快得多。这也是为什么你传 new HashMap<>(10),实际容量会被调整成 16。另一个好处是扩容时,元素要么留在原位置,要么移到「原位置加旧容量」的地方,只需看 hash 多出来的那一位是 0 还是 1,不用重新计算下标。
转红黑树的阈值是 8,这个数字来自泊松分布。在哈希函数分布均匀的前提下,一个桶里出现 8 个元素的概率是千万分之六,基本不可能发生。所以设成 8 的含义是:如果真的到了 8,说明哈希分布已经不正常了(可能是有人恶意构造相同哈希的 key 攻击),这时候才有必要付出转树的代价。反过来说,正常业务里这个转换几乎不会触发。
还有个配套条件容易被忽略:链表长度到 8 的同时,数组长度必须达到 64 才会转树,否则只是扩容。因为数组小的时候冲突多是正常的,扩容比转树更划算。退化的阈值是 6 而不是 8,留了个缓冲区间,避免在 7 和 8 之间反复转换。
顺带说扰动函数:hash() 方法里把 hashCode 的高 16 位和低 16 位做异或。因为下标只用到低位,如果两个 key 的低位相同、只有高位不同,不做扰动就会冲突。异或一下让高位也参与运算,冲突明显减少,成本只是一次位运算。
扩容触发条件是元素个数超过容量乘负载因子,默认 0.75,也就是 16 个桶放到第 13 个元素时扩容成 32。负载因子选 0.75 是空间和时间的折中:太小浪费空间且频繁扩容,太大冲突增多查询变慢。
JDK 8 的扩容比 7 优雅很多。因为容量是 2 的幂,扩容后每个元素的新下标要么不变,要么是原下标加旧容量,只需判断 hash 值在新增的那一位上是 0 还是 1。所以它把原链表拆成两条(低位链和高位链),整条挂到新位置,不用逐个重新计算下标。而且是尾插法,保持原有顺序。
JDK 7 用的是头插法,而且逐个元素重新计算下标插入。头插会导致链表顺序反转,这就是死循环的根源:两个线程同时扩容,线程 A 把链表 A→B 转移后变成 B→A,线程 B 此时用的还是旧的引用关系,结果两个节点互相指向对方,形成环形链表。之后任何一次 get 落到这个桶上,就会无限循环,CPU 直接打满 100%。这个问题在线上很难查,因为现场看到的只是某个线程卡在 HashMap.get 上。
JDK 8 改成尾插之后不会形成环,但并发下依然不安全:可能丢数据(两个线程同时 put 到同一个桶,后者覆盖前者)、size 不准确。所以千万不要以为 JDK 8 的 HashMap 可以并发用,它只是不死循环了而已。
ArrayList 底层是数组,按下标随机访问是 O(1),中间插入删除要移动后面所有元素是 O(n)。LinkedList 是双向链表,头尾操作是 O(1),但按下标访问要从头遍历是 O(n)。
扩容规则是扩到原来的 1.5 倍(oldCapacity + (oldCapacity >> 1)),默认初始容量 10,但注意是懒初始化——new ArrayList<>() 时数组是空的,第一次 add 才分配 10 个位置。扩容要 Arrays.copyOf 复制整个数组,代价不小,所以如果能预估数据量就在构造时指定容量,这是最实际的一条优化,往几万个元素的列表里 add,不指定容量会经历十几次数组复制。
实际怎么选?基本上都用 ArrayList。理论上「频繁插入删除用 LinkedList」,但现实中很少成立:一是链表的每个节点都要额外的对象头和两个指针,内存占用是数组的好几倍;二是节点在内存里不连续,CPU 缓存命中率很差,而数组是连续的,遍历时能充分利用缓存预取,实测遍历速度可能差好几倍;三是 LinkedList 的「插入 O(1)」前提是你已经有了那个位置的引用,如果要先按下标找到位置,加上遍历的开销就没优势了。
LinkedList 真正有价值的场景是当队列或双端队列用(它实现了 Deque),只在头尾操作。但即使这个场景,ArrayDeque 通常性能更好。所以我的结论是:默认用 ArrayList,需要队列语义用 ArrayDeque,LinkedList 基本不用。
ConcurrentModificationException?★★这是 fail-fast 机制。集合内部维护一个 modCount 记录结构性修改次数,创建迭代器时把它复制成 expectedModCount。每次调 next() 都会检查两者是否相等,不等就立刻抛异常。
而 list.remove() 只改了 modCount,没改迭代器持有的 expectedModCount,所以下一次 next() 就炸了。注意增强 for 循环底层就是迭代器,所以在 for-each 里删元素是同一个问题。
它的设计意图是「快速失败优于错误结果」:并发修改可能导致遍历漏掉元素或者读到错乱数据,与其静默出错,不如立刻抛异常暴露问题。所以这个异常是保护机制而不是 bug。
正确的删除方式有几种。用迭代器自己的 remove(),它会同步更新 expectedModCount:
Iterator<String> it = list.iterator();
while (it.hasNext()) {
if (需要删除) it.remove(); // 用迭代器的 remove,不是 list.remove
}
或者用 removeIf(Java 8+),一行搞定而且内部实现更高效:list.removeIf(x -> 条件)。这是我现在的首选写法。
有个经典的坑值得说:用普通 for 循环倒序遍历删除是安全的,正序会漏元素。因为删掉一个元素后面的会前移,正序遍历时索引就跳过了下一个。倒序不受影响,所以「倒序 for 循环删除」是个常见的替代方案。
还要说清 fail-fast 和 fail-safe 的区别:ArrayList、HashMap 这些是 fail-fast;而 CopyOnWriteArrayList、ConcurrentHashMap 是 fail-safe——它们的迭代器基于快照或者弱一致性设计,遍历时修改不会抛异常,但可能读到旧数据。所以并发场景下遍历要用并发容器,而不是给 ArrayList 加锁。
最后提醒一点:fail-fast 只是「尽力检测」不是保证。modCount 不是 volatile 的,多线程下可能检测不到修改,所以它不能用来做并发安全的依据,只能帮你在单线程误用时早点发现问题。
equals 一定要重写 hashCode?★★因为契约规定:两个对象 equals 相等,hashCode 必须相等;反过来 hashCode 相等不要求 equals 相等(那就是哈希冲突)。
不遵守会在哈希容器里出问题。HashMap 查找的流程是先用 hashCode 定位桶,再在桶里用 equals 逐个比较。如果只重写了 equals,两个业务上相等的对象 hashCode 却不同,就会被分到不同的桶,压根走不到 equals 那一步。表现出来就是:你 put 进去一个对象,用一个业务上完全相同的对象去 get,返回 null;或者往 HashSet 里放两个「相等」的对象,居然都放进去了。
这个 bug 很典型:用一个自定义对象做 Map 的 key,或者用 List.contains、distinct 去重,结果行为完全不符合预期,而且不会报错,只是静默地得到错误结果。
实践上直接用 IDE 生成,或者用 Lombok 的 @Data、@EqualsAndHashCode,别手写。有两个细节要注意:一是参与计算的字段要保持一致,equals 用了哪些字段,hashCode 就用哪些;二是不要用可变字段,如果对象已经放进 HashMap,之后改了参与 hashCode 计算的字段,这个对象就再也找不到了,因为它还在旧桶里。所以做 key 的对象最好是不可变的,用 record 是个好选择,它自动生成这两个方法且天生不可变。
Java 只有值传递。这个结论要说得干脆,很多人会答「基本类型值传递、对象引用传递」,那是错的。
关键在于理解传递的是什么值:基本类型传的是数值的拷贝;对象传的是引用(地址)的拷贝。注意是「引用的拷贝」,不是「引用本身」,也不是对象本身。
所以会看到两种现象。方法里修改对象的属性,外面能看到——因为拷贝的引用和原引用指向同一个对象。但方法里把参数重新赋值成新对象,外面看不到——因为改的只是那份拷贝的指向,原来的引用没动。
void change(StringBuilder sb, StringBuilder sb2) {
sb.append(" world"); // 改属性,外面看得到
sb2 = new StringBuilder("new"); // 重新赋值,外面看不到
}
真正的引用传递(比如 C++ 的 &)是把变量本身传过去,方法里重新赋值会影响外面的变量。Java 做不到这一点,这就是判断依据。
String 最容易造成误解:传一个 String 进方法,里面怎么改外面都不变。看起来像值传递的「特例」,其实原因是 String 不可变——方法里任何「修改」操作实际上都是创建了新对象并让局部变量指向它,跟上面 sb2 的情况完全一样。
实践上这个认知有用的地方是:想让方法「返回」修改后的对象,不要指望改参数,要么用返回值,要么传一个容器对象(比如包装类、数组、集合)然后改它的内容。我见过有人写 void getUser(User user) 想在里面 user = queryFromDb() 把结果带出来,结果永远拿到 null,就是这个原因。
不可变带来四个好处:可以安全地共享,也就是常量池能复用同一个对象;hashCode 可以缓存,String 内部有个 hash 字段算过一次就存下来,这让它做 HashMap 的 key 特别高效;天然线程安全,多线程共享不用同步;安全性,比如把文件路径或者 SQL 传给某个方法后,对方不可能偷偷改掉它。
实现上是 final 类加上 private final 的字节数组(JDK 9 之后从 char 数组改成了 byte 数组加编码标记,纯 ASCII 的字符串内存直接省一半)。
拼接方式的选择:循环里绝对不能用 +。因为每次 + 都会新建一个 StringBuilder、拼接、再 toString,循环一万次就产生一万多个临时对象,性能差好几个数量级。循环里必须在外面 new 一个 StringBuilder 复用。
非循环的场景下 + 完全可以用,编译器会优化成 StringBuilder(JDK 9 之后是用 invokedynamic 加 StringConcatFactory,效率更高),可读性也更好,不用为了「性能」把简单的拼接写成 StringBuilder。StringBuffer 是 StringBuilder 的同步版本,现在几乎用不到——需要线程安全通常意味着设计有问题,局部变量的 StringBuilder 本来就是线程安全的。
另外拼接大量文本推荐 String.join 或者 Collectors.joining,处理分隔符更干净,不用手动删最后一个逗号。
== 和 equals 的区别,Integer 的缓存陷阱是什么?★★== 比较的是值,对基本类型就是数值,对引用类型是地址。equals 是方法,默认实现也是比地址(Object 里就是 ==),所以必须重写才有「内容相等」的语义。
Integer 的陷阱是这样:
Integer a = 127, b = 127;
System.out.println(a == b); // true
Integer c = 128, d = 128;
System.out.println(c == d); // false
原因是自动装箱走的是 Integer.valueOf(),它对 -128 到 127 这个范围有缓存,直接返回缓存里的同一个对象,所以 == 成立。超出这个范围就 new 一个新对象,地址不同。这个缓存范围的上限可以用 -XX:AutoBoxCacheMax 调,下限不行。
实际危害在于这类 bug 有隐蔽的数据依赖:用 == 比较两个 Integer,测试时用小数字(订单状态、类型码)一切正常,上线后遇到大数值(金额、ID)就出错。所以规矩很简单:包装类型永远用 equals 比较,或者用 Objects.equals 顺便防空指针。
另一个相关的坑是自动拆箱的空指针:Integer x = map.get("k"); if (x == 1),如果 map 里没有这个 key,x 是 null,拆箱时直接 NPE。这在从数据库查出来的字段上特别容易发生,因为数据库里 null 是很正常的。
double,BigDecimal 怎么用才对?★★因为二进制浮点数没法精确表示大部分十进制小数。0.1 在二进制里是无限循环的,只能近似存储,所以 0.1 + 0.2 得到的是 0.30000000000000004,而 1.0 - 0.9 不等于 0.1。金额计算里这种误差会累积,对账时就会差几分钱,这在财务上是不可接受的。
用 BigDecimal 有几个必须注意的点。一定要用字符串构造:new BigDecimal("0.1") 是精确的 0.1,而 new BigDecimal(0.1) 传的是 double,误差已经产生了,得到的是一长串数字。用 BigDecimal.valueOf(0.1) 也可以,它内部走的是 Double.toString。
比较大小用 compareTo 不用 equals:equals 会同时比较值和精度,所以 new BigDecimal("1.0").equals(new BigDecimal("1.00")) 是 false,而 compareTo 返回 0,后者才是业务想要的。
除法必须指定精度和舍入模式,否则遇到除不尽的情况(比如 1 除以 3)会直接抛 ArithmeticException。舍入模式一般用 RoundingMode.HALF_UP,也就是四舍五入,但金融场景有时要求 HALF_EVEN(银行家舍入),这个要跟业务确认。
另外 BigDecimal 是不可变的,所有运算都返回新对象,a.add(b) 不会改变 a,忘了接收返回值是常见错误。实践中还有一个更省事的方案:金额用 long 存分(或者更小的单位),只在展示时转换,这样所有计算都是整数运算,没有精度问题也没有性能开销,很多支付系统就是这么做的。
finally 会不会覆盖 return?★★顶层是 Throwable,分 Error 和 Exception。Error 是虚拟机层面的问题(OutOfMemoryError、StackOverflowError),程序不该也没法处理。Exception 分受检和非受检,RuntimeException 及其子类是非受检,其他都是受检异常,必须显式捕获或者声明抛出。
什么时候用受检异常:调用方有能力也应该处理的可恢复情况。但实际上现代 Java 的趋势是少用受检异常,因为它污染方法签名、强迫上层写一堆没意义的 try-catch,而且很容易被随手 catch 掉然后什么都不做,反而掩盖问题。Spring 全家桶基本都用运行时异常,业务异常我也一律定义成 RuntimeException 的子类,配合全局异常处理统一兜住。
finally 里的 return 会覆盖 try 里的 return,而且会吞掉 try 里抛出的异常,这是非常危险的写法,所以 finally 里永远不要 return。但要注意另一种情况:如果 finally 里只是修改一个即将返回的基本类型变量,是不生效的,因为返回值在执行 finally 之前就已经被暂存了。如果返回的是对象引用,改对象的属性是生效的(引用还是那个引用),这个区别常被用来出题。
还有两个实践点:catch 不要写空实现,至少要记日志,静默吞异常是排查线上问题时最痛苦的事;不要 catch Exception 一把梭之后只打一句「操作失败」,把原始堆栈丢了,出问题完全没法定位。
try-with-resources 解决了什么问题?★★解决手动关闭资源的两个麻烦。一是写起来啰嗦且容易漏,传统写法要在 finally 里判空再 close,close 本身还可能抛异常,所以又要套一层 try-catch,关两个资源就要嵌套两层,代码丑陋而且很容易写错。二是异常屏蔽:如果 try 块里抛了业务异常,finally 里 close 时又抛了异常,后者会把前者顶掉,你看到的是「关闭流失败」,而真正的业务异常永远丢失了,这个坑非常隐蔽。
try-with-resources 的做法是让资源实现 AutoCloseable,编译器自动生成关闭代码,并且关闭顺序和声明顺序相反(后开的先关,符合依赖关系)。关键在于它对异常的处理:如果两处都抛异常,保留业务异常作为主异常,把 close 的异常作为「被压制的异常」附加上去,可以通过 getSuppressed() 拿到。这样既不丢主要信息也不丢次要信息。
try (var conn = dataSource.getConnection();
var ps = conn.prepareStatement(sql)) {
// 用完自动关闭,ps 先关再关 conn
} catch (SQLException e) {
// 这里拿到的是业务异常,不会被 close 异常顶掉
}
实际开发中凡是涉及流、连接、锁这类需要释放的资源都该用它。也可以自己实现 AutoCloseable,比如做一个自动切换数据源或者自动清理 ThreadLocal 的工具类,用起来很干净。
接口表达「能做什么」,抽象类表达「是什么」。这是选择的根本依据,不是语法差异。
语法上:抽象类可以有构造器、成员变量、具体方法实现;接口只能有常量和方法声明(Java 8 之后可以有 default 和 static 方法,Java 9 加了 private 方法)。类只能单继承但可以实现多个接口,这是最实际的约束。
选择标准:需要在多个子类间复用代码和状态就用抽象类(比如模板方法模式,父类定义流程骨架、子类填具体步骤);只需要定义契约、且实现者之间没有共同血缘就用接口。
default 方法的出现改变了什么值得一提:它让接口可以在不破坏已有实现的前提下增加方法,这是为了让 Collection 能加上 stream() 而不让所有实现类编译失败。副作用是引入了「菱形继承」问题——实现了两个有同名 default 方法的接口时必须显式覆写指定用哪个。
重载(overload)和重写(override)是完全不同的机制,别只答表面区别。
重载是编译期决定的:同一个类里方法名相同、参数列表不同(个数、类型、顺序),返回值和访问修饰符不参与区分。编译器根据参数的静态类型选择调用哪个,这叫静态分派。所以 Object o = "str"; method(o); 会调 method(Object) 而不是 method(String)——这是高频陷阱题。
重写是运行期决定的:子类覆盖父类方法,方法签名必须相同,返回值可以是协变的(子类型),访问权限不能缩小,抛出的受检异常不能扩大。运行时根据对象的实际类型查虚方法表找到具体实现,这叫动态分派,也就是多态的底层机制。
什么不能被重写也是常考点:static 方法(属于类,子类同名方法是隐藏而不是重写)、private 方法(子类看不见)、final 方法(禁止重写)、构造器。理解「static 方法是隐藏不是重写」很重要——用父类引用调静态方法调的是父类的实现,不走多态。
先把两组容易混淆的概念分开:阻塞与非阻塞说的是「等数据的时候线程要不要挂着」,同步与异步说的是「数据准备好之后谁来做拷贝这件事」。很多人把这两组混在一起讲,就说不清了。
BIO 是同步阻塞。一个连接一个线程,调 read() 之后线程就挂在那里等数据。模型极其简单,代码好写好调试。问题是线程数和连接数是一比一的,几千个连接就要几千个线程,内存和上下文切换都撑不住。而且大部分线程都在空等(连接建立了但没数据),纯粹浪费。
NIO 是同步非阻塞,靠 IO 多路复用。关键是一个线程能管很多连接:把所有连接注册到 Selector 上,select() 返回有事件就绪的那些连接,线程只处理这些,不用为每个连接挂一个线程。所以它能用几个线程扛住十万连接。
但要说清它为什么还叫「同步」:select() 告诉你数据准备好了之后,还是要你自己调 read() 把数据从内核拷到用户空间,这个拷贝过程是同步的、由业务线程完成的。这是 NIO 和 AIO 的分界线。
AIO 是异步非阻塞。你发起读操作后就完全不用管了,操作系统把数据准备好并且拷贝到你的缓冲区之后,才通知你(回调)。全程不需要业务线程参与等待和拷贝。但它在 Linux 上的实现(基于信号)并不成熟、性能优势不明显,所以Java 生态里几乎没人用 AIO——Netty 甚至移除了 AIO 的支持。这一点说出来能显示了解实际情况而不只是背概念。
实践中的选择:连接数少且固定、追求代码简单,BIO 完全够用(传统内部服务);高并发长连接必须用 NIO,而且不要自己写——直接用 Netty,因为原生 NIO 的 API 难用、有臭名昭著的空轮询 bug、还要自己处理拆包粘包和内存管理。
另外要知道 Java NIO 除了多路复用,还提供了两个有价值的东西:ByteBuffer(含堆外的 DirectByteBuffer,避免一次内核到堆的拷贝)和零拷贝(FileChannel.transferTo 底层走 sendfile)。这两个才是 NIO 在文件传输场景下性能好的原因,和多路复用是两回事。
Java 的泛型只存在于编译期,编译后类型参数被擦除,替换成上界(没指定上界就是 Object),必要的地方由编译器插入强制类型转换。这么设计是为了向后兼容——泛型是 JDK 5 才加的,必须让新代码和老的非泛型代码互相能用,所以选了擦除这条路,代价就是运行时拿不到泛型信息。
带来的实际问题不少。不能用泛型创建数组和实例,new T[10]、new T() 都不行,因为运行时不知道 T 是什么,只能靠传 Class<T> 参数再反射创建。不能对泛型做 instanceof 判断。静态成员不能用类的类型参数,因为静态成员属于类而类型参数属于实例。
List<String> 和 List<Integer> 在运行时是同一个类,所以不能重载区分这两个方法(签名冲突),getClass() 也返回同一个结果。
还有个很容易踩的坑:因为擦除,可以通过反射往 List<String> 里塞进一个 Integer,编译期检查被绕过了,直到别人取出来用的时候才抛 ClassCastException,而且报错的位置离出错的原因很远。JSON 反序列化时最容易遇到这类问题,比如把 JSON 数组反序列化成 List<Long>,实际拿到的元素可能是 Integer,遍历时才炸。
要在运行时拿到泛型类型只有一个办法:泛型信息保留在类或方法的签名里而不是变量上。所以可以通过继承一个泛型父类,然后用 getGenericSuperclass() 反射拿到具体类型,这就是 Jackson 的 TypeReference 和 Spring 的 ParameterizedTypeReference 那种「匿名子类」写法的原理。
Stream 的价值是代码更声明式、可读性好,但有几个实际问题要清楚。
性能不一定更好。简单的遍历和求和,for 循环通常比 Stream 快,因为 Stream 有 lambda 调用和装箱开销。数据量小的时候差距更明显。所以别为了「显得现代」把所有循环改成 Stream,真正适合 Stream 的是多步组合的数据处理(过滤、映射、分组、汇总),那种场景下可读性收益远大于性能损失。处理基本类型时用 IntStream 这类特化流,能避免装箱。
调试困难,一长串链式调用出错时,堆栈里全是框架内部的方法,很难定位是哪一步的问题。所以链条不要拉太长,复杂逻辑拆成有名字的方法。
Collectors.toMap 的两个坑:key 重复会直接抛 IllegalStateException,得传第三个参数指定冲突时怎么合并;value 为 null 会抛 NPE,这跟 HashMap.put 允许 null 的行为不一致,从数据库查出来的字段很容易是 null。
并行流要非常谨慎。它默认用 ForkJoinPool.commonPool(),这是整个 JVM 共享的池,并行度是核数减一。所以一个地方用并行流处理耗时任务,会拖慢所有其他用到这个池的代码,Web 应用里这可能影响到不相关的请求。而且它不适合有 IO 的操作,线程会被阻塞占着不放。另外 lambda 里如果操作了共享的可变状态(比如往一个普通 ArrayList 里 add),就是并发 bug。
我的判断标准是:只在纯 CPU 计算、数据量足够大(万级以上)、且没有共享状态时才考虑并行流,而且要显式指定自己的 ForkJoinPool(把任务提交到自定义池里执行)。绝大多数业务场景其实用不上它,串行流加合理的批处理就够了。
SimpleDateFormat 定义成成员变量,新的时间 API 好在哪?★★因为它内部有可变状态:格式化过程中会把中间结果存进一个 Calendar 成员字段。多个线程共用一个实例时,会互相覆盖这个中间状态,导致格式化结果错乱——可能返回一个完全错误的日期,也可能抛 NumberFormatException。这类 bug 在压测或者高峰期才出现,平时测不出来,属于很典型的隐藏炸弹。
而 Spring 的 Bean 默认是单例的,所以把它当成员变量就是并发共享。要么每次用的时候在方法里 new(有一定创建开销但安全),要么用 ThreadLocal 包一层,要么直接换掉。
Java 8 的 DateTimeFormatter 是不可变且线程安全的,可以放心做静态常量,这是最推荐的做法。整套新 API 的改进不止这一点:职责清晰,LocalDate 只有日期、LocalTime 只有时间、LocalDateTime 两者都有但不含时区、ZonedDateTime 带时区、Instant 是时间戳,而老的 Date 一个类混着表示所有语义,还有个「Date 里居然有时间」的名字误导。
所有对象都不可变,plusDays 返回新对象而不是改自己,避免了老 Date 被意外修改的问题。月份从 1 开始,老 API 里 Calendar.MONTH 是 0 到 11,这个设计害人无数。时区处理明确,不会像老 API 那样隐式使用系统默认时区,跨时区业务里这一点很关键。
实践建议:新代码一律用新 API,数据库字段映射到 LocalDateTime,跨时区场景用 Instant 或者直接存时间戳,前后端交互统一约定格式和时区。老项目里 Date 和 LocalDateTime 混用时,转换要经过 Instant 并显式指定时区,别用默认值。
浅拷贝只复制对象本身,里面的引用字段还是指向同一个对象,所以改副本的嵌套对象会影响原对象。Object.clone() 默认就是浅拷贝,BeanUtils.copyProperties 也是。
深拷贝要让所有层级都是新对象。几种做法:手动递归 clone,最可控但嵌套深了很繁琐,加字段容易忘;序列化再反序列化,写成工具方法很通用,但要求所有类都可序列化,性能也一般;用 JSON 转一圈,最省事也最常用,但要注意 JSON 会丢失类型信息(泛型、Long 精度、日期格式都可能出问题)。
实际业务里我更倾向从设计上避开这个问题:让传输对象不可变(用 record),需要「修改」就构造新对象,压根不需要拷贝。这比到处写深拷贝要清晰得多。
序列化几个要注意的点。serialVersionUID 要显式指定,不写的话编译器会按类结构自动生成,一旦类改了字段,这个值就变了,导致反序列化旧数据时抛 InvalidClassException——这在有缓存或者 RPC 的场景下是个大坑。transient 字段不参与序列化,用来排除敏感信息(密码、密钥)和不需要传的临时字段。
还有个安全问题值得提:Java 原生序列化有反序列化漏洞,攻击者构造特殊的字节流可以在反序列化时触发任意代码执行,历史上很多重大漏洞都是这个原因(比如 Fastjson、Shiro 那些 CVE)。所以不要反序列化不可信来源的数据,对外接口一律用 JSON 这类数据格式而不是原生序列化,并且 JSON 库要关闭 autoType 这类自动类型推断功能。
反射慢主要是三个原因:方法查找的开销,getMethod 要遍历方法列表并做字符串比较,每次调用都查一遍很浪费;无法被 JIT 有效优化,反射调用是动态的,编译器没法内联,也做不了很多常规优化;参数要装箱成 Object 数组,返回值也要拆箱,多了一堆临时对象。另外还有访问权限检查的开销。
优化手段很直接。缓存 Method 和 Field 对象,只查一次然后复用,这一步能消掉大部分开销,各种框架都是这么干的。调 setAccessible(true) 跳过访问检查,也能快一些。要极致性能就换方案:用 MethodHandle,或者用字节码生成(CGLIB、ByteBuddy)在运行时生成真正的调用代码,那样就跟直接调用差不多了。
实际项目里我很少直接写反射,因为它绕过了编译期检查,字段改名了编译不报错,运行时才炸,重构工具也识别不了这种引用。用得多的场景是写通用工具和框架:注解处理、参数校验、对象拷贝、动态代理、ORM 映射。
更实际的一点是知道它在什么地方拖慢了你的系统。像 BeanUtils.copyProperties 内部大量用反射,在高频接口里循环拷贝几万个对象会成为明显瓶颈,这种时候换成 MapStruct(编译期生成代码)能有数量级的提升。启动慢也常跟反射有关,比如包扫描要反射读大量类的注解。
另外要知道 JDK 9 之后模块化对反射有限制,访问 JDK 内部类会抛 InaccessibleObjectException,需要 --add-opens 显式开放,这是老项目升级 JDK 时最常见的报错来源。
Optional 该怎么用,哪些用法是反模式?★★它的设计目的是「让方法的返回值在类型上表达出可能为空」,把「要不要判空」从注释和口头约定变成编译器可见的信息。所以它最正当的用法是作为返回值类型。
反模式一:当参数用。void f(Optional<String> s) 是错的——调用方要么传 Optional.of(x) 要么传 Optional.empty(),比直接允许传 null 更啰嗦,而且调用方还是可能传一个 null 的 Optional(这时候你要判两层空)。参数的可选性应该用方法重载表达。
反模式二:当字段用。它不可序列化,而且给每个字段套一层包装的内存和访问开销不值得。
反模式三:用 isPresent() 加 get()。这等于把它当成一个换了皮的 null 判断,一点收益都没有。正确的用法是链式的 map / filter / orElse / orElseThrow / ifPresent。
反模式四:orElse 里放有副作用或有成本的表达式。orElse(expensive()) 无论 Optional 有没有值都会求值;要延迟求值必须用 orElseGet(() -> expensive())。这个坑很隐蔽,因为功能是对的,只是白做了工作——如果那个表达式里有副作用(写库、发消息),就不只是浪费,是 bug。
还有一个容易忽略的点:Optional 解决的是「返回值可能没有」,它解决不了「集合可能为空」——集合类的返回值应该直接返回空集合而不是 Optional<List>,那是双层可选,调用方要判两次。
比 int 或 String 常量好在三点,而且第一点最实际。
一是类型安全。方法签名写 void f(Status s),编译器就不允许传进一个无意义的值;而写 void f(int status) 时传任何整数都能编过,错误只能在运行时发现。
二是可以带数据和行为。枚举是完整的类,可以有字段、构造器、方法,甚至可以每个枚举值覆盖同一个方法给出不同实现。这让「按状态分派」的逻辑可以内聚到枚举里,而不是散落成一堆 switch——新增一个状态时,如果它必须实现抽象方法,编译器会强制你补上实现,这比到处找 switch 靠谱得多。
三是天然单例且线程安全,可以安全地做 switch、放进 EnumMap / EnumSet(这两个的实现比通用 Map/Set 高效很多)。
但有三个实际坑要知道。
一、不要用 ordinal() 做持久化或协议字段。它是声明顺序,中间插入一个枚举值就全变了,而已经存进库的数据不会跟着变。要持久化就显式定义一个 code 字段。
二、不要用 values() 在热点路径上频繁调用——它每次都返回一份数组拷贝。需要频繁查找就用静态 Map 建好 code 到枚举的索引。
三、枚举与外部系统交互时要处理未知值。对方新增了一个我们还不认识的状态,valueOf 会抛异常;跨系统的枚举反序列化要有一个「未知」兜底值或明确的失败处理,不能让一个新状态把整条链路打挂。
核心区别是「是否持有外部类实例的引用」,这一条直接决定了内存陷阱。
非静态内部类和匿名类会隐式持有外部类实例的引用(编译后多一个指向外部实例的字段)。静态内部类不持有。Lambda 只在真正用到外部实例的成员时才捕获 this——如果 Lambda 体里没有引用外部实例的字段或方法,它不会持有外部引用。
由此产生的典型内存泄漏:把一个匿名内部类的实例(比如一个监听器、一个回调、一个提交给线程池的 Runnable)注册到了生命周期更长的对象上,那么外部类实例就跟着被一直持有,即使它本身早该被回收。在 Android 上这就是「Activity 泄漏」的经典形态,在服务端则表现为「某个大对象一直不释放」。解法是改用静态内部类(需要外部数据就显式传入),或者用弱引用持有。
第二个陷阱是变量捕获的语义。匿名类和 Lambda 捕获的局部变量必须是 final 或事实上不再改变的(effectively final)。为什么有这个限制:捕获的是值的拷贝而不是变量本身,如果允许之后修改,两边看到的值就会不一致,语义会变得无法解释。而实例字段没有这个限制,因为捕获的是 this,读的始终是最新值——这个差别经常被误解成「Lambda 里不能改变量」,实际是「不能改被捕获的局部变量」。
第三个区别是编译产物。匿名类每一个都会生成一个独立的 class 文件,大量使用会让类数量膨胀(影响启动时的类加载);Lambda 走的是 invokedynamic,不会为每个 Lambda 生成 class 文件。这在类数量敏感的场景(Android、启动速度敏感的服务)是实际差异。
Comparable 和 Comparator 怎么选,排序有哪些坑?★★选择很简单:Comparable 表达「这个类型天然的顺序」,只能有一个;Comparator 表达「某一种排序规则」,可以有很多个。如果一个类的排序规则依赖业务场景(订单按时间还是按金额),那就不该实现 Comparable——那会暗示存在唯一自然序,而实际没有。
第一个坑也是最容易踩的:比较器必须满足传递性与自反一致性,否则会抛 IllegalArgumentException: Comparison method violates its general contract。典型的错误写法是 return a.x > b.x ? 1 : -1——相等时也返回了 -1,违反了「相等必须返回 0」。这个异常的可怕之处在于它只在数据量大到触发某个排序路径时才出现,小数据量下测不出来。
第二个坑:用相减实现比较。return a.value - b.value 在数值溢出时会得到符号相反的结果(两个大整数相减溢出)。正确做法是用 Integer.compare(a, b) 这类方法。
第三个坑:排序稳定性。Collections.sort / List.sort 对对象数组是稳定排序(相等元素保持原有相对顺序),而基本类型数组的排序(快排变体)不保证稳定——不过基本类型没有「相等但可区分」的概念,所以实际不影响。真正要注意的是:如果业务依赖稳定性(先按 A 排再按 B 排,期望 B 相同时保持 A 的顺序),要确认用的是稳定排序,或者干脆用 thenComparing 写成一个复合比较器。
第四个坑:Comparator 与 equals 不一致时放进有序集合。TreeMap / TreeSet 判断「相同」用的是比较器返回 0,不是 equals。如果比较器只比一个字段,那么两个 equals 为 false 但该字段相同的对象,放进 TreeSet 会被当成同一个,后一个直接丢掉——这个数据丢失非常隐蔽。
第五个:null 的处理。比较器里遇到 null 会抛空指针,要用 Comparator.nullsFirst / nullsLast 显式表达,而不是在比较逻辑里手写判空(容易漏一边)。
函数式接口就是「只有一个抽象方法的接口」,Lambda 与方法引用都是它的实例。@FunctionalInterface 注解不是必须的,但加上它编译器会帮你检查「是不是真的只有一个抽象方法」,防止别人后来加了第二个抽象方法把所有调用方打挂。默认方法和静态方法不算抽象方法,所以函数式接口里可以有它们。
方法引用的四种形式:静态方法、特定实例的方法、某类型任意实例的方法(String::length,第一个参数变成接收者)、构造器。第三种最容易看不懂,它和第二种的区别就在于接收者是固定的还是来自参数。
实际限制与坑,按踩到的频率排。
一、受检异常。标准函数式接口(Function、Consumer 等)的方法签名都没有 throws,所以 Lambda 体里不能直接抛受检异常。这是 Stream 用起来别扭的最主要原因——一段 IO 或 JDBC 代码放进 map 里就编不过。解法是自定义一个允许抛异常的函数式接口并包一层,或者在 Lambda 内部捕获并转成运行时异常(但要注意不要吞掉异常)。
二、变量捕获必须是 effectively final,所以「在 Lambda 里累加一个局部变量」写不了。正确做法不是用一个长度为 1 的数组绕过去(那是在骗编译器,而且在并行流里是错的),而是用 reduce 或者 collect 表达聚合语义。
三、Lambda 里的 this 指向外部类实例,而匿名类里的 this 指向匿名类自己。从匿名类改写成 Lambda 时如果原来用了 this,语义会变。
四、调试与栈信息。Lambda 的栈帧名字是生成的(形如 lambda$method$0),出问题时定位不如具名方法直观;所以逻辑稍复杂就抽成具名方法再用方法引用,可读性和可调试性都更好。
先建立一个判断框架:乱码一定发生在「编码」和「解码」用了不同字符集的地方,所以排查就是沿着数据流找出所有编解码点。光看乱码的样子猜没有用,要定位那个点。
Java 里最高频的编解码点:String.getBytes() 与 new String(bytes) 不指定字符集时用平台默认编码——而平台默认编码在不同操作系统、不同 JVM 版本、甚至不同启动参数下可能不同。这是「本地跑好的、上服务器就乱」的最常见原因。结论很简单:任何编解码都显式指定字符集,不要依赖平台默认。
文件读写:FileReader / FileWriter 这类老 API 不能指定字符集(较新的 JDK 版本才补上),要用 InputStreamReader 加显式字符集,或者 Files.readString(path, charset)。
HTTP 链路上的编解码点比想象的多:请求 URL 的参数(要 URL 编码,而且服务器容器对 query 的解码字符集有自己的配置)、请求体(Content-Type 里的 charset 才是权威声明,没写就各方自己猜)、响应头、以及数据库连接的字符集参数。逐个确认这几处,而不是只改一处试试。
数据库这一层有个额外的坑:库、表、字段、连接四个层面都有字符集,而 MySQL 里名字带 utf8 的那个并不能存全部 Unicode(比如 emoji 和部分生僻字),要用 utf8mb4。表面症状是「存进去就变成问号」而不是乱码。
定位手段:看字节而不是看字符。把出问题的那段数据以十六进制打印出来,对照几种候选字符集手工解一遍,就能确定「写入时用的是哪个、读取时用的是哪个」。这比反复改配置试要快得多。
还有一个容易被忽略的:乱码可能是「二次编码」造成的——数据已经被正确编码过一次,又被当成另一种编码解开再编码,这时候原始信息已经损坏,改读取端的字符集是救不回来的,必须找到那次错误的转换。
值得的理由有四个,而第一个在并发场景下最重要。
一是天然线程安全:状态不会变,就不存在竞态,可以自由共享而不需要任何同步。二是可以安全地做缓存的 key 或放进 Set——可变对象作为 key,在放进去之后被改了字段,它的哈希桶位置就错了,从此再也找不到它。三是可以安全地缓存哈希值(String 就是这么做的)。四是防御性:把对象传给别人之后不用担心他偷偷改掉。
正确实现有五步,其中第四步最容易漏。
一、类声明为 final(或者构造器私有加工厂方法),防止子类覆盖方法引入可变性。
二、所有字段 private final。
三、不提供任何修改状态的方法,需要「修改」就返回一个新实例。
四、构造器里对传入的可变对象做防御性拷贝,getter 返回时也要拷贝。这一步最容易漏,漏了就等于没有不可变——比如字段是一个 List,构造时直接把外部传入的引用存下来,那么调用方之后修改那个 List,你的「不可变对象」内容就变了;同理 getter 直接把内部 List 返回出去,调用方也能改。要么深拷贝,要么返回不可修改视图。
五、注意「浅层不可变」不等于「深层不可变」:字段是 final 只保证引用不变,不保证被引用的对象内部不变。如果字段类型本身是可变的,就必须靠第四步兜住。
record 在这方面帮了大忙:它自动生成 final 字段、构造器、访问器、equals / hashCode / toString。但要清楚它只做到「浅层不可变」——record 里放一个 List 字段,那个 List 仍然是可变的,需要在紧凑构造器里做防御性拷贝。这是用 record 时最容易产生错误安全感的地方。
没有匹配的题目,换个关键词试试。
模块 04 · 共 25 题 · 题目与答案分离,建议先自答再展开