HashMap 、ConcurrentHashMap知识点全解析
散列表
在了解hashmap之前,要先知道什么是散列表,因为hashmap就是在散列表结构基础上改造而成的。散列表,也叫哈希表,是根据关键码值(key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表 。
散列表为什么存在?数组不行么?
散列表和数组一样,是八大数据结构中的一种。数组特点是线性结构、顺序存储,也就是数组中的所有元素排序是连续的,在遍历查找时效率非常高,但同时也因为这个特点导致了增删操作效率低的缺点,因为是内存连续的,所以在删除中间某个元素时,某一方的数据就需要全部移动确保元素是内存连续的(但是并不能说对数组执行增删操作效率就一定低,当增删的是两边的数据时就不需要移动其他数据了)。而另一个数据结构则和数组相反,它就是链表,链表的元素并不是连续排列,相邻两个元素是使用prev、next(双链表结构,单链表只有next属性)来表示上一个元素和下一个元素的位置,这种结构的好处就是增删效率高,而修改、查找慢,原因是在增删时只需要改变相邻元素的属性就可以了。那有没有一种结构能结合这两种结构的优点呢,这就是散列表。
散列表的特点
上面已经说过了,散列表是结合了数组和链表优点的结构,它查找和增删效率都不算低,那么它是怎样实现的呢?散列表其实就是将存储的数据通过固定的算法(也就是哈希算法)进行计算得到某一个范围的值,这个范围的值就对应散列表的数组范围(见上图散列表结构,0-15就是数组部分),然后再将这个数据根据刚才计算得出的值找到对应的数组下标进行保存。
哈希冲突是什么?如何解决?缺点是什么?
我们通过哈希算法来计算找到我们要存储的数组下标,但是数组的容量是有限的,数据越多越容易产生多个数据计算得出同一个结果的情况,这就产生了哈希冲突。而一个数组下标位置只能保存一个值,所以我们就需要去解决哈希冲突,解决哈希冲突主要有两种方式。一种就是链地址法,这也是常用的方法,链地址法就是在数组后面以链表的形式添加数据,这也是HashMap处理哈希冲突的方式。第二种是开放定址法,核心思想就是让发生冲突的数据分配到其他空闲的下标位置进行保存,其实现方式有线性探测法、二次探测法、伪随机探测法等。哈希冲突带来的问题就是它会使当前数组的利用率不高,因为链表查询效率不高,所以当数据都集中在那几个下标时查询的效率就会很低。
HashMap
前面已经说过,hashmap 就是散列表的结构上得到的,可以说散列表是一个概念结构,而 hashmap 则是这个概念的实现。hashmap 在 JDK1.8 进行一次升级,引入了红黑树结构,同时将头插法改成了尾插法,还有其他一些改动。接下来就从内部源码入手来看1.7和1.8中 hashmap 的执行过程。
结构
1.7 内部使用 Entry 数组来保存要存储的键值对,1.8 使用 Node 数组来保存要存储的键值对,这个 Entry 类型和 Node 类型都是 hashmap 内部维护的一个内部类,这个数组存储的是各个下标的第一个数据,如果没有数据就是 null,其他数据都是通过 next 属性进行串接的。
1.7 static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash; ... 1.8 static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; ...
可以看出,在 1.7 中的 Entry 内部类 hash 就是普通int类型的属性,而在1.8中改成了 final 类型的,因为每个对象的 hash 值都是唯一的,1.7中的hash属性没有使用final修饰可能会产生安全问题,所以在1,8中改成了 final 修饰的。
此外,hashmap 内部还有其他一些参数,主要看下 1.8 中的
/** * The default initial capacity - MUST be a power of two. 默认容量,指得是在创建 hashmap 时没有指定容量默认的数组容量 */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** * The maximum capacity, used if a higher value is implicitly specified * by either of the constructors with arguments. * MUST be a power of two <= 1<<30. 最大容量,指得是hashmap能存储元素的最大个数,2的30次方 */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * The load factor used when none specified in constructor. 扩容因子,扩容因子 = 当前容量 / 数组总容量 ,当达到扩容因子时就会发生扩容 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 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. 最小树化值,指得是当该链表的长度达到8时就可能进行树化 */ static final int TREEIFY_THRESHOLD = 8; // /** * The bin count threshold for untreeifying a (split) bin during a * resize operation. Should be less than TREEIFY_THRESHOLD, and at * most 6 to mesh with shrinkage detection under removal. 最小链化值,指得是某个数组下标后面已经树化后又发生元素减少而使得元素个数过少再次退化成链表,这里规定就是元素达到6就退化成链表 */ static final int UNTREEIFY_THRESHOLD = 6; /** * The smallest table capacity for which bins may be treeified. * (Otherwise the table is resized if too many nodes in a bin.) * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts * between resizing and treeification thresholds. 最小树化容量,在某条链表元素达到8后会判断当前数组的 length 是否达到规定值,达到才会进行树化,这里是64(这里比较的是数组的 length 而不是存储的数据量) */ static final int MIN_TREEIFY_CAPACITY = 64; /** * 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; /** * Holds cached entrySet(). Note that AbstractMap fields are used * for keySet() and values().
所有键值对数据的 Set 结构 */ transient Set<Map.Entry<K,V>> entrySet; /** * The number of key-value mappings contained in this map.
存储数据的总量 */ transient int size; /** * The number of times this HashMap has been structurally modified * Structural modifications are those that change the number of mappings in * the HashMap or otherwise modify its internal structure (e.g., * rehash). This field is used to make iterators on Collection-views of * the HashMap fail-fast. (See ConcurrentModificationException).
相当于一个版本号,每次对数据修改,添加,删除都会加1,在每次迭代遍历内部元素时都会去检查是否与 expectedModeCount 相等,因为HashMap内部的迭代都是使用内部的迭代器进行迭代的,且维护了母迭代器 HashIterator,
其他的内部迭代器都是继承了这个类,而这个母迭代器内部就含有 expectedModeCount属性,这个属性会在迭代器初始化时被赋予 modCount 数值,所以如果在迭代过程发现 modCount 与 expectedModeCount 不同,那么说明
内部维护的数据被修改过(添加、删除),那么这次迭代就是不安全的(并不是实时的数据),那么就会抛出异常。 */ transient int modCount; /** * The next size value at which to resize (capacity * load factor). * 数组阀值,存储数据总数超过这个值就会进行扩容(注意不是数组不为空的位置数而是存储数据数超过阀值就会扩容),默认是当前数组 length*0.75 * @serial */ // (The javadoc description is true upon serialization. // Additionally, if the table array has not been allocated, this // field holds the initial array capacity, or zero signifying // DEFAULT_INITIAL_CAPACITY.) int threshold; /** * The load factor for the hash table. * 实际的负载因子,默认是 0.75 * @serial */ final float loadFactor;
1.7中相关参数大致相同,感兴趣可以自行去研究。
初始化
hashmap在1.7和1.8中默认容量都是16,如果指定了容量,那么容量就是指定的容量。需要注意的是,在1.7、1.8中如果在创建容器时没有指定容量那么内部的数组都不会初始化,只会在第一次put操作时才会初始化。下面是1.8中的相关代码。
// 构造函数 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } // put 操作 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } /** * 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; ... }
扰动函数
如果要设计hashmap元素存储,可能会设计成下面的方法
1、先获取 key 对象的 hashcode 值 2、将 hashcode 值与(数组容量-1)进行并操作,得到 hash 值 3、根据 hash 值找到对应的数组下标进行存储。
这种方法符合散列表元素存储的定义,可以实现数据的存储,但是却有致命的缺陷,因为我们要存储的 key 可以是各种对象,所以 key 的 hashcode 值可以是非常大的数据,最大可以达到 2147483647,又因为我们在计算 hash 值时使用的是并操作,所有数据会转成二进制进行计算,因为数组容量一般都不会太大,所以面对着 hashcode 数据很大的值时,高位的数往往都不会参与运算,参与运算的只有那几位,这就导致发生哈希冲突的概率增加,带来了各种缺点,所以我们应该极力避免哈希冲突的发生。
hashmap 中使用扰动函数解决了这个问题,过程如下:
前面还是获取 hashcode 值(也就是哈希码),然后调用 hash 方法得到处理后的 hashcode 值,那么我们就需要去看一下这个方法1.8中的源码:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
可以看到,它会将 hashcode 值的二进制向右移动 16 位再与原本的 hashcode 值进行异或操作,得到的值才作为哈希码返回进行并操作得到哈希值,这样计算会让 hashcode 高位的数也参与运算,减少了哈希冲突发生的概率。 1.7中的实现也差不多,思想也是让高位的数也参与运算,代码如下
final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
比较 1.8 与 1.7 扰动函数,可以看出 1.8 扰动函数更加简便,运算效率也更高。
put过程
因为 1.8 引入了红黑树,所以着重以 1.8 源码为例进行讲解
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { /* * tab:临时的 Node 数组, * p:要添加的数据将要存放数组下标位置的第一个数据 * n:原 Node 数据总数 * i:要存储数据位置的数组下标 */ Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 如果内部的 table 数组为空,就执行初始化再赋值 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 如果该位置为空直接赋值 else { /* e:要存储的数据最终的 node 结点 * * 判断该位置的 hashcode 值, * 1、如果与要添加的数据 key 的 hashcode 值相等(意思是该数组下标位置只有一个值并且相等),就赋值给 e * 2、如果该位置是树节点就获取树节点返回并赋值给 e * 3、上面两种都不满足,就进行遍历,每次下一个 Node 都赋值给 e 。 * 1、如果当前位置 key 相等,就返回 * 2、如果到头了,就直接直接后面补一个结点就行了。然后进行树化判断 * 该位置最终的数据 */ Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 如果第一个值就相等,直接赋值p 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); // 在数据后面以链表形式连接起来 if (binCount >= TREEIFY_THRESHOLD - 1) // 如果链表长度达到 8 treeifyBin(tab, hash); // 执行这个方法,这个方法下面再讲解 break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; // 如果遍历的当前数据 key 与要添加的数据 key 相等,就直接退出循环(e此时也是当前的位置) p = e; } } if (e != null) { // 非遍历完还未找到 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; }
通过上面的源码可以知道 put 方法的具体过程:
1、调用扰动函数 hash 去处理 key,
2、检查内部的数组是否为空,如果为空初始化
3、根据扰动后的 hashcode 计算得到 hash 值寻找对应的数组下标,判断该位置是否为空,如果为空就直接将要添加的值设置到数组该下标位置上
4、如果3情况都不满足,则再进行下面判断
1、如果数组该位置的key相等(先比较 hashcode 值是否相等,如果相等再调用 equals 方法比较,如果 equals 返回为 true 才说明两个值相等。下面的 key 判断都一样),返回该 Node 值
2、如果该节点是树节点,调用方法查找 key 值相等的节点返回
3、上面两种情况都不满足,说明是链表结构,就遍历链表,检查各个 key 值与要添加的 key 是否相等,相等就返回,不存在相等的就在最后面进行添加,然后判断是否需要树化(链表长度 >= 8,进行判断。如果数组 length<64,扩容,否则树化成红黑树)。
4、如果返回值不为空,也就是上面的1,2,3三种情况中不是链表且没有值相等的那种情况,换句话说就是存在 key 相等的节点,那么就进行节点值的替换。
5、判读是否需要扩容(大于阀值 threshold 就进行扩容,扩容为原来的2倍)。
这里关于是否需要树化的方法 treeifyBin 还没有分析。接下来就分析一下 这个方法的源码,同样还是以1.8为例。
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) // 如果 数组容量小于规定的最小树化容量,也就是64,就执行扩容 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); } }
从源码中可以很清楚地看出:当数组容量小于64是不会进行扩容的,只有达到64才会进行树化操作。这样也是防止数据全部集中在某几个下标使哈希表退化成链表。
扩容操作
hashmap另一个难点就是扩容,我们知道的是hashmap的扩容会将数组容量扩容为原来的两倍,但是具体是怎样实现的呢,还是以1.8的源码为例
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) { // 如果数组长度达到能存储的最大值,就将阀值改成 Integer 的最大值 threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && // 数组扩容为原来的2倍,然后判断扩容前的数组长度是否达到了默认的数组容量,达到再将阀值也扩容为原来的2倍 oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // 如果之前的数组容量 =0,之前的阀值 >0,就将初始容量置于阈值 newCap = oldThr; else { // 如果之前的数组容量 =0,之前的阀值 =0,就将初始容量置于阈值,阀值也设为初始阀值 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { // 如果阀值等于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) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) 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) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
可以看到在执行扩容操作时,是先确定扩容后的数组长度以及阀值,然后新建一个满足该条件的数组,再将原数组中所关联的所有数据全部添加关联到新数组中,将之前的数据转移到新的数组这个过程是非常消耗时间的,所以我们在最初创建容器对象时就要先确定要存放的数据量,尽量避免扩容。并且如果没有在创建实例时指定扩容因子,就使用默认的扩容因子 0.75。
其他一些问题
1、hashmap数组容量为什么是16?或者说容量为什么是2的幂次方?
其实这是为了防止添加数据时频繁的发生哈希冲突。前面已经说过哈希冲突的危害,频繁的哈希冲突会使哈希表退化成链表,造成查询效率低。那为什么设计成2的幂次方就可以减少哈希冲突呢?通过上面的源码分析,我们都知道在添加操作时计算数组下标需要调用内部的扰动函数然后进行并运算才能得到哈希值,然后将这个哈希值作为数组下标找到对应的位置。
那么这中间关键的运算就是并运算,并运算的特点是“全真且为真”(这是我们那边高中逻辑判断题目记得顺口溜,不知道你们是什么O(∩_∩)O~),也就是进行并运算的两个的二进制该位数都是1,最后的结果才是1,否则结果就是0,那么问题就来了,我想要最终的结果既可能是0,也可能是1,这样才能使得最终的结果不同,起到减少哈希冲突的作用,这是前提,那应该怎么做呢?在进行并运算时,参与运算的两个数有一个数是确定的,那就是(数组的容量-1)这个数,另外一个数是 key 的 hashcode 经过扰动函数处理后的数,那么就要求(数组容量-1)这个数的二进制数每位都是1,这样当另一个数某位是1,结果是1,;某位是0,结果是0,这样就减少了哈希冲突了。每一位都是1,那么四个1转成十进制就是15,那么容量就是16,其他容量同理。
2、hashmap在1.7中是头插法,为什么到了1.8就变成尾插法?
头插法存在着严重的弊端,那就是在多线程下扩容操作时可能会形成环形链表。所以在1.8变成了尾插法。当然,hashmap本身就是线程不安全的容器,不安全指的是数据不安全,可能会造成数据丢失和读取不正确,不能同步。所以这里是改变只是适当地减小了hashmap1.7中的缺点,在多线程下还是不能使用hashmap作为容器存储数据。
3、hashmap1.7与1.8有什么区别?
1、1.7是头插法,1.8是尾插法。更安全
2、1.8引入了红黑树
3、1.7中的数据结点是 Entry 类型,1.8是 Node 类型。
4、扩容检查不一样,1.7是在put操作开始时检查;1.8是在添加数据后检查是否需要扩容。1.8扩容已经分析了,下面看一下 1.7 中的相关代码
public V put(K key, V value) { if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } // 上面就是一些判断,如果有相等的 key 就直接替换,然后直接返回,否则执行下面的 modCount++; addEntry(hash, key, value, i); // 添加数据方法 return null; }
void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { // 如果数据总量 size 达到阀值 threshold,就执行扩容 resize resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); // 真正的添加数据 }
4、使用自定义对象作为 key 时,为什么要重写该对象类的 hashcode() 方法和 equals() 方法?
通过前面的源码可以看出,在 put 方法时在检查是否有 key 值相等的节点存在时,先比较的是他们的 hashcode 值(准确的来说是比较结果扰动函数处理后的 hashcode 值,可以看作就是比较 hashcode 值),然后再 equals 方法去比较。
首先要明白为什么要先判断 hashcode ,再调用 equals 方法去比较,为什么不直接调用 equlas去比较。这是因为 hashcode 值本身就是一个“散列值”,它就是由对象的地址经过散列函数处理转成一个数值而形成的,我们知道散列值是多个对象可能拥有同一个散列值,那么在进行判断时就可以拥有更高的效率。换句话说就是 hashcode 方法效率比 equlas 方法高,但是另一方面因为两个对象他们的 hashcode 值可能相等,所以还需要 equlas 方法去二次判断。而 hashcode 不相等的就直接被 pass 。
然后就是为什么要重写这两个方法,首先要知道,我们使用 String ,Date,Integer这些类直接不用重写,这是为什么。因为这些是内部已经重写了这两个方法,而我们自定义的类,它没有重写,所以它默认调用的就是基类 Object 的方法,而 Object 的这两个方法都是和地址值有关的, hashcode 是地址值转成的,equlas 是比较地址值。我们想要的比较是比较属性值,所以没有重写就会导致两个对象他们虽然属性值相等,但是在比较时却永远不会相等。所以我们在使用自定义对象作为 key 时,需要去重写它的 hashcode 方法和 equals 方法。
5、引入红黑树的好处?
红黑树具有查询效率高的特点,当链表过长时,因为链表查询效率低,所以在数据量大的情况下,链表就会变得很长,那么查询效率就会很低,这时将链表转成红黑树就会极大的提高查询效率。
6、HashMap 知识点小结。
1、底层使用数组加链表结构,在1.8开始又引入了红黑树,这是为了防止在链表长度过长时造成链表数据的查询效率降低。兼顾了查询与增删的效率。
2、没有指定容量时数组初始容量是0,第一次 put 后会初始化为16。
3、key 和 value 都可以为 null 值。
4、扩容时机是容器存储的数据量达到 0.75*数组容量 时就会发生扩容,变成原来的2倍,而树化是在链表长度达到 8 且数组容量达到 64 才会发生树化操作,否则如果只是链表长度达到 8 而数组容量没有达到 64 只会扩容为原来的2倍。
5、HashMap 在计算哈希值前会先调用内部的扰动函数处理 hashcode 值,让高位和低位进行运算,这是为了让高位的数也能参与哈希值的计算,减小哈希冲突。
6、key 值最好是一个常量,如果是自定义对象,那么需要重写 hashcode 与 equals 方法。
ConcurrentHashMap
HashMap 是线程不安全的,也就是在多线程下使用 HashMap 来保存数据数据是不安全的,可能会发生数据遗失,错误等问题。那么为了能在多线程情况下也能使用 HashMap,创建多个线程安全的容器,如 HashTable,ConcurrentHashMap ,但是广泛使用的还是ConcurrentHashMap ,那么 HashTable 为什么会被淘汰?下面会对这个问题进行解答,首先我们先着重来看 ConcurrentHashMap 的结构优势。它的结构和 HashMap 非常像,但是同时它却是一个线程安全的容器。那么它是怎样实现的呢?下面还是从源码上来看看它的结构,put 过程。
结构
/* 1.7*/ static final class HashEntry<K,V> { final int hash; final K key; volatile V value; volatile HashEntry<K,V> next; ... } /* 1.8 */ static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; ... }
可以看出,1.7 和 1.8 内部维护的节点类是差不多的。和 HashMap 一样,1.8 的 ConcurrentHashMap 相比于 1.7 引入了红黑树,树化条件还是链表长度达到 8 , 且数组 length >= MIN_TREEIFY_CAPACITY,也就是 64。
重要属性
// 以下是标记几个特殊的节点的hash值,都是负数 // ForwardingNode节点,表示该节点正在处于扩容工作,内部有个指针指向nextTable static final int MOVED = -1; // 红黑树的首节点,内部不存key、value,只是用来表示红黑树 static final int TREEBIN = -2; // ReservationNode保留节点, // 当hash桶为空时,充当首结点占位符,用来加锁,在compute/computeIfAbsent使用 static final int RESERVED = -3; /** * The array of bins. Lazily initialized upon first insertion. * Size is always a power of two. Accessed directly by iterators. 存储数据首位数据的数组 */ transient volatile Node<K,V>[] table; /** * The next table to use; non-null only while resizing. table 迁移时的临时容器 */ private transient volatile Node<K,V>[] nextTable; /** * Table initialization and resizing control. When negative, the * table is being initialized or resized: -1 for initialization, * else -(1 + the number of active resizing threads). Otherwise, * when table is null, holds the initial table size to use upon * creation, or 0 for default. After initialization, holds the * next element count value upon which to resize the table. 这个参数对应 hashmap 中的 threshold,但是它的作用并不仅仅表示扩容的阀值。 当它为0时,就表示还没有初始化, 当它为-1时,表示正在初始化 当它小于-1时,表示(1 +活动的调整大小线程数) 当它大于0时,表示发生扩容的阀值 */ private transient volatile int sizeCtl;
可以看到属性基本都使用 volatile 去修饰,这样每次去获取这些属性都是从主内存中获取,而不是从各自线程的工作内存中获取。保证了数据的可见性。
初始化
以 1.8 源码为例
/** * Creates a new, empty map with the default initial table size (16). */ public ConcurrentHashMap() { } public ConcurrentHashMap(int initialCapacity) { if (initialCapacity < 0) throw new IllegalArgumentException(); int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1)); this.sizeCtl = cap; }
可以看出如果没有指定初始容量,则不会进行任何操作,指定了容量,则会初始化 sizeCtl ,但是还是没有初始化数组。
put 操作
先看一下1.8中的源码:
/** Implementation for put and putIfAbsent */ final V putVal(K key, V value, boolean onlyIfAbsent) { // ConcurrentHashMap 保存的键值对 key 与 value 都不能为空,所以 key 或者 value 为空直接抛出异常 if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode()); // 调用扰动函数处理 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, // 这里调用 casTabAt 方法使用乐观锁去添加,也是为了保证线程安全,因为外面嵌套了一个for循环,所以这里会一直尝试去获取锁直到获取到获取结构改变 new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin } /* * 如果该节点状态是 MOVED 状态,说明数组正在进行复制,也就是扩容操作中的数据复制阶段, * 那么当前线程也会参与复制操作,以此来减小数据复制需要的时间 */ else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { V oldVal = null; synchronized (f) { // 这里使用 synchronized 锁住数组第一个数 if (tabAt(tab, i) == f) { // 重复检查,防止多线程下的数据错误 if (fh >= 0) { // 取出来的元素的hash值大于0,当转换为树之后,hash值为-2 binCount = 1; for (Node<K,V> e = f;; ++binCount) { // 遍历链表 K ek; if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { // 如果节点key相等就替换 oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } Node<K,V> pred = e; if ((e = e.next) == null) { // 遍历到头了没有 key 相等的节点,就在创建节点在最后面关联 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) // 链表长度达到8,进行扩容或树化操作 treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } } } addCount(1L, binCount); // 新增一个节点数量 return null; }
可以看出,过程如下:
1、判断 key,value 是否为空,如果为空,抛出异常
2、调用扰动函数处理,下面开始多个条件判断
3、判断数组是否为空,为空初始化数组
4、计算找到对应的数组下标,进行判断
1、如果该位置为空,直接使用CAS乐观锁进行添加,如果失败则重新判断。
2、如果该位置的 hash 值为 MOVED,说明正在进行数据复制,那么当前线程也参与数据复制
4、上面两个条件都不满足,则使用 synchronized 锁住该下标的数,然后判断
1、如果是链表节点,遍历,如果存在 key 相等的就替换;不存在就在后面添加关联节点;
2、如果是树节点,就按树节点方式添加。
5、检查链表长度是否达到8,如果达到8,执行 treeifyBin 方法,扩容或者树化。
6、增加节点数量
这里需要注意的是,相比于HashMap,ConcurrentHashMap这里在最后一步不会去判断是否需要扩容了。这里的 treeifyBin 方法和 hashmap 基本一致,这里就不过多分析了。
那1.7 中有什么不同,在解答这个问题之前首先要说明在1.7中有一个 Segment 内部类,这个类内部的 HashEntry[] 类型的属性存储的是 table 某个下标关联的所有数据,因为 1.7 中使用的是 HashEntry 而不是 Node,所以 1.7 中的数组是 HashEntry 数组,同样,因为 Segment类存储的也是 HashEntry 数组,所以 Segment 类的属性和外部 ConcurrentHashMap 的属性很像。
static final class Segment<K,V> extends ReentrantLock implements Serializable { private static final long serialVersionUID = 2249069246763182397L; static final int MAX_SCAN_RETRIES = Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1; transient volatile HashEntry<K,V>[] table; transient int count; transient int modCount; transient int threshold; final float loadFactor; Segment(float lf, int threshold, HashEntry<K,V>[] tab) { this.loadFactor = lf; this.threshold = threshold; this.table = tab; } ... }
从源码可以看到,这个类是继承了 ReentrantLock 的,所以在对这个类对象操作时,可以调用 lock 方法进行加锁操作。
接下来就看一下1.7中的 put 过程:
@SuppressWarnings("unchecked") public V put(K key, V value) { Segment<K,V> s; if (value == null) // 如果 value 为空直接抛出异常 throw new NullPointerException(); int hash = hash(key); // 调用扰动函数 int j = (hash >>> segmentShift) & segmentMask; //计算 hash 值 if ((s = (Segment<K,V>)UNSAFE.getObject // 定位到对应的 Segment 对象,如果为空,初始化 (segments, (j << SSHIFT) + SBASE)) == null) // s = ensureSegment(j); return s.put(key, hash, value, false); // 正式执行添加方法 } final V put(K key, int hash, V value, boolean onlyIfAbsent) { HashEntry<K,V> node = tryLock() ? null : // 尝试获取锁,如果成功,继续执行后面代码, scanAndLockForPut(key, hash, value); // 如果失败,通过执行 scanAndLockForPut 来自旋重复尝试 V oldValue; try { HashEntry<K,V>[] tab = table; int index = (tab.length - 1) & hash; HashEntry<K,V> first = entryAt(tab, index); // 获取对应 segment 中的第一个数据 for (HashEntry<K,V> e = first;;) { // 循环判断 if (e != null) { // 如果第一个数不为空,就遍历判断,存在 key 相等的就替换掉 value K k; if ((k = e.key) == key || (e.hash == hash && key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; ++modCount; } break; } e = e.next; } else { // 如果第一个数为空,或者上面没有找到 key 相等的数。就将该数在后面进行添加,然后判断是否需要扩容 if (node != null) node.setNext(first); else node = new HashEntry<K,V>(hash, key, value, first); int c = count + 1; if (c > threshold && tab.length < MAXIMUM_CAPACITY) rehash(node); else setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break; } } } finally { unlock(); } return oldValue; }
可以看出在 1.7 中特地用 segment 类将原本的数组横向切开,一个 segment 保存的是原本的数组的某一个下标位置所包含的链表数据的数组,用于锁住各个数组下标对应的链表数据。在 put 时是调用 segment 继承 ReentrantLock 类中的加锁方法对这个对象进行加锁,也就是它锁住的是这个桶的数据。 其他和 1.8 中差不多,除了没有树形结构。
总结一下:
ConcurrentHashMap 1.7 和 1.8 的区别:
1、1.7 中没有红黑树。1.8 引入了红黑树
2、1.7 相比于1.8增加了 Segment 类,用于表示数组单个下标所对应的链表数据所组成的数组,用于锁住这个链表;而 1.8 锁住的是数组下标的第一个数据,锁的颗粒度减小,效率更高。1.7本质使用的是 ReentrantLock 锁 + 自旋锁,而1.8 使用的是 synchronized + CAS乐观锁。关于这两种锁的区别,可以查看 Lock 与 synchronized 区别。
HashMap 与 ConcurrentHashMap 的区别?
上面的源码解析得比较清楚了,下面就拿 1.8 来举例。首先,HashMap 不是一个线程安全的容器,ConcurrentHashMap是线程安全的。其次, HashMap 是在 put 操作的最后检查是否需要扩容,而 ConcurrentHashMap 只会进行树形化判断,并不会单独的进行扩容判断。
HashTable 与 ConcurrentHashMap 的区别?
以1.8 的 ConcurrentHashMap为例,简单的看一下的 hashtable 的 put 方法的源码
public synchronized V put(K key, V value) { // Make sure the value is not null if (value == null) { throw new NullPointerException(); } // Makes sure the key is not already in the hashtable. Entry<?,?> tab[] = table; int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length; @SuppressWarnings("unchecked") Entry<K,V> entry = (Entry<K,V>)tab[index]; for(; entry != null ; entry = entry.next) { if ((entry.hash == hash) && entry.key.equals(key)) { V old = entry.value; entry.value = value; return old; } } addEntry(hash, key, value, index); return null; }
可以看到它是直接使用 synchronized 将整个方法锁住,这样在多线程下效率是非常低的,因为某些操作并不会触及到线程安全,比如第三行的 value==null 的判断。在其他线程执行这个方法时,当前线程只能干等着,而 ConcurrentHashMap 的 put 方法在一开始一直没有加锁,在判断到数组下标为空时还是只用 CAS 去尝试处理,直到确定需要遍历时才对第一个数进行加锁,所以 ConcurrentHashMap 的并发量远大于 HashTable ,这也是为什么 HashTable 被淘汰的原因。