JAVA 后端架构 · J01 Java 语言与集合 · 场景全景

一个订单对象的一生

用户 1024 下单买 2 件 88 号商品。一串 JSON 字节进了 Tomcat,被 Jackson 用反射变成一个 record,穿过 Spring 生成的代理,在 HashMap 里查到商品,new 出一个 Order,用 Stream 和模式匹配算好价,扣库存,放进集合和延迟队列,最后又被反射读成 JSON 字节写回去,却还留在堆里。这张全景回答:天天写的这几行 Java,一个对象从生到“死”经过哪 11 站,每站对应 J01 的哪个知识点。

所在:Java 地图 J01 · Java 语言与集合AI 主线用到:站 01整条链路在本机真跑过 实测

场景:交易靶场的第一单

谁
用户 1024,在交易靶场下单。靶场 J01 只是个骨架:一个 Spring Boot 单体,商品、库存、订单三张表。
买什么
2 件 88 号商品(机械键盘,单价 299.00),用一张满减券 MINUS20。88 号库存 50 件。
做了什么
客户端 POST /orders,请求体 65 字节的 JSON。
期望
拿到订单 9527,应付 578.00;库存 50 → 48。库存不够时返回 409,库存一件不少。

实测 HTTP 200,订单 9527,应付 578.00,库存剩 48。进程刚启动后的第一单服务端用了 151 ms;预热 200 单后,同样一单只要 2.6 ms。买 100 件时返回 409,事务回滚。

实测环境:本机 2 核 ARM,JDK 21.0.12,Spring Boot 3.5.16(内嵌 Tomcat、Jackson 2),数据库用内存里的 H2 代替 MySQL。代码里在每一站打了点,记下这一站看到的类名、哈希桶下标和距请求进来的微秒数。

进来的和出去的字节

左边的号是这一部分在第几站起作用。

1POST /orders                      ← 65 字节2{"userId":1024,4 "items":[{"skuId":88,"qty":2}],6 "coupon":"MINUS20"} 11HTTP 200                          → 172 字节5{"id":9527,"userId":1024,8 "lines":[{"skuId":88,"name":"机械键盘",8           "price":299.00,"qty":2}],6 "total":578.00,9 "status":"CREATED",10 "createdAt":"2026-11-09T03:54:25.882313221Z"} 10// Order 还有个 internalNote 字段,带 @JsonIgnore,没发出去

完整代码在 study-labs:java/java/pano-j01-order-object-life →

全景图:十一站

JVM 进程 · OrderApp(JDK 21 · Spring Boot 3.5 · Tomcat) @Transactional 代理里:同一个事务 客户端 curl 用户 1024 2 件 88 号 ① 收字节 Tomcat NIO 读出 交给工作线程 ② 反序列化 Jackson 反射找 record 构造器 ③ 进代理 CGLIB 子类 开事务、借连接 ④ 查商品 HashMap.get(88L) 桶下标 88 ⑤ new Order 实体类 + List<T> 泛型运行时擦掉 ⑥ 算价 Stream 求和 598 模式匹配减 20 ⑦ 扣库存 条件 UPDATE 不够就抛异常 ⑧ 放进集合 ArrayList 装订单行 HashMap 按用户存 ⑨ 延迟队列 DelayQueue 二叉堆 30 分钟后取消 ⑩ 序列化 Jackson 反射读 getter 和注解 ⑪ 写回、留下 172 字节回网络 集合还拉着它 POST65 字节 字节流 PlaceOrderCmd skuId 88 Product 299 Order 9527 应付 578.00 库存剩 48 Order 引用 提交事务 JSON 字节 ← 200 请求结束了,它还活着:5200 单后堆里 5202 个 Order 不够 → 回滚,409
IO 与 NIO 异常、反射、注解与动态代理 集合、HashMap、数据结构 面向对象与泛型 JDK 新特性 出事的地方
上排把请求变成一个算好价的订单,下排把它存起来、再变回字节。虚线框是边界:外框是 JVM 进程,里框是 Spring 代理包住的那一个事务。颜色表示这一站归 J01 的哪个知识点。点任意一站,跳到下面的分步讲解。
预热后的一单:服务端 2.6 ms(条形,微秒) 刚启动的第一单:151 ms ① 收字节250.07 ms ② 读请求体 + 反序列化87667.0 ms ③ 进代理、开事务1326.0 ms ④ HashMap 查商品62(含反射探查)6.8 ms ⑤ new Order65(含反射探查)3.2 ms ⑥ Stream 算价8010.4 ms ⑦ 扣库存、INSERT51716.9 ms ⑧ 放进集合423.3 ms ⑨ 进延迟队列244.6 ms ③ 代理提交事务1392.3 ms ⑩ 返回值交给 Jackson18219.1 ms ⑩⑪ 序列化、写进 Tomcat46211.6 ms 0 200 400 600 800 1000 µs 同一段,第一单
实测 各跑一次,计时包含打点本身的开销(第 4、5 站还用反射探查了 HashMap 内部和泛型签名),只看量级。预热后,最贵的是第 2 站解析 JSON 和第 7 站访问数据库,Java 代码本身(第 4、5、6、8、9 站)加起来不到 0.3 ms。第一单慢 58 倍,大头在第 2 站:要加载类,Jackson 还要用反射把 PlaceOrderCmd 分析一遍、建好反序列化器,之后缓存起来复用。所以“反射慢”主要慢在第一次。

