目录
1、一些参数
初始容量,不直接写16,可能是提醒开发者容量是2的幂数
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
当链表长度大于此值且容量大于64时,转红黑树
static final int TREEIFY_THRESHOLD = 8;
转换链表的临界值,当元素小于此值时,会将红黑树结构转换为链表结构
static final int UNTREEIFY_THRESHOLD = 6;
最小树容量
static final int MIN_TREEIFY_CAPACITY = 64;
2、加载因子(扩容因子)
HashMap在初始化的时候给定预期大小,能减少扩容次数,最大限度的提升效率。推荐使用官方算法,这样能尽可能减少hash冲突,以及减少resize次数
(int) ((float) expectedSize / 0.75F + 1.0F);
HashMap的初始容量为16,当HashMap中有16 * 0.75 = 12个容量时,HashMap就会进行扩容。扩容会变为原来的两倍;
如果加载因子越大,扩容发生的频率就会比较低,占用空间比较小,但是发生hash冲突的几率会提升,对元素操作时间会增加,运行效率降低;
如果加载因子太小,那么表中的数据将过于稀疏(很多空间还没用,就开始扩容了),对空间造成严重浪费;
而且因为容量默认为2的次方,当加载因子为0.75时,容量和加载因子的乘积为整数。所以系统默认加载因子取了0.5 -1 之间的0.75.
3、tableSizeFor
用于获取离当前值最近的2的幂数,一般用来做阈值,通过5次无符号位运算得到
public class MainTest {
public static void main(String[] args) throws InterruptedException {
int in1 = 1073741825;
System.out.println("0 >>> "+(new BigInteger(""+in1)).toString(2));
System.out.println(tableSizeFor(1073741825));
}
private static int tableSizeFor(int cap) {
int n = cap - 1;
System.out.println("1 >>> "+(new BigInteger(""+n)).toString(2));
System.out.println("1 >>> 0"+(new BigInteger(""+(n >>> 1))).toString(2));
n |= n >>> 1;
System.out.println("2 >>> "+(new BigInteger(""+n)).toString(2));
System.out.println("2 >>> 00"+(new BigInteger(""+(n >>> 2))).toString(2));
n |= n >>> 2;
System.out.println("3 >>> "+(new BigInteger(""+n)).toString(2));
System.out.println("3 >>> 0000"+(new BigInteger(""+(n >>> 4))).toString(2));
n |= n >>> 4;
System.out.println("4 >>> "+(new BigInteger(""+n)).toString(2));
System.out.println("4 >>> 00000000"+(new BigInteger(""+(n >>> 8))).toString(2));
n |= n >>> 8;
System.out.println("5 >>> "+(new BigInteger(""+n)).toString(2));
System.out.println("5 >>> 0000000000000000"+(new BigInteger(""+(n >>> 16))).toString(2));
n |= n >>> 16;
System.out.println("6 >>> "+(new BigInteger(""+n)).toString(2));
return (n < 0) ? 1 : (n >= Integer.MAX_VALUE) ? Integer.MAX_VALUE : n + 1;
}
}
0 >>> 1000000000000000000000000000001
1 >>> 1000000000000000000000000000000
1 >>> 0100000000000000000000000000000
2 >>> 1100000000000000000000000000000
2 >>> 0011000000000000000000000000000
3 >>> 1111000000000000000000000000000
3 >>> 0000111100000000000000000000000
4 >>> 1111111100000000000000000000000
4 >>> 0000000011111111000000000000000
5 >>> 1111111111111111000000000000000
5 >>> 0000000000000000111111111111111
6 >>> 1111111111111111111111111111111
2147483647
4、Hash算法
Hash,一般翻译做“散列”,也有直接音译为“哈希”的,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,该输出就是散列值。两个不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做碰撞。
常见的Hash算法有以下几个:
- 直接定址法:直接以关键字k或者k加上某个常数(k+c)作为哈希地址。
- 数字分析法:提取关键字中取值比较均匀的数字作为哈希地址。
- 除留余数法:用关键字k除以某个不大于哈希表长度m的数p,将所得余数作为哈希表地址。
- 分段叠加法:按照哈希表地址位数将关键字分成位数相等的几部分,其中最后一部分可以比较短。然后将这几部分相加,舍弃最高进位后的结果就是该关键字的哈希地址。
- 平方取中法:如果关键字各个部分分布都不均匀的话,可以先求出它的平方值,然后按照需求取中间的几位作为哈希地址。
- 伪随机数法:采用一个伪随机数当作哈希函数。
上面介绍过碰撞。衡量一个哈希函数的好坏的重要指标就是发生碰撞的概率以及发生碰撞的解决方案。任何哈希函数基本都无法彻底避免碰撞,常见的解决碰撞的方法有以下几种:
- 开放定址法:开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
- 链地址法:将哈希表的每个单元作为链表的头结点,所有哈希地址为i的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。
- 再哈希法:当哈希地址发生冲突用其他的函数计算另一个哈希函数地址,直到冲突不再产生为止。
- 建立公共溢出区:将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中。
首先,MD5与hash都是单向加密的算法,可以把一些信息进行单向加密成固定长度的散列码。(hash算法即常说的散列算法,也被人翻译成哈希);其次,MD5也是hash算法的一种,常见的hash算法还有sha1,sha2等。MD5也被称为信息摘要算法,由于其算法复杂不够,容易被暴力破解的。SHA1算法也存在和MD5一样的问题。还有SHA2、SHA256、SHA512等,这些算法的复杂度相对要高,但是依然是可被破解的只是破解成本被增加了,但是一些常用的文本信息(比如密码)的散列码被一些专业厂端记录下来了,还是容易被破解的,怎么办呢? 加个密码盐呗,这样的话暴力破解几乎是搞不定了,即使搞定了可能也因为过去太久时间而变的没有价值。
5、HashMap 的数据结构
在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;而链表的特点是:寻址困难,插入和删除容易。上面我们提到过,常用的哈希函数的冲突解决办法中有一种方法叫做链地址法,其实就是将数组和链表组合在一起,发挥了两者的优势,我们可以将其理解为链表的数组。
HashMap 数组每一个元素的初始值都是 Null
5.1、Put 方法的原理
比如调用 hashMap.put(“apple”, 0) ,插入一个Key为“apple”的元素。这时候我们需要利用一个哈希函数来确定Entry的插入位置(index):index = Hash(“apple”)假定最后计算出的index是2,那么结果如下:
但是,因为 HashMap 的长度是有限的,当插入的 Entry 越来越多时,再完美的 Hash 函数也难免会出现 index 冲突的情况。比如下面这样:
我们可以利用链表来解决,HashMap 数组的每一个元素不止是一个 Entry 对象,也是一个链表的头节点。每一个 Entry 对象通过 Next 指针指向它的下一个 Entry 节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可:
需要注意的是,新来的Entry节点插入链表时,使用的是“头插法”。至于为什么不插入链表尾部,后面会有解释。
5.2、Get方法的原理
首先会把输入的 Key 做一次 Hash 映射,得到对应的 index:index = Hash(“apple”)由于刚才所说的 Hash 冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设我们要查找的Key是 “apple”:
第一步,我们查看的是头节点 Entry6,Entry6 的 Key是banana,显然不是我们要找的结果。
第二步,我们查看的是 Next 节点 Entry1,Entry1 的 Key 是 apple,正是我们要找的结果。
之所以把 Entry6 放在头节点,是因为 HashMap 的发明者认为,后插入的 Entry 被查找的可能性更大。
6、hash方法
hash :该方法主要是将Object转换成一个整型。
indexFor :该方法主要是将hash生成的整型转换成链表数组中的下标。
6.1、HashMap JDK7
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) {
return h & (length-1);
}
indexFor
方法:Java之所有使用位运算(&)来代替取模运算(%),最主要的考虑就是效率。位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。只要保证length的长度是2^n
的话,就可以实现取模运算了。而HashMap中的length也确实是2的倍数,初始值是16,之后每次扩充为原来的2倍。除了性能之外,还有一个好处就是可以很好的解决负数的问题。首先,不管hashcode的值是正数还是负数。length-1这个值一定是个正数。那么,他的二进制的第一位一定是0(有符号数用最高位作为符号位,“0”代表“+”,“1”代表“-”),这样里两个数做按位与运算之后,第一位一定是个0,也就是,得到的结果一定是个正数。
为什么要用2^n作为长度:
HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算,源码中做了优化hash&(length-1),hash%length==hash&(length-1)的前提是length是2的n次方;为什么这样能均匀分布减少碰撞呢?
- 2^n次方实际就是1后面n个0,2的n次方-1 实际就是n个1
- 例如长度为9时候,3&(9-1)=0 2&(9-1)=0 ,都在0上,碰撞了
- 例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞
hash
方法:的具体原理和实现
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
这段代码是为了对key的hashCode进行扰动计算,防止不同hashCode的高位不同但低位相同导致的hash冲突。简单点说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。
6.2、ConcurrentHashMap JDK7
private int hash(Object k) {
int h = hashSeed;
if ((0 != h) && (k instanceof String)) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// Spread bits to regularize both segment and index locations,
// using variant of single-word Wang/Jenkins hash.
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
int j = (hash >>> segmentShift) & segmentMask;
上面这段关于ConcurrentHashMap的hash实现其实和HashMap如出一辙。都是通过位运算代替取模,然后再对hashcode进行扰动。区别在于,ConcurrentHashMap 使用了一种变种的Wang/Jenkins 哈希算法,其主要母的也是为了把高位和低位组合在一起,避免发生冲突。至于为啥不和HashMap采用同样的算法进行扰动,我猜这只是程序员自由意志的选择吧。至少我目前没有办法证明哪个更优。
采用了 数组+Segment+分段锁 的方式实现,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock(Segment继承了ReentrantLock)
6.3、HashMap JDK8
在Java 8 之前,HashMap和其他基于map的类都是通过链地址法解决冲突,它们使用单向链表来存储相同索引值的元素。在最坏的情况下,这种方式会将HashMap的get方法的性能从O(1)
降低到O(n)
。为了解决在频繁冲突时hashmap性能降低的问题,Java 8中使用红黑树来替代链表存储冲突的元素。这意味着我们可以将最坏情况下的性能从O(n)
提高到O(logn)
如果恶意程序知道我们用的是Hash算法,则在纯链表情况下,它能够发送大量请求导致哈希碰撞,然后不停访问这些key导致HashMap忙于进行线性查找,最终陷入瘫痪,即形成了拒绝服务攻击(DoS)。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
以上方法得到的int的hash值,然后再通过h & (table.length -1)
来得到该对象在数据中保存的位置。
为什么链表长度到 8 时转红黑树:
因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3。。如果继续使用链表,平均查找长度为8/2=4。这才有转换为树的必要。。链表长度如果是6以内,6/2=3,速度也很快的。转化为树还有生成树的时间,并不明智。
6.4、ConcurrentHashMap JDK8
Java 8 里面的求hash的方法从hash改为了spread。实现方式如下:
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
Java 8的ConcurrentHashMap同样是通过Key的哈希值与数组长度取模确定该Key在数组中的索引。同样为了避免不太好的Key的hashCode设计,它通过如下方法计算得到Key的最终哈希值。不同的是,Java 8的ConcurrentHashMap作者认为引入红黑树后,即使哈希冲突比较严重,寻址效率也足够高,所以作者并未在哈希值的计算上做过多设计,只是将Key的hashCode值与其高16位作异或并保证最高位为0(从而保证最终结果为正整数)。
采用 synchronized+CAS+HashEntry+红黑树 的实现方式
6.5、ConcurrentHashMap 两个版本比较
数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。
锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。
链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。
查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。
7、HashMap为什么不用平衡树AVL
AVL树是更加严格的平衡,因此可以提供更快的查找速度,一般读取查找密集型任务,适用AVL树。红黑树更适合于插入修改密集型任务。