聊一聊Spring中的執行緒安全性

SylvanasSun發表於2017-11-06

Spring與執行緒安全


Spring作為一個IOC/DI容器,幫助我們管理了許許多多的“bean”。但其實,Spring並沒有保證這些物件的執行緒安全,需要由開發者自己編寫解決執行緒安全問題的程式碼。

Spring對每個bean提供了一個scope屬性來表示該bean的作用域。它是bean的生命週期。例如,一個scope為singleton的bean,在第一次被注入時,會建立為一個單例物件,該物件會一直被複用到應用結束。

  • singleton:預設的scope,每個scope為singleton的bean都會被定義為一個單例物件,該物件的生命週期是與Spring IOC容器一致的(但在第一次被注入時才會建立)。

  • prototype:bean被定義為在每次注入時都會建立一個新的物件。

  • request:bean被定義為在每個HTTP請求中建立一個單例物件,也就是說在單個請求中都會複用這一個單例物件。

  • session:bean被定義為在一個session的生命週期內建立一個單例物件。

  • application:bean被定義為在ServletContext的生命週期中複用一個單例物件。

  • websocket:bean被定義為在websocket的生命週期中複用一個單例物件。

我們交由Spring管理的大多數物件其實都是一些無狀態的物件,這種不會因為多執行緒而導致狀態被破壞的物件很適合Spring的預設scope,每個單例的無狀態物件都是執行緒安全的(也可以說只要是無狀態的物件,不管單例多例都是執行緒安全的,不過單例畢竟節省了不斷建立物件與GC的開銷)。

無狀態的物件即是自身沒有狀態的物件,自然也就不會因為多個執行緒的交替排程而破壞自身狀態導致執行緒安全問題。無狀態物件包括我們經常使用的DO、DTO、VO這些只作為資料的實體模型的貧血物件,還有Service、DAO和Controller,這些物件並沒有自己的狀態,它們只是用來執行某些操作的。例如,每個DAO提供的函式都只是對資料庫的CRUD,而且每個資料庫Connection都作為函式的區域性變數(區域性變數是在使用者棧中的,而且使用者棧本身就是執行緒私有的記憶體區域,所以不存線上程安全問題),用完即關(或交還給連線池)。

有人可能會認為,我使用request作用域不就可以避免每個請求之間的安全問題了嗎?這是完全錯誤的,因為Controller預設是單例的,一個HTTP請求是會被多個執行緒執行的,這就又回到了執行緒的安全問題。當然,你也可以把Controller的scope改成prototype,實際上Struts2就是這麼做的,但有一點要注意,Spring MVC對請求的攔截粒度是基於每個方法的,而Struts2是基於每個類的,所以把Controller設為多例將會頻繁的建立與回收物件,嚴重影響到了效能。

通過閱讀上文其實已經說的很清楚了,Spring根本就沒有對bean的多執行緒安全問題做出任何保證與措施。對於每個bean的執行緒安全問題,根本原因是每個bean自身的設計。不要在bean中宣告任何有狀態的例項變數或類變數,如果必須如此,那麼就使用ThreadLocal把變數變為執行緒私有的,如果bean的例項變數或類變數需要在多個執行緒之間共享,那麼就只能使用synchronized、lock、CAS等這些實現執行緒同步的方法了。

下面將通過解析ThreadLocal的原始碼來了解它的實現與作用,ThreadLocal是一個很好用的工具類,它在某些情況下解決了執行緒安全問題(在變數不需要被多個執行緒共享時)。

本文作者為SylvanasSun(sylvanas.sun@gmail.com),首發於SylvanasSun’s Blog
原文連結:sylvanassun.github.io/2017/11/06/…
(轉載請務必保留本段宣告,並且保留超連結。)

ThreadLocal


ThreadLocal是一個為執行緒提供執行緒區域性變數的工具類。它的思想也十分簡單,就是為執行緒提供一個執行緒私有的變數副本,這樣多個執行緒都可以隨意更改自己執行緒區域性的變數,不會影響到其他執行緒。不過需要注意的是,ThreadLocal提供的只是一個淺拷貝,如果變數是一個引用型別,那麼就要考慮它內部的狀態是否會被改變,想要解決這個問題可以通過重寫ThreadLocal的initialValue()函式來自己實現深拷貝,建議在使用ThreadLocal時一開始就重寫該函式。

ThreadLocal與像synchronized這樣的鎖機制是不同的。首先,它們的應用場景與實現思路就不一樣,鎖更強調的是如何同步多個執行緒去正確地共享一個變數,ThreadLocal則是為了解決同一個變數如何不被多個執行緒共享。從效能開銷的角度上來講,如果鎖機制是用時間換空間的話,那麼ThreadLocal就是用空間換時間。

