JAVA集合知识整理

Java集合知识整理

HashMap相关

HashMap的底层数据结构:jdk1.8之前数组+链表,jdk1.8后数组+链表+红黑树

HashMap的一些基础数据
  • 默认初始容量:1 << 4

  • 最大数组容量:1 << 30

  • 默认加载因子:0.75

    • 使用0.75的原因是这样空间利用率比较高,避免了比较多的hash冲突,使得底层的链表或者红黑树的高度比较低。
    • 如果调大使用1,意味着只有table填满才进行扩容,这样就会有大量的hash冲突,而导致底层的红黑树结构变得十分复杂。这样查询的效率就会很低。
    • 如果调小使用0.5,则table元素达到一半直接扩容,这样底层的链表长度或者红黑树的高度就会减小,查询效率增加了,但是空间利用率就会变得很低。
  • 链表转红黑树的临界值:8

    • 树结点所占空间是普通结点的两倍,所以只有当结点足够多的的时候才会使用树结点。虽然红黑树的效率比较高,但红黑树所占的空间比较大,因此在红黑树占空间大这个劣势不明显的时候才用红黑树。在理想状态下,受随机分布hashCode影响,链表中的结点遵循泊松分布,根据统计,链表长度在8的时候的概率很低,此时链表性能已经很差了,所以在这种情况下才会把链表转变为红黑树。
  • 红黑树转链表的临界值:6

  • 出现红黑树的最小table容量:64

  • 扩容每次:2倍,即大小总是2的幂次

    • 因为HashMap的底层计算位置方法是(n - 1) & hash。其中&是按位与计算,比较高效。&的计算方法是只有对应位置的数据都为1时,运算结果为1。当HashMap的容量为2的幂时,(n - 1)的二进制就是11111这种,这样与添加的元素的hash进行位运算时,能够充分的利用散列,使得添加的元素均匀分布在HashMap的每个位置上,减少hash碰撞,增加查询效率

      例如这是HashMap容量为16时,put进四个元素的分布情况,可以看出hash不同,一般被put进的位置也不同。

      img

      然而如果HashMap的容量为10,即使元素的hash不同,也会导致严重的hash碰撞

      img
HashMap的put流程
  • 首先判断键值对数组table长度是否为0或者null,如果是进行初始化操作
  • 利用hash()方法计算该key要在table中插入的位置
  • 如果当前位置没有元素,直接插入该位置
  • 如果当前位置存在元素,进行判断:
    • key一样(hashcode相等,为同一对象)覆盖掉前面的值
    • key不一样时:
      • 该位置是否已经是红黑树了,如果是的话就给红黑树里面插入结点
      • 该位置是链表,对链表进行尾插。插入后对链表的长度进行判断,如果链表长度达到临界值8且table长度大于64,转换为红黑树。如果在遍历链表的时候发现有key相等的情况,直接覆盖value
  • 插入成功后,判断实际存在的键值对是否已经超过阈值threshold,超过则进行扩容操作
HashMap的扩容机制
  • 当HashMap进行初始化或者HashMap中的键值对达到阈值时,调用resize()进行扩容操作。
  • 每次扩容的时候,新的容量总是原容量的2倍。
  • 扩容后table中的node对象要么在原来的位置,要么移动到偏移两倍的位置(进行偏移的时候是同样的计算index方法(newN - 1) & hash。
  • 链表进行插入的时候,jdk1.8以前用的是头插法进行赋值,jdk1.8开始用尾插法,避免了链表带环而导致的死循环问题。
HashMap与红黑树
  • 为什么使用红黑树?

    • jdk1.8之前采用链表,当链表过长的时候,查询的效率将会很低O(N)。而红黑树是一颗二叉搜索树,其查询效率比较高O(logN)
  • 什么时候转换为红黑树?

    • 当链表长度大于等于8且数组容量大于64的时候,才会将链表转换为红黑树。如果链表长度达到8而数组容量小于64时,进行扩容操作。
HashMap的hash方法与计算下标
  • key.hashCode() ^ key.hashCode() >>> 16 :两次hash函数再次降低了hash冲突的概率
  • index = (table.length - 1) & hash
  • 扩容后计算:newTab[e.hash & (newCap - 1)] = e; 可以看出是拿新的hash值继续分布
HashMap对比Hashtable
HashMapHashtable
线程安全非线程安全线程安全,内部方法基本上都经过synchronized修饰
效率
null key的支持支持,但只能有一个不支持
初始容量16,每次扩容变为原来的2倍11,每次扩容是2n + 1
hash方法key.hashcode & key.hashcode >>> 16hash % tab.length
jdk1.8之前HashMap多线程下的死循环问题
  • 之前版本对于链表的插入是头插法,这样在多个线程扩容迁移元素时,会将元素位置的改变,从而导致两个线程中出现元素的相互指向而形成循环链表。jdk1.8后采用了尾插法,从根源上杜绝了这种问题,但在并发的情况下依然是有一些问题的。所以涉及并发使用ConcurrentHashMap。
为什么是线程不安全的
  • 多个线程同时使用put去添加元素,假设正好存在两个put的key发生了hash碰撞,根据HashMap的实现,这两个key会添加到数组的同一个位置,这样会导致其中一个线程put的数据被覆盖。
  • 多个线程同时检测到元素超过数组大小 * loadFactor,这样会发生多个线程同时对table进行扩容,并重新计算元素的位置以及需要复制的数据,但是最终只有一个线程会扩容复制成功,导致其他线程的操作丢失。
为什么String、Integer这样的包装类适合做为K?
  • String、Integer等包装类都是final类型,保证了K的不可更改性。
  • 内部重写了equals()、hashCode()方法,遵守了HashMap内部规范,不容易出现hash值计算错误的情况。
指定容量问题

在创建HashMap的时候,一开始就指定容量,HashMap操作如下:

  • 调用内部的有参构造this(initialCapacity, DEFAULT_LOAD_FACTOR);
  • 该有参构造会对参数进行检查,如果无误的话,会将调用tableSizeFor(initialCapacity)方法去进行计算容量
  • tableSizeFor通过一系列的计算,会将容量转换为符合的2的幂次。

如果我们传来一个参数25,则会有一些问题:经过算法后,HashMap的初始容量为32,可25 > 32 * 0.75,又会进行一次扩容到64。所以在指定容量的时候应该避免这个操作。如果知道了具体容量,指定容量为 容量 / 0.75,可以减少一次扩容操作。

为什么不直接使用hashCode()处理后的hash值直接作为table的下标?
  • hashCode()返回的是int整数类型,范围为-(2 ^ 31) 到 (2 ^ 31 - 1),约有40亿个映射空间,而HashMap的容量范围最大是2 ^ 30,从而导致hashCode()计算的值很可能不在数组大小范围内。解决方式就是HashMap实现了自己的hash方法去给每一位分配存储空间

ArrayList、LinkedList、Vector相关

ArrayListLinkedListVector
数据结构动态数组双向链表/
随机访问效率O(1)O(N)/
增加删除效率低,因为要进行数组的操作
内存空间占用比较少高,因为不仅存储了数据,还存储了两个引用,一个指向前,另一个指向后/
线程安全非线程安全非线程安全线程安全
  • ArrayList扩容机制:创建一个空数组elementData,第一次插入数据时默认扩容至10,不够的话就扩容为10的1.5倍,还是不够的话就使用需要长度来作为elementData的长度。扩容机制的底层实际就是Arrays.copyOf()

  • 给ArrayList插入大量数据的方式:

    • 一开始指定容量,利用ArrayList(int initialCapacity)这个有参构造。这样解决了频繁扩容而导致的时间损耗。缺点是比较占用内存。
    • 一开始不指定,在插入前使用ensureCapacity方法去指定容量,和上面相比是不占用内存,且拷贝次数也大大减少了,效率比较高。
  • 多线程场景下如何使用ArrayList?

    • Collections.sunchronizedList(list)
  • ArrayList支持序列化,为什么要给数组加上transient修饰?

    • ArrayList重写了writeObject方法,先序列化ArrayList中的非transient元素,然后遍历elementData,只序列化存入的元素。这样既加快了序列化的速度,又减小了序列化之后的文件大小。
  • LinkedList对比ArrayList好处在于其可以控制元素的进出顺序(先进先出、先进后出),这也是为什么Queue、Stack推荐使用LinkedList的原因。

  • 遍历集合的三种方法:

    • for: 因为是基于元素的位置,按位置读取。所以我们可以知道,对于顺序存储,因为读取特定位置元素的平均时间复杂度是O(1),所以遍历整个集合的平均时间复杂度为O(n)。而对于链式存储,因为读取特定位置元素的平均时间复杂度是O(n),所以遍历整个集合的平均时间复杂度为O(n2)(n的平方)。
      • 顺序存储:读取性能比较高
      • 链式存储:时间复杂度太大,不适合用于遍历链式存储
    • foreach:分析Java字节码可知,foreach内部实现原理,也是通过Iterator实现的,只不过这个Iterator是Java编译器帮我们生成的,所以我们不需要再手动去编写。但是因为每次都要做类型转换检查,所以花费的时间比Iterator略长。时间复杂度和Iterator一样。
    • iterator:iterator内部维护了当前遍历的位置,所以每次遍历,读取下一个位置并不需要从集合的第一个元素开始查找,只要把指针向后移一位就行了,这样一来,遍历整个集合的时间复杂度就降低为O(n)

面试基础题:数组链表的对比

数组链表
元素个数元素个数固定元素个数按照需要增减
存储方式顺序存储:数组定义时分配,是内存中一块连续区域。可以根据元素的位置直接计算出内存地址,直接进行读取。读取一个特定位置元素的平均时间复杂度为O(1)。程序执行时动态的向系统申请,不要求连续每一个数据元素,在内存中都不要求处于相邻的位置,每个数据元素包含它下一个元素的内存地址。不可以根据元素的位置直接计算出内存地址,只能按顺序读取元素。读取一个特定位置元素的平均时间复杂度为**O(n)。**主要以链表为代表。
优点查询速度很快,通过下标查询是O1增删相对比较快,只需要改变结点的前驱后继即可
缺点对于非固定长度的列表,用数组会多出来许多的空间造成浪费;增删比较慢,因为要全部的元素后移或者前移;不利于扩展,数组定义空间不够需要重新定义查询对比数组较慢,为ON。

Set相关

  • HashSet是Set接口的典型实现,按照hash算法来存储集合中的元素,因此具有很好的存取和查找性能。不允许重复的值,不保证元素的排列顺序,非线程安全。
  • HashSet底层是基于HashMap实现的。
  • TreeSet是依据插入的值进行排序的,无法插入null。
  • LinkedHashSet维护了插入顺序 。

并发容器ConcurrentHashMap

  • jdk1.8的ConcurrentHashMap大量使用了synchronized、CAS无锁操作来保证ConcurrentHashMap操作的线程安全性。ConcurrentHashMap的值不允许为null

  • 如何实现线程安全?

    • 在jdk1.7的时候,ConcurrentHashMap对整个table数组进行了分割分段,每一把锁只锁容器其中的一部分数据,如果多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高了并发访问率。默认提供了16个分段锁,采用Segment + HashEntry的方式进行实现。一个Segment里面包含了一个HashEntry数组,这个HashEntry数组就类似于table数组,当对HashEntry数组进行修改时,必须先获取对应的Segment锁。Segment锁是一种可重入的锁(ReentrantLock)

    img

    • jdk1.8摒弃了分段锁的概念,而是直接用synchronized和CAS进行并发控制(jdk1.6之后对synchronized做了很多优化)。synchronized只锁定当前链表或者红黑二叉树的首结点,这样只要hash不冲突,就不会产生并发,大大的提升了效率。
      • 减少内存开销:使用segment,就要让每个节点继承AQS,但并不是每个节点都需要同步支持,只有链表的头结点(红黑树的根结点)才需要同步,使用分段锁耗费了内存。
      • 获得JVM的支持:可重入锁是API级别的,后续优化空间比较小。而synchronized是JVM支持的,JVM能够在运行的时候做出相应的优化措施。使用synchronized可以随着JDK版本的升级而不改动代码去提高性能。
  • put过程?

    • 对于每一个放入的值,spread()重哈希来确定这个值在table中的位置,以减少hash冲突
    • 初始化table
    • 判断是否能直接将新值插入到table数组中,计算的方式是hash对长度n取模 ,源代码:(n - 1) & hash
    • 如果可以直接插入,就直接利用CAS进行插入
    • 当前是否正在扩容,如果当前结点不为null且为forwardingNode时,说明当前ConcurrentHashMap正在进行扩容操作。则调用helpTransfer方法加速扩容。判断的方式是该结点的hash值是否等于-1。
    • 不是正在扩容且当前要插入的位置不为null,则通过synchronized加锁来保证安全
      • 当table[i]为链表的头结点时:进行链表插入,首先判断链表中是否已经有相同的hash和key了,如果有就直接覆盖,没有的话就在尾部插一个。
      • 当table[i]为红黑树的根结点时:调用putTreeVal进行直接插入。跟上面一样,如果红黑树中存在相同的,就直接覆盖,否则追加新结点。
    • 更改容量,并对当前容量进行检查,如果超过了临界值(实际大小*加载因子)就进行扩容
    • 插入完成后对链表长度进行检查,如果达到就把这个链表转换为红黑树
  • 扩容过程?

    • 构建一个nextTable,其容量是原table的两倍。
    • 将原来的table元素复制到nextTable中,根据运算得到当前遍历数组的位置i,利用tabAt方法获得i位置元素再进行判断
      • 如果该位置为空,就在原table中的该位置放入forwardNote结点,标识正在扩容
      • 如果该位置是Node结点(fh >= 0)且是链表的头结点,将该链表一分为二,构造一个反序链表,把他们分别放在nextTable的i和i+n的位置上。
      • 如果该位置是TreeBin结点(fh < 0)也做一个类似的处理,将该红黑树一分为二,构造一个反序的树,并且判断是否需要untreefy,把处理的结果分别在nextTable的i和i + n的位置上
      • 遍历完所有的结点以后就完成了复制工作,这时让nextTable作为新的table,并且更新sizeCtl作为新容量的0.75倍
  • 有关ConcurrentHashMap的size问题

    • 对于ConcurrentHashMap来说,这个table里面到底装了多少东西其实是个不确定的数量,因为可能是你统计的时候其他线程又往进塞了或者删除。不可能在调用size()方法的时候让其他线程停下来让你统计。因此这size只能是个估计值。为了统计size,ConcurrentHashMap定义了一些变量和一个内部类。

并发容器CopyOnWriteArrayList

背景:ArrayList并不是线程安全的,在线程在读取ArrayList的时候如果有写线程在写数据的时候,基于fast-fail机制会抛出ConcurrentModificationException异常。解决方法是可以用Vector,或者用Collections的静态方法将ArrayList包装成一个线程安全的类,但是这些方式都是采用Java关键字synchronized对方法进行修饰,利用独占锁的方式来保证线程安全的,这个效率不太高。

许多业务都是读多写少,例如系统配置、白名单黑名单等,只需要读取配置然后检测当前用户是否在该配置范围内。在这些业务中使用Vector,或者用Collections的静态方法都是不太合理的,尽管多个线程会从同一个数据容器中读取数据,但是读线程对该数据并不会修改,而且读写线程同时操作的概率也比较低。这样我们就会想到读写锁ReentrantReadWriteLock,可是其在写数据的时候读线程也会被阻塞。为了效率问题就有了CopyOnWriteArrayList。

COW的通俗理解:当我们往一个容器中添加元素的时候,不直接往当前容器添加,而是先将当前容器进行copy,整出来一个新容器,然后给新的容器里面添加元素,添加完成后将原容器的引用再指向新容器。

  • 设计思想:写时复制,通过延时更新的策略来实现数据的最终一致性,并且能够保证读线程间不阻塞。
  • 底层的数组加了volatile关键字。

COW add逻辑:

  • 使用ReentrantLock,保证同一时刻只有一个写线程正在进行数组的复制,否则内存中就会有多份被复制的数据。
  • 获取旧数组的引用,创建新数组并将旧数组的数据复制到新数组中,然后往新数组中加入元素,最后将旧数组的引用指向新数组。
    • 为什么要复制呢?因为volatile修饰的只是数组引用,数组中的元素修改是不能保证可见性的。
  • 数组通过volatile,将旧数组引用指向新的数组这一操作,根据volatile的happens-before规则,写线程对数组引用的修改对读线程是可见的。
  • 在写数据的时候是在新的数组中插入数据的,从而保证读写是在两个不同的数据容器中进行操作(读旧写新)。

COW vs 读写锁:

  • 相同点:两者都是通过读写分离的思想实现,且读线程间是互不阻塞的
  • 不同点:
    • 对于读线程而言,为了实现数据实时性,在写锁被获取后,读线程会等待;当读锁被获取后,写线程会等待,从而解决数据一致性问题。
    • 而COW则是写的时候读线程依然能够进行读操作。牺牲了数据实时性而保证了数据最终一致性。

COW的缺点:

  • 内存占用问题:CopyOnWriteArrayList是写时复制机制,所以在进行写操作的时候,内存里会同时存在两个对象,旧的对象和新写入的对象(复制的时候只是复制容器里的引用,在写的时候会创建新对象添加到新的容器里,而旧的容器对象还在使用,所以有两份)如果这些对象占容的内存比较大,就很有可能频繁的导致Full GC。
  • 数据一致性问题:CopyOnWriteArrayList只能保证数据的最终一致性,不能保证数据的实时一致性。如果你希望写入的数据能够马上被读取到,不要使用该容器。

迭代器与快速失败机制:fail-fast

Java采用了迭代器来为各种容器提供了公共操作接口,这样使得对容器的便利操作与其底层的实现相隔离,达到解耦的效果。

在Iterator接口中定义了三个方法:

  • boolean hasNext():如果仍然有元素可以迭代,返回true。
  • E next():返回迭代的下一个元素。
  • void remove():从迭代器指向的collection中移除迭代器返回的最后一个元素。

具体实现方式,是在Collection子集中定义的内部类Itr,实现了Itreator接口。Itr中有三个变量,分别是

  • cursor:表示下一个元素的索引位置
  • lastRet:表示上一个元素的索引位置
  • expectModCount:预期被修改的次数

快速失败机制是Java集合的一种错误检测机制,在用迭代器遍历一个集合对象时,如果遍历过程中其他线程对集合对象的内容进行增加删除修改(具体实现就是expectModCount与modCount不符合),则会抛出Concurrent Modification Exception。常见的就是多个线程对集合进行结构上的改变操作时,就很有可能发生快速失败机制。单线程下也会发生快速失败机制。

解决方案:

  • 对于单线程使用迭代器遍历时,在执行删除的时候,不要使用集合自带的删除,而是用迭代器带的remove方法。
  • 而对于多线程,在使用Iterator迭代的时候使用synchronized或者Lock进行同步,或者使用并发容器CopyOnWriteArrayList代替ArrayList和Vector。

如何确保一个集合不被修改

使用Collection col = Collections.unmodifiableCollection(list); 来创建一个只读集合。这样,对于改变该集合的任何操作都会抛出java.lang.UnsupportedOperationException异常

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值