JDK1.8源码阅读(一):HashMap

一、初识HashMap

HashMap的类图设计:
HashMap类图结构

  • HashMap继承自AbstractMap,所以它是一个Map,即一个key-value集合;
  • HashMap实现了Serializable接口,意味着它支持序列化;
  • HashMap实现了Cloneable接口,意味着它能被克隆。

想要弄清楚什么是HashMap,就得先弄清楚什么是Hash。Hash是一种散列算法,把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。简单来说,就是一种将任意长度的消息压缩到某一固定长度的消息摘要(散列值)的函数。

我们平时常用的MD5,SSL等都属于Hash算法,通过Key进行Hash的计算,就可以获取Key对应的HashCode。

那HashMap是什么呢,HashMap是一个“链表散列”的数据结构,即数组和链表的结合体
数组:存储区间连续,占用内存严重,寻址容易,插入删除困难;
链表:存储区间离散,占用内存比较宽松,寻址困难,插入删除容易;

HashMap综合应用了这两种数据结构,实现了寻址容易,插入删除也容易。不同于之前的jdk的实现,JDK 8采用的是数组+链表+红黑树,在链表过长的时候可以通过转换成红黑树提升访问性能。
HashMap数据结构
HashMap通过Key进行Hash的计算,就可以获取Key对应的HashCode,从而得出当前key所在数组的位置。好的Hash算法可以计算出几乎出独一无二的HashCode,如果出现了重复的hashCode,就称作碰撞,就算是MD5这样优秀的算法也会发生碰撞,即两个不同的key也有可能生成相同的MD5。

正常情况下,我们通过hash算法,往HashMap的数组中插入元素。如果发生了碰撞事件,那么意味这数组的一个位置要插入两个或者多个元素,这个时候数组上面挂的链表起作用了,链表会将数组某个节点上多出的元素按照尾插法(jdk1.7及以前为头插法)的方式添加。

三、HashMap的内部实现机制

1、HashMap成员变量

// 默认初始化容量:16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量:2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的填充因子:0.75,能较好的平衡时间与空间的消耗
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 将链表转化成红黑树的临界值
static final int TREEIFY_THRESHOLD = 8;
// 将红黑树转成链表的临界值
static final int UNTREEIFY_THRESHOLD = 6;
// 树化的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
// 数组,长度总是2的幂次
transient Node<K,V>[] table;
// 键值对集合
transient Set<Map.Entry<K,V>> entrySet;
// 键值对的数量
transient int size;
// 统计map修改次数的计数器,fail-fast机制,抛出ConcurrentModificationException
transient int modCount;
// 大于该阈值,则重新进行扩容,threshold = capacity(table.length) * loadFactor
int threshold;
// 填充因子
final float loadFactor;
...

2、Node数据结构

static class Node<K,V> implements Map.Entry<K,V> {
  // key & value 的 hash值
  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; }
  ...

node中包含一个next变量,这个就是链表的关键点,hash结果相同的元素(hash碰撞)就是通过这个next进行关联的。

3、构造函数

// 无参构造函数
public HashMap() {
	// 默认的填充因子:0.75, 其他成员变量也都是默认的
	this.loadFactor = DEFAULT_LOAD_FACTOR;
}

// 指定初始化容量的构造函数(建议,避免扩容)
public HashMap(int initialCapacity) {
	// 调用传初始化容量,填充因子的构造函数
  	this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

// 指定初始化容量、填充因子的构造函数
public HashMap(int initialCapacity, float loadFactor) {
	if (initialCapacity < 0)
		throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
	if (initialCapacity > MAXIMUM_CAPACITY)
		initialCapacity = MAXIMUM_CAPACITY;
	if (loadFactor <= 0 || Float.isNaN(loadFactor))
		throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
	this.loadFactor = loadFactor;
	// tableSizeFor()的作用:求大于等于输入参数且最近的2的整数次幂的数,比如initialCapacity = 7,那么结果就是8。
	this.threshold = tableSizeFor(initialCapacity);
}
// 传map转化为HashMap的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
	this.loadFactor = DEFAULT_LOAD_FACTOR;
	putMapEntries(m, false);
}

