Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

high-performance java techniques

Tung Bui Thanh (TungDaDev)29 mins read

Java high perf.webp

Ngoài bitwise, Java còn khá nhiều kỹ thuật mà dev ứng dụng ít để ý, trong khi người viết framework và thư viện dùng hằng ngày để vắt hiệu năng. Bài này gom chúng lại, từ cách bố trí bộ nhớ, object pooling, thuật toán lock-free tới mấy thứ nằm ở tầng JVM.

Với mỗi kỹ thuật, mình cố trả lời bốn câu: dùng khi nào, vì sao nó nhanh, ví dụ ra sao, và khi nào thì đừng dùng. Câu cuối quan trọng không kém: JVM hiện đại thông minh hơn nhiều so với hồi Java 6, nên không ít "mẹo tối ưu" ngày xưa giờ vô tác dụng, thậm chí phản tác dụng.

1. Object Pooling: tránh GC pressure

Mỗi lần new là một lần cấp phát bộ nhớ, và GC sau đó phải dọn. Một hệ thống tạo hàng triệu object sống ngắn mỗi giây thì GC chạy dày hơn, pause cũng dễ nhảy vọt. Ý tưởng của pooling là tạo sẵn một nhóm object, ai cần thì mượn, dùng xong trả lại thay vì tạo mới.

// ❌ Tạo StringBuilder mới mỗi request (GC pressure)
public String buildResponse(List<Item> items) {
   StringBuilder sb = new StringBuilder();  // Allocated on heap, GC later
   for (Item item : items) {
       sb.append(item.getName()).append(",");
   }
   return sb.toString();
}
 
// ✅ ThreadLocal StringBuilder pool — reuse per thread
private static final ThreadLocal<StringBuilder> SB_POOL =
   ThreadLocal.withInitial(() -> new StringBuilder(1024));
 
public String buildResponse(List<Item> items) {
   StringBuilder sb = SB_POOL.get();
   sb.setLength(0);  // Reset, không allocate mới
   for (Item item : items) {
       sb.append(item.getName()).append(",");
   }
   return sb.toString();
}

Có điều nên nói thẳng: với object nhỏ và rẻ như StringBuilder, pooling thường không đáng. Trên HotSpot, cấp phát một object nhỏ chỉ là đẩy con trỏ trong TLAB (vùng nhớ riêng của mỗi thread), còn young GC chỉ tốn công cho object còn sống, object chết sớm gần như miễn phí. Pool lại có giá riêng: object sống lâu bị đẩy lên old gen, quên reset là lộ dữ liệu của request trước, và ThreadLocal chạy với virtual thread (Java 21) thì mỗi virtual thread một bản, pool mất ý nghĩa. Bản thân sb.toString() vẫn tạo String mới mỗi lần. Pooling chỉ thật sự đáng với thứ đắt để tạo: connection, thread, buffer lớn hoặc direct buffer. Chưa đo bằng profiler thì đừng pool.

Spring Boot thực tế: connection pooling (HikariCP)

# HikariCP = object pool cho database connections
spring:
  datasource:
    hikari:
      maximum-pool-size: 20 # Pre-create 20 connections
      minimum-idle: 5 # Keep 5 idle, ready to use
      # getConnection() → borrow từ pool (< 1ms)
      # thay vì tạo mới (TCP + TLS + auth, thường từ vài ms tới vài chục ms trở lên)

Connection DB là ví dụ chuẩn của "object đắt": mở mới phải bắt tay TCP, có khi thêm TLS, rồi xác thực. Một lưu ý nhỏ: tài liệu HikariCP khuyên để minimum-idle bằng maximum-pool-size (pool cố định kích thước) để khỏi phải tạo connection giữa lúc tải tăng đột ngột.

Byte array pooling cho tác vụ I/O nặng

// Netty's PooledByteBufAllocator concept — simplified
public class ByteArrayPool {
   private final Queue<byte[]> pool = new ConcurrentLinkedQueue<>();
   private final int bufferSize;
 
   public ByteArrayPool(int bufferSize, int initialSize) {
       this.bufferSize = bufferSize;
       for (int i = 0; i < initialSize; i++) {
           pool.offer(new byte[bufferSize]);
       }
   }
 
   public byte[] borrow() {
       byte[] buf = pool.poll();
       return buf != null ? buf : new byte[bufferSize];
   }
 
   public void returnBuffer(byte[] buf) {
       if (buf.length == bufferSize) {
           pool.offer(buf);  // Return to pool for reuse
       }
   }
}

Bản rút gọn này chưa giới hạn kích thước pool (trả về bao nhiêu giữ bấy nhiêu) và không xoá dữ liệu cũ trong buffer. Netty làm phức tạp hơn nhiều: chia arena theo thread, quản lý theo size class, đếm reference để biết lúc nào buffer được trả.


2. Primitive Arrays > Object Collections: tránh boxing

