JDK1.7中的HashMap
Posted 愉悦滴帮主)
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JDK1.7中的HashMap相关的知识,希望对你有一定的参考价值。
JDK1.7中的HashMap
HashMap:底层是数组加链表
ArrayList:底层是数组
问题1:为什么HashMap插入时会根据key插入?
首先对比ArrayList的新增方法,假设我们新增没有设置索引下标,那么ArrayList进行新增时会默认从0开使依次递增。这样做插入效率是比较高的。那么为什么HashMap没有这样做呢?
//add方法
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
思考:HashMap访问查询效率低,原因:因为HashMap底层时数组加链表,假设我们的hashmap数组长度为3每个链表的长度也为3.这样我们就得到一个3x3的表格。当我们把3x3表格存满时,通过get(key)去查询的时候,假设我们get(5),这时我们就要查询表格第二行第二个位置的数据。第一步我们首先要知道该数据的位置位于第几条链上(对5取余,取余也是hashmap拿到要存储位置下标的过程),然后再去遍历该链最后返回数据。这是一个效率非常慢的一个过程,所以hashmap 直接对key位置进行插入操作,这样做查询的时候就仅仅遍历对应的链表就行了。
问题2:HashMap的put()方法的原理是什么?
首先hashmap.put(key,value)的工作过程是什么?1.拿到key 2.key.hashcode(); 3.key.hashcode()%table.length。这时我们会得到一个0 - length-1的index下标位置。这时有可能遇到一个当前下标存在值的一个情况,这时我们要存入的值会插在链表的头部还是尾部?
头插法和尾插法那个效率更高?
头插法:我们只需发链表头部索引指向要插入的对象,然后将key索引位置指向链表头部 table[index] = new Entry{key,value,table[index]}。
尾插法:我们首先要遍历一遍该链表得到链表尾部指针,将尾部指针指向要插入的对象,然后将key索引位置指向链表头部。
备注:在JDK1.7的hashmap进行put时,我们发先无论头插法还是尾差法都会有一个遍历链表返回原数值的过程。也就是说当对应数据已经存在(key相等)且位于链表末尾则头差法尾差法的效率都是一样的。其他大部分情况下还是头插法效率快一些。
下面是代码实现:
package com.company;
/**
* 节点
*/
public class Node {
private int no;
private String name;
private String job;
Node next;
}
/**
* 链表
*/
public class SingleLinkedList {
//定义一个头节点
private Node headNode = new Node(0,"","");
//头插法
public void headInsert(Node node){
//如果链表为空,直接headNode.next = node
if(headNode.next==null){
headNode.next = node;
return;
}
//如果链表不为空,headNode.next指向node,node.next指向之前的headNode.next
Node temp = headNode.next;
headNode.next = node;
node.next = temp;
}
//尾插法
public void tailInsert(Node node){
//如果链表为空,直接headNode.next = node
if(headNode.next==null){
headNode.next = node;
return;
}
Node temp = headNode.next;
while (true){
if(temp.next == null){
break;
}
}
//跳出循环后,temp即为最后一个节点
temp.next = node;
}
}
3.源码分析
3.1:基本参数
默认初始化化容量,即16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
最大容量,即2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
默认装载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
HashMap内部的存储结构是一个数组,此处数组为空,即没有初始化之前的状态
static final Entry<?,?>[] EMPTY_TABLE = {};
空的存储实体
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
实际存储的key-value键值对的个数
transient int size;
阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold
int threshold;
负载因子,代表了table的填充度有多少,默认是0.75
final float loadFactor;
用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
transient int modCount;
默认的threshold值
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
3.2 构造方法
//计算Hash值时的key
transient int hashSeed = 0;
//通过初始容量和状态因子构造HashMap
public HashMap(int initialCapacity, float loadFactor) {
//参数有效性检查
if (initialCapacity < 0)//参数小于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;
threshold = initialCapacity;
init();//init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现
}
//通过扩容因子构造HashMap,容量去默认值,即16
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//装载因子取0.75,容量取16,构造HashMap
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
//通过其他Map来初始化HashMap,容量通过其他Map的size来计算,装载因子取0.75
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);//初始化HashMap底层的数组结构
putAllForCreate(m);//添加m中的元素
}
3.3 初始化方法
如何判断是否为2的幂次方数?首先将传入参数转换为二进制,例如:1----0000 0001, 2----0000 0010 , 3----0000 0011 ,4----0000 0100。我们得知2的幂次方数的二进制只有一个bit位。
判断传入参数是不是大于等于2的幂次方数。如果是则判断是否大于最大容量,如果否判断是否为1,是则返回1,否则进行Integer.highestOneBit((number - 1) << 1)运算。
例如:17 0001 0001
第一步: i |= (i>> 1);右移一位后进行或运算 >>1(左移) 0000 1000 |(或运算) 0001 1001
第二步: i |= (i>> 2);右移二位后进行或运算 >>2 0000 0110 | 0001 1110
第三步: i |= (i>> 4);右移四位后进行或运算 >>4 0000 0001 | 0001 1111
第四步: i |= (i>> 8);右移八位后进行或运算 >>8 0000 0000 | 0001 1111
执行: i-(i>>> 1) >>1 0001 1111 -> 0000 1111 - 0001 0000 -> 1
备注:最后为什么要执行 i |= (i>> 16),防止入参过大,例如int类型32位;
private void inflateTable(int toSize) {
int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次幂
//此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];//分配空间
initHashSeedAsNeeded(capacity);//选择合适的Hash因子
}
通过roundUpToPowerOf2()方法保证初始化容量是2的幂次方数
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
public static int HighestOneBit(int i){
i |= (i>> 1);
i |= (i>> 2);
i |= (i>> 4);
i |= (i>> 8);
i |= (i>> 16);
return i-(i>>> 1);
}
3.4 put(key,value)源码
第一步检验:put方法首先会先判断数组是否为空,如果为空则调用inflateTable初始化一个,默认值为16大小的数组。然后判断key为不为空,如果为空则默认存入数组table[0]的位置上,因为初始化数组时默认数组table[0]的位置有值,那么就会存在冲突。因此hashmap进行put操作时key不为空。
第二部计算存入的索引下标位置:首先调用hash()方法得到hash值,hashSeed默认为0,通过将hashSeed与k.hashSeed进行异或运算(法则:一真为真)后进行一系列的左移异或运算,将原本的hashcode值变得更随机更分散。然后通过 return h & (length-1);操作得到下标(取低四位)。如果数组容量不是2的幂次方数,那么进行l异或操作的时候,得到的下标可能超过数组长度。
这里说明了为什么初始化时候容量位2的幂次方数这个问题。
//用了很多的异或,移位等运算,对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {//这里针对String优化了Hash函数,是否使用新的Hash函数和Hash因子有关
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
//保证高位参加进运算,从而保证结果更分散更随机
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
为什么要进行这么多左移异或运算呢?通过让二进制高位参加进入运算,进而保证得到的下标位置更散列。
//返回数组下标
static int indexFor(int h, int length) {
return h & (length-1);
}
第三步遍历索引位置处链表:如果要存入的value值在该链表位置已经存在,则执行覆盖操作,用新的value值替换旧的value值,最后返回旧的value值。
第四步modCount++:保证并发访问时,若HashMap内部结构发生变化,快速响应失败,防止运行后续代码脏数据。
第五步新增一个Entry对象存入hashmap中。
public V put(K key, V value) {
//如果table数组为空数组,则初始化数组默认16大小
if (table == EMPTY_TABLE) {
inflateTable(threshold);//分配数组空间
}
//如果key为null,存储位置为table[0]或table[0]的冲突链上
if (key == null)
return putForNullKey(value);
int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
int i = indexFor(hash, table.length);//获取在table中的实际位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);//调用value的回调函数,其实这个函数也为空实现
return oldValue;
}
}
modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
addEntry(hash, key, value, i);//新增一个entry
return null;
}
3.5 hashmap扩容
在jdk1.7中的hashmap扩容是通过resize()方法实现的。但是必须满足size超过临界阈值threshold(默认值为12,16*0.75f),并且即将发生哈希冲突时进行扩容,新容量为旧容量的2倍
扩容场景:单线程:首先会生成一个原数组双倍容量的一个数组,然后遍历原数组,如果数组位置不为空则遍历该位置的链表,然后从新计算原数组的每一个key的下标位置,这时会出现一个计算出两个下标位置的情况:比如原key下标位置为3,那么扩容计算后得到的下标位置可能为3,或者table.length+3两种情况,这么做的好处是让数组更分散,链表更短。
多线程:在多线程的场景下,有可能出现循环链表的情况,这时你在get(key)的时候就会出现一个死循环的情况。所以我们在用hashmap的时候最好不让他扩容或者定义一个大小够用的hashmap.
扩容的目的:将链表的长度变短。
hash种子的目的:在put时计算要存入数组下标位置更加的分散。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容,新容量为旧容量的2倍
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);//扩容后重新计算插入的位置下标
}
//把元素放入HashMap的桶的对应位置
createEntry(hash, key, value, bucketIndex);
}
//创建元素
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex]; //获取待插入位置元素
table[bucketIndex] = new Entry<>(hash, key, value, e);//这里执行链接操作,使得新插入的元素指向原有元素。
//这保证了新插入的元素总是在链表的头
size++;//元素个数+1
}
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;//修改HashMap的底层数组
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//修改阀值
}
以上是关于JDK1.7中的HashMap的主要内容,如果未能解决你的问题,请参考以下文章
Java中HashMap底层实现原理(JDK1.8)源码分析