同時使用執行緒本地變數以及物件快取的問題
如有轉載請著名出處:https://www.cnblogs.com/funnyzpc/p/18313879
前面
前些時間看別人寫的一段關於鎖的(物件快取+執行緒本地變數)的一段程式碼,這段程式碼大致描述了這麼一個功能:
外部傳入一個key,需要根據這個key去全域性變數裡面找是否存在,如有有則表示有人對這個key加鎖了,往下就不執行具體業務程式碼,同時,同時哦 還要判斷這個key是不是當前執行緒持有的,如果不是當前執行緒持有的也不能往下執行業務程式碼~
然後哦 還要在業務程式碼執行完成後釋放這個key鎖,也就是要從 ThreadLocal
裡面移除這個key。
當然需求不僅於此,就是業務的特殊性需要 ThreadLocal
同時持有多個不同的key,這就表明 ThreadLocal
的泛型肯定是個List或Set。
然後再說下程式碼,為了演示問題程式碼寫的比較簡略,以下我再一一說明可能存在的問題🎈
基本邏輯
功能大致包含兩個函式:
lock
: 主要是查詢公共快取還有執行緒本地變數是否包含傳入的指定key,若無則嘗試寫入全域性變數及ThreadLocal
並返回true以示獲取到鎖release
: 業務邏輯處理完成後呼叫此,此函式內主要是做全域性快取以及ThreadLocal
內的key的移除並返回狀態(true/false)contains
: 公共方法,供以上兩個方法使用,邏輯:判斷全域性變數或ThreadLocal
裡面有否有指定的key,此方法用private
修飾
好了,準備看程式碼 😂
先看第一版
- 程式碼
public class CacheObjectLock {
// 全域性物件快取
private static List<Object> GLOBAL_CACHE = new ArrayList<Object>(8);
// 執行緒本地變數
private static ThreadLocal<List<Object>> THREAD_CACHE = new ThreadLocal<List<Object>>();
// 嘗試加鎖
public synchronized boolean lock(Object obj){
if(this.contains(obj)){
return false;
}
List al = null;
if((al=THREAD_CACHE.get())==null){
al = new ArrayList(2);
THREAD_CACHE.set(al);
}
al.add(obj);
GLOBAL_CACHE.add(obj);
return true;
}
// 判斷是否存在key
public boolean contains(Object obj){
List<Object> objs;
return GLOBAL_CACHE.contains(obj)?true:(objs=THREAD_CACHE.get())==null?false:objs.contains(obj);
}
// 釋放key鎖,與上面的 lock 方法對應
public boolean release(Object obj){
if( this.contains(obj) ){
List<Object> objs = THREAD_CACHE.get();
if(null!=objs){
objs.remove(obj);
GLOBAL_CACHE.remove(obj);
}
return true;
}
return false;
}
}
-
測試程式碼
因為是鎖,所以必須要使用多執行緒測試,這裡我簡單使用
parallel stream
+多輪迴圈去測試:
public class CacheObjectLockTest {
private CacheObjectLock LOCK = new CacheObjectLock();
public void test1(){
IntStream.range(0,10000).parallel().forEach(i->{
if(i%3==0){
i-=2;
}
Boolean b = null;
if((b=LOCK.lock(i))==false ){
return ;
}
Boolean c = null;
try {
// do something ...
// TimeUnit.MILLISECONDS.sleep(1);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
c = LOCK.release(i);
}
if(b!=c){
System.out.println("b:"+b+" c:"+c+" => "+Thread.currentThread().getName());
}
});
// LOCK.contains(9);
}
@Test
public void test2(){
for(int i=0;i<10;i++){
this.test1();
}
}
}
-
測試結果
-
分析
顯而易見,這是沒有對
release
加鎖導致的,其實呢,這樣說是不準確的...
首先要明白lock
上加的synchronized
的同步鎖的範圍是對當前例項的,而release
是沒有加synchronized
,所以release
是無視lock
上加的synchronized
再仔細看看GLOBAL_CACHE
是什麼?ArrayList
,明白了吧ArrayList
不是執行緒安全的,因為synchronized
的範圍只是lock
函式這一 函式內 ,從測試程式碼可看到LOCK.lock(i)
開始一直到LOCK.release(i)
這中間是沒有加同步鎖的,所以到LOCK.lock(i)
開始一直到LOCK.release(i)
這中間是存線上程競爭的,恰好又碰到ArrayList
這一不安全因素自然會拋錯的!
因為存在不安全類,所以我們有理由懷疑THREAD_CACHE
的泛型變數也是存在多執行緒異常的,因為它這個泛型也是ArrayList
!
再看第二版
好了,明白了問題之所在,自然解決辦法也十分easy:
- 在
release
方法上新增synchronized
宣告,這樣簡單粗暴 - 分別對
objs.remove(obj);
以及GLOBAL_CACHE.remove(obj);
加同步鎖,這樣顆粒度更細
因為 synchronized
是寫獨佔的,所以無需在 contains
中單獨加鎖
- 程式碼 (這裡僅有
release
變更)
public synchronized boolean release(Object obj){
if( this.contains(obj) ){
List<Object> objs = THREAD_CACHE.get();
if(null!=objs){
// synchronized (objs){
objs.remove(obj);
// }
// synchronized (GLOBAL_CACHE){
GLOBAL_CACHE.remove(obj);
// }
}
return true;
}
return false;
}
- 測試結果
-
分析
😂
測試了多輪都是成功的,沒有任何異常,難道就一定沒有異常了???
非也,非也~~~
為了讓問題體現的的更清晰,先修改下測試用例並把contains
方法置為public
,然後測試用例:
public class CacheObjectLockTest {
private CacheObjectLock2 LOCK = new CacheObjectLock2();
public void test1(){
IntStream.range(0,10000).parallel().forEach(i->{
// String it = "K"+i;
if(i%3==0){
i-=2;
}
Boolean b = null;
if((b=LOCK.lock(i))==false ){
return ;
}
Boolean c = null;
try {
// do something ...
// TimeUnit.MILLISECONDS.sleep(1);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
c = LOCK.release(i);
}
if(b!=c){
System.out.println("b:"+b+" c:"+c+" => "+Thread.currentThread().getName());
}
});
LOCK.contains(9);
}
@Test
public void test2(){
for(int i=0;i<10;i++){
this.test1();
}
}
}
在這一行打上斷點 LOCK.contains(9);
然後逐步進入到 ThreadLocal
的 get()
方法中:
看到沒,雖然key已經被移除的,但是 ThreadLocal
裡面關聯的是 key外層的 ArrayList
, 因為開發機配置都較好,一旦導致 ThreadLocal
膨脹,則 OOM 是必然的事兒!
我們知道 ThreadLocal
的基本特性,它會根據執行緒分開存放各自執行緒的所 set
進來的物件,若沒有呼叫其 remove
方法,變數會一直存在 ThreadLocal
這個 map
中,
若上述的測試程式碼放線上程池裡面被管理,執行緒池會根據負載會增減執行緒,如果每一次執行上述程式碼用的執行緒都不是固定的 ThreadLocal
必然會導致 jvm OOM 😂
這就像 java
裡面的 檔案讀寫,open
之後必須要 要有 close
操作。
最後更改
- 程式碼
public class CacheObjectLock3 {
private static List<Object> GLOBAL_CACHE = new ArrayList<Object>(8);
private static ThreadLocal<List<Object>> THREAD_CACHE = new ThreadLocal<List<Object>>();
public synchronized boolean lock(Object obj){
if(this.contains(obj)){
return false;
}
List al = null;
if((al=THREAD_CACHE.get())==null){
al = new ArrayList(2);
THREAD_CACHE.set(al);
}
al.add(obj);
GLOBAL_CACHE.add(obj);
return true;
}
public boolean contains(Object obj){
List<Object> objs;
return GLOBAL_CACHE.contains(obj)?true:(objs=THREAD_CACHE.get())==null?false:objs.contains(obj);
}
public synchronized boolean release(Object obj){
if( this.contains(obj) ){
List<Object> objs = THREAD_CACHE.get();
if(null!=objs){
// synchronized (objs){
objs.remove(obj);
if(objs.isEmpty()){
THREAD_CACHE.remove();
}
// }
// synchronized (GLOBAL_CACHE){
GLOBAL_CACHE.remove(obj);
// }
}
return true;
}
return false;
}
}
- 測試結果
(截圖略)
測試 ok 透過 ~
最後
以上程式碼未必是完美的,但至少看到了問題所在,尤其使用 鎖
或 ThreadLocal
的時候務必謹慎~
核心程式碼是僅是部分擷取過來的,如存在問題煩請告知於我,在此感謝了 ♥