HashMap vs HashTable
HashTable如果插入key/value为null的值时,会报错,但是hashmap不会,在hashmap中,null是作为第0个元素的,相当于是做了特殊化处理。
前者是非线程安全的,后者是线程安全的. 后者线程安全的原因就是因为后者的每一个方法上都有一个synchronized,这样虽然保障了线程安全,但是每次都要锁整个class对象,并且还会阻止其他synchronize方法的访问,所以效率低下! HashTable已经废弃了,尽量别使用了.
所以综上所述,HashTable效率低的原因是因为所有访问它的线程都必须竞争同一把锁,如果容器里面不同的数据有多把锁,那么执行的效率就高了,所以ConcurrentHashMap使用的就是锁分段的技术.
JDK1.8之后,HashMap和之前1.7不同的是,不再单纯的由数组+链表的方式实现的(所谓的链地址法). 1.7由于在Hash冲突的时候,在桶上形成的链表会越来越长,这样在查询的时候效率就会变低,1.8之后改成了由红黑树实现.即:
所以,JAVA8中的HashMap是由: 数组+链表/红黑树组成
Java7使用Entry来表示每个HashMap中的数据节点,8中使用了Node,基本没什么区别,都是key,value,hash和next这四个属性来修饰链表,而红黑树的情况需要使用TreeNode
类属性分析:
默认容量为: 16
这里的默认容量为什么是2的n次幂?
查看它的put方法可知,key在Node[]中的下标为: (n - 1) & hash。如果这个n是2的N次幂,那么hash相当于和111***111做与运算,数据分散的就比较均匀。如果n不是在的N次幂,即hash有可能和1110做与运算,那么最后一位怎么与都是0,那相当于结尾是1的那几个下标永远都不会放数据了,比如0001,0011…这肯定会增加碰撞的几率.
如果size > capacity*loadFactor的话,hashmap还会进行resize操作,会相当耗性能!所以如果事先可以确定你要放进hashMap中的数据大小。那么应该尽量设置成loadFactor * 2^n > initCapacity ,这样既考虑了&的问题,也避免了resize的问题.
但是后面提到会有tableSizeFor()和在put的时候考虑loadFact来保证上面这两个要求的.所以在初始化的时候,设置成自己知道的大小即可,冲突这些由hashmap自身来帮你减免!
不对,还是得自己算下,如果你输入的initcapacity为7,那么算出来的最近的2^n为8,如果选择的是默认的0.75,即最多放入6个元素就要扩容,你放到第7个的时候,还是要resize()…
最大的capacity为 2^30
默认负载因子为0,75,即size > capacity * 0.75,hashmap就要进行扩容
特殊情况下:
1)内存空间很多,时间效率要求很高,可以降低loadFactor(尽量让table扩宽)
2)如果内存紧张,时间效率要求不高,可以增加loadFactor
一个桶中,bin(箱子)的存储方式由链表转成红黑树的阈值为8
一个桶中,由红黑树转成链表的阈值,resize的时候可能会用到这个值.
当桶中的bin被树化时最小的hash表容量.如果树化时bin的数量太多会进行resize扩容.注释中说MIN_TREEIFY_CAPACITY至少是 4 * TREEIFY_THRESHOLD
上面说了这么多的bin,这里该介绍下bin到底是个什么结构了.
每个bin在HashMap代表存储了一个K/V键值对,结构定义如下:
Hashmap中计算key的hash值
可以看到它并没有直接使用Object中生成hashcode的方法,这个方法叫扰动函数,和之前的要将capacity设计成2^n一样,也是为了减少碰撞用的.
根据前面可知,key在Node[]中的下标为 key.hashCode & (n-1),我们知道key.hashCode是一个很长的int类型的数字(范围大概40亿),而n-1显然没有这么长,如果直相与,那么只有key.hashCode的后面几位参与运算了,显然会使得碰撞很激烈!加了这个函数之后,让高位也想办法参与到运算中来,这样就有可能进一步降低碰撞的可能性了!
用于存储Node(K/V)对的hash表(数组),为什么是transient?
为了解答这个问题,我们需要明确下面事实:
Object.hashCode方法对于一个类的两个实例返回的是不同的哈希值
可以试想下面的场景:
我们在机器A上算出对象A的哈希值与索引,然后把它插入到HashMap中,然后把该HashMap序列化后,在机器B上重新算对象的哈希值与索引,这与机器A上算出的是不一样的,所以我们在机器B上get对象A时,会得到错误的结果。
所以说,当序列化一个HashMap对象时,保存Entry的table是不需要序列化进来的,因为它在另一台机器上是错误的,所以属性这里为transient。
因为这个原因,HashMap重写了writeObject与readObject 方法
保存K/V对的Set
目前hashMap中K/V对的数量
每次对这个hashmap做操作,这个modCount就会改变.(CAS?!)
Threadhold表示当容量达到该值时,会进行resize
loadFactor表示用户设置的负载因子大小
构造函数:
有三种,一种是无参的,这个没啥好看的,一种是只配置了initialCapacity,最后一种是设置了initcapacity和loadFactor。看第三种就够了,第二种不过是将loadFactor这个形参用默认0.75传入而已.
方法内部是将loadFactor这个属性设置为用户输入的大小,有意思的是tableSizeFor(initCap)这个函数,也就是说你输入一个10,hash表的大小不一定就是10. 这个函数的功能就是用来保证容量应该大于cap,且为2的整数幂.
随便写个数带进去算一下就可知道,该算法的作用让最高位的1后面的位全变为1.然后再+1,得到的就是恰巧的2的n次幂.
注意table的初始化是在第一次put的时候做的,那个时候还有考虑loadFactor再做一次tableSize的计算,那个时候得到的就是最合适的那个2的n次幂的那个数了!厉害啊!
所以,看下put流程吧!这个很重要:
方法流程如下:
方法定义如下:
False表示,会改变existing value,相当于是key相同的话会做替换.
True表示,table不在creation mode.(这是啥意思?!)
第一次或者扩容的时候会调用resize():
- 如果是第一次初始化且没有输入initcapacity
那么newCap=16, threshold = 0.75 * 16
Hash表大小为: table = Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]
- 如果是第一次初始化,但是设置了initcapacity和loadFactor
注意,此时的threadhold(=initcapacity接近的2^n那个值) 和 loadFactor已经有值了,但是table==null,因为没有初始化嘛,所以此时oldCap=0
- oldThreadHold = threadhold(=initcapacity接近的2^n那个值)
此时 newCap = threadhold
threshold =newThreadHold = newCap * loadFactor (threadhold还变小了!…)
Hash表大小为: table = Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]
- 如果不是第一次初始化
此时 oldTab !=null oldCap = oldTable.length oldThreadHold = threadhold
那么 newCap = oldCap << 1 为原来的一倍 newThreadholder也为oldThr的一倍,即都扩大为原先的一倍!
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
还要将原先旧Table里的数据转移到新的table中.
- 插入完成后,还要检查下是否需要扩容
即 ++size > threshold 那么需要进行resize
这里让我想到有问题的可能就是这个了,首次初始化,cap为16,输入的initcapacity为15的时候,会涉及到一次扩容(如果没有冲突的话—所以这里的扩容一倍啥的操作应该是取了个折中!不然为了不冲突再扩容一倍很耗费空间啊!而且再扩容一倍也不能保证不冲突)
resize()流程分析:
(和Java7不同,8中resize后元素顺序是不变的)
概括起来就是:
- 原先有值的情况下
如果已经是MAXIMUM_CAPACITY,那么返回原先数组,否则将容量扩大为原来的一倍,即newCap = oldCap << 1, newThr=oldThr << 1
- 原先没有值,但是构造函数指定了initCapacity
newCap = oldThr, newThr随后会被指定为newCap*loadFactor
- 原先没有值,且没有指定initCapacity,即无参构造函数
按照默认的值初始化,即initCapacity=16, newThr=0.75*16
- 然后使用newCap构建一个newTab,如果旧表不为空就要迁移数据
迁移数据的流程如下:
- 如果只有一个元素时,那就重新计算位置,插入新的table
- 如果节点是树类型,那使用树的插入方式(这个暂时还不太了解)
- 如果节点是链表类型,因为元素放的位置取决于tab[i = (capacity - 1) & hash]。当长度扩为原来的2倍时,因为oldCap和newCap是2的次幂,并且newCap是oldCap的两倍,就相当于oldCap的唯一一个二进制的1向高位移动了一位,结论为元素要么在原先位置,要么在原位置上再移动2次幂。
举例:
比如原来容量是16,那么就相当于index=e.hash & 0x1111
现在容量扩大了一倍,就是32,那么index=e.hash & 0x11111
现在(e.hash & oldCap) == 0就表明:
已知: e.hash & 0x1111 = index
并且: e.hash & 0x10000 = 0
那么: e.hash & 0x11111 不也就是原先index的值!
get()流程分析:
如果获取到的Node为null则返回null,否则返回Node中存储的值。
点到getNode()中继续查看
分四步进行:
首先,如果table未空,直接返回null。否则就查找节点
再者,计算hash得到的位置刚符合,那直接返回。(只有一个bin的情况)
如果是红黑树,按红黑树的方式查找
如果是链表,逐个查找。 找不到返回null。
entrySet分析:
遍历的话,使用此种方式。比每次从Map中重新获取一个key要快多了!
不是每次都是new EntrySet(),但是暂时没找到这个东西在哪里填充的。
网上的说法是遍历的原理就是hashmap实现的原理。entrySet()该方法返回的是map包含的映射集合视图,视图的概念相当于数据库中视图。提供一个窗口,没有具体到相关数据,而真正获取数据还是从table[]中来。(ps:可以借鉴下hashmap的foreach方法,即先遍历完数组中的第一个链表,再遍历数组中的下一个链表…)
HashMap为什么线程不安全:
Java8以前线程不安全是在于在resize()的时候会在get的时候产生死循环,而之所以产生死循环是因为resize之后,元素的先后顺序会相反。
即转移的时候是这样的:每次取出旧数组的头结点的next,之后重新计算头结点在新的Hash中的位置,然后将头节点的next指向新的table[i],然后把table[i]设置成当前的头结点,那么就完成了头结点的转移。
这时候,线程一种3.next是7,线程二中7.next是3,e.next = newTable[i]就会形成了环形链表,所以在get的时候就会一直循环在这里。
并且在迭代的过程中,如果有线程修改了map,会抛出ConcurrentModificationException错误,就是所谓的fail-fast策略。
Java8之后,因为顺序是相同的,所以上面的那个环形链表问题就没有了。但是后面那个问题还是有的,所以还是线程不安全的。另外还有++size的操作也不是线程安全的!
参考:
http://www.iteye.com/topic/539465 (initailCapacity为什么要设置成2的n次幂?)
https://blog.csdn.net/dog250/article/details/46665743#comments (红黑树的一种解释)
https://coolshell.cn/articles/9606.html(Java7中为什么会形成环形链表)