Memory Management trong JavaScript (Phần 2)
Memory Management trong JavaScript (Phần 2)

Memory Management trong JavaScript (Phần 2)

Published
November 12, 2022
Author
Ha Quynh Nguyen

5. Cấp phát vùng nhớ trong JavaScript

Rồi bây giờ chúng tôi sẽ giải thích về cái bước đầu tiên trong JavaScript "Cấp phát vùng nhớ". JavaScript đã giảm tải trách nhiệm cho developers trong vấn đề cấp phát vùng nhớ này. Nó làm tự động ngay khi bạn khai báo một biến xong.
var n = 374; // cấp phát vùng nhớ cho 1 number var s = 'sessionstack'; // cấp phát vùng nhớ cho 1 string var o = { a: 1, b: null }; // cấp phát vùng nhớ cho một object và cho từng property của nó var a = [1, null, 'str']; // (giống như object) cấp phát vùng nhớ cho một array và từng element của nó function f(a) { return a + 3; } // cấp phát vùng nhớ cho một function (function này được cấp phát như một object nhưng có thể được gọi tới) // function expressions cũng được cấp phát như một object someElement.addEventListener('click', function() { someElement.style.backgroundColor = 'blue'; }, false);
Một số trường hợp khởi tạo object bằng cách gọi một hàm khởi tạo cũng sẽ được cấp phát như một objects
var d = new Date(); // cấp phát vùng nhớ cho Date object var e = document.createElement('div'); // cấp phát cho một DOM element
Các phương thức có thể cấp phát giá trị mới hoặc một object mới. Ví dụ:
var s1 = 'sessionstack'; var s2 = s1.substr(0, 3); // s2 là một string mới // Bởi vì các string là những giá trị bất biến, // JavaScript lúc này sẽ không cấp phát vùng nhớ cho nó, // nhưng vẫn sẽ lưu cái range [0, 3] lại. var a1 = ['str1', 'str2']; var a2 = ['str3', 'str4']; var a3 = a1.concat(a2); // một array mới với 4 elements được ghép lại từ các elements của a1 và a2

6. Sử dụng memory trong JavaScript

Việc sử dụng memory trong JavaScript đơn giản là đọc và ghi đè lên nó.
Điều này được thực hiện bởi việc đọc và ghi các giá trị của một giá trị nào đó hoặc một thuộc tính của object nào đó hay thậm chí là việc chúng ta đưa một thuộc tính (Argument) vào trong một functions.

7. Giải phóng vùng nhớ khi không sử dụng nữa.

Hầu hết các vấn đề hay lỗi trong quá trình xử lý memory đều đến từ bước này.
Nhiệm vụ khó khăn nhất ở đây là làm cách nào để xác định được vùng nhớ đã được cấp phát nào không được sử dụng nữa. Thường thì các developers cần phải xác định được chỗ nào trong code của mình đang không cần dùng tới nữa và phải giải phóng vùng nhớ ở đó ngay.
Với các ngôn ngữ bậc cao thì nó sẽ có thêm một tiến trình gọi là garbage collector (Cái này được cung cấp bởi engine). Tiến trình này nó sẽ giúp theo dõi toàn bộ memory heap của mình và khi đó nó sẽ dò tới nơi nào đang không còn sử dụng nữa thì sẽ remove đi một cách tự động (Nghe có vẽ khoẻ ru nhỉ).
Có điều đương nhiên không có gì là tuyệt đối, các việc xác định vùng nhớ nào cần hay không cần không thể nào dựa vào một thuật toán mà giải quyết hết được.
Hầu hết các garbage collector này hoạt động bằng cách thu thập những vùng nhớ mà không thể truy xuất tới được nữa ví dụ những biến con trỏ nằm ngoài scope hiện tại. Tuy nhiên việc thu thập như vậy cũng mang tính tương đối chứ không thể quét qua được hết. Bởi vì thực tế thì bất kỳ vùng nhớ nào cũng có những biến con trỏ nằm bên trong scope trỏ tới nó nhưng lại không bao giờ được truy cập lại lần nữa.

8. Garbage collection (Thu gom rác)

