浅谈Java的伪随机数发生器和线性同余法

前言

生成伪随机数是用Java编程时的常见需求,本文简单讨论一下最常用的Random和ThreadLocalRandom这两个随机数类,顺便介绍线性同余法。

Random

话休絮烦,直接上源码。

    private final AtomicLong seed;

    private static final long multiplier = 0x5DEECE66DL;
    private static final long addend = 0xBL;
    private static final long mask = (1L << 48) - 1;

    public Random() {
        this(seedUniquifier() ^ System.nanoTime());
    }

    private static long seedUniquifier() {
        for (;;) {
            long current = seedUniquifier.get();
            long next = current * 181783497276652981L;
            if (seedUniquifier.compareAndSet(current, next))
                return next;
        }
    }

    private static final AtomicLong seedUniquifier
        = new AtomicLong(8682522807148012L);

    public Random(long seed) {
        if (getClass() == Random.class)
            this.seed = new AtomicLong(initialScramble(seed));
        else {
            this.seed = new AtomicLong();
            setSeed(seed);
        }
    }

    private static long initialScramble(long seed) {
        return (seed ^ multiplier) & mask;
    }

如果我们构造Random实例时,调用的是有参构造方法(即传入了自定义的随机数种子seed),那么通过initialScramble()方法产生的实际使用的种子为:
(seed XOR 0x5DEECE66DL) AND ((1L << 48) - 1)

可见,种子的长度是48比特。

如果调用的是无参构造方法,那么会将seedUniquifier()方法返回的值与当前JVM运行的纳秒时间戳做异或运算,并作为seed值,再经过上式的运算得出实际使用的种子。而seedUniquifier()是个自旋的方法,它以8682522807148012L作为初始值,不断与181783497276652981L相乘,直到某次相乘前后的结果在long范围内相同时才返回。这两个大数的来历请参见StackOverflow上的这个问答。

下面看看生成伪随机数的核心方法next(),其参数bits是随机数的字长,比如nextInt()方法调用next()方法时,bits就是32。

    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));
    }

产生随机数时,会首先根据当前种子seed更新种子的值为:
(seed * 0x5DEECE66DL + 0xBL) & ((1L << 48) - 1)

然后将新的种子值右移(48 - bits)位,得到随机数的真值。上式就是所谓线性同余法的实现,下面简单分析一下。

线性同余法

线性同余法(linear congruential generator, LCG)是非常古老的伪随机数生成方法,在1958年提出,但时至如今,它仍然是使用最广泛的方法之一。它的思想很简单,用一个递推式即可概括:

X[n+1] = (a·X[n] + c) mod m

其中:

  • m > 0,称为模(modulus);
  • 0 < a < m,称为乘数(multiplier);
  • 0 <= c < m,称为增量(increment);
  • X[0],即递推序列的初始值,称为种子(seed)。X就是生成的随机数序列。

LCG有如下的重要性质(Hull-Dobell定理):

当且仅当
(1) c与m互素
(2) a - 1可以被所有m的质因数整除
(3) 如果m能被4整除,那么a - 1也能被4整除
以上三个条件同时满足时,X是一个周期为m的序列。

既然LCG产生的是周期性的伪随机数序列,为了使它难以预测,m的值一般都会选得非常大。Random类中a、c、m这三个参数的取值分别为:

a = 0x5DEECE66DL = 25214903917   // multiplier
c = 0xBL = 11   //addend
m = 1L << 48 = 281474976710656   // mask

可见,这三个值是满足Hull-Dobell定理的,故Random的周期为248,足够大了。

为了方便理解,下图示出不同参数取值下的LCG周期。可见,当不满足上述定理时(第一行和第二行),X的周期是一定比m短的。

特别地,c=0的特殊情况叫做Lehmer生成器(Lehmer RNG),又叫乘同余法(multiplicative congruential generator, MCG)。它的出现比LCG更早(1951年),并且参数的取值有更多的限制。本文不再展开讲细节,看官可以参见LCG与MCG的英文维基相关页面。

上面所叙述的过程产生的是整数,如果要产生(0, 1)区间内的小数怎么办呢?很简单,结果再除以m就行了,因为X中的原始值肯定比m小。

ThreadLocalRandom

阿里Java开发手册中如是说:

【推荐】避免Random实例被多线程使用,虽然共享该实例是线程安全的,但会因竞争同一seed导致性能下降。
说明:Random实例包括java.util.Random的实例或者Math.random()的方式。
正例:在JDK7之后,可以直接使用API ThreadLocalRandom,而在JDK7之前,需要编码保证每个线程持有一个实例。

从代码可知,由于Random类中的种子是AtomicLong类型,在多线程进入next()方法时,只有一个线程能CAS成功更新种子,其他线程都在不断自旋,性能降低。为了解决这个问题,产生了ThreadLocalRandom。

