Add classfile-api module: java.lang.classfile (JEP 484) parse, generate, transform with timing instrumentation, ASM 9.11.1 comparison, version limits, JDK 24/25/26 API diff

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b4VQVEJWetTT1dYzyJ
This commit is contained in:
2026-10-29 04:14:57 +00:00
co-authored by Claude Sonnet 5.5
parent eca73ad544
commit 41101bea40
37 changed files with 1763 additions and 0 deletions
+1
View File
@@ -73,6 +73,7 @@ These modules keep only sources, a `run.sh` and their transcripts (`output/`). T
| [`pem-api/`](pem-api) | PEM Encodings in Java (JEP 538/542): Read and Write Keys and Certificates Without BouncyCastle | `PEMDecoder`/`PEMEncoder` round trips, JWT signing (RS256/ES256/EdDSA) and a TLS server built from PEM files, encrypted PKCS#8 keys, the failure catalogue (wrong target class, SEC1 `EC PRIVATE KEY`, CRLF output), BouncyCastle and Spring Security `RsaKeyConverters` side by side, preview flag on JDK 27 vs the JDK 28 EA build |
| [`structured-concurrency-final/`](structured-concurrency-final) | Structured Concurrency Is Final in JDK 28 (JEP 543): From Preview to Production | the thread leak of `ExecutorService` and its fix, every built-in `Joiner`, structure violations, timeouts, a custom joiner, JSON thread dumps, scoped values, `javap` diffs of the API on JDK 25, 26, 27 and 28-ea, the compiler output for six pieces of 25/26-era code, and the preview class-file trap between 27 and 28 |
| [`kdf/`](kdf) | Key Derivation Function API in Java (JEP 510): HKDF Explained with Real Examples | `javax.crypto.KDF` final in JDK 25, `HKDF-SHA256/384/512`, extract vs expand, RFC 5869 vectors asserted, a hand-written HKDF and 300 generated cases compared with Python `cryptography` and `openssl kdf`, per-tenant keys with length-prefixed `info`, and the mistakes reproduced (same `info`, ambiguous concatenation, PIN through HKDF vs PBKDF2) |
| [`classfile-api/`](classfile-api) | The Java ClassFile API (JEP 484): Read, Generate and Transform Bytecode Without ASM | `java.lang.classfile` final since JDK 24: `parse` and `ClassModel` traversal, `ClassFile.build` with `defineClass` and `defineHiddenClass`, five deliberately broken classes (including swapped exception-table labels that `verify()` misses), timing inserted with `ClassTransform`/`MethodTransform`/`CodeTransform`, the same three jobs in ASM 9.11.1 asserted equal, class-file version limits, a JDK 24/25/26 API diff and a look inside `spring-core` 7.0.10 |
## Captured output (`docs/output/`)
+1
View File
@@ -0,0 +1 @@
target/
+28
View File
@@ -0,0 +1,28 @@
# classfile-api
Companion code for the ankurm.com article **The Java ClassFile API (JEP 484): Read, Generate and Transform Bytecode Without ASM**
(https://ankurm.com/java-classfile-api-jep-484/).
`java.lang.classfile` was a preview API in JDK 22 and 23 (JEPs 457, 466) and is **final since JDK 24 (JEP 484)**: no `--enable-preview`.
Everything here ran on Temurin 25.0.5+8; the API diff also ran on Temurin 24.0.2 and 26.0.2.1. The only dependency is ASM 9.11.1, used by the comparison classes.
| File | What it shows | Output |
|---|---|---|
| `Target.java`, `Timings.java` | the class we inspect/instrument, and the sink the injected code calls | |
| `ParseDemo.java` | `ClassFile.of().parse`, `ClassModel` traversal, pattern-matching over elements and instructions | `output/01-parse.txt` |
| `GenerateDemo.java` | `ClassFile.build`, `verify`, `Lookup.defineClass` vs `defineHiddenClass` | `output/02-generate.txt`, `03-javap-generated.txt` |
| `BrokenGenerate.java` | dropped stack maps, wrong return type, stack underflow, swapped exception-table labels | `output/04-broken-generate.txt` |
| `TimingTransform.java`, `TransformDemo.java` | `ClassTransform` / `MethodTransform` / `CodeTransform` inserting timing; naive vs exception-safe | `output/05-transform.txt`, `06-javap-instrumented.txt` |
| `AsmParse.java`, `AsmGenerate.java`, `AsmInstrument.java`, `AsmGotcha.java` | the same three jobs in ASM 9.11.1, asserted equal; plus the `EXPAND_FRAMES` trap | `output/08`, `09`, `10`, `15` |
| `VersionLimits.java` | a newer class file than the reader knows: `java.lang.classfile` vs ASM | `output/11-version-limits.txt` |
| `Bench.java` | indicative parse and copy timing on 2000 `java.base` classes (thread CPU time, noisy) | `output/13-bench.txt` |
| `scripts/run-all.sh` | API diff of `java.lang.classfile` across JDK 24, 25, 26 | `output/07-api-diff-24-25-26.txt` |
| `scripts/spring-core-probe.sh` | what `spring-core` 7.0.10 ships for the ClassFile API | `output/14-spring-core-probe.txt` |
```bash
JDK25=/path/to/jdk-25 JDK24=/path/to/jdk-24 JDK26=/path/to/jdk-26 ./scripts/run-all.sh # JDK24/JDK26 optional; needs Maven for the ASM jars
JDK25=/path/to/jdk-25 ./scripts/spring-core-probe.sh
```
The demos assert as they go (`PASS`/`FAIL`, non-zero exit on failure), like the other standalone modules; there is no JUnit. Output is byte-stable except `13-bench.txt`.
Not reproduced: nothing was run on JDK 22/23 previews.
+72
View File
@@ -0,0 +1,72 @@
$ java -cp classes:asm-9.11.1.jar... demo.ParseDemo (25.0.5+8-LTS)
== header ==
bytes : 1488
this : demo/Target
super : java/lang/Object
version : 69.0 (ClassFile.JAVA_25_VERSION = 69)
flags : public,super
constant pool : 69 slots
== fields ==
final,private name Ljava/lang/String;
private calls I
== methods ==
public <init>(Ljava/lang/String;)V
public,static fib(I)I
public,static sumTo(I)J
public greet(Ljava/lang/String;)Ljava/lang/String;
public pause(J)V
public,static boom()V
public,static explode([I)I
public calls()I
== class elements, in file order (the model is a stream of elements) ==
AccessFlags
ClassFileVersion
Superclass
Interfaces
field name
field calls
method <init>
method fib
method sumTo
method greet
method pause
method boom
method explode
method calls
SourceFile=Target.java
BoundInnerClassesAttribute
== fib(I)I, instruction by instruction ==
ILOAD_0
ICONST_2
IF_ICMPGE
ILOAD_0
GOTO
ILOAD_0
ICONST_1
ISUB
INVOKESTATIC demo/Target.fib(I)I
ILOAD_0
ICONST_2
ISUB
INVOKESTATIC demo/Target.fib(I)I
IADD
IRETURN
== invoke targets in the whole class ==
2 x demo/Target.fib
1 x indy:makeConcatWithConstants
1 x java/lang/IllegalStateException.<init>
1 x java/lang/Object.<init>
1 x java/lang/Thread.sleep
== a JDK class from the runtime image: java.lang.String ==
bytes 50116, major 69, methods 171, fields 11, interfaces 5
PASS Target.class is class-file version 69 (compiled with --release 25)
PASS Target has 8 methods (constructor + 7)
PASS fib calls itself twice
PASS greet() uses invokedynamic string concatenation
exit=0
+13
View File
@@ -0,0 +1,13 @@
$ java -cp classes:asm-9.11.1.jar... demo.GenerateDemo target/gen/demo (25.0.5+8-LTS)
generated 501 bytes, major version 69 (wrote target/gen/demo/Generated.class)
ClassFile.verify(bytes) -> 0 problem(s)
PASS verify() finds nothing wrong with the generated class
defineClass -> demo.Generated hidden=false findable by name=true
[defineClass] add(2,3)=5 hello()=Hello from generated bytecode sumTo(100)=5050 classify(-4)=negative classify(4)=non-negative
PASS defineClass: generated methods return the right values
defineHiddenClass -> name starts with 'demo.Generated/': true hidden=true distinct from the normal class=true
[hidden] add(2,3)=5 hello()=Hello from generated bytecode sumTo(100)=5050 classify(-4)=negative classify(4)=non-negative
PASS hidden: generated methods return the right values
Class.forName(hidden name) -> ClassNotFoundException (hidden classes cannot be found by name)
PASS hidden class is hidden, the other is not
exit=0
+106
View File
@@ -0,0 +1,106 @@
$ javap -c -p -v demo.Generated (the class GenerateDemo wrote; 25.0.5+8-LTS)
public class demo.Generated
minor version: 0
major version: 69
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #2 // demo/Generated
super_class: #6 // java/lang/Object
interfaces: 0, fields: 0, methods: 5, attributes: 0
Constant pool:
#1 = Utf8 demo/Generated
#2 = Class #1 // demo/Generated
#3 = Utf8 <init>
#4 = Utf8 ()V
#5 = Utf8 java/lang/Object
#6 = Class #5 // java/lang/Object
#7 = NameAndType #3:#4 // "<init>":()V
#8 = Methodref #6.#7 // java/lang/Object."<init>":()V
#9 = Utf8 add
#10 = Utf8 (II)I
#11 = Utf8 hello
#12 = Utf8 ()Ljava/lang/String;
#13 = Utf8 Hello from generated bytecode
#14 = String #13 // Hello from generated bytecode
#15 = Utf8 sumTo
#16 = Utf8 (I)J
#17 = Utf8 classify
#18 = Utf8 (I)Ljava/lang/String;
#19 = Utf8 negative
#20 = String #19 // negative
#21 = Utf8 non-negative
#22 = String #21 // non-negative
#23 = Utf8 Code
#24 = Utf8 StackMapTable
#25 = Utf8 java/lang/String
#26 = Class #25 // java/lang/String
{
public demo.Generated();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #8 // Method java/lang/Object."<init>":()V
4: return
public static int add(int, int);
descriptor: (II)I
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=2
0: iload_0
1: iload_1
2: iadd
3: ireturn
public static java.lang.String hello();
descriptor: ()Ljava/lang/String;
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: ldc #14 // String Hello from generated bytecode
2: areturn
public static long sumTo(int);
descriptor: (I)J
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=4, args_size=1
0: lconst_0
1: lstore_1
2: iconst_1
3: istore_3
4: iload_3
5: iload_0
6: if_icmpgt 20
9: lload_1
10: iload_3
11: i2l
12: ladd
13: lstore_1
14: iinc 3, 1
17: goto 4
20: lload_1
21: lreturn
StackMapTable: number_of_entries = 2
frame_type = 253 /* append */
offset_delta = 4
locals = [ long, int ]
frame_type = 15 /* same */
public static java.lang.String classify(int);
descriptor: (I)Ljava/lang/String;
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: iload_0
1: ifge 9
4: ldc #20 // String negative
6: goto 11
9: ldc #22 // String non-negative
11: areturn
StackMapTable: number_of_entries = 2
frame_type = 9 /* same */
frame_type = 65 /* same_locals_1_stack_item */
stack = [ class java/lang/String ]
}
@@ -0,0 +1,19 @@
$ java -cp classes:asm-9.11.1.jar... demo.BrokenGenerate (25.0.5+8-LTS)
DROP_STACK_MAPS: verify() -> 1 problem(s)
Expecting a stackmap frame at branch target 8 in demo/Broken::pick(int) @1
DROP_STACK_MAPS: defineClass -> java.lang.VerifyError
Expecting a stackmap frame at branch target 8
Exception Details:
Location:
PASS a branching method built without stack maps fails verification
PASS the same code with default options verifies
ireturn in a String method: build() succeeded (126 bytes)
verify: Bad return type in demo/Wrong::bad() @1 (java/lang/String is not assignable from integer_type)
PASS verify() catches a wrongly typed return
PASS stack underflow is caught while building
underflow: build() threw java.lang.IllegalArgumentException
Operand stack underflow at bytecode offset 0 of method u()
swapped labels: build() succeeded (173 bytes), verify() -> 0 problem(s)
swapped labels: defineClass -> java.lang.ClassFormatError: Illegal exception table range in class file demo/Swapped
PASS the JVM rejects the swapped exception-table labels
exit=0
+31
View File
@@ -0,0 +1,31 @@
$ java -cp classes:asm-9.11.1.jar... demo.TransformDemo target/instrumented (25.0.5+8-LTS)
original 1488 bytes, naive 1799 bytes, with exception handler 2155 bytes
-- naive: time normal returns only (1799 bytes; verify() problems: 0)
PASS naive: time normal returns only: demo.Target here is a different Class object than the one on the class path
boom() threw java.lang.IllegalStateException: boom
explode() threw ArrayIndexOutOfBoundsException
fib recorded 177 call(s)
sumTo recorded 1 call(s)
greet recorded 1 call(s)
pause recorded 1 call(s)
boom recorded 0 call(s)
explode recorded 0 call(s)
PASS naive version never records the two methods that throw
PASS fib(10) makes 177 calls and 177 timings were recorded (recursion included)
-- full: also time when an exception leaves the method (2155 bytes; verify() problems: 0)
PASS full: also time when an exception leaves the method: demo.Target here is a different Class object than the one on the class path
boom() threw java.lang.IllegalStateException: boom
explode() threw ArrayIndexOutOfBoundsException
fib recorded 177 call(s)
sumTo recorded 1 call(s)
greet recorded 1 call(s)
pause recorded 1 call(s)
boom recorded 1 call(s)
explode recorded 1 call(s)
PASS exception-safe version records boom() and explode() once each
PASS fib still recorded 177 times
PASS sumTo and greet recorded once each
PASS pause(50) was recorded as at least 50 ms (>= 50,000,000 ns)
PASS fib(20) gives the same answer before and after
PASS sumTo(12345) gives the same answer before and after
exit=0
@@ -0,0 +1,121 @@
$ javap -c -p demo.Target (original, as javac produced it; fib and boom only)
public static int fib(int);
Code:
0: iload_0
1: iconst_2
2: if_icmpge 9
5: iload_0
6: goto 22
9: iload_0
10: iconst_1
11: isub
12: invokestatic #13 // Method fib:(I)I
15: iload_0
16: iconst_2
17: isub
18: invokestatic #13 // Method fib:(I)I
21: iadd
22: ireturn
public static void boom();
Code:
0: new #31 // class java/lang/IllegalStateException
3: dup
4: ldc #33 // String boom
6: invokespecial #35 // Method java/lang/IllegalStateException."<init>":(Ljava/lang/String;)V
9: athrow
$ javap -c -p demo.Target (naive transform: records on normal return only)
public static int fib(int);
Code:
0: invokestatic #74 // Method java/lang/System.nanoTime:()J
3: lstore_1
4: iload_0
5: iconst_2
6: if_icmpge 13
9: iload_0
10: goto 26
13: iload_0
14: iconst_1
15: isub
16: invokestatic #13 // Method fib:(I)I
19: iload_0
20: iconst_2
21: isub
22: invokestatic #13 // Method fib:(I)I
25: iadd
26: ldc #76 // String Target.fib
28: invokestatic #74 // Method java/lang/System.nanoTime:()J
31: lload_1
32: lsub
33: invokestatic #82 // Method demo/Timings.record:(Ljava/lang/String;J)V
36: ireturn
public static void boom();
Code:
0: invokestatic #74 // Method java/lang/System.nanoTime:()J
3: lstore_0
4: new #31 // class java/lang/IllegalStateException
7: dup
8: ldc #33 // String boom
10: invokespecial #35 // Method java/lang/IllegalStateException."<init>":(Ljava/lang/String;)V
13: athrow
$ javap -c -p demo.Target (full transform: plus a catch-all handler)
public static int fib(int);
Code:
0: invokestatic #74 // Method java/lang/System.nanoTime:()J
3: lstore_1
4: iload_0
5: iconst_2
6: if_icmpge 13
9: iload_0
10: goto 26
13: iload_0
14: iconst_1
15: isub
16: invokestatic #13 // Method fib:(I)I
19: iload_0
20: iconst_2
21: isub
22: invokestatic #13 // Method fib:(I)I
25: iadd
26: ldc #76 // String Target.fib
28: invokestatic #74 // Method java/lang/System.nanoTime:()J
31: lload_1
32: lsub
33: invokestatic #82 // Method demo/Timings.record:(Ljava/lang/String;J)V
36: ireturn
37: astore_3
38: ldc #76 // String Target.fib
40: invokestatic #74 // Method java/lang/System.nanoTime:()J
43: lload_1
44: lsub
45: invokestatic #82 // Method demo/Timings.record:(Ljava/lang/String;J)V
48: aload_3
49: athrow
Exception table:
from to target type
4 37 37 any
public static void boom();
Code:
0: invokestatic #74 // Method java/lang/System.nanoTime:()J
3: lstore_0
4: new #31 // class java/lang/IllegalStateException
7: dup
8: ldc #33 // String boom
10: invokespecial #35 // Method java/lang/IllegalStateException."<init>":(Ljava/lang/String;)V
13: athrow
14: astore_2
15: ldc #90 // String Target.boom
17: invokestatic #74 // Method java/lang/System.nanoTime:()J
20: lload_0
21: lsub
22: invokestatic #82 // Method demo/Timings.record:(Ljava/lang/String;J)V
25: aload_2
26: athrow
Exception table:
from to target type
4 14 14 any
@@ -0,0 +1,30 @@
$ javap -public java.lang.classfile.** on each JDK, then diff by class (24: OpenJDK Runtime Environment Temurin-24.0.2+12 (build 24.0.2+12), 26: OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1))
=== JDK 24 -> JDK 25 ===
+ java.lang.classfile.ClassFile: public static final int JAVA_25_VERSION;
~ java.lang.classfile.attribute.UnknownAttribute
was: public interface java.lang.classfile.attribute.UnknownAttribute extends java.lang.classfile.Attribute<java.lang.classfile.attribute.UnknownAttribute>, java.lang.classfile.ClassElement, java.lang.classfile.MethodElement, java.lang.classfile.FieldElement {
now: public interface java.lang.classfile.attribute.UnknownAttribute extends java.lang.classfile.Attribute<java.lang.classfile.attribute.UnknownAttribute>, java.lang.classfile.ClassElement, java.lang.classfile.MethodElement, java.lang.classfile.FieldElement, java.lang.classfile.CodeElement {
+ java.lang.classfile.constantpool.ClassEntry: public abstract boolean matches(java.lang.constant.ClassDesc);
+ java.lang.classfile.constantpool.MethodTypeEntry: public abstract boolean matches(java.lang.constant.MethodTypeDesc);
+ java.lang.classfile.constantpool.ModuleEntry: public abstract boolean matches(java.lang.constant.ModuleDesc);
+ java.lang.classfile.constantpool.PackageEntry: public abstract boolean matches(java.lang.constant.PackageDesc);
+ java.lang.classfile.constantpool.StringEntry: public abstract boolean equalsString(java.lang.String);
+ java.lang.classfile.constantpool.Utf8Entry: public abstract boolean isFieldType(java.lang.constant.ClassDesc);
+ java.lang.classfile.constantpool.Utf8Entry: public abstract boolean isMethodType(java.lang.constant.MethodTypeDesc);
239 classes in old, 239 in new, 9 difference(s)
=== JDK 25 -> JDK 26 ===
+ java.lang.classfile.ClassFile: public static final int JAVA_26_VERSION;
239 classes in old, 239 in new, 1 difference(s)
=== is it a preview API on JDK 24? ===
$ javap -v java.lang.classfile.ClassFile | grep -ci PreviewFeature
24.0.2+12: 0
25.0.5+8-LTS: 0
26.0.2.1+1: 0
=== @since of ClassFile ===
jdk-24.0.2+12: * @since 24
jdk-25.0.5+8: * @since 24
jdk-26.0.2.1+1: * @since 24
+18
View File
@@ -0,0 +1,18 @@
$ java -cp classes:asm-9.11.1.jar... demo.AsmParse (25.0.5+8-LTS)
ASM sees major version 69
public <init>(Ljava/lang/String;)V
public,static fib(I)I
public,static sumTo(I)J
public greet(Ljava/lang/String;)Ljava/lang/String;
public pause(J)V
public,static boom()V
public,static explode([I)I
public calls()I
2 x demo/Target.fib
1 x indy:makeConcatWithConstants
1 x java/lang/IllegalStateException.<init>
1 x java/lang/Object.<init>
1 x java/lang/Thread.sleep
PASS ASM and java.lang.classfile list identical method lines
PASS ASM and java.lang.classfile count identical invoke targets
exit=0
+7
View File
@@ -0,0 +1,7 @@
$ java -cp classes:asm-9.11.1.jar... demo.AsmGenerate (25.0.5+8-LTS)
ASM generated 501 bytes; java.lang.classfile generated 501 bytes
PASS ASM output passes ClassFile.verify()
[ASM-generated] add(2,3)=5 hello()=Hello from generated bytecode sumTo(100)=5050 classify(-4)=negative classify(4)=non-negative
PASS ASM-generated: generated methods return the right values
PASS both generators emit the same method list
exit=0
@@ -0,0 +1,17 @@
$ java -cp classes:asm-9.11.1.jar... demo.AsmInstrument (25.0.5+8-LTS)
original 1488 bytes, ASM-instrumented 2152 bytes
PASS ASM output passes ClassFile.verify()
-- ASM AdviceAdapter version (2152 bytes; verify() problems: 0)
PASS ASM AdviceAdapter version: demo.Target here is a different Class object than the one on the class path
boom() threw java.lang.IllegalStateException: boom
explode() threw ArrayIndexOutOfBoundsException
fib recorded 177 call(s)
sumTo recorded 1 call(s)
greet recorded 1 call(s)
pause recorded 1 call(s)
boom recorded 1 call(s)
explode recorded 1 call(s)
PASS ASM version also records both throwing methods once
PASS ASM version: fib recorded 177 times
PASS ASM version: pause(50) recorded as at least 50 ms
exit=0
@@ -0,0 +1,22 @@
$ java -cp classes:asm-9.11.1.jar... demo.VersionLimits (25.0.5+8-LTS)
running on Java 25; ClassFile.latestMajorVersion() = 69; ASM Opcodes.V25=69, V28=72
major 69:
java.lang.classfile : parsed, 8 methods
ASM 9.11.1 : parsed, 8 methods
major 70:
java.lang.classfile : java.lang.IllegalArgumentException: Unsupported class file version: 70
ASM 9.11.1 : parsed, 8 methods
major 72:
java.lang.classfile : java.lang.IllegalArgumentException: Unsupported class file version: 72
ASM 9.11.1 : parsed, 8 methods
major 73:
java.lang.classfile : java.lang.IllegalArgumentException: Unsupported class file version: 73
ASM 9.11.1 : java.lang.IllegalArgumentException: Unsupported class file version 73.0
major 99:
java.lang.classfile : java.lang.IllegalArgumentException: Unsupported class file version: 99
ASM 9.11.1 : java.lang.IllegalArgumentException: Unsupported class file version 99.0
PASS major 69 parses with java.lang.classfile
PASS one version above the JDK's own is rejected by java.lang.classfile
PASS ASM 9.11.1 still reads major 72 (Java 28)
PASS ASM 9.11.1 rejects major 73
exit=0
+3
View File
@@ -0,0 +1,3 @@
Lines of code in the method(s) that do the job (non-blank, non-comment), counted by scripts/run-all.sh:
generate demo.Generated java.lang.classfile 32 ASM 63
timing instrumentation java.lang.classfile 61 ASM 43
+18
View File
@@ -0,0 +1,18 @@
--- run 1 of 3 ---
running on Java 25.0.5+8-LTS, ASM 9.11.1; INDICATIVE numbers, thread CPU time, shared 2-vCPU sandbox, no JMH
2000 classes from java.base, 7001 KiB; 4 warm-up rounds, median of 9
instructions seen: java.lang.classfile 478741, ASM 478741
parse + visit every instruction : java.lang.classfile 119.4 ms ASM 166.1 ms (ASM / classfile = 1.39)
parse + copy + write back : java.lang.classfile 38.6 ms ASM 78.9 ms (ASM / classfile = 2.05)
--- run 2 of 3 ---
running on Java 25.0.5+8-LTS, ASM 9.11.1; INDICATIVE numbers, thread CPU time, shared 2-vCPU sandbox, no JMH
2000 classes from java.base, 7001 KiB; 4 warm-up rounds, median of 9
instructions seen: java.lang.classfile 478741, ASM 478741
parse + visit every instruction : java.lang.classfile 63.9 ms ASM 166.2 ms (ASM / classfile = 2.60)
parse + copy + write back : java.lang.classfile 26.8 ms ASM 88.1 ms (ASM / classfile = 3.29)
--- run 3 of 3 ---
running on Java 25.0.5+8-LTS, ASM 9.11.1; INDICATIVE numbers, thread CPU time, shared 2-vCPU sandbox, no JMH
2000 classes from java.base, 7001 KiB; 4 warm-up rounds, median of 9
instructions seen: java.lang.classfile 478741, ASM 478741
parse + visit every instruction : java.lang.classfile 48.8 ms ASM 44.2 ms (ASM / classfile = 0.90)
parse + copy + write back : java.lang.classfile 18.8 ms ASM 101.6 ms (ASM / classfile = 5.39)
@@ -0,0 +1,22 @@
$ unzip -l spring-core-7.0.10.jar | grep 'META-INF/versions/24/.*\.class' | wc -l
9
$ unzip -l spring-core-7.0.10.jar | grep 'versions/24.*ClassFile'
META-INF/versions/24/org/springframework/core/type/classreading/ClassFileAnnotationDelegate$Source.class
META-INF/versions/24/org/springframework/core/type/classreading/ClassFileAnnotationDelegate.class
META-INF/versions/24/org/springframework/core/type/classreading/ClassFileAnnotationMetadata$Builder.class
META-INF/versions/24/org/springframework/core/type/classreading/ClassFileAnnotationMetadata.class
META-INF/versions/24/org/springframework/core/type/classreading/ClassFileMetadataReader.class
META-INF/versions/24/org/springframework/core/type/classreading/ClassFileMetadataReaderFactory.class
META-INF/versions/24/org/springframework/core/type/classreading/ClassFileMethodMetadata$Source.class
META-INF/versions/24/org/springframework/core/type/classreading/ClassFileMethodMetadata.class
$ unzip -l spring-core-7.0.10.jar | grep -c 'org/springframework/asm/.*\.class' (the repackaged ASM is still there)
37
$ javap -v -cp <jar>!/META-INF/versions/24 ...ClassFileMetadataReader | grep java/lang/classfile (does it really call the JDK API?)
java/lang/classfile/ClassFile
java/lang/classfile/ClassModel
$ unzip -p spring-core-7.0.10.jar META-INF/MANIFEST.MF | grep -i multi-release
Multi-Release: true
@@ -0,0 +1,7 @@
$ java -cp classes:asm-9.11.1.jar... demo.AsmGotcha (25.0.5+8-LTS)
readerFlags = 0 -> java.lang.IllegalArgumentException
LocalVariablesSorter only accepts expanded frames (see ClassReader.EXPAND_FRAMES)
PASS the message names the missing flag
readerFlags = EXPAND_FRAMES -> 2152 bytes, verify() problems: 0
PASS with EXPAND_FRAMES the output verifies
exit=0
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ankurm.javademos</groupId>
<artifactId>classfile-api</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>classfile-api</name>
<description>java.lang.classfile (JEP 484, final in JDK 24): read, generate and transform bytecode, compared with ASM</description>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<asm.version>9.11.1</asm.version>
</properties>
<dependencies>
<!-- ASM is used ONLY by the comparison classes (Asm*.java) and Bench; the java.lang.classfile demos need no dependency. -->
<dependency><groupId>org.ow2.asm</groupId><artifactId>asm</artifactId><version>${asm.version}</version></dependency>
<dependency><groupId>org.ow2.asm</groupId><artifactId>asm-commons</artifactId><version>${asm.version}</version></dependency>
<dependency><groupId>org.ow2.asm</groupId><artifactId>asm-util</artifactId><version>${asm.version}</version></dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration><compilerArgs><arg>-Xlint:all</arg></compilerArgs></configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.8.1</version>
</plugin>
</plugins>
</build>
</project>
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Owner-aware diff of two `javap -public` dumps (one per JDK). Usage: api_diff.py OLD.txt NEW.txt"""
import sys
def load(p):
cur, d = None, {}
for line in open(p):
line = line.rstrip("\n")
if not line.strip():
continue
if not line.startswith(" "):
cur = line.rstrip(" {").split(" extends ")[0].split(" implements ")[0].rsplit(" ", 1)[-1]
d[cur] = {"__header__": line}
elif line.strip() != "}":
d[cur][line.strip()] = True
return d
a, b = load(sys.argv[1]), load(sys.argv[2])
changes = 0
for c in sorted(set(a) | set(b)):
if c not in a: print(f"+ class {c}"); changes += 1; continue
if c not in b: print(f"- class {c}"); changes += 1; continue
if a[c]["__header__"] != b[c]["__header__"]:
print(f"~ {c}\n was: {a[c]['__header__']}\n now: {b[c]['__header__']}"); changes += 1
for m in sorted(set(a[c]) - set(b[c]) - {"__header__"}): print(f"- {c}: {m}"); changes += 1
for m in sorted(set(b[c]) - set(a[c]) - {"__header__"}): print(f"+ {c}: {m}"); changes += 1
print(f"{len(a)} classes in old, {len(b)} in new, {changes} difference(s)")
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# classfile-api: regenerates every file in output/.
# JDK25=/path/to/jdk-25 [JDK24=/path/to/jdk-24 JDK26=/path/to/jdk-26] ./scripts/run-all.sh
# Needs Maven (or an existing target/lib with the ASM jars) for the ASM comparison. JDK24/JDK26 are optional: output/07 is skipped without them.
set -uo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
cd "$HERE"
JDK25="${JDK25:-/opt/jdks/jdk-25.0.5+8}"
J="$JDK25/bin"
OUT="$HERE/output"; mkdir -p "$OUT"
CP="target/c:target/lib/*"
JV="$("$J/java" -version 2>&1 | grep -v '^Picked' | sed -n 2p | sed 's/.*(build \(.*\))/\1/')"
# 1. ASM jars (the only dependency) and compile. Unsetting JAVA_TOOL_OPTIONS keeps proxy banners out of the transcripts.
if [ ! -d target/lib ]; then
JAVA_HOME="$JDK25" mvn -B -q -T1 dependency:copy-dependencies -DoutputDirectory=target/lib || exit 1
fi
unset JAVA_TOOL_OPTIONS
rm -rf target/c target/gen target/instrumented; mkdir -p target/c
echo "javac: $("$J/javac" -version 2>&1)"
"$J/javac" --release 25 -Xlint:all -cp "target/lib/*" -d target/c src/main/java/demo/*.java || exit 1
run() { # run <outfile> <main class> [args] -- prints the command, then the output, then the exit code
local f="$1"; shift
{ echo "\$ java -cp classes:asm-9.11.1.jar... $* ($JV)"; "$J/java" -Xmx512m -cp "$CP" "$@" 2>&1; echo "exit=$?"; } > "$OUT/$f"
}
run 01-parse.txt demo.ParseDemo
run 02-generate.txt demo.GenerateDemo target/gen/demo
{ echo "\$ javap -c -p -v demo.Generated (the class GenerateDemo wrote; $JV)"
"$J/javap" -c -p -v -cp target/gen demo.Generated 2>&1 | grep -v -e '^Classfile ' -e '^ Last modified' -e '^ SHA-256'; } > "$OUT/03-javap-generated.txt"
run 04-broken-generate.txt demo.BrokenGenerate
run 05-transform.txt demo.TransformDemo target/instrumented
meth() { awk -v pat="$1" '$0 ~ pat {f=1} f{print} f && /^$/ {f=0}'; } # one method block of javap -c output
{ echo "\$ javap -c -p demo.Target (original, as javac produced it; fib and boom only)"
"$J/javap" -c -p -cp target/c demo.Target | meth 'static int fib'
"$J/javap" -c -p -cp target/c demo.Target | meth 'static void boom'
echo "\$ javap -c -p demo.Target (naive transform: records on normal return only)"
"$J/javap" -c -p -cp target/instrumented/naive demo.Target | meth 'static int fib'
"$J/javap" -c -p -cp target/instrumented/naive demo.Target | meth 'static void boom'
echo "\$ javap -c -p demo.Target (full transform: plus a catch-all handler)"
"$J/javap" -c -p -cp target/instrumented/full demo.Target | meth 'static int fib'
"$J/javap" -c -p -cp target/instrumented/full demo.Target | meth 'static void boom'
} > "$OUT/06-javap-instrumented.txt"
# 2. ASM comparison, same tasks
run 08-asm-parse.txt demo.AsmParse
run 09-asm-generate.txt demo.AsmGenerate
run 10-asm-instrument.txt demo.AsmInstrument
run 15-asm-expand-frames.txt demo.AsmGotcha
run 11-version-limits.txt demo.VersionLimits
# size of the equivalent code, counting only the method(s) that do the job (non-blank, non-comment lines)
body() { awk -v pat="$2" '$0 ~ pat {f=1} f && !/^[[:space:]]*(\/\/|\*|\/\*|$)/ {n++} f && /^ }$/ {f=0} END {print n+0}' "$1"; }
{ echo "Lines of code in the method(s) that do the job (non-blank, non-comment), counted by scripts/run-all.sh:"
printf "%-26s java.lang.classfile %3d ASM %3d\n" "generate demo.Generated" "$(body src/main/java/demo/GenerateDemo.java 'static byte\[\] generate')" "$(body src/main/java/demo/AsmGenerate.java 'static byte\[\] generate')"
printf "%-26s java.lang.classfile %3d ASM %3d\n" "timing instrumentation" "$(( $(body src/main/java/demo/TimingTransform.java 'public static byte\[\] instrument') + $(body src/main/java/demo/TimingTransform.java 'private static final class Timer') ))" "$(body src/main/java/demo/AsmInstrument.java 'public static byte\[\] instrument')"
} > "$OUT/12-line-counts.txt"
# Bench: three separate JVMs, because a single run on a shared machine is not trustworthy
if [ -z "${SKIP_BENCH:-}" ]; then
{ for i in 1 2 3; do echo "--- run $i of 3 ---"; "$J/java" -Xmx512m -cp "$CP" demo.Bench 2>&1 | grep -v '^PASS'; done; } > "$OUT/13-bench.txt"
fi
# 3. Public API of java.lang.classfile across JDK 24, 25, 26 (needs the other JDKs)
if [ -n "${JDK24:-}" ] && [ -n "${JDK26:-}" ]; then
api() { # api <jdk-home> <outfile>
local classes
classes=$("$1/bin/jimage" list "$1/lib/modules" | awk '/^Module: java.base/{f=1;next} /^Module:/{f=0} f' \
| grep -E 'java/lang/classfile/.*\.class$' | grep -v -e package-info -e Snippets | sed 's/\.class$//;s#/#.#g;s/^ *//')
# shellcheck disable=SC2086
"$1/bin/javap" -public -cp none $classes 2>/dev/null | grep -v '^Compiled from' > "$2"
}
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
api "$JDK24" "$T/24.txt"; api "$JDK25" "$T/25.txt"; api "$JDK26" "$T/26.txt"
{ echo "\$ javap -public java.lang.classfile.** on each JDK, then diff by class (24: $("$JDK24/bin/java" -version 2>&1 | grep -v '^Picked' | sed -n 2p), 26: $("$JDK26/bin/java" -version 2>&1 | grep -v '^Picked' | sed -n 2p))"
echo; echo "=== JDK 24 -> JDK 25 ==="; python3 -I scripts/api_diff.py "$T/24.txt" "$T/25.txt"
echo; echo "=== JDK 25 -> JDK 26 ==="; python3 -I scripts/api_diff.py "$T/25.txt" "$T/26.txt"
echo; echo "=== is it a preview API on JDK 24? ==="; echo "\$ javap -v java.lang.classfile.ClassFile | grep -ci PreviewFeature"
for j in "$JDK24" "$JDK25" "$JDK26"; do echo "$("$j/bin/java" -version 2>&1 | grep -v '^Picked' | sed -n 2p | sed 's/.*(build \(.*\))/\1/'): $("$j/bin/javap" -v java.lang.classfile.ClassFile | grep -ci PreviewFeature)"; done
echo; echo "=== @since of ClassFile ==="; for j in "$JDK24" "$JDK25" "$JDK26"; do unzip -p "$j/lib/src.zip" java.base/java/lang/classfile/ClassFile.java | grep -m1 '@since' | sed "s|^|$(basename "$j"): |"; done
} > "$OUT/07-api-diff-24-25-26.txt"
else
echo "JDK24/JDK26 not set: skipped output/07-api-diff-24-25-26.txt"
fi
echo done
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Who is migrating? Look inside spring-core's published jar instead of trusting a blog.
# JDK25=/path/to/jdk-25 ./scripts/spring-core-probe.sh [spring-core-version] -> output/14-spring-core-probe.txt
set -uo pipefail
unset JAVA_TOOL_OPTIONS
HERE="$(cd "$(dirname "$0")/.." && pwd)"; JDK25="${JDK25:-/opt/jdks/jdk-25.0.5+8}"; V="${1:-7.0.10}"
W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT
URL="https://repo1.maven.org/maven2/org/springframework/spring-core/$V/spring-core-$V.jar"
for i in 1 2 3 4 5 6; do c=$(curl -s -o "$W/sc.jar" -w '%{http_code}' "$URL"); [ "$c" = 200 ] && break; sleep 20; done
[ "$c" = 200 ] || { echo "download failed ($c)"; exit 1; }
{
echo "\$ unzip -l spring-core-$V.jar | grep 'META-INF/versions/24/.*\\.class' | wc -l"
unzip -l "$W/sc.jar" | grep -E 'META-INF/versions/24/.*\.class' | wc -l
echo; echo "\$ unzip -l spring-core-$V.jar | grep 'versions/24.*ClassFile'"
unzip -l "$W/sc.jar" | grep -E 'versions/24/.*ClassFile' | awk '{print $4}'
echo; echo "\$ unzip -l spring-core-$V.jar | grep -c 'org/springframework/asm/.*\\.class' (the repackaged ASM is still there)"
unzip -l "$W/sc.jar" | grep -cE 'org/springframework/asm/.*\.class'
echo; echo "\$ javap -v -cp <jar>!/META-INF/versions/24 ...ClassFileMetadataReader | grep java/lang/classfile (does it really call the JDK API?)"
mkdir "$W/x"; unzip -q "$W/sc.jar" 'META-INF/versions/24/org/springframework/core/type/classreading/ClassFileMetadataReader*' -d "$W/x"
"$JDK25/bin/javap" -v -cp "$W/x/META-INF/versions/24" org.springframework.core.type.classreading.ClassFileMetadataReader | grep -o 'java/lang/classfile/[A-Za-z$/]*' | sort -u
echo; echo "\$ unzip -p spring-core-$V.jar META-INF/MANIFEST.MF | grep -i multi-release"
unzip -p "$W/sc.jar" META-INF/MANIFEST.MF | grep -i multi-release
} > "$HERE/output/14-spring-core-probe.txt" 2>&1
cat "$HERE/output/14-spring-core-probe.txt"
@@ -0,0 +1,95 @@
package demo;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Label;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import java.lang.invoke.MethodHandles;
/** The GenerateDemo task done with ASM 9.11.1: the same class, demo.Generated, same four methods. */
public class AsmGenerate implements Opcodes {
static byte[] generate() {
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
cw.visit(V25, ACC_PUBLIC | ACC_SUPER, "demo/Generated", null, "java/lang/Object", null);
MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
mv.visitCode();
mv.visitVarInsn(ALOAD, 0);
mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(0, 0);
mv.visitEnd();
mv = cw.visitMethod(ACC_PUBLIC | ACC_STATIC, "add", "(II)I", null, null);
mv.visitCode();
mv.visitVarInsn(ILOAD, 0);
mv.visitVarInsn(ILOAD, 1);
mv.visitInsn(IADD);
mv.visitInsn(IRETURN);
mv.visitMaxs(0, 0);
mv.visitEnd();
mv = cw.visitMethod(ACC_PUBLIC | ACC_STATIC, "hello", "()Ljava/lang/String;", null, null);
mv.visitCode();
mv.visitLdcInsn("Hello from generated bytecode");
mv.visitInsn(ARETURN);
mv.visitMaxs(0, 0);
mv.visitEnd();
mv = cw.visitMethod(ACC_PUBLIC | ACC_STATIC, "sumTo", "(I)J", null, null);
mv.visitCode();
Label loop = new Label(), end = new Label();
mv.visitInsn(LCONST_0);
mv.visitVarInsn(LSTORE, 1);
mv.visitInsn(ICONST_1);
mv.visitVarInsn(ISTORE, 3);
mv.visitLabel(loop);
mv.visitVarInsn(ILOAD, 3);
mv.visitVarInsn(ILOAD, 0);
mv.visitJumpInsn(IF_ICMPGT, end);
mv.visitVarInsn(LLOAD, 1);
mv.visitVarInsn(ILOAD, 3);
mv.visitInsn(I2L);
mv.visitInsn(LADD);
mv.visitVarInsn(LSTORE, 1);
mv.visitIincInsn(3, 1);
mv.visitJumpInsn(GOTO, loop);
mv.visitLabel(end);
mv.visitVarInsn(LLOAD, 1);
mv.visitInsn(LRETURN);
mv.visitMaxs(0, 0);
mv.visitEnd();
mv = cw.visitMethod(ACC_PUBLIC | ACC_STATIC, "classify", "(I)Ljava/lang/String;", null, null);
mv.visitCode();
Label nonNeg = new Label(), done = new Label();
mv.visitVarInsn(ILOAD, 0);
mv.visitJumpInsn(IFGE, nonNeg);
mv.visitLdcInsn("negative");
mv.visitJumpInsn(GOTO, done);
mv.visitLabel(nonNeg);
mv.visitLdcInsn("non-negative");
mv.visitLabel(done);
mv.visitInsn(ARETURN);
mv.visitMaxs(0, 0);
mv.visitEnd();
cw.visitEnd();
return cw.toByteArray();
}
public static void main(String[] args) throws Throwable {
byte[] asm = generate();
byte[] cfApi = GenerateDemo.generate(java.lang.classfile.ClassFile.of());
System.out.println("ASM generated " + asm.length + " bytes; java.lang.classfile generated " + cfApi.length + " bytes");
Check.that(java.lang.classfile.ClassFile.of().verify(asm).isEmpty(), "ASM output passes ClassFile.verify()");
Class<?> c = MethodHandles.lookup().defineClass(asm);
GenerateDemo.exercise(c, "ASM-generated");
var a = java.lang.classfile.ClassFile.of().parse(asm);
var b = java.lang.classfile.ClassFile.of().parse(cfApi);
Check.that(ParseDemo.methodLines(a).equals(ParseDemo.methodLines(b)), "both generators emit the same method list");
Check.finish();
}
}
@@ -0,0 +1,21 @@
package demo;
/** The first thing that went wrong writing the ASM version: AdviceAdapter without ClassReader.EXPAND_FRAMES. */
public class AsmGotcha {
public static void main(String[] args) {
AsmInstrument.readerFlags = 0; // what you get if you copy a ClassVisitor example and pass 0
try {
AsmInstrument.instrument(Bytes.of(Target.class));
Check.that(false, "AdviceAdapter without EXPAND_FRAMES should have thrown");
} catch (IllegalArgumentException e) {
System.out.println("readerFlags = 0 -> " + e.getClass().getName());
System.out.println(" " + e.getMessage());
Check.that(e.getMessage().contains("EXPAND_FRAMES"), "the message names the missing flag");
}
AsmInstrument.readerFlags = org.objectweb.asm.ClassReader.EXPAND_FRAMES;
byte[] ok = AsmInstrument.instrument(Bytes.of(Target.class));
System.out.println("readerFlags = EXPAND_FRAMES -> " + ok.length + " bytes, verify() problems: " + java.lang.classfile.ClassFile.of().verify(ok).size());
Check.that(java.lang.classfile.ClassFile.of().verify(ok).isEmpty(), "with EXPAND_FRAMES the output verifies");
Check.finish();
}
}
@@ -0,0 +1,76 @@
package demo;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Label;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
import org.objectweb.asm.commons.AdviceAdapter;
/** The TimingTransform task done with ASM 9.11.1 (AdviceAdapter from asm-commons): same semantics as the "full" ClassFile version. */
public class AsmInstrument {
static int readerFlags = ClassReader.EXPAND_FRAMES; // AdviceAdapter extends LocalVariablesSorter, which throws without it
public static byte[] instrument(byte[] original) {
ClassReader cr = new ClassReader(original);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
String simple = cr.getClassName().substring(cr.getClassName().lastIndexOf('/') + 1);
cr.accept(new ClassVisitor(Opcodes.ASM9, cw) {
@Override public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] ex) {
MethodVisitor mv = super.visitMethod(access, name, desc, sig, ex);
if (name.equals("<init>") || (access & (Opcodes.ACC_ABSTRACT | Opcodes.ACC_NATIVE)) != 0) return mv;
return new AdviceAdapter(Opcodes.ASM9, mv, access, name, desc) {
final Label tryStart = new Label(), tryEnd = new Label(), handler = new Label();
int startSlot;
@Override protected void onMethodEnter() {
startSlot = newLocal(Type.LONG_TYPE);
invokeStatic(Type.getType(System.class), new org.objectweb.asm.commons.Method("nanoTime", "()J"));
storeLocal(startSlot);
visitLabel(tryStart);
}
@Override protected void onMethodExit(int opcode) {
if (opcode != ATHROW) emitRecord(); // athrow is covered by the catch-all handler below
}
@Override public void visitMaxs(int maxStack, int maxLocals) {
visitLabel(tryEnd);
visitLabel(handler);
int ex = newLocal(Type.getType(Throwable.class));
storeLocal(ex);
emitRecord();
loadLocal(ex);
visitInsn(ATHROW);
visitTryCatchBlock(tryStart, tryEnd, handler, null);
super.visitMaxs(maxStack, maxLocals);
}
private void emitRecord() {
visitLdcInsn(simple + "." + name);
invokeStatic(Type.getType(System.class), new org.objectweb.asm.commons.Method("nanoTime", "()J"));
loadLocal(startSlot);
visitInsn(LSUB);
invokeStatic(Type.getObjectType("demo/Timings"), new org.objectweb.asm.commons.Method("record", "(Ljava/lang/String;J)V"));
}
};
}
}, readerFlags);
return cw.toByteArray();
}
public static void main(String[] args) throws Throwable {
byte[] original = Bytes.of(Target.class);
byte[] viaAsm = instrument(original);
System.out.println("original " + original.length + " bytes, ASM-instrumented " + viaAsm.length + " bytes");
Check.that(java.lang.classfile.ClassFile.of().verify(viaAsm).isEmpty(), "ASM output passes ClassFile.verify()");
TransformDemo.report("ASM AdviceAdapter version", viaAsm);
Check.that(Timings.get("Target.boom").count() == 1 && Timings.get("Target.explode").count() == 1, "ASM version also records both throwing methods once");
Check.that(Timings.get("Target.fib").count() == 177, "ASM version: fib recorded 177 times");
Check.that(Timings.get("Target.pause").totalNanos() >= 50_000_000L, "ASM version: pause(50) recorded as at least 50 ms");
Check.finish();
}
}
@@ -0,0 +1,56 @@
package demo;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeMap;
/** The ParseDemo task done with ASM 9.11.1 (visitor API): list methods, count invoke targets. */
public class AsmParse {
record Result(List<String> methodLines, TreeMap<String, Integer> invokes, int major) { }
static Result parse(byte[] bytes) {
List<String> lines = new ArrayList<>();
TreeMap<String, Integer> invokes = new TreeMap<>();
ClassReader cr = new ClassReader(bytes);
cr.accept(new ClassVisitor(Opcodes.ASM9) {
@Override public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] ex) {
lines.add(flags(access) + name + desc);
return new MethodVisitor(Opcodes.ASM9) {
@Override public void visitMethodInsn(int op, String owner, String n, String d, boolean itf) {
invokes.merge(owner + "." + n, 1, Integer::sum);
}
@Override public void visitInvokeDynamicInsn(String n, String d, org.objectweb.asm.Handle bsm, Object... args) {
invokes.merge("indy:" + n, 1, Integer::sum);
}
};
}
}, ClassReader.SKIP_DEBUG);
return new Result(lines, invokes, cr.readShort(6));
}
/** Same format as ParseDemo.flags: lower-case names, sorted, comma-joined. */
static String flags(int access) {
List<String> f = new ArrayList<>();
if ((access & Opcodes.ACC_PUBLIC) != 0) f.add("public");
if ((access & Opcodes.ACC_STATIC) != 0) f.add("static");
return String.join(",", f) + " ";
}
public static void main(String[] args) {
byte[] bytes = Bytes.of(Target.class);
Result r = parse(bytes);
System.out.println("ASM sees major version " + r.major());
r.methodLines().forEach(l -> System.out.println(" " + l));
r.invokes().forEach((k, v) -> System.out.println(" " + v + " x " + k));
var cf = java.lang.classfile.ClassFile.of().parse(bytes);
Check.that(r.methodLines().equals(ParseDemo.methodLines(cf)), "ASM and java.lang.classfile list identical method lines");
Check.that(r.invokes().equals(ParseDemo.invokeCounts(cf)), "ASM and java.lang.classfile count identical invoke targets");
Check.finish();
}
}
+116
View File
@@ -0,0 +1,116 @@
package demo;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import java.io.IOException;
import java.lang.classfile.ClassFile;
import java.lang.classfile.ClassModel;
import java.lang.classfile.ClassTransform;
import java.lang.classfile.CodeElement;
import java.lang.classfile.Instruction;
import java.lang.classfile.MethodModel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.ToLongFunction;
import java.util.stream.Stream;
/**
* A deliberately simple, INDICATIVE comparison (no JMH): parse and visit every instruction of the first N java.base classes,
* and parse + copy + write them back. Warm-up rounds first, then the median of the measured rounds.
* Time is the benchmark thread's own CPU time (not wall-clock), because the sandbox is a shared 2-vCPU machine and wall-clock
* numbers swing with whatever else is running. JIT and GC threads are therefore NOT included. Read the ratio, not the milliseconds.
*/
public class Bench {
static final int N = 2000, WARMUP = 4, ROUNDS = 9;
static final java.lang.management.ThreadMXBean CPU = java.lang.management.ManagementFactory.getThreadMXBean();
static long cfWalk(byte[] b) {
ClassModel cm = ClassFile.of().parse(b);
long n = 0;
for (MethodModel m : cm.methods()) {
if (m.code().isPresent()) {
for (CodeElement e : m.code().get()) if (e instanceof Instruction) n++;
}
}
return n;
}
static long asmWalk(byte[] b) {
long[] n = {0};
new ClassReader(b).accept(new ClassVisitor(Opcodes.ASM9) {
@Override public MethodVisitor visitMethod(int a, String nm, String d, String s, String[] e) {
return new MethodVisitor(Opcodes.ASM9) {
@Override public void visitInsn(int o) { n[0]++; }
@Override public void visitIntInsn(int o, int x) { n[0]++; }
@Override public void visitVarInsn(int o, int x) { n[0]++; }
@Override public void visitTypeInsn(int o, String t) { n[0]++; }
@Override public void visitFieldInsn(int o, String a, String b, String c) { n[0]++; }
@Override public void visitMethodInsn(int o, String a, String b, String c, boolean i) { n[0]++; }
@Override public void visitInvokeDynamicInsn(String a, String b, org.objectweb.asm.Handle h, Object... r) { n[0]++; }
@Override public void visitJumpInsn(int o, org.objectweb.asm.Label l) { n[0]++; }
@Override public void visitLdcInsn(Object v) { n[0]++; }
@Override public void visitIincInsn(int v, int i) { n[0]++; }
@Override public void visitTableSwitchInsn(int a, int b, org.objectweb.asm.Label d, org.objectweb.asm.Label... l) { n[0]++; }
@Override public void visitLookupSwitchInsn(org.objectweb.asm.Label d, int[] k, org.objectweb.asm.Label[] l) { n[0]++; }
@Override public void visitMultiANewArrayInsn(String d, int x) { n[0]++; }
};
}
}, 0);
return n[0];
}
static long cfCopy(byte[] b) {
ClassFile cf = ClassFile.of();
return cf.transformClass(cf.parse(b), ClassTransform.ACCEPT_ALL).length;
}
static long asmCopy(byte[] b) {
ClassReader cr = new ClassReader(b);
ClassWriter cw = new ClassWriter(cr, 0);
cr.accept(cw, 0);
return cw.toByteArray().length;
}
static double medianMs(List<byte[]> classes, ToLongFunction<byte[]> f) {
double[] t = new double[ROUNDS];
long sink = 0;
for (int i = 0; i < WARMUP; i++) for (byte[] b : classes) sink += f.applyAsLong(b);
for (int r = 0; r < ROUNDS; r++) {
long t0 = CPU.getCurrentThreadCpuTime();
for (byte[] b : classes) sink += f.applyAsLong(b);
t[r] = (CPU.getCurrentThreadCpuTime() - t0) / 1e6;
}
if (sink == 42) System.out.print("");
Arrays.sort(t);
return t[ROUNDS / 2];
}
public static void main(String[] args) throws IOException {
List<byte[]> classes = new ArrayList<>();
try (Stream<Path> s = Files.walk(Bytes.jrtRoot("java.base"))) {
for (Path p : (Iterable<Path>) s.filter(x -> x.toString().endsWith(".class") && !x.toString().endsWith("module-info.class")).sorted().limit(N)::iterator) {
classes.add(Files.readAllBytes(p));
}
}
long total = classes.stream().mapToLong(b -> b.length).sum();
long insnCf = 0, insnAsm = 0;
for (byte[] b : classes) { insnCf += cfWalk(b); insnAsm += asmWalk(b); }
System.out.println("running on Java " + Runtime.version() + ", ASM 9.11.1; INDICATIVE numbers, thread CPU time, shared 2-vCPU sandbox, no JMH");
System.out.println(classes.size() + " classes from java.base, " + total / 1024 + " KiB; " + WARMUP + " warm-up rounds, median of " + ROUNDS);
System.out.println("instructions seen: java.lang.classfile " + insnCf + ", ASM " + insnAsm);
Check.that(insnCf > 0 && insnCf == insnAsm, "both walkers see exactly the same number of instructions");
double cfW = medianMs(classes, Bench::cfWalk), asmW = medianMs(classes, Bench::asmWalk);
double cfC = medianMs(classes, Bench::cfCopy), asmC = medianMs(classes, Bench::asmCopy);
System.out.printf("parse + visit every instruction : java.lang.classfile %7.1f ms ASM %7.1f ms (ASM / classfile = %.2f)%n", cfW, asmW, asmW / cfW);
System.out.printf("parse + copy + write back : java.lang.classfile %7.1f ms ASM %7.1f ms (ASM / classfile = %.2f)%n", cfC, asmC, asmC / cfC);
Check.finish();
}
}
@@ -0,0 +1,83 @@
package demo;
import java.lang.classfile.ClassFile;
import java.lang.classfile.Label;
import java.lang.classfile.TypeKind;
import java.lang.constant.ClassDesc;
import java.lang.constant.MethodTypeDesc;
import java.lang.invoke.MethodHandles;
import java.util.List;
import static java.lang.constant.ConstantDescs.*;
/** Three ways to produce a bad class, and what each one looks like. */
public class BrokenGenerate {
static final ClassDesc C = ClassDesc.of("demo", "Broken");
/** static int pick(int n) { return n > 0 ? 1 : 2; } -- needs a StackMapTable because it branches. */
static byte[] branching(ClassFile cf) {
return cf.build(C, clb -> clb.withMethodBody("pick", MethodTypeDesc.of(CD_int, CD_int), ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, cob -> {
Label other = cob.newLabel(), done = cob.newLabel();
cob.iload(0).ifle(other).iconst_1().goto_(done).labelBinding(other).iconst_2().labelBinding(done).ireturn();
}));
}
public static void main(String[] args) throws Throwable {
// 1. Same method, stack maps dropped on request.
byte[] noMaps = branching(ClassFile.of(ClassFile.StackMapsOption.DROP_STACK_MAPS));
List<VerifyError> v = ClassFile.of().verify(noMaps);
System.out.println("DROP_STACK_MAPS: verify() -> " + v.size() + " problem(s)");
v.forEach(e -> System.out.println(" " + e.getMessage().lines().findFirst().orElse("")));
try {
MethodHandles.lookup().defineClass(noMaps);
} catch (VerifyError e) {
System.out.println("DROP_STACK_MAPS: defineClass -> java.lang.VerifyError");
e.getMessage().lines().limit(3).forEach(l -> System.out.println(" " + l));
}
Check.that(!v.isEmpty(), "a branching method built without stack maps fails verification");
// 2. The default (stack maps generated when required) is fine for the very same code.
Check.that(ClassFile.of().verify(branching(ClassFile.of())).isEmpty(), "the same code with default options verifies");
// 3. Type confusion: ireturn on a method declared to return String. The builder does not stop you.
byte[] wrong = ClassFile.of().build(ClassDesc.of("demo", "Wrong"), clb -> clb.withMethodBody("bad",
MethodTypeDesc.of(CD_String), ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, cob -> cob.iconst_1().ireturn()));
System.out.println("ireturn in a String method: build() succeeded (" + wrong.length + " bytes)");
List<VerifyError> w = ClassFile.of().verify(wrong);
w.forEach(e -> System.out.println(" verify: " + e.getMessage().lines().findFirst().orElse("")));
Check.that(!w.isEmpty(), "verify() catches a wrongly typed return");
// 4. Stack underflow while generating with stack maps: the generator itself throws.
try {
ClassFile.of().build(ClassDesc.of("demo", "Under"), clb -> clb.withMethodBody("u", MethodTypeDesc.of(CD_int),
ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, cob -> {
Label l = cob.newLabel();
cob.iadd().goto_(l).labelBinding(l).iconst_0().ireturn();
}));
System.out.println("underflow: build() succeeded");
Check.that(false, "stack underflow is caught while building");
} catch (RuntimeException e) {
Check.that(e instanceof IllegalArgumentException, "stack underflow is caught while building");
System.out.println("underflow: build() threw " + e.getClass().getName());
System.out.println(" " + e.getMessage().lines().findFirst().orElse(""));
}
// 5. exceptionCatchAll(start, end, handler): three Labels, so swapping them compiles. verify() says nothing; the JVM objects.
byte[] swapped = ClassFile.of().build(ClassDesc.of("demo", "Swapped"), clb -> clb.withMethodBody("s",
MethodTypeDesc.of(CD_int), ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, cob -> {
Label start = cob.newLabel(), end = cob.newLabel(), handler = cob.newLabel();
cob.labelBinding(start).iconst_1().labelBinding(end).ireturn()
.labelBinding(handler).pop().iconst_0().ireturn()
.exceptionCatchAll(handler, start, end); // WRONG order on purpose; correct is (start, end, handler)
}));
System.out.println("swapped labels: build() succeeded (" + swapped.length + " bytes), verify() -> " + ClassFile.of().verify(swapped).size() + " problem(s)");
try {
MethodHandles.lookup().defineClass(swapped);
Check.that(false, "the JVM rejects the swapped exception-table labels");
} catch (ClassFormatError e) {
System.out.println("swapped labels: defineClass -> " + e.getClass().getName() + ": " + e.getMessage());
Check.that(true, "the JVM rejects the swapped exception-table labels");
}
Check.finish();
}
}
@@ -0,0 +1,38 @@
package demo;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URI;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
/** Where class-file bytes come from in the demos. */
public final class Bytes {
private Bytes() { }
/** The bytes javac produced for one of our own classes, read from the class path. */
public static byte[] of(Class<?> c) {
try (InputStream in = c.getResourceAsStream(c.getSimpleName() + ".class")) {
return in.readAllBytes();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/** A JDK class, read from the runtime image through the jrt: file system. */
public static byte[] jdk(String module, String internalName) {
try {
FileSystem jrt = FileSystems.getFileSystem(URI.create("jrt:/"));
return Files.readAllBytes(jrt.getPath("modules", module, internalName + ".class"));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public static Path jrtRoot(String module) {
return FileSystems.getFileSystem(URI.create("jrt:/")).getPath("modules", module);
}
}
@@ -0,0 +1,20 @@
package demo;
/** Tiny assertion helper: prints PASS/FAIL and fails the process at the end, like the other standalone modules. */
public final class Check {
private static int failures;
private Check() { }
public static void that(boolean ok, String what) {
System.out.println((ok ? "PASS " : "FAIL ") + what);
if (!ok) failures++;
}
public static void finish() {
if (failures > 0) {
System.out.println(failures + " check(s) FAILED");
System.exit(1);
}
}
}
@@ -0,0 +1,109 @@
package demo;
import java.lang.classfile.ClassFile;
import java.lang.classfile.Label;
import java.lang.classfile.TypeKind;
import java.lang.constant.ClassDesc;
import java.lang.constant.MethodTypeDesc;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static java.lang.constant.ConstantDescs.*;
/** Generating a class from scratch with ClassFile.build, then defining it at runtime. */
public class GenerateDemo {
static final ClassDesc GENERATED = ClassDesc.of("demo", "Generated");
/** public class demo.Generated { add(int,int), hello(), sumTo(int), classify(int) }. */
static byte[] generate(ClassFile cf) {
return cf.build(GENERATED, clb -> clb
.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER)
.withMethodBody(INIT_NAME, MTD_void, ClassFile.ACC_PUBLIC, cob -> cob
.aload(0)
.invokespecial(CD_Object, INIT_NAME, MTD_void)
.return_())
// static int add(int a, int b) { return a + b; }
.withMethodBody("add", MethodTypeDesc.of(CD_int, CD_int, CD_int), ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, cob -> cob
.iload(0).iload(1).iadd().ireturn())
// static String hello() { return "Hello from generated bytecode"; }
.withMethodBody("hello", MethodTypeDesc.of(CD_String), ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, cob -> cob
.ldc("Hello from generated bytecode").areturn())
// static long sumTo(int n) { long s = 0; for (int i = 1; i <= n; i++) s += i; return s; }
.withMethodBody("sumTo", MethodTypeDesc.of(CD_long, CD_int), ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, cob -> {
int s = cob.allocateLocal(TypeKind.LONG);
int i = cob.allocateLocal(TypeKind.INT);
Label loop = cob.newLabel(), end = cob.newLabel();
cob.lconst_0().lstore(s)
.iconst_1().istore(i)
.labelBinding(loop)
.iload(i).iload(0).if_icmpgt(end)
.lload(s).iload(i).i2l().ladd().lstore(s)
.iinc(i, 1)
.goto_(loop)
.labelBinding(end)
.lload(s).lreturn();
})
// static String classify(int n) { return n < 0 ? "negative" : "non-negative"; }
.withMethodBody("classify", MethodTypeDesc.of(CD_String, CD_int), ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, cob -> cob
.iload(0)
.ifThenElse(java.lang.classfile.Opcode.IFLT,
t -> t.ldc("negative"),
e -> e.ldc("non-negative"))
.areturn()));
}
static void exercise(Class<?> c, String how) throws Throwable {
MethodHandles.Lookup l = MethodHandles.publicLookup();
MethodHandle add = l.findStatic(c, "add", MethodType.methodType(int.class, int.class, int.class));
MethodHandle hello = l.findStatic(c, "hello", MethodType.methodType(String.class));
MethodHandle sum = l.findStatic(c, "sumTo", MethodType.methodType(long.class, int.class));
MethodHandle cls = l.findStatic(c, "classify", MethodType.methodType(String.class, int.class));
int a = (int) add.invoke(2, 3);
String h = (String) hello.invoke();
long s = (long) sum.invoke(100);
String neg = (String) cls.invoke(-4), pos = (String) cls.invoke(4);
System.out.println("[" + how + "] add(2,3)=" + a + " hello()=" + h + " sumTo(100)=" + s
+ " classify(-4)=" + neg + " classify(4)=" + pos);
Check.that(a == 5 && s == 5050L && "negative".equals(neg) && "non-negative".equals(pos), how + ": generated methods return the right values");
}
public static void main(String[] args) throws Throwable {
ClassFile cf = ClassFile.of();
byte[] bytes = generate(cf);
Path outDir = Path.of(args.length > 0 ? args[0] : "target/gen/demo");
Files.createDirectories(outDir);
Files.write(outDir.resolve("Generated.class"), bytes);
System.out.println("generated " + bytes.length + " bytes, major version " + cf.parse(bytes).majorVersion()
+ " (wrote " + outDir.resolve("Generated.class") + ")");
// 1. Verify without loading: returns a list of VerifyError, empty if the class is well formed.
List<VerifyError> problems = cf.verify(bytes);
System.out.println("ClassFile.verify(bytes) -> " + problems.size() + " problem(s)");
Check.that(problems.isEmpty(), "verify() finds nothing wrong with the generated class");
// 2. Define it as an ordinary class in this package (Lookup.defineClass needs the same package as the lookup class).
MethodHandles.Lookup lookup = MethodHandles.lookup();
Class<?> normal = lookup.defineClass(bytes);
System.out.println("defineClass -> " + normal.getName() + " hidden=" + normal.isHidden()
+ " findable by name=" + (Class.forName("demo.Generated") == normal));
exercise(normal, "defineClass");
// 3. Define the same bytes as a hidden class: no name anyone can resolve, unloadable with its defining loader's GC.
MethodHandles.Lookup hiddenLookup = lookup.defineHiddenClass(bytes, true);
Class<?> hidden = hiddenLookup.lookupClass();
System.out.println("defineHiddenClass -> name starts with 'demo.Generated/': " + hidden.getName().startsWith("demo.Generated/")
+ " hidden=" + hidden.isHidden() + " distinct from the normal class=" + (hidden != normal));
exercise(hidden, "hidden");
try {
Class.forName(hidden.getName());
} catch (ClassNotFoundException e) {
System.out.println("Class.forName(hidden name) -> ClassNotFoundException (hidden classes cannot be found by name)");
}
Check.that(hidden.isHidden() && !normal.isHidden(), "hidden class is hidden, the other is not");
Check.finish();
}
}
@@ -0,0 +1,112 @@
package demo;
import java.lang.classfile.ClassElement;
import java.lang.classfile.ClassFile;
import java.lang.classfile.ClassModel;
import java.lang.classfile.CodeElement;
import java.lang.classfile.FieldModel;
import java.lang.classfile.Instruction;
import java.lang.classfile.MethodModel;
import java.lang.classfile.attribute.SourceFileAttribute;
import java.lang.classfile.instruction.InvokeDynamicInstruction;
import java.lang.classfile.instruction.InvokeInstruction;
import java.lang.reflect.AccessFlag;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeMap;
import java.util.stream.Collectors;
/** Reading a class with java.lang.classfile: ClassFile.of().parse(...) and walking the model. */
public class ParseDemo {
/** One line per method: flags name descriptor. Shared with the ASM comparison, which must print the same lines. */
static List<String> methodLines(ClassModel cm) {
List<String> lines = new ArrayList<>();
for (MethodModel m : cm.methods()) {
lines.add(flags(m.flags().flags()) + m.methodName().stringValue() + m.methodType().stringValue());
}
return lines;
}
static String flags(java.util.Set<AccessFlag> fs) {
return fs.stream().map(f -> f.name().toLowerCase()).sorted().collect(Collectors.joining(",")) + " ";
}
/** Calls per "owner.name" in all method bodies, using a pattern-matching switch over code elements. */
static TreeMap<String, Integer> invokeCounts(ClassModel cm) {
TreeMap<String, Integer> counts = new TreeMap<>();
for (MethodModel m : cm.methods()) {
m.code().ifPresent(code -> {
for (CodeElement e : code) {
switch (e) {
case InvokeInstruction i -> counts.merge(
i.owner().asInternalName() + "." + i.name().stringValue(), 1, Integer::sum);
case InvokeDynamicInstruction i -> counts.merge(
"indy:" + i.name().stringValue(), 1, Integer::sum);
default -> { }
}
}
});
}
return counts;
}
public static void main(String[] args) throws Exception {
byte[] bytes = Bytes.of(Target.class);
ClassModel cm = ClassFile.of().parse(bytes);
System.out.println("== header ==");
System.out.println("bytes : " + bytes.length);
System.out.println("this : " + cm.thisClass().asInternalName());
System.out.println("super : " + cm.superclass().orElseThrow().asInternalName());
System.out.println("version : " + cm.majorVersion() + "." + cm.minorVersion()
+ " (ClassFile.JAVA_25_VERSION = " + ClassFile.JAVA_25_VERSION + ")");
System.out.println("flags : " + flags(cm.flags().flags()));
System.out.println("constant pool : " + cm.constantPool().size() + " slots");
System.out.println("\n== fields ==");
for (FieldModel f : cm.fields()) {
System.out.println(flags(f.flags().flags()) + f.fieldName().stringValue() + " " + f.fieldType().stringValue());
}
System.out.println("\n== methods ==");
methodLines(cm).forEach(System.out::println);
System.out.println("\n== class elements, in file order (the model is a stream of elements) ==");
for (ClassElement ce : cm) {
String d = switch (ce) {
case MethodModel m -> "method " + m.methodName().stringValue();
case FieldModel f -> "field " + f.fieldName().stringValue();
case SourceFileAttribute a -> "SourceFile=" + a.sourceFile().stringValue();
default -> ce.getClass().getSimpleName().replaceAll("Impl$", "");
};
System.out.println(" " + d);
}
System.out.println("\n== fib(I)I, instruction by instruction ==");
MethodModel fib = cm.methods().stream().filter(m -> m.methodName().equalsString("fib")).findFirst().orElseThrow();
for (CodeElement e : fib.code().orElseThrow()) {
if (e instanceof Instruction i) {
String extra = e instanceof InvokeInstruction inv
? " " + inv.owner().asInternalName() + "." + inv.name().stringValue() + inv.type().stringValue() : "";
System.out.println(" " + i.opcode() + extra);
}
}
System.out.println("\n== invoke targets in the whole class ==");
TreeMap<String, Integer> counts = invokeCounts(cm);
counts.forEach((k, v) -> System.out.println(" " + v + " x " + k));
System.out.println("\n== a JDK class from the runtime image: java.lang.String ==");
byte[] sb = Bytes.jdk("java.base", "java/lang/String");
ClassModel string = ClassFile.of().parse(sb);
System.out.println("bytes " + sb.length + ", major " + string.majorVersion() + ", methods " + string.methods().size()
+ ", fields " + string.fields().size() + ", interfaces " + string.interfaces().size());
Check.that(cm.majorVersion() == ClassFile.JAVA_25_VERSION, "Target.class is class-file version 69 (compiled with --release 25)");
Check.that(cm.methods().size() == 8, "Target has 8 methods (constructor + 7)");
Check.that(counts.getOrDefault("demo/Target.fib", 0) == 2, "fib calls itself twice");
Check.that(counts.containsKey("indy:makeConcatWithConstants"), "greet() uses invokedynamic string concatenation");
Check.finish();
}
}
@@ -0,0 +1,45 @@
package demo;
/** The class we inspect and instrument. Deliberately small, and it covers the interesting shapes:
* a recursive method, a loop, string concatenation (invokedynamic), a method that sleeps and one that throws. */
public class Target {
private final String name;
private int calls;
public Target(String name) {
this.name = name;
}
public static int fib(int n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
public static long sumTo(int n) {
long s = 0;
for (int i = 1; i <= n; i++) {
s += i;
}
return s;
}
public String greet(String who) {
calls++;
return "Hello, " + who + " from " + name;
}
public void pause(long millis) throws InterruptedException {
Thread.sleep(millis);
}
public static void boom() {
throw new IllegalStateException("boom");
}
public static int explode(int[] a) {
return a[5]; // throws from the JVM (AIOOBE), not from an explicit athrow
}
public int calls() {
return calls;
}
}
@@ -0,0 +1,108 @@
package demo;
import java.lang.classfile.ClassBuilder;
import java.lang.classfile.ClassElement;
import java.lang.classfile.ClassFile;
import java.lang.classfile.ClassModel;
import java.lang.classfile.ClassTransform;
import java.lang.classfile.CodeBuilder;
import java.lang.classfile.CodeElement;
import java.lang.classfile.CodeTransform;
import java.lang.classfile.Label;
import java.lang.classfile.MethodModel;
import java.lang.classfile.MethodTransform;
import java.lang.classfile.TypeKind;
import java.lang.classfile.instruction.ReturnInstruction;
import java.lang.constant.ClassDesc;
import java.lang.constant.MethodTypeDesc;
import static java.lang.constant.ConstantDescs.*;
/**
* Inserts timing around every method body of a class using java.lang.classfile transforms.
*
* <pre>
* ClassTransform -- looks at each class element; for a method it hands the MethodModel to a MethodTransform
* MethodTransform -- transformingCode(...) forwards the Code attribute to a CodeTransform
* CodeTransform -- sees every instruction; atStart/atEnd bracket the whole body
* </pre>
*/
public final class TimingTransform {
static final ClassDesc SYSTEM = ClassDesc.of("java.lang.System");
static final ClassDesc TIMINGS = ClassDesc.of("demo.Timings");
static final MethodTypeDesc NANO_TIME = MethodTypeDesc.of(CD_long);
static final MethodTypeDesc RECORD = MethodTypeDesc.of(CD_void, CD_String, CD_long);
private TimingTransform() { }
/** @param alsoOnException false = time only normal returns (the naive version); true = also when an exception leaves the method */
public static byte[] instrument(byte[] original, boolean alsoOnException) {
ClassFile cf = ClassFile.of();
ClassModel model = cf.parse(original);
String owner = model.thisClass().asInternalName();
ClassTransform perClass = (ClassBuilder cb, ClassElement ce) -> {
if (ce instanceof MethodModel mm && mm.code().isPresent() && !mm.methodName().equalsString("<init>")) {
String label = owner.substring(owner.lastIndexOf('/') + 1) + "." + mm.methodName().stringValue();
cb.transformMethod(mm, MethodTransform.transformingCode(
CodeTransform.ofStateful(() -> new Timer(label, alsoOnException))));
} else {
cb.with(ce); // everything else is copied unchanged
}
};
return cf.transformClass(model, perClass);
}
/** One instance per method body (ofStateful), so the local-variable slot and labels are per method. */
private static final class Timer implements CodeTransform {
private final String label;
private final boolean alsoOnException;
private int startSlot;
private Label tryStart, tryEnd;
Timer(String label, boolean alsoOnException) {
this.label = label;
this.alsoOnException = alsoOnException;
}
@Override
public void atStart(CodeBuilder b) {
startSlot = b.allocateLocal(TypeKind.LONG); // a fresh local; max_locals is recomputed for us
b.invokestatic(SYSTEM, "nanoTime", NANO_TIME).lstore(startSlot);
if (alsoOnException) {
tryStart = b.newLabel();
tryEnd = b.newLabel();
b.labelBinding(tryStart);
}
}
@Override
public void accept(CodeBuilder b, CodeElement e) {
if (e instanceof ReturnInstruction) {
emitRecord(b); // the return value is on the stack; our code is stack-neutral
}
b.with(e); // forward the original element
}
@Override
public void atEnd(CodeBuilder b) {
if (!alsoOnException) return;
b.labelBinding(tryEnd);
Label handler = b.newLabel();
b.labelBinding(handler);
int ex = b.allocateLocal(TypeKind.REFERENCE);
b.astore(ex);
emitRecord(b);
b.aload(ex).athrow();
b.exceptionCatchAll(tryStart, tryEnd, handler); // (start, end, handler): catch-any over the original body
}
private void emitRecord(CodeBuilder b) {
b.ldc(label)
.invokestatic(SYSTEM, "nanoTime", NANO_TIME)
.lload(startSlot)
.lsub()
.invokestatic(TIMINGS, "record", RECORD);
}
}
}
@@ -0,0 +1,31 @@
package demo;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
/** Sink for the timing code we inject. Public, because the injected bytecode calls it from another class loader. */
public final class Timings {
public record Stat(long count, long totalNanos) { }
private static final Map<String, LongAdder> COUNT = new ConcurrentHashMap<>();
private static final Map<String, LongAdder> NANOS = new ConcurrentHashMap<>();
private Timings() { }
/** Called from instrumented code. Signature (String, long) is mirrored in the ClassFile and ASM transformers. */
public static void record(String method, long nanos) {
COUNT.computeIfAbsent(method, k -> new LongAdder()).increment();
NANOS.computeIfAbsent(method, k -> new LongAdder()).add(nanos);
}
public static Stat get(String method) {
LongAdder c = COUNT.get(method);
return c == null ? new Stat(0, 0) : new Stat(c.sum(), NANOS.get(method).sum());
}
public static void reset() {
COUNT.clear();
NANOS.clear();
}
}
@@ -0,0 +1,86 @@
package demo;
import java.lang.classfile.ClassFile;
import java.lang.invoke.MethodHandles;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/** Instrument demo.Target with TimingTransform, load the result in an isolated class loader, call it, read the timings. */
public class TransformDemo {
/** Defines `transformed` as demo.Target in its own loader; everything else (Timings, ...) comes from the parent. */
static final class Loader extends ClassLoader {
private final byte[] target;
Loader(byte[] target) { super(TransformDemo.class.getClassLoader()); this.target = target; }
@Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (name.equals("demo.Target")) {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) c = defineClass(name, target, 0, target.length);
return c;
}
}
return super.loadClass(name, resolve);
}
}
static void drive(Class<?> target) throws Throwable {
var fib = target.getMethod("fib", int.class);
var sum = target.getMethod("sumTo", int.class);
var boom = target.getMethod("boom");
var explode = target.getMethod("explode", int[].class);
var pause = target.getMethod("pause", long.class);
var greet = target.getMethod("greet", String.class);
Object t = target.getConstructor(String.class).newInstance("loader-2");
fib.invoke(null, 10);
sum.invoke(null, 1000);
greet.invoke(t, "Ankur");
pause.invoke(t, 50L);
try { boom.invoke(null); } catch (java.lang.reflect.InvocationTargetException e) { System.out.println(" boom() threw " + e.getCause()); }
try { explode.invoke(null, (Object) new int[3]); } catch (java.lang.reflect.InvocationTargetException e) { System.out.println(" explode() threw " + e.getCause().getClass().getSimpleName()); }
}
static void report(String title, byte[] bytes) throws Throwable {
Timings.reset();
System.out.println("-- " + title + " (" + bytes.length + " bytes; verify() problems: " + ClassFile.of().verify(bytes).size() + ")");
Class<?> c = new Loader(bytes).loadClass("demo.Target");
Check.that(c != Target.class, title + ": demo.Target here is a different Class object than the one on the class path");
drive(c);
for (String m : List.of("fib", "sumTo", "greet", "pause", "boom", "explode")) {
Timings.Stat s = Timings.get("Target." + m);
System.out.printf(" %-8s recorded %3d call(s)%n", m, s.count());
}
}
public static void main(String[] args) throws Throwable {
byte[] original = Bytes.of(Target.class);
Path dir = Path.of(args.length > 0 ? args[0] : "target/instrumented");
Files.createDirectories(dir.resolve("naive/demo"));
Files.createDirectories(dir.resolve("full/demo"));
byte[] naive = TimingTransform.instrument(original, false);
byte[] full = TimingTransform.instrument(original, true);
Files.write(dir.resolve("naive/demo/Target.class"), naive);
Files.write(dir.resolve("full/demo/Target.class"), full);
System.out.println("original " + original.length + " bytes, naive " + naive.length + " bytes, with exception handler " + full.length + " bytes");
report("naive: time normal returns only", naive);
Timings.Stat naiveBoom = Timings.get("Target.boom"), naiveExplode = Timings.get("Target.explode"), naiveFib = Timings.get("Target.fib");
Check.that(naiveBoom.count() == 0 && naiveExplode.count() == 0, "naive version never records the two methods that throw");
Check.that(naiveFib.count() == 177, "fib(10) makes 177 calls and 177 timings were recorded (recursion included)");
report("full: also time when an exception leaves the method", full);
Check.that(Timings.get("Target.boom").count() == 1 && Timings.get("Target.explode").count() == 1, "exception-safe version records boom() and explode() once each");
Check.that(Timings.get("Target.fib").count() == 177, "fib still recorded 177 times");
Check.that(Timings.get("Target.sumTo").count() == 1 && Timings.get("Target.greet").count() == 1, "sumTo and greet recorded once each");
Check.that(Timings.get("Target.pause").totalNanos() >= 50_000_000L, "pause(50) was recorded as at least 50 ms (>= 50,000,000 ns)");
// Behaviour is unchanged: the transformed class still computes the same answers.
Class<?> c = new Loader(full).loadClass("demo.Target");
Check.that((int) c.getMethod("fib", int.class).invoke(null, 20) == Target.fib(20), "fib(20) gives the same answer before and after");
Check.that(((Long) c.getMethod("sumTo", int.class).invoke(null, 12345)) == Target.sumTo(12345), "sumTo(12345) gives the same answer before and after");
Check.finish();
}
}
@@ -0,0 +1,55 @@
package demo;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.Opcodes;
import java.lang.classfile.ClassFile;
/** What happens when the class file is newer than the reader. Patches the major-version bytes of Target.class. */
public class VersionLimits {
static byte[] withMajor(byte[] original, int major) {
byte[] b = original.clone();
b[6] = (byte) (major >> 8);
b[7] = (byte) major;
return b;
}
static String tryClassFile(byte[] b) {
try {
var cm = ClassFile.of().parse(b);
return "parsed, " + cm.methods().size() + " methods";
} catch (Throwable t) {
return t.getClass().getName() + ": " + t.getMessage();
}
}
static String tryAsm(byte[] b) {
try {
int[] n = {0};
new ClassReader(b).accept(new ClassVisitor(Opcodes.ASM9) {
@Override public org.objectweb.asm.MethodVisitor visitMethod(int a, String nm, String d, String s, String[] e) { n[0]++; return null; }
}, 0);
return "parsed, " + n[0] + " methods";
} catch (Throwable t) {
return t.getClass().getName() + ": " + t.getMessage();
}
}
public static void main(String[] args) {
byte[] t = Bytes.of(Target.class);
System.out.println("running on Java " + Runtime.version().feature() + "; ClassFile.latestMajorVersion() = " + ClassFile.latestMajorVersion()
+ "; ASM Opcodes.V25=" + Opcodes.V25 + ", V28=" + Opcodes.V28);
for (int major : new int[]{69, 70, 72, 73, 99}) {
byte[] b = withMajor(t, major);
System.out.println("major " + major + ":");
System.out.println(" java.lang.classfile : " + tryClassFile(b));
System.out.println(" ASM 9.11.1 : " + tryAsm(b));
}
Check.that(tryClassFile(withMajor(t, 69)).startsWith("parsed"), "major 69 parses with java.lang.classfile");
Check.that(tryClassFile(withMajor(t, ClassFile.latestMajorVersion() + 1)).startsWith("java.lang.IllegalArgumentException"), "one version above the JDK's own is rejected by java.lang.classfile");
Check.that(tryAsm(withMajor(t, Opcodes.V28)).startsWith("parsed"), "ASM 9.11.1 still reads major 72 (Java 28)");
Check.that(tryAsm(withMajor(t, Opcodes.V28 + 1)).startsWith("java.lang.IllegalArgumentException"), "ASM 9.11.1 rejects major 73");
Check.finish();
}
}