問題復現(xiàn)
假設線上是一個典型的Spring Boot Web項目豹障,某一塊業(yè)務的處理邏輯為:
接受一個name字符串參數(shù)梅尤,然后將該值賦予給一個注入的bean對象辰妙,修改bean對象的name屬性后再返回,期間我們用了 Thread.sleep(300)
來模擬線上的高耗時業(yè)務
代碼如下:
@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的伙伴們對這段代碼不會有什么疑問踪区,實際運行也沒有問題,測試也能跑通,但真的上線后晨逝,里面卻會產(chǎn)生一個線程安全問題
不相信的話,我們通過線程池懦铺,開200個線程來測試NameController就可以復現(xiàn)出來
測試代碼如下
@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進行測試冬念,每一個線程將自己的線程名作為參數(shù)提交趁窃,并對返回結(jié)果進行斷言,如果返回的值與提交的值不匹配急前,那么拋出AssertNotEquals異常
實際測試后醒陆,我們可以發(fā)現(xiàn)200個線程近乎一半以上都會拋出異常
問題產(chǎn)生原因
首先我們來分析一下,當一個線程裆针,向 http://localhost:8080/name 發(fā)出請求時刨摩,線上的Spring Boot服務,會通過其內(nèi)置的Tomcat 8.5來接收這個請求
而在Tomcat 8.5中世吨,默認采用的是NIO的實現(xiàn)方式澡刹,及每次請求對應一個服務端線程,然后這個服務端的線程耘婚,再分配到對應的servlet來處理請求
所以我們可以認為罢浇,這并發(fā)的200次客戶端請求,進入NameController執(zhí)行請求的沐祷,也是分為200個不同的服務端線程來處理
但是Spring提供的Bean對象嚷闭,并沒有默認實現(xiàn)它的線程安全性,即默認狀態(tài)下戈轿,我們的NameController跟NameService都屬于單例對象
這下應該很好解釋了凌受,200個線程同時操作2個單例對象(一個NameController對象,一個NameService對象)思杯,在沒有采用任何鎖機制的情況下胜蛉,不產(chǎn)生線程安全問題是不可能的(除非是狀態(tài)無關(guān)性操作)
問題解決辦法
按照標題說明的,我這里提供三種解決辦法色乾,分別是
synchronized修飾方法
synchronized代碼塊
改變bean對象的作用域
接下來對每個解決辦法進行說明誊册,包括他們各自的優(yōu)缺點
synchronized修飾方法
使用synchronized來是修飾可能會產(chǎn)生線程安全問題的方法,應該是我們最容易想到的暖璧,同時也是最簡單的解決辦法案怯,我們僅僅需要在 public String changeAndReadName (@RequestParam String name)
這個方法上,增加一個synchronized進行修飾即可
實際測試澎办,這樣確實能解決問題嘲碱,但是各位是否可以再思考一個問題
我們再來運行測試代碼的時候金砍,發(fā)現(xiàn)程序運行效率大大降低,因為每一個線程必須等待前一個線程完成changeAndReadName()方法的所有邏輯后才可以運行麦锯,而這段邏輯中恕稠,就包含了我們用來模擬高耗時業(yè)務的 Thread.sleep(300)
,但它跟我們的線程安全沒有什么關(guān)系
這種情況下扶欣,我們就可以使用第二種方法來解決問題
synchronized代碼塊
實際的線上邏輯鹅巍,經(jīng)常會遇到這樣的情況:我們需要確保線程安全的代碼,跟高耗時的代碼(比如說調(diào)用第三方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;
}
再次運行測試代碼髓绽,我們可以發(fā)現(xiàn)效率問題基本解決敛苇,但是缺點是需要我們自己把握好哪一塊是可能出現(xiàn)線程安全問題的代碼(而實際的線上邏輯可能非常復雜,這一塊不好把握)
改變bean對象的作用域
現(xiàn)在非常不幸的事情發(fā)生了梧宫,我們連高耗時代碼也是狀態(tài)相關(guān)性的接谨,而同時也需要保證效率問題,那么這種情況下就只能通過犧牲少量的內(nèi)存來解決問題了
大概思路就是通過改變bean對象的作用域塘匣,讓每一個服務端線程對應一個新的bean對象來處理邏輯脓豪,通過彼此之間互不相關(guān)來回避線程安全問題
首先我們需要知道bean對象的作用域有哪些,請見下表
作用域 | 說明 |
---|---|
singleton | 默認的作用域忌卤,這種情況下的bean都會被定義為一個單例對象扫夜,該對象的生命周期是與Spring IOC容器一致的(但出于Spring懶加載機制,只有在第一次被使用時才會創(chuàng)建) |
prototype | bean被定義為在每次注入時都會創(chuàng)建一個新的對象 |
request | bean被定義為在每個HTTP請求中創(chuàng)建一個單例對象驰徊,也就是說在單個請求中都會復用這一個單例對象 |
session | bean被定義為在一個session的生命周期內(nèi)創(chuàng)建一個單例對象 |
application | bean被定義為在ServletContext的生命周期中復用一個單例對象 |
? ? ? ? ? ? ? websocket ? ? ? ? ? ? ? | bean被定義為在websocket的生命周期中復用一個單例對象 |
清楚bean對象的作用域后笤闯,接下來我們就只需要考慮一個問題:修改哪些bean的作用域?
前面我已經(jīng)解釋過棍厂,這個案例中颗味,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
原創(chuàng)不易,轉(zhuǎn)載請申明出處
案例項目代碼: github/liumapp/booklet