说在前面
在40岁老架构师尼恩的(50+)读者社区中,经常有小伙伴,需要面试美团、京东、阿里、 百度、头条等大厂。
下面是一个5年小伙伴成功拿到通过了京东一面面试,并且最终拿到offer,月薪40K。
现在把面试真题和参考答案收入咱们的宝典,大家看看,收个优质Offer需要学点啥?
当然对于中高级开发来说,这些面试题,也有参考意义。
小伙伴说,光代码漂亮不够, 面试,还得会吹。
这里把题目以及小伙伴的吹牛逼的方式方法,经过整理和梳理之后,收入咱们的《尼恩Java面试宝典》 V95版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发、吹牛水平。
《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请到公号【技术自由圈】取
文章目录
- 说在前面
- 京东一面
- 1、说一说 JVM内存模型
- JVM内存结构
- Java内存模型图
- 2、CMS和G1有什么区别? 什么时候发生垃圾回收? 然后,说说大致的回收过程?
- 完整的GC流程
- 什么时候发生垃圾回收
- 对象存活判断方法
- 垃圾回收算法
- 3、对于数据的一致性是怎么保证的?
- 4、Redis集群有没有了解过,主从和选举是怎样的?
- 一、Redis高可用集群架构
- 二、主从数据同步
- 三、选主机制
- 四、过期内存淘汰策略
- 5、看你们公司使用的是MySQL,你们使用的是哪种存储引擎,为什么?MyISAM和InnoDB的区别
- 6、mysql索引的底层数据结构是什么,为什么选择这种数据结构
- 7、说说什么情况下索引失效
- 8、手写代码:设计一个分布式自增id生成服务
- 9、有没有了解过网络安全问题,常见的网络攻击有哪些,原理是什么,可以怎么解决
- 一、XSS 跨站脚本攻击
- 二、CSRF 跨站请求伪造
- 三、DDoS 分布式拒绝服务攻击
- 四、SQL 注入
- 10、平时在开发接口或者设计项目的时候如何保证安全性的
- 11、使用Redis集群时可能会存在什么问题
- 一、数据一致性问题
- 二、性能问题
- 三、可用性问题
- 四、负载均衡问题
- 五、网络延迟问题
- 六、节点故障问题
- 七、安全问题
- 12、有没有了解过cap和base原则
- 一、CAP理论
- 二、BASE 理论
- 13、zk是如何保证一致性的
- 一:ZAB协议(Zookeeper原子消息广播协议)
- 二、选主
- 三、选主后的数据同步
- 四、事务操作
- 14、你如何设计一个能抗住大流量的系统,说说设计方案
- 15、有没有了解过缓存策略有哪些
- 一、Cache Aside(旁路缓存)策略
- 二、Read/Write Through(读穿 / 写穿)策略
- 三、Write Back(写回)策略
- 总结
- 参考文献
- 尼恩说在最后
京东一面
1、说一说 JVM内存模型
小伙伴从以下2个维度去 “吹牛”, 吹到 面试官 “口水直流,不能自已”
- 第一个维度:JVM内存结构
- 第二个维度:Java内存模型图
JVM内存结构
程序计数器:当前线程所执行的字节码的行号指示器,用于记录正在执行的虚拟机字节指令地址,线程私有。
Java虚拟栈:存放基本数据类型、对象的引用、方法出口等,线程私有。
Native方法栈:和虚拟栈相似,只不过它服务于Native方法,线程私有。
Java堆:java内存最大的一块,所有对象实例、数组都存放在java堆,GC回收的地方,线程共享。
方法区:存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码数据等。(即永久带),回收目标主要是常量池的回收和类型的卸载,各线程共享
Java内存模型图
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
尼恩说明:
由于篇幅限制,这里对JVM内存结构、Java内存模式的介绍,没有做展开,
有关JVM内存结构、Java内存模式的详细介绍,可以参考《尼恩Java面试宝典》中JVM面试专题的内存模型部分
2、CMS和G1有什么区别? 什么时候发生垃圾回收? 然后,说说大致的回收过程?
小伙伴从以下6个维度去 “吹牛”, 吹到 面试官 “口水直流,不能自已”
- 首先,说说垃圾回收的本质
- 其次,CMS和G1有什么区别
- 再次:完整的GC流程
- 再次: 什么时候发生垃圾回收
- 再次:对象存活判断方法
- 最后:垃圾回收算法
垃圾回收的本质
垃圾回收是Java程序执行自动内存管理的过程。当Java程序在JVM上运行时,将在堆上创建对象,这是专用于该程序的内存的一部分。最终,将不再需要某些对象。垃圾收集器找到这些未使用的对象并将其删除以释放内存。
CMS和G1有什么区别
CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。
G1(Garbage First)收集器(标记-整理算法): Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。
区别:
- CMS收集器是老年代的收集器,可以配合新生代的Serial和ParNew收集器一起使用;
- G1收集器收集范围是老年代和新生代,不需要结合其他收集器使用;
- CMS收集器以最小的停顿时间为目标的收集器;
- G1收集器可预测垃圾回收的停顿时间
- CMS收集器是使用“标记-清除”算法进行的垃圾回收,容易产生内存碎片
- G1收集器使用的是“标记-整理”算法,进行了空间整合,降低了内存空间碎片。
完整的GC流程
在JVM中一次完整的GC流程如下:
- 标记垃圾对象;
- 清除垃圾对象;
- 将存活的对象复制到另一个区域;
- 清空原区域。
JVM中的垃圾回收分为三个阶段:Minor GC、Full GC和G1 GC。
- Minor GC,是针对新生代的垃圾回收器,主要清理Eden区和Survivor区的垃圾;
- Full GC,是对整个堆空间进行垃圾回收,包括新生代和老年代;
- G1 GC,是一种基于Region的垃圾回收器,它将堆空间划分为不同的Region,每个Region都有一个根节点,当某个Region的大小达到一定阈值时,就会触发一次GC。
什么时候发生垃圾回收
当对象对当前使用这个对象的应用程序变得不可触及的时候,这个对象就可以被回收了。
垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。
如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。
对象存活判断方法
Java中判断对象存活的方法有以下两种算法:
- 引用计数算法:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就+1;当引用失效时,计数器值就-1;任何时刻计数器为0的对象就是不可能再被使用的对象。这种算法虽然简单,但是有个致命的缺点,就是不能适用于相互引用的情况。
- 可达性分析算法:通过从根对象开始向下搜索,直到找到一个无法直接或间接访问到的对象为止。这个过程称为“可达性分析”,如果一个对象不可达,则说明它已经不再使用,可以被回收。
垃圾回收算法
Java中常见的三种垃圾收集算法是标记-清除算法(Mark-Sweep)、复制算法(Copying)和分代收集算法(Generational Collection)。
- 标记-清除算法:这种算法首先会标记出所有活动对象,然后将它们从内存中移除。接下来,算法会遍历整个堆空间,将未被标记的对象进行回收。这种算法的缺点是会产生内存碎片。
- 复制算法:这种算法将堆空间分为两个区域:Eden区和Survivor区。新创建的对象首先分配到Eden区中,然后经过多次复制和清除后才会被移动到Survivor区中。当Survivor区空间不足时,就会触发一次Full GC来进行垃圾回收。这种算法的优点是可以避免内存碎片。
- 分代收集算法:这种算法将堆空间分为三个区域:新生代、老年代和永久代。新生代又分为Eden区、Survivor区和From区。这种算法的优点是可以更好地利用CPU缓存,提高程序运行效率。
3、对于数据的一致性是怎么保证的?
小伙伴说,他是参考《尼恩Java面试宝典》中 Redis 专题中的数据一致性问题及方案去吹的, 面试官非常满意
4、Redis集群有没有了解过,主从和选举是怎样的?
小伙伴从以下7个维度去 “吹牛”, 吹到 面试官 “口水直流,不能自已”
- 首先,说说Redis高可用集群架构
- 两个小的维度一,主从哨兵模式
- 两个小的维度二,Cluster集群模式
- 再次:说说两种主从数据同步方式
- 两个小的维度一,全量复制
- 两个小的维度二,增量复制
- 第三:说说说说两种选主机制
- 两个小的维度一,主从哨兵模式选主
- 两个小的维度二,Cluster集群模式选主
- 最后,嘚瑟一下过期内存淘汰策略
一、Redis高可用集群架构
Redis高可用集群有两种,分别是主从哨兵模式和集群模式
1.主从哨兵模式
其中一台服务器作为master服务器,提供读写服务,配置多台从服务器,从服务器只提供只读服务,同时配置多台sentinel,也即是哨兵,哨兵的作用是可以监控master节点,如果master宕机,可以从从服务器中选举出一台作为master服务器。
哨兵模式,客户端连接哨兵集群,即可获得master服务器的信息。此时客户端并不会做读写分离,也就是所有读写都由master服务器处理,这里相当于从服务器只作为主服务器的数据备份。如果master发生故障,切换到其他从服务器,哨兵会把新的master服务器地址告知客户端。
jedis和RedisTemplate都没有实现读写分离。如果需要可以分别建立master服务器连接池和slave服务器连接池,并严格区分读写操作,路由到需要使用的连接池。需要注意的是,Redis主从复制是异步的,可能存在小概率数据不一致的问题。
2.Cluster集群模式
在主从哨兵模式,所有的写操作都是由master处理,这在性能上可能会出现瓶颈。Redis3.0后推出了集群模式,可以实现水平扩展,配置多台的master服务器处理读写请求。
集群模式下,看似于将一个大的主从架构拆分成多个主从架构的服务器群,具有复制,高可用和分片的特性。不需要哨兵,也可以实现节点故障移除和master选举功能。性能和高可用性均优于哨兵模式,但需要更多的服务器。可以从公司业务的并发量和成本等角度考量选择哪种模式。
Redis Cluster集群模式默认将所有的数据划分为16384个slot槽,每个master节点均匀负责一部的槽位。
通常,会对key值使用crc16算法进行hash得到一个整数值,然后使用这个整数值对16384进行取模,来得到具体的槽位。
Cluster集群模式下,Redis默认从服务是不分担读请求,只作为备注和故障转移。但有读请求到达从服务器,会重定向到主服务器处理。
二、主从数据同步
Redis主从数据同步大致分为两种,全量复制和增量复制。
全量复制
增量复制
一般情况下,主从断开连接后会进行全量复制,但Redis2.8后开始支持部分数据的复制。
master和从服务器第一次连接时会进行全量复制,同时master和所有的slave都会维护一个复制数据的偏移量offset和master的进程id。
如果从服务器断开重连后,会比较偏移量是否太旧或者master进程id是否变更了,如果这样则会进行一次全量复制,否则会进行部分复制,把offset之后的数据同步给从服务器。
三、选主机制
1.主从哨兵模式
这种模式下,是有哨兵监控master服务器状态,并实现故障转移。一旦master服务器宕机,则哨兵会从剩下的从服务器中选举一条作为新的master节点。这里有几个概念:
主观下线:
哨兵会定期向主服务器发送心跳包检测是否正常,如果超过配置文件中sentinel down-after-milliseconds mymaster 配置的时间没有收到主服务器的回复,则这个哨兵认为主服务下线。
客观下线:
一个哨兵把master记为主观下线,并不代表master就一定下线了,此时要向其他哨兵确认master是否真的下线,如果超过sentinel monitor mymaster 配置的数量(一般为哨兵数量/2 + 1)哨兵认为master下线,则记为客观下线。
哨兵选举master服务器过程:
- 先从哨兵中选举出一个leader,并由这个leader选举出新的master
- 过滤故障节点,从剩余的节点中按照下列规则选出master
- 优先选择slave-priority最大的从节点作为主节点
- 其次选择数据偏移量最大的节点
- 选择runid最小的从节点
2.集群模式
- slave发现自己的master下线后,会广播故障转移信息到其他master节点
- master接收到slave故障转移请求后,首先会检测请求的合法性,然后发送响应ack给slave,每轮投票,master只会响应一次
- 一旦一个slave接收超过半数master的ack后,则被选中成为新的master,否则会进行下一轮的投票
四、过期内存淘汰策略
- 被动删除,客户端get 请求某个key时,会判断是否过期,如果过期了,则会清楚
- 主动删除,redis定期扫描一批key,检查是否过期,如果过期,则清楚
- 内存淘汰策略,当redis内存不足以容纳更多的key时,则会触发内存淘汰策略,可以在配置文件配置。常用有lru(最久没访问),lfu(访问频率最低) random(随机),同时可以配置针对所有的key,还是设置了过期时间的key执行淘汰。
5、看你们公司使用的是MySQL,你们使用的是哪种存储引擎,为什么?MyISAM和InnoDB的区别
小伙伴说,他是参考《尼恩Java面试宝典》中Mysql 面试专题中的Mysql原理、MyISAM和InnoDB的对比去吹的
6、mysql索引的底层数据结构是什么,为什么选择这种数据结构
小伙伴从以下6个维度去 “吹牛”, 吹到 面试官 “口水直流,不能自已”
- 首先,索引的底层数据结构
- 其次,时间复杂度对比
- 再次:为什么索引采用B+树,而不采用Hash这种结构
- 再次:为什么不用二叉树
- 再次:为什么不用红黑二叉树
- 最后:为什么没有采用B树,或则说B-树?
索引的底层数据结构有:
- 树Tree,准确的说是B+树
- Hash
时间复杂度对比:
Hash的时间O(1)
Tree的O(logn)
为什么索引采用B+树,而不采用Hash这种结构?
Hash这种结构对与获取单条记录时的查询效率是要比B+树效率要高的,但是对与数据的范围查找效率就很低,特别是对于数据量大的时候(10万 甚至百万级别的数据)。
为什么低?它是根据字段值算出获取一个hash值,然后根据hash值找到在索引表中查找出hash值对应的数据行,它的索引没有按顺序存储
B+树:是一种多路平衡树,一个横向存放多个节点,并且它的非叶子节点都只存放指向子节点的索引指针,不存放数据。所有非叶子节点都存放一个节点在叶子节点上,并且这个叶子节点是一个有序的列表结构。
当我们查一个范围数据时,通过B+树搜索
多路:一课树每个节点最多可以拥有大于2个子节点的树
平衡树: 一颗树左子树和右子树保持相对平衡,不会出现一边层级特别多,一边层级特别少的情况
为什么不用二叉树?
二叉树为非平衡树,如果是有序递变的数据,很有可能就会变成左边树层级过高,或则右边树的层级过高,甚至是退化成链表。
层级过高为什么就不能用了呢?
假设是1,2,3,4,5,6这样的数据,如果我们要找到底部6这个数据,就要进行6次IO(加载磁盘数据进内存,索引是在磁盘文件中以表的形式存在的)才能找到。如果按一次IO10ms计算,60ms也不算什么。但是假设是100万呢?1001000010ms,也就是10000s,换算成分钟也就是167,所以这种结构肯定是不行的。
为什么不用红黑二叉树?
红黑二叉树一种平衡树,虽然它不会出现有一树的层级过高的情况,但是它还是没有从根本上解决树的层级问题,随着数据量增大,树的层级会越来越高。要遍历叶子节点的数据IO消耗还是过高。
为什么没有采用B树,或则说B-树?
B树虽然虽然通过多路平衡树解决了树的高度问题,但是它对与访问数据的查找效率还是低下的,对于数据访问在非叶子节点和叶子节点都有的范围,它不仅非叶子节点的遍历还需要做叶子节点的遍历。
总体来说:选择B+树是因为它具有以下优点:
- 查询效率高:B+树的搜索性能非常接近二叉树,而且它的查询效率更高,因为它的每个节点都包含很多关键字,可以支持范围查询。
- 空间利用率高:B+树的每个节点都包含很多关键字,所以它的空间利用率更高,可以支持更多的索引。
- 适用于外部存储:B+树的节点之间的距离比较大,所以它更适用于外部存储,可以减少内存开销。
因此,B+树是一种非常适合用于MySQL索引的底层数据结构,它可以提供高效、稳定、可靠的查询性能。
7、说说什么情况下索引失效
小伙伴说,他是参考《尼恩Java面试宝典》中Mysql 面试专题中的索引失效面试题去吹的, 面试官非常满意
8、手写代码:设计一个分布式自增id生成服务
设计一个分布式自增id生成服务的步骤如下:
- 选择合适的分布式ID生成算法,例如Snowflake、Apache Skywalking等。
- 设计分布式ID生成服务的架构,包括节点的部署、负载均衡、数据同步等。
- 实现分布式ID生成服务的代码,包括生成ID的算法、节点的连接和数据同步等。
- 部署分布式ID生成服务,并进行测试和性能优化。
下面是一个简单的Java实现:
public class DistributedIdGenerator {
private static final int PARTITION_ID = 1; // 分区ID
private static final int NODE_ID = 1; // 节点ID
private static final int SEQUENCE = 1; // 序列号
private static final long SEQUENCE_ROOT = 1L << 32; // 序列号根节点
private static final long MAX_ID = SEQUENCE_ROOT + 1 - 1L << 32; // 最大ID
private static final long TIMESTAMP = System.currentTimeMillis() / 1000; // 时间戳
private static final long MACHINE_ID = UUID.randomUUID().getMostSignificantBits(); // 机器ID
private static final int SCALE = 10; // 位数
private static final int SHIFT = 22; // 位移
private static final int PARTITION_SIZE = 1 << SCALE; // 分区大小
private static final DistributedIdGenerator instance = new DistributedIdGenerator();
private static int sequence = 0; // 当前节点的序列号
private static long lastId = SEQUENCE_ROOT; // 上一次生成的ID
private static Map<Integer, Long> partitionMap = new HashMap<>(); // 分区ID和最大ID的映射
private DistributedIdGenerator() {
// 初始化分区ID和节点ID
partitionMap.put(PARTITION_ID, SEQUENCE_ROOT);
partitionMap.put(NODE_ID, SEQUENCE_ROOT);
}
public static synchronized long generateId() {
// 获取当前节点的序列号
sequence = (sequence + 1) & SEQUENCE_ROOT;
if (sequence == 0) {
// 如果序列号溢出,则从上一次生成的ID开始
sequence = partitionMap.get(PARTITION_ID);
if (sequence == SEQUENCE_ROOT) {
// 如果分区ID溢出,则从最大ID开始
sequence = partitionMap.get(NODE_ID);
if (sequence == SEQUENCE_ROOT) {
// 如果节点ID溢出,则从最大ID开始
sequence = SEQUENCE_ROOT;
}
}
}
// 获取当前时间的毫秒数
long now = System.currentTimeMillis() / 1000;
// 计算ID
long id = ((now - TIMESTAMP) << SHIFT) | (MACHINE_ID << SCALE) | (sequence << SEQUENCE_SHIFT) | lastId;
// 如果ID溢出,则从最大ID开始
if (id > MAX_ID) {
id = SEQUENCE_ROOT;
}
// 更新上一次生成的ID
lastId = id;
// 更新分区ID和节点ID的映射
partitionMap.put(PARTITION_ID, partitionMap.get(PARTITION_ID) + PARTITION_SIZE);
partitionMap.put(NODE_ID, partitionMap.get(NODE_ID) + PARTITION_SIZE);
// 返回ID
return id;
}
}
这个实现使用了Snowflake算法,通过获取当前时间的毫秒数、机器ID和序列号来生成ID。在生成ID时,首先获取当前节点的序列号,如果序列号溢出,则从上一次生成的ID开始。如果分区ID和节点ID都溢出,则从最大ID开始。最后,计算出ID并更新上一次生成的ID和分区ID和节点ID的映射。
9、有没有了解过网络安全问题,常见的网络攻击有哪些,原理是什么,可以怎么解决
小伙伴从以下4个维度去 “吹牛”, 吹到 面试官 “口水直流,不能自已”
- 首先,XSS 跨站脚本攻击
- 其次,CSRF 跨站请求伪造
- 再次:DDoS 分布式拒绝服务攻击
- 再次:SQL 注入
一、XSS 跨站脚本攻击
XSS 攻击是指攻击者通过在受信任的网页上注入恶意脚本,使得脚本在用户浏览器中执行,从而窃取用户敏感信息、劫持会话或执行其他恶意行为。XSS 攻击的原理是利用网页中的漏洞,将攻击者的恶意脚本注入到网页中,然后当用户浏览该网页时,浏览器会执行该恶意脚本。
解决 XSS 攻击的方法包括:
- 输入验证:对用户输入的数据进行有效性验证,过滤掉无效或恶意的输入。
- 输出过滤:在输出用户输入数据到网页上之前,对数据进行过滤,去掉其中的恶意脚本代码。
- 安全编码:使用安全的编码方式,避免在网页中嵌入恶意脚本。
- 浏览器安全设置:设置浏览器的安全选项,禁用或限制脚本执行,阻止 XSS 攻击。
二、CSRF 跨站请求伪造
CSRF 攻击是指攻击者通过伪造用户的请求,向网站服务器发送恶意请求,从而操纵用户账户或执行其他恶意行为。CSRF 攻击的原理是利用网站应用程序中的漏洞,绕过网站的安全验证机制,以用户的身份执行恶意请求。
解决 CSRF 攻击的方法包括:
- 添加 token:在请求中添加一个随机生成的 token,服务器验证该 token 是否合法,以判断请求是否来自合法用户。
- 采用 POST 方法:使用 POST 方法提交表单,因为 POST 方法不能被缓存,从而避免 CSRF 攻击。
- 限制 HTTP 方法:对网站应用程序进行安全配置,限制允许使用的 HTTP 方法,禁止使用 GET、POST 等可被伪造的方法。
三、DDoS 分布式拒绝服务攻击
DDoS 攻击是指攻击者通过控制大量僵尸主机,向目标主机发送海量流量,从而瘫痪目标主机或使其服务不可用。DDoS 攻击的原理是通过大量流量消耗目标主机的带宽和资源,使其无法正常提供服务。
解决 DDoS 攻击的方法包括:
- 流量清洗:使用流量清洗设备对入侵流量进行过滤和清洗,阻断攻击流量。
- 负载均衡:使用负载均衡设备,将攻击流量分发到多个服务器上,减轻目标服务器的压力。
- 扩大带宽:增加网络带宽,提高网络容量,使攻击流量无法占据主导地位。
- 关闭不必要服务:关闭不必要的服务,减少攻击目标,从而降低攻击效果。
四、SQL 注入
SQL 注入攻击是指攻击者通过在 Web 应用程序的输入框中注入恶意 SQL 语句,从而获取未授权的访问权限或窃取敏感数据。SQL 注入攻击的原理是利用应用程序中的漏洞,将恶意 SQL 语句插入到 SQL 查询语句中,从而操纵数据库。
- 输入验证:对用户输入的数据进行有效性验证,过滤掉无效或恶意的输入。可以使用正则表达式、字符串匹配等方法对输入数据进行过滤,确保输入数据的合法性和准确性。
- 参数化查询:使用参数化查询,避免将用户输入的数据直接拼接到 SQL 查询语句中。参数化查询可以将用户输入的数据与 SQL 语句分离,从而避免 SQL 注入漏洞。在 Java 中,可以使用 PreparedStatement 语句进行参数化查询。
- 预编译语句:使用预编译语句进行预处理,可以将 SQL 语句和参数分离,从而避免 SQL 注入漏洞。预编译语句可以在第一次执行时编译成机器码,后续执行时直接使用机器码执行,提高了执行效率。在 Java 中,可以使用 PreparedStatement 语句进行预编译。
- Mybatis 映射语句:在 Mybatis 映射语句中,使用#{xxx}而不是${}来表示参数。这样可以避免将用户输入的数据直接拼接到 SQL 查询语句中,从而避免 SQL 注入漏洞。在 Mybatis 中,可以使用#{param1}, #{param2}等语法来表示参数。
- 访问控制:对数据库的访问进行严格的权限控制,禁止未经授权的用户访问敏感数据。可以使用数据库的访问控制机制,如 ACL、RLS 等,对数据库的访问进行限制。此外,可以在应用程序中对用户进行身份验证和授权,确保只有授权用户可以访问敏感数据。
- 数据加密:对敏感数据进行加密,防止数据泄露,即使被攻击者窃取也无法获取明文数据。可以使用加密算法,如 AES、SSL 等,对敏感数据进行加密。在 Java 中,可以使用 Java 加密算法或第三方加密库进行加密。
10、平时在开发接口或者设计项目的时候如何保证安全性的
小伙伴从以下8个维度去 “吹牛”, 吹到 面试官 “口水直流,不能自已”
- 签名和加密:确保数据在传输过程中不被篡改或窃取,可以使用签名和加密技术。签名可以确保数据的完整性和真实性,而加密可以确保数据的保密性。常用的签名算法有 SHA-256、SHA-3 等,加密算法有 AES、RSA 等。
- IP 检测和限流:通过检测请求的 IP 地址,可以实现访问控制和限流。例如,可以限制某个 IP 地址的请求次数或限制某个 IP 地址的访问权限。这可以帮助防止 DDoS 攻击和其他恶意行为。
- 接口幂等性:确保接口在多次调用下不会产生副作用,可以实现接口的幂等性。例如,在处理支付接口时,可以确保同一个订单多次支付不会产生重复扣款。
- 特殊字符过滤:通过过滤特殊字符,可以防止 XSS 和 SQL 注入攻击。例如,可以过滤掉单引号、双引号、斜杠等特殊字符,避免攻击者通过注入恶意代码来执行攻击。
- 防止 CSRF 攻击:CSRF 攻击是一种利用用户浏览器发起恶意请求的攻击方式。为了防止 CSRF 攻击,可以使用 token 技术,即在客户端生成一个随机的 token,在请求时将其发送给服务器,服务器验证 token 的合法性,从而确保请求是合法的。
- 防止XSS攻击:可以使用过滤器来过滤掉一些可能会导致XSS攻击的特殊字符或代码,例如反斜杠、单引号等。
- SQL注入的攻击:可以使用预编译语句或者参数化查询来防止SQL注入的攻击,从而保护数据库的数据安全。
- 输入验证:在接收用户输入时,需要对输入进行验证,确保输入的格式和内容是合法的。例如,可以验证输入的数字范围、字符长度等,避免因为输入不合法而导致的安全漏洞。
11、使用Redis集群时可能会存在什么问题
小伙伴从以下7个维度去 “吹牛”, 吹到 面试官 “口水直流,不能自已”
- 首先,数据一致性问题
- 其次,性能问题
- 再次:可用性问题
- 再次:负载均衡问题
- 再次:网络延迟问题
- 再次:节点故障问题
- 最后,安全问题
一、数据一致性问题
在 Redis 集群中,当多个节点同时进行写操作时,可能会导致数据不一致。例如,当节点 A 和节点 B 同时尝试向同一个 key 写入数据时,如果节点 A 先写入成功,而节点 B 的后写入操作失败,则导致该 key 的数据在节点 A 和节点 B 上不一致。为了解决这个问题,可以使用 Redis 集群的主节点来协调多个节点之间的数据写入操作,确保数据的一致性。
解决方法:
- 使用主节点进行写操作协调:所有写操作必须经过主节点协调,由主节点分配节点进行写操作,并确保所有节点写操作的顺序和一致性。这样可以保证数据的一致性,但会增加主节点的负担,降低性能。
- 使用分布式锁:在写操作之前,节点需要获取分布式锁,以确保在该节点完成写操作之前,其他节点无法进行写操作。这样可以避免多个节点同时进行写操作导致的数据不一致问题。然而,分布式锁可能会带来性能瓶颈,因为锁的竞争可能会导致节点之间的延迟。
二、性能问题
在使用 Redis 集群时,可能会出现性能瓶颈。例如,当集群中的某个节点出现网络延迟或负载过高时,可能会导致整个集群的性能下降。此外,由于 Redis 集群需要进行数据同步和协调,因此可能会增加额外的延迟和开销,从而影响集群的性能。
解决方法:
- 增加节点数量:通过增加节点数量来提高集群的性能和吞吐量。更多的节点意味着更多的计算和存储资源,可以更好地支持并发访问和数据处理。
- 优化网络拓扑结构:通过优化网络拓扑结构,例如使用高速网络、增加网络带宽、使用负载均衡器等,来提高集群的性能和吞吐量。
- 优化 Redis 配置:通过调整 Redis 的配置参数,例如调整缓存大小、调整持久化策略、优化数据库文件等,来提高集群的性能和吞吐量。
三、可用性问题
在使用 Redis 集群时,如果某个节点出现故障或宕机,可能会导致整个集群的不可用。
解决方法:
需要使用 Redis 集群的高可用性机制来确保集群的可用性,例如使用多个节点进行数据冗余和备份,以便在节点故障或宕机时能够自动切换到备用节点。
可以通过以下两种方式来实现:
- 数据冗余备份:在 Redis 集群中,可以将数据备份到多个节点上,以确保在节点故障或宕机时,仍有其他节点可以提供数据服务。
- 故障转移:在 Redis 集群中,可以使用故障转移机制,将故障节点的服务切换到其他节点上,以确保集群的可用性。故障转移可以通过主节点检测节点状态,或者通过心跳机制实现。
四、负载均衡问题
在Redis集群中,每个节点都需要承担一定的负载,如果负载不均衡,可能会导致某些节点的负载过高,影响系统的性能。
解决方法:
可以使用Redis Cluster来解决负载均衡问题。Redis Cluster会根据节点的负载情况,自动地将负载较高的节点上的数据转移到负载较低的节点上,从而实现负载均衡。
五、网络延迟问题
在Redis集群中,节点之间的通信需要通过网络进行,如果网络延迟过高,可能会导致数据的延迟,影响系统的性能。
解决方法:
可以使用Redis Cluster来解决网络延迟问题。Redis Cluster会根据节点之间的网络延迟情况,自动地将数据转移到网络延迟较低的节点上,从而降低数据的延迟。
六、节点故障问题
在Redis集群中,如果某个节点发生故障,可能会导致数据的不一致性或者系统的不可用性。
解决方法:
可以使用Redis Cluster来解决节点故障问题。Redis Cluster会自动地将故障节点上的数据转移到其他节点上,保证数据的一致性和系统的可用性。
七、安全问题
在Redis集群中,数据的安全性也是一个问题,如果节点的安全性受到攻击,可能会导致数据的泄露或者被篡改。因此,在使用Redis集群时,需要注意数据的安全性,并采取相应的安全措施。
解决方法:
可以使用Redis Cluster来解决安全问题。Redis Cluster会对节点的安全性进行监控,并采取相应的安全措施,例如限制节点的访问权限、加密数据等,从而保证数据的安全性。
12、有没有了解过cap和base原则
小伙伴从以下8个维度去 “吹牛”, 吹到 面试官 “口水直流,不能自已”
- 首先,CAP理论
- 其次,CAP理论的 一致性(C:Consistency)
- 再次:CAP理论的 可用性(A:Availability)
- 再次:CAP理论的 分区容错性(P:Partition tolerance)
- 再次:BASE 理论
- 再次:BASE 理论的 Basically Available
- 再次:BASE 理论的 Soft State
- 最后,BASE 理论的 Eventually Consistent
一、CAP理论
CAP理论作为分布式系统的基础理论,指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),这三个要素最多只能同时实现两点。
一致性(C:Consistency):
一致性是指数据在多个副本之间能否保持一致的特性。例如一个数据在某个分区节点更新之后,在其他分区节点读出来的数据也是更新之后的数据。
可用性(A:Availability):
可用性是指系统提供的服务必须一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。这里的重点是"有限时间内"和"返回结果"。
分区容错性(P:Partition tolerance):
分布式系统在遇到任何网络分区故障的时候,仍然需要能够保证对外提供满足一致性和可用性的服务。
选择 | 说明 |
---|---|
CA | 放弃分区容错性,加强一致性和可用性,其实就是传统的单机数据库的选择 |
AP | 放弃一致性,分区容错性和可用性,这是很多分布式系统设计时的选择 |
CP | 放弃可用性,追求一致性和分区容错性,网络问题会直接让整个系统不可用 |
二、BASE 理论
BASE 理论, 是对CAP中AP的一个扩展,对于我们的业务系统,我们考虑牺牲一致性来换取系统的可用性和分区容错性。BASE是Basically Available(基本可用),Soft state(软状态),和 Eventually consistent(最终一致性)三个短语的缩写。
Basically Available
基本可用:通过支持局部故障而不是系统全局故障来实现的。如将用户分区在 5 个数据库服务器上,一个用户数据库的故障只影响这台特定主机那 20% 的用户,其他用户不受影响。
Soft State
软状态,状态可以有一段时间不同步
Eventually Consistent
最终一致,最终数据是一致的就可以了,而不是时时保持强一致。
13、zk是如何保证一致性的
小伙伴从以下4个维度去 “吹牛”, 吹到 面试官 “口水直流,不能自已”
- 首先,ZAB协议(Zookeeper原子消息广播协议)
- 其次,选主
- 再次:选主后的数据同步
- 再次:事务操作
一:ZAB协议(Zookeeper原子消息广播协议)
zookeeper实现数据一致性的核心是ZAB协议(Zookeeper原子消息广播协议)。该协议需要做到以下几点:
(1)集群在半数以下节点宕机的情况下,能正常对外提供服务;
(2)客户端的写请求全部转交给leader来处理,leader需确保写变更能实时同步给所有follower及observer;
(3)leader宕机或整个集群重启时,需要确保那些已经在leader服务器上提交的事务最终被所有服务器都提交,确保丢弃那些只在leader服务器上被提出的事务,并保证集群能快速恢复到故障前的状态。
Zab协议有两种模式, 崩溃恢复(选主+数据同步)和消息广播(事务操作)。
任何时候都需要保证只有一个主进程负责进行事务操作,而如果主进程崩溃了,就需要迅速选举出一个新的主进程。主进程的选举机制与事务操作机制是紧密相关的。
下面详细讲解这三个场景的协议规则,从细节去探索ZAB协议的数据一致性原理。
二、选主
leader选举是zk中最重要的技术之一,也是保证分布式数据一致性的关键所在。当集群中的一台服务器处于如下两种情况之一时,就会进入leader选举阶段——服务器初始化启动、服务器运行期间无法与leader保持连接。
选举阶段,集群间互传的消息称为投票,投票Vote主要包括二个维度的信息:ID、ZXID
- ID 被推举的leader的服务器ID,集群中的每个zk节点启动前就要配置好这个全局唯一的ID。
- ZXID 被推举的leader的事务ID ,该值是从机器DataTree内存中取的,即事务已经在机器上被commit过了。
节点进入选举阶段后的大体执行逻辑如下:
(1)设置状态为LOOKING,初始化内部投票Vote (id,zxid) 数据至内存,并将其广播到集群其它节点。节点首次投票都是选举自己作为leader,将自身的服务ID、处理的最近一个事务请求的ZXID(ZXID是从内存数据库里取的,即该节点最近一个完成commit的事务id)及当前状态广播出去。然后进入循环等待及处理其它节点的投票信息的流程中。
(2)循环等待流程中,节点每收到一个外部的Vote信息,都需要将其与自己内存Vote数据进行PK,规则为取ZXID大的,若ZXID相等,则取ID大的那个投票。若外部投票胜选,节点需要将该选票覆盖之前的内存Vote数据,并再次广播出去;同时还要统计是否有过半的赞同者与新的内存投票数据一致,无则继续循环等待新的投票,有则需要判断leader是否在赞同者之中,在则退出循环,选举结束,根据选举结果及各自角色切换状态,leader切换成LEADING、follower切换到FOLLOWING、observer切换到OBSERVING状态。
算法细节可参照FastLeaderElection.lookForLeader(),主要有三个线程在工作:选举线程(主动调用lookForLeader方法的线程,通过阻塞队列sendqueue及recvqueue与其它两个线程协作)、WorkerReceiver线程(选票接收器,不断获取其它服务器发来的选举消息,筛选后会保存到recvqueue队列中。zk服务器启动时,开始正常工作,不停止)以及WorkerSender线程(选票发送器,会不断地从sendqueue队列中获取待发送的选票,并广播至集群)。WorkerReceiver线程一直在工作,即使当前节点处于LEADING或者FOLLOWING状态,它起到了一个过滤的作用,当前节点为LOOKING时,才会将外部投票信息转交给选举线程处理;如果当前节点处于非LOOKING状态,收到了处于LOOKING状态的节点投票数据(外部节点重启或网络抖动情况下),说明发起投票的节点数据跟集群不一致,这时,当前节点需要向集群广播出最新的内存Vote(id,zxid),落后节点收到该Vote后,会及时注册到leader上,并完成数据同步,跟上集群节奏,提供正常服务。
三、选主后的数据同步
选主算法中的zxid是从内存数据库中取的最新事务id,事务操作是分两阶段的(提出阶段和提交阶段),leader生成提议并广播给followers,收到半数以上的ACK后,再广播commit消息,同时将事务操作应用到内存中。
follower收到提议后先将事务写到本地事务日志,然后反馈ACK,等接到leader的commit消息时,才会将事务操作应用到内存中。可见,选主只是选出了内存数据是最新的节点,仅仅靠这个是无法保证已经在leader服务器上提交的事务最终被所有服务器都提交。比如leader发起提议P1,并收到半数以上follower关于P1的ACK后,在广播commit消息之前宕机了,选举产生的新leader之前是follower,未收到关于P1的commit消息,内存中是没有P1的数据。而ZAB协议的设计是需要保证选主后,P1是需要应用到集群中的。这块的逻辑是通过选主后的数据同步来弥补。
选主后,节点需要切换状态,leader切换成LEADING状态后的流程如下:
(1)重新加载本地磁盘上的数据快照至内存,并从日志文件中取出快照之后的所有事务操作,逐条应用至内存,并添加到已提交事务缓存commitedProposals。这样能保证日志文件中的事务操作,必定会应用到leader的内存数据库中。
(2)获取learner发送的FOLLOWERINFO/OBSERVERINFO信息,并与自身commitedProposals比对,确定采用哪种同步方式,不同的learner可能采用不同同步方式(DIFF同步、TRUNC+DIFF同步、SNAP同步)。这里是拿learner内存中的zxid与leader内存中的commitedProposals(min、max)比对,如果zxid介于min与max之间,但又不存在于commitedProposals中时,说明该zxid对应的事务需要TRUNC回滚;如果 zxid 介于min与max之间且存在于commitedProposals中,则leader需要将zxid+1~max 间所有事务同步给learner,这些内存缺失数据,很可能是因为leader切换过程中造成commit消息丢失,learner只完成了事务日志写入,未完成提交事务,未应用到内存。
(3)leader主动向所有learner发送同步数据消息,每个learner有自己的发送队列,互不干扰。同步结束时,leader会向learner发送NEWLEADER指令,同时learner会反馈一个ACK。当leader接收到来自learner的ACK消息后,就认为当前learner已经完成了数据同步,同时进入“过半策略”等待阶段。当leader统计到收到了一半已上的ACK时,会向所有已经完成数据同步的learner发送一个UPTODATE指令,用来通知learner集群已经完成了数据同步,可以对外服务了。
细节可参照Leader.lead() 、Follower.followLeader()及LearnerHandler类。
四、事务操作
ZAB协议对于事务操作的处理是一个类似于二阶段提交过程。
针对客户端的事务请求,leader服务器会为其生成对应的事务proposal,并将其发送给集群中所有follower机器,然后收集各自的选票,最后进行事务提交。
流程如下图
ZAB协议的二阶段提交过程中,移除了中断逻辑(事务回滚),所有follower服务器要么正常反馈leader提出的事务proposal,要么就抛弃leader服务器。follower收到proposal后的处理很简单,将该proposal写入到事务日志,然后立马反馈ACK给leader,也就是说如果不是网络、内存或磁盘等问题,follower肯定会写入成功,并正常反馈ACK。leader收到过半follower的ACK后,会广播commit消息给所有follower,并将事务应用到内存;follower收到commit消息后会将事务应用到内存。
ZAB协议中多次用到“过半”设计策略 ,该策略是zk在A(可用性)与C(一致性)间做的取舍,也是zk具有高容错特性的本质。相较分布式事务中的2PC(二阶段提交协议)的“全量通过”,ZAB协议可用性更高(牺牲了部分一致性),能在集群半数以下服务宕机时正常对外提供服务。
14、你如何设计一个能抗住大流量的系统,说说设计方案
小伙伴说,他是参考《尼恩Java面试宝典》中架构设计面试专题 吹的, 面试官非常满意
15、有没有了解过缓存策略有哪些
小伙伴从以下3个维度去 “吹牛”, 吹到 面试官 “口水直流,不能自已”
- 首先,Cache Aside(旁路缓存)策略
- 其次,Read/Write Through(读穿 / 写穿)策略
- 再次:Write Back(写回)策略
一、Cache Aside(旁路缓存)策略
我们可以在更新数据时不更新缓存,而是删除缓存中的数据,在读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。
缓存读写过程
这个策略就是我们使用缓存最常见的策略,Cache Aside 策略(也叫旁路缓存策略),这个策略数据以数据库中的数据为准,缓存中的数据是按需加载的。它可以分为读策略和写策略,
读策略的步骤是:
从缓存中读取数据;
如果缓存命中,则直接返回数据;
如果缓存不命中,则从数据库中查询数据;
查询到数据后,将数据写入到缓存中,并且返回给用户。
写策略的步骤是:
更新数据库中的记录;
删除缓存记录。
注意
Cache Aside 存在的最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果你的业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:
- 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
- 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。
二、Read/Write Through(读穿 / 写穿)策略
这个策略的核心原则是用户只与缓存打交道,由缓存和数据库通信,写入或者读取数据。这就好比你在汇报工作的时候只对你的直接上级汇报,再由你的直接上级汇报给他的上级,你是不能越级汇报的。
Write Through 的策略是这样的:先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,我们把这种情况叫做“Write Miss(写失效)”。
一般来说,我们可以选择两种“Write Miss”方式:一个是“Write Allocate(按写分配)”,做法是写入缓存相应位置,再由缓存组件同步更新到数据库中;另一个是“No-write allocate(不按写分配)”,做法是不写入缓存中,而是直接更新到数据库中。
在 Write Through 策略中,我们一般选择“No-write allocate”方式,原因是无论采用哪种“Write Miss”方式,我们都需要同步将数据更新到数据库中,而“No-write allocate”方式相比“Write Allocate”还减少了一次缓存的写入,能够提升写入的性能。
Read Through 策略就简单一些,它的步骤是这样的:先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据。
下面是 Read Through/Write Through 策略的示意图:
Read/Write Through策略示意图
Read Through/Write Through 策略的特点是由缓存节点而非用户来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是 Memcached 还是 Redis 都不提供写入数据库,或者自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略,比如说在上一节中提到的本地缓存 Guava Cache 中的 Loading Cache 就有 Read Through 策略的影子。
我们看到 Write Through 策略中写数据库是同步的,这对于性能来说会有比较大的影响,因为相比于写缓存,同步写数据库的延迟就要高很多了。那么我们可否异步地更新数据库?这就是我们接下来要提到的“Write Back”策略。
三、Write Back(写回)策略
这个策略的核心思想是在写入数据时只写入缓存,并且把缓存块儿标记为“脏”的。而脏块儿只有被再次使用时才会将其中的数据写入到后端存储中。
需要注意的是,在“Write Miss”的情况下,我们采用的是“Write Allocate”的方式,也就是在写入后端存储的同时要写入缓存,这样我们在之后的写请求中都只需要更新缓存即可,而无需更新后端存储了,我将 Write back 策略的示意图放在了下面:
Write Back 写回策略示意图
如果使用 Write Back 策略的话,读的策略也有一些变化了。
我们在读取缓存时如果发现缓存命中则直接返回缓存数据。如果缓存不命中则寻找一个可用的缓存块儿,如果这个缓存块儿是“脏”的,就把缓存块儿中之前的数据写入到后端存储中,并且从后端存储加载数据到缓存块儿,如果不是脏的,则由缓存组件将后端存储中的数据加载到缓存中,最后我们将缓存设置为不是脏的,返回数据就好了。
Write Back 读策略示意图
其实这种策略不能被应用到我们常用的数据库和缓存的场景中,它是计算机体系结构中的设计,比如我们在向磁盘中写数据时采用的就是这种策略。无论是操作系统层面的 Page Cache,还是日志的异步刷盘,亦或是消息队列中消息的异步写入磁盘,大多采用了这种策略。因为这个策略在性能上的优势毋庸置疑,它避免了直接写磁盘造成的随机写问题,毕竟写内存和写磁盘的随机 I/O 的延迟相差了几个数量级呢。
但因为缓存一般使用内存,而内存是非持久化的,所以一旦缓存机器掉电,就会造成原本缓存中的脏块儿数据丢失。所以你会发现系统在掉电之后,之前写入的文件会有部分丢失,就是因为 Page Cache 还没有来得及刷盘造成的。
当然,你依然可以在一些场景下使用这个策略,在使用时,我想给你的落地建议是:你在向低速设备写入数据的时候,可以在内存里先暂存一段时间的数据,甚至做一些统计汇总,然后定时地刷新到低速设备上。比如说,你在统计你的接口响应时间的时候,需要将每次请求的响应时间打印到日志中,然后监控系统收集日志后再做统计。但是如果每次请求都打印日志无疑会增加磁盘 I/O,那么不如把一段时间的响应时间暂存起来,经过简单的统计平均耗时,每个耗时区间的请求数量等等,然后定时地,批量地打印到日志中。
总结
- Cache Aside 是我们在使用分布式缓存时最常用的策略,你可以在实际工作中直接拿来使用。推荐使用
- Read/Write Through 和 Write Back 策略需要缓存组件的支持,所以比较适合你在实现本地缓存组件的时候使用;
- Write Back 策略是计算机体系结构中的策略,不过写入策略中的只写缓存,异步写入后端存储的策略倒是有很多的应用场景。
参考文献
尼恩的系统架构知识图谱(一张价值10w的系统架构知识图谱)
www.processon.com/view/link/60fb9421637689719d246739
尼恩的秒杀系统的架构
www.processon.com/view/link/61148c2b1e08536191d8f92f
尼恩说在最后
在尼恩的(50+)读者社区中,很多、很多小伙伴需要进大厂、拿高薪。
尼恩团队,会持续结合一些大厂的面试真题,给大家梳理一下学习路径,看看大家需要学点啥?
前面用多篇文章,给大家介绍阿里、百度、字节、滴滴的真题:
《太猛了,靠“吹牛”过顺丰一面,月薪30K》
《炸裂了…京东一面索命40问,过了就50W+》
《问麻了…阿里一面索命27问,过了就60W+》
《百度狂问3小时,大厂offer到手,小伙真狠!》
《饿了么太狠:面个高级Java,抖这多硬活、狠活》
《字节狂问一小时,小伙offer到手,太狠了!》
《收个滴滴Offer:从小伙三面经历,看看需要学点啥?》
这些真题,都会收入到 史上最全、持续升级的 PDF电子书 《尼恩Java面试宝典》。
本文收录于 《尼恩Java面试宝典》 V95版。
基本上,把尼恩的 《尼恩Java面试宝典》吃透,大厂offer很容易拿到滴。
另外,下一期的 大厂面经,更加精彩,具体可以参见文末公号。
《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》PDF,请到下面公号【技术自由圈】取↓↓↓