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

Explore
MikeDaDev's Blog
in java

Optional trong Java

Tung Bui Thanh (TungDaDev)12 mins read

Java optional.webp

Tôi gọi nó là sai lầm tỷ đô của mình: phát minh ra null reference năm 1965. Khi đó tôi đang thiết kế hệ thống kiểu hoàn chỉnh đầu tiên cho tham chiếu trong một ngôn ngữ hướng đối tượng (ALGOL W). Nhưng tôi đã không cưỡng lại được ý muốn thêm null reference, chỉ vì nó quá dễ implement. Nó đã dẫn tới vô số lỗi, lỗ hổng bảo mật và sập hệ thống, có lẽ đã gây thiệt hại cỡ một tỷ đô la trong bốn mươi năm qua. (Sir Tony Hoare, giải Turing 1980, phát biểu tại QCon London 2009)

Để bớt khổ vì NullPointerException (NPE), từ Java 8 team thiết kế ngôn ngữ đưa vào java.util.Optional<T>. Có điều sau chục năm, Optional đang bị dùng tràn lan: bọc ở cả những chỗ không cần, tạo thêm object trong vòng lặp nóng, và code rối hơn mức cần thiết.

Brian Goetz (Java Language Architect ở Oracle) từng phải nói rõ trên StackOverflow: "Our intention was to provide a limited mechanism for library method return types where there needed to be a clear way to represent "no result", and using null for such was overwhelmingly likely to cause errors." Javadoc của Optional cũng ghi đúng ý đó: nó chủ yếu dành cho kiểu trả về của method.

Bài này xem Optional thật sự tốn gì trong bộ nhớ JVM, rồi chốt lại vài quy tắc null-safety nên theo khi chạy production.


# bên trong JVM: Optional tốn bộ nhớ & GC ra sao

Nhiều người nghĩ bọc một object vào Optional là "miễn phí". Với JVM thì chẳng có gì miễn phí cả.

Một Optional là một object bình thường, nằm trên heap:

# một Optional instance chiếm bao nhiêu

  • Object Header: 12 bytes (8 bytes Mark Word + 4 bytes Klass Word nhờ -XX:+UseCompressedClassPointers, mặc định bật).
  • Field Reference: 4 bytes (con trỏ value, nén nhờ -XX:+UseCompressedOops, mặc định bật khi heap dưới khoảng 32GB).
  • Memory Alignment: 12 + 4 = 16 đã tròn bội số của 8 nên không cần padding.
  • → Mỗi instance Optional tốn 16 bytes RAM, chưa tính object thật nằm bên trong. Heap lớn hơn 32GB (compressed oops tắt) thì reference thành 8 bytes, tổng lên 24 bytes. Từ Java 25, bật compact object headers (-XX:+UseCompactObjectHeaders, JEP 519) thì header còn 8 bytes, nhưng Optional vẫn tròn lên 16 bytes.

Số liệu trên kiểm tra được bằng JOL (java -jar jol-cli.jar internals java.util.Optional).

# boxing với kiểu nguyên thuỷ còn tệ hơn

Nếu bạn viết:

Optional<Integer> count = Optional.of(1_000);

Thì cái giá là:

  1. Tạo 1 object java.lang.Integer (autoboxing): 16 bytes. Riêng các số từ -128 tới 127 thì Integer.valueOf lấy từ cache dựng sẵn, không cấp phát mới.
  2. Tạo 1 object java.util.Optional: 16 bytes.
  3. → Mất 32 bytes chỉ để chứa một số int 4 byte.

Cách tránh: với primitive thì dùng mấy class riêng OptionalInt, OptionalLong, OptionalDouble, khỏi phải tạo thêm wrapper ở giữa.

# ảnh hưởng tới garbage collector (gc pressure)

Giả sử hệ thống chạy 50.000 requests/giây mà trong mấy vòng lặp xử lý cứ tạo ra vài trăm nghìn Optional sống ngắn, thì vùng Young Generation (Eden Space) đầy nhanh hơn và Minor GC chạy dày hơn.

