1. 程式人生 > 程式設計 >復現一個典型的線上Spring Bean物件的執行緒安全問題(附三種解決辦法)

復現一個典型的線上Spring Bean物件的執行緒安全問題(附三種解決辦法)

問題復現

假設線上是一個典型的Spring Boot Web專案,某一塊業務的處理邏輯為:

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

程式碼如下:

@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物件

具體程式碼如下所示:

@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的夥伴們對這段程式碼不會有什麼疑問,實際執行也沒有問題,測試也能跑通,但真的上線後,裡面卻會產生一個執行緒安全問題

不相信的話,我們通過執行緒池,開200個執行緒來測試NameController就可以復現出來

測試程式碼如下

    @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個執行緒近乎一半以上都會丟擲異常

問題產生原因

首先我們來分析一下,當一個執行緒,向 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 {

}
複製程式碼

參考文獻

  • https://dzone.com/articles/understanding-spring-reactiveclient-to-server-comm
  • https://dzone.com/articles/understanding-spring-reactive-servlet-async
  • https://medium.com/sipios/how-to-make-parallel-calls-in-java-springboot-application-and-how-to-test-them-dcc27318a0cf

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

案例專案程式碼: github/liumapp/booklet