List<Integer> chứa các object Integer được boxing. Trên JVM 64-bit bật compressed oops (mặc định khi heap dưới 32GB), mỗi Integer là một object 16 bytes (12 bytes header + 4 bytes giá trị), cộng thêm 4 bytes reference nằm trong mảng của ArrayList, tức khoảng 20 bytes cho mỗi phần tử. int[] chỉ tốn 4 bytes mỗi phần tử, xếp liền nhau. Chênh nhau cỡ 5 lần về bộ nhớ, chưa kể mỗi lần đọc phải nhảy theo con trỏ tới chỗ khác trên heap, dễ cache miss.

// ❌ Boxed (heap allocation mỗi element, GC phải track)
List<Integer> scores = new ArrayList<>();  // ~20 bytes/element (object overhead)
int sum = scores.stream().mapToInt(Integer::intValue).sum();  // Unboxing cost
 
// ✅ Primitive array (contiguous memory, cache-friendly)
int[] scores = new int[1000];  // 4 bytes/element, contiguous
int sum = 0;
for (int score : scores) sum += score;  // No boxing/unboxing
 
// Khi CẦN collection behavior + primitives → Eclipse Collections / HPPC
IntArrayList scores = new IntArrayList(1000);  // Primitive int list, no boxing
long sum = scores.sum();  // Eclipse Collections: sum() trả về long, no auto-boxing

Spring Boot thực tế: batch processing

// ❌ Process 1M records with boxed IDs
List<Long> ids = repository.findAllIds();  // 1M Long objects ≈ 28MB heap (24 bytes/Long + 4 bytes ref)
ids.forEach(id -> process(id));
 
// ⚠️ Unbox sau khi đã load cả List: không tiết kiệm được gì,
// 1M Long vẫn nằm trong heap như trên
repository.findAllIds().stream()
   .mapToLong(Long::longValue)
   .forEach(this::processById);
 
// ✅ Stream từ DB: đọc tới đâu xử lý tới đó, không giữ cả list trong memory
// (vẫn là Long boxed, nhưng object chết ngay sau khi xử lý)
@Query(value = "SELECT id FROM products WHERE status = 'ACTIVE'", nativeQuery = true)
Stream<Long> streamAllActiveIds();

Method trả về Stream của Spring Data JPA chỉ chạy được trong transaction đang mở (@Transactional(readOnly = true)) và phải đóng stream sau khi dùng (try-with-resources), không thì connection bị giữ lại. Ngoài ra driver JDBC có thể vẫn kéo hết kết quả về trước: PostgreSQL chỉ đọc theo cursor khi tắt autocommit và đặt fetch size, MySQL Connector/J cũng cần cấu hình fetch size riêng. Nên muốn "đọc dần" thật sự thì kiểm tra cả cấu hình driver.


3. String Interning & Deduplication: tiết kiệm memory

Hàng nghìn object String cùng nội dung ("ACTIVE", "PENDING", "USD"), mỗi cái lại là một object riêng trên heap. Chuyện này hay gặp khi đọc dữ liệu từ DB hay file: driver tạo String mới cho từng dòng, dù giá trị chỉ có vài loại.

// ❌ Hàng ngàn duplicate strings từ DB queries
List<Order> orders = orderRepository.findAll();  // 100K orders
// Mỗi order.getCurrency() = new String("VND") → 100K identical String objects
 
// ✅ String.intern() → share reference từ String Pool
public class Order {
   private String currency;
 
   public void setCurrency(String currency) {
       this.currency = currency.intern();  // Reuse existing String from pool
       // 100K orders nhưng chỉ 3-5 unique currency Strings in memory
   }
}
 
// ✅✅ Enum thay vì String (best approach cho fixed set of values)
public enum Currency { VND, USD, EUR, JPY }
// Zero allocation, zero GC, type-safe, comparable, serializable

Hai lưu ý với ví dụ trên. Một là intern() có chi phí: tra bảng string pool của JVM mỗi lần gọi, nên chỉ hợp với tập giá trị nhỏ và lặp lại nhiều, đừng intern những chuỗi gần như không trùng như email hay ID. Hai là JPA mặc định có thể gán thẳng vào field (field access) mà không đi qua setter, khi đó intern() trong setter không bao giờ chạy. Với tập giá trị cố định thì enum (map bằng @Enumerated(EnumType.STRING)) gọn và an toàn hơn hẳn.

JVM option: String Deduplication

# G1GC tự detect duplicate strings và deduplicate
java -XX:+UseG1GC -XX:+UseStringDeduplication -jar app.jar
# Không cần code change — JVM handles automatically
# Effective khi app có nhiều duplicate strings (parsed from DB/files)

Cờ này có từ JDK 8u20 cho G1 (JEP 192), từ JDK 18 thì Serial, Parallel và ZGC cũng hỗ trợ. Nó không gộp các object String lại làm một: 100K object String vẫn còn đó, chỉ có mảng byte bên trong là được dùng chung. Và nó chỉ xử lý những string đã sống qua vài lần GC (ngưỡng StringDeduplicationAgeThreshold, mặc định 3), string sống ngắn thì không được đụng tới. Nên mức tiết kiệm thường khiêm tốn hơn intern hay enum, bù lại không phải sửa dòng code nào.


