Máy ảo Java - Hướng dẫn nhanh

JVM là một đặc điểm kỹ thuật và có thể có các cách triển khai khác nhau, miễn là chúng tuân thủ các thông số kỹ thuật. Các thông số kỹ thuật có thể được tìm thấy trong liên kết dưới đây -https://docs.oracle.com

Oracle có triển khai JVM của riêng mình (được gọi là HotSpot JVM), IBM có triển khai JVM của riêng mình (ví dụ: JVM J9).

Các hoạt động được xác định bên trong thông số kỹ thuật được đưa ra bên dưới (nguồn - Thông số kỹ thuật Oracle JVM, xem liên kết ở trên) -

  • Định dạng tệp 'lớp'
  • Loại dữ liệu
  • Các kiểu và giá trị ban đầu
  • Các loại và giá trị tham chiếu
  • Vùng dữ liệu thời gian chạy
  • Frames
  • Biểu diễn các đối tượng
  • Số học dấu phẩy động
  • Phương pháp đặc biệt
  • Exceptions
  • Tóm tắt bộ hướng dẫn
  • Thư viện lớp học
  • Thiết kế công cộng, triển khai tư nhân

JVM là một máy ảo, một máy tính trừu tượng có ISA riêng, bộ nhớ riêng, ngăn xếp, heap, v.v. Nó chạy trên hệ điều hành chủ và đặt các yêu cầu về tài nguyên cho nó.

Kiến trúc của HotSpot JVM 3 được hiển thị bên dưới:

Công cụ thực thi bao gồm bộ thu gom rác và trình biên dịch JIT. JVM có hai hương vị -client and server. Cả hai đều chia sẻ cùng một mã thời gian chạy nhưng khác nhau về những gì JIT được sử dụng. Chúng ta sẽ tìm hiểu thêm về điều này sau. Người dùng có thể kiểm soát hương vị sẽ sử dụng bằng cách chỉ định cờ JVM -client hoặc -server . Máy chủ JVM đã được thiết kế cho các ứng dụng Java chạy lâu dài trên máy chủ.

JVM có các phiên bản 32b và 64b. Người dùng có thể chỉ định phiên bản sẽ sử dụng bằng cách sử dụng -d32 hoặc -d64 trong các đối số VM. Phiên bản 32b chỉ có thể đáp ứng tối đa 4G bộ nhớ. Với các ứng dụng quan trọng duy trì bộ dữ liệu lớn trong bộ nhớ, phiên bản 64b đáp ứng nhu cầu đó.

JVM quản lý quá trình tải, liên kết và khởi tạo các lớp và giao diện theo cách năng động. Trong quá trình tải,JVM finds the binary representation of a class and creates it.

Trong quá trình liên kết, loaded classes are combined into the run-time state of the JVM so that they can be executed during the initialization phase. JVM về cơ bản sử dụng bảng ký hiệu được lưu trữ trong nhóm hằng số thời gian chạy cho quá trình liên kết. Khởi tạo bao gồm thực sựexecuting the linked classes.

Các loại máy xúc lật

Các BootStraptrình tải lớp nằm trên cùng của hệ thống phân cấp trình tải lớp. Nó tải các lớp JDK tiêu chuẩn trong thư mục lib của JRE .

Các Extension trình tải lớp nằm ở giữa phân cấp trình tải lớp và là con trực tiếp của trình tải lớp bootstrap và tải các lớp trong thư mục lib \ ext của JRE.

Các Applicationtrình tải lớp nằm ở cuối phân cấp trình tải lớp và là con trực tiếp của trình tải lớp ứng dụng. Nó tải các lọ và các lớp được chỉ định bởiCLASSPATH ENV Biến đổi.

Liên kết

Quá trình liên kết bao gồm ba bước sau:

Verification- Điều này được thực hiện bởi trình xác minh Bytecode để đảm bảo rằng các tệp .class được tạo (Bytecode) là hợp lệ. Nếu không, một lỗi sẽ xảy ra và quá trình liên kết bị tạm dừng.

Preparation - Bộ nhớ được cấp cho tất cả các biến tĩnh của một lớp và chúng được khởi tạo với các giá trị mặc định.

Resolution- Tất cả các tham chiếu bộ nhớ tượng trưng được thay thế bằng các tham chiếu ban đầu. Để thực hiện điều này, bảng ký hiệu trong bộ nhớ hằng thời gian chạy của vùng phương thức của lớp được sử dụng.

Khởi tạo

Đây là giai đoạn cuối cùng của quá trình tải lớp. Các biến tĩnh được gán giá trị ban đầu và các khối tĩnh được thực thi.

Đặc tả JVM xác định các vùng dữ liệu thời gian chạy nhất định cần thiết trong quá trình thực thi chương trình. Một số trong số chúng được tạo trong khi JVM khởi động. Những người khác là cục bộ của các luồng và chỉ được tạo khi một luồng được tạo (và bị hủy khi luồng bị hủy). Chúng được liệt kê dưới đây -

Đăng ký PC (Bộ đếm chương trình)

Nó là cục bộ cho mỗi luồng và chứa địa chỉ của lệnh JVM mà luồng hiện đang thực thi.

Cây rơm

Nó là cục bộ cho mỗi luồng và lưu trữ các tham số, biến cục bộ và địa chỉ trả về trong khi gọi phương thức. Lỗi StackOverflow có thể xảy ra nếu một luồng yêu cầu nhiều không gian ngăn xếp hơn mức cho phép. Nếu ngăn xếp có thể mở rộng động, nó vẫn có thể ném OutOfMemoryError.

Đống

