HshMap的线程不安全也是老生常谈的经典问题了,其实这个问题对Android 开发来说实际意义不大,因为从Android N版本之后都是用的java 8,虽然也有些多线程问题,但是概率太小了。从实用主义来说HashMap知道字面含义就行,正好遇到相关问题,本着学习的态度还是重新看了下源码
/libcore/ojluni/src/main/java/java/util/HashMap.java
一、HashMap
HashMap的作用就是存储一系列的键值对,存储类型的数据结构,能想到最简单的就是数组吧,ArrayList,HashMap本质上也是一个数组,数组里储存的内容就是键值对,那么HashMap是怎么插入数据的呢?先看hashcode()函数
1.hashCode()
Java的老太爷是Object类,hashCode()就是定义在Object.java中的一个函数,所以对任何Java对象都可以调用hashCode(),这个函数的作用相当于编解码,最终可以把任意的对象转换成一个int值
295 public final int hashCode() {
296 return Objects.hashCode(key) ^ Objects.hashCode(value);
297 }
121 /* package-private */ static int identityHashCode(Object obj) {
122 int lockWord = obj.shadow$_monitor_;
123 final int lockWordStateMask = 0xC0000000; // Top 2 bits.
124 final int lockWordStateHash = 0x80000000; // Top 2 bits are value 2 (kStateHash).
125 final int lockWordHashMask = 0x0FFFFFFF; // Low 28 bits.
126 if ((lockWord & lockWordStateMask) == lockWordStateHash) {
127 return lockWord & lockWordHashMask;
128 }
129 return identityHashCodeNative(obj);
130 }
在hashCode()中,根据根据一系列的运算,最终得到了一个int。所以有了hashCode()函数,我们就可以把传进去的一个任意Key转换成int,再根据当前数组长度,就可以把这个int和数组的下标Index对应起来。实际这个转换过程也很简单,假设现在数组长度是8,那么下标只有0到7,我们只需要把计算出来的int值对7取余,即可确定一个唯一的Index。
Index = hashCode(key) % (n - 1)
计算出Index之后,我们就按照数组访问的形式,直接去访问对应下标的数据就好了。
但这样一来就有个显而易见的问题,通过哈希值对长度取余的方式计算出来的index,当然可能存在不同的key对应相同Index的情况。比如12 % 7等于5, 19 % 7也等于5的,这就叫哈希冲突。
2.哈希冲突
那么如何解决哈希冲突?答案就是链表。我们可以将同一个Index对应的内容作为一个根节点,里面保存的就是Key和Value,对于Index相同的情况,只需要再执行一个链表的插入即可,对于Index相等,并且Key也相同的情况,则是用新的Vaule去覆盖旧的Value。
除此之外,还有一个经典的问题就是,为什么HashMap喜欢用String作为Key?实际上使用String作为Key的原因就是为了解决哈希冲突。看下String类
/libcore/ojluni/src/main/java/java/lang/String.java
1824 int h = hash;
1825 // BEGIN Android-changed: Implement in terms of charAt().
1826 /*
1827 if (h == 0 && value.length > 0) {
1828 hash = h = isLatin1() ? StringLatin1.hashCode(value)
1829 : StringUTF16.hashCode(value);
1830 */
1831 final int len = length();
1832 if (h == 0 && len > 0) {
1833 for (int i = 0; i < len; i++) {
1834 h = 31 * h + charAt(i);
1835 }
1836 hash = h;
1837 // END Android-changed: Implement in terms of charAt().
1838 }
1839 return h;
1840 }
在String类中重写了hashCode()方法,Object中的hashCode()作用是将任意一个对象转换成int型,而String类中重写的hashCode()方法只针对将字符串对象转换成int型,它可以使最终转换出来的int结果分布更均匀,一定程度上也是有利于解决哈希冲突的。当然还有其他解决哈希冲突的算法,比如再哈希法等等,好多做算法的大佬在研究这个,但是Android 中目前没涉及到这些方式。
最终的HashMap
在HashMap中,有index对应位置不包含数据的,也有index对应位置只包含一个node的,也有包含多个node的,简单来说就是,先通过Key值计算出hashCode,然后根据hashCode计算数组中对应的index,再来根据Key选择覆盖或是添加Node。
总结来说,HashMap这种数据结构就是对数组和链表的取长补短,而我们评估一个数据结构无非就是增删改查,对于数组来说,查改是很快的,可以直接访问ArrayList[i],但是增删比较慢(因为需要遍历数组);同理,链表结构的增删比较快,但查改就会慢。使用HashMap融合了二者的优点,尤其是在数据量大的情况下,效率优化非常明显。HashMap根源上来讲是一个数组,但是数组中的每一位又是一个链表。我们在使用过程中,通过Key和hashCode()计算出来的index,就能快速的实现数据结构的增删改查。
那么HashMap存在的问题有哪些呢?
了解原理之后,其实很容易理解HashMap也是有一定缺点的(当然比我写的代码强几百倍),首先就是HashMap虽然相对于单纯的链表结构或数组结构,性能上优化了很多,但对于存储庞大的键值对,还是存在效率低的问题,毕竟本质上还是数组或链表。这点问题在Java8中通过把链表换成红黑树来解决;此外,还有个问题就是并发操作下会存在线程不安全性。HashMap的线程不安全主要会遇到两类问题,一个是死循环,另一个get的时候异常读到null。
二、使用HashMap get到空值
使用HashMap get 到空值个人觉得有三种情况,如果有不同意见可以留言,我再去看看代码
1. 多线程操作HashMap.put()导致存储数据丢失
624 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
625 boolean evict) {
626 Node<K,V>[] tab; Node<K,V> p; int n, i;
//进来之后的第一步就是初始化动作,属于即用即申请
627 if ((tab = table) == null || (n = tab.length) == 0)
628 n = (tab = resize()).length;
//这里的if判断的是当前数组是否有元素,如果没有的话新建一个节点
629 if ((p = tab[i = (n - 1) & hash]) == null)
630 tab[i] = newNode(hash, key, value, null);
//到这里说明当前index有节点了,就按照链表的方式插入
631 else {
632 Node<K,V> e; K k;
//表示has值和key都相等,就直接覆盖数据
633 if (p.hash == hash &&
634 ((k = p.key) == key || (key != null && key.equals(k))))
635 e = p;
636 else if (p instanceof TreeNode)
//如果是红黑树的节点,就按照树的方式插入
637 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//否则就通过链表的方式插入
638 else {
639 for (int binCount = 0; ; ++binCount) {
//p是当前链表,e是中间变量,这里的for循环就是尾插法
640 if ((e = p.next) == null) {
641 p.next = newNode(hash, key, value, null);
642 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
643 treeifyBin(tab, hash);
644 break;
645 }
//循环中遇到相等的直接覆盖
646 if (e.hash == hash &&
647 ((k = e.key) == key || (key != null && key.equals(k))))
648 break;
649 p = e;
650 }
651 }
652 if (e != null) { // existing mapping for key
653 V oldValue = e.value;
654 if (!onlyIfAbsent || oldValue == null)
655 e.value = value;
656 afterNodeAccess(e);
657 return oldValue;
658 }
659 }
660 ++modCount;
//扩容过程,涉及到一个填充因子
661 if (++size > threshold)
662 resize();
663 afterNodeInsertion(evict);
664 return null;
665 }
这里639行对链表节点的插入是尾插法,用图片来解释下
比如我们现在通过hashCode得到了一个数组下标,假设是index2,并且此时index2上面已经存在节点了,那么就按照链表的形式来插入新的键值,步骤是直接在链表尾部指向新new出来的键值
p.next = newNode(hash, key, value, null);
那么很显然,如果现在线程1,执行完newNode(hash, key, value, null)还没有赋值,只是把新的节点new出来了,cpu时间片切换到线程2,线程2同样在执行p.next = newNode(hash, key, value, null);这一行,假设线程1是key4,线程2是key5,那么此时key4应该是new出来在一边凉快凉快,先把key5接到表尾了
key5接到表尾之后,屁股还没坐热cpu时间片转回来,key4继续赋值,就相当于key4把key5的值覆盖掉了,那么此时再去get这个key就会返回null,甚至hashMap中连这个key都不存在了。
2. 在HashMap扩容时get()导致返回值为null
HashMap何时会扩容,这个需要根据当前的填充因子,HashMap的填充因子一般是0.6-0.75效果最好,在Android 中都是默认0.75.至于填充因子为什么在这个区间取值,大概源自某些科研文献吧...0.75应该是在Android 系统中运行效率最高的,不要乱改就完事了。
首先我们知道HashMap的容量是2的幂,至于为什么一定要是2的幂?回顾下前面解释过,我们对Key值进行哈希计算之后得到的int值,还要进行一个取余运算得到数组的Index,看下Android 中这里是怎么做的
/libcore/ojluni/src/main/java/java/util/HashMap.java
624 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
625 boolean evict) {
626 Node<K,V>[] tab; Node<K,V> p; int n, i;
627 if ((tab = table) == null || (n = tab.length) == 0)
628 n = (tab = resize()).length;
629 if ((p = tab[i = (n - 1) & hash]) == null)
630 tab[i] = newNode(hash, key, value, null);
631 else {
在往HashMap中插入数据时,这里得到Index的方式,并不是 hash % (n - 1), 而是 (n - 1)&hash, 显然这是两种不同的运算。结果能一样吗?这里当然不一样,随便算下就知道了。以二进制数为例,3%2 = 1, 但 100 & 010 显然是000。
实际上这里这样做的意义,还是为了提高HashMap的效率,在计算机中进行位运算是最快速的,因为 % 取余运算最终到计算机里也是经过一系列转化变成位运算,再转化回来。直接进行位运算可以最大效率的解决问题。这就是为什么HashMap的数组大小取值必须是2的幂次的原因。比如取初始值16,此时数组长度n-1 = 15,表示的二进制数就是1111
int & 1111 最终可取到的范围是 0~15
但如果不是2的幂次呢?假设数组长度是10,n-1 = 9,表示的二进制是1001
int & 1001 最终可以得到的值,只有四种 0000 0001 1000 和1001
所以HashMap大小为2的幂次就是这个原因。那么在HashMap需要扩容时,当然也是2的倍数。其实考虑这个问题很简单的,上源码
660 ++modCount;
661 if (++size > threshold)
662 resize();
663 afterNodeInsertion(evict);
664 return null;
665 }
如果当前HashMap中Index被使用超过threshold就会扩容,threshold实际就是数组长度x扩容因子。看看是怎么resize()扩容的
676 final Node<K,V>[] resize() {
677 Node<K,V>[] oldTab = table;
678 int oldCap = (oldTab == null) ? 0 : oldTab.length;
679 int oldThr = threshold;
680 int newCap, newThr = 0;
681 if (oldCap > 0) {
682 if (oldCap >= MAXIMUM_CAPACITY) {
683 threshold = Integer.MAX_VALUE;
684 return oldTab;
685 }
686 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
687 oldCap >= DEFAULT_INITIAL_CAPACITY)
688 newThr = oldThr << 1; // double threshold
689 }
690 else if (oldThr > 0) // initial capacity was placed in threshold
691 newCap = oldThr;
692 else { // zero initial threshold signifies using defaults
693 newCap = DEFAULT_INITIAL_CAPACITY;
694 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
695 }
696 if (newThr == 0) {
697 float ft = (float)newCap * loadFactor;
698 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
699 (int)ft : Integer.MAX_VALUE);
700 }
701 threshold = newThr;
702 @SuppressWarnings({"rawtypes","unchecked"})
//根据新的长度new一个新的表 ----> 1
703 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//将创建的新表赋值给table ---->2
704 table = newTab;
705 if (oldTab != null) {
706 for (int j = 0; j < oldCap; ++j) {
707 Node<K,V> e;
708 if ((e = oldTab[j]) != null) {
709 oldTab[j] = null;
710 if (e.next == null)
711 newTab[e.hash & (newCap - 1)] = e;
712 else if (e instanceof TreeNode)
代码有点长,但看到这里就可以理解了,实际上resize()的过程就是先把新的HashMap拿出来,然后再把旧的HashMap按序塞进去。观察上面注释的位置,在注释1的位置创建了一个新的表,然后将新的表赋值给了原先的table对象,在这个瞬间,table对象就是刚new出来的表,里面都是空的。如果此时CPU转到其他线程去get值的话,就会返回null。
3. 并发操作put()和get()带来的可见性问题导致返回null
这种情况其实非常少见,我也没有debug过此类情况,只是按理来说是存在的,属于system内存管理范畴。我们要知道,在代码中写值,实际上并不是第一时间写入到实际内存中去的,会有一个缓存机制,那么当第一个线程刚put键值对进去,另一个线程就去get,此时对于缓存区的数据是没有写在实际内存上的,也就是说,对于线程1来说,它已经完成put动作了,但是对于线程2来说,这个数据还没有写进内存。由此带来线程2去get这个Key返回为null。有点玄学。
三、HashMap 的死循环
HashMap的死循环,其实对现在来说已经不重要了,在Java 8中已经不存在这个问题。原因是因为在之前的Java版本中,HashMap的链表结构采用的是头插法,而Java8中的链表结构换成了尾插法和红黑树。还是来看下为什么头插法会导致死循环的问题吧,多了解一些流程,也有利于分析其他问题。假设数组长度是2,Key的hashCode分别是357,那么此时对应的Index都是1
此时如果扩容的话,结果应该是这个样子的
由于是多线程操作,且使用的是头插法,会将同一个位置上的新元素放在链表的头部,那么假设现在是两个线程同时在扩容,当两个待插入的数对应在同一个Index的情况下,根据头插法,会先把新加入的节点new出来,并指向一个null
在并发的情况下此时它俩都以头插法往里插,假设此时节点3刚被new出来,指向null,时间片切换到另一个线程来resize,而另一个线程做完了完整的reisze流程,把节点都拉出来挂好,此时如上图,节点5会被单独挂起来,在Index3上新的链表是 节点7 --> 节点3 --> null
然后此时前一个线程恢复了时间片,继续操作节点3,采用头插法把节点3插在节点7的前面,然后new出来的节点3本来是指向null的,会把节点3的下一个指针指向7。到这里就开始套娃了,3 --> 7 --> 3 --> 7..... 这里死循环会导致在get时一直无法返回,最终CPU跑到100%
不过在Java8中此问题已经不存在了,但是其他的多线程问题仍然存在,所以说HashMap是线程不安全的。那么线程不安全是否有办法解决?当然加锁是最直接的方法。实际上给每个操作都加个锁,就变成了HashTable嘛,如果全部操作都加锁导致效率太低的话,就只加几个关键位置的锁好了,那就叫ConcurrentHashMap嘛,这几个数据结构核心都是差不多的,后面有时间的话再整理吧。