Java集合-面试题

目录

集合家谱

集合和数组的区别

线程安全的集合有哪些?线程不安全的呢

ArrayList 和 LinkedList 的区别是什么

ArrayList 和 Vector 的区别是什么

多线程场景下如何使用 ArrayList

List 和 Set ,Map的区别

Set常用实现类

HashMap在jdk7中实现原理

HashMap在jdk8中相较于jdk7在底层实现方面的不同

解决hash冲突的办法有哪些?HashMap用的哪种?

为什么在解决 hash 冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树

HashMap默认加载因子是多少?为什么是 0.75,不是 0.6 或者 0.8

HashMap 中 key 的存储索引是怎么计算的

HashMap 的扩容方式

一般用什么作为HashMap的key

HashMap线程不安全影响

HashMap 与 HashTable 有什么区别

ConcurrentHashMap 的实现原理是什么

ConcurrentHashMap的put方法执行逻辑是什么

ConcurrentHashMap 的 get 方法是否要加锁,为什么

HashSet 和 HashMap 区别

Collection框架中实现比较要怎么做


集合家谱

集合和数组的区别

  • 数组是固定长度的;集合可变长度的。

  • 数组可以存储基本数据类型,也可以存储引用数据类型;集合只能存储引用数据类型
  • 数组存储的元素必须是同一个数据类型;集合存储的对象可以是不同数据类型
  • 对象的个数确定可以使用数组,对象的个数不确定的可以用集合。因为集合是可变长度的

线程安全的集合有哪些?线程不安全的呢

  • 线程安全的

    • Hashtable:比HashMap多了个线程安全。
    • ConcurrentHashMap:是一种高效但是线程安全的集合。
    • Vector:比Arraylist多了个同步化机制。
    • Stack:栈,也是线程安全的,继承于Vector。
  • 线性不安全的
    • HashMap
    • Arraylist
    • LinkedList
    • HashSet
    • TreeSet
    • TreeMap

ArrayList 和 LinkedList 的区别是什么

  • 数据结构实现:ArrayList 是动态数组的数据结构实现,而 LinkedList 是双向链表的数据结构实现。

  • 随机访问效率:ArrayList 比 LinkedList 在随机访问的时候效率要高,因为 LinkedList 是线性的数据存储方式,所以需要移动指针从前往后依次查找。
  • LinkedList 使用双向链表实现存储,按序号索引数据需要进行前向或后向遍历,但插入数据时只需要记录当前项的前后项即可,所以 LinkedList 插入速度较快
  • 增加和删除效率:在非首尾的增加和删除操作,LinkedList 要比 ArrayList 效率要高,因为 ArrayList 增删操作要影响数组内的其他数据的下标。
  • 内存空间占用:LinkedList 比 ArrayList 更占内存,因为 LinkedList 的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。
  • 线程安全:ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全
  • 综合来说,在需要频繁读取集合中的元素时,更推荐使用 ArrayList,而在插入和删除操作较多时,更推荐使用 LinkedList

ArrayList 和 Vector 的区别是什么

  • 这两个类都实现了 List 接口(List 接口继承了 Collection 接口),他们都是有序集合

  • 线程安全:Vector 使用了 Synchronized 来实现线程同步,是线程安全的,而 ArrayList 是非线程安全的。
  • 性能:ArrayList 在性能方面要优于 Vector,因为ArrayList线程不安全所以效率要高
  • 扩容:ArrayList 和 Vector 都会根据实际的需要动态的调整容量,只不过在 Vector 扩容每次会增加 1 倍而 ArrayList 只会增加 50%
  • Vector类的所有方法都是同步的。可以由两个线程安全地访问一个Vector对象、但是一个线程访问Vector的话代码要在同步操作上耗费大量的时间。
  • Arraylist不是同步的,所以在不需要保证线程安全时时建议使用Arraylist

多线程场景下如何使用 ArrayList

  • ArrayList 不是线程安全的,如果遇到多线程场景,可以通过 Collections 的 synchronizedList 方法将其转换成线程安全的容器后再使用。例如像下面这样:

