Spring Bean的線程安全問題

Spring容器中的Bean是否線程安全,容器本身並沒有提供Bean的線程安全策略,因此可以說Spring容器中的Bean本身不具備線程安全的特性,但是具體還是要結合具體scope的Bean去研究。

Spring的bean作用域(scope)類型:

  • singleton
  • prototype
  • request
  • session
  • global-session

線程安全這個問題,要從單例與原型Bean分別進行說明:

  • 原型Bean:對於原型Bean,每次創建一個新對象,也就是線程之間並不存在Bean共享,自然是不會有線程安全的問題
  • 單例Bean:對於單例Bean,所有線程都共享一個單例實例Bean,因此是存在資源的競爭。如果單例Bean是一個無狀態Bean,也就是線程中的操作不會對Bean的成員執行 查詢 以外的操作,那麼這個單例Bean是線程安全的。比如Spring mvc的 Controller 、 Service 、 Dao 等,這些Bean大多是無狀態的,隻關註於方法本身

bean 分為 有狀態 bean 和無狀態 bean ,有狀態 bean 即類定義瞭成員變量,可能被多個線程同時訪問,則會出現線程安全問題;無狀態 bean 每個線程訪問不會產生線程安全問題,因為各個線程棧及方法棧資源都是獨立的,不共享。即是,無狀態 bean 可以在多線程環境下共享,有狀態 bean不能

Spring中的Bean默認是單例模式的,框架並沒有對bean進行多線程的封裝處理。

實際上大部分時間Bean是無狀態的(比如Dao) 所以說在某種程度上來說Bean其實是安全的。

但是如果Bean是有狀態的,那就需要開發人員自己來進行線程安全的保證,最簡單的辦法就是改變bean的作用域,把 singleton 改為 protopyte 這樣每次請求Bean就相當於是 new Bean() 這樣就可以保證線程的安全瞭。

  • 有狀態就是有數據存儲功能
  • 無狀態就是不會保存數據

Controller 、 Service 和 Dao 層本身並不是線程安全的,隻是如果隻是調用裡面的方法,而且多線程調用一個實例的方法,會在內存中復制變量,這是自己的線程的工作內存,是安全的。

Java虛擬機棧是線程私有的,它的生命周期與線程相同。虛擬機棧描述的是Java方法執行的內存模型:每個方法在執行的同時都會創建一個棧幀用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。

局部變量的固有屬性之一就是封閉在執行線程中。 它們位於執行線程的棧中,其他線程無法訪問這個棧。

所以其實任何無狀態單例都是線程安全的。

Spring的根本就是通過大量這種單例構建起系統,以事務腳本的方式提供服務。

@Controller、@Service是不是線程安全的?

默認配置下不是的。 因為默認情況下@Controller沒有加上@Scope,沒有加@Scope就是默認值singleton,單例的 。意思就是系統隻會初始化一次 Controller 容器,所以每次請求的都是同一個 Controller 容器,當然是非線程安全的。舉個栗子:

@RestController
public class TestController {
    private int var = 0;
    @GetMapping(value = "/test_var")
    public String test() {
        System.out.println("普通變量var:" + (++var));
        return "普通變量var:" + var ;
    }
}

在postman裡面發三次請求,結果如下:

普通變量var:1
普通變量var:2
普通變量var:3

說明它不是線程安全的。可以給它加上 @Scope 註解,如下:

@RestController
@Scope(value = "prototype") // 加上@Scope註解,有2個取值:單例-singleton 多實例-prototype
public class TestController {
    private int var = 0;
    @GetMapping(value = "/test_var")
    public String test() {
        System.out.println("普通變量var:" + (++var));
        return "普通變量var:" + var ;
    }
}

這樣一來,每個請求都單獨創建一個 Controller 容器,所以各個請求之間是線程安全的,三次請求結果:

普通變量var:1
普通變量var:1
普通變量var:1

加瞭 @Scope 註解的 prototype 實例一定就是線程安全的嗎?

@RestController
@Scope(value = "prototype") // 加上@Scope註解,有2個取值:單例-singleton 多實例-prototype
public class TestController {
    private int var = 0;
    private static int staticVar = 0;
​
    @GetMapping(value = "/test_var")
    public String test() {
        System.out.println("普通變量var:" + (++var)+ "---靜態變量staticVar:" + (++staticVar));
        return "普通變量var:" + var + "靜態變量staticVar:" + staticVar;
    }
}

