Java Random Number Generators in Multithreaded Code

A shared Random is thread-safe but makes every thread fight over one seed. Learn which generator each thread should use, how to keep seeded results reproducible, and what a JMH benchmark measured on Java 25.

Top: SplittableRandom with seed 42 is split four times for Alex, Ben, Chen and Dana before a pool with 1, 4 or 8 threads runs them, and the totals are always Alex 3473, Ben 3584, Chen 3421, Dana 3537. Bottom: one Random with seed 42 shared by 4 threads gives Alex 3410 in run 1 and 3483 in run 2

A pseudo-random number generator is an object that computes each “random” number from internal state, called the seed, and then updates that seed for the next call. Java random number generators such as java.util.Random and SecureRandom are thread-safe, so many threads can call one shared instance without corrupting it. The price is contention: every call from every thread must update the same seed, so threads retry or wait, and each call costs more than a call on a generator that belongs to one thread.

We need a random generator in concurrent code whenever several threads draw values at the same time, for example a game server that rolls dice for every player on its own thread, or a raffle service that draws tickets for parallel requests.

The following example creates the generators that matter for concurrent code and uses them for dice rolls and raffle tickets.

Random shared = new Random();                              // thread-safe, one shared seed
ThreadLocalRandom local = ThreadLocalRandom.current();     // one seed per thread, no setSeed()
SplittableRandom root = new SplittableRandom(42);          // not thread-safe, split() per task
RandomGenerator lxm = RandomGeneratorFactory.of("L64X128MixRandom").create(42);
SecureRandom secure = new SecureRandom();                  // thread-safe, for security values
int roll = ThreadLocalRandom.current().nextInt(1, 7);      // roll = 3 (differs per run)
SplittableRandom alex = root.split();                      // one generator for one task
List<Integer> rolls = rolls(alex, 5);                      // rolls = [4, 3, 4, 4, 3]
int[] ticket = TicketDraw.draw(lxm);                       // ticket = [17, 20, 22, 27, 31, 32]
ThreadLocalRandom.current().setSeed(42);                   // UnsupportedOperationException

Notice that SplittableRandom and L64X128MixRandom give the same numbers for the same seed, whereas ThreadLocalRandom has no seed we can set and gives different numbers in every run.

This guide shows which generator each thread should use, how to keep seeded results reproducible when the work runs on many threads, and what a JMH benchmark measured for the shared and per-thread options in Java 25.

1. Why Does a Shared Random Slow Down Threads?

The class java.util.Random keeps its 48-bit seed in an AtomicLong. Each call reads the seed, computes the next one, and writes the new seed back with compareAndSet(). Compare-and-set (CAS) writes the new value only if the variable still holds the value the thread read; otherwise the CAS fails, and the thread tries again. The method Random.next() in the JDK 25 sources (lib/src.zip in every JDK) shows the retry loop.

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));
}

With one thread, the CAS always succeeds. With four threads on one instance, only one CAS wins each round, and the others loop and read the seed again. The seed also moves between CPU caches on every successful write. ThreadLocalRandom and per-thread generators avoid both problems because no two threads write the same state.

Left: four threads point to one AtomicLong seed of a shared Random; thread 1 wins the compareAndSet and threads 2 to 4 fail and retry. Right: four threads each write their own seed with a plain write and no retries
A shared Random turns every call into a race for one AtomicLong. Per-thread state removes the race.

Math.random() has the same problem, because it calls nextDouble() on one static Random for the whole JVM, and nextDouble() calls next() twice. So each Math.random() call makes two CAS updates on a seed that every thread in the application shares.

private static final class RandomNumberGeneratorHolder {
    static final Random randomNumberGenerator = new Random();
}

The JDK contains five generator families, and each behaves differently when threads share or seed the generator. Thread-safe does not mean fast when shared, and it does not mean reproducible.

GeneratorThread-safeContention when sharedReproducible with a seedUse case
Random, Math.random()YesHigh: CAS on one AtomicLongYes, single thread onlyLegacy and single-threaded code
ThreadLocalRandomYes, one state per threadNoneNo, setSeed() throwsDefault for multithreaded code
SplittableRandomNoDo not share an instanceYes, with split() per taskSeeded fork/join and pool tasks
L64X128MixRandom and other LXMNoDo not share an instanceYes, create(seed) or split()Seeded simulations, better quality
SecureRandomYesHigh: locks inside the providerNoTokens, passwords, prize draws

