天天看點

《Java特種兵》5.2 線程安全(二)

在jmm中要求final域(屬性)的初始化動作必須在構造方法return之前完成。換言之,一個對象建立以及将其指派給一個引用是兩個動作,對象建立還需要經曆配置設定空間和屬性初始化的過程,普通的屬性初始化允許發生在構造方法return之後(指令重排序)。

似乎這個問題變得很可怕,因為在java程式中得到的對象竟然有可能還沒有執行完構造方法内的屬性指派,但在大部分情況下,對象的使用都是線上程内部定義的,在單線程中是絕對可靠的,或者說在單線程中要求使用對象引用時,該對象已經被初始化好。但如果在此過程中有另一個線程通過這個未初始化好的對象引用讀取相應的屬性,那麼就可能讀取到的并不是真正想要的值。在java中final可以保證這一點,是以它可以避免這種類型的逃逸問題。

但是它并不能完全解決所有的逃逸問題,而隻是確定在構造方法return以前是會被初始化的,無法確定不與其他的指令進行重排序,比如下面的代碼:

<code>01</code>

<code>private</code> <code>static</code> <code>testobject testobject = </code><code>null</code><code>;</code>

<code>02</code>

<code>final</code> <code>int</code> <code>a;</code>

<code>03</code>

<code>public</code> <code>構造方法() {</code>

<code>04</code>

<code>    </code><code>a = </code><code>100</code><code>;</code>

<code>05</code>

<code>    </code><code>testobject = </code><code>this</code><code>;          </code><code>//這個地方可能和a=100發生指令重排序</code>

<code>06</code>

<code>}</code>

<code>07</code>

