Данная статья рассчитана на тех, кто только начинает постигать основы языка 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 - это уже другая история.