Java 生成随机数及 SecureRandom 引发的线程阻塞
2024-09-18 02:46:04 # Technical # JavaBase

随机数的生成

通常随机数会通过 Random 类、ThreadLocalRandom 类、SecureRandom 类或者 Math.random() 方法生成

Random

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.Random;

public class RandomExample {
public static void main(String[] args) {
Random rand = new Random();

// 生成一个随机整数
int randomInt = rand.nextInt();
System.out.println("Random Integer: " + randomInt);

// 生成一个0到99之间的随机整数
int randomIntWithinRange = rand.nextInt(100);
System.out.println("Random Integer (0-99): " + randomIntWithinRange);

// 生成一个随机浮点数(0.0 到 1.0 之间)
float randomFloat = rand.nextFloat();
System.out.println("Random Float: " + randomFloat);

// 生成一个随机布尔值
boolean randomBoolean = rand.nextBoolean();
System.out.println("Random Boolean: " + randomBoolean);
}
}

ThreadLocalRandom

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.concurrent.ThreadLocalRandom;

public class ThreadLocalRandomExample {
public static void main(String[] args) {
// 生成一个0到99之间的随机整数
int randomInt = ThreadLocalRandom.current().nextInt(100);
System.out.println("Random Integer (0-99): " + randomInt);

// 生成一个0.0到1.0之间的随机浮点数
double randomDouble = ThreadLocalRandom.current().nextDouble();
System.out.println("Random Double: " + randomDouble);

// 生成一个指定范围内的随机整数
int randomIntWithinRange = ThreadLocalRandom.current().nextInt(50, 100);
System.out.println("Random Integer (50-99): " + randomIntWithinRange);
}
}

SecureRandom

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.security.SecureRandom;

public class SecureRandomExample {
public static void main(String[] args) {
SecureRandom secureRand = new SecureRandom();

// 生成一个0到99之间的随机整数
int randomInt = secureRand.nextInt(100);
System.out.println("Secure Random Integer (0-99): " + randomInt);

// 生成一个随机的字节数组
byte[] randomBytes = new byte[16];
secureRand.nextBytes(randomBytes);
System.out.println("Secure Random Bytes: " + Arrays.toString(randomBytes));
}
}

Math.random

1
2
3
4
5
6
7
8
9
10
11
public class MathRandomExample {
public static void main(String[] args) {
// 生成一个0.0到1.0之间的随机浮点数
double randomDouble = Math.random();
System.out.println("Random Double: " + randomDouble);

// 生成一个0到99之间的随机整数
int randomInt = (int)(Math.random() * 100);
System.out.println("Random Integer (0-99): " + randomInt);
}
}

优缺点

四种生成方式在性能和安全性方面的优缺点比较

Random

Random 使用了一种简单且高效的线性同余生成器(LCG)算法来生成 ==伪随机数==
$$
seed_{n+1} = (seed_n \times a + c) mod m
$$
这样可以带来较高的性能,但缺点是会导致随机数可被预测

如果两个 Random 实例使用相同的种子进行初始化,那么它们生成的随机数序列将完全相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.Random;

public class PredictableRandom {
public static void main(String[] args) {
// 使用相同的种子创建两个Random实例
long seed = 12345L;
Random rand1 = new Random(seed);
Random rand2 = new Random(seed);

// 打印前10个随机数
System.out.println("rand1: ");
for (int i = 0; i < 10; i++) {
System.out.print(rand1.nextInt(100) + " ");
}

System.out.println("\nrand2: ");
for (int i = 0; i < 10; i++) {
System.out.print(rand2.nextInt(100) + " ");
}
}
}

输出结果将显示两个实例生成的随机数序列完全相同:

1
2
3
4
rand1: 
87 1 54 20 93 94 95 68 15 57
rand2:
87 1 54 20 93 94 95 68 15 57

Random 是 ==线程安全== 的

1
2
3
4
5
6
7
8
9
10
11
12
protected int next(int bits) {
AtomicLong seed = this.seed;

long oldseed;
long nextseed;
do {
oldseed = seed.get();
nextseed = oldseed * 25214903917L + 11L & 281474976710655L;
} while(!seed.compareAndSet(oldseed, nextseed));

return (int)(nextseed >>> 48 - bits);
}

Random 通过 CAS 确保线程安全

ThreadLocalRandom

ThreadLocalRandom 与 Random 的特性类似,主要的区别在于 ThreadLocalRandom 解决了 Random 在高并发场景下性能不足的问题

Radom 通过 CAS 来解决线程安全问题,但 CAS 在线程竞争比较激烈的场景中性能低下,原因是 CAS 对比时如果经常有其他线程修改原来的值,就会导致 CAS 对比失败,于是一直自旋进行 CAS

而 ThreadLocalRandom 利用 ThreadLocal 为每个线程分配一个独立的随机数生成实例,避免了线程间的竞争。另外,为了进一步优化性能,ThreadLocalRandom 利用缓存行填充技术来防止伪共享(false sharing)

CPU 与内存间存在 L1,L2,L3 三级缓存,CPU 在执行运算的时候,首先去 L1 查找所需数据,其次是 L2,L3,如果缓存中都没有,就去内存中拿。走得越远,耗时越长

由于共享变量在 CPU 缓存中的存储是以 缓存行 为单位,一个缓存行可以存储多个变量(存满当前缓存行的字节数)