Nhưng cũng đừng phóng đại. Cấp phát trong Eden rất rẻ (chỉ là tăng con trỏ trong TLAB), và chi phí Minor GC tỉ lệ với số object còn sống chứ không phải số object đã chết. Hơn nữa, JIT có escape analysis: Optional tạo ra rồi dùng ngay trong một method, không thoát ra ngoài, thường được scalar replacement xoá hẳn, không cấp phát gì trên heap. Vì vậy với code thông thường, Optional ở kiểu trả về hầu như không đáng lo. Chỉ nên tối ưu khi profiler (JFR, async-profiler) chỉ ra allocation thật sự nằm ở đó.


# mấy anti-pattern nên bỏ khi dùng Optional

# anti-pattern 1: dùng Optional làm tham số method (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!
}
  • Dở ở chỗ: người gọi phải tự bọc dữ liệu: sendInvoice(customer, Optional.of(code)) hoặc sendInvoice(customer, Optional.empty()).
  • Mà vẫn chưa an toàn: người gọi vẫn truyền thẳng sendInvoice(customer, null) được → tới lúc gọi discount.isPresent() là lại ăn NullPointerException.
  • Nên làm: dùng method overloading hoặc annotation nullable:
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. Để nó làm field là mấy framework cache (Redis Session, Hazelcast) hay RMI serialization gãy ngay.
  • JPA không hỗ trợ Optional làm kiểu của attribute, Hibernate cũng không map thẳng nó xuống cột database.
  • Cách quen dùng: field vẫn là String có thể null, còn getter trả Optional.ofNullable(middleName). Entity map bình thường, mà code gọi getter vẫn được nhắc là giá trị có thể không có.

# 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) { ... }
  • Nguyên tắc: bản thân collection đã diễn tả được "không có phần tử nào" bằng danh sách rỗng (List.of() hoặc Collections.emptyList()).
  • Đừng bao giờ trả về Optional<List<T>> hay null. Cứ trả về collection rỗng.

# anti-pattern 4: khác biệt quan trọng: orelse() vs orelseget()

Lỗi này làm chậm hệ thống một cách âm thầm, và rất hay gặp:

// 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 lúc nào cũng được tính, kể cả khi Optional có giá trị. Nếu bên trong là gọi database, REST API hay một thuật toán nặng thì bạn đang đốt tài nguyên vô ích.
  • orElseGet(Supplier<? extends T> supplier) (lazy evaluation): chỉ khi Optional rỗng thật thì Supplier mới chạy:
// CHUẨN XÁC: Chỉ chạy khi không tìm thấy User
User user = userRepository.findByEmail(email)
    .orElseGet(() -> authService.createDefaultUser(email));

Còn giá trị mặc định là hằng số hoặc biến có sẵn (orElse("N/A"), orElse(DEFAULT_CONFIG)) thì cứ dùng orElse, chẳng tốn gì thêm.

Hai anti-pattern cuối ngắn hơn nên gom luôn ở đây. Gọi get() không kiểm tra thì chẳng khác gì dùng null: rỗng là ăn NoSuchElementException thay vì NPE. Từ Java 10 có orElseThrow() không tham số, làm đúng việc của get() nhưng tên gọi nói rõ là có thể ném exception, nên dùng nó thay get(). Trả null từ method kiểu Optional thì phá luôn lời hứa của kiểu trả về: người gọi viết findX().map(...) và nhận NPE ngay tại chỗ tưởng an toàn nhất. Không có gì thì trả Optional.empty().


# dùng Optional theo kiểu functional: monadic pipeline

Thay vì viết kiểu imperative với if (opt.isPresent()), có thể dùng map, flatMap, filter của Optional (cùng ý tưởng với Stream, dân functional gọi đây là kiểu monad) để biến đổi dữ liệu cho an toàn:

# luồng xử lý thanh toán không bao giờ ném npe

@RequiredArgsConstructor
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: mọi nhánh rỗng ở trên đều đổ về đây
            .orElseThrow(() -> new PaymentProcessingException("Giao dịch bị từ chối hoặc dữ liệu không hợp lệ"));
    }
}

