深入学习Java中该如何获取随机数

在java中,我们经常会遇到一些需要生成随机数的场景,像生成随机id、业务码、订单号,摇号获取随机数之类的。
虽然我们无师自通的直接拿random类用了很久,但其实它里面也是有很多门道的,并不像表面那么简单。它在我们的开发的实际应用场景中也经常用到,所以我觉得很有必要系统的学习理一下关于随机数生成。
另外,随机数生成的还涉及了部分密码学、加密算法和锁之类的知识,顺带可以学到一些其他知识。

 
 
首先我们看看JAVA中,常用的几种获取随机值的方式

一、JAVA中获取随机数的方式

1.Math.random(),隐约记得最开始学C的时候,也是通过Math数学函数获取随机数的,java里一开始我也是继续用的这个。它可以获取大于等于0且小于1随机double数,每次要获取的时候,都不得不做个计算(加减乘除之类的),让它能获取到理想范围的数。
也是比较麻烦的。

2.Random类,后面知道了这个类,才解放了双手。它是java提供的一个随机数生成的工具类,也就是专门搞随机数的,显然他比Math.random()专业多了。
虽然它里面有很多种方法,但是一般我们还是用nextInt的,并且可以通过传进一个参数的形式,指定0到最大值的随机范围。
这个类就是我们需要重点钻研的类了,虽然看起来简单,但是也是最常用的,甚至前面的Math方法也是用了它里面的nextDouble()生成的。也正是因为常用,所以要考虑的东西也就太多了,所以我们延迟到后面再讲。

3.UUID工具类,再后来,做项目的时候,见到了这个工具类,它同样是java提供的工具类,但是它生成的不是随机数,而是随机字符串。也就是我们通常会用它来生成字符串的ID之类的。
关于UUID(Universally Unique Identifier全局唯一标识符),它是有几个版本的,每个版本的生成规则都不一样,这里由于我们本篇的重点不是它,也就不深入了。只需要知道在java.util.UUID这个工具类生成的UUID,是几乎不可能重复的,我想也不用去证明了,毕竟人家官方都敢这样说,说明不是我一个普通人能怀疑的了的。。

 
 

二、关于伪随机数和种子

在详解Random类前,我们需要知道一个名词:伪随机数。
伪随机数就是通过获取一个“种子”,并对它进行一系列算法后生成的数字。这里前面的第一二种方法其实都是伪随机数。也就是它并不是真正意义上的未知,只要掌握一定的规律并计算,我们就可以推算出它生成的随机数。
这个种子的概念其实并不陌生,我们在new Random()时,其实是可以传一个参数的,这个参数就是种子seed,一旦我们显示指定了种子,并且在相同的执行次数下,不管是nextInt()还是nextDouble()还是别的方法,它们获取到的结果都是固定的。这恐怕也是Random类生成随机数是用“next”而不是“create”之类的原因之一吧,因为它是获取下一个计算结果,而不是真正意义上的创造生成随机数。
举个例子,也就是假如我指定了seed为1,那么这个random对象第一次nextint()的结果永远都会是21321,然后第二次nextint()永远都会是43215,是固定不变的。

这个种子就是我们随机的关键了,不管是怎样的随机,它都一定是通过了一定规律的算法,这个算法一旦被知晓,那么我们就可以轻易的通过种子预测结果。
jdk不可能去保护这个算法,它本身都是开源的,所以它是在种子上做了手脚,以让我们能获取更“随机”的随机数,这就是SecureRandom类的产生。

对种子的个人理解:
起先我也疑惑,为啥要这么绕的搞个种子,又搞个算法,难道不能直接获取随机数吗。没错,计算机就是不能这么做,实际上就算放到现实中,我们谁又敢说能做到绝对的随机呢?就算是你抛个硬币,或许在某种精密的计算下,计算风向风速,计算地板材质,计算你抛硬币的力度和硬币高度重量,计算的这一些数值其实都可以看作seed种子,最后通过计算,猜测出落地后是哪一面的概率更高。
计算机更是如此,严格精密的计算机,在没有外力影响它的情况下,它的“世界”里一切数据都是在它掌控之下的,你让它怎么能做出连它自己都未知的举动呢?
所以它只能搞一些花里胡哨的干扰性的seed,最后得出一个人类很难计算预测的随机结果。就像我们一边蹦迪跳舞一边抛硬币,这样的结果比普通方式抛硬币更难计算了一点一样。

