Blog post

Java24: Go deeper on parsing Java class files and broader with Stream gatherers

Jonathan Vila

Jonathan Vila Lopez

Developer Advocate - Java

6 min read

This is part three of our series on the latest Java features, and the new rules available in SonarQube to help check for the proper usage of Class-file new API and Stream gatherers, ensuring your code adheres to best practices and avoids common pitfalls. 

We’ve explored Java 22 and 23, and are finishing up with Java 24. Version 24 version introduces several new language features which collectively simplify code, and provide powerful tools for bytecode manipulation and advanced stream processing. Read on to learn how to leverage these new features  with simple examples.

What are Class-File APIs?

Java 24 introduces the Class-File API (JEP 457), a significant enhancement for parsing, generating, and transforming Java class files. This API provides a programmatic way to work with class files at a low level, offering more flexibility and control than existing bytecode manipulation libraries. It's particularly beneficial for tools that perform static analysis, bytecode instrumentation, or code generation, enabling them to operate directly on the structured representation of class files. By standardizing this access, the Class-File API simplifies development for such tools and ensures greater compatibility across different Java versions.

SonarQube provides a new set of rules to help developers effectively utilize the Class-File API. These rules — including S7479, S7477, and S7478 — are designed to ensure that you use the API efficiently and correctly, leading to more concise, readable, and maintainable bytecode generation and transformation code. Adhering to these guidelines helps developers avoid common pitfalls and leverage the full potential of Java 24's Class-File API.

Rule S7479: withMethodBody should be used to define methods with a body

The new Class-File API (JEP 484) provides a standardized and flexible way to programmatically generate and modify Java class files. When building a class, the ClassBuilder API offers two similar methods for adding a method: withMethod and withMethodBody.

While both can achieve the same result, withMethod is a general-purpose tool that requires an extra step to define the method's code via a nested methodBuilder. For the common case of defining a non-abstract method with a body, the withMethodBody method is a more direct and efficient choice. It reduces boilerplate code, lowers cognitive complexity by removing a layer of nesting, and ultimately improves the maintainability of your class-generation code.

This rule encourages replacing withMethod with its more concise counterpart, withMethodBody, whenever you are defining a method that has a concrete implementation.

Noncompliant Code Example:

ClassBuilder addMethod(ClassBuilder builder) {

    return builder

        .withMethod("foo", MTD_void, ACC_PUBLIC | ACC_STATIC, methodBuilder -> { // Noncompliant

            methodBuilder.withCode(codeBuilder ->

                codeBuilder.getstatic(ClassDesc.of("java.lang.System"), "out", ClassDesc.of("java.io.PrintStream"))

                    .ldc("Hello World")

                    .invokevirtual(ClassDesc.of("java.io.PrintStream"), "println", MTD_void)

                    .return_()

            );

        });

}

This code uses withMethod, which introduces a methodBuilder. This then requires a call to withCode and an additional nested lambda (codeBuilder -> ...) just to define the method's body, making the code unnecessarily verbose.

Compliant Solution:

ClassBuilder addMethod(ClassBuilder builder) {

    return builder

        .withMethodBody("foo", MTD_void, ACC_PUBLIC | ACC_STATIC, codeBuilder ->

            codeBuilder.getstatic(ClassDesc.of("java.lang.System"), "out", ClassDesc.of("java.io.PrintStream"))

                .ldc("Hello World")

                .invokevirtual(ClassDesc.of("java.io.PrintStream"), "println", MTD_void)

                .return_()

        );

}

The compliant solution uses withMethodBody, which directly accepts the code-building lambda. This removes the intermediate methodBuilder, resulting in flatter, more readable, and more maintainable code that clearly expresses the intent of defining a method and its body in a single, streamlined operation.

Rule S7477: The simpler transformClass overload should be used when the class name is unchanged

The Class-File API, introduced in Java via JEP 484, provides powerful methods for transforming class files. Among these is the transformClass method, which comes in several overloaded versions to handle different use cases.

