Нужно ли чистить строки в JavaScript?

в 19:52, , рубрики: Google Chrome, javascript, оптимизация, отладка, утечки памяти, я в шоке

Что? Строки могут быть «грязными»?
Да, могут.

//.....Какой-то код
console.log(typeof str); // string
console.log(str.length); // 15
console.log(str); // zzzzzzzzzzzzzzz 

Вы думаете, в этом примере строка занимает 30 байт?
А вот и нет! Она занимает 30 мегабайт!

Дьявол кроется в деталях. В данном примере — это «какой-то код». Очевидно, какой-то код что-то делает, что строка занимает много памяти. И вроде бы это вас не касается, но лишь до тех пор, пока это не ваш собственный код. Возможно, в вашем коде уже сейчас много мест, где строки занимают в десятки раз больше, чем в них содержится.

Предисловие

Сразу хочу заметить, что этот баг фича давно известна. Я не открыл ничего нового. Это особенность движка V8, которая позволяет ускорить работу со строками в ущерб, естественно, памяти. То есть это касается Google Chrome и прочих хромиум-браузеров, а также Node.js. Этого уже достаточно, чтобы отнестись серьёзно к этому явлению.

Практичный пример

Сидите вы, значит, под пальмой за компьютером, пишите очередной AJAX на JavaScript, ни о чём не подозреваете, и у вас получается что-то вроде этого:

var news = [];

function checkNews() {
  var xhr = new XMLHttpRequest();
  xhr.open('GET', 'http://example.com/WarAndPeace', true); //Проверяем сайт
  xhr.onload = function() {
    if (this.status != 200) return;
    //Извлекаем новости
    var m = this.response.match(/<div class="news">(.*?)<.div>/); 
    if (!m) return;
    var feed = m[1]; //Новость
    if (!news.find(e=>e==feed)) { //Свежая новость
      news.push(feed);
      document.getElementById('allnews').innerHTML += '<br>' + feed;
    }
  };
  xhr.send();
}

setInterval(checkNews, 55000);

Написали, значит, проверили, опубликовали. Но вдруг оказывается, что сайт начинает жрать память. 200-300Мб — фигня, думаете вы, и уходите на пляж купаться, оставляя браузер открытым. Потом возвращаетесь, а ваш сайт уже 2 гигабайта! Вы удивляетесь, и сразу после этого Chrome крашится у вас на глазах.

Что-то здесь не так. Но код-то ведь простой! Вы начинаете искать утечку памяти в вашем коде и… не находите! А знаете почему? Да потому что её там нет! Вы не допустили ни одной ошибки. Однако проблема есть, заказчик будет не доволен, и решать всё равно придётся вам.

Профилирование

Без паники! Есть проблема — значит, решаем. Открываем профилировщик и видим, что там куча строк в памяти JS, которые там не должны быть. А именно — полностью загруженные страницы.
Нужно ли чистить строки в JavaScript? - 1

Смотрим дальше на Retainers.
Нужно ли чистить строки в JavaScript? - 2

Что же такое sliced string?

Суть проблемы

В общем, оказывается, что строки содержат ссылки на родительские строки! Что??

Это вроде бы не сильно на что-то влияет, но только до тех пор, пока вы не захотите из огромной строки вырезать маленький кусочек и оставить себе, а большую строку удалить. Ведь она не удалится. Она останется в памяти, потому что на неё ссылается новая строка, которую вы где-то сохранили, поэтому сборщик мусора не хочет может освободить память.

Получается такая цепочка указателей:
какой-то массив или объект -> ваша новая маленькая строка -> старая большая строка

Рабочий пример:

function MemoryLeak() {
  let huge = "x".repeat(15).repeat(1024).repeat(1024); // 15МБ строка
  let small = huge.substr(0,15); //Маленький кусочек
  return small;
}

