「持续100 天更新 Java 相关面试题」—— 第 6 天

前言

今天是更新第 6 天,今天的主要内容还是 Java 基础

正文

ArrayList相关

扩容机制

三种构造器

1.ArrayList()会使用长度为0的数组

// 无参构造器
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public ArrayList() {
    // 初始化为空数组
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

2.ArrayList(int initialCapacity)会使用指定容量的数组

// 指定初始容量的构造器
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);
    }
}

3.public ArrayList(Collection<? extends E> c)会使用c的大小作为数组容量

// 传入集合的构造器
public ArrayList(Collection<? extends E> c) {
    Object[] a = c.toArray();
    if ((size = a.length) != 0) {
        if (c.getClass() == ArrayList.class) {
            elementData = a;
        } else {
            elementData = Arrays.copyOf(a, size, Object[].class);
        }
    } else {
        // replace with empty array.
        elementData = EMPTY_ELEMENTDATA;
    }
}

无参构造器的扩容机制:

  • 扩容是懒惰式的,即没有添加元素前,即使指定了容量,也不会真正创建数组

  • 对于add(Object o)方法:首次添加元素扩容为10,再次扩容为上次容量的1.5倍

    扩容1.5倍的实现方式是位运算:当前容量 + (当前容量 >> 1)

  • 对于addAll(Collection c)方法:

    没有元素时,扩容为Math.max(10,实际元素个数),有元素时为Math.max(原容量1.5倍,实际元素个数)

    // 当前数组容量为 0
    List<Integer> list = new ArrayList<>();
    // 添加元素时触发扩容,实际扩容容量为 Math.max(10, 4)
    list.addAll(Arrays.asList(1,2,3,4)); 
    System.out.println(list); 
    
    // 当前数组容量为 10
    List<Integer> list = new ArrayList<>(Arrays.asList(1,2,3,4,5,6,7,8,9,10));
    // 添加元素时触发扩容,实际扩容容量为 Math.max(15, 17)
    list.addAll(Arrays.asList(1,2,3,4,5,6,7));
    System.out.println(list); 
    

ArrayList 、Vector 和 LinkedList 的区别

ArrayList

  1. 基于数组,需要连续内存
  2. 随机访问快(指根据下标访问)
  3. 尾部插入、删除性能可以,其它部分插入、删除都会移动数据,因此性能会低

LinkedList

  1. 基于双向链表,无需连续内存
  2. 随机访问慢(要沿着链表遍历)
  3. 头尾插入删除性能高
  4. 占用内存多

详细:

  • ArrayList 、Vector 和 LinkedList 都实现了 List 接口

  • ArrayList:基于可变数组,连续内存存储,适合下标访问(随机访问),数组的缺点是每个元素之间不能有间隔,当数组大小不满足时需要增加存储能力,就要将已经有数组的数据复制到新的存储空间中。尾部插入删除元素性能良好,当从ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动,会降低性能。

    // JDK 中的定义
    public class ArrayList<E> extends AbstractList<E>
            implements List<E>, RandomAccess, Cloneable, java.io.Serializable
        
    public class LinkedList<E> extends AbstractSequentialList<E>
            implements List<E>, Deque<E>, Cloneable, java.io.Serializable
    

    ArrayList 实现了 RandomAccess 接口,而 LinkedList 没有实现,而根据索引遍历时比迭代器效率要高的

    即连续内存存储可以利用起始地址 + 数组元素大小 * n 来快速定位到目标索引的元素,效率比较高

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

  • LinkedList:基于双向链表,可以存储在分散的内存中,在头尾数据插入及删除操作效率高,随机访问和遍历速度比较慢(需要从头遍历)。和ArrayList相比它实现了Deque接口,可以当作栈、队列和双向队列使用。

    LinkedList 在中间插入/删除元素的性能是非常低的,远不如ArrayList,虽然插入/删除操作只需要修改指针即可,但是找到要找到插入/删除的位置需要遍历整个链表,效率非常低,综合来看LinkedList不如ArrayList

List 和 Set 的区别

  • List:有序,按对象存入时的顺序保存对象,可重复,允许多个Null元素对象,可以使用get(int index)获取指定下标的元素

  • Set:无序,不可重复,最多允许有一个Null元素对象,取元素时只能用Iterator接口取得所有元素,再逐一遍历各个元素,不能通过下标访问

迭代器Iterator

FailFast

一旦发现遍历的同时集合内容发生了修改,则立刻抛异常

ArrayList是fail-fast的典型代表,遍历的同时不能修改

