高频八股自用
涵盖java基础、Java集合、jvm、juc、mysql、redis、计网、os、kafka、微服务(分布式)、场景题、智力题、AI相关
Java基础
String了解吗?
有了解过,String是字符串常用类型,它的实现有这么几个关键点
首先是底层数据类型:在 JDK1.8 之前,底层使用的是 char 类型的数组;在 JDK1.9 以后,底层是 byte 类型的数组,另外增加了一个编码标识coder来取分编码,能更有效的利用空间。
然后是不可变性:String类不能被继承,因为 String 类被
final关键字修饰,而final修饰类的含义是 “该类不可被继承”,这样做的目的是杜绝通过子类重写方法破坏 String 的不可变性。最后是长度限制:String 的长度限制分 编译期 和 运行期 两种情况:如果是字符串常量,在编译期会存入字符串常量池,而常量池的限制为65535。而在运行期,通过拼接等方式生成的字符串限制就受类中length的大小限制,而length是int类型,所以长度最大为2^31-1,不过如果在实际开发中,通常用不到这么长的字符,而且还受到堆内存的限制,所以通常不会使用过长的字符串。
String为什么是不可变的
是不可变类,实现原理是String类里有final数组来存放字符串,本身类也是final不可被继承修改,而且提供的方法对字符串操作都不会修改源字符串,会新建一个返回。
string为什么设计成不可变的?
(三个方面来答:缓存、安全、不可变性)我觉得主要有三个方面吧
首先是缓存方面,jvm里实现了字符串常量池来存放,通过字符串池,两个内容相同的变量可以指向字符串池中同一个字符串对象,这样能节省内存资源提高性能。
然后是安全性,因为字符串通常会存密码、url等敏感信息,我们在程序传递中如果字符串不可变那我们就可以相信它的内容。如果可变那这个字符串随时可能被修改,系统安全性就得不到保障了。比如多个变量指向同一个密码,那我们必须保证字符串不可变,否则其中一个变量非法的修改了密码,这会影响其他变量从而出错
最后是线程安全方面,不可变字符串可以在并发性正常运行,并发读显然不会有问题,而并发写是新创建字符串也不会有问题,所以在多线程环境下非常安全。
字符串常量池是什么?
字符串常量池是堆内存中的一个区域,用来存储字符串。它实现了字符串的复用功能,保证多个变量使用相同的字符串时可以只生成一个对象,能有效节省内存提高性能。而在实际使用中我们为了能更好利用它这个特性,通常会使用intern方法,从而不会生成多余的字符串
你提到intern方法,能详细说说吗?
可以,intern方法主要为了能减少内存开销。 如果当前字符串内容存在于常量池则返回对象的引用,如果常量池没有,则将堆中该字符串对象复制放入常量池,并返回该引用。
String\StringBuilder\StringBuffer的区别
这三个都是字符串相关类,不过他们也有所不同,主要区别在三个方面:可变性、线程安全、性能。
- 首先是可变性,我们说String对象是不可变的,一旦创建了原本的内容就不能修改。而StringBuilder和StringBuffer是可变的。
- 然后是线程安全问题,我们说String和StringBuffer是线程安全的,因为String的不可变性所以没有并发安全问题,而StringBuffer是通过在方法上添加synchronized关键字来实现线程安全的,不过StringBuilder是没有解决这个问题的,所以只推荐在单线程环境下使用。
- 最后是性能问题,由于String是不可变的所以每次修改要创建新的对象所以性能开销最大,而StringBuffer由于是同步方法,为了实现同步会增加一定的性能开销,而StringBuilder没有实现并发安全,所以性能开销通常较小。
说说浅拷贝和深拷贝,以及如何实现深拷贝?
浅拷贝:是指只复制原始对象的基本数据类型的字段或引用,而不复制引用指向的对象,两个引用指向相同的对象
深拷贝:是复制原始对象的所有字段和引用指向的对象,而不仅仅是复制引用本身,还包括对象内部的对象。新引用拥有彼此独立的副本。
Java里实现深拷贝可以有三种方式
1、重写Cloneable的克隆方法,引用对象也重新克隆一块地址来存放2、使用序列化和反序列化,将对象序列化为字节流,前提是对象要实现serializable接口,再从字节流反序列化为对象来实现深拷贝。原理是序列化是会将原对象所有涉及的对象都转换为字节流,反序列化时根据信息会新建对象,所以不会共享同一个对象
3、手动递归复制,自己写方法实现,new一个对象把值填进去,对象结构简单可以这样写
Error和Exception的区别?
Error通常指错误,也就是系统级的严重问题,比如内存溢出、栈溢出,程序通常只能终止运行,不建议捕获处理。
Exception通常指异常,是程序可处理的,比如文件不存在、空指针异常等等,一般我们使用trycatch捕获异常或是throws向上抛
throw与throws有什么区别?
throw和throws是异常处理的关键字,但他们本质并不一样
throw是主动抛出一个异常对象,而且完成后方法立即终止,如果方法内调用了声明检查型的方法,必须用trycatch捕获或throws向上抛。(非检查型异常不用声明,如果出错了程序会直接崩溃掉。)
throws是在方法签名中声明可能抛出的异常,但通常用于上抛检查型异常,
说说java的异常体系
java里的异常分为两大类:检查型异常和非检查型异常(编译时异常和运行时异常)
检查型异常也叫编译时异常,通常是需要更改代码来处理的,而且是必须要处理的,通常包括文件不存在、类未找到、IO异常
非检查型异常也叫运行时异常,编写代码时不强制需要我们捕获,运行期间如果发送通常会中断程序的运行,一般也是由于代码原因导致的,常见的有空指针异常、数组越界等等。
finally代码一定会执行吗?
如果是正常运行的话finally的代码一定会执行,但如果碰到异常情况那也可能不会执行。
- 首先是try块里面调用了System.exit方法,会立即终止程序运行不走finally
- 还有就是try块里出现死锁或者死循环问题,程序无法跳出try块也就不会执行finally
- 最后就是硬件问题,机器断电或者jvm崩溃了那么finally也就无法执行
说说集合体系
集合主要有两条大的支线:
一条是Collection,由List\Set\Queue组成。
List代表有序可重复的集合,有封装了动态数组的ArrayList,封装了链表的LinkedList
Set代表无序不可重复的集合,主要有HashSet、TreeSet
Queue代表队列,典型的有双端队列ArrayDeque,优先级队列PriorityDeque
第二条线就是Map,表示键值对的集合,主要代表就是HashMapTreeMap\LinkedHashMap
ArrayList和LinkedList区别
1、ArrayList是基于数组实现的,LinkedList是基于链表实现的
2、ArrayList实现了RamdomAccess接口,支持随机访问,查找复杂度为O(1),适用于频繁访问读取的场景
而LinkedList不支持随机访问,因为他是双向链表,插入删除效率为O(1),使用于频繁的增删场景
3、ArrayList是空间占用少,使用的是连续的内存空间,而LinkedList包含了节点的引用,占用会更多。,一般而言ArrayList性能会更加高一些。
4、使用场景的话,读多写少用ArrayList,写多读少用LinkedList
ArrayList扩容机制?(为什么建议指定初始化容量?)
因为它底层是基于数组实现的,所以没添加元素时它还是个空数组,当添加第一个元素时,默认初始化容量为10.
当往ArrayList中添加元素时,如果超过当前容量的限制则会进行扩容(如果已经达到了Integer,MAX_VALUE则抛出异常)。扩容是通过一个grow方法,扩容后新数组的长度是原来的1.5倍,如果1.5倍不够,则直接扩容到当前所需的大小。最后再把原数组的值拷贝到新数组中。
不过我们建议初始化的时候直接设置容量值,这样可以减少扩容造成的开销。
为什么默认扩容为1.5倍?
首先ArrayList扩容是通过位运算的方式来实现的,而为什么是1.5倍,我认为应该是一种折中的考虑。
如果扩容倍数太小(比如 1.1 倍):扩容次数会非常多,频繁复制元素,性能差;
如果扩容倍数太大(比如 2 倍):内存浪费严重(10->20,只存 11 个元素,浪费 9 个位置);
1.5 倍是折中方案:既不会过于频繁扩容,也不会过度浪费内存,是兼顾两种的选择。
说说ArrayList怎么不安全?有哪几种实现ArrayList线程安全的方法?
在往ArrayList加元素时如果是多线程环境可能会出问题。
举个例子,线程a增加元素时发现有9个元素,容量为10则不扩容,而线程b接着此时进行判断得出同样的结论,然后两个线程就会往数组里添加元素,得出来size为11但是数组大小为10的结果,这就引发了线程安全问题
对于线程安全问题的解决方案有两种:
1、使用Collections的synchronizedList,会返回一个线程安全的集合,其内部是通过synchronized加锁来实现的,性能跟vector差不多,但实际上他只是单个方法保证同步,如果同时涉及多个方法的操作,需要我们在外部加锁来保证同步。所以这种方案现在基本不使用了
2、使用JUC的CopyOnWriteArrayList,使用写时复制技术,每当对列表进行修改时,都会加锁并创建一个新数组(保证只有一个线程写,使用reentrantlock加锁),这个新数组最后会替换旧的数组,而所有读取操作仍然在原有的数组上进行,这样并发读时无需加锁就实现了线程安全,适合读多写少的使用场景,但它的复制会比较占内存,且读到的数据可能不是实时最新的数据,因此不适合实时性要求很高的场景。
说说HashMap底层原理?
HashMap是将数据以键值对的形式存储的,是线程不安全的。(支持null键)
jdk7是使用数组+链表来实现的,Hash冲突时会使用拉链法将冲突元素放进一个链表中。
jdk8引入了红黑树,链表长度超过8且数组长度大于64会将链表转换为红黑树,具有更好的性能。
解决hash冲突的方法有哪些?HashMap用的哪个
1、线性探测法:如果发生冲突则顺序查看该下标的下一个位置,直到该下标未被使用
2、二次探测法:发生冲突则交替变化正负x的平方移动,x从1开始递增。
3、伪随机探测法:预先生成一个伪随机序列,根据序列的值来进行移动
4、最后还有链地址法,HashMap就是基于这种方法实现的,冲突的话会放在对应下标的链表上。这里冲突的判断方式是先用hashcode找桶位置,再用equals判断,如果都一样则认为key一样,更新value
为什么不直接用红黑树,而是先用链表再转为红黑树?
因为红黑树需要进行旋转染色保持平衡,而单链表不需要。当元素小于 8 个的时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于 8 个的时候, 红黑树搜索时间复杂度是 O(logn),而链表是 O(n),此时需要红黑树来加快查询速度
而如果一开始就用红黑树结构,元素太少,发生冲突概率较大会频繁旋转,这无疑是浪费性能的。
HashMap默认加载因子是多少?为什么是 0.75,不是 0.6 或者 0.8 ?
负载因子是0.75,当HashMap里元素的数量超过容量*负载因子时会发生扩容至原来的2倍。
负载因子如果太低,比如0.5则会浪费很多空间,如果是0.9则会发生太多冲突导致性能下降。
0.75 是 JDK 作者经过大量验证后得出的最优解,能够最大限度减少 rehash 的次数。
而且由于容量是2的幂,这样算出来的数恰好都为整数。虽然0.625,0.875也能整除,但折中考虑0.75更加恰当
HashMap 中 key 的存储索引是怎么计算的?
以jdk1.8为例,首先会取key的 hashCode 值、根据 hashcode 计算出hash值,这里采用高16位与低16位异或来算的、最后与数组长度-1进行&运算来得出位置。不过1.7的话hash值是通过多次移位hashcode来计算的,这里有些不太一样。
HashMap的put流程
先判断数组是否为空,为空则进行初始化。
然后扰动hashcode计算哈希值(高16位与低16位异或运算)
(n-1)&hash计算下标位置,构造Node节点放入
如果发生哈希冲突则判断是否为同一个key,如果key不同就要根据数据结构放入节点
如果是红黑树就构造树形节点插进去,链表的话就是Node节点插进去,这里看看是否需要转为红黑树,如果数组长度大于等于64且节点大于等于8就转换。
最后判断节点数是否大于阈值,大于则扩容为原数组的两倍。
HashMap的扩容机制说说?
jdk1.8中扩容会先生成新数组,其容量是原来的两倍,然后遍历旧哈希表元素
如果是链表的话,则重新计算下标放入新数组中,放置的结果等效于hash&(n-1),n为新的容量大小。这里的话其实本质上每次扩容都只需要看最高位即可(因为hash没变,n-1只是多了一位),是0则放原位,是1则加一个原本的数组长度即可快速定位
如果是红黑树的话,会遍历红黑树计算出新的下标位置。
如果发生哈希冲突,则构造链表或红黑树解决
如果该位置下元素超过8则生成新的红黑树放进去。如果没超过8则生成一个链表将元素放进去
最后将新数组赋值给HashMap的table属性
为什么扩容为原来的2倍?
首先扩容为2倍我们通过位运算来实现,计算相对比较快
而且2倍跟它的下标计算也是相关的,因为计算下标位置是hash&(n-1),n即数组长度都为2倍,扩容也为2倍的话能均匀利用空间,而且这样实际扩容时只用看最高为是0还是1,0则放原位,是1则加一个原本的数组长度即可快速定位
HashMap通常用什么做key?
一般用Integer、String 这种不可变类当 HashMap 当 key,而且 String 最为常用。
- 因为字符串是不可变的,所以在它创建的时候 hashcode 就被缓存了,不需要重新计算,大大提高运行效率。
- 同时获取对象的时候要用到 equals() 和 hashCode() 方法,(算下标用hashcode,冲突解决用equals) 这些类已经很规范的重写了 hashCode() 以及 equals() 方法,我们可以直接拿来就用。
HashMap是线程安全的吗?
不是线程安全的,多个线程同时读写时可能会出现并发修改问题。而且它的一些操作不是原子性的,在多线程下可能会出现竟态条件。
比如Jdk7里会出现死锁问题,因为多线程操作HashMap并触发扩容时,可能会形成环形链表,后续遍历链表则会发生死循环。
举个例子,线程1、2同时触发了扩容且都修改到数组的某一位置,此刻扩容前链表是A到B的,这时线程1先存储next和cur节点然后被挂起,接着线程2完成扩容,链表变成b到A,接着线程1继续扩容,将B存为a的下一节点,这时就出现了环形链表
jdk8虽然使用尾插法解决了死锁问题,但并发修改导致的数据异常依然没有解决,比如多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失
HashMap如何实现线程安全?
1、使用Collections下的synchronizedMap来创建,返回一个同步的Map包装器,所有的Map操作都是同步的。内部是通过 synchronized 对象锁来保证线程安全的
2、使用ConcurrentHashMap,1.7使用了分段锁机制(用到Reentrantlock锁),允许多个线程同时读,提高并发性能。1.8则使用了CAS和synchronized来保证线程安全,
3、自己使用显式的锁,比如ReentrantLock来保证线程安全
说一下ConcurrentHashMap的实现原理
ConcurrentHashMap是 Java 并发包中提供的线程安全哈希表实现,它通过 分段锁(Java 7) 和 CAS + synchronized(Java 8) 实现高效并发访问.jdk7中整个 Map 会被分为若干段,每个段都可以独立加锁,每个段维护一个HashEntry为元素的单向链表,其中put流程是先定位到具体的段,再通过 ReentrantLock 进行加锁操作
jdk8中的 ConcurrentHashMap 取消了分段锁,采用 CAS + synchronized 来实现更细粒度的桶锁,并且使用红黑树来优化链表以提高哈希冲突时的查询效率,性能比 JDK 7 有了很大的提升。
(补充,因为1.8桶锁粒度小,冲突的频率地低了所以使用synchronized不用reentrantlock,低竞争下synchronized接近无锁会性能更快)
为什么1.8采用cas+sychronized实现?
1、CAS它做的事情是初始化数组或者初始化头节点
synchronized保证线程安全时插入元素,动作更多;
2、CAS无锁就适合做短时间的任务,所以用来初始化
synchronized就适合做长时间、高竞争的任务,加锁后,其余线程会进入休眠,不会占用CPU。
这样实现了对 不同场景的精细化控制
ConcurrentHashMap的put流程
1.7:
会先自旋获取锁,获取锁后会尝试找到对应的key,没有则新建一个 HashEntry 并加入到 Segment 中(拉链法解决冲突),最后释放锁。
1.8:
CAS先初始化哈希数组,然后根据hash值计算对应下标,如果为空则使用cas插入,不为空则使用synchronized锁住然后进行插入(链表或者红黑树)。
ConcurrentHashMap的get方法怎么实现的,加锁了吗?
get 方法不需要加锁。因为 Node 的元素 val 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。
(注意与volatile修饰的数组没关系,这个只看节点,数组可不可见当然无关)
迭代器的底层原理
迭代器是一种遍历集合元素的模式,它屏蔽了不同集合底层的数据差异,提供了统一的遍历接口。
所有的集合都实现了Iterable接口负责返回迭代器,即Iterator实例,而我们可以利用Iterator接口来进行遍历的调用,因为集合内部实现了具体的遍历逻辑可以让我们使用
反射了解吗?好处是什么?使用场景?
Java 反射机制是在运行状态中,对于任意一个类,都能够知道这个类中的所有属性和方法,对于任意一个对象,都能够调用它的任意一个方法和属性。
我们可以利用反射机制动态的创建对象实例,调用方法,而且还可以访问和修改字段的值,它的这种动态绑定机制很利于我们的开发。
Spring 框架就大量使用了反射来动态加载和管理 Bean,java的动态代理机制也是利用了反射,其本质是利用Class对象获取方法区的元数据信息来使用。
不过反射也有一些问题,通常反射操作比直接调用慢,性能开销大,而且它可能破坏封装性,暴露私有逻辑
泛型了解吗?好处是什么?
泛型允许在定义类、接口或方法时使用类型参数,而声明的类型参数在使用时再用具体的类型来替换。
优点:泛型在编译时检查类型匹配。避免了运行时类型转换错误,而且允许我们为多种数据类型编写同一套逻辑,提高了代码的复用性。
类型擦除是什么?
类型擦除 是 Java 泛型实现的核心机制之一。它是编译器在编译阶段将泛型代码中的类型参数信息“擦除”,替换为原生类型(如
Object),从而在生成的字节码中不再保留泛型的类型信息。也就是泛型仅在编译阶段被检查是否类型匹配,运行时已不存在泛型的类型信息。这主要是为了泛型兼容,Java 5之前Java没有泛型支持,因此类型擦除使得泛型代码能够在不支持泛型的环境中运行
说说动态代理
动态代理是一种在程序运行时 动态生成代理对象的技术。主要有两种动态代理方式:JDK 动态代理、Cglib 动态代理
JDK 动态代理
JDK 动态代理在运行时动态为目标类所实现的接口,去生成一个实现类,并实现了接口的所有方法的增强代码。调用时会被代理类拦截,相当于调用到代理类的方法,方法内执行增强逻辑,再通过反射的方式调用目标类的目标方法
Cglib 动态代理
Cglib 动态代理在运行时动态生成目标类的一个子类,并且重写父类的所有方法来增强代码
它的底层是通过使用字节码处理框架ASM来转换字节码并生成新的类
调用时会被代理类拦截,相当于调用到代理类的方法,方法内执行增强逻辑,再直接调用父类对应的目标方法
不过他们都有局限性,jdk动态代理要求目标类必须要实现一个接口,而cglib要求目标类不能被final修饰,否则它无法继承。
jdk新特性了解吗?
了解过一些,比如jdk5,8,21
jdk5的话是引入了泛型,提高了代码复用性
jdk8引入了Lambda表达式,用来简化匿名内部类的语法,将函数作为方法参数传递。还有Stream流,能对集合数据进行流水线似的高效运行,在数据过滤场景下比较实用。
jdk21引入了虚拟线程,它跟普通线程不太一样,它更节省资源效率高(由jvm管理不需要切换为内核态),更像是一种临时工,举个例子,比如面馆开店要同时照顾一千个客人,如果是正常的线程,那就是雇佣1000个正式工,每个员工都有灶台来做菜端菜,成本开销较大;而虚拟线程则是我雇佣5个正式工来做菜,1000个临时工负责端菜下单,那这样成本就小了,效率也比较高。
什么是同步与异步?
说白了就是同步就是要等活干完再干后面的,异步就是你先干着我去忙后面的事
打个比方,同步就像打电话,必须对方接听,而异步就像发短信,我发了就完成了,你什么时候回复我以后再看。
(同步就是任务有依赖关系,而异步没有)
什么是阻塞与非阻塞? :是否占用cpu
阻塞就是等待任务完成不能干别的,而非阻塞就是等待的时候可以干别的。核心区别就是是否占用CPU。它跟同步与异步不太一样,而且没有必然关系,同步与异步关键在于能不能先干后面的事,而阻塞与非阻塞关键在于能不能先干其他事(既可以是后续的也可以是其他的)。
BIO、NIO、AIO的区别
实际这三个是io的三种方式。
BIO:采用同步阻塞式 I/O 模型,线程在执行 I/O 操作时被阻塞,无法处理其他任务,举个例子,去菜市场买菜,我选好很多菜等店家一个个称完算钱才结账,期间我站着不动等他算账。IO 的效率很低,适用于连接数较少的场景。(啥都不能干)
NIO:采用同步非阻塞 I/O 模型,线程在等待 I/O 时可执行其他任务,通过 Selector 监控多个 Channel 上的事件,举个例子,去菜市场买菜,我选好很多菜,然后店家算账的时候我去隔壁买水果,选完水果再回来付菜钱,这适用于连接数多但连接时间短的场景。(不能干后面的但可以干其他的)
AIO:使用异步非阻塞I/O 模型,线程发起 I/O 请求后立即返回,当 I/O 操作完成时通过回调函数通知线程,举个例子,我叫送水师傅上门,期间我该干啥干啥,师傅送到水后按门铃我再去接。适用于连接数多且连接时间长的场景。(即能干后面的也能干其他的)
JVM
Jvm的组织架构
首先是类加载器,负责读入二进制文件,并将数据读入到内存中。
然后是运行时数据区,按照虚拟机规范来划分区域便于管理。有方法区、堆、虚拟机栈,本地方法栈,程序计数器。
最后是执行引擎负责执行字节码,垃圾回收等(以及JNI与本地库进行交互、热点代码复用)
说一下Jvm的内存区域(运行时数据区结构说一下)
内存区域分为方法区、堆、虚拟机栈、本地方法栈、程序计数器
其中方法区和堆是线程共享的,而虚拟机栈,本地方法栈,程序计数器是线程私有
程序计数器放的是Jvm的指令地址
虚拟机栈存储线程执行方法的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息
而当一个 Java 程序调用一个 native 方法时,JVM 会切换到本地方法栈来执行这个方法,Java经常通过本地方法来调用操作系统底层功能
堆是JVM内存中最大的一块区域,主要存储对象,堆也是垃圾收集器的主要管理目标
方法区用于存储已被 JVM 加载的类信息、常量、静态变量等。在 HotSpot 虚拟机中,方法区的实现称为永久代 但在 Java 8 及之后的版本中,已经被元空间所替代。
堆和栈的主要区别
(线程、大小、消亡、功能)
堆属于线程共享的内存区域,几乎所有 new 出来的对象都会堆上分配,生命周期不由单个方法调用所决定,直到不再被任何变量引用被垃圾收集器回收。堆的空间一般较大
栈属于线程私有的内存区域,主要存储局部变量、方法参数、对象引用等,通常随着方法调用的结束而自动释放。栈的空间一般较小,而且存取速度也比堆快
默认情况下,Java 对象是在堆中分配的,但 JVM 会判断对象的生命周期是否只在方法内部,如果是的话,这个对象可以在栈上分配。
为什么要取分堆和栈?
因为他们的存储内容不同,可以分开管理各司其职。堆内存可以用垃圾回收器管理,栈内存可以靠编译器和虚拟机执行完成。可以做到更好的隔离,提升整体效率。
创建对象一定在堆里吗?
不一定。没逃逸的对象,JVM 会通过 “栈上分配” 放栈里。逃逸分析是JVM 判断对象会不会 “逃出” 当前方法被别人用;栈上分配是没逃逸的对象,直接放栈里,不用放堆里。
举个例子,比如我自己一个人吃苹果,那我就把削苹果的刀放在我的柜子里,如果大家都想吃苹果,那我就把刀放在客厅里让大家一块用。
内存分配会出现竞争吗?(TLAB是什么,说一下)
jvm采用TLAB来解决多线程竞争堆内存的分配问题
TLAB就是线程本地分配缓冲区,线程分配对象时直接在TLAB分配,Tlab本身也在新生代区里,如果TLAB不够了再放到新生代区
说说类加载过程(介绍一下类的生命周期)
加载:通过类的全限定名获取到该类的二进制字节流,将其转化为方法区运行时的数据结构,在内存中生成一个代表该类的Class对象,作为方法区这个类的各种数据的访问入口
连接:验证、准备、解析 3 个阶段统称为连接。
验证:确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。
准备:为类中的静态字段分配内存,并设置默认的初始值,比如int类型初始值是0(若为final static则直接给=右边的值)
解析:解析阶段是虚拟机将常量池的「符号引用」直接替换为「直接引用」的过程。(符号引用是以一组符号来描述所引用的目标。直接引用可以是直接指向目标的指针如果有了直接引用, 那引用的目标必定已经存在在内存中了。)
初始化:执行静态代码块和静态变量初始化。
(类的生命周期还包括使用和卸载:
使用:可以通过元数据和Class创建对象,完成一系列操作
卸载:如果对象都回收、class也不用、类加载器也被回收,那就回收元数据和class对象)
类加载器知道吗?
类加载器主要有四种:
启动类加载器,负责加载 JVM 的核心类库
扩展类加载器,负责加载java扩展目录下的jar包
应用程序类加载器,负责加载 classpath 的类库
我们编写的任何类都是由应用程序类加载器加载的,除非显式使用自定义类加载器。
用户自定义类加载器:开发者可以根据需求定制类的加载方式
什么是双亲委派模型,有什么好处
双亲委派模型要求类加载器在加载类时,先委托父加载器尝试加载,只有父加载器无法加载时,子加载器才会加载。若所有加载器都无法加载这个类,最终抛出 ClassNotFoundException。
好处:
避免类的重复加载:父加载器加载的类,子加载器无需重复加载。
保证核心类库的安全性:如
java.lang只能由启动类加载器加载,防止被篡改。
如何打破双亲委派模型?
1、只需要重写ClassLoader的loadClass方法即可(Tomcat打破方式)
2、SPI加载,(如jdbc驱动)
spi是 Java 提供的一种服务发现机制,用于加载和注册第三方类库,常见于 JDBC 等框架。
SPI接口是由启动类加载器完成加载的,而SPI实现类则是App类加载器进行加载的。但往往在SPI接口中会经常调用实现者的代码,所以一般会需要先去加载自己的实现类,但就是父类加载器是不能将类加载请求委派给自己的子类加载器进行加载的,所以此时就要打破双亲委派模型。所以spi机制会为接口找具体实现,会在jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入,这里实际上是调用了线程上下文类加载器实现的,而线程上下文类加载器默认是
App系统类加载器,所以最终还是调用了App系统类加载器。3、热部署框架,不同的框架打破双亲委派的过程不太一样,比如SpringBootDevTools,他会
先监测文件变化:当文件修改时触发更新。
然后销毁旧的类加载器其加载的所有类会被 JVM 回收(方法区中旧类的字节码失效)。
最后创建新的类加载器加载修改后的
.class文件,Spring 容器会重新初始化该类的 Bean
tomcat的类加载了解吗?(也是打破双亲的一种实现)
基于jvm类加载器做了扩展,最关键的就是WebAppClassLoader,它通过重写
具体流程为loadClass方法,打破双亲委派,实现 “应用内类优先加载”。
先判断若为jdk核心类,直接委托给父类加载器
检查自身是否已加载:若当前类加载器已加载过该类,直接返回。
优先加载应用内的类:尝试加载
WEB-INF目录下的类若应用内未找到,委托给父类加载器
如何判断对象仍然存活?(根可达性算法了解吗?)
第一种是引用计数法,每个对象有一个引用计数器,记录引用它的次数。当计数器为零时,对象可以被回收。
而jvm使用可达性分析算法来判断,通过一组GCroots根对象沿着引用链进行查找,能找到的对象即不可回收,不在GC Root 引用链上的对象即可视为垃圾。而在垃圾回收前,jvm会暂停所有线程(STW)。
GCroots包括虚拟机栈和本地方法栈中的引用,类静态变量、运行常量池的常量以及生成的实例对象
垃圾回收你了解吗?
我了解一些,主要包括三个部分:垃圾的判断(可达性)、垃圾回收算法(4种)、垃圾回收器(分代、分区多种)
垃圾收集算法说一说
1、标记清除算法:标记要回收的对象来清楚它们,但易产生内存碎片
2、标记复制算法:将内存空间划为两块,当一块用完了就将活着的对象复制到另一块上面,但这样浪费了一半的空间
3、标记整理算法:将存活的对象向内存的一端移动,再清理边界以外的内存,缺点是移动的成本高
4、目前主流的垃圾收集算法就是分代收集算法,通常将堆分为新生代和老年代,新生代用标记复制算法,老年代通常用标记整理算法。这样根据生命周期优化垃圾回收
知道哪些垃圾收集器?
JVM 的垃圾收集器主要分为两大类:分代收集器和分区收集器,分代收集器的代表是 CMS,分区收集器的代表是 G1 和 ZGC。
1、CMS 是一种低延迟的垃圾收集器,采用标记-清除算法,分为初始标记、并发标记、重新标记和并发清除四个阶段,优点是垃圾回收线程和应用线程同时运行,停顿时间短,但容易产生内存碎片,可能触发 Full GC。
2、G1 是一种面向大内存、高吞吐场景的垃圾收集器,它将堆划分为多个小的 Region,通过标记-整理算法,避免了内存碎片问题,现在时新版jdk的默认垃圾收集器
3、ZGC是 JDK 11 时引入的一款低延迟的垃圾收集器,它通过并发标记和重定位来避免大部分 Stop-The-World 停顿,主要依赖指针染色来管理对象状态(较新,低版本jdk不支持)
此外还有serial\serial old:它是单线程的垃圾收集器,在执行GC时并不会出现线程间的切换。因此,在单颗CPU的机器上,它的清理效率非常高(标记复制、标记整理)
parnew:是多线程新生代垃圾收集器,可以控制垃圾回收的线程数,通常与CMS配套使用(标记复制)
parallel \parallelold :主要关注垃圾的吞吐量,支持多线程GC,可通过XX:+UseAdaptiveSizePolicy参数,让JVM启动自适应的GC调节策略,从而达成较好的吞吐量(标记复制、标记整理)
说一下CMS的垃圾回收过程
CMS 使用标记-清除算法进行垃圾收集,这里采用了三色标记法,分 4 大步:
- 初始标记:标记所有从 GC Roots 直接可达的对象,这个阶段需要 STW,但速度很快。只会将GCroots直接接触的标记为灰色
- 并发标记:从初始标记的对象出发,遍历所有对象,标记所有可达的对象。这个阶段是并发进行的。将所有灰色标记可达对象标记为灰色,原本的灰色对象标为黑色
- 重新标记:完成剩余的标记工作,包括处理并发阶段遗留下来的少量变动,这个阶段通常需要短暂的 STW 停顿。将灰色对象标记为黑色,至此所有存活对象都应是黑色的。
- 并发清除:清除未被标记的对象,即为白色对象,回收它们占用的内存空间。不过这一阶段用户线程可能继续产生浮动垃圾,如果浮动垃圾过多可能导致fullgc(退化为serial old)
说一下G1垃圾收集器的原理
G1在 JDK 9 时取代 CMS 成为默认的垃圾收集器,把 Java 堆划分为多个大小相等的独立区域Region,而且还多了大对象区。
具体过程是这样的:(也用的三色标记法)
初始标记:短暂STW,,标记从 GC Roots 可直接引用的对象
并发标记:G1 通过并发标记的方式找出堆中的垃圾对象。并发标记阶段与应用线程同时执行,不会导致应用线程暂停。
最终标记: 短暂停顿(STW),处理并发标记阶段结束后残留的少量未处理的引用变更。
筛选回收:根据标记结果,选择回收价值高的区域,复制存活对象到新区域,回收旧区域内存。这一阶段包含一个或多个停顿(STW),具体取决于回收的复杂度。
除此之外,G1 在停顿时间上添加了预测机制,用户可以 JVM 启动时指定期望停顿时间,G1 会尽可能地在这个时间内完成垃圾回收
CMS和G1有什么区别
1、CMS通常需要配一个新生代垃圾收集器如parnew,而G1不需要
2、CMS 停顿时间较短,适用于延迟敏感的环境。G1 则提供了停顿时间预测和内存压缩能力,适用于大内存和多核处理器环境。
3、CMS使用标记清除算法容易产生内存碎片,而G1使用标记整理和分区,能有效管理空间
4、他们的回收过程不一样,CMS主要来回收老年代对象,而G1则通过多个STW停顿来回收垃圾最多的高价值区域。
垃圾收集器怎么选
如果内存小无要求就使用serial收集器
如果对吞吐量要求高就考虑用parallel收集器
如果对响应时间和STW时间有要求的话考虑用CMS或G1收集器
如果硬件条件好,对响应时间要求非常严苛的话用zgc收集器。
三色标记法是什么?有什么作用和好处?
三色标记法就是找垃圾对象的方法,通常先将所有对象都标记成白色,然后把gcroots根对象标记成灰色,再把灰色对象关联的对象标记成灰色,自己改为黑色。最后将所有的白色对象删掉。
好处是高效遍历对象图,同时支持与用户线程并发执行,减少垃圾回收的停顿时间
如何解决漏标、对象消失问题?
漏标问题它的原因在于黑色对象新增了白色对象的引用,但黑色对象不再遍历,不会找到该白色对象,而白色对象却不被任何灰色对象引用,导致白色对象被错误回收。
针对这个问题,不同垃圾回收器的解决方案不一样,cms采用增量更新来解决,将这个新的引用关系记录下来;而G1采用原始快照解决,当删除灰色对象到白色对象的引用时把这个关系记录下来。
总的来说,他们是根据问题出现的两个原因来采用不同的解决方案。
浮动垃圾和多标问题呢?
浮动垃圾是指在清理的时候又产生了新的垃圾,其实一般而言这个影响不大,因为下一次垃圾回收会将他们给收掉。
而多标问题就是没用的当成了有用的,不需要特别解决,在下一次GC中该浮动垃圾就会被回收掉。
总的来说,他们都会在下一次垃圾回收中处理掉。
Java是编译型还是解释型语言?
Java是编译与解释并存的语言。
因为Java先会将原代码进行编译生成字节码文件,这里体现他的编译性了。
而JVM里有解释器,会逐行解释字节码生成机器码到平台运行,这里体现了他的解释性。
当然这里对于热点代码会使用即时编译器来优化执行
了解JIT编译器吗?工作原理是什么?
JIT编译器是一种用于JVM的动态编译技术。它在 Java 程序运行时,将 Java 字节码转换为本地机器代码,从而提高程序的运行效率。
当 JVM 发现某些代码被多次执行时,JIT 编译器会将这些热点代码编译为机器码,并将其缓存。之后运行时直接执行这些本地代码,而不再解释,从而显著提高性能。
有什么jvm的调优经验?
这个虽然我没有实际调优过,但我了解过一些调优的实例。
首先如果使用默认的JVM参数配置,在大多数情况应该是不需要调优的。因为JVM默认参数是通过官方JVM团队长期试验的一个结果。
但还有少数场景需要调优,比如我之前看到的一个解决方案。
服务环境:ParNew + CMS + JDK8
问题现象:C端核心业务每个接口的请求耗时一般有限制在例如100ms以内,C端核心业务在高峰期服务器发生 FullGC,导致部分请求超时报错,影响用户体验
原因分析:针对老年代区域CMS使用标记清除算法,意味着老年代区域随着应用的运行会变得碎片化;碎片过多会影响对象的分配,长期如此,最终会导致FullGC的发生。
优化策略:业务低峰期(例如凌晨四点)显式触发FullGC System.gc(),CMS触发FullGC就会退化为单线程的SerialOld垃圾回收器,它会采用标记整理算法进行回收,可以整理堆内存优化掉内存碎片的情况,降低业务高峰期发生FullGC的概率。
总结复盘:结合业务有高峰期和低峰期,所以在低峰期把内存碎片整理好,尽量保证高峰期的正常运行。
JUC
线程的六种状态(生命周期)
new:线程创建但未启动,已经分配了资源
runnable: 线程已启动处于就绪或正在运行状态,可能正在运行也可能等待获取CPU的时间片(调用start方法)
blocked:线程获取锁失败被阻塞、(竞争锁失败)
waiting:无休止等待,需要其他线程显示唤醒 (调用wait方法)
TIMED_waiting:有期限等待,然后自动返回可运行状态(调用sleep方法)
terminated:终止 ,生命周期结束,不再被重新启动,可调用Interrupt方法判断进行强制终止
sleep和wait的区别
1、sleep属于Thread类的静态方法,而wait是Object类的实例方法
2、sleep不会释放锁,而wait会释放锁
3、sleep无需事先获取锁,而wait必须先获取锁
4、sleep会进入timewaiting,结束后线程自动进入就绪状态,wait会进入waiting需要其它线程调用notify等方法来唤醒它
创建线程的方式
1、写一个类,该类继承Thread重写父类的run方法,缺点是需要继承,这样这个类不能继承其他类了
2、写一个类实现Runnable接口,然后重写该接口的run方法,将该对象作为参数传入Thread类生成Thread类对象。这种方法最常用,缺点是没有返回值
3、写一个类实现Callable接口并重写call方法,再将该对象通过参数生成一个FutureTask<>对象,然后将FutureTask对象通过参数生成Thread对象。这种方法有返回值
4、使用线程池创建,这个也比较常用,但是增加了程序的复杂度,故障排查时可能比较麻烦。
什么是线程池?工作流程说说?
线程池是用来管理线程的工具,它可以减少线程的创建和销毁开销
Java中使用ThreadPoolExecutor来使用线程池。
工作流程为:创建线程池,提交任务,如果核心线程满了放入等待队列,等待队列满了启用新线程直至达到最大线程数。启用的非核心线程不会立即销毁,而是会等待直到超时才销毁。关闭线程池用shutdown
为什么用线程池?
- 降低资源消耗。 通过重复利用已创建的线程降低消耗。
- 提高响应速度。 当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
执行execute()方法和submit()方法的区别是什么呢?
execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;- submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值.
线程池的主要参数?
corePoolSize:核心线程数,长期存活,执行任务的主力。
maximumPoolSize:线程池允许的最大线程数。
workQueue:任务队列,存储等待执行的任务。
handler:拒绝策略,任务超载时的处理方式。也就是线程数达到 maximumPoolSiz,任务队列也满了的时候,就会触发拒绝策略。
threadFactory:线程工厂,用于创建线程,可自定义线程名。
keepAliveTime:非核心线程的存活时间,空闲时间超过该值就销毁。
unit:keepAliveTime 参数的时间单位:
线程池的拒绝策略?
主要有四种,
默认的是会直接丢弃任务并抛出异常,还有一种跟默认策略很像,是丢弃任务不抛异常。第三种会抛弃最老的任务,将新任务加入队尾。不过这三种策略都会抛弃任务,还有第四种不抛弃任务的拒绝策略,就是让调用线程执行任务,如果失败则抛异常。
线程池参数设置有经验吗?
了解过核心线程数(corePoolSize)设置的经验:
CPU密集型:corePoolSize = CPU核数 + 1,尽量减少上下文切换,优化CPU使用率
IO密集型:corePoolSize = CPU核数 x 2 ,由于线程长时间等待,可以设置更多的线程来提高并发。
不过这些公式只是推荐值,不建议生搬硬套,应该根据实际情况调整。
或者采用动态线程池,把几个核心参数暴露出来,将线程池的参数从代码中迁移到分布式配置中心上,实现线程池参数可动态配置和即时生效,根据线上线程池的监控,需要时去调整核心参数
你会关注线程池的哪些指标?
1、会观察核心线程数和活跃线程数状态,如果活跃线程数长期逼近核心线程数说明线程池负载高,可能需扩容。
2、观察任务完成速率,判断是否存在任务堆积风险
3、观察线程池所在节点的CPU占用情况,观察cpu使用率是否过高但任务完成速度低的情况,可能需要调整参数。
有几种常见的线程池?
固定大小的线程池
FixedThreadPool(int nThreads);适合用于任务数量确定,且对线程数有明确要求的场景。例如,IO 密集型任务、数据库连接池等。缓存线程池
CachedThreadPool();,适用于短时间内任务量波动较大的场景,因为它的最大线程数量没有限制。例如,短时间内有大量的文件处理任务或网络请求。不过这里需要控制任务的数量,否则很有可能会发生OOM。定时任务线程池
ScheduledThreadPool(int corePoolSize);适用于需要定时执行任务的场景,因为他会周期性的执行任务。例如定时发送邮件、定时备份数据等。单线程线程池
SingleThreadExecutor();适用于需要按顺序执行任务的场景,因为它只会使用一个线程来执行任务。例如日志记录、文件处理等。
为什么不推荐使用jdk的线程池?
因为它们设置的默认参数通常不满足我们的需求,比如缓存线程池的最大线程数为MAX_VALUE,可能会创建大量的线程导致OOM
常见的阻塞队列说说?
主要常用的有3种
首先是LinkedBlockedQueue:它是单线程线程池和固定大小线程池的阻塞队列,因为它们的阻塞队列容量尾MAX_VALUE,所以需要 LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务。
然后是SynchronousQueue,对应的线程池是 CachedThreadPool。其实它本质只是线程交接站,因为缓存线程池无线程上限,所以来一个任务就要分配线程执行
还有就是DelayedWorkQueue,它对应的线程池是 ScheduledThreadPool,内部会按照延迟的时间长短对任务进行排序,从而进行任务的执行。
源码里怎么实现线程复用的?
源码中ThreadPoolExecutor中有个内置对象Worker,每个worker都是一个线程,每个worker会while死循环从阻塞队列中取数据,通过置换worker中Runnable对象,运行其run方法起到线程置换的效果,从而实现复用提高程序运行性能。
线程池的几种状态说说?
RUNNING 状态的线程池可以接收新任务,并处理阻塞队列中的任务;
SHUTDOWN 状态的线程池不会接收新任务,但会处理阻塞队列中的任务;
STOP 状态的线程池不会接收新任务,也不会处理阻塞队列中的任务,并且会尝试中断正在执行的任务;
TIDYING 状态表示所有任务已经终止;
TERMINATED 状态表示线程池完全关闭,所有线程销毁。
多线程安全的理解?
线程安全问题产生的根本原因:多条线程同时对一个共享资源进行非原子性操作时会诱发线程安全问题
为了实现线程安全我们可以从它的三要素来考虑:
首先是原子性,就是说一个操作要么完全执行要么完全失败,不会出现中间状态。可以通过原子操作或synchronized关键字来实现
其次是可见性,指的是当一个线程修改了共享变量,其他线程能立即看到。可以用volatile来保证可见性。
最后是有序性,指代码执行顺序是否可能被编译器和 CPU 重排序。
并发和并行
并发是指单核cpu上,多个任务交替执行,通过时间片轮转的方式实现,是逻辑上的同时运行。
而并行是多核cpu上,多个任务在同一刻同时运行,是真正在物理上做到了同时运行
举个例子,比如说我们排队去接咖啡,
并行就是有两个咖啡机同时排两个队,保证两个队都在接咖啡。
而并发是只有一个咖啡机但还是排了两个队,不过我们让其中一个队先接一个人另一个队再接,通过交替接咖啡来减少两个队伍的人数,让人感觉上是有两个咖啡机。
什么是乐观锁和悲观锁?
乐观锁:认为并发冲突的概率较低,在提交时检查数据是否被修改,若未被修改则提交,否则重试或抛出异常。
- 例如 版本号法、CAS(compare and swap),即乐观锁实现
- 应用场景:适合读多写少(并发冲突的概率低),可以降低锁定带来的性能开销、并发性能高
悲观锁:持悲观态度,认为并发冲突的概率高,每次操作数据时都加锁,确保线程安全。
- 例如 synchronized,即悲观锁实现
- 应用场景:适合写多读少(并发冲突的概率高),避免频繁冲突导致的多次重试,并发性能低(串行化操作)
说一下Java内存模型
Java 内存模型是一个抽象模型,用来保证多线程环境中共享变量的可见性、有序性、原子性。具体实现比如volatile
具体来说是共享变量存储在
主内存中,每个线程都有一个私有的本地内存,存储了共享变量的副本。当一个线程更改了本地内存中共享变量的副本,它需要 刷新到主内存中;当一个线程需要读取共享变量时,它会从主内存中刷新到本地内存读取。(另外 JMM 提供了一系列和并发处理相关的关键字,如 volatile、synchronized,这些就是Java内存模型封装了底层的实现后提供给我们使用的关键字。)
了解volatile吗
volatile主要有两个作用,保证变量的可见性,和防止指令重排也就是有序性,但是它不能保证原子性.
可见性是指这个变量被声明为volatile时,它会保证对这个变量的写操作会立即刷新到主存中,而对这个变量的读操作会直接从主存中读取,从而确保了多线程环境下对该变量访问的可见性。(底层通常使用lock 指令+MESI协议来实现)
指令重排主要包括编译器指令重排和cpu指令重排,编译器指令重排是在不改变单线程语义的情况下修改的(实现是jvm规范约束的),而cpu指令重排会根据流水线以及多线程调度来重排指令。
原理还是内存屏障,编译器通过jvm规范禁止重排并插入内存屏障,从而约束cpu指令重排(不过具体内存屏障实现交由不同jvm来实现,jmm只进行规定,只要能做到它的规定就行)其原理为:
写前后都加屏障,读的话后面加两个屏障
写 Volatile 变量时:JVM 在写前加个StoreStore屏障,保证前面的写与volatile写不重排,还会在后面加个StoreLoad屏障,防止volatile写与后续读写重排,还能保证把变量的新值刷到主内存,确保其他线程能看到。
读 Volatile 变量时:JVM 在读之后加LoadLoad屏障,保证后续读不在volatile读之前,还会在后面加一个LoadStore屏障,保证后面的写不会在volatile读之前,并且会强制从主内存中读最新的数据。
但是volatile无法保证变量的非原子性操作不被别的线程打断,所以保证不了原子性,从而会有线程安全问题。
就比如i++操作,这个操做可以拆为3步,读改写回,多线程环境下他们指令的顺序是不确定的,都读的是最新值,写完也能保证背别人看见,但是却覆盖了刚改的值,导致结果出现中间状态,不满足完全成功或失败,所以还会有并发安全问题。
CAS了解吗?
CAS 是一种乐观锁,它的实现需要三个参数,变量、预期值、新值。
先从内存中读取变量当前值与预期值比较,如果相等则修改,不等则自旋重试(或者有的直接失败)。
AQS和原子类中就使用了cas来实现线程安全。 (应用)
它的底层汇编用CMPXCHG实现的,所以天生有原子性。(原理)
cas优点是无锁就实现了并发安全,缺点是如下三个问题
CAS有什么问题?
它主要有三个问题:
1、ABA问题:如果变量由A改成了B再改成了A则会认为没有发生变化。
解决方案是使用版本号和时间戳
2、自旋开销大:CAS 失败时会不断自旋重试,如果一直不成功,会给 CPU 带来非常大的执行开销。
解决方案是加上一个次数的限制,超过了则挂起线程
3、只能保证一个变量的原子操作,涉及到多个变量就不行了
解决方案是将变量包装成一个对象使用 AtomicReference 进行 CAS 更新。
说说ReentrantLock实现原理
ReentrantLock是基于 AQS 实现的可重入排他锁,使用 CAS 尝试获取锁,失败的话会进入 CLH 阻塞队列,支持公平锁/非公平锁(实现了AQS的两个内部类),可以中断、超时等待,比synchronized更灵活。
底层通过计数器state来跟踪锁的状态,如果线程尝试获取锁时state=0,则会直接加锁,state置1,如果多次获取该锁,则state不断++。后来的线程发现state不为0则会加入等待队列中。
这里有非公平锁和公平锁两种情况,默认是非公平锁(通常非公平锁性能好一些,减少了上下文切换):
获取锁失败进入同步队列等待,等轮到了再唤醒用cas抢锁,state+1
修改state-1,唤醒后续节点的线程
非公平锁:当一个线程执行ReetrantLock.lock()方法获取锁失败时会tryacquire再尝试一次,如果还失败该线程会被封装成Node节点通过不断cas的方式加入同步队列等待锁资源的释放。当该线程所在节点的前驱节点为队列头结点时,当前线程就会开始尝试对同步状态标识state进行修改(+1),如果可以修改成功则代表获取锁资源成功,然后将自己所在的节点设置为队头head节点,表示自己已经持有锁资源。
那么当一个线程调用ReetrantLock.unlock()释放锁时,最终会调用Sync内部类中的tryRelease(int releases)方法再次对同步状态标识state进行修改(-1),成功之后唤醒当前线程所在节点的后继节点中的线程。
说说synchronized关键字
synchronized修饰普通方法时上锁的是该对象,修饰静态方法时上锁的时类的Class对象,修饰代码块时给括号里的对象上锁(this,Class,其它对象)本质上都是对象锁
synchronized 加锁代码块时 会依赖JVM 内部的 Monitor 对象来实现线程同步,JVM 会通过moniterenter、moniterexit两个指令来实现同步(在操作系统层面会从用户态切换到内核态,使用Mutex互斥量,所以性能开销大), 依赖对象头的 Mark Word 进行状态管理,支持无锁、偏向锁、轻量级锁,以及重量级锁。
synchronized如何实现可重入性(底层实现原理)
synchronized 之所以支持可重入,这里实现的底层其实是监视器,Moniter里面有count计数器,还有Owner线程(以及等待队列和阻塞队列,等待队列是调用wait后进入,阻塞队列则是所有尝试获取锁失败的线程),可重入就是通过Owner线程和计数器实现的,线程内多次获取同一个锁则计数器加1(如果不是重量级锁没有moniter,那就在markword里记录重入
synhronized锁升级了解吗?
锁升级其实是通过改变对象头里的MarkWord标志位来实现的,一共有四种锁状态
无锁:就是没有锁,new一个对象会先进入无锁状态,不过这里java做了优化,会延迟4秒进入匿名偏向锁的状态,也就是无线程ID的偏向锁,这样能减少获取锁时的性能开销。
偏向锁:当线程第一次获取锁时,会进入偏向模式。Mark Word 会记录线程 ID。下次进入 synchronized 时,如果还是同一个线程,可以直接执行,无需额外加锁。如果有线程竞争获取锁失败则会升级为轻量级锁。
轻量级锁:此时多个线程通过CAS去抢锁,如果失败达到阈值(默认10次)说明前面的线程没有释放锁或者竞争比较激烈,这时就升级为重量级锁 。
重量级锁:在这种情况下,线程会自旋(10次默认)去抢锁,如果失败则进入阻塞队列。(JVM 会在操作系统层面创建一个互斥锁mutex)(注意,只要调用wait方法那么锁都会变为重量级锁,因为wait依赖于moniter)
synchronized和reentrantlock的区别了解吗?
二者都是可重入锁和悲观锁
1、 Synchronized是由JVM内部的monitor机制和markword实现的,可以自动加锁和解锁
而reentrantlock是基于AQS实现的,需要手动加锁和解锁。
2、如果再高并发场景下倾向于使用reentrantlock,因为它支持Condition能提供更细粒度的锁控制,并且同时支持公平锁和非公平锁、超时和中断,应对场景更多。并且无需像synchronized那样上下文切换来运行,因为这种场景下Synchronized通常会膨胀为重量级锁,会影响性能。
不过现在jvm对于synchronized有优化,两者的性能实际上差别没有很大,具体采用哪个还是需要根据业务需求决定。
说说AQS
AQS 是阻塞式锁和相关的同步器工具的框架,它维护了一个共享变量 state 和一个FIFO的基于双向链表实现的同步队列,里面存储封装了线程的Node节点。
AQS 的设计是基于模板方法模式的,它有一些方法必须由子类去实现的,比如
tryAcquire(int):独占方式尝试获取资源
tryRelease(int):独占方式尝试释放资源
tryAcquireShared(int):共享方式尝试获取资源。
tryReleaseShared(int):共享方式。尝试释放资源内部通过一个用volatile关键字修饰的int类型全局变量state作为标识来控制同步状态
而且它支持两种模式:
独占模式:如reentrantlock,只能有一个线程获取锁
共享模式:如Semaphore\CountDownLatch,多个线程可以同时获取锁
简单说说Semphore\CountDownLatch\Condition
Condition主要是实现一种通知等待机制,主要方法为await()和signal(),如果线程调用await会进入等待队列尾部,如果被signal,则把他加进AQS的同步队列里,实现精准唤醒
Semphore是信号,表示最多允许多少个线程同时访问资源。线程调用
acquire()方法获取许可证,调用release()方法释放许可证,如果没有可用许可证,则阻塞等待。CountDownLatch 是 JUC 中的一个同步工具类,子线程执行完任务后,调用
countDown()方法计数器减 1。主线程调用await()方法进入阻塞,直到计数器为 0继续
ThreadLocal是什么?
ThreadLocal是一种线程局部变量的工具类,允许每个线程拥有自己独立的副本
底层基于Thread类中,有个ThreadLocalMap 的成员变量。 ThreadLocalMap内部维护了Entry数组(key是弱引用),每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的value对象
使用的时候先定义一个ThreadLocal变量,然后线程去调用变量的set\get等方法就可以使用了,通常我们可以使用ThreadLocal会存储当前的会话信息,这样我们在处理请求时能方便的访问当前用户的会话信息。
ThreadLocal有哪些优点
1、每个线程访问的变量副本都是独立的,避免了共享变量引起的线程安全问题(而且不能继承),而且开销较小
2、在同一个线程内使用ThreadLocal可以减少参数的传递,降低代码之间的耦合度,使代码更加清晰和模块化。
3、由于ThreadLocal避免了线程间的同步开销(每个线程有自己的变量无需走共享内存读写),所以在大量线程并发执行时,相比传统的锁机制,它可以提供更好的性能
说说ThreadLocal的原理
Thread类中,有个ThreadLocalMap 的成员变量。 ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是对象的引用。
当我们使用set方法时会获取当前线程的ThreadLocalMap,调用其set方法,然后会根据hash值计算数组下标位置,如果冲突就用线性探测法解决找threadlocal。如果容量不够则扩容。
get方法原理类似。(获取map,掉用get,找位置取值)
ThreadLocal内存泄漏是怎么回事?
由于ThreadLocal里的Entry对象,key是弱引用(下一次gc掉),value是强引用,当ThreadLocal变量被回收时,key变为null,该Entry已经无用了,而value是强引用无法回收。
当然正常使用一般是没有这个问题的,因为线程使用完就会销毁,value对象也会被回收。但是如果是使用线程池就不一样了,因为会复用线程,所以原本不用的value可能一直存在Map里从而导致内存泄漏。
解决方案是使用完ThreadLocal后调用remove方法,回收所有key为null的Entry对象。虽然threadlocal自带清理机制,但通常会存在不少未清理的残余value,如果等到它们被自动清理的话时间可能很长,所以最佳还是手动清理。
不过具体什么时候remove还要看使用场景,像web项目我就是在拦截器里进行remove,而像一些长时间作业的任务通常会在方法里进行remove()
多线程的上下文切换是什么?
上下文切换是指 CPU 从一个线程转到另一个线程时,需要保存当前线程的上下文状态,恢复另一个线程的上下文状态,以便于下一次恢复执行该线程时能够正确地运行。
此时需要保存当前线程的状态信息,包括程序计数器、寄存器、栈指针等
过多的上下文切换会降低系统的运行效率,因此需要尽可能减少上下文切换的次数。
什么时候会发生上下文切换?
1、线程终止或时间片耗尽,那就会进行上下文切换。
2、线程主动让出CPU,比如调用sleep或wait方法,触发调度器执行其他线程
3、高优先级线程抢占也有可能,会导致高优先级任务先处理,中断当前线程。
如何减少上下文切换?
1、减少线程数,这从根本上减少了上下文切换
2、使用CAS算法,适当采用CAS算法可以避免线程的阻塞和唤醒,从而减少切换
3、使用虚拟线程,协程是一种用户态线程,其切换不需要操作系统的参与,因此可以避免上下文切换
原子类的定义,原理,使用场景?
原子类是atomic包下的一组工具类(如AtomicInteger、AtomicReference等),通过 无锁编程和 CAS 机制实现线程安全的无锁的变量操作。
从原理上来说就是:
- Atomic 包的类的实现大多数都是调用的 unsafe 方法
- 而 unsafe 底层实际上是调用C代码,C代码调用汇编
- 最后生成出一条 CPU 的原子指令 cmpxchg,直接操作内存完成操作,CPU的原子指令是原子性的
当我们只是需要一个简单高效的递增或者递减方案:例如统计请求次数、在线用户数就可以使用
UnSafe类是什么?
Unsafe 是 CAS 的核心类,它提供了硬件级别的原子操作。
JUC包、一些三方框架都使用 Unsafe 类来保证并发安全。
这个类的提供了一些 绕开 JVM 的、更底层的功能,基于它的实现可以提高效率。但是它是一把双刃剑:它所分配的内存需要手动 free
所以我们使用unsafe类是要很小心才行。
Mysql
索引怎么分类的?
数据结构分:B+树索引、hash索引、全文索引。
B+树索引将所有数据存储在叶子节点,复杂度低查询快,也是Innodb存储引擎的默认索引结构
hash索引:适合等值查询,但无法高效查询范围(mysql不支持)
物理存储分:主键索引、二级索引
主键索引的 B+Tree 的叶子节点存放的是实际数据,所有完整的用户记录都存放在主键索引的 B+Tree 的叶子节点里;
二级索引的 B+Tree 的叶子节点存放的是主键值,而不是实际数据。
在查询时使用了二级索引,如果查询的数据能在二级索引里查询的到,那么就不需要回表,这个过程就是覆盖索引。如果查询的数据不在二级索引里,就会先检索二级索引,找到对应的叶子节点,获取到主键值后,然后再检索主键索引,就能查询到数据了,这个过程就是回表。
从字段特性的角度来看,索引分为主键索引、唯一索引、普通索引、前缀索引(全文索引,通常用于like模糊查询)
主键索引:在主键上建立的索引
唯一索引:在unique字段上建立的索引
普通索引:对字段无要求
前缀索引:指对字符类型字段的前几个字符建立的索引
按字段个数分类:单列索引,联合索引
单列索引就是对一个列设置索引
联合索引则是对多个列设置索引,遵循最左匹配原则。联合索引的最左匹配原则,在遇到范围查询的时候就会停止匹配,也就是范围查询的字段可以用到联合索引,但是在范围查询字段的后面的字段无法用到联合索引。
Innodb为什么采用B+树作为索引?
使用B+树是因为它适合范围查询,而且IO次数少。
如果使用B树的话,由于B树的非叶子节点也要存数据,页内索引记录少导致分叉变少,磁盘IO次数多一些(而且插入删除的效率要低一些)。当然B树也无法做到快速范围查询,因为它的叶子节点没有链表来维护,而且B 树的范围查询还需要通过遍历逐层回溯,更加复杂
如果使用二叉树的话,则磁盘IO次数会太大,因为他的复杂度是OlogN,但B+树是Olog
,d为每层的节点数通常大于100,甚至是一千多,这意味着B+树哪怕是千万级别的数据也只会发生3\4次IO。
如果使用hash索引的话因为不支持范围查询,所以应用场景少。而全文索引是为文本分词设计的,对数值型或短字符串的精确查询效率低下
如果使用红黑树的话在这种数据量下的查询次数则跟二叉树差不多,层级也会很高时间开销大
所以最适合的还是B+树作为索引。
什么是聚簇索引和非聚簇索引?
1、在聚簇索引中,索引的叶子节点包含了实际的数据行。非聚簇索引的叶子节点不包含完整的数据行,而是包含主键值。而数据行本身存储在聚簇索引中。
2、聚簇索引通常是基于主键构建的,因此每个表只能有一个聚簇索引。但一个表可以有多个非聚簇索引,因为它们不直接影响数据的物理存储位置。
3、当通过聚簇索引查找数据时,可以直接从索引中获得数据行。当通过非聚簇索引查找数据时,通常会在非聚簇索引中找到对应的主键值,然后通过这个主键值回溯到聚簇索引中查找实际的数据行,进行回表查询,通常效率低一些。
(默认行为:在 InnoDB 引擎中,如果未显式定义主键,优先使用第一个非空的唯一索引作为聚集索引,没有主键且无非空唯一索引时,自动生成隐藏的 DB_ROW_ID作为聚集索引)
回表了解吗?(采用覆盖索引减少回表)
回表的存在主要源于聚簇索引和非聚簇索引,聚簇索引存储的是表的所有数据,而非聚簇索引只存储索引字段和主键值,当我们使用非聚簇索引时,假如要查询所有字段时通常会再去聚簇索引查一次,因为非聚簇索引不包含我们要查的所有字段,而这个过程就是回表。
覆盖索引了解吗?
覆盖索引是指一个二级索引包含了查询所需的所有列,因此不需要访问一级索引中的数据行就能完成查询,也就是不需要进行回表查询。
通常我们可以将高频查询的字段(如 WHERE 条件和 SELECT 列)组合为联合索引,实现覆盖索引。因为覆盖索引能够显著提高查询性能,因为减少了访问数据页的次数,从而减少了I/O操作。
什么是索引下推?
索引下推是指:MySQL 把 WHERE 条件尽可能“下推”到索引扫描阶段,在存储引擎层提前过滤掉不符合条件的记录。
没有索引下推的时候,每查询到一条二级索引记录,都要进行回表操作,然后将记录返回给 Server,接着 Server 再判断该记录。
而有索引下推时,先不执行回表操作,而是先判断一下该索引中包含的列的条件是否成立。如果条件不成立,则直接跳过该二级索引。如果成立则执行回表操作,将完成记录返回给 Server 层
索引哪些情况会失效
(表达式、Like、最左、whereor、字符与数字)
1、对索引列使用了函数表达式会失效 length()
2、还有使用Like模糊查询时,以通配符开头会导致索引失效
3、然后就是联合索引,当我们使用联合索引时违背了最左匹配原则会失效,比如索引(a,b),where b=1 就失效了
4、还有就是where子句中,使用or连接非索引列就会导致索引失效
5、还有就是等值查询时如果涉及字符串字段与数字进行比较时会失效,因为mysql会将字段转为数字,相当于使用了函数,所以会失效
Mysql的事务特性说一下?
事务是数据库中一组不可分割的操作集合,要么全部执行成功,要么全部执行失败,并保证保证数据操作的完整性与可靠性
它的特性主要有四个,原子性、隔离性、持久性、一致性
原子性指的是一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节,Innodb引擎使用undolog日志来保证原子性。
隔离性是数据库允许多个并发事务同时对其数据进行读写和修改的能力,而Innodb引擎是通过MVCC机制和锁机制来实现的
持久性是事务处理结束后,对数据的修改就是永久的不会丢失,Innodb引擎采用redolog日志来实现的
一致性是是指事务操作前和操作后,数据满足完整性约束,数据库仍然保持一致性状态。一致性的实现是通过持久性+原子性+隔离性来保证的。
事务的隔离级别有哪些?
读未提交RU:事务可以读取其他未提交事务修改的数据。所以会出现脏读、不可重复读、幻读问题。
读已提交RC:指一个事务提交之后,它做的变更才能被其他事务看到;解决了脏读问题,但没有解决幻读不可重复读问题
可重复读RR:指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,是MySQL的默认隔离级别。解决了可重复读和大部分的幻读问题。(A更新一条B新提交的数据,两次快照读去查就多了一条记录)
串行化:串行化是最高的隔离级别,通过读写锁强制事务串行执行来解决“幻读”问题,不过效率通常较低。
通常更改命令类似于SET TRANSACTION ISOLATION LEVEL
什么是脏读、不可重复读、幻读问题?(并发事务会有哪些问题?)
脏读是指当前事务修改数据但还未提交事务,但是其他事务却读到了数据。
不可重复读是在一个事务内多次读取同一个数据,但是出现前后两次读到的数据不一样的情况
幻读则是在一个事务内多次查询某个符合查询条件的记录数量,但是出现前后两次查询到的记录数量不一样的情况
事务的隔离级别是如何实现的?
读未提交主要是靠互斥锁来实现的,快照读读任意的数据,所以有脏读、不可重复读问题;当前读通过互斥锁来实现,间隙插入数据导致数量不一致,会有幻读问题。(写不共享)
读已提交是通过加互斥锁+MVCC来实现修改安全的,针对快照读,使用MVCC来实现,在每一次的读操作都生成一个快照来看,会读取上锁数据的历史版本,别人提交了你也能读到,所以有不可重复读问题;针对当前读,使用互斥锁来实现,依然会有幻读问题。
可重复读则通过MVCC和临键锁来解决可重复读问题和大部分幻读问题。对于快照读,使用了MVCC实现,只在第一次查询时生成一个快照,后面每次读都是读这个快照,保证了多次读取数据一致。针对当前读则使用临键锁来解决,凡是当前事务要查询和修改所涉及到的数据及范围上锁,不让别的事务插入数据,这样前后多次读写都不会出问题。但是可重复读级别还有极少部分的幻读未解决。
最后是串行化,是通过加表锁来实现的,所有事务按顺序排队执行,避免了脏读、不可重复读、幻读问题。
(能解决RR级别的幻读问题)
读已提交和可重复读的区别
1、快照读不同:虽然都使用了MVCC机制,但生成快照的时机不同,读已提交是在每一次快照读时都生成快照,而可重复读是在第一次读取时生成快照。
2、当前读不同:虽然它们都使用了锁,但粒度不同,读已提交是使用行级互斥锁,而可重复读则使用的是临键锁,也就是互斥锁+间隙锁
3、主从复制不同,MySQL 的 binlog 主要支持三种格式,分别是 statement、row 以及 mixed,但读已提交隔离级别只支持 row 格式的 binlog(因为执行顺序rc有依赖,可能导致执行顺序与提交顺序不一样,a看到b改了后先提交,到从库a看到的是并没改之前的数,产生不一致),而可重复读则都支持。
为什么互联网公司采用读已提交?
提升并发:因为可重复读采用了间隙锁和临键锁,在高并发下修改较慢,所以互联网公司通常是在业务层面保证事务的成功
减少死锁:可重复读的锁粒度更大,所以更容易出现死锁,所以不采用
MVCC了解吗?怎么实现的?
MVCC 指的是多版本并发控制,每次修改数据时,都会生成一个新的版本,而不是直接在原有数据上进行修改。它的底层实现依赖于readview和undolog。
readview就是快照,它有四个重要的字段:当前事务、活跃事务id列表、最小活跃事务id、下一个可分配事务id(最大事务id+1),对于数据库表的每一条记录都包含两个隐藏列:当前事务id(6字节)和回滚指针(7字节),回滚指针指向上一个版本的记录,多个历史版本就是通过回滚指针来形成版本链的(undolog)。
读取数据时,用数据的 事务id 和当前事务快照比对:比最小活跃 ID 小可见;比最大活跃 ID 大不可见;在中间的看是否还在活跃且未提交,未提交不可见,已提交可见;不可见就通过 undo 指针找旧版本,直到找到可见的为止。
一条查询sql语句的执行流程
(连接、查缓存、解析、预处理、优化、执行、返回)
首先客户端编写Sql语句,发起网络请求获取数据库连接池对象(这里使用TCP协议,而且双方各维护一个数据库连接池),会进行身份校验,通过后会开启一个线程绑定session会话信息,完成后续对话。
然后真正开始处理sql,先去查询缓存(8.0后删除了缓存),缓存以 key-value 形式保存在内存中的,key 为 SQL 查询语句,value 为 SQL 语句查询的结果。若缓存中存在该语句的结果则返回给客户端。
如果没有则将sql语句交给解析器,判断是否符合sql语法规范。解析完成后会生成语法树
然后开始执行sql,先会进行预处理,检查表和字段是否存在
然后优化器会根据语法树生成多条解决方案然后选择一个最好的计划去执行。
最后执行器会进行调用对应存储引擎的api,存储引擎去硬盘中查数据发生磁盘io。返回给客户端
一条更新语句是如何执行的?(结合日志)
(连接、删缓存、解析、预处理、优化、记录日志、缓冲区修改、记录日志、返回)
- 客户端先获取数据库连接池对象,将SQL发送给
SQL接口- 然后会在缓存中根据哈希值检索数据,将对应表的所有缓存全部删除。
- 如果没有则将sql语句交给解析器,判断是否符合sql语法规范。解析完成后会生成语法树,再进行预处理,判断字段是否存在
- 接着优化器根据
SQL制定出不同的执行方案,并择选出最优的执行计划。- 在执行开始之前,先记录一下
undolog日志和redo-log(prepare状态)日志。- 接着在缓冲区中查找是否存在当前要操作的行记录或表数据(内存中):
- 存在:
- 直接对缓冲区中的数据进行写操作。然后利用
Checkpoint机制刷写到磁盘。- 不存在:
- 磁盘加载数据页到Buffer Pool,然后在内存中修改
- 写操作完成后,记录
bin-log日志,同时将redo-log日志中的记录改为commit状态。- 将
SQL执行的结果返回给SQL接口,再由SQL接口返回给客户端。
执行计划怎么看,有用过吗?
学习的时候有使用explain。在 EXPLAIN 输出结果中我最关注的字段是 type(是否走索引)、key(走的哪个索引)、rows(预估扫描行数) 和 Extra(是否使用文件排序或临时表。
我会通过它们判断 SQL 有没有走索引(type)、是否全表扫描(type=ALL)、预估扫描行数是否太大(rows),以及是否触发了 filesort 或临时表(Extra)。一旦发现问题,比如 type=ALL 或者 Extra=Using filesort,我会考虑建索引、改写 SQL 来做优化。
mysql日志文件有哪些?
有很多种:
①、错误日志:记录 MySQL 服务器启动运行时出现的问题。
②、慢查询日志:记录执行时间超过 long_query_time 的所有 SQL 语句。这个时间值是可配置的,默认情况下慢查询日志功能是关闭的。
③、中继日志:用于主从复制的,
④、binLog:记录所有修改数据库状态的 SQL 语句,以及每个语句的执行时间
⑤、redo Log:记录对于 InnoDB 表的每个写操作,主要用于崩溃恢复。
⑥、undo Log:记录数据被修改前的值,用于事务的回滚。
(中继日志,写在从库里的)
讲讲redolog、undolog、binlog
undolog是回滚日志,它保证了事务中的原子性,在事务没提交之前,MySQL 会先记录更新前的数据到 undo log 日志文件里面,当事务回滚时,可以利用 undo log 来进行回滚。
而基于undolog形成的版本链,也从而与readview共同实现了MVCC机制,MySQL 在执行快照读的时候,会根据事务的 Read View 里的信息,顺着 undo log 的版本链找到满足其可见性的记录。
redolog是重做日志,主要用来崩溃恢复的,会记录某个数据页做了什么修改,在事务提交时,只要先将 redo log 持久化到磁盘即可,可以不需要等到将缓存池里的脏页数据持久化到磁盘。系统崩溃时,虽然脏页数据没有持久化,但是 redo log 已经持久化,接着 MySQL 重启后可以根据 redo log 的内容,将所有数据恢复到最新的状态。
除此之外,redolog将持久化由随机写升级为了顺序写,能有效的提高性能。不过通常redolog会先写入到redolog缓存里,再由缓存写到磁盘,这样能减少io次数。这里写回时间有三种选择,第一种是提交事务时开启后台线程,它每隔一秒将日志写入内核缓冲区再写入磁盘,这种方式性能高但安全性低,数据库崩溃时会丢失一秒内的数据;第二种是事务提交时直接写入磁盘,这种方式性能低一些但安全性高;第三种则是事务提交时直接写入内核缓冲区,再每隔一秒写入到磁盘,这种性能和安全性适中,只有系统断电才会丢失一秒的数据。
除此之外,如果redolog写满了,则会阻塞mysql,因为redolog是使用循环写的方式,脏页更新后会擦除无效记录,如果写满了只能阻塞进行脏页的落盘,等腾出空间再继续,所以我们实际使用时要设置适当的redolog文件组大小减少阻塞。
binlog是server层的,记录了所有数据库表结构变更和表数据修改,主要用于主从复制。binlog 有 3 种格式类型,分别是 STATEMENT(默认格式)、ROW、 MIXED: STATEMENT:每一条修改数据的 SQL 都会被记录到 binlog 中,主从复制中 slave 端再根据 SQL 语句重现。但 STATEMENT 有动态函数的问题,比如你用了 now 这些函数,你在主库上执行的结果并不是你在从库执行的结果,会导致复制的数据不一致;
ROW:记录行数据最终被修改成什么样了,不会出现 STATEMENT 下动态函数的问题。但 ROW 的缺点是每行数据的变化结果都会被记录,比如执行批量 update 语句,会产生很多条记录,使 binlog 文件过大,而在 STATEMENT 格式下只会记录一个 update 语句而已; MIXED:包含了 STATEMENT 和 ROW 模式,对于可以复制的SQL采用Statment模式记录,对于无法复制的SQL采用Row记录。
binlog的刷盘会先写入到内核缓冲区里,再进行刷盘,刷盘时机也有三种
sync_binlog = 0 的时候,表示罗盘时机交由操作系统决定;
sync_binlog = 1 的时候,表示马上执行落盘;
sync_binlog =N的时候,表示累积 N 个事务后才落盘。
为什么redolog使用WAL技术?
WAL即是先写日志, 写的就是RedoLog日志
这样做最主要是写入效率高:而且把脏页数据写入磁盘是随机IO,而把日志记录写入RedoLog文件中是顺序IO
顺序IO效率远大于随机IO,使用WAL写入更快
为什么有了undolog还要redolog?
因为undolog是写入缓存池里的,而缓存池是基于内存的,万一断电了数据未落盘就会造成丢失,所以增加了redolog就是为了实现持久化机制的。每当开启事务进行数据的修改时都会在redolog里面写入具体操作,等到事务提交时将redolog写入磁盘。如果这时候断电了,对于已提交的事务那就说明redolog写完了,而未提交的事务的话,既然未提交就是未完成,我再进行回滚到开启时状态即可,这样就保证了数据的持久化。
为什么有了binlog还要redolog、undolog?
binlog 属于 Server 层,与存储引擎无关,无法直接操作物理数据页。而 redo log 和 undo log 是 InnoDB 存储引擎实事务的基石。
binlog 关注的是逻辑变更的全局记录;redo log 用于确保物理变更的持久性,确保事务最终能够刷盘成功;undo log 主要用于回滚 记录的是旧值,方便恢复到事务开始前的状态。他们的职责和功能都不一样。
不过实际上binlog和redolog有部分功能是类似的,都要进行持久化,但是mysql一开始的默认引擎是myisam,myisam不支持事务,所以mysql在server层实现了binlog,redolog是innodb特有的,不能说因为后面更换了引擎就不要binlog了,如果人为更换引擎总得要保证持久化吧,所以我认为server层的binlog更像一个兜底机制,保证数据持久化。
当然binlog还可以用作主从复制,也是redolog做不了的
binlog和redolog有什么区别?
- 生效范围不同,
Redo-log是InnoDB专享的,Bin-log是所有引擎通用的。- 写入方式不同,
Redo-log是用多个文件循环写,而Bin-log是不断创建新文件追加写。- 文件格式不同,
Redo-log中记录的都是变更后的数据,而Bin-log会记录变更SQL语句。- 功能不同,
Redo-log主要实现故障情况下的数据恢复,Bin-log则用于主从同步。
binlog两阶段提交了解吗?
两阶段提交主要是为了保证 redo log 和 binlog 中的数据一致性,防止主从复制和事务状态不一致。
当客户端提交事务时,MySQL 内部开启一个 XA 事务,分两阶段来完成 XA 事务的提交
首先是prepare 阶段:将 XID 写入到 redo log,同时将 redo log 对应的事务状态设置为 prepare,然后将 redo log 持久化到磁盘(默认这样,因为最安全);
接着是commit 阶段:把 XID 写入到 binlog,然后将 binlog 持久化到磁盘,接着调用引擎的提交事务接口,将 redo log 状态设置为 commit。
对于处于 prepare 阶段的 redo log,即可以提交事务,也可以回滚事务,这取决于是否能在 binlog 中查找到与 redo log 相同的 XID,如果有就提交事务,如果没有就回滚事务。这样就可以保证 redo log 和 binlog 这两份日志的一致性了。
主从复制了解吗?
MySQL 的主从复制是一种数据同步机制,用于将数据从主数据库复制到一个或多个从数据库。
主要过程如下:
从服务器在开启主从复制后,会创建出两个线程:I/O 线程 和 SQL 线程
从服务器的 I/O 线程,会尝试和主服务器建立连接,相对应的,主服务器中也有一个线程,专门来和从服务器的 I/O 线程做交互的
从服务器的 I/O 线程会告诉主服务线程自己要从什么位置开始接收 binlog
主服务器在更新过程中,将更改记录保存到自己的binlog中
在主服务器线程检测到 binlog 变化时,会从指定位置开始读取内容,然后会被从服务器的的 I/O 线程把数据拉取过去(拉的模式,从库可以自行管理同步进度和处理延迟)
从服务器的 I/O 线程接收到通知事件后,会把内容保存在中继日志 relaylog 中
从服务器还有一个SQL线程,他会不断地读取 中继日志 relay log 中的内容,把他解析成具体的操作,然后写入到自己的数据表中
分库分表了解吗?
分库分表是针对高并发、数据量大的场景下的技术优化方案。分库分表的本质就是数据分片。主要包括水平分和垂直分两种方案。
垂直分库:一般来说按照业务和功能的维度进行拆分,将不同业务数据分别放到不同的数据库中,但并没有解决由于单表数据量过大导致的性能问题,所以就需要配合后边的分表来解决。
垂直分表:针对业务上字段比较多的大表进行的,是一种大表拆小表的模式。核心表大多是访问频率较高的字段,增加索引查询的命中率
水平分库:是把同一个表按一定规则拆分到不同的数据库中,但由于同一个表被分配在不同的数据库中,因此系统的复杂度也被提升了。
水平分表:是在同一个数据库内,把每个表只存原表的一部分数据。水平分表只是解决了单一表数据量过大的问题,要想进一步提升性能,就需要将拆分后的表分散到不同的数据库中才行
什么时候分库分表?
分表通常是当一个表的数据量超过2000万行时要分,因为这一般是3层B+树存的最大上限,再多的话查询效率就要降低了。(1000*1000*16)
分库的话主要是一个数据库访问太多,扛不住了就要分。
如何预估分库表的量?
对于分表主要看增长和使用年限吧,
某张表已有存量数据2000w,预计每年增长500w,数据需要保留10年
所以10年内总共要存储的数据上限是 7000w,那么我们就要分4张表
分库则是要看并发量来去分
分片算法了解吗?
主要有两种:hash去模和一致性hash
hash取模:就是算出分片键的hash值,对库和表的数量取模算它的位置,这种方法简单,但二次扩容的话成本较高
一致性hash:它将整个哈希空间视为一个环状结构,将节点和数据都映射到这个环上。每个节点通过计算一个哈希值,将节点映射到环上的一个位置。而数据也通过计算一个哈希值,将数据映射到环上的一个位置。
当有新的数据需要存储时,首先计算数据的哈希值,然后顺时针或逆时针在环上找到最近的节点,将数据存储在这个节点上。当需要查找数据时,同样计算数据的哈希值,然后顺时针或逆时针在环上找到最近的节点,从该节点获取数据。
如果节点数据发生变动,也只影响部分数据,而不用全量移动,比如AB服务器节点加了C服务器,那么只移动这之间的部分数据归到C即可。
分片键如何选择?
分片键通常应选高频查询、数据均匀的字段
举个例子,
电商场景,针对于订单数据,应该用什么分片键:卖家ID、用户ID、订单ID
需要分析业务场景和数据分布
最高频的场景是 用户去查询自己的订单,其次是商家去查看自己售卖的订单
大商家的 订单量非常多,占整个数据集的比重很大,而小商家订单量少
具体分析:
如果选择卖家ID为分片键,大商家都会被分到同一张表或数据库,造成数据倾斜
而且用户查看自己的订单,结果a订单在A表/库,b订单在B表/库,此时需要多表/库扫描才行性能比较低
如果选择订单ID为分片键
虽然不会存在数据倾斜,但是仍然是没有考虑到用户查看自己订单这样一个最高频的场景,仍然需要多表/库扫描才能得到用户的订单集合,性能比较低
如果选择用户ID为分片键
第一,不可能有一个用户买了很多很多商品,以致于数据倾斜,因此数据分布是均匀的
第二,符合业务的最高频场景,当用户查看自己的订单时,由于同一个用户ID的订单都会被分到同一张表/一个库,因此只需要去这一张表/库中查询,性能高
所以应该选择以用户ID为分片键
非分片键查询怎么办?
还是电商场景,选流用户ID作为分片键但是查询根据订单ID查,这时如果扫描所有库表性能太低,所以我们可以采用基因法。
我们将分库和分表的数量都定为2的n次幂,生成订单ID时对用户ID提取一个基因,加入每个库64张表,那么就是用户ID后6位决定在哪个库和表,我们把它作为订单ID后6位,这样订单ID与用户ID路由结果一致,也能用分片键查询的方法。
当然有些公司还会采用分析型数据库,既能支持查询,还能进行报表分析。
sql语句的执行顺序了解吗?
先执行 FROM 确定主表,再执行 JOIN 连接,然后 WHERE 进行过滤,接着 GROUP BY 进行分组, HAVING 过滤聚合结果, SELECT 选择最终列 ,ORDER BY 排序,最后 LIMIT 限制返回行数。
① FROM 确定主表,准备数据 ② ON 连接多个表的条件 ③ JOIN 执行 INNER JOIN / LEFT JOIN 等 ④ WHERE 过滤行数据(提高效率) ⑤ GROUP BY 进行分组 ⑥ HAVING 过滤聚合后的数据 ⑦ SELECT 选择最终返回的列 ⑧ DISTINCT 进行去重 ⑨ ORDER BY 对最终结果排序 ⑩ LIMIT 限制返回行数
Innodb和MyIsam有什么区别?
事务:InnoDB 支持事务,MyISAM 不支持事务,因为innodb有特有的undolog\redolog日志,支持崩溃恢复和事务回滚,这也是 MySQL 将默认存储引擎从 MyISAM 变成 InnoDB 的重要原因之一。
索引结构:InnoDB 是聚簇索引,MyISAM 是非聚簇索引。聚簇索引的文件存放在主键索引的叶子节点上,因此 InnoDB 必须要有主键,通过主键索引效率很高。而 MyISAM 是非聚簇索引,数据文件是分离的,索引保存的是数据文件的指针。索引走二级索引时不需要回表。
锁粒度:InnoDB 最小的锁粒度是行锁,MyISAM 最小的锁粒度是表锁。一个更新语句会锁住整张表,导致其他查询和更新都会被阻塞,因此并发访问受限。
count 的效率:InnoDB 不保存表的具体行数,执行 select count(*) from table 时需要全表扫描。而MyISAM 用一个变量保存了整个表的行数,执行上述语句时只需要读出该变量即可,速度很快。
如果应用需要高度的数据完整性和事务支持,那么InnoDB是更好的选择。InnoDB适合频繁修改以及涉及到安全性较高的应用。 如果应用主要是读取操作,或者需要高效的全文搜索功能,那么MyISAM可能更适合,基于内存的,不过通常我们还是用innodb。MyISAM 适合查询以及插入为主的应用
慢sql是如何优化的?
-- 1. 开启慢查询日志(ON=开启,OFF=关闭) SET GLOBAL slow_query_log = 'ON';首先我会分析慢查询日志,不过我们需要先开启慢查询日志,然后进行慢sql的分析与优化。
通常慢sql有这么几种原因,索引和sql语句的问题。
对于索引我们需要建立高频使用的索引,而且数量不能太多,否则优化器在进行索引选择时会增加耗时。还有就是我们使用联合索引进行查询时,左侧放区分度高和频繁使用的字段,保证查询能走索引减少时间。
举个例子:
还有就是sql语句,我们尽量避免使用*查询,这会增加解析器的解析时间,还有就是join进行多表查询时使用小表驱动大表(小表加载到内存,里面的每一条记录在大表查一遍,外层全表扫描,内层走索引,性能瓶颈来源于外层),能减少查询次数。
Redis
redis为什么这么快?
1、基于内存(最主要的原因):Redis 是一种基于内存的数据库,数据存储在内存中,数据的读写速度非常快
2、执行命令是单线程模型:Redis 使用单线程模型,,不需要进行线程切换和上下文切换。这大大提高了 Redis 的运行效率和响应速度
3、多路复用 I/O 模型:Redis 在单线程的基础上,采用了I/O 多路复用技术,实现了单个线程同时处理多个客户端连接的能力,从而提高了 Redis 的并发性能
4、高效的数据结构:Redis 提供了多种高效的数据结构,如哈希表、有序集合、列表等,能够在较低的时间复杂度内完成数据读写操作
redis是单线程还是多线程?
这个关键在于看待问题的角度,如果从数据读写层面那确实是单线程,如果是整个redis包括持久化集群等功能,那就是多线程。
比如redis4.0后引入的unlink异步删除命令,还有持久化的bgsave,以及6.0引入的多线程解决网络并发量的问题,都是用多线程实现的。但是它的核心命令处理仍然是单线程。
redis6.0为什么引入多线程?
Redis 6.0 中的多线程,是针对处理网络请求过程采用多线程,而数据的读写命令,仍然是单线程处理的。因为限制redis的性能瓶颈主要在网络IO上。在请求和响应数据时,通过多线程+IO多路复用方式获取和发送,虽然单条请求的处理速度没有变快,但是整体的吞吐量是有很大提升的。
redis为什么使用单线程执行命令?
这个问题关键在于redis的性能瓶颈在哪,因为redis是基于内存的,所以执行速度很快,网络开销反而更大,使用多线程并不会提高速度,返回可能会引入线程安全问题、以及上下文切换导致的性能问题。
redis是AP还是CP的?
Redis 是AP的
Redis 的设计目标是高性能、高可扩展性、高可用性
- 当发生网络分区时或者部分节点故障不可用时,Redis 仍然允许接受读写请求,保证可用性(A)
- Redis 是保证最终一致性,即在某个时间点读取的数据可能并不是最新的,但最终会达到一致的状态。
redis支持事务吗?事务回滚呢?
Redis 支持事务,但它的事务只保证多个命令执行的原子性,且事务不支持回滚。因为redis设计是为了简单高效的,引入事务回滚会提高复杂性,而且如果对强一致性需求应该采用Mysql等数据库。
redis基本数据类型有哪些(结合项目)
redis最常见的基本数据类型有5种:
string:是存放字符串和整数的,底层是用动态字符串或int实现的,通常我们可以用来缓存json对象,计数、以及实现分布式锁。
List:List是有序可重复的集合,在3.2版本之前是用压缩列表或双向链表来实现的,而现在则使用了quicklist来实现,我们可以用它做消息队列或者堆栈来用
Hash:hash是键值对的集合,非常适合存储对象。底层是由压缩列表或哈希表实现的,不过7.0后压缩列表被弃用,使用listpack来实现,我们通常用hash来存对象或者是类似于购物车的信息模型
Set:set是一个无序并唯一的键值集合,底层是通过哈希表或整数集合来实现的,通常我们可以用来做点赞功能,共同关注的功能
ZSET:是有序且唯一的集合,底层是由压缩列表或跳表实现的,不过7.0后压缩列表被弃用,使用listpack来实现,我们通常可以用zset做排行榜功能,或者姓名电话号排序
此外还有四种后加的数据类型
bitmap:位图,是一串连续的二进制数组,可以通过偏移量定位元素,通常可以用来做签到统计
HyperLogLog 是一种用于统计基数的数据集合类型,统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%。通常可以做百万级网页UV统计
GEO 主要用于存储地理位置信息,我们通常利用它存位置坐标
Stream 是 Redis 专门为消息队列设计的数据类型,支持消息的持久化。
redis的底层数据结构了解吗?
SDS简单动态字符串:在原本字符数组之上,增加了字符串长度、分配空间长度,用来解决 C 语言无法直接获取长度、缓冲区溢出的问题。
list链表:不过它内存开销太大、无法很好利用缓存
压缩列表:由连续内存块组成的顺序型数据结构,整个结构包含总的字节数、尾部偏移量、节点数量,以及连续的节点数据。而每个节点包含三个部分:前一个节点的长度、编码类型和实际的数据。压缩列表有个致命问题,就是连锁更新。当插入或删除节点导致某个节点长度发生变化时,可能会影响后续所有节点存储的“前一个节点长度”字段
哈希表:键值对的数据结构,Redis 采用了「链式哈希」来解决哈希冲突,而且这里redis采用了两个哈希数组实现,其中一个是用来rehash的,也就是当哈希表要扩容时触发rehash,新增操作写在新数组中,而删改查在两个数组中依次执行并搬运一部分数据。
整数集合:是 Set 对象的底层实现之一。当一个 Set 对象只包含整数值元素,并且元素数量不大时,就会使用整数集这个数据结构作为底层实现。
跳表:在有序链表的基础上建立了多层索引,最底层包含所有数据,每往上一层,节点数量就减少一半。它的核心思想是"用空间换时间",通过多层索引来跳过大量节点,从而提高查找效率。查找的时候从最高层开始水平移动,当下一个节点值大于目标时,就向下跳一层,直到找到目标节点。
quicklist:它的链表节点的元素不再是单纯保存元素值,而是保存了一个压缩列表,添加一个元素的时候会检查插入位置的压缩列表,选择合适的位置容纳该元素
listpack:它实际上也是一个链表,不过listpack 没有压缩列表中记录前一个节点长度的字段了,只记录当前节点的长度从而避免了压缩列表的连锁更新问题。
ZSET的底层实现详细说说?
Zset 类型的底层数据结构是由压缩列表或跳表实现的:
如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构; 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构; 在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
跳表详细说说
跳表有序链表的基础上建立了多层索引,每往上一层,节点数量就减少一半,因为他在插入节点的时候是随机生成层数(有50%概率升到下一层,有个最大层数限制)
插入和查找流程类似,在每层向右移动直到下个节点的值大于要插入的值,然后下降到下一层。从而使查找、插入和删除都能保持 O(log n) 的时间复杂度。
而且跳表支持范围查询,找到起始位置后可以直接沿着底层链表顺序遍历,满足 ZRANGE 按排名获取元素,或者 ZRANGEBYSCORE 按分值范围获取元素。(可以按得分或排名来查范围)
Redis怎么实现分布式锁?
1、setnx命令
set key value nx ex TTL ,这里value通常为线程ID,用来判断锁是否是本线程加上的,假如锁1超时被锁2获取了,这样加个判断能保证线程1不会误删线程2 的锁,而且删除锁(先判断再删)最好用lua脚本实现保证原子性。
不过setnx命令有局限性,它不支持可重入锁,而且TTL怎么设置最好比较难确定。
2、redission分布式锁组件
比较成熟的分布式锁组件,支持可重入机制、超时续约。比setnx更好用
Redission可重入的底层原理
redission底层使用哈希结构,记录获取锁的线程和次数。
获取锁:先判断锁是否存在,不存在就创建一个,key设为机器码+线程ID,value设为1
存在则判断是否是当前线程持有锁,是的话就value+1,不是的话就自旋再次尝试, 多次尝试都失败就获取失败。
释放锁:重入次数-1,若为0则删除锁
Redission的超时续约机制
当 Redisson 获取锁没有指定锁的TTL时,默认会使用超时续约机制机制。
默认超时时间30秒,每过1/3,也就是默认10秒就去续期,直到业务执行完,这样保证锁不会超时释放影响逻辑。
Redission有什么问题?
主从问题,主节点挂了从节点未同步,可能导致其他线程重复加锁。
这样的话需要考虑使用多主多从架构,采用联锁(所有主节点加锁成功才行)或红锁(半数主节点加锁成功就行)方案。
redis的持久化方式有哪些?
主要有三种:AOF持久化、RDB持久化以及混合持久化
第一种是AOF日志持久化:AOF 通过记录每个写操作命令,并将其追加到 AOF 文件来实现持久化,Redis 服务器宕机后可以通过重新执行这些命令来恢复数据。具体流程是这样的:首先将写操作命令追加到aof缓冲区,然后通过write系统调用将数据写入到内核的pagecache里,最后再将pachcache落盘。
AOF持久化的优点是数据安全性更高,但是文件体积大,恢复较慢
而内核的aof文件什么时候刷盘具体有三种策略(本质是在不同时机下调用fsync()函数):
Always:每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
Evertsec:每隔一秒将缓冲区里的内容写回到硬盘
No:转交给操作系统控制写回的时机
如果追求高性能的话就选用No策略,它不会阻塞主进程;如果追求高可靠就使用always,刷盘即时但有性能开销,如果折中考虑就选everysec(主进程阻塞和减少数据丢失是对立的,无法完美解决只能平衡考虑)
不过为了避免aof文件过大,redis还提供了aof重写机制,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到新的 AOF 文件,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件,而且这个重写机制是由后台子进程完成的,这样可以使得主进程可以继续正常处理命令。
(不过这里在重写的时候如果主进程修改了值则会发生写时复制,导致重写完成后的数据与实际数据不一样,而redis通过aof通过重写缓冲区解决了这个问题,在开启子进程重写后的写命令都会追加到重写缓冲区,等重写完成后再追加缓冲区的命令即可)
第二种是Rdb快照持久化,RDB 持久化可以在指定的时间间隔内将 Redis 某一时刻的数据保存到磁盘上的 RDB 文件中,当 Redis 重启时,可以通过加载这个 RDB 文件来恢复数据。RDB 持久化可以通过 save 和 bgsave(是否开启子进程执行) 命令手动触发,也可以通过配置文件中的 save 指令自动触发。一般采用bgsave
而执行bgsave的时候会通过写时复制技术来实现,主进程如果要修改数据会复制一块副本来操作,bgsave子进程会将原来的内存数据写入到快照里。
RDB持久化的优点是文件体积小,但是缺点是操作比较耗时,有丢失数据的可能
第三种是混合持久化,使用rdb+aof的方式(修改配置文件aof-use-rdb-preamble yes开启),
混合持久化工作在 AOF 日志重写过程。当开启了混合持久化时,在 AOF 重写日志时,子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的操作命令会写入到 AOF 文件。也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。
混合持久化相对兼顾了两者的优点,不过文件可读性会较差一些,而且低版本redis不兼容
三种持久化方式有什么区别(优缺点)?
RDB 通过 fork 子进程在特定时间点对内存数据进行全量备份,生成二进制格式的快照文件。其最大优势在于备份恢复效率高,文件紧凑,恢复速度快,适合大规模数据的备份和迁移场景。缺点是如果第二次备份宕机的话,可能丢失两次快照期间的所有数据变更。
AOF 会记录每一条修改数据的写命令。这种日志追加的方式让 AOF 能够提供接近实时的数据备份,数据丢失风险可以控制在 1 秒内甚至完全避免。缺点是文件体积较大,恢复速度慢。
而混合持久化兼顾了两者的优点,使得数据恢复的效率大幅提升以及较为快速的数据备份。不过它的兼容性不好,在4.0之前版本都不识别该aof文件,因为前面部分是RDB格式,而且文件同时包含了两种格式,阅读性较差
Redis作为缓存有什么问题?(什么是缓存击穿、缓存穿透、缓存雪崩?)
redis作为缓存主要有四个问题,分别是缓存穿透、缓存击穿、缓存雪崩以及缓存一致性问题。
缓存穿透是指当用户访问的数据,既不在缓存中,也不在数据库中,导致出现大量
Redis不存在数据的请求落入DB,从而导致DB出现瓶颈或者直接被打宕机,整个系统陷入瘫痪。解决方案也有几种,对于恶意攻击我们可以做ip限流和非法校验,在redis中缓存一个空值或默认值,或者也可以使用布隆过滤器快速判断是否存在。缓存雪崩是指大量redis的key同时过期时或redis宕机,全部请求都直接访问数据库,从而导致数据库的压力骤增,从而导致DB出现瓶颈或者直接被打宕机。解决方案也有几种,针对大量key同时过期的问题,我们可以对热点数据设置永不过期,并对其余数据的过期时间加一个随机数,并且当发现数据过期时只开启一个线程去访问数据库构建缓存,保证数据库的请求不重复冗余。针对redis宕机的问题,我们可以采用redis集群,主节点宕机了就切换到从节点。或者采用服务熔断,直接暂停业务对缓存服务的访问返回错误,还可以启用请求限流机制,只将少部分请求发送到数据库,其他请求直接拒绝服务。
缓存击穿是指缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库导致大量请求落入DB。解决方案也有很多,可以设置热点数据永不过期,或者采用互斥锁,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
还有缓存一致性问题(下面说)
你如何让数据库和redis保持一致性的?
数据一致性问题主要是在于数据更新上,因为读操作若缓存未命中,只能去数据库读取然后更新到缓存里,当然这期间有其他对该key的访问可以返回一个空对象或阻塞解决。而对于写操作则需要考虑对缓存和数据库的更新,这里又有两个问题。
1、是更新缓存还是删除缓存?如果是更新缓存的话,如果遇上频繁更新但是一次读都没有的话,无效写操作过多,而删除缓存更快,等真正要查的时候再重建缓存,无效写较少。所以我采用删除缓存的方法
2、是先更新数据库还是先删缓存?如果先删缓存,那么如果同时有多个读操作,那么请求会直接打到数据库上,很有可能会造成缓存击穿问题。而如果先更新数据库,假设一个线程正由于查询未命中而写缓存时,另一个线程突然来了一个写请求,并更新数据库删除缓存,当然缓存本来就还没写所以就没删,写的是旧值,这时之前的线程再写缓存的话会出现脏读问题,但是实际上这种可能非常小,因为更新数据库的时间远大于写缓存的时间,所以这种脏读问题很难出现,所以综合考虑选择先操作数据库再删除缓存。
当然实际应用时,我们通常还会采用内存淘汰策略+超时删除作为兜底方案。
如果我对缓存一致性要求非常高,你有什么解决方案?(MQ)
缓存一致性我认为可以采取上面我说的先更新数据库再删缓存+内存淘汰和超时删除兜底的方案,它已经很大程度上保证了一致性。不过这里也存在一致性的问题,那就是删除缓存失败了怎么办?那很有可能会造成一段时间的脏读,所以我们还可以采取别的方案进行优化:
第一种是引入消息队列来保证缓存最终被删除,比如说在数据库更新的事务中插入一条本地消息记录,事务提交后异步发送给 MQ 进行缓存删除。即使缓存删除失败,消息队列的重试机制也能保证最终一致性。这种实现起来较为简单
第二种则是使用使用 Canal 监听 MySQL 的 binlog,在数据更新时,将数据变更记录到消息队列中,消费者消息监听到变更后去删除缓存。这种方案完全解耦了业务代码和缓存维护逻辑。不过实现起来较为复杂,而且链路更长,可能延迟略高一点,但能保证强一致性
(具体流程为Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送请求,MySQL 收到请求后就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后投递消息给mq,缓存消费者从MQ中拉取到消息删除缓存)
redis高可用了解吗?
redis高可用就是解决单台redis的性能瓶颈问题,通常包括主从架构、哨兵机制、cluster集群。
主从复制了解吗?
主从复制允许从节点维护主节点的数据副本。在这种架构中,一个主节点可以连接多个从节点,从而形成一主多从的结构。主节点负责处理写操作,从节点自动同步主节点的数据变更,并处理读请求,从而实现读写分离。
具体原理是这样的:
首先是第一次同步,我们使用replicaof命令后会进行,第一次同步的过程可分为三个阶段:
第一阶段是建立链接,主节点收到连接请求后会建立连接和复制缓冲区,准备进行全量同步,这里会涉及到两个关键的参数,runId和offset,runID用来表示数据集,同步后主从节点为同一数据集,而offset是偏移量,判断同步到了什么位置; 第二阶段是同步数据,主节点fork 一个子进程生成 RDB 文件,同时将文件生成期间收到的写命令缓存到复制缓冲区。然后将 RDB 文件发送给从节点,从节点清空自己的数据并加载这个 RDB 文件; 第三阶段是主服务器发送新写操作命令给从服务器, RDB 传输完成后,主节点再将缓存的写命令发送给从节点执行,确保数据完全一致。至此第一次同步完成
第一次同步完成后双方会维持一个TCP长连接,后续主服务器可以通过这个连接继续将写操作命令传播给从服务器,然后从服务器执行该命令,使得与主服务器的数据库状态相同。
在此期间,如果从节点过多可能导致主节点生成RDB文件和传输RDB文件的开销过大,从而无法正常处理请求,那么我们还可以使用replicaof命令,安排从节点把数据同步给自己旗下的从服务器,从而减轻主服务器的负担
如果主从节点在完成第一次同步后的网络连接断开了,这时从节点的数据就没办法和主节点保持一致了,客户端就可能从从节点读到旧的数据。如果连接恢复后还进行全量复制的话开销太大,所以只进行增量同步。
增量同步的过程主要是两步:
首先从节点发送同步请求,主节点根据runId判断要进行增量同步,回复一个continue,然后主节点会根据offset来发送后面的数据。
不过这里offset传输是通过一个环形缓冲区来实现的,当缓冲区写满后,主服务器继续写入的话,就会覆盖之前的数据。这样在恢复连接后就会进行全量同步的方式,从而降低了性能。所以我们应该调整缓冲区大小,尽可能的大一些,避免后续的全量同步。
不过主从架构也有问题,如果master宕机了,故障恢复不好处理,所以又有了哨兵机制。
哨兵机制了解吗?
哨兵机制的作用是实现主从节点故障转移。它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。
哨兵一般是以集群的方式部署,至少需要 3 个哨兵节点,哨兵集群主要负责三件事情:监控、选主、通知。
哨兵节点通过 Redis 的发布者/订阅者机制相互连接组成哨兵集群,同时哨兵又通过 INFO 命令,在主节点里获得了所有从节点连接信息,于是就能和从节点建立连接,并进行监控了。
核心工作流程主要分三步:
1、第一轮投票:判断主节点下线
当哨兵集群中的某个哨兵判定主节点下线(主观下线)后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。
当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」。
2、第二轮投票:选出哨兵 leader
某个哨兵判定主节点客观下线后,该哨兵就会发起投票,告诉其他哨兵它想成为 leader,想成为 leader 的哨兵节点要满足两个条件:
第一,拿到半数以上的赞成票; 第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。这里候选leader优先投票给自己,quorum的值最好设置为哨兵数量一半加1,不过如果哨兵节点挂掉太多的话就需要人为介入了。
3、由哨兵 leader 进行主从故障转移
选举出了哨兵 leader 后,就可以进行主从故障转移的过程了。该操作包含以下四个步骤:
第一步:在已下线主节点属下的所有从节点里面,挑选出一个从节点,并将其转换为主节点,选择的规则是先过滤掉已经离线、历史网络连接状态不好的从节点; 将剩下的从节点,进行三轮考察:优先级、复制进度、ID 号。在每一轮考察过程中,如果找到了一个胜出的从节点,就将其作为新主节点。 第二步:让已下线主节点属下的所有从节点修改复制目标,修改为复制新主节点; 第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端; 第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点;
不过哨兵也有问题,它没有解决数据集过大时,Redis单个节点无法承载的问题。所以又有了cluster集群
redis cluster了解吗?
Redis Cluster 是 Redis 官方提供的一种分布式集群解决方案。其核心理念是去中心化,没有中心节点的概念。每个节点都保存着数据和整个集群的状态,当需要存储或查询一个键值对时,Redis Cluster 会先计算这个键的哈希槽编号,然后根据哈希槽编号找到对应的节点进行操作。
Redis 会有16384个插槽,分别分配给不同的 master 节点
存入 Redis 的数据,key 是与插槽绑定的,而不是与节点绑定。Redis 会根据 key 的有效部分计算插槽值,以此判断 key 是和哪个插槽绑定的。当要写入数据或读取数据时,通过知道key对应的插槽值,就可以找到对应的Redis节点。
这种插槽方案保证数据的均匀分布,并且能快速查找redis节点。
而节点之间的状态通过心跳机制来确认,加入新结点时重新分配插槽,实现了这种多主多从架构。
什么是热key?怎么处理热key?
所谓的热 Key,就是指在很短时间内被频繁访问的键。比如电商大促期间爆款信息,优惠券秒杀等,都可能成为热Key。
由于 Redis 是单线程模型,大量请求集中到同一个键会导致该 Redis 节点的 CPU 使用率飙升,响应时间变长。在 Redis 集群环境下,热Key 还会导致数据分布不均衡,某个节点承受的压力过大而其他节点相对空闲。
针对热key通常有三种方法:
首先是读写分离,读请求走多个从节点,但如果只是少量热key而加节点,运维成本很高
其次是本地缓存,比如java的caffeine,但是具体哪个key会成为热点我们无法准确判断,只能预测一下。
最后则是京东的hotkey探查框架,用一组中间件夹在redis和客户端之间。采用worker集群来计算监听热key,worker集群用监控中心维护,后台服务从配置中心获取worker信息,worker集群将计算的热Key发给后台服务,让它使用本地缓存不走redis,从而减轻压力
(比sdk方案好,代码侵入少,易于维护,而且减少实例自己计算的开销,保证高可用)
什么是大key?怎么处理大key?
大Key 是指占用内存空间较大的缓存键,比如超过 10M 的键值对,value可能有大字符串或过多元素
在内存有限的情况下,可能导致 Redis 内存不足、操作阻塞i。另外,大Key 还会导致主从复制同步延迟,甚至引发网络拥塞。
对于大 Key 问题,最根本的解决方案是拆分大 Key,将其拆分成多个小 Key 存储。比如将一个包含大量用户信息的 Hash 拆分成多个小 Hash。
(删除的时候使用unlink异步删除,避免影响主线程)
内存淘汰策略了解吗?
Redis 内存淘汰策略共有八种
1、不进行数据淘汰的策略(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,会报错通知禁止写入,不淘汰任何数据
2、进行数据淘汰的策略
又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。
volatile-random:随机淘汰设置了过期时间的任意键值;
volatile-ttl:优先淘汰即将过期的键值。
volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值; 在所有数据范围内进行淘汰:
allkeys-random:随机淘汰任意键值;
allkeys-lru:淘汰整个键值中最久未使用的键值;
allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。
(2lru\2lfu\2random\1ttl\1no)
通常建议使用allkeyslru或allkeyslfu这类策略,因为理论上所有数据都可淘汰,这样就是防止内存写满导致服务不可用。
过期淘汰策略了解吗?
常见的三种:定时删除、惰性删除、定期删除
定时删除策略的做法是,在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。key多的时候对cpu不友好
惰性删除策略的做法是,不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。容易造成了一定的内存空间浪费
定期删除策略的做法是,每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。它时空资源利用率介于二者之间,但是极不稳定
所以, Redis 选择「惰性删除+定期删除」这两种策略配和使用
spring
谈谈spring
Spring 是一个 Java 后端开发框架,其最核心的作用是帮我们管理 Java 对象,其最重要的功能有两个,分别是IOC和AOP。
IOC和AOP介绍一下
IoC:即控制反转的意思,它是一种创建和获取对象的技术思想,依赖注入(DI)是实现这种技术的一种方式。传统开发过程中,我们需要通过new关键字来创建对象。使用IoC思想开发方式的话,我们不通过new关键字创建对象,而是通过IoC容器来帮我们实例化对象,并进行Bean对象生命周期的管理,可以大大降低对象之间的耦合度,因为使用者不用感知具体实现对象,只使用它上层的接口,接口具体实现根据场景去调整。
AOP:是面向切面编程,能够将那些共同调用的逻辑封装起来,比如日志,以减少系统的重复代码,降低模块间的耦合度。Spring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理。
Bean的作用域有哪些?
Singleton(单例):在整个应用程序中只存在一个 Bean 实例。默认作用域,Spring 容器中只会创建一个 Bean 实例,并在容器的整个生命周期中共享该实例。
Prototype(原型):每次请求时都会创建一个新的 Bean 实例。每次从容器中获取该 Bean 时都会创建一个新实例。
如果是基于spring的web应用,那么还会有两种作用域
Request(请求):每个 HTTP 请求都会创建一个新的 Bean 实例。适用于 Web 应用中需求局部性的 Bean。
Session(会话):Session 范围内只会创建一个 Bean 实例。适用于与用户会话相关的 Bean。
Resourse和Autowired的区别?
首先从来源上说,
@Resource是 JDK 自带的,而@Autowired是 Spring 特有的。从注入方式上说,
@Autowired默认按照类型,也就是 byType 进行注入,没有再byName,而@Resource默认按照名称,也就是 byName 进行注入,没有再按照byType。除此之外,Resource 只可以使用在 字段,Setter 方法上,不支持构造器注入
springboot的启动流程说说
Spring Boot 的启动由 SpringApplication 类负责:
首先main方法开始run,加载METAINF下配的初始化器和监听器
然后创建应用程序上下文和bean工厂对象
接着就是核心refresh方法,这里会先注册实例化关键的bean,比如beanpostprocessor\beanfactorypostprocessor等,然后注册实例化发布启动完成事件 ApplicationReadyEvent,并调用 ApplicationRunner 的 run 方法完成启动后的逻辑。
Springboot自动装配了解吗?
在 Spring Boot 中,开启自动装配的注解是
@EnableAutoConfiguration。这个注解会告诉 Spring 去扫描所有可用的自动配置类。Spring Boot 为了进一步简化,把这个注解包含到了
@SpringBootApplication注解中。也就是说,当我们在主类上使用@SpringBootApplication注解时,实际上就已经开启了自动装配。
SpringMVC的处理流程
Spring MVC 是一个基于 Servlet 的请求处理框架,核心流程可以概括为:请求接收 → 路由分发 → 控制器处理 → 视图解析。
用户发起的 HTTP 请求,首先会被 DispatcherServlet 捕获
DispatcherServlet 接收到请求后,会根据 URL、请求方法等信息,交给 HandlerMapping 进行路由匹配,查找对应的处理器,也就是 Controller 中的具体方法。
找到对应 Controller 方法后,DispatcherServlet 会委托给处理器适配器 HandlerAdapter 进行调用。处理器适配器负责执行方法本身,并处理参数绑定、数据类型转换等。在注解驱动开发中,常用的是 RequestMappingHandlerAdapter。这一层会把请求参数自动注入到方法形参中,并调用 Controller 执行实际的业务逻辑。
Controller 方法最终会返回结果,比如视图名称、ModelAndView 或直接返回 JSON 数据。
当 Controller 方法返回视图名时,DispatcherServlet 会调用 ViewResolver 将其解析为实际的 View 对象,比如 Thymeleaf 页面。在前后端分离的接口项目中,这一步则通常是返回 JSON 数据。
最后,由 View 对象完成渲染,或者将 JSON 结果直接通过 DispatcherServlet 返回给客户端。
spring有哪些常用注解?
依赖注入方面,
@Autowired是用得最多的,可以标注在字段、setter 方法或者构造方法上。@Qualifier在有多个同类型 Bean 的时候用来指定具体注入哪一个。@Resource和@Autowired功能差不多,不过它是按名称注入的。AOP 相关的注解,
@Aspect定义切面,@Pointcut定义切点,@Before、@After、@Around这些定义通知类型。配置方面有Configuration、Bean、Value
事务由Transactional
MVC方面:requestMapping、RestController、RequestBody
@SpringBootApplication = 三个注解合体:
@SpringBootConfiguration (配置类)@EnableAutoConfiguration (自动装配)@ComponentScan (注解包扫描)
Spring怎么解决循环依赖
Spring 通过三级缓存机制来解决循环依赖:
一级缓存:存放完全初始化好的单例 Bean。
二级缓存:存放提前暴露的 Bean,实例化完成,但未初始化完成。
三级缓存:存放 Bean 工厂,用于生成提前暴露的 Bean。
Spring 中 Bean 的创建过程其实可以分成两步,第一步叫做实例化,第二步叫做初始化。
实例化的过程只需要调用构造函数把对象创建出来并给他分配内存空间
而初始化则是给对象的属性进行赋值
而 Spring 之所以可以解决循环依赖就是因为对象的初始化是可以延后的,
也就是说,当我创建一个Bean ServiceA的时候,会先把这个对象实例化出来,然后再初始化其中的serviceB属性。而当一个对象只进行了实例化,但是还没有进行初始化时,我们称之为半成品对象。所以,所谓半成品对象,其实只是 bean 对象的一个空壳子,还没有进行属性注入和初始化。当两个Bean在初始化过程中互相依赖的时候,如初始化A发现他依赖了B,继续去初始化B,发现他又依赖了A。
此时的解决流程(以A依赖B,B依赖A为例)
创建Bean A:实例化A(调用构造函数),将A的ObjectFactory存入三级缓存。填充A的属性:发现需要注入B,触发B的创建。创建Bean B:实例化B,并将B的ObjectFactory存入三级缓存。填充B的属性:发现需要注入A,此时从三级缓存获取A的ObjectFactory,生成早期对象并移至二级缓存。B完成初始化:B的早期对象注入A后,B继续完成属性填充和初始化,最终存入一级缓存。A完成初始化:A获取到B实例后完成初始化,存入一级缓存,移除二、三级缓存中的记录。
说说Spring的事务
Spring 提供了两种事务管理方式,编程式事务和声明式事务。编程式事务就是我们要手动调用事务的开始、提交、回滚这些操作,虽然灵活但是代码比较繁琐。声明式事务只需要在需要事务的方法上加上
@Transactional注解就好了,Spring 会帮我们自动处理事务的整个生命周期。
Transactional在哪些情况下会失效?
第一种,
@Transactional注解用在非 public 修饰的方法上。Spring 的 AOP 代理机制决定了它无法代理 private 方法。因为 private 方法在子类中是不可见的,代理类无法覆盖它。第二种,方法内部调用。如果在一个类的方法 A 中,直接调用本类的另外一个加了 @Transactional 的方法 B,那么方法 B 的事务是不会生效的。因为这里实质调用的是this对象,不是代理对象
第三种,如果在事务方法内部用 try-catch 捕获了异常,但没有在 catch 块中将异常重新抛出,或者抛出一个新的能触发回滚的异常,那么 Spring 的事务拦截器就无法感知到异常的发生,也就没办法回滚。
第四种,Spring 事务默认只对 RuntimeException 和 Error 类型的异常进行回滚。如果在代码中抛出的是一个Checked Exception,又没有人为指定事务回滚的异常类型,那么事务同样不会回滚。
事务的传播机制说说
Spring 定义了七种事务传播行为
其中 REQUIRED 是默认的传播行为,表示如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。
SUPPORTS则是支持当前事务,如果当前存在事务就加入,如果不存在则以非事务方式执行,适用于那些不需要强事务保证但可以参与现有事务的操作。
MANDATORY要求必须存在一个当前事务,否则抛出异常,用于强制方法必须在事务上下文中被调用。
REQUIRES_NEW总是会启动一个新的事务,如果当前存在事务则将其挂起,新事务与原有事务完全独立,互不影响,适用于需要独立提交或回滚的场景。
NOT_SUPPORTED以非事务方式执行操作,如果当前存在事务则将其挂起,等该方法执行完毕后再恢复原事务,用于排除某些不需要事务管理的方法。
NEVER严格要求不能在事务中执行,如果当前存在事务则抛出异常,确保方法不会在事务上下文中被调用。
NESTED如果当前存在事务,则在嵌套事务内执行,嵌套事务可以独立提交或回滚,但外层事务回滚会导致嵌套事务也回滚,这种机制提供了更细粒度的事务控制。
SpringBoot优雅停机
只需在application.properties 文件中添加一行代码
1
server.shutdown=gracefu
Spring 6.0新特性
AOT指的是在程序运行前编译,这样就可以避免在运行时的编译性能消耗和内存消耗,可以在程序运行初期就达到最高性能、也可以显著的加快程序的启动。
Spring的设计模式
单例模式:默认情况下,Spring IOC 容器中会确保每个 Bean 只有一个实例,该实例在整个应用中被共享使用。好处:对象复用,避免了频繁创建和销毁对象的开销,减少内存占用和 GC 压力
工厂模式:
Spring IOC 容器就像是一个工厂,封装了创建对象的细节
我们通过(id或name或类型)getBean 方法从工厂(容器)中获取对象
模板方法模式:
工厂里使用了。
kafka
kafka基础概念
架构:
Broker:一个kafka的集群通常由多个Broker组成,这样方便负载均衡。而且他们都是无状态的,状态通过zookeeper集群来维护。
Zookeeper:管理和协调Broker,存储了kafka的元数据,通知哪些broker可以用。
Topic:主题就是发布订阅走的具体通道
Partition:一个主题被分成多个partition,一个topic的消息会追加写入某个分区,每个分区在不同的Broker上,而且每个分区还有自己的副本,也在不同的broker上,提高容错率和消费能力。
Offset:Offset 是 Kafka 为每条消息分配的一个唯一的编号,它表示消息在分区中的顺序位置。Offset写在了consumer_offsets 主题的某个分区上,通过指定 offset,消费者可以准确地找到分区中的某条消息,或者从某个位置开始消费消息。
消费者组:同一个消费者组不能重复消费消息,但不同消费者组可以。
一般消费组内消费者的数量应该等于Partition的数量;多了会空闲
但是如果需要消费的任务压力不大。也可以是第二种情况,即消费者的数量小于Partition数量。
为什么选择kafka?跟其他的比呢?
我们说常用的消息队列有三个,rabbitmq、rocketmq、kafka,他们各有优劣。
首先是使用场景不一样,rabbitmq通常适用于中小型规模场景,rocketmq适用于一些分布式事务场景,kafka则适用于一些大规模数据处理。简单来说kafka的吞吐量更高一些,rocketmq和rabbitmq则在消息可靠性方面做的不错。
而我之前参与的项目都适用了Kafka,我觉得有以下一些原因吧
首先就是rag中文件上传后构建的任务较多,kafka更适用于大规模数据场景,所以选择它。
不过我根据这个项目的需求来看其实使用的时候并发量不大,因为是内部使用,几十几百qps通常顶天了,所以我认为之前他们选择kafka更多的原因是之前的业务也是采用kafka的,效果不错,而且我们的负责人好像也都是kafka用的多一些,而且kafka的技术社区活跃度最高,通常有很多不同的解决方案可以查看,所以选择了kafka。
kafka(消息队列)有什么作用?
消息队列的作用主要就三个:异步、解耦、削峰
首先是异步:如果我们一个线程要发邮件什么的话,使用同步方案通常会阻塞主线程,降低系统的性能和响应时间,而我们使用消息队列异步处理,专门安排一些线程去做就不过影响主线程,用户体验也会好一些。
然后是解耦:我们说复杂的系统如果直接调用api,那后续的维护可能会比较麻烦,模块间的耦合比较高。而如果采用消息队列,那么我们无论是调用方还是被调用方都不需要关心关联的另一个模块是谁,只负责去消息队列里拿,就算修改也只需要改本模块就行。
最后是削峰:如果我们遇到请求量非常大的时候,来不及处理,那么我们可以把请求放入消息队列里,后面再慢慢处理,这样虽然无法满足大量请求的快速响应,但是能比较好的提高系统稳定性和可靠性。
kafka自动提交了解吗?
我们说kafka用offset来表示消费者消费到哪条消息了,而自动提交就是主动告诉kafka我消费到哪里了,这个只需要开启配置就行,默认每5秒提交一次。
(kafka同时维护消费的offset和已提交的offset,如果消费者故障可以重新从上次提交的offset处消费)
ISR了解吗?
ISR是机制定义,主要是指与leader保持同步的follower副本集合,因为kafka里每个分区都有副本来保证消息的不丢失,而这些follower会不断拉取leader数据,如果延迟高的话会被踢出ISR集合,如果重新跟上的话再拉入ISR。
这个ISR主要有三个作用
首先就是保证节点容灾,如果leader挂了,那么kafka会从它的ISR集合挑选一个作为新leader,保证数据不丢失以及快速恢复。
然后就是保证follower可靠,因为如果延迟过大的话那么它是不适合作为leader的,所以ISR这种机制保证follower可靠。
最后就是根据参数调整我们对可靠性的需求,我们可以设置不同的ack来更改,比如设置all来保证消息的完全可靠,也就是副本完全备份好leader的消息才认为消息提交成功。
(0,1:立即发、等leader确认)
rebalance机制
rebalance是重平衡机制,如果消费者变了或者topic变了会触发。
这时候会暂停所有消费者,重新分配每个消费者订阅的topic和分区。
这种暂停与jvm的STW差不多,会非常影响性能,生产环境要尽量避免rebalance,比如延长心跳超时时间,不让kafka误以为消费者挂了,不过实际还是要根据情况设置,兼顾性能和安全。
如何避免消息不丢失?
主要从三方面展开吧:
生产者方面:使用带回调的api,并且等消息被副本同步后才认为成功,同时可以设置重试次数
broker方面:配置适当数量的副本,依靠ISR机制保证消息存储不丢失
消费者方面:设置手动提交,则可以在业务线程完成任务后,手动提交offset,保证不少消费消息。
如何保证消息不重复消费
这主要从两方面吧:
首先是生产者端:启用幂等性来避免重复消费,这样kafka会自动去重避免重复
然后是消费者端:如果提交offset宕机了可能也会重复消费,所以要维护一个已处理消息的标识,使用MySQL 存储已处理的消息 ID。而且最好也安排一个重试机制,失败后发往死信队列。
消息堆积怎么办?
这也从三个方面考虑:
首先生产者端:可以适当控制限流,避免生产太快
然后是broker,适当调整分区数量,提高业务并行处理能力,不过分区增多会增加管理开销,需要谨慎处理
最后是消费者端:这里可以看看offset是否正确提交,消费者数量是否适当,代码逻辑能不能优化,主要就这几点吧
怎么实现消息顺序消费?
多个消息 如果写入同一个 Topic 的不同 partition 分区,由于不同 partition 分区消息被消费的速度是不可预知的,所以此时无法保证消息被顺序消费,
因此多个消息需要写入同一个 Topic 的 同一个 partition 分区,因为在单个分区内,消息是有序的,同时需要保证每个 partition 分区由一个消费者单线程消费,确保单个分区内的消息按顺序处理。如此,即可确保消息顺序消费。
微服务
幂等
幂等问题关键在于请求的消息是否精确一次发送到远端服务的问题,因为网络可能发生丢包或者远程服务故障。
如果我们认为这是一个临时的故障,对请求进行重试,那么可能会出现多次执行的情况,如果不进行重试,就可能会出现一次都没有执行的情况,这样都对用户的体验有影响。
主要有两种解决方案:
1、增加消息幂等性:每一次请求都包含一个唯一消息ID,这样哪怕有哪次发送出错,只要到达一次就能保证正确执行并只执行一次。
2、分布式快照回滚:我们定期对系统做快照,出错了就回滚。但这种方案不适用于在线业务,会大量消耗资源。如果系统架构设计包括系统状态的存储,那么可以考虑这种方案。
这样来看增加消息幂等性貌似更加通用,但是在执行重试策略的过程中,我们要避免重试导致的系统雪崩的问题。所以需要限制重试的次数和重试的间隔时间。
服务雪崩:
雪崩是由于局部故障被正反馈循环,从而导致的不断放大的连锁故障,通常会率先出现服务能力过载的现象,从而导致服务资源耗尽不可用,导致调用方同样出现请求积压耗尽资源,最终产生雪崩。
想要避免系统雪崩,要么通过快速减少系统负载,即熔断、降级、限流等快速失败和降级机制;要么通过快速增加系统的服务能力来避免雪崩的发生,即弹性扩容机制。
熔断
计算机领域的熔断通常有三种状态,正常运行是闭合状态,同时会记录特定错误码,达到一定比例就变成断开状态;断开状态时就直接返回错误(或降级的结果),同时启动一个计时器,到达时间时会切换为半打开状态;此时会发少量请求检测后台服务恢复了没,没有就回到断开状态循环,如果恢复了就切换到闭合状态。这里面将服务由于过载原因导致的错误比例,作为熔断器断开的阈值。
而实现熔断机制通常有一些关键点:首先就是粒度,通常熔断范围选择某个实例的接口,这样熔断的敏感度高且不容易误伤,而且熔断机制消耗的资源不多,通常也能接受。;
还有就是范围,只要是过载问题的场景都可以上
降级 :限流算法
熔断是防止雪崩的一种被动机制,而我们可以主动的去保证,那就是用限流。
限流算法常见的有四个:固定窗口、滑动窗口、漏桶和令牌桶。
1、固定窗口是把时间切成固定长度的窗口,该窗口内请求超过阈值就会触发限流。但是这种会有整点突刺问题,刚好到下一个窗口的瞬间请求满了,那后续时间片内所有请求都会被限流
2、滑动窗口在此基础之上更加平滑,统计「当前时间往前推 一段时间」内的总请求数,也就是窗口更小并且是滚动窗口。这是比较常用的算法,但仍有抗抖动性差的问题
3、漏桶则又有了改进,它增加了一个桶来缓存流量,并对出口的流量做了限制,但流出速率一般会设置得相对保守,可是这样就无法完全利用系统的性能,就增加了请求的排队时间
4、令牌桶则在此之上又进行了改进,它是在桶里不断放入令牌,程序要获取令牌才能执行,只要令牌生成速率>=令牌获取速率,那这个系统处理能力是比较高的。
不过令牌桶放弃了流量整形的能力,如果流量激增很可能会导致上游服务影响下游服务。所以我们说一个方案通常是有得必有失,我们要根据情况和需求来选择
限流实现 (客户端指上游服务,服务端指被调用方)
实现主要分为单节点限流和多节点限流。
单节点限流:单节点限流我们一般要在服务端做限流处理,不过这里要考虑触发限流后是直接抛弃还是阻塞等待,如果是在线业务,那流量通常是不可控的,很可能极高,所以为了安全,我们应该抛弃保证服务尽量不崩;而如果是后台任务,那我们会同时对上下游业务都做一个优化,这时的流量比较可控,所以我们可以阻塞等待,进行限流
多节点限流:这里主要是一个服务我们会运行多个实例,我们该如何协调他们进行统一的限流,如果我们考虑引入一个外部服务的话,那这个外部服务就会成为限流瓶颈,而且增加了调用的时延,所以一般是做一个本地化处理,设定一个总的限流阈值,按照实例性能分配,但是这也有可能有问题,恰好请求都发往一个实例,看似流量不多但却把这个实例打到限流了,这样也不好。但我们可以折中一下,安排一个外部服务限流器存总令牌数,每个实例配一个小的令牌桶,正常先用实例桶里的令牌,不够了再去限流器取令牌,这样又接近集中式处理,又能近似是本地处理,效果好一些。
不过限流机制还有一些问题我们需要思考,首先是确定阈值,这个我们通常根据经验或者压测来去设置,不过后续还要根据实际去迭代。还有就是如果系统遍布限流可能会变得很脆弱,所以我们通常在核心服务,比如网关这些做限流,其他不启用,等出故障再启动。
扩容
熔断限流降级都是牺牲用户体验来保证系统稳定性的,而扩容就是一种接近无损的方式来保证系统正常运行的。
而具体一般是采用监控来动态扩容
对于监控什么时候扩容,我们可以参考服务的qps,设定一个阈值,或者看看cpu或内存使用达到一定比例来判断,而具体实现的话可以使用云原生来自动扩容。
监控
监控之于分布式系统,更甚于仪表盘之于汽车,因为分布式系统的内部更加复杂,更容易出现意外的情况
CAP定理:
C 一致性:每次读取都是最新的,所有节点同一时刻看到的数据一样
A 可用性:每个请求都会收到响应,服务一直可用
P 分区容错性:发生网络分区或节点故障依然能用
由此推出,分布式系统满足p但必须在ac之间2选1
CP系统:如zookeeper,优先保证一致性,发生网络分区时停止写入服务,避免数据不一致
AP系统:redis,优先保证可用性,允许每个分区继续接受读写请求,后续再恢复数据一致。
通常金融、支付分布式系统选择满足CP,社交内容分布式系统选择满足AP
Base理论
BASE 理论是对 CAP 理论的延伸,强调通过放松一致性(Consistency)的要求,换取分布式系统的高可用性和良好的性能。核心思想是虽然无法做到强一致性 (CAP的一致性就是强致性),但应用可以采用适当的方式达到最终一致性(Eventual Consitency)。
BASE 是指
- 基本可用(Basically Available):出故障了还能用,可能响应时间长点
- 软状态(Soft State):允许数据出现中间不一致
- 最终一致性(Eventual Consistency):在一定期限达到最终一致性
全局分布式ID
Spring Cloud 其实是一套基于 Spring Boot 的微服务全家桶,帮我们把分布式系统里的基础设施做了一个“拿来即用”的封装,比如服务注册与发现、配置管理、负载均衡、熔断限流、链路追踪这些。
我自己用得比较多的是 Spring Cloud Alibaba 这一套,比如:
- 我们使用 Nacos 做服务注册和配置中心,并且将配置信息持久化到了 MySQL 中,这样就可以统一管理注册信息和配置信息,还支持动态刷新配置。
- 使用 Gateway 做 API 网关,支持路由转发、全局过滤器、限流等功能。
- 使用 Sentinel 做熔断、限流、降级策略,结合业务自定义规则比较方便。
- 使用 OpenFeign 做服务间的声明式调用,比 RestTemplate 更省代码,也更清晰可维护。
- 使用 Seata 处理分布式事务,这个在订单、支付、审批流场景中用得比较多。
计算机网络
OSI模型和TCP/IP模型介绍一下,五层模型又是什么?
OSI 是理论上的网络通信模型,TCP/IP 是实际应用层面上的网络通信模型
OSI模型包括7层:
应用层:负责给应用程序提供统一的接口
表示层:负责数据格式转换
会话层:维护通信会话
传输层:负责端到端的传输
网络层:负责主机到主机的传输
数据链路层:进行数据的封装、寻址
物理层:在物理链路中进行传输
TCP/IP模型包括四层:
应用层 :支持HTTP、DNS(SMTP、FTP)等协议,为用户提供应用功能
传输层:负责端到端的传输(TCP、UDP协议)
网际层:实现主机到主机的传输,依靠于IP协议
网络接口层:将数据封装成帧发送到网络上
OSI 是理论上的网络通信模型,TCP/IP 是实际应用层面上的网络通信模型,五层结构是为了方便理解和教学
五层结构把四层结构的网络接口层拆为数据链路层和物理层
数据链路层:确保从一个节点到另一个节点的可靠传输,会将数据封装成帧。交换机、网桥是数据链路层设备。
物理层:实际传输经过的设备,比如电缆、光纤等。
地址栏输入url到显示网页的过程了解吗?
解析url:分析 URL 所需要使用的传输协议和请求的资源路径,生成http请求
DNS 解析:然后获取域名对应的IP地址,会去浏览器缓存和系统缓存里判断是否存在对应的ip地址,如果不存在,浏览器会发起一个 DNS 请求到本地 DNS 服务器,将域名解析为服务器的 IP 地址,如果本地DNS服务器没有则递归去根、顶级、权威域名服务器去查。(DNS协议)
TCP 连接:接着浏览器会通过解析得到的 IP 地址与服务器建立 TCP 连接。这一步涉及到 TCP 的三次握手,用于确保双方都已经准备好进行数据传输了。(TCP协议、IP协议、ospf协议、arp协议)
IP报文:传输层完了到达网络层,添加IP头部
MAC头部:然后到达数据链路层,添加源MAC和目的MAC地址构建MAC头部并添加,目标MAC地址通过ARP协议广播获取。
物理层链路:通过网卡交换机和路由器,逐渐到达web服务器,
服务器响应:服务器检查各层报文的正确性,然后根据TCP的目的端口号找到对应进程构建响应报文发给客户端,
浏览器接收 HTTP 响应:浏览器接收到服务器返回的 HTTP 响应数据后,开始解析响应体中的 HTML 内容,最终渲染页面。
断开连接:最后进行TCP 四次挥手,连接结束。
Get和Post有什么区别
GET 请求主要用于获取数据,参数附加在 URL 中,存在长度限制(不同浏览器限制不一样,FireFox 中 URL 的最大长度限制是 65536 个字符),且容易被浏览器缓存,有安全风险;而 POST 请求用于提交数据,参数放在请求体中,适合提交大量或敏感的数据。
另外,GET 请求是安全且幂等的,多次请求不会改变服务器状态;而 POST 请求不是安全且幂等的,可能对服务器数据有影响,所以浏览器一般不会缓存 POST 请求。
(幂等:执行多次相同的操作结果一样)
不过这都是理论上的区别,如果你实际用get来进行删除内容操作,那肯定也不是安全和幂等的,如果用post来查询,那也是安全且幂等的。所以是否安全和幂等还要看具体操作。
HTTP的常见的状态码
HTTP 状态码分为 5 大类
1xx 类状态码属于提示信息,实际用到的比较少。
2xx 类状态码表示服务器成功处理了客户端的请求
3xx 类状态码表示客户端请求的资源发生了变动,需要客户端用新的 URL 重新发送请求获取资源,也就是重定向。
4xx 类状态码表示客户端发送的报文有误,服务器无法处理,也就是错误码的含义。
5xx 类状态码表示客户端请求报文正确,但是服务器处理时内部发生了错误,属于服务器端的错误码。
状态码可以由1-5开头的部分三位数,常见的有:
200:表示成功,301:永久重定向,302:临时重定向
403 服务器禁止访问资源,并不是客户端的请求出错。
404:无法找到该页面,405:方法类型不支持,500:服务器内部出错
(2:成功,3:重定向问题,4:客户端报文问题,5:服务端问题)
http1.0\1.1\2.0有什么区别?
HTTP1.0 默认是短连接,HTTP 1.1 默认是长连接,HTTP 2.0 采用的多路复用。
HTTP1.0:
无状态协议:HTTP 1.0 是无状态的,每个请求之间相互独立,服务器不保存任何请求的状态信息。
非持久连接:默认情况下,每个 HTTP 请求/响应对之后,连接会被关闭,属于短连接。这意味着对于同一个网站的每个资源请求,如 HTML 页面上的图片和脚本,都需要建立一个新的 TCP 连接。(可以设置
Connection: keep-alive强制开启长连接。)HTTP1.1
持久连接:HTTP 1.1 引入了持久连接(也称为 HTTP keep-alive),默认情况下不会立即关闭连接,可以在一个连接上发送多个请求和响应。极大减轻了 TCP 连接的开销。
流水线处理:HTTP 1.1 支持客户端在前一个请求的响应到达之前发送下一个请求,以提高传输效率。不过依然存在响应的队头阻塞问题,所以不常用这个功能。
而且1.1依然是用的明文传输和无状态,所以安全性方面不好,状态方面可以考虑再使用cookie来解决
Http2.0
多路复用:一个 TCP 连接上可以同时进行多个 HTTP 请求/响应,因为它将每个请求 / 响应拆成二进制帧,所有帧通过同一个 TCP 连接传输,帧头部携带 流 ID标识归属的请求。解决了 HTTP 1.1 的队头阻塞问题。
头部压缩:HTTP 协议不带状态,所以每次请求都必须附上所有信息。HTTP 2.0 引入了头部压缩机制,可以压缩后再发送,减少了冗余头部信息的带宽消耗(比如说host\User-Agent这些名字是冗余的)。
服务端推送:服务器可以主动向客户端推送资源,而不需要客户端明确请求,当然这前提肯定是已经建立了连接才行。
HTTP3.0了解吗?
3.0是基于QUIC协议的,QUIC又是基于UDP的,所以连接建立耗时短,
HTTPS了解吗?
HTTPS 解决了 HTTP 不安全的缺陷,在 TCP 和 HTTP 网络层之间加入了 SSL/TLS 安全协议,这其中涉及了非对称加密和对称加密两种加密方式,使得报文能够加密传输。
而TLS握手主要有四次。(HTTPS 默认端口号是 443。)详细流程如下(基于rsa算法,不同的算法过程不太一样):
客户端与服务器先通过tcp三次握手建立连接
然后客户端发送加密通信请求,会将客户端随机数、密码套件、TLS版本等信息发过去
服务器收到后会确认tls版本是否支持,并选择一个密码套件再生成一个随机数发过去,然后服务端还会发送数字签名。
(数字签名生成流程:首先 CA 会把持有者的公钥等信息打成一个包,然后对这些信息进行 Hash 计算, 然后 CA 会使用自己的私钥将该 Hash 值加密添加在文件证书上,形成数字证书;)
客户端收到后,使用本地的CA公钥确认数字证书的真实性,取出服务器的公钥再加密生成新的随机数发送给服务端,而后续的报文都会被这三个随机数生成的对称密钥加密,不过第三次握手客户端还会发送一个加密报文验证加密是否可用。
服务端收到后会告诉客户端是否加密解密成功,如果成功就算建立了连接。
udp和tcp有什么区别?
1、连接:tcp是面向连接的,而udp是不需要连接直接传输的。
2、服务对象:tcp只支持1对1传输,而udp还支持一对多、多对多的通信。
3、可靠性:tcp是可靠交付数据的,而udp是尽最大可能交付,通常是不可靠的,不过我们可以基于udp实现可靠传输
4、拥塞控制、流量控制:tcp有拥塞控制和流量控制、保证传输的安全性。
5、首部开销:tcp最少也要20个字节、但udp只需要8个字节就够了
6、传输方式:tcp是流式传输,把数据拆分成多个 “数据段”,通过网络传输后,在接收端重新拼接成完整的数据流,可能出现两个应用层报文夹杂在一个tcp报文里。udp是一个包一个包发送的,通常客户端能直接知晓发的是哪个请求。
7、分片不同:因为ip层要保证数据包大小小于mtu(1500字节默认),如果运输层超了,tcp会在运输层进行分端,保证不会超过mss(不包含ip头的最大长度),而udp不管,让ip来分片。
http是无状态的吗?怎么样有状态?
HTTP 协议是无状态的,这意味着每个 HTTP 请求都是独立的,服务器不会保留任何关于客户端请求的历史信息。所以我们通常会采用Cookie\session\jwt来实现(目的是验证哪个用户来了,是谁)
Cookie相当于存储在浏览器的标识,长度较短而且不安全,举个例子,好比你去超市存包,柜子给你一张带编号的小纸条(Cookie),上面写着 “你的包在 10 号柜”,接下来几天你都可以用这个柜子,但这张纸条记录的信息很短而且你的纸条有可能被别人偷看所以不安全。
Session相当于存储内容在服务器里,给客户端一个标识去取信息,通常时效性短而且会占用服务器资源。举个例子,你去超市存包服务员用本子记录你的信息,并给你一个柜号,你必须买完东西后赶紧来取。
Jwt主要包括头部、载荷以及签名,头部记录签名算法等信息,载荷会记录一些不敏感的信息,最后是签名,服务器会根据自己的私钥以及签名算法加密,会保证jwt不被篡改
举个例子,你拿身份证住酒店,卡上直接写着你的信息而且有公章,一查这个证件是对的,而一旦证件被改了就会发现,所以只要证是对的就能证明你来了。
Tcp三次握手说一下
一开始,客户端和服务端都处于 CLOSE 状态。先是服务端主动监听某个端口,处于 LISTEN 状态
客户端会随机初始化序号,将此序号置于 TCP 首部的「序号」字段中,同时把 SYN 标志位置为 1,表示 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态。
服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号,将此序号填入 TCP 首部的「序列号」字段中,其次把 TCP 首部的「确认号」字段填入 客户端的序列号+ 1, 接着把 SYN 和 ACK 标志位置为 1。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。
客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认号」字段填入 服务端的序列号 + 1 ,序列号为刚才服务端的应答号,最后把报文发送给服务端,这次报文可以携带客户到服务端的数据,之后客户端处于 ESTABLISHED 状态。
服务端收到客户端的应答报文后,也进入 ESTABLISHED 状态。
为什么是三次握手不是两次、四次
最关键的一点我认为是三次握手可以防止旧的重复连接初始化。
举个例子,假设客户端由于宕机发送了两次第一次握手报文,如果是三次握手的情况下,旧的报文被服务端收到回了一个第二次握手报文,这时客户端判断不是最新的连接,直接发一个rst终止连接,而等新的报文到了又能与服务端重新连接了。
但是如果是两次握手,服务端收到旧的报文直接进入established状态,然后发送数据,白白浪费服务端资源。而且二次握手还有问题,假如旧的第一次握手在网络中堵住了,客户端重发了并完成了后续的连接操作,等连接结束后旧的报文到达了,这时服务端又开启了无效连接,从而白白浪费资源。
还有就是序列号同步,序列号和应答号总共四个,但服务端可以把序列号和应答号合并起来所以不需要四次握手。而两次握手更不用说了,明明序列号没同步就进入了连接状态显然不合理。
TCP的全连接队列与半连接队列说一下
半连接指的是在 TCP 三次握手过程中,服务器接收到了客户端的 SYN 包,但还没有完成第三次握手,而完成第三次握手的连接为全连接队列。
- 当服务端接收到客户端的 SYN 报文时,会创建一个半连接的对象,然后将其加入到「 SYN 队列」;
- 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文;
- 服务端接收到 ACK 报文后,从「 SYN 队列」取出一个半连接对象,然后创建一个新的连接对象放入到「 Accept 队列」;
- 应用从「 Accept 队列」取出连接对象来用
SYN攻击了解吗?
SYN 攻击方式最直接的表现就会把 TCP 半连接队列打满,这样当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃,导致客户端无法和服务端建立连接。
我们可以 减少 SYN+ACK 重传次数:通过
tcp_synack_retries减少 SYN-ACK 的重传次数,加速释放半连接资源
Tcp四次挥手说一下
客户端打算关闭连接,此时会发送一个 FIN 报文,之后客户端进入 FIN_WAIT_1 状态。
服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入 CLOSE_WAIT 状态。
客户端收到服务端的 ACK 应答报文后,之后进入 FIN_WAIT_2 状态。
等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入 LAST_ACK 状态。 客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入 TIME_WAIT 状态
服务端收到了 ACK 应答报文后,就进入了 CLOSE 状态,至此服务端已经完成连接的关闭。 客户端在经过 2MSL 一段时间后,自动进入 CLOSE 状态,至此客户端也完成连接的关闭。
为什么挥手需要四次?
关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。 服务端收到客户端的 FIN 报文时,先回一个 ACK 应答报文表示我知道了我过一会就不接受了,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端来表示我也不会再发了,最后客户端回应一个ack表示我知道了我不会再接受了。因为tcp是全双工通信,这里需要对双方的发送接受都关闭才行,所以需要四次。
不过在某些时候也可能只需要三次。
说说TCP的流量控制?
TCP 通过滑动窗口来控制流量
- 接收方通过 TCP 头部的窗口大小字段告知发送方自己的接收缓冲区剩余空间。
- 发送方根据窗口大小调整发送数据的速率,避免接收方缓冲区溢出。
TCP的拥塞控制了解吗?
TCP 通过拥塞控制算法动态调整数据发送速率,避免网络拥塞。拥塞控制的过程:
- 慢启动:初始阶段拥塞窗口大小为1,指数增长发送数据包的速率,即每收到一个ACK,则拥塞窗口x2
- 拥塞避免:当达到慢启动门限后,线性增长发送数据包的速率,即每收到一个ACK,则拥塞窗口+1
- 拥塞发生:发生了丢包的现象,此时有两种情况
- 如果发送方连续收到三个ACK,则快速重传丢失的包(数据驱动,非时间驱动),就不需要等到超时才重传这个包,这时候调整方案是:从新慢开始门限(当前窗口大小/2)+3开始线性增长发送数据包的速率,直到该数据包被正确接受才从新慢开始门限进行拥塞避免算法。这种情况称为:快速重传和快速恢复
- 如果发送方没有连续收到三个ACK,那么会发生超时重传机制(时间驱动),这时候说明网络是真的不行,所以又回到慢启动状态,即将拥塞窗口大小变成1,重新进行慢启动算法(慢开始门限为当前窗口大小/2)
TCP的可靠性怎么保证的?
TCP 首先通过三次握手和四次挥手来保证连接的可靠性,然后通过校验和、确认应答、超时重传、流量控制、拥塞控制来保证数据的可靠传输。
三次握手与四次挥手用来建立连接和释放连接
校验和确保数据没有被损毁
确认应答保证数据按顺序传输与接受
超时重传保证数据丢失后能重新发送
流量控制保证数据能正常接受,不会超过接受方的接受能力
拥塞控制是根据网络环境来优化发送速率的。
TCP的粘包和拆包说说
TCP是面向字节流的协议,没有消息边界,接收方无法正确解析经过粘包或者拆包后的数据
粘包:(粘包产生的原因有两种:发送方的行为或接收方的行为)
- 发送方会缓存小数据包,将多个小数据包合并发送,减少网络开销
- 接收方可能因在缓冲区内一次性读取多个包,也会造成粘包
拆包:由于数据包太大,超过最大报文长度,此时发送方需要拆分大数据包,分成多个小数据包再发送
解决方案通常是在上层报文中添加length字段来说明报文有多长,从而定义边界。
操作系统
什么是用户态和内核态?
用户态和内核态的划分主要源于内存的划分,操作系统的内存分为内核空间和用户空间,
内核空间就是操作系统核心代码运行时所在的区域,拥有对系统资源的完全访问权限。
用户空间就是应用程序所使用的空间,用户空间的进程不能直接访问硬件或内核,只能通过系统调用或通信。
所以总的来说,当程序使用用户空间时,我们常说该程序在用户态执行,而当程序使内核空间时,程序则在内核态执行。
并发和并行的区别?
并发:在一段时间内,多个任务都在处理,但是某一时刻只有一个任务真正在运行,本质上是操作系统在进行时间片轮转的方式调度进程,给人一种好像都在同时运行的感觉。
并行:同一时刻有多个cpu任务在执行,需要多核处理器才能完成,是真正意义上的同时运行。
说说进程、线程、协程
进程是操作系统中进行资源分配的基本单位,它拥有自己的独立内存空间和系统资源。每个进程都有独立的堆和栈,不与其他进程共享。进程间通信需要通过特定的机制,如管道、消息队列、信号量等。但上下文切换的开销大,因为需要保存和恢复整个进程的状态。
线程是CPU调度的基本单位。线程共享进程的内存空间,包括堆和全局变量。线程之间通信更加高效,因为它们可以直接读写共享内存。线程的上下文切换开销较小,因为只需要保存和恢复线程的上下文,而不是整个进程的状态。然而由于多个线程共享内存空间,因此存在数据竞争和线程安全的问题,需要通过同步和互斥机制来解决。
最后是协程。协程是一种用户态的轻量级线程,其调度完全由用户程序控制,而不需要内核的参与。协程拥有自己的寄存器上下文和栈,但与其他协程共享堆内存。协程的切换开销非常小,因为只需要保存和恢复协程的上下文而无需进行内核级的上下文切换。这使得协程在处理大量并发任务时具有非常高的效率。然而协程需要程序员显式地进行调度和管理,其编程模型更为复杂。
线程与进程切换有什么区别,为什么线程快?
进程切换:进程切换涉及到更多的内容,包括整个进程的地址空间、全局变量、文件描述符等。因此进程切换的开销通常比线程切换大。
线程切换:线程切换只涉及到线程的堆栈、寄存器和程序计数器等,不涉及进程级别的资源,因此线程切换的开销较小,避免了进程切换时需要切换内存映射表等大量资源的开销,从而节省了时间和系统资源
什么是僵尸进程和孤儿进程?
僵尸进程是已完成且处于终止状态,但在进程表中却仍然存在的进程。
僵尸进程一般发生有父子关系的进程中,一个子进程的进程描述符在子进程退出时不会释放,只有当父进程通过 wait()获取了子进程信息后才会释放。如果子进程退出而父进程并没有调用 wait() ,那么子进程的进程描述符仍然保存在系统中,所以僵尸进程是会对系统造成危害的,我们应该即时释放资源。
一个父进程退出而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被 init 进程 (进程 ID 为 1 的进程) 所收养,并由 init 进程对它们完成状态收集工作。因为孤儿进程会被 init 进程收养,所以孤儿进程不会对系统造成危害。
进程间通信方式有哪些?
- 管道:管道就是内核中的一串缓存,从管道的一端写入数据,另一端读取。分为匿名管道和命名管道,匿名管道通常用于父子进程之间,而命名管道任意进程都可以用。
- 消息队列:消息队列是保存在内核中的消息链表,但每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。
- 共享内存:允许两个或多个进程共享一个给定的内存区,一个进程写入的东西,其他进程马上就能看到,但当多进程竞争同一个共享资源时,会造成数据错乱的问题。
- 信号:通知进程某件事发生了,比如SIGINT表示程序终止信号,SIGKILL,强制杀死该进程
- 信号量:本质上是一个计数器,用来控制对共享资源的访问数量,确保任何时刻只能有一个进程访问共享资源,JUC的semaphore类就实现了类似功能
- socket:套接字,提供网络通信的端点,可以让不同机器上运行的进程之间进行双向通信,基于的是计算机网络体系。
进程调度算法知道多少?
1、先来先服务:每次从就绪队列选择最先进入队列的进程运行,但是当一个长时间的作业运行了,后面的短时间作业等待会很长,不利于短作业。
2、最短作业优先:优先选择运行时间最短的进程来运行,但类似的,这反而又不利于长左业了。
3、时间片轮转调度:每个进程都是公平的,依次调度运行。尽管进程上下文切换有一定开销,但它依然是主流的进程调度算法。
4、优先级调度:给每个进程分配一个优先级,哪个优先级最高调度哪个,不过低优先级的进程可能会永远不执行。
5、高响应比优先调度:根据等待时间和要求服务时间算出优先级,等待长的,服务时间短的优先调度,是比较好的权衡了短作业与长作业。
6、多级反馈队列调度:时间片轮转和优先级调度的综合和发展,不同队列根据时间片分类,优先执行时间片短的队列,如果线程在短时间片队列中没执行完任务会被移动到长时间片队列,还是有较好的响应时间的。
死锁、如何避免、活锁饥饿锁
死锁:在两个或者多个并发线程中,如果每个线程持有某种资源,而又等待其它线程释放它或它们现在保持着的资源,在未改变这种状态之前都不能向前推进,称这一组线程产生了死锁。
(条件:资源只能被一个线程占用,多个线程互相持有对方想要的资源(其实是两个:持有资源+对方想要)、它们不能强制释放获取资源)
避免:可以按照顺序申请资源,就是资源有顺序,只有拥有小顺序资源才能申请大的。或者我们可以强制终止进程。
活锁:活锁线程组里的线程状态可以改变,但是整个活锁组的线程无法推进。活锁可以用两个人过一条很窄的小桥来比喻:为了让对方先过,两个人都往旁边让,但两个人总是让到同一边。这样,虽然两个人的状态一直在变化,但却都无法往前推进。
饥饿锁:某个线程一直等不到它所需要的资源,从而无法向前推进,就像一个人因为饥饿无法成长。
物理内存和虚拟内存有什么区别?
物理内存指的是计算机中实际存在的硬件内存。物理内存是计算机用于存储运行中程序和数据的实际内存资源,操作系统和应用程序最终都必须使用物理内存来执行。
虚拟内存的核心思想是通过硬件和操作系统的配合,为每个进程提供一个独立的、完整的虚拟地址空间,解决物理内存不足的问题。它与实际的物理内存地址不同,必须经过地址转换才能映射到物理内存。操作系统通过 页表(Page Table) 将虚拟地址映射到物理地址。当程序访问某个虚拟地址时,CPU 会通过页表找到对应的物理地址。
内存分段了解吗?
程序是由若⼲个逻辑分段组成的,如可由代码段、数据段、栈段、堆段组成。不同的段是有不同的属性的,所以就⽤分段的形式把这些段分离出来。
分段机制下的虚拟地址由两部分组成,段号和段内偏移量。然后通过查段表来计算实际的物理地址。
内存分页了解吗?
分页是把整个虚拟和物理内存空间切成⼀段段固定的大小。这样⼀个连续并且尺⼨固定的内存空间,我们叫页(Page)。在 Linux 下,每⼀页的大小为 4KB 。
访问分页系统中内存数据需要两次的内存访问 :一次是从内存中访问页表,从中找到指定的物理页号,加上页内偏移得到实际物理地址,第二次就是根据第一次得到的物理地址访问内存取出数据。
中断
中断会导致处理器暂停当前正在执行的任务,并转向执行一个特定的处理程序(中断处理程序)。然后在处理完这些特殊情况后,处理器会返回到被打断的任务继续执行。
典型的中断包括I/O设备中断(如键盘输入、鼠标事件)和硬件错误中断等。操作系统通常会为每种类型的中断分配一个中断处理程序,用于处理相应的事件。
select\poll\epoll
IO多路复用:IO 多路复用是一种高效的 IO 模型,它允许单个线程同时监听多个文件描述符(FD),并在某个 FD 可读、可写或出现异常时得到通知。这样可以避免无效的等待,充分利用 CPU 资源。监听FD以及通知的方式 有多种实现,
常见的有:select、poll、epoll epoll 相比于 poll 和 select ,能支持更大的并发连接数、性能更高
select 模式内核空间存储FD的结构是固定大小为1024的数组,因此最大并发连接数受限于数组大小。当某个FD就绪时,从内核空间将所有FD拷贝到用户空间,用户空间需要遍历数组,找出哪些FD就绪
poll模式内核空间存储FD的结构是链表,因此理论上没有最大连接数限制当某个FD就绪时,从内核空间将所有FD拷贝到用户空间,用户空间需要遍历链表,找出哪些FD就绪,当监听的FD越多 (即链表越长),遍历耗时增加,影响并发性能
epoll模式内核空间存储FD的结构是红黑树,增删改查性能稳定当通知有数据就绪可读/可写时,从内核空间只将就绪的FD拷贝到用户空间,减少了内核空间和用户空间之间的数据拷贝,且用户空间无需再遍历所有FD,直接处理返回的就绪的FD
Linux常用命令
常用设计模式
场景题:
设计一个支付系统
首先我们应该先分析一下需求:首先支付系统要从买家那里收钱再转给卖家,其中涉及到第三方支付,因为用户信息比较敏感,通常不应该让我们存储或处理,而且需要保证各方账单一致性(这里支付可能还涉及国际货币的问题,暂且不论只考虑单一货币;而且暂且不考虑高并发)
这里面最关键的应该是支付流程:用户点击支付生成支付事件,后台收到后我们先进行落库存储,然后再进行付款执行。当然这里一个支付事件可能对应多个付款流程,比如购物车里面有多个不同商家的商品,我点一下付款肯定要把每个都付了,这里具体执行时再生成付款订单落库,并调用第三方服务进行付款,然后将钱增加到商家的钱包里,这里也是要落库,后续商家通过提现再一次性提取。最后这里我们还应该调用账本信息进行落库,这对我们售后服务是比较重要的,最后更新一下支付事件的状态。(提现的话就只需要将钱从公司打给商家,流程类似)。
当然这中间很可能会出现问题,比如恰好一次支付和内部服务通信,恰好一次支付这个问题非常关键,因为涉及到金额,所以必须保证幂等性,假设用户点了两次支付,这里我们只需要用一个购物车ID或总订单ID来实现,后端去重检查即可,而且这里与服务商的交互也是用唯一ID来实现,我们传给它的相同nonce,他会生成对应token,多次执行结果仍一样,去重后不会重复处理。还有就是内部通信,如果是同步比如http那么很影响性能,而且紧密耦合难以扩展,如果采用异步比如消息队列就比较好,扩展性高,适合支付系统。
(当然这些考虑的比较基础,也还有很多扩展性的点可以考虑。)
设计一个短链接系统
我们先分析一下系统的功能。主要是用户点击短链接,将短链接重定向为长链接
首先我们先分析一下使用量,假设一天生成一亿给url,那每秒大概是1000个,每秒读取的并发量大概是几万,然后这个服务器可能要运行数十年,那么它的总记录要达到几千亿条,而如果一个url存储要100字节,那么系统大概就是要几十TB的存储。所以我们要部署的话存储要大一些。
(千-KB,百万-MB,十亿-GB,万亿->TB)
然后就是分析使用了,与之相关联的API显然有两个,根据长收到短,根据短定向长。
我们先来分析一下url缩短,一般这里应该用post请求发送一个长url,然后后台将其映射为哈希值,这里我认为可以采用base62转换,而在数据库我们用唯一ID,短url,长url存储,短url用ID进行base62转换成短字符作为短url,如果是基于长url去映射,那生成的哈希值会比较长,而且可能会哈希冲突。(base62能将13位数字缩短为7个字符,很符合我们需求)
接着就是分下短链接定向为长,这里我们可以用301或者302,301是永久重定向,如果我们不想让服务器承受过大压力可以考虑;301是临时重定向,如果我们想追踪点击次数等信息可以考虑用这个。定向完之后就简单了,服务器根据短链接去数据库查就行,没有说明短链接无效。
设计一个高并发实时排行榜
好我们先简单分析一下,一般我们会显示排行榜前10或多少名玩家,而玩家通过游戏获取分数,将分数加进排行榜,然后用户会实时查看排行榜。
好我们简单分析一下api,一是更新分数,将用户得分发到后台,二是获取排行榜靠前的用户排名,三是获取用户自身的用户排名。
然后我们简单分析一下架构,显而易见最少涉及到游戏服务和排行榜服务两个模块,再考虑一下具体的实现。
因为并发量很大而且需要实时性和高效,所以我们可以采用redis来实现,那么就可以使用zset了,用户完成游戏将得分加进来(zincreby),获取前几名用户就是将最靠前的用户取出(zrevrange),获取用户排名(zrevrank).
实现功能后我们考虑一下存储,一般存分数和用户ID,假设一个条目30字节,活跃用户1000万,那么也就才300MB空间,对redis来说很充足了。
其实这里基本功能差不多实现了,当然如果redis崩掉了我们还需要做灾后恢复,这里我们可以把得分带时间戳存到mysql里,如果redis挂掉后重新部署一台redis重建排行榜来维持服务。
设计一个分布式消息队列
首先我们先分析一下需求,首先能让生产者发送消息到消息队列,消息队列发消息到消费者。其次消息可以重复消费或者消费一次、消息能够按序消费、支持数据交付语义定义。
所以我们先考虑一下架构,这里可以参考其他的消息队列,采用发布订阅模型,还有就是这里应该考虑单节点抗不住怎么办,所以这里我们可以对消息进行分区管理,消息根据消息键发往不同的区,而且应该多设置一些副本来保证消息不会丢。对于消费者我们可以设置消费者组这个模式,同一组内不能消费同一分区,不同组可以消费,这样能满足更多使用场景(具体说说)。
10亿数据快速插入数据库?
这里我们先假设1条数据1kb,并且有序导入,那么我们再详细分析一下:
首先数据库单表是存不下的,单表上限一般在2000万条,这里我们暂且按1000万算,那就是100张表,
智力题:
大模型基础
如何理解大模型
大模型通常指基于深度学习的大规模人工智能模型,大模型的"大"主要体现在参数规模大,能达到百亿甚至更高。
大模型擅长做生成内容、代码编写、文本处理、多态任务,
但可能犯逻辑错误,推理链条过长时,可能得出错误结论
AIAgent是什么?
AI Agent 是比传统 AI 应用更智能的系统,简单来说就是能够自主执行任务、做决策的 AI 助手。
传统的 AI 应用更像是"问答机器",你问什么它答什么。但是 Agent 不一样,它能理解你的目标,然后自己制定计划,调用各种工具来完成任务,就像一个真正的助手一样。
MCP了解吗?
MCP是为了解决大模型函数调用问题的协议。
没有MCP时,客户端向大模型发送请求,大模型会分析意图及应该调用的工具,客户端根据大模型的结果再去调用工具,但不同的工具调用方式不一样,代码维护起来复杂。
而有了MCP后客户端只需要整理工具信息和调用大模型,工具的调用和实现交给MCP服务器来实现,这种方式实现了声明和调用的分离,只要对方按照标准实现了MCP服务器,就能接入到支持MCP的客户端中。
如何理解检索增强技术(RAG)
rag是检索增强技术,就是在让大模型生成答案之前,先找资料增强它的知识,再用这些资料生成更准确的回答。
因为对于很多大语言模型来说,他的知识是基于历史数据训练出来的,这就需要通过资料的方式增强他原来不熟悉或没接触的知识。
具体流程是把资料喂给embedding模型生成向量存到向量数据库里,当用户提问时,先用相同的Embedding模型把问题也转成向量。然后在向量数据库里用向量相似度搜索,找出最相关的几段资料。这些找到的内容就是上下文增强材料。紧接着就可以把用户的问题 + 检索到的资料一起,作为Prompt发给大语言模型
大模型幻觉了解吗?
大模型的“幻觉”指的是 AI 生成了看似合理但实际上错误或编造的信息,例如它可能会编造不存在的事实。
原因:大语言模型主要是根据概率来预测输出,如果训练数据本身有缺陷,就可能会导致出现幻觉。
解决:使用rag检索增强技术来增强输入数据,并通过训练来调整参数尽可能减少幻觉发生
你平时怎么用AI的
平时通常是用国内的AI,比如deepseek\豆包、混元、智谱清言等等,不过这些都是文字对话式AI,我还了解过一些其他类型的AI,比如midjourny\stabblediffution,它们是知名的 AI 绘图工具,利用 AI 算法根据关键字生成图片,属于文生图的模型。
更多推荐


所有评论(0)