Thứ 2 - Chủ Nhật, 8h - 21h.
Hãy gọi: 0906 12 56 56

Thời gian chạy của Java sử dụng bộ nhớ riêng như thế nào

Thời gian chạy của Java là một tiến trình hệ điều hành chịu các ràng buộc của phần cứng và hệ điều hành mà tôi nêu trong phần trước. Các môi trường thời gian chạy cung cấp các khả năng theo đòi hỏi của mã của người sử dụng còn chưa biết; điều này làm cho không thể dự đoán môi trường thời gian chạy sẽ đòi hỏi tài nguyên nào trong mỗi tình huống. Mỗi hành động mà một ứng dụng Java thực hiện bên trong môi trường Java được quản lý đều có khả năng có thể ảnh hưởng đến các yêu cầu tài nguyên của thời gian chạy cung cấp môi trường đó. Phần này mô tả các ứng dụng Java tiêu dùng bộ nhớ riêng như thế nào và tại sao.

Vùng heap của Java và việc thu dọn dữ liệu rác

 


Vùng heap của Java là vùng bộ nhớ mà các đối tượng được cấp phát ở đó. Hầu hết các triển khai thực hiện Java SE có một vùng heap lôgic, mặc dù một số thời gian chạy Java chuyên biệt, ví dụ như là triển khai thực hiện Đặc tả thời gian thực cho Java (Real Time Specification for Java -RTSJ) có nhiều vùng heap. Một vùng heap vật lý có thể được chia một cách lô gic thành các phần tùy thuộc vào thuật toán thu dọn dữ liệu rác (GC) được sử dụng để quản lý bộ nhớ của vùng heap. Những phần này thường được triển khai thực hiện như các ô nhớ liền khối của bộ nhớ riêng dưới sự kiểm soát của trình quản lý bộ nhớ Java (bao gồm các bộ thu dọn dữ liệu rác).

 


Kích thước của vùng heap được điều khiển từ dòng lệnh Java bằng cách sử dụng các tuỳ chọn -Xmx và -Xms (mx là kích thước tối đa của vùng heap, ms là kích thước ban đầu). Mặc dù vùng heap lô-gic (vùng bộ nhớ được sử dụng thực sự) có thể tăng lên và thu nhỏ theo số lượng các đối tượng trên vùng heap và thời gian dành cho GC, dung lượng bộ nhớ riêng được sử dụng vẫn không đổi và được quyết định bởi giá trị -Xmx: kích thước vùng heap tối đa. Hầu hết các thuật toán GC dựa trên vùng heap đang được cấp phát như một dãy ô nhớ liền khối của bộ nhớ, do đó không thể cấp phát thêm nhiều bộ nhớ riêng khi vùng heap cần mở rộng. Tất cả bộ nhớ của vùng heap phải được dự trữ trước.

 


Việc dự trữ bộ nhớ riêng không giống như việc cấp phát nó. Khi bộ nhớ riêng được dự trữ, nó không được hậu thuẫn bởi bộ nhớ vật lý hoặc thiết bị lưu trữ khác. Mặc dù việc dự trữ các đoạn của vùng địa chỉ sẽ không làm cạn kiệt tài nguyên vật lý, nhưng nó ngăn cản không cho bộ nhớ đó được sử dụng cho các mục đích khác. Lỗ rò do việc dự trữ bộ nhớ gây ra vì không bao giờ được sử dụng cũng nghiêm trọng không kém lỗ rò bộ nhớ được cấp phát.

 


Một số bộ thu gom dữ liệu rác giảm thiểu việc sử dụng bộ nhớ vật lý bằng cách không chuyển giao (giải phóng thiết bị lưu trữ phía sau cho) các phần của vùng heap khi mà vùng heap sử dụng bị thu nhỏ.

 


Thêm bộ nhớ riêng là cần thiết để duy trì trạng thái của hệ thống quản lý bộ nhớ đang duy trì vùng heap của Java. Các cấu trúc dữ liệu phải được phân phối để theo dõi thiết bị lưu trữ chưa sử dụng và ghi lại tiến trình khi thu dọn dữ liệu rác. Kích thước chính xác và bản chất của các cấu trúc dữ liệu ấy thay đổi tùy từng triển khai thực hiện, nhưng phần nhiều là tỉ lệ thuận với kích thước của vùng heap.

 


Trình biên dịch tức thời (JIT)

 

