Как поймать NullPointerException на этапе компиляции без тормозов в сборке

31 авг 2026
4,095
362
70
1 неделя

Каждый Java-разработчик хоть раз разбирал падение сервиса посреди ночи из-за банального NullPointerException. Ошибка коварная: локальные тесты прошли, код-ревью завершился успешно, но на проде прилетел неожиданный null от стороннего API или базы данных, и процесс упал.

В Kotlin или Swift защита от разыменования нулевых ссылок встроена прямо в систему типов. Компилятор просто отказывается собирать небезопасный код. В классической Java с этим сложнее. Долгое время приходилось выбирать между слепой верой в ручные проверки и тяжелыми статическими анализаторами вроде Checker Framework.

Проблема академических анализаторов в их медлительности. Когда сборка проекта на CI вместо двух минут начинает занимать пятнадцать, разработчики быстро отключают строгие проверки. Инженеры Uber столкнулись с этой дилеммой, когда их монорепозиторий разросся до миллионов строк кода. Ответом на проблему стал NullAway — инструмент, проверяющий типы на null-safety во время компиляции почти без замедления сборки.

В чем идея и как это работает

NullAway оформлен как плагин для Error Prone, надстройки над стандартным компилятором javac от Google. Инструмент выполняет быстрый локальный анализ типов прямо во время стандартного шага компиляции.

Логика проверок опирается на предположение: весь код по умолчанию считается ненулевым. Любой метод, параметр или поле класса изначально трактуются как @NonNull. Если переменная действительно может содержать null, вы обязаны явно пометить ее аннотацией @Nullable.

Реклама

Как только NullAway видит @Nullable, он берет переменную на строгий контроль. Если вы попытаетесь вызвать метод у такого объекта без предварительной проверки через if (x != null), компилятор выдаст предупреждение или сразу остановит сборку с ошибкой.

Разбираем работу на живом коде

Посмотрим на классический ошибочный фрагмент:

static void log(Object x) {
    System.out.println(x.toString());
}

static void foo() {
    log(null);
}

Здесь метод foo передает явный null в метод log, который ожидает полноценный объект. При сборке NullAway сразу подсвечивает несоответствие контрактов:

warning: [NullAway] passing @Nullable parameter 'null' where @NonNull is required
    log(null);
        ^

Чтобы исправить предупреждение, помечаем аргумент метода аннотацией @Nullable:

static void log(@Nullable Object x) {
    System.out.println(x.toString());
}

Теперь компилятор указывает на следующую опасность: мы пытаемся вызвать toString() у объекта, который потенциально равен null:

warning: [NullAway] dereferenced expression 'x' is @Nullable
    System.out.println(x.toString());
                        ^

Добавляем стандартную проверку:

static void log(@Nullable Object x) {
    if (x != null) {
        System.out.println(x.toString());
    }
}

После этого код собирается без нареканий. Анализатор проверил поток выполнения и убедился, что внутри блока if разыменование безопасно.

Инициализация полей и зоопарк аннотаций

NullAway следит не только за вызовами методов, но и за конструкторами. Если в классе объявлено ненулевое поле, а конструктор забывает присвоить ему значение, анализатор прервет билд. Это избавляет от ситуаций, когда объект существует в полуинициализированном состоянии.

Долгое время в экосистеме Java царил беспорядок с аннотациями: свои варианты предлагали JetBrains, FindBugs, AndroidX и Checker Framework. NullAway умеет считывать большинство существующих аннотаций, но авторы рекомендуют использовать стандарт JSpecify (org.jspecify.annotations.Nullable). Это открытая инициатива компаний вроде Google, JetBrains и Uber по стандартизации разметки nullability в Java.

Если в проекте используется библиотека Guava свежих версий (начиная с 33.4.1), ее пакеты уже размечены через JSpecify @NullMarked. NullAway подхватывает эти правила автоматически.

Почему он работает быстрее других

Главный секрет скорости NullAway заключается в осознанном компромиссе. Полные верификаторы вроде Checker Framework пытаются математически доказать безопасность программы, анализируя сложные графы вызовов сквозь все слои приложения. Это требует гигантских вычислительных ресурсов.

NullAway выполняет локальный внутриметодный анализ. Он проверяет контракты методов и локальные переменные, не пытаясь просчитать состояние всей памяти приложения. По замерам авторов, оверхед по времени сборки составляет менее 10%.

Инструмент не обещает отловить 100% гипотетических NPE во всех закоулках программы, но уверенно закрывает абсолютное большинство практических ошибок, которые обычно долетают до продакшена.

Настройка в Gradle

Для запуска проекта потребуются JDK 17 или новее и Error Prone версии от 2.36.0. В стандартном build.gradle подключение выглядит так:

plugins { id "net.ltgt.errorprone" version "4.1.0" } dependencies { errorprone "com.uber.nullaway:nullaway:0.12.3" errorprone "com.google.errorprone:error_prone_core:2.36.0" api "org.jspecify:jspecify:1.0.0" } import net.ltgt.gradle.errorprone.CheckSeverity tasks.withType(JavaCompile) { options.errorprone { check("NullAway", CheckSeverity.ERROR) option("NullAway:AnnotatedPackages", "com.mycompany.app") } if (name.toLowerCase().contains("test")) { options.errorprone { disable("NullAway") } } }

Параметр NullAway:AnnotatedPackages сообщает анализатору, какие пакеты уже размечены аннотациями. Это упрощает поэтапное внедрение: сторонние библиотеки без разметки не будут валить сборку ложными срабатываниями.

Если в каком-то месте вы уверены в безопасности логики (например, объект заполняется через рефлексию), проверку можно временно заглушить с помощью аннотации @SuppressWarnings("NullAway").

Подводные камни

При интеграции анализатора в реальный проект неизбежно всплывают нюансы кодогенерации и сторонних инструментов:

  • Кодогенерация в Dagger и AutoValue. Генераторы часто помещают классы в те же пакеты, что и ваш код. Чтобы компилятор не спотыкался о чужие сгенерированные файлы, исключайте эти директории через опцию options.errorprone.excludedPaths.
  • Lombok. Эта библиотека перестраивает AST прямо в памяти, что традиционно конфликтует с Error Prone. NullAway базово понимает @Data и @Builder, но требует свежий Lombok (1.18.34+) или флаг lombok.addLombokGeneratedAnnotation = true в конфигурации lombok.config.
  • Сборка под Android. Плагин gradle-errorprone-plugin версий 3.0+ прекратил прямую поддержку Android-проектов. Придется либо настраивать флаги компилятора вручную, либо оставаться на ветке плагина 2.x.
  • Обобщенные типы в сложных иерархиях. Анализ дженериков иногда выдает ложные предупреждения. Для тонкой настройки авторы развивают отдельный режим поддержки JSpecify, который включается через дополнительные флаги конфигурации.

Кому стоит попробовать

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

Начинать внедрение проще всего с мягкого режима: подключить плагин с уровнем предупреждений WARN, выбрать один небольшой изолированный пакет и расставить @Nullable. Как только пакет очистится от предупреждений, его можно переводить в режим ERROR на CI и переходить к следующему модулю. Такой подход защитит кодовую базу от глупых аварий без остановки текущей продуктовой разработки.

🍪 Мы используем файлы cookie и сервис аналитики Яндекс.Метрика, чтобы сайт работал лучше. Продолжая пользоваться devtrends.ru, вы соглашаетесь с обработкой данных согласно Политике конфиденциальности.