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

Memory Management trong JavaScript (Phần 1)

Published
November 12, 2022
Author
Ha Quynh Nguyen
Quản lý bộ nhớ (Memory management) là chủ đề quan trọng khác mà các developer đã bỏ quên nhiều nhất do sự phát triển về sức mạnh và độ phức tạp của các ngôn ngữ lập trình đang được sử dụng hàng ngày.

1. Overview

Các ngôn ngữ lập trình như C có các hàm quản lý vùng nhớ cấp thấp sơ khai (Low-level memory management) như malloc() và free(). Các hàm nguyên thủy này được nhà phát triển và sử dụng để phân bổ vùng nhớ và giải phóng vùng nhớ cho hệ điều hành.
Trong cùng một lúc, JavaScript sẽ cấp phát vùng nhớ khi objects hay string, v.v.... được tạo ra và theo một cách tự động nó giải phóng luôn các đối tượng khi chúng không được sử dụng nữa, một quá trình gọi là Garbage collection.
Tính chất giải phóng vùng nhớ nghe có vẻ tự động hoá một cách tự nhiên này thực tế là một sự nhầm lẫn. Nó khiến các developer JavaScript và mốt số ngôn ngữ bậc cao khác mặc định cho qua hoặc cố tình không quan tâm tới nó. Đây là một sai lầm rất lớn.
Ngay cả khi làm việc với các ngôn ngữ bậc cao (High-level languages), các nhà phát triển nên có sự hiểu biết về vấn đề quản lý vùng nhớ (Hoặc ít nhất là nắm được cơ bản). Đôi khi có những vấn đề xãy ra trong việc quản lý vùng nhớ (Bugs hoặc sự thực thi trong garbage collection bị hạn chế) và khi đó các developer cần phải hiểu được cơ chế của nó để có thể xử lý vấn đề một cách tối ưu nhất. (Hoặc để tìm một cách giải quyết phù hợp, với tối thiểu risky và bad code).

2. Memory life cycle (Chu kỳ của vùng nhớ)

Cho dù bạn sử dụng ngôn ngữ lập trình nào, Memory life cycle vẫn luôn giống nhau:
notion image
Dưới đây là tổng quan về những gì xảy ra ở mỗi bước của chu kỳ:
Allocate memory (Cấp phát vùng nhớ): Memory sẽ được cấp phát bởi hệ điều hành đang chạy chương trình của bạn. Trong các ngôn ngữ cấp thấp (Ví dụ: C) tiến trình này được xử lý bởi chính developer. Tuy nhiên với ngôn ngữ bật cao thì tiến trình này được tự động xử lý bởi engine.
Use memory (Sử dụng vùng nhớ được cấp phát): đây là lúc chương trình của bạn thực sự sử dụng vùng nhớ đã được cấp phát trước đó. Các hoạt động Read và Write sẽ diễn ra ngay khi bạn tạo biến, cấp phát vùng nhớ cho biến của mình trong code.
Release memory (Giải phóng vùng nhớ): Đây là khi bạn giải phóng toàn bộ vùng nhớ mà bạn không dùng nữa. Lúc này chúng sẽ được giải phóng và cũng sẵn sàng để được sử dụng tiếp. Đối với các ngôn ngữ bậc thấp thì tiến trình Release memory và cả Allocate memory đều khá là rõ ràng và các lập trình viên sẽ phải tự handle vấn đề này với code của họ.

3. What is memory?

Trước khi đi trực tiếp vào chi tiết, chúng ta nên xem khái quát về khái niệm tổng quan của memory và nó hoạt động như thế nào.
Ở cấp độ phần cứng, bộ nhớ máy tính bao gồm một số lượng lớn flip-flop. Mỗi flip-flop chứa một vài bóng bán dẫn và có khả năng lưu trữ một bit. Mỗi flip-flop sẽ được đánh địa chỉ với một định danh duy nhất, vì vậy chúng ta có thể đọc và ghi đè lên chúng. Do đó, về mặt khái niệm, chúng ta có thể nghĩ đơn giản như thế này: Toàn bộ bộ nhớ máy tính của mình chỉ là một mảng bit khổng lồ mà chúng ta có thể đọc và ghi.
Vì là con người nên chúng ta không có khả năng để đọc toàn bộ suy nghĩ hay các công thức toán học từ các chuỗi bits, chúng ta sắp xếp chúng thành các nhóm lớn hơn, xếp chúng cùng nhau và có thể được sử dụng để biểu thị các con số. 8 bits sẽ là 1 byte. Nói về Byte, có một số kiểu (Đôi khi là 16 bits, đôi khi là 32 bits).
Rất nhiều thứ được lưu trữ trong memory:
  1. Tất cả các biến và dữ liệu được sử dụng trong phần mềm.
  1. Tất cả code, bao gồm cả hệ điều hành.
