前言:
为啥出来工作4年之后再来探究HashMap的原理呢?
从开始出来工作之后,就断断续续的了解了HashMap的一些原理,但终究是没形成一个比较完整的架构,还有就是近期在进行数据结构的系统学习,还有就是发现JDK8之后对HashMap做了不少的优化。
首先需要明确一点 HashMap的底层数据结构是散列表,其整个组成由数组+链表或数组+链表与树组成。如下图所示:
有关于散列表原理相关的详细说明可见笔者之前写的这篇博文《数据结构之散列表(七)》
一. HashMap 的一些重要属性
1. HashMap 底层存储
如下可见HashMap是由一个Node数组构成的,每个Node中包含key与value键值对。
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
2. 加载因子 (默认值 0.75)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
3. hashMap初始长度
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
左移4位也就是16,一开始很好奇为啥不直接用16进行赋值呢?
当了解深入一些才感觉到这么写的魅力所在,个人感觉主要是表示HashMap初始长度必须为2的幂。当我们需要自定义HashMap长度时,也可采用这种方式进行初始化。
4. hashMap 临界值
// The next size value at which to resize (capacity * load factor)
int threshold;
根据HashMap默认的初始长度可知,该临界值默认值是12,也就是说当HashMap长度达到12后在默认情况下就会自动进行扩容。
因此如果知道需要存储的数据大小便可直接调用如下构造函数创建指定大小的HashMap以减少自动扩容。
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and the default load factor (0.75).
*
* @param initialCapacity the initial capacity.
* @throws IllegalArgumentException if the initial capacity is negative.
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
这里有个特别需要注意的点是传的数据必须是2的幂,不过这里如果你传入的不是2的幂,其底层也还是会将其转换成2的幂的。转换的源码如下:
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;
this.threshold = tableSizeFor(initialCapacity);
}
特别注意在对传入的初始值进行基本判断后,再进行调用tableSizeFor方法对传入的初始值进行转换,具体代码如下所示:
/**
* Returns a power of two size for the given target capacity.*
*/
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;
}
代码解说:这段代码主要是作用就是返回给定目标容量的两倍幂,也就是保证了HashMap的初始值一定是2的幂次方。下文再进行分析为什么需要这么做。
n |= n >>> 1
这一步运算过程:先将n右移一位,再将n与n右移一位的值进行或运算,最终将或运算的值再赋值给n。这里的运算都是基于二进制来运算的,其实 n |= a 可以表示成 n = n|a,在表达方面类似与 a += b的格式,只不过比较少用到,因而看起来有点怪。
这短短的几行代码,感觉真是牛逼,所以有时多读点源码感受下大神的写法挺好的。
对于这个算法笔者表示一开始也没能看出个所以然,还好奇为啥移动的位数一定得是2的次方。
当在纸上将拿几个值出来画画,才发现该算法最终的目标是将二进制中的首位 1 后面的所有值都变成 1。将首位 1 后面的都变成 1 ,为了最终得到的结果加上 1 之后为2的次方幂,那么每次移动的位数自然而然也得是2的次方了。
5. 支持的最大值(2的30次方,够夸张...)
static final int MAXIMUM_CAPACITY = 1 << 30;
6. Map当前所存在的数据量大小
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
如果仔细研究HashMap源码会发现还有挺多的属性,但基本上我们平常能用到也就以上这些了。
二. HashMap的基本操作
介绍完HashMap基本属性后,下面进行HashMap常用操作的简述。HashMap其实主要涉及 Put 与 Get方法。
由上文我们知道,对于HashMap来说,其底层对应的是一个Node数组,数组中存储的是key-value这类的键值对。
既然是数组,那么就必须得知道数组下标才方便存储吧。像HashMap支持通过传入key的方式实现O(1)检索数据。
那么HashMap是如何将传入的key-value键值对转换成数组下标的呢?又是如何通过传入key的方式实现像数组随机读取的功能的呢?
Put方法的原理
在Java中,往HashMap中插入数据直接调用其 put(key,value)方法便可,当原先的Map中存在该key则更新覆盖。
其原理通俗来说就是 HashMap 通过hash算法将key值转换成对应的数组下标,然后再将该key对应的value值存入该下标对应的存储单元中。HashMap在初始化后生成的数组对应的每个元素都为空,如下图所示:
在进行put方法后,例如 hashMap.put("apple", 0),此时hashMap会调用hash算法进行hash(apple) 转换为数组下标,假定这里转换后的下标为2,那么上述过程就变成如下图所示:
由hashMap中对数组的存储,我们可以知道下标2对应的Entry值为hello-BOBO键值对;
当进行多次Put数据后,由于数组的长度有限,那么在转换成数组下标时难免会出现不同的key对应同一个下标,这也就是出现散列冲突了。HashMap采用链表的方式进行存储(当出现链表长度超过8时,就会采用红黑树来存储,这个后文会详细描述),当出现冲突时,将新插入的数据放到头部,如下图所示:
Get 方法的原理
在Java中,可直接通过传入key值调用hashMap的get(key)方法便可实现近O(1)时间内随机检索数据。
其原理其实就是运用了数组下标随机读取数据的特点,将转入的key通过与put同样的hash算法转换成数组下标,然后根据数组下标对数据进行随机读取。
由于可能存在冲突问题,当出现冲突时需要再对Entry对象中的key进行值对比。如下图所示:
第一步,我们查看的是头节点Entry6,Entry6 的Key是 banana,显然不是我们要找的结果。
第二步,我们查看的是Next节点 Entry1,Entry1的Key是apple,正是我们要找的结果。
之所以把 Entry6 放在头节点,是因为HashMap的发明者认为,后插入的Entry被查找的可能性更大。
三. 疑问点小记
笔者在学习过程中的一些个人疑点
疑问一:为什么HashMap底层采用数组的形式来存储数据呢?
原因:采用数组主要是为了应用到散列表的快速检索的功能。
疑问二:为什么加载因子设为0.75呢?
原因:由散列表的特性,我们知道当数据插入散列表时会出现散列冲突的情况,当加载因子设置的过大,那么链表的长度可能会越长,散列冲突的可能性会加大,从而导致数据插入或检索效率低下。
当设置的过小又会造成内存的浪费,因此折中设置成0.75,当然我们可以根据实际业务场景对这一设置进行适当的修改。
疑问三:HashMap的初始值为啥设置成16呢?(或者说为什么HashMap容量一定要为2的幂呢?)
其实主要是因为2的幂减 1 后,其二进制所有位对应的都是 1。如16减1后15 对应的二进制为 1111,那么当其与hashcode值进行与计算时,将会保证取的是hashcode值为真实hashcode值的尾部。
疑问四:为什么一般情况用hashcode值与hash表长度做模运算映射数组下标呢?以及HashMap为什么不用取模而是用位运算呢?这里的原理到底是怎么样的呢?
原因:首先需要知道的是无论是取模还是位运算,其实都只是为了让映射后的数据均匀分布到数组中。而让映射后的数据均匀分布的原理,只不过是因为hash函数计算出来的hashcode值理论是比较均匀分布的,我们对hashcode取模也只是为了截取对应数组长度的值。这里一开始笔者想竟然只是要截取对应长度的hashcode值,那为啥不直接用string对应的字符切割方法呢?对于这个疑问只要你去瞧瞧string字符切割方法的源码,便清楚的很了,其效率跟与运算简单不是一个量级的。
知道了这一点,那就可以定向分析了,问题也就变成了为啥hashcode值与hash表长度做模运算会使数据均匀分布了。其实这一点的原理很简单,那就是模运算其实就是求余,例如当hash的长度为16,那么对应与任何值与16求余也只可能会是0到15,而结合hashcode值的随机分布,便很容易便知道这个疑问的原因了。为啥hashMap采用位运算(index = HashCode(Key) & (Length - 1)),其实底层原理跟模运算是一样的,只不过是位运算的效率比模运算的高。
四. Put 方法与Get方法源码分析
Put 方法源码分析
源码如下:
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
在上述方法中调用了 putVal() 方法,需要注意的点:
1. 在进行插入数据时需要调用 hash函数对key进行转换成数组下标;源码如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
2. 当Map容量达大于临界值则进行扩容;
if (++size > threshold)
resize();
未完待续。。。。