一步一步走

1Tomcat:收下 65 个字节

Tomcat 的 NIO 线程发现连接上有数据可读,从 SocketChannel 把字节读进 ByteBuffer,再交给工作线程。业务代码拿到的是包装好的输入流。

实测 Tomcat 线程 http-nio-18080-exec-8 收到 POST /orders,请求体 65 字节,输入流 CoyoteInputStream

为什么要它网络上来的只有字节。少量线程盯住大量连接(NIO),读好了再交给工作线程,这样不必一个连接占一个线程干等。

往下一层IO 与 NIO 基础:流、Buffer、Channel · 上一圈:网络 IO 模型

常出的事自己读流忘了关,或者读文件不带缓冲,一次读一个字节;请求体很大时整个读进内存。

2Jackson:字节变成 record

Spring MVC 看到参数上有 @RequestBody,把输入流交给 Jackson。Jackson 用反射查到 PlaceOrderCmd 是个 record,按组件名对上 JSON 的键,调它的构造器 new 出对象;items 是 List<Item>,它从字段的泛型签名里知道元素该 new 成 Item。

实测 PlaceOrderCmd[userId=1024, items=[Item[skuId=88, qty=2]], coupon=MINUS20],isRecord=true

为什么要它业务代码想要的是有类型的对象,不是字符串。反射让 Jackson 不用为每个类手写解析代码。

往下一层异常、反射、注解与动态代理 · JDK 8 到 25 的新特性(record) · 补给:反射、注解与 SPI 待做 · 已有:Spring MVC 请求处理流程

常出的事金额用 double 接,0.1 + 0.2 不等于 0.3;JSON 多了一个字段,默认配置下直接报错(Spring Boot 默认关掉了这个检查,自己 new 的 ObjectMapper 没关)。

3Spring 代理:开事务

控制器里注入的 orderService 不是你写的那个类,而是 Spring 启动时用 CGLIB 生成的子类。调用先进子类,子类开事务、从连接池借连接,再调真正的 place();方法正常返回就提交,抛出运行时异常就回滚。

实测 注入的 orderService 实际是 OrderService$$SpringCGLIB$$0;进到方法里,this 已经是 OrderService,事务已开启:true。

为什么要它开事务、提交、回滚这些重复的事,不想写进每个业务方法。代理在方法前后插进去,业务代码一行不改。

往下一层异常、反射、注解与动态代理 · 已有:Spring AOP 代理原理、Spring AOP 与事务

常出的事同一个类里 this.place() 自己调自己,没经过代理,事务不生效;private 或 final 方法,子类覆盖不了,也不生效。

4HashMap:查到 88 号商品

商品启动时缓存在一个 HashMap<Long, Product> 里。get(88L):先算 hashCode(),高 16 位异或到低 16 位(扰动),再和“桶数组长度 − 1”按位与,得到下标,去那个桶里用 equals 比对。

实测 hashCode=88,扰动后 88,100 个商品,桶数组长 256,下标 88,这个桶里 1 个节点(Node)

为什么要它按 id 找东西,数组下标一步到位,比挨个比对快得多。长度总是 2 的幂,取模就能换成一次按位与。