// evict表示是否初始化map,false则初始化map
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
	// 获取m中键值对的数量
	int s = m.size();
	if (s > 0) {
		// table为空,计算阈值
		if (table == null) {
	  		// 计算map的容量,键值对的数量 = 容量 * 填充因子
			float ft = ((float)s / loadFactor) + 1.0F;
			int t = ((ft < (float)MAXIMUM_CAPACITY) ?
			(int)ft : MAXIMUM_CAPACITY);
			// 如果容量大于了阈值,则重新计算阈值。
			if (t > threshold)
			threshold = tableSizeFor(t);
		}
	  	// 如果table存在且键值对数量大于阈值,进行扩容
	  	else if (s > threshold)
	    resize();
		for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
	    	K key = e.getKey();
	    	V value = e.getValue();
	    	putVal(hash(key), key, value, false, evict);
		}
	}
}

tableSizeFor

// 求大于等于输入参数且最近的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;
}

【|】是或运算符,比如说0100 | 0011 = 0111
【>>>】是无符号右移,忽略符号位,空位都以0补齐,比如说0100 >>> 2 = 0001

​【>>>】和【|】的操作的目的就是把n从最高位的1以下都填充为1,以010011为例:

  1. 010011无符号位移:010011 >>> 1 = 001001,然后进行或运算:001001 | 010011 = 011011,此次之后就保证了最高位开始连续两个1;
  2. 继续把011011无符号右移两位:011011 >>> 2 = 000110,然后进行或运算:000110 | 011011 = 011111,此次之后就保证了最高位开始连续4个1;
  3. 后面的4、8、16计算过程就都省去了,int类型为32位,所以计算到16就全部结束了,最终得到的就是最高位及其以下的都为1,这样就能保证得到的结果肯定大于或等于原来的n且为奇数,最后再加上1,那么肯定是:大于且最接近输入值的2的整数次幂的数。

​ 那么为什么要先cap - 1呢,因为如果传进来的本身就是2的整数幂次,比如说01000,10进制是8,那么如果不减,得到的结果就是16,显然不对。所以先减1的目的是cap如果恰好是2的整数次幂,那么返回的也是本身。

​ 最终这个tableSizeFor()方法的目的就是返回大于等于输入参数且最近的2的整数次幂的数。

4、get方法

public V get(Object key) {
	Node<K,V> e;
	return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
	Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
	// 判断table是否为空以及根据hash找到存放的table数组的下标,并赋值给临时变量
	if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
		// 检查数组下标第一个节点是否满足key,满足则返回
		if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
			return first;
		// 如果第一个与key不相等,则继续往下查找
		if ((e = first.next) != null) {
			// 是否为树节点,若是则采用树节点的方法来获取对应的key的值
			if (first instanceof TreeNode)
				return ((TreeNode<K,V>)first).getTreeNode(hash, key);
			// do-while循环遍历链表
			do {
				if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
					return e;
			} while ((e = e.next) != null);
		}
	}
	return null;
}

1、调用 hash(K) 方法(计算 K 的 hash 值)从而获取该键值所在链表的数组下标;
2、顺序遍历链表,equals()方法查找相同 Node 链表中 K 值对应的 V 值。

