Facebook Pixel

Quản lý và Tối ưu hóa Memory trong JavaScript

12 Nov, 2024

Tran Thuy Vy

Frontend Developer

Tìm hiểu cách JavaScript quản lý memory, các vấn đề phổ biến và kỹ thuật tối ưu để bạn có thể tạo ra những ứng dụng hiệu quả nhất

Quản lý và Tối ưu hóa Memory trong JavaScript

Mục Lục

Khi phát triển ứng dụng JavaScript, việc quản lý bộ nhớ đóng vai trò quan trọng trong việc đảm bảo hiệu suất. Tối ưu hóa bộ nhớ giúp cải thiện tốc độ, mang đến trải nghiệm tốt hơn cho người dùng. Vấn đề này, mình nghĩ chắc nhiều bạn cũng đã và đang mắc phải, ngay cả bản thân mình cũng vậy.

Bài viết này, mình sẽ hướng dẫn chi tiết dựa trên kinh nghiệm cá nhân về cách JavaScript quản lý memory, các vấn đề phổ biến và kỹ thuật tối ưu để bạn có thể tạo ra những ứng dụng hiệu quả nhất. Đừng lo lắng, vì nó không quá khó khăn như bạn nghĩ đâu, làm là bị cuốn á.

1. Hiểu rõ về JavaScript’s Memory Lifecycle

JavaScript quản lý memory thông qua cơ chế Garbage Collection (GC), giúp các developer không cần trực tiếp thao tác cấp phát và giải phóng memory.

Tuy nhiên, để hiểu rõ cách tối ưu hóa, bạn cần nắm rõ ba giai đoạn trong memory lifecycle:

  • Cấp phát (Allocation): JavaScript cấp phát memory khi bạn tạo các variables, object và function.
  • Cách sử dụng (Usage): Memory được sử dụng khi các variables, object hoặc function còn cần trong code.
  • Giải phóng (Garbage Collection): GC sẽ tự động giải phóng memory của các object không còn tham chiếu nào.

Ví dụ như:

Javascript
function allocateMemory() {
    let data = new Array(1000000).fill("memory");
}

Khi mà allocateMemory chạy, một array lớn sẽ được cấp phát. Khi function hoàn thành, mảng không còn tham chiếu đến data thì GC sẽ giải phóng memory.

Nhưng giả sử mình giữ lại tham chiếu thì sẽ như thế nào?

Javascript
let leakedData;
function allocateMemory() {
    leakedData = new Array(1000000).fill('memory');
}
allocateMemory();

Ở đây, biến toàn cục leakedData vẫn giữ tham chiếu đến mảng, khiến GC không thể giải phóng được memory.

2. Memory Leaks phổ biến trong JavaScript

Mặc dù JavaScript có cơ chế Garbage Collection, một số lỗi code vẫn khiến memory không được giải phóng, dẫn đến rò rỉ. Dưới đây, là các trường hợp phổ biến và cách phát hiện.

2.1 Biến toàn cục

Biến toàn cục sẽ tồn tại trong suốt vòng đời của ứng dụng và rất hiếm khi được Garbage Collection. Nếu như không cần thiết, việc sử dụng biến toàn cục sẽ gây lãng phí memory.

Javascript
function myFunc() {
    globalVar = "I'm a memory leak!";
}
myFunc();

Ở đây, globalVar được khai báo không đúng cách (không có let, const hoặc var), vô hình chung sẽ trở thành biến toàn cục.

Cách khắc phục: luôn khai báo biến với let, const, var

Javascript
function myFunc() {
    let localVar = "I'm safe!";
}
myFunc();

2.2 Tách biệt DOM Nodes

Khi DOM nodes bị xóa khỏi DOM nhưng vẫn còn được tham chiếu đến, chúng sẽ không bị giải phóng.

Javascript
let element = document.getElementById('myElement');
document.body.removeChild(element);
// biến 'element' vẫn giữ tham chiếu đến DOM nodes

Thường thì để tránh, bạn nên xóa tham chiếu bằng cách gán null.

Javascript
let element = document.getElementById("myElement");
document.body.removeChild(element);

2.3 Timers và Callbacks Không Được Giải Phóng

Các setInterval, setTimeout giữ lại tham chiếu nếu không được xóa.

Mình để ý thấy nhiều bạn sẽ viết như thế này:

Javascript
function startTimer() {
    setInterval(() => {
        // Thực hiện một số công việc
    }, 1000);
}
startTimer();
// Timer chạy vô thời hạn mà không thấy dừng

Để khắc phục điểm này, bạn nên lưu lại ID của timer và xóa khi không còn cần thiết.

Javascript
function startTimer() {
    let timerId = setInterval(() => {
        // Thực hiện một số công việc
    }, 1000);
    // clear khi không cần
    clearInterval(timerId);
}
startTimer();

2.4 Closures Không Được Giải Phóng

Closures lưu trữ các biến từ func ngoài. Nếu không cẩn thận, closures có thể giữ lại dữ liệu lớn không cần thiết.

Javascript
function outer() {
    let bigData = new Array(1000000).fill('data');
    return function inner() {
        console.log(bigData[0]);
    };
}
let closureFunc = outer();
// 'bigData' vẫn tồn tại vì 'inner' giữ tham chiếu

Khắc phục điểm này bằng cách giảm phạm vi của biến hoặc tránh giữ lại tham chiếu không còn dùng.

Javascript
function outer() {
    let bigData = new Array(1000000).fill('data');
    function inner() {
        console.log(bigData[0]);
    }
    inner();
}
outer();
// 'bigData' sẽ được giải phóng sau khi 'outer' hoàn thành

3. Cách phòng và khắc phục Memory Leaks

Memory leaks xảy ra khi ứng dụng tiếp tục sử dụng bộ nhớ mà không giải phóng nó sau khi không còn dùng. Điều này sẽ dẫn đến việc sử dụng bộ nhớ tăng dần theo thời gian, có thể giảm hiệu suất hoặc treo ứng dụng. Dưới đây là các kỹ thuật phổ biến để ngăn và xử lý memory leaks trong JavaScript.

3.1 Giảm thiểu biến toàn cục

Biến toàn cục tồn tại trong suốt vòng đời của ứng dụng và không được thu gom bởi Garbage Collector (GC) cho đến khi ứng dụng kết thúc. Việc sử dụng quá nhiều biến toàn cục có thể dẫn đến tiêu tốn bộ nhớ không cần thiết. Bạn hãy cùng theo dõi ví dụ sau:

Javascript
function createLeak() {
  globalVar = 'I am a global variable';
}

createLeak();

Trong ví dụ này, globalVar được khai báo mà không sử dụng let, const hoặc var, do đó nó trở thành một biến toàn cục. Vậy vấn đề ở đây là gì?

  • Biến toàn cục globalVar sẽ tồn tại cho đến khi ứng dụng kết thúc.
  • Nếu biến này lưu trữ lượng dữ liệu lớn, nó sẽ chiếm nhiều bộ nhớ không dùng.

Cách khắc phục vấn đề này khá là đơn giản, bạn hãy nhớ luôn sử dụng let, const hoặc var để giới hạn lại phạm vi của biến.

Javascript
function scopedFunction() {
    let localVar = "This is local";
    // Sử dụng 'localVar' trong phạm vi func
}
scopedFunction();
// 'localVar' không tồn tại bên ngoài func

3.2 Xóa tham chiếu DOM Nodes

Khi bạn thao tác với DOM (Document Object Model), việc tạo và xóa các nút (nodes) là thường xuyên. Nếu bạn không xóa các tham chiếu đến các nút DOM đã bị loại bỏ khỏi cây DOM, bộ nhớ sẽ không được giải phóng.

Javascript
let element = document.getElementById('myElement');

// Xóa phần tử khỏi DOM
document.body.removeChild(element);

// Nhưng biến 'element' vẫn giữ tham chiếu đến nút DOM đã bị xóa
  • Biến element vẫn giữ tham chiếu đến nút DOM đã bị xóa khỏi cây DOM.
  • GC không thể giải phóng bộ nhớ của nút DOM này.

Bạn nên gán null cho biến để đảm bảo xóa các tham chiếu đến DOM nodes khi không còn sử dụng.

Ví dụ như:

Javascript
// Xóa phần tử khỏi DOM
document.body.removeChild(element);

// Xóa tham chiếu
element = null;
  • Khi bạn tiến hành gán null cho element, bạn sẽ loại bỏ tham chiếu đến nút DOM.
  • GC sẽ xác định rằng nút DOM không còn được tham chiếu ở bất kỳ đâu và giải phóng memory.

