Анализ утечки памяти за пределами кучи JVM, вызванной однократным запоминанием шрифта

Java

причина

Во время Double 11 использование памяти службой Java в компании достигло 37 г, но конфигурация JVM приложения была-Xms6g -Xmx6g

анализ проблемы

бизнес

В основном связан с бизнесом по синтезу изображений и текста.

код

Ниже приведена упрощенная версия рассматриваемого кода.

public class FontMain {

    public static void main(String[] args) throws IOException, FontFormatException, InterruptedException, NoSuchMethodException, IllegalAccessException, InvocationTargetException {
        File file = new File("/Users/cayun/PingFang.ttc");
        while (true) {
            run(file);
            Thread.sleep(1);
        }
    }

    private static void run(File file) throws IOException, FontFormatException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        BufferedImage blankImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = blankImage.createGraphics();
        Font font = Font.createFont(Font.TRUETYPE_FONT, file);
        font = font.deriveFont(12.0f);
        g.setFont(font);
        g.drawString("hello", 12, 12);
    }
}

Утечка

Обзор причин

После каждого нового Font() вызов метода g.drawString() будет выделять часть памяти в области, отличной от кучи, а не освобождать ее.

стек вызовов

Стек вызовов g.drawString() выглядит следующим образом:

SunGraphics2D.drawString(String, int, int) -> ValidatePipe.drawString(SunGraphics2D, String, double, double) -> SunGraphics2D.getFontInfo() -> SunGraphics2D.checkFontInfo -> Font2D.getStrike(Font, AffineTransform, AffineTransform, int, int) -> Font2D.getStrike(FontStrikeDesc, boolean)->FileFont.createStrike(FontStrikeDesc) -> ... -> T2KFontScaler.<init>(Font2D, int, boolean, int) -> T2KFontScaler.initNativeScaler(...)

основная причина

Вторая часть стека вызовов отмечена красным

Новый метод t2kfontscaler() вызывает собственный метод T2kfontscaler.initnativeScaler(), этот собственный метод будет выделять память в разделе, отличном от HEAP, и нет соответствующего механизма повторного использования.

демо-код и визуализация

public class FontMain {

    public static void main(String[] args) throws IOException, FontFormatException, InterruptedException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, ClassNotFoundException, InstantiationException {
        File file = new File("/System/Library/Fonts/AquaKana.ttc");
        Font font = Font.createFont(Font.TRUETYPE_FONT, file);
        font = font.deriveFont(12.0f);
        BufferedImage blankImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = blankImage.createGraphics();
        g.setFont(font);

        // T2KFontScaler无法通过new的方式创建,此处使用反射创建
        Class clazz = Class.forName("sun.font.T2KFontScaler");
        Constructor constructor = clazz.getConstructor(Font2D.class, int.class, boolean.class, int.class);
        constructor.setAccessible(true);

        while (true) {
            constructor.newInstance(((SunGraphics2D) g).getFontInfo().font2D, 0, true, 80005872);
            Thread.sleep(1);
        }
    }
}

image-20181127183725026

Вспомогательное доказательство: известная ошибка JDK

JDK-7074159 : run out of memory

решение

кеш для шрифтов

public class FontMain {
    private static Font font = null;
    private static Object lock = new Object();

    public static void main(String[] args) throws IOException, FontFormatException, InterruptedException, NoSuchMethodException, IllegalAccessException, InvocationTargetException {
        File file = new File("/Users/cayun/PingFang.ttc");
        while (true) {
            run(file);
            Thread.sleep(1);
        }
    }

    private static void run(File file) throws IOException, FontFormatException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        BufferedImage blankImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = blankImage.createGraphics();
        if (font == null) {
            synchronized (lock) {
                if (font == null) {
                    font = Font.createFont(Font.TRUETYPE_FONT, file);
                }
            }
        }
        font = font.deriveFont(12.0f);
        g.setFont(font);
        g.drawString("hello", 12, 12);
    }
}

Подробное объяснение причин

Это решение выглядит немного странно, и легко может возникнуть такой вопрос: именно метод g.drawString() вызывает утечку памяти, а зачем Font кешировать?

Чтобы просто объяснить причину, мы сначала определим две схемы

  1. Вариант 1: не использовать кеш, который является оригинальным решением, вызывающим утечку памяти.
  2. Решение 2. Кэш-шрифты

конкретная причина

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

image-20181122122215650

Быстрый способ диагностики таких проблем

jmap -histo <pid> | grep FontScaler

Если объект особенно большой, это, скорее всего, связано с этой причиной