Skip to content

AbstractJacksonEncoder never returns its BufferRecycler to the pool, defeating buffer recycling with Jackson 3 #37058

Description

@samueldlightfoot

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    status: supersededAn issue that has been superseded by another

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions