硬核 - Java 隨機數相關 API 的演進與思考(上)

乾貨滿滿張雜湊發表於2022-01-10

本系列將 Java 17 之前的隨機數 API 以及 Java 17 之後的統一 API 都做了比較詳細的說明,並且將隨機數的特性以及實現思路也做了一些簡單的分析,幫助大家明白為何會有這麼多的隨機數演算法,以及他們的設計思路是什麼。

本系列會分為兩篇,第一篇講述 Java 隨機數演算法的演變思路以及底層原理與考量,之後介紹 Java 17 之前的隨機演算法 API 以及測試效能,第二篇詳細分析 Java 17 之後的隨機數生成器演算法以及 API 和底層實現類以及他們的屬性,效能以及使用場景,如何選擇隨機演算法等等,並對 Java 的隨機數對於 Java 的一些未來特性的適用進行展望

這是第一篇

如何生成隨機數

我們一般使用隨機數生成器的時候,都認為隨機數生成器(Pseudo Random Number Generator, PRNG)是一個黑盒:

image

這個黑盒的產出,一般是一個數字。假設是一個 int 數字。這個結果可以轉化成各種我們想要的型別,例如:如果我們想要的的其實是一個 long,那我們可以取兩次,其中一次的結果作為高 32 位,另一次結果作為低 32 位,組成一個 long(boolean,byte,short,char 等等同理,取一次,取其中某幾位作為結果)。如果我們想要的是一個浮點型數字,那麼我們可以根據 IEEE 標準組合多次取隨機 int 然後取其中某幾位組合成浮點型數字的整數位以及小數位。

如果要限制範圍,最簡單的方式是將結果取餘 + 偏移實現。例如我們想取範圍在 1 ~ 100 之間,那麼我們就將結果先對 99 取餘,然後取絕對值,然後 +1 即可。當然,由於取餘操作是一個效能消耗比較高的操作,最簡單的優化即檢查這個數字 N 與 N-1 取與運算,如果等於 0 即這個書是 2 的 n 次方(2 的 n 次方 2 進製表示一定是 100000 這樣的,減去 1 之後 為 011111,取與肯定是 0);對於 2 的 n 次方取餘相當於對 2 的 n 次方減一取與運算。這是一個簡單的優化, 實際的優化要比這個複雜多。

初始化這個黑盒的時候,一般採用一個 SEED 進行初始化,這個 SEED 的來源可能多種多樣,這個我們先按下不表,先來看一些這個黑盒中的一些演算法。

image

線性同餘演算法

首先是最常見的隨機數演算法:線性同餘(Linear Congruential Generator)。即根據當前 Seed 乘以一個係數 A,然後加上一個偏移 B,最後按照 C 進行取餘(限制整體在一定範圍內,這樣才能選擇出合適的 A 和 B,為什麼要這麼做後面會說),得出隨機數,然後這個隨機數作為下次隨機的種子,即:

X(n+1) = ( A * X(n) + B ) % C

這種演算法的優勢在於,實現簡單,並且效能算是比較好的。 A,B 取值必須精挑細算,讓在 C 範圍內的所有數字都是等可能的出現的。例如一個極端的例子就是 A = 2, B = 2, C = 10,那麼 1,3,5,7,9 這些奇數在後續都不可能出現。為了能計算出一個合適的 A 和 B,要限制 C 在一個比較可控的範圍內。一般為了計算效率,將 C 限制為 2 的 n 次方。這樣取餘運算就可以優化為取與運算。不過好在,數學大師們已經將這些值(也就是魔法數)找到了,我們直接用就好了。

這種演算法生成的隨機序列,是確定的,例如 X 下一個是 Y, Y 下一個是 Z,這可以理解成一個確定環(loop)。
image

這個環的大小,即 Period。由於 Period 足夠大,初始 SEED 一般也是每次不一樣的,這樣近似做到了隨機。但是,假設我們需要多個隨機數生成器的時候,就比較麻煩了,因為我們雖然能保證每個隨機生成器的初始 SEED 不一樣,但是在這種演算法下,無法保證某個隨機數生成器的初始 SEED 就是另一個隨機數生成器初始 SEED 的下一個(或者很短步驟內的) SEED。舉個例子,假設某個隨機數生成器的初始 SEED 是 X,另一個是 Z,雖然 X 和 Z 可能看上去差距很大,但是他們在這個演算法的隨機序列中僅隔了一個 Y。這樣的不同的隨機數生成器,效果不好

