集合容器
容器就是可以装载其他java对象的对象。从jdk1.2开始,Java提供了很多通用的容器。
思考下为什么需要容器呢?
因为很多程序都是在运行时才知道需要创建什么对象、创建多少对象,我们需要在任意时刻任意位置创建任意数量的对象。正是因为它的不确定性,我们必须要动态的创建对象,保存对象(其实是对象的引用)。
Java提供了一套集合类容器,基本类型包括List、Set、Queue和Map。与编译器支持的数组不同,java容器可以动态调节自己的大小,因此编程中可以将任意数量的对象放置到容器中。借助java泛型这个“语法糖”,java容器能够容纳任何类型的对象,总结下容器主要优点有:
- 降低学习难度,降低编程难度
- 提高API间的互操作性
- 降低设计和实现相关API的难度
- 提高程序性能,增加程序重用性
集合类图如下:
图里没包括进去的主要有:
- Queue接口及其实现,包括优先权队列PriorityQueue和各种BlockingQueue
- ConcurrentMap接口及其实现ConcurrentHashMap
- CopyOnWriteArrayList和CopyOnWriteArraySet
- 为使用enum而提供的Set和Map的特殊实现,EnumSet和EnumMap
标红的部分就是接下来重点介绍的实现。
一、迭代器
说具体容器之前,有必要先了解一下迭代器。作为一种设计模式,迭代器给我们提供了遍历容器中元素的方法, Iterator是作为一个接口存在的,它定义了迭代器所具有的功能,接口如下:
package java.util; public interface Iterator<E> { 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); }
二、ArrayList 和 LinkedList
List实现了将元素维护在特定的序列中,有两种类型的List:ArrayList和LinkedList,总体来说,ArrayList的随机访问效率较好,但是插入、删除元素较慢;LinkedList提供了优化的顺序访问,随机访问逊色于ArrayList,但插入、删除的代价较低。
1. 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; }
2. LinkedList详解
LinkedList实现了List接口,因此它也是一个顺序容器,但是同时也可以将其用作栈、队列或双端队列(实现了Deque接口)。LinkedList底层通过双向链表实现。如图所示:
双向链表的实现依赖于内部类Node这个数据结构,源码如下:
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的first和last引用分别指向链表的第一个和最后一个元素,链表为空时,first和last指向null。
LinkedList中和下标相关的操作都是线性时间,在头部和尾部删除元素只需要常数时间。
(1) get()
get(int index)返回值的下标处的元素。
public E get(int index) { //下标越界检查 checkElementIndex(index); //返回下标处的值(Node中的item) return node(index).item; }
其中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<E> 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<E> l = last; final Node<E> 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<E> succ = node(index); final Node<E> pred = succ.prev; final Node<E> 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
HashMap 和 HashSet的区别并不大,HashSet的实现就是依赖于HashMap,利用适配器模式(HashSet里面包含了一个HashMap)。因此搞清楚HashMap基本也就理解了HashSet。
1. HashMap详解
HashMap实现了Map接口,该容器不保证元素顺序,会根据需要对元素进行重新hash,不同时间对同一个HashMap迭代元素顺序可能会不同。
HashMap的底层实现是数组+链表,借助hash表,处理hash冲突使用的是冲突链表方式(另一种解决冲突方法为开放地址法)。
(jdk1.8对hashmap进行了很多优化,当冲突链表长度大于8时使用红黑树解决冲突,从而在链表过长是提高查找效率)
这里的两个关键方法时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<K,V> getEntry(Object key) { ...... int hash = (key == null) ? 0 : hash(key); for (Entry<K,V> 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<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e); size++; }
(3) remove()
put(K key, V value)方法将指定的键值对添加到map中,方法首先查找原始数组是否已经包含要插入的值(查找过程类似getEntry),如果有,直接返回。如果没有找打,通过addEntry方法插入新的entry。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; }
2. HashSet详解
HashSet借助HashMap实现了无重复元素的集合,对HashSet的函数调用本质上是对HashMap调用。
public class HashSet<E> { ...... private transient HashMap<E,Object> 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; } ...... }
我们知道HashMap中的key是肯定唯一的不会重复的,因此HashSet利用了这一特点,在add的时候调用HashMap的put方法,map.put(e, PRESENT)如果有返回值说明HashMap中已经存在该元素,插入失败,如果返回null表明HashMap还没有要插入的元素,因此插入才会成功。
四、LinkedHashMap 和 LinkedHashSet
1. LinkedHashMap
类似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,不同的是这里的插入有两个含义:
- 对于table而言,新的entry插入到指定的bucket时如果产生冲突,使用头插法将entry插入冲突链表头部
- 对于header而言,新的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 } // 1.在冲突链表头部插入新的entry HashMap.Entry<K,V> old = table[bucketIndex]; Entry<K,V> e = new Entry<>(hash, key, value, old); table[bucketIndex] = e; // 2.在双向链表的尾部插入新的entry e.addBefore(header); size++; }
其中addBefore将新的entry插入到header前,使新Entry成为链表的最后一个元素。
private void addBefore(Entry<K,V> existingEntry) { after = existingEntry; before = existingEntry.before; before.after = this; after.before = this; }
(3) remove()
remove(Object key)删除过程类似HashMap的remove,不同的是这里的删除也有两个含义:
- 对于table来说,删除对应bucket中的entry,然后修改冲突链表引用
- 对于header来说,将entry从双向链表删除,然后修改冲突链表该位置前后元素的引用
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; }
2. LinkedHashSet
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
1. TreeMap
TreeMap 和 TreeSet是什么关系呢,TreeSet的也是借助TreeMap实现的的适配器模式的体现。TreeMap实现了SortedMap接口,会根据key的大小对Map中的元素进行排序。key的大小判断在没有传入比较器Comparator的情况下通过自身的自然顺序比较。TreeMap底层通过红黑树实现。
红黑树是一颗近似平衡的二叉查找树,任何一个节点的左右子树高度差不会超过二者中较低的那个的一倍,TreeMap的每个节点即为一个键值对,红黑树的特性如下:
- 每个节点要么是黑色,要么是红色
- 根节点必须为黑色
- 红色节点不能连续,即红色节点的孩子和父亲只能是黑色
- 任何节点到树的末端的任何路径包含的黑色节点个数相同
每次对红黑树操作后都要使其满足上述条件,调整红黑树的策略主要是:
- 改变节点颜色;
- 改变树的结构(左旋操作、右旋操作)
根据红黑树的特点,TreeMap的containsKey(),get(),put(),remove()的时间复杂度都为log(n)
(1) get()
get(object key)返回指定key对于的value,该方法调用getEntry的到entry,然后返回entry.value,借助红黑树是二叉查找树,查找过程只需log(n)时间复杂度。
final Entry<K,V> getEntry(Object key) { ...... if (key == null)//不允许key值为null throw new NullPointerException(); Comparable<? super K> k = (Comparable<? super K>) key;//使用元素的自然顺序 Entry<K,V> 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<K,V> 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<K,V> e = new Entry<>(key, value, parent);//创建并插入新的entry if (cmp < 0) parent.left = e; else parent.right = e; fixAfterInsertion(e);//调整 size++; return null; }
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 <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) { if (t == null) return null; else if (t.right != null) {// t的右孩子不空,则t的后继是其右子树中最小的那个元素 Entry<K,V> p = t.right; while (p.left != null) p = p.left; return p; } else {// t的右孩子为空,则t的后继是其第一个向左走的祖先 Entry<K,V> p = t.parent; Entry<K,V> ch = t; while (p != null && ch == p.right) { ch = p; p = p.parent; } return p; } }
fixAfterDeletion函数用于在删除操作执行后调整红黑树结构。(其实只有在删除点是黑色的时候才会调用调整函数)。
2. TreeSet
public class TreeSet<E> extends AbstractSet<E> implements NavigableSet<E>, Cloneable, java.io.Serializable { ...... private transient NavigableMap<E,Object> 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<E,Object>();// TreeSet里面有一个TreeMap } //方法适配 ...... public boolean add(E e) { return m.put(e, PRESENT)==null; } ...... }
六、WeakHashMap
WeakHashMap是基于弱引用的HashMap,它里面的entry随时可能被GC,因此对WeakHashMap的调用结果是不确定的,WeakHashMap的使用主要集中在缓存场景。
弱引用区别于强引用,如果一个对象具有弱引用,在GC线程扫描内存区域的过程中,不管当前内存空间足够与否,都会回收内存。如利用jdk中的ThreadLocal就是弱引用的。
七、ConcurrentHashMap
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),可以改进性能。
八、集合容器比较
参考
https://blog.csdn.net/starlh35/article/details/79262472
https://blog.csdn.net/P_Doraemon/article/details/80353579