Java中的Set集合

来源:互联网 发布:java记录日志的方式 编辑:程序博客网 时间:2024/06/06 00:07

Set集合,类似于一个罐子,程序依次把多个对象“丢进”Set集合,而Set集合通常不能记住元素的添加顺序Set集合与Collection基本相同,没有提供额外的方法。实际上Set就是Collection,只是行为略有不同(Set不允许包含重复元素)

Set集合不允许包含相同的元素,如果试图把两个相同的元素加入同一个Set集合中,则添加操作失败,add()方法返回false,且新元素不会被加入。

上面介绍的是Set集合的通用知识,因此完全适合后面介绍的HashSet、TreeSet和EnumSet三个实现类,只是三个实现类各有特色。

一、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()方法总是返回trueclass 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()方法的一般步骤

  1. 把对象内每个有意义的实例变量(即每个参数equals()方法比较的标准的实例变量)计算出一个int类型hashCode值。
  2. 用第一步计算出来的多个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(this == obj)            return true;        if(obj != null && obj.getClass() == R.class)        {            R r = (R)obj;            return this.count == r.count;        }        return false;    }    public int hashCode()    {         return this.count;     }}public class HashSetTest {    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));        //打印HashSet集合,集合元素没有重复        System.out.println(hs);        //取出第一个元素        Iterator it = hs.iterator();        R first = (R)it.next();        //为第一个元素的count实例变量赋值        first.count = -3;//①        //再次输出HashSet集合,集合元素有重复元素        System.out.println(hs);        //删除count为-3的对象        hs.remove(new R(-3));//②        //可以看到被删除一个R元素        System.out.println(hs);        System.out.println("hs是否包含count为-3的R对象?"                 + hs.contains(new R(-3)));//输出false        System.out.println("hs是否包含count为-2的R对象?"                + hs.contains(new R(-2)));//输出false    }}

上面代码更改了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里的全部元素时将由很好的性能,因为它以链表来维护内部的次序。

三、TreeSet类

TreeSet是SortedSet接口的实现类,正如SortSet名字所暗示的,TreeSet集合可以保元素处于排序状态。与HashSet集合相比,TreeSet还提供了几个额外的方法。如first()、last()、headSet()等。
看起来方法很多,其实它们很简单:因为TreeSet中的元素是有序的,所以增加了访问第一个、前一个、后一个、最后一个元素的方法,并提供了三个从TreeSet中截取子TreeSet的方法。

下面测试的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]    }}

可以看出TreeSet是根据元素的实际大小顺序进行排序的
TreeSet采用红黑树数据结构来存储集合元素,并且支持两种排序方法:自然排序和定制排序

1.自然排序

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集合元素的关键实例变量。

2.定制排序

TreeSet的自然排序是根据元素的大小,升序排列。如果需要实现定制排序,例如降序排序,则可以通过Comparator接口的帮助。该接口里包含一个int compare(T o1,T o2)方法,该方法用于比较o1和o2的大小:如果该方法返回正整数,表明o1 > o2;返回0,则相等,返回负整数o1< o2。

如果我们需要实现定制排序,则需要在创建TreeSet集合时,传入一个Comparator对象与该TreeSet集合关联,由该Comparator对象负责集合元素的排序逻辑,由于Comparator是一个函数式接口,因此可使用Lambda表达式来代替Comparator对象。

class M{    int age;    public M(int age)    {        this.age = age;    }    public String toString()    {        return "M[age:" + age + "]";    }}public class TreeSetTest4 {    public static void main(String[] args) {        //此处Lambda表达式的目标类型是Comparator        TreeSet ts = new TreeSet((o1 , o2)->        {            M m1 = (M)o1;            M m2 = (M)o2;            return m1.age > m2.age ? -1 :m1.age < m2.age ?1 : 0;        });        ts.add(new M(5));        ts.add(new M(-3));        ts.add(new M(9));        System.out.println(ts);    }}

上面程序使用了目标类型为Comparator的Lambda表达式,它负责ts集合的排序,所以当把M对象添加到ts集合中时,无须M类实现Comparable接口,因为此时TreeSet无须通过M对象本身来比较大小,而是由TreeSet关联的Lambda表达式负责集合元素的排序。

注意:当通过Comparator对象(Lambda表达式)来实现TreeSet定制排序时,依然不可以向TreeSet中添加类型不同的对象,否则引发ClassCastException异常。TreeSet判断两个元素相当的规则标准是:通过Comparator(或Lambda表达式)比较两个元素返回0,这样TreeSet不会把第二个元素添加到集合中。

四、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实例:

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]    }}

上面程序代表了EnumSet的常规用法。除此之外,还可以复制另一个Enumset集合中的所有元素,或者赋值另一个Collection集合中的所有元素。当复制Collection集合中的所有元素时,集合中的元素必须是同一个枚举类的枚举值。

public class EnumSetTest2 {    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);//①        System.out.println(enumset);        c.add("JAVA");        c.add("集合");        //下面代码出现异常,因为集合c里面的元素不是全部为枚举值        enumset = EnumSet.copyOf(c);//②    }}

程序执行②处代码时,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(...));
0 0
原创粉丝点击