8.3.5 各Set实现类的性能分析
8.3.5 各Set实现类的性能分析
优先使用HashSet
HashSet
和TreeSet
是set
的两个典型实现,到底如何选择HashSet
和TreeSet
呢?HashSet
的性能总是比TreeSet
好(特别是最常用的添加、查询元素等操作),因为TreeSet
需要额外的红黑树算法来维护集合元素的次序。
需要排序时使用TreeSet
只有当需要一个保持排序的Set
时,才应该使用TreeSet
,否则都应该使用HashSet
。
LinkedHashSet有序
HashSet
还有一个子类: LinkedHashSet
,对于普通的插入、删除操作, LinkedHashSet
比HashSet
要略微慢一点,这是由维护链表所带来的额外开销造成的,但由于有了链表,遍历LinkedHashSet
会更快。
EnumSet用来保存枚举
EnumSet
是所有Set
实现类中性能最好的,但它只能保存同一个枚举类的枚举值作为集合元素。
HashSet TreeSet EnumSet都是线程不安全的
必须指出的是,Set
的三个实现类HashSet
、 TreeSet
和EnumSet
都是线程不安全
的。如果有多个线程同时访问一个Set
集合,并且有超过一个线程修改了该Set
集合,则必须手动保证该Set
集合的同步性。
如何线程安全:使用Collections工具类包装Set集合
通常可以通过Collections
工具类的synchronizedSortedSet
方法来”包装”该Set
集合。此操作最好在创建时进行,以防止对Set
集合的意外非同步访问。例如:SortedSet s= Collections.synchronizedSortedSet(new TreeSet(...));
关于Collections
工具类的更进一步用法,可以参考8.8节的内容。
8.3.4 EnumSet类
8.3.4 EnumSet类
EnumSet
是一个专为枚举类设计的集合类, EnumSet
中的所有元素都必须是指定枚举类型的枚举值,该枚举类型在创建EnumSet
时显式或隐式地指定。
EnumSet有序
EnumSet
的集合元素也是有序的, EnumSet
以枚举值在Enum
类内的定义顺序来决定集合元素的顺序。
EnumSet以向量存储 内存占用小
EnumSet
在内部以位向量的形式存储,这种存储形式非常紧凑、高效,因此EnumSet
对象占用内存很小,而且运行效率很好,尤其是调用containsAll()
,retainAll()
等方法来进行批量操作时。如果其参数也是EnumSet
集合,则该批量操作的执行速度也非常快。
EnumSet中不能加入null
EnumSet
集合不允许加入null
元素,如果试图插入null
元素, EnumSet
将抛出NullPointerException
异常。如果只是想判断EnumSet
是否包含null
元素或试图删除null
元素都不会抛出异常,只是删除操作将返回false
,因为EnumSet
中无法存入null
,所以根本没有任何null
元素被删除。
EnumSet不提供构造器,通过类方法来创建EnumSet对象
EnumSet
类没有暴露任何构造器来创建该类的实例,程序应该通过它提供的类方法来创建EnumSet
对象。
常用EnumSet类方法
EnumSet
类它提供了如下常用的类方法来创建EnumSet
对象。
创建所有枚举值构成的EnumSet
方法 | 描述 |
---|---|
static <E extends Enum<E>> EnumSet<E> allOf(Class<E> elementType) |
创建一个包含指定枚举类里所有枚举值的EnumSet 集合。 |
创建EnumSet的补集构成的EnumSet
方法 | 描述 |
---|---|
static <E extends Enum<E>> EnumSet<E> complementOf(EnumSet<E> s) |
创建一个其元素类型与指定EnumSet 里元素类型相同的EnumSet 集合,新EnumSet 集合包含原EnumSet 集合所不包含的、此枚举类剩下的枚举值(即新EnumSet 集合和原EnumSet 集合的集合元素加起来就是该枚举类的所有枚举值)。就是类似求差集 |
复制Collection集合或者其他EnumSet中的所有元素来创建EnumSet
方法 | 描述 |
---|---|
static <E extends Enum<E>> EnumSet<E> copyOf(Collection<E> c) |
使用一个普通集合来创建EnumSet 集合。 |
static <E extends Enum<E>> EnumSet<E> copyOf(EnumSet<E> s) |
Creates an enum set with the same element type as the specified enum set, initially containing the same elements (if any). |
创建 指定枚举类型的 空的EnumSet
方法 | 描述 |
---|---|
static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) |
创建一个元素类型为指定枚举类型的空EnumSet |
直接给定的枚举值来创建EnumSet
of
方法可以创建一个包含一个或多个枚举值的EnumSet
集合,传入的多个枚举值必须属于同一个枚举类。
方法 | 描述 |
---|---|
static <E extends Enum<E>> EnumSet<E> of(E e) |
Creates an enum set initially containing the specified element. |
static <E extends Enum<E>> EnumSet<E> of(E e1, E e2) |
Creates an enum set initially containing the specified elements. |
static <E extends Enum<E>> EnumSet<E> of(E first, E... rest) |
Creates an enum set initially containing the specified elements. |
static <E extends Enum<E>> EnumSet<E> of(E e1, E e2, E e3) |
Creates an enum set initially containing the specified elements. |
static <E extends Enum<E>> EnumSet<E> of(E e1, E e2, E e3, E e4) |
Creates an enum set initially containing the specified elements. |
static <E extends Enum<E>> EnumSet<E> of(E e1, E e2, E e3, E e4, E e5) |
Creates an enum set initially containing the specified elements. |
创建指定区间之间的枚举值构成的EnumeSet
方法 | 描述 |
---|---|
static <E extends Enum<E>> EnumSet<E> range(E from, E to) |
创建一个包含从from 枚举值到to 枚举值范围内所有枚举值的EnumSet 集合。 |
程序 创建EnumSet的各种姿势
下面程序示范了如何使用EnumSet
来保存枚举类的多个枚举值。
1 | import java.util.*; |
运行效果:
1 | [SPRING, SUMMER, FALL, WINTER] |
通过复制集合中的元素来创建EnumSet
除此之外,还可以复制另一个EnumSet
集合中的所有元素来创建新的EnumSet
集合,或者复制另一个Collection
集合中的所有元素来创建新的EnumSet
集合。当复制Collection
集合中的所有元素来创建新的EnumSet
集合时,要求Collection
集合中的所有元素必须是同一个枚举类的枚举值。
复制Collection来创建EnumSet的前提
当试图复制一个Collection
集合里的元素来创建EnumSet
集合时,必须保证Collection
集合里的所有元素都是同一个枚举类的枚举值
程序 复制集合中的元素来创建EnumSet
下面程序示范了这个用法。
1 | import java.util.*; |
上面程序中表示的①,和②处的代码没有任何区别,只是因为执行②行代码时,c集合中的元素不全是枚举值,而是包含了两个字符串对象,所以在②行代码处抛出ClassCastException
异常
8.3.3 TreeSet类 2.定制排序
8.3.3 TreeSet类 2.定制排序
自然排序默认是升序排列的
TreeSet
的自然排序是根据集合元素的大小, TreeSet
将它们以升序排列。
定制排序
如果需要实现定制排序,例如以降序排列,则可以通过Comparator
接口的帮助。该接口里包含一个int compare(T o1,T o2)
方法:
Comparator 接口的compare 方法 |
描述 |
---|---|
int compare(T o1, T o2) |
Compares its two arguments for order. |
该方法用于比较o1
和o2
的大小:
- 如果该方法返回正整数,则表明
o1
大于o2
; - 如果该方法返回0,则表明
o1
等于o2
; - 如果该方法返回负整数,则表明
o1
小于o2
;
如何实现定制排序
如果需要实现定制排序,则需要在创建TreeSet
集合对象时,提供一个Comparator
对象与该TreeSet
集合关联,由该Comparator
对象负责集合元素的排序逻辑。
由于Comparator
是一个函数式接口,因此可使用Lambda
表达式来代替Comparator
对象。
程序 TreeSet定制排序 使用Lambda表达式
1 | import java.util.*; |
上面程序中使用了目标类型为Comparator
的Lambda
表达式,它负责ts
集合的排序。所以当把M
对象添加到ts
集合中时,M
类无须实现Comparable
接口,因为此时TreeSet
无须通过M
对象本身来比较大小,而是由与TreeSet
关联的Lambda
表达式来负责集合元素的排序。
运行程序,看到如下运行结果:
1 | [M[age:9], M[age:5], M[age:-3]] |
放入TreeSet中对象的类型要相同
当通过Comparator
对象(或Lambda
表达式)来实现TreeSet
的定制排序时,依然不可以向TreeSet
中添加类型不同的对象,否则会引发ClassCastException
异常。
相等的元素不会再次添加到TreeSet中
使用定制排序时, TreeSet
对集合元素排序不管集合元素本身的大小,而是由Comparator
对象(或Lambda
表达式)负责集合元素的排序规则。 此时,TreeSet
判断两个集合元素相等的标准是:通过Comparator
(或Lambda
表达式)比较两个元素返回了0,这样TreeSet
不会把第二个元素添加到集合中。
8.3.3 TreeSet类 1.自然排序
8.3.3 TreeSet类
TreeSet是自动排序的集合
TreeSet
是SortedSet
接口的实现类,正如SortedSet
名字所暗示的, TreeSet
可以确保集合元素处于排序状态。
TreeSet比HashSet多出的方法
与HashSet
集合相比, TreeSet
还提供了如下几个额外的方法。
SortedSet接口方法
SortedSet接口方法 | 描述 |
---|---|
Comparator<? super E> comparator() |
如果TreeSet 采用了定制排序 ,则该方法返回定制排序所使用的Comparator ;如果TreeSet 采用了自然排序,则返回null . |
E first() |
返回集合中的第一个元素。 |
SortedSet<E> headSet(E toElement) |
返回此Set 的子集,由小于toElement 的元素组成。 |
E last() |
返回集合中的最后一个元素。 |
default Spliterator<E> spliterator() |
Creates a Spliterator over the elements in this sorted set. |
SortedSet<E> subSet(E fromElement, E toElement) |
返回此Set 的子集合,范围从fromElement (包含)到toElement (不包含)(范围前闭后开,类似String 类的substring 方法)。 |
SortedSet<E> tailSet(E fromElement) |
返回此Set 的子集,由大于或等于fromElement 的元素组成。 |
NavigableSet接口方法
方法 | 描述 |
---|---|
E lower(E e) |
返回集合中位于指定元素之前的元素(即集合中小于指定元素的最大元素,参考元素不需要是TreeSet 集合里的元素)。 |
E higher(E e) |
返回集合中位于指定元素之后的元素(即集合中大于指定元素的最小元素,参考元素不需要是TreeSet 集合里的元素)。 |
小结
表面上看起来这些方法很多,其实它们很简单:因为TreeSet
中的元素是有序的,所以增加了访问第一个、前一个、后一个、最后一个元素的方法,并提供了三个从TreeSet
中截取子TreeSet
的方法。
实例
下面程序测试了TreeSet
的通用用法。
1 | import java.util.*; |
运行效果:
1 | [-9, 2, 5, 10] |
TreeSet排序
根据上面程序的运行结果即可看出, TreeSet
并不是根据元素的插入顺序进行排序的,而是根据元素实际值的大小
来进行排序
的。
与HashSet
集合采用hash
算法来决定元素的存储位置不同,
红黑树
TreeSet
采用红黑树
的数据结构来存储集合元素。
那么TreeSet
进行排序的规则是怎样的呢? TreeSet
支持两种排序方法:自然排序
和定制排序
。在默认情况下, TreeSet
采用自然排序。
1.自然排序
TreeSet
会调用集合元素的compareTo(Object object)
方法来比较元素之间的大小关系,然后将集合元素按升序排列
,这种方式就是自然排序。
Comparable接口
Java
提供了一个Comparable
接口,该接口里定义了一个compareTo(Object object)
方法:
Comparable 接口方法 |
描述 |
---|---|
int compareTo(T o) |
Compares this object with the specified object for order. |
该方法返回个整数值,实现该接口的类必须实现该方法,实现了该接口的类的对象就可以比较大小。当一个对象调用该方法与另一个对象进行比较时,例如object1.compareTo(object2)
- 如果该方法返回
0
,则表明这两个对象相等; - 如果该方法返回一个
正整数
,则表明object1
大于object2
; - 如果该方法返回一个
负整数
,则表明object1
小于object2
。
实现了Comparable接口的常见类
Java
的一些常用类已经实现了Comparable
接口,并提供了比较大小的标准。下面是实现了Comparable
接口的常用类。
BigDecimal
、BigInteger
以及所有的数值型对应的包装类
:按它们对应的数值大小进行比较。Character
:按字符的unicode
值进行比较。Boolean
:true
对应的包装类实例大于false
对应的包装类实例String
:按字符串中字符的unicode
值进行比较。Date
、Time
:后面的时间、日期比前面的时间、日期大。
TreeSet中的对象必须实现Comparable接口
如果试图把一个对象添加到TreeSet
时,则该对象的类必须实现Comparable
接口,否则程序将会抛出异常。如下程序示范了这个错误。
1 | import java.util.*; |
运行效果:
1 | Exception in thread "main" java.lang.ClassCastException: Err cannot be cast to java.lang.Comparable |
- 上面程序试图向
TreeSet
集合中添加两个Err
对象,添加第一个对象时,TreeSet
里没有任何元素,所以不会出现任何问题; - 当添加第二个
Err
对象时,TreeSet
就会调用该对象的compareTo()
方法与集合中的其他元素进行比较。- 如果其对应的类没有实现
Comparable
接口,则会引发ClassCastException
异常。因此,上面程序将会在①号代码处引发该异常。
- 如果其对应的类没有实现
TreeSet中的第一个元素可以不实现Comparable接口
向TreeSet
集合中添加元素时,只有第一个元素无须实现Comparable
接口,后面添加的所有元素都必须实现Comparable
接口。
当然这也不是一种好做法,当试图从TreeSet
中取出元素时,依然会引发ClassCastException
异常。
还有一点必须指出:大部分类在实现compareTo(Object object)
方法时,都需要将被比较对象object
强制类型转换成相同类型,因为只有相同类的两个实例才会比较大小。
TreeSet中应该存放同一个类的对象
当试图把一个对象添加到TreeSet
集合时, TreeSet
会调用该对象的compareTo(Object object)
方法与集合中的其他元素进行比较——这就要求集合中的其他元素与该元素是同一个类的实例。也就是说,向TreeSet
中添加的应该是同一个类的对象,否则也会引发ClassCastException
异常。如下程序示范了这个错误。
程序 TreeSet中添加不同类型的对象会出现异常
1 | import java.util.*; |
运行效果:
1 | Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.util.Date |
- 上面程序先向
TreeSet
集合中添加了一个字符串对象,这个操作完全正常。 - 当添加第二个
Date
对象时,TreeSet
就会调用该对象的compareTo(Object object)
方法与集合中的其他元素进行比较Date
对象的compareTo(Object object)
方法无法与字符串对象比较大小,所以上面程序将在①代码处引发异常
如果向TreeSet
中添加的对象是程序员自定义类的对象,则可以向TreeSet
中添加多种类型的对象,前提是用户自定义类实现了Comparable
接口,且实现compareTo(Object object)
方法没有进行强制类型转换。但当试图取出TreeSet
里的集合元素时,不同类型的元素依然会发生ClassCastException
异常。
总结 TreeSet只能添加同一种类的对象
总结起来一句话:如果希望TreeSet
能正常运作, TreeSet
只能添加同一种类型的对象。
当把一个对象加入TreeSet
集合中时, TreeSet
调用该对象的compareTo(Object object)
方法与容器中的其他对象比较大小,然后根据红黑树
结构找到它的存储位置。
相等的元素不会添加到TreeSet中
如果两个对象通过compareTo(Object object)
方法比较相等,新对象将无法添加到TreeSet
集合中。
compareTo返回0则TreeSet就认为这两个对象相等
对于TreeSet
集合而言,它判断两个对象是否相等的唯一标准是:两个对象通过compareTo(Object object)
方法比较是否返回0:
- 如果通过
compareTo( Object object)
方法比较返回0,TreeSet
则会认为它们相等 - 否则就认为它们不相等。
程序
1 | import java.util.*; |
程序中①代码行把同一个对象再次添加到TreeSet
集合中,因为z1
对象的compareTo(Object object)
方法总是返回1
,虽然它的equals
方法总是返回true
,但TreeSet
会认为z1
对象和它自己也不相等,因此TreeSet
可以添加两个z1
对象。图8.5显示了TreeSet
及Z对象在内存中的存储示意图
从图8.5可以看到TreeSet
对象保存的两个元素的引用,实际上是同一个元素的引用(集合里放的总是引用
)。所以当修改TreeSet
集合里第一个元素的age
变量后,该TreeSet
集合里最后一个元素的age
变量也随之改变了。
TreeSet中元素的规则
要确保equals方法返回true时compareTo返回0
**如果两个对象通过equals
方法比较返回true
时,这两个对象通过compareTo(Object object)
方法比较应该返回0
**。
equals方法返回false时compareTo返回0引起的问题
如果两个对象通过compareTo(Object object)
方法比较返回0时,但它们通过equals
方法比较返回false
,这将很麻烦,因为两个对象通过compareTo(Object object)
方法比较相等, TreeSet
不会让第二个元素添加进去,这就会与Set
集合的规则产生冲突。
TreeSet只会在添加元素时排序
如果向TreeSet
中添加一个可变对象后,并且后面程序修改了该可变对象
的实例变量,这将导致它与其他对象的大小顺序发生了改变,但**TreeSet
不会再次调整它们的顺序**,甚至可能导致TreeSet
中保存的这两个对象通过compareTo(Object object)
方法比较返回0。
程序 TreeSet存放可变对象的情况
下面程序演示了这种情况。
展开/折叠
1 | import java.util.*; |
上面程序中的R对象对应的类正常重写了equals()
方法和compareTo()
方法,这两个方法都以R对象的count
实例变量作为判断的依据。
当程序执行①行代码时,看到程序输出的Set
集合元素处于有序状态;
因为R类是一个可变类,因此可以改变R对象的count
实例变量的值,程序后续代码行改变了该集合里第一个元素和最后一个元素的count
实例变量的值。当程序执行②行代码输出时,将看到该集合处于无序状态,而且集合中包含了重复元素。运行上面程序,看到如下所示的结果。
1 | [R[count:-3], R[count:-2], R[count:5], R[count:9]] |
一旦改变了TreeSet
集合里可变元素的实例变量,当再试图删除该对象时, TreeSet
也会删除失败(甚至集合中原有的实例变量没被修改但与修改后元素相等的元素也无法删除),所以在上面程序的③代码处,删除count
为-2
的R对象时,没有任何元素被删除;
程序执行④代码时,可以看到删除了count
为5的R对象,这表明:TreeSet
可以删除没有被修改实例变量、且不与其他被修改实例变量的对象重复的对象。
注意
当执行了④代码后, TreeSet
会对集合中的元素重新索引(不是重新排序),接下来就可以删除TreeSet
中的所有元素了,包括那些被修改过实例变量的元素。与HashSet
类似的是,如果TreeSet
中包含了可变对象,当可变对象的实例变量被修改时, TreeSet
在处理这些对象时将非常复杂,而且容易出错。
不要修改TreeSet中对象的关键实例变量值
为了让程序更加健壮,推荐不要修改放入HashSet
和TreeSet
集合中元素的关键实例变量
8.3.2 LinkedHashSet类
8.3.2 LinkedHashSet类
LinkedHashSet有序
HashSet
还有一个子类LinkedHashSet
, LinkedHashSet
集合也是根据元素的hashCode
值来决定元素的存储位置,但它同时使用链表维护元素的次序,这样使得元素看起来是以插入的顺序保存的。
也就是说,当遍历LinkedHashSet
集合里的元素时, LinkedHashSet
将会按元素的添加顺序来访问集合里的元素。LinkedHashSet
需要维护元素的插入顺序,因此性能略低于HashSet
的性能,但在迭代访问Set
里的全部元素时将有很好的性能,因为它以链表来维护内部顺序。
程序 LinkedHashSet类
1 | import java.util.*; |
编译、运行上面程序,看到如下输出:
1 | [疯狂Java讲义, 轻量级Java EE企业应用实战] |
LinkedHashSet是有序的集合
输出LinkedHashSet
集合的元素时,元素的顺序总是与添加顺序一致。
Set集合不可重复
虽然LinkedHashSet
使用了链表记录集合元素的添加顺序,但LinkedHashSet
依然是HashSet
,因此它依然不允许集合元素重复
8.3 Set集合 8.3.1 HashSet类
8.3 Set集合
Set集合无序
前面已经介绍过Set集合,它类似于一个罐子,程序可以依次把多个对象”丢进”Set集合,而Set集合通常不能记住元素的添加顺序。
Set集合不可重复
Set
集合与Collection
基本相同,没有提供任何额外的方法。实际上Set
就是Collection
,只是行为略有不同(Set
不允许包含重复元素)。
Set
集合不允许包含相同的元素,如果试图把两个相同的元素加入同一个Set
集合中,则添加操作失败,add()
方法返回false
,并且新元素不会覆盖旧元素。
上面介绍的是Set
集合的通用知识,因此完全适合后面介绍的HashSet
、 TreeSet
和EnumSet
三个实现类,只是三个实现类还各有特色。
8.3.1 HashSet类
HashSet
是Set
接口的典型实现,大多数时候使用Set
集合时就是使用这个实现类。 HashSet
按Hash
算法来存储集合中的元素,具有很好的存取和査找性能。
HashSet特点
HashSet
具有以下特点。
- 不能保证元素的排列顺序,顺序可能与添加顺序不同,顺序也有可能发生变化。
HashSet
不是同步的,如果多个线程同时访问一个HashSet
,假设有两个或者两个以上线程同时修改了HashSet
集合时,则必须通过代码来保证其同步。- 集合元素值可以是
null
HashSet判断两个元素相等的标准
当向HashSet
集合中存入一个元素时, HashSet
会调用该对象的hashCode()
方法来得到该对象的hashCode
值,然后根据该hashCode
值决定该对象在HashSet
中的存储位置。
如果有两个元素通过equals()
方法比较返回true
,但它们的hashCode()
方法返回值不相等, HashSet
将会把它们存储在不同的位置,依然可以添加成功。
也就是说, HashSet
集合判断两个元素相等的标准是两个对象通过equals
方法比较相等,并且两个对象的hashCode()
方法返回值也相等。
程序 HashSet判断元素相同的标准
下面程序分别提供了三个类A、B和C,它们分别重写了equals
、 hashCode()
两个方法的一个或全部,通过此程序可以让读者看到HashSet
判断集合元素相同的标准。
1 | import java.util.*; |
上面程序中向books
集合中分别添加了两个A对象、两个B对象和两个C对象,其中C类重写了equals
方法总是返回true
, hashCode()
方法总是返回2,这将导致HashSet
把两个C对象当成同一个对象。运行上面程序,看到如下运行结果:
1 | [A@7852e922, B@1, B@1, C@2, A@4e25154f] |
从上面程序可以看出:
- 即使两个A对象通过
equals()
方法比较返回true
,但HashSet
依然把它们当成两个对象; - 即使两个B对象的
hashCode()
返回相同值(都是1),但HashSet
依然把它们当成两个对象。
HashSet中的元素要求 equals方法和hashCode方法返回值相同
这里有一个注意点:当把一个对象放入HashSet
中时,如果需要重写该对象对应类的equals()
方法,则也应该重写其hashCode()
方法。
规则是:如果两个对象通过equals
方法比较返回true
,这两个对象的hashCode()
方法的返回值也应该相同
hashCode返回值相同 equals返回值不同时会怎样
如果两个对象的hashCode()
方法返回的hashCode
值相同,但它们通过equals()
方法比较返回false
时将更麻烦:因为两个对象的hashCode
值相同, HashSet
将试图把它们保存在同一个位置,但是放在同一个位置是不行的,因为新的对象会覆盖旧的对象导致数据丢失,所以实际上会在这个位置用链式结构来保存多个对象;
而HashSet
访问集合元素时也是根据元素的hashCode
值来快速定位的,如果HashSet
中两个以上的元素具有相同的hashCode
值,这需要链表上继续查找,这将会导致性能下降。
hashCode()
方法对于HashSet
是不是十分重要
hash
算法可以快速查找被检索的对象,hash
算法的价值在于速度。当需要查询集合中某个元素时,hash
算法可以直接根据该元素的hashCode
值计算出该元素的存储位置,从而快速定位到该元素。
通过索引可以计算数组元素在内存中的位置
为了理解这个概念,可以先看数组(数组是所有能存储一组元素里最快的数据结构)数组可以包含多个元素,每个元素都有索引,如果需要访问某个数组元素,只需提供该元素的索引,接下来即可根据索引计算该元素在内存里的存储位置。
通过hashCode也可以计算元素的存储位置
表面上看起来, HashSet
集合里的元素都没有索引,实际上,当程序向HashSet
集合中添加元素时, HashSet
会根据该元素的hashCode
值来计算它的存储位置,这样也可快速定位到该元素。
为什么不直接使用数组、还需要使用HashSet
呢?
为什么不直接使用数组、还需要使用HashSet
呢?因为数组元素的索引是连续的,而且数组的长度是固定的,无法自由增加数组的长度。
而HashSet
就不一样了, HashSet
采用每个元素的hashCode
值来计算其存储位置,从而可以自由增加HashSet
的长度,并可以根据元素的hashCode
值来访问元素。
因此,当从HashSet
中访问元素时, HashSet
先调用该对象的hashCode
方法来计算该元素的hashCode
值,然后直接到该hashCode
值对应的内存地址去取出该元素,这就是HashSet
速度很快的原因。
HashSet的桶
HashSet
中每个能存储元素的”槽位”(slot
)通常称为”桶”(bucket
),如果有多个元素的hashCode
值相同,但它们通过equals
方法比较返回false
,就需要在一个”桶”里放多个元素,这样会导致性能下降。
重写hashCode()方法的基本规则
前面介绍了hashCode()
方法对于HashSet
的重要性(实际上,对象的hashCode
值对于后面的HashMap
同样重要),下面给出重写hashCode()
方法的基本规则。
- 在程序运行过程中,同一个对象多次调用
hashCode()
方法应该返回相同的值。 - 当两个对象通过
equals()
方法比较返回true
时,这两个对象的hashCode()
方法应返回相等的值。 - 对象中用作
equals
方法比较标准的实例变量,都应该用于计算hashCode
值。
重写hashCode()方法的一般步骤
下面给出重写hashCode()
方法的一般步骤。
- 把对象内每个参与
equals
方法比较标准的实例变量都计算出一个int
类型的hashCode
值。计算方式如下表所示:
实例变量类型 | 计算方式 |
---|---|
boolean 变量f |
hashCode=(f?0:1) |
整数类型(byte,short,char,int )变量f |
hashCode=(int) f; |
long 类型变量f |
hashCode=(int)(f^(f>>>32)); |
float 类型变量f |
hashCode=Float.frontToIntBits(f) ; |
double 类型变量f |
long l=Double.doubleToLongBits(f); hashCode=(int)(l^(l>>>32)); |
引用类型变量f | hashCode=f.hashCode(); |
- 用第1步计算出来的多个
hashCode
值组合计算出一个hashCode
值返回。例如如下代码:为了避免直接相加产生偶然相等(两个对象的1
return f1.hashCode()+(int)f2;
f1
、f2
实例变量并不相等,但它们的hash Code
的和恰好相等),可以通过为各实例变量的hashCode
值各自乘以任意一个质数
后再相加。例如如下代码:1
return f1.hashCode() * 19 +(int) f2 *31;
HashSet中添加可变对象出现的问题
如果向HashSet
中添加一个可变对象后,后面程序修改了该可变对象的实例变量,则可能导致它与集合中的其他元素相同(即两个对象通过equals
方法比较返回true
,两个对象的hashCode
值也相等),这就有可能导致HashSet
中包含两个相同的对象。
下面程序演示了这种情况。
1 | import java.util.*; |
上面程序中提供了R类,R类重写了equals(Object object)
方法和hashCode()
方法,这两个方法都是根据R对象的count
实例变量来判断的。
上面程序的①号代码处改变了Set
集合中第一个R对象的count
实例变量的值,这将导致该R对象与集合中的其他对象相同。
程序运行结果如下所示:
1 | [R[count:-2], R[count:-3], R[count:5], R[count:9]] |
正如上面运行结果中所见到的,HashSet
集合中的第1个元素和第2个元素完全相同,这表明两个元素已经重复。
此时HashSet
会比较混乱:当试图删除count
为-3
的R对象时, HashSet
会计算出该对象的hashCode
值,从而找出该对象在集合中的保存位置,然后把此处的对象与count
为-3
的R对象通过equals
方法进行比较,如果相等则删除该对象。
HashSet
只有第2个元素才满足该条件(第1个元素实际上保存在count
为-2
的R对象对应的位置),所以第2个元素被删除。
至于第一个count
为-3
的R对象,它保存在count
为-2
的R对象对应的位置,但使用equals
方法拿它和count
为-2
的R对象比较时又返回false
这将导致HashSet
不可能准确访问该元素。
元素放到HashSet后,不要修改参与计算hashCode和equals的实例变量的值
由此可见,当程序把可变对象添加到HashSet
中之后,尽量不要去修改该集合元素中参与计算hashCode()
、 equals()
的实例变量,否则将会导致HashSet
无法正确操作这些集合元素。
注意:
当向HashSet
中添加可变对象时,必须十分小心。如果修改HashSet
集合中的对象,有可能导致该对象与集合中的其他对象相等,从而导致HashSet
无法准确访问该对象。
8.2.6 使用Java8新增的Stream操作集合
8.2.6 使用Java 8新增的Stream操作集合
Java 8
还新增了Stream
、 IntStream
、 LongStream
、 DoubleStream
等流式API
,这些API
代表多个支持串行
和并行
聚集操作的元素。上面4个接口中, Stream
是一个通用的流接口,而IntStream
、 LongStream
、DoubleStream
则代表元素类型为int
、long
、 double
的流。
如何创建类型为int long double的Stream
Java 8
为上面每个流式API
提供了对应的Builder
,例如Stream.Builder
、 IntStream.Builder
、LongStream.Builder
、 DoubleStream.Builder
,开发者可以通过这些Builder
来创建对应的流。
独立使用Stream的步骤
独立使用Stream
的步骤如下:
- 使用
Stream
或XxxStream
的builder()
类方法创建该Stream
对应的Builder
。 - 重复调用
Builder
的add()
方法向该流中添加多个元素。 - 调用
Builder
的build()
方法获取对应的Stream
- 调用
Stream
的聚集方法。
在上面4个步骤中,第4步可以根据具体需求来调用不同的方法, Stream
提供了大量的聚集方法供用户调用,具体可参考Stream
或XxxStream
的API
文档。
每个Stream只能执行一次
对于大部分聚集方法而言,每个Stream
只能执行一次。
程序 IntStream使用示例
1 |
|
上面程序先创建了一个IntStream
,接下来分别多次调用IntStream
的聚集方法执行操作,这样即可获取该流的相关信息。注意:IntStream
的方法每次只能执行一个,因此需要把其他代码注释掉。
中间方法和末端方法
Stream
提供了大量的方法进行聚集操作,这些方法既可以是”中间的”(intermediate
),也可以是”末端的”(terminal
)。
中间方法
:中间操作允许流保持打开状态,并允许直接调用后续方法。上面程序中的map()
方法就是中间方法。中间方法的返回值是另外一个流。末端方法
:末端方法是对流的最终操作。当对某个Stream
执行末端方法后,该流将会被”消耗”且不再可用。上面程序中的sum()
、count()
、average()
等方法都是末端方法。
流的方法有什么特征
除此之外,关于流的方法还有如下两个特征。
有状态的方法
:这种方法会给流增加一些新的属性,比如元素的唯一性、元素的最大数量,保证元素以排序的方式被处理等。有状态的方法往往需要更大的性能开销。短路方法
:短路方法可以尽早结束对流的操作,不必检査所有的元素。
Stream常用的中间方法
下面简单介绍一下Stream
常用的中间方法。
Stream 中间方法 |
描述 |
---|---|
Stream<T> filter(Predicate<? super T> predicate) |
过滤Stream 中所有不符合predicate 的元素。 |
IntStream mapToInt(ToIntFunction<? super T> mapper) |
使用ToIntFunction 对流中的元素执行一对一的转换,该法返回的新流中包含了ToIntFunction 转换生成的所有元素。 |
LongStream mapToLong(ToLongFunction<? super T> mapper) |
Returns a LongStream consisting of the results of applying the given function to the elements of this stream. |
DoubleStream mapToDouble(ToDoubleFunction<? super T> mapper) |
Returns a DoubleStream consisting of the results of applying the given function to the elements of this stream. |
Stream<T> peek(Consumer<? super T> action) |
依次对每个元素执行一些操作,该方法返回的流与原有流包含相同的素。该方法主要用于调试. |
Stream<T> distinct() |
该方法用于排序流中所有重复的元素(判断元素重复的标准是使 equals() 比较返回true )这是一个有状态的方法 |
Stream<T> sorted() |
该方法用于保证流中的元素在后续的访问中处于有序状态。这是一个有状态的方法。 |
Stream<T> sorted(Comparator<? super T> comparator) |
Returns a stream consisting of the elements of this stream, sorted according to the provided |
Stream<T> limit(long maxSize) |
该方法用于保证对该流的后续访问中最大允许访问的元素个数。这是一个有状态的、短路方法。 |
Stream常用的末端方法
下面简单介绍一下Stream
常用的末端方法。
方法 | 描述 |
---|---|
void forEach(Consumer<? super T> action) |
遍历流中所有元素,对每个元素执行action |
将流转成数组
方法 | 描述 |
---|---|
Object[] toArray() |
将流中所有元素转换为一个数组。 |
<A> A[] toArray(IntFunction<A[]> generator) |
Returns an array containing the elements of this stream, using the provided generator function to allocate the returned array, as well as any additional arrays that might be required for a partitioned execution or for resizing. |
合并流中的元素:reduce方法
该方法有三个重载的版本,都用于通过某种操作来合并流中的元素。
方法 | 描述 |
---|---|
Optional<T> reduce(BinaryOperator<T> accumulator) |
Performs a reduction on the elements of this stream, using an associative accumulation function, and returns an Optional describing the reduced value, if any. |
T reduce(T identity, BinaryOperator<T> accumulator) |
Performs a reduction on the elements of this stream, using the provided identity value and an associative accumulation function, and returns the reduced value. |
<U> U reduce(U identity, BiFunction<U,? super T,U> accumulator, BinaryOperator<U> combiner) |
Performs a reduction on the elements of this stream, using the provided identity, accumulation and combining functions. |
求最大值 最小值 统计流中元素数量
方法 | 描述 |
---|---|
Optional<T> max(Comparator<? super T> comparator) |
返回流中所有元素的最大值 |
Optional<T> min(Comparator<? super T> comparator) |
返回流中所有元素的最小值 |
long count() |
返回流中所有元素的数量。 |
判断流中的元素是否符合Predicate
条件
方法 | 描述 |
---|---|
boolean allMatch(Predicate<? super T> predicate) |
判断流中是否每个元素都符合Predicate 条件。 |
boolean anyMatch(Predicate<? super T> predicate) |
判断流中是否至少包含一个元素符合Predicate 条件。 |
boolean noneMatch(Predicate<? super T> predicate) |
判断流中是否所有元素都不符合Predicate 条件 |
返回流中的元素
方法 | 描述 |
---|---|
Optional<T> findFirst() |
返回流中的第一个元素。 |
Optional<T> findAny() |
返回流中的任意一个元素。 |
获取集合对应的流
Java 8
允许使用流式API
来操作集合, Collection
接口提供了一个stream()
默认方法,该方法可返回该集合对应的流,接下来即可通过流式API
来操作集合元素。由于Stream
可以对集合元素进行整体的聚集操作,因此Stream
极大地丰富了集合的功能。
Collection 接口的stream 方法 |
描述 |
---|---|
default Stream<E> stream() |
Returns a sequential Stream with this collection as its source. |
程序 使用流操作集合
例如,对于8.2.5节介绍的示例程序,该程序需要额外定义一个calAll()
方法来遍历集合元素,然后依次对每个集合元素进行判断——这太麻烦了。如果使用Stream
,那么就直接对集合中所有元素进行批量操作。
下面使用Stream
来改写这个程序。
1 | import java.util.Collection; |
运行效果:
1 | 包含字符C的元素个数:2 |
从上面程序中的代码可以看出,
程序只要调用Collection
的stream()
方法即可返回该集合对应的Stream
,接下来就可通过Stream
提供的方法对所有集合元素进行处理,这样大大地简化了集合编程的代码。
上面程序中最后一段代码:
1 | IntStream intStream = collection.stream().mapToInt(str -> str.length()); |
先调用Collection
对象的stream()
方法将集合转换为Stream
对象,然后调用Stream
对象的mapToInt()
方法将其转换为IntStream
——这个mapToInt()
方法就是一个中间方法,因此程序可继续调用IntStream
的forEach()
方法来遍历流中的元素。
8.2.5 使用Java8新增的Predicate操作集合
8.2.5 使用Java8新增的Predicate操作集合
谓词:在计算机语言的环境下,谓词是指条件表达式的求值返回真或假的过程。
谓词是返回真/假(即布尔)值的函数
在Java 8中, 谓词是一个功能接口,它接受参数并返回布尔值。
谓词的一般含义是对正确或错误的陈述。 在编程中,谓词表示返回布尔值的单个参数函数。
使用removeIf
方法 批量删除 集合中的元素
Java 8
为Collection
集合新增了一个removeIf(Predicate filter)
方法:
Collection接口的removeIf方法 | 描述 |
---|---|
default boolean removeIf(Predicate<? super E> filter) |
Removes all of the elements of this collection that satisfy the given predicate. |
该方法将会批量删除
符合filter
条件的所有元素。
Predicate是函数式接口
该方法需要一个Predicate
(谓词)对象作为参数, Predicate
也是函数式接口,因此可使用Lambda
表达式作为参数。
实例 使用removeIf
批量删除集合元素
如下程序示范了使用Predicate
来过滤集合。
1 | import java.util.Collection; |
1 | [1, 2, 3, 4, 5] |
上面程序中调用了Collection
集合的removeIf()
方法批量删除集合中符合条件的元素,程序传入一个Lambda
表达式作为过滤条件
函数式接口Predicate的抽象方法 test
Predicate的抽象方法 | 描述 |
---|---|
boolean test(T t) |
Evaluates this predicate on the given argument. |
Predicate
的test
方法可以判断一个对象是否满足谓词中给定的条件.
使用Predicate类的test方法统计集合元素
可以创建一个countCollectionByPredicate
方法,第一个参数是Collection
,第二个参数是Predicate
,
然后就可以在方法中遍历集合元素时,计算Predicate对象.test(集合元素)
方法返回true
的次数,从而实现对集合元素的统计.
实例Predicate统计集合元素
1 | import java.util.*; |
运行效果如下:
1 | [Java, C++, C, JavaScirpt, HelloWorld] |
8.2.4 使用foreach循环遍历集合元素
8.2.4 使用foreach循环遍历集合元素
除了可以使用Iterator
接口迭代访问Collection
集合里的元素之外,使用Java 5
提供的foreach
循环迭代访问集合元素更加便捷。
JDK 1.5
的foreach
循环需要注意的地方
不要修改foreach循环中的迭代变量的值
与使用Iterator
接口迭代访问集合元素类似的是, foreach
循环中的迭代变量也不是集合元素本身,系统只是依次把集合元素的值赋给迭代变量,因此在foreach
循环中修改迭代变量的值也没有任何实际意义.
迭代集合时集合不能改变
同样,当使用foreach
循环迭代访问集合元素时,该集合也不能被改变,否则将引发ConcurrentModificationException
异常。
程序 使用foreach循环 遍历Collection集合
如下程序示范了使用foreach
循环来迭代访问集合元素。
1 | import java.util.*; |
1 | 1 |
取消collection.remove(str);
这行代码前面的注释.再次运行:
1 | 1 |
上面代码使用foreach
循环来迭代访问Collection
集合里的元素更加简洁,这正是JDK 1.5
的foreach
循环带来的优势。