Java HashMap和 ConcurrentHashMap 热门面试题

目录

  更多关于HashMap的知识点,请戳《HashMap知识点梳理、常见面试题和源码分析》。

  HashMap的结构无疑是Java面试中出现频率最高的一道题,此题是如此之常见,每个人都应该信手拈来,然而,能完整回答HashMap问题的人却是寥寥无几。

对于一位中高级java程序员而言,若对集合类的内部原理不了解,基本上面试都会被pass掉。故,下面从面试官的角度梳理了一份精选面试题,来聊聊一位候选者应该对HashMap了解到什么程度才算是合格。

  楼兰胡杨希望大家不但研究过JDK中HashMap的源代码,而且熟悉不同版本JDK中使用的优化机制。当然了,如果具有手动实现HashMap的能力就更优秀了。

在日常开发中使用过的java集合类有哪些

  一般应聘者都会回答ArrayList,LinkedList,HashMap,HashSet等等。如果连这几个集合类都不知道,基本上可以pass了。

谈一下HashMap的特性

  • HashMap初始化时使用懒加载机制,只初始化变量,未初始化数组,数组在首次添加元素时初始化。
  • 存储键值对,实现快速存取。key值不可重复,若key值重复则覆盖。
  • 键和值位置都可以是null,但是键位置只能存在唯一一个null。
  • 非同步,线程不安全。
  • 底层是hash表,不保证有序(比如插入的顺序)。
  • 链表长度不小于8并且数组长度大于64,才将链表转换成红黑树,变成红黑树的目的是提高搜索速度,高效查询

HashMap 的数据结构是什么

  自Java 8 开始,哈希表结构(链表散列)由数组+单链表+红黑树实现,结合Node数组和单链表的优点。当链表长度不小于 8且数组长度不小于64时,链表转换为红黑树;否则,扩容。数组是HashMap的主题,链表和红黑树主要是为了解决哈希冲突(拉链法解决冲突)。

单链表和红黑树相互转换的条件是什么

  当单链表长度不小于8,并且桶的个数不小于64时,将单链表转化为红黑树,以减少搜索时间。

  同样,后续如果由于删除或者其它原因调整了大小,当红黑树的节点数不大于 6时,又会转换为链表。

  hashCode 均匀分布时,TreeNode 用到的机会很小。理想情况下bin 中节点的分布遵循泊松分布,一个 bin 中链表长度达到 8 的概率(0.00000006)不足千万分之一,因此将转换的阈值设为 8。

  通常如果 hash 算法正常的话,链表的长度也不会很长,那么红黑树也不会带来明显的查询时间上的优势,反而会增加空间负担(TreeNode的大小大约是常规节点Node的两倍)。所以通常情况下,并没有必要转为红黑树。

链表和红黑树相互转换的阈值为什么是 8 和 6

  如果选择6和8,中间有个差值7可以有效防止链表和红黑树频繁转换。如果一个 HashMap 不停地进行插入和删除元素,链表的个数一直在 8 左右徘徊,这种情况会频繁地进行红黑树和链表的相互转换,效率很低。

  hashCode 均匀分布时,TreeNode 用到的机会很小。理想情况下bin 中节点的分布遵循泊松分布,一个 bin 中链表长度达到 8 的概率(0.00000006)不足千万分之一,因此将转换的阈值设为 8。

为什么要在数组长度不小于64之后,链表才会进化为红黑树

  如果在数组比较小时出现红黑树结构,反而会降低效率,而红黑树需要通过左旋、右旋和变色操作来保持平衡。同时数组长度小于64时,搜索时间相对要快些,总之是为了加快搜索速度,提高性能。

  Java 8 以前HashMap的实现是数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当HashMap中有大量的元素都存放在同一个桶中时,这个桶下有一条长长的链表,此时HashMap就相当于单链表,假如单链表有n个元素,遍历的时间复杂度就从O(1)退化成O(n),完全失去了它的优势。为了解决此种情况,Java 8中引入了红黑树(查找的时间复杂度为O(logn))。

HashMap 的容量如何确定

  容量就是HashMap中的数组大小,是由 capacity 这个参数确定的。默认是16,也可以构造时传入,最大限制是1<<30;

  HashMap采用懒加载机制,也就是说在执行new HashMap()的时候,构造方法并没有在构造HashMap实例的同时也初始化实例里的数组。那么什么时候才去初始化数组呢?答案是只有在第一次需要用到这个数组的时候才会去初始化它,就是在你往HashMap里面put元素的时候

