@jdk7Hashmap
jdk7Hashmap
本片文章主要是为了JDK7的Hashmap源码分析。
hashmap
首先我们要明白jdk7的hashmap底层是由数组+链表组成的,数组和链表的创建是怎么回事呢,首先了解一下大致的流程,首先是创建Hashmap,创建hashmap时是可以指定底层数组的大小,如果不指定数组大小,则会自动创建2的4次方长度的数组。当我们在使用put方法的时候,会通过put的key调用**hashCode()**方法来获取一个hash值,因为hash值是非常大的,刚刚也说了数组大小初始只有16,所以肯定是不能来拿哈市值直接作为数组的下标的,java刚开始通过下面的方式获取下标:
int index = hashcode % arr.length
获取下标需要满足两个条件:
1.每个角标出现的次数尽量相同,
2.角标不能超过数组长度;
因为这样求下标的效率比较低在jdk7中使用了移位的方式来计算,具体怎样实现接下来会讲到。获取到角标之后就可以在数组中存入对象了,我们可以发现这样存储肯定是会出现相同下标的数据的,这是链表就出现了,如果出现相同下标的数据时会使用链表来进行存储,数组中存储的只是链表头节点的引用,头节点实际上是entry对象的引用,entry对象里有key,value,下一个节点entry对象,还有个hash ,对象存在堆内存中。接下来一一详细的来讲。
hashmap的创建
不指定数组长度创建
HashMap<String,String> hashMap = new HashMap<>();
指定数组长度创建
HashMap<String,String> hashMap = new HashMap<>(9);
1.上面讲到不指定数组长度时,会在初始化时创建2的4次方长度的数组。
2.指定数组长度时,这里指定数组的长度为9,但是真正在初始化时创建数组的长度不是为9而是16。
创建数组是在使用put方法时进行初始化创建。我们来看看put方法的源码。
public V put(K key, V value) {
//判断数组是否存在
if (table == EMPTY_TABLE) {
//如果不存在创建数组
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
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++;
添加entry对象,进入addEntry之后会有判断扩容,两倍扩容,之后还得是2^n
//数组扩容条件:1.已经存在的key-value mappings的个数大于等于阈(yu)值 (数组的长度乘以加载因子的值),因为加载因子默认0.75,所以要想不扩容就设为1,下标就不会大于yu值。
// 2.底层数组的bucketIndex坐标处不等于null
addEntry(hash, key, value, i);
return null;
}
进入inflateTable方法
private void inflateTable(int toSize) {
// 通过roundUpToPowerOf2方法获取大于toSize最近的2次方数
int capacity = roundUpToPowerOf2(toSize);
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//最终创建entry对象的数组
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
进入roundUpToPowerOf2
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;
}
这里最终会执行 Integer的highestOneBit方法。
首先我们来说一下highestOneBit方法,
//得到刚好比输入值小的2次方
public static int highestOneBit(int i) {
// HD, Figure 3-1
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
return i - (i >>> 1);
}
这里主要是通过右移之后与运算来实现的接下来写一个例子比如输入12见下图,可以看出在右移四位和八位之后最终得到的数都是一样的,因为在指定数组大小时如果大于2的30次方时会都会给threshold(输入值)赋值2的30次方,所以不管输入什么最终得到的值低位都是1,所以经过运算i=0000 1111=15,最后的是return i - (i >>> 1); ( i>>>1,是得到最高位为0其他位为1), 所以i - (i >>> 1)=0000 1111 - 0000 0111=0000 1000=8,所以最终返回8。
Integer.highestOneBit((number - 1) << 1)这是最终得到数组长度的方法,当指定长度为12也就是number=12 ,((number - 1) << 1)就是将值减1再乘2。最终得到的值是比指定数组长度刚刚大的2次方数。这里减1乘2主要是为了值刚好是2的次方的数,他们需要返回他自己,比如8,(8-1)*2=14,14在进行highestOneBit方法得到8。所以不过指定数组长度为多少最终得到的长度都是2的次方数。至于为什么一定要是2的次方接下来会讲到。
put
put首先将key调用hashcode,生成hash值,具体生成hash值得方法如下
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
//首先通过hashcode方法获取hash值
h ^= k.hashCode();
// hash值进行右移之后做异或操作(不同为1),增加hash值的散列性
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
最终得到的hash值太大不能作为数组的角标(key如果等于null直接会将entry对象放在数组下标为0的位置,注意数组下标为0的地方不一定只是key为null,还可能是通过下面的算法算出角标为0的可能,所以还是需要遍历,覆盖), 所以需要处理,并且要满足前面说的,
1.每个角标出现的次数尽量相同,
2.角标不能超过数组长度,
会通过以下方法获取下标
static int indexFor(int h, int length) {
return h & (length-1);
// 例如长度为16-1
0000 1111 数组长度
0001 0100 hash
0000 0100 结果
}
数组长度设定为了2的幂次方,比如长度为16,那么二进制为0001 0000 这里会减一以后为15,0000 1111,所以低位都为1,其他2的幂次方也是一样减1之后低位都是1,因为是与运算所以不管哈希值多大,计算到的位数都只会是与数组长度的二进制一样,比如,哈希值为20,0001 0100,数组长度减1为 0000 1111 与运算之后的结果为,0000 0100,可以看出结果只与哈希值低位有关,数组长度约束了结果的大小,能够取到的值是在数组范围之内,并且哈希值随机的,低为肯定也是随机,(所以这也就是为什么前面需要使用右移异或增加散列性的原因,如果不右移做异或那么这里计算就只与低位有关,高位毫无关系,右移异或操作之后,高位也参与进来,增加了散列性。)也就满足角标出现次数尽量相同 这是jdk7获取数组角标的方法和哈希值取余一样能够满足 要求只是效率比这个低,所以jdk7没有再用下面获取角标的方法,不过能够更好的理解角标获取的要求,也是讲课刚开始讲的。这也是为什在初始化时为什么要将数组长度初始化为2的次方。
给entry对象赋值,1头插法插入链表。2更新数组的头节点
void createEntry(int hash, K key, V value, int bucketIndex) {
将需要插入的数组位置的entry对象table[bucketIndex](也就是当前的头节点的引用)赋值给entry对象
Entry<K,V> **e** = table[bucketIndex];
新建一个entry对象,注入值,主要是将已存在的链表头节点entry e,作为自己下一个节点,(也就是新节点,也就是链表的头插法),最后将entry对象(现在链表头节点)的引用赋值给当前的数组的位置,也就是更新头节点
table[bucketIndex] = new Entry<>(hash, key, value, **e**);
size++;
}
简单来说就是将已存在的链表的头节点给新插入的entry对象作为下一个节点。
链表
当确定数组的下标的时候,我们会发现,会出现相同数组下标的情况。这样就出现了链表,我们知道数组存的是entry对象的引用,当出现相同下标的时候,就会形成链表的形势存储,并且为了插入效率,采用插入头节点的方式,如果插入尾节点,需要遍历整个链表,找到尾节点也就是下个节点为null的节点,头节点插入只需要将当前链表的头节点赋值给新加entry对象中的下个节点,不需要遍历链表。因为是单向链表,所以查询时只能从头节点向下查询,每次插入头节点之后都会将数组中头节点引用替换覆盖成最新头节点引用。put会有返回值,当map中插入值时,会去遍历链表,查找是否存在当前key,不存在当前存入的key时,返回null,存在时返回已存在key的value,并且会用当前value将其覆盖,保证key value一一对应,有人会说既然都要遍历为什么还要头节点插入,因为这里遍历链表查找是否有已存在的key,当找到之后就不再遍历还未遍历的,头插效率还是比尾插高。
扩容
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
新数组扩容两倍,下面会详细讲resize
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
扩容之后获取下标
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
resize
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
transfer(newTable, initHashSeedAsNeeded(newCapacity));
将新数组赋给hashmap的数组对象
table = newTable;
重新计算阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
transfer
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
主要的就是这里的转移值,将老数组的值转移到新数组
与插入数据createEntry的操作基本相同
e.next是数组中存的entry对象头节点的引用,这里将新数组需要插入的位置赋值值老数组头节点引用,再将老数组的entry对象引用赋值给新数组需要插入的位置,其实也就是两点,1头插到老数组,2现在的老数组头节点后的节点都是来自于新数组,再将引用赋值给新数组,就完成了将老数组的头节点转移 到新数组头节点,这里老数组中一个下标对应的链表不一定全都,转移到新的数组中一个下标中,这也是扩容真正目的缩短链表。因为数组长度改变,计算出的下标也会发生改变。
e.next = newTable[i];
更新新数组的引用
newTable[i] = e;
将老数组的刚转移完的头节点的下一个节点(现在他就是老数组的头节点了)赋值给e,再将e循环进行相同的操作,将这个节点转移
e = next;
}
}
}
简单的说就是
1.将新数组链表头节点newTable[i]给到老数组链表的头节点的下一个节点e.next;
2.将产生的链表e赋值给新数组对应下标的位置;
在多线程扩容的情况可能会出现,循环链表的情况,再put和get会造成死循环。主要是因为转移完之后链表中元素会颠倒,第一个线程完成转移之后,第二个线程还未完成那么顺序已经不是原来的顺序 了,这就是主要原因。
扩容的主要目的不是将数组变长而是将链表变短,通过改变数组长度,进而改变数组下边
hash种子可增加hash值散列性,默认为0不起作用。