除了不够随机的问题,还有就是线程问题,如果我们只生成一个Random实例,然后用多线程并发执行它,会不会有重复的随机数呢?毕竟它的种子是同一个。
其实这点倒无需担心,Random是线程安全的,也就是同一时刻只能有一个线程在计算获取随机值,也就是无论多少个线程,它们的执行始终都分了“先后”顺序。一旦分了先后,就说明它的这次结果肯定和上一次的结果不一样。
但是如果我们挖它一下,就会发现它还有性能问题。虽然线程安全了,但是各线程在争夺共享的seed种子时,也会不可避免的导致性能下降。这里就产生了ThreadLocalRandom类。

为什么不考虑每个线程都new一个Random?
因为这样容易产生多个Random实例不小心取到相同种子的情况,毕竟Random类只是通过系统时间获取种子的。一旦种子相同,那就不可避免的两个Random实例每次产生的随机数也是相同的。

接下来我们会分析,上文提到的这两个类
SecureRandom:安全的、不可预测的、更“随机”的随机数。
ThreadLocalRandom:不影响效率的情况下,多线程生成的随机数

 
 
由于SecureRandom更为重要,我们放到压轴讲

三、ThreadLocalRandom

首先看ThreadLocalRandom,它是适用在多线程的情况下生成随机数的工具类,是从JDK7开始新增的。在前面我们知道了Random虽然也是线程安全的,但是它在多线程的情况下性能很差。
Random生成随机数其实是利用CAS机制,通过旧的seed经过某种算法生成新的seed,然后再通过新的seed计算得出随机数。那么多线程的情况下,每个线程都不得不去争抢seed,一旦没抢到,就只能自旋重试不停的抢了,也就拖慢了效率。

关于CAS机制:
这里Random是用CAS机制做到安全锁的,网上查了下,大概就是乐观锁的一种。它让每个线程都可以正常的获取旧的seed去生成新的seed,并没有任何限制。
但是,在把新的seed覆盖在旧的seed上之前,会加一层判断,判断你当前持有的旧的seed,是否跟Random当前的seed一样。如果一样,说明你在获取旧seed然后计算新seed这个过程中,其他线程没抢过你,你是第一个成功达到覆盖操作的人,那么它就会放行,让你成功的覆盖掉旧seed。而慢了你一步的其他线程,再比较时,取到的seed就是你生成的新seed了,自然就没能成功覆盖到,这时它们只能灰溜溜的滚回去重新获取seed计算了。
因此效率是大大滴差,每个线程都在争抢这个旧seed,不停的循环。。
注:这里我没有过深的分析源码,因为(我也不会)大家都是小白,很多情况下,知其然就可以了,如果我们现在不是很有时间的话,深挖源码就交给几年后的我们吧,毕竟现在需要学的东西太多了。

而为了解决这个问题,ThreadLocalRandom出现了,它在多线程的情况下,让每个线程都维护一个seed变量,这样就不用抢了。就好像给篮球场上的每个人都发一个篮球,大家一人一个,多和谐。
但是这里要注意一下使用方式
因为ThreadLocalRandom类获取实例的方式比较特别,是这样的

ThreadLocalRandom.current()

在使用时,我们直接在各线程里初始化调用如下方法即可,千万不要在线程外初始化实例共用一个实例,这样会导致多线程生成的随机数都是相同的。

ThreadLocalRandom.current().nextInt(1000)

有人会想,如果这样的话,那不就产生很多实例了吗?
其实并不是,JDK8中的ThreadLocalRandom类,看源码就可以知道它始终只用了一个实例,这也是为什么它的初始化是current()方法这么奇怪的原因。
正确的使用方法:

@Override  
public void run() { 
    //每一次产生随机数时都要执行ThreadLocalRandom.current()  
    System.out.println(ThreadLocalRandom.current().nextInt());
}  

通过这样使用,我们就可以在多线程的情况下,不影响效率的生成随机数了。

关于JDK7和8里ThreadLocalRandom的不同:
虽然从JDK7开始,ThreadLocalRandom就诞生了,并且7和8实现的功能和目的是相同的,但是它们在设计上实现方式上还是有所不同。JDK8里的ThreadLocalRandom相较JDK7更为完善,它的性能和效率也要更好一点。不过最主要的区别还是实例,JDK7其实相当于每个线程都维护了一个ThreadLocalRandom实例,而JDK8则是总共就只有一个实例。这其中原理就需要自己去翻书看源码了,这里我就只是简单提一下(我也没时间看)。

