Керування пам'яттю у JavaScript

20 хв. читання

Вступ

Низькорівневі мови, наприклад С, мають інструменти керування пам'яттю, такі як malloc() і calloc(). Ці функції використовуються розробниками для явного виділення і звільнення пам'яті окремо від операційної системи.

JavaScript виділяє пам'ять під час створення об'єкту й автоматично її звільнює, якщо об'єкт більше не використовується, то цей процес має назву «Garbage Collection» – збір сміття. Такий «автоматичний» підхід до звільнення ресурсів дає розробникам JS та інших високорівневих мов помилкову впевненість у тому, що можна не хвилюватися про керування пам'яттю. Що ж, це є великою помилкою.

Працюючи з високорівневими мовами програмування розробнику не слід забувати, що розуміння процесу керування пам'яттю, навіть на базовому рівні, варте уваги. Іноді виникають помилки пов'язані виключно з автоматичним керуванням пам'яттю, тож розробники повинні розумітися безпосередньо на принципах її роботи, щоб знаходити правильні шляхи обходу, з мінімальним часом та збільшенням кількості коду.

Життєвий цикл пам'яті

Немає різниці яку мову програмування ви використовуєте, пам'ять використовується певний відрізок часу, і зазвичай має приблизно однаковий життєвий цикл.

Весь процес покроково:

  • Виділення – пам'ять виділяється операційною системою, яка дозволяє програмі використовувати її. У низькорівневих мовах це явна операція, яку має продумати розробник. У високорівневих мовах цей процес ,зазвичай, залишається поза увагою програміста.

  • Використання – це той час, у який програма фактично використовує попередньо виділену пам'ять. Операції читання і запису даних у пам'ять виконуються під час використання змінних у коді.

  • Звільнення – ця операція є явною у мовах низького рівня. Пам'ять звільняється і тепер знову може бути виділена програмі.

Що таке пам'ять?

На апаратному рівні пам'ять містить велику кількість схем, що мають два стабільних стани й можуть використовуватися для зберігання інформації. Кожна схема містить декілька транзисторів і може зберігати один біт. Кожен такий елемент має адресу (унікальний ідентифікатор), тож ми можемо читати та перезаписувати їх за адресами. Концептуально це можна уявити як гіганський масив елементів, кожен з яких містить по одному біту. До кожної комірки масиву можна звертатися за її унікальною адресою.

Такі комірки об'єднано у великі кластери, які разом можуть бути використані для представлення цифр. 8 біт утворюють 1 байт. З декількох байтів можна створити рядок певної розмірності (16, або іноді 32 біти довжиною).

Пам'ять може зберігати:

  • Імена змінних та іншу інформацію використану всіма програмами.
  • Код програм і операційної системи.

Компілятор перевіряє примітивні типи даних і обчислює скільки пам'яті вони можуть зайняти. Необхідна пам'ять потім виділяється програмі для виклику з простору стеків. Цей простір отримав таку назву тому, що під час виклику функцій їхня пам'ять додається після зайнятої. Вона функціонує за принципом останній зайшов – перший вийшов.

Наприклад:

int n; //4 байти
int x[4]; // масив з 4 елементів, по 4 байти кожен
double m; // 8 байтів

Компілятор порахує, що код вимагає 4 + 4 × 4 + 8 = 28 байти.

Компілятор працює так з поточними розмірами для цілих чисел, або чисел подвоєної точності. Близько 20 років тому цілі числа займали, як правило, 2 і 4 байти відповідно. Ваш код ніколи не повинен залежати від наявного розміру основних типів даних.

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

У прикладі вище компілятор знає адресу пам'яті кожної змінної. Фактично, щоб записати у пам'ять змінну, її транслюють за конкретною адресою у пам'яті. Зауважте, що спроба доступу до змінної вимагає доступу до комірки пам'яті. Тому, що ми отримуємо доступ до елементу в масиві, який не існує – а це на 4 байти більше, ніж останній фактично виділений елемент у масиві x [3], тож це може призвести до читання (або перезапису) деяких бітів m. Це майже напевно матиме дуже небажані наслідки для решти програми.

