Java面试题003:一文深入了解JAVA集合

欢迎大家关注我的微信公众号:吾城十二楼,文章是从公众号直接复制粘贴过来的,排版不太美观。

10、JAVA集合

集合相关类和接⼝都在java.util中,主要分为3种:List(列表)、Map(映射)、Set(集)。

图片

其中 Collection 是集合 List 、 Set 的⽗接⼝,它主要有两个⼦接⼝:

List :存储的元素有序,可重复。

Set :存储的元素⽆序,不可重复。

Map 是另外的接⼝,是键值对映射结构的集合。

图片

1、List

图片

(1)ArrayList:底层数据结构是数组,查询快,增删慢,线程不安全,效率高,可以存储重复元素。

图片

ArrayList 实现了RandomAccess 接口, RandomAccess 是一个标志接口,表明实现这个这个接口的 List 集合是支持快速随机访问的。在 ArrayList 中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。get(int index)可以直接通过数组下标获取,时间复杂度是O(1)。

图片

ArrayList增删如果是数组末尾的位置,直接插⼊或者删除就可以了,但是如果插⼊中间的位置,就需要把插⼊位置后的元素都向前或者向后移动,甚⾄还有可能触发扩容,这就是为什么它增删慢的原因。

图片

ArrayList 的底层是数组队列,相当于动态数组。与 Java 中的数组相比,它的容量能动态增长。在添加大量元素前,应用程序可以使用 ensureCapacity 操作来增加 ArrayList 实例的容量。

    在内存占⽤方面,ArrayList基于数组,是⼀块连续的内存空间,由于是预先定义好的数组,可能会有空的内存空间,存在⼀定空间浪费。

扩容技术核心源码:

