Beyond java.util.Random: The Evolution of Random Number Generators in Java
TL;DR — Java’s journey from the classic java.util.Random to the new java.util.random (Java 17+) brings thread‑safety, parallel‑friendly streams, and a shelf full of high‑quality algorithms. This post walks through that history, shows code to list every built‑in generator (and what splittable, jumpable, leapable, etc. really mean), and finishes with a JMH harness so you can measure them yourself.
1. java.util.Random — the Old Good RNG (Java 1.0)
Random arrived with Java 1.0 in 1995. It is a linear‑congruential generator (LCG) with 48‑bit state.
- Pros — Simple API, deterministic streams via seeds.
- Cons — Shared mutable state (contention in multi‑threaded code), mediocre statistical quality, period of 2 ⁴⁸ (~2.8 ×10 ¹⁴) which is tiny for today’s data‑hungry apps.
2. ThreadLocalRandom — cheap randomness for everyone (Java 7)
Java 7’s java.util.concurrent.ThreadLocalRandom fixed contention by giving each thread its own RNG backed by a XorShift algorithm.
- No locking; faster than Random when many threads generate numbers.
- Still tied to the JVM’s global seed; not splittable.
3. java.util.random — a modern playground (Java 17)
Java 17 introduced the Random Generator API.
- RandomGenerator – minimal interface implemented by anything that can generate random primitives.
- RandomGeneratorFactory – produces generators by name, group, jump size, etc.
- New algorithm families: LCG‑LXM, Xoroshiro/Xoshiro, SplitMix, plus upgrades to the classics.
3.1 Programmatically listing every built‑in generator
Note: the majority of this code is to produce a nice and clean output
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.util.*;
import java.util.random.*;
public class ListRandomGenerators {
public static void main(String[] args) throws Exception {
/* Discover capability-flag methods (isXxx) ---------------------------------- */
List<Method> flags = Arrays.stream(RandomGeneratorFactory.class.getMethods())
.filter(m -> m.getName().startsWith("is")
&& !m.getName().equals("isDeprecated")
&& m.getParameterCount() == 0
&& m.getReturnType() == boolean.class)
.sorted(Comparator.comparing(Method::getName))
.toList();
/* Prepare list of factories -------------------------------------------------- */
List<RandomGeneratorFactory<RandomGenerator>> factories =
RandomGeneratorFactory.all()
.sorted(Comparator.comparing(RandomGeneratorFactory::name))
.toList();
/* Column widths -------------------------------------------------------------- */
int wName = Math.max("Name".length(),
factories.stream().mapToInt(f -> f.name().length()).max().orElse(4));
int wGroup = Math.max("Group".length(),
factories.stream().mapToInt(f -> f.group().length()).max().orElse(5));
int wPeriod = "Period".length();
Map<Method, Integer> wFlag = new LinkedHashMap<>();
for (RandomGeneratorFactory<?> f : factories) {
String per = periodString(f);
wPeriod = Math.max(wPeriod, per.length());
}
for (Method m : flags)
wFlag.put(m, Math.max(3, m.getName().substring(2).length()));
/* Build dynamic printf format string ----------------------------------------- */
StringBuilder fmt = new StringBuilder();
fmt.append("%-").append(wName).append("s ")
.append("%-").append(wGroup).append("s ")
.append("%-").append(wPeriod).append("s");
for (Method m : flags)
fmt.append(" %-").append(wFlag.get(m)).append("s");
fmt.append("%n");
String format = fmt.toString();
/* Header --------------------------------------------------------------------- */
List<String> header = new ArrayList<>();
header.add("Name");
header.add("Group");
header.add("Period");
flags.forEach(m -> header.add(m.getName().substring(2)));
System.out.printf(format, header.toArray());
int rule =
wName + wGroup + wPeriod + 2 * (header.size() - 1) +
wFlag.values().stream().mapToInt(Integer::intValue).sum();
System.out.println("-".repeat(rule));
/* Rows ----------------------------------------------------------------------- */
for (RandomGeneratorFactory<?> f : factories) {
List<String> row = new ArrayList<>();
row.add(f.name());
row.add(f.group());
row.add(periodString(f));
for (Method m : flags)
row.add(flag((boolean) m.invoke(f)));
System.out.printf(format, row.toArray());
}
}
/* Helpers ----------------------------------------------------------------------- */
private static String flag(boolean b) { return b ? "yes" : "no"; }
/* ------------------------------------------------------------------ */
/* Pretty-print the generator period */
/* ------------------------------------------------------------------ */
private static String periodString(RandomGeneratorFactory<?> f) {
BigInteger p = f.period();
/* 0 ⇒ period not declared in the JDK table */
if (p.equals(BigInteger.ZERO)) return "unknown";
/* Check if p is a power of two (p & (p-1)) == 0 */
if (p.and(p.subtract(BigInteger.ONE)).equals(BigInteger.ZERO)) {
int n = p.bitLength() - 1; // because 2^n has bitLength = n+1
return "2^" + n;
}
/* Check if p+1 is a power of two => period = 2^n − 1 */
BigInteger pPlus1 = p.add(BigInteger.ONE);
if (pPlus1.and(pPlus1.subtract(BigInteger.ONE)).equals(BigInteger.ZERO)) {
int n = pPlus1.bitLength() - 1;
return "2^" + n + "−1";
}
int power = p.bitLength();
/* Fallback: approximation of a period */
String s = "~2^"+ power;
return s;
}
}
Sample output:
3.2 What the capability markers mean and when to use them?
- SplittableGenerator
Method: split()
What it does: returns a brand‑new generator whose sequence is provably disjoint from the parent.
Use it when: you have a tree‑shaped task graph (ForkJoinTask (especially recursive tasks), parallel streams) and want each task to own its own RNG with zero contention and ZERO correlation to other generators. - StreamableGenerator
Methods: splits(), splits(long)
What it does: lazily produces an infinite Stream<RandomGenerator> of independent RNGs (internally uses split() on demand).
Use it when: you need thousands of RNGs but don’t know the count up front — e.g. reactive pipelines or batch jobs that allocate workers on the fly. - JumpableGenerator
Method: jump() (fixed distance, usually 2 ⁶⁴)
What it does: teleports the same generator far down its deterministic tape, giving you a fresh subsequence without allocating anything.
Use it when: you have a flat pool of long‑running workers and want each to own a shard of a master stream (no per‑thread objects, no seed coordination). - ArbitrarilyJumpableGenerator
Method: jump(long distance)
What it does: like jump() but you specify the offset — forward or backward — giving true random‑access into the stream.
Use it when: you need a seek()/rewind() ability; e.g. resume a Monte‑Carlo run at record #7 000 000 after a crash.
Note: There are currently no RNG in Java which implements this feature.
- LeapableGenerator
Method: leap() (huge stride, typically 2 ¹²⁸)
What it does: carves the full stream into galaxy‑sized blocks that can themselves be jump‑ or split‑partitioned.
Use it when: you’re orchestrating cluster‑scale simulations — leap() per cluster ⇒ jump() per node ⇒ split() per thread.
Mnemonic: Split makes new objects; Stream gives you a lazy factory of those; Jump and Leap teleport the same object.
3.3 Algorithm families at a glance
Below are the headline families and why you might pick them:
- LCG — Classic linear‑congruential (java.util.Random). Small state, easy to understand, but statistically weak.
- LXM — Hybrid Linear step + Xor + Mix. Good all‑rounders (L64X128MixRandom, etc.) and the default for RandomGenerator.getDefault().
- SplitMix — (SplittableRandom). Extremely fast, splittable, moderate period. Great for fork/join work.
- Xoroshiro — Tiny state, jumpable. (Xoroshiro128PlusPlus)
- Xoshiro — Larger state (256/512 bits), longer period, jumpable. (Xoshiro256PlusPlus)
- XorShift — Legacy speed king powering ThreadLocalRandom.
4. Why do we need Splittable for?
Creating a new generator with an arbitrary seed can give you another sequence, but split() (or the broader Splittable concept) solves several practical problems that “just pick a new seed” doesn’t:
Guaranteed non-overlap: You must hope the new seed lands on a non-overlapping subsequence — harder than it sounds for short-period or related algorithms. split() derives the child’s state mathematically from the parent, so sequences are provably disjoint.
Reproducibility: Parallel tasks each inventing their own seed make an experiment’s results fragile — tiny changes in thread ordering alter every downstream value. One root seed → deterministic tree of sub-generators. Rerun the job and every number is identical.
Seed management: You need a global, thread-safe seed generator to avoid collisions — often slower than the RNG itself. Totally local: each task calls split() on the generator it already has. No contention.
Cost: Good seeds require hashing/mixing to avoid correlation; done naively it hurts performance. split() is O(1) and allocation-free for most of the modern algorithms (e.g., LXM, SplitMix).
Statistical quality: Poor seed hygiene can introduce hidden correlations across tasks. The split formulas are designed alongside the algorithm to preserve statistical quality.
When does this matter?
- Fork/Join, parallelStream(), MapReduce-style workloads — You fan out recursively, so deterministic independence at each split is golden.
- Simulation or Monte-Carlo research — Reviewers can reproduce your exact run with one top-level seed.
- High-throughput systems — Avoiding a synchronized global seed counter removes a bottleneck.
If your app is single-threaded or you already have rock-solid seed distribution logic, a plain constructor with a seed is fine. But for modern parallel code, Splittable generators give you independence, repeatability, and speed “for free”.
5. Why “jump” at all?
Think of an RNG’s state as the position of a read-head on a very long tape:
─►────────►────────►────────►────────►───────
Stepping one value at a time is like advancing the head one frame.
Jumpable
Jump operations let you teleport that head far down the tape instantly, without iterating through every intermediate value and skip a fixed distance that the algorithm designer chose because it lands on a far-away, non-overlapping subsequence with proven statistical independence.
Use it when you need to partition one master stream into 64K+ disjoint shards for parallel tasks or quickly “fast-forward” a simulation to time T without generating all earlier events.
Arbitrarily Jumpable
Skip any distance you supply — positive or negative
Use cases:
- Restart exactly at record #7 billion after a failure or
- Deterministic seek in data-stream pipelines
- Rewind or replay portions of a Monte-Carlo run
Note: Java does not support this type of RNG yet
Leapable
Same idea as jump() but the stride is enormous — typically 2 ¹²⁸ — to carve the full stream into galaxy-sized blocks that are themselves jumpable 2 ¹²⁸ steps.
Use cases:
- Super-massive simulations (climate, astrophysics) that need trillions of independent sequences
- Hierarchical partitioning (leap → jump → split) on HPC clusters
Bottom line:
Jump, arbitrary jump, and leap aren’t about getting new random numbers — they’re about where in the colossal deterministic sequence you start reading them. That location control is what makes large-scale, reproducible parallel simulations both feasible and fast.
6. Benchmarking with JMH
Below is the JMH benchmark code which measures performance of each RNG in a single and multi-threaded setup. The benchmark allocates array of integers of a size 100M and fill it with a random numbers using every available RNG in the system.
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.random.*;
import java.util.stream.IntStream;
import org.openjdk.jmh.annotations.*;
/**
* Benchmarks random-array generation.
*/
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(value = 2, warmups = 0)
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
public class RandomFillBench {
/* ---------- benchmark parameters ---------- */
@State(Scope.Benchmark)
public static class Params {
/** Which generator to test. ThreadLocalRandom handled specially. */
@Param({ "Random", "SecureRandom", "ThreadLocalRandom", "SplittableRandom", "L32X64MixRandom",
"L64X128MixRandom", "L64X256MixRandom", "L64X1024MixRandom", "L64X128StarStarRandom",
"L128X128MixRandom", "L128X256MixRandom", "Xoshiro256PlusPlus", "Xoroshiro128PlusPlus" })
public String generator;
/** How many ints to fill. 100_000_000 ≈ 400 MB. */
@Param({ "100000000" })
public int size;
/** Shared array reused across iterations (avoids allocation noise). */
int[] arr;
@Setup(Level.Trial)
public void allocate() { // one array for the whole trial
arr = new int[size];
}
}
/* ---------- single-thread benchmark ---------- */
@Benchmark
public void single(Params p) {
RandomGenerator rng = createGenerator(p.generator, 1234L);
fill(p.arr, rng);
}
/* ---------- parallel benchmark ---------- */
@Benchmark
public void parallel(Params p) {
RandomGeneratorFactory<RandomGenerator> factory =
p.generator.equals("ThreadLocalRandom") ? null : RandomGeneratorFactory.of(p.generator);
fillParallel(p.arr, factory, 1234L);
}
/* ---------- implementation details ---------- */
/** Fast single-thread fill (same as before). */
private static void fill(int[] a, RandomGenerator rng) {
int i = 0, nEven = a.length & ~1;
while (i < nEven) {
long bits = rng.nextLong();
a[i++] = (int) bits;
a[i++] = (int) (bits >>> 32);
}
if (i < a.length) a[i] = rng.nextInt();
}
/** Build worker RNGs and fill in parallel. */
private static void fillParallel(int[] a, RandomGeneratorFactory<RandomGenerator> factory,
long seed) {
int workers = ForkJoinPool.getCommonPoolParallelism();
int chunk = (a.length + workers - 1) / workers; // ceil
/* -- per-worker generators -- */
RandomGenerator[] g = new RandomGenerator[workers];
if (factory != null) { // ThreadLocalRandom
for (int i = 0; i < workers; i++) {
g[i] = factory.create(seed ^ 0x9E3779B97F4A7C15L * i);
}
}
/* -- parallel fill -- */
IntStream.range(0, workers).parallel().forEach(tid -> {
int from = tid * chunk;
int to = Math.min(from + chunk, a.length);
RandomGenerator rng = g[tid];
if (rng == null) rng = ThreadLocalRandom.current();
int i = from, nEven = (to - from) & ~1;
nEven += from;
while (i < nEven) {
long bits = rng.nextLong();
a[i++] = (int) bits;
a[i++] = (int) (bits >>> 32);
}
if (i < to) a[i] = rng.nextInt();
});
}
/** Helper to create a single-thread generator. */
private static RandomGenerator createGenerator(String name, long seed) {
return name.equals("ThreadLocalRandom") ? java.util.concurrent.ThreadLocalRandom.current()
: RandomGeneratorFactory.of(name).create(seed);
}
public static void main(String[] args) throws Exception {
org.openjdk.jmh.runner.options.Options opt =
new org.openjdk.jmh.runner.options.OptionsBuilder().include("RandomFillBench\.*") // run
// this
// benchmark
// only
.forks(1) // 1 JVM fork
.warmupIterations(3) // quick defaults
.measurementIterations(5).build();
new org.openjdk.jmh.runner.Runner(opt).run();
}
}
This is the output of this program running on Mac Studio M1 (20 cores):
Benchmark takeaway — In an 20‑thread parallel run, ThreadLocalRandom and SplittableRandom emerge as the two fastest algorithms. Because ThreadLocalRandom can’t be reseeded for deterministic output, SplittableRandom is the recommended workhorse for most throughput‑hungry workloads, achieving about 57 GB/s of 32‑bit integers on a modern CPU. Only step up to leapable/jumpable generators when you truly need huge, reproducible math simulations.
7. Which generator should I choose?
- Utility code, single‑threaded — Random or L32X64MixRandom (better stats).
- High‑volume thread pools — ThreadLocalRandom (no contention).
- Fork/join parallel streams — SplittableRandom or any splittable LXM.
- Massive Monte‑Carlo simulations — Leapable + Jumpable (Xoshiro256PlusPlus).
- Reproducible research — Any algorithm with explicit seed and jump.
- Security/crypto — SecureRandom, which delegates to a CSPRNG.
8. Summary
Upgrade — If you’re still on Random, modern algorithms are drop‑in better.
Match capability to workload — split(), jump(), leap() exist to tame parallelism.
Measure — The JMH harness above gives real numbers on your hardware.
Happy randomising!
Questions or corrections? Drop a comment below and clap, clap, clap!
Read my other articles:
- Carrot Cache: High Performance, SSD-friendly caching library for Java
- Caching 1 billion tweets on a laptop
- Memory Matters: Benchmarking Caching Servers with Membench
- Four Billions Tweets Challenge
Beyond java.util.Random: The Evolution of Random Number Generators in Java was originally published in Javarevisited on Medium, where people are continuing the conversation by highlighting and responding to this story.
This post first appeared on Read More