Керування пам'яттю у JavaScript
***

Коли функції викликають інші функції, кожна отримує свій власний шматок стека, під час виклику. Там будуть зберігатися всі його локальні змінні, а також лічильник програм, який запам'ятовує його позицію в стеку під час виконання. Після завершення роботи функції, блок пам'яті у якому вона зберігалася знову стає доступним для виділення.

Динамічне виділення пам'яті

На жаль, під час компіляції неможливо передбачати скільки пам'яті необхідно для збереження усіх змінних. Можливо, вам на думку спадає щось на зразок:

int n = readInput(); // зчитує введені дані
...
// створює масив розмірністю "n" елементів

Але у такому випадку компілятор не знатиме скільки пам'яті займатиме масив, адже його розмірність залежить від об'єму введених даних.

Час звернутися до динамічного виділення пам'яті.

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

Різниця між статичним і динамічним виділенням пам'яті полягає у наступному:

Статичне виділення Динамічне виділення
Розмірність пам'яті має бути попередньо відома Розмір може бути визначено під час компіляції
Виконується під час компіляції Виконується під час запуску
Виділяється зі стеку Виділяється з купи
Звільнюється за принципом стеку Не має окремого порядку виділення і звільнення

Щоб повністю зрозуміти як працює динамічне виділення пам'яті вам необхідно розібратися з вказівниками, на жаль цей матеріал виходить за межі даної статті.

Виділення пам'яті у JavaScript

JavaScript автоматично виділяє пам'ять під час отримання значень змінних. Тож розробникам не має необхідності займатися розподіленням пам'яті вручну.

var n = 374; // виділення пам'яті для числа
var s = 'sessionstack'; // виділення пам'яті для рядка
var o = {
  a: 1,
  b: null
}; // виділення пам'яті для об'єкта і його вмісту
var a = [1, null, 'str'];  // для масиву і його значень
function f(a) {
  return a + 3;
} // для функії
// Виклик функції також виділяє пам'ять під об'єкт
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);

Виклик деяких функцій також призводить до розподілу об'єктів:

var d = new Date(); // відведення пам'яті під об'єкт
var e = document.createElement('div'); //відведення пам'яті для елементу DOM 

Методи можуть розподіляти нові значення або об'єкти:

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 – новий рядок
// Оскільки рядки незмінні, то JavaScript може вирішити не виділяти пам'ять, а просто зберегти значення

var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2); 
// новий масив а3 – це просто 2 з'єднаних масиви, без виділення нової пам'яті.

Використання пам'яті у JavaScript

У JavaScript виділена пам'ять використовується для читання та запису даних.

Звільнення пам'яті

Найважче – це визначення моменту, у який виділена пам'ять втрачає актуальність і може бути звільнена. Для цього, розробник має визначити умови при яких пам'ять може бути звільнено від даних.

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

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

Посилання до пам'яті

Основна концепція алгоритмів збирання сміття базується на посиланнях.

У контексті керування пам'яттю, об'єкт має посилання на інший об'єкт, якщо перший має доступ до останнього (може бути неявним або явним). Наприклад, об'єкт JavaScript має посилання на його прототип (неявне посилання) та значення його властивостей (явне посилання).

У цьому контексті ідея «об'єкта» ширша ніж зазвичай, а також містить селектор функцій (або належить лексичному простору імен).

Лексичний простір визначає, як імена змінних розташовуються у вкладених функціях: внутрішні функції містять список батьківських функцій, навіть якщо батьківська функція була повернута як значення.

Збір сміття залежно від кількості посилань на об'єкт

Це найпростіший алгоритм збирання сміття. Об'єкт є придатним для збору сміття, якщо не існує вказівників на нього.

Погляньте на наступний приклад:

var o1 = {
  o2: {
    x: 1
  }
};
// Створено 2 об'єкти, 'o2' має посилання на 'o1'
// 'о1' не може бути викинуто з пам'яті через те, що 'о2' явно вказує на нього.

var o3 = o1;    // 'о3' – друга сутність, яка вказує на 'о1'
                                                       
o1 = 1;         // попереднє значення 'о1' досі міститься у 'о3'

var o4 = o3.o2; // посилання на поле 'o2', яке належить об'єкту 'o3'.
               //тепер об'єкт має два посилання: поле та змінна 'o4'

o3 = '374';     // тепер об'єкт 'o1' втратив останнє посилання
               // І його може бути викинуто з пам'яті
              // Але об'єкт 'o2' досі має посилання на себе у змінній 'o4'
             // Тож його не можна витерти з пам'яті

o4 = null; // після перезапису, знищено посилання на поле 'о2' 
          // 'o1' не має посилань, і його може бути видалено

Проблема циклічних посилань

У наступному прикладі створюються два об'єкти, які посилаються один на одного, утворюючи таким чином цикл. Після виклику функції вони вийдуть зі сфери застосування, тому вони фактично не потрібні і можуть бути звільнені. Проте алгоритм підрахунку посилань вважає, що, оскільки кожен з двох об'єктів має посилання принаймні один раз, їх також не можна видалити.

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 посилається на o2
  o2.p = o1; // o2 посилається на o1. Таким чином утворюється зациклене посилання.
}

f();

Алгоритм маркування та видалення

Щоб вирішити, чи потрібен об'єкт, алгоритм посилається на його доступність.

Алгоритм складається з наступних кроків:

  • Будується дерево глобальних змінних, які мають посилання у коді.

  • Усі корені дерева перевіряються та позначаються як активні (не сміття). Всі діти оглядаються рекурсивно. Все, що можна отримати за шляхом кореня, не вважається сміттям.

  • Всі частини пам'яті, які не позначені як активні, можуть вважатися сміттям. Тепер «збирач сміття» може звільнити цю пам'ять і повернути її в ОС.

Такий алгоритм виконує завдання ліпше, ніж попередній, оскільки, якщо на об'єкт не має посилань, то він стає недоступним програмі.

Починаючи з 2012 року, всі сучасні браузери використовують саме цей алгоритм для звільнення пам'яті. Всі вдосконалення, зроблені в області збирання сміття у JavaScript (генераційна / інкрементальна / конкурентна / паралельна колекція сміття) за останні роки, – це покращення даного алгоритму (маркування та видалення), але не вдосконалення самої суті алгоритму.

Вирішення проблеми циклічних посилань

У прикладі нижче, два об'єкти більше не мають посилань від головного об'єкту, і вони стають недосяжними для програми. Отже, вони будуть зібрані смітником.

Керування пам'яттю у JavaScript
***

Хоча існують посилання між об'єктами, вони більше не досяжні з головного об'єкту.

Поведінка колекторів

Не зважаючи на те, що збирачі сміття зручні, вони мають деякі проблеми. Одна з них – вони непередбачувані в контексті виявлення помилок. Ви не можете сказати, коли буде звільнено пам'ять, тож це означає, що в деяких випадках програми використовують більше пам'яті, ніж насправді потрібно. Не зважаючи на те, що не визначений час роботи призводить до того неявного звільнення ресурсів, а значить підвищує ризик втрати даних. Досі, більшість реалізацій збірників сміття мають загальну схему збирання, під час розподілу. Тобто, якщо жодних операцій, вимагаючих виділення нової пам'яті, не виконується, то пошуку сміття не відбувається.

Розглянемо такий сценарій:

  1. Виконується велика кількість операцій з виділення пам'яті.
  2. Більшість цих елементів є недоступними, тому, що очікується обнулення посилань на попередньо виділені.
  3. Виділення не буде виконуватися, інтерпретатор не бачить об'єктів, які можуть бути перезаписані.

