八股文系列:程序开发中的集合容器,你了解多少?

文章有点长请耐心观看,相信看完之后会对你有帮助和收获,后续也会持续更新Java各个技术点,可以持续关注我哦

集合的由来

通常,我们的Java程序需要根据程序运行时才知道创建了多少个对象。但若非程序运行,程序开发阶段,我们根本不知道到底需要多少个数量的对象,甚至不知道它的准确类型。为了满足这些常规的编程需要,我们要求能在任何时候,任何地点创建任意数量的对象,而这些对象用什么来容纳呢?我们首先想到了数组,但是!数组只能存放同一类型的数据,而且其长度是固定的,那怎么办了?集合便应运而生了。

集合容器概述

什么是集合
集合框架: 用于存储数据的容器。

集合框架是为表示和操作集合而规定的一种统一的标准的体系结构。 任何集合框架都包含三大块内容:对外的接口、接口的实现和对集合运算的算法。

接口: 表示集合的抽象数据类型。接口允许我们操作集合时不必关注具体实现, 从而达到“多态”。在面向对象编程语言中,接口通常用来形成规范。

实现: 集合接口的具体实现,是重用性很高的数据结构。

算法: 在一个实现了某个集合框架中的接口的对象身上完成某种有用的计算的方法,例如查找、排序等。这些算法通常是多态的,因为相同的方法可以在同一个 接口被多个类实现时有不同的表现。事实上,算法是可复用的函数。 它减少了程序设计的辛劳。

集合框架通过提供有用的数据结构和算法使你能集中注意力于你的程序的重要部分上,而不是为了让程序能正常运转而将注意力于底层设计上。

通过这些在无关API之间的简易的互用性,使你免除了为改编对象或转换代码以 便联合这些API而去写大量的代码。 它提高了程序速度和质量。

集合的特点

集合的特点主要有如下两点:

  • 对象封装数据,对象多了也需要存储。集合用于存储对象。
  • 对象的个数确定可以使用数组,对象的个数不确定的可以用集合。因 为集合是可变长度的。

集合和数组的区别

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

数据结构: 就是容器中存储数据的方式。

对于集合容器,有很多种。因为每一个容器的自身特点不同,其实原理在于每个 容器的内部数据结构不同。

集合容器在不断向上抽取过程中,出现了集合体系。在使用一个体系的原则:参阅顶层内容。建立底层对象。

 

集合图

 ​ 常用的集合类有哪些?

Map接口和Collection接口是所有集合框架的父接口:

  1. Collection接口的子接口包括:Set接口和List接口
  2. Map接口的实现类主要有:HashMap、TreeMap、Hashtable、 ConcurrentHashMap以及Properties等
  3. Set接口的实现类主要有:HashSet、TreeSet、LinkedHashSet等
  4. List接口的实现类主要有:ArrayList、LinkedList、Stack以及Vector等


HashMap

 数据结构(重点)
HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的即哈希表和红黑树

首先有一个每个元素都是链表(可能表述不准确)的数组,当添加一个元素(key-value)时,就首先计算元素key的hash值,以此确定插入数组中的位置,但是可能存在同一hash值的元素已经被放在数组同一位置了,这时就添加到同一hash值的元素的后面,他们在数组的同一位置,但是形成了链表,同一各链表上的Hash值是相同的,所以说数组存放的是链表。而当链表长度太长时,链表就转换为红黑树,这样大大提高了查找的效率

当链表数组的容量超过初始容量的0.75时,再散列将链表数组扩大2倍,把原链表数组的搬移到新的数组中。
利用反射机制获取容量,阈值,元素个数

public class HashMapT {
    public static void main(String[] args) throws Exception {
        //指定初始容量15来创建一个HashMap
        HashMap m = new HashMap();
        //获取HashMap整个类
        Class<?> mapType = m.getClass();
        //获取指定属性,也可以调用getDeclaredFields()方法获取属性数组
        Field threshold = mapType.getDeclaredField("threshold");
        //将目标属性设置为可以访问
        threshold.setAccessible(true);
        //获取指定方法,因为HashMap没有容量这个属性,但是capacity方法会返回容量值
        Method capacity = mapType.getDeclaredMethod("capacity");
        //设置目标方法为可访问
        capacity.setAccessible(true);
        //打印刚初始化的HashMap的容量、阈值和元素数量
        System.out.println("容量:" + capacity.invoke(m) + "    阈值:" + threshold.get(m) + "    元素数量:" +m.size());
        for (int i = 0; i < 30; i++) {
            m.put(i, i);
            //动态监测HashMap的容量、阈值和元素数量
            System.out.println("容量:" + capacity.invoke(m) + "    阈值:" + threshold.get(m) +  "    元素数量:" +m.size());
        }
    }
}

 

