你代碼里的ThreadLocalRandom,真的安全嗎?
前言
最近在寫(xiě)一些業(yè)務(wù)代碼時(shí)遇到一個(gè)需要產(chǎn)生隨機(jī)數(shù)的場(chǎng)景,這時(shí)自然想到 jdk 包里的 Random 類。但出于對(duì)性能的極致追求,就考慮使用 ThreadLocalRandom 類進(jìn)行優(yōu)化,在查看 ThreadLocalRandom 實(shí)現(xiàn)的過(guò)程中,又追了下 Unsafe 有部分代碼,整個(gè)流程下來(lái),學(xué)到了不少東西,也通過(guò)搜索和提問(wèn)解決了很多疑惑,于是總結(jié)成本文。
Random 的性能問(wèn)題
使用 Random 類時(shí),為了避免重復(fù)創(chuàng)建的開(kāi)銷,我們一般將實(shí)例化好的 Random 對(duì)象設(shè)置為我們所使用服務(wù)對(duì)象的屬性或靜態(tài)屬性,這在線程競(jìng)爭(zhēng)不激烈的情況下沒(méi)有問(wèn)題,但在一個(gè)高并發(fā)的 web 服務(wù)內(nèi),使用同一個(gè) Random 對(duì)象可能會(huì)導(dǎo)致線程阻塞。
Random 的隨機(jī)原理是對(duì)一個(gè)”隨機(jī)種子”進(jìn)行固定的算術(shù)和位運(yùn)算,得到隨機(jī)結(jié)果,再使用這個(gè)結(jié)果作為下一次隨機(jī)的種子。在解決線程安全問(wèn)題時(shí),Random 使用 CAS 更新下一次隨機(jī)的種子,可以想到,如果多個(gè)線程同時(shí)使用這個(gè)對(duì)象,就肯定會(huì)有一些線程執(zhí)行 CAS 連續(xù)失敗,進(jìn)而導(dǎo)致線程阻塞。
ThreadLocalRandom
jdk 的開(kāi)發(fā)者自然考慮到了這個(gè)問(wèn)題,在 concurrent 包內(nèi)添加了 ThreadLocalRandom 類,第一次看到這個(gè)類名,我以為它是通過(guò) ThreadLocal 實(shí)現(xiàn)的,進(jìn)而想到恐怖的內(nèi)存泄漏問(wèn)題,但點(diǎn)進(jìn)源碼卻沒(méi)有 ThreadLocal 的影子,而是存在著大量 Unsafe 相關(guān)的代碼。
我們來(lái)看一下它的核心代碼:
UNSAFE.putLong(t = Thread.currentThread(), SEED, r = UNSAFE.getLong(t, SEED) + GAMMA);
翻譯成更直觀的 Java 代碼就像:
- Thread t = Thread.currentThread();
- long r = UNSAFE.getLong(t, SEED) + GAMMA;
- UNSAFE.putLong(t, SEED, r);
看上去非常眼熟,像我們平常往 Map 里 get/set 一樣,以 Thread.currentThread() 獲取到的當(dāng)前對(duì)象里 key,以 SEED 隨機(jī)種子作為 value。
但是以對(duì)象作為 key 是可能會(huì)造成內(nèi)存泄漏的啊,由于 Thread 對(duì)象可能會(huì)大量創(chuàng)建,在回收時(shí)不 remove Map 里的 value 時(shí)會(huì)導(dǎo)致 Map 越來(lái)越大,最后內(nèi)存溢出。
Unsafe
功能
不過(guò)再仔細(xì)看 ThreadLocalRandom 類的核心代碼,發(fā)現(xiàn)并不是簡(jiǎn)單的 Map 操作,它的 getLong() 方法需要傳入兩個(gè)參數(shù),而 putLong() 方法需要三個(gè)參數(shù),查看源碼發(fā)現(xiàn)它們都是 native 方法,我們看不到具體的實(shí)現(xiàn)。兩個(gè)方法簽名分別是:
- public native long getLong(Object var1, long var2);
- public native void putLong(Object var1, long var2, long var4);
雖然看不到具體實(shí)現(xiàn),但我們可以查得到它們的功能,下面是兩個(gè)方法的功能介紹:
- putLong(object, offset, value) 可以將 object 對(duì)象內(nèi)存地址偏移 offset 后的位置后四個(gè)字節(jié)設(shè)置為 value。
- getLong(object, offset) 會(huì)從 object 對(duì)象內(nèi)存地址偏移 offset 后的位置讀取四個(gè)字節(jié)作為 long 型返回。
不安全性
作為 Unsafe 類內(nèi)的方法,它也透露著一股 “Unsafe” 的氣息,具體表現(xiàn)就是可以直接操作內(nèi)存,而不做任何安全校驗(yàn),如果有問(wèn)題,則會(huì)在運(yùn)行時(shí)拋出 Fatal Error,導(dǎo)致整個(gè)虛擬機(jī)的退出。
在我們的常識(shí)里,get 方法是最容易拋異常的地方,比如空指針、類型轉(zhuǎn)換等,但 Unsafe.getLong() 方法是個(gè)非常安全的方法,它從某個(gè)內(nèi)存位置開(kāi)始讀取四個(gè)字節(jié),而不管這四個(gè)字節(jié)是什么內(nèi)容,總能成功轉(zhuǎn)成 long 型,至于這個(gè) long 型結(jié)果是不是跟業(yè)務(wù)匹配就是另一回事了。而 set 方法也是比較安全的,它把某個(gè)內(nèi)存位置之后的四個(gè)字節(jié)覆蓋成一個(gè) long 型的值,也幾乎不會(huì)出錯(cuò)。
那么這兩個(gè)方法”不安全”在哪呢?
它們的不安全并不是在這兩個(gè)方法執(zhí)行期間報(bào)錯(cuò),而是未經(jīng)保護(hù)地改變內(nèi)存,會(huì)引起別的方法在使用這一段內(nèi)存時(shí)報(bào)錯(cuò)。
- public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
- // Unsafe 設(shè)置了構(gòu)造方法私有,getUnsafe 獲取實(shí)例方法包私有,在包外只能通過(guò)反射獲取
- Field field = Unsafe.class.getDeclaredField("theUnsafe");
- field.setAccessible(true);
- Unsafe unsafe = (Unsafe) field.get(null);
- // Test 類是一個(gè)隨手寫(xiě)的測(cè)試類,只有一個(gè) String 類型的測(cè)試類
- Test test = new Test();
- test.ttt = "12345";
- unsafe.putLong(test, 12L, 2333L);
- System.out.println(test.value);
- }
運(yùn)行上面的代碼會(huì)得到一個(gè) fatal error,報(bào)錯(cuò)信息為 “A fatal error has been detected by the Java Runtime Environment: … Process finished with exit code 134 (interrupted by signal 6: SIGABRT)”。
可以從報(bào)錯(cuò)信息中看到虛擬機(jī)因?yàn)檫@個(gè) fatal error abort 退出了,原因也很簡(jiǎn)單,我使用 unsafe 將 Test 類 value 屬性的位置設(shè)置成了 long 型值 2333,而當(dāng)我使用 value 屬性時(shí),虛擬機(jī)會(huì)將這一塊內(nèi)存解析為 String 對(duì)象,原 String 對(duì)象對(duì)象頭的結(jié)構(gòu)被打亂了,解析對(duì)象失敗拋出了錯(cuò)誤,更嚴(yán)重的問(wèn)題是報(bào)錯(cuò)信息中沒(méi)有類名行號(hào)等信息,在復(fù)雜項(xiàng)目中排查這種問(wèn)題真如同大海撈針。
不過(guò) Unsafe 的其他方法可不一定像這一對(duì)方法一樣,使用他們時(shí)可能需要注意另外的安全問(wèn)題,之后有遇到再說(shuō)。
ThreadLocalRandom 的實(shí)現(xiàn)
那么 ThreadLocalRandom 是不是安全的呢,再回過(guò)頭來(lái)看一下它的實(shí)現(xiàn)。
ThreadLocalRandom 的實(shí)現(xiàn)需要 Thread 對(duì)象的配合,在 Thread 對(duì)象內(nèi)存在著一個(gè)屬性 threadLocalRandomSeed,它保存著這個(gè)線程專屬的隨機(jī)種子,而這個(gè)屬性在 Thread 對(duì)象的 offset,是在 ThreadLocalRandom 類加載時(shí)就確定了的,具體方法是 SEED = UNSAFE.objectFieldOffset(Thread.class.getDeclaredField("threadLocalRandomSeed"));
我們知道一個(gè)對(duì)象所占用的內(nèi)存大小在類被加載后就確定了的,所以使用 Unsafe.objectFieldOffset(class, fieldName) 可以獲取到某個(gè)屬性在類中偏移量,而在找對(duì)了偏移量,又能確定數(shù)據(jù)類型時(shí),使用 ThreadLocalRandom 就是很安全的。
疑問(wèn)
在查找這些問(wèn)題的過(guò)程中,我也產(chǎn)生了兩個(gè)疑問(wèn)點(diǎn)。
使用場(chǎng)景
首先就是 ThreadLocalRandom 為什么非要使用 Unsafe 來(lái)修改 Thread 對(duì)象內(nèi)的隨機(jī)種子呢,在 Thread 對(duì)象內(nèi)添加 get/set 方法不是更方便嗎?
stackOverFlow 上有人跟我同樣的疑問(wèn),why is threadlocalrandom implemented so bizarrely,被采納的答案里解釋說(shuō),對(duì) jdk 開(kāi)發(fā)者來(lái)說(shuō) Unsafe 和 get/set 方法都像普通的工具,具體使用哪一個(gè)并沒(méi)有一個(gè)準(zhǔn)則。
這個(gè)答案并沒(méi)有說(shuō)服我,于是我另開(kāi)了一個(gè)問(wèn)題,里面的一個(gè)評(píng)論我比較認(rèn)同,大意是 ThreadLocalRandom 和 Thread 不在同一個(gè)包下,如果添加 get/set 方法的話,get/set 方法必須設(shè)置為 public,這就有違了類的封閉性原則。
內(nèi)存布局
另一個(gè)疑問(wèn)是我看到 Unsafe.objectFieldOffset 可以獲取到屬性在對(duì)象內(nèi)存的偏移量后,自己在 IDEA 里使用 main 方法試了上文中提到的 Test 類,發(fā)現(xiàn) Test 類的唯一一個(gè)屬性 value 相對(duì)對(duì)象內(nèi)存的偏移量是 12,于是比較疑惑這 12 個(gè)字節(jié)的組成。
我們知道,Java 對(duì)象的對(duì)象頭是放在 Java 對(duì)象的內(nèi)存起始處的,而一個(gè)對(duì)象的 MarkWord 在對(duì)象頭的起始處,在 32 位系統(tǒng)中,它占用 4 個(gè)字節(jié),而在 64 位系統(tǒng)中它占用 8 個(gè)字節(jié),我使用的是 64 位系統(tǒng),這毫無(wú)疑問(wèn)會(huì)占用 8 個(gè)字節(jié)的偏移量。
緊跟 MarkWord 的應(yīng)該是 Test 類的類指針和數(shù)組對(duì)象的長(zhǎng)度,數(shù)組長(zhǎng)度是 4 字節(jié),但 Test 類并非數(shù)組,也沒(méi)有其他屬性,數(shù)據(jù)長(zhǎng)度可以排除,但在 64 位系統(tǒng)下指針也應(yīng)該是 8 字節(jié)的啊,為什么只占用了 4 個(gè)字節(jié)呢?
唯一的可能性是虛擬機(jī)啟用了指針壓縮,指針壓縮只能在 64 位系統(tǒng)內(nèi)啟用,啟用后指針類型只需要占用 4 個(gè)字節(jié),但我并沒(méi)有顯示指定過(guò)使用指針壓縮。查了一下,原來(lái)在 1.8 以后指針壓縮是默認(rèn)開(kāi)啟的,在啟用時(shí)使用 -XX:-UseCompressedOops 參數(shù)后,value 的偏移量變成了 16。
小結(jié)
在寫(xiě)代碼時(shí)還是要多注意查看依賴庫(kù)的具體實(shí)現(xiàn),不然可能踩到意想不到的坑,而且多看看并沒(méi)有壞處,仔細(xì)研究一下還能學(xué)到更多。