Making most of Java: Features that you should use as a Java developer
Java is a popular programming language used for developing a wide range of applications. From simple command-line programs to enterprise-level applications, Java is widely used for its simplicity, reliability, and security. This blog post will cover 12 Java programming features with examples that will help you write more efficient and scalable code.
1. Use Streams
Java streams are a powerful feature introduced in Java 8 that allows you to process collections of data in a functional and declarative way. Streams provide a way to operate on collections of data without explicitly iterating over them, making your code more concise and expressive.
Here are some key concepts to understand when working with Java streams:
- Stream pipeline: A stream pipeline is a sequence of stream operations that are performed on a source (such as a collection) to produce a result. Stream pipelines include a source, zero or more intermediate operations, and a terminal operation.
- Intermediate operations: Intermediate operations are stream operations that transform the data in the stream. These operations are typically stateless, meaning they do not modify the elements in the stream, but instead create a new stream that contains the transformed elements.
- Terminal operations: Terminal operations are stream operations that produce a result or side effect. Terminal operations are typically stateful, meaning they consume the elements in the stream and have a result or side effect.
- Lazy evaluation: Streams are evaluated lazily, meaning that intermediate operations are only executed when a terminal operation is called. This allows streams to process large amounts of data efficiently, as only the necessary operations are performed.
Here are some of the most commonly used methods for working with Java streams:
filter(Predicate<T> predicate)
: Returns a new stream consisting of the elements that match the given predicate.map(Function<T, R> mapper)
: Returns a new stream consisting of the results of applying the given function to the elements of this stream.flatMap(Function<T, Stream<R>> mapper)
: Returns a new stream consisting of the results of replacing each element of this stream with the contents of a mapped stream produced by applying the provided mapping function to each element.distinct()
: Returns a stream consisting of the distinct elements of this stream.sorted()
: Returns a stream consisting of the elements of this stream, sorted according to their natural order.limit(long maxSize)
: Returns a stream consisting of the firstmaxSize
elements of this stream.skip(long n)
: Returns a stream consisting of the remaining elements of this stream after discarding the firstn
elements.reduce(BinaryOperator<T> accumulator)
: Performs a reduction on the elements of this stream, using the given binary operator to combine them into a single value.collect(Collector<T, A, R> collector)
: Performs a mutable reduction operation on the elements of this stream using the specified collector.
Here’s an example of how to use Java streams to filter and transform a collection of data:
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Emma"); List<String> result = names.stream() .filter(name -> name.length() < 5) .map(name -> name.toUpperCase()) .collect(Collectors.toList()); System.out.println(result); // prints "[BOB]"
In this example, we start with a list of names and create a stream from the list using the stream() method, filter the stream to include only names with lengths less than 5 characters, and transform the names to uppercase using the map()
method, and finally collect the results into a new list using the toList()
collector.
2. Use Lambdas
Lambdas are a powerful feature introduced in Java 8 that allows you to write more concise and expressive code for functional programming. In essence, a lambda expression is a way to define an anonymous function that can be passed around as a value.
Here’s an example of a lambda :
public class LambdaExample { public static void main(String[] args) { // define a lambda expression to print a message Runnable r = () -> System.out.println("Hello, world!"); // call the run method on the lambda expression r.run(); } }
In this example, we define a lambda expression using the Runnable
interface. The Runnable
interface is a functional interface with a single method, run()
, that takes no arguments and returns no value. We define the lambda expression using the arrow ->
syntax, which specifies the arguments and behaviour of the function. In this case, the lambda expression takes no arguments and simply prints a message.
We then create an instance of the Runnable
interface and assign it to the variable r
. We call the run()
method on r
, which executes the lambda expression and prints the message “Hello, world!” to the console.
Lambda expressions are often used in Java to define behaviour for functional interfaces, such as Runnable
, Predicate
, and Consumer
. By using lambda expressions, you can write more concise and expressive code that is easier to read and understand.
3. Functional Interface
A functional interface is an interface that has exactly one abstract method. Functional interfaces are used in Java to represent functions, which can be passed around as values and used to define behaviour in a more concise and expressive way.
Here’s an example of a functional interface in Java:
@FunctionalInterface interface MyFunction { int apply(int x, int y); }
In this example, we define a functional interface called MyFunction
. The interface has a single abstract method called apply()
, which takes two int
parameters and returns an int
result. The @FunctionalInterface
annotation is optional, but it is a good practice to include it to make it clear that the interface is intended to be used as a functional interface.
We can use the MyFunction
interface to define behaviour in a more expressive way. Here’s an example of how we can use the interface to define a lambda expression:
MyFunction add = (x, y) -> x + y; int result = add.apply(2, 3); System.out.println(result); // prints "5"
In this example, we define a lambda expression that adds two int
values. We create an instance of the MyFunction
interface using the lambda expression (x, y) -> x + y
. We then call the apply()
method on the add
instance, passing in the values 2 and 3. The apply()
method executes the lambda expression and returns the result, which we then print to the console.
Functional interfaces are a powerful feature in Java that allows you to write more expressive and concise code by defining behaviour as values. By understanding functional interfaces, you can take advantage of Java’s functional programming features and write code that is easier to read and maintain.
4. Use Optional
Optional
is a container object that may or may not contain a non-null value. It is used to avoid NullPointerException
in situations where a value may or may not be present. To create an Optional
object, we can use the static factory method ofNullable()
or of()
methods.
Here’s an example that demonstrates the usage of Optional
:
import java.util.Optional; public class OptionalExample { public static void main(String[] args) { String name = null; Optional<String> optionalName = Optional.ofNullable(name); if (optionalName.isPresent()) { System.out.println("Name is present: " + optionalName.get()); } else { System.out.println("Name is not present"); } name = "John"; optionalName = Optional.ofNullable(name); if (optionalName.isPresent()) { System.out.println("Name is present: " + optionalName.get()); } else { System.out.println("Name is not present"); } } }
In this example, we create an Optional
object optionalName
with the value null
using the ofNullable()
method. We check if the optionalName
is present using the isPresent()
method, and if it is not present, we print "Name is not present"
.
Next, we set name
to "John"
and create an Optional
object optionalName
again using the ofNullable()
method. We check if the optionalName
is present using the isPresent()
method, and if it is present, we print "Name is present: John"
using the get()
method.
By using Optional
, we avoid NullPointerException
in case the value of name
is null
.
5. Concurrency APIs
Java provides a set of Concurrency APIs that help developers write concurrent and multi-threaded programs. The Concurrency APIs are designed to make it easier to write thread-safe code, coordinate multiple threads, and avoid common concurrency issues like race conditions and deadlocks. Here’s an overview of some of the main Concurrency APIs in Java:
- Threads: Java’s
Thread
class is used to create and manage threads. A thread is a separate execution context that can run concurrently with other threads in a program. By default, Java programs have a main thread, but you can create additional threads to perform parallel tasks. - Executors: The
Executor
framework provides a high-level abstraction for executing tasks asynchronously. It manages a pool of worker threads that can be used to execute tasks concurrently, which can improve performance and reduce latency. Executors can also handle tasks that fail, retry tasks, and manage task dependencies. - Synchronization: Synchronization is the process of coordinating multiple threads to ensure that they access shared resources in a safe and orderly manner. Java provides several synchronization mechanisms, including
synchronized
blocks,volatile
variables, and theLock
interface. These mechanisms ensure that threads access shared resources in a thread-safe manner, which helps to prevent race conditions and other concurrency issues. - Atomic Variables: Atomic variables are thread-safe variables that can be read and written atomically, meaning that they are guaranteed to be in a consistent state at all times. Java provides several atomic variable classes, such as
AtomicInteger
,AtomicLong
, andAtomicReference
, which can be used to implement thread-safe counters, accumulators, and other data structures. - Concurrent Collections: Concurrent collections are thread-safe data structures that can be used to manage shared collections of data. Java provides several concurrent collection classes, such as
ConcurrentHashMap
andConcurrentLinkedQueue
, which can be used to implement thread-safe maps, lists, and sets. - Fork/Join Framework: The Fork/Join framework is a parallel programming framework that is used to divide a large task into smaller sub-tasks, which can then be executed concurrently on multiple threads. It is particularly useful for parallelizing recursive algorithms, such as sorting or searching algorithms.
Example of Executors
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ExecutorsExample { public static void main(String[] args) { ExecutorService executor = Executors.newFixedThreadPool(5); for (int i = 0; i < 10; i++) { Runnable worker = new WorkerThread("Task " + i); executor.execute(worker); } executor.shutdown(); while (!executor.isTerminated()) { } System.out.println("Finished all tasks"); } } class WorkerThread implements Runnable { private String task; public WorkerThread(String s) { this.task = s; } @Override public void run() { System.out.println(Thread.currentThread().getName() + " Starting task: " + task); processTask(); System.out.println(Thread.currentThread().getName() + " Completed task: " + task); } private void processTask() { // Task implementation goes here } }
In this example, we create an ExecutorService
with a thread pool of 5 threads using the Executors.newFixedThreadPool()
method. We then create 10 WorkerThread
instances and submit them to the ExecutorService
using the execute()
method.
Each WorkerThread
instance is executed in a separate thread from the thread pool. Once all tasks have been submitted, we shut down the ExecutorService
and wait for all threads to complete by calling executor.isTerminated()
method.
6. HTTP Client ( Java 11 )
The HTTP Client API was introduced in Java 11 as a new standard library module to provide a modern, flexible, and efficient HTTP client API. It supports both HTTP/1.1 and HTTP/2 protocols and offers a simple and fluent API for making HTTP requests and handling responses.
Here’s a simple example that demonstrates how to use the HTTP Client API to send a GET request and receive the response:
import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class HttpClientExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://jsonplaceholder.typicode.com/todos/1")) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println("Response status code: " + response.statusCode()); System.out.println("Response body: " + response.body()); } }
In this example, we create an instance of HttpClient
using the newHttpClient()
factory method. Then, we build an HttpRequest
using the HttpRequest.newBuilder()
method and specify the URI of the resource we want to request. Next, we send the request using the client.send()
method and pass in the request and a BodyHandler
that will convert the response body to a String
. Finally, we print the response status code and body to the console.
The HttpClient
class also provides methods for customizing the HTTP request, such as adding headers, setting a timeout, and specifying the HTTP method. Additionally, it supports asynchronous requests using the CompletableFuture
API, which can improve the performance of applications that make many HTTP requests.
Overall, the HTTP Client API is a powerful and flexible tool for making HTTP requests in Java.
7. Use Records ( Java 16 )
Records were introduced in Java 16 and provide a concise way to declare classes that are mainly used to store data. A record is similar to a class, but its primary purpose is to model data rather than behaviour. In this way, records can be seen as an extension of Java’s existing support for value types, which are data-oriented constructs.
Here’s an example that demonstrates how to use records in Java:
public record Person(String name, int age) {}
In this example, we define a Person
record that has two components: a name
of type String
and an age
of type int
. This record automatically generates a constructor, accessor methods, and equals()
and hashCode()
methods based on its components.
We can then use this record to create new instances of Person
:
Person person = new Person("John Doe", 25); System.out.println(person.name()); // prints "John Doe" System.out.println(person.age()); // prints 25
In this example, we create a new Person
instance with the name “John Doe” and age 25. We can then use the accessor methods name()
and age()
to access the record’s components.
Records can also be used with inheritance and interfaces, just like classes:
public sealed interface Animal permits Cat, Dog { String sound(); } public record Cat(String name) implements Animal { public String sound() { return "Meow"; } } public record Dog(String name) implements Animal { public String sound() { return "Woof"; } }
In this example, we define an Animal
interface that is sealed and permits two implementing classes: Cat
and Dog
. We then define two records, Cat
and Dog
, that implements the Animal
interface and provide an implementation of the sound()
method.
Records provide a concise and powerful way to model data in Java and are particularly useful in scenarios where classes are used primarily to store data.
8. Use Switch expression ( Java 12 )
Switch expressions is a new feature introduced in Java 12 that simplifies the syntax of the traditional switch statement. It allows us to use expressions in the switch case, and also removes the need for a break statement in each case.
Here is an example of how to use switch expressions in Java:
public class SwitchExpressionExample { public static void main(String[] args) { int dayOfWeek = 3; String dayType = switch (dayOfWeek) { case 1, 2, 3, 4, 5 -> "Weekday"; case 6, 7 -> "Weekend"; default -> throw new IllegalArgumentException("Invalid day of the week: " + dayOfWeek); }; System.out.println(dayType); // Output: Weekday } }
In this example, we are using a switch expression to determine the type of day of the week based on the day number. The switch expression takes the dayOfWeek
variable as its input and returns a string representing the type of day.
In the switch cases, we are using the new arrow syntax ->
to specify the output value for each case. In this example, we have used a multi-case syntax to specify that weekdays have day numbers 1 to 5, and weekends have day numbers 6 and 7. The default
case is used to handle any invalid input and throws an IllegalArgumentException
.
Finally, we have assigned the output of the switch expression to the dayType
variable, which is printed to the console.
Switch expressions provide a more concise and expressive syntax for handling multiple cases in Java.
9. try-with-resources
Prior to Java 7, it was common to use a try-catch-finally block to handle exceptions and to ensure that any resources, such as files or network connections, were properly closed after use. However, this approach could lead to cluttered and error-prone code.
The try-with-resources statement was introduced in Java 7 to simplify resource management and exception handling. It allows you to declare one or more resources in a try block, and the resources are automatically closed when the block is exited, either normally or due to an exception.
Here is the syntax of the try-with-resources statement:
try (resource1; resource2; ... ) { // code that uses resources } catch (ExceptionType e) { // exception handling code }
The resources that are declared in the parentheses after the try keyword must implement the AutoCloseable
interface, which has a single close()
method. This interface is implemented by many standard Java classes that deal with I/O and other resources.
Here is an example of using try-with-resources to read the contents of a file:
try (FileReader reader = new FileReader("myfile.txt"); BufferedReader br = new BufferedReader(reader)) { String line; while ((line = br.readLine()) != null) { System.out.println(line); } } catch (IOException e) { System.err.println("Error reading file: " + e.getMessage()); }
In this example, the FileReader
and BufferedReader
objects are declared in parentheses after the try
keyword. These resources are automatically closed at the end of the block, even if an exception is thrown. The catch
block handles any IOExceptions
that may be thrown while reading the file.
Overall, try-with-resources can greatly simplify resource management and exception handling in Java and is a feature that every advanced developer should be familiar with.
10. Use Annotations for metadata
Annotations in Java are a way of adding metadata to code elements such as classes, methods, fields, and parameters. They provide a way to give additional information about code elements that can be used by tools and frameworks to generate code, enforce rules, or provide additional functionality.
Here’s an example that demonstrates how to use annotations in Java:
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface LogExecutionTime { }
In this example, we define a custom annotation called LogExecutionTime
. This annotation has two other annotations applied to it: @Retention
and @Target
. The @Retention
annotation specifies that the annotation should be available at runtime, and the @Target
annotation specifies that the annotation can only be applied to methods.
We can then use this annotation in our code to mark methods that we want to log the execution time for:
public class Example { @LogExecutionTime public void someMethod() { // ... } }
In this example, we apply the @LogExecutionTime
annotation to the someMethod()
method. We can then use a tool or framework to process this annotation and log the execution time of the method.
11. JShell ( Java 9 )
JShell, also known as Java Shell, is an interactive command-line tool introduced in Java 9 that allows you to execute Java code snippets and see the output immediately, without the need to write a full Java class. It provides a quick and easy way to experiment with Java code and test small snippets without the overhead of creating a complete project.
Here’s an example of how to use JShell:
- Open a terminal or command prompt and type
jshell
to start JShell. - Type any valid Java code you want to execute, such as
System.out.println("Hello, world!");
- Press Enter, and JShell will execute the code and display the output:
jshell> System.out.println("Hello, world!"); Hello, world!
You can also define variables, classes, and methods in JShell, just like in a regular Java program. For example:
jshell> int x = 5; x ==> 5 jshell> String message = "Hello, world!"; message ==> "Hello, world!" jshell> void greet(String name) { ...> System.out.println("Hello, " + name + "!"); ...> } | created method greet(String) jshell> greet("Alice"); Hello, Alice!
JShell also provides tab completion and syntax highlighting to make it easier to write Java code interactively. You can use JShell to test out new language features or APIs, prototype code, or debug small sections of code without having to create a full Java project.
You can refer to the below post for a complete guide on JShell
12. var keyword ( Java 10 )
Local Variable Type Inference (var) is a feature introduced in Java 10 that allows us to declare local variables without specifying the type explicitly. Instead, the type of the variable is inferred from the initializer expression.
Here is an example of how to use var in Java:
public class VarExample { public static void main(String[] args) { var name = "John Doe"; var age = 30; var salary = 50000.00; System.out.println("Name: " + name + ", Age: " + age + ", Salary: " + salary); } }
In this example, we are using var to declare three local variables – name
, age
, and salary
. The type of these variables is inferred from the initializer expression, which is a string literal, an integer literal, and a double literal respectively.
The System.out.println
statement then prints the values of these variables to the console.
Local Variable Type Inference can be especially useful when working with complex generic types, as it can simplify the declaration of these types. However, it is important to use var judiciously and not to overuse it, as it can make the code harder to read and understand.
Footnotes
Java is a powerful programming language with a variety of advanced features that can help developers write efficient and maintainable code. Some of the most important features for Java developers include Lambdas, Functional Interfaces, Stream API, Optional, Concurrency APIs, HTTP Client API, Records, IO APIs, and Try-With-Resources. With a solid understanding of these features, developers can take their skills to the next level and develop impressive software applications.