一、小林-Java集合面试题
1、list可以一边遍历一边修改元素吗?
在 Java 中,List在遍历过程中是否可以修改元素取决于遍历方式和具体的List实现类,以下是几种常见情况:
- 使用普通for循环遍历:可以在遍历过程中修改元素,只要修改的索引不超出
List的范围即可。
import java.util.ArrayList;
import java.util.List;
public class ListTraversalAndModification {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
// 使用普通for循环遍历并修改元素
for (int i = 0; i < list.size(); i++) {
list.set(i, list.get(i) * 2);
}
System.out.println(list);
}
}
- 使用foreach循环遍历:一般不建议在
foreach循环中直接修改正在遍历的List元素,因为这可能会导致意外的结果或ConcurrentModificationException异常。在foreach循环中修改元素可能会破坏迭代器的内部状态,因为foreach循环底层是基于迭代器实现的,在遍历过程中修改集合结构,会导致迭代器的预期结构和实际结构不一致。
import java.util.ArrayList;
import java.util.List;
public class ListTraversalAndModification {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
// 使用foreach循环遍历并尝试修改元素,会抛出ConcurrentModificationException异常
for (Integer num : list) {
list.set(list.indexOf(num), num * 2);
}
System.out.println(list);
}
}
- 使用迭代器遍历:可以使用迭代器的
remove方法来删除元素,但如果要修改元素的值,需要通过迭代器的set方法来进行,而不是直接通过List的set方法,否则也可能会抛出ConcurrentModificationException异常。
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class ListTraversalAndModification {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
// 使用迭代器遍历并修改元素
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
Integer num = iterator.next();
if (num == 2) {
// 使用迭代器的set方法修改元素
iterator.set(4);
}
}
System.out.println(list);
}
}
对于线程安全的List,如CopyOnWriteArrayList,由于其采用了写时复制的机制,在遍历的同时可以进行修改操作,不会抛出ConcurrentModificationException异常,但可能会读取到旧的数据,因为修改操作是在新的副本上进行的。
2、Arraylist和LinkedList的区别,哪个集合是线程安全的?
ArrayList和LinkedList都是Java中常见的集合类,它们都实现了List接口。
- 底层数据结构不同:ArrayList使用数组实现,通过索引进行快速访问元素。LinkedList使用链表实现,通过节点之间的指针进行元素的访问和操作。
- 插入和删除操作的效率不同:ArrayList在尾部的插入和删除操作效率较高,但在中间或开头的插入和删除操作效率较低,需要移动元素。LinkedList在任意位置的插入和删除操作效率都比较高,因为只需要调整节点之间的指针,但是LinkedList是不支持随机访问的,所以除了头结点外插入和删除的时间复杂度都是0(n),效率也不是很高所以LinkedList基本没人用。
- 随机访问的效率不同:ArrayList支持通过索引进行快速随机访问,时间复杂度为O(1)。LinkedList需要从头或尾开始遍历链表,时间复杂度为O(n)。
- 空间占用:ArrayList在创建时需要分配一段连续的内存空间,因此会占用较大的空间。LinkedList每个节点只需要存储元素和指针,因此相对较小。
- 使用场景:ArrayList适用于频繁随机访问和尾部的插入删除操作,而LinkedList适用于频繁的中间插入删除操作和不需要随机访问的场景。
- 线程安全:这两个集合都不是线程安全的,Vector是线程安全的
3、为什么ArrayList不是线程安全的,具体来说是哪里不安全?
在高并发添加数据下,ArrayList会暴露三个问题;
- 部分值为null(我们并没有add null进去)
- 索引越界异常
- size与我们add的数量不符
为了知道这三种情况是怎么发生的,ArrayList,add 增加元素的代码如下:
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
ensureCapacityInternal()这个方法的详细代码我们可以暂时不看,它的作用就是判断如果将当前的新元素加到列表后面,列表的elementData数组的大小是否满足,如果size + 1的这个需求长度大于了elementData这个数组的长度,那么就要对这个数组进行扩容。
大体可以分为三步:
- 判断数组需不需要扩容,如果需要的话,调用grow方法进行扩容;
- 将数组的size位置设置值(因为数组的下标是从0开始的);
- 将当前集合的大小加1
下面我们来分析三种情况都是如何产生的:
- 部分值为null:当线程1走到了扩容那里发现当前size是9,而数组容量是10,所以不用扩容,这时候cpu让出执行权,线程2也进来了,发现size是9,而数组容量是10,所以不用扩容,这时候线程1继续执行,将数组下标索引为9的位置set值了,还没有来得及执行size++,这时候线程2也来执行了,又把数组下标索引为9的位置set了一遍,这时候两个先后进行size++,导致下标索引10的地方就为null了。
- 索引越界异常:线程1走到扩容那里发现当前size是9,数组容量是10不用扩容,cpu让出执行权,线程2也发现不用扩容,这时候数组的容量就是10,而线程1 set完之后size++,这时候线程2再进来size就是10,数组的大小只有10,而你要设置下标索引为10的就会越界(数组的下标索引从0开始);
- size与我们add的数量不符:这个基本上每次都会发生,这个理解起来也很简单,因为size++本身就不是原子操作,可以分为三步:获取size的值,将size的值加1,将新的size值覆盖掉原来的,线程1和线程2拿到一样的size值加完了同时覆盖,就会导致一次没有加上,所以肯定不会与我们add的数量保持一致的;
4、ArrayList 和 LinkedList 的应用场景?
- ArrayList适用于需要频繁访问集合元素的场景。它基于数组实现,可以通过索引快速访问元素,因此在按索引查找、遍历和随机访问元素的操作上具有较高的性能。当需要频繁访问和遍历集合元素,并且集合大小不经常改变时,推荐使用ArrayList
- LinkedList适用于频繁进行插入和删除操作的场景。它基于链表实现,插入和删除元素的操作只需要调整节点的指针,因此在插入和删除操作上具有较高的性能。当需要频繁进行插入和删除操作,或者集合大小经常改变时,可以考虑使用LinkedList。
5、ArrayList的扩容机制说一下
ArrayList在添加元素时,如果当前元素个数已经达到了内部数组的容量上限,就会触发扩容操作。ArrayList的扩容操作主要包括以下几个步骤:
- 计算新的容量:一般情况下,新的容量会扩大为原容量的1.5倍(在JDK 10之后,扩容策略做了调整),然后检查是否超过了最大容量限制。
- 创建新的数组:根据计算得到的新容量,创建一个新的更大的数组。
- 将元素复制:将原来数组中的元素逐个复制到新数组中。
- 更新引用:将ArrayList内部指向原数组的引用指向新数组。
- 完成扩容:扩容完成后,可以继续添加新元素。
ArrayList的扩容操作涉及到数组的复制和内存的重新分配,所以在频繁添加大量元素时,扩容操作可能会影响性能。为了减少扩容带来的性能损耗,可以在初始化ArrayList时预分配足够大的容量,避免频繁触发扩容操作。
之所以扩容是 1.5 倍,是因为 1.5 可以充分利用移位操作,减少浮点数或者运算时间和运算次数。
// 新容量计算
int newCapacity = oldCapacity + (oldCapacity >> 1);
6、线程安全的 List, CopyonWriteArraylist是如何实现线程安全的
CopyOnWriteArrayList底层也是通过一个数组保存数据,使用volatile关键字修饰数组,保证当前线程对数组对象重新赋值后,其他线程可以及时感知到。
private transient volatile Object[] array;
在写入操作时,加了一把互斥锁ReentrantLock以保证线程安全。
public boolean add(E e) {
//获取锁
final ReentrantLock lock = this.lock;
//加锁
lock.lock();
try {
//获取到当前List集合保存数据的数组
Object[] elements = getArray();
//获取该数组的长度(这是一个伏笔,同时len也是新数组的最后一个元素的索引值)
int len = elements.length;
//将当前数组拷贝一份的同时,让其长度加1
Object[] newElements = Arrays.copyOf(elements, len + 1);
//将加入的元素放在新数组最后一位,len不是旧数组长度吗,为什么现在用它当成新数组的最后一个元素的下标?建议自行画图推演,就很容易理解。
newElements[len] = e;
//替换引用,将数组的引用指向给新数组的地址
setArray(newElements);
return true;
} finally {
//释放锁
lock.unlock();
}
}
看到源码可以知道写入新元素时,首先会先将原来的数组拷贝一份并且让原来数组的长度+1后就得到了一个新数组,新数组里的元素和旧数组的元素一样并且长度比旧数组多一个长度,然后将新加入的元素放置都在新数组最后一个位置后,用新数组的地址替换掉老数组的地址就能得到最新的数据了。
在我们执行替换地址操作之前,读取的是老数组的数据,数据是有效数据;执行替换地址操作之后,读取的是新数组的数据,同样也是有效数据,而且使用该方式能比读写都加锁要更加的效率。
现在我们来看读操作,读是没有加锁的,所以读是一直都能读
public E get(int index) {
return get(getArray(), index);
}
7、Map
常见的Map集合(非线程安全):
HashMap是基于哈希表实现的Map,它根据键的哈希值来存储和获取键值对,JDK 1.8中是用数组+链表+红黑树来实现的。HashMap是非线程安全的,在多线程环境下,当多个线程同时对HashMap进行操作时,可能会导致数据不一致或出现死循环等问题。比如在扩容时,多个线程可能会同时修改哈希表的结构,从而破坏数据的完整性。LinkedHashMap继承自HashMap,它在HashMap的基础上,使用双向链表维护了键值对的插入顺序或访问顺序,使得迭代顺序与插入顺序或访问顺序一致。由于它继承自HashMap,在多线程并发访问时,同样会出现与HashMap类似的线程安全问题。TreeMap是基于红黑树实现的Map,它可以对键进行排序,默认按照自然顺序排序,也可以通过指定的比较器进行排序。TreeMap是非线程安全的,在多线程环境下,如果多个线程同时对TreeMap进行插入、删除等操作,可能会破坏红黑树的结构,导致数据不一致或程序出现异常。
常见的Map集合(线程安全):
Hashtable是早期 Java 提供的线程安全的Map实现,它的实现方式与HashMap类似,但在方法上使用了synchronized关键字来保证线程安全。通过在每个可能修改Hashtable状态的方法上加上synchronized关键字,使得在同一时刻,只能有一个线程能够访问Hashtable的这些方法,从而保证了线程安全。ConcurrentHashMap在 JDK 1.8 以前采用了分段锁等技术来提高并发性能。在ConcurrentHashMap中,将数据分成多个段(Segment),每个段都有自己的锁。在进行插入、删除等操作时,只需要获取相应段的锁,而不是整个Map的锁,这样可以允许多个线程同时访问不同的段,提高了并发访问的效率。在 JDK 1.8 以后是通过 volatile + CAS 或者 synchronized 来保证线程安全的。
8、如何对map进行快速遍历?
- 使用for-each循环和entrySet()方法:这是一种较为常见和简洁的遍历方式,它可以同时获取
Map中的键和值
import java.util.HashMap;
import java.util.Map;
public class MapTraversalExample {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("key1", 1);
map.put("key2", 2);
map.put("key3", 3);
// 使用for-each循环和entrySet()遍历Map
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
}
}
- 使用for-each循环和keySet()方法:如果只需要遍历
Map中的键,可以使用keySet()方法,这种方式相对简单,性能也较好。
import java.util.HashMap;
import java.util.Map;
public class MapTraversalExample {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("key1", 1);
map.put("key2", 2);
map.put("key3", 3);
// 使用for-each循环和keySet()遍历Map的键
for (String key : map.keySet()) {
System.out.println("Key: " + key + ", Value: " + map.get(key));
}
}
}
- 使用迭代器:通过获取Map的entrySet()或keySet()的迭代器,也可以实现对Map的遍历,这种方式在需要删除元素等操作时比较有用。
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
public class MapTraversalExample {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("key1", 1);
map.put("key2", 2);
map.put("key3", 3);
// 使用迭代器遍历Map
Iterator<Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Entry<String, Integer> entry = iterator.next();
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
}
}
- 使用 Lambda 表达式和forEach()方法:在 Java 8 及以上版本中,可以使用 Lambda 表达式和
forEach()方法来遍历Map,这种方式更加简洁和函数式。
import java.util.HashMap;
import java.util.Map;
public class MapTraversalExample {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("key1", 1);
map.put("key2", 2);
map.put("key3", 3);
// 使用Lambda表达式和forEach()方法遍历Map
map.forEach((key, value) -> System.out.println("Key: " + key + ", Value: " + value));
}
}
- 使用Stream API:Java 8 引入的
Stream API也可以用于遍历Map,可以将Map转换为流,然后进行各种操作。
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
public class MapTraversalExample {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("key1", 1);
map.put("key2", 2);
map.put("key3", 3);
// 使用Stream API遍历Map
map.entrySet().stream()
.forEach(entry -> System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue()));
// 还可以进行其他操作,如过滤、映射等
Map<String, Integer> filteredMap = map.entrySet().stream()
.filter(entry -> entry.getValue() > 1)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
System.out.println(filteredMap);
}
}
9、HashMap实现原理介绍一下?
在 JDK 1.7 版本之前, HashMap 数据结构是数组和链表,HashMap通过哈希算法将元素的键(Key)映射到数组中的槽位(Bucket)。如果多个键映射到同一个槽位,它们会以链表的形式存储在同一个槽位上,因为链表的查询时间是O(n),所以冲突很严重,一个索引上的链表非常长,效率就很低了。 所以在 JDK 1.8 版本的时候做了优化,当一个链表的长度超过8的时候就转换数据结构,不再使用链表存储,而是使用红黑树,查找时使用红黑树,时间复杂度O(log n),可以提高查询性能,但是在数量较少时,即数量小于6时,会将红黑树转换回链表。
10、了解的哈希冲突解决方法有哪些?
- 链接法:使用链表或其他数据结构来存储冲突的键值对,将它们链接在同一个哈希桶中。
- 开放寻址法:在哈希表中找到另一个可用的位置来存储冲突的键值对,而不是存储在链表中。常见的开放寻址方法包括线性探测、二次探测和双重散列。
- 再哈希法(Rehashing):当发生冲突时,使用另一个哈希函数再次计算键的哈希值,直到找到一个空槽来存储键值对。
- 哈希桶扩容:当哈希冲突过多时,可以动态地扩大哈希桶的数量,重新分配键值对,以减少冲突的概率。
11、hashmap的put过程介绍一下
HashMap HashMap的put()方法用于向HashMap中添加键值对,当调用HashMap的put()方法时,会按照以下详细流程执行(JDK8 1.8版本):
第一步:根据要添加的键的哈希码计算在数组中的位置(索引)。
第二步:检查该位置是否为空(即没有键值对存在)
- 如果为空,则直接在该位置创建一个新的Entry对象来存储键值对。将要添加的键值对作为该Entry的键和值,并保存在数组的对应位置。将HashMap的修改次数(modCount)加1,以便在进行迭代时发现并发修改。
第三步:如果该位置已经存在其他键值对,检查该位置的第一个键值对的哈希码和键是否与要添加的键值对相同?
- 如果相同,则表示找到了相同的键,直接将新的值替换旧的值,完成更新操作。
第四步:如果第一个键值对的哈希码和键不相同,则需要遍历链表或红黑树来查找是否有相同的键:
如果键值对集合是链表结构,从链表的头部开始逐个比较键的哈希码和equals()方法,直到找到相同的键或达到链表末尾。
- 如果找到了相同的键,则使用新的值取代旧的值,即更新键对应的值。
- 如果没有找到相同的键,则将新的键值对添加到链表的头部。
如果键值对集合是红黑树结构,在红黑树中使用哈希码和equals()方法进行查找。根据键的哈希码,定位到红黑树中的某个节点,然后逐个比较键,直到找到相同的键或达到红黑树末尾。
- 如果找到了相同的键,则使用新的值取代旧的值,即更新键对应的值。
- 如果没有找到相同的键,则将新的键值对添加到红黑树中。
第五步:检查链表长度是否达到阈值(默认为8):
- 如果链表长度超过阈值,且HashMap的数组长度大于等于64,则会将链表转换为红黑树,以提高查询效率。
第六步:检查负载因子是否超过阈值(默认为0.75):
- 如果键值对的数量(size)与数组的长度的比值大于阈值,则需要进行扩容操作。
第七步:扩容操作:
- 创建一个新的两倍大小的数组。
- 将旧数组中的键值对重新计算哈希码并分配到新数组中的位置。
- 更新HashMap的数组引用和阈值参数。
第八步:完成添加操作。
此外,HashMap是非线程安全的,如果在多线程环境下使用,需要采取额外的同步措施或使用线程安全的ConcurrentHashMap。