那麼如何能保證不同的隨機數生成器之間間隔比較大呢?也就是,我們能通過簡單計算(而不是計算 100w 次從而調到 100w 次之後的隨機數)直接使另一個隨機數生成器的初始 SEED 與當前這個的初始 SEED,間隔一個比較大的數,這種性質叫做可跳躍性基於線性反饋移位暫存器演算法的 Xoshiro 演算法給我們提供了一種可跳躍的隨機數演算法

線性反饋移位暫存器演算法

線性反饋移位暫存器(Linear feedback shift register,LFSR)是指給定前一狀態的輸出,將該輸出的線性函式再用作輸入的移位暫存器。異或運算是最常見的單位元線性函式:對暫存器的某些位進行異或操作後作為輸入,再對暫存器中的每個 bit 進行整體移位。

但是如何選擇這些 Bit,是一門學問,目前比較常見的實現是 XorShift 演算法以及在此基礎上進一步優化的
Xoshiro 的相關演算法。Xoshiro 演算法是一種比較新的優化隨機數演算法,計算很簡單並且效能優異。同時實現了可跳躍性。

這種演算法是可跳躍的。假設我們要生成兩個差距比較大的隨機數生成器,我們可以使用一個隨機初始 SEED 建立一個隨機數生成器,然後利用演算法的跳躍操作,直接生成一個間隔比較大的 SEED 作為另一個隨機數生成器的初始 SEED。

image

還有一點比較有意思的是,線性同餘演算法並不可逆,我們只能通過 X(n) 推出 X(n + 1),而不能根據 X(n + 1) 直接推出 X(n)。這個操作對應的業務例如隨機播放歌單,上一首下一首,我們不需要記錄整個歌單,而是僅根據當前的隨機數就能知道。線性反饋移位暫存器演算法能實現可逆

線性反饋移位暫存器演算法在生成不同的隨機序列生成器也有侷限性,即它們還是來自於同一個環,即使通過跳躍操作讓不同的隨機數生成器都間隔開了,但是如果壓力不夠均衡,隨著時間的推移,它們還是有可能 SEED,又變成一樣的了。那麼有沒有那種能生成不同隨機序列環的隨機演算法呢

DotMix 演算法

DotMix 演算法提供了另一種思路,即給定一個初始 SEED,設定一個固定步長 M,每次隨機,將這個 SEED 加上步長 M,經過一個 HASH 函式,將這個值雜湊對映到一個 HASH 值:

X(n+1) = HASH(X(n) + M)

這個演算法對於 HASH 演算法的要求比較高,重點要求 HASH 演算法針對輸入的一點改變則造成輸出大幅度改變。基於 DotMix 演算法的 SplitMix 演算法使用的即 MurMurHash3 演算法,這個即 Java 8 引入的 SplittableRandom 的底層原理。

這種演算法好在,我們很容易能明確兩個不同引數的隨機生成器他們的生成序列是不同的,例如一個生成的隨機序列是 1,4,3,7,... 另一個生成的是 1,5,3,2。這點正是線性同餘演算法無法做到的,他的序列無論怎麼修改 SEED 也是確定的,而我們有不能隨意更改演算法中的 A、B、C 的值,因為可能會導致無法遍歷到所有數字,這點之前已經說過了。Xoshiro 也是同理。而 SplitMix 演算法不用擔心,我們指定不同的 SEED 以及不同的步長 M 就可以保證生成的序列是不同的。這種可以生成不同序列的性質,稱為可拆分性

image

