相信大家都知道,在Java8之前,HashMap采用了数组+链表的结构,在Java8中加入了红黑树,为什么在Java8中决定加入红黑树呢?我们先来看一下HashMap的数据结构
如图,在Java8之前是没有右边红黑树结构的,只有数组+链表,为什么采用了这两种数据结构呢?
数组:查询效率比较高,增加删除的效率比较低;
链表:增加删除效率较高,查询效率较低;
基于这两种数据结构的特性,所以在Java8之前采用了数组+链表的方式,结合两种数据结构的优点。
-
那为什么在Java8还要引入红黑树呢?
大家思考一下链表的存储方式。尽管在数组中加入了链表的结构,那么数据存储多了,链表是不是会变得越来越长,那查找链表末尾的数据时,需要将链表遍历完成才能得到该数据。基于这种情况,所以在Java8中引入了红黑树。
-
HashMap的总体结构就是这样了。不知道大家现在是否会有一个疑惑,如图中,每个方框表示数据存储,HashMap又是以Key-Value的形式存储的,又有数组,链表等结构,HashMap是如何实现存储的?
其实,按照我们学过的数据结构知识,可以很快想到,Java是基于面向对象的,那么作者可能会在创建一个包含key和value属性的类,假如是Node,那么HashMap就会维护一个Node[]数组和Node类型的链表, 我们可以从源码中得到考证:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; /** * The table, initialized on first use, and resized as * necessary. When allocated, length is always a power of two. * (We also tolerate length zero in some operations to allow * bootstrapping mechanics that are currently not needed.) */ transient Node<K,V>[] table; }
从源码中我们可以看到,作者在HashMap中确实维护了一个有泛型的key和value的类,只不过这里多了两个属性:hash和next;还定义了**Node[]**成员变量,所以从这里可以看出,目前我们的想法是正确的。
大家已经了解了Java8中HashMap的总体组成:数组+链表+红黑树
在使用HashMap中,这种组合方式是一个循序渐进的结果,在这种组合中,大家认为哪种结构是最容易实现的?我认为是数组,因为数组只要new Node[size]创建一个就了事了,在源码中我们也可以看到“DEFAULT_INITIAL_CAPACITY”这个属性,为16,这是一个二进制的左移操作。(1的二进制位0001,左移4位变成10000,也就是2^4=16)
注:这里大家请注意一下属性的注释
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
- 那么在这样的一个长度16的数组里,HashMap是如何确定存放位置的呢?
当我们要向数组里存放数据时,应当先确定存放的位置,然后判断位置上是否已有数据,再决定数据的存储方式。
这里我们直接map.put(“key”,“value”)操作,回来到下面的代码中
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
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);
可以看到,**tab[i = (n - 1) & hash]**这里计算出数组中的位置,那为什么作者要这么计算存储的位置呢?
我们先不管作者是如何想的。我们自己思考,要在一个长度16的数组里确定存放位置,我们应当将索引确定在
0-15之间,如何确定索引?这一hash值就派上用场了,首先我们看一段代码:
System.out.println("Dawn".hashCode());
/**
* 结果
*/
2122804
我们可以先假设通过hashCode()方法取到一个int整数,那么,作者可以使用这个整数去计算得到一个0-15的整数,我们可以想到,最简单的方法就是直接取余,2122804%16就可以得到一个符合要求的数,那么为什么作者选择了
(n-1)&hash逻辑运算的方式呢?
我们从上面已经得知,数组的默认长度是16,作者采用二进制右移的操作得到,16的二进制位10000.这里判断采用与运算可以看到16-1的结果为15(01111),那么这里就可以得出结果了,这样运算,也保证了结果是在0-15之间的,如下:
10101010010101
01111 &
-----------------
00000000000101 = 5 (00000-011111)
到这里,我们也可以知道,作者处理hash冲突的方法,当计算到相同的索引时,会转化成链表向下顺移存储。那么,这种方法还有什么需要优化的吗?
大家有没有发现,如果这样直接用hashCode去进行与运算,真正决定索引位置的只有后面5位,如果每次计算的索引值都是同一个,那么链表会越来越长,性能上肯定实惠大打折扣的,那么针对这种情况如何进行优化呢?目前能想到的,就是尽可能的让更多的位数来计算决定索引的位置,得出0和1的概率要接近或相等。参考下图:
可以看出,进行异或运算得出0和1的概率是相同的,我们再去瞅瞅作者的源码:
/**
* Computes key.hashCode() and spreads (XORs) higher bits of hash
* to lower. Because the table uses power-of-two masking, sets of
* hashes that vary only in bits above the current mask will
* always collide. (Among known examples are sets of Float keys
* holding consecutive whole numbers in small tables.) So we
* apply a transform that spreads the impact of higher bits
* downward. There is a tradeoff between speed, utility, and
* quality of bit-spreading. Because many common sets of hashes
* are already reasonably distributed (so don't benefit from
* spreading), and because we use trees to handle large sets of
* collisions in bins, we just XOR some shifted bits in the
* cheapest possible way to reduce systematic lossage, as well as
* to incorporate impact of the highest bits that would otherwise
* never be used in index calculations because of table bounds.
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里可以看出,作者将hashCode高16位右移16位与低16位进行异或运算,这里也解答了上面初始长度的注释中为什么会有这样一句了**“The default initial capacity - MUST be a power of two”**
为了保证索引在0-(n-1)之间,那么初始长度只能是2的n次方了,因为作者进行了一次减1操作,会得到01111这样的二进制数。但是,在创建HashMap时我们可以自己设定长度,如果我们偏不按照作者的规则走,那又如何呢?其实作者有自己一套应付方法。
/**
* Returns a power of two size for the given target capacity.
*/
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;
}
如果你传入了不符合规则的长度,作者会将你的长度转化成2的n次方。
对索引的均匀分布已经得到解决,那链表长度过长的问题呢?这个就简单了,当链表的长度达到一个阈值时,会转化成红黑树,从源码中可以得到该阈值是8
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;
具体大家可以去看看源码。
现在还有一个问题,就是数组的扩容问题
//------------------------------------------------------------------------------
if (++size > threshold)
resize();
每次向HashMap中添加值的时候,都会size变量都会自加与threshold进行判断。我们可以数组初始化resize()方法中找到相应的代码
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
----------------------------------分界线--------------------------------------------
final Node<K,V>[] resize() {
...
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
threshold = newThr;
...
}
将数组的长度乘以0.75,然后将结果值赋给threshold,所以这个阈值为0.75,如果没有进行长度指定,那么数组的长度为16,当超过16*0.75=12的时候,数组就会扩容。
那么如何扩容呢?其实很简单,就是重新创建一个数组,容量为原来的两倍,将原有的数据转移到新的数组上,但是,数组的位置可能会不同,这里会根据新的数组长度进行hash计算,原数据可能会在原下标的位置上,或者是在原下标+原容量的位置上。
相信大家也知道,HashMap不是线程安全的。
HashTable是对每一个get put等方法加上一个synchronize实现线程安全的,但是这种方法会影响性能。
所以,这里要说的是ConcurrentHashMap,相比HashTable,ConcurrentHashMap的线程安全机制做的比较好,它没有向HashTable那样直接在方法上进行加锁,而是采用了锁分段技术。这里笔者能力有限,还没继续深入研究。
对HashMap的分析就到这里,如果有错的地方,欢迎留言。_