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

55d67a9d8a33d780906aaeffc76085c4

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

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

Для примера возьмем класс Dog, который имеет два поля — имя и возраст:

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 +
			"}";
  }
}

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

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

Set 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 переменные бывают двух типов: примитивные и ссылочные. Если с примитивами все понятно — они сразу хранят значение внутри себя, то с ссылочными переменными все несколько сложнее — они лишь хранят ссылку на область памяти, где расположено значение. Соотвественно, меняя поля внутри объекта мы не изменяем ссылку на него.

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

© Habrahabr.ru