Imagine you’re upgrading your smartphone. Every new version brings exciting features that make your life easier, whether it’s a better camera, faster performance, or smarter apps. Java 23 is just like that—a new version of your favorite programming language that comes with powerful tools and updates to make coding smoother, faster, and more efficient.
Java has been like a reliable toolbox for developers for years, and with Java 23, that toolbox gets a shiny new set of tools! Think of it like a Swiss Army knife—it already has a knife, scissors, and a bottle opener, but now it also has a laser pointer and a screwdriver that works in tight spaces. These updates might seem small, but they save you time and effort when you’re solving everyday problems.
In this post, we’ll walk through the coolest features of Java 23, explain how they can make your coding journey easier, and explore why it’s worth upgrading. Whether you’re building apps, writing APIs, or solving interview questions, Java 23 has something to make your work smarter and more efficient. Let’s decode the magic!
Understanding Preview Features in Java 23
In the context of Java features, “preview” refers to features that are introduced in a version of Java but are not yet finalized. These features are included for developers to experiment with and provide feedback on. They may change based on user input and further development before being finalized in a future release.
In Java, features introduced as “preview” can go through multiple stages before becoming finalized. Here’s a breakdown of what the first, second, and third preview features typically mean:
- First Preview: This is the initial introduction of a feature in a particular Java release. It allows developers to use and test the feature in real-world applications. The purpose is to gather feedback and identify any issues or improvements needed.
- Second Preview: If a feature receives positive feedback and shows promise, it may be revisited in a subsequent release as a second preview. This stage often includes refinements based on the feedback from the first preview, and it may introduce some changes to the API or behavior of the feature.
- Third Preview: A feature may go through a third preview phase if further adjustments are deemed necessary. This allows for more feedback and iteration before the feature is finalized. By this stage, the feature is typically more stable and closer to its final form, but it can still be subject to change.
Each preview phase is designed to encourage community involvement and ensure the feature aligns with Java’s overall design principles.
Understanding JEP Numbers
Q) What are these numbers- 455, 466 and so on?
The numbers you see (like 455, 466, 467, etc.) refer to the JEP (JDK Enhancement Proposal) identifiers assigned to specific features or enhancements in the Java Development Kit (JDK). Each JEP number represents a proposal that outlines a particular change or addition to the Java platform, including its purpose, design, and implementation details.
1. JEP 455: Primitive Types in Patterns, instanceof, and switch (Preview)
Before: In Java, pattern matching has been evolving for some time. For instance, using pattern matching with instanceof allows checking if an object is of a certain type and then casting it directly in one step. But this did not work for primitive types.
Now: With this JEP, you can perform pattern matching for primitive types in instanceof and switch statements. This simplifies the code when dealing with primitive values, enhancing readability and reducing boilerplate. This is a preview feature (Java 21).
Code snippet:
Object obj = 42;
if (obj instanceof Integer num) { // Works for the Integer wrapper class
System.out.println(num);
}
Once the primitive type pattern matching feature is fully implemented, it may look like:
Object obj = 42;
if (obj instanceof int num) { // Pattern matching with primitive types
System.out.println(num); // num is a primitive int, no boxing involved
}
But right now u will face exception like this that can be fixed by adding this flag in compilation as well as run / debug run configurations.
[Image showing configuration flag]
2. JEP 466: Class-File API (Second Preview)
The Class-File API was proposed as a preview feature by JEP 457 in JDK 22. This is a second preview with refinements based upon experience and feedback.
Before: The class file format is vital in Java, but there was no standard API for analyzing and transforming .class files. Developers had to use external libraries like ASM or BCEL.
Now: JEP 466 introduces a standard API to work with class files directly. This simplifies manipulation of .class files, especially useful in tools like compilers, debuggers, and profilers. This is a preview feature, meaning it’s still being refined.
API for programmatically accessing class files could look like:
ClassFile cf = ClassFile.read(Paths.get(“MyClass.class”));
cf.constantPool(); // Access constant pool
cf.methods(); // Access methods
3. JEP 467: Markdown Documentation Comments
Enable JavaDoc documentation comments to be written in Markdown rather than solely in a mixture of HTML and JavaDoc @-tags.
The choice of HTML for a markup language was reasonable in 1995. HTML is powerful, standardized, and was very popular at the time.
Markdown is a popular markup language for simple documents that is easy to read, easy to write, and easily transformed into HTML
Before: Javadoc comments use a custom format. Although functional, it lacks the simplicity and features of Markdown, which has become a widely-used format for writing documentation.
Now: You can write Java documentation comments in Markdown format, allowing for richer, more readable documentation with simpler syntax.
Before (traditional Javadoc):
/**
* <p>This is a paragraph.</p>
* <ul>
* <li>First item</li>
* <li>Second item</li>
* </ul>
*/
Now (Markdown comments):
///
/// This is a paragraph.
///
/// – First item
/// – Second item
///
Key differences to observe:
- The use of Markdown is indicated by a new form of documentation comment in which each line begins with /// instead of the traditional /** … */ syntax.
- The HTML <p> element is not required; a blank line indicates a paragraph break.
- The HTML <ul> and <li> elements are replaced by Markdown bullet-list markers, using – to indicate the beginning of each item in the list.
- The HTML <em> element is replaced by using underscores (_) to indicate the font change.
- Instances of the {@code …} tag are replaced by backticks (…) to indicate the monospace font.
- Instances of {@link …} to link to other program elements are replaced by extended forms of Markdown reference links.
- Instances of block tags, such as @implSpec, @return, and @see, are generally unaffected except that the content of these tags is now also in Markdown, for example here in the backticks of the content of the @implSpec tag.
4. JEP 469: Vector API (Eighth Incubator)
In Java, an incubator is a mechanism for introducing new APIs or features that are not yet fully mature and are still under experimentation.
The Eighth Incubator means that the Vector API has undergone seven previous rounds of development and feedback (from JDK 16 onwards) and is still being refined before it becomes a permanent part of the Java Development Kit (JDK).
Multiple incubator phases allow the community to:
- Test the API extensively.
- Provide feedback.
- Suggest improvements.
- Ensure that the API is stable, high-performing, and meets user expectations.
Incubator features are experimental APIs and more subject to changes, targeting early adopters and testers for feedback on how the API works.
Preview features are near-complete language or JVM features that are almost ready for general use, but the community is given a chance to test them and provide feedback before they are finalized.
Aspect | Incubator | Preview |
Purpose | Experimental APIs | Near-final language or JVM features |
Scope | New APIs or libraries | Language or JVM enhancements |
Maturity | Early-stage, experimental | Close to final, small changes expected |
Intended Feedback | Feedback on API design and usage | Feedback on language feature stability |
How to Use | Available by default, but for experimentation | Enabled via flag |
Example | Vector API (JEP 469) | Pattern Matching for instanceof (JEP 394) |
Change Likelihood | Higher, may undergo major changes or removal | Lower, minor tweaks expected |
Duration | Can remain for multiple releases | Typically limited to 1–2 releases |
Before: Java had limited support for vector computations, which are essential in performance-critical applications, such as machine learning and numerical computing.
Now: The Vector API (still incubating) provides a way to perform vector computations on hardware that supports SIMD (Single Instruction, Multiple Data). This improves performance in certain workloads by leveraging hardware acceleration.
var v1 = IntVector.fromArray(IntVector.SPECIES_128, new int[]{1, 2, 3, 4}, 0);
var v2 = IntVector.fromArray(IntVector.SPECIES_128, new int[]{5, 6, 7, 8}, 0);
var result = v1.add(v2);
result.intoArray(new int[4], 0);
//The Vector API is part of the jdk.incubator.vector module, which is in an incubator stage. It’s not included in the default module graph.
5. JEP 473: Stream Gatherers (Second Preview)
Before: Working with streams in a more structured way was possible but could be verbose or limited in terms of gathering or grouping operations.
Now: This JEP introduces a gather operation for streams, which allows better control and optimization when collecting data from streams in certain scenarios.
Stream<String> stream = Stream.of(“a”, “b”, “c”, “d”);
List<String> result = stream.gather(Collectors.toList()); // Hypothetical method
Difference between gather and collect:
Though still hypothetical in its final form (as the feature is in preview), it might work similarly to collect, but it focuses more on the optimization for various scenarios.
gather is expected to provide more advanced optimizations and better control over the stream collection process, particularly in parallel streams or for performance-critical applications.
collect is a widely-used, general-purpose operation that works effectively for everyday use cases but doesn’t provide the same level of specialization or performance tuning as gather might in certain situations.
6. JEP 471: Deprecate the Memory-Access Methods in sun.misc.Unsafe for Removal
Before: sun.misc.Unsafe allowed direct memory manipulation, widely used by developers in low-level operations, but was dangerous and prone to errors.
Now: This JEP marks the memory access methods in sun.misc.Unsafe for deprecation, pushing developers to use safer alternatives like VarHandle or the Foreign-Memory Access API.
Before:
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class UnsafeExample {
public static void main(String[] args) throws Exception {
// Access the Unsafe instance via reflection (since it’s not publicly accessible)
Field unsafeField = Unsafe.class.getDeclaredField(“theUnsafe”);
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
// Allocate a block of memory manually (4 bytes)
long memoryAddress = unsafe.allocateMemory(4);
// Store an int value (42) at the allocated memory address
unsafe.putInt(memoryAddress, 42);
// Retrieve the value stored in memory
int retrievedValue = unsafe.getInt(memoryAddress);
System.out.println(“Value stored in memory: ” + retrievedValue); // Output: 42
// Free the allocated memory
unsafe.freeMemory(memoryAddress);
}
}
Risks of Unsafe:
- Memory Leaks: If freeMemory isn’t called, the allocated memory isn’t managed by the JVM’s garbage collector, leading to potential leaks.
- Segmentation Faults: Incorrect usage, like accessing invalid memory addresses, can cause low-level errors and crash the JVM.
- No Bounds Checks: Unsafe methods bypass Java’s built-in safety checks, allowing out-of-bounds access and buffer overflows.
- Portability Issues: Code using Unsafe is often platform-specific and may not work across different JVMs or operating systems.
Now:
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
public class VarHandleExample {
public static void main(String[] args) throws Exception {
// Define a class with a field
class MyClass {
public int field = 0;
}
MyClass myObject = new MyClass();
// Obtain a VarHandle for the ‘field’ in MyClass
VarHandle handle = MethodHandles.lookup().findVarHandle(MyClass.class, “field”, int.class);
// Safely set and get the value of the field using VarHandle
handle.set(myObject, 42);
int value = (int) handle.get(myObject);
System.out.println(“Value using VarHandle: ” + value); // Output: 42
}
}
7. JEP 474: ZGC: Generational Mode by Default
Before: ZGC (Z Garbage Collector) was non-generational, meaning it treated all objects equally in terms of collection cycles, even though most objects are short-lived.
Now: This change makes ZGC generational by default, which is expected to improve performance by applying different garbage collection strategies to young and old generations of objects, similar to other generational garbage collectors (e.g., G1 GC).
8. JEP 476: Module Import Declarations (Preview)
It introduces a more flexible way of handling module dependencies by allowing local imports of modules, similar to how you import packages or classes in Java today. This provides an alternative to the traditional requires statement in the module-info.java file, which applies globally to the entire module.
Before (Traditional requires in module-info.java):
In the standard module system, you declare the dependencies of a module using the requires keyword in the module-info.java file. This approach makes the entire module dependent on another module, which can sometimes be overkill if you only need to use a small portion of that module in a specific part of your code.
For example:
// module-info.java
module com.example.myapp {
requires com.example.library; // Required for the entire module
}
Now (Local Module Imports with JEP 476):
JEP 476 introduces the ability to import modules locally, within specific classes or methods, without having to declare them globally in module-info.java. This allows for a more fine-grained control over module dependencies, meaning you can reduce unnecessary dependencies at the module level, and only import modules where they are needed.
Example:
module com.example.myapp {
// No global ‘requires’ for the com.example.library module
}
public class MyClass {
public void myMethod() {
import com.example.library; // Importing the module only for this scope
// Use classes from com.example.library here
}
}
Key Benefits:
- Reduced Global Dependencies: Instead of declaring dependencies for an entire module, you can declare them only where necessary, reducing unnecessary dependencies.
- Cleaner Module Declarations: The module-info.java file remains cleaner and less verbose, as you no longer need to list all possible module dependencies.
- Greater Flexibility: You can scope module dependencies to specific classes, methods, or even code blocks, which provides more flexibility in managing dependencies and making your codebase modular.
Why is this important?
- Modularity: This feature improves the modularity of your code by allowing you to keep module dependencies limited to where they are used.
- Less Coupling: You reduce the coupling of your module to others, which can lead to easier maintenance, better readability, and improved build times, as the entire module doesn’t need to be recompiled or tested when a module dependency changes.
When to Use Local Module Imports:
- Small, localized use: If you only need a few classes or methods from a module, you can import them locally without adding a global dependency.
- Reducing module complexity: Use this approach to simplify your module-info.java file by keeping it focused on essential dependencies and reducing unnecessary top-level imports.
This feature provides a way to manage dependencies more dynamically and is particularly useful for large modular systems where over-declaring dependencies can lead to unnecessary complexity.
9. JEP 477: Implicitly Declared Classes and Instance Main Methods (Third Preview)
JEP 477 introduces a new way to declare and execute main methods in Java applications by allowing instance main methods instead of requiring the traditional static main methods.
Before JEP 477: Traditional Static Main Method
In Java, the entry point of any standalone application has traditionally been a static method called main in a class. It must follow this exact signature:
public class MyApp {
public static void main(String[] args) {
System.out.println(“This is a static main method!”);
}
}
Key characteristics:
- The main method has to be static , meaning it belongs to the class and not to an instance of the class.
- You always need to explicitly declare a class (in this case, MyApp ).
- Every standalone Java program starts with this public static void main(String[] args) method.
Now with JEP 477: Instance Main Methods
Since the method is no longer static, the JVM has to create an instance of the class. This aligns more with the object-oriented nature of Java, where methods typically operate on instances of classes.
Since this is now an instance method, it can easily access other non-static fields or methods within the same object, which wasn’t possible in the traditional static main method. This makes the design more flexible.
With JEP 477, Java allows the main method to be an instance method (i.e., non-static), simplifying the way applications are written and structured. You no longer need to declare a static main method, and the main method can belong to an instance of the class.
Example:
public class MyApp {
void main() {
System.out.println(“This is an instance main method!”);
}
}
Key characteristics:
- The main method is no longer static . It can be an instance method.
- When the JVM starts, it creates an instance of MyApp and invokes the main() instance method.
- This makes it easier to write and understand small applications or scripts without the need for boilerplate code (i.e., the static keyword).
Implicitly Declared Classes
In addition to allowing instance main methods, JEP 477 simplifies class declarations in certain contexts. For smaller programs, it’s now possible to implicitly declare classes, meaning you don’t have to write a full class declaration explicitly when it’s not necessary.
Example:
Instead of writing:
public class MyApp {
void main() {
System.out.println(“Hello from an instance main method!”);
}
}
You could implicitly declare the class like this:
void main() {
System.out.println(“Hello from an instance main method!”);
}
Here, the class is implicitly defined by the JVM at runtime, and you don’t need to explicitly declare it yourself. This can make writing small programs or command-line applications much more streamlined and less verbose.
Benefits of JEP 477
- Simplicity for Small Applications: For small applications, scripts, or educational purposes, this feature reduces boilerplate code. You no longer need to explicitly define classes and static methods if they’re unnecessary.
- Instance Methods are More Flexible: Instance methods allow for more flexibility and object-oriented design patterns. With a static main method, you couldn’t access non-static members or create instances without extra steps. Now, you can design your programs more naturally using objects.
- Ease of Use: It’s more intuitive for beginners who might find static methods confusing. Having a simpler structure encourages faster development for small tasks and learning.
- Better Structure for Scripts: In scripting-like contexts (or small utility programs), you can write compact code that is cleaner and easier to read and maintain.
How it Works Internally
When the JVM detects a class with an instance main method (i.e., void main()), it automatically creates an instance of that class and invokes the main method on that instance. This contrasts with the traditional approach where the static main method is invoked directly without creating an object.
- Traditional static main: The JVM calls the static main method without creating an instance.
- Instance main with JEP 477: The JVM creates an instance of the class and calls the main() method on it.
Use Cases
- Small Command-line Applications: This is particularly useful for quick command-line tools or small programs where defining an entire class and static method seems like overkill.
- Scripting in Java: With implicitly declared classes and instance main methods, Java can behave more like a scripting language for small tasks, which improves its accessibility for simpler use cases.
Summary
- Before JEP 477: You needed to declare a static main method in a class, making your code more verbose for small applications.
- Now with JEP 477: You can use instance main methods (non-static), and you can even implicitly declare classes, which reduces boilerplate code and makes Java more suitable for smaller, simpler applications.
**Before JEP 477 (static ****main** ): The JVM looks for a method with the exact signature public static void main(String[] args) as the entry point. If it’s not present, you’ll get a NoSuchMethodError .
**After JEP 477 (instance ****main** ): The JVM will now look for a method with the signature void main() , without arguments. Once it finds it, the JVM will:If there is no void main() method, the JVM will still throw a NoSuchMethodError :
- Instantiate the class.
- Call the main() method on that instance.
With the introduction of **instance ***main()*** methods** in JEP 477, the main() method no longer accepts the traditional String[] args array that was used for passing command-line arguments. This raises a valid question: how can you pass arguments to the program now?
One approach can be – Pass Arguments Via Environment Variables
10. JEP 480: Structured Concurrency (Third Preview)
Overview: Structured concurrency is an approach designed to make working with concurrent tasks simpler, more reliable, and less error-prone. Before this feature, Java developers had to manage concurrency manually by creating threads or using more flexible structures like ExecutorService
Before: Handling concurrency involved creating threads manually or using executor services, which often led to complex and error-prone code.
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<Integer> future1 = executor.submit(() -> expensiveComputation());
Future<Integer> future2 = executor.submit(() -> anotherComputation());
try {
Integer result1 = future1.get(); // Can block indefinitely, manual handling
Integer result2 = future2.get();
System.out.println(result1 + result2);
} catch (Exception e) {
// Complex error handling, cancellations
} finally {
executor.shutdown(); // Must manually manage resources
}
Now: With structured concurrency, managing multiple tasks is simplified, as tasks are tied to a scope, which ensures resources are automatically cleaned up and failure is handled in a clean, coordinated way., ensuring that tasks complete or fail together.
Example:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<Integer> future1 = scope.fork(() -> expensiveComputation());
Future<Integer> future2 = scope.fork(() -> anotherComputation());
scope.join(); // Wait for both tasks
scope.throwIfFailed();
System.out.println(future1.resultNow() + future2.resultNow());
}
11. JEP 481: Scoped Values (Third Preview)
Before: Passing values to threads often required using ThreadLocal, which has limitations and isn’t ideal for parallel computations.
Now: Scoped values provide a more flexible and efficient way to share immutable data across threads without the complexity of ThreadLocal.
1. Earlier Approach with ThreadLocal
In the previous approach, using ThreadLocal to share data across threads could lead to complexity and potential memory leaks if not managed carefully.
Example Using ThreadLocal:
public class ThreadLocalExample {
// Define a ThreadLocal variable
static final ThreadLocal<String> threadLocalName = ThreadLocal.withInitial(() -> “Default Name”);
public static void main(String[] args) {
// Set the ThreadLocal value
threadLocalName.set(“John Doe”);
// Create a new thread
Thread thread = new Thread(() -> {
// Access the ThreadLocal value
System.out.println(“ThreadLocal Name: ” + threadLocalName.get());
// You can perform other computations here
});
thread.start(); // Start the thread
try {
thread.join(); // Wait for the thread to finish
} catch (InterruptedException e) {
e.printStackTrace();
}
// Clean up the ThreadLocal value (important to prevent memory leaks)
threadLocalName.remove();
}
}
Explanation of the ThreadLocal Approach:
- ThreadLocal Definition: The ThreadLocal variable is initialized with a default value.
- Setting Value: We set the value for the current thread using threadLocalName.set() .
- Accessing Value: The new thread accesses the ThreadLocal variable using threadLocalName.get() .
– Cleanup: It’s crucial to call threadLocalName.remove() to prevent memory leaks.
2. New Approach with Scoped Values
Now, using Scoped Values simplifies this process significantly, as shown below.
Example Using Scoped Values:
import jdk.incubator.concurrent.ScopedValue;
public class ScopedValuesExample {
// Define a scoped value
static final ScopedValue<String> SCOPED_NAME = ScopedValue.newInstance();
public static void main(String[] args) {
// Set a value for the scoped value
try (var scope = ScopedValue.where(SCOPED_NAME, “John Doe”)) {
// Fork a new task in the scope
scope.fork(() -> {
// Access the scoped value
System.out.println(“Scoped Name: ” + SCOPED_NAME.get());
return null; // Return type for the task
}).join(); // Wait for the task to complete
}
// No need for manual cleanup, scope handles it automatically
}
}
Explanation of the Scoped Values Approach:
- Scoped Value Definition: A ScopedValue is created, similar to ThreadLocal , but it’s designed for structured concurrency.
- Setting Value: The value is set within a try-with-resources block that defines the scope.
- Forking a Task: A new task is created within the scope using scope.fork() , allowing it to access the scoped value.
– Automatic Cleanup: When the scope ends, the scoped values are automatically cleaned up, eliminating the need for manual cleanup like ThreadLocal .
12. JEP 482: Flexible Constructor Bodies (Second Preview)
Before: Java constructors had to follow strict rules, particularly regarding the order of field initialization.
Now: This JEP offers more flexibility in the constructor bodies, allowing initialization code to be more freely structured.
Implicit super() call:
- Before: Explicit super() or this() call required as the first statement
class Example {
private final int value;
public Example(int input) {
super(); // Must be first
if (input < 0) { // Validation after super()
throw new IllegalArgumentException(“Value must be non-negative”);
}
this.value = input;
}
}
- After: super() is implicitly called at the end if not explicitly called
class Example {
private final int value;
public Example(int input) {
if (input < 0) { // Validation before super()
throw new IllegalArgumentException(“Value must be non-negative”);
}
this.value = input;
// super() is implicitly called here if not explicitly called
}
}
Statement order:
- Before: super() or this() must be first, then other statements
- After: Other statements can come before super() or this()
Error handling and validation: - Before: Limited ability to handle errors or validate before object initialization
class Resource {
private final FileInputStream fis;
public Resource(String filename) throws FileNotFoundException {
super();
this.fis = new FileInputStream(filename);
}
}
- After: Can use try-catch , if-else , etc. before calling super() or this()
class Resource {
private final FileInputStream fis;
public Resource(String filename) throws FileNotFoundException {
try {
this.fis = new FileInputStream(filename);
} catch (FileNotFoundException e) {
System.err.println(“File not found: ” + filename);
throw e;
}
// super() is implicitly called at the end
}
}