diff --git a/README.md b/README.md
index e9fb543..b4de231 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ article; each module's own README has that article's version table, quickstart,
| [`hashmap-concurrenthashmap`](hashmap-concurrenthashmap/) | Java HashMap vs ConcurrentHashMap: Complete Interview Guide |
| [`exceptions`](exceptions/) | Java Exception Handling Deep Dive: Checked vs Unchecked, Suppression, and What Exceptions Actually Cost |
| [`regex`](regex/) | Java Regex Tutorial: Pattern, Matcher, Groups, Lookarounds and ReDoS |
+| [`serialization`](serialization/) | Java Serialization in 2026: Why It's Dangerous and What Replaced It |
## License
diff --git a/pom.xml b/pom.xml
index 94b6fef..1ef3162 100644
--- a/pom.xml
+++ b/pom.xml
@@ -24,6 +24,7 @@
hashmap-concurrenthashmap
exceptions
regex
+ serialization
diff --git a/serialization/README.md b/serialization/README.md
new file mode 100644
index 0000000..c1b4497
--- /dev/null
+++ b/serialization/README.md
@@ -0,0 +1,42 @@
+# serialization
+
+Companion code for the ankurm.com post *"Java Serialization in 2026: Why It's Dangerous and What Replaced It."*
+Module `serialization` in `java-core-examples`.
+
+All explanation lives in the post; this module holds the runnable evidence and the captured output.
+Nothing here uses a real library gadget chain: the "attacker" classes are local classes that print a line.
+
+## Versions
+
+| Component | Version |
+|---|---|
+| JDK | 25.0.4.1+1 (Temurin, LTS) |
+| Jackson (`tools.jackson.core:jackson-databind`) | 3.2.3 |
+| protobuf-java | 4.36.2 |
+| JMH | 1.37 |
+| JUnit Jupiter | 5.11.0 |
+| Hardware | 2 vCPU x86-64 VM (timings are indicative, not a leaderboard) |
+
+## Quickstart
+
+```bash
+export JDK25_HOME=/path/to/jdk-25
+./scripts/run-all.sh # rebuilds and regenerates everything in output/
+```
+
+## What is in here
+
+| File | Shows | Output |
+|---|---|---|
+| `UidInStreamDemo` | the serialVersionUID is stored in the stream; patching it gives `InvalidClassException` | `01` |
+| `src/versions/` | two versions of one class compiled separately by `run-all.sh`, without and with an explicit UID | `02`, `03` |
+| `ReadObjectRunsCodeDemo` | `readObject` of the class named in the stream runs before the cast; an allow-list filter stops it | `04` |
+| `ResourceLimitsDemo` | forged array length, deep graph, `maxarray` / `maxdepth` / `maxbytes` | `05` |
+| `RecordsDemo` | records run the canonical constructor on deserialization; ordinary classes run none | `06` |
+| `FilterFactoryDemo` | JEP 415 filter factory with a per-request context, on top of `-Djdk.serialFilter` | `07` |
+| `Codecs`, `Order`, `order.proto`, `FormatSizeDemo` | the same object as Java serialization, Jackson 3 JSON and Protobuf wire format (hand-coded, no protoc) | `08` |
+| `SerializationBenchmark` | JMH round trip of the three encoders | `09` |
+| `SerializationTest` | 11 assertions behind the claims above | `10` |
+
+The JMH run is 2 forks, 5 warmup and 8 measurement iterations of 1 s; re-running moves the numbers
+but the Java-serialization-is-slowest ordering held in every run here.
diff --git a/serialization/output/01-uid-in-stream.txt b/serialization/output/01-uid-in-stream.txt
new file mode 100644
index 0000000..52d74f0
--- /dev/null
+++ b/serialization/output/01-uid-in-stream.txt
@@ -0,0 +1,4 @@
+declared serialVersionUID = 1
+UID found in the stream = 1
+patched stream UID = 2
+InvalidClassException: com.ankurm.serialization.UidInStreamDemo$Ticket; local class incompatible: stream classdesc serialVersionUID = 2, local class serialVersionUID = 1
diff --git a/serialization/output/02-drift-implicit.txt b/serialization/output/02-drift-implicit.txt
new file mode 100644
index 0000000..eb6fe44
--- /dev/null
+++ b/serialization/output/02-drift-implicit.txt
@@ -0,0 +1,6 @@
+$ # implicit serialVersionUID (none declared)
+$ java -cp v1 DriftWrite
+wrote 113 bytes; serialVersionUID in stream = 1201216851776330172
+$ java -cp v2 DriftRead # v2 adds a field
+this class's serialVersionUID = -732213015030957059
+InvalidClassException: com.ankurm.serialization.drift.Account; local class incompatible: stream classdesc serialVersionUID = 1201216851776330172, local class serialVersionUID = -732213015030957059
diff --git a/serialization/output/03-drift-explicit.txt b/serialization/output/03-drift-explicit.txt
new file mode 100644
index 0000000..8f0f975
--- /dev/null
+++ b/serialization/output/03-drift-explicit.txt
@@ -0,0 +1,6 @@
+$ # explicit serialVersionUID = 1L
+$ java -cp v1 DriftWrite
+wrote 113 bytes; serialVersionUID in stream = 1
+$ java -cp v2 DriftRead # v2 adds a field
+this class's serialVersionUID = 1
+read: Account[owner=asha, balance=500, email=null]
diff --git a/serialization/output/04-readobject-runs-code.txt b/serialization/output/04-readobject-runs-code.txt
new file mode 100644
index 0000000..18bc4ee
--- /dev/null
+++ b/serialization/output/04-readobject-runs-code.txt
@@ -0,0 +1,6 @@
+application expects a Greeting and writes: String greeting = (Greeting) in.readObject()
+ >>> Noisy.readObject() is running -- code of the class named in the stream
+ClassCastException AFTER the side effect: class com.ankurm.serialization.ReadObjectRunsCodeDemo$Noisy cannot be cast to class com.ankurm.serialization.ReadObjectRunsCodeDemo$Greeting (com.ankurm.serialization.ReadObjectRunsCodeDemo$Noisy and com.ankurm.serialization.ReadObjectRunsCodeDemo$Greeting are in unnamed module of loader 'app')
+
+same bytes, allow-list filter that only admits Greeting:
+InvalidClassException: filter status: REJECTED
diff --git a/serialization/output/05-resource-limits.txt b/serialization/output/05-resource-limits.txt
new file mode 100644
index 0000000..310beae
--- /dev/null
+++ b/serialization/output/05-resource-limits.txt
@@ -0,0 +1,16 @@
+max heap = 256 MiB
+forged stream is 37 bytes long but claims a byte[1000000000]
+no filter -> OutOfMemoryError: Java heap space
+maxarray=100000 -> InvalidClassException: filter status: REJECTED
+ filter saw: class=class [B arrayLength=-1 depth=1 streamBytes=21 -> UNDECIDED
+ filter saw: class=class [B arrayLength=1000000000 depth=1 streamBytes=27 -> REJECTED
+maxarray, logged -> InvalidClassException: filter status: REJECTED
+
+a legitimate-looking chain of 200 nodes is 1324 bytes
+no filter -> accepted: Node
+maxdepth=50 -> InvalidClassException: filter status: REJECTED
+
+maxbytes=10000, one 50 KB array -> accepted: byte[]
+an ArrayList of 5000 integers is 50125 bytes
+maxbytes=10000, 5000 integers -> InvalidClassException: filter status: REJECTED
+maxbytes=1000000, 5000 integers -> accepted: ArrayList
diff --git a/serialization/output/06-records.txt b/serialization/output/06-records.txt
new file mode 100644
index 0000000..7bd318b
--- /dev/null
+++ b/serialization/output/06-records.txt
@@ -0,0 +1,5 @@
+record default serialVersionUID = 0
+forged stream with years = -5:
+ record -> InvalidObjectException: years out of range: -5
+ cause: java.lang.IllegalArgumentException: years out of range: -5
+ class -> AgeClass[years=-5] (no constructor ran)
diff --git a/serialization/output/07-filter-factory.txt b/serialization/output/07-filter-factory.txt
new file mode 100644
index 0000000..63dc6d6
--- /dev/null
+++ b/serialization/output/07-filter-factory.txt
@@ -0,0 +1,13 @@
+$ java -Djdk.serialFilter="maxdepth=10;com.ankurm.serialization.*;java.lang.*;!*" FilterFactoryDemo
+jdk.serialFilter (system property) = maxdepth=10;com.ankurm.serialization.*;java.lang.*;!*
+Config.getSerialFilter() = maxdepth=10;com.ankurm.serialization.*;java.lang.*;!*
+factory before = java.io.ObjectInputFilter$Config$BuiltinFilterFactory
+factory installed; installing a second one:
+IllegalStateException: Cannot replace filter factory
+context A (Ok + String allowed):
+ read Ok -> Ok[s=fine]
+ read String -> a plain string
+ read 50-deep chain -> InvalidClassException: filter status: REJECTED
+context B (String only):
+ read String -> a plain string
+ read Ok -> InvalidClassException: filter status: REJECTED
diff --git a/serialization/output/08-format-sizes.txt b/serialization/output/08-format-sizes.txt
new file mode 100644
index 0000000..7e0a0ae
--- /dev/null
+++ b/serialization/output/08-format-sizes.txt
@@ -0,0 +1,10 @@
+Java serialization : 382 bytes, starts with aced0005 (magic aced0005)
+Jackson 3 JSON : 223 bytes
+Protobuf wire : 80 bytes
+
+JSON text: {"id":1000042,"customer":"Asha Mehta","currency":"INR","lines":[{"sku":"BK-JAVA-25","quantity":2,"priceMinor":49900},{"sku":"BK-JVM-INT","quantity":1,"priceMinor":79900},{"sku":"CBL-USB-C","quantity":3,"priceMinor":19900}]}
+
+round trips equal : java=true json=true protobuf=true
+
+JSON cannot name a class to instantiate unless you opt in. Feeding it a type hint:
+ parsed as Order[id=1, customer=x, currency=INR, lines=[]]
diff --git a/serialization/output/09-jmh-raw.txt b/serialization/output/09-jmh-raw.txt
new file mode 100644
index 0000000..f0c4977
--- /dev/null
+++ b/serialization/output/09-jmh-raw.txt
@@ -0,0 +1,4 @@
+Benchmark Mode Cnt Score Error Units
+SerializationBenchmark.jacksonJson avgt 16 3107.305 ± 467.508 ns/op
+SerializationBenchmark.javaSerialization avgt 16 11316.514 ± 538.967 ns/op
+SerializationBenchmark.protobufWire avgt 16 3736.484 ± 283.111 ns/op
diff --git a/serialization/output/10-tests.txt b/serialization/output/10-tests.txt
new file mode 100644
index 0000000..07b78da
--- /dev/null
+++ b/serialization/output/10-tests.txt
@@ -0,0 +1,4 @@
+-------------------------------------------------------------------------------
+Test set: com.ankurm.serialization.SerializationTest
+-------------------------------------------------------------------------------
+Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.851 s -- in com.ankurm.serialization.SerializationTest
diff --git a/serialization/pom.xml b/serialization/pom.xml
new file mode 100644
index 0000000..4838b67
--- /dev/null
+++ b/serialization/pom.xml
@@ -0,0 +1,100 @@
+
+
+ 4.0.0
+
+
+ com.ankurm
+ java-core-examples
+ 1.0
+
+
+ serialization
+ serialization
+ Java serialization in 2026: serialVersionUID drift, deserialization filters (JEP 290/415), records, and JSON/Protobuf alternatives with size and speed numbers.
+
+
+ 1.37
+ 3.2.3
+ 4.36.2
+
+
+
+
+ org.openjdk.jmh
+ jmh-core
+ ${jmh.version}
+
+
+ org.openjdk.jmh
+ jmh-generator-annprocess
+ ${jmh.version}
+
+
+ tools.jackson.core
+ jackson-databind
+ ${jackson.version}
+
+
+ com.google.protobuf
+ protobuf-java
+ ${protobuf.version}
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.11.0
+ test
+
+
+
+
+ benchmarks
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 25
+
+
+
+ org.openjdk.jmh
+ jmh-generator-annprocess
+ ${jmh.version}
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.1
+
+
+ package
+ shade
+
+
+
+ org.openjdk.jmh.Main
+
+
+
+
+
+
+
+
+
diff --git a/serialization/scripts/run-all.sh b/serialization/scripts/run-all.sh
new file mode 100755
index 0000000..edf4a6a
--- /dev/null
+++ b/serialization/scripts/run-all.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# Regenerates every file in ../output/. Requires JDK25_HOME.
+set -euo pipefail
+[[ -z "${JDK25_HOME:-}" ]] && { echo "JDK25_HOME must be set" >&2; exit 1; }
+cd "$(dirname "$0")/.."
+OUT=output; mkdir -p "$OUT"
+export JAVA_HOME="$JDK25_HOME"
+mvn -q -f ../pom.xml -pl serialization -am package 2>&1 | grep -v -E "Picked up|^WARNING" || true
+J="$JDK25_HOME/bin/java"; JC="$JDK25_HOME/bin/javac"
+M2="${HOME}/.m2/repository"
+CP="target/classes:$(ls $M2/tools/jackson/core/jackson-databind/3.2.3/*.jar):$(ls $M2/tools/jackson/core/jackson-core/3.2.3/*.jar):$(ls $M2/com/fasterxml/jackson/core/jackson-annotations/*/*.jar | tail -1):$(ls $M2/com/google/protobuf/protobuf-java/4.36.2/*.jar)"
+run() { f=$1; shift; echo "==> $f"; "$J" "$@" 2>&1 | grep -v "Picked up" > "$OUT/$f"; }
+
+# 01: the UID travels inside the stream
+run 01-uid-in-stream.txt -cp "$CP" com.ankurm.serialization.UidInStreamDemo
+
+# 02/03: two versions of the same class, with and without an explicit serialVersionUID
+for mode in implicit explicit; do
+ rm -rf target/drift-$mode; mkdir -p target/drift-$mode
+ for v in v1 v2; do
+ d=target/drift-$mode/$v/com/ankurm/serialization/drift; mkdir -p $d
+ if [[ $mode == explicit ]]; then
+ sed 's#/\*UID\*/#private static final long serialVersionUID = 1L;#' src/versions/$v/Account.java > $d/Account.java
+ else
+ sed 's#/\*UID\*/##' src/versions/$v/Account.java > $d/Account.java
+ fi
+ cp src/versions/common/*.java $d/
+ $JC -d target/drift-$mode/$v/classes $d/*.java 2>&1 | grep -v "Picked up" || true
+ done
+done
+{
+ echo '$ # implicit serialVersionUID (none declared)'
+ echo '$ java -cp v1 DriftWrite'
+ "$J" -cp target/drift-implicit/v1/classes com.ankurm.serialization.drift.DriftWrite target/account-implicit.ser 2>&1 | grep -v "Picked up"
+ echo '$ java -cp v2 DriftRead # v2 adds a field'
+ "$J" -cp target/drift-implicit/v2/classes com.ankurm.serialization.drift.DriftRead target/account-implicit.ser 2>&1 | grep -v "Picked up"
+} > "$OUT/02-drift-implicit.txt"
+{
+ echo '$ # explicit serialVersionUID = 1L'
+ echo '$ java -cp v1 DriftWrite'
+ "$J" -cp target/drift-explicit/v1/classes com.ankurm.serialization.drift.DriftWrite target/account-explicit.ser 2>&1 | grep -v "Picked up"
+ echo '$ java -cp v2 DriftRead # v2 adds a field'
+ "$J" -cp target/drift-explicit/v2/classes com.ankurm.serialization.drift.DriftRead target/account-explicit.ser 2>&1 | grep -v "Picked up"
+} > "$OUT/03-drift-explicit.txt"
+
+run 04-readobject-runs-code.txt -cp "$CP" com.ankurm.serialization.ReadObjectRunsCodeDemo
+run 05-resource-limits.txt -Xmx256m -cp "$CP" com.ankurm.serialization.ResourceLimitsDemo
+run 06-records.txt -cp "$CP" com.ankurm.serialization.RecordsDemo
+{
+ echo '$ java -Djdk.serialFilter="maxdepth=10;com.ankurm.serialization.*;java.lang.*;!*" FilterFactoryDemo'
+ "$J" -Djdk.serialFilter='maxdepth=10;com.ankurm.serialization.*;java.lang.*;!*' -cp "$CP" com.ankurm.serialization.FilterFactoryDemo 2>&1 | grep -v "Picked up"
+} > "$OUT/07-filter-factory.txt"
+run 08-format-sizes.txt -cp "$CP" com.ankurm.serialization.FormatSizeDemo
+echo "==> JMH"
+"$J" -jar target/benchmarks.jar SerializationBenchmark -rf text -rff "$OUT/09-jmh-raw.txt" > /dev/null 2>&1
+cp target/surefire-reports/com.ankurm.serialization.SerializationTest.txt "$OUT/10-tests.txt"
+echo Done
diff --git a/serialization/src/main/java/com/ankurm/serialization/Codecs.java b/serialization/src/main/java/com/ankurm/serialization/Codecs.java
new file mode 100644
index 0000000..c80956e
--- /dev/null
+++ b/serialization/src/main/java/com/ankurm/serialization/Codecs.java
@@ -0,0 +1,77 @@
+package com.ankurm.serialization;
+
+import com.google.protobuf.CodedInputStream;
+import com.google.protobuf.CodedOutputStream;
+import tools.jackson.databind.json.JsonMapper;
+
+import java.io.*;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Three encoders for {@link Order}: Java serialization, Jackson 3 JSON, and Protobuf wire format.
+ * The Protobuf side is written against protobuf-java's CodedOutputStream / CodedInputStream using the
+ * same field numbers a .proto file would declare (see order.proto). That is the real wire format, but
+ * there is no protoc-generated class here.
+ */
+public final class Codecs {
+ private Codecs() {}
+
+ public static final JsonMapper JSON = JsonMapper.builder().build();
+
+ // ---- Java serialization
+ public static byte[] javaWrite(Order o) throws IOException { return Wire.write(o); }
+ public static Order javaRead(byte[] b) throws Exception { return (Order) Wire.read(b); }
+
+ // ---- Jackson 3
+ public static byte[] jsonWrite(Order o) { return JSON.writeValueAsBytes(o); }
+ public static Order jsonRead(byte[] b) { return JSON.readValue(b, Order.class); }
+
+ // ---- Protobuf wire format: Order { int64 id=1; string customer=2; string currency=3; repeated Line lines=4; }
+ // Line { string sku=1; int32 quantity=2; int64 price_minor=3; }
+ public static byte[] pbWrite(Order o) throws IOException {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream(128);
+ CodedOutputStream out = CodedOutputStream.newInstance(bos);
+ out.writeInt64(1, o.id());
+ out.writeString(2, o.customer());
+ out.writeString(3, o.currency());
+ for (Order.Line l : o.lines()) {
+ ByteArrayOutputStream lb = new ByteArrayOutputStream(32);
+ CodedOutputStream lo = CodedOutputStream.newInstance(lb);
+ lo.writeString(1, l.sku());
+ lo.writeInt32(2, l.quantity());
+ lo.writeInt64(3, l.priceMinor());
+ lo.flush();
+ out.writeByteArray(4, lb.toByteArray());
+ }
+ out.flush();
+ return bos.toByteArray();
+ }
+
+ public static Order pbRead(byte[] b) throws IOException {
+ CodedInputStream in = CodedInputStream.newInstance(b);
+ long id = 0; String customer = "", currency = ""; List lines = new ArrayList<>();
+ for (int tag; (tag = in.readTag()) != 0; ) {
+ switch (tag >>> 3) {
+ case 1 -> id = in.readInt64();
+ case 2 -> customer = in.readStringRequireUtf8();
+ case 3 -> currency = in.readStringRequireUtf8();
+ case 4 -> {
+ CodedInputStream li = CodedInputStream.newInstance(in.readByteArray());
+ String sku = ""; int q = 0; long price = 0;
+ for (int t; (t = li.readTag()) != 0; ) {
+ switch (t >>> 3) {
+ case 1 -> sku = li.readStringRequireUtf8();
+ case 2 -> q = li.readInt32();
+ case 3 -> price = li.readInt64();
+ default -> li.skipField(t);
+ }
+ }
+ lines.add(new Order.Line(sku, q, price));
+ }
+ default -> in.skipField(tag);
+ }
+ }
+ return new Order(id, customer, currency, lines);
+ }
+}
diff --git a/serialization/src/main/java/com/ankurm/serialization/FilterFactoryDemo.java b/serialization/src/main/java/com/ankurm/serialization/FilterFactoryDemo.java
new file mode 100644
index 0000000..da9395c
--- /dev/null
+++ b/serialization/src/main/java/com/ankurm/serialization/FilterFactoryDemo.java
@@ -0,0 +1,61 @@
+package com.ankurm.serialization;
+
+import java.io.*;
+import java.util.function.BinaryOperator;
+
+/**
+ * JEP 415: a process-wide filter FACTORY chooses the filter for each ObjectInputStream from the
+ * calling context. Here the context is a ThreadLocal holding a per-request filter, merged with a global one.
+ * Run with: -Djdk.serialFilter="maxdepth=10;java.base/*;!*" (see run-all.sh).
+ */
+public class FilterFactoryDemo {
+ static final ThreadLocal CONTEXT = new ThreadLocal<>();
+
+ record Ok(String s) implements Serializable {}
+
+ public static void main(String[] args) throws Exception {
+ System.out.println("jdk.serialFilter (system property) = " + System.getProperty("jdk.serialFilter"));
+ System.out.println("Config.getSerialFilter() = " + ObjectInputFilter.Config.getSerialFilter());
+ System.out.println("factory before = " + ObjectInputFilter.Config.getSerialFilterFactory().getClass().getName());
+
+ BinaryOperator factory = (current, requested) -> {
+ ObjectInputFilter ctx = CONTEXT.get();
+ // 'current' is the filter already in effect for the stream (the process-wide one on first call)
+ ObjectInputFilter merged = ObjectInputFilter.merge(ctx, current);
+ return ObjectInputFilter.merge(requested, merged);
+ };
+ ObjectInputFilter.Config.setSerialFilterFactory(factory);
+ System.out.println("factory installed; installing a second one:");
+ try {
+ ObjectInputFilter.Config.setSerialFilterFactory(factory);
+ } catch (IllegalStateException e) {
+ System.out.println("IllegalStateException: " + e.getMessage().substring(0, e.getMessage().indexOf(':')));
+ }
+
+ byte[] ok = Wire.write(new Ok("fine"));
+ byte[] str = Wire.write("a plain string");
+
+ // Context A: a request that may read Ok records
+ CONTEXT.set(ObjectInputFilter.Config.createFilter("com.ankurm.serialization.FilterFactoryDemo$Ok;com.ankurm.serialization.ResourceLimitsDemo$Node;java.lang.String;!*"));
+ System.out.println("context A (Ok + String allowed):");
+ System.out.println(" read Ok -> " + Wire.read(ok));
+ System.out.println(" read String -> " + Wire.read(str));
+
+ // the process-wide maxdepth=10 from -Djdk.serialFilter still applies underneath the context filter
+ try {
+ Wire.read(Wire.write(ResourceLimitsDemo.chain(50)));
+ } catch (InvalidClassException e) {
+ System.out.println(" read 50-deep chain -> InvalidClassException: " + e.getMessage());
+ }
+
+ // Context B: a request that may only read Strings
+ CONTEXT.set(ObjectInputFilter.Config.createFilter("java.lang.String;!*"));
+ System.out.println("context B (String only):");
+ System.out.println(" read String -> " + Wire.read(str));
+ try {
+ Wire.read(ok);
+ } catch (InvalidClassException e) {
+ System.out.println(" read Ok -> InvalidClassException: " + e.getMessage());
+ }
+ }
+}
diff --git a/serialization/src/main/java/com/ankurm/serialization/FormatSizeDemo.java b/serialization/src/main/java/com/ankurm/serialization/FormatSizeDemo.java
new file mode 100644
index 0000000..c669af3
--- /dev/null
+++ b/serialization/src/main/java/com/ankurm/serialization/FormatSizeDemo.java
@@ -0,0 +1,28 @@
+package com.ankurm.serialization;
+
+import java.nio.charset.StandardCharsets;
+
+/** Prints the encoded size of the same Order in each format, plus what each one looks like on the wire. */
+public class FormatSizeDemo {
+ public static void main(String[] args) throws Exception {
+ Order o = Order.sample();
+ byte[] java = Codecs.javaWrite(o), json = Codecs.jsonWrite(o), pb = Codecs.pbWrite(o);
+ System.out.println("Java serialization : " + java.length + " bytes, starts with " + Wire.hexHead(java, 4) + " (magic aced0005)");
+ System.out.println("Jackson 3 JSON : " + json.length + " bytes");
+ System.out.println("Protobuf wire : " + pb.length + " bytes");
+ System.out.println();
+ System.out.println("JSON text: " + new String(json, StandardCharsets.UTF_8));
+ System.out.println();
+ System.out.println("round trips equal : java=" + o.equals(Codecs.javaRead(java))
+ + " json=" + o.equals(Codecs.jsonRead(json)) + " protobuf=" + o.equals(Codecs.pbRead(pb)));
+
+ System.out.println();
+ System.out.println("JSON cannot name a class to instantiate unless you opt in. Feeding it a type hint:");
+ String hostile = "{\"@class\":\"java.lang.ProcessBuilder\",\"id\":1,\"customer\":\"x\",\"currency\":\"INR\",\"lines\":[]}";
+ try {
+ System.out.println(" parsed as " + Codecs.JSON.readValue(hostile, Order.class));
+ } catch (Exception e) {
+ System.out.println(" " + e.getClass().getSimpleName() + ": " + e.getMessage().lines().findFirst().orElse(""));
+ }
+ }
+}
diff --git a/serialization/src/main/java/com/ankurm/serialization/Order.java b/serialization/src/main/java/com/ankurm/serialization/Order.java
new file mode 100644
index 0000000..4916b86
--- /dev/null
+++ b/serialization/src/main/java/com/ankurm/serialization/Order.java
@@ -0,0 +1,17 @@
+package com.ankurm.serialization;
+
+import java.io.Serializable;
+import java.util.List;
+
+/** The same small business object encoded three ways for the size/speed comparison. */
+public record Order(long id, String customer, String currency, List lines) implements Serializable {
+
+ public record Line(String sku, int quantity, long priceMinor) implements Serializable {}
+
+ public static Order sample() {
+ return new Order(1_000_042L, "Asha Mehta", "INR", List.of(
+ new Line("BK-JAVA-25", 2, 49_900),
+ new Line("BK-JVM-INT", 1, 79_900),
+ new Line("CBL-USB-C", 3, 19_900)));
+ }
+}
diff --git a/serialization/src/main/java/com/ankurm/serialization/ReadObjectRunsCodeDemo.java b/serialization/src/main/java/com/ankurm/serialization/ReadObjectRunsCodeDemo.java
new file mode 100644
index 0000000..7128450
--- /dev/null
+++ b/serialization/src/main/java/com/ankurm/serialization/ReadObjectRunsCodeDemo.java
@@ -0,0 +1,46 @@
+package com.ankurm.serialization;
+
+import java.io.*;
+
+/**
+ * Safe demonstration of the core problem: deserialization executes code from the class named in the
+ * stream, BEFORE the caller gets the object back and therefore before any cast or instanceof check.
+ * The "payload" here only prints a line. No real library class is involved.
+ */
+public class ReadObjectRunsCodeDemo {
+
+ /** A class that happens to be on the classpath and has a readObject with a side effect. */
+ static class Noisy implements Serializable {
+ private static final long serialVersionUID = 1L;
+ String note = "hello";
+
+ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
+ in.defaultReadObject();
+ System.out.println(" >>> Noisy.readObject() is running -- code of the class named in the stream");
+ }
+ }
+
+ /** The class the application thinks it is reading. */
+ record Greeting(String text) implements Serializable {}
+
+ public static void main(String[] args) throws Exception {
+ byte[] bytes = Wire.write(new Noisy());
+ System.out.println("application expects a Greeting and writes: String greeting = (Greeting) in.readObject()");
+ try {
+ Greeting g = (Greeting) Wire.read(bytes);
+ System.out.println("got " + g);
+ } catch (ClassCastException e) {
+ System.out.println("ClassCastException AFTER the side effect: " + e.getMessage());
+ }
+
+ System.out.println();
+ System.out.println("same bytes, allow-list filter that only admits Greeting:");
+ ObjectInputFilter onlyGreeting = ObjectInputFilter.Config.createFilter(
+ "com.ankurm.serialization.ReadObjectRunsCodeDemo$Greeting;!*");
+ try {
+ Wire.read(bytes, onlyGreeting);
+ } catch (InvalidClassException e) {
+ System.out.println("InvalidClassException: " + e.getMessage());
+ }
+ }
+}
diff --git a/serialization/src/main/java/com/ankurm/serialization/RecordsDemo.java b/serialization/src/main/java/com/ankurm/serialization/RecordsDemo.java
new file mode 100644
index 0000000..f00316d
--- /dev/null
+++ b/serialization/src/main/java/com/ankurm/serialization/RecordsDemo.java
@@ -0,0 +1,42 @@
+package com.ankurm.serialization;
+
+import java.io.*;
+
+/** Records deserialize through their canonical constructor; ordinary classes do not run any constructor. */
+public class RecordsDemo {
+
+ record AgeRecord(int years) implements Serializable {
+ AgeRecord {
+ if (years < 0 || years > 150) throw new IllegalArgumentException("years out of range: " + years);
+ }
+ }
+
+ static class AgeClass implements Serializable {
+ private static final long serialVersionUID = 1L;
+ final int years;
+ AgeClass(int years) {
+ if (years < 0 || years > 150) throw new IllegalArgumentException("years out of range: " + years);
+ this.years = years;
+ }
+ @Override public String toString() { return "AgeClass[years=" + years + "]"; }
+ }
+
+ public static void main(String[] args) throws Exception {
+ System.out.println("record default serialVersionUID = " + ObjectStreamClass.lookup(AgeRecord.class).getSerialVersionUID());
+
+ byte[] rec = Wire.write(new AgeRecord(30));
+ byte[] cls = Wire.write(new AgeClass(30));
+ // the int field is the last four bytes of each stream
+ Wire.putInt(rec, rec.length - 4, -5);
+ Wire.putInt(cls, cls.length - 4, -5);
+
+ System.out.println("forged stream with years = -5:");
+ try {
+ System.out.println(" record -> " + Wire.read(rec));
+ } catch (InvalidObjectException e) {
+ System.out.println(" record -> InvalidObjectException: " + e.getMessage());
+ System.out.println(" cause: " + e.getCause());
+ }
+ System.out.println(" class -> " + Wire.read(cls) + " (no constructor ran)");
+ }
+}
diff --git a/serialization/src/main/java/com/ankurm/serialization/ResourceLimitsDemo.java b/serialization/src/main/java/com/ankurm/serialization/ResourceLimitsDemo.java
new file mode 100644
index 0000000..16672ad
--- /dev/null
+++ b/serialization/src/main/java/com/ankurm/serialization/ResourceLimitsDemo.java
@@ -0,0 +1,75 @@
+package com.ankurm.serialization;
+
+import java.io.*;
+
+/**
+ * Resource exhaustion without any gadget: a stream can ask for a huge array or a very deep object
+ * graph. The JEP 290 limits maxarray / maxdepth / maxbytes reject it before the allocation or recursion.
+ */
+public class ResourceLimitsDemo {
+
+ static class Node implements Serializable {
+ private static final long serialVersionUID = 1L;
+ Node next;
+ }
+
+ static Node chain(int depth) {
+ Node head = new Node(), cur = head;
+ for (int i = 1; i < depth; i++) { cur.next = new Node(); cur = cur.next; }
+ return head;
+ }
+
+ /** Serialize a byte[10], then patch the declared array length to 'claimed'. The array length int is 14 bytes from the end. */
+ static byte[] forgedArray(int claimed) throws IOException {
+ byte[] b = Wire.write(new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9});
+ Wire.putInt(b, b.length - 14, claimed);
+ return b;
+ }
+
+ static void attempt(String label, byte[] bytes, ObjectInputFilter filter) {
+ try {
+ Object o = filter == null ? Wire.read(bytes) : Wire.read(bytes, filter);
+ System.out.println(label + " -> accepted: " + o.getClass().getSimpleName());
+ } catch (InvalidClassException e) {
+ System.out.println(label + " -> InvalidClassException: " + e.getMessage());
+ } catch (EOFException e) {
+ System.out.println(label + " -> EOFException (stream ended; the array WAS allocated first)");
+ } catch (OutOfMemoryError e) {
+ System.out.println(label + " -> OutOfMemoryError: " + e.getMessage());
+ } catch (Exception e) {
+ System.out.println(label + " -> " + e);
+ }
+ }
+
+ public static void main(String[] args) throws Exception {
+ System.out.println("max heap = " + Runtime.getRuntime().maxMemory() / (1024 * 1024) + " MiB");
+
+ byte[] bomb = forgedArray(1_000_000_000);
+ System.out.println("forged stream is " + bomb.length + " bytes long but claims a byte[1000000000]");
+ attempt("no filter ", bomb, null);
+ attempt("maxarray=100000 ", bomb, ObjectInputFilter.Config.createFilter("maxarray=100000"));
+ ObjectInputFilter limit = ObjectInputFilter.Config.createFilter("maxarray=100000");
+ attempt("maxarray, logged ", bomb, info -> {
+ ObjectInputFilter.Status st = limit.checkInput(info);
+ System.out.println(" filter saw: class=" + info.serialClass() + " arrayLength=" + info.arrayLength()
+ + " depth=" + info.depth() + " streamBytes=" + info.streamBytes() + " -> " + st);
+ return st;
+ });
+
+ System.out.println();
+ byte[] deep = Wire.write(chain(200));
+ System.out.println("a legitimate-looking chain of 200 nodes is " + deep.length + " bytes");
+ attempt("no filter ", deep, null);
+ attempt("maxdepth=50 ", deep, ObjectInputFilter.Config.createFilter("maxdepth=50;com.ankurm.serialization.ResourceLimitsDemo$Node;!*"));
+
+ System.out.println();
+ byte[] one = Wire.write(new byte[50_000]);
+ attempt("maxbytes=10000, one 50 KB array ", one, ObjectInputFilter.Config.createFilter("maxbytes=10000"));
+ java.util.ArrayList many = new java.util.ArrayList<>();
+ for (int i = 0; i < 5_000; i++) many.add(i);
+ byte[] list = Wire.write(many);
+ System.out.println("an ArrayList of 5000 integers is " + list.length + " bytes");
+ attempt("maxbytes=10000, 5000 integers ", list, ObjectInputFilter.Config.createFilter("maxbytes=10000"));
+ attempt("maxbytes=1000000, 5000 integers ", list, ObjectInputFilter.Config.createFilter("maxbytes=1000000"));
+ }
+}
diff --git a/serialization/src/main/java/com/ankurm/serialization/SerializationBenchmark.java b/serialization/src/main/java/com/ankurm/serialization/SerializationBenchmark.java
new file mode 100644
index 0000000..c4fa48b
--- /dev/null
+++ b/serialization/src/main/java/com/ankurm/serialization/SerializationBenchmark.java
@@ -0,0 +1,19 @@
+package com.ankurm.serialization;
+
+import org.openjdk.jmh.annotations.*;
+import java.util.concurrent.TimeUnit;
+
+/** Round trip (encode + decode) of the same Order. Indicative only; 2 vCPU VM. */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 5, time = 1)
+@Measurement(iterations = 8, time = 1)
+@Fork(2)
+public class SerializationBenchmark {
+ final Order order = Order.sample();
+
+ @Benchmark public Order javaSerialization() throws Exception { return Codecs.javaRead(Codecs.javaWrite(order)); }
+ @Benchmark public Order jacksonJson() { return Codecs.jsonRead(Codecs.jsonWrite(order)); }
+ @Benchmark public Order protobufWire() throws Exception { return Codecs.pbRead(Codecs.pbWrite(order)); }
+}
diff --git a/serialization/src/main/java/com/ankurm/serialization/UidInStreamDemo.java b/serialization/src/main/java/com/ankurm/serialization/UidInStreamDemo.java
new file mode 100644
index 0000000..b7e0592
--- /dev/null
+++ b/serialization/src/main/java/com/ankurm/serialization/UidInStreamDemo.java
@@ -0,0 +1,31 @@
+package com.ankurm.serialization;
+
+import java.io.*;
+
+/** The serialVersionUID is written into the stream next to the class name; the reader compares it with its own class. */
+public class UidInStreamDemo {
+
+ static class Ticket implements Serializable {
+ private static final long serialVersionUID = 1L;
+ String seat = "12A";
+ }
+
+ /** Offset of the 8-byte UID: magic+version (4), TC_OBJECT (1), TC_CLASSDESC (1), name length (2), name. */
+ static int uidOffset(Class> c) { return 4 + 1 + 1 + 2 + c.getName().length(); }
+
+ public static void main(String[] args) throws Exception {
+ byte[] bytes = Wire.write(new Ticket());
+ int off = uidOffset(Ticket.class);
+ long inStream = java.nio.ByteBuffer.wrap(bytes, off, 8).getLong();
+ System.out.println("declared serialVersionUID = " + ObjectStreamClass.lookup(Ticket.class).getSerialVersionUID());
+ System.out.println("UID found in the stream = " + inStream);
+
+ bytes[off + 7] = 2; // pretend the writer was running version 2 of the class
+ System.out.println("patched stream UID = " + java.nio.ByteBuffer.wrap(bytes, off, 8).getLong());
+ try {
+ Wire.read(bytes);
+ } catch (InvalidClassException e) {
+ System.out.println("InvalidClassException: " + e.getMessage());
+ }
+ }
+}
diff --git a/serialization/src/main/java/com/ankurm/serialization/Wire.java b/serialization/src/main/java/com/ankurm/serialization/Wire.java
new file mode 100644
index 0000000..bc111e7
--- /dev/null
+++ b/serialization/src/main/java/com/ankurm/serialization/Wire.java
@@ -0,0 +1,36 @@
+package com.ankurm.serialization;
+
+import java.io.*;
+
+/** Small helpers shared by the demos: serialize to bytes, deserialize from bytes, hex dump. */
+final class Wire {
+ private Wire() {}
+
+ static byte[] write(Object o) throws IOException {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (ObjectOutputStream out = new ObjectOutputStream(bos)) { out.writeObject(o); }
+ return bos.toByteArray();
+ }
+
+ static Object read(byte[] bytes) throws IOException, ClassNotFoundException {
+ try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) { return in.readObject(); }
+ }
+
+ static Object read(byte[] bytes, ObjectInputFilter filter) throws IOException, ClassNotFoundException {
+ try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
+ in.setObjectInputFilter(filter);
+ return in.readObject();
+ }
+ }
+
+ /** Overwrites four bytes with a big-endian int at the given offset. */
+ static void putInt(byte[] b, int off, int v) {
+ b[off] = (byte) (v >>> 24); b[off + 1] = (byte) (v >>> 16); b[off + 2] = (byte) (v >>> 8); b[off + 3] = (byte) v;
+ }
+
+ static String hexHead(byte[] b, int n) {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < Math.min(n, b.length); i++) sb.append(String.format("%02x", b[i]));
+ return sb.toString();
+ }
+}
diff --git a/serialization/src/main/order.proto b/serialization/src/main/order.proto
new file mode 100644
index 0000000..57eec02
--- /dev/null
+++ b/serialization/src/main/order.proto
@@ -0,0 +1,15 @@
+syntax = "proto3";
+
+// The schema that Codecs.pbWrite/pbRead follow by hand (field numbers and wire types).
+message Order {
+ int64 id = 1;
+ string customer = 2;
+ string currency = 3;
+ repeated Line lines = 4;
+}
+
+message Line {
+ string sku = 1;
+ int32 quantity = 2;
+ int64 price_minor = 3;
+}
diff --git a/serialization/src/test/java/com/ankurm/serialization/SerializationTest.java b/serialization/src/test/java/com/ankurm/serialization/SerializationTest.java
new file mode 100644
index 0000000..7223676
--- /dev/null
+++ b/serialization/src/test/java/com/ankurm/serialization/SerializationTest.java
@@ -0,0 +1,85 @@
+package com.ankurm.serialization;
+
+import org.junit.jupiter.api.Test;
+import java.io.*;
+import java.util.ArrayList;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SerializationTest {
+
+ @Test void uidMismatchThrowsInvalidClassException() throws Exception {
+ byte[] b = Wire.write(new UidInStreamDemo.Ticket());
+ b[UidInStreamDemo.uidOffset(UidInStreamDemo.Ticket.class) + 7] = 2;
+ InvalidClassException e = assertThrows(InvalidClassException.class, () -> Wire.read(b));
+ assertTrue(e.getMessage().contains("local class incompatible"));
+ }
+
+ @Test void readObjectRunsBeforeTheCast() throws Exception {
+ PrintStream old = System.out;
+ ByteArrayOutputStream cap = new ByteArrayOutputStream();
+ System.setOut(new PrintStream(cap));
+ try {
+ byte[] b = Wire.write(new ReadObjectRunsCodeDemo.Noisy());
+ assertThrows(ClassCastException.class, () -> { ReadObjectRunsCodeDemo.Greeting g = (ReadObjectRunsCodeDemo.Greeting) Wire.read(b); });
+ } finally { System.setOut(old); }
+ assertTrue(cap.toString().contains("Noisy.readObject() is running"));
+ }
+
+ @Test void allowListRejectsUnexpectedClass() throws Exception {
+ byte[] b = Wire.write(new ReadObjectRunsCodeDemo.Noisy());
+ ObjectInputFilter f = ObjectInputFilter.Config.createFilter("com.ankurm.serialization.ReadObjectRunsCodeDemo$Greeting;!*");
+ assertThrows(InvalidClassException.class, () -> Wire.read(b, f));
+ }
+
+ @Test void maxarrayRejectsForgedLengthBeforeAllocation() throws Exception {
+ byte[] bomb = ResourceLimitsDemo.forgedArray(1_000_000_000);
+ ObjectInputFilter f = ObjectInputFilter.Config.createFilter("maxarray=100000");
+ assertThrows(InvalidClassException.class, () -> Wire.read(bomb, f));
+ }
+
+ @Test void maxdepthRejectsDeepChain() throws Exception {
+ byte[] deep = Wire.write(ResourceLimitsDemo.chain(200));
+ assertNotNull(Wire.read(deep));
+ ObjectInputFilter f = ObjectInputFilter.Config.createFilter("maxdepth=50");
+ assertThrows(InvalidClassException.class, () -> Wire.read(deep, f));
+ }
+
+ @Test void maxbytesChecksAtCallbacksNotAtTheArrayHeader() throws Exception {
+ ObjectInputFilter f = ObjectInputFilter.Config.createFilter("maxbytes=10000");
+ assertNotNull(Wire.read(Wire.write(new byte[50_000]), f)); // single array: accepted
+ ArrayList many = new ArrayList<>();
+ for (int i = 0; i < 5_000; i++) many.add(i);
+ assertThrows(InvalidClassException.class, () -> Wire.read(Wire.write(many), f));
+ }
+
+ @Test void recordConstructorValidationRunsOnDeserialization() throws Exception {
+ byte[] rec = Wire.write(new RecordsDemo.AgeRecord(30));
+ Wire.putInt(rec, rec.length - 4, -5);
+ InvalidObjectException e = assertThrows(InvalidObjectException.class, () -> Wire.read(rec));
+ assertInstanceOf(IllegalArgumentException.class, e.getCause());
+ }
+
+ @Test void plainClassSkipsItsConstructor() throws Exception {
+ byte[] cls = Wire.write(new RecordsDemo.AgeClass(30));
+ Wire.putInt(cls, cls.length - 4, -5);
+ assertEquals(-5, ((RecordsDemo.AgeClass) Wire.read(cls)).years);
+ }
+
+ @Test void recordDefaultUidIsZero() {
+ assertEquals(0L, ObjectStreamClass.lookup(RecordsDemo.AgeRecord.class).getSerialVersionUID());
+ }
+
+ @Test void allThreeFormatsRoundTrip() throws Exception {
+ Order o = Order.sample();
+ assertEquals(o, Codecs.javaRead(Codecs.javaWrite(o)));
+ assertEquals(o, Codecs.jsonRead(Codecs.jsonWrite(o)));
+ assertEquals(o, Codecs.pbRead(Codecs.pbWrite(o)));
+ }
+
+ @Test void sizeOrderingIsJavaGreaterThanJsonGreaterThanProtobuf() throws Exception {
+ Order o = Order.sample();
+ int j = Codecs.javaWrite(o).length, s = Codecs.jsonWrite(o).length, p = Codecs.pbWrite(o).length;
+ assertTrue(j > s && s > p, j + " " + s + " " + p);
+ }
+}
diff --git a/serialization/src/versions/common/DriftRead.java b/serialization/src/versions/common/DriftRead.java
new file mode 100644
index 0000000..85a27ea
--- /dev/null
+++ b/serialization/src/versions/common/DriftRead.java
@@ -0,0 +1,16 @@
+package com.ankurm.serialization.drift;
+
+import java.io.*;
+import java.nio.file.*;
+
+public class DriftRead {
+ public static void main(String[] args) throws Exception {
+ long uid = ObjectStreamClass.lookup(Account.class).getSerialVersionUID();
+ System.out.println("this class's serialVersionUID = " + uid);
+ try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(Path.of(args[0])))) {
+ System.out.println("read: " + in.readObject());
+ } catch (InvalidClassException e) {
+ System.out.println("InvalidClassException: " + e.getMessage());
+ }
+ }
+}
diff --git a/serialization/src/versions/common/DriftWrite.java b/serialization/src/versions/common/DriftWrite.java
new file mode 100644
index 0000000..1dc6724
--- /dev/null
+++ b/serialization/src/versions/common/DriftWrite.java
@@ -0,0 +1,15 @@
+package com.ankurm.serialization.drift;
+
+import java.io.*;
+import java.nio.file.*;
+
+public class DriftWrite {
+ public static void main(String[] args) throws Exception {
+ Path file = Path.of(args[0]);
+ try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(file))) {
+ out.writeObject(new Account("asha", 500));
+ }
+ long uid = ObjectStreamClass.lookup(Account.class).getSerialVersionUID();
+ System.out.println("wrote " + Files.size(file) + " bytes; serialVersionUID in stream = " + uid);
+ }
+}
diff --git a/serialization/src/versions/v1/Account.java b/serialization/src/versions/v1/Account.java
new file mode 100644
index 0000000..5f743a8
--- /dev/null
+++ b/serialization/src/versions/v1/Account.java
@@ -0,0 +1,14 @@
+package com.ankurm.serialization.drift;
+
+import java.io.Serializable;
+
+/** Version 1 of the class: two fields. The marker line below is replaced by run-all.sh. */
+public class Account implements Serializable {
+ /*UID*/
+ final String owner;
+ final long balance;
+
+ public Account(String owner, long balance) { this.owner = owner; this.balance = balance; }
+
+ @Override public String toString() { return "Account[owner=" + owner + ", balance=" + balance + "]"; }
+}
diff --git a/serialization/src/versions/v2/Account.java b/serialization/src/versions/v2/Account.java
new file mode 100644
index 0000000..265a587
--- /dev/null
+++ b/serialization/src/versions/v2/Account.java
@@ -0,0 +1,15 @@
+package com.ankurm.serialization.drift;
+
+import java.io.Serializable;
+
+/** Version 2 of the class: one extra field, email. The marker line below is replaced by run-all.sh. */
+public class Account implements Serializable {
+ /*UID*/
+ final String owner;
+ final long balance;
+ final String email;
+
+ public Account(String owner, long balance) { this.owner = owner; this.balance = balance; this.email = null; }
+
+ @Override public String toString() { return "Account[owner=" + owner + ", balance=" + balance + ", email=" + email + "]"; }
+}