HashMap 和 Hashtable 的区别
线程是否安全: HashMap 是⾮线程安全的, Hashtable 是线程安全的,因为 Hashtable 内部的 ⽅法基本都经过 synchronized 修饰。(如果你要保证线程安全的话就使⽤ ConcurrentHashMap 吧!)
效率: 因为线程安全的问题, HashMap 要⽐ Hashtable 效率⾼⼀点。另外, Hashtable 基本 被淘汰,不要在代码中使⽤它;
对 Null key 和 Null value 的⽀持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只 能有⼀个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException 。
初始容量⼤⼩和每次扩充容量⼤⼩的不同 : ① 创建时如果不指定容量初始值, Hashtable 默认 的初始⼤⼩为 11,之后每次扩充,容量变为原来的 2n+1。 HashMap 默认的初始化⼤⼩为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使⽤你给定的⼤⼩,⽽ HashMap 会将其扩充为 2 的幂次⽅⼤⼩( HashMap 中的 tableSizeFor() ⽅法保证,下⾯给出了源代码)。也就是说 HashMap 总是使⽤ 2 的幂作为哈希 表的⼤⼩,后⾯会介绍到为什么是 2 的幂次⽅。
底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了⼤的变化,当链表⻓度⼤ 于阈值(默认为 8)时,将链表转化为红⿊树(将链表转换成红⿊树前会判断,如果当前数组的 ⻓度⼩于 64,那么会选择先进⾏数组扩容,⽽不是转换为红⿊树),以减少搜索时间(后⽂中 我会结合源码对这⼀过程进⾏分析)。 Hashtable 没有这样的机制。
HashMap 的底层实现
JDK1.8 前
JDK1.8 之前 HashMap 底层是 数组和链表 结合在⼀起使⽤也就是链表散列。
HashMap 通过 key 的 hashcode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位 置(这⾥的 n 指的是数组的⻓度),如果当前位置存在元素的话,就判断该元素与要存⼊的元素的hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
所谓扰动函数指的就是 HashMap 的 hash ⽅法。使⽤ hash ⽅法也就是扰动函数是为了防⽌⼀些 实现比较差的 hashCode() ⽅法 换句话说使⽤扰动函数之后可以减少碰撞。
JDK1.7 的 HashMap 的 hash ⽅法源码.
static int hash(int h) { // 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); }
JDK 1.8 的 hash ⽅法 相⽐于 JDK 1.7 hash ⽅法更加简化,但是原理不变。
static final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^ :按位异或 // >>>:⽆符号右移,忽略符号位,空位都以0补⻬ return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
相⽐于 JDK1.8 的 hash ⽅法 ,JDK 1.7 的 hash ⽅法的性能会稍差⼀点点,因为毕竟扰动了 4 次。
所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建⼀个链表数组,数组中每⼀格就是⼀个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
JDK1.8 之后
相⽐于之前的版本, JDK1.8 之后在解决哈希冲突时有了较⼤的变化,当链表⻓度⼤于阈值(默认为 8)(将链表转换成红⿊树前会判断,如果当前数组的⻓度⼩于 64,那么会选择先进⾏数组扩容,⽽ 不是转换为红⿊树)时,将链表转化为红⿊树,以减少搜索时间。
TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都⽤到了红⿊树。红⿊树就是为了解决 ⼆叉查找树的缺陷,因为⼆叉查找树在某些情况下会退化成⼀个线性结构。
结合源码分析⼀下 HashMap 链表到红⿊树的转换
- putVal ⽅法中执⾏链表转红⿊树的判断逻辑。
链表的⻓度⼤于 8 的时候,就执⾏ treeifyBin (转换红⿊树)的逻辑。
// 遍历链表 for (int binCount = 0; ; ++binCount) { // 遍历到链表最后⼀个节点 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 如果链表元素个数⼤于等于TREEIFY_THRESHOLD(8) 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; }
- 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) // 如果当前数组的⻓度⼩于 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,那么会选择先进⾏数组扩容,⽽不是 转换为红⿊树。
HashMap 的长度为什么是 2 的幂次方
为了能让 HashMap 存取⾼效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上⾯也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,前后加起来⼤概 40 亿的映射空间,只要哈希 函数映射得⽐较均匀松散,⼀般应⽤是很难出现碰撞的。
但问题是⼀个 40 亿⻓度的数组,内存是放不下的。所以这个散列值是不能直接拿来⽤的。⽤之前还要先做对数组的⻓度取模运算,得到的余数 才能⽤来要存放的位置也就是对应的数组下标。这个数组下标的计算⽅法是“ (n - 1) & hash ”。(n 代表数组⻓度)。这也就解释了 HashMap 的⻓度为什么是 2 的幂次⽅。
这个算法应该如何设计呢?
我们⾸先可能会想到采⽤%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是 2 的 幂次则等价于与其除数减⼀的与(&)操作(也就是说 hash%length==hash&(length-1) 的前提是 length 是 2 的 n 次⽅;)。” 并且采⽤⼆进制位操作 &,相对于%能够提⾼运算效率,这就解释了 HashMap 的⻓度为什么是 2 的幂次⽅。
HashMap 常见的遍历方式
ConcurrentHashMap 和 Hashtable 的区别
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的⽅式上不同。
底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采⽤ 分段的数组+链表 实现,JDK1.8 采⽤ 的数据结构跟 HashMap1.8 的结构⼀样,数组+链表/红⿊⼆叉树。 Hashtable 和 JDK1.8 之前 的 HashMap 的底层数据结构类似都是采⽤ 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突⽽存在的;
实现线程安全的⽅式(重要)
在 JDK1.7 的时候, ConcurrentHashMap 对整个桶数组进⾏了分割分段( Segment ,分段锁),每⼀把锁只锁容器其中⼀部分数据(下⾯有示意图),多线程访问容器⾥不同数据段 的数据,就不会存在锁竞争,提⾼并发访问率。
到了 JDK1.8 的时候, ConcurrentHashMap 已经摒弃了 Segment 的概念,⽽是直接⽤ Node 数组+链表+红⿊树的数据结构来实现,并发控制使⽤ synchronized 和 CAS 来操 作。(JDK1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全 的 HashMap ,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性, 只是为了兼容旧版本;
Hashtable (同⼀把锁) :使⽤ synchronized 来保证线程安全,效率⾮常低下。当⼀个线程访 问同步⽅法时,其他线程也访问同步⽅法,可能会进⼊阻塞或轮询状态,如使⽤ put 添加元 素,另⼀个线程不能使⽤ put 添加元素,也不能使⽤ get,竞争会越来越激烈效率越低。
ConcurrentHashMap 线程安全的具体实现⽅式/底层具体实现
JDK1.8前
⾸先将数据分为⼀段⼀段(这个“段”就是 Segment )的存储,然后给每⼀段数据配⼀把锁,当⼀个 线程占⽤锁访问其中⼀个段数据时,其他段的数据也能被其他线程访问。
ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。
Segment 继承了 ReentrantLock ,所以 Segment 是⼀种可重⼊锁,扮演锁的⻆⾊。 HashEntry ⽤于 存储键值对数据。
⼀个 ConcurrentHashMap ⾥包含⼀个 Segment 数组, Segment 的个数⼀旦初始化就不能改变。 Segment 数组的⼤⼩默认是 16,也就是说默认可以同时⽀持 16 个线程并发写。
Segment 的结构和 HashMap 类似,是⼀种数组和链表结构,⼀个 Segment 包含⼀个 HashEntry 数组,每个 HashEntry 是⼀个链表结构的元素,每个 Segment 守护着⼀个 HashEntry 数组⾥的元 素,当对 HashEntry 数组的数据进⾏修改时,必须⾸先获得对应的 Segment 的锁。也就是说,对 同⼀ Segment 的并发写⼊会被阻塞,不同 Segment 的写⼊是可以并发执⾏的。
JDK1.8后
ConcurrentHashMap 取消了 Segment 分段锁,采⽤ Node + CAS + synchronized 来保证并发安全。 数据结构跟 HashMap 1.8 的结构类似,数组+链表/红⿊⼆叉树。Java 8 在链表⻓度超过⼀定阈值 (8)时将链表(寻址时间复杂度为 O(N))转换为红⿊树(寻址时间复杂度为 O(log(N)))。
Java 8 中,锁粒度更细, synchronized 只锁定当前链表或红⿊⼆叉树的⾸节点,这样只要 hash 不 冲突,就不会产⽣并发,就不会影响其他 Node 的读写,效率⼤幅提升。
1.7 和 1.8 实现的不同总结
线程安全实现⽅式 :JDK 1.7 采⽤ Segment 分段锁来保证安全, Segment 是继承⾃ ReentrantLock 。JDK1.8 放弃了 Segment 分段锁的设计,采⽤ Node + CAS + synchronized 保 证线程安全,锁粒度更细, synchronized 只锁定当前链表或红⿊⼆叉树的⾸节点。
Hash 碰撞解决⽅法 : JDK 1.7 采⽤拉链法,JDK1.8 采⽤拉链法结合红⿊树(链表⻓度超过⼀定 阈值时,将链表转换为红⿊树)。
并发度 :JDK 1.7 最⼤并发度是 Segment 的个数,默认是 16。JDK 1.8 最⼤并发度是 Node 数 组的⼤⼩,并发度更⼤。