5、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;
  // 若table为空则进行扩容
  if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  // 若tab对应的数组位置为空,则创建新的node,并指向它
  if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null); 
  else {
    Node<K,V> e; K k;
    // 若hash值和key的值都相等,说明要put的键值对已经在里面,赋值给e
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
      e = p;
    // 若p节点是树节点,则执行插入树的操作
    else if (p instanceof TreeNode)
      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    // 若p不是树节点且数组中第一个也不是,则在桶中查找
    else {
      for (int binCount = 0; ; ++binCount) {
        // 找到了最后一个都不满足的话,则在最后插入节点。注意这里的e = p.next,赋值兼具判断都在if里了
        if ((e = p.next) == null) 
          p.next = newNode(hash, key, value, null);
          // 若桶中的数量大于树化阈值,则转化成树,第一个是-1
          if (binCount >= TREEIFY_THRESHOLD - 1)
            treeifyBin(tab, hash);
          break;
        }
      	// 若在桶中找到了对应的key,赋值给e,退出循环
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
          break;
      	// 若没有找到,则继续向下一个节点寻找
        p = e;
      }
    }
  	// 上面循环中找到了e,则根据onlyIfAbsent是否为true来决定是否替换旧值
    if (e != null) {
      V oldValue = e.value;
      if (!onlyIfAbsent || oldValue == null)
        e.value = value;
      // 钩子函数,用于给LinkedHashMap继承后使用,在HashMap里是空的
      afterNodeAccess(e);
      return oldValue;
    }
  }
  // 修改计数器+1
  ++modCount;
  // 实际大小+1, 如果大于阈值,重新计算并扩容
  if (++size > threshold)
    resize();
  // 钩子函数,用于给LinkedHashMap继承后使用,在HashMap里是空的
  afterNodeInsertion(evict);
  return null;
}
  1. 调用 hash(K) 方法计算 K 的 hash 值,然后结合数组长度,计算得数组下标;

  2. 调整数组大小(当容器中的元素个数大于 capacity * loadfactor 时,容器会进行扩容resize 为 2n);

  3. 如果 K 的 hash 值在 HashMap 中不存在,则执行插入,若存在,则发生碰撞;

  4. 如果 K 的 hash 值在 HashMap 中存在,若它们两者 equals 返回 true,则更新键值对;若返回true则插入链表的尾部(尾插法)或者红黑树中(树的添加方式)。(JDK 1.7 之前使用头插法、JDK 1.8 使用尾插法)(注意:当碰撞导致链表大于 TREEIFY_THRESHOLD = 8 时,就把链表转换成红黑树)

6、resize方法

