1 HashMap概述
本文描述的是JDK1.7
在了解HashMap之前,我们来了简单的了解一下其他数据结构。
我们知道,数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中。
- 数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)
- 线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)
- 二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。
- 哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。
大概了解了上述知识后,我们来讲解HashMap。
HashMap是我们最常用的Map集合类,底层实现是哈希表,检索数据可达到O(1)时间复杂度。
1.1哈希寻址
哈希表是一种逻辑数据结构的底层是通过数组实现的。
看下图:
由上图可知,每一个存入HashMap的key-velue,key都会经过哈希算法,计算出一个数字index,然后将value存放在数组arr[index]的元素上。由此可见,每次取值也只需要通过key算出index,去arr[index]处读取值即可。这样的存值和读取每一次的步骤几乎是一个差不多的常数,所以时间复杂度为0(1)。
观察上面的实现步骤我们可以发现,哈希算法才是整个取值和读值的重点,我们无法保证不同的key不会算出一样的index,如果算出一样的index,一个数组元素里如何存放另两个值?这种现像,我们叫做哈希碰撞,也叫哈希冲突。
1.2哈希冲突
解决哈希冲突的方法很多: 1 开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),2 再散列函数法,3 链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式。
其实HashMap底层的数组每个元素是一个链表,如果index冲突后,直接逐个存放到链表后。
如下图:
以上就是HashMap的大概实现逻辑。
2 HashMap底层解析
2.1 HashMap当中的常量和变量
HashMap中有几个常量和变量还有概念要先理解一下。
MAXIMUM_CAPACITY = 1<<30=2^30:hashMap允许创建的最大长度,即底层数组的长度。
EFAULT_LOAD_FACTOR = 0.75:默认加载因子。
DEFAULT_INITIAL_CAPACITY = 16:默认HashMap的初始化长度。
size:HashMap中存放的key-value的数目。
modCount:修改HashMap的次数,每次增删改的时候都会+1。
threshold:容量上限 ,用容量*负载因子求得,当size>threadshold时,HashMap会扩容。
2.2 HashMap的构造方法
1 public HashMap(){this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);}
2 public HashMap(int initialCapacity){this(initialCapacity, DEFAULT_LOAD_FACTOR);}
3 public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
putAllForCreate(m);
}
4 public HashMap(int initialCapacity, float loadFactor) {......}
由上代码可知,HashMap提供四个构造方法。1 2 3 构造函数最后都是调用了4的构造函数,实例化HashMap'主要细节全部都在4构造函数里,接下来让我们来详细看看。
public HashMap(int initialCapacity, float loadFactor) {
/*容量不能小于0*/
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
/*输入的容量大于最大容量,只用最大容量当创建HashMap的容量*/
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
/*负载因子不能小于0,且不能为NaN,
NaN是浮点型一个无法表达的值,例如负数的平方根,0数除以0的时候都得NaN,非0除以0是得到无限大Infinity*/
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
/*算出最接近initialCapacity的2的倍数的容量*/
int capacity = 1; //创建的真实容量
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
/*在最大容量和 指定容量*负载因子 里挑最小值*/
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//容量上限
/*创建指定容量的底层数组*/
table = new Entry[capacity];
/*这个参数的作用是在算哈希时,决定字符串是否使用另外一种hash算法*/
useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
init();//子类会重写该方法,给子类留的入口,这里不研究这个
}
流程图如下:
上诉代码需要注意的是下面的代码
int capacity = 1; //创建的真实容量
while (capacity < initialCapacity)
capacity <<= 1;
这里决定了不管我们在调用构造方法时传入什么initialCapacity,都会被转换成2^n值capacity ,并求出最接近initialCapacity的2^n,且大于initialCapacity的2^n。
这中种做法是为了让HashMap'底层数组的长度总是2^n,为后面的求数组小标做准备。
负载因子的作用
由上可知上限容量threshold=真实容量*负载因子;
HashMap的哈希表中的哈希碰撞经常发生时,会造成链表被拉长,检索会变慢。当底层数组越大时,哈希碰撞发生的可能性越小,反之越大,即常说的用空间去换时间。
当我们往HashMap中存的值size>threshold时,HashMap底层数组会扩容,变成原来的两倍长度,并将旧数组中存的值重新哈希,放到新的数组中。
负载因子就是控制用空间换时间的程度。负载因子越大,即扩容发生可能性越低,底层数组会用教少的数组存值,哈希碰撞的可能性会更高,检索速度肯定更慢;
反之负载因子越小,扩容发生的可能性越高,底层的数组会用较多的数组存值,哈希碰撞的可能性越低,检索速度自然快。
举例:负载因子a=0.5,b=0.8; 数组长度capacity=16.存30个值,
capacity=16 | 上限容量 | 扩容次数 | 扩容后的数组长度 |
a=0.5 | 8 | 2次 | 64 |
b=1 | 16 | 1次 | 32 |
由上表可以看到,最终负载因子高的HashMap会用更小的数组存更多的数据,从而造成检索变慢。
2.3 HashMap的put()方法
HashMap通过put(Object key,Object value)方法插入key-value。
public V put(K key, V value) {
/*键如果为空*/
if (key == null)
return putForNullKey(value);
int hash = hash(key);//求出hash值
int i = indexFor(hash, table.length);//算出的数组下标
/*使用数组下标找到链表,遍历链表,求出是否有一样key的结点,有就替换值,没有就往下走,去添加结点*/
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);//添加结点
return null;
}
看代码可知,HashMap通过哈希算法求得数组下表分为两步:
第一步:使用int hash = hash(key);通过key求哈希值
第二步:使用int index = indexFor(hash,table.length);用hash值求数组小标index
hash()算法会进行一堆移位运算。代码如下
final int hash(Object k) {
int h = 0;
/*该值为true,且key类型为字符串,key采用指定的方法求哈希,据说这样可以减少哈希碰撞*/
if (useAltHashing) {
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
h ^= k.hashCode();
return h ^ (h >>> 7) ^ (h >>> 4);
}
为什么算出的数组小标不会超出数组的长度呢?
看indexFor()方法代码:
static int indexFor(int h, int length) { return h & (length-1);//长度-1是为了,让二进制表示时,最后一位为1,这样进行与运算的时候减少哈希冲突。 }
通过indexFor()方法代码可以知道,通过与(&)运算,保证求得的值永远小于length。
例子:用1010和1101进行与运算和或运算。
第3位 | 第2位 | 第1位 | 第0位 | |
1 | 0 | 1 | 0 | |
1 | 1 | 0 | 1 | |
与(&) | 1 | 0 | 0 | 0 |
或(|) | 1 | 1 | 1 | 1 |
看结果可知,与运算使得每一位值都不会大于当前值,所以总数最后也不会大于当前值。而或运算是可能会大于当前值的!
因为数组扩容都是偶数,即长度也为偶数,length-1后,肯定会变成奇数。强制使用奇数的目的是将length-1换算成二进制时,最后一位会为1,1做与(&)运算时,可以降低哈希冲突的概率。
做与运算 | 0 | 1 |
0001 | 0000 | 0001 |
0000 | 0000 | 0000 |
看上表可以知道,末尾为1时出现相同数据的概率比末尾为0时高!
看一下总的put()的流程图
由上图可知,当HashMap存放key-value的数量size > 上限容量threadShold时,会发生扩容,在扩容时发现此时的HashMap底层的数组长度capacity已达到最大值时,将不会发生扩容操作,只是将上限容量值设置为capacity的最大值,然后直接返回。所以HashMap的底层数组长度永远都不会超过规定的最大值2^30。只要你的内存够大,可以存放无限的key-value。
HashMap的get(Object key);基本逻辑就没什么特殊的地方了,只是简单的那key算出hash,用hash算出index,去底层数组arr[index],读取该元素中存的链表,遍历链表。找到链表的中key与之相等的结点,返回结点的value。
最后我们来看一下底层链表结点的数据结构:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}
存的是key,value,算出来的hash值以及下一个对象next。
hash值是用来当扩容时,再调用indexFor()求得数组下标!
2.4 Holder
HashMap中还有一个内部静态类Holder,该类的作用是保存在VM(虚拟机)加载后才能初始化的值。具体各种作用,没有深入研究。
private static class Holder {
// Unsafe mechanics
/**
* 不安全的工具
*/
static final sun.misc.Unsafe UNSAFE;
/**
* 我们必须在readObject()方法中设置“final”散种子字段的偏移量。
*/
static final long HASHSEED_OFFSET;
/**
* 表容量超过此值可切换使用其他散列
*/
static final int ALTERNATIVE_HASHING_THRESHOLD;
static {
//获取系统变量jdk.map.althashing.threshold
String altThreshold = java.security.AccessController.doPrivileged(
new sun.security.action.GetPropertyAction(
"jdk.map.althashing.threshold"));
int threshold;
try {
threshold = (null != altThreshold)
? Integer.parseInt(altThreshold)
: ALTERNATIVE_HASHING_THRESHOLD_DEFAULT;
// jdk.map.althashing.threshold系统变量默认为-1,如果为-1,则将阈值设为Integer.MAX_VALUE
// disable alternative hashing if -1
/*禁用替代哈希*/
if (threshold == -1) {
threshold = Integer.MAX_VALUE;
}
/*阀值要大于0*/
if (threshold < 0) {
throw new IllegalArgumentException("value must be positive integer.");
}
} catch(IllegalArgumentException failed) {
throw new Error("Illegal value for 'jdk.map.althashing.threshold'", failed);
}
ALTERNATIVE_HASHING_THRESHOLD = threshold;
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
HASHSEED_OFFSET = UNSAFE.objectFieldOffset(
HashMap.class.getDeclaredField("hashSeed"));
} catch (NoSuchFieldException | SecurityException e) {
throw new Error("Failed to record hashSeed offset", e);
}
}
}
第一次完整的开一份源码,描述不当或理解有误之处请提出,让我多学习学习。
参考博客:https://www.cnblogs.com/chengxiao/p/6059914.html