阅读 235

【碎片时间】硬刷java面试题之集合框架

这是我参与更文挑战的第1天,活动详情查看: 更文挑战

没错,就是硬刷题!请叫我头铁

为了金九银十面大厂,拼了,✊利用碎片时间坚持刷题两个月!

废话不多说,直接题目刷起来!(答案和解析都附上了)


Java的常用数据结构有哪些?(阿里社招一面题)

平淡版:

java的常用数据结构有数组,栈,队列,链表,树,图,散列表(哈希表)。

✅升级版:

java常用数据结构包含8种

数组是最简单、也是使用最广泛的数据结构;

是LIFO(后进先出)的线性数据结构;

队列与栈相似,区别在队列是FIFO (先进先出),他们都是属于顺序存储元素的线性数据结构;

链表是另一个重要的线性数据结构,包括单链表和双链表;

是一组以网络形式相互连接的节点;

树形结构是一种层级式的数据结构,由顶点(节点)和连接它们的边组成;

对象以键值对的形式存储,这些键值对的集合被称为“字典”;

哈希表,基于哈希法最常用的数据结构,其属于散列数据结构。


java集合框架指的是什么?(阿里社招一面题)

平淡版:

集合框架是java的一种工具类, 包括Collection 和Map两大父接口,常用的集合类有ArrayList,HashMap,HashSet等。线程安全的有Vector,HashTable。线程不安全的有LinkedList,TreeMap,ArrayList,HashMap等等。

✅升级版:

Java最初的版本中包含以下几种集合类:Vector、Stack、HashTable和Array。 随着集合的广泛使用,Java1.2提出了囊括所有集合接口、实现类和算法的集合框架, 所以集合框架是java提供的工具包,是一个用来代表和操纵集合的统一架构,所有集合类都位于java.util包下。

结构:

  • Map接口和Collection接口是所有集合框架的父接口
  • Collection接口的子接口包括:Set接口和List接口
  • Map接口的实现类主要有:HashMap、TreeMap、Hashtable、ConcurrentHashMap以及Properties等
  • Set接口的实现类主要有:HashSet、TreeSet、LinkedHashSet
  • List接口的实现类主要有:ArrayList、LinkedList、Stack以及Vector等

优点:

  • 通过使用JDK附带的集合类,可以降低代码维护成本和开发成本。
  • 集合框架还有高复用性和可操作性的特点,且都是经过严格测试,可提高代码质量。

Arraylist 与 LinkedList 异同?

平淡版:

共同点: 都是List的实现类

不同点: 实现方式不一样, ArrayList是以数组方式实现,快速随机的获取对象使用Arraylist较方便; LinkedList是采用链表的方式实现List接口,在进行insert和remove动作时,效率较Arraylist更高。

✅升级版:

从线程安全角度看,两者都是不同步,不能保证线程安全;

从底层数据结构,Arraylist 底层使用的是Object数组;LinkedList 底层使用的是双向循环链表数据结构;

从执行效率上ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认将指定的元素追加到此列表的末尾,这种情况时间复杂度就是O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element))时间复杂度就为 O(n-i)近似O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。

LinkedList 采用链表存储,所以插入、删除元素的时间复杂度不受元素位置的影响,都是近似 O(1)而数组为近似 O(n),两者的效率区别不言而喻了。

是否支持快速随机访问 ,LinkedList 不支持高效的随机元素访问,而ArrayList 实现了RandmoAccess 接口,所以有随机访问功能。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。


hashmap的工作原理是什么?(字节面试题)

平淡版:

hashmap的数据结构是key-value键值对方式,数组只允许一个key为null,允许多个value为null,且不是线程安全。

✅升级版:

hashmap的工作原理可以从三个角度说明:

首先是底层数据结构

HashMap是用hash表(hashtable)作为底层数据结构来存储的,即key-value键值对的方式,实现Map接口。

hashmap里为解决hash冲突,使用链地址法,即数组+链表的形式来解决,当数据被hash后,得到数组下标index,把key-value作为数据放在对应下标的链表中。

再是方法实现原理:

put方法实现为例子,put方法的第一步,就是计算出要put元素在hash桶数组中的索引位置,得到索引位置需要三步,分别是取put元素key的hashcode值->高位运算->取模运算

