Home | Send Feedback | Share on Bluesky |

Small changes in Java 12 to 17

Published: 12. May 2020  •  Updated: 31. August 2025  •  java

In this blog post we take a look at the smaller changes in Java 12 to 17. A smaller change is, for example, a new method added to an existing class or smaller language changes. I don't cover in this article major changes like a new language feature or a major new package like the new HTTP client or the Class File API.

I also focus on useful features for my daily programming life as an application developer. I'm personally less interested in changes in low-level APIs like Reflection and low-level IO. I also omit in this blog post changes to the Java Virtual Machine (JVM), like new Garbage Collectors (GC).

Here are the other parts of this series:

Java 12 (March 2019)

A significant change in this release is the addition of JMH (Java Microbenchmark Harness JEP 230). JMH is a library that existed before, but starting with Java 12, it is now part of the standard library.

CompactNumberFormat

This is an extension to the existing number formatting API (java.text.NumberFormat). This addition provides the functionality to format numbers into more compact and, for humans, easier to grasp strings.

To obtain a compact number formatter call the method java.text.NumberFormat.getCompactNumberInstance(Locale, NumberFormatStyle) and pass the locale and the style. Alternatively call NumberFormat.getCompactNumberInstance() which returns a compact number format for the default locale and with the SHORT format style.

The following code converts the number 1_234 into "1K" and "1 thousand".

java.text.NumberFormat shortFormat = NumberFormat.getCompactNumberInstance(Locale.ENGLISH, NumberFormat.Style.SHORT);
java.text.NumberFormat longFormat = NumberFormat.getCompactNumberInstance(Locale.ENGLISH, NumberFormat.Style.LONG);
    
String shortOutput = shortFormat.format(1_234); // 1K
String longOutput = longFormat.format(1_234); // 1 thousand

By default, the rounding mode half even is used but can be changed with the setRoundingMode(RoundingMode) method.

NumberFormat longFormat = NumberFormat.getCompactNumberInstance(Locale.ENGLISH, NumberFormat.Style.LONG);
longFormat.setRoundingMode(RoundingMode.UP);
String longOutput = longFormat.format(1_234); // 2 thousand

The default formatting behavior returns a formatted string with no fractional digits, however calling the setMinimumFractionDigits(int) method includes the fractional part. Note that the result is rounded according to the configured rounding mode.

NumberFormat shortFormat = NumberFormat.getCompactNumberInstance(Locale.ENGLISH, NumberFormat.Style.SHORT);
NumberFormat longFormat = NumberFormat.getCompactNumberInstance(Locale.ENGLISH, NumberFormat.Style.LONG);

shortFormat.setMinimumFractionDigits(2);
longFormat.setMinimumFractionDigits(1);
    
String shortOutput = shortFormat.format(1_256); // 1.26K
String longOutput = longFormat.format(1_256); // 1.3 thousand

Parsing strings that contain numbers in a compact format is also supported.

NumberFormat longFormat = NumberFormat.getCompactNumberInstance(Locale.GERMAN, NumberFormat.Style.LONG);
        
String input1 = "1,11 Tausend";
String input2 = "1,22 Million";
String input3 = "1,33 Milliarde";
    
Number n1 = longFormat.parse(input1); // 1110
Number n2 = longFormat.parse(input2); // 1220000
Number n3 = longFormat.parse(input3); // 1330000000

teeing Stream Collector

A stream can only be terminated with one collector. This is an issue if multiple streams' results need to be acquired. The new Collectors.teeing(Collector, Collector, BiFunction) collector can apply two collectors on the items of a stream.

The teeing collector expects three arguments. Two Collectors and a BiFunction. The teeing collector applies the two collectors on the items. The result of each collector is then passed as arguments to the BiFunction, which produces the final result of the teeing collector.

The following example loops over a list of strings, transforms them to upper case, and returns the number of elements and the list with the modified city names.

List<String> cities = List.of("Paris", "London", "Tokyo", "Madrid", "Rome");
List<Object> result = cities.stream().map(String::toUpperCase)
                        .collect(java.util.stream.Collectors.teeing(
                                    Collectors.counting(), 
                                    Collectors.toUnmodifiableList(), 
                                    (count, list) -> List.of(count, list))
                                );
    
