以八股的视角打开集合框架

目录

集合框架的结构

迭代器(Iterable接口)

什么是迭代器,作用是什么

迭代器如何进行遍历

如何安全的删除元素

Collection

List(有序可重复)

Set(无序不可重复)

Queue(单向队列,先进先出)

Map(重点)

equals()与HashCode

 Map如何判断Key是否重复 

HashMap

HashMap数据结构


集合框架的结构

Iterable(迭代器)的作用:遍历集合、删除集合、支持多线程操作。

Collection:单列集合;

Collections:Collection与Collections的区别,Collections是操作集合的工具类,而Collection是存放数据的集合。

Map:Key-Value结构,通过Key去取值。通常通过Hash运算去计算Key的位置。

迭代器(Iterable接口)

什么是迭代器,作用是什么

        迭代器是Java集合中的顶级接口,迭代器之下的所有接口类都实现了迭代器。什么是迭代器:他本质上是对容器遍历的算法。迭代器的作用:遍历集合、删除集合、支持多线程操作。

迭代器如何进行遍历

        List<String> list = new ArrayList<>();
 Collections.addAll(list, "篮球", "足球", "羽毛球", "乒乓球", "排球", "游泳", "跑步", "健身");
        Iterator<String> iterator = list.iterator();
        //第一种方式,通过List调用iterator()方法
        //并且可在迭代过程中调用iterator.remove()安全移除当前元素
        while (iterator.hasNext()){ //判断是否有下一个值
            System.out.println(iterator.next()); //获取下一个值
        }
        //第二种方式,通过List调用listIterator()方法。
        //使用ListIterator的优势是可以进行正向遍历和逆向遍历,还可以在遍历过程中添加,修改,删除元素
        ListIterator<String> listIterator = list.listIterator();
        while (listIterator.hasNext()){
            System.out.println(listIterator.next());
        }

遍历集合还可以使用普通for循环,增强for循环,forEach这么五种方式。

其中增强for循环的本质就是获取集合的iterator迭代器对象进行迭代遍历。

如何安全的删除元素

        如果我们在listIterable和Iterable中遍历集合,使用list.remove()来删除元素,就会报ConcurrentModificationException。这是什么原因那?

              List集合中维护了一个ModCount用来统计list修改次数,Iterable接口中维护了一个expectModeCount,他们在修改之后会比对两者值是否相等。如果在迭代器遍历的过程中使用list.remove()删除,就会导致两者值不想当,出现问题。

  • 不能使用增强For遍历的时候删除元素
  • 不能在Iterable和ListIterable遍历中使用List.remove()删除元素,可以使用迭代器自带的删除方法删除。
  • 每当删除一个元素时,集合的size方法的值都会减小1,这将直接导致集合中元素的索引重新排序,进一步说,就是剩余所有元素的索引值都减1,而for循环语句的局部变量i仍然在递增,这将导致删除操作发生跳跃。从而导致没循环一次,就少删除一个元素。可以通过补偿机制进行i--
        List<String> list = new ArrayList<>();
Collections.addAll(list, "篮球", "足球", "羽毛球", "乒乓球", "排球", "游泳", "跑步", "健身");
        for (int i = 0; i < list.size(); i++) {
            list.remove(i);
        }
        for (String s : list) {
            System.out.println(s);
        }

控制台输出结果

Collection

List(有序可重复)

ArrayList:底层数据结构为数组。他的特点跟数组几乎一致,查询快(可以直接根据索引进行查询),插入慢(插入一个数据,该索引之后的数据全部向后移位)。

LinkedList:底层数据结构为链表。他的特点跟链表一致,查询慢(需要遍历链表),插入快(只需要修改前后指针就可以)。

ArrayList与LinkedList的区别

  1. 数据结构不一样,ArrayList是数组,LinkedList是双向链表。
  2. 插入与删除受元素位置影响关系,这里我直接上结论,百分之99的情况下用ArrayList更好。原因是ArrayList如果插入尾部时间复杂度是O(1),LinkedList采用头插或者尾差也是O(1)。但是如果根据元素位置删除元素的时候,是先查询位置再进行删除或者添加操作。ArrayList查找元素效率是O(1),但是添加删除操作需要移位所以他整体就是O(n),而LinkedList查找元素的时间复杂度是O(n),所以整体都是O(n)。这么说来,两者好像没什么区别。但是ArrayList在查询操作上时间复杂度为O(1),所以ArrayList的性能优势更加明显。
  3. 内存占用,ArrayList会在结尾留出一段空间,LinkedList是每个节点占据的空间大。

Vector

Vector底层的数据结构是数组,他是线程安全的,通过Sync关键字。(Vector已经被淘汰)

Set(无序不可重复)

Queue(单向队列,先进先出)

Deque(双向队列)

Map(重点)

