Kotlin Null-Safety vs ClassLoader

от автора

Недавно я проходил собеседование и одним из вопросов, стал такой загадочный экземпляр:
«А какое главное преимущество системы типов Kotlin перед Java»?

Честно говоря, выделить какое преимущество считалось главным, оказалось неразрешимой для меня задачей: Nothing, отсутсвие Wildcard и First-Class Functions вместо Java-костыля с Functional Interface (имеется ввиду 8я версия Java) не заняли первых мест в личном топе интервьюера, который мне предложили угадать.

Оказалось что главное в Kotlin — возможность обьявить Nullable Type и Null Safety подход (Замечу, что по моему опыту собственные или библиотечные Optional или Maybe решают эту проблему, и пишутся за 10 минут на Java 7. А еще есть аннотации Nullable, позволяющие проверять поля в сompile-time. Короче, есть много способов заставить делать Null проверки в plain Java. Ну да ладно).

Но речь пойдет не о странных вопросах, связанных со вкусовыми предпочтениями интервьюеров относительно синтаксического сахара.
Дело в том, что Null Safety в Kotlin можно сломать, притом не выходя из под его безопасного купола в суровый дикий мир Java и Null-Referrences.

Как?

Long story short: ClassLoader ведет себя интересным образом при попытке загрузить статические поля классов рекурсивно ссылающиеся на классы друг-друга.

Под катом примеры кода и подробное объяснение того, как он обманывает проверки на Nullable. Я искренне надеюсь что специфические знания Java/Kotlin для статьи не нужны — я объясню все детали на ходу, и уложу расследование в 10 минут.

Начнем.


Не буду тянуть кота за хвост. Вот код:

class ClassToLoad1() {     val classToLoad2 = ClassToLoad1.classToLoad2      //Creating single instance of object     companion object {         val classToLoad2 = ClassToLoad2()     } }  class ClassToLoad2() {     val classToLoad1 = ClassToLoad2.classToLoad1      companion object {         val classToLoad1 = ClassToLoad1()     } }   fun main() {     val check = ClassToLoad1()     val classToLoadRecurciveRef = check.classToLoad2.classToLoad1.classToLoad2     println(classToLoadRecurciveRef) //null      classToLoadRecurciveRef.classToLoad1 //Throws NPE }

Ни одной строчки кода на Java, ни одного предупреждения и NPE в результате выполнения.

Почему так?

Роковое стечение нескольких обстоятельств:
Рекурсивная ссылка, статическая инициализация, и не самый явный контракт поведения ClassLoader в JVM в таких случаяx.


//Рекурсивные ссылки

Давайте отсечем от нашего примера все, кроме рекурсивной ссылки классов на самих себя.

Базовый пример — это один класс, который хочет получить себя же в качестве экземпляра.

Зачем вообще так делать?

Самый распространенный кейс, где вы такую структуру встречали — это Linked List (который, кстати, тоже любят на собеседованиях).

Любая другая ссылочная структура данных, например Node в Tree, тоже будет ссылаться на себя саму. И если вы очень любите писать графы, вы возможно столкнетесь с таким кодом напрямую.

class SelfClassLoad(val s: SelfClassLoad) // warning: Constructor has non-null self reference parameter 

Ого, у нас есть предупреждение!
И оно есть по вполне понятной причине — вы не сможете использовать этот класс:

class SelfClassLoad(val s: SelfClassLoad) // warning: Constructor has non-null self reference parameter  fun main() {     SelfClassLoad(         SelfClassLoad(             SelfClassLoad(                 SelfClassLoad(                     SelfClassLoad(                     //No value passed for parameter 's'                     )                 )             )         )     ) }

Интересно будет так-же немного изменить пример:

class SelfClassLoad(){     val s = SelfClassLoad() }  fun main() {     SelfClassLoad() //Stackoverflow error } 

По понятным причинам, мы просто получим StackOverflow — бесконечная инициализация себя к добру не приводит.

Но будет ли у нас предупреждение, если мы попробуем создать рекурсивную связку из двух классов?

class SelfClassLoad1(val s2: SelfClassLoad2) class SelfClassLoad2(val s1: SelfClassLoad1) //No warnings

Такой кейс warning уже не выдает.

Issue на эту тему уже создан:

И это первая ступенька на пути к нашему NPE.


//Статическая инициализация

Что происходит в Kotlin когда вы создаете Companion Object?

Если вы пытались вызвать подобный код из Java — то вы знаете о том, что в вашем классе будет находиться статическая ссылка на объект-компаньон.

Если попробовать транслировать Kotlin в Java, получится что-то подобное:

public final class ClassToLoad1 {     @NotNull     private static final ClassToLoad2 classToLoad2;     @NotNull     public static final Companion Companion; //Here, it's "static"      @NotNull     public final ClassToLoad2 getClassToLoad2() {         return this.classToLoad2$1;     }      static {         Companion = new Companion(null);         classToLoad2 = new ClassToLoad2();      }      public static final class Companion {         @NotNull         public final ClassToLoad2 getClassToLoad2() {             return classToLoad2;         }          private Companion() {         }          public Companion(DefaultConstructorMarker $constructor_marker) {             this();         }     } } 

Опытные Java разработчики уже поняли к чему все идет, (если конечно не поняли с самого начала), а для широкой аудитории оставлю пояснение:

Ключевое слово static, делает ровно то, что оно гласит — объект становится статичным. А в мире JVM это значит, что этот объект находится в специальной области памяти — Permanent Generation Space.

Это означает 2 вещи:

1. Объект в static будет жить пока жива его JVM.
2. Он будет проинициализирован (положен в PermGen) 1 раз. (Да, да есть кейсы когда это не правда, это на данный момент Out of Scope)

Для тех, кто вспомнил про синглтоны:

Возможно вы сейчас подумали что-то вроде «А зачем тогда нужен сложный синглтон с Syncronized если static’a достаточно?».

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

class Singleton1{      public final static Singleton1 singleton = new Singleton1()  } //Warranty to be single time initialized, but will init on class init.
class Singleton2{     private static Singleton2 singleton;          public static void getSingleton(){ if(singleton == null)         singleton = new Singleton2()         return singleton;     } } //Late init but no warranty of single init in concurrent case.

Если у вас небольшой объект, который можно инициализировать сразу — то можно написать Singleton1.

Singleton2 можно написать если вы гарантируете вызов его метода get из одного потока всегда. В противном случае — он неверен, но это выходит за рамки обсуждения. Но вот неплохой пост на эту тему: https://habr.com/ru/post/27108/

Нас интересует второй нюанс. Инициализация объекта идет 1 раз, а мы тут создали 2 объекта с перекрестными ссылками друг на друга.

Как поведет себя JVM в таком случае?


//Classloader

Итак, мы пришли к нижнему уровню — загрузке класса. Когда мы первый раз пытаемся использовать класс — JVM загружает информацию о классе (в том числе статическую) в PermGen, а затем использует ее для создания экземпляров класса.

Что происходит в случае рекурсивной статической ссылки?

Примерно такая цепочка:

TryLoadClass1 -> LoadingClass1 -> FoundClass2InStatic -> TryLoadClass2 -> FoundClass1InStatic -> TryLoadClass1 -> AlreadyLoading, Null

И в результате мы получим Class1, в котором есть Class2, в котором находится ссылка на Null.

Почему оно так себя ведет, а не падает с Runtime Exception?
Мое предположение, и это только предположение — из-за обратной совместимости. Если мы исправим ClassLoader и дадим ему упасть в этом кейсе — у нас может повалиться огромное количество серверов просто из-за смены версии Java. Поэтому контракт на выдачу Null сохраняется на протяжении всех версий и останется там навсегда.

//Sidenote: Заранее думайте о контрактах, если хотите написать что-то обратно совместимое.


//Big Picture

Итак, вся цепочка событий:

  • Мы пишем код на Kotlin с циклической/рекурсивной ссылкой двух и и более объектов самих на себя в блоке с инициализацией. Анализатор — молчит.

  • Kotlin компилируется в java bytecode и переходит в чудесный мир JVM и Null.

  • Благодаря статической инициализации классы будут загружены 1 раз, и мы не получим StackOverflow в Runtime, как только до них дойдем.

