diff --git a/.github/workflows/pr-workflow.yml b/.github/workflows/pr-workflow.yml index ec8586d72..07b14755e 100644 --- a/.github/workflows/pr-workflow.yml +++ b/.github/workflows/pr-workflow.yml @@ -11,26 +11,12 @@ jobs: - uses: actions/setup-java@v3 with: distribution: 'temurin' - java-version: 11 + java-version: 17 cache: 'maven' - - name: Build with JDK 11 + - name: Build with JDK 17 run: mvn -B install --no-transfer-progress -DskipTests - - uses: actions/setup-java@v3 - with: - distribution: 'temurin' - java-version: 8 - - name: Test with JDK 8 - run: mvn -v && mvn -B test - - - uses: actions/setup-java@v3 - with: - distribution: 'temurin' - java-version: 11 - - name: Test with JDK 11 - run: mvn -v && mvn -B test - - uses: actions/setup-java@v3 with: distribution: 'temurin' diff --git a/benchmarks/src/main/java/com/esotericsoftware/kryo/benchmarks/RecordSerializerBenchmark.java b/benchmarks/src/main/java/com/esotericsoftware/kryo/benchmarks/RecordSerializerBenchmark.java new file mode 100644 index 000000000..089ae17c4 --- /dev/null +++ b/benchmarks/src/main/java/com/esotericsoftware/kryo/benchmarks/RecordSerializerBenchmark.java @@ -0,0 +1,99 @@ +/* Copyright (c) 2008-2022, Nathan Sweet + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following + * conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided with the distribution. + * - Neither the name of Esoteric Software nor the names of its contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ + +package com.esotericsoftware.kryo.benchmarks; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.Serializer; +import com.esotericsoftware.kryo.SerializerFactory.CompatibleFieldSerializerFactory; +import com.esotericsoftware.kryo.SerializerFactory.TaggedFieldSerializerFactory; +import com.esotericsoftware.kryo.benchmarks.data.Image; +import com.esotericsoftware.kryo.benchmarks.data.Image.Size; +import com.esotericsoftware.kryo.benchmarks.data.Media; +import com.esotericsoftware.kryo.benchmarks.data.Media.Player; +import com.esotericsoftware.kryo.benchmarks.data.MediaContent; +import com.esotericsoftware.kryo.benchmarks.data.Sample; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; +import com.esotericsoftware.kryo.serializers.CollectionSerializer; +import com.esotericsoftware.kryo.serializers.FieldSerializer; +import com.esotericsoftware.kryo.serializers.RecordSerializer; +import com.esotericsoftware.kryo.serializers.VersionFieldSerializer; + +import java.util.ArrayList; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; + +public class RecordSerializerBenchmark { + @Benchmark + public void field (FieldSerializerState state) { + state.roundTrip(); + } + + @Benchmark + public void record (RecordSerializerState state) { + state.roundTrip(); + } + + @State(Scope.Thread) + static public abstract class BenchmarkState { + @Param({"true", "false"}) public boolean references; + + final Kryo kryo = new Kryo(); + final Output output = new Output(1024 * 512); + final Input input = new Input(output.getBuffer()); + Object object; + + @Setup(Level.Trial) + public void setup () { + object = new RecordRectangle("2134324", 10, 10L, 20D); + kryo.setReferences(references); + } + + public void roundTrip () { + output.setPosition(0); + kryo.writeObject(output, object); + input.setPosition(0); + input.setLimit(output.position()); + kryo.readObject(input, object.getClass()); + } + } + + public record RecordRectangle (String height, int width, long x, double y) { } + + static public class FieldSerializerState extends BenchmarkState { + public void setup () { + kryo.setDefaultSerializer(FieldSerializer.class); + kryo.register(RecordRectangle.class); + super.setup(); + } + } + + static public class RecordSerializerState extends BenchmarkState { + public void setup () { + kryo.register(RecordRectangle.class, new RecordSerializer<>()); + super.setup(); + } + } +} diff --git a/pom.xml b/pom.xml index b4aac4a5a..4a32bf136 100644 --- a/pom.xml +++ b/pom.xml @@ -49,7 +49,7 @@ ${basedir} 5 - 1.8 + 17 UTF-8 5.10.0 1.9.10 diff --git a/src/com/esotericsoftware/kryo/Kryo.java b/src/com/esotericsoftware/kryo/Kryo.java index c83fa4865..d3c672b63 100644 --- a/src/com/esotericsoftware/kryo/Kryo.java +++ b/src/com/esotericsoftware/kryo/Kryo.java @@ -81,7 +81,6 @@ import com.esotericsoftware.kryo.serializers.ImmutableCollectionsSerializers; import com.esotericsoftware.kryo.serializers.MapSerializer; import com.esotericsoftware.kryo.serializers.OptionalSerializers; -import com.esotericsoftware.kryo.serializers.RecordSerializer; import com.esotericsoftware.kryo.serializers.TimeSerializers; import com.esotericsoftware.kryo.util.DefaultClassResolver; import com.esotericsoftware.kryo.util.DefaultGenerics; @@ -230,10 +229,6 @@ public Kryo (ClassResolver classResolver, ReferenceResolver referenceResolver) { OptionalSerializers.addDefaultSerializers(this); TimeSerializers.addDefaultSerializers(this); ImmutableCollectionsSerializers.addDefaultSerializers(this); - // Add RecordSerializer if JDK 14+ available - if (isClassAvailable("java.lang.Record")) { - addDefaultSerializer("java.lang.Record", RecordSerializer.class); - } lowPriorityDefaultSerializerCount = defaultSerializers.size(); // Primitives and string. Primitive wrappers automatically use the same registration as primitives. @@ -284,17 +279,6 @@ public void addDefaultSerializer (Class type, SerializerFactory serializerFactor insertDefaultSerializer(type, serializerFactory); } - /** Instances with the specified class name will use the specified serializer when {@link #register(Class)} or - * {@link #register(Class, int)} are called. - * @see #setDefaultSerializer(Class) */ - private void addDefaultSerializer (String className, Class serializer) { - try { - addDefaultSerializer(Class.forName(className), serializer); - } catch (ClassNotFoundException e) { - throw new KryoException("default serializer cannot be added: " + className); - } - } - /** Instances of the specified class will use the specified serializer when {@link #register(Class)} or * {@link #register(Class, int)} are called. Serializer instances are created as needed via * {@link ReflectionSerializerFactory#newSerializer(Kryo, Class, Class)}. By default, the following classes have a default diff --git a/src/com/esotericsoftware/kryo/serializers/AsmField.java b/src/com/esotericsoftware/kryo/serializers/AsmField.java index f925f1903..d630f4b45 100644 --- a/src/com/esotericsoftware/kryo/serializers/AsmField.java +++ b/src/com/esotericsoftware/kryo/serializers/AsmField.java @@ -74,6 +74,13 @@ public void read (Input input, Object object) { access.setInt(object, accessIndex, input.readInt()); } + public Object read(Input input) { + if (varEncoding) + return input.readVarInt(false); + else + return input.readInt(); + } + public void copy (Object original, Object copy) { access.setInt(copy, accessIndex, access.getInt(original, accessIndex)); } @@ -92,6 +99,10 @@ public void read (Input input, Object object) { access.setFloat(object, accessIndex, input.readFloat()); } + public Object read(Input input) { + return input.readFloat(); + } + public void copy (Object original, Object copy) { access.setFloat(copy, accessIndex, access.getFloat(original, accessIndex)); } @@ -110,6 +121,10 @@ public void read (Input input, Object object) { access.setShort(object, accessIndex, input.readShort()); } + public Object read(Input input) { + return input.readShort(); + } + public void copy (Object original, Object copy) { access.setShort(copy, accessIndex, access.getShort(original, accessIndex)); } @@ -128,6 +143,10 @@ public void read (Input input, Object object) { access.setByte(object, accessIndex, input.readByte()); } + public Object read(Input input) { + return input.readByte(); + } + public void copy (Object original, Object copy) { access.setByte(copy, accessIndex, access.getByte(original, accessIndex)); } @@ -146,6 +165,10 @@ public void read (Input input, Object object) { access.setBoolean(object, accessIndex, input.readBoolean()); } + public Object read(Input input) { + return input.readBoolean(); + } + public void copy (Object original, Object copy) { access.setBoolean(copy, accessIndex, access.getBoolean(original, accessIndex)); } @@ -164,6 +187,10 @@ public void read (Input input, Object object) { access.setChar(object, accessIndex, input.readChar()); } + public Object read(Input input) { + return input.readChar(); + } + public void copy (Object original, Object copy) { access.setChar(copy, accessIndex, access.getChar(original, accessIndex)); } @@ -188,6 +215,14 @@ public void read (Input input, Object object) { access.setLong(object, accessIndex, input.readLong()); } + public Object read(Input input) { + if (varEncoding) { + return input.readVarLong(false); + } else { + return input.readLong(); + } + } + public void copy (Object original, Object copy) { access.setLong(copy, accessIndex, access.getLong(original, accessIndex)); } @@ -206,6 +241,10 @@ public void read (Input input, Object object) { access.setDouble(object, accessIndex, input.readDouble()); } + public Object read(Input input) { + return input.readDouble(); + } + public void copy (Object original, Object copy) { access.setDouble(copy, accessIndex, access.getDouble(original, accessIndex)); } @@ -224,6 +263,10 @@ public void read (Input input, Object object) { access.set(object, accessIndex, input.readString()); } + public Object read(Input input) { + return input.readString(); + } + public void copy (Object original, Object copy) { access.set(copy, accessIndex, access.getString(original, accessIndex)); } diff --git a/src/com/esotericsoftware/kryo/serializers/CachedFields.java b/src/com/esotericsoftware/kryo/serializers/CachedFields.java index 346cb0434..d8f1ab2b4 100644 --- a/src/com/esotericsoftware/kryo/serializers/CachedFields.java +++ b/src/com/esotericsoftware/kryo/serializers/CachedFields.java @@ -62,6 +62,7 @@ import java.lang.reflect.Field; import java.lang.reflect.Modifier; +import java.lang.reflect.RecordComponent; import java.security.AccessControlException; import java.util.ArrayList; import java.util.Arrays; @@ -135,8 +136,9 @@ private void addField (Field field, boolean asm, ArrayList fields, boolean isTransient = Modifier.isTransient(modifiers); if (isTransient && !config.serializeTransient && !config.copyTransient) return; + Class type = serializer.type; Class declaringClass = field.getDeclaringClass(); - GenericType genericType = new GenericType(declaringClass, serializer.type, field.getGenericType()); + GenericType genericType = new GenericType(declaringClass, type, field.getGenericType()); Class fieldClass = genericType.getType() instanceof Class ? (Class)genericType.getType() : field.getType(); int accessIndex = -1; if (asm // @@ -144,7 +146,7 @@ private void addField (Field field, boolean asm, ArrayList fields, && Modifier.isPublic(modifiers) // && Modifier.isPublic(fieldClass.getModifiers())) { try { - if (access == null) access = FieldAccess.get(serializer.type); + if (access == null) access = FieldAccess.get(type); accessIndex = ((FieldAccess)access).getIndex(field); } catch (RuntimeException | LinkageError ex) { if (DEBUG) debug("kryo", "Unable to use ReflectASM.", ex); @@ -152,7 +154,7 @@ private void addField (Field field, boolean asm, ArrayList fields, } CachedField cachedField; - if (unsafe) + if (unsafe && !type.isRecord()) cachedField = newUnsafeField(field, fieldClass, genericType); else if (accessIndex != -1) { cachedField = newAsmField(field, fieldClass, genericType); @@ -183,6 +185,17 @@ else if (accessIndex != -1) { "Cached " + fieldClass.getSimpleName() + " field: " + field.getName() + " (" + className(declaringClass) + ")"); } + final RecordComponent[] recordComponents = type.getRecordComponents(); + if (recordComponents != null) { + for (int i = 0; i < recordComponents.length; i++) { + RecordComponent recordComponent = recordComponents[i]; + if (recordComponent.getName().equals(field.getName())) { + cachedField.index = i; + break; + } + } + } + applyAnnotations(cachedField); if (isTransient) { diff --git a/src/com/esotericsoftware/kryo/serializers/CompatibleFieldSerializer.java b/src/com/esotericsoftware/kryo/serializers/CompatibleFieldSerializer.java index d918421df..892157872 100644 --- a/src/com/esotericsoftware/kryo/serializers/CompatibleFieldSerializer.java +++ b/src/com/esotericsoftware/kryo/serializers/CompatibleFieldSerializer.java @@ -114,8 +114,12 @@ public void write (Kryo kryo, Output output, T object) { public T read (Kryo kryo, Input input, Class type) { int pop = pushTypeVariables(); - T object = create(kryo, input, type); - kryo.reference(object); + T object = null; + final boolean isRecord = type.isRecord(); + if (!isRecord) { + object = create(kryo, input, type); + kryo.reference(object); + } CachedField[] fields = (CachedField[])kryo.getGraphContext().get(this); if (fields == null) fields = readFields(kryo, input); @@ -127,6 +131,7 @@ public T read (Kryo kryo, Input input, Class type) { fieldInput = inputChunked = new InputChunked(input, config.chunkSize); else fieldInput = input; + Object[] values = null; for (int i = 0, n = fields.length; i < n; i++) { CachedField cachedField = fields[i]; @@ -182,10 +187,19 @@ public T read (Kryo kryo, Input input, Class type) { } if (TRACE) log("Read", cachedField, input.position()); - cachedField.read(fieldInput, object); + if (object != null) { + cachedField.read(fieldInput, object); + } else { + if (values == null) values = new Object[cachedFields.fields.length]; + values[cachedField.index] = cachedField.read(fieldInput); + } if (chunked) inputChunked.nextChunk(); } + if (isRecord) { + object = invokeCanonicalConstructor(type, cachedFields.fields, values); + } + popTypeVariables(pop); return object; } diff --git a/src/com/esotericsoftware/kryo/serializers/FieldSerializer.java b/src/com/esotericsoftware/kryo/serializers/FieldSerializer.java index 243f6fd44..164d69867 100644 --- a/src/com/esotericsoftware/kryo/serializers/FieldSerializer.java +++ b/src/com/esotericsoftware/kryo/serializers/FieldSerializer.java @@ -37,7 +37,11 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.lang.reflect.Constructor; import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Objects; /** Serializes objects using direct field assignment. FieldSerializer is generic and can serialize most classes without any * configuration. All non-public fields are written and read by default, so it is important to evaluate each class that will be @@ -120,14 +124,25 @@ public void write (Kryo kryo, Output output, T object) { public T read (Kryo kryo, Input input, Class type) { int pop = pushTypeVariables(); - T object = create(kryo, input, type); - kryo.reference(object); + T object = null; + final boolean isRecord = type.isRecord(); + if (!isRecord) { + object = create(kryo, input, type); + kryo.reference(object); + } CachedField[] fields = cachedFields.fields; + Object[] values = null; for (int i = 0, n = fields.length; i < n; i++) { if (TRACE) log("Read", fields[i], input.position()); try { - fields[i].read(input, object); + final CachedField field = fields[i]; + if (object != null) { + field.read(input, object); + } else { + if (values == null) values = new Object[fields.length]; + values[field.index] = field.read(input); + } } catch (KryoException e) { throw e; } catch (OutOfMemoryError | Exception e) { @@ -135,10 +150,39 @@ public T read (Kryo kryo, Input input, Class type) { } } + if (isRecord) { + object = invokeCanonicalConstructor(type, fields, values); + } + popTypeVariables(pop); return object; } + static T invokeCanonicalConstructor(Class type, CachedField[] fields, Object[] values) { + final Class[] objects = Arrays.stream(fields) + .sorted(Comparator.comparing(f -> f.index)) + .map(f -> f.field.getType()) + .toArray(Class[]::new); + return invokeCanonicalConstructor(type, objects, values); + } + + static T invokeCanonicalConstructor (Class type, Class[] paramTypes, Object[] args) { + try { + Constructor canonicalConstructor; + try { + canonicalConstructor = type.getConstructor(paramTypes); + } catch (NoSuchMethodException e) { + canonicalConstructor = type.getDeclaredConstructor(paramTypes); + canonicalConstructor.setAccessible(true); + } + return canonicalConstructor.newInstance(args); + } catch (Throwable t) { + KryoException ex = new KryoException(t); + ex.addTrace("Could not construct type (" + type.getName() + ")"); + throw ex; + } + } + /** Prepares the type variables for the serialized type. Must be balanced with {@link #popTypeVariables(int)} if >0 is * returned. */ protected int pushTypeVariables () { @@ -223,12 +267,27 @@ protected T createCopy (Kryo kryo, T original) { } public T copy (Kryo kryo, T original) { - T copy = createCopy(kryo, original); - kryo.reference(copy); - - for (int i = 0, n = cachedFields.copyFields.length; i < n; i++) - cachedFields.copyFields[i].copy(original, copy); - + final T copy; + final CachedField[] copyFields = cachedFields.copyFields; + final boolean isRecord = original.getClass().isRecord(); + if (!isRecord) { + copy = createCopy(kryo, original); + kryo.reference(copy); + for (int i = 0, n = copyFields.length; i < n; i++) { + copyFields[i].copy(original, copy); + } + } else { + final Object[] values = new Object[copyFields.length]; + for (int i = 0, n = copyFields.length; i < n; i++) { + final CachedField field = copyFields[i]; + try { + values[field.index] = field.get(original); + } catch (IllegalAccessException e) { + throw new KryoException("Error accessing field: " + field.getName() + " (" + type.getName() + ")", e); + } + } + copy = (T) invokeCanonicalConstructor(type, copyFields, values); + } return copy; } @@ -243,6 +302,9 @@ public abstract static class CachedField { // For AsmField. FieldAccess access; int accessIndex = -1; + + // For Records + int index; // For UnsafeField. long offset; @@ -346,8 +408,13 @@ public String toString () { public abstract void read (Input input, Object object); + public abstract Object read (Input input); + public abstract void copy (Object original, Object copy); + Object get(Object object) throws IllegalAccessException { + return field.get(object); + } } /** Indicates a field should be ignored when its declaring class is registered unless the {@link Kryo#getContext() context} has diff --git a/src/com/esotericsoftware/kryo/serializers/ReflectField.java b/src/com/esotericsoftware/kryo/serializers/ReflectField.java index a121a5196..1b4f855ad 100644 --- a/src/com/esotericsoftware/kryo/serializers/ReflectField.java +++ b/src/com/esotericsoftware/kryo/serializers/ReflectField.java @@ -149,6 +149,47 @@ public void read (Input input, Object object) { } } + public Object read(Input input) { + Kryo kryo = fieldSerializer.kryo; + try { + Object value; + + Serializer serializer = this.serializer; + Class concreteType = resolveFieldClass(); + if (concreteType == null) { + // The concrete type of the field is unknown, read the class first. + Registration registration = kryo.readClass(input); + if (registration == null) { + return null; + } + if (serializer == null) serializer = registration.getSerializer(); + kryo.getGenerics().pushGenericType(genericType); + value = kryo.readObject(input, registration.getType(), serializer); + } else { + if (serializer == null) { + serializer = kryo.getSerializer(concreteType); + // The concrete type of the field is known, always use the same serializer. + if (valueClass != null && reuseSerializer) this.serializer = serializer; + } + kryo.getGenerics().pushGenericType(genericType); + if (canBeNull) + value = kryo.readObjectOrNull(input, concreteType, serializer); + else + value = kryo.readObject(input, concreteType, serializer); + } + kryo.getGenerics().popGenericType(); + + return value; + } catch (KryoException ex) { + ex.addTrace(name + " (" + fieldSerializer.type.getName() + ")"); + throw ex; + } catch (Throwable t) { + KryoException ex = new KryoException(t); + ex.addTrace(name + " (" + fieldSerializer.type.getName() + ")"); + throw ex; + } + } + Class resolveFieldClass () { if (valueClass == null) { Class fieldClass = genericType.resolve(fieldSerializer.kryo.getGenerics()); @@ -205,6 +246,13 @@ public void read (Input input, Object object) { } } + public Object read (Input input) { + if (varEncoding) + return input.readVarInt(false); + else + return input.readInt(); + } + public void copy (Object original, Object copy) { try { field.setInt(copy, field.getInt(original)); @@ -241,6 +289,10 @@ public void read (Input input, Object object) { } } + public Object read (Input input) { + return input.readFloat(); + } + public void copy (Object original, Object copy) { try { field.setFloat(copy, field.getFloat(original)); @@ -277,6 +329,10 @@ public void read (Input input, Object object) { } } + public Object read (Input input) { + return input.readShort(); + } + public void copy (Object original, Object copy) { try { field.setShort(copy, field.getShort(original)); @@ -313,6 +369,10 @@ public void read (Input input, Object object) { } } + public Object read (Input input) { + return input.readByte(); + } + public void copy (Object original, Object copy) { try { field.setByte(copy, field.getByte(original)); @@ -349,6 +409,10 @@ public void read (Input input, Object object) { } } + public Object read(Input input) { + return input.readBoolean(); + } + public void copy (Object original, Object copy) { try { field.setBoolean(copy, field.getBoolean(original)); @@ -385,6 +449,10 @@ public void read (Input input, Object object) { } } + public Object read(Input input) { + return input.readChar(); + } + public void copy (Object original, Object copy) { try { field.setChar(copy, field.getChar(original)); @@ -427,6 +495,13 @@ public void read (Input input, Object object) { } } + public Object read(Input input) { + if (varEncoding) + return input.readVarLong(false); + else + return input.readLong(); + } + public void copy (Object original, Object copy) { try { field.setLong(copy, field.getLong(original)); @@ -463,6 +538,10 @@ public void read (Input input, Object object) { } } + public Object read(Input input) { + return input.readDouble(); + } + public void copy (Object original, Object copy) { try { field.setDouble(copy, field.getDouble(original)); diff --git a/src/com/esotericsoftware/kryo/serializers/TaggedFieldSerializer.java b/src/com/esotericsoftware/kryo/serializers/TaggedFieldSerializer.java index 288be5b35..fbabb3005 100644 --- a/src/com/esotericsoftware/kryo/serializers/TaggedFieldSerializer.java +++ b/src/com/esotericsoftware/kryo/serializers/TaggedFieldSerializer.java @@ -38,6 +38,8 @@ import java.lang.annotation.Target; import java.lang.reflect.Field; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; /** Serializes objects using direct field assignment for fields that have a @Tag(int) annotation, providing backward * compatibility and optional forward compatibility. This means fields can be added or renamed and optionally removed without @@ -174,8 +176,12 @@ public T read (Kryo kryo, Input input, Class type) { int pop = pushTypeVariables(); - T object = create(kryo, input, type); - kryo.reference(object); + T object = null; + final boolean isRecord = type.isRecord(); + if (!isRecord) { + object = create(kryo, input, type); + kryo.reference(object); + } boolean chunked = config.chunked, readUnknownTagData = config.readUnknownTagData; Input fieldInput; @@ -184,7 +190,9 @@ public T read (Kryo kryo, Input input, Class type) { fieldInput = inputChunked = new InputChunked(input, config.chunkSize); else fieldInput = input; - IntMap readTags = this.readTags; + + CachedField[] fields = cachedFields.fields; + Object[] values = null; for (int i = 0; i < fieldCount; i++) { int tag = input.readVarInt(true); CachedField cachedField = readTags.get(tag); @@ -231,10 +239,19 @@ public T read (Kryo kryo, Input input, Class type) { } if (TRACE) log("Read", cachedField, input.position()); - cachedField.read(fieldInput, object); + if (object != null) { + cachedField.read(fieldInput, object); + } else { + if (values == null) values = new Object[fields.length]; + values[cachedField.index] = cachedField.read(fieldInput); + } if (chunked) inputChunked.nextChunk(); } + if (isRecord) { + object = invokeCanonicalConstructor(type, fields, values); + } + popTypeVariables(pop); return object; } diff --git a/src/com/esotericsoftware/kryo/serializers/UnsafeField.java b/src/com/esotericsoftware/kryo/serializers/UnsafeField.java index 23e68ec63..60cb11ea5 100644 --- a/src/com/esotericsoftware/kryo/serializers/UnsafeField.java +++ b/src/com/esotericsoftware/kryo/serializers/UnsafeField.java @@ -79,6 +79,13 @@ public void read (Input input, Object object) { unsafe.putInt(object, offset, input.readInt()); } + public Object read(Input input) { + if (varEncoding) + return input.readVarInt(false); + else + return input.readInt(); + } + public void copy (Object original, Object copy) { unsafe.putInt(copy, offset, unsafe.getInt(original, offset)); } @@ -98,6 +105,10 @@ public void read (Input input, Object object) { unsafe.putFloat(object, offset, input.readFloat()); } + public Object read(Input input) { + return input.readFloat(); + } + public void copy (Object original, Object copy) { unsafe.putFloat(copy, offset, unsafe.getFloat(original, offset)); } @@ -117,6 +128,10 @@ public void read (Input input, Object object) { unsafe.putShort(object, offset, input.readShort()); } + public Object read(Input input) { + return input.readShort(); + } + public void copy (Object original, Object copy) { unsafe.putShort(copy, offset, unsafe.getShort(original, offset)); } @@ -136,6 +151,10 @@ public void read (Input input, Object object) { unsafe.putByte(object, offset, input.readByte()); } + public Object read(Input input) { + return input.readShort(); + } + public void copy (Object original, Object copy) { unsafe.putByte(copy, offset, unsafe.getByte(original, offset)); } @@ -155,6 +174,10 @@ public void read (Input input, Object object) { unsafe.putBoolean(object, offset, input.readBoolean()); } + public Object read(Input input) { + return input.readShort(); + } + public void copy (Object original, Object copy) { unsafe.putBoolean(copy, offset, unsafe.getBoolean(original, offset)); } @@ -174,6 +197,10 @@ public void read (Input input, Object object) { unsafe.putChar(object, offset, input.readChar()); } + public Object read(Input input) { + return input.readShort(); + } + public void copy (Object original, Object copy) { unsafe.putChar(copy, offset, unsafe.getChar(original, offset)); } @@ -199,6 +226,13 @@ public void read (Input input, Object object) { unsafe.putLong(object, offset, input.readLong()); } + public Object read (Input input) { + if (varEncoding) + return input.readVarLong(false); + else + return input.readLong(); + } + public void copy (Object original, Object copy) { unsafe.putLong(copy, offset, unsafe.getLong(original, offset)); } @@ -218,6 +252,10 @@ public void read (Input input, Object object) { unsafe.putDouble(object, offset, input.readDouble()); } + public Object read(Input input) { + return input.readDouble(); + } + public void copy (Object original, Object copy) { unsafe.putDouble(copy, offset, unsafe.getDouble(original, offset)); } @@ -237,6 +275,10 @@ public void read (Input input, Object object) { unsafe.putObject(object, offset, input.readString()); } + public Object read(Input input) { + return input.readString(); + } + public void copy (Object original, Object copy) { unsafe.putObject(copy, offset, unsafe.getObject(original, offset)); } diff --git a/src/com/esotericsoftware/kryo/serializers/VersionFieldSerializer.java b/src/com/esotericsoftware/kryo/serializers/VersionFieldSerializer.java index e0eb18470..187ed1ea5 100644 --- a/src/com/esotericsoftware/kryo/serializers/VersionFieldSerializer.java +++ b/src/com/esotericsoftware/kryo/serializers/VersionFieldSerializer.java @@ -32,6 +32,8 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Comparator; /** Serializes objects using direct field assignment, providing backward compatibility with minimal overhead. This means fields * can be added without invalidating previously serialized bytes. Removing, renaming, or changing the type of a field is not @@ -117,10 +119,15 @@ public T read (Kryo kryo, Input input, Class type) { int pop = pushTypeVariables(); - T object = create(kryo, input, type); - kryo.reference(object); + T object = null; + final boolean isRecord = type.isRecord(); + if (!isRecord) { + object = create(kryo, input, type); + kryo.reference(object); + } CachedField[] fields = cachedFields.fields; + Object[] values = null; for (int i = 0, n = fields.length; i < n; i++) { // Field is not present in input, skip it. if (fieldVersion[i] > version) { @@ -128,7 +135,17 @@ public T read (Kryo kryo, Input input, Class type) { continue; } if (TRACE) log("Read", fields[i], input.position()); - fields[i].read(input, object); + final CachedField field = fields[i]; + if (object != null) { + field.read(input, object); + } else { + if (values == null) values = new Object[fields.length]; + values[field.index] = field.read(input); + } + } + + if (isRecord) { + object = invokeCanonicalConstructor(type, fields, values); } popTypeVariables(pop); diff --git a/test-jdk17/com/esotericsoftware/kryo/serializers/RecordSerializerTest.java b/test-jdk17/com/esotericsoftware/kryo/serializers/RecordSerializerTest.java index 1a8a92e68..955397791 100644 --- a/test-jdk17/com/esotericsoftware/kryo/serializers/RecordSerializerTest.java +++ b/test-jdk17/com/esotericsoftware/kryo/serializers/RecordSerializerTest.java @@ -306,8 +306,7 @@ public static record RecordWithSuperType(Number n) {} @Test void testRecordWithSuperType() { - var rc = new RecordSerializer<>(RecordWithSuperType.class); - kryo.register(RecordWithSuperType.class, rc); + kryo.register(RecordWithSuperType.class); final var r = new RecordWithSuperType(1L); final var output = new Output(32); @@ -329,4 +328,13 @@ void testNonPublicRecords() { roundTrip(4, new PackagePrivateRecord(1, "s1")); roundTrip(4, new PrivateRecord("s2",2)); } + + record InterfaceRecord(int i, CharSequence s) {} + + @Test + void testRecordWithInterface() { + kryo.register(InterfaceRecord.class); + + roundTrip(5, new InterfaceRecord(1, "s1")); + } } diff --git a/test/com/esotericsoftware/kryo/SerializationCompatTest.java b/test/com/esotericsoftware/kryo/SerializationCompatTest.java index 9cd59a34c..73cae51bb 100644 --- a/test/com/esotericsoftware/kryo/SerializationCompatTest.java +++ b/test/com/esotericsoftware/kryo/SerializationCompatTest.java @@ -84,7 +84,7 @@ class SerializationCompatTest extends KryoTestCase { } } private static final int EXPECTED_DEFAULT_SERIALIZER_COUNT = JAVA_VERSION < 11 - ? 58 : JAVA_VERSION < 14 ? 68 : 69; // Also change Kryo#defaultSerializers. + ? 58 : 68; // Also change Kryo#defaultSerializers. private static final List TEST_DATAS = new ArrayList<>(); static { diff --git a/test/com/esotericsoftware/kryo/serializers/CompatibleFieldSerializerTest.java b/test/com/esotericsoftware/kryo/serializers/CompatibleFieldSerializerTest.java index d78d67e67..7635af72c 100644 --- a/test/com/esotericsoftware/kryo/serializers/CompatibleFieldSerializerTest.java +++ b/test/com/esotericsoftware/kryo/serializers/CompatibleFieldSerializerTest.java @@ -513,6 +513,49 @@ void testClassWithGenericField () { roundTrip(9, new ClassWithGenericField<>(1)); } + @Test + void testRecordNewToOld() { + final RecordClass recordClass = new RecordClass("1", 2, 3L, 4d); + + kryo.setDefaultSerializer(CompatibleFieldSerializer.class); + kryo.register(RecordClass.class); + kryo.register(OldRecordClass.class); + + Output output = new Output(2048, -1); + kryo.writeObject(output, recordClass); + output.close(); + + Input input = new Input(output.toBytes()); + OldRecordClass deserialized = kryo.readObject(input, OldRecordClass.class); + input.close(); + + assertEquals(deserialized.width(), recordClass.width()); + assertEquals(deserialized.x(), recordClass.x()); + assertEquals(deserialized.y(), recordClass.y()); + } + + @Test + void testRecordOldToNew() { + final OldRecordClass recordClass = new OldRecordClass(3L, 4d, 2); + + kryo.setDefaultSerializer(CompatibleFieldSerializer.class); + kryo.register(RecordClass.class); + kryo.register(OldRecordClass.class); + + Output output = new Output(2048, -1); + kryo.writeObject(output, recordClass); + output.close(); + + Input input = new Input(output.toBytes()); + RecordClass deserialized = kryo.readObject(input, RecordClass.class); + input.close(); + + assertNull(deserialized.height()); + assertEquals(deserialized.width(), recordClass.width()); + assertEquals(deserialized.x(), recordClass.x()); + assertEquals(deserialized.y(), recordClass.y()); + } + public static class TestClass { public String text = "something"; public int moo = 120; @@ -809,4 +852,8 @@ public boolean equals (Object o) { return Objects.equals(value, that.value); } } + + public record OldRecordClass(long x, double y, int width) { } + + public record RecordClass(String height, int width, long x, double y) { } } diff --git a/test/com/esotericsoftware/kryo/serializers/FieldSerializerTest.java b/test/com/esotericsoftware/kryo/serializers/FieldSerializerTest.java index f87bb883f..b0104b044 100644 --- a/test/com/esotericsoftware/kryo/serializers/FieldSerializerTest.java +++ b/test/com/esotericsoftware/kryo/serializers/FieldSerializerTest.java @@ -716,6 +716,22 @@ void testCircularReference () { fail("Exception was expected"); } + @Test + void testRecord () { + kryo.register(RecordClass.class); + + roundTrip(13, new RecordClass("1", 1, 1L, 1d)); + } + + @Test + void testCopyRecord () { + kryo.register(RecordClass.class); + + final RecordClass o = new RecordClass("1", 1, 1L, 1d); + final RecordClass copy = kryo.copy(o); + doAssertEquals(o, copy); + } + public static class DefaultTypes { // Primitives. public boolean booleanField; @@ -1300,4 +1316,6 @@ public Inner(CircularReference a) { } } + public record RecordClass(String height, int width, long x, double y) { } + } diff --git a/test/com/esotericsoftware/kryo/serializers/TaggedFieldSerializerTest.java b/test/com/esotericsoftware/kryo/serializers/TaggedFieldSerializerTest.java index 712806ee8..12bd847db 100644 --- a/test/com/esotericsoftware/kryo/serializers/TaggedFieldSerializerTest.java +++ b/test/com/esotericsoftware/kryo/serializers/TaggedFieldSerializerTest.java @@ -49,10 +49,12 @@ void testTaggedFieldSerializer () { object1.other = new AnotherClass(); object1.other.value = "meow"; object1.ignored = 32; + object1.record = new RecordClass("1", 1, 1L, 1d); kryo.setDefaultSerializer(TaggedFieldSerializer.class); kryo.register(TestClass.class); kryo.register(AnotherClass.class); - TestClass object2 = roundTrip(57, object1); + kryo.register(RecordClass.class); + TestClass object2 = roundTrip(77, object1); assertEquals(0, object2.ignored); } @@ -67,7 +69,8 @@ void testAddedField () { serializer.removeField("text"); kryo.register(TestClass.class, serializer); kryo.register(AnotherClass.class, new TaggedFieldSerializer(kryo, AnotherClass.class)); - roundTrip(39, object1); + kryo.register(RecordClass.class, new TaggedFieldSerializer(kryo, AnotherClass.class)); + roundTrip(43, object1); kryo.register(TestClass.class, new TaggedFieldSerializer(kryo, TestClass.class)); Object object2 = kryo.readClassAndObject(input); @@ -100,6 +103,7 @@ void testForwardCompatibility () { factory.getConfig().setChunkedEncoding(true); kryo.setDefaultSerializer(factory); kryo.register(TestClass.class); + kryo.register(RecordClass.class); kryo.register(Object[].class); TaggedFieldSerializer futureSerializer = new TaggedFieldSerializer(kryo, FutureClass.class); futureSerializer.getTaggedFieldSerializerConfig().setChunkedEncoding(true); @@ -137,6 +141,47 @@ void testForwardCompatibility () { assertEquals(futureArray[1], presentArray[1]); } + @Test + void testTaggedRecordNewToOld() { + final RecordClass recordClass = new RecordClass("1", 2, 3L, 4d); + + final TaggedFieldSerializer.TaggedFieldSerializerConfig cfg = new TaggedFieldSerializer.TaggedFieldSerializerConfig(); + cfg.setChunkedEncoding(true); + cfg.setReadUnknownTagData(true); + kryo.setDefaultSerializer(new TaggedFieldSerializerFactory(cfg)); + kryo.register(RecordClass.class); + kryo.register(OldRecordClass.class); + + Output output = new Output(2048, -1); + kryo.writeObject(output, recordClass); + output.close(); + + Input input = new Input(output.toBytes()); + Object deserialized = kryo.readObject(input, OldRecordClass.class); + input.close(); + + assertNotNull(deserialized); + } + + @Test + void testTaggedRecordOldToNew() { + final OldRecordClass recordClass = new OldRecordClass(3L, 4d, 2); + + kryo.setDefaultSerializer(TaggedFieldSerializer.class); + kryo.register(RecordClass.class); + kryo.register(OldRecordClass.class); + + Output output = new Output(2048, -1); + kryo.writeObject(output, recordClass); + output.close(); + + Input input = new Input(output.toBytes()); + Object deserialized = kryo.readObject(input, RecordClass.class); + input.close(); + + assertNotNull(deserialized); + } + /** Attempts to register a class with a field tagged with a value already used in its superclass. Should receive * IllegalArgumentException. */ @Test @@ -196,7 +241,8 @@ public static class TestClass { @Tag(3) public TestClass child; @Tag(4) public int zzz = 123; @Tag(5) public AnotherClass other; - @Tag(6) @Deprecated public int ignored; + @Tag(6) public RecordClass record; + @Tag(7) @Deprecated public int ignored; public boolean equals (Object obj) { if (this == obj) return true; @@ -212,6 +258,7 @@ public boolean equals (Object obj) { if (other.text != null) return false; } else if (!text.equals(other.text)) return false; if (zzz != other.zzz) return false; + if (!Objects.equals(record, other.record)) return false; return true; } } @@ -224,6 +271,10 @@ public static class AnotherClass { @Tag(1) String value; } + public record OldRecordClass(@Tag(0) long x, @Tag(1) double y, @Tag(2) int width) { } + + public record RecordClass(@Tag(3) String height, @Tag(2) int width, @Tag(0) long x, @Tag(1) double y) { } + private static class FutureClass { @Tag(0) public Integer value; @Tag(1) public FutureClass2 futureClass2; diff --git a/test/com/esotericsoftware/kryo/serializers/VersionedFieldSerializerTest.java b/test/com/esotericsoftware/kryo/serializers/VersionedFieldSerializerTest.java index 5d8669a90..a91f51b54 100644 --- a/test/com/esotericsoftware/kryo/serializers/VersionedFieldSerializerTest.java +++ b/test/com/esotericsoftware/kryo/serializers/VersionedFieldSerializerTest.java @@ -22,10 +22,14 @@ import static org.junit.jupiter.api.Assertions.*; import com.esotericsoftware.kryo.KryoTestCase; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; import com.esotericsoftware.kryo.serializers.VersionFieldSerializer.Since; import org.junit.jupiter.api.Test; +import java.util.Objects; + class VersionedFieldSerializerTest extends KryoTestCase { { supportsCopy = true; @@ -38,21 +42,61 @@ void testVersionFieldSerializer () { object1.child = null; object1.other = new AnotherClass(); object1.other.value = "meow"; + object1.record = new RecordClass("1", 2, 3L, 4d); kryo.setDefaultSerializer(VersionFieldSerializer.class); kryo.register(AnotherClass.class); + kryo.register(RecordClass.class); // Make VersionFieldSerializer handle "child" field being null. VersionFieldSerializer serializer = new VersionFieldSerializer(kryo, TestClass.class); serializer.getField("child").setValueClass(TestClass.class, serializer); kryo.register(TestClass.class, serializer); - TestClass object2 = roundTrip(25, object1); + TestClass object2 = roundTrip(38, object1); assertEquals(object2.moo, object1.moo); assertEquals(object2.other.value, object1.other.value); } + @Test + void testVersionedRecordNewToOld() { + final RecordClass recordClass = new RecordClass("1", 2, 3L, 4d); + + kryo.setDefaultSerializer(VersionFieldSerializer.class); + kryo.register(RecordClass.class); + kryo.register(OldRecordClass.class); + + Output output = new Output(2048, -1); + kryo.writeObject(output, recordClass); + output.close(); + + Input input = new Input(output.toBytes()); + Object deserialized = kryo.readObject(input, OldRecordClass.class); + input.close(); + + assertNotNull(deserialized); + } + + @Test + void testVersionedRecordOldToNew() { + final OldRecordClass recordClass = new OldRecordClass( 2, 3L, 4d); + + kryo.setDefaultSerializer(VersionFieldSerializer.class); + kryo.register(RecordClass.class); + kryo.register(OldRecordClass.class); + + Output output = new Output(2048, -1); + kryo.writeObject(output, recordClass); + output.close(); + + Input input = new Input(output.toBytes()); + Object deserialized = kryo.readObject(input, RecordClass.class); + input.close(); + + assertNotNull(deserialized); + } + public static class TestClass { @Since(1) public String text = "something"; @Since(1) public int moo = 120; @@ -60,6 +104,7 @@ public static class TestClass { @Since(2) public TestClass child; @Since(3) public int zzz = 123; @Since(3) public AnotherClass other; + @Since(3) public RecordClass record; public boolean equals (Object obj) { if (this == obj) return true; @@ -75,6 +120,7 @@ public boolean equals (Object obj) { if (other.text != null) return false; } else if (!text.equals(other.text)) return false; if (zzz != other.zzz) return false; + if (!Objects.equals(record, other.record)) return false; return true; } } @@ -83,6 +129,10 @@ public static class AnotherClass { @Since(1) String value; } + public record OldRecordClass(int width, long x, double y) { } + + public record RecordClass(@Since(1) String height, int width, long x, double y) { } + private static class FutureClass { @Since(0) public Integer value; @Since(1) public FutureClass2 futureClass2;