Trình biên dịch tức thời JIT biên dịch bytecode của Java thành mã thực thi riêng được tối ưu hóa trong thời gian chạy. Điều này cải thiện rất nhiều tốc độ thời gian-chạy của các thời gian chạy của Java và cho phép các ứng dụng Java chạy ở các tốc độ so sánh được với mã riêng.

 


Việc biên dịch Bytecode sử dụng bộ nhớ riêng (giống như cách mà một trình biên dịch tĩnh như là gcc đòi hỏi bộ nhớ để chạy), nhưng cả đầu vào (bytecode) lẫn đầu ra (mã thực thi) từ JIT cũng phải được lưu trữ trong bộ nhớ riêng. Các ứng dụng Java có chứa nhiều phương thức được biên dịch tức thời (JIT) sử dụng nhiều bộ nhớ riêng hơn các ứng dụng nhỏ hơn.

 


Các lớp và các trình nạp lớp (classloader)

 

Các ứng dụng Java gồm có các lớp định nghĩa cấu trúc đối tượng và logic phương thức. Chúng cũng sử dụng các lớp từ các thư viện lớp thời gian chạy Java (như java.lang.String) và có thể sử dụng các thư viện của bên thứ ba. Các lớp này cần phải được lưu trữ trong bộ nhớ khi mà chúng được sử dụng.


Theo cách triển khai thực hiện các lớp được lưu trữ thay đổi như thế nào. Sun JDK sử dụng vùng heap được tạo ra cố định (PermGen). Việc thực hiện của IBM từ Java 5 trở đi cấp phát dãy ô nhớ của bộ nhớ riêng cho mỗi một trình nạp lớp (classloader) và lưu trữ dữ liệu lớp trong đó. Thời gian chạy Java hiện đại có các công nghệ như việc dùng chung lớp có thể yêu cầu ánh xạ các vùng bộ nhớ dùng chung vào trong vùng địa chỉ. Để hiểu cách các cơ chế cấp phát này ảnh hưởng đến dấu vết riêng của thời gian chạy Java của bạn như thế nào, bạn cần phải đọc tài liệu kỹ thuật về việc triển khai thực hiện đó. Tuy nhiên, một số sự kiện phổ biến ảnh hưởng đến tất cả các việc thực hiện.

 


Ở mức độ cơ sở nhất, việc sử dụng càng nhiều lớp hơn thì càng sử dụng nhiều bộ nhớ hơn. (Điều này có thể có nghĩa là việc sử dụng bộ nhớ riêng của bạn tăng lên hoặc bạn phải thay đổi kích thước một vùng một cách rõ ràng — chẳng hạn như PermGen hoặc bộ nhớ sẵn (cache) của lớp-dùng chung — để cho phép chứa hết tất cả các lớp). Hãy nhớ rằng không chỉ cần chứa hết ứng dụng của bạn; mà các khung công tác, các máy chủ ứng dụng, các thư viện của bên thứ ba và thời gian chạy của Java đều chứa các lớp được nạp theo yêu cầu và chiếm vùng nhớ.

 


Thời gian chạy của Java có thể giải phóng các lớp để lấy lại vùng nhớ, nhưng chỉ trong những điều kiện nghiêm ngặt. Không thể chỉ giải phóng một lớp đơn lẻ; thay vào đó các trình nạp lớp được giải phóng và mang theo tất cả các lớp mà chúng đã nạp. Một trình nạp lớp có thể được giải phóng chỉ khi:

 

 

  • Vùng heap của Java không chứa tham chiếu tới đối tượng java.lang.ClassLoader đại diện cho trình nạp lớp đó.
  • Vùng heap của Java không chứa tham chiếu tới bất cứ các đối tượng java.lang.Class đại diện cho các lớp được nạp bởi trình nạp lớp đó.
  • Không có đối tượng nào của lớp bất kỳ được trình nạp lớp đó nạp vào đang còn hoạt động (được tham chiếu) trên vùng heap của Java.

 


Cần lưu ý rằng trong ba trình nạp lớp mặc định do thời gian chạy Java tạo ra cho tất cả các ứng dụng Java — bootstrap (tự mồi), extension (phần mở rộng) và application (ứng dụng)— có thể không bao giờ đáp ứng các tiêu chí này; do vậy, các lớp hệ thống bất kỳ (như java.lang.String) hoặc các lớp ứng dụng bất kỳ được nạp qua trình nạp lớp của ứng dụng không thể được giải phóng trong thời gian chạy.

 


