Tất tần tật về Stream API trong Java 8

Stream API giúp viết code ngắn gọn, dễ đọc và đặc biệt hữu ích trong việc xử lý các tác vụ phức tạp nhờ vào tính năng Lazy Evaluation và hỗ trợ Parallel Stream.

Upload image

1. Giới thiệu Stream API

Được giới thiệu lần đầu tiên trong Java 8, sự xuất hiện của Stream API là một ưu thế cho Java, cho phép lập trình viên có thể xử lí dữ liệu dạng collection theo hướng lập trình hàm (functional programming). Stream API giúp các thao tác xử lí phức tạp, biến đổi dữ liệu đầu vào trở nên dễ hiểu hơn mà không phải dùng các vòng lặp và các câu điều kiện dài dòng.

2. Các thành phần chính của Stream API

Upload image

Các thành phần chính và quy trình xử lý của Stream API

Stream API bao gồm các thành phần sau:

  • Source: Đầu vào của một tập dữ liệu sẽ được đưa vào stream để xử lí.
    Ví dụ: HashMap,List,…
  • Intermediate Operation (Toán tử trung gian): Các phép toán tử trung gian có nhiệm vụ biến đổi hoặc xử lý các phần tử (element) trong stream. Khi xâu chuỗi các toán tử trung gian lại với nhau, chúng tạo thành một đường ống xử lý (pipeline).
    Ví dụ: filter(), map(), sorted(), distinct(),…
  • Terminal Operations (Toán tử kết thúc): Toán tử kết thúc được đặt ở cuối pipeline, stream sẽ được tiêu thụ và trả về kết quả cuối cùng đã được xử lý.
    Ví dụ: forEach(), collect(), reduce(),…

Dưới đây là đoạn code mô tả một luồng xử lí qua các toán tử để loại bỏ các phần tử trùng lặp, lọc các phần tử lớn hơn 2 và duyệt xuất dữ liệu:

public class StreamComponent {
    public static void main(String[] args) throws InterruptedException {
        // Dữ liệu đầu vào (Source) là [1, 1, 2, 3, 4]
        Stream.of(1, 1, 2, 3, 4)
            // Toán tử trung gian: loại bỏ các phần tử trùng lặp
            .distinct()
            // Toán tử trung gian: lọc các phần tử lớn hơn 2
            .filter(element -> element > 2)
            // Toán tử kết thúc: duyệt qua và xử lý các phần tử còn lại (3, 4)
            .forEach(element -> {
                System.out.println(element + " is matched");
            });
    }
}

So với cách sử dụng vòng lặp truyền thống, cách viết trên vừa ngắn gọn, vừa dễ hiểu hơn. Điều này giúp giảm thiểu sự dài dòng và nguy cơ lỗi trong quá trình code.

3. Hai tính chất quan trọng của Stream API

Đặc biệt hơn, Stream API trong Java có hai tính chất rất quan trọng giúp tăng hiệu suất đối với các tác vụ phức tạp hoặc xử lý khối lượng dữ liệu lớn đó là Lazy EvaluationParallelism.

3.1. Lazy Evaluation

3.1.1. Ưu điểm của Lazy Evaluation so với Normal Evaluation

Trong một số ngôn ngữ lập trình như JavaScript, khi xử lý dữ liệu qua một pipeline, toàn bộ phần tử trong stream phải được duyệt qua ở từng toán tử (operation) trước khi chuyển sang bước tiếp theo. Điều này có nghĩa là, ngay cả những phần tử đã được xử lý xong cũng không thể tiếp tục "chảy" mà phải chờ cho đến khi tất cả các phần tử trong stream đã hoàn thành ở operation hiện tại.

Trái lại, với tính chất Lazy Evaluation trong Java Stream API, dữ liệu chỉ được xử lý khi thật sự cần thiết, toàn bộ các phần tử không nhất thiết phải xử lí hết mà chỉ lấy đủ số lượng cần thiết. Các phần tử đã qua xử lý có thể tiếp tục "chảy" đi tiếp.

Upload image

So sánh Normal Evaluation và Lazy Evaluation

Nhìn vào quá trình của cách xử lý dữ liệu thông thường (Normal Evaluation) so với Lazy Evaluation, ta có thể thấy:

  • Cách thông thường: Tất cả các phần tử trong stream phải hoàn thành việc xử lý ở một operation trước khi chuyển tiếp sang operation tiếp theo.
  • Lazy Evaluation: Các phần tử trong stream "chảy" qua từng toán tử trong pipeline theo thứ tự từng phần tử một.

Dưới đây là kết quả sẽ hiển thị cho hai loại xử lý:

  • Cách thông thường:
Filter processed element 1
Filter processed element 2
Filter processed element 3
Filter processed element 4


Map processed element 2
Map processed element 4


Terminal OP print element 6
Terminal OP print element 12
  • Lazy Evaluation:
Element: 1
Filter processed element 1
-------------------------

Element: 2
Filter processed element 2
Map processed element 2
Terminal OP print element 2
-------------------------

Element: 3
Filter processed element 3
-------------------------