We pick a generator by answering three questions in order, starting with security.

Decision tree: if someone can gain by predicting the value, use SecureRandom; otherwise if a seed need not repeat the results, use ThreadLocalRandom.current() at each use; otherwise if the work is not split into many tasks, use one seeded L64X128MixRandom per thread; if it is, split() one generator per task in a fixed order before the tasks start
Start with security, then reproducibility, then the shape of the work.

2. Java Random Number Generators in a Raffle and Dice Game

The complete project uses Java 25, JMH 1.37 and JUnit 6.1.3. The project has a raffle service that draws tickets and a dice game where each player rolls on its own thread. The 15 JUnit tests run with mvn test, and mvn -q compile exec:java prints the results.

2.1. The Ticket Draw and the Dice Roll

Since Java 17, all JDK generators implement the RandomGenerator interface, including Random, ThreadLocalRandom and SecureRandom. So the domain code takes a RandomGenerator, and the caller decides which one to pass.

public static int[] draw(RandomGenerator rng) {      // 6 distinct numbers from 1 to 49
  return rng.ints(1, 50).distinct().limit(6).sorted().toArray();
}

public static int roll(RandomGenerator rng) {        // one die: 1 to 6
  return rng.nextInt(1, 7);
}

2.2. java.util.Random With a Seed

The same seed gives the same sequence, so new Random(42) always draws the same ticket, but only while one thread uses the instance. For example, a unit test for the raffle passes new Random(42) and expects the ticket [20, 26, 30, 32, 34, 35] in every run.

int[] first = TicketDraw.draw(new Random(42));       // [20, 26, 30, 32, 34, 35]
int[] second = TicketDraw.draw(new Random(42));      // [20, 26, 30, 32, 34, 35]

2.3. ThreadLocalRandom

ThreadLocalRandom stores its seed in a field of the current Thread object, so each thread has its own state. Unlike a ThreadLocal variable, ThreadLocalRandom needs no map lookup. We call current() at each use.

int roll = ThreadLocalRandom.current().nextInt(1, 7);      // 3 in one run
int[] ticket = TicketDraw.draw(ThreadLocalRandom.current());
ThreadLocalRandom.current().setSeed(42);                   // UnsupportedOperationException

The JDK seeds ThreadLocalRandom itself, so its results cannot be repeated. For tests and replays that need a fixed seed, we use one of the next two options.

2.4. SplittableRandom and split()

SplittableRandom is not thread-safe, so instead of sharing one instance, we call split(), which returns a new, independent generator for each task and advances the parent. The same seed and the same order of split() calls give the same children (rolls(rng, n) is a demo helper that calls roll() n times).

SplittableRandom root = new SplittableRandom(42);
SplittableRandom alex = root.split();
SplittableRandom ben = root.split();

List<Integer> alexRolls = rolls(alex, 5);                         // [4, 3, 4, 4, 3]
List<Integer> benRolls = rolls(ben, 5);                           // [5, 2, 3, 3, 4]
List<Integer> again = rolls(new SplittableRandom(42).split(), 5);  // [4, 3, 4, 4, 3] again

2.5. The RandomGenerator API and L64X128MixRandom

JEP 356 (Java 17) added the java.util.random package with the RandomGenerator interface and new algorithms such as the LXM family, which RandomGeneratorFactory creates by name. The name LXM stands for “LCG, XBG, Mixer”, which means each generator combines a linear congruential generator (the same kind of formula as Random) with a xor-based generator and mixes their outputs with a function. L64X128MixRandom is the recommended choice for applications with no special requirements, with a separate instance for each thread.

RandomGenerator defaultRng = RandomGenerator.getDefault();      // L32X64MixRandom
RandomGenerator lxm = RandomGenerator.of("L64X128MixRandom");
RandomGenerator seeded = RandomGeneratorFactory.of("L64X128MixRandom").create(42);
int[] ticket = TicketDraw.draw(seeded);                         // [17, 20, 22, 27, 31, 32]

