The newest Java release includes a bunch of new and exciting features such as virtual threads or structured concurrency. But even if you are using an older JDK version, Java still has some aces up its sleeve! This article will guide you through useful Java features that make developer’s life easier and apps more performant.
Most of these treasures are hidden in JDK 8+. Some features are new and thus will give you a great incentive to finally migrate to the latest LTS release!
- Methods and constructors in enum
- DelayQueue
- Shutdown Hooks
- Modules
- Double-brace initialization
- Instance initializers
- Phaser
- Pattern matching for switch
- Conclusion
1. Methods and constructors in enum
enum types are Java classes defining collections of constants. In their simplest and most widely used form they look like this:
public enum Vehicle { CAR, BUS, BICYCLE, SCOOTER }
However, Java enums are much more powerful and allow the developers to add fields, methods, interfaces, etc. They are Comparable and Serializable and can implement all the Object methods.
For instance, let’s create an enum Animal and assign a proverb to each value:
public enum Animal {
DOG("A barking dog doesn't bite"),
CAT("Curiosity killed a cat"),
RAT("Rats desert a sinking ship");
private final String proverb;
Animal(String proverb) {
this.proverb = proverb;
}
public String getProverb() {
return proverb;
}
}
We can then iterate through the values by means of a static valueOf() method:
public class Main {
public static void main(String[] args) {
for (Animal a : Animal.values()) {
System.out.println(a.name() + " - " + a.getProverb());
}
}
}
The output will be as follows:
DOG - A barking dog doesn't bite
CAT - Curiosity killed a cat
RAT - Rats desert a sinking ship
Each constant can be assigned a different behavior for a specific method. For instance, by making a main method abstract and overriding it in each constant:
public enum Operation {
PLUS {
double evaluate(double x, double y) {
return x + y;
}
},
MINUS {
double evaluate(double x, double y) {
return x - y;
}
};
abstract double evaluate(double x, double y);
}
All these capabilities enable convenient work with any type of constants.
2. DelayQueue
DelayQueue is part of java.util.concurrent package. It is a blocking queue that orders elements based on the delay time. Elements can be taken from the queue only when their delay has expired. The head element is the one whose delay has expired earlier. If there is no delay expiration, the poll will return null. All objects have to belong to the Delay class or extend the Delayed interface.
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;
public class DelayObject implements Delayed {
private String name;
private long startTime;
public DelayObject(String name, long delayInMilliseconds) {
this.name = name;
this.startTime = System.currentTimeMillis() + delayInMilliseconds;
}
@Override
public long getDelay(TimeUnit unit) {
long diff = startTime - System.currentTimeMillis();
return unit.convert(diff, TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
if (this.startTime < ((DelayObject) o).startTime) {
return -1;
}
if (this.startTime > ((DelayObject) o).startTime) {
return 1;
}
return 0;
}
}
When the getDelay(TimeUnits.NANOSECONDS)
method returns a value less than or equal to zero, the delay expires. In addition, this queue is unbounded meaning it can store any number of elements.
DelayQueue is helpful for controlling the intervals of data processing. The application logic can be set in such a way so as to perform tasks at certain intervals regardless of the number of submitted tasks. This way, the application will not be overloaded and perform smoothly.
3. Shutdown Hooks
JVM can shutdown in two ways: in a controlled manner and abruptly due to external forces. Although we can’t influence the JVM behavior in case of abrupt shutdown, we can structure our code to perform some house-keeping tasks (release resources, etc.) before a controlled shutdown. This is achieved with shutdown hooks. They belong to the Thread class and are initialized but unstarted threads. When the JVM starts the shutdown process, it will start all registered shutdown hooks in an unspecified order and run them concurrently. When all hooks are finished, the JVM will halt.
Shutdown hooks are easy to create:
Thread hook = new Thread(() -> System.out.println("Shutting down, bye!"));
Runtime.getRuntime().addShutdownHook(hook);
Note that the JVM will execute the hooks only when it terminates normally. If it aborts due to
kill -9 <jvm_pid>
signal (Unix) orTerminateProcess
(Windows)Runtime.getRuntime().halt()
- Power failure or other situation killing the OS
there is no guarantee that shutdown hooks will execute.
4. Modules
Modules introduced in JDK 9 enable the developers to write Java applications as a set of modules rather than an indivisible piece of software. A module includes closely related packages, resources, and a module descriptor file.
Modularity brings enormous benefits to Java development: it makes the code stable and reusable and speeds up the development process because the developers can write the code in parallel and test the modules on the fly.
Starting with Java 9, JDK itself is organized in modules. If you run
java --list-modules
You will get the list of modules your JDK distribution uses, where java modules are implementation classes for the core Java SE specification, jdk modules are libraries used by the JDK, and javafx modules (if you have a Liberica Full version) contain FX UI libraries.
It means that modularity can be used to create a custom JDK by using only the modules required by the application. The containers created this way consume several times less memory than standard Docker containers and thus minimize resource consumption and cloud expenses.
You can create a modular JDK yourself or use a ready solution developed by BellSoft — microcontainers with Liberica Lite and Alpaquita Linux, our own lightweight Linux distribution optimized for cloud-native applications.
5. Double-brace initialization
Starting with Java 9, double braces can be used to create and initialize classes in a single expression. This feature should be used to add elements to HashMap, which can only be initialized in a constructor. We do not recommend utilizing it for other purposes for the sake of code readability and easy debugging.
Below is a traditional way of creating and populating a HashMap:
Map<Integer,String> data = new HashMap<Integer,String>();
data.put(1,"value1");
data.put(2,"value2");
But we can shorten the code by using double braces:
Map<Integer,String> data = new HashMap<Integer,String>(){{
put(1,"value1");
put(2,"value2");
}};
Here, we created an anonymous subclass of HashMap and provided instance initialization to add two elements.
Double-brace initialization may be perceived as syntactic sugar, which doesn’t affect app’s performance but makes the coding process more convenient if you work with HashMaps. In this case, the feature reduces the notorious Java verbosity by minimizing boilerplate code.
6. Instance initializers
Technically, you can use static initialization for everything, but you have to be extremely careful with this feature in Java because of the added complexity and unexpected behavior in some cases, for example, when using native image technology.
It is recommended to share code between constructors. Instance initialization blocks are used to initialize instance data members. Instance blocks run every time an object of the class is created. Initializers execute in the order they are defined in the class. They are also invoked after the parent class constructor invocation.
Consider the following code snippet:
public class Dog {
public Dog() {
System.out.println("Dog constructor");
}
{
System.out.println("Dog instance initializer");
}
}
public class Puppy extends Dog {
public Puppy() {
System.out.println("Puppy constructor");
}
{
System.out.println("Puppy instance initializer #1");
}
{
System.out.println("Puppy instance initializer #2");
}
}
public class Main {
public static void main(String[] args) {
Puppy jack = new Puppy();
Puppy sam = new Puppy();
}
}
The output will be:
Dog instance initializer
Dog constructor
Puppy instance initializer #1
Puppy instance initializer #2
Puppy constructor
Dog instance initializer
Dog constructor
Puppy instance initializer #1
Puppy instance initializer #2
Puppy constructor
Initializer blocks are copied into every constructor, so they come handy when you have multiple constructors and have to use common code for them.
7. Phaser
Another valuable component of java.util.concurrent package is Phaser. It is similar to CountDownLatch and CyclicBarrier that allow thread coordination, but is more functional and flexible. Phaser enables the developers to synchronize the dynamic number of threads that need to wait on a barrier before proceeding the execution. Unlike other barriers, the Phaser barrier can be reused for all program phases, and the number of threads may vary in each phase. It can synchronize one or multiple phases whereas a CyclicBarrier supports only a single-phase synchronization.
Let’s take a look at a simple class for coordinating multiple phases.
import java.util.concurrent.Phaser;
class PhaserThread implements Runnable {
private String thread;
private Phaser phaser;
PhaserThread(String thread, Phaser phaser) {
this.thread = thread;
this.phaser = phaser;
phaser.register();
}
@Override
public void run() {
System.out.println(thread + " starting phase " + phaser.getPhase());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(thread + " finished work, waiting for others");
phaser.arriveAndAwaitAdvance();
phaser.arriveAndDeregister();
}
}
We are registering to the Phaser with a register()
method. The arriveAndAwaitAdvance()
method makes all current threads wait on the barrier until the last thread finishes its job. After that, threads deregister themselves with the arriveAndDeregister()
method.
You should pass 1 (the coordinator thread) as an argument when creating a Phaser instance in the main app which is the same as calling register()
from the thread.
public static void main(String[]args) {
ExecutorService executorService=Executors.newCachedThreadPool();
Phaser phaser=new Phaser(1);
executorService.submit(new PhaserThread("Thread A",phaser));
executorService.submit(new PhaserThread("Thread B",phaser));
executorService.submit(new PhaserThread("Thread C",phaser));
phaser.arriveAndAwaitAdvance();
System.out.println("Phase "+phaser.getPhase()+" is completed");
executorService.submit(new PhaserThread("Thread D",phaser));
executorService.submit(new PhaserThread("Thread E",phaser));
phaser.arriveAndAwaitAdvance();
System.out.println("Phase "+phaser.getPhase()+" is completed");
phaser.arriveAndDeregister();
}
The output will be similar to
Thread B starting phase 0
Thread A starting phase 0
Thread C starting phase 0
Thread C finished work, waiting for others
Thread B finished work, waiting for others
Thread A finished work, waiting for others
Phase 1 is completed
Thread D starting phase 1
Thread E starting phase 1
Thread D finished work, waiting for others
Thread E finished work, waiting for others
Phase 2 is completed
Another benefit of Phaser is that it can be tiered, i.e., constructed in tree structures where a group of sub-phasers has a common parent. It helps to avoid heavy contention costs and increase throughput.
8. Pattern matching for switch
Pattern matching for switch is a feature introduced in Java 17 and aimed at enhancing the experience of working with switch expressions and statements. Previously, switch statements could take only a few types of values — numeric types, enums, String — and test only the exact equality. We had to resort to numerous if… else blocks to use patterns with switch before Java 17, which would look like this:
static String formatter(Object o) {
String formatted = "unknown";
if (o instanceof Integer i) {
formatted = String.format("int %d", i);
} else if (o instanceof Long l) {
formatted = String.format("long %d", l);
} else if (o instanceof Double d) {
formatted = String.format("double %f", d);
} else if (o instanceof String s) {
formatted = String.format("String %s", s);
}
return formatted;
}
With pattern matching for switch the above code is reduced to
static String formatterPatternSwitch(Object o) {
return switch (o) {
case Integer i -> String.format("int %d", i);
case Long l -> String.format("long %d", l);
case Double d -> String.format("double %f", d);
case String s -> String.format("String %s", s);
default -> o.toString();
};
}
Furthermore, we can include the null test into the switch block and reduce boilerplate code for checking for null in a separate statement. The restrictions on the selector type have also been relaxed: the type of the selector expression can be either an integral primitive type or any reference type.
All in all, pattern matching for switch enables the developers to write clear, concise code and reduces the risk of errors.
Conclusion
These features demonstrate that Java has an API for every occasion. Moreover, Java code becomes more user-friendly with every release.
If you would like to experiment with the above solutions but your Java version doesn’t support them, download Liberica JDK — a TCK-verified OpenJDK distribution. And don’t worry about platform compatibility: Liberica works with the widest range of system configurations in the market!