Java集合系列之HashMap源码分析

前面我们已经分析了ArrayList和LinkedList这两个集合,我们知道ArrayList是基于数组实现的,LinkedList是基于链表实现的。它们各自有自己的优劣势,例如ArrayList在定位查找元素时会优于LinkedList,而LinkedList在添加删除元素时会优于ArrayList。而本篇介绍的HashMap综合了二者的优势,它的底层是基于哈希表实现的,如果不考虑哈希冲突的话,HashMap在增删改查操作上的时间复杂度都能够达到惊人的O(1)。我们先看看它所基于的哈希表的结构。

从上图中可以看到,哈希表是由数组和链表共同构成的一种结构,当然上图是一个不好的示例,一个好的哈希函数应该要尽量平均元素在数组中的分布,减少哈希冲突从而减小链表的长度。链表的长度越长,意味着在查找时需要遍历的结点越多,哈希表的性能也就越差。接下来我们来看下HashMap的部分成员变量。

//默认初始容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

//默认最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;

//默认加载因子, 指哈希表可以达到多满的尺度
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//空的哈希表
static final Entry<?,?>[] EMPTY_TABLE = {};

//实际使用的哈希表
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

//HashMap大小, 即HashMap存储的键值对数量
transient int size;

//键值对的阈值, 用于判断是否需要扩增哈希表容量
int threshold;

//加载因子
final float loadFactor;

//修改次数, 用于fail-fast机制
transient int modCount;

//使用替代哈希的默认阀值
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

//随机的哈希种子, 有助于减少哈希碰撞的次数
transient int hashSeed = 0;

由成员变量中看到,HashMap默认的初始容量为16,默认的加载因子是0.75。而threshold是集合能够存储的键值对的阀值,默认是初始容量*加载因子,也就是16*0.75=12,当键值对要超过阀值时,意味着这时候的哈希表已处于饱和状态,再继续添加元素就会增加哈希冲突,从而使HashMap的性能下降。这时会触发自动扩容机制,以保证HashMap的性能。我们还可以看到哈希表其实就是一个Entry数组,数组存放的每个Entry都是单向链表的头结点。这个Entry是HashMap的静态内部类,来看看Entry的成员变量。

static class Entry<K,V> implements Map.Entry<K,V> {
  final K key;   //键
  V value;     //值
  Entry<K,V> next; //下一个Entry的引用
  int hash;     //哈希码

  ...        //省略下面代码
}

一个Entry实例就是一个键值对,里面包含了key和value,同时每个Entry实例还有一个指向下一个Entry实例的引用。为了避免重复计算,每个Entry实例还存放了对应的Hash码。可以说Entry数组就是HashMap的核心,所有的操作都是针对这个数组来完成的。由于HashMap源码比较长,不可能面面俱到的介绍它的所有方法,因此我们只抓住重点来进行介绍。接下来我们将以问题为导向,针对下面几个问题深入探究HashMap的内部机制。

1. HashMap在构造时做了哪些操作?

//构造器, 传入初始化容量和加载因子
public HashMap(int initialCapacity, float loadFactor) {
  if (initialCapacity < 0) {
    throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
  }
  //如果初始化容量大于最大容量, 就把它设为最大容量
  if (initialCapacity > MAXIMUM_CAPACITY) {
    initialCapacity = MAXIMUM_CAPACITY;
  }
  //如果加载因子小于0或者加载因子不是浮点数, 则抛出异常
  if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
    throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
  }
  //设置加载因子
  this.loadFactor = loadFactor;
  //阈值为初始化容量
  threshold = initialCapacity;
  init();
}

void init() {}

所有的构造器都会调用到这个构造器,在这个构造器中我们看到除了对参数做一些校验之外,它就做了两件事,设置加载因子为传入的加载因子,设置阀值为传入的初始化大小。而init方法是空的,啥也没做。注意,这时候并没有根据传入的初始化大小去新建一个Entry数组哦。那在什么时候再去新建数组呢?继续往下看。

2. HashMap添加键值对时会进行什么操作?

