Phát triển với Java thời gian thực, Phần 2: Cải thiện chất lượng dịch vụ


Tính đa dạng trong một ứng dụng Java — thường gây ra do các đoạn dừng, hoặc trễ, xảy ra vào những lúc không thể đoán trước được — có thể xảy ra qua ngăn xếp phần mềm. Các trễ có thể xuất hiện do:
  • Phần cứng (trong các quá trình xử lý chẳng hạn như nhớ nhanh).

  • Phần đệm (xử lý của các ngắt quản lý hệ thống chẳng hạn như dữ liệu về nhiệt độ bộ xử lý trung tâm).

  • Hệ điều hành (trả lời một ngắt hoặc khai thác một hoạt động thông minh đã lên lịch thường kì).

  • Các chương trình khác chạy trên cùng hệ thống.

  • JVM (gom rác, biên dịch Đúng lúc, và tải lớp).

  • Chính ứng dụng Java.
Bạn có thể hiếm khi bù lại ở một mức cao cho các trễ do mức thấp hơn gây nên, vậy nếu bạn cố gắng giải quyết độ đa dạng chỉ ở mức ứng dụng, có thể bạn chỉ cần chuyển đổi JVM hoặc các trễ của hệ điều hành ở đâu đó mà không giải quyết vấn đề thực. May mắn là các thời gian chờ đối với các mức thấp hơn có xu hướng tương đối ngắn hơn các thời gian chờ ở các mức cao, như vậy chỉ khi nào yêu cầu của bạn đối với việc giảm độ đa dạng là vô cùng nhiều thì bạn mới cần xem xét thấp hơn JVM hoặc hệ điều hành. Nếu các yêu cầu của bạn không nhiều đến như vậy, thì bạn có thể gần như chắc chắn tập trung đủ các cố gắng của bạn ở mức JVM và trong ứng dụng của bạn.

Java thời gian thực cho bạn các công cụ mà bạn cần phải vật lộn với các tài nguyên biến đổi trong một JVM và trong các ứng dụng của bạn để cung cấp chất lượng dịch vụ mà những người sử dụng của bạn đòi hỏi. Bài viết này đề cập đến nguồn gốc của sự đa dạng ở các mức JVM và ứng dụng một cách chi tiết và mô tả các công cụ và kỹ thuật mà bạn có thể sử dụng để giảm nhẹ tác động của chúng. Sau đó nó đưa ra một ứng dụng máy chủ Java đơn giản trình bày một số khái niệm này.

Nhằm vào các nguồn biến đổi

