2015 年 9 月,云盘服务端从 Node.js 整体转向 Java,背景在之前的云盘服务端从nodejs 专项 java 相关复盘里写过。此前我做过 Node.js 和 C# 的后端开发,知道 Java 项目的请求处理会涉及多线程、共享对象和线程池。这些问题如果等到线上出现再补,很难判断范围,因此在迁移前后先集中学习 Java 并发基础,并把结论用到服务端代码检查中。
这个「云盘 Java 服务端修炼笔记」系列记录当时的学习和改造。本文先说明 Node.js 事件循环与 Java 请求线程的差异,再说明成员变量、原子操作和可见性的基本限制。下一篇继续讨论线程池的参数、队列与快慢接口隔离。
系列目录
- 从单线程到线程池:云盘转 Java 后的第一堂并发课(本篇)
- 线程池参数、队列与快慢接口隔离
迁移前需要确认的并发问题
Java 版第一个 beta 是 2015 年 9 月上线的。服务端只有我一个人维护,还兼顾 web 端。Node.js 中常见的模块变量写法,放到 Spring 单例 Bean 中可能被多个请求线程同时访问。为避免把原有习惯直接带入 Java,我先按共享状态排查 service 代码,并补了线程模型、JMM 和并发工具的基础知识。
之后一次 code review 验证了这项排查的必要性。组里有 Java 经验的同学指出:Spring 的 bean 默认是单例,成员变量会被所有请求线程同时读写,必须确认访问方式。代码中有几类需要处理的状态:
- 一个 service 用成员变量暂存请求的中间状态,例如一个 List。方法开头
clear(),中间往里塞,结尾读出来组装返回值; SimpleDateFormat图省事做成了成员变量,整个类共用;- 还有一个
int成员变量做简单的调用计数。
处理方式先从边界明确的地方开始:请求中间状态改为方法参数和局部变量,SimpleDateFormat 改成每次使用时创建。这个改动解决了已发现的问题,但还需要回答两个问题:Node.js 中共享模块变量为什么较少出现线程竞争,Java 中哪些状态需要额外同步。
Node.js 的模块通常由同一进程中的一个 require 加载。中间状态、配置和连接句柄可以放在模块变量上,所有请求都能访问。Java 的 Spring 单例 Bean 在外观上与这种模块相似,但两者的请求执行方式不同。后面的排查和改造都以这个差异为前提。
Node.js 与 Java 的请求执行方式
在单个 Node.js 进程中,JavaScript 回调通常由事件循环线程依次执行。 事件循环从队列中取一个回调,执行完后再取下一个。在一段同步 JavaScript 执行结束前,另一个事件循环回调不会插入执行。因此,模块级变量虽然由请求共享,只要访问不跨越异步边界,业务代码不会发生两个回调同时读写的线程竞争。这个结论不适用于多进程部署、worker_threads,也不能避免异步操作前后交错导致的状态问题。
同一进程的 JavaScript 回调由一个线程执行。回调中运行 CPU 密集逻辑时,后续回调需要等待。2015 复盘里提到「node 版本 CPU 经常不正常波动、后来拆了一些 C++ 模块来处理」,与这个限制有关。
在当时使用的 Jetty 9 同步请求处理方式下,请求会占用线程池(QueuedThreadPool)中的工作线程。 线程会执行请求解析以及 Spring 的 controller、service、dao 调用,直到处理完成并返回响应。多个请求可以在多个线程上并发执行。线程池的线程数量有上限,请求多于可用线程时会等待。Servlet 异步处理或非阻塞处理可以在等待期间释放工作线程,但这不是当时这段同步代码的处理方式。
Node.js 的「单线程」指业务 JavaScript 在一个事件循环线程上执行。libuv 会用线程池处理部分异步操作,但这些线程不会并行执行 JavaScript 回调。Java 服务端需要明确代码运行的线程,并检查哪些共享状态可能被其他线程同时访问。
模型变化后,代码检查集中在三点:
- 共享变量需要按并发访问方式处理。
@Controller、@Service、@Repository等 Spring Bean 默认是单例,单例对象的成员变量会被请求线程共享,static变量也是如此。方法局部变量通常由各线程独立持有,只要引用对象没有逃逸到共享位置,就不会因多线程直接发生竞争。因此,服务端组件应尽量不保存请求相关状态。 - 阻塞调用会占用当前工作线程。 Node.js 的事件循环回调中同步等待结果会阻塞后续回调。Java 中线程等待 JDBC 返回是常见做法,其他工作线程仍可处理请求;但线程池耗尽时,后续请求仍会等待。
- CPU 密集任务可以使用多个线程利用多核。 任务的耗时和线程池容量仍会影响请求处理,不能直接放入请求线程后忽略其资源占用。
两个线程同时更新一个变量
「线程不安全」需要落实到具体操作。下面的最小例子可以在 JDK 7/8 运行:
public class CounterDemo {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[8];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
count++;
}
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("期望 80000,实际 " + count);
}
}多跑几次,结果通常小于 80000,而且每次可能不同。count++ 包含读旧值、加一、写回三个步骤。两个线程可能读到同一个旧值,各自加一后写回相同结果,一次更新因此丢失。这是原子性问题。
在单个 Node.js 事件循环线程内,不会有两个 JavaScript 回调同时执行 count++。异步流程仍可能在前后两个阶段交错,因此模块变量的状态管理依然需要谨慎。Java 中这段代码不会报错,却可能计算错误,并发问题也可能只在特定时序下出现。
阅读《Java并发编程实战》前几章后,我将 Java 内存模型(JMM)先理解为三个需要检查的条件:
- 可见性:如果两个线程之间没有 JMM 定义的 happens-before 关系,一个线程对共享变量的写入,另一个线程的读取可能看到旧值。缓存和编译器、处理器优化都可能影响实际表现,但 JMM 不要求用某种具体存储结构解释它。
- 原子性:上面的
count++就是一例。synchronized可以让受同一把锁保护的临界区互斥执行;AtomicInteger可以为单个变量提供原子读改写操作。 - 指令重排:编译器和 CPU 可以在不改变单线程语义的前提下调整操作顺序。缺少正确同步时,对象引用可能在对象状态对其他线程可见前被发布。
修复方式取决于共享状态的用途。计数可用 AtomicLong;需要保护一段逻辑时可用 synchronized,同时要确定锁保护的状态范围。修复后的计数器长这样:
@RestController
public class StatsController {
// 单例 Bean 的成员变量可能由多个请求线程共享,需要使用线程安全的类型
private final AtomicLong hits = new AtomicLong();
@RequestMapping("/stats/hit")
public long hit() {
return hits.incrementAndGet();
}
}JMM 的 happens-before 规则,例如哪些操作之间保证可见性,以及同一 volatile 变量的写入为何 happens-before 后续读取,当时还没有完全理解。volatile 的适用范围先明确为:它保证可见性和一定的有序性,但不保证复合操作的原子性。 用 volatile int 做 count++ 仍会丢失更新。锁和并发容器的实现细节留到系列第 4 篇继续讨论。
将检查结果用于云盘代码
理解这些限制后,我回到云盘做了一次全局排查,清单大致是:
- 把所有
@Controller、@Service、@Component的成员变量过一遍,逐个确认这个变量是否会被多个请求线程同时访问。 - 将可无状态化的组件改为无状态。 暂存中间状态的成员变量,改成方法参数和局部变量传递。
- 为必须共享的状态选择并发实现。 计数换
AtomicLong;需要共享的 Map 换ConcurrentHashMap。多个 Map 操作组成的逻辑仍可能需要额外同步。 - 检查已知非线程安全的工具类。
SimpleDateFormat当时改成每次使用时创建;ThreadLocal的写法是后来才知道的。 - 保留一条检查规则:写成员变量或静态变量前,先确认它是否会被多个线程访问;无法确认时,不把请求状态存进去。
排查出的几处隐患,大多数在请求量较小时没有触发。SimpleDateFormat 的问题后来用 demo 复现过:并发使用时可能抛出解析异常,也可能得到错误日期,因此提前改掉了。
这次排查花了一个周末,重点检查了几十个类。它让我在看到成员变量时先判断其并发访问方式。Node.js 单事件循环的代码中,这个问题通常不突出;Java 服务端需要在设计和 review 阶段主动判断。
后续要继续验证的问题
从 Node.js 转 Java 时,需要先调整对共享状态和并发执行的判断。Java 语法和 Spring 用法之外,还要理解请求线程如何访问同一对象。JMM 的 happens-before 细则,以及锁在 JVM 层面的实现,当时只掌握了概念。系列第 2 篇讨论线程池参数、队列和快慢接口隔离;第 4 篇再讨论并发容器与锁。
参考资料
- 《Java并发编程实战》(Brian Goetz)前 3 章
- Jetty 9 官方文档(线程模型部分)
- 云盘服务端从nodejs 专项 java 相关复盘