Pipeline này gọn, nhưng có hai chỗ cần biết. Thứ nhất, nó chỉ chống được request null; req.amount() mà null thì compareTo vẫn ném NPE ngay trong filter. Thứ hai, số tiền không hợp lệ, thẻ bị gắn cờ gian lận và cổng thanh toán từ chối đều thành cùng một exception, cùng một message, nên log và client không biết thật ra hỏng ở bước nào. Khi các nhánh lỗi cần xử lý khác nhau thì kiểm tra tuần tự với exception riêng cho từng trường hợp vẫn rõ ràng hơn.

# mấy method có thêm từ Java 9+

  • optional.ifPresentOrElse(Consumer, Runnable): xử lý cả nhánh có giá trị lẫn nhánh rỗng, khỏi cần if-else.
  • optional.or(Supplier<Optional<T>>): đưa ra phương án dự phòng là một Optional khác (giống fallback chain).
  • optional.stream(): biến Optional thành Stream có 0 hoặc 1 phần tử, rất tiện khi muốn lọc bỏ giá trị rỗng trong Stream.flatMap().
  • Thêm nữa: orElseThrow() không tham số (Java 10) và isEmpty() (Java 11).

# sắp tới: null-safety với JSpecify & project valhalla

Với Java sau này, Optional không còn là cách duy nhất để kiểm soát null. Về phía Valhalla, JEP 401 (Value Objects) đã được tích hợp cho JDK 28 dưới dạng preview, và Optional, OptionalInt... nằm trong nhóm class được khai báo lại thành value class. Value object không có identity nên JVM được phép làm phẳng hoặc giữ nó trong thanh ghi thay vì cấp phát trên heap, nhưng đó là cho phép chứ không phải cam kết, và phải bật --enable-preview mới dùng được.

# chuẩn hoá với JSpecify

Thay vì cái gì cũng bọc vào Optional, nhiều người khuyên dùng chuẩn JSpecify (bản 1.0 ra tháng 7/2024, Spring Framework 7 đã chuyển sang dùng) cùng công cụ phân tích tĩnh NullAway của Uber. Bản thân javac không hiểu mấy annotation này; NullAway chạy như một plugin của Error Prone trong lúc biên dịch, nên build fail khi có chỗ dùng giá trị nullable mà chưa kiểm tra:

import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
 
public class CustomerService {
    // NullAway (chạy lúc build) bắt người gọi phải kiểm tra null trước khi dùng
    public @Nullable Customer findCustomer(@NonNull String nationalId) {
        // ...
    }
}

Dev mà dùng kết quả khi chưa kiểm tra null là IDE (IntelliJ đã hiểu JSpecify) và NullAway báo lỗi ngay, còn lúc runtime thì không tốn thêm gì cả. Thực tế thường gắn @NullMarked ở mức package hoặc class để mặc định mọi thứ là non-null, khi đó chỉ cần ghi @Nullable ở chỗ có thể null, khỏi rải @NonNull khắp nơi.


# checklist trước khi lên production

  • 1. Chỉ dùng làm return type: Optional chỉ nên là kiểu trả về của những method nghiệp vụ có thể không có kết quả. Không dùng làm tham số hay field.
  • 2. Không bao giờ return null: method trả về Optional thì đừng viết return null;. Luôn trả Optional.empty().
  • 3. Phân biệt orElse và orElseGet: chỗ nào gọi orElse() mà bên trong có gọi hàm hay tính toán thì đổi hết sang orElseGet().
  • 4. Primitive specialization: dùng OptionalInt, OptionalLong, OptionalDouble cho kiểu nguyên thuỷ để khỏi autoboxing.
  • 5. Không bọc collection: method nào trả về List, Set, Map thì trả collection rỗng, không trả Optional.

# lời kết

Optional là cách rất hay để Java nói rõ "chỗ này có thể không có giá trị", thay vì bắt mọi người đoán mò về null. Nhưng dùng cho tốt thì phải thấy được cái giá đằng sau sự tiện lợi đó.

Hiểu JVM cấp phát bộ nhớ thế nào, tránh mấy anti-pattern ở trên, dùng Optional ở tầng API public và kết hợp thêm công cụ kiểm tra tĩnh (JSpecify / NullAway), vậy là hệ thống vừa yên tâm về NullPointerException vừa giữ được hiệu 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 😎 👍🏻 🚀 🔥.