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 →
全景图:十一站
一步一步走
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、8 | HashMap:哈希、扩容、树化 懂设计 | 有页 | 3这一站的过关题,也是 J02 ConcurrentHashMap 的前置 |
| 2、5、6 | JDK 8 到 25 的新特性:Lambda、Stream、record、模式匹配 懂机制 | 有页(版本:上一圈;机制:本圈) | 4Stream 建在集合上;record、sealed 接着第 1 个的面向对象讲 |
| 2、3、7、10 | 异常、反射、注解与动态代理 懂机制 | 有页(Spring 层:上一圈;JDK 层:本圈) | 5要先懂类、接口、泛型签名和 record,反射才看得明白;它是 J05 Spring 的根 |
| 1、11 | IO 与 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 请求处理流程、连接池与事务性能,这条链路在框架里的部分。
回到全景
复述练习
- 合上页面,画出 11 站,标出哪几站在代理的事务里。
- 每站用一句话讲它做了什么,说出流过箭头的是什么:字节、PlaceOrderCmd、skuId、Product、Order、应付金额、引用、JSON 字节。
- 挑 3 站往下讲两层。比如第 4 站:为什么要把高 16 位异或下来?桶数组为什么总是 2 的幂?扩容时一个桶里的节点怎么分家?
也可以直接讲给我听,说“回到全景”,我对照这张图帮你查漏。
动手:交易靶场这一站
地图上 J01 给靶场的任务是“搭骨架”。实测程序就是骨架的起点,在它上面补:
- 商品、库存、订单三张表接到真 MySQL 8.4,把 H2 换掉。
- 加“查用户最近 10 单”接口:数据用 Stream 分组、排序、截取,对比你会怎么用 for 循环写。
- 过关两题对着代码讲: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 站推出。
下一圈
同一秒,1000 个人抢 100 件 88 号
第 7 站的条件更新还靠得住吗?第 8 站的 HashMap 会丢多少数据?换成 synchronized、CAS、ConcurrentHashMap 各差多少?这是 J02 并发编程的全景:一个下单请求在线程池里 →
这个订单在堆里占多少字节,什么时候被收走
对象头、字段对齐、年轻代到老年代,以及第 11 站那个只放不删的 Map 怎么用 jmap、MAT 揪出来。这是 J03 JVM 的全景。