Данная статья рассчитана на тех, кто только начинает постигать основы языка 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)
Andrey_Solomatin
12.10.2023 15:24+8Как сломать HashSet в Java?
Ничего не сломанно, просто неправильно используется структура данных.
Зато теперь на собесах бедете уверенно отвечать про устройство хэшмепы.quorcs Автор
12.10.2023 15:24-1Структура не используется неправильно. Посыл скорее в том, что при использовании mutable объектов могут возникать подобные ситуации, и к ним нужно быть внимательнее.
KivApple
12.10.2023 15:24+5Неправильно. Нарушается контракт HashSet. Элемент после изменения хеша будет лежать на неправильной позиции. И это полностью ломает хеш-таблицу как структуру. Например, вы изменили элемент. А потом вставили элемент равный новому значению. Теперь у вас в хештаблице два одинаковых элемента, но один из них просто не на своём месте (и поэтому метод вставки его не заметил). Затем вы вставляете в хештаблицу ещё элементы, в ней кончается место и случается перехеширование. Что произойдёт в этот момент? Может быть сработает какой-нибудь assert и у вас случится исключение в месте вообще не связанном с исходной ошибкой. Может быть один элемент тихо потеряется. Может быть у вас теперь поиск начнет выдавать другой элемент, не тот что прежде. И держу пари это никак не регламентировано стандартом. Самое настоящее undefined behavior.
У вас после модификации ключа хештаблица превращается в бомбу, которая может взорваться (бросить недокументированное исключение, произвольно изменить видимое количество элементов) при вызове теоретически любого метода после изменения значения (даже если вы не пытаетесь обратиться к самому бракованному элементу). Их поведение больше не соответствует документации.
Andrey_Solomatin
12.10.2023 15:24+1Я бы сказал, что поведение будет соответствовать документации. Но не будет соответствовать ожиданиям. Вроде не с чего там кидать исключения. При изменении размера будет пере-хеширование и объект встанет на своё место, если есть дубликаты, то они уберутся.
При вставке одинакового объекта создастся копия если нет коллизии хэшей.
При итерации после такой вставки вернётся два одинаковых объекта. Собственно это у автора и произошло.
При поиске скорее не найдётся если нет коллизии хэшей.
MadMaxLab
12.10.2023 15:24+5Ждем цикл статей где будет на примерах раскрыта тема того, что некоторые переменные метода передаются по ссылке, а некоторые по значению. :)
aleksandy
12.10.2023 15:24В java все аргументы метода передаются по значению.
Hidden text
Надо только не забывать, что для объектных типов значением является указатель.
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);
quorcs Автор
12.10.2023 15:24-3Я понимаю, о чем вы. Но в данном случае вариант с переопределением equals и hashCode не рассматривался, поскольку разбирается случай именно с модификацией объекта, который уже существует в коллекции.
Viacheslav01
12.10.2023 15:24+4Какой смысл разбирать случай который исходно не имел шансов работать? Без всех потрашков необходимых для работы хештаблиц, нет никакого смысла расказывать об опасности изменяемых типов!
Опять же без их реализации не не понтяно, в чем коварность изменяемых типов.
Andrey_Solomatin
12.10.2023 15:24Так этот код норм. Эти объекты не равны ни по hash ни по equals.
Изменяемые объекты можно использовать в хэшмапах, если hash и equals продолжают возвращать те же значения. Не спрашивайте меня когда это может быть полезно.Viacheslav01
12.10.2023 15:24Да все верно )
Но автора волновало это "два одинаковых по своему наполнению объекта", а тут без переопределения этих методов никуда.
Как в том анектоде: "В крайнем случае 10 лет и два тонеля"
Actaeon
12.10.2023 15:24Тоже мне новости 197затертого года "А вы знаете товарищи , что если в фортране передать константу в процедуру и там её переписать , то она потом поменяет значение ?? "
quorcs Автор
12.10.2023 15:24-4Данная тема может быть очевидна для вас, как для человека с опытом, но новичкам может быть полезно.
aamonster
12.10.2023 15:24О, в Фортране это было ещё фееричнее! В отладчике (MS Fortran) наводишь мышку на число 3 – а он показывает тултип со значением 5.
Nialpe
12.10.2023 15:24вот вам идея для следующей статьи начального уровня - какое будет поведения у HashSet'a объектов класса classA, если в классе classА:
1. Не переопределять ни equals(), ни hashCode().
2. Переопределить только equals().
3. Переопределить только hashCode().
4. Переопределить и equals(), и hashCode().
siarheiblr
12.10.2023 15:24+6Следующую статью посветите вопросу почему
new Boolean(true) == Boolean.TRUE
это false
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.
Folko85
12.10.2023 15:24+2Ну это очень просто, прям очевидно. Это даже до простого уровня не дотягивает. Лучше б написал статью "Как я вошёл в IT после курсов, не зная элементарных вещей".
bugy
12.10.2023 15:24Что самое забавное, это то что на интервью многие кандидаты (любой уровень) говорят, что генерировать хешкод по всем полям обьекта, например через автогенерацию ИДЕ или ломбок, это прям хорошая идея.
И большая часть из них, даже намеки про мутабельные поля не понимает.
Так что, к сожалению, автор не одинок. Надо запретить ломбок и автогенерацию! (Сарказм)
Kelbon
12.10.2023 15:24-2эх, если бы только в языках придумали защиту... const...
Andrey_Solomatin
12.10.2023 15:24+1В Джаву Record завезли. В Питоне tuple и dataclass.
maybessss
12.10.2023 15:24-2А как рекорд нас спасёт? По прежнему можно значения поля объекта поменять же
Fancryer
12.10.2023 15:24Поля у рекордов финальные.
maybessss
12.10.2023 15:24-1Что, правда что-ли?))
А у вас весь проект на рекордах сразу стал и все остальные типы иммутабельные стали?))Andrey_Solomatin
12.10.2023 15:24Что, правда что-ли?))
Правда.
А у вас весь проект на рекордах сразу стал и все остальные типы иммутабельные стали?))
Не понял о каком проекте вы говорите.
MzMz
Эту ситуацию спрашивают на собеседованиях на Junior позицию, так как все описано в любой базовой книге по Java.
quorcs Автор
Естественно. Поэтому у статьи уровень сложности простой, и она не нацелена на "прожженных" джавистов.