Пользователь
0,0
рейтинг
3 сентября 2012 в 15:51

Разработка → JavaScript: оператор delete создает утечку!? из песочницы

Здравствуй хабрнарод, хочу вам поведать об истории коварной утечки, и о великом недопонимании.
На самом деле все очень просто, вот такая, казалось бы, обычная строчка кода, в определенных условиях может вызвать утечку:
delete testedObject[ i ].obj;

Но, повторюсь только в определенных условиях. Еще одно но, пока точно неизвестно это браузерный баг или особенность JS.
Гугл, ничего не сказал мне по этому поводу, Копания в спецификации ECMAScript, тоже ничего не дало, ибо ее трудно понимать в трезвом состоянии. Собственно это и стало поводом написания данной статьи.


Предистория


Итак, я начал заниматься проектом X, для одной фирмы Y. Им нужна была система управления фирмой, сотрудниками и т.д., все в одном пакете. Все это дело должно было работать через браузер. Было решено писать это чудо на JS. Специфика приложения такова, что за вкладка могла быть открыта целый день, и активно использоваться. Поэтому мы боролись за каждый килобайт памяти и каждую миллисекунду процессорного времени. Фреймворки, не очень справлялись с этой задачей, и пришлось писать свои велосипеды, тут то и начались чудеса!

Суть


После очередной порции законченного куска приложения началось тестирование, и о боже приложение потихоньку кушает память и не хочет останавливаться. Причем во всех браузерах поголовно. Для Лисы самый фатальный исход, закрытие вкладки не помогает, и память остается съеденной. Сначала я грешил на свой велосипед, но спустя некоторое время я докопался до истины и понял что все дело в операторе delete.

Поначалу я «обрадовался» что нашел очередной браузеры баг. Но потом мучился сомнениями, не может ведь быть такое, что все браузеры поголовно текут. Да и в крупных Фреймворках оператор delete очень активно используется.

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

Собственно код прототипа, с утечкой
<!DOCTYPE html>
<html>
<head>
    <title></title>
    <script type="text/javascript">

    var count = 100000;
    var testedObject;

    function getObject() {
        // делаем объект потяжелее
         return {
            first:   { val : 'tratata', item : { val : 'tratata' } },
            second:  { val : 'tratata', item : { val : 'tratata' } },
            third:   { val : 'tratata', item : { val : 'tratata' } },
            fourth:  { val : 'tratata', item : { val : 'tratata' } },
            fifth:   { val : 'tratata', item : { val : 'tratata' } },
            sixth:   { val : 'tratata', item : { val : 'tratata' } },
            seventh: { val : 'tratata', item : { val : 'tratata' } },
            eighth:  { val : 'tratata', item : { val : 'tratata' } },
            ninth:   { val : 'tratata', item : { val : 'tratata' } }
        };
    }

    //Заполняем массив объектами
    function fillArray() {
        testedObject = {};
        for( var i = 0; i < count; i++ ) {
            testedObject[ i ] = {
                obj : getObject()
            };
        }
    }

    //Удаляем созданные объекты
    function deleteObjects() {
        for( var i = 0; i < count; i++ ) {
            delete testedObject[ i ].obj;
        }
    }
    </script>
</head>
<body>
<div>
    <button onclick="fillArray()" >Заполнить массив</button>
    <button onclick="deleteObjects()" >Удалить объекты</button>
</div>
</body>
</html>



Скрин утечки:


На скрине хорошо видно, что в момент удаления объектов память почему то сильно отжирается, но потом освобождается до прежнего состояния. Но все же память, отведенная для объектов которые были удалены осталась не очищенной.

При этом оператор delete, ведет себя вполне нормально, не ругается и не кричит, объекты как бы удаляются и как бы все нормально, но память, занимаемая ими, не освобождается.

Долой утечку


Некоторое время спустя я понял, что все достаточно просто, и утечки можно избежать, но придется немного перестроить код.
Память очищалась, если сделать вот так:
delete testedObject[ i ];


Вариант без утечки
<!DOCTYPE html>
<html>
<head>
    <title></title>
    <script type="text/javascript">

    var count = 100000;
    var testedObject;
    
    function getObject() {
        // делаем объект потяжелее
         return {
            first:   { val : 'tratata', item : { val : 'tratata' } },
            second:  { val : 'tratata', item : { val : 'tratata' } },
            third:   { val : 'tratata', item : { val : 'tratata' } },
            fourth:  { val : 'tratata', item : { val : 'tratata' } },
            fifth:   { val : 'tratata', item : { val : 'tratata' } },
            sixth:   { val : 'tratata', item : { val : 'tratata' } },
            seventh: { val : 'tratata', item : { val : 'tratata' } },
            eighth:  { val : 'tratata', item : { val : 'tratata' } },
            ninth:   { val : 'tratata', item : { val : 'tratata' } }
        };
    }

    function fillArray() {
        testedObject = {
            obj : {}
        };
        for( var i = 0; i < count; i++ ) {
            testedObject.obj[ i ] = getObject();
        }
    }

    function deleteObjects() {
        for( var i = 0; i < count; i++ ) {
            delete testedObject.obj[ i ];
        }
    }
    </script>
