Java集合类 (牛客Java面试宝典补充)
根据牛客面试指南Java工程师模块问题进行整理和细微补充,仅供参考,不合理地方请评论区指出共勉
1.Java中有哪些容器(集合类)?
Java中的集合类主要由Collection和Map这两个接口派生而出,其中Collection接口又派生出三个子接口,分别是Set、List、Queue。所有的Java集合类,都是Set、List、Queue、Map这四个接口的实现类,这四个接口将集合分成了四大类,其中
- Set代表无序的,元素不可重复的集合;
- List代表有序的,元素可以重复的集合;
- Queue代表先进先出(FIFO)的队列;
- Map代表具有映射关系(key-value)的集合。
这些接口拥有众多的实现类,其中最常用的实现类有HashSet、TreeSet、ArrayList、LinkedList、ArrayDeque、HashMap、TreeMap等。
2.Java中的容器,线程安全和线程不安全的分别有哪些?
java.util 包下的集合类大部分都是线程不安全的,例如我们常用的(HashSet、TreeSet、ArrayList、LinkedList、ArrayDeque、HashMap、TreeMap)这些都是线程不安全的集合类,但是它们的优点是性能好。如果需要使用线程安全的集合类,则可以使Collections 工具类提供的 synchronizedXxx()方法,将这些集合类包装成线程安全的集合类。
从 Java5 开始,Java 在 java.util.concurrent 包下提供了大量支持高效并发访问的集合类,它们既能包装良好的访问性能,有能包装线程安全。这些集合类可以分为两部分,它们的特征如下:
- 以 Concurrent 开头的集合类:以 Concurrent 开头的集合类代表了支持并发访问的集合,它们可以支持多个线程并发写入访问,这些写入线程的所有操作都是线程安全的,但读取操作不必锁定。以 Concurrent 开头的集合类采用了更复杂的算法来保证永远不会锁住整个集合,因此在并发写入时有较好的性能。
- 以 CopyOnWrite 开头的集合类:以 CopyOnWrite 开头的集合类采用复制底层数组的方式来实现写操作。当线程对此类集合执行读取操作时,线程将会直接读取集合本身,无须加锁与阻塞。当线程对此类集合执行写入操作时,集合会在底层复制一份新的数组,接下来对新的数组执行写入操作。由于对集合的写入操作都是对数组的副本执行操作,因此它是线程安全的。
3. Map接口有哪些实现类?
Map接口有很多实现类,其中比较常用的有HashMap、LinkedHashMap、TreeMap、ConcurrentHashMap。
对于不需要排序的场景,优先考虑使用HashMap,因为它是性能最好的Map实现。如果需要保证线程安全,则可以使用ConcurrentHashMap。它的性能好于Hashtable,因为它在put时采用分段锁/CAS的加锁机制,而不是像Hashtable那样,无论是put还是get都做同步处理。
对于需要排序的场景,如果需要按插入顺序排序则可以使用LinkedHashMap,如果需要将key按自然顺序排列甚至是自定义顺序排列,则可以选择TreeMap。如果需要保证线程安全,则可以使用Collections工具类将上述实现类包装成线程安全的Map。
4.描述一下Map put的过程
HashMap是最经典的Map实现,下面以它的视角介绍put的过程(JDK1.8之后):
- 先判断数组是否为空,为空则进行第一次扩容(resize)(HashMap默认初始化为16.之后每次扩容,容量变为原来的2倍。并且HashMap总是以2的幂作为hash表的大小)
- HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过
(n - 1) & hash
判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。(所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。) - 当链表长度大于阈值(默认是8),会首先调用treeifyBin()方法。这个方法会根据HashMap的数据来决定是否转换为红黑树。只有当数组长度大于或者等于64的情况下,才会执行链表转红黑树的操作,以减少搜索时间。否则,只执行resize()方法对数组进行扩容
- loadFactor 负载因子是控制数组存放数据的疏密程度,loadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。
- threshold = capacity * loadFactor,当 Size>threshold的时候,那么就要考虑对数组的扩增了,也就是说,这个的意思就是 衡量数组是否需要扩增的一个标准。
- 当Size>threshold时,则需要再次进行数组扩容操作。
5.如何得到一个线程安全的Map?
-
使用HashTable
实现原理是在增删改查的方法上使用了synchronized锁机制,在多线程环境下,无论是读数据还是修改数据,在同一时刻只能有一
个线程在执行synchronized方法(所有线程竞争同一把锁),因为对整个表进行锁定。所以线程越多,对该map的亮争越激烈,效
率越低。 -
使用Collections.synchronizedMap
调用synchronizedMap()方法后会返回一个SynchronizedMap类的对象,而在SynchronizedMap类中使用了synchronized同步关键字来保证对Map的操作是安全的。
实现原理是使用工具类里的静态方法,把传入的HashTable包装成同步的,即在增删改查的方法上增加了synchronized锁机制,每
次操作HashMap都需要先获取到这个对象锁,这个对象锁加了synchronized修饰,其实现方式和HashTable差不多,效率也很低。 -
使用ConcurrentHashMap
- Java7 中
ConcurrentHashMap
使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个Segment
都是一个类似HashMap
数组的结构,它可以扩容,它的冲突会转化为链表。但是Segment
的个数一但初始化就不能改变。 - Java8 中的
ConcurrentHashMap
使用的Synchronized
锁加 CAS 的机制。结构也由 Java7 中的Segment
数组 +HashEntry
数组 + 链表 进化成了 Node 数组 + 链表 / 红黑树,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。
- Java7 中
6.HashMap有什么特点?
- 允许null键和null值
- 不保证键值对的顺序,即插入顺序不一定等于遍历顺序
- 线程不安全
7.JDK7和JDK8中的HashMap有什么区别?
-
数据结构上的区别
-
JDK7中的数据结构主要是:数组+链表 ,数据和链表的节点实现类是Entry类
-
JDK8中的数据结构主要是:数组+链表/红黑树,当链表的元素个数大于等于8的时候判断当前数组长度是否大于64,大于64则转为红黑树,否则扩容数组;当元素个数小于等于6时,红黑树结构还原成链表,数组和链表的节点的实现类是Node类
1.红黑树是解决链表查询出现的O(n)情况,那么为什么不用其他树呢? 如平衡二叉树等,我们通过以下两方面分析: 平均插入效率:链表>红黑树>平衡二叉树 平均查询效率:平衡二叉树>红黑树>链表 可以看出红黑树介于二者之间,hashMap作为各种操作频繁的容器,自然选择综合性能较好的红黑树
2.为什么阈值是6和8呢? 1.为什么8转红黑树? 红黑树的平均查找次数是log2(n), 长度为8时: 红黑树平均查找次数为3,链表平均查找长度为8/2=4,此时选择红黑树优 长度为4为: 红黑树平均查找次数为2,链表平均长度为4/2=2,此时次数一样,红黑树开销大 至于567我们在这没有讨论的必要 2.为什么6转回链表? 若选择7,在7和8链表之间的增删元素,必然会导致频繁进行链表和红黑树的转换
-
-
Hash值的计算区别
-
JDK7:h^ =(h>>>20)^(h>>>12) return h ^(h>>>7) ^(h>>>4);
-
JDK8:(key==null)?0:(h=key.hashCode())^(h>>>16)
jdk7中因为要保持hash函数的散列性,所以进行了多次的异或和位运算而jdk8中因为链表长度超过等于8会转红黑树,所以我们可以稍微减少元素的散列性,从而避免很多异或和位运算操作
-
-
链表数据插入的区别
-
JDK7:使用的是头插入法,扩容后与原位置相反(resize会导致环形链表)
-
JDK8:使用的尾插法,扩容后位置与原链表相同
jdk7插入链表头部,因为这样无需遍历链表(需要判断是否为尾部,然后插入尾部),可以直接插入头部 jdk8中插入元素时,要判断个数是否需要构造红黑树,这样已存在了遍历, 所以插入尾部方便, 并且解决了jdk7中头插法导致的环状链表问题
-
-
扩容机制的不同
-
JDK7扩容条件:元素个数 > 容量(16) * 加载因子 (0.75) && 插入的数组位置有元素存在
-
JDK8扩容条件 :元素个数 > 容量 (16) * 加载因子(0.75)
虽然都是进行2倍扩容,但是JDK1.7中扩容的时候,重新计算位置, JDk8则不会,只要看看原hash值新增的那个bit位是1还是0就好了,是0的话索引没有变, 是1的话索引变成“原索引+oldCap(旧数组大小)
-
8.介绍一下HashMap底层的实现原理
它基于hash算法,通过put方法和get方法存储和获取对象。
存储对象时,我们将K/V传给put方法时,它调用K的hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。
如果发生碰撞的时候,HashMap通过链表将产生碰撞冲突的元素组织起来。在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度
9.介绍一下HashMap的扩容机制
- 数组的初始容量为16,而容量是以2的次方扩充的,一是为了提高性能使用足够大的数组,二是为了能使用位运算代替取模预算(据说提升了5~8倍)。
- 数组是否需要扩充是通过负载因子判断的,如果当前元素个数为数组容量的0.75时,就会扩充数组。这个0.75就是默认的负载因子,可由构造器传入。我们也可以设置大于1的负载因子,这样数组就不会扩充,牺牲性能,节省内存。
- 为了解决碰撞,数组中的元素是单向链表类型。当链表长度到达一个阈值时(7或8),会将链表转换成红黑树提高性能。而当链表长度缩小到另一个阈值时(6),又会将红黑树转换回单向链表提高性能。
- 对于第三点补充说明,检查链表长度转换成红黑树之前,还会先检测当前数组数组是否到达一个阈值(64),如果没有到达这个容量,会放弃转换,先去扩充数组。所以上面也说了链表长度的阈值是7或8,因为会有一次放弃转换的操作。
10.HashMap中的循环链表是如何产生的?
在多线程的情况下,当重新调整HashMap大小的时候,就会存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历。如果条件竞争发生了,那么就会产生死循环了。
11.HashMap为什么用红黑树而不用B树?
HashMap本来是数组+链表的形式,链表由于其查找慢的特点,所以需要被查找效率更高的树结构来替换。如果用B/B+树的话,在数据量不是很多的情况下,数据都会“挤在”一个结点里面,这个时候遍历效率就退化成了链表。
12. HashMap为什么线程不安全?
- JDK1.7 中,由于多线程对HashMap进行扩容,调用了HashMap#transfer(),具体原因:某个线程执行过程中,被挂起,其他线程已经完成数据迁移,等CPU资源释放后被挂起的线程重新执行之前的逻辑,数据已经被改变,造成死循环、数据丢失。
- JDK1.8 中,由于多线程对HashMap进行put操作,调用了HashMap#putVal(),具体原因:假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
13.HashMap如何实现线程安全?
-
直接使用Hashtable类;
-
直接使用ConcurrentHashMap;
-
使用Collections将HashMap包装成线程安全的Map。
14.HashMap是如何解决哈希冲突的?
HashMap在JDK1.8版本中是通过链式寻址法以及红黑树来解决Hash冲突的问题,其中红黑树是为了优化Hash表的链表过长导致时间复杂度增加的问题,当链表长度大于等于8并且Hash表的容量大于64的时候,再向链表添加元素,就会触发链表向红黑树的一个转化
15.说一说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
没有这样的机制。
16.HashMap与ConcurrentHashMap有什么区别?
-
HashMap是非线程安全的,这意味着不应该在多线程中对这些Map进行修改操作,否则会产生数据不一致的问题,甚至还会因为并发插入元素而导致链表成环,这样在查找时就会发生死循环,影响到整个应用程序。
-
Collections工具类可以将一个Map转换成线程安全的实现,其实也就是通过一个包装类,然后把所有功能都委托给传入的Map,而包装类是基于synchronized关键字来保证线程安全的(Hashtable也是基于synchronized关键字),底层使用的是互斥锁,性能与吞吐量比较低。
-
ConcurrentHashMap的实现细节远没有这么简单,因此性能也要高上许多。它没有使用一个全局锁来锁住自己,而是采用了减少锁粒度的方法,尽量减少因为竞争锁而导致的阻塞与冲突,而且ConcurrentHashMap的检索操作是不需要锁的。
17.介绍一下ConcurrentHashMap是怎么实现的?
-
在 jdk 1.7 中,ConcurrentHashMap 是由 Segment 数据结构和 HashEntry 数组结构构成,采取分段锁来保证安全性。Segment 是 ReentrantLock 重入锁,在 ConcurrentHashMap 中扮演锁的角色,HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组,Segment 的结构和 HashMap 类似,是一个数组和链表结构。
-
JDK1.8 的实现已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 Synchronized 和 CAS 来操作,整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。
18.ConcurrentHashMap是怎么分段分组的?
-
JDK1.7
-
put
-
计算要 put 的 key 的位置,获取指定位置的
Segment
。 -
如果指定位置的
Segment
为空,则初始化这个Segment
.初始化 Segment 流程:
- 检查计算得到的位置的
Segment
是否为 null. - 为 null 继续初始化,使用
Segment[0]
的容量和负载因子创建一个HashEntry
数组。 - 再次检查计算得到的指定位置的
Segment
是否为 null. - 使用创建的
HashEntry
数组初始化这个 Segment. - 自旋判断计算得到的指定位置的
Segment
是否为 null,使用 CAS 在这个位置赋值为Segment
.
- 检查计算得到的位置的
-
Segment.put
插入 key,value 值。
由于
Segment
继承了ReentrantLock
,所以Segment
内部可以很方便的获取锁,put 流程就用到了这个功能。1.
tryLock()
获取锁,获取不到使用scanAndLockForPut
方法继续获取。(这个方法做的操作就是不断的自旋tryLock()
获取锁。当自旋次数大于指定次数时,使用lock()
阻塞获取锁。在自旋时顺表获取下 hash 位置的HashEntry
。)2.计算 put 的数据要放入的 index 位置,然后获取这个位置上的
HashEntry
。3.遍历 put 新元素,为什么要遍历?因为这里获取的
HashEntry
可能是一个空元素,也可能是链表已存在,所以要区别对待。如果这个位置上的
HashEntry
不存在:-
如果当前容量大于扩容阀值,小于最大容量,进行扩容。(
ConcurrentHashMap
的扩容只会扩容到原来的两倍。老数组里的数据移动到新的数组时,位置要么不变,要么变为index+ oldSize
,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。) -
直接头插法插入。
如果这个位置上的
HashEntry
存在: -
判断链表当前元素 key 和 hash 值是否和要 put 的 key 和 hash 值一致。一致则替换值
-
不一致,获取链表下一个节点,直到发现相同进行值替换,或者链表表里完毕没有相同的。
- 如果当前容量大于扩容阀值,小于最大容量,进行扩容。
- 直接链表头插法插入。
4.如果要插入的位置之前已经存在,替换后返回旧值,否则返回 null.
-
-
get
- 计算得到 key 的存放位置。
- 遍历指定位置查找相同 key 的 value 值。
-
-
JDK1.8
-
put
- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。
- 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
- 如果当前位置的
hashcode == MOVED == -1
,则需要进行扩容。 - 如果都不满足,则利用 synchronized 锁写入数据。
- 如果数量大于
TREEIFY_THRESHOLD
则要执行树化方法,在treeifyBin
中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树。
-
get
- 根据 hash 值计算位置。
- 查找到指定位置,如果头节点就是要找的,直接返回它的 value.
- 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找之。
- 如果是链表,遍历查找之。
-
19.说一说你对LinkedHashMap的理解
LinkedHashMap使用双向链表来维护key-value对的顺序(其实只需要考虑key的顺序),该链表负责维护Map的迭代顺序,迭代顺序与key-value对的插入顺序保持一致。
LinkedHashMap可以避免对HashMap、Hashtable里的key-value对进行排序(只要插入key-value对时保持顺序即可),同时又可避免使用TreeMap所增加的成本。
LinkedHashMap需要维护元素的插入顺序,因此性能略低于HashMap的性能。但因为它以链表来维护内部顺序,所以在迭代访问Map里的全部元素时将有较好的性能。
20.请介绍LinkedHashMap的底层原理
LinkedHashMap 是 HashMap 的子类,在HashMap存储结构的基础上,使用了一对双向链表来记录添加元素的顺序。LinkedHashMap底层使用的结构与HashMap相同,因为LinkedHashMap继承于HashMap.二者之间的区别就在于LinkedHashMap内部提供了Entry替换HashMap中的Node。
LinkedHashMap相对于HashMap,增加了双链表的结果(即节点中增加了前before、后after指针,LinkedHashMap中增加了head、tail指正),其他处理逻辑与HashMap一致,同样也没有锁保护,多线程使用存在风险。
21. 请介绍TreeMap的底层原理
TreeMap基于红黑树(Red-Black tree)实现。映射根据其键的自然顺序进行排序,或者根据创建映射时提供的 Comparator 进行排序,具体取决于使用的构造方法。TreeMap的基本操作containsKey、get、put、remove方法,它的时间复杂度是log(N)。
TreeMap包含几个重要的成员变量:root、size、comparator。其中root是红黑树的根节点。它是Entry类型,Entry是红黑树的节点,它包含了红黑树的6个基本组成:key、value、left、right、parent和color。Entry节点根据Key排序,包含的内容是value。Entry中key比较大小是根据比较器comparator来进行判断的。size是红黑树的节点个数。
红黑树的性质:
①每个节点要么是红色,要么是黑色。
②根节点永远是黑色的。
③所有的叶子节点都是空节点(null),并且是黑色的。
④每个红色节点的两个子节点都是黑色。从每个叶子到根的路径上不会有两个连续的红色节点。
⑤从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点。
要诀:根叶黑,不红红,黑路同
22.Map和Set有什么区别?
Set
(注重独一无二的性质): 存储的元素是无序的、不可重复的。Map
(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),“x” 代表 key,“y” 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。
23.List和Set有什么区别?
List
(对付顺序的好帮手): 存储的元素是有序的、可重复的。Set
(注重独一无二的性质): 存储的元素是无序的、不可重复的。
24.ArrayList和LinkedList有什么区别?
-
是否保证线程安全:
ArrayList
和LinkedList
都是不同步的,也就是不保证线程安全; -
底层数据结构:
ArrayList
底层使用的是Object
数组;LinkedList
底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别) -
插入和删除是否受元素位置的影响:
-
ArrayList
采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)
方法的时候,ArrayList
会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)
),时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 -
LinkedList
采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e)
、addFirst(E e)
、addLast(E e)
、removeFirst()
、removeLast()
),时间复杂度为 O(1),如果是要在指定位置i
插入和删除元素的话(add(int index, E element)
,remove(Object o)
,remove(int index)
), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入和删除。
-
-
是否支持快速随机访问:
LinkedList
不支持高效的随机元素访问,而ArrayList
(实现了RandomAccess
接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)
方法)。 -
内存空间占用:
ArrayList
的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
25.有哪些线程安全的List?
- Vector
Vector是比较古老的API,虽然保证了线程安全,但是由于效率低一般不建议使用。
- Collections.SynchronizedList
SynchronizedList是Collections的内部类,Collections提供了synchronizedList方法,可以将一个线程不安全的List包装成线程安全的List,即SynchronizedList。它比Vector有更好的扩展性和兼容性,但是它所有的方法都带有同步锁,也不是性能最优的List。
- CopyOnWriteArrayList
CopyOnWriteArrayList是Java 1.5在java.util.concurrent包下增加的类,它采用复制底层数组的方式来实现写操作。当线程对此类集合执行读取操作时,线程将会直接读取集合本身,无须加锁与阻塞。当线程对此类集合执行写入操作时,集合会在底层复制一份新的数组,接下来对新的数组执行写入操作。由于对集合的写入操作都是对数组的副本执行操作,因此它是线程安全的。在所有线程安全的List中,它是性能最优的方案。
26.介绍一下ArrayList的数据结构?
ArrayList的底层是用数组来实现的,默认第一次插入元素时创建大小为10的数组,超出限制时会增加50%的容量,并且数据以 System.arraycopy() 复制到新的数组,因此最好能给出数组大小的预估值。
按数组下标访问元素的性能很高,这是数组的基本优势。直接在数组末尾加入元素的性能也高,但如果按下标插入、删除元素,则要用 System.arraycopy() 来移动部分受影响的元素,性能就变差了,这是基本劣势。
27. 谈谈CopyOnWriteArrayList的原理
CopyOnWriteArrayList是Java并发包里提供的并发类,简单来说它就是一个线程安全且读操作无锁的ArrayList。正如其名字一样,在写操作时会复制一份新的List,在新的List上完成写操作,然后再将原引用指向新的List。这样就保证了写操作的线程安全。
CopyOnWriteArrayList允许线程并发访问读操作,这个时候是没有加锁限制的,性能较高。而写操作的时候,则首先将容器复制一份,然后在新的副本上执行写操作,这个时候写操作是上锁的。结束之后再将原容器的引用指向新容器。注意,在上锁执行写操作的过程中,如果有需要读操作,会作用在原容器上。因此上锁的写操作不会影响到并发访问的读操作。
- 优点:读操作性能很高,因为无需任何同步措施,比较适用于读多写少的并发场景。在遍历传统的List时,若中途有别的线程对其进行修改,则会抛出ConcurrentModificationException异常。而CopyOnWriteArrayList由于其"读写分离"的思想,遍历和修改操作分别作用在不同的List容器,所以在使用迭代器进行遍历时候,也就不会抛出ConcurrentModificationException异常了。
- 缺点:一是内存占用问题,毕竟每次执行写操作都要将原容器拷贝一份,数据量大时,对内存压力较大,可能会引起频繁GC。二是无法保证实时性,Vector对于读写操作均加锁同步,可以保证读和写的强一致性。而CopyOnWriteArrayList由于其实现策略的原因,写和读分别作用在新老不同容器上,在写操作执行过程中,读不会阻塞但读取到的却是老容器的数据。
28.说一说TreeSet和HashSet的区别
-
HashSet
和TreeSet
都是Set
接口的实现类,都能保证元素唯一,并且都不是线程安全的。 -
HashSet
和TreeSet
的主要区别在于底层数据结构不同。HashSet
的底层数据结构是哈希表(基于HashMap
实现)。TreeSet
底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。 -
底层数据结构不同又导致应用场景不同。
HashSet
用于不需要保证元素插入和取出顺序的场景,TreeSet
用于支持对元素自定义排序规则的场景。
29.说一说HashSet的底层结构
HashSet是基于HashMap实现的,默认构造函数是构建一个初始容量为16,负载因子为0.75 的HashMap。它封装了一个 HashMap 对象来存储所有的集合元素,所有放入 HashSet 中的集合元素实际上由 HashMap 的 key 来保存,而 HashMap 的 value 则存储了一个 PRESENT,它是一个静态的 Object 对象。
30.BlockingQueue中有哪些方法,为什么这样设计?
BlockingQueue
(阻塞队列)是一个接口,继承自 Queue
。BlockingQueue
阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。
BlockingQueue
常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。
为了应对不同的业务场景,BlockingQueue 提供了4 组不同的方法用于插入、移除以及对队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每组方法的表现是不同的。这些方法如下:
抛异常 | 特定值 | 阻塞 | 超时 | |
---|---|---|---|---|
插入 | add(e) | offer(e) | put(e) | offer(e,time,unit) |
移除 | remove() | poll() | take() | poll(time,unit) |
检查 | element() | peek() |
四组不同的行为方式含义如下:
- 抛异常:如果操作无法立即执行,则抛一个异常;
- 特定值:如果操作无法立即执行,则返回一个特定的值(一般是 true / false)。
- 阻塞:如果操作无法立即执行,则该方法调用将会发生阻塞,直到能够执行;
- 超时:如果操作无法立即执行,则该方法调用将会发生阻塞,直到能够执行。但等待时间不会超过给定值,并返回一个特定值以告知该操作是否成功(典型的是true / false)。
31.BlockingQueue是怎么实现的?
BlockingQueue是一个接口,它的实现类有ArrayBlockingQueue、DelayQueue、 LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue等。它们的区别主要体现在存储结构上或对元素操作上的不同,但是对于put与take操作的原理是类似的。下面以ArrayBlockingQueue为例,来说明BlockingQueue的实现原理。
首先看一下ArrayBlockingQueue的构造函数,它初始化了put和take函数中用到的关键成员变量,这两个变量的类型分别是ReentrantLock和Condition。ReentrantLock是AbstractQueuedSynchronizer(AQS)的子类,它的newCondition函数返回的Condition实例,是定义在AQS类内部的ConditionObject类,该类可以直接调用AQS相关的函数。
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair); //非公平锁
notEmpty = lock.newCondition(); //条件对象,用于唤醒指定线程
notFull = lock.newCondition(); //条件对象
}
生产者会向队列中put产品,生产者后会持有锁,此时会向队列中存放产品,如果队列满了,则会阻塞自己,并且在最后会释放锁。
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock; //生产者加锁
lock.lockInterruptibly();
try {
while (count == items.length) //如果队列满了,则会阻塞
notFull.await();
enqueue(e);
} finally {
lock.unlock(); //释放锁
}
}
再来看一下消费者调用的take函数,take函数在队列为空时会被阻塞,一直到阻塞队列加入了新的元素。
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;//消费者获取锁
lock.lockInterruptibly();
try {
while (count == 0) notEmpty.await();//如果队列满了,则会阻塞
return dequeue();
} finally {
lock.unlock();//释放锁
}
}
扩展阅读
await操作:
我们发现ArrayBlockingQueue并没有使用Object.wait,而是使用的Condition.await,这是为什么呢?Condition对象可以提供和Object的wait和notify一样的行为,但是后者必须先获取synchronized这个内置的monitor锁才能调用,而Condition则必须先获取ReentrantLock。这两种方式在阻塞等待时都会将相应的锁释放掉,但是Condition的等待可以中断,这是二者唯一的区别。
我们先来看一下Condition的await函数,await函数的流程大致如下图所示。await函数主要有三个步骤:
-
是调用addConditionWaiter函数,在condition wait queue队列中添加一个节点,代表当前线程在等待一个消息。
-
调用fullyRelease函数,将持有的锁释放掉,调用的是AQS的函数。
-
一直调用isOnSyncQueue函数判断节点是否被转移到sync queue队列上,也就是AQS中等待获取锁的队列。如果没有,则进入阻塞状态,如果已经在队列上,则调用acquireQueued函数重新获取锁。
signal操作:
signal函数将condition wait queue队列中队首的线程节点转移等待获取锁的sync queue队列中。这样的话,await函数中调用isOnSyncQueue函数就会返回true,导致await函数进入最后一步重新获取锁的状态。
我们这里来详细解析一下condition wait queue和sync queue两个队列的设计原理。condition wait queue是等待消息的队列,因为阻塞队列为空而进入阻塞状态的take函数操作就是在等待阻塞队列不为空的消息。而sync queue队列则是等待获取锁的队列,take函数获得了消息,就可以运行了,但是它还必须等待获取锁之后才能真正进行运行状态。
signal函数其实就做了一件事情,就是不断尝试调用transferForSignal函数,将condition wait queue队首的一个节点转移到sync queue队列中,直到转移成功。因为一次转移成功,就代表这个消息被成功通知到了等待消息的节点。
signal函数的示意图如下所示。
32. Stream(不是IO Stream)有哪些方法?
Stream提供了大量的方法进行聚集操作,这些方法既可以是“中间的”,也可以是“末端的”。
- 中间方法:中间操作允许流保持打开状态,并允许直接调用后续方法。上面程序中的map()方法就是中间方法。中间方法的返回值是另外一个流。
- 末端方法:末端方法是对流的最终操作。当对某个Stream执行末端方法后,该流将会被“消耗”且不再可用。上面程序中的sum()、count()、average()等方法都是末端方法。
除此之外,关于流的方法还有如下两个特征:
- 有状态的方法:这种方法会给流增加一些新的属性,比如元素的唯一性、元素的最大数量、保证元素以排序的方式被处理等。有状态的方法往往需要更大的性能开销。
- 短路方法:短路方法可以尽早结束对流的操作,不必检查所有的元素。
下面简单介绍一下Stream常用的中间方法:
- filter(Predicate predicate):过滤Stream中所有不符合predicate的元素。
- mapToXxx(ToXxxFunction mapper):使用ToXxxFunction对流中的元素执行一对一的转换,该方法返回的新流中包含了ToXxxFunction转换生成的所有元素。
- peek(Consumer action):依次对每个元素执行一些操作,该方法返回的流与原有流包含相同的元素。该方法主要用于调试。
- distinct():该方法用于排序流中所有重复的元素(判断元素重复的标准是使用equals()比较返回true)。这是一个有状态的方法。
- sorted():该方法用于保证流中的元素在后续的访问中处于有序状态。这是一个有状态的方法。
- limit(long maxSize):该方法用于保证对该流的后续访问中最大允许访问的元素个数。这是一个有状态的、短路方法。
下面简单介绍一下Stream常用的末端方法:
- forEach(Consumer action):遍历流中所有元素,对每个元素执行action。
- toArray():将流中所有元素转换为一个数组。
- reduce():该方法有三个重载的版本,都用于通过某种操作来合并流中的元素。
- min():返回流中所有元素的最小值。
- max():返回流中所有元素的最大值。
- count():返回流中所有元素的数量。
- anyMatch(Predicate predicate):判断流中是否至少包含一个元素符合Predicate条件。
- noneMatch(Predicate predicate):判断流中是否所有元素都不符合Predicate条件。
- findFirst():返回流中的第一个元素。
- findAny():返回流中的任意一个元素。
除此之外,Java 8允许使用流式API来操作集合,Collection接口提供了一个stream()默认方法,该方法可返回该集合对应的流,接下来即可通过流式API来操作集合元素。由于Stream可以对集合元素进行整体的聚集操作,因此Stream极大地丰富了集合的功能。
eam常用的末端方法:
- forEach(Consumer action):遍历流中所有元素,对每个元素执行action。
- toArray():将流中所有元素转换为一个数组。
- reduce():该方法有三个重载的版本,都用于通过某种操作来合并流中的元素。
- min():返回流中所有元素的最小值。
- max():返回流中所有元素的最大值。
- count():返回流中所有元素的数量。
- anyMatch(Predicate predicate):判断流中是否至少包含一个元素符合Predicate条件。
- noneMatch(Predicate predicate):判断流中是否所有元素都不符合Predicate条件。
- findFirst():返回流中的第一个元素。
- findAny():返回流中的任意一个元素。
除此之外,Java 8允许使用流式API来操作集合,Collection接口提供了一个stream()默认方法,该方法可返回该集合对应的流,接下来即可通过流式API来操作集合元素。由于Stream可以对集合元素进行整体的聚集操作,因此Stream极大地丰富了集合的功能。