1. Introduction

In this tutorial, we are going to learn about Records in Java. Record is a new preview feature added to Java 14. Record is a special type of class declaration in Java 14. If you have used languages like Scala or Kotlin before then you can relate them as case class in Scala or data class in Kotlin.

2. What is a Record in Java?

A Record is a restricted form of class declaration just like an enum. To declare any class as a record class, we need to use record token instead of class.

Though it looks like a new reserved keyword in Java language, it’s not. The Record in Java aims to reduce the verbosity and the ceremony involved in declaring data classes.

What is a Data Class?

Data class is a class that is just the carrier of data throughout your application and does nothing special in terms of business logic. Eg. The POJOs and DTOs could be said as data classes. Any class can be thought of as a data class as long as it is being used for data transmission only and not performing any business logic.

3. Why a new type – record?

If you ever wrote code in Java and created DTOs or POJOs just to carry or represent the state of the data, then you might have complained a lot about the verbosity of code and ceremony involved in it. The verbosity comes from the fields and their accessors (getters and setters),  public constructors, overriding the equals(), hashcode(), and toString() methods.

The record is a better way to get rid of this unnecessary ceremony and verbosity involved. The Record provides a concise way to declare these classes. Since they are no more than just a data carrier, we need the field members and their values only. And hence, it requires declaring the data fields and carrier-class name only.

a. Example of a POJO class

If we need to create a POJO Person class, currently we have to declare the private member fields, their public accessors (the getters & setters), a public constructor, equals(), hashcode(), and toString() implementation. After doing all this, we end up writing something like below:-

package com.jstobigdata.java14;

import java.util.Objects;

class Person{
    private long id;
    private String firstName;
    private String lastName;
    private int age;

    public Person(long id, String firstName, String lastName, int age) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    public long getId() {
        return id;
    }

    public void setId(long id) {
        this.id = id;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return id == person.id &&
                age == person.age &&
                firstName.equals(person.firstName) &&
                lastName.equals(person.lastName);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, firstName, lastName, age);
    }

    @Override
    public String toString() {
        return "Person{" +
                "id=" + id +
                ", firstName='" + firstName + '\'' +
                ", lastName='" + lastName + '\'' +
                ", age=" + age +
                '}';
    }
}

Wait !! Just look at the code size and the verbosity in it. We just wanted the four fields id, firstName, lastName, and age. But eventually, we typed this huge block of code.

Here, one may argue that our IDEs are now smart enough to generate this for us, so we don’t have to type it. Ok, Agreed. But, what about the readability then? If one of your teammates is looking at the above code to understand what it does, s/he has to read all this huge code and then would come to know that ‘Oh! It’s a data class. Wouldn’t it be so cool if s/he has to read just a couple of lines and can easily know that it’s a data class?

b. Java 14 Records to Rescue

The record can avoid this boilerplate code and help us to write very concise and least verbose code for the very same behavior as the below example does.

record Person (long id, String firstName, String lastName, int age){}

Only this piece of code is sufficient for replacing that huge POJO above. Isn’t it so cool? Now you might be wondering where are the getters and setters or How would you access the fields then? Well, the accessors are present right there. If you want to access the fields (components) of a record then Java provides implicit accessors for you. You can just call them like this.

var person = new Person( 1, "John", "Doe", 24);    // create object of Person
person.id();                                       // returns the id of person - long
person.firstName();                                // returns the firstName - String
person.lastName();                                 // returns the lastName - String
person.age();                                      // returns the age - int

Although, it looks somewhat similar to what project Lombok already provides but with annotation processing. Notice that these getter names are not starting with get prefix. Instead, they are the same as their component names.

4. Rules & Restrictions

The record class comes with few restrictions. Below are the rules & restrictions of a record class. In order to get records working correctly, you need to adhere to these rules.

  • A record class is an implicit final class. Hence, no other class can extend it further.
  • A record class cannot extend any other class as well.
  • Record classes cannot have instance fields but components.
  • The components/state fields are implicitly final to support immutability.
  • A record class may have static fields, but it’s not needed in most cases.
  • A record body may have static fields, static methods, static initializers, and instance methods.
  • Record class can be generic and nested.
  • Just like any other class, records can also have constructors and methods.
  • A record class can be instantiated with the new keyword like any normal class.
  • A record class can throw exceptions from its method bodies and constructors.

5. What’s the magic behind the record?

So far, you have learned that record is a restricted form of a class declaration. If you fail to comply with the above rules, you will see a compiler yelling at you with some relevant error message. That means these restrictions are put by the compiler. Yes, it’s the compiler that does this magic. If you’ve noticed earlier, the accessors in the above code sample were present actually at compile time. The compiler also generates appropriate equals(), hashCode() and toString() methods automatically.

