Управління пам’яттю JavaScript
Що відбувається з об'єктом, коли він стає «не потрібним»? Чи можливе «переповнення» пам'яті? Для відповіді на ці питання – заліземо «під капот» інтерпретатора.
- Певна кількість значень вважається досяжною спочатку, зокрема:
Значення, посилання на які містяться у стеку дзвінка, тобто – всі локальні змінні та параметри функцій, які зараз виконуються або знаходяться в очікуванні закінчення вкладеного дзвінка.
Усі глобальні змінні.
Ці значення гарантовано зберігаються у пам'яті. Ми називатимемо їх корінням.
- Будь-яке інше значення зберігається в пам'яті лише до тих пір, поки доступне з кореня за посиланням або ланцюжком посилань.
Для очищення пам'яті від недосяжних значень у браузерах використовується автоматичний Складальник сміття (Garbage collection, GC), вбудований в інтерпретатор, який спостерігає за об'єктами і час від часу видаляє недосяжні.
Найпростіша ситуація тут із примітивами. При присвоєнні вони копіюються повністю, посилань на них не створюється, так що якщо в змінній був один рядок, а його замінили на інший, то попередній можна сміливо викинути.
Далі ми подивимося низку прикладів, які допоможуть цьому розібратися.
Досяжність та наявність посилань
Але таке спрощення буде вірним лише в один бік.
Вірно - у тому плані, що якщо посилань на значення немає, то пам'ять з-під нього очищається.
Тепер об'єкт < name: "Вася" >недоступніший. Пам'ять буде звільнено.
Неправильно - в інший бік: наявність посилання не гарантує, що значення залишиться в пам'яті.
Така ситуація виникає з об'єктами, які посилаються один на одного:
Тому вони будуть видалені зпам'яті.
Тут якраз і відіграє роль «досяжність» – обидва ці об'єкти стають недосяжними з коріння, насамперед із глобальної області, стеку.
Складальник сміття відстежує такі ситуації та очищує пам'ять.
Алгоритм складання сміття
Наприклад, розглянемо приклад об'єкта «родина»:
Функція marry приймає два об'єкти, дає їм посилання один на одного і повертає третій, що містить посилання на обидва.
Об'єкт family, що вийшов, можна зобразити так:

Тут стрілками показані посилання, а ось властивість name посиланням не є, там зберігається примітив, тому воно всередині самого об'єкта.
Щоб запустити збирач сміття, видалімо два посилання:
Звернімо увагу, видалення тільки одного з цих посилань ні до чого не призвело б. Поки до об'єкта можна дістатися з кореня window, об'єкт залишається живим.
А якщо дві, то виходить, що від колишнього family.father посилання виходять, але в нього жодна не йде:

Зовсім неважливо, що з об'єкта виходять якісь посилання, вони не впливають на досяжність цього об'єкта.
Колишній family.father став недосяжним і буде видалений разом зі своїми даними, які також недоступні з програми.

А тепер – розглянемо складніший випадок. Що буде, якщо видалити основне посилання family ?
Вихідний об'єкт - той же, що і на початку, а потім:

Як бачимо, об'єкти в конструкції все ще пов'язані між собою. Однак, пошук від кореня їх не знаходить, вони не досяжні, і отже збирач сміття видалить їх із пам'яті.
Проблема описаного алгоритму у великих затримках. Якщо об'єктів багато, то пошук всіх досяжних піде досить багато часу. Адже виконання скрипту при цьому має бути зупинено, вжепроскановані об'єкти не повинні змінюватись до закінчення процесу. Виходять невеликі, але неприємні паузи-зависання у роботі скрипта.
Тому сучасні інтерпретатори використовують різні оптимізації.
Найчастіша – це розподіл об'єктів на два види «старі» та «нові». До кожного типу виділяється своя область пам'яті. Кожен об'єкт створюється в новій області і, якщо прожив досить довго, мігрує в стару. "Нова" область зазвичай невелика. Вона часто очищається. "Стара" - рідко.
Насправді виходить ефективно, зазвичай більшість об'єктів створюються і вмирають майже відразу, наприклад, служачи локальними змінними функції:
Якщо ви знаєте низькорівневі мови програмування, то докладніше про організацію складання сміття в V8 можна почитати, наприклад, у статті A tour of V8: Garbage Collection.
Об'єкти змінних, про які йшлося раніше, на чолі для замикання, також схильні до складання сміття. Вони дотримуються тих самих правил, як і звичайні об'єкти.
Об'єкт змінних зовнішньої функції існує у пам'яті до того часу, поки є хоч одна внутрішня функція, яка посилається нею через властивість [[Scope]] .
У коді вище value g є властивостями об'єкта змінних. Під час виконання f() її об'єкт змінних перебуває у поточному стеку виконання, тому живий. По закінченню він стане недосяжним і буде прибраний з пам'яті разом з іншими локальними змінними.
…А ось у цьому випадку лексичне оточення, включаючи змінну value , буде збережено:
Якщо f() буде викликатися багато разів, а отримані функції зберігатимуться, наприклад, складатися в масив, то зберігатимуться і об'єкти LexicalEnvironment з відповідними значеннями value :
Об'єкт LexicalEnvironment живе рівно до тихпір, поки на нього існують посилання. У коді нижче після видалення посилання на g вмирає:
Оптимізація у V8 та її наслідки
Сучасні JS-движки роблять оптимізації замикань пам'яті. Вони аналізують використання змінних і у разі, коли змінна із замикання абсолютно точно не використовується, видаляють її.
У коді вище змінна value не використовується. Тому її буде видалено з пам'яті.
Важливий побічний ефект у V8 (Chrome, Opera) полягає в тому, що віддалена змінна стане недоступною і при налагодженні!
Спробуйте запустити приклад нижче з відкритою консоллю Chrome. Коли він зупиниться, наберіть в консолі alert(value) .
Як ви могли побачити – немає такої змінної! Недоступна вона зсередини g. Інтерпретатор вирішив, що вона нам не знадобиться та видалив.
Це може призвести до кумедних казусів при налагодженні, аж до того, що замість цієї змінної буде інша, зовнішня.
Про цю особливість важливо знати. Якщо ви налагоджуєте під Chrome/Opera, то напевно рано чи пізно з нею зустрінетеся!
Це не глюк відладчика, а особливість роботи V8, яка, можливо, буде колись змінена. Ви завжди зможете перевірити, чи не змінилося чогось, запустивши приклади на цій сторінці.
Вплив управління пам'яттю на швидкість
На створення нових об'єктів та їх видалення витрачається час. Це важливо мати на увазі у разі, коли важлива продуктивність.
Як приклад розглянемо рекурсію. При вкладених дзвінках щоразу створюється новий об'єкт зі змінними та поміщається у стек. Потім пам'ять із-під нього потрібно очистити. Тому рекурсивний код завжди буде повільніше використовує цикл, але наскільки?
Приклад нижче тестує додавання чисел до цього через рекурсію в порівнянні зі звичайнимциклом:
Відмінність швидкості на такому прикладі може становити, залежно від інтерпретатора, 2-10 разів.
Взагалі цей приклад – не показовий. Ще раз звертаю вашу увагу на те, що такі штучні «мікротести» часто брешуть. Правильно їх робити – окрема наука, яка виходить за межі цього розділу. Але і на практиці прискорення в 2-10 разів оптимізацією за кількістю об'єктів (і взагалі будь-яких значень) – аж ніяк не міф, а цілком досяжно.