Lưu ý chút nha, nếu biến element của bạn được tham chiếu ở nhiều nơi khác, bạn cần đảm bảo xóa tất cả các tham chiếu để GC có thể giải phóng memory.

3.3 Quản Lý Event Listeners và Timers

3.3.1 Timers

Các bộ đếm thời gian (setInterval, setTimeout) và event listeners nếu không được xóa khi không cần thiết sẽ giữ lại các tham chiếu đến hàm callback và các biến liên quan.

Đối với setInterval, thường bạn sẽ sử dụng thế này đúng không?

Javascript
function startAutoRefresh() {
  setInterval(() => {
    // Thực hiện một số công việc
  }, 1000);
}

startAutoRefresh();
// Timer sẽ chạy vô thời hạn nếu không được dừng

Nếu bạn đang sử dụng như thế này, sẽ xảy ra vấn đề:

  • setInterval tiếp tục chạy mãi mãi, ngay cả khi không còn sử dụng.
  • Điều này gây lãng phí tài nguyên và bộ nhớ.

Vậy nên, bạn hãy kiểm tra và sử dụng clearInterval để dừng bộ đếm thời gian khi không còn cần thiết. Ví dụ như sau:

Javascript
let intervalId;

function startAutoRefresh() {
  intervalId = setInterval(() => {
    // TODO: ...
  }, 1000);
}

function stopAutoRefresh() {
  clearInterval(intervalId);
}

startAutoRefresh();

// Khi không còn cần thiết
stopAutoRefresh();

Giải thích một chút về ví dụ trên:

  • Mình lưu lại ID của bộ đếm để có thể dừng nó sau này, khi mà không còn sử dụng nữa
  • Gọi clearInterval(intervalId) để dừng bộ đếm thời gian và giải phóng memory.

3.3.2 Event Listeners

Bạn hãy theo dõi ví dụ này:

Javascript
function attachEvent() {
  let element = document.getElementById('myButton');
  element.addEventListener('click', function handleClick() {
    // Xử lý sự kiện
  });
}

attachEvent();
// Listener sẽ tồn tại ngay cả khi 'element' bị xóa khỏi DOM
  • Event listener giữ tham chiếu đến element và hàm handleClick.
  • Nếu không gỡ bỏ listener, memory sẽ không được giải phóng.

Chú ý sử dụng removeEventListener để gỡ bỏ listener khi không còn sử dụng.

Javascript
function attachEvent() {
  let element = document.getElementById('myButton');

  function handleClick() {
    // Xử lý sự kiện
  }

  element.addEventListener('click', handleClick);

  // Khi không còn cần thiết
  element.removeEventListener('click', handleClick);
}

attachEvent();
  • Luôn lưu trữ hàm callback trong một biến hoặc hàm đặt tên để có thể gỡ bỏ sau này.
  • Gọi removeEventListener với cùng loại sự kiện và hàm callback để gỡ bỏ listener.

Một lưu ý nhỏ khi bạn làm việc với ứng dụng Single Page (SPA):

Ở trong SPA, các component có thể được thêm và xóa động, việc quản lý event listeners và timers sẽ càng quan trọng để ngăn ngừa memory leaks. Ví dụ trong React Component:

JSX
import React, { useEffect } from 'react';

function MyComponent() {
  useEffect(() => {
    const intervalId = setInterval(() => {
      // Thực hiện một số công việc
    }, 1000);

    return () => {
      // Hàm cleanup
      clearInterval(intervalId);
    };
  }, []);

  return <div>My Component</div>;
}

export default MyComponent;
  • Sử dụng hàm cleanup trong useEffect để dọn dẹp timers khi component bị unmount.
  • Giúp ngăn ngừa memory leaks khi component không còn hiển thị.

4. Các kỹ thuật tối ưu (Optimization) memory

Ngoài việc tránh rò rỉ memory, tối ưu hóa bộ nhớ sẽ giúp cải thiện hiệu suất ứng dụng. Dưới đây là một số kỹ thuật bạn nên biết đến

4.1 Sử dụng Weak References (WeakMap, WeakSet)

Weak References là các tham chiếu yếu đến đối tượng, cho phép Garbage Collector - GC giải phóng bộ nhớ khi không còn tham chiếu mạnh nào đến đối tượng đó.

  • Trong JavaScript, WeakMap và WeakSet là hai cấu trúc dữ liệu hỗ trợ Weak References.
  • WeakMap lưu trữ cặp key-value, trong đó key là một đối tượng và value có thể là bất kỳ loại dữ liệu nào.
  • WeakSet lưu trữ các đối tượng duy nhất.

