【第一步社招总结】Java的数据结构相关类相关面试总结

此系列总结基于博客 回答阿里社招面试如何准备,顺便谈谈对于Java程序猿学习当中各个阶段的建议,LZ总结了其中的相关问题,并进行了一些归纳。希望可以加深自身的印象。内容借鉴了很多CSDN博主的知识点,如若有造成引用问题,望请原谅。由于LZ是一个刚毕业的菜鸟,其中很多内容都较为片面,希望广大阅读者多多指正,谢谢。


JAVA中数据结构类主要在Java.lang.util包中。其常用类的继承结构如下所示:
Collection{ - List{-LinkedList  -Vector -ArrayList}  -Set{-TreeSet -HashSet}}
Map{ -TreeMap -HashMap -LinkedHashMap}

针对上述若干种类进行分类阐述

一、首先List中的LinkedList、Vector、ArrayList
ArrayList:其底层维护的是一个数组类型,默认长度为10。当然也可以制定initialCapacity。在JDK8之后,其构造函数中默认创建的对象中维护的数组长度为0。数组会在第一次执行add方法时,通过ensureCapacityInternal方法中的calculateCapacity函数去判断当前size和默认的长度10的大小之后,再判断是否需要对数组进行扩容操作。
private static int calculateCapacity(Object[] elementData, int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        return Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    return minCapacity;
}

扩容操作在grow函数中进行。新的长度为旧的长度的3/2倍。(>>1),得到新的长度若仍小于所需最小容量,则新的长度=最小容量。随后回去确保新的长度是否大于最大长度。最后利用Arrays的copyOf方法复制数组。

ArrayList中的相关操作都是针对于数组层面的,相对较简单。这里就不一一介绍。简单说一下其中fastRemove方法:该方法是在移除元素时,会先快速定位到需要移动到的元素的索引,并将index后面的元素往前移动一位之后,再将最后一位元素置空。调用了System.arrayCopy方法。

可以看到所有的类中都存在modCount,这个变量的意思是用于计数对数据结构修改的次数。其最关键的用处在迭代iterate中用于判断expectedModCount和modCount是否仍然一致。若不一致则会抛出ConcurrentModificationException。

注意:添加元素时进行的扩容操作,在移除元素时只会将位置上的元素置为Null,等待GC回收,但是并不会减少数组的容量。这边会导致空间浪费。可以通过trimToSize和clear方法减少null和清空数组。

Vector:Vector与ArrayList大体相同。最大区别在于Vector是线程安全的,但是ArrayList是线程不安全的。一般若不要求并发问题的情况下,基本采用ArrayList。

还有一点在于Vector的构造函数,在创建时便会初始化一个长度10的数组。随之,其还可以指定扩容大小。且在扩容方面,Vector扩容的大小是扩大到制定的capacityIncrement或增加一倍,并且如若在扩容之后还不够,会直接扩容到指定的index。
private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
     int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
                                    capacityIncrement : oldCapacity);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    elementData = Arrays.copyOf(elementData, newCapacity);
}

LinkedList:其底层维护的是一个继承于AbstractSequencelist链表结构。源码中本身属性很少:链表大小size,头节点first,尾节点last。初始化时size默认为0,first和last均为Null。
add:自动在链表末尾添加+1,size+1;或指定index,先判断长度是否合法,随后若index=size,则相当于添加至末尾,否则先索引到index位置,然后再添加元素
void linkBefore(E e, Node<E> succ) {
    // assert succ != null;
    final Node<E> pred = succ.prev;
    final Node<E> newNode = new Node<>(pred, e, succ);
    succ.prev = newNode;
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    size++;
    modCount++;
}
get:判断索引有无越界之后,再判断索引的位置与当前链表长度的一半的大小,若索引位置小于长度一半,则从头开始遍历。反之从尾开始遍历。

     其他方法不多加阐述。
    
 List的相关问题可能只会涉及到三者之间的区别,故而较为简单。所以也不贴一些源码了,只摘了其中的一两个供大家参考。
区别如下:
1、LinkedList底层维护的是一个继承于AbstractSequenceList的双向链表,而Verctor和ArrayList底层维护的是数组结构。从中也可以看出,LinkedList添加、删除的效率较高,时间复杂度为O(1),但是查找的效率相对较低,时间复杂度为O(n)。而数组查找是根据下标index索引,故而查找修改的效率较高,时间复杂度为O(1),但是添加、删除的效率就比较低了,时间复杂度为O(n).
2、LinkedList和ArrayList都是线程不安全的,而Vector是线程安全的,其很多方法头中都存在Synchronized关键字。
3、数组结构的话在添加删除的过程中会造成空间的浪费。(上面提到过)

二、Set、Map
由于这两种类型在底层中有很多相似之处。故而放在一起总结。