HashMap怎么设定初始容量大小的吗?

一般如果new HashMap() 不传值,默认大小是16,负载因子是0.75, 如果自己传入初始大小k,初始化大小为 大于k的 2的整数次方,例如如果传10,大小为16。

HashMap的哈希函数怎么设计的吗?

hash函数是先拿到 key 的hashcode,是一个32位的int值,然后让hashcode的高16位和低16位进行异或操作

那你知道为什么这么设计吗?

一定要尽可能降低hash碰撞,越分散越好;

算法一定要尽可能高效,因为这是高频操作, 因此采用位运算;

为什么采用hashcode的高16位和低16位异或能降低hash碰撞?hash函数能不能直接用key的hashcode?
1、原因:当数组的长度很短时,只有低位数的hashcode值能参与运算。而让高16位参与运算可以更好地均匀散列,减少碰撞,进一步降低hash冲突的几率。并且使得高16位和低16位的信息都被保留了
2、因为key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。int值范围为-2 ^32 =>+2^32 或者**-2147483648~2147483647**,前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。你想,如果HashMap数组的初始大小才16,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。

1.8对hash函数做了优化,1.8还有别的优化吗?

数组+链表改成了数组+链表或红黑树;
链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后;
扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;
为什么要做这几点优化

  • 防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);
  • 因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;

现在把架构师必须具备的一些技术总结出来一套思维导图和录制了一些相关视频,分享给大家,供大家参考。感兴趣的铁子们可以后台私信【架构】免费获取高清知识体系思维导图及相关资料,后续也会持续更新Java的其它知识点和面试方法资料等等...感兴趣的铁汁们可以持续关注我

HashMap 和 ConcurrentHashMap 的区别

ConcurrentHashMap对整个桶数组进行了分割分段(Segment),然后在每一个分段上都用lock锁进行保护,相对于HashTable的synchronized锁的粒度更精细了一些,并发性能更好,而HashMap没有锁机制,不是线程安全的。(JDK1.8之后ConcurrentHashMap启用了一种全新的方式实现,利用CAS算法。)
HashMap的键值对允许有null,但是ConCurrentHashMap都不允

ConcurrentHashMap 和 Hashtable 的区别?

ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。

底层数据结构: JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;

实现线程安全的方式(重要):

① 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,比Hashtable效率提高16倍。) 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;

② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

两者的对比图:

HashTable:

 JDK1.7的ConcurrentHashMap:

 JDK1.8的ConcurrentHashMap(TreeBin: 红黑二叉树节点 Node: 链表节点):

 答:ConcurrentHashMap 结合了 HashMap 和 HashTable 二者的优势。 HashMap 没有考虑同步,HashTable 考虑了同步的问题。但是 HashTable 在每次同步执行时都要锁住整个结构。 ConcurrentHashMap 锁的方式是稍微细粒度的。

ArrayList理解,谈谈你的理解(重点)

ArrayList是基于数组实现的,是一个动态数组,get和set效率高;其容量能自动增长,内存连续。

说说ArrayList为什么增删慢

ArrayList本质是数组的操作

增删慢:
1.每当插入或删除操作时 对应的需要向前或向后的移动元素
2.当插入元素时 需要判定是否需要扩容操作
扩容操作:创建一个新数组 增加length 再将元素放入进去
较为繁琐

查询快:
数组的访问 实际上是对地址的访问 效率是挺高的
列如 new int arr[5];
arr数组的地址假设为0x1000
arr[0] ~ arr[5] 地址可看作为 0x1000 + i * 4

