10 Year anniversary of Java 1.8

Devayan Kumar Sarkar
11 min readMar 19, 2024

18th March 2024, marks the 10 year anniversary of Java 1.8, popularly known as Java 8. A release so significant that it changed the trajectory of Java from being, “a remnant of the past” to the language that gets downloaded 1 billion times every year.

Not bad for a language that has been dying for 25 years.

10 years anniversary of Java 1.8

Introduced in 1995, the initial versions of Java had its own fair share of issues. “Write once and run everyone” was great but frustratingly slow. Thanks to Just-In-Time compilation introduced a year later, an inevitable doom was avoided. Gradually but steadily, Java improved itself and became a mainstream language for industries to build applications at scale.

Eventually, almost 19 years later, Java 1.8 was introduced. This version rejuvenated the interest and excitement around the language. Let’s look at some of the major features that were added to the language that made it exciting :

Stream

Functional Interface and Lambda expressions

Optional

default methods in Interface

CompletableFuture

java.time package

Let’s start with the most popular addition to Java, Streams

Java Stream is like a pipeline which contains the flow of elements and operations that need to be performed on the elements. It is not a structure. Stream does not hold the data.

Different kinds of filtering, mapping, flattening and even reducing operations can be added on the Stream and at the end of the process, a desired output is received.

filtering only even numbers from a list of numbers

One significant aspect of Stream is that it is lazy. The operations are not performed unless the result of the Stream is “collected”.

Let’s take an example :

Squaring even numbers using Stream.filter and Stream.map

At first glance it may seem that the print statement on line 8 will be logged before the statements on line 12. However that is not the case. The stream is only evaluated when the output of the operation is needed.

Here is the output of the code :

First logger
Second logger
Operation is on 1, Filtering for divisible by 2
Operation is on 2, Filtering for divisible by 2
Operation is on 2, Squaring even numbers
Operation is on 3, Filtering for divisible by 2
Operation is on 4, Filtering for divisible by 2
Operation is on 4, Squaring even numbers
Operation is on 5, Filtering for divisible by 2
Operation is on 6, Filtering for divisible by 2
Operation is on 6, Squaring even numbers
Operation is on 7, Filtering for divisible by 2
Operation is on 8, Filtering for divisible by 2
Operation is on 8, Squaring even numbers

Notice that the map operation even though configured later on line 10, does not receive computed stream of even numbers.

The return type of operations like filter or mapis another Stream. This helps in chaining multiple operations and collecting the result as a last step.

Along with Stream, there exists ParallelStream. Any operations that can be parallelised, will be done so without needing any additional configuration. In Stream, the flow is sequential and uses one thread. ParallelStream on the other hand splits the data into multiple smaller chunks and utilises multiple threads to perform the operations.

Let’s check the same example using ParallelStream :

filtering only even numbers from a list of numbers using ParallelStream

Multiple operations can also be chained in ParallelStream. We can implement the same squaredNumbers code in a parallelStream.

filtering and squaring even numbers using ParallelStream

Here is the output of the operation:

First logger
Second logger
Operation is on 6, Filtering for divisible by 2
Operation is on 7, Filtering for divisible by 2
Operation is on 1, Filtering for divisible by 2
Operation is on 4, Filtering for divisible by 2
Operation is on 4, Squaring even numbers
Operation is on 2, Filtering for divisible by 2
Operation is on 2, Squaring even numbers
Operation is on 3, Filtering for divisible by 2
Operation is on 5, Filtering for divisible by 2
Operation is on 8, Filtering for divisible by 2
Operation is on 8, Squaring even numbers
Operation is on 6, Squaring even numbers

“First logger” and “Second logger” are still logged first. But results show that the operations were not performed on the numbers sequentially.

In the above examples, filter or map do not look like simple methods. This bring us to Functional Interface and lambda expressions.

A block like this is not like the usual method invocations :

 .filter(num -> {
System.out.printf("Operation is on %d, Filtering for divisible by 2 %n", num);
return num % 2 == 0;
})

filter takes an argument of Interface called Predicate. This interface is tagged as @FuntionalInterface

