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

Это история о том, как DatatypeFactory.newInstance() поставил на колени наш высокопроизводительный Java-сервис, и об удивительно простом решении, позволившем полностью избавиться от проблемы.

Открытие

В нашей организации есть микросервис отчётности, обрабатывающий примерно 800 запросов в секунду. Время от времени пользователи сообщали о медленной реакции системы, но наши дэшборды показывали, что всё в пределах SLA. Не возникало никаких алертов. Сервис «работал нормально».

В процессе обыденного ревью производительности я изучил дампы потоков из продакшена. Обнаруженное заставило меня напрячься:

"hystrix-SearchProviderGroup-1" #1111 daemon prio=5
   java.lang.Thread.State: BLOCKED (on object monitor)
    at jdk.internal.loader.URLClassPath.getLoader(URLClassPath.java:425)
    - waiting to lock <0x00000000846e4668> (a jdk.internal.loader.URLClassPath)
    ...
    at javax.xml.datatype.FactoryFinder.findServiceProvider(FactoryFinder.java:287)
    at javax.xml.datatype.DatatypeFactory.newInstance(DatatypeFactory.java:169)

102 потока. Все они заблокированы. И все ждут одной и той же блокировки: <0x00000000846e4668>.

Разбираемся в проблеме

Что делает DatatypeFactory.newInstance()?

Вызов DatatypeFactory.newInstance() выглядит довольно просто:

public XMLGregorianCalendar getXMLGregorianCalendarValue(Date date) {
    DatatypeFactory factory = DatatypeFactory.newInstance();
    GregorianCalendar cal = new GregorianCalendar();
    cal.setTime(date);
    return factory.newXMLGregorianCalendar(cal);
}

Но на самом деле этот невинно выглядящий вызов запускает сложную цепочку операций:

DatatypeFactory.newInstance()
    └── FactoryFinder.find()
        └── FactoryFinder.findServiceProvider()
            └── ServiceLoader.load()
                └── ServiceLoader$LazyClassPathLookupIterator.hasNext()
                    └── ClassLoader.getResources("META-INF/services/...")
                        └── URLClassLoader.findResources()
                            └── URLClassPath.getLoader()  ← SYNCHRONIZED

Метод URLClassPath.getLoader() синхронизируется. В Java 11 соответствующий код выглядит так:

// From jdk.internal.loader.URLClassPath
private synchronized Loader getLoader(int index) {
    // ... логика поиска загрузчика
}

Каждый вызов DatatypeFactory.newInstance() попадает в этот синхронизируемый блок. Когда этот метод вызывают сотни потоков, они все становятся в очередь, ожидая эту единственную блокировку.

Налог на ServiceLoader

Механизм ServiceLoader языка Java спроектирован для обеспечения гибкости — он позволяет обнаруживать реализации в среде исполнения при помощи файлов META-INF/services. Но за эту гибкость приходится расплачиваться:

  1. Сканирование путей к классам: ServiceLoader итеративно обходит все JAR в поисках файлов поставщиков сервисов

  2. Синхронизация ClassLoader: поиск ресурсов требует синхронизированного доступа к URLClassPath

  3. Повторяющаяся работа: без кэширования это происходит при каждом вызове newInstance()

В нашем случае, когда выполняется 800 запросов в секунду, и на каждый запрос происходит несколько вызовов DatatypeFactory.newInstance(), мы обращаемся к этому синхронизируемому блоку миллионы раз в час.

Улика в дампе потоков

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

Паттерн 1: конфликт DatatypeFactory (большинство заблокированных потоков)

"hystrix-SearchProviderGroup-1" BLOCKED
    at jdk.internal.loader.URLClassPath.getLoader(URLClassPath.java:425)
    - waiting to lock <0x00000000846e4668>
    ...
    at javax.xml.datatype.DatatypeFactory.newInstance(DatatypeFactory.java:169)

