Part1: JavaScript types và cách JavaScript engine lưu chúng vào bộ nhớ

Part1: JavaScript types và cách JavaScript engine lưu chúng vào bộ nhớ

{tocify} $title={Table of Content}

JavaScript types

Types trong Javascript có thể được chia làm 2 nhóm: primitive valuesnon-primitive values (hay objects hoặc reference values). Việc phận biệt giữa primitive valuesobjects là một điều quan trọng vì chúng hoạt động và sử dụng bộ nhớ theo các cách khác nhau.

Primitive values (nguyên thủy)

Javascript có 7 kiểu dữ liệu thuộc nhóm này, bao gồm: boolean, null, undefined, string, number, bigint và symbol.

Kích thước của một primitive value là cố định, do đó JavaScript lưu primitive value trên Stack. Và việc thực thi code cũng được diễn ra trên Stack nên khi ta gán một giá trị primitive cho một biến nào đó thì giá trị đó sẽ được lưu trực tiếp trên Stack.

Primitive values trong JavaScript là bất biến (immutable). Chúng ta có thể tham chiếu đến địa chỉ mà nó được lưu và truy cập các giá trị này NHƯNG không thể thay đổi, xóa hoặc thêm bất kỳ thứ gì vào nó - Read Only.

Lưu ý:

  • Bạn cần phân biệt rõ, một primitive value và một biến được gán bằng primitive value. Biến chỉ trỏ đến địa chỉ lưu primitive value, do đó, nó có thể được gán lại với một giá trị mới NHƯNG giá trị đã tồn tại trước đó thì không thể bị thay đổi. Cũng đồng nghĩa với việc, vùng nhớ đang lưu trữ giá trị primitive gốc chỉ cấp quyền read only.
  • Để thay đổi giá trị của biến, chúng ta phải gán lại cho biến đó một giá trị mới, lúc này, JavaScript sẽ cấp phát một vùng mới để lưu giá trị mới và trỏ biến đó đến vùng nhớ này. Giá trị cũ vẫn tồn tại trên Stack cho đến khi hệ thống giải phóng nó.

Để minh họa cho điều này, bạn hãy xem ví dụ dưới đây:

let name = 'Benji';
console.log(name[0]) // B
name[0] = 'K';
name.toUpperCase();
name.length = 0;
console.log(name) // Benji

Như bạn thấy, chúng ta có thể truy cập phần tử đầu tiên của Benji nhưng chúng ta không thể dùng bất cứ phương thức nào để thay đổi trực tiếp giá trị Benji của chính biến name hiện tại. Cách duy nhất để thay đổi giá trị chứa trong biến name là gán lại cho nó một giá trị khác:

let name = 'Benji';
name = 'Kenji'; 
console.log(name) // Kenji
name = name.toUpperCase();
console.log(name) // KENJI

Mình sẽ giải thích một chút về cách hoạt động như sau:

  1. Đầu tiên, chúng ta khai báo một biến name và gán cho nó một giá trị là Benji. Về cơ bản, chúng ta có thể hiểu đơn giản là name bằng Benji. Tuy nhiên về mặt kỹ thuật, biến name khi được khai báo, nó cũng chiếm một vị trí trong bộ nhớ Stack. Sau đó, khi gán name = 'Benji', JavaScript sẽ cấp phát một vùng nhớ trên Stack (0x1001) và lưu giá trị Benji vào trong đó. Tiếp theo, biến name sẽ trỏ đến vùng nhớ chứa chứa giá trị Benji (0x1001).
  2. Khi ta gán lại biến name cho Kenji, JavaScript tiếp tục cấp phát một vùng nhớ mới (0x1002) và lưu giá trị Kenji vào trong đó, điều này xảy ra vì primitive values trong JavaScript là bất biến. Biến name sẽ gỡ bỏ liên kết với địa chỉ (0x1001) chứa giá trị Benji trước đó và trỏ đến địa chỉ (0x1002) chứa giá trị Kenji mới. Giá trị Benji trên địa chỉ (0x1001) sẽ không có biến nào trỏ đến nó, tuy nhiên nó sẽ vẫn tồn tại cho đến khi hệ thống giải phóng nó. Sau khi vùng nhớ 0x1001 được giải phóng thì nó sẽ được tái sử dụng để lưu trữ giá trị khác.
  3. Hành vi tương tự cũng sẽ diễn ra khi ta gán name = name.toUpperCase(). name.toUpperCase() sẽ được thực thi trên Call Stack và trả về giá trị KENJI. JavaScript sẽ cấp phát một vùng nhớ mới (0x1003) để lưu giá trị này và trỏ biến name đến vùng nhớ này. Do đó, hiện tại name = KENJI.

