Java集合面试题(持续更新)
概念
数组和集合的区别,你用过哪些
- 数组是固定的,一旦创建便不可改变。而集合是可变的,可以动态调整元素个数。
- 数组可以存储基本数据类型和对象类型,但是集合只能存储对象。
- 数组可以使用下标直接访问元素,而集合需要迭代器或者其他方法访问元素
我用过如下Java集合类:
- ArrayList:动态数组,实现了List接口,支持动态增长
- LinkList:双线链表,也实现了List接口,支持快速插入和删除
- HashMap:基于hash表的map实现,存储key-value,通过key快速查找
- HashSet:基于hash实现的set集合,存储唯一元素
- TreeMap:基于红黑树实现的有序map集合,可以按照key的顺序排序
- LinkHashMap:基于hash和双向链表实现的map集合,保持插入顺序和访问速度
- ProorityQueue:优先队列,按照比较器或元素的自然顺序进行排序
说说Java中的集合
List
List是有序的Collection,使用这个接口可以精确的控制每个元素的位置,用户可以根据索引访问list中的元素。
- ArrayList:是容量可变的非线程安全列表,其底层使用的是数组实现,当扩容时,会扩大创建更大的数组,然后将原数组复制到新数组。ArrayList可以对元素快速访问,但是随机插入删除很慢。
- LinkList:本质是一个双向链表,与ArrayList相比,随机查找比较满,但是随机插入和删除比较快
Set
set不允许存在重复的元素,与list不同,set是无序的。
HashSet:是通过HashMap实现的,HashMap的key就是HashSet存储元素,其中的value值都是一样的,使set内元素保持唯一性。由于hashset由hashmap实现的,因此线程不安全。
LinkHashSet:继承自HashSet,使用双向链表保持元素插入的顺序,
TreeSet:通过TreeMap实现,添加数据按照比较规则将其插入合适的位置,保证插入数据的有序性。
Map
是一个键值对集合,存储键值之间的映射。key无序,要求唯一;value不要求有序,允许重复。map没有继承Collection,从map中获取元素时,只要给出键对象,就会返回相应的value。
HashMap:在Java8之前,hashmap采用数组+链表去实现的,数组是hashmap的主体,链表是为了解决hash冲突存储的(拉链法)。【ps:在Java8之前,当一个key要插入hashmap时,需要根据相应的hash公式计算出key该放在数组的哪个位置,但是数组长度有限,很可能出现撞车现象,那么此时我们在数组内存放一个链表,链表内存储键值对,然后将撞车的key插入到头部。这就是拉链法】。但是在Java8之后发生了改变,当链表长度大于8时,会将链表转化为红黑树。
LinkHashMap:继承自hashmap,底层还是基于链表+数组+红黑树数组。但是在上面结构的情况下,添加了一条双向链表,可以保持插入的顺序。
HashTable:数组+链表组成。
TreeMap:红黑树
ConcurrentHashMap:Node数组+链表+红黑树实现,是线程安全的(Java8之前是Segment锁,1.8之后采用synchronized实现)
Java中的线程安全集合是什么
java.util
vector:线程安全的动态数组,其内部方法基本都是经过synchronize修饰,但是如果不需要线程安全,不建议使用,同步是有额外开销的
HashTable:线程安全的hash表,给每个方法加上synchronized关键字,这样锁住的就是整个table对象。
java.util.concurrent
并发Map:
- ConcurrentHashMap:他与HashTable的主要区别是二者加锁力度不同,在Java8之前,ConcurrentHashMap加的是分段锁,也就是Segment,每个Segment都含有table的一部分,这样不同分段之间的并发就不受影响。在Java8之后,他取消了Segment字段,直接在table元素上加锁,实现对每一行进行枷锁,进一步减少了并发冲突的概率。对于put操作,如果key对应的数组为null,则通过CAS操作将其设置为当前值,如果key对应的数组元素不为null,则对该元素使用sychronized关键字申请加锁,如果该链表的长度大于8,则将链表转化为红黑树。
- ConcurrentSkipListMap:基于跳表算法的可排序的并发集合。
并发set:
- ConcurrentSkipListSet:是线程安全的有序集合,底层是基于ConcurrentSkipListMap实现的
- ConcurrentOnWriteAtrraySet:是线程安全的set实现,他是线程安全的无序集合。
并发list:
- CopyOnWriteArrayList:他是ArrayList线程安全的变体,其中所有写操作都通过对底层数组进行全新复制来实现,允许存储null。当对象进行写操作时,使用lock进行同步处理,内部拷贝了原数组,并在新的数组上进行添加操作,最后将新数组覆盖掉旧数组。
Collections和Collention的区别
- Collection时Java集合类的一个基础接口,他定义了一系列的通用操作和方法,如添加、删除、遍历等,用户同意操作和管理。List、Set都是他的实现类
- Collentions是Java提供的一个工具类,位于java.util中,他提供了一系列的静态方法,用于对集合进行操作和算法,比如排序、查找、替换等。这些方法可以对实现了Collent的接口进行操作
遍历集合的方法
普通for循环
List<String> list = new ArrayList<>();for(int i=0;i<list.size();i++){}
增强for循环 也即是for-each
//这里不再定义集合,使用list代表集合for(String element:list){//业务操作
}
Iterator迭代器
用来迭代遍历集合,用户要删除元素的状况
//这里不再定义集合了 使用list代替
Iterator<String> iterator = list.iterator();while(iterator.hasNext()){String ele = iterstor.next();//业务操作
}
使用Java8后的Stream API
//这里不再定义listlist.forEach(element->log.info(element))
List
说一下常见List
线程不安全
- ArrayList:基于动态数组实现,他允许快速的根据下表检索数据,在删除和添加数据时,如果不在末尾或者头部进行操作,那么他需要移动大量元素,性能还是比较低的,适用于频繁的随机访问的场景
- LinkList:基于双向链表实现,在插入和删除元素时,只要删除元素的指针并释放空间即可,但是随机访问元素时,需要从头部开始遍历,大数据量下,还是比较吃性能的。适用于进行频繁插入删除的场景
线程安全
- Vector:与ArrayList相同,底层都是由动态数组实现,由于Vector都是同步的,所以可以保持数据的一致性,但是如果在单线程的环境下,可能会增加系统的开销。
- CopyOnWriteArrayList:在对列表进行更新或修改的时候,底层会创建(复制)一个新的底层数组,在修改、更新时会对数组进行加锁,此时读操作仍在原数组上进行,不会被写操作阻塞,实现了读写分离。更新完毕后,将新数组覆盖旧数组。
List如何一边遍历一边修改
在Java中,如何一边遍历一边修改取决于遍历List的方法。
使用for
在for循环中,是可以一边遍历和修改的
//这里不再定义List,使用list变量代替for(int i=0;i<list.size();i++){list.set(i,list.get(i)*2)
}
使用foreach
非常不建议使用foreach进行一边遍历一边修改,这样很很可能会损坏破坏迭代器内部的构造,foreach底层是基于迭代器实现的,在遍历过程中修改会使得迭代器结构和实际结构不一致,会出现非常意外的状况
//这里的List对象才采用list代替for(Object obj:list){//运行到这里时,一定会报错!obj.set(list.indexOf(obj),obj*2+1)
}
使用迭代器
使用迭代器可以非常完美的进行边遍历边修改,但是修改时要使用迭代器对象,不要使用list对象
//使用list表示List对象Iterator<Integer> iterator = list.iterator();for(iterator.hasNext()){Integer num = iterator.next();//这里注意要使用迭代器对象进行修改iterator.set(num*2);
}
对于线程安全的CopyOnWriteArrayList(),一般来说修改是不会报错的,但是可能会删除旧的数据,因为更新操作是在新数组上进行的。
ArrayList线程安全吗,如何让他变得安全
ArrayList的线程并不是安全的,如果想要让他变成安全可以:
使用Collections 的synchronizedList方法包装成线程安全的List。
List<String> SynchronizedList = Collections.synchronizedList(arrayList);
使用CopyOnWriteArrayList代替ArrayList
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<String>()
使用vector代替ArrayList
Vector<String> vector = new Vector<String>()
好的,那么为什么ArrayList是不安全的,详细说下
ArrayList主要的线程不安全主要是如下原因:
- null值错误(并不是我们add的null)
- 索引越界异常
- size与我们add数量不符
详细解释如下:
我们先看下ArrayList 内的add的源码
其中的作用大概运行流程如下
- ensureCapacityInternal方法判断size+1的空间是否足够,需不需要扩容,需要扩容的话就调用grow方法进行扩容
- 空间足够,在下标为size的位置设置值
- 将集合大小+1
那么这样就很好理解上述三个错误
- 部分值为null:假设此时线程1到达arraylist,现在的下标为9,数组大小为11,无需扩容,cpu让出执行权,此时线程2到达arraylist,现在获取的下标也是9,数组大小为11,无需扩容,此时线程1已经在下标为9的地方set了值,然后size++,此时线程2也在下标为9的地方完成了set(线程1数据丢失),此时线程2页将size++,随后size值为11,此时下标为10的位置就是空,不主动干扰,10的位置就是一直为ull
- 索引越界异常:在上面的情况中,将数组大小改为10,那么此时线程2的size++就会导致索引越界异常
- size与我们add数量不符:其实这个每次都会出现,其实size++这个操作并不是原子性的,他的步骤为三大步:拿出size,size+1,覆盖掉原来的size,假设两个线程交汇到size++这个操作上了,假设size=9。此时线程1拿到的是9,size++后为10,线程2拿到的是9,size++后为10,并且同时覆盖,这样就会导致size最终为10,但是我们的实际add的为11.
好的,说一下ArrayList的扩容机制
好的,ArrayList在添加元素时,如果当前元素的个数已经超过了list的容量上限,那么就会触发我们的扩容机制。扩容机制大致流程如下:
- 计算新的容量,新的容量将会是原容量的1.5倍(JDK10之前的版本),然后检查是否超过最大容量限制。
- 创建新的数组:根据计算的容量创建新的数组
- 将旧数组的元素复制到新数组中
- 更新引用,将旧数组的指针指向新的数组
- 完成扩容
ArrayList的扩容涉及到内存的重新分配和数据的复制,所以需要大量数据时,尽量一次性指定好数组的大小
线程安全的List:CopyOnWriteArrayList是如何实现线程安全的
这个的底层也是通过一个数组来存储数据的,使用avolatile关键字修饰数组,保证当前线程对数组重新赋值后,其他线程可以感知到到。
volatile
是一个关键字,用于修饰变量,表示该变量的值可能会被多个线程并发访问和修改。它的主要作用是确保变量的可见性和禁止指令重排序,但并不保证操作的原子性。
写入操作时,加了一把互斥锁保证线程安全
在写入新元素时,首先会将原来的数组拷贝一份,并且让原来数组的长度+1后得到了一个新的数组,新数组的元素和旧数组一致并多一个长度,然后将新的数据放在最后一个位置,用新数组替换掉老数组即可。
在我们执行替换操作之前,读取是老数组的数据,老数组并没有加锁,可以直接访问。