第一步不用说了很简单,第二步高位运算就是用第一步得到的值h,用h的高16位和低16位进行异或操作,第三步为了使hash桶数组元素分布更均匀,采用取模运算,取模运算就是用第二步得到的值和hash桶数组长度-1的值取与。这样得到的结果和传统取模运算结果一致,而且效率比取模运算高。

如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap

HashMap的特定参数:

hashmap数组的默认初始长度是16,这是为了充分利用链表长度,得到均匀分布的hash。

hashmap的加载因子默认为0.75,当容器中元素数量超过「数组容量*加载因子」时,会进行自动扩容,扩容机制主要进行两步骤,第一步把数组长度变为原来的两倍第二步一个非常重要的方法是transfer方法,采用头插法,把旧数组的元素插入到新数组中。使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法作用是将原有Entry数组的元素拷贝到新的Entry数组里。

而在java1.8开始,就改用了尾插法,为了防止环化,因为多线程并发进行put操作时,当触发了HashMap的扩容,会出现链表的两个节点形成闭环,导致死循环,用尾插法以保证程序能正常执行。

当链表长度大于阈值8时,会将链表转化为红黑树,以减少搜索时间。


hashmap的容量为什么必须是2的幂值?

平淡版:

为了加快哈希计算,减少哈希冲突,使得数据均匀分布。

✅升级版:

因为hashmap的执行原理是要先找到key在hash表中的位置,这就需要通过计算 hash(key)%数组长度来获得,可是传统取模算法效率低且慢,故用&代替%,为了保证结果一致需要把数组长度减去1,就得到了现在的位运算公式,hash(key)&(length-1),&运算比%更快,这就加快了哈希计算。

为什么长度要设置成2幂次方,是因为2的幂次方-1后的值的二进制数每一位上都是1,然后代入公式,最终的计算结果只和key的hashcode值本身有关,这样就使得新增的元素能均匀的分布在hashmap的数组上,减少hash碰撞,避免形成链表的结构,降低查询效率。

如果length不为2的幂,比如15。那么length-1的2进制就会变成1110。在key的hashcode值为随机数的情况下,和1110做&操作。尾数永远为0。那么0001、1001、1101等尾数为1的位置就永远不可能被占用。这样会造成浪费,不随机等问题。


hashmap为什么链表超过8要转化为红黑树?(阿里1-3年经验社招题)

平淡版:

因为当链表越来越长时,转化为红黑树可以提高查找效率。

✅升级版:

假设链表长度n为8时,红黑树的平均查找时间复杂度是log(n),即log(8)=3,链表的平均查找时间复杂度为n/2,即8/2=4,结果显而易见,改成红黑树可以缩短时间,提升查询效率。

那为什么要在8的时候转换呢?

平淡版:按照泊松分布计算结果获得,设定为8可以最好的提高效率,加快检索速度。

✅升级版:

之前说过长度设定为2的n次幂可以让数据分布均匀,离散较好,这样的结果下其实链表就不太会出现很长的情况,因为理想情况下,链表长度符合泊松分布,各个长度的命中概率是依次递减的,当长度为8时的概率仅仅为0.00000006,概率极低,所以通常情况也不会发生链表向红黑树的转换。

但既然有这样的设计肯定有他的意义,事实上,这个转换设计可以防止用户自己实现不好的哈希算法导致链表过长,从而导致查询效率太低,算是一种保底策略。


hashmap的负载因子为什么是0.75?(快手面试题)

平淡版:

提高空间利用率,减少查询成本,根据泊松分布,0.75的哈希碰撞最小。

✅升级版:

和初始容量16一样,负载因子也是影响hashmap性能的因素之一。

负载因子的作用,当哈希表中数量超过负载因子和当前容量的乘积时,对该哈希表进行扩容操作(rehash),扩充容量为原来的两倍。

那么为什么不是1不是0.5,偏偏是0.75呢?

其实这是一种折中机制,如果是1,是提高了空间利用率,但是会增加查询时间成本;

如果是0.5,减少了查询时间成本,却也降低了空间利用率,增加了rehash的操作次数。

选择0.75作为默认负载因子,就是考虑了时间和空间成本的折中办法。


Tips

hashMap术语介绍:

桶: 就是hashmap的table数组

bin: 就是挂在数组上的链表

TreeNode: 红黑树

capacity: table总容量

MIN_TREEIFY_CAPACITY :64 转化为红黑树table最小大小