A common scenario is transforming a class without changing its name. For this specific situation, the API provides a concise two-argument version of transformClass. Using the more complex, three-argument overload and manually passing the original class name is unnecessary.

This rule encourages using the simplest possible API to make code shorter, clearer, and less prone to error. By choosing the correct transformClass overload, you explicitly signal that the class is not being renamed, which improves the overall readability and maintainability of the code.

Noncompliant Code Example:

public static void transformClassFile(Path path) throws IOException {

    ClassFile classFile = ClassFile.of();

    ClassModel classModel = classFile.parse(path);

    byte[] newBytes = classFile.transformClass(classModel,

      classModel.thisClass().asSymbol(), // Noncompliant: This argument is redundant

      (classBuilder, classElement) -> {

        if (!(classElement instanceof MethodModel methodModel &&

            methodModel.methodName().stringValue().startsWith("debug"))) {

            classBuilder.with(classElement);

        }

      });

}

In this example, the class name is explicitly passed to transformClass, even though it remains unchanged. This adds unnecessary code and can make the transformation's intent harder to grasp at a glance.

Compliant Solution:

public static void transformClassFile(Path path) throws IOException {

    ClassFile classFile = ClassFile.of();

    ClassModel classModel = classFile.parse(path);

    byte[] newBytes = classFile.transformClass(classModel,

      (classBuilder, classElement) -> {

        if (!(classElement instanceof MethodModel methodModel &&

            methodModel.methodName().stringValue().startsWith("debug"))) {

            classBuilder.with(classElement);

        }

      });

}

The compliant solution uses the simpler, two-argument overload of transformClass. By removing the redundant class name parameter, the code becomes more direct and effectively communicates that the transformation modifies the class in place without renaming it.

Rule S7478: transformClass should be used to modify existing classes

The Class-File API (JEP 484) provides developers with two primary methods for generating class files: build and transformClass. While build is a general-purpose tool for creating a class from scratch, transformClass is specifically designed for the common task of modifying an existing class.

A frequent pattern in bytecode manipulation is to parse a class, iterate through its elements (like methods or fields), and write a new version with some elements removed or altered. Implementing this pattern with build requires manually iterating over the original class's elements and adding them one by one to a new ClassBuilder. This approach is verbose and full of boilerplate code that obscures the core transformation logic.

This rule encourages using the transformClass method for such tasks. It abstracts away the manual iteration, leading to code that is more declarative, easier to read, and clearly expresses the intent of transforming an existing class model.

Noncompliant Code Example:

public static void transformClassFile(Path path) throws IOException {

  ClassFile classFile = ClassFile.of();

  ClassModel classModel = classFile.parse(path);

  byte[] newBytes = classFile.build( // Noncompliant

    classModel.thisClass().asSymbol(), classBuilder -> {

        // Manual iteration over class elements is boilerplate

        for (ClassElement classElement : classModel) {

          if (!(classElement instanceof MethodModel methodModel &&

              methodModel.methodName().stringValue().startsWith("debug"))) {

            classBuilder.with(classElement);

          }

        }

    });

  Files.write(path, newBytes);

}

This code manually rebuilds the class using build, requiring an explicit loop to copy over the elements that are being kept. This boilerplate distracts from the actual goal: filtering out debug methods.

Compliant Solution:

public static void transformClassFile(Path path) throws IOException {

  ClassFile classFile = ClassFile.of();

  ClassModel classModel = classFile.parse(path);

  byte[] newBytes = classFile.transformClass(

    classModel, (classBuilder, classElement) -> {

      // The transform is applied to each element, no manual loop needed

      if (!(classElement instanceof MethodModel methodModel &&

            methodModel.methodName().stringValue().startsWith("debug"))) {

          classBuilder.with(classElement);

        }

      });

  Files.write(path, newBytes);

}

