一、Hash的概念
官方解释
Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
hash总结为以下几点
- 对任意长度的输入,通过hash函数(取模,),映射成一个固定长度的输出,无论输入多少次,输入值是一样的,那么输出值就是一样的。
- 不可以通过Hash值反推出原文。
- 输入数据的微小变化会得到完全不一样的hash值
- 哈希函数的执行效率要高,长的文本也能快速计算出hash值
- 理论上不能完全避免hash碰撞,就算哈希函数在怎样复杂,在海量(趋近于无限大)数据下都有可能计算出一样的hash值。
- hash算法的另一个要求是hash冲突的概率要小
- hash冲突可以类比抽屉原理,九个抽屉要放下十个苹果,必然有一个抽屉会存放两个苹果。
二、数组和链表
1、数组的优势和劣势
优势:内存空间连续,有下标。访问速度快
劣势:扩容麻烦,定义一个数组需要指定大小。当需要添加数据时没有办法进行动态扩容。
int a [] =new int[5];
2、链表的优势和劣势
优势:每个节点保留下一个数据的引用next指针。容易扩容
劣势:不是连续的内存空间,访问速度慢,需要一个一个比较
下面是LinkList的元素获取方法:
Node<E> node(int index) {
//通过下标和当前集合元素大小右移一位,假设值为mid进行比较
//list是按照元素的添加顺序来存储对象的,因此是有序的
//LinkList是双向链表。然后又是有序的,通过index和mid进行比较
//可以确认当前需要查找的下标在链表的那一半位置
//小于的话说明是求的下标位置小的那一半,通过循环和first比较,往后推index值的大小,就是你要的那个元素。从第一个往后推。
//大于等于的话,说明是求的下标位置大的那一半。通过循环和last进行比对往前推size-1-index位,就是你要的那个值。从最后一个往前推。
//这种通过类似二分法查找,其实只二分了一次,即O(n)/2,实际上时间复杂度还是O(n)
//这种访问顺序支持从头开始,也支持从尾巴开始。
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
LinkListde的数据展示如下,是一个双向链表结构,正是因为如此才支持从头遍历和从尾巴遍历。
这里稍微扯多了
3、散列表
散列表可谓是继承了数组和链表的特点。
三、HashMap
1、HashMap的继承体系
如网图:
2、静态内部类Node分析
链表Node类继承于Map的Entry接口。
红黑树TreeNode类继承于LinkedHashMap.Entry
3、几个参数说明
//默认数组大小
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//数组最大长度
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//树化阈值,并不是达到8就发生树化,满足树化容量>=64
static final int TREEIFY_THRESHOLD = 8;
//化成链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
//最小树化容量
static final int MIN_TREEIFY_CAPACITY = 64;
/* ---------------- Fields -------------- */
//Node数组
transient Node<K, V>[] table;
//enrtySet
transient Set<Map.Entry<K, V>> entrySet;
//map的size
transient int size;
//记录修改map的操作数,多线程下的一种并发保证
//使用foreach或者迭代器的时候不能对元素进行增加和删除
transient int modCount;
//扩容阈值
int threshold;
//负载因子
final float loadFactor;
4、构造方法分析
//默认构造方法,只指定了默认加载因子,其余值都是默认的,如默认数组大小。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
//指定初始化容量的构造方法
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;
//扩容阈值此时等于2^n次幂,initialCapacity=13,threshold =16,即大于initialCapacity最小的2^n次幂,使用tableSizeFor方法可以确定,数组的大小也是2^n次幂。
this.threshold = tableSizeFor(initialCapacity);
}
5、tableSizeFor方法分析
1、代码
//计算数组大小
static final int tableSizeFor(int cap) {
//防止cap=2^n次幂的情况
int n = cap - 1;
//与计算逻辑,只要有1就位1
//无符号右移一位并或计算一下
n |= n >>> 1;
//无符号右移2位并或计算一下
n |= n >>> 2;
//无符号右移4位并与计算一下
n |= n >>> 4;
//无符号右移8位并与计算一下
n |= n >>> 8;
//无符号右移16位并与计算一下
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
2、具体分析
Java中有许多位运算。这里就不描述了。直接贴图
将n无符号左移一位得到n1,在和n进行或运算
这里我们用数据说话:
cap=13
n=12
n的二进制: 0000 0000 0000 0000 0000 0000 0000 1101
n无符号右移1位n1 0000 0000 0000 0000 0000 0000 0000 0110
n=n|n1 0000 0000 0000 0000 0000 0000 0000 1111
n无符号右移2位n2 0000 0000 0000 0000 0000 0000 0000 0011
n=n|n2 0000 0000 0000 0000 0000 0000 0000 1111
同理,后面无论右移多少位,进行或运算后都是1111。
当然这里的13比较特殊,只要右移一位其实就够了
发现特征就是:无符号右移就是为了让高位的1右移。然后进行或运算就是为了让二进制数产生更多的1,我们知道2^n次幂的特征就是高位是1,低位全是0,当我们低位产生足够多的1时,加上1不就是2^n次幂了吗。
为什么只运算到16就停止了呢?
右移16位然后或运算一下最大能得到32个1,就是int的最大值。
1111 1111 1111 1111 0000 0000 0000 0000
#右移16
1111 1111 1111 1111 1111 1111 1111 1111
6、为什么hashMap的容量是2^n次幂
要说清楚这个问题,其实也不难。我们知道hash值是一个int类型的值,四个字节32位。但是数组的大小是固定了的。假设是10个,那么一个很大的hash值要映射到这个大小为10的数组中去,必然是要对数组大小进行取模运算得到一个在0-9之间的数子。但是我们知道对于cpu来说任何运算都是二进制之间的运算。求余运算是很耗cpu性能的。那有没有一种运算既可以达到这种效果又能让cpu的执行性能高呢?
当然有,任何一个一个正数数k对正整数m做与运算得到的值都是在[0,m]这个区间的。但是得到的值并不完全可以去到每个值,这个可以简单验算以下。但如果m=2^n-1的话就不一样了。
如图,三种值的情况,循环到int的最大值模拟所有的hash值,看set结合的值。心疼CPU,可以考虑用线程池来模拟。不过这个是CPU密集型,建议不要超过核数+1.
cap=2^n次幂
cap=2^n-1,可以取满
cap=其他值,也取不满
这个其实就是二进制位运算的神奇之处了。看看位运算的是怎么说的,然后自己练练就能发现规律了。其实m的取值越靠近2的n次幂,值越多。因为2的n次幂-1低位永远是1,通过和hash值与运算,全部是1才是1,这样hash值的高位就没用,值的大小永远有m决定(小的一方)。所以求出来的值总能取到0-m。
而我们发现hash值对(这里假设a是2的n次幂)a求余是在0-(a-1)之间,而且数组下标的计算也是这样的。所以既然位运算这么搞笑,也满足取值都可以取到。何不用2的n次幂作为数组的大小呢。使用位运算求下标时直接用数组长度-1.达到和求余一样的效果。
7、put源码分析
1、我们知道hashmap内的数组存放的是Node的引用。Node存放的是我们的key,value,key的hash值,和一个next(hash冲突时放的位置)
先放一段代码
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* 计算hash值,为了让hash值均匀分布
* 作用:让key的高16位也参与运算,为什么这样说,无符号右移相当于说把高十六位移到了低十六位,这样进行异或运算时
* 可以保留高位的特性,同时低十六位的二进制数被扰乱了。
* 因为如果低16位一样(这是很正常的),那么和tableSize-1进行与运算(即求余运算)时,产生的值将一样。
* 异或^:相同则返回0,不同返回1
* h =0b 0010 0101 1010 1100 0011 1111 0010 1110
* 无符号右移16位
* h1=0b 0000 0000 0000 0000 0010 0101 1010 1100
* <p>
* 进行异或运算
* 0b 0010 0101 1010 1100 0001 1010 1000 0010
*
* @param key
* @return
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
发现key的hash值并不是key.hashCode()方法产生的。需要经过hash扰动函数。一般来说Map的数据不会特别大,使用hash&(table.length-1)运算时,高16位基本上是没有用的,永远是低16位参与运算。既然高位没有用,低位参与运算产生的hash冲突就会大大增加,因为低16位很容易相同。所以就想办法将hash值的二进制扰乱。实现方式JDK8和JDK7不一样,这里我们只说JDK8,JDK8的实现方式是将hashCode值无符号右移16位,即丢弃了低16位的hash值,将高16位移到低16位,同时高位补0,然后和hashCode进行异或运算。可以看到位运算太牛逼了。进行异或运算后就会扰乱低16位hash值得到新的一个hash值,完美的将高16位和低16位进行相亲相爱。
2、讲完hash扰乱,我们正式进入putVal(方法),比较长。
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent 如果为true,不改变存在的值。和putIfAbsent方法有关,会设置为true
* @param evict 忽略
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//引用当前hashMap的散列表
Node<K, V>[] tab;
//当前散列表的元素
Node<K, V> p;
//散列表数组的长度
int n;
//表示路由寻址结果的下标
int i;
tab = table;
//如果table没有初始化,或者没有元素在里面,懒加载机制
//延迟初始化逻辑。第一次调用putVal进行数组的初始化,初始化最好内存的散列表
if (tab == null || (n = tab.length) == 0) {
//进行扩容操作
n = (tab = resize()).length;
}
//(p = tab[i = (n - 1) & hash])路由算法,计算下标
i = (n - 1) & hash;
p = tab[i];
//当前散列表下的这solt位置没有元素,直接存放,next指向null,即没有形成拉链
if (p == null) {
tab[i] = newNode(hash, key, value, null);
} else {
//不为null的花,表示找到了一个与当前插入的元素的key完全一致,这个e的作用很大,在后面代码中依靠这个e来判断是不是替换操作
Node<K, V> e;
//表示临时的一个key
K k;
//如果当前node的hash值等于需要put的元素key的hash值,并且满足key的equals方法相等
//表示这是一次replace操作
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) {
e = p;
//如果当前node时树节点,先忽略
} else if (p instanceof TreeNode) {
//放置树节点数据,e如果去树里面没有找到key完全一致的就会返回null,否则返回一致的那个数据赋值给e.
e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
} else {
//链表的情况,即hash值完全不想等(hash值相同,equals方法也相同)也不是树
for (int binCount = 0; ; ++binCount) {
e = p.next;
if (e == 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;
}
}
//e部位null,说明找到一个与你插入元素key完全一致的数据,替换操作
if (e != null) { // existing mapping for key
V oldValue = e.value;
//onlyIfAbsent, true:不存在这个key就进行插入,存在就不插入。@see putIfAbsent()类似于redis的putIfAbsent
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//没有用到
afterNodeAccess(e);
//返回老的值
return oldValue;
}
}
//表示散列表结构被修改的次数,替换node元素value不算
++modCount;
//当size(先加再用),
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
在这之前,我们发现当table为null时。说明当前Map没有进行初始化。(HashMap采用延迟加载机制,只有第一次put时才会数据初始化。)会先对数组进行初始化,也即是resize方法。也叫第一次扩容。现在到了真正put数据的时候。
- 情况一:(p = tab[i = (n - 1) & hash])路由算法,计算下标。p这个节点为null的话,这种情况最简单,构造好一个node节点,放入数组对应的位置。tab[i] = newNode(hash, key, value, null);
- 情况二:那就是当前key计算出来的下标位置有值p,此时比较p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))。如果为true说明是同一个key进来了。这是一个replace操作。直接新值替换旧值。
- 情况三:如果不是替换操作,说明产生了链表。这个时候判断当前节点是不是树节点。是树节点就使用putTreeVal的方法。这个后面再说有点复杂。
- 情况四:不是树那就是链表的情况了,此时循环查找链表,一个个判断链表数据,当还未形成链表时,当前桶位的node节点next位null,直接在后面拉链一个节点。形成链表的情况,我们需要循环比较next的节点key是不是和当前插入的key完全一致。一致说明是替换操作,不是那就在最后一个节点新建一个节点存放数据。但此时要注意如果达到树化阈值,需要对链表进行树化(binCount >= TREEIFY_THRESHOLD - 1。即调用treeifyBin方法,但是在treeifyBin方法里面有这么一段
*/
final void treeifyBin(Node<K, V>[] tab, int hash) {
int n, index;
Node<K, V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
当tab的大小小于64时只是会调用一次resize方法。将数组进行扩容,所以并不只有数据到达阈值才会扩容。当链表长度达到树化时也会。这是一种时间换空间的思想。
最后在代码里面我们发现有个变量e,e不为null表示时替换操作。并且此时返回旧值出去。最后几段代码记录了散列表结构被修改的次数,替换操作不算,并将size+1。然后计算当前数组大小是否超过阈值,超过进行扩容操作。
ps:这里我们可以了解到,为什么重写hashCode最好也重写equals方法。对于基本类型的包装类和String其实已经帮我们重写好了,如果是自定义对象,不重写这两个方法是有可能产生问题的。
8、resize方法分析
先贴代码
//扩容方法
//final修饰,表示该方法不可以被子类重写
//为什么需要扩容
//空间换时间
final Node<K, V>[] resize() {
//老数组,引用扩容之前的hash表
Node<K, V>[] oldTab = table;
//表示扩容之前table数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//表示扩容之前的扩容阈值,触发本次扩容的阈值
int oldThr = threshold;
//扩容之后的数组大小,下次的扩容阈值
int newCap, newThr = 0;
//标志hashmap已经初始化过了,是一次正常的扩容
if (oldCap > 0) {
//已经达到最大数组大小不进行扩容,直接返回原来的数组
if (oldCap >= MAXIMUM_CAPACITY) {
//设置扩容阈值位int的最大值
threshold = Integer.MAX_VALUE;
return oldTab;
//没有超过,扩容为之前数组的两倍
} else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//
newThr = oldThr << 1; // double threshold
}
//oldCap=0的情况,第一次扩容
//oldThr>0 有以下几种情况
//1、new HashMap(initCap,loadFactory)
//2、new HashMap(initCap)
//3、new HashMap(map),并且map有数据的
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
//oldCap=0,oldThr=0的情况
//new HashMap()无参构造
// zero initial threshold signifies using defaults
//16
newCap = DEFAULT_INITIAL_CAPACITY;
//12
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"})
//新建一个table
Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
//当前node节点
Node<K, V> e;
//说明当前桶位有数据,但是不确定有没有链表
if ((e = oldTab[j]) != null) {
//令当前桶位数据置为null,方便GC回收
oldTab[j] = null;
if (e.next == null)
//第一种情况,当前桶位只有一个数据,从未发生过碰撞。这种情况,直接计算当前元素应该存放的位置。
//重新计算元素在新数组的下标,并赋值给当前node节点
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//如果当前桶位有数据,判断是不是树节点,第二种情况
((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
else { //
//第三种情况,桶位已经形成链表
// preserve order
//低位链表:存放在扩容之后的数组的下标位置,与当前数组的下表一致
Node<K, V> loHead = null, loTail = null;
//高位链表存放在扩容之后的数组的下标位置为:当前数组的下标+扩容之前数组的长度
Node<K, V> hiHead = null, hiTail = null;
Node<K, V> next;
//高低链核心代码
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
} else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
- 情况一:当oldCap>0,说明当前数组已经初始化,是一次正常的扩容。当oldCap>=MAXIMUM_CAPACITY,已经达到最大数组大小不进行扩容,直接返回原来的数组。并设置设置扩容阈值位Integer.MAX_VALUE。
- 情况二:没有超过,是一次正常扩容,oldCap>>1,扩容至原来数组的两倍。但是如果扩容之后的数组小于MAXIMUM_CAPACITY(2<<30)且大于等于默认数组大小(16),将新的扩容阈值设置为老的阈值<<1 。
- 情况三:oldThr(老的扩容阈值>0),新数组大小就是老的扩容阈值,我们可以它通过构造方法发现,当构造方法为如下三个方法时:
//1、new HashMap(initCap,loadFactory)
//2、new HashMap(initCap)
//3、new HashMap(map),并且map有数据的
是有值的。
- 情况四:oldCap=0,oldThr=0的情况,是由HashMap的无参构造导致的,newCap就是默认大小,newThr就是默认大小*0.75。
- 情况五:通过以上步骤得出newThr=0的话,说明是通过有参构造进来的,我们要计算下一次的扩容阈值。并最终赋值给threshold。
上面的操作是计算扩容之后数组的大小和下次扩容阈值。
扩容完就需要将老数组的数据放入到新数组里面。
循环遍历老数组,确认每个数组元素在新数组的位置。如果当老数组前桶位没有值,不管。有值(不确定是否有链表),先置为null,方便GC回收,如果当前桶位没有next节点,说明未发生过碰撞。这种情况,直接计算当前元素应该存放的位置。赋值给新tab newTab[e.hash & (newCap - 1)] = e;
如果当前节点有next节点,判断是否为树节点,先不考虑。红黑树不太会。
此时桶位已经形成链表。我们在外面定义高/低位链表头节点和尾节点。低位链表存放扩容之后的数组的下标位置,与当前数组的下标一致。
高位链表存放在扩容之后的数组的下标位置为:当前数组的下标+扩容之前数组的长度。
我们通过do while循环,上面还有一个for循环时遍历每个数组元素的。来确定高低链该存放什么数据。
高位链表和低位链表分析。
对于有链表的node节点。我们先看(e.hash & oldCap) == 0这段代码,e就是当前链表遍历的节点,e的hash值和oldCap进行与运算。得到的值要么是0,如果是0,那么e.hash&(oldCap-1)=e.hash&(newCap-1)。要么是oldCap。如果是oldCap,e.hash&(oldCap-1)=e.hash&(newCap-1)+oldCap,就是这么神奇。只要得到的是0我们称这个node节点就是低位链的节点,否则就是高位链节点。然后将高位的node节点放到新数组的index+oldCap位置就行。低位的node节点放到原来数组位置即可。
9、get源码分析
get源码相对比较简单。
- 情况一:通过hash值求得当前元素的下标,如果当前存在,而且key值完全相同,就直接返回
- 情况二:key不完全相等,而且有下一个节点元素,说明是链表或者是红黑树。红黑树的情况,调用getTreeNode方法。
- 情况三:不是红黑树,是链表,循环遍历链表看是否存在key完全一致的,存在返回,不存在返回null。
- 情况四:当前下标没有数据,返回null。
10、remove源码分析
也比较简单,判断当前key值的下标元素是否存在,存在,key值完全相等,当前table下标置为null。
不存在,而且形成了链表或者树。如果是链表,循环遍历直到找到一个key完全相等的node节点,将这个节点的上一个节点指向这个节点的下一个节点即可。
四、红黑树
1、红黑树概念
- 红黑树满足平衡二叉搜索树
- 每个节点要么是红色要么是黑色
- 根节点一定是黑色
- 每个叶子节点是黑色的
- 每个红色节点的两个子节点一定都是黑色的
- 任意一个节点到每个叶子节点的路径都包含数量相同的黑节点,俗称黑高
- 如果一个节点存在黑子节点,那么该节点肯定存在两个子节点
- 每次插入的都是红色节点,然后按照上面红黑树的特性,将自己的父节点,父父节点变色,变色发现不满足根节点是黑色就进行旋转,如果根节点左节子点是黑色,那么就进行右旋。右子节点是黑色就左旋。