源码分析

// 在进入for循环时会先初始化一个迭代器对象,并进行赋值

// modCount:当前 List 集合的修改次数
int expectedModCount = modCount;
// 每次迭代前会调用 hasNext() 方法来判断是否到了末尾
public boolean hasNext() {
    return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
    // 每次调用 next() 方法会先调用 checkForComodification 方法来进行判断
    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];
}
// 判断当前的遍历前的修改次数和当前的修改次数是否一致,如果不一致则抛出异常
final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}
FailSafe

发现遍历的同时其它人来修改,应当能有应对策略,例如牺牲一致性来让整个遍历运行完成

CopyOnWriteArrayList是fail-safe的典型代表,遍历的同时可以修改,原理是读写分离

在遍历开始前会将当前的数组记录下来,遍历的时候遍历的就是记录的这个数组,对于遍历过程中发生的修改数组实际上修改的是复制出来的新数组,对当前正在遍历的数组不会产生影响。

HashMap相关

HashMap 根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,遍历顺序是无序的

一些参数:

  • table:底层维护的数组

  • capacity:当前数组容量,始终保持 2^n,可以扩容,扩容后数组大小为当前的 2 倍。

  • loadFactor:加载因子,默认为 0.75。

  • threshold:扩容的阈值,等于 capacity * loadFactor

  • MIN_TREEIFY_CAPACITY:最小树化容量

  • TREEIFY_THRESHOLD:树化的阈值

HashMap 和 HashTable 区别

  1. HashMap 最多只允许一条记录的键为null,允许多条记录的值为null;Hashtable不允许键和值为 null
  2. HashMap 非线程安全,即同一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致;Hashtable 是线程安全的,同一时刻只有一个线程能写Hashtable。
  3. Hashtable 不建议使用,不需要线程安全的场合可以用 HashMap 替换,需要线程安全的场合可以用ConcurrentHashMap 替换。(Hashtable并发性不如ConcurrentHashMap,因为ConcurrentHashMap 引入了分段锁。)

HashMap的初始容量

JDK1.7的时候初始容量是16

JDK1.8的时候初始化HashMap的时候并没有指定容量大小,而是在第一次执行put方法时才初始化容量。

// 负载因子大小
final float loadFactor;

// 默认负载因子大小
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 初始化方法 执行new HashMap()方法初始化的时候,只指定了负载因子的大小。 
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; 
}

HashMap 底层实现和扩容机制

  • JDK1.7采用的是数组+链表,数组可以通过下标访问,实现快速查询,链表用来解决哈希冲突。

  • 链表的查询时间复杂度是O(n),性能较差,所以JDK1.8做了优化,引入了红黑树,查询时间复杂度是O(logn)。

    JDK1.8采用的是数组+链表+红黑树的结构,当某一条链表长度大于等于8,并且数组长度大于等于64时,该链表才需要转换成成红黑树。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zX5YfNce-1677742993031)(Java基础.assets/image-20221008155901275-16693771431461.png)]

扩容机制:

1. HashMap底层维护的是一个数组,默认为null
2. 当创建 `new HashMap()` 时,将加载因子`loadfactor`初始化为0.75
3. 第1次添加元素,则需要扩容table容量为16,临界值(threshold)为12。以后再扩容,则需要扩容table容量为原来的2倍,临界值为原来的2倍,即24,依次类推
4. 添加完元素后会检查容量是否超过阈值,一旦超过走扩容的逻辑(用数组容量大小乘以加载因子得到一个值,一旦数组中存储的元素个数超过该值就会调用rehash方法进行扩容)
5. 在Java8中,如果一条链表的元素个数大于等于TREEIFY_THRESHOLD(默认是8),并且table的大小 >= MIN_TREEIFY_CAPACITY(默认64),该条链表就会进行树化(转成红黑树),如果 table 的大小 < 64,暂时不树化,而是继续进行扩容。
6. 在扩容时如果 `hash & 旧的数组容量 = 0`的元素留在原来位置,否则`新位置 = 旧位置 + 旧的数组容量`。

为什么要用红黑树,为何一上来不树化?

红黑树用来防止链表超长时性能下降,树化应当是偶然情况

hash 表的查找,更新的时间复杂度是O(1),而红黑树的查找,更新的时间复杂度是O(log2 n),TreeNode占用空间也比普通Node的大,如非必要,尽量还是使用链表。

树化阈值为何是8