4. Lazy Initialization & Compute-on-demand

Vấn đề ở đây là tạo sẵn một thứ tốn kém ngay từ đầu, trong khi có khi không bao giờ dùng tới.

// ❌ Eager — always compute, even if never used
@Service
public class ReportService {
   private final Map<String, ReportTemplate> templates;
 
   public ReportService() {
       this.templates = loadAllTemplates();  // 500ms startup, may never use all
   }
}
 
// ✅ Lazy with double-checked locking (thread-safe)
@Service
public class ReportService {
   private volatile Map<String, ReportTemplate> templates;
 
   public Map<String, ReportTemplate> getTemplates() {
       Map<String, ReportTemplate> result = templates;
       if (result == null) {
           synchronized (this) {
               result = templates;
               if (result == null) {
                   templates = result = loadAllTemplates();
               }
           }
       }
       return result;
   }
}
 
// ✅✅ Java's built-in lazy holders (cleanest pattern)
public class ExpensiveComputation {
   // Inner class NOT loaded until first access → thread-safe lazy init
   private static class Holder {
       static final ExpensiveComputation INSTANCE = new ExpensiveComputation();
   }
 
   public static ExpensiveComputation getInstance() {
       return Holder.INSTANCE;  // Class loaded lazily by JVM
   }
}
 
// ✅✅✅ computeIfAbsent — cache computed results
private final Map<UUID, ProductDTO> cache = new ConcurrentHashMap<>();
 
public ProductDTO getProduct(UUID id) {
   return cache.computeIfAbsent(id, this::loadFromDatabase);
   // First call: compute + cache. Subsequent calls: return cached. Thread-safe.
}

Double-checked locking chỉ đúng khi field là volatile. Thiếu nó thì thread khác có thể thấy reference khác null trong khi object chưa khởi tạo xong. Biến cục bộ result giúp chỉ đọc field volatile một lần ở đường nhanh. Holder pattern thì dựa vào việc JVM khởi tạo class một cách lười và thread-safe, nên khỏi cần lock.

computeIfAbsent của ConcurrentHashMap tiện nhưng có hai cái bẫy. Hàm tính toán chạy trong lúc map đang khoá bin chứa key đó, nên gọi DB chậm bên trong sẽ chặn luôn các thread cần key khác rơi vào cùng bin, và tuyệt đối không được sửa chính map đó trong hàm tính. Thứ hai, map này không bao giờ tự xoá phần tử, chạy lâu là phình heap. Cache thật trong production nên dùng Caffeine (có giới hạn kích thước, hết hạn theo thời gian).


5. Tránh tạo object không cần thiết: reuse patterns

// ❌ Pattern compilation mỗi lần gọi (regex compile = expensive)
public boolean isValidEmail(String email) {
   return email.matches("^[A-Za-z0-9+_.-]+@(.+)$");  // Compiles Pattern EVERY CALL
}
 
// ✅ Pre-compile once, reuse forever
private static final Pattern EMAIL_PATTERN =
   Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$");
 
public boolean isValidEmail(String email) {
   return EMAIL_PATTERN.matcher(email).matches();  // Reuse compiled pattern
}
 
// ❌ DateTimeFormatter mới mỗi lần
public String format(LocalDateTime dt) {
   return dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));  // Parse pattern each time
}
 
// ✅ Static formatter (thread-safe since Java 8)
private static final DateTimeFormatter FORMATTER =
   DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
public String format(LocalDateTime dt) {
   return dt.format(FORMATTER);
}
 
// ❌ Autoboxing in loop
Long sum = 0L;  // Boxed Long!
for (long val : values) {
   sum += val;  // Unbox, add, RE-BOX → millions of Long objects created!
}
 
// ✅ Primitive in loop
long sum = 0L;
for (long val : values) {
   sum += val;  // Pure arithmetic, zero allocation
}

Mấy chỗ này đáng sửa vì vừa nhanh hơn vừa không làm code khó đọc đi. String.matches, String.replaceAll đều compile lại regex mỗi lần gọi; Pattern đã compile thì thread-safe, dùng chung thoải mái (còn Matcher thì không, mỗi lần dùng tạo cái mới). DateTimeFormatter là immutable nên để static được, khác hẳn SimpleDateFormat ngày xưa vốn không thread-safe.


6. Data Locality & Cache-Friendly Structures

CPU không đọc RAM từng byte mà theo cache line, thường là 64 bytes. Mảng object thực chất là mảng con trỏ: mỗi phần tử trỏ tới một object nằm đâu đó trên heap, nên mỗi lần đọc có thể là một lần cache miss (gọi là pointer chasing). Mảng primitive thì dữ liệu nằm liền nhau, duyệt tuần tự là ăn trọn từng cache line, bộ prefetch của CPU còn đoán trước được.

// ❌ Array of objects (pointer chasing → cache misses)
class Point { double x, y; }
Point[] points = new Point[1_000_000];
// Memory: [ptr1, ptr2, ptr3, ...] → [Point@heap1] → [Point@heap2] → scattered!
 
double sumX = 0;
for (Point p : points) sumX += p.x;  // Each p.x = random memory access
 
// ✅ Struct of Arrays (SoA) — contiguous per field
double[] xs = new double[1_000_000];  // All X values contiguous
double[] ys = new double[1_000_000];  // All Y values contiguous
 
double sumX = 0;
for (int i = 0; i < xs.length; i++) sumX += xs[i];
// Sequential memory access → CPU prefetcher happy → thường nhanh hơn rõ rệt, mức chênh tuỳ data
 
// ✅ Spring Boot application: Batch processing with arrays
@Service
public class BulkPriceCalculator {
 
   // Instead of List<Product> → process price arrays
   public double[] calculateDiscounts(double[] prices, double discountRate) {
       double[] results = new double[prices.length];
       for (int i = 0; i < prices.length; i++) {
           results[i] = prices[i] * (1 - discountRate);
       }
       return results;  // Vectorizable by JIT compiler (SIMD instructions)
   }
}

Mức chênh thực tế phụ thuộc nhiều thứ. Object tạo liền một lượt thường nằm gần nhau trong TLAB, và GC kiểu copying hay giữ thứ tự đó, nên duyệt Point[] vừa tạo không tệ như lý thuyết. Sau vài vòng GC, hay khi object được tạo xen kẽ với thứ khác, thì chúng mới tản mát. SoA đáng làm ở những vòng lặp số học thật nóng (tính toán hàng loạt, xử lý tín hiệu, game), còn code nghiệp vụ thông thường thì đổi List<Product> thành mấy mảng rời rạc chỉ làm code khó bảo trì.


7. Lock-Free & Wait-Free Algorithms (atomic operations)

synchronized bắt các thread xếp hàng: thread này giữ lock thì thread khác phải chờ, contention càng cao thì throughput càng tụt. Cách thay thế là dùng lệnh atomic của CPU như Compare-And-Swap (CAS): đọc giá trị, tính giá trị mới, rồi chỉ ghi nếu giá trị hiện tại vẫn như lúc đọc, không thì thử lại. Không ai bị block cả.

// ❌ Synchronized counter (threads block each other)
public class SyncCounter {
   private int count = 0;
   public synchronized void increment() { count++; }  // Lock acquire + release
   public synchronized int get() { return count; }
}
 
// ✅ AtomicInteger (lock-free)
public class AtomicCounter {
   private final AtomicInteger count = new AtomicInteger(0);
   public void increment() { count.incrementAndGet(); }  // No lock (x86: 1 lệnh LOCK XADD)
   public int get() { return count.get(); }
}
 
// ✅ LongAdder — even faster under HIGH contention
// Internally: multiple cells, each thread updates its own cell, sum on read
public class HighThroughputCounter {
   private final LongAdder counter = new LongAdder();
   public void increment() { counter.increment(); }  // Striped, minimal contention
   public long get() { return counter.sum(); }        // Sum all cells
}
 
// Thứ tự thường thấy khi nhiều thread cùng tăng 1 counter:
// synchronized ≈ AtomicLong (cùng tranh nhau 1 cache line) chậm hơn LongAdder nhiều lần.
// Con số tuyệt đối tuỳ CPU, số core, JVM: tự đo bằng JMH trên máy của bạn.

Trên x86, incrementAndGet được JIT thay bằng một lệnh LOCK XADD chứ không phải vòng lặp CAS. Thứ làm AtomicLong chậm khi nhiều core cùng ghi là cache line chứa biến đó bị chuyền qua lại giữa các core. LongAdder né chuyện này bằng cách chia ra nhiều ô (cell), mỗi thread thường ghi vào ô riêng, đọc thì cộng tất cả lại. Đổi lại sum() không phải snapshot chính xác tại một thời điểm, và tốn bộ nhớ hơn. Nên LongAdder hợp với metric, counter thống kê; còn cần giá trị chính xác để ra quyết định (như sinh ID, so sánh rồi set) thì vẫn dùng AtomicLong.

Spring Boot thực tế: rate limiter lock-free

// Fixed-window rate limiter lock-free: mỗi giây một cửa sổ,
// sang giây mới thì bộ đếm tự reset
public class FixedWindowRateLimiter {
 
   private final int maxRequestsPerSecond;
   // Gói (giây hiện tại, số request) vào 1 long để cập nhật bằng 1 lần CAS:
   // 32 bit cao = epoch second, 32 bit thấp = count
   private final AtomicLong state = new AtomicLong();
 
   public FixedWindowRateLimiter(int maxRequestsPerSecond) {
       this.maxRequestsPerSecond = maxRequestsPerSecond;
   }
 