Theo như thực tế thì việc xác địch được vùng nhớ nào còn sử dụng hay vùng nhớ nào không được sử dụng rất là khó khăn và mang tính tương đối. Cho nên giải pháp Garbage Collection này cũng rất hạn chế cho cái vấn đề này vì vậy trong phần này sẽ giải thích các khái niệm cần thiết để hiểu các thuật toán chủ yếu của việc thu gom rác và những hạn chế của chúng.

8.1. Memory references

Các thuật toán của GC chủ yếu dựa vào các reference của nó. Trong context của việc quản lý vùng nhớ, một object có thể reference đến một object khác nếu như object đầu có thể truy cập vào object sau (Có thể ẩn hoặc rõ ràng).
Ví dụ: Một cái object có thể reference tới prototype của chính nó (Implicit reference - Truy vấn ẩn) và cả các giá trị của từng properties của nó (Explicit reference - Truy vấn công khai).
Trong context này, một objects có thể được mở rộng ra thành một object bự hơn so với ban đầu và nó còn chứa được các function scopes (Hoặc cả global lexical scope - Biến toán cục).
Lexical scopes là những biến được khởi tạo trong các hàm lồng nhau. Hàm bên trong có thể chứa scope của hàm bọc nó ngay cả khi hàm bọc nó đã được return (Xem thêm về closure and scopes).

8.2. Reference-counting garbage collection

Thuật toán thu góm rác này siêu đơn giản. Một đối tượng được coi là rác và có thể được gôm khi mà chả có cái nào tham chiếu tới nó cả. Xem ví dụ dưới đây:
var o1 = { o2: { x: 1 } };
Chúng ta tạo ra 2 objects. o1 tham chiếu tới o2 tại biến x của o2. Lúc này thì không có cái nào là rác cả.
var o3 = o1;
o3 được khởi tạo và có giá trị là o1 lúc này o3 nó cũng trỏ tới cái object mà o1 trỏ tới (Chính là o2).
o1 = 1;
Khi mình gán trực tiếp o1 = 1 rồi thì lúc này o1 sẽ chỉ có tham chiếu duy nhất trỏ tới nó đó là o3 (o1 = 1 thì khi đó o1 không còn tham chiếu tới o2 nữa).
var o4 = o3.o2;
o4 được khởi tạo và nó bằng o3.o2 nghĩa là lúc này nó có trỏ tới o2 và có một property là x = 1. o4 lúc này sẽ có 2 reference:
  1. Biến x trong o2
  1. Chính giá trị của nó
o3 = '374';
Nếu gán trực tiếp o3 với giá trị '374' thì lúc này o1 sẽ không có đối tượng nào tham chiếu đến nó nữa cả. Lúc này nó chính là rác. Nhưng mà lúc này giá trị ban đầu của nó là o2 vẫn tồn tại và được tham chiếu bởi o4, cho nên vùng nhớ chứa nó vẫn không được giải phóng.
o4 = null;
o4 trước đó tham chiếu tới o2 nhưng mà giờ được gán giá trị khác vậy lúc này o1 chính xác là không còn ai tham chiếu tới nữa. Nó sẽ được thu gom.

8.3. Vấn đề đến từ cycles (Chu kỳ, vòng lặp)