Predicate tagged as Functional interface

In simple terms, a Functional interface has exactly one abstract method. When any interface has exactly one abstract method, it automatically becomes a FunctionalInterface. @FunctionalInterfaceis the annotation to mark the interface as Functional interface and avoid adding new abstract methods accidentally.

Java 1.8 introduced four main types of Functional interfaces.

Consumer

Consumer functional interface accepts one argument but does not return any result. These interfaces are usually used to perform operations where output is not desired. Functional variations of Consumer functional interface also exist in the form of DoubleConsumer, IntConsumer and LongConsumer which accepts primitive data types as arguments.

Bi-Consumer is another variation of Consumer. Bi-Consumer as the name suggests accepts two arguments as input and like Consumer , does not return any output.

forEach takes in a Consumer

forEach is a new method introduced in Iterable in Java 1.8. This method takes in a Consumer . This consumer takes in exactly one argument and does not return any result.

Predicate

Predicate functional interface accepts one argument but unlike Consumer functional interface returns a boolean (True/False) result.

Stream.filteris a good example of Predicate functional interface.

And similar to the Consumer functional interface, Predicate also has some variations. These variations are IntPredicate, DoublePredicate and LongPredicate. And just like Consumer, these predicates also accept primitive data types as arguments.

Bi-Predicate is another variation of Predicate. Bi-Predicate accepts two arguments as input and returns a boolean value as output.

filter takes in Predicate

filter operation accepts a Predicate. This predicate takes in exactly one parameter and returns boolean result based on the operation.

Function

Function is a type of Functional interface that can accept an argument of a certain type and return output of a different type based on desired operations. The input or output of the functional interface is not restrictive, however primitive data type can’t imply a generic type argument and this has resulted in many variations of the Function functional interface.

Like Predicate and Consumer, Bi-Function also exists which accepts two arguments and returns an output.

There are two more variations of the Function functional interface :

  • Unary operator
  • Binary operator

Unary operator extends the function functiơnal interface but the input and output types are designed to be the same instead of defining separate input and output types.

Binary operator as the name suggests extends Bi-Function and both inputs and output are designed to be the same.

Function functional interface accepts integer and returns double

Supplier

Supplier functional interface does not take in any arguments but does provide an output. This functional interface is used to generate values and data lazily.

Like the other functional interfaces, specific variations exist for the primitive types like DoubleSupplier, LongSupplier and IntSupplier.

Supplier functional interface accepts no argument but returns String

This brings us to the lambda expressions.

Since the Functional interfaces have one method, creating a Class is an overkill for straightforward operations. For example, filtering even numbers in a list. Even anonymous classes can be bulky for certain use cases.

This is where the Lambda expressions come in handy. They are simple expressions that provide the method implementation. All we need to do is handle the method arguments and write the business logic.

This is example without lambda expression :

Predicate with anonymous inner class
Same filter with lambda expression

Together with Stream, Functional interfaces and Lambda expressions, Java 1.8 brought in a concise and expressive code and functional programming paradigm.

Null references, a billion dollar mistake.

It is important to note that Stream cannot guarantee availability of results. To elaborate on this, given a list of Names, “Adam, John and Sam”, if we were to write up a stream to filter out names that start with “D” and get the first name, we would not have a result. However, had the list contained a name like “Daniel”, we would have received the name as an output.
Java has been plagued with NullPointerExceptions and returning null at the end of an operation does open up a possibility of NPEs.

Sir Tony Hoare, the inventor of null references, considers it his “Billion Dollar Mistake”.

Null definitely could not be returned, at least not in its raw form. The client must get an indication that the result may not be available.

This is where Optionalwas introduced. Optional as a return type is a container that may or may not have a result . Optional forces the clients to check for availability of data before accessing the data. This check is in compile time and has proven to be effective against preventing NullPointerException.

Along with providing capability to safely access the data, Optional also provides methods to either get alternative values in case the primary value is null or throw Exception to be handled when non-availability of data is fatal for the execution.

Operation on String value returned

If the method myStringOperation returns null , line number 3 would throw NullPointerException . The only safeguard against this was to consciously check for nulls before accessing the object.

However, Optional changes that. Optional indicates the client to check the availability of the data before accessing it.

Warning for accessing the data without checking availability

We can go one step further and even get a default value in case the returned value is null .

Returning default value in case the returned value is null

default methods in Interface

Streams and its method are invoked on the objects of type Collection, e.g: List. But the Collection package was introduced long before Stream, so a question may arise, “Did Java introduce breaking contracts by introducing new abstract methods on Collection ?”

Java introduced default methods on Interface. Default methods are methods added to interfaces along with their implementations. This allowed introduction to Stream without breaking the contract with its implementation.

stream on Collection Interface

Default methods allow us to add new methods along with their behaviour on interfaces. These default methods are automatically available in all the implementations of the interface. No modification is required in the class implementing the interface.

Since then many new features are added on to existing packages without breaking contract and maintaining backward compatibility.

Concurrency in Java 1.8

Java 1.8 also gave concurrency a nice improvement with CompletableFuture. The CompletableFuture class implements the Future interface already introduced in Java 1.5. CompletableFuture allows us to write non-blocking asynchronous code without handling the low level complexities of handling threads and runnables.

Configuring CompletableFuture for a long running operation

Note that supplyAsync takes in a Supplier functional interface. The functional interface accepts no argument but returns response.

Thread.sleep(4000) is added to wait for the logs to appear.

The result of the above code is :

Log after configuring CompletableFuture
Log after `thenAccept`
Hello, world!

Using CompletableFuturewe can run multiple Futurein parallel. This allows us to initiate multiple asynchronous tasks and combine them with allOf. A neat way to accumulate all asynchronous operations and act or combine the results at one location.

Combining multiple asynchronous operations

java.time package

Working with java.util.Datehad its own shortcomings. So, in Java 1.8 java.timepackage was introduced to provide a better alternative to java.util.Date and address some of the issues with the package.

The main issues with java.util.Date that were being addressed were:

Thread safety

Date and Calendarwere primarily two classes extensively being used for all date and calendar related operations. However, both of these classes are not threadsafe. Developers had the onus on making sure that the implementations were threadsafe and not prone to concurrency issues.
java.time package on the other hand are built to be threadsafe and immutable.

Timezone

java.util.Date had only date and time information, not timezone. This would result in timezone being handled separately. However, with LocalDateTime or LocalDate has timezone information built into the implementation. This allows us to adjust date and time and perform operations based on the timezone of a different location.

This proved to be immensely helpful for systems that handle global traffic and serve clients across multiple time zones.

Ease of use

In java.util.Date, month argument was of type int. To make this a little confusing the months were mapped starting from 0. So, basically January was zero. This would result in accidental issues where the Months were miscalculated based on the numbering. There are of course many more examples.

java.util.Date also lacked many utility methods to perform operations on the Date, this would result in teams creating the same utility methods across applications.

java.time package handled this internally and swapped primitive data types in favour of Enums as arguments to initiate LocalDateTime or LocalDate objects. java.time package also added helpful utility methods to make handling and operating on dates and time simpler.

Java 1.8 is 10 years old now. The current Long term supported Java version is 21. Since Java 9, the release cycle of the language has been made more frequent. More frequent releases have helped the language catch up to some of the present industry norms by releasing features faster. Post Java 9 the upgrades have been a breeze. With each new release Java is evolving into a language that will be radically distinct and different from the version that was released in 1995.

For example, java is recognisable from its “Hello world code”. This code is often quoted to showcase the verbosity of the language and code required to even print a simple “Hello world”.

Simple hello world in Java

Java 21, allows us to write the main method without “public static void” keywords.

Hello world, without public static void main

We can take it even further. Unnamed classes are introduced in Java 21. Unnamed classes allow us to write programs without even declaring a classes.

Unnamed class in Java 21

10 years and countless iterations later, Java will release Java 22 on 19th March 2024. This release will surely bring many exciting features for the time to come.

--

--

Devayan Kumar Sarkar

Creator, developer and tinkerer. Loves to read books and play badminton. Keeps things simple.