Паттерн 2: загрузка ресурсов ClassLoader для чтения файлов

"https-jsse-nio-8443-exec-26" BLOCKED
    at jdk.internal.loader.URLClassPath.getLoader(URLClassPath.java:425)
    - waiting to lock <0x00000000846e4668>
    ...
    at java.net.URLClassLoader.getResourceAsStream(URLClassLoader.java:322)

Оба паттерна соревновались за одну и ту же блокировку. Вызовы DatatypeFactory создавали «пробку», а операции чтения файлов застревали в одной и той же очереди.

Данные: численная оценка проблемы

Я выполнил запрос Splunk, чтобы понять, как часто мы считываем файлы: 34 миллиона операций чтения одних и тех же маленьких файлов JSON за 12 часов. Каждое считывание обращалось к ClassLoader. Каждый доступ к ClassLoader потенциально конкурировал с вызовами DatatypeFactory.newInstance().

Устраняем проблему

Исправление 1: статическая инициализация DatatypeFactory

После создания экземпляра DatatypeFactory он потокобезопасен. Нет никакой причины создавать новый для каждого вызова.

До:

public XMLGregorianCalendar getXMLGregorianCalendarValue(Date date) {
    DatatypeFactory factory = DatatypeFactory.newInstance();
    GregorianCalendar cal = new GregorianCalendar();
    cal.setTime(date);
    return factory.newXMLGregorianCalendar(cal);
}

После:

private static final DatatypeFactory DATATYPE_FACTORY;

static {
    try {
        DATATYPE_FACTORY = DatatypeFactory.newInstance();
    } catch (DatatypeConfigurationException e) {
        throw new RuntimeException("Failed to initialize DatatypeFactory", e);
    }
}

public XMLGregorianCalendar getXMLGregorianCalendarValue(Date date) {
    GregorianCalendar cal = new GregorianCalendar();
    cal.setTime(date);
    return DATATYPE_FACTORY.newXMLGregorianCalendar(cal);
}

Это единственное изменение устранило весь оверхед ServiceLoader после первой инициализации. Фабрика создаётся один раз при загрузке классов, а все последующие вызовы используют кэшированный экземпляр.

Исправление 2: кэш Caffeine для содержимого файлов

Для решения проблемы конкуренции с чтением файлов я реализовал простой кэш на основе Caffeine:

@Component
@Slf4j
public class FileUtil {

    private static final int CACHE_MAX_SIZE = 100;
    private static final int CACHE_EXPIRE_AFTER_WRITE_MINUTES = 60;

    private final Cache<String, String> stringCache;

    public FileUtil() {
        this.stringCache = Caffeine.newBuilder()
                .maximumSize(CACHE_MAX_SIZE)
                .expireAfterWrite(CACHE_EXPIRE_AFTER_WRITE_MINUTES, TimeUnit.MINUTES)
                .build();
    }

    public String readFileAsString(Object classLoaderSource, String fileName) {
        String cachedValue = stringCache.getIfPresent(fileName);
        if (cachedValue != null) {
            return cachedValue;
        }

        String text = null;
        try (InputStream inputStream = classLoaderSource.getClass()
                .getClassLoader().getResourceAsStream(fileName)) {
            if (inputStream != null) {
                text = IOUtils.toString(inputStream, StandardCharsets.UTF_8);
                stringCache.put(fileName, text);
            }
        } catch (Exception e) {
            log.error("Error reading file={}", fileName, e);
        }
        return text;
    }
}

Почему Caffeine?

Внутри Caffeine используется ConcurrentHashMap, обеспечивающая гораздо более детализированную блокировку, чем URLClassPath ClassLoader:

// Из BoundedLocalCache Caffeine
final ConcurrentHashMap<Object, Node<K, V>> data;

Анализ: почему это произошло?

Блокировка URLClassPath

