分享

惊艳面试官-Java中关于随机数生成8种方式的思考

 编程一生 2022-03-09
Java中生成随机数常用的有下面这8种写法:简而言之,名称带安全的未必安全,名字简洁的未必简单。
  • Math.random()

  • Random

  • ThreadLocalRandom

  • SecureRandom

  • UUID.randomUUID()

  • RandomStringUtils

  • RandomUtils

  • RandomUtil

Math.random()

Talk is cheap, show me the code.  先上源码:

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

从源码可以看出:Math.random本质上用的就是new Random。而且人家给了一个最佳实践:使用的时候不用每次就new一个对象,直接做成一个静态类就可以了。

Random

Random是伪随机数类,采用线性同余算法产生的。伪随机数是1946年冯诺依曼提出来的。他在参加一个氢弹的制造项目,需要大量的随机数模拟核聚变、核裂变。因为当时设备没办法去存储大量的随机数,所以就想到了这个办法。伪随机数(或称伪乱数)这个称呼是因为真随机数都不是计算出来的。这里是使用一个确定性的算法计算出来的似乎是随机的数序,因此伪随机数实际上并不随机。从更宏大的视角看来,是非常有规律的,就像我在《大话高可用》里提到的炊烟。

下面来说说这个线性同余算法,大家不用担心。我比大家还对算法一窍不通。但是我有人类的常识,这个常识是什么呢?既然是计算得到的,并且人类看起来没有规律,那一定有一个外部因子(种子),种子是一个变量。顺着这个思路看源码:


/**
* Creates a new random number generator. This constructor sets
* the seed of the random number generator to a value very likely
* to be distinct from any other invocation of this constructor.
*/
public Random() {
this(seedUniquifier() ^ System.nanoTime());
}

没想到在构造函数里就给出了答案,这个因子就是系统时间,精确到纳秒。这里为了方便描述,我们举例想要一个int类型的随机数(实际它可以产生其他数值类型的随机数)。来看看源码:

public int nextInt() {
return next(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));
}

解释一下:调用nextInt默认会生成32个比特位的数字。调用next方法,会有oldseed和nextseed。计算出nextseed会使CAS,将nextseed替换现有oldseed。nextseed的计算方法是oldseed做一些运算,运算的其他数值都是常量。最终返回nextseed值。

因为使用了AtomicLong、自旋+CAS(可以参考《系统梳理一下锁》),所以Random生成随机数是线程安全的。new一个全局变量,下次不用新建,Math.random的调用方法是合理的。

通过这个源码大胆猜测一下:如果两个不同的进程或者线程,在纳秒级相同的时间同时调用new Random,这时候nextInt的返回值是相同的!下次调用nextInt的值也相同!想要验证的话,可以下载java源码,在new Random的地方稍作修改,

System.nanoTime()改成固定值,这里我就不验证了。

ThreadLocalRandom

上面提到Random使用了线程安全的算法:AtomicLong、自旋+CAS。这在保证线程安全的同时造成了很大的性能开销。ThreadLocalRandom是JDK 7之后提供并发产生随机数,能够解决多个线程发生的竞争。

它不是直接用new实例化,而是第一次使用时初始化其静态方法current()。直接调用静态方法,可以每个线程实例化一个。有朋友测试过,100个线程的情况下,3分钟,Math.random可以跑几百次。而

ThreadLocalRandom.current().nextInt()可以跑十几万次!

来看一下它是怎么做到的:

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

}

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

简而言之,Random是通过系统时间这个外部因子,而ThreadLocalRandom都是通过UNSAFE调用本地方法拿到线程本身的一些变量作为外部因子。所有的参数绑定在线程本身,和其他线程没有竞争,所以可以不加锁就保证线程安全。

public int nextInt() {
return mix32(nextSeed());

}

private static int mix32(long z) {
z = (z ^ (z >>> 33)) * 0xff51afd7ed558ccdL;
return (int)(((z ^ (z >>> 33)) * 0xc4ceb9fe1a85ec53L) >>> 32);
}
final long nextSeed() {
Thread t; long r; // read and update per-thread seed
UNSAFE.putLong(t = Thread.currentThread(), SEED,
r = UNSAFE.getLong(t, SEED) + GAMMA);
return r;
}

生成随机数计算的时候也都是调用线程内的变量,不加锁。大家可以举一反三一下,回忆一下ThreadLocal相关知识。

SecureRandom

在安全应用场景,随机数应该使用安全的随机数。密码学意义上的安全随机数,要求必须保证其不可预测性。