List<String> synchronizedList = Collections.synchronizedList(list);
synchronizedList.add("aaa");
synchronizedList.add("bbb");
for (int i = 0; i < synchronizedList.size(); i++) {
    System.out.println(synchronizedList.get(i));
}

List 和 Set ,Map的区别

  • List和Set都是继承Collection接口 List存放有序可重复数据 List主要实现类有ArrayList、LinkedList、Vector

  • Set存放无序不可重复数据 这里无序指的是元素放入的顺序是按照HashCode决定, Set主要是先烈有HashSet、LinkedHashSet、TreeSet
  • Map是一个键值对集合,存储键、值和之间的映射。 Key无序,唯一;value 不要求有序,允许重复。Map没有继承于Collection接口,从Map集合中检索元素时,只要给出键对象,就会返回对应的值对象

Set常用实现类

  • Set接口:存储无序的、不可重复的数据

    • HashSet:作为Set接口的主要实现类;线程不安全的;可以存储null值
      • LinkedHashSet:作为HashSet的子类;遍历其内部数据时,可以按照添加的顺序遍历,但是并不是说
      • LinkedHashSet是有序的, 在添加数据的同时,每个数据还维护了两个引用,记录此数据前一个数据和后一个数据,对于频繁的遍历操作, LinkedHashSet效率高于HashSet
    • TreeSet:可以照添加对象的指定属性,进行排序

HashMap在jdk7中实现原理

在实例化HashMap后,
底层创建了长度是16的一维数组Entry[] table
经过put操作添加数据
首先,计算key的哈希值,此哈希值经过某种算法计算以后,确定在数组中的存放的位置

情况1:如果此位置上的数据为空,此时的key-value添加成功

情况2:如果此位置上的数据不为空,代表着此位置上已存在一个或多个数据,通过链表形式存在,链表则是主要为了解决哈希冲突而存在的
依次比较key的HashCode:
如果哈希值与已经存在的数据的哈希值都不相同,此时数据添加成功

情况3:如果哈希值和已经存在的某一个数据的哈希值相同,继续比较:
key值通过equals方法进行比较:
如果equals()返回false:此时数据添加成功
如果equals()返回true:value值进行替代

补充:关于情况2和情况3:此时新添加的数据和原来的数据以链表的方式存储。

扩容:在不断的添加过程中,会涉及到扩容问题,当超出临界值(且要存放的位置非空)时,扩容。默认的扩容方式:扩容为原来容量的2倍,并将原的数据复制过来

HashMap在jdk8中相较于jdk7在底层实现方面的不同

  1. new HashMap():底层没创建一个长度为16的数组

  2. jdk 8底层的数组是:Node[],而非Entry[]
  3. 首次调用put()方法时,底层创建长度为16的数组
  4. jdk7底层结构:数组+链表。jdk8中底层结构:数组+链表+红黑树
    4.1 形成链表时,七上八下(jdk7:新的元素指向旧的元素。jdk8:旧的元素指向新的元素)
    4.2 当数组的某一个索引位置上的元素以链表形式存在的数据个数 > 8 且当前数组的长度 > 64时,此时此索引位置上的所数据改为使用红黑树存储。
    长度低于6转换为链表

解决hash冲突的办法有哪些?HashMap用的哪种?

解决Hash冲突方法有:开放定址法、再哈希法、链地址法(拉链法)、建立公共溢出区。HashMap中采用的是链地址法 。

  • 开放定址法也称为再散列法,基本思想就是,如果p=H(key)出现冲突时,则以p为基础,再次hash,p1=H(p),如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址pi。 因此开放定址法所需要的hash表的长度要大于等于所需要存放的元素,而且因为存在再次hash,所以只能在删除的节点上做标记,而不能真正删除节点。

  • 再哈希法(双重散列,多重散列),提供多个不同的hash函数,当R1=H1(key1)发生冲突时,再计算R2=H2(key1),直到没有冲突为止。 这样做虽然不易产生堆集,但增加了计算的时间。
  • 链地址法(拉链法),将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。链表法适用于经常进行插入和删除的情况。
  • 建立公共溢出区,将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区