往下一层HashMap:哈希、扩容、树化 · 补给:HashMap 与 ConcurrentHashMap 源码 待做

常出的事拿可变对象当 key,放进去以后改了字段,hashCode 变了,再也取不出来;多线程同时写 HashMap,数据丢失(第 8 站就埋着这个雷)。

5new Order:实体类和泛型

new Order(9527, 1024, lines, …)。Order 有身份、状态会从“已创建”变成“已取消”,所以写成普通类,字段私有、只开放需要的方法;订单行、商品、下单命令不会变,写成 record。lines 是 List<OrderLine>,泛型在编译时检查,运行时被擦掉。

实测 ArrayList<OrderLine>.class == ArrayList<String>.class:true;但字段签名仍是 java.util.List<OrderLine>

为什么要它把数据和改它的规则放在一起,外面只能按规则改。擦除让泛型代码和 JDK 5 之前的老代码共用同一份字节码。

往下一层面向对象、接口与抽象类、泛型和类型擦除

常出的事getter、setter 全开,谁都能把状态改乱;以为运行时能拿到 T,写出 new T() 或 instanceof List<String>,编译都过不了。

6算价:Stream 和模式匹配

订单行用 Stream 求和:lines.stream().map(OrderLine::amount).reduce(ZERO, BigDecimal::add),得 598.00。优惠是一个 sealed 接口,只有“不减”“满减”“打折”三种实现;switch (d) { case Minus(BigDecimal off) -> … } 直接拆出减多少,三种都写到了,编译器才放行。

实测 Stream 求和 598.00,优惠 Minus[off=20] → 应付 578.00

为什么要它写“做什么”而不是“怎么循环”;新加一种优惠忘了处理,编译期就报错,不用等线上算错价。

往下一层面向对象、接口与抽象类(sealed、record) · JDK 8 到 25 的新特性:Lambda、Stream、record、模式匹配 · 上一圈:JDK 8、17、21、25

常出的事金额用 double 累加;new BigDecimal(0.1) 拿到的是 0.1000000000000000055…;随手 parallelStream(),用的是全局公共线程池。

7扣库存:不够就抛异常

执行 UPDATE stock SET qty = qty - 2 WHERE sku_id = 88 AND qty >= 2,影响 1 行,库存 50 → 48,再 INSERT 订单。影响 0 行说明库存不够,抛出 OutOfStockException(继承 RuntimeException),它一路穿出代理,事务回滚,@ExceptionHandler 把它转成 409。

实测 买 100 件:UPDATE stock 影响 0 行 → throw OutOfStockException → 异常穿过代理,事务已回滚;转成 409

为什么要它异常让“出错了”沿调用栈一层层往上走,不用每层都检查返回值;代理在半路接住它,决定回滚。

往下一层异常、反射、注解与动态代理 · 已有:Spring AOP 与事务(事务什么时候失效)

常出的事异常继承 Exception(受检),@Transactional 默认不回滚;catch 住只打一行日志就吞掉,钱扣了、订单没建。

8放进集合

订单行早就放在一个 ArrayList 里(创建时就指定了容量)。订单再放进 userOrders:computeIfAbsent(1024, k -> new ArrayList<>()).add(order),按用户找订单。

实测 lines 是 ArrayList,userOrders.get(1024) 现在有 1 单,userOrders 共 1 个用户

为什么要它按下标读多、尾部追加多,用 ArrayList;按 key 找,用 HashMap。选错了,复杂度差一个量级。

往下一层常用集合怎么选:ArrayList、LinkedList、Set、Queue · HashMap:哈希、扩容、树化(并发写为什么丢数据)

常出的事for-each 里 remove,抛 ConcurrentModificationException;userOrders 是普通 HashMap,多个 Tomcat 线程同时写会丢数据,而且只放不删,越来越大(J02、J03 的雷)。

9延迟队列:30 分钟不付就取消

放一个 CancelTask 进 DelayQueue。它内部是一个按到期时间排序的二叉堆:堆顶永远是最早到期的那个,放入和取出都是 O(log n)。一个后台线程 take() 堆顶,没到期就睡到它到期。

实测 202 单后:DelayQueue 里 202 个任务,堆顶订单 9527,还剩 1797 秒