添加元素

    /**
     * Appends the specified element to the end of this list.
     *
     * @param e element to be appended to this list
     * @return <tt>true</tt> (as specified by {@link Collection#add})
     */
    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!! 是否扩容
        elementData[size++] = e; // 最后一个位置添加元素,数组长度+1
        return true;
    }
    public void add(int index, E element) {
        rangeCheckForAdd(index);

        ensureCapacityInternal(size + 1);  // Increments modCount!!
        System.arraycopy(elementData, index, elementData, index + 1,
                         size - index);
        elementData[index] = element;
        size++;
    }

直接添加元素,先判断添加元素之后容量是否够用,再遍历list数组,到最后一个位置,元素添加进去,长度+1
指定位置添加元素,先判断指定位置是否超过数组范围,在判断添加元素之后容量是否够用,最后
在指定位置添加元素,原来在这个位置的元素后移 数组长度-索引位置

删除元素

// 指定位置删除
 public E remove(int index) {
        rangeCheck(index);

        modCount++;
        E oldValue = elementData(index);

        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--size] = null; // clear to let GC do its work

        return oldValue;
    }
// 指定元素删除
public boolean remove(Object o) {
        if (o == null) {
            for (int index = 0; index < size; index++)
                if (elementData[index] == null) {
                    fastRemove(index);
                    return true;
                }
        } else {
            for (int index = 0; index < size; index++)
                if (o.equals(elementData[index])) {
                    fastRemove(index);
                    return true;
                }
        }
        return false;
    }
 /*
     * Private remove method that skips bounds checking and does not
     * return the value removed.
     */
    private void fastRemove(int index) {
        modCount++;
        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--size] = null; // clear to let GC do its work
    }


分析:如果查看这个元素是否存在list数组,遍历存在的话进行删除操作,指定元素前移
如果不存在,遍历数组,存在为空的元素位置,将其删除,数组长度减一
根据指定位置元素,首先检查索引位置是否正确,其次删除指定位置元素,元素前移,数组长度减一
最后一个位置置为null

ArrayList常用方法

1、add(Object element): 向列表的尾部添加指定的元素。
2、size(): 返回列表中的元素个数。
3、get(int index): 返回列表中指定位置的元素,index从0开始。
4、add(int index, Object element): 在列表的指定位置插入指定元素。
5、set(int i, Object element): 将索引i位置元素替换为元素element并返回被替换的元素。
6、clear(): 从列表中移除所有元素。
7、isEmpty(): 判断列表是否包含元素,不包含元素则返回 true,否则返回false。
8、contains(Object o): 如果列表包含指定的元素,则返回 true。
9、remove(int index): 移除列表中指定位置的元素,并返回被删元素。
10、remove(Object o): 移除集合中第一次出现的指定元素,移除成功返回true,否则返回false。
11、iterator(): 返回按适当顺序在列表的元素上进行迭代的迭代器。

ArrayList⽤来做队列合适么

队列一般是FIFO的,如果用ArrayList做队列,就需要在数组尾部追加数据,数组头部删除数组,反过来也可以。但是无论如何总会有一个操作会涉及到数组的数据搬迁,这个是比较耗费性能的。

这个回答是错误的!

ArrayList固然不适合做队列,但是数组是非常合适的。比如ArrayBlockingQueue内部实现就是一个环形队列,它是一个定长队列,内部是用一个定长数组来实现的。另外著名的Disruptor开源Library也是用环形数组来实现的超高性能队列,具体原理不做解释,比较复杂。简单点说就是使用两个偏移量来标记数组的读位置和写位置,如果超过长度就折回到数组开头,前提是它们是定长数组。

Iterator迭代器

我们可以发现一个特点,上述所有的集合类,除了map系列的集合,即左边的集合都实现了Iterator接口

它是Java集合的顶层接口(不包括map系列的集合,Map接口是map系列集合的顶层接口)

  Object next():返回迭代器刚越过的元素的引用,返回值是Object,需要强制转换成自己需要的类型。

  boolean hasNext():判断容器内是否还有可供访问的元素。

  void remove():删除迭代器刚越过的元素。

所以除了map系列的集合,我么都能通过迭代器来对集合中的元素进行遍历。