Це не пряма втрата пам'яті, проте використання буте вищим за необхідне.

Що таке витік пам'яті?

По суті, витоки пам'яті можуть бути визначені як пам'ять, яка більше не використовується програмою, але з якоїсь причини не повертається до операційної системи чи пулу вільної пам'яті.

Керування пам'яттю у JavaScript
***

Мови програмування втілюють різні способи керування пам'яттю. Однак, визначення того, чи використовується певна частина пам'яті чи ні, досі є нерозв'язною проблемою. Іншими словами, лише розробники можуть чітко визначити, чи може частина пам'яті бути повернута операційній системі, чи ні.

Деякі мови програмування надають функції, які допомагають розробникам керувати пам'яттю. Інші сподіваються, що розробники матимуть повну інформацію про те, коли частина пам'яті не використовується.

Чотири типи витоків у JavaScript

Глобальні змінні

JavaScript обробляє незадекларовані змінні цікавим способом: посилання на незареєстровану змінну створює нову змінну усередині глобального об'єкта. У випадку браузерів глобальним об'єктом є window.

Іншими словами:

function foo(arg) {
   bar = "some text";
}

Дорівнює:

function foo(arg) {
   window.bar = "some text";
}

Якщо bar повинен мати посилання на змінну лише всередині функції foo, і ви забули використати var, щоб оголосити його явно, то створюється глобальна змінна.

Інший спосіб створення глобальної змінної:

function foo() {
   this.var1 = "небажана глобальна змінна";
}
// Foo вказує на глобальний об'єкт вікна замість того, щоб бути undefined
foo();

Щоб запобігти помилкам такого роду, додавайте use strict; до ваших файлів JavaScript. Це дає змогу застосувати більш суворий режим аналізу JavaScript, який запобігає випадковим глобальним змінним.

Навіть якщо ми говоримо про несподівані глобальні змінні, варто пам'ятати, що багато коду заповнюються явними глобальними змінними. Вони за визначенням не підлягають знищенню (тільки, якщо вони не призначені як пусті або перепризначені). Зокрема, викликають занепокоєння глобальні змінні, які використовуються для тимчасового зберігання та обробки великої кількості інформації. Якщо вам потрібно використовувати глобальну змінну для зберігання великої кількості даних, обов'язково призначте її нульовою або перепризначте її після операцій над даними, щоб запобігти втраті пам'яті.

Виклики функцій та таймери

Більшість бібліотек, які надають можливість приймати зворотній виклик, дбають про те, щоб будь-які посилання на об'єкти зворотнього виклику не були доступними після того, як вони стали недосяжними. Однак у випадку setInterval можна зустріти:

var serverData = loadData();
setInterval(function() {
   var renderer = document.getElementById('renderer');
    if(renderer) {
       renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); //Це код буде виконуватися кожні 5 секунд.

Цей приклад ілюструє, що може статися з таймерами які містять посилання на вузли або дані, які більше не використовуються.

Об'єкт, представлений як render, може бути видалений в майбутньому, і тому весь блок всередині обробника інтервалу не має сенсу. Однак обробник не може бути знищений збірником сміття, оскільки інтервал все ще активний (інтервал потрібно зупинити явно). Якщо обробник інтервалу не може бути зібраний, то його залежності також не можуть бути зібрані. Це означає, неможливо видалити serverData, який, ймовірно, зберігає досить велику кількість даних.

У випадку зі спостерігачами, важливо явно видаляти їх. У минулому це було особливо важливим, оскільки певні браузери (старі IE 6) не могли керувати циклічними посиланнями. В даний час більшість браузерів можуть і збиратимуть обробників спостерігачів, коли спостережуваний об'єкт стане недосяжним, навіть якщо слухач не буде явно видалений. Проте хорошим тоном вважається, явне вилучення спостерігачів перш ніж об'єкт буде знищено.

Наприклад:

var element = document.getElementById('launch-button');
var counter = 0;

function onClick(event) {
   counter++;
   element.innerHtml = 'text ' + counter;
}

element.addEventListener('click', onClick);

// Якісь дії

element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Після виходу елементу з поля зору функції,
// element та onClick будуть знищені навіть у старих браузерах 
// проте цикли знищені не будуть

Сьогодні браузери (у тому числі Internet Explorer та Microsoft Edge) використовують сучасні алгоритми збору сміття, вони можуть виявляти цикли та коректно їх обробляти. Іншими словами, не обов'язково викликати removeEventListener, перш ніж зробити вузол недоступним.

Фреймворки та бібліотеки, такі як jQuery, видаляють слухачів перед розпорядженням вузла (якщо для цього використовуються їх конкретні API). Обробляється це засобами внутрішньої бібліотеки, яка також забезпечує відсутність витоків навіть під час роботи з проблемними браузерами, такими як IE 6.

Замикання

Ключовим аспектом розробки JavaScript є замикання: внутрішня функція, яка має доступ до змінних зовнішньої функції. Через деталі реалізації середовища JavaScript існує можливість витоку пам'яті таким чином:

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) // посилання до 'originalThing'
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};
setInterval(replaceThing, 1000);

Цей фрагмент робить одне: кожного разу, коли викликається replaceThing, новий об'єкт, який містить великий масив даних і нове замикання (someMethod) передається до thehing .

У той же час, змінна unused містить замикання, яке має посилання на originalThing (theThing від попереднього виклику replaceThing). Важливо те, що коли створюється область видимості для замикань, що перебувають у тій самій батьківській області, область видимості стає спільною.

У цьому випадку область видимості, створена для замикання someMethod є спільною і для unused. У свою чергу unused має посилання на originalThing. Не зважаючи на те, що unused ніколи не використовується, someMethod може бути викликаний через theThing за межами поля зору replaceThing. Оскільки someMethod ділить область видимості з unused, посилання unused до originalThing має залишатися активним. Це запобігає знищенню останнього.

Коли цей фрагмент запускається кілька разів, можна спостерігати постійне збільшення використання пам'яті. І цей об'єм не зменшиться під час запуску збору сміття. По суті, створюється пов'язаний список замикань (його коренем у вигляді змінної Thing), і кожна з цих областей видимості здійснює непряме посилання на великий масив, що призводить до значного витоку пам'яті.

Посилання за межі DOM

Інколи корисно зберігати вузли DOM усередині структур даних. Припустимо, ви хочете швидко оновити вміст декількох рядків у таблиці. Можливо, існує сенс зберігати посилання на кожен рядок DOM у словнику або масиві. У цьому випадку у пам'яті зберігається два посилання на один і той же елемент DOM: перший – у дереві DOM, а інший – у словнику. Якщо в якийсь момент у майбутньому ви вирішите видалити ці рядки, вам потрібно зробити ці посилання недосяжними.

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};

function doStuff() {
    elements.image.src = 'https://web.archive.org/web/20230322032024/http://example.com/image_name.png';
}

function removeImage() {
   // Зображення – дочірній об'єкт елементу body.
    document.body.removeChild(document.getElementById('image'));
		
    // Ми досі маємо посилання на #button у глобальному об'єкті
    // Іншими словами, елемент #button залишиться у пам'яті.
}

Скажімо, ви зберігаєте посилання на комірку таблиці тегом <td> у вашому коді JavaScript. Припустимо, що ви вирішили видалити таблицю з DOM, але зберегти посилання на цю клітинку. Інтуїтивно можна вважати, що буде зібрано все, окрім цієї комірки. Насправді – ні: комірка таблиці є дочірнім вузлом, а дочірні елементи зберігають посилання на своїх батьків.

Тобто, посилання на комірку таблиці з коду JavaScript призводить до того, що вся таблиця залишається в пам'яті.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 649
Приєднався: 1 рік тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід