HashMap
我们知道Map
是一个key-val
的集合,HashMap
是基于Hash
表的Map
接口的非同步实现。HashMap
的基本数据结构是数组和链表。
HashMap原理
基于hashing
原理,我们通过put()
和get()
方法储存和获取对象。当我们将键值对传递给put()
方法时,它调用键对象的hashCode()
方法来计算hashcode
,返回的hashCode
用于找到bucket
位置来储存Entry
对象。如果该位置已经有元素了,调用equals
方法判断是否相等,相等的话就进行替换值,不相等的话,放在链表里面.
当获取对象时,通过键对象的equals()
方法找到正确的键值对,然后返回值对象。HashMap
使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap
在每个链表节点中储存键值对对象
HashMap的存储原理和存储过程
声明一个下标范围比较大的数组来存储元素,另外设计一个哈希函数获得每一个元素的Key
(关键字)的函数值(即数组下标,hash
值)相对应,数组存储的元素是一个Entry
类,这个类有三个数据域,key
、value
(键值对),next
(指向下一个Entry)。 当两个key
通过哈希函数计算相同时,则发生了hash
冲突(碰撞),HashMap
解决hash
冲突的方式是用链表。
例如, 第一个键值对A
进来。通过计算其key
的hash
得到的index=0
。记做:Entry[0] = A
。
第二个键值对B
,通过计算其index
也等于0
, HashMap
会将B.next =A
,Entry[0] =B
,
第三个键值对 C
,index
也等于0
,那么C.next = B
,Entry[0] = C
;这样我们发现index=0
的地方事实上存取了A
,B
,C
三个键值对,它们通过next
这个属性链接在一起。所以当hash
冲突很多时,HashMap
退化成链表。
put
过程:
- 对 key 的 hashCode() 做 hash,然后再计算 index;
- 如果没碰撞直接放到 bucket 里;
- 如果碰撞了,以链表的形式存在 buckets 后;
- 如果碰撞导致链表过长(大于等于 TREEIFY_THRESHOLD),就把链表转换成红黑树;
- 如果节点已经存在就替换 old value(保证 key 的唯一性)
- 如果 bucket 满了(超过 load factor*current capacity),就要 resize。
get
过程:
- 先通过 key 值进行哈哈希函数的运算得到 hash 值;
- 调用 getNode(),得到桶号;
- 在桶里面找元素和 key 值相等的即可,未找到返回空。
hashmap的负载因子
HashMap的初始化容量为什么为2的次幂?
因为在 get()方法中,获得元素的位置是通过 (length- 1) & h 来得到的,其中 h:为插入元素的 hashcode length:为 map 的容量大小。如果 length 为 2 的次幂 则 length-1 转化为二进制必定是 1111…… 的形式,在于 h的二进制与操作效率会非常的快,而且空间不浪费。如果是其他的话,空间不够,碰撞的几率变大,查询变慢,空间会浪费。
为什么HashMap是非线程安全的?
首先我们知道为了减少冲突,我们需要时刻留意当前的size
是否太大,检查是否需要扩容,一旦超过设定的threshold
,那么就要重新增大数组尺寸,此时所有元素都需要重新计算应该放置的下标。同时HashMap
在扩容的时候,是通过重新创建一个新的hash
表,把原来旧数组中的Entry
一个个迁移到新数组的,注意一点就是计算在newTable
中的位置,原来在同一条链上的元素可能被分配到不同的位置,看下面的源码。每次会扩容长度为以前的2
倍,原因看上面。
1 | void transfer(Entry[] newTable) { |
单线程的情况 resize()是没有问题的,但是多线程的时候就可能会出现形成环形链表的情况,导致扩容失败。具体详细的图可以看https://blog.csdn.net/andy_budd/article/details/81413464
HashMap和HashTable的区别:
HashTable 是不能接受 NULL,NULL 值组合的,而 HashMap 可以。(因为 HashMap 做了对应的 NULL 值处理,会把 NULL 值的键值对放到 hashcode 为 0 的链表里面)。
HashTable 是线程安全的,HashMap 是线程非安全的。因为 HashTable 是 synchronized,要想是 HashMap 线程安全 Map m = Collections.synchronizeMap(hashMap)
HashMap和TreeMap比较
- HashMap 适用于在 Map 中插入、删除和定位元素。
- Treemap 适用于按自然顺序或自定义顺序遍历键(key)。
- HashMap 通常比 TreeMap 快一点(树和哈希表的数据结构使然),建议多使用 HashMap,在需要排序的Map时候才用 TreeMap。TreeMap 的底层是红黑树。
- HashMap 非线程安全 TreeMap 非线程安全
- HashMap 的结果是没有排序的,而 TreeMap 输出的结果是排好序的。
为什么HashMap长度大于8才转换为红黑树,而不是6
红黑树的平均查找长度是log(n)
,长度为8
,查找长度为log(8)=3
,链表的平均查找长度为n/2
,当长度为8
时,平均查找长度为8/2=4
,这才有转换成树的必要;链表长度如果是小于等于6
,6/2=3
,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。中间有个差值7
可以防止链表和树之间频繁的转换。
这个只是简单的说法个人感觉,正确的说话应该是理想情况下随机hashCode
算法下所有bin
中节点的分布频率会遵循泊松分布,据概率统计决定的。
CourrentHashMap
Java 7 中的 ConcurrentHashMap 的底层数据结构仍然是数组和链表。与 HashMap 不同的是,ConcurrentHashMap 最外层不是一个大的数组,而是一个 Segment 的数组。每个 Segment 包含一个与HashMap 数据结构差不多的链表数组。整体数据结构如下图所示。
get
过程
在读写某个Key
时,先取该Key
的哈希值。并将哈希值的高N
位对Segment
个数取模从而得到该Key
应该属于哪个Segment
,接着如同操作HashMap
一样操作这个Segment
。为了保证不同的值均匀分布到不同的Segment
,需要通过如下方法计算哈希值。
Segment
继承自ReentrantLock
,使用分段锁的机制。
对于写操作,并不要求同时获取所有Segment
的锁,因为那样相当于锁住了整个Map
。它会先获取该Key-Value
对所在的Segment
的锁,获取成功后就可以像操作一个普通的HashMap
一样操作该Segment
,并保证该Segment的安全性。
同时由于其它Segment
的锁并未被获取,因此理论上可支持concurrencyLevel
(等于Segment
的个数)个线程安全的并发读写。
对于读操作,获取Key
所在的Segment
时,需要保证可见性,ConcurrentHashMap
并没有通过锁或者volatile
关键字,而是通过以下方式。
1 | Segment<K,V> s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u) |
Java 8
为进一步提高并发性,摒弃了分段锁的方案,而是直接使用一个大的数组。同时为了提高哈希碰撞下的寻址性能,Java 8
在链表长度超过一定阈值(8)
时将链表(寻址时间复杂度为O(N)
)转换为红黑树(寻址时间复杂度为O(long(N)))
。其数据结构如下图所示:
这个版本,是通过大量的volatile
关键字以及CAS
操作来实现线程安全的。
对于put
操作,如果Key
对应的数组元素为null
,则通过CAS
操作将其设置为当前值。如果Key
对应的数组元素(也即链表表头或者树的根元素)不为null
,则对该元素使用synchronized
关键字申请锁,然后进行操作。如果该put
操作使得当前链表长度超过一定阈值,则将该链表转换为树,从而提高寻址效率。
对于读操作,由于数组被volatile
关键字修饰,因此不用担心数组的可见性问题。同时每个元素是一个Node
实例(Java 7
中每个元素是一个HashEntry
),它的Key
值和hash
值都由final
修饰,不可变更,无须关心它们被修改后的可见性问题。而其Value
及对下一个元素的引用由volatile
修饰,可见性也有保障。
HashMap,HashTable,CourrentHashMap的key和value是否可为null
HashMap
对象的key
、value
值均可为null
。
ConcurrentHashMap
,HahTable
对象的key
、value
值均不可为null
。
HashMap
在put
的时候会调用hash()
方法来计算key
的hashcode
值,可以从hash
算法中看出当key==null
时返回的值为0。因此key
为null
时,hash
算法返回值为0,不会调用key
的hashcode
方法。但是HashTable
存入的value
为null
时,抛出NullPointerException
异常。如果value
不为null
,而key
为空,在执行到int hash = key.hashCode()
时同样会抛出NullPointerException
异常。
那为什么这么设计?
ConcurrentHashmap 和 Hashtable 都是支持并发的,这样会有一个问题,当你通过 get(k) 获取对应的 value时,如果获取到的是 null 时,你无法判断,它是 put(k,v)的时候 value 为 null,还是这个 key 从来没有做过映射。HashMap 是非并发的,可以通过 contains(key) 来做这个判断。
对于TreeMap
的话value
是可以为null
的,对于key
的话未实现 Comparator
接口时,key
不可以为null
,否则抛 NullPointerException
异常;当实现Comparator
接口时,若未对null
情况进行判断,则可能抛 NullPointerException
异常。如果针对null
情况实现了,可以存入,但是却不能正常使用get()
访问,只能通过遍历去访问。