В исходном коде JDK URLClassPath хранит список объектов Loader, которые знают, как загружать ресурсы из разных типов URL (файлов JAR, папок и так далее). Метод getLoader() синхронизируется. потому что он выполняет ленивую инициализацию этих загрузчиков:

// Упрощённая версия кода из jdk.internal.loader.URLClassPath
private synchronized Loader getLoader(int index) {
    if (closed) {
        return null;
    }
    while (loaders.size() < index + 1) {
        // Создание нового загрузчика для следующего URL
        URL url = urls.get(loaders.size());
        Loader loader = getLoader(url);
        loaders.add(loader);
    }
    return loaders.get(index);
}

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

Скрытые затраты ServiceLoader

Класс ServiceLoader, используемый DatatypeFactory.newInstance(), итеративно обходит все URL по пути к классам, ища файлы поставщиков сервисов:

// Из java.util.ServiceLoader
private class LazyClassPathLookupIterator implements Iterator<Provider<S>> {
    Enumeration<URL> configs;
    
    private boolean hasNextService() {
        while (configs == null || !configs.hasMoreElements()) {
            // Получение следующего набора URL поставщиков сервисов
            configs = loader.getResources(PREFIX + service.getName());
        }
        // ...
    }
}

Каждый вызов getResources() в процессе поиска по JAR может совершать множество вызовов URLClassPath.getLoader(). В типичном приложении Spring Boot, имеющем сотни JAR в путях к классам, эти затраты быстро накапливаются.

Накопительный эффект

Нашу ситуацию ухудшило сочетание:

  1. Высокой конкурентности: 800 запросов в секунду с множество потоков на каждый запрос

  2. Многократное создание фабрики: DatatypeFactory.newInstance() вызывался при каждом преобразовании даты

  3. Частое чтение файлов: 34 миллиона считываний файлов за 12 часов

  4. Общая блокировка: обе операции конкурировали за одну блокировку URLClassPath

Каждый заблокированный поток в процессе ожидания занимал память и ресурсы операционной системы. При блокировке 102 потоков мы впустую тратили существенные ресурсы просто на ожидание получения блокировки.

Основные выводы

1. Фабричные методы не бесплатны

Методы наподобие DatatypeFactory.newInstance(), DocumentBuilderFactory.newInstance() и TransformerFactory.newInstance() используют внутри ServiceLoader. Они проектировались с расчётом на гибкость, а не на производительность. При вызове их на горячем пути исполнения кода нужно кэшировать результат.

Другие частые причины проблем:

  • SAXParserFactory.newInstance()

  • SchemaFactory.newInstance()

  • XPathFactory.newInstance()

  • XMLInputFactory.newInstance()

2. Операции ClassLoader синхронизируются

Все операции, затрагивающие ClassLoader (getResource(), getResourceAsStream(), loadClass()), потенциально могут конкурировать за блокировки. В коде обработки запросов высокопроизводительных приложений следует минимизировать эти операции.

3. Информация из дампов потоков

Без анализа дампа потоков эту проблему было бы практически невозможно диагностировать. Её симптомы (периодические резкие скачки задержек) не указывают напрямую на конкуренцию ClassLoader. Регулярный анализ дампа потоков должен быть частью вашего инструментария мониторинга производительности.

4. «Нормальной работы» недостаточно

Наш сервис отвечал SLA. Никаких алертов не возникало. Однако мы тратили огромные ресурсы на ненужную работу. Оптимизация производительности — это не только ремонт поломанного, но и в первую очередь устранение ненужной работы.

5. Статические ресурсы должны кэшироваться

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

Как обнаружить подобное в своём приложении

Анализ дампа потоков

Создавайте дампы потоков в период пиковой нагрузки и ищите в них:

  • Большое количество потоков в состоянии BLOCKED

  • Трассировки стека, содержащие URLClassPath, ServiceLoader или ClassLoader

  • Потоки, ожидающие одного объекта блокировки