而 CPU 对缓存的修改又是以 缓存行 为最小单位的,在多线程情况下,如果需要修改「共享同一个缓存行的变量」,就会无意中影响彼此的性能,这就是伪共享(False Sharing)

ThreadLocalRandom 虽然提高了 Random 的性能,但仍是统计学上的随机数,是可预测的随机数

SecureRandom

要想获得密码学上的随机性,需要满足更严格的要求:

  • 不可预测性:即使攻击者知道了一部分随机数,也无法推算出其他部分
  • 熵源的复杂性:需要复杂和高质量的熵源,确保生成的随机数无法被逆向工程
  • 安全算法:使用复杂的算法,增加攻击和预测的难度

SecureRandom 不同于 Random 使用系统当前时间作为种子,SecureRandom 使用 随机事件 作为种子,比如:鼠标点击,键盘点击等

这一点十分重要,也是后面导致 SecureRandom 引发线程阻塞的原因

SecureRandom 引发的线程阻塞

起因是,通过如下代码实现了一个验证码生成的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
private Object[] genVerificationCodeGraph() {
int width = 200;
int height = 69;
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = (Graphics2D) image.getGraphics();
// 设置画笔颜色-验证码背景色
graphics.setColor(Color.WHITE);
// 填充背景
graphics.fillRect(0, 0, width, height);
graphics.setFont(new Font("微软雅黑", Font.BOLD, 40));

StringBuilder sb = new StringBuilder();
// 旋转原点的 x 坐标
int x = 10;
String ch;

for (int i = 0; i < 4; i++) {
graphics.setColor(getRandomColor());

// 设置字体旋转角度,角度小于30度
int degree = random.nextInt() % 30;
int dot = random.nextInt(codeSequence.length);

ch = String.valueOf(codeSequence[dot]);
sb.append(ch);

// 正向旋转
graphics.rotate(degree * Math.PI / 180, x, 45);
graphics.drawString(ch, x, 45);

// 反向旋转
graphics.rotate(-degree * Math.PI / 180, x, 45);
x += 48;
}

//画干扰线
for (int i = 0; i < 6; i++) {
// 设置随机颜色
graphics.setColor(getRandomColor());
// 随机画线
graphics.drawLine(random.nextInt(width), random.nextInt(height),
random.nextInt(width), random.nextInt(height));
}

//添加噪点
for (int i = 0; i < 30; i++) {
int x1 = random.nextInt(width);
int y1 = random.nextInt(height);

graphics.setColor(getRandomColor());
graphics.fillRect(x1, y1, 2, 2);
}

graphics.dispose();
return new Object[]{sb.toString(), image};
}

/**
* 随机取色
*/
private Color getRandomColor() {
return new Color(random.nextInt(256),
random.nextInt(256), random.nextInt(256));
}

代码运行并无任何问题,不过 SonarLint 却提示代码存在缺陷,SonarLint 建议如下

Noncompliant Code Example

1
2
3
4
public void doSomethingCommon() {
Random rand = new Random(); // Noncompliant; new instance created with each invocation
int rValue = rand.nextInt();
//...

Compliant Solution

1
2
3
4
5
private Random rand = SecureRandom.getInstanceStrong();  // SecureRandom is preferred to Random

public void doSomethingCommon() {
int rValue = this.rand.nextInt();
//...

当时并没过多思考,便直接将 SonarLint 建议的直接应用了

发布到测试环境进行测试时就出现了十分怪异的现象,获取验证码的接口一直无响应,重启应用也无济于事,但我本地运行又是没有任何问题的

到测试环境通过 jstack 查看线程状态,发现线程都阻塞在 SecureRandom 中

原因

原因就出在操作系统上,SecureRandom generateSeed() 使用 /dev/random 生成种子,/dev/random 是一个从环境噪声中收集熵(如键盘输入、鼠标移动、磁盘操作等)来生成随机数的 阻塞 数字生成器,如果它没有足够的随机数据提供,它就一直等待

类似的,还存在一个 /dev/urandom,一个非阻塞随机数生成器,它适用于需要随机数但不要求极高安全性的场合

解决

  • 最直接的方式

    不使用 SecureRandom,换为 Random 或 ThreadLocalRandom

  • 修改配置

    1
    2
    3
    -Djava.security.egd=file:/dev/urandom
    或者
    -Djava.security.egd=file:/dev/./urandom
  • 代码中显式指定非阻塞算法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import java.security.NoSuchAlgorithmException;
    import java.security.SecureRandom;
    import java.security.Security;

    public class SecureRandomExample {
    public static void main(String[] args) {
    try {
    // 使用指定的算法 "NativePRNGNonBlocking" 以避免阻塞
    SecureRandom secureRandom = SecureRandom.getInstance("NativePRNGNonBlocking");
    System.out.println("Random Integer: " + secureRandom.nextInt());
    } catch (NoSuchAlgorithmException e) {
    e.printStackTrace();
    }
    }
    }
  • 使用第三方库增加熵源

    可以使用 havegedrng-tools 来增加系统的熵源,从而减少阻塞的概率

    安装 haveged

    1
    2
    3
    $ sudo apt-get install haveged
    $ sudo systemctl enable haveged
    $ sudo systemctl start haveged

    安装 rng-tools

    1
    2
    3
    $ sudo apt-get install rng-tools
    $ sudo systemctl enable rngd
    $ sudo systemctl start rngd