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 first maxSize elements of this stream.
  • skip(long n): Returns a stream consisting of the remaining elements of this stream after discarding the first n 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:

  1. 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.
  2. 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.
  3. 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 the Lock interface. These mechanisms ensure that threads access shared resources in a thread-safe manner, which helps to prevent race conditions and other concurrency issues.
  4. 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, and AtomicReference, which can be used to implement thread-safe counters, accumulators, and other data structures.
  5. 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 and ConcurrentLinkedQueue, which can be used to implement thread-safe maps, lists, and sets.
  6. 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:

  1. Open a terminal or command prompt and type jshell to start JShell.
  2. Type any valid Java code you want to execute, such as System.out.println("Hello, world!");
  3. 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.

You may also like...

Leave a Reply

Your email address will not be published. Required fields are marked *