Java基础
-
形参和实参:
形参:用于定义方法/函数的参数,用于接收实参且不需要确定的值
实参:用于传递给方法/函数的参数,必须由确定的值
-
值传递和引用传递:
程序设计将实参传递给方法/函数的方式分为两种:值传递和引用传递
值传递:方法接收的是实参值的拷贝,会创建副本 引用传递:方法接收的是实参所引用对象在堆中的地址,不会创建副本,对形参的改变影响实参
-
在Java中只有值传递,原因? Java中将实参传递给方法/函数的方式是值传递。如果参数是基本类型的话,传递基本类型字面量值的拷贝,会创建副本;如果参数是引用类型的话,传递实参所引用对象在堆中地址的拷贝,也会创建副本。
-
Java反射:在程序运行时动态加载类并获取类的详细信息以操作类或对象的属性和方法。
反射的优点:方便创建灵活的代码,为各种框架提供开箱即用的功能提供了实现基础
反射的缺点:运行时分析操作类的能力和动态加载特性,增加了安全问题。由于泛型的安全检查发生在编译器,反射无视这种检查;反射会消耗更多的系统资源
运行时程序通过Class类对象得到一个类的方法和变量信息,获取反射类Class的方式:getClass()、Class.forName()、类.class、getClassLoader()
-
Java泛型:提供了编译时类型安全检测机制。编译器会在编译期间动态将泛型T擦除为Object,为了保证引入泛型机制但不创建新的类型,减少虚拟机的运行开销,编译器通过擦除将泛型类转化为一般类
-
Java枚举
-
代理模式:使用代理对象来代替对真实对象的访问,在不修改原目标对象的前提下,提供额外的功能操作并扩展目标对象的功能。代理模式主要作用在于扩展目标对象的功能,比如在目标对象的某个方法执行前后增加一些自定义操作。
- 静态代理与动态代理的区别:动态代理更加灵活,不必必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都实现一个代理类。静态代理接口一旦新增方法,目标对象和代理对象都要进行修改;此外,静态代理在编译阶段就将接口、实现类、代理类都变成了一个个实际的class文件,而动态代理是在运行时生成类字节码并加载到JVM中
- JDK动态代理与CGLIB动态代理的区别:JDK动态代理只能代理接口或者实现了接口的类,CGLIB可以代理未实现任何接口的类;此外CGLIB是生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明了final的类和方法
-
死锁产生的四个条件:
- 互斥:线程已经获取到的资源进行排他性使用。该资源同时只能由一个线程占用,如果还有其他线程请求获取该资源那么请求者只能等待占有资源的线程释放
- 请求并持有:一个线程已经持有了至少一个资源,但又提出了新的资源请求。但新的资源已经被其他线程持有所以当前线程会被阻塞,阻塞的同时并不会释放自己已经获取的资源
- 不可剥夺:线程获取到的资源在自己是用完之前不能被其他线程抢占,只能自己使用完毕后自己释放
- 环路等待:发生死锁时,必然存在一个线程-资源的环形链,线程集合{T0,T1,T2,...,Tn}中的T0正在等待T1占用的资源,T1等待T2,T2等待T3...
避免死锁:一次性申请所有资源来破坏请求并持有条件;使用资源有序分配法来破坏环路等待条件;占有部分资源的线程在申请其他资源失败时释放已经持有的资源,破坏不可剥夺条件。
-
用户态与内核态
为保证操作系统的稳定性与安全性,一个进程的地址空间划分为用户空间和内核空间;平时运行的应用程序在用户态、只有内核空间才会进行内核态资源的有关操作比如文件管理、进程管理和内存管理等,应用程序发起系统调用由操作系统内核完成具体的操作。
-
I/O模型
UNIX系统下,IO模型5种:同步阻塞I/O、同步非阻塞I/O、I/O多路复用、信号驱动I/O和异步I/O。
-
同步阻塞I/O即BIO,应用程序发起read系统调用后会一直阻塞,直到内核把数据拷贝到用户空间;BIO是面向字节流的、一次只能从流中读取一个或多个字节且读完之后无法再读取、需要自己缓存数据;BIO每来一个连接创建一个线程并以死循环的方式轮询是否有数据可读
-
同步非阻塞I/O即NIO,应用程序不断发起I/O系统调用以轮询数据是否准备好,直到数据准备好并且内核将数据拷贝到用户空间;NIO读写面向Buffer、仅通过移动读写指针实现读取Buffer中的任一字节且不需要缓存数据;NIO提出了多路复用器Selector、实现一个线程管理多个客户端连接实现数据可读连接的批量检测
NIO的不断轮询是消耗CPU的,为此提出了I/O多路复用(Java中的NIO就是I/O多路复用)。I/O多路复用包括Select、Poll、Epoll三种模式,首先每个连接作为一个fd描述符:
-
Select: 采用主动轮询的方式,多个网络连接I/O注册到一个复用器上,客户端调用select函数,OS在内核态对fd集合进行遍历,直到某一个fd描述符返回结果,如果网卡没有收到网络请求那么整个线程被阻塞。一次select调用发生一次用户态到系统内核调用和内核态内部的多次fd描述符的read函数调用。
特点包括采用默认大小为1024的bitmap存储文件描述符fd,限制了并发数量;时间复杂度为O(n);select在内核态仍然采用主动遍历方式来判断哪个fd已经处于就绪状态;会将内核态fd集合中没有发生状态变化的fd返回用户态,涉及较多的拷贝
-
Poll:和Select差不多,仅修改了fd集合上界大小
-
Epoll:不再采用主动轮训而是基于操作系统提供的I/O通知机制。放弃了fd数量的限制同时采用链表+红黑树的方式存储fd;只会将变化的fd拷贝到用户态,同时这里的拷贝通过当fd就绪时,采用异步事件驱动通知的方式告知
-
异步I/O即AIO基于事件和回调机制实现,应用操作之后直接返回不会阻塞,当后台处理完成操作系统通知相应的线程进行后续的操作
-
-
Java中包括8种基本数据类型,分别有6种数字类型:byte(1)、short(2)、int、long、float、double,一种字符类型:char(2),一种布尔型:boolean。
-
Java中浮点数基本数据类型不能用==比较,包装数据类型不能用equals比较
原因:计算机系统中,浮点数采用IEEE 754标准表示,编码方式是符号+阶码+尾数,十进制数转换为二进制科学表达式之后得到的尾数位数有可能是很长或无限长,使用浮点格式存储数字实际存储的尾数是被截取或者执行舍入后的近似值,存在精度丢失的风险
-
包装类型与基本类型的区别:包装类型不赋值默认为null,基本数据类型有默认值且不为null;基本数据类型保存在Java虚拟机栈中的局部变量表中,而包装类型属于对象类型、对象实例存放在堆中。 Java基本类型的包装类基本都实现了常量池技术,Byte、Short、Integer、Long四种类型默认创建了
[-128,127]相应类型的缓存数据,Character创建了[0,127]范围的缓存数据,Boolean直接返回False或者True。
-
-
ArrayList底层采用动态数组实现,插入和删除的时间复杂度在O(n),查询的时间复杂度在O(1)。数组被transient修饰不会被序列化,同时ArrayList重写了writeObject()和readObject()控制只序列化数组中有元素填充的内容部分。ArrayList实例化时为空,调用add方法且没有指定初始化容量默认为10,每次调用add判断是否需要扩容并将容量扩为原来的1.5倍。为了避免频繁扩容可以在初始化是指定合适的capacity。ArrayList空间浪费主要体现在list列表的结尾会预留一定的容量空间
-
Arrays.asList() 生成的是Arrays内部的类,所以不可被修改不可赋值给ArrayList。
-
序列化与反序列化:序列化指将数据结构或对象转换成二进制字节流的过程,用于对象保存(文件、数据库、内存)和网络传输(RPC);将序列化所产生的的二进制字节流转换成数据结构或对象。
OSI模型中,序列化和反序列化对应应用层、表示层和会话层;TCP/IP模型中,对应应用层;
-
面向过程:把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题;面向对象:先抽象出对象,用对象执行方法的方式解决问题。
成员变量与局部变量:都可以被final修饰但是只有成员变量能被private/public/protected/static修饰;成员变量属于类,局部变量在方法和代码块中定义或是方法参数;局部变量的生命周期随方法的调用而存在而消亡;成员变量自动赋默认值,局部变量则不赋值。
构造方法:名字与类名相同、没有返回值同时不能用void声明,生成类的对象时自动调用。构造方法不能被重写,但可以被重载。
- 三大特性:
- 封装:将一个对象的状态信息隐藏在对象内部,不允许外部对象直接访问对象的内部状态,提供一些方法供外界访问
- 继承:子类拥有父类所有方法和属性但private的无法访问;子类可以有父类没有的方法和属性用作扩展;
- 多态:相同类型变量在调用同一方法时表现不同行为特征,具体表现为父类的引用指向子类的实例。对象与引用之间具有继承类/实现接口的关系、引用变量发起的方法调用具体指的是哪个方法需要在程序运行期间才能确定、多态不能调用只在子类存在而在父类不存在的方法、如果子类重写了父类方法则执行子类方法否则执行父类方法.
- 三大特性:
-
接口与实现类的异同
- 都不能被实例化;都可以包含抽象方法;都可以有默认实现的方法(Java8基于default关键字在接口中定义默认方法)
- 接口主要对类行为进行约束,实现某个接口具备行为,抽象类多用于代码复用;接口多实现,抽象类单继承;接口默认都是public static final,不能修改且必须要有初始值,抽象类成员变量默认default,可以在子类中重新定义和赋值。
-
深拷贝、浅拷贝与引用拷贝:
-
Object方法
-
==与equals的区别:
- == 多用于比较基本数据类型,equals多用于比较对象数据类型
- == 进行对象比较时比较对象的引用地址
- String.equals进行重写所以比较字符串值是否相等,Object.equals比较对象的内存地址
-
hashCode:每个对象都有hashCode,通过将对象物理地址转化为一个整数并经过hash函数计算得到hashCode,hashCode用于确定对象在hash表中的索引位置,所以hashCode不一定是对象地址、取决于运行时库和JVM的具体实现。
hashCode和equals都用于比较对象是否相等,有了hashCode之后比较效率更高。两个不同的对象可能具备相同的hashCode,表示发生了hash碰撞。两个对象使用equals()方法判断为相等,则hashCode()方法也应该相等。所以重写了equals也尽量重写hashcode保证此原则。集合中判断对象存在的方法:首先计算对象的hashcode并判断哈希表中是否存在此hashcode,如果不存在直接存储对象,否则根据equals来判断两个对象是否相同,不相同则进行冲突解决并hash到新的地址进行存储。
-
registerNatives:类加载时将Object中的一些本地方法绑定到指定函数中方便调用,比如equals和hashCode绑定到JVM_IEQUALS和JVM_IHASHCODE
-
-
String/StringBuilder/StringBuffer
StringBuilder、StringBuffer、String都是用final进行修饰所以不能被继承。从可变性来讲,String是不可变的,StringBuffer和StringBuilder长度可变;从运行速度来讲,StringBuilder>StringBuffer>String;从线程安全上讲,StringBuilder是线程不安全的,StringBuffer是线程安全的。String适用于少量字符串操作,StringBuilder适用于单线程在字符串缓冲区下的大量操作,StringBuffer适用于多线程在字符串缓冲区下的大量操作
- String存储数据的char数组使用final进行修饰并且为私有,String类没有提供/暴露修改这个字符串的方法;另外String被final修饰不能被继承,避免了子类对String的破坏。注意在java9中,String底层由
char[]替换为byte[],且支持Latin-1和UTF-16两种编码方式,在能表示的范围内,byte占用1字节、char占用2字节,更加节省空间。 - StringBuffer中常用操作都是用synchronized修饰所以线程安全
- String存储数据的char数组使用final进行修饰并且为私有,String类没有提供/暴露修改这个字符串的方法;另外String被final修饰不能被继承,避免了子类对String的破坏。注意在java9中,String底层由
-
synchronized解决多个线程之间访问资源的同步性,synchronized保证被它修饰的方法或代码块在任意时刻只能被一个线程执行
synchronized实现的原理:synchronized实现依赖于JVM的monitor监视器锁,主要是用monitorenter和monitorexit指令实现方法同步和代码块同步。任何一个对象都有一个monitor与之关联,当一个monitor被持有后将被处于锁定状态。编译时将monitorenter插入同步代码块开始处,将monitorexit插入方法结束处或异常处,每一个monitorenter指令对应一个monitorexitzhiling。在执行monitorenter时会尝试获取对象的锁,如果锁的计数器为0表示锁可以被获取,获取后将锁计数器设为1;对象锁的拥有者线程才可以执行monitorexit指令来释放锁,在执行monitorexit指令后,将锁计数器设为0表示锁被释放,其他线程可以尝试获取锁。
在Java早期版本,synchronized属于重量级锁性能低下,因为监视器锁(monitor)依赖于底层操作系统的Mutex Lock实现,Java线程是映射到操作系统原生线程上的,操作系统完成线程切换需要由用户态转到内核态,状态转换时间长,成本高
-
锁升级即为了减少获得锁和释放锁带来的性能损耗引入了偏向锁、轻量级锁和重量级锁:
-
首先是无锁状态,线程进入同步代码块时,检查对象头和栈帧中的锁记录是否存入当前线程ID,如果没有使用CAS进行存入ID。此后线程进入和退出同步代码块不需要进行CAS来加解锁,只需要判断对象头MarkWord中是否存储当前线程ID,如果有表示锁获取成功且不改变状态,如果没有或者不是则进行CAS替换,如果替换成功则当前线程持有偏向锁否则将偏向锁撤销并设置为轻量级锁。
等待锁竞争才会释放锁,并且偏向锁的撤销需要等待全局安全点(此刻没有任何正在执行的字节码)
-
轻量级锁的加锁过程,JVM会在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的Mark Word复制到锁记录Displaced Mark Word中,然后线程尝试使用CAS将对象头中的MarkWord替换为指向锁记录的指针。如果失败,表示其他线程竞争锁,采用自旋的方式获取锁。
解锁时,使用CAS将Displaced Mark Word替换回到对象头中
-
重量级锁
-
-
Java中所有异常的公共祖先为java.lang.Throwable。其中主要的两个子类:Exception和Error。Exception表示可以catch捕获的异常,Error表示程序无法处理的错误。Exception又包括受检查异常和不受检查异常。如果受检查异常没有被catch或者throws的话就不能通过编译,除了RuntimeException及其子类之外,其他异常都属于受检查异常。
try-with-resources
JDK7 提供try-with-resources可以更容易编写要关闭的资源代码
-
transient:阻止实例中用此关键字修饰的变量序列化,对象被反序列化时被transient修饰的变量不会被持久化和恢复。transient只能修饰变量不能修饰类和方法。static变量不属于任何对象,不管有没有transient关键字修饰均不会被序列化。
-
I/O流为什么分为字节流和字符流?
字符流由Java虚拟机将字节转换得到,这一过程耗时且在不知编码类型时容易出现乱码问题,I/O提供直接操作字符的接口方便对字符进行流操作。音频和图片用字节流,涉及到字符使用字符流更好
-
LinkedList基于双向链表实现,增删时间复杂度O(1),查询时间复杂度O(n)。LinkedList作为双端队列使用,默认offer、add尾部添C加,addFirst头部添加。LinkedList的空间浪费体现在每一个元素要同时保存直前驱和后继
-
Comparable和Comparator的区别:
- Comparable属于java.lang,有一个compareTo()方法用于排序
- Comparator属于java.util,有一个compare(A,B)方法用于排序
-
无序性和不可重复性:无序性指存储的数据在底层数组中并非按照数组索引顺序添加而是根据数据哈希值决定的;不可重复性指的是添加的元素按equals判断时返回false,需要重写equals和hashCode方法。
-
比较HashSet、LinkedHashSet、TreeSet:
- HashSet、LinkedHashSet、TreeSet都是Set接口的实现类、都能保证元素唯一、都不是线程安全
- HashSet底层数据结构是基于HashMap实现的哈希表且不保证元素插入取出顺序,LinkedHashSet底层实现是哈希表+链表且元素的插入取出顺序满足FIFO,TreeSet底层是红黑树,元素是有序的且排序方式支持自然排序和定制排序
-
ArrayDeque和LinkedList的区别:
- ArrayDeque基于可变长的数组和双指针实现,LinkedList基于链表实现
- ArrayDeque不支持存储NULL数据,但LinkedList支持
- ArrayDeque插入时可能存在扩容,LinkedList不需要扩容但每次插入数据需要申请新的堆内存空间
-
HashMap与HashTable区别:
- HashMap是非线程安全的,HashTable内部方法都经过synchronized修饰所以是线程安全的,所以HashMap的效率更高
- HashMap允许存在null的key和value,其中null作为key只能由一个,作为value可以有多个;HashTable不允许出现null键和null值
- HashMap默认初始大小为16,每次扩容容量为原来的2倍;HashTable默认初始大小为11,每次扩容容量为原来的2n+1。此外如果初始大小,HashTable会直接使用作为容量初始值,HashMap会将其扩充为2的幂次方大小
- HashMap在1.8以后解决hash冲突时有了较大变化,链表大于阈值8且当前数组长度大于64时,会将链表转化为红黑树,如果数组长度小于64则只进行链表扩容,如果红黑树节点个数少于6个则又会转化为链表。HashTable没有此机制
-
HashMap
-
哈希表的定义: 通过一个映射函数将一组数据散列存储到数组中的数据结构
-
解决hash冲突有开放定址法、再哈希法、链地址法、建立公共溢出区。
- 开放定址法指当前key冲突时,再次计算hash(key)并反复执行下去找到一个合适的不冲突key;
- 再哈希法指设置多个hash函数,key1冲突时使用备选hash进行计算新key;
- 链地址将所有冲突值进行链表存放;
- 建立公共溢出区:哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区。
-
计算哈希值的方法:
- 平方取中法
- 取余法
- 伪随机数法
- 数字分析法
-
HashMap Key的设置规范:
一般用Integer、String这种不可变类当HashMap当key。这些类规范的重写了equals和hashCode方法;String是不可变的,当创建字符串时,它的hashcode被缓存下来,不需要再次计算,相对于其他对象更快
-
HashMap判重和设值原则:
hashCode相等产生hash碰撞,hashCode相等会调用equals方法比较内容是否相等,内容如果相等则会进行覆盖,内容如果不等则会连接到链表后方,链表长度超过8且数组长度超过64,会转变红黑树节点
-
HashMap的底层实现
jdk1.8以前HashMap的底层采用数组+链表存储(hash冲突严重时链表过长导致查询性能低下O(n)),jdk1.8开始,少数据量以数组+链表存储,当链表超过8且数组长度(数据总量)超过64时,链表转化为红黑树,当然如果数组的长度不超过64那么会对数组进行扩容而不转化为红黑树,如果红黑树节点少于6个又会转化为链表。
-
为什么设置为6和8
在进行方案设计时,要同时考虑空间和时间因素,阈值为8是空间和时间上权衡的结果;红黑树节点大小约为链表节点2倍,节点太少时,红黑树查找性能优势并不明显;理想情况下,使用随机的hash码,节点分布在hash桶中的概率符合泊松分布,发生hash碰撞8次的几率是百万分之6已经够用,但如果hash碰撞次数在8附近徘徊,会一直发生链表和红黑树的互相转化,为了预防这种情况的发生,设置为6。
-
HashMap的重要属性
-
loadFactor负载因子,默认为0.75:
时间上和空间上权衡的结果,如果过大比如设置为1,会减少空间开销但hash冲突概率增大、增加了查找成本;如果过小比如设置为0.5,hash冲突降低但是会有一半的空间浪费。
-
默认初始容量为16,且HashMap的容量必须是2的N次方。
当n为2的N次方时,n - 1低位全是1,此时任何值跟n - 1进行&运算的结果为该值的低 N 位,达到了和取模同样的效果,实现了均匀分布、降低了hash冲突的概率;位运算相较取模运算效率更高
-
-
HashMap的hash值计算以及取索引元素过程:
-
HashMap通过key的hashCode经过扰动函数处理过后得到hash值,然后通过(n - 1)&hash 判断当前元素存放的位置
-
扰动函数、hash函数、hash值计算过程:将32位int值key的hashCode高16位和低16位进行异或,实现原始hash值高位与低位的混合,增大了低位的随机性,避免原来hash值的低X位出现规律直接与造成大概率冲突。
hash扰动函数的目的:
- 尽可能降低hash碰撞,越分散越好;
- 尽可能高效,因为这是高频操作, 因此采用位运算;
-
根据下标取索引元素:直接根据key.hashCode和hash函数计算得到的hash值返回的int型映射范围过大,所以(n-1)&hash,得到的余数访问数组下标
-
-
HashMap插入与查找原理:
-
插入原理
- 判断数组是否为空,为空进行初始化;
- 不为空,计算 k 的 hash 值,通过(n - 1) & hash计算应当存放在数组中的下标 index;
- 查看 table[index] 是否存在数据,没有数据就构造一个Node节点存放在 table[index] 中;
- 存在数据,说明发生了hash冲突(存在二个节点key的hash值一样), 继续判断key是否相等(equals),相等,用新的value替换原数据(onlyIfAbsent为false);
- 如果不相等,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中;
- 如果不是树型节点,创建普通Node加入链表中;判断链表长度是否大于 8且数组长度大于64, 大于的话链表转换为红黑树;
- 插入完成之后判断当前节点数是否大于阈值,如果大于开始扩容为原数组的二倍。
-
查找原理
- 使用扰动函数,获取新的哈希值
- 计算数组下标,获取节点
- 当前节点和key匹配,直接返回
- 否则,当前节点是否为树节点,查找红黑树
- 否则,遍历链表查找
-
-
HashMap的线程不安全性:
多线程下扩容死循环。JDK1.7中的HashMap使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题
-
红黑树
- 每个节点要么是红色,要么是黑色;
- 根节点永远是黑色的;
- 所有的叶子节点都是是黑色的(注意这里说叶子节点指的是NULL节点);
- 每个红色节点的两个子节点一定都是黑色;
- 从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点;
-
-
ConcurrentHashMap
-
JDK1.7及以前基于 分段数组+链表 实现,JDK1.8及以后基于 数组+链表/红黑树 实现。具体说来,JDK1.7及以前基于 Segment 数据结构和 HashEntry 数组实现,Segment继承了ReentrantLock,扮演了可重入锁的角色
-
线程安全保证方面:JDK1.7及以前基于分段锁对数组进行分段加锁;JDK1.8及以后直接使用 Node数组+链表+红黑树实现,并基于synchronized和CAS进行并发控制
-
ConcurrentHashMap的扩容只会扩容到原来的两倍,老数组里的数据移动到新的数组时,位置要么不变,要么变为index+ oldSize,参数里的node会在扩容之后使用链表头插法插入到指定位置
-
AtomicInteger主要利用CAS+volatile和native方法来保证原子操作,避免synchronized的高开销,执行效率大为提升。
JVM
JVM内存结构
JDK1.8和JDK1.7的最大差别就是元数据区取代了永久代作为JVM规范中方法区的实现,元数据区属于本地内存而不在虚拟机中
-
程序计数器:程序计数器是一块较小的内存,是线程私有的,是当前线程正在执行的字节码指令的地址,如果当前线程正在执行的是本地方法,则程序计数器为undefined
-
作用:
- 字节码解释器通过改变程序计数器来依次读取指令,实现代码流程的控制
- 程序计数器记录当前线程执行位置,多线程上下文切换时用于恢复现场
-
特点:
- 线程私有,每个线程都有自己的程序计数器
- 唯一一块不会发生OOM异常的内存区域
- 一块较小的内存空间
- 生命周期:随线程创建而创建,随线程销毁而销毁
-
-
虚拟机栈:描述了Java方法运行过程的内存模型
- 方法调用的数据需要通过栈进行传递,每一个方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后都会有一个栈帧被弹出,栈由一个个栈帧组成,每一个栈帧包括局部变量表、操作数栈、动态链接、方法返回地址。
- 虚拟机栈是私有的,保证线程中的局部变量不被其他线程访问
- 局部变量表存放了编译期可知的各种数据类型和对象引用
- 操作数栈存放方法执行过程中的中间结果和临时变量
- 由于Java源代码文件编译成class字节码文件所有的变量和方法引用都作为符号引用保存在Class文件的常量池中,动态链接实现一个方法调用其他方法时将常量池中指向方法的符号引用转换为在内存地址中直接引用
-
特点:
- 运行速度快仅次于PC寄存器
- 局部变量表随栈帧创建而创建,大小在编译时确定且在方法运行过程中不会改变
- 请求栈深度超过JVM最大栈深度,报StackOverFlowError;请求栈内存用完,抛出OOM Error
- 线程私有,随线程创建而创建销毁而销毁
-
本地方法栈:本地方法栈描述了JVM运行C语言实现的本地方法时的内存模型
-
堆:几乎所有的对象实例和数组都在堆上分配内存,JDK1.7默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外界使用,那么对象可以直接在栈上分配内存
- 线程共享区域
- 虚拟机启动时创建
- 垃圾回收的主要场所
- 堆可以分为新生代和老年代。不同区域存放不同生命周期的对象,不同区域使用不同垃圾回收算法,更有针对性
-
逃逸分析的优势:
- 栈上分配:如果确定一个对象不会逃逸到线程之外,可以考虑将此对象在栈上分配,对象占用内存随着栈帧出栈而销毁,降低了垃圾收集器的压力
- 同步消除:栈上分配的对象避免了其他线程的并发访问
- 标量替换:不会被方法外部访问的对象可以被拆散,不创建对象而直接用创建若干个成员变量替换,实现对象的成员变量在栈上分配和读写
-
方法区:JVM虚拟机规范定义方法区是堆的一个逻辑部分
-
方法区存在的信息包括:
- 已经被虚拟机加载的类信息
- 常量,具体来说存放到运行时常量池中
- 静态变量
- 即时编译器编译的代码
-
特点:
- 作为堆的一个逻辑部分,线程共享
- JDK1.7划分堆的永久代作为方法区实现,JDK1.8划分直接内存中的元数据区作为方法区实现
- 方法区的信息一般需要长期存在,内存回收效率低
- JVM规范对方法区要求比较宽松,和堆一样允许固定大小也允许动态扩展
-
-
Java堆分区、新生代与老年代:
- 老年代生命周期比新生代长
- 新生代与老年代空间默认比例1:2
- HotSpot中,Eden空间和另外两个Survivor空间缺省所占比例是8:1:1
- 几乎所有对象都是在Eden创建,Eden创建不了大对象直接进入老年代
-
Stop The World、OopMap、安全点
-
Stop The World
进行垃圾回收过程中涉及到对象的移动,为了保证对象引用更新的正确性,必须暂停所有的用户线程这种停顿为Stop The World即STW
-
OopMap
-
安全点
-
-
内存泄漏与内存溢出:内存泄露可能导致内存溢出。内存泄漏表示申请的内存没有被正确释放,导致内存被被白白占用;内存溢出表示申请内存超过了可用内存
-
垃圾收集触发时机
-
目标不是完整收集整个Java堆的垃圾收集为部分收集,即Partial GC:
- Minor/Young GC为新生代收集:只针对于新生代的垃圾收集,Minor GC非常频繁且回收速度快
- Major/Old GC为老年代收集:只针对老年代的垃圾收集,CMS只针对于老年代收集
- Mixed GC混合收集:针对于整个新生代和部分老年代的收集,如G1
-
整个Java堆和方法区的收集为整堆收集,即Full GC
-
新生代收集触发时机:
新创建的对象优先在新生代Eden区进行分配,Eden区没有足够空间时触发Young GC清理新生代
-
对象进入老年代的时机:
- 长期存活对象进入老年代:对象头信息存储对象的迭代年龄,迭代年龄会在每次YoungGC后对象的移区操作时增加1,年龄达到15之后对象移入老年代
- 大对象直接进入老年代:诸如数组和长字符串等占用大量连续内存空间的对象直接进入老年代
- 动态对象年龄判定:Survivor空间中相同年龄所有对象大小总和大于Survivor一半,年龄大于等于这一年龄的对象进入老年代
- 空间分配担保:Young GC后如果新生代仍然有大量对象存活,需要老年代进行分配担保,Survivor无法容纳的对象直接进入老年代
-
整堆收集触发时机:
- 调用System.gc()
- 老年代空间不足
- 如果方法区由永久代实现,方法区内存空间不足触发Full GC
- 空间分配担保失败,新生代To区放不下由From和Eden拷贝的对象或者新生代对象GC年龄到达阈值需要晋升,老年代放不下都会FullGC
- 老年代可用的连续内存空间 < 新生代历次Young GC后升入老年代的对象总和的平均大小,触发Full GC
-
-
内存分配与回收策略
-
对象优先在 Eden 分配
大多数情况下,对象在新生代Eden区分配。当Eden区没有足够空间进行分配时,虚拟机发起一次Minor GC。
-
对象进入老年代并基于其时机
-
-
直接内存(堆外内存):直接内存是除JVM之外的内存,可能被Java使用
- 直接内存申请空间耗费更多性能
- 直接内存读取IO性能优于普通堆内存
- NIO可以调用本地方法直接分配JVM虚拟机之外的内存,通过堆中的DirectByteBuffer对象直接操作该内存
-
四种引用方式
- 强引用:创建对象并赋值给一个引用变量,new出来的对象的变量引用都是强引用,有引用变量指向时永远不会垃圾回收即便JVM抛出OOM。将引用赋值为null,所指向的对象就会垃圾回收
- 软引用:内存空间足够不会,垃圾回收器不会回收;内存空间不够,垃圾回收器回收这些对象的内存
- 弱引用:JVM进行回收时,无论内存是否充足,弱引用对象都会被回收
- 虚引用:虚引用不会决定对象的生命周期,一个对象持有虚引用就和没有持有引用一样,任何时候都有可能被垃圾回收器回收
对象的内存布局、创建过程、分配过程与访问方式
-
对象的内存布局
- 对象内存布局分为对象头、实例数据和对齐填充
- 对象头记录了对象运行过程中使用的一些数据,包括哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳;对象头可能包含类型指针,通过该指针能确定对象属于哪个类;如果对象是数组,对象头还包括了数组长度
- 实例数据包含了父类成员变量和当前类成员变量的值
- 确保对象总长度是8字节的整数倍
-
对象创建过程
-
类加载:虚拟机在解析.class时,若遇到一条new指令,则会去检查常量池中是否有这个类的符号引用、这个符号引用所代表的的类是否已经被加载、解析和初始化过,如果没有则进行类加载过程。
-
为新生对象分配内存:对象所需内存大小在类加载完后可以确定,从堆中划分一块对应大小的内存空间给新的对象,分配堆中内存有两种方式:
- 指针碰撞:如果堆中内存绝对规整,说明采用的是复制算法或标记整理算法,空闲内存和已使用内存中间放一个指针作为分界点指示器,分配内存时只需要把指针向空闲内存挪动一段与对象大小一样的距离
- 空闲列表:如果堆中内存并不规整,已使用内存和空闲内存交错即存在碎片,说明采用的是标记-清除法。JVM维护一个列表,记录空闲可用的内存块,从空闲列表中找到一块足够大的内存空间划分给对象实例
-
初始化:内存分配完之后,为对象中成员变量赋初始值、设置对象头信息,调用对象的构造方法进行初始化
-
-
new对象时是否存在线程安全问题
JVM new对象可能存在
-
对象分配过程
- new 的对象先放在 Eden 区,大小有限制
- 如果创建新对象时,Eden 空间填满了,就会触发 Minor GC,将 Eden 不再被其他对象引用的对象进行销毁,再加载新的对象放到 Eden 区,特别注意的是 Survivor 区满了是不会触发 Minor GC 3的,而是 Eden 空间填满了,Minor GC 才顺便清理 Survivor 区
- 将 Eden 中剩余的对象移到 Survivor0 区
- 再次触发垃圾回收,此时上次 Survivor 下来的,放在 Survivor0 区的,如果没有回收,就会放到 Survivor1 区
- 再次经历垃圾回收,又会将幸存者重新放回 Survivor0 区,依次类推
- 默认是 15 次的循环,超过 15 次,则会将幸存者区幸存下来的转去老年区 jvm 参数设置次数 : -XX:MaxTenuringThreshold=N 进行设置
- 频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间搜集
-
对象访问方式
由于对象存储空间在堆中分配、对象引用在堆栈中分配。根据引用存放的地址类型不同,对象有不同的访问方式:
-
句柄访问方式
堆中有一块称作“句柄池”的内存空间,句柄中包含了对象实例数据和类型数据各自的具体地址信息。访问对象时,首先需要通过引用类型的变量找到该对象的句柄,然后根据句柄中对象的地址找到对象。
-
直接指针访问方式
引用类型变量直接存放对象地址,不需要句柄池,通过引用直接访问对象。只需要一次寻址操作,所以在性能上比句柄访问方式快一倍,但需要额外策略存储对象在方法区中的类信息。
-
垃圾收集策略与算法
-
判断对象是否存活
-
引用计数法:对象头维护一个counter计数器,对象被引用一次则计数器+1,引用失效则计数器-1,计数器为0时对象无效。由于对象之间循环引用问题的存在,引用计数算法
-
可达性分析法:所有和GC Roots直接或间接关联的对象都是有效对象,和GC Roots没有关联的对象就是无效对象
-
GC Roots:
- Java虚拟机栈中局部变量表引用的对象
- 本地方法栈中引用的对象
- 方法区中常量引用的对象
- 方法区中类静态属性引用的对象
-
-
-
回收方法区内存:
- 方法区中主要清除两类垃圾:废弃常量和无用的类
- 只要常量池中的常量不被任何对象或变量引用,被清除
- 一个类被虚拟机加载进方法区,那么在堆中有一个代表该类的对象java.lang.Class。当该类的所有对象已被清除、加载该类的ClassLoader已被回收、该类的java.lang.Class对象没有被引用且无法在任何地方通过反射访问该类的方法,此时该类作为无用的类被清除
-
垃圾收集算法
-
标记-清除算法:
- 标记过程:遍历所有的GC Roots,将所有GC Roots可达的对象标记为存活的对象
- 清除:遍历堆中所有对象,将没有被标记的对象全部清除,清除被标记对象的标记以便下次垃圾回收
不足:标记和清除的两个过程效率都不高,标记清除之后产生大量不连续的内存碎片,导致无法进行大对象内存空间的分配而不得不进行再一次垃圾回收
-
用于新生代的复制算法
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完,需要进行垃圾收集时,就将存活者的对象复制到另一块上面,然后将第一块内存全部清除。为了解决内存碎片、内存会缩小为原来一半而浪费空间的问题,可以将内存分为三块: Eden、From Survivor、To Survivor,比例是 8:1:1,每次使用 Eden 和其中一块 Survivor。回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才使用的 Survivor 空间。这样只有 10% 的内存被浪费。
为对象分配内存空间时,如果 Eden+Survivor 中空闲区域无法装下该对象,会触发 MinorGC 进行垃圾收集。但如果 Minor GC 过后依然有超过 10% 的对象存活,这样存活的对象直接通过分配担保机制进入老年代,然后再将新对象存入 Eden 区。
-
用于老年代的标记-整理算法
- 遍历所有的GC Roots,将所有GC Roots可达的对象标记为存活的对象
- 移动所有存活的对象且按照内存地址次序依次排列,将末端内存地址以后的内存全部回收
-
分代算法:根据对象存活周期的不同,将内存分为几块,一般是把java堆分为新生代和老年代,根据各个年代的特点采用最适当的收集算法。比如新生代采用复制算法,老年代采用标记清除算法和标记整理算法
-
HotSpot垃圾收集器
-
新生代垃圾收集器
- 单线程的Serial垃圾收集器:只开启一条线程进行垃圾回收,在垃圾收集过程中停止一切用户线程机Stop The World。避免了线程切换的开销,简单高效,适合客户端使用。
- 多线程的ParNew垃圾收集器:由多条 GC 线程并行地进行垃圾清理,清理过程依然需要 Stop The World
- 多线程的Parallel Scanvenge垃圾收集器:与ParNew不同,ParNew追求降低用户停顿时间,适合交互式应用,Parallel Scavenge追求 CPU 吞吐量,能够在较短时间内完成指定任务,因此适合没有交互的后台计算。
-
老年代垃圾收集器
-
单线程的Serial Old垃圾收集器:与Serial类似,不同之处在于Serial Old 工作在老年代,使用“标记-整理”算法;Serial 工作在新生代,使用“复制”算法。
-
多线程的Parallel Old垃圾收集器:Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。
-
CMS垃圾收集器:
- 初始标记:STW、单线程运行标记GC Roots直达的对象
- 并发标记:无停顿和用户线程同时运行,从GC Roots直达对象开始遍历整个对象图
- 重新标记:STW、多线程运行标记并发阶段产生的对象
- 并发清除:无停顿,和用户线程同时运行清理标记阶段标记的死亡的对象
-
CMS缺点:
- CMS产生的内存碎片比较多
- CMS并发能力依赖于CPU,并发回收时垃圾收集线程可能会抢占用户线程的资源,导致用户程序性能下降。
- 并发清除阶段,用户线程依然在运行,产生“浮动垃圾”,本次垃圾收集无法处理浮动垃圾,必须到下一次垃圾收集才能处理。如果浮动垃圾太多,会触发新的垃圾回收,导致性能降低。
-
-
G1通用垃圾收集器: 面向服务端应用的垃圾收集器,没有新生代和老年代的概念,避免了CMS内存碎片
工作步骤:
- 初始标记:Stop The World,仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记。
- 并发标记:使用一条标记线程与用户线程并发执行。此过程进行可达性分析,速度很慢。
- 最终标记:Stop The World,使用多条标记线程并发执行。
- 筛选回收:回收废弃对象,此时也要 Stop The World,并使用多条筛选回收线程并发执行。
-
垃圾收集器的取舍:
- Serial:适用于有较小内存空间的应用程序
- Parallel:比较关注峰值性能,可以忍受1秒或更长停顿时间的程序
- CMS/G1:关注响应时间,垃圾收集器暂停必须保持在1秒以内
类文件结构
- 4字节的魔数、4字节的JDK版本号、常量池(字面值常量即程序中定义的字符串、final修饰的值;符号引用即类和接口的全限定名、字段和方法名称及修饰符)、访问标志(Class是类还是接口、是否定义为public、是否被abstract/final修饰)
类加载时机和加载过程和类加载器
-
类完整生命周期:加载->连接(验证/准备/解析)->初始化->使用->卸载
-
类加载过程:加载->连接->初始化
-
加载:获取类的二进制字节流,一个非数组类的加载阶段是可控性最强的阶段,自定义类加载器通过重写loadClass方法控制字节流的获取方式。数组类型不通过类加载器创建,通过JVM直接创建。 所有类都由类加载器加载,加载的作用就是将.class加载至内存
具体说来分为三步:
- 通过全类名获取定义此类的二进制字节流
- 将字节流代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口
-
连接过程:验证->准备->解析。加载阶段和连接阶段部分内容交叉进行,加载阶段尚未结束连接阶段可能已经开始
-
验证:确保加载的类符合 JVM 规范和安全,保证被校验类的方法在运行时不会做出危害虚拟机的事件,其实就是一个安全检查
- 文件格式验证,即验证字节流是否符合Class文件格式的规范
- 元数据验证,对字节码描述信息进行语义分析,保证描述信息符合Java语言规范的要求
- 字节码验证,通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的
- 符号引用验证,确保解析动作能正确执行
-
准备:正式为类变量分配内存并设置类变量的初始化值,这些内存都将在方法区中分配。JDK7以前,HotSpot使用永久代实现方法区;JDK7及以后,原本放在永久代中的字符串常量池和静态变量放到了堆中。
-
解析:虚拟机将常量池中的符号引用替换为直接引用,即得到类、字段、方法在内存中的指针和偏移量;符号引用就是一组符号描述目标,可以是任何字面量;直接引用包括直接指向目标的指针、相对偏移量、一个间接定位到目标的句柄。
-
初始化:执行类构造器方法的
<clinit>()过程,是类加载最后一步,JVM开始真正执行类中定义的Java程序代码。<clinit>()是带锁线程安全的,多线程环境下进行类初始化可能会引起多个进程阻塞。- 程序在实例化一个类对象时、访问类的静态变量、调用类的静态方法时对类进行初始化
- 程序在使用Class.forName、getInstance对类进行反射调用时对类进行初始化
- 初始化一个类,如果父类未初始化则伴随对父类的初始化
-
卸载:
卸载类即该类的Class对象被GC,需要满足3个条件:
- 该类所有的实例对象都已被GC,即堆中不存在该类的实例对象
- 该类没有在其他地方被引用
- 该类的类加载器实例已经被GC
-
-
Java包括三个重要的类加载器ClassLoader,除了BootstrapClassLoader其他类加载器均由Java实现且继承java.lang.ClassLoader。
- 启动类加载器BootstrapClassLoader,最顶层的加载类,由C++实现,负责加载JAVA_HOME/lib下的jar和类
- 扩展类加载器ExtClassLoader,负责加载JRE_HOME/lib/ext下的jar和类
- 应用程序类加载器AppClassLoader,加载当前应用classpath下的所有jar和类
-
类加载器负责加载.class文件,字节码文件在开头有特定的文件标示,类加载器负责将.class文件字节码内容加载到内存并将其转换成方法区的运行时数据结构
-
双亲委派模型
双亲委派模型描述了类加载器之间的层次关系,要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器,父子关系一般不会以继承的关系实现,而是以组合关系来复用父加载器的代码。每一层的类加载器收到类加载请求,首先不会自己尝试去加载这个类,而是把这个请求委派给父加载器完成,所有的加载请求最终都会传送到顶层的启动类加载器BootstrapClassLoader中,只有父加载器不能处理当前请求子加载器才会尝试加载。
双亲委派模型保证了Java程序的稳定运行,避免类的重复加载、保证Java的核心API不被篡改。诸如java.lang.Object这些存放在rt.jar中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,使得不同类加载器加载的Object类是同一个。如果不采用双亲委派模型,自己编写Object并放在classpath下,系统将会出现多个不同Object类,Java类型体系中的最基础行为无法保证。
如果不想用类加载器:
自定义类加载器需要继承ClassLoader,如果不想打破双亲委派模型需要重写ClassLoader的findclass方法,无法被类加载器加载的类最终通过这个方法加载,如果想打破双亲委派模型需要重写loadclass方法
-
引用方式
- 强引用可以直接访问目标对象。只要这个对象被强引用所关联,那么垃圾回收器都不会回收,那怕是抛出OOM异常。容易导致内存泄漏。
- 如果某个对象他只被软引用所指向,那么他将会在内存要溢出的时候被回收。如果某个对象他只被软引用所指向,那么他将会在内存要溢出的时候被回收。
- 弱引用:非必需对象,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。
- 虚引用:虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
-
判断类是否“相等”:任意一个类,都由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。
数据库
-
基本概念:
- 数据库的概念:信息的集合、由数据库管理系统管理的数据的集合
- 数据库管理系统:是一种操作和管理数据库的大型软件,通常用于建立、使用、维护数据库
- 数据库管理员:负责全面管理和控制数据库系统
- 数据库系统:由软件、数据库、数据库管理员DBA组成
-
主键和外键的区别
- 用于唯一标识一个元组,不能有重复、不允许为空。一个表只能有一个主键
- 外键用于与其他表建立关联,外键是另一张表的主键吗,外键可以有重复、可以为空。一张表可以有多个外键
-
数据库范式
- 第一范式,表中的字段(即属性)不能再被分割,这个字段只能是一个值不能再分为多个其他的字段。1NF是所有关系型数据库最基本的要求
- 第二范式,在第一范式的基础上消除了非主属性对于码的部分函数依赖
- 第三范式,在第二范式的基础上消除了非主属性对于码的传递函数依赖
所谓的函数依赖,指在一张表中,X值确定的情况下必能确定Y的值,说明Y依赖于X
-
不同隔离级别的实现
在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个SQL语句开始执行的时候创建的。“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念。串行化”隔离级别下直接用加锁的方式来避免并行访问
-
MVCC实现原理
MVCC多版本并发控制,通过版本链的方式控制并发事务访问同一个记录时的行为。ReadView包括四个重要字段:活跃事务的id列表、活跃事务中id最小的事务、创建ReadView时数据库应该给下一个事务的id值、创建ReadView的事务id;此外每一个事务对某条聚簇索引记录修改时,事务id记录在trx_id隐藏列中。访问记录时,如果trx_id小于最小id说明事务已经在创建ReadView前提交,对当前事务可见;如果trx_id大于最大事务id则说明在ReadView后提交,对当前事务不可见;如果事务id在最小事务id和最大事务id之间,检查是否在活跃事务列表中,如果不在说明事务已经提交可见,否则不可见。
-
幻读是如何解决的?
幻读:在一个事务内多次查询符合某个查询范围的记录,后一次查询看到了前一次查询中没有看到的行
解决:首先对于MySQL而言,可重复隔离级别下,普通的查询是快照读,是看不到其他事务插入数据的。而对于update、delete、insert都是当前读(这些操作会对记录加独占锁,其他事务对持有独占锁的记录进行修改会被阻塞,锁必须等到事务结束才会释放)
InnoDB为了解决可重复读隔离级别使用当前读而造成的幻读问题,引入了行锁。通过next-key锁,即记录锁和间隙锁的组合锁住记录之间的间隙和记录本身,防止其他事务在这个记录之间插入新记录。 -
不要执行不带索引的update语句,导致业务停滞
Next-Key Lock算法锁的是索引而不是数据本身,如果update语句的where条件没有用到索引列就会全表扫描,在一行行的扫描过程中,不仅加了行锁还加了间隙锁,相当于锁住整个表并直到事务结束才会释放锁
-
两阶段提交:目的是避免出现两份日志redo log和binlog之间的逻辑不一致问题
- prepare 阶段:将 XID(内部 XA 事务的 ID) 写入到 redo log,同时将 redo log 对应的事务状态设置为 prepare,然后将 redo log 写入到log buffer,并 fsync持久化到磁盘中;
- commit 阶段:把 XID 写入到 binlog,然后将 binlog 写入logbuffer,并fsync持久化到磁盘,接着调用引擎的提交事务接口,将 redo log 状态设置为 commit;
-
binlog(Server)以及与 redo log的区别
binlog只用于归档,基于redo log才实现了故障恢复的能力;使用binlog完成备份恢复、主从复制;binlog采用追加写入,binlog文件写到一定大小后会切换下一个且不会覆盖以前的日志
- binlog是MySQL的Server层实现的日志,redo log是InnoDB存储引擎层实现的日志
- binlog 记录事务的具体操作内容,是逻辑日志,redo log记录每个页更改的情况,诸如XXX表空间的YYY页的ZZZ偏移量做了AAA更新,是物理日志
- binlog是追加写,保存了全量的日志,写满一个文件就创建一个新的文件写入不会覆盖之前的数据;redo log循环写,日志空间大小是固定的,保存未被刷入磁盘的脏页数据
- binlog用于主从复制、备份恢复;redo log用于掉电等故障恢复
- redo log在prepare阶段就刷盘一次,事务执行过程中的redo log也是写在redo log buffer中,而缓存在redo log buffer中的重做日志被后台线程每隔1秒刷入磁盘一次,所以在事务未提交前就可以持久化到磁盘;binlog必须在事务提交后才可以持久化到磁盘
MySQL 锁
-
根据加锁范围分为:全局锁、表级锁和行锁
-
全局锁:对整个数据库实例加锁,使整个库处于只读状态。对整个库加锁导致对数据的增删改、对表结构的更改等操作都会被阻塞。用于全库逻辑备份场景,不会因为表结构或数据的更新出现备份文件的数据与预期的不一样。
风险:主库备份期间不能更新,业务停摆;从库备份期间不能执行主库同步的binlog,导致主从延迟。
为了解决数据备份时对业务影响,对于支持可重复读隔离级别的数据库,mysqldump 时加上 –single-transaction,在备份数据库之前开启事务,整个事务在执行期间使用事务开启时的Read View,基于MVCC的支持,可以在主从备份期间保持业务更新。
-
表级锁:MySQL中的表级锁分为表锁、元数据锁(MDL)、意向锁、AUTO-INC锁。
-
在对数据库表操作时自动加MDL。对表进行CRUD加MDL读锁,对表进行结构变更操作加MDL写锁。保证对表进行CRUD时防止其他线程对表结构做了变更。MDL在事务执行期间一直持有,直到事务提交后才会释放
-
当执行插入、更新、删除,先对表加上意向独占锁,然后对该记录加独占锁。意向锁的目的是为了快速判断表里是否有记录被加锁,在没有意向锁时需要遍历表里所有记录来查看是否有记录存在独占锁,效率很低。表锁和行锁是读读共享、读写互斥、写写互斥的。
-
在插入数据时,会加一个表级别的AUTO-INC锁,然后为被AUTO_INCREMENT修饰的字段赋值递增的值,等插入语句执行完成后,才会把AUTO-INC锁释放掉;AUTO-INC是特殊的表锁机制,锁不再是一个事务提交后才释放,而是在执行完插入语句后就会立刻释放,其他事务如果在未释放时向表插入语句会被阻塞,大量数据插入影响性能。MySQL 5.1.22版本开始,InnoDB使用一种轻量级锁实现自增,插入数据时对AUTO_INCREMENT修饰的列加上轻量级锁,当给字段赋自增值就把轻量级锁释放,不需要等待插入语句完成。
轻量级锁在并发场景下带来一定问题,因为并发插入的存在,在每次插入时自增长的值可能不是连续的,这在主从复制场景下不安全
-
-
行锁:行锁分为三种类型,其中Record Lock表示仅把一条记录锁上的记录锁;Gap Lock表示仅锁定一个范围且不包含记录本身的间隙锁;Next-Key Lock为Record Lock+Gap Lock,同时锁定记录和范围。对记录加锁时,加锁的基本单位是由记录锁和间隙锁组成的 next-key lock。next-key lock在一些场景下会退化成记录锁或间隙锁。
-
当用唯一索引进行等值查询时,如果查询的记录存在,next-key lock会退化成记录锁,如果查询的记录不存在,next-key lock退化成间隙锁
-
当用非唯一索引进行等值查询时,如果查询的记录存在,除了会加next-key lock外,还额外加间隙锁,也就是加两把锁,如果查询的记录不存在,next-key lock退化为间隙锁,也就是会加一把锁。
-
非唯一索引和主键索引的范围查询的加锁规则不同之处在于:唯一索引在满足一些条件的时候,next-key lock 退化为间隙锁和记录锁;非唯一索引范围查询,next-key lock 不会退化为间隙锁和记录锁。
-
在InnoDB事务中,对记录加锁的基本单位是next-key lock,但会因为一些条件退化成记录锁或间隙锁,加锁的位置准确说来时加在索引上而非行上。
Next-Key Lock算法锁的是索引而不是数据本身,如果update语句的where条件没有用到索引列就会全表扫描,在一行行的扫描过程中,不仅加了行锁还加了间隙锁,相当于锁住整个表并直到事务结束才会释放锁;如果update的where条件采用唯一索引等值查询那么会对某记录进行记录锁,不会阻塞其他update操作;如果update的where条件没有使用索引就会全盘扫描,对所有记录加 next-key lock,相当于锁住整个表。只有update语句where条件带上了索引并且优化器最终选择的是索引扫描才能避免全表被锁
-
-
行锁的实现:
-
-
数据库层面避免死锁:通过打破循环等待条件来解除死锁
- 设置事务等待锁的超时时间,一个事务的超时时间超过指定值后对事务进行回滚,锁释放
- 开启主动死锁检测。主动死锁检测在发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。
-
InnoDB会为Buffer Pool申请一片连续的内存空间并按照默认的16KB大小划分出一个个页,Buffer Pool中的页叫做缓存页。与操作系统一样,MySQL刚启动时使用到的虚拟内存空间很大而物理内存空间较小,只有在虚拟内存被访问后操作系统才会触发缺页中断并将虚拟地址和物理地址建立映射关系才会真正使用物理内存。
- InnoDB为每一个缓存页创建一个控制块来更好的管理Buffer Pool中的缓存页。其中空闲缓存页的控制块构成了free链表
- 每当需要从磁盘中加载一个页到Buffer Pool中时,就从free链表中取一个空闲的缓存页并把该缓存页对应的控制块信息填上,然后把该缓存页对应的控制块从free链表中移除
- 更新数据时不需要每次都写入磁盘,而是将Buffer Pool中对应的缓存页标记为脏页并由后台线程写入磁盘
-
如何提高Buffer Pool的缓存命中率
- 将 LRU 链表 分为young 和 old 两个区域,加入缓冲池的页,优先插入 old 区域;页被访问时,才进入 young 区域,目的是为了解决预读失效的问题。
- 当**「页被访问」且「 old 区域停留时间超过 innodb_old_blocks_time 阈值(默认为1秒)」**时,才会将页插入到 young 区域,否则还是插入到 old 区域,目的是为了解决批量数据访问,大量热数据淘汰的问题
-
脏页的刷新:
- 当redo log日志满了的情况下主动触发脏页刷新到磁盘
- Buffer Pool空间不足时需要将一部分数据页淘汰,如果淘汰的是脏页需要将脏页刷新到磁盘
- MySQL认为空闲时,后台线程定期将适量脏页刷新到磁盘
- MySQL正常关闭时,将所有脏页刷新到磁盘
脏页刷新到磁盘可能带来性能开销导致数据库操作抖动,增大BufferPool或者redo log
Redis
-
简单介绍redis:
Redis是使用C语言开发的基于键值对的内存数据库,读写速度更快,提供了丰富的数据类型支持不同业务场景,支持事务、持久化、集群等特性,多用于缓存、消息队列、分布式锁。Redis提供的数据类型包括String/Hash/List/Set/ZSet/BitMaps/HyperLogLog/GEO/Stream,且对数据类型的操作是原子的,执行命令由单线程负责不存在并发竞争
-
Redis与Memcached都基于内存实现,作为缓存使用都具备过期策略,两者的性能都很高。但:
- Redis支持更丰富的数据类型,支持更复杂的场景
- Redis支持数据持久化并支持灾难恢复机制,可以把缓存数据持久化到磁盘,重启或者故障恢复可以再次加载使用;Memcached数据只能存在内存中
- Redis原生支持集群模式,Memcached没有原生集群模式,需要依靠客户端实现往集群中分片写入数据
- Redis支持发布订阅模型、Lua脚本和事务功能,Memcached不支持
-
为什么用Redis作为缓存 高性能+高并发,Redis单机QPS可达10W
-
单线程的Redis为什么这么快?
- 纯内存操作
- 单线程避免了频繁的上下文切换
- 采用了非阻塞I/O多路复用技术(单个线程通过跟踪每个I/O流的状态,管理多个I/O流)
- 高效的数据结构
-
Redis数据类型与应用场景:
- String用于缓存对象、常规计数、分布式锁、共享session信息
- List用于消息队列
- Hash缓存对象、购物车
- Set支持聚合计算即求并集、差集、交集等,比如点赞、共同关注
- ZSet用于排序场景,比如排行榜、电话和姓名排序
- BitMaps用于二值状态统计场景,包括签到、判断用户登录状态、连续签到用户总数等
- HyperLogLog用于海量数据基数统计的场景,比如百万级网页UV计数
- GEO存储地理位置信息
- Stream消息队列,相对于List,自动生成全局唯一消息ID、以消费组形式消费数据
-
Redis数据结构
- String类型底层数据结构主要是简单动态字符串SDS,相对于C原生字符串,SDS不仅可以保存文本数据还可以保存二进制数据,SDS使用len而不是空字符来判断字符串是否结束,获取字符串长度的时间复杂度为O(1),拼接字符串之前检查SDS空间是否足够如果不够则自动扩容,SDS的API是安全的拼接字符串不会造成缓冲区溢出
- 早期List类型底层结构基于双向链表或压缩列表实现,如果列表元素个数小于512、列表每个元素值小于64字节,Redis使用压缩列表作为List底层数据结构;否则List使用双向链表作为底层数据结构。Redis3.2版本之后,List数据类型底层数据结构采用quicklist实现
- 如果Hash元素个数小于512、所有元素值小于64字节采用压缩列表实现,否则采用哈希表实现;Redis7.0以后基于listpack实现
- 集合中元素都是整数且元素个数小于512,Redis使用整数集合作为Set类型数据结构,否则使用哈希表作为Set底层结构
- 如果元素个数小于128且元素值小于64字节,采用压缩列表作为底层结构,否则使用跳表作为底层结构
- Redis过期删除策略和内存淘汰策略
-
Redis如何判断数据过期:Redis通过类似hash表的过期字典保存数据过期时间,过期字典键即Redis数据库中的key,值为longlong类型的整数保存了过期时间
-
过期数据的删除策略:惰性删除+定期删除。惰性删除只有在取key时才对数据进行过期检查,对CPU更加友好但是会有过期key遗留内存;定期删除每隔一段时间抽取一批key进行过期key的删除。
-
内存淘汰机制(缓存的热点数据量远小于数据库数量量,此时要考虑内存淘汰机制)
- volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
- allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
- no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。
-
- Redis事务:使用 MULTI 命令后可以输入多个命令。Redis 不会立即执行这些命令,而是将它们放到队列,调用EXEC命令执行所有命令
-
持久化
Redis作为内存数据库会将缓存数据保存磁盘,Redis支持两种持久化技术分别是AOF日志和RDB快照,默认开启RDB快照。RDB的优点在于容灾性好,适合备份、全量复制的场景;缺点在于实时性低,间隔持久化会导致数据丢失;AOF优点在于实时性好,尽可能避免了数据丢失,但AOF文件更大,恢复速度慢。
-
AOF
-
Redis每执行一条写操作命令,就把该命令以追加的方式写入到一个文件中
-
先执行写操作命令再进行命令追加的优势:
- 避免额外指令合法性检查的开销
- 不会阻塞当前写操作指令的执行。但此命令主线程执行可能对后续操作产生阻塞
-
AOF的执行步骤:
- Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区;
- 然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;
- 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。
-
AOF的3种写回磁盘策略(内核缓存区数据写入磁盘由fsync函数控制):
- Always,每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;高可靠
- Everysec,每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
- No,每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。高性能
-
AOF重写:根据每一个键值对当前的最新状态,然后用一条命令去记录键值对(读取最新的值,一条命令替换了历史版本多条命令;写到新AOF目的是:如果 AOF 重写过程中失败了,现有的 AOF 文件就会造成污染)
-
重写 AOF 过程是由后台子进程 bgrewriteaof 来完成
- AOF重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程;
- 子进程带有主进程的数据副本,父子进程任意一方修改了该共享内存,就会发生写时复制,避免了多线程共享数据加锁保证并发安全的性能开销
-
为了避免AOF重写过程中,父进程与子进程数据不一致,Redis设置了AOF 重写缓冲区:
AOF重写期间,Redis执行完一个写操作命令同时将写命令写入到AOF缓冲区和AOF重写缓冲区。子进程完成AOF重写之后向主进程发送一条信号,主进程收到后执行信号处理函数:
1. 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致; 2. 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件 -
AOF重写期间,只有写时复制和执行信号处理函数对主进程产生阻塞
-
-
-
RDB
-
Redis可以通过创建快照来获取存储在内存中数据在某个时间点的副本,RDB快照是Redis默认的持久化方式,RDB快照是全量快照,每次执行快照将内存中所有数据记录到磁盘
-
Redis可以使用bgsave指令创建子进程来生成RDB文件,避免对主线程的阻塞。在执行bgsave过程中,Redis可以继续处理操作命令,数据可以被修改,这里会用到写时复制策略。
具体说来:
- 执行bgsave指令时,通过fork()创建子进程,复制父进程的页表,页表记录着虚拟地址和物理地址映射关系,此时页表指向的物理内存还是同一个,子进程和父进程共享同一片内存数据,当内存数据发生修改时,CPU触发缺页中断,操作系统在缺页异常处理函数中进行物理内存复制,物理内存被复制(这一过程的目的是为了减少创建子进程时的性能损耗,加快创建子进程速度,毕竟创建子进程是阻塞主线程的;此外只复制页表,在没发生数据修改时可以节约物理内存资源)
- 写时复制时,父进程将数据的物理内存复制一份,主线程在数据副本进行操作,而bgsave子进程可以继续把原来的数据写入到RDB文件
-
RDB比AOF的数据恢复速度快但是快照的频率需要更好的把握:
- 频率低,两次快照间一旦服务器宕机,丢失比较多的数据
- 频率高,频繁写入磁盘和创建子进程带来额外性能开销
-
-
混合持久化:混合使用AOF日志和内存快照,AOF文件的前半部分是RDB格式的全量数据,后半部分是AOF格式的增量数据。
AOF重写日志时,fork出的重写子进程会先将与主线程共享的内存数据以RDB方式写入AOF文件,主线程处理的操作命令记录在重写缓冲区并以AOF方式写入AOF文件,写入完成后通知主进程将新的含有RDB格式和AOF格式的文件替换旧AOF文件。实现了加载速度快和丢失数据少。
-
-
Redis集群:Redis集群通过数据分区来实现数据的分布式存储,通过自动故障转移实现高可用。
-
如何保证Redis高可用:
基于主从复制搭建Redis集群,主从服务器之间采用读写分离的方式。主服务器可以进行读写操作,当发生写操作时自动同步给从服务器,从服务器一般只读、接收主服务器同步的写操作命令并执行,此外基于Redis Sentinel哨兵模式实现主从故障转移,此外为了缓解写压力以及解决缓存数据量过大问题,使用Redis Cluster实现分片集群。
-
数据分区
数据分区 (或称数据分片) 是集群最核心的功能。集群将数据分散到多个节点,一方面 突破了 Redis 单机内存大小的限制,存储容量大大增加;另一方面 每个主节点都可以对外提供读服务和写服务,大大提高了集群的响应能力。
Redis切片集群:部署多Redis实例,Redis实例之间地位平等,没有主从之分且同时对外提供读写服务,缓存数据库相对均匀分布在这些Redis实例上。Redis Cluster通过分片进行数据管理、提供复制和故障转移功能。Redis使用虚拟槽分区方案,Redis集群把所有的数据映射到16384个槽中。每个节点对应若干个槽,只有当节点分配了槽,才能响应和这些槽关联的键命令。
-
主从复制:Redis提供的主从复制模式可以保证多台服务器的数据一致性,且主从服务器之间采用读写分离的方式。主服务器可以进行读写操作,当发生写操作时自动同步给从服务器,从服务器一般只读、接收主服务器同步的写操作命令并执行。
- 第一次同步:在从服务器上执行replicaof命令确定服务器主从关系。第一次同步分为三个阶段:建立链接、协商同步,为全量复制做准备;主服务器同步数据给从服务器;主服务器发送新写操作命令给从服务器。
-
哨兵模式:哨兵模式可以监控主从服务器的状态,提供主从节点故障转移的功能。哨兵是一个运行在特殊模式下的Redis进程,负责监控、选主、通知,哨兵一般是以哨兵集群形式存在,多个哨兵共同监控,避免因单个哨兵网络状态不好引起的误判。
-
监控的过程
-
哨兵每隔1秒给所有主从节点发送PING命令,主从节点收到命令后响应哨兵,如果规定时间内没有响应哨兵PING,哨兵将其标记为主观下线。
-
为了避免因系统压力或网络阻塞引起的误判,由多个节点组成哨兵集群,共同判断主节点的运行状况;
-
哨兵判断主节点主观下线后,向其他哨兵发起投票过程,投出每个哨兵对主节点状态的判断票,当认为主节点下线的哨兵票达到哨兵配置文件中的quorum配置项设定的值后,主节点被哨兵标记为客观下线。
-
-
哨兵集群leader选举的过程:主从切换的过程由leader完成
标记主节点为客观下线的哨兵为候选者,候选者向其他哨兵发送指令即发起投票过程,任何哨兵可以投一票给任一候选者,只有候选者自身可以投票给自身,且在投票过程中任何一个拿到半数以上投票、拿到超过quorum值票数的哨兵可以成为leader。
-
主从故障转移的过程
- 已下线主节点下属的所有从节点中挑选一个作为新主节点。
1. 首先根据down-after-milliseconds过滤网络状态不好的节点2. 首先根据从节点的优先级排序,优先级越小排名越靠前 3. 优先级相同,查看复制的下标,哪个从节点复制主节点的数据最多,越靠前 4. 优先级和复制下标相同的前提下,选择从节点ID较小的一个 5. 哨兵发送SLAVEOF no one给被选中的从节点,解除其从节点身份并作为新的主节点2. 已下线主节点下属的所有剩余从节点修改复制目标为新主节点 3. 新主节点的IP地址和信息以“发布者/订阅者”机制通知给客户端
每个哨兵节点提供发布者/订阅者机制,客户端可以从哨兵订阅消息。具体说来,客户端订阅哨兵提供的频道,主从切换完成后,哨兵向 +switch-master频道发布新主节点的IP地址和端口信息,客户端收到信息并基于新主节点的IP地址和端口进行通信4. 继续监视旧主节点,旧主节点重新上线时设置为新主节点的从节点
-
哨兵集群的原理
哨兵节点之间通过发布者/订阅者机制相互感知实现相互发现,哨兵集群对从节点的运行状态进行监控,主节点已知从节点的信息,哨兵会以每10秒1次的频率向主节点发送INFO命令来获取所有从节点的信息,哨兵根据节点列表的连接信息与每一个从节点建立连接,以持续的对从节点进行监控。
-
-
操作系统
-
操作系统的概念:操作系统本质上是一个运行在计算机上的软件程序用于管理硬件资源和软件资源,屏蔽了硬件层的复杂性。操作系统内核是操作系统的核心部分,负责系统的内存管理、硬件设备的管理、文件系统的管理以及应用程序的管理。
-
什么是虚拟内存?
通过虚拟内存可以让程序拥有超过系统物理内存大小的可用内存空间,虚拟内存为每一个进程提供了一个一致的、私有的内存空间,让每个进程产生了一种自己在独享内存的错觉即每个进程拥有一篇连续完整的内存空间,实现了内存的有效管理并减少了出错的可能。核心在于定义了一个连续的虚拟地址空间,并且把内存扩展到硬盘空间
-
虚拟内存的作用?
- 每个进程都有自己的页表,这些页表是私有的,每个进程的虚拟内存空间都是独立的。解决了多进程之间地址冲突的问题
- 页表里的页表项除了物理地址外,还有一些标记属性的比特,比如控制一个页的读写权限、标记该页是否存在。在内存访问方面,操作系统提供了更好的安全性。
-
虚拟地址空间?
- 程序所使用的内存地址叫做虚拟内存地址,存在硬件里的空间地址叫做物理地址
- 操作系统引入虚拟内存,通过CPU芯片中的内存管理单元MMU映射关系来转换变为物理地址,基于物理地址访问内存
-
-
局部性原理:
- 时间局部性:程序中存在大量循环操作,执行过的指令和访问过的数据在不久的将来再次执行或访问
- 空间局部性:程序访问了某个存储单元的不久之后附近的存储单元也会被访问
-
系统调用及用户态与内核态:
- 根据进程访问资源的特点,可以把进程在系统上的运行分为两个级别:
- 用户态:用户态运行的进程可以直接读取用户程序的数据
- 内核态:内核态运行的进程几乎可以访问计算机的任何资源,不受限制
- 系统调用: 在用户程序的运行过程中,凡是与内核态程序有关的操作(文件管理、内存管理、进程管理)都必须通过系统调用的方式向操作系统提出服务请求,由操作系统代为完成
- 根据进程访问资源的特点,可以把进程在系统上的运行分为两个级别:
-
进程的5种状态:
- 创建状态
- 就绪状态:获得了除CPU之外的一切资源
- 运行状态
- 阻塞状态
- 结束状态
-
操作系统的内存管理主要负责内存的分配与回收,负责地址转换即将逻辑地址转换为相应的物理地址
-
操作系统内存管理机制和方式:
- 内存管理分为连续分配管理和非连续分配管理。连续分配管理指为用户程序分配连续内存空间如块式管理;非连续分配管理方式允许一个程序使用的内存分布在离散或者不相邻的内存中,如页式管理和段式管理
- 块式管理:将内存分为几个固定大小的块,每个块只包含一个进程,容易产生碎片
- 段式管理:段式管理把主存分为一段段的,段有实际意义且每个段定义了一组逻辑信息,比如有主程序段MAIN、子程序段X、数据段D和栈段S。段式管理通过段表对应逻辑地址和物理地址。但是每个段的大小都不是统一的,这就会导致内存碎片和内存交换效率低的问题。
- 页式管理:分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。。将主存分为大小相等且固定的一页一页的形式、页较小,相比于块式管理的划分粒度更小,提高了内存利用率,减少了碎片。页式管理通过页表对应逻辑地址和物理地址
- 段页式管理:先把主存分为若干段,每个段又分为若干页。段页式管理机制中,段与段之间以及段的内部之间都是离散的。段页式地址变换得到物理地址需要三次内存访问,第一次访问段表得到页表起始地址,第二次访问页表得到物理页号,第三次将物理页号与页内偏移组合得到物理地址。
简单来说,页属于物理单位、段属于逻辑单位,分页有利于提高内存利用率、分段可以更好满足用户需求。
-
快表与多级页表
为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。
-
内存分配的过程?内存紧张时会发生什么?
- 内存分配的过程:
- 使用malloc函数申请内存时申请到的是虚拟内存而不是物理内存,当应用程序读写这块虚拟内存,CPU就会去访问这块虚拟内存,发现虚拟内存没有映射到物理内存就产生缺页中断,进程由用户态转为核心态,并将缺页中断中断交由内核的缺页中断函数(Page Default Handler)处理
- 缺页中断函数检查是否有空闲物理内存,如果有则直接分配物理内存并建立物理内存与虚拟内存的映射关系;如果没有就开始进行内存回收,如果内存回收之后空闲内存仍然无法满足此次物理内存的申请,内核触发OOM
- 内存分配的过程:
-
Linux中32位和64位操作系统的虚拟地址空间大小不同,且其被分为用户空间和内核空间两部分。32位虚拟内存中用户空间占3G,64位虚拟内存中用户空间占128T。
-
进程间通信方式:
- 管道:具有亲缘关系的父子进程/兄弟进程间通信
- 信号
- 消息队列
- 信号量
- 共享内存
- 套接字
-
线程间同步方式:
- 互斥量:采用互斥对象机制,公共资源不会被多个线程并发访问
- 信号量:控制同一时刻访问同一资源的最大线程数量
- 事件:采用wait/notify等通知操作的方式实现线程同步
-
进程的调度算法:
- 先到先服务FCFS调度算法:从就绪队列中选择一个最先进入该队列的进程分配资源,使其立即执行并一直执行到完成或者发生某事件而被阻塞放弃占用CPU
- 短作业优先SJF调度算法:从就绪队列中选择一个估计运行时间最短的进程分配资源
- 高响应比优先调度算法:解决FCFS和SJF没有很好权衡短作业和长作业的缺点,先计算响应比优先级,选择响应比优先级高的进程调度
- 时间片轮转调度算法:每个进程分配一个CPU时间片
- 最高优先级调度:为每个进程分配优先级,按照优先级调度
- 多级反馈队列调度算法:「时间片轮转算法」和「最高优先级算法」的综合和发展。多级表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短;「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列。
抢占式调度:进程正在运行时可以被打断,使其将CPU让给其他进程,抢占的原则分为时间片原则、优先权原则、短作业优先原则
-
缺页中断的处理流程
- 在 CPU 里访问一条 Load M 指令,然后 CPU 会去找 M 所对应的页表项。
- 如果该页表项的状态位是「有效的」,那 CPU 就可以直接去访问物理内存了,如果状态位是「无效的」,则 CPU 则会发送缺页中断请求。
- 操作系统收到了缺页中断,则会执行缺页中断处理函数,先会查找该页面在磁盘中的页面的位置。
- 找到磁盘中对应的页面后,需要把该页面换入到物理内存中,但是在换入前,需要在物理内存中找空闲页,如果找到空闲页,就把页面换入到物理内存中。
- 页面从磁盘换入到物理内存完成后,则把页表项中的状态位修改为「有效的」。
- 最后,CPU 重新执行导致缺页异常的指令。
-
页面置换算法:当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面
- 最佳页面置换算法:置换在未来最长时间不访问的页面
- 先进先出页面置换算法:选择在内存中驻留时间最长的页面置换
- 最近最久未使用置换算法:选择最长时间没有被访问的页面置换
- 时钟页面置换算法:所有的页面都保存在一个类似钟面的「环形链表」中
- 最不常用置换算法:发生缺页中断时,选择访问次数最少的页面进行淘汰
-
CPU缓存一致性
CPU和内存的访问性能差距大,CPU内部嵌入CPU cache作为CPU和内存的缓存,分为每个核心的L1/L2 cache以及所有核心共享的L3 cache。CPU Cache由Cache Line组成,同时CPU Line是CPU从内存读取数据的基本单位
-
CPU cache与内存数据同步
- 写直达:数据同时写入Cache和内存
- 写回:新数据仅仅写入Cache Block,只有当Cache Block被替换时才需要写入内存
-
缓存一致性
条件:写传播即某个CPU核心Cache数据更新时,必须要传播到其他核心Cache;事务串行化,某个CPU核心对数据操作顺序必须在其他核心看来一致
-
写传播:总线嗅探,每个核心都会监听总线上的广播事件,CPU对变量的修改通过总线广播事件通知到其他核心,总线嗅探不保证事务串行化
-
MESI基于总线嗅探机制实现了事务串行化,基于状态机机制降低了总线带宽压力,MESI用4个状态已修改、独占、共享、已失效标记Cache Line。
整个MSI状态的变更,由本地CPU核心请求或者其他CPU通过总线传输的请求构成一个流动的状态机。对于已修改、独占状态的Cache Line,修改更新其数据不需要发送广播给其他 CPU 核心
-
计算机网络
-
常见安全攻击
- DNS劫持:域名劫持,将原域名对应的IP地址进行替换,使用户访问到错误的网站或者无法正常访问网站
- CSRF:跨站请求伪造攻击,伪装来自受信任用户的请求来利用受信任的网站
- XSS:跨站脚本攻击,攻击者向Web页面中插入恶意HTML代码,用户浏览网页时恶意HTML代码执行并显示。对输入进行过滤,特别是过滤标签、校验合法输入、限制长度
- DOS:拒绝服务,一切能引起拒绝服务的攻击都被称为DOS攻击,诸如计算机网络带宽攻击、连通性攻击
- DDos:分布式拒绝服务,处于多个不同位置的攻击者同时向一个或几个目标发起攻击
-
RSA和AES的区别:
- RSA采用非对称加密方式,公钥加密,私钥解密。私钥长度一般较长。由于需要大数的乘幂求模运算,运算速度慢,不适合大量数据文件加密
- AES采用对称加密方式,秘钥长度最长256比特,加密和解密速度快,通信双方需要在数据传输前获知加密秘钥
-
GET请求和POST请求的区别:
- GET请求用于请求获取数据,是幂等的、可缓存的,请求数据附加在URL之后,以?分割URL和数据,多个参数以&连接,URL的编码格式采用ASCII码,所有非ASCII字符需要编码之后传输;GET提交数据有大小限制,浏览器一般要求不超过1024字节,少量数据用GET。由于GET请求数据暴露在地址栏,安全性较低
- 一般来说POST请求可携带的数据更多,虽然浏览器也有限制。POST请求一般用于提交数据,有副作用且不幂等、不可缓存,请求的数据存在HTTP请求包的包体。POST请求数据在请求头中,安全性较高
-
TCP可以两次握手吗?
-
三次握手确保客户端和服务端都具备发送数据和接收数据的能力
-
阻止重复历史连接的初始化。在网络拥堵的情况下,旧SYN报文比新SYN报文更早到达了服务端,服务端返回SYN+旧ACK给客户端并处于ESTABLISHED状态,此时服务端可以发送客户端数据,但是客户端期待收到新SYN的确认,这里客户端判断历史连接并发送RST报文断开TCP连接,不会接收这里的数据造成了资源浪费。
-
同步双方的初始序列号。接收方可以去除重复的数据并根据数据包的序列号按序接收,根据ACK报文的序列号标识哪些发送的数据包已经被对方收到两次握手无法保证双方的初始序列号都能被确认接收
-
为什么要四次挥手?
客户端发送FIN报文,表示客户端不再发送数据了但是仍然可以接收数据。服务端通常需要等待完成数据的发送和处理,FIN与ACK报文分开发送,所以是四次
-
TIME_WAIT状态过多的危害?
TIME_WAIT状态只会出现在主动发起连接关闭的一方,会占用系统资源和端口
-
第四次挥手为什么要等待2MSL
- 为了保证客户端发送的最后一个ACK报文段能够到达服务端。客户端发送的ACK报文段可能丢失,因而使服务器收不到对自己已发送的释放连接报文段的确认;服务端重传连接释放报文段,客户端可以在2MSL内收到这个FIN+ACK报文段,随后客户端重传一次确认并重新启动2MSL,客户端和服务端都能进入
- 防止已经失效的连接请求报文段出现在本连接中。客户端在发送完最后一个ACK报文段后,再经过2MSL就可以使本连接持续时间内产生的所有报文段在网络中消失
-
ping的工作原理:ping是一种因特网包探索器,用于测试网络连接量,工作在TCP/IP体系中的应用层,基于控制报文协议ICMP向目的主机发送请求报文,测试目的站是否可达及了解其有关状态
-
IP与MAC
网络层负责提供主机间的逻辑通信,主要作用有寻址和路由、分段和重组。
数据链路层将网络层交付的IP数据包封装成帧,在两个相邻节点间的链路上传送帧
-
为什么有了IP地址还需要MAC地址?
- 只有当设备连入网络时,才能根据他进入了哪个子网来为其分配IP地址,在没有IP地址或者IP地址的分配过程中需要MAC地址来区分不同设备
- IP地址可以比作目标,MAC地址比作真正的收件人,二者缺一不可
-
为什么有了MAC地址还需要IP地址?
-
-
IPv4地址不够的解决方案
- DHCP,动态主机配置协议用于动态分配IP地址
- CIDR,
- NAT,网络地址转换协议
- IPv6
-
ARP协议工作流程
-
从浏览器输入网址到页面显示的过程:
- DNS 解析:将域名解析成对应的 IP 地址。
- TCP连接:与服务器通过三次握手,建立 TCP 连接
- 向服务器发送 HTTP 请求
- 服务器处理请求,返回HTTp响应
- 浏览器解析并渲染页面
- 断开连接:TCP 四次挥手,连接结束
-
Socket与WebSocket:
- Socket是一套标准,完成了对TCP/IP的高度封装,屏蔽了网络细节方便开发者更好的进行网络编程
- WebSocket是应用层的持久化的通信协议,伴随H5而出,用于解决http不支持持久化连接的问题
-
服务协议与端口对应:文件传输协议FTP=21,SSH=22,远程登录服务Telnet=23,DNS域名解析服务=53,HTTP超文本传输协议=80,HTTPS=443
-
HTTP报文结构?
-
请求报文。请求首部=请求行+首部行,请求行=请求方法 请求路径 HTTP版本,请求首部之后有一个空行
-
响应报文。状态行+首部行+响应实体,状态行=协议版本 响应码 状态描述
-
Cookie和Session的区别:
-
Cookie保存在客户端,Session保存在服务端
-
有效期不同,Cookie可设置为长时间保持,比如经常用到的默认登录;Session一般失效时间短,客户端关闭或者Session超时都会失效
-
隐私策略不同,Cookie存储在客户端容易被窃取,Session存储服务端,安全性相对Cookie好一些
-
存储大小不同,单个Cookie保存数据不能超过4k,对于Session来说存储没有上限
-
滑动窗口:
TCP利用滑动窗口实现流量控制,控制发送方的发送速率保证接收方来得及接收。TCP会话双方都各自维护一个发送窗口和接收窗口,TCP头包含window字段,代表窗口的字节容量,最大为65536,这个字段是告诉发送端字节可以接收数据的缓冲区大小
-
拥塞控制: 防止过多数据注入网络中,包括慢开始、拥塞避免、快重传和快恢复
-
慢启动:拥塞窗口cwnd小于慢启动门限时,TCP刚完成连接建立,发送方每收到一个ACK,拥塞窗口cwnd的大小就会加1。发包的个数是指数性的增长。
-
拥塞避免:拥塞窗口cwnd大于等于慢启动门限时,发包的个数是线性增长。
-
快重传:个别报文段的丢失,发送方迟迟收不到确认,误以为发生了拥塞,发送方错误的进行慢开始调整拥塞窗口,降低了传输效率。快重传算法首先要求接收方每收到一个失序的报文段后就立即发出重复确认,使发送方及早知道有报文段没有到达对方,发送方一连收到三个重复确认就应当立即重传接收方未收到的报文段。
-
快恢复:当发送方连续收到三个重复确认,就会把慢开始门限ssthresh减半,接着把cwnd值设置为慢开始门限ssthresh减半后的数值,然后开始执行拥塞避免算法,使拥塞窗口缓慢地线性增大。
-
http与https的区别:
-
http是超文本传输协议,信息是明文传输;Https是具有安全性的SSL加密传输协议
-
http的端口是80,https的端口是443
-
https需要到CA机构申请证书,需要一定费用
-
http直接建立在TCP之上,https是首先需要TCP的三次握手然后需要SSL/TLS的四次握手才能建立连接
-
http1.1与http2.0的区别:
-
http1.1基于文本格式传输数据,http2.0采用二进制格式传输数据更加高效
-
与http1.1不同,http2.0允许在一个连接中同时发送多个请求或响应,且这些请求和响应并行传输而不会被阻塞,避免了http1.1的队头阻塞
-
http1.1每次都要重复header中携带的大量信息,http2.0把header从数据中分离且封装成头帧和数据帧,使用特定算法压缩头帧有效减少了头部信息大小。http2.0在客户端和服务器端记录了之前发送的键值对,对于相同的数据,不会重复发送
-
http2.0允许服务端向客户端推送数据
-
对于TCP连接的理解:
为了数据传输的可靠性和流量控制而维护的状态信息,这些信息的组合包括Socket、序列号和窗口大小称为连接。建立一个TCP连接是达成客户端与服务端在以上三个信息的共识,其中Socket由IP地址+端口号组成,序列号用于解决乱序问题,窗口大小用于流量控制。在Linux上,每一个连接都是一个文件。
-
键入URL到网页显示的详细步骤:
- 浏览器对URL进行解析,生成发送给web服务器的请求信息
- 从DNS中解析域名得到IP地址,应用程序调用Socket库,委托操作系统协议栈将请求消息发送web服务器,
- 协议栈上层TCP/UDP接收客户端委托执行收发数据操作,协议栈下层IP协议控制网络包的收发。IP还包括ICMP协议用于负责报告网络包传送过程中产生的错误和各种控制信息,ARP协议根据IP地址查询相应的以太网MAC地址。HTTP 是经过 TCP 传输的,所以在 IP 包头的协议号,要填写为 06(十六进制),表示协议为 TCP。
- http数据包在加上TCP头部和IP头部之后来到数据链路层,这一层的设备主要是网卡,控制网卡的是网卡驱动程序,网卡会在开头加上报头(MAC地址+协议类型,IP是)和起始帧分界符,在末尾加上用于检测错误的帧校验序列。
- 网卡将数据包由数字信号转化成电信号并由网线发送出去,电信号到达网线接口并由交换机模块接收,交换机端口不具备MAC地址,将所有包放到缓存区且不会进行帧错误校验和丢弃,根据MAC地址表查找 MAC 地址,然后将信号发送到相应的端口。
- 数据包最终到达了路由器,路由器端口具备MAC地址且将电信号转化数字信号后对包末尾的 FCS 进行错误校验,如果不是发给自己的就丢弃;满足条件的包MAC头部被丢弃,根据路由表进行路由转发。找到发送的IP之后仍然根据ARP确定MAC地址,然后同样的基于交换机发送到下一个路由器直到到达最终目的主机。
-
ARP工作原理
- 每台主机都在自己的ARP缓冲区中建立一个ARP列表,以表示IP和MAC地址的对应关系
- 源主机发送目的主机数据包时,查询ARP缓冲区,如果不存在IP和MAC地址的对应关系,向本地网段发起ARP请求的广播包,广播包包含了源主机IP地址、MAC地址和目的主机IP地址,查询目的主机IP对应的MAC地址
- 网络中所有铸就都收到ARP广播包,检查目的IP地址和自己IP地址是否一致,不一致忽略,一致则首先将源追加的IP地址和MAC地址保存在自己ARP缓冲区中,并封装ARP响应返回MAC地址
- 源主机收到ARP响应后在ARP缓冲区中记录并开始数据的传输,如果收不到ARP响应包返回失败
Netty
-
介绍一下Netty:
- Netty是一个基于NIO同步非阻塞模型的网络编程框架,可以简单快速的开发网络应用程序
- 极大地简化并优化了TCP和UDP套接字服务器等网络编程,提供了更好的性能与安全性
- 支持如FTP、SMTP、HTTP等各种基于二进制和文本的传统协议
-
Netty的优点:
- 相对于JDK自带的NIO相关的API来说更加易用
- 统一的API,提供阻塞和非阻塞等多种传输类型
分布式系统
-
一致性hash算法
-
加权轮询无法应对分布式系统,因为每个节点存储的数据不同
-
一致性哈希算法就很好地解决了分布式系统在扩容或者缩容时,发生过多的数据迁移的问题。一致性哈希也采用了取模运算,与哈希算法不同,哈希算法是对节点数量进行取模运算,一致性哈希算法对2^32进行取模运算,是一个固定的值。我们可以把一致性哈希算法对2^32进行取模运算的结果值组成一个圆环,即哈希环。
-
具体说来,一致性哈希要进行2步hash操作:
- 对存储节点进行哈希运算,即对存储节点进行哈希映射,比如根据节点的IP地址进行哈希
- 对数据进行存储或访问,即对数据进行哈希映射,映射的结果值往顺时针的方向的找到第一个节点,就是存储该数据的节点。
总的来说,一致性哈希是将存储节点和数据映射到一个首尾相连的哈希环上.
在一致性哈希算法中,如果增加或移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其他数据不会受到影响。但是一致性哈希存在节点分布不均匀的问题,扩容或者容灾时容易发生雪崩式连锁反应。
-
一致性哈希的寻址方式:
- 首先对key进行哈希运算,确定key在哈希环上的位置
- 从确定的位置顺时针走,遇到的第一个节点就是存储key的节点
-
通过虚拟节点提高均衡度
一个真实节点做多个副本作为虚拟节点,不再将真实节点而是将虚拟节点映射到哈希环上。
虚拟节点不仅提高了均衡度而且提高系统的稳定性。当节点变化时,会有不同的节点共同分担系统的变化,因此稳定性更高。
-
-
分布式锁
- 锁保证多线程环境对同一份资源竞争的数据安全性,分布式锁保证整个集群内多线程并发线程安全性
- 应用场景:
- 防止缓存击穿:Redis缓存中某个Key失效,加分布式锁保证只有一个请求同时到达数据库查询数据并缓存Redis
- 保证接口幂等性:接口加分布式锁避免表单重复提交
- 任务调度:保证集群内同时只有一台机器执行任务,避免定时任务的重复执行
- 秒杀减库存防止超卖
- 分布式锁特点:
- 互斥:不允许多个客户端同时执行一段代码
- 可重入:同一个节点上的同一个线程获取锁之后可以重复获取;释放锁时,重入次数必须为0才可以释放
- 锁超时:支持锁超时时间设置,避免死锁
- 锁续期
- 高效高可用:加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级。
- 支持阻塞和非阻塞:和ReentrantLock一样支持lock和trylock以及tryLock(long timeOut)。
- 支持公平锁和非公平锁(可选):公平锁的意思是按照请求加锁的顺序获得锁,非公平锁就相反是无序的。
- 常见分布式锁的实现:
-
MySQL
-
Zookeeper: Zookeeper实现分布式锁的原理是使用临时顺序节点,客户端在Zookeeper上创建的节点是通过session心跳来续期的,如果Zookeeper服务器长时间没收到这个Session的心跳就认为这个Session过期了,把对应的节点进行删除;此外当客户端宕机后,临时节点随之消亡。
工作流程:当客户端抢锁后就给这个客户端分配一个临时节点,只要没释放锁就一直持有这个临时节点,当服务器宕机或者释放锁的时候临时节点被删除,其他客户端又能抢锁。如果客户端抢锁发现锁已被其他客户端持有,则进行阻塞等待,并创建一个监听器监听上一个节点,如果发现节点释放锁则立即得到通知,开始创建顺序的下一个临时节点,实现上锁。
加锁:采用LockData表示一个锁对象,属性包括重入次数,并由全局
ConcurrentHashMap<Thread, LockData>来维护,锁重入即原子变量重入次数+1,如果有客户端抢锁其他客户端抢锁就会互斥,核心在于监听上一个节点(因为这里使用临时顺序节点,可以很方便得到上一节点同时保证公平性)且调用wait进入阻塞状态 解锁:先判断有没有锁重入,有的话就先重入次数 -1,没有的话就直接 delete 掉临时顺序节点;锁释放完成后,阻塞排队的监听器会收到释放的通知,然后进行 notifyAll() 唤醒 wait() 阻塞等待的客户端。 -
Redis
-
-
分布式事务
- 分布式事务是指服务的参与者、支持事务的服务器、资源服务器、事务管理器分别位于不同分布式系统的不同节点上。简单来说分布式事务是要保证不同数据库的数据一致性。一个是由微服务或SOPA架构模式所导致的分布式事务,一个是由MySQL分库分表导致的分布式事务
- CAP与BASE
-
分布式ID
-
CAP理论,BASE理论
-
CAP
- 一致性,即分布式系统中所有节点的数据强一致,所有节点都能看到修改后的新数据。
- 可用性,访问节点必然返回数据但不保证数据一致,返回本地数据。
- 分区容错性,分布式系统内部节点必然有通信,即便是节点之间内部通信出现任意数量的消息丢失或者高延迟也需要对客户端响应(如果做不到分区容错性,那么分布式系统必须要求所有节点之间通信正常这是不可能的)。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,容错指的就是需要在C和A之间做出选择。
只能追求CP或者AP,保证数据吞吐量使用AP,保证主从同步一致则使用CP。
对于CP而言,一旦出现了消息丢失、延迟过高发生了网络分区,就可能出现数据不一致的情况,此策略保证分布式系统不再响应新数据的写入,比如etcd。
对于AP而言,一旦出现了分区故障,访问分布式系统的不同节点会得到不同数据。
绝大多数系统设计采用AP,Zookeeper是CP 强一致性放弃可用性。
设计一个系统的关键是设计一个分区容错一致性模型,在发生分区错误时,保证系统稳定运行且不影响业务,实现集群能力。
-
BASE理论
BASE是基本可用、软状态、最终一致性三个短语的缩写,是对CAP中一致性C和可用性A权衡的结果,BASE与传统ACID不同,提出通过牺牲强一致性来获得可用性,允许数据在一段时间内的不一致但需要达到最终一致,基本可用指遇到了故障,允许损失部分可用性但不是完全不可用,软状态允许不同副本之间的数据同步存在延迟。
-
-
Raft算法
-
缓存一致性
-
异常:
- 先更新数据库再更新缓存的不一致:A库B库B缓A缓;
- 先更新缓存再更新数据库的不一致:A缓B缓B库A库
-
旁路缓存模式:
-
先删除缓存,再更新数据库。
A删缓B写缓A库造成缓存数据不一致
-
先更新数据库,再删除缓存
A读库B库B删缓A写缓造成数据不一致但是概率低,原因是缓存写入远快于数据库写入。所以绝大多数情况按照先更新数据库再删除缓存进行,同时可以为缓存设置过期时间避免缓存删除失败时数据不能更新的故障。本质上是因为更新数据库+删除缓存是两步。
-
问题:
- 但是一旦缓存删除失败在过期之前,缓存不能淘汰仍然存在数据不一致
- 每次更新数据库都伴随的缓存删除这对缓存命中率要求高的应用场景不友好
-
解决方案:
- 采用先更新数据库再更新缓存方案解决缓存命中率低问题,同时使用分布式锁保证同一时刻只有一个请求更新缓存,避免了并发问题。写入性能带来了影响
- 采用先更新数据库再更新缓存方案解决缓存命中率低问题,同时设置缓存很短的失效时间,但是会常读数据库也有影响
- 对于先更新数据库再删除缓存问题解决:
- 重试机制:将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,重试一定次数不成功向上层报告错误;删除缓存成功,就要把数据从消息队列中移除,避免重复操作。
- 订阅MySQL binlog再操作缓存:第一步更新数据库产生一条变更日志,记录在 binlog 里,订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除
-
-
-
-
缓存的基本思想:为了避免用户在请求数据时获取速度过于缓慢,在数据库上增加了缓存用于解决此类问题;CPU Cache缓存内存数据解决CPU处理速度与内存不匹配问题、内存缓存硬盘数据解决硬盘访问速度过慢问题、操作系统在页表基础上引入快表来加速虚拟地址到物理地址的转换,快表可以理解为一种特殊的高速缓冲存储器
-
缓存常见问题:
- 缓存击穿:业务频繁访问的数据作为热点数据,缓存中某个热点数据过期,大量请求无法从缓存中读取直接访问数据库,数据库很容易被高并发的请求冲垮
- 业务线程在处理用户请求时,如果发现访问的数据不在缓存就加一个互斥锁,保证同一时间只有一个请求构建缓存。未获得锁的请求可以睡眠一定时间之后重试,获取锁设置重试次数和超时时间,避免系统阻塞无响应,避免大量请求的堆积占用过多内存
- 热点数据不再设置过期时间,由后台线程异步定时更新缓存,或者在热点数据准备要过期前,业务线程发送消息提前通知后台线程重新设置过期时间;
- 缓存雪崩:大量缓存数据在同一时间过期/失效或者Redis故障宕机,大量用户请求无法在Redis中处理,全部的请求直接访问数据库导致数据库压力骤增,严重情况下导致数据库宕机并形成一系列连锁反应造成整个系统崩溃。
- 大量数据过期
- 在对缓存数据设置过期时间时,给数据过期时间加上随机数避免数据在同一时间过期
- 业务线程在处理用户请求时,如果发现访问的数据不在缓存就加一个互斥锁,保证同一时间只有一个请求构建缓存。未获得锁的请求可以睡眠一定时间之后重试,获取锁设置重试次数和超时时间,避免系统阻塞无响应,避免大量请求的堆积占用过多内存
- 缓存不再设置过期时间,缓存更新的操作不由业务线程完成而由后台线程定时更新,请求到来之间进行缓存预热。此外如果业务线程发现缓存已经被淘汰,通过消息队列发送消息通知后台线程更新缓存。
- Redis故障 采用服务熔断,暂停业务应用对缓存服务的访问直接返回错误;启用限流机制,少部分请求放行访问数据库,直到Redis恢复正常并进行缓存预热之后解除请求限流的机制;此外,需要基于主从机制构建Redis高可用集群。
- 大量数据过期
- 缓存穿透:故意查询一个在缓存中没有并且在数据库中也不存在的数据,高并发请求下无法构建缓存服务后续请求,大量请求导致服务器压力骤增
-
针对查询的数据,在缓存中设置一个空值或者默认值,后续的请求数据从缓存中读取空值或默认值且不用查询数据库
-
使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在。 具体说来,在将数据写入数据库时使用布隆过滤器标记,用于请求到来且业务线程确认缓存失效时,先查询布隆过滤器,避免查询数据库数据是否存在。
布隆过滤器由初始值全为0的位图数组和N个哈希函数两部分组成,具体说来,使用N个哈希函数分别对数据做哈希计算得到N个哈希值,N个哈希值对位图数组的长度取模得到每个哈希值在位图数组对应位置,对应位置值置1。新值仍然经过相同过程计算,如果对应值都为1则命中,否则命中失败.
- 布隆过滤器的应用: 1.网站爬虫对URL的去重,避免爬取重复URL的数据 2.反垃圾邮件/短信,从数十亿个垃圾邮件列表中判断某邮箱是否是垃圾邮箱 3.将已有缓存放入布隆过滤器中,访问缓存时如果不存在直接返回
-
- 缓存击穿:业务频繁访问的数据作为热点数据,缓存中某个热点数据过期,大量请求无法从缓存中读取直接访问数据库,数据库很容易被高并发的请求冲垮
缓存双删:新数据设置到mysql之前先到redis删除一次,然后在新数据设置到mysql的1ms之后再进行一次redis数据的删除
框架+设计模式
-
Spring是什么?
Spring是一个开源、松耦合、分层、可配置的一站式Java开发框架,其核心是IOC和AOP,可以更容易的构建出企业级Java应用,可以根据应用开发的组件需要整合对应的技术。 IOC用于组件间解耦,面向切面编程可以将应用业务作统一或特定的功能增强、实现应用业务与增强逻辑的解耦,Spring作为容器管理应用中使用的组件Bean、托管Bean的生命周期、提供事件与监听器的驱动机制
-
Spring作为IOC容器的优势?
- 典型的IOC管理,包括依赖查找和依赖注入
- AOP抽象
- 事务抽象
- 事件机制
- SPI扩展
- 强大的第三方整合
- 易测试性
- 更好的面向对象
-
Spring核心模块有哪些?
- spring-core: Spring基础API模块,如资源管理(Resource),泛型处理(GenericTypeResolver)
- spring-beans: Spring Bean相关,如依赖查找(BeanFactory)、依赖注入(AutowiredAnnotationBeanPostProcessor)
- spring-aop: Spring AOP处理,如动态代理、AOP字节码提升
- spring-context: 事件驱动(ApplicationEvent)、注解驱动(@Component)、模块驱动(@EnableCaching)等
- spring-expression: Spring表达式语言模块
-
SPI:SPI通过服务寻找的机制,动态加载接口/抽象类的具体实现类,把接口具体实现类的声明和定义和声明权交给了外部化的配置文件
IOC?
IOC控制反转是一种思想,它的核心是将控制权转交出去,基于IOC实现组件间解耦,实现方式包括依赖查找和依赖注入
-
IOC容器的职责
-
将控制权转交出去,实现组件间解耦
-
解决对象间的依赖关系,实现方式包括依赖查找和依赖注入
-
生命周期管理,包括容器生命周期的管理和托管的资源的生命周期的管理
-
配置管理,包括容器配置管理、外部化配置和托管的资源的配置管理
-
-
依赖查找与依赖注入
- 区别:
依赖查找是主动获取,目标可以是方法体内可以是方法外,通常借助于上下文搜索。(比如拿到BeanFactory或者ApplicationContext后调用getBean)
依赖注入是被动提供的过程,目标通常是类成员和方法参数,通常借助于上下文被动的接收。(@Autowired)
-
依赖注入的注入方式:
- 构造器注入
- 参数注入
- 属性注入
- setter注入
SpringFramework4.0.2之前推荐setter注入,原因是一个Bean有多个依赖时,构造器的参数列表会很长,而且如果Bean中依赖的属性都不是必须的话,注入会变得很麻烦; SpringFramework4.0.3以后推荐构造器注入,原因是构造器注入的依赖是不可变的、完全初始化好的且可以保证不为null。如果构造器参数列表长,说明Bean承担的责任太多需要责任拆解。
-
依赖注入的目的、优点:
- 解耦,不需要直接new依赖对象,更好的统一控制对象的创建机制和初始化过程,简化了组件之间的多级依赖关系
- 实现依赖对象的可配置,通过xml和注解声明可以指定和调整组件注入的对象
-
回调注入
在Bean中实现Spring提供的一系列接口,Spring容器在初始化的某个阶段调用这些接口的方法注入对应需要的依赖。回调注入可以注入BeanFactory、ApplicationContext、BeanName、Environment
-
BeanFactory和ApplicationContext的区别
BeanFactory提供了一个抽象的配置和对象管理机制,ApplicationContext是BeanFactory的子接口整合了与AOP、消息机制、事件机制以及对Web环境的扩展。ApplicationContext提供的扩展还包括: 1. AOP的支持(AnnotationAwareAspectJAutoProxyCreator作用于Bean的初始化之后) 2. 配置元信息(BeanDefinition、Environment、注解) 3. 资源管理的抽象(Resource) 4. 事件驱动机制(ApplicationEvent、ApplicationListener) 5. 消息与国际化(LocaleResolver) 6. Environment
-
BeanFactory与FactoryBean的区别
BeanFactory是最顶层的接口,是最深层次的容器,ApplicationContext在底层组合了BeanFactory
FactoryBean是创建对象的工厂Bean,使用其来直接创建一些初始化流程比较复杂的对象
-
@Autowired实现原理
- 根据属性类型从IOC容器中查找Bean,如果找到的Bean唯一则直接返回;
- 如果找到多个类型相同的Bean,将属性名与Bean的id进行比对,返回相同id的;
- 如果没有任何相同id与注入的属性名相同,返回NoUniqueBeanDefinitionException
-
Environment Enviroment是Spring3.1引入的抽象,包含profiles和properties信息,实现统一的配置存储和注入、配置属性的解析
-
BeanDefinition
BeanDefinition描述了Spring Framework中Bean的元信息,包含bean的类信息、属性、行为、依赖关系、配置信息等,BeanDefinition具有层次性,在IOC初始化阶段被BeanDefinitionRegistryPostProcessor构造和注册,被BeanFactoryPostProcessor拦截修改
-
BeanDefinitionRegistry
BeanDefinitionRegistry是维护BeanDefinition的注册中心,其内部存放了IOC容器中Bean的定义信息,同时BeanDefinitionRegistry也是支撑其他组件和动态注册Bean的重要组件,在SpringFramework中,BeanDefinitionRegistry的实现是DefaultListableBeanFactory
-
BeanPostProcessor
BeanPostProcessor是一个容器的扩展点,可以在bean生命周期中初始化阶段前后添加自定义处理逻辑,并且BeanPostProcessor容器隔离、不同容器间的BeanPostProcessor不会干预。处理目标是Bean,在Bean创建之后的初始化前后执行,多用于为bean的属性赋值、创建代理对象
-
BeanFactoryPostProcessor
BeanFactoryPostProcessor是一个容器的扩展点,用于IOC容器的生命周期中。在所有BeanDefinition都注册到BeanFactory触发回调并用于访问/修改已经存在的BeanDefinition。处理目标是BeanDefinition,在BeanDefinition解析完毕并注册到BeanFactory之后执行,此时Bean还未实例化,多用于为BeanDefinition增删属性和移除BeanDefinition
-
BeanDefinitionRegistryPostProcessor
BeanDefinitionRegistryPostProcessor是一个容器的扩展点,用于IOC容器的生命周期中,在配置文件、配置类已经解析完毕并注册BeanFactory但还没有被BeanFactoryPostProcessor处理时触发回,用于在BeanFactory添加新的BeanDefinition。
-
Bean的生命周期
Bean声明周期包括BeanDefinition阶段和Bean实例阶段
- BeanDefinition生命周期
BeanDefinition阶段分为加载XML配置文件、解析注解配置类、编程式构造BeanDefinition、BeanDefinition后置处理四个阶段
1. 加载XML配置文件:发生在基于XML配置文件的ApplicationContext的refresh方法的BeanFactory初始化阶段,此时BeanFactory刚刚构建完成。使用XMLBeanDefinitionReader来加载XML配置文件、使用DefaultBeanDefinitionDocumentReader来解析配置文件,封装声明的bean标签内容并转换为BeanDefinition 2. 解析注解配置类:发生在ApplicationContext中refresh方法的BeanDefinitionRegistryPostProcessor执行阶段,该阶段首先执行ConfigurationClassPostProcessor中postProcessBeanDefinitionRegistry方法,找出所有配置类、排序后依次解析。借助ClassPathBeanDefinitionScanner实现包扫描的BeanDefinition封装,借助ConfigurationClassBeanDefinitionReader实现@Bean注解方法的BeanDefinition解析和封装 3. 编程式构造BeanDefinition:与解析注解配置类一样,也是发生在ApplicationContext中refresh方法的BeanDefinitionRegistryPostProcessor执行阶段。借助ConfigurationClassPostProcessor的ImportBeanDefinitionRegister实现编程式构造BeanDefinition并注入BeanDefinitionRegistry-
Bean实例阶段
-
bean的实例化
在所有非延迟加载的单实例bean初始化之前,先初始化所有的BeanPostProcessor。
在ApplicationContext的refresh方法中,finishBeanFactoryInitialization步骤会初始化所有的非延迟加载的单实例bean,实例化bean的入口是 getBean -> doGetBean,该阶段会合并BeanDefinition并根据bean的scope选择实例化bean的策略
具体创建bean的逻辑在 createBean 中,该方法会执行所有 InstantiationAwareBeanPostProcessor 的 postProcessBeforeInstantiation 尝试创建bean实例,如果成功创建则调用 postProcessAfterInstantiation 初始化后返回,如果 InstantiationAwareBeanPostProcessor 没有创建bean实例,则调用 doCreateBean 创建实例,在 doCreateBean 中,先根据bean的 Class 中的构造器定义决定如何实例化bean,如果没有构造器则使用无参构造器反射创建bean对象
-
属性赋值+依赖注入
bean实例化完成之后,会进行属性赋值、组件依赖注入以及初始化阶段方法的回调。在populateBean属性赋值阶段会事先收集bean中标注了依赖注入的注解(@Autowired、@Value、@Resource、@Inject)然后借助后置处理器回调postProcessProperties实现依赖注入。
-
bean初始化生命周期回调
属性赋值和依赖注入之后,回调执行bean的初始化方法以及后置处理器的逻辑:具体说来,首先执行Aware相关的回调注入,之后执行后置处理器的前置回调包括标注了@PostConstruct注解的方法、InitializingBean中的afterPropertiesSet方法和init-method方法,然后执行后置处理器的后置回调。
全部的bean初始化结束,ApplicationContext的start方法触发调用实现了LifeCycle接口的bean的start方法。
-
bean实例销毁
bean对象销毁时,由ApplicationContext发起关闭动作,BeanFactory取出所有单实例bean并逐个销毁,具体来说首先销毁当前bean依赖的所有bean,随后回调自定义的bean销毁方法
-
-
AOP
面向切面编程AOP是OOP的补充,OOP关注的核心是对象、AOP关注的核心是切面;AOP可以在不修改功能代码本身的前提下使用运行时动态代理技术对已有代码逻辑增强;AOP可以实现组件化、可插拔式的功能扩展,通过简单的配置可以将功能增强到指定切入点。
Spring AOP是运行时增强,AspectJ属于编译时增强
-
事务
- Spring事务包括编程式事务和声明式事务,编程式事务使用TransactionTemplate显式执行,声明式事务只需要使用@Transactional注解即可实现将事务规则应用到业务逻辑中
- Spring基于ThreadLocal实现事务传播机制,默认是PROPAFATION_REQUIRED,依次调用的多个方法处于一个事务中
-
SpringBoot
- 概念:SpringBoot基于约定大于配置的思想,摒弃了传统Spring繁琐的XML配置,内嵌了Tomcat、Jetty等多种web容器,可以快速创建独立的Spring应用程序并可以快速整合常用依赖
- 自动配置:@SpringBootApplication是组合注解,包含@EnableAutoConfiguration作为自动配置关键,自动装配核心功能由AutoConfigurationImportSelector实现,这个类实现了ImportSelector接口,收集需要导入的配置类。在selectImports方法中,从META-INF/spring.factories获取自动配置类的路径,使用 SpringFactoriesLoader.loadFactoryNames得到自动装配类
- Spring Cloud:在SpringBoot的基础上轻松实现微服务项目的构建,SpringCloud为我们提供了一套简易的编程模型,提供了包括诸如服务注册与发现、配置中心、消息总线、负载均衡、断路器等微服务架构的一站式解决方案。
- Spring用到的设计模式
- 工厂模式:Spring使用工厂模式通过BeanFactory、ApplicationContext创建Bean对象
- 单例模式:Spring中的Bean默认都是单例的
- 代理模式:AOP将与业务无关但被多个业务模块共同调用的逻辑抽取封装,减少重复代码、降低模块间耦合性、实现可扩展性和可维护性
- 模板方法模式:jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式
- 观察者模式:Spring事件驱动模型就是观察者模式很经典的一个应用
- 适配器模式:Spring AOP 的增强或通知(Advice)使用到了适配器模式、Spring MVC 中也是用到了适配器模式适配Controller。
系统设计
-
高可用:一个系统在大部分时间都是可用的、可以为我们提供服务的;系统即使在发生硬件故障或者系统升级时,服务仍然可用。用多少个9来评判系统的可用性:
- 使用一些工具如sonarqube、Arthas提升代码质量
- 集群部署如Redis,减少单点故障。冗余,相同的服务部署多份:高可用集群(仅强调冗余,不考虑地域)、同城灾备(相同服务部署同一城市不同机房且备用服务不处理请求)、异地灾备(相同服务部署不同城市不同机房且备用服务不处理)、同城多活(同一城市不同机房部署相同服务,可以同时对外提供服务)、异地多活(应对火灾、地震等人为或自然灾害)
- 限流:监控应用流量的QPS或并发线程数,当达到指定阈值时对流量进行控制
- 熔断:收集所依赖资源的使用情况和性能指标,当所依赖服务恶化或调用次数达到某个阈值时就快速失败,当前系统立即切换到其他服务
- 超时重试,特别是基于RPC框架在请求第三方服务时,不设置超时重试机制导致请求响应速度慢、导致请求堆积让系统无法再处理请求
- 异步调用
- 使用缓存
- 备份回滚、监控告警、灰度发布
-
限流:常见的4种限流算法:
- 固定窗口计数器算法:规定了单位时间处理的请求数量,无法保证限流速率、无法控制突然激增的流量
- 滑动窗口计数器算法
- 漏桶算法:准备一个队列保存请求、定期从队列中取出请求并执行
- 令牌桶算法:根据限流大小,按照一定速率往桶里添加令牌,请求处理之前需要先获取令牌且请求处理完毕将令牌丢弃