Ví dụ khi bạn sử dụng Map

Javascript
let map = new Map();
let obj = { name: 'Object 1' };
map.set(obj, 'Some metadata');

// Xóa tham chiếu gốc
obj = null;

// Đối tượng 'obj' vẫn tồn tại trong 'map', ngăn GC giải phóng bộ nhớ

Mặc dù mình đã gán obj = null, đối tượng vẫn được giữ lại trong map. Bạn nên sử dụng WeakMap để khắc phục vấn đề này:

Javascript
let weakMap = new WeakMap();
let obj = { name: 'Object 1' };
weakMap.set(obj, 'Some metadata');

// Xóa tham chiếu gốc
obj = null;

Khi obj không còn được tham chiếu ở nơi nào khác ngoài weakMap, GC sẽ tự động giải phóng bộ nhớ của obj.

Nhưng hãy cẩn thận, cân nhắc khi sử dụng WeakMap:

  • WeakMap chỉ chấp nhận các keys là đối tượng, không phải là giá trị nguyên thủy như số hay chuỗi.
  • Không thể lặp qua WeakMap hoặc WeakSet bằng cách sử dụng vòng lặp for...of hoặc các phương thức như forEach, vì các keys có thể biến mất bất cứ lúc nào do GC.

Theo mình, thì chỉ nên sử dụng khi: cần lưu trữ dữ liệu phụ liên quan đến đối tượng mà không muốn ngăn cản việc GC giải phóng đối tượng đó. Ví dụ: lưu trữ trạng thái, metadata hoặc các thông tin tạm thời khác.

4.2 Lazy Loading

Lazy Loading là kỹ thuật trì hoãn việc khởi tạo hoặc tải dữ liệu cho đến khi thực sự cần thiết. Mục đích là giảm thiểu việc sử dụng tài nguyên hệ thống (bộ nhớ, băng thông) và cải thiện hiệu suất.

Các lợi ích khi sử dụng Lazy Loading:

  • Tiết kiệm bộ nhớ: chỉ sử dụng bộ nhớ khi cần thiết.
  • Tăng tốc độ khởi động ứng dụng: tránh tải các tài nguyên không cần thiết ngay lập tức.
  • Cải thiện trải nghiệm người dùng: ứng dụng phản hồi nhanh hơn.
Javascript
// Tải dữ liệu lớn ngay khi ứng dụng khởi động
let heavyData = loadHeavyData();

function loadHeavyData() {
  // Giả sử đây là hàm tải dữ liệu lớn
  return new Array(1000000).fill('data');
}

function processData() {
  // Sử dụng 'heavyData' để xử lý
}
  • heavyData được tải ngay cả khi processData chưa được gọi.
  • Lãng phí bộ nhớ nếu heavyData không được sử dụng.
Javascript
let heavyData = null;

function getHeavyData() {
  if (!heavyData) {
    heavyData = loadHeavyData();
  }
  return heavyData;
}

function loadHeavyData() {
  // Giả sử đây là hàm tải dữ liệu lớn
  return new Array(1000000).fill('data');
}

function processData() {
  let data = getHeavyData();
  // Sử dụng 'data' để xử lý
}
  • heavyData chỉ được khởi tạo khi getHeavyData được gọi lần đầu tiên.
  • Nếu như processData không bao giờ được gọi, heavyData sẽ không bao giờ được load.

Trong thực tế bạn cũng dễ dàng thấy được:

  • Tải hình ảnh hoặc video khi người dùng cuộn đến (ví dụ: trên các trang web dài).
  • Tải module hoặc component khi cần thiết trong các ứng dụng SPA (Single Page Application).
  • Kết nối cơ sở dữ liệu hoặc thư viện bên ngoài chỉ khi cần sử dụng.

Đừng quá lạm dụng lazy loading, bạn cần đảm bảo việc trì hoãn sẽ không ảnh hưởng đến trải nghiệm người dùng (ví dụ: không gây ra độ trễ đáng kể khi người dùng thực sự cần dữ liệu).

4.3 Cấu trúc Dữ liệu Map, Set

