跳到正文
hello world

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 处理逻辑
    }
}

sourcePathtargetPath 是方法内部的局部变量。

每个请求线程调用 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 是单例,sourcePathtargetPath 属于这个单例对象。

多个请求线程访问的是同一份成员变量。

可能会出现这种情况:

线程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 一样能解决线程安全问题,但写法更灵活,也更接近一些真实并发工具的使用方式。