目录
2、HashSet、LinkedHashSet 和 TreeSet 的异同
4、ArrayList 与 LinkedList 区别?(⭐⭐⭐⭐⭐)
7、ArrayList list=new ArrayList(10) 中的list扩容几次(⭐⭐)
1、为什么要使用HashMap,底层原理是什么?(⭐⭐⭐⭐⭐)
3、HashMap 中的数组长度为什么必须是 2 的幂次方?(⭐⭐⭐)
4、JDK 1.7中HashMap 为什么会形成死循环?(⭐⭐⭐)
9、ConcurrentHashMap 和 Hashtable 的区别
10、JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
摘自javaguide的集合总体框架图:
一、单列集合汇总
1、List, Set, Queue, Map 的区别
-
List:底层基于object[]数组,存储的元素有序、可重复。
-
Set:底层基于HashMap实现,存储的元素无序,不可重复。
-
Queue:单端队列,存储的元素有序、可重复。
-
Map:使用键值对(key-value)存储,key 是无序的、不可重复的。
2、HashSet、LinkedHashSet 和 TreeSet 的异同
- 三者都是
Set
接口的实现类,都能保证元素唯一,并且都不是线程安全的。 - 三者主要区别在于底层实现的数据结构不同:
- HashSet底层基于哈希表,元素无序,不可重复。
- LinkedHashSet底层基于链表和哈希表,元素具有唯一性和有序性(元素顺序满足FIFO)
- TreeSet底层基于红黑树,支持对元素自定义排序规则。
3、ArrayList 和 Array(数组)的区别?
- Array的大小固定;ArrayList可以动态扩容。
- ArrayList允许使用泛型确保类型安全;Array不行。
- ArrayList具备基本的增删改查操作;Array只能下标进行查询,没有动态增删改元素的能力。
- Array既可以存储基本数据类型也可以存储对象;ArrayList只能存储对象,对于基本数据类型,需要将其转化为对应的包装类。
4、ArrayList 与 LinkedList 区别?(⭐⭐⭐⭐⭐)
- 底层数据结构:ArrayList底层使用object[]数组,LinkedList底层使用双向链表。
- 是否支持快速随机访问:ArrayList支持,LinkedList不支持。
- 插入和删除是否受元素位置的影响:
- ArrayList添加元素时默认添加至列表尾部,此时时间复杂度为O(1);但如果在指定位置添加和删除元素时,时间复杂度为O(n)。
- LinkedList在头尾插入和删除时时间复杂度为O(1);但如果在指定位置插入删除元素时间复杂度为O(n)。
5、ArrayList底层原理及扩容机制(⭐⭐⭐⭐)
ArrayList三个构造函数:
-
ArrayList() 默认创建长度为0的数组。
-
ArrayList(int initialCapacity) 创建指定容量的数组。
-
ArrayList(Collection<? extends E> c) 使用集合c的大小作为数组容量。
ArrayList底层实现:
- 底层数据结构:
- 底层采用动态数组object[]实现。
- 初始容量:
- ArrayList初始容量为0,第一次添加数据时扩容为10。
- 扩容逻辑:
- 首先会创建一个新的数组,长度为原始数组的1.5倍(使用位运算),然后使用Arrays.copy方法将老数组的元素copy到新数组中,再将需要添加的新元素添加到新数组中。
int newCapacity = oldCapacity + (oldCapacity >> 1);
6、Comparable 和 Comparator 的区别
Comparable和Comparator都是接口,都可以用来进行比较、排序,可以将Comparable理解为“内部比较器”,Comparator理解为“外部比较器”。
- 实现方式:
- Comparable可以直接在需要进行排序的实体类中实现,重写compateTo方法即可。
- Comparator需要另外创建一个实现Comparator接口的实现类来作为“比较器”,并在排序时将比较器作为参数传入。
- 各自的优缺点:
- Comparable 实现比较简单,但是需要修改源代码。
- Comparator需要新建比较器类,较为复杂,但是不需要修改源代码,并且新建的比较器类可以供多个对象排序使用。
具体参考这篇文章 Comparable和Comparator区别
7、ArrayList list=new ArrayList(10) 中的list扩容几次(⭐⭐)
该语句声明和实例了一个 ArrayList,指定了容量为 10,未进行扩容。
8、如何实现数组和List之间的转换(⭐⭐)
- 数组转List:使用Arrays工具类的asList方法。
- List转数组:使用List的toArray方法。
数组转List后,如果修改了数组内容,list受影响吗?
- 受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,会传入数组对象的引用。
List转数组后,如果修改了List内容,数组受影响吗?
- 不受影响,调用toArray方法之后,底层会进行数组的拷贝,和原来List的元素就没关系了。
二、双列集合汇总
1、为什么要使用HashMap,底层原理是什么?(⭐⭐⭐⭐⭐)
关键点:①底层数据结构 ②工作方式
HashMap是一个集合了查询效率和增删效率的容器,内部存的都是一个个键值对,可以通过访问键值对其进行访问和修改。
- 在jdk1.8以前:
- HashMap底层采用数组+链表的结构。
- 添加数据时,会计算key值对应的哈希值,以定位key存放的位置,然后判断该位置的key是否相同,相同则直接覆盖;不相同即发生哈希冲突,会采用头插法将元素插入链表中。
- 从jdk1.8之后,HashMap底层采用数组+链表+红黑树的结构,发生哈希冲突时采用尾插法将元素插入链表中,并且当链表长度大于阈值(默认为8)时,数组长度大于64时,会将链表改为红黑树进行存储。
2、HashMap 中 hash 值的作用?
HashMap 中的 hash 值是由hash函数产生的,所有键值对存放的位置都是由 hash值和(length-1) 与运算得到的(length必须为2的幂次方,此时与运算等价于对length-1取模)。因此,简单来说hash值就是用来定位某键值对在HashMap中存放的位置的。
3、HashMap 中的数组长度为什么必须是 2 的幂次方?(⭐⭐⭐)
length为2的幂次方可以确保(length-1)的二进制低位都是1,此时hash&(length-1) 等价于 hash%(length-1) ,并且位运算的效率较高。
4、JDK 1.7中HashMap 为什么会形成死循环?(⭐⭐⭐)
JDK 1.7中在链表中添加元素的方式是头插法,当两个线程同时对HashMap进行扩容操作时,可能会形成环形链表,产生死循环。JDK 1.7中采用了尾插法来避免链表导致,从而避免产生环形链表。
具体来说:HashMap扩容时,会将旧HashMap的数据移植到 扩容的新HashMap中,而由于链表的插入方式是头插法,a->b->c 会变成 c->b->a ,旧线程仍然认为a节点后面是b,而b节点后面已经是a了,这里就会产生死循环。
5、HashMap和Hashtable的区别
- 线程安全与效率:
- HashMap线程不安全,但效率相对较高。
- Hashtable线程安全,效率相对较低。
- 对 Null 的支持:
- HashMap可以存储null值,键只能存一个(对应的key为0),值可以存多个。
- Hashtable不可以存储null键和null值。
- 初始容量大小和每次扩容大小:
- 不指定容量:
- HashMap默认初始化大小为 16,之后每次扩充,容量变为原来的 2 倍。
- Hashtable默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。
- 指定容量:
- HashMap会将指定容量扩充为 2 的幂次方大小,即HashMap总是使用 2 的幂作为哈希表的大小。
- Hashtable直接使用指定的容量。
- 不指定容量:
- 扩容方式不同:当容量不足时要进行resize方法,而resize有两个步骤:
- ①扩容:两者扩容大小不一样。
- ②rehash:两者都会重新计算hash值,而两者计算hash的值的方式也不同。(如下代码)
//hashMap计算hash值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//而hashtable直接使用hashcode值作为最终的hash值
- 底层数据结构:
- JDK1.8 以后的 HashMap在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时 ,将链表转化为红黑树以减少搜索时间。
- Hashtable只使用链表解决哈希冲突。
6、HashSet 如何检查重复
当我们将对象加入HashSet时,HashSet会计算该对象的hashcode值,并与HashSet中其他对象作比较:若没有hashcode相同的对象,则该对象不重复,允许加入;若有hashcode相同的对象,还需要使用equals()方法检查两对象是否真的相同,如果相同则不允许加入该对象。
ps:
- hashcode是某个对象的哈希值,相同对象的hashcode值一定相同,不同对象的hashcode值也有可能相同(即哈希冲突)。
7、HashMap 为什么线程不安全?
jdk1.8之前HashMap存在死循环和数据丢失的问题。而数据丢失是所有版本都存在的问题,主要是由于并发情况下多线程同时进行put操作,并发生了哈希冲突,此时线程A在判断完哈希冲突之后阻塞了,线程B将数据插入,然后线程B苏醒之后就会将线程A的数据覆盖掉。
8、HashMap 常见的遍历方式?
主要有四种遍历方式:
-
使用迭代器(Iterator)的方式进行遍历。
//使用迭代器(Iterator)EntrySet 的方式进行遍历. 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()); } //使用迭代器(Iterator)KeySet 的方式进行遍历; Iterator<Integer> iterator1 = map.keySet().iterator(); while (iterator1.hasNext()){ Integer next = iterator1.next(); System.out.println(next); System.out.println(map.get(next)); }
-
使用 For Each的方式进行遍历。
//使用 For Each EntrySet 的方式进行遍历; for(Map.Entry<Integer,String> entry : map.entrySet()){ System.out.println(entry.getKey()); System.out.println(entry.getValue()); } //使用 For Each KeySet 的方式进行遍历; for(Integer key:map.keySet()){ System.out.println(key); System.out.println(map.get(key)); }
-
使用 Lambda 表达式的方式进行遍历。
map.forEach((key,value)->{ System.out.println(key); System.out.println(value); });
-
使用 Streams API 的方式进行遍历。
map.entrySet().stream().forEach((entry)->{
System.out.println(entry.getKey());
System.out.println(entry.getValue());
});
从性能上来说,迭代器是遍历是最快的,使用entryset比使用keyset要快。
9、ConcurrentHashMap 和 Hashtable 的区别
实现线程安全的方式不一样:
- Hashtable几乎在所有添加、删除等方法都加了
synchronized
同步锁,相当于给哈希表加了一个大锁,多线程访问的时候,大量线程会被阻塞,效率低下。 - jdk1.8之前的ConcurrentHashMap采用的是分段锁的思想,将哈希数组切割成若干个segment,每个segment包含n个键值对。 每一个segment都进行加锁,不同的segment不会有锁竞争,因此比Hashtable效率要高。
- jdk1.8之后的ConcurrentHashMap锁粒度更细,没有采用分段锁的策略,而是在元素的节点上采用
CAS + synchronized
操作来保证并发的安全性,仅在链表或红黑树的首节点进行加锁,只要hash值不冲突,就不会产生并发,大大提高了效率。
10、JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
- 线程安全实现方式:JDK 1.7采用分段锁,JDK 1.8锁粒度更细,对每个链表或红黑树的头节点加锁。
- Hash 碰撞解决方法:JDK 1.7采用拉链法(头插法),JDK 1.8采用拉链法(尾插法)+红黑树。
- 并发度:JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。
11、HashMap的put方法的具体流程。(⭐⭐⭐⭐⭐)
- 判断键值对数组是否为空,是的话就执行resize()方法进行扩容。
- 根据键值key计算hash值得到数组索引。
- 判断索引位置是否为空,是的话直接新建节点进行添加;不为空的话,判断索引位置的首个元素是否和key一样,一样就直接覆盖,不一样则:
- 判断索引位置是否为treenode,即判断是否是红黑树,如果是红黑树,直接在树中插入键值对。
- 遍历链表,如果链表长度大于8就转成红黑树,在树中执行插入操作;如果不是大于8,就在链表中执行插入;在遍历过程中判断key是否存在,存在就直接覆盖对应的value值。
- 最后插入成功后,判断存在的键值对数量是否超过阈值,超过了需要进行扩容。
12、HashMap的扩容机制(⭐⭐⭐⭐)
- 在初始化或元素达到阈值时,需要调用resize函数进行扩容,第一次数组长度初始化为16,扩容阈值为12(数组长度*0.75),每次扩容扩大为原来的两倍。
- 扩容之后会创建一个新数组,遍历老数组,将元素移到新数组中:
- 当前节点没有哈希冲突,直接对新数组的长度做模运算,插入当前节点。
- 如果是红黑树,直接走红黑树的添加逻辑。
- 如果是链表,需要遍历链表,(可能需要拆分链表),链表元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上。
13、hashMap的寻址算法(⭐⭐⭐⭐)
- 先计算对象的哈希值,再进行二次哈希,即将哈希值右移16位再进行异或运算,可以使哈希分布更加均匀。
- 最后使用与运算( (capacity-1) & hash))得到对象的索引。
三、其他
1、为什么数组的索引从0开始?
- 数组在根据索引获取元素时,会使用寻址公式:数组首地址 + 索引 * 存储数据的类型大小
- 如果索引从1开始算的话,寻址公式就变为:数组首地址 + (索引-1) * 存储数据的类型大小,底层多运算了一次减法操作,性能会降低。