CPU-профиль работающего сервиса отдаёт 72% одному методу JDK — определите причину
Сервис заказов упирается в ядро CPU на ~200 req/s. Вы подключаетесь семплирующим профилировщиком к живой JVM на 30 секунд в режиме CPU и получаете плоский профиль и горячий стек ниже.
Ограничения:
- сервис только валидирует и сохраняет заказы; время GC ниже 2%, машина не свопится
- JVM полностью прогрета — профиль снят через час после старта
- считайте артефакт полным; второго снимка не будет
$ ./profiler.sh -e cpu -d 30 -f cpu.txt 8123
--- Execution profile ---
Total samples: 29841 (CPU 29720, non-CPU 121)
ns percent samples method
------ ------- ------- ------------------------------------------
21.4 s 71.7 % 21391 java.util.regex.Pattern.compile
3.1 s 10.4 % 3101 java.util.regex.Pattern$Curly.match
1.9 s 6.4 % 1902 java.lang.String.substring
--- Hot stack (21391 samples, 71.7 %)
[ 0] java.util.regex.Pattern.compile
[ 1] java.util.regex.Pattern.<init>
[ 2] java.util.regex.Pattern.compile
[ 3] com.acme.order.SkuValidator.isValid
[ 4] com.acme.order.OrderService.validate
[ 5] com.acme.order.OrderController.create
Определите причину.
Pattern.compile доминирует в профиле и стоит прямо под SkuValidator.isValid, значит регулярка компилируется на каждый запрос: код зовёт Pattern.compile либо String.matches/split прямо в пути запроса. Компиляция разбирает шаблон в дерево узлов и стоит куда дороже сопоставления готовым. Поднимите его в static final Pattern, а на запрос зовите только matcher(input).
- ✗Читать горячий кадр JDK как проблему JDK, вместо того чтобы смотреть на кадр приложения прямо под ним
- ✗Путать стоимость компиляции
Patternсо стоимостью сопоставления по готовому шаблону - ✗Считать
String.matchesиString.splitдешёвыми — каждый из них компилирует новыйPatternпри каждом вызове
- →Почему
String.matches(regex)внутри цикла обходится так же дорого, как вызовPattern.compileтам же? - →Что покажет профиль по календарному времени, чего не видно в профиле по CPU?
Решение
Профиль читают снизу вверх по стеку: горячий метод JDK сам по себе никогда не «виноват», виноват кадр приложения прямо под ним. Здесь это SkuValidator.isValid, а под ним — Pattern.compile. Значит, шаблон строится заново на каждый запрос.
// ❌ было: новый Pattern на каждый вызов
final class SkuValidator {
boolean isValid(String sku) {
return Pattern.compile("^[A-Z]{3}-\\d{4}-[A-Z0-9]{2}$")
.matcher(sku)
.matches();
}
}
// ✅ стало: компиляция один раз, на запрос — только сопоставление
final class SkuValidator {
private static final Pattern SKU =
Pattern.compile("^[A-Z]{3}-\\d{4}-[A-Z0-9]{2}$");
boolean isValid(String sku) {
return SKU.matcher(sku).matches();
}
}
Почему это так дорого. Pattern.compile разбирает выражение и строит дерево узлов — это на порядки дороже, чем прогнать по готовому шаблону Matcher. Pattern неизменяем и потокобезопасен, поэтому один экземпляр в статическом поле спокойно используют все потоки; небезопасен только Matcher, и его создают на вызов.
Та же ловушка прячется за String.matches, String.split и String.replaceAll: каждый из них внутри вызывает Pattern.compile. В горячем пути их заменяют на предкомпилированный Pattern (а для простого разделителя — на indexOf).
Проверка. После правки снимите профиль повторно: Pattern.compile должен исчезнуть из верхушки, а профиль — стать плоским.