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ư:
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?
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.
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
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.
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.
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:
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.
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.
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.
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:
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.
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.
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ư:
// 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?
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:
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:
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.
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:
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
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:
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.
// 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ả khiprocessData
chưa được gọi.- Lãng phí bộ nhớ nếu
heavyData
không được sử dụng.
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 khigetHeavyData
đượ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.
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
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
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.
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.
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:
- Mở Chrome DevTools: nhấn F12 hoặc Ctrl + Shift + I (Windows/Linux) hoặc Cmd + Option + I (macOS).
- Chọn Tab Memory: ở phía trên của DevTools, nhấp vào tab Memory.
- 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
- 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:
<!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:
- Đá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.
- 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:
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.
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
React Toastify là gì? Hướng dẫn sử dụng Toast Notification với React Toastify
Nov 21, 2024 • 7 min read
Hướng dẫn sử dụng Zustand trong NextJS
Nov 21, 2024 • 8 min read
Lazy Loading: Kỹ thuật Tối ưu Hiệu suất Website
Nov 17, 2024 • 14 min read
Hướng dẫn sử dụng Redux Toolkit và Redux Saga trong dự án React
Nov 15, 2024 • 10 min read
WebGL là gì? Hướng dẫn tạo đồ họa đơn giản với WebGL
Nov 13, 2024 • 7 min read
Test-Driven Development (TDD) là gì? Hướng dẫn thực hành TDD
Nov 13, 2024 • 6 min read