JAVA基础:集合
1.集合类图
2.讲一下对集合的了解
集合的顶级接口是Collection接口,底下有Set、List、Queue三个接口。其中Set的特点为不能存储相同的对象。
Set下面有两个实现类
HashSet:可以做到存放对象唯一(利用对象的hashCode()方法比较对象),但是是无序的。无序的意思是存和取的顺序不一致。底层用HashMap实现。
TreeSet:存放对象唯一,可以做到存取有序,需要存放对象实现Comparable接口。
List主要有三个实现类:
ArrayList:有序、存放元素可以重复。
LinkedList:有序链表、存放元素可以重复。
Vector:是一个线程安全的有序、存放元素可以重复的集合
ArrayList和LinkedList的区别:
ArrayList是特点是查询快,通过下标能快速定位元素,内存中是连续的,成块的,通过偏移量来确定元素位置,但是增删慢,因为其涉及元素的移动。 LinkedList则是查询慢,增删快,他的查询必须要遍历元素,而增删快的原因则是只需要变更元素的头尾节点就可以了。
Queue是一种队列,先入先出,下面主要有两个实现类PriorityQueue和Deque。
Map则是一种以键值对存储的容器,键只能唯一,相同的键会进行覆盖操作。
3.ArrayList和Array(数组)的区别:
1.ArrayList是一个动态集合,支持动态扩容和缩容。而Array只在初始化开始指定大小之后就无法再变更了。因此ArrayList在创建是也无序指定大小,而Array则需要。
2.ArrayList支持元素删除、添加,遍历等常见操作,有很多API方法,Array只是一个定长的数组。只能通过下标访问、操作元素。
3.ArrayList只能存放对象,基本数据类型就只能存放其对应的包装类,可以使用泛型来确保类型安全;Array可以存放基本数据类型。
4.ArrayList的新增和删除的时间复杂度?
新增:
1.头部新增:需要在头部新增一个元素后,后面所有元素都需要往后移动一位,所以时间复杂度是O(n)
2.尾部新增:如若不需要扩容,只需要在最后新增一个元素即可,所以这时的时间复杂度是O(1);但是如果需要扩容,则需要将原有元素复制到新的扩容后的ArrayList中,这里时间复杂度为O(n),最后在尾部新增元素,时间复杂度为O(1).
3.中间指定位置新增:中间新增需要把新增目标位置后的所有的元素都进行往后移一位。平均需要移动n/2个元素,所以时间复杂度是O(n).
删除:
1.头部删除:后续元素往前移,时间复杂度O(n);
2.尾部新增:只需删除最后一个元素,时间复杂度O(1);
3.中间删除:需要将目标删除位置之后的所有元素都往前移动,所以时间复杂度为O(n);
5.LinkedList的插入和删除元素的时间复杂度?
1.头部插入|删除和尾部插入|删除都无需遍历,只需要在指定位置改变其节点即可,时间复杂度为O(1).
2.指定位置插入:因为需要找到插入位置,所以需要遍历到指定位置,这里时间复杂度为O(n)。
6.为什么LinkedList不能实现RandomAccess接口?
RandomAccess是一个标记接口,表示该接口实现了随机访问。因为LinkedList在内存上不是连续的,只能通过指针去遍历访问,所以无法实现所及访问。
7.LinkedList和ArrayList的区别?
1.线程安全:都不是线程安全的集合。
2.底层实现:ArrayList的底层实现是Object数组,而LinkedList的底层则是双向链表。
3.插入删除操作:
ArrayList:头部或尾部插入、删除,在不涉及扩容(新增操作)的情况下,时间复杂度是O(1)。在指定位置插入删除是,需要平均移动n/2的元素,所时间复杂度是O(n).
LinkedList:头部和尾部新增、删除都时间复杂度都是O(1),在指定位置删除、新增时则因为需要先遍历,所以时间复杂度为O(n).
3.是否支持随机访问:ArrayList是内存连续的,所以支持;LinkedList则只是支持节点去访问,内存不连续,所以不支持随机访问。
4.内存消耗:ArrayList在内存中的多余消耗则是在尾部会预留一部分容量位置;而LinkedList则是每一个元素因为要多存储它的节点信息,所以所需要的空间也大。
注:工作中一般只用ArrayList,两者使用性能其实相差不大。
***** 8.ArrayList的扩容机制:
1.通过构造器去创建ArrayList的时候,并未直接创建指定大小的Object数组:
private static final int DEFAULT_CAPACITY = 10; private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; /** *默认构造函数,使用初始容量10构造一个空列表(无参数构造) */ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; }2.在进行新增元素的时候会执行以下代码:
/** * 将指定的元素追加到此列表的末尾。 */ public boolean add(E e) { //添加元素之前,先调用ensureCapacityInternal方法 ensureCapacityInternal(size + 1); // Increments modCount!! //这里看到ArrayList添加元素的实质就相当于为数组赋值 elementData[size++] = e; return true; } //得到最小扩容量 private void ensureCapacityInternal(int minCapacity) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 获取默认的容量和传入参数的较大值 minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } //判断是否需要扩容 private void ensureExplicitCapacity(int minCapacity) { modCount++; // overflow-conscious code if (minCapacity - elementData.length > 0) //调用grow方法进行扩容,调用此方法代表已经开始扩容了 grow(minCapacity); }执行
add()方法,会调用ensureCapacityInternal()方法,ensureCapacityInternal()的作用是得到一个最小的扩容量(默认大小和传入参数比较),然后调用ensureExplicitCapacity()方法,ensureExplicitCapacity()会判断最小容量是否比数组长度大,大了则需要进行扩容。3.扩容方法:
/** * 要分配的最大数组大小 */ private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; /** * ArrayList扩容的核心方法。 */ private void grow(int minCapacity) { // oldCapacity为旧容量,newCapacity为新容量 int oldCapacity = elementData.length; //将oldCapacity 右移一位,其效果相当于oldCapacity /2, //我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍, int newCapacity = oldCapacity + (oldCapacity >> 1); //然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量, if (newCapacity - minCapacity < 0) newCapacity = minCapacity; // 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和 MAX_ARRAY_SIZE, //如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。 if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); }
int newCapacity = oldCapacity + (oldCapacity >> 1);这里是扩容成原来数组的1.5倍左右,如果扩容容量还不满足所需最小容量,则直接取最小容量,如果新容量大于了最大容量则会执行hugeCapacity()方法:rivate static int hugeCapacity(int minCapacity) { if (minCapacity < 0) // overflow throw new OutOfMemoryError(); //对minCapacity和MAX_ARRAY_SIZE进行比较 //若minCapacity大,将Integer.MAX_VALUE作为新数组的大小 //若MAX_ARRAY_SIZE大,将MAX_ARRAY_SIZE作为新数组的大小 //MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; }
9.Comparable和Comparator的区别和作用?
Comparable和Comparator都是用作比较用的。Comparable是java.lang包下的一个接口,用法是实现接口,重写
compareTo(Object obj)方法,后续在将元素放入到TreeSet或TreeMap中是会根据类中的compareTo(Object obj)方法对插入对象进行排序;Comparator则是java.util包下的一个接口,在使用Arrays.sort(T[],new Comparator<T>{})或者Collections.sort(List<T> l , new Comparator<T>{})对集合进行排序时的一种自定义排序规则,使用Comparator需要重写compare()方法。
10.HashSet、LinkedHashSet和TreeSet的异同?
三者都能保证元素唯一性,都是线程不安全的。
底层实现:HashSet底层是有哈希表(HashMap)实现;LinkedHashSet底层是有链表加哈希表实现的,是一个先入先出的集合;TreeSet底层是由红黑树实现的,可以保证存入元素的有序性。
使用场景:HashSet适用于简单的去重,无需保证有序性的场景;LinkedHashSet适用于需要考虑先进先出的场景;TreeSet则适用于需要排序的场景。
11.Queue和Duque的区别?
Queue是一个单端队列,队头出,队尾入。
Queue扩展了Collection的接口。因处理容量问题而导致失败后处理方式不同 可以分为两类方法:
Queue接口 抛出异常 返回特殊值 插入队尾 add() offer() 删除队首 remove() poll() 查看队首 element() peek() Deque是一个双端队列,在队列两端均可插入或删除元素
Deque扩展了Queue的接口,支持在队尾和队首插入删除元素,同样的根据失败后的处理方式分为两类接口
Deque接口 抛出异常 返回特殊值 插入队首 addFirst() offerFirst() 插入队尾 addLast() offerLast() 删除队首 removeFirst() pollFirst() 删除队尾 removeLast() pollLast() 查看队首 getFirst() peekFirst() 查看队尾 getLast() peekLast() Deque 还提供了push()和pop()方法来模拟栈。
12.ArrayDeque和LinkedList的区别?
ArrayDeque和LinkedList均实现了Deque接口,都能实现队列操作。
ArrayDeque的底层是可变长数组加指针实现的;LinkedList是由链表实现的。
ArrayDeque不支持存储null;LinkedList支持存储null
ArrayDeque插入时虽然要考虑扩容问题,但是均摊下来的时间复杂度依然是O(1);LinkedList虽然无需扩容但是每次插入都需要申请分配堆空间,所以均摊下来其实速度没有更快。
13.PriorityQueue
PriorityQueue是二叉堆的数据结构来实现的,底层使用可变长的数组来实现。
PriorityQueue通过堆元素的上浮和下沉,实现了O(logn)的时间复杂度内插入元素和删除堆顶元素。
PriorityQueue是非线程安全的,不支持存储null,且不支持存储non-Comparable的对象
PriorityQueue默认是小顶堆,但是可以接受一个Comparator作为构造参数实现自定义的元素优先级。
14.BlockingQueue
BlockingQueue (阻塞队列)是一个接口,继承自Queue,当队列没有元素时一直阻塞,直到有元素;当队列满时,一直等到队列可以放入时才能放入。常用于生产者消费者模式。
BlockingQueue的实现类:
1.ArrayBlockingQueu:是一种由数组实现的有界阻塞队列,创建时必须指定大小,支持公平和非公平锁。
2.LinkedBlockQueue:是一种由链表实现的有界阻塞队列,创建时可以指定大小,若是不指定大小,则默认是Integer.MAX ,支持公平和非公平锁
3.PriorityBlockQueue:支持优先级排序的无界阻塞队列,存储元素必须实现Comparable接口或者传入Comparator作为构造参数,不支持存储null
4.SynchronousQueue:同步队列,不存储元素的队列,每个插入操作都必须要有对应的删除操作,反之一样。
5.DelayQueue:延迟队列,只有到了其指定的延迟时间,才能从队列中出队。
15.HashMap和HashTable的区别?
线程是否安全:HashMap线程不安全,HashTable线程安全,其内部方法都用
synchronized字段修饰,但是不建议为了线程安全而使用HashTable,更推荐使用CurrentHashMap。是否可以存储null:HashMap的key可以但是只能由一个为null,value可以多个为null;HashTable不允许key或value为null,会报错。
初始容量:HashMap初始容量为16,每次扩容为原来的2倍,HashTable的初始容量为11,每次扩容2n+1。指定大小时HashTable会使用妮指定的大小,而HashMap这回变为它的2的幂次方大小。
底层结构:HashMap在jdk1.7后引入了红黑树的结构,而HashTable则没有。
16.HashMap的构造函数以及对初始容量的设置?
构造函数
public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
tableSizeFor()方法static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }有上两个方法可以知道HashMap的构造方法最大能有两个参数,一个是初始大小,一个是负载因子(默认0.75)。
tableSizeFor()方法则是对初始大小进行处理:假设初始cap-1之后的二进制为000001XXXXX...
n |= n >>> 1得到
n = 000001XXXXX ...| 0000001XXXX... = 0000011XXXX...
n |= n >>> 2得到
n = 0000011XXXX ...| 000000011XX... = 00000001111XX..
.........
最终得到n |= n >>> 16为000000011111111
即约等于cap 的2的幂次方整数。
17.HashMap和HashSet的区别?
HashSet的底层实现用的是HashMap。HashSet只存储对象,利用hashCode()和equals()来判断对象是否重复。HashMap存储的是K-V对,利用hashCode()来判断K是否重复。
18.HashMap和TreeMap的区别?
HashMap和TreeMap都实现了AbstractMap接口,但是TreeMap还实现了NavigableMap和SortedMap接口。
NavigableMap接口让TreeMap有了对集合内元素的搜索的能力;SortedMap接口使得TreeMap的K的有序性。
public class Person { private Integer age; public Person(Integer age) { this.age = age; } public Integer getAge() { return age; } public static void main(String[] args) { TreeMap<Person, String> treeMap = new TreeMap<>(new Comparator<Person>() { @Override public int compare(Person person1, Person person2) { int num = person1.getAge() - person2.getAge(); return Integer.compare(num, 0); } }); treeMap.put(new Person(3), "person1"); treeMap.put(new Person(18), "person2"); treeMap.put(new Person(35), "person3"); treeMap.put(new Person(16), "person4"); treeMap.entrySet().stream().forEach(personStringEntry -> { System.out.println(personStringEntry.getValue()); }); } }
19.HashSet是怎样检查元素重复的?
JDK1.8中HashSet的
add()方法实际上就是调用的HashMap的put()方法:public boolean add(E e) { return map.put(e, PRESENT)==null; }
20.HashMap的底层实现
JDK1.7:
HashMap的底层结构是由数组加链表实现的,在新增元素时,首先会调用扰动函数得到key的hash值,然后再
(n - 1) & hash来确定新增元素在数组的哪一个位置,然后判断改位置上是否有元素,有元素则判断该hash和key值是否相同,相同则直接替换;不相同则通过拉链法来解决冲突。扰动算法是指HashMap的
hash()方法,防止hashCode()方法实现的太差导致碰撞冲突频繁发生。所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
JDK1.8:
引入了红黑树的结构。在当链表长度大于8时,不会立马转化为红黑树,还要判断数组长度是否大于64,不大于64则会有限扩容数组,只有当链表长度大于8且数组长度大于64时才会转变为红黑树。
// 遍历链表 for (int binCount = 0; ; ++binCount) { // 遍历到链表最后一个节点 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 如果链表元素个数大于等于TREEIFY_THRESHOLD(8) if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st // 红黑树转换(并不会直接转换成红黑树) treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; // 判断当前数组的长度是否小于 64 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // 如果当前数组的长度小于 64,那么会选择先进行数组扩容 resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { // 否则才将列表转换为红黑树 TreeNode<K,V> hd = null, tl = null; do { TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) hd.treeify(tab); } }
21.HashMap的长度为什么都是2的幂次方?
为了减少hash碰撞,尽可能的将元素分布在不同的不同的数组上,所以用hash值去取余数组大小来得到一个数来确定存储位置。即为hash%n
,但是当n都是2的幂次方时,hash%n == (n-1)&hash。而在计算机中位与运算比取余运算效率高,所以就设定HashMap的长度都是2的幂次方。
22.HashMap 多线程操作导致死循环问题?
JDK1.7:多线程新增元素时,一个桶位中有多个元素而要进行扩容时,多线程对链表进行操作,头插法可能会导致节点指向错误而导致链表收尾相连而形成循环。 JDK1.8:采用了尾插法的方式进行新增元素,这样无论什么时候都是操作尾部的数据,但是也不希望在多线程中去操作,可能会导致元素被覆盖的情况。
多线程中推荐使用CurrentHashMap
23.HashMap为什么是线程不安全的?
1.插入时将值覆盖:
当两个线程都在同一个桶位新增元素,即为发生了hash冲突,但是第一个插入操作的CPU时间片使用完毕,第二个继续执行,第二个执行完毕之后,因为第一个已经处理完了hash冲突了,只需要处理插入操作了,所以就会将第一个元素给覆盖。
2.两次插入后发现其size变更错误:
两个线程都执行到了
if(++size > threshold)代码处,线程1执行完毕但是线程2中的size还未及时变更,所以两次操作后size++相当于只执行了一次。这样会提高hash冲突的概率而提高第一种线程不安全事故的概率。
24.ConcurrentHashMap 和 Hashtable 的区别?
底层结构:
JDK1.7ConcurrentHashMap 使用的是数组加链表的数据结构,采用的是将容器分段管理,分段加锁;JDK1.8之后采用的是数组加链表加红黑树的数据结构,采用的是
synchronized和CAS来实现的线程安全;JDK1.7和1.8的HashTable没什么区别,底层都是使用的数组加链表的数据结构,内部方法都是使用的是synchronized来保证线程安全。实现线程安全的方式(重要):
- JDK1.7中 ConcurrentHashMap将整个桶分段,每一段都是自己的锁(分段锁)Segment数组加HashEntry数组(数组加链表)的结构,,当两个线程对不同的分段进行操作是不会进行加锁。这样细化了锁的粒度,提高了效率。
- JDK1.8后ConcurrentHashMap摈弃了Segment的操作, Node 数组 + 链表 / 红黑树。使用的是
synchronized和CAS操作来控制同步操作,锁粒度更细,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。 整个看起来就像是优化过且线程安全的HashMap。- Hashtable(同一把锁) :使用 synchronized` 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。