Nó được chia sẻ giữa tất cả các luồng và chứa các đối tượng, siêu dữ liệu của lớp, mảng, v.v., được tạo trong thời gian chạy. Nó được tạo ra khi JVM khởi động và bị hủy khi JVM tắt. Bạn có thể kiểm soát số lượng yêu cầu JVM của bạn từ Hệ điều hành bằng cách sử dụng một số cờ nhất định (thêm về điều này sau). Cần cẩn thận để không yêu cầu quá ít hoặc quá nhiều bộ nhớ, vì nó có ý nghĩa quan trọng về hiệu suất. Hơn nữa, GC quản lý không gian này và liên tục loại bỏ các đối tượng đã chết để giải phóng không gian.

Khu vực phương pháp

Vùng thời gian chạy này là chung cho tất cả các luồng và được tạo khi JVM khởi động. Nó lưu trữ các cấu trúc cho mỗi lớp như nhóm hằng số (sẽ nói thêm về điều này sau), mã cho các hàm tạo và phương thức, dữ liệu phương thức, v.v. JLS không chỉ định liệu khu vực này có cần được thu thập rác hay không, và do đó, các triển khai của JVM có thể chọn bỏ qua GC. Hơn nữa, điều này có thể mở rộng hoặc không thể tùy theo nhu cầu của ứng dụng. JLS không bắt buộc bất cứ điều gì liên quan đến điều này.

Nhóm cố định thời gian chạy

JVM duy trì cấu trúc dữ liệu mỗi lớp / mỗi loại hoạt động như bảng biểu tượng (một trong nhiều vai trò của nó) trong khi liên kết các lớp được tải.

Ngăn xếp phương pháp gốc

Khi một luồng gọi ra một phương thức gốc, nó sẽ bước vào một thế giới mới, trong đó các cấu trúc và hạn chế bảo mật của máy ảo Java không còn cản trở sự tự do của nó nữa. Một phương thức gốc có thể có thể truy cập các vùng dữ liệu thời gian chạy của máy ảo (nó phụ thuộc vào giao diện phương thức gốc), nhưng cũng có thể làm bất cứ điều gì khác mà nó muốn.

Thu gom rác thải

JVM quản lý toàn bộ vòng đời của các đối tượng trong Java. Khi một đối tượng được tạo, nhà phát triển không cần phải lo lắng về nó nữa. Trong trường hợp đối tượng trở nên chết (nghĩa là không còn tham chiếu đến nó nữa), nó sẽ bị GC đẩy ra khỏi đống bằng cách sử dụng một trong nhiều thuật toán - GC nối tiếp, CMS, G1, v.v.

Trong quá trình GC, các đối tượng được di chuyển trong bộ nhớ. Do đó, những đối tượng đó không thể sử dụng được trong khi quá trình đang diễn ra. Toàn bộ ứng dụng phải được dừng lại trong thời gian của quá trình. Những lần tạm dừng như vậy được gọi là tạm dừng 'dừng lại trên thế giới' và là một khoản chi phí rất lớn. Các thuật toán GC chủ yếu nhằm mục đích giảm thời gian này. Chúng ta sẽ thảo luận rất chi tiết về vấn đề này trong các chương sau.

Nhờ có GC, việc rò rỉ bộ nhớ rất hiếm trong Java, nhưng chúng có thể xảy ra. Chúng ta sẽ xem trong các chương sau cách tạo rò rỉ bộ nhớ trong Java.

Trong chương này, chúng ta sẽ tìm hiểu về trình biên dịch JIT, và sự khác biệt giữa các ngôn ngữ biên dịch và thông dịch.

Ngôn ngữ được biên dịch so với Ngôn ngữ được thông dịch

Các ngôn ngữ như C, C ++ và FORTRAN là các ngôn ngữ biên dịch. Mã của chúng được phân phối dưới dạng mã nhị phân được nhắm mục tiêu vào máy bên dưới. Điều này có nghĩa là mã cấp cao được biên dịch thành mã nhị phân ngay lập tức bởi một trình biên dịch tĩnh được viết riêng cho kiến ​​trúc bên dưới. Hệ nhị phân được tạo ra sẽ không chạy trên bất kỳ kiến ​​trúc nào khác.

Mặt khác, các ngôn ngữ thông dịch như Python và Perl có thể chạy trên bất kỳ máy nào, miễn là chúng có trình thông dịch hợp lệ. Nó đi từng dòng qua mã cấp cao, chuyển đổi mã đó thành mã nhị phân.

Mã được thông dịch thường chậm hơn mã đã biên dịch. Ví dụ, hãy xem xét một vòng lặp. Một thông dịch sẽ chuyển đổi mã tương ứng cho mỗi lần lặp lại của vòng lặp. Mặt khác, một mã được biên dịch sẽ làm cho bản dịch chỉ có một. Hơn nữa, vì trình thông dịch chỉ nhìn thấy một dòng tại một thời điểm, họ không thể thực hiện bất kỳ mã quan trọng nào, chẳng hạn như thay đổi thứ tự thực hiện các câu lệnh như trình biên dịch.

Chúng tôi sẽ xem xét một ví dụ về tối ưu hóa như vậy bên dưới:

Adding two numbers stored in memory. Vì việc truy cập bộ nhớ có thể tiêu tốn nhiều chu kỳ CPU, một trình biên dịch tốt sẽ đưa ra hướng dẫn để tìm nạp dữ liệu từ bộ nhớ và chỉ thực hiện việc bổ sung khi có dữ liệu. Nó sẽ không chờ đợi và trong khi chờ đợi, thực hiện các hướng dẫn khác. Mặt khác, không thể tối ưu hóa như vậy trong quá trình diễn giải vì trình thông dịch không nhận thức được toàn bộ mã tại bất kỳ thời điểm nào.

Nhưng sau đó, các ngôn ngữ được thông dịch có thể chạy trên bất kỳ máy nào có trình thông dịch hợp lệ của ngôn ngữ đó.

