Java's Journey: An Insightful Guide to the Features Introduced in Each Release from Java 8 to Java 17

Vishal ParekhVishal Parekh
29 min read

Introduction:

Java is one of the most popular programming languages in the world, used by millions of developers to build a wide range of applications, from enterprise systems to mobile apps and games. Since its initial release in 1995, Java has evolved significantly, with new features and enhancements added in each major release. From the introduction of the Java Virtual Machine (JVM) and applets in Java 1.0, to the module system and switch expressions in Java 14, Java has come a long way in the last few decades.

In this blog, we'll take you on a journey through Java's history, exploring the major features and improvements introduced in each release. We'll start with Java 8 and work our way up to the latest version Java 17, discussing the significance of each feature and its impact on Java developers. Whether you're a beginner looking to learn more about Java or an experienced developer looking to stay up-to-date with the latest features, this blog will provide you with a comprehensive guide to Java's evolution and the key features with examples that have made it the popular language today.

Java 8: Lambda Expressions, Streams and Default Methods

  1. Lambdas: Java 8 introduced support for functional programming with the introduction of lambdas.

     List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Dave");
    
     // Using a lambda expression to sort the list by length
     names.sort((a, b) -> Integer.compare(a.length(), b.length()));
    
     // Using a lambda expression to print each name in the list
     names.forEach(name -> System.out.println(name));
    
  2. Streams: The Streams API provides a way to perform functional-style operations on collections of objects.

     List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
     List<Integer> squares = numbers.stream()
                                     .filter(n -> n % 2 == 0)
                                     .map(n -> n * n)
                                     .collect(Collectors.toList());
     /*
     In this example, we use the "stream()" method to create a stream of 
     integers from a list. We then use the "filter()" method to keep only the
     even numbers, and the "map()" method to square each number. Finally, we 
     use the "collect()" method to convert the stream back into a list of 
     integers.
     */
    
  3. Default methods: Java 8 introduced the ability to add default implementations to methods in interfaces.

     interface Animal {
       void eat();
    
       default void sleep() {
         System.out.println("Zzz...");
       }
     }
    
     class Dog implements Animal {
       public void eat() {
         System.out.println("Dog is eating");
       }
     }
    
     Dog dog = new Dog();
     dog.eat(); // Output: Dog is eating
     dog.sleep(); // Output: Zzz...
    

Java 9: Modularization, JShell and More

  1. Modularization: Java 9 introduced the concept of modules, which allows developers to create more modular and maintainable code.

     module com.example.myapp {
         requires java.base;
         requires java.sql;
         requires spring.context;
         exports com.example.myapp.service;
     }
     /*
     This module declaration defines a module named "com.example.myapp" that 
     requires the "java.base", "java.sql", and "spring.context" modules, and 
     exports the "com.example.myapp.service" package.
     */
    
  2. JShell: JShell is an interactive shell that allows developers to experiment with Java code and explore APIs. JShell is an interactive REPL (Read-Eval-Print Loop) tool that allows developers to experiment with Java code snippets and get immediate feedback. Here is an example of using JShell:

     jshell> int a = 10
     a ==> 10
    
     jshell> int b = 20
     b ==> 20
    
     jshell> a + b
     $3 ==> 30
    
     /*
     In this example, we define two integer variables "a" and "b" in JShell, 
     and then use the "+" operator to add them together. JShell immediately 
     returns the result, which is 30.
     */
    
  3. Improved performance: Java 9 also introduced several performance improvements to the JVM (Java Virtual Machine), including better memory management and more efficient garbage collection. Here is an example of measuring the execution time of a piece of code:

     public class MyClass {
         public static void main(String[] args) {
             long startTime = System.nanoTime();
             // Code to be timed
             long endTime = System.nanoTime();
             long duration = (endTime - startTime) / 1000000; // Convert to milliseconds
             System.out.println("Time taken: " + duration + "ms");
         }
     }
    
     /*
     In this example, we use the "System.nanoTime()" method to measure the 
     start and end time of a code block, and then calculate the duration of 
     the code execution in milliseconds. This is a simple way to measure the 
     performance of your code and identify any bottlenecks that need to be 
     optimized.
     */
    
  4. Private method in Interface: Java 9 introduced the ability to define private methods in interfaces, which can be used to provide helper methods or shared implementations between default methods. Here's an example:

     public interface MyInterface {
    
         private int myPrivateMethod(int a, int b) {
             return a + b;
         }
    
         default void myDefaultMethod() {
             // call private method from default method
             int result = myPrivateMethod(5, 10);
             System.out.println("Result is: " + result);
         }
     }
     /*
     In this example, we define an interface MyInterface with a default method 
     myDefaultMethod. We also define a private method myPrivateMethod that is 
     used by the default method.
    
     The private method is only accessible within the interface and cannot be 
     accessed from outside. It can only be used by other methods within the 
     interface.
    
     By providing private methods in interfaces, developers can now reuse code 
     across multiple default methods without having to duplicate code or 
     create helper classes. This makes interfaces more powerful and flexible 
     in Java 9 and later versions.
     */
    

