Java được định kiểu tĩnh, điều này ảnh hưởng đến việc thiết kế các lệnh Bytecode sao cho một lệnh tự hoạt động trên các giá trị với kiểu cụ thể. Ví dụ có một vài câu lệnh add
dùng để thêm vào 2 số: iadd
, ladd
, fadd
, dadd
. Chúng yêu cầu toán hạng của kiểu, tương ứng, int, long, float, và double. Đa số Bytecode có đặc tính này có các hình thức khác nhau của cùng chức năng dựa trên toán hạng.
Các kiểu dữ liệu định nghĩa bởi JVM là:
- Các kiểu dữ liệu cơ bản:
- Các kiểu số:
byte
(số bù 2 8 bit),short
(số bù 2 16 bit),int
(số bù 2 32-bit),long
(số bù 2 64-bit),char
(16-bit unsigned Unicode),float
(32-bit IEEE 754 single precision FP),double
(64-bit IEEE 754 double precision FP) - Kiểu
boolean
returnAddress
: Con trỏ để chỉ dẫn
2. Các kiểu tham chiếu:
- Các kiểu Class
- Các kiểu Array
- Các kiểu Interface
Kiểu boolean
được hỗ trợ hạn chế trên Bytecode. Không có chỉ dẫn nào thi hành trực tiếp trên giá trị boolean
. Giá trị boolean thay vào đó được chuyển đổi sang int bởi trình biên dịch và tương ứng với chỉ dẫn int
được sử dụng.
Các lập trình viên Java nên biết các kiểu ở bên trên, ngoại trừ returnAddress
, không có kiểu tương đương trong ngôn ngữ lập trình.
Kiến trúc dựa trên ngăn xếp
Sự đơn giản của tập lệnh Bytecode phần lớn là do Sun có thiết kế kiến trúc VM dựa trên ngăn xếp, trái ngược với tập lệnh dựa trên thanh ghi. Có những thành phần bộ nhớ khác nhau được sử dụng bởi tiến trình JVM, nhưng chỉ có ngăn xếp JVM cần được kiểm tra chi tiết để về cơ bản có thể làm theo chỉ dẫn Bytecode.
Thanh ghi PC: Cho mỗi Thread chạy trong một chương trình Java, thanh nghi PC sẽ lưu trữ địa chỉ của lệnh hiện tại.
Ngăn xếp JVM: cho mỗi Thread, ngăn xếp được cấp phát nơi lưu trữ các biến local, các tham số phương thức, và các giá trị trả về. Sau đây là hình minh họa về ngăn xếp cho 3 Thread
Heap: Bộ nhớ được chia sẽ bở tất cả các Thread và lưu các Object (các class và mảng). Giải phóng các Object được quản lý bởi Garbage Collector.
Vùng phương thức: Đối với từng class được tải về, nó lưu trữ code của phương thức và một bảng các biểu tượng (ví dụ tham chiếu đến các trường hoặc các phương thức) và các hằng số được biết như là một vùng chứa hằng số (constant Pool).
Một ngăn xếp JVM bao gồm các Frame, mỗi lần được đẩy lên ngăn xếp khi một phương thức được gọi và được đẩy ra khỏi ngăn xếp khi phương thức hoàn thành (bằng cách trả về bình thường hoặc đưa ra một lỗi). Mỗi Frame bao gồm:
- Một mảng các biến local, được đánh index từ 0 đến độ dài của mảng trừ đi 1. Độ dài được tính bởi trình biên dịch. Biến local có thể nắm giữ giá trị với kiểu bất kỳ, ngoại trừ các giá trị
long
vàdouble
, mà nắm giữ 2 biến local. - Một ngăn xếp toán hạng thường sử dụng lưu trữ các giá trị trung gian sẽ đóng vai trò như toán hạng cho các lệnh, hoặc để đẩy các tham số đến các viện dẫn phương thức.
Tìm hiểu Bytecode
Với một quan niệm về các phần bên trong JVM, chúng ta có thể nhìn thấy một vài ví dụ Bytecode cơ bản được tạo từ code mẫu. Mỗi phương thức trong file class Java có một đoạn code bao gồm các câu lệnh nối tiếp, mỗi cái có một định dạng như sau:
opcode (1 byte) operand1 (optional) operand2 (optional) ...
Đây là một câu lệnh bao gồm 1 byte opcode
và không hoặc nhiều toán hạng có chứa dữ liệu để chạy.
Bên trong Frame ngăn xếp của phương thức đang chạy, một câu lệnh có thể đẩy vào hoặc lấy ra các giá trị bên trong ngăn xếp toán hạng, và nó có thể có khả năng tải hoặc lưu trữ các giá trị trong các biến mảng local. Hãy cùng xem ví dụ đơn giản sau:
public static void main(String[] args) { int a = 1; int b = 2; int c = a + b; }
Mục đích để in ra kết quả Bytecode trong class được biên dịch (giả sử nó là trong file Test.class
), chúng ta có thể chạy công cụ javap
javap -v Test.class
Và chúng ta nhận được
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: (0x0009) ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=4, args_size=1 0: iconst_1 1: istore_1 2: iconst_2 3: istore_2 4: iload_1 5: iload_2 6: iadd 7: istore_3 8: return ...
Chúng ta có thể nhìn thấy ký hiệu của phương thức main
, descriptor chỉ định rằng nhận một mảng String làm tham số ([Ljava/lang/String;
); và có một kiểu trả về void (v
). Một thiết lập của flags theo mô tả phương thức là public (ACC_PUBLIC
) và static (ACC_STATIC
).
Phần quan trọng nhất là thuộc tính code, chứa các câu lệnh cho phương thức cùng với thông tin như là độ sâu lớn nhất của ngăn xếp toán hạng (là 2 trong trường hợp này), số lượng biến local được cấp phát trong Frame trong phương thức này ( là 4 trong trường hợp này). Tất cả các biến local được tham chiếu trong các câu lệnh ở trên, ngoại trừ biến đầu tiên (tại index 0), chứa tham chiếu đến tham số args
. 3 biến local khác tương ứng với các biến a
, b
, và c
trong Source Code.
Các câu lệnh trong địa chỉ từ 0 đến 8 sẽ làm như sau:
iconst_1
: Đẩy hằng số số nguyên 1 vào trong ngăn xếp toán hạng
istore_1
: lấy ra toán hạng ở trên (một giá trị số nguyên) và lưu trữ nó trong biến local tại index 1, tương đương với biến a
iconst_2
: Đẩy hằng số số nguyên 2 vào trong ngăn xếp toán hạng
istore_2
: Lấy ra giá trị số nguyên ở toán hạng ở trên và lưu nó trong biến local tại index 2, tương ứng với biến b
.
iload_1
: Tải giá trị int từ biến local tại index 1 và đẩy nó vào trong ngăn xếp toán hạng
iload_2
: Tải giá trị int từ biến local tại index 2 và đẩy nó vào trong ngăn xếp toán hạng
iadd
: Lấy ra 2 giá trị int ở trên từ ngăn xếp toán hạng, cộng chúng, và đẩy kết quả lại vào trong ngăn xếp toán hạng.
istore_3
: Lấy ra giá trị int toán hạng ở trên và lưu nó trong biến local tại index 3, tương ứng với giá trị c
return
: Trả về từ phương thức void
Mỗi câu lệnh ở trên chỉ bao gồm một opcode
, chỉ ra chính xác thao tác được JVM thực hiện.
Phương thức
Trong ví dụ bên trên chỉ có một phương thức, phương thức main
. Giả sử chúng ta cần tính toán kỹ lưỡng hơn cho biến c
, và chúng ta đặt nó trong một phương thức mới gọi là calc
public static void main(String[] args) { int a = 1; int b = 2; int c = calc(a, b); } static int calc(int a, int b) { return (int) Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2)); }
Hãy cùng xem kết quả của Bytecode
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: (0x0009) ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=4, args_size=1 0: iconst_1 1: istore_1 2: iconst_2 3: istore_2 4: iload_1 5: iload_2 6: invokestatic #2 // Method calc:(II)I 9: istore_3 10: return static int calc(int, int); descriptor: (II)I flags: (0x0008) ACC_STATIC Code: stack=6, locals=2, args_size=2 0: iload_0 1: i2d 2: ldc2_w #3 // double 2.0d 5: invokestatic #5 // Method java/lang/Math.pow:(DD)D 8: iload_1 9: i2d 10: ldc2_w #3 // double 2.0d 13: invokestatic #5 // Method java/lang/Math.pow:(DD)D 16: dadd 17: invokestatic #6 // Method java/lang/Math.sqrt:(D)D 20: d2i 21: ireturn
Điều khác biệt duy nhất trong code phương thức main
là thay vì có câu lệnh iadd
, chúng ta giờ đây có câu lệnh invokestatic
, đơn giản là gọi đến phương thức static calc
. Điều quan trọng là ngăn xếp toán hạng chứa 2 tham số mà được truyền cho phương thức calc
. Nói cách khác, phương thức gọi chuẩn bị tất cả tham số của phương thức được gọi bằng cách đẩy chúng vào trong ngăn xếp toán hạng theo đúng thứ tự. invokestatic
(hay giống như gọi lệnh) sau đó sẽ lấy ra các tham số, và một Frame mới được tạo cho phương thức được gọi, nơi các tham số được đặt trong mảng biến local.
Chúng ta cũng nhận thấy rằng câu lệnh invokestatic
chiếm 3 byte khi nhìn vào địa chỉ, nhảy từ 6 đến 9. Điều này bởi vì, không giống như tất cả câu lệnh được nhìn thấy cho đến nay, invokestatic
bao gồm 2 byte bổ sung để xây dựng tham chiếu đến phương thức được gọi (ngoài opcode
). Tham chiếu được hiển thị bởi javap
là #2, là tham chiếu tượng trưng cho phương thức calc
, được quyết định từ vùng chứa hằng số (constant pool) được miêu tả trước đấy.
Các thông tin mới khác rõ ràng là code cho phương thức calc
. Đầu tiên nó tải về tham số số nguyên đầu tiên trong ngăn xếp toán hạng (iload_0
). Câu lệnh tiếp theo, i2d
, chuyển đổi nó thành double bằng cách áp dụng chuyển đổi mở rộng. Kết quả double được thay thế phần trên của ngăn xếp toán hạng.
Câu lệnh tiếp theo đẩy hằng số double a 2.0d
(lấy từ vùng chứa hằng số – constant pool) trên ngăn xếp toán hạng. Sau đó phương thức static Math.pow
được gọi với 2 giá trị toán hạng được chuẩn bị cho đến hiện tại (tham số đầu tiên cho calc
và hằng số 2.0d
). Khi phương thức Math.pow
trả về, kết quả của nó sẽ được lưu trữ trên ngăn xếp toán hạng của phía gọi). Điều này có thể hình dung như sau
Quy trình tương tự được áp dụng để tính toán Math.pow(b, 2)
:
Câu lệnh tiếp theo, dadd
, lấy ra 2 kết quả trung gian ở trên đầu, cộng lại, sau đó đẩy tổng ngược lại vào trên đầu. Cuối cùng, invokestatic
gọi Math.sqrt
trên kết quả tính tổng, và kết quả được đổi kiểu từ double sang int sử dụng chuyển đổi hẹp (d2i
). Kết quả int được trả về cho phương thức main
, lưu trữ ngược lại vào c
(istore_3
).
Tạo instance
Hãy sửa đổi ví dụ và đưa vào một class Point
để đóng gọi toạ độ XY
public class Test { public static void main(String[] args) { Point a = new Point(1, 1); Point b = new Point(5, 3); int c = a.area(b); } } class Point { int x, y; Point(int x, int y) { this.x = x; this.y = y; } public int area(Point b) { int length = Math.abs(b.y - this.y); int width = Math.abs(b.x - this.x); return length * width; } }
Bytecode được biên dịch cho phương thức main được hiển thị như bên dưới:
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: (0x0009) ACC_PUBLIC, ACC_STATIC Code: stack=4, locals=4, args_size=1 0: new #2 // class test/Point 3: dup 4: iconst_1 5: iconst_1 6: invokespecial #3 // Method test/Point."<init>":(II)V 9: astore_1 10: new #2 // class test/Point 13: dup 14: iconst_5 15: iconst_3 16: invokespecial #3 // Method test/Point."<init>":(II)V 19: astore_2 20: aload_1 21: aload_2 22: invokevirtual #4 // Method test/Point.area:(Ltest/Point;)I 25: istore_3 26: return
Các câu lệnh mới được nhìn thấy ở đây là new
, dup
, và invokespecial
. Giống như toán tử new
trong ngôn ngữ lập trình, câu lệnh new
tạo một Object với kiểu cụ thể trong toán hạng được truyền cho nó (là tham chiếu tương trưng đến class Point
). Bộ nhớ cho Object được cấp phát ở trong Heap, và một tham chiếu đến Object được đẩy trên ngăn xếp toán hạng.
Câu lệnh dup
sao chép làm 2 giá trị, nghĩa là hiện tại chúng ta có 2 tham chiếu đến Object Point
trên đầu của ngăn xếp. 3 câu lệnh tiếp theo đẩy các tham số của Constructor (được sử dụng để khởi tạo Object) lên ngăn xếp toán hạng, và sau đó gọi phương thức khởi tạo đặc biệt, tương đương với Constructor. Phương thức tiếp theo là nơi x
và y
sẽ được khởi tạo. Sau khi phương thức được hoàn thành, 3 giá trị ngăn xếp toán hạng trên đầu được sử dụng, và những gì còn lại là tham chiếu khởi đầu đến Object được tạo (hiện tại, đã được khởi tạo thành công).
Tiếp theo, astore_1
lấy tham chiếu đó ra và gán nó vào biến local tại index 1 ( a
trong astore_1
cho biết đây là một giá trị tham chiếu).
Quá trình tương tự được lặp lại cho việc tạo và khởi tạo Instance Point
thứ 2, được gán cho biến b
Bước cuối cùng tải các tham chiếu đến 2 Object Point
từ các biến local tại index 1 và 2 (sử dụng aload_1
và aload_2
tương ứng) và gọi phương thức area
sử dụng invokevirtual
, trong đó xử lý việc gửi cuộc gọi đến phương thức thích hợp dựa trên kiểu Object thực tế. Ví dụ, nếu biến a
chứa Instance của kiểu SpecialPoint
mà mở rộng từ Point
, và kiểu con ghi đè phương thức area
, sau đó phương thức ghi đè được gọi. Trong trường hợp này, không có subclass, và vì thế chỉ có một phương thức area
sẵn sàng để dùng.
Để ý rằng mặc dù phương thức area
nhận một tham số, có 2 tham chiếu Point
trên đầu của ngăn xếp. Cái đầu tiên (pointA
, đến từ biến a
) thực ra là Instance trên phương thức được gọi (ngoài ra được ám chỉ đến là this
trong ngôn ngữ lập trình) và nó sẽ được truyền trong biến local đầu tiên của Frame mới cho phương thức area
. Giá trị toán hạng khác (pointB
) là một tham số cho phương thức area
.
Ngược lại
Bạn không cần phải nắm vững về từng câu lệnh và luồng chạy chính xác để có được quan niệm về những gì chương trình làm dựa trên Bytecode trong tầm kiểm soát. Ví dụ trong trường hợp sau, chúng ta muốn kiểm tra code được sử dụng Java Stream để đọc một file, và liệu stream đã được đóng đúng cách chưa. Bây giờ chúng ta sẽ cùng kiểm tra Bytecode. Nó tương đối dễ dàng để xác định rằng thực sự một stream được sử dụng và rất có thể nó đang bị đóng như một phần của câu lệnh try-with-resources
public static void main(java.lang.String[]) throws java.lang.Exception; descriptor: ([Ljava/lang/String;)V flags: (0x0009) ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=8, args_size=1 0: ldc #2 // class test/Test 2: ldc #3 // String input.txt 4: invokevirtual #4 // Method java/lang/Class.getResource:(Ljava/lang/String;)Ljava/net/URL; 7: invokevirtual #5 // Method java/net/URL.toURI:()Ljava/net/URI; 10: invokestatic #6 // Method java/nio/file/Paths.get:(Ljava/net/URI;)Ljava/nio/file/Path; 13: astore_1 14: new #7 // class java/lang/StringBuilder 17: dup 18: invokespecial #8 // Method java/lang/StringBuilder."<init>":()V 21: astore_2 22: aload_1 23: invokestatic #9 // Method java/nio/file/Files.lines:(Ljava/nio/file/Path;)Ljava/util/stream/Stream; 26: astore_3 27: aconst_null 28: astore 4 30: aload_3 31: aload_2 32: invokedynamic #10, 0 // InvokeDynamic #0:accept:(Ljava/lang/StringBuilder;)Ljava/util/function/Consumer; 37: invokeinterface #11, 2 // InterfaceMethod java/util/stream/Stream.forEach:(Ljava/util/function/Consumer;)V 42: aload_3 43: ifnull 131 46: aload 4 48: ifnull 72 51: aload_3 52: invokeinterface #12, 1 // InterfaceMethod java/util/stream/Stream.close:()V 57: goto 131 60: astore 5 62: aload 4 64: aload 5 66: invokevirtual #14 // Method java/lang/Throwable.addSuppressed:(Ljava/lang/Throwable;)V 69: goto 131 72: aload_3 73: invokeinterface #12, 1 // InterfaceMethod java/util/stream/Stream.close:()V 78: goto 131 81: astore 5 83: aload 5 85: astore 4 87: aload 5 89: athrow 90: astore 6 92: aload_3 93: ifnull 128 96: aload 4 98: ifnull 122 101: aload_3 102: invokeinterface #12, 1 // InterfaceMethod java/util/stream/Stream.close:()V 107: goto 128 110: astore 7 112: aload 4 114: aload 7 116: invokevirtual #14 // Method java/lang/Throwable.addSuppressed:(Ljava/lang/Throwable;)V 119: goto 128 122: aload_3 123: invokeinterface #12, 1 // InterfaceMethod java/util/stream/Stream.close:()V 128: aload 6 130: athrow 131: getstatic #15 // Field java/lang/System.out:Ljava/io/PrintStream; 134: aload_2 135: invokevirtual #16 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 138: invokevirtual #17 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 141: return ...
Chúng ta nhìn thấy sự xuất hiện của java/util/stream/Stream
nơi foreach
được gọi, tới trước bằng việc gọi đến InvokeDynamic
với một tham chiếu đến Consumer
. Sau đó chúng ta nhìn thấy một đoạn Bytecode gọi Stream.close
cùng với các nhánh gọi Throwable.addSuppressed
. Đây là đoạn code cơ bản được tạo ra bởi trình biên dịch cho câu lệnh try-with-resources.
Đây là Source Code hoàn chỉnh ban đầu
public static void main(String[] args) throws Exception { Path path = Paths.get(Test.class.getResource("input.txt").toURI()); StringBuilder data = new StringBuilder(); try(Stream lines = Files.lines(path)) { lines.forEach(line -> data.append(line).append("\n")); } System.out.println(data.toString()); }