這也是 SplittableRandomRandom (Random 基於線性同餘)更適合多執行緒的原因:

  • 假設多執行緒使用同一個 Random,保證了序列的隨機性,但是有 CompareAndSet 新 seed 的效能損失。
  • 假設每個執行緒使用 SEED 相同的 Random,則每個執行緒生成的隨機序列相同。
  • 假設每個執行緒使用 SEED 不相同的 Random,但是我們不能保證一個 Random 的 SEED 是否是另一個 Random SEED 的下一個結果(或者是很短步長以內的結果),這種情況下如果執行緒壓力不均勻(執行緒池在比較閒的時候,其實只有一部分執行緒在工作,這些執行緒很可能他們私有的 Random 來到和其他執行緒同一個 SEED 的位置),某些執行緒也會有相同的隨機序列。

使用 SplittableRandom 只要直接使用介面 split 就能給不同執行緒分配一個引數不同SplittableRandom ,並且引數不同基本就可以保證生成不了相同序列。

思考:我們如何生成 Period 大於生成數字容量的隨機序列呢?

最簡單的做法,我們將兩個 Period 等於容量的序列通過輪詢合併在一起,這樣就得到了 Period = 容量 + 容量 的序列:

image

我們還可以直接記錄兩個序列的結果,然後將兩個序列的結果用某種運算,例如異或或者雜湊操作拼到一起。這樣,Period = 容量 * 容量

如果我們想擴充套件更多,都可以通過以上辦法拼接。用一定的操作拼接不同演算法的序列,我們可以得到每種演算法的隨機優勢。 Java 17 引入的 LXM 演算法就是一個例子。

LXM 演算法

這是在 Java 17 中引入的演算法 LXM 演算法(L 即線性同餘,X 即 Xoshiro,M 即 MurMurHash)的實現較為簡單,結合線性同餘演算法和 Xoshiro 演算法,之後通過 MurMurHash 雜湊,例如:

  • L34X64M:即使用一個 32 位的數字儲存線性同餘的結果,兩個 32 位的數字儲存 Xoshiro 演算法的結果,使用 MurMurHash 雜湊合併這些結果到一個 64 位數字。
  • L128X256M:即使用兩個 64 位的數字儲存線性同餘的結果,4 個 64 位的數字儲存 Xoshiro 演算法的結果,使用 MurMurHash 雜湊合併這些結果到一個 64 位數字。

LXM 演算法通過 MurMurhash 實現了分割性,沒有保留 Xoshiro 的跳躍性。

SEED 的來源

由於 JDK 中所有的隨機演算法都是基於上一次輸入的,如果我們使用固定 SEED 那麼生成的隨機序列也一定是一樣的。這樣在安全敏感的場景,不夠合適,官方對於 cryptographically secure 的定義是,要求 SEED 必須是不可預知的,產生非確定性輸出。

在 Linux 中,會採集使用者輸入,系統中斷等系統執行資料,生成隨機種子放入池中,程式可以讀取這個池子獲取一個隨機數。但是這個池子是採集一定資料後才會生成,大小有限,並且它的隨機分佈肯定不夠好,所以我們不能直接用它來做隨機數,而是用它來做我們的隨機數生成器的種子。這個池子在 Linux 中被抽象為兩個檔案,這兩個檔案他們分別是:/dev/random/dev/urandom。一個是必須採集一定熵的資料才放開從池子裡面取否則阻塞,另一個則是不管是否採集夠直接返回現有的。

在 Linux 4.8 之前:

image

在 Linux 4.8 之後:

image

在熵池不夠用的時候,file:/dev/random會阻塞file:/dev/urandom不會。對於我們來說,/dev/urandom 一般就夠用,所以一般通過-Djava.security.egd=file:/dev/./urandom設定 JVM 啟動引數,使用 urandom 來減少阻塞。

我們也可以通過業務中的一些特性,來定時重新設定所有 Random 的 SEED 來進一步增加被破解的難度,例如,每小時用過去一小時的活躍使用者數量 * 下單數量作為新的 SEED。

測試隨機演算法隨機性

以上演算法實現的都是偽隨機,即當前隨機數結果與上一次是強相關的關係。事實上目前基本所有快速的隨機演算法,都是這樣的

並且就算我們讓 SEED 足夠隱祕,但是如果我們知道演算法,還是可以通過當前的隨機輸出,推測出下一個隨機輸出。或者演算法未知,但是能從幾次隨機結果反推出演算法從而推出之後的結果。