Java 10: Local Variable Type Inference and More

  1. Local variable type inference: Local Variable Type Inference allows developers to declare local variables without specifying their type. Here is an example of using Local Variable Type Inference:

     var name = "John";
     var age = 30;
     var height = 1.80;
     var list = List.of("one", "two", "three");
    
     /*
     In this example, we declare three local variables without explicitly 
     specifying their types. The compiler infers their types based on the 
     values assigned to them.
     */
    
  2. Garbage Collector Interface: Java 10 introduced a new interface for interacting with the garbage collector, called the "JEP 304: Garbage Collector Interface". This allows developers to write more efficient and flexible garbage collectors that can be tailored to specific use cases. Here is an example of using the Garbage Collector MXBean to get information about the garbage collector:

     List<GarbageCollectorMXBean> beans = ManagementFactory.getGarbageCollectorMXBeans();
     for (GarbageCollectorMXBean bean : beans) {
         System.out.println("Name: " + bean.getName());
         System.out.println("Collection count: " + bean.getCollectionCount());
         System.out.println("Collection time: " + bean.getCollectionTime() + "ms");
     }
    
     /*
     In this example, we use the "ManagementFactory.getGarbageCollectorMXBeans()" method to get a list of 
     GarbageCollectorMXBean objects, which represent the garbage collectors in 
     the JVM. We then loop through the list and output some information about 
     garbage collector, including its name, collection count, and collection 
     time.
     */
    
  3. Application Class-Data Sharing: Java 10 introduced a new feature called "Application Class-Data Sharing", which allows developers to share pre-compiled class data between multiple JVM instances to reduce startup time and memory usage. Here is an example of creating an archive file containing the shared class data:

     java -Xshare:dump -XX:SharedArchiveFile=myapp.jsa -cp myapp.jar
    
     /*
     In this example, we use the "java -Xshare:dump" command to dump the 
     shared class data from the "myapp.jar" file into an archive file called 
     "myapp.jsa". This archive file can then be used by other JVM instances to 
     load the pre-compiled classes, reducing startup time and memory usage.
     */
    