Java được biên dịch hay thông dịch?

Java đã cố gắng tìm ra điểm trung gian. Vì JVM nằm giữa trình biên dịch javac và phần cứng bên dưới, trình biên dịch javac (hoặc bất kỳ trình biên dịch nào khác) biên dịch mã Java trong Bytecode, được hiểu bởi JVM nền tảng cụ thể. Sau đó JVM biên dịch Bytecode ở dạng nhị phân bằng cách sử dụng biên dịch JIT (Just-in-time), khi mã thực thi.

HotSpots

Trong một chương trình điển hình, chỉ có một đoạn mã nhỏ được thực thi thường xuyên và thường thì đoạn mã này ảnh hưởng đáng kể đến hiệu suất của toàn bộ ứng dụng. Các phần mã như vậy được gọi làHotSpots.

Nếu một số đoạn mã chỉ được thực thi một lần, thì việc biên dịch nó sẽ rất lãng phí công sức và thay vào đó, việc diễn giải Bytecode sẽ nhanh hơn. Nhưng nếu phần này là một phần nóng và được thực thi nhiều lần, JVM sẽ biên dịch nó thay thế. Ví dụ: nếu một phương thức được gọi nhiều lần, thì các chu kỳ bổ sung mà nó cần để biên dịch mã sẽ được bù đắp bởi nhị phân nhanh hơn được tạo ra.

Hơn nữa, JVM càng chạy một phương thức hoặc một vòng lặp cụ thể, thì nó càng thu thập được nhiều thông tin để thực hiện các tối ưu hóa lặt vặt để tạo ra một tệp nhị phân nhanh hơn.

Chúng ta hãy xem xét đoạn mã sau:

for(int i = 0 ; I <= 100; i++) {
   System.out.println(obj1.equals(obj2)); //two objects
}

Nếu mã này được thông dịch, trình thông dịch sẽ suy ra cho mỗi lần lặp lại các lớp của obj1. Điều này là do mỗi lớp trong Java có một phương thức .equals (), được mở rộng từ lớp Đối tượng và có thể bị ghi đè. Vì vậy, ngay cả khi obj1 là một chuỗi cho mỗi lần lặp, việc khấu trừ vẫn sẽ được thực hiện.

Mặt khác, điều thực sự sẽ xảy ra là JVM sẽ nhận thấy rằng đối với mỗi lần lặp, obj1 thuộc lớp String và do đó, nó sẽ trực tiếp tạo ra mã tương ứng với phương thức .equals () của lớp String. Do đó, không cần tra cứu và mã đã biên dịch sẽ thực thi nhanh hơn.

Loại hành vi này chỉ có thể thực hiện được khi JVM biết mã hoạt động như thế nào. Do đó, nó đợi trước khi biên dịch các phần nhất định của mã.

Dưới đây là một ví dụ khác -

int sum = 7;
for(int i = 0 ; i <= 100; i++) {
   sum += i;
}

Trình thông dịch, đối với mỗi vòng lặp, lấy giá trị của 'sum' từ bộ nhớ, thêm 'I' vào nó và lưu trữ lại vào bộ nhớ. Truy cập bộ nhớ là một hoạt động tốn kém và thường mất nhiều chu kỳ CPU. Vì mã này chạy nhiều lần, nó là một HotSpot. JIT sẽ biên dịch mã này và thực hiện tối ưu hóa sau.

Bản sao cục bộ của 'sum' sẽ được lưu trữ trong một sổ đăng ký, cụ thể cho một chuỗi cụ thể. Tất cả các hoạt động sẽ được thực hiện với giá trị trong thanh ghi và khi vòng lặp hoàn thành, giá trị sẽ được ghi trở lại bộ nhớ.

Điều gì sẽ xảy ra nếu các luồng khác cũng đang truy cập biến? Vì các cập nhật đang được thực hiện cho bản sao cục bộ của biến bởi một số luồng khác, chúng sẽ thấy một giá trị cũ. Đồng bộ hóa luồng là cần thiết trong những trường hợp như vậy. Một nguyên thủy đồng bộ rất cơ bản sẽ là khai báo 'sum' là dễ bay hơi. Bây giờ, trước khi truy cập một biến, một luồng sẽ xóa các thanh ghi cục bộ của nó và lấy giá trị từ bộ nhớ. Sau khi truy cập nó, giá trị ngay lập tức được ghi vào bộ nhớ.

Dưới đây là một số tối ưu hóa chung được thực hiện bởi trình biên dịch JIT -

  • Nội tuyến phương pháp
  • Loại bỏ mã chết
  • Heuristics để tối ưu hóa các trang web cuộc gọi
  • Gấp liên tục

JVM hỗ trợ năm cấp độ biên dịch -

  • Interpreter
  • C1 với tối ưu hóa đầy đủ (không có cấu hình)
  • C1 với lệnh gọi và bộ đếm cạnh sau (cấu hình ánh sáng)
  • C1 với đầy đủ hồ sơ
  • C2 (sử dụng dữ liệu cấu hình từ các bước trước)

Sử dụng -Xint nếu bạn muốn tắt tất cả các trình biên dịch JIT và chỉ sử dụng trình thông dịch.

Máy khách so với Máy chủ JIT

Sử dụng -client và -server để kích hoạt các chế độ tương ứng.

Trình biên dịch máy khách (C1) bắt đầu biên dịch mã sớm hơn trình biên dịch máy chủ (C2). Vì vậy, vào thời điểm C2 bắt đầu biên dịch, C1 đã biên dịch các phần mã.