long count = (long) result.get(0); // 5
List<String> citiesUC = (List<String>) result.get(1); // [PARIS, LONDON, TOKYO, MADRID, ROME]

Multiple teeing collectors can be combined to get more than just two results. The following example returns the shortest name, longest name, the number of cities, and the list with the transformed cities themselves.

List<String> cities = List.of("Paris", "London", "Tokyo", "Madrid", "Rome");

var stringLengthComparator = Comparator.comparing(String::length);

var minMaxCollector = Collectors.teeing(Collectors.minBy(stringLengthComparator),
        Collectors.maxBy(stringLengthComparator),
        (min, max) -> List.of(min.get(), max.get()));

var countListCollector = Collectors.teeing(Collectors.counting(),
        Collectors.toUnmodifiableList(), (count, list) -> List.of(count, list));

var result = cities.stream().map(String::toUpperCase).collect(
        Collectors.teeing(minMaxCollector, countListCollector, (minMax, countList) -> List
            .of(minMax.get(0), minMax.get(1), countList.get(0), countList.get(1))));

String min = (String) result.get(0); // ROME
String max = (String) result.get(1); // LONDON
long count = (long) result.get(2); // 5
List<String> list = (List<String>) result.get(3); // [PARIS, LONDON, TOKYO, MADRID, ROME]

java.nio.file.Files.mismatch(Path, Path)

The new java.nio.file.Files.mismatch(Path, Path) method finds and returns the position of the first mismatched byte in the content of two files, or -1L if there is no difference.

// file1.txt:Hello World
// file2.txt:Hello world

java.nio.file.Path file1 = java.nio.file.Paths.get("file1.txt");
java.nio.file.Path file2 = java.nio.file.Paths.get("file2.txt");
long diffPos = java.nio.file.Files.mismatch(file1, file2); // 6

java.lang.String

The new String.indent(int) method adds spaces to the beginning of a string. If there are multiple lines in the string, terminated with either "\n", "\r" or "\r\n", the method adds spaces to each line.

String basic = "10 PRINT \"Hello, World!\"\n" + 
               "20 END";
String indented = basic.indent(3);
/*
   10 PRINT "Hello, World!"
   20 END
*/    

It's important to note that the indent(int) method also normalizes the line termination characters. It always ends a line with "\n" regardless of what line termination characters the input uses. Even passing 0 to the method (indent(0)), which does not indent the string, the method still changes the line termination characters.

It is possible to pass a negative value to the method. In that case, indent(int) removes the given number of white space characters from the beginning of each line. If there are not enough white space characters in one line, then all leading white space characters are removed.

String basic = " \t\t 10 PRINT \"Hello, World!\"\n" + 
                "\t20 END";
String indented = basic.indent(-3);
/*
 10 PRINT "Hello, World!"
20 END
*/

With the new String.transform(Function) method, a function can be applied to the string. The function should expect the string as an argument and return a result. The result can be another string or something different.

String hw = "Hello World";
int len2 = hw.transform(str -> str.length() * 2); // 22

String cities = "Tokyo;Madrid;London;Paris";
List<String> citiesList = cities.transform(str -> List.of(str.split(";")).stream()
                              .sorted().collect(Collectors.toUnmodifiableList()));
// [London, Madrid, Paris, Tokyo]

String text = "this is line 1\nthe second line";
String transformed = text.transform(str -> 
                      str.lines()
                      .map(line -> line.substring(0, 1).toUpperCase() + line.substring(1))
                      .collect(Collectors.joining("\n")));
//This is line 1\nThe second line

Java 13 (September 2019)

This release did not introduce major changes visible to the developer. A notable significant change under the surface is the re-implementation of java.net.Socket and java.net.ServerSocket (JEP 353)

I didn't find a small change that qualifies for this article.

Java 14 (March 2020)

With Java 14, we get a new language feature: Switch Expressions (JEP 361)

Exact Arithmetic

When we do arithmetic in Java, we need to be aware that datatypes like int and long silently overflow.

int i = 2_147_483_647; // max value of Integer
i = i + 10;  // i == -2_147_483_639

This can lead to subtle bug. Java 8 introduced methods like java.lang.StrictMath.addExact, which throw java.lang.ArithmeticException if an overflow occurs.

