Java 21 Scoped Values
In Java programming, the essential task of sharing data among various components of an application running within a single thread has traditionally been addressed using ThreadLocal variables. However, given the emergence of VirtualThread and the increasing demand for scalability, the efficiency of sharing data through ThreadLocal variables is not always optimal. This is where Scoped Values come into the picture. Let us delve into understanding the Java 21 Scoped Variables.
1. Introduction
ThreadLocal
is a class that allows you to create variables that can be accessed and mutated only by the same thread. Each thread that accesses a ThreadLocal
variable gets its own, independently initialized copy. This means that changes made by one thread do not affect the values seen by other threads. To use ThreadLocal
, you typically create an instance of it and then use its get
and set
methods to access and modify the variable, respectively. Here’s a simple example:
public class ExampleThreadLocal { private static final ThreadLocal < String > threadLocalVariable = new ThreadLocal < > (); public static void main(String[] args) { // Set the thread-local variable threadLocalVariable.set("Hello, ThreadLocal!"); // Access the thread-local variable String value = threadLocalVariable.get(); System.out.println(value); } }
ThreadLocal
is particularly useful in scenarios where data needs to be associated with a thread, and sharing that data between threads is not required. Common use cases include managing per-thread resources, such as database connections or user context in a web application. While ThreadLocal
can be a powerful tool, it’s essential to use it judiciously. Improper usage can lead to memory leaks and difficulties in debugging. Be sure to clear the ThreadLocal
variables appropriately, especially in scenarios where threads are reused or pooled.
2. Understanding the Challenges with ThreadLocal
ThreadLocal is a class in Java that provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get
or set
method) has its own, independently initialized copy of the variable.
- Limited to a Single Thread: One of the primary challenges with ThreadLocal is that it is limited to a single thread. While this is its intended behavior, it can become a challenge in scenarios where sharing data between multiple threads is necessary. This limitation can lead to increased complexity in certain multi-threaded applications.
- Potential for Memory Leaks: Incorrect usage of ThreadLocal can result in memory leaks. If a thread-local variable is not appropriately cleared after its use, it prevents the associated thread from being garbage collected, leading to unnecessary memory consumption. Developers need to be mindful of cleaning up ThreadLocal variables to avoid such issues.
- Global State in Disguise: While ThreadLocal variables are local to a thread, excessive use of them can introduce a form of global state. This is because multiple threads have their copies of thread-local variables, but they are all operating on a shared codebase. This shared codebase can introduce hidden dependencies and make the code harder to reason about.
- Difficulty in Testing: Testing threaded code that relies heavily on ThreadLocal variables can be challenging. The behavior of these variables depends on the specific execution context of each thread, making it harder to predict and control in unit tests. Developers need to employ advanced testing techniques or mocks to effectively test code utilizing ThreadLocal.
3. Java 21 Scoped Variables
Java 20 introduced a notable feature known as scoped values (JEP 429). This feature facilitates the sharing of immutable data within a thread and across threads without relying on method arguments. While thread-local variables offer similar functionality, scoped values introduce distinct concepts that make them a more preferable choice. Scoped values are specifically designed to enable lightweight data sharing. The introduction of virtual threads has shed light on the limitations of thread-local variables. These variables often prove too intricate and resource-intensive for efficient data sharing. Consider the scenario where an application employs millions of virtual threads, each equipped with mutable thread-local variables – this situation could potentially lead to memory leaks. To address this concern, Java introduced scoped values. These values are immutable, possess a bounded lifetime, and provide control over how data is inherited by child threads.
3.1 Working with Scoped Values in Java
Scoped values offer a powerful way to share data within a thread while adhering to the immutability concept. Let’s explore how to use scoped values with an example using the following class to store employee data:
@AllArgsConstructor @NoArgsConstructor @Builder @Getter public class Employee { private int id; private String name; }
Scoped values are created using the ScopedValue
class, which has a parameterized type allowing you to define the type of the variable to be shared. To illustrate, let’s define a scoped value for our Employee
class:
public static final ScopedValue CURRENT_EMPLOYEE = ScopedValue.newInstance();
To bind scoped values, you can use the where
method, which comes in different variations. The first one returns a Carrier
and allows you to bind multiple values by chaining multiple .where()
calls. You can then execute a method using run
(for Runnable
) or call
(for Callable
). The scoped values will be accessible in the invoked method as well as other methods called indirectly, as long as it’s in the same thread.
ScopedValue .where(CURRENT_EMPLOYEE, employee) .where(REQUEST_TIME, ZonedDateTime.now()) .run(employee::handleRequest);
The second and third methods directly accept and execute a Callable
or Runnable
. They essentially call it the first method. However, it’s important to note that the second and third methods do not support multiple-scoped values.
After the execution of the run
or call
method finishes, the binding is destroyed. This ensures that data is transmitted only from the caller to the callee. The callee cannot modify the data from the caller. Nonetheless, the callee can create another binding when calling another method. Additionally, the lifetime of scoped variables is confined within the run
or call
method executed by the Carrier
.
3.2 Understanding Immutability in Scoped Values
One key characteristic of scoped values is their immutability. Once a value is set using the ScopedValue
class, it cannot be modified. However, it is possible to rebind values, introducing a useful flexibility. Consider the scenario where the LoggerService
requires logging names in uppercase. In such cases, you can create a new instance of the Employee
class with an uppercase name.
To rebind the values and ensure that the LoggerService
receives the employee with an uppercase name, invoke ScopedValue.where
again to create a new Carrier
. Provide the new Employee
instance as the second argument. By doing this, attempting to retrieve the value from the ScopedValue
variable within the method executed by the new Carrier
will yield the rebound value.
// Rebinding the values for uppercase logging ScopedValue .where(CURRENT_EMPLOYEE, newEmployeeInstanceWithUpperCaseName) .run(loggerService::logUpperCaseName);
3.3 Optimal Data Sharing with Records in Thread Context
When the need arises to share multiple values within the thread context and among child threads, a recommended approach is to create a record that encapsulates all the required values. This record can then be shared through conventional means.
One significant advantage of adopting this approach is that, in the future, if there is a requirement to add more values or remove certain values from the scope, the necessary code changes will be minimal, resulting in a more maintainable codebase.
// Define an ApplicationContext record public record ApplicationContext(Principal principal, Role role, Region region) {} // Create an instance of ApplicationContext private final ApplicationContext CONTEXT = new ApplicationContext(...); // Use ScopedValue to run a block of code with the shared context ScopedValue.runWhere(ApplicationContext, CONTEXT, () -> { doSomething(); });
4. Scoped Values vs Thread-Local Variables
- Immutability: Scoped values are immutable, as the
ScopedValue
class lacks any setter method. This immutability results in a performance advantage, making it as fast as reading a local variable. In contrast, thread-local variables are mutable, allowing data to flow in any direction due to the presence of a set method. This can sometimes lead to challenges in understanding the data flow. - Lifetime: Scoped values are available for a bounded period, confined to the Callable or Runnable invoked by the Carrier. After execution, all bindings are destroyed. Thread-local variables, however, have an unbounded lifetime. Once set using the set method, the value persists as long as the thread is alive, although removal using the remove method is possible but not always practiced.
- Inheritance: Scoped values are not inherited to child threads by default. However, passing values to child threads can be achieved using StructuredTaskScope. This allows child threads to reuse bindings from the parent thread without copying, resulting in more cost-effective inheritances with less overhead.
5. Conclusion
In conclusion, our exploration of Java programming has encompassed key concepts such as ThreadLocal, Scoped Values, and additional features. ThreadLocal, a mechanism for thread-local variables, offers isolated copies for each thread, though challenges like limited scope and potential memory leaks should be considered. Java 20 introduced Scoped Values, providing an alternative for lightweight data sharing within a thread. Immutable and designed for simplicity, Scoped Values address challenges associated with thread-local variables, particularly in the context of virtual threads and scalability. The practical use of Scoped Values involves creating instances with various data types and binding them using the ScopedValue.where
method. Immutability ensures data transmission without modification, while the ability to rebind values provides flexibility for data transformation. For optimal data sharing, leveraging records in Java is recommended. Records offer a concise way to encapsulate and share data, enhancing maintainability by minimizing code changes when adding or removing values.