</head>
<body>
<div>
    <button onclick="fillArray()" >Заполнить массив</button>
    <button onclick="deleteObjects()" >Удалить объекты</button>
</div>
</body>
</html>



Изменения конечно не столь сложные, и сильно не повлияли на архитектуру моего велосипеда, но было очень неожиданно и досадно, что вариант первый создавал утечку.

Сразу оговорюсь что, причина утечки не скоупы, не контексты, не индексы, не имена параметров — я это все проверял.

Заключение


Итак, я вроде устранил утечку, и все хорошо, жизнь удалась. Но меня все же мучают много вопросов:
Чем кординально отличается первый вариант функции от второго?
И особенность ли это или браузерный баг?
Если особенность, то почему ни в одном мане по JS эта особенность не описана?
Если браузерный баг, то почему столько лет спустя он до сих пор есть?
Может ктото уже знает про это, но молчит?

Надеюсь, моя статья кому то поможет! И думаю, стоит проверить свои велосипеды на подобные утечки!

PS: Спасибо всем за внимание!!!

UPDATE 1:

Написал об этом пока что в багктрекер Мозилы, посмотрим что они скажут.

UPDATE 2:

Луч в конце тунеля



Итак, ситуация прояснилась, спасибо за это огромное mraleph и seriyPS, в их комментариях, все подробно описано. #comment_5107501, #comment_5107585, #comment_5107692, так же Mavim подробно описывает мой конкретный случай.

На все вопросы получены четкие ответы, это не магия JavaScript, не утечка и не браузерный баг, это особенность реализации оператора delete. И скорее это можно назвать избыточное использование памяти, и я как раз наступил на такую мину. Но теперь врага мы знаем в лицо и без проблем на от него избавимся.