The compliant solution uses transformClass, which handles the iteration implicitly. The provided lambda is applied to each ClassElement, allowing the developer to focus solely on the transformation logic. The resulting code is more concise, readable, and less error-prone.

Stream Gatherers

Java 24 also introduces Stream Gatherers (JEP 461), a new feature designed to enhance the Stream API by allowing for custom intermediate stream operations. Unlike existing `map`, `filter`, or `reduce` operations, Gatherers enable more complex, stateful, and flexible transformations of stream elements. This allows developers to implement operations like grouping, windowing, or de-duplication directly within the stream pipeline, leading to more expressive, efficient, and readable code for advanced data processing scenarios.

SonarQube continues its commitment to code quality by introducing new rules specifically for Java 24's Stream Gatherers. These rules — including S7481, S7482 and S7629 — are designed to guide developers in effectively leveraging this powerful new Stream API feature. They ensure that your custom intermediate stream operations are implemented efficiently and clearly, promoting best practices and helping to avoid common pitfalls associated with stateful and stateless gatherers, leading to more robust and readable stream pipelines.

Rule S7481: Sequential gatherers should use Gatherer.ofSequential

The introduction of Stream Gatherers (JEP 461) in Java provides a powerful way to create custom intermediate operations in stream pipelines. When creating a gatherer, the API offers two main factories: Gatherer.of(...) for gatherers that can be used in both sequential and parallel streams, and Gatherer.ofSequential(...) for those designed exclusively for sequential processing.

A common pattern for a sequential-only gatherer is to provide a combiner function—the third argument in Gatherer.of(...)—that simply throws an exception, as it's never expected to be called. This, however, is a signal that the gatherer is not truly parallel-capable.

This rule helps improve code clarity by guiding you to use the more specific Gatherer.ofSequential(...) factory in these cases. Doing so makes the intended processing model explicit, removes the need for a dummy or throwing combiner, and makes the code cleaner and easier to understand.

Noncompliant Code Example:

public static List<Integer> diffWithFirstPositive(List<Integer> list) {

  Gatherer<Integer, AtomicInteger, Integer> gatherer = Gatherer.of(

    () -> new AtomicInteger(-1),

    (state, number, downstream) -> {

      if (state.get() < 0) {

        state.set(number);

        return true;

      }

      return downstream.push(number - state.get());

    },

    (_, _) -> { // The combiner is never meant to be called

      throw new IllegalStateException();

    },

    Gatherer.defaultFinisher());

  return list.stream().gather(gatherer).toList();

}

In this code, the presence of a combiner that unconditionally throws an IllegalStateException is a clear indicator that the gatherer cannot function in a parallel stream.

Compliant Solution:

public static List<Integer> diffWithFirstPositive(List<Integer> list) {

  Gatherer<Integer, AtomicInteger, Integer> gatherer = Gatherer.ofSequential(

    () -> new AtomicInteger(-1),

    (state, number, downstream) -> {

      if (state.get() < 0) {

        state.set(number);

        return true;

      }

      return downstream.push(number - state.get());

    },

    Gatherer.defaultFinisher());

  return list.stream().gather(gatherer).toList();

}

By switching to Gatherer.ofSequential, the code becomes more obvious about its intent. It clearly communicates that the operation is sequential-only and eliminates the unnecessary and misleading throwing combiner, resulting in a cleaner implementation.

Rule S7482: Stateless gatherers should be created without a null initializer

Stream Gatherers can be either stateful—maintaining a state across elements—or stateless, processing each element independently. For stateless gatherers, there is no need to initialize a state object. The java.util.stream.Gatherer API reflects this distinction by providing overloaded factory methods, including versions that do not take an initializer function.

When creating a stateless gatherer, it is a common mistake to use a factory method that requires an initializer and simply provide a dummy one, such as () -> null. This practice, while functional, makes the code less clear and fails to communicate the gatherer's stateless nature effectively.

This rule encourages the use of the correct factory method for stateless gatherers. By choosing the factory that omits the initializer, you make the stateless design explicit and your code more concise and readable.