針對這種偽隨機演算法,需要驗證演算法生成的隨機數滿足一些特性,例如:

  • period 儘可能長:a full cycle 或者 period 指的是隨機序列將所有可能的隨機結果都遍歷過一遍,同時結果回到初始 seed 需要的結果個數。這個 period 要儘可能的長一些。
  • 平均分佈(equidistribution),生成的隨機數的每個可能結果,在一個 Period 內要儘可能保證每種結果的出現次數是相同的。否則,會影響在某些業務的使用,例如抽獎這種業務,我們需要保證概率要準。
  • 複雜度測試:生成的隨機序列是否夠複雜,不會有那種有規律的數字序列,例如等比數列,等差數列等等。
  • 安全性測試:很難通過比較少的結果反推出這個隨機演算法。

目前,已經有很多框架工具用來針對某個演算法生成的隨機序列進行測試,評價隨機序列結果,驗證演算法的隨機性,常用的包括:

Java 中內建的隨機演算法,基本都通過了 testU01 的大部分測試。目前,上面提到過的優化演算法都或多或少的暴露出一些隨機性問題。目前, Java 17 中的 LXM 演算法是隨機性測試中表現最好的注意是隨機性表現,而不是效能

Java 中涉及到的所有隨機演算法(不包括 SecureRandom)

image

為什麼我們在實際業務應用中很少考慮隨機安全性問題

主要因為,我們一般做了負載均衡多例項部署,還有多執行緒。一般每個執行緒使用不同初始 SEED 的 Random 例項(例如 ThreadLocalRandom)。並且一個隨機敏感業務,例如抽獎,單個使用者一般都會限制次數,所以很難採集夠足夠的結果反推出演算法以及下一個結果,而且你還需要和其他使用者一起抽。然後,我們一般會限制隨機數範圍,而不是使用原始的隨機數,這就更大大增加了反解的難度。最後,我們也可以定時使用業務的一些實時指標定時設定我們的 SEED,例如:,每小時用過去一小時的(活躍使用者數量 * 下單數量)作為新的 SEED。

所以,一般現實業務中,我們很少會用 SecureRandom。如果我們想初始 SEED 讓編寫程式的人也不能猜出來(時間戳也能猜出來),可以指定隨機類的初始 SEED 源,通過 JVM 引數 -Djava.util.secureRandomSeed=true。這個對於所有 Java 中的隨機數生成器都有效(例如,Random,SplittableRandom,ThreadLocalRandom 等等)

對應原始碼:

static {
        String sec = VM.getSavedProperty("java.util.secureRandomSeed");
        if (Boolean.parseBoolean(sec)) {
            //初始 SEED 從 SecureRandom 中取
            // SecureRandom 的 SEED 源,在 Linux 中即我們前面提到的環境變數 java.security.egd 指定的 /dev/random 或者 /dev/urandom
            byte[] seedBytes = java.security.SecureRandom.getSeed(8);
            long s = (long)seedBytes[0] & 0xffL;
            for (int i = 1; i < 8; ++i)
                s = (s << 8) | ((long)seedBytes[i] & 0xffL);
            seeder.set(s);
        }
    }

所以,針對我們的業務,我們一般只關心演算法的效能以及隨機性中的平均性,而通過測試的演算法,一般隨機性都沒啥大問題,所以我們只主要關心效能即可

針對安全性敏感的業務,像是 SSL 加密,生成加密隨機雜湊這種,則需要考慮更高的安全隨機性。這時候才考慮使用 SecureRandom。SecureRandom 的實現中,隨機演算法更加複雜且涉及了一些加密思想,我們這裡就不關注這些 Secure 的 Random 的演算法了

Java 17 之前一般如何生成隨機數以及對應的隨機演算法

首先放出演算法與實現類的對應關係:

image

使用 JDK 的 API

1.使用 java.util.Random 和基於它的 API

Random random = new Random();
random.nextInt();

Math.random() 底層也是基於 Random

java.lang.Math

public static double random() {
    return RandomNumberGeneratorHolder.randomNumberGenerator.nextDouble();
}
private static final class RandomNumberGeneratorHolder {
    static final Random randomNumberGenerator = new Random();
}