   public boolean tryAcquire() {
       long nowSec = System.currentTimeMillis() / 1000;
       while (true) {
           long current = state.get();
           long windowSec = current >>> 32;
           int count = (int) current;
           long next;
           if (windowSec != nowSec) {
               next = (nowSec << 32) | 1;          // Sang giây mới: reset về 1
           } else if (count < maxRequestsPerSecond) {
               next = current + 1;                 // Cùng giây: tăng count
           } else {
               return false;                       // Hết quota của giây này
           }
           if (state.compareAndSet(current, next)) {
               return true;
           }
           // CAS thất bại: thread khác vừa cập nhật, đọc lại rồi thử tiếp
       }
   }
}

Mẹo ở đây là nhét cả "đang ở giây nào" lẫn "đã đếm bao nhiêu" vào một long, để kiểm tra và cập nhật trong một lần CAS. Nếu tách thành hai biến atomic riêng, giữa lúc reset cửa sổ và lúc tăng bộ đếm sẽ có khe hở cho race condition. Đây là fixed window nên ở ranh giới hai giây có thể lọt gần gấp đôi quota (cuối giây trước và đầu giây sau). Cần mượt hơn thì dùng token bucket hoặc sliding window, có sẵn trong Resilience4j hay Bucket4j. Và nhớ là limiter này chỉ đếm trong một instance; chạy nhiều instance mà cần giới hạn chung thì phải đếm ở chỗ dùng chung như Redis.


8. Bulk Operations & Batch Processing

N lần gọi database là N lần đi lại qua mạng. Mỗi lần dù chỉ 1 tới 5ms, nhân với vài nghìn record là đã thành vài giây.

// ❌ N+1 problem — 1000 individual saves
for (Product product : products) {
   productRepository.save(product);  // 1000 INSERT statements, 1000 round-trips
}
 
// ✅ Batch insert (gom statement theo lô)
@Transactional
public void batchInsert(List<Product> products) {
   // JPA batch: spring.jpa.properties.hibernate.jdbc.batch_size=50
   productRepository.saveAll(products);  // 50 INSERT mỗi lô → 1000 rows ≈ 20 round-trips
}
 
// ✅✅ Native batch (fastest — bypass Hibernate overhead)
@Transactional
public void nativeBatchInsert(List<Product> products) {
   String sql = "INSERT INTO products (id, name, price, status) VALUES (?, ?, ?, ?)";
   jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
       @Override
       public void setValues(PreparedStatement ps, int i) throws SQLException {
           Product p = products.get(i);
           ps.setObject(1, p.getId());
           ps.setString(2, p.getName());
           ps.setBigDecimal(3, p.getPrice());
           ps.setString(4, p.getStatus().name());
       }
       @Override
       public int getBatchSize() { return products.size(); }
   });
   // 1 JDBC batch cho tất cả rows (gửi đi mấy round-trip là tuỳ driver)
}
 
// ✅ MongoDB bulk operations
public void bulkUpdate(List<ProductUpdate> updates) {
   BulkOperations bulkOps = mongoTemplate.bulkOps(BulkOperations.BulkMode.UNORDERED, "products");
   for (ProductUpdate update : updates) {
       Query query = Query.query(Criteria.where("_id").is(update.getId()));
       Update mongoUpdate = new Update().set("price", update.getNewPrice());
       bulkOps.updateOne(query, mongoUpdate);
   }
   BulkWriteResult result = bulkOps.execute();  // 1 round-trip for all updates
}

Batch của Hibernate hay bị "tắt ngầm" vì mấy lý do. Entity dùng GenerationType.IDENTITY (auto increment) thì Hibernate phải INSERT ngay từng dòng để lấy ID, batching không áp dụng cho insert; muốn batch thì dùng SEQUENCE. Entity tự gán ID (như UUID) mà không có @Version hay Persistable thì saveAll gọi merge, mỗi entity tốn thêm một câu SELECT. Nên hibernate.order_inserts=true cũng hay được bật kèm để gom các INSERT cùng bảng.

Với JDBC batch, "một batch" chưa chắc là một round-trip. MySQL Connector/J chỉ viết lại thành một câu INSERT nhiều VALUES khi bật rewriteBatchedStatements=true; PostgreSQL JDBC có reWriteBatchedInserts=true cho việc tương tự. Không bật thì driver vẫn gửi từng câu (PostgreSQL gửi gộp trong ít round-trip hơn, nhưng vẫn là từng statement). Mongo thì driver tự chia nhỏ bulk quá lớn theo giới hạn của server (tối đa 100.000 thao tác mỗi lô).


9. Zero-Copy & Buffer Reuse: hiệu năng I/O

Cách gửi file truyền thống: đọc từ đĩa vào bộ đệm của kernel, copy sang buffer của ứng dụng, rồi copy ngược vào socket buffer của kernel để gửi đi. Dữ liệu chạy qua user space một vòng chỉ để... đi ra lại. Zero-copy (sendfile trên Linux, FileChannel.transferTo trong Java) để kernel chuyển thẳng từ page cache sang socket.

// ❌ Traditional I/O (multiple copies)
byte[] data = Files.readAllBytes(path);  // Copy 1: disk → kernel buffer → user buffer
response.getOutputStream().write(data);   // Copy 2: user buffer → kernel socket buffer
 
