Java – Bytecode

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ố: iaddladdfadddadd. 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à:

  1. 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:

  1. 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ị longdouble, mà nắm giữ 2 biến local.
  2. 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 xy 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());
}

 

 

 

You May Also Like

About the Author: Nguyen Dinh Thuc

Leave a Reply

Your email address will not be published.