为什么要它几十万个订单各有各的截止时间,每次只关心最早的那个;排序太贵,堆刚好只维护“最小的在顶上”。

往下一层数据结构与算法:日常和面试够用的那部分 · 常用集合怎么选(Queue 和二叉堆)

常出的事队列在内存里,进程重启全丢了,订单永远不会被取消。生产上要么定时扫库,要么用消息队列的延迟消息(J08)。

10Jackson:对象变回字节

place() 返回,代理提交事务。Spring MVC 把返回的 Order 交给 Jackson,Jackson 用反射找出它所有的 getter,读注解:@JsonIgnore 的 internalNote 跳过,@JsonFormat 的 createdAt 写成字符串,最后得到 172 字节的 JSON。

实测 交给 MappingJackson2HttpMessageConverter 序列化 Order

为什么要它对象只活在这个 JVM 里,要送出去,就得变成大家都认的格式。注解让你声明“哪些字段、什么格式”,不用手写拼接代码。

往下一层异常、反射、注解与动态代理 · IO 与 NIO 基础(编码成字节)

常出的事直接把带懒加载关联的实体返回出去,序列化时触发一串查询,甚至双向引用无限递归;用 Java 原生序列化(Serializable)跨服务传对象,又慢又有反序列化漏洞。

11写回网络,对象却留在堆里

172 个字节写进 Tomcat 的缓冲区,再写回连接,客户端收到 200。请求结束,PlaceOrderCmd 没人引用了,下次 GC 就收走;但 Order 还被第 8 站的 userOrders 和第 9 站的 DelayQueue 拉着,收不走。

实测 连发 5200 单后用 jcmd GC.class_histogram(会先做一次完整 GC)数存活对象:Order 5202 个(每个 48 字节)、OrderLine 5202 个、CancelTask 5202 个,PlaceOrderCmd 一个都不剩。

为什么要它Java 不用手动释放:一个对象从 GC Roots 出发够不着了,才算死。反过来,只要还有一条引用链,它就一直活着。

往下一层IO 与 NIO 基础 · 下一站:JVM 内存结构(J03)

常出的事只放不删的集合当缓存:一天几十万单,老年代慢慢涨满,频繁 Full GC,最后 OOM。这正是 J03 要往靶场里埋的第一个雷。

知识点地图

知识点名照抄学习地图。下钻按依赖排:被别人用到的先学。三个已有页面的知识点,这一次是“下一圈”:带着这张全景回去重学,补上地图要的深度。

站知识点状态下钻顺序和理由
5、6面向对象、接口与抽象类、泛型和类型擦除 懂机制有页1地基:集合是一堆接口和泛型类,反射读的也是类和泛型签名
8、9常用集合怎么选:ArrayList、LinkedList、Set、Queue 懂机制有页2先看清整个集合家族,再钻进最重要的 HashMap
4、8HashMap:哈希、扩容、树化 懂设计有页3这一站的过关题,也是 J02 ConcurrentHashMap 的前置
2、5、6JDK 8 到 25 的新特性:Lambda、Stream、record、模式匹配 懂机制有页(版本:上一圈;机制:本圈)4Stream 建在集合上;record、sealed 接着第 1 个的面向对象讲
2、3、7、10异常、反射、注解与动态代理 懂机制有页(Spring 层:上一圈;JDK 层:本圈)5要先懂类、接口、泛型签名和 record,反射才看得明白;它是 J05 Spring 的根
1、11IO 与 NIO 基础:流、Buffer、Channel 会用有页(连接与线程:上一圈;API 与字节:本圈)6只要求会用;J04 网络那一站还会往下讲
4、9数据结构与算法:日常和面试够用的那部分 会用有页7放最后,作为长期练习:哈希表和堆在前面已经见过实物

这张全景没走到的

  • J01 的 7 个知识点都在图上。地图里没列、但离这条链路很近的几件事,放到后面的站:
  • ConcurrentHashMap 和线程安全:第 8 站的雷,J02 并发编程用“1000 人抢最后 10 件”来讲。
  • 对象在堆里长什么样、什么时候被回收:第 11 站只讲到“还有引用就活着”,对象头、分代、GC 是 J03。
  • Java 原生序列化(Serializable):这条链路用 JSON,不用它;第 10 站的“常出的事”提了一句为什么别用。
  • 类加载和 SPI:第一单慢 58 倍的一部分原因是类加载,放在补给“反射、注解与 SPI”和 J03 里讲。

