集合面试题总结

集合

  • Collection接口:单列集合,用来存储一个一个的对象

    • List接口:存储有序的、可重复的数据,。 -->“动态”数组
      • 实现类:ArrayList、LinkedList、Vector
    • Set接口:存储无序的、不可重复的数据
      • 实现类:HashSet、LinkedHashSet、TreeSet
  • Map接口:双列集合,用来存储一对(key - value)一对的数据 -->函数:y = f(x)

    • 实现类:HashMap、LinkedHashMap、TreeMap、Hashtable、Properties
1.1 List实现类的异同

三者相同点:三个类都是实现了List接口,存储数据的特点相同:存储有序的、可重复的数据
不同点:
1)ArrayList是存储有序,可重复的元素,支持随机访问,线程不安全,执行效率比vector高,底层使用Object[] elementData存储

  • ArrayList list = new ArrayList();
    JDK7:调用无参构造函数时,创建了长度是10的Object[]数组elementData
    JDK8:调用无参构造函数时,底层Object[] elementData初始化为{}.在第一次add()的时候才创建长度为10的数组

  • 在add过程中如果此次的添加导致底层elementData数组容量不够,则扩容。 默认情况下,扩容为原来的容量的1.5倍,同时需要将原有数组中的数据复制到新数组中。

  • 这也是arrayList扩容的缺点:当插入大量数据时,需要扩容,copy数组造成效率变低,一般使用带参构造函数,传入一个预估容量,一次性将容量确定好

  • JDK8ArrayList的对象的创建类似于单例的懒汉式,延迟了数组的创建,节省内存。

2)LinkedList,双向链表

ArrayList与LinkedList的异同

1、ArrayList是实现了基于动态数组的数据库结构,LinkedList基于链表的数据结构。
arraylist的初始化时默认10容量,而linkedlist默认初始化为空。

2、对于随机访问get和set,ArrayList优于LinkedList,因为LinkedList要移动指针。

3、linkedlist的增删要优于arraylist,因为ArrayList要移动数据。

3)vector

  • jdk7和jdk8中通过Vector()构造器创建对象时,底层都创建了长度为10的数组,使用Object[]
    elementData进行数据存储,支持动态扩容,默认扩容为原来的数组长度的2倍,支持随机访问。
  • 线程安全的,很多实现方法都加了synchronized, 效率较低

2、set实现类

  • Collection接口:单列集合,用来存储一个一个的对象
    • Set接口:存储无序的、不可重复的数据 -->“集合”

      • HashSet:作为Set接口的主要实现类;线程不安全的;可以存储null值
        • LinkedHashSet:作为HashSet的子类;遍历其内部数据时,可以按照添加遍历

      对于频繁的遍历操作,LinkedHashSet效率高于HashSet

      • TreeSet:可以按照添加对象的指定属性,进行排序。
2.1 Set实现类的异同

相同点:存储无序的【不等于随机性。存储的数据在底层数组中并非按照数组索引的顺序添加,而是根据数据的哈希值决定的】,不可重复的数据【保证添加的元素按照equals()判断时,不能返回true.即:相同的元素只能添加一个】
1)HashSet

  • 底层:底层实际上是用HashMap存:数组+链表
    2)TreeSet
    TreeSet中添加的数据,要求是相同类的对象。
    两种排序方式:自然排序(实现Comparable接口) 和 定制排序(Comparator)
    自然排序,定制排序中,比较两个对象是否相同的标准为:compareTo()返回0.不再是equals()
- Set接口中没有额外定义新的方法,使用的都是Collection中声明过的方法。
- 向Set(主要指:HashSet、LinkedHashSet)中添加的数据,其所在的类一定要重写hashCode()和equals()
- 重写的hashCode()和equals()尽可能保持一致性:相等的对象必须具有相等的散列码
- 重写两个方法的小技巧:对象中用作 equals() 方法比较的 Field,都应该用来计算 hashCode 值。

3、Map实现类

  • Map:双列数据,存储key-value对的数据
  • HashMap:作为Map的主要实现类;线程不安全的,效率高;存储null的key和value
    • LinkedHashMap:保证在遍历map元素时,可以按照添加的顺序实现遍历
  • TreeMap:保证按照添加的key-value对进行排序,实现排序遍历。此时考虑key的自然排序或定制排序, 底层使用红黑树
  • Hashtable:作为古老的实现类;线程安全的,效率低;不能存储null的key和value
(1)HashTable

在这里插入图片描述

