ThreadLocal 那點事兒

發表於2016-07-07

ThreadLocal,直譯為“執行緒本地”或“本地執行緒”,如果你真的這麼認為,那就錯了!其實,它就是一個容器,用於存放執行緒的區域性變數,我認為應該叫做 ThreadLocalVariable(執行緒區域性變數)才對,真不理解為什麼當初 Sun 公司的工程師這樣命名。

早在 JDK 1.2 的時代,java.lang.ThreadLocal 就誕生了,它是為了解決多執行緒併發問題而設計的,只不過設計得有些難用,所以至今沒有得到廣泛使用。其實它還是挺有用的,不相信的話,我們一起來看看這個例子吧。

一個序列號生成器的程式,可能同時會有多個執行緒併發訪問它,要保證每個執行緒得到的序列號都是自增的,而不能相互干擾。

先定義一個介面:

每次呼叫 getNumber() 方法可獲取一個序列號,下次再呼叫時,序列號會自增。

再做一個執行緒類:

線上程中連續輸出三次執行緒名與其對應的序列號。

我們先不用 ThreadLocal,來做一個實現類吧。

序列號初始值是0,在 main() 方法中模擬了三個執行緒,執行後結果如下:

Thread-0 => 1
Thread-0 => 2
Thread-0 => 3
Thread-2 => 4
Thread-2 => 5
Thread-2 => 6
Thread-1 => 7
Thread-1 => 8
Thread-1 => 9

由於執行緒啟動順序是隨機的,所以並不是0、1、2這樣的順序,這個好理解。為什麼當 Thread-0 輸出了1、2、3之後,而 Thread-2 卻輸出了4、5、6呢?執行緒之間竟然共享了 static 變數!這就是所謂的“非執行緒安全”問題了。

那麼如何來保證“執行緒安全”呢?對應於這個案例,就是說不同的執行緒可擁有自己的 static 變數,如何實現呢?下面看看另外一個實現吧。

通過 ThreadLocal 封裝了一個 Integer 型別的 numberContainer 靜態成員變數,並且初始值是0。再看 getNumber() 方法,首先從 numberContainer 中 get 出當前的值,加1,隨後 set 到 numberContainer 中,最後將 numberContainer 中 get 出當前的值並返回。

是不是很噁心?但是很強大!確實稍微饒了一下,我們不妨把 ThreadLocal 看成是一個容器,這樣理解就簡單了。所以,這裡故意用 Container 這個單詞作為字尾來命名 ThreadLocal 變數。

執行結果如何呢?看看吧。

Thread-0 => 1
Thread-0 => 2
Thread-0 => 3
Thread-2 => 1
Thread-2 => 2
Thread-2 => 3
Thread-1 => 1
Thread-1 => 2
Thread-1 => 3

每個執行緒相互獨立了,同樣是 static 變數,對於不同的執行緒而言,它沒有被共享,而是每個執行緒各一份,這樣也就保證了執行緒安全。 也就是說,TheadLocal 為每一個執行緒提供了一個獨立的副本!

搞清楚 ThreadLocal 的原理之後,有必要總結一下 ThreadLocal 的 API,其實很簡單。

  1. public void set(T value):將值放入執行緒區域性變數中
  2. public T get():從執行緒區域性變數中獲取值
  3. public void remove():從執行緒區域性變數中移除值(有助於 JVM 垃圾回收)
  4. protected T initialValue():返回執行緒區域性變數中的初始值(預設為 null)
    為什麼 initialValue() 方法是 protected 的呢?就是為了提醒程式設計師們,這個方法是要你們來實現的,請給這個執行緒區域性變數一個初始值吧。

瞭解了原理與這些 API,其實想想 ThreadLocal 裡面不就是封裝了一個 Map 嗎?自己都可以寫一個 ThreadLocal 了,嘗試一下吧。

以上完全山寨了一個 ThreadLocal,其中中定義了一個同步 Map(為什麼要這樣?請讀者自行思考),程式碼應該非常容易讀懂。
下面用這 MyThreadLocal 再來實現一把看看。

以上程式碼其實就是將 ThreadLocal 替換成了 MyThreadLocal,僅此而已,執行效果和之前的一樣,也是正確的。

其實 ThreadLocal 可以單獨成為一種設計模式,就看你怎麼看了。

ThreadLocal 具體有哪些使用案例呢?

我想首先要說的就是:通過 ThreadLocal 存放 JDBC Connection,以達到事務控制的能力。

還是保持我一貫的 Style,用一個 Demo 來說話吧。使用者提出一個需求:當修改產品價格的時候,需要記錄操作日誌,什麼時候做了什麼事情。

想必這個案例,只要是做過應用系統的小夥伴們,都應該遇到過吧?無外乎資料庫裡就兩張表:product 與 log,用兩條 SQL 語句應該可以解決問題:

But!要確保這兩條 SQL 語句必須在同一個事務裡進行提交,否則有可能 update 提交了,但 insert 卻沒有提交。如果這樣的事情真的發生了,我們肯定會被使用者指著鼻子狂罵:“為什麼產品價格改了,卻看不到什麼時候改的呢?”。

