Java中的HashMap

HashMap简介

  1. HashMap基于哈希表的Map接口实现,是以key-value存储形式存在,即主要用来存放键值对。HashMap 的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。
  2. HashMap在jdk1.8之前是使用数组+链表的形式进行存储,在jdk1.8及其以后使用数组+链表+红黑树进行实现。其中数组是HashMap的主体,用来区分存取key的hash值。链表和红黑树则是应用于Hash冲突时索引相同的情况,将具有相同的hash数不同的key用链表进行存储。
  3. 对于key、value则是将其装进一个Node里,以Node.key进行hash计算,存储
  4. 需要明确的一点是,一切的改变都是为了高效

HashMap的结构

如下是HashMap的源码以及UML类图。

public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable 

在这里插入图片描述
可以看出HashMap继承了AbstarctMap类,实现了三个接口Map<K,V>、Cloneable、Serializable,其实可以发现一个冗余,既然继承的AbstarctMap类已经实现了Map接口,为什么HashMap还要实现Map接口呢。
其实这就是写集合的人的一个失误(其它集合也是这样),写的时候认为这样有价值,但是后来发现没有,但又不值得去更改,所以就留了下来(我也是听将视频的老师说的)。
Cloneable接口没啥好说的,为了可以被克隆,Serializable也是,进行序列化,反序列化。

HashMap的Filed

如下是HashMap中定义的成员变量
在这里插入图片描述

  1. serialVersionUID
    序列化版本号
private static final long serialVersionUID = 362498820763181265L;
  1. DEFAULT_INITIAL_CAPACITY
    new HashMap()的时候的初始容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

<<位运算,将1进行位运算左移4次,相当于1*2的4次方,结果为16,即HashMap的默认初始容量是16。但是我奇怪地是为啥不直接写个16呢。

  1. MAXIMUM_CAPACITY
    HashMap的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;

1<<30 的结果是1073741824,为什么是这个数字呢,虽然1<<31的结果是-2147483648,即Integer.MIN_VALUE,但是为什么不能是Integer.MAX_VALUE呢。这就涉及到一个问题,HashMap要求其容量必须是2的整数幂,也就是说CAPACITY只能为2、4、8、16···1 << 30之中的一个。这个问题后面会说明。

  1. DEFAULT_LOAD_FACTOR
    默认负载因子,值为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
  • loadFactor可更改的负载因子,衡量HashMap满的程度,表示HashMap的疏密程度,loadFactor = size/capacity,size就是当前HashMap种存入的数据个数,capacity就是当前HashMap的容量
  • loadFactor默认值是0.75是基于一种权衡,太大导致查找效率低、太小导致数组利用率低。
  • loadFactor = 0.75 表示若当前的存入数据量达到capacity的75%了就表示HashMap太拥挤,需要进行扩容,扩容涉及rehash,复制数据等操作,消耗性能,因此需要避免扩容。
  1. TREEIFY_THRESHOLD
    表示当链表长度达到8的时候,将链表转成红黑树进行存储。(条件1)
static final int TREEIFY_THRESHOLD = 8;
  1. UNTREEIFY_THRESHOLD
    表示当前索引值下的数据个数小于等于6了,就将原来的红黑树结构转换成链表进行存储
static final int UNTREEIFY_THRESHOLD = 6;
  1. MIN_TREEIFY_CAPACITY
    将链表转成红黑树需要满足的第二个条件,当前HashMap的capacity >=64,也就是说需要同时满足当前索引的数据达到8且当前capacity>=64才将该链表转成红黑树,否则进行扩容
static final int MIN_TREEIFY_CAPACITY = 64;
  1. table;
transient Node<K,V>[] table;

就是HashMap中的数组,存放Node类,即键值对
jdk1.8之前数组类型是Entry<K,V>,因为加入了红黑树所以更改了类型,但他们都实现了一样的接口Map.Entry<K,V>

  1. entrySet
    存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySet;
  1. size
    HashMap存放键值对数
transient int size;
  1. modCount
    记录HashMap的修改次数,不知道有啥用
transient int modCount;
  1. threshold
    调整容量的阈值 threshold = loadFacrot * capacity 当size超过该阈值就会扩容
int threshold;

问题

  1. HashMap是如何产生hash值进行索引产生的
  • hash值产生
    判断key是否为null,为null则其hash值为0,否则调用Objects的hashCode方法得到hash值h,将h与h进行无符号右移16位的结果进行异或返回。最后得到该键值对(根据key产生)的hash值。
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

扩展:hash函数的实现还有平方取中法,伪随机数法和取余数法。这三种效率都比较低。而无符号右移16位异或运算效率是最高的。

  • 索引产生
    因为HashMap主体是个数组table,所以必有索引进行存储数据,其索引产生原理很简单,就是单纯的取余(为了均匀分布,减少碰撞),比如要存入的键值对的hash值为18,而当前容量为16,进行18%16的结果为2,所以该键值对产生的Node存入table[2]中。
    但是HashMap的取余操作并不是这样的,这就引出下一个问题,为什么HashMap的容量必须为2的整数幂
  1. 为什么HashMap的容量必须为2的整数幂
    为了高效,以下就是索引的计算,用通过上一问中产生的hash值与capacity-1进行位运算与操作得到索引(对源码稍微修改了一下,为了直观),其结果就是hash%capacity的结果,当然这是在capacity为2的整数幂的前提下。
index = (capacity - 1) & hash

举个栗子

hash = 18,capacity = 16;
18的二进制表示:0001 0010
15的二进制表示:0000 1111
    0001 0010
  & 0000 1111
