復現一個典型的線上Spring Bean對象的線程安全問題(附三種解決辦法)

問題復現

假設線上是一個典型的Spring Boot Web項目,某一塊業務的處理邏輯爲:java

接受一個name字符串參數,而後將該值賦予給一個注入的bean對象,修改bean對象的name屬性後再返回,期間咱們用了 Thread.sleep(300) 來模擬線上的高耗時業務react

代碼以下:ios

@RestController
@RequestMapping("name")
public class NameController {

    @Autowired
    private NameService nameService;

    @RequestMapping("")
    public String changeAndReadName (@RequestParam String name) throws InterruptedException {
        System.out.println("get new request: " + name);
        nameService.setName(name);
        Thread.sleep(300);
        return nameService.getName();
    }

}

上述的nameService也很是簡單,一個普通的Spring Service對象git

具體代碼以下所示:github

@Service
public class NameService {

    private String name;

    public NameService() {
    }

    public NameService(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public NameService setName(String name) {
        this.name = name;
        return this;
    }
}

相信使用過Spring Boot的夥伴們對這段代碼不會有什麼疑問,實際運行也沒有問題,測試也能跑通,但真的上線後,裏面卻會產生一個線程安全問題web

不相信的話,咱們經過線程池,開200個線程來測試NameController就能夠復現出來spring

測試代碼以下api

@Test
    public void changeAndReadName() throws Exception {
        ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(200, 300 , 2000, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(200));
        for (int i = 0; i < 200; i++) {
            poolExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        System.out.println(Thread.currentThread().getName() + " begin");
                        Map<String, String> headers = new HashMap<String, String>();
                        Map<String, String> querys = new HashMap<String, String>();

                        querys.put("name", Thread.currentThread().getName());
                        headers.put("Content-Type", "text/plain;charset=UTF-8");
                        HttpResponse response = HttpTool.doGet("http://localhost:8080",
                                "/name",
                                "GET",
                                headers,
                                querys);
                        String res = EntityUtils.toString(response.getEntity());

                        if (!Thread.currentThread().getName().equals(res)) {
                            System.out.println("WE FIND BUG !!!");
                            Assert.assertEquals(true, false);
                        } else {
                            System.out.println(Thread.currentThread().getName() + " get received " + res);
                        }
                    }catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            });
        }
        while(true) {
            Thread.sleep(100);
        }
    }

這段測試代碼,啓動200個線程,對NameController進行測試,每個線程將本身的線程名做爲參數提交,並對返回結果進行斷言,若是返回的值與提交的值不匹配,那麼拋出AssertNotEquals異常安全

實際測試後,咱們能夠發現200個線程近乎一半以上都會拋出異常springboot

問題產生緣由

首先咱們來分析一下,當一個線程,向 http://localhost:8080/name 發出請求時,線上的Spring Boot服務,會經過其內置的Tomcat 8.5來接收這個請求

而在Tomcat 8.5中,默認採用的是NIO的實現方式,及每次請求對應一個服務端線程,而後這個服務端的線程,再分配到對應的servlet來處理請求

因此咱們能夠認爲,這併發的200次客戶端請求,進入NameController執行請求的,也是分爲200個不一樣的服務端線程來處理

可是Spring提供的Bean對象,並無默認實現它的線程安全性,即默認狀態下,咱們的NameController跟NameService都屬於單例對象

這下應該很好解釋了,200個線程同時操做2個單例對象(一個NameController對象,一個NameService對象),在沒有采用任何鎖機制的狀況下,不產生線程安全問題是不可能的(除非是狀態無關性操做)

問題解決辦法

按照標題說明的,我這裏提供三種解決辦法,分別是

  • synchronized修飾方法
  • synchronized代碼塊
  • 改變bean對象的做用域

接下來對每一個解決辦法進行說明,包括他們各自的優缺點

synchronized修飾方法

使用synchronized來是修飾可能會產生線程安全問題的方法,應該是咱們最容易想到的,同時也是最簡單的解決辦法,咱們僅僅須要在 public String changeAndReadName (@RequestParam String name) 這個方法上,增長一個synchronized進行修飾便可

實際測試,這樣確實能解決問題,可是各位是否能夠再思考一個問題

咱們再來運行測試代碼的時候,發現程序運行效率大大下降,由於每個線程必須等待前一個線程完成changeAndReadName()方法的全部邏輯後才能夠運行,而這段邏輯中,就包含了咱們用來模擬高耗時業務的 Thread.sleep(300) ,但它跟咱們的線程安全沒有什麼關係

這種狀況下,咱們就可使用第二種方法來解決問題

synchronized代碼塊

實際的線上邏輯,常常會遇到這樣的狀況:咱們須要確保線程安全的代碼,跟高耗時的代碼(好比說調用第三方api),很不湊巧的寫在同一個方法中

那麼這種狀況下,使用synchronized代碼塊,而不是直接修飾方法會來得高效的多

具體解決代碼以下:

@RequestMapping("")
    public String changeAndReadName (@RequestParam String name) throws InterruptedException {
        System.out.println(Thread.currentThread().getName() + " get new request: " + name);
        String result = "";
        synchronized (this) {
            nameService.setName(name);
            result = nameService.getName();
        }
        Thread.sleep(300);
        return result;
    }

再次運行測試代碼,咱們能夠發現效率問題基本解決,可是缺點是須要咱們本身把握好哪一塊是可能出現線程安全問題的代碼(而實際的線上邏輯可能很是複雜,這一塊很差把握)

改變bean對象的做用域

如今很是不幸的事情發生了,咱們連高耗時代碼也是狀態相關性的,而同時也須要保證效率問題,那麼這種狀況下就只能經過犧牲少許的內存來解決問題了

大概思路就是經過改變bean對象的做用域,讓每個服務端線程對應一個新的bean對象來處理邏輯,經過彼此之間互不相關來回避線程安全問題

首先咱們須要知道bean對象的做用域有哪些,請見下表

做用域 說明
singleton 默認的做用域,這種狀況下的bean都會被定義爲一個單例對象,該對象的生命週期是與Spring IOC容器一致的(但出於Spring懶加載機制,只有在第一次被使用時纔會建立)
prototype bean被定義爲在每次注入時都會建立一個新的對象
request bean被定義爲在每一個HTTP請求中建立一個單例對象,也就是說在單個請求中都會複用這一個單例對象
session bean被定義爲在一個session的生命週期內建立一個單例對象
application bean被定義爲在ServletContext的生命週期中複用一個單例對象
              websocket               bean被定義爲在websocket的生命週期中複用一個單例對象

清楚bean對象的做用域後,接下來咱們就只須要考慮一個問題:修改哪些bean的做用域?

前面我已經解釋過,這個案例中,200個服務端線程,在默認狀況下是操做2個單例bean對象,分別是NameController和NameService(沒錯,在Spring Boot下,Controller默認也是單例對象)

那麼是否是直接將NameController和NameServie設置爲prototype就能夠了呢?

若是您的項目是用的Struts2,那麼這樣作沒有任何問題,可是在Spring MVC下會嚴重影響性能,由於Struts2對請求的攔截是基於類,而Spring MVC則是基於方法

因此咱們應該將NameController的做用域設置爲request,將NameService設置爲prototype來解決

具體操做代碼以下

@RestController
@RequestMapping("name")
@Scope("request")
public class NameController {

}
@Service
@Scope("prototype")
public class NameService {

}

參考文獻

原創不易,轉載請申明出處

案例項目代碼: github/liumapp/booklet

相關文章
相關標籤/搜索