diff --git a/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufByteShiftUtil.java b/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufByteShiftUtil.java new file mode 100644 index 000000000..e2a2c313b --- /dev/null +++ b/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufByteShiftUtil.java @@ -0,0 +1,66 @@ +package tools.jackson.dataformat.protobuf; + +/** + * Byte-shifting fallback for reading and writing multi-byte primitives on byte + * arrays, used on runtimes where {@link ProtobufVarHandleUtil} is unusable. + *
+ * NOTE: accessors here are LITTLE-endian, unlike the big-endian ones CBOR needs: + * protobuf encodes its {@code fixed32}/{@code fixed64} types (and hence + * {@code float}/{@code double}) as little-endian. + *
+ * IMPORTANT: this class must NOT reference {@code java.lang.invoke.VarHandle}, + * directly or indirectly: it is the fallback for runtimes that lack that type, + * and naming it here would make this class fail to link on exactly those + * runtimes. Keeping the two implementations in separate classes is what makes + * the fallback path safe; see {@link ProtobufVarHandleUtil} for the full pattern. + * + * @since 3.3 + */ +final class ProtobufByteShiftUtil +{ + private ProtobufByteShiftUtil() { } + + /** + * Reads 4 bytes starting at given offset as a little-endian {@code int}. + * Caller MUST have verified that {@code offset+4} is within bounds of + * given array. + */ + static int getIntLE(byte[] buffer, int offset) { + return (buffer[offset] & 0xFF) + | ((buffer[offset+1] & 0xFF) << 8) + | ((buffer[offset+2] & 0xFF) << 16) + | ((buffer[offset+3] & 0xFF) << 24); + } + + /** + * Reads 8 bytes starting at given offset as a little-endian {@code long}. + * Caller MUST have verified that {@code offset+8} is within bounds of + * given array. + */ + static long getLongLE(byte[] buffer, int offset) { + // the two 32-bit halves combine to exactly a little-endian 8-byte read + final int i1 = getIntLE(buffer, offset); + final int i2 = getIntLE(buffer, offset+4); + return (((long) i1) & 0xFFFFFFFFL) | (((long) i2) << 32); + } + + /** + * Writes given {@code int} as 4 little-endian bytes at given offset; caller + * MUST have verified that {@code offset+4} is within bounds of given array. + */ + static void setIntLE(byte[] buffer, int offset, int value) { + buffer[offset] = (byte) value; + buffer[offset+1] = (byte) (value >> 8); + buffer[offset+2] = (byte) (value >> 16); + buffer[offset+3] = (byte) (value >> 24); + } + + /** + * Writes given {@code long} as 8 little-endian bytes at given offset; caller + * MUST have verified that {@code offset+8} is within bounds of given array. + */ + static void setLongLE(byte[] buffer, int offset, long value) { + setIntLE(buffer, offset, (int) value); + setIntLE(buffer, offset+4, (int) (value >> 32)); + } +} diff --git a/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufGenerator.java b/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufGenerator.java index 951efdac3..701a11cfd 100644 --- a/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufGenerator.java +++ b/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufGenerator.java @@ -17,6 +17,28 @@ public class ProtobufGenerator extends GeneratorBase { + /** + * Whether VarHandles are usable on this runtime; probed once at class load. + * Being {@code static final} lets the branches in the {@code _writeIntXX()} + * helpers fold away at JIT time. + * + * @since 3.3 + */ + private static final boolean _VARHANDLE_AVAILABLE = _checkVarHandleAvailable(); + + private static boolean _checkVarHandleAvailable() { + // NOTE: this call is what first loads `ProtobufVarHandleUtil`, and that + // class names `VarHandle` in its field/method signatures. On a runtime + // without `java.lang.invoke.VarHandle` (some Android builds) loading it + // raises `NoClassDefFoundError` -- an Error, not an Exception -- so + // `Throwable` is what has to be caught here. + try { + return ProtobufVarHandleUtil.isAvailable(); + } catch (Throwable t) { + return false; + } + } + /* /********************************************************************** /* Constants @@ -1662,14 +1684,13 @@ private final void _writeInt32(int v) throws JacksonException _ensureRoom(9); // max tag 5 bytes int ptr = _writeTag(_currPtr); final byte[] buf = _currBuffer; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - _currPtr = ptr; + // protobuf fixed32 is little-endian + if (_VARHANDLE_AVAILABLE) { + ProtobufVarHandleUtil.setIntLE(buf, ptr, v); + } else { + ProtobufByteShiftUtil.setIntLE(buf, ptr, v); + } + _currPtr = ptr + 4; } private final void _writeInt32NoTag(int v) throws JacksonException @@ -1677,14 +1698,13 @@ private final void _writeInt32NoTag(int v) throws JacksonException _ensureRoom(4); int ptr = _currPtr; final byte[] buf = _currBuffer; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - _currPtr = ptr; + // protobuf fixed32 is little-endian + if (_VARHANDLE_AVAILABLE) { + ProtobufVarHandleUtil.setIntLE(buf, ptr, v); + } else { + ProtobufByteShiftUtil.setIntLE(buf, ptr, v); + } + _currPtr = ptr + 4; } private final void _writeInt64(long v64) throws JacksonException @@ -1693,27 +1713,13 @@ private final void _writeInt64(long v64) throws JacksonException int ptr = _writeTag(_currPtr); final byte[] buf = _currBuffer; - int v = (int) v64; - - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - - v = (int) (v64 >> 32); - - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - - _currPtr = ptr; + // protobuf fixed64 is little-endian: low 32 bits first, then high + if (_VARHANDLE_AVAILABLE) { + ProtobufVarHandleUtil.setLongLE(buf, ptr, v64); + } else { + ProtobufByteShiftUtil.setLongLE(buf, ptr, v64); + } + _currPtr = ptr + 8; } private final void _writeInt64NoTag(long v64) throws JacksonException @@ -1722,27 +1728,13 @@ private final void _writeInt64NoTag(long v64) throws JacksonException int ptr = _currPtr; final byte[] buf = _currBuffer; - int v = (int) v64; - - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - - v = (int) (v64 >> 32); - - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - v >>= 8; - buf[ptr++] = (byte) v; - - _currPtr = ptr; + // protobuf fixed64 is little-endian: low 32 bits first, then high + if (_VARHANDLE_AVAILABLE) { + ProtobufVarHandleUtil.setLongLE(buf, ptr, v64); + } else { + ProtobufByteShiftUtil.setLongLE(buf, ptr, v64); + } + _currPtr = ptr + 8; } /* diff --git a/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufParser.java b/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufParser.java index 146823f60..fe0f6113b 100644 --- a/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufParser.java +++ b/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufParser.java @@ -16,6 +16,28 @@ public class ProtobufParser extends ParserMinimalBase { + /** + * Whether VarHandles are usable on this runtime; probed once at class load. + * Being {@code static final} lets the branches in {@link #_decode32Bits()} + * and {@link #_decode64Bits()} fold away at JIT time. + * + * @since 3.3 + */ + private static final boolean _VARHANDLE_AVAILABLE = _checkVarHandleAvailable(); + + private static boolean _checkVarHandleAvailable() { + // NOTE: this call is what first loads `ProtobufVarHandleUtil`, and that + // class names `VarHandle` in its field/method signatures. On a runtime + // without `java.lang.invoke.VarHandle` (some Android builds) loading it + // raises `NoClassDefFoundError` -- an Error, not an Exception -- so + // `Throwable` is what has to be caught here. + try { + return ProtobufVarHandleUtil.isAvailable(); + } catch (Throwable t) { + return false; + } + } + // State constants // State right after parser created; may start root Object @@ -2948,8 +2970,10 @@ protected final int _decode32Bits() throws JacksonException { return _slow32(); } final byte[] b = _inputBuffer; - int v = (b[ptr] & 0xFF) + ((b[ptr+1] & 0xFF) << 8) - + ((b[ptr+2] & 0xFF) << 16) + ((b[ptr+3] & 0xFF) << 24); + // protobuf fixed32 is little-endian + final int v = _VARHANDLE_AVAILABLE + ? ProtobufVarHandleUtil.getIntLE(b, ptr) + : ProtobufByteShiftUtil.getIntLE(b, ptr); _inputPtr = ptr+4; return v; } @@ -2979,12 +3003,12 @@ protected final long _decode64Bits() throws JacksonException { return _slow64(); } final byte[] b = _inputBuffer; - int i1 = (b[ptr++] & 0xFF) | ((b[ptr++] & 0xFF) << 8) - | ((b[ptr++] & 0xFF) << 16) | (b[ptr++] << 24); - int i2 = (b[ptr++] & 0xFF) | ((b[ptr++] & 0xFF) << 8) - | ((b[ptr++] & 0xFF) << 16) | (b[ptr++] << 24); - _inputPtr = ptr; - return _long(i1, i2); + // protobuf fixed64 is little-endian + final long v = _VARHANDLE_AVAILABLE + ? ProtobufVarHandleUtil.getLongLE(b, ptr) + : ProtobufByteShiftUtil.getLongLE(b, ptr); + _inputPtr = ptr+8; + return v; } protected final long _slow64() throws JacksonException { diff --git a/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufVarHandleUtil.java b/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufVarHandleUtil.java new file mode 100644 index 000000000..484c19362 --- /dev/null +++ b/protobuf/src/main/java/tools/jackson/dataformat/protobuf/ProtobufVarHandleUtil.java @@ -0,0 +1,84 @@ +package tools.jackson.dataformat.protobuf; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.nio.ByteOrder; + +/** + * Utility class that provides {@link VarHandle} instances for efficient + * multi-byte primitive reads and writes on byte arrays. + *
+ * NOTE: handles here are LITTLE-endian, unlike the big-endian ones CBOR needs: + * protobuf encodes its {@code fixed32}/{@code fixed64} types (and hence + * {@code float}/{@code double}) as little-endian. + *
+ * Handles are resolved once at class initialization. On runtimes where + * {@code MethodHandles.byteArrayViewVarHandle()} is unsupported (for example + * some Android runtimes) they are left {@code null} and {@link #isAvailable()} + * returns {@code false}. Callers MUST check {@link #isAvailable()} first: the + * {@code getXxx()}/{@code setXxx()} methods dereference the handles + * unconditionally, and the byte-shifting fallback lives in the caller, not here. + *
+ * Callers must also invoke {@link #isAvailable()} from within a + * {@code try}/{@code catch (Throwable)} block: this class names {@link VarHandle} + * in its field and method signatures, so on a runtime lacking + * {@code java.lang.invoke.VarHandle} entirely it fails to link, raising + * {@link LinkageError} before any code here can run. + * + * @since 3.3 + */ +final class ProtobufVarHandleUtil +{ + /** + * VarHandle for reading/writing an {@code int} as 4 little-endian bytes. + * {@code null} if VarHandles are unavailable. + */ + static final VarHandle INT_LE; + + /** + * VarHandle for reading/writing a {@code long} as 8 little-endian bytes. + * {@code null} if VarHandles are unavailable. + */ + static final VarHandle LONG_LE; + + static { + VarHandle intLe = null; + VarHandle longLe = null; + try { + intLe = MethodHandles.byteArrayViewVarHandle(int[].class, ByteOrder.LITTLE_ENDIAN); + longLe = MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN); + } catch (Throwable t) { + // VarHandles not available (e.g., Android): fall back to manual byte shifting + } + INT_LE = intLe; + // assigned last: non-null implies every handle above resolved too + LONG_LE = longLe; + } + + private ProtobufVarHandleUtil() { } + + static boolean isAvailable() { + return LONG_LE != null; + } + + // Helper methods that read/write primitives via the class's own VarHandle + // fields. Only called when the corresponding field is non-null, which implies + // VarHandle is available on this runtime. Caller MUST also have verified that + // the full 4/8 bytes are within bounds of the given array. + + static int getIntLE(byte[] array, int offset) { + return (int) INT_LE.get(array, offset); + } + + static long getLongLE(byte[] array, int offset) { + return (long) LONG_LE.get(array, offset); + } + + static void setIntLE(byte[] array, int offset, int value) { + INT_LE.set(array, offset, value); + } + + static void setLongLE(byte[] array, int offset, long value) { + LONG_LE.set(array, offset, value); + } +} diff --git a/protobuf/src/test/java/tools/jackson/dataformat/protobuf/VarHandleFallbackTest.java b/protobuf/src/test/java/tools/jackson/dataformat/protobuf/VarHandleFallbackTest.java new file mode 100644 index 000000000..929652fe8 --- /dev/null +++ b/protobuf/src/test/java/tools/jackson/dataformat/protobuf/VarHandleFallbackTest.java @@ -0,0 +1,136 @@ +package tools.jackson.dataformat.protobuf; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.Random; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link ProtobufByteShiftUtil}, the byte-shifting fallback used on + * runtimes where {@link ProtobufVarHandleUtil} is unusable. + *
+ * Needed because {@code ProtobufParser._VARHANDLE_AVAILABLE} (and the generator's + * equivalent) is {@code static final} and true on every JDK we test on: the + * fallback branches are constant-folded away, so parsing and generation tests + * alone never execute them. Here the fallback is called directly and checked + * against {@link ByteBuffer} as an independent oracle, plus against the VarHandle + * implementation it stands in for. + */ +public class VarHandleFallbackTest extends ProtobufTestBase +{ + private final static int OFFSET = 3; // deliberately unaligned + + @Test + public void testGetIntLE() throws Exception + { + for (byte[] input : _inputs(4)) { + final int exp = ByteBuffer.wrap(input, OFFSET, 4) + .order(ByteOrder.LITTLE_ENDIAN).getInt(); + assertEquals(exp, ProtobufByteShiftUtil.getIntLE(input, OFFSET)); + if (ProtobufVarHandleUtil.isAvailable()) { + assertEquals(exp, ProtobufVarHandleUtil.getIntLE(input, OFFSET)); + } + } + } + + @Test + public void testGetLongLE() throws Exception + { + for (byte[] input : _inputs(8)) { + final long exp = ByteBuffer.wrap(input, OFFSET, 8) + .order(ByteOrder.LITTLE_ENDIAN).getLong(); + assertEquals(exp, ProtobufByteShiftUtil.getLongLE(input, OFFSET)); + if (ProtobufVarHandleUtil.isAvailable()) { + assertEquals(exp, ProtobufVarHandleUtil.getLongLE(input, OFFSET)); + } + } + } + + @Test + public void testSetIntLE() throws Exception + { + for (int value : _intValues()) { + byte[] exp = new byte[OFFSET+4]; + ByteBuffer.wrap(exp, OFFSET, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(value); + + byte[] act = new byte[OFFSET+4]; + ProtobufByteShiftUtil.setIntLE(act, OFFSET, value); + assertArrayEquals(exp, act, "for value "+value); + + if (ProtobufVarHandleUtil.isAvailable()) { + byte[] viaHandle = new byte[OFFSET+4]; + ProtobufVarHandleUtil.setIntLE(viaHandle, OFFSET, value); + assertArrayEquals(exp, viaHandle, "for value "+value); + } + } + } + + @Test + public void testSetLongLE() throws Exception + { + for (long value : _longValues()) { + byte[] exp = new byte[OFFSET+8]; + ByteBuffer.wrap(exp, OFFSET, 8).order(ByteOrder.LITTLE_ENDIAN).putLong(value); + + byte[] act = new byte[OFFSET+8]; + ProtobufByteShiftUtil.setLongLE(act, OFFSET, value); + assertArrayEquals(exp, act, "for value "+value); + + if (ProtobufVarHandleUtil.isAvailable()) { + byte[] viaHandle = new byte[OFFSET+8]; + ProtobufVarHandleUtil.setLongLE(viaHandle, OFFSET, value); + assertArrayEquals(exp, viaHandle, "for value "+value); + } + } + } + + // // // Helper methods for building inputs: sign-bit and all-bits-set cases + // // // first, then pseudo-random ones (fixed seed, for reproducibility) + + private byte[][] _inputs(int length) { + final int size = OFFSET + length; + byte[][] result = new byte[3+100][]; + result[0] = new byte[size]; + result[1] = new byte[size]; + Arrays.fill(result[1], (byte) 0xFF); + result[2] = new byte[size]; + result[2][size-1] = (byte) 0x80; // high bit of most-significant (last) byte only + Random rnd = new Random(1234); + for (int i = 3; i < result.length; ++i) { + byte[] b = new byte[size]; + rnd.nextBytes(b); + result[i] = b; + } + return result; + } + + private int[] _intValues() { + int[] result = new int[4+100]; + result[0] = 0; + result[1] = -1; + result[2] = Integer.MIN_VALUE; + result[3] = Integer.MAX_VALUE; + Random rnd = new Random(5678); + for (int i = 4; i < result.length; ++i) { + result[i] = rnd.nextInt(); + } + return result; + } + + private long[] _longValues() { + long[] result = new long[4+100]; + result[0] = 0L; + result[1] = -1L; + result[2] = Long.MIN_VALUE; + result[3] = Long.MAX_VALUE; + Random rnd = new Random(9012); + for (int i = 4; i < result.length; ++i) { + result[i] = rnd.nextLong(); + } + return result; + } +} diff --git a/release-notes/CREDITS b/release-notes/CREDITS index be1ad13c1..6f5d21a88 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -73,3 +73,6 @@ PJ Fanning (@pjfanning) * Contributed #762: (avro) Use `VarHandle` for `float`/`double` reads in Avro parser (3.3.0) +* Contributed #763: (protobuf) Use `VarHandle` for multi-byte primitive reads + and writes in `ProtobufParser` / `ProtobufGenerator` + (3.3.0) diff --git a/release-notes/VERSION b/release-notes/VERSION index d8b05c9f3..34c5dfd62 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -32,6 +32,9 @@ implementations) (fix by @cowtowncoder, w/ Claude code) #762: (avro) Use `VarHandle` for `float`/`double` reads in Avro parser (contributed by @pjfanning) +#763: (protobuf) Use `VarHandle` for multi-byte primitive reads and writes in + `ProtobufParser` / `ProtobufGenerator` + (contributed by @pjfanning) #767: (smile) Use more efficient `String` construction wrt "Compact Strings" for "short" ASCII text values of async parser (fix by @cowtowncoder, w/ Claude code)