Java面试专项——集合专题一

Java面试专项——集合专题一,第1张

集合是JAVA重点中的重点也是面试官必会提问的一点,通常涉及到集合的面试题通常呈现以下几点:难度大、粒度细、重底层、涉及数据结构等基础知识。

目录

集合框架回顾

 List集合及其实现类的特点

ArrayList与源码分析

探究一:initialCapacity?成员变量size?ArrayList中元素个数?ArrayList当前容量?还在傻傻分不清?

探究二:jdk1.8下的ArrayList扩容机制?

探究三:为什么ArrayList是线程不安全的?


集合框架回顾
完整版Collection继承实现树

完整版Map集合继承实现树

 由于集合的继承实现树过于庞大复杂,而且一些接口、抽象类不经常使用,因此把集合的继承实现树简化如下:

简易版

如上图所示,集合分为两大类(Collection与Map),其中List接口与Set接口是Collection众多子接口中的 两个。也可以说JAVA集合分为三大类---List、Set、Map。但是这几个接口各有特点:

  • Collection接口储存的数据无序且数据可重复 
  • List接口存储的数据有序且数据可重复 
  • Set接口储存的数据无序但数据不可重复 
  • Map接口可以存储一组键值对象,提供key到value的映射。key唯一(不可重复)无序,value不唯一(可重复)无序

三大集合存储数据的示意图如下: 

 List集合及其实现类的特点

List集合的主要实现类有ArrayList和LinkedList,分别是数据结构中顺序表和链表的实现。

  • List集合特点:有序,不唯一(可重复)
  • ArrayList特点:    (1)在内存中分配连续的空间,实现了长度可变的数组 (2)优点:遍历元素和随机访问元素的效率比较高(3)缺点:添加和删除元素需要移动大量元素导致效率低下,按照内容查询效率低  
  • LinkedList特点:(1)采用双向链表存储方式(2)缺点:遍历和随机访问元素效率低下(3)优点:插入、删除元素效率比较高   
ArrayList与源码分析

ArrayList类中有几个非常重要的成员变量(DEFAULT_CAPACITY、EMPTY_ELEMENTDATA、DEFAULTCAPACITY_EMPTY_ELEMENTDATA、elementData)。此外还有成员变量size,它用来表示当前集合的数据容量。

 再看类的构造方法

 public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }

   
