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?
Your code is broken, and it is mere happenstance that it works most of the time.
A
ThreadLocalprovides 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 thatThreadLocal.You run the code querying the
ThreadLocalinside aCompletableFuture.runAsyncwith a single argument. This means that the code will run on theForkJoinPool.commonPool()executor, i.e. on some thread in the thread pool.You should absolutely not rely on any value inside a
ThreadLocalin such code.So why does the code work most of the time? This is because you made the
ThreadLocalanInheritableThreadLocal(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, anInheritableThreadLocalgets initialized to the same value the parent thread (i.e. the one that creates the new thread), whereas a normalThreadLocaljust initializes tonull.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 callCompletableFuture.runAsync(), and the threads in the pool are therefore children of the main thread, inheriting the service you already stored in theThreadLocal.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
ForkJoinPoolandThreadLocalis fundamentally wrong. UseThreadLocalfor things that are truly thread-local and meant to be so.As for the
LocalThreadclass - you do realize that that's just a class with arun()method that you call on your main thread in normal execution? If you want to run it on another thread, you need to donew Thread(new LocalThread()).start(). But since you explicitly start this thread, you are guaranteed to inherit theThreadLocal's value here, so the code will reliably work. (Though my thread-safety concerns for cached services remain.)