Search code examples
javamultithreadingcompletable-futurejava-17thread-local

Java 8 to Java 17 ThreadLocal issue


I have code that works well in Java 8, but it doesn't work when I migrate it to Java 17. It involves ThreadLocal and CompletableFuture.runAsync.

Here are the classes:

public class UriParameterHandler {
}

public class DateRangeEntity {
    public String getCurrentDate(){
        DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
        LocalDateTime now = LocalDateTime.now();
        return dtf.format(now);
    }
}

public class SessionHandler {

    private static ThreadLocal<SessionHandler> instance = new InheritableThreadLocal<>();
    private UriParameterHandler uriParameterHandler;
    private DateRangeEntity dateRangeEntity;

    private SessionHandler() {
        instance.set(this);
    }

    public static void initialize() {
        SessionHandler handler = new SessionHandler();
        handler.uriParameterHandler = new UriParameterHandler();
    }

    public static UriParameterHandler getUriParameterHandler() {
        return instance.get().uriParameterHandler;
    }

    public static void setUriParameterHandler(UriParameterHandler uriParameterHandler) {
        instance.get().uriParameterHandler = uriParameterHandler;
    }

    public static DateRangeEntity getDateRangeEntity() {
        return instance.get().dateRangeEntity;
    }

    public static void setDateRangeEntity(DateRangeEntity dateRangeEntity) {
        instance.get().dateRangeEntity = dateRangeEntity;
    }
}

public class LocalThread implements Runnable{
    @Override
    public void run() {
        if(SessionHandler.getDateRangeEntity()!=null){
            System.out.println("not null");
        }else{
            System.out.println("is null");
        }
    }
}

public class SessionHandlerMain {

    public static void main(String[] args) {
        threadLocalDemo();
    }

    private static void threadLocalDemo(){
        SessionHandler.initialize();
        SessionHandler.setDateRangeEntity(new DateRangeEntity());

        //works in java8 but not in java17
        CompletableFuture.runAsync(()->{
            if(SessionHandler.getDateRangeEntity()!=null){
                System.out.println("not null");
            }else{
                System.out.println("is null");
            }

        }).exceptionally(e->{
            e.printStackTrace();
            return null;
        });

        /*
        LocalThread localThread = new LocalThread();
        localThread.run();
         */
    }
}

In the threadLocalDemo() in SessionHandlerMain, I first set new DateRangeEntity object for the SessionHandler and then in a runAsync() method, I call the getDateRangeEntity() to check if the object is not null. This works in Java 8 and prints "not null", but when I migrated to Java 17, this now throws this exception:

java.util.concurrent.CompletionException: java.lang.NullPointerException: Cannot read field dateRangeEntity because the return value of java.lang.ThreadLocal.get() is null
        at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:315)
        at java.base/java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:320)
        at java.base/java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1807)
        at java.base/java.util.concurrent.CompletableFuture$AsyncRun.exec(CompletableFuture.java:1796)
        at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:373)
        at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1182)
        at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1655)
        at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1622)
        at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:165)
Caused by: java.lang.NullPointerException: Cannot read field dateRangeEntity because the return value of java.lang.ThreadLocal.get() is null

However if I move the logic of the runAsync() method in a class that extends Runnable then this works in Java 17.

Can someone please provide me some insight of why is this behavior different in Java 17 and if there is any other workaround for it?


Solution

  • Your code is broken, and it is mere happenstance that it works most of the time.

    A ThreadLocal provides a storage location that is unique to each thread. Different thread, different storage location. This means if you're on a different thread, you should expect to get something different in that ThreadLocal.

    You run the code querying the ThreadLocal inside a CompletableFuture.runAsync with a single argument. This means that the code will run on the ForkJoinPool.commonPool() executor, i.e. on some thread in the thread pool.

    You should absolutely not rely on any value inside a ThreadLocal in such code.


    So why does the code work most of the time? This is because you made the ThreadLocal an InheritableThreadLocal (which is a bad idea for "store an instance of service" - either your service is thread-safe and you should use one globally, or it is not (as yours is) and sharing it between threads is broken). The special thing about this class is that when a new thread gets created, an InheritableThreadLocal gets initialized to the same value the parent thread (i.e. the one that creates the new thread), whereas a normal ThreadLocal just initializes to null.

    It is unspecified when and how ForkJoinPool.commonPool() is initialized. The most likely strategy is lazy creation, i.e. the pool is created when the method is first called. Thus, usually, the pool is created when you call CompletableFuture.runAsync(), and the threads in the pool are therefore children of the main thread, inheriting the service you already stored in the ThreadLocal.

    In Java 17, however, it appears that something initializes the pool before you initialize your ThreadLocal, and so it doesn't inherit the value you set.

    However, even though other Java versions might not have the same behavior, I still contend that combining ForkJoinPool and ThreadLocal is fundamentally wrong. Use ThreadLocal for things that are truly thread-local and meant to be so.


    As for the LocalThread class - you do realize that that's just a class with a run() method that you call on your main thread in normal execution? If you want to run it on another thread, you need to do new Thread(new LocalThread()).start(). But since you explicitly start this thread, you are guaranteed to inherit the ThreadLocal's value here, so the code will reliably work. (Though my thread-safety concerns for cached services remain.)