RandomGeneratorFactory.all() lists the algorithms with their properties. A splittable generator has split(), and a jumpable one has jump(), which moves the state far ahead (2^64 or more steps). The state bits column shows the size of the internal state.

AlgorithmGroupSplittableJumpableState bits
L32X64MixRandom (default)LXMYesNo96
L64X128MixRandom, L64X128StarStarRandomLXMYesNo192
L64X256MixRandom, L64X1024MixRandomLXMYesNo320, 1088
L128X128MixRandom, L128X256MixRandom, L128X1024MixRandomLXMYesNo256, 384, 1152
Xoroshiro128PlusPlus, Xoshiro256PlusPlusXoroshiro, XoshiroNoYes128, 256
SplittableRandomLegacyYesNo64
Random, SecureRandomLegacyNoNo48, Integer.MAX_VALUE

All 13 are streamable except Random and SecureRandom. We can also pick an algorithm by property instead of by name.

RandomGenerator picked = RandomGeneratorFactory.all()
    .filter(RandomGeneratorFactory::isSplittable)
    .filter(f -> f.stateBits() >= 128)
    .min(Comparator.comparingInt(RandomGeneratorFactory::stateBits))
    .orElseThrow()
    .create();                                       // L64X128MixRandom or L64X128StarStarRandom (order varies per run)

2.6. Splittable and Jumpable Generators for Parallel Work

A SplittableGenerator returns many children at once with splits(n), and a JumpableGenerator returns n generators whose sequences do not overlap with jumps(n).

SplittableGenerator lxmRoot = (SplittableGenerator) RandomGeneratorFactory.of("L64X128MixRandom").create(42);
List<SplittableGenerator> players = lxmRoot.splits(4).toList();   // first rolls [5, 6, 1, 5]

JumpableGenerator xoshiro = (JumpableGenerator) RandomGeneratorFactory.of("Xoshiro256PlusPlus").create(42);
List<RandomGenerator> lanes = xoshiro.jumps(3).toList();          // first rolls [1, 6, 5]

The dice game uses splitting for a reproducible match. We split one generator per player on the calling thread, in a fixed order, before any task starts. Then the thread pool can run the players in any order, on any number of threads.

SplittableRandom root = new SplittableRandom(seed);
Map<String, SplittableRandom> perPlayer = new LinkedHashMap<>();
for (String player : players) {
  perPlayer.put(player, root.split());               // fixed order
}
// each pool task rolls 1000 times with its own generator
splits, 1 thread  = {Alex=3473, Ben=3584, Chen=3421, Dana=3537}
splits, 4 threads = {Alex=3473, Ben=3584, Chen=3421, Dana=3537}
shared Random(42), 4 threads, run 1 = {Alex=3410, Ben=3483, Chen=3533, Dana=3488}
shared Random(42), 4 threads, run 2 = {Alex=3483, Ben=3410, Chen=3488, Dana=3533}

The shared Random(42) still produces the same 4000 rolls, and the grand total is 13914 in every run. But the threads take values in a different order each time, so the players get different rolls.

Top: SplittableRandom with seed 42 is split four times for Alex, Ben, Chen and Dana before a pool with 1, 4 or 8 threads runs the players, and the totals are always Alex 3473, Ben 3584, Chen 3421, Dana 3537. Bottom: one Random with seed 42 shared by 4 threads gives Alex 3410 in run 1 and 3483 in run 2
Splitting first fixes which rolls each player gets. A shared seeded generator fixes only the overall sequence.

Parallel streams need the same care. In JDK 25, ints() on a SplittableRandom or LXM generator returns a stream that reads the same generator object in every worker thread of the fork/join pool (the source comment in RandomSupport says “The same generatingGenerator is used, with no splitting or copying”). So a seeded parallel stream is neither reproducible nor safe, and splitting one generator per chunk first fixes both.

long sequential = new SplittableRandom(42).ints(1_000_000, 1, 7).asLongStream().sum();             // 3500266
long parallel = new SplittableRandom(42).ints(1_000_000, 1, 7).parallel().asLongStream().sum();
// 20 runs on a 4-thread pool gave 5 different sums: 3500266, 3500315, 3500323, 3500354, 3500366

