保障執行緒安全的設計

eacape發表於2022-05-07

目錄

無狀態物件

有狀態和無狀態的區別:有狀態-會儲存資料、無狀態-不會儲存資料

物件就是操作和資料的封裝(物件 = 操作 + 資料),物件所包含的資料就被稱為該物件的狀態,它包含物件的例項變數和靜態變數中的資料,也有可能包含物件中引用其它變數的例項變數或靜態變數。如果一個類的例項被多個執行緒共享不會存在共享狀態,那麼則稱其為無狀態物件。

這個類的物件是一個有狀態物件
class A{
  Integer age;
}

這個類的物件中會引用其它有狀態的物件所以它是一個由狀態物件
@Component
class B{
  @Autowire
  private A a;  
}

只有操作沒有狀態 - 無狀態
class C{
  public void test(){
    ......
  }
}

本身和引用都是無狀態 - 無狀態
@Component
class D{
  @Autowire
  private C c;
}

一個類即使不存在例項變數或靜態變數仍然可能存在共享狀態,如下

enum Singleton{
  INSTANCE;
  
  private EnumSingleton singletonInstance;
  
  Singleton(){
    singletonInstance = new EnumSingleton();
  }
  
  public EnumSingleton getInstance(){
    //對singletonInstance做一些配置操作
    //例如 singletonInstance.num++;
    return singletonInstance;
  }
}

class SingletonTest{
  public void test(){
    Singleton st = Singleton.INSTANCE;
    st.getInstance();
  }
}

enum的INSTANCE只會被例項化一次,所以如果沒有註釋所對應的操作,這就是一個完美的單例,且其它類對它的引用都是無狀態的。但是如果在getInstance方法中對singletonInstance變數有所操作,那麼在多執行緒環境中singletonInstance會出現執行緒安全問題。下面的例子這個問題更明顯,num變數就是一個共享狀態的變數。

enum Singleton{
  INSTANCE;
  
  private int num;
  
  public int doSomething(){
    num++;
    return num;
  }
}

class SingletonTest{
  public void test(){
    Singleton st = Singleton.INSTANCE;
    st.doSomething();
  }
}

還有一種情況是靜態變數的使用,靜態變數與類(class)直接關聯,不會隨著例項的建立而改變,所以當Test沒有例項變數和靜態變數的情況下,在方法中通過類名直接操作靜態變數,仍然會造成執行緒安全問題,即Test中存在共享狀態變數的呼叫。

class A{
  static int num;
}

class Test{
  public void doSomething(){
    A.num++;
  }
}

總結:無狀態物件肯定是不包含任何例項變數或者可更新靜態變數(包括來自相應類的上層類的例項變數或者靜態變數)。但是,一個類不包含任何例項變數或者靜態變數卻不一定是無狀態物件。

使用無狀態類和只有靜態方法的類

Servlet類就是無狀態物件的典型應用,Servlet一般被web伺服器(tomcat)託管,控制它的建立、執行、銷燬等生命週期,一般一個Servlet類在web伺服器中只會有一個例項被託管,但是它會被多個執行緒請求訪問,並且,其處理請求的方法service並沒有鎖修飾,如果這個類裡面包含例項變數或者是靜態變數就會產生執行緒安全問題,所以一般情況下Servlet例項是無狀態物件。

不可變物件

不可變物件(Immutable Object)指的是一經建立就不可改變狀態的物件。

不可變物件滿足以下條件

  • 類是被final欄位修飾,防止通過繼承來改變類的定義
  • 所有成員變數需要使用final修飾,一方面保證變數不能被修改,另一方面保證final屬性對其它執行緒可見時,必定是初始化完成的(有的博文寫必須是private+final,其實final就保證了資料不可修改)。
  • 如果這個欄位是可變的引用物件,需要用private修飾這個欄位且不提供修改這個引用物件狀態的方法。
  • 物件在初始化過程中沒有逸出(防止物件在初始化過程中被修改狀態(匿名類)):一個還沒初始化完成的物件被其它執行緒感知到,這就被稱作是物件逸出,從而可能導致程式執行錯誤。下面是可能導致物件逸出的方式。

    • 在構造器中將this賦值給一個共享變數
    • 在構造器中將this作為引數傳遞給其它方法
    • 在構造器中啟動基於匿名類的執行緒

