GroovyShell в Java8: утечка памяти/дублированные классы [исходный код + нагрузочный тест предоставлен]

У нас есть утечка памяти, вызванная сценариями GroovyShell/ Groovy (см. код GroovyEvaluator в конце). Основные проблемы (копипаст из MAT анализатора):

Класс "java.beans.ThreadGroupContext", загружаемый "‹системным загрузчиком классов›", занимает 807 406 960 (33,38%) байт.

а также:

16 экземпляров «org.codehaus.groovy.reflection.ClassInfo$ClassInfoSet$Segment», загруженных «sun.misc.Launcher$AppClassLoader @ 0x7004e9c80», занимают 1 510 256 544 (62,44%) байта.

Мы используем Groovy 2.3.11 и Java8 (точнее, 1.8.0_25).
Обновление до Groovy 2.4.6 не решает проблему. Просто улучшает использование памяти a немного немного, особенно. не кучи.
Используемые аргументы Java: -XX:+CMSClassUnloadingEnabled -XX:+UseConcMarkSweepGC

Кстати, я прочитал https://dzone.com/articles/groovyshell-and-memory-leaks. Мы устанавливаем для оболочки GroovyShell значение null, когда она больше не нужна. Использование GroovyShell().parse(), вероятно, помогло бы, но на самом деле это не вариант для нас - у нас есть> 10 наборов, каждый из которых состоит из 20-100 скриптов, и их можно изменить в любой момент. время (во время выполнения).

Установка MaxMetaspaceSize также должна помочь, но на самом деле она не решает основную проблему, не устраняет основную причину. Так что я все еще пытаюсь прибить его вниз.


Я создал нагрузочный тест, чтобы воссоздать проблему (см. код в конце). Когда я запускаю его:

  • размер кучи, размер метапространства и количество классов продолжают увеличиваться
  • дамп кучи, сделанный через несколько минут, больше 4 ГБ

Графики производительности за первые 3 минуты: введите здесь описание изображения

Как я уже упоминал, я использую MAT для анализа дампов кучи. Итак, давайте проверим отчет дерева Dominator:

введите здесь описание изображения

Hashmap занимает> 30% кучи. Итак, давайте проанализируем это дальше. Посмотрим, что в нем сидит. Давайте проверим записи хэша:

введите здесь описание изображения

Он сообщает о 38 830 сообщениях. Включая 38 780 записей с ключами, соответствующими ".class Script."

Другое дело, отчет "дублирующиеся классы":

введите здесь описание изображения

У нас есть 400 записей (поскольку нагрузочные тесты определяют 400 G.scripts), все для классов "ScriptN". Все они содержат ссылки на groovyclassloader$innerloader.

Я обнаружил похожую ошибку: https://issues.apache.org/jira/browse/GROOVY-7498 (см. комментарии в конце и прикрепленный скриншот) - их проблемы были решены путем обновления Java до 1.8u51. Хотя нам это не помогло.

Наш код:

public class GroovyEvaluator
{
    private GroovyShell shell;

    public GroovyEvaluator()
    {
        this(Collections.<String, Object>emptyMap());
    }

    public GroovyEvaluator(final Map<String, Object> contextVariables)
    {
        shell = new GroovyShell();
        for (Map.Entry<String, Object> contextVariable : contextVariables.entrySet())
        {
            shell.setVariable(contextVariable.getKey(), contextVariable.getValue());
        }
    }

    public void setVariables(final Map<String, Object> answers)
    {
        for (Map.Entry<String, Object> questionAndAnswer : answers.entrySet())
        {
            String questionId = questionAndAnswer.getKey();
            Object answer = questionAndAnswer.getValue();
            shell.setVariable(questionId, answer);
        }
    }

    public Object evaluateExpression(String expression)
    {
        return shell.evaluate(expression);
    }

    public void setVariable(final String name, final Object value)
    {
        shell.setVariable(name, value);
    }

    public void close()
    {
        shell = null;
    }
}

Нагрузочный тест:

/** Run using -XX:+CMSClassUnloadingEnabled -XX:+UseConcMarkSweepGC */
public class GroovyEvaluatorLoadTest
{
    private static int NUMBER_OF_QUESTIONS = 400;
    private final Map<String, Object> contextVariables = Collections.emptyMap();
    private List<Fact> factMappings = new ArrayList<>();

    public GroovyEvaluatorLoadTest()
    {
        for (int i=0; i<NUMBER_OF_QUESTIONS; i++)
        {
            factMappings.add(new Fact("fact" + i, "question" + i));
        }
    }

    private void callEvaluateExpression(int iter)
    {
        GroovyEvaluator groovyEvaluator = new GroovyEvaluator(contextVariables);

        Map<String, Object> factValues = new HashMap<>();
        Map<String, Object> answers = new HashMap<>();
        for (int i=0; i<NUMBER_OF_QUESTIONS; i++)
        {
            factValues.put("fact" + i, iter + "-fact-value-" + i);
            answers.put("question" + i, iter + "-answer-" + i);
        }

        groovyEvaluator.setVariables(answers);
        groovyEvaluator.setVariable("answers", answers);
        groovyEvaluator.setVariable("facts", factValues);

        for (Fact fact : factMappings)
        {
            groovyEvaluator.evaluateExpression(fact.mapping);
        }
        groovyEvaluator.close();
    }