调用无参数构造方法时将储存数据的Object[]elementData赋值为空数组
    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

   
    public ArrayList(Collection c) {
        elementData = c.toArray();
        if ((size = elementData.length) != 0) {
            // c.toArray might (incorrectly) not return Object[] (see 6260652)
            if (elementData.getClass() != Object[].class)
                elementData = Arrays.copyOf(elementData, size, Object[].class);
        } else {
            // replace with empty array.
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }

由以上源码可知。我们经常调用new ArrayList<>()无参数构造方法来创建对象时,在构造方法内部默认设置的是一个空数组,因此底层数组长度为0;值得注意的是这是jdk1.8的特性,在1.7中底层数组长度为10;我们还注意到调用有参构造方法public ArrayList(int initialCapacity)可以初始化一个initialCapacity容量大小的elementData数组。下面我们以构造方法public ArrayList(int initialCapacity)中形参initialCapacity变量为切入点的来探究一下ArrayList类

探究一:initialCapacity?成员变量size?ArrayList中元素个数?ArrayList当前容量?还在傻傻分不清?

如果你不知道这个问题描述的是什么,那么请看下面一个简单的测试

public static void main(String[] args) {
        ArrayList arrayList=new ArrayList<>(10);
        int index=9;
        int data=9;
        arrayList.add(index,data);
    }

上面的代码,我们调用有参构造方法初始化一个容量为10的ArrayList,那么用于存储数据的elementData数组就是一个容量为10的数组,然后我们在索引为9的位置存储值为9的元素,值9似乎自然而然能存储在elementData数组索引为9的位置,但是我们看一下控制台

根据这个异常错误信息,往下标9的位置插入数据超越了数组的界限。 这就很奇怪了,明明我们ArrayList的initalCapacity是10,为什么下标9会越界呢?

我们根据控制台错误信息点击进入ArrayList类中的add方法、rangeCheckForAdd方法

public void add(int index, E element) {
        rangeCheckForAdd(index);

        ensureCapacityInternal(size + 1);  // Increments modCount!!
        System.arraycopy(elementData, index, elementData, index + 1,
                         size - index);
        elementData[index] = element;
        size++;
    }
private void rangeCheckForAdd(int index) {
        if (index > size || index < 0)
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }

 从代码中可以看出,异常信息就是从rangeCheckForAdd方法中抛出来的,原来ArrayList是使用size属性来确定数组是否越界,并非构造方法public ArrayList(int initialCapacity)中的initialCapacity。 

让我们再来看看add(E e)函数的源代码: 

public boolean add(E e) {
    ensureCapacityInternal(size + 1);
    elementData[size++] = e; // 在末尾插入数据之后,size++
    return true;
}

可以看到,add(E e)函数会在数组的末尾插入数据,并进行size++的 *** 作。

也就是说,只有当插入新数据的时候,size才会往上提升。

同样的,如果我们检查 remove(int index) 的源代码,也会发现它会进行size–的 *** 作。

因此,size表示的是数组中元素的数量,并非数组的容量。

这一点从ArrayList类中对elementDa、size这两个成员变量的注释有中也可以看出: 

ArrayList的容量是elementData数组的长度,ArrayList中包含的元素的个数是size的大小 

在检查了ArrayList的源代码之后,我们可以总结出以下结论:

  1. 在ArrayList中,size与initialCapacity是不同的概念
  2. size指的是ArrayList中元素的数量
  3. initialCapacity指的是在ArrayList底层实现中Object数组的大小,也可以理解为ArrayList的容量
  4. ArrayList是使用size来判断数组是否越界
     

在之前我们为了引出问题,实质上错误地使用了add(int index, E element)方法。要想不触发IndexOutOfBoundsException异常,由上面的源码可以看出,得保证0<=index<=size.也就是说索引值必须为正数并且在ArrayList中已经包含的元素个数的范围之内。如下图所示,假设我们调用有参数构造方法ArrayList arrayList=new ArrayList<>(7),将elementData[]数组容量初始化为7。并且调用add()方法依次在索引 0、1、2、3的位置添加数据 3、2、7、9。我们接下来再调用

add(int index, E element)方法,正确的索引应该是 0~3,而插入索引值为 4、5、6的位置都会触发IndexOutOfBoundsException异常

利用有参数构造方法public ArrayList(int initialCapacity) 来初始化一个ArrayList,这正是阿里巴巴Java开发手册中所建议的,原话是:集合初始化时,指定集合初始值大小。

探究二:jdk1.8下的ArrayList扩容机制?

首先,网上有关“ArrayList扩容机制”的文章很多,众说纷坛,我用的jdk的详细版本是jdk1.8.0_191。本文所展示的源码也是出自jdk1.8.0_191。

我们知道ArrayList有下面三种构造方法:

 public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }

   
调用无参数构造方法时将储存数据的Object[]elementData赋值为空数组
    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

   
    public ArrayList(Collection c) {
        elementData = c.toArray();
        if ((size = elementData.length) != 0) {
            // c.toArray might (incorrectly) not return Object[] (see 6260652)
            if (elementData.getClass() != Object[].class)
                elementData = Arrays.copyOf(elementData, size, Object[].class);
        } else {
            // replace with empty array.
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }

使用每一种构造方法创建ArrayList实例之后,ArrayList初试容量以及扩容机制的具体行为都是不一样的。

下面我们来逐一探讨一下:

1.使用无参数构造方法public ArrayList()创建实例后,ArrayList初试容量以及扩容机制的具体行为。

首先明确,使用无参数构造方法创建ArrayList实例之后,Object[] elementData为空数组;

elementData.length=0;ArrayList成员变量size=0

我们看一下源码:

 public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }
 private static int calculateCapacity(Object[] elementData, int minCapacity) {
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            return Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        return minCapacity;
    }

    private void ensureCapacityInternal(int minCapacity) {
        ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
    }

    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;

        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }
private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        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);
    }

 下面我们自己写一个简单的示例,并打开dubug模式跟踪一下源码

 首先我们进入add(E e)方法,该方法首先调用ensureCapacityInternal(size+1),由于size=0,故该方法传入的形参为1

 然后我们进入ensureCapacityInternal方法,发现该方法中调用了ensureExplicitCapacity方法,而ensureExplicitCapacity方法的形参是calculateCapacity函数的返回值

 我们现在需要计算出calculateCapacity函数的返回值。由于调用无参数构造方法时将储存数据的Object[]elementData赋值为空数组,那么自然而然进入calculateCapacity函数中的if语句。DEFAULT_CAPACITY=10,minCapacity=1 。故calculateCapacity函数的返回值为10。

 calculateCapacity函数的返回值拿到以后,传入到ensureExplicitCapacity函数中,并且该函数中minCapacity=10,而elementData目前为空数组,故elementData.length=0.

该函数中if条件成立,执行grow函数

 在grow函数中

int oldCapacity = elementData.length 执行后oldCapacity=0

int newCapacity = oldCapacity + (oldCapacity >> 1)执行后newCapacity=0

minCapacity=10,if语句条件成立