Các nguồn biến đổi ban đầu trong một JVM xuất phát từ tính chất động của ngôn ngữ Java:
  • Bộ nhớ không giải phóng hiển hiện do ứng dụng mà thay vào đó được phục hồi theo định kỳ bởi bộ gom rác.

  • Các lớp được giải quyết khi ứng dụng sử dụng chúng đầu tiên.

  • Mã riêng được biên dịch (và có thể được biên dịch lại bằng một Bộ biên dịch Đúng lúc (Just-in-time (JIT) compiler) khi ứng dụng đang chạy, dựa trên các lớp và phương thức nào được gọi ra thường xuyên.

Ở mức ứng dụng Java, việc quản lý các xử lí là lĩnh vực chủ yếu liên quan đến độ đa dạng.

Đoạn dừng gom rác

Khi bộ gom rác chạy để phục hồi bộ nhớ mà chương trình không còn sử dụng nữa, nó có thể dừng tất cả các xử lí ứng dụng. (Kiểu bộ gom này gọi là một bộ gom Stop-the-world (Dừng lại tất cả), hay bộ gom STW.) Hoặc nó có thể thực hiện một số công việc của mình đồng thời với ứng dụng. Trong trường hợp ấy, các tài nguyên mà bộ gom rác cần có cũng không đủ dùng cho ứng dụng, cho nên việc gom rác (GC) là nguyên nhân gây dừng và biến đổi đối với hiệu năng ứng dụng Java, như thường được biết đến. Mặc dù nhiều hình mẫu GC có các ưu điểm và nhược điểm của nó, khi mục tiêu đối với một ứng dụng là các đoạn dừng GC ngắn, hai lựa chọn chính là bộ gom sản sinh (generational) và và bộ gom thời gian thực (real-time).

Các bộ gom sản sinh tổ chức đống thành ít nhất 2 phần thường gọi là không gian mới và không gian  (đôi khi gọi là theo nhiệm kỳ tenured). Các đối tượng mới luôn luôn được phân bổ trong không gian mới. Khi không gian mới hết bộ nhớ tự do, rác chỉ được thu gom trong không gian đó. Việc sử dụng một không gian mới tương đối nhỏ có thể giữ được thời gian chu trình GC khá ngắn. Các đối tượng qua được một số thu gom không gian-mới được thúc đẩy trở thành không gian cũ. Các thu gom không gian cũ thường ít xuất hiện hơn nhiều so với các thu gom không gian mới, nhưng do không gian cũ lớn hơn nhiều so với không gian mới, các chu trình GC này có thể mất nhiều thời gian hơn. Các bộ gom rác sản sinh đưa ra các đoạn dừng GC trung bình tương đối ngắn, nhưng chi phí của các thu gom không gian cũ có thể gây ra sai lệch chuẩn sau các lần đoạn dừng này sẽ là khá lớn. Các bộ gom sản sinh là hiệu quả nhất trong các ứng dụng mà tập hợp các dữ liệu sống không thay đổi nhiều theo thời gian nhưng nhiều rác được tạo ra. Trong kịch bản này, các thu gom không gian cũ là vô cùng hiếm, và như vậy các lần tạm ngừng GC là do các thu gom không gian cũ ngắn.

Ngược lại với các bộ gom sản sinh, các bộ gom rác thời gian thực điều khiển hành vi của chúng để thu ngắn rất nhiều độ dài của chu trình GC (bằng cách khai thác các chu trình khi ứng dụng không dùng đến) hoặc để giảm bớt ảnh hưởng của các chu trình này về hiệu năng ứng dụng (bằng cách thực hiện công việc bằng các gia số nhỏ phù hợp với một “mức độ rút ngắn” với ứng dụng). Việc sử dụng một trong những bộ gom này cho phép bạn lường trước được trường hợp xấu nhất để hoàn tất một tác vụ riêng. Thí dụ, bộ gom rác trong các JVM thời gian thực IBM® WebSphere® chia các chu trình GC thành các việc nhỏ — gọi là các lượng tử GC (GC quanta ) — mà có thể được hoàn tất gia tăng. Việc lên lịch các lượng tử có một tác động vô cùng thấp về hiệu năng ứng dụng, với các trễ thấp đến phần trăm micro-giây nhưng thường nhỏ hơn 1 milli-giây. Để đạt được mức trễ này, bộ gom rác phải có khả năng lập kế hoạch công việc của nó bằng cách đưa ra khái niệm về một mức độ rút ngắn sử dụng ứng dụng. Mức độ rút ngắn này điều khiển mức độ thường xuyên mà GC được phép ngắt ứng dụng để thực hiện công việc của nó. Thí dụ, mức độ rút ngắn sử dụng mặc định là 70% mà chỉ cho phép GC sử dụng đến 3 mili-giây trong mỗi 10 mili-giây, với các đoạn dừng điển hình khoảng 500 micro-giây, khi chạy trên một hệ điều hành thời gian thực. (xem "Java thời gian thực, Phần 4: Gom rác Thời gian thực" để được mô tả chi tiết về phép gom rác Thời gian Thực WebSphere của IBM).

Kích thước của đống và mức sử dụng ứng dụng là các tuỳ chọn điều chỉnh quan trọng cần cân nhắc khi chạy một ứng dụng trên một bộ gom rác thời gian thực. Khi mức sử dụng ứng dụng tăng lên, bộ gom rác nhận được ít thời gian hơn để hoàn tất công việc của nó, như vậy cần có đống lớn hơn để đảm bảo chu trình GC có thể được hoàn tất gia tăng. Nếu bộ gom rác không thể theo kịp với tốc độ phân bố, GC quay trở lại một thu gom đồng bộ.

Thí dụ, một ứng dụng chạy trên các JVM thời gian thực WebSphere của IBM, với mức độ sử dụng ứng dụng mặc định 70% của chúng, đòi hỏi nhiều đống theo mặc định hơn nếu nó được chạy trên một JVM bằng cách sử dụng một bộ gom rác sản sinh (mà không đưa ra mức độ rút ngắn việc sử dụng. Do các bộ gom rác thời gian thực điều khiển thời lượng dừng GC, việc gia tăng kích thước của đống làm hạ tần suất GC mà không kéo dài thời gian tạm ngừng. Trong các bộ gom rác không phải thời gian thực, về mặt khác, việc gia tăng kích thước đống thường làm giảm bớt tần số của các chu trình GC, nó giảm bớt tổng thể tác động của bộ gom rác; khi một chu trình GC xuất hiện, thời gian đoạn dừng nói chung là lớn hơn (do có nhiều đống hơn cần kiểm tra).

Trong các JVM thời gian thực WebSphere của IBM, bạn có thể điều chỉnh kích thước đống bằng tuỳ chọn -Xmx<size>. Thí dụ, -Xmx512m quy định một đống 512MB. Bạn cũng có thể điều chỉnh việc sử dụng ứng dụng. Ví dụ, -Xgc:targetUtilization=80đặt nó ở mức 80%.

Các đoạn dừng nạp lớp Java

Đặc tả ngôn ngữ Java đòi hỏi các lớp phải được giải quyết, nạp, xác thực, và khởi tạo khi có ứng dụng đầu tiên tham chiếu đến chúng. Nếu tham chiếu đầu tiên đến một lớp C xuất hiện trong thời gian có một phép toán chiếm nhiều thời gian, thì thời gian để giải quyết, xác thực, nạp, và khởi tạo C có thể làm cho phép toán đó mất nhiều thời gian hơn được chờ đợi. Do việc nạp C gồm cả việc xác thực lớp đó — mà có thể yêu cầu phải nạp các lớp khác — trễ toàn bộ ứng dụng Java xảy ra để có thể sử dụng một lớp cụ thể đối với lần đầu tiên với thời gian có nghĩa lâu hơn dự kiến.

Tại sao một lớp chỉ có thể được tham chiếu đến lần đầu tiên sau đó trong một khai thác ứng dụng? Các đường dẫn được khai thác rất hiếm là nguyên nhân phổ biến của một việc nạp lớp mới. Ví dụ, bộ mã trong Liệt kê 1 chứa một điều kiện if mà rất hiếm được phép khai thác. (để cho ngắn gọn, việc xử lý ngoại lệ và sai sót phần lớn được bỏ qua, từ tất cả các liệt kê trong bài viết này.)
Liệt kê 1. Thí dụ về một điều kiện rất hiếm được thực hiện để nạp một lớp mới

Iterator<MyClass> cursor = list.iterator();
while (cursor.hasNext()) {
MyClass o = cursor.next();
if (o.getID() == 17) {
NeverBeforeLoadedClass o2 = new NeverBeforeLoadedClass(o);
// do something with o2
}
else {
// do something with o
}
}


Các lớp ngoại lệ là các ví dụ khác của các lớp mà không được phép nạp cho đến khi chuyển sang hẳn một sự khai thác của ứng dụng, do các ngoại lệ hiếm khi xảy ra (mặc dù không phải luôn thế). Vì các ngoại lệ hiếm khi được xử lý nhanh chóng, việc tăng các quá tải lớp phụ có thể đẩy thời gian chờ phép toán đến ngưỡng gay cấn. Nói chung, các ngoại lệ bị loại bỏ khi có các phép toán chiếm nhiều thời gian phải được tránh đi bất cứ khi nào có thể.

Các lớp mới cũng có thể được nạp khi các dịch vụ nào đó, chẳng hạn như phản chiếu, được sử dụng trong thư viện lớp Java. Việc cài đặt ẩn của các lớp phản chiếu tạo ra các lớp mới đang chạy sẽ được nạp trong JVM. Việc sử dụng lặp lại các lớp phản chiếu trong mã nhạy thời gian có thể gây ra hoạt động nạp-lớp liên tục mà đưa ra các trở ngại. Sử dụng tuỳ chọn -verbose:class là cách tốt nhất để phát hiện ra các lớp này đang được tạo ra. Có lẽ cách tốt nhất để tránh việc tạo ra chúng trong thời gian chạy chương trình là tránh sử dụng các dịch vụ phản chiếu để ánh xạ lớp, trường, hoặc các phương thức từ các chuỗi khi chạy các bộ phận tiêu tốn thời gian của ứng dụng của bạn. Thay vào đó, gọi trước các dịch vụ này trong ứng dụng của bạn và lưu các kết quả để sau này sử dụng nhằm ngăn chặn hầu hết các loại lớp này được tạo ra khi đang chạy mà bạn không muốn tạo ra chúng.

Một kỹ thuật chung để tránh các trễ khi nạp lớp khi chạy các bộ phận nhạy thời gian của ứng dụng của bạn là nạp trước các lớp khi khởi động hoặc khởi tạo ứng dụng. Mặc dù bước nạp trước này đưa ra một trở ngại khởi động bổ sung nào đó (thật đáng tiếc là, việc cải tiến một độ đo thường gây hậu quả tiêu cực đối với độ đo khác), nếu được sử dụng cẩn thận, có thể loại bỏ việc nạp lớp không mong muốn về sau. Quy trình khởi động này rất dễ thực hiện, như trong Liệt kê 2:

Liệt kê 2. Nạp lớp được điều khiển từ một danh sách lớp

Iterator<String> classIt = listOfClassNamesToLoad.iterator();
while (classIt.hasNext()) {
String className = classIt.next();
try {
Class clazz = Class.forName(className);
String n=clazz.getName();
} catch (Exception e) {
System.err.println("Could not load class: " + className);
System.err.println(e);
}


Hãy chú ý đến lần gọi clazz.getName() nó buộc lớp này phải được khởi tạo. Việc xây dựng danh sách các lớp đòi hỏi phải thu thập thông tin từ ứng dụng của bạn khi nó chạy, hoặc phải sử dụng một tiện ích mà có thể xác định được các lớp nào mà ứng dụng của bạn sẽ nạp. Thí dụ, bạn có thể nắm bắt được kết quả đầu ra của chương trình của bạn khi chạy với tuỳ chọn -verbose:class. Liệt kê 3 trình bày kết quả của lệnh này sẽ trông như thế nào nếu bạn sử dụng một sản phẩm thời gian thực WebSphere của IBM:

Liệt kê 3. Trích đoạn kết quả từ việc chạy java với -verbose:class


    ...
class load: java/util/zip/ZipConstants
class load: java/util/zip/ZipFile
class load: java/util/jar/JarFile
class load: sun/misc/JavaUtilJarAccess
class load: java/util/jar/JavaUtilJarAccessImpl
class load: java/util/zip/ZipEntry
class load: java/util/jar/JarEntry
class load: java/util/jar/JarFile$JarFileEntry
class load: java/net/URLConnection
class load: java/net/JarURLConnection
class load: sun/net/www/protocol/jar/JarURLConnection
...


Bằng cách lưu lại danh sách các lớp do ứng dụng của bạn nạp khi khai thác và sử dụng danh sách đó để đưa vào danh sách các tên lớp cho vòng lặp như hiển thị trong Liệt kê 2, bạn có thể chắc chắn rằng các lớp đó nạp trước khi ứng dụng của bạn bắt đầu chạy. Dĩ nhiên, các khai thác khác nhau của ứng dụng của bạn có thể dùng các đường dẫn khác nhau, nên danh sách từ một khai thác có thể không đầy đủ. Chuẩn bị cho điều đó, nếu ứng dụng của bạn đang được phát triển, bộ mã vừa mới viết ra hoặc sửa đổi có thể dựa vào các lớp mới mà không phải là bộ phận của danh sách (hoặc lớp mà nằm trong danh sách có thể không lâu hơn yêu cầu). Đáng tiếc là, việc bảo trì danh sách lớp lại là một phần vô cùng rắc rối khi theo tiếp cận này đối với việc nạp trước lớp. Nếu bạn theo tiếp cận này, hãy nhớ rằng tên của sản phẩm đầu ra lớp của -verbose:class không phù hợp với định dạng mà Class.forName() đòi hỏi: đầu ra rườm rà (verbose output) tách riêng các gói lớp bằng các dấu gạch chéo tiến, khi Class.forName() chờ chúng được tách riêng bằng các dấu chấm câu.

Đối với các ứng dụng mà việc nạp lớp là công việc, một số công cụ có thể giúp bạn quản lý việc nạp trước, gồm có Công cụ Phân tích Lớp Thời gian Thực (Real Time Class Analysis Tool - RATCAT) và Bộ Tối ưu hoá Khai thác Ứng dụng Thời gian Thực dùng cho Java của IBM (IBM Real Time Application Execution Optimizer for Java) (xem Tài nguyên). Các công cụ này cung cấp một số kỹ thuật tự động để định danh danh sách các lớp để nạp trước và kết hợp bộ mã nạp trước vào ứng dụng của bạn.

Các đoạn dừng biên dịch-mã JIT

Vẫn còn một nguồn thứ ba của các trở ngại trong chính JVM là bộ biên dịch JIT. Nó hoạt động khi ứng dụng của bạn chạy để dịch các phương thức của chương trình từ các từ ngôn ngữ máy được bộ biên dịch javac tạo ra thành các chỉ thị riêng của bộ xử lý trung tâm mà ứng dụng chạy trên nó. Bộ biên dịch JIT là cốt yếu đối với sự thành công của nền Java vì nó tạo ra hiệu năng ứng dụng cao mà không phải hi sinh tính trung lập nền của các ngôn ngữ máy Java. Trong thập niên vừa qua và sau này, các kỹ sư biên dịch JIT đã có các bước tiến dài trong việc cải thiện thông lượng và thời gian chờ đối với ứng dụng Java.

Một ví dụ về sự tối ưu hoá JIT

Một ví dụ tốt về tối ưu hoá JIT là sự chuyên môn hoá về các arraycopy (sao chép mảng/dãy). Đối với một phương thức thường xuyên được thực hiện, bộ biên dịch JIT có thể vẽ biên dạng độ dài của một lần gọi arraycopy riêng để xem liệu các độ dài nhất định là phổ biến nhất hay không. Sau tạo hình lần gọi một lúc, bộ biên dịch JIT có thể thấy rằng chiều dài gần như lúc nào cũng là 12 byte. Với tri thức này, JIT có thể tạo ra một đường dẫn vô cùng nhanh cho lần gọi arraycopy mà sao chép trực tiếp số 12 byte đòi hỏi này theo cách hiệu quả nhất đối với bộ xử lý đích. JIT chèn vào một vận cản có điều kiện để xem độ dài có phải là 12 hay không, và nếu như vậy thì việc sao chép đường dẫn-nhanh siêu hiệu quả sẽ được thực hiện. Nếu độ dài không phải là 12, thì một đường dẫn khác xuất hiện, thực hiện việc sao chép theo kiểu mặc định, nó có thể liên quan đến việc bổ sung lâu hơn nhiều vì nó có thể xử lý bất kỳ độ dài mảng nào. Nếu phần lớn các phép toán trong ứng dụng sử dụng đường dẫn nhanh, thì thời gian chờ hoạt động chung sẽ dựa trên thời gian mà nó mất để sao chép trực tiếp 12 byte đó. Tuy nhiên bất kỳ phép toán nào mà đòi hỏi một sự sao chép của một độ dài khác sẽ xuất hiện để được làm chậm lại liên quan đến việc định thời hoạt động chung.

Đáng tiếc là, các cải tiến như vậy đi kèm các đoạn dừng trong hiệu năng ứng dụng Java, do bộ biên dịch JIT “ăn cắp” các chu trình từ chương trình ứng dụng để tạo ra bộ mã được biên dịch (hoặc thậm chí biên dịch lại) cho một phương thức riêng. Tuỳ thuộc vào kích thước của phương thức mà được biên dịch và mức độ tích cực mà JIT chọn để biên dịch nó, thời gian biên dịch có thể có biên độ từ dưới 1 milli-giây đến hơn một giây đối với các phương thức đặc biệt lớn mà được bộ biên dịch JIT quan sát đang góp phần đáng kể vào thời gian thực hiện của ứng dụng. Tuy nhiên hoạt động của bộ biên dịch JIT tự nó không phải là nguồn duy nhất của các biến đổi bất ngờ trong các việc định thời mức ứng dụng. Do các kỹ sư biên dịch JIT đã hầu như chỉ tập trung vào hiệu năng ca trung bình (average-case performance) để cải thiện hiệu năng thông lượng và thời gian chờ một cách hiệu quả nhất, các bộ biên dịch JIT thường thực hiện một loạt các tối ưu hoá mà “thường” là đúng hoặc “chủ yếu” là hiệu năng cao. Trong trường hợp chung, các tối ưu hoá này vô cùng hiệu quả, và kinh nghiệm được phát triển, thực hiện một công việc ráp nối sự tối ưu hoá khá tốt với các tình huống phổ biến nhất khi một ứng dụng đang chạy. Tuy nhiên, trong một số trường hợp thì các tối ưu hoá như vậy có thể đưa ra nhiều mức thay đổi hiệu năng.

Ngoài việc nạp trước tất cả các lớp, bạn cũng có thể yêu cầu bộ biên dịch JIT biên dịch hiện các phương thức của các lớp đó khi khởi tạo ứng dụng. Liệt kê 4 mở rộng bộ mã nạp trước lớp trong Liệt kê 2 để điều khiển việc biên dịch phương thức:
Liệt kê 4. Biên dịch phương thức được điều khiển

Iterator<String> classIt = listOfClassNamesToLoad.iterator();
while (classIt.hasNext()) {
String className = classIt.next();
try {
Class clazz = Class.forName(className);
String n = clazz.name();
java.lang.Compiler.compileClass(clazz);
} catch (Exception e) {
System.err.println("Could not load class: " + className);
System.err.println(e);
}
}
java.lang.Compiler.disable(); // optional

Bộ mã này làm cho một tập hợp các lớp được nạp và các phương thức của tất cả các lớp được biên dịch bởi bộ biên dịch JIT. Dòng cuối cùng vô hiệu hoá bộ biên dịch JIT đối với phần còn lại của khai thác ứng dụng.

Cách tiếp cận này nói chung làm cho thông lượng tổng thể hoặc hiệu năng thời gian chờ thấp hơn so với việc bộ biên dịch JIT hoàn toàn tự do chọn ra các phương thức sẽ được biên dịch. Do các phương thức không được gọi trước khi bộ biên dịch JIT chạy — bộ biên dịch JIT có ít thông tin hơn nhiều về mức độ tối ưu hóa các phương thức mà nó biên dịch; nên mong chờ các phương thức này thực hiện chậm hơn. Ngoài ra, vì bộ biên dịch bị vô hiệu hóa, không phương thức nào sẽ được biên dịch lại ngay cả khi chúng chịu trách nhiệm về phần lớn thời gian khai thác của chương trình, nên các khung làm việc biên dịch JIT có khả năng thích nghi như các khung được sử dụng trong hầu hết các JVM hiện đại nhất sẽ không hoạt động. LệnhCompiler.disable() không hoàn toàn cần thiết để giảm bớt một số lớn các đoạn dừng gây-ra-bởi-bộ-biên-dịch-JIT, nhưng các đoạn dừng vẫn còn sẽ vẫn cần dịch trên các phương thức nóng của ứng dụng, thường đòi hỏi các thời gian biên dịch lâu hơn với tác động tiềm tàng cao hơn lên các định thời ứng dụng. Bộ biên dịch JIT trong một JVM riêng không thể được bỏ nạp khi phương thức disable() được gọi, nên có thể vẫn còn bộ nhớ được tiêu thụ, các thư viện được chia sẻ được nạp, và các tạo tác khác của bộ biên dịch JIT có mặt trong giai đoạn thời gian chạy của chương trình ứng dụng.

Mức độ tác động của biên dịch mã riêng đến hiệu năng của ứng dụng thay đổi theo ứng dụng. Cách tiếp cận tốt nhất của bạn để xem việc biên dịch có thể là vấn đề hay không là bật đầu ra rườm rà lên, cho biết khi nào các biên dịch xảy ra để xem chúng có thể ảnh hưởng đến các định thời ứng dụng của bạn hay không. Ví dụ, với JVM Thời gian Thực WebSphere của IBM, bạn có thể bật ghi nhật ký rườm rà JIT với dòng lệnh tuỳ chọn -Xjit:verbose.

Ngoài cách tiếp cận nạp trước và biên tập sớm này, không có cách nào một người viết ứng dụng có thể tránh các đoạn dừng do bộ biên dịch JIT tạo nên, làm ngắn việc dùng tuỳ chọn dòng lệnh bộ biên dịch JIT dành riêng cho nhà cung cấp bên ngoài — một cách tiếp cận rủi ro. Các nhà cung cấp JVM hiếm khi hỗ trợ các tuỳ chọn này trong các kịch bản sản xuất. Vì chúng không phải là các cấu hình mặc định, chúng được kiểm thử kém cẩn thận hơn bởi các nhà cung cấp, và chúng có thể thay đổi về cả tên và ý nghĩa từ bản phát hành này đến bản tiếp theo.

Tuy nhiên, một số JVM thay thế có thể cung cấp một vài tuỳ chọn cho bạn, tuỳ thuộc vào mức độ quan trọng các đoạn dừng gây-ra-bởi-bộ-biên-dịch-JIT cho bạn. Các JVM thời gian thực được thiết kế để sử dụng trong các hệ thống Java thời gian thực cứng nhìn chung cung cấp nhiều tuỳ chọn hơn. Ví dụ thời gian Thực WebSphere của IBM dùng cho JVM Linux® Thời gian Thực có 5 chiến lược biên dịch-mã sẵn có để dùng với việc thay đổi khả năng giảm bớt các đoạn dừng bộ biên dịch JIT:
  • Biên dịch JIT mặc định, nhờ đó xử lí bộ biên dịch JIT chạy ở mức ưu tiên thấp.

  • Biên dịch JIT mặc định ở mức ưu tiên thấp với bộ mã được biên dịch Đi-trước-thời-gian (Ahead-of-time - AOT) được dùng khởi đầu.

  • Dịch điều khiển theo chương trình (Program-controlled compilation) vào lúc khởi động với việc biên tập lại được kích hoạt.

  • Dịch điều khiển theo chương trình vào lúc khởi động với việc biên dịch lại bị vô hiệu hoá.

  • Chỉ bộ mã được-AOT-biên dịch.
Các tuỳ chọn này được lên danh sách nói chung theo thứ tự giảm dần của mức được mong đợi về hiệu năng thông lượng/thời gian chờ và số lần dừng dự kiến. Như vậy tuỳ chọn biên dịch JIT mặc định, sử dụng một xử lí biên dịch JIT chạy ở mức ưu tiên thấp nhất (mà có thể thấp hơn các xử lí ứng dụng), cung cấp hiệu năng thông lượng mong đợi cao nhất nhưng cũng được mong đợi thể hiện các đoạn dừng lớn nhất do biên dịch JIT (của 5 tuỳ chọn này.) Hai tuỳ chọn đầu tiên sử dụng biên dịch không đồng bộ, nghĩa là một xử lí ứng dụng mà cố gắng gọi một phương thức mà đã được chọn để biên dịch (lại) không cần đợi đến khi việc biên dịch hoàn tất. Tuỳ chọn cuối cùng có hiệu năng thông lượng/thời gian chờ mong đợi nhưng không có đoạn dừng nào từ bộ biên dịch JIT vì bộ biên dịch JIT bị vô hiệu hoá hoàn toàn theo kịch bản này.

Thời gian Thực WebSphere của IBM dùng cho JVM Linux® Thời gian Thực cung cấp một công cụ với tên admincache cho phép bạn tạo ra bộ nhớ nhanh cho lớp dùng chung chứa các tệp lớp từ một tập hợp các tệp JAR và, theo tùy chọn, lưu giữ bộ mã được-AOT-biên dịch đối với các lớp đó trong cùng một bộ nhớ nhanh. Bạn có thể thiết đặt một tuỳ chọn trong dòng lệnh java của bạn mà làm cho các lớp được lưu lại trong bộ nhớ nhanh lớp được chia sẻ sẽ được nạp từ bộ nhớ nhanh và mã AOT sẽ tự động được nạp vào JVM khi lớp được nạp. Một vòng lặp nạp trước lớp như vòng lặp trong Liệt kê 2 là tất cả các thứ được yêu cầu để đảm bảo cho bạn nhận được lợi ích đầy đủ của bộ mã được-AOT-biên dịch. Xem phần Tài nguyên để có một liên kết đến tư liệuadmincache.

Quản lý các xử lí

Việc điều khiển khai thác của các xử lí trong một ứng dụng đa xử lí chẳng hạn máy chủ giao dịch là cốt yếu đối với việc loại bỏ độ đa dạng trong các lần giao dịch. Mặc dù ngôn ngữ lập trình Java định nghĩa một mô hình xử lí, có ý niệm về các ưu tiên xử lí, hành vi của các xử lí trong một JVM thực phần lớn được định nghĩa bằng sự cài đặt với một số nguyên tắc mà một chương trình Java có thể dựa vào. Thí dụ, mặc dù các xử lí Java có thể được chỉ định 1 trong số 10 ưu tiên xử lí, việc ánh xạ của các quyền ưu tiên mức-ứng-dụng đó cho các giá trị ưu tiên hệ điều hành được định nghĩa bằng cài đặt. (Hoàn toàn hợp lệ đối với một JVM khi ánh xạ tất cả các ưu tiên xử lí Java lên cùng một giá trị ưu tiên hệ điều hành.) Chuẩn bị cho điều đó, chính sách lên lịch cho các xử lí Java cũng được định nghĩa bằng cài đặt nhưng thường kết thúc việc bị chia cắt thời gian để ngay cả các xử lí ưu-tiên-cao kết thúc việc chia sẻ các tài nguyên bộ xử lý trung tâm với các xử lí ưu-tiên-thấp-hơn. Việc chia sẻ các tài nguyên với các xử lí ưu-tiên-thấp-hơn có thể làm cho các xử lí ưu-tiên-cao phải trễ khi chúng được lên lịch sao cho các tác vụ khác có thể nhận được một lát cắt thời gian. Hãy nhớ rằng khối lượng bộ xử lý trung tâm mà một xử lí trở nên phụ thuộc không những về quyền ưu tiên mà còn về tổng số các xử lí mà cần được lên lịch. Nếu bạn không thể kiểm soát nghiêm ngặt có bao nhiêu xử lí là hoạt động vào bất kỳ thời gian nào cho trước, thì thời gian nó sử dụng ngay cả các xử lí ưu-tiên-cao-nhất của bạn để thực hiện một phép toán có lẽ rơi vào một phạm vi tương đối lớn.

Như vậy ngay cả khi bạn quy định quyền ưu tiên xử lí Java cao nhất (java.lang.Thread.MAX_PRIORITY) cho các xử lí lao động của bạn, nó có lẽ không đảm bảo nhiều cô lập với các tác vụ ưu-tiên–thấp-hơn trên hệ thống. Đáng tiếc là, trừ việc sử dụng một tập hợp cố định các xử lí làm việc (đừng tiếp tục phân bổ các xử lí mới khi dựa vào GC để thu thập các xử lí chưa dùng đến, hoặc phát triển lên và thu nhỏ lại bộ trữ xử lí của bạn) và cố gắng giảm thiểu số lượng các hoạt động ưu-tiên-thấp-hơn trên hệ thống trong lúc ứng dụng của bạn chạy, có lẽ bạn không thể làm được gì nhiều hơn vì mô hình xử lí Java chuẩn không cung cấp các công cụ cần thiết để điều khiển hành vi xử lí. Ngay cả một JVM thời gian thực mềm, nếu nó dựa trên mô hình xử lí Java chuẩn, thường không thể cung cấp được nhiều trợ giúp ở đây.

Một JVM thời gian thực cứng mà hỗ trợ Đặc tả Thời gian Thực cho Java (RTSJ), tuy nhiên — chẳng hạn như Thời gian Thực WebSphere của IBM dùng cho Linux® Thời gian Thực V2.0 hoặc RTS 2 của Sun — có thể cung cấp một hành vi xử lí được cải thiện một cách rõ rệt trên Java chuẩn. Trong số các cải tiến của nó về ngôn ngữ Java chuẩn và các đặc tả VM, RTSJ đưa ra hai kiểu xử lí mới, RealtimeThread và NoHeapRealtimeThread, nó được định nghĩa nghiêm túc hơn nhiều so với mô hình xử lí Java chuẩn. Các loại xử lí này đưa ra việc lên lịch dựa trên quyền ưu tiên trước: Nếu một tác vụ ưu-tiên-cao cần thực hiện và một tác vụ ưu-tiên-thấp-hơn hiện thời được lên lịch trên một lõi bộ xử lý, thì tác vụ ưu-tiên-thấp-hơn có quyền để tác vụ ưu-tiên-cao có thể thực hiện.

Phần lớn các hệ điều hành thời gian thực có thể thực hiện quyền ưu tiên trước này trên thứ tự hàng chục micro-giây, nó chỉ ảnh hưởng đến các ứng dụng với các yêu cầu định thời vô cùng nhạy. Cả hai kiểu xử lí mới cũng thường sử dụng một chính sách lập lịch biểu FIFO (vào trước, ra trước) chứ không phải kiểu lập lịch biểu luân chuyển quen thuộc (round-robin scheduling) do các JVM sử dụng chạy trên hầu hết các hệ điều hành. Sự khác biệt rõ nhất giữa chính sách lập lịch biểu luân chuyển và chính sách lập lịch biểu FIFO là ở chỗ, trong số các xử lí của cùng quyền ưu tiên, một khi được lên lịch biểu một xử lí tiếp tục thực hiện cho đến khi nó chặn lại hoặc tự nguyện giải phóng bộ xử lý. Ưu điểm của mô hình này là thời gian để thực hiện một tác vụ riêng có thể đoán trước được nhiều hơn do bộ xử lý không bị chia sẻ, ngay cả khi có một số tác vụ với cùng quyền ưu tiên. Chuẩn bị cho điều đó, nếu bạn giữ xử lí đó tránh bị khóa nhờ xóa bỏ đồng bộ và hoạt động nhập/xuất, hệ điều hành sẽ không can thiệp với tác vụ khi nó khởi động. Tuy nhiên, Trên thực tế, việc loại bỏ tất cả các đồng bộ hoá là vô cùng khó khăn, nên có thể khó đạt được lý tưởng này đối với các tác vụ thực tế. Dù sao thì việc lên lịch biểu FIFO cũng đưa ra một sự trợ giúp quan trọng cho một người thiết kế ứng dụng cố gắng để khắc phục trễ.

Bạn có thể nghĩ tới RTSJ như một chiếc hộp lớn đựng các công cụ mà có thể giúp bạn thiết kế các ứng dụng với hành vi thời gian thực; có thể chỉ sử dụng một vài công cụ hoặc có thể viết lại hoàn toàn ứng dụng của bạn để đảm bảo hiệu năng dự tính trước. Thường không khó sửa đổi ứng dụng của bạn để sử dụng các RealtimeThread (xử lí Thời gian thực), và bạn có thể thực hiện điều đó thậm chí không cần có quyền truy cập đến một JVM thời gian thực để biên dịch mã Java của bạn, thông qua việc sử dụng cẩn thận các dịch vụ phản chiếu Java.

Việc tận dụng các lợi ích thay đổi của việc lập lịch biểu FIFO, tất nhiên có thể đòi hỏi thay đổi nhiều đối với ứng dụng của bạn. Việc lập lịch biểu FIFO đối xử khác nhau từ việc lập lịch biểu luân chuyển, và các sự khác biệt có thể làm treo máy trong một số chương trình Java. Thí dụ, nếu ứng dụng của bạn dựa vào Thread.yield() để cho phép các xử lí khác chạy trên một lõi — một kỹ thuật thường sử dụng để thăm dò đối với một số điều kiện mà không cần sử dụng một lõi đầy đủ để làm điều nó — thì hiệu quả mong đợi sẽ không xảy ra, vì với việc lập lịch biểu, Thread.yield() không ngăn chặn xử lí hiện tại. Vì xử lí hiện tại bảo trì được tính có thể lập lịch biểu và nó đã là xử lí từ trước tại phía trước của hàng đợi lập lịch biểu trong lõi hệ điều hành, nó sẽ chỉ cần tiếp tục thực hiện. Như vậy một mô hình mã hoá dự định để cung cấp quyền truy cập công bằng đến các tài nguyên bộ xử lý trung tâm khi đợi một điều kiện để trở thành hiện thực trên thực tế tiêu thụ 100% bất kỳ lõi bộ xử lý trung tâm nào mà nó ngẫu nhiên bắt đầu chạy trên đó. Và đó là kết quả khả dĩ tốt nhất. Nếu xử lí cần thiết lập điều kiện với quyền ưu tiên thấp hơn, thì nó không bao giờ có thể nhận được quyền truy cập đến một lõi để thiết lập điều kiện. Với các bộ xử lý nhiều lõi ngày nay, vấn đề này có lẽ ít khả năng xảy ra, nhưng nó nhấn mạnh rằng bạn cần phải suy nghĩ cẩn thận về việc bạn sử dụng quyền ưu tiên nào nếu bạn sử dụng các RealtimeThread. Cách tiếp cận an toàn nhất là làm cho tất cả các xử lí sử dụng một giá trị ưu tiên đơn lẻ và loại bỏ việc sử dụng Thread.yield() và các loại vòng lặp quay vòng khác mà sẽ tiêu thụ hết một bộ xử lý trung tâm vì chúng không bao giờ ngăn chặn. Dĩ nhiên, việc tận dụng ưu điểm của các giá trị ưu tiên sẵn có cho các RealtimeThread sẽ cho bạn cơ hội tốt nhất đáp ứng các mục tiêu chất lượng dịch vụ của bạn. (Để có nhiều mách nước hơn về việc sử dụng cácRealtimeThread trong ứng dụng của bạn, xin xem "Java thời gian thực, Phần 3: Xử lí và đồng bộ hoá.")
 

Về đầu trang

Một thí dụ về máy chủ Java

Trong phần còn lại của bài này, chúng ta sẽ áp dụng một số ý tưởng đưa ra trong các mục trước đây vào một ứng dụng máy chủ Java tương đối đơn giản được xây dựng nên bằng cách sử dụng dịch vụ Executors (Các bộ khai thác) trong Thư viện Lớp Java. Chỉ với một lượng nhỏ bé của mã ứng dụng, dịch vụ Executors cho phép bạn tạo ra một máy chủ quản lý vùng đệm cho các xử lí làm việc, như trong Liệt kê 5:
Liệt kê 5. Các lớp Server và TaskHandler sử dụng dịch vụ Executors 

import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadFactory;

class Server {
private ExecutorService threadPool;
Server(int numThreads) {
ThreadFactory theFactory = new ThreadFactory();
this.threadPool = Executors.newFixedThreadPool(numThreads, theFactory);
}

public void start() {
while (true) {
// main server handling loop, find a task to do
// create a "TaskHandler" object to complete this operation
TaskHandler task = new TaskHandler();
this.threadPool.execute(task);
}
this.threadPool.shutdown();
}

public static void main(String[] args) {
int serverThreads = Integer.parseInt(args[0]);
Server theServer = new Server(serverThreads);
theServer.start();
}
}

class TaskHandler extends Runnable {
public void run() {
// code to handle a "task"
}
}


Máy chủ này tạo ra nhiều nhất các xử lí làm việc cần có, đến mức quy định tối đa khi máy chủ được tạo ra (được giải mã từ dòng lệnh trong thí dụ đặc biệt này). Mỗi xử lí làm việc thực hiện một ít công việc bằng cách sử dụng lớp TaskHandler (Bộ xử lý Tác vụ). Theo mục đích của chúng ta, sẽ tạo ra một phương thức TaskHandler.run(), cần đến một khoảng thời gian tương tự mỗi khi nó chạy. Vậy nên bất kỳ sự biến đổi nào vào lúc đo để thực hiện TaskHandler.run(), phụ thuộc vào các đoạn ngừng hoặc biến đổi trong JVM ẩn, một vấn đề xử lí nào đó, hoặc đoạn ngừng do mức thấp hơn của vùng nhớ dự trữ yêu cầu. Liệt kê 6 hiển thị lớp TaskHandler:
Liệt kê 6. Lớp TaskHandler với hiệu năng có thể đoán trước được

import java.lang.Runnable;
class TaskHandler implements Runnable {
static public int N=50000;
static public int M=100;
static long result=0L;

// constant work per transaction
public void run() {
long dispatchTime = System.nanoTime();
long x=0L;
for (int j=0;j < M;j++) {
for (int i=0;i < N;i++) {
x = x + i;
}
}
result = x;
long endTime = System.nanoTime();
Server.reportTiming(dispatchTime, endTime);
}
}


Các vòng lặp trong phương thức run() này tính toán M (100) lần tổng của các số nguyên N đầu tiên (50,000). Các giá trị của M vàN được chọn sao cho số lần giao dịch trên máy mà chúng ta chạy nó trên khoảng 10 mili-giây đo được để một phép toán đơn lẻ có thể được ngắt bởi một lượng tử lập lịch biểu (mà thường kéo dài khoảng 10 mili-giây). Chúng ta xây dựng nên các vòng lặp trong tính toán này sao cho một bộ biên dịch JIT có thể tạo ra bộ mã tuyệt hảo, thực hiện trong một khoảng thời gian có thể dự đoán được: Phương thức run() không ngăn chặn rõ rệt giữa hai lần gọi đến System.nanoTime() dùng để tính thời gian cho các vòng lặp chạy. Do bộ mã được đo là rất dễ đoán trước được, chúng ta có thể sử dụng nó để hiển thị các tài nguyên quan trọng của các trễ và thay đổi không nhất thiết phải bắt nguồn từ mã bạn đang đo.

Chúng ta hãy làm cho ứng dụng này một chút hiện thực hơn bằng cách buộc hệ thống con bộ thu gom rác được hoạt động khi mã TaskHandler đang chạy. Liệt kê 7 hiển thị lớp GCStressThread này:
Liệt kê 7. Lớp GCStressThread để tạo ra rác liên tục


class GCStressThread extends Thread {
HashMap<Integer,BinaryTree> map;
volatile boolean stop = false;

class BinaryTree {
public BinaryTree left;
public BinaryTree right;
public Long value;
}
private void allocateSomeData(boolean useSleep) {
try {
for (int i=0;i < 125;i++) {
if (useSleep)
Thread.sleep(100);
BinaryTree newTree = createNewTree(15); // create full 15-level BinaryTree
this.map.put(new Integer(i), newTree);
}
} catch (InterruptedException e) {
stop = true;
}
}

public void initialize() {
this.map = new HashMap<Integer,BinaryTree>();
allocateSomeData(false);
System.out.println("\nFinished initializing\n");
}

public void run() {
while (!stop) {
allocateSomeData(true);
}
}
}

GCStressThread bảo trì một tập hợp các BinaryTree qua một HashMap. Nó lặp lại trên cùng một tập hợp các khoá Integer(số nguyên) để HashMap lưu lại các cấu trúc mới BinaryTree, mà chỉ cần được đưa vào đủ BinaryTrees 15-mức. (Như vậy có 215 = 32.768 nút trong mỗi BinaryTree được lưu lại vào HashMap.) HashMap giữ 125 BinaryTree vào bất cứ một lần nào (dữ liệu sống), và cứ 100 mili-giây nó thay thế một trong số chúng với một BinaryTree mới. Với cách này, cấu trúc dữ liệu bảo trì một tập hợp khá phức tạp các đối tượng sống cũng như tạo ra rác với một tốc độ riêng. HashMap đầu tiên được khởi tạo với một tập hợp 125 BinaryTree bằng cách sử dụng chương trình con initialize(), nó không làm phiền đến việc đoạn dừng giữa các cấp phát của từng cây. Khi GCStressThread đã được khởi động (ngay trước khi máy chủ được khởi động) nó hoạt động xuyên qua việc xử lý của các phép toán TaskHandler của các xử lí làm việc của máy chủ.

Chúng ta sẽ không sử dụng một máy khách để điều khiển máy chủ này. Đơn giản là ta sẽ tạo NUM_OPERATIONS == 10000phép toán trực tiếp bên trong vòng lặp chính của máy chủ (trong phương thức Server.start()). Liệt kê 8 trình bày phương thứcServer.start():
Liệt kê 8. Gửi các phép toán vào trong máy chủ

public void start() {
for (int m=0; m < NUM_OPERATIONS;m++) {
TaskHandler task = new TaskHandler();
threadPool.execute(task);
}
try {
while (!serverShutdown) { // boolean set to true when done
Thread.sleep(1000);
}
}
catch (InterruptedException e) {
}
}


Nếu chúng ta thu thập các thống kê về số lần hoàn thành từng lần dẫn ra TaskHandler.run()chúng ta có thể thấy mức độ biến đổi được JVM và thiết kế của ứng dụng đưa vào. Chúng ta đã sử dụng một máy chủ IBM xServer e5440 với 8 lõi vật lý với hệ điều hành thời gian thực Red Hat RHEL MRG (Siêu xử lí bị vô hiệu hóa). Chú ý rằng mặc dù việc siêu phân luồng có thể cung cấp một số cải tiến về thông lượng trong một điểm định chuẩn, vì các lõi ảo của nó là không đầy, hiệu năng lõi vật lý của các phép toán trên các bộ xử lý với siêu phân luồng được kích hoạt có thể có các định thời khác nhau rõ rệt. Khi chúng ta chạy máy chủ này với 6 xử lí trên máy 8-lõi (chúng ta sẽ thoải mái để lại 1 lõi để xử lí chính Server và 1 lõi để GCStressorThread sử dụng) với JVM IBM Java6 SR3, chúng ta nhận được các kết quả (đại diện) sau đây:

$ java -Xms700m -Xmx700m -Xgcpolicy:optthruput Server 6
10000 operations in 16582 ms
Throughput is 603 operations / second
Histogram of operation times:
9ms - 10ms 9942 99 %
10ms - 11ms 2 0 %
11ms - 12ms 32 0 %
30ms - 40ms 4 0 %
70ms - 80ms 1 0 %
200ms - 300ms 6 0 %
400ms - 500ms 6 0 %
500ms - 542ms 6 0 %


Bạn có thể thấy rằng hầu như tất cả các phép toán hoàn tất trong 10 mili-giây, nhưng một số phép toán mất hơn một nửa giây (chậm hơn 50 lần.) Đó đúng là một sự khác nhau! Chúng ta hãy xem cách chúng ta có thể loại bỏ một số thay đổi này bằng cách loại bỏ các trễ xuất hiện do việc nạp lớp Java, Biên dịch mã riêng JIT, GC, và xử lí.

Đầu tiên chúng ta đã thu thập danh sách các lớp được nạp bởi ứng dụng qua việc chạy đầy đủ với -verbose:class. Chúng ta đã lưu lại kết quả vào một tệp và sau đó sửa đổi nó để có một tên được định dạng thích hợp trên mỗi dòng của tệp đó. Chúng ta đã gộp một phương thức preload() vào lớp Server để nạp các lớp, JIT biên dịch tất cả các phương thức của các lớp đó, và sau đó vô hiệu hóa bộ biên dịch JIT, như trình bày trong Liệt kê 9:

Liệt kê 9. Nạp trước các lớp và các phương thức cho máy chủ

private void preload(String classesFileName) {
try {
FileReader fReader = new FileReader(classesFileName);
BufferedReader reader = new BufferedReader(fReader);
String className = reader.readLine();
while (className != null) {
try {
Class clazz = Class.forName(className);
String n = clazz.getName();
Compiler.compileClass(clazz);
} catch (Exception e) {
}
className = reader.readLine();
}
} catch (Exception e) {
}
Compiler.disable();
}


Việc nạp lớp không phải là một vấn đề quan trọng trong máy chủ đơn giản của chúng ta vì phương thức TaskHandler.run()của chúng ta rất đơn giản: một khi lớp nào đó được nạp, việc nạp lớp xảy ra không nhiều sau đó trong khai thác của Server, nó có thể được xác thực bằng cách chạy với -verbose:class. Lợi ích chính thu được từ việc biên dịch các phương thức trước khi chạy bất kỳ phép toán TaskHandler được đo. Mặc dù chúng ta đã có thể sử dụng được một vòng lặp khởi động (warm-up loop), cách tiếp cận này có xu hướng riêng cho JVM vì sự suy nghiệm mà bộ biên dịch JIT sử dụng để chọn ra các phương thức để biên dịch khác với các cài đặt JVM. Việc sử dụng dịch vụ Compiler.compile() đưa vào hoạt động biên dịch có thể điều khiển được nhiều hơn, nhưng như chúng ta đã đề cập trước đó trong bài viết, chúng ta sẽ chờ đợi sự giảm thông lượng khi dùng tiếp cận này. Các kết quả từ việc chạy ứng dụng với các tuỳ chọn này là:

$ java -Xms700m -Xmx700m -Xgcpolicy:optthruput Server 6
10000 operations in 20936 ms
Throughput is 477 operations / second
Histogram of operation times:
11ms - 12ms 9509 95 %
12ms - 13ms 478 4 %
13ms - 14ms 1 0 %
400ms - 500ms 6 0 %
500ms - 527ms 6 0 %


Chú ý rằng mặc dù các trễ dài nhất không thay đổi nhiều, biểu đồ này ngắn hơn nhiều so với ban đầu. Nhiều trễ ngắn hơn thấy được ngay từ bộ biên dịch JIT nên việc thực hiện các biên dịch trước đó và sau đó vô hiệu hoá bộ biên dịch JIT rõ ràng là một bước tiến. Một sự nhận xét thú vị khác là số thời gian hoạt động chung đã chiếm thời gian lâu hơn một chút (từ khoảng 9 đến 10 mili-giây, đến 11-12 mili-giây). Các phép toán đã bị chậm lại do chất lượng của mã được tạo ra bởi một biên dịch JIT bị áp đặt trước khi gọi các phương thức đó thường thấp hơn các phương thức của bộ mã được áp dụng đầy đủ. Đó không phải là một kết quả bất ngờ, vì một trong những lợi thế to lớn của bộ biên dịch JIT là khai thác các đặc tính động của ứng dụng đang chạy để làm cho nó chạy hiệu quả hơn.

Chúng ta sẽ tiếp tục sử dụng bộ mã nạp trước lớp này và biên dịch trước phương thức này trong phần còn lại của bài viết.

Vì GCStressThread của chúng ta gây nên thay đổi đều đặn tập hợp dữ liệu sống, việc sử dụng chính sách GC sản sinh là không mong muốn, để đảm bảo lợi ích nhiều thời gian đoạn dừng hơn. Thay vào đó, chúng ta đã thử bộ gom rác thời gian thực trong sản phẩm Thời gian Thực WebSphere của IBM dùng cho Linux Thời gian Thực V2.0 SR1. Các kết quả ban đầu thật thất vọng, thậm chí sau khi chúng ta đã bổ sung tuỳ chọn -Xgcthreads8, cho phép bộ gom sử dụng 8 xử lí GC chứ không phải xử lí đơn lẻ mặc định. (Bộ gom không thể theo kịp một cách tin cậy tốc độ phân bổ của ứng dụng này với chỉ một xử lí GC đơn lẻ.)

$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
10000 operations in 72024 ms
Throughput is 138 operations / second
Histogram of operation times:
11ms - 12ms 82 0 %
12ms - 13ms 250 2 %
13ms - 14ms 19 0 %
14ms - 15ms 50 0 %
15ms - 16ms 339 3 %
16ms - 17ms 889 8 %
17ms - 18ms 730 7 %
18ms - 19ms 411 4 %
19ms - 20ms 287 2 %
20ms - 30ms 1051 10 %
30ms - 40ms 504 5 %
40ms - 50ms 846 8 %
50ms - 60ms 1168 11 %
60ms - 70ms 1434 14 %
70ms - 80ms 980 9 %
80ms - 90ms 349 3 %
90ms - 100ms 28 0 %
100ms - 112ms 7 0 %


Việc sử dụng thời gian thực bộ gom đã giảm bớt thời gian phép toán tối đa một cách đáng kể, nhưng nó cũng tăng thêm thời gian phép toán kéo dài. Tồi hơn nữa, tốc độ truyền thông giảm đáng kể.

Bước cuối cùng là sử dụng các RealtimeThread — chứ không phải là các xử lí Java thông thường — đối với các xử lí làm việc. Chúng ta đã tạo ra một lớp RealtimeThreadFactory cho phép chúng ta có thể cung cấp cho dịch vụ Executors, như trong Liệt kê 10:
Liệt kê 10. Lớp RealtimeThreadFactory 

import java.util.concurrent.ThreadFactory;
import javax.realtime.PriorityScheduler;
import javax.realtime.RealtimeThread;
import javax.realtime.Scheduler;
import javax.realtime.PriorityParameters;

class RealtimeThreadFactory implements ThreadFactory {
public Thread newThread(Runnable r) {
RealtimeThread rtThread = new RealtimeThread(null, null, null, null, null, r);

// adjust parameters as needed
PriorityParameters pp = (PriorityParameters) rtThread.getSchedulingParameters();
PriorityScheduler scheduler = PriorityScheduler.instance();
pp.setPriority(scheduler.getMaxPriority());

return rtThread;
}
}

Việc vượt qua một cá thể của lớp RealtimeThreadFactory đến dịch vụ Executors.newFixedThreadPool() làm cho các xử lí làm việc là các RealtimeThread bằng cách sử dụng việc lập lịch biểu FIFO với quyền ưu tiên cao nhất sẵn có. Bộ gom rác sẽ vẫn còn ngắt các xử lí này khi nó cần thực hiện công việc, nhưng không có tác vụ ưu-tiên-thấp-hơn nào khác sẽ can thiệp với các xử lí làm việc:

$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
Handled 10000 operations in 27975 ms
Throughput is 357 operations / second
Histogram of operation times:
11ms - 12ms 159 1 %
12ms - 13ms 61 0 %
13ms - 14ms 17 0 %
14ms - 15ms 63 0 %
15ms - 16ms 1613 16 %
16ms - 17ms 4249 42 %
17ms - 18ms 2862 28 %
18ms - 19ms 975 9 %
19ms - 20ms 1 0 %

Với thay đổi cuối cùng này, chúng ta cải thiện đáng kể cả thời gian phép toán xấu nhất (xuống chỉ còn 19 mili-giây) cũng như là thông lượng tổng thể (lên đến 357 phép toán mỗi giây). Như vậy chúng ta đã cải thiện đáng kể theo độ đa dạng của các thời gian phép toán, nhưng chúng ta đã trả một giá khá đắt về hiệu năng thông lượng. Phép toán của bộ gom rác, sử dụng đến 3 mili-giây cứ mỗi 10 mili-giây, giải thích tại sao một phép toán mà thường mất khoảng 12 mili-giây có thể được kéo dài đến 4 - 5 mili-giây, đó là lý do số lượng lớn các phép toán bây giờ lại mất khoảng 16 - 17 mili-giây. Sự cắt giảm thông lượng gần như chắc chắn nhiều hơn bạn mong đợi vì JVM thời gian thực, ngoài việc sử dụng bộ gom rác thời gian thực Metronome, cũng đã sửa đổi việc khoá các sơ khởi (primitives) cho phép chống được việc đảo ngược quyền ưu tiên, một vấn đề quan trọng khi việc lập lịch biểu FIFO được dùng (xem "Java thời gian thực, Phần 1: Sử dụng mã Java để lập trình các hệ thống thời gian thực"). Đáng tiếc là, việc đồng bộ hoá giữa xử lí chủ (master thread) và các xử lí làm việc tạo nên nhiều gia tăng ảnh hưởng về thông lượng, mặc dù nó không được đo đạc như là một bộ phận của bất kỳ thời gian phép toán (nên nó không thể hiện trong biểu đồ).

Như vậy khi máy chủ của chúng ta được lợi từ các sửa đổi được thực hiện để cải thiện khả năng dự đoán, chắc chắn nó trải qua một sự giảm thông lượng khá lớn. Dù sao, nếu vài phép toán chạy lâu khó tin nổi, ứng với mức độ chất lượng không thể chấp nhận được, thì sử dụng các RealtimeThread với một JVM thời gian thực có thể chính là giải pháp đúng.

Tóm lược

Trong thế giới của các ứng dụng Java, thông lượng và thời gian chờ theo truyền thống đã trở thành các độ đo được ứng dụng chọn và các nhà thiết kế băng chuẩn để báo cáo và tối ưu hoá. Lựa chọn này đã có một tác động rộng khắp lên sự tiến hoá của các thời gian chạy Java được xây dựng nên để cải thiện hiệu năng. Mặc dù các thời gian chạy Java được khởi động như là các phiên dịch viên với thời gian chờ chạy và thông lượng vô cùng chậm, các JVM hiện đại có thể cạnh tranh tốt với các ngôn ngữ khác về các thước đo này đối với nhiều ứng dụng. Mặc dù cho đến thời kỳ tương đối gần đây cũng không thể được nói giống như vậy về một số thước đo khác mà có thể có một ảnh hưởng lớn về một hiệu năng nhận biết của ứng dụng — nhất là độ đa dạng, mà ảnh hưởng đến chất lượng dịch vụ.

Việc đưa ra Java thời gian thực đã cho các nhà thiết kế ứng dụng các công cụ mà họ cần phải nhắm đến nguồn thay đổi trong một JVM và trong các ứng dụng của họ để cung cấp chất lượng dịch vụ mà các khách hàng và tác nhân tiêu thụ của họ chờ đợi. Bài viết này đã giới thiệu một số kỹ thuật mà bạn có thể sử dụng để sửa đổi một ứng dụng Java để giảm bớt các đoạn dừng và sự thay đổi mà bắt nguồn từ JVM và từ việc lập lịch biểu xử lí. Việc giảm bớt sự thay đổi thường gây ra việc cắt bớt về thời gian chờ và hiệu năng thông lượng. Mức độ theo đó việc cắt bỏ có thể chấp nhận được xác định các công cụ nào là thích hợp cho một ứng dụng đặc thù.

Post a Comment

Mới hơn Cũ hơn