equals()与HashCode

提及Map我们必需先来聊聊equals方法,提及equals方法我们又不可避免的需要了解HashCode。

为什么我们要提及equals?

        当我们插入元素到Map当中,如果Key值重复了会出现什么情况,后一个key的value值会把前一个key的value值覆盖掉。判断key值是否重复,Map中的做法就是使用equals方法。

为什么重写equals方法必须重写HashCode?

        重写equals方法,是为了判断两个对象的值是否相等。

        如果重写了equals方法而未重写HashCode方法,就会导致在Map中查询Key值的时候,明明两个值相等的对象,确在Map中没有找到。

        原因是什么?每一个对象的HashCode都是根据地址值进行计算的,每new出来一个对象,地址值都不一样,所以Hash值也就不一样,那么储存在Map中hash表中的位置就会不一样。那么我们拿着Key去Hash表中去找对应值相同地址值不同的Key时就会找不到Key,导致误判。

        所以重写equals方法必须重写HashCode

 Map如何判断Key是否重复 

         Map为了避免Key重复是不是就需要一个个元素去比较去equals,这样时间复杂度是O(n),这里采用Hash算法进行优化。

        每一个Map都维护了一个Hash表,表里面将key值进行Hash处理然后取模放到对应Hash表的位置中,当检查元素是否重复时会先去比较Hash值,如果Hash值不相等那么也没有必要进行equals比较了,只有当Hash值相等的时候才会调用equals方法比较。这样就优化了Map查询Key是否重复。

HashMap

HashMap数据结构

JDK1.7之前

采用数组+链表,采用这种数据结构的目的是为了解决Hash冲突(拉链法)。

