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

問題復現(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 {

}

參考文獻

原創(chuàng)不易,轉(zhuǎn)載請申明出處

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末币他,一起剝皮案震驚了整個濱河市坞靶,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌圆丹,老刑警劉巖滩愁,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件躯喇,死亡現(xiàn)場離奇詭異辫封,居然都是意外死亡,警方通過查閱死者的電腦和手機廉丽,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進店門倦微,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人正压,你說我怎么就攤上這事欣福。” “怎么了焦履?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵拓劝,是天一觀的道長。 經(jīng)常有香客問我嘉裤,道長郑临,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任屑宠,我火速辦了婚禮厢洞,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘典奉。我一直安慰自己躺翻,他們只是感情好,可當我...
    茶點故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布卫玖。 她就那樣靜靜地躺著公你,像睡著了一般。 火紅的嫁衣襯著肌膚如雪假瞬。 梳的紋絲不亂的頭發(fā)上陕靠,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天,我揣著相機與錄音笨触,去河邊找鬼懦傍。 笑死,一個胖子當著我的面吹牛芦劣,可吹牛的內(nèi)容都是我干的粗俱。 我是一名探鬼主播,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼虚吟,長吁一口氣:“原來是場噩夢啊……” “哼寸认!你這毒婦竟也來了签财?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤偏塞,失蹤者是張志新(化名)和其女友劉穎唱蒸,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體灸叼,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡神汹,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了古今。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片屁魏。...
    茶點故事閱讀 40,040評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖捉腥,靈堂內(nèi)的尸體忽然破棺而出氓拼,到底是詐尸還是另有隱情,我是刑警寧澤抵碟,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布桃漾,位于F島的核電站,受9級特大地震影響拟逮,放射性物質(zhì)發(fā)生泄漏撬统。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一唱歧、第九天 我趴在偏房一處隱蔽的房頂上張望宪摧。 院中可真熱鬧,春花似錦颅崩、人聲如沸几于。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽沿彭。三九已至,卻和暖如春尖滚,著一層夾襖步出監(jiān)牢的瞬間喉刘,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工漆弄, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留睦裳,地道東北人。 一個月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓撼唾,卻偏偏與公主長得像廉邑,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,979評論 2 355

推薦閱讀更多精彩內(nèi)容