Affects: 7.0.x
AbstractJacksonEncoder.encode(...) and encodeValue(...) acquire a pooled recycler:
ByteArrayBuilder byteBuilder = new ByteArrayBuilder(writer.generatorFactory()._getBufferRecycler());
_getBufferRecycler() calls RecyclerPool.acquireAndLinkPooled(), so the recycler must go back via BufferRecycler.releaseToPool(). Nothing releases it. byteBuilder.release() returns only the current byte block. The generator won't release it either: TokenStreamFactory._createContext detects the builder's recycler through BufferRecycler.Gettable, reuses it, and calls markBufferRecyclerReleased(), which leaves the life-cycle to the caller.
The omission was free under Jackson 2.x, whose default pool is ThreadLocal-based with a no-op release. Jackson 3.0 changed the default to a per-factory ConcurrentDequePool, repopulated only by explicit releaseToPool() calls. Leased recyclers aren't tracked, so nothing leaks — but the pool stays empty and every encode allocates a fresh BufferRecycler and its buffers. Recycling never happens.
The pattern predates the RecyclerPool API (Jackson 2.16): it dates to gh-24198, gained its cleanup calls in gh-25910, and was ported verbatim to Jackson 3 in gh-33798. For comparison, jackson-databind's own ObjectWriter.writeValueAsBytes() uses the same pattern and does release:
final BufferRecycler br = _generatorFactory._getBufferRecycler();
try (ByteArrayBuilder bb = new ByteArrayBuilder(br)) {
// ...
} finally {
br.releaseToPool();
}
Adding the release at both call sites restores reuse; in a local test, allocations dropped noticeably. It belongs in the finally block of encodeValue and in the streaming path's doAfterTerminate, strictly after generator.close() and byteBuilder.release() — gh-30493 shows what happens when this cleanup runs out of order. releaseToPool() is idempotent, so sharing the recycler with the generator cannot double-release.
The deprecated AbstractJackson2Encoder has the same gap, harmless under Jackson 2.x defaults.
This was identified from memory allocation profiling in which BufferRecycler pooled resources made up an unexpectedly high proportion.
Allocation profile: BufferRecycler pool-release change
Setup: Bytes allocated per encodeValue call, measured with HotSpot ThreadMXBean.getThreadAllocatedBytes() single-threaded at steady state (200k warmup, 500k ops/round × 7, median), using a default Jackson 3 JsonMapper; the only difference between arms is the added BufferRecycler.releaseToPool().
| Payload (JSON size) |
Before |
After |
Saving |
| Single Pojo (25 B) |
18,720 B/op |
800 B/op |
−17,920 B/op (−96%) |
| 200-Pojo list (9,381 B) |
45,992 B/op |
27,578 B/op |
−18,414 B/op (−40%) |
The fix removes a fixed ~18 KB of recycler-buffer allocation per encode — constant in absolute terms, so the percentage just tracks payload size.
Affects: 7.0.x
AbstractJacksonEncoder.encode(...)andencodeValue(...)acquire a pooled recycler:_getBufferRecycler()callsRecyclerPool.acquireAndLinkPooled(), so the recycler must go back viaBufferRecycler.releaseToPool(). Nothing releases it.byteBuilder.release()returns only the current byte block. The generator won't release it either:TokenStreamFactory._createContextdetects the builder's recycler throughBufferRecycler.Gettable, reuses it, and callsmarkBufferRecyclerReleased(), which leaves the life-cycle to the caller.The omission was free under Jackson 2.x, whose default pool is ThreadLocal-based with a no-op release. Jackson 3.0 changed the default to a per-factory
ConcurrentDequePool, repopulated only by explicitreleaseToPool()calls. Leased recyclers aren't tracked, so nothing leaks — but the pool stays empty and every encode allocates a freshBufferRecyclerand its buffers. Recycling never happens.The pattern predates the
RecyclerPoolAPI (Jackson 2.16): it dates to gh-24198, gained its cleanup calls in gh-25910, and was ported verbatim to Jackson 3 in gh-33798. For comparison, jackson-databind's ownObjectWriter.writeValueAsBytes()uses the same pattern and does release:Adding the release at both call sites restores reuse; in a local test, allocations dropped noticeably. It belongs in the
finallyblock ofencodeValueand in the streaming path'sdoAfterTerminate, strictly aftergenerator.close()andbyteBuilder.release()— gh-30493 shows what happens when this cleanup runs out of order.releaseToPool()is idempotent, so sharing the recycler with the generator cannot double-release.The deprecated
AbstractJackson2Encoderhas the same gap, harmless under Jackson 2.x defaults.This was identified from memory allocation profiling in which BufferRecycler pooled resources made up an unexpectedly high proportion.
Allocation profile: BufferRecycler pool-release change
Setup: Bytes allocated per
encodeValuecall, measured with HotSpotThreadMXBean.getThreadAllocatedBytes()single-threaded at steady state (200k warmup, 500k ops/round × 7, median), using a default Jackson 3JsonMapper; the only difference between arms is the addedBufferRecycler.releaseToPool().The fix removes a fixed ~18 KB of recycler-buffer allocation per encode — constant in absolute terms, so the percentage just tracks payload size.