Java 11: HTTP Client, String Methods, and More

  1. HTTP Client (Standard): Java 11 introduced a new HTTP client library as a standard feature, which provides a more efficient and flexible way to send HTTP requests and receive responses. Here is an example of using the new HTTP client to send a GET request and read the response:

     HttpClient client = HttpClient.newHttpClient();
     HttpRequest request = HttpRequest.newBuilder()
         .uri(URI.create("https://www.example.com"))
         .GET()
         .build();
     HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
     System.out.println(response.body());
    
     /*
     In this example, we create a new HttpClient instance and use it to send a 
     GET request to "https://www.example.com". We then use the BodyHandlers 
     class to read the response body as a string, and output it to the 
     console.
     */
    
  2. Local-Variable Syntax for Lambda Parameters: Java 11 introduced a new feature that allows developers to use "var" instead of an explicit type declaration for the parameters of a lambda expression. Here is an example of using "var" to declare the parameters of a lambda expression:

     BiFunction<Integer, Integer, Integer> add = (var x, var y) -> x + y;
     System.out.println(add.apply(2, 3));
    
     /*
     In this example, we use "var" to declare the parameters of a BiFunction 
     lambda expression, which takes two integer arguments and returns their 
     sum. We then use the "apply()" method to apply the lambda expression to 
     the values 2 and 3, and output the result (5) to the console.
     */
    
  3. Nest-Based Access Control: Java 11 introduced a new feature called "Nest-Based Access Control", which allows inner classes to access private members of their enclosing class, even if they are in a different package. Here is an example of using a nested class to access a private member of its enclosing class:

     package com.example;
    
     public class Outer {
         private int x = 42;
    
         public class Inner {
             public int getX() {
                 return x;
             }
         }
     }
    
     package com.other;
    
     import com.example.Outer;
    
     public class Other {
         public static void main(String[] args) {
             Outer outer = new Outer();
             Outer.Inner inner = outer.new Inner();
             System.out.println(inner.getX());
         }
     }
    
     /*
     In this example, we define an Outer class with a private member variable 
     "x", and an Inner class that can access "x" using the new nest-based 
     access control feature. We then create an instance of the Outer class and an Inner class, and use the Inner class to access the value of "x" and 
     output it to the console.
     */
    
  4. Flight Recorder: The Flight Recorder feature in Java 11 allows you to capture low-level events and performance data from your Java applications. This can be extremely helpful for debugging issues, identifying performance bottlenecks, and monitoring system health.

    Here's an example of how to use the Flight Recorder feature in Java 11:

     import jdk.jfr.*;
    
     public class FlightRecorderExample {
         public static void main(String[] args) throws InterruptedException {
             FlightRecorder recorder = FlightRecorder.getFlightRecorder();
    
             // Start a recording
             RecordingOptions options = new RecordingOptions.Builder()
                 .name("MyRecording")
                 .duration(Duration.ofMinutes(5))
                 .build();
             Recording recording = recorder.startRecording(options);
    
             // Perform some operations that you want to monitor
             for (int i = 0; i < 1000000; i++) {
                 String s = "Test " + i;
                 Thread.sleep(10);
             }
    
             // Stop the recording and dump the data to a file
             recording.stop();
             recording.dump("recording.jfr");
         }
     }
    
     /*
     In this example, we first import the "jdk.jfr.*" package, which contains 
     the Flight Recorder classes and interfaces.
    
     Next, we use the "FlightRecorder.getFlightRecorder()" method to get an 
     instance of the Flight Recorder.
    
     We then create a new recording using the "recorder.startRecording()" 
     method, passing in a set of options that define the name of the recording 
     and how long it should run for.
    
     We then perform some operations that we want to monitor, in this case 
     simply sleeping for 10 milliseconds a million times.
    
     Finally, we stop the recording using the "recording.stop()" method, and 
     dump the data to a file using the "recording.dump()" method.
    
     The resulting "recording.jfr" file can then be analyzed using tools like 
     the Java Mission Control tool, which allows you to view low-level data 
     about your application's performance and behavior.
    
     Overall, the Flight Recorder feature can be a powerful tool for 
     diagnosing issues and optimizing the performance of your Java 
     applications.
     */
    
  5. Epsilon: The Epsilon feature in Java 11 is a new experimental garbage collector that does essentially nothing - it simply allocates memory for objects and then immediately discards them without any garbage collection. This is useful in cases where you need to test the performance of your application without worrying about garbage collection overhead.

    Here's an example of how to use the Epsilon garbage collector in Java 11:

     import java.util.ArrayList;
    
     public class EpsilonExample {
         public static void main(String[] args) {
             // Enable the Epsilon garbage collector
             System.setProperty("jdk.internal.vm.gc.Epsilon.Mode", "all");
    
             // Allocate memory for a large number of objects
             ArrayList<byte[]> list = new ArrayList<>();
             for (int i = 0; i < 1000000; i++) {
                 list.add(new byte[1024]);
             }
    
             // Wait for a little while to give the garbage collector a chance to do its thing
             try {
                 Thread.sleep(5000);
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         }
     }
    
     /*
     In this example, we first enable the Epsilon garbage collector by setting 
     the "jdk.internal.vm.gc.Epsilon.Mode" system property to "all".
    
     We then allocate memory for a large number of objects by creating an 
     ArrayList and adding a million byte arrays to it.
    
     Finally, we wait for a little while to give the Epsilon garbage collector 
     a chance to do its thing (which, in this case, is nothing!).
    
     Note that the Epsilon garbage collector is an experimental feature and 
     should only be used for testing and experimentation, as it is not 
     suitable for real-world applications.
     */
    
  6. New String methods:

    1. isBlank(): This method returns true if the string is empty or contains only white space, and false otherwise. This can be useful for checking if a string contains any meaningful content, especially when reading input from a user or a file. Here's an example of how to use the isBlank method:

       String emptyString = "";
       String whitespaceString = "   ";
       String nonEmptyString = "hello world";
      
       System.out.println(emptyString.isBlank()); // true
       System.out.println(whitespaceString.isBlank()); // true
       System.out.println(nonEmptyString.isBlank()); // false
      
       /*
       In this example, we use the isBlank method to check if three 
       different strings are blank or not. The first two strings are empty 
       or contain only white space, so their isBlank method returns true. 
       The third string contains meaningful content, so its isBlank method 
       returns false.
       */
      
    2. lines(): This method returns a stream of lines from the string, split by line terminators. This can be useful for processing text that is organized into separate lines, such as log files or configuration files. Here's an example of how to use the lines method:

       String text = "hello\nworld\nhow are you?";
       text.lines().forEach(System.out::println);
      
       /*
       In this example, we use the lines method to split the string into 
       separate lines and create a stream of those lines. We then use the 
       forEach method to print each line to the console. 
      
       The output of this code would be:
       */
      
       hello
       world
       how are you?
      
    3. repeat(int count): This method returns a new string that repeats the original string a specified number of times. This can be useful for generating test data, filling in templates, or formatting text in a repetitive way. Here's an example of how to use the repeat method:

       String repeated = "hello".repeat(3);
       System.out.println(repeated); // outputs "hellohellohello"
      
       /*
       In this example, we use the repeat method to repeat the string 
       "hello" three times. The result of this repetition is returned as a 
       new string, which we then print to the console.
       */
      
    4. strip(): This method returns a new string with all whitespace characters removed from both the beginning and the end of the original string. Here's an example of how to use the strip method:

       String text = "   Hello, World!   ";
       String stripped = text.strip();
       System.out.println(stripped); // outputs "Hello, World!"
      
       /*
       In this example, we use the strip method to remove the leading and 
       trailing whitespace characters from the string text. The result is a 
       new string with only the text "Hello, World!".
       */
      
    5. stripLeading(): This method returns a new string with all whitespace characters removed from the beginning of the original string. Here's an example of how to use the stripLeading method:

       String text = "   Hello, World!   ";
       String stripped = text.stripLeading();
       System.out.println(stripped); // outputs "Hello, World!   "
      
       /*
       In this example, we use the stripLeading method to remove only the 
       leading whitespace characters from the string text. The result is a 
       new string with the same trailing whitespace characters, but without 
       the leading ones.
       */
      
    6. stripTrailing(): This method returns a new string with all whitespace characters removed from the end of the original string. Here's an example of how to use the stripTrailing method:

       String text = "   Hello, World!   ";
       String stripped = text.stripTrailing();
       System.out.println(stripped); // outputs "   Hello, World!"
      
       /*
       In this example, we use the stripTrailing method to remove only the 
       trailing whitespace characters from the string text. The result is a 
       new string with the same leading whitespace characters, but without 
       the trailing ones.
       */
      

Java 12: Switch Expressions and More

  1. Switch Expressions(Preview): Java 12 introduced a new syntax for switch statements called "Switch Expressions." This feature allows developers to use switch statements as expressions, which means they can be used more concisely and expressively. Here is an example:

     public class SwitchExample {
         public static void main(String[] args) {
             int day = 4;
             String dayName = switch (day) {
                 case 1 -> "Monday";
                 case 2 -> "Tuesday";
                 case 3, 4, 5 -> "Wednesday";
                 default -> "Invalid day";
             };
             System.out.println(dayName); // prints "Wednesday"
         }
     }
    
     /*
     In this example, the switch statement is used as an expression that 
     assigns a value to the variable dayName. The case statements now use the 
     -> arrow notation to indicate the expression that should be returned.
     */
    
  2. Compact Number Formatting: Java 12 introduces a new API for formatting numbers in a compact format that is easier to read. The new NumberFormat class provides a new method called getCompactNumberInstance() that returns an instance of the formatter. Here is an example:

     import java.text.NumberFormat;
    
     public class CompactNumberExample {
         public static void main(String[] args) {
             NumberFormat nf = NumberFormat.getCompactNumberInstance();
             System.out.println(nf.format(12345)); // prints "12K"    .
             System.out.println(nf.format(1000000)); // prints "1M"
         }
     }
    
     /*
     In this example, the getCompactNumberInstance() method returns an 
     instance of NumberFormat that formats numbers in a compact format. The 
     number 12345 is formatted as "12K".
     */
    
  3. Teeing Collectors: Java 12 introduces a new method in the Collectors class called teeing(), which allows developers to collect data from two different collectors at the same time. This is useful when you need to perform two different calculations on the same set of data. Here is an example:

     import java.util.List;
     import java.util.stream.Collectors;
    
     public class TeeingCollectorsExample {
         public static void main(String[] args) {
             List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
             double average = numbers.stream()
                     .collect(Collectors.teeing(
                             Collectors.summingDouble(i -> i),
                             Collectors.counting(),
                             (sum, count) -> sum / count
                     ));
             System.out.println(average); // Output: 5.5
         }
     }
     /*
     This feature allows you to collect data from a stream using two different 
     collectors, and then combine the results of those collectors into a 
     single value. In this example, we use the "Collectors.teeing()" method to 
     collect the sum and count of a list of integers, and then calculate the 
     average by dividing the sum by the count.
     */
    
  4. Shenandoah: Shenandoah is a new garbage collector in Java 12 that is designed to reduce the pause times that are typically associated with garbage collection. Here's an example of how to use Shenandoah:

     java -XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC MyApp
    
     /*
     In this example, we use the "-XX:+UseShenandoahGC" option to enable the 
     Shenandoah garbage collector. We also use the "-
     XX:+UnlockExperimentalVMOptions" option to enable experimental options, 
     as Shenandoah is still an experimental feature in Java 12.
    
     By using Shenandoah, you should see reduced pause times during garbage 
     collection, which can help to improve the performance of your 
     application.
     */
    
  5. Microbenchmark suite: The Microbenchmark Suite is a new set of tools in Java 12 that can be used to perform microbenchmarks on your code. This can help you to identify performance bottlenecks and improve the overall performance of your application. Here's an example of how to use the Microbenchmark Suite:

     @Benchmark
     @BenchmarkMode(Mode.AverageTime)
     @OutputTimeUnit(TimeUnit.MICROSECONDS)
     public void testMethod(Blackhole bh) {
         // code to be benchmarked goes here
     }
    
     /*
     In this example, we use the "@Benchmark" annotation to mark a method as a 
     benchmark. We also use the "@BenchmarkMode" annotation to specify that we 
     want to measure the average time taken by the method, and the 
     "@OutputTimeUnit" annotation to specify that we want to display the 
     results in microseconds.
    
     The Microbenchmark Suite can help you to identify performance issues in 
     your code that may not be apparent during normal testing. By identifying 
     these issues and optimizing your code, you can improve the overall 
     performance of your application
     */
    
  6. New methods in String class:

    1. indent(int n): This method returns a string that is indented by the specified number of spaces. This can be useful for formatting strings in a readable way, especially when you need to display nested or hierarchical data. Here's an example of how to use the indent method:

       String text = "Hello\nworld\n\nHow are you?";
       System.out.println(text.indent(4));
      

      In this example, we use the indent method to indent the string by 4 spaces. The output of this code would be:

           Hello
           world
      
           How are you?
      
    2. transform(Function<String, ? extends R> f): This method applies a transformation function to the string and returns the result as a new string. This can be useful for performing custom transformations on strings, such as converting them to a different format or encoding. Here's an example of how to use the transform method:

       String text = "hello world";
       String reversed = text.transform(s -> new StringBuilder(s).reverse().toString());
       System.out.println(reversed); // outputs "dlrow olleh"
      
       /*
       In this example, we use the transform method to reverse the 
       characters in the string. We pass in a lambda function that creates a 
       new StringBuilder object and calls its reverse method to reverse the 
       characters. The result of this transformation is returned as a new 
       string, which we then print to the console.
       */
      

Java 13: Text Blocks and More

  1. Text blocks(Preview): Text blocks allow you to declare strings with embedded newlines and formatting without having to use escape characters. Here's an example:

     String html = """
         <html>
             <body>
                 <p>Hello, world!</p>
             </body>
         </html>
         """;
     /*
     In this example, we use three double quotes to indicate the start and end 
     of the text block. The text block can contain any characters, including 
     newlines, and it preserves the formatting of the text.
     */
    
  2. Switch expressions enhancements(Preview): Java 13 enhances the switch expressions feature introduced in Java 12 by allowing the use of "yield" statements to return values from a switch expression. Here's an example:

     int result = switch (dayOfWeek) {
         case MONDAY, FRIDAY, SUNDAY -> {
             yield 6;
         }
         case TUESDAY -> {
             yield 7;
         }
         case THURSDAY, SATURDAY -> {
             yield 8;
         }
         case WEDNESDAY -> {
             yield 9;
         }
         default -> {
             yield 0;
         }
     };
    
     /*
     In this example, we use the "yield" statement to return a value from each 
     case in the switch expression. This makes the code more concise and 
     easier to read.
     */
    
  3. Dynamic CDS Archives: Java 13 introduces support for creating Dynamic Class-Data Sharing (CDS) archives, which can improve the startup time and memory footprint of your application. Here's an example of how to create a dynamic CDS archive:

     java -XX:ArchiveClassesAtExit=myapp.jsa -cp myapp.jar MyApp
    
     /*
     In this example, we use the "-XX:ArchiveClassesAtExit" option to specify 
     the name of the CDS archive to be created. We also use the "-cp" option 
     to specify the classpath for our application.
    
     By creating a dynamic CDS archive, we can reduce the startup time and 
     memory footprint of our application, which can improve the overall 
     performance and user experience.
     */
    

Java 14: Records, Pattern Matching and More

  1. Records(Preview): Records are a new kind of class introduced in Java 14 that is designed to make it easier to create simple, immutable data objects. Here's an example:

     public record Point(int x, int y) { }
    
     /*
     In this example, we use the "record" keyword to define a new class called 
     "Point". The class has two fields, "x" and "y", which are initialized via 
     the constructor. Because the class is defined as a record, it is 
     automatically immutable and provides a compact, easy-to-read syntax for 
     creating simple data objects.
    
     This simple declaration will automatically add a constructor, getters,
      equals, hashCode and toString methods for us.
     */
    
  2. Pattern matching(Preview): Pattern matching is a new feature introduced in Java 14 that allows developers to write more concise and readable code when dealing with object types.

    Here's an example of how to use pattern matching in Java 14:

     public static int getArea(Object shape) {
         if (shape instanceof Rectangle r) {
             return r.width * r.height;
         } else if (shape instanceof Circle c) {
             return (int) (Math.PI * c.radius * c.radius);
         } else {
             throw new IllegalArgumentException("Unknown shape: " + shape);
         }
     }
    
     /*
     In this example, we define a method called "getArea" that takes an object 
     called "shape". We then use a series of "if" statements to check the type 
     of the object, and if it matches a certain pattern, we can bind the 
     variable to a new name and use it in the corresponding block of code.
    
     In the first "if" statement, we check if the object is an instance of 
     "Rectangle" and if it is, we bind the variable "r" to the object and 
     calculate the area. In the second "if" statement, we check if the object 
     is an instance of "Circle" and if it is, we bind the variable "c" to the 
     object and calculate the area. Finally, if the object doesn't match 
     either pattern, we throw an exception.
    
     Pattern matching can be particularly useful when dealing with complex 
     object hierarchies or APIs that return objects of different types. It can 
     make the code more concise and easier to read, and reduce the amount of 
     boilerplate code required to handle different cases.
     */
    
  3. Helpful NullPointerExceptions: The Helpful NullPointerExceptions feature in Java 14 is designed to improve the error messages that are produced when a NullPointerException occurs. It provides more information about the cause of the exception, which can make it easier for developers to identify and fix the underlying issue. Here's an example of how this feature works:

     String s = null;
     int length = s.length();
    

    In previous versions of Java, this code would result in a NullPointerException, with a message that simply said: "null". However, with the Helpful NullPointerExceptions feature, the message would now include additional information about the cause of the exception:

     Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "s" is null
         at com.example.myprogram(MyProgram.java:6)
    
     /*
     As you can see, the new error message includes a more detailed 
     explanation of the problem, including the method that caused the 
     exception and the reason why it occurred.
    
     This feature can be especially useful when working with large codebases 
     or complex systems, where it can be difficult to track down the source of 
     a NullPointerException. By providing more information about the cause of 
     the exception, developers can save time and reduce frustration when 
     debugging their code.
     */
    
  4. Text Blocks: Text Blocks were introduced as a preview feature in Java 13 and are further enhanced in Java 14 with additional escape sequences and support for nested blocks. Here's an example of how to use text blocks:

     String message = """
                     Hello, world!
    
                     This is a text block with multiple lines.
                     It also supports "double quotes" and special characters:
    
                     \t- Tab
                     \n- Newline
                     \r- Carriage return
                     \f- Form feed
                     """;
     /*
     In this example, we use the """ notation to create a text block 
     containing a message. The text block can span multiple lines and supports 
     special escape sequences, such as tabs and newlines.
     */
    

Java 15: Sealed Classes, Hidden Classes and More

  1. Sealed classes(Preview): Sealed classes allow developers to restrict the set of subclasses that can extend a given class or interface. Here's an example:

     public abstract sealed class Shape permits Circle, Rectangle {
         // ...
     }
    
     public final class Circle extends Shape {
         // ...
     }
    
     public final class Rectangle extends Shape {
         // ...
     }
    
     /*
     In this example, the abstract class "Shape" is declared as "sealed", 
     which means that only classes listed in the "permits" clause can extend 
     it. This can help to ensure that the class hierarchy is well-defined and 
     prevent unexpected subclasses from being added later.
     */
    
  2. Hidden classes: Hidden classes provide a way to define classes that are not visible to the rest of the program, which can help to improve performance and security. Here's an example:

     import java.lang.invoke.MethodHandles;
     import java.lang.invoke.MethodHandles.Lookup;
    
     public class MyClass {
    
         public void createHiddenClass() {
             Lookup lookup = MethodHandles.privateLookupIn(MyHiddenClass.class, MethodHandles.lookup());
             Class<?> hiddenClass = lookup.defineHiddenClass(new byte[] {}, true);
             System.out.println(hiddenClass.getName());
         }
    
         private static class MyHiddenClass {
             // class definition
         }
     }
     /*
     In this example, the createHiddenClass() method creates a hidden class 
     called MyHiddenClass using the defineHiddenClass() method. The 
     privateLookupIn() method is used to create a private Lookup object that 
     can access the private members of the MyHiddenClass class. The 
     defineHiddenClass() method takes a byte array as input, which is used to 
     define the class, and a boolean value that indicates whether the class 
     should be linked.
    
     When the createHiddenClass() method is called, it prints the name of the 
     newly created hidden class to the console. Because the MyHiddenClass  is 
     hidden, it cannot be accessed from outside the MyClass class, which 
     provides better security and encapsulation.
     */
    

Java 16: Foreign-Memory Access, Vector API and More

  1. Vector API: The Vector API is a new feature in Java 16 that provides a set of classes for performing vector operations on arrays of numeric data types. It allows you to perform operations on multiple elements of an array in a single instruction, which can significantly improve performance for certain types of computations.

    The following example demonstrates how to use the Vector API to perform a simple calculation:

     import java.util.Arrays;
     import jdk.incubator.vector.FloatVector;
     import jdk.incubator.vector.VectorSpecies;
    
     public class VectorExample {
         public static void main(String[] args) {
             // Create an array of float values
             float[] data = new float[8];
             Arrays.fill(data, 1.0f);
    
             // Create a vector species that supports 4 float values
             VectorSpecies<Float> species = FloatVector.SPECIES_4;
    
             // Create two vectors with the same data
             FloatVector a = FloatVector.fromArray(species, data, 0);
             FloatVector b = FloatVector.fromArray(species, data, species.length());
    
             // Add the vectors together
             FloatVector c = a.add(b);
    
             // Convert the result back to an array
             float[] result = new float[8];
             c.intoArray(result, 0);
    
             // Print the result
             System.out.println(Arrays.toString(result));
         }
     }
     /*
     In this example, we first create an array of float values and fill it 
     with the value 1.0f. Then, we create a vector species that supports 4 
     float values. We use this species to create two vectors, a and b, with 
     the same data. We add these vectors together to create a new vector, c. 
     Finally, we convert the result back to an array and print it to the 
     console.
    
     The output of this program should be [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 
     2.0], which is the result of adding a and b together element-wise.
    
     In summary, the Vector API provides a set of classes for performing 
     vector operations on arrays of numeric data types. It allows you to 
     perform operations on multiple elements of an array in a single 
     instruction, which can significantly improve performance for certain 
     types of computations.
     */
    
  2. Foreign Linker API: The Foreign Linker API is a new feature in Java 16 that provides a standard way for Java programs to call native code and interact with other programming languages. It allows you to access native libraries and APIs without the need for JNI (Java Native Interface) or other low-level programming techniques.

    The following example demonstrates how to use the Foreign Linker API to call a C function from a Java program:

    First, create a C file called hello.c with the following code:

     #include <stdio.h>
    
     void sayHello(char *name) {
         printf("Hello, %s!\n", name);
     }
    

    Compile the hello.c file into a shared library called libhello.so using the following command:

     gcc -shared -fPIC -o libhello.so hello.c
    

    In Java, load the libhello.so library using the System.loadLibrary() method. Then, create a CLinker object and use it to define a Java interface that maps to the sayHello() function in the libhello.so library:

     import jdk.incubator.foreign.*;
    
     public class HelloWorld {
         public static void main(String[] args) throws Throwable {
             // Load the libhello.so library
             System.loadLibrary("hello");
    
             // Create a CLinker object
             CLinker linker = CLinker.getInstance();
    
             // Define a Java interface that maps to the sayHello() function in libhello.so
             FunctionDescriptor descriptor = FunctionDescriptor.ofVoid(CLinker.C_POINTER);
             MethodHandle sayHello = linker.downcallHandle(
                 linker.lookup("sayHello").get(),
                 descriptor,
                 FunctionDescriptor.ofVoid(CLinker.C_CHAR.arrayType())
             );
    
             // Call the sayHello() function using the Java interface
             sayHello.invokeExact("Java");
         }
     }
    
     /*
     In this example, we first load the libhello.so library using 
     System.loadLibrary(). Then, we create a CLinker object and use it to 
     define a Java interface that maps to the sayHello() function in 
     libhello.so. Finally, we call the sayHello() function using the Java 
     interface.
    
     When we run the HelloWorld program, it should print Hello, Java! to the 
     console, which is the output of the sayHello() function in the 
     libhello.so library.
    
     In summary, the Foreign Linker API provides a standard way for Java 
     programs to interact with native code and other programming languages. It 
     allows you to call native functions, handle memory management, and access 
     native data types with a simple and safe API.
     */
    
  3. Pattern Matching enhancements: Java 16 also includes enhancements to pattern matching that make it easier to match complex patterns, such as nested patterns, and to perform actions based on the match result. Example:

     if (obj instanceof Point p && p.x() == 0) {
         System.out.println("Point is on the y-axis");
     }
     /*
     In this example, obj is checked to see if it is an instance of Point, and 
     if it is, the object is cast to a Point and assigned to the variable p. 
     The condition also checks if the x coordinate of the Point is zero, and 
     if it is, the message "Point is on the y-axis" is printed.
     */
    

Java 17: Sealed Classes Enhancements, Pattern Matching Enhancements and More

  1. Sealed classes enhancements: Java 17 introduced some enhancements to sealed classes, including the ability to use sealed interfaces.

  2. New methods for generating streams of pseudo-random numbers: Java 17 introduces several new methods to the Random class that allows you to generate streams of pseudo-random numbers. For example, you can use the ints() method to generate an infinite stream of random integers or the doubles() method to generate an infinite stream of random doubles.

     Random random = new Random();
     IntStream ints = random.ints(10, 0, 100);
     DoubleStream doubles = random.doubles(10);
    
     /*
     In this example, we create a Random object and use its ints() and 
     doubles() methods to generate streams of random numbers.
     */
    
  3. New algorithms for generating random bytes and integers: Java 17 introduces new algorithms for generating random bytes and integers that can improve the performance and randomness of the generated numbers. You can use the RandomGenerator interface and the L32RNG, XoShiRo256PlusPlusRNG, and XoShiRo512PlusPlusRNG classes to access these algorithms.

     Random random = new Random(new L32RNG());
     int randomInt = random.nextInt();
     byte[] randomBytes = new byte[10];
     random.nextBytes(randomBytes);
    
     /*
     In this example, we create a Random object with a L32RNG algorithm and 
     use its nextInt() and nextBytes() methods to generate a random integer 
     and an array of random bytes.
     */
    
  4. Improved seeding algorithms: Java 17 introduces improved seeding algorithms that can help improve the randomness of the generated numbers. The Random class now uses a combination of system-provided and application-provided entropy sources to seed the generator.

     SecureRandom secureRandom = new SecureRandom();
     byte[] seed = secureRandom.generateSeed(10);
     Random random = new Random(seed);
     int randomInt = random.nextInt();
     /*
     In this example, we use a SecureRandom object to generate a random seed, 
     and then use that seed to create a Random object. We can then use the 
     Random object's nextInt() method to generate a random integer.
     */
    
  5. Switch expressions improvements (Preview): Java 17 introduces several improvements to switch expressions, including the ability to use a pattern as a branch condition and to use a yield statement to return a value from a branch. Here's an example:

     public static int calculateDiscount(Object obj) {
         return switch(obj) {
             case String s && s.startsWith("VIP") -> 20;
             case Integer i && i > 100 -> {
                 int discount = i * 10 / 100;
                 yield discount > 50 ? 50 : discount;
             }
             default -> 0;
         };
     }
    
     /*
     In this example, we use a switch expression to calculate a discount based 
     on the type and value of the input object. We use pattern matching syntax 
     to test whether the input is a string that starts with "VIP", and we use 
     a yield statement to return the calculated discount.
     */
    

Conclusion: The Future of Java and Its Importance in Modern Software Development.

Java has come a long way since its inception in the mid-1990s, and it has evolved into one of the most widely used programming languages in the world. With each new release, Java has introduced a host of new features and enhancements that have helped developers write better, more efficient and more secure code.

As we have seen in this blog, Java has undergone many significant changes over the years, from the introduction of lambda expression in Java 8 to the recent enhancements in pattern matching and sealed classes in Java 17. These features have not only made Java more powerful and versatile but have also helped to address the challenges faced by modern software development, such as concurrency, security, and modularity.

Looking to the future, Java is expected to continue evolving and adapting to the changing needs of the software development industry. The Java community is actively working on new features and enhancements that will make it easier for developers to write high-quality, scalable, and maintainable code.

In conclusion, Java has come a long way since its inception, and its journey has been filled with exciting and innovative features. As we move forward, we can expect Java to continue to be a dominant force in software development, providing developers with the tools they need to build the next generation of applications. Whether you are a seasoned Java developer or a beginner, it is essential to stay up-to-date with the latest features and trends in the language to remain competitive in today's rapidly evolving software development landscape.

0
Subscribe to my newsletter

Read articles from Vishal Parekh directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Vishal Parekh
Vishal Parekh

Software Developer