11. Spring Boot 单例 Bean 为什么还能处理并发请求
发布于 • 阅读量 1
前面讲对象锁的时候,已经提到一个点:Spring Boot 里的 @Service 默认是单例。
这个地方很容易产生一个疑问:
既然 Service 是单例,那多个请求同时进来,不都在调用同一个对象吗?这样会不会线程不安全?
这个问题我之前也想过。后来发现,关键不在于“是不是单例”,而在于这个单例对象里面有没有共享的可变状态。
也就是说:
单例 Bean 本身不是问题;
单例 Bean 里乱放可变成员变量,才是问题。
这一节就把这个问题捋一下。
Spring Boot 里的 Service 默认是单例
比如我有一个普通的 Service:
@Service
public class PdfWatermarkService {
public void addWatermark(FileItemContext fileItemContext) {
// 添加 PDF 水印
}
}
在 Spring Boot 里,这个 PdfWatermarkService 默认只会创建一个对象。
也就是说,整个应用运行期间,Spring 容器里通常只有一个 PdfWatermarkService 实例。
Controller 里注入它:
@RestController
public class PdfController {
@Autowired
private PdfWatermarkService pdfWatermarkService;
@PostMapping("/pdf/watermark")
public String watermark() {
// 调用 Service
return "ok";
}
}
多个请求进来,调用的是同一个 pdfWatermarkService 对象。
但这并不代表请求只能一个一个处理。
多个请求不是同一个线程
Spring Boot 接收 HTTP 请求时,底层一般是由 Tomcat 的线程池来处理的。
比如同时来了三个请求:
请求1:处理 a.pdf
请求2:处理 b.pdf
请求3:处理 c.pdf
它们可能分别由不同的请求线程处理:
http-nio-8080-exec-1
http-nio-8080-exec-2
http-nio-8080-exec-3
这些线程都会调用同一个 PdfWatermarkService 对象的方法。
也就是说,实际情况更像这样:
同一个 Service 对象
↑
被多个请求线程同时调用
所以问题不是“单例能不能被多个线程调用”。
答案是:可以。
真正要看的是:这些线程调用方法时,会不会互相改同一份数据。
方法里的局部变量一般是安全的
比如下面这种写法:
@Service
public class PdfWatermarkService {
public void addWatermark(FileItemContext fileItemContext) {
String sourcePath = fileItemContext.getSourcePath();
String targetPath = fileItemContext.getTargetPath();
System.out.println("源文件:" + sourcePath);
System.out.println("目标文件:" + targetPath);
// 后面执行 PDF 处理逻辑
}
}
sourcePath 和 targetPath 是方法内部的局部变量。
每个请求线程调用 addWatermark() 时,都会有自己的方法栈帧。
所以请求1、请求2、请求3 虽然调用的是同一个 Service 对象,但它们方法里的局部变量不是同一份。
可以简单理解成:
请求1 有自己的 sourcePath;
请求2 有自己的 sourcePath;
请求3 也有自己的 sourcePath。
它们不会互相覆盖。
所以这种写法一般没问题。
真正危险的是成员变量
如果我把请求相关的数据放到成员变量里,就不一样了。
比如这样写:
@Service
public class PdfWatermarkService {
private String sourcePath;
private String targetPath;
public void addWatermark(FileItemContext fileItemContext) {
this.sourcePath = fileItemContext.getSourcePath();
this.targetPath = fileItemContext.getTargetPath();
doWatermark();
}
private void doWatermark() {
System.out.println("处理文件:" + sourcePath + " -> " + targetPath);
// 根据 sourcePath 和 targetPath 处理 PDF
}
}
这个写法在单线程下可能看不出问题。
但是在 Spring Boot 里,PdfWatermarkService 是单例,sourcePath 和 targetPath 属于这个单例对象。
多个请求线程访问的是同一份成员变量。
可能会出现这种情况:
线程1:设置 sourcePath = a.pdf
线程1:准备继续处理
线程2:设置 sourcePath = b.pdf
线程2:设置 targetPath = b-watermark.pdf
线程1:继续处理时,发现 sourcePath 已经变成 b.pdf
这样就乱了。
线程1 本来应该处理 a.pdf,结果用到了线程2 写进去的数据。
这种问题很隐蔽,因为代码不一定每次都出错。并发量低的时候可能看起来正常,并发一高就开始出现奇怪问题。
单例 Bean 不是不能有成员变量
这里也不能理解成:Spring 单例 Bean 里绝对不能写成员变量。
不是这个意思。
成员变量分情况。
像下面这种依赖对象,一般没问题:
@Service
public class PdfWatermarkService {
private final PdfConfig pdfConfig;
public PdfWatermarkService(PdfConfig pdfConfig) {
this.pdfConfig = pdfConfig;
}
}
这种配置对象如果是只读的,或者不会在请求处理中被频繁修改,通常问题不大。
还有这种工具类依赖也没问题:
@Service
public class PdfWatermarkService {
private final FileStorageService fileStorageService;
private final PdfTaskRepository pdfTaskRepository;
public PdfWatermarkService(FileStorageService fileStorageService,
PdfTaskRepository pdfTaskRepository) {
this.fileStorageService = fileStorageService;
this.pdfTaskRepository = pdfTaskRepository;
}
}
这些成员变量是依赖对象,不是每个请求的临时状态。
真正要避免的是把请求级别的数据放成成员变量,比如:
private String currentFileName;
private String currentUserId;
private FileItemContext currentFileItem;
private int successCount;
这些数据如果会被多个请求同时修改,就要小心。
我现在的判断方式
我会把 Service 里的成员变量大概分成两类。
一类是比较安全的:
依赖对象;
只读配置;
线程安全工具类;
不随请求变化的数据。
另一类是要谨慎的:
当前处理文件;
当前用户;
当前任务状态;
临时统计数量;
请求过程中的中间结果。
如果某个变量的值会随着每次请求变化,我一般不会把它放到单例 Service 的成员变量里。
这种数据更适合放在方法参数、局部变量,或者单独的任务对象里。
用 PDF 水印项目来理解
比如处理单个 PDF 时,我更愿意这样写:
@Service
public class PdfWatermarkService {
public void addWatermark(FileItemContext fileItemContext) {
String sourcePath = fileItemContext.getSourcePath();
String targetPath = fileItemContext.getTargetPath();
String waterMakeText = fileItemContext.getWaterMakeText();
// 使用局部变量处理 PDF
System.out.println("开始处理:" + sourcePath);
System.out.println("输出路径:" + targetPath);
System.out.println("水印文字:" + waterMakeText);
}
}
这样每个请求进来,都会把自己的参数传进来。
方法内部使用局部变量。
线程之间互不影响。
我不太会这样写:
@Service
public class PdfWatermarkService {
private String sourcePath;
private String targetPath;
private String waterMakeText;
public void addWatermark(FileItemContext fileItemContext) {
this.sourcePath = fileItemContext.getSourcePath();
this.targetPath = fileItemContext.getTargetPath();
this.waterMakeText = fileItemContext.getWaterMakeText();
// 后面继续使用成员变量处理
}
}
这就把一次请求的状态挂到了单例对象上。
如果并发请求进来,很容易互相覆盖。
那要不要给方法加 synchronized?
看到这里,可能会想:既然多个请求会同时调用同一个 Service,那我给方法加 synchronized 不就安全了吗?
比如:
@Service
public class PdfWatermarkService {
public synchronized void addWatermark(FileItemContext fileItemContext) {
// 添加水印
}
}
这样确实能让同一时间只有一个线程进入这个方法。
但问题是:它把并发能力也限制住了。
如果同时来了三个 PDF 处理请求:
请求1 处理 a.pdf;
请求2 处理 b.pdf;
请求3 处理 c.pdf。
加了 synchronized 后,很可能变成:
请求1 先处理;
请求1 完成后,请求2 才处理;
请求2 完成后,请求3 才处理。
这就把本来可以并发处理的任务变成串行了。
如果 PDF 之间本来互不影响,那就没有必要这样锁整个方法。
更好的方式是:
不要把请求状态放到成员变量里;
让每个线程使用自己的局部变量;
只在真正共享的数据上加锁。
哪些情况才需要加锁?
如果 Service 里确实有共享数据,而且多个线程会修改它,那就要考虑加锁或者换线程安全的数据结构。
比如我想统计成功处理数量:
@Service
public class PdfWatermarkService {
private int successCount = 0;
public void addSuccessCount() {
successCount++;
}
}
这个 successCount++ 就不安全。
可以用 synchronized:
@Service
public class PdfWatermarkService {
private int successCount = 0;
public synchronized void addSuccessCount() {
successCount++;
}
}
也可以用 AtomicInteger:
@Service
public class PdfWatermarkService {
private final AtomicInteger successCount = new AtomicInteger(0);
public void addSuccessCount() {
successCount.incrementAndGet();
}
}
这种场景才是真的共享修改。
但如果只是处理每个请求自己的文件路径,就没必要为了“单例”而加锁。
Controller 也是单例吗?
一般情况下,Spring MVC 的 Controller 也是单例。
所以同样的规则也适用。
不要在 Controller 里写这种请求状态成员变量:
@RestController
public class PdfController {
private String currentFileName;
@PostMapping("/pdf/watermark")
public String watermark(String fileName) {
this.currentFileName = fileName;
// 继续处理
return "ok";
}
}
这个写法也有并发风险。
更好的方式是把数据放在方法参数和局部变量里:
@RestController
public class PdfController {
@PostMapping("/pdf/watermark")
public String watermark(String fileName) {
String currentFileName = fileName;
// 使用局部变量继续处理
return "ok";
}
}
Controller 和 Service 都一样。
只要是单例对象,就不要随便把请求级别状态放成员变量里。
ThreadLocal 能不能解决?
有时候也会看到用 ThreadLocal 存当前用户、请求 ID 这类信息。
ThreadLocal 的确可以让每个线程有自己的一份变量。
但我不打算在这里把它当成主要方案。
因为 ThreadLocal 用不好也会有坑,尤其在线程池环境下,如果没有及时清理,可能出现数据残留。
比如请求1 在线程 A 里设置了用户信息,处理完没有清除。后面线程 A 又被复用去处理请求2,就可能拿到旧数据。
所以如果只是 PDF 处理参数,最简单可靠的方式还是:
通过方法参数传递;
在方法内部使用局部变量;
不要放成单例成员变量。
ThreadLocal 适合特定场景,比如链路追踪、当前登录用户上下文,但不是用来解决所有共享状态问题的万能方案。
回到这句话:单例不是问题
现在再看开头的问题:
Spring Boot Service 是单例,为什么还能处理并发请求?
我的理解是:
因为多个请求虽然调用的是同一个对象,但每次方法调用都有自己的局部变量和执行栈。
只要 Service 不把请求状态写到共享成员变量里,通常就没问题。
单例对象只是被多个线程共享调用。
它是否安全,取决于它内部怎么写。
所以我不会简单说:
单例一定线程不安全。
也不会简单说:
Spring Bean 默认都是安全的。
更准确的说法应该是:
无状态的单例 Bean 通常是线程安全的;
有共享可变状态的单例 Bean 要特别小心。
我写 Service 时的习惯
在实际写代码时,我一般会尽量让 Service 保持“无状态”。
也就是说,Service 里主要放:
依赖的 Mapper / Repository;
依赖的其他 Service;
只读配置;
工具对象。
请求过程中的数据,尽量放到:
方法参数;
局部变量;
任务对象;
数据库记录;
缓存或队列中明确管理的对象。
这样代码会稳很多。
特别是像 PDF 批量处理这种任务,每个文件本来就应该是一个独立任务对象,不应该把“当前正在处理哪个文件”挂在单例 Service 上。
这一节小结
这一节我主要记住几点:
1. Spring Boot 里的 Service 默认是单例;
2. 多个请求会由不同线程处理,它们可以同时调用同一个 Service 对象;
3. 单例 Bean 本身不是线程安全问题的根源;
4. 请求级别的数据不要放到单例 Bean 的成员变量里;
5. 方法里的局部变量一般是安全的;
6. 只有共享可变状态,才需要考虑 synchronized、AtomicInteger 或线程安全集合;
7. 不要为了“安全”随便给整个 Service 方法加 synchronized,否则可能把并发处理变成串行。
用一句话总结:
Spring 单例 Bean 能并发处理请求,前提是不要把每个请求自己的状态写到共享成员变量里。
下一节开始看 ReentrantLock。
它和 synchronized 一样能解决线程安全问题,但写法更灵活,也更接近一些真实并发工具的使用方式。