美团 后端开发工程师 二面
1.HashMap的数据结构
1.JDK1.8以前,HashMap的底层实现是使用数组加链表的方式实现,在JDK1.8后,当数组长度大于等于64,链表长度大于(默认)8时,会将链表转为红黑树。(补充:JDK1.8链表采用尾插法,之前使用的是头插法)
2.HashMap的扩容实现
先明确:容量(默认16),负载因子(默认0.75),阈值(默认160.75=12) 1.当元素数量超过阈值,便会进行扩容,每次扩容都是之前容量的两倍,这个容量有上限,是小于1<<30的。
2.JDK8前,如果不是第一次扩容(第一次put会初始化数组,算一次扩容,使其容量变为不小于指定容量的2的次方)新容量是旧的两倍,阈值是新容量负载因子,然后遍历旧数组中的每一个桶,对其中元素重新计算hash(也可能不计算),对应到新数组中的位置,用头插法插入。
3.JDK1.8后,如果不是第一次扩容(第一次put会初始化数组,算一次扩容,使其容量变为不小于指定容量的2的次方)新容量是旧的两倍,阈值是新容量*负载因子,然后遍历旧数组,无需重计算hash值,只要判断其首节点目前的最高位hash值是0还是1,是0就仍在原位置,是1就在原位置+旧数组长度的位置。
3.为什么HashMap是线程不安全的
1.HashMap的put 操作未加锁,可能会导致线程不安全,当有两个线程同时对一个HashMap实例对象进行插入的时候,线程A的时间片使用完时,只获取到了对应散列桶的链表头,此时线程B(假设他们散列到同一个桶)成功在链表头部写入了新的头结点,这就导致线程A再次执行的时候,从它原本持有的那个链表头进行头插法,这样,线程B的插入的那个头就被线程A的覆盖了(尾插法也一样)
2.JDK1.8以前HashMap的多线程扩容导致死循环:JDK1.7中扩容会重新定位每个桶的下表,并用头插法将元素迁移到新数组中,头插法会将链表的顺序翻转,导致死循环,(有删减)代码如下:
for (Entry<K,V> e : table) {
while(null != e) {
? Entry<K,V> next = e.next; // 1:线程A在这里时间片耗尽
? e.next = newTable[i];
? newTable[i] = e;
? e = next;
}
}
假设目前一个桶里面有如下链表1->2,当线程A和B都同时插入一个值导致扩容的时候,线程A执行到注释1的时候时间片用尽,此时线程A的e指向1,next指向2。而线程B成功扩容,因为是头插法,所以在线程B最后扩容后指向的新数组的桶中链表为 2->1。当线程A重新使用CPU的时候,它已经是从线程B扩容后的新数组中去取了,但是它的e和next是已经确定的。第一轮循环:他会将null(线程B产生的链表的e.next)指向newTable[i](此时为空),然后再将newTable[i]设置为1(e此时是线程A被抢占前获取的),然后再将e设置为2(next是线程A被抢占前获取的)。第二轮循环:next被设置为1(e此时为2,2在线程B中的下一个是1),2的下一个(是1)设置为newTable[i](此时为1),newTable[i](此时为2)被设置为2(此时的e为2),最后e被设置为1(此时next为1)。第三轮循环:next被设置为null(此时的e为1,而在线程B中的1的是最后一个节点),1的下一个被设置为newTable[i](此时的值为2),至此,循环产生了(实际上-线程A的实际执行头插的顺序变为了1->2->1导致循环)。图文解释可见:图解HashMap为什么线程不安全?愿万事胜意-CSDN博客hashmap为什么线程不安全
3.在JDK1.8后,HashMap使用尾插法,不会有链表成环的问题
4.为什么JDK1.8后HashMap长度是2的幂次方
1.为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。。我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。”,所以这个数组下标的计算方法是“ (n - 1) & hash ”。(n 代表数组长度)这也就解释了 HashMap 的长度为什么是 2 的幂次方。
5.介绍一下ConcurrentHashMap
1.ConcurrentHashMap在JDK1.8以前底层使用的是分段数组加链表的方式实现,JDK1.8采用的和HashMap一致,是数组+链表+红黑树。
2.ConcurrentHashMap在JDK1.8以前使用分段锁对整个桶数组进行分端分割,每把锁只锁定部分数据,这样可用增加并发量。JDK1.8中,使用synchornized和CAS来进行并发控制
6.LRU是什么?如何实现?
1.LRU(Least Recently Used)最近最久未使用页面置换算法,LRU算法给每个页面一个访问字段,用来记录每个页面自上次被访问以来所经历的时间T,当需要淘汰一个页面时,选择现有页面中T最大的,即最久未使用的页面进行淘汰。
2.一般的思路是基于HashMap和双向链表实现,当在寻找页面的时候,先在HashMap中进行查找,如果有则直接返回并且将节点移动到双向链表的最前端,如果未命中,则从内存中获取页面并构建新节点将其加入到双向链表的最前端,如果空间不足,则淘汰双向链表的队尾。
3.也可以基于LinkedHashMap(底层存储结构是双向链表)实现,初始化时,将accessOrder设置为true,并重写removeEldestEntry方法为淘汰最久未使用的entry就可实现LRU。(参考mybatis的lru实现)
4.工业上的思路是使用Redis,因为按照HashMap和双向链表的实现。需要额外的存储存放next和pre指证,会消耗比较大的内存,不划算。我们可用Redis近似实现,随机取出若干key(默认是5,越大则越与真正的lru相同),然后按照访问时间排序后,淘汰掉最不经常使用的。
7.介绍一下平衡二叉树
1.即我们常说的AVL树,是带有平衡条件的二叉查找树,一般是用平衡因子差值判断是否平衡并通过旋转来实现平衡,左右子树树高不超过1,查找、插入和删除在平均和最坏情况下的时间复杂度都是logn,和红黑树相比,AVL树是严格的平衡二叉树,平衡条件必须满足(所有节点的左右子树高度差的绝对值不超过1)。不管我们是执行插入还是删除操作,只要不满足上面的条件,就要通过旋转来保持平衡,而旋转是非常耗时的,由此我们可以知道AVL树适合用于插入与删除次数比较少,但查找多的情况。
8.堆是什么?如何调整
1.堆是一种顺序存储结构(它采用数组方式存储),它的逻辑结构是完全二叉树。将满足根的值小于等于所有子树节点的值称为小根堆,将根的值大于等于所有子树节点的值称为大根堆。
2.向下调整:它的前提是,对于一颗完全二叉树,除了一个位置外都已经满足堆的性质了。它是为了让调整的结点与其孩子节点进行比较。假设是大根堆,先计算出其子节点的数组下标,比较它和左右子节点(不一定有右节点)中较大的一个,如果根节点较大则直接返回,否则继续向下调整,将其与更大的节点对调,直到找到自己的正确位置。
3.向上调整:它的前提是,对于一颗完全二叉树,除了一个位置外都已经满足堆的性质了。它是为了让调整的结点与其父亲节点向比较。假设是大根堆,先计算出父节点的值,比较父节点的值和它的值是否满足该堆的关系,如果符合就退出,否则对换节点值然后再向上比较。
9.口述二叉树最近的公共祖先节点
1.我们可以使用递归的方式。使用前序遍历,每次搜根节点的左右节点,当节点的值为给定值时,返回true,并且让它的所有父节点都返回true,该根节点为公共祖先节点的条件是左右子节点都为true,或者仅有一个为true但自己也是给定的值。
10.栈和队列,举例使用场景
1.栈是后进先出的数据结构,队列是先入先出的数据结构。
2.栈使用场景:在函数调用中,我们通常就是使用栈来记录函数调用,后加入的先执行,然后弹出栈后才执行前面压入的函数
3.队列使用场景:实际生产中,我们的系统通常会使用消息队列进行限流削峰,它的逻辑模型就是一个队列,先进入的先处理,依次处理到最后一个。比如对抢购的请求中,对订单的消费是依次加入队列后,逐个消费的。
11.MySQL为什么InnoDB是默认引擎
1.因为InnoDB是有事务支持的,能够实现事务性操作。不会出现数据的错误修改,实现了数据库的ACID属性
2.因为InnoDB相比于其他MySQL支持的引擎,它支持行级锁,能在进行数据修改的时候,仅对数据行进行锁定,从而最大限度的减少性能的损耗,在进行大量并发写入的时候能有较高的性能。
3.同时InnoDB也支持外键,虽然我们不被推荐使用外键
4.InnoDB具有崩溃恢复的能力,InnoDB通过redo log和undo log对事务进行持久性与原子性的保证。
5.InnoDB有对MVCC(多版本并发控制)的支持,能够在行级锁带来的性能提升上,更进一步的减少并发时锁定产生的开销
12.MySQL为什么使用B+ 树
1.因为MySQL希望减少磁盘IO次数,B树可以在非叶节点中存储数据,但是B+树的所有数据都存储在叶子节点中,而这些叶子节点可以通过指针依次顺序连接形成链表,当我们在进行查找的时候,经常会对多条进行查找选择,如果是在B树中,因为范围查找可能会出现要进行局部中序遍历的情况(比如根的值在希望查找的范围中间,这样就需要进行多次的IO获取页来查询数据),而B+树就可以通过链表直接获取。
2.如果是和HashMap进行对比,因为HashMap对键的索引平均时间是O(1),但是如果需要进行范围查询,那么HashMap进行的时间就会严重退化。并且数据库的索引一般在硬盘上,数据量大的时候,无法一次性加载进内存,而B+树可以让数据分批加载进内存,同时因为是多路搜索树,高度低且有序,查找的性能好。
13.B+树的叶子节点链表是单向还是双向?为什么?
1.是双向
2.为了可以倒序查找,当我们找到的点是在我们希望的查找范围的中间的时候,为了查找效率,我们如果可以进行双向查询,那么查询效率显然会得到提升
14.MVCC是什么?作用?
1.MVCC是多版本并发控制,它的作用是对一致性非锁定读(快照读)的实现,它只在REPEATABLE READ和READ COMMITTED两个隔离级别下工作,他的实现基于隐藏字段、Read View、undo log
2.它实现了多事务并发的情况下,对表内数据的读不加锁,实现了读操作的非阻塞,减少了加锁的性能开销,他是进行快照读,通过在select查询(RR是仅生成一次,RC是每次查找都生成)之前生成快照进行对数据的读取,快照中保存了当前数据库系统中正处于活跃的事务ID号m_ids ,通过对比数据行的事务ID和其他的可见性条件(如m_up_limit_id:活跃事务列表 m_ids 中最小的事务 ID,小于他的都可见。m_low_limit_id :目前出现过的最大的事务 ID+1,大于他的都不可见。在他们两个中间的ID也有可能是已提交的,所以我们要在生成的快照中查找是否存在,存在说明处于活跃,不可见。不存在说明已提交,可见。)
15.当前读和快照读?
我不太懂。
1.当前读用next-key锁(即行锁和Gap间隙锁实现,锁定一个范围,包含记录本身)实现,可以保证一个事务,内部读取对应某一个数据的时候,数据都是一致的,同时读取到的都是最新的数据,因为他会将当前数据的一个范围进行加锁,可以防止不可重复读和幻读。达到一致更新的效果。
2.快照读通过MVCC和undo log实现,他在读取数据之前,会生成一个快照,通过该快照和读取的数据行的版本进行可见性分析,获取数据,他能防止不可重复读,也能防止幻读,但他的数据是历史数据。
16.MySQL如何回滚一条数据
1.MySQL通过undo log来进行事务的回滚,当我们开启事务的时候,每次执行操作之前,首先会将其对应的redo log和undo log持久化到磁盘上,当事务回滚的时候,就会通过undo log反序执行,这样一个事务的数据就可以回滚了
17.如何查询慢sql产生的原因
1.首先在MySQL的配置文件中开启慢查询日志,并指定慢查询的时间,这样在指定文件中,就可以查看发生慢查询的日志了。
2.然后对这些慢sql前加EXPLAIN关键字获取sql的执行计划,通过生成的执行计划查看是否使用了索引,是否使用了关联查询,是否使用了延迟查询
|