When you create a record class, the compiler creates accessors for each of its components. It also creates a default parameterized constructor. It marks the components as final so that the component values cannot be modified once set. That’s why the records are shallowly immutable classes.

Immutability is there because the assumption behind introducing records is that the record classes are just a data carrier. So once the value is set, the state of the object should not be modified. Since it’s an immutable data class, the equals() method here compare against each value of components to check whether the two objects are actually equal by value or not.

6. More insights into Records

Since you’ve been reading this for a while and reached here, I’m giving you some extra insights into the records. These two bonus topics will explore records in more detail. I’ll try to prove some of the above rules that we discussed before. I’ll also show you performance & benchmarking on the usage of records over POJOs.

a. Reverse engineering for fun

Let’s do some reverse engineering to verify the above concepts with some facts and proof. I hope it will be fun for you as well.

Let’s say I have a regular POJO class Person as below:-

package com.jstobigdata.java14;

final class Person{

   private long id;

   private String firstName;

   private String lastName;
  
   private int age;

   public final long getId() {
       return id;
   }

   public final void setId(long id) {
       this.id = id;
   }

   public final String getFirstName() {
       return firstName;
   }

   public final void setFirstName(String firstName) {
       this.firstName = firstName;
   }

   public final String getLastName() {
       return lastName;
   }

   public final void setLastName(String lastName) {
       this.lastName = lastName;
   }

   public int getAge() {
       return age;
   }

   public void setAge(int age) {
       this.age = age;
   }

   public Person(long id, String firstName, String lastName, int age){
       this.id = id;
       this.firstName = firstName;
       this.lastName = lastName;
       this.age = age;
   }
  
}

Next, Analyse the Byte code

Let’s compile the code and generate its bytecode. Then analyze that bytecode via javap command. For this, you can use the below steps:

# compile the class first
 javac Person.java

# analyze and store o/p to file named 'person.pojo.txt' to access later
 javap Person.class > person.pojo.txt

Now open the file person.pojo.txt. It should look something like this:-

Compiled from "Person.java"
final class com.jstobigdata.java14.Person {
    public final long getId();
    public final void setId(long);
    public final java.lang.String getFirstName();
    public final void setFirstName(java.lang.String);
    public final java.lang.String getLastName();
    public final void setLastName(java.lang.String);
    public int getAge();
    public void setAge(int);
    public com.jstobigdata.java14.Person(long, java.lang.String, java.lang.String, int);
}

There’s nothing special about this file. It just shows what compiler has done so far. After this, the role of the compiler gets over. You can compare it later with the bytecode of record.

Bytecode of record class

Let’s create a record equivalent of Person class.

package com.example.java14;
public record Person(long id, String firstName, String lastName, int age){ }

Let’s compile this record class. Since the record is still a preview feature, we need to use --enable-feature  command while compiling.

# Compile the code with enabling preview feature
javac Person.java --enable-preview --release 14 -Xlint:preview

# analyze the class file and store o/p to a file
javap Person.class > person.record.txt

Notice that I used --enable-preview for javac only and not for the javap. It means that the record is a feature of the compiler only.

Let’s open the file person.record.txt, it should be something like below.

Compiled from "Person.java"
public final class com.jstobigdata.java14.Person extends java.lang.Record {
    public com.jstobigdata.java14.Person(long, java.lang.String, java.lang.String, int);
    public java.lang.String toString();
    public final int hashCode();
    public final boolean equals(java.lang.Object);
    public long id();
    public java.lang.String firstName();
    public java.lang.String lastName();
    public int age();
}
  • Notice that the compiler has created the id(), firstName(), lastName() and age() accessors on it’s own for you. And no setters at all.
  • Notice that the class Person is marked as final. Did you remember the rules section where I mentioned that the records cannot be extended by any other class? This is the reason.
  • Notice one more thing that the class Person is extending another class called `Record`. This class `Record` is part of java.lang.* package/module.
  • Also, notice that there is no ‘record’ keyword is present. It’s just a regular class. Hence, It is clear that the record is not a reserved keyword in Java (at least so far).

If you are more curious about how the equals() method does the comparisons, then you can explore this by the same javap command but in verbose mode as below.

javap -v Person.class > person.record.verbose.txt

If you open this file, you will see a lot of bytecodes. The output is huge. So I’m just trimming off other parts. In your output file, try to locate the equals(java.lang.Object) method.