loadFactor 是什么

  loadFactor 是装载因子,主要目的是用来确认table 数组是否需要动态扩展,默认值是0.75,比如table 数组大小为 16,装载因子为 0.75 时,threshold 就是12,当 table 的实际大小超过 12 时,table就需要动态扩容。

  空间换时间:如果希望加快Key查找的时间,还可以进一步降低加载因子,加大初始容量大小,以降低哈希冲突的概率。

  性能分析:空桶太多会浪费空间,如果使用的太满则会严重影响操作的性能。

HashMap使用了哪些方法来有效解决哈希冲突

  1、使用链地址法(使用散列表)来链接拥有相同hash值的数据。

  2、使用2次扰动函数(hash函数)降低哈希冲突的概率,使得数据分布更均匀。

  3、引入红黑树进一步降低遍历的时间复杂度。

为什么数组长度要保证为2的幂次方

  只有当数组长度为2的幂次方时,h&(length-1)才等价于h%length,即实现了key的定位,2的幂次方也可以减少哈希冲突次数,提高HashMap的查询效率。

  如果 length 为 2 的次幂 则 length-1 转化为二进制必定是 11111……的形式,在于 h 的二进制与操作效率会非常的快,而且空间不浪费;如果 length 不是 2 的次幂,比如 length 为 15,则 length - 1 为 14,对应的二进制为 1110,在于 h 与操作,最后一位都为 0 ,而 0001,0011,0101,1001,1011,0111,1101 这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费。

为什么是两次扰动

  加大哈希值低位的随机性,使得分布更均匀,从而提高数组下标位置的随机性和均匀性,最终减少哈希冲突。两次就够了,已经达到了高位和低位同时参与运算的目的。

数组扩容机制是什么

  扩容方法是resize()方法。

  • 默认是空数组,初始化的容量是16,即桶的个数默认为16;
  • 总元素个数超过容量✖️加载因子时,进行数组扩容;
  • 创建一个新的数组,其大小为旧数组大小的两倍,并重新计算旧数组中结点的存储位置。
  • 结点在新数组中的位置只有两种,原下标位置或原下标+旧数组的大小。

  扩容带来的危害:在数据量很大的情况下扩容将会带来性能的损失,在性能要求很高的地方,这种损失可能很致命。扩容太频繁就会导致内存抖动问题,增加瞬间的内存消耗和性能消耗,因此在创建HashMap的时候,如果能预知初始数据量的大小,在构造的时候可以设置初始容量,也可以设置扩容因子。

为什么要重新Hash,直接复制过去不好吗

  哈希函数中,包括对数组长度取模,故长度扩大以后,哈希函数也随之改变。

谈一下hashMap中put是如何实现的

  1.基于key的hashcode值计算哈希值(与Key.hashCode的高16位做异或运算)
  2.如果散列表为空时,调用resize()初始化散列表
  3.如果没有发生哈希碰撞(hash值相同),直接添加元素到散列表中去
  4.如果发生了哈希碰撞,进行三种判断
    4.1:若key地址相同或者equals后内容相同,则替换旧值
    4.2:如果是红黑树结构,就调用树的插入方法
    4.3:链表结构,循环遍历直到链表中某个节点为空,尾插法进行插入,插入之后判断链表是否树化:当链表长度不小于 8且数组长度不小于64时,链表转换为红黑树
  5.如果桶满了,即元素个数大于阈值,则resize进行扩容

  hashCode 是定位的,用于确认数组下标;equals是定性的,比较两者是否相等。

hashMap中get函数是如何实现的

  对key的hashCode进行哈希运算,然后借助与运算计算下标获取bucket位置,如果在桶的首位上就可以找到就直接返回;否则,在树或者链表中遍历找。如果有hash冲突,则利用equals方法遍历查找节点。

Java 7 与 Java 8 中,HashMap的区别

  1.数据结构不同 Java 7采用Entry数组+单链表的数据结构,而java 8 采用Node数组+单链表+红黑树,把时间复杂度从O(n)变成O(logN),提高了效率。这是Java 7与Java 8中HashMap实现的最大区别。

  1.hash冲突解决方案不同 发生hash冲突时,在Java 7中采用头插法,新元素插入到链表头中,即新元素总是添加到数组中,旧元素移动到链表中。 Java 8会优先判断该节点的数据结构式是红黑树还是链表,如果是红黑树,则在红黑树中插入数据;如果是链表,则采用尾插法,将数据插入到链表的尾部,然后判断是否需要转成红黑树。

  链表插入元素时,Java 7用的是头插法,而Java 8及之后使用的都是尾插法,那么他们为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。