Ngay cả khi một trình nạp lớp đủ điều kiện bị thu gom, thời gian chạy thu gom các trình nạp lớp chỉ như là một phần của một chu kỳ GC. Một số triển khai thực hiện chỉ giải phóng các trình nạp lớp trong một số chu kỳ GC nào đó.
Cũng có khả năng các lớp được tạo ra trong thời gian chạy, mà bạn không biết điều đó. Nhiều ứng dụng JEE sử dụng công nghệ JavaServer Pages (JSP) để sản xuất các trang Web. Việc sử dụng JSP sẽ tạo ra một lớp cho mỗi trang .jsp được thi hành sẽ tồn tại suốt vòng đời của trình nạp lớp đã nạp chúng — vòng đời tiêu biểu của ứng dụng Web.

 


Một cách phổ biến khác để tạo ra các lớp là sử dụng sự phản chiếu của Java. Cách thức sự phản chiếu Java hoạt động thay đổi theo các việc triển khai thực hiện Java, nhưng cả hai việc triển khai thực hiện của Sun và IBM đều sử dụng phương thức mà tôi sẽ mô tả bây giờ.

 


Khi sử dụng API java.lang.reflect, thời gian chạy Java phải kết nối các phương thức của một đối tượng phản chiếu java.lang.reflect.Field) đến đối tượng hoặc lớp được phản chiếu tới. Điều này có thể được thực hiện bằng cách sử dụng trình truy cập (accessor) của Giao diện riêng của Java (Java Native Interface-JNI), JNI đòi hỏi phải thiết lập rất ít, nhưng lại rất chậm khi chạy, hoặc bằng cách xây dựng một lớp động trong thời gian chạy cho từng kiểu đối tượng mà bạn muốn phản chiếu tới. Phương thức sau thiết lập chậm hơn, nhưng lại chạy nhanh hơn, và là lý tưởng cho các ứng dụng thường xuyên phải phản chiếu đến một lớp cụ thể.

 


Thời gian chạy Java sử dụng phương thức JNI vài lần đầu tiên khi một lớp được phản chiếu, nhưng sau khi được sử dụng một số lần, trình truy cập lớn lên thành một trình truy cập (accessor) bytecode, bao gồm việc xây dựng một lớp và nạp nó nhờ một trình nạp lớp mới. Việc thực hiện nhiều sự phản chiếu có thể làm cho phải sinh ra nhiều lớp của trình truy cập và trình nạp lớp. Việc duy trì các tham chiếu đến các đối tượng phản chiếu làm cho các lớp này vẫn hoạt động và tiếp tục chiếm vùng nhớ. Vì việc tạo ra các trình truy cập bytecode khá chậm, nên thời gian chạy Java có thể ghi nhớ sẵn (cache) các trình truy cập này để sử dụng lại sau. Một số ứng dụng và các khung công tác cũng ghi nhớ sẵn các đối tượng phản chiếu, do đó làm tăng dấu vết riêng của chúng.

 


JNI

 

JNI cho phép mã riêng (các ứng dụng được viết bằng ngôn ngữ được biên dịch ban đầu như C và C++) để gọi các phương thức Java và ngược lại. Thời gian chạy Java tự nó dựa chủ yếu vào mã JNI để thực hiện các hàm thư viện-lớp như là tệp và vào/ra (I/O) mạng. Một ứng dụng JNI có thể làm tăng dấu vết riêng của thời gian chạy Java theo ba cách:

 

 

  • Mã riêng cho một ứng dụng JNI được biên dịch thành một thư viện dùng chung hoặc mã có thể chạy được rồi nạp vào vùng địa chỉ tiến trình. Các ứng dụng riêng lớn có thể chiếm một đoạn đáng kể của vùng địa chỉ tiến trình đơn giản ngay khi được nạp.
  • Mã riêng phải dùng chung vùng địa chỉ với thời gian chạy Java. Bất kỳ các việc cấp phát bộ nhớ riêng hay các việc ánh xạ bộ nhớ nào được thực hiện bởi mã riêng đều lấy bộ nhớ từ thời gian chạy Java.
  • Một số hàm JNI nhất định có thể sử dụng bộ nhớ riêng như là một phần hoạt động bình thường của chúng. Các hàm GetTypeArrayElements và GetTypeArrayRegion có thể sao chép dữ liệu của vùng heap của Java vào các bộ đệm của bộ nhớ riêng để cho mã riêng làm việc với chúng. Việc có tạo ra một bản sao chép hay không phụ thuộc vào việc triển khai thực hiện thời gian chạy. (Bộ dụng cụ của nhà phát triển của IBM cho Java 5 – IBM Developer for Java 5.0 - và cao hơn, có tạo ra một bản sao riêng). Việc truy cập một số lượng lớn dữ liệu của vùng heap của Java theo cách này có thể sử dụng một số lượng lớn của vùng heap riêng tương ứng.

 