Всем спасибо!!!
Гевергес Олег @djlexs
карма
0,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (48)

  • 0
    Сообщите о баге в багзиллы. Если не исправят, то хоть расскажут что к чему.
    • 0
      Позже обязательно напишу им, и потом отпишусь если получу ответ от них…
  • +17
    Ох уж это Vanilla JS!
  • +7
    Насколько я понимаю, первый вариант удаляет объект, а не элемент массива. И, если я не ошибаюсь, я даже где-то видел предостережение от такого использования delete.
    • 0
      Не совсем, если внимательно посмотреть, то там даже не массив, а объект, так же можно заменить
       testedObject[ i ] = ... 
      на такое
       testedObject[ 'item_' + i ] 
      но это ничего не даст.

      Первый и второй вариант различаются лишь структурой хранения данных.

      Вариант первый:
      
      testedObject = {
          1 : {
              obj : {....}
          },
          ....
          .....
      }
      

      Вариант второй:
      
      testedObject = {
          obj : {
              1 : {
                   .....
               },
                ....
                .....
          }
      }
      
      • 0
        Ну, я про это и пишу. В варианте с утечкой, delete применяется НЕ к элементу массива, а к объекту.
        • +1
          Точно, не правильно понял вас, но замечу что и в варианте без утечки delete так же применяется к объекту.
          • 0
            Теоритически, да. Но, вероятно, при таком синтаксисе уже работает «магия JavaScript» и delete делает именно то, что нужно.
            • 0
              Тоже возможно. Но как тогда объяснить, что если в качестве индетефикатора свойства, использовать не число, а строку 'item_' + i, и это ничего не меняет. Думаете тоже магия?
              • 0
                Ничего не меняет в том смысле что при таком варианте утечки продолжаются?
                • 0
                  Именно!
                  • +1
                    Вот это уже интересно. Т.к. удаление «элемента хэша» (или «свойства») это достаточно стандартная ситуация.
                    • 0
                      Вот и я о том же!!! Из-за этого потерял кучу времени пока понял в чем дело. У меня куча мест которые используют оператор delete, и везде все нормально, но в одной функции был именно такой вариант хранения данных, и уж никак я не мог грешить на delete.
                      • 0
                        Я смутно припоминаю, что я в таком случае сначала присваивал свойству пустое значение.
                        • 0
                          Пробовал, тоже не помогает. Я вообще кучу всего пробовал чтоб в варианте с утечкой не меняя структуры данных избавиться от утечки: переприсваивал свойства, делал удаление в отдельной функции и еще куча бредовых штук, но ничего не помогало…
                          • 0
                            Нда… Круто. Остается надеяться на разработчиков браузеров или виртальной машины JS.
                          • 0
                            А если заполнять вне функции?
                            • 0
                              Только что попробовал, все так же… течет ((
                          • 0
                            ха же хочется сюда `rm -fR .`
                            • 0
                              ха = как.
                              (голосую в соседнем посте за включение функции редактирования комментария =) )
          • +6
            Нет, в варианте без утечки вы удаляете элемент массива, а в варианте с утечкой — только одно его свойство.

            Представьте, что у вас более сложная структура данных в варианте с утечкой:
             testedObject[ i ] = {
                            obj : getObject(),
            
                            fld1 : val1,
                            fld2 : val2 
            // и тд
                        };
            


            А удаляете вы только
            delete testedObject[ i ].obj;
            


            тогда как

            testedObject[ i ].fld1;
            

            и

            testedObject[ i ].fld2;
            

            и тд остаются на месте.

            Все что надо — просто удалять

            testedObject[ i ];
            

            — то есть сам элемент массива, а не одно из его свойств.
            При удалении свойства сам элемент никуда не девается.
            Вот и вся разгадка.

            Нет там утечки и не было.
            • 0
              Вы правы, именно так как вы описали все и задумывалось, но вот только если удалить testedObject[ i ].obj; а остальных свойсв нету, то мой массив занимает около 6-8 мб, а не 50. Я специально сделал свойство obj достаточно боьшим чтоб он заметно ощущался на фоне массива.
      • 0
        Интересно, а
        testedObject[ i ] = null;
        
        не поможет избавиться от утечки?
  • 0
    А я правильно понимаю, что сборщик мусора потом все же вычистил память?
    • 0
      Нет, сборщик вычищает только ту память которая была съедена во время удаления объектов. Причем в варианте без утечки во время удаления память не съедается. А память занимаемая объектами остается не вычещенной.
  • +1
    Учитывая, что delete для элементов массива не меняет его длинну, это может привести к всяческим ошибкам при дальнейших операциях с этим массивом. Предпочитаю использовать splice.
  • 0
    Кстати, вопрос не в тему))
    Как сделать чтоб мой пост был во вкладке посты?
    • –1
      А все ненадо, разобрался))
  • +1
    Скажите, а для отлова утечек вы используете только стандартные средства хрома или что-то ещё?
    И я правильно понимаю что вы скрупулёзно, в силу требований проекта, проверяете на утечки новые куски кода?
    • 0
      мне кажется это ощютилось тормозами броузера. вероятно в системе объекты больше
    • 0
      Да, сейчас это уже обычная практика.
  • 0
    Прикольно, что судя по всему, если сделать вместо
    delete testedObject[ i ].obj;
    testedObject[ i ].obj = null;
    сборщик мусора собирает эти объекты
  • 0
    Как Вы пользуетесь гуглом?
    У меня первым результатом ссылка на StackOverflow, смотрите второй ответ по популярности в качестве разъясняющего примера.
    testedObject[ i ].obj = null;
    

    будет в данном случае правильным решением, так как однозначно говорит удалить все ссылки на объект.
    • +1
      С чего это зануление однозначно говорит удалить _все_ ссылки на объект? Оно просто зануляет эту ссылку и все.
      Когда вызывается delete в примере автора, других ссылок на него все-равно нет больше, по идее объект должен собираться со временем, поэтому разумным выглядит объяснение habrahabr.ru/post/150723/#comment_5107501
      • 0
        да, Вы правы, неправильно выразился, в данном случае зануляется ссылка на объект, которая была единственной, что и «видит» GC, поэтому и освобождает память из под объекта.
        • 0
          хм, нет, здесь все-таки прямое «уничтожение» объекта заменой на null, то есть mraleph ниже прав, у меня 848 байт на каждый элемент массива остается, что в сумме дает порядка 82 МБ.
  • +8
    Прежде всего совет: не пытайтесь понять, что удаляется, а что не удаляется по графику потребления памяти — это гадание на кофейной гуще. Просто посмотрите на снапшот кучи.

    Объяснение (по крайней мере для Хрома) очень простое: когда вы делаете delete testedObject[i].obj, V8 нормализует объект testedObject[i] — трансформирует его из быстрого компактного представления в медленное и раздутое представление на основе словаря, который еще и выделяется с запасом по размеру. При этом V8 не замечает, что после удаления в словаре будет пусто — и словарь (800 байтов) остается болтаться в воздухе. И так для каждого из ваших объектов.
    • +3
      а сами объекты с 9 свойствами, конечно же, помирают… просто они были меньше с словаря, который теперь свисает с каждого testedObject[i], что пораждает иллюзию их живости.
  • +4
    Дополню mraleph:

    После создания объектов:


    После удаления филдов obj


    Как видно — obj прекрасно подчистился и никаких «first»-«second»… не осталось. Но остался массив в 10 000 элементов (сам по себе занимающиий 470КБ), в каждой ячейке из которого пустой объект весом 424 Байта (почему пустой объект весит больше заполненного объяснил mraleph — полагаю потому что сказав delete testedObject[i].obj вы фактически сказали что testedObject[i] — изменяемый и вы можете захотеть впихнуть в него новых данных например а не просто обратиться к его полям в «read-only» режиме)
    • 0
      подскажите, это и есть «снапшот кучи», о котором упомянул mraleph?
      • 0
        да, в хроме Heap Snapshot во вкладке Profiles
  • +1
    Все логично. В первом варианте у Вас:
    testedObject = {
        1 : {
            obj : {....}
        },
        ....
        .....
    }
    

    что дает count объектов внутри testedObject, внутри каждого из которых по 1 объекту obj, то есть суммарное кол-во «значимых» объектов 2*count.

    Во втором варианте у Вас:
    testedObject = {
        obj : {
            1 : {
                 .....
             },
              ....
              .....
        }
    }
    

    что дает внутри testedObject 1 объект obj, в котором count объектов с индексами, то есть суммарное кол-во «значимых» объектов count + 1.
    То есть второй вариант изначально потребляет меньше памяти, что также можно видеть на графиках, достаточно выключить Record, а потом опять включить, чтоб увидеть конечный результат после нажатия «Заполнить массив».

    С учетом комментария mraleph и комментария seriyPS можно принять, что при операции delete родительский объект «нормализуется» и в конечном счете становится не меньше N байт (как видно, зависит от системы).

    Для первого варианта родительским объектом является testedObject[i] (который Вы не удаляете), для второго варианта родительским элементом является testedObject.obj, то есть в первом варианте (с «утечкой») Вы получаете в остатке:
    (2*count - count)*N => count*N байт лишними
    Во втором варианте Вы получаете:
    (count + 1 - count)*N => N байт лишними
    Как-бы логично, что count*N > N, «примерно» в count раз ;)
  • +1
    выше уже написали что удаляются разные вещи, но они удаляются — delete возвращает также результат операции, в обоих случаях true.

    кроме того, странно видеть профайлер хрома, если баг открывают на мозилловцев.
    если смотреть about:memory, то память возвращается спустя какое-то время или запуском сборщика мусора.
  • 0
    >> На все вопросы получены четкие ответы, это не магия JavaScript, не утечка и не браузерный баг, это особенность реализации.

    Не совсем, все-таки, на мой взгляд утечка здесь есть, потому как был {}, потом {obj: {...}}, потом удалили obj через delete и получили, что размер полученного {} > начального {}, или теперь memory leak == «особенность реализации»? )
    Просто у Вас некорректный пример, так как сравниваете изначально разные вещи, если уж отписывать баг, то скрипт должен быть другим, в котором будет видна утечка от «особенностей реализации».

    В приведенном выше рассуждении была некая переменная N, вместо которой нужно (N + M), где:
    — M — это размер пустого объекта {};
    — N — размер «особенностей нормализации».
    Тогда testedObject[i] должен после delete testedObject[i].obj стать размером M, а становится размером (N + M), то есть в первом варианте после «очистки» в идеале должны получить M*count, а получили (N + M)*count, а еще в куче видно, что M много больше N, что немного неправильно.
    • 0
      Простите первый раз неправильно прочитал ваш комент.
      Вы все же считаете что утечка ест, я правильно понял? Я изначально думал тоже об утечке, но сильно сомневался изза того что не может быть такого о всех браузерах одновременно, и вариант описывающий реализацию delete, более реалистичен.
    • +2
      утечка — это когда память болтается непонятно где, не используется и не может быть переиспользована. а здесь не эффективное использование памяти, потому что это память никуда не пропала и занята объектом, на который есть валидные ссылки из другого объекта.
  • 0
    Не совсем, все-таки, на мой взгляд утечка здесь есть


    Да, я согласен, это не утечка, как таковой казалась на первый взгляд, и я написал об этом. Под особенность реализации, о которых я не знал, думаю и многие другие, я имел в виду:
    когда вы делаете delete testedObject[i].obj, V8 нормализует объект testedObject[i] — трансформирует его из быстрого компактного представления в медленное и раздутое представление на основе словаря, который еще и выделяется с запасом по размеру. При этом V8 не замечает, что после удаления в словаре будет пусто — и словарь (800 байтов) остается болтаться в воздухе.

    зная о такой реализации оператора delete, я бы не наткнулся на такие грабли, и поста бы вообще небыло. Но пост есть, и думаю заслужиает существования, т.к. вполне вероятно многие незнаю как работает delete.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.