Trình biên dịch và hệ điều hành phối hợp với nhau để đảm nhiệm hầu hết việc quản lý bộ nhớ cho bạn, nhưng bạn nên biết những gì mà diễn ra trong đó.
Khi bạn biên dịch mã của mình, trình biên dịch có thể kiểm tra các kiểu dữ liệu nguyên thủy (Primitive data types) và tính toán trước chúng sẽ cần bao nhiêu vùng nhớ. Số lượng cần thiết sau đó được phân bổ và đặt trong stack space (Không gian ngăn xếp). Cái space mà các biến này được phân bổ gọi là stack space (Không gian ngăn xếp) bởi vì khi các functions được gọi, memory của chúng sẽ được thêm vào phía trên memory hiện có. Khi chúng chấm dứt, chúng được xóa theo thứ tự LIFO (Last-in, Last-out). Ví dụ, hãy xem xét các trường hợp:
int n; // 4 bytes int x[4]; // array có 4 elements, mỗi elements có 4 bytes double m; // 8 bytes
Trình biên dịch sẽ thấy được như thế này: 4 + 4 × 4 + 8 = 28 bytes.
Đó là cách mà nó hoạt động với các kích thước hiện tại cho integers và doubles. Khoảng 20 năm trước, integers thường là 2 byte và doubles là 4 byte. Mã của bạn không bao giờ phải phụ thuộc vào kích thước của các kiểu dữ liệu cơ bản tại thời điểm này.
Trình biên dịch sẽ chèn code để tương tác với hệ điều hành, yêu cầu số byte cần thiết trên stack để lưu trữ các biến của bạn.
Trong ví dụ trên, trình biên dịch sẽ biết chính xác địa chỉ vùng nhớ của từng biến. Trong thực tế, bất cứ khi nào chúng ta ghi vào biến n, nó sẽ được biên dịch đại khái thành memory address 4127963.
Lưu ý rằng nếu chúng ta cố gắng truy cập vào x[4] lúc này, chúng ta sẽ phải truy cập vào kiểu dữ liệu giống biến m. Đó là bởi vì chúng ta đang truy cập vào một phần tử trong không hề tồn tại trong mảng (4 bytes của nó được phân bổ thêm vào sau phần tử cuối cùng của mảng này x[3] - Lưu ý mảng x này trong ví dụ có nói chỉ có 4 phần tử thôi x[4] lúc này có thể được đọc và ghi đè lên một số bits của biến m kiểu double). Nó chắc chắn là không mang lại sự tối ưu của việc phân bổ vùng nhớ cho phần mềm của chúng ta.
notion image
Khi các functions gọi các functions khác, mỗi function sẽ có một đoạn riêng trong ngăn xếp khi nó được gọi. Nó giữ tất cả các biến cục bộ của nó ở đó, nhưng cũng chứa một bộ đệm ghi nhớ vị trí thực thi của nó. Khi functions đó kết thúc, memory block của nó một lần nữa được giải phóng và sẳng sàng được cung cấp cho các mục đích khác.

4. Cấp phát tự động

Thật không may, mọi thứ không hoàn toàn dễ dàng khi chúng ta không hề biết sẽ phải có bao nhiêu thời gian biên dịch cho bao nhiêu vùng nhớ mà một biến sẽ cần. Giả sử chúng ta muốn làm một cái gì đó như sau:
int n = readInput(); // reads input from the user ... // create an array with "n" elements
Ở đây, tại thời gian biên dịch, trình biên dịch không biết mảng sẽ cần bao nhiêu vùng nhớ vì nó được xác định bởi một giá trị động được cung cấp từ người dùng.
Chính vì thế chúng ta không thể biết mà cấp vùng nhớ cho biến trong stack được. Thay vào đó, chương trình của chúng ta cần phải đưa yêu cầu rõ ràng đến hệ điều hành có thể cấp phát chúng ta đúng dung lượng cần lúc run-time. Vùng nhớ này sẽ được phân bổ từ vùng heap. Sự khác biệt giữa cấp phát bộ nhớ tĩnh và động được tóm tắt trong bảng sau:
notion image
Để có thể hiểu tường tận cách mà quy trình cấp phát động thực hiện như thế nào. Chúng ta cần phải bỏ nhiều thời gian để hiểu về pointers (Con trỏ).