Java集合原理你都不知道?公司门很大,你忍一下,就不送了......

欢迎访问我的blog http://www.codinglemon.cn/

立个flag,8月20日前整理出所有面试常见问题,包括有:
Java基础、JVM、多线程、Spring、Redis、MySQL、Zookeeper、Dubbo、RokectMQ、分布式锁、算法。

1. 集合篇

1.1 HashMap

1.1.1 HashMap的底层数据结构?

  1. JDK 1.7 数组+链表,使用头插法。
  2. JDK1.8 数组+链表+红黑树,使用尾插法。数组长度小于64且链表长度大于8时,会先扩容数组;当数组长度大于64且链表长度大于8时会将链表转为红黑树减少搜索时间。链表长度大于等于8时转成红黑树正是遵循泊松分布。根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。

1.1.2 HashMap的存取原理?

存数据的时候,先将key通过hash函数计算出插入的位置,如果该位置上有元素,则将数据插入到该位置对应的链表的末尾。每一个节点都会保存自身的hash、key、value、以及下个节点。
Hash公式:index = HashCode(Key) & (Length - 1)
取数据的时候,也是先key通过hash函数计算出要取出的位置,然后拿到key与链表或者红黑树上的key进行比较,找出相等的key,取出value值。

1.1.3 1.7和1.8的区别

  1. 底层数据结构不一样,1.7是数组+链表,1.8则是数组+链表+红黑树结构(当数组长度大于64且链表长度大于8,转为红黑树)。
  2. JDK1.8中resize()方法在表为空时,创建表;在表不为空时,扩容;而JDK1.7中resize()方法负责扩容,inflateTable()负责创建表。
  3. 1.8中没有区分键为null的情况,而1.7版本中对于键为null的情况调用putForNullKey()方法。但是两个版本中如果键为null,那么调用hash()方法得到的都将是0,所以键为null的元素都始终位于哈希表table【0】中。
  4. 当1.8中的桶中元素处于链表的情况,遍历的同时最后如果没有匹配的,直接将节点添加到链表尾部;而1.7在遍历的同时没有添加数据,而是另外调用了addEntry()方法,将节点添加到链表头部。
  5. 1.7中新增节点采用头插法,1.8中新增节点采用尾插法。这也是为什么1.8不容易出现环型链表的原因。
  6. 1.7中是通过更改hashSeed值修改节点的hash值从而达到rehash时的链表分散,而1.8中键的hash值不会改变,rehash时根据(hash&oldCap)==0将链表分散。
  7. 1.8rehash时保证原链表的顺序,而1.7中rehash时会改变链表的顺序,链表顺序会反转(头插法导致),那这在多线程情况下有可能会导致形成环形链表,因为可能会改变链表内部顺序,导致原本是链表中A–>B,在rehash之后又会让B—>A,且A—>B的链接还在。
  8. 在扩容的时候:1.7在插入数据之前扩容,而1.8插入数据成功之后扩容。

1.1.4 为啥会线程不安全?

  1. 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。
  2. 在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。

1.1.5 有什么线程安全的类代替么?

可以使用ConcurrentHashMap。

1.1.6 默认初始化大小是多少?为啥是这么多?为啥大小都是2的幂?

默认初始化大小:在HashMap中,有两个比较容易混淆的关键字段:size和capacity ,这其中capacity就是Map的容量,而size我们称之为Map中的元素个数。HashMap的默认capacity值为16。
为什么是16:这应该是一个经验值,太小了就有可能频繁发生扩容,影响效率。太大了又浪费空间,不划算。
大小是2的幂:因为位运算的效率比取模运算效率要高,因此用位运算取代取模运算,但这就要求数组长度为2的幂,用按位与运算的方式。
如果用户指定容器容量大小,则capacity会向上扩展到2的幂。

1.1.7 HashMap的扩容方式?负载因子是多少?为什是这么多?

HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。
loadFactor是装载因子,表示HashMap满的程度,默认值为0.75f,设置成0.75有一个好处,那就是0.75正好是3/4,而capacity又是2的幂。所以,两个数的乘积都是整数。对于一个默认的HashMap来说,默认情况下,当其size大于12(16*0.75)时就会触发扩容。