头插法优点: resize后transfer数据时不需要遍历链表到尾部再插入;最近put的可能等下就被get,头插遍历到链表头就匹配到了。

  2.扩容机制不同 Java 7:在扩容resize()过程中,采用单链表的头插入方式,在线程下将旧数组上的数据 转移到 新数组上时,容易出现死循环。此时若(多线程)并发执行 put()操作,一旦出现扩容情况,则容易出现环形链表,从而在获取数据、遍历链表时形成死循环(Infinite Loop),即死锁的状态。

  Java 8:由于 Java 8 转移数据操作 = 按旧链表的正序遍历链表、在新链表的尾部依次插入,所以不会出现链表 逆序、倒置的情况,故不容易出现环形链表的情况。

  3. 扩容后存放位置不同 java 7 受rehash影响,java 8 调整后是原位置 or 原位置+旧容量

  使用HashMap时,楼兰胡杨的一些经验之谈:

  1. 使用时设置初始值,避免多次扩容的性能消耗。
  2. 使用自定义对象作为key时,需要重写hashCode和equals方法。
    3.多线程下,使用CurrentHashMap代替HashMap。

HashMap的key一般使用什么数据类型

  String、Integer等包装类的特性可以保证哈希值的不可更改性和计算准确性,可以有效地减少哈希碰撞的概率。

  都是final类型,即不可变类,作为不可变类天生是线程安全的。这些包装类已重写了equals()和hashCode()等方法,保证key的不可更改性,不会存在多次获取哈希值时哈希值却不相同的情况。

如果让自己的创建的类作为HashMap的key,应该怎么实现

  重写hashCode()和equals()方法。

  重写hashCode()是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的Hash碰撞。重写equals()方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性。

HashMap为什么由Java 7 的头插法改为Java 8的尾插法

  当HashMap要在链表里插入新的元素时,在Java 8之前是将元素插入到链表头部,自Java 8开始插入到链表尾部(Java 8用Node对象替代了Entry对象)。Java 7 插入链表头部,是考虑到新插入的数据,更可能作为热点数据被使用,放在头部可以减少查找时间。Java 8改为插入链表尾部是为了防止环化。因为resize的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

HashMap与Hashtable的区别是什么

  HashMap与Hashtable的区别见如下表格:

不同点 HashMap Hashtable
数据结构 数组 + 单链表 + 红黑树 数组+链表
继承的类 AbstractMap Dictonary,但二者都实现了map接口
线程安全
性能搞定 低,因为需要保证线程安全
默认初始化容量 16
扩容方式 数组大小×2 数组大小×2+1
底层数组容量 一定为2的整数幂次 不要求
hash值算法 (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16) key.hashCode()
数组下标计算方法 (n-1) & hash hash&0x7FFFFFFF%length
key-value是否允许null
是否提供contains方法 有containsvalue和containsKey方法 有contains方法方法
遍历方式 Iterator Iterator 和 Enumeration

  看完之后,是不是可以和面试官PK三分钟了?更多内容请戳《面试题:HashMap和Hashtable的区别和联系》。

HashMap为什么线程不安全

  多线程下扩容死循环。JDK1.7中的HashMap使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。

  多线程的put可能导致元素的丢失。多线程同时执行put操作,如果两个不同key计算出来的索引位置是相同的,那会造成前一个key被后一个key覆盖,从而导致元素丢失。此问题在JDK1.7和JDK1.8中都存在。

  put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题,此问题在JDK1.7和JDK1.8中都存在。