Map và Set là các cấu trúc dữ liệu được cung cấp trong ES6, cung cấp hiệu suất và tính năng vượt trội so với Object và Array trong một số trường hợp.

  • Map: lưu trữ cặp key-value, keys có thể là bất kỳ loại dữ liệu nào (bao gồm cả object, function).
  • Set: lưu trữ các giá trị duy nhất, không trùng lặp.

Lợi ích:

  • Hiệu suất cao hơn: các thao tác thêm, xóa, tìm kiếm nhanh hơn với dữ liệu lớn.
  • Linh hoạt: keys trong Map có thể là bất kỳ giá trị nào, không chỉ là string.
  • Tránh trùng lặp: Set đảm bảo mỗi giá trị chỉ xuất hiện duy nhất một lần.
Javascript
let data = {};
for (let i = 0; i < 1000000; i++) {
  data['key' + i] = 'value' + i;
}

// Kiểm tra sự tồn tại của một key
if (data.hasOwnProperty('key500000')) {
  // Thực hiện công việc
}
  • Các thao tác với Object có thể chậm khi số lượng keys lớn.
  • Keys trong Object chỉ có thể là chuỗi hoặc symbol.
  • Các phương thức kế thừa từ Object.prototype có thể gây ra xung đột.

Bạn có thể cải thiện đoạn code trên với Map

Javascript
let data = new Map();
for (let i = 0; i < 1000000; i++) {
  data.set('key' + i, 'value' + i);
}

// Kiểm tra sự tồn tại của một key
if (data.has('key500000')) {
  // Thực hiện công việc
}

Hay bạn có thể sử dụng Set để loại bỏ sự trùng lặp trong dữ liệu

Javascript
let arrayWithDuplicates = [1, 2, 3, 2, 1, 4, 5];

let uniqueArray = Array.from(new Set(arrayWithDuplicates));

console.log(uniqueArray); // Kết quả: [1, 2, 3, 4, 5]

Lưu ý:

  • Khi làm việc với dữ liệu lớn, việc chọn cấu trúc dữ liệu phù hợp có thể cải thiện hiệu suất đáng kể.
  • Map và Set không thay thế hoàn toàn Object và Array được, mà cần sử dụng khi phù hợp với nhu cầu cụ thể.

4.4 Pooling

Object Pooling là kỹ thuật tái sử dụng các đối tượng đã tạo thay vì tạo mới mỗi lần cần. Phù hợp khi việc khởi tạo đối tượng tốn kém về tài nguyên (CPU, bộ nhớ) và bạn cần tạo nhiều đối tượng tương tự trong suốt quá trình chạy ứng dụng.

  • Tránh việc tạo và hủy đối tượng liên tục.
  • Giảm thiểu việc cấp phát và giải phóng bộ nhớ.
  • Ứng dụng chạy mượt hơn, đặc biệt trong các trò chơi hoặc các ứng dụng đồ họa.
Javascript
function createParticle() {
  return {
    x: 0,
    y: 0,
    velocityX: Math.random(),
    velocityY: Math.random(),
    // Các thuộc tính khác
  };
}

function simulate() {
  let particles = [];
  for (let i = 0; i < 1000; i++) {
    particles.push(createParticle());
  }
  // Thực hiện mô phỏng với particles
  // Sau khi xong, particles sẽ được GC thu gom
}

Bạn có thể nhận thấy:

  • Mỗi lần simulate được gọi, 1000 đối tượng mới sẽ được tạo.
  • Gây áp lực lên GC khi phải thu gom nhiều đối tượng ngắn hạn.
Javascript
const particlePool = [];

function getParticle() {
  if (particlePool.length > 0) {
    return particlePool.pop();
  } else {
    return {
      x: 0,
      y: 0,
      velocityX: 0,
      velocityY: 0,
      // Các thuộc tính khác
    };
  }
}

function releaseParticle(particle) {
  // Đặt lại trạng thái của particle nếu cần
  particle.x = 0;
  particle.y = 0;
  particle.velocityX = 0;
  particle.velocityY = 0;
  particlePool.push(particle);
}

function simulate() {
  let particles = [];
  for (let i = 0; i < 1000; i++) {
    let particle = getParticle();
    particle.velocityX = Math.random();
    particle.velocityY = Math.random();
    particles.push(particle);
  }
  // Thực hiện mô phỏng với particles

  // Sau khi xong, đưa các particles trở lại pool
  for (let particle of particles) {
    releaseParticle(particle);
  }
}

