您的位置:首页 > 其它

HashMap 的源码分析

2017-11-06 20:44 337 查看

一.java中的位运算符

在具体分析之前,先补充点基础知识

1.1 算术位运算符

<< :代表左移 << 3 左移三位,即本来数值 乘于 2^3; 左移低位补0

public void test(){
int x = 4;
System.out.println(Integer.toBinaryString(x)); //100
int y = 4<<2;
System.out.println(Integer.toBinaryString(y)); //10000
System.out.println(y);   //16 = 4*2^2
}


>>:代表右移 >> 3 右移三位, 即本来数值处于2^3; 右移,最高符号位不变,其余位补0

public void test(){
int x = 16;
System.out.println(Integer.toBinaryString(x)); //10000
int y = 16>>2;
System.out.println(Integer.toBinaryString(y)); //100
System.out.println(y);   //4 = 16/2^2
}


>>>:代表无符号右移 任何值都会移动。没有最高位作为符号位一说了。

public void test(){
// 为正数时,无符号右移
int x = 16;
System.out.println(Integer.toBinaryString(x)); //10000
int y = 16>>>2;
System.out.println(Integer.toBinaryString(y)); //100
System.out.println(y);   //4

// 为负数时,无符号右移
int x = -16;
System.out.println(Integer.toBinaryString(x)); //11111111111111111111111111110000
int y = -16>>>2;
System.out.println(Integer.toBinaryString(y)); //00111111111111111111111111111100
System.out.println(y);   //1073741820
}


由上面的代码可知:当一个数为正数时,>> 和 >>> 作用是一样的,也可以作为除于2 来表示。但当一个数为负数时,>> 和 >>> 就不能等价了。来分析一下上面的代码:

System.out.println(Integer.toBinaryString(-16)); //11111111111111111111111111110000

为什么-16的二进制码这样表示,在计算机中是这样表示的呢?

在计算机中,数据的存储和计算都是采用补码的形式,这样做的好处是在计算机中,加减都能变成加法: A-B=A+(-B补码)。因此-16的原码是1000/0000/0000/0000/0000/0000/0001/0000 它的补码按照规则:从低位开始,一直到第一个为1的位数,保留这个1,之后除符号位,所有的位数取反。 因此补码就如上所示。

int y = -16>>>2; 即无符号右移4位,因此二进制形式变成了

0011/1111/1111/1111/1111/1111/1111/1100

最高位符号位发生了改变,右移高位补0,所以直接变成了正数了。

可以看出来,>>>的作用并不是乘除,最典型的应用就是获取 int 类型的符号位。

通过这个式子 int y = (x>>>31) & 1 来获取符号位,如果 y = 1,负数,y = 0,正数。

1.2 逻辑位运算符

& 与:对二进制每一位进行逻辑与运算, 都为 1 才为 1。

1100 & 0101 = 0100


与位运算的典型应用如下:

将数据清零 1101 & 0000 = 0000

获取数据特定位,如,获取 101010 的低4位

101010 & (16-1) = 101010 & 1111 = 1010

保留数据特定位,如,保存 10110101 的 低3位

10110101 & 00000100 = 00000010

| 或:对二进制每一位进行逻辑或运算,有一个为 1 就为1 。

1100 | 0101 = 1101
或运算的运用不多,主要是对特定位置 1
如,把 11010100 的 低三位置 1
11010100 | 0x7 = 11010111


^ 异或 : 也叫半加法,即加了不进位 。相同为0,不同为1.

1010 ^ 1011 = 0001


异或的性质:

n ^ 0 = n;      //任何数和0异或,为他本身;
n ^ n = 0;     // 任何数和自己异或,为0;


典型应用:

不交换也可以两个数互换:

a = a ^ b;
b = b ^ a;  //b = b ^ a ^ b = a
a = a ^ b;  //a = a ^ b ^ a = b


排除一个数组中出现次数为奇数的数字:

public int getOdd(int[] arr){
int x = 0;
for(int i = 0; i < arr.length; i++){
x ^= arr[i];
}
return x;
}


将指定位取反

// 将第四位取反
1100101 ^ 0xf = 1101010


将内容加密解密

假设一篇文章 ,将所有字符都和一个密码psw 异或,加密;
然后再异或一次,就可以还原,解密。


位运算符的优先级: 优先级由高到低

~ << >> >>> & ^ |

二. 哈希函数:

2.1 概念:

Hash,也可以叫做散列,就是把任意长度的输入(又叫做预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,而不可能从散列值来唯一的确定输入值。

2.2 哈希函数的实现和讨论

hash转换一般是一种压缩映射,这里以哈希表的实现来进一步解释这句话:



整个图是是HashMap的实现,下面会详细分析,但这并不是散列表,散列表只是图中左边那个有着固定长度的数组,而后面的链表是为了解决hash冲突而产生的。也就是被压缩成的固定长度,它的长度才是经过hash函数之后得到的值。

在看到这个图的时候,脑海中想一下,什么样的hash函数才能称作好的hash函数呢?

首先数据肯定得最好能均匀排列

hash转换的效率要高

先来看一个最简单的hash法:取余法

// m为table的长度
public int hash(int key){
return key % m;
}


关键在于 m 的取值,最好是素数,这种设计能最大可能让数据均匀分布在数据表中。来实际证明一下,对0~20 进行hash

表1 : m = 6

012345
012345
67891011
121314151617
181920
表2 : m = 7

0123456
0123456
78910111213
14151617181920
通过两个表对比,不是都分布的很均匀吗?

但是,要记住一点原始数据不大会是真正的随机的,可能有某些规律,比如大部分是偶数,这时候如果HASH数组容量是偶数,容易使原始数据HASH后不会均匀分布。

比如 2 4 6 8 10 12这6个数,如果对 6 取余

012345
24
6810
12
得到 2 4 0 /2 4 0 只会得到3种HASH值,冲突会很多。

如果对 7 取余

0123456
246
81012
得到 2 4 6 1 3 5 得到6种HASH值,没有冲突。

这就是取余法的取素数的好处,因为素数除了1,只有它本身能被整除。

key % m 这种简单的形式,会造成原始数据经过hash后,依然相邻,所以有一种改进方法。

(a * key + b)% m


三.HashMap的分析:

终于到这里了-。- 由于 java8 对于HashMap的改动非常大,这里就以 java8 的源码来分析。

3.1变量定义部分:

/* HashMap 继承的是AbstractMap ,而HashTable 继承的是 Dictionary ,HashTable 在java8 中基本不使用了*/
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {

private static final long serialVersionUID = 362498820763181265L;

/**
* 默认的HashMap中散列表的长度,必须是2的指数倍(这里非常重要,因为这和HashTable中的哈希函数设计有关)
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/**
* 最大的容量。
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
* 默认的加载因子。 用来计算阀值的
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
* The bin count threshold for using a tree rather than list
* java8 之后,如果 HashMap 中元素较多,那么 HashMap 中的原来链表阶段,
* 就会变成红黑树。 这里只默认的红黑树的阀值。
*/
static final int TREEIFY_THRESHOLD = 8;

/**
* 默认的链表阀值。
*/
static final int UNTREEIFY_THRESHOLD = 6;

/**
* The smallest table capacity for which bins may be treeified.
* 当容量超过 64 之后,链表结构就变成红黑树结果。
* 这就是java8 的改变。
*/
static final int MIN_TREEIFY_CAPACITY = 64;

/**
* 当为链表时,采用Node节点,红黑树采用 TreeNode 节点
*/
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
java.util.HashMap.Node<K,V> next;
/* 获取 Node 的hash值,这里采用的是将 key 和 value 的hash值 异或混合*/
public final int hashCode() {
// 异或,相同都为0.
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
}


3.2 put方法:

public V put(K key, V value) {
// 最终调用的putVal,并且传了一个 hash(key) 过去
return putVal(hash(key), key, value, false, true);
}

/**
* 为了避免碰撞采取的一种新的 hash 策略
* 这里就用到了前面提到了 无符号右移 ,hash(key)
* 本质是,把高16位和低16位混合。 这种处理方式叫做“扰动函数”
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0
: (h = key.hashCode()) ^ (h >>> 16);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
java.util.HashMap.Node<K,V>[] tab;
java.util.HashMap.Node<K,V> p;
int n, i;
if ((tab = table) == null || (n = tab.length) == 0)       // 获取 散列表 tab 的长度。
n = (tab = resize()).length;
/**
*  这里出现了一个 (n-1) & hash 是一个非常巧妙的处理方式,
*  hash 为 key 经过 hashCode() 处理过 再经过
*  hash() 处理后的值。 n 为 tab 的长度。
*  又因为 n = 2 ^ m ,则 n-1 化为二进制代表 m 位都是 1
*  如: 16 = 2 ^ 4 ,则 15 的二进制是 1111
*  前面有提到 & 有截取特定位数的能力。
*  这里(n - 1) & hash 就是截取了hash值的低4位。
*
*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
java.util.HashMap.Node<K,V> e; K k;
// 这里是比较 要添加的对象 是否和在 table 中的 p 的key值是一样的。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果节点是TreeNode 的话说明已经转化成红黑树
else if (p instanceof java.util.HashMap.TreeNode)
e = ((java.util.HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}


总结:hashMap 中的 hash 函数的设计步骤如下:

将 key 调用 Object 自带的hashCode() 方法,获取初始hash值。

h = key.hashCode()

将初始hash值的高16位和低16位混合。

h ^ (h >>> 16);

截取相应位数的值

(n - 1) & hash

code说明
0010/0010/1001/0010/0111/1010/1000/0001h=key.hashCode()
0000/0000/0000/0000/0010/0010/1001/0010h >>> 16
0010/0010/1001/0010/0101/1000/0001/0011hash=h ^ h >>> 16
0011(2 ^ 4 - 1) & hash
通过上表可以看出来最后取到 0011 = 3 ,这里有个细节就是散列表的长度为 2 ^ m ,那么就取低 m 位。这样hash值的变化最大不过散列表的长度。可推出 当 n = 2 ^ m 的时候

hash % n = (n - 1) & hash

3.3 散列表扩容方法

一般来说,在使用hashMap的时候,要大概估算一下 hash表的大小,且一般为 2 的幂方,因为hash扩容是一个非常损耗性能的行为。HashMap 在两种情况下会产生扩容:

散列表初始值为 0 的时候

散列表的个数超过阀值的时候

来看一下其中的扩容方法:

final java.util.HashMap.Node<K,V>[] resize() {
// 得到旧表
java.util.HashMap.Node<K,V>[] oldTab = table;
// 旧表的大小,旧表为空 那么 =0 ; 否则等于 oldTab.length;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧的阀值
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {   // 如果旧表长度大于0
if (oldCap >= MAXIMUM_CAPACITY) {  // 再次判断旧表是否大于 最大容量 2^30 ,
// 如果大于,那么 把阀值定为 2^31-1,不会再扩容了,因为后面的扩容
// 策略会使得 长度为 2^31 ,溢出了。
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 如果表不大于最大容量,那么就把表长度扩大两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold   // 新的阀值也扩大两倍
}
// 下面是初始状态 即 oldCap = 0 的状态
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {               // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 把新阀值赋给阀值。
threshold = newThr;
// 创建一个新的 两倍原来长度 的 散列表
@SuppressWarnings({"rawtypes","unchecked"})
java.util.HashMap.Node<K,V>[] newTab = (java.util.HashMap.Node<K,V>[])new java.util.HashMap.Node[newCap];
table = newTab;
// 如果旧表不为空,说明有数据要转移。
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
java.util.HashMap.Node<K,V> e;
// 把旧表的值赋给 e , 把 e 作为临时变量,进行操作
// 如果 e 不为空,就把e赋值给 新表
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
// 给新表赋值的时候,需要重新计算hash值,但这里有一个
// 非常巧妙的地方,依然是用 原来的hash值 和 数组长度 &
// 如果初始值是 16 ,那就是截取 4位 ,而扩展一倍,那么就
// 截取5位,以此作为 新的hash 值。
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof java.util.HashMap.TreeNode)
((java.util.HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
java.util.HashMap.Node<K,V> loHead = null, loTail = null;
java.util.HashMap.Node<K,V> hiHead = null, hiTail = null;
java.util.HashMap.Node<K,V> next;
do {
// 这里是节点为链表时的节点复制
}
}
}
}
return newTab;
}


最后一个问题:

那么,为什么hashMap 没有采用前面的取余法,没有采用素数作为散列表的长度呢?

首先一个好的hash函数,必须兼顾均匀性 和 效率高,还有一点是安全性(比如MD5函数),取余法确实简单实用,做到了均匀性,但是在效率性上非常的低,安全性也不高。 在计算机中取模运算是效率非常低的,hashmap中实质也是采用了取余法,但是这里利用了 hash % n = (n - 1) & hash ,将取模运算变成了位运算,而这里不用 素数 作为散列表长度是因为要满足 n = 2 ^ m ,而素数带来的均匀性,也因为扰动函数的加入变得满足了。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  hashmap 源码