final Node<K,V>[] resize() {
  Node<K,V>[] oldTab = table;
  // 扩容/缩容前的容量
  int oldCap = (oldTab == null) ? 0 : oldTab.length;
  // 旧的阈值
  int oldThr = threshold;
  int newCap, newThr = 0;
  // 说明之前已经初始化过map
  if (oldCap > 0) {
    // 达到了最大的容量,则将阈值设为最大,并且返回旧的table
    if (oldCap >= MAXIMUM_CAPACITY) {
      threshold = Integer.MAX_VALUE;
      return oldTab;
    }
    // 如果两倍的旧容量小于最大的容量且旧容量大于等于默认初始化容量,则旧的阈值也扩大两倍。
    // oldCap << 1,其实就是*2的意思。
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
             oldCap >= DEFAULT_INITIAL_CAPACITY)
      newThr = oldThr << 1; 
  }
  // 旧容量为0且旧阈值大于0,则赋值给新的容量
  else if (oldThr > 0)
    newCap = oldThr;
  else {    
  	// 默认值           
    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;
  // 根据新的容量来初始化table,并赋值给table
  @SuppressWarnings({"rawtypes","unchecked"})
  Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
  table = newTab;
  // 如果旧的table里面有存放节点,则初始化给新的table
  if (oldTab != null) {
    for (int j = 0; j < oldCap; ++j) {
      Node<K,V> e;
      // 将下标为j的数组赋给临时节点e
      if ((e = oldTab[j]) != null) {
        // 清空
        oldTab[j] = null;
        // 如果该节点没有指向下一个节点,则直接通过计算hash和新的容量来确定新的下标,并指向e
        if (e.next == null)
          newTab[e.hash & (newCap - 1)] = e;
        // 如果为树节点,按照树节点的来拆分
        else if (e instanceof TreeNode)
          ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
        // e还有其他的节点,将该桶拆分成两份(不一定均分)
        else {
          // loHead是拆分后的,链表的头部,tail为尾部
          Node<K,V> loHead = null, loTail = null;
          Node<K,V> hiHead = null, hiTail = null;
          Node<K,V> next;
          do {
            next = e.next;
            // 根据e的hash值和旧的容量做位与运算是否为0来拆分,注意之前是 e.hash & (oldCap - 1)
            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;
}

resize()方法对整个数组以及桶进行了遍历,极其耗费性能。所以在我们明确知道map要用的容量的时候,使用指定初始化容量的构造函数。

7、链表转红黑树

final void treeifyBin(Node<K,V>[] tab, int hash) { 
	int n, index; Node<K,V> e;
	if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)    			
		// 如果map的容量小于64(默认值),会调用resize扩容,不会转换为红黑树
		resize();
	else if ((e = tab[index = (n - 1) & hash]) != null) {
		TreeNode<K,V> hd = null, tl = null;
		do {
			TreeNode<K,V> p = replacementTreeNode(e, null);    		
			// Node转换为TreeNode
			if (tl == null)
				hd = p;
			else {
				p.prev = tl;
				tl.next = p;
			}
			tl = p;
		} while ((e = e.next) != null);
		if ((tab[index] = hd) != null)
			// 调用TreeNode的树排序方法
			hd.treeify(tab); 
	}
}

可以看到如果冲突的节点数已经达到8个,会首先判断当前HashMap的长度,如果不足64,只进行resize,扩容table,如果达到64,那么将链表转为红黑树。

为什么要先判断HashMap的长度是否小于64?链表长度大于8有两种情况:

  1. table长度足够,hash冲突过多
  2. hash没有冲突,但是在计算table下标的时候,由于table长度太小,导致很多hash不一致的

第二种情况是可以用扩容的方式来避免的,扩容后链表长度变短,读写效率自然提高。另外,扩容相对于转换为红黑树的好处在于可以保证数据结构更简单。

四、几个常见的面试问题

1、HashMap默认加载因子为什么选择0.75?

选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择,0.75的话碰撞最小,查询成本较低,同时空间利用率较高。

加载因子过高,例如为1,虽然减少了空间开销,提高了空间利用率,但同时也增加了查询时间成本;

加载因子过低,例如0.5,虽然可以减少查询时间成本,但是空间利用率很低,同时提高了rehash操作的次数。

2、容量为什么是 2 的 n 次幂

为了利用位运算 & 求 key 的下标。

3、求索引的时候为什么是:h&(length-1),而不是 h&length,更不是 h%length

// h&(length-1)
1010&1111=1010;      => 10&15=10;
1011&1111=1011;      => 11&15=11;
10000&01111=1000;      => 16&15=8;
10001&01111=1001;      => 17&15=9;

// h&length
01010&10000=00000;   => 10&16=0;
01011&10000=00000;   => 11&16=0;
10000&10000=10000;      => 16&16=16;
10001&10000=10000;      => 17&16=16;

h%length :取模运算效率不如位运算快;

h&length :length是2^n,除最高位外都是0,意味着会提高碰撞几率,导致 table 的空间得不到更充分的利用、降低 table 的操作效率;

h&(length-1):length-1最高位到最低位都是1,可以减少碰撞几率,更充分地利用table的空间,提升table的操作效率。

我们需要利用好 & 运算的特点,当右边的数的低位二进制是连续的 1 ,且左边是一个均匀的数(需要 hash 方法实现,尽量保证 key 的 h 唯一),那么得到的结果就比较完美了。低位二进制连续的 1,我们很容易想到 2^n - 1; 而关于左边均匀的数,则通过 hash 方法来实现。

4、为何引入红黑树,红黑树这么好,为什么不直接使用红黑树

链表插入快,查询慢,小于8个的情况下更适合使用链表;
红黑树查询快,插入慢,大于8个更适合使用红黑树来提升查询效率及性能。

链表的时间复杂度为O(n),红黑树虽然本质上是一棵二叉查找树,但它在二叉查找树的基础上增加了着色和相关的性质使得红黑树相对平衡,从而保证了红黑树的查找、插入、删除的时间复杂度最坏为O(log n),加快检索速率。

那又为什么不使用avl(平衡二叉树)树呢?红黑树相比avl树,在检索的时候效率其实差不多,都是通过平衡来二分查找。但对于插入删除等操作效率提高很多。红黑树不像avl树一样追求绝对的平衡,他允许局部很少的不完全平衡,这样对于效率影响不大,但省去了很多没有必要的调平衡操作,avl树调平衡有时候代价较大,所以效率不如红黑树。

5、总结

  1. table.length = 2^n,是为了能利用位运算(&)来求 key 的下标,而 h&(length-1) 是为了充分利用 table 的空间,并减少 key 的碰撞

  2. 加载因子太小, table 需要不断的扩容,影响 put 效率;太大会导致碰撞越来越多,链表越来越长(转红黑树),影响效率;0.75 是一个比较理想的中间值

  3. table.length = 2^n、hash 方法获取 key 的 h、加载因子 0.75、数组 + 链表(或红黑树),一环扣一环,保证了 key 在 table 中的均匀分配,充分利用了空间,也保证了操作效率。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值