    public static void main(String [] args)
    {
        GroovyEvaluatorLoadTest test = new GroovyEvaluatorLoadTest();

        for (int i=0; i<995000; i++)
        {
            test.callEvaluateExpression(i);
        }
        test.callEvaluateExpression(0);
    }
}

public class Fact
{
    public final String factId;

    public final String mapping;

    public Fact(final String factId, final String mapping)
    {
        this.factId = factId;
        this.mapping = mapping;
    }
}

Есть предположения? Спасибо заранее


person Patrycja K    schedule 04.04.2016    source источник


Ответы (1)


Хорошо, это мое решение:

public class GroovyEvaluator
{
    private static GroovyScriptCachingBuilder groovyScriptCachingBuilder = new GroovyScriptCachingBuilder();
    private Map<String, Object> variables = new HashMap<>();

    public GroovyEvaluator()
    {
        this(Collections.<String, Object>emptyMap());
    }

    public GroovyEvaluator(final Map<String, Object> contextVariables)
    {
        variables.putAll(contextVariables);
    }

    public void setVariables(final Map<String, Object> answers)
    {
        variables.putAll(answers);
    }

    public void setVariable(final String name, final Object value)
    {
        variables.put(name, value);
    }

    public Object evaluateExpression(String expression)
    {
        final Binding binding = new Binding();
        for (Map.Entry<String, Object> varEntry : variables.entrySet())
        {
            binding.setProperty(varEntry.getKey(), varEntry.getValue());
        }
        Script script = groovyScriptCachingBuilder.getScript(expression);
        synchronized (script)
        {
            script.setBinding(binding);
            return script.run();
        }
    }

}

public class GroovyScriptCachingBuilder
{
    private GroovyShell shell = new GroovyShell();
    private Map<String, Script> scripts = new HashMap<>();

    public Script getScript(final String expression)
    {
        Script script;
        if (scripts.containsKey(expression))
        {
            script = scripts.get(expression);
        }
        else
        {
            script = shell.parse(expression);
            scripts.put(expression, script);
        }
        return script;
    }
}

Новое решение поддерживает количество загруженных классов и размер метаданных на постоянном уровне. Использование выделенной памяти без кучи = ~ 70 МБ.

Также: больше нет необходимости использовать UseConcMarkSweepGC. Вы можете выбрать любой GC, который вам нужен, или придерживаться стандартного :)

Синхронизация доступа к объектам сценария, возможно, не лучший вариант, но единственный, который я нашел, который поддерживает размер метапространства в пределах разумного уровня. И даже лучше - он держит его постоянным. Еще. Это может быть не лучшим решением для всех, но отлично работает для нас. У нас есть большие наборы крошечных скриптов, что означает, что это решение (в значительной степени) масштабируемо.

Давайте посмотрим на некоторые СТАТИСТИЧЕСКИЕ данные для GroovyEvaluatorLoadTest с GroovyEvaluator, используя:

  • старый подход с shell.evaluate(выражение):
0 iterations took 5.03 s
100 iterations took 285.185 s
200 iterations took 821.307 s
  • script.setBinding (привязка):
0 iterations took 4.524 s
100 iterations took 19.291 s
200 iterations took 33.44 s
300 iterations took 47.791 s
400 iterations took 62.086 s
500 iterations took 77.329 s

Таким образом, дополнительное преимущество: это молниеносно по сравнению с предыдущим решением с утечкой;)

person Patrycja K    schedule 29.04.2016
comment
Большое спасибо за это. Я имею дело с похожим сценарием: (потенциально) множество небольших скриптов, каждый из которых часто выполняется: github.com/delving/sip-creator/issues/485 - person Hans Westerbeek; 15.11.2016
comment
По сути, утечка памяти сохраняется. Сценарий, однажды загруженный, все еще никогда не выгружается. Кэшируя их, вы гарантируете, что метапространство не заполняется так быстро, что приятно. Для моих целей я бы предпочел решение, которое устраняет все следы выполнения, и я рад пожертвовать ради этого некоторой производительностью. Поиски продолжаются... :) - person Hans Westerbeek; 16.11.2016
comment
Да, это лучшее решение, которое мне удалось найти ДЛЯ НАШЕГО USE CASE :) Как я уже писал: у нас ›10 наборов, каждый из которых состоит из 20-100 скриптов. Так что для наших нужд он делает именно то, что нам нужно. Я не знаю, что вам нужно, но, может быть, все, что нужно, это настроить GroovyScriptCachingBuilder? Пожалуйста, опубликуйте свое решение, если вы его найдете, другим оно может оказаться полезным :) - person Patrycja K; 29.12.2016