diff --git a/src/changes/changes.xml b/src/changes/changes.xml index e6ac5db0..0921698f 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -18,6 +18,12 @@ Allow Java records to become beans. Add `RecordBean` interface that can be implemented by records. + + Add `LinkedByteArrayOutputStream`, which is like `ByteArrayOutputStream` but faster. + + + Add `ResolevdType`, which allows generic type information to be managed in a simple way. + Potentially incompatible change: Manual equals, hashCode and toString methods must now be located *before* the autogenerated block. @@ -45,7 +51,7 @@ Potentially incompatible change: - The standard binary and simple JSON formats now handle `Iterable` as a collection type. + The standard binary and JSON formats now handle `Iterable` as a collection type. Incompatible change: diff --git a/src/main/java/org/joda/beans/ser/LinkedByteArrayOutputStream.java b/src/main/java/org/joda/beans/ser/LinkedByteArrayOutputStream.java new file mode 100644 index 00000000..0c00ba46 --- /dev/null +++ b/src/main/java/org/joda/beans/ser/LinkedByteArrayOutputStream.java @@ -0,0 +1,188 @@ +/* + * Copyright 2001-present Stephen Colebourne + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.joda.beans.ser; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.Objects; + +/** + * An optimised byte array output stream. + *

+ * This class holds a number of smaller byte arrays internally. + * Each array is typically 1024 bytes, but if a large byte array is written + * the class will hold it as a single large array. + *

+ * Calling {@link #toByteArray()} returns a single combined byte array. + * Calling {@link #writeTo(OutputStream)} writes the internal arrays without needing to create a combined array. + *

+ * This class is not thread-safe. + */ +public class LinkedByteArrayOutputStream extends OutputStream { + + // segment holding one byte array, the current position in the array, and the next segment when it is full + private static final class ByteSegment { + private final byte[] bytes; + private int pos; + private ByteSegment next; + + private ByteSegment(byte[] bytes) { + this.bytes = bytes; + } + } + + // the head/root segment + private final ByteSegment head = new ByteSegment(new byte[1024]); + // the current tail + private ByteSegment tail = head; + // the total number of bytes written + private int total; + + /** + * Creates an instance. + */ + public LinkedByteArrayOutputStream() { + } + + //------------------------------------------------------------------------- + /** + * Writes a single byte to the output stream. + * + * @param val the value + */ + @Override + public void write(int val) { + var tailRemaining = tail.bytes.length - tail.pos; + if (tailRemaining == 0) { + tail.next = new ByteSegment(new byte[1024]); + tail = tail.next; + } + tail.bytes[tail.pos] = (byte) val; + tail.pos++; + total++; + } + + /** + * Writes all or part of a byte array to the output stream. + * + * @param bytes the byte array to write, not null + * @param offset the offset from the start of the array + * @param length the number of bytes to write + * @throws IndexOutOfBoundsException if the offset or length is invalid + */ + @Override + public void write(byte[] bytes, int offset, int length) { + Objects.checkFromIndexSize(offset, length, bytes.length); + var tailRemaining = tail.bytes.length - tail.pos; + // first part + var firstPartLength = Math.min(tailRemaining, length); + System.arraycopy(bytes, offset, tail.bytes, tail.pos, firstPartLength); + tail.pos += firstPartLength; + // remainder + var newLength = length - firstPartLength; + if (newLength > 0) { + var newOffset = offset + firstPartLength; + if (newLength >= 1024) { + tail.next = new ByteSegment(Arrays.copyOfRange(bytes, newOffset, length)); + } else { + tail.next = new ByteSegment(new byte[1024]); + System.arraycopy(bytes, newOffset, tail.next.bytes, 0, newLength); + } + tail = tail.next; + tail.pos = newLength; + } + total += length; + } + + /** + * Writes a byte array to the output stream. + * + * @param bytes the byte array to write, not null + */ + @Override + public void write(byte[] bytes) { + write(bytes, 0, bytes.length); + } + + /** + * Writes all the bytes to the specified output stream. + * + * @param out the output stream to write to + * @throws IOException if an IO error occurs + */ + public void writeTo(OutputStream out) throws IOException { + for (var segment = head; segment != null; segment = segment.next) { + out.write(segment.bytes, 0, segment.pos); + } + } + + /** + * Returns a single byte array containing all the bytes written to the output stream. + *

+ * The returned array contains a copy of the internal state of this class. + *

+ * It is not expected that callers will call this method multiple times, although it is safe to do so. + * + * @return the combined byte array + */ + public byte[] toByteArray() { + var result = new byte[total]; + var pos = 0; + for (var segment = head; segment != null; segment = segment.next) { + System.arraycopy(segment.bytes, 0, result, pos, segment.pos); + pos += segment.pos; + } + return result; + } + + /** + * Gets the current number of bytes written. + * + * @return the number of bytes written + */ + public int size() { + return total; + } + + /** + * A no-op, as this class does not need flushing. + */ + @Override + public void flush() { + } + + /** + * A no-op, as this class does not need closing. + */ + @Override + public void close() { + } + + /** + * Returns a hex-formatted string of the bytes that have been written. + */ + @Override + public String toString() { + var hex = HexFormat.of(); + var buf = new StringBuilder(total * 2); + for (var segment = head; segment != null; segment = segment.next) { + hex.formatHex(buf, segment.bytes, 0, segment.pos); + } + return buf.toString(); + } +} diff --git a/src/main/java/org/joda/beans/ser/bin/JodaBeanBinWriter.java b/src/main/java/org/joda/beans/ser/bin/JodaBeanBinWriter.java index f88198bd..894efed2 100644 --- a/src/main/java/org/joda/beans/ser/bin/JodaBeanBinWriter.java +++ b/src/main/java/org/joda/beans/ser/bin/JodaBeanBinWriter.java @@ -18,13 +18,13 @@ import static org.joda.beans.ser.bin.JodaBeanBinFormat.REFERENCING; import static org.joda.beans.ser.bin.JodaBeanBinFormat.STANDARD; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.Objects; import org.joda.beans.Bean; import org.joda.beans.ser.JodaBeanSer; +import org.joda.beans.ser.LinkedByteArrayOutputStream; /** * Provides the ability for a Joda-Bean to be written to a binary format. @@ -100,7 +100,7 @@ public byte[] write(Bean bean) { * @return the binary data, not null */ public byte[] write(Bean bean, boolean rootType) { - var baos = new ByteArrayOutputStream(1024); + var baos = new LinkedByteArrayOutputStream(); try { write(bean, rootType, baos); } catch (IOException ex) { diff --git a/src/test/java/org/joda/beans/ser/TestLinkedByteArrayOutputStream.java b/src/test/java/org/joda/beans/ser/TestLinkedByteArrayOutputStream.java new file mode 100644 index 00000000..acd5034f --- /dev/null +++ b/src/test/java/org/joda/beans/ser/TestLinkedByteArrayOutputStream.java @@ -0,0 +1,120 @@ +/* + * Copyright 2001-present Stephen Colebourne + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.joda.beans.ser; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; + +import org.junit.jupiter.api.Test; + +/** + * Test {@link LinkedByteArrayOutputStream}. + */ +class TestLinkedByteArrayOutputStream { + + @Test + void test_empty() { + try (var test = new LinkedByteArrayOutputStream()) { + assertThat(test).hasToString(""); + assertThat(test.toByteArray()).isEqualTo(new byte[0]); + assertThat(test.size()).isEqualTo(0); + } + } + + @Test + void test_writeByte() { + try (var test = new LinkedByteArrayOutputStream()) { + test.write(33); + assertThat(test).hasToString("21"); + assertThat(test.toByteArray()).isEqualTo(new byte[] {33}); + assertThat(test.size()).isEqualTo(1); + } + } + + @Test + void test_writeByte_growCapacity() { + try (var test = new LinkedByteArrayOutputStream()) { + test.write(new byte[1024]); + test.write(33); + assertThat(test.toString()).hasSize(2050).endsWith("0021"); + assertThat(test.toByteArray()).hasSize(1025).endsWith(new byte[] {33}); + } + } + + @Test + void test_writeByteArray_empty() { + try (var test = new LinkedByteArrayOutputStream()) { + test.write(new byte[0]); + assertThat(test).hasToString(""); + assertThat(test.toByteArray()).isEqualTo(new byte[0]); + assertThat(test.size()).isEqualTo(0); + } + } + + @Test + void test_writeByteArray_normal() { + try (var test = new LinkedByteArrayOutputStream()) { + var bytes = new byte[] {33, 34, 35, 36, 37}; + test.write(bytes, 1, 3); + assertThat(test).hasToString("222324"); + assertThat(test.toByteArray()).isEqualTo(new byte[] {34, 35, 36}); + assertThat(test.size()).isEqualTo(3); + } + } + + @Test + void test_writeByteArray_growCapacityExact() { + try (var test = new LinkedByteArrayOutputStream()) { + var bytes = new byte[] {33, 34, 35, 36, 37}; + test.write(new byte[1024]); + test.write(bytes, 1, 4); + assertThat(test.toString()).hasSize(2056).endsWith("22232425"); + assertThat(test.toByteArray()).hasSize(1028).endsWith(new byte[] {34, 35, 36, 37}); + assertThat(test.size()).isEqualTo(1028); + assertThat(test.toByteArray()).isEqualTo(test.toByteArray()); + } + } + + @Test + void test_writeByteArray_growCapacitySplit() { + try (var test = new LinkedByteArrayOutputStream()) { + var bytes = new byte[] {33, 34, 35, 36, 37}; + test.write(new byte[1022]); + test.write(bytes, 0, 3); + assertThat(test.toString()).hasSize(2050).endsWith("212223"); + assertThat(test.toByteArray()).hasSize(1025).endsWith(new byte[] {33, 34, 35}); + assertThat(test.size()).isEqualTo(1025); + assertThat(test.toByteArray()).isEqualTo(test.toByteArray()); + } + } + + @Test + void test_writeByteArray_large() { + try (var test = new LinkedByteArrayOutputStream()) { + var bytes = new byte[2048]; + Arrays.fill(bytes, (byte) 33); + test.write(new byte[1022]); + test.write(bytes); + test.write(34); + assertThat(test.toString()).hasSize((1022 + 2048 + 1) * 2).endsWith("212122"); + assertThat(test.toByteArray()).hasSize(1022 + 2048 + 1).endsWith(new byte[] {33, 33, 34}); + assertThat(test.size()).isEqualTo(1022 + 2048 + 1); + assertThat(test.toByteArray()).isEqualTo(test.toByteArray()); + } + } + +} diff --git a/src/test/java/org/joda/beans/ser/bin/TestBinPerformance.java b/src/test/java/org/joda/beans/ser/bin/TestBinPerformance.java new file mode 100644 index 00000000..91ff0fbb --- /dev/null +++ b/src/test/java/org/joda/beans/ser/bin/TestBinPerformance.java @@ -0,0 +1,86 @@ +/* + * Copyright 2001-present Stephen Colebourne + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.joda.beans.ser.bin; + +import java.io.IOException; +import java.time.Duration; + +import org.joda.beans.sample.Address; +import org.joda.beans.ser.JodaBeanSer; +import org.joda.beans.ser.SerTestHelper; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import com.google.common.base.Stopwatch; + +/** + * Test bean round-trip using Binary. + */ +@Disabled("Performance test - run manually when needed") +class TestBinPerformance { + + private static final int REPEAT_OUTER = 20; + private static final int REPEAT_INNER = 5000; + + @Test + void testPerformance() throws IOException { + var address = SerTestHelper.testAddress(); + invokeNew(address); + invokeOld(address); + invokeNew(address); + invokeOld(address); + invokeNew(address); + invokeOld(address); + System.out.println("---"); + invokeNew(address); + invokeOld(address); + } + + private void invokeNew(Address address) throws IOException { + byte[] bytes = null; + var total = Duration.ZERO; + for (int i = 0; i < REPEAT_OUTER; i++) { + Stopwatch watch = Stopwatch.createStarted(); + for (int j = 0; j < REPEAT_INNER; j++) { + bytes = new JodaBeanBinWriter(JodaBeanSer.PRETTY, JodaBeanBinFormat.PACKED).write(address); + if (bytes.length < 100) { + System.out.println(); + } + } + watch.stop(); + total = total.plus(watch.elapsed()); + } + System.out.println("NEW-AVG-B: " + ((total.dividedBy(REPEAT_OUTER).toNanos() / 1000) / 1000d) + " ms"); + } + + private void invokeOld(Address address) { + byte[] bytes = null; + var total = Duration.ZERO; + for (int i = 0; i < REPEAT_OUTER; i++) { + Stopwatch watch = Stopwatch.createStarted(); + for (int j = 0; j < REPEAT_INNER; j++) { + bytes = new JodaBeanBinWriter(JodaBeanSer.PRETTY, JodaBeanBinFormat.STANDARD).write(address); + if (bytes.length < 100) { + System.out.println(); + } + } + watch.stop(); + total = total.plus(watch.elapsed()); + } + System.out.println("OLD-AVG-B: " + ((total.dividedBy(REPEAT_OUTER).toNanos() / 1000) / 1000d) + " ms"); + } + +} diff --git a/src/test/java/org/joda/beans/ser/json/TestJsonPerformance.java b/src/test/java/org/joda/beans/ser/json/TestJsonPerformance.java new file mode 100644 index 00000000..7aa0c0b2 --- /dev/null +++ b/src/test/java/org/joda/beans/ser/json/TestJsonPerformance.java @@ -0,0 +1,86 @@ +/* + * Copyright 2001-present Stephen Colebourne + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.joda.beans.ser.json; + +import java.io.IOException; +import java.time.Duration; + +import org.joda.beans.sample.Address; +import org.joda.beans.ser.JodaBeanSer; +import org.joda.beans.ser.SerTestHelper; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import com.google.common.base.Stopwatch; + +/** + * Test bean round-trip using JSON. + */ +@Disabled("Performance test - run manually when needed") +class TestJsonPerformance { + + private static final int REPEAT_OUTER = 20; + private static final int REPEAT_INNER = 5000; + + @Test + void testPerformance() throws IOException { + var address = SerTestHelper.testAddress(); + invokeNew(address); + invokeOld(address); + invokeNew(address); + invokeOld(address); + invokeNew(address); + invokeOld(address); + System.out.println("---"); + invokeNew(address); + invokeOld(address); + } + + private void invokeNew(Address address) { + String json; + var total = Duration.ZERO; + for (int i = 0; i < REPEAT_OUTER; i++) { + Stopwatch watch = Stopwatch.createStarted(); + for (int j = 0; j < REPEAT_INNER; j++) { + json = new JodaBeanJsonWriter(JodaBeanSer.PRETTY).write(address); + if (json.length() < 1000) { + System.out.println(); + } + } + watch.stop(); + total = total.plus(watch.elapsed()); + } + System.out.println("NEW-AVG-J: " + ((total.dividedBy(REPEAT_OUTER).toNanos() / 1000) / 1000d) + " ms"); + } + + private void invokeOld(Address address) { + String json; + var total = Duration.ZERO; + for (int i = 0; i < REPEAT_OUTER; i++) { + Stopwatch watch = Stopwatch.createStarted(); + for (int j = 0; j < REPEAT_INNER; j++) { + json = new JodaBeanSimpleJsonWriter(JodaBeanSer.PRETTY).write(address); + if (json.length() < 1000) { + System.out.println(); + } + } + watch.stop(); + total = total.plus(watch.elapsed()); + } + System.out.println("OLD-AVG-J: " + ((total.dividedBy(REPEAT_OUTER).toNanos() / 1000) / 1000d) + " ms"); + } + +}