jdk8中对HashMap做了哪些改变

  • 在java 8中,引入了红黑树,链表可以转换为红黑树。
  • 发生hash碰撞时,java 7 会在链表的头部插入,而java 8会在链表的尾部插入。
  • 在java 8中,Entry被Node替代(换了一个马甲。

拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树

  之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。推荐:面试问红黑树,我脸都绿了。而红黑树在插入新数据后可能需要通过左旋、右旋和变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题。我们知道红黑树属于平衡二叉树,为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源比遍历线性链表少很多,所以当链表长度不小于8且数组长度大于64的时候,会使用红黑树,如果链表长度很短的话,根本引入红黑树反而会降低效率。

HashMap为什么引入红黑树

  Java8以前 HashMap 的实现是 数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素发生哈希碰撞时,这些元素都被存放到同一个桶中,从而形成一条长长的链表,此时 HashMap 就相当于一个单链表,遍历的时间复杂度会退化到O(n),完全失去了它的优势。针对这种情况,JAVA 8 中引入了红黑树来优化这个问题,红黑树的好处就是它的自平衡性,n个节点的树的查找时间复杂度只有 O(log n)。

如果两个键的哈希值相同,如何获取值对象

  哈希值相同,通过equals比较内容获取值对象。

说说你对红黑树的见解

  • 每个节点非红即黑。
  • 根节点总是黑色的。
  • 如果节点是红色的,则它的子节点必须是黑色的(反之不一定);
  • 每个叶子节点都是黑色的空节点(NIL节点);
  • 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)。

为什么是16?为什么必须是2的幂?如果输入值不是2的幂比如10会怎么样

  为什么槽位数必须使用2^n

①为了使得数据均匀分布,减少哈希碰撞。因为确定数组位置使用的位运算,若数据不是2的次幂则会增加哈希碰撞的次数和浪费数组空间。(PS:其实若不考虑效率,求余也可以就不用位运算了也不用长度必需为2的幂次)。

  输入数据若不是2的幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字。

②等价于length取模

  当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。位运算的运算效率高于算术运算,原因是算术运算还是会被转化为位运算。最终目的还是为了让哈希后的结果更均匀的分部,减少哈希碰撞,提升hashmap的运行效率。

为什么String, Interger这样的wrapper类适合作为键

  String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其它的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其它的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。

如果不重写作为可以的Bean的hashCode()方法,是否会对性能带来影响

  这个问题非常好,仁者见仁智者见智。按照我掌握的知识来说,如果一个哈希方法写得不好,直接的影响是,在向HashMap中添加元素的时候会更频繁地造成哈希冲突,因此最终增加了耗时。但是自从Java 8开始,这种影响不再像前几个版本那样显著了,因为当哈希冲突的发生超出了一定的限度之后,链表将会被替换成红黑树,这时你仍可以得到O(logN)的开销,优于链表类的O(n)。

谈一下当两个对象的哈希值相等时会怎么样

  会产生哈希碰撞,若key值相同则替换旧值,不然采用尾插法链接到链表后面,链表长度超过阈值8且桶的个数大于64就转为红黑树存储。

请解释一下HashMap的参数loadFactor,它的作用是什么

  loadFactor是负载因子,表示HashMap的拥挤程度,影响hash操作到同一个数组位置的概率。默认loadFactor等于0.75,当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容,在HashMap的构造器中可以定制loadFactor。

Java 8 HashMap 扩容之后,旧元素存放位置是什么

  HashMap 在扩容的时候会创建一个新的 Node<K,V>[] 对象,用于存放扩容之后的键值对,并将旧的Node数组(其大小记作n)置空;至于旧值移动到新的节点时存放于哪个节点,是根据 (e.hash & oldCap) == 0 来判断的:
① 等于0时,则其索引位置h不变;
② 不等于0时,则其在新数组的索引位置=原索引位置+旧数组长度n。

你了解重新调整HashMap大小存在什么问题吗

  当hashMap因为扩容而调整hashMap的大小时,会导致之前计算出来的索引下标无效,所以所有的节点都需要重新进行哈希运算,结果就是带来时间上的浪费。故建议尽量避免hashMap调整大小,所以我们使用hashMap的时候要给它设置一个初始容量,此值要大于hashMap中存放的节点个数。

你知道哈希函数的实现吗?为什么要这样实现?还有哪些hash函数的实现方式

  Java 8 中使用了异或运算,通过 key的hashCode() 的高 16 位异或低 16 位实现:(h = k.hashCode()) ^ (h >>> 16),此实现方案主要是从速度、功效和质量三个角度来考量的,用于减少系统的开销,也可以避免因为高位没有参与下标的计算而造成哈希碰撞。

  其它实现方式还有平方取中法,除留余数法,伪随机数法等。

  如果面试者的技术面比较宽,或者算法基础以及数论基础比较好,这个问题才可以做很好的回答。首先,hashCode()不要求唯一但是要尽可能的均匀分布,而且算法效率要尽可能的快

  如果都结束了,不要忘了再问一句你知道hash攻击吗?有避免手段吗?就看面试者对各个jdk版本对HashMap的优化是否了解了。这就引出了另一个数据结构红黑树了。可以根据岗位需要继续考察rb-tree,b-tree,lsm-tree等常用数据结构以及典型应用场景。