TREEIFY_THRESHOLD :8 转化为红黑树的阈值

loadFactor: 0.75 table扩容因子(负载因子),当实际length大于等于 capacity*loadFactor时会进行扩容,并且扩容是按照2的整数次幂扩的,原因上面解释了。

threshold: capacity*loadFactor


重要,这题面试官爱问

concurrenthashmap 了解多少,1.7和1.8区别?(阿里1-3年经验社招题)

平淡版:

数据结构不同,1.7中采用Segment + HashEntry + Unsafe,在1.8中移除了Segment,Synchronized + CAS + Node + Unsafe的结构,锁的颗粒度更小。

保证线程安全不同,1.7中采用分段(Segment)对整个桶数组进行了分割,每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。

而在1.8中最明显的区别就是锁的粒度更小了,效率更高。看图可知每个头结点均有一把锁,而在当链表达到某一阈值时,转换为红黑树,提高查询效率。采用synchronized 关键字同步代码块和 cas操作了维护并发。

✅升级版:

从数据结构上

JDK1.7 中的 ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成,即 ConcurrentHashMap 把哈希桶数组切分成小数组(Segment ),每个小数组由 n 个 HashEntry 组成。

JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的Node数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用 CAS + synchronized实现更加细粒度的锁。

重点来了,这两个版本的最大区别在并发锁的设计。

JDK1.7 是对需要进行数据操作的 Segment 加锁,Segment继承了ReentrantLock,锁定需要操作的Segment,其他的Segment不受影响,并发度是Segment的个数,数组扩容也不会影响其他的Segment。

JDK1.8 调整为对每个数组元素加锁(Node),即链表的头部节点,粒度更细,大大提高并发访问率,采用CAS+synchronized 保证线程安全,扩容时,阻塞所有的读写操作,支持并发扩容。

这点必须记住,毕竟在1.8中ConcurrentHashMap实现这块锁功能,用了6000行代码,而在1.7中只有1000行代码!

面试官还可能会问道:

JDK1.8 中为什么使用内置锁 synchronized替换 可重入锁 ReentrantLock?

  • 在 JDK1.6 中,对 synchronized 锁的实现引入了大量的优化,并且 synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。
  • 减少内存开销 。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。

再说下ConcurrentHashMap的put方法实现的不同

1.7:先定位到相应的Segment,再定位到桶,再进行put操作。put全程加锁,所以首先尝试获取锁,没有获取锁的线程利用 scanAndLockForPut()方法自旋获取,并最多自旋64次获取锁,超过则挂起改为阻塞锁获取,保证获取成功。

1.8:由于移除了Segment,就类似HashMap,可以直接通过计算出hash值定位到桶,定位到Node后,拿到首节点first进行判断,

  • 如果为 null ,则通过 CAS 的方式尝试插入;
  • 如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容;
  • 如果都不满足 ,synchronized 锁住 f 节点,判断是链表还是红黑树,遍历插入;

当在链表长度达到阈值(8) 的时候,会将数组扩容或者将链表转换为红黑树。

从查询时间复杂度: JDK1.7的遍历链表的O(n), JDK1.8 变成遍历红黑树的O(logN)。

面试官还可能会问道:

为啥使用synchronized呢?而不用采取乐观锁,或者lock呢?

  • 乐观锁比较适用于竞争冲突比较少的场景,如果冲突比较多,那么就会导致不停的重试,这样反而性能更低。
  • synchronized在经历了优化之后,其实性能已经和lock没啥差异了,某些场景可能还比lock快。所以,我觉得这是采用synchronized来同步的原因。

以上,是我昨天和今天利用碎片时间刷的题。

明天继续,不见不散。

上述题目有两个来源:一是来自广大网友的面试经验,二是一些大V整理的面试题。

​ 为什么我要写「平淡版」和「升级版」两个答案呢,是因为我觉得自己以前面试回答都不够好,没答在点子上,希望通过两个版本的比较,鞭策自己回答的要更有深度和广度,挖掘更多知识点。

一起努力💪

对了,刷题过程中还看了很多的源码和图解没来得及整理,晚上整理完附上,毕竟hashmap那块还是对着源码和图解会更直观些。

很多知识点我是读了两到三遍才理解,前路漫漫,大家一起共勉🤝。

文章分类
后端
文章标签