getParticle:

  • Kiểm tra xem có đối tượng nào trong pool không.
  • Nếu có, sẽ lấy ra và sử dụng. Nếu không, tạo mới một đối tượng.

releaseParticle:

  • Đặt lại trạng thái của đối tượng để chuẩn bị cho lần sử dụng tiếp theo.
  • Đưa đối tượng trở lại pool.

simulate:

  • Sử dụng các đối tượng từ pool thay vì tạo mới.
  • Sau khi sử dụng xong, trả lại đối tượng vào pool.

Lưu ý:

  • Cần cẩn thận khi tái sử dụng đối tượng để tránh giữ lại dữ liệu cũ không mong muốn.
  • Đảm bảo reset hoặc khởi tạo lại các thuộc tính của đối tượng trước khi sử dụng lại.
  • Object Pooling có thể không phù hợp với mọi trường hợp. Cân nhắc trước giữa lợi ích và sự phức tạp trước khi triển khai.

5. Giám sát và phân tích memory

Việc giám sát và phân tích memory là bước quan trọng trong quá trình phát triển ứng dụng JavaScript, đặc biệt khi bạn muốn tối ưu hóa hiệu suất. Chrome DevTools cung cấp các công cụ giúp bạn theo dõi và phân tích việc sử dụng memory trong ứng dụng của mình.

5.1 Giới thiệu về Chrome DevTools

Chrome DevTools là bộ công cụ phát triển tích hợp trong trình duyệt Google Chrome, cung cấp các tính năng như kiểm tra DOM, gỡ lỗi JavaScript, phân tích hiệu suất và quản lý memory.

Tab Memory trong Chrome DevTools cho phép bạn:

  • Heap Snapshot để xem các đối tượng đang tồn tại trong bộ nhớ.
  • Allocation Timeline để theo dõi quá trình phân bổ memory theo thời gian.
  • Allocation Profiler để phân tích và phát hiện rò rỉ memory.

5.1.1 Sử dụng Heap Snapshot

Heap Snapshot là một bản chụp nhanh về trạng thái memory tại một thời điểm cụ thể. Nó cho phép bạn xem tất cả các đối tượng đang tồn tại trong bộ nhớ, kích thước của chúng và cách chúng được tham chiếu.

Khi nào nên sử dụng Heap Snapshot?

  • Khi bạn muốn kiểm tra xem có đối tượng nào không được giải phóng sau khi thực hiện một hành động cụ thể.
  • Khi bạn nghi ngờ có memory leaks và muốn xác định gốc.

Hướng dẫn sử dụng Heap Snapshot:

  1. Mở Chrome DevTools: nhấn F12 hoặc Ctrl + Shift + I (Windows/Linux) hoặc Cmd + Option + I (macOS).
  2. Chọn Tab Memory: ở phía trên của DevTools, nhấp vào tab Memory.
  3. Chọn Heap snapshot: trong phần Memory, bạn sẽ thấy ba tùy chọn:
  • Heap snapshot
  • Allocations on timeline
  • Allocation sampling

4. Bấm Take snapshot: nhấn chọn Take snapshot để chụp trạng thái bộ nhớ hiện tại.

Phân tích Heap Snapshot:

Sau khi chụp xong, bạn sẽ thấy một danh sách các đối tượng trong bộ nhớ tương tự như hình bên dưới

heap snapshot
  • Constructor: tên của function tạo đối tượng.
  • Distance: khoảng cách từ đối tượng đến gốc (root) của biểu đồ đối tượng.
  • Shallow Size: kích thước memory của đối tượng riêng lẻ.
  • Retained Size: tổng kích thước bộ nhớ mà đối tượng và các đối tượng con giữ lại.

Ví dụ, giả sử bạn có một đoạn code memory leaks như này:

Javascript
<!DOCTYPE html>
<html>
<head>
  <title>Memory Leak Example</title>
</head>
<body>
  <button id="leakButton">Click Me</button>
  <script>
    const leakedArrays = [];

    document.getElementById('leakButton').addEventListener('click', function() {
      // Mỗi lần nhấp chuột, tạo một mảng lớn và giữ tham chiếu
      const largeArray = new Array(1000000).fill('leak');
      leakedArrays.push(largeArray);
    });
  </script>
</body>
</html>

Cách phát hiện memory leaks:

B1: chụp Heap Snapshot trước khi bạn nhấp chuột.

