Hướng dẫn javascript object lifetime
Nguồn: https://blog.sessionstack.com/how-javascript-works-memory-management-how-to-handle-4-common-memory-leaks-3f28b94cfbec Show
*Notes: Bài này là bài dịch từ blog khác, mình sẽ loại bỏ phần quảng cáo không liên quan để các bạn tập trung vào kiến thức được chia sẻ cụ thể trong bài này. Nếu muốn đọc sâu hơn và kỹ hơn thì các bạn nên đọc bài gốc. Cảm ơn ^^! PS: Bài này khá là dài, mình dịch cũng nản luôn. Nhưng kiến thức về quản lý vùng nhớ rất hay. Bài đầu tiên của loạt bài tập trung vào cung cấp cho chúng ta một cái nhìn tổng quan về engine, runtime và Call stasks. Bài đăng thứ hai chúng ta đi sâu vào các phần bên trong của công cụ JavaScript Google V8 và cũng cung cấp một số tips về cách viết code JavaScript tốt hơn.* Với bài thứ 3 trong series này, chúng tôi sẽ thảo luận về một 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 - quản lý bộ nhớ (memory management). Chúng tôi cũng cung cấp một số mẹo về cách xử lý rò rỉ bộ nhớ trong JavaScript. OverviewCá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ư 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 (Xem lại phần 2).
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). 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: 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 nên đượ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 (Xử 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ẳng sàng để được xử 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ọ. Để đi nhanh về các khái niệm của call stacks và memory heap, bạn có thể đọc bài viết đầu tiên của chúng tôi về chủ đề này. 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:
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 chúng tôi khuyên 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:
Trình biên dịch sẽ thấy được như thế này: 4 + 4 × 4 + 8 = 28 bytes.
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 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. Cấp phát tự độngThậ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:
Ở đâ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: Để 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ỏ), vấn đề này có thể hơi đi lệch với chủ để đang đề cập trong bài này. (Đoạn này tác giả có nói nếu bạn muốn đào sâu hơn về pointers thì cứ comment vào bài post của tác giả để được giải thích thêm) Cấp phát vùng nhớ trong JavaScriptRồi bây giờ chúng tôi sẽ giải thích về cái bước đầu tiên trong JavaScript "Cấp phát vùng nhớ". JavaScript đã giảm tải trách nhiệm cho developers trong vấn đề cấp phát vùng nhớ này. Nó làm tự động ngay khi bạn khai báo một biến xong.
Xem thêm Function Expressions ở đây Một số trường hợp khởi tạo object bằng cách gọi một hàm khởi tạo cũng sẽ được cấp phát như một objects
Các phương thức có thể cấp phát giá trị mới hoặc một object mới. VD:
Xử dụng memory trong JavaScriptViệc xử dụng memory trong javaScript đơn giản là đọc và ghi đè lên nó. Điều này được thực hiện bởi việc đọc và ghi các giá trị của một giá trị nào đó hoặc một thuộc tính của object nào đó hay thậm chí là việc chúng ta đưa một thuộc tính (argument) vào trong một functions. Giải phóng vùng nhớ khi không xử dụng nữa.Hầu hết các vấn đề hay lỗi trong quá trình xử lý memory đều đến từ bước này. Nhiệm vụ khó khăn nhất ở đây là làm cách nào để xác định được vùng nhớ đã được cấp phát nào đang không được xử dụng nữa. Thường thì các developers cần phải xác định được chổ nào trong code của mình đang không cần dùng tới nữa và phải giải phóng vùng nhớ ở đó ngay. Với các ngôn ngữ bậc cao thì nó sẽ có thêm một tiến trình gọi là garbage collector (cái này được cung cấp bởi engine). Tiến trình này nó sẽ giúp theo dõi toàn bộ memory heap của mình và khi đó nó sẽ dò tới nơi nào đang không còn xử dụng nữa thì sẽ remove đi một cách tự động (Nghe có vẽ khoẻ ru nhỉ Có điều đương nhiên không có gì là tuyệt đối, các việc xác định vùng nhớ nào cần hay không cần không thể nào dựa vào một thuật toán mà giải quyết hết được. Hầu hết các garbage collector này hoạt động bằng cách thu thập những vùng nhớ mà không thể truy xuất tới được nữa ví dụ những biến con trỏ nằm ngoài scope hiện tại. Tuy nhiên việc thu thập như vậy cũng mang tính tương tối chứ không thể quét qua được hết. Bởi vì thực tế thì bất kỳ vùng nhớ nào cũng có những biến con trỏ nằm bên trong scope trỏ tới nó nhưng lại không bao giờ được truy cập lại lần nữa. Garbage collection (Thu gom rác)Theo như thực tế thì việc xác địch được vùng nhớ nào còn xử dụng hay vùng nhớ nào không được xử dụng rất là khó khăn và mang tính tương đối. Cho nên giải pháp Garbage Collection này cũng rất hạn chế cho cái vấn đề này vì vậy trong phần này chúng tôi sẽ giải thích các khái niệm cần thiết để hiểu các thuật toán chủ yếu của việc thu gom rác và những hạn chế của chúng. Memory referencesCác thuật toán của GC chủ yếu dựa vào các reference của nó. Trong context của việc quả lý vùng nhớ, một object có thể reference đến một object khác nếu như object đầu có thể truy cập vào object sau (có thể ẩn hoặc rõ ràng). Ví dụ: Một cái object có thể reference tới prototype của chính nó (implicit reference - dịch nôm na là try vấn ẩn) và cả các giá trị của từng properties của nó. (explicit reference - dịch nôm na là truy vấn công khai). Trong context này, một objects có thể được mở rộng ra thành một object bự hơn so với ban đầu và nó còn chứa được các function scopes (hoặc cả global lexical scope - biến toán cục).
Reference-counting garbage collectionThuật toán thu góm rác này siêu đơn giản. Một đối tượng được coi là rác và có thể được gôm khi mà chả có cái nào tham chiếu tới nó cả. Hãy xem một ví dụ dưới đây:
( Bao rối rắm (_ _!)) Vấn đề đến từ cycles (chu kỳ, vòng lặp)Có một sự hạn chế đến từ các vòng lặp. Ở ví dụ sau chúng ta sẽ thấy 2 đối tượng tham chiếu lẫn nhau và tạo ra một vòng lặp. Chùng nằm ngoài scope sau khi function được gọi và khi đó chúng không còn tác dụng gì nữa và vùng nhớ của chúng có thể được giải phóng. Thế nhưng đối với thuật toán đếm số lượt tham chiếu để dọn dẹp rác hiện tại thì nó vẫn sẽ thấy rằng nếu ít nhất còn một đối tượng tham chiếu tới thì vẫn sẽ chưa thể được giải phóng. Nghĩa là khi chúng cứ liên tục tham chiếu tới nhau tạo ra một vòng lặp. Thì lúc đó cả hai đều không thể được giải phóng.
Thuật toán Mark-and-sweepĐể quyết định xem một đối tượng có còn được sử dụng hay không, thuật toán này xác định xem đối tượng có thể truy cập được hay không. Thuật toán này có 3 bước:
Mặc dù chúng có sự tham chiếu qua lại lẫn nhau nhưng vẫn được coi là rác vì không được root tham chiếu tới. Nên lúc này chúng sẽ bị gom đi. Sự không trực quan của Garbage CollectorsMặc dù Garbage Collectors rất tiện lợi nhưng đi kèm đó cũng có một số hạn chế khi xảy ra một vài vấn đề có thể không tích hợp được với chính nó. Một trong số đó là non-determinism (thuật toán không đơn định). Nói cách khác thì GCs là một kiểu không thể đoán trước được. Bạn thực sự không thể biết được khi nào thì việc collection được thực hiện. Trong một vài trường hợp thì chương trình của chúng ta có thể xử dụng nhiều vùng nhớ hơn mức nó yêu cầu. Trong một số trường hợp vấn đề tạm ngưng hoạt động (short-pause) cũng khá cần được lưu tâm đối với một vài ứng dụng nhạy cảm. Mặc dù non-determinism nghĩa là chúng ta không chắc chắn được khi nào thì việc collection được thực thi, nhưng trong hầu hết các tiến trình của CGs chúng đều dùng chung một mô hình để thực hiện việc dọn dẹp trong suốt quá trình phần bổ vùng nhớ. Nếu không có hoạt động phân bổ nào được thực hiện thì hầu hết các CGs cũng sẽ không hoạt động. Hãy xem xét các kịch bản sau đây:
Trong trường này hầu hết CG đều sẽ không chạy. Nói một cách khác, mặc dù trong các trường hợp trên GCs đã xác được những đối tượng không được truy vấn tới và chúng sẳng sàng để gôm dọn nhưng những collector không đưa ra yêu cầu thực thi thì GC cũng sẽ không chạy. Những trường hợp này không phải vấn đề rò rỉ nghiệm trọng lắm nhưng thực tế thì nó vẫn dẫn tới vấn đề sự dụng vùng nhớ nhiều hơn mức định ra. Vậy rò rỉ bộ nhớ là gì?Đại khái rò rỉ bộ nhớ nghĩa là những phần vùng nhớ mà chúng ta đã sài trước đây nhưng giờ thì không dùng tới nữa. Nhưng chúng vẫn chưa được giải phóng về cho HĐH hoặc về khu vực chứa các vùng nhớ sẳng sàng được sử dụng. Các ngôn ngữ lập trình khác nhau sẽ xử dụng những cách khác nhau để xử lý vấn đề quản lý memory. Tuy nhiên việc xác định một vùng nhớ có được xử dụng xong rồi hay chưa thực sự là một vấn đề không thể giải quyết được (undecidable problem). Nói các khác thì chỉ có developer mới có thể xác định được vùng nhớ nào anh ta còn cần vùng nhớ nào thì không. Cũng có một số ngôn ngữ lập trình cung cấp các tính năng giúp nhà phát triển thực hiện điều này. Nhưng một số khác thì yêu cầu các lập trình viên phải tự xác định và quản lý được vấn đề này. Wikipedia có khá nhiều các articles hay cho vấn đề manual hay auto quản lý memory. 4 kiểu thất thoát vùng nhớ hay gặp phải trong javaScript1: Global variables (biến toàn cục)JavaScript xử lý các biến chưa được khai báo một cách khá là thú vị: Khi một biến chưa được khai báo nhưng được trỏ tới thì ngay lập tực một
thuộc tính sẽ được tạo ra trong global object được
Nó sẽ tương đương với
Nói thế này, mục đích của biến Bạn cũng có thể vô tình tạo một biến toàn cục bằng cách sử dụng
Việc vô tình tạo ra các biến toàn cục chắc chắn không phải là một vấn đề lớn, tuy nhiên điều quan trọng là nếu bạn thường xuyên tạo ra các biến như vậy, thì khi đó theo lý thuyết GC sẽ không thể thu thập và dọn dẹp hết đc các biến này. Cho nên chúng ta cần lưu ý đặt biệt đến các biến toàn cục khi tạo ra để lưu trử thông tin một lượng lớn các bits. Chỉ sử dụng biến toàn cục khi bạn buộc phải làm vậy, phải đảm bảo sẽ gán giá trị null hoặc update lại giá trị của nó sau khi bạn dùng xong. 2: Các Timers hoặc callbacks bị bỏ quênLấy ví dụ với Những thư viện cung cấp các observers (quan sát viên) hay các công cụ khác cho phép các hàm callbacks thường sẽ phải đảm bảo rằng một khi các instances của nó unreachable (không thể truy cập được) thì tất cả các tham chiếu (con trỏ trỏ tới) các callbacks mà nó cho phép cũng đều phải unreachable (không truy cập đc). Trường hợp dưới đây xãy ra cũng không ít:
Đoạn mã trên cho thấy hậu quả của việc sử dụng timers mà các reference nodes hoặc data của nó mà không còn cần thiết nữa. Chổ này giải thích hơi phức tạp và rườm ra nên mình không dịch. Mình sẽ giải thích theo quan điểm cá nhân đơn giản như thế này. Cái biến Khi sử dụng observer, bạn cần phải có một câu lệnh tường minh để remove chúng mỗi khi xong việc (Dù là observer đó không cần dùng nữa hay object không thể truy cập được). Thật may là hầu hết các trình duyệt hiện đại sẽ thực hiện công việc này cho bạn: browser sẽ tự động thu thập các observers một khi một observer trở nên không thể truy cập được ngay cả khi bạn quên xóa listener. Một số browser cũ trước đây không làm được chuyện này (IE6). Tuy nhiên với ví dụ dưới đây sẽ cho các bạn thấy một vài trường hợp cụ thể chúng ta cần lưu ý loại bỏ các observers đi khi nó trở nên vô dụng:
Những với các trình duyệt hiện đại ngày nay bạn thậm chí không cần thực hiện thao tác 3: ClosuresMột khía cạnh khá quan trọng trong JavaScript đó là Closures: Một inner functions có thể acccess tới các biến của một outer (enclosing) function. Bởi vì các triển khai của JavaScript trong runtime nên cũng sẽ xãy ra các vấn đề rò rỉ vùng nhớ như sau:
Khi hàm Trong trường hợp này, Scrope được tạo ra cho hàm closure Với ví dụ vừa rồi, scope của Tất cả những điều này có thể dẫn tới một vụ rò rĩ bộ nhớ đáng kể. Bạn có thể thấy được sự tăng đột biến trong việc sử dụng bộ nhớ khi đoạn mã trên được
chạy đi chạy lại. Kích thước của nó sẽ không giảm thiểu khi GC chạy. Một chuỗi liên kết giữa các closures sẽ được tạo ra (root của nó sẽ là biến Vấn đề này đã được tìm thấy bởi nhóm Meteor và họ có một bài viết tuyệt vời mô tả nó rất chi tiết. 4: Out of DOM referencesCó một số trường hợp gặp phải khi mà các lập trình viên lưu các DOM nodes bên trong data structures. Giả sử bạn muốn update contents cho một số rows trong một table một cách nhanh chóng. Nếu như bạn lưu một reference cho từng DOM row trong một dictionary hay một array, lúc đó sẽ có đến 2 reference trỏ tới cùng một DOM element: một ở DOM tree và một trong dictionary. Nếu muốn loại bỏ các row này, bạn cũng cần lưu ý phải loại bỏ luôn các references kia.
Có một sự xem xét khác cần được tính đến khi nó bắt đầu thàm chiếu đến các nhánh bên trong của một DOM tree. Nếu bạn tạo một reference đến một table cell (thẻ ) và sau đó muốn xoá cái table đi nhưng vẫn giữ cái reference đến một cell cụ thể nào đó, lúc đó có thể xãy ra một vụ rò rỉ vùng nhớ, tuy không lớn lắm. Có thể bạn sẽ nghĩ GC sẽ dọn dẹp toàn bộ mọi thứ trừ cái cell cụ thể đó, nhưng không, không phải như vậy. Một khi cái cell là child node của table thì children
luôn giữ reference đến parents của nó, lúc đó cái reference tưởng chừng duy nhất này sẽ giữ toàn bộ table trong vùng nhớ.
| |