HashTable底层用数组+链表进行存储,大部分函数用synchonized修饰,保证线程安全,但多线程操作时执行效率低
HashTable默认数组大小为11,按照2*table.length+1,进行扩容
不可以存储为null的key、value都不可以null

2) HashMap

在这里插入图片描述

  • 可以存储null的key和value
  • HashMap是线程不安全的
  • 底层用数组+链表+红黑树的存储,也叫哈希桶
    【 jdk 1.8之前都是数组+链表的结构,在链表中的查询操作都是O(N)的时间复杂度。为了提高效率,1.8之后改为数组+链表+红黑树,当链表元素数目到8个,同时HashMap的数组长度要大于64,链表会转成红黑树,链表转换为红黑树结构,增删改查都是O(log n)。】

HashMap存储过程:

> HashMap map = new HashMap()
> 当调用HashMap的构造函数,只是对相关属性初始化,只有在第一次put元素时才对散列表进行初始化,容量初始为16
> 如put(key1,value1):
> 首先,使用hash()计算key1的哈希值,哈希值经过路由算法(table.length-1)&hash计算出元素在数组中的存储位置
> 情况一:如果此位置上的数据为空,则将key1-value1添加;
> 情况二:如果此位置上的数据不为空,则此位置上可能是链表也可能是红黑树,如果是红黑树,则以红黑树的方式插入,如果是链表,则遍历链表
> 	1.如果key1的哈希值与所有节点key的哈希值都不相同,则将key1-value1添加到链表尾部。
> 	2.如果key2的哈希值和某个节点的key哈希值相同,则调用key1所在类equals(key2)比较,如果为true,则进行替换,否则添加到链表尾部,当链表长度>8,则链表转红黑树
> 添加完后如果容量达到了临界值且要存放的位置非空[临界值=加载因子0.75*数组长度],则使用resize()进行扩容,并将原有的数据复制过来。【扩容长度必须是2^n,默认的扩容方式:扩容为原来容量的2倍】

jdk8、jdk7不同点:

===JDK7JDK8
new HashMap()JDK7创建长度为16的数组JDK8第一次put时创建数组
底层数组Entry[] tableNode[] table
底层实现数组+链表数组+链表+红黑树
插入元素形成链表时头插法尾插法

为什么红黑树的效率比链表高
树化以后查找和插入最差时间复杂度为 O(logN),链表挨个查找效率O(N)

为什么不一开始就用红黑树
红黑树需要进行左旋,右旋,变色来保持平衡,而单链表不需要。
当元素小于8个当时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于8个的时候,此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。因此,如果一开始就用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。

不用红黑树,用二叉查找树可以么?
可以。但是二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢

为什么需要扩容
为了解决hash冲突导致的链化影响查询的效率,扩容会缓解该问题

什么时候链表转红黑树
当数组的某一个索引位置上的元素以链表形式存在的数据个数 > 8 且数组长度>64时,此时此索引位置上的所数据改为使用红黑树存储。

初始容量是16,为什么是2的指数次幂?【使用tableSizeFor()保证容量是2^n】
方便使用位运算计算key的索引
HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个算法实际就是取模,index=hash%length。但是,大家都知道这种运算不如位移运算快。因此,源码中做了优化hash&(length-1)。也就是说hash%length==hash&(length-1)

那为什么是2的n次方呢?
因为2的n次方实际就是1后面n个0,2的n次方-1,实际就是n个1。
例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞。
而长度为5的时候,3&(5-1)=0 2&(5-1)=0,都在0上,出现碰撞了。
所以,保证容积是2的n次方,是为了保证在做(length-1)的时候,每一位都能&1 ,也就是和1111……1111111进行与运算。

加载因子为什么是 0.75f
默认加载因子 0.75 在时间和空间开销上给出了一个较好的折衷。
如果加载因子过小,扩容阈值threshold=table.length*loaderFactor,空间开销少了,但会导致查找元素效率低;而过大则会导致空间开销大,数组利用率低,分布稀疏。

为什么底层用数组+链表+红黑树进行存储
数组是用来确定桶的位置,利用元素的key的hash值对数组长度取模得到.
链表是用来解决hash冲突问题,当出现hash值一样的情形,就在数组上的对应位置形成一条链表。
红黑树是用来当提升查询查找和插入效率,链表时间复杂度为 O(logN),链表查找效率O(N)

什么条件下进行扩容

  • 当put时发现table未初始化时,进行初始化扩容
  • 当put加入节点后,发现size(键值对数量)>threshold时,进行扩容
  • 链表节点大于8且数组长度小于64的时候也会进行扩容

