你好呀,我是歪歪。 我最近在 stackoverflow 上看到一段代碼,怎麼說呢。 就是初看一臉懵逼,看懂直接跪下! 我先帶你看看 stackoverflow 上的這個問題是啥,然後引出這段代碼: https://stackoverflow.com/questions/15182496/why-d ...
你好呀,我是歪歪。
我最近在 stackoverflow 上看到一段代碼,怎麼說呢。
就是初看一臉懵逼,看懂直接跪下!
我先帶你看看 stackoverflow 上的這個問題是啥,然後引出這段代碼:
https://stackoverflow.com/questions/15182496/why-does-this-code-using-random-strings-print-hello-world
問題特別簡單,就一句話:
誰能給我解釋一下:為什麼這段代碼使用隨機字元串列印出了 hello world?
代碼也很簡單,我把它拿出來給你跑一下:
public class MainTest {
public static void main(String[] args) {
System.out.println(randomString(-229985452) + " " + randomString(-147909649));
}
public static String randomString(int i) {
Random ran = new Random(i);
StringBuilder sb = new StringBuilder();
while (true) {
int k = ran.nextInt(27);
if (k == 0)
break;
sb.append((char) ('`' + k));
}
return sb.toString();
}
}
上面的代碼你也可以直接粘貼到你的運行環境中跑一下,看看是不是也輸出的 hello world:
我就問你:即使代碼都給你了,第一眼看到 hello world 的時候你懵不懵逼?
高贊回答
高贊回答也特別簡單,就這麼兩句話。
我給你翻譯一下,這個哥們說:
當我們調用 Random 的構造方法時,給定了一個“種子”(seed)參數。比如本例子中的:-229985452 或 -147909649。
那麼 Random 將從指定的種子值開始生成隨機數。
而每個用相同的種子構造的 Random 對象,都會按照產生相同的模式產生數字。
沒看的太明白,對不對?
沒關係,我給你上一段代碼,你就能恍然大悟上面這一段說的是啥事:
public static void main(String[] args) {
randomString(-229985452);
System.out.println("------------");
randomString(-229985452);
}
private static void randomString(int i) {
Random ran = new Random(i);
System.out.println(ran.nextInt());
System.out.println(ran.nextInt());
System.out.println(ran.nextInt());
System.out.println(ran.nextInt());
System.out.println(ran.nextInt());
}
這段代碼,在我的機器上運行結果是這樣的:
你拿過去跑,你的運行結果也一定是這樣的。
這是為什麼呢?
答案就在 Javadoc 上寫著的:
如果用相同的種子創建了兩個 Random 的實例,並且對每個實例進行了相同的方法調用序列,那麼它們將生成並返回相同的數字序列。
在上面的代碼中兩個 -229985452
就是相同的種子,而三次 nextInt()
調用,就是相同的調用序列。
所以,他們生成並返回相同的、看起來是隨機的數字。
而我們正常在程式裡面的用法應該是這樣的:
在 new Random() 的時候,不會去指定一個值。
我們都知道 Random 是一個偽隨機演算法,而構建的時候指定了 seed 參數的就是一個更加偽的偽隨機演算法了。
因為如果我可以推測出你的 seed 的話,或者你的 seed 泄露了,那麼理論上我就可以推測出你隨機數生成序列。
這個我已經在前面的代碼中演示了。
再看看問題
在前面稍微解釋了 “seed” 的關鍵之處之後,我們再回過頭去品一品這個問題,大概就能看出點端倪了。
主要看這個迴圈裡面的代碼。
首先 nextInt(27) 就限定了,當前返回的數 k 一定是在 [0,27) 之間的一個數字。
如果返回 0,那麼迴圈結束,如果不為零。則做一個類型轉換。
接下來就是一個 char 類型的強制轉換。
看到數字轉 char 類型,就應該條件反射的想到 ascii 碼:
從 ascii 碼 表中,我們可以到 “96” 就是這裡的這個符號:
所以,下麵這個代碼的範圍就是 [96+1,96+26]:
'`' + k
也就是 [97,122],即對應 ascii 碼的 a-z。
所以,我帶你再把上面的演示代碼拆解一下。
首先 new Random(-229985452).nextInt(27) 的前五個返回是這樣的:
而 new Random(-147909649).nextInt(27) 的前五個返回是這樣的:
所以,對照著 ascii 碼表看,就能看出其對應的字母了:
8 + 96 = 104 --> h
5 + 96 = 101 --> e
12 + 96 = 108 --> l
12 + 96 = 108 --> l
15 + 96 = 111 --> o
23 + 96 = 119 --> w
15 + 96 = 111 --> o
18 + 96 = 114 --> r
12 + 96 = 108 --> l
4 + 96 = 100 --> d
現在,對於這一段謎一樣的代碼為什麼輸出了 “hello world” 的原因,心裡是不是撥開雲霧見青天,心裡跟明鏡兒似的。
看穿了,也就是一個小把戲而已。
然後這個問題下麵還有個評論,讓我看到了另外一種打開方式:
你能指定列印出 hello world,那麼理論上我也能指定打出其他的單詞。
比如這個老哥就打了一個短語:the quick browny fox jumps over a lazy dog.
如果從字面上直譯過來,那麼就是“敏捷的棕色狐狸跨過懶狗”,好像也是狗屁不通的樣子。
但是,你知道的,我的 English 水平是比較 high 的,一眼就看出這個短語在這裡肯定不簡單。
於是查了一下:
果然是有點故事的,屬於 tricks in tricks。
你看學沙雕技術的時候還順便豐富了自己的英語技能,一舉多得,這一會還不得在文末給我點個贊、點個“在看”啥的?
看完這個老哥的 quick brown fox 示例之後,我又有一點新想法了。
既然它能把所有的字母都打出來,那我是不是也能把我想要的特定的短語也打出來呢?
比如 i am fine thank you and you 這樣的東西。
而查找指定單詞對應的 seed 這樣的功能的代碼,在這個問題的回答中,已經有“好事之人”幫我們寫出來了。
我就直接粘過來,你也可以直接拿去就用:
public static long generateSeed(String goal, long start, long finish) {
char[] input = goal.toCharArray();
char[] pool = new char[input.length];
label:
for (long seed = start; seed < finish; seed++) {
Random random = new Random(seed);
for (int i = 0; i < input.length; i++)
pool[i] = (char) (random.nextInt(27) + '`');
if (random.nextInt(27) == 0) {
for (int i = 0; i < input.length; i++) {
if (input[i] != pool[i])
continue label;
}
return seed;
}
}
throw new NoSuchElementException("Sorry :/");
}
那麼我要找前面提到的短語,就很簡單了:
而且運行的時候我明顯感覺到,在搜索“thank”這個單詞的時候,花了很多時間。
為什麼?
我給你講一個故事啊,只有一句話,你肯定聽過:
只要時間足夠漫長,猴子都能敲出一部《莎士比亞》。
我們這裡 generateSeed 方法,就相當於這個猴子。而 thank 這個單詞,就是《莎士比亞》。
在 generateSeed 方法裡面,通過 26 個字母不斷的排列組合,總是能排列出 “thank” 的,只是時間長短而已。
單詞越長,需要的時間就越長。
比如我來個 congratulations,這麼長的單詞,我從 00:05 分,跑了 23 個小時都還沒跑出來:
但是理論上來講只要有足夠長的時間,這個 seed 一定會被找到。
至此,你應該完全明白了為什麼前面提到的那段代碼,使用隨機字元串的方式列印出了 hello world。
源碼
你以為我要帶你讀源碼?
不是的,我主要帶你吃瓜。
首先,看一下的 Random 無參構造函數:
好家伙,原來也是套個了個“無參”的殼而已,實際上還是自己搞了一個 seed,然後調用了有參構造方法。
只是它構建的時候加入了“System.nanoTime()”這個變數,讓 seed 看起來隨機了一點而已。
等等,前面不是還有一個“seedUniquifier”方法嗎?
這個方法是這樣的:
好家伙,看到第一眼的時候我頭都大了,這裡面有兩個“魔法數”啊:
181783497276652981L
8682522807148012L
這玩意也看不懂啊?
遇事不決,stackoverflow。
一搜就能找到這個地方:
https://stackoverflow.com/questions/18092160/whats-with-181783497276652981-and-8682522807148012-in-random-java-7
在這個問題裡面,他說他對這兩個數字也感到很懵逼,網上找了一圈,相關的資料非常的少。但是找到一個論文,裡面提到了其中一個很接近的“魔數”:
論文中提到的數是這樣的:
看到沒有?
這 Java 源碼中的數字前面少了一個“1”呀,咋回事呢,該不會是拷貝的時候弄錯了吧?
下麵的一個高贊回答是這樣的:
“看起來確實像是拷錯了。”
有點意思,你要說這是寫 Java 源碼的老哥 copy 代碼的時候手抖了,我就來勁了。
馬上去 Java Bug 的頁面上拿著那串數字搜一下,還真有意外收穫:
https://bugs.openjdk.java.net/browse/JDK-8201634
在這個 bug 的描述裡面,他讓我註意到了源碼的這個地方:
原來這個地方的註釋代表著一個論文呀,那麼這個論文裡面肯定就藏著這個數的來源。
等等,我怎麼感覺這個論文的名字有點像眼熟啊?
前面 stackoverflow 中提到的這個鏈接,點進去就是一個論文地址:
你看看這個論文的名稱和 Java 這裡的註釋是不是說的一回事呀:
那必須是一回事啊,只是一個小寫一個大寫而已。
所以,到這裡實錘了,確實是最開始寫 Java 這塊源碼的老哥 copy 數字的時候手抖了,少 copy 了一個 “1”。
而且我甚至都能想象到當時寫這部分源碼的時候,那個老哥把“1181783497276652981”這個數字粘過來,發現:哎,這前面怎麼有兩個 1 啊,整重覆了,刪除了吧。
至於把這個“1”刪除了之後,會帶來什麼問題呢?
反正這裡關聯了一個問題,說的是:併發調用 new Random() 的隨機性不夠大。
這我就沒去研究了,有興趣可以去看看,我只負責帶你吃瓜。
所以,基於這個“瓜”,官方修改了一次這個代碼:
剛好我這裡有 JDK 15 和 JDK 8 版本的代碼,我去看了一下,還真是差了一個 “1” :
而且關於隨機數,現在一般很少用 Random 了吧。
直接就是上 ThreadLocalRandom 了,它不香嗎?
什麼,你說不會?