diff --git a/README.md b/README.md index 26682cf..2039c7a 100644 --- a/README.md +++ b/README.md @@ -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/`) diff --git a/classfile-api/.gitignore b/classfile-api/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/classfile-api/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/classfile-api/README.md b/classfile-api/README.md new file mode 100644 index 0000000..4d58351 --- /dev/null +++ b/classfile-api/README.md @@ -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. diff --git a/classfile-api/output/01-parse.txt b/classfile-api/output/01-parse.txt new file mode 100644 index 0000000..25b23d5 --- /dev/null +++ b/classfile-api/output/01-parse.txt @@ -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 (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 + 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. + 1 x java/lang/Object. + 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 diff --git a/classfile-api/output/02-generate.txt b/classfile-api/output/02-generate.txt new file mode 100644 index 0000000..35774d6 --- /dev/null +++ b/classfile-api/output/02-generate.txt @@ -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 diff --git a/classfile-api/output/03-javap-generated.txt b/classfile-api/output/03-javap-generated.txt new file mode 100644 index 0000000..dcc6e29 --- /dev/null +++ b/classfile-api/output/03-javap-generated.txt @@ -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 + #4 = Utf8 ()V + #5 = Utf8 java/lang/Object + #6 = Class #5 // java/lang/Object + #7 = NameAndType #3:#4 // "":()V + #8 = Methodref #6.#7 // java/lang/Object."":()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."":()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 ] +} diff --git a/classfile-api/output/04-broken-generate.txt b/classfile-api/output/04-broken-generate.txt new file mode 100644 index 0000000..da2cc62 --- /dev/null +++ b/classfile-api/output/04-broken-generate.txt @@ -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 diff --git a/classfile-api/output/05-transform.txt b/classfile-api/output/05-transform.txt new file mode 100644 index 0000000..9db58d0 --- /dev/null +++ b/classfile-api/output/05-transform.txt @@ -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 diff --git a/classfile-api/output/06-javap-instrumented.txt b/classfile-api/output/06-javap-instrumented.txt new file mode 100644 index 0000000..2fb3db2 --- /dev/null +++ b/classfile-api/output/06-javap-instrumented.txt @@ -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."":(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."":(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."":(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 + diff --git a/classfile-api/output/07-api-diff-24-25-26.txt b/classfile-api/output/07-api-diff-24-25-26.txt new file mode 100644 index 0000000..d1d6e8e --- /dev/null +++ b/classfile-api/output/07-api-diff-24-25-26.txt @@ -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.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.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 diff --git a/classfile-api/output/08-asm-parse.txt b/classfile-api/output/08-asm-parse.txt new file mode 100644 index 0000000..a9e817b --- /dev/null +++ b/classfile-api/output/08-asm-parse.txt @@ -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 (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. + 1 x java/lang/Object. + 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 diff --git a/classfile-api/output/09-asm-generate.txt b/classfile-api/output/09-asm-generate.txt new file mode 100644 index 0000000..8dc5510 --- /dev/null +++ b/classfile-api/output/09-asm-generate.txt @@ -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 diff --git a/classfile-api/output/10-asm-instrument.txt b/classfile-api/output/10-asm-instrument.txt new file mode 100644 index 0000000..0373316 --- /dev/null +++ b/classfile-api/output/10-asm-instrument.txt @@ -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 diff --git a/classfile-api/output/11-version-limits.txt b/classfile-api/output/11-version-limits.txt new file mode 100644 index 0000000..f4fb32f --- /dev/null +++ b/classfile-api/output/11-version-limits.txt @@ -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 diff --git a/classfile-api/output/12-line-counts.txt b/classfile-api/output/12-line-counts.txt new file mode 100644 index 0000000..ede9ad5 --- /dev/null +++ b/classfile-api/output/12-line-counts.txt @@ -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 diff --git a/classfile-api/output/13-bench.txt b/classfile-api/output/13-bench.txt new file mode 100644 index 0000000..710925d --- /dev/null +++ b/classfile-api/output/13-bench.txt @@ -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) diff --git a/classfile-api/output/14-spring-core-probe.txt b/classfile-api/output/14-spring-core-probe.txt new file mode 100644 index 0000000..c6e63a1 --- /dev/null +++ b/classfile-api/output/14-spring-core-probe.txt @@ -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 !/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 diff --git a/classfile-api/output/15-asm-expand-frames.txt b/classfile-api/output/15-asm-expand-frames.txt new file mode 100644 index 0000000..3811db8 --- /dev/null +++ b/classfile-api/output/15-asm-expand-frames.txt @@ -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 diff --git a/classfile-api/pom.xml b/classfile-api/pom.xml new file mode 100644 index 0000000..4450aea --- /dev/null +++ b/classfile-api/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + com.ankurm.javademos + classfile-api + 1.0.0 + jar + classfile-api + java.lang.classfile (JEP 484, final in JDK 24): read, generate and transform bytecode, compared with ASM + + + 25 + UTF-8 + 9.11.1 + + + + + org.ow2.asmasm${asm.version} + org.ow2.asmasm-commons${asm.version} + org.ow2.asmasm-util${asm.version} + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + -Xlint:all + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + + diff --git a/classfile-api/scripts/api_diff.py b/classfile-api/scripts/api_diff.py new file mode 100644 index 0000000..5803d69 --- /dev/null +++ b/classfile-api/scripts/api_diff.py @@ -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)") diff --git a/classfile-api/scripts/run-all.sh b/classfile-api/scripts/run-all.sh new file mode 100755 index 0000000..57c380d --- /dev/null +++ b/classfile-api/scripts/run-all.sh @@ -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
[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 + 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 diff --git a/classfile-api/scripts/spring-core-probe.sh b/classfile-api/scripts/spring-core-probe.sh new file mode 100755 index 0000000..fb34106 --- /dev/null +++ b/classfile-api/scripts/spring-core-probe.sh @@ -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 !/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" diff --git a/classfile-api/src/main/java/demo/AsmGenerate.java b/classfile-api/src/main/java/demo/AsmGenerate.java new file mode 100644 index 0000000..22433fc --- /dev/null +++ b/classfile-api/src/main/java/demo/AsmGenerate.java @@ -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, "", "()V", null, null); + mv.visitCode(); + mv.visitVarInsn(ALOAD, 0); + mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "", "()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(); + } +} diff --git a/classfile-api/src/main/java/demo/AsmGotcha.java b/classfile-api/src/main/java/demo/AsmGotcha.java new file mode 100644 index 0000000..b0ebbe9 --- /dev/null +++ b/classfile-api/src/main/java/demo/AsmGotcha.java @@ -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(); + } +} diff --git a/classfile-api/src/main/java/demo/AsmInstrument.java b/classfile-api/src/main/java/demo/AsmInstrument.java new file mode 100644 index 0000000..a04c192 --- /dev/null +++ b/classfile-api/src/main/java/demo/AsmInstrument.java @@ -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("") || (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(); + } +} diff --git a/classfile-api/src/main/java/demo/AsmParse.java b/classfile-api/src/main/java/demo/AsmParse.java new file mode 100644 index 0000000..2567e99 --- /dev/null +++ b/classfile-api/src/main/java/demo/AsmParse.java @@ -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 methodLines, TreeMap invokes, int major) { } + + static Result parse(byte[] bytes) { + List lines = new ArrayList<>(); + TreeMap 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 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(); + } +} diff --git a/classfile-api/src/main/java/demo/Bench.java b/classfile-api/src/main/java/demo/Bench.java new file mode 100644 index 0000000..59658fa --- /dev/null +++ b/classfile-api/src/main/java/demo/Bench.java @@ -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 classes, ToLongFunction 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 classes = new ArrayList<>(); + try (Stream s = Files.walk(Bytes.jrtRoot("java.base"))) { + for (Path p : (Iterable) 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(); + } +} diff --git a/classfile-api/src/main/java/demo/BrokenGenerate.java b/classfile-api/src/main/java/demo/BrokenGenerate.java new file mode 100644 index 0000000..05c70c0 --- /dev/null +++ b/classfile-api/src/main/java/demo/BrokenGenerate.java @@ -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 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 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(); + } +} diff --git a/classfile-api/src/main/java/demo/Bytes.java b/classfile-api/src/main/java/demo/Bytes.java new file mode 100644 index 0000000..730d374 --- /dev/null +++ b/classfile-api/src/main/java/demo/Bytes.java @@ -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); + } +} diff --git a/classfile-api/src/main/java/demo/Check.java b/classfile-api/src/main/java/demo/Check.java new file mode 100644 index 0000000..0ad6f27 --- /dev/null +++ b/classfile-api/src/main/java/demo/Check.java @@ -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); + } + } +} diff --git a/classfile-api/src/main/java/demo/GenerateDemo.java b/classfile-api/src/main/java/demo/GenerateDemo.java new file mode 100644 index 0000000..73ffd36 --- /dev/null +++ b/classfile-api/src/main/java/demo/GenerateDemo.java @@ -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 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(); + } +} diff --git a/classfile-api/src/main/java/demo/ParseDemo.java b/classfile-api/src/main/java/demo/ParseDemo.java new file mode 100644 index 0000000..27adf82 --- /dev/null +++ b/classfile-api/src/main/java/demo/ParseDemo.java @@ -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 methodLines(ClassModel cm) { + List 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 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 invokeCounts(ClassModel cm) { + TreeMap 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 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(); + } +} diff --git a/classfile-api/src/main/java/demo/Target.java b/classfile-api/src/main/java/demo/Target.java new file mode 100644 index 0000000..95e315a --- /dev/null +++ b/classfile-api/src/main/java/demo/Target.java @@ -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; + } +} diff --git a/classfile-api/src/main/java/demo/TimingTransform.java b/classfile-api/src/main/java/demo/TimingTransform.java new file mode 100644 index 0000000..18e6ff7 --- /dev/null +++ b/classfile-api/src/main/java/demo/TimingTransform.java @@ -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. + * + *
+ *   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
+ * 
+ */ +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("")) { + 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); + } + } +} diff --git a/classfile-api/src/main/java/demo/Timings.java b/classfile-api/src/main/java/demo/Timings.java new file mode 100644 index 0000000..6da312a --- /dev/null +++ b/classfile-api/src/main/java/demo/Timings.java @@ -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 COUNT = new ConcurrentHashMap<>(); + private static final Map 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(); + } +} diff --git a/classfile-api/src/main/java/demo/TransformDemo.java b/classfile-api/src/main/java/demo/TransformDemo.java new file mode 100644 index 0000000..ed448e8 --- /dev/null +++ b/classfile-api/src/main/java/demo/TransformDemo.java @@ -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(); + } +} diff --git a/classfile-api/src/main/java/demo/VersionLimits.java b/classfile-api/src/main/java/demo/VersionLimits.java new file mode 100644 index 0000000..895235e --- /dev/null +++ b/classfile-api/src/main/java/demo/VersionLimits.java @@ -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(); + } +}