LinkedList
LinkedList 双向链表,实现了 Deque (double ended queue),Deque 又继承自 Queue 接口。这样 LinkedList 就具备了队列的功能,因为Dequeue是双向的,所以可以用来替换Stack结构
LinkedList 继承自 AbstractSequentialList,AbstractSequentialList 提供了一套基于顺序访问的接口,如根据索引获取、插入元素。
查找时进行了优化:
Node<E> node(int index) { /* * 则从头节点开始查找,否则从尾节点查找 * 查找位置 index 如果小于节点数量的一半, */ if (index < (size >> 1)) { Node<E> x = first; // 循环向后查找,直至 i == index for (int i = 0; i < index; i++) x = x.next; return x; } else { Node<E> x = last; for (int i = size - 1; i > index; i--) x = x.prev; return x; } }
linkedList.get(i) 随机访问的性能很差,这种方式每获取一个元素,LinkedList 都需要从头节点(或尾节点)进行遍历
ArrayList
构造时,如果不指定初始容量,默认的底层数组是一个{},存放元素时才进行判断扩容
自动扩容时,按照原大小的1.5倍扩容。没有自动的缩容机制,可以通过手动触发trimToSize来实现,能缩小至元素数量
fail-fast 快速失败机制
该机制被触发时,会抛出并发修改异常ConcurrentModificationException
创建迭代器后,除了迭代器的remove、add方法来修改集合之外,其他任意的修改,会触发快速失败机制
迭代器创建时记录当前集合的一个modCount,每次调用iter.next()时,都会检测当前集合的modCount与迭代器创建时记录的modCount是否一致,不一致则抛出ConcurrentModificationException
List<String> a = new ArrayList<String>(); a.add("1"); a.add("2"); for (String temp : a) { System.out.println(temp); if("1".equals(temp)){ a.remove(temp); } }
以上代码运行输出1,却没有输出2,也不会抛出异常。
foreach只是迭代器的语法糖,编译器转换后的代码是迭代器,所以代码可以转换为:
while (it.hasNext()) { String temp = it.next(); System.out.println("temp: " + temp); if("1".equals(temp)){ a.remove(temp); } }
hasNext方法会判断当前的游标值cursor是否不等于外部集合的size
每次next调用时,cursor值都会变成当前元素索引 + 1,所以当以上1被删除之后,集合size就由2变成1了,cursor的值为1,与size相同,所以hasNext就返回false,循环就结束了,所以2没输出。
HashMap
HashMap 允许 null 键和 null 值,在计算哈键的哈希值时,null 键哈希值为 0。HashMap 并不保证键值对的顺序,非线程安全
JDK 1.8 中对HashMap的优化:引入了红黑树优化过长的链表、重写resize方法、移除了 alternative hashing 相关方法、避免重新计算键的 hash 等
数据结构 = 数组 + 链表/红黑树
构造方法中和ArrayList一样,并没有做实际的底层数据初始化,而是延迟到放入元素时才初始化
- initialCapacity HashMap 初始容量,默认的初始容量为 1 << 4 16
- loadFactor 负载因子,默认为0.75f,可以设置为 > 1。值太小,查询速度很快,但浪费很多空间,典型的空间换时间;太大,节省空间,但冲突概率高,链表长,查询速度慢,一般使用默认值
- threshold 当前 HashMap 所能容纳键值对数量的最大值,超过这个值,则需扩容,这个值根据前两个值相乘得来
initialCapacity和loadFactor可以通过构造函数进行调整
找到大于或等于 cap 的最小2的幂:
static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
查找流程
根据key的hash值进行快速求余,得到桶bucket的位置,然后对桶进行判断
first = tab[(n - 1) & hash]
HashMap.Node类:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; }
底层数组为Node类型,红黑树节点为TreeNode类型,查找时会判断如果是TreeNode类型,则调用红黑树的查找方法,否则使用链表遍历查询,当key的hash(调用key.hashCode,如果key==null,)相同并且key相等时==比较或 equals比较,才认为找到了链表中的目标
do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null);
计算hash的方法,说明允许key为null,此时的hash为0
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
上面有个抑或操作。回顾定位bucket时,使用求余,即只有hash的低位参与了运算,高位没参与。以上的抑或中,可以使高位与低位参与运算,以此加大低位信息的随机性,变相的让高位数据参与到计算中
关于遍历
一般都是对 HashMap 的 map.keySet()或 map.entrySet()进行遍历,多次对 HashMap 进行遍历时,遍历结果顺序都是一致的,但这个顺序和插入的顺序一般都是不一致的。
遍历策略:先从桶数组中找到包含链表节点引用的桶。然后对这个桶指向的链表进行遍历。遍历完成后,再继续寻找下一个包含链表节点引用的桶,找到继续遍历。找不到,则结束遍历
插入数据
首先肯定是先定位要插入的键值对属于哪个桶,定位到桶后,再判断桶是否为空。如果为空,则将键值对存入即可。如果不为空,则需将键值对接在链表最后一个位置(1.8使用尾插法),或者更新键值对
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 初始化桶数组 table,table 被延迟到插入新数据时再进行初始化 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 如果桶中不包含键值对节点引用,则将新键值对节点的引用存入桶中即可 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { // 对链表进行遍历,并统计链表长度。binCount理解为链表的元素的“索引”,第一个节点为0,下一个为1 for (int binCount = 0; ; ++binCount) { // 链表中不包含要插入的键值对节点时,则将该节点接在链表的最后 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 如果链表长度大于或等于树化阈值,则进行树化操作 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } // 条件为 true,表示当前链表包含要插入的键值对,终止遍历 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // 判断要插入的键值对是否存在 HashMap 中 if (e != null) { // existing mapping for key V oldValue = e.value; // onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值 if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; // 键值对数量超过阈值时,则进行扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
扩容机制
在 HashMap 中,桶数组的长度均是2的幂,扩容阈值大小为桶数组长度与负载因子的乘积。当 HashMap 中的键值对数量超过阈值时,进行扩容。
按当前桶数组长度的2倍进行扩容,也就意味着扩容阈值也变为原来的2倍。数组的大小最大为2^30,超过这个长度不再进行扩容
扩容之后,要重新计算键值对的位置,并把它们移动到合适的位置上去。jdk8核心resize代码(移除了部分代码):能保留原次序
// 处理每个bucket for (int j = 0; j < oldCap; ++j) { // 获取到链表头节点 Node<K,V> e = oldTab[j]; // 打算将一条链表划分为两条,需要以下4个指针指向 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; // 遍历链表,并将链表节点按原顺序进行分组 do { next = e.next; // 分组的依据是 节点的hash & 旧数组长度 是否等于 0 ,是则分到lo链表,否则分到hi链表 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); // 将分组后的链表放到新的数组中 // lo链表放在和原来旧数组相同的索引中,hi链表放在旧索引 + 旧数组长度对应的位置中 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } }
以上是通过数学规律推导出来的,如:17 % 5 = 2,扩容之后 17 % 10 = 7 = 5 + 2
LinkedHashMap
LinkedHashMap 在 HashMap 基础上,通过维护一条双向链表,解决了 HashMap 不能随时保持遍历顺序和插入顺序一致的问题。
节点类型:
static class Entry<K,V> extends HashMap.Node<K,V> { Entry<K,V> before, after; Entry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, next); } }
所谓的维护双向链表,就是在每个节点上加了前后节点的指向而已,从而记录了放入次序