Nhưng trong khi chờ đợi, C2 cấu hình mã để biết về nó nhiều hơn C1. Do đó, thời gian nó chờ nếu được bù đắp bởi các tối ưu hóa có thể được sử dụng để tạo ra một tệp nhị phân nhanh hơn nhiều. Từ quan điểm của người dùng, sự đánh đổi là giữa thời gian khởi động chương trình và thời gian chạy chương trình. Nếu thời gian khởi động là phí bảo hiểm, thì C1 nên được sử dụng. Nếu ứng dụng dự kiến ​​sẽ chạy trong một thời gian dài (điển hình của các ứng dụng được triển khai trên máy chủ), tốt hơn nên sử dụng C2 vì nó tạo ra mã nhanh hơn nhiều, giúp giảm đáng kể thời gian khởi động thêm.

Đối với các chương trình như IDE (NetBeans, Eclipse) và các chương trình GUI khác, thời gian khởi động là rất quan trọng. NetBeans có thể mất một phút hoặc lâu hơn để bắt đầu. Hàng trăm lớp được biên dịch khi các chương trình như NetBeans được khởi động. Trong những trường hợp như vậy, trình biên dịch C1 là lựa chọn tốt nhất.

Lưu ý rằng có hai phiên bản của C1 - 32b and 64b. C2 chỉ có trong64b.

Biên dịch theo bậc

Trong các phiên bản cũ hơn trên Java, người dùng có thể đã chọn một trong các tùy chọn sau:

  • Thông dịch viên (-Xint)
  • C1 (-client)
  • C2 (-máy chủ)

Nó có trong Java 7. Nó sử dụng trình biên dịch C1 để khởi động và khi mã trở nên nóng hơn, sẽ chuyển sang C2. Nó có thể được kích hoạt với các tùy chọn JVM sau: -XX: + TieredCompilation. Giá trị mặc định làset to false in Java 7, and to true in Java 8.

Trong số năm cấp biên dịch, sử dụng biên dịch theo cấp 1 -> 4 -> 5.

Trên máy 32b, chỉ có thể cài đặt phiên bản 32b của JVM. Trên máy 64b, người dùng có thể lựa chọn giữa phiên bản 32b và 64b. Nhưng có những sắc thái nhất định đối với điều này có thể ảnh hưởng đến cách các ứng dụng Java của chúng tôi hoạt động.

Nếu ứng dụng Java sử dụng bộ nhớ ít hơn 4G, chúng ta nên sử dụng JVM 32b ngay cả trên máy 64b. Điều này là do tham chiếu bộ nhớ trong trường hợp này sẽ chỉ là 32b và thao tác với chúng sẽ ít tốn kém hơn so với thao tác địa chỉ 64b. Trong trường hợp này, 64b JVM sẽ hoạt động kém hơn ngay cả khi chúng ta đang sử dụng OOPS (con trỏ đối tượng thông thường). Sử dụng OOPS, JVM có thể sử dụng địa chỉ 32b trong JVM 64b. Tuy nhiên, thao tác với chúng sẽ chậm hơn các tham chiếu 32b thực vì các tham chiếu gốc cơ bản vẫn sẽ là 64b.

Nếu ứng dụng của chúng tôi sẽ sử dụng nhiều hơn bộ nhớ 4G, chúng tôi sẽ phải sử dụng phiên bản 64b vì tham chiếu 32b có thể giải quyết không quá 4G bộ nhớ. Chúng ta có thể cài đặt cả hai phiên bản trên cùng một máy và có thể chuyển đổi giữa chúng bằng cách sử dụng biến PATH.

Trong chương này, chúng ta sẽ tìm hiểu về Tối ưu hóa JIT.

Nội tuyến phương pháp

Trong kỹ thuật tối ưu hóa này, trình biên dịch quyết định thay thế các lệnh gọi hàm của bạn bằng thân hàm. Dưới đây là một ví dụ cho tương tự -

int sum3;

static int add(int a, int b) {
   return a + b;
}

public static void main(String…args) {
   sum3 = add(5,7) + add(4,2);
}

//after method inlining
public static void main(String…args) {
   sum3 = 5+ 7 + 4 + 2;
}

Sử dụng kỹ thuật này, trình biên dịch giúp máy tiết kiệm chi phí thực hiện bất kỳ lệnh gọi hàm nào (nó yêu cầu đẩy và đưa các tham số vào ngăn xếp). Do đó, mã được tạo chạy nhanh hơn.

Nội tuyến phương thức chỉ có thể được thực hiện cho các hàm không ảo (các hàm không bị ghi đè). Hãy xem xét điều gì sẽ xảy ra nếu phương thức 'add' bị đè lên trong một lớp con và loại đối tượng chứa phương thức không được biết cho đến thời gian chạy. Trong trường hợp này, trình biên dịch sẽ không biết phương pháp nào để nội tuyến. Nhưng nếu phương thức được đánh dấu là 'cuối cùng', thì trình biên dịch sẽ dễ dàng biết rằng nó có thể nằm trong dòng bởi vì nó không thể bị đè nén bởi bất kỳ lớp con nào. Lưu ý rằng hoàn toàn không đảm bảo rằng một phương pháp cuối cùng sẽ luôn nằm trong hàng.

Không thể truy cập và loại bỏ mã chết

Mã không thể truy cập là mã không thể truy cập được bằng bất kỳ luồng thực thi nào. Chúng ta sẽ xem xét ví dụ sau:

void foo() {
   if (a) return;
   else return;
   foobar(a,b); //unreachable code, compile time error
}

Mã chết cũng là mã không thể truy cập được, nhưng trình biên dịch không tạo ra lỗi trong trường hợp này. Thay vào đó, chúng tôi chỉ nhận được một cảnh báo. Mỗi khối mã như hàm tạo, hàm, try, catch, if, while, v.v., có các quy tắc riêng cho mã không thể truy cập được xác định trong JLS (Java Language Specification).

Gấp liên tục

Để hiểu khái niệm gấp không đổi, hãy xem ví dụ dưới đây.