Nhưng đối với các kiểu là object thì ta có thể thay đổi trực tiếp nó mà không phải thực hiện gán lại:

let names = ['A', 'B', 'C'];
names[0] = 'K';
console.log(names); // ['K', 'B', 'C']
names.length = 0;
console.log(names); // []

Các lưu ý

  • Một number chiếm 8 bytes trong bộ nhớ và một giá trị boolean có thể được biểu diễn bằng 1 bit duy nhất. number là kiểu lớn nhất trong các primitive.
  • Để xác định kiểu của một giá trị nguyên thủy, bạn sử dụng toán tử typeof. typeof null sẽ trả về object nhưng nếu bạn so sánh null === null thì kết quả sẽ là true, vì null vẫn được xem là primitive value và nó được lưu trên Stack.

typeof null 	 //--> object

Đây là một BUG phát sinh từ phiên bản JavaScript đầu tiên, khi phát hiện ra thì nó đã quá muộn để fix. Do đó, typeof nullobject đã ra đời, tuy nhiên nó vẫn được xem là primitive nhé. Bạn có thể đọc bài viết The history of "typeof null" tại đây để biết thêm về quả bug này nhé.

  • number có 2 giá trị đặc biệt là NaNInfinity. typeof NaNInfinity đều trả về number nhưng ta không thể thực hiện phép so sánh với NaN (Not A Number)phép so sánh với NaN luôn trả về false, kể cả so sánh với chính nó NaN === NaNfalseInfinity === Infinity sẽ return true.

NaN trả về khi chúng ta thực hiện một phép toán trên một số có giá trị không phải là số. Tuy nhiên, +null = +[] = 0.

console.log(Number.NEGATIVE_INFINITY); // -Infinity
console.log(Number.POSITIVE_INFINITY); // Infinity
console.log(5/0); // Infinity
console.log(-5/0); // -Infinity
console.log(Number('hi')); // NaN
console.log('hi' * 5); // NaN
console.log(typeof NaN); // number
console.log(NaN === NaN); // false
const test = String(5); //---> lưu trên stack
const test1 = new String(5); //---> lưu trên Heap
const test2 = new String(5);
console.log(typeof test); // string
console.log(typeof test1); // object
console.log(test1 === test2); // false

  • Khi các giá trị nguyên thủy (primitive) được gán cho các biến hoặc được truyền cho các hàm, nội dung của chúng sẽ được sao chép (passed by value) - nghĩa là khi gán nó cho một biến thì biến sẽ lưu giá trị dữ liệu thực sự chứ không phải là một tham chiếu đến đối tượng được lưu ở vùng nhớ khác. Do đó, các primitive value được xem là tham trị trong JavaScript.
  • Các biến chứa primitive value trong closure không được lưu trữ trong bộ nhớ Stack. Nó được lưu trữ trong bộ nhớ Heap.
  • JavaScript cho phép chúng ta truy cập trực tiếp vào bộ nhớ Stack và việc thực thi code diễn ra trên Call Stack, do đó, khi so sánh 2 biến (== hoặc ===) thì chính là so sánh 2 giá trị được lưu ở địa chỉ vùng nhớ mà 2 biến này trỏ đến.
let a = 5;
let b = a;
a = 6;
Khi ta khai báo và gán b = a thì b sẽ chiếm một vị trí trong Stack và trỏ đến địa chỉ lưu giá trị 5 giống địa chỉ mà biến a trỏ đến. Sau đó, vì các primitive value là bất biến nên khi ta gán lại cho a bằng giá trị 6, JavaScript sẽ cấp phát một vùng nhớ để lưu 6 và trỏ biến a đến địa chỉ vùng nhớ này. Do đó, ab không ảnh hưởng đến nhau, cho nên b vẫn chứa giá trị 5.

