欢迎访问我的blog http://www.codinglemon.cn/
立个flag,8月20日前整理出所有面试常见问题,包括有:
Java基础、JVM、多线程、Spring、Redis、MySQL、Zookeeper、Dubbo、RokectMQ、分布式锁、算法。
1. 集合篇
1.1 HashMap
1.1.1 HashMap的底层数据结构?
- JDK 1.7 数组+链表,使用头插法。
- 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.7是数组+链表,1.8则是数组+链表+红黑树结构(当数组长度大于64且链表长度大于8,转为红黑树)。
- JDK1.8中resize()方法在表为空时,创建表;在表不为空时,扩容;而JDK1.7中resize()方法负责扩容,inflateTable()负责创建表。
- 1.8中没有区分键为null的情况,而1.7版本中对于键为null的情况调用putForNullKey()方法。但是两个版本中如果键为null,那么调用hash()方法得到的都将是0,所以键为null的元素都始终位于哈希表table【0】中。
- 当1.8中的桶中元素处于链表的情况,遍历的同时最后如果没有匹配的,直接将节点添加到链表尾部;而1.7在遍历的同时没有添加数据,而是另外调用了addEntry()方法,将节点添加到链表头部。
- 1.7中新增节点采用头插法,1.8中新增节点采用尾插法。这也是为什么1.8不容易出现环型链表的原因。
- 1.7中是通过更改hashSeed值修改节点的hash值从而达到rehash时的链表分散,而1.8中键的hash值不会改变,rehash时根据(hash&oldCap)==0将链表分散。
- 1.8rehash时保证原链表的顺序,而1.7中rehash时会改变链表的顺序,链表顺序会反转(头插法导致),那这在多线程情况下有可能会导致形成环形链表,因为可能会改变链表内部顺序,导致原本是链表中A–>B,在rehash之后又会让B—>A,且A—>B的链接还在。
- 在扩容的时候:1.7在插入数据之前扩容,而1.8插入数据成功之后扩容。
1.1.4 为啥会线程不安全?
- 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。
- 在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:
- 首先计算key的下标,获取hashtable中的数组
- 找到对应下标数组,获取对应的链表
- 遍历链表,判断是否有满足条件的entry,如果有,获取value对应的值返回
- 否则,返回为空.
HashTable的put:
- 首先判断value的是是否为空,如果为空,抛出空指针异常
- 如果不为空,获取hashtable中的table,获取key的hash值以及对应的下标
- 从数组中获取下标对应的链表信息,遍历链表,查询是否有满足条件的entry对象,如果有满足条件的,旧值被新值覆盖,返回旧值
- 如果没有,添加一个新的entry
- 添加新的entry前先判断当前数组的元素数量是否满足扩容,如果满足,进行扩容后,重新计算hash值和下标
- 然后获取当前已经存在的entry链表,然后新建的entry放在原链表的头部,存进数组中.
总结:线程安全,因为对数组操作都会上锁,使用synchronized关键字,所以效率比较低。
1.2.3 ConcurrentHashMap是如何实现的?他的get和put过程?
ConcurrentHashMap:
JDK1.7:
如图所示,是由 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特性:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)
- 禁止进行指令重排序。(实现有序性)
- volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。(原子性)
原理上来说,ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。就是说如果容量大小是16他的并发度就是16,可以同时允许16个线程操作16个Segment而且还是线程安全的。
1.7的put:
他先定位到Segment,然后再进行put操作。首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。
- 尝试自旋获取锁。
- 如果重试的次数达到了 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:
- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。
- 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
- 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
- 如果都不满足,则利用 synchronized 锁写入数据。
- 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
1.8的get:
- 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
- 如果是红黑树那就按照树的方式获取值。
- 就不满足那就按照链表的方式遍历获取值。
1.2.4 CAS是什么?如何解决CAS带来的ABA问题?
CAS:
- CAS 是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。
- 线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。这是一种乐观策略,认为并发操作并不总会发生。
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
双向链表,适合插入删除频繁的情况,内部维护了链表的长度。只能顺序遍历,无法按照索引获得元素,因此查询效率不高;没有固定容量,不需要扩容;线程不安全。