Automatically turn your plain classes/records into value classes/records! Codes like a class on older JDKs, works like an int on Valhalla.
auto-valhalla is a Java agent that rewrites eligible identity classes into value classes at class-load time, so existing code (compiled on older JDKs or even JDK28) transparently gets benefits value objects when you run on Valhalla-enabled JVM.
Background: Project Valhalla JEP 401 (https://openjdk.org/jeps/401).
Add auto-valhalla-annotation dependency and annotate your plain identity class or record:
import io.github.thunkware.auto.valhalla.AutoValhalla;
@AutoValhalla
public final class Point {
public final int x;
public final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
@AutoValhalla
public record Pair<T>(T first, T second) { }Then download the agent and launch:
java --enable-preview \
-javaagent:auto-valhalla.jar \
-jar myapp.jarAfter transformation, your class or record behaves like a value object:
Objects.hasIdentity(new Point(1, 2)) == falseObjects.hasIdentity(new Pair(3, 4)) == false
Because @AutoValhalla annotation was compiled with Java 5, it is
compatible JDK 1.5 and later. You can apply the annotation in older codebases
without raising their JDK compile version.
Use -Dauto-valhalla.includes to convert classes if you cannot or do not want to edit
the source code. A setting ending in . matches a package prefix; otherwise it is an
exact class name:
java --enable-preview \
-Dauto-valhalla.includes=com.example. \
-javaagent:auto-valhalla.jar \
-jar myapp.jarTo discover classes that can or cannot be converted, run this a few times:
java --enable-preview \
-Dauto-valhalla.includes-mode=safe \
-Dauto-valhalla.includes='*' \
-Dauto-valhalla.includes.on-success-append-to=success.txt \
-Dauto-valhalla.includes.on-fail-append-to=failures.txt \
-Dauto-valhalla.excludes-file=failures.txt \
-javaagent:auto-valhalla.jar \
-jar myapp.jarsuccess.txt will contain all classes that can be converted, and failures.txt that cannot.
Be warned that a converted class becomes final and loses identity — ==
becomes value equality and System.identityHashCode, synchronized,
WeakReference, and IdentityHashMap behave differently.
Just because a class can be converted does not mean your application will behave correctly on
all execution paths.
Flags are supplied as, in that order of precedence:
- agent arguments
-javaagent:auto-valhalla.jar=option1=value1,option2=value2, or - system properties
-Doption1=value1, or - environment variables.
Within the agent-argument list, later options override earlier ones, and a .config file is expanded in place (see below).
| Option | Env var | Description |
|---|---|---|
auto-valhalla.includes |
AUTO_VALHALLA_INCLUDES |
Comma-separated classes/packages to convert. * matches everything; foo. is a package prefix; foo.Bar an exact class name |
auto-valhalla.excludes |
AUTO_VALHALLA_EXCLUDES |
Same matching rules, but for exclusion (overrides includes and the annotation). |
auto-valhalla.includes-file |
AUTO_VALHALLA_INCLUDES_FILE |
Path to a file with one pattern per line. Blank lines and # comments are ignored. |
auto-valhalla.excludes-file |
AUTO_VALHALLA_EXCLUDES_FILE |
As above, for excludes. |
auto-valhalla.annotation-mode |
AUTO_VALHALLA_ANNOTATION_MODE |
Modes narrowing annotation-selected classes. Defaults to safe. See the mode table below. |
auto-valhalla.includes-mode |
AUTO_VALHALLA_INCLUDES_MODE |
Modes narrowing includes-selected classes. Defaults to yolo. See the mode table below. |
auto-valhalla.debug |
AUTO_VALHALLA_DEBUG |
true for verbose logging of selection decisions. |
auto-valhalla.annotation.on-success-append-to |
AUTO_VALHALLA_ANNOTATION_ON_SUCCESS_APPEND_TO |
Path to a file; Appends class name of each annotation-selected class that is successfully converted. |
auto-valhalla.includes.on-success-append-to |
AUTO_VALHALLA_INCLUDES_ON_SUCCESS_APPEND_TO |
Same, for includes-selected classes. |
auto-valhalla.annotation.on-fail-throw |
AUTO_VALHALLA_ANNOTATION_ON_FAIL_THROW |
true (default) to surface a loud LinkageError (a ClassFormatError at load) when an annotation-selected class cannot be safely transformed, instead of silently keeping it an identity class. |
auto-valhalla.includes.on-fail-throw |
AUTO_VALHALLA_INCLUDES_ON_FAIL_THROW |
Same, for includes-selected classes. Defaults to false so a broad includes sweep cannot crash the application. |
auto-valhalla.annotation.on-fail-append-to |
AUTO_VALHALLA_ANNOTATION_ON_FAIL_APPEND_TO |
Path to a file; Appends class name of each annotation-selected class that fails to transform. |
auto-valhalla.includes.on-fail-append-to |
AUTO_VALHALLA_INCLUDES_ON_FAIL_APPEND_TO |
Same, for includes-selected classes. |
auto-valhalla.config |
AUTO_VALHALLA_CONFIG |
Path to a Java properties file supplying the options above (keys may omit the auto-valhalla. prefix). |
For every *-append-to option the target file is read once at start-up so names
already present are not appended again, and a missing file is simply treated as
empty (no error).
Canonical form uses the auto-valhalla. prefix; agent arguments may also use the
unprefixed name (e.g. includes-mode).
The @AutoValhalla annotation and includes/excludes decide which classes should be selected. Mode further decides
which or how those selected classes should be converted. If a class is selected but not converted, that's considered
a failure; see on-fail-* flags for failure handling.
| Mode | Effect |
|---|---|
safe |
Keep only candidates classes that can be safely converted. |
ignore-synchronized |
Allow candidates with synchronized instance methods; their ACC_SYNCHRONIZED is stripped so they can become value classes. |
mark-class-final |
Also convert non-final candidates. They are made final, so only opt in when nothing subclasses them (otherwise subclasses fail with IncompatibleClassChangeError). |
mark-fields-final |
If instance fields are non-final yet written only once in a constructor, mark them final. Candidates with a non-final field written elsewhere (or more than once) are rejected, since a value class cannot have a mutable field. |
yolo |
Shorthand for ignore-synchronized,mark-class-final,mark-fields-final (the default). |
Mode names are case-insensitive and may use -, _ or camelCase. e.g. mark-class-final, mark_class_final, and
markClassFinal are all the same.
When .config agent argument is expanded, its entries are placed at that position in the agent argument
stream. Therefore:
- if
.configappears first, later agent arguments override it; - if agent argument options appear first and
.configlater, the file overrides them.
# convert a whole package by prefix
-Dauto-valhalla.includes=com.example.
# convert only specific classes
-Dauto-valhalla.includes=com.example.Foo,com.example.Bar
# read options from a properties file (keys may omit auto-valhalla.)
-Dauto-valhalla.config=/etc/auto-valhalla.properties/etc/auto-valhalla.properties:
includes=com.example.
excludes=com.example.dto.
annotation.on-fail-throw=trueincludes.on-fail-append-to and .excludes-file are designed to work together. Run once
with includes.on-fail-throw disabled (the default) and includes.on-fail-append-to
pointing at a file; every class that could not be safely transformed is recorded
there. Feed that file back as .excludes-file on subsequent runs so those classes
are skipped instead of surfacing errors:
# first pass: record anything that fails
-Dauto-valhalla.includes=com.example. \
-Dauto-valhalla.includes.on-fail-append-to=/var/tmp/auto-valhalla-failures.txt
# later passes: skip the classes that failed before
-Dauto-valhalla.includes=com.example. \
-Dauto-valhalla.excludes-file=/var/tmp/auto-valhalla-failures.txtThe companion includes.on-success-append-to records the classes that were
converted, which is handy for turning a broad includes sweep into an explicit
includes-file list:
# record what a broad sweep actually converted
-Dauto-valhalla.includes='*' \
-Dauto-valhalla.includes-mode=safe \
-Dauto-valhalla.includes.on-success-append-to=/var/tmp/auto-valhalla-converted.txt- If annotation-selected classes fail conversion, an exception is thrown by default. Use
annotation.on-fail-throwto change behavior. - If includes-selected classes fail conversion, the classes are left as identity classes. Use
includes.on-fail-throwto change behavior. - A converted class is
final. If anything subclasses it, that subclass will fail class loading. - The agent rewrites identity records and final classes only. It never transforms
JDK/system classes or its own support classes. Non-final classes are converted
(as final) when the mode includes
mark-class-final; any existing subclass then fails to load. - Semantics change. For a converted class,
==becomes value equality (two instances with equal fields compare==),equals/hashCodeof the two fields,synchronizedmethods no longer take a monitor, andSystem.identityHashCode,WeakReference, andIdentityHashMapno longer see per-instance identity. A value class has no identity, so identity-keyed caches and==-based deduplication silently change behavior. This is especially dangerous withincludes-mode, which converts classes without annotating them. - Safe to use with any JDK. The agent's entry point is a JDK 5 class file, so
the agent jar loads on any JVM from JDK 5 up. On a JVM older than JDK 28 (or on JDK 28
without
--enable-preview) the agent prints a single warning and does nothing — your classes keep their original (identity) behavior and the application runs unchanged. There is no need to guard its use behind a JVM-version check. - An already-loaded identity class cannot be retroactively made a value class at
runtime; classes loaded after the agent attaches (or from the start, when
attached via
-javaagent) are the ones rewritten.
This project was vibe-coded with an AI coding agent (opencode). Humans designed, directed, reviewed, edited the work. The agent authored the bulk of the implementation, build configuration, and documentation.