为什么在解决 hash 冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树

因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于 8 个的时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于 8 个的时候, 红黑树搜索时间复杂度是 O(logn),而链表是 O(n),此时需要红黑树来加快查询速度,但是新增节点的效率变慢了

HashMap默认加载因子是多少?为什么是 0.75,不是 0.6 或者 0.8

  • Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳键值对的最大值。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多默认的loadFactor是0.75,0.75是对空间和时间效率的一个平衡选择,一般不要修改,除非在时间和空间比较特殊的情况下 :

    • 如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值
    • 相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1

HashMap 中 key 的存储索引是怎么计算的

  • 首先根据key的值计算出hashcode的值,然后根据hashcode计算出hash值,最后通过hash&(length-1)计算得到存储的位置。看看源码的实现:

//jdk1.7
方法一:
static int hash(int h){
    int h = hashSeed;
        if(0 != h && k instanceof String){
            return sun.misc.Hashing.stringHash32((String) k);
        }
    h^= k.hashCode(); // 为第一步:取hashCode值
    h^= (h >>> 20)^(h >>> 12); 
    return h ^ (h >>> 7)^(h >>> 4);
}
方法二:
static int indexFor(int h, int length) {  //jdk1.7的源码,jdk1.8没有这个方法,但实现原理一样
     return h & (length-1);  //第三步:取模运算
}

// jdk1.8
static final int hash(Object key) {   
     int h;
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    /* 
     h = key.hashCode() 为第一步:取hashCode值
     h ^ (h >>> 16)  为第二步:高位参与运算
    */
}

  • 这里的 Hash 算法本质上就是三步:取key的 hashCode 值、根据 hashcode 计算出hash值、通过取模计算下标。其中,JDK1.7和1.8的不同之处,就在于第二步。我们来看下详细过程,以JDK1.8为例,n为table的长度。

HashMap 的扩容方式

  • HashMap 在容量超过负载因子所定义的容量之后,就会扩容。Java 里的数组是无法自动扩容的,方法是将 HashMap 的大小扩大为原来数组的两倍,并将原来的对象放入新的数组中

  • 那扩容的具体步骤是什么?让我们看看源码
  • 先来看下JDK1.7 的代码:
    • 首先判断容积是否达到最大,如果达到最大的话,就不会进行扩容了
    • 如果没有达到最大的话,会进行tranfer方法进行数组转移,然后修改阈值即新的长度×loadFactor加载因子

void resize(int newCapacity) {   //传入新的容量
        Entry[] oldTable = table;    //引用扩容前的Entry数组
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {  //扩容前的数组大小如果已经达到最大(2^30)了
            threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
            return;
        }
        Entry[] newTable = new Entry[newCapacity];  //初始化一个新的Entry数组
        transfer(newTable);                         //!!将数据转移到新的Entry数组里
        table = newTable;                           //HashMap的table属性引用新的Entry数组
        threshold = (int)(newCapacity * loadFactor);//修改阈值
    }

  • 这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里就是循环遍历数组以及链表进行复制到新的数组中去
  • newTable[i] 的引用赋给了 e.next ,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到 Entry 链的尾部(如果发生了 hash 冲突的话)。

void transfer(Entry[] newTable) {
        Entry[] src = table;                   //src引用了旧的Entry数组
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
            Entry<K,V> e = src[j];             //取得旧Entry数组的每个元素
            if (e != null) {
                src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
                    e.next = newTable[i]; //标记[1]
                    newTable[i] = e;      //将元素放在数组上
                    e = next;             //访问下一个Entry链上的元素
                } while (e != null);
            }
        }
    }