  • ClassLoader попробует загрузить класс и благодаря старому, неявному контракту на возврат Null поставит null-refference в экземпляр Class2 при попытке обратно сослаться на Class1, который будет заблокирован собственной инициализацией, в контексте которой как раз и происходит инициализация Class2. Схемка:
    loading Class1 -> loading Class2 -> try load Class1 — result null

  • Бинго, мы провели анализатор и получили Null в контексте, где Kotlin его совершенно не ждет.

  • Наслаждаемся нашим NPE


//Критикуешь — предлагай

Ну, действительно. Критиковать все могут, а решение то какое?

Давайте для начала, посмотрим на пример решения такой проблемы в другом языке.
Я — мобильный разработчик, поэтому мне близок Swift:

//Won't compile enum RelationDirect{//Error about recursive type declaration shown     case Blocks(RelationBT) } enum RelationBT{     case Reffers(RelationRevese) } enum RelationRevese{     case BlockedBy(RelationDirect) }

Такой код в свифте просто не скомпилируется, вам нужно пометить его отдельным ключевым словом indirect:

//Compiles indirect enum RelationDirect{ //No errors     case Blocks(RelationBT) } ...

Но есть одна проблема — если мы просто возьмем решение из Swift — мы сломаем Backward Compatibility — при обновлении часть проектов перестанет собираться.

Нельзя просто взять и все сломать — это не JVM Way.

Я подумал некоторое время об этой проблеме (целых 45 минут), и возможно придумал решение.

Мы не можем сломать старый код, но можем сделать так, чтобы в старом коде возникли Warnings (подробнее о том, как может работать warning — в тикете), которые посоветуют добавить keyword, который пометит для компилятора Kotlin этот класс, как требующий проверки. Это является дополнением контракта, а не его изменением, поэтому не ломает совместимость (по крайней мере, на первый взгляд).

Примерно таким образом в Java 1.1 были добавлены final, и таким же образом работает val в Kotlin: Мы не заставляем компилятор бросать exception, если мы модифицируем любое поле. Но мы можем пометить поле, и попросить компилятор бросить exception, тогда и только тогда когда оно помечено. На мой вкус, это даже более элегантное решение, чем то, что используется в Swift.

В коде это будет выглядеть так:

Warning без keyword:

//Compiles with warning sealed class RelationDirect( val opposite: RelationReverseSealed ) { //Warning - add selfref key to enable checks for self-refference in initializers     object Blocks : RelationDirectSealed(RelationReverseSealed.IsBlockedBy) }  sealed class RelationReverse( val opposite: RelationDirectSealed ) {      object IsBlockedBy : RelationReverseSealed(RelationDirectSealed.Blocks) } 

Добавление keyword, теперь компилятор чекает иерархию и находит проблему:

//Won't compile sealed selfref class RelationDirect(         val opposite: RelationReverseSealed ) { //Error, initializer self reffrence detected     object Blocks : RelationDirectSealed(RelationReverseSealed.IsBlockedBy) }  sealed class RelationReverse(         val opposite: RelationDirectSealed ) {      object IsBlockedBy : RelationReverseSealed(RelationDirectSealed.Blocks) }

Но, я не претендую на идеальные знания Kotlin и Java, JVM и компиляторов и тут будет интересен комментарий от JetBrains. Было бы здорово, если бы вы дополнили эту статью. Или помогли поправить, если я где-то ошибся в объяснении.


Напоследок, я бы хотел сказать, что это не первый кейс в моей жизни, когда меня подводило излишнее доверие языку, тулингу и библиотекам. Singleton может быть не один, если вы забыли про один нюанс связанный с ClassLoader. ConcurrentHashMap.computeIfAbsent — может оказаться местом где заблокируются все треды вашего сервера, хоть он и считается потокобезопасным (в Java 8). И это реальные production кейсы, которые мне пришлось расследовать.

Поменьше доверяйте тулингу и embedded фичам, и всегда запускайте тесты.

А если у вас есть интересные кейсы из собственного опыта, когда какая-нибудь функциональность встроенная в библиотеку/язык отказывала при специфических обстоятельствах — было бы интересно увидеть эту историю в комментариях.


ссылка на оригинал статьи https://habr.com/ru/articles/561962/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *