TungDaDev's Blog

Optional trong Java

Java optional.webp
Published on
/10 mins read/

Tôi gọi null reference là 'sai lầm tỷ đô' của mình. Năm 1965, khi thiết kế hệ thống kiểu đầu tiên cho ngôn ngữ hướng đối tượng ALGOL W, tôi đã không thể cưỡng lại sự cám dỗ đặt vào một con trỏ rỗng (null pointer) chỉ vì nó quá dễ để implement ở tầng compiler. Quyết định đó đã dẫn đến vô số lỗi bảo mật, sập hệ thống và thiệt hại kinh tế hàng tỷ dollar trong suốt nửa thế kỷ qua. — Sir Tony Hoare (Giải thưởng Turing 1980)

Để giải quyết vấn nạn NullPointerException (NPE) kinh hoàng trong Java, từ phiên bản Java 8, nhóm thiết kế ngôn ngữ đã giới thiệu java.util.Optional<T>. Tuy nhiên, sau một thập kỷ áp dụng, Optional đang bị lạm dụng một cách vô tội vạ, tạo ra hàng loạt vấn đề mới về hiệu năng: rác bộ nhớ (Memory Allocation Churn), áp lực lên Garbage Collection (GC Pause), và những đoạn mã phức tạp hóa không đáng có.

Brian Goetz (Kiến trúc sư trưởng ngôn ngữ Java) từng phải thốt lên: "Optional was intended primarily as a library method return type where there was a clear need to represent 'no result' — and where using null was overwhelmingly likely to cause errors".

Bài viết này phân tích chi phí vận hành tầng sâu của Optional trong bộ nhớ JVM và thiết lập bộ quy tắc chuẩn mực Null-Safety cho production.


# chi phí tầng sâu trong JVM: cái giá về bộ nhớ & garbage collection

Rất nhiều lập trình viên nghĩ rằng việc bọc một đối tượng vào Optional là hoàn toàn "miễn phí". Dưới góc độ kiến trúc máy tính và quản trị bộ nhớ JVM, không có gì là miễn phí cả.

Một đối tượng Optional thông thường là một thực thể nằm trên bộ nhớ Heap (Heap-allocated Object):

# phân bổ bộ nhớ của 1 Optional instance

  • Object Header: 12 bytes (8 bytes Mark Word + 4 bytes Klass Word khi bật -XX:+UseCompressedOops).
  • Field Reference: 4 bytes (con trỏ trỏ tới value).
  • Memory Alignment: 4 bytes padding để đạt bội số của 8 bytes.
  • → Mỗi instance Optional ngốn tối thiểu 16 bytes RAM (chưa tính dung lượng của đối tượng thật nằm bên trong).

# thảm họa boxing với kiểu dữ liệu nguyên thủy

Nếu bạn viết:

Optional<Integer> count = Optional.of(100);

Chi phí sẽ là:

  1. Tạo 1 đối tượng java.lang.Integer (Autoboxing): 16 bytes.
  2. Tạo 1 đối tượng java.util.Optional: 16 bytes.
  3. → Tiêu tốn 32 bytes chỉ để chứa một con số nguyên 4-byte int!

Khắc phục: Luôn sử dụng các class chuyên dụng cho primitive: OptionalInt, OptionalLong, OptionalDouble để tránh việc cấp phát thêm đối tượng wrapper trung gian.

# tác động tới garbage collector (gc pressure)

Nếu một hệ thống xử lý 50.000 requests/giây, việc tạo ra hàng trăm ngàn instance Optional ngắn hạn trong các vòng lặp xử lý sẽ làm tràn ngập vùng nhớ Young Generation (Eden Space), kích hoạt các đợt Minor GC liên tục và làm tăng độ trễ đuôi (P99 Tail Latency) của toàn bộ hệ thống.


# các anti-patterns cần loại bỏ khi dùng Optional

# anti-pattern 1: dùng Optional làm tham số phương thức (method parameter)

