庆祝1024,从冯诺依曼解析到Java Random类源码

大家好, 这里是 K 字的研究. 我是爱灌水的老 K.

好久没有解析源码了, 今天来给大家解析一个 Java 里的常用工具类: java.util.Random.

先说一点, Random 类其实和我们提过的 Integer 有点像. 整个类里 1 成是主逻辑, 9 成都是 util 代码,工具代码. 我会略过一些工具代码.另外, Random核心的几个参数, 我暂时没考虑好,先不涉及.

1引子

按照惯例, 一般会先自己实现一个残次品版本, 然后再去翻源码.

不过, 鉴于这块我的水平不行,自己实现确实有点困难. 我就直接给大家抄一个经典算法过来实现下吧.

平方取中法

import java.math.BigInteger;

public class RandomNeumann {
    public static void main(String[] args) {
        long r = random(5772156649L10);
        System.out.println("r = " + r);
    }

    static public long random(long n, int size) {
        BigInteger bigN = BigInteger.valueOf(n);
        BigInteger mul = bigN.multiply(bigN);
        String num = mul.toString();
        int numSize = num.length();
        int padding = (numSize - size) / 2;
        return Long.parseLong(num.substring(padding, padding + size));
    }
}

这个算法实现的很挫, 不过反正是玩具,凑合看, 应该很好看懂逻辑.用上一个随机数取平方, 从正中间截出想要的位数.

入参有两个:

  1. 上次生成的随机数
  2. 要生成多少位
庆祝1024,从冯诺依曼解析到Java Random类源码
约翰

这个算法叫: 平方取中法.1946 年一个叫约翰的人发明了这个算法.

虽然看上去很卡哇伊, 但是他在计算机界也算是小有名气, 全名是(John von Neumann),一般被翻译为冯·诺依曼[1].

算法特点

这算法有几个特点

  • 每一个数都依赖于上一个数,因此需要自己手动提供第一个数.
  • 过了一阵子之后, 数字序列就开始固定不变了,或者进入一个循环.

需要提供的第一个数字,一般被称为种子,这个种子对后面的生成很关键.假若我们提供的种子是 10000000000, 那么后面生成的所有数字都会变成 0.基本上就宣告这算法废了.

种子在随机数生成时候, 很关键.

后续

他这个算法, 很快就被人发现有问题, 不过, 对于我们刚开始看Random类的人来说, 了解下这个算法还是有必要的.

这套每一个数由上一个数生成的架构, 这么多年并没有变化: 种子的选取,以及随机数序列周期的存在,依然还在.

每一个随机数都被上一个数字计算出来, 也带来一个问题. 就是他们看着像随机数, 但是本质上不是真随机的,一般被称为伪随机数.

冯诺依曼本人曾经嘲讽过这么一句话:

庆祝1024,从冯诺依曼解析到Java Random类源码

翻译成当代文明话就是: 想用算术方法生成随机数的人都是**.

2重点回顾

前摇结束, 知道了这两点:

  • 每一个随机数是由上一个随机数决定的.
  • 第一个随机数叫 种子(seed)

我们现在就可以开始看 Random 的源码了.

3Random 类图

属性图

庆祝1024,从冯诺依曼解析到Java Random类源码
Random Field清单

这里明显能看到, 确实有个 seed.

构造器

庆祝1024,从冯诺依曼解析到Java Random类源码这里有两个构造器, 第二个构造器的参数就是种子. 第一个无参构造器,里面会自己产生一个种子,然后继续调用第二个构造器.

我们先来试一下, 用两个种子相同的Random,是不是生成的随机数就一样.

Random a = new Random(1);
Random b = new Random(1);
for (int i = 0; i < 10; i++) {
  System.out.println(
    a.nextLong()+","
           +b.nextLong());
}

//输出还真是一样的.

-4964420948893066024,-4964420948893066024
7564655870752979346,7564655870752979346
3831662765844904176,3831662765844904176
6137546356583794141,6137546356583794141
-594798593157429144,-594798593157429144
112842269129291794,112842269129291794
-669528114487223426,-669528114487223426
-1109287713991315740,-1109287713991315740
-974081879987450628,-974081879987450628
-1160629452687687109,-1160629452687687109

4如何生成不一样的种子?

刚刚的实验结果已经看到了, 种子相同会带来奇怪的随机数相同问题. 那么无参构造器里种子, 是怎么生成的?

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

代码倒是好理解, 取纳秒级别的当前时间, 然后异或一个函数seedUniquifier. 因为这里是异或, 所以, 两边只要有一个不一样的就可以.

大多数时候, 时间都是不一样的, 所以肯定种子不一样.主要要处理的是相同时间内的问题, 这时候 seedUniquifier,就是用来生成局部唯一数的.是不是感觉到, 其实这部分跟生成分布式唯一 id 雪花算法有点像.

