Java 的坑都在语义边界上
Java 语法本身不难,难的是几个"看起来一样、实际不一样"的边界:== 和 equals、可变的错觉、擦除后的类型、迭代器里的删除。这些地方答得含糊,说明只写过业务代码没踩过坑。
01先讲结论再举例每节都从一个能跑的例子开始,输出就是答案,不靠背诵。
02盯住三个边界相等性、可变性、类型信息——Java 的题八成出在这三处。
03对照前端经验泛型擦除像 TS,迭代器跳过像 forEach 里 splice,能对上就记住了。
1
String 为什么必须是不可变的★
不是设计洁癖,是三个能力的前提
String 内部是
private final byte[] value(JDK 9 起用 byte[],之前是 char[]),引用不可变、内容也不给你改。换字符串只能造新对象。这一个约束换来了三件事:常量池能安全共享、hashCode 能缓存、可以放心当 HashMap 的 key。输出b = hello
a = hello world
a 还是同一个对象吗: false
字面量复用: true
new 出来的同一个吗: false
equals 呢: true
intern 之后呢: true
- 常量池能共享,前提是没人能改它。如果 String 可变,两个字面量
"hello"指向同一块内存,一个改了另一个就跟着变。正因为改不了,JVM 才敢把字面量放进池子里复用。这也是==对字面量成立的原因。 - hashCode 可以缓存。String 内部有个
hash字段,第一次算完就存下来,之后直接用。String 被大量用作 HashMap 的 key,这个缓存省下的是实打实的重复计算。 - 线程安全是免费的。不可变对象没有状态可以被并发修改,跨线程传不需要任何同步。你不需要证明它安全,它天生安全。
- 代价在拼接上。每次
+都可能造新对象,循环里拼字符串是 O(n²) 的拷贝量。下一节专门讲这个。 - 和 JS 对照:JS 的 string 也是不可变原始值,
s[0] = "x"静默失效。但 JS 没有"常量池共享"这一层可观察的语义差异,Java 因为有==和intern(),这个设计才暴露在语言表面。
面试怎么答String 不可变的根本原因不是"为了安全"这种空话,而是它支撑了三个具体能力:一是常量池共享,字面量相同的字符串指向同一块内存,如果可变就互相污染;二是 hashCode 缓存,String 里存了 hash 字段,算一次就复用,因为它是最高频的 Map key;三是天然线程安全,不需要任何同步。代价是拼接会不断造新对象,所以循环拼接要用 StringBuilder。JDK 9 之后内部从 char[] 换成了 byte[] 加一个 coder 字段,纯拉丁字符只用一半内存,这叫紧凑字符串。
2
循环里拼接字符串,慢在哪
编译器帮你优化的,只是单条表达式
很多人背过"编译器会把
+ 优化成 StringBuilder"。这句话只对单条表达式成立。写在循环体里的 s += i,每次迭代都是一次完整的"新建 + 拷贝全部旧内容",量级是 O(n²)。JDK 9 起改用 invokedynamic 的字符串拼接,但每次迭代照样新建,循环累积的风险没变。输出(这台机器上的实测,数字随机器波动,看量级)+= : 134 ms, 长度 88890
StringBuilder : 不到 1 ms, 长度 88890
结果相同: true
- 差距来自拷贝量,不是"API 快慢"。
+=第 k 次迭代要把前 k-1 个字符全拷一遍,Σk 就是 O(n²)。StringBuilder 只在容量不够时把容量翻倍再拷,摊还是 O(n)。2 万次迭代就差两个数量级,20 万次会差到不可用。 - 编译器到底优化了什么:
"a" + b + "c"这样一条表达式,JDK 8 编译成一次new StringBuilder().append(a).append(b).append(c);JDK 9 起编译成invokedynamic调用StringConcatFactory。两种都只优化"一条表达式内部",不跨迭代复用对象。 - 编译期就能算的直接折叠。
"a" + "b"两个字面量,javac 直接合成"ab"存进常量池,运行时零开销。所以"字符串常量相加"和"变量相加"完全不是一回事。 - 该怎么选:单条拼接用
+,可读性最好;循环或累积用StringBuilder(单线程)或StringBuffer(要同步,实际很少需要);已知长度时给个初始容量,省掉扩容拷贝。 - 和 JS 对照:JS 里
s += x在现代引擎上是 ropes 结构,不一定是每次全拷,所以"循环拼字符串慢"在 JS 里没 Java 这么夸张。这不是 JS 更聪明,是引擎在字符串内部做了惰性合并。
面试怎么答循环里用 += 拼字符串的复杂度是 O(n²),因为 String 不可变,每次拼接都要新建对象并把之前所有字符拷过去,第 k 次迭代拷 k 个字符,累加起来就是平方级。StringBuilder 内部是可扩容的数组,只在容量不足时翻倍拷贝,摊还是 O(n)。编译器确实会把加号优化成 StringBuilder,但只针对单条表达式,不会跨循环复用对象,所以循环累积它救不了。实测两万次迭代差两个数量级。实践上单条拼接直接用加号,循环里用 StringBuilder,能预估长度就先给初始容量。
3
== 和 equals 到底在比什么★
一个比地址,一个比内容,装箱还插了一脚
== 比的是引用是不是指向同一个对象(基本类型比数值),equals 默认也是比地址,只有重写之后才比内容。最容易翻车的是包装类:自动装箱会走缓存,-128 到 127 之间返回同一个对象,超出就新建。输出127 == 127 : true
128 == 128 : false
128 equals : true
Long 128 == : false
new Integer(127) == 127 : false
- 缓存范围是 IntegerCache 定的:默认 -128 到 127。上限可以用
-XX:AutoBoxCacheMax=1000调大,下限不可调。所以"128 用 == 是 false"这件事依赖 JVM 参数,不能当语义保证用——本来就该用equals。 - 什么时候会触发装箱:把 int 赋给 Integer、把 int 放进
List<Integer>、方法参数要 Integer 却传了 int。只要走Integer.valueOf()就有缓存,走new就没有。 - 最阴的一个坑:
Integer a = 1; Long b = 2L; a.equals(b)返回 false,连编译都不报错——因为Integer.equals里判断的是instanceof Integer,类型不同直接 false。要是写成a == b,会先拆箱成 int 和 long 再比,反而可能是 true。 - 三目运算符的拆箱陷阱:
Integer x = null; int y = flag ? x : 0;这里x和0会被统一成 int,null 拆箱直接 NPE。这类问题线上很常见。 - 和 JS 对照:JS 的
==是隐式类型转换、===才严格。Java 正好反过来——==是严格的引用比较,看起来"更安全",但对包装类它就变成了"比地址",语义上比内容比较更容易出错。
面试怎么答== 对基本类型比数值,对引用类型比地址。equals 是 Object 的方法,默认实现就是 ==,只有重写后才比内容。包装类的坑在于自动装箱走 Integer.valueOf,-128 到 127 有缓存,返回同一对象,所以这个区间内 == 是 true,超出就是 false。这个范围上限还能通过 AutoBoxCacheMax 调,所以它根本不是语言保证。结论很简单:包装类一律用 equals,或者更稳的做法是先拆箱成基本类型再比。还要注意 Integer 和 Long 之间 equals 永远 false,因为 equals 里有 instanceof 判断。
4
重写 equals 就必须重写 hashCode
不是规范洁癖,是散列表的查找前提
HashMap 找 key 分两步:先按 hashCode 定位到桶,再在桶里用 equals 逐个比。只重写 equals 不重写 hashCode,两个"相等"的对象会算出不同的桶,于是
get 直接找不到——返回 null,还不报错。输出只重写 equals,同 id 新对象去取: null
两个都重写,同 id 新对象去取: 张三
equals 相等: true
hashCode 相等: false
最后两行就是病根:equals 说相等,hashCode 说不是一路人
- 契约原文:equals 相等的两个对象,hashCode 必须相等;hashCode 相等,equals 不一定相等(哈希碰撞)。反过来不成立就是 bug。
- 为什么只影响散列表:ArrayList 里存对象用 equals 线性找,不碰 hashCode,所以只重写 equals 在 ArrayList 里能用。HashMap / HashSet / ConcurrentHashMap 全靠 hashCode 分桶,立刻就废。这也是"本地测试过了、上线出错"的典型来源。
- 可变对象不能当 key。放进去之后改了参与计算 hashCode 的字段,桶的位置就变了,再 get 找不到,remove 也删不掉——对象永久留在 Map 里,是内存泄漏。真要可变,把业务主键(不可变的 id)作为 hashCode 依据。
- 正确写法:用
Objects.hash(id, name)(注意它内部装箱,超高频场景可以手写),equals 用Objects.equals,并对 null 做保护。IDEA 和 Lombok 的@EqualsAndHashCode生成的是一致的,但继承场景要小心——默认不调 super。 - 和 JS 对照:JS 的
Map用 SameValueZero 比较 key,对象永远按引用算,所以不存在"要成对重写"的问题,但也没法让两个不同对象被视为同一个 key。Java 给了你定义"相等"的权力,代价是必须自己保持一致。
面试怎么答HashMap 的查找是先用 hashCode 定位桶,再在桶内用 equals 比对,所以两个 equals 相等的对象如果 hashCode 不同,会落到不同桶,get 直接返回 null 且不报错。这就是必须成对重写的全部原因,本质是散列表的查找前提,不是风格规范。还有两个延伸考点:可变对象当 key 会导致 hashCode 变化后彻底找不到,实质是内存泄漏;Lombok 生成的两个方法是一致的,但子类继承时默认不含父类字段,要显式加 callSuper。
5
ArrayList 和 LinkedList 怎么选
链表在纸上更快,在 CPU 上更慢
教科书说"随机访问用 ArrayList,频繁插入删除用 LinkedList"。后半句在真实数据上基本是错的:链表插入前要先从头走到那个位置,本身是 O(n),而且节点散落在堆上,缓存命中率极差。实测中间插入 LinkedList 比 ArrayList 还慢。
输出(数字随机器波动,重点看倍数)中间位置 get x2000 : ArrayList 0 ms / LinkedList 68 ms
中间位置 add 一次 : ArrayList 11 us / LinkedList 65 us
- ArrayList 的 get 是一次乘法:
elementData[index],数组下标寻址,O(1)。LinkedList 的 get 要判断 index 靠前还是靠后,然后从头或尾一步步走,O(n)。2000 次访问 5 万元素的中位,差了 67ms。 - 插入慢的另一半原因在缓存。ArrayList 插入是
System.arraycopy,连续内存的顺序拷贝,CPU 预读全部命中,跑到接近内存带宽。LinkedList 插入要先遍历找位置(同样是 O(n) 的指针追逐),而且每次跳转都可能是一次缓存未命中。 - LinkedList 唯一明显赢的场景:已知节点引用、要在它前后插入删除(比如实现 LRU 用的双向链表)。用
List接口按索引操作时,它几乎全面落后。所以 JDK 官方文档自己都写了"极少使用"。 - ArrayList 的三个常考细节:默认容量是 10,但首次 add 才分配(无参构造给的是空数组);扩容是
old + old >> 1,即 1.5 倍;remove(int)删下标、remove(Object)删元素,传Integer时容易调错重载。 - subList 是视图不是拷贝。对 subList 的修改会写回原 list,原 list 结构变了再操作 subList 会抛 ConcurrentModificationException。要独立副本就
new ArrayList<>(list.subList(a, b))。
面试怎么答教科书那句"随机访问用 ArrayList、频繁增删用 LinkedList"只对了一半。LinkedList 的插入本身是 O(1),但前提是你已经拿到了那个节点的引用;通过 List 接口按索引插入,得先遍历到位置,复杂度还是 O(n),而且节点在堆上分散,指针追逐的缓存命中率很低。实测 5 万元素中间插入,LinkedList 比 ArrayList 慢三四倍,因为 ArrayList 是 arraycopy 连续内存拷贝,速度快得多。所以实践结论是默认用 ArrayList,LinkedList 只在已经持有节点引用、或者当 Deque 用的时候才有价值。
6
HashMap 什么时候扩容,什么时候树化★
把反射打开看真实结构
HashMap 是数组 + 链表 + 红黑树。容量永远是 2 的幂,负载因子 0.75,元素数超过
容量 × 0.75 就翻倍扩容。链表长度到 8 并且容量到 64 才树化——不到 64 优先扩容而不是树化。输出(JDK 17,需要 --add-opens 才读得到私有字段)刚创建 table 长度: 0
放 12 个后 table 长度: 16
放第 13 个后 table 长度: 32
放 25 个后 table 长度: 64
"Aa" 和 "BB" 的 hashCode 相等: true
两个都放进去后 size: 2
- 0 说明 table 是懒初始化的。无参构造只设了负载因子,数组第一次 put 才分配,默认 16。这样 new 一个不用的小 Map 不占数组内存。
- 16 放 12 个刚好卡在阈值上:16 × 0.75 = 12,第 13 个触发扩容到 32;32 × 0.75 = 24,第 25 个扩到 64。所以"扩容发生在超过阈值的那一次 put",不是到达时。
- 容量必须是 2 的幂,因为索引用
(n - 1) & hash代替取模,位运算快,且 n-1 的低位全 1 能让哈希值分布更均匀。就算你new HashMap(100),内部也会向上取整到 128。 - hash 先做了扰动:
(h = key.hashCode()) ^ (h >>> 16),把高 16 位异或到低位。因为索引只用到低位,不扰动的话高位信息全浪费,容易撞桶。 - 树化条件常被记错:链表长度 ≥ 8 且 table 长度 ≥ 64 才转红黑树;不到 64 时只扩容,因为扩容会把节点重新打散,通常就不冲突了。退化阈值是 6,留了 7 作为缓冲,避免在 7、8 之间反复转换。
- "Aa" 和 "BB" 是经典碰撞对,hashCode 都是 2112,所以它们进同一个桶,用链表串起来,size 仍是 2(equals 不同)。
- JDK 7 头插 vs JDK 8 尾插:1.7 扩容时头插会反转链表顺序,多线程并发扩容可能形成环形链表导致 CPU 100%;1.8 改成尾插避免了这个死循环。但这不代表 HashMap 线程安全——并发 put 仍会丢数据、size 不准。要并发就用 ConcurrentHashMap。
面试怎么答HashMap 是数组加链表加红黑树,容量恒为 2 的幂,负载因子 0.75,元素数超过容量乘 0.75 就翻倍扩容,因为索引用 n-1 与 hash 做位运算代替取模,容量是 2 的幂才能保证低位全 1、分布均匀。链表长度到 8 并且表容量到 64 才树化,不到 64 优先扩容,因为扩容会重新打散节点。hash 计算前会用高低 16 位异或做扰动,因为定位只用到低位。JDK 8 把扩容的头插改成尾插,避免了 1.7 并发扩容成环导致 CPU 打满,但 HashMap 依然不是线程安全的,并发场景要用 ConcurrentHashMap。
7
遍历时删除元素会怎样★
fail-fast 不是保证,跳过元素比抛异常更危险
用 for-each 遍历时调
list.remove(),可能抛 ConcurrentModificationException,也可能静默跳过元素——取决于删的是第几个。判断依据是内部 modCount 和迭代器手里的 expectedModCount 是否还相等。输出删 a 之后继续走: ConcurrentModificationException
删 b 不抛异常,结果却是: [a, c],c 被跳过了
iterator.remove 之后: [a]
Arrays.asList 删元素: UnsupportedOperationException
- 为什么删 b 反而不报错:删掉 b 后 size 变 2,游标正好走到 2,
hasNext()判定为 false,循环直接结束——根本没机会做下一次 modCount 检查。所以异常没抛,但 c 永远没被访问。这种"不报错但少处理一条数据"才是线上最难查的 bug。 - 检查发生在 next() 里:
checkForComodification比modCount != expectedModCount,不等就抛。iterator.remove()会把expectedModCount同步成新的modCount,所以合法。 - fail-fast 是尽力而为,不是保证。JDK 文档明确说它只用于发现 bug,不能依赖它做并发正确性。并发下它可能不抛,也可能误抛。
- 三种正确写法:
iterator.remove();list.removeIf(predicate)(内部就是迭代器,最简洁);倒序遍历删(下标不会错位)。还有一招是遍历副本、删原表,但要知道自己付了一份拷贝。 - Arrays.asList 返回的是固定长度视图,底层就是原数组,
add/remove一律 UnsupportedOperationException,set可以。要可变用new ArrayList<>(Arrays.asList(...))。 - 和 JS 对照:JS 的
Array.prototype.forEach里 splice 同样会跳过元素,而且完全静默、不抛错。这个坑两个语言一模一样,只是 Java 至少在某些情况下会喊一声。
面试怎么答for-each 底层是迭代器,迭代器在 next 里比较 modCount 和 expectedModCount,不等就抛 ConcurrentModificationException。但删元素的位置会决定结果:删中间的,size 缩小后游标正好到达末尾,hasNext 返回 false,循环提前结束,不抛异常但后面的元素被跳过,这种静默数据丢失比抛异常危险得多。正确做法是三种:用 iterator.remove,它会同步 expectedModCount;用 removeIf;或者倒序遍历。还要补充 fail-fast 不是并发保证,JDK 文档只说它用于发现 bug,并发场景必须用并发容器。
8
泛型是编译期的,运行时会被擦掉
擦除带来的三个后果,每个都是高频题
Java 泛型是编译期检查 + 运行时擦除,
List<String> 和 List<Integer> 在运行时是同一个类。擦除带来三个后果:不能按泛型参数重载、不能 new T[]、能用反射塞进错误类型——而且编译期不报错,取出来赋值时才炸。输出取出来是: java.lang.Integer
赋值时才抛 ClassCastException
运行时类型: java.util.ArrayList
两个泛型参数是同一个类: true
- 擦除擦到哪:
List<String>擦成List,<T extends Comparable>擦成Comparable,无界T擦成Object。所以泛型参数不参与运行时类型,instanceof List<String>直接编译不过。 - 不能按泛型参数重载:
void f(List<String>)和void f(List<Integer>)编译报"same erasure",因为擦完签名完全相同。要区分只能换方法名或加个 dummy 参数。 - 不能 new T[]:运行时不知道 T 是什么,没法分配数组。
new T[10]直接编译错;(T[]) new Object[10]能过编译但运行时可能 ClassCastException。标准解法是传Class<T>用Array.newInstance。 - 桥方法(bridge method)解决多态分派:
class MyList implements Comparable<MyList>编译后会额外生成一个compareTo(Object)的桥方法,内部强转再调compareTo(MyList)。所以反射看到的方法数比你写的多。 - 为什么要擦除:为了向后兼容。Java 5 引入泛型时,必须让泛型代码和老的非泛型字节码互相调用,只能选擦除。C# 是重新设计了运行时,所以泛型信息保留到运行时。
- 和 TS 对照(这条最有价值):TypeScript 的泛型同样是纯编译期,运行时擦得一干二净,所以也不能
new T()、不能instanceof T、不能按泛型参数重载。不同的是 TS 擦除后连强制转换都没有,Java 至少会在赋值点插checkcast指令兜底。
面试怎么答Java 泛型只在编译期做检查,运行时被擦除,List<String> 和 List<Integer> 是同一个类。擦除有三个直接后果:第一不能按泛型参数重载,因为擦完签名一样,编译报 same erasure;第二不能 new T 数组,运行时不知道 T 是什么,只能传 Class 对象用 Array.newInstance;第三可以通过原始类型绕过检查塞进错误元素,编译不报错,取出来赋值时才抛 ClassCastException,因为赋值点会插入 checkcast 指令。另外多态分派靠编译器生成的桥方法来兼容擦除后的签名。选擦除是为了兼容 Java 5 之前的老代码。
9
finally 里的 return 会吃掉异常★
写得越"稳妥",越容易埋雷
finally 一定会执行,而且它的 return 会覆盖 try 里的返回值、并丢弃 try 里正在抛的异常。所以"在 finally 里兜底返回"这种写法,等于把所有异常变成静默的正常返回,是线上最难查的一类 bug。输出finally 里 return 覆盖返回值: 2
finally 里改局部变量: 1
finally 里 return 吞掉异常: 3
- 为什么 b() 返回 1 而不是 99:
return x在进入 finally 之前就把值算好、存到操作数栈上了,finally 里改x改的是局部变量槽,不影响已经算出的返回值。这是"值传递"在 return 上的体现。 - 为什么 c() 能吞异常:finally 里的 return 直接结束方法,JVM 会丢弃待抛出的异常。编译器甚至会给"try 里 throw、finally 里 return"的写法发警告。生产代码里 finally 有 return 基本等同于 bug。
- finally 什么情况下不执行:
System.exit()、Runtime.halt()、JVM 崩溃、守护线程被强制结束。守护线程这点在写后台任务时要注意——用 finally 做清理,线程被杀就漏了。 - checked 和 unchecked 的取舍:受检异常(
IOException、SQLException)强制调用方处理,好处是 API 契约显式,坏处是污染签名、在 lambda 里没法直接用(函数式接口不允许抛受检异常),于是满地都是"catch 了包成 RuntimeException"。Spring 就是全转成DataAccessException这种非受检异常。 - 异常链别断:
throw new BizException("下单失败", e)保留 cause,日志只打最外层也能看到根因。只写e.getMessage()再抛出,会把堆栈和根因一起丢掉。 - 和 JS 对照:JS 只有非受检异常,没有
throws声明,所以永远不会遇到"lambda 里不能抛受检异常"这种设计的连带问题。但finally里return同样会吞异常——这一点两个语言完全一致。
面试怎么答finally 一定执行,但它的 return 会覆盖 try 的返回值并且丢弃异常,因为 return 直接结束方法,待抛的异常被抛弃。所以生产代码里绝对不要在 finally 里 return。另一个细节是 return 表达式在进入 finally 之前就求值并压栈了,finally 里改局部变量不会影响已经算好的返回值。还要知道 finally 在 System.exit、JVM 崩溃、守护线程被杀这几种情况下不会执行。异常设计上,受检异常强制处理但会污染签名、在 lambda 里没法用,所以现在的主流做法是统一转成非受检异常,Spring 就是这么做的。
10
反射与动态代理:Spring 的地基★
理解这一节,Bean 和 AOP 就不用背了
反射是运行时按名字找类、找方法、改字段、造对象。动态代理是在反射之上再包一层:不写实现类,运行时生成一个新类替你转调。JDK 的代理只能代理接口,CGLIB 的代理靠继承生成子类。Spring 的 IoC 和 AOP 全都建立在这两件事上。
输出[前置] find 参数 7
[后置] 返回 用户7
结果: 用户7
代理类: $Proxy0
还是 UserService 吗: true
- 代理类名
$Proxy0说明它是运行时生成的,不是编译产物,也没有对应的 .java 文件。它实现了UserService接口,每个方法体都是"调 InvocationHandler.invoke"再返回。Spring 的@Transactional、@Cacheable就是这么把逻辑插进去的。 - JDK 代理的硬限制:必须有接口。因为生成的新类只能
implements,没法凭空继承一个类。CGLIB 反过来,用 ASM 生成目标类的子类并覆盖方法,所以能代理普通类,但代理不了 final 类、final 方法和 private 方法。 - Spring Boot 2.x 起默认用 CGLIB(
spring.aop.proxy-target-class=true),因为很多 Service 没有接口。可以用proxy-target-class=false强制回 JDK 代理——前提是注入了接口类型。 - AOP 失效的根本原因就是代理没生效:同类内部
this.method()调用走的是原始对象,不经过代理,注解形同虚设。解法是自注入、AopContext.currentProxy()、或者把方法挪到另一个 Bean。 - 反射的代价没有传说中那么吓人:现代 JVM 会生成字节码让反射调用接近直接调用,JDK 18 之后核心反射重建在 MethodHandle 上,快了很多。真正贵的是"每次都
getDeclaredMethod"这种查找,所以要缓存 Method 对象。 - JDK 17 的模块化会拦你:反射
java.util的私有字段会抛InaccessibleObjectException,需要启动参数--add-opens java.base/java.util=ALL-UNNAMED。JDK 8 没有这个限制,所以从 8 升到 17 的项目经常在这里翻车。 - 和 JS 对照:JS 的
Proxy更强——能拦 get/set/deleteProperty/函数调用,连"这个属性不存在"都能拦。Reflect提供默认行为。但 JS 没有类型、没有编译期检查,所以不存在 Java 那种"生成一个实现了接口的新类"的必要。Vue 3 的响应式就是new Proxy,原理同源。
面试怎么答反射是运行时按名字获取类、方法、字段并调用,Class 对象是入口。动态代理是在运行时生成一个新类,把方法调用转发到 InvocationHandler,Spring 的事务、缓存、日志都是这么插进去的。两种实现:JDK 代理基于接口,因为只能 implements;CGLIB 用 ASM 生成目标类的子类,所以能代理普通类但代理不了 final 类和 final 方法。Spring Boot 2.x 默认 CGLIB。AOP 失效最常见的原因是同类内部 this 调用不经过代理。反射本身性能在现代 JVM 上已接近直接调用,但要缓存 Method 对象避免重复查找。JDK 9 之后模块系统会禁止反射访问其他模块的私有成员,需要 add-opens。
11
IO、NIO 与零拷贝
四次拷贝变两次,省的是 CPU 和上下文切换
传统"读文件再写 socket"要经过四次拷贝、四次用户态/内核态切换。零拷贝(
sendfile)让数据不进用户态,在内核里直接搬运,降成两次拷贝。Java 的 FileChannel.transferTo 底层用的就是它。两次拷贝差在哪【传统 read + write】4 次拷贝
磁盘 → 内核页缓存 (DMA 拷贝)
内核页缓存 → 用户缓冲区(CPU 拷贝) ← 这次是白干的
用户缓冲区 → socket 缓冲区(CPU 拷贝) ← 这次也是白干的
socket 缓冲区 → 网卡 (DMA 拷贝)
外加 4 次用户态/内核态切换
【sendfile】2 次拷贝
磁盘 → 内核页缓存 (DMA 拷贝)
内核页缓存 → 网卡 (DMA 拷贝,带 scatter-gather 时 CPU 完全不管数据)
切换:2 次
- 省掉的两次 CPU 拷贝才是重点。用户缓冲区那次纯粹是因为"应用要能看见数据"这个假设,但转发文件时应用根本不看内容,所以这两次可以省。不是所有场景都能用:需要在数据中间做修改(加密、压缩、拼接)时,数据必须进用户态,零拷贝就失效了。
- BIO / NIO / AIO 是三种模型,和零拷贝是两回事。BIO 一连接一线程、read 阻塞;NIO 用 Selector 一个线程管多个 Channel,非阻塞 + 事件通知;AIO 是内核完成后再回调(Linux 上实现不成熟,Netty 用的是 NIO)。零拷贝解决的是"数据搬几次",IO 模型解决的是"谁来等"。
- Netty 为什么不用 JDK 原生 NIO 直接上:JDK 的 Selector 在 Linux 上有 epoll 空轮询 bug(会让 CPU 100%),Netty 自己统计轮询次数并重建 Selector 规避。另外 JDK NIO 的 ByteBuffer API 太原始,读写切换要 flip(),Netty 用读写双指针的 ByteBuf 解决了。
- DirectByteBuffer 是堆外内存,不受 GC 管理,靠 Cleaner 在回收时释放。好处是省掉一次堆内到堆外的拷贝、GC 不扫它;坏处是分配慢、容易 OOM(
-XX:MaxDirectMemorySize限制),而且排查内存问题时不看堆 dump。 - mmap 是另一种零拷贝:把文件映射到虚拟地址空间,读写就像访问内存,靠缺页中断按需加载。适合随机访问大文件(Kafka 的索引文件就用它),不适合超大文件一次性读——会触发大量缺页。
面试怎么答传统 read 加 write 要四次拷贝四次切换:磁盘到页缓存、页缓存到用户缓冲区、用户缓冲区到 socket 缓冲区、socket 缓冲区到网卡,中间两次 CPU 拷贝在纯转发场景下是白干的。sendfile 让数据不进用户态,内核里直接从页缓存搬到网卡,配 scatter-gather DMA 后 CPU 完全不参与数据搬运,降到两次拷贝两次切换。Java 里对应 FileChannel.transferTo。但需要在数据中间做加密压缩的场景用不了,数据必须进用户态。注意零拷贝解决的是数据搬几次,BIO/NIO/AIO 解决的是谁来等,两回事。
12
Java 序列化:能不用就别用
它在生产环境里主要是负债
Java 原生序列化把对象转成字节流,只序列化非静态、非 transient 的字段,靠
serialVersionUID 判定版本是否兼容。它在生产环境的问题比价值多:不可读、跨语言不通、反序列化是经典漏洞入口。现在基本用 JSON 或 Protobuf 替代。输出序列化后字节数: 68
字节里能找到密码吗: false
name = 张三
password = null
company = B 公司
- company 变成 B 公司说明静态字段根本没进字节流,读出来的是当前类里的值。所以"用静态字段做缓存再序列化"这种设计是错的。
- serialVersionUID 的作用是版本校验:显式声明了就用它,没声明则由编译器按类结构算一个。类改一个字段,自动算出的值就变,反序列化老数据直接抛
InvalidClassException。所以生产上必须显式写死,否则一次无害的重构就能让缓存里的对象全部读不出来。 - 反序列化漏洞是真实的攻击面:
readObject是可重写的方法,攻击者构造恶意字节流,就能让目标类在反序列化时执行任意逻辑(Commons-Collections 那条著名利用链)。所以外部输入绝对不能直接反序列化成 Java 对象。 - 性能也不占优势:原生序列化要写完整的类描述(类名、字段名、类型),体积比 JSON 还大,速度也没优势。Dubbo 默认用 hessian2,Spring Cloud 用 JSON,RPC 场景多用 Protobuf。
- 该用什么:跨语言、要可读 → JSON;高频、要小体积 → Protobuf / Avro;本地 JVM 内传对象 → 根本不用序列化,直接传引用(同一进程)。
- 和 JS 对照:
JSON.stringify就是 JS 的序列化,但它会丢掉undefined、函数、Date变字符串、Map/Set变空对象,圆形引用直接抛错。所以"序列化会丢信息"这件事两个语言都有,只是丢的东西不同。
面试怎么答Java 原生序列化只序列化非静态、非 transient 的字段,静态字段属于类不跟着对象走。serialVersionUID 决定版本兼容性,不显式声明时由编译器按类结构自动计算,改一个字段就会变,老数据反序列化会抛 InvalidClassException,所以必须显式写死。生产环境的问题有三个:一是反序列化是经典漏洞入口,readObject 可以被利用执行任意代码,外部输入绝对不能直接反序列化;二是体积大速度慢,因为它要写完整的类元信息;三是跨语言不通。现在主流用 JSON 做通用交换,Protobuf 做高性能 RPC。
13
static 嵌套类和内部类差在哪
一个持有外部引用,一个不持有
static class 叫静态嵌套类,和外部类只是命名空间关系;不加 static 的才是内部类,每个实例都隐式持有一个 this$0 指向外部对象。这个隐藏引用是内存泄漏的常见源头——内部类活得久,外部对象就跟着活。输出 内部类看到外部字段: 占内存的大对象
内部类有外部引用吗: true
静态嵌套类有 this$0 吗: false
- 创建语法就已经暴露了关系:内部类要写成
outer.new Inner(),因为你必须先有一个外部对象;静态嵌套类直接new Outer.Nested()。语法差异背后就是"要不要一个外部实例"。 - 内存泄漏长什么样:Android 里把非静态内部类(Runnable、Handler、Listener)交给一个长生命周期的对象持有,外部 Activity 就被拴住回收不了。Java 里同理——内部类被静态集合引用、被线程池的任务队列引用,都会拖住外部对象。
- 什么时候必须用非静态内部类:真的要访问外部实例的字段和方法,而且生命周期一致。其余情况一律加 static,这是《Effective Java》第 24 条。
- final 的三种含义:修饰类不可继承(
String、Integer);修饰方法不可重写;修饰字段表示引用/值不能变(但对象内部状态仍可变,final List还能 add)。 - 匿名内部类捕获局部变量,要求 effectively final:变量声明后没再赋值即可,不一定要写 final。因为捕获的是值的一份拷贝,语言层面直接禁止你改,避免"改了但看不到效果"的困惑。
面试怎么答加了 static 的是静态嵌套类,和外部类只是命名空间关系;不加的才是内部类,每个实例都隐含一个 this$0 字段指向外部对象,创建时要写 outer.new Inner()。这个隐藏引用是内存泄漏的常见原因:内部类被长生命周期对象持有时,外部对象也回收不了,Android 的 Handler 泄漏就是典型。所以除非真要访问外部实例状态,一律加 static,这是 Effective Java 的建议。另外 final 修饰字段只是引用不可变,对象内部状态照样能改,final List 还能 add。
14
重载看编译期类型,重写看运行期类型
一句话区分静态分派和动态分派
重载(overload)在编译期就定死了,编译器按参数的静态类型选方法;重写(override)在运行期按实际类型分派,走虚方法表。所以把 String 赋给 Object 变量再传给重载方法,走的是 Object 那个版本。
输出静态类型是 Object:
-> Object 版本
静态类型是 String:
-> String 版本
传 null:
-> String 版本
重写看实际类型:
-> Sub.hi
- 第一组输出说明编译器只看声明类型。
o的静态类型是 Object,编译器就只认say(Object),哪怕运行时它是 String。这就是静态分派,发生在编译期,靠参数类型挑方法。 - 传 null 挑最具体的那个,所以是 String 版本。如果再加一个
say(Integer),say(null)就编译报错——String 和 Integer 没有继承关系,编译器选不出更具体的,这叫歧义。 - 重写走虚方法表(vtable)。每个类有一张方法表,子类覆盖的方法槽里填的是子类实现。调用时按对象头里的类型指针找到表,再取槽位——这就是动态分派,多态的实现原理。
- 重载的三条硬规则:返回值不能作为重载依据(
int f()和String f()不算重载,编译报错,因为调用点无法区分);访问权限可以不同;异常声明可以不同。 - 重写的约束(两同两小一大):方法名和参数列表相同;返回类型更小或相同(协变返回);抛出的异常更小或相同;访问权限不能更小。
private、static、final方法不能被重写——static 方法"重写"只是隐藏,编译期绑定。 - 和 JS 对照:JS 没有重载,同名函数后定义的覆盖前面的,想模拟只能靠
arguments.length或类型判断。TS 的重载签名也是纯编译期体操,运行时只有一个实现。所以"重载看静态类型"这个问题在 JS 里根本不存在,但"重写看实际类型"是一样的——原型链查找就是 JS 的虚方法表。
面试怎么答重载是编译期按参数的静态类型选方法,叫静态分派;重写是运行期按对象的实际类型选实现,叫动态分派,靠虚方法表。所以把 String 赋给 Object 变量去调重载方法,走的是 Object 版本。返回值不能作为重载依据,因为调用点无法区分。传 null 时编译器挑最具体的参数类型,如果有两个平级的重载就会报歧义。重写的规则是两同两小一大,private、static、final 方法不能被重写。多态的实现就是对象头里的类型指针找到方法表再取槽位。
15
接口的 default 方法冲突怎么解
Java 8 加它,是为了不破坏已有实现
两个接口有同名
default 方法,一个类同时实现它们时编译直接报错,必须显式覆盖,并用 接口名.super.方法() 指定要调哪个。Java 8 引入 default 的唯一动机是:给老接口加方法,不破坏所有已有实现类。输出A+B
如果把 @Override 那段删掉,编译直接报:
class DefaultConflict inherits unrelated defaults for hello() from types A and B
- 规则优先级:类中显式实现 > 子接口的 default > 父接口的 default。如果父类和接口有同名方法,父类的具体方法赢("类优先"原则),接口的 default 直接被忽略,连冲突都不报。
A.super.hello()这个语法只能用在类里,而且只能指向直接实现的接口,不能A.super.super。它的作用是明确给出"我要这个接口的默认实现",绕开歧义。- default 为什么必须存在:Java 8 要给
Collection加stream()、给List加sort()。List的实现类全世界有无数个(包括你项目里的),如果加抽象方法,全部编译失败。加 default 就只有愿意覆盖的人覆盖。这是向后兼容的经典手法。 - 抽象类还是接口:接口能多实现、不能有状态(字段隐含
public static final)、不能有构造器;抽象类能有实例字段和构造器,只能单继承。要定义"能力"用接口,要复用"实现骨架"用抽象类。Java 8 之后 default 让接口也能带实现,但依然不能有实例状态,所以两者没被合并。 - 和 TS 对照:TS 的
interface是纯类型,编译后完全消失,不能有方法体。所以"两个接口同名方法冲突"这种事在 TS 里不存在——类型层面的合并反而更宽松(同名同签名的属性会合并)。要带实现就得写abstract class或者用 mixin。
面试怎么答两个接口有同名 default 方法时,实现类必须显式覆盖,否则编译报错说是继承了不相关的默认方法,解法是在实现里用接口名点 super 调用指定版本。优先级是类中的实现最高,然后是子接口,最后是父接口;如果父类和接口有同名方法,父类的具体方法优先,这条叫类优先原则。default 方法是 Java 8 为了向后兼容加的,目的是给 Collection 这类老接口加 stream 方法而不让全世界的实现类编译失败。接口和抽象类的分工是,接口定义能力且能多实现,抽象类能持有实例状态但只能单继承。
16
try-with-resources 的关闭顺序
后开的先关,关闭失败的异常会被压制
多个资源写在同一个 try 括号里,关闭顺序和打开顺序相反(后开先关,像栈)。如果业务代码和 close 都抛异常,业务异常是主异常,close 的异常挂在 suppressed 里,不会覆盖主异常——这正是它比手写 finally 强的地方。
输出-- 正常退出 --
打开 A
打开 B
业务代码
关闭 B
关闭 A
-- 业务和 close 都抛 --
打开 C
关闭 C
捕获: 业务异常
被压制的: C 关闭失败
- 后开先关不是约定,是依赖关系决定的:B 可能依赖 A(比如包装流
BufferedWriter(new FileWriter(f))),先关 A 再关 B,B 的 close 就会写到一个已关闭的流上。 - 手写 finally 的经典错误就是"close 抛异常覆盖主异常":finally 里 close 抛了,原来的业务异常就丢了,日志里只剩一个莫名其妙的关闭失败。try-with-resources 用 suppressed 机制把两个都留下,这是它存在的核心理由,不只是"少写几行"。
- AutoCloseable 和 Closeable 的区别:
Closeable继承自AutoCloseable,close()抛IOException且可重复调用;AutoCloseable.close()抛Exception。自定义资源实现 AutoCloseable 就够了,除非要和 IO 体系对接。 - 资源变量是隐式 final:不能在 try 块里给
a重新赋值,编译报错。Java 9 之后可以写try (existingVar)直接引用外面已声明的 effectively final 变量,省掉一个临时变量。 - 配合连接池要注意:try-with-resources 调的是
close(),连接池的 close 通常是"归还"而不是真关闭(HikariCP 的ProxyConnection.close()就是把连接放回池子)。所以不用担心写 try-with-resources 会把连接真关掉。
面试怎么答多个资源写在同一个 try 括号里,关闭顺序是后开先关,因为后开的资源可能依赖先开的,比如包装流。它比手写 finally 强的核心不是省代码,而是异常处理:业务异常和 close 抛的异常同时出现时,业务异常作为主异常抛出,close 的异常挂在 getSuppressed 里,不会覆盖主异常。手写 finally 最常见的 bug 就是 close 抛异常把原始业务异常吞掉,日志里只剩关闭失败。资源变量是隐式 final。AutoCloseable 是基接口,Closeable 是它的子接口,只多一个 IOException 约束。
17
金额为什么必须用 BigDecimal★
double 存不下 0.1,这不是 Java 的问题
float/double 是二进制浮点,0.1 在二进制里是无限循环小数,只能近似存。所以 0.1 + 0.2 != 0.3。金额场景必须用 BigDecimal,而且要用字符串构造、用 compareTo 比较、divide 必须指定精度。输出0.1 + 0.2 = 0.30000000000000004
0.1 + 0.2 == 0.3 ? false
new BigDecimal(0.1) = 0.1000000000000000055511151231257827021181583404541015625
BigDecimal.valueOf(0.1) = 0.1
new BigDecimal("0.1") = 0.1
equals : false
compareTo : true
divide 不整除: Non-terminating decimal expansion; no exact representable decimal result.
给精度: 0.3333
- 那一长串数字就是 0.1 的真身。
new BigDecimal(0.1)是把 double 里的近似值一比一搬进来,所以精度问题原封不动。BigDecimal.valueOf(0.1)内部走Double.toString,拿到最短的十进制表示"0.1"再构造,所以是对的。结论:永远别用 double 构造 BigDecimal,用字符串或valueOf。 - equals 比的是"数值 + 精度",
1.0和1.00精度不同,equals 是 false,但 compareTo 是 0。Java 里有个著名的坑:BigDecimal.ZERO.equals(new BigDecimal("0.00"))是 false。所以判断金额相等一律用 compareTo。 - divide 必须给精度:1/3 是无限小数,不给 scale 和 RoundingMode 就抛
ArithmeticException。这不是缺陷,是设计——它拒绝替你决定舍入方式。金额计算一定要明写RoundingMode.HALF_UP(四舍五入),别用默认的HALF_EVEN(银行家舍入),除非业务真的要求。 - setScale 也要给 RoundingMode,不然同样抛异常。
setScale(2)只在你确定不会有更多小数位时才安全,否则线上会在某笔金额上炸。 - 性能不是问题:BigDecimal 比 double 慢一个量级以上,但金额计算的量级根本不构成瓶颈。为了性能用 double 算钱是典型的错误取舍。真正的高频场景(比如计费引擎)用 long 存"分"更快也更安全。
- 和 JS 对照(这条最能说明问题的本质):
0.1 + 0.2 === 0.30000000000000004在 JS 里一模一样,因为两边都是 IEEE 754 double。这不是 Java 的毛病,是所有用二进制浮点的语言的共同行为。JS 没有内置十进制类型,所以前端算金额要么用整数分、要么用 decimal.js 这类库;Java 有 BigDecimal 可以直接用。
面试怎么答float 和 double 是二进制浮点,0.1 在二进制里是无限循环小数只能近似表示,所以 0.1 加 0.2 不等于 0.3。金额必须用 BigDecimal,而且有三个必须注意的点:一是不能用 double 构造,new BigDecimal(0.1) 会把 double 的误差一比一搬进来,要用字符串或者 valueOf;二是比较不能用 equals,因为 equals 比数值加精度,1.0 和 1.00 是 false,要用 compareTo;三是 divide 遇到无限小数会抛 ArithmeticException,必须显式指定 scale 和 RoundingMode。另外这个精度问题不是 Java 独有的,JS 里完全一样,因为都是 IEEE 754。
18
Java 8:Lambda、Stream 与 Optional
lambda is syntax sugar, the stream is the point
Lambda 只是把匿名内部类写短了,真正的变化是 Stream 把"遍历"变成了"声明式流水线"。它有两个必须知道的特性:中间操作是惰性的(不写终止操作就一行都不跑)、一个流只能消费一次。Optional 则是给返回值用的容器,不是"用来判空"的工具。
实测输出(JDK 17)--- 流已经建好,但什么都没发生 ---
filter 处理了 amy
filter 处理了 bob
filter 处理了 cathy
filter 处理了 david
filter 处理了 eve
count = 2(到这里才真的开始跑)
链式结果: CATHY, DAVID
findFirst 命中: david
findFirst 落空: (没有)
map 之后: -1
- 中间操作 vs 终止操作:
filter、map、sorted都是中间操作,只登记不执行;count、collect、forEach、findFirst才是终止操作,一出现整条流水线才开始跑。上面那段输出就是证据:流建好之后什么都没发生。 - 惰性带来一个副作用:短路。
findFirst找到第一个就停,后面的元素根本不会被 filter 处理。所以"先 filter 再 findFirst"和"先 findFirst 再判断"性能差很远。 - 一个流只能消费一次:终止操作执行后流就关闭了,再调用会抛
IllegalStateException: stream has already been operated upon or closed。要复用就把stream()的调用写成方法,每次重新生成。 - Lambda 捕获的变量必须是 effectively final:不是"不能改",而是"不能在捕获之后再赋值"。原因是 lambda 编译后捕获的是值的副本,不是引用,改了会让副本和原变量不一致。想改就用只有一个元素的数组或者 AtomicInteger。
- parallelStream 不是免费的:它用公共的 ForkJoinPool.commonPool,默认线程数是 CPU 核数减一。在 Web 容器里用它会和其他请求抢同一个池,一个慢任务能把整个池占满。数据量小、或者操作里有 IO 时,并行流比串行还慢。
- 方法引用只是 lambda 的简写:
String::toUpperCase等价于s -> s.toUpperCase()。四种形式:静态方法、实例方法、任意对象的实例方法、构造器。
✕ Optional 的典型误用
- 把 Optional 当参数类型 —— 参数应该直接要求非空,或者用重载
- 把 Optional 当字段 —— 它不实现 Serializable,而且多一层包装
optional.get()前先isPresent()—— 白用了,和 if (x != null) 一样orElse(compute())—— 无论命不命中都会算一遍
✓ 正确的用法
- 只用在返回值上,表示"这里可能没有结果",强迫调用方处理
- 用
orElse/orElseGet/orElseThrow收尾 - 需要惰性求值用
orElseGet(() -> 默认值),别用 orElse - 用
map/filter链式处理,避免嵌套判空
面试怎么答Lambda 本质是函数式接口的实例,编译后是一个 invokedynamic 指令,不是匿名内部类,所以不会生成额外的 class 文件。Stream 是惰性的:filter、map 这些中间操作只做登记,只有遇到 count、collect、findFirst 这类终止操作才会真正开始遍历,所以一条流水线可以一次遍历完成多次转换。要注意流只能消费一次,终止操作之后流就关闭了。parallelStream 用的是 ForkJoinPool 的公共池,在 Web 应用里可能和业务抢线程,数据量小或者有 IO 的时候反而更慢。Optional 应该只用在返回值上表示可能没有结果,不要当参数或字段,取值用 orElse 或者 orElseThrow,而不是 get 之前先 isPresent,那样和判空没区别。
19
速答清单
面试前一小时只看这页
01Stream 的中间操作和终止操作有什么区别?
中间操作(filter、map、sorted)只登记不执行,是惰性的;终止操作(count、collect、findFirst、forEach)一出现整条流水线才开始跑。所以不写终止操作,一个元素都不会被处理。
02一个 Stream 能用两次吗?
不能。终止操作执行后流就关闭了,再调用抛 IllegalStateException: stream has already been operated upon or closed。要复用就把 stream() 的调用包成方法,每次重新生成。
03parallelStream 有什么坑?
它用的是 ForkJoinPool 的公共池,线程数是 CPU 核数减一。Web 应用里会和业务抢同一个池,一个慢任务能把池占满。数据量小或者操作里有 IO 时,并行流反而比串行慢。
04Lambda 里为什么不能改外部变量?
捕获的是值的副本,不是引用。改了会让副本和原变量不一致,所以要求 effectively final——不是完全不能改,而是不能在捕获之后再赋值。要改就用单元素数组或 AtomicInteger。
05Optional 该怎么用?
只用在返回值上,表示"这里可能没有结果"。取值用 orElse / orElseGet / orElseThrow,不要 get 之前先 isPresent,那和判空没区别。不要当参数类型和字段,它不实现 Serializable,而且多一层包装。
06orElse 和 orElseGet 有什么区别?
orElse 的参数是值,无论 Optional 有没有值都会先算出来;orElseGet 的参数是 Supplier,只有真的为空时才调用。默认值需要计算(比如查库)时用 orElseGet。
07String 为什么不可变?
内部是 final 的字节数组,引用和内容都不给改。换来常量池可共享、hashCode 可缓存、天然线程安全。
08String、StringBuilder、StringBuffer 怎么选?
少量拼接用 +;循环累积用 StringBuilder;需要同步才用 StringBuffer,实际很少需要。
09字符串常量相加和变量相加有什么区别?
常量在编译期直接折叠成一个字面量存进常量池,零运行时开销;变量必须运行时拼接,每次新建对象。
10== 和 equals 的区别?
== 比引用(基本类型比数值),equals 默认也是比引用,重写后才比内容。包装类一律用 equals。
11Integer 128 == Integer 128 为什么是 false?
自动装箱走 Integer.valueOf,-128 到 127 有缓存返回同一对象,超出就新建。上限可用 AutoBoxCacheMax 调,所以不是语言保证。
12为什么重写 equals 必须重写 hashCode?
HashMap 先用 hashCode 定桶再用 equals 比对,不重写会落到不同桶,get 返回 null 且不报错。
13可变对象为什么不能当 HashMap 的 key?
放进去后改字段会改变 hashCode,桶位置变了,get 和 remove 都找不到,对象永久留在表里,是内存泄漏。
14ArrayList 默认容量多少,怎么扩容?
无参构造是空数组,首次 add 分配 10。扩容是 old + old>>1,即 1.5 倍,用 arraycopy 搬数据。
15为什么 LinkedList 插入反而不快?
按索引插入要先遍历定位,本身 O(n),而且节点分散导致缓存命中率差。只有已持有节点引用时才快。
16HashMap 的容量为什么必须是 2 的幂?
索引用 (n-1) & hash 代替取模,位运算更快,且 n-1 低位全 1 时分布更均匀。new HashMap(100) 会被取整到 128。
17HashMap 什么时候树化?
链表长度到 8 并且 table 长度到 64 才转红黑树;不到 64 优先扩容。退化阈值是 6,留 7 作缓冲。
18JDK 8 为什么把头插改成尾插?
1.7 并发扩容头插会反转链表并可能成环,导致 CPU 100%。改成尾插规避了这个问题,但 HashMap 依然不是线程安全的。
19for-each 里删元素为什么有时不报错?
删倒数第二个时 size 缩小后 hasNext 直接返回 false,循环提前结束,异常没机会触发,但后面的元素被静默跳过。
20正确删除集合元素的三种写法?
iterator.remove()、removeIf()、倒序遍历按下标删。前两个最稳。
21泛型擦除带来哪些限制?
不能按泛型参数重载(签名相同)、不能 new T[]、不能 instanceof List<String>、能用原始类型绕过检查。
22桥方法是什么?
泛型类实现泛型接口时,编译器生成的额外方法,参数是擦除后的类型,内部强转后调真实方法,用于保证多态分派。
23finally 里的 return 有什么后果?
覆盖 try 的返回值,并丢弃正在抛的异常。生产代码里 finally 有 return 基本等同于 bug。
24finally 什么情况下不执行?
System.exit、Runtime.halt、JVM 崩溃、守护线程被强制结束。
25受检异常和非受检异常怎么选?
受检强制处理但污染签名、lambda 里没法用,所以主流是把受检异常转成非受检(Spring 就是这么做的)。
26JDK 动态代理和 CGLIB 的区别?
JDK 基于接口,只能代理由接口定义的方法;CGLIB 生成子类,能代理普通类但不能代理 final 类和 final 方法。Spring Boot 2.x 默认 CGLIB。
27AOP 什么时候会失效?
同类内部 this 调用不经过代理。解法是自注入、AopContext.currentProxy()、或把方法拆到另一个 Bean。
28零拷贝省掉了什么?
省掉内核页缓存到用户缓冲区、用户缓冲区到 socket 缓冲区这两次 CPU 拷贝,以及两次上下文切换。sendfile 底层靠 DMA 搬运。
29为什么需要加密压缩就不能用零拷贝?
数据必须进用户态才能被应用处理,一旦进用户态就退化成传统的四次拷贝。
30Java 序列化有哪些问题?
不可读、体积大、跨语言不通、反序列化是经典漏洞入口。生产上用 JSON 或 Protobuf。
31serialVersionUID 不写会怎样?
编译器按类结构自动算,改一个字段值就变,老数据反序列化抛 InvalidClassException。必须显式写死。
32transient 和 static 字段会被序列化吗?
都不会。transient 是明确排除,static 属于类不跟着对象走。
33内部类和静态嵌套类的区别?
内部类隐含 this$0 指向外部实例,创建要写 outer.new Inner(),会拖住外部对象导致泄漏;静态嵌套类不持有。
34final 修饰字段等于不可变吗?
不等于。只是引用不能变,对象内部状态照样能改,final List 还能 add。
35重载和重写分别什么时候确定?
重载编译期按静态类型定(静态分派),重写运行期按实际类型定(动态分派,走虚方法表)。
36为什么返回值不能作为重载依据?
调用点无法从方法名和参数区分要调哪个,编译器没法生成确定的调用指令。
37两个接口有同名 default 方法怎么办?
必须显式覆盖,用 接口名.super.方法() 指定。父类和接口同名时父类优先。
38try-with-resources 的关闭顺序?
后开先关。业务异常和 close 异常同时出现时,业务异常是主异常,close 的挂到 suppressed 里。
39BigDecimal 有哪些坑?
别用 double 构造(要用字符串或 valueOf)、比较用 compareTo 不用 equals、divide 必须指定 scale 和 RoundingMode。
40为什么 0.1 + 0.2 不等于 0.3?
IEEE 754 二进制浮点表示不了 0.1,只能近似。这不是 Java 的问题,JS 里一模一样。
41金额计算还有别的做法吗?
存整数分,用 long 运算,展示时再除以 100。高频计费场景比 BigDecimal 更快,但要自己管好精度。