<code>public</code> <code>static</code> <code>void</code> <code>read() {</code>

<code>08</code>

<code>    </code><code>if</code><code>(testobject != </code><code>null</code><code>) {</code>

<code>09</code>

<code>    </code><code>//對變量testobject.a做操作</code>

<code>10</code>

<code>    </code><code>}</code>

<code>11</code>

如果有另一個線程調用靜态方法read(),則可能得到testobject非空值,而此時有可能a=100這個動作還未執行(因為它可以與testobject = this進行重排序),那麼操作的資料就将是錯誤的。

進一步探讨:如果final所修飾的不是普通變量,而是數組、對象,那麼它能保證自己本身的初始化在其外部對象的構造方法傳回之前,但是它本身作為對象,對内部的屬性是無法保證的。如果是某些具有标志性的屬性,則需要根據實際情況做進一步處理,才可以達到線程安全的目的。

經過jsr-133對final進行語義增強後,我們就可以比較放心地使用final文法了。但是我們想看看構造方法還沒做完,變量是什麼樣子呢?普通變量和final變量到底又有什麼差別呢?下面我們就寫一段和并發程式設計沒多大關系的代碼來跑一跑看看。

代碼清單5-7 構造方法未結束,看看屬性是什麼樣子

<code>public</code> <code>class</code> <code>finalconstructortest {</code>

<code>    </code><code>static</code> <code>abstract</code> <code>class</code> <code>a {</code>

<code>        </code><code>public</code> <code>a() {</code>

<code>            </code><code>display();</code>

<code>        </code><code>}</code>

<code>        </code><code>public</code> <code>abstract</code> <code>void</code> <code>display();</code>

<code>12</code>

<code>    </code><code>static</code> <code>class</code> <code>b </code><code>extends</code> <code>a {</code>

<code>13</code>

<code>14</code>

<code>        </code><code>private</code> <code>int</code> <code>int = </code><code>100</code><code>;</code>

<code>15</code>

<code>16</code>

<code>        </code><code>private</code> <code>final</code> <code>int</code> <code>final_int = </code><code>100</code><code>;</code>

<code>17</code>

<code>18</code>

<code>        </code><code>private</code> <code>final</code> <code>integer final_integer = </code><code>100</code><code>;</code>

<code>19</code>

<code>20</code>

<code>        </code><code>private</code> <code>string str1 = </code><code>"abc"</code><code>;</code>

<code>21</code>

<code>22</code>

<code>        </code><code>private</code> <code>final</code> <code>string final_str1 = </code><code>"abc"</code><code>;</code>

<code>23</code>

<code>24</code>

<code>        </code><code>private</code> <code>final</code> <code>string final_str2 = </code><code>new</code> <code>string(</code><code>"abc"</code><code>);</code>

<code>25</code>

<code>26</code>

<code>        </code><code>private</code> <code>final</code> <code>list&lt;string&gt; final_list = </code><code>new</code> <code>arraylist&lt;string&gt;();</code>

<code>27</code>

<code>28</code>

<code>        </code><code>public</code> <code>b() {</code>

<code>29</code>

<code>            </code><code>super</code><code>();</code>

<code>30</code>

<code>            </code><code>system.out.println(</code><code>"abc"</code><code>);</code>

<code>31</code>

<code>32</code>

<code>33</code>

<code>        </code><code>public</code> <code>void</code> <code>display() {</code>

<code>34</code>

<code>            </code><code>system.out.println(int);</code>

<code>35</code>

<code>            </code><code>system.out.println(final_int);</code>

<code>36</code>

<code>            </code><code>system.out.println(final_integer);</code>

<code>37</code>

<code>            </code><code>system.out.println(str1);</code>

<code>38</code>

<code>            </code><code>system.out.println(final_str1);</code>

<code>39</code>

<code>            </code><code>system.out.println(final_str2);</code>

<code>40</code>

<code>            </code><code>system.out.println(final_list);</code>

<code>41</code>

<code>42</code>

<code>43</code>

<code>44</code>

<code>    </code><code>public</code> <code>static</code> <code>void</code> <code>main(string []args) {</code>

<code>45</code>

<code>        </code><code>new</code> <code>b();</code>

<code>46</code>

<code>47</code>

在這段代碼中,我們跳開了構造方法傳回之前對final的初始化動作,而是在構造方法内部去輸出這些final屬性。這段代碼的輸出結果可能會讓我們意想不到,大家可以自行測試,如果在測試過程中使用斷點跟蹤去檢視這些資料的值,則可能在斷點中看到的值與實際輸出的值還會有所差別,因為斷點也是通過另一個線程去看對象的屬性值的,看到的對象可能正好是沒有初始化好的對象。

這樣看來,volatile和final在程式中必不可少嗎?當然不是!

如果每個屬性都使用這樣的修飾符,那麼系統就沒有必要設定這樣的修飾符了!其實它們是有一定的性能開銷的!我們關注的是代碼是否真的有并發問題,如果資料本身就是某些隻讀資料,或者這些java對象本身就是線程所私有的局部變量或類似于threallocal的變量,那麼就沒有必要使用這些修飾符了。

提到final,我們再補充一個相關的話題(該話題與并發程式設計無關)。當在方法中使用匿名内部類時,匿名内部類的方法要直接使用外部方法中的局部變量,這個局部變量必須用final來聲明才可以被使用。很多人會問這到底是為什麼?僞代碼如下:

<code>1</code>

<code>public</code> <code>void</code> <code>test() {</code>

<code>2</code>

<code>    </code><code>final</code> <code>int</code> <code>a = </code><code>100</code><code>;</code><code>//這個a必須定義為final,才能被匿名子類直接使用</code>

<code>3</code>

<code>    </code><code>new</code> <code>a() {</code>

<code>4</code>

<code>5</code>

<code>            </code><code>system.out.println(a);</code>

<code>6</code>

<code>7</code>

<code>8</code>

<code>    </code><code>其他操作</code>

<code>9</code>

這其實是對一個文法的疑問,本身沒什麼好解釋的,但是如果非要解釋,或許我們可以從這個角度來了解:jvm本身也是一種軟體平台,它設計了一種文法規則,自然也有它的局限性和初衷。在編譯時,這個地方會自動生成一個匿名内部類,而本地變量a的作用域是在方法test()中,它如何能用到另一個内部類中呢?

其中一種方式是參數傳遞;另一種方式就是作為一個屬性存在于匿名内部類對象中。不論哪一種方式都會在這個匿名内部類對象中有一份資料拷貝(如果本地變量是引用,那麼拷貝将是引用的值),不過這似乎與外部定義本地變量時是否定義為final沒有關系。

jvm在設計時并不知道我們的代碼會怎麼寫,或者說它不明确在所建立的匿名内部類中到底會做什麼,例如在代碼中完全可以在内部建立一個線程來使用這個變量,或者建立一個任務送出給線程池來使用這個變量。如果是這樣,匿名内部類的運作将與該方法本身的運作處于兩個線程中,當外部方法可能已經結束時,那麼相應的局部變量的作用域已經結束,自動會被回收,要保證匿名内部類一直可以使用該變量,就隻能用拷貝的方法,這似乎還是與final沒有多大關系。

我們現在回過頭來回答:為何外部方法必須使用final定義這個這個變量?我們将上述結論反過來想,如果這個屬性不是final修飾的,在匿名内部類中使用“同名”的變量操作,并且可以對它做任意修改,自然外部也應當能感覺到。但是事實上不能感覺到,因為這是一份資料拷貝,就像傳遞的參數一樣,不是原來的資料。胖哥認為這種文法的設計手段是為了避免誤解,文法上強制限制它為final修飾。

如果這裡的a是一個引用,那麼就隻會拷貝引用值,而不是整個對象的内容,在内部修改引用所指向的對象不會影響外部,外部的final也無法限制其改變,但如果改變其對象内部的屬性,隻要外部還會使用這個對象,那麼就會受到影響。

棧封閉算是一種概念,也就是線程操作的資料都是私有的,不會與其他的線程共享資料。簡單來說,如果每個線程所通路的jvm區域是隔離的,那麼這個系統就像一個單線程系統一樣簡單了,我們把它叫作棧封閉。這樣說是不是有點抽象,下面講實際的例子。

通常在做web開發時不需要自己去關注多線程的各種内在,因為容器給我們做好了,從前端請求就給業務處理配置設定好了資料,我們無須關注那些并發的過程,web容器會自動給我們提供私有的reqeust、response對象的處理,因為它不會被其他的線程所占用,是以可以放心使用,它是線程絕對安全的(當使用session或servletcontext時,它也在内部給你封裝好了并發的控制)。

但這并不意味着程式永遠不關注多線程與異步的問題,當需要并發去通路某些共享緩存資料時,當需要去操作共享檔案資料時,當自定義多線程去并發做一些任務時,都必然會用到這些基本的知識體系來作為支撐,否則代碼出現某些詭異的問題還不知道怎麼回事。

比如在一個項目中,使用了spring注入的dao層,大家都應該知道spring生成的對象預設是“單例”的,也就是一個類隻會生成一個對應的執行個體。在這個dao的許多方法中,使用stringbuilder進行sql拼接,在dao裡面定義了stringbuilder,認為這個變量可以提供給許多的dao層的方法共同使用,以節約空間,每個相應的方法都是對它做一個或多個append操作後,通過tostring()擷取結果string對象,最後再把這個stringbuilder清空。

先抛開并發本身的問題,這樣做也根本節約不了什麼空間,因為這個對象将擁有與這個dao一樣永久的生命周期占用記憶體,由于dao是“單例”的,是以相當于永久的生命周期。我們節約空間的方式通常是希望它短命,在young空間就幹掉它,而不是讓它共享。

繼續看有什麼并發的問題。雖然這個stringbuilder不是static修飾的,但由于它所在的這個dao對象執行個體是“單例”的,由spring控制生成,是以它幾乎等價于全局對象。它至少會在這個類裡面的所有方法被通路時共享,就算這個類裡面隻有一個方法也會有并發問題(因為同一個方法是可以被多個線程同時通路的,為何?因為它是代碼段,程式運作時隻需要從這裡擷取到指令清單即可,或者反過來了解,如果所有的代碼都不能并行通路,那麼多線程程式就完全被串行化了)。

如果資料區域發生共享就有問題了,多個線程可能在同時改一個資料段,這樣沒有任何安全政策的資料段,最終結果會是什麼樣子,誰也不清楚。本例中提到的stringbuilder就是一個共享的資料區域,假如有兩個線程在append(),然後一個線程tostring()得到的結果将有可能是兩個線程共同寫入的資料,它在操作完成後可能還會将資料清空,那麼另一個線程就可能拿到一個空字元串甚至于更加詭異的結果。這樣的程式很明顯是有問題的。

如果改成stringbuffer,是否可行?

答曰:stringbuffer是同步的,但是并不代表它在業務上是絕對安全的,認為它安全是因為它在每一次做append()類似操作時都會加上synchronized的操作,但是在實際的程式中是可以對stringbuffer進行多次append()操作的,在這些append()操作之間可能還會有其他的代碼步驟,stringbuffer可以保證每次append()操作是線程安全的,但它無法保證多線程通路時進行多次append()也能得到理想的結果。

難道我們還要在外層加一個鎖來控制?

如果是這樣的話,新的問題就出現了,這個類所有的方法通路到這裡都是串行的,如果所有的dao層都是這樣的情況,抛開鎖本身的開銷,此時系統就像單線程系統一樣在運作,外部的并發通路到來時,系統将奇慢無比。如果通路過大,就會堆積大量的線程阻塞,以及線程所持有的上下文無法釋放,而且會越堆積越多,後果可想而知。

鎖的開銷是巨大的,它對于并發程式設計中的性能是十分重要的,于是許多大牛開始對無鎖化的問題有了追求,或者說盡量靠近無鎖化。在大多數情況下我們希望事情是樂觀的,希望使用盡量細粒度化的鎖機制,不過對于大量循環調用鎖的情況會反過來使用粗粒度化的鎖機制,因為加鎖的開銷本身也是巨大的。

關于棧封閉,除了使用局部變量外,還有一種方式就是使用threadlocal,threadlocal使用一種變通的方式來達到棧封閉的目的,具體的請參看下一小節的内容。

雖然threadlocal與并發問題相關,但是許多程式員僅僅将它作為一種用于“友善傳參”的工具,胖哥認為這也許并不是threadlocal設計的目的,它本身是為線程安全和某些特定場景的問題而設計的。

threadlocal是什麼呢!

每個threadlocal可以放一個線程級别的變量,但是它本身可以被多個線程共享使用,而且又可以達到線程安全的目的,且絕對線程安全。

例如:

<code>public</code> <code>final</code> <code>static</code> <code>threadlocal&lt;string&gt; resource = </code><code>new</code> <code>threadlocal&lt;string&gt;();</code>

resource代表一個可以存放string類型的threadlocal對象,此時任何一個線程可以并發通路這個變量,對它進行寫入、讀取操作,都是線程安全的。比如一個線程通過resource.set(“aaaa”);将資料寫入threadlocal中,在任何一個地方,都可以通過resource.get();将值擷取出來。

但是它也并不完美,有許多缺陷,就像大家依賴于它來做參數傳遞一樣,接下來我們就來分析它的一些不好的地方。

為什麼有些時候會将threadlocal作為友善傳遞參數的方式呢?例如當許多方法互相調用時,最初的設計可能沒有想太多,有多少個參數就傳遞多少個變量,那麼整個參數傳遞的過程就是零散的。進一步思考:若a方法調用b方法傳遞了8個參數,b方法接下來調用c方法-&gt;d方法-&gt;e方法-&gt;f方法等隻需要5個參數,此時在設計api時就涉及5個參數的入口,這些方法在業務發展的過程中被許多地方所複用。

某一天,我們發現f方法需要加一個參數,這個參數在a方法的入口參數中有,此時,如果要改中間方法牽涉面會很大,而且不知道修改後會不會有bug。作為程式員的我們可能會随性一想,threadlocal反正是全局的,就放這裡吧,确實好解決。

但是此時你會發現系統中這種方式有點像在貼更新檔,越貼越多,我們必須要求調用相關的代碼都使用threadlocal傳遞這個參數,有可能會搞得亂七八糟的。換句話說,并不是不讓用,而是我們要明确它的入口和出口是可控的。

詭異的threadlocal最難琢磨的是“作用域”,尤其是在代碼設計之初很亂的情況下,如果再增加許多threadlocal,系統就會逐漸變成神龍見首不見尾的情況。有了這樣一個省事的東西,可能許多小夥伴更加不在意設計,因為大家都認為這些問題都可以通過變化的手段來解決。胖哥認為這是一種惡性循環。

對于這類業務場景,應當提前有所準備,需要粗粒度化業務模型,即使要用threadlocal,也不是加一個參數就加一個threadlocal變量。例如,我們可以設計幾種對象來封裝入口參數,在接口設計時入口參數都以對象為基礎。

也許一個類無法表達所有的參數意思,而且那樣容易導緻強耦合。

通常我們按照業務模型分解為幾大類型對象作為它們的參數包裝,并且将按照對象屬性共享情況進行抽象,在繼承關系的每一個層次各自擴充相應的參數,或者說加參數就在對象中加,共享參數就在父類中定義,這樣的參數就逐漸規範化了。

我們回到正題,探讨一下threadlocal到底是用來做什麼的?為此我們探讨下文中的幾個話題。

(1)應用場景及使用方式

為了說明threadlocal的應用場景,我們來看一個架構的例子。spring的事務管理器通過aop切入業務代碼,在進入業務代碼前,會根據對應的事務管理器提取出相應的事務對象,假如事務管理器是datasourcetransactionmanager,就會從datasource中擷取一個連接配接對象,通過一定的包裝後将其儲存在threadlocal中。并且spring也将datasource進行了包裝,重寫了其中的getconnection()方法,或者說該方法的傳回将由spring來控制,這樣spring就能讓線程内多次擷取到的connection對象是同一個。

為什麼要放在threadlocal裡面呢?因為spring在aop後并不能向應用程式傳遞參數,應用程式的每個業務代碼是事先定義好的,spring并不會要求在業務代碼的入口參數中必須編寫connection的入口參數。此時spring選擇了threadlocal,通過它保證連接配接對象始終線上程内部,任何時候都能拿到,此時spring非常清楚什麼時候回收這個連接配接,也就是非常清楚什麼時候從threadlocal中删除這個元素(在9.2節中會詳細講解)。

從spring事務管理器的設計上可以看出,spring利用threadlocal得到了一個很完美的設計思路,同時它在設計時也十厘清楚threadlocal中元素應該在什麼時候删除。由此,我們簡單地認為threadlocal盡量使用在一個全局的設計上,而不是一種打更新檔的間接方法。

了解了基本應用場景後,接下來看一個例子。定義一個類用于存放靜态的threadlocal對象,通過多個線程并行地對threadlocal對象進行set、get操作,并将值進行列印,來看看每個線程自己設定進去的值和取出來的值是否是一樣的。代碼如下:

代碼清單5-8 簡單的threadlocal例子

<code>public</code> <code>class</code> <code>threadlocaltest {</code>

<code>    </code><code>static</code> <code>class</code> <code>resourceclass {</code>

<code>        </code><code>public</code> <code>final</code> <code>static</code> <code>threadlocal&lt;string&gt; resource_1 =</code>

<code>                                       </code><code>new</code> <code>threadlocal&lt;string&gt;();</code>

<code>        </code><code>public</code> <code>final</code> <code>static</code> <code>threadlocal&lt;string&gt; resource_2 =</code>

<code>    </code><code>static</code> <code>class</code> <code>a {</code>

<code>        </code><code>public</code> <code>void</code> <code>setone(string value) {</code>

<code>            </code><code>resourceclass.resource_1.set(value);</code>

<code>        </code><code>public</code> <code>void</code> <code>settwo(string value) {</code>

<code>            </code><code>resourceclass.resource_2.set(value);</code>

<code>    </code><code>static</code> <code>class</code> <code>b {</code>

<code>            </code><code>system.out.println(resourceclass.resource_1.get()</code>

<code>                        </code><code>+ </code><code>":"</code> <code>+ resourceclass.resource_2.get());</code>

<code>        </code><code>final</code> <code>a a = </code><code>new</code> <code>a();</code>

<code>        </code><code>final</code> <code>b b = </code><code>new</code> <code>b();</code>

<code>        </code><code>for</code><code>(</code><code>int</code> <code>i = </code><code>0</code> <code>; i &lt; </code><code>15</code> <code>; i ++) {</code>

<code>            </code><code>final</code> <code>string resouce1 = </code><code>"線程-"</code> <code>+ i;</code>

<code>            </code><code>final</code> <code>string resouce2 = </code><code>" value = ("</code> <code>+ i + </code><code>")"</code><code>;</code>

<code>            </code><code>new</code> <code>thread() {</code>

<code>                </code><code>public</code> <code>void</code> <code>run() {</code>

<code>                </code><code>try</code> <code>{</code>

<code>                    </code><code>a.setone(resouce1);</code>

<code>                    </code><code>a.settwo(resouce2);</code>

<code>                    </code><code>b.display();</code>

<code>                </code><code>}</code><code>finally</code> <code>{</code>

<code>                    </code><code>resourceclass.resource_1.remove();</code>

<code>                    </code><code>resourceclass.resource_2.remove();</code>

<code>                </code><code>}</code>

<code>            </code><code>}</code>

<code>48</code>

<code>        </code><code>}.start();</code>

<code>49</code>

<code>50</code>

<code>51</code>

關于這段代碼,我們先說幾點。

◎ 定義了兩個threadlocal變量,最終的目的就是要看最後兩個值是否能對應上,這樣才有機會證明threadlocal所儲存的資料可能是線程私有的。

◎ 使用兩個内部類隻是為了使測試簡單,友善大家直覺了解,大家也可以将這個例子的代碼拆分到多個類中,得到的結果是相同的。

◎ 測試代碼更像是為了友善傳遞參數,因為它确實傳遞參數很友善,但這僅僅是為了測試。

◎ 在finally裡面有remove()操作,是為了清空資料而使用的。為何要清空資料,在後文中會繼續介紹細節。

測試結果如下:

線程-6: value = (6)

線程-9: value = (9)

線程-0: value = (0)

線程-10: value = (10)

線程-12: value = (12)

線程-14: value = (14)

線程-11: value = (11)

線程-3: value = (3)

線程-5: value = (5)

線程-13: value = (13)

線程-2: value = (2)

線程-4: value = (4)

線程-8: value = (8)

線程-7: value = (7)

線程-1: value = (1)

大家可以看到輸出的線程順序并非最初定義線程的順序,理論上可以說明多線程應當是并發執行的,但是依然可以保持每個線程裡面的值是對應的,說明這些值已經達到了線程私有的目的。

不是說共享變量無法做到線程私有嗎?它又是如何做到線程私有的呢?這就需要我們知道一點點原理上的東西,否則用起來也沒那麼放心,請看下面的介紹。

(2)threadlocal内在原理

從前面的操作可以發現,threadlocal最常見的操作就是set、get、remove三個動作,下面來看看這三個動作到底做了什麼事情。首先看set操作,源碼片段如圖5-5所示。

《Java特種兵》5.2 線程安全(二)

圖5-5 threadlcoal.set源碼片段

圖5-5中的第一條代碼取出了目前線程t,然後調用getmap(t)方法時傳入了目前線程,換句話說,該方法傳回的threadlocalmap和目前線程有點關系,我們先記錄下來。進一步判定如果這個map不為空,那麼設定到map中的key就是this,值就是外部傳入的參數。這個this是什麼呢?就是定義的threadlocal對象。

代碼中有兩條路徑需要追蹤,分别是getmap(thread)和createmap(thread , t)。首先來看看getmap(t)操作,如圖5-6所示。

圖5-6 getmap(thread)操作

《Java特種兵》5.2 線程安全(二)

在這裡,我們看到threadlocalmap其實就是線程裡面的一個屬性,它在thread類中的定義是:

threadlocal.threadlocalmap threadlocals = null;

這種方法很容易讓人混淆,因為這個threadlocalmap是threadlocal裡面的内部類,放在了thread類裡面作為一個屬性而存在,threadlocal本身成為這個map裡面存放的key,使用者輸入的值是value。太亂了,理不清楚了,畫個圖來看看(見圖5-7)。

簡單來講,就是這個map對象在thread裡面作為私有的變量而存在,是以是線程安全的。threadlocal通過thread.currentthread()擷取目前的線程就能得到這個map對象,同時将自身作為key發起寫入和讀取,由于将自身作為key,是以一個threadlocal對象就能存放一個線程中對應的java對象,通過get也自然能找到這個對象。

圖5-7 thread與threadlocal的僞代碼關聯關系

《Java特種兵》5.2 線程安全(二)

如果還沒有了解,則可以将思維放寬一點。當定義變量string a時,這個“a”其實隻是一個名稱(在第3章中已經說到了常量池),虛拟機需要通過符号表來找到相應的資訊,而這種方式正好就像一種k-v結構,底層的處理方式也确實很接近這樣,這裡的處理方式是顯式地使用map來存放資料,這也是一種實作手段的變通。

現在有了思路,繼續回到上面的話題,為了驗證前面的推斷和了解,來看看createmap方法的細節,如圖5-8所示。

《Java特種兵》5.2 線程安全(二)

圖5-8 createmap操作

這段代碼是執行一個建立新的map的操作,并且将第一個值作為這個map的初始化值,由于這個map是線程私有的,不可能有另一個線程同時也在對它做put操作,是以這裡的指派和初始化是絕對線程安全的,也同時保證了每一個外部寫入的值都将寫入到map對象中。

最後來看看get()、remove()代碼,或許看到這裡就可以認定我們的理論是正确的,如圖5-9所示。

《Java特種兵》5.2 線程安全(二)

圖5-9 get()/remove()方法的代碼片段

給我們的感覺是,這樣實作是一種技巧,而不是一種技術。

其實是技巧還是技術完全是從某種角度來看的,或者說是從某種抽象層次來看的,如果這段代碼在c++中實作,難道就叫技術,不是技巧了嗎?當然不是!胖哥認為技術依然是建立在思想和方法基礎上的,隻是看實作的抽象層次在什麼級别。就像在本書中多個地方探讨的一些基礎原理一樣,我們探讨了它的思想,其實它的實作也是基于某種技巧和手段的,隻是對程式封裝後就變成了某種文法和api,是以胖哥認為,一旦學會使用技巧思考問題,就學會了通過技巧去看待技術本身。我們應當通過這種設計,學會一種變通和發散的思維,學會了解各種各樣的場景,這樣便可以積累許多真正的财富,這些财富不是通過某些工具的使用或測試就可以獲得的。

threadlocal的這種設計很完美嗎?

不是很完美,它依然有許多坑,在這裡對它容易誤導程式員當成傳參工具就不再多提了,下面我們來看看它的使用不當會導緻什麼技術上的問題。

(3)threadlocal的坑

通過上面的分析,我們可以認識到threadlocal其實是與線程綁定的一個變量,如此就會出現一個問題:如果沒有将threadlocal内的變量删除(remove)或替換,它的生命周期将會與線程共存。是以,threadlocal的一個很大的“坑”就是當使用不當時,導緻使用者不知道它的作用域範圍。

大家可能認為線程結束後threadlocal應該就回收了,如果線程真的登出了确實是這樣的,但是事實有可能并非如此,例如線上程池中對線程管理都是采用線程複用的方法(web容器通常也會采用線程池),線上程池中線程很難結束甚至于永遠不會結束,這将意味着線程持續的時間将不可預測,甚至與jvm的生命周期一緻。那麼相應的threadlocal變量的生命周期也将不可預測。

也許系統中定義少量幾個threadlocal變量也無所謂,因為每次set資料時是用threadlocal本身作為key的,相同的key肯定會替換原來的資料,原來的資料就可以被釋放了,理論上不會導緻什麼問題。但世事無絕對,如果threadlocal中直接或間接包裝了集合類或複雜對象,每次在同一個threadlocal中取出對象後,再對内容做操作,那麼内部的集合類和複雜對象所占用的空間可能會開始膨脹。

抛開代碼本身的問題,舉一個極端的例子。如果不想定義太多的threadlocal變量,就用一個hashmap來存放,這貌似沒什麼問題。由于threadlocal在程式的任何一個地方都可以用得到,在某些設計不當的代碼中很難知道這個hashmap寫入的源頭,在代碼中為了保險起見,通常會先檢查這個hashmap是否存在,若不存在,則建立一個hashmap寫進去;若存在,通常也不會替換掉,因為代碼編寫者通常會“害怕”因為這種替換會丢掉一些來自“其他地方寫入hashmap的資料”,進而導緻許多不可預見的問題。

在這樣的情況下,hashmap第一次放入threadlocal中也許就一直不會被釋放,而這個hashmap中可能開始存放許多key-value資訊,如果業務上存放的key值在不斷變化(例如,将業務的id作為key),那麼這個hashmap就開始不斷變長,并且很可能在每個線程中都有一個這樣的hashmap,逐漸地形成了間接的記憶體洩漏。曾經有很多人吃過這個虧,而且吃虧的時候發現這樣的代碼可能不是在自己的業務系統中,而是出現在某些二方包、三方包中(開源并不保證沒有問題)。

要處理這種問題很複雜,不過首先要保證自己編寫的代碼是沒問題的,要保證沒問題不是說我們不去用threadlocal,甚至不去學習它,因為它肯定有其應用價值。在使用時要明白threadlocal最難以捉摸的是“不知道哪裡是源頭”(通常是代碼設計不當導緻的),隻有知道了源頭才能控制結束的部分,或者說我們從設計的角度要讓threadlocal的set、remove有始有終,通常在外部調用的代碼中使用finally來remove資料,隻要我們仔細思考和抽象是可以達到這個目的的。有些是二方包、三方包的問題,對于這些問題我們需要學會的是找到問題的根源後解決,關于二方包、三方包的運作跟蹤,可參看第3.7.9節介紹的btrace工具。

補充:在任何異步程式中(包括異步i/o、非阻塞i/o),threadlocal的參數傳遞是不靠譜的,因為線程将請求發送後,就不再等待遠端傳回結果繼續向下執行了,真正的傳回結果得到後,處理的線程可能是另一個。