重头戏来了,虽然我们不会经常需要用多线程的生成随机数,但我们一定经常会需要安全的、几乎不可预测的随机数。(注:更贴切的说,有时候不是你想用,而是你的公司想让你这样用,各种安全可信编码的扫描之类的)
 
 

四、SecureRandom

这个更“随机”的随机数SecureRandom,本质上跟Random没什么区别,它也是依赖seed种子的。如果给它固定写死的seed,它也是跟Random一样。

1.那为什么说SecureRandom可以更安全的生成随机数呢?
就在于它获取种子方式,前面说了,Random是不可能生成安全的随机数的,因为它选用种子的方式是跟系统时间相关的,说明都是可预测的。而secureRandom,它不仅仅取了系统时间之类的计算机内部参数,它还收集了很多计算机外部的事件参数,比如鼠标点击,键盘点击等等,这些对于计算机而言,是真真正正不可预测的的随机事件。
而它取了这么多不可预测的事件作为生成种子的参数,自然就安全了很多了。我们如果需要生成相对安全的随机数时,就必须用secureRandom。

SecureRandom到这里就结束了,也没什么难的对不对。
不过我们需要再深入一些,因为SecureRandom,也并不是完美的。

2.服务器上使用SecureRandom产生的性能隐患问题
使用了SecureRandom的程序运行在Linux服务器上时,有时候会引发线程阻塞。
因为在linux系统中,它默认是使用/dev/random(linux提供的一个设备)生成种子。但是/dev/random是一个阻塞的随机数生成器,如果它没有足够的随机数据提供,它就会一直等待。有时候我们生成随机数太过频繁了,不停的获取种子。每次获取种子就要从/dev/random里获取随机值,这时就可能出现性能问题,没人知道linux系统熵池里的数量什么时候会不够用,而一旦不够用了,就会阻塞线程。

什么是熵(entropy):
首先我个人简单的概括一下,熵池(读shang)是linux专门用来搞随机值的地方,它定期的从服务器的周围环境噪音或其他输入设备中取得数据,并加入熵池。这跟我们windows下从键盘鼠标里获取随机值是差不多意思,但是显然服务器的输入设备更少,它更难从外界获取随机信息。
官方一点的解释: 在Linux
内核采用熵来描述数据的随机性,熵(entropy)是描述系统混乱无序程度的物理量,一个系统的熵越大则说明该系统的有序性越差,即不确定性越大。
内核维护了一个熵池用来收集来自设备驱动程序和其它来源的环境噪音。理论上,熵池中的数据是完全随机的,可以实现产生真随机数序列。为跟踪熵池中数据的随机性,内核在将数据加入池的时候将估算数据的随机性,这个过程称作熵估算。熵估算值描述池中包含的随机数位数,其值越大表示池中数据的随机性越好。
内核中随机数发生器 PRNG 为一个字符设备 random,代码实现在
drivers/char/random.c,该设备实现了一系列接口函数用于获取系统环境的噪声数据,并加入熵池。系统环境的噪声数据包括设备两次中断间的间隔,输入设备的操作时间间隔,连续磁盘操作的时间间隔等。

网上一查就会发现,不少人踩了这个坑,本地好好的,一到线上环境就阻塞异常了。就是因为这个/dev/random是阻塞的,但是它也的确是极高安全性的随机生成。
解决办法:
解决也很容易,因为linux其实还提供了另外一个非阻塞的随机数发生器:/dev/urandom,它会重复使用熵池中的数据以产生伪随机数据,所以根本不会产生阻塞。

可能有些人又会疑问了,又是伪随机,那绕这么大个圈最后不就回来了吗?
实际上/dev/urandom已经完全足够了,虽然其输出的熵估算值肯定是小于/dev/random,但它对我们大多数应用来说,已经够用了,因为我们正常开发中,绝大多数应用情况都涉及不到需要那种层次的安全性。

具体的切换方式有两种
方法1:直接改JDK的文件,在jdk根目录下:jre\lib\security\java.security文件,打开找一下,就能看见如下的配置

securerandom.source=file:/dev/random

将其改成file:/dev/./urandom就可以了(以前貌似是file:/dev/urandom,但是现在由于BUG,中间加多了一个点)
linux上安装的jdk同样也可以这样改。
方法2(推荐):因为我们经常不方便去直接改jdk的文件,而且改了后也会影响其他的程序,所以通常是用这种方法:指定java系统参数【-Djava.security.egd=file:/dev/./urandom】

