从浏览器发出 HTTP 请求 → 到 Java 代码逐行执行 → 到数据返回浏览器,每一步都是谁在干活
🏢 进程 = 银行大楼:一座建筑,有大门、柜台、保险箱(内存)。操作系统分配资源的最小单位。
🧑💼 线程 = 柜员:楼里工作的人,每个人独立服务一个客户。CPU 调度的最小单位。
📋 一个客户 = 一个 HTTP 请求:走进银行,被分配给一个空闲柜员服务。
💾 大楼保险箱 = 堆内存(Heap):所有柜员共享的区域,存放贵重物品(对象)。
🧰 柜员自己的抽屉 = 栈(Stack):每个柜员私人区域,放自己的工作笔记(局部变量)。
1. 进程是"容器"——你的 `java -jar app.jar` 启动后,操作系统给它一块内存空间,这就是 JVM 进程。
2. 线程是"执行者"——CPU 实际运行的是线程。一个进程内有多个线程,它们共享堆内存,但各自有独立的栈。
3. 请求是"任务"——每个 HTTP 请求进来,Tomcat 从线程池取一个线程来处理它。处理完线程归还池子,等待下一个请求。
因为 99% 的并发 Bug 都来自这里:多个线程同时读+写堆内存中的同一个对象 → 数据不一致。而栈(局部变量)是线程私有的,天然安全。这就是 synchronized、volatile、ConcurrentHashMap 存在的根本原因。
Spring Boot 内嵌 Tomcat,默认线程池配置:
server.tomcat.threads.max=200(最多200个线程)
server.tomcat.threads.min-spare=10(最少保持10个就绪)
server.tomcat.accept-count=100(等待队列长度,超了返回503)
🏥 Tomcat = 医院。200个诊室(线程)。
🚶 你(请求)到了大厅,护士分诊台(Connector)给你分配一个空闲诊室(线程)。
⏳ 如果200个诊室全满,你在走廊排队(等待队列,accept-count=100)。
🚫 如果排队也满了 → 医院直接挂"今日满诊"(503 Service Unavailable)。
✅ 你看完病离开,诊室消毒(线程清栈)→ 归还线程池 → 等下一个病人。
在 Spring 启动时(不是请求来的时候)。Spring 扫描所有 @Controller / @RestController 类,读取 @RequestMapping / @GetMapping 等注解,构建一张 URL → Method 的路由表,存在 HandlerMapping 里。
请求来了就是查表——O(1) 级别的 Map 查找。如果查不到 → 返回 404。
找到的不是一个单纯的 Java 方法,而是一个 HandlerExecutionChain = 目标方法 + 这条路径上配置的所有拦截器(Interceptor)。所以拦截器在 Controller 方法执行之前就已经可以介入了。
🌟 你想找明星拍戏 → 你不是直接联系明星本人,而是先联系经纪人。
🤝 经纪人(代理)在真正转达给明星之前,会先做很多额外的事:审核合同、谈片酬、确认档期。
🎬 明星真正拍戏 → 这是你的业务逻辑执行。
📝 拍完回来 → 经纪人又做收尾工作:发通稿、结算费用。
代理对象 = 经纪人。真实对象 = 明星。@Before/@After = 经纪人的额外工作。你只管找经纪人,经纪人帮你处理所有附加事务。
| 代码行 | 栈上发生了什么 | 堆上发生了什么 |
|---|---|---|
| 方法入口 | 创建栈帧,存 dto 引用 | 无变化(DTO 对象已存在) |
| new Order() | order 局部变量入栈 | ★ 堆中分配一块内存给 Order 对象 |
| setUserId("U001") | setUserId 栈帧创建→弹出 | 堆中 Order.userId 被修改 |
| LocalDateTime.now() | now() 栈帧创建→弹出 | ★ 堆中又分配一个时间对象 |
| return order | create() 栈帧弹出 | Order 对象仍存在(被返回引用) |
因为每个线程有自己的栈。Thread-3 的 create() 栈帧里的 order 引用,和 Thread-4 的完全隔离。它们各自指向堆中不同的 Order 对象。所以局部变量永远不会出现并发冲突。
但如果两个线程的引用指向同一个堆对象(比如共享的单例 Service 里的成员变量)——并发问题就来了。
📦 你的 Java 对象 = 快递物品(各种属性的货物)。
📝 MyBatis = 快递员,负责打包:把对象属性填到运单上(SQL 模板的 #{} 位置)。
🚚 JDBC = 运输公司,通过公路(TCP连接)送到目的地(MySQL)。
🏪 MySQL = 收件仓库,拆包→入库(写表)→签收回执。
重要:数据库是另一个进程!Java 和 MySQL 之间的通信是网络请求。所以数据库连接很宝贵——用连接池复用,而不是每次新建。
线程池(Tomcat):200个线程,每个处理一个 HTTP 请求。
连接池(HikariCP):10个数据库连接(默认),被线程借用。
当 200 个线程同时查数据库,只有 10 个能拿到连接,其余 190 个排队等待。这就是为什么慢查询会拖垮整个应用——连接被长时间占用,后面的请求全部卡住。
Spring MVC 用 Jackson(HttpMessageConverter)把 Java 对象序列化成 JSON。这个过程是递归反射——读取对象所有 getter 方法,拼成 JSON 字符串。
如果你的对象有循环引用(A 引用 B,B 又引用 A)→ StackOverflowError。这就是为什么 @JsonIgnore / @JsonBackReference 存在。
响应写完后,Tomcat 的工作线程清空自己的栈(栈帧全部弹出),归还线程池。注意:线程对象本身不销毁,只是回到"空闲"状态等下一个请求。但堆中的对象不会立即回收——要等 GC 发现没人引用了才清理。
用户点击按钮,浏览器构建 HTTP 请求(DNS 解析→TCP 三次握手→发送数据)
耗时:10-50ms(网络延迟,取决于距离)
操作系统网卡接收数据 → TCP 协议栈重组 → 放入 8080 端口的 Socket 缓冲区
耗时:<1ms
NIO Selector 检测到数据可读 → 分配给 Thread-3 → 解析成 HttpServletRequest
耗时:1-2ms
查 HandlerMapping → 找到 OrderController.createOrder → 查到拦截器链
耗时:<1ms(Map 查找)
执行 Interceptor.preHandle() → 鉴权、日志、限流检查
耗时:1-5ms(取决于逻辑)
调用 orderService.create(dto) → 进入代理对象
耗时:<1ms
LogAspect 记录入参 → @Transactional 从连接池获取 Connection → setAutoCommit(false)
耗时:1-3ms(连接池获取连接)
new Order() → set 属性 → 各种业务校验 → 调用 Mapper
耗时:1-5ms
参数绑定 → JDBC execute → MySQL 接收 → 解析 → InnoDB 执行 → 返回结果
耗时:5-50ms(★ 通常是最大耗时点!索引差可能 100ms+)
commit → 连接归还连接池 → LogAspect 记录返回值
耗时:2-5ms
Jackson 把 Result 对象序列化成 JSON → 写入 Socket → 操作系统发回浏览器
耗时:1-5ms(大对象序列化慢)
浏览器收到 JSON → 前端框架(Vue/React)更新 DOM → 用户看到结果
耗时:10-30ms
Thread-3 栈帧弹出 → 线程归还 Tomcat 线程池 → 等待下一个请求
整个过程线程被占用约 75ms
📊 一个请求约 150ms,其中数据库占 50ms(33%)
这就是为什么SQL 优化是性能调优最有效的手段——它能砍掉最大的一块耗时。其次是减少网络往返(批量查询 vs 循环查询)。
200 个线程 × 每个请求 75ms = 理论最大 QPS ≈ 2666。如果慢查询把 75ms 变成 500ms → QPS 暴跌到 400。
启动时创建一次,之后永远复用。Spring 在应用启动时扫描 @Component/@Service/@Repository 等注解,对每个类 new 一个实例(默认单例),放进 IoC 容器。之后所有请求共用这一个实例。
所以 Controller 和 Service 是线程不安全的——200 个线程同时访问同一个 Service 实例。这也是为什么 Service 里不能有成员变量存请求数据(除非用 ThreadLocal)。正确做法:请求数据用方法参数传递(局部变量,在栈上,天然安全)。
Service 单例 = 银行柜员:整个银行只有这 1 个柜员(Spring 启动时就上岗了,不会每个顾客来都 new 一个柜员)。
成员变量 = 柜员的私人笔记本:如果柜员把顾客 A 的信息写在笔记本上,顾客 B 来了又写在同一页 → 💥 数据串了!
局部变量 / new 对象 = 顾客的纸条 + 表单:每个顾客自带纸条(方法参数),柜员给每张表单独填写(new Order()),填完这张表跟下一张完全无关。
200 个并发请求 = 200 个人排队:都找同一个柜员,但每人拿自己的纸条和表单,互不影响——前提是柜员没有用那个共享笔记本!
this.xxx = 在 Service 里存请求数据 → 🔴 危险(共享成员变量)。看到 new Xxx() 或方法参数 → 🟢 安全(每次新建/栈上隔离)。单例本身不是问题,单例 + 可变成员变量才是问题。
转发(Forward):服务器内部的事。请求到了 Controller A,A 说"这事归 B 管",直接在同一个线程内调用 B 的方法。浏览器完全不知道,URL 不变。整个过程是一次 HTTP 请求。
重定向(Redirect):服务器告诉浏览器"你去另一个地址"。浏览器收到 302 状态码 → 发起新的 HTTP 请求 → URL 变了。这是两次 HTTP 请求。
类比:转发=你去柜台 A,A 的柜员直接帮你拿到 B 柜台的东西。重定向=柜台 A 告诉你"请去 B 窗口重新排队"。
在 Controller 方法被调用之前,Spring 的 HandlerAdapter 做了参数解析:
@RequestParam → 从 URL query string 或 form-data 中取值 → 基本类型/String
@RequestBody → 从 HTTP body 中读取 JSON → Jackson 反序列化成 Java 对象(new 一个对象放堆里)→ 把引用传给方法参数
@PathVariable → 从 URL 路径模板中提取(如 /api/order/{id} → id=123)
这些参数都在栈上(如果是对象则栈上存引用,指向堆中的对象)。因为是局部变量,天然线程安全。
它们是独立的池,通过"借用"关联。
一个 Tomcat 线程(Thread-3)在执行到 `orderMapper.insert()` 时,从 HikariCP 连接池借一个 Connection。用完后归还。在线程等待数据库返回期间,这个线程是阻塞的(BLOCKED 状态),CPU 会去执行其他线程。
如果连接池只有 10 个连接,而 200 个线程同时查库 → 190 个线程排队等连接。这就是为什么 HikariCP.maximumPoolSize 的设置很关键——太小会卡,太大会压垮数据库。
当 Service 方法抛出 RuntimeException:
1. 异常从 Service 方法弹出 → 到达代理对象的 invoke 方法
2. 代理对象检测到有 @Transactional → catch 住异常 → 执行 connection.rollback()
3. 代理对象把异常重新抛出 → 到 Controller → 如果有 @ExceptionHandler 就捕获处理 → 返回错误 JSON
4. 如果没人处理 → DispatcherServlet → 最终返回 500
关键认知:事务回滚不是你的代码做的,是代理对象在 try-catch 中帮你做的。这也是为什么"类内部调用"不生效——内部调用走原始对象,没有代理拦截异常。
同步:Thread-3 从 Controller → Service → Mapper 一路执行,遇到 I/O(数据库、HTTP 调用)就阻塞等待,直到结果返回才继续。线程被"钉"在这里,什么都做不了。
异步(@Async):Thread-3 调用 @Async 方法时,代理对象把任务交给另一个线程池(AsyncTaskExecutor),立即返回一个 CompletableFuture。Thread-3 不等待,继续执行后续代码。
类比:同步=你自己去快递柜取件,走一趟。异步=你叫快递员帮你取,你继续干别的。
坑:@Async 同样需要通过代理调用才生效!类内部直接调 this.asyncMethod() 不走代理 → 不异步。
🎯 一句话总结
一个请求 = 一个线程在 一个进程内跑一段代码。它走过的每个环节——Tomcat 路由、AOP 代理、Service 逻辑、MyBatis 查库——都是这个线程在工作。理解了"谁在执行"和"数据在哪个内存区域",你就拥有了 Java 后端运行时的心智模型。
请求全链路三原则:① 一次请求经过 网卡→线程池→Filter→Controller→Service→DAO→DB 全链路 → ② 每一层都可能丢请求,超时和异常要逐层传递 → ③ ThreadLocal 和 MDC 是跨层传 traceId 的关键