+3

Memory Leaks trong Javascript

Giới thiệu

Memory leaks là vấn đề mà mọi deveploper đều sẽ gặp phải khi code. Memory leaks sẽ dấn đến việc ứng dụng sẽ chạy chậm hơn, crashes, hay có thể ảnh hưởng đến các ứng dụng khác. Vậy memory leaks là gì?

Memory leaks có thể được định nghĩa là một bộ nhớ (memory) không được sử dụng trong ứng dụng nữa nhưng vì một lý do nào đó mà nó chưa được giải phóng và trả về hệ điều hành hoặc một cái pool chứa các bộ nhớ (memory) chưa sử dụng. Các ngôn ngữ lập trình khác nhau sẽ có các cách khác nhau để quản lý bộ nhớ. Những cách quản lý bộ nhớ này sẽ giúp giảm thiểu khả năng bị memory leaks của chương trình. Tuy nhiên, việc xác định một vùng bộ nhớ có còn được sử dụng hay không lại là một vấn đề khó có thể xác định. Chỉ có developer mới có khả năng quyết định xem là vùng nhớ này nên được giải phóng hay không. Một số ngôn ngữ (như javascript) cung cấp tính năng tự động giải phóng bộ nhớ cho developer, một số khác thì developer cần phải tự mình giải phóng bộ nhớ khi không sử dụng đến nó nữa.

Quản lý bộ nhớ trong JS

Javascript là một trong những ngôn ngữ có garbage collection. Những ngôn ngữ lập trình như Javascript thế này sẽ thay developer quản lý bộ nhớ bằng cách kiểm tra định kỳ các vùng nhớ được cấp phát trước đó có có thể được "với tới" bởi các phần khác trong ứng dụng. Có thể nói cách khác là những ngôn ngữ như Javascript sẽ giúp biến vấn đề từ "những vùng nhớ nào vẫn còn cần trong ứng dụng" thành "những vùng nhớ nào có thể được ứng dụng access đến". Sự khác biệt của 2 vấn đề là không nhiều nhưng lại rất quan trọng: chỉ developer mới có thể biết được là vùng nhớ nào còn cần để chạy tuy nhiên, việc xác định xem một vùng nhớ có thể vươn tới không trong ứng dụng thì có thể làm tự động bởi thuật toán.

Memory leaks trong JS

Lý do chính của memory leaks trong các ngôn ngữ có garbage collection là các reference không mong muốn vào bộ nhớ (unwanted references), tức là một vùng nhớ được trỏ đến mà lại không được sử dụng trong ứng dụng. Để có thể hiểu rõ hơn về nó, trước hết ta cần tìm hiểu các hoạt động của garbage collector, cách nó xác định một vùng nhớ có thể được "với tới" (reach) bởi ứng dụng.

Mark and sweep

Hầu hết các garbage collector đều sử dụng thuật toán mark-and-sweep để thực hiện việc giải phóng bộ nhớ. Thuật toán này bao gồm các bước sau:

  1. Đầu tiên, garbage collector sẽ xây dựng một danh sách các roots. Roots thực chất là các biến toàn cục mà có reference được lưu trong code. Trong Javascript, window chính là một biến toàn cục như vậy. Window sẽ luôn hiện hữu trong chương trình nên garbage collector có thể coi nó và tất cả các con của nó luôn hiện hữu.

  2. Tất cả roots và con của chúng sẽ được đánh dẫu là đang hoạt động. Tất cả những vùng nhớ mà có thể được vươn tới từ roots thì đều được coi là đang hoạt động và không đánh dấu là rác (garbage).

  3. Tất cả các vùng nhớ mà không được đánh dẫu là rác (garbage) thì bây giớ đều sẽ được coi là rác. Bây giờ thì các collector có thể giải phóng các vùng nhớ này.

Mặc dù thuật toán này được tối ưu bởi các GC (garbage collector) hiện đại tuy nhiên cơ chế của nó vẫn không đổi: những vùng nhớ vươn tói được thì được coi là đang hoạt động, những vùng nhớ khác sẽ được coi là rác.

Những tham chiếu không mong muốn (Unwanted references) là những tham chiếu đến các vùng bộ nhớ mà developer biết là nó không được cần đến nữa nhưng vì lý do nào đó mà nó vẫn được giữ lại trong hệ thống. Trong JS, những tham chiếu không mong muốn này là các biến (variables) được giữ đâu đó trong code mà nó sẽ không được sử dụng đến nữa nhưng lại trỏ đến một vùng nhớ mà cần được giải phóng.

Để hiểu được memory leaks trong JS, ta cần biết được là khi nào thì một tham chiếu bị lãng quên.

3 loại memory leaks trong JS

1: Biến toàn cục

Javascript có một cơ chế là đặt biến mà không cần khai báo. Ví dụ:

a = "value";
console.log(a); //"a"

Khi một biến được khai báo như trên thì JS sẽ tự động gán nó vào global object (window trên browser). Nếu như biến này chỉ hoạt động trên phạm vi toàn cục (global scope) thì cũng không có sự khác biệt cho lắm. Tuy nhiên, nếu nó được định nghĩa trong một hàm thì đó lại là chuyện khác. Ví dụ:

function foo(){
  bar = "đây là biến toàn cục ẩn"
}

Đoạn code trên sẽ tương đương với đoạn code sau trên browser:

function foo(){
  window.bar = "đây là biến toàn cục"
}

Nếu khai báo bar trong phạm vi của hàm foo mà lại không sử dụng var để khai báo thì biến bar sẽ được tạo với phạm vi toàn cục, và đây là một ví dụ điển hình về memory leaks.

Một cách khác mà có thể vô tình tạo ra biến toàn cục đó là thông qua this:

function foo() {
  this.variable = "có thể là biến toàn cục";
}

foo();

this trong hàm sẽ trỏ đến biến root toàn cục (window) nếu hàm đó được gọi trực tiếp không thông qua object nào khác nên ở ví dụ trên, biến variable sẽ được gắn vào phạm vi toàn cục.

Một cách để giảm thiểu những lỗi trên đó là thêm "use strict;" vào dòng đầu tiên của file JS. Nó sẽ giúp ngăn chặn việc khai báo biến toàn cục như trên.

Chú ý khi làm việc với biến toàn cục

Biến toàn cục không bao giờ được giải phóng bộ nhớ tự động theo thuật toán mark-and-sweep ở trên. Vì thế, biến toàn cục chỉ nên được sử dụng để lưu tạm dữ liệu để xử lý. Nếu cần lưu một lượng lớn dữ liệu vào biến toàn cục thì cần đảm bạo là nó sẽ bị gán về null hoặc gán lại dữ liệu khi mà bạn đã sử dụng xong nó.

2: Callback và timer bị lãng quên

Sau đây là một ví dụ dẫn đến memory leak khi sử dụng setInterval:

var data = getData();
setInterval(function(){
  var node = document.getElementById("Node");
  if(node){
    node.innerHTML = JSON.stringify(someResource));
  }
}, 1000);

Đây là một ví dụ về một timer bị treo. Timẻ bị treo tức là khi timer tham chiếu đến các node hoặc dữ liệu mà không còn được sử dụng nữa. Ở ví dụ trên, nếu như node bị xóa ở một lúc nào đấy thì toàn bộ đoạn code xử lý trong hàm callback của interval sẽ không cần đến nữa. Tuy nhiên, vì interval vẫn còn hoạt động nên các vùng nhớ được sử dụng trong hàm callback của interval cũng không được giải phóng (muốn giải phóng cần dừng interval lại). Tiếp đó, các object từ bên ngoài mà được hàm callback của interval tham chiếu đến cũng không thể được giải phóng vì vẫn có thể vươn tới được thông qua hàm callback kia. Theo ví dụ trên thì đó là data.

Một trường hợp có thể dẫn đến leaks đó là do các observers object (DOM và event listener của chúng). Điều này chỉ ảnh hưởng đến các trình duyệt cũ (vd: IE6) vì các trình duyệt mới sẽ tự động làm điều này cho chúng ta. Đây là một bug của GC của IE6 và dẫn đến việc tham chiếu quay vòng.

3: Tham chiếu tới các DOM đã bị xóa

Có những lúc bạn muốn lưu các DOM vào một số cấu trúc dữ liệu như mảng hoặc object trong JS code để làm một loạt các tác vụ nào đấy. Ví dụ bạn muốn update dữ liệu của một vài element nào đấy thì việc lưu các element này vào một mảng là hoàn toàn hợp lý. Khi điều này xảy ra thì sẽ có 2 tham chiếu đên DOM element này: một là từ DOM tree, hai là từ đối tượng mảng của JS. Nếu bạn muốn xóa các element này thì bạn cần phải xóa toàn bộ các tham chiếu tới chúng để có thể giải phóng bộ nhớ.

Ví dụ:

var elements = {
  button: document.getElementById('button'),
  image: document.getElementById('image'),
  text: document.getElementById('text')
}

function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
}

function removeButton() {
    // button là con của body.
    document.body.removeChild(document.getElementById('button'));

    // Ở đây thì button vẫn được tham chiểu đến bởi elements. Nói cách khác là
    // nó vẫn nằm trong bộ nhớ và không thể được giải phóng.
}

Còn một vấn đề quan trọng nữa là khi tham chiếu đến một node lá hoặc một inner node của DOM tree, ví dụ như một ô trong bảng (<td> của <table>). Nếu bạn tham chiếu đến <td> này trong JS code thì khi bạn xóa <table> chứa node này thì GC sẽ không giải phóng được cả table chứ không phải là chỉ mỗi <td> node không được giải phóng. Vì node con còn tham chiếu đến node cha nên nó sẽ được GC coi là vẫn được tham chiếu và bỏ qua nó. Vì thế nên cẩn thận khi tham chiếu đến các DOM.

4: Closures

Closures có nghĩa đơn giản là hàm nằm trong phạm vi của một hàm khác có thể tham chiếu tới các biến của hàm bao nó. Vì sao Closures có thể gây ra leak, hãy xem ví dụ sau:

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage);
    }
  };
};
setInterval(replaceThing, 1000);

Ví dụ này cho ta thấy mỗi khi replaceThing được gọi, theThing sẽ tạo ra một object mới chứa một mảng và một closures (someMethod). Cùng lúc đó, biến unused cũng lưu một closures tham chiếu đên originalThing (là object theThing được tạo ra từ việc gọi replaceThing ở bước trước đó). Một điều quan trọng nữa là khi một scope được tạo ra cho các closures mà có cùng scope cha, chúng sẽ cùng chia sẻ scope đó. Trong ví dụ này thì someMethodunused đều chia sẻ cùng một scope. Mặc dù unused không được gọi đến nhưng vì nó có tham chiếu đến originalThing nên nó sẽ được GC coi là vẫn đang hoạt động. Khi đoạn code này chạy thì bộ nhớ của chương trình sẽ tăng đều đặn và có thể nhìn thấy ngay được. Về bản chất, một linked-list của closures được tạo (với root là theThing) khi đoạn code trên được chạy và đó là lý do bộ nhớ bị tăng dần theo thời gian.

Tạm kết

Trên đây là một số nguyên nhân dẫn đến memory leak và cách khắc phục chúng trong JS. Phần sau sẽ đề cập đến việc phát hiện memory leak trong code với một số profiling tool.

References

4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.