最近整理HashMap的知识,发现HashMap的底层数据结构在jdk1.8版本之后发生了变化,在1.8版本之前,和散列表的一样,是由散列桶(数组)+链表组成的。在1.8版本之后,结构如下图所示,由散列桶(数组)+链表+红黑树实现。
先贴出HashMap中数据结构的定义
/* 部分HashMap类中属性的定义 */
transient HashMap.Node<K, V>[] table;//这是在HashMap类中定义的Node数组,transient表示该属性不需要加入序列化对象中
transient int size;//HashMap数组当前已存放数量大小
static final int DEFAULT_INITIAL_CAPACITY = 16;//HashMap中数组容量默认初始化大小
static final float DEFAULT_LOAD_FACTOR = 0.75F;//负载因子默认是0.75
static final int TREEIFY_THRESHOLD = 8;//散列桶(数组)中元素存储由链表转至红黑树的阈值
/* 数组元素的定义 */
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
/* 红黑树 */
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
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
/**
* 返回当前节点的根节点
*/
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
HashMap的扩容机制
什么时候发生扩容:当向hashMap容器中添加元素时,会判断当前容器的元素个数,如果大于或等于数组长度*负载因子时,HashMap就会进行扩容操作,默认扩大至原来的两倍。扩容是一件很耗时的工作。
扩容的时候需要reshash操作,每个的Node节点需要重新分配存储位置,根据key的hash和新数组长度做位运算得到新的数组下标,然后将这个Node节点存放到新数组的这个下标中,由于rehash操作比较耗费时间和空间,所以特殊情况下可以根据需求调整Node数组长度和负载因子的大小。负载因子较大时,去给table扩容的可能性就会少,所以相对占用内存较少(空间上较少),但是每条链上的元素会相对较多,查询的时间也会增长。反之就是,负载因子较少的时候,给table扩容的可能性就高,那么内存空间占用就多,但是链上的元素就会相对较少,查出的时间也会减少。所以才有了负载因子是时间和空间上的一种折中的说法。所以设置负载因子的时候要考虑自己追求的是时间还是空间上的少。
HashMap中的put方法:
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;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/*如果table的在(n-1)&hash的值是空,就新建一个节点插入在该位置*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
/*表示有冲突,开始处理冲突*/
else {
Node<K,V> e;
K k;
/*检查第一个Node,p是不是要找的值*/
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
/*指针为空就挂在后面*/
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果冲突的节点数已经达到8个,看是否需要改变冲突节点的存储结构,
//treeifyBin首先判断当前hashMap的长度,如果不足64,只进行
//resize,扩容table,如果达到64,那么将冲突的存储结构为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
/*如果有相同的key值就结束遍历*/
if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
/*就是链表上有相同的key值*/
if (e != null) { // existing mapping for key,就是key的Value存在
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;//返回存在的Value值
}
}
++modCount;
/*如果当前大小大于门限,门限原本是初始容量*0.75*/
if (++size > threshold)
resize();//扩容两倍
afterNodeInsertion(evict);
return null;
}
put(key,value)的过程:
1、判断数组table是否为空,如果为空就调用resize()创建一个长度为16的数组。2、根据key的hash得到插入的下标index,如果table[index]==null,就直接新建链表节点,如果链表长度>=8,则拆散链表重建红黑树;
3、如果存储个数达到了阈值,就会促发扩容。
在java8之前,HashMap的put操作,是通过hash计算,所有落在同一个散列桶中的元素都是以链表结构存储的。在最坏情况下,put的多个key值hash计算结果都相同,导致HashMap查询时间复杂度为由O(1)涨至O(N),在java8之后,HashMap的put操作会判断桶中的元素个数,当桶中元素个数小于8个的时候,所有节点是以链表结构存储的;当桶中元素大于8个的时候,会将链表中的节点全部拿出,以树形结构重新存储。
- 插入null的情况:
HashMap允许插入key为null,或value为null的值。且所有key仅允许一个null值,再插入key为null的键值时,会覆盖前者。value允许出现多个为null的值。
HashTable和ConcurrentHashMap不允许key或value插入null值。
key为null时,值存在了哪里?
在put方法里头,其实第一行就处理了key=null的情况。
if (key == null)
return putForNullKey(value);
//那就看看这个putForNullKey是怎么处理的吧。
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
可以看到,前面那个for循环,是在talbe[0]链表中查找key为null的元素,如果找到,则将value重新赋值给这个元素的value,并返回原来的value。
如果上面for循环没找到。则将这个元素添加到talbe[0]链表的表头。
java7和java8中,table获取数组下标的算法发生了改变。
/* jdk1.7中计算index值的方法 */
static int indexFor(int h, int length) {
return h & (length-1);
}
/* jdk1.8中计算index值的方法 */
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h =key.hashCode()) ^ (h >>> 16);
}
HashMap中的get方法:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;//根据key及其hash值查询node节点,如果存在,则返回该节点的value值。
}
final Node<K,V> getNode(int hash, Object key) {//根据key搜索节点的方法。记住判断key相等的条件:hash值相同 并且 符合equals方法。
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&//根据输入的hash值,可以直接计算出对应的下标(n - 1)& hash,缩小查询范围,如果存在结果,则必定在table的这个位置上。
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))//判断第一个存在的节点的key是否和查询的key相等。如果相等,直接返回该节点。
return first;
if ((e = first.next) != null) {//遍历该链表/红黑树直到next为null。
if (first instanceof TreeNode) //当这个table节点上存储的是红黑树结构时,在根节点first上调用getTreeNode方法,在内部遍历红黑树节点,查看是否有匹配的TreeNode。
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash && //当这个table节点上存储的是链表结构时,用跟第11行同样的方式去判断key是否相同。
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null); //如果key不同,一直遍历下去直到链表尽头,e.next == null。
}
}
return null;
}
hash(Object key)方法根据输入的key值,返回数组下标,先判断第一个节点是否与方法参数相等,不等就遍历后面的链表找到相同的key值并返回对应的Value值即可,在java8中,如果桶中元素大于8个,则就是遍历该红黑树,查询速度会比遍历链表要快,时间复杂度由O(n)变为O(lgn)。
在使用put方法添加新键值对时,当两个key通过hash运算得到相同的数组下标index的时候,就可以用到链表来解决了,HashMap 会在 table[index]
处形成链表,采用头插法将数据插入到链表中(jdk1.7)。在jdk1.8之前,在多线程环境下,如果put一个新键值对时,size达到需要HashMap扩容的时候,有可能会出现链表里的Entry顺序颠倒导致出现环链进而导致在getvalue的时候出现死循环问题。具体解释起来比较绕,此处偷懒放上参考博文:图解集合 5 :不正确地使用HashMap引发死循环及元素丢失。我在jdk1.8源码中找了下发现已经没有在多线程情况下导致死循环的transfer方法了,而是对resize方法进行了修改,看了很多篇博客,我的理解是新方法改用尾插法,保持链表中Entry的相对顺序不变,可以避免形成环链。但是这并不能改变HashMap是线程不安全的类, 因为还是存在丢失节点的问题。
把一个线程非安全的集合作为全局共享的,本身就是一种错误的做法,并发下一定会产生错误。
所以,在多线程环境下,要想使用Map类工具,建议使用以下:
1、使用Hashtable或ConcurrentHashMap这两个线程安全的Map
2、使用Collections.synchronizedMap(Mao<K,V> m)方法把HashMap变成一个线程安全的Map
不过选择了线程安全的办法,那么必然要在性能上付出一定的代价
写的有点杂乱,请见谅。如有不正确的地方,欢迎指出错误。
参考链接:https://github.com/crossoverJie/Java-Interview/blob/master/MD/HashMap.md
http://www.importnew.com/25070.html
http://blog.csdn.net/tuke_tuke/article/details/51588156