说明
Set集合,类似于一个罐子,程序依次把多个对象“丢进”Set集合,而Set集合通常不能记住元素的添加顺序。**Set集合与Collection基本相同,没有提供额外的方法。实际上Set就是Collection,只是行为略有不同(**Set不允许包含重复元素)。
Set集合不允许包含相同的元素,如果试图把两个相同的元素加入同一个Set集合中,则添加操作失败,add()方法返回false,且新元素不会被加入。
上面介绍的是Set集合的通用知识,因此完全适合后面介绍的HashSet、TreeSet和EnumSet三个实现类,只是三个实现类各有特色。
Set判断两个对象相同是使用equals方法,若返回true,则Set集合不会接受这两个对象。
下面是使用普通Set的示例程序:
public class TestSet
{
public static void main(String[] args)
{
Set books = new HashSet();
//添加一个字符串对象
books.add(new String("Struts2权威指南"));
//再次添加一个字符串对象,
//因为两个字符串对象通过equals方法比较相等,所以添加失败,返回false
boolean result = books.add(new String("Struts2权威指南"));
System.out.println(result);
//下面输出看到集合只有一个元素
System.out.println(books);
}
}
HashSet类
HashSet是Set接口的典型实现,大多数时候使用Set集合就是使用这个集合类。
HashSet按Hash算法来存储集合中的元素,因此具有很好地存取和查找性能。
HashSet具有以下特点:
不能保证元素的排列顺序,顺序可能与添加顺序不同,顺序也有可能发生变化。
HashSet不是同步的,如果多个线程访问一个HashSet,假设有两个或者两个以
上的线程同时修改了HashSet集合时,必须通过代码来保证其同步。集合元素可以是null。
当向HashSet集合中存入一个元素时,HashSet会调用该对象的hashCode()方法来得到该对象的hashCode值,然后根据hashCode值来决定该对象在HashSet中的存储位置。如果有两个元素通过equals()方法比较返回true,但它们的hashCode()方法返回值不相等,HashSet将会把它们存储在不同的位置,依然可以添加成功。
也就是说,HashSet集合判断两个元素相等的标准就是两个对象通过equals()方法比较相等,并且两个对象的hashCode()返回值也相等。
//类A的equals()方法总返回true,但没有重写其hashCode()方法
class A
{
public boolean equals(Object obj)
{
return true;
}
}
//类B的hashCode()方法总是返回1,但没有重写其equals()方法
class B
{
public int hashCode()
{
return 1;
}
}
//类C的hashCode()方法总是返回2,且重写其equals()方法总是返回true
class C
{
public int hashCode()
{
return 2;
}
public boolean equals(Object obj)
{
return true;
}
}
public class HashSetTest {
public static void main(String[] args) {
HashSet books = new HashSet();
//分别向books集合中添加两个A对象,两个B对象,两个C对象
books.add(new A());
books.add(new A());
books.add(new B());
books.add(new B());
books.add(new C());
books.add(new C());
System.out.println(books);
}
}
程序中向books集合中分别添加了两个A对象,两个B对象,两个C对象,其中C对象重写了equals()方法总是返回true,hashCode()总是返回2,这将导致HashSet把两个C对象当成同一个对象。运行结果:
[B@1,B@1,C@2,A@15db9742,A@6d06d69c]
从上面程序可以看出,即使两个A对象通过equals()返回true,但HashSet依然把它们当成不同的两个对象;即使两个B对象的hashCode()返回相同值1,但HashSet仍把它们当成两个对象。
这里有个注意点:当把一个对象放入HashSet时,如果需要重写该对应类的equals()方法,则也应该重写其hashCode()方法。
规则是:如果两个对象通过equals()方法比较返回true,这两个对象的hashCode值也应该相同。
如果两个对象通过equals()方法比较返回true,但这两个对象的hashCode()方法返回不同的hashCode时,这将导致HashSet会把这两个对象存在不同的位置,从而使两个对象都可以添加成功,这就与Set的规则冲突了。
如果两个对象的hashCode()方法返回hashCode值相同,但它们通过equals()方法返回false时将更麻烦:因为两个对象的hashCode值相同,HashSet试图向把它们保存在同一位置,但又不行(否则将只剩下一个对象),所以实际上会在这个位置使用链式结构来保存多个对象;而HashSet访问集合元素时也是根据元素的hashCode值来快速定位的,如果HashSet中两个以上的元素具有相同的HashCode值,将会导致性能下降。
重写hashCode()方法的规则:
在程序运行过程中,同一个对象多次调用hashCode()值应该返回相等的值
当两个程序通过equals()方法比较返回true时,这个两个对象的hashCode()方法也应该返回相等的值。
对象中用作equals()方法比较的实例变量,都应该用于计算hashCode值。
下面给出重写hashCode()方法的一般步骤。
把对象内每个有意义的实例变量(即每个参数equals()方法比较的标准的实例变量)计算出一个int类型hashCode值。
用第一步计算出来的多个hashCode值合计算出一个hashCoe值返回。为了避免直接相加偶然相等,可以通过为各实例变量的hashCode值乘以任意一个质数后相加。
例如:
return f1.hashCode() + (int)f2 * 31;
如果向HashSet中添加一个可变对象后,后面程序修改了该可变对象的实例变量,则可能导致它与集合中的其他元素相同(即两个对象通过equals()方法比较返回true,两个对象的hashCode值也相等),这就有可能导致HashSet中包含两个相同的对象。
下面程序演示了这种情况:
class R
{
int count;
public R(int count)
{
this.count = count;
}
public String toString()
{
return "R(count属性:" + count + ")";
}
public boolean equals(Object obj)
{
if (obj instanceof R)
{
R r = (R)obj;
if (r.count == this.count)
{
return true;
}
}
return false;
}
public int hashCode()
{
return this.count;
}
}
public class TestHashSet2
{
public static void main(String[] args)
{
HashSet hs = new HashSet();
hs.add(new R(5));
hs.add(new R(-3));
hs.add(new R(9));
hs.add(new R(-2));
//打印TreeSet集合,集合元素是有序排列的
System.out.println(hs);
//取出第一个元素
Iterator it = hs.iterator();
R first = (R)it.next();
//为第一个元素的count属性赋值
first.count = -3;
//再次输出count将看到TreeSet里的元素处于无序状态
System.out.println(hs);
hs.remove(new R(-3));
System.out.println(hs);
//输出false
System.out.println("hs是否包含count为-3的R对象?" + hs.contains(new R(-3)));
//输出false
System.out.println("hs是否包含count为5的R对象?" + hs.contains(new R(5)));
}
}
上面代码更改了Set集合中第一个R对象的count值,导致R对象与集合中的其他对象实例变量相同。
HashSet集合中的第1个元素和第2个元素完全相同,这表明两个元素已经重复,此时HashSet会比较混乱,当试图删除count为-3的R对象时先计算HashCode再使用equals()比较——此时只有第二个元素符合,所以第二个元素被删除。第一个元素保存在-2的位置,但使用equals()方法拿它和count为-2的R对象进行比较又返回false——这将导致HashSet不可能准确访问该元素。
LinkedHashSet类
HashSet还有一个子类LinkedHashSet,LinkedHashSet集合也是根据元素的hashCode值来决定元素的存储位置,但它同时使用链表维护元素的次序,这样使得元素看起来是以插入的顺序保存的。也就是说当遍历LinkedHashSet集合里的元素时,LinkedHashSet将会按元素的添加顺序来访问集合里的元素。
LinkedHashSet需要维护元素的插入顺序,因此性能略低于HashSet的性能,但在迭代访问Set里的全部元素时将由很好的性能,因为它以链表来维护内部的次序。
public class TestLinkedHashSet
{
public static void main(String[] args)
{
LinkedHashSet books = new LinkedHashSet();
books.add("Struts2权威指南");
books.add("轻量级J2EE企业应用实战");
//删除 Struts2权威指南
books.remove("Struts2权威指南");
//重新添加 Struts2权威指南
books.add("Struts2权威指南");
System.out.println(books);
}
}
输出结果:
[轻量级J2EE企业应用实战,Struts2权威指南]
上面的集合里,元素顺序正好与添加顺序一致。
TreeSet类
TreeSet是SortedSet接口唯一的实现类,正如SortSet名字所暗示的,TreeSet集合可以确保元素处于排序状态。与HashSet集合相比,TreeSet还提供了几个额外的方法。如first()、last()、headSet()等。
看起来方法很多,其实它们很简单:因为TreeSet中的元素是有序的,所以增加了访问第一个、前一个、后一个、最后一个元素的方法,并提供了三个从TreeSet中截取子TreeSet的方法。
Comparator comparator():返回当前set使用的Comparator,或者返回null,表示以自然方式排序。
Object first():返回集合中的第一个元素。
Object last():返回集合中的最后一个元素。
Object lower(Object e):返回集合中位于指定元素之前的一个元素。
Object higher(Object e):返回集合中位于指定元素之后的一个元素。
SortedSet subSet(from Element,to Element):返回此set的子集合,范围从from Element到to Element(闭包)。
SortedSet headSet(toElement):返回此Set的子集,由小于toElement的元素组成。
SortedSet tailSet(fromElement):返回此Set的子集,由大于等于fromElement的元素组成。
下面测试的TreeSet的通用用法:
public class TreeSetTest {
public static void main(String[] args) {
TreeSet nums = new TreeSet();
//向TreeSet中添加四个Integer对象
nums.add(5);
nums.add(2);
nums.add(10);
nums.add(-9);
//输出集合元素,看到集合元素处于排序状态
System.out.println(nums);
//输出集合中的第一个元素和最后一个元素
System.out.println(nums.first());//输出9
System.out.println(nums.last());//输出10
//返回小于4的子集,不包含4
System.out.println(nums.headSet(4));//输出[-9,2]
//返回大于5的子集,不包含5
System.out.println(nums.tailSet(5));//输出[5,10]
//返回大于等于-3,小于4的自己 [-3,4)
System.out.println(nums.subSet(-3, 4));//输出[2]
}
}
输出结果:
[-9, 2, 5, 10]
-9
10
[-9, 2]
[5, 10]
[2]
可以看出TreeSet是根据元素的实际大小顺序进行排序的。
TreeSet采用红黑树数据结构来存储集合元素,并且支持两种排序方法:自然排序和定制排序。
自然排序
TreeSet会调用集合元素的compareTo(Object obj)方法来比较元素之间的大小关系,然后将进而元素按升序排列,这种方式就是自然排序。
Java提供了Comparable接口,该接口里定义了一个compareTo(Object obj)方法,该方法返回一个整数值,实现该接口的类必须实现该方法。当一个对象调用该方法与另一个对象进行比较时,例如obj1.compareTo(obj2),返回0表示两个对象相等,返回正整数表明obj1 > obj2;负整数,obj1 < obj2。
Java的一些常用类已经实现了Comparable接口,并提供了比较大小的标准。
BigDecimal、BigInteger即所有数值型对于的包装类
Character:按字符的UNICODE值进行比较
Boolean:true大于false对于的包装类
String:按字符的UNICODE
Date、Time:后面的时间、日期比前面的大
当把一个对象添加到TreeSet时,该对象必须实现Comparable接口,否则程序抛出异常。
class Err()
public class TreeSetErrorTest
{
public static void main(String[] args)
{
TreeSet ts = new TreeSet();
//向TreeSet集合中添加两个Err对象
ts.add(new Err());
ts.add(new Err());//①
}
}
上面程序添加第一个对象时候没有,TreeSet里没有任何元素,所以不会出现问题;当加入第二个Err对象时,TreeSet会调用该对象的compareTo(Object obj)方法与集合中的其他元素进行比较——如果没有实现Comparable接口,则会引发ClassException异常。
注意:当我们没有实现Comparable接口,即使只添加一个元素,当我们取出该元素时,依然会引发ClassCastException异常。
还有一点需要指出:大部分类在实现compareTo(Object obj)方法时,都需要将被比较对象强制类型转换成相同类型,因为只有相同类型的两个实例才会比较大小,当试图把一个对象加入到TreeSet集合时,TreeSet会调用该对象的compareTo(Object obj)。也就是说,向TreeSet中添加的应该是同一个类的对象,否则也会引发ClassCastException。
public class TreeSetErrorTest2
{
public static void main(String[] args)
{
TreeSet ts = new TreeSet();
//向TreeSet中添加两个对象
ts.add(new String("Java"));
ts.add(new Date());///①
}
}
上述代码在①处引发异常,原因是当添加第二个元素时,无法使用compareTo()来比较大小。
如果向TreeSet中添加的对象时程序员自定义类的对象,则可以向TreeSet中添加多种类型的对象,前提是用户自定义实现了compareTo()接口,且实现compareTo(Object obj)方法没有进行强制类型抓换。
总结:如果希望TreeSet能够正常工作,TreeSet只能添加同一种类型的对象。
当把一个对象加入到TreeSet中石化,TreeSet调用该对象的compareTo(Object obj)方法与容器中的其他对象比较大小,然后根据红黑树找到它的存储位置。如果通过compareTo()方法比较相等,新对象将无法添加到TreeSet集合中。
对于TreeSet而言,判断两个对象是否相等的标准就是:两个对象通过compareTo()方法0。
class Z implements Comparable
{
int age;
public Z(int age)
{
this.age = age;
}
//重写equals方法,总是返回true
public boolean equals(Object obj)
{
return true;
}
//重写了ComparabTo(Object obj)方法,总是返回1
public int compareTo(Object obj)
{
return 1;//总是不相等
}
public class TreeSetTest2
{
public static void main(String[] args)
{
TreeSet set = new TreeSet();
Z z1 = new Z(6);
set.add(z1);
//第二次添加同一个对象,输出true,表明添加成功
System.out.println(set.add(z1));
//下面输出set集合,将看到有两个元素
System.out.println(set);
//修改第一个set集合的第一个元素的age变量
((Z)(set.first())).age = 9;
//输出set集合的最后一个元素的age变量
System.out.println((Z)(set.last()).age);
}
}
}
程序①代码把同一个对象再次添加到TreeSet中,因为z1对象的compareTo(Object obj)方法总是返回1,虽然它的equals()方法总是返回true,但TreeSet认为z1对象和它自己也不相等,因此TreeSet可以添加两个z1对象。
虽然集合中保存的是两个元素(集合的元素实际上是引用,但习惯上把被引用的对象称为集合元素),实际上是同一个元素。所以当修改TreeSet集合里的第一个元素的age变量后,该TreeSet集合里的最后一个元素的age变量也变了。
由此应该注意一个问题,当需要把一个对象放入TreeSet中时,重写该对象对于类的equals()方法时,应该保证该方法与compareTo(Object obj)方法有一致的结果。
其规则是:如果两个对象通过equals()方法比较返回true,那么通过compareTo()方法比较应该返回。
果向TreeSet中添加一个可变对象后,并且后面程序修改了该可变对象的实例变量,这将导致它与其他对象的大小顺序发生变化,但TreeSet不会调整他们的顺序,甚至导致TreeSet中保存的这两个对象通过compareTo(Object obj)方法比较返回0。
下面程序演示了这种情况。
class X implements Comparable
{
int count;
public X(int count)
{
this.count = count;
}
public String toString()
{
return "X[count:" + count + "]";
}
//重写equals()方法,根据count来判断
public boolean equals(Object obj)
{
if(this == obj)
{
return true;
}
if(obj != null && obj.getClass() == X.class)
{
X r = (X)obj;
return r.count == this.count;
}
return false;
}
//重写compareTo()方法
public int compareTo(Object obj)
{
X r = (X)obj;
return count > r.count ? 1 : count == r.count ? 0 : -1;
}
}
public class TreeSetTest3 {
public static void main(String[] args) {
TreeSet ts = new TreeSet();
ts.add(new X(5));
ts.add(new X(-3));
ts.add(new X(9));
ts.add(new X(-2));
//打印TreeSet集合,集合元素是有序排列的
System.out.println(ts);//①
//取出第一个元素
X first = (X)ts.first();
//对第一个元素的count赋值
first.count = 20;
//取出最后一个元素
X last = (X)ts.last();
//对最后一个元素赋值-2,与第二个元素相同
last.count = -2;
//再次看到输出TreeSet元素处于无序状态,且有重复元素
System.out.println(ts);
//删除实例变量被改变的元素,删除失败
System.out.println(ts.remove(new X(-2)));//false
System.out.println(ts.remove(new X(20)));//false
System.out.println(ts);//②
//删除实例变量没有被改变的元素,成功
System.out.println(ts.remove(new X(5)));//③
System.out.println(ts);
//接下来可以删除被修改的元素
System.out.println(ts.remove(new X(-2)));//true
System.out.println(ts.remove(new X(-2)));//true
System.out.println(ts);
}
}
程序执行①处代码输出时,将看到集合处于无序状态,而且集合中包含了重复元素。
一旦改变了TreeSet集合里元素的实例变量,当再试图删除该对象时候,TreeSet也会删除失败(甚至集合中原有的、实例变量没被修改但与修改后的元素相等的元素也无法删除),比如②处,删除-2和20都删除失败。
但是当程序执行了③处的代码后,TreeSet会集合中的元素重新索引(并不是重新排序)。接下来就可以删除所有元素了。
与HashSet类似的是,如果TreeSet中包含了可变对象,当那些可变对象的实例变量被修改时,TreeSet在处理这些对象时变得十分复杂,而且容易出错。因此为了让程序更贱健壮,推荐不要修改防卫HashSet和TreeSet集合元素的关键实例变量。
定制排序
如果需要实现定制排序,则可以使用Comparator接口的帮助,该接口包含一个int compare(T o1,T o2)方法,该方法用于计较两个数的大小,如果返回正整数,则表明o1>o2;如果返回0,则表明o1=o2;如果返回负整数,则表明o1<02。
如果需要实现定制排序,则需要在创建TreeSet集合对象时,并提供一个Comparator对象与该TreeSet集合关联,由该Comparator对象负责集合元素的排序逻辑。
class M
{
int age;
public M(int age)
{
this.age = age;
}
public String toString(){
return "M对象(age:" + age + ")";
}
}
public class TestTreeSet3
{
public static void main(String[] args)
{
TreeSet ts = new TreeSet(new Comparator()
{
@Override
public int compare(Object o1, Object o2) {
// TODO Auto-generated method stub
M m1 = (M)o1;
M m2 = (M)o2;
if (m1.age > m2.age)
{
return -1;
}
else if (m1.age == m2.age)
{
return 0;
}
else
{
return 1;
}
}
});
ts.add(new M(5));
ts.add(new M(-3));
ts.add(new M(9));
System.out.println(ts);
}
}
上面程序中创建了一个Comparator接口的匿名内部类对象,该对象负责ts集合的排序。所以当我们把M对象添加到ts集合中时,无须M类实现Comparable接口,因为此时TreeSet无需通过M对象来比较大小,而是由与TreeSet关联的Comparator对象来负责集合元素的排序。
输出结果:
[M对象(age:9), M对象(age:5), M对象(age:-3)]
注意:不可以向TreeSet中添加类型不同的对象,否则会引起ClassCastException异常。
EnumSet类
EnumSet是一个专门为枚举类设计的集合类,EnumSet中所有元素都必须是指定枚举类的枚举值,该枚举类在创建EnumSet时显式或隐式地指定。EnumSet的集合元素也是有序的,EnumSet以枚举值在Enum类内部的定义顺序来决定集合元素的顺序。
EnumSet在内部以位向量的形式存储,这种存储方式非常紧凑高效。因此EnumSet对象占用内存很小,而且运行效率很好。尤其是在进行批量操作时候,如果其参数是EnumSet集合,则该批量操作的速度也非常快。
EnumSet集合不允许加入null元素,如果试图插入null元素,将会报NullPointerException异常。如果只是判断是否包含null元素或者试图删除null元素,则不会报错,只是删除时返回false。
EnumSet类并没有暴露任何构造器来创建该类的实例,程序应该通过它的类方法来创建EnumSet对象。
比如:
EnumSet allOf(Class elementType)
EnumSet complement(EnumSet s)
EnumSet copyOf(Collection c)
下面示范了如何创建EnumSet实例:
enum Season
{
SPRING,SUMMER,FALL,WINTER
}
public class EnumSetTest {
public static void main(String[] args) {
//创建一个EnumSet,集合元素就是Season枚举类的全部枚举值
EnumSet es1 = EnumSet.allOf(Season.class);
System.out.println(es1);//输出[SPRING,SUMMER,FALL,WINTER]
//创建一个EnumSet空集合,指定其集合元素是Season类的枚举类
EnumSet es2 = EnumSet.noneOf(Season.class);
System.out.println(es2);//输出[]
//主动添加两个元素
System.out.println(es2.add(Season.SPRING));
System.out.println(es2.add(Season.WINTER));
System.out.println(es2);//输出[SPRING,WINTER]
//以指定枚举值创建EnumSet集合
EnumSet es3 = EnumSet.of(Season.SUMMER ,
Season.WINTER);
System.out.println(es3);//输出[SUMMER,WINTER]
EnumSet es4 = EnumSet.range(Season.SUMMER,
Season.WINTER);
System.out.println(es4);//输出[SUMMER,FALL,WINTER]
//新创建的EnumSet集合元素和es4集合元素有相同的类型
//es5 + es4的集合元素 = Season枚举类的全部枚举值
EnumSet es5 = EnumSet.complementOf(es4);
System.out.println(es4);//输出[SPRING]
}
}
输出结果:
[SPRING, SUMMER, FALL, WINTER]
[]
[SPRING, WINTER]
[SUMMER, WINTER]
[SUMMER, FALL, WINTER]
[SPRING]
上面程序代表了EnumSet的常规用法。除此之外,还可以复制另一个Enumset集合中的所有元素,或者赋值另一个Collection集合中的所有元素。当复制Collection集合中的所有元素时,集合中的元素必须是同一个枚举类的枚举值。
enum Season
{
SPRING,SUMMER,FALL,WINTER
}
public class TestEnumSet
{
public static void main(String[] args)
{
Collection c = new HashSet();
c.clear();
c.add(Season.FALL);
c.add(Season.SPRING);
//复制Collection集合中所有元素来创建EnumSet集合
EnumSet enumSet = EnumSet.copyOf(c);
//输出[SPRING,FALL]
System.out.println(enumSet);
c.add("Struts2权威指南");
c.add("轻量级J2EE企业应用实战");
//下面代码出现异常:因为c集合里的元素不是全部都为枚举值
//enumSet = EnumSet.copyOf(c);
}
}
输出结果:
[SPRING, FALL]
程序执行②处代码时,c集合中的元素不全是枚举值所以抛出ClassCastException异常。
各Set实现类的性能分析
HashSet和TreeSet是Set的两个典型实现。
HashSet的性能总是要优于TreeSet,特别是最常用的添加、查询等操作。因为TreeSet需要使用额外的红黑树算法来维护集合元素的次序。只有当需要一个排序的Set时,才需要TreeSet。
HashSet还有一个子类,LinkedHashSet,对于普通的插入、删除操作,LinkedHashSet比HashSet要略慢一点,这是由于维护链表所带来的额外开销,但是由于有链表,遍历LinkedHashSet会很快。
EnumSet是所有类中性能最好的,但它只能保存枚举类的枚举值。
Set的三个实现类HashSet、TreeSet、EnumSet都是线程不安全的。如果有多个线程同时访问Set集合,并且有超过一个线程修改了Set集合,必须手动保证该Set集合的同步性。
通常可以通过Collections的工具类synchronizedSortedSet方法来“包装”该Set集合。此操作最好在创建时进行,以防止对Set集合的意外非同步访问。
例如:
SortedSet s = Collections.synchronizedSorted(new
TreeSet(...));