补给站

  • 第 4 站:HashMap 与 ConcurrentHashMap 源码 待做。学完 HashMap 知识点、准备进 J02 之前看。
  • 第 2、10 站:反射、注解与 SPI 待做。Jackson 和 Spring 是怎么“自动”认出你的类的。
  • 第 2、3 站:Spring MVC 请求处理流程、连接池与事务性能,这条链路在框架里的部分。

回到全景

复述练习

  1. 合上页面,画出 11 站,标出哪几站在代理的事务里。
  2. 每站用一句话讲它做了什么,说出流过箭头的是什么:字节、PlaceOrderCmd、skuId、Product、Order、应付金额、引用、JSON 字节。
  3. 挑 3 站往下讲两层。比如第 4 站:为什么要把高 16 位异或下来?桶数组为什么总是 2 的幂?扩容时一个桶里的节点怎么分家?

也可以直接讲给我听,说“回到全景”,我对照这张图帮你查漏。

动手:交易靶场这一站

地图上 J01 给靶场的任务是“搭骨架”。实测程序就是骨架的起点,在它上面补:

  1. 商品、库存、订单三张表接到真 MySQL 8.4,把 H2 换掉。
  2. 加“查用户最近 10 单”接口:数据用 Stream 分组、排序、截取,对比你会怎么用 for 循环写。
  3. 过关两题对着代码讲:HashMap put 的全过程和扩容为什么是 2 倍;你项目里真会用的 5 个 JDK 新特性。

预测题

1. 商品 id 是 344,它和 88 号在不在同一个桶?商品从 100 个加到 200 个以后呢?

现在在。100 个商品时桶数组长 256(实测),344 = 256 + 88,按位与 255 以后两个下标都是 88,挤在一个桶里成了链表。加到 200 个时,超过 256 × 0.75 = 192,扩容到 512:344 & 511 = 344,88 还是 88,两个分开了。扩容时只看多出来的那一位(hash & 256)是 0 还是 1,决定留在原位还是挪到“原位 + 256”。

从第 4 站推出:下标 = 扰动后的 hash & (长度 − 1),长度是 2 的幂。

2. 下单高峰,两个 Tomcat 线程同时给两个新用户下单,userOrders 会怎样?

两个线程都在第 8 站往同一个 HashMap 里放新 key。可能互相覆盖,丢掉一个用户的订单列表;碰巧赶上扩容,丢得更多。JDK 7 扩容时还可能把链表弄成环,get 死循环把 CPU 打满;JDK 8 改成尾插,不再成环,但照样丢数据。修法是换 ConcurrentHashMap,J02 讲它为什么能做到。

从第 1 站(Tomcat 用线程池,请求是并发的)和第 8 站推出。

3. 把 OutOfStockException 改成继承 Exception,一单买两种商品,第二种库存不够,会怎样?

第一种商品的库存已经扣了。异常穿过代理时,@Transactional 默认只对 RuntimeException 和 Error 回滚,受检异常照样提交:第一种商品的库存就这么少了,订单却没建成。修法是写 rollbackFor = Exception.class,或者用运行时异常。

从第 3、7 站推出:回不回滚,是代理按异常类型决定的。

4. 一天 50 万单,进程不重启,哪个集合先出问题?用户会看到什么?

userOrders。DelayQueue 里的任务 30 分钟后就被取走,最多攒半小时的量;userOrders 只放不删,Order、OrderLine、ArrayList、BigDecimal 一起越攒越多,晋升到老年代。先是 Full GC 越来越频繁,接口时快时慢;最后 OOM,所有接口报 500。

从第 8、9、11 站推出:对象的生死看还有没有引用链,和请求结没结束无关。

5. 把 Order 也改成 record,会碰到什么问题?

record 的字段都是 final,第 9 站超时取消要把状态改成“已取消”,只能 new 一个新的 Order 替换掉旧的,而集合和队列里拉着的还是旧对象。另外 record 的 equals 和 hashCode 用全部字段算,状态一变,“同一个订单”就不相等了。所以有身份、会变的实体用类,不会变的值用 record。

从第 5、8、9 站推出。

下一圈