Set不可存储重复元素,尤其是不能保证存储元素的顺序。这里所说的顺序是元素的存储地址。
从HashSet和TreeSet的底层源码可以看出,HashSet主要维护的是一个HashMap,而TreeSet主要维护的是一个TreeMap。故而了解Set集合主要了解相对应的Map类即可。

HashMap:HashMap底层基于哈希表的Map接口实现,底层维护了一个数组和链表。允许Null值和Null键,不可保证元素的迭代顺序。
(HashMap是线程不安全的,然而我们也可以使用公共类Collections的sychronizedMap方法将其变成线程安全。)

HashMap的属性较多。其中较为重要的几个为:
int DEFAULT_INITIAL_CAPACITY = 1 <<4 //HashMap默认容量。这里要注意,HashMap的元素长度必须为2的幂次方倍。为何?
int DEFAULT_LOAD_FACTOR = 0.75f //加载因子(用于决定HashMap中的元素个数超过和值时便需进行扩容操作)
int initialCapacity //HashMap初始化元素容量。默认为16

其中HashMap较为关键的两个地方是扩容和保证元素长度为2的幂次方倍。
1、那么为何initialCapacity必须保证是2的幂次方倍?
    HashMap底层是数组+链表的形式,在这种形式下,我们肯定是希望放置在其中的元素可以尽可能的分布均匀。这样的话查找的效率也会提高很多,不许说在获取到index索引之后还要去遍历链表。
    HashMap确定key的index分为两步。首先通过hash函数返回一个key的hashcode低16位与高16位的异或运算的结果。

这样的结果是尽可能的让hashcode中的1尽可能分布的均匀一点。
随后返回的h&(length-1)进行于运算(等价于对length取模,但是效率更高),得到一个比length小的正数,即为index。在这里便牵扯到了为何数组长度length需要时2的幂次方倍。

参考一些博主的例子,以长度分别为16和15,hashcode分别为8和9举例
length                  16                         15
length-1               1111                       1110
hashcode       1001     1000      1001     1000
     &
h&(length-1)  1001     1000       1000    1000

从上述结果我们可以看出当8和9在与15进行&时,产生的结果均为1000,即8和9会被放置在同一个index之中。查找时需要遍历链表,效率便降低了。但如果8和9与16进行&时,产生的结果一个是1001,一个是1000。会被放置在不同的index之中。并且当长度为奇数时,最后一位便会永远为0,故而任何与0&的结果均为0,这便导致了1001、1101、1011、0001这几个位置永远不会有元素存放,从而造成空间浪费。
2、resize扩容。
当HashMap中的元素个数超过了数组大小*负载因子时,便会进行扩容操作。默认是0.75*16=12,会扩大为原来的2倍。随后会重新计算各个元素在数组中的位置。

