1. 程式人生 > >【Java併發基礎】區域性變數是執行緒安全的

【Java併發基礎】區域性變數是執行緒安全的

前言

方法中的變數(即區域性變數)是不存在資料競爭(Data Race)的,也是執行緒安全的。為了理解為什麼,我們先來了一下方法是如何被執行的,然後再分析區域性變數的安全性,最後再介紹利用區域性變數不會共享的特點而產生的解決併發問題的一些技術。

方法是如何被執行的

int a = 7;
int[] b = fibonacci(a);
int[] c = b;

以上程式碼轉換成CPU指令執行,方法的呼叫過程示意圖如下:(圖來自參考[1])

當呼叫fibonacci(a)時,CPU要先找到方法fibonacci()的地址(在CPU堆疊暫存器中),然後跳轉到這個地址去執行程式碼(藍色線),最後CPU執行完方法,再返回原來呼叫方法的下一條語句(紅色線)。

CPU找呼叫方法的引數和返回地址,是通過堆疊暫存器。CPU支援一種線性結構,因為與方法呼叫有關,所以也稱為呼叫棧。

再舉個例子,有三個方法A、B、C。方法A中呼叫方法B,方法B中呼叫方法C。那麼將會構建出如下呼叫棧。每個方法在呼叫棧裡都有自己的獨立空間,稱為棧幀。每個棧幀都有對應方法需要的引數和返回地址。當呼叫新方法時,會建立新的棧幀,並壓入呼叫棧;當方法返回時,對應的棧幀就會被自動彈出。即,棧幀和方法同生共死。

三個方法生成的呼叫棧如上圖所示。

不同的程式語言雖定義方法雖各有所異,但是它們執行方法的原理卻是一致的:都是依靠棧結構解決。Java語言雖然是靠虛擬機器解釋執行,但是方法的呼叫也是利用棧結構解決的。

區域性變數的存放位置

區域性變數是定義在方法內,作用域也是在方法內部。當方法執行結束後,區域性變數也就失效了。那麼我們可以得出,區域性變數的存放位置應該在呼叫棧中。事實上,區域性變數就是存放到呼叫棧中的。

呼叫棧與執行緒

兩個執行緒可以同時用不同的引數呼叫相同的方法,那麼呼叫棧和執行緒之間是什麼關係呢?答案就是:每個執行緒都有自己獨立的呼叫棧。

所以,Java方法裡面的區域性變數是不存在併發問題的。每個執行緒都有自己獨立的呼叫棧,區域性變數儲存在各自的呼叫棧中,不會被共享,自然也就沒有併發問題。

利用不共享解決併發問題的技術: 執行緒封閉

當多執行緒訪問沒有同步的可變共享變數時就會出現併發問題,而解決方案之一便是使變數不共享。變數不會和其他變數共享,也就不會存在併發問題。僅在單執行緒裡訪問資料,不需要同步,我們稱之為執行緒封閉。當某個物件封閉在一個執行緒中時,這種用法將自動實現執行緒安全性,即使被封閉的物件本身不是執行緒安全的。

採用執行緒封閉技術的案例非常多。例如一種常見的應用便為JDBC的Connection物件。從資料庫連線池中獲取一個Connection物件,在JDBC規範中並沒有要求這個Connection一定是執行緒安全的。資料庫連線池通過執行緒封閉技術,保證一個Connection物件一旦被一個執行緒獲取之後,在這個Connection物件返回之前,連線池不會將它分配給其他執行緒,從而保證了Connection物件不會有併發問題。

執行緒封閉技術的一個具體實現是我們上面提到的區域性變數的使用(棧封閉),還有一種需要提一下,即ThreadLocal類。

ThreadLoacl類

維持執行緒封閉性一種更規範方法是使用ThreadLocal,這個類能使執行緒中的某個值與儲存值的物件相關聯起來。ThreadLocal提供了get()set()等訪問介面,這些方法為每個使用該變數的執行緒都存有一份獨立的副本,因此get()總是返回由當前執行執行緒在呼叫set()時設定的最新值。

ThreadLocal物件通常用於防止對可變的單例項變數(Singleton)或全域性變數進行共享。
例如,在單執行緒應用程式中可能會維持一個全域性的資料庫連線,並在執行緒啟動時初始化這個連線物件,從而避免在呼叫每個方法時都要傳遞一個Connection物件。由於JDBC的連線物件不一定執行緒安全的,因此,當多執行緒應用程式在沒有協同的情況下使用全域性變數時,就不是執行緒安全的。通過將JDBC的連線儲存到ThreadLocal物件中,每個執行緒都會擁有屬於自己的連線。

如以下程式碼所示,利用ThreadLocal來維持執行緒的封閉性:(程式碼來自參考[2])

public class ConnectionDispenser {
    static String DB_URL = "jdbc:mysql://localhost/mydatabase";

    private ThreadLocal<Connection> connectionHolder
        = new ThreadLocal<Connection>() {
        public Connection initialValue() {
            try {
                return DriverManager.getConnection(DB_URL);
            } catch (SQLException e) {
                throw new RuntimeException("Unable to acquire Connection, e");
            }
        };
    };

    public Connection getConnection() {
        return connectionHolder.get();
    }
}

當某個頻繁執行的操作需要一個臨時物件,例如一個緩衝區,而同時又希望避免在每次執行時都重新分配該臨時物件,就可以使用這項技術。例如,在Java 5.0之前,Integer.toString()方法使用ThreadLocal物件來儲存一個12位元組大小的緩衝區,用於對結果進行格式化,而不是使用共享的靜態緩衝區(需要使用加鎖機制)或者每次呼叫時都分配一個新的緩衝區。

小結

知道方法是如何呼叫的也就明白了局部變數為什麼是執行緒安全的。方法呼叫會產生棧幀,區域性變數會放在棧幀的工作記憶體中,執行緒之間不共享,故不存線上程安全問題。後面我們介紹了基於不共享解決併發問題的執行緒封閉技術,除了不共享這種思想可以解決併發問題,還有兩種:使用不可變變數和正確使用同步機制。

參考:
[1]極客時間專欄王寶令《Java併發程式設計實戰》
[2]Brian Goetz.Tim Peierls. et al.Java併發程式設計實戰[M].北京:機械工業出版社,2