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

Года полтора назад работал над одним проектом. Развернут он был на AWS. Сервис на Java работал с БД DynamoDB (NoSQL). После очередной фичи в логах стали появляться ошибки, что приложение не может сохранить данные в БД из-за дублирования ключа. Я был в замешательстве, поскольку в коде для работы с данными использовал HashSet, и был уверен, что дубликаты не могут существовать. Оказалось - еще как могут.

Мы вполне законно можем закинуть объект в HashSet и дальше использовать и модифицировать его в коде. Из-за чего наш объект, находящийся в HashSet, тоже меняется. Это связано с тем, что в HashSet хранятся ссылки на объекты, а не сами объекты. Таким образом, два разных с точки зрения места в памяти объекта могут быть совершенно одинаковыми по своему наполнению. И в этом случае переопределение методов equals() и hashCode() не даст нужного эффекта, поскольку они срабатывают при добавлении нового элемента в коллекцию.

Для примера возьмем класс Dog, который имеет два поля - имя и возраст со стандартной реализацией equals и hashCode из пакета commons-lang3:

public class Dog {
    private String name;
    private int age;

    public Dog(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "Dog{" +
                "name='" + name + '\'' +
                ", age=" + age +
                "}";
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;

        if (o == null || getClass() != o.getClass()) return false;

        Dog dog = (Dog) o;

        return new EqualsBuilder().append(age, dog.age).append(name, dog.name).isEquals();
    }

    @Override
    public int hashCode() {
        return new HashCodeBuilder(17, 37).append(name).append(age).toHashCode();
    }
}

А теперь добавим в HashSet два объекта класса Dog - fluffy и jimmy:

Dog fluffy = new Dog("Fluffy", 3);
Dog jimmy = new Dog("Jimmy", 4);

Set<Dog> dogs = new HashSet<>();
dogs.add(fluffy);
dogs.add(jimmy);

Если мы выведем в консоль содержимое множества dogs, то получим строку вида:

[Dog{name='Fluffy', age=3}, Dog{name='Jimmy', age=4}]

А теперь объекту jimmy изменим значения полей name и age на 'Fluffy' и '3'. И тут самое интересное: в нашем множестве окажутся два одинаковых по своему наполнению объекта.

[Dog{name='Fluffy', age=3}, Dog{name='Fluffy', age=3}]

Почему так произошло? Здесь стоит вспомнить о типах переменных.

В Java переменные бывают двух типов: примитивные и ссылочные. Если с примитивами все понятно - они сразу хранят значение внутри себя, то с ссылочными переменными все несколько сложнее - они лишь хранят ссылку на область памяти, где расположено значение. Соответственно, меняя поля внутри объекта, мы не изменяем ссылку на него.

Поэтому важно запомнить, что любая коллекция фактически хранит в себе ссылку на объект, поэтому при работе с ними следует соблюдать осторожность.