NIO

 

Các lớp I/O mới (NIO) được bổ sung thêm vào Java 1.4 đã đưa vào một cách làm mới để thực hiện I/O dựa trên các kênh và các bộ đệm. Giống như các bộ đệm I/O được hậu thuẫn bởi bộ nhớ trên vùng heap Java, NIO bổ sung thêm sự hỗ trợ cho các ByteBuffer trực tiếp (được cấp phát bằng cách sử dụng phương thức java.nio.ByteBuffer.allocateDirect() được hậu thuẫn bởi bộ nhớ riêng chứ không phải vùng heap Java. Các ByteBuffer trực tiếp có thể được chuyển trực tiếp tới các hàm thư viện của hệ điều hành riêng để thực hiện I/O — làm cho chúng nhanh hơn đáng kể trong một số kịch bản vì chúng có thể tránh việc sao chép dữ liệu giữa vùng heap Java và vùng heap riêng.

 


Dễ bị lúng túng về dữ liệu ByteBuffer trực tiếp đang được lưu giữ ở đâu. Ứng dụng này vẫn còn sử dụng một đối tượng trên vùng heap Java để hòa phối các hoạt động I/O, nhưng bộ đệm chứa dữ liệu được tổ chức trong bộ nhớ riêng — đối tượng của vùng heap Java chỉ chứa một tham chiếu đến bộ đệm của vùng heap riêng. Một ByteBuffer không trực tiếp sẽ chứa dữ liệu của nó trong một mảng byte[] trên vùng heap Java. Hình 4 cho thấy sự khác biệt giữa các đối tượng ByteBuffer trực tiếp và không trực tiếp:

 

Hình 4. Hình trạng (tô pô) của bộ nhớ với các java.nio.ByteBuffer trực tiếp và không trực tiếp

 

 

 


Các đối tượng ByteBuffer trực tiếp tự động xóa bộ đệm riêng của chúng nhưng chỉ có thể làm như vậy như là một phần của GC của vùng heap Java — vì vậy chúng không tự động đáp ứng với sức ép trên vùng heap riêng. GC xảy ra chỉ khi vùng heap Java trở nên đầy đến nỗi nó không thể phục vụ một yêu cầu cấp phát- vùng heap hoặc nếu ứng dụng Java yêu cầu thực hiện một cách rõ ràng (việc này không được khuyến khích vì nó gây ra các vấn đề về hiệu năng).

 


Các trường hợp không hợp lý sẽ là vùng heap riêng trở nên đầy và một hoặc nhiều ByteBuffers (các bộ đệm byte) trực tiếp có đủ điều kiện để thu dọn dữ liệu rác (và có thể được giải phóng để tạo ra chỗ trống dành cho vùng heap riêng), nhưng vùng heap Java chủ yếu trống rỗng nên việc thu dọn dữ liệu rác (GC) không xảy ra.

 


Các luồng

 


Mỗi luồng trong một ứng dụng đòi hỏi bộ nhớ để lưu trữ ngăn xếp của nó (vùng bộ nhớ được sử dụng để chứa các biến tại chỗ và duy trì trạng thái khi gọi các hàm). Mỗi luồng Java yêu cầu vùng ngăn xếp để chạy. Tùy thuộc vào việc triển khai thực hiện, một luồng Java có thể có ngăn xếp riêng và ngăn xếp Java riêng biệt. Ngoài vùng ngăn xếp, mỗi luồng yêu cầu một số bộ nhớ riêng để lưu trữ cục bộ của luồng và các cấu trúc dữ liệu bên trong.

 


Kích thước ngăn xếp thay đổi theo việc triển khai thực hiện Java và theo kiến trúc. Một số việc triển khai thực hiện cho phép bạn quy định kích thước ngăn xếp cho các luồng Java. Điển hình là các giá trị giữa 256KB và 756KB.

 


Mặc dù số lượng bộ nhớ được sử dụng cho mỗi luồng là khá nhỏ, đối với một ứng dụng có hàng trăm luồng, tổng bộ nhớ sử dụng cho các ngăn xếp luồng có thể lớn. Việc chạy một ứng dụng với nhiều luồng hơn số các bộ xử lý có sẵn để chạy chúng thường không hiệu quả và có thể dẫn đến hiệu năng kém cũng như việc sử dụng bộ nhớ tăng lên.

 

 

Phần tiếp theo: Khi dùng hết bộ nhớ