// SAI LẦM KIẾN TRÚC:
public void sendInvoice(Customer customer, Optional<DiscountCode> discount) {
    // Phức tạp hóa lời gọi hàm!
}
  • Tại sao xấu: Bắt người gọi hàm phải bọc dữ liệu: sendInvoice(customer, Optional.of(code)) hoặc sendInvoice(customer, Optional.empty()).
  • Nguy cơ tiềm ẩn: Người gọi vẫn có thể truyền thẳng sendInvoice(customer, null) → Phương thức lại tiếp tục ném NullPointerException khi cố gọi discount.isPresent()!
  • Chuẩn Enterprise: Sử dụng nạp chồng phương thức (Method Overloading) hoặc Nullable annotation:
public void sendInvoice(Customer customer) {
    sendInvoice(customer, null);
}
public void sendInvoice(Customer customer, @Nullable DiscountCode discount) {
    if (discount != null) { ... }
}

# anti-pattern 2: dùng Optional làm field trong JPA Entity

// NGUY HIỂM: Phá vỡ chuẩn JPA & Serialization!
@Entity
public class Employee {
    @Id
    private Long id;
 
    // SAI LẦM:
    private Optional<String> middleName;
}
  • Optional không implements java.io.Serializable. Việc đặt nó làm field sẽ làm gãy các framework caching (Redis Session, Hazelcast) hoặc RMI serialization.
  • Hibernate và JPA không hỗ trợ ánh xạ Optional trực tiếp vào cột cơ sở dữ liệu nếu thiếu custom AttributeConverter.

# anti-pattern 3: bọc collection bên trong Optional (Optional<list<t>>)

// SAI LẦM: Bọc 2 tầng vô nghĩa!
public Optional<List<Order>> getOrdersByCustomer(Long customerId) { ... }
  • Quy tắc vàng: Một Collection bản thân nó đã có khả năng biểu diễn trạng thái "không có phần tử nào" thông qua danh sách rỗng (List.of() hoặc Collections.emptyList()).
  • Không bao giờ trả về Optional<List<T>> hay null. Luôn luôn trả về một Empty Collection!

# anti-pattern 4: sự khác biệt sống còn: orelse() vs orelseget()

Đây là một trong những lỗi gây suy giảm hiệu năng âm thầm phổ biến nhất trong các hệ thống Java:

// NGUY HIỂM CHẾT NGƯỜI VỀ HIỆU NĂNG:
User user = userRepository.findByEmail(email)
    .orElse(authService.createDefaultUser(email)); // authService luôn luôn bị thực thi!
  • orElse(T other) (Eager Evaluation): Biểu thức bên trong orElse luôn luôn được tính toán, bất kể Optional có rỗng hay không! Nếu bên trong là một cuộc gọi Database, REST API hoặc thuật toán nặng, bạn đang tiêu tốn tài nguyên vô ích.
  • orElseGet(Supplier<? extends T> supplier) (Lazy Evaluation): Chỉ khi nào Optional thực sự rỗng thì Supplier mới được kích hoạt:
// CHUẨN XÁC: Chỉ chạy khi không tìm thấy User
User user = userRepository.findByEmail(email)
    .orElseGet(() -> authService.createDefaultUser(email));

# khai thác sức mạnh functional: monadic pipeline trong thực tế

Thay vì viết mã theo phong cách mệnh lệnh (Imperative) với if (opt.isPresent()), hãy tận dụng tính chất Monadic Functor của Optional để biến đổi dữ liệu an toàn:

# xử lý luồng giao dịch tài chính không bao giờ ném npe

public class PaymentProcessingService {
 
    private final PaymentGateway paymentGateway;
    private final FraudDetector fraudDetector;
 
    public String processPayment(PaymentRequest request) {
        return Optional.ofNullable(request)
            // 1. Kiểm tra điều kiện đầu vào
            .filter(req -> req.amount().compareTo(BigDecimal.ZERO) > 0)
            // 2. Chặn các giao dịch nghi vấn gian lận
            .filter(req -> !fraudDetector.isFlagged(req.cardNumber()))
            // 3. FlatMap: Gọi phương thức trả về Optional khác mà không bị lồng Optional<Optional<T>>
            .flatMap(req -> paymentGateway.charge(req.cardNumber(), req.amount()))
            // 4. Biến đổi dữ liệu đầu ra
            .map(Receipt::transactionId)
            // 5. Thất bại: Ném exception có ngữ cảnh rõ ràng
            .orElseThrow(() -> new PaymentProcessingException("Giao dịch bị từ chối hoặc dữ liệu không hợp lệ"));
    }
}

# các tính năng bổ sung từ Java 9+

  • optional.ifPresentOrElse(Consumer, Runnable): Xử lý cả 2 nhánh có giá trị và rỗng mà không cần if-else.
  • optional.or(Supplier<Optional<T>>): Cung cấp giải pháp dự phòng bằng một Optional khác (tương tự fallback chain).
  • optional.stream(): Chuyển đổi một Optional thành một Stream có 0 hoặc 1 phần tử, cực kỳ hữu dụng khi lọc bỏ các giá trị rỗng trong Stream.flatMap().

# tương lai: kiến trúc null-safety với JSpecify & project valhalla

Trong tương lai của Java, Optional không phải là công cụ duy nhất để kiểm soát null.

# chuẩn hóa với JSpecify

Thay vì bọc mọi thứ vào Optional, các kiến trúc sư khuyến nghị sử dụng chuẩn JSpecify kết hợp với công cụ phân tích tĩnh NullAway của Uber:

import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
 
public class CustomerService {
    // Trình biên dịch bắt buộc người gọi phải kiểm tra null trước khi dùng
    public @Nullable Customer findCustomer(@NonNull String nationalId) {
        // ...
    }
}

Trình biên dịch sẽ báo lỗi ngay trong IDE nếu lập trình viên truy cập vào kết quả mà chưa kiểm tra null, đạt được Zero Runtime Overhead.


# bảng kiểm tra sẵn sàng vận hành (architect's checklist)

  • 1. Chỉ Dùng Làm Return Type: Đảm bảo Optional chỉ được dùng làm kiểu trả về của các phương thức nghiệp vụ có khả năng trả về rỗng. Tuyệt đối không dùng làm tham số hay field.
  • 2. Tuyệt Đối Không Return Null: Không bao giờ viết return null; trong phương thức trả về Optional. Luôn luôn trả về Optional.empty().
  • 3. Phân Biệt orElse và orElseGet: Toàn bộ các lời gọi orElse() có chứa biểu thức hàm hay logic tính toán phải được đổi sang orElseGet().
  • 4. Primitive Specialization: Sử dụng OptionalInt, OptionalLong, OptionalDouble cho các kiểu nguyên thủy để triệt tiêu autoboxing.
  • 5. Không Bọc Collection: Đảm bảo toàn bộ phương thức trả về danh sách, tập hợp (List, Set, Map) trả về Collection rỗng thay vì Optional.

# lời kết

Optional là một công cụ biểu đạt tuyệt vời của ngôn ngữ Java để chấm dứt hội chứng "đoán mò về null". Tuy nhiên, một kiến trúc sư phần mềm giỏi phải nhìn thấy được cái giá đằng sau sự tiện lợi đó.

Hiểu rõ chi phí cấp phát bộ nhớ của JVM, tránh xa các anti-patterns và kết hợp linh hoạt giữa Optional ở tầng API công khai với các công cụ kiểm soát tĩnh (JSpecify / NullAway) sẽ giúp hệ thống của bạn vừa an toàn tuyệt đối trước NullPointerException, vừa duy trì được hiệu năng xử lý ở mức tối ưu nhất.


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 😎 👍🏻 🚀 🔥.