//放置key-value键值对到HashMap中
public V put(K key, V value) {
  //如果哈希表没有初始化就进行初始化
  if (table == EMPTY_TABLE) {
    //初始化哈希表
    inflateTable(threshold);
  }
  if (key == null) {
    return putForNullKey(value);
  }
  //计算key的hash码
  int hash = hash(key);
  //根据hash码定位在哈希表的位置
  int i = indexFor(hash, table.length);
  for (Entry<K,V> e = table[i]; e != null; e = e.next) {
    Object k;
    //如果对应的key已经存在, 就替换它的value值, 并返回原先的value值
    if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
      V oldValue = e.value;
      e.value = value;
      e.recordAccess(this);
      return oldValue;
    }
  }
  modCount++;
  //如果没有对应的key就添加Entry到HashMap中
  addEntry(hash, key, value, i);
  //添加成功返回null
  return null;
}

看到,在添加键值对时会先检查哈希表是否是个空表,如果是空表就进行初始化。之后再进行后续操作,调用哈希函数计算传入的key的Hash码。根据hash码定位到Entry数组的指定槽位,然后对该槽位的单向链表进行遍历,如果传入的已经存在了就进行替换操作,否则就新建一个Entry添加到哈希表中。

3. 哈希表是怎样初始化的?

//初始化哈希表, 会对哈希表容量进行膨胀, 因为有可能传入的容量不是2的幂
private void inflateTable(int toSize) {
  //哈希表容量必须是2的次幂
  int capacity = roundUpToPowerOf2(toSize);
  //设置阀值, 这里一般是取capacity*loadFactor
  threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
  //新建指定容量的哈希表
  table = new Entry[capacity];
  //初始化哈希种子
  initHashSeedAsNeeded(capacity);
}

上面我们知道,在构造HashMap时不会新建Entry数组,而是在put操作时检查当前哈希表是否是个空表,如果是空表就调用inflateTable方法进行初始化。上面贴出了这个方法的代码,可以看到方法内部会重新计算Entry数组的容量,因为在构造HashMap时传入的初始化大小可能不是2的幂,因此要将这个数转换成2的幂再去根据新的容量新建Entry数组。初始化哈希表时再次重新设置阀值,阀值一般是capacity*loadFactor。此外,在初始化哈希表时还会去初始化哈希种子(hashSeed),这个hashSeed用于优化哈希函数,默认为0是不使用替代哈希算法,但是也可以自己去设置hashSeed的值,以达到优化效果。具体下面会讲到。

4. HashMap在什么时候判断是否要扩容,以及它是怎样扩容的?

//添加Entry方法, 先判断是否要扩容
void addEntry(int hash, K key, V value, int bucketIndex) {
  //如果HashMap的大小大于阀值并且哈希表对应槽位的值不为空
  if ((size >= threshold) && (null != table[bucketIndex])) {
    //因为HashMap的大小大于阀值, 表明即将发生哈希冲突, 所以进行扩容
    resize(2 * table.length);
    hash = (null != key) ? hash(key) : 0;
    bucketIndex = indexFor(hash, table.length);
  }
  //在这里表明HashMap的大小没有超过阀值, 所以不需要扩容
  createEntry(hash, key, value, bucketIndex);
}