在扰动函数中,为什么要将hash值先高16位异或低16位运算
让hash值的低16位也具有高16位的特征,以减小数组长度较小时的hash碰撞


//异或:相同返回0,不同返回1
/*
eg:
hash   = 0b 0010 0101 1010 1100 0011 1111 0010 1110
h>>>16 = 0b 0000 0000 0000 0000 0010 0101 1010 1100
	   ^ 0b 0010 0101 1010 1100 0001 1010 1000 0010
	   
这样避免了如果table.length小,hash数值很大且末尾都是零的情况下,计算元素索引位置,
即hash^(table.length-1),大量元素冲突
,扰动后可以让这些数值更加分散,减少哈希冲突
*/
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • 为什么hash%table.length==hash&(table.length-1)
通过hash计算出key的hash值,再用(table.length - 1) & hash
将hash与n-1异或,相当于hash%数组哈希表长度,将位置控制在了数组长度范围内
如hash=25 散列表长度为16-1=15
  0001 1001
& 0000 1111
  0000 1001
即高位变为0,保留低位,从而保证了hash值分布在1数组合法位置
3) LinkedHashMap

继承自HashMap,在原有HashMap基础上,添加了一对指针,指向前一个和后一个元素,保证在遍历map元素时,可以按照添加或LRU(最近最久为访问策略)的顺序实现遍历。对于频繁的遍历操作,其执行效率高于HahsMap
在这里插入图片描述

6)TreeMap:

添加key-value,要求key必须是由同一个类创建的对象,可按照key进行排序:自然排序 、定制排序

7)ConcurrentHashMap

