Vector-related improvements in JDK 21 and JDK 25 compared with JDK 17 on AArch64
Discover how JDK 21 and JDK 25 improve AArch64 performance with enhanced C2 auto-vectorization, NEON, SVE, and new JVM intrinsics
By Fei Gao

Executive summary
Java developers running workloads on Arm may be able to unlock meaningful performance gains simply by upgrading the JDK. Compared with JDK 17, JDK 21 and JDK 25 enable HotSpot to generate more efficient AArch64 code through improved C2 auto-vectorization and new NEON- and SVE-accelerated intrinsics, often without requiring any application source changes.
The performance gains depend on the workload, CPU features, vector length, data types, and loop shapes. This blog highlights representative examples where newer JDK releases can generate more efficient SIMD code, helping developers identify workloads where an upgrade is worth evaluating.
Overview
The target audience for this blog is developers who ask :
“What vector-related runtime performance improvements can we get by upgrading the JDK?”
Modern OpenJDK releases provide several ways to use AArch64 SIMD hardware (NEON and SVE) to improve application performance. These mechanisms fall into 3 main categories:
1. JIT Vectorization
The top-tier C2 JIT compiler can automatically vectorize eligible scalar loops. It transforms them into NEON or SVE vector instructions. These optimizations require no application code changes and continue to improve as the JDK evolves.
2. Vector API
The Vector API is currently available as an incubator module. It provides explicit and portable SIMD programming capabilities at the Java language level. Applications can gain additional performance benefits by adopting the API, although this requires code changes. This blog focuses on automatic vectorization and intrinsics rather than explicit Vector API programming.
3. JVM intrinsics on AArch64
Selected JDK library methods use AArch64 intrinsics, allowing applications to benefit from SIMD acceleration by upgrading to a newer JDK version.
For some workloads, upgrading OpenJDK can improve performance without changing application source code. More code becomes eligible for auto-vectorization, and more library methods use vectorized intrinsic implementations.
The rest of this blog highlights the key improvements introduced in JDK 21 and JDK 25 compared with JDK 17. The generated code shown below represents specific benchmark shapes and hardware configurations. Actual code generation can vary based on CPU features, vector length, loop shape, data types, and profitability heuristics.
| Tip: If you are evaluating the auto-vectorization improvements described in this article, JDK 25 provides the diagnostic option -XX:+UnlockDiagnosticVMOptions -XX:AutoVectorizationOverrideProfitability=2. This option helps you compare the generated code with and without the default profitability heuristics to determine whether C2 can vectorize a loop more aggressively. It is intended for performance investigation rather than production tuning. |
Improvements in JDK 21
Induction-variable-dependent loop
Some loops use the loop index as part of the computation. In the following example , the loop index i is multiplied by each array element:
void induction_dependent(int[] ia, int[] ib, int len) {
for (int i = 0; i < len; i++) {
ib[i] = ia[i] * i;
}
}
Arm CPUs with SVE support can efficiently generate a sequence of increasing numbers directly in vector registers, for example, 0, 1, 2, 3, and so on. JDK 21 can use this hardware feature to automatically vectorize loops like this one.
By comparison, JDK 17 generates scalar code for the same loop:
LOOP:
sbfiz x11, x14, #2, #32
add x17, x10, x11
ldr w12, [x17, #16]
mul w13, w12, w14
add x0, x2, x11
str w13, [x0, #16]
...
add w14, w14, #0x8
cmp w14, w18
b.lt LOOP
JDK 21 generates SVE vector instructions that process multiple elements in parallel, improving throughput on SVE-enabled systems. For example, on a system with 128-bit SVE vectors, JDK 21 generates assembly code such as the following:
LOOP:
sbfiz x11, x0, #2, #32
add x12, x17, x11
ldr q16, [x12, #16]
index z17.s, w0, #1
mul v16.4s, v17.4s, v16.4s
add x11, x18, x11
str q16, [x11, #16]
...
add w0, w0, #0x20
cmp w0, w10
b.lt LOOP
Type conversion
JDK 17 did not support auto-vectorization for type-conversions , even though AArch64 provides vector conversion instructions for both NEON and SVE. JDK 21 adds vectorization support for many type conversions, improving data format conversion performance.
The following table summarizes the supported conversions for the NEON and SVE backends.
Conversions marked NEON are supported on both NEON and SVE, while entries marked as SVE require SVE support:
|
From/To |
byte |
short |
int |
long |
float |
double |
|
byte |
- |
- |
- |
SVE |
NEON |
SVE |
|
short |
- |
- |
- |
SVE |
NEON |
SVE |
|
int |
- |
- |
- |
NEON |
NEON |
NEON |
|
long |
SVE |
SVE |
NEON |
- |
SVE |
NEON |
|
float |
NEON |
NEON |
NEON |
NEON |
- |
NEON |
|
double |
SVE |
SVE |
SVE |
NEON |
NEON |
- |
For example, consider the following loop:
void float2double(float[] fa, double[] da, int len) {
for (int i = 0; i < len; i++) {
da[i] = (double) fa[i];
}
}
JDK 17 generates scalar code for each conversion:
LOOP:
sxtw x15, w2
add x17, x20, x15, uxtx #2
ldr s17, [x17, #16]
fcvt d17, s17
add x15, x18, x15, uxtx #3
str d17, [x15, #16]
...
add w2, w2, #0x8
cmp w2, w14
b.lt LOOP
Instead of converting values one at a time, JDK 21 generates SIMD code that converts multiple array elements in parallel using NEON or SVE vector instructions. This improves the throughput of data-processing loops like this one:
LOOP:
sxtw x13, w0
add x18, x15, x13, uxtx #2
ldr d16, [x18, #16]
fcvtl v16.2d, v16.2s
add x13, x17, x13, uxtx #3
str q16, [x13, #16]
…
add w0, w0, #0x8
cmp w0, w14
b.lt LOOP
Math functions
JDK 21 adds auto-vectorization support for several commonly used math operations on AArch64 by using NEON or SVE where available. Supported operations include:
-
Math.max(int, int) and Math.min(int, int) -
Math.round(double) to long and Math.round(float) to int -
Math.signum(double) and Math.signum(float)
For example:
void math_min(int[] ia, int[] ib, int[] ic, int len) {
for (int i = 0; i < len; i++) {
ic[i] = Math.min(ia[i], ib[i]);
}
}
JDK 17 cannot vectorize this loop, so it processes one element at a time with scalar instructions:
LOOP:
sbfiz x12, x17, #2, #32
add x14, x2, x12
add x13, x10, x12
ldr w18, [x14,#16]
ldr w16, [x13,#16]
cmp w16, w18
csel w16, w16, w18, lt
add x12, x3, x12
str w16, [x12,#16]
...
add w17, w17, #0x4
cmp w17, w6
b.lt LOOP
In contrast, JDK 21 generates SIMD code. The following loop uses vector loads, a vector minimum instruction, and a vector store to process multiple int elements in parallel:
LOOP:
mov w13, w11
sbfiz x11, x13, #2, #32
add x17, x14, x11
add x18, x2, x11
ldr q16, [x17,#16]
ldr q17, [x18,#16]
smin v16.4s, v16.4s, v17.4s
add x11, x19, x11
str q16, [x11,#16]
...
add w11, w13, #0x20
cmp w11, w16
b.lt LOOP
More efficient vector code for reduction loop
Consider a loop like this:
int reduction(int[] ia, int[] ib, int len) {
int total = 0;
for (int i = 0; i < len; i++) {
int v = (ia[i] * ib[i]);
total += v;
}
return total;
}
This is a reduction loop. The variable total is updated on each iteration by adding a new value to it.
JDK 17 already vectorizes this loop, but it repeatedly sums vector results back into scalar values during execution. This creates extra work and limits the available parallelism the CPU can exploit.
JDK 21 improves this by keeping the accumulated values in vector registers for longer.
Although total appears to depend on its previous value, each value added to it is independent. For example, the value added when i = 10 does not depend on the value added when i = 9. When no strict order is required, the JIT can process many iterations in parallel, accumulate the intermediate results in vector registers, and perform the final summation only once after the vector loop finishes.
Instead of reducing each group of vector results inside the loop, JDK 21 accumulates them in a temporary vector register:
movi v16.4s, #0x0
mov v17.16b, v16.16b
LOOP:
mov w20, w10
sbfiz x10, x20, #2, #32
add x11, x22, x10
add x10, x16, x10
ldr q18, [x11, #16]
ldr q19, [x10, #16]
mla v17.4s, v18.4s, v19.4s
...
ldr q18, [x11, #128]
ldr q19, [x10, #128]
mla v17.4s, v18.4s, v19.4s
add w10, w20, #0x20
cmp w10, w12
b.lt LOOP
The final reduction is then performed once after the loop:
addv s18, v17.4s
mov w11, v18.s[0]
add w11, w11, w19
This reduces the amount of work inside the loop and removes the repeated scalar update of total, allowing the CPU to make better use of SIMD parallelism.
This optimization applies only when the reduction operation can be safely reordered. This includes integer and long reductions that use addition, multiplication, bitwise AND, OR, or XOR, and minimum or maximum operations. It also applies to floating-point minimum and maximum reductions. Floating-point addition and multiplication reductions are not eligible because reordering these operations can change the final result due to floating-point rounding . JDK 25 adds support for long minimum and maximum reductions.
More advanced algorithm to handle loop dependencies
Sometimes a loop cannot be fully vectorized, but parts of it can still benefit from SIMD acceleration. When vectorizing these loops, the compiler must handle dependencies between scalar and vector operations within the same loop. A less capable vectorization scheduler must take a conservative approach and avoid vectorizing the loop .
JDK 21 improves the auto-vectorization scheduling algorithm to track memory dependencies between scalar and vector operations more accurately. This enables the C2 compiler to safely reorder independent operations and generate partial vector code where it improves performance.
As a result, JDK 21 can automatically vectorize a more loops than JDK 17.
For example:
static void partial_vectorizing(float[] fa, float[] fb, int[] ia, int[] ib, int len) {
for (int i = 0; i < len; i += 2) {
ia[i + 0] = (int)(fa[i + 0] + 3.0f); // A
fb[i + 0] = ib[i + 0]; // B
fb[i + 1] = ib[i + 1]; // C
ia[i + 1] = (int)(fa[i + 1] * 9.0f); // D
}
}
Prior to JDK 26, C2 conservatively assumes that arrays of the same type might alias at compile time. For example, ia and ib may refer to the same array, and the same applies to fa and fb.
During auto-vectorization dependency analysis, the compiler introduces dependency edges between the operations: B depends on A, and D depends on C. Because of these dependencies, JDK 17 cannot effectively vectorize the loop.
However, JDK 21, can identify the operations that are safe to vectorize and generate a mix of scalar and vector instructions.
After loop unrolling, the compiler generates operations such as:
ia[i + 0] = (int)(fa[i + 0] + 3.0f); // A0
fb[i + 0] = ib[i + 0]; // B0
fb[i + 1] = ib[i + 1]; // C0
ia[i + 1] = (int)(fa[i + 1] * 9.0f); // D0
ia[i + 2] = (int)(fa[i + 2] + 3.0f); // A1
fb[i + 2] = ib[i + 2]; // B1
fb[i + 3] = ib[i + 3]; // C1
ia[i + 3] = (int)(fa[i + 3] * 9.0f); // D1
A and D are not vectorized because they are not isomorphic. However, B and C have the same computation pattern, so the compiler can combine them into vector operations.
To do this, the compiler must also reorder the surrounding scalar operations. The A operations must remain before the vectorized B and C operations, while the D operations must remain after them. The compiler therefore moves all A operations before the vector block and all D operations after it.
One valid reordered sequence is: A0 A1 [B0, C0, B1, C1] D0 D1.
After reordering, the compiler can safely combine [B0, C0, B1, C1] into vector instructions and execute them in parallel. This enables JDK 21 to partially vectorize the loop while preserving the original program semantics and improving performance.
Optimize exclusive OR
The optional FEAT_SHA3 extension adds the eor3 instruction. eor3 performs an exclusive OR of three vectors. This benefits applications with consecutive eor operations because the compiler can combine them into fewer operations using the eor3 instruction.
For a loop like:
void eor(int[] ia, int[] ib, int[] ic, int[] id, int len) {
for (int i = 0; i < len; i++) {
id[i] = ia[i] ^ ib[i] ^ ic[i];
}
}
JDK 17 can already vectorize this loop using NEON vector eor instructions. The generated loop body contains two separate operations:
eor v16.16b, v17.16b, v16.16b
eor v16.16b, v16.16b, v18.16b
JDK 21 further improves the generated code by recognizing this pattern and using a single eor3 instruction instead:
eor3 v16.16b, v18.16b, v16.16b, v17.16b
Bit Manipulation Operations
JDK 21 adds auto-vectorization support for a variety of bit-manipulation operations on AArch64.
AArch64 C2 backend can vectorize these using NEON or SVE instructions:
Integer.numberOfTrailingZeros()
Integer.numberOfLeadingZeros()
Long.bitCount()
Long.reverse(), Long.reverseBytes()
Integer.reverse(), Integer.reverseBytes()
Character.reverseBytes()
AArch64 C2 backend additionally can vectorize these operations using SVE:
Long.numberOfTrailingZeros()
Long.numberOfLeadingZeros()
On platforms that support FEAT_SVE_BitPerm, JDK 21 also supports vectorization of:
Integer.compress()
Integer.expand()
Long.compress()
Long.expand()
This extended vectorization support is expected to improve throughput of bit-oriented workloads across a wide range of AArch64 hardware.
For example,
void trailing_zeros(long[] la, long[] lb, int len) {
for (int i = 0; i < len; ++i) {
lb[i] = Long.numberOfTrailingZeros(la[i]);
}
}
JDK 17 can only generate scalar code for this loop:
LOOP:
sbfiz x12, x17, #3, #32
add x1, x14, x12
ldr x13, [x1,#16]
rbit x13, x13
clz x13, x13
sxtw x13, w13
add x2, x0, x12
str x13, [x2,#16]
...
add w17, w17, #0x8
cmp w17, w11
b.lt LOOP
Instead of computing trailing zeros one value at a time, JDK 21 uses SVE vector instructions to process multiple elements simultaneously, improving throughput for bit-processing workloads:
LOOP:
sbfiz x14, x12, #3, #32
add x15, x18, x14
ldr q16, [x15, #16]
rbit z16.d, p7/m, z16.d
clz z16.d, p7/m, z16.d
xtn v16.2s, v16.2d
sxtl v16.2d, v16.2s
add x14, x16, x14
str q16, [x14, #16]
...
add w12, w12, #0x10
cmp w12, w13
b.lt LOOP
String.compareTo
JDK 21 adds SVE implementation for String.compareTo, improving Latin-1 and UTF-16 comparisons for long strings.
Relative performance improvement for systems with different SVE vector lengths, where, higher values indicate better performance, based on the benchmark results reported in OpenJDK String.compareTo() microbenchmark:
case size 128-bits 256-bits 512-bits
compareToLL 24 0.17% 0.58% 0.00%
compareToLL 36 0.00% 2.25% 0.04%
compareToLL 72 -4.40% 3.87% -12.82%
compareToLL 128 4.55% 58.31% 13.53%
compareToLL 256 19.39% 69.77% 82.03%
compareToLL 512 1.81% 68.38% 170.93%
compareToLU 24 25.57% 46.98% 54.61%
compareToLU 36 36.03% 70.26% 94.33%
compareToLU 72 35.86% 90.58% 146.04%
compareToLU 128 70.82% 119.19% 266.22%
compareToLU 256 80.77% 146.33% 420.01%
compareToLU 512 94.62% 171.72% 530.87%
compareToUL 24 20.82% 34.48% 62.14%
compareToUL 36 39.77% 60.79% 69.77%
compareToUL 72 35.46% 84.34% 121.90%
compareToUL 128 67.77% 110.97% 220.53%
compareToUL 256 77.05% 160.29% 331.30%
compareToUL 512 91.88% 184.57% 524.21%
compareToUU 24 -0.13% 0.40% 0.00%
compareToUU 36 -9.18% 12.84% -13.93%
compareToUU 72 1.67% 60.61% 6.69%
compareToUU 128 13.51% 60.33% 55.27%
compareToUU 256 2.55% 62.17% 153.26%
compareToUU 512 4.12% 68.62% 201.68%
String.indexOf(char)
Since JEP 254, “Compact Strings,” Java strings can use either UTF-16 or Latin-1 representations depending on their contents.
JDK 21 introduces SVE intrinsics for String.indexOf(char) for both UTF-16 and Latin-1 strings. In JDK 17, the implementation compares 4 UTF-16 characters or 8 Latin-1 characters at a time. JDK 21 uses SVE to compare more characters in each iteration, improving search efficiency for longer strings.
This improves performance for benchmarks such as latin1_mixed_char() and utf16_mixed_char(), particularly for certain string lengths (for example, length = 65).
Improvements in JDK 25
Diagnostic flag for evaluating auto-vectorization profitability: AutoVectorizationOverrideProfitability
The JDK HotSpot C2 compiler uses several heuristics to estimate whether vectorization is profitable, however, these heuristics are not always accurate. JDK 25 introduces the AutoVectorizationOverrideProfitability diagnostic flag, which gives developers more control over this behavior:
0: Disable vectorization, as if it were deemed unprofitable.
1: Use the existing profitability heuristics.
2: Always vectorize when possible, even if the heuristics predict it is unprofitable.
This flag allows developers to override the default profitability checks. It can be used to assess performance and evaluate the profitability heuristics.
For a loop:
int simple_reduction(int[] ia) {
int sum = 0;
for (int i = 0; i < ia.length; i++) {
sum += ia[i];
}
return sum;
}
The existing heuristics do not consider simple reductions like the one above profitable to vectorize, resulting in assembly such as:
LOOP:
add x10, x20, w17, sxtw #2
ldp w12, w13, [x10,#16]
add w15, w19, w12
ldr w12, [x10,#24]
add w15, w15, w13
ldr w13, [x10,#28]
add w15, w15, w12
ldr w12, [x10,#32]
add w13, w15, w13
ldr w1, [x10,#36]
add w12, w13, w12
ldp w18, w13, [x10,#40]
add w10, w12, w1
add w12, w10, w18
add w17, w17, #0x8
add w19, w12, w13
cmp w17, w11
b.lt LOOP
We can force JDK 25 to vectorize this loop by using -XX:+UnlockDiagnosticVMOptions -XX:AutoVectorizationOverrideProfitability=2 The resulting vectorized code is shown below. This lets us evaluate whether forcing vectorization improves performance for this simple reduction loop and whether the current profitability heuristics make the right decision for this use case:
LOOP:
mov w13, w20
add x15, x21, w13, sxtw #2
ldr q18, [x15, #16]
ldr q19, [x15, #32]
add v17.4s, v18.4s, v17.4s
ldr q18, [x15, #48]
add v17.4s, v19.4s, v17.4s
ldr q19, [x15, #64]
add v17.4s, v18.4s, v17.4s
ldr q18, [x15, #80]
add v17.4s, v19.4s, v17.4s
ldr q19, [x15, #96]
add v17.4s, v18.4s, v17.4s
ldr q18, [x15, #112]
add v17.4s, v19.4s, v17.4s
ldr q19, [x15, #128]
add v17.4s, v18.4s, v17.4s
add v17.4s, v19.4s, v17.4s
add w20, w13, #0x20
cmp w20, w10
b.lt LOOP
Read-forward loop
For a loop like
void read_forward(int[] ia, int[] ib, int len) {
for (int i = 0; i < len - 1; i++) {
ia[i] = ib[i] + ia[i + 1];
}
}
This is a read-forward pattern, where each iteration reads a value from ia[i + 1] while writing to ia[i].
In JDK 21, C2 conservatively assumes a dependency between the vector load and store operations because they access partially overlapping memory regions. As a result, the compiler does not vectorize the loop.
However, this overlap does not create a true dependency. The loaded values are consumed before the store updates memory, so vectorization preserves the original program semantics. JDK 25 improves the dependency analysis and correctly recognizes that no real dependency exists, allowing this read-forward pattern to be vectorized successfully.
JDK 25 generates vector code such as:
LOOP:
mov w10, w14
sbfiz x14, x10, #2, #32
add x16, x11, x14
add x14, x2, x14
ldur q16, [x16, #20]
ldr q17, [x14, #16]
add v16.4s, v17.4s, v16.4s
str q16, [x16, #16]
...
add w14, w10, #0x20
cmp w14, w13
b.lt LOOP
Instead of the scalar code generated in JDK 21:
LOOP:
sbfiz x14, x13, #2, #32
add x4, x19, x14
add x3, x16, x14
ldr w17, [x4, #16]
ldr w14, [x3, #20]
add w14, w14, w17
str w14, [x3, #16]
...
add w13, w13, #0x8
cmp w13, w2
b.lt LOOP
Split vectors for wider hardware
For some operations, the AArch64 backend only supports specific vector lengths. In JDK 21, this can prevent loops from being vectorized on systems with larger vector sizes.
For example:
void mul_add_s2i(short[] sa, short[] sb, int[] ia, int len) {
for (int i = 0; i < len; i++) {
ia[i] += ((sa[2*i] * sb[2*i]) + (sa[2*i+1] * sb[2*i+1]));
}
}
The AArch64 backend in JDK 21 can vectorize this loop only when the operation uses16-byte vectors. On systems with a larger preferred vector length, for example, a 32-byte SVE vector length, C2 constructs a 32-byte vector operation. Because the backend does not support that vector width, the loop cannot be vectorized, and JDK 21 falls back to scalar code:
LOOP:
lsl w14, w15, #1
sbfiz x13, x14, #1, #32
add x20, x17, x13
add x7, x16, x13
ldrsh w1, [x20, #16]
ldrsh w14, [x7, #18]
ldrsh w13, [x20, #18]
add x21, x19, w15, sxtw #2
ldrsh w4, [x7, #16]
mul w8, w13, w14
madd w13, w1, w4, w8
ldr w1, [x21, #16]
add w1, w13, w1
str w1, [x21, #16]
...
add w15, w15, #0x8
cmp w15, w6
b.lt LOOP
However, in JDK 25, the compiler can split a 32-byte vector operation into 2 16-byte vector operations. This allows the loop to be vectorized successfully:
LOOP:
mov w5, w2
lsl w12, w5, #1
sbfiz x12, x12, #1, #32
add x13, x15, x12
add x12, x3, x12
ldr q16, [x13, #16]
ldr q17, [x12, #16]
add x14, x19, w5, sxtw #2
ldr q18, [x12, #32]
ldr q19, [x13, #32]
ldr q20, [x14, #16]
smull v26.4s, v16.4h, v17.4h
smull2 v27.4s, v16.8h, v17.8h
addp v27.4s, v26.4s, v27.4s
add v16.4s, v27.4s, v20.4s
str q16, [x14, #16]
...
add w2, w5, #0x10
cmp w2, w7
b.lt LOOP
Unsafe.setMemory
Unsafe::setMemory is used, for example, to zero newly allocated native MemorySegments.
In JDK 21, Unsafe::setMemory is implemented as a native method in unsafe.cpp. As a result, every invocation requires thread state transitions from Java to native and back to Java, which introduces noticeable overhead, especially for small memory fills.
JDK 25 introduces a SIMD-based intrinsic for Unsafe::setMemory on AArch64. C2 intrinsifies this method as a leaf call, eliminating thread state transitions. The new implementation also uses vector instructions for memory writes. This reduces invocation overhead and improves performance, particularly for small fill sizes.
Arrays.hashCode()
JDK 25 introduces intrinsics for Arrays.hashCode() on several primitive array types, including int[], char[], byte[], and short[].
NEON instructions are used to process multiple elements per iteration, improving performance of the method across all AArch64 systems.
Upgrade your JDK - benefit your workloads
JDK 21 and JDK 25 extend the JVM’s ability to use AArch64 SIMD hardware through improved auto-vectorization and new intrinsic implementations. As OpenJDK evolves, more workloads can benefit from NEON and SVE acceleration without requiring application changes.
Upgrading to a newer JDK release is one of the simplest ways to benefit from ongoing JVM performance improvements on modern Arm systems.
By Fei Gao
Re-use is only permitted for informational and non-commercial or personal use only.
