后端面试
2025-02-06 20:20:28 0 举报
AI智能生成
在高级后端开发工程师的面试过程中,面试官将深入探究应聘者的专业技术能力,特别是对于编程语言、数据结构、算法、系统设计、数据库优化和软件工程等方面的理解和实践经验。参与面试的应聘者应能熟练掌握至少一种高级编程语言,比如Java、Python或C#,并能展示其解决复杂问题的能力,以及优雅编写清晰、高效代码的能力。 面试常包括算法编码题目测试快速解决方案设计,以及针对数据库技术(如SQL和NoSQL)、缓存机制(如Redis)、消息队列(如Kafka)和微服务架构(如Spring Boot或.NET Core)等关键技术的深入讨论。除了理论和实践技术问题,面试还可能覆盖软件开发生命周期和团队协作,特别是敏捷开发和版本控制工具(如Git)的使用。 应聘者面对这些挑战时,需要展示出色的问题解决能力、扎实的技术底子以及对软件开发最佳实践的理解,同时应具备快速学习新技术的能力。整个面试过程不仅考察技能的掌握程度,也评估候选人是否适应不断变化的技术环境,并能在团队中发挥作用。
作者其他创作
大纲/内容
JVM
并发编程
Java内存模型
1、首先是定义了每个线程独享一个工作内存,和一个共享主内存
2、变量在工作内存和主内存之间的读写机制,主要有8个,lock、unlock、read、load、use、assign、store、write
3、围绕着原子性、可见性、有序性;
首先read、load、use、assign、store、write都是原子操作,lock、unlock可以提供更大范围的原子操作,通过限制锁的临界区只能被一个线程同时访问,lock,unlock对应字节码指令是monitorenter、monitorexit,java里面就是synchronized关键字。
可见性:保证变量在多个工作内存中共享。主要有volatile、synchronized两种实现。volatile通过对象赋值之后立即刷新主内存,每次取值都从主内存获取实现;synchronized通过每次unlock之后会将lock对象刷新到主内存实现。
有序性:单个线程一定是有序的,多个线程就要保证有序性。volatile是语义级别的,每次赋值之后会立即带上lock前缀的空命令;synchronized通过加锁的形式实现。
4、内存模型规定,long,double类型在32位系统可以分两次加载,不实现原子性,但商用虚拟机都是实现原子操作的,可以不管
volatile
内存可见性
1、变量赋值之后会立即会写到主内存中(这也决定了volatile在并发环境下不一定是完全安全的,因为有些操作不一定是原子的)
2、有一个神奇的地方就是:就是当一个线程在读取一个使用volatile修饰的变量的时候,会将该线程中所有使用的变量从主内存中重新读取。
禁止指令重排序
cpu的指令重排序是:cpu会改变指令分发给各处理单元的先后顺序,前提是保证指令与指令之间的依赖关系和处理结果一致
volatile起的作用,在指令编译时,如果volatile变量被赋值后,会在指令后新增一个带lock前缀的空操作A,这个带lock前缀的空操作A会将变量立即会写入主内存,以此避免了操作A前后的指令重排,也称为“内存屏障”。
volatile性能
volatile性能相比synchronized加锁的效率会更高,volatile变量的读取速度与普通变量差不多,写入操作需要同步到主内存
volatile不是随便用的
正如先前说的,volatile变量在被赋值后会回写到主内存,这个操作并不能保证并发一定安全。原因是:计算机执行一条程序一条指令并不是原子性的,比如一个i++自增操作,在汇编层面需要取数据,+1,会写数据多步,更别说后面的机器码了,如果一个线程对变量赋值后,会写到主内存的之前,此时另一个线程已经读取了前一次的数据,那么势必会出现问题。
所以:volatile变量使用的前提是1、变量的操作不依赖前一次操作结果
2、变量不需要与其他变量共同约束
基本概念
并行与并发
并行:同时进行多个任务,发挥多cpu,多核cpu的能力,开发难度较大,多用于一些大型计算,图像处理
并发:轮流执行多个任务,发挥多cpu,多核cpu的能力,多用于互联网多用户请求
“并行”是“并发”概念的一个子集
为什么会出现并发
硬件原因:cpu多核,每个cpu都拥有高速缓存,高速缓存中的变量不共享,主内存中的变量才共享
相应的,JVM内存模型也有类似的概念,分为工作内存和主内存,每个线程独立享有一个工作内存,工作内存中变量不共享。所以如果要让多核cpu协同工作,就有了并发的概念
锁优化
1、自旋锁、适应性自旋
2、锁消除
3、锁粗化
4、轻量级锁
5、偏向锁
1、自旋是针对大部分同步代码块持有锁的时间都很短,而切换线程会产生很多用户态到内核态的切换,比如栈的切换,过多地切换会耗费更多的性能,于是就采用了CAS自旋锁,该锁会通过循环的方式获取锁对象,jdk6以后是默认开始了自旋锁 -XX:+UseSpinning,自旋次数默认10次,-XX:PreBlockSpin来更改
2、锁消除:即使存在同步代码块,但检测到不存在共享数据竞争时。
3、锁粗化:当同步代码块较多且比较零碎时,直接扩大锁的范围。
4、轻量级锁:通过LockRecord指向锁对象,标志位为00
5、偏向锁:记录第一个使用线程的ID和时间戳,适用于并发情况很少的情况。如果有多个线程竞争,就会转为轻量级锁,乃至重量级锁。
1、Synchronized是一种悲观的锁机制
2、CAS(Compare and set)是一种乐观的锁机制,是通过硬件的机器码支持的,把当前值与预期的旧值进行比较,相等时才进行赋值,将这个过程原子化。像AutoInterger的就是采用Cas机制,JAVA提供了unsafe类型
线程安全
1、判断一个操作是否是线程安全的,应采用“先行发生”原则判断
线程安全的分类
不可变
1、某些变量用final修饰,保证其在各线程之间的可见性
绝对线程安全
1、一般情况下,除了原子操作,很少有绝对线程安全,即使很多synchronized线程安全类也不一定是绝对线程安全的,还需要人为进行同步
相对线程安全
1、java中的线程安全类,如Vector,都是相对安全的
线程兼容
线程互斥
1、有些线程操作本身就是互斥的,容易产生死锁
线程安全的实现
互斥同步
互斥锁,如synchronized
无阻塞同步
CAS
无同步方案
1、可重入代码
2、本地线程存储(如ThreadLocal,web服务中每个请求就是一个线程,线程中采用本地存储)
并发编程
并发工具包
ReentrantLock(condition)
Semaphore
ReadWriteLock
StampedLock
CountDownLatch和CyclicBarrier
Executor与线程池
CompletableFuture
CompletionService
Fork/Join
并发理论基础
可见性、原子性、有序性
多核cpu缓存带来可见性问题
线程切换带来原子性问题(cpu线程切换发生在任意一条cpu指令之后)
编译优化带来有序性问题
思考题:听说32位机器对long型变量加减会有并发问题,是真的吗?<br>long类型64位,所以在32位的机器上,对long类型的数据操作通常需要多条指令组合出来,无法保证原子性,所以并发的时候会出问题<br>
java内存模型
Java 内存模型的本质:规范了 JVM 如何提供按需禁用缓存和编译优化的方法。
Happens-Before 规则
单线程中,后面的代码对前面执行的代码是可见的。(如果后面的代码对前面的写有读操作,则需要禁止重排序,否则程序运行结果会出错)
管程中,前一个线程的解锁对后一个线程的加锁是可见的。(如果不能保证可见,锁的竞争就会有问题, 也无法保证互斥同步)
volatile变量的写操作对后续的读操作是可见的。(禁用缓存,同时加入内存屏障禁止了指令重排序)
主线程启动子线程,在子线程start前面的操作对子线程来说都是可见的。
主线程启动子线程,子线程调用join方法之后,子线程中的操作对join之后的代码都是可见的。
线程中断规则:线程发起中断interrupt对于后续的中断检测代码Thread.interrupted()都是可见的。
对象终结规则:一个对象的初始化对于后续它调用finalize方法都是可见的。
死锁
产生死锁的四个条件
互斥
不可抢占
占有且等待,不会主动释放
循环等待
解决死锁(破坏其中一个条件)
破坏占有后不可抢占:申请锁失败,尝试释放已经占有的锁
破坏占有且等待:一次性申请所有的锁
破坏循环等待:将多把锁按一定顺序进行加锁
等待-通知机制
https://blog.csdn.net/weixin_37968613/article/details/109510781 尽量使用notifyAll,而不用notify
分布式系统
基础
为什么使用分布式
增大系统容量
加强可用性,消除单点故障
技术栈
提高性能
负载均衡
异步调用
主要是消息队列,能够对前端的请求进行削峰,增大系统的吞吐量。针对消息可能丢失的问题需要对消息持久化
加缓存
缓存可以有效提高系统的访问能力,从浏览器、网络、数据库、CPU、硬盘都有缓存。在分布式缓存系统中,需要的是缓存集群,此时需要一个proxy来做缓存的分片和路由。
数据分区
分库、分表
数据镜像
读写分离,主要关注数据一致性问题
提高稳定性
服务拆分
可以隔离故障、模块重用
限流降级
高可用架构
多租户隔离,灾备多活,或是数据 可以在其中复制保持一致性的集群。总之,就是为了不出单点故障。
高可用运维
CI/CD 、自动化测试、灰度发布
服务冗余
多搞点机器
全栈系统监控
监控什么
基础层
cpu、内存、磁盘IO、网络
中间层
各种中间件,redis、kafka、tomcat、nginx
应用层
HTTP访问请求、响应时间、是否正确、方法调用链路、性能瓶颈
监控系统注意什么
要有关联性
上层系统出现问题,能关联出此时基础层的情况,可以快速定位问题,如google的zipkin可以做到全链路的监控
监控指标不用多,要精
信息太多等于没有信息
分布式
基础
为什么使用分布式
增大系统容量
加强可用性,消除单点故障
技术栈
提高性能
负载均衡
异步调用
主要是消息队列,能够对前端的请求进行削峰,增大系统的吞吐量。针对消息可能丢失的问题需要对消息持久化
加缓存
缓存可以有效提高系统的访问能力,从浏览器、网络、数据库、CPU、硬盘都有缓存。在分布式缓存系统中,需要的是缓存集群,此时需要一个proxy来做缓存的分片和路由。
数据分区
分库、分表
数据镜像
读写分离,主要关注数据一致性问题
提高稳定性
服务拆分
可以隔离故障、模块重用
限流降级
高可用架构
多租户隔离,灾备多活,或是数据 可以在其中复制保持一致性的集群。总之,就是为了不出单点故障。
高可用运维
CI/CD 、自动化测试、灰度发布
服务冗余
多搞点机器
全栈系统监控
监控什么
基础层
cpu、内存、磁盘IO、网络
中间层
各种中间件,redis、kafka、tomcat、nginx
应用层
HTTP访问请求、响应时间、是否正确、方法调用链路、性能瓶颈
监控系统注意什么
要有关联性
上层系统出现问题,能关联出此时基础层的情况,可以快速定位问题,如google的zipkin可以做到全链路的监控
监控指标不用多,要精
信息太多等于没有信息
服务调度
主要方面
服务关键程度
服务依赖关系
微服务是最优解,下限是不能产生依赖环
服务发现
架构的版本管理
每个模块之间版本的依赖关系
服务生命周期管理
我们会有一张服务架构的地图,可以知道整个架构有多少种服务、服务版本、每个服务有多少实例,相应状态
服务无状态
有状态的服务是难办的,可以通过“转移问题”,将状态转移到第三方服务上,such as redis、mysql、zookeeper、或NFS、Ceph(分布式文件系统)
分布式事务的一致性
两阶段/三阶段提交
paxos
流量调度
主要功能
服务流控
发现、降级、熔断、路由
流量控制
负载均衡、流量分配
流量管理
协议转换、校验、缓存、边缘计算等
关键技术
高性能
高性能语言编写
扛流量
集群、paxos
简单的业务计算
流控应是服务化
设计模式
行为型模式
策略模式
1、对同一事情的不同复杂算法进行封装,这样比如要新增一种方式或修改一种算法,不会影响到已有的算法(减少算法定义与算法使用的耦合)
2、可以设计一个策略中心类,用来策略选择,策略执行,采用聚合的形式。策略中心可以采用一个简单工厂,根据不同条件选择不同策略
分支主题
模板方法
1、针对一些流程重复代码多,流程单一的情况,我们可以把不变的行为搬到父类中,变化的逻辑由子类实现。
分支主题
观察者模式
1、定义一种监听关系,当对象发生变化时会通知观察者对象
2、查看结构图可以看出主体对象与观察者对象是耦合的,虽然依赖的是接口,但观察者和主体对象没有一定的关联关系,可以采用委托的形式解除这种耦合
<br>
状态模式
1、一些有顺序有条件的状态转换,把写在一处的if else移到不同的状态类中,这样加入要新增,修改状态,只需要修改相应的状态类
2、该模式可以使与特定状态相关的行为局部化
<br>
备忘录模式
1、执行过程中备份一份记录,如 游戏存进度,将这份记录保存在对象之外
<br>
迭代器模式
1、不管一个集合中的元素关系对集合内的元素进行遍历
2、平时java开发中的foreach和Iterator就是迭代器模式
<br>
命令模式
1、将请求对象化,可以对请求排队,以及支持可撤销
<br>
职责链模式
1、将多个对象连成一条链,传递请求,直到可以被处理。避免了发送者和接收者的耦合。
2、如果各处理对象直接没有先后关系,也可以直接对处理对象进行循环判断 for(){
if(a.supports(b)){}
}
<br>
中介者模式
1、创建一个中介者对象,其他对象间的交互通过中介者来完成,减少有些不必要的耦合,但中介者对象的逻辑相对复杂
分支主题
解释器模式
1、如果一种特定类型的问题发生的频率足够高,就可能值得为各个实例表述为简单语言的句子,通过文法分析构建一个解释器
2、正则表达式的解析 就是解释器模式的例子
分支主题
访问者模式
1、最复杂的设计模式,并且使用频率不高,《设计模式》的作者评价为:大多情况下,你不需要使用访问者模式,但是一旦需要使用它时,那就真的需要使用了。
访问者模式是一种将数据操作和数据结构分离的设计模式。
https://www.jianshu.com/p/1f1049d0a0f4
分支主题
分支主题
设计原则
单一职责
<br>
开闭原则
依赖倒转
平时我们开发的代码如果依赖低层模块,是违背依赖倒转原则的
<br>
里氏代换
子类型能够替换父类型,这也是依赖倒转能实现的前提
迪米特法则
1、最少知识原则,如果两个类不必直接通信,那么久没必要发生直接的相互作用,如果一定要调用,可以通过第三者转发
2、中介者模式
聚合复用原则
尽量使用聚合,少使用继承
结构型模式
装饰模式
1、为已有对象增加职责
2、一般在增加新功能的时候,增加一些行为时,可以采用装饰模式
比如IO流使用的是装饰者模式,可以层层增加功能
分支主题
代理模式
1、提供一个代理,控制被代理对象的访问
2、装饰模式是在原有对象的基础上新增功能,一般会将原有对象传入装饰器
3、实际应用:远程代理、虚拟代理、安全代理等
分支主题
外观模式
1、接口的封装,为多个接口提供一个统一的接口
<br>
适配器模式
1、一般针对老接口,第三方接口不适配的情况,增加一个适配器
<br>
桥接模式
1、将对象的属性以抽象的形式聚合,这样多个属性可以独立变化,也就是“将抽象部分与它的实现分离”
<br>
享元模式
1、对象实现共享,当程序中有大量类似的对象创建的时候可以使用
2、类似JVM存放字符串采用的就是享元模式
<br>
组合模式
1、树形结构以表示“部分-整体”的关系
分支主题
创建型模式
单例
1、保证类只有一个实例
2、饱汉(需要实例再创建)、饿汉(一初始化就创建对象)
分支主题
<br>
双检锁的写法需要对instance加volitile,否则可能在高并发场景下因指令重排导致获取到空对象<br>
简单工厂
1、定义一个factory,根据条件创建相应的对象
2、增加对象时,需要修改工厂类
<br>
工厂方法
1、一种对象的创建采用一个独立的工厂,增加对象时只要增加一个工厂,符合开闭原则
<br>
抽象工厂
1、工厂也是抽象的,可以提供一系列接口的多种实现
2、与工厂方法的区别:
顾名思义:工厂方法:一个工厂生产中产品有多种方法 抽象工厂:一种产品可以由多个工厂生产,且每个工厂还有不同的生产方式
工厂方法专注于一个对象的创建,抽象工厂是一组对象
抽象工厂的抽象程度比工厂方法高,一般抽象工厂中生产不同产品可以使用工厂方法配合
分支主题
原型模式
1、对象复制比对象创建效率更高
2、java里面可以实现cloneable接口重写clone方法,实现复制方法即可
3、注意深拷贝与浅拷贝的问题,对于对象的拷贝,默认会只拷贝对象的引用
<br>
建造者模式
1、针对复杂多样的对象构建,封装构建过程
<br>
JAVA
JAVA集合
集合有哪些
Map
Hashtable
所有public方法都有synchronized关键字 线程安全
key和value都不允许null值
父类是Dictionary,而HashMap的父类是AbstractMap
LinkedHashMap
TreeMap
HashMap
ConcurrentHashMap
Collection
Set
HashSet
LinkedHashSet
可以保证元素先进先出FIFO
TreeSet
红黑树,里面的元素一定是有序的
Queue
PriorityQueue
List
ArrayList
Vector
LinkedList
如何选用集合
元素需要唯一吗?
是:set
需要排序吗?
是:TreeSet或LinkedHashSet
否:HashSet
否:List
需要安全吗?
是:Vector
否:ArrayList或者LinkedList
查询多用ArrayList、增删多用LinkedList
提问
为什么Hashtable不允许null值?
我们可以看源码里面,hashtable的Entry中setValue方法:
public V setValue(V value) {
if (value == null)
throw new NullPointerException();
V oldValue = this.value;
this.value = value;
return oldValue;
}
HashSet和LinkHashSet允许存在null数据,但是TreeSet中插入null数据时为什么会报NullPointerException?
Dictionary是怎么设计的?
官方注释里面说该类已经过时,新的实现采用Map接口
NOTE: This class is obsolete. New implementations should
implement the Map interface, rather than extending this class.
但我们可以来看一下,dictionary抽象类里面定义了类似size(),isEmpty(),put(),get(),keys(),elements(),remove()方法,
TreeSet排序原理是怎样的?
为什么Map不继承Collection接口?
虽然Map属于Java集合,但map不属于集合,map包含的是键值对,不适合“一组对象”这一说
通过迭代器fail-fast属性,fail-safe了解吗?
HashMap和HashTable
hashMap
Jdk6
HashMap:能插入key为null,放在0号位。插入已经存在的key会替换相应value,并返回旧值,否则返回null<br>采用头插法:<br> void addEntry(int hash, K key, V value, int bucketIndex) {<br> Entry&lt;K,V&gt; e = table[bucketIndex];<br> table[bucketIndex] = new Entry&lt;K,V&gt;(hash, key, value, e); //头插法<br> if (size++ &gt;= threshold)<br> resize(2 * table.length);<br> }<br>计算hash的方法:<br> static int hash(int h) { //其中h为key对象的hashCode<br> h ^= (h &gt;&gt;&gt; 20) ^ (h &gt;&gt;&gt; 12);<br> return h ^ (h &gt;&gt;&gt; 7) ^ (h &gt;&gt;&gt; 4);<br> }<br>
jdk8
jdk8做了很多优化
put方法加了onlyIfAbsent、evict参数来优化不需要改变值和创建模式,结点名称变为Node,引入树的概念
计算hash的方法:
key.hashCode()) ^ (h &gt;&gt;&gt; 16)
hashTable
Jdk6
HashTable: key和value都不能为null,插入已经存在的值时逻辑和hashMap一样。
采用头插法:
// Creates the new entry.
Entry&lt;K,V&gt; e = tab[index];
tab[index] = new Entry&lt;K,V&gt;(hash, key, value, e);
计算hash的方法:
hash & 0x7FFFFFFF 其中hash为key对象的hashCode
两者的细微差异
看到hashMap和HashTable的扩容重hash过程:
hashTable是在put时发现count触发到threshold了,直接进行重哈希rehash,大小升级到2*old+1,然后再插入,这会是put操作时间延长
hashMap会提前预测,先插入成功,然后判断当发现再加一个就触发threshold时,就提前开始resize,升级为2*old大小
设计模式
创建型模式
结构型模式
行为型模式
面向对象的好处
封装,面向对象的优点就是关注点是在对象,每个对象负责自己的事情,需求变化时,代码改动较少。而相对于面向过程,需求变化之后可能整个流程都要重新测试,不利于软件的发展。
OO精神:可维护、可拓展、可复用、灵活性好
JVM
并发编程
为什么会出现并发
硬件原因:cpu多核,每个cpu都拥有高速缓存,高速缓存中的变量不共享,主内存中的变量才共享
相应的,JVM内存模型也有类似的概念,分为工作内存和主内存,每个线程独立享有一个工作内存,工作内存中变量不共享。所以如果要让多核cpu协同工作,就有了并发的概念
Java内存模型
1、首先是定义了每个线程独享一个工作内存,和一个共享主内存
2、变量在工作内存和主内存之间的读写机制,主要有8个,lock、unlock、read、load、use、assign、store、write
volatile
内存可见性
变量赋值之后会立即会写到主内存中(这也决定了volatile在并发环境下不一定是完全安全的)
禁止指令重排序
cpu的指令重排序是:cpu会改变指令分发给各处理单元的先后顺序,前提是保证指令与指令之间的依赖关系和处理结果一致
volatile起的作用,在指令编译时,如果volatile变量被赋值后,会在指令后新增一个带lock前缀的空操作A,这个带lock前缀的空操作A会将变量立即会写入主内存,以此避免了操作A前后的指令重排,也称为“内存屏障”。
volatile性能
volatile性能相比synchronized加锁的效率会更高,volatile变量的读取速度与普通变量差不多,写入操作需要同步到主内存
volatile不是随便用的
正如先前说的,volatile变量在被赋值后会回写到主内存,这个操作并不能保证并发一定安全。原因是:计算机执行一条程序一条指令并不是原子性的,比如一个i++自增操作,在汇编层面需要取数据,+1,会写数据多步,更别说后面的机器码了,如果一个线程对变量赋值后,会写到主内存的之前,此时另一个线程已经读取了前一次的数据,那么势必会出现问题。
所以:volatile变量使用的前提是1、变量的操作不依赖前一次操作结果
2、变量不需要与其他变量共同约束
代码规范
变量
将局部变量的作用域最小化
1、 在第一次使用的地方进行声明
2、 局部变量都是要自行初始化,初始化条件不满足,就不要声明;
初始化的好处,减小局部变量表的大小,提高性能;
同时避免局部变量过早声明导致不正确的使用。
将常量声明为static final,并以大写命名
这样在编译期间就可以把这些内容放入常量池中,避免运行期间计算生成常量的值。另外,将常量的名字以大写命名也可以方便区分出常量与变量
循环内不要不断创建对象引用
例如:<br>for (int i = 1; i &lt;= count; i++)<br>{<br> Object obj = new Object(); <br>}<br>这种做法会导致内存中有count份Object对象引用存在,count很大的话,就耗费内存了,建议为改为:<br>Object obj = null;<br>for (int i = 0; i &lt;= count; i++)<br>{<br> obj = new Object();<br>}<br>这样的话,内存中只有一份Object对象引用,每次new Object()的时候,Object对象引用指向不同的Object罢了,但是内存中只有一份,这样就大大节省了内存空间了。
尽量避免随意使用静态变量,尤其是静态对象
要知道,当某个对象被定义为static的变量所引用,那么gc通常是不会回收这个对象所占有的堆内存的,如:
public class A
{
private static B b = new B();
}
此时静态变量b的生命周期与A类相同,如果A类不被卸载,那么引用B指向的B对象会常驻内存,直到程序终止
对象操作
当复制大量数据时,使用System.arraycopy()命令
使用最有效率的方式去遍历Map
遍历Map的方式有很多,通常场景下我们需要的是遍历Map中的Key和Value,那么推荐使用的、效率最高的方式是:<br>public static void main(String[] args)<br><br>{<br><br> HashMap&lt;String, String&gt; hm = new HashMap&lt;String, String&gt;();<br><br> hm.put(&quot;111&quot;, &quot;222&quot;);<br><br> Set&lt;Map.Entry&lt;String, String&gt;&gt; entrySet = hm.entrySet();<br><br> Iterator&lt;Map.Entry&lt;String, String&gt;&gt; iter = entrySet.iterator();<br><br> while (iter.hasNext())<br><br> {<br><br> Map.Entry&lt;String, String&gt; entry = iter.next();<br><br> System.out.println(entry.getKey() + &quot;&quot; + entry.getValue());<br><br> }<br><br>}<br>如果你只是想遍历一下这个Map的key值,那用”Set&lt;String&gt; keySet = hm.keySet();”会比较合适一些
使用带缓冲的输入输出流进行IO操作
带缓冲的输入输出流,即BufferedReader、BufferedWriter、BufferedInputStream、BufferedOutputStream,这可以极大地提升IO效率
对象创建
如果能估计到待添加的内容长度,为底层以数组方式实现的集合、工具类指定初始长度
比如ArrayList、LinkedLlist、StringBuilder、StringBuffer、HashMap、HashSet等等,以StringBuilder为例:
StringBuilder() // 默认分配16个字符的空间
StringBuilder(int size) // 默认分配size个字符的空间
StringBuilder(String str) // 默认分配16个字符+str.length()个字符空间
可以通过类(这里指的不仅仅是上面的StringBuilder)的构造函数来设定它的初始化容量,这样可以明显地提升性能。比如StringBuilder吧,length表示当前的StringBuilder能保持的字符数量。因为当StringBuilder达到最大容量的时候,它会将自身容量增加到当前的2倍再加2,无论何时只要StringBuilder达到它的最大容量,它就不得不创建一个新的字符数组然后将旧的字符数组内容拷贝到新字符数组中—-这是十分耗费性能的一个操作。试想,如果能预估到字符数组中大概要存放5000个字符而不指定长度,最接近5000的2次幂是4096,每次扩容加的2不管,那么:
在4096 的基础上,再申请8194个大小的字符数组,加起来相当于一次申请了12290个大小的字符数组,如果一开始能指定5000个大小的字符数组,就节省了一倍以上的空间
把原来的4096个字符拷贝到新的的字符数组中去
这样,既浪费内存空间又降低代码运行效率。所以,给底层以数组实现的集合、工具类设置一个合理的初始化容量是错不了的,这会带来立竿见影的效果。但是,注意,像HashMap这种是以数组+链表实现的集合,别把初始大小和你估计的大小设置得一样,因为一个table上只连接一个对象的可能性几乎为0。初始大小建议设置为2的N次幂,如果能估计到有2000个元素,设置成new HashMap(128)、new HashMap(256)都可以。
静态类、单例类、工厂类将它们的构造函数置为private
这是因为静态类、单例类、工厂类这种类本来我们就不需要外部将它们new出来,将构造函数置为private之后,保证了这些类不会产生实例对象。
基于效率和类型检查的考虑,应该尽可能使用array,无法确定数组大小时才使用ArrayList
Array和ArrayList的区别
1、Array定长,内容可以包含基本数据类型和对象类型
2、ArrayList长度可变,内容只能包含对象类型
3、如果对这个集合有频繁的移动或删除,那么Array和ArrayList都不合适,因使用LinkedList.
不要将数组声明为public static final
因为这毫无意义,这样只是定义了引用为static final,数组的内容还是可以随意改变的,将数组声明为public更是一个安全漏洞,这意味着这个数组可以被外部类所改变
不要创建不必要的对象
避免无意中创建的对象,如自动装箱
方式选择
使用同步代码块替代同步方法
这点在多线程模块中的synchronized锁方法块一文中已经讲得很清楚了,除非能确定一整个方法都是需要进行同步的,否则尽量使用同步代码块,避免对那些不需要进行同步的代码也进行了同步,影响了代码执行效率。
https://www.cnblogs.com/woyaobianfei/p/8046616.html
使用数据库连接池和线程池
不捕获Java类库中定义的继承自RuntimeException的运行时异常类,就是不要通过捕获异常来控制程序流程,可以通过判断来规避
异常处理效率低,RuntimeException的运行时异常类,其中绝大多数完全可以由程序员来规避,比如:
ArithmeticException可以通过判断除数是否为空来规避
NullPointerException可以通过判断对象是否为空来规避
IndexOutOfBoundsException可以通过判断数组/字符串长度来规避
ClassCastException可以通过instanceof关键字来规避
ConcurrentModificationException可以使用迭代器来规避
方法
可变参数要谨慎使用
可变参数是允许传0个参数的
如果是参数个数在1~多个之间的时候,要做单独的业务控制。
//可能很多 0~很多<br> static int sum(int... args) {<br> int sum = 0;<br> for (int arg : args)<br> sum += arg;<br> return sum;<br> }<br> <br> //要求参数的个数,是1~多个<br> //<br> static int sum1(int... args) {<br> if(args.length==0) {<br> //做点异常处理<br> }<br> if(args[0]==100) {<br><br> }<br> for(int i=1;i&lt;args.length;i++) {<br> int sum = 0;<br> sum += args[i];<br> return sum; <br> }<br> }<br><br> static int sum2(int flag, int... args) {<br> if(flag==100) {<br><br> }<br> int sum = 0;<br> for (int arg : args)<br> sum += arg;<br> return sum;<br> return Collections.EMPTY_LIST;<br> }
返回零长度的数组或集合,不要返回null
方法的结果返回null,会导致调用方的要单独处理为null的情况。返回零长度,调用方可以统一处理,如使用foreach即可。
JDK中也为我们提供了Collections.EMPTY_LIST这样的零长度集合
JAVA基础
基本数据类型 (注意:char是无符号数,2个字节)
boolean 1字节
byte 1字节 8位
char 2字节 0 - 2^16-1 无符号数
short 2字节 16位 2^15-1
int 4字节 32位 2^31-1
float 4字节 单浮点 2^31-1
double 8字节 双浮点 2^63-1
long 8字节 长整形 2^63-1
其他
接口优于抽象类
在JDK里常用的一种设计方法是:定义一个接口,声明一个抽象的骨架类实现接口,骨架类类实现通用的方法,而实际的业务类可以同时实现接口又继承骨架类,也可以只实现接口。
如HashSet实现了implements Set接口 但是又extends 类AbstractSet,而AbstractSet本身也实现了Set接口。其他如Map,List都是这样的设计的。
乘法和除法使用移位操作
分支主题
对于ThreadLocal使用前或者使用后一定要先remove
当前基本所有的项目都使用了线程池技术,这非常好,可以动态配置线程数、可以重用线程。<br><br>然而,如果你在项目中使用到了ThreadLocal,一定要记得使用前或者使用后remove一下。这是因为上面提到了线程池技术做的是一个线程重用,这意味着代码运行过程中,一条线程使用完毕,并不会被销毁而是等待下一次的使用。我们看一下Thread类中,持有ThreadLocal.ThreadLocalMap的引用:<br><br>/* ThreadLocal values pertaining to this thread. This map is maintained<br><br> * by the ThreadLocal class. */<br><br>ThreadLocal.ThreadLocalMap threadLocals = null;<br><br>线程不销毁意味着上条线程set的ThreadLocal.ThreadLocalMap中的数据依然存在,那么在下一条线程重用这个Thread的时候,很可能get到的是上条线程set的数据而不是自己想要的内容。<br><br>这个问题非常隐晦,一旦出现这个原因导致的错误,没有相关经验或者没有扎实的基础非常难发现这个问题,因此在写代码的时候就要注意这一点,这将给你后续减少很多的工作量。
避免Random实例被多线程使用,虽然共享该实例是线程安全的,但会因竞争同一seed 导致的性能下降,JDK7之后,可以使用ThreadLocalRandom来获取随机数
解释一下竞争同一个seed导致性能下降的原因,比如,看一下Random类的nextInt()方法实现:<br>1 public int nextInt() {<br><br>2 return next(32);<br><br>3 }<br><br>调用了next(int bits)方法,这是一个受保护的方法:<br><br>1 protected int next(int bits) {<br><br>2 long oldseed, nextseed;<br><br>3 AtomicLong seed = this.seed;<br><br>4 do {<br><br>5 oldseed = seed.get();<br><br>6 nextseed = (oldseed * multiplier + addend) & mask;<br><br>7 } while (!seed.compareAndSet(oldseed, nextseed));<br><br>8 return (int)(nextseed &gt;&gt;&gt; (48 - bits));<br><br>9 }<br><br>而这边的seed是一个全局变量:<br><br>1 /**<br><br>2 * The internal state associated with this pseudorandom number generator.<br><br>3 * (The specs for the methods in this class describe the ongoing<br><br>4 * computation of this value.)<br><br>5 */<br><br>6 private final AtomicLong seed;<br><br>多个线程同时获取随机数的时候,会竞争同一个seed,导致了效率的降低。
Redis
应用
基础操作
基本数据类型
String
set、mset、setex、setnx(如果key不存在才创建)、get、incr
List
lpush、lpop、rpush、rpop、llen、lrange
Hash
hset、hmset、hget、hgetall、hlen
Set
sadd、smembers、sismenber、scard、spop
Zset
可以排序的set,引入score概念,通过跳表实现,mysql索引也使用跳表
zadd、zrange、zrevrange、zcard、zscore、zrangebyscore、zrem
scan
与keys命令相比,scan通过游标分步进行,不会阻塞线程
高位进位加法的遍历顺序,避免了字典扩容的影响
操作渐进式rehash的hash时,需要同时遍历新旧两个hash
位图
setbit、getbit、bitcount(计数)、bitpos(查找定位)、
bitfield(可以操作多个bit位),bitfield还提供了溢出策略sat和fail
如bitfield w overflow sat incrby u4 2 1
限流
简单限流
1、zset通过时间窗口实现简单限流,以时间戳作为zset元素的score
漏斗限流
漏斗包含:容量、流速、剩余容量、上一次流出时间,每次取流量的时候会计算一下当前漏斗的剩余容量
漏斗可以交给hash缓存redis-cell,指令 cl.throttle key capacity operations seconds quota
分布式锁
redis可以通过一个key:value实现分布式锁功能
1、为了解决无法释放锁的问题,给锁设置超时时间。
为了避免设置超时时间失败,将set和expire作为原子操作,set keyname value ex seconds nx
2、可能出现事务还没结束,锁已经超时而释放。所以redis作为分布式锁不能用于时间比较长的事务
3、可重入问题(锁上加锁),可以通过ThreadLocal实现,对lock、unlock进行计数
分布式锁没有获取到锁怎么办?有哪些处理方式?
1、首先想到的就是重试,可以sleep一下重试
2、直接抛出异常,提示用户稍后重试
3、把请求加到延时队列
消息队列
异步消息队列
怎么实现?list可以通过push、pop实现消息队列
如果队列为空,消费者会不断轮询怎么办?消费者不断地轮询会造成较大的性能损耗,使得redis的qps大大增高,第一种可以采用如果队列为空,则sleep一下,但这样在现实情况下及时性会受到影响,第二种采用blpop、brpop命令,阻塞读,如果队列为空则阻塞在哪里,当队列有值时,可以立即读出;在这种情况下,如果迟迟未读到数据,服务器会释放连接,抛出异常,客户端需要作出处理,比如重试。
延时队列
延时队列怎么实现?通过zset,把下一次任务执行的时间戳作为score加入到延时队列,用多个线程根据时间戳取出任务进行执行。此时要保证的是只会有一个线程能抢到消息。
在多个线程从zset延时队列中获取任务zrangeByScore的时候,是会存在多个线程同时获取到,但zrem只会有一个线程能做到,zrem成功的线程可以处理消息
我们可以看到,上面的情况zrangeByScore是会有较大浪费的,可以采用lua脚本将zrangeByScore和zrem作为一个原子操作
其他
HyperLogLog
pfadd、pfcount、pfmerge,可用于不需要很精确的计数,如网站的用户访问量,会去重
原理:各组随机数末尾0的个数的最大值K,与总随机数个数N存在 近似 2^k = N的关系
Bloom Filter
HyperLogLog只能解决去重计数问题,无法解决ismenbers问题,引入布隆过滤器
bf.add、bf.exsits
布隆过滤器当元素超出初始大小,准确率会下降,而且增加会比较可怕,可通过bf.reserve调整参数,设置错误率和估算数据量
分支主题
原理:通过hash排布判断元素是否出现过,bloomFilter判断没出现过,则肯定没出过。判断出现过,可能没出现过
GeoHash
附近的人 geoadd、geodist、geopos、geohash
georadiusbymenber key value number km count number asc列出附近的人
地图上的事物是会移动的,而我们把位置数据放在一个zset集合中,在redis集群,可能从一个节点到另一个节点,性能影响较大,建议每个key的数据量&lt;1M
不建议集群,建议单redis实例部署,可以对geo数据按照国家、省市区拆分
最佳实践
存储用户信息
1、生成一个用户id,用户信息序列化成json存为string,将string的key存入set
INCR id:users
SET user:{id} &apos;{&quot;name&quot;:&quot;Fred&quot;,&quot;age&quot;:25}&apos;
SADD users {id}
2、也可以在1的基础上,用户信息用hash存
INCR id:users
HMSET user:{id} name &quot;Fred&quot; age 25
SADD users {id}
拓展
Stream
工作机制
分支主题
Info指令
再谈分布式锁
前面说过:redis分布式锁可以通过一条指令set keyname value ex seconds nx 就可以实现
比如在sentinel集群中,主节点挂了,从节点取而代之,但主节点中分布式锁对象还没有同步到从节点,此时另一个线程就能拿到锁了,这是一个不安全问题
redlock算法:通过多个redis记录分布式锁信息,保证高可用
过期策略
设置了会过期的key处理方式
会过期的key会放入一个字典并定期扫描。扫描采用贪心算法,每次随机20个key,删过过期key,直到取出来的过期key小于1/4
很多key同时过期,造成缓存穿透,redis卡顿问题
key过期时间上再加上一个随机数,防止同时过期
从节点key的过期策略
从节点过期key依赖主节点的aof日志,所以主节点挂了会影响从节点的key过期
淘汰策略
当redis内存超出物理内存,会使用磁盘作为虚拟内存,速度极慢,可以通过maxmemory 来限制内存超出期望大小
Redis 提供了几种可选策略 (maxmemory-policy) 来让
用户自己决定该如何腾出新的空间以继续提供读写服务。
noeviction 不会继续服务写请求 (DEL 请求可以继续服务),读请求可以继续进行。这样
可以保证不会丢失数据,但是会让线上的业务不能持续进行。这是默认的淘汰策略。
LRU算法
近似LRU算法
懒惰删除(lazy free)
del指令会直接释放对象内存,大部分情况是很快的。但如果是一个非常大的对象删除,会导致线程卡段。redis4.0引入unlink指令,先断开引用,丢给后台线程异步处理。unlink key,如果key对象较小,会直接回收内存
清空数据库的flushdb、flushall指令,是极其缓慢的,在其后加上async进行异步处理,将整棵大树连根拔起,然后异步处理
异步删除的操作会包装成认为塞进异步队列
AOF sync一般需要每秒一次执行,也很慢,也是采用的异步队列
使用lazy free需要额外配置
分支主题
Jedis
redis的java客户端
保护redis
redis有很多危险指令,如keys、flushdb、flushall;可以通过在配置文件中修改指令名称,如rename-command keys abckeysabc 把keys指令重命名,避免误用。如果把命令重命名为空串,就无法调用了。
redis端口暴露,不要暴露给外网
SSL代理
spiped
源码
字符串
通过SDS(Simple Dynamic String)的字节数组存储
一开始分配的字节数组长度和数组长度相等 capacity==len
两种存储形式,len(str)&lt;=44,采用emb形式存储,否则使用raw形式存储
为什么44作为一个交界点
redis的对象信息都会有一个对象头
struct RedisObject {
int4 type; // 4bits
int4 encoding; // 4bits
int24 lru; // 24bits
int32 refcount; // 4bytes
void *ptr; // 8bytes,64-bit system
} robj;
64位系统占用16字节
其中 *ptr执行指向字符串的SDS对象,SDS对象的结构如下:
struct SDS {
int8 capacity; // 1byte
int8 len; // 1byte
int8 flags; // 1byte
byte[] content; // 内联数组,长度为 capacity
}
其中SDS存储其他信息的占去3bytes,总共19bytes
考虑到redis采用的内存分配器 jemalloc/tcmalloc 等分配内存大小的单位都是 2、4、8、16、32、64 等
等,为了存储一个字符串,至少申请32bytes字符串,32bytes太小了,这里就设定为64bytes。如果是64bytes,留给字符串内容的长度为64-19=45.还多了1个。我们要知道,redis字符串是以‘/0’结尾了,为了便于直接使用 glibc 的字符串处理函数
扩容策略
字符串长度&lt;1M,加倍扩容,&gt;1M,每次扩容1M
字典
压缩列表
跳表
原理
线程io模型
redis单线程为什么还这么快?非阻塞io、多路复用、指令队列、响应队列、定时任务
非阻塞io
事件轮询(多路复用,select系统调用)
通信协议
RESP (redis序列化协议)-一个简单易理解的文本协议
redis即使采用了浪费流量的文本协议,依然可以获得极高的访问性能,单节点跑满一个cpu核心能达到10w/s的qps
持久化(未雨绸缪)
rdb快照
aof日志
混合持久化(快照+增量aof)
即使使用混合持久化,也可能存在信息丢失,比如在保存aof时,突然宕机,日志没来得及刷到磁盘中,linux提供glibc下的fsync(int fd)可以将指定文件的内容强制从内核刷到磁盘,但是很慢。要控制频率。
通常主节点不会进行持久化操作,而是从节点进行
管道(Pipeline)
管道能省时的原因
管道压力测试redis-benchmark
事务
单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务的执行并不是原子性的。
事务可以理解为一个打包的批量执行脚本,但批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做指令的回滚,也不会造成后续的指令不做。
PubSub(基本不用)
可以实现消息多播
但是当redis宕机,pubsub的消息就丢失了,不会持久化,所以一般不太好用
新的中间件Disque还是beta版本,redis5新增了stream数据结构,消息队列可以持久化,所以pubsub以后一般不会用了
小对象压缩
1、redis内存资源非常宝贵,redis使用32bit编译可以使指针空间占用减半
2、zipList、intset都是redis紧凑的数据结构
3、redis对象压缩也不是一味地压缩,当元素个数,元素大小超过某个限制,会直接采用标准存储
分支主题
内存回收机制
redis无法立即将空闲内存立即归还操作系统,但可复用
flushdb命令会强制清理内存
内存分配算法
采用第三方内存分配库jemalloc
主从同步
概念
CAP(一致性Consistent、可用性Availability、分区容错性Partition tolerance)原理:
一句话概括,一致性和可用性无法同时满足
网络分区:就是不同机器之间断网了,网络隔离了
分布式的redis不满足“一致性”要求,但满足可用性要求,即使从节点挂了,主节点能正常提供服务,但会保证最终一致性,
同步方式
增量同步
主节点将修改性影响的指令加入到本地buffer,然后从节点从buffer中读取执行同时返回给主节点自己同步到哪里了(偏移量),buffer是一个环形数组,如果buffer满了,会采用快照同步
快照同步
首先进行一次bgsave将内存的数据快照到磁盘文件中,然后将磁盘文件传送给从节点,从节点清空内存所有数据,根据快照恢复内存。
注意点
在快照同步恢复的同时,增量同步还在继续,如果buffer又满了,就会快照同步死循环,所以buffer的大小要合理配置,避免这种死循环
增加从节点
从节点刚加入集群,首先进行快照同步,然后再增量同步
无盘复制
考虑到有盘复制是很重的io操作,甚至会使得AOF的fsync操作推迟。
无盘复制直接通过遍历内存,将内容通过套接字发给从节点,从节点保存到磁盘文件再一次性加载
wait指令
为了保证修改性指令能同步到从节点,通过wait指令是的修改性指令执行后立即同步给从节点,并设置timeout,如果timeout为0,且从节点挂了,会使得主节点也会不可用
思考
有人说 Redis 只适合用来做缓存,当数据库来用并不合适,你怎么看?
1、redis速度快
2、redis成本更高
3、存储小数据量,热点数据适合redis,大数据量,冷数据建议数据库
为什么 Redis 的事务不能支持回滚?
1、数据库的回滚是基于日志的,而redis的日志只有aof,aof能保证完整实时吗?
2、对回滚的支持会降低执行效率
找到网上这个解释比较到位:<br>单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务的执行并不是原子性的。<br>事务可以理解为一个打包的批量执行脚本,但批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做指令的回滚,也不会造成后续的指令不做。
redis是不是一定要主从复制保证其高可用?
不一定,如果你只是用于简单缓存就不需要,如果你用到了redis的持久化功能,就必须认真对待主从复制,才能保证数据高可用。
redis主节点挂了,主要有哪些操作?如果主节点又“复活”了,它还能重新作为主节点吗?
sentinel发现主节点挂了,会重新选举master。从节点和客户端会从sentinel集群获取最新的master地址,建立连接
主节点master复活会变为slave进入集群
sentinel 进行主从切换时,客户端如何知道地址变更了 ?(分为主节点挂了或哨兵主动切换)
1、客户端每次建立连接时,回会去查询主库地址
2、如果主节点变为从节点,对从节点执行修改性指令时,会抛出readonlyError,并会重新查找主库地址
分支主题
集群
sentinel(哨兵)
可以看成是一个zookeeper集群,当主节点挂掉会重新选举主节点。新节点加入也都是从sentinel集群获取信息
消息丢失
哨兵有两个可调参数,限制消息丢失
min-slaves-to-write 1 至少有一个从节点可以正常复制
min-slaves-max-lag 10 等待从节点10s,超过时间未回应认为从节点挂了,主节点停止工作
codis
1、sentinel实现的集群只有一个主节点对外服务,在高并发下压力会很大,所以出现了codis,是豌豆荚中间件团队开发,国内的开源项目
2、codis可以实现集群,作为多个redis实例代理,有codis面向客户端提供服务。一个redis指令打到codis服务,codis会根据key的hash计算出应该打到哪台redis实例上进行处理。这里打到哪台redis实例上是根据槽位分配的(codis的分片管理),一个redis实例对应几个槽位。
3、codis集群中需要同步槽位分配的一个情况,通过zookeeper记录槽位信息,当槽位分配发生变化的时候,会给codis发送信息进行同步
4、可以在线扩容和自动均衡,当一台redis实例压力很大时,可以加入新的redis节点,此时codis会重新分配槽位信息并对原redis实例中的key进行迁移,迁移过程中如果发现被迁移的key要被修改,那么会先迁移这个key然后执行修改。同时codis也会自动均衡。
但是codis和官方处于竞争的关系,很多更新比较之滞后
分支主题
redis cluster
redis官方的集群方案
通过16384个槽位进行分片
每个节点存储各自的槽位信息,而codis是通过分布式存储来保存槽位信息
客户端缓存了一份槽位信息,自己可以直接定位槽位信息,如果槽位信息与redis不一致,以致于发送了错误指令,服务器会返回-MOVED 3999 127.0.0.1:6381错误,叫客户端去127.0.0.1:6381节点的3999槽位获取,同时更新槽位缓存
槽位定位算法:和codis类似,crc32进行hash,然后取余。同时还可以强制某个槽位
槽位迁移:redis-trib工具可以手动调整槽位
1、首先对要迁移的槽位进行标记migrating和importing
2、源节点会一次性获取所有要迁移的key,然后一个一个key进行迁移
3、源节点将当前key dump得到序列化内容,然后想目标节点发送restore+序列信息 指令,目标节点接受指令(接收后源节点删除key),然后执行
客户端访问正在迁移的key:
首先slot信息没变,肯定是先先访问源节点,如果有key,则提供服务,如果没有(可能是已经迁移,也可能是没有key),原节点向客户端返回-ASK targetNodeAddr重定向指令,然后客户端向目标节点发送不带任何参数的 asking 指令(发送asking指令是为了此时目标节点认为槽位是在源节点的,会返回-MOVED指令,形成死循环),发送asking指令是要求目标节点将下一条指令强制作为自己的槽位进行处理
容错:参数 cluster-require-full-coverage
cluster-node-timeout (当超过timeout时,才认为节点故障)
cluster-slave-validity-factor 作为倍数可以放大cluster-node-timeout的超时时间
redis Cluster是去中心化的,在判断一个节点是否下线,需要大多数节点都认为该节点失联了
moved和asking都是重试指令,有一定区别
0 条评论
下一页