1.7
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kKrhmBK8-1646399590074)(https://dlnu19javastudy.yuque.com/api/filetransfer/images?url=https%3A%2F%2Fimg-blog.csdnimg.cn%2F20190426100401737.png%3Fx-oss-process%3Dimage%2Fwatermark%2Ctype_ZmFuZ3poZW5naGVpdGk%2Cshadow_10%2Ctext_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzQxODg0OTc2%2Csize_16%2Ccolor_FFFFFF%2Ct_70&sign=855dc4c4fa28fd0ee143d267e9a355e5c9e1acde30af4b39567c2437575f8c8c#from=url&height=374&id=ZVS2r&margin=%5Bobject%20Object%5D&originHeight=420&originWidth=654&originalType=binary&ratio=1&status=done&style=none&width=582)]

1.8
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KxZmAVYH-1646399590074)(https://dlnu19javastudy.yuque.com/api/filetransfer/images?url=https%3A%2F%2Fimg2.baidu.com%2Fit%2Fu%3D1066423476%2C261490557%26fm%3D253%26fmt%3Dauto%26app%3D138%26f%3DPNG%3Fw%3D499%26h%3D320&sign=f1f42df7b627d4b03f10924f4a20ca504f02216640b77301c43c26449c0f54e8#from=url&id=M3kGH&margin=%5Bobject%20Object%5D&originHeight=320&originWidth=499&originalType=binary&ratio=1&status=done&style=none)]

执行原理:
HashMap:是线程不安全的,在并发环境下会造成死循环
HashTable:给每个方法都加了synchronized,整个表都被锁住了,在执行同步方法时相当于单线程工作,其他线程都处于阻塞状态,执行效率低

ConcurrentHashMap1.7

使用分段所技术,在高并发环境下,多个线程可访问不同分段的数据表,相当于将整个表外层套了一层,将数据分为一段一段的进行存储。每一段数据拥有一把锁,线程之间通过获取不同段的锁来操作数据
底层存储结构:
​数组(Segment) + 数组(HashEntry) + 链表(HashEntry节点)
整体用Segments数组存储锁,即一个Segments对象,一个Segment对象中储存一个HashEntry数组,存储的每个Entry对象又是一个链表头结点

ConcurrentHashMap1.8:

底层和HashMap的存储结构一样,使用数组+链表+红黑树来实现
没有使用JDK1.7的分段锁技术,即没有segement对象,利用CAS + synchronized来保证并发更新的安全,JDK1.7的是锁住一段数据,1.8只对散列表的单个元素上锁,增加了并发度,且减少了锁的粒度。

  • ConcurrentHashMap 不支持 key 或者 value 为 null

常用方法介绍

put方法

在这里插入图片描述

  • 首先判断当前key或value是否为null,为null,直接返回
  • 然后进入一个循环,当满足里面某个条件时,会退出
    • 如果table为null或为{},利用InitTable()进行初始化
    • 如果数组不为空,通过key的hash值计算元素下标,获取对应位置的元素,如果该位置为空,则利用cas添加
    • 如果该位置元素hash值是-1,表示数组正在扩容中的数据迁移,让当前线程也参与到扩容中去转移元素。
    • 如果当前线程没有扩容,则说明该桶可能是链表或红黑树
    • 如果是链表,则遍历链表,如果遇到key的hash值和内容一样的节点,则更新value,否则将节点插入元素尾部
    • 如果是红黑树,则调用它自己的方法(putTreeVal)将节点插入树中,
    • 如果该位置的节点数>8且数组长度>64,则将链表进行树化,如果<64,则进行扩容操作【treeifyBin()】
    • 添加完元素后 addCount 方法,主要用于统计数量以及决定是否需要扩容

扩容方法

多并发扩容实现:
ConcurrentHahMap支持多线程同时扩容,从数组的最末尾还是每次处理一个stride长度的节点。
transfer大致可分为3部分:
第一部分:
首先,根据长度和CPU的数量计算步长,最小步长MIN_TRANSFER_STRIDE是16
创建新数组,大小为原来的两倍。然后进入死循环【当元素迁移完会直接退出】。
第二部分:
通过一个while循环和advance的状态来控制迁移情况。while中有三个逻辑
1:更新下一次处理的桶位位置
2:检查nextIndex<=0,则表示当前扩容的数组下标已经全部分配完毕
3:更新本次处理的起始位置和边界
三个判断逻辑之后都会将advance设置为true,跳出while循环,执行下面的数据迁移部分逻辑。
第三部分:
数据迁移部分逻辑分为四种情况,
1、如果当前是最后一个数据迁移线程,会将扩容线程数进行递减,更新finishing和advance为true,重
新检查一次旧哈希表中的所有桶是否迁移完毕,最后又进入这个判断逻辑,finishing为true说明扩容已
经完成,更新相应参数值然后返回。
2、如果当前位置元素为null,不用迁移,直接尝试放一个 ForwardingNode 结点告知迁移完毕
3、如果当前位置元素hash值为-1,则表示该桶位已经迁移完毕
4、如果该位置有元素,对该位置上锁,判断如果是链表结构,则将低位链表放原位置,高位链表放i+n
位置,然后将该节点用cas设置为ForwardingNode 结点表示处理完毕,如果是红黑树也是将树拆分成高低两部分,如果两部分树的节点数<6,会将其转化会链表结构,否则,就将红黑树封装在Treebin中,然后利用cas将低位部分TreeBin放在原位置,高位放在i+1位置
上面四种情况执行完都会设置advance为true,进入while更新位置,继续往前推进。

执行过程:第一次advance为true,会先确认当前步长内的处理边界和处理的起始位置,然后设置
advance为true后跳出循环,执行下面的数据迁移部分,如果当前桶位为空,放置 ForwardingNode节点,如果当前有节点,则进行相应的迁移,如果该位置已经处理过的,三种情况都会设置advance为true,重新进入上面的 while 循环的前面两个分支,下标 i 往前推进之后再次把 advance 设置为false ,跳出循环重复上面的节点处理,若处理完一个步长内的节点会对执行while的第三个if逻辑,确认当前步长内的处理边界和处理的起始位置,设置advance为true后跳出循环,执行下面的数据迁移部
分,依次类推,当前线程迁移完后,会将扩容线程数进行递减,,将处理的下标i设置为旧数组最后一个元素下标,重新按上面步骤检查一遍,是否都为 ForwardingNode 结点,最后通过通过finishing为true,
扩容结束,return。

统计节点数目方法

在这里插入图片描述

多线程修改baseCount时,每次只能一个线程CAS修改,会降低执行效率,引入了计数单元格子,其他等待的线程可将结果先存到计数单元格中,最后统conCurrentHashMap节点数量=baseCount+计数单元格中元素的和。
过程:
首先各线程利用CAS竞争baseCount,成功的则在baseCount上累加,竞争失败的线程会执行
fullAddCount(),计算写入位置,把值写入到counterCell类,并添加到计数器单元格中,如果写入位置出现冲突,则对计数器数组进行扩容,重新计算写入位置进行写入。最后利用sumCount统计。
sizeCtl
是一个控制标识符,在不同的地方有不同用途,而且它的取值不同,也代表不同的含义。
负数代表正在进行初始化或扩容操作
-1 代表正在初始化
-N 表示有N-1个线程正在进行扩容操作
正数或0代表hash表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小

get方法:

计算key的hash值获取对应位置的元素
如果该位置节点为null,直接返回null,
如果该位置节点刚好是要找的节点(hash一样,equals为true),返回该节点的值
该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树,调用他们自己的find方法查找
都不满足则说明是一颗链表,依次遍历,找到则返回节点的值,否则返回null

问题:
读操作没有加锁原因:
1.添加节点时,如果是链表结构,添加的节点会放置在链表的尾部,而查找时是从链表头部开始,不
影响链表的循环
2.如果是红黑树的结构,当红黑树正在调整时,使用的是较慢的方式:链表迭代进行查找节点,而不
是等待树调整后再查找;如果再循环的过程中,红黑树已经调整完毕,则又会自动采用红黑树查找方式
进行遍历
3.如果是ForwardNode,则会进入nextTab进行查找,查找方式同样是链表或红黑树查找方式进行遍历
4.Node的成员val是用volatile修饰的,但value被其他线程更新,当前线程也可知道
【注意和table数组用volatile修饰没有关系,其用volatile修饰主要是保证在数组扩容的时候保证可见性】
【volatile int array[10]是指array的地址是volatile的而不是数组元素的值是volatile的】
ConcurrentHashMap迭代器是强一致性还是弱一致性?HashMap呢?
弱一致性的迭代器,在这种迭代方式中,当iterator被创建后集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时new新的数据从而不影响原有的数据,iterator完成后再将头指针替换为新的数据,这样iterator线程可以使用原来老的数据,而写线程也可以
并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。
ConcurrentHashMap可以支持在迭代过程中,向map添加新元素,而HashMap则抛出了
ConcurrentModificationException ,因为HashMap包含一个修改计数器modCount,当调用他的next() 方法来获取下一个元素时,迭代器将会用到这个计数器。
存储在ConCurrentHashmap中每个节点是什么样的,有哪些变量
它是实现 Map.Entry<K,V> 接口。里面存放了hash,key,value,以及next节点。它的value和next节点是用volatile进行修饰,可以保证多线程之间的可见性。
ConCurrentHashmap 每次扩容是原来容量的几倍
2倍, 在transfer方法里面会创建一个原数组的俩倍的node数组来存放原数据。
ConcurrentHashmap 不支持 key 或者 value 为 null 的原因?
ConcurrentHashmap 和 hashMap 不同的是, concurrentHashMap 的 key 和 value 都不允许为null,因为 concurrenthashmap 它们是用于多线程并发的 ,如果 map.get(key) 得到了null,不能判断到底是映射的value是null,还是因为没有找到对应的key而为空,而用于单线程状态的 hashmap 却可以用containKey(key) 去判断到底是否包含了这个null。
ConcurrentHashmap什么时候扩容
第一次put时,若没有指定容量,默认为16
达到阈值时(sizeCtl=加载因子0.75*数组长度),会扩容为原来的两倍
当链表节点数>8且数组长度<64
JDK1.7 与 JDK1.8 中ConcurrentHashMap 的区别?
数据结构:<jdk1.7>:数组(Segments) + 数组(HashEntry) + 链表(HashEntry节点)
<jdk1.8>:数组 + 链表 + 红黑树
保证线程安全机制:JDK1.7 采用 Segment 的分段锁机制实现线程安全,其中 Segment 继承自ReentrantLock 。JDK1.8 采用CAS+synchronized保证线程安全。
锁的粒度:JDK1.7 是对需要进行数据操作的 Segment 加锁,JDK1.8 调整为对每个数组元素加锁(Node)。
链表转化为红黑树:定位节点的 hash 算法简化会带来弊端,hash 冲突加剧,因此在链表节点数量大于8(且数据总量大于等于 64)时,会将链表转化为红黑树进行存储。
查询时间复杂度:从 JDK1.7的遍历链表O(n), JDK1.8 变成遍历红黑树O(logN)。
谈谈各对map集合线程并发安全、ConcurrentHashMap 和 Hashtable 实现锁的机制是什么,谁的效率哪个更高
HashMap:是线程不安全的,在并发环境下会造成死循环
Hashtable 是使用 synchronized来实现线程安全的,给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问操作此对象,其他线程只能阻塞等待,在竞争激烈的多线程场景中性能就会非常差!
ConcurrentHashMapJDK1.7 中采用分段锁实现线程安全,将数据分成一段段的存在HashEntry中,每一段数据配一把锁segment,这些锁存在segments中,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。
在 JDK1.8 中采用CAS+synchronized实现线程安全,只对散列表的单个元素上锁,增加了并发度,且减少了锁的粒度

  • 5
    点赞
  • 73
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值