聰明的我在接到這個需求以後,是這樣做的:

首先,我寫一個 DBUtil 的工具類,封裝了資料庫的常用操作:

裡面搞了一個 static 的 Connection,這下子資料庫連線就好操作了,牛逼吧!

然後,我定義了一個介面,用於給邏輯層來呼叫:

根據使用者提出的需求,我想這個介面完全夠用了。根據 productId 去更新對應 Product 的 price,然後再插入一條資料到 log 表中。

其實業務邏輯也不太複雜,於是我快速地完成了 ProductService 介面的實現類:

程式碼的可讀性還算不錯吧?這裡我用到了 JDBC 的高階特性 Transaction 了。暗自慶幸了一番之後,我想是不是有必要寫一個客戶端,來測試一下執行結果是不是我想要的呢? 於是我偷懶,直接在 ProductServiceImpl 中增加了一個 main() 方法:

我想讓 productId 為 1 的產品的價格修改為 3000。於是我把程式跑了一遍,控制檯輸出:

Update product success!
Insert log success!

應該是對了。作為一名專業的程式設計師,為了萬無一失,我一定要到資料庫裡在看看。沒錯!product 表對應的記錄更新了,log 表也插入了一條記錄。這樣就可以將 ProductService 介面交付給別人來呼叫了。

幾個小時過去了,QA 妹妹開始罵我:“我靠!我才模擬了 10 個請求,你這個介面怎麼就掛了?說是資料庫連線關閉了!”。

聽到這樣的叫聲,讓我渾身打顫,立馬中斷了我的小視訊,趕緊開啟 IDE,找到了這個 ProductServiceImpl 這個實現類。好像沒有 Bug 吧?但我現在不敢給她任何回應,我確實有點怕她的。

我突然想起,她是用工具模擬的,也就是模擬多個執行緒了!那我自己也可以模擬啊,於是我寫了一個執行緒類:

我用這執行緒去呼叫 ProduceService 的方法,看看是不是有問題。此時,我還要再修改一下 main() 方法:

我也模擬 10 個執行緒吧,我就不信那個邪了!

執行結果真的讓我很暈、很暈:

Thread-1
Thread-3
Thread-5
Thread-7
Thread-9
Thread-0
Thread-2
Thread-4
Thread-6
Thread-8
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
com.mysql.jdbc.exceptions.jdbc4.MySQLNonTransientConnectionException: No operations allowed after connection closed.
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:39)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:27)
at java.lang.reflect.Constructor.newInstance(Constructor.java:513)
at com.mysql.jdbc.Util.handleNewInstance(Util.java:411)
at com.mysql.jdbc.Util.getInstance(Util.java:386)
at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:1015)
at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:989)
at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:975)
at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:920)
at com.mysql.jdbc.ConnectionImpl.throwConnectionClosedException(ConnectionImpl.java:1304)
at com.mysql.jdbc.ConnectionImpl.checkClosed(ConnectionImpl.java:1296)
at com.mysql.jdbc.ConnectionImpl.commit(ConnectionImpl.java:1699)
at com.smart.sample.test.transaction.solution1.ProductServiceImpl.updateProductPrice(ProductServiceImpl.java:25)
at com.smart.sample.test.transaction.ClientThread.run(ClientThread.java:18)

我靠!竟然在多執行緒的環境下報錯了,果然是資料庫連線關閉了。怎麼回事呢?我陷入了沉思中。於是我 Copy 了一把那句報錯資訊,在百度、Google,還有 OSC 裡都找了,解答實在是千奇百怪。

我突然想起,既然是跟 Connection 有關係,那我就將主要精力放在檢查 Connection 相關的程式碼上吧。是不是 Connection 不應該是 static 的呢?我當初設計成 static 的主要是為了讓 DBUtil 的 static 方法訪問起來更加方便,用 static 變數來存放 Connection 也提高了效能啊。怎麼搞呢?

於是我看到了 OSC 上非常火爆的一篇文章《ThreadLocal 那點事兒》,終於才讓我明白了!原來要使每個執行緒都擁有自己的連線,而不是共享同一個連線,否則執行緒1有可能會關閉執行緒2的連線,所以執行緒2就報錯了。一定是這樣!

我趕緊將 DBUtil 給重構了:

我把 Connection 放到了 ThreadLocal 中,這樣每個執行緒之間就隔離了,不會相互干擾了。

此外,在 getConnection() 方法中,首先從 ThreadLocal 中(也就是 connContainer 中) 獲取 Connection,如果沒有,就通過 JDBC 來建立連線,最後再把建立好的連線放入這個 ThreadLocal 中。可以把 ThreadLocal 看做是一個容器,一點不假。

同樣,我也對 closeConnection() 方法做了重構,先從容器中獲取 Connection,拿到了就 close 掉,最後從容器中將其 remove 掉,以保持容器的清潔。

這下應該行了吧?我再次執行 main() 方法:

Thread-0
Thread-2
Thread-4
Thread-6
Thread-8
Thread-1
Thread-3
Thread-5
Thread-7
Thread-9
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!
Update product success!
Insert log success!

相關文章