辛苦准备两三天,转载希望指明原处。
http://blogs.xzchain.cn
java容器是每个java开发者必须打交道的东西,简单来说容器就是可以装载其他java对象的对象。自jdk1.2开始JCF(Java Collections Framework)为java开发者提供了通用的容器。
思考下为什么需要容器呢?
《thinking in java》书中说:“如果一个程序只包含固定数量的且生命周期都已知的对象,那么这是一个非常简单的数据。”但是事实上,我们平时接触的程序都不是如此简单的,很多程序都是在运行时才知道需要创建什么对象、创建多少对象,因此很可能我们需要在任意时刻任意位置创建任意数量的对象。因此,不能依靠创建命名的引用持有每一个对象,因为不确定性,我们必须要动态的创建对象,保存对象(其实是对象的引用)。
基于上述原因(之一),java提供了一套集合类容器,基本类型包括List、Set、Queue和Map。与编译器支持的数组不同,java容器可以动态调节自己的大小,因此编程中可以将任意数量的对象放置到容器中。借助java泛型这个“语法糖”,java容器能够容纳任何类型的对象,总结下容器主要优点有:
在讨论具体细节前,先看一张完整的集合类图(参考《thinking in java》):
图里描述了java容器类库,包括抽象类和遗留的构建,反复看懂有助于理解整个集合框架,图里没包括进去的主要有:
标红的部分就是接下来重点介绍的实现。
在说具体容器之前,有必要先了解一下迭代器。作为一种设计模式,迭代器给我们提供了遍历容器中元素的方法, Iterator是作为一个接口存在的,它定义了迭代器所具有的功能,接口如下:
package java.util;
public interface Iterator {
boolean hasNext();
E next();
void remove();
}
迭代器只能通过容器本身得到,每个容器都通过内部类实现了自己的迭代器
因此可以这样遍历一个容器:
ArrayList<String> list = new ArrayList<String>();
//省略初始化list……
……
//从list得到其迭代器
Iterator<String> iterator = list.iterator();
while(iterator.hasNext()) {
//
String element = iterator.next();
System.out.println(element);
}
如果只是向前遍历List,并不打算修改List对象本身,使用foreach语法会更加简洁。
for(String e : list){
System.out.println(e);
}
List实现了将元素维护在特定的序列中,有两种类型的List:ArrayList和LinkedList,总体来说,ArrayList的随机访问效率较好,但是插入、删除元素较慢;LinkedList提供了优化的顺序访问,随机访问逊色于ArrayList,但插入、删除的代价较低。
ArrayList是顺序容器,底层通过数组实现,允许放入null值。每个ArrayList都有一个容量capacity,表示底层实现数组的大小,当添加元素的时候,如果capacity不够,会自动增加数组的大小。
对于ArrayList而言,size(),isEmpty(),get(),set()方法的时间复杂度是常数时间,add()方法开销和插入的位置有关,addAll()方法开销和添加的元素数量成正比,其余方法都是线性时间完成。
(1)get()
public E get(int index) {
rangeCheck(index);
return (E) elementData[index];
}
该方法根据下标直接从底层数组取出对应的值,rangeCheck方法用来下标越界检查,由于底层数组是Object [],因此返回时要进行类型转换。
(2)set()
public E set(int index, E element) {
rangeCheck(index);
E oldValue = elementData[index];
elementData[index] = element;
return oldValue;
}
set方法先从底层数组取出之前的值,然后将新值的引用设置到指定位置,返回结果是set之前的值。
(3)add()
ArrayList末尾添加元素的方法是add(E e),指定位置插入元素的方法是add(int index, E e),在添加的过程中,可能会存在capacity容量不足的问题,在每次添加前都要进行容量检查,如果容量不足,需要使用grow()扩容方法进行自动扩容
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
//原来的1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
//扩展空间完成后复制
elementData = Arrays.copyOf(elementData, newCapacity);
}
在空间容量足够,或者扩容之后,添加元素的过程很好理解:
add(E e)方法直接在底层数组末尾中添加元素e即可,数组size+1;
add(int index, E e)需要先对插入位置之后的元素进行移动,然后完成插入操作,数组size+1,方法有线性时间复杂度。
(4)addAll()
ArrayList允许一次插入多个元素,在末尾添加的方法是addAll(Collection< ? extends E> c)方法,从指定位置添加元素的方法为addAll(int index, Collection< ? extends E> c)方法。其实现思路和add方法类似。时间复杂度和插入位置以及插入的元素数量有关。
(5)remove()
ArrayList的remove也有两个实现方法,remove(int index),该方法删除指定位置的元素,remove(Object o)删除第一个满足o.equals(elementData[index])的元素。
public E remove(int index) {
//下标越界检查
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
//生成新的数组
System.arraycopy(elementData, index+1, elementData, index, numMoved);
//清除引用,让GC起作用
elementData[--size] = null;
//返回删除之前的值
return oldValue;
}
LinkedList实现了List接口,因此它也是一个顺序容器,但是同时也可以将其用作栈、队列或双端队列(实现了Deque接口)。LinkedList底层通过双向链表实现。如图所示:
双向链表的实现依赖于内部类Node这个数据结构,源码如下:
private static class Node {
E item;
Node next;
Node prev;
Node(Node prev, E element, Node next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
LinkedList的first和last引用分别指向链表的第一个和最后一个元素,链表为空时,first和last指向null。
LinkedList中和下标相关的操作都是线性时间,在头部和尾部删除元素只需要常数时间
(1)get()
get(int index)返回值的下标处的元素。
public E get(int index) {
//下标越界检查
checkElementIndex(index);
//返回下标处的值(Node中的item)
return node(index).item;
}
ps:其中node(int index)函数根据index找到该位置的元素,查找方向取决于index靠近头部还是尾部,判断条件为 index < (size >> 1)。
(2)set()
set(int index, E element)将指定下标处的元素修改成指定值。先利用node(int index)找到下标引用,然后修改Node的item,方法返回修改前的值。
public E set(int index, E element) {
checkElementIndex(index);
Node x = node(index);
E oldVal = x.item;
//直接替换新值
x.item = element;
return oldVal;
}
(3)add()
add()方法包含两个,add(E e)方法在链表末尾插入元素,借助last指针,末尾插入只需常数时间;add(int index, E element)方法在指定下标处插入元素,需要先查找位置再执行插入。
add(E e)方法如图:
借助图理解其源码:
public boolean add(E e) {
final Node l = last;
final Node newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
//若原链表为空,插入的即为第一个元素
first = newNode;
else
//将last指向新的Node
l.next = newNode;
size++;
return true;
}
add(int index, E element)方法如图:
借助图理解其源码:
主要比add(E e)多了校验下标和使用node函数查找下标位置,其他的插入实现类似。
public void add(int index, E element) {
//下标越界检查(index >= 0 && index <= size)
checkPositionIndex(index);
if (index == size)
//插入位置是末尾,或列表为空
add(element);
else{
//先根据index找到要插入的位置
Node succ = node(index);
final Node pred = succ.prev;
final Node newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
}
(4)remove()
remove方法包括删除指定下标处的元素remove(int index),和删除与指定元素相等的第一个元素remove(Object o),相等根据o.equals(x.item)判断。其实现基本和add方法相逆,只需要修改链表指针指向要删除的节点的后继节点,并且将后继节点指向删除元素的前驱节点。两种方法都要查找,因此具有线性时间复杂度,remove方法通过unlink(Node< E > x)完成。
E unlink(Node<E> x) {
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) {//边界条件1:删除的是第一个元素
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {//边界条件2:删除的是最后一个元素
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null;//let GC work
size--;
return element;
}
HashMap 和 HashSet的区别并不大,HashSet的实现就是依赖于HashMap,利用适配器模式(HashSet里面包含了一个HashMap)。因此搞清楚HashMap基本也就理解了HashSet。
HashMap实现了Map接口,该容器不保证元素顺序,会根据需要对元素进行重新hash,不同时间对同一个HashMap迭代元素顺序可能会不同。
HashMap的底层实现是数组+链表,借助hash表,处理hash冲突使用的是冲突链表方式(另一种解决冲突方法为开放地址法)。
(ps:jdk1.8对hashmap进行了很多优化,当冲突链表长度大于8时使用红黑树解决冲突,从而在链表过长是提高查找效率)
HashMap的结构如图(手动画图……很久没用笔,有点丑):
这里的两个关键方法时hashCode()和equals(), hashCode方法决定了对象会被放到哪个bucket,超过一个对象放入相同的bucket时即为冲突,equals方法用于区别冲突链表中的对象是否是同一个。我们可以根据需求自定义这两个方法实现自定义的对象hash。
PS:举个例子,我们在给物品归类的时候,一个好习惯就是把同一类物品(例如都是Cd光盘)放到一个统一的储物盒,这个储物盒就是一个bucket,同一类的判断方法在程序里就是hashCode计算哈希值。这样当我们想找CD的时候,只需要找到CD储物盒(hashCode),然后找到具体的CD(equals)。
根据上图,还可以看出是否产生冲突和选定的hash函数以及HashMap的大小有关系,在对HashMap进行迭代时,首先要对整个table进行遍历找到相应的bucket然后再对整个冲突链表遍历,因此对需要频繁迭代的场景,不宜将HashMap初始大小设置过大。
HashMap有连个关键参数,初始容量(inital capacity)、负载因子(load factor),初始容量指定了table的大小,这个参数和哈希函数会影响到冲突的频繁性,负载因子用来指定自动扩容的临界值,当entry(存放键值对的对象)的数量超过capacity*load_factor时,会进行自动扩容和重新哈希。
结合上面所示,理解了关键的思想后再看具体实现就容易很多了:
(1)get()
get(Object key)方法根据key返回对应的value,该方法调用getEntry(Object key)得到对于的entry,然后返回entry.getValue();
上面已经分析过了,基本思想是通过hash函数找到bucket的下标,然后遍历冲突链表使用equals方法找到对应的entry。
final Entry getEntry(Object key) {
......
int hash = (key == null) ? 0 : hash(key);
for (Entry e = table[hash&(table.length-1)];//得到冲突链表
e != null; e = e.next) {//依次遍历冲突链表中的每个entry
Object k;
//依据equals()方法判断是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
由于HashMap的table的长度是2的指数,所以table.length-1二进制低位全是1,与hash(k)相与等价于取余操作。因此代码里的hash(k)&(table.length-1)等价于hash(k)%(table.length)。
(2)put()
put(K key, V value)方法将指定的键值对添加到map中,方法首先查找原始数组是否已经包含要插入的值(查找过程类似getEntry),如果有,直接返回。如果没有找打,通过addEntry方法插入新的entry。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);//容量不够时自动扩容,并重新哈希
hash = (null != key) ? hash(key) : 0;
bucketIndex = hash & (table.length-1);//计算要插入的bucket
}
//在冲突链表头部插入新的entry(使用头插法)
Entry e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
(3)remove()
remove(Object key)删除key对应的entry,首先查找该entry(查找过程类似getEntry),然后使用removeEntryForKey(Object key)删除。
final Entry<K,V> removeEntryForKey(Object key) {
......
int hash = (key == null) ? 0 : hash(key);
int i = indexFor(hash, table.length);//hash&(table.length-1)
Entry<K,V> prev = table[i];//得到冲突链表
Entry<K,V> e = prev;
while (e != null) {//遍历冲突链表
Entry<K,V> next = e.next;
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {//找到要删除的entry
modCount++; size--;
if (prev == e) table[i] = next;//删除的是冲突链表的第一个entry
else prev.next = next;
return e;
}
prev = e; e = next;
}
return e;
}
只要理解了最上面的HashMap结构图,插入和删除的理解并不难,对链表的插入和删除具体实现也很方便,只需要更改指针即可,这里不展开说了。
HashSet借助HashMap实现了无重复元素的集合,对HashSet的函数调用本质上是对HashMap调用。
public class HashSet
{
......
private transient HashMap map;//HashSet里面有一个HashMap
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
public HashSet() {
map = new HashMap<>();
}
......
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
......
}
这里就挑选一个add方法分析下如何保证无重复元素的吧,其实思路也不难,我们知道HashMap中的key是肯定唯一的不会重复的,因此HashSet利用了这一特点,在add的时候调用HashMap的put方法,map.put(e, PRESENT)如果有返回值说明HashMap中已经存在该元素,插入失败,如果返回null表明HashMap还没有要插入的元素,因此插入才会成功。
类似HashMap和HashSet,LinkedHashSet的实现也是借助LinkedHashMap使用适配器模式实现的。分析了LinkedHashMap也就理解了LinkedHashSet,LinkedHashMap是HashMap的子类,二者区别在于LinkedHashMap在HashMap的基础上采用双向链表将冲突链表的entry联系起来,这样保证了元素的迭代顺序跟插入顺序相同。LinkedHashMap的图就不画了,大家脑补在HashMap结构图的基础上,冲突链表加入了双向链表的元素(before、after、next,其中next用于保证entry的链表结构,before、after用于完成双向链表的定义),同时引入了header指向双向链表的头部(哑元)。这样LinkedHashMap在遍历的时候不同于HashMap需要先遍历整个table,LinkedHashMap只需要遍历header指向的双向链表即可,因此LinkedHashMap的迭代时间只和entry数量相关。其他的包括初始容量、负载因子以及hashCode、equals方法基本和HashMap一致。
(1)get()
思路同HashMap的get方法。见上文
(2)put()
put(K key,V value)方法插入过程类似HashMap,不同的是这里的插入有两个含义:
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);// 自动扩容,并重新哈希
hash = (null != key) ? hash(key) : 0;
bucketIndex = hash & (table.length-1);/计算要插入的bucket
}
// 1.在冲突链表头部插入新的entry
HashMap.Entry old = table[bucketIndex];
Entry e = new Entry<>(hash, key, value, old);
table[bucketIndex] = e;
// 2.在双向链表的尾部插入新的entry
e.addBefore(header);
size++;
}
其中addBefore将新的entry插入到header前,使新Entry成为链表的最后一个元素。
private void addBefore(Entry existingEntry) {
after = existingEntry;
before = existingEntry.before;
before.after = this;
after.before = this;
}
(3)remove()
remove(Object key)删除过程类似HashMap的remove,不同的是这里的删除也有两个含义:
final Entry<K,V> removeEntryForKey(Object key) {
......
int hash = (key == null) ? 0 : hash(key);
int i = indexFor(hash, table.length);// hash&(table.length-1)
Entry<K,V> prev = table[i];// 得到冲突链表
Entry<K,V> e = prev;
while (e != null) {// 遍历冲突链表
Entry<K,V> next = e.next;
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {// 找到要删除的entry
modCount++; size--;
// 1. 将e从对应bucket的冲突链表中删除
if (prev == e) table[i] = next;
else prev.next = next;
// 2. 将e从双向链表中删除
e.before.after = e.after;
e.after.before = e.before;
return e;
}
prev = e; e = next;
}
return e;
}
LinkedHashSet实现借助了LinkedHashMap,不再累赘。
public class LinkedHashSet<E>
extends HashSet<E>
implements Set<E>, Cloneable, java.io.Serializable {
......
// LinkedHashSet里面有一个LinkedHashMap
public LinkedHashSet(int initialCapacity, float loadFactor) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
......
public boolean add(E e) {//适配器方法转换
return map.put(e, PRESENT)==null;
}
......
}
TreeMap 和 TreeSet是什么关系呢,看到这里相比大家都有了答案,没错TreeSet的也是借助TreeMap实现的的适配器模式的体现。TreeMap懂了TreeSet也了解了,TreeMap实现了SortedMap接口,会根据key的大小对Map中的元素进行排序。key的大小判断在没有传入比较器Comparator的情况下通过自身的自然顺序比较。TreeMap底层通过红黑树实现
红黑树是一颗近似平衡的二叉查找树,任何一个节点的左右子树高度差不会超过二者中较低的那个的一倍,TreeMap的每个节点即为一个键值对,红黑树的特性如下:
每次对红黑树操作后都要使其满足上述条件,调整红黑树的策略主要是
1.改变节点颜色;2.改变树的结构(左旋操作、右旋操作)
根据红黑树的特点,TreeMap的containsKey(),get(),put(),remove()的时间复杂度都为log(n)
(1)get()
get(object key)返回指定key对于的value,该方法调用getEntry的到entry,然后返回entry.value,借助红黑树是二叉查找树,查找过程只需log(n)时间复杂度
final Entry getEntry(Object key) {
......
if (key == null)//不允许key值为null
throw new NullPointerException();
Comparable super K> k = (Comparable super K>) key;//使用元素的自然顺序
Entry p = root;
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)//向左找
p = p.left;
else if (cmp > 0)//向右找
p = p.right;
else
return p;
}
return null;
(2)put()
put(K key, V value)方法将指定的键值对添加到map中,先进行查找(类似getEntry),如果要插入的元素已经存在则直接返回,否则在红黑树中插入entry,插入完成后若是破坏了红黑树约束需要进行调整。
public V put(K key, V value) {
......
int cmp;
Entry parent;
if (key == null)
throw new NullPointerException();
Comparable super K> k = (Comparable super K>) key;//使用元素的自然顺序
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0) t = t.left;//向左找
else if (cmp > 0) t = t.right;//向右找
else return t.setValue(value);
} while (t != null);
Entry e = new Entry<>(key, value, parent);//创建并插入新的entry
if (cmp < 0) parent.left = e;
else parent.right = e;
fixAfterInsertion(e);//调整
size++;
return null;
}
调整方法即上文中所说的两种(1.改变颜色;2.改变结构)
fixAfterInsertion具体实现包括颜色改变,左旋函数(rotateLeft),右旋函数(rotateRight),这里不展开说了。
(3)remove()
remove(Object key)删除指定key对于的entry,也会先进行查找(getEntry),然后调用deleteEntry(Entry< K,V> entry)删除对应的entry,删除之后破坏红黑树约束时需要调整。
private void deleteEntry(Entry<K,V> p) {
modCount++;
size--;
//若删除点p的左右子树都非空,需要用p的后继节点(大于x的最小的节点)代替p,然后删除
if (p.left != null && p.right != null) {
Entry<K,V> s = successor(p);// 后继
p.key = s.key;
p.value = s.value;
p = s;
}
Entry<K,V> replacement = (p.left != null ? p.left : p.right);
// 若删除点p只有一棵子树非空,用p的后继节点代替p,然后删除
if (replacement != null) {
replacement.parent = p.parent;
if (p.parent == null)
root = replacement;
else if (p == p.parent.left)
p.parent.left = replacement;
else
p.parent.right = replacement;
p.left = p.right = p.parent = null;
if (p.color == BLACK)
fixAfterDeletion(replacement);// 调整
} else if (p.parent == null) {
root = null;
} else { //若删除点p的左右子树都为空,直接删除
if (p.color == BLACK)
fixAfterDeletion(p);// 调整
if (p.parent != null) {
if (p == p.parent.left)
p.parent.left = null;
else if (p == p.parent.right)
p.parent.right = null;
p.parent = null;
}
}
}
其中successor用于计算某节点后继节点,其思路为,如果t的右孩子不空,则t的后继是其右子树中最小的那个元素;如果t的右孩子为空,则t的后继是其第一个向左走的祖先
static TreeMap.Entry successor(Entry t) {
if (t == null)
return null;
else if (t.right != null) {// t的右孩子不空,则t的后继是其右子树中最小的那个元素
Entry p = t.right;
while (p.left != null)
p = p.left;
return p;
} else {// t的右孩子为空,则t的后继是其第一个向左走的祖先
Entry p = t.parent;
Entry ch = t;
while (p != null && ch == p.right) {
ch = p;
p = p.parent;
}
return p;
}
}
fixAfterDeletion函数用于在删除操作执行后调整红黑树结构。(其实只有在删除点是黑色的时候才会调用调整函数),这里同上,也不展开说了,感兴趣的可以学习一下《算法》对红黑树的调整。
TreeSet的实现借助了TreeMap,不再累赘
public class TreeSet<E> extends AbstractSet<E>
implements NavigableSet<E>, Cloneable, java.io.Serializable
{
......
private transient NavigableMap m;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
public TreeSet() {
this.m = new TreeMap();// TreeSet里面有一个TreeMap
}
//方法适配
......
public boolean add(E e) {
return m.put(e, PRESENT)==null;
}
......
}
WeakHashMap是基于弱引用的HashMap,它里面的entry随时可能呗GC,因此对WeakHashMap的调用结果是不确定的,WeakHashMap的使用主要集中在缓存场景
弱引用区别于强引用,如果一个对象具有弱引用,在GC线程扫描内存区域的过程中,不管当前内存空间足够与否,都会回收内存。如利用jdk中的ThreadLocal就是弱引用的。
WeakHashMap的实现与HashMap大同小异。
HashMap不是线程安全的,HashTable是线程安全的,但是其安全性由全局锁保证,因此效率很低。而ConcurrentHashMap 是将锁的范围细化来实现高效并发的。 基本策略是将数据结构分为一个一个 Segment(每一个都是一个并发可读的 hash table, 即分段锁)作为一个并发单元。 为了减少开销, 除了一处 Segment 是在构造器初始化的, 其他都延迟初始化。 并使用 volatile 关键字来保证 Segment 延迟初始化的可见性问题。
jdk1.8对ConcurrentHashMap做了一些改进:
改进一:取消segments字段,直接采用transient volatile HashEntry< K,V>[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。
改进二:将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。在冲突链表长度过长的情况,如果还是采用单向链表方式,那么查询某个节点的时间复杂度为O(n);因此,对于个数超过8(默认值)的列表,jdk1.8中采用了红黑树的结构,那么查询的时间复杂度可以降低到O(logN),可以改进性能。
这里面其实还引出了一些问题,比如使用同步方法进行包装的具体实现方式,以及包装后的容器效率问题等,只能下回分解了~
参考资料:
1.《thinking in java》第四版
2.《算法》 第四版
3. http://www.cnblogs.com/CarpenterLee/p/5440428.html