// ✅ Không load cả file vào heap: Spring stream file ra response
@GetMapping("/files/{id}")
public ResponseEntity<Resource> downloadFile(@PathVariable UUID id) {
   Path filePath = storageService.getPath(id);
   Resource resource = new FileSystemResource(filePath);
 
   return ResponseEntity.ok()
       .contentType(MediaType.APPLICATION_OCTET_STREAM)
       .body(resource);
   // Spring MVC copy file qua buffer nhỏ (InputStream.transferTo) → không tốn heap theo kích thước file,
   // nhưng dữ liệu vẫn đi qua user space. Zero-copy thật (sendfile) có ở WebFlux + Reactor Netty
}
 
// ✅ StreamingResponseBody — no full file in memory
@GetMapping("/export")
public ResponseEntity<StreamingResponseBody> export() {
   StreamingResponseBody body = outputStream -> {
       // Spring Data MongoDB 3.x+: stream() trả về java.util.stream.Stream, phải đóng sau khi dùng
       try (Stream<Document> docs = mongoTemplate.stream(query, Document.class)) {
           Iterator<Document> it = docs.iterator();
           while (it.hasNext()) {
               Document doc = it.next();
               outputStream.write(toCsvLine(doc).getBytes(StandardCharsets.UTF_8));
               // Stream line by line — never hold full dataset in memory
           }
       }
   };
   return ResponseEntity.ok().body(body);
}

Trong Spring MVC, trả về Resource đã tránh được cái tệ nhất là đọc cả file vào heap, nhưng chưa phải zero-copy: ResourceHttpMessageConverter copy từ InputStream sang output stream của servlet. Muốn kernel tự chuyển file thì có mấy đường: chạy WebFlux trên Reactor Netty (nó dùng ZeroCopyHttpOutputMessage cho file resource), để reverse proxy như Nginx phục vụ file tĩnh, hoặc đẩy file lên object storage rồi trả presigned URL. Lưu ý thêm: bật TLS thì dữ liệu phải mã hoá trong user space, zero-copy kiểu sendfile không dùng được nữa (trừ khi có kernel TLS).

Ở đoạn export, dùng iterator thay vì forEach vì outputStream.write ném IOException, mà lambda của forEach không cho ném checked exception.


10. Efficient Collections: chọn đúng công cụ

// HashMap vs EnumMap — when keys are enum
// ❌ HashMap with Enum keys (hash computation, node object mỗi entry)
Map<OrderStatus, List<Order>> grouped = new HashMap<>();
 
// ✅ EnumMap (array-based internally, O(1) access, no hashing)
Map<OrderStatus, List<Order>> grouped = new EnumMap<>(OrderStatus.class);
// Nhanh hơn và ít memory hơn HashMap cho enum keys
 
// ArrayList vs LinkedList (almost ALWAYS ArrayList wins)
// LinkedList only wins for: frequent add/remove at HEAD (use as Deque)
 
// HashSet.contains() vs sorted array + binary search
// Small set (<20 elements): array scan can be faster (no hashing overhead)
// Large set: HashSet O(1) amortized
 
// ✅ Specialized collections for high-performance
// Agrona (used by Aeron messaging): Int2ObjectHashMap, MutableLong
// Eclipse Collections: IntArrayList, LongHashSet, UnifiedMap
// HPPC: IntIntHashMap (primitive-to-primitive, zero boxing)
 
// ✅ Pre-size collections
// ❌
List<ProductDTO> results = new ArrayList<>();  // Default capacity 10, grows via arraycopy
// ✅
List<ProductDTO> results = new ArrayList<>(expectedSize);  // No resize needed
Map<String, Object> map = HashMap.newHashMap(expectedSize);  // Java 19+, or capacity = size/0.75+1

EnumMap bên trong chỉ là một mảng đánh số theo ordinal() của enum, nên không phải tính hash, không tạo node cho mỗi entry. EnumSet còn gọn hơn: enum dưới 64 hằng số thì cả set chỉ là một long bitmask.

Với LinkedList, ngay cả "chèn/xoá ở đầu" thì ArrayDeque cũng thường nhanh hơn, vì mỗi node của LinkedList là một object riêng (lại pointer chasing như mục 6). Chỗ HashMap.newHashMap đáng nhớ: new HashMap<>(n) nhận vào capacity chứ không phải số phần tử, với load factor 0.75 thì truyền n vẫn bị resize khi đầy tới 75%. newHashMap(n) (Java 19) tự tính capacity cho đủ chứa n phần tử.


11. JIT-Friendly Code: giúp compiler giúp bạn

JIT compiler của JVM tối ưu code lúc chạy dựa trên profile thu được. Có những cách viết giúp nó tối ưu được, có những cách chặn đường nó.

// ✅ Small methods → JIT can INLINE (eliminate method call overhead)
// Method < 35 bytes bytecode (MaxInlineSize) được inline cả khi chưa nóng;
// method nóng được inline tới 325 bytes (FreqInlineSize)
public boolean isActive(Product p) { return p.getStatus() == ProductStatus.ACTIVE; }
// JIT inlines this → zero method call overhead at call sites
 
// ❌ Megamorphic dispatch blocks inlining
interface Processor { void process(Data d); }
// Nếu tại cùng 1 call site JIT thấy 3+ kiểu thực tế → không inline → virtual dispatch every call
// Fix: limit implementations, or restructure to avoid hot polymorphic calls
 
// ✅ Final classes/methods → devirtualization
public final class FastCalculator {  // JIT biết chắc không có subclass
   public final double compute(double x) { return x * x + 1; }
}
// (HotSpot vốn tự devirtualize qua class hierarchy analysis khi chưa load subclass nào,
// nên final thường không làm nhanh thêm bao nhiêu)
 
// Loop-invariant code motion — JIT thường tự làm việc này:
// ❌
for (int i = 0; i < list.size(); i++) { ... }  // list.size() called each iteration
// ✅
int size = list.size();
for (int i = 0; i < size; i++) { ... }  // Hoisted out of loop
// (size() ngắn nên được inline; hoist tay chủ yếu có ích khi lời gọi đắt thật)
 
// ✅ Avoid megamorphic calls in hot loops
// JIT optimizes monomorphic (1 type) and bimorphic (2 types) call sites
// 3+ types at same call site → megamorphic → slow virtual dispatch

Điểm hay bị hiểu nhầm: megamorphic là chuyện của từng call site, không phải của interface. Interface có 10 implementation nhưng một vòng lặp nóng chỉ toàn gặp một loại thì call site đó vẫn là monomorphic và được inline bình thường. JIT ghi lại kiểu thực tế nó gặp ở mỗi chỗ gọi; một khi thấy từ 3 kiểu trở lên thì C2 thôi inline và chuyển sang tra bảng phương thức (vtable/itable). Muốn biết JIT đã làm gì thì xem bằng -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining hoặc JITWatch, đừng đoán.


12. Concurrent Data Structures: hơn cả synchronized

// ConcurrentHashMap — Java 7: segment locking; Java 8+: CAS + synchronized trên từng bin
ConcurrentHashMap<UUID, ProductDTO> cache = new ConcurrentHashMap<>();
cache.computeIfAbsent(id, k -> loadProduct(k));  // Atomic compute, no external lock
 
// CopyOnWriteArrayList — reads never block (copy on write)
// Perfect for: rarely-modified, frequently-read lists (event listeners, config)
List<EventHandler> handlers = new CopyOnWriteArrayList<>();
// Reads: zero synchronization. Writes: copy entire array (expensive but rare)
 
// ConcurrentLinkedQueue — lock-free FIFO queue (Michael-Scott algorithm)
Queue<Task> taskQueue = new ConcurrentLinkedQueue<>();
// Non-blocking add/poll — ideal for producer-consumer without locks
 
// Disruptor pattern (LMAX) — mechanical sympathy, ring buffer
// Theo benchmark của LMAX: hơn 25M messages/second (1 producer, 1 consumer), latency ổn định
// Used in: trading systems, high-throughput event processing

ConcurrentLinkedQueue không giới hạn kích thước và không có thao tác chờ: consumer muốn đợi có việc thì phải tự poll liên tục hoặc ngủ. Producer nhanh hơn consumer thì hàng đợi cứ phình ra cho tới khi hết heap. Mô hình producer-consumer thông thường thì ArrayBlockingQueue/LinkedBlockingQueue có giới hạn (tạo backpressure) thường là lựa chọn đúng hơn. Disruptor nhanh nhờ ring buffer cấp phát sẵn, tránh false sharing bằng padding và hạn chế tối đa lock; con số 25M/s là từ paper của LMAX cho kịch bản đơn giản nhất, ứng dụng thật còn tuỳ việc mỗi consumer làm.


13. Memory-Mapped Files: để OS lo cache

// Cho large files (GB+): mmap = OS manages caching, zero explicit I/O
// File acts like byte array in memory — OS pages in/out automatically
// Lưu ý: 1 MappedByteBuffer tối đa Integer.MAX_VALUE bytes (~2GB).
// File lớn hơn phải map từng đoạn, hoặc dùng channel.map(mode, 0, size, arena)
// trả về MemorySegment (FFM API, Java 22+) không bị giới hạn này
 
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
   MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size());
   // 'buffer' is now backed by OS page cache
   // Random access = O(1), OS handles prefetching
   // No explicit read() calls needed — access like array
 
   while (buffer.hasRemaining()) {
       byte b = buffer.get();  // Không gọi read(); trang chưa có trong RAM thì page fault → OS đọc đĩa
   }
}
 
// Use case: Log file processing, large CSV parsing, database file access
// SharedMemory between processes (IPC)