seedUniquifier

继续看函数 seedUniquifier, 这段就是不断做乘法, 跟上一个 Uniquifier 作比较, 来确保短时间内唯一.这里借助了 CAS 的自旋操作.

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

最为神奇的代码, 应该还是这句.long next = current * 1181783497276652981L; 盲猜又是和三距离定理相关的内容, 关于这部分我们ThreadLocal的 hashCode 生成部分介绍过一次了.就不提了. 作用就是让碰撞变的没那么频繁, 这里的自旋消耗小点.

5有了种子以后

有了种子以后, 也并不是直接就使用了,后半句针对子类做的优化不提了, 只看构造器前面部分的代码.

这里用到一个this.seed = new AtomicLong(initialScramble(seed));

内部这个initialScramble的实现是这样的

(seed ^ multiplier) & mask;

先取 mask

mask=(1L << 48) - 1;

这个其实很容易理解, 1 << n, 那他就是只有一个 1,1 的最右边只有 n 个 0. 1L<<48就是 1 后面跟着 48 个 0 的一个数字,是 . 这就是一种快速得到一个连续 48 个 1 的小技巧.

我们画过一次这种神奇的数字,像这种只有一个 1 的数字, -1, 就意味着后面的位置会全部变成 1.

庆祝1024,从冯诺依曼解析到Java Random类源码

然后, 通过 &mask生成了一个只有 48 位的数字做种.

6生成随机数

这样下来之后, Random 已经构造完成. 剩下的就是根据当前数生成下一个数的算法.

Random 方法这么多, 算法的实现在哪里呢?

庆祝1024,从冯诺依曼解析到Java 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));
}

看着一点也不复杂, 仍然是前面那套自旋逻辑.取出旧种子, 按照(oldseed * multiplier + addend) & mask;生成新种子.

7适配结果

这里调用 next 产生的随机序列, 得到的是固定的 48bit 内容.

Random 的方法虽然多的,不过基本大同小异.这些 next 开头的, 都是适配函数.

  • nextInt 其实就是说我要取 32bit 出来的快捷写法.
  • nextLong 是我要取 2 个 32bit 出来的快捷写法.
  • nextDouble 是分别取两个 27 位并计算(复习浮点数可以参考这里)
  • nextBoolean 最简单, 是取一个 bit, 看看是不是 0:next(1) != 0;

各种适配函数的写法,都是把这 48bit 转换成想要的结果.比如 nextBoolean 只要 1 个 bit, 那就右移 48-1=47 位,把后面的全都丢掉, 其他 next 方法也是类似的.

这里面最复杂的方法,其实是nextGaussian.

前面所有的操作, 都是在均匀分布的前提下进行的.比如 1-256 之间, 生成每一个数的概率是相等的.

而 nextGaussian 这个函数, 生成出来的内容, 是符合高斯分布的. 这里的写法很巧妙,用到了一个函数来进行转换. 和我们前阵子说过的如何生成更偏向一端的随机数有点相似. 这段我还没想好怎么处理, 先挖个坑, 回头专门写一篇谈谈这个. 今天继续谈别的 .

8线性同余法(LCG)

Random 里没有使用平方取中法, 他生成随机数使用的算法, 叫线性同余法(Linear Congruential Generator), 大致如下图.

庆祝1024,从冯诺依曼解析到Java Random类源码

这里面的 4 个值, 就对应着Random里面的变量.

  • mask 对应模数 m-1
  • multiplier 对应乘数 a
  • addend 对应增量 c
  • 对应着种子

(oldseed * multiplier + addend) & mask

这里面的几个值的选择, 都是很有讲究的. 不过我感觉我这篇是写不完了. 咱们挖个坑, 回头再聊. 主要是没想好怎么画.

9后话

Random 这个类的核心代码不难写, 也不难看. 最难的部分其实是Why, 为什么这么写就可以.别的都能看明白, 这个为什么,看代码是看不出来的. 喜欢这部分原理的, 可以去看那本书, 你懂的.我再看看, 等找到合适的方法以后, 我再来画一下那几个神奇的数字都是哪里来的.

  • 如果真的想看懂这些代码在干什么, 看代码可以.
  • 如果真的想看懂代码为什么这么干, 最好还是看书. 看开发者看的那些书.然而这些人看的书都好难啊, 我的天.

不过, 如果想学习Atomic**怎么用, 这个 Random 里面简直是妙极了.强烈推荐通过学习 Random 源码来学习Atomic的实用.

参考资料

[1]

冯·诺依曼: https://www.ias.edu/von-neumann


庆祝1024,从冯诺依曼解析到Java Random类源码


原文始发于微信公众号(K字的研究):庆祝1024,从冯诺依曼解析到Java Random类源码

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/24688.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!