-------------------
    0000 0010
0000 0010的十进制表示为2

初看感觉挺神奇的,但是仔细想想,不过是把hash值的二进制进行末尾截取,而截取长度就是capacity - 1,为什么是减1呢。因为二进制,capacity为2的整数幂,所以肯定的是capacity的二进制表示中只有一个1,其余全0,要表得到索引,就是在0~capacity-1中选取,上例中即0 ~ 15,即0000 0000 ~0000 1111。所以只需这样就能得到索引。
要知道的是位运算是非常高效的。所以为了数据的均匀分布以及高效,使得HashMap的容量必须为2的整数幂。
当然,如果不考虑效率问题直接求余也可,这样就不要求capacity必须是2的整数幂了。
通过这个问题也知道了,HashMap扩容的时候,容量为其原来的两倍(相当于左移一位),又原有数据的存放位置要么就是当前索引,要么就是当前索引+原数组容量。

  1. 如果创建HashMap时,输入数组长度不为2的整数幂会怎样
    首先结果就是HashMap会创建一个大于且离那个数最近的2的幂数
    源码
// 只传初始容量的构造器
public HashMap(int initialCapacity) {
  this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

// 传初始容量和负载因子的构造器
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 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;
    this.threshold = tableSizeFor(initialCapacity);
}
// 调整初始容量
static final int tableSizeFor(int cap) {
    int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

// Integer中的方法,返回无符号整型i的最高非零位前面的0的个数,包括符号位在内;
public static int numberOfLeadingZeros(int i) {
    // HD, Count leading 0's
    if (i <= 0)
        return i == 0 ? 32 : 0;
    int n = 31;
    if (i >= 1 << 16) { n -= 16; i >>>= 16; }
    if (i >= 1 <<  8) { n -=  8; i >>>=  8; }
    if (i >= 1 <<  4) { n -=  4; i >>>=  4; }
    if (i >= 1 <<  2) { n -=  2; i >>>=  2; }
    return n - (i >>> 1);
}

关于numberOfLeadingZeros可以看这里向天葵,我的jdk是12,应该是改进了,不过大概意思差不多。其实我看其它博客讲的HashMap中的这个得到大于等于该数的最近2的整数幂的代码跟我这也不一样,也是应为我这是jdk12的原因吧。

  • 为什么要进行cap - 1:为了防止cap已经是2的幂,不然的话,若输入为16则会得到32容量的HashMap
  • 关于numberOfLeadingZeros,我们知道8 ~ 15的二进制表示,他们的无符号最高非零位前面的0的个数都一样为28,所以可以知道的是在2 ^ n ~ 2 ^ (n+1) - 1之间的数其无符号最高非零位前面的0的个数都一样。
  • int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);的意义,得到了无符号最高非零位前面的0的个数,又有-1的二进制表示为其补码,即32个1,比如cap = 10,则无符号最高非零位前面的0的个数为28,n的二进制就是-1>>>28,表示为0000 1111(前面24个0省略了),即为15,最后有个+1操作,得到capacity为16。所以该句结果为将二进制表示的最高位非零后的值变为1。
  1. 哈希碰撞
    数组长度有限的时候,即便我们使用了很好的hash函数有效减少了不同数据产生相同哈希值,或者是不同hash值在进行索引定位的时候,由于数组长度的限制,定位到同一位置,这就出现了哈希碰撞。这就是为什么要使用数组+链表+红黑树结构进行存储的原因,数组的同一位置,通过链表+红黑树的方式对不同键值对数据进行区分,如果键值相同,则替换其value,键值不同则在链表或红黑树中添加。
  2. 链表与红黑树的转换
  • 当链表长度达到8且此时capacity >= 64 时,链表将转成红黑树
  • 当红黑树元素低于6时,转为链表
  • 为什么阈值为8呢
    源码注释:
Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due to removal or resizing) they are converted back to plain bins.  In usages with well-distributed user hashCodes, tree bins are rarely used.  Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution
	(http://en.wikipedia.org/wiki/Poisson_distribution) with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-0.5)*pow(0.5, k)/factorial(k)).
	The first values are:
	因为树节点的大小大约是普通节点的两倍,所以我们只在箱子包含足够的节点时才使用树节点(参见TREEIFY_THRESHOLD)。当它们变得太小(由于删除或调整大小)时,就会被转换回普通的桶。在使用分布良好的用户hashcode时,很少使用树箱。理想情况下,在随机哈希码下,箱子中节点的频率服从泊松分布
	(http://en.wikipedia.org/wiki/Poisson_distribution),默认调整阈值为0.75,平均参数约为0.5,尽管由于调整粒度的差异很大。忽略方差,列表大小k的预期出现次数是(exp(-0.5)*pow(0.5, k)/factorial(k))。
	第一个值是:
	
	0:    0.60653066
	1:    0.30326533
	2:    0.07581633
	3:    0.01263606
	4:    0.00157952
	5:    0.00015795
	6:    0.00001316
	7:    0.00000094
	8:    0.00000006
	more: less than 1 in ten million

可以看到当hashCode离散性很好的时候,通过泊松分布计算,当元素达到8的时候概率已经很小了。但是若离散型不好,就会导致不均匀的数据分布,出现一个索引位置有很多元素,而其余索引元素较少的情况,为了增加检索效率,所以引入红黑树。
当然前面是官方说法,还有一种说法是

红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,链表的平均查找长度为n/2,当长度为8时,平均查找长度为8/2=4,这才有转换成树的必要;链表长度如果是小于等于66/2=3,而log(6)=2.6,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值