初看滿頭問號,看懂直接跪下!

why技術發表於2022-04-14

你好呀,我是歪歪。

我最近在 stackoverflow 上看到一段程式碼,怎麼說呢。

就是初看一臉懵逼,看懂直接跪下!

我先帶你看看 stackoverflow 上的這個問題是啥,然後引出這段程式碼:

https://stackoverflow.com/que...

問題特別簡單,就一句話:

誰能給我解釋一下:為什麼這段程式碼使用隨機字串列印出了 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/que...

在這個問題裡面,他說他對這兩個數字也感到很懵逼,網上找了一圈,相關的資料非常的少。但是找到一個論文,裡面提到了其中一個很接近的“魔數”:

論文中提到的數是這樣的:

看到沒有?

這 Java 原始碼中的數字前面少了一個“1”呀,咋回事呢,該不會是拷貝的時候弄錯了吧?

下面的一個高贊回答是這樣的:

“看起來確實像是拷錯了。”

有點意思,你要說這是寫 Java 原始碼的老哥 copy 程式碼的時候手抖了,我就來勁了。

馬上去 Java Bug 的頁面上拿著那串數字搜一下,還真有意外收穫:

https://bugs.openjdk.java.net...

在這個 bug 的描述裡面,他讓我注意到了原始碼的這個地方:

原來這個地方的註釋代表著一個論文呀,那麼這個論文裡面肯定就藏著這個數的來源。

等等,我怎麼感覺這個論文的名字有點像眼熟啊?

前面 stackoverflow 中提到的這個連結,點進去就是一個論文地址:

你看看這個論文的名稱和 Java 這裡的註釋是不是說的一回事呀:

那必須是一回事啊,只是一個小寫一個大寫而已。

所以,到這裡實錘了,確實是最開始寫 Java 這塊原始碼的老哥 copy 數字的時候手抖了,少 copy 了一個 “1”。

而且我甚至都能想象到當時寫這部分原始碼的時候,那個老哥把“1181783497276652981”這個數字粘過來,發現:哎,這前面怎麼有兩個 1 啊,整重複了,刪除了吧。

至於把這個“1”刪除了之後,會帶來什麼問題呢?

反正這裡關聯了一個問題,說的是:併發呼叫 new Random() 的隨機性不夠大。

這我就沒去研究了,有興趣可以去看看,我只負責帶你吃瓜。

所以,基於這個“瓜”,官方修改了一次這個程式碼:

剛好我這裡有 JDK 15 和 JDK 8 版本的程式碼,我去看了一下,還真是差了一個 “1” :

而且關於隨機數,現在一般很少用 Random 了吧。

直接就是上 ThreadLocalRandom 了,它不香嗎?

什麼,你說不會?

相關文章