Có một sự hạn chế đến từ các vòng lặp. Ở ví dụ sau chúng ta sẽ thấy 2 đối tượng tham chiếu lẫn nhau và tạo ra một vòng lặp. Chùng nằm ngoài scope sau khi function được gọi và khi đó chúng không còn tác dụng gì nữa và vùng nhớ của chúng có thể được giải phóng. Thế nhưng đối với thuật toán đếm số lượt tham chiếu để dọn dẹp rác hiện tại thì nó vẫn sẽ thấy rằng nếu ít nhất còn một đối tượng tham chiếu tới thì vẫn sẽ chưa thể được giải phóng. Nghĩa là khi chúng cứ liên tục tham chiếu tới nhau tạo ra một vòng lặp. Thì lúc đó cả hai đều không thể được giải phóng.
function f() { var o1 = {}; var o2 = {}; o1.p = o2; // o1 references o2 o2.p = o1; // o2 references o1. This creates a cycle. } f();
notion image

8.4. Thuật toán Mark-and-sweep

Để quyết định xem một đối tượng có còn được sử dụng hay không, thuật toán này xác định xem đối tượng có thể truy cập được hay không.
Thuật toán này có 3 bước:
  1. Roots: Về cơ bản thì roots là một biến toàn cục đc tham chiếu đến trong code. Ví dụ như trong JavaScript chúng ta có object window là biến global được coi như là root. Trong Nodejs các đối tượng giống nhau được gọi là "global". Một danh sách đầy đủ các roots sẽ được build bởi garbage collector.
  1. Tiếp theo đó thuật toán sẽ bắt đầu kiểm tra toàn bộ các roots trong list và các phần tử con của nó sau đó đánh giấu là active (Nghĩa là không phải là rác). Và ngược lại nếu cái nào mà root không trỏ tới thì coi như là rác.
  1. Và cuối cùng garbage collector sẽ giải phóng hết vùng nhớ của những phần tử không được đánh active và giải phóng vùng nhớ của chúng trả về cho hệ điều hành có thể sử dụng.
notion image
Thuật toán này tốt hơn thuật toán trước vì một đối tượng không có tham chiếu nào dẫn đến việc đối tượng này cũng không thể truy cập được. Và rõ ràng lý thuyết này không đúng với trường hợp các cycles. Kể từ năm 2012, tất cả các trình duyệt hiện đại đều có bộ Mark-and-sweep garbage collector. Tất cả các cải tiến về vấn đề thu gom rác ở JavaScript như (generational - incremental - concurrent - parallel garbage collection) đều hướng tới việc phát triển thuật toán này. Nhưng mà nó thực tế không cải thiện vấn đề chung của chính GC hay có thể đạt được mục tiêu là xác định được một đối tượng nào đó có thể tiếp cận được hay không.

8.5. Cycles đã không còn là vấn đề lúc này nữa

Trong ví dụ đầu tiên ở trên, sau khi function được gọi trả về giá trị, hai đối tượng không được tham chiếu bởi global variable nữa, thì lúc này chúng sẽ được tìm thấy bởi GC.
notion image
Mặc dù chúng có sự tham chiếu qua lại lẫn nhau nhưng vẫn được coi là rác vì không được root tham chiếu tới. Nên lúc này chúng sẽ bị gom đi.

8.6. Sự không trực quan của Garbage Collectors

Mặc dù Garbage Collectors rất tiện lợi nhưng đi kèm đó cũng có một số hạn chế khi xảy ra một vài vấn đề có thể không tích hợp được với chính nó. Một trong số đó là non-determinism (Thuật toán không đơn định). Nói cách khác thì GCs là một kiểu không thể đoán trước được. Bạn thực sự không thể biết được khi nào thì việc collection được thực hiện. Trong một vài trường hợp thì chương trình của chúng ta có thể sử dụng nhiều vùng nhớ hơn mức nó yêu cầu. Trong một số trường hợp vấn đề tạm ngưng hoạt động (Short - Pause) cũng khá cần được lưu tâm đối với một vài ứng dụng nhạy cảm.
Mặc dù non-determinism nghĩa là chúng ta không chắc chắn được khi nào thì việc collection được thực thi, nhưng trong hầu hết các tiến trình của CGs chúng đều dùng chung một mô hình để thực hiện việc dọn dẹp trong suốt quá trình phần bổ vùng nhớ. Nếu không có hoạt động phân bổ nào được thực hiện thì hầu hết các CGs cũng sẽ không hoạt động. Hãy xem xét các kịch bản sau đây:
  1. Cấp phát một vùng nhớ với size khá lớn.
  1. Hầu hết các elements (Hoặc tất cả chúng) đều được đánh giấu không thể truy cập được (Giả sử chúng ta vô hiệu hóa một con trỏ trỏ đến cái cache mà chúng ta không còn dùng nữa).
  1. Không còn hoạt động phân bổ nào được thực hiện nữa.
Trong trường này hầu hết CG đều sẽ không chạy. Nói một cách khác, mặc dù trong các trường hợp trên GCs đã xác được những đối tượng không được truy vấn tới và chúng sẳng sàng để gom dọn nhưng những collector không đưa ra yêu cầu thực thi thì GC cũng sẽ không chạy. Những trường hợp này không phải vấn đề rò rỉ nghiệm trọng lắm nhưng thực tế thì nó vẫn dẫn tới vấn đề sử dụng vùng nhớ nhiều hơn mức định ra.