mmap tránh được việc copy từ page cache sang buffer của ứng dụng, nhưng không làm đĩa nhanh hơn. Lần đầu chạm vào một trang chưa có trong RAM thì thread bị chặn lại chờ OS đọc từ đĩa (page fault), và chuyện này không hiện ra như một lời gọi I/O nên khó thấy khi profile. Thêm nữa, MappedByteBuffer chỉ được giải phóng khi GC thu hồi object buffer, không có hàm unmap công khai; đó là một lý do API MemorySegment với Arena (đóng arena là unmap ngay) dễ kiểm soát hơn ở Java mới.


14. Escape Analysis & Scalar Replacement

Escape analysis là bước JIT kiểm tra xem một object có "thoát" ra khỏi method hay không (bị trả về, gán vào field, truyền cho code mà JIT không nhìn thấu). Nếu không thoát, HotSpot có thể bỏ hẳn việc cấp phát object đó: các field được tách ra thành biến cục bộ nằm trong thanh ghi hoặc trên stack. Kỹ thuật này gọi là scalar replacement; HotSpot không thật sự "cấp phát object trên stack" như cách người ta hay nói, nhưng kết quả tương tự: không tốn heap, GC không phải biết tới.

// ✅ Object that DOESN'T escape method → JIT có thể bỏ hẳn allocation (scalar replacement, no GC!)
public double calculateDistance(double x1, double y1, double x2, double y2) {
   Point p1 = new Point(x1, y1);  // JIT: p1 doesn't escape → không cần cấp phát trên heap
   Point p2 = new Point(x2, y2);  // JIT: p2 doesn't escape → không cần cấp phát trên heap
   return p1.distanceTo(p2);
   // Fields thành biến cục bộ/thanh ghi. Zero GC work.
}
 
// ❌ Object ESCAPES method → must heap allocate
public Point createPoint(double x, double y) {
   return new Point(x, y);  // Returned to caller → escapes → heap allocated → GC tracks
}
 
// Help escape analysis:
// - Keep methods small (easier for JIT to analyze)
// - Avoid storing local objects into fields/collections
// - Avoid passing local objects to methods that might store them
// - Use records/value types for small temporary data

Ở ví dụ trên, p2 được truyền vào p1.distanceTo(p2). JIT chỉ biết chắc p2 không thoát nếu nó inline được distanceTo; method đó quá lớn hoặc call site megamorphic thì p2 bị coi là thoát và vẫn được cấp phát. Đây là lý do "giữ method nhỏ" ở trên có tác dụng thật. Escape analysis cũng chỉ chạy ở code đã được C2 compile (code nóng), nên đừng kỳ vọng nó ở đoạn chỉ chạy vài lần.


15. Quick Reference: khi nào dùng gì

Cột lợi ích dưới đây chỉ là ước lượng định tính. Mức cải thiện thực tế có thể từ không đáng kể tới rất lớn tuỳ workload, phần cứng và JVM, nên coi đây là gợi ý chỗ để đo, không phải lời hứa.

TechniqueLợi ích (ước lượng)When to useComplexity
Primitive arrays~5x ít memory hơn boxed, duyệt nhanh hơnBulk numeric processingLow
Object poolingGiảm allocation, chỉ đáng với object đắtHigh-allocation hot pathsMedium
String.intern() / EnumGiảm memory khi chuỗi lặp nhiềuFixed-set string valuesLow
Pre-compile regex/formatterBỏ chi phí compile mỗi lần gọiAny repeated pattern usageLow
Batch DB operationsGiảm số round-trip, thường cải thiện rất rõBulk inserts/updatesLow
Lock-free (Atomic/LongAdder)Rõ rệt khi contention caoShared counters, hot metricsMedium
EnumMap/EnumSetNhanh hơn, ít memory hơn HashMap/HashSetEnum keys/valuesLow
Pre-size collectionsTránh resize và copy mảngKnown-size collectionsLow
Zero-copy I/OGiảm copy và CPU khi gửi file lớnLarge file servingMedium
Memory-mapped filesTruy cập ngẫu nhiên file lớn không cần read()GB+ file processingMedium
Cache-friendly layout (SoA)Có thể nhanh hơn nhiều lần khi duyệt tuần tựNumeric batch processingHigh
JIT-friendly patternsTuỳ hot path, cần xem log JIT mới biếtInner loops, framework codeMedium

Mấy nguyên tắc nên giữ:

  1. Đo trước đã: benchmark bằng JMH trước và sau khi sửa. Đừng đoán. Benchmark viết tay bằng System.nanoTime() rất dễ bị JIT "tối ưu mất" đoạn code cần đo.
  2. Mặc định ưu tiên dễ đọc: chỉ tối ưu chỗ đã chứng minh là nút cổ chai.
  3. Hiểu GC mình đang dùng: nhiều "tối ưu" trở nên thừa với GC hiện đại (G1, ZGC, Shenandoah).
  4. JIT thông minh hơn bạn nghĩ: nhiều khi "tối ưu" của bạn là thứ JIT đã tự làm.
  5. Profile, đừng phỏng đoán: dùng JFR, async-profiler để tìm đúng chỗ nóng.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.