ThreadLocal中含有一個叫做ThreadLocalMap的內部類,該類為一個採用線性探測法實現的HashMap。它的key為ThreadLocal物件而且還使用了WeakReference,ThreadLocalMap正是用來儲存變數副本的。

    /**
     * ThreadLocalMap is a customized hash map suitable only for
     * maintaining thread local values. No operations are exported
     * outside of the ThreadLocal class. The class is package private to
     * allow declaration of fields in class Thread.  To help deal with
     * very large and long-lived usages, the hash table entries use
     * WeakReferences for keys. However, since reference queues are not
     * used, stale entries are guaranteed to be removed only when
     * the table starts running out of space.
     */
    static class ThreadLocalMap {
        /**
         * The entries in this hash map extend WeakReference, using
         * its main ref field as the key (which is always a
         * ThreadLocal object).  Note that null keys (i.e. entry.get()
         * == null) mean that the key is no longer referenced, so the
         * entry can be expunged from table.  Such entries are referred to
         * as "stale entries" in the code that follows.
         */
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
        ....
    }複製程式碼

ThreadLocal中只含有三個成員變數,這三個變數都是與ThreadLocalMap的hash策略相關的。

    /**
     * ThreadLocals rely on per-thread linear-probe hash maps attached
     * to each thread (Thread.threadLocals and
     * inheritableThreadLocals).  The ThreadLocal objects act as keys,
     * searched via threadLocalHashCode.  This is a custom hash code
     * (useful only within ThreadLocalMaps) that eliminates collisions
     * in the common case where consecutively constructed ThreadLocals
     * are used by the same threads, while remaining well-behaved in
     * less common cases.
     */
    private final int threadLocalHashCode = nextHashCode();

    /**
     * The next hash code to be given out. Updated atomically. Starts at
     * zero.
     */
    private static AtomicInteger nextHashCode =
        new AtomicInteger();

    /**
     * The difference between successively generated hash codes - turns
     * implicit sequential thread-local IDs into near-optimally spread
     * multiplicative hash values for power-of-two-sized tables.
     */
    private static final int HASH_INCREMENT = 0x61c88647;

    /**
     * Returns the next hash code.
     */
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }複製程式碼

唯一的例項變數threadLocalHashCode是用來進行定址的hashcode,它由函式nextHashCode()生成,該函式簡單地通過一個增量HASH_INCREMENT來生成hashcode。至於為什麼這個增量為0x61c88647,主要是因為ThreadLocalMap的初始大小為16,每次擴容都會為原來的2倍,這樣它的容量永遠為2的n次方,該增量選為0x61c88647也是為了儘可能均勻地分佈,減少碰撞衝突。

        /**
         * The initial capacity -- MUST be a power of two.
         */
        private static final int INITIAL_CAPACITY = 16;    

        /**
         * Construct a new map initially containing (firstKey, firstValue).
         * ThreadLocalMaps are constructed lazily, so we only create
         * one when we have at least one entry to put in it.
         */
        ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
            table = new Entry[INITIAL_CAPACITY];
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
            table[i] = new Entry(firstKey, firstValue);
            size = 1;
            setThreshold(INITIAL_CAPACITY);
        }複製程式碼

要獲得當前執行緒私有的變數副本需要呼叫get()函式。首先,它會呼叫getMap()函式去獲得當前執行緒的ThreadLocalMap,這個函式需要接收當前執行緒的例項作為引數。如果得到的ThreadLocalMap為null,那麼就去呼叫setInitialValue()函式來進行初始化,如果不為null,就通過map來獲得變數副本並返回。

setInitialValue()函式會去先呼叫initialValue()函式來生成初始值,該函式預設返回null,我們可以通過重寫這個函式來返回我們想要在ThreadLocal中維護的變數。之後,去呼叫getMap()函式獲得ThreadLocalMap,如果該map已經存在,那麼就用新獲得value去覆蓋舊值,否則就呼叫createMap()函式來建立新的map。

    /**
     * Returns the value in the current thread's copy of this
     * thread-local variable.  If the variable has no value for the
     * current thread, it is first initialized to the value returned
     * by an invocation of the {@link #initialValue} method.
     *
     * @return the current thread's value of this thread-local
     */
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

    /**
     * Variant of set() to establish initialValue. Used instead
     * of set() in case user has overridden the set() method.
     *
     * @return the initial value
     */
    private T setInitialValue() {
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }

    protected T initialValue() {
        return null;
    }複製程式碼

ThreadLocal的set()與remove()函式要比get()的實現還要簡單,都只是通過getMap()來獲得ThreadLocalMap然後對其進行操作。

    /**
     * Sets the current thread's copy of this thread-local variable
     * to the specified value.  Most subclasses will have no need to
     * override this method, relying solely on the {@link #initialValue}
     * method to set the values of thread-locals.
     *
     * @param value the value to be stored in the current thread's copy of
     *        this thread-local.
     */
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

    /**
     * Removes the current thread's value for this thread-local
     * variable.  If this thread-local variable is subsequently
     * {@linkplain #get read} by the current thread, its value will be
     * reinitialized by invoking its {@link #initialValue} method,
     * unless its value is {@linkplain #set set} by the current thread
     * in the interim.  This may result in multiple invocations of the
     * {@code initialValue} method in the current thread.
     *
     * @since 1.5
     */
     public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);
     }複製程式碼