下面是一個不可變物件,正常情況下我們建立例項後就不能再改變它的狀態,所以需要對他進行修改的話只能重新建立一個例項來替代它。

final class Score{
  final int score;
  private final Student student;
  
  public Score(int score,Student student){
    this.score = score;
    this.student = student;
  }
  
  public String getName(){
    return student.getName();
  }
}

class test(){
  ......
  public void update(){
    Score s = new Score(...);
  }
}

不可變物件的使用會對垃圾回收的效率產生影響,既有積極的一方面影響,又有消極的影響。

負面:由於只要想對不可變物件做出更新,就得重新建立一個新的不可變物件,過於頻繁的建立物件會增加垃圾回收的頻率。

正面:一般來說物件中存在成員變數是物件引用的話,那麼一般在可變物件中這個引用物件是在年輕代,而可變物件本身在老年代,但是不可變物件一般是引用物件處於老年代,而不可變物件本身處於年輕代。修改一個狀態可變物件的例項變數值的時候,如果這個物件已經位於年老代中,那麼在垃圾回收器進行下一輪次要回收(Minor Collection)的時候,年老代中包含這個物件的卡片(Card,年老代中儲存物件的儲存單位,一個Card的大小為512位元組)中的所有物件都必須被掃描一遍,以確定年老代中是否有物件對待回收的物件持有引用。因此,年老代物件持有對年輕代物件的引用會導致次要回收的開銷增加。

可以採用迭代器模式來減少不可變物件的記憶體空間佔用1

執行緒特有物件

對於一個一個非執行緒安全的物件,每個訪問它的物件都建立一個該物件的例項,每個執行緒只能訪問自己建立的物件例項,這個物件例項就被稱作為執行緒特有物件(TSO,Thread Specific Object)或執行緒區域性變數。

ThreadLoacl\<T>相當於執行緒訪問其特有物件的代理,執行緒可以通過這個物件建立並訪問各自執行緒的特有物件。

ThreadLocal例項為每個訪問它的執行緒提供了一個該執行緒的執行緒特有物件。

方法功能
public T get()獲取與該執行緒區域性變數關聯的當前執行緒的執行緒特有物件
public void set(T value)重新關聯該執行緒區域性變數所對應的當前執行緒的執行緒特有物件
protected T initialValue()該方法的返回值(物件)就是初始狀態下該執行緒區域性變數所對應的當前執行緒的執行緒特有物件
public void remove()刪除該執行緒區域性變數與相應的當前執行緒的執行緒特有物件之間的關聯關係

ThreadLocal的簡單使用方法及原始碼分析

public class ThreadLocalDemo {

    public static void main(String[] args) {
        Thread t = new Thread(){
            @Override
            public void run() {
                A a = new A();
                a.testA();
                A.TL.remove();
            }
        };
        t.setName("t1");
        t.start();
    }
}
class A{
    final static ThreadLocal<String> TL = new ThreadLocal<String>(){
        @Override
        protected String initialValue() {
            return "A";
        }
    };

     void testA(){
        String str = TL.get();
        System.out.println("str = " + str);
        TL.set("B");
        str = TL.get();
        System.out.println("str = " + str);
    }
}
--------------------------------------
str = A
str = B

如上我們在ThreadLocalDemo類中新建一個ThreadLocal例項且使用匿名類匿名類將initialValue方法重寫。下面除錯檢視一下整個樣例的流轉方式。

在上圖位置給get方法打一個斷點

因為此執行緒對應的threadLocals是一個空值所以要先進入到setInitialValue方法對其進行初始化。

在這個方法中主要就是獲取我們要代理的物件,如果在宣告ThreadLocal的時候不重寫initialValue()方法則在這個地方獲取的是一個空值。然後就給當前執行緒建立一個ThreadlocalMap例項並寫入元素,最後將物件例項返回,ThreadLocal.get()方法就獲取到了值。

然後我們再進入set方法觀察其中的流程

同樣會先獲取當先執行緒的threadlocals判斷其是否為空,如果為空將會幫他初始化一個,並將set的引數存入到threadlocals,否則將直接將Threadlocal→value存入這個容器中。

