集合篇(一)--- HashMap和ConcurrentHashMap

问:HashMap 底层数据结构

JDK1.8 之前 HashMap 底层是数组和链表结合在一起使用也就是链表散列HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

扰动函数指的就是 HashMap 的hash方法。使用hash方法也就是扰动函数是为了防止一些实现比较差的hashCode()方法换句话说使用扰动函数之后可以减少碰撞。

JDK1.8 之后当链表长度大于阈值(默认为 8)时,会首先调用treeifyBin()方法。这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容。


  1. /**
  2.  * tab:元素数组,
  3.  * hash:hash值(要增加的键值对的key的hash值)
  4.  */
  5. final void treeifyBin(Node<K,V>[] tab, int hash) {
  6.  
  7.     int n, index; Node<K,V> e;
  8.     /*
  9.      * 如果元素数组为空 或者 数组长度小于 树结构化的最小限制
  10.      * MIN_TREEIFY_CAPACITY 默认值64,对于这个值可以理解为:如果元素数组长度小于这个值,没有必要去进行结构转换
  11.      * 当一个数组位置上集中了多个键值对,那是因为这些key的hash值和数组长度取模之后结果相同。(并不是因为这些key的hash值相同)
  12.      * 因为hash值相同的概率不高,所以可以通过扩容的方式,来使得最终这些key的hash值在和新的数组长度取模之后,拆分到多个数组位置上。
  13.      */
  14.     if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
  15.         resize(); // 扩容,可参见resize方法解析
  16.  
  17.     // 如果元素数组长度已经大于等于了 MIN_TREEIFY_CAPACITY,那么就有必要进行结构转换了
  18.     // 根据hash值和数组长度进行取模运算后,得到链表的首节点
  19.     else if ((e = tab[index = (n - 1) & hash]) != null) { 
  20.         TreeNode<K,V> hd = null, tl = null// 定义首、尾节点
  21.         do { 
  22.             TreeNode<K,V> p = replacementTreeNode(e, null); // 将该节点转换为 树节点
  23.             if (tl == null// 如果尾节点为空,说明还没有根节点
  24.                 hd = p; // 首节点(根节点)指向 当前节点
  25.             else { // 尾节点不为空,以下两行是一个双向链表结构
  26.                 p.prev = tl; // 当前树节点的 前一个节点指向 尾节点
  27.                 tl.next = p; // 尾节点的 后一个节点指向 当前节点
  28.             }
  29.             tl = p; // 把当前节点设为尾节点
  30.         } while ((e = e.next) != null); // 继续遍历链表
  31.  
  32.         // 到目前为止 也只是把Node对象转换成了TreeNode对象,把单向链表转换成了双向链表
  33.  
  34.         // 把转换后的双向链表,替换原来位置上的单向链表
  35.         if ((tab[index] = hd) != null)
  36.             hd.treeify(tab);
  37.     }
  38. }

​​​​​​​ 问:HashMap 的扩容机制

一般情况下,当元素数量超过阈值时便会触发扩容。每次扩容的容量都是之前容量的2倍。HashMap的容量是有上限的,必须小于1<<30,即1073741824。如果容量超出了这个数,则不再增长,且阈值会被设置为Integer.MAX_VALUE。

JDK7中的扩容机制

空参数的构造函数:以默认容量、默认负载因子、默认阈值初始化数组。内部数组是空数组。

有参构造函数:根据参数确定容量、负载因子、阈值等。

第一次put时会初始化数组,其容量变为不小于指定容量的2的幂数,然后根据负载因子确定阈值。

如果不是第一次扩容,则新容量=旧容量*2 ,新阈值=新容量*负载因子。 

JDK8的扩容机制

空参数的构造函数:实例化的HashMap默认内部数组是null,即没有实例化。第一次调用put方法时,则会开始第一次初始化扩容,长度为16。

有参构造函数:用于指定容量。会根据指定的正整数找到不小于指定容量的2的幂数,将这个数设置赋值给阈值(threshold)。第一次调用put方法时,会将阈值赋值给容量,然后让 阈值 = 容量 * 负载因子。

如果不是第一次扩容,则容量变为原来的2倍,阈值也变为原来的2倍。(容量和阈值都变为原来的2倍时,负载因子还是不变)。

细节注意:

首次put时先会触发扩容(算是初始化),然后存入数据,然后判断是否需要扩容;

不是首次put,则不再初始化,直接存入数据,然后判断是否需要扩容;

​​​​​​​ 问: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 的幂次方。

​​​​​​​ 问:如何使用 Object 作为 HashMap 的 Key

重写hashCode()和equals()方法

重写hashCode()是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的Hash碰撞。

“==”如果是基本类型的话就是看他们的数据值是否相等就可以。 如果是引用类型的话,比较的是栈内存局部变量表中指向堆内存中的指针的值是否相等。 “equals”如果对象的equals方法没有重写的话,equals方法和==是同一种。hashcod是返回对象实例内存地址的hash映射。 理论上所有对象的hash映射都是不相同的。

重写equals()方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性。

​​​​​​​ 问: HashMap 中 String、Integer包装类适合作为K

String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减少Hash碰撞的几率。 都是final类型,即不可变性,保证key的不可更改性,不会存在获取hash值不同的情况内部已重写了equals()、hashCode()等方法,遵守了HashMap内部的规范(自反、传递、可重复、a.equals(null)=false),不容易出现Hash值计算错误的情况; 

​​​​​​​问:ConcurrentHashMap 的存储结构

Java7 中 ConcurrnetHashMap 使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个 Segment 都是一个类似 HashMap 数组的结构,它可以扩容,它的冲突会转化为链表。但是 Segment 的个数一但初始化就不能改变,默认 Segment 的个数是 16 个。

Java8 中的 ConcurrnetHashMap 使用的 Synchronized 锁加 CAS 的机制。结构也由 Java7 中的 Segment 数组 + HashEntry 数组 + 链表 进化成了 Node 数组 + 链表 / 红黑树,Node 是类似于一个 HashEntry 的结构。它的冲突达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。

HashMap的键值对允许有null,但是ConCurrentHashMap都不允许

​​​​​​​问:ConcurrentHashMap和1.8的差异

1.8采用synchronized代替可重入锁ReentrantLock (现代 JDK 中,synchronized 已经被不断优化,可以不再过分担心性能差异)

1.8取消了Segment分段锁的数据结构,使用数组+链表+红黑树的结构代替

1.8对每个数组元素加锁,1.7对要操作的Segment数据段加锁 ​​​​​​​

 问:ConcurrentHashMap和HashTable区别

ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。HashTable就是实现了HashMap加上了synchronized,而ConcurrentHashMap底层采用分段的数组+链表实现,线程安全

实现线程安全的方式:

  • 在 JDK1.7 的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。整个看起来就像是优化过且线程安全的 HashMap

② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

​​​​​​​问: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 的幂次方大小

底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

​​​​​​​ 问:ConcurrentHashMap 线程安全的具体实现

 Jdk 1.7

首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。 ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。

Segment 实现了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。

static class Segment<K,V> extends ReentrantLock implements Serializable { }

一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。

JDK1.8

ConcurrentHashMap 取消了 Segment 分段锁,采用 CAS 和 synchronized 来保证并发安全。数据结构跟 HashMap1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))

synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。

​​​​​​​问:HashMap 和 ConcurrentHashMap 的区别

Jdk1.7中ConcurrentHashMap对整个桶数组进行了分割分段(Segment),然后在每一个分段上都用lock锁进行保护,相对于HashTable的synchronized锁的粒度更精细了一些,并发性能更好

Jdk1.8中ConcurrentHashMap取消了 Segment 分段锁,采用 CAS 和 synchronized 来保证并发安全

HashMap没有锁机制,不是线程安全的。 HashMap的键值对允许有null,但是ConCurrentHashMap都不允许

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值