getMap()函式與createMap()函式的實現也十分簡單,但是通過觀察這兩個函式可以發現一個祕密:ThreadLocalMap是存放在Thread中的。

    /**
     * Get the map associated with a ThreadLocal. Overridden in
     * InheritableThreadLocal.
     *
     * @param  t the current thread
     * @return the map
     */
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

    /**
     * Create the map associated with a ThreadLocal. Overridden in
     * InheritableThreadLocal.
     *
     * @param t the current thread
     * @param firstValue value for the initial entry of the map
     */
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

    // Thread中的原始碼

    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;

    /*
     * InheritableThreadLocal values pertaining to this thread. This map is
     * maintained by the InheritableThreadLocal class.
     */
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;複製程式碼

仔細想想其實就能夠理解這種設計的思想。有一種普遍的方法是通過一個全域性的執行緒安全的Map來儲存各個執行緒的變數副本,但是這種做法已經完全違背了ThreadLocal的本意,設計ThreadLocal的初衷就是為了避免多個執行緒去併發訪問同一個物件,儘管它是執行緒安全的。而在每個Thread中存放與它關聯的ThreadLocalMap是完全符合ThreadLocal的思想的,當想要對執行緒區域性變數進行操作時,只需要把Thread作為key來獲得Thread中的ThreadLocalMap即可。這種設計相比採用一個全域性Map的方法會多佔用很多記憶體空間,但也因此不需要額外的採取鎖等執行緒同步方法而節省了時間上的消耗。

ThreadLocal中的記憶體洩漏


我們要考慮一種會發生記憶體洩漏的情況,如果ThreadLocal被設定為null後,而且沒有任何強引用指向它,根據垃圾回收的可達性分析演算法,ThreadLocal將會被回收。這樣一來,ThreadLocalMap中就會含有key為null的Entry,而且ThreadLocalMap是在Thread中的,只要執行緒遲遲不結束,這些無法訪問到的value會形成記憶體洩漏。為了解決這個問題,ThreadLocalMap中的getEntry()、set()和remove()函式都會清理key為null的Entry,以下面的getEntry()函式的原始碼為例。

        /**
         * Get the entry associated with key.  This method
         * itself handles only the fast path: a direct hit of existing
         * key. It otherwise relays to getEntryAfterMiss.  This is
         * designed to maximize performance for direct hits, in part
         * by making this method readily inlinable.
         *
         * @param  key the thread local object
         * @return the entry associated with key, or null if no such
         */
        private Entry getEntry(ThreadLocal<?> key) {
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];
            if (e != null && e.get() == key)
                return e;
            else
                return getEntryAfterMiss(key, i, e);
        }

        /**
         * Version of getEntry method for use when key is not found in
         * its direct hash slot.
         *
         * @param  key the thread local object
         * @param  i the table index for key's hash code
         * @param  e the entry at table[i]
         * @return the entry associated with key, or null if no such
         */
        private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
            Entry[] tab = table;
            int len = tab.length;

            // 清理key為null的Entry
            while (e != null) {
                ThreadLocal<?> k = e.get();
                if (k == key)
                    return e;
                if (k == null)
                    expungeStaleEntry(i);
                else
                    i = nextIndex(i, len);
                e = tab[i];
            }
            return null;
        }複製程式碼

在上文中我們發現了ThreadLocalMap的key是一個弱引用,那麼為什麼使用弱引用呢?使用強引用key與弱引用key的差別如下:

  • 強引用key:ThreadLocal被設定為null,由於ThreadLocalMap持有ThreadLocal的強引用,如果不手動刪除,那麼ThreadLocal將不會回收,產生記憶體洩漏。

  • 弱引用key:ThreadLocal被設定為null,由於ThreadLocalMap持有ThreadLocal的弱引用,即便不手動刪除,ThreadLocal仍會被回收,ThreadLocalMap在之後呼叫set()、getEntry()和remove()函式時會清除所有key為null的Entry。

但要注意的是,ThreadLocalMap僅僅含有這些被動措施來補救記憶體洩漏問題。如果你在之後沒有呼叫ThreadLocalMap的set()、getEntry()和remove()函式的話,那麼仍然會存在記憶體洩漏問題。

在使用執行緒池的情況下,如果不及時進行清理,記憶體洩漏問題事小,甚至還會產生程式邏輯上的問題。所以,為了安全地使用ThreadLocal,必須要像每次使用完鎖就解鎖一樣,在每次使用完ThreadLocal後都要呼叫remove()來清理無用的Entry。

參考文獻


相關文章