Noncompliant Code Example:

private static Gatherer inRange(int start, int end) {

    return Gatherer.<Integer, Void, Integer>ofSequential(

      () -> null, // Noncompliant: unnecessary initializer for a stateless gatherer

      (_, element, downstream) -> {

        if (element >= start && element <= end)

          return downstream.push(element - start);

        return !downstream.isRejecting();

      },

      (_, downstream) -> downstream.push(-1)

    );

}

Here, the () -> null initializer serves no purpose other than to satisfy the signature of the factory method. This adds unnecessary boilerplate and obscures the fact that the operation does not depend on a state.

Compliant Solution:

private static Gatherer inRange(int start, int end) {

    return Gatherer.<Integer, Integer>ofSequential(

      (_, element, downstream) -> {

        if (element >= start && element <= end)

          return downstream.push(element - start);

        return !downstream.isRejecting();

      },

      (_, downstream) -> downstream.push(-1)

    );

}

The compliant solution uses the appropriate Gatherer.ofSequential overload that does not require an initializer. This removes the redundant code and clearly signals to anyone reading it that the gatherer is stateless by design.

Rule S7629: When a defaultFinisher is passed to a Gatherer factory, use the overload that does not take a finisher

The java.util.stream.Gatherer API, used for creating custom stream operations, provides overloaded factory methods like of(...) and ofSequential(...). Some of these overloads accept a finisher function to perform a final action after all elements have been processed.

However, the API also provides a Gatherer.defaultFinisher(), which does nothing. Passing this default finisher to a factory method is redundant and adds unnecessary boilerplate to the code. Using the simpler overload of the factory method that does not take a finisher at all achieves the same result while more clearly communicating that no special finishing action is needed.

This rule helps you write more concise code by flagging the unnecessary use of Gatherer.defaultFinisher().

Noncompliant Code Example:

Gatherer<Integer, AtomicInteger, Integer> gatherer = Gatherer.ofSequential(

  () -> new AtomicInteger(-1),

  (state, number, downstream) -> {

    if (state.get() < 0) {

      state.set(number);

      return true;

    }

    return downstream.push(number - state.get());

  },

  Gatherer.defaultFinisher()); // Noncompliant: this finisher is useless

In this code, Gatherer.defaultFinisher() is explicitly passed, making the code more verbose than necessary for no additional benefit.

Compliant Solution :

Gatherer<Integer, AtomicInteger, Integer> gatherer = Gatherer.ofSequential(

  () -> new AtomicInteger(-1),

  (state, number, downstream) -> {

    if (state.get() < 0) {

      state.set(number);

      return true;

    }

    return downstream.push(number - state.get());

  }); // Compliant

The compliant solution removes the default finisher and calls the simpler overload of Gatherer.ofSequential. The functionality is identical, but the code intent—that no special finisher is required—is perfectly clear.

How Java 24 and SonarQube work together 

By embracing the new features in Java 24—such as the Class-File API, and Stream Gatherers—developers can write more efficient, and more maintainable code resulting in a higher-quality code. However, staying abreast of these evolving language enhancements and consistently applying best practices can be challenging. 

This is where tools like SonarQube, become invaluable. They provide automated checks that help ensure your code not only leverages these modern features correctly but also adheres to high-quality standards, ultimately improving code clarity and overall project quality.

Ready to transform your code?

See how easy it is to integrate SonarQube into your workflow and start finding bugs and vulnerabilities today.

Image for rating

Über 120 G2-Bewertungen

Fordern Sie eine Demo anTry For Free
  • Follow SonarSource on Twitter
  • Follow SonarSource on Linkedin
language switcher
Deutsch (German)
  • Rechtliche Dokumentation
  • Vertrauenszentrum

© 2008-2024 SonarSource SA. All rights reserved. SONAR, SONARSOURCE, SONARQUBE, and CLEAN AS YOU CODE are trademarks of SonarSource SA.