一般用什么作为HashMap的key

  • 一般用Integer、String 这种不可变类当 HashMap 当 key,而且 String 最为常用。

  • 因为字符串是不可变的,所以在它创建的时候 hashcode 就被缓存了,不需要重新计算。这就是 HashMap 中的键往往都使用字符串的原因。

  • 因为获取对象的时候要用到 equals() 和 hashCode() 方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的重写了 hashCode() 以及 equals() 方法

HashMap线程不安全影响

  • 多线程下扩容死循环。JDK1.7中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题
  • 多线程的put可能导致元素的丢失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在JDK 1.7和 JDK 1.8 中都存在
  • put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题。此问题在JDK 1.7和 JDK 1.8 中都存在

HashMap 与 HashTable 有什么区别

  • 线程安全: HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过 synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!)

  • 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它; 对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛NullPointerException
  • 初始容量大小和每次扩充容量大小的不同 : ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小
  • 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制
  • 推荐使用:在 Hashtable 的类注释可以看到,Hashtable 是保留类不建议使用,推荐在单线程环境下使用 HashMap 替代,如果需要多线程使用则用 ConcurrentHashMap 替代

ConcurrentHashMap 的实现原理是什么

ConcurrentHashMap 在 JDK1.7 和 JDK1.8 的实现方式是不同的。

先来看下JDK1.7

JDK1.7中的ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成,即ConcurrentHashMap 把哈希桶切分成小数组(Segment ),每个小数组有 n 个 HashEntry 组成。

其中,Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色;HashEntry 用于存储键值对数据。

首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问,能够实现真正的并发访问。

再来看下JDK1.8

在数据结构上, JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized实现更加低粒度的锁。

将锁的级别控制在了更细粒度的哈希桶元素级别,也就是说只需要锁住这个链表头结点(红黑树的根节点),就不会影响其他的哈希桶元素的读写,大大提高了并发度。

ConcurrentHashMap的put方法执行逻辑是什么

先来看JDK1.7

首先,会尝试获取锁,如果获取失败,利用自旋获取锁;如果自旋重试的次数超过 64 次,则改为阻塞获取锁。

获取到锁后:

  1. 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
  2. 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
  3. 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
  4. 释放 Segment 的锁。

再来看JDK1.8

大致可以分为以下步骤:

  1. 根据 key 计算出 hash值。
  2. 判断是否需要进行初始化。
  3. 定位到 Node,拿到首节点 f,判断首节点 f:
    • 如果为 null ,则通过cas的方式尝试添加。
    • 如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容。
    • 如果都不满足 ,synchronized 锁住 f 节点,判断是链表还是红黑树,遍历插入。
  4. 当在链表长度达到8的时候,数组扩容或者将链表转换为红黑树。

ConcurrentHashMap 的 get 方法是否要加锁,为什么

get 方法不需要加锁。因为 Node 的元素 val 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。

这也是它比其他并发集合比如 Hashtable、用 Collections.synchronizedMap()包装的 HashMap 安全效率高的原因之一。

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    //可以看到这些都用了volatile修饰
    volatile V val;
    volatile Node<K,V> next;
}

HashSet 和 HashMap 区别

补充HashSet的实现:HashSet的底层其实就是HashMap,只不过我们HashSet是实现了Set接口并且把数据作为K值,而V值一直使用一个相同的虚值来保存。如源码所示:

public boolean add(E e) {
    return map.put(e, PRESENT)==null;// 调用HashMap的put方法,PRESENT是一个至始至终都相同的虚值
}

由于HashMap的K值本身就不允许重复,并且在HashMap中如果K/V相同时,会用新的V覆盖掉旧的V,然后返回旧的V,那么在HashSet中执行这一句话始终会返回一个false,导致插入失败,这样就保证了数据的不可重复性。

Collection框架中实现比较要怎么做

  • 第一种,实体类实现Comparable接口,并实现 compareTo(T t) 方法,称为内部比较器。

  • 第二种,创建一个外部比较器,这个外部比较器要实现Comparator接口的 compare(T t1, T t2)方法

猜你喜欢

转载自blog.csdn.net/jiayoubaobei2/article/details/121802370