八股之底层容器

本文详细讲解ArrayList的扩容策略,从初始容量、无参构造到add方法的调整,以及与LinkedList的区别。同时深入解析HashMap在1.7和1.8版本的底层结构变化,包括数组+链表到数组+红黑树的转变及其优化理由。
摘要由CSDN通过智能技术生成

ArrayList

扩容规则:

ArrayList初始构造数组大小:

ArrayList的无参构造:
没有添加元素时容量为0:最开始的elementData数组为0

    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
 	
    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

ArrayList(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);
        }
    }

ArrayList(Collection<? extends E> c):使用集合c 的大小作为数组容量

 	public ArrayList(Collection<? extends E> 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.add()的扩容:

无参构造来讲,第一次添加元素时扩容到10

	private static final int DEFAULT_CAPACITY = 10;
	//minCapacity:the desired minimum capacity 最小容量
	public void ensureCapacity(int minCapacity) {
        int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)? 0: DEFAULT_CAPACITY;

        if (minCapacity > minExpand) {
            ensureExplicitCapacity(minCapacity);
        }
    }

后面的扩容都是原容量的1.5倍:乘1.5倍会出现小数,这怎么扩容?
扩容规则:
1.原容量>>1 得到原容量的一半。
2.原容量与右移一位后的结果相加。

如果是有参构造,那第一次扩容就是原来容量的1.5倍。

ArrayList.addAll()的扩容:

扩容时取Math.max(实际容量,扩容容量)
举例:
容器中没有元素,要往容器中添加11个元素,实际容量:0+11 > 扩容容量 10 ——所以实际扩容到11。
容器中有10个元素,要往容器中添加3个元素,实际容量:10+3 <扩容容量 15 ——所以实际扩容到15。

总结:
容器中没有元素时,扩容为Math.max(10,实际元素个数),
容器中有元素时,扩容为Math.max(原容量的1.5倍,实际元素个数)。


Iterator迭代器:

fail-fast:遍历时发现有人修改,则马上抛出异常

ArrayList就是这种fail-fast,如果遍历时容器内容修改过,遍历完当前轮次后会抛出并发修改异常

	//初始化
	public Iterator<E> iterator() {
        return new Itr();
    }
    private class Itr implements Iterator<E> {
    	//增删都是修改
        //expectedModCount:记载遍历最初时容器的修改次数
        //modCount:容器中修改次数,是个实时值
        int expectedModCount = modCount;
        
        //往下遍历时调用checkForComodification();
        public E next() {
            checkForComodification();
           	...
         }
		//每次遍历都会判断集合是否发生变化,发生变化就抛出并发修改异常
		final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }

fail-safe:遍历时发现有人修改,有应对策略来完成遍历

CopyOnWriteArrayList是这种fail-safe,遍历容器发现内容修改过时会牺牲一致性,当前遍历取到的依旧是没修改前的旧内容,无法获取修改过的新内容。

遍历旧数组,添加到新数组。

	static final class COWIterator<E> implements ListIterator<E> {
       	
        private final Object[] snapshot;
        
        //把遍历时的数组保存到snapshot里
        private COWIterator(Object[] elements, int initialCursor) {
            cursor = initialCursor;
            snapshot = elements;
        }
        ...
    }

所以遍历的时候使用的是旧数组,添加的元素不在这。

	public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            
            //添加时复制了原来的数组,开了个更大的数组并把它作为新数组
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            //添加新元素到新数组中
            newElements[len] = e;
            setArray(newElements);
            
            return true;
        } finally {
            lock.unlock();
        }
    }

LinkedList vs ArrayList

随机访问:
ArrayList实现了RandomAccess,可以直接下标找,LinkedList没实现所以要用迭代器找。

增删性能:
ArrayList头插:所有元素后移,再将头部元素更换。所以除了尾部的插入和删除,其余部分的删除和插入都会受到影响。
LinkedList双向链表有头尾指针,所以插入和删除会快,但是如果是中间位置,指针要遍历过去较消耗时间。(链表插入和删除当然快,是指针过去慢)

局部性原理:
被访问的内存附近的内容很大可能被用到,数组连续内存的特点可以配合cpu缓存,充分利用局部性原理。

LinkedList内存占用大:其中的节点,pre和next指针等都比数组耗费空间。

二者区别

主要是底层数据结构造成的区别:
底层数据结构的不同:
ArrayList的底层数据结构是数组,需要连续的内存,随机访问快,但是除了尾部插入和删除之外,其他的插入和删除操作都要移动数据所以性能低。ArrayList可以配合cpu缓存充分利用局部性原理
LinkedList的底层数据结构是双向链表,需要连续内存,随机访问慢,只能沿着链表遍历,但是头部和尾部插入和删除性能很不错。LinkedList内存占用大。


HashMap

面试题:

HashMap的底层数据结构在1.7和1.8有什么不同?

1.7时,HashMap的底层数据结构是数组+链表
1.8时,底层数据结构是数组+(链表||红黑树)。链表长度超过8时会转换成红黑树,红黑树中元素减少也可能会退化成链表。

为什么用红黑树?

1.7中链表过长会影响查找性能,1.8在链表过长时用红黑树换掉链表能够提高查询性能。

什么时候树化?

满足两个条件:数组长度大于等于64 且 链表长度大于8

为什么最初不用红黑树而是链表?

链表元素少时,查找性能高于红黑树,就没必要花大力气用红黑树,而且用红黑树还耗内存;
链表元素多时,其性能远不如红黑树,所以才换成红黑树。

树化的阈值为什么是8?

红黑树用于避免dos攻击,防止链表过长性能下降,链表正常情况下不会超过8。
红黑树占用的空间比链表大,非必要不要使用红黑树。
hash值足够随机时,负载因子0.75的情况下链表长度超过8的概率很低,最大可能降低树化的概率。

什么时候退化成链表?

1.扩容时桶下标重新计算导致树的拆分,树元素数量<=6时退化成链表。
2.remove树节点前,有根节点,左右孩子或者左孙子为null时,退化成链表。

索引怎么计算?

过程:
桶下标 = 哈希码% 数组容量
只要保证数组容量是2 的n次幂,就可以有更快的计算方式:桶下标 = 哈希码 & (数组容量-1)
结果:
计算对象的hashcode(),再使用HashMap的hash()二次哈希,最后&(capacity-1)得到索引。

有了hashcode为什么还有hash()方法?/ 为什么要二次哈希?

做出扰动,综合高位数据,让哈希分布的更均匀。

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

计算索引时,可以使用按位与操作来代替取模,可以提高效率。
扩容时,二次哈希值hash & 数组原容量oldCapacity == 0 ?留在原位 :新位置 = 旧位置 + oldCapacity。

如果为了追求更好的哈希分布性,数组容量取质数更好。
如果为了追求更高效率,数组容量取2的n次幂更好。显然HashMap更追求性能,以上的索引与计算,二次哈希以及扩容时位置计算都是配合数组容量为2的n次幂做出的优化。

1.7和1.8中,put方法的流程有什么不同?

流程:
1.HashMap懒惰创建数组,首次使用put方法时才创建数组;
2.计算索引(桶下标):得到对象的key,得到对象的hashcode,二次哈希后&(数组容量-1)得到索引。
3.桶下标是否占用?
    3.1无人占用,创建node占位返回
    3.2有人占用,如果里面时TreeNode就用红黑树逻辑添加;如果是node就走链表的逻辑添加;如果链表长度超过8,就走红黑树逻辑(还是看树化的两个条件)。
4.添加元素检查数组容量是否超过阈值?超过就扩容。

注意:添加时判断是否需要树化,是否需要扩容。

不同:
链表插入方式:1.7是头插法,1.8是尾插法
扩容时计算索引方式:1.8会优化:旧位置+oldCapacity
扩容细节:1.7 大于等于阈值且无空位才扩容,1.8大于阈值就扩容。

1.7 大于等于阈值但是有空位的情况:接着往里加,不扩容:
在这里插入图片描述

加载因子为什么是0.75?

因为0.75在空间占用(扩容)和查询时间(哈希冲突,链表长度是否过长)之间取得了较好的平衡。
大于0.75节省空间,但是链表过长会影响性能。
小于0.75冲突减少,但是扩容频繁,空间占用多。

多线程下HashMap会有什么问题?

1.   1.7的情况下:并发丢数据:
put方法流程中,获取桶下标后会判断此时是否有人占用,假如有两个线程t1,t2同时向map中添加元素,且桶下标一样还同时做完了是否有人占用当前桶下标的判断,且同时还没有添加元素,此时t1,t2都认为没人占用当前桶下标,因此都把数据写进桶下标,导致t1写的内容被t2修改或t2写的内容被t1修改,应该有两个元素写入map但最后只有一个元素写入map,造成了数据的丢失。
2.;数据错乱:1.7头插法可能导致多线程时出现链表死链的情况。

key可以为null吗?作为key有什么要求?

只有HashMap的key可以作为Null,其他的map都不行。
作为key的对象要重写hashcode()和equals(),且key不能修改,变了用key再找hashcode可能就不一样了,导致找不到对象。

String对象的hashCode()如何设计?

目的是让每个字符串的哈希值足够独特,分布的比较均匀。
把String中每个字符作为一个数字Si,i属于[0,n-1],使用散列公式:
在这里插入图片描述

为什么乘的31的x次幂?

31带入散列公式有较好的散列特性,而且31X可以优化为: X>>5 -X
31
X = 32X-X = 2^5X-X = X>>5 -X


对以上面试题的部分解释

快速查找:

元素做两次哈希,将哈希码和容量取模后得到的余数(桶下标)即为数组中位置。然后再在数组中当前链表下遍历查找。由于这样的计算会让查找非常快速。

链表过长解决方法:

找了数组又找链表,如果链表过长那么查找也不快速了。

1.扩容:
map中元素到达 3/4(临界点) 数组就扩容为原来的两倍,此时会重新计算桶下标,如果哈希码不一样那么重新计算的桶下标也会不一样,因此链表长度减少
但是如果链表过长的哈希码都一样,那么即使数组扩容了桶下标重新计算也还是一样,因此链表长度不会减少

元素数量到达临界点或链表长度大于8都会扩容。

2.红黑树树化: 万不得已才树化
达成两个条件才树化:.数组长度大于等于64链表长度大于8
如果链表长度大于8,会看能不能用数组扩容解决链表过长的问题,如果可以解决就认为没必要树化。

退化发生的情况:

1.扩容拆分树时,桶下标重新计算,如果之后的树元素个数<=6,则会退化成链表
2.remove树节点,如果root,root.right,root.left,root.left.left 有一个为null,也会退化成链表链表。
在这里插入图片描述
注意:是移除之前检查,如果真的此次要移除以上四个节点中的一个,由于在移除之前四个节点都在,已经检查过认为都不为null,所以即使移除了四个中的任意一个,移除过后仍然没有退化。

举例子:
在这里插入图片描述
在这里插入图片描述

数据错乱:

1.7 头插法导致的线程死链的问题:
在这里插入图片描述
在这里插入图片描述
线程2完成操作后:线程1 的操作如下:
在这里插入图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值