解决Hash冲突的其他方法:

  1. 拉链法(Hash冲突后,将冲突数据添加到对应链表中)
  2. 再Hash法(使用其他Hash算法,再次计算)
  3. 再散列法(将Hash后的值加某个值或者减某个值,将下次Hash位置放到本次位置的两边空闲位置,也被称为开放定址法
  4. 简历公共溢出区(将Hash表分为基本表和溢出表,将发生冲突的放到溢出表中)

JDK1.8

采用数组+链表+加红黑树(原因是,当链表过长查询速度变慢,这个时候需要将链表进行树化)

链表树化的条件:

  1. 数组长度大于64
  2. 链表长度大于8

        这里有一个有趣的点是,树化条件是链表长度大于8;而当元素减少,将红黑树变为链表的条件是,红黑树高度小于6.

        原因:如果树化和逆转树化都设置为8,那么刚好在8这个长度进行添加和删除,就会一直处于树化和逆转树化的过程极为浪费资源。

常见问题

1.什么是红黑树?

        红黑树是一种数据结构,他是由二叉树等树结构演化出来的。(小编也不是很了解红黑树,在这里我就简单介绍一下红黑树的特点把!)

  • 根节点是黑色的
  • 红黑树的节点只有两种,黑色节点和红色节点
  • 红色节点的子节点都是黑色节点
  • 最尾端节点(该节点没有再次向下的分支,被称为叶子节点)都是黑色节点,储存的值为null
  • 每一个节点到其分支节点的最尾端节点,经过的黑色节点数目相同

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

        红黑树相对于链表来说,在存储和维护上需要更多的空间和时间消耗。因此只有在链表过长的时候我们才会使用红黑树代替链表。

3.不用红黑树,用二叉查找树可以么?

        理论上是可以的,但是如何维持二叉树的平衡,如何放置二叉树退化成链表那?

HashMap中如何通过Key找到对应值

首先要了解HashMap的内部存储结构

数据在HashMap中存储,其实存储的是一个个的Entry对象。

key是键,Value是值

next(1.7是链表,next代表指针),node(1.8因为会转换为红黑树,所以叫做节点更加贴切)

hash就是hash值

如何通过Key去找到对应Value

       举个例子,我们都知道数组是如何取值的,Map跟数组取值挺像的。

        arr[i]=value

        通过下标去找到对应的Value,Map也一样,知道了Key我们就可以通过运算去找到对应的下标。

        Key->Hash->&->index

        这个流程是,将key变为Hash值,然后对Hash值进行& (length-1),最后得到index下标。

        通过下标就成功找到对应值了,成功完成任务。        

        为什么是&运算,而不是取模?

        什么是&运算,即将两个数转变为二进制,然后数字都为1结果为1,有一个不为1就会0.

        这样和取模一起都可以得到0-对应数值大小的值。

        那为什么要使用&,因为计算机能够更好的处理位运算,用人话说就是计算机在进行&(位运算)时效率更高。

为什么HashMap扩容一定是2的幂次方数

        上文中我们提到了HashMap通过Key去找值的过程,其中用到了一个操作就是&(位操作)

        这个时候我们就需要来介绍一下&运算的特点。

        当HashMap扩容是2的次幂,那么length-1得到的值低位全为1,举个例子

        

#16 二进制
0001 0000

#length-1
0000 1111



#如果此时有一个17对15进行&运算

0001 0001
0000 1111

0000 0001

        看出来什么妙的地方了吗,如果是2的幂次方,低位有值的地方会被全部保留,这个就是我们所需要的索引。但是如果不是2的幂次方就会出现低位有0的情况,这样就会把进行&运算的值可能有的位不会进行计算。(有问题的,自己举个例子就明白了,我也是脑袋尖尖的,全靠举例子)

兄弟们,这个解释细节不细节,我也是在网上看了好长时间资料才理解的!!!

HashMap1.7使用头插法,1.8改为尾插法的原因

        1.7使用头插法的优势在于(官方说法,我也没理解啥意思)1.7使用头插法的原因最近访问到的数据下次访问概率变大,这样可以提高效率。(这里因为要遍历链表中的每一个元素,保证Key唯一所以不会出现,使用头插法就不用遍历链表的现象)

        缺点:多线程出现死循环(这个问题我理解了好几遍但还是忘了,就不细讲了,问我那我就说不会,摆烂了)

        1.8使用尾插法原因,我们需要判断Key是否重复,必然会遍历,所以直接使用尾插法了。(小伙伴们可能会误认为是不是要判断链表长度是不是8,然后导致必须遍历。其实不是的,内部有一个变量在记录链表长度的)

常见线程安全的Map

ConcurrentHashMap(重点)

ConcurrentHashMap的Key不能为null

HashMap的Key可以为null,并且永远存在索引为0的数组中

ConcurrentHashMap实现线程安全的方式(重点)

ConcurrentHashMap1.7保证线程安全的方式

了解ReentrantLock(可重入锁);这个知识点我在面试的过程中被问过,当时还不会尴尬死了,这里带大家了解一下吧

        可重入锁ReentrantLock指的是一个线程可以获取多次同一把锁,不会出现线程阻塞等待,这样就不会出现死锁问题。

 注意事项:   

  • 但是获取几次锁就需要释放几次锁
  • 可重入锁不是指,没有获取到锁就会等待重新获取锁(小编当时就是回答这个,被面试官狠狠批斗了一次,血的教训啊!!!)

1.7保证线程安全的措施,采用分段数组,分段加synchronized锁的方式

        1.7将一整段数组分割为很多个桶(Sagment),目的是在使用synchronized加锁的时候,减少锁的颗粒度。

        1.7最多可以用16个桶(Sagment),Sagement继承ReentrantLock。也就是说可以承载16个并发量。当我们要对ConcurrentHashMap进行并发写的操作时,先去获取Sagment中的锁,然后再进行增加删除操作

ConcurrentHashMap1.8保证线程安全的方式

Node+CAS+synchronized方式保证线程安全。

ConcurrentHashMap1.8的数据结构和HashMap1.8的数据结构都是数组+链表+红黑树

Node指的就是对每一个数组个体进行加锁(即链表表头,树的根节点),粒度更细。

CAS :Compare and Swap(比较并交换),他是一个逻辑锁。

原理是,程序会先计算出一个期望值,然后执行出来实际值,对比实际值与期望值,如果数据一致,那么就不用加锁继续向下执行,如果不一致就对Node加锁

synchronized:java关键字,重锁。

HashTable

数据结构:数组+链表

HashTable实现线程安全的方式:删除添加都是在同一把synchronized下进行的,虽然保证了线程安全,但是效率低下

集合扩容

ArrayList扩容

        我们知道ArrayList的底层是数组,是数组就必然需要考虑数据量变大之后带来的扩容问题,正常数组的做法是创建一个容量更大的数组,将原来数组复制到新数组当中。我们的ArrayList也是如此实现的。

        当我们去创建一个ArrayList的时候,初始化容量为10,,如果你设置10以下的容量,默认还是会创建10这个容量。

        先判断容量是否超过10,如果超过扩容为原来的1.5倍,就是原始容量+原始容量向右移一位(二进制表达方式,就是除以2),然后调用Arrays.copyOf(原始数据,新长度)方法创建一个新的数组,并且扩展容量到原来的1.5倍。

HashMap扩容

先给大家梳理一下思路,帮助大家更好的去理解源码,当然也可以背下来当八股答案^_^。

  1. 第一步:根据HashMap的初始化情况,确定新数组的数组大小与下一次扩容的阀值。
  2. 第二步:根据数组节点采用的数据结构,将老数组的值迁移到新数组中。

注意事项:

    在链表数据迁移的时候为什么要进行链表拆分为高低位链表那?

    原因就是:老数组已经到达阈值,查询速度变慢。拆分可以提高速度,减少Hash碰撞。高位链表的位置一定是老索引+扩容大小(可以自己举例验证)

废话不多说,直接上HashMap1.8源码,下面请欣赏resieze()吧!!!

final Node<K,V>[] resize() {
        //第一步:确认newCap,newThr
        //oldTab 老Hash表      oldCap老数组容量              oldThr 老Hash表下次扩容的阀值
        //newCap 新数组容量    newThr 新数组下次扩容的阀值    threshold 扩容的阀值
        Node<K,V>[] oldTab = table;
        //HashMap采用懒加载方式,这里表示的就是如果没有数据就不分配内存空间
        //有数据就将Hash表长度赋值给oldCap
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {//数组进行初始化过之后扩容动作,进行的操作
            if (oldCap >= MAXIMUM_CAPACITY) {//老数组已经到达Map存储极限,无法扩容
                threshold = Integer.MAX_VALUE;
                return oldTab; 
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 
                     oldCap >= DEFAULT_INITIAL_CAPACITY) 
                newThr = oldThr << 1; 
        //再不超过Map存储极限的情况下,newCap,newThr进行左移一位(位运算),表示乘2  
        }
        else if (oldThr > 0) //Map未被初始化,但是通过构造方法设置了Map容量的情况
        //例如:new Hash(initCap,loadFactor) 、new Hash(initCap)、new Hash(Map)
            newCap = oldThr;//因为确定了容量大小,就会将阈值当做数组容量
        else {     // 相当于new Hash(),这里就是默认初始化HashMap的地方
            newCap = DEFAULT_INITIAL_CAPACITY; //默认HashMap数组大小为16
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //当oldThr > 0时,可能设置了loadFactor,所以就不需要预设阈值,所以就抽出来一个if
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;


        //第二步:根据数据结构进行扩容
        @SuppressWarnings({"rawtypes","unchecked"})
        //newTab的大小就是上文中的newCap,Node代表节点,就是每一个数据块
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            //循环数组
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {//e可能是链表的头节点,也可以是树的根节点
                    oldTab[j] = null;
                    //当数组里面只是数组结构时,直接将数据放到新数组里面即可
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                     //结构为红黑树时进行数据转移,小编不了解红黑树,就不讲解了
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // 当数据结构为数组+链表时数据的迁移方式
                        // loHead 低位链表表头   loTail 低位链表表尾
                        Node<K,V> loHead = null, loTail = null;
                        // hiHead 高位链表表头   hiHead 高位链表表尾
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {//遍历链表
                            next = e.next;
                            //只有0或者oldCap的次方数 Hash值&oldCap==0 
                            //这就是用来拆分链表的依据
                            if ((e.hash & oldCap) == 0) {//操作低位链表
                            //第一次循环尾节点没值,就代表这就是头节点
                                if (loTail == null)
                                    loHead = e;
                                else
                            //这个时刻,尾节点为loTail,即上个循环的值
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        //找到头结点之后,只需要把头结点传递给信数组就行了
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

Concurrent扩容

Java8中ConcurrentHashMap是怎样扩容的?(借鉴Alex大哥的视频,简短但不简单的让我认识到了ConcurrentHashMap的扩容机制)

参数介绍:

        通过Volatile修饰的sizeCtl、transferIndex共享变量,用来保证线程间的可见性。并且采用

自旋+CAS进行修改保证原子性

sizeCtl:

sizeCtl在扩容前表示:扩容前阈值

siezeCtl在扩容中的含义:siezeCtl=-1表示扩容完毕,siezeCtl=-2表示有一个线程正在扩容,每多一个线程进行扩容就会去对siezeCtl进行-1操作

sizeCtl在扩容后表示:扩容后阈值

transferIndex:

划分区间,保证每一个区间最多只有一个线程进行扩容。

扩容过程

第一步:新建一个新的Table用来储存数据,并且将sizeCtl设置为-2,表示当前有一个线程在进行扩容

        

第二步:线程A扩容的时候,transferIndex向前移动两位,划分出线程A扩容的区间,其他线程如果再去参加扩容,就是从transferIndex开始,不会再去影响ThreadA扩容区间了。

第三步:节点迁移,将正在扩容区间的数据发,放到newTable中。(这个过程是可以并发进行的,这就是提高效率的原因)

第四步:标记原节点。被标记位置,如果有查数据请求,就会让他去newTable中找数据;如果是增删操作,就会先暂停该请求,把该线程用来扩容。

添加一个线程进行扩容,sizeCtl会减一

第五步:所有线程扩容操作完成,sizeCl=-1,完成扩容

第六步:table = newTable,sizeCtl=扩容后阈值,完成扩容

注意:

        在扩容过程中,新的线程非查操作,都会先帮助扩容然后再去进行原操作,多个线程操作过程是并发进行的。

总结

这里直接使用Alex大佬的总结图了。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值