3、由于HashMap是线程不安全的,所以会出现很多并发问题。
(1) resize造成死循环。以JDK7说明,在resize的transfer方法中(这部分LZ不是很理解,可以参考https://zhuanlan.zhihu.com/p/21673805)
 Entry<K,V> next = e.next;
 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
 e.next = newTable[i]; //标记[1]
 newTable[i] = e;      //将元素放在数组上
 e = next;             //访问下一个Entry链上的元素
    上述代码容易在多线程的情况下出现死循环的现象。举例来说,例如一个HashMap的容量是2,负载因子为1,存入A、B、C、D。假设A和B的hashcode相等,C和D不相等。线程1放入这第三个值之后,A>B,C,随后会进行一次扩容操作。随后线程1暂停,由线程2执行,此时线程2也存入A、B、C、D,A>B,随后线程1继续执行transfer,拷贝数组之后B>A,然后A>B>A,出现死循环。(2)HashMap是fail-fast机制的    如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。
附加一个HashMap的key的问题:
4、为什么String, Interger这样的wrapper类适合作为键?(引用http://www.importnew.com/22011.html)String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。


5、由于HashMap允许使用Null做为值,故而不可使用get方法去判断是否存在某个key值,因为返回结果如果为null,可能是因为null是值也可能是不存在这个key。故而这里要使用containsKey替换。
还有一些关于JDK8中HashMap的扩容策略以及JDK8的HashMap新特性可以参考以下链接,都非常值得一看。

HashSet:底层是一个HashMap,各种方法基本以HashMap实现。其与HashMap的区别在于,HashSet是Set的实现类,而HashMap是Map的实现类;同时HashSet以对象为元素,而HashMap以映射key-value为对象。

Hashtable:Hashtable和HashMap大体一致,除却Hashtable是线程安全之外。

关于HashMap这一块LZ认为看我转的几个链接之后应该会有一定的认识了。主要还是得研究一下实现方式。

TreeMap:关于TreeMap,最重要的便是它的可排序的特性了。其实现了SortedMap接口,使其可以根据键的自然顺序进行排序,也可以自定义比较器(比较器实现Comparator接口,重写compare方法)或者类实现Comparable接口(重写compareTo方法)。
那么TreeMap是如何保证key的顺序的呢?(put方法)
TreeMap底层维护的是红黑树结构,即二叉平衡树。具体实现较为复杂,对于LZ这种对于数据结构不是很熟悉的人来说理解起来需要一定时间。待明白之后会继续更进。

TreeSet:TreeSet底层维护的是一个TreeMap,基本操作基于TreeMap中的方法。

LinkedHashMap:该类继承于HashMap,其重写了HashMap中的若干方法,从而保证了序列的有序性。关键在于accessOrder参数。该参数用于控制迭代时的节点顺序。当accessOrder为true时,节点按照修改顺序进行排序。若为false,则按照插入顺序排序。
重写put:在LinkedHashMap的put方法的newNode中,在每次创建节点时就会将该新节点放置于链表的尾部。
重写get:将当前访问到的节点移至链表尾部。故而当你的accessOrder为true时,迭代LinkedHashMap时同时进行查找操作,会捯饬fail-fast。
LinkedHashMap底层维护的是一个HashMap+双向链表的结构。一个Before,一个After。

其余重写的方法不一一介绍(主要是LZ也没怎么看)

关于这一块,主要会涉及的问题最基本的就是两两相互之间的区别。接下来LZ进行一些主要归纳
【1】LinkedHashMap和HashMap的区别
  • LinkedHashMap继承于HashMap,但重写了其中的几个方法用以保证输出的顺序。
  • LinkedHashMap在构造函数中增加了accessOrder参数,以控制输出顺序是按插入顺序还是访问顺序。
  • 在插入、访问、修改数据时,LinkedHashMap在accessOrder为true的情况下,被访问到的节点会移至链表尾部,从而会导致fail-fast。
  • LinkedHashMap优化了HashMap中的containsValue方法,通过内部链表之间遍历去对比value值是否相等,而HashMap使用了两层for循环。
附加:为何不重写containsKey方法,也使用内部链表去对比key值?
          containsKey调用的是getNode方法,比较的是key的hash值,只需要要遍历哈希桶即可,去遍历内部链表反而会使效率降低。
【2】HashMap和Hashtable的区别
  • 继承的父类不同。Hashtable继承于Dictionary类,而HashMap继承自AbstractMap类。但均实现了Map.Clonable.Serializable接口。
  • 线程安全性不同。Hashtable中的方法很多是synchronized的,而HashMap中的方法是线程不安全的。
  • HashMap中把contains方法去掉了,改为了containsValue和containsKey。而Hashtable保留了contains、containsValue、containsKey
  • Hashtable中键和值均不允许为Null值,而HashMap可出现一个Null键和多个Null值。但是HashMap中不可由get方法来判断其中是否存在某个键,而应用containsKey判断,因为返回的值可能就Null。
  • Hash值的计算方法不同。Hashtable直接使用的是对象hashcode。而HashMap则在其基础上进行了变化。先进行高位运算之后再进行&运算。
  • 初始大小和扩容方式不同。Hashtable初始大小为11,扩容为2*old+1。而HashMap初始大小默认为16,扩容为2*old。

【3】TreeMap和HashMap的区别
  • 首先两者都是线程不安全的,且均继承于AbstractMap。
    不同在于
  • HashMap底层维护的是数组和链表,而TreeMap底层维护的是红黑树(二叉平衡树)。
  • HashMap是无序的,且无法保证迭代顺序的恒久不变。而TreeMap实现了SortedMap接口,元素可按照自然顺序或自定义比较器方式进行排序。
  • HashMap允许Null值和Null键,但进允许一个Null键,可有多个Null值,但TreeMap不允许Null作为键值。
  • HashMap插入、删除、定位元素的效率较高。
【4】LinkedList、Vector、ArrayList三者的区别
  • LinkedList底层维护的是一个继承于AbstractSequenceList的双向链表,而Verctor和ArrayList底层维护的是数组结构。从中也可以看出,LinkedList添加、删除的效率较高,时间复杂度为O(1),但是查找的效率相对较低,时间复杂度为O(n)。而数组查找是根据下标index索引,故而查找修改的效率较高,时间复杂度为O(1),但是添加、删除的效率就比较低了,时间复杂度为O(n).
  • LinkedList和ArrayList都是线程不安全的,而Vector是线程安全的,其很多方法头中都存在Synchronized关键字。
  • 数组结构的话在添加删除的过程中会造成空间的浪费。(上面提到过)
其实有经验的面试人员都知道一般一类知识点都是连环炮。所以你必须保证你的知识比较充分,才能抗住面试官的连环冲击。当然如果你真的不知道,千万直说不知道,不要敷衍。这一部分的话主要是底层需要多看,熟记并适当扩展即可。

猜你喜欢

转载自blog.csdn.net/qq_32302897/article/details/80988047