Bài viết nằm trong series Java memory management & performance.
Với bài viết đầu tiên, cùng nhìn sơ lược về cách JVM thực thi các đoạn code của chúng ta như thế nào nhé.
1) WORA
Java nổi tiếng với WORA, nghĩa là Write once, run anywhere, viết một lần và chạy mọi nơi. Oh shit, wtf... làm sao lại có một ngôn ngữ thần thánh như vậy, bí ẩn phía sau nó là gì?
Lấy một ví dụ trong thực tế, nếu mình chỉ biết tiếng Việt, liệu mình có thể đi du lịch nước ngoài được không? Làm sao mình giao tiếp được với người bản địa. Chẳng có nhẽ với mỗi một quốc gia khác nhau mình phải học ngôn ngữ của họ.
Có 3 cách cơ bản để giải quyết vấn đề trên:
- Thứ nhất, tham dự một khóa học ngoại ngữ cấp tốc để giao tiếp cơ bản được với người bản địa. Khá tốn, không khả thi.
- Thứ hai, cũng đi học ngoại ngữ nhưng là international language. Không khác cách đầu tiên mấy.
- Thứ ba, thuê luôn tour guide cho nhàn, giàu nữa thì chơi hẳn interpreter cho máu. Có vẻ khả thi nhất rồi. Vẫn hơi tốn kém, thôi dùng cái máy thông dịch (khá phổ biến hiện nay) tầm 5 - 8 củ là ngon nghẻ rồi.
Như vậy, bài toán được giải quyết đơn giản với cách thứ ba, đầu tư một lần, dùng nhiều lần. Không cần biết tất cả các loại ngôn ngữ, chỉ cần biết một và.. đi đâu cũng dùng được với chiếc máy interpreter nhỏ gọn.
Java dựa trên idea này để thực hiện WORA, viết một lần dùng mọi nơi. Interpreter lúc này là JVM và compiler. Các ngôn ngữ của từng quốc gia là các hệ điều hành như Windows, MacOS, Linux... và ngôn ngữ chúng ta nói là các đoạn code Java.
Quá trình để một đoạn code Java được thực thi với WORA diễn ra như sau:
- Mua một cái máy interpreter trước, hay nói cách khác là cài đặt JDK (Java development kit). Nếu chưa rõ các bạn có thể tìm đọc thêm về JDK, JRE và JVM nhé.
- Sau đó, nói vào máy thông dịch để nó ghi nhận thông tin. Tương ứng với việc viết code trong file .java.
- Lúc này máy thực hiện chuyển ngôn ngữ của chúng ta sang dạng ngôn ngữ máy có thể hiểu được. Cụ thể đó là chuyển thông tin từ dạng âm thanh sang dạng tín hiệu điện từ để tiến hành phân tích. Với Java, ta phải làm việc này thủ công, đó là thực hiện biên dịch các file .jav sang file .class chứa các bytecode là ngôn ngữ của JVM để được thực thi.
- Cuối cùng, interpreter dịch sang ngôn ngữ bản địa và phát âm thanh. Tương ứng với JVM dịch bytecode sang machine language phù hợp với từng OS khác nhau để thực thi.
Trong thực tế, ứng dụng bao gồm rất nhiều file, sau khi compile ta sẽ nén nó thành một file duy nhất cho gọn, thường là .jar hoặc .war. Đem file jar này đi chạy trên OS nào cũng được, với điều kiện đã có JRE. Write once, run anywhere là như vậy.
2) JIT Compiler
Bỏ qua phần compile code, để start một Java application với jar file, ta sử dụng câu lệnh:
java -jar file-name.jar
Sau khi chạy, một JVM mới được tạo ra và cthực thi trên đó. Như vậy, mỗi một application chạy trên một JVM riêng biệt, nếu có được hỏi phỏng vấn thì cứ mạnh dạn trả lời nhé .
JVM có nhiệm vụ thông dịch các đoạn code sang mã máy để OS thực thi. Như vậy, có thể nói Java là interpreter language. Nếu bàn về tốc độ thực thi thì không có cửa so với các ngôn ngữ compiled language như C, C++ hay C#...
Các ngôn ngữ như C hay C# được trực tiếp biên dịch ra mã máy nên khi thực thi sẽ nhanh hơn do không tốn công chuyển đổi từ bytecode sang native code như Java. Do đó, với compiled language, mỗi khi chạy trên OS khác nhau cần compile lại code để phù hợp với OS đó, không có WORA như Java, bù lại tốc độ thực thi nhanh hơn.
Tuy nhiên nó chỉ là bề nổi của tảng băng chìm, các kĩ sư của Sun/Oracle đã thiết kế JVM với hàng loạt tính năng và thuật toán phức tạp để làm chương trình hoạt động hiệu quả hơn là việc đơn thuần sử dụng interpreter.
JVM sẽ monitor xem đoạn code nào được thực thi nhiều lần. Đoạn code đó có thể là method, một phần của method hoặc một vòng lặp (loop). Sau khi tìm được block of code đó, thay vì mỗi lần thực thi phải interpreter sang machine languague thì nó được compile sang machine language luôn cho nhanh. Tất nhiên phải có nơi lưu trữ cho native code đó, là code cache. Như vậy, tại một thời điểm, một vài phần của chương trình sẽ không chạy trong trạng thái interpretive nữa mà là thực thi luôn native machine code. Đó là Just-in-time Compiler, ngắn gọn hơn là JIT Compiler.
Machine language hay nói cách khác là native code là những đoạn code mà OS có thể hiểu ngay được để thực thi, không cần thông qua interpreter. JIT Compiler phần nào giúp cho Java application chạy nhanh hơn. Ngoài ra, nó cũng là lý do vì sao khi benchmark những đoạn code chạy lần đầu luôn chậm hơn các lần sau đó.
Thực ra không cần đi quá sâu vào phần này nếu bạn không cần optimize application. Nên nếu muốn optimize application ta cần hiểu rõ hơn về nó. Let's continue .
Những đoạn code được chạy thường xuyên (hot code) sẽ được JIT Compiler chuyển thành native code để tăng tốc độ thực thi. Tương tự, method nào cả ngày chỉ chạy có một vài lần thì.. thôi mỗi lần chạy là một lần interpreter cũng được, để dành memory lưu trữ cho các method khác cần thiết hơn.
Việc chuyển từ Java bytecode sang native machine code sẽ được thực hiện trên thread độc lập với quá trình interpreter. Bản chất JVM cũng là một application, và là multi-thread application. Một thread thực hiện interpreter bytecode sang native code, thread khác thực thi native code đó, và một thread compile bytecode sang native code lưu trữ trên code cache. Do vậy, việc compile sang native code không có bất kì ảnh hưởng gì đến quá trình run application. Nếu chưa có sẵn native code, JVM vẫn sử dụng interpreter để thực thi đoạn hot code. Và khi native code đã sẵn sàng, JVM sẽ chuyển sang dùng luôn, chả tội gì thông qua interpreter nữa.
3) Practice
Đến giờ thực hành rồi, cùng tìm hiểu xem dòng code nào được compile sang native code. Bài toán đơn giản như sau, in ra 10 số nguyên tố đầu tiên. Tạo file Application.java và code thôi:
class PrimeNumberGenerator { private boolean isPrime(int n) { final double result = Math.sqrt(n); for (int i = 2; i <= result; i++) { if (n % i == 0) { return false; } } return true; } private int getNextPrimeAbove(int previous) { int testNumber = previous + 1; while (!isPrime(testNumber)) { ++testNumber; } return testNumber; } public void generateNumbers(int count) { final List<Integer> primes = new ArrayList<>(); primes.add(2); int next = 2; while (primes.size() <= count) { next = getNextPrimeAbove(next); primes.add(next); } System.out.println(primes); } } public class Application { public static void main(String[] args) { final PrimeNumberGenerator generator = new PrimeNumberGenerator(); generator.generateNumbers(10); } }
Với Java 8, ta cần thực hiện compile sang bytecode trước khi chạy ứng dụng, tức là cần 2 câu lệnh. Tuy nhiên với Java 11, chỉ cần 1 câu lệnh, nó sẽ thực hiện compile on the fly luôn (chỉ thực hiện được với chương trình có 1 file duy nhất).
Nguy hiểm tí thôi, mình thực hiện luôn trên IDE cho nhanh, output là:
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31]
Thêm JVM options khi chạy -XX:+PrintCompilation, nó sẽ in thêm các thông tin về quá trình code compilation. Nói qua về JVM options trước, nó là các tham số.. nâng cao trong quá trình thực thi code. Phần lớn JVM options tuân theo format chia làm 3 phần:
- Phần đầu tiên -XX:
- Phần tiếp theo là dấu + hoặc - nhằm mục đích bật/tắt các options.
- Phần cuối là tên của options dưới dạng CamelCase, viết hoa các chữ cái đầu tiên của mỗi từ.
JVM options sẽ được thêm ngay sau keywork java khi thực thi chương trình, ví dụ:
java -XX:+PrintComplication -jar file-name.jar
Sau khi thêm -XX:+PrintCompilation, kết quả là:
48 1 3 java.lang.String::hashCode (55 bytes) 48 2 n 0 java.lang.System::arraycopy (native) (static) 48 3 3 java.lang.String::charAt (29 bytes) 49 4 3 java.lang.Object::<init> (1 bytes) 49 5 3 java.lang.String::length (6 bytes) 58 6 3 java.lang.String::equals (81 bytes) 60 8 3 java.lang.CharacterData::of (120 bytes) 60 9 3 java.lang.CharacterDataLatin1::getProperties (11 bytes) 60 10 3 java.lang.Character::toLowerCase (9 bytes) 60 11 3 java.lang.CharacterDataLatin1::toLowerCase (39 bytes) 60 7 3 java.lang.AbstractStringBuilder::ensureCapacityInternal (27 bytes) 61 16 4 java.lang.String::charAt (29 bytes) 61 13 3 java.lang.AbstractStringBuilder::append (29 bytes) 61 12 3 java.lang.Math::min (11 bytes) 61 15 3 java.io.WinNTFileSystem::isSlash (18 bytes) 61 14 3 java.lang.StringBuilder::append (8 bytes) 61 3 3 java.lang.String::charAt (29 bytes) made not entrant 61 18 3 java.lang.String::indexOf (70 bytes) 61 17 s 3 java.lang.StringBuffer::append (13 bytes) 62 19 3 java.util.Arrays::copyOfRange (63 bytes) 65 20 3 java.lang.System::getSecurityManager (4 bytes)
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31] 66 21 3 java.lang.String::getChars (62 bytes)
Vãi nhái thật , thế này ai chơi. Hít mấy hơi đọc tiếp xem quá trình compilation diễn ra thế nào.
Nhìn sơ qua cách tổ chức thông tin compilation chia làm các column:
- Với column đầu tiên là các con số tăng dần 48, 48, 49... 66. Nó là số đo thời gian biểu diễn dưới đơn vị miliseconds kể từ khi JVM bắt đầu chạy, chỉ có tăng chứ không giảm.
- Column tiếp theo là trình tự compile các method hoặc block of code. Nhưng tại sao chúng không sắp xếp theo thứ tự, đơn giản là do có những đoạn code gọi các method được định nghĩa ở các vị trí khác nhau, file khác nhau. Ngoài ra còn do compilation time. Ta chưa cần quan tâm đến column này lắm.
- Column tiếp theo đa số là các empty value, tuy nhiên có 2 giá trị cần quan tâm là s và n: s là synchronized, n là native code.
- Tiếp theo là column có đoạn giá trị từ 0 đến 4, nói về loại compilation. 0 là không cần compile, các giá trị 1 đến 4 là code compilation level theo chiều sâu. Nhìn lại dòng có cột thứ ba giá trị n, vì là native code nên không cần compile nữa, do đó nhận giá trị 0. Level càng cao thì quá trình compile càng nhiều bước, time càng lớn.
- Column cuối cùng là vị trí của đoạn code đó.
Bạn có nhận ra điều gì kì lạ không, gợi ý ở column cuối cùng.
... waiting ...
Đó là.. không thấy code của mình viết ở đâu nhỉ, không có bất kì một dòng nào ngoài System.out.println() cuối cùng .
Chạy lại với và in ra nhiều số nguyên tố hơn, 5000 cho máu, bỏ luôn System.out.println() cho khỏi thừa thãi.
55 1 3 java.lang.String::hashCode (55 bytes) 55 2 n 0 java.lang.System::arraycopy (native) (static) 55 3 3 java.lang.String::charAt (29 bytes) 56 4 3 java.lang.Object::<init> (1 bytes) 56 5 3 java.lang.String::length (6 bytes) 67 6 3 java.lang.String::equals (81 bytes) 68 7 3 java.lang.AbstractStringBuilder::ensureCapacityInternal (27 bytes) 69 8 3 java.lang.CharacterData::of (120 bytes) 69 9 3 java.lang.CharacterDataLatin1::getProperties (11 bytes) 69 10 3 java.lang.Character::toLowerCase (9 bytes) 69 11 3 java.lang.CharacterDataLatin1::toLowerCase (39 bytes) 70 14 3 java.lang.String::indexOf (70 bytes) 70 12 3 java.lang.AbstractStringBuilder::append (29 bytes) 70 16 4 java.lang.String::charAt (29 bytes) 70 13 3 java.lang.StringBuilder::append (8 bytes) 70 15 3 java.io.WinNTFileSystem::isSlash (18 bytes) 70 17 s 3 java.lang.StringBuffer::append (13 bytes) 71 18 3 java.lang.AbstractStringBuilder::append (50 bytes) 71 3 3 java.lang.String::charAt (29 bytes) made not entrant 71 19 3 java.lang.StringBuilder::append (8 bytes) 72 20 3 java.util.Arrays::copyOfRange (63 bytes) 76 21 3 java.lang.System::getSecurityManager (4 bytes) 76 22 3 java.lang.String::startsWith (72 bytes) 77 23 3 com.op.PrimeNumberGenerator::isPrime (34 bytes) 78 25 3 java.lang.Number::<init> (5 bytes) 78 27 3 java.util.ArrayList::add (29 bytes) 78 31 4 com.op.PrimeNumberGenerator::isPrime (34 bytes) 78 32 3 com.op.PrimeNumberGenerator::getNextPrimeAbove (20 bytes) 78 24 1 java.util.ArrayList::size (5 bytes) 78 26 3 java.lang.Integer::<init> (10 bytes) 78 28 3 java.util.ArrayList::ensureCapacityInternal (13 bytes) 79 29 3 java.util.ArrayList::calculateCapacity (16 bytes) 79 30 3 java.util.ArrayList::ensureExplicitCapacity (26 bytes) 79 23 3 com.op.PrimeNumberGenerator::isPrime (34 bytes) made not entrant 79 34 % 4 com.op.PrimeNumberGenerator::isPrime @ 9 (34 bytes) 79 33 3 java.lang.Integer::valueOf (32 bytes) 80 32 3 com.op.PrimeNumberGenerator::getNextPrimeAbove (20 bytes) made not entrant 80 35 3 com.op.PrimeNumberGenerator::getNextPrimeAbove (20 bytes) 81 36 4 com.op.PrimeNumberGenerator::getNextPrimeAbove (20 bytes) 81 37 3 java.lang.Math::min (11 bytes) 82 35 3 com.op.PrimeNumberGenerator::getNextPrimeAbove (20 bytes) made not entrant
Đã thấy sự khác biệt rõ rệt, colum thứ ba có thêm giá trị % và các đoạn code chúng ta viết đã xuất hiện ở compilation.
Chú ý vào dòng có giá trị %, method isPrime() được gọi rất nhiều lần và được đặt vào code cache, chính là nơi lưu trữ native code từ quá trình JIT Compiler. Mình sẽ nói cụ thể hơn về nó ở phần sau.
Nếu đọc đến đây thì xin chúc mừng, bạn khá kiên nhẫn đấy . Đón chờ bài tiếp theo về Code cache và tuning code cache nhé.
Reference
Reference in series https://viblo.asia/s/java-memory-management-performance-vElaB80m5kw
© Dat Bui