int i = StrictMath.addExact(2_147_483_647, 10);
// java.lang.ArithmeticException: integer overflow

In Java 14, we get six more of these *Exact methods. Three for Integers and three for Longs.

int i = 2_147_483_647;
i = StrictMath.decrementExact(i); // i--
i = StrictMath.incrementExact(i); // i++
i = StrictMath.negateExact(i); // i = -i

See Javadoc of StrictMath for more information


Plural Support in CompactNumberFormat

java.text.CompactNumberFormat introduced in Java 12 is now capable of dealing with plural forms. Here is an example in French that has plural forms for "number" words.

// Java 13
NumberFormat cnf = NumberFormat.getCompactNumberInstance(Locale.FRENCH, Style.LONG);
String output = cnf.format(1_000_000); // 1 million
output = cnf.format(2_000_000); // 2 million

// Java 14
NumberFormat cnf = NumberFormat.getCompactNumberInstance(Locale.FRENCH, Style.LONG);
String output = cnf.format(1_000_000); // 1 million
output = cnf.format(2_000_000); // 2 millions  <=======

Java 15 (September 2020)

Java 15 introduces Text Blocks (JEP 378).

Helpful NullPointerExceptions (JEP 358)

Not a new language feature or API change, nonetheless a useful change for developers. When a NullPointException occurs, the error message is more detailed and shows which part of the code caused the exception. Note this feature is for security, performance, and compatibility reasons disabled by default.

Let's assume we have this Java application.

   class Address {
     private final String city;
     private Address(String city) {
       this.city = city;
     }
   }
   class Person {
     private final Address address;
     private Person(Address address) {
       this.address = address;
     }     
   }
   Address a = new Address(null);
   Person p = new Person(a);
   String cityUpperCase = p.address.city.toUpperCase();    

When we run the application with a JDK prior to Java 15, the JVM prints out this error message.

Exception in thread "main" java.lang.NullPointerException
        at JavaTest.main(JavaTest.java:19)

Just from the error message alone, we don't know what caused the exception. In this application it could be because p or p.address or p.address.city is null.

The same code running on Java 15 gives us a more detailed message, and we immediately see what caused the error.

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because "<local2>.address.city" is null
        at JavaTest.main(JavaTest.java:19)

Java 16 (March 2021)

Java 16 adds Record (JEP 395) classes and pattern matching for instanceof (JEP 394) to the language.

Stream.toList convenient method

When you work with streams in Java, you often use a collector to collect all the elements from the stream into a container. The most commonly used collector is .collect(Collectors.toList())

		List<String> fruits = List.of("apple", "pear", "pineapple");
		List<String> upperCaseFruits = fruits.stream().map(String::toUpperCase)
				.collect(Collectors.toList());

Because this collector is so prevalent the JDK developers added a new convenient method to Stream interface: toList() With this addition, we can simplify the code above.

		List<String> upperCaseFruitsTl = fruits.stream().map(String::toUpperCase)
				.toList();

Be aware that .toList() is not an exact equivalent of .collect(Collectors.toList())). The latter returns a modifiable list whereas .toList() returns an unmodifiable list. It behaves similar to .collect(Collectors.toUnmodifiableList())

		List<String> fruits = List.of("apple", "pear", "pineapple");
		List<String> upperCaseFruits = fruits.stream().map(String::toUpperCase)
				.collect(Collectors.toList());
		upperCaseFruits.add("ORANGE"); // ok
		
		List<String> upperCaseFruitsTl = fruits.stream().map(String::toUpperCase)
				.toList();
		upperCaseFruitsTl.add("ORANGE"); // throws java.lang.UnsupportedOperationException

Stream.mapMulti

mapMulti is a new method of the Stream interface. This method is an intermediate operation and replaces each stream element with zero or more elements.

