Java7 HashMap
在Java7中,HashMap采用的是数组+链表的形式存储的,(默认bucket数目16,负载因子0.75)
那么依照原HashMap,假设bucket=12的数组对应的链表中有10个元素(此时负载仍没超过0.75,10/16)那么在查找该bucket的过程中最坏的情况是要比较10次。(删除操作也是)
如果当超过负载,就要rehash,那么此过程,就是要对bucket=12中的每一个元素都要重新计算hash值,在放到新的扩容后的bucket中,这里请注意是每个元素。
Java8 HashMap
数组+链表+红黑树
Java8采用了数组+链表+红黑树的存储结构,首次还是按照数组+链表的存储方式,当某个bucket同时满足以下两条时,将其链表转化成红黑树(16,0.75):
- (a)当某个bucket对应的链表元素个数>8
- (b)HashMap总大小(总元素个数)>64
- 如果红黑树中的元素个数<6就将其转化成链表
新结构如图:
扩容:
-
java7 插入在链表头,Java8 插入在链表尾
以下来自敖丙大神。。。
单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。一旦几个线程都调整完成,就可能出现环形链表
使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。
- java7 每个元素重新计算hash值,java 8 新 bucket=旧 bucket + 数组长度
优点
- 除了插入操作以外,所有操作都加快了。原有链表的插入是在表头,就是bucket的位置。而新的插入,要进行红黑树的插入,时间复杂度为O(logn)(n是该bucket对应的元素个数)
- 查找、删除操作红黑树都是O(logn), 而链表是O(n),所以其他的操作都是红黑树优先于链表,也就是Java8更快。
- rehash操作:对于原链表,rehash需要对每个元素重新计算,但是采用红黑树中的元素,新 bucket= 旧bucket+原数组长度
- Java8 链表的插入是在链表尾部,可以在扩容时保证链表元素原来的顺序,不会出现循环链表。
比如图中黄线标记的元素,它的新bucket=4+16=20
final Node<K,V>[] resize() {
。。。
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
。。。
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;
。。。
}
}
return newTab;
}
O(n)到O(logn)的时间开销:
如果恶意程序知道我们用的是Hash算法,则在纯链表情况下,它能够发送大量请求导致哈希碰撞,然后不停访问这些key导致HashMap忙于进行线性查找,最终陷入瘫痪,即形成了拒绝服务攻击(DoS)。但是使用后者之后,能够有效的防止此类攻击,对该攻击所产生的影响会减弱,同时也让HashMap性能的可预测性有了增强。
注意:既然有了排序树,则key一定需要排序,那么Key类就需要有Comparable接口进行辅助排序。这是早期版本中key所不具有的。
Node和TreeNode
Java8中HashMap中的元素有两种情况,链表中的Node,和红黑树中的TreeNode。TreeNode的属性多了很多
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
而查找、删除、插入等操作也对node和treenode采用了不同的方法
内存方面
Java7
使用HashMap会消耗一些内存。在Java 7中,HashMap将键值对封装成Entry对象,一个Entry对象包含以下信息:
- 指向下一个记录的引用
- 一个预先计算的哈希值(整数)
- 一个指向键的引用
- 一个指向值的引用
此外,Java 7中的HashMap使用了Entry对象的内部数组。假设一个Java 7 HashMap包含N个元素,它的内部数组的容量是CAPACITY,那么额外的内存消耗大约是:sizeOf(integer)* N + sizeOf(reference)* (3*N+C)
整数的大小是4个字节,引用的大小依赖于JVM、操作系统以及处理器,但通常都是4个字节。这就意味着内存总开销通常是16 * N + 4 * CAPACITY字节。
注意:
在Map自动调整大小后,CAPACITY的值是下一个大于N的最小的2的幂值。
从Java 7开始,HashMap采用了延迟加载的机制。这意味着即使你为HashMap指定了大小,在我们第一次使用put()方法之前,记录使用的内部数组(耗费4*CAPACITY字节)也不会在内存中分配空间。
Java 8
在Java 8实现中,计算内存使用情况变得复杂一些,因为Node可能会和Entry存储相同的数据,或者在此基础上再增加6个引用和一个Boolean属性(指定是否是TreeNode)。
如果所有的节点都只是Node,那么Java 8 HashMap消耗的内存和Java 7 HashMap消耗的内存是一样的。
如果所有的节点都是TreeNode,那么Java 8 HashMap消耗的内存就变成:N * sizeOf(integer) + N * sizeOf(boolean) + sizeOf(reference)* (9*N+CAPACITY )在大部分标准JVM中,上述公式的结果是44 * N + 4 * CAPACITY 字节。
ConcurrentHashMap
Java7 段+表+链表的形式
每一个segment都是一个HashEntry<K,V>[] table, table中的每一个元素本质上都是一个HashEntry的单向队列。比如table[3]为首节点,table[3]->next为节点1,之后为节点2,依次类推。
Java8 ConcurrentHashMap:
- 取消segments字段,
- 直接采用transient volatile HashEntry<K,V>[] table 保存数据,采用 table 数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率,代替原来的每一段加锁。
- 因为段的隔离级别不太容易确定,默认是16,但是很多情况下并不合适,如果太大很多空间就浪费了,如果太小每个段中可能元素过于多,所以取消segments,改成了CAS算法
- table数组+单向链表+红黑树的结构
- 对于hash表来说,最核心的能力在于将key hash之后能均匀的分布在数组中。如果hash之后散列的很均匀,那么table数组中的每个队列长度主要为0或者1。但实际情况并非总是如此理想,虽然ConcurrentHashMap类默认的加载因子为0.75,但是在数据量过大或者运气不佳的情况下,还是会存在一些队列长度过长的情况,如果还是采用单向列表方式,那么查询某个节点的时间复杂度为O(n);因此,对于个数超过8(默认值)的列表,jdk1.8中采用了红黑树的结构,那么查询的时间复杂度可以降低到O(logN),可以改进性能。
- 比如新增字段 transient volatile CounterCell[] counterCells;
- 可方便的计算hashmap中所有元素的个数,性能大大优于jdk1.7中的size()方法。
put方法:
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;
// 如果table为空,初始化;否则,根据hash值计算得到数组索引i,如果tab[i]为空,直接新建节点Node即可。注:tab[i]实质为链表或者红黑树的首节点。
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
}
// 如果tab[i]不为空并且hash值为MOVED,说明该链表正在进行transfer操作,返回扩容完成后的table。
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 针对首个节点进行加锁操作,而不是segment,进一步减少线程冲突
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 如果在链表中找到值为key的节点e,直接设置e.val = value即可。
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
// 如果没有找到值为key的节点,直接新建Node并加入链表即可。
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 如果首节点为TreeBin类型,说明为红黑树结构,执行putTreeVal操作。
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) {
// 如果节点数>=8,那么转换链表结构为红黑树结构。
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 计数增加1,有可能触发transfer操作(扩容)。
addCount(1L, binCount);
return null;
}