注意:我们可以在源码中追溯到集合的顶层接口,比如Collection接口,可以看到它继承的是类Iterable

 ​ 然后Iterable中有Iterator

 ​ 我们来具体聊聊 Iterator

 ​ 总共4个方法

  • 判断下个迭代器是否还有下一个元素
  • 返回下一个元素的值,并且把自身offset移动下一位
  • 第三个方法 这个可以删除用这个迭代器集合中的元素(注意如果删除之后还是前面获得的迭代器,你会发现原来的迭代器还是没变,得重新获得删除元素之后的迭代器)
  • 1.8的新方法 可以直接遍历迭代器剩下的元素,如果从最开始的话就是遍历所有的迭代器(1.8的函数式编程,写的蛮爽,后面博客会补)

所以我想说的是所有的集合都有迭代器可以用来遍历哈 它是所有集合的最上级

ListIterator

为什么要讲它呢,本来没打算讲,但是想了一下,要写就写全点吧

 ​ ListIterator 是 Iterator 的子接口,ListIterator 不仅可以向后迭代,也可以向前迭代。相比 Iterator,

  • 它增加了以下这些方法:
  • boolean hasPrevious();
  • E previous();
  • int nextIndex();
  • int previousIndex();
  • void set(E e);
  • void add(E e);

其实就是 增加可以向前一个下标的操作。大家可以写个测试方法自己试试就知道了 还可以对迭代出来的元素进行替换set()方法

Collection接口介绍

Collection的作用就是规定了一个集合有哪些基本的操作。

 这里主要是插入数据,清空数据,是否包含,是否相等,集合里的数据个数和转化成熟组这几种操作。

比如:

  int size() 获取元素个数

  boolean isEmpty() 是否个数为零

  boolean contains(Object element) 是否包含指定元素

  boolean add(E element) 添加元素,成功时返回true

  boolean remove(Object element) 删除元素,成功时返回true

  Iterator iterator() 获取迭代器      Stream 1.8的流 (后面也比较常用)

还有些操作整个集合的方法,比如:

  boolean containsAll(Collection<?> c)  是否包含指定集合 c 的全部元素

  boolean addAll(Collection<? extends E> c) 添加集合 c 中所有的元素到本集合中,如果集合有改变就返回 true

  boolean removeAll(Collection<?> c) 删除本集合中和 c 集合中一致的元素,如果集合有改变就返回 true

  boolean retainAll(Collection<?> c)  保留本集合中 c 集合中两者共有的,如果集合有改变就返回 true

  void clear()  删除所有元素

还有对数组操作的方法:   Object[] toArray() 返回一个包含集合中所有元素的数组

   T[] toArray(T[] a) 返回一个包含集合中所有元素的数组,运行时根据集合元素的类型指定数组的类型   

ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?

JDK1.7

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

在JDK1.7中,ConcurrentHashMap采用Segment + HashEntry的方式进行实现,结构如下:

一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。

  1. 该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色;
  2. Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。

JDK1.8

在JDK1.8中,放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N 倍。

结构如下:

看插入元素过程(建议去看看源码):

如果相应位置的Node还没有初始化,则调用CAS插入相应的数据;

	else if ((f = tabAt(tab, i = (n ‐ 1) & hash)) == null) {
	if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
	break; // no lock when adding to empty bin
	}
复制代码

如果相应位置的Node不为空,且当前该节点不处于移动状态,则对该节点加synchronized锁,如果该节点的hash不小于0,则遍历链表更新节点或插入新节点;

 if (fh >= 0) { 
 binCount = 1;
	for (Node<K,V> e = f;; ++binCount) {
	K ek;
	if (e.hash == hash &&
	((ek = e.key) == key ||
	(ek != null && key.equals(ek)))) {
	oldVal = e.val;
	if (!onlyIfAbsent)
	e.val = value;
	break;
	}
	Node<K,V> pred = e;
	if ((e = e.next) == null) {
	pred.next = new Node<K,V>(hash, key, value, null);
	break;
	   }
	}
}
复制代码
  1. 如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过 putTreeVal方法往红黑树中插入节点;如果binCount不为0,说明put操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin方法转化为红黑树,如果oldVal不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值;
  2. 如果插入的是一个新节点,则执行addCount()方法尝试更新元素个数 baseCount;

结尾

好了各位,以上就是这篇文章的全部内容了,能看到这里的人呀,都是人才。希望能对铁子们有帮助和收获。创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见。喜欢的铁子们可以点点赞和关注, 文章持续更新,也可以评论出你想看哪一块技术。铁子们的支持是我的动力,创作离不开铁子们的支持,在此先感谢大家!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值