1.1.8 HashMap的主要参数都有哪些?

1、 默认初始化容量:DEFAULT_INITIAL_CAPACITY = 1 << 4(16)
2、 最大容量:MAXIMUM_CAPACITY = 1 << 30(2的30次方)
3、 默认负载因子:DEFAULT_LOAD_FACTOR = 0.75f
4、 树形化阈值:TREEIFY_THRESHOLD = 8,即当链表的长度大于8的时候,会将链表转为红黑树,优化查询效率。
5、 解树形化阈值:UNTREEIFY_THRESHOLD = 6,其实就是当红黑树的节点的个数小于等于6时,会将红黑树结构转为链表结构。
6、 树形化的最小容量:MIN_TREEIFY_CAPACITY = 64,HashMap数组的容量大于等于64且链表长度大于8才转换为红黑树

1.1.9 HashMap是怎么处理hash碰撞的?

1.7使用链表,1.8使用链表+红黑树,链表时间复杂度为O(n),红黑树时间复杂度为O(logn),红黑树是不严谨的AVL树。但是查找、插入、删除效率都比较高。

1.1.10 hash的计算规则?

return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
将hashcode右移16位相当于将高16位移入到低16位,再与原hashcode做异或计算(位相同为0,不同为1)可以将高低位二进制特征混合起来 => 高16位没有发生变化,但是低16位改变了。拿到的hash值会参与hashmap中数组槽位的计算,计算公式:(n - 1) & hash
高区的16位很有可能会被数组槽位数的二进制码锁屏蔽,如果我们不做刚才移位异或运算,那么在计算槽位时将丢失高区特征。

1.2 ConcurrentHashMap

1.2.1 快速失败(fail—fast)是啥,应用场景有哪些?安全失败(fail—safe)同问。

快速失败(fail—fast) 是java集合中的一种机制, 在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。
迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。
集合在被遍历期间如果内容发生变化,就会改变modCount的值。
每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。
Tip:这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。
因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。
java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)算是一种安全机制吧。
安全失败(fail—safe) 大家也可以了解下,java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。(采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。因此在开始遍历后也不会对遍历过程中发生修改的元素敏感)

1.2.2 谈谈你理解的 Hashtable,讲讲其中的 get put 过程?

HashTable的get:

  1. 首先计算key的下标,获取hashtable中的数组
  2. 找到对应下标数组,获取对应的链表
  3. 遍历链表,判断是否有满足条件的entry,如果有,获取value对应的值返回
  4. 否则,返回为空.

HashTable的put:

  1. 首先判断value的是是否为空,如果为空,抛出空指针异常
  2. 如果不为空,获取hashtable中的table,获取key的hash值以及对应的下标
  3. 从数组中获取下标对应的链表信息,遍历链表,查询是否有满足条件的entry对象,如果有满足条件的,旧值被新值覆盖,返回旧值
  4. 如果没有,添加一个新的entry
  5. 添加新的entry前先判断当前数组的元素数量是否满足扩容,如果满足,进行扩容后,重新计算hash值和下标
  6. 然后获取当前已经存在的entry链表,然后新建的entry放在原链表的头部,存进数组中.
    总结:线程安全,因为对数组操作都会上锁,使用synchronized关键字,所以效率比较低。

1.2.3 ConcurrentHashMap是如何实现的?他的get和put过程?

ConcurrentHashMap:
JDK1.7:
image.png
如图所示,是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。
Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:

static final class Segment<K,V> extends ReentrantLock implements Serializable {
    private static final long serialVersionUID = 2249069246763182397L;
    // 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
    transient volatile HashEntry<K,V>[] table;
    transient int count;
    // 记得快速失败(fail—fast)么?
    transient int modCount;
    // 大小
    transient int threshold;
    // 负载因子
    final float loadFactor;
}

HashEntry跟HashMap差不多的,但是不同点是,他使用volatile去修饰了他的数据Value还有下一个节点next。

volatile特性:

  1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)
  2. 禁止进行指令重排序。(实现有序性)
  3. volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。(原子性)
    原理上来说,ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。就是说如果容量大小是16他的并发度就是16,可以同时允许16个线程操作16个Segment而且还是线程安全的。