Random 本身是設計成執行緒安全的,因為 SEED 是 Atomic 的並且隨機只是 CAS 更新這個 SEED:

java.util.Random

protected int next(int bits) {
    long oldseed, nextseed;
    AtomicLong seed = this.seed;
    do {
        oldseed = seed.get();
        nextseed = (oldseed * multiplier + addend) & mask;
    } while (!seed.compareAndSet(oldseed, nextseed));
    return (int)(nextseed >>> (48 - bits));
}

同時也看出,Random 是基於線性同餘演算法的

2.使用 java.util.SplittableRandom 和基於它的 API

SplittableRandom splittableRandom = new SplittableRandom();
splittableRandom.nextInt();

前面的分析我們提到了,SplittableRandom 基於 SplitMix 演算法實現,即給定一個初始 SEED,設定一個固定步長 M,每次隨機,將這個 SEED 加上步長 M,經過一個 HASH 函式(這裡是 MurMurHash3),將這個值雜湊對映到一個 HASH 值。

SplittableRandom 本身不是執行緒安全的
java.util.SplittableRandom

public int nextInt() {
    return mix32(nextSeed());
}   
private long nextSeed() {
    //這裡非執行緒安全
    return seed += gamma;
}

ThreadLocalRandom 基於 SplittableRandom 實現,我們在多執行緒環境下使用 ThreadLocalRandom

ThreadLocalRandom.current().nextInt();

SplittableRandom 可以通過 split 方法返回一個引數全新,隨機序列特性差異很大的新的 SplittableRandom,我們可以將他們用於不同的執行緒生成隨機數,這在 parallel Stream 中非常常見:

IntStream.range(0, 1000)
    .parallel()
    .map(index -> usersService.getUsersByGood(index))
    .map(users -> users.get(splittableRandom.split().nextInt(users.size())))
    .collect(Collectors.toList());

但是由於沒有做對齊性填充以及其他一些多執行緒效能優化的東西,導致其多執行緒環境下的效能表現還是比基於 SplittableRandomThreadLocalRandom 要差。

3. 使用 java.security.SecureRandom 生成安全性更高的隨機數

SecureRandom drbg = SecureRandom.getInstance("DRBG");
drbg.nextInt();

一般這種演算法,基於加密演算法實現,計算更加複雜,效能也比較差,只有安全性非常敏感的業務才會使用,一般業務(例如抽獎)這些是不會使用的。

測試效能

單執行緒測試:

Benchmark                                      Mode  Cnt          Score          Error  Units
TestRandom.testDRBGSecureRandomInt            thrpt   50     940907.223 ±    11505.342  ops/s
TestRandom.testDRBGSecureRandomIntWithBound   thrpt   50     992789.814 ±    71312.127  ops/s
TestRandom.testRandomInt                      thrpt   50  106491372.544 ±  8881505.674  ops/s
TestRandom.testRandomIntWithBound             thrpt   50   99009878.690 ±  9411874.862  ops/s
TestRandom.testSplittableRandomInt            thrpt   50  295631145.320 ± 82211818.950  ops/s
TestRandom.testSplittableRandomIntWithBound   thrpt   50  190550282.857 ± 17108994.427  ops/s
TestRandom.testThreadLocalRandomInt           thrpt   50  264264886.637 ± 67311258.237  ops/s
TestRandom.testThreadLocalRandomIntWithBound  thrpt   50  162884175.411 ± 12127863.560  ops/s

多執行緒測試:

Benchmark                                      Mode  Cnt          Score           Error  Units
TestRandom.testDRBGSecureRandomInt            thrpt   50    2492896.096 ±     19410.632  ops/s
TestRandom.testDRBGSecureRandomIntWithBound   thrpt   50    2478206.361 ±    111106.563  ops/s
TestRandom.testRandomInt                      thrpt   50  345345082.968 ±  21717020.450  ops/s
TestRandom.testRandomIntWithBound             thrpt   50  300777199.608 ±  17577234.117  ops/s
TestRandom.testSplittableRandomInt            thrpt   50  465579146.155 ±  25901118.711  ops/s
TestRandom.testSplittableRandomIntWithBound   thrpt   50  344833166.641 ±  30676425.124  ops/s
TestRandom.testThreadLocalRandomInt           thrpt   50  647483039.493 ± 120906932.951  ops/s
TestRandom.testThreadLocalRandomIntWithBound  thrpt   50  467680021.387 ±  82625535.510  ops/s