哈希函数为什么要用异或运算符

  保证了对象的 hashCode 的 32 位值只要有一位发生改变,返回的 hash值就会改变。尽可能的减少哈希碰撞。

能否让HashMap实现线程安全

  HashMap可以通过下面的语句进行同步:Collections.synchronizeMap(hashMap)。 synchronizedMap()方法返回一个SynchronizedMap类的对象,而在SynchronizedMap类中使用了synchronized来保证对Map的操作是线程安全的,故效率其实也不高。

如果多个线程操作同一个HashMap对象会产生哪些非正常现象?

  HashMap在多线程环境下操作可能会导致程序死循环。

  其实这已经开始考察候选人对并发知识的掌握情况了。HashMap在resize的时候,如果多个线程并发操作如何导致死锁的。面试者不一定知道,但是可以让面试者分析。毕竟很多类库在并发场景中不恰当使用HashMap导致过生产问题。

Java 中的另一个线程安全的、与 HashMap 极其类似的类是什么?同样是线程安全,它与 Hashtable 在线程同步上有什么不同

  ConcurrentHashMap 类(是 Java并发包 java.util.concurrent 中提供的一个线程安全且高效的 HashMap 实现)。Hashtable 是使用 synchronized 关键字加锁的原理(就是对对象加锁);而针对ConcurrentHashMap,在 JDK 7 中采用分段锁的方式,而JDK 8 中直接采用了CAS(无锁算法)+ synchronized。

Get方法的流程是怎样的

  先调用Key的hashcode方法拿到对象的hash值,然后用hash值对第一维数组的长度进行取模,得到数组的下标。这个数组下标所在的元素就是第二维链表的表头。然后遍历这个链表,使用Key的equals同链表元素进行比较,匹配成功即返回链表元素里存放的值。

Get方法的时间复杂度是多少

  答:是O(1)。很多人在回答这道题时脑细胞会出现短路现象,开始怀疑人生。明明是O(1)啊,平时都记得牢牢的,又在质疑由于Get方法的流程里需要遍历链表,难道遍历的时间复杂度不是O(n)么?

假如HashMap里的元素有100w个,请问链表的长度大概是多少

  链表的长度很短,相比总元素的个数可以忽略不计。这个时候小伙伴们的眼睛通常会开始发光,很童贞。作为面试官是很喜欢看到这种眼神的。我使用反射统计过HashMap里面链表的长度,在HashMap里放了100w个随机字符串键值对,发现链表的长度几乎从来没有超过7这个数字,当我增大loadFactor的时候,才会偶尔冒出几个长度为8的链表来。

请说明一下HashMap扩容的过程

  扩容需要重新分配一个新数组,新数组是老数组的2倍长,然后遍历整个老结构,把所有的元素挨个重新hash分配到新结构中去。

  这个rehash的过程是很耗时的,特别是HashMap很大的时候,会导致程序卡顿,而2倍内存的关系还会导致内存瞬间溢出,实际上是3倍内存,因为老结构的内存在rehash结束之前还不能立即回收。那为什么不能在HashMap比较大的时候扩容扩少一点呢,关于这个问题我也没有非常满意的答案,我只知道hash的取模操作使用的是按位操作,按位操作需要限制数组的长度必须是2的指数。另外就是Java堆内存底层用的是TcMalloc这类library,它们在内存管理的分配单位就是以2的指数的单位,2倍内存的递增有助于减少内存碎片,减少内存管理的负担。

你了解Redis么,你知道Redis里面的字典是如何扩容的吗

好,如果这道题你也回答正确了,恭喜你,毫无无疑,你是一位很有钱途的高级程序员。

HashMap & ConcurrentHashMap 的区别

  除了加锁,原理上无太大区别。另外,HashMap 的键值对允许有null,但是ConCurrentHashMap 都不允许。

HashMap、LinkedHashMap和TreeMap 有什么区别

  LinkedHashMap 保存了记录的插入顺序,在用 Iterator 遍历时,先取到的记录肯定是先插入的;遍历比 HashMap 慢;TreeMap 实现 SortMap 接口,能够把它保存的记录根据键排序(默认按键值升序排序,也可以指定排序的比较器)。

