在Java开发中,使用Stream API处理集合数据已经非常普遍,其中distinct方法常被用来去除重复元素。但当处理的是我们自己定义的业务对象时,很多开发者会发现distinct似乎完全不起作用,所有对象都被保留了下来。这背后的原因和Object类中hashCode与equals的设计机制密切相关,只有正确理解并改写这两个方法,才能让自定义对象的去重真正生效。
一、为什么自定义对象直接distinct会失败
Stream的distinct操作在并行流中会使用ConcurrentHashMap来记录已出现的元素,在顺序流中也会借助相似的状态记录方式,其核心判定逻辑就是先比较对象的hashCode,再调用equals方法。如果我们的类没有重写这两个方法,那么使用的是Object类的默认实现:hashCode返回对象内存地址相关的整数,equals比较的是引用是否指向同一个实例。
对于new出来的两个内容相同但内存地址不同的自定义对象,默认equals会返回false,hashCode也几乎必然不同,因此distinct会认为它们是不同的元素,全部保留。这就导致了业务上明明是重复数据,却在去重步骤中被漏掉。理解这一点是解决问题的前提,我们不能指望distinct自动识别业务层面的相等性。
二、正确重写hashCode与equals的原则
Java规范明确要求:如果两个对象根据equals方法是相等的,那么调用这两个对象的hashCode必须产生相同的整数结果;反之不强制要求,但良好的实践应尽量减少碰撞。因此去重时必须同时重写二者,且参与equals比较的字段,也要参与hashCode的计算。
以一个简单的订单类Order为例,假设业务主键是订单号orderId和用户ID userId,只要这两项相同就视为同一订单。我们可以用IDE自动生成或手动编写,核心是使用java.util.Objects工具类来保证空安全。下面给出一个典型实现:
import java.util.Objects;
public class Order {
private String orderId;
private String userId;
private double amount;
public Order(String orderId, String userId, double amount) {
this.orderId = orderId;
this.userId = userId;
this.amount = amount;
}
// 只使用业务主键字段参与判等
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Order order = (Order) o;
return Objects.equals(orderId, order.orderId) &&
Objects.equals(userId, order.userId);
}
// 基于相同字段生成哈希值
@Override
public int hashCode() {
return Objects.hash(orderId, userId);
}
// 省略getter和setter
}
上述代码中,amount字段没有被纳入equals和hashCode,说明金额变化不影响订单唯一性,这符合多数业务场景。需要注意的是,如果参与计算的字段本身是可变对象且在放入流之后发生了修改,会导致后续hashCode不一致,因此应尽量使用不可变字段或确保在去重前完成赋值。
三、结合Stream.distinct完成去重实战
当Order类正确重写方法后,就可以利用Stream流水线轻松去重。常见做法是把集合转为流,调用distinct,再收集回List。以下代码演示了如何从包含重复订单的列表中提取唯一订单:
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class DistinctDemo {
public static void main(String[] args) {
List<Order> orders = new ArrayList<>();
orders.add(new Order("A001", "U1", 100.0));
orders.add(new Order("A001", "U1", 200.0)); // 业务重复
orders.add(new Order("A002", "U1", 150.0));
List<Order> uniqueOrders = orders.stream()
.distinct()
.collect(Collectors.toList());
System.out.println("去重后数量: " + uniqueOrders.size());
// 输出应为2,A001重复项被合并
}
}
运行上述示例,distinct会依据我们重写的hashCode将元素分桶,对同桶内对象调用equals,从而识别出第一个和第二个Order业务相等,只保留首次出现的元素。这种写法比手动维护Set并写循环判断要简洁很多,也更容易并行化。
在真实项目中,如果去重逻辑仅使用一次,且不想污染实体类,也可以不重写类方法,而是在流中利用Collectors.toMap或自定义TreeSet配合Comparator实现,但那样会失去distinct语义的直观性。对于全局通用的领域对象,重写hashCode与equals仍是最规范的做法。
四、使用Record类型简化实现
从Java 16开始,record类型自动根据声明组件生成equals和hashCode,且语义正是基于所有组件的值比较。如果我们的去重维度恰好等于record的全部字段,那么直接定义为record就能零样板代码获得正确行为。
public record OrderKey(String orderId, String userId) {}
// 使用时提取key去重,或若整个订单就是record且字段合适:
public record OrderRecord(String orderId, String userId, double amount) {}
// OrderRecord自带正确的hashCode与equals,可直接distinct
不过record中所有字段都参与判等,如果像前面例子那样希望忽略amount,就不适合直接用record做实体,而可以用record作为临时的去重键。总之,掌握hashCode与equals的约定,才能灵活选用传统类或record,让Stream.distinct在自定义对象上精准生效。
五、常见误区与排查建议
一个典型错误是只重写equals却忘记重写hashCode,结果在HashSet或distinct中依然出现重复。另一个误区是在equals里使用 getClass() 比较类型,导致继承场景下子类无法与父类判等,若业务需要可改用 instanceof 判断。排查时可在对象上打印hashCode,确认相同业务值的实例是否产出相同哈希。
此外,当对象被当作Map的键或放入HashSet后,不要修改参与hashCode的字段,否则会破坏集合内部结构,造成找不到元素或重复存储。把这些细节控制好,Stream.distinct就能成为处理自定义对象去重时的可靠工具。
Stream_distincthashCodeequals修改时间:2026-08-09 21:39:37