B2: nhấp vào button vài lần để tạo memory leaks.

B3: chụp Heap Snapshot sau khi bạn nhấp vào button.

B4: so sánh hai snapshots để xem sự gia tăng bộ nhớ.

Bạn sẽ thấy số lượng lớn các mảng Array được tạo ra. Tiếp đó, bạn hãy kiểm tra đường dẫn đến các đối tượng này để xác định chúng được giữ lại bởi biến leakedArrays.

5.1.2 Allocation Timeline

Allocation Timeline cho phép bạn theo dõi việc phân bổ memory theo thời gian, giúp xác định khi nào và ở đâu memory sẽ được cấp phát.

Khi nào nên sử dụng Allocation Timeline?

  • Khi bạn muốn theo dõi việc sử dụng memory trong quá trình tương tác với ứng dụng.
  • Khi cần xác định các đỉnh (peaks) trong việc sử dụng memory.

Hướng dẫn sử dụng Allocation Timeline:

  • Bước 1: mở Chrome DevTools và chọn tab Memory.
  • Bước 2: chọn Allocations on timeline.
  • Bước 3: bấm Start để bắt đầu ghi lại.
  • Bước 4: thực hiện các hành động trong ứng dụng mà bạn muốn theo dõi.
  • Bước 5: chọn biểu tượng Stop góc trái màn để kết thúc ghi.

Phân tích kết quả nhận được:

  • Timeline View: hiển thị biểu đồ về việc phân bổ memory theo thời gian.
  • Summary: liệt kê các đối tượng được cấp phát và số lượng của chúng.

Bạn có thể lấy lại ví dụ phía trên để test thử: bạn có thể sử dụng Allocation Timeline để thấy rõ việc bộ nhớ tăng lên mỗi khi nhấp chuột.

  • Mỗi lần nhấp, sẽ có một đỉnh mới trong biểu đồ, thể hiện sự gia tăng bộ nhớ.
  • Bạn có thể chọn khoảng thời gian cụ thể để xem chi tiết các đối tượng được tạo ra.

5.1.3 Allocation Profiler

Allocation Profiler là công cụ lấy mẫu việc phân bổ memory, giúp bạn hiểu được mẫu phân bổ memory trong ứng dụng.

Khi nào sử dụng Allocation Profiler?

  • Khi bạn cần có cái nhìn tổng quan về việc phân bổ memory mà không cần chi tiết cho từng đối tượng.
  • Khi bạn muốn phân tích hiệu suất memory mà không ảnh hưởng nhiều đến hiệu suất của ứng dụng.

Hướng dẫn thực hiện Allocation Profiler:

  • Bước 1: chọn Allocation sampling trong tab Memory.
  • Bước 2: bấm Start để bắt đầu lấy mẫu.
  • Bước 3: thực hiện các hành động trong ứng dụng.
  • Bước 4: bấm Stop để kết thúc.

Phân tích kết quả:

  • Hiển thị các function và đoạn code gây ra việc phân bổ memory.
  • Giúp bạn xác định các phần code tốn nhiều memory nhất.

5.1.4 Lời khuyên khi sử dụng Chrome DevTools

  • Đặt tên cho các function và variables: giúp bạn có thể dễ dàng nhận diện trong Heap Snapshot.
  • Sử dụng phiên bản Chrome mới nhất: để tận dụng các cải tiến và tính năng mới.
  • Đóng các tab và ứng dụng không cần thiết: để giảm nhiễu trong kết quả phân tích.
  • Lặp lại quá trình nhiều lần: để xác nhận rằng vấn đề đã được giải quyết hay chưa.

6. Các kỹ thuật Garbage Collection nâng cao trong JavaScript

Garbage Collection (GC) là một cơ chế quan trọng trong JavaScript, giúp quản lý bộ nhớ tự động bằng cách giải phóng bộ nhớ của các đối tượng không còn được tham chiếu. Hiểu rõ về các kỹ thuật GC nâng cao sẽ giúp bạn viết code tối ưu hơn.

6.1 Mark-and-Sweep

Mark-and-Sweep là thuật toán cơ bản và phổ biến nhất được sử dụng trong các trình thông dịch JavaScript (như V8).