JFR (Java Flight Recorder)

Включите JFR в продакшене и ищите в нём:

  • События конкуренции за блокировки

  • Высокую частоту вызовов ClassLoader.getResource*

  • Активность ServiceLoader

java -XX:StartFlightRecording=duration=60s,filename=recording.jfr ...

В заключение

Единственная строка кода (DatatypeFactory.newInstance()) вызывала блокировку 102 потоков в нашем продакшен-сервисе. Устранить проблему было столь же просто: инициализировать фабрику один раз, а затем использовать её многократно.

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

Уделите время изучению того, что на самом делает код. Изучите дампы потоков. Критически оцените фабричные методы. Кэшируйте статичные ресурсы.

Описанные в этом посте изменения снизили конкуренцию ClassLoader в нашем среде продакшена более чем на 99%. Если у вас есть высокопроизводительный Java-сервис, советую проверить свои дампы потоков на предмет похожих паттернов.

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


  1. ris58h
    23.02.2026 15:48

    Для решения проблемы конкуренции с чтением файлов я реализовал простой кэш на основе Caffeine

    Напомнило Яндексовую задачку на ревью кода про кэш. Тут тоже несколько потоков могут начать загружать одни и те же данные одновременно.


  1. panzerfaust
    23.02.2026 15:48

    Механизм ServiceLoader языка Java спроектирован для обеспечения гибкости — он позволяет обнаруживать реализации в среде исполнения при помощи файлов META-INF/services

    Остался без ответа главный вопрос: ну что, сынку, помогли тебе твои ляхи она вам хоть как-то помогает, "гибкость" эта. Или это такой же гиммик, как независимость хибера от СУБД?


    1. gerashenko
      23.02.2026 15:48

      Слышал звон, да не знаю где он

      Вообще не про то обе технологии, за которое пытаетесь их укоротить


      1. panzerfaust
        23.02.2026 15:48

        Вы частично правы. Я не сразу понял, что этот DatatypeFactory из XML-кухни. Там тот еще enterprise fizz buzz, так что допущу, что именно так и нужно писать.

        Ну а возможность смены СУБД без переписывания кода для хибера довольно часто подается как сильный аргумент в пользу сложных ORM-фреймворков. Хотя что-то мне подсказывает, что при переезде терабайтной базы с одного движка на другой проблемы с ORM будут на десятом плане.


  1. PirateTigo
    23.02.2026 15:48

    Зачем вообще нужно было в разных потоках загружать одни и те же классы? Почему нельзя было выполнить их предзагрузку? Конечно, - это неоптимальное решение изначально. Я уж молчу про инстанцирование фабрики типов для каждого потока. Выглядит как мы сами создали оружие для выстрела себе в колено, а потом героически собрали коленную чашечку и закопали ствол на заднем дворе от греха подальше. Механизм загрузки классов не предназначен для быстрой обработки данных.


  1. hir0kashi
    23.02.2026 15:48

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


  1. Artyomcool
    23.02.2026 15:48

    Рекомендую async-profiler вместо дампа потоков, наглядно будет видно всё происходящее без необходимости применять интеллект.


    1. Ratenti
      23.02.2026 15:48

      В чем отличие от VisualVM? Вы же не про Lock profiling?


      1. Artyomcool
        23.02.2026 15:48

        Нет, в данной ситуации я бы использовал wall-profile. Ключевых отличий для нашего случая как минимум два:

        1. Async-profiler может показывать граф вызовов вплоть до ядра, а не только java-часть.

        2. Возможность визуализации в виде хитмапа с возможностью дальнейшего анализа и сравнения флеймграфов за произвольный период.

        Также в некоторых случаях async-profiler показывает значительно более высокую точность профиля, т.к. проектировался как раз в т.ч. для патологических corner-case'ов.

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


  1. rusia2054
    23.02.2026 15:48

    Спасибо за статью и за то что поделились опытом.