引入:上篇说到有三个待解答的问题:
- HashMap 为什么默认数组长度是16?
- 16个单位真的够用吗?如果不够用该怎么办?
- 如果多线程执行put数据,get数据,是否是安全的?如何解决安全的问题?
我们先来看第一个:HashMap 为什么默认数组长度是16?
上篇源码分析中我们看到,如果构造HashMap的时候没有指定为数组的长度,那么,数组的长度是默认的:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
分析之前,我们将上一个问题拆分为两个问题:
- 为什么hashmap的容量约定是the power of 2 size呢(2的幂次方)?
- 基于问题1的前提下,为什么不是32,或者8呢
首先看第一个:
上篇文章介绍了将元素放到指定位置(桶)的规则:key的hash值对16取余(实际是位运算)。
如果这个分配算法不合理,就会出现某个桶(数组的某一项)大概率被选中放入数据:
就像宋小宝的一个小品台词说的那样,后宫佳丽三千,皇上独爱嫔妾,我就和皇上说呀,要雨~露~均~沾~,可皇上偏不听,就宠我,就宠我!很明显这样是不合理的,老是被翻牌子,身体也受不了。长此以往的累积下去,桶的负载就失衡了,同时效率也急剧下降且不可控。
我们通过下图看一下2的幂次方的合理性:
也就是说要想保证每个值都可以被取到,一定要是1 、11、 111、 1111等才可以,也就是2的幂次方。
再来看第二个问题,为什么是16 而不是8、32或者其他的2的幂次方呢?
引用官方的一句话就是:
Maybe a tradeoff between speed, utility, and quality of bit-spreading.
可能是速度、效用和比特扩展质量之间的权衡。
换言之也就是说,16刚刚好,多了浪费,少了不够用,这是一个折中的考虑。
我们再来看看第二个大问题:16个单位真的够用吗?如果不够用该怎么办?
这一点,源码作者肯定考虑到了:
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
加载因子,即在已被使用了多少比例的时候进行扩容,默认数组长度是16也就是说,当用掉了12(16*0.75)个桶的时候,容量就进行了一次翻倍处理:数组长度由16变为了32。这样就保证了数组容量总是提前被扩展的。
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
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);
}
我们在构造函数里可以自定义加载因子的大小,在每次put和remove的时候都进行长度的校验,当put后超过加载因子,就执行扩容,当remove后低于加载因子就缩容。
JDK1.8中,除了链表节点外,还新增了一个TreeNode:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
}
可以看到就是个红黑树节点,有父亲、左右孩子、前一个元素的节点,还有个颜色值。
另外由于它继承自 LinkedHashMap.Entry ,而 LinkedHashMap.Entry 继承自 HashMap.Node ,因此还有额外的 6 个属性:
//继承 LinkedHashMap.Entry 的
Entry<K,V> before, after;
//HashMap.Node 的
final int hash;
final K key;
V value;
Node<K,V> next;
这个红黑树是干什么的呢?:我们假想,当我们王hashMap忠put了很多的数据,每一个桶中都拥有大量的节点,此时,链表的效率已经逐渐降低了,考虑到这点,JDK1.8推出了红黑树节点,如果一个桶中的元素个数超过 TREEIFY_THRESHOLD(默认是 8 ),就使用红黑树来替换链表,从而提高速度。执行链表替换为红黑树的操作是:treeifyBin() 即树形化方法:
//将桶内所有的 链表节点 替换成 红黑树节点
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果当前哈希表为空,或者哈希表中元素的个数小于 进行树形化的阈值(默认为 64),就去新建/扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
//如果哈希表中的元素个数超过了 树形化阈值,进行树形化
// e 是哈希表中指定位置桶里的链表节点,从第一个开始
TreeNode<K,V> hd = null, tl = null; //红黑树的头、尾节点
do {
//新建一个树形节点,内容和当前链表节点 e 一致
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);
}
}
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
这样,在HashMap的扩展过程中,一些可预见的问题得到了解决。
我们再来看看第三个问题:如果多线程执行put数据,get数据,是否是安全的?如何解决安全的问题?
我们可能刷面试题的时候,看到过,HashMap是线程不安全的,HashTable是线程安全的。那么他们的区别在哪里呢?也很简单,我们知道线程是程序执行的最小单位,每时每刻,线程都在抢夺着CPU的时间片,如果我用a线程修改HashMap里的数据,b线程从HashMap里去数据,我们会发现,取到的数据并不是对应的,也就是并不是同步的,而使用HashTable则会打印出来对应顺序的日志。原因就在源码里:
以下代码及注释来自java.util.HashTable
public synchronized V put(K key, V value) {
// 如果value为null,抛出NullPointerException
if (value == null) {
throw new NullPointerException();
}
// 如果key为null,在调用key.hashCode()时抛出NullPointerException
// ...
}
以下代码及注释来自java.util.HasMap
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 当key为null时,调用putForNullKey特殊处理
if (key == null)
return putForNullKey(value);
// ...
}
比较发现,HasnTable的方法有synchronized 关键字,这也就是为什么hashTable可以保证存取的一致性的原因了。
但是当我们的并发操作非常频繁时,HashTable的弊端就展露出来了,因为,他是对整个方法同步,这会导致调用该方法的其他操作被阻塞。如何解决这个问题呢?这个问题出现的原因是同步的粒度太大,如果让同步只在被操作的Node上,就不会导致整个方法被阻塞了,我们再看下ConcurrentHashMap的部分源码:
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) { ///////////////////敲黑板,敲黑板,敲黑板
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
我们看到,这时同步关键字放在了 f 上, f 是谁?往上看就可以看到 f 正是操作的节点!
综上:hashMap不是线程安全的,但是速度更快,hashTable作为低频率的同步可用,高频率的同步操作,选择ConrrentHashMap更好一点。
到此,我们三个问题都分析完了,HashMap是必用,必考,必问,必知的问题,源码简单但很经典,谢谢大家赏光。欢迎交流。