为什么 ConcurrentHashMap 比 Hashtable 效率高

  Hashtable 使用一把锁(锁住整个链表结构)处理并发问题,多个线程竞争一把锁容易导致阻塞;而ConcurrentHashMap降低了锁粒度。ConcurrentHashMap在JDK 7 中使用分段锁(ReentrantLock + Segment + HashEntry),相当于把一个 HashMap 分成多个段,每段分配一把锁,这样支持多线程访问。锁粒度:基于 Segment,包含多个 HashEntry。在JDK 8 中,使用 CAS + synchronized + Node + 红黑树,锁粒度为Node(首结点)(实现 Map.Entry)。

ConcurrentHashMap 在 Java 8 中,为什么要使用内置锁 synchronized 来代替重入锁 ReentrantLock

  ①、粒度降低了。

  每扩容一次,ConcurrentHashMap的并发度就增加一倍。

  ②、获得JVM的支持。

  在大量的数据操作下,对于 JVM 的内存压力,基于 API 的 可重入锁 ReentrantLock 会开销更多的内存,而且后续的性能优化空间更小。而且JVM 开发团队没有放弃 synchronized,JVM能够在运行时做出更大的优化空间更大:锁粗化、锁消除、锁自旋等等,这就使得synchronized能够随着JDK版本的升级而重构代码的前提下获得性能上的提升。

  ③、减少内存开销

  假如使用可重入锁获得同步支持,那么每个节点都需要通过继承AQS来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头结点(红黑树的根节点)需要同步,这无疑造成了巨大的内存开销。

ConcurrentHashMap 的并发度是什么

  程序运行时能够同时更新 ConccurentHashMap 且不产生锁竞争的最大线程数。默认为 16,且可以在构造函数中设置。当用户设置并发度时,ConcurrentHashMap 会使用大于等于该值的最小2次幂指数作为实际并发度(假如用户设置并发度为17,实际并发度则为32)。

ConcurrentHashMap 加锁机制

  它加锁的场景分为两种:

  1、没有发生hash冲突的时候,添加元素的位置在数组中是空的,使用CAS的方式来加入元素,这里加锁的粒度是数组中的元素。

  2、如果出现了hash冲突,添加的元素的位置在数组中已经有了值,那么又存在三种情况。
    (1)key相同,则用新的元素覆盖旧的元素。
    (2)如果数组中的元素是链表的形式,那么将新的元素挂载在链表尾部。
    (3)如果数组中的元素是红黑树的形式,那么将新的元素加入到红黑树。

  第二种场景使用的是synchronized加锁,锁住的对象就是链表头节点(红黑树的根节点),加锁的粒度和第一种情况相同。

  结论:ConcurrentHashMap分段加锁机制其实锁住的就是数组中的元素,当操作数组中不同的元素时,是不会产生竞争的。

ConcurrentHashMap 存储对象的过程

1> 如果没有初始化,就调用 initTable() 方法来进行初始化;
2> 如果没有哈希冲突就直接 CAS 无锁插入;
3> 如果需要扩容,就先进行扩容;
4> 如果存在哈希冲突,就加锁来保证线程安全,两种情况:一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入;
5> 如果该链表长度大于阀值 8(且数组中元素数量大于64),就要先转换成红黑树的结构。

ConcurrentHashMap get操作需要加锁吗?线程安全吗

  get 方法不需要加锁。因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改节点的 value 或者新增节点的时候是对线程B可见的。这也是它比其它并发集合比如 Hashtable、用 Collections.synchronizedMap()包装的 HashMap 效率高的原因之一。

如何保证 HashMap 总是使⽤ 2 的幂次作为桶的⼤⼩

  /**
     \*  Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

HashSet 与 HashMap 的区别

  HashSet与HashMap的扩容机制一样,区别见如下表格:

不同点 HashMap HashSet
实现的接口 Map接口 Set接口
存储方式 存储键值对 存储对象
存储方法 使用put函数 使用add函数
性能高低 快,因为使用唯一的键来获取对象
默认初始化容量 16
哈希值 使用键对象计算哈希值 使用成员对象计算哈希值

结束语

  眼界不凡,前途无量。攻城狮们,加油吧!楼兰胡杨祝君平步青云。

Reference

posted @ 2022-04-16 19:56  楼兰胡杨  阅读(793)  评论(0编辑  收藏  举报