JAVA基础-集合篇

222 阅读6分钟

背景

写博客的初衷一方面是为了记录自己之前的一些面试经验和常见问题,也为了复盘之前的知识,更系统的学习,也想和大家一起交流学习经验

1、集合的体系结构

image.png

上图是一些常见工具的继承和实现关系,还有很多衍生类也是基于这个基础进行继承或者实现。

Java集合主要由以下几个接口构成

  1. Collection:Collection接口是集合Set、List、Queue的父类接口。主要的实现类有ArrayList,LinkedList、HashSet等
  2. Map:是映射表的基础接口。主要实现类有HashMap、Hashtable、TreeMap等
  3. Iterator:迭代器接口。可通过迭代器遍历集合中的数据

接下来就围绕着这三个关键的接口做详细的描述

2、 Collection体系

2.1 List体系

2.1.1 List常见的实现类

主要的实现类有ArrayList,LinkedList、Vector、Stack等 像ArrayList和LinkedList是线程不安全的集合类,而Vector和Stack底层方法都加入了synchronize关键字来保证线程安全。 LinkedList底层采用的是双向链表的结构储存数据

image.png

而ArrayList、Vector、Stack底层都采用了对象数组进行储存数据

image.png

2.1.2 ArrayList和LinkedList有哪些区别

ArrayList 的特点

  1. ArrayList实现了List, RandomAccess, Cloneable, java.io.Serializable接口。
  2. ArrayList底层采用数组的数据结构,支持随机访问,连续的内存空间,初始化大小是10。
  3. 往容器中添加元素之前会先进行容量和下标的校验,当容量超出原始容量时会以1.5倍容量拓容,并将数据复制到新的数组中。
  4. 如果插入的元素位于数组的非尾部还要对插入元素之后的数据进行右移。删除元素不会进行缩容操作,如果删除的元素在数组的非尾部,需要进行左移,对尾部的元素置空处理,由垃圾回收器回收内存

说到拓容,ArrayList的拓容机制氛围以下几步

  1. 调用 ensureCapacityInternal 确定最小拓容量

image.png

  1. 最小拓容量小于实际元素容量时就会触发拓容

image.png


LinkedList 的特点

  1. Linked实现了List, Deque, Cloneable, java.io.Serializable接口
  2. LinkedList底层使用一个Node数据结构,有前后两个指针,双向链表实现的。相对数组,链表插入效率较高,只需要更改前后两个指针即可;另外链表不存在扩容问题,因为链表不要求存储空间连续,每次插入数据都只是改变last指针;另外,链表所需要的内存比数组要多,因为他要维护前后两个指针;它适合删除,插入较多的场景。另外,LinkedList还实现了Deque接口。

ArrayList 和 LinkedList 共同的特点

  1. FailFast机制:许多非线程安全的集合类在迭代器和for循环都有这个机制,如果一个线程在迭代一个容器时,另一个线程去修改容器会导致modCount不一致,从而抛出“ConcurrentModificationException”异常
  2. 缩容,两者都不会主动触发缩容,而是在删除的时候将元素置为空由GC进行垃圾回收

image.png


2.2 Set体系

2.2.1 Set常见的实现类及其实现原理

set常见实现类 HashSet

image.png

上图是set主要实现原理,底层使用的是HashMap存储数据,我们往set里add元素实际上就是向HashMap中添加元素

2.3 Queue体系

2.3.1 Queue常见的实现类

Queue常见实现类有 PriorityQueue、LinkedList等

PriorityQueue 的特点

优先级队列底层也是采用的对象数组来存放元素

拓容的机制是分级拓容

image.png

另外 PriorityQueue 底层采用的是堆的数据结构来实现的

        ArrayList<Integer> list = new ArrayList<>();
        PriorityQueue<Integer> priorityQueue = new PriorityQueue(11);
        priorityQueue.add(36);
        priorityQueue.add(10);
        priorityQueue.add(12);
        priorityQueue.add(89);
        priorityQueue.add(56);
        priorityQueue.add(27);
        priorityQueue.add(43);
        priorityQueue.add(38);
        priorityQueue.add(98);
        priorityQueue.add(73);
        priorityQueue.add(37);
        Iterator<Integer> iterator = priorityQueue.iterator();
        while (iterator.hasNext()) {
            System.out.println(priorityQueue);
            System.out.println(priorityQueue.poll());
        }
输出结果
[10, 36, 12, 38, 37, 27, 43, 89, 98, 73, 56]
10 
[12, 36, 27, 38, 37, 56, 43, 89, 98, 73]
12 
[27, 36, 43, 38, 37, 56, 73, 89, 98]
27 
[36, 37, 43, 38, 98, 56, 73, 89]
36 
[37, 38, 43, 89, 98, 56, 73]
37 
[38, 73, 43, 89, 98, 56]
38 
[43, 73, 56, 89, 98]
43 
[56, 73, 98, 89]
56 
[73, 89, 98]
73 
[89, 98]
89 
[98]
98 

由上可以看出数组内部的排序并非有序的,但是每次poll的时候队头元素总是最小,当然这个也和compareTo方法有关系

堆的介绍可以看看网上其他博客的介绍,这里不过多赘述 www.cnblogs.com/chengxiao/p…

3、 Map体系

3.1 Map体系

3.1.1 Map常见的实现类

主要实现类有HashMap、TreeMap等实现类.

3.1.2 HashMap的特点 以及 JDK 1.7 和 1.8 前后的区别

初始化容量大小是16,最大容量是2^30,默认负载因子是0.75,链表树化容量是8,红黑树非树化容量是6,树化最小容量是64(即总容量要大于64且链表树化容量达到8才会树化)

HashMap1.7和1.8的区别

image.png

put方法解析

  1. 判断容量是否为空,HashMap采用了懒加载的形式。只有在第一次put的时候才初始化容量

    image.png

  2. 调用hash计算方法计算数组下标,如果数组中不存在该元素则创建一个新的对象

    image.png

    a. hash方法先获取key的hashCode,再将hashCode进行无符号右移16位,与原hashCode进行异或运算。如果不这么操作,在容量很小的时候,(n - 1) & hash 高位的值基本为0,从而增加了Hash碰撞的概率。这样操作可以将高位的特征混入到低位中,降低Hash碰撞的概率

    这是1.8版本的hash函数

    static final int hash(Object key) {
         int h;
         return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    

    这是1.7版本的hash函数

    static int hash(int h) {
         h ^= (h >>> 20) ^ (h >>> 12);
         return h ^ (h >>> 7) ^ (h >>> 4);
     }
    

    两者对比1.8减少了大量的扰动运算,采用了高低位的异或来减少碰撞概率

  3. 反之如果存在值的话

    a. 判断第一个元素是否与之相同,相同的话直接替换

    image.png

    b. 不同的话需要判断是否是红黑书节点,如果是则进行红黑树的插入

    image.png

    c. 不是的话遍历链表把数据插在链表尾部

    image.png

     期间如果达到树化阈值还会进行红黑树的转换
    

    d. 查看是否需要拓容 拓容则调用resize方法

resize方法解析

  1. 判断数组容量是否达到最大阈值达到则不进行拓容
  2. 没达到最大值则进行2倍拓容
  3. 如果还没初始化则将一些初始化容量设置进去
  4. 拓容之后需要将老的数据复制到新的存储位置中
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // 超过最大值就不再扩充了,就只好随你碰撞去吧
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 没超过最大值,就扩充为原来的2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {
        // signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 计算新的resize上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
    }