final int num = 5;
int b = num * 6; //compile-time constant, num never changes
//compiler would assign b a value of 30.

Vòng đời của một đối tượng Java được quản lý bởi JVM. Khi một đối tượng được lập trình viên tạo ra, chúng ta không cần lo lắng về phần còn lại của vòng đời của nó. JVM sẽ tự động tìm những đối tượng không còn được sử dụng nữa và lấy lại bộ nhớ của chúng từ đống.

Thu gom rác là một hoạt động chính mà JVM thực hiện và việc điều chỉnh nó theo nhu cầu của chúng ta có thể mang lại hiệu suất lớn cho ứng dụng của chúng ta. Có nhiều thuật toán thu gom rác được cung cấp bởi các JVM hiện đại. Chúng ta cần biết về nhu cầu của ứng dụng để quyết định sử dụng thuật toán nào.

Bạn không thể định vị một đối tượng theo lập trình trong Java, giống như bạn có thể làm trong các ngôn ngữ không phải GC như C và C ++. Do đó, bạn không thể có các tham chiếu lơ lửng trong Java. Tuy nhiên, bạn có thể có các tham chiếu rỗng (các tham chiếu tham chiếu đến một vùng bộ nhớ mà JVM sẽ không bao giờ lưu trữ các đối tượng). Bất cứ khi nào một tham chiếu rỗng được sử dụng, JVM sẽ ném một NullPointerException.

Lưu ý rằng mặc dù hiếm khi tìm thấy rò rỉ bộ nhớ trong các chương trình Java nhờ GC, nhưng chúng vẫn xảy ra. Chúng tôi sẽ tạo ra một rò rỉ bộ nhớ ở cuối chương này.

Các GC sau được sử dụng trong các JVM hiện đại

  • Bộ sưu tập nối tiếp
  • Bộ thu thông lượng
  • Bộ sưu tập CMS
  • Bộ sưu tập G1

Mỗi thuật toán trên thực hiện cùng một nhiệm vụ - tìm kiếm các đối tượng không còn được sử dụng và lấy lại bộ nhớ mà chúng chiếm trong đống. Một trong những cách tiếp cận đơn giản cho điều này là đếm số lượng tham chiếu mà mỗi đối tượng có và giải phóng nó ngay khi số lượng tham chiếu chuyển sang 0 (đây còn được gọi là đếm tham chiếu). Sao lại ngây thơ thế này? Hãy xem xét một danh sách liên kết vòng tròn. Mỗi nút của nó sẽ có một tham chiếu đến nó, nhưng toàn bộ đối tượng sẽ không được tham chiếu từ bất kỳ đâu, và lý tưởng là nên được giải phóng.

JVM không chỉ giải phóng bộ nhớ mà còn kết hợp các bộ nhớ nhỏ thành bộ nhớ lớn hơn. Điều này được thực hiện để ngăn chặn sự phân mảnh bộ nhớ.

Một lưu ý đơn giản, một thuật toán GC điển hình thực hiện các hoạt động sau:

  • Tìm đồ vật không sử dụng
  • Giải phóng bộ nhớ mà chúng chiếm trong heap
  • Kết dính các mảnh

GC phải dừng các luồng ứng dụng trong khi nó đang chạy. Điều này là do nó di chuyển các đối tượng xung quanh khi nó chạy, và do đó, những đối tượng đó không thể được sử dụng. Những điểm dừng như vậy được gọi là 'tạm dừng trên toàn thế giới và giảm thiểu tần suất và thời lượng của những lần tạm dừng này là mục tiêu của chúng tôi khi điều chỉnh GC của mình.

Coalescing bộ nhớ

Dưới đây là một minh chứng đơn giản về liên kết bộ nhớ

Phần bóng mờ là các đối tượng cần được giải phóng. Ngay cả sau khi tất cả không gian được lấy lại, chúng tôi chỉ có thể cấp phát một đối tượng có kích thước tối đa = 75Kb. Điều này thậm chí sau khi chúng tôi có 200Kb dung lượng trống như hình dưới đây

Hầu hết các JVM chia heap thành ba thế hệ - the young generation (YG), the old generation (OG) and permanent generation (also called tenured generation). Những lý do đằng sau suy nghĩ như vậy là gì?

Các nghiên cứu thực nghiệm đã chỉ ra rằng hầu hết các đối tượng được tạo ra đều có tuổi thọ rất ngắn -

Nguồn

https://www.oracle.com

Như bạn có thể thấy rằng khi ngày càng có nhiều đối tượng được phân bổ theo thời gian, thì số lượng byte còn sót lại trở nên ít hơn (nói chung). Các đối tượng Java có tỷ lệ tử vong cao.

Chúng ta sẽ xem xét một ví dụ đơn giản. Lớp String trong Java là bất biến. Điều này có nghĩa là mỗi khi bạn cần thay đổi nội dung của một đối tượng String, bạn phải tạo một đối tượng mới hoàn toàn. Giả sử bạn thực hiện thay đổi chuỗi 1000 lần trong một vòng lặp như được hiển thị trong đoạn mã dưới đây -

String str = “G11 GC”;

for(int i = 0 ; i < 1000; i++) {
   str = str + String.valueOf(i);
}

Trong mỗi vòng lặp, chúng tôi tạo một đối tượng chuỗi mới và chuỗi được tạo trong lần lặp trước đó sẽ trở nên vô dụng (nghĩa là nó không được tham chiếu bởi bất kỳ tham chiếu nào). Thời gian tồn tại của đối tượng đó chỉ là một lần lặp lại - chúng sẽ được GC thu thập ngay lập tức. Những đồ vật tồn tại trong thời gian ngắn như vậy được lưu giữ trong khu vực thế hệ trẻ của đống. Quá trình thu thập các đồ vật từ thế hệ trẻ được gọi là thu gom rác nhỏ, và nó luôn gây ra sự tạm dừng 'thế giới dừng lại'.

