首先先来说一下为什么在有数组的情况下还出现了一个和数组拥有差不多方法的集合类。其实是为了方便对多个对象的操作,就用集合对对象进行存储。
那又有问题了,数组也可以存储对象,为什么不用数组?因为数组的长度是固定的并且数组存储的类型必须是同一种类型的。因此集合就诞生了,其长度是可变的,并且集合存储的元素可以是不同类型。
集合因为内部的数据结构不同,不断地将多种具体集合容器向上抽取共同部分,就形成了集合框架。
我们来看一张集合框架体系的结构图:
可以看出,集合框架主要包含两种类型的容器,即Collection和Map,分别存储一个元素集合以及存储键值对映射。接下来我们先介绍Collection接口。
查看API文档,我们可以看到几个会比较常见的方法,如下所示:
// 添加
boolean add(E e);
boolean addAll(Collection<? extends E> c);
// 删除
boolean remove(Object o);
boolean removeAll(Collection<?> c);
void clear();
// 获取
int size();
Iterator iterator();
// 判断
boolean contains(Object obj);
boolean containsAll(Collection coll);
boolean isEmpty();
// 额外
<T> T[] toArray(T[] a);
对于E泛型后面会提,在这里暂时不讲。
通常来说,获取数组中的元素时可以通过数组角标亦或者对数组循环遍历,但是在Collection中我们并没有发现相类似的描述,只看到了一个非常奇怪的iterator()方法。其实这个方法就是用于获取集合中的元素,而返回的Iterator对象我们称其为“迭代器”亦或者可以称为“游标”。我们发现其类型Iterator是一个接口,那么到底迭代器是怎么实现的?迭代器对象又是如何实现的呢?
迭代器对象其实依赖集合的子类实现的。该怎么理解这句话呢?一个容器它里面装的东西,如果要取容器里的东西,那么是不是就应该取决于该容器,取的行为方式是不是就不一样了?比如说,药瓶装的药,我们要取药时可以直接通过瓶口倒出的方式。而那种锡箔纸包装的药片,我们则是以破坏锡纸片的方式取药。药瓶和锡箔纸都是容器的具体实例,也可以说是容器的子类,那么如何取这个药就需要容器的结构来决定。抽象起来就是,每个集合容器都有自己的数据结构(意思就是容器中的元素是如何放置的),得根据它的特点来取它其中的元素。
迭代器的实现是通过集合子类的内部类来完成的。这句话又是什么意思呢?那么对容器中的元素进行直接访问(直接将药从一个容器中提取),在Java中不就代表着这个迭代器对象是在容器内部完成的实现,因为要想达到一个类直接访问另一个类的内容,内部类就是最快的方式。
这里找到Vector的源码,如下所示:
从代码上我们就能看到上面两句话的体现。查看其他具体的集合容器的代码,可以说迭代器其实就是一个实现了iterator接口的每一个集合容器内部的对象。
接下来就演示一下部分方法的使用,代码如下所示:
public class Demo01 {
public static void main(String[] args) {
Collection coll = new ArrayList();
System.out.println("---addEle----");
addEle(coll);
showOri(coll);
removeEle(coll, "collThree");
showByItr(coll);
}
public static void showByItr(Collection coll) {
/* Iterator itr = coll.iterator();
while (itr.hasNext()) {
System.out.println(itr.next());
}*/
for (Iterator itr = coll.iterator(); itr.hasNext();) {
System.out.println(itr.next());
}
}
public static void removeEle(Collection coll, String str) {
if (coll.contains(str)) {
coll.remove(str);
}
}
public static void showOri(Collection coll) {
System.out.println("toString: " + coll);
}
public static void addEle(Collection coll) {
coll.add("collOne");
coll.add("collTwo");
coll.add("collThree");
coll.add("collFour");
}
}
结果如下所示:
需要注意的一点,循环遍历集合中的元素的方式有两种,while循环遍历以及for循环遍历。一般而言,如果迭代器后续没有任何使用的可能,就用for循环(局部变量特性);如果后续需要再使用迭代器则用while循环。
集合框架体系的共性内容都在Collection接口里,在Collection下面主要分为List接口以及Set接口,先来讲List。
在List接口下还有具体的三个主要的实现类,分别是Vector、ArrayList和LinkedList。我们先讲其父类接口List。
所谓的List其实就是有序的可重复Collection,即序列。所谓的有序并不是指元素根据大小排序,而是元素是按照什么顺序存进去的,就按什么顺序取出来,这和数据结构中的队列是一样的。
List接口将Collection中的方法进行了扩展,比如说可以在序列指定位置插入指定元素,或者可以替换序列中指定位置的元素。下面放上方法列表:
void add(int index, E element);
E get(int index);
ListIterator<E> listIterator();
ListIterator<E> listIterator(int index);
E set(int index, E element);
通过代码演示方法:
public class Demo02 {
public static void main(String[] args) {
List list = new ArrayList();
addEle(list);
System.out.println(list);
System.out.println("----showByItr----");
showByItr(list);
}
public static void showByItr(List list) {
Iterator itr = list.iterator();
while(itr.hasNext()) {
System.out.println(itr.next());
}
}
public static void addEle(List list) {
list.add("listOne");
list.add("listTwo");
list.add("listThree");
// 扩展的方法
list.add(2, "listFour");
list.set(1, "listFive");
}
}
接下来我们想要在使用迭代器的时候对序列进行元素添加的行为,代码如下所示:
public static void showByItr(List list) {
Iterator itr = list.iterator();
while(itr.hasNext()) {
list.add("listSix");
System.out.println(itr.next());
}
}
结果却发现报出了ConcurrentModificationException异常,这是为什么呢?假设集合中有5个元素,当调用iterator()方法时,迭代器只知道集合里有五个元素,那么就将这五个元素遍历。当我们在对集合迭代的过程中,如果对集合进行结构上的更改,就会影响迭代器的遍历而导致产生不可预知的结果。就以上文说的,每个集合容器都有自己的数据结构,得根据它的特点来取它其中的元素。如果特点都改变了,那么又怎么保证原有的取物工具是适用的呢?
通过查看源码就可以证明其特性,如下所示:
private class Itr implements Iterator<E> {
int cursor; // 要访问的元素的下标
int lastRet = -1; // 上一次访问过的元素的下标
int expectedModCount = modCount;
/*
* modCount 表示ArrayList对象被修改次数
* expectedModCount 表示期待的修改次数
*/
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
/*
* 这里会检查在迭代过程中List对象是否被修改了
* fail-fast机制
*/
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
那么到底如何在迭代的时候对序列进行操作呢?查看了Iterator接口发现只有三个方法根本就不能对集合有修改的操作。其实在查看List接口的方法时,其中有一个方法可以获得Iterator的子接口ListIterator,其提供了对集合容器元素的增删查改功能,即如下所示:
void add(E e);
boolean hasNext();
boolean hasPrevious();
E next();
int nextIndex();
E previous();
int previousIndex();
void remove();
void set(E e);
接下来我们再来验证该接口的使用,代码如下所示:
public static void main(String[] args) {
List list = new ArrayList();
addEle(list);
System.out.println(list);
System.out.println("----showByItr----");
uniqueMethod(list);
showByItr(list);
}
public static void uniqueMethod(List list) {
ListIterator itr = list.listIterator();
while (itr.hasNext()) {
if (itr.next().equals("listFour"))
itr.remove();
}
}
结果如下所示:
[listOne, listFive, listFour, listThree]
----showByItr----
listOne
listFive
listThree
接下来我们讲两个实现了List接口的具体集合类ArrayList以及LinkedList。(Vector和ArrayList是一样的数组结构)
ArrayList是一个不同步的集合,其内部结构是一个不能被序列化的对象数组。由于是数组结构,可知查询的速度快,修改速度慢。ArrayList其实和实现List接口的另一个集合Vector大致等同,除去ArrayList是不同步的。
ArrayList的常用方法如下所示:
// 构造方法
ArrayList(); // 默认列表初始容量为10
ArrayList(int initialCapacity); // 指定列表容量
boolean add(E e);
void add(int index, E element);
void clear();
boolean isEmpty();
boolean remove(Object o);
E set(int index, E element);
int size();
以add方法为例,查看其源码,如下所示:
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
关注的地方就在第一行调用的方法,其用于增加修改次数,源码如下所示:
private void ensureCapacityInternal(int minCapacity) {
modCount++;
// 当前elementData的容量不满足要求时就进行扩容
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
其中minCapacity表示数组的最小容量,为当前元素的个数+1。那么当最小容量依旧比数组的大小还要大时,就会进行扩容处理。
对于ArrayList,我们只需要知道其底层是基于数组来实现容量大小动态变化即可。其中还需要注意的地方就是它的两个成员变量,
private transient Object[] elementData;
private int size;
对于数组的length()方法返回的是集合容量,即表示集合最多可以容纳多少个元素。而size则是指集合中实际上存储了多少个元素。
LinkedList也是一个不同步的集合,其内部是双向链表数据结构。由于是链表结构,可知其增删速度很快,而查询速度慢。查看其源码中的成员属性,如下所示:
transient int size = 0;
// 指向链表中的第一个结点,即头结点
transient Node<E> first;
// 指向链表中的最后一个结点,即尾结点
transient Node<E> last;
其中,Node类是LinkedList的私有内部类,通过这个类来存储集合中的元素。代码如下所示:
private static class Node<E> {
E item; // 结点的值
Node<E> next; // 当前结点的下一个结点的引用
Node<E> prev; // 当前结点的前一个结点的引用
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
对于LinkedList需要知道的是可以用其来模拟栈和队列,以队列为例,如下所示:
class Person {
private String name;
private int age;
public Person() {
super();
}
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person [name=" + name + ", age=" + age + "]";
}
}
class Queue {
private LinkedList link;
public Queue() {
link = new LinkedList();
}
public void add(Object obj) {
link.addLast(obj);
}
public Object get() {
return link.removeFirst();
}
public boolean isNull() {
return link.isEmpty();
}
}
public class Demo03 {
public static void main(String[] args) {
Person p = new Person("zhangSan", 18);
Person p1 = new Person("liSi", 20);
Person p2 = new Person("wangWu", 21);
Queue q = new Queue();
q.add(p);
q.add(p1);
q.add(p2);
while (!q.isNull()) {
System.out.println(q.get());
}
}
}
结果如下所示:
需要注意的是,removeLast是获取元素但是附加移除元素的操作。在JDK6以后增加了获取元素但不移除的方法,这里以对比方式列出需要注意的方法,如下所示:
void addFirst(E e); // 将指定元素插入此列表的开头。
void addLast(E e); // 将指定元素添加到此列表的结尾。
boolean offerFirst(E e); // 在此列表的开头插入指定的元素。
boolean offerLast(E e); // 在此列表末尾插入指定的元素。
E getFirst(); // 返回此列表的第一个元素
E getLast(); // 返回此列表的最后一个元素
// ↑ 如果此列表为空,抛出NoSuchElementException异常
// JDK6 ↓ 如果此列表为空,则返回null
E peekFirst(); // 获取但不移除此列表的第一个元素
E peekLast(); // 获取但不移除此列表的最后一个元素
E removeFirst(); // 移除并返回此列表的第一个元素
E removeLast(); // 移除并返回此列表的最后一个元素
// ↑ 如果此列表为空,抛出NoSuchElementException异常
// JDK6 ↓ 如果此列表为空,则返回null
E pollFirst(); // 获取并移除此列表的第一个元素
E pollLast(); // 获取并移除此列表的最后一个元素;如果此列表为空,则返回 null。
最后总结一下Vector、ArrayList以及LinkedList之间的区别:
1. Vector是Java最早期时提供的线程安全的动态数组。Vector内部是使用对象数组来保存数据,可以根据需要来自动地增加容量,当数组满了后,会创建新数组并且拷贝原有数组数据,新数组的大小是原数组大小的两倍。因为其内部元素以数组形式存储,因此适合随机访问的场合
2. ArrayList是非线程安全的动态数组。与Vector相近,也是可以根据需要来调整容量,但是ArrayList是在原有的基础之上增加50%的容量。同Vector一样适合随机访问的场合。
3. LinkedList是Java提供的非线程安全的双向链表。由于是链表的关系,无需调整容量,但随机访问的性能没有前面两个高。
Set是一个不包含重复元素的抽象集合,其内部方法和Collection接口是一样的,那么只需要看其实现类即可。Set接口最主要的三个实现类分别是HashSet、LinkedHashSet以及TreeSet。(LinkedHashSet不打算讲,知道其是有序的HashSet即可)
HashSet是非同步非有序的集合容器,其内部数据结构是哈希表。接下来用代码演示一下HashSet的特点:
public static void main(String[] args) {
HashSet hs = new HashSet();
hs.add("abcd");
hs.add("bcd");
hs.add("Abcd");
hs.add("abcd");
System.out.println(hs);
}
结果如下所示:
从结果上来看,我们可以发现HashSet并没有保存重复的元素,这是怎么实现的呢?其实就是通过对象的hashCode以及equals方法来保证元素唯一性。
如果对象的hashCode值不同,那么不需要再调用equals方法,直接存储到HashSet中;如果对象的hashCode值相同,那么要再次判断对象的equals方法是否为true,如果为true,则视为相同元素,不存入HashSet中。如果为false,则视为不同元素,就存储到HashSet中。
在第一章的时候就说过,不同对象是存在相同的hashCode,必须要再加上equals方法来判断两个对象是否相同。以Person为例,假设我们只重写hashCode方法,不重写equals方法,如下所示:
@Override
public int hashCode() {
return age;
}
结果如下所示:
我们会发现在集合中存在了重复内容的对象,原因是未重写Person类中的equals()方法则调用的是父类Object的equals()方法。两个对象地址值都不同,那当然满足条件可以直接存入集合中。
那么当我们重写equals()方法后,将避免内容相同的对象重复存入HashSet集合中。
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof Person))
throw new ClassCastException();
Person p = (Person)obj;
return this.name.equals(p.name) && this.age == p.age;
}
结果如下所示:
TreeSet底层数据结构是二叉树,其可以对Set集合中的元素进行排序,而它也是不包含重复元素的。需要知道的是,TreeSet判断元素唯一性的方式和HashSet是不同的,TreeSet是根据比较方法的返回结果来判断元素唯一性,需要注意的是这个比较不需要用到hashCode以及equals方法。
那么到底是以什么排序的呢?我们先用String类来演示一下:
public static void main(String[] args) {
TreeSet ts = new TreeSet();
ts.add("abcd");
ts.add("nba");
ts.add("xcz");
ts.add("asd");
Iterator itr = ts.iterator();
while (itr.hasNext()) {
System.out.println(itr.next());
}
}
结果如下:
可以发现其根据自然顺序来排序的,查看API我们可以发现String类实现了一个Comparable接口,并且重写了接口中的compareTo方法,如下所示:
public int compareTo(String anotherString) {
int len1 = value.length;
int len2 = anotherString.value.length;
int lim = Math.min(len1, len2);
char v1[] = value;
char v2[] = anotherString.value;
int k = 0;
while (k < lim) {
char c1 = v1[k];
char c2 = v2[k];
if (c1 != c2) {
return c1 - c2;
}
k++;
}
return len1 - len2;
}
那么从String类的例子我们可以得出,TreeSet对元素进行排序的思路就是可以让元素自身具备比较功能。即让元素实现Comparable接口,覆盖compareTo方法。
查看API文档,我们可以看到对compareTo方法的描述如下:
那么接下来用Person类来演示一下,代码如下所示:
class Person implements Comparable<Person> {
/*..代码省略..*/
@Override
public int compareTo(Person p) {
int temp = this.age - p.age;
return temp == 0 ? this.name.compareTo(p.name) : temp;
}
}
public class Demo05 {
public static void main(String[] args) {
TreeSet ts = new TreeSet();
ts.add(new Person("zhangSan", 19));
ts.add(new Person("zhangXi", 23));
ts.add(new Person("liSi", 22));
ts.add(new Person("wangWu", 19));
Iterator itr = ts.iterator();
while (itr.hasNext()) {
System.out.println(itr.next());
}
}
}
结果如下所示:
需要注意的是,比较的条件可以是多元的,比如说上面的以年龄相同与否为主条件,名字相同与否为附条件。
那么当这个类并不是我们自己创建的并且不具备自然顺序亦或者我们不想要按照对象中具备的自然顺序进行排序的话,又怎么能够再TreeSet中进行排序呢?
这里就需要第二个方法来解决这个问题,我们可以让集合自身具备比较功能。在查看文档的时候,会发现TreeSet有一个构造方法引人注目,如下所示:
// 构造一个新的空 TreeSet,它根据指定比较器进行排序。
TreeSet(Comparator<? super E> comparator);
从构造方法我们就能知道,可以使用一个Comparator来让集合自身具备比较功能。从文档中我们可以看到Comparator是一个接口,那么第二个方法就是:定义一个类实现Comparator接口,覆盖compare方法,将该类对象作为参数传递给TreeSet的构造方法。
即整体代码如下所示:
public class ComparatorByAge implements Comparator<Person> {
@Override
public int compare(Person p0, Person p1) {
int temp = p0.getAge() - p1.getAge();
return temp == 0 ? p0.getName().compareTo(p1.getName()) : temp;
}
}
public class Demo05 {
public static void main(String[] args) {
TreeSet ts = new TreeSet(new ComparatorByAge());
ts.add(new Person("zhangSan", 23));
ts.add(new Person("zhangXi", 23));
ts.add(new Person("liSi", 22));
ts.add(new Person("wangWu", 19));
Iterator itr = ts.iterator();
while (itr.hasNext()) {
System.out.println(itr.next());
}
}
}
结果如下所示:
通过查看TreeSet的源码发现,其内部实现实际上是通过TreeMap的子类NavigableMap来实现的。
对于Map,我们可以称之为映射,用于存储键值对。其中,键必须保证唯一性。查看API文档,我们可以发现Map接口中定义了以下方法:
// 添加
V put(K key, V value); // 返回前一个和key相关联的值,若没有则返回null
// 删除
void clear();
V remove(Object key);
// 获取
V get(Object key);
Set<K> keySet();
Set<Map.Entry<K,V>> entrySet();
// 判断
boolean containsKey(Object key);
boolean containsValue(Object value);
稍微提一下,为什么获取方法中有两个方法返回的是Set集合,因为要保证键的唯一性,而Set集合正好满足这个要求。
对于Map,其最主要的实现类有三个:Hashtable、TreeMap以及LinkedHashMap。根据先前的学习经验以及实验,第一种是无序的,而TreeMap、LinkedHashMap是有序的。那么到底这三者有什么区别呢?区别如下所示:
对于这三个实现类,这里只着重讲HashMap。
查看HashMap的源码(JDK1.7),如下所示:
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{
transient Entry<K,V>[] table;
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
}
不难发现HashMap实际上可以看作是数组和链表结合组成的复合结构。其实就是数组被分为一个个桶,然后通过哈希值来决定键值对在数组的位置。如果数组位置上存有对象,则比对两个对象的key值是否相等,如果相等就覆盖对应的value值;如果不相等则形成链表结构。即如下图所示:
但是这有个弊端,就是在JDK1.7插入时是将后来的放到链表的头,那么如果要查找的数据正好是最后一个,当链表特别长时就会导致效率降低。
那么在JDK8后,就将HashMap的存储结构改成了由数组、链表以及新增的红黑树构成。当链表大小超过一定阈值,链表就会转换成数树。并且在JDK8后,将插入方法改成了尾插法。查看源码,可以知道阈值为8,如下所示:
static final int TREEIFY_THRESHOLD = 8;
这里稍微提一下吧,印象中有个美团面试题,是问为什么阈值设置为8而不是其他的值。第一个是作者根据概率统计而选择8位阈值,因为容器中节点分布在哈希桶中的频率遵循了泊松分布,桶的长度超过8的概率很小。第二个是其实在链表小于某个阈值的时候,会将树还原成链表,那么当还原链表以及转换树的阈值分别为6和8时,中间有一个值7可以防止链表和树之间频繁转换。(为什么是7在源码中有体现)
这里稍微多说一句,HashMap树化本质上是基于安全考虑的。因为在元素存储过程中,如果一个对象哈希值冲突,都被放置到同一个桶里,则会形成一个链表,而链表查询是线性的,会影响到性能。那么就可以利用这一点而去编写恶意代码构成哈希碰撞拒绝服务器攻击,因此本质上其实就是为了安全考虑的。
接着继续看JDK1.8中的源码,如下所示:
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
从上面的非拷贝构造函数的实现来看,HashMap并没有在创建对象的时候初始化其内部的数组,也就是说,HashMap是按照懒加载的原则初始化。那么我们再去查看其添加方法,如下所示:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果HashMap里的成员table为空,就调用resize()方法初始化table
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/*
* 判断条件中的运算是键值对在哈希表中的位置的位运算
* 通过(n-1)&hash计算值作为tab下标,并令p表示tab[i],即该链表第一个结点的位置
* p为空说明tab[i]上没有元素,那么就创建新结点并且返回给tab[i]
*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//p不为空情况 p可能为链表结点、红黑树结点
else {
Node<K,V> e; K k;
// 哈希值相同并且键相同则让p赋值给e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 哈希值不同时,如果结点类型为红黑树的结点,就添加到红黑树中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// p为链表结点但要判断插入后是链表还是转红黑树
else {
// 统计链表中结点的个数
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 把新结点插入到链表的末尾
p.next = newNode(hash, key, value, null);
/*
* 如果链表结点的个数大于阈值-1,就将链表转换成红黑树
* binCount 并不包含新节点,所以判断时要将阈值-1
* 如果满足转换条件就将链表转成红黑树
*/
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
// onlyIfAbsent表示键是否相同。当键相同时,不修改已存在的值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 当数组中含有的键值对数目大于阈值就会进行扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
从源码中可以看到,当HashMap为空时,会在putValue()方法通过resize()方法初始化它。resize()方法除了初始化功能以外还能在容量不满足需求的时候进行扩容。
在HashMap中自己定义了一个计算hashCode的另一个方法hash(),如下所示:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
它这里是将高位数据移位到低位进行了异或运算,这是因为有些计算出的哈希值差异主要在高位,而HashMap里的哈希寻址是忽略掉容量以上的高位的,因此可以避免哈希碰撞问题的产生。
接下来我们再看负责多个功能的resize()方法,如下所示:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// 数组长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 阈值
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 原数组长度大于设置的最大容量(2^30)时,就将阈值设为Integer.MAX_VALUE
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // 带参初始化
newCap = oldThr;
else { // 默认无参初始化
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
/*
* loadFactor是哈希加载因子(默认0.75)
* 16 * 0.75 = 12 表示可以放12个键值对
*
*/
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr; //将阈值更新
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 原有table有数据,则将数据复制到新的table中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 旧链表迁移新链表,实际上是对对象的内存地址进行操作
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
这里暂时对resize源码就解释到这,接下来讲一下容量以及负载因子。
容量和负载因子非常重要,其决定了可用的桶的数量,空桶太多会浪费空间,如果太满又影响操作的性能。因此,如果能够预先知道HashMap要存取的键值对数量,可以考虑预先设置合适的容量大小。在前面的代码上能够知道预先设置的容量需要满足
容量 > 元素数量 / 负载因子
而对于负载因子,如果没有特殊需求,默认用JDK中设置的默认值(0.75)。如果需要调整,建议不超过默认值,会降低HashMap的性能。也不建议过于太小的负载因子,否则会导致频繁的扩容开销产生。至于为什么是0.75,则是因为对于使用链表法的哈希表来说,查找一个元素的平均时间是O(n+1),其中n为链表的长度。因此加载因子越大,空间利用度就高,这也就说明了链表的长度越长,查询效率越低。
集合框架工具类是一个包装类。它包含有各种有关集合操作的静态多态方法。此类不能实例化,服务于Java的Collection框架。工具类总共有两种:Collections以及Arrays,这里主要讲Collections。
首先来看一下主要的方法:
// 根据元素自然顺序排序
static <T extends Comparable<? super T>> void sort(List<T> list);
// 根据比较器排序
static <T> void sort(List<T> list, Comparator<? super T> c);
// 随机排序
static void shuffle(List<?> list);
static void shuffle(List<?> list, Random rnd);
// 逆序
static <T> Comparator<T> reverseOrder();
static <T> Comparator<T> reverseOrder(Comparator<T> cmp);
// 根据自然顺序查找
static <T> int binarySearch(List<? extends Comparable<? super T>> list, T key);
// 根据比较器排序查找
static <T> int binarySearch(List<? extends T> list, T key, Comparator<? super T> c)
// 不同步集合转同步集合
static <T> Collection<T> synchronizedCollection(Collection<T> c)
static <T> List<T> synchronizedList(List<T> list)
static <K,V> Map<K,V> synchronizedMap(Map<K,V> m)
static <T> Set<T> synchronizedSet(Set<T> s)
这里只演示二分查找以及同步操作,代码如下所示:
public class Demo01 {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("acd");
list.add("xcd");
list.add("ab");
list.add("defhg");
// 在二分查找前必须排序
Collections.sort(list);
System.out.println(list);
// 如果找不到则返回 (-(插入点) - 1)
System.out.println("find:" + Collections.binarySearch(list, "da"));
}
}
结果如下所示:
这里需要解释一下二分查找这个方法的返回值:当搜索的键包含在集合里,那么就返回键的索引;否则返回(-(插入点)-1)。那什么是插入点呢?所谓的插入点,就是将键插入列表的那一点,即第一个大于搜索的键的元素的索引。比如说,现在要查找"da"这个元素,排序过后的集合中大于"da"的首个元素为"defhg",那么插入点即"defhg"的索引2,那返回的值就是-3。
这样的设计就能保证当且仅当键被找到时,返回的值必定大于等于0。该方法源码如下:
private static <T> int indexedBinarySearch(List<? extends Comparable<? super T>> list, T key)
{
int low = 0;
int high = list.size()-1;
while (low <= high) {
int mid = (low + high) >>> 1;
Comparable<? super T> midVal = list.get(mid);
int cmp = midVal.compareTo(key);
if (cmp < 0) // 当查找的键小于中间元素时
low = mid + 1;
else if (cmp > 0) // 当查找的键大于中间元素时
high = mid - 1;
else
return mid; // key found
}
return -(low + 1); // key not found
}
那对于实现让非同步的集合支持同步,就是将每个基本方法,如add、get等,通过synchronized添加基本的同步支持。
这里稍微提一下Java内部的一些排序算法。
最后再补充一个JDK9新增加的特性:在List接口中增加了一堆静态工厂方法,通过ImmutableCollections类,批量添加集合以及满足了线程安全的需求。比如说
ArrayList<String> list = new ArrayList<>();
list.add("abcd");
list.add("efgh");
// JDK9以后可以使用静态工厂方法,如下所示
List<String> simpleList = List.of("abcd","efgh");
System.out.println(simpleList);
通过在线编译来演示一下这个功能结果如下所示:
需要说明的就是,这个方法创建的List对象都是具有不可变性的。除了List接口以外,Set接口以及Map接口都加入了这个静态方法。
前面因为内容的主次关系,没有过多地述说集合框架里的一些重要的源码。接下来先讲HashMap中的源码(这里主研究JDK8)。
找到HashMap的源码,从源码中可以看到有一个非常重要的属性:
/**
* 第一次使用时初始化,并根据需会调整大小。
* 当分配大小时,大小总是2的幂数。
*/
transient Node<K,V>[] table;
那其实就说明了HashMap是由一个Node数组构成,然后每个Node元素包含了一个键值对。因为我们可以看到在HashMap中有一个内部类Node,其有如下属性:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
其实从名字上我们就已经见名知意了,hash属性是每一个Node对象的哈希值,key是键值对中的键,value是键值中的值,而next就是相当于一个指针,指向了下一个Node对象。这个Node对象其实是当有哈希冲突情况发生时,HashMap就会用相同的哈希值相对应存储的Node对象,然后再通过next去指向新的相同哈希值的Node对象的引用。
在前面的时候提HashMap是懒加载模式,那么在构造方法中主要做了两件事,就是初始化了两个属性loadFactor(加载因子)和threshold(边界值)。
/**
* 要调整大小的下一个大小值 (初始容量 * 负载因子)
*/
int threshold;
// 间接设置哈希表的内存空间大小 默认值为0.75
final float loadFactor;
接下来开始讲HashMap中的put方法,在前面的时候其实省略了一个部分,即
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
在前面的时候已经大致地讲了putVal方法,现在要讲的则是hash()方法中的算法机制,即如下所示:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
看到这个方法的时候需要思考一个问题:为什么明明已经有了Object类的hashCode方法还要再自己再创建一个新的计算hashCode方法?
在先前的第一章说过,Object类中的计算hashCode方法其实是有些许问题的。那么其次,我们可以尝试通过假设法来分别通过公式(n-1)&hashCode计算得到两个元素的hash。比如说,假设要添加两个元素a和b,HashMap中的数组长度恰好是16。然后我们假设
a.hashCode = 11011100000011101001011001100000
& ( n - 1 ) = 15 = 0000000000000000000000000001111
-> hash = 0
b.hashCode = 10010011100011100011110010100000
& ( n - 1 ) = 15 = 0000000000000000000000000001111
-> hash = 0
因此在这两个原因的基础之上,做了进一步的处理:将hashCode值无符号右移16位,然后对位异或运算。
在前面讲putVal源码的时候,其中有一个判断条件决定了Node在HashMap存储的位置。即如下所示:
/*
* 判断条件中的运算是键值对在哈希表中的位置的位运算
* 获取数组中第length个链表,如果链表为空,则创建新结点添加进去
*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 即(n - 1) & hash
前面说过,公式中的n代表了哈希表的长度,而哈希表的长度一般情况为2的n次幂,这样就恰好地可以保证计算到的索引值总是在哈希表长度以内,也就是在table数组的索引以内。
但其实这个公式大有看点,首先,需要了解一件事,大多数的哈希算法为了使元素分布均匀,都是用的取模运算(即n%hash)。而在这个哈希算法中却用了比模运算效率更高的按位与运算符。那么到底如何将%运算转换为&运算呢?
其实特别简单,我们都知道数值运算在计算机中都是以二进制的形式,那么我们将一个十进制数用二进制来表示,即如下所示:
X n X n − 1 X n − 2 . . . X 1 X 0 = X n ∗ 2 n + X n − 1 ∗ 2 n − 1 + . . . + X 1 ∗ 2 1 + X 0 ∗ 2 0 X_nX_{n-1} X_{n-2}...X_1X_0 = X_n*2^n + X_{n-1}*2^{n-1}+...+X_1*2^1+X_0*2^0 XnXn−1Xn−2...X1X0=Xn∗2n+Xn−1∗2n−1+...+X1∗21+X0∗20
接下来再让其除以 2 k 2^k 2k的数,得到以下公式:
X n X n − 1 X n − 2 . . . X 1 X 0 2 k = X n ∗ 2 n 2 k + X n − 1 ∗ 2 n − 1 2 k + . . . + X 1 ∗ 2 1 2 k + X 0 ∗ 2 0 2 k \frac{X_nX_{n-1} X_{n-2}...X_1X_0}{2^k} = \frac{X_n*2^n}{2^k} + \frac{X_{n-1}*2^{n-1}}{2^k}+...+\frac{X_1*2^1}{2^k}+\frac{X_0*2^0}{2^k} 2kXnXn−1Xn−2...X1X0=2kXn∗2n+2kXn−1∗2n−1+...+2kX1∗21+2kX0∗20
那么当我们想求余数就特别容易理解了,当 k ∈ [ 0 , n ] k\in[0,n] k∈[0,n]之间,舍掉比k大的n次幂,保留下来比k小的数总和就是余数。当 k > n k>n k>n时,余数为整个数。
这里用一个例子来演示一下过程:
例子:23 % 4 = 3
23 = 0b10111 = 1*2^4 + 1*2^3 + 1*2^2 + 1*2^1 + 1*2^0
4 = 2^2 => n=2
不可以整除的部分为1*2^1以及1*2^0,那么
余数就为2^1+2^0 = 3
那么其实在计算机中所谓的加减乘除运算都是通过移位来操作的,这样我们就可以得到一个结论:一个二进制数对一个 2 n 2^n 2n的数取余,就这个二进制数右移n位,移掉的n位即为余数。说白了就是我们只需要拿到二进制数的低n位即可。
那又如何获取到这个右移出的n位数呢?其实就是通过按位与运算获取的,让二进制数与 2 n − 1 2^{n-1} 2n−1进行按位与运算即可得到余数。
这里就涉及到按位与运算可以获取n位二进制数的机制, 2 n 2^n 2n的数的二进制都是第n+1位为1,余下位为0。那么只需要对其减一即可得到第n+1位为0其余位为1的数,与运算即可得到余数。
所以在HashMap中的表现就是:
当 l e n g t h = 2 n 时 , m 当length=2^n时,m 当length=2n时,m % l e n g t h = m length = m length=m & ( l e n g t h − 1 ) (length - 1) (length−1)
需要注意的是,只有当长度满足 2 n 2^n 2n才能用按位与。
所以其实这也就是为什么设置初始容量时最好取2的整数次幂的原因,就是为了减少哈希冲突,提高查询效率。如果不是设置2的整数次幂,HashMap也会自行调整到比初始化值大且最近的一个2的幂作为初始容量。
接下来对HashMap的扩容继续延伸一下。在前面的时候对resize源码进行了一些注释,HashMap的扩容使用的是二次幂扩容,也就是说新数组的长度是原数组的两倍。因此,元素要么在原位置,要么就是在原位置移动两次幂的位置。
以扩容前和扩容后进行对照,如下所示:
这里省略了余下位数,只以8位显示
这里假设初始容量为16,有一个元素的hash为01011011
其存放的位置计算如下:
hash = 0101 1011
& n - 1 = 0000 1111
pos = 0000 1011
当HashMap进行扩容操作,将长度扩为原长度的二次幂,即32,则新位置为
hash = 0101 1011
& n - 1 = 0001 1111
pos = 0001 1011
我们将这两个旧数组的位置和新数组的位置做一个异或运算,可以发现其值正好为16
oldPos = 0101 1011
^ newPos = 0001 1011
result = 0001 0000
从上面的对照我们可以确定结论是正确的。那么我们再来看resize源码中的最有意思的一段代码,如下所示:
/* 不移位的链表 */
Node<K, V> loHead = null, loTail = null;
/* 移位的链表 */
Node<K, V> hiHead = null, hiTail = null;
Node<K, V> next;
/* 旧链表迁移新链表,实际上是对对象的内存地址进行操作 */
do
{
next = e.next;
/*
* 表示元素的位置没有发生变化
* 将桶中的头结点添加到loHead和loTail
* 后续添加如果还有不移位的结点,就向尾部继续插入结点
*/
if ( (e.hash & oldCap) == 0 )
{
if ( loTail == null )
loHead = e;
else
loTail.next = e;
loTail = e;
}
/* 元素的位置发生了变化 */
else {
if ( hiTail == null )
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
}
while ( (e = next) != null );
/* 遍历结束后,将不移位的链表添加到对应的链表数组中 */
if ( loTail != null )
{
loTail.next = null;
newTab[j] = loHead;
}
/* 编译结束后,将移位的链表添加到对应的链表数组中 */
if ( hiTail != null )
{
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
为什么说它有意思呢?因为它会按原链表的长度同计算过的hash做按位与运算的结果来分两条链表,再将两条链表分到数组的不同位置上。相比于JDK7来说,更改了插入链表结点的方法为尾插法,并且不会产生转移前后链表顺序倒置问题。
这里稍微讲一下为什么判断条件是hash&oldCap。刚刚在前面的时候讲过HashMap是通过hash&(n-1)公式来决定元素位置,其实位置就是取hash值的后n位,那 2 n 2^n 2n的二进制形式都是第n+1位是1而余下位都是0,那么通过按位与运算可以取出hash中第n+1位数,然后将其判断0还是1就能知道其位置是否改变。
就以上面的对照中的数为例子,如下所示:
数组扩容前:
数组长度为16(2^4),元素hash为01011011,原先位置为00001011
数组扩容后:
数组长度为32(2^5),元素hash为01011011,新的位置为00011011
对比旧新位置的二进制数,我们不难发现第五位是相异的,
那么只要将原先的hash值和原有的数组的长度作按位与运算即可得到第n位的值。
因为2^n的二进制数除了第n位以外都是0,而位置又是根据hash&(n-1)得到的。那么当扩容时,只是n往上加了1,算出来的位置变化的只是第n位,只需要判断第n位的值是否为1还是0即可知道位置有没有改变。
HashMap的源码就先解析到这里吧。
'''
原本想要在这里顺便把集合框架中涉及到的排序算法写一下
后来想着还是以后放到算法的系列中讲吧
集合框架体系涉及的东西非常多,有兴趣可以继续往下了解
在JDK8的时候出现了Lambda表达式以及Stream API
这里暂时不予以概述 未来在JDK8特性的时候再统一讲
'''