ThreadLocalRandom是Random的子类,但在JDK7与JDK8中的实现方法颇有不同。下面是JDK7版本的主要源码。

    private static final long multiplier = 0x5DEECE66DL;
    private static final long addend = 0xBL;
    private static final long mask = (1L << 48) - 1;

    private long rnd;
    boolean initialized;

    // 填充字节,避免不同线程的ThreadLocalRandom竞争CPU缓存行造成虚共享
    private long pad0, pad1, pad2, pad3, pad4, pad5, pad6, pad7;

    ThreadLocalRandom() {  
        super();  
        initialized = true;  
    }  

    private static final ThreadLocal localRandom =
        new ThreadLocal() {
            protected ThreadLocalRandom initialValue() {
                return new ThreadLocalRandom();
            }
    };

    public static ThreadLocalRandom current() {
        return localRandom.get();
    }

    public void setSeed(long seed) {
        if (initialized)
            throw new UnsupportedOperationException();
        rnd = (seed ^ multiplier) & mask;
    }

    protected int next(int bits) {
        rnd = (rnd * multiplier + addend) & mask;
        return (int) (rnd >>> (48-bits));
    }

可见,ThreadLocalRandom产生随机数的思路与Random完全相同,不过是利用ThreadLocal为每个线程维护了不同的ThreadLocalRandom实例。在ThreadLocalRandom初始化时,会调用Random的无参构造方法,并通过重写过的setSeed()方法将计算出的种子存入rnd变量,故每个线程都拥有了不同的种子,通过current()方法即可取得自己的那一份,不会再产生冲突。

下面是JDK8版本的主要源码。

    private static final AtomicInteger probeGenerator =
        new AtomicInteger();

    private static final AtomicLong seeder = new AtomicLong(initialSeed());

    boolean initialized;

    private ThreadLocalRandom() {
        initialized = true;
    }

    static final ThreadLocalRandom instance = new ThreadLocalRandom();

    static final void localInit() {
        int p = probeGenerator.addAndGet(PROBE_INCREMENT);
        int probe = (p == 0) ? 1 : p;
        long seed = mix64(seeder.getAndAdd(SEEDER_INCREMENT));
        Thread t = Thread.currentThread();
        UNSAFE.putLong(t, SEED, seed);
        UNSAFE.putInt(t, PROBE, probe);
    }

    public static ThreadLocalRandom current() {
        if (UNSAFE.getInt(Thread.currentThread(), PROBE) == 0)
            localInit();
        return instance;
    }

    public void setSeed(long seed) {
        if (initialized)
            throw new UnsupportedOperationException();
    }

    final long nextSeed() {
        Thread t; long r;
        UNSAFE.putLong(t = Thread.currentThread(), SEED,
                       r = UNSAFE.getLong(t, SEED) + GAMMA);
        return r;
    }

    protected int next(int bits) {
        return (int)(mix64(nextSeed()) >>> (64 - bits));
    }

这些源码中并没有出现ThreadLocal,那么每个线程的种子去哪里了?答案是加进了Thread类里,作为独立的字段存在。

// sun.misc.Contended注解用于填充缓存行
@sun.misc.Contended("tlr")  
long threadLocalRandomSeed;  
  
@sun.misc.Contended("tlr")  
int threadLocalRandomProbe;  

threadLocalRandomSeed就是随机数种子,threadLocalRandomProbe是指示种子是否被初始化的探针变量。可见,JDK8虽然没有显式使用ThreadLocal,但是基本的思想是相通的——即每个线程都持有独享的种子值。ThreadLocalRandom代码中的SEED和PROBE两个量,实际上是通过Unsafe API取得的字段偏移地址。

    private static final sun.misc.Unsafe UNSAFE;
    private static final long SEED;
    private static final long PROBE;
    static {
        try {
            UNSAFE = sun.misc.Unsafe.getUnsafe();
            Class tk = Thread.class;
            SEED = UNSAFE.objectFieldOffset
                (tk.getDeclaredField("threadLocalRandomSeed"));
            PROBE = UNSAFE.objectFieldOffset
                (tk.getDeclaredField("threadLocalRandomProbe"));
        } catch (Exception e) {
            throw new Error(e);
        }
    }

JDK8版ThreadLocalRandom只有一个全局的实例,通过current()方法取得。当某个Thread实例的探针threadLocalRandomProbe为0时,表示是首次执行current()方法,进而会调用localInit()方法初始化种子和探针的值,并将它们写入对应的Thread实例中。通过nextSeed()方法就可以分别更新对应线程的种子了。

可见,与JDK7版本相比,JDK8版本的ThreadLocalRandom不再依赖于父类Random的构造方法产生种子,也就彻底消除了CAS自旋带来的开销。关于这个改进的细节,还可以参考StackOverflow上的这个问题。

最后一丢丢

上面讲的LCG、MCG是伪随机数发生器,也就是说如果知道了a、c、m参数的值,随机数就被破解了,无法做到安全。要保证安全,可以采用SecureRandom类。它利用了Linux的特殊设备/dev/random和/dev/urandom,基于系统的熵(进程数、内存用量、设备输入等无法预测的指标)产生随机数,这样也就没办法预测随机序列的值了。

晚安晚安。

你可能感兴趣的:(浅谈Java的伪随机数发生器和线性同余法)