Khi thế hệ trẻ được lấp đầy, GC thực hiện một công việc thu gom rác nhỏ. Các vật thể chết được loại bỏ, và các vật thể sống được chuyển sang thế hệ cũ. Các chuỗi ứng dụng dừng trong quá trình này.

Ở đây, chúng ta có thể thấy những lợi thế mà một thiết kế thế hệ như vậy mang lại. Thế hệ trẻ chỉ là một phần nhỏ trong đống và bị lấp đầy nhanh chóng. Nhưng quá trình xử lý mất ít thời gian hơn nhiều so với thời gian cần thiết để xử lý toàn bộ đống. Vì vậy, thời gian tạm dừng 'stop-theworld' trong trường hợp này ngắn hơn nhiều, mặc dù thường xuyên hơn. Chúng ta nên luôn hướng tới những lần tạm dừng ngắn hơn những lần tạm dừng dài hơn, mặc dù chúng có thể thường xuyên hơn. Chúng ta sẽ thảo luận chi tiết về vấn đề này trong các phần sau của hướng dẫn này.

Thế hệ trẻ được chia thành hai không gian - eden and survivor space. Các vật thể sống sót trong quá trình thu thập eden được chuyển đến không gian dành cho người sống sót, và những người sống sót trong không gian sống sót được chuyển đến thế hệ cũ. Thế hệ trẻ được nén chặt trong khi nó được thu thập.

Khi các đối tượng được chuyển sang thế hệ cũ, cuối cùng nó sẽ đầy lên và phải được thu thập và nén chặt. Các thuật toán khác nhau có những cách tiếp cận khác nhau đối với điều này. Một số người trong số họ dừng các luồng ứng dụng (điều này dẫn đến việc tạm dừng 'stop-the-world' trong một thời gian dài vì thế hệ cũ khá lớn so với thế hệ trẻ), trong khi một số người trong số họ thực hiện đồng thời trong khi các luồng ứng dụng tiếp tục chạy. Quá trình này được gọi là GC đầy đủ. Hai nhà sưu tập như vậy làCMS and G1.

Bây giờ chúng ta hãy phân tích chi tiết các thuật toán này.

GC nối tiếp

nó là GC mặc định trên các máy cấp khách (máy xử lý đơn hoặc 32b JVM, Windows). Thông thường, GC có nhiều luồng đa luồng, nhưng GC nối tiếp thì không. Nó có một luồng duy nhất để xử lý đống và nó sẽ dừng các luồng ứng dụng bất cứ khi nào nó đang thực hiện một GC nhỏ hoặc một GC chính. Chúng ta có thể ra lệnh cho JVM sử dụng GC này bằng cách chỉ định cờ:-XX:+UseSerialGC. Nếu chúng ta muốn nó sử dụng một số thuật toán khác, hãy chỉ định tên thuật toán. Lưu ý rằng thế hệ cũ được nén hoàn toàn trong một GC lớn.

Thông lượng GC

GC này được mặc định trên các máy JVM 64b và nhiều CPU. Không giống như GC nối tiếp, nó sử dụng nhiều luồng để xử lý thế hệ trẻ và thế hệ cũ. Do đó, GC còn được gọi làparallel collector. Chúng tôi có thể ra lệnh cho JVM của mình sử dụng bộ thu này bằng cách sử dụng cờ:-XX:+UseParallelOldGC hoặc là -XX:+UseParallelGC(dành cho JDK 8 trở đi). Các luồng ứng dụng bị dừng trong khi nó thực hiện một bộ sưu tập rác lớn hoặc nhỏ. Giống như bộ sưu tập nối tiếp, nó hoàn toàn thu gọn thế hệ trẻ trong thời kỳ GC lớn.

Thông lượng GC thu thập YG và OG. Khi eden đã đầy, bộ thu sẽ đẩy các vật thể sống từ nó vào OG hoặc một trong các không gian sống sót (SS0 và SS1 trong sơ đồ bên dưới). Các vật thể chết được loại bỏ để giải phóng không gian mà chúng chiếm dụng.

Trước GC của YG

Sau GC của YG

Trong một GC đầy đủ, bộ thu thập thông lượng làm trống toàn bộ YG, SS0 và SS1. Sau khi hoạt động, OG chỉ chứa các đối tượng sống. Chúng ta nên lưu ý rằng cả hai bộ sưu tập trên đều dừng các luồng ứng dụng trong khi xử lý đống. Điều này có nghĩa là 'stopthe- world' tạm dừng trong thời gian dài của GC lớn. Hai thuật toán tiếp theo nhằm mục đích loại bỏ chúng, với chi phí tốn nhiều tài nguyên phần cứng hơn -

Bộ sưu tập CMS

Nó là viết tắt của 'quét đánh dấu đồng thời'. Chức năng của nó là nó sử dụng một số luồng nền để quét qua thế hệ cũ theo định kỳ và loại bỏ các đối tượng đã chết. Nhưng trong một GC nhỏ, các luồng ứng dụng bị dừng. Tuy nhiên, số lần tạm dừng là khá nhỏ. Điều này làm cho CMS trở thành một bộ thu có khoảng dừng thấp.

Bộ sưu tập này cần thêm thời gian CPU để quét qua heap trong khi chạy các luồng ứng dụng. Hơn nữa, các luồng nền chỉ thu thập đống và không thực hiện bất kỳ quá trình nén nào. Chúng có thể dẫn đến đống trở nên phân mảnh. Khi điều này tiếp tục diễn ra, sau một thời gian nhất định, CMS sẽ dừng tất cả các luồng ứng dụng và thu gọn đống bằng một luồng duy nhất. Sử dụng các đối số JVM sau để yêu cầu JVM sử dụng bộ thu CMS:

“XX:+UseConcMarkSweepGC -XX:+UseParNewGC” như các đối số JVM để yêu cầu nó sử dụng bộ thu CMS.

Trước GC

Sau GC

Lưu ý rằng việc thu thập đang được thực hiện đồng thời.

G1 GC

Thuật toán này hoạt động bằng cách chia đống thành một số vùng. Giống như bộ sưu tập CMS, nó dừng các luồng ứng dụng trong khi thực hiện một GC nhỏ và sử dụng các luồng nền để xử lý thế hệ cũ trong khi vẫn giữ cho các luồng ứng dụng tiếp tục. Vì nó phân chia thế hệ cũ thành các vùng, nó tiếp tục nén chúng lại trong khi di chuyển các đối tượng từ vùng này sang vùng khác. Do đó, sự phân mảnh là tối thiểu. Bạn có thể sử dụng cờ:XX:+UseG1GCđể yêu cầu JVM của bạn sử dụng thuật toán này. Giống như CMS, nó cũng cần thêm thời gian CPU để xử lý đống và chạy các luồng ứng dụng đồng thời.

Thuật toán này đã được thiết kế để xử lý các đống lớn hơn (> 4G), được chia thành một số vùng khác nhau. Một số khu vực đó bao gồm thế hệ trẻ, và phần còn lại bao gồm những người già. YG được xóa bằng cách sử dụng truyền thống - tất cả các luồng ứng dụng bị dừng và tất cả các đối tượng vẫn còn sống đến thế hệ cũ hoặc không gian sống sót.

Lưu ý rằng tất cả các thuật toán GC đã chia đống thành YG và OG, đồng thời sử dụng STWP để xóa YG. Quá trình này thường rất nhanh.

Trong chương trước, chúng ta đã tìm hiểu về các Gcs Thế hệ khác nhau. Trong chương này, chúng ta sẽ thảo luận về cách điều chỉnh GC.

Kích thước kinh ngạc

Kích thước heap là một yếu tố quan trọng trong hiệu suất của các ứng dụng Java của chúng tôi. Nếu nó quá nhỏ, thì nó sẽ thường xuyên bị lấp đầy và do đó, GC sẽ phải thường xuyên thu thập. Mặt khác, nếu chúng ta chỉ tăng kích thước của đống, mặc dù nó cần phải được thu thập ít thường xuyên hơn, thì thời gian tạm dừng sẽ tăng lên.

Hơn nữa, việc tăng kích thước heap sẽ bị phạt nặng đối với hệ điều hành cơ bản. Sử dụng phân trang, hệ điều hành làm cho các chương trình ứng dụng của chúng tôi thấy nhiều bộ nhớ hơn so với thực tế. Hệ điều hành quản lý điều này bằng cách sử dụng một số không gian hoán đổi trên đĩa, sao chép các phần không hoạt động của chương trình vào đó. Khi cần những phần đó, hệ điều hành sẽ sao chép lại chúng từ đĩa vào bộ nhớ.

Giả sử rằng một máy có 8G bộ nhớ và JVM thấy 16G bộ nhớ ảo, thì JVM sẽ không biết rằng trên thực tế chỉ có 8G trên hệ thống. Nó sẽ chỉ yêu cầu 16G từ hệ điều hành và khi nhận được bộ nhớ đó, nó sẽ tiếp tục sử dụng. Hệ điều hành sẽ phải trao đổi rất nhiều dữ liệu vào và ra, và đây là một hình phạt hiệu suất rất lớn trên hệ thống.

Và sau đó là các tạm dừng sẽ xảy ra trong GC đầy đủ của bộ nhớ ảo đó. Vì GC sẽ hoạt động trên toàn bộ heap để thu thập và nén, nó sẽ phải đợi rất nhiều để bộ nhớ ảo được hoán đổi ra khỏi đĩa. Trong trường hợp có bộ thu đồng thời, các luồng nền sẽ phải đợi rất nhiều để dữ liệu được sao chép từ không gian hoán đổi vào bộ nhớ.

Vì vậy, ở đây câu hỏi làm thế nào chúng ta nên quyết định về kích thước đống tối ưu. Quy tắc đầu tiên là không bao giờ yêu cầu hệ điều hành nhiều bộ nhớ hơn thực tế. Điều này sẽ hoàn toàn ngăn chặn vấn đề hoán đổi thường xuyên. Nếu máy có nhiều JVM được cài đặt và đang chạy, thì tổng yêu cầu bộ nhớ của tất cả chúng cộng lại sẽ nhỏ hơn RAM thực tế có trong hệ thống.

Bạn có thể kiểm soát kích thước của yêu cầu bộ nhớ bởi JVM bằng cách sử dụng hai cờ -

  • -XmsN - Kiểm soát bộ nhớ ban đầu được yêu cầu.

  • -XmxN - Kiểm soát bộ nhớ tối đa có thể được yêu cầu.

Giá trị mặc định của cả hai cờ này phụ thuộc vào hệ điều hành cơ bản. Ví dụ: đối với 64b JVM chạy trên MacOS, -XmsN = 64M và -XmxN = tối thiểu 1G hoặc 1/4 tổng bộ nhớ vật lý.

Lưu ý rằng JVM có thể tự động điều chỉnh giữa hai giá trị. Ví dụ: nếu nó nhận thấy rằng có quá nhiều GC đang xảy ra, nó sẽ tiếp tục tăng kích thước bộ nhớ miễn là nó dưới -XmxN và các mục tiêu hiệu suất mong muốn được đáp ứng.

Nếu bạn biết chính xác dung lượng bộ nhớ mà ứng dụng của bạn cần, thì bạn có thể đặt -XmsN = -XmxN. Trong trường hợp này, JVM không cần phải tìm ra giá trị “tối ưu” của đống, và do đó, quy trình GC trở nên hiệu quả hơn một chút.

Kích thước thế hệ

Bạn có thể quyết định về việc bạn muốn phân bổ bao nhiêu đống cho YG và bạn muốn phân bổ bao nhiêu cho OG. Cả hai giá trị này đều ảnh hưởng đến hiệu suất của các ứng dụng của chúng tôi theo cách sau.

Nếu quy mô của YG là rất lớn, thì nó sẽ được thu thập ít thường xuyên hơn. Điều này sẽ dẫn đến số lượng đối tượng được thăng cấp lên OG ít hơn. Mặt khác, nếu bạn tăng kích thước của OG quá nhiều, thì việc thu thập và thu gọn nó sẽ mất quá nhiều thời gian và điều này sẽ dẫn đến việc dừng STW lâu. Do đó, người dùng phải tìm sự cân bằng giữa hai giá trị này.

Dưới đây là các cờ mà bạn có thể sử dụng để đặt các giá trị này:

  • -XX:NewRatio=N: Tỷ lệ YG so với OG (giá trị mặc định = 2)

  • -XX:NewSize=N: Quy mô ban đầu của YG

  • -XX:MaxNewSize=N: Kích thước tối đa của YG

  • -XmnN: Đặt NewSize và MaxNewSize thành cùng một giá trị bằng cách sử dụng cờ này

Quy mô ban đầu của YG được xác định bởi giá trị của NewRatio theo công thức đã cho:

(total heap size) / (newRatio + 1)

Vì giá trị ban đầu của newRatio là 2, nên công thức trên cho giá trị ban đầu của YG là 1/3 tổng kích thước heap. Bạn luôn có thể ghi đè giá trị này bằng cách chỉ định rõ ràng kích thước của YG bằng cờ NewSize. Cờ này không có bất kỳ giá trị mặc định nào và nếu nó không được đặt rõ ràng, kích thước của YG sẽ tiếp tục được tính theo công thức trên.

Permagen và Metaspace

Permagen và metaspace là vùng heap nơi JVM lưu giữ siêu dữ liệu của các lớp. Không gian được gọi là 'permagen' trong Java 7 và trong Java 8, nó được gọi là 'metaspace'. Thông tin này được sử dụng bởi trình biên dịch và thời gian chạy.

Bạn có thể kiểm soát kích thước của permagen bằng các cờ sau: -XX: PermSize=N-XX:MaxPermSize=N. Kích thước của Metaspace có thể được kiểm soát bằng cách sử dụng:-XX:Metaspace- Size=N-XX:MaxMetaspaceSize=N.

Có một số khác biệt về cách permagen và metaspace được quản lý khi các giá trị cờ không được đặt. Theo mặc định, cả hai đều có kích thước ban đầu mặc định. Nhưng trong khi metaspace có thể chiếm nhiều heap khi cần thiết, thì permagen có thể chiếm không nhiều hơn các giá trị ban đầu mặc định. Ví dụ: 64b JVM có 82M không gian heap là kích thước permagen tối đa.

Lưu ý rằng vì siêu không gian có thể chiếm số lượng bộ nhớ không giới hạn trừ khi được chỉ định không, nên có thể xảy ra lỗi hết bộ nhớ. GC đầy đủ diễn ra bất cứ khi nào các vùng này được thay đổi kích thước. Do đó, trong quá trình khởi động, nếu có nhiều lớp đang được tải, metaspace có thể tiếp tục thay đổi kích thước dẫn đến GC đầy đủ mọi lúc. Do đó, các ứng dụng lớn sẽ mất rất nhiều thời gian để khởi động trong trường hợp kích thước metaspace ban đầu quá thấp. Bạn nên tăng kích thước ban đầu vì nó làm giảm thời gian khởi động.

Mặc dù permagen và metaspace giữ siêu dữ liệu của lớp, nó không phải là vĩnh viễn và không gian được GC thu hồi, như trong trường hợp đối tượng. Điều này thường xảy ra với các ứng dụng máy chủ. Bất cứ khi nào bạn thực hiện triển khai mới cho máy chủ, siêu dữ liệu cũ phải được dọn dẹp vì các bộ tải lớp mới bây giờ sẽ cần dung lượng. Không gian này được giải phóng bởi GC.

Chúng ta sẽ thảo luận về khái niệm rò rỉ bộ nhớ trong Java trong chương này.

Đoạn mã sau tạo ra một rò rỉ bộ nhớ trong Java:

void queryDB() {
   try{
      Connection conn = ConnectionFactory.getConnection();
      PreparedStatement ps = conn.preparedStatement("query"); // executes a
      SQL
      ResultSet rs = ps.executeQuery();
      while(rs.hasNext()) {
         //process the record
      }
   } catch(SQLException sqlEx) {
      //print stack trace
   }
}

Trong đoạn mã trên, khi phương thức thoát, chúng ta chưa đóng đối tượng kết nối. Do đó, kết nối vật lý vẫn mở trước khi GC được kích hoạt và coi đối tượng kết nối là không thể truy cập. Bây giờ, nó sẽ gọi phương thức cuối cùng trên đối tượng kết nối, tuy nhiên, nó có thể không được thực hiện. Do đó, đối tượng sẽ không được thu gom rác trong chu kỳ này.

Điều tương tự sẽ xảy ra tiếp theo cho đến khi máy chủ từ xa thấy rằng kết nối đã được mở trong một thời gian dài và buộc phải chấm dứt nó. Do đó, một đối tượng không có tham chiếu sẽ lưu lại trong bộ nhớ trong một thời gian dài, điều này tạo ra một rò rỉ.