commit 98e01bed4bda30728143fd6a7224a78ea9945f29 Author: Ankur Mhatre Date: Thu Oct 8 04:28:50 2026 +0000 framework-benchmarks: Spring Boot 4.1.1 vs Quarkus 3.40.1 vs Micronaut 5.2.2, JVM + native, benchmarked diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac008a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +*.class +*.log +.idea/ +*.iml +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aa5473f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ankur Mhatre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c6da74 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# framework-benchmarks + +Three identical REST APIs - Spring Boot, Quarkus, Micronaut - built JVM and native, +and benchmarked for cold-start time, resident memory, and throughput. Companion repo +for the ankurm.com post "Spring Boot 4.1 vs Quarkus vs Micronaut: Startup, Memory and +Throughput Benchmarked." + +No `docs/` chapter folder here by design - every finding below is written up in full +in the post itself, inside per-section `
` accordions. This repo carries the +runnable code and the raw captured output those sections link to. + +## Versions under test + +| Component | Version | Verified against | +|---|---|---| +| Spring Boot | 4.1.1 | `spring-boot-dependencies` Maven Central metadata | +| Quarkus | 3.40.1 (the 3.40 LTS stream) | `quarkus-bom` Maven Central metadata + quarkus.io/releases | +| Micronaut | 5.2.2 | `micronaut-platform` Maven Central metadata | +| GraalVM | CE 25.4.4.1.1 (OpenJDK 25.0.4.1.1) | graalvm/graalvm-ce-builds GitHub releases (latest at build time) | +| JDK baseline | 25 (current LTS) | all three apps compiled and run on the same GraalVM JDK 25 | + +## Quickstart + +```bash +# JVM mode +cd spring-boot-app && mvn -q package -DskipTests && java -jar target/spring-boot-app.jar # :8081 +cd quarkus-app && mvn -q package -DskipTests && java -jar target/quarkus-app/quarkus-run.jar # :8082 +cd micronaut-app && mvn -q package -DskipTests && java -jar target/micronaut-app.jar # :8083 + +# Native mode (needs GraalVM's native-image on PATH; budget ~7-8 min per app) +cd spring-boot-app && mvn -q -Pnative package -DskipTests && mvn -q -Pnative native:compile && ./target/spring-boot-app +cd quarkus-app && mvn -q -Pnative clean package -DskipTests && ./target/quarkus-app-runner +cd micronaut-app && mvn -q -Pnative clean package -DskipTests \ + && mvn -q -Pnative package org.graalvm.buildtools:native-maven-plugin:compile -DskipTests \ + && ./target/micronaut-app +``` + +`scripts/run-all.sh` does all of the above plus every benchmark, end to end, and +regenerates every file in `docs/output/`. + +## Modules + +| Module | Framework | JVM port | Build profile for native | +|---|---|---|---| +| `spring-boot-app` | Spring Boot 4.1.1, Tomcat, Spring MVC | 8081 | `-Pnative`, then explicit `native:compile` (see below) | +| `quarkus-app` | Quarkus 3.40.1, Quarkus REST (RESTEasy Reactive), Vert.x | 8082 | `-Pnative` | +| `micronaut-app` | Micronaut 5.2.2, Netty, Micronaut Serialization (compile-time, not reflection) | 8083 | `-Pnative` + explicit `native-maven-plugin:compile` (see below) | + +All three expose the identical endpoints, same JSON shapes, same seed data, no database: + +| Method | Path | Behaviour | +|---|---|---| +| `GET` | `/widgets` | list all widgets (seeded with 5) | +| `GET` | `/widgets/{id}` | one widget, 404 if unknown | +| `POST` | `/widgets` | create (`{"name": "...", "quantity": N}`), 400 on blank name or negative quantity | +| `GET` | `/health` (Micronaut) / `/actuator/health` (Spring Boot) / `/q/health` (Quarkus) | trivial liveness check - deliberately each framework's own normal way to add one, not forced to be identical, since that difference is itself one of the post's findings | + +No database on purpose: it isolates framework/runtime startup overhead from JDBC +driver and native-image reflection-metadata concerns, which is a separate, larger +topic than this post covers. + +## The two real build failures this repo hit (and the fixes, committed as-is) + +1. **Micronaut silently ignored `application.yml`.** `micronaut-yaml` (which wraps + snakeyaml) isn't a transitive dependency of `micronaut-http-server-netty` - without + it, Micronaut skips YAML config files with no warning and falls back to every + default (here, port 8080 instead of the configured 8083). Fixed by using + `application.properties` instead, matching the other two modules. See the post's + Micronaut section for the full story. + +2. **Micronaut's native-image build failed four times in a row** on a Netty/logback + static-initialization clash before `--initialize-at-run-time=io.netty` (the + whole package) fixed it. All four real `native-image` error messages are in + `docs/output/06-micronaut-native-build-failures.txt`, verbatim. The working build + argument lives in `micronaut-app/pom.xml`'s `native` profile. + +Also note: neither `native-maven-plugin` nor Spring Boot's own native profile +auto-binds `native-image` to a Maven phase in a plain multi-module layout the way +`mvn -Pnative package` alone implies - both modules need the goal invoked explicitly +(`native:compile`, or Micronaut's `native-maven-plugin:compile`). See `scripts/run-all.sh` +for the exact commands that actually trigger a native build end to end. + +## Captured output + +| File | What it is | +|---|---| +| `docs/output/01-startup-memory-jvm.txt` | All 3 frameworks, JVM mode, 4 cold-start runs each, real `measure.py` JSON | +| `docs/output/02-throughput-jvm.txt` | All 3 frameworks, JVM mode, 15s/20-concurrency throughput runs | +| `docs/output/03-startup-memory-native.txt` | Same as 01, native mode | +| `docs/output/04-throughput-native.txt` | Same as 02, native mode | +| `docs/output/05-summary.txt` | Consolidated table, hand-written from the four files above | +| `docs/output/06-micronaut-native-build-failures.txt` | The four real native-image build attempts for Micronaut, verbatim | + +`scripts/measure.py` and `scripts/throughput.py` are dependency-free Python (stdlib +only) - no `wrk`/`hey`/`ab` install required to reproduce any number in this repo. + +## License + +MIT diff --git a/docs/output/01-startup-memory-jvm.txt b/docs/output/01-startup-memory-jvm.txt new file mode 100644 index 0000000..f58aabf --- /dev/null +++ b/docs/output/01-startup-memory-jvm.txt @@ -0,0 +1,18 @@ +=== Spring Boot JVM startup+memory (4 runs) === +{"label": "spring-boot-jvm", "ready": true, "served_ok": true, "startup_s": 6.117, "rss_kb": 233324, "run": 1, "discarded": true} +{"label": "spring-boot-jvm", "ready": true, "served_ok": true, "startup_s": 6.358, "rss_kb": 234664, "run": 2, "discarded": false} +{"label": "spring-boot-jvm", "ready": true, "served_ok": true, "startup_s": 6.308, "rss_kb": 236924, "run": 3, "discarded": false} +{"label": "spring-boot-jvm", "ready": true, "served_ok": true, "startup_s": 6.287, "rss_kb": 232612, "run": 4, "discarded": false} +{"label": "spring-boot-jvm", "summary": true, "runs_kept": 3, "avg_startup_s": 6.318, "avg_rss_kb": 234733.3, "avg_rss_mb": 229.2} +=== Quarkus JVM startup+memory (4 runs) === +{"label": "quarkus-jvm", "ready": true, "served_ok": true, "startup_s": 2.173, "rss_kb": 171968, "run": 1, "discarded": true} +{"label": "quarkus-jvm", "ready": true, "served_ok": true, "startup_s": 2.084, "rss_kb": 175640, "run": 2, "discarded": false} +{"label": "quarkus-jvm", "ready": true, "served_ok": true, "startup_s": 2.19, "rss_kb": 176004, "run": 3, "discarded": false} +{"label": "quarkus-jvm", "ready": true, "served_ok": true, "startup_s": 2.282, "rss_kb": 175364, "run": 4, "discarded": false} +{"label": "quarkus-jvm", "summary": true, "runs_kept": 3, "avg_startup_s": 2.185, "avg_rss_kb": 175669.3, "avg_rss_mb": 171.6} +=== Micronaut JVM startup+memory (4 runs) === +{"label": "micronaut-jvm", "ready": true, "served_ok": true, "startup_s": 2.465, "rss_kb": 183740, "run": 1, "discarded": true} +{"label": "micronaut-jvm", "ready": true, "served_ok": true, "startup_s": 2.648, "rss_kb": 184576, "run": 2, "discarded": false} +{"label": "micronaut-jvm", "ready": true, "served_ok": true, "startup_s": 2.372, "rss_kb": 183996, "run": 3, "discarded": false} +{"label": "micronaut-jvm", "ready": true, "served_ok": true, "startup_s": 2.502, "rss_kb": 184316, "run": 4, "discarded": false} +{"label": "micronaut-jvm", "summary": true, "runs_kept": 3, "avg_startup_s": 2.507, "avg_rss_kb": 184296.0, "avg_rss_mb": 180.0} diff --git a/docs/output/02-throughput-jvm.txt b/docs/output/02-throughput-jvm.txt new file mode 100644 index 0000000..aaec66c --- /dev/null +++ b/docs/output/02-throughput-jvm.txt @@ -0,0 +1,42 @@ +=== Spring Boot JVM throughput (15s, concurrency 20) === +{ + "label": "spring-boot-jvm", + "path": "/widgets", + "concurrency": 20, + "duration_s": 15.01, + "requests_ok": 34158, + "requests_err": 0, + "requests_per_sec": 2275.7, + "latency_ms_mean": 8.78, + "latency_ms_p50": 5.6, + "latency_ms_p95": 26.83, + "latency_ms_p99": 57.06 +} +=== Quarkus JVM throughput (15s, concurrency 20) === +{ + "label": "quarkus-jvm", + "path": "/widgets", + "concurrency": 20, + "duration_s": 15.01, + "requests_ok": 56912, + "requests_err": 0, + "requests_per_sec": 3791.4, + "latency_ms_mean": 5.27, + "latency_ms_p50": 4.6, + "latency_ms_p95": 10.55, + "latency_ms_p99": 19.49 +} +=== Micronaut JVM throughput (15s, concurrency 20) === +{ + "label": "micronaut-jvm", + "path": "/widgets", + "concurrency": 20, + "duration_s": 15.01, + "requests_ok": 64180, + "requests_err": 0, + "requests_per_sec": 4276.2, + "latency_ms_mean": 4.67, + "latency_ms_p50": 3.99, + "latency_ms_p95": 11.52, + "latency_ms_p99": 19.7 +} diff --git a/docs/output/03-startup-memory-native.txt b/docs/output/03-startup-memory-native.txt new file mode 100644 index 0000000..e6853c4 --- /dev/null +++ b/docs/output/03-startup-memory-native.txt @@ -0,0 +1,18 @@ +=== Spring Boot NATIVE startup+memory (4 runs) === +{"label": "spring-boot-native", "ready": true, "served_ok": true, "startup_s": 0.174, "rss_kb": 81420, "run": 1, "discarded": true} +{"label": "spring-boot-native", "ready": true, "served_ok": true, "startup_s": 0.133, "rss_kb": 81384, "run": 2, "discarded": false} +{"label": "spring-boot-native", "ready": true, "served_ok": true, "startup_s": 0.121, "rss_kb": 81408, "run": 3, "discarded": false} +{"label": "spring-boot-native", "ready": true, "served_ok": true, "startup_s": 0.143, "rss_kb": 81408, "run": 4, "discarded": false} +{"label": "spring-boot-native", "summary": true, "runs_kept": 3, "avg_startup_s": 0.132, "avg_rss_kb": 81400.0, "avg_rss_mb": 79.5} +=== Quarkus NATIVE startup+memory (4 runs) === +{"label": "quarkus-native", "ready": true, "served_ok": true, "startup_s": 0.048, "rss_kb": 34368, "run": 1, "discarded": true} +{"label": "quarkus-native", "ready": true, "served_ok": true, "startup_s": 0.035, "rss_kb": 34368, "run": 2, "discarded": false} +{"label": "quarkus-native", "ready": true, "served_ok": true, "startup_s": 0.036, "rss_kb": 34348, "run": 3, "discarded": false} +{"label": "quarkus-native", "ready": true, "served_ok": true, "startup_s": 0.034, "rss_kb": 34356, "run": 4, "discarded": false} +{"label": "quarkus-native", "summary": true, "runs_kept": 3, "avg_startup_s": 0.035, "avg_rss_kb": 34357.3, "avg_rss_mb": 33.6} +=== Micronaut NATIVE startup+memory (4 runs) === +{"label": "micronaut-native", "ready": true, "served_ok": true, "startup_s": 0.049, "rss_kb": 47332, "run": 1, "discarded": true} +{"label": "micronaut-native", "ready": true, "served_ok": true, "startup_s": 0.037, "rss_kb": 47304, "run": 2, "discarded": false} +{"label": "micronaut-native", "ready": true, "served_ok": true, "startup_s": 0.05, "rss_kb": 47328, "run": 3, "discarded": false} +{"label": "micronaut-native", "ready": true, "served_ok": true, "startup_s": 0.036, "rss_kb": 47324, "run": 4, "discarded": false} +{"label": "micronaut-native", "summary": true, "runs_kept": 3, "avg_startup_s": 0.041, "avg_rss_kb": 47318.7, "avg_rss_mb": 46.2} diff --git a/docs/output/04-throughput-native.txt b/docs/output/04-throughput-native.txt new file mode 100644 index 0000000..55723e4 --- /dev/null +++ b/docs/output/04-throughput-native.txt @@ -0,0 +1,42 @@ +=== Spring Boot NATIVE throughput (15s, concurrency 20) === +{ + "label": "spring-boot-native", + "path": "/widgets", + "concurrency": 20, + "duration_s": 15.0, + "requests_ok": 71947, + "requests_err": 0, + "requests_per_sec": 4795.1, + "latency_ms_mean": 4.16, + "latency_ms_p50": 3.37, + "latency_ms_p95": 10.17, + "latency_ms_p99": 18.1 +} +=== Quarkus NATIVE throughput (15s, concurrency 20) === +{ + "label": "quarkus-native", + "path": "/widgets", + "concurrency": 20, + "duration_s": 15.01, + "requests_ok": 63690, + "requests_err": 0, + "requests_per_sec": 4243.8, + "latency_ms_mean": 4.7, + "latency_ms_p50": 3.81, + "latency_ms_p95": 11.56, + "latency_ms_p99": 16.78 +} +=== Micronaut NATIVE throughput (15s, concurrency 20) === +{ + "label": "micronaut-native", + "path": "/widgets", + "concurrency": 20, + "duration_s": 15.0, + "requests_ok": 74439, + "requests_err": 0, + "requests_per_sec": 4961.2, + "latency_ms_mean": 4.02, + "latency_ms_p50": 3.01, + "latency_ms_p95": 10.41, + "latency_ms_p99": 15.85 +} diff --git a/docs/output/05-summary.txt b/docs/output/05-summary.txt new file mode 100644 index 0000000..5dde561 --- /dev/null +++ b/docs/output/05-summary.txt @@ -0,0 +1,45 @@ +framework-benchmarks — consolidated results +Sandbox: 2 vCPU / 7.8GiB RAM Linux container, GraalVM CE 25.4.4.1.1 (JDK 25.0.4.1.1) +Versions under test: Spring Boot 4.1.1, Quarkus 3.40.1 (LTS), Micronaut 5.2.2 +All numbers are the average of 3 kept runs after discarding one cold-cache run (startup/memory), +or one 15-second run at concurrency 20 (throughput). Raw per-run JSON in 01-04 in this directory. + +STARTUP TIME (seconds, cold process to first 200 response) + framework JVM native native is Nx faster + spring-boot 6.318 0.132 47.9x + quarkus 2.185 0.035 62.4x + micronaut 2.507 0.041 61.1x + +RESIDENT MEMORY (MB, RSS right after first successful request) + framework JVM native native is Nx smaller + spring-boot 229.2 79.5 2.9x + quarkus 171.6 33.6 5.1x + micronaut 180.0 46.2 3.9x + +THROUGHPUT (requests/sec, GET /widgets, 20 concurrent stdlib-http.client workers, 15s) + framework JVM req/s native req/s + spring-boot 2275.7 4795.1 + quarkus 3791.4 4243.8 + micronaut 4276.2 4961.2 + +DEPLOYMENT ARTIFACT SIZE (MB) + framework JVM (fat jar / fast-jar dir) native executable + spring-boot 23 73 + quarkus 21 44 + micronaut 14 54 + +NATIVE BUILD TIME (wall clock, 2 vCPU, this sandbox — native-image is CPU-bound and +parallelizes with available cores, so a bigger CI runner will be meaningfully faster) + quarkus: 6m22s (succeeded on the first attempt) + micronaut: 7m15s (succeeded on the 4th attempt — 3 earlier attempts failed in + ~42-50s each over a Netty/logback static-initialization clash; + see docs/output/06-micronaut-native-build-failures.txt) + spring-boot: 8m25s (succeeded on the first attempt, after routing around the + native-maven-plugin's execution binding — see repo README) + +Cross-checks against the independent 2025-10 benchmark by gillius.org (different +hardware: Apple M2 Pro laptop vs this shared 2-vCPU container; different app: a +Postgres-backed CRUD app vs this repo's deliberately DB-free one; Spring Boot 3.5.6/ +Quarkus 3.28.1/Micronaut 4.5.4 vs the current GA versions here) — see post for the full +comparison table. Qualitatively consistent: native beats JVM mode by roughly one to two +orders of magnitude on both startup and memory, in both benchmarks, on both machines. diff --git a/docs/output/06-micronaut-native-build-failures.txt b/docs/output/06-micronaut-native-build-failures.txt new file mode 100644 index 0000000..8be7fcf --- /dev/null +++ b/docs/output/06-micronaut-native-build-failures.txt @@ -0,0 +1,62 @@ +Micronaut native-image build: the three failed attempts before the fix, verbatim +error text from the real `native-image` invocations (trimmed to the diagnostic +message; the full stack scan is in each build's svm_err_*.md, not committed since +it also repeats the full classpath). + +=== Attempt 1 (no --initialize-at-build-time / --initialize-at-run-time args at all) === +Failed generating 'micronaut-app' after 42.4s. + +> com.oracle.graal.pointsto.util.ParallelExecutionException: An object of type 'ch.qos.logback.classic.Logger' was found in the image heap. This type, however, is marked for initialization at image run time for the following reason: classes are initialized at run time by default. +This is not allowed for correctness reasons: All objects that are stored in the image heap must be initialized at build time. +... +Object was reached by + scanning root constant ch.qos.logback.classic.Logger@7f585330: Logger[io.netty.util.ResourceLeakDetector] embedded in + + at io.netty.util.ResourceLeakDetector.needReport() [bci: -5] + +=== Attempt 2 (--initialize-at-build-time=ch.qos.logback.classic.Logger) === +Failed generating 'micronaut-app' after 46.0s. + +> com.oracle.graal.pointsto.util.ParallelExecutionException: An object of type 'ch.qos.logback.classic.LoggerContext' was found in the image heap. This type, however, is marked for initialization at image run time ... +Object was reached by + reading field ch.qos.logback.classic.Logger.loggerContext of constant + ch.qos.logback.classic.Logger@6f5cb3e9: Logger[io.netty.util.ResourceLeakDetector] + scanning root constant ch.qos.logback.classic.Logger@6f5cb3e9: Logger[io.netty.util.ResourceLeakDetector] embedded in + + at io.netty.util.ResourceLeakDetector.needReport() [bci: -5] + +=== Attempt 3 (--initialize-at-build-time=ch.qos.logback [whole package]) === +Failed generating 'micronaut-app' after 49.9s. + +> com.oracle.graal.pointsto.util.ParallelExecutionException: An object of type 'ch.qos.logback.classic.Logger' was found in the image heap. This type, however, is marked for initialization at image run time ... + (identical trace to Attempt 1 - the package-level directive lost to a more specific + --initialize-at-run-time directive for this exact class shipped in logback-classic's + own META-INF/native-image/*/native-image.properties: GraalVM lets a more specific + per-class directive override a broader package-level one, in either direction.) + +=== Attempt 4 (--initialize-at-run-time=io.netty.util.ResourceLeakDetector,io.netty.util.ResourceLeakDetectorFactory) === +Failed generating 'micronaut-app' after 42.9s. + +> com.oracle.graal.pointsto.util.ParallelExecutionException: An object of type 'io.netty.util.ResourceLeakDetector' was found in the image heap. This type, however, is marked for initialization at image run time ... +Object was reached by + scanning root constant io.netty.util.ResourceLeakDetector@2a2de2c0: io.netty.util.ResourceLeakDetector@2a2de2c0 embedded in + io.netty.buffer.AbstractByteBufAllocator.toLeakAwareBuffer(AbstractByteBufAllocator.java:53) + parsing method io.netty.buffer.AbstractByteBufAllocator.toLeakAwareBuffer(AbstractByteBufAllocator.java:53) reachable via the parsing context + at static root method.(Unknown Source) + (deferring just the leak detector to run-time wasn't enough: AbstractByteBufAllocator, + itself build-time-initialized by default, holds a static reference to one.) + +=== Fix that worked (--initialize-at-run-time=io.netty [whole package]) === +Finished generating 'micronaut-app' in 7m 15s. + 2,621 types -> 3,395 types reachable after the fix (more code stays reachable because + initialization now genuinely happens at run time instead of being folded away), image + heap 854MB peak RSS during the build, final executable 53.31MiB. + +Takeaway for the post: this is a build-time ordering conflict between two framework +defaults, not a bug in either library - Netty wants its leak detector and buffer +allocator built eagerly (for speed), logback wants its Logger/LoggerContext run-time +(so file handles and config aren't baked into the image), and the two defaults only +collide when both are on the classpath AND something (here, static root scanning +during Micronaut's own analysis) forces Netty's allocator to be build-time reachable. +Pushing the whole io.netty package to run-time init was the fix that actually stuck; +chasing individual classes one GraalVM error message at a time was not. diff --git a/micronaut-app/pom.xml b/micronaut-app/pom.xml new file mode 100644 index 0000000..1750de6 --- /dev/null +++ b/micronaut-app/pom.xml @@ -0,0 +1,129 @@ + + + 4.0.0 + + + io.micronaut.platform + micronaut-parent + 5.2.2 + + + com.ankurm.benchmarks + micronaut-app + 1.0.0 + jar + micronaut-app + Widget REST API on Micronaut 5.2.2 - framework-benchmarks + + + jar + 25 + 25 + 5.2.2 + netty + com.ankurm.benchmarks.micronaut.Application + + + + + io.micronaut + micronaut-http-server-netty + + + io.micronaut.serde + micronaut-serde-jackson + + + io.micronaut.validation + micronaut-validation + + + jakarta.annotation + jakarta.annotation-api + + + ch.qos.logback + logback-classic + runtime + + + + + micronaut-app + + + io.micronaut.maven + micronaut-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.micronaut + micronaut-http-validation + ${micronaut.core.version} + + + io.micronaut.validation + micronaut-validation-processor + ${micronaut.validation.version} + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + + ${exec.mainClass} + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + native + + + + org.graalvm.buildtools + native-maven-plugin + + micronaut-app + ${exec.mainClass} + + + --initialize-at-run-time=io.netty + + + + + + + + + diff --git a/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/Application.java b/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/Application.java new file mode 100644 index 0000000..2f80bb5 --- /dev/null +++ b/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/Application.java @@ -0,0 +1,9 @@ +package com.ankurm.benchmarks.micronaut; + +import io.micronaut.runtime.Micronaut; + +public class Application { + public static void main(String[] args) { + Micronaut.run(Application.class, args); + } +} diff --git a/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/CreateWidgetRequest.java b/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/CreateWidgetRequest.java new file mode 100644 index 0000000..47a2eac --- /dev/null +++ b/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/CreateWidgetRequest.java @@ -0,0 +1,11 @@ +package com.ankurm.benchmarks.micronaut; + +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PositiveOrZero; + +@Serdeable +public record CreateWidgetRequest( + @NotBlank String name, + @PositiveOrZero int quantity) { +} diff --git a/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/HealthController.java b/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/HealthController.java new file mode 100644 index 0000000..ab1ac77 --- /dev/null +++ b/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/HealthController.java @@ -0,0 +1,19 @@ +package com.ankurm.benchmarks.micronaut; + +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; + +import java.util.Map; + +/** + * A plain custom endpoint rather than the micronaut-management starter, to keep the three + * health checks comparably trivial instead of comparing three different libraries. + */ +@Controller("/health") +public class HealthController { + + @Get + public Map health() { + return Map.of("status", "UP"); + } +} diff --git a/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/Widget.java b/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/Widget.java new file mode 100644 index 0000000..a81ef86 --- /dev/null +++ b/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/Widget.java @@ -0,0 +1,14 @@ +package com.ankurm.benchmarks.micronaut; + +import io.micronaut.serde.annotation.Serdeable; + +/** + * The one domain type every app in this repo shares, field for field, across + * Spring Boot, Quarkus and Micronaut. See the post's "the app under test" section. + * {@code @Serdeable} is Micronaut's own compile-time (de)serialization annotation, + * the equivalent of what Jackson's reflection does automatically at runtime for the + * other two apps - this is itself one of the post's findings. + */ +@Serdeable +public record Widget(String id, String name, int quantity) { +} diff --git a/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/WidgetController.java b/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/WidgetController.java new file mode 100644 index 0000000..860ae72 --- /dev/null +++ b/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/WidgetController.java @@ -0,0 +1,38 @@ +package com.ankurm.benchmarks.micronaut; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.*; +import io.micronaut.validation.Validated; +import jakarta.validation.Valid; + +import java.net.URI; +import java.util.Collection; + +@Controller("/widgets") +@Validated +public class WidgetController { + + private final WidgetStore store; + + public WidgetController(WidgetStore store) { + this.store = store; + } + + @Get + public Collection all() { + return store.findAll(); + } + + @Get("/{id}") + public HttpResponse one(@PathVariable String id) { + return store.findById(id) + .map(HttpResponse::ok) + .orElseGet(() -> HttpResponse.notFound()); + } + + @Post + public HttpResponse create(@Valid @Body CreateWidgetRequest request) { + Widget created = store.create(request.name(), request.quantity()); + return HttpResponse.created(created, URI.create("/widgets/" + created.id())); + } +} diff --git a/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/WidgetStore.java b/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/WidgetStore.java new file mode 100644 index 0000000..50b7016 --- /dev/null +++ b/micronaut-app/src/main/java/com/ankurm/benchmarks/micronaut/WidgetStore.java @@ -0,0 +1,49 @@ +package com.ankurm.benchmarks.micronaut; + +import jakarta.inject.Singleton; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Thread-safe in-memory store, seeded identically to the Spring Boot and Quarkus versions. + * Deliberately no database: this repo isolates framework/runtime overhead from JDBC driver + * and native-image reflection-metadata concerns, which is a separate, larger topic. + */ +@Singleton +public class WidgetStore { + + private final Map widgets = new ConcurrentHashMap<>(); + private final AtomicInteger sequence = new AtomicInteger(0); + + public WidgetStore() { + seed("Hex Wrench Set", 42); + seed("Torque Screwdriver", 17); + seed("Bearing Puller", 5); + seed("Digital Caliper", 23); + seed("Bench Vise", 8); + } + + private void seed(String name, int quantity) { + String id = "w-" + sequence.incrementAndGet(); + widgets.put(id, new Widget(id, name, quantity)); + } + + public Collection findAll() { + return widgets.values(); + } + + public Optional findById(String id) { + return Optional.ofNullable(widgets.get(id)); + } + + public Widget create(String name, int quantity) { + String id = "w-" + sequence.incrementAndGet(); + Widget widget = new Widget(id, name, quantity); + widgets.put(id, widget); + return widget; + } +} diff --git a/micronaut-app/src/main/resources/application.properties b/micronaut-app/src/main/resources/application.properties new file mode 100644 index 0000000..3bfb9ca --- /dev/null +++ b/micronaut-app/src/main/resources/application.properties @@ -0,0 +1,4 @@ +micronaut.application.name=micronautApp +micronaut.server.port=8083 +endpoints.health.enabled=true +endpoints.health.sensitive=false diff --git a/micronaut-app/src/main/resources/logback.xml b/micronaut-app/src/main/resources/logback.xml new file mode 100644 index 0000000..c6e9b33 --- /dev/null +++ b/micronaut-app/src/main/resources/logback.xml @@ -0,0 +1,10 @@ + + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + + + + + diff --git a/quarkus-app/pom.xml b/quarkus-app/pom.xml new file mode 100644 index 0000000..97a0252 --- /dev/null +++ b/quarkus-app/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + com.ankurm.benchmarks + quarkus-app + 1.0.0 + quarkus-app + Widget REST API on Quarkus 3.40.1 LTS - framework-benchmarks + + + 25 + UTF-8 + io.quarkus.platform + quarkus-bom + 3.40.1 + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-hibernate-validator + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-arc + + + + + quarkus-app + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + + + + + native + + false + true + + + + + diff --git a/quarkus-app/src/main/java/com/ankurm/benchmarks/quarkus/CreateWidgetRequest.java b/quarkus-app/src/main/java/com/ankurm/benchmarks/quarkus/CreateWidgetRequest.java new file mode 100644 index 0000000..bfdd84c --- /dev/null +++ b/quarkus-app/src/main/java/com/ankurm/benchmarks/quarkus/CreateWidgetRequest.java @@ -0,0 +1,9 @@ +package com.ankurm.benchmarks.quarkus; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PositiveOrZero; + +public record CreateWidgetRequest( + @NotBlank String name, + @PositiveOrZero int quantity) { +} diff --git a/quarkus-app/src/main/java/com/ankurm/benchmarks/quarkus/Widget.java b/quarkus-app/src/main/java/com/ankurm/benchmarks/quarkus/Widget.java new file mode 100644 index 0000000..b87c05e --- /dev/null +++ b/quarkus-app/src/main/java/com/ankurm/benchmarks/quarkus/Widget.java @@ -0,0 +1,8 @@ +package com.ankurm.benchmarks.quarkus; + +/** + * The one domain type every app in this repo shares, field for field, across + * Spring Boot, Quarkus and Micronaut. See the post's "the app under test" section. + */ +public record Widget(String id, String name, int quantity) { +} diff --git a/quarkus-app/src/main/java/com/ankurm/benchmarks/quarkus/WidgetResource.java b/quarkus-app/src/main/java/com/ankurm/benchmarks/quarkus/WidgetResource.java new file mode 100644 index 0000000..8a4dd84 --- /dev/null +++ b/quarkus-app/src/main/java/com/ankurm/benchmarks/quarkus/WidgetResource.java @@ -0,0 +1,38 @@ +package com.ankurm.benchmarks.quarkus; + +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.net.URI; +import java.util.Collection; + +@Path("/widgets") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class WidgetResource { + + @Inject + WidgetStore store; + + @GET + public Collection all() { + return store.findAll(); + } + + @GET + @Path("/{id}") + public Response one(@PathParam("id") String id) { + return store.findById(id) + .map(w -> Response.ok(w).build()) + .orElseGet(() -> Response.status(Response.Status.NOT_FOUND).build()); + } + + @POST + public Response create(@Valid CreateWidgetRequest request) { + Widget created = store.create(request.name(), request.quantity()); + return Response.created(URI.create("/widgets/" + created.id())).entity(created).build(); + } +} diff --git a/quarkus-app/src/main/java/com/ankurm/benchmarks/quarkus/WidgetStore.java b/quarkus-app/src/main/java/com/ankurm/benchmarks/quarkus/WidgetStore.java new file mode 100644 index 0000000..9446be2 --- /dev/null +++ b/quarkus-app/src/main/java/com/ankurm/benchmarks/quarkus/WidgetStore.java @@ -0,0 +1,49 @@ +package com.ankurm.benchmarks.quarkus; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Thread-safe in-memory store, seeded identically to the Spring Boot and Micronaut versions. + * Deliberately no database: this repo isolates framework/runtime overhead from JDBC driver + * and native-image reflection-metadata concerns, which is a separate, larger topic. + */ +@ApplicationScoped +public class WidgetStore { + + private final Map widgets = new ConcurrentHashMap<>(); + private final AtomicInteger sequence = new AtomicInteger(0); + + public WidgetStore() { + seed("Hex Wrench Set", 42); + seed("Torque Screwdriver", 17); + seed("Bearing Puller", 5); + seed("Digital Caliper", 23); + seed("Bench Vise", 8); + } + + private void seed(String name, int quantity) { + String id = "w-" + sequence.incrementAndGet(); + widgets.put(id, new Widget(id, name, quantity)); + } + + public Collection findAll() { + return widgets.values(); + } + + public Optional findById(String id) { + return Optional.ofNullable(widgets.get(id)); + } + + public Widget create(String name, int quantity) { + String id = "w-" + sequence.incrementAndGet(); + Widget widget = new Widget(id, name, quantity); + widgets.put(id, widget); + return widget; + } +} diff --git a/quarkus-app/src/main/resources/application.properties b/quarkus-app/src/main/resources/application.properties new file mode 100644 index 0000000..9bbf1b7 --- /dev/null +++ b/quarkus-app/src/main/resources/application.properties @@ -0,0 +1,3 @@ +quarkus.http.port=8082 +quarkus.banner.enabled=false +quarkus.log.level=WARN diff --git a/scripts/measure.py b/scripts/measure.py new file mode 100644 index 0000000..283fde8 --- /dev/null +++ b/scripts/measure.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +measure.py - launches one framework-benchmarks app, times cold startup, and samples RSS +right after the app answers its first successful request. + +No external dependencies: stdlib only, so the measurement script itself is auditable +without installing anything. See docs/output/ for the real transcripts this script produced. + +Usage: + measure.py