Element: 4
Filter processed element 4
Map processed element 4
Terminal OP print element 4

3.1.2. Short-Circuiting (ngắn mạch)

Ngoài ra, còn một ưu điểm đáng chú ý của Lazy Evaluationkhả năng hỗ trợ hành vi Short-Circuiting. Short-circuiting cho phép stream kết thúc sớm khi đã xử lý đủ các phần tử cần thiết. Điều này xuất hiện trong các toán tử như findFirst(), findAny(), anyMatch().

Upload image

Hình trên là luồng xử lí của một stream có toán tử short-circuiting là findFirst() trong pipline. Điều kiện để dừng stream lúc này là tìm đc một phần tử lớn hơn hoặc bằng 2 và điều kiện thỏa ở phần tử thứ 2 nên phần còn lại (3,4) bị bỏ qua.

Ta có thể thấy tính chất Lazy Evaluation giúp Stream API tối ưu hóa hiệu suất nhờ giảm thiểu các thao tác không cần thiếthạn chế việc duyệt toàn bộ dữ liệu.

3.1.3. Stateful và Stateless Operations (Toán tử Stateful và Stateless)

Bên cạnh đó, một khái niệm quan trọng khi tìm hiểu về tính chất “lazy” của Stream API là sự khác biệt giữa stateful và stateless operations.

  • Stateful operation:

    • Các toán tử stateful cần lưu trữ ngữ cảnh (context) hoặc trạng thái (state) dữ liệu trong quá trình duyệt qua stream để thực hiện đánh giá, so sánh hoặc xử lý dựa trên thông tin được giữ lại. Các toán tử này gồm: sorted(), skip(), limit()distinct().
    • Tuy nhiên, các toán tử stateful có khả năng làm giảm hiệu suất vì chúng có thể yêu cầu toàn bộ stream phải được duyệt qua trước khi chuyển sang bước tiếp theo trong pipeline (như sorted() chẳng hạn).
  • Stateless operation:

    • Trái ngược với toán tử stateful, các toán tử stateless không lưu giữ bất kỳ trạng thái nào từ các phần tử đã xử lý. Ví dụ điển hình bao gồm: filter(), map(), flatMap(),…
    • Toán tử stateless không gây ra bất kỳ ảnh hưởng tiêu cực nào đến hiệu suất và thường được ưu tiên khi xây dựng pipeline

Dưới đây là hình mô tả luồng xử lí của pipeline khi có toán tử stateful là sorted():

Upload image

Luồng xử lí của pipeline khi có toán tử stateful là sorted()

Các phần tử đã được xử lí xong (4,3) đang phải chờ cho các phần tử còn lại (2,1) hoàn tất xử lí thì toán tử sort() mới thật sự thực thi. Dưới đây là kết quả khi chạy thử luồng phía trên:

Element: 4
First intermediate OP processed element 4

Element: 3
First intermediate OP processed element 3

Element: 2
First intermediate OP processed element 2

Element: 1
First intermediate OP processed element 1

Terminal OP print element 1
Terminal OP print element 2
Terminal OP print element 3
Terminal OP print element 4

Hiểu rõ bản chất của các toán tử trong Stream API có thể giúp lập trình viên tránh được các nguy cơ tiềm ẩn làm suy giảm hiệu suất.

3.2. Parallelism

3.2.1. Parallel Stream (Luồng xử lý song song)

Java Stream API hỗ trợ việc tạo parallel stream, trong đó các phần tử của stream được chia thành nhiều đoạn (chunk)xử lý đồng thời bởi nhiều luồng (thread). Để cho phép pipeline xử lý song song, lập trình viên chỉ cần gọi toán tử parallel() là được.

Việc cho phép nhiều phần tử được xử lí đồng thời có thể sẽ giúp tăng đáng kể hiệu xuất so với chạy tuần tự và tận dụng tối đa nguồn tài nguyên còn đang trống.

Upload image

Luồng xử lý parallel stream

Hình trên mô tả cách pipeline được xử lý song song. Trong ví dụ, toán tử stateless được mình chủ động dùng để tránh các "tác dụng phụ" như đã đề cập ở phần trước. Khi phải sử dụng toán tử stateful với parallel stream, chúng ta cần lưu ý các vấn đề sau:

  • Thread safety và race condition: Khi nhiều thread cùng truy cập và thay đổi trạng thái chung (shared state), rất dễ xảy ra race condition, dẫn đến dữ liệu được lưu không chính xác hoặc không thể xác định (non-deterministic).
  • Giảm hiệu suất: Toán tử stateful có thể làm giảm hiệu suất do phải thực hiện thêm các bước tính toán để duy trì vị trí của các phần tử được xử lý song song. Điều này đặc biệt liên quan đến tính ordered (sắp xếp theo thứ tự) và unordered (sắp xếp ngẫu nhiên) của stream.

3.2.2. Unordered & Ordered stream

Unordered & Ordered stream cũng là phần đặc biệt quan trọng trong quá trình ứng dụng parallel stream nên mình sẽ giải thích kỹ như sau:

  • Ordered stream: Trong ordered stream, thứ tự của các phần tử cần được bảo toàn trong kết quả trả về của mỗi toán tử. Việc này sẽ làm tăng chi phí tính toán trong parallel stream.
  • Unordered stream: Với unordered stream, các phần tử không cần giữ nguyên thứ tự trong kết quả trả về. Do đó, không phát sinh thêm chi phí tính toán.

Nếu thứ tự của các phần tử không quan trọng, hãy sử dụng toán tử unordered() để có thể cải thiện hiệu suất, và đặc biệt tốt hơn khi kết hợp với toán tử short-circuiting. Bên dưới là đoạn code đơn giản để so sánh thời gian xử lí unordered và ordered stream với toán tử short-circuiting findAny() với 1 triệu phần tử.

public class UnorderedAndOrderStream {

    // Hàm dùng để đo thời gian mà xử lí của stream sẽ chạy
    public static <T, R> long performanceBenchmark (Function<T,R> func, T data) {
        R result = null;
        long startTime = System.currentTimeMillis();
        result = func.apply(data);
        long endTime = System.currentTimeMillis();
        System.out.println("Result: " + result);
        return endTime - startTime;
    }

    // Hàm xử lí unordered stream
    public static Optional<Object> processUnorderedStream(List<Integer> data) {
        System.out.println("Process unordered stream:");
        return Stream.of(data.toArray())
                .unordered()
                .parallel()
                .distinct().findAny();
    }

    // Hàm xử lí ordered stream
    public static Optional<Object> processOrderedStream(List<Integer> data) {
        System.out.println("Process ordered stream:");
        // List mặc định sẽ có ordered nên stream này là ordered stream nếu để mặc định
        return Stream.of(data.toArray())
                .parallel()
                .distinct().findAny();
    }

    public static void main(String[] args) {
        // Số lượng dữ liệu để thử là 1 triệu phần tử
        List<Integer> data = IntStream.rangeClosed(1, 1000000).boxed().collect(Collectors.toList());
        // Kết quả thời gian chạy của ordered stream
        System.out.println("With ordered: " + performanceBenchmark(list -> processOrderedStream(list), data) + "ms \n");
        // Kết quả thời gian chạy của unordered stream
        System.out.println("With ordered: " + performanceBenchmark(list -> processUnorderedStream(list), data) + "ms");
    }
}

Kết quả của đoạn code cho ta thấy thời gian xử lý chênh lệch là rất lớn:

Process ordered stream:
Result: Optional[1]
With ordered: 360ms

Process unordered stream:
Result: Optional[703126]
With ordered: 5ms

3.2.3. Lưu ý khi sử dụng parallel stream

Ngoài ra, việc sử dụng parallel stream không phải lúc nào cũng đem lại lợi ích. Với tập dữ liệu nhỏ hoặc các phép toán đơn giản, chi phí chuẩn bị và thiết lập xử lý song song có thể lớn hơn hoặc xấp xỉ toàn bộ chi phí chạy tuần tự.

Vì vậy, ta cần xem xét 3 yếu tố cốt lõi sau khi quyết định sử dụng parallel stream:

  • Kích thước dữ liệu: Stream cần đủ lớn để đảm bảo lợi ích từ xử lý song song.
  • Độ phức tạp của phép toán: Khi các thao tác trên dữ liệu phức tạp, kích thước dữ liệu có thể ít quan trọng hơn.
  • Khả năng phân tách: Stream cần dễ dàng được chia nhỏ, ArrayList hay HashMap dễ phân tách hơn so với LinkedList hoặc các nguồn dữ liệu từ I/O (đọc/ghi file).

4. Lợi ích và nhược điểm của Stream API

Sau khi hiểu các khái niệm và tính năng của Stream API, dưới đây là tổng hợp ưu và nhược điểm của công cụ này:

  • Lợi ích:

    • Cải thiện khả năng đọc và giảm lặp code (boilerplate): Stream được thiết kế theo phong cách lập trình hàm với biểu thức lambda, giúp các phép toán ngắn gọn và dễ hiểu.
    • Tăng hiệu suất: Lazy evaluation và parallelism có thể giúp tối ưu hóa hiệu suất, tận dụng tối đa tài nguyên hệ thống.
  • Nhược điểm:

    • Không hiệu quả với tập dữ liệu nhỏ: Với tập dữ liệu nhỏ hoặc luồng xử lý đơn giản, việc sử dụng stream không đem lại nhiều lợi ích.
    • Giới hạn trong các toán tử có sẵn: Mặc dù Stream API cung cấp nhiều toán tử, nhưng với các trường hợp yêu cầu logic tùy chỉnh, việc sử dụng stream sẽ không khả thi. Tuy nhiên, Java 22 (ra mắt tháng 3/2024) đã giới thiệu tính năng thử nghiệm Stream Gatherers, cho phép lập trình viên tạo toán tử riêng.

Mặc dù vẫn còn tồn đọng một vài hạn chế nhưng với bề dày phát triển hơn một thập kỷ, Stream API vẫn tiếp tục được nâng cấp thêm các tính năng mới và được cộng đồng tin dùng.

Atekco - Home for Authentic Technical Consultants