集合
版本号:1.0
作者:huangdos
日期: 2006 年 6 月 06 日
摘 要
摘要内容
Java里面最重要,最常用也就是集会一部分了。能够用好集合和理解好集合对于做Java程序的开发拥有无比的好处。本文详细解释了关于Java中的集合是如何实现的,以及他们的实现原理。
关键字:
Collection , List ,Set , Map , 集合,框架。
目 录
集合
1 集合框架
1.1 集合框架概述
1.1.1 容器简介
到目前为止,我们已经学习了如何创建多个不同的对象,定义了这些对象以后,我们就可以利用它们来做一些有意义的事情。
举例来说,假设要存储许多雇员,不同的雇员的区别仅在于雇员的身份证号。我们可以通过身份证号来顺序存储每个雇员,但是在内存中实现呢?是不是要准备足够的内存来存储1000个雇员,然后再将这些雇员逐一插入?如果已经插入了500条记录,这时需要插入一个身份证号较低的新雇员,该怎么办呢?是在内存中将500条记录全部下移后,再从开头插入新的记录? 还是创建一个映射来记住每个对象的位置?当决定如何存储对象的集合时,必须考虑如下问题。
对于对象集合,必须执行的操作主要以下三种:
u 添加新的对象
u 删除对象
u 查找对象
我们必须确定如何将新的对象添加到集合中。可以将对象添加到集合的末尾、开头或者中间的某个逻辑位置。
从集合中删除一个对象后,对象集合中现有对象会有什么影响呢?可能必须将内存移来移去,或者就在现有对象所驻留的内存位置下一个“洞”。
在内存中建立对象集合后,必须确定如何定位特定对象。可建立一种机制,利用该机制可根据某些搜索条件(例如身份证号)直接定位到目标对象;否则,便需要遍历集合中的每个对象,直到找到要查找的对象为止。
前面大家已经学习过了数组。数组的作用是可以存取一组数据。但是它却存在一些缺点,使得无法使用它来比较方便快捷的完成上述应用场景的要求。
1. 首先,在很多数情况下面,我们需要能够存储一组数据的容器,这一点虽然数组可以实现,但是如果我们需要存储的数据的个数多少并不确定。比如说:我们需要在容器里面存储某个应用系统的当前的所有的在线用户信息,而当前的在线用户信息是时刻都可能在变化的。 也就是说,我们需要一种存储数据的容器,它能够自动的改变这个容器的所能存放的数据数量的大小。这一点上,如果使用数组来存储的话,就显得十分的笨拙。
2. 我们再假设这样一种场景:假定一个购物网站,经过一段时间的运行,我们已经存储了一系列的购物清单了,购物清单中有商品信息。如果我们想要知道这段时间里面有多少种商品被销售出去了。那么我们就需要一个容器能够自动的过滤掉购物清单中的关于商品的重复信息。如果使用数组,这也是很难实现的。
3. 最后再想想,我们经常会遇到这种情况,我知道某个人的帐号名称,希望能够进一步了解这个人的其他的一些信息。也就是说,我们在一个地方存放一些用户信息,我们希望能够通过用户的帐号来查找到对应的该用户的其他的一些信息。再举个查字典例子:假设我们希望使用一个容器来存放单词以及对于这个单词的解释,而当我们想要查找某个单词的意思的时候,能够根据提供的单词在这个容器中找到对应的单词的解释。如果使用数组来实现的话,就更加的困难了。
为解决这些问题,Java里面就设计了容器集合,不同的容器集合以不同的格式保存对象。
数学背景
在常见用法中,集合(collection)和数学上直观的集(set)的概念是相同的。集是一个唯一项组,也就是说组中没有重复项。实际上,“集合框架”包含了一个 Set 接口和许多具体的 Set 类。但正式的集概念却比 Java 技术提前了一个世纪,那时英国数学家 George Boole 按逻辑正式的定义了集的概念。大部分人在小学时通过我们熟悉的维恩图引入的“集的交”和“集的并”学到过一些集的理论。
集的基本属性如下:
u 集内只包含每项的一个实例
u 集可以是有限的,也可以是无限的
u 可以定义抽象概念
集不仅是逻辑学、数学和计算机科学的基础,对于商业和系统的日常应用来说,它也很实用。“连接池”这一概念就是数据库服务器的一个开放连接集。Web 服务器必须管理客户机和连接集。文件描述符提供了操作系统中另一个集的示例。
映射是一种特别的集。它是一种对(pair)集,每个对表示一个元素到另一元素的单向映射。一些映射示例有:
u IP 地址到域名(DNS)的映射
u 关键字到数据库记录的映射
u 字典(词到含义的映射)
u 2 进制到 10 进制转换的映射
就像集一样,映射背后的思想比 Java 编程语言早的多,甚至比计算机科学还早。而Java中的Map 就是映射的一种表现形式。
1.1.2 容器的分类
既然您已经具备了一些集的理论,您应该能够更轻松的理解“集合框架”。 “集合框架”由一组用来操作对象的接口组成。不同接口描述不同类型的组。在很大程度上,一旦您理解了接口,您就理解了框架。虽然您总要创建接口特定的实现,但访问实际集合的方法应该限制在接口方法的使用上;因此,允许您更改基本的数据结构而不必改变其它代码。框架接口层次结构如下图所示。
Java容器类类库的用途是“保存对象”,并将其划分为两个不同的概念:
1) Collection 。 一组对立的元素,通常这些元素都服从某种规则。List必须保持元素特定的顺序,而Set 不能有重复元素。
2) Map 。 一组 成对的“键值对”对象。初看起来这似乎应该是一个Collection ,其元素是成对的对象,但是这样的设计实现起来太笨拙了,于是我们将Map明确的提取出来形成一个独立的概念。另一方面,如果使用Collection 表示Map的部分内容,会便于查看此部分内容。因此Map一样容易扩展成多维Map ,无需增加新的概念,只要让Map中的键值对的每个“值”也是一个Map即可。
Collection和Map的区别在于容器中每个位置保存的元素个数。Collection 每个位置只能保存一个元素(对象)。此类容器包括:List ,它以特定的顺序保存一组元素;Set 则是元素不能重复。
Map保存的是“键值对”,就像一个小型数据库。我们可以通过“键”找到该键对应的“值”。
u Collection – 对象之间没有指定的顺序,允许重复元素。
u Set – 对象之间没有指定的顺序,不允许重复元素
u List– 对象之间有指定的顺序,允许重复元素,并引入位置下标。
u Map – 接口用于保存关键字(Key)和数值(Value)的集合,集合中的每个对象加入时都提供数值和关键字。Map 接口既不继承 Set 也不继承 Collection。
List、Set、Map共同的实现基础是Object数组
除了四个历史集合类外,Java 2 框架还引入了六个集合实现,如下表所示。
接口 | 实现 | 历史集合类 |
Set | HashSet |
|
| TreeSet |
|
List | ArrayList | Vector |
| LinkedList | Stack |
Map | HashMap | Hashtable |
| TreeMap | Properties |
这里没有 Collection
接口的实现,接下来我们再来看一下下面的这张关于集合框架的大图:
这张图看起来有点吓人,熟悉之后就会发现其实只有三种容器:Map,List和Set ,它们各自有两个三个实现版本。常用的容器用黑色粗线框表示。
点线方框代表“接口”,虚线方框代表抽象类,而实线方框代表普通类(即具体类,而非抽象类)。虚线箭头指出一个特定的类实现了一个接口(在抽象类的情况下,则是“部分”实现了那个接口)。实线箭头指出一个类可生成箭头指向的那个类的对象。例如任何集合( Collection )都能产生一个迭代器( Iterator ),而一个List 除了能生成一个ListIterator (列表迭代器)外,还能生成一个普通迭代器,因为List 正是从集合继承来的.
1.2 Collection
1.2.1 常用方法
Collection
接口用于表示任何对象或元素组。想要尽可能以常规方式处理一组元素时,就使用这一接口。Collection 在前面的大图也可以看出,它是List和Set 的父类。并且它本身也是一个接口。它定义了作为集合所应该拥有的一些方法。如下:
注意:
集合必须只有对象,集合中的元素不能是基本数据类型。
Collection接口支持如添加和除去等基本操作。设法除去一个元素时,如果这个元素存在,除去的仅仅是集合中此元素的一个实例。
u boolean add(Object element)
u boolean remove(Object element)
Collection 接口还支持查询操作:
u int size()
u boolean isEmpty()
u boolean contains(Object element)
u Iterator iterator()
组操作 :Collection 接口支持的其它操作,要么是作用于元素组的任务,要么是同时作用于整个集合的任务。
u boolean containsAll(Collection collection)
u boolean addAll(Collection collection)
u void clear()
u void removeAll(Collection collection)
u void retainAll(Collection collection)
containsAll()
方法允许您查找当前集合是否包含了另一个集合的所有元素,即另一个集合是否是当前集合的子集。其余方法是可选的,因为特定的集合可能不支持集合更改。 addAll()
方法确保另一个集合中的所有元素都被添加到当前的集合中,通常称为并。 clear()
方法从当前集合中除去所有元素。 removeAll()
方法类似于 clear()
,但只除去了元素的一个子集。 retainAll()
方法类似于 removeAll()
方法,不过可能感到它所做的与前面正好相反:它从当前集合中除去不属于另一个集合的元素,即交。
我们看一个简单的例子,来了解一下集合类的基本方法的使用:
import java.util.*;
public class CollectionToArray {
public static void main(String[] args) {
Collection collection1=new ArrayList();//创建一个集合对象
collection1.add("000");//添加对象到Collection集合中
collection1.add("111");
collection1.add("222");
System.out.println("集合collection1的大小:"+collection1.size());
System.out.println("集合collection1的内容:"+collection1);
collection1.remove("000");//从集合collection1中移除掉 "000" 这个对象
System.out.println("集合collection1移除 000 后的内容:"+collection1);
System.out.println("集合collection1中是否包含000 :"+collection1.contains("000"));
System.out.println("集合collection1中是否包含111 :"+collection1.contains("111"));
Collection collection2=new ArrayList();
collection2.addAll(collection1);//将collection1 集合中的元素全部都加到collection2中
System.out.println("集合collection2的内容:"+collection2);
collection2.clear();//清空集合 collection1 中的元素
System.out.println("集合collection2是否为空 :"+collection2.isEmpty());
//将集合collection1转化为数组
Object s[]= collection1.toArray();
for(int i=0;i<s.length;i++){
System.out.println(s[i]);
}
}
}
运行结果为:
集合collection1的大小:3
集合collection1的内容:[000, 111, 222]
集合collection1移除 000 后的内容:[111, 222]
集合collection1中是否包含000 :false
集合collection1中是否包含111 :true
集合collection2的内容:[111, 222]
集合collection2是否为空 :true
111
222
这里需要注意的是,Collection 它仅仅只是一个接口,而我们真正使用的时候,确是创建该接口的一个实现类。做为集合的接口,它定义了所有属于集合的类所都应该具有的一些方法。
而ArrayList (列表)类是集合类的一种实现方式。
这里需要一提的是,因为Collection的实现基础是数组,所以有转换为Object数组的方法:
u Object[] toArray()
u Object[] toArray(Object[] a)
其中第二个方法Object[] toArray(Object[] a) 的参数 a 应该是集合中所有存放的对象的类的父类。
1.2.2 迭代器
任何容器类,都必须有某种方式可以将东西放进去,然后由某种方式将东西取出来。毕竟,存放事物是容器最基本的工作。对于ArrayList,add()是插入对象的方法,而get()是取出元素的方式之一。ArrayList很灵活,可以随时选取任意的元素,或使用不同的下标一次选取多个元素。
如果从更高层的角度思考,会发现这里有一个缺点:要使用容器,必须知道其中元素的确切类型。初看起来这没有什么不好的,但是考虑如下情况:如果原本是ArrayList ,但是后来考虑到容器的特点,你想换用Set ,应该怎么做?或者你打算写通用的代码,它们只是使用容器,不知道或者说不关心容器的类型,那么如何才能不重写代码就可以应用于不同类型的容器?
所以迭代器(Iterator)的概念,也是出于一种设计模式就是为达成此目的而形成的。所以Collection不提供get()方法。如果要遍历Collectin中的元素,就必须用Iterator。
迭代器(Iterator)本身就是一个对象,它的工作就是遍历并选择集合序列中的对象,而客户端的程序员不必知道或关心该序列底层的结构。此外,迭代器通常被称为“轻量级”对象,创建它的代价小。但是,它也有一些限制,例如,某些迭代器只能单向移动。
Collection
接口的 iterator()
方法返回一个 Iterator
。Iterator
和您可能已经熟悉的 Enumeration
接口类似。使用 Iterator
接口方法,您可以从头至尾遍历集合,并安全的从底层 Collection
中除去元素。
下面,我们看一个对于迭代器的简单使用:
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
public class IteratorDemo {
public static void main(String[] args) {
Collection collection = new ArrayList();
collection.add("s1");
collection.add("s2");
collection.add("s3");
Iterator iterator = collection.iterator();//得到一个迭代器
while (iterator.hasNext()) {//遍历
Object element = iterator.next();
System.out.println("iterator = " + element);
}
if(collection.isEmpty())
System.out.println("collection is Empty!");
else
System.out.println("collection is not Empty! size="+collection.size());
Iterator iterator2 = collection.iterator();
while (iterator2.hasNext()) {//移除元素
Object element = iterator2.next();
System.out.println("remove: "+element);
iterator2.remove();
}
Iterator iterator3 = collection.iterator();
if (!iterator3.hasNext()) {//察看是否还有元素
System.out.println("还有元素");
}
if(collection.isEmpty())
System.out.println("collection is Empty!");
//使用collection.isEmpty()方法来判断
}
}
程序的运行结果为:
iterator = s1
iterator = s2
iterator = s3
collection is not Empty! size=3
remove: s1
remove: s2
remove: s3
还有元素
collection is Empty!
可以看到,Java的Collection的Iterator 能够用来,:
1) 使用方法 iterator() 要求容器返回一个Iterator .第一次调用Iterator 的next() 方法时,它返回集合序列的第一个元素。
2) 使用next() 获得集合序列的中的下一个元素。
3) 使用hasNext()检查序列中是否元素。
4) 使用remove()将迭代器新返回的元素删除。
需要注意的是:方法删除由next方法返回的最后一个元素,在每次调用next时,remove方法只能被调用一次 。
大家看,Java 实现的这个迭代器的使用就是如此的简单。Iterator(跌代器)虽然功能简单,但仍然可以帮助我们解决许多问题,同时针对List 还有一个更复杂更高级的ListIterator。您可以在下面的List讲解中得到进一步的介绍。
1.3 List
1.3.1 概述
前面我们讲述的Collection接口实际上并没有直接的实现类。而List是容器的一种,表示列表的意思。当我们不知道存储的数据有多少的情况,我们就可以使用List 来完成存储数据的工作。例如前面提到的一种场景。我们想要在保存一个应用系统当前的在线用户的信息。我们就可以使用一个List来存储。因为List的最大的特点就是能够自动的根据插入的数据量来动态改变容器的大小。下面我们先看看List接口的一些常用方法。
1.3.2 常用方法
List 就是列表的意思,它是Collection 的一种,即
继承了 Collection
接口,以定义一个允许重复项的有序集合。该接口不但能够对列表的一部分进行处理,还添加了面向位置的操作。List 是按对象的进入顺序进行保存对象,而不做排序或编辑操作。它除了拥有Collection接口的所有的方法外还拥有一些其他的方法。
面向位置的操作包括插入某个元素或 Collection 的功能,还包括获取、除去或更改元素的功能。在 List 中搜索元素可以从列表的头部或尾部开始,如果找到元素,还将报告元素所在的位置。
u void add(int index, Object element) :添加对象element到位置index上
u boolean addAll(int index, Collection collection) :在index位置后添加容器collection中所有的元素
u Object get(int index) :取出下标为index的位置的元素
u int indexOf(Object element) :查找对象element 在List中第一次出现的位置
u int lastIndexOf(Object element) :查找对象element 在List中最后出现的位置
u Object remove(int index) :删除index位置上的元素
u Object set(int index, Object element) :将index位置上的对象替换为element 并返回老的元素。
先看一下下面表格:
| 简述 | 实现 | 操作特性 | 成员要求 |
List | 提供基于索引的对成员的随机访问 | ArrayList | 提供快速的基于索引的成员访问,对尾部成员的增加和删除支持较好 | 成员可为任意Object子类的对象 |
LinkedList | 对列表中任何位置的成员的增加和删除支持较好,但对基于索引的成员访问支持性能较差 | 成员可为任意Object子类的对象 |
在“集合框架”中有两种常规的 List
实现:ArrayList
和 LinkedList
。使用两种 List
实现的哪一种取决于您特定的需要。如果要支持随机访问,而不必在除尾部的任何位置插入或除去元素,那么,ArrayList
提供了可选的集合。但如果,您要频繁的从列表的中间位置添加和除去元素,而只要顺序的访问列表元素,那么,LinkedList
实现更好。
我们以ArrayList 为例,先看一个简单的例子:
例子中,我们把12个月份存放到ArrayList 中,然后用一个循环,并使用get()方法将列表中的对象都取出来。
而
LinkedList
添加了一些处理列表两端元素的方法(下图只显示了新方法):
使用这些新方法,您就可以轻松的把 LinkedList
当作一个堆栈、队列或其它面向端点的数据结构。
我们再来看另外一个使用LinkedList
来实现一个简单的队列的例子:
import java.util.*;
public class ListExample {
public static void main(String args[]) {
LinkedList queue = new LinkedList();
queue.addFirst("Bernadine");
queue.addFirst(" Elizabeth ");
queue.addFirst("Gene");
queue.addFirst(" Elizabeth ");
queue.addFirst("Clara");
System.out.println(queue);
queue.removeLast();
queue.removeLast();
System.out.println(queue);
}
}
运行程序产生了以下输出。请注意,与 Set
不同的是 List
允许重复。
[Clara, Elizabeth, Gene, Elizabeth, Bernadine]
[Clara, Elizabeth, Gene]
该的程序演示了具体 List
类的使用。第一部分,创建一个由 ArrayList
支持的 List
。填充完列表以后,特定条目就得到了。示例的 LinkedList
部分把 LinkedList
当作一个队列,从队列头部添加东西,从尾部除去。
List
接口不但以位置友好的方式遍历整个列表,还能处理集合的子集:
u ListIterator listIterator() :返回一个ListIterator 跌代器,默认开始位置为0
u ListIterator listIterator(int startIndex) :返回一个ListIterator 跌代器,开始位置为startIndex
u List subList(int fromIndex, int toIndex) :返回一个子列表List ,元素存放为从 fromIndex 到toIndex之前的一个元素。
处理 subList()
时,位于 fromIndex
的元素在子列表中,而位于 toIndex
的元素则不是,提醒这一点很重要。以下 for-loop
测试案例大致反映了这一点:
for (int i=fromIndex; i<toIndex; i++) {
// process element at position i
}
此外,我们还应该提醒的是:对子列表的更改(如 add()
、remove()
和 set()
调用)对底层 List
也有影响。
ListIterator 接口
ListIterator
接口继承 Iterator
接口以支持添加或更改底层集合中的元素,还支持双向访问。
以下源代码演示了列表中的反向循环。请注意 ListIterator
最初位于列表尾之后(list.size()
),因为第一个元素的下标是0。
List list = ...;
ListIterator iterator = list.listIterator(list.size());
while (iterator.hasPrevious()) {
Object element = iterator.previous();
// Process element
}
正常情况下,不用 ListIterator
改变某次遍历集合元素的方向 — 向前或者向后。虽然在技术上可能实现时,但在 previous()
后立刻调用 next()
,返回的是同一个元素。把调用 next() 和 previous() 的顺序颠倒一下,结果相同。
我们看一个List的例子:
import java.util.*;
public class ListIteratorTest {
public static void main(String[] args) {
List list = new ArrayList();
list.add("aaa");
list.add("bbb");
list.add("ccc");
list.add("ddd");
System.out.println("下标0开始:"+list.listIterator(0).next());//next()
System.out.println("下标1开始:"+list.listIterator(1).next());
System.out.println("子List 1-3:"+list.subList(1,3));//子列表
ListIterator it = list.listIterator();//默认从下标0开始
//隐式光标属性add操作 ,插入到当前的下标的前面
it.add("sss");
while(it.hasNext()){
System.out.println("next Index="+it.nextIndex()+",Object="+it.next());
}
//set属性
ListIterator it1 = list.listIterator();
it1.next();
it1.set("ooo");
ListIterator it2 = list.listIterator(list.size());//下标
while(it2.hasPrevious()){
System.out.println("previous Index="+it2.previousIndex()+",Object="+it2.previous());
}
}
}
程序的执行结果为:
下标0开始:aaa
下标1开始:bbb
子List 1-3:[bbb, ccc]
next Index=1,Object=aaa
next Index=2,Object=bbb
next Index=3,Object=ccc
next Index=4,Object=ddd
previous Index=4,Object=ddd
previous Index=3,Object=ccc
previous Index=2,Object=bbb
previous Index=1,Object=aaa
previous Index=0,Object=ooo
我们还需要稍微再解释一下 add()
操作。添加一个元素会导致新元素立刻被添加到隐式光标的前面。因此,添加元素后调用 previous()
会返回新元素,而调用 next()
则不起作用,返回添加操作之前的下一个元素。下标的显示方式,如下图所示:
对于List 的基本用法我们学会了,下面我们来进一步了解一下List的实现原理,以便价升我们对于集合的理解。
1.3.3 实现原理
前面已经提了一下Collection的实现基础都是基于数组的。下面我们就已ArrayList 为例,简单分析一下ArrayList 列表的实现方式。首先,先看下它的构造函数。
下列表格是在SUN提供的API中的描述:
|
|
|
其中第一个构造函数ArrayList
()
和第二构造函数ArrayList
(
Collection
c)
是按照Collection
接口文档所述,所应该提供两个构造函数,一个无参数,一个接受另一个 Collection
。
第3个构造函数:
ArrayList
(int initialCapacity)
是
ArrayList
实现的比较重要的构造函数,虽然,我们不常用它,但是某认的构造函数正是调用的该带参数:
initialCapacity
的构造函数来实现的。
其中参数:
initialCapacity
表示我们构造的这个
ArrayList
列表的初始化容量是多大。如果调用默认的构造函数,则表示默认调用该参数为
initialCapacity =10
的方式,来进行构建一个
ArrayList
列表对象。
为了更好的理解这个
initialCapacity
参数的概念,我们先看看
ArrayList
在
Sun
提供的源码中的实现方式。先看一下它的属性有哪些:
ArrayList
继承了
AbstractList
我们主要看看
ArrayList
中的属性就可以了。
ArrayList
中主要包含
2
个属性:
u
private transient Object elementData[];
u
private int size;
其中数组::elementData[]
是列表的实现核心属性:数组。
我们使用该数组来进行存放集合中的数据。而我们的初始化参数就是该数组构建时候的长度,即该数组的
length
属性就是
initialCapacity
参数。
Keys:transient 表示被修饰的属性不是对象持久状态的一部分,不会自动的序列化。
第2个属性:size
表示列表中真实数据的存放个数。
我们再来看一下
ArrayList
的构造函数
,
加深一下
ArrayList
是基于数组的理解。
从源码中可以看到默认的构造函数调用的就是带参数的构造函数:
public ArrayList(int initialCapacity)
不过参数
initialCapacity
=
10
。
我们主要看
ArrayList(int initialCapacity)
这个构造函数。可以看到:
this
.elementData = new Object[initialCapacity];
我们就是使用的
initialCapacity
这个参数来创建一个
Object
数组。而我们所有的往该集合对象中存放的数据,就是存放到了这个
Object
数组中去了。
我们在看看另外一个构造函数的源码:
这里,我们先看
size()
方法的实现形式。它的作用即是返回
size
属性值的大小。然后我们再看另外一个构造函数
public ArrayList(Collection c)
,该构造函数的作用是把另外一个容器对象中的元素存放到当前的
List
对象中。
可以看到,首先,我们是通过调用另外一个容器对象
C
的方法
size()
来设置当前的
List
对象的
size
属性的长度大小。
接下来,就是对
elementData
数组进行初始化,初始化的大小为原先容器大小的
1.1
倍。最后,就是通过使用容器接口中的
Object[] toArray(Object[] a) 方法来把当前容器中的对象都存放到新的数组elementData
中。这样就完成了一个ArrayList 的建立。
可能大家会存在一个问题,那就是,我们建立的这个
ArrayList
是使用数组来实现的,但是数组的长度一旦被定下来,就不能改变了。而我们在给
ArrayList
对象中添加元素的时候,却没有长度限制。这个时候,
ArrayList
中的
elementData
属性就必须存在一个需要动态的扩充容量的机制。我们看下面的代码,它描述了这个扩充机制:
这个方法的作用就是用来判断当前的数组是否需要扩容,应该扩容多少。其中属性:
modCount
是继承自父类,它表示当前的对象对
elementData
数组进行了多少次扩容,清空,移除等操作。该属性相当于是一个对于当前
List
对象的一个操作记录日志号。 我们主要看下面的代码实现:
1.
首先得到当前
elementData
属性的长度
oldCapacity
。
2.
然后通过判断
oldCapacity
和
minCapacity
参数谁大来决定是否需要扩容
n
如果
minCapacity
大于
oldCapacity
,那么我们就对当前的
List
对象进行扩容。扩容的的策略为:取
(oldCapacity * 3)/2 + 1
和
minCapacity
之间更大的那个。然后使用数组拷贝的方法,把以前存放的数据转移到新的数组对象中
n
如果
minCapacity
不大于
oldCapacity
那么就不进行扩容。
下面我们看看上的那个
ensureCapacity
方法的是如何使用的
:
上的两个add方法都是往
List
中添加元素。每次在添加元素的时候,我们就需要判断一下,是否需要对于当前的数组进行扩容。
我们主要看看
public boolean add(Object o)
方法,可以发现在添加一个元素到容器中的时候,首先我们会判断是否需要扩容。因为只增加一个元素,所以扩容的大小判断也就为当前的
size+1
来进行判断。然后,就把新添加的元素放到数组
elementData
中。
第二个方法
public boolean addAll(Collection c)
也是同样的原理。将新的元素放到
elementData
数组之后。同时改变当前
List
对象的
size
属性。
类似的
List
中的其他的方法也都是基于数组进行操作的。大家有兴趣可以看看源码中的更多的实现方式。
最后我们再看看如何判断在集合中是否已经存在某一个对象的:
由源码中我们可以看到,
public boolean contains(Object elem)
方法是通过调用
public int indexOf(Object elem)
方法来判断是否在集合中存在某个对象
elem
。我们看看
indexOf
方法的具体实现。
u
首先我们判断一下
elem
对象是否为
null
,如果为
null
的话,那么遍历数组
elementData
把第一个出现
null
的位置返回。
u
如果
elem
不为
null
的话,我们也是遍历数组
elementData
,并通过调用
elem
对象的
equals()
方法来得到第一个相等的元素的位置。
这里我们可以发现,
ArrayList
中用来判断是否包含一个对象,调用的是各个对象自己实现的
equals()
方法。在前面的高级特性里面,我们可以知道:如果要判断一个类的一个实例对象是否等于另外一个对象,那么我们就需要自己覆写
Object
类的
public boolean equals(Object obj)
方法。如果不覆写该方法的话,那么就会调用
Object
的
equals()
方法来进行判断。这就相当于比较两个对象的内存应用地址是否相等了。
在集合框架中,不仅仅是
List
,所有的集合类,如果需要判断里面是否存放了的某个对象,都是调用该对象的
equals()
方法来进行处理的。
1.4 Map
1.4.1 概述
数学中的映射关系在Java中就是通过Map来实现的。它表示,里面存储的元素是一个对(pair),我们通过一个对象,可以在这个映射关系中找到另外一个和这个对象相关的东西。
前面提到的我们对于根据帐号名得到对应的人员的信息,就属于这种情况的应用。我们讲一个人员的帐户名和这人员的信息作了一个映射关系,也就是说,我们把帐户名和人员信息当成了一个“键值对”,“键”就是帐户名,“值”就是人员信息。下面我们先看看Map 接口的常用方法。
1.4.2 常用方法
Map
接口不是 Collection
接口的继承。而是从自己的用于维护键-值关联的接口层次结构入手。按定义,该接口描述了从不重复的键到值的映射。
我们可以把这个接口方法分成三组操作:改变、查询和提供可选视图。
改变操作允许您从映射中添加和除去键-值对。键和值都可以为 null。但是,您不能把 Map 作为一个键或值添加给自身。
u
Object put(Object key,Object value)
:用来存放一个键
-
值对
Map
中
u
Object remove(Object key)
:根据
key(
键
)
,移除一个键
-
值对,并将值返回
u
void putAll(Map mapping)
:将另外一个
Map
中的元素存入当前的
Map
中
u
void clear()
:清空当前
Map
中的元素
查询操作允许您检查映射内容:
u
Object get(Object key)
:根据
key(
键
)
取得对应的值
u
boolean containsKey(Object key)
:判断
Map
中是否存在某键(
key
)
u
boolean containsValue(Object value):
判断
Map
中是否存在某值
(value)
u
int size():
返回
Map
中
键-值对的个数
u
boolean isEmpty()
:判断当前
Map
是否为空
最后一组方法允许您把键或值的组作为集合来处理。
u
public Set keySet()
:返回所有的键(
key
),并使用
Set
容器存放
u
public Collection values()
:返回所有的值(
Value
),并使用
Collection
存放
u
public Set entrySet()
:
返回一个实现 Map.Entry 接口的元素 Set
因为映射中键的集合必须是唯一的,就使用 Set 来支持。因为映射中值的集合可能不唯一,就使用 Collection 来支持。最后一个方法返回一个实现 Map.Entry 接口的元素 Set。
我们看看Map的常用实现类的比较,如下表:
| 简述 | 实现 | 操作特性 | 成员要求 |
Map | 保存键值对成员,基于键找值操作,使用compareTo或compare方法对键进行排序 | HashMap | 能满足用户对Map的通用需求 | 键成员可为任意Object子类的对象,但如果覆盖了equals方法,同时注意修改hashCode方法。 |
TreeMap | 支持对键有序地遍历,使用时建议先用HashMap增加和删除成员,最后从HashMap生成TreeMap; 附加实现了SortedMap接口,支持子Map等要求顺序的操作 | 键成员要求实现 | ||
LinkedHashMap | 保留键的插入顺序,用equals 方法检查键和值的相等性 | 成员可为任意Object子类的对象,但如果覆盖了equals方法,同时注意修改hashCode方法。 |
下面我们看一个简单的例子:
import java.util.*;
public class MapTest {
public static void main(String[] args) {
Map map1 = new HashMap();
Map map2 = new HashMap();
map1.put("1","aaa1");
map1.put("2","bbb2");
map2.put("10","aaaa10");
map2.put("11","bbbb11");
//根据键 "1" 取得值:"aaa1"
System.out.println("map1.get(/"1/")="+map1.get("1"));
// 根据键 "1" 移除键值对"1"-"aaa1"
System.out.println("map1.remove(/"1/")="+map1.remove("1"));
System.out.println("map1.get(/"1/")="+map1.get("1"));
map1.putAll(map2);//将map2全部元素放入map1中
map2.clear();//清空map2
System.out.println("map1 IsEmpty?="+map1.isEmpty());
System.out.println("map2 IsEmpty?="+map2.isEmpty());
System.out.println("map1 中的键值对的个数size = "+map1.size());
System.out.println("KeySet="+map1.keySet());//set
System.out.println("values="+map1.values());//Collection
System.out.println("entrySet="+map1.entrySet());
System.out.println("map1 是否包含键:11 = "+map1.containsKey("11"));
System.out.println("map1 是否包含值:aaa1 = "+map1.containsValue("aaa1"));
}
}
运行输出结果为:
map1.get("1")=aaa1
map1.remove("1")=aaa1
map1.get("1")=null
map1 IsEmpty?=false
map2 IsEmpty?=true
map1 中的键值对的个数size = 3
KeySet=[10, 2, 11]
values=[aaaa10, bbb2, bbbb11]
entrySet=[10=aaaa10, 2=bbb2, 11=bbbb11]
map1 是否包含键:11 = true
map1 是否包含值:aaa1 = false
在该例子中,我们创建一个HashMap,并使用了一下Map接口中的各个方法。
其中Map中的entrySet
()
方法先提一下,该
方法返回一个实现 Map.Entry
接口的对象集合。集合中每个对象都是底层 Map
中一个特定的键-值对。
Map.Entry
接口是
Map
接口中的一个内部接口,该内部接口的实现类存放的是键值对。在下面的实现原理中,我们会对这方面再作介绍,现在我们先不管这个它的具体实现。
我们再看看排序的
Map
是如何使用:
import java.util.*;
public class MapSortExample {
public static void main(String args[]) {
Map map1 = new HashMap();
Map map2 = new LinkedHashMap();
for(int i=0;i<10;i++){
double s=Math.random()*100;//产生一个随机数,并将其放入Map中
map1.put(new Integer((int) s),"第 "+i+" 个放入的元素:"+s+"/n");
map2.put(new Integer((int) s),"第 "+i+" 个放入的元素:"+s+"/n");
}
System.out.println("未排序前HashMap:"+map1);
System.out.println("未排序前LinkedHashMap:"+map2);
//使用TreeMap来对另外的Map进行重构和排序
Map sortedMap = new TreeMap(map1);
System.out.println("排序后:"+sortedMap);
System.out.println("排序后:"+new TreeMap(map2));
}
}
该程序的一次运行结果为:
未排序前HashMap:{64=第 1 个放入的元素:64.05341725531845
, 15=第 9 个放入的元素:15.249165766266382
, 2=第 4 个放入的元素:2.66794706854534
, 77=第 0 个放入的元素:77.28814965781416
, 97=第 5 个放入的元素:97.32893518378948
, 99=第 2 个放入的元素:99.99412014935982
, 60=第 8 个放入的元素:60.91451419025399
, 6=第 3 个放入的元素:6.286974058646977
, 1=第 7 个放入的元素:1.8261658496439903
, 48=第 6 个放入的元素:48.736039522423106
}
未排序前LinkedHashMap:{77=第 0 个放入的元素:77.28814965781416
, 64=第 1 个放入的元素:64.05341725531845
, 99=第 2 个放入的元素:99.99412014935982
, 6=第 3 个放入的元素:6.286974058646977
, 2=第 4 个放入的元素:2.66794706854534
, 97=第 5 个放入的元素:97.32893518378948
, 48=第 6 个放入的元素:48.736039522423106
, 1=第 7 个放入的元素:1.8261658496439903
, 60=第 8 个放入的元素:60.91451419025399
, 15=第 9 个放入的元素:15.249165766266382
}
排序后:{1=第 7 个放入的元素:1.8261658496439903
, 2=第 4 个放入的元素:2.66794706854534
, 6=第 3 个放入的元素:6.286974058646977
, 15=第 9 个放入的元素:15.249165766266382
, 48=第 6 个放入的元素:48.736039522423106
, 60=第 8 个放入的元素:60.91451419025399
, 64=第 1 个放入的元素:64.05341725531845
, 77=第 0 个放入的元素:77.28814965781416
, 97=第 5 个放入的元素:97.32893518378948
, 99=第 2 个放入的元素:99.99412014935982
}
排序后:{1=第 7 个放入的元素:1.8261658496439903
, 2=第 4 个放入的元素:2.66794706854534
, 6=第 3 个放入的元素:6.286974058646977
, 15=第 9 个放入的元素:15.249165766266382
, 48=第 6 个放入的元素:48.736039522423106
, 60=第 8 个放入的元素:60.91451419025399
, 64=第 1 个放入的元素:64.05341725531845
, 77=第 0 个放入的元素:77.28814965781416
, 97=第 5 个放入的元素:97.32893518378948
, 99=第 2 个放入的元素:99.99412014935982
}
从运行结果,我们可以看出,
HashMap
的存入顺序和输出顺序无关。而
LinkedHashMap
则保留了键值对的存入顺序。
TreeMap
则是对
Map
中的元素进行排序。在实际的使用中我们也经常这样做:使用
HashMap
或者
LinkedHashMap
来存放元素,当所有的元素都存放完成后,如果使用则是需要一个经过排序的
Map
的话,我们再使用
TreeMap
来重构原来的
Map
对象。这样做的好处是:因为
HashMap
和
LinkedHashMap
存储数据的速度比直接使用
TreeMap
要快,存取效率要高。当完成了所有的元素的存放后,我们再对整个的
Map
中的元素进行排序。这样可以提高整个程序的运行的效率,缩短执行时间。
这里需要注意的是,
TreeMap
中是根据键(
Key
)进行排序的。而如果我们要使用
TreeMap
来进行正常的排序的话,
Key
中存放的对象必须实现
Comparable 接口。
我们简单介绍一下这个接口:
1.4.3 Comparable 接口
在 java.lang
包中,Comparable
接口适用于一个类有自然顺序的时候。假定对象集合是同一类型,该接口允许您把集合排序成自然顺序。
它只有一个方法:
compareTo()
方法,用来比较当前实例和作为参数传入的元素。如果排序过程中当前实例出现在参数前(当前实例比参数大),就返回某个负值。如果当前实例出现在参数后(当前实例比参数小),则返回正值。否则,返回零。如果这里不要求零返回值表示元素相等。零返回值可以只是表示两个对象在排序的时候排在同一个位置。
上面例子中的整形的包装类:
Integer
就实现了该接口。我们可以看一下这个类的源码:
可以看到compareTo 方法里面通过判断当前的Integer
对象的值是否大于传入的参数的值来得到返回值的。
在 Java 2 SDK,版本 1.2 中有十四个类实现 Comparable 接口。下表展示了它们的自然排序。虽然一些类共享同一种自然排序,但只有相互可比的类才能排序。
类 | 排序 |
BigDecimal, BigInteger, Byte, Double, Float, Integer, Long, Short | 按数字大小排序 |
Character | 按 Unicode 值的数字大小排序 |
CollationKey | 按语言环境敏感的字符串排序 |
Date | 按年代排序 |
File | 按系统特定的路径名的全限定字符的 Unicode 值排序 |
ObjectStreamField | 按名字中字符的 Unicode 值排序 |
String | 按字符串中字符 Unicode 值排序 |
这里只是简单的介绍一下排序接口,如果要详细的了解排序部分内容的话,可以参考文章最后的附录部分对于排序的更加详细的描述。
我们再回到
Map
中来,
Java
提高的
API
中除了上面介绍的几种
Map
比较常用以为还有一些
Map
,大家可以了解一下:
u
WeakHashMap
:
WeakHashMap
是 Map
的一个特殊实现,它只用于存储对键的弱引用。当映射的某个键在 WeakHashMap
的外部不再被引用时,就允许垃圾收集器收集映射中相应的键值对。使用 WeakHashMap
有益于保持类似注册表的数据结构,其中条目的键不再能被任何线程访问时,此条目就没用了。
u
IdentifyHashMap
:
Map
的一种特性实现,关键属性的
hash
码不是由
hashCode()
方法计算,而是由
System.identityHashCode
方法计算,使用
==
进行比较而不是
equals()
方法。
通过简单的对与
Map
中各个常用实现类的使用,为了更好的理解
Map
,下面我们再来了解一下
Map
的实现原理。
1.4.4 实现原理
有的人可能会认为 Map
会继承 Collection
。在数学中,映射只是对(pair)的集合。但是,在“集合框架”中,接口 Map
和 Collection
在层次结构没有任何亲缘关系,它们是截然不同的。这种差别的原因与 Set
和 Map
在 Java 库中使用的方法有关。Map
的典型应用是访问按关键字存储的值。它支持一系列集合操作的全部,但操作的是键-值对,而不是单个独立的元素。因此 Map
需要支持 get()
和 put()
的基本操作,而 Set
不需要。此外,还有返回 Map
对象的 Set
视图的方法:
Set set = aMap.keySet();
下面我们以HashMap为例,对Map的实现机制作一下更加深入一点的理解。
因为HashMap里面使用Hash算法,所以在理解HashMap之前,我们需要先了解一下Hash算法和Hash表。
Hash,一般翻译做“散列”,也有直接音译为"哈希"的,就是把任意长度的输入(又叫做 预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能 会散列成相同的输出,而不可能从散列值来唯一的确定输入值。
说的通俗一点,Hash算法的意义在于提供了一种快速存取数据的方法,它用一种算法建立键值与真实值之间的对应关系,(每一个真实值只能有一个键值,但是一个键值可以对应多个真实值),这样可以快速在数组等里面存取数据。
看下图:
我们建立一个HashTable(哈希表),该表的长度为N,然后我们分别在该表中的格子中存放不同的元素。每个格子下面存放的元素又是以链表的方式存放元素。
u 当添加一个新的元素Entry 的时候,首先我们通过一个Hash函数计算出这个Entry元素的Hash值hashcode。通过该hashcode值,就可以直接定位出我们应该把这个Entry元素存入到Hash表的哪个格子中,如果该格子中已经存在元素了,那么只要把新的Entry元存放到这个链表中即可。
u 如果要查找一个元素Entry的时候,也同样的方式,通过Hash函数计算出这个Entry元素的Hash值hashcode。然后通过该hashcode值,就可以直接找到这个Entry是存放到哪个格子中的。接下来就对该格子存放的链表元素进行逐个的比较查找就可以了。
举一个比较简单的例子来说明这个算法的运算方式:
假定我们有一个长度为8的Hash表(可以理解为一个长度为8的数组)。在这个Hash表中存放数字:如下表
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
假定我们的Hash函数为:
Hashcode = X%8 -------- 对8 取余数
其中X就是我们需要放入Hash表中的数字,而这个函数返回的Hashcode就是Hash码。
假定我们有下面10个数字需要依次存入到这个Hash表中:
11 , 23 , 44 , 9 , 6 , 32 , 12 , 45 , 57 , 89
通过上面的Hash函数,我们可以得到分别对应的Hash码:
11――3 ; 23――7 ;44――4 ;9――1;6――6;32――0;12――4;45――5;57――1;89――1;
计算出来的Hash码分别代表,该数字应该存放到Hash表中的哪个对应数字的格子中。如果改格子中已经有数字存在了,那么就以链表的方式将数字依次存放在该格子中,如下表:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
32 | 9 |
| 11 | 44 | 45 | 6 | 23 |
| 57 |
|
| 12 |
|
|
|
| 89 |
|
|
|
|
|
|
Hash表和Hash算法的特点就是它的存取速度比数组差一些,但是比起单纯的链表,在查找和存储方面却要好很多。同时数组也不利于数据的重构而排序等方面的要求。
更具体的说明,读者可以参考数据结构相关方面的书籍。
简单的了解了一下Hash算法后,我们就来看看HashMap的属性有哪些:
里面最重要的3个属性:
n transient Entry[] table: 用来存放键值对的对象Entry数组,也就是Hash表
n transient int size:当前Map中存放的键值对的个数
n final float loadFactor:负载因子,用来决定什么情况下应该对Entry进行扩容
我们Entry 对象是Map接口中的一个内部接口。即是使用它来保存我们的键值对的。
我们看看这个Entry 内部接口在HashMap中的实现:
通过查看源码,我们可以看到Entry类有点类似一个单向链表。其中:
final Object key 和 Object value;存放的就是我们放入Map中的键值对。
而属性Entry next;表示当前键值对的下一个键值对是哪个Entry。
接下来,我们看看HashMap的主要的构造函数:
我们主要看看 public HashMap(int initialCapacity, float loadFactor)
因为,另外两个构造函数实行也是同样的方式进行构建一个HashMap 的。
该构造函数:
1. 首先是判断参数int initialCapacity和float loadFactor是否合法
2. 然后确定Hash表的初始化长度。确定的策略是:通过传进来的参数initialCapacity 来找出第一个大于它的2的次方的数。比如说我们传了18这样的一个initialCapacity 参数,那么真实的table数组的长度为2的5次方,即32。之所以采用这种策略来构建Hash表的长度,是因为2的次方的运算对于现代的处理器来说,可以通过一些方法得到更加好的执行效率。
3. 接下来就是得到重构因子(threshold)了,这个属性也是HashMap中的一个比较重要的属性,它表示,当Hash表中的元素被存放了多少个之后,我们就需要对该Hash表进行重构。
4. 最后就是使用得到的初始化参数capacity 来构建Hash表:Entry[] table。
下面我们看看一个键值对是如何添加到HashMap中的。
该put方法是用来添加一个键值对(key-value)到Map中,如果Map中已经存在相同的键的键值对的话,那么就把新的值覆盖老的值,并把老的值返回给方法的调用者。如果不存在一样的键,那么就返回null 。我们看看方法的具体实现:
1. 首先我们判断如果key为null则使用一个常量来代替该key值,该行为在方法maskNull()终将key替换为一个非null 的对象k。
2. 计算key值的Hash码:hash
3. 通过使用Hash码来定位,我们应该把当前的键值对存放到Hash表中的哪个格子中。indexFor()方法计算出的结果:i 就是Hash表(table)中的下标。
4. 然后遍历当前的Hash表中table[i]格中的链表。从中判断已否已经存在一样的键(Key)的键值对。如果存在一样的key的键,那么就用新的value覆写老的value,并把老的value返回
5. 如果遍历后发现没有存在同样的键值对,那么就增加当前键值对到Hash表中的第i个格子中的链表中。并返回null 。
最后我们看看一个键值对是如何添加到各个格子中的链表中的:
我们先看void addEntry(int hash, Object key, Object value, int bucketIndex)方法,该方法的作用就用来添加一个键值对到Hash表的第bucketIndex个格子中的链表中去。这个方法作的工作就是:
1. 创建一个Entry对象用来存放键值对。
2. 添加该键值对 ---- Entry对象到链表中
3. 最后在size属性加一,并判断是否需要对当前的Hash表进行重构。如果需要就在void resize(int newCapacity)方法中进行重构。
之所以需要重构,也是基于性能考虑。大家可以考虑这样一种情况,假定我们的Hash表只有4个格子,那么我们所有的数据都是放到这4个格子中。如果存储的数据量比较大的话,例如100。这个时候,我们就会发现,在这个Hash表中的4个格子存放的4个长长的链表。而我们每次查找元素的时候,其实相当于就是遍历链表了。这种情况下,我们用这个Hash表来存取数据的性能实际上和使用链表差不多了。
但是如果我们对这个Hash表进行重构,换为使用Hash表长度为200的表来存储这100个数据,那么平均2个格子里面才会存放一个数据。这个时候我们查找的数据的速度就会非常的快。因为基本上每个格子中存放的链表都不会很长,所以我们遍历链表的次数也就很少,这样也就加快了查找速度。但是这个时候又存在了另外的一个问题。我们使用了至少200个数据的空间来存放100个数据,这样就造成至少100个数据空间的浪费。 在速度和空间上面,我们需要找到一个适合自己的中间值。在HashMap中我们通过负载因子(loadFactor)来决定应该什么时候应该重构我们的Hash表,以达到比较好的性能状态。
我们再看看重构Hash表的方法:void resize(int newCapacity)是如何实现的:
它的实现方式比较简单:
1. 首先判断如果Hash表的长度已经达到最大值,那么就不进行重构了。因为这个时候Hash表的长度已经达到上限,已经没有必要重构了。
2. 然后就是构建新的Hash表
3. 把老的Hash表中的对象数据全部转移到新的Hash表newTable中,并设置新的重构因子threshold
对于HashMap中的实现原理,我们就分析到这里。大家可能会发现,HashCode的计算,是用来定位我们的键值对应该放到Hash表中哪个格子中的关键属性。而这个HashCode的计算方法是调用的各个对象自己的实现的hashCode()方法。而这个方法是在Object对象中定义的,所以我们自己定义的类如果要在集合中使用的话,就需要正确的覆写hashCode() 方法。下面就介绍一下应该如何正确覆写hashCode()方法。
1.4.5 覆写hashCode()
在明白了HashMap具有哪些功能,以及实现原理后,了解如何写一个hashCode()方法就更有意义了。当然,在HashMap中存取一个键值对涉及到的另外一个方法为equals (),因为该方法的覆写在高级特性已经讲解了。这里就不做过多的描述。
设计hashCode()时最重要的因素就是:无论何时,对同一个对象调用hashCode()都应该生成同样的值。如果在将一个对象用put()方法添加进HashMap时产生一个hashCode()值,而用get()取出时却产生了另外一个hashCode()值,那么就无法重新取得该对象了。所以,如果你的hashCode()方法依赖于对象中易变的数据,那用户就要小心了,因为此数据发生变化时,hashCode()就会产生一个不同的hash码,相当于产生了一个不同的“键”。
此外,也不应该使hashCode()依赖于具有唯一性的对象信息,尤其是使用this的值,这只能产生很糟糕的hashCode()。因为这样做无法生成一个新的“键”,使之与put()种原始的“键值对”中的“键”相同。例如,如果我们不覆写Object的hashCode()方法,那么调用该方法的时候,就会调用Object的hashCode()方法的默认实现。Object的hashCode()方法,返回的是当前对象的内存地址。下次如果我们需要取一个一样的“键”对应的键值对的时候,我们就无法得到一样的hashCode值了。因为我们后来创建的“键”对象已经不是存入HashMap中的那个内存地址的对象了。
我们看一个简单的例子,就能更加清楚的理解上面的意思。假定我们写了一个类:Person (人),我们判断一个对象“人”是否指向同一个人,只要知道这个人的身份证号一直就可以了。
先看我们没有实现hashCode的情况:
package c08.hashEx;
import java.util.*;
//身份证类
class Code{
final int id;//身份证号码已经确认,不能改变
Code(int i){
id=i;
}
//身份号号码相同,则身份证相同
public boolean equals(Object anObject) {
if (anObject instanceof Code){
Code other=(Code) anObject;
return this.id==other.id;
}
return false;
}
public String toString() {
return "身份证:"+id;
}
}
//人员信息类
class Person {
Code id;// 身份证
String name;// 姓名
public Person(String name, Code id) {
this.id=id;
this.name=name;
}
//如果身份证号相同,就表示两个人是同一个人
public boolean equals(Object anObject) {
if (anObject instanceof Person){
Person other=(Person) anObject;
return this.id.equals(other.id);
}
return false;
}
public String toString() {
return "姓名:"+name+" 身份证:"+id.id+"/n";
}
}
public class HashCodeEx {
public static void main(String[] args) {
HashMap map=new HashMap();
Person p1=new Person("张三",new Code(123));
map.put(p1.id,p1);//我们根据身份证来作为key值存放到Map中
Person p2=new Person("李四",new Code(456));
map.put(p2.id,p2);
Person p3=new Person("王二",new Code(789));
map.put(p3.id,p3);
System.out.println("HashMap 中存放的人员信息:/n"+map);
// 张三 改名为:张山 但是还是同一个人。
Person p4=new Person("张山",new Code(123));
map.put(p4.id,p4);
System.out.println("张三改名后 HashMap 中存放的人员信息:/n"+map);
//查找身份证为:123 的人员信息
System.out.println("查找身份证为:123 的人员信息:"+map.get(new Code(123)));
}
}
运行结果为:
HashMap 中存放的人员信息:
{身份证:456=姓名:李四 身份证:456
, 身份证:123=姓名:张三 身份证:123
, 身份证:789=姓名:王二 身份证:789
}
张三改名后 HashMap 中存放的人员信息:
{身份证:456=姓名:李四 身份证:456
, 身份证:123=姓名:张三 身份证:123
, 身份证:123=姓名:张山 身份证:123
, 身份证:789=姓名:王二 身份证:789
}
查找身份证为:123 的人员信息:null
上面的例子的演示的是,我们在一个HashMap中存放了一些人员的信息。并以这些人员的身份证最为人员的“键”。当有的人员的姓名修改了的情况下,我们需要更新这个HashMap。同时假如我们知道某个身份证号,想了解这个身份证号对应的人员信息如何,我们也可以根据这个身份证号在HashMap中得到对应的信息。
而例子的输出结果表示,我们所做的更新和查找操作都失败了。失败的原因就是我们的身份证类:Code 没有覆写hashCode()方法。这个时候,当查找一样的身份证号码的键值对的时候,使用的是默认的对象的内存地址来进行定位。这样,后面的所有的身份证号对象new Code(123) 产生的hashCode()值都是不一样的。所以导致操作失败。
下面,我们给Code类加上hashCode()方法,然后再运行一下程序看看:
//身份证类
class Code{
final int id;//身份证号码已经确认,不能改变
Code(int i){
id=i;
}
//身份号号码相同,则身份证相同
public boolean equals(Object anObject) {
if (anObject instanceof Code){
Code other=(Code) anObject;
return this.id==other.id;
}
return false;
}
public String toString() {
return "身份证:"+id;
}
//覆写hashCode方法,并使用身份证号作为hash值
public int hashCode(){
return id;
}
}
再次执行上面的HashCodeEx 的结果就为:
HashMap 中存放的人员信息:
{身份证:456=姓名:李四 身份证:456
, 身份证:789=姓名:王二 身份证:789
, 身份证:123=姓名:张三 身份证:123
}
张三改名后 HashMap 中存放的人员信息:
{身份证:456=姓名:李四 身份证:456
, 身份证:789=姓名:王二 身份证:789
, 身份证:123=姓名:张山 身份证:123
}
查找身份证为:123 的人员信息:姓名:张山 身份证:123
这个时候,我们发现。我们想要做的更新和查找操作都成功了。
对于Map部分的使用和实现,主要就是需要注意存放“键值对”中的对象的equals()方法和hashCode()方法的覆写。如果需要使用到排序的话,那么还需要实现Comparable 接口中的compareTo()
方法。我们需要注意Map中的“键”是不能重复的,而是否重复的判断,是通过调用“键”对象的equals()方法来决定的。而在HashMap中查找和存取“键值对”是同时使用hashCode()方法和equals()方法来决定的。
1.5 Set
1.5.1 概述
Java 中的Set和正好和数学上直观的集(set)的概念是相同的。Set最大的特性就是不允许在其中存放的元素是重复的。根据这个特点,我们就可以使用Set 这个接口来实现前面提到的关于商品种类的存储需求。Set 可以被用来过滤在其他集合中存放的元素,从而得到一个没有包含重复新的集合。
1.5.2 常用方法
按照定义,Set
接口继承 Collection
接口,而且它不允许集合中存在重复项。所有原始方法都是现成的,没有引入新方法。具体的 Set
实现类依赖添加的对象的 equals()
方法来检查等同性。
我们简单的描述一下各个方法的作用:
u public int size() :返回set中元素的数目,如果set包含的元素数大于Integer.MAX_VALUE,返回Integer.MAX_VALUE
u public boolean isEmpty() :如果set中不含元素,返回true
u public boolean contains(Object o) :如果set包含指定元素,返回true
u public Iterator iterator()
l 返回set中元素的迭代器
l 元素返回没有特定的顺序,除非set是提高了该保证的某些类的实例
u public Object[] toArray() :返回包含set中所有元素的数组
u public Object[] toArray(Object[] a) :返回包含set中所有元素的数组,返回数组的运行时类型是指定数组的运行时类型
u public boolean add(Object o) :如果set中不存在指定元素,则向set加入
u public boolean remove(Object o) :如果set中存在指定元素,则从set中删除
u public boolean removeAll(Collection c) :如果set包含指定集合,则从set中删除指定集合的所有元素
u public boolean containsAll(Collection c) :如果set包含指定集合的所有元素,返回true。如果指定集合也是一个set,只有是当前set的子集时,方法返回true
u public boolean addAll(Collection c) :如果set中中不存在指定集合的元素,则向set中加入所有元素
u public boolean retainAll(Collection c) :只保留set中所含的指定集合的元素(可选操作)。换言之,从set中删除所有指定集合不包含的元素。 如果指定集合也是一个set,那么该操作修改set的效果是使它的值为两个set的交集
u public boolean removeAll(Collection c) :如果set包含指定集合,则从set中删除指定集合的所有元素
u public void clear() :从set中删除所有元素
“集合框架” 支持 Set
接口两种普通的实现:HashSet
和 TreeSet
以及LinkedHashSet。下表中是Set的常用实现类的描述:
| 简述 | 实现 | 操作特性 | 成员要求 |
Set | 成员不能重复 | HashSet | 外部无序地遍历成员。 | 成员可为任意Object子类的对象,但如果覆盖了equals方法,同时注意修改hashCode方法。 |
TreeSet | 外部有序地遍历成员; 附加实现了SortedSet, 支持子集等要求顺序的操作 | 成员要求实现 | ||
LinkedHashSet | 外部按成员的插入顺序遍历成员 | 成员与HashSet成员类似 |
在更多情况下,您会使用 HashSet 存储重复自由的集合。同时HashSet中也是采用了Hash算法的方式进行存取对象元素的。所以添加到 HashSet 的对象对应的类也需要采用恰当方式来实现 hashCode() 方法。虽然大多数系统类覆盖了 Object 中缺省的 hashCode() 实现,但创建您自己的要添加到 HashSet 的类时,别忘了覆盖 hashCode()。
对于Set的使用,我们先以一个简单的例子来说明:
import java.util.*;
public class HashSetDemo {
public static void main(String[] args) {
Set set1 = new HashSet();
if (set1.add("a")) {//添加成功
System.out.println("1 add true");
}
if (set1.add("a")) {//添加失败
System.out.println("2 add true");
}
set1.add("000");//添加对象到Set集合中
set1.add("111");
set1.add("222");
System.out.println("集合set1的大小:"+set1.size());
System.out.println("集合set1的内容:"+set1);
set1.remove("000");//从集合set1中移除掉 "000" 这个对象
System.out.println("集合set1移除 000 后的内容:"+set1);
System.out.println("集合set1中是否包含000 :"+set1.contains("000"));
System.out.println("集合set1中是否包含111 :"+set1.contains("111"));
Set set2=new HashSet();
set2.add("111");
set2.addAll(set1);//将set1 集合中的元素全部都加到set2中
System.out.println("集合set2的内容:"+set2);
set2.clear();//清空集合 set1 中的元素
System.out.println("集合set2是否为空 :"+set2.isEmpty());
Iterator iterator = set1.iterator();//得到一个迭代器
while (iterator.hasNext()) {//遍历
Object element = iterator.next();
System.out.println("iterator = " + element);
}
//将集合set1转化为数组
Object s[]= set1.toArray();
for(int i=0;i<s.length;i++){
System.out.println(s[i]);
}
}
}
程序执行的结果为:
1 add true
集合set1的大小:4
集合set1的内容:[222, a, 000, 111]
集合set1移除 000 后的内容:[222, a, 111]
集合set1中是否包含000 :false
集合set1中是否包含111 :true
集合set2的内容:[222, a, 111]
集合set2是否为空 :true
iterator = 222
iterator = a
iterator = 111
222
a
111
从上面的这个简单的例子中,我们可以发现,Set中的方法与直接使用Collection中的方法一样。唯一需要注意的就是Set中存放的元素不能重复。
我们再看一个例子,来了解一下其它的Set的实现类的特性:
package c08;
import java.util.*;
public class SetSortExample {
public static void main(String args[]) {
Set set1 = new HashSet();
Set set2 = new LinkedHashSet();
for(int i=0;i<5;i++){
//产生一个随机数,并将其放入Set中
int s=(int) (Math.random()*100);
set1.add(new Integer( s));
set2.add(new Integer( s));
System.out.println("第 "+i+" 次随机数产生为:"+s);
}
System.out.println("未排序前HashSet:"+set1);
System.out.println("未排序前LinkedHashSet:"+set2);
//使用TreeSet来对另外的Set进行重构和排序
Set sortedSet = new TreeSet(set1);
System.out.println("排序后 TreeSet :"+sortedSet);
}
}
该程序的一次执行结果为:
第 0 次随机数产生为:96
第 1 次随机数产生为:64
第 2 次随机数产生为:14
第 3 次随机数产生为:95
第 4 次随机数产生为:57
未排序前HashSet:[64, 96, 95, 57, 14]
未排序前LinkedHashSet:[96, 64, 14, 95, 57]
排序后 TreeSet :[14, 57, 64, 95, 96]
从这个例子中,我们可以知道HashSet的元素存放顺序和我们添加进去时候的顺序没有任何关系,而LinkedHashSet 则保持元素的添加顺序。TreeSet则是对我们的Set中的元素进行排序存放。
一般来说,当您要从集合中以有序的方式抽取元素时,TreeSet 实现就会有用处。为了能顺利进行,添加到 TreeSet 的元素必须是可排序的。 而您同样需要对添加到TreeSet中的类对象实现 Comparable 接口的支持。对于Comparable接口的实现,在前一小节的Map中已经简单的介绍了一下。我们暂且假定一棵树知道如何保持 java.lang 包装程序器类元素的有序状态。一般说来,先把元素添加到 HashSet,再把集合转换为 TreeSet 来进行有序遍历会更快。这点和HashMap的使用非常的类似。
其实Set的实现原理是基于Map上面的。通过下面我们对Set的进一步分析大家就能更加清楚的了解这点了。
1.5.3 实现原理
Java中Set的概念和数学中的集合(set)一致,都表示一个集内可以存放的元素是不能重复的。
前面我们会发现,Set中很多实现类和Map中的一些实现类的使用上非常的相似。而且前面再讲解Map的时候,我们也提到:Map中的“键值对”,其中的“键”是不能重复的。这个和Set中的元素不能重复一致。我们以HashSet为例来分析一下,会发现其实Set利用的就是Map中“键”不能重复的特性来实现的。
先看看HashSet中的有哪些属性:
再结合构造函数来看看:
通过这些方法,我们可以发现,其实HashSet的实现,全部的操作都是基于HashMap来进行的。我们看看是如何通过HashMap来保证我们的HashSet的元素不重复性的:
看到这个操作我们可以发现HashSet的巧妙实现:就是建立一个“键值对”,“键”就是我们要存入的对象,“值”则是一个常量。这样可以确保,我们所需要的存储的信息之是“键”。而“键”在Map中是不能重复的,这就保证了我们存入Set中的所有的元素都不重复。而判断是否添加元素成功,则是通过判断我们向Map中存入的“键值对”是否已经存在,如果存在的话,那么返回值肯定是常量:PRESENT ,表示添加失败。如果不存在,返回值就为null 表示添加成功。
我们再看看其他的方法实现:
了解了这些后,我们就不难理解,为什么HashMap中需要注意的地方,在HashSet中也同样的需要注意。其他的Set的实现类也是差不多的原理。
至此对于Set我们就应该能够比较好的理解了。
1.6 总结:集合框架中常用类比较
用“集合框架”设计软件时,记住该框架四个基本接口的下列层次结构关系会有用处:
- Collection 接口是一组允许重复的对象。
- Set 接口继承 Collection,但不允许重复。
- List 接口继承 Collection,允许重复,并引入位置下标。
- Map 接口既不继承 Set 也不继承 Collection, 存取的是键值对
我们以下面这个图表来描述一下常用的集合的实现类之间的区别:
接口 | 成员重复性 | 元素存放顺序(Ordered/Sorted) | 元素中被调用的方法 | 基于那中数据结构来实现的 | |
Set | Unique elements | No order | equals() hashCode() | Hash 表 | |
LinkedHashSet | Set | Unique elements | Insertion order | equals() hashCode() | Hash 表和双向链表 |
SortedSet | Unique elements | Sorted | equals() compareTo() | 平衡树(Balanced tree) | |
ArrayList | List | Allowed | Insertion order | equals() | 数组 |
LinkedList | List | Allowed | Insertion order | equals() | 链表 |
Vector | List | Allowed | Insertion order | equals() | 数组 |
HashMap | Map | Unique keys | No order | equals() hashCode() | Hash 表 |
LinkedHashMap | Map | Unique keys | Key insertion order/Access order of entries | equals() hashCode() | Hash 表和双向链表 |
Hashtable | Map | Unique keys | No order | equals() hashCode() | Hash 表 |
TreeMap | SortedMap | Unique keys | Sorted in key order | equals() compareTo() | 平衡树(Balanced tree) |
2 练习
撰写一个Person class,表示一个人员的信息。令该类具备多辆Car的信息,表示一个人可以拥有的车子的数据,以及:
Certificate code: 身份证对象
name: 姓名
cash: 现金
List car; 拥有的汽车,其中存放的是Car对象
boolean buycar(car); 买车子
boolean sellcar(Person p);//把自己全部的车子卖给别人
boolean buyCar(Car car,Person p);//自动查找卖车的人p是否有买主想要买的车car,如果有就买,并返回true ,否则返回false
viod addCar(car);//把某辆车送给方法的调用者。
String toString();//得到人的信息
并撰写第二个Car class 具备的属性:
String ID; //ID车牌号
cost //价格
color //颜色
Person owner; //车子的拥有者
toString();//得到汽车的信息
equals();//比较车子是否同一俩汽车,ID相同则认为相同
在另外一个Market类里面,进行车子的买卖。并保留所有交易人员的的信息到一个HashMap中,我们可以通过身份证号来查找到对应的人员的信息。同时所有的车子种类都在市场中进行注册,即车子的信息使用一个Set来保存
属性:
HashMap people;//存放交易人员的信息。Key为身份证号,value为Person对象
方法:
static boolean sellCar(Person p1 ,Car car1, Person p2);
//p1 将car1卖给 p2 。并在该方法中记录效益人的信息到people 中。
撰写类Certificate 表示身份证:
属性:
Id;//号码
方法:
equals();//比较两个身份证是否同一个,ID相同则认为相同
hashCode();//正确编写hashCode 方法
场景:
一个叫Bob的人:身份证:310 现金:30000。
有一辆车子:ID:001,红色,价格:50000的车子;
一个叫Tom的人:身份证:210 现金:70000,
有一辆车子: 颜色:白色,ID:003, 价格:25000。
一个叫King 的人:身份证:245 现金:60000,
有2辆车子: 颜色:白色,ID:005, 价格:18000。
颜色:红色,ID:045, 价格:58000。
Tom 买了Bob的车子.他就拥有了2辆汽车
King 把ID=005的车子买给了Bob
最后各人的信息如何?
3 附录:排序
为了用“集合框架”的额外部分把排序支持添加到 Java 2 SDK,版本 1.2,核心 Java 库作了许多更改。像 String 和 Integer 类如今实现 Comparable 接口以提供自然排序顺序。对于那些没有自然顺序的类、或者当您想要一个不同于自然顺序的顺序时,您可以实现 Comparator 接口来定义您自己的。
为了利用排序功能,“集合框架”提供了两种使用该功能的接口:SortedSet 和 SortedMap。
Comparable 接口
在 java.lang 包中,Comparable 接口适用于一个类有自然顺序的时候。假定对象集合是同一类型,该接口允许您把集合排序成自然顺序。
compareTo() 方法比较当前实例和作为参数传入的元素。如果排序过程中当前实例出现在参数前,就返回某个负值。如果当前实例出现在参数后,则返回正值。否则,返回零。这里不要求零返回值表示元素相等。零返回值只是表示两个对象排在同一个位置。
在 Java 2 SDK,版本 1.2 中有十四个类实现 Comparable 接口。下表展示了它们的自然排序。虽然一些类共享同一种自然排序,但只有相互可比的类才能排序。
类 | 排序 |
BigDecimal, BigInteger, Byte, Double, Float, Integer, Long, Short | 按数字大小排序 |
Character | 按 Unicode 值的数字大小排序 |
CollationKey | 按语言环境敏感的字符串排序 |
Date | 按年代排序 |
File | 按系统特定的路径名的全限定字符的 Unicode 值排序 |
ObjectStreamField | 按名字中字符的 Unicode 值排序 |
String | 按字符串中字符 Unicode 值排序 |
创建您自己的类 Comparable 只是个实现 compareTo() 方法的问题。通常就是依赖几个数据成员的自然排序。您自己的类也应该覆盖 equals() 和 hashCode() 以确保两个相等的对象返回同一个散列码。
Comparator 接口
若一个类不能用于实现 java.lang.Comparable,您可以提供自己的 java.util.Comparator 行为。如果您不喜欢缺省的 Comparable 行为,您照样可以提供自己的 Comparator。
Comparator 的 compare() 方法的返回值和 Comparable 的 compareTo() 方法的返回值相似。在此情况下,如果排序时第一个元素出现在第二个元素之前,则返回一个负值。如果第一个元素出现在后,那么返回一个正值。否则,返回零。与 Comparable 相似,零返回值不表示元素相等。一个零返回值只是表示两个对象排在同一位置。由 Comparator 用户决定如何处理。如果两个不相等的元素比较的结果为零,您首先应该确信那就是您要的结果,然后记录行为。
为了演示,您会发现编写一个新的忽略大小写的 Comparator,代替使用 Collator 进行语言环境特定、忽略大小写的比较会更容易。这样的一种实现如下所示:
class CaseInsensitiveComparator implements Comparator {
public int compare(Object element1, Object element2) {
String lowerE1 = ((String)element1).toLowerCase();
String lowerE2 = ((String)element2).toLowerCase();
return lowerE1.compareTo(lowerE2);
}
}
因为每个类在某些地方都建立了 Object 子类,所以这不是您实现 equals() 方法的必要条件。实际上大多数情况下您不会去这样做。切记该 equals() 方法检查的是 Comparator 实现的等同性,不是处于比较状态下的对象。
Collections 类有个预定义的 Comparator 用于重用。调用 Collections.reverseOrder() 返回一个 Comparator,它对逆序实现 Comparable 接口的对象进行排序。