//对哈希表进行扩容
void resize(int newCapacity) {
  Entry[] oldTable = table;
  int oldCapacity = oldTable.length;
  //如果当前已经是最大容量就只能增大阀值了
  if (oldCapacity == MAXIMUM_CAPACITY) {
    threshold = Integer.MAX_VALUE;
    return;
  }
  //否则就进行扩容
  Entry[] newTable = new Entry[newCapacity];
  //迁移哈希表的方法
  transfer(newTable, initHashSeedAsNeeded(newCapacity));
  //将当前哈希表设置为新的哈希表
  table = newTable;
  //更新哈希表阈值
  threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

在调用put方法添加一个键值对时,如果集合中没有存在的key就去调用addEntry方法新建一个Entry。看到上面贴出的addEntry代码,在新建一个Entry之前会先判断当前集合元素的大小是否超过了阀值,如果超过了阀值就调用resize进行扩容。传入的新的容量是原来哈希表的两倍,在resize方法内部会新建一个容量为原先的2倍的Entry数组。然后将旧的哈希表里面的元素全部迁移到新的哈希表,其中可能会进行再哈希,根据initHashSeedAsNeeded方法计算的值来确定是否进行再哈希。完成哈希表的迁移之后,将当前哈希表替换为新的,最后再根据新的哈希表容量来重新计算HashMap的阀值。

5. 为什么Entry数组的大小必须为2的幂?

 //返回哈希码对应的数组下标
 static int indexFor(int h, int length) {
   return h & (length-1);
 }

indexFor方法是根据hash码来计算出在数组中对应的下标。我们可以看到在这个方法内部使用了与(&)操作符。与操作是对两个操作数进行位运算,如果对应的两个位都为1,结果才为1,否则为0。与操作经常会用于去除操作数的高位值,例如:01011010 & 00001111 = 00001010。我们继续回到代码中,看看h&(length-1)做了些什么。

已知传入的length是Entry数组的长度,我们知道数组下标是从0开始计算的,所以数组的最大下标为length-1。如果length为2的幂,那么length-1的二进制位后面都为1。这时h&(length-1)的作用就是去掉了h的高位值,只留下h的低位值来作为数组的下标。由此可以看到Entry数组的大小规定为2的幂就是为了能够使用这个算法来确定数组的下标。

6. 哈希函数是怎样计算Hash码的?

//生成hash码的函数
final int hash(Object k) {
  int h = hashSeed;
  //key是String类型的就使用另外的哈希算法
  if (0 != h && k instanceof String) {
    return sun.misc.Hashing.stringHash32((String) k);
  }
  h ^= k.hashCode();
  //扰动函数
  h ^= (h >>> 20) ^ (h >>> 12);
  return h ^ (h >>> 7) ^ (h >>> 4);
}

hash方法的最后两行是真正计算hash值的算法,计算hash码的算法被称为扰动函数,所谓的扰动函数就是把所有东西杂糅到一起,可以看到这里使用了四个向右移位运算。目的就是将h的高位值与低位值混合一下,以此增加低位值的随机性。在上面我们知道定位数组的下标是根据hash码的低位值来确定的。key的hash码是通过hashCode方法来生成的,而一个糟糕的hashCode方法生成的hash码的低位值可能会有很大的重复。为了使得hash码在数组上映射的比较均匀,扰动函数就派上用场了,把高位值的特性糅合进低位值,增加低位值的随机性,从而使散列分布的更加松散,以此提高性能。下图举了个例子帮助理解。

7. 替代哈希是怎么回事?

我们看到hash方法中首先会将hashSeed赋值给h。这个hashSeed就是哈希种子,它是一个随机的值,作用就是帮助优化哈希函数。hashSeed默认是0,也就是默认不使用替代哈希算法。那么什么时候使用hashSeed呢?首先需要设置开启替代哈希,在系统属性中设置jdk.map.althashing.threshold的值,在系统属性中这个值默认是-1,当它是-1的时候使用替代哈希的阀值为Integer.MAX_VALUE。这也意味着可能你永远也不会使用替代哈希了。当然你可以把这个阀值设小一点,这样当集合元素达到阀值后就会生成一个随机的hashSeed。以此增加hash函数的随机性。为什么要使用替代哈希呢?当集合元素达到你设定的阀值之后,意味着哈希表已经比较饱和了,出现哈希冲突的可能性就会大大增加,这时对再添加进来的元素使用更加随机的散列函数能够使后面添加进来的元素更加随机的分布在散列表中。

注:以上分析全部基于JDK1.7,不同版本之间会有较大的改动,读者需要注意。

(0)

相关推荐

  • Java源码解析HashMap的keySet()方法

    HashMap的keySet()方法比较简单,作用是获取HashMap中的key的集合.虽然这个方法十分简单,似乎没有什么可供分析的,但真正看了源码,发现自己还是有很多不懂的地方.下面是keySet的代码. public Set<K> keySet() { Set<K> ks = keySet; if (ks == null) { ks = new KeySet(); keySet = ks; } return ks; } 从代码中了解到,第一次调用keySet方法时,keySet

  • Java源码解析HashMap的resize函数

    HashMap的resize函数,用于对HashMap初始化或者扩容. 首先看一下该函数的注释,如下图.从注释中可以看到,该函数的作用是初始化或者使table的size翻倍.如果table是null,那么就申请空间进行初始化.否则,因为我们在使用2的指数的扩张,在原来table的每个位置的元素,在新的table中,他们要么待在原来的位置,要么移动2的指数的偏移.从这里可以看出,扩容前table每个位置上如果有多个元素,元素之间组成链表时,在扩容后,该链表中的元素,有一部分会待在原地,剩下的元素会

  • 深入理解Java之HashMap源码剖析

    一.HashMap概述 HashMap基于哈希表的 Map 接口的实现.此实现提供所有可选的映射操作,并允许使用 null 值和 null 键.(除了不同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同.)此类不保证映射的顺序,特别是它不保证该顺序恒久不变. 值得注意的是HashMap不是线程安全的,如果想要线程安全的HashMap,可以通过Collections类的静态方法synchronizedMap获得线程安全的HashMap. Map map = Coll

  • Java源码解析HashMap的tableSizeFor函数

    aka,HashMap的容量大小必须为2的指数,即16,32,64,128这样的值.那么,在构造函数中,如果调用者指定了HashMap的初始大小不是2的指数,那么,HashMap的tableSizeFor函数,会计算一个大于或等于给定参数的2的指数的值.先来看一下tableSizeFor函数的源码,如下 /** * Returns a power of two size for the given target capacity. **/ static final int tableSizeFo

  • Java源码解析HashMap成员变量

    本文基于jdk1.8进行分析 关于HashMap的简介,可以参考这篇文章https://www.jb51.net/article/154177.htm. 首先看一下HashMap的一些静态常量.第一个是DEFAULT_INITIAL_CAPACITY,默认初始大小,16.从注释中可以了解到,大小必须为2的指数.这里的16,采用的1左移4位实现.而"aka",是as known as的缩写. /** * The default initial capacity - MUST be a p

  • java基础类型源码解析之多角度讲HashMap

    前言 终于来到比较复杂的HashMap,由于内部的变量,内部类,方法都比较多,没法像ArrayList那样直接平铺开来说,因此准备从几个具体的角度来切入. 桶结构 HashMap的每个存储位置,又叫做一个桶,当一个Key&Value进入map的时候,依据它的hash值分配一个桶来存储. 看一下桶的定义:table就是所谓的桶结构,说白了就是一个节点数组. transient Node<K,V>[] table; transient int size; 节点 HashMap是一个map结

  • Java源码解析HashMap简介

    本文基于jdk1.8进行分析 HashMap是java开发中可以说必然会用到的一个集合.本文就HashMap的源码实现进行分析. 首先看一下源码中类的javadoc注释对HashMap的解释.如下图.HashMap是对Map接口的基于hash表的实现.这个实现提供了map的所有可选操作,并且允许null值(可以多个)和一个null的key(仅限一个).HashMap和HashTable十分相似,除了HashMap是非同步的且允许null元素.这个类不保证map里的顺序,更进一步,随着时间的推移,

  • 在Java8与Java7中HashMap源码实现的对比

    一.HashMap的原理介绍 此乃老生常谈,不作仔细解说. 一句话概括之:HashMap是一个散列表,它存储的内容是键值对(key-value)映射. 二.Java 7 中HashMap的源码分析 首先是HashMap的构造函数代码块1中,根据初始化的Capacity与loadFactor(加载因子)初始化HashMap. //代码块1 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0)

  • Java源码角度分析HashMap用法

    -HashMap- 优点:超级快速的查询速度,时间复杂度可以达到O(1)的数据结构非HashMap莫属.动态的可变长存储数据(相对于数组而言). 缺点:需要额外计算一次hash值,如果处理不当会占用额外的空间. -HashMap如何使用- 平时我们使用hashmap如下 Map<Integer,String> maps=new HashMap<Integer,String>(); maps.put(1, "a"); maps.put(2, "b&quo

  • 剖析Java中HashMap数据结构的源码及其性能优化

    存储结构 首先,HashMap是基于哈希表存储的.它内部有一个数组,当元素要存储的时候,先计算其key的哈希值,根据哈希值找到元素在数组中对应的下标.如果这个位置没有元素,就直接把当前元素放进去,如果有元素了(这里记为A),就把当前元素链接到元素A的前面,然后把当前元素放入数组中.所以在Hashmap中,数组其实保存的是链表的首节点.下面是百度百科的一张图: 如上图,每个元素是一个Entry对象,在其中保存了元素的key和value,还有一个指针可用于指向下一个对象.所有哈希值相同的key(也就

随机推荐