JAVA基础——集合

214 阅读16分钟

JAVA基础:集合

1.集合类图

1.png

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,竞争会越来越激烈效率越低。