Java 8–17: Những tính năng thay đổi cách bạn viết code
Java 8–17: Những tính năng thay đổi cách bạn viết code
Lazy evaluation, Optional, Stream, Record, Sealed Class – không chỉ để phỏng vấn, mà để viết production code an toàn và rõ ràng.
Mở đầu: Khi senior viết code fintech và tôi phải review
Bạn là tech lead của một công ty fintech. Một developer 4 năm kinh nghiệm gửi PR xử lý danh sách giao dịch. Nhìn qua, code chạy được. Nhìn kỹ, có cả tá vấn đề:
public class TransactionProcessor {
public List<String> getHighValueTxIds(List<Transaction> transactions) {
List<String> result = new ArrayList<>();
for (Transaction tx : transactions) {
if (tx != null) {
if (tx.getAmount() != null) {
if (tx.getAmount().compareTo(BigDecimal.valueOf(10_000_000)) > 0) {
if (tx.getStatus() != null && tx.getStatus().equals("COMPLETED")) {
result.add(tx.getId());
}
}
}
}
}
return result;
}
public String getCustomerName(Long customerId) {
Customer customer = customerRepository.findById(customerId);
return customer.getName(); // NullPointerException waiting to happen
}
public BigDecimal calculateTotal(List<Transaction> transactions) {
BigDecimal total = BigDecimal.ZERO;
for (Transaction tx : transactions) {
total = total.add(tx.getAmount());
}
return total;
}
}
Hãy thử đếm: bạn thấy mấy vấn đề?
- Nested
ifkhiến logic khó đọc, khó sửa. tx.getStatus().equals("COMPLETED")ngược (nên dùng hằng số hoặc enum ở vế trái).getCustomerNamechắc chắn sẽ ném NPE nếu customer không tồn tại.calculateTotalkhông xử lýtransactionsrỗng hay phần tử null,tx.getAmount()null cũng gây lỗi.
Bài viết này không chỉ sửa đoạn code trên. Nó sẽ giúp bạn hiểu sâu các tính năng Java 8–17, biết khi nào dùng Stream, khi nào tránh, cách dùng Optional đúng bản chất, và tận dụng Record, Sealed Class, Pattern Matching để code vừa an toàn vừa biểu đạt được ý đồ thiết kế.
1. Stream API: không phải collection, mà là pipeline mô tả
1.1 Lazy evaluation – cốt lõi của Stream
Stream không lưu trữ dữ liệu. Nó giống như một recipe: bạn mô tả các bước (filter, map, sorted…), nhưng chưa nấu. Chỉ khi gọi terminal operation (collect, reduce, forEach, findFirst…) thì pipeline mới chạy.
// Chưa có gì xảy ra ở đây
Stream<Transaction> stream = transactions.stream()
.filter(tx -> tx.getAmount().compareTo(TEN_MILLION) > 0)
.map(Transaction::getId);
// Lúc này mới thực sự duyệt nguồn
List<String> ids = stream.collect(Collectors.toList());
Lợi ích lớn nhất: short-circuit. Khi bạn thêm limit(10), pipeline dừng ngay sau khi lấy đủ 10 phần tử, không cần duyệt hết 1 triệu record.
transactions.stream()
.filter(tx -> isHighValue(tx))
.limit(10) // chỉ cần 10 cái đầu tiên thoả mãn
.collect(toList());
1.2 Stateless vs Stateful – ảnh hưởng đến parallel
- Stateless operations (
filter,map,flatMap,peek): mỗi phần tử được xử lý độc lập. An toàn khi dùng parallel. - Stateful operations (
sorted,distinct,limit,skip): cần nhớ hoặc so sánh nhiều phần tử. Khi chạy parallel, chúng phải buffer dữ liệu và có thể gây overhead lớn.
📌 Mẹo phỏng vấn: “Khi nào
parallelStream()chậm hơnstream()?”
Một câu trả lời tốt: Khi dataset nhỏ (< 10K), hoặc operations stateful (sorted, distinct), hoặc task bị IO-bound (gọi DB, API). Trong fintech, parallelStream thường chỉ an toàn cho batch CPU-bound, không dùng cho real-time API.
2. Optional – đừng coi nó như một món đồ trang trí
Optional ra đời để giải quyết vấn đề null reference – “mistake of a billion dollars”. Nhưng nó chỉ có giá trị nếu bạn dùng đúng.
2.1 Mental model: Optional là container buộc caller xử lý
public Optional<String> getCustomerName(Long customerId) {
return customerRepository.findById(customerId)
.map(Customer::getName);
}
Người dùng method này biết ngay rằng kết quả có thể không tồn tại, và họ bắt buộc phải nghĩ cách xử lý:
String name = getCustomerName(id)
.orElseGet(() -> fetchDefaultName());
2.2 Cạm bẫy số một: orElse luôn được thực thi
// ❌ hoặcElse làm DB call dù không cần
Optional<BigDecimal> fee = getCustomFee();
BigDecimal finalFee = fee.orElse(getDefaultFeeFromDB()); // DB call luôn!
// ✅ orElseGet chỉ chạy khi empty
BigDecimal finalFee = fee.orElseGet(() -> getDefaultFeeFromDB());
2.3 Nơi không nên dùng Optional
- Tham số method – gây khó chịu cho caller (phải wrap giá trị).
- Field của entity/JPA – vì serialization không hỗ trợ.
- Trong collection –
List<Optional<T>>là dấu hiệu thiết kế tồi, hãy dùngfilter(Objects::nonNull).
✅ Nguyên tắc vàng: Optional chỉ dùng làm return type cho các method mà “không có kết quả” là một trường hợp bình thường, không phải lỗi.
3. Lambda và Functional Interface – cơ chế bên dưới
Bạn có thể nghĩ lambda là “anonymous class ngắn gọn”. Thực tế, Java compiler dùng invokedynamic để tạo implementation tại runtime, cache lại, không tạo class riêng mỗi lần → giảm memory pressure.
Bốn functional interface bạn phải thuộc lòng:
| Interface | Method | Input | Output |
|---|---|---|---|
Supplier<T> | get() | không | T |
Consumer<T> | accept(T) | T | void |
Function<T,R> | apply(T) | T | R |
Predicate<T> | test(T) | T | boolean |
Ví dụ trong banking:
Supplier<String> txIdGenerator = () -> UUID.randomUUID().toString();
Consumer<Transaction> auditor = tx -> auditLog.record(tx);
Function<String, Long> parser = Long::parseLong;
Predicate<Transaction> isCompleted = tx -> "COMPLETED".equals(tx.getStatus());
4. Record, Sealed Class, Pattern Matching (Java 14–17)
4.1 Record – DTO không cần Lombok
Record là immutable data carrier. Compiler tự sinh constructor, accessors, equals, hashCode, toString.
public record TransferRequest(String idempotencyKey, Long fromAccount,
Long toAccount, BigDecimal amount, String description) {
// Compact constructor: validation
public TransferRequest {
Objects.requireNonNull(idempotencyKey);
if (amount.compareTo(BigDecimal.ZERO) <= 0)
throw new IllegalArgumentException("amount must be positive");
description = description == null ? "" : description.trim();
}
}
Nên dùng Record cho: DTO, Value Object, Event, tuple kết quả.
Không dùng cho: JPA Entity (cần mutable, no-arg constructor, proxy), Builder pattern phức tạp, class cần kế thừa.
4.2 Sealed class + Pattern Matching – xử lý exhaustive
Sealed class kiểm soát hệ thống phân cấp: chỉ những class được phép mới có thể extends/implements.
public sealed interface PaymentResult permits Success, Declined, Error {
record Success(String txId, BigDecimal amount, Instant at) implements PaymentResult {}
record Declined(String reasonCode, String message, boolean retryable) implements PaymentResult {}
record Error(String errorCode, String technicalMessage) implements PaymentResult {}
}
Kết hợp với pattern matching trong switch (Java 17+), compiler đảm bảo bạn xử lý tất cả các trường hợp:
switch (result) {
case Success s -> handleSuccess(s);
case Declined d when d.retryable() -> scheduleRetry(d);
case Declined d -> handleDeclined(d);
case Error e -> alertOps(e);
}
// Không cần default – sealed class giúp compiler biết đã đủ
Trong phỏng vấn, đây là điểm phân biệt senior: biết dùng sealed class để mô hình hoá các trạng thái hữu hạn (payment status, transaction outcome).
5. Refactor production code – từ lỗi đến chuẩn
Quay lại đoạn code đầu bài. Đây là cách viết lại bằng Stream + Optional đúng chuẩn.
@Service
@Slf4j
public class TransactionProcessorRefactored {
private static final BigDecimal HIGH_VALUE_THRESHOLD = BigDecimal.valueOf(10_000_000);
public List<String> getHighValueTxIds(List<Transaction> transactions) {
if (transactions == null || transactions.isEmpty())
return Collections.emptyList();
return transactions.stream()
.filter(Objects::nonNull)
.filter(tx -> tx.getAmount() != null)
.filter(tx -> TransactionStatus.COMPLETED.name().equals(tx.getStatus()))
.filter(tx -> tx.getAmount().compareTo(HIGH_VALUE_THRESHOLD) > 0)
.map(Transaction::getId)
.collect(Collectors.toUnmodifiableList()); // immutable result
}
public Optional<String> getCustomerName(Long customerId) {
return customerRepository.findById(customerId)
.map(Customer::getName)
.filter(name -> !name.isBlank());
}
public BigDecimal calculateTotal(List<Transaction> transactions) {
if (transactions == null) return BigDecimal.ZERO;
return transactions.stream()
.map(Transaction::getAmount)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
Lý do từng thay đổi:
- Dùng
Objects::nonNullvà filter amount null – phòng dữ liệu dirty. - So sánh enum thay vì string literal.
- Trả về
Optionalcho method có thể không tìm thấy. - Dùng
reducethay vì vòng lặp mutate bên ngoài.
6. Stream nâng cao: flatMap, groupingBy, toUnmodifiableMap
6.1 flatMap – làm phẳng nested collections
public List<Transaction> getAllTransactions(List<Customer> customers) {
return customers.stream()
.flatMap(c -> c.getAccounts().stream())
.flatMap(a -> a.getTransactions().stream())
.collect(Collectors.toList());
}
6.2 groupingBy với downstream collector
Báo cáo tổng tiền theo trạng thái giao dịch:
Map<String, BigDecimal> totalByStatus = transactions.stream()
.collect(Collectors.groupingBy(
Transaction::getStatus,
Collectors.mapping(Transaction::getAmount,
Collectors.reducing(BigDecimal.ZERO, BigDecimal::add))
));
6.3 toUnmodifiableMap và xử lý duplicate key
Một cạm bẫy phổ biến: khi có duplicate key, toUnmodifiableMap ném IllegalStateException nếu không cung cấp merge function.
Map<Long, Transaction> idToTx = transactions.stream()
.collect(Collectors.toUnmodifiableMap(
Transaction::getId,
Function.identity(),
(existing, duplicate) -> existing // keep first
));
7. Khi nào KHÔNG dùng Stream?
| Tình huống | Giải pháp | Lý do |
|---|---|---|
Cần break sớm | for loop hoặc findFirst() | Stream chỉ short-circuit ở terminal op |
| Cần sửa index trong khi duyệt | for loop | Stream không có index |
| Lambda ném checked exception | for loop hoặc wrap trong unchecked | Lambda khó xử lý checked exception sạch |
| Dưới 5 phần tử | for loop | Overhead của stream không đáng |
| Debug phức tạp | for loop | Step-through dễ hơn pipeline |
Anti-pattern đáng nhớ nhất: Stream với side effect.
// ❌ Sai – forEach thay đổi external list
List<String> ids = new ArrayList<>();
stream.forEach(tx -> ids.add(tx.getId()));
// ✅ Đúng – collect về collection mới
List<String> ids = stream.map(Transaction::getId).collect(toList());
8. Interview sâu: Câu hỏi phân biệt Junior, Mid, Senior
Tầng 1 (Junior)
Q: Stream khác Collection thế nào?
Stream không lưu dữ liệu, là pipeline lazy, chỉ dùng một lần. Collection lưu dữ liệu, dùng nhiều lần.
Q: Optional để làm gì?
Tránh NPE, buộc caller xử lý trường hợp “không có giá trị” ngay tại compile time.
Tầng 2 (Mid – phân biệt senior tiềm năng)
Q: parallelStream() khi nào chậm hơn stream()?
- Dataset nhỏ (< 10K): overhead phân chia công việc lớn hơn lợi ích.
- Operation stateful (sorted, distinct): cần buffer và đồng bộ.
- IO-bound (DB, API) trong lambda: thread chờ IO, không tăng thông lượng, còn gây starvation cho commonPool.
- Có shared mutable state: phải serialize → mất song song.
Q: Phân biệt orElse và orElseGet?
orElseluôn evaluate tham số (eager), dù Optional có giá trị hay không.orElseGetchỉ evaluate khi Optional empty (lazy). DùngorElseGetkhi default value tốn kém (tạo object, gọi DB).
Tầng 3 (Senior – architecture)
Q: Thiết kế pipeline xử lý 500K transaction từ CSV, memory <512MB, hoàn thành trong 30 phút. Dùng Stream thế nào?
- Đọc file lazy bằng
Files.lines(), không load hết vào memory.- Không dùng
parallelStreamcho DB enrichment – thay vào đó, group theo batch 1000, dùng custom ExecutorService.- Dùng
Collectors.groupingByđể chia batch, mỗi batch gọi DB một lần.- Thêm checkpoint, dead letter queue cho record lỗi, progress logging.
- Nếu 30 phút không đủ, chuyển sang batch job riêng (Spring Batch) – nhưng trong phỏng vấn, đề xuất giải pháp streaming + chunking là đủ.
9. Checklist để tự tin “thành thạo” Java 8–17
- Stream lazy evaluation: giải thích được pipeline không chạy cho đến terminal op, và
limit()giúp short-circuit. - Optional đúng chỗ: biết 3 nơi không dùng (param, field, collection) và 2 nơi nên dùng (return type). Phân biệt
orElsevsorElseGet. - parallelStream trade-offs: nêu rõ khi nào nhanh, khi nào chậm, và tại sao không dùng cho IO-bound.
- Record: viết được record với validation, biết không dùng cho JPA Entity.
- Sealed class + pattern matching: tạo sealed interface, dùng switch pattern matching exhaustive.
- Stream anti-patterns: nhận ra side-effect trong
forEach, tạo stream trong loop, dùngpeekcho logic nghiệp vụ.
Kết luận
Java từ 8 đến 17 không chỉ thêm cú pháp mới. Nó thay đổi cách chúng ta tư duy về xử lý dữ liệu (Stream), về sự vắng mặt (Optional), về bất biến và cấu trúc dữ liệu (Record), và về tính toàn vẹn của domain (Sealed classes).
Một senior Java không chỉ dùng được chúng, mà còn hiểu khi nào nên và không nên, và giải thích được trade-off trong bối cảnh production (banking, fintech, batch, real-time).
Hãy bắt đầu từ việc refactor đoạn code ở đầu bài. Bạn sẽ thấy code không chỉ ngắn hơn – nó an toàn hơn, dễ đọc hơn, và ít ngạc nhiên hơn.