var arr = [];
setInterval(e=>{ //Каждую секунду добавляем 15 байт или 15 мегабайт?
  let str = MemoryLeak();
  //str = clearString(str);
  console.log('Добавляем памяти:',str.length + ' байт');
  arr.push(str);
  console.log('Текущая память страницы:',JSON.stringify(arr).length+' байт');
},1000);

В этом примере мы каждую секунду увеличиваем память на 15 байт. Ой ли? Смотрим диспетчер задач и видим, как память быстро растёт. Ладно, просто GC (сборщик мусора) немного запаздывает, сейчас очухается и очистит. Но нет. Проходит несколько минут, память заполняется до предела, — и браузер крашится.

Ради чистоты эксперимента можно довести до 1.5 гига, остановить таймер и оставить вкладку сайта на ночь. GC типа сам решит, когда пора чистить память, ага. Главное, дождаться.

Решение

В качестве решения можно предложить лишь «очистку» строки от внешних зависимостей. Тогда эти внешние зависимости GC сможет спокойно удалить, как недостижимые.

Простейший кейс, когда мы точно знаем, что в строке число, либо нам нужно получить число:

str = str - 0;

На этом простые кейсы кончаются. В любом случае, очевидно, что строку нужно превратить в другой тип. Это не обязательно единственное решение, но это точно поможет. Пробуем:

function clearString(str) {
  return str.split('').join('');
}

Да, это работает, строка очищается.

Но можно немного улучшить решение. Если капнуть чуть глубже, то окажется, что V8 не оставляет ссылок у очень маленьких строк, меньше 13 символов. Видимо, такие маленькие строки проще скопировать целиком, чем ссылаться на область памяти в другой строке. Воспользуемся этим:

function clearString(str) {
  return str.length < 13 ? str : str.split('').join('');
}

Сложно сказать, изменится ли это число 13 в будущих версиях V8, но пока так.

Что же это выходит? Все строки надо чистить?!

Конечно, нет. Это кэширование не просто так придумано. Оно реально ускоряет работу со строками. Просто иногда это выходит боком, как в примерах выше.

В качестве глобального фикса можно через прототипы заменить стандартные строковые фукнции, типа извлечения подстроки, но это замедлит их работу в десятки раз. Оно вам надо?

Лучшая стратегия такая. При анализе большой строки, вы обычно выделяете куски, потом из этих кусков выделяете более мелкие строки, потом их приводите в должный вид, всякие там replace(), trim() и т.п. И вот конечную маленькую строку, которая точно сохраняется в вечно-живой объект/массив, уже нужно чистить.

А чистка в самом начале просто не имеет смысла. Лишняя нагрузка на ЦП.

let cleared = clearString(xhr.response); //бред

Оптимальный способ очистки

Ещё способы:

Пробуем найти другие решения

function clearString(str) { //Работает 700мс
  return str.split('').join('');
}
function clearString2(str) { //Работает 300мс
  return JSON.parse(JSON.stringify(str));
}
function clearString3(str) { //Работает 280мс
  //Но остаётся ссылка на строку ' ' + str
  //То есть в итоге строка занимает памяти в 2 раза больше
  return (' ' + str).slice(1);
}


function Test(test_arr,fn) {
  let check1 = performance.now();
  let a = []; //Мешаем оптимизатору.
  for(let i=0;i<1000000;i++){
    a.push(fn(test_arr[i]));
  }
  let check2 = performance.now();
  return check2-check1 || a.length;
}

var huge = "x".repeat(15).repeat(1024).repeat(1024); // 15Mb string
var test_arr = [];
for(let i=0;i<1000000;i++) {
  test_arr.push(huge.substr(i,15)); //Мешаем оптимизатору.
}

console.log(Test(test_arr,clearString));
console.log(Test(test_arr,clearString2));
console.log(Test(test_arr,clearString3));

Возможно, вы знаете другой способ? Поделитесь, пожалуйста.

Автор: dollar

Источник

* - обязательные к заполнению поля