Giá trị Class Vẫn Cần Sự 'Cảm Thông' Từ Trình Biên Dịch: Phân Tích Sâu Về JEP 401 Trong JDK 28
Bài viết phân tích sâu về JEP 401 (Value Classes) trong JDK 28, một bước tiến lớn của dự án Valhalla. Dù mang lại nhiều lợi ích về hiệu năng, bài viết chỉ ra rằng các giá trị class không phải lúc nào cũng tự động nhanh hơn; chúng vẫn cần sự hỗ trợ và tối ưu đúng cách từ trình biên dịch JIT (C2) cũng như cách khai báo code hợp lý. Tác giả đưa ra các ví dụ cụ thể về cách JVM xử lý bộ nhớ, inline và các tình huống có thể gây ra suy giảm hiệu năng nếu không được sử dụng đúng.
Giá trị Class Vẫn Cần Sự 'Cảm Thông' Từ Trình Biên Dịch: Phân Tích Sâu Về JEP 401 Trong JDK 28
Bài viết này thảo luận về các tính năng xem trước (preview) trong JDK 28. JEP 401, một cột mốc quan trọng của dự án Valhalla, đã được tích hợp như một tính năng xem trước trong JDK 28. Điều này rất thú vị, vì các value class (lớp giá trị) không chỉ tăng khả năng truyền đạt ngữ nghĩa của chương trình cho người khác mà còn mở ra nhiều cơ hội tối ưu hóa cho JVM.
Tuy nhiên, tôi đã thấy nhiều người trên mạng có cách tiếp cận kiểu "Hãy biến tất cả các class thành value class!". Tôi lo lắng rằng có một niềm tin rằng các class thông thường tạo ra một "mức sàn" về hiệu năng, và các value class sẽ cố gắng nâng bạn lên trên mức sàn đó, nhưng sẽ không bao giờ đưa bạn xuống dưới nó. Thật không may, điều đó không đúng. Một chương trình có ý tốt có thể đặt JVM vào tình huống mà biểu diễn "phẳng" (flattened) nhanh hơn cho một số phương thức, nhưng biểu diễn tham chiếu (reference) lại nhanh hơn cho các phương thức khác. Khi các phương thức này tương tác với nhau, JVM buộc phải chuyển đổi giữa hai biểu diễn này. Tôi muốn các value class không chỉ là một phép màu, vì vậy hôm nay tôi sẽ cho các bạn thấy JVM hiện có khả năng gì và đâu là giới hạn của nó. Tôi hy vọng với bài viết này, bạn sẽ có bối cảnh để suy luận về code mà bạn (hoặc tác nhân AI của bạn) viết ra.
Lợi Ích Chính: Từ Bỏ Danh Tính (Identity) để Tối Ưu
Lợi thế tối ưu hóa chính của value class là chúng ta từ bỏ danh tính (identity). Điều này cho JVM quyền tự do lựa chọn một biểu diễn phù hợp cho một tình huống cụ thể. Không có yêu cầu về danh tính, runtime có thể dễ dàng làm phẳng (flatten) các giá trị (tránh việc theo dõi con trỏ), hoặc vô hướng hóa (scalarize) chúng bằng cách biểu diễn các thành phần của chúng một cách độc lập trong thanh ghi hoặc trên ngăn xếp. Đối với bản thân đối tượng value, việc phân tích thoát (escape analysis) trở nên tầm thường: không có danh tính nào mà sự thoát của nó phải được chứng minh là không thể quan sát được.
Chúng ta sẽ xem xét ba ví dụ: một value cuối cùng (final) lớn được lưu trữ phẳng, một phép biến đổi value trực tiếp được biên dịch mà không cần cấp phát bộ nhớ, và một lệnh gọi ảo generic yêu cầu vật chất hóa (materialization).
Tính Bất Biến Cho Phép Làm Phẳng
JEP 539, Strict Field Initialization in the JVM, cho phép JVM dựa vào việc một trường final đã được khởi tạo trước khi đối tượng chứa nó trở nên có thể quan sát được. Bởi vì một trường như vậy không thể được cập nhật sau đó, JVM có thể sử dụng một bố cục phẳng không nguyên tử mà không có rủi ro bị "rách" (torn) trong quá trình gán. Tuy nhiên, các trường có thể thay đổi (mutable) phải đảm bảo việc gán không bị rách. Nếu một trường mutable chứa một giá trị quá lớn cho một bản cập nhật phẳng nguyên tử, JVM phải sử dụng bố cục tham chiếu. Sự đảm bảo khởi tạo nghiêm ngặt này mở ra nhiều khả năng tối ưu hóa.
Hãy xem xét ví dụ nhỏ này:
value record FourLongs(long a, long b, long c, long d) {}
record Envelope(FourLongs payload) {}
FourLongs có 32 byte dữ liệu, quá lớn so với một bản cập nhật phẳng nguyên tử trong JVM hiện tại. Nhưng Envelope.payload là một thành phần của record, do đó nó là một trường final được khởi tạo nghiêm ngặt: sau khi được khởi tạo, nó không bao giờ được cập nhật. Do đó, JVM có thể tự do lưu trữ payload bằng cách sử dụng bố cục phẳng không nguyên tử. Trong bản dựng Valhalla hiện tại, chẩn đoán bố cục trường báo cáo như sau khi sử dụng PrintFieldLayout:
Layout of class FourLongs
@8 REGULAR 8/8 "a" J
@16 REGULAR 8/8 "b" J
@24 REGULAR 8/8 "c" J
@32 REGULAR 8/8 "d" J
@40 NULL_MARKER 1/1
NULLABLE_NON_ATOMIC_FLAT layout: 33/8
Layout of class Envelope
@8 FLAT 33/8 "payload" LFourLongs;
FourLongs NULLABLE_NON_ATOMIC_FLAT
Ở đây chúng ta có thể thấy rằng một FourLongs bao gồm bốn thành phần của nó và một bộ đánh dấu null 1 byte, và nó hỗ trợ một bố cục phẳng nullable, không nguyên tử. Runtime sử dụng thực tế này trong record Envelope và cho phép FourLongs được làm phẳng. Điểm mấu chốt là Envelope cũng là bất biến; bố cục sẽ phải thay đổi nếu chúng ta thay thế nó bằng một class có thể thay đổi:
class MutableEnvelope {
public FourLongs payload;
public MutableEnvelope(FourLongs payload) { this.payload = payload; }
}
Layout of class MutableEnvelope
@8 REGULAR 4/4 "payload" LFourLongs;
Tại sao vậy? Hãy xem xét một cuộc đua dữ liệu giữa hai luồng:
void thread1(MutableEnvelope a) {
a.payload = new FourLongs(1, 0, 0, 0);
}
void thread2(MutableEnvelope a) {
a.payload = new FourLongs(0, 1, 0, 0);
}
void main() throws InterruptedException {
MutableEnvelope a = new MutableEnvelope(new FourLongs(0, 0, 0, 0));
var t1 = new Thread(() -> thread1(a));
var t2 = new Thread(() -> thread2(a));
t1.start();
t2.start();
t1.join();
t2.join();
IO.println(a.payload);
}
Việc ghi một trường phẳng yêu cầu ghi các thành phần riêng lẻ của nó. Nếu thread1 và thread2 ghi các thành phần đó một cách độc lập, một luồng khác có thể quan sát thấy một giá trị bị "rách" như (1, 1, 0, 0), được ghép từ các phần của hai phép gán khác nhau. Mô hình bộ nhớ Java (Java Memory Model) cấm sự "rách" như vậy. Đảm bảo việc gán không bị rách (tear-free) cho một giá trị phẳng lớn như thế này là rất tốn kém, vì vậy JVM hiện tại sử dụng bố cục tham chiếu. Mỗi luồng xây dựng một FourLongs hoàn chỉnh và sau đó thực hiện một thao tác lưu trữ tham chiếu nguyên tử.
Loại Bỏ Danh Tính Loại Bỏ Việc Cấp Phát
Nếu chúng ta có một hàm nhỏ thay đổi một thành phần trong một vòng lặp, như thế này:
static FourLongs bumpA(FourLongs value) {
return new FourLongs(value.a() + 1, value.b(), value.c(), value.d());
}
static long run(long iterations) {
FourLongs value = new FourLongs(0, 2, 3, 4);
for (long i = 0; i < iterations; i++) {
value = bumpA(value);
}
return value.a();
}
Khi FourLongs là một value class, JVM có thể chứng minh rằng phép biến đổi chỉ là một phép tính toán học trên các thành phần và hoàn toàn loại bỏ việc cấp phát bộ nhớ. Nó thậm chí có thể loại bỏ cả vòng lặp nếu iterations >= 0. Kết quả là, toàn bộ phương thức run sau khi được biên dịch chỉ còn lại một vài lệnh, không có một lần cấp phát heap nào.
Điều này xảy ra bởi vì JVM có thể "vô hướng hóa" (scalarize) FourLongs thành bốn biến long độc lập (a, b, c, d). Việc gọi bumpA không còn là một lệnh gọi hàm với tham chiếu đối tượng; nó chỉ đơn giản là một phép cộng trên một trong các biến này. Không có heap allocation nào được tạo ra, và vòng lặp có thể được tính toán trực tiếp.
Nếu chúng ta giữ bumpA không thay đổi nhưng làm cho FourLongs trở thành một identity record, C2 phải chứng minh rằng danh tính của nó không ảnh hưởng đến tính toán. Trình biên dịch thường có thể làm điều này, nhưng bây giờ chúng ta phụ thuộc vào việc trình biên dịch chứng minh điều đó trong từng trường hợp. Khi tôi thử điều này (bằng cách xóa value khỏi khai báo FourLongs), C2 đã không thể thực hiện tối ưu hóa này. Trong trường hợp identity record, vòng lặp run vẫn phải cấp phát một đối tượng FourLongs mới ở mỗi lần lặp. Điều này cho thấy việc cung cấp các đảm bảo ngữ nghĩa mạnh mẽ hơn cho trình biên dịch đôi khi có thể mang lại lợi ích rất lớn.
Sự Xóa Bỏ Kiểu (Type Erasure) Mang Lại Việc Cấp Phát
Bây giờ chúng ta sẽ xem xét một ví dụ phức tạp hơn. Ví dụ này được lấy từ một email mà chúng tôi nhận được từ một người dùng trên danh sách gửi thư valhalla-dev. Anh ấy đã chuyển một thư viện phân tích cú pháp từ Elm sang Java và nhận thấy sự chậm lại sau khi chuyển đổi tất cả các record của mình thành value record. Một sự suy giảm hiệu năng rõ ràng không phải là kết quả chúng tôi mong muốn, nhưng một kết quả bất ngờ như thế này lại rất hấp dẫn: value class cho JVM nhiều thông tin ngữ nghĩa hơn và nhiều quyền tự do hơn, vậy làm sao việc sử dụng chúng lại có thể làm chương trình chậm hơn?
Hãy xem xét code ví dụ:
value record LargeValue(long a, long b, long c, long d) {}
value record Carrier(LargeValue v, boolean b) {}
interface Fun {
R apply(F value);
}
interface Frobber extends Fun {}
final class FrobIt implements Frobber {
public Carrier apply(LargeValue value) {
return new Carrier(value, true);
}
}
final class GrobIt implements Frobber { /* triển khai được bỏ qua có chủ đích */ }
final class DrobIt implements Frobber { /* triển khai được bỏ qua có chủ đích */ }
Hãy nhìn qua lăng kính của JVM để hiểu chuyện gì đang xảy ra. Java triển khai generics thông qua sự xóa bỏ kiểu (type erasure), thay thế các tham số kiểu này bằng Object. Điều đó có nghĩa là Frobber về mặt hiệu quả kế thừa phương thức này theo cách JVM nhìn thấy:
interface Frobber extends Fun {
Object apply(Object value);
}
Chữ ký có kiểu Carrier apply(LargeValue) không xuất hiện trong mô tả phương thức JVM được kế thừa và do đó phải được khôi phục thông qua phân tích động. Để phù hợp với sự khác biệt về kiểu này, javac tạo ra các phương thức cầu nối (bridge methods) trong tệp class. Mỗi triển khai sẽ có một phương thức tương đương với:
// Được tạo bởi javac
public Object apply(Object value) {
return apply((LargeValue) value);
}
Cầu nối chấp nhận đối số đã bị xóa, ép kiểu nó thành kiểu mong đợi, và gọi phương thức chúng ta thực sự viết. Bây giờ hãy xem xét phương thức từ bản sao chép (reproducer) ban đầu:
static Carrier reproduce(LargeValue value, Frobber a, Frobber b) {
Carrier c = a.apply(value);
return b.apply(c.v());
}
Có hai điểm gọi interface riêng biệt ở đây. Nếu mỗi điểm gọi chỉ quan sát một triển khai, C2 có thể loại bỏ tính đa hình (devirtualize) nó. Trong báo cáo chúng tôi nhận được, các điểm gọi này là megamorphic (nhiều biến thể). Trong ví dụ của chúng ta, điều đó có nghĩa là FrobIt, GrobIt và DrobIt được gọi lẫn lộn. Với ba triển khai nóng tại mỗi điểm gọi, C2 để lại các lệnh gọi được phân phối động:
invokeinterface Frobber.apply:(Object)Object
Mô tả phương thức được kế thừa định nghĩa một ABI (Application Binary Interface) yêu cầu người gọi phải truyền tham chiếu đối tượng và các triển khai trả về tham chiếu đối tượng. Người gọi hiện có một LargeValue đã được vô hướng hóa, nhưng Object apply(Object) không thể chấp nhận bốn thành phần vô hướng riêng biệt. Nó yêu cầu một tham chiếu thực sự. Người gọi do đó phải vật chất hóa (materialize) giá trị trước khi thực hiện lệnh gọi. Cầu nối được chọn động sau đó phải dịch ngược lại theo hướng khác: nó ép kiểu tham chiếu đến LargeValue, trích xuất các thành phần của nó và gọi triển khai có kiểu bằng cách sử dụng quy ước gọi vô hướng hóa. Kết quả trả về lại phải được vật chất hóa. Toàn bộ quá trình này rất tốn kém. Tại mỗi lần gọi, người gọi phải biến một giá trị vô hướng hóa thành một tham chiếu đối tượng, nghĩa là vật chất hóa giá trị trên heap.
Giải Pháp Đơn Giản
May mắn thay, cách khắc phục rất đơn giản! Chúng ta tránh điều này bằng cách khai báo lại một cách rõ ràng phương thức có kiểu trong Frobber:
interface Frobber extends Fun {
@Override
Carrier apply(LargeValue value);
}
Chú thích @Override ghi lại những gì chúng ta đang làm, nhưng phần quan trọng là khai báo phương thức rõ ràng. Các lệnh gọi mà kiểu tĩnh của người nhận là Frobber giờ đây sử dụng trực tiếp mô tả có kiểu:
invokeinterface Frobber.apply:(LargeValue)Carrier
Lệnh gọi vẫn là megamorphic. C2 vẫn không biết liệu nó sẽ phân phối tới FrobIt, GrobIt hay DrobIt. Nhưng nó không còn cần kiến thức đó để chọn đúng quy ước gọi. Mọi mục tiêu có thể chấp nhận một LargeValue và trả về một Carrier, vì vậy các giá trị có thể vượt qua ranh giới lệnh gọi động ở dạng vô hướng hóa. Trong bản sao chép (reproducer), phiên bản megamorphic bị xóa kiểu đã cấp phát 192 byte cho mỗi lần gọi reproduce. Việc khai báo lại một cách rõ ràng phương thức có kiểu đã giảm con số đó xuống còn 0.
Kết Luận
Việc khai báo một value class trước hết và quan trọng nhất là một quyết định ngữ nghĩa. Nó cho các lập trình viên đồng nghiệp của chúng ta biết rằng các thể hiện của nó được định nghĩa hoàn toàn bởi trạng thái của chúng và không cần danh tính. Mô hình rõ ràng hơn đó tự nó đã có giá trị! Quyền tự do bổ sung của JVM để tối ưu hóa cách biểu diễn các giá trị đó là một phần thưởng đáng hoan nghênh.
C2 có thể làm những điều tuyệt vời với quyền tự do đó, nhưng nó không phải lúc nào cũng có thể khôi phục thông tin bị ẩn sau các ranh giới trừu tượng hóa (abstraction boundaries). Việc lập hồ sơ (profiling) và kiểm tra code được tạo ra vẫn là những cách tốt nhất để hiểu chuyện gì đang xảy ra. Để có kết quả tốt nhất, chúng ta vẫn có thể cần phải có một chút "cảm thông" cho trình biên dịch.
Phụ lục: In mã assembly C2
Nếu bạn muốn kiểm tra lại công việc của tôi, bạn có thể lấy các đoạn mã này và tự kiểm tra assembly. Biên dịch với cờ preview, đảm bảo phương thức mục tiêu được gọi đủ thường xuyên để trở nên "nóng", và sau đó yêu cầu VM biên dịch và in nó:
javac --enable-preview --release 28 Example.java
java --enable-preview -Xbatch -XX:-TieredCompilation \
-XX:+UnlockDiagnosticVMOptions \
-XX:CompileCommand=compileonly,Example::method \
-XX:CompileCommand=print,Example::method \
-XX:+PrintAssembly Example
-Xbatch làm cho việc biên dịch đồng bộ, và compileonly giữ cho đầu ra tập trung. Lưu ý rằng compileonly hạn chế các phương thức có thể được biên dịch; nó không kích hoạt việc biên dịch. Example::method vẫn phải được gọi đủ số lần để đạt đến ngưỡng biên dịch. Việc in assembly yêu cầu một bản dựng JVM có bộ phân tách (disassembler). Bỏ qua --enable-preview khi biên dịch và chạy so sánh với record thông thường.