文章目錄
-
- 一.單例模式有全局變量存在的問題
-
- 1.1 線程不安全測試
- 1.2 單例模式下保證線程安全
-
- 1.2.1. 原子類保障線程安全
- 1.2.2 volatile 關鍵字能否保證 i++ 線程安全?
- 1.2.3 采用ThreadLocal 實作變量線程隔離
- 二. 采用原型模式實作多例
首先,讓我們弄清楚各種變量的差別: 成員變量、全局變量、執行個體變量、類變量、靜态變量和局部變量的差別
Spring架構裡的bean,或者說元件,擷取執行個體的時候都是預設的單例模式,單例模式的意思就是隻有一個執行個體當多使用者同時請求一個服務時,容器會給每一個請求配置設定一個線程,這是多個線程會并發執行該請求多對應的業務邏輯(成員方法),此時就要注意了,如果該處理邏輯中有對該單列狀态的修改(展現為該單列的成員屬性),則必須考慮線程同步問題
一.單例模式有全局變量存在的問題
目前我們的系統中,Bean都是采取的單例模式,也就是在使用有 @Service 注解的類時都是采用 @Autowired。如果在 Bean 中 有全局變量,則由于該Bean 隻有一個執行個體,當多使用者通路統一接口時,Spring 采用多線程的方式去操作這個Bean ,這個全局變量也就可能存線上程不安問題。
1.1 線程不安全測試
Service
@Service
public class ConcService {
private int i;
public void add() {
i++;
}
public int getI() {
return i;
}
}
Controller
@RestController
@RequestMapping("conc")
public class ConcController {
@Autowired
private ConcService concService;
@GetMapping("/addi")
public void addI() {
concService.add();
}
@GetMapping("/geti")
public int getI() {
return concService.getI();
}
}
-
concService是否是單例測試
輸出concService的hashcode值,發現是一樣的,也就是c對象一直沒變,為單例(注意:如果ConcService類上配置了lombok的 Data注解,則會發現不一樣)
-
對執行個體變量i的線程安全測試
使用jmeter并發測試,并發通路 localhost:8080/conc/addi 接口 500次,如果是線程安全,則i應該為500,結果表明不是。
1.2 單例模式下保證線程安全
如果如果多個線程并發通路的對象執行個體隻允許,也隻能建立一個,那就隻能采取同步措施,對于常用的 synchronized 和 lock ,這裡暫不講解 。
1.2.1. 原子類保障線程安全
這裡的執行個體變量 i 是基本類型int,是以用它的原子類 AutomicInteger 應該就是線程安全的了。service代碼如下
@Service
public class ConcService {
private AtomicInteger i = new AtomicInteger();
public void add() {
i.incrementAndGet();
}
public int getI() {
return i.intValue();
}
}
結果表明是線程安全的了。
至于為什麼原子類可以保證線程安全,請參考下面連結:
Java并發程式設計-無鎖CAS與Unsafe類及其并發包Atomic
1.2.2 volatile 關鍵字能否保證 i++ 線程安全?
既然研究到這裡了,那麼突然想到如果用volatile 關鍵字修飾 i 會線程安全麼?
volatile關鍵字有如下兩個作用
(1)保證被volatile修飾的共享變量對所有線程總數可見的,也就是當一個線程修改了一個被volatile修飾共享變量的值,新值總數可以被其他線程立即得知。
(2)禁止指令重排序優化。
測試如下:
@Service
public class ConcVolatileService {
private volatile int i;
public void addi() {
i++;
}
public int getI() {
return i;
}
}
測試結果表明 volatile 不能保證線程安全:
原因分析:正如上述代碼所示,i變量的任何改變都會立馬反應到其他線程中,但是如此存在多條線程同時調用increase()方法的話,就會出現線程安全問題,畢竟i++;操作并不具備原子性,該操作是先讀取值,然後寫回一個新值,相當于原來的值加上1,分兩步完成,如果第二個線程在第一個線程讀取舊值和寫回新值期間讀取i的域值,那麼第二個線程就會與第一個線程一起看到同一個值,并執行相同值的加1操作,這也就造成了線程安全失敗,是以對于increase方法必須使用synchronized修飾,以便保證線程安全。需要注意的是一旦使用synchronized修飾方法後,由于synchronized本身也具備與volatile相同的特性,即可見性,是以在這樣種情況下就完全可以省去volatile修飾變量
@Service
public class ConcVolatileService {
private int i;
public synchronized void addi() {
i++;
}
public int getI() {
return i;
}
}
延伸閱讀:
全面了解Java記憶體模型(JMM)及volatile關鍵字
1.2.3 采用ThreadLocal 實作變量線程隔離
如果在單例模式下,執行個體變量需要實作線程隔離,也就是每次通路的 i 是初始值,則可以使用ThreadLocal 。ThreadLocal 的作用 則是實作将單例對象的屬性 與 目前線程進行綁定。
ThreadLocal類為每一個線程都維護了自己獨有的變量拷貝。每個線程都擁有了自己獨立的一個變量,競争條件被徹底消除了,那就沒有任何必要對這些線程進行同步,它們也能最大限度的由CPU排程,并發執行。并且由于每個線程在通路該變量時,讀取和修改的,都是自己獨有的那一份變量拷貝,變量被徹底封閉在每個通路的線程中,并發錯誤出現的可能也完全消除了。對比前一種方案,這是一種以空間來換取線程安全性的政策。
代碼改造如下
Service層改造:
@Service
public class ConcurrencyService {
//private int i
// 定義 ThreadLocal 對象i ,差別于上面的int i
private static ThreadLocal<Integer> i = new ThreadLocal<Integer>() {
@Override
// 初始化 i
protected Integer initialValue() {
return 0;
}
};
public void add() {
i.set(getI() + 1);
}
public int getI() {
// 分裝一層,調用ThreadLocal對象 i 的 get()方法
return i.get();
}
public void setI(int ii) {
// 分裝一層,調用ThreadLocal對象 i 的 set()方法
i.set(ii);
}
}
Controller層:
public class ConcurrencyController {
@Autowired
private ConcurrencyService c;
@GetMapping("/addi")
public void addi() {
log.info(c.hashCode() + " " + c.getI());
c.add();
log.info(c.getI() + "");
}
@GetMapping("/geti")
public int getI() {
return c.getI();
}
}
兩次通路該接口,輸出結果為:
可以看出,c仍然是同一個對象,但是i 每次都是初始值0,也就是意味着i實作了線程隔離,也就是線程安全的了。使用這種方式隻需要修改原先的變量的調用方式就好。
弊端:
ThreadLocal變量的這種隔離政策,也不是任何情況下都能使用的。如果多個線程并發通路的對象執行個體隻允許,也隻能建立一個,那就沒有别的辦法了,此時需要使用同步機制(synchronized)
延伸閱讀:
ThreadLocal 原理和使用場景分析
Java并發程式設計–了解ThreadLocal
二. 采用原型模式實作多例
如果如果多個線程并發通路的對象執行個體可以建立多個,則可以用原型模式實作多例,也就是每次不是用同一個對象,而是類似new一個出來。
Spring或者Springboot實作多例
// //注入方式
// @Autowired
// private ConcurrencyService c;
// 不使用用@Autowired
@Autowired
private org.springframework.beans.factory.BeanFactory beanFactory;
@GetMapping("/addi")
public void addi() {
ConcurrencyService c = beanFactory.getBean(ConcurrencyService.class);
log.info(c.hashCode()+""+c.getI();
c.add();
}
改成這種方式後,會發現兩次通路的執行個體c是不一樣的,i也沒有在兩次通路之後變成1
弊端:
但是這種方式對原有代碼改動太大,原先通過Autowired 使用的對象都需要改造,而且每次接口通路都會變成new對象出來,對象能消耗大。
源碼位址:ConcOfField