結果和我們之前說明的預期基本一致,多執行緒環境下 ThreadLocalRandom 的效能最好。單執行緒環境下 SplittableRandomThreadLocalRandom 基本接近,效能要好於其他的。SecureRandom 和其他的相比效能差了幾百倍。

測試程式碼如下(注意雖然 Random 和 SecureRandom 都是執行緒安全的,但是為了避免 compareAndSet 帶來的效能衰減過多,還是用了 ThreadLocal。):

package prng;

import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Random;
import java.util.SplittableRandom;
import java.util.concurrent.ThreadLocalRandom;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

//測試指標為吞吐量
@BenchmarkMode(Mode.Throughput)
//需要預熱,排除 jit 即時編譯以及 JVM 採集各種指標帶來的影響,由於我們單次迴圈很多次,所以預熱一次就行
@Warmup(iterations = 1)
//執行緒個數
@Threads(10)
@Fork(1)
//測試次數,我們測試50次
@Measurement(iterations = 50)
//定義了一個類例項的生命週期,所有測試執行緒共享一個例項
@State(value = Scope.Benchmark)
public class TestRandom {
	ThreadLocal<Random> random = ThreadLocal.withInitial(Random::new);
	ThreadLocal<SplittableRandom> splittableRandom = ThreadLocal.withInitial(SplittableRandom::new);
	ThreadLocal<SecureRandom> drbg = ThreadLocal.withInitial(() -> {
		try {
			return SecureRandom.getInstance("DRBG");
		}
		catch (NoSuchAlgorithmException e) {
			throw new IllegalArgumentException(e);
		}
	});

	@Benchmark
	public void testRandomInt(Blackhole blackhole) throws Exception {
		blackhole.consume(random.get().nextInt());
	}

	@Benchmark
	public void testRandomIntWithBound(Blackhole blackhole) throws Exception {
		//注意不取 2^n 這種數字,因為這種數字一般不會作為實際應用的範圍,但是底層針對這種數字有優化
		blackhole.consume(random.get().nextInt(1, 100));
	}

	@Benchmark
	public void testSplittableRandomInt(Blackhole blackhole) throws Exception {
		blackhole.consume(splittableRandom.get().nextInt());
	}

	@Benchmark
	public void testSplittableRandomIntWithBound(Blackhole blackhole) throws Exception {
		//注意不取 2^n 這種數字,因為這種數字一般不會作為實際應用的範圍,但是底層針對這種數字有優化
		blackhole.consume(splittableRandom.get().nextInt(1, 100));
	}

	@Benchmark
	public void testThreadLocalRandomInt(Blackhole blackhole) throws Exception {
		blackhole.consume(ThreadLocalRandom.current().nextInt());
	}

	@Benchmark
	public void testThreadLocalRandomIntWithBound(Blackhole blackhole) throws Exception {
		//注意不取 2^n 這種數字,因為這種數字一般不會作為實際應用的範圍,但是底層針對這種數字有優化
		blackhole.consume(ThreadLocalRandom.current().nextInt(1, 100));
	}

	@Benchmark
	public void testDRBGSecureRandomInt(Blackhole blackhole) {
		blackhole.consume(drbg.get().nextInt());
	}

	@Benchmark
	public void testDRBGSecureRandomIntWithBound(Blackhole blackhole) {
		//注意不取 2^n 這種數字,因為這種數字一般不會作為實際應用的範圍,但是底層針對這種數字有優化
		blackhole.consume(drbg.get().nextInt(1, 100));
	}

	public static void main(String[] args) throws RunnerException {
		Options opt = new OptionsBuilder().include(TestRandom.class.getSimpleName()).build();
		new Runner(opt).run();
	}
}


微信搜尋“我的程式設計喵”關注公眾號,每日一刷,輕鬆提升技術,斬獲各種offer

相關文章