p.s. при хранении внутри коллекции immutable объектов данной ситуации не случится, но разбор mutable/immutable - это уже другая история.

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


  1. MzMz
    12.10.2023 15:24
    +13

    Эту ситуацию спрашивают на собеседованиях на Junior позицию, так как все описано в любой базовой книге по Java.


    1. quorcs Автор
      12.10.2023 15:24
      -4

      Естественно. Поэтому у статьи уровень сложности простой, и она не нацелена на "прожженных" джавистов.


  1. Andrey_Solomatin
    12.10.2023 15:24
    +8

    Как сломать HashSet в Java?

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

    Зато теперь на собесах бедете уверенно отвечать про устройство хэшмепы.


    1. quorcs Автор
      12.10.2023 15:24
      -1

      Структура не используется неправильно. Посыл скорее в том, что при использовании mutable объектов могут возникать подобные ситуации, и к ним нужно быть внимательнее.


      1. KivApple
        12.10.2023 15:24
        +5

        Неправильно. Нарушается контракт HashSet. Элемент после изменения хеша будет лежать на неправильной позиции. И это полностью ломает хеш-таблицу как структуру. Например, вы изменили элемент. А потом вставили элемент равный новому значению. Теперь у вас в хештаблице два одинаковых элемента, но один из них просто не на своём месте (и поэтому метод вставки его не заметил). Затем вы вставляете в хештаблицу ещё элементы, в ней кончается место и случается перехеширование. Что произойдёт в этот момент? Может быть сработает какой-нибудь assert и у вас случится исключение в месте вообще не связанном с исходной ошибкой. Может быть один элемент тихо потеряется. Может быть у вас теперь поиск начнет выдавать другой элемент, не тот что прежде. И держу пари это никак не регламентировано стандартом. Самое настоящее undefined behavior.

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


        1. Andrey_Solomatin
          12.10.2023 15:24
          +1

          Я бы сказал, что поведение будет соответствовать документации. Но не будет соответствовать ожиданиям. Вроде не с чего там кидать исключения. При изменении размера будет пере-хеширование и объект встанет на своё место, если есть дубликаты, то они уберутся.

          При вставке одинакового объекта создастся копия если нет коллизии хэшей.
          При итерации после такой вставки вернётся два одинаковых объекта. Собственно это у автора и произошло.
          При поиске скорее не найдётся если нет коллизии хэшей.


  1. vital_pavlenko
    12.10.2023 15:24
    +2

    Кажется, хаб "Изучение языков" не про языки программирования ????


  1. MadMaxLab
    12.10.2023 15:24
    +5

    Ждем цикл статей где будет на примерах раскрыта тема того, что некоторые переменные метода передаются по ссылке, а некоторые по значению. :)


    1. aleksandy
      12.10.2023 15:24

      В java все аргументы метода передаются по значению.

      Hidden text

      Надо только не забывать, что для объектных типов значением является указатель.


  1. Viacheslav01
    12.10.2023 15:24
    +3

    Вы удивитесь, но в данном коде даже не надо ничего менять в уже добавленных в мапу объектах, достаточно сделать вот так:

    public class Dog {
      private String name;
      private int age;
    	
      public Dog(String name, int age) {
        this.name = name;
      	this.age = age;
      }
    	
      public String getName() {
        return name;
      }
    	
      public int getAge() {
        return age;
      }
    	
      @Override
      public String toString() {
        return "Dog{" +
                "name='" + name + '\'' +
    			", age=" + age +
    			"}";
      }
    }
    
    Dog jimmyOne = new Dog("Jimmy", 4);
    Dog jimmyTwo = new Dog("Jimmy", 4);
    
    Set<Dog> dogs = new HashSet<>();
    dogs.add(jimmyOne);
    dogs.add(jimmyTwo);


    1. quorcs Автор
      12.10.2023 15:24
      -3

      Я понимаю, о чем вы. Но в данном случае вариант с переопределением equals и hashCode не рассматривался, поскольку разбирается случай именно с модификацией объекта, который уже существует в коллекции.


      1. Viacheslav01
        12.10.2023 15:24
        +4

        Какой смысл разбирать случай который исходно не имел шансов работать? Без всех потрашков необходимых для работы хештаблиц, нет никакого смысла расказывать об опасности изменяемых типов!

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


    1. Andrey_Solomatin
      12.10.2023 15:24

      Так этот код норм. Эти объекты не равны ни по hash ни по equals.

      Изменяемые объекты можно использовать в хэшмапах, если hash и equals продолжают возвращать те же значения. Не спрашивайте меня когда это может быть полезно.


      1. Viacheslav01
        12.10.2023 15:24

        Да все верно )

        Но автора волновало это "два одинаковых по своему наполнению объекта", а тут без переопределения этих методов никуда.

        Как в том анектоде: "В крайнем случае 10 лет и два тонеля"


  1. Actaeon
    12.10.2023 15:24

    Тоже мне новости 197затертого года "А вы знаете товарищи , что если в фортране передать константу в процедуру и там её переписать , то она потом поменяет значение ?? "


    1. quorcs Автор
      12.10.2023 15:24
      -4

      Данная тема может быть очевидна для вас, как для человека с опытом, но новичкам может быть полезно.


    1. aamonster
      12.10.2023 15:24

      О, в Фортране это было ещё фееричнее! В отладчике (MS Fortran) наводишь мышку на число 3 – а он показывает тултип со значением 5.


  1. Nialpe
    12.10.2023 15:24

    вот вам идея для следующей статьи начального уровня - какое будет поведения у HashSet'a объектов класса classA, если в классе classА:

    1. Не переопределять ни equals(), ни hashCode().
    2. Переопределить только equals().
    3. Переопределить только hashCode().
    4. Переопределить и equals(), и hashCode().


  1. siarheiblr
    12.10.2023 15:24
    +6

    Следующую статью посветите вопросу почему

    new Boolean(true) == Boolean.TRUE

    это false


    1. aamonster
      12.10.2023 15:24

      Вы ещё классику вспомните:

      #define true false // счастливой отладки!


      1. aleksandy
        12.10.2023 15:24

        В классике, емнип, в define ещё рандом был добавлен.


        1. aamonster
          12.10.2023 15:24

          Не, это чуть позже, чтобы не было слишком просто)


  1. ColdPhoenix
    12.10.2023 15:24
    +3

    Смотрим документацию на Set:

    Note: Great care must be exercised if mutable objects are used as set elements. The behavior of a set is not specified if the value of an object is changed in a manner that affects equals comparisons while the object is an element in the set.


  1. Folko85
    12.10.2023 15:24
    +2

    Ну это очень просто, прям очевидно. Это даже до простого уровня не дотягивает. Лучше б написал статью "Как я вошёл в IT после курсов, не зная элементарных вещей".


  1. bugy
    12.10.2023 15:24

    Что самое забавное, это то что на интервью многие кандидаты (любой уровень) говорят, что генерировать хешкод по всем полям обьекта, например через автогенерацию ИДЕ или ломбок, это прям хорошая идея.

    И большая часть из них, даже намеки про мутабельные поля не понимает.

    Так что, к сожалению, автор не одинок. Надо запретить ломбок и автогенерацию! (Сарказм)


    1. Kelbon
      12.10.2023 15:24
      -2

      эх, если бы только в языках придумали защиту... const...


      1. Andrey_Solomatin
        12.10.2023 15:24
        +1

        В Джаву Record завезли. В Питоне tuple и dataclass.


        1. maybessss
          12.10.2023 15:24
          -2

          А как рекорд нас спасёт? По прежнему можно значения поля объекта поменять же


          1. Fancryer
            12.10.2023 15:24

            Поля у рекордов финальные.


            1. maybessss
              12.10.2023 15:24
              -1

              Что, правда что-ли?))
              А у вас весь проект на рекордах сразу стал и все остальные типы иммутабельные стали?))


              1. Andrey_Solomatin
                12.10.2023 15:24

                Что, правда что-ли?))

                Правда.

                А у вас весь проект на рекордах сразу стал и все остальные типы иммутабельные стали?))

                Не понял о каком проекте вы говорите.