如果是tomcat 环境,网上搜索有如下解决方式(未经过我验证) 可以通过配置JRE使用非阻塞的Entropy Source:
在catalina.sh中加入这么一行:-Djava.security.egd=file:/dev/./urandom 即可

3.SecureRandom的初始化
SecureRandom内置的两种随机数算法,NativePRNG 和 SHA1PRNG。
通过new初始化,没有指定算法的情况下,不同平台的默认选择是不一样的。
Windows默认使用SHA1PRNG(windows也没有NativePRNG算法),linux则是NativePRNG。

通常初始化有如下几种方式:
①new SecureRandom();
通常我们都是用无参new进行初始化,使用默认的算法即可,然后按照2里的配置,指定好/dev/./urandom。

②SecureRandom.getInstance(“SHA1PRNG”);
当然还有另一种SecureRandom.getInstance(“算法”);初始化,这种必须要显式的指定算法类型,并且它可能会抛出找不到该算法的异常,在一些特别的场景会用到这种初始化方式。

③SecureRandom.getInstanceStrong()
最后是最不建议使用的一种方式:SecureRandom.getInstanceStrong(),它默认会使用securerandom.strongAlgorithms属性设置的算法(也是前面JDK里的那个文件里的配置),通常默认是NativePRNGBlocking:SUN算法,这种算法和前两种不一样,它是完全依赖/dev/random的,也就是很容易产生阻塞问题,十分影响性能。
由于没有找到合适的系统变量,还只能通过改文件的方式改它默认的算法,非常麻烦。所以一般不要用SecureRandom.getInstanceStrong(),你还不如用SecureRandom.getInstance(“SHA1PRNG”")显式指定算法呢。

这里的SUN: 这个位置一般指提供包的程序,getInstance也可以传两个参数,第二个参数就可以填这个SUN,只是一般不填也没关系。

4.SecureRandom的几种算法
这个真是找了很久,在网上都没有找到相关的讨论,最后要放弃的时候,竟然突然在JDK的Providers的文档里找到了一些- -,
https://docs.oracle.com/en/java/javase/11/security/oracle-providers.html#GUID-3A80CC46-91E1-4E47-AC51-CB7B782CEA7D
在这里插入图片描述
虽然是英文,但也大概看得懂一点,
SHA1PRNG:初始种子设定是通过当前系统属性和java.security熵收集装置
虽然它没有直接说它是用dev/(u)random的,但java.security的linux环境下里设置的已经是这个了,所以大概率它也是用这个生成的。
NativePRNG:nextBytes方法是dev/urandom,生成种子方法是dev/random
NativePRNGBlocking:两个都是dev/random,阻塞的
NativePRNGNonBlocking:两个都是dev/urandom,非阻塞的

另外还有一张,不同平台有什么可选的算法,以及他们的优先度
在这里插入图片描述
可以看到SHA1PNG是哪里都有的,但这不代表我们就要指定用它哦。

虽然好不容易找到这么点,但依然不能十分完全的解答我的疑问,对SHA1PRNG和NativePRNG的区别的了解还是差了点。可惜还是英文不好,不然看看老外是怎么说的就好了。

 

总结:

最后决定这样使用:
绝大多数场景下,使用new无参构造SecureRandom(),并且在启动时加上这一句,防止线程阻塞。
-Djava.security.egd=file:/dev/./urandom

毕竟通常情况下,显示指定算法并没有什么很大的好处,反而在不同平台下很容易出现异常(例如windows就找不到NativePRNG算法),直接使用系统默认的算法就好了。

 
 
 

参考: java中的SecureRandom()
https://blog.csdn.net/qq_36850813/article/details/90901113
java-Random类(伪随机数)学习
https://blog.csdn.net/ununie/article/details/94721413
java.util.Random和ThreadLocalRandom
https://www.iteye.com/blog/pzh9527-2428066
多线程下ThreadLocalRandom用法【评论说他源码分析的有点瑕疵,但是我觉得他大体上说的挺清楚的】
https://www.jianshu.com/p/89dfe990295c
并发包中ThreadLocalRandom作用-CAS是什么?为什么使用AtomicLong而不是Long?
https://blog.csdn.net/PJF1501105594/article/details/87928653 Java
SecureRandom的一点记录【说明了SecureRandom的两种算法】
https://www.iteye.com/blog/wwwcomy-2342229

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值