使用ThreadLocakRandom替换Random
参考:https://cloud.tencent.com/developer/article/1330453
参考:https://developer.51cto.com/article/656036.html
Random类及其局限性
在JDK7之前包括现在java.util.Random应该是使用比较广泛的随机数生成工具类,另外java.lang.Math中的随机数生成也是使用的java.util.Random的实例。下面先看看java.util.Random的使用:
public class RandomTest { public static void main(String[] args) { //(1)创建一个默认种子的随机数生成器 Random random = new Random(); //(2)输出10个在0-5(包含0,不包含5)之间的随机数 for (int i = 0; i < 10; ++i) { System.out.println(random.nextInt(5)); } } }
- 代码(1)创建一个默认随机数生成器,使用默认的种子。
- 代码(2)输出输出10个在0-5(包含0,不包含5)之间的随机数。
这里提下随机数的生成需要一个默认的种子,这个种子其实是一个long类型的数字,这个种子要么在Random的时候通过构造函数指定,那么默认构造函数内部会生成一个默认的值,有了默认的种子后,如何生成随机数那?
public int nextInt(int bound) { //(3)参数检查 if (bound <= 0) throw new IllegalArgumentException(BadBound); //(4)根据老的种子生成新的种子 int r = next(31); //(5)根据新的种子计算随机数 ... return r; }
如上代码可知新的随机数的生成需要两个步骤
- 首先需要根据老的种子生成新的种子。
- 然后根据新的种子来计算新的随机数。
其中步骤(4)我们可以抽象为seed=f(seed),其中f是一个固定的函数,比如seed= f(seed)=a*seed+b;步骤(5)也可以抽象为g(seed,bound),其中g是一个固定的函数,比如g(seed,bound)=(int)((bound * (long)seed) >> 31);在单线程情况下每次调用nextInt都是根据老的种子计算出来新的种子,这是可以保证随机数产生的随机性的。但是在多线程下多个线程可能都拿同一个老的种子去执行步骤(4)计算新的种子,这会导致多个线程产生的新种子是一样的,由于步骤(5)算法是固定的,所以会导致多个线程产生相同的随机值,这并不是我们想要的。所以步骤(4)要保证原子性,也就是说多个线程在根据同一个老种子计算新种子时候,第一个线程的新种子计算出来后,第二个线程要丢弃自己老的种子,要使用第一个线程的新种子来计算自己的新种子,依次类推,只有保证了这个,才能保证多线程下产生的随机数是随机的。Random函数使用一个原子变量达到了这个效果,在创建Random对象时候初始化的种子就保存到了种子原子变量里面,下面看下next()代码:
protected int next(int bits) { long oldseed, nextseed; AtomicLong seed = this.seed; do { //(6) oldseed = seed.get(); //(7) nextseed = (oldseed * multiplier + addend) & mask; //(8) } while (!seed.compareAndSet(oldseed, nextseed)); //(9) return (int)(nextseed >>> (48 - bits)); }
- 代码(6)获取当前原子变量种子的值
- 代码(7)根据当前种子值计算新的种子
- 代码(8)使用CAS操作,使用新的种子去更新老的种子,多线程下可能多个线程都同时执行到了代码(6)那么可能多个线程都拿到的当前种子的值是同一个,然后执行步骤(7)计算的新种子也都是一样的,但是步骤(8)的CAS操作会保证只有一个线程可以更新老的种子为新的,失败的线程会通过循环从新获取更新后的种子作为当前种子去计算老的种子,可见这里解决了上面提到的问题,也就保证了随机数的随机性。
- 代码(9)则使用固定算法根据新的种子计算随机数。
总结下:每个Random实例里面有一个原子性的种子变量用来记录当前的种子的值,当要生成新的随机数时候要根据当前种子计算新的种子并更新回原子变量。多线程下使用单个Random实例生成随机数时候,多个线程同时计算随机数计算新的种子时候多个线程会竞争同一个原子变量的更新操作,由于原子变量的更新是CAS操作,同时只有一个线程会成功,所以会造成大量线程进行自旋重试,这是会降低并发性能的,所以ThreadLocalRandom应运而生。
ThreadLocalRandom
ThreadLocalRandom用法
ThreadLocalRandom使用不当多线程下产生相同随机数
import java.util.concurrent.ThreadLocalRandom; public class ThreadLocalRandomDemo { private static final ThreadLocalRandom RANDOM = ThreadLocalRandom.current(); public static void main(String[] args) { for (int i = 0; i < 10; i++) { new Player().start(); } } private static class Player extends Thread { @Override public void run() { System.out.println(getName() + ": " + RANDOM.nextInt(100)); } } }
运行该代码,结果如下:
Thread-0: 4 Thread-1: 4 Thread-2: 4 Thread-3: 4 Thread-4: 4 Thread-5: 4 Thread-6: 4 Thread-7: 4 Thread-8: 4 Thread-9: 4
原因如下:
除了初始化 ThreadLocalRandom 的主线程获取的随机值是无模式的(调用者不可预测下个返回值,满足我们对伪随机的要求)之外,其他线程获得随机值都不是相互独立的(本质上来说,是因为他们用于生成随机数的种子 seed 的值可预测的,为 i*gamma,其中 i 是当前线程调用随机数生成方法次数,而 gamma 是 ThreadLocalRandom 类的一个 long 静态字段值)。例如,一个有趣的现象是,所有非初始化 ThreadLocalRandom 实例的线程如果调用相同次数的 nextInt() 方法,他们得到的随机数串是完全相同的。
造成这样现象的原因在于,ThreadLocalRandom 类维护了一个类单例字段,线程通过调用 ThreadLocalRandom#current() 方法来获取 ThreadLocalRandom 单例,然后以线程维护的实例字段 threadLocalRandomSeed 为种子生成下一个随机数和下一个种子值。
那么既然是单例模式,为什么多线程共用主线程初始化的实例就会出问题呢。问题就在于 current 方法,线程在调用 current() 方法的时候,会根据用每个线程的 thread 的一个实例字段 threadLocalRandomProbe 是否为 0 来判断是否当前线程实例是否为第一次调用随机数生成方法,从而决定是否要给当前线程初始化一个随机的 threadLocalRandomSeed 种子值。因此,如果其他线程绕过 current 方法直接调用随机数方法,那么它的种子值就是 0, 1*gamma, 2*gamma... 因此也就是可预测的了。
正确用法:
ThreadLocalRandom的正确使用方式是ThreadLocalRandom.current().nextX(...),不能在多线程之间共享ThreadLocalRandom
import java.util.concurrent.ThreadLocalRandom; public class ThreadLocalRandomDemo { public static void main(String[] args) { for (int i = 0; i < 10; i++) { new Player().start(); } } private static class Player extends Thread { @Override public void run() { System.out.println(getName() + ": " + ThreadLocalRandom.current().nextInt(100)); } } }
ThreadRandom原理分析
首先看下ThreadLocalRandom的类图结构:
可知:
- ThreadLocalRandom继承了Random并重写了nextInt方法,ThreadLocalRandom中并没有使用继承自Random的原子性种子变量。
- ThreadLocalRandom中并没有具体存放种子,具体的种子是存放到具体的调用线程的threadLocalRandomSeed变量里面的,ThreadLocalRandom类似于ThreadLocal类就是个工具类
- 当线程调用ThreadLocalRandom的current方法时候ThreadLocalRandom负责初始化调用线程的 threadLocalRandomSeed变量,也就是初始化种子。
- 当调用ThreadLocalRandom的nextInt方法时候,实际上是获取当前线程的threadLocalRandomSeed变量作为当前种子来计算新的种子,然后更新新的种子到当前线程的threadLocalRandomSeed变量,然后在根据新种子和具体算法计算随机数。这里需要注意的是threadLocalRandomSeed变量就是Thread类里面的一个普通long变量,并不是原子性变量,其实道理很简单,因为这个变量是线程级别的,根本不需要使用原子性变量,如果还是不理解可以思考下ThreadLocal的原理。
- 其中变量seeder和probeGenerator是两个原子性变量,在初始化调用线程的种子和探针变量时候用到,每个线程只会使用一次。
- 另外变量instance是个ThreadLocalRandom的一个实例,该变量是static的,当多线程通过ThreadLocalRandom的current方法获取ThreadLocalRandom的实例时候其实获取的是同一个,但是由于具体的种子是存放到线程里面的,所以ThreadLocalRandom的实例里面只是与线程无关的通用算法,所以是线程安全的。
在Thread中的变量
为了应对线程竞争,Java中有一个ThreadLocal类,为每一个线程分配了一个独立的,互不相干的存储空间。
ThreadLocal的实现依赖于Thread对象中的ThreadLocal.ThreadLocalMap threadLocals成员字段。
与之类似,为了让随机数生成器只访问本地线程数据,从而避免竞争,在Thread中,又增加了3个成员:
/** The current seed for a ThreadLocalRandom */ @sun.misc.Contended("tlr") long threadLocalRandomSeed;
/** Probe hash value; nonzero if threadLocalRandomSeed initialized */ @sun.misc.Contended("tlr") int threadLocalRandomProbe;
/** Secondary seed isolated from public ThreadLocalRandom sequence */ @sun.misc.Contended("tlr") int threadLocalRandomSecondarySeed;
这3个字段作为Thread类的成员,便自然和每一个Thread对象牢牢得捆绑在一起,因此成为了名副其实的ThreadLocal变量,而依赖这几个变量实现的随机数生成器,也就成为了ThreadLocalRandom。
消除伪共享
不知道大家有没有注意到, 在这些变量上面,都带有一个注解@sun.misc.Contended,这个注解是干什么用的呢?要了解这个,大家得先知道一下并发编程中的一个重要问题——伪共享:
我们知道,CPU是不直接访问内存的,数据都是从高速缓存中加载到寄存器的,高速缓存又有L1,L2,L3等层级。在这里,我们先简化这些负责的层级关系,假设只有一级缓存和一个主内存。
CPU读取和更新缓存的时候,是以行为单位进行的,也叫一个cache line,一行一般64字节,也就是8个long的长度。
因此,问题就来了,一个缓存行可以放多个变量,如果多个线程同时访问的不同的变量,而这些不同的变量又恰好位于同一个缓存行,那会发生什么呢?
如上图所示,X,Y为相邻2个变量,位于同一个缓存行,两个CPU core1 core2都加载了他们,core1更新X,同时,core2更新Y,由于数据的读取和更新是以缓存行为单位的,这就意味着当这2件事同时发生时,就产生了竞争,导致core1和core2有可能需要重新刷新自己的数据(缓存行被对方更新了),这就导致系统的性能大大折扣,这就是伪共享问题。
那怎么改进呢?如下图:
上图中,我们把X单独占用一个缓存行,Y单独占用一个缓存行,这样各自更新和读取,都不会有任何影响了。
而上述代码中的@sun.misc.Contended("tlr")就会在虚拟机层面,帮助我们在变量的前后生成一些padding,使得被标注的变量位于同一个缓存行,不与其它变量冲突。
在Thread对象中,成员变量threadLocalRandomSeed,threadLocalRandomProbe,threadLocalRandomSecondarySeed被标记为同一个组tlr,使得这3个变量放置于一个单独的缓存行,而不与其它变量发生冲突,从而提高在并发环境中的访问速度。
反射的高效替代方案
随机数的产生需要访问Thread的threadLocalRandomSeed等成员,但是考虑到类的封装性,这些成员却是包内可见的。
很不幸,ThreadLocalRandom位于java.util.concurrent包,而Thread则位于java.lang包,因此,ThreadLocalRandom并没有办法访问Thread的threadLocalRandomSeed等变量。
这时,Java老鸟们可能就会跳出来说:这算什么,看我的反射大法,不管啥都能抠出来访问一下。
说的不错,反射是一种可以绕过封装,直接访问对象内部数据的方法,但是,反射的性能不太好,并不适合作为一个高性能的解决方案。
有没有什么办法可以让ThreadLocalRandom访问Thread的内部成员,同时又具有远超于反射的,且无限接近于直接变量访问的方法呢?答案是肯定的,这就是使用Unsafe类。
这里,就简单介绍一下用的两个Unsafe的方法:
public native long getLong(Object o, long offset); public native void putLong(Object o, long offset, long x);
其中getLong()方法,会读取对象o的第offset字节偏移量的一个long型数据;putLong()则会将x写入对象o的第offset个字节的偏移量中。
这类类似C的操作方法,带来了极大的性能提升,更重要的是,由于它避开了字段名,直接使用偏移量,就可以轻松绕过成员的可见性限制了。
性能问题解决了,那下一个问题是,我怎么知道threadLocalRandomSeed成员在Thread中的偏移位置呢,这就需要用unsafe的objectFieldOffset()方法了,请看下面的代码:
private static final sun.misc.Unsafe UNSAFE; private static final long SEED; private static final long PROBE; private static final long SECONDARY; static { try { //获取unsafe实例 UNSAFE = sun.misc.Unsafe.getUnsafe(); Class<?> tk = Thread.class; //获取Thread类里面threadLocalRandomSeed变量在Thread实例里面偏移量 SEED = UNSAFE.objectFieldOffset (tk.getDeclaredField("threadLocalRandomSeed")); //获取Thread类里面threadLocalRandomProbe变量在Thread实例里面偏移量 PROBE = UNSAFE.objectFieldOffset (tk.getDeclaredField("threadLocalRandomProbe")); //获取Thread类里面threadLocalRandomProbe变量在Thread实例里面偏移量 SECONDARY = UNSAFE.objectFieldOffset (tk.getDeclaredField("threadLocalRandomSecondarySeed")); } catch (Exception e) { throw new Error(e); } }
ThreadLocalRandom current()方法:该方法获取ThreadLocalRandom实例,并初始化调用线程中threadLocalRandomSeed和threadLocalRandomProbe变量。
static final ThreadLocalRandom instance = new ThreadLocalRandom(); public static ThreadLocalRandom current() { //(12) if (UNSAFE.getInt(Thread.currentThread(), PROBE) == 0) //(13) localInit(); //(14) 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); }
如上代码(12)如果当前线程中threadLocalRandomProbe变量值为0(默认情况下线程的这个变量为0),说明当前线程第一次调用ThreadLocalRandom的current方法,那么就需要调用localInit方法计算当前线程的初始化种子变量。这里设计为了延迟初始化,不需要使用随机数功能时候Thread类中的种子变量就不需要被初始化,这是一种优化。
代码(13)首先计算根据probeGenerator计算当前线程中threadLocalRandomProbe的初始化值,然后根据seeder计算当前线程的初始化种子,然后把这两个变量设置到当前线程。
代码(14)返回ThreadLocalRandom的实例,需要注意的是这个方法是静态方法,多个线程返回的是同一个ThreadLocalRandom实例。
int nextInt(int bound)方法:计算当前线程的下一个随机数
public int nextInt(int bound) { //(15)参数校验 if (bound <= 0) throw new IllegalArgumentException(BadBound); //(16) 根据当前线程中种子计算新种子 int r = mix32(nextSeed()); //(17)根据新种子和bound计算随机数 int m = bound - 1; if ((bound & m) == 0) // power of two r &= m; else { // reject over-represented candidates for (int u = r >>> 1; u + m - (r = u % bound) < 0; u = mix32(nextSeed()) >>> 1) ; } return r; }
如上代码逻辑步骤与Random相似,我们重点看下nextSeed()方法:
final long nextSeed() { Thread t; long r; // UNSAFE.putLong(t = Thread.currentThread(), SEED, r = UNSAFE.getLong(t, SEED) + GAMMA); return r; }
如上代码首先使用 r = UNSAFE.getLong(t, SEED)获取当前线程中threadLocalRandomSeed变量的值,然后在种子的基础上累加GAMMA值作为新种子,然后使用UNSAFE的putLong方法把新种子放入当前线程的threadLocalRandomSeed变量。
这种Unsafe的方法掉地能有多快呢,让我们一起看做个试验看看:
这里,我们自己写一个ThreadTest类,使用反射和unsafe两种方法,来不停读写threadLocalRandomSeed成员变量,比较它们的性能差异,代码如下:
上述代码中,分别使用反射方式byReflection() 和Unsafe的方式byUnsafe()来读写threadLocalRandomSeed变量1亿次,得到的测试结果如下:
byUnsafe spend :171ms byReflection spend :645ms
不难看到,使用Unsafe的方法远远优于反射的方法,这也是JDK内部,大量使用Unsafe来替代反射的原因之一。
随机数种子
我们知道,伪随机数生成都需要一个种子,threadLocalRandomSeed和threadLocalRandomSecondarySeed就是这里的种子。其中threadLocalRandomSeed是long型的,threadLocalRandomSecondarySeed是int。
threadLocalRandomSeed是使用最广泛的大量的随机数其实都是基于threadLocalRandomSeed的。而threadLocalRandomSecondarySeed只是某些特定的JDK内部实现中有使用,使用并不广泛。
初始种子默认使用的是系统时间:
上述代码中完成了种子的初始化,并将初始化的种子通过UNSAFE存在SEED的位置(即threadLocalRandomSeed)。
接着就可以使用nextInt()方法获得随机整数了:
public int nextInt() { return mix32(nextSeed()); } 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; }
每一次调用nextInt()都会使用nextSeed()更新threadLocalRandomSeed。由于这是一个线程独有的变量,因此完全不会有竞争,也不会有CAS的重试,性能也就大大提高了。
总结
今天,我们介绍了ThreadLocalRandom对象,这是一个高并发环境中的,高性能的随机数生成器。
我们不但介绍了ThreadLocalRandom的功能和内部实现原理,还介绍介绍了ThreadLocalRandom对象是如何达到高性能的(比如通过伪共享,Unsafe等手段),希望大家可以将这些技术灵活运用到自己的工程中。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix