30 Years of Java – How the language has evolved

Sebastian Hempel

We are celebrating 30 years of Java. Time to look back at how Java as a programming language evolved since then. We will see how Java integrated different programming paradigms without losing its clear structure. It is fascinating that with all these changes, you are still able to program in Java the same way as 30 years ago.

When Java arrived in the mid 90s, object-oriented programming was the dominant programming paradigm. This is reflected in Java, which was a „pure“ object-oriented language from the beginning. „Everything is an object“ can be taken literally. Except for primitives, everything is derived from the base class object. In the early days, the language strictly sticks to this paradigm. This changed over time as the language incorporated more and more elements from the functional programming paradigm. This is a general development that can also be seen in other programming languages.

This article will not describe each and every change that was made to Java since 1995. I will pick out the changes that are the most important for me and my daily work. This might leave out important development of the language in certain areas. I will also look at improvements in the standard library of Java. The library is an essential part and, for me, cannot be separated from the language itself.

Collections

The language itself supports the concept of an array. An array stores elements that can be accessed by an index. The size of an array is fixed and is given when creating the array. Arrays can be used to store primitives and objects.

To enable the work with „dynamic“ arrays that grow automatically when new elements are added, JDK 1.0 introduced the Vector class. The class is still available but should not be used in new projects. It was integrated into the collection framework introduced in Java 1.2 in 1998.

You create an instance of the Vector class and add elements with the addElement method. To add an element at a given index, the method insertElementAt can be used. The method ensures that the element is inserted at the given index and all existing elements are moved by one position.

To go through all elements of a Vector we get an Enumeration with the elements method. The enumeration can be used to walk through all elements using a while loop. With the method hastMoreElements we check if there are still elements not visited. With the method nextElement of the Enumeration we move to the next element.

package de.ithempel.java30;

import java.util.Enumeration;
import java.util.Vector;

public class VectorExample {

    public static void main(String[] args) {
        Vector v = new Vector();

        v.addElement("Duke");
        v.addElement("JAVAPRO");
        v.addElement("Java");

        v.insertElementAt("Sebastian", 1);

        v.remove(2);

        Enumeration elements = v.elements();
        while (elements.hasMoreElements()) {
            System.out.println(elements.nextElement());
        }
    }

}

Beside the Vector class, JDK 1.0 also introduced the class Dictionary to assign a key to the value. This allows the access of the element via a key. The class allows retrieving an Enumeration of all keys or all values of the dictionary. Reusing an existing key replaces the old with the new value. It is also possible to remove the value for the key.

package de.ithempel.java30;

import java.util.Dictionary;
import java.util.Enumeration;
import java.util.Hashtable;

public class DictionaryExample {

    public static void main(String[] args) {
        Dictionary dict = new Hashtable();

        dict.put("A", "Duke");
        dict.put("B", "JAVAPRO");
        dict.put("C", "Java");

        System.out.println("Value of B: " + dict.get("B"));

        String oldValue = (String) dict.put("C", "Sebastian");
        System.out.println("Old Value of C: " + oldValue);

        dict.remove("A");

        Enumeration elements = dict.keys();
        while (elements.hasMoreElements()) {
            String key = (String) elements.nextElement();
            System.out.println(dict.get(key));
        }
    }

}

JDK 1.2 in 1998 introduced a completely new framework to work with collections. The framework supports different types of collections, like List, Set or Map to name only some.

The use of these new collections does not differ from the use of Vector or Dictionary. The method names are shorter. Instead of the Enumeration we now use an Iterator to walk through all elements of a collection. Internally, the implementation of the collections has a better performance. The Vector class synchronised every operation. This was dropped with the move to the collection framework.

package de.ithempel.java30;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class ListExample {

    public static void main(String[] args) {
        List list = new ArrayList();

        list.add("Duke");
        list.add("JAVAPRO");
        list.add("Java");

        list.add(1, "Sebastian");

        list.remove(2);

        Iterator iter = list.iterator();
        while (iter.hasNext()) {
            System.out.println(iter.next());
        }
    }

}

Generics

We still have to cast elements we retrieve from a collection from the generic type Object to the concrete type we store in the collection. The type cast operation might lead to a ClassCastException at runtime. There is no possibility for the compiler to check for the correct type while creating the object code.

Java 5 in 2005 introduced the concept of generics. It allows extending the type system of Java. The collection framework supports generics to specify the type of objects that are stored inside a collection. With generic, the compiler is able to check the type of the element at compile time.

We get a type safe iterator that returns the type of the element stored in a collection.

package de.ithempel.java30;

import java.util.ArrayList;
import java.util.List;

public class ListGenericExample {

    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();

        list.add("Duke");
        list.add("JAVAPRO");
        list.add("Java");

        list.add(1, "Sebastian");

        list.remove(2);

        for (String elem : list) {
            System.err.println(elem); 
        }
    }

}

But Java 5 not only brought us generics. It also introduced a new type of the for loop. The for each loop can iterate over each member of an array or an Iterable that every standard collection class implements. We don’t have to check the hasNext method or use the next method to get the next element. Together with the Iterable interface now collections feel like a first class element of the language.

package de.ithempel.java30;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class ListForEachExample {

    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();

        list.add("Duke");
        list.add("JAVAPRO");
        list.add("Java");

        list.add(1, "Sebastian");

        list.remove(2);

        Iterator<String> iter = list.iterator();
        while (iter.hasNext()) {
            System.out.println(iter.next());
        }
    }

}

Java 7 in 2011 improved the type inference for the creation of generic instances. It is a small enhancement that eliminates some kind of duplication in the code. The type of the generic only has to be defined in the constructor. In the definition of the variable, the generic type could be left out.

List<String> list = new ArrayList<>();

Working with dates

Java has support for working with dates and times from the beginning. The class Date was introduced in Java 1.0. It is a kind of wrapper around the unix time value also taking into account time zones. It is also the class with the most long-lasting deprecated methods for decades. All constructors, getters and setters are deprecated in favour of the new class Calendar, that was introduced in Java 1.1 in 1997.

package de.ithempel.java30;

import java.util.Calendar;
import java.util.Date;

public class DateTimeExample {

    public static void main(String[] args) {
       Date now = new Date(); 

       Calendar cal = Calendar.getInstance();
       cal.setTime(now);

       int dayOfMonth = cal.get(Calendar.DAY_OF_MONTH);
       int month = cal.get(Calendar.MONTH) + 1;

       System.out.println("We have the " + dayOfMonth + " of the " + month + " month of the year");
    }

}

The Calendar class can work with both timezones and the calendar system. After setting the time, we can use the get method to get parts of the timestamp like the month or the day of the month.

We had to wait until Java 8 in 2014 to get a complete new date and time API. Java integrates the previously available Joda-Time library into the JDK. The api introduces new classes for handling date and time in the current (local) timezone LocalTime, LocalDate and LocalDateTime. When working with timezones, we now have the classes ZonedDateTime. The class Instant can be used to work with exact timestamp in nanoseconds.

The classes support methods to do date calculation and calculation differences between date / times. There are also methods to get or set certain fields for a date or time. The interfaces of the new classes support chaining multiple operations (fluent interface).

package de.ithempel.java30;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;

public class DateTimeApiExample {

    public static void main(String[] args) {
        LocalDateTime now = LocalDateTime.now();

        LocalDateTime tomorrow = now.plusDays(1);
        LocalDateTime yesterday = now.minusDays(1)
                .withHour(0).withMinute(0).withSecond(0);
        
        DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM);
        System.out.println("Today: " + dateTimeFormatter.format(now) +
                ", yesterday: " + dateTimeFormatter.format(yesterday) +
                ", tomorrow: " + dateTimeFormatter.format(tomorrow));
    }

}

The evolution of switch

The switch statement was there from the beginning. The first version could only be used with integers. It helped to better implement multiple if – else if – else constructs. The feature of the fall through was lent by other languages as C.

package de.ithempel.java30;

public class SwitchExample {

    public static void main(String[] args) {
        int value = 5;
        switch (value) {
            case 1:
                System.out.println("One");
                break;
            case 5:
                System.out.println("five");
                break;
            default:
                System.out.println("Unknown");
        }
    }

}

The first change was done in Java 7 in 2011. Beside integers, we can now use the switch statement to also select between different strings and enum values. This made the switch statement even more useful.

Many improvements of the switch statement and the now possible switch expression were introduced in Java 14 in 2020. Multiple case values can be provided in a single case. The biggest change was the possibility to use switch as an expression. You can return values from a switch block in multiple ways. There is a new statement yield to return a value, or you can use the arrow operator to define some kind of „mapping“.

package de.ithempel.java30;

public class SwitchExpressionExample {

    public static void main(String[] args) {
        String day = "Tuesday";

        String typeOfDay = switch (day) {
            case "Monday":
                yield "Weekday";
            case "Tuesday":
                yield "Weekday";
            case "Wednesday":
                yield "Weekday";
            case "Thursday":
                yield "Weekday";
            case "Friday":
                yield "Weekday";
            case "Saturday", "Sunday":
                yield "Weekend";
            default:
                yield "Unknown";
        };

        System.out.println(typeOfDay);
    }

}

Pattern Matching

Pattern matching was introduced in multiple steps. The feature of pattern matching was first implemented in function programming languages and soon implemented in other languages like Scala or F#. For Java, the first implementation of one kind of pattern matching was introduces with Java 14 in 2020.

Instead of only checking for a specific type with the instanceof operator you can now in one step assign the casted value to a new variable.

package de.ithempel.java30;

public class InstanceofExample {

    public static void main(String[] args) {
        Object obj = "Duke";

        if (obj instanceof String) {
            String s = (String) obj;
            System.out.println(s.length());
        }

        if (obj instanceof String s) {
            System.out.println(s.length());
        }
    }

}

A much bigger step was to introduce pattern matching for switch with Java 21 in 2023. It allows cases to be selected based on the type of the argument. Beside the matching itself, the casted value is available as a new variable. After matching to a type, additional expressions can be used to refine the selection based on other conditionals.

package de.ithempel.java30;

public class PatternMatchingExample {

    public static void main(String[] args) {
        Object o = 42;

        String formatted = switch (o) {
            case null       -> "Null";
            case String s   -> "String %s".formatted(s);
            case Long l     -> "long %d".formatted(l);
            case Double d   -> "double %f".formatted(d);
            case Integer i when i > 0             // guarded pattern
                            -> "positive int %d".formatted(i);
            case Integer i when i == 0
                            -> "zero int %d".formatted(i);
            case Integer i when i < 0
                            -> "negative int %d".formatted(i);
            default         -> o.toString();
        };

        System.out.println(formatted);
    }

}

Not only with pattern matching but also with a normal switch statement we can add a case for null. This allows to work around a NullPointerException when giving a null value to a switch statement.

Records

In Java 16 from 2021 we get the possibility to define immutable data classes without having to write a lot of boilerplate code. With records, we only have to define the type and the name of the fields. All other code like equals, hashCode, toString, getters and public constructors are „created“ by the compiler for us.

Getters can be used like normal classes. The difference for getters is the missing get before each field name. We simply write the name of the field we want to access, like name() to get the name field of a record. Records can be enhanced by static variables and methods.

package de.ithempel.java30;

public class RecordsExample {

    public record Person(String firstName, String lastName, String address) {

        public String fullName() {
            return "%s, %s".formatted(lastName, firstName);
        }

    }

    public static void main(String[] args) {
        Person duke = new Person("Duke", "Java", "Javaland");

        System.out.println(duke.lastName() + ", " + duke.firstName());
        System.out.println(duke.fullName());
    }

}

As records are immutable – they have no setters – they are perfect to be used as data objects. In many current source code, the lombok library is used for that purpose. In newer projects, I myself try to replace lombok with records.

Stream Processing

One of the biggest changes in Java 8 from 2014 was the support of lambda expressions. Instead of defining anonymous inner classes, we now have a function as a new type. We can define functions / lambdas and pass them to other methods that call them.

Lambdas make use of functional interfaces. Interfaces of this type consist of one (abstract) function. There are many pre-defined functional interfaces in the package java.util.function. The annotations ensure, that we get a compilation error when we try to add another function to our interface.

package de.ithempel.java30;

public class LambaExample {

    @FunctionalInterface
    public interface Foo {
        public String method(String s);
    }

    public static void main(String[] args) {
        Foo foo = parameter -> parameter + " from lambda";
        String result = add("Duke", foo);

        System.out.println(result);
    }

    private static String add(String s, Foo foo) {
        return foo.method(s);
    }

}

The Java library itself makes use of lambdas by providing a streaming api to work with a sequence of data. Streams are inspired by the map reduce programming model to process big data in parallel and distributed. The operations provided by streams are also known in functional programming. In java, a stream provides different operations. The operations are chained to a stream pipeline.

The streaming API provides operations like filter to select elements on certain conditions. With the map operation we can transform an element. Final operations can simply iterate over all elements like the operation forEach. There are also Collectors to collect all elements of a stream to a collection.

A pipeline can work on a stream in parallel. With the operation parallel we can distribute the work of the pipeline to multiple threads.

package de.ithempel.java30;

import java.util.stream.Stream;

public class StreamingExample {

    public static void main(String[] args) {
        Stream.of("a", "b", "c", "d", "Duke")
            .filter(e -> e.length() == 1)
            .map(e -> e.toUpperCase())
            .forEach(e -> System.out.println(e));
    }

}

The streaming api knows intermediate and terminal operations. Terminal operations trigger the execution of a stream pipeline. Intermediate operations are only executed, when a terminal operation is executed. This optimises the execution of a stream pipeline.

This was my short and quick evolution of the Java language. As already mentioned, I did not cover all changes in the language since the last 30 years. One big area I did not mentione are the different mechanisms to manage multiple threads.

Looking at the current development I see a bright and interesting future for Java. There are a plenty of changes ahead. Since the change to a release train we constantly see previews of new features and changes in the language and the library.

Total
0
Shares
Previous Post

Java in Critical Operations: How Custom Development Ensures Control and Secures Mission-Critical Systems

Next Post
API Design in Java

Best Practices for API Design in Java

Related Posts