set方法的流程基本就是向ThreadLocalMap的Entry陣列中插入或修改我們這個ThreadLocal(TL)例項要代理的物件例項("B"),然後就是通過ThreadLocal(TL)的hashcode來確定這個物件例項在陣列中的位置。

基於對treadLocalHashCode的追溯,我們發現其就是ThreadLocal類中一個自增的靜態變數。

上面是對於相關的threadLocal例項線上程的儲存條目陣列中的查詢,及插入替換方式:

  • 當查詢到threadLocal有對應的條目時會直接將value替換
  • 當遍歷的的時候發現了無效條目,將這個條目的key和value替換
  • 沒有上述兩種情況,在為null的槽位插入新的條目new Entry(key,value)

在插入新的條目後要對Entry陣列進行遍歷查詢value為null的Entry,並將其對應條目置為null,然後判斷Entry陣列是否需要進行擴容操作。

當set結束後,再次呼叫get獲取ThreadLocal代理的例項物件,因為之前當先執行緒的threadlocals已經被初始化並且存在當前ThreadLocal(TL)物件對應的Entry(第一次get的時候呼叫initValue初始化為A後面又set為B),所以可以直接獲得目標代理的例項物件("B")。

一般執行緒區域性變數都會被宣告為靜態變數,因為這樣只會在類載入的時候被建立一次,如果宣告為例項變數,那麼每次建立一個類的例項這個執行緒區域性變數都會被建立一次,這樣會造成資源的浪費。

執行緒特有物件可能造成的問題

  • 資料退化與錯亂問題

    如上圖因為TL是一個靜態變數,所以每次new Task()是不會對TL重新初始化,就會導致當Thread-A在執行完Task-1後再執行Task-2時,因為執行它們的時同一個執行緒,所以,它們通過TL獲取到的Map物件例項都是同一個執行緒特有物件,這就導致Task-2可能會獲取到Task-1操作的資料,這樣就可能造成資料錯亂問題。

    所以在這種情況下,我們需要在獲取到ThreadLocal代理的物件例項後,需要先對其做一些前置操作,如對上面的HashMap物件例項進行清空。

    TL.get().clear();

    很多時候我們也會用ThreadLocal傳遞一些資料,例如:在ThreadLocal中儲存token等資訊,但是為了不讓下一個任務獲取到這次的請求token資訊,需要在攔截器的後置處理器中將其remove掉,此操作就是為了防止資料錯亂!

  • 記憶體洩漏問題

    記憶體洩漏→指的是一個物件永遠不能被虛擬機器垃圾回收,一直佔用某一塊記憶體無法釋放。記憶體洩漏的增加會導致可用的記憶體越來越少,甚至可能導致記憶體溢位。

    由之前的ThreadLocal原始碼分析可知,我們將ThreadLocal物件例項和它代理的物件以key-value的方式存入到Thread對應的ThreadLocalMap中,而真正儲存這個key-value的是ThreadLocalMap一個的Entry陣列,也就是我們會將key-value封裝成Entry物件。

    由上圖可知Entry對於ThreadLocal例項的引用是以個弱引用,在沒有其它物件強引用時ThreadLocal例項會被虛擬機器回收掉 ,這時候Entry中的key就會程式設計null,也就是此時Entry會變成一個無效條目。

    另外,Entry對於執行緒特有物件的引用是強引用,所以如果Entry變成了無效條目後,這個執行緒特有物件由於強引用的關係並不會被回收,也就是說,如果無效條長時間不會被清理或者永遠不被清理那麼就會對記憶體長時間的佔用,營造出記憶體洩漏的現象。

    那麼無效條目什麼時候會被清理呢?之前ThreadLocal的原始碼分析中,ThreadLocal.set()操作(對ThreadLocalMap的插入)時可以引發失效Entry的替換或者清理,但是如果一直沒有對這個執行緒的ThreadLocalMap的插入的操作的話無效條目就會一直佔用記憶體。所以為了防止這種現象的發生,我們需要養成一個良好的習慣,在每次對ThreadLocal物件使用完畢後,手動的呼叫ThreadLocal.remove方法來清理無效條目(一般線上程結束後呼叫)。


  1. 後面補

相關文章