if (newCapacity - minCapacity < 0){  newCapacity = minCapacity;}执行后newCapacity=10

然后执行elementData = Arrays.copyOf(elementData, newCapacity)将elementData由原来的空数组变成容量为10的数组

 至此add方法中的ensureCapacityInternal执行完毕,接下来执行elementData[size++] = e 将值1加入集合并将size+1

此后,我们再向ArrayList中添加2、3、4、5、6、7、8、9、10。跟踪源码发现,

 添加2、3、4、5、6、7、8、9、10的过程中不再执行grow扩容函数

  但是当我们执行arrayList(11)时,即向集合中添加第11个元素的时候,gorw函数再次执行了

 这次int newCapacity = oldCapacity + (oldCapacity >> 1)执行后newCapacity=15

再经过elementData = Arrays.copyOf(elementData, newCapacity),elementData拓展为容量15的数组。

关于一个整形变量右移一位,num>>1,如果num为正偶数,那么num>>1等价于num/2。

如果num为正奇数,那么num>>1等价于num/2向下取整。所以ArrayLis容量不足时,扩容50%或者扩容到原来的1.5倍的说法并不严谨

但是就这样一直1.5倍地增加,难道就没有上限?其实不然,我们仔细看一下grow函数中的第二个if语句的条件。首先我们明确常量MAX_ARRAY_SIZE的值

 当newCapacity(也即size+size>>1)大于MAX_ARRAY_SIZE时,会触发hugeCapacity函数。如果

>=size+1 > ,那么就扩容到;如果size+1<= 就扩容到; 

如果size+1> 

 则会抛出OutOfMemoryError错误

 2.使用有参数构造方法public ArrayList(initialCapacity)创建实例后,ArrayList初试容量以及扩容机制的具体行为。

首先我们明确调用有参构造方法以后,elementData不再是一个空数组,而是一个容量为initialCapacity的数组

下面我们再来看一个简单的例子:

在这个例子中,我们初始化elementData数组容量为5.然后依次在集合中添加12个数据。跟前面的方法类似,利用debug 调试模式跟踪源码发现。在集合中添加initialCapacity+1个元素之前,ArrayList均不发生扩容行为,添加initialCapacity+1个时,扩容50%。之后依次类推,容量不够时调用grow函数扩容50%。依次类推,最后由hugeCapacity函数确定扩容上限或抛出错误

3.使用有参数构造方法public ArrayList(Collection c)创建实例后,ArrayList初始容量以及扩容机制的具体行为。

首先看看构造方法:

public ArrayList(Collection c) {
        elementData = c.toArray();
        if ((size = elementData.length) != 0) {
            // c.toArray might (incorrectly) not return Object[] (see 6260652)
            if (elementData.getClass() != Object[].class)
                elementData = Arrays.copyOf(elementData, size, Object[].class);
        } else {
            // replace with empty array.
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }

首先参数构造方法public ArrayList(Collection c)创建实例后,elementData是空数组还是装载了一定数量元素的数组,取决于传入的集合,若传入集合为空,那么elementData还是空数组,集合的初始容量为0。如果传入集合中有n个元素,那么新创建的ArrayList实例的初始容量也为n,之后再向ArrayList中添加元素就会触发扩容函数,以50%扩展容量。依次类推,最后由hugeCapacity函数确定扩容上限或抛出错误。

下面我们来总结一下使用无参数构造方法创建ArrarList实例后,ArrayList内部的扩容行为

前面我们是调用add方法来分析具体的扩容行为, 在addAll()方法中呢?扩容机制有没有变化?先看addAll方法

public boolean addAll(Collection c) {
        Object[] a = c.toArray();
        int numNew = a.length;
        ensureCapacityInternal(size + numNew);  // Increments modCount
        System.arraycopy(a, 0, elementData, size, numNew);
        size += numNew;
        return numNew != 0;
    }

 下面看一个例子

 在addAll方法中将调用ensureCapacityInternal()方法进行扩容,并传入 size + numNew = 11

在这个例子中因为初始elementData是一个空数组,符合条件,所以它将返回DEFAULT_CAPACITY和minCapacity中较大的那个,结果是minCapacity较大,所以返回11,这就导致addAll()方法执行结果后ArrayList的容量为11.再调用list.add(12),会触发扩容行为,扩容1.5倍,容量到11+5=16。

此外。ArrayList中提供了一个内部类Itr,实现了Iterator接口,采用内部类可以访问成员变量elementData数组,实现对集合元素的遍历

 public Iterator iterator() {
        return new Itr();
    }

    /**
     * An optimized version of AbstractList.Itr
     */
    private class Itr implements Iterator {
        int cursor;       // index of next element to return
        int lastRet = -1; // index of last element returned; -1 if no such
        int expectedModCount = modCount;

        Itr() {}

        public boolean hasNext() {
            return cursor != size;
        }

        @SuppressWarnings("unchecked")
        public E next() {
            checkForComodification();
            int i = cursor;
            if (i >= size)
                throw new NoSuchElementException();
            Object[] elementData = ArrayList.this.elementData;
            if (i >= elementData.length)
                throw new ConcurrentModificationException();
            cursor = i + 1;
            return (E) elementData[lastRet = i];
        }

明确了ArrayList类的功能,对照源码梳理了类的重要成员变量以及基本方法的实现思路之后,我们尝试一下自己手写一个简单的ArrayList。我们自定义一个MyArrayList类使它也具备ArrayList增添删改的基本功能。


public class MyArrayList {
    //定义数组
    private Object elementData[];
    //定义集合容量
    private int size;
    //定义集合默认容量
    private static final int DEFAULT_CAPACITY=10;
    //定义无参数构造方法
    public MyArrayList(){
        elementData=new Object[DEFAULT_CAPACITY];
    };
    //定义有参数构造方法
    public MyArrayList(int capacity){
        elementData=new Object[capacity];
    };
    //定义向集合中添加元素的方法
    public void add(E element){
        //首先判断数组是否需要扩容
        if(size==elementData.length){
            //创建一个容量为原来数组1.5倍的新数组
Object[] newElementData=new Object[elementData.length+(elementData.length>>1)];
            //将原来数组的数据复制到新数组中
     System.arraycopy(elementData,0,newElementData,0,elementData.length);
     elementData=newElementData;
        }
        elementData[size++]=element;
    }
    //定义向集合中修改元素的方法
    public void set(int index,E element){
        checkIndex(index);
        elementData[index]=element;
    }
    //定义检查索引的方法
    private void checkIndex(int index){
        if (index<0||index>size-1){
            throw new RuntimeException("非法的索引:"+index);
        }
    }
    //定义取集合集合中的元素的方法
    public E get(int index){
        checkIndex(index);
        return (E) elementData[index];
    };
    public void remove(E element){
        for (int i=0;i0){
    /*
 System.arraycopy(Object src,  int  srcPos,Object dest, int destPos,int length)
      Params:
         src – the source array.
         srcPos – starting position in the source array.
         dest – the destination array.
         destPos – starting position in the destination data.
         length – the number of array elements to be copied.
     */
            System.arraycopy(elementData,index+1,elementData,index,moveCount);
            elementData[size-1]=null;
        }
        //如果删除的是数组最后一个元素,不复制数组,直接将size-1位置置null
        if(moveCount==0){
            elementData[size-1]=null;
        }
        size--;
    }
    //定义获取
    public int size(){
        return size;
    }
}
探究三:为什么ArrayList是线程不安全的?

此外,ArrayList是线程不安全的。首先说一下什么是线程不安全:线程安全就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。线程不安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据。 List接口下面有两个实现,一个是ArrayList,另外一个是vector。 从源码的角度来看,因为Vector的方法前加了,synchronized 关键字,也就是同步的意思,因此Vector是线程安全的,而arraylist类中的方法没有加锁虽然线程不安全但是多线程访问该类效率明显高于Vector。 以向集合中添加元素为例,首先看一下ArrayList与Vector类中的add()方法源码

//arrayList类add方法源码
 public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }
//Vector类add方法源码
public synchronized boolean add(E e) {
        modCount++;
        ensureCapacityHelper(elementCount + 1);
        elementData[elementCount++] = e;
        return true;
    }

一个 ArrayList ,在添加一个元素的时候,它可能会有两步来完成: 

  1. 在elementData[size]数组位置添加元素
  2. size++数组容量加1                                                                                                           

在单线程运行的情况下,如果 size = 0,添加一个元素后,此元素在位置 0,而且 Size=1; 
而如果是在多线程情况下,比如有两个线程,线程 A 先将元素存放在位置 0。但是此时 CPU 调度线程A暂停,线程 B 得到运行的机会。线程B也向此 ArrayList 添加元素,因为此时 Size 仍然等于 0 (注意,我们假设的是添加一个元素是要两个步骤,而线程A仅仅完成了步骤1),所以线程B也将元素存放在位置0。然后线程A和线程B都继续运行,都增加 Size 的值。 那好,现在我们来看看 ArrayList 的情况,元素实际上只有一个,存放在位置 0,而 Size 却等于 2。这就是“线程不安全”了。  

本文是自己看过ArrayList之后自己的见解,可能会有错误,如果有不当之处,欢迎留言探讨

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/719588.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-25
下一篇 2022-04-25

发表评论

登录后才能评论

评论列表(0条)

保存