Objects (reference types)

Các loại thuộc nhóm này gồm: object, array, function. Nếu bạn dùng toán tử typeof để kiểm tra thì arrayobject sẽ trả về object, function sẽ trả về function.

Các kiểu dữ liệu thuộc nhóm này là các mutable values - tức giá trị dữ liệu có thể thay đổi được (có thể sửa đổi, thêm, xóa phần tử hoặc thuộc tính của nó).

Object có thể có độ dài bất kỳ - không cố định một kích thước. Điều này cũng đúng với mảng vì mảng có không giới hạn số phần tử. Tương tự, một function có thể chứa một lượng JavaScript code bất kỳ. Vì những loại này không có kích thước cố định, giá trị của chúng không thể được lưu trữ trực tiếp trong 8 bytes của bộ nhớ Stack được liên kết với mỗi biến. Thay vào đó, biến lưu trữ một tham chiếu (reference) đến giá trị được lưu trên Heap sử dụng Stack pointers. Thông thường, reference là một dạng địa chỉ con trỏ hoặc địa chỉ ô nhớ. Bản thân nó không phải là giá trị dữ liệu, nhưng nó cho biến biết nơi cần tìm để tìm giá trị.

JavaScript không cho phép truy cập trực tiếp vào vị trí trong bộ nhớ Heap, vì vậy chúng ta không thể thao tác trực tiếp với không gian bộ nhớ Heap của đối tượng. Do đó, về mặt kỹ thuật, khi chúng ta muốn truy cập dữ liệu của một object hay một mảng thông qua một biến, trước tiên JavaScript lấy địa chỉ tham chiếu của đối tượng (object hoặc array đã gán cho biến) từ bộ nhớ Stack, sau đó dùng địa chỉ đó để truy cập đến vị trí lưu trữ nó trên Heap và lấy dữ liệu ra.

Các lưu ý

Vì JavaScript không cho phép truy cập trực tiếp vào Heap mà phải thông qua tham chiếu của nó từ Stack và việc thực thi code diễn ra trên Call Stack (JavaScript là single thread - do đó, khi thực thi code chỉ có 1 Stack được hoạt động nên Stack cũng là Call Stack. Các biến sẽ thuộc các Stack Frame trên Call Stack trong quá trình thực thi) do đó, khi so sánh 2 biến được gán bằng 2 đối tượng (trong code), JavaScript không thực sự so sánh giá trị của object mà sẽ so sánh các giá trị được lưu trên Stack tại các vị trí mà các biến đó trỏ tới - Tức so sánh địa chỉ của tham chiếu đến các đối tượng lưu trên Heap. Nó có nghĩa là một biến lưu trữ một đối tượng được truy cập bằng tham chiếu.
const arr = [1, 2, 3]; // Cấp phát một vùng nhớ 0x1001H trên Heap để lưu [1, 2, 3] và lưu 0x1001H trên Stack
const arr2 = [1, 2, 3]; // Cấp phát một vùng nhớ mới 0x1002H trên Heap để lưu [1, 2, 3] và lưu 0x1002H trên Stack
console.log(arr === arr2); // false - do địa chỉ tham chiếu khác nhau
Giá trị tham chiếu được lưu tại địa chỉ Stack mà biến arr trỏ đến là tĩnh. Giá trị mảng [1, 2, 3] trong Heap có thể thay đổi được. Khi chúng ta sử dụng biến arr để làm điều gì đó, chẳng hạn như push một giá trị, JavaScript engine sẽ thông qua tham chiếu từ Stack tìm đến ví trị lưu [1, 2, 3] trên Heap và làm việc với thông tin được lưu trữ ở đó. Do đó, phép gán kiểu tham chiếu sẽ hưởng đến đối tượng gốc vì cả 2 biến đều tham chiếu đến cùng một đối tượng lưu trên Heap. Ví dụ:
const arr = [1, 2, 3];
const arr2 = arr;
console.log(arr2); // [1, 2, 3]
arr.push(4);
console.log(arr); // [1, 2, 3, 4]
console.log(arr2); // [1, 2, 3, 4]



