Java八股文——集合

1. HashMap

  基本信息:

  • 数据结构:数据+链表,数组+链表+红黑树
    • jdk1.8中,当链表大小超过8时,就会转换为红黑树,当小于6时变回链表,主要是根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候转换,小于等于6转为链表
  • 默认大小:16
  • 负载因子:0.75(原因:0.75的负载因子,能让随机hash更加满足0.5的泊松分布

  默认容量为什么是16?

  • 当我们想要往一个hashmap中put元素的时候,需要通过hash方法计算出放到哪个桶中,hash方法是根据key来定位这个K-V在链表数组中的位置的,hash的公式:HashCode(key) & (length-1),其实就是取模.
    用位运算来代替取模,主要就是因为位运算的效率较高。例如:X % 2^n = X & (2^n -1),假设n为3,则2^3 = 8 ,表示为二进制就是1000,2^3 - 1 = 7,即0111,此时 X & (2^n -1)就相当于取X的二进制的最后三位数,从二进制角度来看,X / 8相当于 X >> 3 ,此时得到了X / 8 的商,而被移位的部分(后三位)就是 X % 8,也就是余数
    因此,如果保证map的长度是 2^n的话,就可以实现取模运算了
    那么为什么一定是16呢?
    关于这个默认容量的选择,官方没有给出相关解释,应该是一个经验值,需要在效率和内存使用上权衡,不能太小也不能太大
  • 并且,Hashmap在两个可能改变容量的地方做了兼容处理,一个是扩容,一个是初始化。
  • 当我们初始化Map且设置了容量时,HashMap不一定会采用传入的值,而是经过计算,得到一个新值,以提高hash效率,源码中的算法就是根据用户传入的容量值,得到第一个比他大的二次幂返回

  扩容:

·  扩容的阈值是负载因子 * 当前容量。

  1. 创建一个新的Entry数组,长度是原数组的两倍
  2. rehash:便利原Entry数组,将所有的Entry重新hash到新数组(重新hash是因为长度扩大之后,hash的值可能不同)

  Hashmap是怎么放入数据的?(put方法)

  • 首先判断key是否是null,是的话hash值就是0,获得hash值后进行扰动,1.7版本是5次异或4次位移,1.8是一次异或一次位移,然后根据计算出的新hash值找到对应的index,然后找到对应Node/Entity,遍历链表/红黑树,遇到hash值相同且equals相同的,则覆盖,不是则新增,如果节点数大于8就树化。put完成后,判断当前长度是否大于阈值,是就扩容
  • 对于链表插入,1.7之前是头插法,从1.8开始变成尾插法,主要是为了解决rehash出现的死循环问题,并且1.7的时候是先扩容再插入,而1.8是先插入后扩容。正常来说,如果先插入,就可能节点需要树化,会多一次损耗,个人猜测,是由于读写问题,hashmap并不是线程安全的,如果先扩容后插入,那么扩容期间是访问不到新放入的值的,所以先插入,在扩容期间是可以访问到值的

  为什么需要从头插法变成尾插法?

  • 在多线程的时候,如果不同的线程同时插入一个map,当达到扩容阈值时,两个线程同时触发扩容,而头插法在循环中会导致某个节点发生循环指向,后续查找元素过程中就会发生死循环

  HashMap线程不安全主要体现在:

  1. 1.7多线程环境下,扩容会造成环形链表或数据丢失
  2. 1.8多线程环境下,put方法会发生数据覆盖的情况

  如何处理HashMap的线程不安全的情况?

  1. 使用Collection.synchronizedMap()创建线程安全的map
    • 实现线程安全的原理:
      SynchronizedMap内部维护了一个普通对象map和排斥锁mutex,通过该方法创建出map之后,操作map就会以mutex对方法进行加锁,mutex默认是SynchronizedMap,可以通过构造参数传入
  2. HashTable
    • 所有数据操作方法都加锁,效率低不考虑
    • 不允许键值为null,HashMap可以
    • 使用安全失败机制(fail-safe)而HashMap使用快速失败机制(fail-fast),在安全失败机制下,如果使用null,会使得无法判断对应的key是不存在还是为空
  3. ConcurrentHashMap
    • 结构与HashMap相同,但是采用了CAS + synchronized来保证安全性
    • 最大容量:1 << 30
    • PUT操作:
      1. 根据key计算出hashcode
      2. 判断是否需要初始化
      3. 根据当前key定位的node,如果为空则可以写入,利用CAS尝试写入,失败则自旋保证成功
      4. 如果当前位置的hashcode == MOVED == -1,则需要扩容
      5. 如果都不满足,则利用synchronized写入数据
      6. 如果大于TREEIFY_THRESHOLD则需要转换为红黑树
    • GET操作
      • 直接根据计算出来的hashcode寻址,如果在桶上直接返回,如果是红黑树则按照树的方式获取,如果不满足则用链表方式遍历获取
    • 扩容
      • 1.7版本是基于segment,segment内部维护了HashEntity数组,所以扩容方式是在这个基础上的,类似HashMap扩容
      • 1.8版本扩容较为复杂,利用了ForwardingNode,先根据机器内核数来分配每个线程能分到的busket数(最小是16),这样可以做到多线程协助迁移,提升速度,然后根据自己分配的busket数来进行节点迁移,如果为空就放置ForwardingNode,代表迁移完成,如果是非空节点(判断是不是ForwardingNode,是就结束了),加锁,链路循环进行迁移
    • 为什么在Java8放弃了segment?
      • 由于在创建一个ConcurrentHashMap的时候,segment的数量就已经固定了,当需要进行扩容的时候,变化的是segment的大小,segment继承了ReentrantLock,如果segment变得很大了,那么锁的粒度就会变得很大,分段锁就没有意义了,每一个段就相当于一个同步的Map
      • 在java8中替换成了Node数组链表红黑树,并且因为ReentrantLock需要节点继承AQS来获得同步支持,会增大内存开销,因此java8不使用ReentrantLock,改为synchronized,synchronized是jvm直接支持的,能够在运行的时候进行相应的优化措施,比如锁粗化,自旋等

2.List

  1. ArrayList

    • 底层使用数组实现,查找与访问较快,新增和删除较慢
    • 实现了RandomAccess接口,可以随机访问
    • 默认初始容量 10
    • 以1.5倍扩容
    • 调用构造函数时只会初始化数组大小,而size这个变量不会初始化,此时如果调用set方法指定下标设置数据会抛出数组越界异常,因为set方法中是根据size来判断是否可以设置当前下标的数据
    • 构造方法中,如果设置初始化大小为0,则数组扩容时,大小由0变为1
    • 无参构造方法中,数组默认大小也是0,但扩容时,大小由0变为10
  2. LinkedList

    • 底层使用链表实现,新增与删除较快,查找较慢
    • 内部维护了链表长度,以及头尾节点,获取长度不需要遍历
    • 实现了队列接口,具有队列的先进先出的功能

 

posted @ 2020-09-15 11:41  一秋复一秋  阅读(10537)  评论(0编辑  收藏  举报