/*** 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;    //再检查新容量是否超出了ArrayList所定义的最大容量,  //若超出了,则调用hugeCapacity()来比较minCapacity和 MAX_ARRAY_SIZE,  //如果minCapacity大于MAX_ARRAY_SIZE,则新容量则为Interger.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE。  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);}

ArrayList 实现了Cloneable 接口,即覆盖了函数 clone(),能被克隆。

ArrayList 实现java.io.Serializable 接口,这意味着ArrayList支持序列化,能通过序列化去传输。

ArrayList 中的操作不是线程安全的!所以,建议在单线程中才使用 ArrayList,而在多线程中可以选择 Vector 或者 CopyOnWriteArrayList。

(2)LinkedList 底层数据结构是链表,查询慢,增删快,线程不安全,效率高,可以存储重复元素。

LinkedList基于链表的数据结构,地址是任意的,所以在开辟内存空间的时候不需要等一个连续的地址,对于新增和删除操作add和remove,LinedList比较占优势。LinkedList 适用于要头尾操作或插入指定位置的场景

缺点:因为LinkedList要移动指针,所以查询操作性能比较低。LinkedList基于链表,所以它没法根据序号直接获取元素,它没有实现RandmoAccess 接⼝,标记不⽀持随机访问。

它在空间占⽤上都有⼀些额外的消耗,LinkedList每个节点,需要存储前驱和后继,所以每个节点会占⽤更多的空间。

图片

LinkedList类中的一个内部私有类Node

private static class Node<E> {  E item;//节点值  Node<E> next;//后继节点  Node<E> prev;//前驱节点  Node(Node<E> prev, E element, Node<E> next) {    this.item = element;    this.next = next;    this.prev = prev;  }}

这个类就代表双端链表的节点Node。这个类有三个属性,分别是前驱节点,本节点的值,后继节点。

add(E e) 方法:将元素添加到链表尾部

​​​​​​​

public boolean add(E e) {      linkLast(e);      return true;}​​​​​​​
/*** 链接使e作为最后一个元素。*/void linkLast(E e) {  final Node<E> l = last;  final Node<E> newNode = new Node<>(l, e, null);  last = newNode;//新建节点  if (l == null)    first = newNode;  else    l.next = newNode;//指向后继元素也就是指向下一个元素  size++;  modCount++;}

add(int index,E e):在指定位置添加元素

图片

    public void add(int index, E element) {  checkPositionIndex(index); //检查索引是否处于[0-size]之间  if (index == size)//添加在链表尾部    linkLast(element);  else//添加在链表中间    linkBefore(element, node(index));}​​​​​​​
    void linkBefore(E e, Node<E> succ) {        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++;}

    LinkedList不是线程安全的,如果想使LinkedList变成线程安全的,可以调用静态类Collections类中的synchronizedList方法:

    List list=Collections.synchronizedList(new LinkedList(...));

    (3)Vector:底层数据结构是数组,查询快,增删慢,线程安全,效率低,可以存储重复元素

    Vector的数据结构和ArrayList差不多,它包含了3个成员变量:elementData , elementCount, capacityIncrement。

     elementData 是"Object[]类型的数组",它保存了添加到Vector中的元素。elementData是个动态数组,如果初始化Vector时,没指定动态数组的>大小,则使用默认大小10。

    capacityIncrement是与容量增长相关的增长系数,当Vector容量不足以容纳全部元素时,Vector的容量会增加。若容量增加系数 >0,则将容量的值增加“容量增加系数”;否则,将容量大小增加一倍。

     elementCount 是动态数组的实际大小。

    它支持线程的同步,即某一时刻只有一个线程能够写 Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问它比访问 ArrayList 慢。

    (4)CopyOnWriteArrayList是Java集合框架中的一种并发容器。它是ArrayList的线程安全版本,采用了一种特殊的“写时复制”策略,即每次修改操作都会先复制一份当前集合,然后再对新的副本进行修改,最后再用新的副本替换旧的副本。这种策略能够保证并发操作时不会影响到其他线程的读操作,从而保证线程安全性。

    并发访问时不需要使用额外的同步机制,因为每个线程访问的都是不同的副本,读取操作不需要加锁,所以CopyOnWriteArrayList在读取操作上具有很高的性能。

    CopyOnWriteArrayList适合于读多写少的场景,例如日志、观察者模式等。每次修改都需要复制整个数组导致写入操作会产生较大的开销,所以在高并发写入的场景下不适合使用。CopyOnWriteArrayList的写入操作有一定的延迟,因此不适用于实时数据的场景,例如股票、期货等实时数据的更新。

    在使用CopyOnWriteArrayList时要注意规避一些常见的性能陷阱:​​​​​​​

    public class ListUsageGuide {    private final CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
        // 避免频繁修改操作    public void wrongUsage() {        // 错误示范:频繁修改导致性能问题        for (int i = 0; i < 10000; i++) {            list.add("item" + i);  // 每次add都会复制整个数组        }    }
        // 正确的使用方式    public void correctUsage() {        // 正确示范:批量操作        ArrayList<String> temp = new ArrayList<>();        for (int i = 0; i < 10000; i++) {            temp.add("item" + i);        }        list.addAll(temp);  // 只复制一次数组    }}

    2、Set

    是一种不允许包含重复元素的集合。它继承自Collection接口,Set的主要实现类有HashSet、LinkedHashSet和TreeSet。

    • 不允许重复:Set中的元素是唯一的,如果添加重复元素,Set会忽略该操作。

    • 无序性:Set中的元素没有特定的顺序(除了LinkedHashSet和TreeSet)。

    • 允许null元素:Set允许包含一个null元素。

    (1)HashSet
    • 基于哈希表实现,元素无序。

    • 允许null元素。

    • 不是线程安全的。

    • 使用场景:需要快速查找元素,且不关心元素的顺序

    HashSet如何检查重复?

    HashSet会通过元素的hashcode()和equals方法进行判断元素是否重复。

    当你试图把对象加入HashSet时,HashSet会使用对象的hashCode来判断对象加入的位置。同时也会与其他已经加入的对象的hashCode进行比较,如果没有相等的hashCode,HashSet就会假设对象没有重复出现。

    简单一句话,如果对象的hashCode值是不同的,那么HashSet会认为对象是不可能相等的。

    因此我们自定义类的时候需要重写hashCode,来确保对象具有相同的hashCode值。

    如果元素(对象)的hashCode值相同,是不是就无法存入HashSet中了? 当然不是,会继续使用equals 进行比较.如果 equals为true 那么HashSet认为新加入的对象重复了,所以加入失败。如果equals 为false那么HashSet 认为新加入的对象没有重复.新元素可以存入.

    (2) LinkedHashSet

    • 继承自HashSet,基于哈希表和链表实现。

    • 元素按照插入顺序排列。

    • 允许null元素。

    • 不是线程安全的。

    • 使用场景:需要快速查找元素,且需要保持插入顺序。

    (3) TreeSet

    • 基于红黑树实现,元素有序。

    • 不允许null元素。

    • 不是线程安全的。

    • 使用场景:需要元素有序且快速查找。

    Java提供了以下几种线程安全的Set实现:

    1) Collections.synchronizedSet:将普通Set转换为线程安全的Set

      Set<String> synchronizedSet = Collections.synchronizedSet(new HashSet<>());

    2) CopyOnWriteArraySet:线程安全的Set实现,适合读多写少的场景

    ​​​​​​​

      import java.util.concurrent.CopyOnWriteArraySet;
      Set<String> set = new CopyOnWriteArraySet<>();

    3、Map

    Map用于保存具有映射关系的数据,Map集合里保存着两组值,一组用于保存Map的ley,另一组保存着Map的value。

    图片

    Map中包括一个内部类Entry,该类封装一个键值对,常用方法:

    • Object getKey():返回该Entry里包含的key值;

    • Object getvalue():返回该Entry里包含的value值;

    • Object setValue(V value):设置该Entry里包含的value值,并设置新的value值。

    (1)HashMap

    JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的.

    图片

    横向是一个数组,然后数组中每个元素是一个单向链表。上图中,每个绿色的实体是嵌套类 Entry 的实例,Entry 包含四个属性:key, value, hash 值和用于单向链表的 next。

    查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。

    Java8 由 数组+链表+红黑树 组成。数组是⽤来存储数据元素,链表是⽤来解决冲突,红⿊树是为了提⾼查询的效率。

    图片

    • 数据元素通过映射关系,也就是散列函数,映射到桶数组对应索引的位置

    • 如果发⽣冲突,从冲突的位置拉⼀个链表,插⼊冲突的元素

    • 如果链表⻓度>8 &并且数组⼤⼩>=64,链表转为红⿊树

    • 如果红⿊树节点个数<6 ,转为链表

    【重要】在JDK8中,HashMap中的链表转换为红黑树需要满足以下两个条件:

    1)链表长度条件:

    数组  arr[i] 处存放的链表长度大于8。当链表长度增长到这个阈值时,就有可能需要转换为红黑树结构,这是因为较长的链表会导致查询效率降低,而红黑树结构可以提高查询性能。

    2)数组长度条件:

    数组的长度大于64。如果数组长度小于64,即使链表长度大于8,也不会直接将链表转换为红黑树,而是先进行扩容操作。这是因为在数组长度较小时,扩容可能会使链表长度降低,从而避免不必要的红黑树转换,因为红黑树的操作相对链表来说较为复杂,在数据量较小时链表可能已经足够高效。

    当同时满足这两个条件时,数组  arr[i] 处的链表将自动转化为红黑树,而其他位置如  arr[i + 1] 处的数组元素仍为链表,不受影响。

    HashMap 的 put 方法具体流程

    图片

      public V put(K key, V value) {    return putVal(hash(key), key, value, false, true);}
      final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {    Node<K,V>[] tab; Node<K,V> p; int n, i;    // 1. 初始化数组    if ((tab = table) == null || (n = tab.length) == 0)        n = (tab = resize()).length;    // 2. 定位桶,若桶空则新建节点    if ((p = tab[i = (n - 1) & hash]) == null)        tab[i] = newNode(hash, key, value, null);    else {        Node<K,V> e; K k;        // 3. 桶首节点匹配        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))            e = p;        // 4. 红黑树处理        else if (p instanceof TreeNode)            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);        else {            // 5. 遍历链表            for (int binCount = 0; ; ++binCount) {                if ((e = p.next) == null) {                    p.next = newNode(hash, key, value, null);                    // 链表转红黑树                    if (binCount >= TREEIFY_THRESHOLD - 1)                         treeifyBin(tab, hash);                    break;                }                // 找到重复键                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))                    break;                p = e;            }        }        if (e != null) { // 覆盖值            V oldValue = e.value;            if (!onlyIfAbsent || oldValue == null)                e.value = value;            return oldValue;        }    }    ++modCount;    // 6. 检查扩容    if (++size > threshold)        resize();    return null;}
      

      HashMap 的扩容机制

      图片

      当前元素数量超过 threshold(扩容阈值,即 容量 × 负载因子)时,触发扩容。

      首先会创建新数组:新数组容量为旧数组的 2 倍,例如旧容量 16,新容量 32。然后,迁移元素,遍历旧数组每个桶,重新计算元素在新数组的位置并复制。由于容量是 2 的幂次,元素新位置要么在原位置,要么在原位置 + 旧容量。

      假设初始容量为 16,负载因子 0.75,阈值为 12。当添加第 13 个元素时:触发扩容,创建容量为 32 的新数组。遍历旧数组,每个元素重新计算哈希:若元素原桶索引为 i,新索引可能是 i 或 i + 16。例如,旧数组中某元素哈希值与 15(16-1)按位与得 5,扩容后与 31(32-1)按位与,若高位变化,新索引为 5 + 16 = 21。

      HashMap不是线程安全的

      多线程的 put 可能导致元素的丢失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从⽽而导致元素的丢失。

      put 和 get 并发时,可能导致 get 为 null。线程 1 执⾏行行 put 时,因为元素个数超出 threshold 而导致rehash,线程 2 此时执⾏行行 get,有可能导致这个问题。

      线程安全的 Map

      • HashTable 是直接在操作方法上加 synchronized 关键字,锁住整个table数组,粒度比较大;

      • Collections.synchronizedMap 是使⽤用 Collections 集合工具的内部类,通过传入 Map 封装出一个SynchronizedMap 对象,内部定义了一个对象锁,方法内通过对象锁实现;

      • ConcurrentHashMap 在jdk1.7中使用分段锁,在jdk1.8中使用CAS+synchronized。

      HashMap是无序的

      根据 hash 值随机插入。如果想使用有序的Map,可以使用LinkedHashMap 或者TreeMap。

      (2)ConcurrentHashmap

      ConcurrentHashmap线程安全在jdk1.7版本是基于分段锁实现,在jdk1.8是基于 CAS+synchronized 实现。

      1.7版本的ConcurrentHashMap采用分段锁机制,里面包含一个Segment数组,每个Segment都是一个HashMap,默认的Segment长度是16,也就是支持16个线程的并发写,Segment之间相互不会受到影响。

      图片

      1.8 对 ConcurrentHashMap 也引入了红黑树,和HashMap是一样的,数组+链表+红黑树,它实现线程安全的关键点在于put流程。

      图片

      (3)HashTable

      Hashtable 是遗留类,很多映射的常用功能与 HashMap 类似,且是线程安全的,任一时间只有一个线程能写 Hashtable,并发性不如 ConcurrentHashMap,因为 ConcurrentHashMap 引入了分段锁。Hashtable 不建议在新代码中使用,不需要线程安全的场合可以用 HashMap 替换,需要线程安全的场合可以用 ConcurrentHashMap 替换。

      (4)LinkedHashMap

      通过维护一个运行于所有条目的双向链表,LinkedHashMap保证了元素迭代的顺序该迭代顺序可以是插入顺序或者是访问顺序。LinkedHashMap可以认为是HashMap+LinkedList,即它既使用HashMap操作数据结构,又使用LinkedList维护插入元素的先后顺序。

      图片

      图片

      循环双向链表的头部存放的是最久访问的节点或最先插入的节点,尾部为最近访问的或最近插入的节点,迭代器遍历方向是从链表的头部开始到链表尾部结束,在链表尾部有一个空的header节点,该节点不存放key-value内容,为LinkedHashMap类的成员属性,循环双向链表的入口。

      ​​​​​​​

      关于LinkedHashMap的源码解读,详细查看下面这篇文章,很不错。https://www.cnblogs.com/xiaoxi/p/6170590.html

      它可以实现按插入的顺序或访问顺序排序。

      LinkedHashMap只定义了两个属性:​​​​​​​

      /** * The head of the doubly linked list. * 双向链表的头节点 */private transient Entry<K,V> header;/** * The iteration ordering method for this linked hash map: true * for access-order, false for insertion-order. * true表示最近最少使用次序,false表示插入顺序 */private final boolean accessOrder;

      就是这个accessOrder,它表示:

      (1)false,所有的Entry按照插入的顺序排列

      (2)true,所有的Entry按照访问的顺序排列

      LinkedHashMap重写了父类HashMap的get方法,实际在调用父类getEntry()方法取得查找的元素后,再判断当排序模式accessOrder为true时(即按访问顺序排序),先将当前节点从链表中移除,然后再将当前节点插入到链表尾部。由于的链表的增加、删除操作是常量级的,故并不会带来性能的损失。​​​​​​​

      /** * 通过key获取value,与HashMap的区别是:当LinkedHashMap按访问顺序排序的时候,会将访问的当前节点移到链表尾部(头结点的前一个节点) */public V get(Object key) {    // 调用父类HashMap的getEntry()方法,取得要查找的元素。      Entry<K,V> e = (Entry<K,V>)getEntry(key);    if (e == null)        return null;    // 记录访问顺序。    e.recordAccess(this);    return e.value;}/** * 在HashMap的put和get方法中,会调用该方法,在HashMap中该方法为空 * 在LinkedHashMap中,当按访问顺序排序时,该方法会将当前节点插入到链表尾部(头结点的前一个节点),否则不做任何事 */void recordAccess(HashMap<K,V> m) {    LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;    //当LinkedHashMap按访问排序时    if (lm.accessOrder) {        lm.modCount++;        //移除当前节点        remove();        //将当前节点插入到头结点前面        addBefore(lm.header);    }}/** * 移除节点,并修改前后引用 */private void remove() {    before.after = after;    after.before = before;}private void addBefore(Entry<K,V> existingEntry) {    after  = existingEntry;    before = existingEntry.before;    before.after = this;    after.before = this;}

      如果accessOrder为false。那么默认就是插入顺序,直接通过链表的特点就能依次找到插入元素,不用做特殊处理。

      评论
      添加红包

      请填写红包祝福语或标题

      红包个数最小为10个

      红包金额最低5元

      当前余额3.43前往充值 >
      需支付:10.00
      成就一亿技术人!
      领取后你会自动成为博主和红包主的粉丝 规则
      hope_wisdom
      发出的红包
      实付
      使用余额支付
      点击重新获取
      扫码支付
      钱包余额 0

      抵扣说明:

      1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
      2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

      余额充值