Spring单例Bean如何服务于并发请求?

2023/05/13

1. 概述

在本教程中,我们将了解使用单例作用域创建的Spring bean如何在幕后工作以提供多个并发请求。 此外,我们将了解Java如何将bean实例存储在内存中以及它如何处理对它们的并发访问。

2. Spring Beans和Java堆内存

正如我们所知,Java堆是一个全局共享内存,可供应用程序中所有正在运行的线程访问。 当Spring容器创建具有单例作用域的bean时,该bean存储在堆中。这样,所有并发线程都能够指向同一个bean实例。

接下来,让我们了解线程的堆栈内存是什么以及它如何帮助服务并发请求。

3. 如何处理并发请求?

举个例子,让我们看一个Spring应用程序,它有一个名为ProductService的单例bean:


@Service
public class ProductService {

    private final static List<Product> productRepository = asList(
            new Product(1, "Product 1", new Stock(100)),
            new Product(2, "Product 2", new Stock(50))
    );

    public Optional<Product> getProductById(int id) {
        Optional<Product> product = productRepository.stream()
                .filter(p -> p.id() == id)
                .findFirst();
        String productName = product.map(Product::name).orElse(null);
        System.out.printf("Thread: %s; bean instance: %s; product id: %s has the name: %s%n", currentThread().getName(), this, id, productName);
        return product;
    }
}

public record Product(int id, String name, Stock stock) {

}

@RestController()
@RequestMapping("product")
public class ProductController {
    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping("/{id}")
    public Product getProductDetails(@PathVariable("id") int productId) {
        return productService.getProductById(productId).orElse(null);
    }
}

这个bean有一个方法getProductById(),它将产品数据返回给调用方。 此外,此bean返回的数据在端点/product/{id}上公开给客户端。

接下来,让我们看看当两个调用同时到达端点/product/{id}时,运行时会发生什么。 具体来说,第一个线程将调用/product/1,第二个线程将调用/product/2。

Spring为每个请求创建一个不同的线程。正如我们在下面的控制台输出中看到的,两个线程都使用相同的ProductService实例来返回产品数据:

Thread: http-nio-8080-exec-1; bean instance: cn.tuyucheng.taketoday.concurrentrequest.ProductService@43c71e4d; product id: 2 has the name: Product 2
Thread: http-nio-8080-exec-2; bean instance: cn.tuyucheng.taketoday.concurrentrequest.ProductService@43c71e4d; product id: 1 has the name: Product 1

Spring可以在多个线程中使用同一个bean实例,首先是因为Java会为每个线程创建一个私有堆栈内存。

栈内存负责存储线程执行期间方法内部使用的局部变量的状态。这样,Java确保并行执行的线程不会覆盖彼此的变量。

其次,由于ProductService bean在堆级别没有设置任何限制或锁定, 因此每个线程的程序计数器都能够指向堆内存中bean实例的相同引用。 因此,两个线程可以同时执行getProductById()方法。

接下来,我们将理解为什么单例bean必须是无状态的。

4. 无状态单例bean与有状态单例bean

为了理解无状态单例bean的重要性,让我们看看使用有状态单例bean的副作用是什么。

假设我们将productName变量改为类级别:


@Service
public class ProductService {
    private String productName = null;

    // ...

    public Optional getProductById(int id) {
        // ...
        productName = product.map(Product::getName).orElse(null);
        // ...
    }
}

现在,让我们再次调用Service并查看输出:

Thread: http-nio-8080-exec-1; bean instance: cn.tuyucheng.taketoday.concurrentrequest.ProductService@2b8bcfb5 product id: 2 has the name: Product 2
Thread: http-nio-8080-exec-2; bean instance: cn.tuyucheng.taketoday.concurrentrequest.ProductService@2b8bcfb5 product id: 1 has the name: Product 2

正如我们所见,对productId 1的调用显示的productName是“Product 2”而不是“Product 1”。 发生这种情况是因为ProductService是有状态的,并且与所有正在运行的线程共享相同的productName变量。

在代码中,使用线程池执行两次mock调用,可以模拟出上面的运行效果。


@SpringBootTest
@AutoConfigureMockMvc
public class ConcurrentRequestUnitTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ProductController controller;

    @Test
    void givenContextLoads_thenProductControllerIsAvailable() {
        assertThat(controller).isNotNull();
    }

    @Test
    void givenMultipleCallsRunInParallel_thenAllCallsReturn200() throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(2);
        executor.submit(() -> performCall("/product/1", status().isOk()));
        executor.submit(() -> performCall("/product/2/stock", status().isOk()));
        if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
            executor.shutdownNow();
        }
    }

    private void performCall(String url, ResultMatcher expect) {
        try {
            this.mockMvc.perform(get(url)).andExpect(expect);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

为了避免这样的副作用,保持我们的单例bean无状态至关重要

5. 总结

在本文中,我们了解了Spring中对单例bean的并发访问是如何工作的。 首先,我们知道Java将单例bean存储在堆内存中,不同线程可以从堆访问同一个单例实例。 最后,我们阐明了使用无状态bean重要性,并通过一个例子演示了使用有状态bean可能带来的问题。

与往常一样,本教程的完整源代码可在GitHub上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章