HashMap
1. HashMap概述
-
key-value 允许使用null
-
线程不安全
-
value构成的集合是Collection:无序的、可以重复的。所以value所在的类要重写:equals()。
所以HashMap 判断两个 value相等的标准:两个 value 通过 equals() 方法返回 true -
key构成的集合是Set:无序的、不可重复的。所以key所在的类要重写 equals()、hashCode()方法
所以 HashMap 判断两个 key 相等的标准:两个 key 的hashCode 值相等且通过 equals() 方法返回 true -
无序性:不等同于随机性。只是添加数据的顺序不是按照索引递增的,而是根据hash值计算得到的
不可重复性:调用hashcode( )方法和equals( )方法判断。(如果存放在key中对象的实现类没有重写hashcode( )和equals( )方法,则会调用Object类中的hashcode( )和equals( )方法。通过JDK源码发现Object类中的hashcode( )方法是native 的,所以未重写以上2个方法或只重写的equals()方法时,可能可以向key中添加两个相同的对象 )
//Person类没有重写hashcode()、equals()方法
HashMap hashMap=new HashMap();
hashMap.put(new Person("Tom", 20), "A");
hashMap.put(new Person("Tom", 20), "B");
Iterator iterator = hashMap.entrySet().iterator();
while(iterator.hasNext())
{
Map.Entry entry = (Map.Entry)iterator.next();
System.out.println(entry.getKey() +" - "+entry.getValue());
}
//Person [name=Tom, No=20] - A
//Person [name=Tom, No=20] - B
通过debug发现,add()时调用了Object.hashcode()方法,没有调用Object.equals()。
因为 Object.hashcode()为两个Person对象随机分配的hashcode值,以至于直接添加到Set集合中而没有调用equals()
- IDE自动重写hashcode()方法
//Person类(String name int age)重写hashcode()
public int hashCode()
{
final int prime = 31;
int result = 1;
result = prime * result + No;
result = prime * result + ((name == null) ? 0 : name.hashCode());
return result;
}
- 为何重写hashCode( )方法时有31这个数字?
- 尽量选择大系数。大系数时计算出来的hash地址越大,所谓的“冲突”就越少,查找起来效率也会提高。(减少冲突)
- 31只占用5bits,相乘造成数据溢出的概率较小。
- 31可以由i*31== (i<<5)-1来表示。计算机底层的乘除法器都是用基本的加法器(减法器构成的),所以用移位运算符和减法代替乘法能加快运行速度
- 31是一个素数,素数作用就是如果我用一个数字来乘以这个素数,那么最终出来的结果只能被素数本身、被乘数、1来整除(减少冲突)
2. 源码解析(JDK7)
2.1 概述
- key-value 构成Entry数组
- 底层存储结构:数组+链表
- 添加元素时链表指向:新元素指向旧元素
2.2 添加元素过程
2.3 源码分析
1.变量
static final int DEFAULT_INITIAL_CAPACITY = 16; //默认容量
static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f;//默认负载因子
transient int size;//HashMap中已经存储的键值对的数量
int threshold; //扩容的临界值 =容量*负载因子
2.底层Entry数组
transient Entry<K,V>[] table; //table是存储元素的数组名
Entry(int h, K k, V v, Entry<K,V> n)
{
value = v;
next = n;
key = k;
hash = h;
}
3.构造器
//因为第1/2个构造器都调用了第3个构造器,所以我们只分析第3个构造器
public HashMap() //空参构造器创建默认大小(16)默认负载因子(0.75)的HashMap
{
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity) //创建initialCapacity大小,默认负载因子的Hashmap
{
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor)
{
if (initialCapacity < 0)//初始化容量小于0则抛出异常
throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY) //初始化容量大于1<<30 则用1<<30初始化容量
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +loadFactor);
//hashMap底层创建的数组大小一定是2的n次幂,不一定是传入的形参
//比如传入的capacity是31 那么实际造的数组大小是32
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;//将传入的负载因子赋值给当前对象的加载因子
//threshold:影响扩容的临界值
//( 例如空参构造器底层创建长度为16的数组,HashMap并不等到添加第17个时才扩容,而是达到扩容临界值(16*0.75=12)时就扩容)
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];//创建capacity大小的Entry数组 table是Entry数组名
useAltHashing = sun.misc.VM.isBooted() &&(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
init();
}
4.添加元素 put()
public V put(K key, V value)
{
if (key == null)//因为可以存储NULL值,所以当传入的key是NULL时,也放入HashMap
return putForNullKey(value);
int hash = hash(key); //计算key的hash值
//求已经计算出来的hash值应该在底层数组中存放的位置,
//table.length是当前底层数组的长度(前面我们已经知道底层数组的长度一定是2的n次幂)
int i = indexFor(hash, table.length);//请转5查看解析
//利用for循环将要添加的key与 Entry数组中的第i个数据(链)依次进行比较(即key与table[i](或其链上的元素)进行比较)
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;
}
}
//1.初始化table[i],发现table[i]==NULL,则表明此位置没有数据,跳出for循环,直接调用addEntry()进行添加
//2.初始化table[i],发现table[i]!=NULL,表明此位置有数据,则转入if语句
// 多次循环都是 e.hash==hash不成立,表明要添加的key与table[i]对应的数据链上的每一个key都不相同。最后跳出for循环,调用addEntry()进行添加
//3.初始化table[i],发现table[i]!=NULL,表明此位置有数据,则转入if语句
// 某次循环发现 e.hash==hash成立,表明要添加的key与table[i]对应的数据链上的某一个key的hash值相同
// 然后调用equals()方法判断值是否相同。
// 若不同则if语句返回false,继续循环直到跳出循环,随后调用addEntry()进行添加
// 若相同则if语句返回true,进入if语句。将要添加的value替换具有相同hash值和内容的value
//为何使用这句话 ((k = e.key) == key || key.equals(k))
// 为了加快比较速度,先进行地址比较(因为若地址相同就不用比较内容了)
modCount++;
addEntry(hash, key, value, i);//请转6查看解析
return null;
}
5.计算索引 indexFor()
static int indexFor(int h, int length)
{
return h & (length-1);
}
//Q:为何length-1再和h进行与操作
//A:为了保证计算得到的下标在底层数组中存放的位置不能超出当前数组长度
//比如length=16,则length-1=15,用二进制表示是1111(即低4位全部是1,高位全部是0)
//不论hash值是多少,和15进行与操作后只有低4位不为0(转换成十进制后一定小于16,所以肯定小于当前数组的长度)
6.向数组中添加 addEntry()
void addEntry(int hash, K key, V value, int bucketIndex)
{
//if语句主要是扩容相关
//如果已经存放的数据长度大于临界值 而且 对应的数组处的值(不为空) 则进行扩容(若对应的数组处的值为空则直接添加)
if ((size >= threshold) && (null != table[bucketIndex]))
{
resize(2 * table.length); //扩容为当前数组长度的2倍
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
//数据链上添加元素
createEntry(hash, key, value, bucketIndex);//请转7查看解析
}
7.数据链上添加元素 creatEntry()
void createEntry(int hash, K key, V value, int bucketIndex)
{
//因为JDK7 是新的元素指向旧的元素
Entry<K,V> e = table[bucketIndex];//先将新元素要添加的位置上存放的旧元素
//新建一个Entry对象,hash、key、value都是要添加的新元素的相应属性,
//e是刚才取出来的旧元素(即新元素指向刚才取出来的旧元素,与JDK7是新的元素指向旧的元素吻合)
//最终结果是将要添加的新元素放入对应的位置,指向新元素
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
3. 源码解析(JDK8)
3.1 概述
- key-value 构成Node数组
- 底层存储结构:数组+链表+红黑树
- 添加元素时链表指向:旧元素指向新元素
3.2 添加元素过程
3.3源码分析
1.变量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始化容量
static final int MAXIMUM_CAPACITY = 1 << 30;//最大支持容量
static final float DEFAULT_LOAD_FACTOR = 0.75f;//默认负载因子
static final int TREEIFY_THRESHOLD = 8;//Bucket中链表长度大于该默认值则转化为红黑树
static final int UNTREEIFY_THRESHOLD = 6;//Bucket中红黑树存储的Node小于该默认值则转化为链表
static final int MIN_TREEIFY_CAPACITY = 64;//桶中的Node被树化时最小的hash表容量
///当哈希表的大小超过64且Bucket中链表长度大于8,才会把链式结构转化成红黑树,否则仅采取扩容来尝试减少冲突
2.底层Node数组
Node(int hash, K key, V value, Node<K, V> next)
{
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
3.构造函数 //构造器都没有创建HashMap
public HashMap()
{
//未创建HashMap。当前对象的负载因子初始化为默认负载因子
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
public HashMap(int initialCapacity)
{
//未创建HashMap。将initialCapacity初始化为大于等于他的最小的2的指数值(比如initialCapacity=45,实际是64),当前对象的负载因子初始化为默认负载因子
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor)
{
if (initialCapacity < 0) //初始化容量小于0则抛出异常
throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY) //初始化容量大于1<<30 则用1<<30初始化
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +loadFactor);
this.loadFactor = loadFactor;//负载因子赋值
this.threshold = tableSizeFor(initialCapacity);//初始化容量赋值
}
4.计算初始化容量 tableSizeFor()
// 获得第一个大于等于cap的2的幂次方的数(例如cap=34,则返回64)
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;
}
5.put()方法
public V put(K key, V value)
{
//主要调用putVal()方法(请查看6)、hash()方法请查看7
return putVal(hash(key), key, value, false, true);
}
6.putVal()方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)
{
Node<K, V>[] tab;
Node<K, V> p;
int n; //hash数组的长度
int i; //在hash数组中的映射下标
//如果table为空 或 table 长度为0 则进行扩容(正如我们已经知道调用构造器并不会创建HashMap,第一次调用put()时才初始化容量)
Q:为何调用构造器时不初始化而是选择put()时才初始化?
A:延迟初始化逻辑以减小内存占用(因为某些hashMap new出来之后并不会立即使用,那么put()时再初始化容量就可以减小内存占用)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;//resize扩容函数请转到8查看
//(n-1 & hash) 是在计算key应该映射到table数组的下标(n在上一步已经得到)
if ((p = tab[i = (n - 1) & hash]) == null)//插入成功情况1:如果计算得到的下标处对应的元素是NUll(表明无元素)
tab[i] = newNode(hash, key, value, null);//则直接new (key-value)对象并放置在bucket
else //计算得到的下标处对应的元素是不为空
{
Node<K, V> e; K k;
//上一步的if已经得到了p = tab[i = (n - 1) & hash]
//此时指针指向table[i],如果key的hash值与table[i]相同且内容相同就将table[i]赋值给e然后进行替换(替换语句在后面)
//否则就有可能是红黑树或者链表,以下两种情况就是红黑树或者链表
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode) //p已经被树化
e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);//插入成功情况2:直接在红黑树上插入key-value(如果发现相同key也跳入替换语句)
else { //此种情况是链表
for (int binCount = 0; ; ++binCount)
{
if ((e = p.next) == null) //插入成功情况3:已经遍历到末尾,未发现与要插入的key一致的Node,则直接new (key-value)对象并放置在末尾
{
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)//如果遍历到最后发现结点数>8 那么就触发树化操作(树化时还要保证hash表容量大于64)
{
treeifyBin(tab, hash);
}
break;//都遍历到末尾了而且也添加了那么就退出循环操作
}
if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))//遍历链表过程中找到了相同key的Node元素,则需要替换(跳转到替换语句)
{
break;
}
p = e;
}
}
if (e != null) //替换操作:发现了一个与key相同Node的元素,则将要插入的key对应的value替换原来的value
{
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
{
e.value = value;
}
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//表示散列表被修改的次数(替换Node元素的value不计数)
if (++size > threshold)//如果存储的key-value数量大于扩容阈值则进行扩容
resize();
afterNodeInsertion(evict);
return null;
}
7.hash()方法
public native int hashCode();//hash()方法里调用此native方法(本地实现)
static final int hash(Object key)
{
int h;
//不难发现,计算hash值时并不是直接调用Object类里的hashcode()方法
//而是调用hashcode()方法得到hash值后右移16位再和得到的hash值进行异或操作
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
前置知识:与JDK7一致,JDK8在调用hash()方法后得到hash值,然后计算需要放置的key应该映射到哪个桶上时运用 ( hash & n-1 )
Q:hash()方法为何此处要这么处理hash值而不直接调用hashcode()方法得到hash值?
A:目的是减小哈希冲突。例如当n很小时(假设为64,一般hashcode()方法得到的hash值都很大),那么 n-1
即为 63(0x111111),这样的值跟 hashCode()直接进行与操作,实际上只使用了哈希值的后6位(因为0x111111高位全部是0,进行与操作后只有后6位不为0)
如果当哈希值的高位变化很大而低位变化很小,因为没有把高位利用起来,这样就很容易造成哈希冲突,所以这里把高低位都利用起来从而解决了这个问题。
8.resize() 方法
//待解析
4. 关于HashMap的说明
- Entry / Node 数组中可以存放元素的位置我们称之为“桶”(bucket),每个bucket都有自己的索引,系统可以根据索引快速的查找bucket中的元素
- 默认创建数组的容量是16
默认负载因子是0.75
默认扩容临界值12 ( 16×0.75 ) - 向HashMap中添加key相同,value不同的对象时会覆盖原来的value(比如已经添加了key1-value1 再添加key1-value2,那么最终只有key1-value2)
- hashMap底层创建的数组大小一定是2的n次幂,不一定是传入的形参。
比如传入的capacity是31 那么实际造的数组大小是32 - Q:为何hashMap底层创建的数组大小一定是2的n次幂?
A:我们已经知道得到key的hash值后计算映射位置的时候使用(n-1) & hash ,其中n是底层数组的长度。因为hash值是不固定的,所以说key的hash值的二进制数任何位都可能是0也可能是1。那么要想保证尽量减少hash碰撞,而且充分占据每个数组的位置,必须要保证 数组大小-1 的二进制全是1,如此才能保证最后的运算结果,完全取决于hash的二进制数,也就是最后的结果会保证每个位都有可能是0或1。而一个十进制数,如果它是2的n次幂,那么它减一后的二进制数就都是1 - 若已经存放的数据长度大于临界扩容值 而且 对应的数组处的值(不为空) 则进行扩容,默认扩容为当前数组长度的2倍
- Q:为什么需要扩容?
A:解决 hash冲突导致的链化从而影响查询效率。比如hash表的长度为8,不扩容的情况下可能链上元素过多导致查询效率由o(1)上升到o(n) - JDK8中当数组的某一个索引位置上的元素以链表形式存在的数据个数 > 8 且当前数组的长度 > 64时,此时此索引位置上的所数据改为使用红黑树存储。若知识链表形式存在的数据个数 > 8 但是当前数组的长度 <=64时会扩容当前数组。如果当映射关系被移除后,下次resize方法时判断树的结点个数低于6个,也会把树再转为链表
- JDK7 调用构造函数就创建了hashMap
( 比如new HashMap() 就已经创建了长度为16,负载因子为0.75的Entry数组)
JDK8调用构造函数时未创建hashMap而是调用put( )时才创建
(比如new Hashmap()后再put()就创建了长度为16,负载因子为0.75的Node数组) - 以JDK8为例:计算hash值时并不是直接调用Object类里的hashcode()方法而是调用hashcode()方法得到hash值后右移16位再和得到的hash值进行异或操作得到最终的hash值
- Q:hash()方法为何此处要这么处理hash值而不直接调用hashcode()方法得到hash值?
A:目的是减小哈希冲突。例如当n很小时(假设n为64,一般hashcode()方法得到的hash值都很大),那么 n-1即为 63(0x111111),这样的值跟 hashCode()直接进行与操作,实际上只使用了哈希值的后6位(因为0x111111高位全部是0,进行与操作后只有后6位不为0)如果当哈希值的高位变化很大而低位变化很小,因为没有把高位利用起来,这样就很容易造成哈希冲突,所以这里把高低位都利用起来从而解决了这个问题。 - Q:计算出hash值之后再计算对应的数组位置时 为何length-1再和h进行与操作
A:为了保证计算得到的下标在底层数组中存放的位置不能超出当前数组长度
比如length=16,则length-1=15,用二进制表示是1111(即低4位全部是1,高位全部是0)
不论hash值是多少,和15进行与操作后只有低4位不为0(转换成十进制后一定小于16,则肯定小于当前数组的长度) - Q:调用equals()时为何使用 ( (k = e.key) == key || key.equals(k) )
A:为了加快比较速度,先进行地址比较(因为若地址相同就不用比较内容了) - Q:为何源码大量使用位运算符而不使用算数运算符(±×÷)
A:因为位运算符能提升运行速度。计算机底层是操作二进制数,使用位运算符能直接操作二进制从而加快速度 - Q:负载因子值的大小对HashMap有什么影响
A:负载因子的大小决定了HashMap的数据密度。
负载因子越大则密度越大,发生碰撞的几率越高,数组中的链表越容易长,造成查询或插入时的比较次数增多,性能会下降。
负载因子越小就越容易触发扩容,数据密度也越小,意味着发生碰撞的几率越小,数组中的链表也就越短,查询和插入时比较的次数也越小,性能会更高。但是会浪费一定的内容空间。而且经常扩容也会影响性能
按照其他语言的参考及研究经验,会考虑将负载因子设置为0.7~0.75,此时平均检索长度接近于常数。