Java 17 API Additions: HexFormat, InstantSource and More

Introduction Besides sealed classes and the new random number generators, Java 17Click to read tutorial: Overview of New Features in Java 17 added several smaller classes and methods to the core libraries. This tutorial explains the Java 17 API additions with examples. We will convert bytes to and from hexadecimal with HexFormat, make time-dependent code testable with the InstantSource interface, and protect deserialization with the context-specific filters of JEP 415. Every example is compiled for Java 17. HexFormat Converting bytes to a hexadecimal string - for hashes, keys, binary protocols or debugging - used to need a hand-written loop or a library, or the removed javax.xml.bind.DatatypeConverterClick to read: javax.xml.bind.DatatypeConverter. java.util.HexFormat does it in one call, in both directions, with optional delimiters, prefixes, suffixes and upper case -
Formatting and parsing hexadecimal
//HexFormatDemo.java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HexFormat;

public class HexFormatDemo {
  public static void main(String[] args) throws Exception {
    HexFormat hex = HexFormat.of();
    byte[] data = "Java 17".getBytes(StandardCharsets.UTF_8);
    String encoded = hex.formatHex(data);
    System.out.println("formatHex : " + encoded);
    System.out.println("parseHex  : " + new String(hex.parseHex(encoded), StandardCharsets.UTF_8));

    byte[] sha = MessageDigest.getInstance("SHA-256").digest(data);
    System.out.println("SHA-256   : " + hex.formatHex(sha));

    HexFormat mac = HexFormat.ofDelimiter(":").withUpperCase();          // like a MAC address
    System.out.println("MAC style : " + mac.formatHex(new byte[] {0x3c, 0x22, (byte) 0xfb, 0x1a, 0x00, 0x7e}));

    HexFormat cArray = HexFormat.ofDelimiter(", ").withPrefix("0x");     // like a C array
    System.out.println("C style   : {" + cArray.formatHex(new byte[] {1, 2, (byte) 255}) + "}");

    System.out.println("toHexDigits(255) : " + hex.toHexDigits((byte) 255) + ", " + hex.toHexDigits(1025));
    System.out.println("fromHexDigits    : " + HexFormat.fromHexDigits("7e1"));
    System.out.println("Round trip equal : " + Arrays.equals(sha, hex.parseHex(hex.formatHex(sha))));
  }
}
 OUTPUT of the above code
formatHex : 4a617661203137
parseHex  : Java 17
SHA-256   : ee8852e3c233dc78273829b0506ecdd43ad216e28db6888836a78dd54d797ae1
MAC style : 3C:22:FB:1A:00:7E
C style   : {0x01, 0x02, 0xff}
toHexDigits(255) : ff, 00000401
fromHexDigits    : 2017
Round trip equal : true
A HexFormat is immutable and thread-safe; methods like withUpperCase() return a new formatter, so formatters can be created once and shared as constants.
InstantSource Code which calls Instant.now() directly is hard to test: the result changes on every run. The usual fix was to pass in a java.time.Clock, but a Clock also carries a time zone, which much code does not need. Java 17 extracted the essential part into a new interface, java.time.InstantSource, with a single abstract method, instant(). Clock now implements it, and InstantSource.system() and InstantSource.fixed() provide the real and a fixed source -
Testable time with InstantSource
//InstantSourceDemo.java
import java.time.Duration;
import java.time.Instant;
import java.time.InstantSource;

public class InstantSourceDemo {
  static class Session {
    private final InstantSource time;
    private final Instant startedAt;
    Session(InstantSource time) { this.time = time; this.startedAt = time.instant(); }
    boolean isExpired() { return Duration.between(startedAt, time.instant()).toMinutes() >= 30; }
  }

  public static void main(String[] args) {
    // Production code would pass InstantSource.system()
    Session live = new Session(InstantSource.system());
    System.out.println("Live session expired right away? " + live.isExpired());

    // A test controls time with a mutable source - InstantSource is a functional interface
    Instant[] now = {Instant.parse("2021-09-14T10:00:00Z")};
    Session tested = new Session(() -> now[0]);
    now[0] = now[0].plus(Duration.ofMinutes(29));
    System.out.println("After 29 minutes expired? " + tested.isExpired());
    now[0] = now[0].plus(Duration.ofMinutes(1));
    System.out.println("After 30 minutes expired? " + tested.isExpired());

    System.out.println("Fixed source: " + InstantSource.fixed(Instant.EPOCH).instant());
  }
}
 OUTPUT of the above code
Live session expired right away? false
After 29 minutes expired? false
After 30 minutes expired? true
Fixed source: 1970-01-01T00:00:00Z
Because InstantSource has one abstract method, a test can supply time with a lambda, as above, and move it forward without sleeping. Context-specific deserialization filters Deserializing untrusted data with ObjectInputStream is a well-known source of security vulnerabilities: a malicious stream can make the application instantiate unexpected classes. Java 9 added deserialization filters, which accept or reject classes as they are read. Java 17 (JEP 415) added a filter factory, set once for the whole JVM with ObjectInputFilter.Config.setSerialFilterFactory(), which chooses the right filter for each stream, and convenience methods such as ObjectInputFilter.allowFilter() -
Allowing only expected classes during deserialization
//DeserializationFilterDemo.java
import java.io.*;
import java.util.ArrayList;
import java.util.List;

public class DeserializationFilterDemo {
  static byte[] serialize(Object o) throws IOException {
    ByteArrayOutputStream bytes = new ByteArrayOutputStream();
    try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { out.writeObject(o); }
    return bytes.toByteArray();
  }

  public static void main(String[] args) throws Exception {
    // JVM-wide factory: combine whatever filter a stream already has with an allow-list
    ObjectInputFilter allowList = ObjectInputFilter.allowFilter(
        c -> c.getName().startsWith("java.lang.") || c == ArrayList.class || c == Object[].class,
        ObjectInputFilter.Status.REJECTED);
    ObjectInputFilter.Config.setSerialFilterFactory((current, requested) ->
        requested == null ? allowList : ObjectInputFilter.merge(requested, allowList));

    List<String> safe = new ArrayList<>(List.of("Tom", "Harry"));
    try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(serialize(safe)))) {
      System.out.println("Allowed: " + in.readObject());
    }
    try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(serialize(new java.util.HashMap<String, String>())))) {
      in.readObject();
    } catch (InvalidClassException e) {
      System.out.println("Rejected: " + e.getMessage());
    }
  }
}
 OUTPUT of the above code
Allowed: [Tom, Harry]
Rejected: filter status: REJECTED
The factory is called for every new ObjectInputStream, so a library can decide filtering for streams it does not create itself. Its second argument is the filter requested for that stream, which is null when the stream has none - so the factory must handle null, since merge() does not accept it. Rejected classes cause an InvalidClassException before the object is constructed. Note that the allow-list includes Object[]: an ArrayList asks the filter to check the array it creates for its elements while it is being deserialized, so leaving it out rejects every ArrayList. Building a tight allow-list means testing it with real data, which is exactly what makes it effective.
Other additions in Java 17 Summary In this tutorial we went through the library additions in Java 17. We formatted and parsed hexadecimal with HexFormat, including delimiters, prefixes and upper case, made time-dependent code testable with InstantSource, and allowed only expected classes during deserialization with a JVM-wide filter factory. Lastly, we listed the other additions in Java 17. To see everything new from Java 12 to 17 in one place, read the overview of new features in Java 17Click to read tutorial: Overview of New Features in Java 17.