The method is similar to flatMap in that it applies a one-to-many transformation and flattens the result. According to the Javadoc, mapMulti is preferable to flatMap when an application replaces each element with a small (possibly zero) number of elements. mapMulti avoids the overhead of creating a new Stream instance as flatMap requires. mapMulti also uses an imperative approach which might be easier to implement for certain use cases than returning the result as a Stream.

		List<String> input = List.of("AA", "AB", "AC", "AD");

		List<Character> output = input.stream()
				.flatMap(elem -> Stream.of(elem.charAt(0), elem.charAt(1))).toList();
		System.out.println(output);
		// [A, A, A, B, A, C, A, D]

		List<Character> outputMapMulti = input.stream()
				.<Character>mapMulti((elem, consumer) -> {
					consumer.accept(elem.charAt(0));
					consumer.accept(elem.charAt(1));
				}).toList();
		System.out.println(outputMapMulti);
		// [A, A, A, B, A, C, A, D]

DateTimeFormatter pattern B

In the date-time formatter, we can use the new pattern B to get the period of the day in human-readable form.

		DateTimeFormatter dtf = DateTimeFormatter.ofPattern("B", Locale.ENGLISH);
		System.out.println(dtf.format(LocalTime.of(8, 0))); // in the morning
		System.out.println(dtf.format(LocalTime.of(13, 0))); // in the afternoon
		System.out.println(dtf.format(LocalTime.of(20, 0))); // in the evening
		System.out.println(dtf.format(LocalTime.of(23, 0))); // at night
		System.out.println(dtf.format(LocalTime.of(0, 0))); // midnight
		
		dtf = DateTimeFormatter.ofPattern("B", Locale.FRENCH);
		System.out.println(dtf.format(LocalTime.of(8, 0))); // du matin
		System.out.println(dtf.format(LocalTime.of(13, 0))); // de l’après-midi
		System.out.println(dtf.format(LocalTime.of(20, 0))); // du soir
		System.out.println(dtf.format(LocalTime.of(23, 0))); // du soir
		System.out.println(dtf.format(LocalTime.of(0, 0))); // minuit

The implementation of the B pattern follows the Unicode standard.

Java 17 (September 2021)

This release adds one new language feature to Java: Sealed Classes (JEP 409)

HexFormat

The new java.util.HexFormat class handles conversions from byte array to hexadecimal strings and vice versa. To use the new class, you need to create an instance with either of() or ofDelimiter(String). The returned instance is thread-safe

		HexFormat hexFormat = HexFormat.of();
		byte[] input = new byte[] { 0, 1, 2, 3, 11, 12 };
		String inputHex = hexFormat.formatHex(input); // 000102030b0c    

    byte[] output = hexFormat.parseHex(inputHex);
		// [0, 1, 2, 3, 11, 12]

HexFormat also provides a method to convert an byte, short, int, long and char into a hexadecimal string

String hex =  HexFormat.of().toHexDigits(44)); // 0000002c

By default, HexFormat returns the hexadecimal string with lowercase characters. You can change this with withUpperCase(). Additionally, Hexformat allows adding prefix, suffix, and delimiter to control the output of the hexadecimal string even further.

		byte[] input = new byte[] { 0, 1, 2, 3, 11, 12 };
		String hex1 = HexFormat.of().withUpperCase().formatHex(input)); 
    // 000102030B0C

		String hex2 = HexFormat.ofDelimiter(":").formatHex(input)); 
    // 00:01:02:03:0b:0c

		String hex3 = HexFormat.of().withPrefix("<").withSuffix(">").withDelimiter("-").formatHex(input);
		// <00>-<01>-<02>-<03>-<0b>-<0c>

Enhanced Pseudo-Random Number Generators (JEP 356)

JEP 356 provides new interfaces and implementations for pseudo-random number generators (PRNGs).

java.util.random.RandomGenerator is a new interface for objects that return random values.

The existing classes java.util.Random, [java.util.SplittableRandom](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/SplittableRandom.html), and [java.security.SecureRandom](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/security/SecureRandom.html), now implement the RandomGenerator interface, but apart from that they behave the same as before.

java.util.random.RandomGeneratorFactory is the entry point to the implementations of the RandomGenerator interface. all() returns all implementations, of(String) selects a specific implementation.

		RandomGeneratorFactory.all().map(r -> r.name()).forEach(System.out::println);
		
		RandomGenerator rnd = RandomGeneratorFactory.of("L64X128StarStarRandom").create();
		int r = rnd.nextInt(1000);
		System.out.println(r);

Check out this article for more information about the new pseudo-random number generators:
https://mbien.dev/blog/entry/enhanced-pseudo-random-number-generators