HashMap作为Java中最常用的数据结构之一,也算是最受欢迎的数据结构之一,我们在用的时候可能只是简单的知道他是用Hash实现的一个数据结构,在查找,删除,等操作上会有很高效的复杂度,所以只要遇到类似的需求就会直接上HashMap(也可能是我太菜了,hh),但是HashMap有我们想象中的那么完美吗,如果只凭表面的工作当然不能下定论,所以我们来详细的看看HashMap的那些事。
(本文把hashmap基本能遇到的问题都讲了一遍,所以如果觉得太长可以点击收藏,分几次看,慢慢理解)
1、HashMap的底层(哈希碰撞)
我们先来看看一般的hash表,5,12,9插入直接 % 8就可以了
如果我来一个,13,我这个时候再哈希一下13%8=5会有和5产生哈希碰撞,然后有问题就会有方法,解决哈希碰撞的方法有下面几种:
- 线性探测法:这其实就是最简单的方法,公式可以表达为(Key + d)%8,d为0,1,2,…7就是我碰撞到了5,我找一个地址6,如果6没人,我就到6住下了,如果6也有人,我就走下一个,依次类推,这个方法的弊端很明显,如果碰撞的多的话,我一直往下找,大家都连成线了,这个哈希,跟线性的找空位就没啥区别了
- 二次探测再散列法,跟线性探测法差不多,只是d换成了另外的表达式,(key + f)%8,这里的f分别为+1,-1,+4,-4其实也就是±1的平方,2的平方(有没有一种左右横跳的感觉,hhh),直到找到下一个位置,但是因为步长是1,4,9其实也是固定的,所以跟线性探测法一样,很容易产生聚集,就是所有产生哈希碰撞的元素都聚集在一块。这样一来的话产生碰撞的概率又增加了。
- 再哈希法:对于易聚集这个问题,再哈希法师可以解决的,再哈希法就是我准备了不止一个哈希函数,在第一个哈希函数产生冲突后,我们进行下一个哈希函数,这个时候的key值不变,只是哈希函数变了,直到找到下一个空位置,这个的弊端就是,要计算很多次,增加了计算时间。
- 拉链法:解释一下就是拉一个链表的方法,意思就是如果你们都映射到同一个位置,我也不管了,你们全放这吧,但是我一个位置肯定不够,那我就拉个链表把你们全放进去,这个方法是不是就省力多了。HashMap用的就是这个方法,并且这就是他的底层。
从别人博客偷张图(hh备注一下链接https://blog.csdn.net/lppl010_/article/details/80839160)
这样一看,hashmap的底层是不是很简单其实就是一个拉链的数据结构,但是如果思维敏捷的可能已经发现了,看上去确实没啥问题,但是仔细一想,他这个虽然解决了哈希碰撞问题,但是如果碰撞次数过多了,这一条链表非常长怎么办?
我这个只是一个链表,要是我要去查找其中一个元素,是不是还是逃不过O(n)的命运?没想到也没关系,人家jdk也是在1.8版本才改进了这个问题,1.7之前都一直是链表,所以这样是jdk1.7和jdk1.8的一个区别所在。
整个哈希表我们可以看成是一个数组,每个数组元素就是一个链表的root,在jdk18.中如果这个链表的长度大于8的时候这个root下面的数据结构会转换成红黑树。
既然人家都1.8版本了,那我们也就跟着最新版本学习吧
2、HashMap的1.8源码底层实现
一个简单的HashMap的继承关系。我们接下来看看在jdk中hashmap的具体实现吧
2.1、HashMap的底层数据结构实现
在jdk1.7中,hashmap的底层是一个Entry数组,每个元素就是一个Entry<K,V>,这里截取了Entry<K,V>中的一部分,主要是知道这个类有哪些变量就行了。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
}
有了单个节点的类,那么hashmap的整体其实就是一个Entry类型的数组了。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
在jdk1.8,1.7中的Entry<K,V>在1.8中改成了Node<K,V>其实改了个名字,但他们都是实现了Map.Entry<K,V>这个接口,从上面的继承关系我们也能知道。除了改名,内部的结构基本一致,只是数组的类型也变成了Node<K,V>而已。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
transient Node<K,V>[] table;
所以你看,用数据结构实现Hashmap的底层也不是什么难事嘛。
2.2、HashMap链表转红黑树
首先,我们看在HashMap内定义了这么两个阈值,后面能用上,这个TREEIFY_THRESHOLD也就是如果链表长度大于8就转红黑树的阈值。
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;
然后我们最常用的HashMap的put函数来了,我们看这个源码就知道,他其实是调用了一个putVal的函数,那我们主要看putVal这个函数就好了,
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//这一步是判断如果数量大于8了,那么就进行红黑树转换,转换完了之后,直接break
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;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
而这个函数中
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
这一句就是判断是否长度超过TREEIFY_THRESHOLD = 8,如果超过了,就转换成红黑树,这个时候调用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();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
这个函数里面我们重点看下MIN_TREEIFY_CAPACITY那一句就好了,这一句可以让我们知道,在jdk1.8里面,只有在当前链表长度超过8并且数组大小大于64才会进行红黑树的转换。
转成红黑树那如果同一个哈希对应的值多了就不怕了,因为查找的复杂度就是O(log(n))了。
那这里就有问题了,为啥我不一开始就直接把链表作为红黑树,要等到8才把他转成红黑树?而且这个8是怎么来的?我们看看源码里面的注释
/*
* Implementation notes.
*
* This map usually acts as a binned (bucketed) hash table, but
* when bins get too large, they are transformed into bins of
* TreeNodes, each structured similarly to those in
* java.util.TreeMap. Most methods try to use normal bins, but
* relay to TreeNode methods when applicable (simply by checking
* instanceof a node). Bins of TreeNodes may be traversed and
* used like any others, but additionally support faster lookup
* when overpopulated. However, since the vast majority of bins in
* normal use are not overpopulated, checking for existence of
* tree bins may be delayed in the course of table methods.
*
* Tree bins (i.e., bins whose elements are all TreeNodes) are
* ordered primarily by hashCode, but in the case of ties, if two
* elements are of the same "class C implements Comparable<C>",
* type then their compareTo method is used for ordering. (We
* conservatively check generic types via reflection to validate
* this -- see method comparableClassFor). The added complexity
* of tree bins is worthwhile in providing worst-case O(log n)
* operations when keys either have distinct hashes or are
* orderable, Thus, performance degrades gracefully under
* accidental or malicious usages in which hashCode() methods
* return values that are poorly distributed, as well as those in
* which many keys share a hashCode, so long as they are also
* Comparable. (If neither of these apply, we may waste about a
* factor of two in time and space compared to taking no
* precautions. But the only known cases stem from poor user
* programming practices that are already so slow that this makes
* little difference.)
*
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
*/
当然,要看懂注释还需要一些水平,hhh,我们看看大神的理解就好了,这一段来自https://blog.csdn.net/qq_39387434/article/details/111152391。
首先,TreeNodes占用空间是普通Nodes的两倍,如果在node数量不多的情况下,时间复杂度影响不大的前提下如果专用TreeNode的话,空间的代价比时间代价更大,所以只有在一定阈值的时候才会转换成TreeNode
再者,在理想情况下hashCode的算法是满足泊松分布的,那么根据泊松分布就有后面那一串数字
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
可以看到,链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。
3、HashMap的容量及扩容问题
除去HashMap的底层是很关键的问题,HashMap的扩容问题也是java中的一个技术点,也很关键。
3.1、HashMap的容量问题
想到容量,那么最简单的问题那就是初始化了,任意一个有大小的数据结构在初始化的时候肯定会初始化一个大小。当然我们一般在使用的时候,都是直接HashMap<K,V> hash = new HashMap<>();
,这就是采用最简单的构造函数去new对象了,那么除了这个构造函数,hashmap还提供了哪些构造函数呢?
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;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
具体的怎么实现的,大家可以自己看看代码,这里其实给我们最重要的信息就是两个参数,一个是initialCapacity,一个是loadFactor。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;
如果我们是常用的 HashMap()构造函数,那么初始的容量就是DEFAULT_INITIAL_CAPACITY = 16了,并且这个loadFactor的值也是0.75。
但是我们不用这个简单的构造函数,我们自己定义一个initialCapacity,并且用函数HashMap(int initialCapacity, float loadFactor)
这个构造函数呢?这里有一个tableSizeFor函数非常关键,因为hashmap的容量规定要是2的次方,所以当我们initialCapacity的大小不是2的次方的时候,那么这个函数可以找到第一个比initialCapacity大的2的次方的数,然后将此时的threshold设为这个数,这个算法还是很有意思的。
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
这个怎么理解呢,| 或,无符号右移>>>:左边位用0补充,右边丢弃。这个算法看起来很难理解,很多人可能只是知道可以这样算,但是怎么来的呢,我来讲讲我的理解,接下来非常关键,好好看好好学:
首先,int在java里面是32位的,所以假设每个数在内存中都是32位的二进制存着。然后我们想想这个问题本身,我们是要找第一个比自身大的2次幂数,那么在二进制上,如果一个数是123,00000000 00000000 00000000 01111011,比他大的,是不是就是最左边的1的前一位为1,其余都为0?00000000 00000000 00000000 10000000,所以我们如果把左边数第一个1开始的右边位数全置为1,return的时候再+1,是不是就得到了这个数?
好了,那么现在的问题就是怎么将所有位置为1了,jdk中的这些位运算其实就是为了实现这个。
我们想想如果一个数为01000000 00000000 00000000 00000001,比他大的第一个二次幂是不是10000000 00000000 00000000 00000000,那么我们就要在return之前把这个数变成01111111 11111111 11111111 11111111,那么这个时候或运算和无符号右移就来了。
第一个,因为我们最左边只有一个1,所以会有左移一位n >>> 1
得到00100000 00000000 00000000 00000000,这个时候两个数或一下,是不是就有
01100000 00000000 00000000 00000000了,这样我们是不是就利用之前最左边的一个1得到了两个1?然后我们再想想这个时候再右移两位,然后再或一下是不是就由两个1得到四个1?01111000 00000000 00000000 00000000。然后得到8个1,16个1,01111111 11111111 10000000 00000000,这个时候再右移16位,是不是就得到15个1?然后或一下不就31个1了?然后return的时候+1,不就得到10000000 00000000 00000000 00000000了?
当然上面的例子是个特例,因为这就是为什么从右移一位开始,右移16位结束的原因,如果中间某个位置有1的话,那也不影响,1|1也是1,能把中间的位置填为1就行。
懂了吗?没懂评论区可以说一下,hhhh我们再讨论讨论。
那这个0.75是用来干嘛的呢,我们先看这四个变量
transient int size;
//size很简单,我们的hashmap.size()返回的就是这个值,表示现在的hashmap里面有多少个键值对
transient int modCount;
//modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。
int threshold;
final float loadFactor;
loadFactoer就是默认为0.75的值了,这个loadFactor * Length(Node初始化数组长度) = threshold,而当Node数组中的数量超过threshold的时候,那么就是要扩容了。扩容为了保证容量依旧是2的幂次。每次扩容都是容量x2。
3.2、HashMap的扩容问题
最简单的问题,什么时候需要进行扩容呢?
1、众所周知当HashMap的使用的桶数达到总桶数*加载因子的时候会触发扩容;
2、当某个桶中的链表长度达到8进行链表扭转为红黑树的时候,会检查总桶数是否小于64,如果总桶数小于64也会进行扩容;
3、当new完HashMap之后,第一次往HashMap进行put操作的时候,首先会进行扩容,这个其实就是,如果只是new了一个hashmap,他并不会给他分配内存,初始化大小,只有在第一次put的时候才会进行扩容,分配。
JDK1.7
我们看看1.7的resize方法:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
这段代码比较简单,就是如果table的长度到了上限了,就不再扩容了,如果还未到上线,则创建一个新的table,然后调用transfer方法,这个transfer方法才是关键。
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next; //注释1
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity); //注释2
e.next = newTable[i]; //注释3
newTable[i] = e; //注释4
e = next; //注释5
}
}
}
transfer方法的作用就是将原来table的node放到新的table中,使用的是头插法,也就是说,新table中链表的顺序和旧列表的是相反的(在HashMap线程不安全的情况下,这种头插法可能会导致环状节点,所以1.8把这个改成了尾插法)。
其实过程比较简单,假设现在的链表为3->5->7,e表示当前节点,next表示下一个节点。
第一步e指向3,next指向5,然后将3放入newtable,newtable变成3->null,table变成5->7,
第二步e指向5,next指向7,然后e的next指向newtable的第一个节点3,然后此时将e赋给newtable[i](注释4),就相当于newtable的链表变成了5->3
依次类推、、
头插法怎么导致的环形链表呢,我们看如果有两个线程对同一个hashmap进行处理,线程A在分配给e = 3,next=5的时候,这个时候线程B抢占了CPU,对hashmap进行处理,并且完成了对hashmap扩容的过程,得到了下面的结果
在线程A中e=A,next=B,但是当线程B执行完之后会有一个什么结果?因为A,B是对同一个hashmap进行处理,所以线程A所对应的hashmap其实也变成了B的样子,在这个时候A得到了CPU的使用权,但是我们看到的是虽然e=A,next=B,但是这个时候5.next = 3,3.next=null(这个是环形产生的关键)
当线程A继续操作的时候,按照管理,先把3放入第三个位置,得到,然后e=next=5。
按照正常操作,
int i = indexFor(e.hash, newCapacity); //注释2
e.next = newTable[i]; //注释3
newTable[i] = e;
下一步就是把5插到3前面,得到
到了这个时候,是最关键的一步,在线程B执行之前,5.next = 7,这个时候我们再进行e=next的时候e应该等于7吧。但是因为线程B执行了之后,现在5.next=3,所以线程A下一个循环应该是e=3,然后e.next = newTable[i] = 5;
是不是就有了3.next = 5?那么环形链表就成了
这里有一个indexFor函数是JDK1.7为table链表中的元素计算新下标的函数其实也可以看成是rehash,h为当前元素的哈希值,length为新table的长度。
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
因为length-1其实就是length/2位上的所有位都变成了1,比如扩容前为16,扩容后为32,31就是11111,也就是对应的后五位为1,前面的位数全是0,这样的结果就是任何数跟11111与的话,得到的结果都在0-31的范围内,并且因为全是1,比有0存在的情况,更容易将原来的hash值散列化。
为什么全是1与更容易散列?如果11111的话 ,只有后五位全相同的情况下,才可能会产生哈希冲突,但是如果有一个0的话,假设11011,我现在10100和10000哈希得到的结果都是100000,这样就11011就比11111会多产生一次哈希冲突。虽然在解决散列问题又不错的效果,但每个hash值都要重新计算,还是很费时间,所以1.8就把这个给改掉了。
JDK1.8
同样先看看resize函数
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY) //注释1
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
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"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) { //注释2
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null) //注释3
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) { //注释4
if (loTail == null) //注释5
loHead = e;
else
loTail.next = e; //注释6
loTail = e; //注释7
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) { /注释8
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
1.8在1.7的基础上改进了不少。
第一个把原来的头插法改成了尾插法,这样就避免了产生换新链表,尾插法就很简单了,结合前面的讲解,看个图就好了。
第二个改进也就是我们前面聊到的rehash需要每个hash值都重新算一遍的问题,在1.8版本,jdk改进了这个index的过程。
因为扩容之后,容量多了一倍,就相当于index的范围在二进制的位数上最左边多了一位,所以,可以直接判断oldhash值的前一位是0还是1,比如现在16要扩容为32.对于1 0010和0 0010原位置都为2,jdk1.7需要对两个hash值对11111进行与操作,在jdk1.8中只需要判断,32/2 = 16该二进制位上是1还是0,如果是0的话,就直接保留原位置,如果为1的话就原位置+16,所以10010rehash之后为18,而00010rehash之后还是2。
1.8这一步就大大减少了rehash的位运算时间,提高了效率。
关于hashmap的容量和扩容问题差不多到这就结束了,接下来就是hashmap和hashtable的对接部分,hashmap的安全问题了。
4、HashMap的安全问题
其实安全问题在上面1.7版本的时候已经说过一个了,就是在不上锁的情况下,扩容的时候会形成环形链表,这个在1.8版本已经用尾插法解决了。
在JDK1.8中,put函数我们可以看到,其实也是没锁的,所以当两个线程同时对同一个key进行操作的话,后操作的线程会将前操作的线程给覆盖掉。
所以,即使hashmap在很多技术上都采用了很巧妙的方法,但是因为线程不安全,所以用的时候还是需要注意。
5、HashMap解决线程安全问题
解决HashMap的线程安全问题第一个就是使用HashTable,但是从JDK1.0HashTable提出,到现在,HashTable已经被弃用了,一方面是HashTable为了线程安全,变得效率比较低下,比不上HashMap,一方面是HashTale继承了Dictionary这个已经被弃用的父类,父类都被弃用了,那子类肯定就不会再用了。
所以一般解决HashMap线程安全问题一般都是用第二种方法:用ConcurrentHashMap类。
一方面ConcurrentHashMap继承了HashMap性能较好的优势,一方面又跟HashTale一样,线程安全,并且实现线程安全的方法要优于HashTable。所以这里我们主要看看ConcurrentHashMap为什么实现线程安全的方法优于HashTable。
HashTable实现线程安全得方法比较简单粗暴,每次上锁的时候,直接将整张表锁住,这样就只能每次一个线程独占hash表,这样虽然实现了线程安全,但一定程度上性能还是很差的。ConcurrentHashMap在HashTable的基础上进行了改进,先是把哈希表分成若干个segment,给每个segment上一个锁,这样当某一个segment被独占的时候,其他的segment也能被其他线程并发访问,很显著的提升了效率。
那么这样一来,ConcurrentHashMap的结构其实就很明显了,ConcurrentHashMap主要数据结构为一个segment数组,每个segment数组元素对应一个HashEntry数组,这个数组就跟HashMap的Node数组基本差不多,每个segment享有一把锁。
上面说的都是Java1.7版本的ConcurrentHashMap的实现,在1.8版本,Java通过CAS实现了无锁的处理方法,这个在这里就不细讲,准备在下一篇文章写JAVA多线程JUC和锁的时候详细分析一下CAS。但是我们可以看到,segment是继承了ReetrantLock类,这是JUC中的一个可重入类,具体的可以看看这篇文章
static class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
final float loadFactor;
Segment(float lf) { this.loadFactor = lf; }
}
ConcurrentHashMap的不足?
第一个是因为多了一个segment选择, ConcurrentHashMap在hash的时候其实是hash了两次,第一次找到segment,第二次才找到HashEntry,所以如果不是多线程的话,用HashMap的效率一般会更高。
第二个因为写的时候会锁住segment,但是读的时候是畅通无阻的,随时可读,所以可能读到的一些结果不是最新更新过的结果。
对于HashMap和ConcurrentHashMap你有个大概的了解了吗?如果有什么问题或者文章又不足的地方,欢迎交流!