SplittableRandom root = new SplittableRandom(42);
List<SplittableRandom> perChunk = IntStream.range(0, 1_000).mapToObj(i -> root.split()).toList();
long chunked = IntStream.range(0, 1_000).parallel()
    .mapToLong(i -> DiceGame.total(perChunk.get(i), 1_000))
    .sum();                                          // 3499222 in every run

ThreadLocalRandom.current().ints().parallel() is safe, because its stream calls current() in each worker thread, but the stream is not reproducible.

2.7. SecureRandom in Multithreaded Code

SecureRandom produces values an attacker cannot predict, so a raffle with real prizes draws its winners with SecureRandom. Its instances are safe for use by concurrent threads. A provider that registers the attribute ThreadSafe=true handles locking itself; for other providers, SecureRandom synchronizes the calls. In JDK 25, all five SUN algorithms (NativePRNG, NativePRNGBlocking, NativePRNGNonBlocking, DRBG, SHA1PRNG) declare ThreadSafe=true.

SecureRandom secure = new SecureRandom();            // algorithm = NativePRNG
int[] ticket = TicketDraw.draw(secure);              // [2, 7, 9, 18, 30, 31] in one run

SecureRandom strong = SecureRandom.getInstanceStrong();   // NativePRNGBlocking, reads /dev/random

SecureRandom drbg = SecureRandom.getInstance("DRBG",
    DrbgParameters.instantiation(256, PR_AND_RESEED, "raffle".getBytes()));
SecureRandomParameters params = drbg.getParameters();   // 256,pr_and_reseed,[114, 97, ...]

Thread-safe does not mean free of contention. NativePRNG reads /dev/urandom into one buffer for the whole JVM and takes a lock on that buffer in every nextBytes() call, so even one NativePRNG object per thread shares the lock. DRBG (a deterministic random bit generator from NIST SP 800-90A) synchronizes every request on its instance. With PR_AND_RESEED (prediction resistance), DRBG also reseeds from the entropy source on every request, which is slower.

We avoid getInstanceStrong() on request paths. On Linux getInstanceStrong() returns NativePRNGBlocking, which reads /dev/random. Recent Linux kernels block /dev/random only until the entropy pool is first initialized, but older kernels and some containers can block for seconds. The first nextInt() took about 1 millisecond (929 and 1188 microseconds in two runs) on the sandbox, so the call does not block there.

2.8. JMH Benchmark With 1, 4 and 8 Threads

The benchmark rolls one die per operation with JMH 1.37. Scope.Benchmark state is shared by all benchmark threads, and Scope.Thread state is created once per thread.

@State(Scope.Benchmark)
public static class Shared {
  final Random random = new Random();
  final SecureRandom secureRandom = new SecureRandom();
}

@State(Scope.Thread)
public static class PerThread {
  final SplittableRandom splittable = new SplittableRandom();
  final RandomGenerator lxm = RandomGeneratorFactory.of("L64X128MixRandom").create();
}

@Benchmark
public int sharedRandom(Shared s) {
  return s.random.nextInt(1, 7);
}

@Benchmark
public int threadLocalRandom() {
  return ThreadLocalRandom.current().nextInt(1, 7);
}
mvn package -DskipTests
java -jar target/benchmarks.jar -t 4

We ran the benchmark with -t 1, -t 4 and -t 8 (2 forks, 3 warmup and 5 measurement iterations of 1 second each). The scores are operations per millisecond, so higher is better. We measured on a small sandbox with 2 vCPUs (Intel Xeon, 2.10 GHz) and JDK 25.0.4, while other processes were using the same CPUs.

Generator1 thread4 threads8 threads
Shared Random24,80019,10024,500
Math.random()8,1007,90010,100
ThreadLocalRandom102,50081,100138,200
Per-thread SplittableRandom156,30090,400278,600
Per-thread L64X128MixRandom43,70083,50088,000
Shared SecureRandom (NativePRNG)2,8003,5002,800
new Random() per call3,3003,4003,500

ThreadLocalRandom and the per-thread SplittableRandom were 4 to 11 times faster than the shared Random at every thread count, and the per-thread L64X128MixRandom was 1.8 to 4.4 times faster. The shared SecureRandom was 5 to 9 times slower than the shared Random. Math.random() was slower than the shared Random, because each call makes two CAS updates and a double conversion. Even with 1 thread, ThreadLocalRandom was 4 times faster than the shared Random, which pays for the atomic CAS instruction on every call.

The error margins were large, up to the size of the score for some rows, because other processes used the CPUs during the run. Treat differences under about 2 times as noise. With only 2 vCPUs, at most two benchmark threads ran at the same moment, so this machine could not show how a shared Random slows down on a server with many cores. Run the benchmark on the target hardware before deciding on numbers.

3. Random Generators and Concurrency FAQs

3.1. Why Does ThreadLocalRandom.current() in a Field Return the Same Numbers?

The method current() initializes the seed of the thread that calls it and returns one JVM-wide ThreadLocalRandom object. That object always reads the seed of the thread that calls nextInt(). If we store the object in a field and use the object from a thread that never called current(), that thread’s seed was never initialized, and the values depend only on the thread ID.

private static final ThreadLocalRandom TLR_FIELD = ThreadLocalRandom.current();   // main thread

// on a new thread
int wrong = DiceGame.roll(TLR_FIELD);                      // wrong: never initialized for this thread
int right = DiceGame.roll(ThreadLocalRandom.current());    // right
$ java -cp target/classes com.howtodoinjava.random.CommonBugs
[3, 1, 5, 3, 2, 4, 5, 4, 4, 3]
$ java -cp target/classes com.howtodoinjava.random.CommonBugs
[3, 1, 5, 3, 2, 4, 5, 4, 4, 3]

Every run of the application rolls the same “random” numbers. In a raffle, anyone who knows the code can predict the draw. The stream methods such as ints() are not affected, because those methods call current() internally.

3.2. Is It Bad to Create a New Random for Every Call?

Yes, for two reasons. The constructor new Random() updates a static AtomicLong (seedUniquifier) shared by all threads, so the constructor call itself has contention, and the benchmark measured newRandomPerCall as the slowest non-secure option. A clock seed is worse, because calls in the same millisecond get the same seed and draw the same ticket.

int[] ticket = TicketDraw.draw(new Random(System.currentTimeMillis()));   // called 3 times in a loop
// run 1: [3, 5, 7, 31, 37, 39], [3, 5, 7, 31, 37, 39], [3, 5, 7, 31, 37, 39]
// run 2: [10, 22, 33, 34, 36, 40], [10, 22, 33, 34, 36, 40], [8, 13, 15, 20, 23, 24]

3.3. Can I Set a Seed on ThreadLocalRandom?

No. ThreadLocalRandom.current().setSeed(42) throws UnsupportedOperationException. For a seeded run, we create new SplittableRandom(seed) or RandomGeneratorFactory.of(“L64X128MixRandom”).create(seed) and split the generator per task, as in section 2.6.

3.4. Does ThreadLocalRandom Work With Virtual Threads?

Yes. Every virtual thread is a Thread object with its own seed field. We started 10,000 virtual threads that each called ThreadLocalRandom.current().nextLong(), and they produced 10,000 distinct values.

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
  for (int i = 0; i < 10_000; i++) {
    executor.submit(() -> values.add(ThreadLocalRandom.current().nextLong()));
  }
}
int count = values.size();                           // 10000

3.5. Should Each Thread Have Its Own SecureRandom?

In most cases, no. With the default NativePRNG on Linux, all SecureRandom objects read the same JVM-wide buffer under one lock, as section 2.7 shows, so a second object per thread adds memory without removing that lock. A DRBG instance per thread has its own state and its own lock, but each instance takes a seed from the entropy source when it starts. One shared SecureRandom is correct, and we change the setup only when a profiler shows threads waiting on the shared instance.

4. Conclusion

Random and SecureRandom are thread-safe, but a shared instance makes every thread update the same seed, and in our benchmark each call cost 4 to 11 times more than a call on ThreadLocalRandom or a per-thread SplittableRandom. In new code we call ThreadLocalRandom.current() at each use, and when results must repeat from a seed, we split a SplittableRandom or an LXM generator such as L64X128MixRandom into one generator per task before the tasks start. SecureRandom stays for values that must not be predictable. More topics on threads and locks are in the Java concurrency guide.

5. References

Happy Learning !!

Source Code on Github

Leave a Comment

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.