面试题-集合
Collection接口下有哪些实现?
为什么set是无序的,list是有序的
这里的无序和有序,是根据添加到集合中元素的顺序,而不是对数据进行排序。
因为set在添加元素的时候,计算元素的hash值,进行存储。所以set集合中的元素不能有重复,也不能保证内部存储是按元素添加的顺序而存储的。
list是按照添加的顺序进行输出的,所以为有序。
ArrayList 与 LinkedList 区别?
是否保证线程安全:都不是线程安全的
底层数据结构:
ArrayList
底层使用的是Object
数组;LinkedList
底层使用的是 双向链表 数据结构插入和删除是否受元素位置的影响:
ArrayList
:采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响,add(E e)
方法,将指定的元素追加到此列表的末尾,时间复杂度就是 O(1)。要是在指定元素位置插入和删除时,时间复杂度为O(n-i),因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。LinkedList
:采用链表存储,如果是在头尾插入或者删除元素,时间复杂度为 O(1)。如果要在指定位置删除和插入时,时间复杂度都为O(n) ,因为需要先移动到指定位置再插入。
是否支持快速随机访问:
LinkedList
不支持高效的随机元素访问,而ArrayList
支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)
方法)。内存空间占用:
ArrayList
的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
使用场景
(1)如果应用程序对数据有较多的随机访问,ArrayList对象要优于LinkedList对象;、
(2)如果应用程序有更多的插入或者删除操作,较少的随机访问,LinkedList对象要优于ArrayList对象;
(3)不过ArrayList的插入,删除操作也不一定比LinkedList慢,如果在List靠近末尾的地方插入,那么ArrayList只需要移动较少的数据,而LinkedList则需要一直查找到列表尾部,反而耗费较多时间,这时ArrayList就比LinkedList要快。
总结:
我们在项目中一般是不会使用到 LinkedList
的,需要用到 LinkedList
的场景几乎都可以使用 ArrayList
来代替,并且,性能通常会更好!就连 LinkedList
的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 LinkedList
HashSet和TreeSet
(1)TreeSet的底层是使用TreeMap实现的。HashSet的底层是使用HashMap实现的。
(2)TreeSet判断两个对象不相等的方式是两个对象通过equals方法返回false,或者通过CompareTo方法比较没有返回0.HashSet是使用hashCode判断的。
(3)TreeSet 是二叉树实现的,Treeset中的数据是自动排好序的,不允许放入null值。HashSet 是哈希表实现的,HashSet中的数据是无序的,可以放入null,但只能放入一个null,两者中的值都不能重复,就如数据库中唯一约束。
ArrayList扩容机制
ArrayList扩容的核心方法:
1 |
|
ArrayList的底层是通过数组实现的,底层创建了长度为10的数组,当容量不够时,则扩容为原来的1.5倍。jdk1.7和1.8中的区别主要是在什么时候初始化,jdk1.7:new时就初始化长度为10的数组,1.8:当在add时才进行初始化,分配长度为10的数组。
在jdk1.8的时候,先创建 一个长度为0的数组,当进行add操作的时候,才进行分配长度为10的数组.看下面的代码:
1 |
|
1 |
|
1 |
|
上述三段代码描述了这样的事情:
- 首先,当我们进行add第一个元素的时候,elementData.length 为 0 (因为还是一个空的 list),因为执行了
ensureCapacityInternal()
方法 ,所以 minCapacity 此时为 10。此时,minCapacity - elementData.length > 0
成立,所以会进入grow(minCapacity)
方法。进入该方法时,旧容量为0,右移一位扩容后,也为0,因此第一个if条件成立,所以新容量就为10,此时elementData.length(容量扩容成 10 了。
- 当 add 第 2 个元素时,minCapacity 为 2,此时 e lementData.length(容量)在添加第一个元素后扩容成 10 了。此时,
minCapacity - elementData.length > 0
不成立,所以不会进入 (执行)grow(minCapacity)
方法。 - 直到添加第 11 个元素,minCapacity(为 11)比 elementData.length(为 10)要大。进入 grow 方法进行扩容。旧容量为10,新容量为15,第一个if和第二个if都不成立,因此新容量为15.
其中hugeCapacity()
方法:如果新容量大于 MAX_ARRAY_SIZE,进入(执行) hugeCapacity()
方法来比较 minCapacity 和 MAX_ARRAY_SIZE,如果 minCapacity 大于最大容量,则新容量则为Integer.MAX_VALUE
,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 Integer.MAX_VALUE - 8
。
System.arraycopy()和
Arrays.copyOf()`方法
联系:
看两者源代码可以发现 copyOf()
内部实际调用了 System.arraycopy()
方法
区别:
arraycopy()
需要目标数组,将原数组拷贝到你自己定义的数组里或者原数组,而且可以选择拷贝的起点和长度以及放入新数组中的位置 copyOf()
是系统自动在内部新建一个数组,并返回该数组。
comparable 和 Comparator 的区别
comparable
接口实际上是出自java.lang
包 它有一个compareTo(Object obj)
方法用来排序comparator
接口实际上是出自 java.util 包它有一个compare(Object obj1, Object obj2)
方法用来排序
定制排序的用法
1 |
|
重写 compareTo 方法实现按年龄来排序,并且类中要实现Comparable
1 |
|
hashmap和hashtable的区别
- hashmap不是线程安全的,hashtable是线程安全的
- hashmao的效率比hashtable高
- hashmap的初始容量为16,扩容为原来的2倍,hashtable初始容量为11,扩容为原来的2n+1
- hashmap允许键值为空,但hashtable不允许
- hashmap继承的是AbstratMap类,但hashtable继承的是Dictionary
HashMap和TreeMap的区别
TreeMap可以对map中的key进行排序。
TreeMap不可以有null的key
HashMap的底层是Array,所以HashMap在添加,查找,删除等方法上面速度会非常快。而TreeMap的底层是一个Tree结构,所以速度会比较慢。
另外HashMap因为要保存一个Array,所以会造成空间的浪费,而TreeMap只保存要保持的节点,所以占用的空间比较小。
HashSet 如何检查重复?
当你把对象加入
HashSet
时,HashSet
会先计算对象的hashcode
值来判断对象加入的位置,同时也会与其他加入的对象的hashcode
值作比较,如果没有相符的hashcode
,HashSet
会假设对象没有重复出现。但是如果发现有相同hashcode
值的对象,这时会调用equals()
方法来检查hashcode
相等的对象是否真的相同。如果两者相同,HashSet
就不会让加入操作成功。
在 JDK1.8 中,HashSet
的add()
方法只是简单的调用了HashMap
的put()
方法,并且判断了一下返回值以确保是否有重复元素。直接看一下HashSet
中的源码:
1 |
|
1 |
|
也就是说,在 JDK1.8 中,实际上无论HashSet
中是否已经存在了某元素,HashSet
都会直接插入,只是会在add()
方法的返回值处告诉我们插入前是否存在相同元素。
hashmap底层实现
hashmap线程不安全,效率高,可以存储null的key和value。
HashMap的底层: 数组+链表 (jdk7及之前) 数组+链表+红黑树 (jdk 8)
底层实现:
jdk 1.7:在实例化之后,底层创建了长度为16的一维数组Node[]。
jdk1.8时,实例化时初始容量为0,当执行put操作时,才扩容到16的一维数组Entry[] table
对于hashmap的put操作:首先对key1计算hash值,此hash值表示在entry中的位置,如果此位置上的数据为空,则直接添加成功。
如果此位置上的数据不为空,说明该位置上存在一个链表,里面包含一个或多个数据,比较key1与他们的hash值
如果key1的hash值与该链表中所有元素的值都不相等,则直接添加成功
如果key1的hash值与链表中已经存在的某一个key2数据的hash值相等,则使用equals方法继续进行比较
如果key1.equas(key2)返回false,则添加成功
否则如果返回true,则将value2替换为value1
在上述添加的过程中,会出现容量不够,扩容问题,
扩容问题:
当超出临界值(初始容量×填充因子=16×0.75=12)时,进行扩容,扩容为原来的2倍,并将原有的数据复制过来。
jdk8 相较于jdk7在底层实现方面的不同:
new HashMap()
:底层没有创建一个长度为16的数组- jdk 8底层的数组是:
Node[]
,而非Entry[]
- 首次调用``put()`方法时,底层创建长度为16的数组
- jdk7底层结构只有:数组+链表。jdk8中底层结构:数组+链表+红黑树。
4.1 形成链表时,七上八下(jdk7:新的元素指向旧的元素。jdk8:旧的元素指向新的元素)
4.2 当数组的某一个索引位置上的元素以链表形式存在的数据个数 > 8 且当前数组的长度 > 64时,此时此索引位置上的所数据改为使用红黑树存储。
为什么jdk8从头插法改变为尾插法
当HashMap达到扩容的条件时候,会把HashMap中的每个元素,重新进行运算Hash值,jdk7通过头插法打入到扩容后的数组中。
解决了头插法在rehash时导致死循环的问题。
扩容时transfer代码如下:
1 |
|
如下所示的hashmap, 有两个元素, 它们的key分别是1和3, 假设再增加一个元素时会触发扩容操作
假设线程1扩容时, 执行完transfer()中的Entry<K,V> next = e.next;
被挂起, 此时e指向1, next指向3, 如下图所示
假设1和3在新的数组中仍然发生哈希碰撞, 假设线程2完成了扩容, 那么此时哈希表的样子如下图所示
可以发现, 由于使用了头插法, 所以3变成了头结点
回到线程1, e指向的是1, next指向的是3, 继续向下执行
当执行完e.next = newTable[i];
便出现了循环链表, 其中,newTable[i]
是3
put()方法
- 如果定位到的数组位置没有元素 就直接插入。
- 如果定位到的数组位置有元素就和要插入的 key 比较,如果 key 相同就直接覆盖,如果 key 不相同,就判断 p 是否是一个树节点,如果是就调用
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)
将元素添加进入。如果不是就遍历链表插入(插入的是链表尾部)
1 |
|
get()方法
1 |
|
首先查找是否与数组的元素相等,如果相等则返回.
如果不相等,并且数组中的某个位置不止一个节点,则判断是否是树,如果是树,则调用方法在树中get.
如果不是树,则在链表中get.
resize()方法
进行的是扩容操作,会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,是非常耗时的。在编写程序中,要尽量避免 resize。
HashMap的遍历方式
迭代器EntrySet
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public class HashMapTest {
public static void main(String[] args) {
// 创建并赋值 HashMap
Map<Integer, String> map = new HashMap();
map.put(1, "Java");
map.put(2, "JDK");
map.put(3, "Spring Framework");
map.put(4, "MyBatis framework");
map.put(5, "Java中文社群");
// 遍历
Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Integer, String> entry = iterator.next();
System.out.println(entry.getKey());
System.out.println(entry.getValue());
}
}
}迭代器KeySet
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public class HashMapTest {
public static void main(String[] args) {
// 创建并赋值 HashMap
Map<Integer, String> map = new HashMap();
map.put(1, "Java");
map.put(2, "JDK");
map.put(3, "Spring Framework");
map.put(4, "MyBatis framework");
map.put(5, "Java中文社群");
// 遍历
Iterator<Integer> iterator = map.keySet().iterator();
while (iterator.hasNext()) {
Integer key = iterator.next();
System.out.println(key);
System.out.println(map.get(key));
}
}
}ForEach EntrySet
1
2
3
4for (Map.Entry<Integer, String> entry : map.entrySet()) {
System.out.println(entry.getKey());
System.out.println(entry.getValue());
}ForEach KeySet
1
2
3
4for (Integer key : map.keySet()) {
System.out.println(key);
System.out.println(map.get(key));
}Lambda
1
2
3
4
5// 遍历
map.forEach((key, value) -> {
System.out.println(key);
System.out.println(value);
});Stream API
1
2
3
4
5// 遍历
map.entrySet().stream().forEach((entry) -> {
System.out.println(entry.getKey());
System.out.println(entry.getValue());
});
总结:
entrySet
的性能比 keySet
的性能高出了一倍之多,因此我们应该尽量使用 entrySet
来实现 Map 集合的遍历。
HashMap初始化时,构造方法传值为10000会发生扩容么
向 HashMap 中存 10000 条数据,初始化时,构造方法传值 10000,会触发扩容吗?
1 |
|
分析源码
在 HashMap 中,提供了一个指定初始容量的构造方法 HashMap(int initialCapacity),这个方法再通过 DEFAULT_LOAD_FACTOR 调用 HashMap 另一个构造方法,初始化 loadFactor 为 0.75。
1 |
|
构造方法初始化了两个成员变量 threshold
和 loadFactor
,其中 threshold
就是用来存储触发 HashMap
扩容的阈值,也就是说,当 HashMap
存储的数据量达到 threshold
时,就会触发扩容。
从改造方法中可以看出,threshold
并没有直接使用传入的 initialCapacity
作为扩容阈值,而是通过 tableSizeFor
方法处理后再赋值给 threshold
。
1 |
|
tableSizeFor 的作用就是寻找大于 cap 的 2 的整数次方,例如如果传入 10,经过 tableSizeFor 处理后返回 16。
还有一个问题,一直在说 threshold 是触发扩容的阈值,即 (initialCapacity * loadFactor
),但是我们再构造方法中使用 tableSizeFor
初始化 threshold
,并没有用到 loadFactor
,其实这一步被移交给 resize
方法实现了,因为我们并没有在构造方法中初始化哈希表数组,扩容阈值 threshold = initialCapacity * loadFactor,这一步在 resize 中完成。
因此此题的结果是:
如果我们从外部传递进来 10000 初始化 initialCapacity
,实际上经过 tableSizeFor
方法处理之后,最终 threshold
就会变成 2 的 14 次幂 16384,再在 resize
方法中乘上负载因子 0.75,实际在不触发扩容的前提下,可存储的数据容量是 12288=(16384 * 0.75)
,用来存放 10000 条数据,绰绰有余,所以并不会触发扩容。
CurrentHashMap的实现原理
CurrentHashMap主要是由于HashMap并发下不安全所诞生的,不支持key和value为空值。
大量的使用了,volatile,final,CAS来减少锁竞争对于性能的影响。
(1)在jdk1.7中的底层实现:
ConcurrentHashMap采用了数组+Segment+分段锁的方式实现。而每一个 Segment
是一个类似于 HashMap
的结构,所以每一个 HashMap
的内部可以进行扩容。但是 Segment
的个数一旦初始化就不能改变,默认 Segment
的个数是 16 个,你也可以认为 ConcurrentHashMap
默认支持最多 16 个线程并发。
内部结构:
分段锁技术:将数据分成一段一段的存储,然后给每一段数据分配一把锁,这样,当一个线程访问其中一段数据的时候,其他段的数据可以同时被其他线程访问,能够实现真正的并发访问,内部图为:
通过观察上图,ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作。(第一次定位到在哪个segment,第二次定位到元素在哪个链表。)
优缺点:
缺点:hash操作的过程比普通hashmap要长
优点:对于写操作时只对所在元素的segment加锁,不影响其他segment。因此可以同时支持segment大小数量的写操作,大大的提高了并发能力
(2)在jdk1.8中的底层实现:
参考了jdk1.8中的hashMap的实现,采用了 数组+链表+红黑树的实现方式来设计。内部采用了大量的CAS操作。
介绍CAS:
CAS是compare and swap的缩写,就是我们所说的比较和交换,cas是一种基于锁的操作,而且是乐观锁,在java中分为乐观锁和悲观锁,其中悲观锁是将资源锁住,等获得锁的线程释放锁之后,下一个线程才能访问,而乐观锁采取一种宽泛的态度,不加锁的方式进行处理,比如:给记录加version来获取数据,性能较悲观锁有很大的提升。
每次进行compareAndSwap方法时,需要cas有3个参数
内存地址
V
,
旧的预期值E
,
要修改的新值N
- 当
E == V
时,修改V
值为N
,返回true- 当
E != V
时,修改失败,返回false
JDK8中彻底放弃了Segment转而采用的是Node,其设计思想也不再是JDK1.7中的分段锁思想。
画图介绍CAS的操作:
Node:保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。
volatile:使用volatile修饰的变量,就具有可见性,直接修改内存,也就是,一个线程修改的结果,其他线程立刻就能看见,volatile只能让被他修饰内容具有可见性
1 |
|
查找性能为O(logN)
源码分析(JDK1.8)
初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27/**
* Initializes table, using the size recorded in sizeCtl.
*/
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 如果 sizeCtl < 0 ,说明另外的线程执行CAS 成功,正在进行初始化。
if ((sc = sizeCtl) < 0)
// 让出 CPU 使用权
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}从源码中可以发现
ConcurrentHashMap
的初始化是通过自旋和 CAS 操作完成的。里面需要注意的是变量sizeCtl
,它的值决定着当前的初始化状态。- -1 说明正在初始化
- -N 说明有N-1个线程正在进行扩容
- 表示 table 初始化大小,如果 table 没有初始化
- 表示 table 容量,如果 table 已经初始化。
put:
- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。
- 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
- 如果当前位置的
hashcode == MOVED == -1
,则需要进行扩容。 - 如果都不满足,则利用 synchronized 锁写入数据。
- 如果数量大于
TREEIFY_THRESHOLD
则要执行树化方法,在treeifyBin
中会首先判断当前数组长度≥64时才会将链表转换为红黑树
get:
- 根据 hash 值计算位置。
- 查找到指定位置,如果头节点就是要找的,直接返回它的 value.
- 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找之。
- 如果是链表,遍历查找之
ConcurrentHashMap中的size方法
在jdk7中有两种方案计算size,第一种方案他会使用不加锁的模式去尝试多次计算 ConcurrentHashMap 的 size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的。 第二种方案是如果第一种方案不符合,他就会给每个 Segment 加上锁,然后计算 ConcurrentHashMap 的 size 返回。
在jdk1.8中:
最大值是 Integer 类型的最大值,但是 Map 的 size 可能超过 MAX_VALUE, 所以还有一个方法 mappingCount()
,JDK 的建议使用 mappingCount()
而不是 size()
。mappingCount()
的代码如下:
1 |
|
无论是 size()
还是 mappingCount()
, 计算大小的核心方法都是 sumCount()
JDK1.8 ConCurrentHashMap的大小 size
通过baseCount
和counterCells
两个变量维护:
(1)在没有并发的情况下,使用一个volatile
修饰的baseCount
变量即可;
(2)当有并发时,CAS
修改 baseCount
失败后,会使用 CounterCell
类,即 创建一个CounterCell
对象,设置其volatile
修饰的value
属性为 1,并将其放在ConterCells
数组的随机位置;
最终在sumCount()
方法中通过累加 baseCount
和CounterCells
数组里每个CounterCell
的值 得出Map
的总大小Size
。
ConcurrentHashMap 和 Hashtable 的区别
ConcurrentHashMap
和 Hashtable
的区别主要体现在实现线程安全的方式上不同。
- 底层数据结构: JDK1.7 的
ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8
的结构一样,数组+链表/红黑二叉树。Hashtable
和 JDK1.8 之前的HashMap
的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; - 实现线程安全的方式(重要):
- 在 JDK1.7 的时候,
ConcurrentHashMap
对整个桶数组进行了分割分段(Segment
,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 - 到了 JDK1.8 的时候,
ConcurrentHashMap
已经摒弃了Segment
的概念,而是直接用Node
数组+链表+红黑树的数据结构来实现,并发控制使用synchronized
和 CAS 来操作。(JDK1.6 以后synchronized
锁做了很多优化) 整个看起来就像是优化过且线程安全的HashMap
,虽然在 JDK1.8 中还能看到Segment
的数据结构,但是已经简化了属性,只是为了兼容旧版本; Hashtable
(同一把锁) :使用synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
- 在 JDK1.7 的时候,
HashMap为什么线程不安全
死循环
因为扩容的时候采用的是头插法,头插法会将链表的顺序翻转。这就是引起死循环的原因。线程1和线程2同时进行扩容时。例如说线程1和线程2指向了3节点,线程1开始操作,使用头插法将节点7插入到了3节点的前面,也就是
3.next=7
。线程2为7.next=3。造成了死循环。数据丢失
数据覆盖
A、B两个线程put时,假设计算出的hash值并对数组取余后是同一个位置,那么就会出现这种情况:A发现这个位置可以插入,但在插入前被挂起了,而B也发现可以插入,并且成功了,那么A继续执行后,由于刚刚已经判断过了是可以插入的,因此会直接把B的值给覆盖掉。
ArrayList为什么是线程不安全的
add元素之后,会出现有些位置的值为null的情况
1
2
3
4
5
6private void add(E e, Object[] elementData, int s) {
if (s == elementData.length) // 先不看
elementData = grow();
elementData[s] = e;
size = s + 1;
}假设有两个线程A和B都执行了add方法,并假设此时size为3,A添加完元素后(即执行完 elementData[s] = e;)被挂起了,B也添加元素,注意,此时size还是3,这会导致两个问题:A写的被覆盖,size为5,但5的位置为null。
数组下标越界
还是假设A、B两个线程,并设此时数组长度为10,而s为9,那么A和B同时进到if判断都不会扩容,现在A在上面代码标注位置挂起了,而B接着执行添加操作,所以size = 10,A继续执行,此时就出现了数组下标越界错误。
size大小不是预期的值
s+1不是原子操作,举个例子,假设现在s = 4,A、B两个线程都去进行s + 1操作,在Java中每个线程是有自己的工作内存的,因此A和B各自都有一份s = 4,然后分别执行s + 1,最后再写会主存。理想情况是A计算s + 1 = 5之后写回主存,然后B再读取,将6写回主存。但事实却是A和B可能同时执行s + 1 = 5然后写会主存,这就导致了size不是我们预期的结果。