1. Overview

Java 17, a long-term support (LTS) release, brings several improvements beneficial for Scala applications. We gain enhanced performance, improved garbage collection, and new APIs through the Scala-Java Interop.

When Scala applications interoperate with Java libraries or frameworks, targeting Java 17 ensures compatibility and allows us to use Java 17 features and libraries. This interoperability is crucial in mixed-language projects or using Java-based tools and libraries within Scala projects.

Configuring a Maven project with Java and Scala code to target Java 17 involves setting up the appropriate compiler plugins:

<plugin>
  <groupId>net.alchim31.maven</groupId>
  <artifactId>scala-maven-plugin</artifactId>
  <version>4.5.4</version>
  <configuration>
    <scalaVersion>2.13.6</scalaVersion>
    <args>
      <arg>-target:jvm-17</arg>
    </args>
  </configuration>
</plugin>

We can review a previous article to understand how to set up the Java version in detail.

This is a good start, but if we use SBT, we can do even better. We can take extra steps to avoid discrepancies in JVM versions between different environments, preventing us from wasting time investigating issues created by differences in the environments.

Enforcing the desired version at the build level is more effective. We can ensure all team members use the same JVM version from the local development environment through the CI/CD pipelines.

A consistent development environment across all stages can help us avoid the typical “It works on my computer” scenario.

2. Configuring JVM Targets in SBT for Scala and Java

Simple Build Tool (SBT) offers a robust and flexible way to manage Scala projects. Its capabilities include explicitly setting the target JVM versions for Scala and Java code.

To set the target JVM version for Scala code, we adjust the scalacOptions in our build.sbt file.

For example, to target Java 17, we would add:

scalacOptions += "-target:17"

This way, we ensure the Scala compiler generates bytecode optimized for the Java 17 virtual machine.

We can also set the source and target compatibility for the Java compiler by configuring the javacOptions in build.sbt:

javacOptions ++= Seq("-source", "17", "-target", "17")

This ensures the generated bytecode is optimized for a particular JVM version. However, we have yet to ensure that the same JVM version is being run by every developer or in every stage of our CI/CD pipeline.

This disparity can lead to the “It works on my computer” syndrome, where code behaves differently on various machines due to differences in the JVM version.

3. Enforcing JVM Version with SBT Initialize

In addition to setting the target JVM version for compiling Scala and Java code, it’s equally important to enforce the use of the correct Java Runtime Environment (JRE) during project execution.

SBT provides a powerful way to achieve this through the initialize task. The initialize task is executed when starting the build process. In this task, we perform a runtime check of the JVM version and assert that it meets our project’s requirements.

Let’s see how we can implement this in our build.sbt file:

initialize := {
  // Ensure previous initializations are run
  val _ = initialize.value

  // Retrieve the JVM's class version and specification version
  val classVersion = sys.props("java.class.version")
  val specVersion = sys.props("java.specification.version")

  // Assert that the JVM meets the minimum required version, for example, Java 17
  assert(specVersion.toDouble >= 17, "Java 17 or above is required to run this project.")
}

In this script, we first ensure that any previous initialization code is executed. Then, we obtain the current JVM’s class version and specification version.

We use the assert statement to check that the JVM running our build meets our minimum requirement (in this case, Java 17). If the running JVM doesn’t meet this version requirement, SBT will halt the build process, and an error message will be displayed, informing us that a higher JVM version is required.

This runtime check prevents discrepancies between development and production environments. It ensures that no matter where our code runs, it is always executed in the intended JVM environment.

This step is crucial in avoiding issues arising from differences in JVM versions, such as incompatibility with particular features or unexpected behavior.

4. Complex JVM Version Checking in SBT

To further refine the enforcement of the JVM version in our Scala projects, we can utilize SBT’s capabilities to define more sophisticated version-checking rules. This method allows us to match specific JVM versions more accurately, providing a robust way to ensure our project is compatible with the exact runtime environment we expect.

First, we need to add a scala file under the project folder. In this script, we define a custom object, CompatibleJavaVersion, which extends VersionNumberCompatibility. This object contains the logic to determine if the current JVM version is compatible with the required version. The isCompatible() method checks the version number components (major, minor, and more) to ensure that the current version meets or exceeds the required version:

import sbt._

// Define a custom object for Java specification version compatibility
object CompatibleJavaVersion extends VersionNumberCompatibility {
  def name = "Java specification compatibility"

  def isCompatible(current: VersionNumber, required: VersionNumber): Boolean =
    current.numbers.zip(required.numbers)
      .foldRight(required.numbers.size <= current.numbers.size) {
        case ((curr, req), acc) => (curr > req) || (curr == req && acc)
  }

  def apply(current: VersionNumber, required: VersionNumber): Boolean = 
    isCompatible(current, required)
}

Next, we enhance our build.sbt by adding an initialize task. This task halts the build and prints an error message if the JVM running our build is lower than the version we require:

// Enforcing the minimum JVM version
initialize := {
  val _ = initialize.value // Ensure previous initializations are run

  val required = VersionNumber("17")
  val current = VersionNumber(sys.props("java.specification.version"))
  println(current)
  assert(CompatibleJavaVersion(current, required), s"Java $required or above is required to run this project.")
}

For example, if we try to run this build in JVM 11, we’ll get an error:

sbt-jvm-error

We could be more strict and require a specific version instead of a minimum one, which could be interesting for situations that demand absolute build reproducibility.

5. Conclusion

In this article, we’ve explored how to target and enforce specific JVM versions in Scala applications using Maven and SBT, focusing on SBT’s capabilities.

We started by understanding the necessity of targeting a specific JVM version like Java 17, especially in mixed-language projects involving Scala and Java. This approach is crucial for leveraging the performance improvements, security updates, and new features of newer JVM versions.

Moving to SBT, we configured the target JVM version for both Scala and Java code, which is essential for compilation consistency.

Finally, we ensured the same JVM version is used during runtime across different environments, which is equally important. We implemented this using SBT’s initialize task and a custom Scala object. This advanced method allows for more precise enforcement, ensuring that our Scala projects run on the exact JVM version they are intended for.

These steps, while technical, are fundamental in avoiding the common pitfalls associated with JVM version discrepancies, such as the infamous “It works on my computer” issue. By rigorously enforcing JVM version consistency, we streamline the development process and enhance our Scala applications’ overall quality and reliability.

As always, the code is available over on GitHub.