Skip to content

Commit

Permalink
Improve binary serialization performance
Browse files Browse the repository at this point in the history
* Add a dedicated byte array output stream
* Performance gain of 20-30% when used
* Add Binary and JSON performance tests
  • Loading branch information
jodastephen committed Jan 2, 2025
1 parent e462e8b commit 03acad7
Show file tree
Hide file tree
Showing 6 changed files with 489 additions and 3 deletions.
8 changes: 7 additions & 1 deletion src/changes/changes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
Allow Java records to become beans.
Add `RecordBean` interface that can be implemented by records.
</action>
<action dev="jodastephen" type="add">
Add `LinkedByteArrayOutputStream`, which is like `ByteArrayOutputStream` but faster.
</action>
<action dev="jodastephen" type="add">
Add `ResolevdType`, which allows generic type information to be managed in a simple way.
</action>
<action dev="jodastephen" type="add" issue="232">
Potentially incompatible change:
Manual equals, hashCode and toString methods must now be located *before* the autogenerated block.
Expand Down Expand Up @@ -45,7 +51,7 @@
</action>
<action dev="jodastephen" type="add" issue="446">
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.
</action>
<action dev="jodastephen" type="update">
Incompatible change:
Expand Down
188 changes: 188 additions & 0 deletions src/main/java/org/joda/beans/ser/LinkedByteArrayOutputStream.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* <p>
* Calling {@link #toByteArray()} returns a single combined byte array.
* Calling {@link #writeTo(OutputStream)} writes the internal arrays without needing to create a combined array.
* <p>
* 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.
* <p>
* The returned array contains a copy of the internal state of this class.
* <p>
* 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();
}
}
4 changes: 2 additions & 2 deletions src/main/java/org/joda/beans/ser/bin/JodaBeanBinWriter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
120 changes: 120 additions & 0 deletions src/test/java/org/joda/beans/ser/TestLinkedByteArrayOutputStream.java
Original file line number Diff line number Diff line change
@@ -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());
}
}

}
Loading

0 comments on commit 03acad7

Please sign in to comment.