hash值如果足够随机,则在 hash 表内按泊松分布,在负载因子0.75的情况下,长度超过8的链表出现概率是0.00000006(亿分之六),选择8就是为了让树化几率足够小

何时会树化,何时会退化为链表?

  • 树化两个条件:链表长度超过树化阈值;数组容量>=64

  • 退化情况1:在扩容时会进行树的拆分,树元素个数<=6则会退化链表

    退化情况2: 删除树节点前,若待删除节点的root、root.left、root.right、root.left.left有一个为null,也会再删除后退化为链表

索引/桶下标的计算方式

  1. 首先调用 Object 类的 hashCode() 方法计算哈希值

  2. 然后调用 Map 的 hash() 方法进行二次哈希

  3. 最后再和数组容量进行取模运算,求出key所在的数组索引(桶下标)

    取模用位运算实现:索引值 = 二次哈希值 & (数组容量 - 1)

为什么要进行二次哈希

为了综合高位数据,让哈希分布更为均匀

数组容量为何是2的n次幂?

  1. 计算索引时,如果是2的n次幂可以使用与运算代替取模,效率更高;

  2. 在扩容时 hash值 & 旧的数组容量 = 0的元素留在原来位置,否则新位置 = 旧位置 + 旧的数组容量

    以上优化手段都建立在数组容量为2的n次幂时,二次哈希也是针对数组容量为 2 的n次幂进行的(因为使用 2 的n次幂哈希分布不是很平均)。

  3. 数组容量不使用 2 的 n 次幂(习惯上使用质数)的话哈希分布可能会更均匀,但是求取数组的索引速度会变慢。

    例如Hashtable的容量就不是2的n次幂,并不能说哪种设计更优,应该是设计者综合了各种因素,最终选择了使用2的n次幂作为容量

put方法流程及1.7与1.8有何不同

流程

  1. 计算索引(桶下标)
  2. 判断该索引处是否有元素,如果没有元素直接添加并返回
  3. 如果该索引处有元素,继续判断该元素的key是否和准备加入的key相等,如果相等,则直接替换value
  4. 如果不相等需要判断是树结构还是链表结构,做出相应处理。如果链表长度超过树化阈值,走树化逻辑
  5. 返回前检查容量是否超过阈值,一旦超过走扩容的逻辑(先添加元素,再检查是否超过

不同点

  1. 链表插入节点时,1.7是头插法,1.8是尾插法
  2. 1.7是大于等于阈值且没有空位时才扩容,而1.8是大于阈值就扩容
  3. 1.8在扩容计算Node索引时,会优化

加载因子为何默认是0.75f

  1. 在空间占用与查询时间之间取得较好的权衡
  2. 大于这个值,空间节省了,但链表就会比较长影响性能
  3. 小于这个值,冲突减少了,但扩容就会更频繁,空间占用多

多线程下操作HashMap会出现的问题

  1. 扩容死链(1.7)
  2. 数据错乱(1.7,1.8)

key能否为null,作为key的对象有什么要求?

  1. HashMap 的key可以为null,但Map的其他实现则不然
  2. 作为key的对象,必须实现hashCode和equals,并且key的内容不能修改(不可变)

String对象的hashCode()如何设计的,为什么每次乘的是31

  1. 目标是达到较为均匀的散列效果,每个字符串的hashcode足够独特
  2. 字符串中的每个字符都可以表现为一个数字,称为Si,其中i的范围是0~n - 1,散列公式相当于求原字符串的三十一进制表示
  3. 31代入公式有较好的散列特性,并且31 * h 可以被位运算优化为 32 * h - h2^5 * h - h,进一步优化为h << 5 - h

TreeMap 和 LinkedHashMap 区别

TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以自己指定排序规则,当用Iterator 遍历TreeMap 时,得到的记录是排过序的。

在使用TreeMap 时,key 必须实现 Comparable 接口并且重写相应的compareTo()函数或者在构造 TreeMap 时传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException 类型的异常。

LinkedHashMap 是HashMap 的一个子类,保存了记录的插入顺序,在用Iterator 遍历LinkedHashMap 时,先得到的记录肯定是先插入的。

LinkedHashMap 如何保证插入元素的顺序和遍历时的顺序相同:每个节点有 before 和 after 属性,相当于维护了一个双向链表

ConcurrentHashMap 相关

ConcurrentHashMap支持并发操作。整个 ConcurrentHashMap 由一个个 Segment(分段锁) 组成,每个Segment可以看作是一个HashMap。

简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

concurrencyLevel:并行级别、并发数、Segment 数,默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值