由于 Random 是采用时间作为随机数种子,如果黑客知道随机数产生的时间,那就能重现随机数。而 SecureRandom 属于强随机数,一般不单独采用时间作为随机数种子,除了系统时间,还会采用临时文件夹中大小、某个线程从休眠到被唤醒所耗的时间等等一系列无法重现的值作为随机数种子。

因为SecureRandom 采用了很多外部参数,会产生熵源不足时阻塞问题。在我做过的项目中,因为有的业务凌晨没有流量,这个问题实际发生过。建议有明显低谷的业务,低谷时低于1TPS时不要用。

SecureRandom这种强随机在很多场景下效果不一定好。有一个小故事说itunes之前播歌的时候采用的是真随机。有些用户总是抱怨说你这是真随机吗?怎么来来回回给我播放那几首歌。苹果公司有苦说不出,于是把算法改成伪随机:洗牌算法,先把所有的歌打乱顺序,然后按顺序播放。改完之后用户纷纷点赞。

适合使用SecureRandom的场景,比如要给每个资源生成一个url,为了防止url规律被别人攻破,使用爬虫爬取到自己的资源可以用这个。

UUID.randomUUID()

看下面的代码就知道UUID.randomUUID()是基于SecureRandom

public static UUID randomUUID() {
SecureRandom ng = Holder.numberGenerator;
byte[] randomBytes = new byte[16];
ng.nextBytes(randomBytes);
randomBytes[6] &= 0x0f; /* clear version */
randomBytes[6] |= 0x40; /* set to version 4 */
randomBytes[8] &= 0x3f; /* clear variant */
randomBytes[8] |= 0x80; /* set to IETF variant */
return new UUID(randomBytes);
}

有的朋友说不对呀,网上告诉我UUID是时间戳+时钟序列+MAC地址。注意上面代码注释有个set to version 4。

从UUID的类注释里就可以看到java 8提到4个版本:

The version field holds a value that describes the type of this {@code
* UUID}. There are four different basic types of UUIDs: time-based, DCE
* security, name-based, and randomly generated UUIDs. These types have a
* version value of 1, 2, 3 and 4, respectively.

翻译一下:

版本1 - 基于时间

版本2 - 基于DCE sercrity

版本3 - 基于名字(MD5)

版本4 - 基于随机数

记住啦:java默认UUID是基于随机数的!

证明一下:如果是基于时间戳的,生成的UUID前几位应该相同

@Test
public void test() {
for (int i = 0; i < 3; i++) {
System.out.println(UUID.randomUUID());
}
}

结果:

a0858771-c903-4061-ba6a-53efc372d8a0

17ebdc58-db6c-452f-9b21-b4f54d3958a5

116d6a4f-e50a-4001-b94c-61596da3a75d

事实胜于记忆中的知识,它有可能是错的!

java UUID也支持版本3:


@Test
public void test() {
for (int i = 0; i < 3; i++) {
System.out.println(UUID.nameUUIDFromBytes("编程一生".getBytes()));
}
}

运行结果:

743f24b2-0914-363b-ace1-f4da750dccad

743f24b2-0914-363b-ace1-f4da750dccad

743f24b2-0914-363b-ace1-f4da750dccad

这个在你的机器上执行也是这个结果,就是MD5了一下。

有人说版本1、版本2怎么用,好像还听说过版本5。可以用linux命令

uuid -n 3 -v1

运行结果:

5b01cea2-9561-11e9-965b-a3d050dd0f23

5b01cf60-9561-11e9-965c-1b66505f5845

5b01d118-9561-11e9-965d-97354eb9e914

linux命令版本可指定!

RandomStringUtils、RandomUtils和RandomUtil

这两个不是Java原生的方法,apache commons下有这两个工具类,本质上还是Random。跟进代码去看可以发现:RandomUtils用的是JVMRandom。这个只不过是封装了一个静态的Random,所有使用RandomUtils类的,全局只用一个,减少了新建对象成本。

如果你问我一般情况下建议用哪个,我选ThreadLocalRandom。那这么好的方法是不是应该也有对应的封装工具类呢?有的,hultool就搞了一个RandomUtil,

RandomUtil.getRandom()返回的就是ThreadLocalRandom。

RandomUtil.getSecureRandom()返回的是SecureRandom,提供多种选择。

总结

一张图表示他们之间的关系:

《CURD系统怎么做出技术含量--怎样引导面试》里我提到可以利用工作中总结的技巧惊艳面试官,如果能把一个问题系统性讲明白,甚至让面试官意识到之前的知识竟然是错的,也一定能让面试官眼前一亮。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多