From cbeaf01dc29978417de9c23305c004a97aa31617 Mon Sep 17 00:00:00 2001 From: Nikita Fedkin Date: Wed, 15 Jul 2026 20:23:52 +0200 Subject: [PATCH 1/2] perf(reader): replace case-insensitive skip-list caches with get-first ConcurrentHashMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Профилирование чтения показало, что кэши TransformationUtils (METHODS/TYPES/SETTERS) на ConcurrentSkipListMap с компаратором CASE_INSENSITIVE_ORDER — крупнейший прикладной hotspot чтения: ~6% CPU уходит на ConcurrentSkipListMap.doGet + StringLatin1.compareToCI при поиске метода/типа по имени XML-узла. Регистронезависимость нужна лишь при первом резолве (скан equalsIgnoreCase по методам класса), а не на каждый поиск в кэше. Переводим кэши на ConcurrentHashMap с точным ключом-именем узла (имена узлов повторяются и интернированы Xerces) — поиск O(1) по хешу без CI-сравнений. Первый резолв по-прежнему матчит имя регистронезависимо. Доступ к кэшу — get()-first, а не computeIfAbsent с захватывающей лямбдой: ConcurrentHashMap.computeIfAbsent не встраивается JIT-ом, из-за чего лямбда-функция отображения перестаёт скаляризоваться escape-анализом и аллоцируется на каждый вызов. На попадании в кэш (горячий путь) лямбда не создаётся. Замеры (JMH MDClassesBenchmark на cpm, JFR sample share — устойчив к шуму машины): - доля CI-поиска в CPU чтения: 6.0% -> 1.1% (-4.9 п.п.); - аллокации: без изменений (5078 -> 5077 МБ/чтение), лямбды кэша не аллоцируются; - wall: 9065 -> 8887 мс (в пределах шума). Поведение не меняется. Тесты TransformationUtilsTest и интеграционные MDOReaderTest зелёные. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HLvkKQCwBjTHwuZ4EGBQK6 --- .../reader/common/TransformationUtils.java | 56 ++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/github/_1c_syntax/bsl/reader/common/TransformationUtils.java b/src/main/java/com/github/_1c_syntax/bsl/reader/common/TransformationUtils.java index 893ec0659..0acbeeabd 100644 --- a/src/main/java/com/github/_1c_syntax/bsl/reader/common/TransformationUtils.java +++ b/src/main/java/com/github/_1c_syntax/bsl/reader/common/TransformationUtils.java @@ -36,7 +36,6 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentSkipListMap; /** * Вспомогательный класс для конвертирования значений между моделями @@ -94,10 +93,13 @@ public void invoke(Object source, String methodName) { */ @Nullable public Type fieldType(Object source, String methodName) { - return TYPES.computeIfAbsent(source.getClass().getName(), - k -> new ConcurrentSkipListMap<>(String.CASE_INSENSITIVE_ORDER)) - .computeIfAbsent(methodName, l -> computeFieldType(source, methodName)) - .orElse(null); + var perClass = perClassCache(TYPES, source.getClass().getName()); + var cached = perClass.get(methodName); + if (cached == null) { + cached = computeFieldType(source, methodName); + perClass.putIfAbsent(methodName, cached); + } + return cached.orElse(null); } /** @@ -173,19 +175,47 @@ public Object build(Object builder) { @Nullable private Method getMethod(Class clazz, String methodName) { - return METHODS.computeIfAbsent(clazz.getName(), k -> new ConcurrentSkipListMap<>(String.CASE_INSENSITIVE_ORDER)) - .computeIfAbsent(methodName, k -> Arrays.stream(clazz.getDeclaredMethods()) + var perClass = perClassCache(METHODS, clazz.getName()); + var cached = perClass.get(methodName); + if (cached == null) { + cached = Arrays.stream(clazz.getDeclaredMethods()) .filter(m -> methodName.equalsIgnoreCase(m.getName())) - .findFirst()) - .orElse(null); + .findFirst(); + perClass.putIfAbsent(methodName, cached); + } + return cached.orElse(null); } @Nullable private SetterDescriptor getSetter(Class clazz, String methodName) { - return SETTERS.computeIfAbsent(clazz.getName(), - k -> new ConcurrentSkipListMap<>(String.CASE_INSENSITIVE_ORDER)) - .computeIfAbsent(methodName, k -> computeSetter(clazz, methodName)) - .orElse(null); + var perClass = perClassCache(SETTERS, clazz.getName()); + var cached = perClass.get(methodName); + if (cached == null) { + cached = computeSetter(clazz, methodName); + perClass.putIfAbsent(methodName, cached); + } + return cached.orElse(null); + } + + /** + * Возвращает (создавая при первом обращении) пер-классовый кэш по имени класса. + *

+ * Используется {@code get()}-first вместо {@code computeIfAbsent} с захватывающей лямбдой: + * на попадании в кэш (горячий путь) лямбда не аллоцируется. {@code ConcurrentHashMap.computeIfAbsent} + * не встраивается JIT-ом, из-за чего захватывающая лямбда-функция отображения перестаёт + * скаляризоваться escape-анализом и аллоцируется на каждый вызов. + * + * @param cache Двухуровневый кэш «имя класса → (имя свойства → значение)». + * @param className Имя класса. + * @param Тип кэшируемого значения. + * @return Пер-классовый кэш свойств. + */ + private static Map perClassCache(Map> cache, String className) { + var perClass = cache.get(className); + if (perClass == null) { + perClass = cache.computeIfAbsent(className, k -> new ConcurrentHashMap<>()); + } + return perClass; } private Optional computeSetter(Class clazz, String methodName) { From 61dbe300381a5cbc39ba4dc8d7f924a0e88fcf7b Mon Sep 17 00:00:00 2001 From: Nikita Fedkin Date: Wed, 15 Jul 2026 21:00:46 +0200 Subject: [PATCH 2/2] fix(reader): drop Optional cache values, use sentinels (Sonar S2789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get()-first проверял значение кэша (Optional) на null — Sonar S2789 (Optional не должен быть null). Убираем Optional из значений кэшей METHODS/TYPES/SETTERS: храним сырые Method/Type/SetterDescriptor, а отрицательный результат — через сентинелы (в ConcurrentHashMap нельзя хранить null). Перф opt-A сохранён: CI-поиск ~1% CPU, аллокации 5078 МБ/чтение (без изменений), лямбды кэша не аллоцируются. Заодно упрощён perClassCache: лямбда k -> new ConcurrentHashMap<>() незахватывающая (синглтон), поэтому computeIfAbsent там безопасен по аллокациям — get()-first обёртка не нужна. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HLvkKQCwBjTHwuZ4EGBQK6 --- .../reader/common/TransformationUtils.java | 65 ++++++++++++------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/github/_1c_syntax/bsl/reader/common/TransformationUtils.java b/src/main/java/com/github/_1c_syntax/bsl/reader/common/TransformationUtils.java index 0acbeeabd..530b5ef92 100644 --- a/src/main/java/com/github/_1c_syntax/bsl/reader/common/TransformationUtils.java +++ b/src/main/java/com/github/_1c_syntax/bsl/reader/common/TransformationUtils.java @@ -34,7 +34,6 @@ import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; /** @@ -44,9 +43,17 @@ @Slf4j public class TransformationUtils { - private static final Map>> METHODS = new ConcurrentHashMap<>(); - private static final Map>> TYPES = new ConcurrentHashMap<>(); - private static final Map>> SETTERS = new ConcurrentHashMap<>(); + private static final Map> METHODS = new ConcurrentHashMap<>(); + private static final Map> TYPES = new ConcurrentHashMap<>(); + private static final Map> SETTERS = new ConcurrentHashMap<>(); + + // Сентинелы отрицательного результата. В ConcurrentHashMap нельзя хранить null, но + // отсутствие метода/типа/сеттера тоже нужно кэшировать (иначе повторный дорогой резолв + // на каждый промах). Отличаются по ссылочной идентичности от любого настоящего значения. + private static final Method ABSENT_METHOD = absentMethod(); + private static final Type ABSENT_TYPE = void.class; + private static final SetterDescriptor ABSENT_SETTER = new SetterDescriptor(ABSENT_METHOD, false, null); + private static final String BUILD_METHOD_NAME = "build"; private static final String BUILDER_METHOD_NAME = "builder"; private static final String TO_BUILDER_METHOD_NAME = "toBuilder"; @@ -99,7 +106,7 @@ public Type fieldType(Object source, String methodName) { cached = computeFieldType(source, methodName); perClass.putIfAbsent(methodName, cached); } - return cached.orElse(null); + return cached == ABSENT_TYPE ? null : cached; } /** @@ -180,10 +187,11 @@ private Method getMethod(Class clazz, String methodName) { if (cached == null) { cached = Arrays.stream(clazz.getDeclaredMethods()) .filter(m -> methodName.equalsIgnoreCase(m.getName())) - .findFirst(); + .findFirst() + .orElse(ABSENT_METHOD); perClass.putIfAbsent(methodName, cached); } - return cached.orElse(null); + return cached == ABSENT_METHOD ? null : cached; } @Nullable @@ -194,16 +202,17 @@ private SetterDescriptor getSetter(Class clazz, String methodName) { cached = computeSetter(clazz, methodName); perClass.putIfAbsent(methodName, cached); } - return cached.orElse(null); + return cached == ABSENT_SETTER ? null : cached; } /** * Возвращает (создавая при первом обращении) пер-классовый кэш по имени класса. *

- * Используется {@code get()}-first вместо {@code computeIfAbsent} с захватывающей лямбдой: - * на попадании в кэш (горячий путь) лямбда не аллоцируется. {@code ConcurrentHashMap.computeIfAbsent} - * не встраивается JIT-ом, из-за чего захватывающая лямбда-функция отображения перестаёт - * скаляризоваться escape-анализом и аллоцируется на каждый вызов. + * Лямбда {@code k -> new ConcurrentHashMap<>()} незахватывающая, поэтому компилируется в + * синглтон и не аллоцируется на вызов; {@code computeIfAbsent} здесь безопасен по аллокациям. + * Захватывающие лямбды (резолв метода/типа/сеттера) вынесены на {@code get()}-first в вызывающих + * методах — иначе {@code ConcurrentHashMap.computeIfAbsent} (не встраивается JIT-ом) не даёт + * escape-анализу их скаляризовать. * * @param cache Двухуровневый кэш «имя класса → (имя свойства → значение)». * @param className Имя класса. @@ -211,21 +220,17 @@ private SetterDescriptor getSetter(Class clazz, String methodName) { * @return Пер-классовый кэш свойств. */ private static Map perClassCache(Map> cache, String className) { - var perClass = cache.get(className); - if (perClass == null) { - perClass = cache.computeIfAbsent(className, k -> new ConcurrentHashMap<>()); - } - return perClass; + return cache.computeIfAbsent(className, k -> new ConcurrentHashMap<>()); } - private Optional computeSetter(Class clazz, String methodName) { + private SetterDescriptor computeSetter(Class clazz, String methodName) { var setter = getMethod(clazz, methodName); if (setter == null) { - return Optional.empty(); + return ABSENT_SETTER; } var parameterized = setter.getGenericParameterTypes()[0] instanceof ParameterizedType; var singularAdder = parameterized ? getMethod(clazz, "add" + methodName) : null; - return Optional.of(new SetterDescriptor(setter, parameterized, singularAdder)); + return new SetterDescriptor(setter, parameterized, singularAdder); } /** @@ -260,7 +265,7 @@ private Method target(Object value) { } } - private static Optional computeFieldType(Object source, String methodName) { + private static Type computeFieldType(Object source, String methodName) { var method = getMethod(source.getClass(), methodName); if (method != null) { var fieldClass = method.getGenericParameterTypes()[0]; @@ -272,8 +277,22 @@ private static Optional computeFieldType(Object source, String methodName) fieldClass = type; } } - return Optional.of(fieldClass); + return fieldClass; + } + return ABSENT_TYPE; + } + + private static Method absentMethod() { + try { + return TransformationUtils.class.getDeclaredMethod("absentMethodTarget"); + } catch (NoSuchMethodException e) { + throw new ExceptionInInitializerError(e); } - return Optional.empty(); + } + + @SuppressWarnings("unused") + private static void absentMethodTarget() { + // Целевой метод-маркер для ABSENT_METHOD; никогда не вызывается — нужен лишь как + // ненулевой уникальный экземпляр Method для сентинела отрицательного результата кэша. } }