Search code examples
javamultithreadingrandomjava-17

Thread-Safe RandomGenerator in Java 17


Java 17 has added a new RandomGenerator interface. However it seems that all of the new implementations are not thread-safe. The recommended way to use the new interface in multi-threaded situations is to use SplittableRandom and call split from the original thread when a new thread is spawned. However, in some situations you don't have control over the parts of the code where the new threads are spawned, and you just need to share an instance between several threads.

I could use Random but this leads to contention because of all the synchronization. It would also be possible to use ThreadLocalRandom, but I'm reluctant to do this because this class is now considered "legacy", and because this doesn't give me a thread-safe implementation of RandomGenerator without a whole load of boilerplate:

 new RandomGenerator() {
    
    @Override 
    public int nextInt() {
      return ThreadLocalRandom.current().nextInt();
    }
    
    @Override
    public long nextLong() {
      return ThreadLocalRandom.current().nextLong();
    }
    
    ...
}

To me this appears to be a fairly fundamental gap in the new API, but I could be missing something. What is the idiomatic Java 17 way to get a thread-safe implementation of RandomGenerator?


Solution

  • When you don’t have control over the work splitting or creation of threads, the simplest solution from the using site’s perspective, is a ThreadLocal<RandomGenerator>.

    public static void main(String[] args) {
        // spin up threads
        ForkJoinPool.commonPool().invokeAll(
            Collections.nCopies(8, () -> { Thread.sleep(300); return null; }));
    
        doWork(ThreadLocal.withInitial(synching(SplittableGenerator.of("L32X64MixRandom"))));
        doWork(ThreadLocal.withInitial(synching(new SplittableRandom())));
        doWork(ThreadLocal.withInitial(ThreadLocalRandom::current));
    }
    
    static final Supplier<SplittableGenerator> synching(SplittableGenerator r) {
        return () -> {
            synchronized(r) {
                return r.split();
            }
        };
    }
    
    private static void doWork(ThreadLocal<RandomGenerator> theGenerator) {
        System.out.println(theGenerator.get().toString());
        Set<Thread> threads = ConcurrentHashMap.newKeySet();
        var ints = Stream.generate(() -> theGenerator.get().nextInt(10, 90))
            .parallel()
            .limit(100)
            .peek(x -> threads.add(Thread.currentThread()))
            .toArray();
        System.out.println(Arrays.toString(ints));
        System.out.println(threads.stream().map(Thread::getName).toList());
        System.out.println();
    }
    

    Since this will not split the RNG before handing one over to another thread but from the already existing worker thread, it has to synchronize the operation. But this happens exactly once per thread when the thread local variable is queried the first time. It’s also worth noting the the base RNG is only accessed from that synchronized block.

    Note that this also allows the integration of the legacy ThreadLocalRandom.current() without additional synchronization. It would even work with a synchronizing RNG like Random r = new Random(); doTheWork(ThreadLocal.withInitial(() -> r));.

    Of course, that’s only for illustration, as the RNGs in question have dedicated methods for creating streams which can split before workload is handed over to another worker thread.