三次請求結果:

普通變量var:1—靜態變量staticVar:1
普通變量var:1—靜態變量staticVar:2
普通變量var:1—靜態變量staticVar:3

雖然每次都是單獨創建一個 Controller 但是扛不住它變量本身是 static 的,所以說,即便是加上 @Scope 註解也不一定能保證 Controller 100%的線程安全。所以是否線程安全在於怎樣去定義變量以及 Controller 的配置。來個全乎一點的實驗,代碼如下:

@RestController
@Scope(value = "singleton") // prototype singleton
public class TestController {
​
    private int var = 0; // 定義一個普通變量
​
    private static int staticVar = 0; // 定義一個靜態變量
​
    @Value("${test-int}")
    private int testInt; // 從配置文件中讀取變量
​
    ThreadLocal<Integer> tl = new ThreadLocal<>(); // 用ThreadLocal來封裝變量
​
    @Autowired
    private User user; // 註入一個對象來封裝變量
​
    @GetMapping(value = "/test_var")
    public String test() {
        tl.set(1);
        System.out.println("先取一下user對象中的值:"+user.getAge()+"===再取一下hashCode:"+user.hashCode());
        user.setAge(1);
        System.out.println("普通變量var:" + (++var) + "===靜態變量staticVar:" + (++staticVar) + "===配置變量testInt:" + (++testInt)
                + "===ThreadLocal變量tl:" + tl.get()+"===註入變量user:" + user.getAge());
        return "普通變量var:" + var + ",靜態變量staticVar:" + staticVar + ",配置讀取變量testInt:" + testInt + ",ThreadLocal變量tl:"
                + tl.get() + "註入變量user:" + user.getAge();
    }
}@RestController
@Scope(value = "prototype") // prototype singleton
public class TestController {
​
    private int var = 0; // 定義一個普通變量
​
    private static int staticVar = 0; // 定義一個靜態變量
​
    @Value("${test-int}")
    private int testInt; // 從配置文件中讀取變量
​
    ThreadLocal<Integer> tl = new ThreadLocal<>(); // 用ThreadLocal來封裝變量
​
    @Autowired
    private User user; // 註入一個對象來封裝變量
​
    @GetMapping(value = "/test_var")
    public String test() {
        tl.set(1);
        System.out.println("先取一下user對象中的值:"+user.getAge()+"===再取一下hashCode:"+user.hashCode());
        user.setAge(1);
        System.out.println("普通變量var:" + (++var) + "===靜態變量staticVar:" + (++staticVar) + "===配置變量testInt:" + (++testInt)
                + "===ThreadLocal變量tl:" + tl.get()+"===註入變量user:" + user.getAge());
        return "普通變量var:" + var + ",靜態變量staticVar:" + staticVar + ",配置讀取變量testInt:" + testInt + ",ThreadLocal變量tl:"
                + tl.get() + "註入變量user:" + user.getAge();
    }
}

補充 Controller 以外的代碼:

config裡面自己定義的Bean: User

@Configuration
public class MyConfig {
    @Bean
    public User user(){
        return new User();
    }
}

三次http請求結果如下:

先取一下user對象中的值:0===再取一下hashCode:241165852
普通變量var:1===靜態變量staticVar:1===配置變量testInt:1===ThreadLocal變量tl:1===註入變量user:1
先取一下user對象中的值:1===再取一下hashCode:241165852
普通變量var:2===靜態變量staticVar:2===配置變量testInt:2===ThreadLocal變量tl:1===註入變量user:1
先取一下user對象中的值:1===再取一下hashCode:241165852
普通變量var:3===靜態變量staticVar:3===配置變量testInt:3===ThreadLocal變量tl:1===註入變量user:1

可以看到,在單例模式下 Controller 中隻有用 ThreadLocal 封裝的變量是線程安全的。可以看到3次請求結果裡面隻有 ThreadLocal 變量值每次都是從 0+1=1 的,其他的幾個都是累加的,而 user 對象呢,默認值是0,第二交取值的時候就已經是1瞭,關鍵它的 hashCode 是一樣的,說明每次請求調用的都是同一個 user 對象。

下面將 TestController 上的 @Scope 註解的屬性改一下改成多實例的: @Scope(value = "prototype") ,其他都不變,再次請求,結果如下:

先取一下user對象中的值:0===再取一下hashCode:853315860
普通變量var:1===靜態變量staticVar:1===配置變量testInt:1===ThreadLocal變量tl:1===註入變量user:1
先取一下user對象中的值:1===再取一下hashCode:853315860
普通變量var:1===靜態變量staticVar:2===配置變量testInt:1===ThreadLocal變量tl:1===註入變量user:1
先取一下user對象中的值:1===再取一下hashCode:853315860
普通變量var:1===靜態變量staticVar:3===配置變量testInt:1===ThreadLocal變量tl:1===註入變量user:1

分析這個結果發現,多實例模式下普通變量,取配置的變量還有 ThreadLocal 變量都是線程安全的,而靜態變量和 user (看它的 hashCode 都是一樣的)對象中的變量都是非線程安全的。也就是說盡管 TestController 是每次請求的時候都初始化瞭一個對象,但是靜態變量始終是隻有一份的,而且這個註入的 user 對象也是隻有一份的。靜態變量隻有一份這是當然的咯,那麼有沒有辦法讓 user 對象可以每次都new一個新的呢?當然可以:

public class MyConfig {
    @Bean
    @Scope(value = "prototype")
    public User user(){
        return new User();
    }    
}

在config裡面給這個註入的Bean加上一個相同的註解 @Scope(value = "prototype") 就可以瞭,再來請求一下:

先取一下user對象中的值:0===再取一下hashCode:1612967699
普通變量var:1===靜態變量staticVar:1===配置變量testInt:1===ThreadLocal變量tl:1===註入變量user:1
先取一下user對象中的值:0===再取一下hashCode:985418837
普通變量var:1===靜態變量staticVar:2===配置變量testInt:1===ThreadLocal變量tl:1===註入變量user:1
先取一下user對象中的值:0===再取一下hashCode:1958952789
普通變量var:1===靜態變量staticVar:3===配置變量testInt:1===ThreadLocal變量tl:1===註入變量user:1

可以看到每次請求的 user 對象的 hashCode 都不是一樣的,每次賦值前取 user 中的變量值也都是默認值0。

ThreadLocal vs 線程同步機制

ThreadLocal 和線程同步機制都是為瞭解決多線程中相同變量的訪問沖突問題。

線程同步機制

在同步機制中,通過對象的 鎖機制 保證同一時間隻有一個線程訪問變量。這時該變量是多個線程共享的,使用同步機制要求程序慎密地分析什麼時候對變量進行讀寫,什麼時候需要鎖定某個對象,什麼時候釋放對象鎖等繁雜的問題,程序設計和編寫難度相對較大。

ThreadLocal

  • ThreadLocal 則從另一個角度來解決多線程的並發訪問。 ThreadLocal會為每一個線程提供一個獨立的變量副本,從而隔離瞭多個線程對數據的訪問沖突 。因為每一個線程都擁有自己的變量副本,從而也就沒有必要對該變量進行同步瞭。 ThreadLocal 提供瞭線程安全的共享對象,在編寫多線程代碼時,可以把不安全的變量封裝進 ThreadLocal 。
  • 由於 ThreadLocal 中可以持有任何類型的對象,低版本JDK所提供的 get() 返回的是 Object 對象,需要強制類型轉換。但JDK 5.0通過泛型很好的解決瞭這個問題,在一定程度地簡化 ThreadLocal 的使用

概括起來說,對於多線程資源共享的問題, 同步機制采用瞭“以時間換空間”的方式,而ThreadLocal采用瞭“以空間換時間”的方式 。前者僅提供一份變量,讓不同的線程排隊訪問,而後者為每一個線程都提供瞭一份變量,因此可以同時訪問而互不影響。

總結

  • 在 @Controller/@Service 等容器中,默認情況下,scope值是單例- singleton 的,也是線程不安全的
  • 盡量不要在 @Controller/@Service 等容器中定義靜態變量,不論是單例( singleton )還是多實例( prototype )都是線程不安全的
  • 默認註入的Bean對象,在不設置scope的時候也是線程不安全的
  • 一定要定義變量的話,用 ThreadLocal 來封裝,這個是線程安全的

 到此這篇關於Spring Bean的線程安全問題的文章就介紹到這瞭,更多相關Spring Bean線程安全內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: