Back to Blog
javaperformancegcproduction

The Java arraycopy() Trap: Why Your Bulk Copy Is Slow

System.arraycopy() looks fast but hides GC costs. Learn why bulk operations kill latency and real solutions.

J

JOptimize Team

July 4, 2026· 12 min read

You think System.arraycopy() is fast. It's not.

System.arraycopy() is native code, yes. But copying 1GB of arrays triggers full GC pauses that destroy latency.

This happened in production. A "batch operation" doing bulk array copies every 100ms caused 500ms GC pauses. Users saw nothing for half a second. Hundreds of times per day.

The Problem

Code looks innocent:

public void processBatch(byte[] data) { byte[] copy = new byte[data.length]; System.arraycopy(data, 0, copy, 0, data.length); process(copy); }

Calls this 1000 times per second on 1MB arrays = 1GB/sec allocated.

Young generation fills instantly. GC runs every 10ms. Each GC touches old generation. After 1 minute, full GC kicks in. 2 second pause.

Why It Happens

JVM allocates new array → copies data → old copy becomes garbage.

If you do this in bulk (batch processing, message handlers, stream processing):

  • 10,000 requests/sec × 1MB copy = 10GB/sec
  • Young gen ~512MB fills in 50ms
  • GC runs continuously
  • Old gen fills with long-lived copies
  • Full GC every 30-60 seconds
  • 1-3 second pause = traffic loss

Real Case: Message Queue Handler

Actual production bug:

@Component public class MessageHandler { @KafkaListener(topics = "events") public void handle(byte[] payload) { // Bad: Copy every message byte[] copy = new byte[payload.length]; System.arraycopy(payload, 0, copy, 0, payload.length); process(copy); } }

Kafka: 50,000 messages/sec, 10KB each = 500MB/sec allocation.

Result: GC pause every 1 second. P99 latency = 1500ms (SLA was 100ms).

Solution 1: Work In-Place (No Copy)

@Component public class MessageHandler { @KafkaListener(topics = "events") public void handle(byte[] payload) { // Good: No copy, work directly process(payload); } } private void process(byte[] data) { // Don't modify original, just read int sum = 0; for (byte b : data) { sum += b; } }

No allocation = no GC.

Solution 2: Object Pool for Fixed-Size Copies

If you MUST copy (thread-unsafe data, modification needed):

public class ByteArrayPool { private final Queue<byte[]> pool = new ConcurrentLinkedQueue<>(); private final int bufferSize; public ByteArrayPool(int bufferSize, int poolSize) { this.bufferSize = bufferSize; for (int i = 0; i < poolSize; i++) { pool.offer(new byte[bufferSize]); } } public byte[] acquire() { byte[] buf = pool.poll(); return buf != null ? buf : new byte[bufferSize]; } public void release(byte[] buf) { Arrays.fill(buf, (byte) 0); // Reset pool.offer(buf); } } // Usage private final ByteArrayPool pool = new ByteArrayPool(10_000, 50); public void handle(byte[] payload) { byte[] copy = pool.acquire(); try { System.arraycopy(payload, 0, copy, 0, payload.length); process(copy); } finally { pool.release(copy); } }

Result: Fixed 50 arrays allocated, reused infinitely. Zero GC for copies.

Solution 3: Off-Heap Buffers (DirectByteBuffer)

For large, long-lived copies:

import java.nio.ByteBuffer; public class OffHeapCopy { private ByteBuffer directBuf = ByteBuffer.allocateDirect(10_000_000); // 10MB off-heap public void handle(byte[] payload) { directBuf.clear(); directBuf.put(payload); process(directBuf); // Work with ByteBuffer } private void process(ByteBuffer buf) { buf.flip(); while (buf.hasRemaining()) { byte b = buf.get(); // Process } } }

Off-heap = GC never touches it. No pauses for this allocation.

Downside: manual cleanup required, slight performance overhead vs on-heap.

Solution 4: Lazy Copy (Copy-On-Write)

Don't copy until you know you need it:

public class LazyByteArrayCopy { private final byte[] original; private volatile byte[] copy = null; public LazyByteArrayCopy(byte[] data) { this.original = data; } public byte[] get() { return original; // Return original until modified } public void modify(int index, byte value) { if (copy == null) { synchronized (this) { if (copy == null) { copy = new byte[original.length]; System.arraycopy(original, 0, copy, 0, original.length); } } } copy[index] = value; } }

Only copy on first modification. Most reads = no allocation.

Measurement: Before/After

Before (Eager Copy)

Throughput: 10,000 req/sec
P50 latency: 5ms
P99 latency: 500ms
GC pause time: 1-3 seconds
GC frequency: Every 1-2 seconds

After (Pool + In-Place)

Throughput: 50,000 req/sec
P50 latency: 1ms
P99 latency: 50ms
GC pause time: <10ms (young gen only)
GC frequency: Every 30 seconds

Why This Matters

Array copy looks like a micro-optimization. It's not.

One bad pattern:

  • Scales with throughput
  • Compounds across services (each layer copies)
  • Invisible until production load
  • Destroys P99 latency

Production Checklist

  1. Profile allocation rate: target <200MB/sec
  2. Find array copies with: -XX:+PrintAllocationRate
  3. Check: Do you need the copy or can you work in-place?
  4. If copy needed: Use object pool for fixed-size or off-heap for large
  5. Monitor GC pause time: Alert if >100ms
  6. Test under expected throughput (not 100 req/sec)
  7. Measure P99, not average
  8. Review all batch operations for hidden copies

Summary

System.arraycopy() itself is fast. But bulk allocation on hot paths kills GC.

Work in-place when possible. Pool when you must copy. Monitor GC under real load.

One "innocent" array copy at scale = multi-second pause.

Optimize with JOptimize PRO. Use code LINKEDIN40 for 40% OFF.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.