1.7的put:
他先定位到Segment,然后再进行put操作。首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。

  1. 尝试自旋获取锁。
  2. 如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。

1.7的get:
get 逻辑比较简单,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。
ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。

1.7的缺点:
因为基本上还是数组加链表的方式,我们去查询的时候,还得遍历链表,会导致效率很低,这个跟jdk1.7的HashMap是存在的一样问题,所以他在jdk1.8完全优化了。

JDK1.8:
其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。跟HashMap很像,也把之前的HashEntry改成了Node,但是作用不变,把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。

1.8的put:

  1. 根据 key 计算出 hashcode 。
  2. 判断是否需要进行初始化。
  3. 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
  4. 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
  5. 如果都不满足,则利用 synchronized 锁写入数据。
  6. 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

1.8的get:

  1. 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
  2. 如果是红黑树那就按照树的方式获取值。
  3. 就不满足那就按照链表的方式遍历获取值。

1.2.4 CAS是什么?如何解决CAS带来的ABA问题?

CAS:

  1. CAS 是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。
  2. 线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。这是一种乐观策略,认为并发操作并不总会发生。
    CAS无法保证ABA问题,可以加版本号进行解决,在修改前去查询他原来的值的时候再带一个版本号,每次判断就连值和版本号一起判断,判断成功就给版本号加1。或者加时间戳…

1.2.5 JDK1.8中为什么synchronized用的更多?他不是重量级锁嘛?锁升级过程。

1.8中的synchronized有一个锁升级过程,偏向锁–>CAS轻量级锁–>自旋锁–>synchronized锁…

1.3 ArrayList

1.3.1 ArrayList的特点

ArrayList底层是用数组实现的存储。特点:查询效率高,增删效率低,线程不安全。使用频率很高。
如果涉及频繁的增删,可以使用LinkedList,如果你需要线程安全就使用Vector。

1.3.2 ArrayList为什么可以存储任意数量的对象?

ArrayList可以通过构造方法在初始化的时候指定底层数组的大小。
通过无参构造方法的方式ArrayList()初始化,则赋值底层数Object[] elementData为一个默认空数组Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}所以数组容量为0,只有真正对数据进行添加add时,才分配默认DEFAULT_CAPACITY = 10的初始容量。
ArrayList的扩容方法:其实实现方式比较简单,他就是通过数组扩容的方式去实现的。扩容方式是:
newCapacity = oldCapacity + oldCapacity/2
把原数组的数据,原封不动的复制到新数组中,这个时候再把指向原数的地址换到新数组。
在扩容的时候,老版本的jdk和8以后的版本是有区别的,8之后的效率更高了,采用了位运算,右移一位,其实就是除以2这个操作。

1.3.3 ArrayList为什么插入删除慢?

因为在插入或删除时,需要移动该位置之后的所有数据,如果还要涉及到扩容就会更慢。

1.3.4 ArrayList(int initialCapacity)会不会初始化数组大小?

不会初始化数组大小!而且将构造函数与initialCapacity结合使用,然后使用set()会抛出异常,尽管该数组已创建,但是大小设置不正确。进行此工作的唯一方法是在使用构造函数后,根据需要使用add()多次。

1.3.5 ArrayList插入删除一定慢么?

取决于你删除的元素离数组末端有多远,ArrayList拿来作为堆栈来用还是挺合适的,push和pop操作完全不涉及数据移动操作。

1.3.6 ArrayList是线程安全的么?

当然不是,线程安全版本的数组容器是Vector。Vector的实现很简单,就是把所有的方法统统加上synchronized就完事了。你也可以不使用Vector,用Collections.synchronizedList把一个普通ArrayList包装成一个线程安全版本的数组容器也可以,原理同Vector是一样的,就是给所有的方法套上一层synchronized。

1.3.7 ArrayList的遍历和LinkedList遍历性能比较如何?

论遍历ArrayList要比LinkedList快得多,ArrayList遍历最大的优势在于内存的连续性,CPU的内部缓存结构会缓存连续的内存片段,可以大幅降低读取内存的性能开销。

1.4 LinkedList

双向链表,适合插入删除频繁的情况,内部维护了链表的长度。只能顺序遍历,无法按照索引获得元素,因此查询效率不高;没有固定容量,不需要扩容;线程不安全。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值