public final boolean equals(java.lang.Object);
 descriptor: (Ljava/lang/Object;)Z
 flags: ACC_PUBLIC, ACC_FINAL
 Code:
   stack=2, locals=2, args_size=2
      0: aload_0
      1: aload_1
      2: invokedynamic #36,  0             // InvokeDynamic #0:equals:(Lcom/jstobigdata/java14/Person;Ljava/lang/Object;)Z
      7: ireturn
   LineNumberTable:
     line 57: 0

Notice the invokedynamic instruction. The equals method in Record classes rely upon invokedynamic. The invokedynamic will dynamically call the most appropriate implementation at runtime. This happens inside the JVM.

b. Performance & Benchmarking

I assume you are pretty much liking the new Java feature – record. Let’s do some performance analysis on records. The aim here is to check if there is any performance difference between records and POJOs. Therefore, we’ll compare memory & CPU usage of the system using records over traditional POJOs.

Let’s compare the heap memory and CPU usage. For this, You can use Java VisualVM. You can download the latest version of VisualVM from their official website here.

Here is sample code which populates 50,00,000 Person objects into a people list (List<Person>).

package com.jstobigdata.java14;

import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class Main {

   private final static List<Integer> timeTakes = new ArrayList<>();

   public static void main(String[] args)  throws InterruptedException {

       // running 10 times to take average value.
       for(int loop=0; loop<10; loop++)
          populatePeople(50_00_000);

       final int skip = 3; // skip first 3 for JVM warm-up
       System.out.println("Avg RunTime :" 
                     + timeTakes.stream().skip(skip)
                       .reduce(0, Integer::sum)/(timeTakes.size()-skip));
   }

   private static final Function<Integer, Person> create = 
               i -> new Person(i+0l, "FN_"+i, "SN_"+i, randomAge());

   private static void populatePeople(int length) throws InterruptedException{
       final var startTime = System.currentTimeMillis(); // start time
      
       // populate 50L person objects into list.
       List<Person> people = IntStream.rangeClosed(1, length)
               .mapToObj(create::apply)
               .collect(Collectors.toUnmodifiableList());
       final var endTime = System.currentTimeMillis(); // end time
       final var timeTaken = (endTime-startTime);
       timeTakes.add((int) timeTaken);
       System.out.println("Size : " + people.size());
       System.out.println("Time taken: " + timeTaken + " ms");
      
       // hold objects in memory for a while
       Thread.sleep(5_000);
       people = null;
       System.gc(); // explicitly triggering gc
   }

   private static int randomAge() {
       // random age between 1 to 120.
       return (int) (Math.random()*119 + 1);
   }

}

 Let’s create Person POJO.

package com.jstobigdata.java14;
final class Person{

   private long id;
   private String firstName;
   private String lastName;
   private int age;

   public final long getId() {
       return id;
   }

   public final void setId(long id) {
       this.id = id;
   }

   public final String getFirstName() {
       return firstName;
   }

   public final void setFirstName(String firstName) {
       this.firstName = firstName;
   }

   public final String getLastName() {
       return lastName;
   }

   public final void setLastName(String lastName) {
       this.lastName = lastName;
   }

   public int getAge() {
       return age;
   }

   public void setAge(int age) {
       this.age = age;
   }

   public Person(long id, String firstName, String lastName, int age){
       this.id = id;
       this.firstName = firstName;
       this.lastName = lastName;
       this.age = age;
   }

}

After running the above program, the results are as below.

CPU and Heap Memory usage of Person class as POJO
CPU and Heap Memory usage of ‘Person’ class as POJO
Max CPU usage				:	90 % 
Avg RAM Usage				:	836 MB
Avg Execution Time	 :	1429 ms

Let’s replace the Person POJO with it’s equivalent record class.

package com.jstobigdata.java14;

public record Person(long id, String firstName, String lastName, int age){ }

Re-run the code and observe the results.

CPU and Heap Memory usage of Person class as Record
CPU and Heap Memory usage of Person record
Max CPU usage				:	70 % 
Avg RAM Usage				:	830 MB
Avg Execution Time	 :	1315 ms

We can see that both the variations are having almost similar results. But record implementation is slightly faster and has a lower memory footprint and CPU spikes. However, the difference is almost negligible and can vary for different users on different machines.

Looking at results, now it’s clear that using records instead of regular POJO does not have any significant performance difference but the code size and verbosity does reduce drastically.

Conclusion

Records do help in reducing verbosity, the code becomes intuitive. They also support immutability (shallow) by default. You also learned how the compiler is dealing with records internally. Moreover, all this comes with no extra performance cost involved.

I hope you enjoyed reading it and found it helpful. Looking forward to hearing your feedback in the comments below.