本文源码基于jdk1.8
java 中常见的集合 list map set 其中list 和set都是collection的之类,map不是,是键值对应容器
常见的map 集合有 hashmap hashTable linkedHashMap ConcurrentHashMap
值是key -value 键值对的形式
比较:
key-value是否可为null | 线程安全 | 是否有序 | 实现方式 | 继承类 | 实现类 | 效率 | |
hashMap | 可 | 不安全 | 无序 | 数组加链表,1.8以后增加红黑树 | AbstractMap | Map Cloneable Serializable | 高 |
hashTable | 非 | 安全 | 无序 | 和hashMap 一样也是散列表 | Dictionary | Map Cloneable Serializable | 低 |
linkedHashMap | 可 | 不安全 | 有序 | 在hashMap基础上加了双向的链表 | HashMap | Map Serializable | 高 |
ConcurrentHashMap | 可 | 安全 | 无序 | 分多个桶,减少开销 | AbstractMap | ConcurrentMap Serializable | 高 |
hashMap
特点: key 唯一不允许重复,允许key,value 为null 的情况 线程不安全,存值无序
原理:数组加链表(1.8以前),1.8之后添加了红黑树,基于hash表的map接口实现,
阈值(边界值)> 8 并且桶位数(数组长度)大于 64,才将链表转换为红黑树,变为红黑树的目的是为了高效的查询;
引入红黑树的原因:无论hash如何取值,也无法保证百分百的均匀分布元素,当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就是O(n),完全失去了它的优势。
针对这种情况,jdk1.8 中引入了红黑树(查找时间复杂度为 O(logn))来优化这个问题。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。
初始化数组大小16 最大值1<<30 初始负载因子 0.75
- 容量:容量为数组的长度,亦即桶的个数,默认为16 ,最大为2的30次方,当容量达到64时才可以树化。
- 装载因子:装载因子用来计算容量达到多少时才进行扩容,默认装载因子为0.75。当 HashMap 里面容纳的元素已经达到 HashMap 数组长度的 75% 时,表示 HashMap
太挤了,需要扩容,而扩容这个过程涉及到 rehash、复制数据等操作,非常消耗性能。所以开发中尽量减少扩容的次数,可以通过创建HashMap 集合对象时指定初始容量来尽量避免。原因:当负载因子越大,接近于1,数组中存放的数据就越多,越挤,会导致查询速度变慢,相反如果太小是0.4或者更低,会导致过小就开始扩容,导致利用率太低。所以为了兼顾两方面,同时从最终的实验数据得出最佳的数值0.75; - 树化:树化,当容量达到64且链表的长度达到8时进行树化,当链表的长度小于6时反树化。
- 当链表长度低于6会从红黑树转化成链表
第一次当我们put数据的时候,先重写hashcode 的值,得到hash值,然后将数据存在数组的对应下标位置。当再次put数据的时候,也是先进行上面的操作,找到对应位置,如果该桶的位置已经存在值了,则比较hash值,如果不相同,则下面生成一个链表,用来存放,如果相同,
则比较数值,
值数据也相同:直接覆盖,
数据不同:从该桶位的链表开始,一直往下比,直到出现不同的时候,便存在不同的地方的下一个位置,如果这个时候链表长度超过了8,那么链表就会转化成红黑树
扩容,在不断的添加数据的时候,如果刚好达到阀值,同时我们要添加的数据位置不为空,则需要扩容,每次扩容是原来的大小的2倍。
继承体系:
- HashMap 实现了Cloneable接口,可以被克隆。
- HashMap 实现了Serializable接口,属于标记性接口,HashMap 对象可以被序列化和反序列化。
- HashMap 继承了AbstractMap,父类提供了 Map 实现接口,具有Map的所有功能,以最大限度地减少实现此接口所需的工作。
关于红黑树:
红黑树是一个平衡的二叉搜索树,同时他只要求部分地达到平衡,任何不平衡都会在三次旋转之内解决。故而期增删的效率相对AVL高,查询效率相对较低。
红黑树每个节点都有存储位,来表示节点的颜色颜色是红或者黑。
特点:每个节点是红色或者黑色
根节点是黑色
每个叶子节点都是黑色 叶子节点是只为空的节点(NIL或者null)
如果一个节点是红色,则他的子节点必须是黑色
从一个节点到其左右子树叶子节点的所有路径上 包含相同数目的黑节点,确保没有一条路径会比其他路径长两倍,因而,红黑树是相对接近平衡的二叉树。
下面是右旋操作 ,左旋则刚好相反。
当长度大于8 的时候,红黑树的效率比链表要高,
小于8的时候,效率要低,多以才用红黑树这个数据结构来优化性能。
扩容机制: 上源码
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
final Node<K,V>[] resize() {
//把旧的table 赋值个一个变量
Node<K,V>[] oldTab = table;
//获取旧的tabel的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧的阈值
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
//判断数组的长度是否大约等于最大值
if (oldCap >= MAXIMUM_CAPACITY) {
//如果数组的长度达到了最大值,那么就不在进行扩容,直接返回,不管了任由hash冲突
threshold = Integer.MAX_VALUE;
return oldTab;
//把旧的数组长度左移一位(也就是乘以2),然后判断是否小于最大值,并且判断旧的数组长度是否大于等于默认的长度16
}else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//如果条件成立就把旧的阈值左移一位复制给新的阈值
newThr = oldThr << 1; // double threshold
}//如果就的数组长度小于0并且旧的阈值大于0
else if (oldThr > 0) // initial capacity was placed in threshold
//就把旧的阈值赋值给新的数组长度(初始化新的数组长度)
newCap = oldThr;
else { // zero initial threshold signifies using defaults
//使用默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如果新的阈值等于0
if (newThr == 0) {
//那么就重新计算新的阈值上限
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;
//已完成新的数组扩容,开始把就的数据移动到新的数组中,通过循环遍历
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
//如果没有子元素那么说明是下面不是一个链表,直接通过 hash&(新的数组长度-1)计算出新的位置,把就的数据放入新的位置
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
//有多个数据并且不是树那么该节点上放的是链表
//这里是java1.8很精妙的地方,如果 e.hash& 旧的数组长度 如果等于0
那么该数据的位置没有发生变化,还在原来的索引位置上,如果不等于0 那么就在该值就在 (原来的索引位置+旧的数组长度)的位置上,
这里重新创建了两个节点,在原来位置上的放入loHead中,在新的位置上的放入
hiHead 中,最后把这两组数据放入新的数组中即可。(这里的精妙之处是不用重新计算每一个数据的hash,就可以把旧的数据放入新的数组中去)
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;
}
hashTable
HashTable 与 HashMap 一样,也是链表散列,存储键值对,但 HashTable 继承了 Dictionary 类,实现了 Map、Clonable、Serializable 接口
初始化大小 11 负载因子0.75
public Hashtable() { this(11, 0.75f); }
方法加了synchronized 线程安全 但是效率较低
put 过程:
HashTable的put过程
1、判断value不能为null,若为null抛出异常-》hashtable中value不能为null
2、通过key进行hash获取到key该存储的索引位置
3、该索引位置的链表进行遍历,获取key是否存在(key存在条件 hash相等且通过key.equals判断相等)
4、在存在该key的情况下,将value值进行更新且直接返回
5、key不存在则进行新节点插入逻辑
5.1、扩容考虑:entry节点个数大于阈值 (count>threshold)进行扩容
5.2、新容量大小为:2*table.length+1
5.3、将原哈希表中的数据全部进行重新hash到新的hash表中
5.4、更新插入的key的新的位置
5.5、找到新节点位置,创建entry实体通过头插入将元素插入
和hashMap 的区别:
相同点
1、底层数据结构都为数组+链表
2、key都不能重复
3、插入元素有不能保证插入有序
4、哈希过程通过key进行hash
不同点:
1、安全性问题:
HashMap不能保证线程安全
HashTable能保证线程
2、继承关系:
HashMap继承自AbstractMap
HashTable继承自Dictionary
3、null值问题
HashMap的key和value都可以为null
HashTable的key和value都不能为null
4、扩容方式
HashMap按照2table.length
HashTable按照2table.length+1
5、默认值
HashMap默认数组大小为16
HashTable默认数组大小为11
6、hash算法不同
7、效率不同
HashMap在单线程小效率高
HashTable在单线程小效率低
linkedHashMap
hashMap的子类,所以hashMap的属性他基本都有,
区别在于,他是有序的;只是加了指针,把元素串联了起来
原理:双向链表 加hash
ConcurrentHashMap
主要分为1.7版本和1.8版本以后:
1.7版本 采用的的分段锁的机制
存储结构如下图:
ConcurrnetHashMap 由很多个 Segment 组合,而每一个 Segment 是一个类似于 HashMap 的结构,所以每一个 HashMap 的内部可以进行扩容。但是 Segment 的个数一旦初始化就不能改变,默认 Segment 的个数是 16 个,你也可以认为 ConcurrentHashMap 默认支持最多 16 个线程并发。
初始化逻辑。
必要参数校验。
校验并发级别 concurrencyLevel 大小,如果大于最大值,重置为最大值。无惨构造默认值是 16.
寻找并发级别 concurrencyLevel 之上最近的 2 的幂次方值,作为初始化容量大小,默认是 16。
记录 segmentShift 偏移量,这个值为【容量 = 2 的N次方】中的 N,在后面 Put 时计算位置时会用到。默认是 32 - sshift = 28.
记录 segmentMask,默认是 ssize - 1 = 16 -1 = 15.
初始化 segments[0],默认大小为 2,负载因子 0.75,扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容。
由于 Segment 继承了 ReentrantLock,所以 Segment 内部可以很方便的获取锁,put 流程就用到了这个功能。
tryLock() 获取锁,获取不到使用 scanAndLockForPut 方法继续获取。
计算 put 的数据要放入的 index 位置,然后获取这个位置上的 HashEntry 。
遍历 put 新元素,为什么要遍历?因为这里获取的 HashEntry 可能是一个空元素,也可能是链表已存在,所以要区别对待。
如果这个位置上的 HashEntry 不存在:
如果当前容量大于扩容阀值,小于最大容量,进行扩容。
直接头插法插入。
如果这个位置上的 HashEntry 存在:
判断链表当前元素 Key 和 hash 值是否和要 put 的 key 和 hash 值一致。一致则替换值
不一致,获取链表下一个节点,直到发现相同进行值替换,或者链表表里完毕没有相同的。
如果当前容量大于扩容阀值,小于最大容量,进行扩容。
直接链表头插法插入。
如果要插入的位置之前已经存在,替换后返回旧值,否则返回 null.
1.8的结构
放弃了segment,引入了红黑树:
不再是之前的 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。当冲突链表达到一定长度时,链表会转换成红黑树。
/** * Initializes table, using the size recorded in sizeCtl. */ private final Node<K,V>[] initTable() { Node<K,V>[] tab; int sc; while ((tab = table) == null || tab.length == 0) { if ((sc = sizeCtl) < 0) Thread.yield(); // lost initialization race; just spin else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if ((tab = table) == null || tab.length == 0) { int n = (sc > 0) ? sc : DEFAULT_CAPACITY; @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; table = tab = nt; sc = n - (n >>> 2); } } finally { sizeCtl = sc; } break; } } return tab; }
从源码中可以发现 ConcurrentHashMap 的初始化是通过自旋和 CAS 操作完成的。里面需要注意的是变量 sizeCtl ,它的值决定着当前的初始化状态。
-1 说明正在初始化
-N 说明有N-1个线程正在进行扩容
表示 table 初始化大小,如果 table 没有初始化
表示 table 容量,如果 table 已经初始化。
put 方法
/** Implementation for put and putIfAbsent */ final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode()); int binCount = 0; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { V oldVal = null; synchronized (f) { if (tabAt(tab, i) == f) { if (fh >= 0) { binCount = 1; for (Node<K,V> e = f;; ++binCount) { K ek; if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } Node<K,V> pred = e; if ((e = e.next) == null) { pred.next = new Node<K,V>(hash, key, value, null); break; } } } else if (f instanceof TreeBin) { Node<K,V> p; binCount = 2; if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } } } } if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } } } addCount(1L, binCount); return null; }
根据 key 计算出 hashcode 。
判断是否需要进行初始化。
即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
如果都不满足,则利用 synchronized 锁写入数据。
如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
总结:
Java7 中 ConcruuentHashMap 使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个 Segment 都是一个类似 HashMap 数组的结构,它可以扩容,它的冲突会转化为链表。但是 Segment 的个数一但初始化就不能改变。
Java8 中的 ConcruuentHashMap 使用的 Synchronized 锁加 CAS 的机制。结构也由 Java7 中的 Segment 数组 + HashEntry 数组 + 链表 进化成了 Node 数组 + 链表 / 红黑树,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。
有些同学可能对 Synchronized 的性能存在疑问,其实 Synchronized 锁自从引入锁升级策略后,性能不再是问题,有兴趣的同学可以自己了解下 Synchronized 的锁升级。