红黑树原理+手写红黑树代码
HashMap底层源码解析下
最近看了黑马和刘老师的源码视频,故总结一篇HashMap的源码解析,文章分为上下两部分
HashMap基于哈希表的Map接口实现,是以key-value存储形式存在,即主要用来存放键值对。HashMap的实现是不同步的,这意味着他
不是线程安全的
,它的key,value都可以为null
此外,HashMap中的映射不是有序的
问题:为什么要在数组长度大于64之后,链表才会进化为红黑树?
在了解源码之前,我们通过一个案例来说明hashMap底层数据结构存储数据的过程
我们先创建一个hashMap实例
HashMap<String, Integer> MapTest = new HashMap<>();
MapTest.put("a",1);
MapTest.put("b",2);
MapTest.put("c",3);
MapTest.put("a",44);
System.out.println(MapTest);
结果:
{
a=44, b=2, c=3}
存储过程分析
HashMap<String, Integer> MapTest = new HashMap<>();
1.在创建集合对象的时候,在jdk8前,在构造方法中创建一个长度为16的Entry[] table数组用来存储键值对数据,在jdk8以后不是在HashMap构造方法底层创建数组了,是在第一次调用put方法时创建的数组Node[] table
不在构造方法中创建的原因是:创建散列表需要耗费内存,而有些时候我们只是创建hashMap,并不向其中put元素
2.假设向哈希表中存储“a”-1数据,根据"a"调用String类中重写之后的hashCode方法计算出hash值,利用寻址算法(hash&(cap-1))cap为其容量,来寻找在哈希表中的索引,如果该索引空间没有数据,则直接将数据存储到数组中
3.向哈希表中存储数据"b"-2,假设"b"计算出的hashCode方法结合数组长度计算出的索引值也是3,如果此时数组空间不是null,此时底层会比较a和b的hash值是否一致,如果不一致,则在此空间上划出一个节点来存储键值对数据“b”-2,这种方式称为“拉链法”
4.假设向哈希表中存储数据"a"-44,根据“a”调用hashCode方法结合数组长度计算出索引值为3,此时数组空间不为null,此时比较后存储的数据留言和已经存在的数据的hash值是否相等,如果相等,此时发生hash碰撞,那么底层会调用“a”所属类String中的equals方法比较两个内容是否相等
相同hash值的元素内容可能不同,例如"重地"和"通话"
System.out.println("重地".hashCode()=="通话".hashCode());
true
索引值->hash值->key值
为什么引入红黑树的进一步解答
JDK1.8以前HashMap的实现是数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当HashMap中有大量的元素都存放在同一个桶中时,这个桶下有一条长长的链表,此时HashMap就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就从O(1)退化成O(n),完全失去了它的优势,针对此种情况,JDK1.8中引入了红黑树(查找的时间复杂度为O(logn))来优化这种问题。
总结图
了解了hashMap的存储流程之后我们来看几道面试题加深印象
面试题1:哈希表底层采用何种算法计算hash值?还有哪些算法可以计算出hash值?
面试题2:当两个对象的hashCode相等时会怎么样?
面试题3:何时发生哈希碰撞和什么是哈希碰撞,如何解决哈希碰撞
面试题4:如果两个键的hashcode相同,如何存储键值对
从图中可以看出HashMap实现了Map,Cloneable,Serializable接口
知识补充:
通过上述继承关系我们发现一个很奇怪的现象,HashMap已经继承了AbstractMap而AbstractMap已经实现了Map接口,那为什么HashMap还要再实现Map接口呢?同样在ArrayList中LinkedList中都是这种结构
据java集合框架的创始人Josh Bloch描述,这样的写法是一个失误,在java集合框架中,类似这样的写法很多,最开始写java集合框架的时候,他认为这样写,在某些地方可能是有价值的,直到他意识错了。显然的,JDK的维护者后来不认为这个小小的失误值得去修改,所以就这样存在下来了
代码如下(示例):
1.序列化版本号
private static final long serialVersionUID = 362498820763181265L;
2.集合的初始化容量(必须是2的n次幂)
//默认的初始容量是16 -- 1<<4相当于1*2的4次方---1*16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
问题:为什么必须是2的n次幂?如果输入值不是2的n次幂比如10会怎样
HashMap的构造方法可以指定集合的初始化容量大小:
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
根据上述讲解我们已经知道,当向HashMap中添加一个元素的时候,需要根据key的hash值去确定其在数组中的具体位置。HashMap为了存取高效,要尽量减少碰撞,把数据均匀分配,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法中
这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算(这点上述已经讲解)。所以源码中做了优化,使用hash&(length-1),而实际上hash%length等于hash&(length-1)的前提是length是2的n次幂
为什么使用hash&(length-1)能均匀分布减少碰撞呢?2的n次方实际就是1后面n个0,2的n-1次方实际就是n个1
举例:
说明:按位与运算:相同的二进制数位上,都是1的时候,结果为1,否则为0。
如果我们设置的数组大小不是2的幂会怎么样
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
我们以设置initialCapacity为10为例,他会在itableSizeFor方法中将非2次幂的数转换成离他最近且比它大的二次幂数
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;
}
下面分析这个算法
1.首先,为什么要对cap做减1操作。int n = cap - 1;
这是为了防止cap已经是2的幂,如果cap已经是2的幂,没有减1操作的话,方法最后返回的capacity将是这个cap的2倍
2.如果这时n为0,经过cap-1之后,则经过后面的几次无符号右移依然是0,最后返回的capacity是1(最后有个n+1的操作),我们这里只讨论n不等于0的情况
3.|(按位或运算):运算规则,相同的二进制数位上,都是0的时候,结果为0,否则为1。
3.默认的负载因子,默认值是0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
4.集合的最大容量
//集合最大容量的上限是:2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;
5.链表的值超过8则会转成红黑树
//当桶(bucket)上的节点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
8这个阈值定义在HashMap中,针对这个成员变量,在源码的注释中只说明了8是bin(bin就是nucket桶)从链表转成树的阈值,但是并没有说明为什么是8
在HashMap中有一段注释说明:我们继续往下看:
* 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)).
翻译:
因为树节点的大小大约是普通节点的两倍,所以我们只在箱子包含足够的节点时才使用树节点(参见TREEIFY_THRESHOLD)。
当他们边的太小(由于删除或调整大小)时,就会被转换回普通的桶,在使用分布良好的hashcode时,很少使用树箱。
理想情况下,在随机哈希码下,箱子中节点的频率服从泊松分布
第一个值是:
* 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
TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转化成TreeNodes,而是否足够多就是由TREEIFY_THRESHOLD的值决定的。当bin中节点数变少时,又会转成普通的bin,并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin。
这样就解释了不是一开始就将其转换成TreeNodes而是需要一定节点才转成TreeNodes,说白了就是空间和时间的权衡,红黑树节点占用空间比较大,如果一开始就是用红黑树,就会消耗大量的空间资源
这段内容还说到:当hashCode离散性很好的时候,树形bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件,所以之所以选择8,不是随便决定的,而是根据概率统计决定的。
补充:
泊松分布
泊松分布的参数
是单位时间(或单位面积)内随机事件的平均发生次数。泊松分布适合于描述单位时间内随机事件发生的次数
2.以下是我在研究问题时,在一些资料上面翻看的为什么长度为8时链表进化为红黑树,长度小于等于6时红黑树又退化成链表的解释:供大家参考
红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,链表的平均查找长度为n/2,当长度为8时,
平均查找长度为4,此时才有转换成树的必要,链表长度如果是小于等于6,链表的平均查找长度为6/2=3,而红黑树此
时为log(6)=2.6,虽然速度也很快
但是转化为树结构和生成树的时间并不会太短,两者此时所用时间相差无几
6.当链表中的值小于6则会从红黑树转回链表
//当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
7.链表转红黑树时数组最大容量
当Map里面的数量超过这个值时,表中的桶才能进行树形化,否则桶内元素太多时会扩容,而不是树形化,为了避免进行扩容、树形化的选择的冲突,这个值不能小于4*TREEIFY_THRESHOLD(8)
//桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;
8.table用来初始化(必须是二的n次幂)(重点)
//存储元素的数组
transient Node<K,V>[] table;
在JDK1.8中我们了解到HashMap是由数组+链表+红黑树来组成的结构,其中table就是HashMap中的数组,jdk8之前数组类型是Entry
9.存放缓存
//存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySet;
10.HashMap中存放元素的个数(重点)
//存放元素的个数,注意这个不等于数组的长度
transient int size;
size为HashMap中K-V的实时数量,不是数组table的长度
11.用来记录HashMap的修改次数
//每次扩容和更改map结构的计数器
transient int modCount;
12.用来调整大小下一个容量的值,计算方式为(容量*负载因子)
//临界值,当实际大小(容量*负载因子)超过临界值时,会进行扩容
int threshold;
13.哈希表的加载因子(重点)
//加载因子
final float loadFactor;
loadFactor说明:
public HashMap(int initialCapacity, float loadFactor) //构造一个带指定初始容量和加载因子的空HashMap
loadFactory越趋近于1,那么数组中存放的数据(entry也就越多),也就越密集,也就会有更多的链表长度处于一个更长的数值,导致查询效率降低,每当我们添加数据,产生hash冲突的概率也会更高
loadFactory越小,也就是越趋近于0,数组中存放的数据(entry)也就越少,表现得更加稀疏
如果希望链表尽可能少些,要提前扩容,有的数组空间有可能一直没有存储数据,加载因子尽可能小一些
HashMap中最重要的构造方法,他们分别如下:
构造一个空的HashMap,默认初始容量(16)和默认负载因子(0.75)
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // 将默认的加载因子0.75赋值给loadFactory,并没有创建数组
}
构造一个具有指定的初始容量和默认负载因子的HashMap
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
构造一个具有指定的初始容量和负载因子的HashMap
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);
}
对于this.threshold = tableSizeFor(initialCapacity)的问题:
tableSizeFor(initialCapacity)判断指定的初始化容量是否是2的n次幂,如果不是那么会变为
比指定初始化容量大的最小的2的n次幂,但是注意,在tableSizeFor方法体内部将计算后的数
据返回给调用这里了,并且直接赋值给threshold边界值了,有些人会觉得这里是一个bug,应该这样书写,
this.threshold = tableSizeFor(initialCapacity)*this.loadFactor;
这样才符合threshold的意思(HashMap的size到达threshold这个阈值时会扩容)
但是请注意,在jdk8以后的构造方法中并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold重新计算
包含另一个Map的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
//将loadFactor赋值为默认的负载因子
this.loadFactor = DEFAULT_LOAD_FACTOR;
//如果传进来map中有数据,把数据移植到新的map集合中
putMapEntries(m, false);
}
最后调用了putMapEntries,来看一下方法实现
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
//记录传进来的map的大小
int s = m.size();
//证明里面有数据
if (s > 0) {
//判断table是否进行过初始化
if (table == null) {
// pre-size
//为求数组最大容量最准备,为避免除出来是小数,1.0F强转
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
//如果s数据数量超过新集合阈值,则进行扩容
else if (s > threshold)
resize();
//如果s数据数量小于新集合阈值,则将s集合中的数据添加到新集合中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
注意float ft = ((float)s / loadFactor) + 1.0F;这一行代码为什么要加1.0F?
HashMap底层源码解析下