原问题
问题由来
起因是在公司做游戏项目的时候遇到一个需求需要实现:
服务器要维护一个帮派成员(Member)的集合,这个集合要按照
在线状态
、成员等级
和名称
依次有序排列。
由于每时每刻都有玩家在不断上下线,成员的经验值也在不断的改变。因此这个集合的有序状态在不断变化,起初的想法是维护一个有序的列表 List<Member>
用 Collections.sort()
加上一个定时结构,比如每十分钟对列表排序一次。然后客户端请求集合的时候返回这个列表即可。
这种方法的优点是排序是显而易见的:定时执行、开销相对小。类似的代码如下:
class Member {
// 帮派成员类
}
// 维护有序列表
List<Member> orderedMembers = Collections.synchronizedList(new ArrayList<Member>());
// 在服务器的刷帧循环中定时排序
@Override
public void onFrameUpdated(long currentTimeMillis) {
// 十分钟排序一次
if (lastSorting + 10 * 60 * 1000 < currentTimeMillis) {
// 排序 ....
sort(orderedMembers);
}
}
实时响应的排行榜
我想让这个集合能实时响应玩家自身的变化,而不是定时扫描一次,于是一开始我是这么尝试的,继承 TreeSet
然后实现一个重新排序的回调 ReorderCallback
,在任何玩家经验值改变或是上线下线的时候调用回调的方法 reorder()
来使集合保持有序,代码如下
interface ReorderCallback<T> {
// 回调方法,在任何影响排序的状态值改变的时候被调用
void reorder(T element);
}
// 自定义了一个 Set 用于监测成员类的变化
class AlwaysOrderedSet<T> extends TreeSet<T> implements ReorderCallback<T> {
// 实现回调
@Override
public void reorder(T element) {
remove(element);
add(element);
}
}
然后在需要的地方回调:
// 帮派成员类
class Member {
ReorderCallback rCallback;
// 玩家上线
@Override
public void onOnline() {
if (rCallback != null) {
rCallback.reorder(this);
}
}
// 玩家下线
@Override
public void onOffline() {
if (rCallback != null) {
rCallback.reorder(this);
}
}
}
然而,每次玩家状态改变后调用 reorder()
并不能保持原集合的有序,反而会重复添加 member 对象。因为 TreeSet
无法追踪元素的变化,就像以下的演示一样:
public class Sorter {
public static void main(String[] args) {
class Student implements Comparable<Student> {
int id;
String name;
int age;
Student(int id, String name, int age) {
this.id = id;
this.name = name;
this.age = age;
}
@Override
public String toString() {
return String.format("id=%d, name=%s, age=%d", id, name, age);
}
@Override
public int compareTo(Student o) {
return o.age - this.age;
}
}
Set<Student> alwaysOrdered = new TreeSet<>();
Student a = new Student(1, "Amy", 50);
Student b = new Student(2, "Bob", 30);
Student c = new Student(3, "Chris", 40);
alwaysOrdered.add(a);
alwaysOrdered.add(b);
alwaysOrdered.add(c);
System.out.println("-- before --");
alwaysOrdered.forEach(System.out::println);
// 集合元素发生改变
b.age = 100;
System.out.println("-- after --");
alwaysOrdered.forEach(System.out::println);
// 这里就相当于回调,简单的 remove 再 add
alwaysOrdered.remove(b);
alwaysOrdered.add(b);
System.out.println("-- after remove and add --");
alwaysOrdered.forEach(System.out::println);
}
}
上面的代码运行结果如下:
-- before --
id=1, name=Amy, age=50
id=3, name=Chris, age=40
id=2, name=Bob, age=30
-- after --
id=1, name=Amy, age=50
id=3, name=Chris, age=40
id=2, name=Bob, age=100
-- after remove and add --
id=2, name=Bob, age=100
id=1, name=Amy, age=50
id=3, name=Chris, age=40
id=2, name=Bob, age=100
可以看出,我试图改变 Bob
的年龄,然后先后调用集合的 remove
和 add
却并不是我预想的那样,remove
实际是返回了 false
,因此 b
根本没有从集合中移除。
百思不得其解后我试图阅读 Set
的源代码,发现了一些线索:
* <p>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 <tt>equals</tt> comparisons while the
* object is an element in the set. A special case of this prohibition is
* that it is not permissible for a set to contain itself as an element.
意思是:
注:如果将可变对象用作 set 元素,那么必须极其小心。如果对象是 set 中某个元素,以一种影响
equals
比较的方式改变对象的值,那么 set 的行为就是不确定的。此项禁止的一个特殊情况是不允许某个 set 包含其自身作为元素。
而 TreeSet
的源码文档里是这么写的
* <p>Note that the ordering maintained by a set (whether or not an explicit
* comparator is provided) must be <i>consistent with equals</i> if it is to
* correctly implement the {@code Set} interface. (See {@code Comparable}
* or {@code Comparator} for a precise definition of <i>consistent with
* equals</i>.) This is so because the {@code Set} interface is defined in
* terms of the {@code equals} operation, but a {@code TreeSet} instance
* performs all element comparisons using its {@code compareTo} (or
* {@code compare}) method, so two elements that are deemed equal by this method
* are, from the standpoint of the set, equal. The behavior of a set
* <i>is</i> well-defined even if its ordering is inconsistent with equals; it
* just fails to obey the general contract of the {@code Set} interface.
大致意思是:
注意,如果要正确实现
Set
接口,则 set 维护的顺序(无论是否提供了显式比较器)必须与equals
一致。(关于与equals
一致 的精确定义,请参阅Comparable
或Comparator
。)这是因为Set
接口是按照equals
操作定义的,但TreeSet
实例使用它的compareTo
(或compare
)方法对所有元素进行比较,因此从 set 的观点来看,此方法认为相等的两个元素就是相等的。即使 set 的顺序与equals
不一致,其行为也是定义良好的;它只是违背了Set
接口的常规协定。
总的来说就是,Set
的定义是不包含重复元素的集合,这个不重复的特性由它维护的元素的 equals()
方法来保证,这个好理解,关键是下面。TreeSet
并不完全遵守这个定义,它的不重复性由元素的 compareTo()
方法来保证。
因此,即便元素重写了 equals()
方法和 hashCode()
方法,当元素 E
的值发生改变时,对于 TreeSet
,对 contains(E)
的调用始终返回 false
,因此对 remove(E)
的调用也始终返回 false
,因为 TreeSet
只依据 compareTo
方法来判断两个元素是否相等并进行排序。
所以对于之前的 Student
示例,解决方法就是让 Student
实现 Comparable
并重写 compareTo
方法,用该方法代替 equals()
方法去定义对象是否相等的逻辑,同时加上排序:
class Student implements Comparable<Student> {
int id;
String name;
int age;
Student(int id, String name, int age) {
this.id = id;
this.name = name;
this.age = age;
}
@Override
public String toString() {
return String.format("id=%d, name=%s, age=%d", id, name, age);
}
// compareTo 方法有两个作用:1、代替 equals,2、compareTo
@Override
public int compareTo(Student o) {
// 这里的判断非常关键,代替了 equals() 方法
if (id == o.id) {
return 0;
}
// 这里再进行原始的 compareTo 判断
if (age == o.age) {
if (name.equals(o.name)) {
return id - o.id;
}
return name.compareTo(o.name);
}
return o.age - age;
}
}
改写后的 Student
的代码运行结果如下:
id=1, name=Amy, age=50
id=3, name=Chris, age=40
id=2, name=Bob, age=30
-- after --
id=1, name=Amy, age=50
id=3, name=Chris, age=100
id=2, name=Bob, age=30
-- after remove and add --
id=3, name=Chris, age=100
id=1, name=Amy, age=50
id=2, name=Bob, age=30