Ví dụ mô phỏng quá trình khai báo biến và cấp phát bộ nhớ cho các JavaScript Types


1 / 11
2 / 11
3 / 11
4 / 11
5 / 11
6 / 11
7 / 11
8 / 11
9 / 11
10 / 11
11 / 11


Lưu ý:
  • frame - stack frame - hay còn gọi là execution context.
  • Ở trường hợp của person object, khi thực hiện copy một object trong JavaScript, chúng ta có 2 loại copy là shadow copydeep copy. Trong đó, shadow copy là một dạng copy nông - nghĩa là chỉ copy được một hoặc một số cấp bao ngoài của object, không thể copy hết tất cả các cấp lồng nhau - tức chỉ thực sự copy được những property có giá trị là primitive. Ví dụ shadow copy với toán tử spread (...).
  • Nhìn vào cách cấp phát bộ nhớ cho person object, ta có thể hiểu được lý do vì sao khi ta copy bằng toán tử spead, ta chỉ có thể thực sự tạo ra object mới với các property có giá trị là primitive values hoàn toàn tách biệt với object gốc, nhưng với các property có giá trị thuộc type là object vẫn bị tham chiếu.
      let person = {
        name: 'Kenji',
        age: 24,
        hobbies: ['movie', 'music']
      }
      const personCopy = { ...person };
      personCopy.name = "KenjiCopy";
      personCopy.hobbies.push('game');
      console.log(person);
      // {
      //   "name": "Kenji",
      //    "age": 24,
      //    "hobbies": [
      //      "movie",
      //      "music",
      //      "game"
      //    ]
      // }
      console.log(personCopy);
      // {
      //   "name": "KenjiCopy",
      //    "age": 24,
      //    "hobbies": [
      //      "movie",
      //      "music",
      //      "game"
      //    ]
      // }

Tóm tắt

Trong JavaScript có 2 nhóm types: Primitive valuesObjects.
Primive values: là các khối dữ liệu nguyên tử (single value) trong JavaScript
  • Bạn không thể thay đổi, thêm hoặc xóa các property của primitive vì nó là bất biến. Bạn chỉ có thể tham chiếu đến nó, truy cập và sử dụng nó. Mỗi lần reassign lại biến cho một giá trị primitive mới, một vùng nhớ mới trên Stack sẽ được cấp phát để lưu giá trị mới và thay đổi địa chỉ trỏ đến của biến.
  • Biến và giá trị là hai thứ khác nhau, quy tắc áp dụng khác nhau.
  • Khi các giá trị nguyên thủy được gán cho các biến hoặc được truyền cho các hàm, nội dung của chúng sẽ được sao chép - passed by value.
  • Khi so sánh hai primitive value, nội dung của chúng được so sánh - compared by value.
  • Đối với primitive dấu bằng = là toán tử gán, nó là một phép gán thật sự vì nó thực sự tạo ra một giá trị mới được lưu trên Stack.
Objects (Reference types): là các mẫu dữ liệu ghép, được ghép từ một hoặc nhiều cặp key - value và value có thể là primitive values hoặc object khác.
  • Reference types lưu con trỏ (địa chỉ tham chiếu đến dữ liệu thực sự) trên Stack và object values trên Heap.
  • Khi các đối tượng được gán cho các biến hoặc được truyền cho các hàm, địa chỉ của chúng (reference) sẽ được sao chép - passed by reference.
  • Khi so sánh hai đối tượng bằng các toán tử == hoặc ===, địa chỉ (vị trí được lưu trên Heap) của chúng được so sánh - compare by reference.
  • Khi gán giá trị tham chiếu cho một biến, dấu = thực sự thêm một con trỏ vào biến mới để trỏ đến một đối tượng trong bộ nhớ Heap, đây là một loại thao tác "định địa chỉ".

Các minh họa ở trên chỉ mô phỏng cách JavaScript cấp phát bộ nhớ cho các types. Để hiểu rõ hơn về cách JavaScript hoạt động khi thực thi code, bạn hãy xem bài Excution Context và Event Loop.

Đăng nhận xét

Mới hơn Cũ hơn