Quy trình hoạt động:

  1. Đánh dấu (Mark): GC bắt đầu từ các root objects (như window trong trình duyệt), và đánh dấu tất cả các đối tượng có thể truy cập trực tiếp hoặc gián tiếp.
  2. Quét (Sweep): sau khi đánh dấu, GC quét qua bộ nhớ và giải phóng bộ nhớ của các đối tượng không được đánh dấu (tức là không thể truy cập).

Giả sử bạn có các đối tượng liên kết với nhau như sau:

Javascript
let objectA = { name: 'A' };
let objectB = { name: 'B' };
let objectC = { name: 'C' };

objectA.ref = objectB;
objectB.ref = objectC;

Ban đầu:

  • objectA được tham chiếu bởi biến toàn cục.
  • objectB được tham chiếu bởi objectA.
  • objectC được tham chiếu bởi objectB.

Sau khi bạn thực hiện gán objectA = null:

  • objectA không còn tham chiếu đến đối tượng { name: 'A' }.
  • Nhưng objectB và objectC vẫn được tham chiếu qua các thuộc tính ref.

Nếu áp dụng Mark-and-Sweep:

  • Bắt đầu từ các root objects, GC thấy rằng objectA là null.
  • Không có tham chiếu đến objectA, objectB, hoặc objectC.
  • Do đó, không có đối tượng nào được đánh dấu.
  • GC quét qua bộ nhớ và giải phóng bộ nhớ của objectA, objectB, và objectC.

Lưu ý với tham chiếu vòng (Circular References): trong một số ngôn ngữ, tham chiếu vòng có thể gây ra vấn đề cho GC. Tuy nhiên, trong JavaScript, thuật toán Mark-and-Sweep xử lý tham chiếu vòng khá tốt.

Javascript
function createCycle() {
  let objectA = {};
  let objectB = {};
  objectA.ref = objectB;
  objectB.ref = objectA;
  return objectA;
}

let myObject = createCycle();
myObject = null;
  • Mặc dù có tham chiếu vòng giữa objectA và objectB, khi myObject được gán null, không có tham chiếu từ root objects đến objectA và objectB.
  • GC sẽ giải phóng bộ nhớ của cả hai đối tượng.

6.2 Incremental Collection

  • Incremental Collection là kỹ thuật chia quá trình thu gom rác thành các phần nhỏ hơn, thay vì thực hiện một lần toàn bộ.
  • Mục tiêu là tránh gián đoạn luồng chính (main thread) của ứng dụng, giúp ứng dụng phản hồi nhanh hơn.

Lý do cần đến Incremental Collection bởi vì với Mark-and-Sweep truyền thống:

  • Nếu heap (bộ nhớ động) lớn, việc thu gom rác có thể mất nhiều thời gian.
  • Trong thời gian GC hoạt động, luồng chính bị dừng lại, gây ra hiện tượng lag hoặc treo ứng dụng.

Cách hoạt động của Incremental Collection:

  • Chia quá trình thu gom rác thành nhiều bước nhỏ.
  • Thực hiện các bước này xen kẽ với việc thực thi mã JavaScript của ứng dụng.
  • Giảm thời gian dừng luồng chính, cải thiện trải nghiệm người dùng.

Bạn có thể hình dung đơn giản như thế này:

  • Thay vì dừng ứng dụng trong 100ms để thu gom rác toàn bộ.
  • Sử dụng Incremental Collection: dừng ứng dụng 10 lần, mỗi lần 10ms, để thu gom rác từng phần.

7. Kết luận

Quản lý bộ nhớ hiệu quả không phải là một nhiệm vụ dễ dàng, nhưng lại có sức hút mạnh mẽ, bạn sẽ thấy nó trở thành một phần tự nhiên trong quy trình phát triển của mình. Đừng ngần ngại thử nghiệm và áp dụng những gì bạn đã học vào dự án thực tế. Những cải tiến nhỏ trong cách bạn quản lý bộ nhớ hôm nay có thể mang lại những lợi ích lớn về hiệu suất và trải nghiệm của người dùng.

Các bài viết liên quan:

Bài viết liên quan

Lập trình backend expressjs

xây dựng hệ thống microservices
  • Kiến trúc Hexagonal và ứng dụngal font-
  • TypeScript: OOP và nguyên lý SOLIDal font-
  • Event-Driven Architecture, Queue & PubSubal font-
  • Basic scalable System Designal font-

Đăng ký nhận thông báo

Đừng bỏ lỡ những bài viết thú vị từ 200Lab