Java面经
面经
问题主要分类
Java
- Java基础:面向对象的特性、String源码、深拷贝浅拷贝、序列化、异常、反射、注解、JDK新特性等
- 集合框架:ArrayList、HashMap、HashSet等类的源码,包括扩容、冲突、并发等问题。
- 并发编程:Synchronized原理、ReentrantLock源码、并发编程三大特征、CAS、Atomic、线程池原理、AQS、CountDownLatch源码、CopyOnWrite等。
- 网络编程:简历写了才会问。一般会问Netty相关的。
- JVM:Java内存模型、类加载、GC算法、GC调优、JVM相关工具的使用等。
数据库
- MySQL:索引、调优、主从、隔离级别、MVCC、三种日志、锁等。
- Redis:为什么这么快、底层数据结构、数据同步、穿透击穿雪崩、集群等。
- ElasticSearch:简历写了才会问。常见问题包括:数据结构、数据同步、优缺点、与MySQL全文索引作比较等
- 其他问题:SQL与NoSQL的区别、比较一下你使用过的数据库等。
Spring
问的比较少,常见问题包括:Spring事务实现原理、Bean作用域与生命周期、自动装配、SpringBoot启动流程、SpringMVC工作流程、依赖循环、AOP实现原理等。
消息队列
简历写了才会问。常见问题包括:如何防止各阶段的消息丢失与重复消费、死信队列、延时队列、比较市面上主流的消息队列等。
计算机网络
分层协议、HTTP各版本比较以及常见状态码、HTTPS、从URL到渲染出页面发生了什么、TCP与UDP、握手挥手、拥塞控制、粘包拆包半包、网络攻击等。
操作系统
线程进程协程、死锁、CPU调度算法、用户态内核态、内存管理等。
算法
其中排序算法尤为重要,各种排序算法的时间空间复杂度、相互比较、适用场景。
设计模式
- 单例模式和工厂模式要能手写,注意单例模式有懒汉和饿汉模式。
- JDK中用到了哪些设计模式
- Spring里用到了哪些设计模式
- 你的项目里用到了哪些设计模式
- 选一个你熟悉的设计模式说一说
…
Linux
问的比较少,但面的多了总能遇到:
- 说一说top命令
- 平时哪些命令用的多
- 介绍一下平时怎么排查线上问题的
- 怎么查看线上日志
- 我需要上线一款应用,写一个shell脚本进行部署,包括数据库表建立以及其他环境搭建…
基本知识
ASCII码
数字1是49,A的ASCII码是65,a的ASCII码是97
用户组
拥有者、同组、其他组
r=4、w=2、x=1
字节与字符
- ASCII 码中,一个英文字母(不分大小写)为一个字节,一个中文汉字为两个字节。
- UTF-8 编码中,一个英文字为一个字节,一个中文为三个字节。
- Unicode 编码中,一个英文为一个字节,一个中文为两个字节。
- 符号:英文标点为一个字节,中文标点为两个字节。例如:英文句号 . 占1个字节的大小,中文句号 。占2个字节的大小。
XML
(1)& & amp; 按位与,可以用来屏蔽某一位
(2)< & lt;
(3)> & gt;
(4)" & quot;
(5)' & apos;
名词解释
BPS
比特率,指单位时间内传送的比特(bit)数
OOM
全称“Out Of Memory”
内存泄露:申请使用完的内存没有释放,导致虚拟机不能再次使用该内存,此时这段内存就泄露了,因为申请者不用了,而又不能被虚拟机分配给别人用。
内存溢出:申请的内存超出了JVM能提供的内存大小,此时称之为溢出。
常见的情况有三种:
Java heap space ——>java堆内存溢出,一般由于内存泄露或者堆的大小设置不当引起。堆大小可以通过虚拟机参数-Xms
,-Xmx
等修改。
PermGen space ——>java永久代溢出,即方法区溢出了,一般出现于大量Class(jdk1.8中放到内存了,叫元空间,出现这个问题频率低)。可以通过-XX:PermSize=64m -XX:MaxPermSize=256m修改,jdk1.8以后通过-XX:MetaspaceSize 和 -XX:MaxMetaspaceSize指定
StackOverflowError ——> 不会抛OOM error,但也是比较常见的Java内存溢出。JAVA虚拟机栈溢出,一般是由于程序中存在死循环或者深度递归调用造成的。可以通过虚拟机参数-Xss来设置栈的大小。
JMM
Java内存模型(Java Memory Model),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
happens- before
前一个操作的结果对后续操作时可见
- 程序顺序规则:一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。
- 监视器锁原则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- volatile:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。
OSPF
开放式最短路径优先(英语:Open Shortest Path First,缩写为 OSPF)是一种基于IP协议的路由协议
ICMP
互联网控制消息协议(英语:Internet Control Message Protocol,缩写:ICMP)。它**用于网际协议(IP)**中发送控制消息,提供可能发生在通信环境中的各种问题反馈。
RAFT
RAFT是一种更为简单方便易于理解的分布式算法,主要解决了分布式中的一致性问题。
SCSI
小型计算机系统接口(SCSI,Small Computer System Interface)是一种用于计算机及其周边设备之间(硬盘、软驱、光驱、打印机、扫描仪等)系统级接口的独立处理器标准。
HTTP
超文本传输协议(Hyper Text Transfer Protocol,HTTP)是一个简单的请求-响应协议,它通常运行在TCP之上。
ARP
地址解析协议,即ARP(Address Resolution Protocol),是根据IP地址获取物理地址的一个 TCP/IP协议 。
TCP
传输控制协议(TCP,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议。只能提供点对点服务
STMP
Simple Mail Transfer Protocol,简单邮件传输协议,使用TCP端口25。要为一个给定的域名决定一个SMTP服务器,需要使用MX (Mail eXchange) DNS。
POP
Post Office Protocol,邮局协议。本协议主要用于支持使用客户端远程管理在服务器上的电子邮件。
IMAP
Internet Message Access Protocol,交互邮件访问协议,是一个应用层协议,用来从本地邮件客户端(如Microsoft Outlook、Outlook Express、Foxmail、Mozilla Thunderbird)访问远程服务器上的邮件。
Java
变量命名规则
1. 标识符必须以【字母】【下划线(_)】或【美元符号($)】开头,不能以数字开头。
2. 标识符不能是true、false、package、null
3. 标识符的组成: 【字母】【数字】【下划线】【美元符号】
4. 标识符可以是任意长度
基础数据类型
**整型:**byte 、short 、int 、long
byte 的取值范围:-128~127(-2的7次方到2的7次方-1),这个区间的值会被放置到常量池
short 的取值范围:-32768~32767(-2的15次方到2的15次方-1)
int 的取值范围:-2147483648~2147483647(-2的31次方到2的31次方-1)
long 的取值范围:-9223372036854774808~9223372036854774807(-2的63次方到2的63次方-1)
正数都要少个1,原因是最高位是符号位0表示正,1表示负,0000 0000已经表示了0,那1000 0000就被闲置了,所以用它表示-128。补码=(正数)原码取反(=反码)+1
**浮点型:**float 、 double
**字符型:**char
**布尔型:**boolean (一种是说占1位,一种是说占1字节、一种是说占4字节,因为底层调用是int类型)
运算符优先级
表达式转型规则
1、所有的byte,short,char型的值将被提升为int型;
2、如果有一个操作数是long型,计算结果是long型;
3、如果有一个操作数是float型,计算结果是float型;
4、如果有一个操作数是double型,计算结果是double型;
5、被fianl修饰的变量不会自动改变类型,当2个final修饰相操作时,结果会根据左边变量的类型而转化。
上转型和下转型
父类引用指向子类对象为向上转型,多态的一个体现
1 | fatherClass obj = new sonClass(); |
子类对象指向父类引用为向下转型
1 | sonClass obj = (sonClass) fatherClass; |
代码块
普通方法块
就是在方法后面使用”{}”括起来的代码片段,不能单独执行,必须调下其方法名才可以执行。
静态代码块
在类中使用static修饰,并使用”{}”括起来的代码片段,用于静态变量的初始化或对象创建前的环境初始化。
1 | static { |
同步代码块
使用synchronize关键字修饰,并使用”{}”括起来的代码片段。它表示在同一时间只能有一个线程进入到该方法快中,是一种多线程保护机制。
1 | synchronized (Test.class) { |
构造代码块
在类中没与任何的前缀或后缀,并使用”{}”括起来的代码片段。构造块会嵌入到构造方法的最开头位置。
1 | { |
关键字
abstract
抽象方法不能有{},也就是方法体,只是有一个声明
abstract 和 final不能同时修饰一个方法
抽象类既可以实现多个接口也可以继承一个父类
抽象类中既可以包含抽象方法也可以有非抽象方法,还可以为空
抽象类可以有构造函数,但是不能实例化
abstract可以和static共用吗?
abstract与static不能同时使用,static关键字修饰的成员是属于类的,而abstract一般是用来修饰普通方法目的是为了让子类继承后重写该方法,而static修饰的方法是不存在继承重写的。
final
- 当final修饰类时,该类不能被继承,例如java.lang.Math类就是一个final类,它不能被继承。
- final修饰的方法不能被重写,如果出于某些原因你不希望子类重写父类的某个方法,就可以用final关键字修饰这个方法。 但是可以重载,子类可以继承
- 当final用来修饰变量时,代表该变量不可被改变,一旦获得了初始值(必须初始化),该final变量的值就不能被重新赋值。如果是基本变量则值不能再改变,如果是引用变量则引用地址不能改变,但值可以改变。
interface
接口中的方法前的访问权限控制符默认为public,并且只能是public
在接口里面的变量默认都是public static final 的,它们是公共的、静态的、最终的常量,相当于全局常量,可以直接省略修饰符,实现类可以直接访问接口中的变量
访问修饰符:
关键字 | 同类 | 同包 | 子类 | 外包 |
---|---|---|---|---|
private | √ | |||
default | √ | √ | ||
protected | √ | √ | √ | |
public | √ | √ | √ | √ |
变量修饰符不仅包括上述的访问修饰符,还有final、static
接口修饰符:public、default和abstract,Java1.8之后,接口允许定义static静态方法
jdk1.8后接口中用static
或default
修饰的方法可以有方法体
super
可以调用父类的方法和属性,private不行,private一般用get或者set方法来影响它的值(@lomlok
)。
super()可以直接调用父类的构造方法,但是必须在子类的构造方法中,且要在第一行。
instanceof
是 Java 的保留关键字。它的作用是测试它左边的对象是否是它右边的类的实例,返回 boolean 的数据类型
static
- 可以修饰除了构造器之外的几大成员,比如类、方法、成员变量、内部类
- static修饰的部分会和类同时被加载。
- 被修饰部分不需要实例化就可以访问,
- 被修饰的对象不能访问实例化对象,否则可能引起未实例化完成就被访问出现的错误。
- 静态方法中没有this关键词,因为静态方法是和类同时被加载的,而this是随着对象的创建存在的。
多态、继承、封装(面向对象三大特征)
多态:
同一个方法的调用,由于对象的不同可能会有不同的行为。
继承中的重载和重写():
继承:父子继承,Object是公共父类,不能多继承,考虑钻石继承问题,多继承缺失的补充(内部类)
封装:隐藏内部实现,保留外部方法,分级public、default、protected、private
继承和组合的关系
继承是is a,优点是子类可以重写父类的方法来方便地实现对父类的扩展。
组合是has a,把要组合的类的对象加入到该类中作为自己的成员变量。
继承的缺点
- 父类内部实现对子类可见
- 子类从父类继承的方法在编译时就已经确定了,无法在运行期间改变从父类继承的方法的行为。
- 父类修改,子类要跟着修改,违背面向对象的低耦合思想。
组合
优点:
- 当前对象只能通过所包含的那个对象去调用其方法,所以所包含的对象的内部细节对当前对象时不可见的。
- 当前对象与包含的对象是一个低耦合关系,如果修改包含对象的类中代码不需要修改当前对象类的代码。
缺点:
- 容易产生过多的对象。
- 为了能组合多个对象,必须仔细对接口进行定义。
内部类
定义在类当中的一个类,好处是:
1.增强封装,把内部类隐藏在外部类当中,不允许其他类访问这个内部类
2.增加了代码维护性
3.内部类可以直接访问外部类当中的成员
4.每个内部类都能独立的继承一个接口的实现,所以无论外部类是否已经继承了某个(接口的)实现,对于内部类都没有影响。内部类使得多继承的解决方案变得完整
内部类的分类:
1.实例内部类:直接定义在类当中的一个类,在类前面没有任何一个修饰符
2.静态内部类:在内部类前面加上一个static,(可以不创建外部对象,直接使用,但只能访问外部的静态属性,其他可能没有被初始化)
3.局部内部类:定义在方法的内部类,是放在代码块或方法中的,不能有访问控制修饰符,且不能用static修饰
4.匿名内部类:属于局部内部的一种特殊情况
局部内部类和匿名内部类只能访问局部final变量:
生命周期问题,外部类结束后局部变量被销毁,如果可以访问外部类的局部变量就会导致空指针,解决措施是创建一个拷贝。如果局部变量的值在编译期间就可以确定,则直接在匿名内部里面创建一个拷贝。如果局部变量的值无法在编译期间确定,则通过构造器传参的方式来对拷贝进行初始化赋值。但是拷贝会出现数据不一致的问题,所以将其限制为final
非静态内部类为什么不能有静态成员变量和静态方法:
- static类型的属性和方法,在类加载的时候就会存在于内存中。
- 要想使用某个类的static属性和方法,那么这个类必须要加载到虚拟机中。
- 非静态内部类并不随外部类一起加载,只有在实例化外部类之后才会加载。
try-catch-finally
try中的返回值会存储到临时的栈中,等finally执行结束,再返回try的值。
try中的值如果被先执行的函数更新,不会影响临时的栈。
如果finally中有return语句,那么程序就return了,所以finally中的return是一定会被return的。
throws是用来声明一个方法可能抛出的所有异常信息,throw 是抛出一个异常
1 | public static void throwChecked(int a)throws Exception { |
单例模式
确保实例全局唯一,避免多次的创建和销毁。可以节约系统资源,控制实例数目。是一种设计模式。
懒汉、饿汉、get加锁的懒汉、双重检查锁
这是get加锁的懒汉,instance加上了volatile修饰,提供多线程情况下的可见性。get方法使用synchronized修饰,保证在实例创建完成前不会被其他线程调用,提供线程安全性。
1 | public class LazySingleton { // 保证 instance 在所有线程中同步 |
饿汉实例,利用final关键字,在一开始就创建了类实例
1 | public class HungrySingleton { |
双重检查锁,先判断对象是否实例化过了,这是一次检查,在加锁对象后,再判断一次,避免了创建多个实例。
1 | public class Singleton { |
volatile
volatile[ˈvɒlətaɪl]
是Java提供的一种轻量级的同步机制。Java 语言包含两种内在的同步机制:同步块(或方法)和 volatile 变量,相比于synchronized[ˈsɪŋkrənaɪz]
(synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。但是volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。可以保证可见性,有序性,无法保证线程安全
并发特性
原子性:一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
有序性:程序执行的顺序按照代码的先后顺序执行
volatile具有的特性
保证可见性,不保证原子性
(1)当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去;(int
a = b+1
;例如这个变量的操作,并非原子操作)多线程情况下,线程A修改了共享变量,线程B中的变量副本确实会立即失效并重新加载,但是线程B中已执行的逻辑并不会重新执行(寄存器中的值不会重新计算),所以导致线程安全问题。
举例:在某一时刻线程1将i的值load取出来,放置到cpu缓存中,然后再将此值放置到寄存器A中,然后A中的值自增1(寄存器A中保存的是中间值,没有直接修改i,因此其他线程并不会获取到这个自增1的值)。如果在此时线程2也执行同样的操作,获取值i10,自增1变为11,然后马上刷入主内存。此时由于线程2修改了i的值,实时的线程1中的i10的值缓存失效,重新从主内存中读取,变为11。接下来线程1恢复。将自增过后的A寄存器值11赋值给cpu缓存i。这样就出现了线程安全问题。
(2)这个写会操作会导致其他线程中的volatile变量缓存无效。
禁止指令集重排
重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段,底层原理是通过内存屏障
(1)有依赖关系的不会被重排序,例如先初始化再赋值
(2)单线程下结果唯一,多线程可能会被重排序导致结果不一致
序列化
**序列化:**把对象转化为可传输的字节序列过程称为序列化。
**反序列化:**把字节序列还原为对象的过程称为反序列化。
序列化最终的目的是为了对象可以跨平台存储和进行网络传输。而我们进行跨平台存储和网络传输的方式就是IO,而我们的IO支持的数据格式就是字节数组。
在实现序列化和反序列化的时候,我们需要使用ObjectInputStream
和ObjectOutputStream
这两个流。其中,序列化时可以通过ObjectOutputStream
的writeObject()
输出对象序列,反序列化时可以通过ObjectInputStream
的readObject()
将序列恢复为对象。
使用transient
修饰的变量不会被序列化,对象序列化的所属类需要实现Serializable
接口
序列化的目的是将对象中的数据(成员变量)转为字节序列,和成员方法无关。为了正确地序列化某个对象,需要这个对象的类符合如下规则:
- 该对象中引用类型的成员变量也必须是可序列化的。
- 该类的直接或间接的父类,要么具有无参构造器,要么也是可序列化的。
- 一个对象只会被序列化一次,再次序列化时仅仅会输出它的序列号而已。
序列化的几种方案
1.Java原生序列化方式,主要由ObjectInputStream和ObjectOutputStream实现
2.FastJson序列化
3.Json序列化jackson
4.ProtoBuff序列化
集合
HashMap
特性
HashMap是一个插入慢、查询快的数据结构
扩容机制
扩容时机:
- 如果数组为空,则进行首次扩容。
- 将元素接入链表后,如果数组长度小于64,且链表长度达到8则扩容。
- 添加后,如果数组中元素超过阈值,即比例超出限制(默认为0.75),则扩容。
JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。 JDK1.8 以后的 HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。并且, HashMap
总是使用 2 的幂作为哈希表的大小。原因是2的n次方可以减小碰撞
index0-3就是存储桶,是数组中的元素,也是DEFAULT_INITIAL_CAPACITY
退化条件
- 扩容 resize( ) 时,红黑树拆分成的 树的结点数小于等于临界值6个,则退化成链表。
- 移除节点时,在红黑树的root节点为空或者root的右节点、root的左节点、 root左节点的左节点为空时,退化成链表
hash方法
JDK 1.8 的 hash 方法(扰动函数),目的是通过去除原来的HashCode的特征来减小碰撞
1 | static final int hash(Object key) { |
在这个方法中将 key 的 hashcode 右移 16 位,然后按位异或。异或算法是相同为 0,不同为 1 。
右移 16 位以后,原来的高 16 位就到了低 16 位上,再与原来的数异或,就相当于高 16 位与低 16 位异或。
因此 hash 算法的作用就是高 16 位不变,低 16 位和高 16 位做异或。
这种方式保证高位也能参加到运算,增大散列程度,让数据分布更均匀。
HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash
判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
为什么是length-1不是length?
n - 1的原因是16是10000 15是01111。16与任何数只能是0或者16。15与任何数等于小于16的任何数本身。
为什么容量是2的n次方?
2的n次方一定是最高位1其它低位是0,
这样减1的时候才能得到01111这样都是1的二进制。
loadFactor 负载因子
loadFactor 负载因子是控制数组存放数据的疏密程度,loadFactor 越趋近于 1,那么 数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加。loadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。
临界值(threshold) = 负载因子(loadFactor) * 容量(capacity)
时间换空间 或者 空间换时间的例子
其他特性
HashMap是无序的,但是可以借助TreeMap进行排序,TreeSet依靠TreeMap实现,所以可以用TreeSet排序
为什么HashMap线程不安全
jdk1.7中扩容时会造成死循环和数据丢失:
扩容时HashMap重新定位每个桶的下标,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。
当线程A执行一部分transfer
的代码后,时间片耗尽并交给线程B执行,线程B在执行完transfer
的代码后又轮到线程A,通过上下文的切换,线程A从未执行处开始执行,用头插法插入数据。但是B已经处理过一次,所以在某个Hash桶中的最后一个元素本来指向null,但是由于翻转后,指向了前一个节点,造成了死循环。并且过程中,由于部分节点已经处理过了,会造成节点丢失。
jdk1.8中会发生数据覆盖的情况:
JDK1.8 中,由于多线程对HashMap进行put操作,调用了HashMap#putVal(),具体原因:假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完hash碰撞的判断后被挂起,B拿到时间片并且正常put,等到A重新拿到时间片的时候继续从未执行的代码开始,不会再检测Hash碰撞。所以同样写入。B线程写入内容被覆盖。
LinkedHashMap
在HashMap中,元素的迭代顺序是无序的,不可控的,但是LinkedHashMap使用双端队列来存储元素。在HashMap.Entry<K,V> 的基础上又添加了两个属性after和before,分别表示下一个节点和前一个节点,正是这些额外的操作,才可以让LinkedHashMap变得有序
HashSet
是基于HashMap实现的,默认构造函数是构建一个初始容量为16,负载因子为0.75 的HashMap。封装了一个 HashMap 对象来存储所有的集合元素,所有放入 HashSet 中的集合元素实际上由 HashMap 的 key 来保存,而 HashMap 的 value 则存储了一个 PRESENT,它是一个静态的 Object 对象。
ConcurrentHashMap
Java 7 中 ConcurrentHashMap
的存储结构如图,ConcurrnetHashMap
由很多个 Segment
组合,而每一个 Segment
是一个类似于 HashMap 的结构,所以每一个 HashMap
的内部可以进行扩容。但是 Segment
的个数一旦初始化就不能改变,默认 Segment
的个数是 16 个,也可以认为 ConcurrentHashMap
默认支持最多 16 个线程并发。
java1.8中是Node 数组 + 链表 / 红黑树。抛弃了segment,采用了 CAS + synchronized 来保证并发安全性。当冲突链表达到一定长度时,链表会转换成红黑树。synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。
ConcurrentHashMap 的初始化是通过自旋和 CAS 操作完成的,并且put加锁处理时锁的是头结点,扩容也一样,并且支持多个线程同时扩容,提高并发能力。在扩容时仍然可以访问。
Hashtable
Hashtable 不允许重复 key,value会被覆盖
- 插入null:HashMap允许有一个键为null,允许多个值为null;但HashTable不允许键或值为null;
- 线程安全:HashTable是线程安全的类,很多方法都是用synchronized修饰,但同时因为加锁导致并发效率低下,单线程环境效率也十分低;
- 长度:HashTable底层数组长度可以为任意值,这就造成了hash算法散射不均匀,容易造成hash冲突,默认为11;
- 结构:HashTable一直都是数组+链表
- 继承关系:HashTable继承自Dictionary类;而HashMap继承自AbstractMap类;
ArrayList
以无参数构造方法创建 ArrayList
时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 DEFAULT_CAPACITY =10
。扩容的时机是数组被填满。如果是有参构造,则不需要扩容。
最大的容量为Integer.MAX_VALUE
右移一位相当于除 2
int newCapacity = oldCapacity + (oldCapacity >> 1),所以 ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)! 奇偶不同,比如 :10+10/2 = 15, 33+33/2=49。如果是奇数的话会丢掉小数
通过Arrays.copyOf()方法拷贝。
为什么线程不安全?
同时赋值可能会导致值被覆盖,同时add元素可能会导致数组越界
ArrayList 与 LinkedList 区别
- 是否保证线程安全:
ArrayList
和LinkedList
都是不同步的,也就是不保证线程安全; - 底层数据结构:
Arraylist
底层使用的是Object
数组;LinkedList
底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别) - 插入和删除是否受元素位置的影响:
ArrayList
采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)
方法的时候,ArrayList
会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)
)时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。LinkedList
采用链表存储,所以,如果是在头尾插入或者删除元素不受元素位置的影响(add(E e)
、addFirst(E e)
、addLast(E e)
、removeFirst()
、removeLast()
),时间复杂度为 O(1),如果是要在指定位置i
插入和删除元素的话(add(int index, E element)
,remove(Object o)
), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入。
- 是否支持快速随机访问:
LinkedList
不支持高效的随机元素访问,而ArrayList
支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)
方法)。 - 内存空间占用:
ArrayList
的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
线程安全的Map
有hashtable和ConcurrentHashMap
hashtable的话是在所有操作中都加了syncronized
关键字,这就相当于给整张hash表上了锁,这样的代价太大了,如果一个线程访问那么其他线程只能等待,在并发条件下的效果就非常差了
ConcurrentMap的话是分段(segment,jdk1.7)加锁,给每个entry加上锁,这样就保证访问一个entry时不会影响其他。jdk1.8用cas+syncronized方法,锁住的是链表或者红黑树头结点。
Hash解决冲突的办法
开放定址
线性探测再散列、平方探测、伪随机探测(因为有一个数作为种子,只是符合随机的均匀、独立特征,但并不完全是随机)
一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
再哈希
用另外一个哈希函数的方式来处理冲突,增加了计算时间
链地址法
每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来
建立公共溢出区
将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表
CAS(无锁算法)
CAS(Compare And Swap )是乐观锁的一种实现方式,是一种轻量级锁,实际存放值和预期值一致时才做更改。
- V:当前内存地址实际存放的值;
- O:内存存放预期值(预期值就是线程保留的副本);
- N:更新的新值。
**ABA问题:**出现修改,又改回去。
加版本号
synchronized
Synchronized在Java JVM里的实现是基于进入和退出Monitor对象来实现方法同步和代码块同步的。monitor enter指令是在编译后插入到同步代码块的开始位置,而monitor exit是插入到方法结束处和异常处,JVM要保证每个monitor enter必须有对应的monitor exit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitor enter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
synchronized用的锁是存在Java对象头里的。
对象头包含三部分
- Mark Word:用来存储对象的hashCode及锁信息
- Class Metadata Address:用来存储对象类型的指针
- Array length:用来存储数组对象的长度
如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit。数组类多一个字节用于存储数组长度,也就是说程序获取数组长度的时间复杂度为O(1)。在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据:
synchronized是基于悲观锁的,当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。
- 对于普通同步方法加锁时,锁是当前实例对象
- 对于静态同步方法加锁时,锁是当前类的Class对象
- 对于同步方法块加锁时,锁是Synchonized括号里配置的对象
当线程企图访问临界资源时,先会查看该临界资源当前是否已被加锁,如果没有被加锁,则对该临界资源加锁,并进入该同步块或方法,加锁后,其他线程也就无法访问该临界资源了。所以判定获取了一个内部锁的标准为:进入该同步区域
同步块:有synchronized修饰符修饰的语句块,被该关键词修饰的语句块,将加上内置锁。实现同步。例:synchronized(Object o ){}
同步块存在的目的是尽可能的降低同步部分对效率的影响。当一个线程访问object的一个synchronized(this)同步代码块时,另一个线程仍然可以访问该object中的非synchronized(this)同步代码块。
锁升级
jdk1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”
偏向锁
在锁对象的对象头中有个ThreadId字段, 这个字段如果是空的,第一次获取锁的时候,就将自身的ThreadId写入到锁的ThreadId字内,将锁头内的是否偏向锁的状态位置1。这样下次获取锁的时候,直接检查ThreadId是否和自身线程Id一致,如果一致,则认为当前线程已经获取了锁,因此不需再次获取锁,略过了轻量级锁和重量级锁的加锁阶段。提高了效率。偏向锁是单线程下的锁优化。
轻量级锁
偏向锁是单线程下的锁优化,这个就说多线程下的锁优化了,当有多个线程竞争同一个临界资源,这个时候偏向锁就会被撤(这个步骤也是十分消耗资源的),然后升级为轻量级锁。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。自旋锁默认的次数为 10,超过后锁升级为重量级锁。
重量级锁
重量级锁也就是普通的悲观锁,竞争锁失败会阻塞等待唤醒再次竞争。
使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到索竞争的线程,使用自旋会消耗CPU | 追求响应速度,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
锁降级
发生锁升级后,偏向锁就会被禁用。
与Lock的区别
- synchronized是Java关键字,在JVM层面实现加锁和解锁;Lock是一个接口,在代码层面实现加锁和解锁。
- synchronized可以用在代码块上、方法上;Lock只能写在代码里。
- synchronized在代码执行完或出现异常时自动释放锁;Lock不会自动释放锁,需要在finally中显示释放锁。
- synchronized会导致线程拿不到锁一直等待;Lock可以设置获取锁失败的超时时间。
- synchronized无法得知是否获取锁成功;Lock则可以通过tryLock得知加锁是否成功。
- synchronized锁可重入(可重复可递归调用的锁,还有Re entrant Lock也是可重入)、不可中断、非公平;Lock锁可重入、可中断、可公平/不公平,并可以细分读写锁以提高效率。
非公平锁:每个线程获取锁的顺序是随机的,并不会遵循先来先得的规则,任何线程在某时刻都有可能直接获取并拥有锁。非公平锁有更高的吞吐率,省略了排队时间,理念是相同时间内执行更多的任务。
Lock
JDK1.5以后引入的补充synchronized的接口,ReentrantLock是最经典的实现,Lock依赖于AQS。
线程阻塞于synchronized的监视器锁时会进入阻塞状态,而线程阻塞于Lock锁时进入的却是等待状态,这是因为Lock接口实现类对于阻塞的实现均使用了LockSupport类中的相关方法。
lock与Condition(条件等待)结合,可以创建多个条件队列,因为不同的条件不满足而阻塞的线程都可以放到不同的队列里, 这样就可以做到按需排队,按需通知。
AQS
AQS(AbstractQueuedSynchronizer)是线程同步器,是用来构建锁的基础框架,Lock实现类都是基于AQS实现的。AQS是基于模板方法模式进行设计的,所以锁的实现需要继承AQS并重写它指定的方法。AQS内部定义了一个FIFO的队列来实现线程的同步,同时还定义了同步状态来记录锁的信息。
核心思想:通过一个volatile修饰的int属性state代表同步状态,例如0是无锁状态,1是上锁状态。多线程竞争资源时,通过CAS的方式来修改state,例如从0修改为1,修改成功的线程即为资源竞争成功的线程,将其设为exclusiveOwnerThread,也称【工作线程】,资源竞争失败的线程会被放入一个FIFO的队列中并挂起休眠,当exclusiveOwnerThread线程释放资源后,会从队列中唤醒线程继续工作,循环往复。
CLH队列:一个虚拟的双向队列,虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。
简而言之:AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。
JVM
内存区域
jdk1.8中相较于之前版本,将方法区以及其中涵盖的运行时常量池放进了直接内存,并且命名为元空间。
JVM由三部分组成:类加载子系统、执行引擎、运行时数据区。
- 类加载子系统:可以根据指定的全限定名来载入类或接口。
- 执行引擎:负责执行那些包含在被载入类的方法中的指令。
- 运行时数据区:当程序运行时,JVM需要内存来存储许多内容,例如:字节码、对象、参数、返回值、局部变量、运算的中间结果等等。JVM会把这些东西都存储到运行时数据区中,以便于管理。而运行时数据区又可以分为方法区、堆、虚拟机栈、本地方法栈、程序计数器。
程序计数器(PC寄存器)
- 流程控制:字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 保留现场(保留上下文):在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
还可以用这两个特性回答为什么是私有的
程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
Java虚拟机栈
除了一些 Native 方法调用是通过本地方法栈实现的,其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)
方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。
栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。
局部变量表:用于存放方法参数和方法内定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位,Java虚拟机规范并没有定义一个槽所应该占用内存空间的大小,但是规定了一个槽应该可以存放一个32位以内的数据类型。
操作数栈:主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
动态链接:主要服务一个方法需要调用其他方法的场景。在 Java 源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用(Symbilic Reference)保存在 Class 文件的常量池里。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用。
方法返回:分两种,一种是正常退出,根据字节码指令确定有无返回值,另一种是异常退出,没有异常处理器就会直接异常退出。无论方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态。
返回的具体操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者的操作数栈中,调整PC计数器的值以指向方法调用指令后的下一条指令。
本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是: **虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。**本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
目的是与操作系统交互、与java环境交互
堆,又称 GC 堆(Garbage Collected Heap)
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。**此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。**同时,堆也是垃圾回收的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)
逃逸分析:jdk1.7及以后,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
方法区
方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
类信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),存储全名、父类(interface或是java.lang.object,没有父类)、类型的修饰符(public,abstract,final的某个子集)、类型直接接口的一个有序列表。
域信息
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。域(就是字段,或者说是属性)的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)。
方法信息
方法的名称、返回类型、修饰符、以及方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)和异常表(abstract和native方法除外),每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
运行时常量池
运行时常量池就是类被JVM加载后在JVM中的版本。有一点儿区别就是常量池只有类文件在编译的时候才会产生,而且是存储在类文件中的。而运行时常量池是在方法区,而且可在JVM运行期间动态向运行时常量池中写入数据。
字符串常量池
jdk1.6,存放在永生代,1.7开始字符串常量池在堆的old区,字符串在Young的Eden区产生。其不仅可以存字符串常量,还可以存字符串对象的引用。移动的原因是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。
堆、栈、方法区的关系
- Person:存放在元空间,也可以说方法区;
- person:存放在Java栈的局部变量表中;
- new Person():存放在Java堆中。
类的变量存储位置
静态成员变量:JDK8之前,静态成员变量确实存放在方法区,但JDK8之后就取消了“永久代”,取而代之的是“元空间”,永久代中的数据也进行了迁移,静态成员变量迁移到了堆中
其他成员变量:属性的实际存放位置要看是基本数据类型还是引用数据类型,基本数据类型应该是常量池,引用数据类型放在堆区,对象持有的是引用。
垃圾回收
区域分类
Eden 区、From Survivor0(“From”) 区、To Survivor1(“To”) 区都属于新生代,Old Memory 区属于老年代。
Eden、Survivor0、Survivor1比例为8:1:1
在1.7中还有永生代,1.8中将永生代变为了存储在内存的元空间。
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为大于 15 岁,原因是对象头中的Mark Word
采用4个bit位来保存年龄,4个bit位能表示的最大数就是15),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置默认值,这个值会在虚拟机运行过程中进行调整,可以通过-XX:+PrintTenuringDistribution
来打印出当次 GC 后的 Threshold(阈值)。调整是通过年龄的累计,找到某个超过了 survivor 区的一半的年龄时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值。
经过这次 GC 后,Eden 区和”From”区已经被清空。这个时候,”From”和”To”会交换他们的角色,并保证名为 To 的 Survivor 区域是空的(体现的是复制算法)。Minor GC 会一直重复这样的过程,在这个过程中,有可能当次 Minor GC 后,Survivor 的”From”区域空间不够用,有一些还达不到进入老年代条件的实例放不下,则放不下的部分会提前进入老年代。
收集分类
部分收集 (Partial GC):
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC):收集整个 Java 堆和方法区。
分配特性和原因
小对象优先在eden区域分配,当 eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC
大对象直接进老年代,大对象就是需要大量连续内存空间的对象(比如:字符串、数组),这是为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率
长期存活的对象进入老年代,如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1.对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。
判定对象死亡的方法
引用计数法:每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。存在循环引用,例如A和B循环引用,除了他俩之外没有其他地方引用,则不能触发回收机制。
可达性分析算法:通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。
这些可以作为GC Roots:虚拟机栈(栈帧中的本地变量表)中引用的对象、本地方法栈(Native 方法)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、所有被同步锁持有的对象
object中有finalize
方法,表示执行完毕,**当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。(即意味着直接回收)**。如果有必要执行,则**放进F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。**但是该方法会触发,却并不保证执行完成(是为了避免永久等待)。该方法除了垃圾回收会调用外,程序退出和显式的方式也可以调用
引用
判定对象死亡的方法都指向了引用。软引用、弱引用可以和ReferenceQueue(引用队列,用于存放待回收的引用对象)联合使用。虚引用必须和引用队列联合使用。
引用队列的作用:对于软引用、弱引用和虚引用,如果我们希望当一个对象被垃圾回收器回收时能得到通知,进行额外的处理,这时候就需要使用到引用队列了。在一个对象被垃圾回收器扫描到将要进行回收时,其相应的引用包装类,即reference对象会被放入其注册的引用队列queue中。可以从queue中获取到相应的对象信息,同时进行额外的处理。比如反向操作,数据清理,资源释放等。
软引用(SoftReference)的作用:当做缓存,因为只有在内存不足的时候才会被回收
弱引用(WeakReference)的作用:ThreadLocal中减小内存泄露概率,或者当做缓存
虚引用(PhantomReference)的作用:无法直接获取对象实例,用来跟踪对象被垃圾回收器回收的活动,当一个虚引用关联的对象被垃圾收集器回收之前会收到一条系统通知。
垃圾收集算法
无用的类应该满足
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的
ClassLoader
已经被回收。 - 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
标记清除算法:该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:效率问题、空间问题(标记清除后会产生大量不连续的碎片)
标记复制法:它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。适用于新生代,因为朝生夕死,并且引入了较大的Eden和两块较小的survivor区域,比例是8:1:1,这就是Appel式回收
标记整理法:根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
分代收集:根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
垃圾收集器
Serial收集器:单线程,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程,直到它收集结束。新生代采用标记-复制算法,老年代采用标记-整理算法。
Serial Old:Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。
ParNew收集器:其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
Parallel Scavenge:使用标记-复制算法的多线程收集器,Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。可以手动设置参数,也可以设置自动配置。
ParallelOld:Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。
CMS收集器:使用标记-清除,CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。CMS的缺点:对 CPU 资源敏感、无法处理浮动垃圾、它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
- 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
- 并发标记: 同时开启 GC 和用户线程,用一个闭包结构(外层函数的作用域对象,在外层函数被调用后,依然被内层函数引用着,无法释放,形成了这样一个结构,就叫闭包)去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
- 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
- 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
MySQL里也有类似的思路,做备份的时候也是开写锁或者用可重复读
并行和并发在垃圾收集当中的体现
- 并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
- 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。
G1收集器:面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。G1从整体来看是基于标记整理算法实现的收集器,但从局部上看又是基于标记复制算法实现。无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。
- 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
- 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
- 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
- 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。
G1收集器运作大致分为:初始标记、并发标记、最终标记(重新标记)、筛选回收。跟CMS基本一致
我的电脑使用的是Parallel GC
Parallel Scavenge
:标记复制、Serial Old
:标记整理
jdk1.9 默认垃圾收集器G1
GC什么时候触发
Scavenge GC
当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。
Full GC
整堆GC,触发的原因可能有:
- System.gc()被显示调用,建议虚拟机执行GC
- 老年代空间不足,比如大的数组进入
- 空间分配担保失败,有两种情况:1)每次晋升的对象的平均大小 > 老年代剩余空间;2)Minor GC后存活的对象超过了老年代剩余空间
JVM调优
1 | -Xms:初始堆大小 -Xmx:最大堆大小-Xmn:年轻代大小-Xss:每个线程的堆栈大小 |
JVM调优的条件(核心指标):gc时间、整堆gc次数
- jvm.gc.time:每分钟的GC耗时在1s以内,500ms以内尤佳
- jvm.gc.meantime:每次YGC耗时在100ms以内,50ms以内尤佳
- jvm.fullgc.count:FGC最多几小时1次,1天不到1次尤佳
- jvm.fullgc.time:每次FGC耗时在1s以内,500ms以内尤佳
分析瓶颈
CPU指标
- 查看占用CPU最多的进程
- 查看占用CPU最多的线程
- 查看线程堆栈快照信息
- 分析代码执行热点
- 查看哪个代码占用CPU执行时间最长
- 查看每个方法占用CPU时间比例
1 | // 显示系统各个进程的资源使用情况 top |
JVM内存指标
- 查看当前 JVM 堆内存参数配置是否合理
- 查看堆中对象的统计信息
- 查看堆存储快照,分析内存的占用情况
- 查看堆各区域的内存增长是否正常
- 查看是哪个区域导致的GC
- 查看GC后能否正常回收到内存
1 | // 查看当前的 JVM 参数配置 ps -ef | grep java |
JVM GC指标
- 查看每分钟GC时间是否正常
- 查看每分钟YGC次数是否正常
- 查看FGC次数是否正常
- 查看单次FGC时间是否正常
- 查看单次GC各阶段详细耗时,找到耗时严重的阶段
- 查看对象的动态晋升年龄是否正常
JVM参数如下:
1 | // 打印GC的详细信息-XX:+PrintGCDetails |
优化方案
- 修复BUG(死循环、无界队列)
- 配置参数:新生代内存、堆内存、元空间的大小
JVM工具
1、jps:查看本机java进程信息。
2、jstack:生成当前时刻JVM线程的快照,制作线程dump文件。帮助定位程序问题出现的原因,如长时间停顿、CPU占用率过高等
3、jmap:打印内存映射,制作堆dump文件
4、jstat:性能监控工具
5、jhat:内存分析工具
6、jconsole:简易的可视化控制台
7、jvisualvm:功能强大的控制台
阻塞队列
阻塞队列(BlockingQueue)是一个线程安全的存取队列,支持两个附加操作:
- 生产者线程会一直不断的往阻塞队列中放入数据,直到队列满了为止。队列满了后,生产者线程阻塞等待消费者线程取出数据。
- 消费者线程会一直不断的从阻塞队列中取出数据,直到队列空了为止。队列空了后,消费者线程阻塞等待生产者线程放入数据。
典型的场景是生产者和消费者,它提供了四种处理方式:
JDK7一共提供了7个阻塞队列,典型的有ArrayBlockingQueue(数组有界阻塞队列) 、LinkedBlockingQueue(链表有界阻塞队列) 、PriorityBlockingQueue (支持优先级排序的无界阻塞队列)
线程
创建方式
创建线程有三种方式,分别是继承Thread类、实现Runnable接口、实现Callable接口。
通过继承Thread类来创建并启动线程的步骤如下:
- 定义Thread类的子类,并重写该类的run()方法,该run()方法将作为线程执行体。
- 创建Thread子类的实例,即创建了线程对象。
- 调用线程对象的start()方法来启动该线程。
join()方法,启动线程后直接调用,属于Thread的一个方法,目的是让“主线程”等待“子线程”结束之后才能继续运行。
yield()方法,“谦让”,让出CPU时间片,使正在运行中的线程重新变成就绪状态,并重新竞争 CPU 的调度权
通过实现Runnable接口来创建并启动线程的步骤如下:
- 定义Runnable接口的实现类,并实现该接口的run()方法,该run()方法将作为线程执行体。
- 创建Runnable实现类的实例,并将其作为Thread的target来创建Thread对象(通过new Thread(target , name)方法创建新线程,name是自定义的线程名),Thread对象为线程对象。
- 调用线程对象的start()方法来启动该线程。
通过实现Callable和Future接口来创建并启动线程的步骤如下:
- 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法有返回值。然后再创建Callable实现类的实例。
- 使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
- 使用FutureTask对象作为Thread对象的target创建并启动新线程。
- 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。
使用线程池,例如用Executor框架,下边有单独章节
Runnable和Callable的区别
Runnable
自 Java 1.0 以来一直存在,但Callable
仅在 Java 1.5 中引入,目的就是为了来处理Runnable
不支持的用例。Runnable
接口不会返回结果或抛出检查异常,但是 Callable
接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable
接口
采用实现Runnable、Callable接口的方式创建多线程的优缺点:
- 线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。
- 在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
- 劣势是,编程稍稍复杂,如果需要访问当前线程,则必须使用Thread.currentThread()方法。
采用继承Thread类的方式创建多线程的优缺点:
- 劣势是,因为线程类已经继承了Thread类,所以不能再继承其他父类。
- 优势是,编写简单,如果需要访问当前线程,则无须使用Thread.currentThread()方法,直接使用this即可获得当前线程。
run()和start()有什么区别?
run()方法被称为线程执行体(可以看做普通方法),它的方法体代表了线程需要完成的任务,而start()方法用来启动线程。
调用start()方法启动线程时,系统会把该run()方法当成线程执行体来处理。但如果直接调用线程对象的run()方法,则run()方法立即就会被执行,而且在run()方法返回之前其他线程无法并发执行。也就是说,如果直接调用线程对象的run()方法,系统把线程对象当成一个普通对象,而run()方法也是一个普通方法,而不是线程执行体。
当线程对象调用了start()方法之后,该线程处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于JVM里线程调度器的调度。
多线程之间的通信方式
在Java中线程通信主要有以下三种方式:
monitor(synchronize)、condition(Lock)、blockingqueue
-
wait()、notify()、notifyAll()(是object类的方法)
如果线程之间采用synchronized来保证线程安全,则可以利用wait()、notify()、notifyAll()来实现线程通信。这三个方法都不是Thread类中所声明的方法,而是Object类中声明的方法。原因是每个对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该通过这个对象来操作。并且因为wait 和 notify 它们是 Java 中两个线程之间的通信机制,Object位置更合适。另外,这三个方法都是本地方法,并且被final修饰,无法被重写。
wait()方法可以让当前线程释放对象锁并进入阻塞状态。notify()方法用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。notifyAll()用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。
每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了已就绪(将要竞争锁)的线程,阻塞队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入就绪队列,进而等待CPU的调度。反之,当一个线程被wait后,就会进入阻塞队列,等待被唤醒。
-
await()、signal()、signalAll()
如果线程之间采用Lock来保证线程安全,则可以利用await()、signal()、signalAll()来实现线程通信。这三个方法都是Condition接口中的方法,该接口是在Java 1.5中出现的,它用来替代传统的wait+notify实现线程间的协作,它的使用依赖于 Lock。相比使用wait+notify,使用Condition的await+signal这种方式能够更加安全和高效地实现线程间协作。
-
BlockingQueue
Java 5提供了一个BlockingQueue接口,虽然BlockingQueue也是Queue的子接口,但它的主要用途并不是作为容器,而是作为线程通信的工具。BlockingQueue具有一个特征:当生产者线程试图向BlockingQueue中放入元素时,如果该队列已满,则该线程被阻塞;当消费者线程试图从BlockingQueue中取出元素时,如果该队列已空,则该线程被阻塞。
程序的两个线程通过交替向BlockingQueue中放入元素、取出元素,即可很好地控制线程的通信。线程之间需要通信,最经典的场景就是生产者与消费者模型,而BlockingQueue就是针对该模型提供的解决方案。
sleep和wait方法
- sleep()是Thread类中的静态方法,而wait()是Object类中的成员方法;
- sleep()可以在任何地方使用,而wait()只能在同步方法或同步代码块中使用;
- sleep()不会释放锁,因为它的实现不依赖锁,而wait()会释放锁,并需要通过notify()/notifyAll()重新获取锁。
阻塞线程的方式
- 线程调用sleep()方法主动放弃所占用的处理器资源;
- 线程调用了一个阻塞式IO方法,在该方法返回之前,该线程被阻塞;
- 线程试图获得一个同步监视器(锁),但该同步监视器正被其他线程所持有;
- 线程在等待某个通知(notify);
- 程序调用了线程的suspend()方法将该线程挂起,但这个方法容易导致死锁,所以应该尽量避免使用该方法。可以用resume()恢复
多线程等待的方案
CountDownLatch(倒计时器)是等待count个线程执行完,才执行后面的代码。此时这组线程已经执行完。
CyclicBarrier(循环栅栏)是等待一组线程至某个状态后再同时全部继续执行线程。此时这组线程还未执行完。
不使用synchronized和Lock,如何保证变量的线程安全
-
volatile
volatile关键字为域变量的访问提供了一种免锁机制,使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。需要注意的是,volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。
-
原子变量
在java的util.concurrent.atomic包中提供了创建了原子类型变量的工具类,使用该类可以简化线程同步。例如AtomicInteger 表可以用原子方式更新int的值,可用在应用程序中(如以原子方式增加的计数器),但不能用于替换Integer。可扩展Number,允许那些处理机遇数字类的工具和实用工具进行统一访问。缺点是只能保证单个共享变量的线程安全,锁则可以保证临界区内的多个共享变量的线程安全。
-
本地存储
可以通过ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。
-
不可变的
只要一个不可变的对象被正确地构建出来,那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态,“不可变”带来的安全性是最直接、最纯粹的。Java语言中,如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,由于Java语言目前暂时还没有提供值类型的支持,那就需要对象自行保证其行为不会对其状态产生任何影响才行。String类是一个典型的不可变类,可以参考它设计一个不可变类。 -
并发工具包(JUC)
使用
Semaphore
|ˈseməfɔː®|信号量控制线程个数,使用CountDownLatch去等待一组操作完成后再执行,使用CyclicBarrier让一组线程到某个状态才继续执行
单例线程安全,参考单例模式中双重检查锁的思路、还有Lock、CAS也都可以实现。
ThreadLocal
线程私有的局部变量存储容器,内部真正存取是一个ThreadLocalMap。每个线程可以通过set()和get()存取变量,多线程间无法访问各自的局部变量,相当于在每个线程间建立了一个隔板。只要线程处于活动状态,它所对应的ThreadLocal实例就是可访问的,线程被终止后,它的所有实例将被垃圾收集。
ThreadLocal经典的使用场景是为每个线程分配一个 JDBC 连接 Connection,这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B线程正在使用的 Connection。 另外ThreadLocal还经常用于管理Session会话,将Session保存在ThreadLocal中,使线程处理多次处理会话时始终是同一个Session。
ThreadLocal不能替代同步机制,两者面向的问题领域不同。同步机制是为了同步多个线程对相同资源的并发访问,是多个线程之间进行通信的有效方式。而ThreadLocal是为了隔离多个线程的数据共享,从根本上避免多个线程之间对共享资源(变量)的竞争,也就不需要对多个线程进行同步了。
内存泄露:虽然ThreadLocalMap中的key是弱引用,当不存在外部强引用的时候,就会自动被回收,但是Entry中的value依然是强引用。这个value的引用链条如下:
每个key都弱引用指向threadlocal,当把threadlocal实例置为null以后,没有任何强引用指向threadlocal实例,所以threadlocal将会被gc回收。但是,我们的value却不能回收,因为存在一条从current thread连接过来的强引用。只有当Thread被回收时,这个value才有被回收的机会,否则,只要线程不退出,value总是会存在一个强引用。但是,要求每个Thread都会退出,是一个极其苛刻的要求,对于线程池来说,大部分线程会一直存在在系统的整个生命周期内,那样的话,就会造成value对象出现泄漏的可能。处理的方法是,在ThreadLocalMap进行set(),get(),remove()的时候,都会进行清理。如果不需要这个ThreadLocal变量时,主动调用remove()
处理hash冲突:线性探测,占用则找下一个位置
守护线程
Java分为两种线程:用户线程和守护线程。如果我们不将一个线程以守护线程方式来运行,即使主线程已经执行完毕,程序也永远不会结束。创建一个守护线程,对于一个系统来说在功能上不是主要的。例如抓取系统资源明细和运行状态的日志线程或者监控线程。
Java的守护线程有垃圾回收器线程,终结器线程等。
JVM是在所有非守护线程退出后才退出。
创建方法:在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程
Fork/Join
Fork/Join 框架是 Java7 提供了的一个用于并行执行任务的框架, 是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。
线程池
线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 线程池可以降低资源消耗、提高响应速度、提高线程可管理性。
Executor|ɪɡˈzekjətə|
,在 Java 5 之后,通过 Executor
来启动线程比使用 Thread
的 start
方法更好,除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。(this调用尚未完全构造的对象的方法)
线程池的生命周期包含5个状态: RUNNING、SHUTDOWN、STOP、TIDING、TERMINATED。这5种状态的状态值分别是:-1、0、1、2、3。在线程池的生命周期中,它的状态只能由小到大迁移,是不可逆的。
三大结构
- 任务:执行任务需要实现的
Runnable
接口 或Callable
接口。Runnable
接口或Callable
接口 实现类都可以被ThreadPoolExecutor
或ScheduledThreadPoolExecutor
执行。 - 任务的执行:包括任务执行机制的核心接口
Executor
,以及继承自Executor
接口的ExecutorService
接口。ThreadPoolExecutor
和ScheduledThreadPoolExecutor
这两个关键类实现了 ExecutorService 接口。 - 异步计算的结果:
Future
接口以及Future
接口的实现类FutureTask
类都可以代表异步计算的结果。
ScheduledThreadPoolExecutor
实际上是继承了 ThreadPoolExecutor
并实现了 ScheduledExecutorService
,而 ScheduledExecutorService
又实现了 ExecutorService
。
- Future:封装并行调用的类,可以取消任务的执行,确定执行是否已成功完成或出错,以及其他操作;
- FutureTask:这是 Future 接口的实现,将在并行调用中执行。
- Callable:用于实现并行执行的接口。它与 Runnable 接口非常相似,但是它不返回任何值,而 Callable 必须在执行结束时返回一个值。
- ExecutorService:用于在创建线程池,开始和取消管理并行执行的线程。
使用流程
- 主线程首先要创建实现
Runnable
或者Callable
接口的任务对象。 - 把创建完成的实现
Runnable
/Callable
接口的 对象直接交给ExecutorService
执行:ExecutorService.execute(Runnable command)
)或者也可以把Runnable
对象或Callable
对象提交给ExecutorService
执行(ExecutorService.submit(Runnable task)
或ExecutorService.submit(Callable <T> task)
)。 - 如果执行
ExecutorService.submit(…)
,ExecutorService
将返回一个实现Future
接口的对象由于FutureTask
实现了Runnable
,我们也可以创建FutureTask
,然后直接交给ExecutorService
执行。 - 最后,主线程可以执行
FutureTask.get()
方法来等待任务执行完成。主线程也可以执行FutureTask.cancel(boolean mayInterruptIfRunning)
来取消此任务的执行。 shutdown()
可以终止线程池,再通过executor.isTerminated()
判断线程池是否关闭
执行流程
- 随着任务数量的增加,会增加活跃的线程数。
- 当活跃的线程数 = 核心线程数,此时不再增加活跃线程数,而是往任务队列里堆积。
- 当任务队列堆满了,随着任务数量的增加,会在核心线程数的基础上加开线程。
- 直到活跃线程数 = 最大线程数,就不能增加线程了。
- 如果此时任务还在增加,则: 任务数11 > 最大线程数8 + 队列长度2 ,抛出异常RejectedExecutionException,拒绝任务
线程池参数
ThreadPoolExecutor
类中提供的四个构造方法。主要有一个方法,其他几个构造方法都是给定某些默认参数的构造方法比如默认制定拒绝策略。
1 | /** * 用给定的初始参数创建一个新的ThreadPoolExecutor。 */ |
corePoolSize
: 核心线程数线程数定义了最小可以同时运行的线程数量。maximumPoolSize
: 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中,简言之就是用来储存等待执行任务的队列。keepAliveTime
:当线程池中的线程数量大于corePoolSize
的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁。unit
:keepAliveTime
参数的时间单位。threadFactory
:executor 创建新线程的时候会用到。handler
:饱和策略。
饱和策略
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolTaskExecutor
定义一些策略:
ThreadPoolExecutor.AbortPolicy
:抛出RejectedExecutionException
来拒绝新任务的处理。ThreadPoolExecutor.CallerRunsPolicy
:调用执行自己的线程运行任务,也就是直接在调用execute
方法的线程中运行(run
)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。ThreadPoolExecutor.DiscardPolicy
:不处理新任务,直接丢弃掉。ThreadPoolExecutor.DiscardOldestPolicy
: 此策略将丢弃最早的未处理的任务请求。
当我们不指定 RejectedExecutionHandler
饱和策略的话来配置线程池的时候默认使用的是 ThreadPoolExecutor.AbortPolicy
。在默认情况下,ThreadPoolExecutor
将抛出 RejectedExecutionException
来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。 对于可伸缩的应用程序,建议使用 ThreadPoolExecutor.CallerRunsPolicy
。当最大池被填满时,此策略可以通过创建了线程池的线程来执行 被拒绝的任务。
推荐使用 ThreadPoolExecutor
构造函数创建线程池
《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors
去创建,而是通过 ThreadPoolExecutor
构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
FixedThreadPool
和SingleThreadExecutor
: 允许请求的队列长度为Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。CachedThreadPool
和ScheduledThreadPool
: 允许创建的线程数量为Integer.MAX_VALUE
,可能会创建大量线程,从而导致 OOM。
下面使用构造函数和工具类创建ThreadPoolExecutor
1 | // ThreadPoolExecutor创建,这里使用构造函数 |
常见的线程池
也就是上边说的工具类
FixedThreadPool
线程数固定,核心线程等于最大线程,使用无界队列LinkedBlockingQueue,如果因为异常线程退出,会重新创建一个线程。其实本质上也是调用了ThreadPoolExecutor
这种方式不推荐,理由如下:
FixedThreadPool
使用无界队列 LinkedBlockingQueue
(队列的容量为 Integer.MAX_VALUE)作为线程池的工作队列会对线程池带来如下影响 :
- 当线程池中的线程数达到
corePoolSize
后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize; - 由于使用无界队列时
maximumPoolSize
将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建FixedThreadPool
的源码可以看出创建的FixedThreadPool
的corePoolSize
和maximumPoolSize
被设置为同一个值。 - 由于 1 和 2,使用无界队列时
keepAliveTime
将是一个无效参数; - 运行中的
FixedThreadPool
(未执行shutdown()
或shutdownNow()
)不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。
SingleThreadExecutor
串行执行task,相当于核心线程数只有1,使用无界队列LinkedBlockingQueue
同样不推荐,与FixedThreadPool
理由一致,因为都是无界队列。
CachedThreadPool
核心线程为0,最大线程为Integer.MAX_VALUE,有任务进来时,如果当前线程都繁忙,则创建新的线程
同样不推荐,理由是当提交速度过快,会导致OOM
execute()
和 submit()
execute()
方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;submit()
方法用于提交需要返回值的任务。线程池会返回一个Future
类型的对象,通过这个Future
对象可以判断任务是否执行成功,并且可以通过Future
的get()
方法来获取返回值,get()
方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)
方法的话,如果在timeout
时间内任务还没有执行完,就会抛出java.util.concurrent.TimeoutException
。
shutdown()
和shutdownNow()
shutdown()
:关闭线程池,线程池的状态变为SHUTDOWN
。线程池不再接受新任务了,但是队列里的任务得执行完毕。shutdownNow()
:关闭线程池,线程的状态变为STOP
。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。
isTerminated()
和 isShutdown()
isShutDown
当调用shutdown()
方法后返回为 true。isTerminated
当调用shutdown()
方法后,并且所有提交的任务完成后返回为 true
ScheduledThreadPoolExecutor
ScheduledThreadPoolExecutor
主要用来在给定的延迟后运行任务,或者定期执行任务。
ScheduledThreadPoolExecutor
使用的任务队列 DelayQueue
封装了一个 PriorityQueue
,PriorityQueue
会对队列中的任务进行排序,执行所需时间短的放在前面先被执行(ScheduledFutureTask
的 time
变量小的先执行),如果执行所需时间相同则先提交的任务将被先执行(ScheduledFutureTask
的 squenceNumber
变量小的先执行)。
ScheduledThreadPoolExecutor
和 Timer
的比较:
Timer
是任务调度的一个工具,不过有了ScheduledThreadPoolExecutor
后不推荐使用Timer了
Timer
对系统时钟的变化敏感,ScheduledThreadPoolExecutor
不是;Timer
只有一个执行线程,因此长时间运行的任务可以延迟其他任务。ScheduledThreadPoolExecutor
可以配置任意数量的线程。 此外,如果你想(通过提供 ThreadFactory),你可以完全控制创建的线程;- 在
TimerTask
中抛出的运行时异常会杀死一个线程,从而导致Timer
死机:-( …即计划任务将不再运行。ScheduledThreadExecutor
不仅捕获运行时异常,还允许您在需要时处理它们(通过重写afterExecute
方法ThreadPoolExecutor
)。抛出异常的任务将被取消,但其他任务将继续运行。
ScheduledThreadPoolExecutor
的执行主要分为两大部分:
- 当调用
ScheduledThreadPoolExecutor
的scheduleAtFixedRate()
方法或者scheduleWithFixedDelay()
方法时,会向ScheduledThreadPoolExecutor
的DelayQueue
添加一个实现了RunnableScheduledFuture
接口的ScheduledFutureTask
。 - 线程池中的线程从
DelayQueue
中获取ScheduledFutureTask
,然后执行任务。
ScheduledThreadPoolExecutor
为了实现周期性的执行任务,对 ThreadPoolExecutor
做了如下修改:
- 使用
DelayQueue
作为任务队列; - 获取任务的方不同
- 执行周期任务后,增加了额外的处理
执行周期任务的步骤
- 线程 1 从
DelayQueue
中获取已到期的ScheduledFutureTask(DelayQueue.take())
。到期任务是指ScheduledFutureTask
的 time 大于等于当前系统的时间; - 线程 1 执行这个
ScheduledFutureTask
; - 线程 1 修改
ScheduledFutureTask
的 time 变量为下次将要被执行的时间; - 线程 1 把这个修改 time 之后的
ScheduledFutureTask
放回DelayQueue
中(DelayQueue.add()
)。
线程池大小的确定
不合理的线程池大小会造成CPU和内存资源的浪费,上下文切换的成本。
- CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
- I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
通常来讲,在内存中对大量数据进行排序是CPU密集型,涉及到网络读取,文件读取这类都是 IO 密集型。
反射
反射就是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意方法和属性;并且能改变它的属性。因为有一些类在编译时无法得知它属于哪个类,只能依靠运行时来获取。
通过反射机制,我们可以实现如下的操作:
- 程序运行时,可以通过反射获得任意一个类的Class对象,并通过这个对象查看这个类的信息;
- 程序运行时,可以通过反射创建任意一个类的实例,并访问该实例的成员;
- 程序运行时,可以通过反射机制生成一个类的动态代理类或动态代理对象。
反射有哪些类
在JDK中,主要由以下类来实现Java反射机制,这些类都位于java.lang.reflect包中
Class类:代表一个类
Class.forName()
:通过限定名获取类
Class.getClass()
:获取实例的类
Field 类
代表类的成员变量(属性)
Class.getFields()
:获取该类及其父类的所有public字段
Class.getDeclareFields()
:获取该类的所有字段,不包括父类字段
Method类
代表类的成员方法
getDeclaredMethods()
:获取所有非构造方法
getMethods()
:仅可获取公有非构造方法
method.invoke(Object obj,Object args[])
:调用method类代表的方法,其中obj是对象名,args是传入method方法的参数,简而言之就是调用obj中的method方法
Constructor 类
代表类的构造方法
getDeclaredConstructors()
:获取所有构造方法
getConstructors()
:仅可获取公有构造方法
Array类
提供了动态创建数组,以及访问数组的元素的静态方法
Spring 通过 XML 配置模式装载 Bean就用到了反射,解析xml并且通过Class.forName获取到对象。好处是便于维护,并且外部调用方便。
使用JDBC时,如果要创建数据库的连接,则需要先通过反射机制加载数据库的驱动程序。
1 | Person per = new Person(); |
IO
- 按照数据流向,可以将流分为输入流和输出流,其中输入流只能读取数据、不能写入数据,而输出流只能写入数据、不能读取数据。
- 按照数据类型,可以将流分为字节流和字符流,其中字节流操作的数据单元是8位的字节,而字符流操作的数据单元是16位的字符。字符流=字节流+编码集。stream结尾都是字节流,reader和writer结尾都是字符流 两者的区别就是读写的时候一个是按字节读写,一个是按字符。 实际使用通常差不多。 在读写文件需要对内容按行处理,比如比较特定字符,处理某一行数据的时候一般会选择字符流。 只是读写文件,和文件内容无关的,一般选择字节流。
- 按照处理功能,可以将流分为节点流和处理流,其中节点流可以直接从/向一个特定的IO设备(磁盘、网络等)读/写数据,也称为低级流,而处理流是对节点流的连接或封装,用于简化数据读/写功能或提高效率,也称为高级流。
节点流:可以从或向一个特定的地方(节点)读写数据。如FileReader
处理流:**是对一个已存在的流的连接和封装,通过所封装的流的功能调用实现数据读写。**如BufferedReader
在返回二维码的时候就要用到缓冲流BufferedOutputStream
再放进byte数组,最后写进response
,下载要用到FileInputStream
StringBuffer
对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。
StringBuilder
并没有对方法进行加同步锁,所以是非线程安全的。
对于文件读可能会用到Scanner或者FileReader,Scanner(System.in)
还用来读取用户输入
BIO、NIO、AIO
- BIO 就是传统的 java.io 包,它是基于流模型实现的,交互的方式是同步阻塞方式,也就是说在读入输入流或者输出流时,在读写动作完成之前,线程会一直阻塞在那里,它们之间的调用时可靠的线性顺序。它的优点就是代码比较简单、直观;缺点就是 IO 的效率和扩展性很低,容易成为应用性能瓶颈,可以通过线程池的情况改善,适用于并发小的系统。
- NIO 是 Java 1.4 引入的 java.nio 包,提供了 Channel(通道,类似于流,但是通道可以提供双向、异步的读写,并且可以从Buffer中读写数据)、Selector(选择器,一个组件,构建队列并检测多个NIO channel,看看读或者写事件是否就绪,轮询监听IO请求)、Buffer(缓冲,包装成了对象,能够提供数据读写的服务) 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序。适用于多连接数量,短连接时长的架构中比如常见的聊天系统中。
- AIO 是 Java 1.7 之后引入的包,是 NIO 的升级版本,提供了异步非堵塞的 IO 操作方式,所以人们叫它 AIO(Asynchronous IO),异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。适用于数量多,连接长的架构中,编程比较复杂。
同步和异步:关注的是任务完成时消息通知的方式。由调用方盲目主动问询的方式是同步调用,由被调用方主动通知调用方任务已完成的方式是异步调用。
阻塞与非阻塞:阻塞还是非阻塞,关注的是接口调用(发出请求)后等待数据返回时的状态。被挂起无法执行其他操作的则是阻塞型的,可以被立即「抽离」去完成其他「任务」的则是非阻塞型的。
异常
Exception
:程序本身可以处理的异常,可以通过catch
来进行捕获。Exception
又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。Error
:Error
属于程序无法处理的错误 ,不建议通过catch
捕获 。例如 Java 虚拟机运行错误(Virtual MachineError
)、虚拟机内存不够错误(OutOfMemoryError
)、类定义错误(NoClassDefFoundError
)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
还有一个特殊的部分是RuntimeException,它是不受检查的,比如空指针、数组越界,也属于Exception
抛InterruptedException的方法有:
- java.lang.Object 类的 wait 方法
- java.lang.Thread 类的 sleep 方法
- java.lang.Thread 类的 join 方法
堆栈分配效率
栈的分配效率比堆高
栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,入栈出栈都有专门的指令执行,所以速度更快。
栈使用的是一级缓存, 他们通常都是被调用时处于存储空间中,调用完毕立即释放;
堆是存放在二级缓存中,生命周期由虚拟机的垃圾回收算法来决定,所以调用这些对象的速度要相对来得低一些。
栈上申请内存并不是总是成功。(内存不足、越界访问导致信息被破坏)
类加载流程
首先是加载阶段(Loading),它是 Java 将字节码数据从不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构(Class 对象),这里的数据源可能是各种各样的形态,如 jar 文件、class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出 ClassFormatError。
加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。
第二阶段是链接(Linking),这是核心的步骤,简单说是把原始的类定义信息平滑地转化入 JVM 运行的过程中。这里可进一步细分为三个步骤:
- 验证(Verification),这是虚拟机安全的重要保障,JVM 需要核验字节信息是符合 Java 虚拟机规范的,否则就被认为是 VerifyError,这样就防止了恶意信息或者不合规的信息危害 JVM 的运行,验证阶段有可能触发更多 class 的加载。
- 准备(Preparation),创建类或接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的,侧重点在于分配所需要的内存空间,不会去执行更进一步的 JVM 指令。
- 解析(Resolution),在这一步会将常量池中的符号引用(symbolic reference)替换为直接引用(符号引用是因为编译时不确定地址,现在具有地址就可以替换成实际地址了)。在Java 虚拟机规范中,详细介绍了类、接口、方法和字段等各个方面的解析。
最后是初始化阶段(initialization),这一步真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑。
双亲委派
当类加载器(Class-Loader)试图加载某个类型的时候,除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载 Java 类型。
每个ClassLoader都只能加载自己所绑定目录下的资源;
加载资源时的ClassLoader可以有多种选择:系统类加载器SystemClassLoader、加载当前类的ClassLoader、线程上下文类加载器ContextClassLoader
双亲委派模型如下:
- 启动类加载器(Bootstrap Class-Loader),加载 jre/lib 下面的 jar 文件,如 rt.jar。它是个超级公民,即使是在开启了 Security Manager 的时候,JDK 仍赋予了它加载的程序 AllPermission。
- 扩展类加载器(Extension or Ext Class-Loader),负责加载我们放到 jre/lib/ext/ 目录下面的 jar 包,这就是所谓的 extension 机制。
- 应用类加载器(Application or App Class-Loader),加载classpath 的内容。
类加载机制的特征:
- 双亲委派模型。但不是所有类加载都遵守这个模型,有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如 JDK 内部的 ServiceProvider/ServiceLoader机制,用户可以在标准 API 框架上,提供自己的实现(SPI),JDK 也需要提供些默认的参考实现例如JDBC,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器。
- 可见性,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的,不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。
- 单一性,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互相并不可见。
上下文加载器
ContextClassLoader,简称TCCL
Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。当服务的提供者提供了服务接口的一种实现之后,在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件。该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。但是存在一个问题:SPI的接口是Java核心库的一部分,是由引导(启动)类加载器(Bootstrap Classloader)来加载的;SPI的实现类是由系统类加载器(System类加载器)来加载的。因为Bootstrap Classloader只加载核心库,找不到SPI的实现类。
JDBC的实现
把自己加载不了的类加载到线程上下文类加载器中(通过Thread.currentThread()获取),而线程上下文类加载器默认是使用AppClassLoader。即用appClassLoarder去加载这些实现类。可以用getContextClassLoader取得当前线程的ClassLoader(即appClassLoarder),然后去加载这些实现类,就能让应用访问到。
Tomcat的实现
容器不希望它下面的webapps之间能互相访问到,所以不能用appClassLoarder去加载,所以在Application ClassLoader下新建了许多类加载器。对于每个webapp,为其新建一个webappClassLoader,用于加载webapp下面的类,这样webapp之间就不能相互访问了。webappClassLoader去加载某个类,如果找不到,再交给parent。而对于java核心库,不在tomcat的ClassLoader的加载范围。这是 Java Servlet 规范中的推荐做法,其目的是使得 Web 应用自己的类的优先级高于 Web 容器提供的类。这种代理模式的一个例外是:Java 核心库的类是不在查找范围之内的。这也是为了保证 Java 核心库的类型安全。
泛型和泛型擦除
- T 意味某种类型
- E 意味 链表、数组里的元素,如List list 表示 list 里的元素。
- K意味map(k,v) 里的键值 Key
- V 意味 返回或映射的值。
泛型的本质:参数化类型,即给类型指定一个参数。有泛型接口,泛型类,泛型方法。
泛型的好处:
1.可以在编译时检查类型安全。
2.所有的强制转换都是自动和隐式的,可以提高代码的重用率。
泛型擦除:编译器在编译期间将我们写好的泛型进行擦除,并相应的做出一些类型转换。
泛型上下限:上限使用extends,表示参数类型只能是该类型或该类型的子类。下限使用super,表示参数类型只能是该类型或该类型的父类。实例:<? extends List>
并发工具包JUC
- 原子类:从JDK 1.5开始,并发包下提供了
atomic
子包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。在atomic包里一共提供了17个类,属于4种类型的原子更新方式,分别是原子更新基本类型、原子更新引用类型、原子更新属性、原子更新数组。 - Lock接口:从JDK 1.5开始,并发包中新增了Lock接口以及相关实现类用来实现锁功能,它提供了与synchronized关键字类似的同步功能,只是在使用时需要显式地获取和释放锁。虽然它缺少了隐式获取释放锁的便捷性,但是却拥有了多种synchronized关键字所不具备的同步特性,包括:可中断地获取锁、非阻塞地获取锁、可超时地获取锁。
- 线程池:从JDK 1.5开始,并发包下新增了内置的线程池。其中,ThreadPoolExecutor类代表常规的线程池,而它的子类
ScheduledThreadPoolExecutor
对定时任务提供了支持,在子类中我们可以周期性地重复执行某个任务,也可以延迟若干时间再执行某个任务。此外,Executors是一个用于创建线程池的工具类,由于该类创建出来的是带有无界队列的线程池,所以在使用时要慎重。 - 并发容器:从JDK 1.5开始,并发包下新增了大量高效的并发的容器,这些容器按照实现机制可以分为三类。第一类是以降低锁粒度来提高并发性能的容器,它们的类名以Concurrent开头,如
ConcurrentHashMap
。第二类是采用写时复制技术实现的并发容器,它们的类名以CopyOnWrite开头,如CopyOnWriteArrayList。第三类是采用Lock实现的阻塞队列,内部创建两个Condition分别用于生产者和消费者的等待,这些类都实现了BlockingQueue接口,如ArrayBlockingQueue。 - 同步工具:从JDK 1.5开始,并发包下新增了几个有用的并发工具类,一样可以保证线程安全。其中,Semaphore类代表信号量,可以控制同时访问特定资源的线程数量;CountDownLatch类则允许一个或多个线程等待其他线程完成操作;CyclicBarrier可以让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会打开,所有被屏障拦截的线程才会继续运行。
自动拆箱和自动装箱
自动装箱、自动拆箱是JDK1.5提供的功能。
自动装箱是指把一个基本类型的数据直接赋值给对应的包装类型;
自动拆箱是指把一个包装类型的对象直接赋值给对应的基本类型;
通过自动装箱、自动拆箱功能,可以大大简化基本类型变量和包装类对象之间的转换过程。比如,某个方法的参数类型为包装类型,调用时我们所持有的数据却是基本类型的值,则可以不做任何特殊的处理,直接将这个基本类型的值传入给方法。
包装类的值都是final,传参的话只是new了一个新对象,不对原对象造成影响。
类的实例化过程
类加载: 当JVM遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。
初始化零值:内存分配完成之后,虚拟机必须将分配到的内存空间都初始化为零值,如果使用了TLAB(线程本地分配缓存区)的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。比如布尔值默认为false,int默认为0,String默认为null。
状态设置:虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
构造函数:从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即Class文件中的<init>()
方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。
设计原则
开闭原则
一个软件实体,如类,模块和函数应该对扩展开放(对提供方),对修改关闭(对使用方)。用抽象构建框架,用实现扩展细节。
举例:把创建Shape类做成抽象类,并提供一个抽象的draw方法,让子类去实现即可,这样有新的图形种类时,只需要让新的图形类继承Shape,并实现draw方法即可,使用方的代码就不需要修改(满足了开闭原则)
依赖倒置原则
指设计代码结构时,高层(调用层)模块不应该依赖底层(被调用层)模块,二者都应该依赖其抽象。抽象不应该依赖细节;细节应该依赖抽象。即面向接口编程,不要面向实现编程。
举例:小强学习课程,定义了语文和数学课程,结果有一天增加了英语,不单要改调用层,也要改被调用层。这时可以将课程转化为类,直接传递类进去。这个就是依赖注入!
单一职责原则
其实就是降低耦合,一个类只负责一项职责,应该仅有一个引起它变化的原因。
接口隔离原则
用多个专门的接口,而不使用单一的总接口,客户端不应该依赖它不需要的接口。接口隔离原则符合我们常说的高内聚低耦合的设计思想
1.可读性、复用性、可维护性和易变更性
接口隔离原则跟单一职责原则区别:
- 单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。
- 单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建。
举例:动物接口有fly和swim,但是子类dog明显不能fly,所以要细化一个陆地动物接口
里氏替换
任何父类对象出现的地方,我们都可以用其子类的对象来替换,并且可以保证原有程序的逻辑行为和正确性。
- 子类不能违背父类定义的功能
- 子类要完全实现父类的抽象方法
迪米特法则
又称最少知识原则,个类对于其他类知道的越少越好,就是说一个对象应当对其他对象有尽可能少的了解,只和朋友通信,不和陌生人说话。核心思想是最小依赖,降低程序耦合
API
常用包
java.lang 提供java基础类,例如:Object\Math\String\StringBuffer\System\Tread等,这是我们最常用的包包,但是我们并不常见到她,因为我们不需要将她手动导入;
java.util 提供包括集合框架、事件模型、日期时间、等等的使用工具类;
java.io 提供通过文件系统、数据流和序列化提供系统的输入输入;
java.net 提供实时网络应用和开发的类;
java.sql 提供使用java语言访问并处理存储在数据源中的数据API;
java.awt 和 java.swing 提供了GUI开发与设计的类,awt提供了创建界面和绘制图形图像的所有类,swing包提供了一组“轻量级”的组件,尽量让这些组件在所有平台上的工作方式相同;
java.text 提供了与自然语言无关的方式来处理文本日期、数字和消息的类和接口。
JDBC
JDBC提供了Statement、PreparedStatement 和 CallableStatement三种方式来执行查询语句
Statement 用于通用查询,不安全、容易产生SQL注入式攻击
1 | Statement sta=con.createStatement(); |
PreparedStatement 用于执行参数化查询,大量执行SQL语句可以采用批处理性能比Statement快。同时由于支持可变参数的设置,可以防止SQL注入式攻击,因此相对来更安全。“?” 叫做占位符,有多少个占位符就需要有多少个对应的值。作为 Statement 的子类,PreparedStatement 继承了 Statement 的所有功能。三种方法execute
、 executeQuery
和 executeUpdate
已被更改以使之不再需要参数
1 | PreparedStatement pst=con.prepareStatement("select * from book"); |
CallableStatement则是用于存储过程,可以接受运行时输入和输出参数。
查询流程
a、使用DriverManager类注册Mysql驱动
此操作由DriverManager.registerDriver(new com.mysql.jdbc.Driver());实现,但是实际上,com.mysql.jdbc.Driver类中的静态代码块中包含如下语句:java.sql.DriverManager.registerDriver(new Driver);,这段代码说明只要这个com.mysql.jdbc.Driver类被用到,DriverManager类就会自动被注册为mysql驱动,故只需要加载该类即可:Class.forName(“com.mysql.jdbc.Driver”);
b、调用DriverManager类中的静态方法public static Connection getConnection(String url,String user, String password) throws SQLException()来获取连接对象,这个方法的实参分别代表的是指定需要连接的数据库的地址,该数据库的用户名,该数据库的密码。
c、获取到连接对象后,通过Connection接口的createStatement()方法来获取Statement对象,此处不关心实现类,Statement对象可用于执行SQL语句
d、使用Statement对象执行executeQuery(String sql);或者executeUpdate(String sql)执行SQL语句
e、如果d步骤中执行的操作为查询,那么该方法的返回值是ResultSet对象,获取到该对象后,使用该对象调用next()方法,该方法的返回值为布尔类型,再在循环中调用get方法获取对应的值即可。如果d步骤执行的操作为增删改,那么返回值为int类型的数据,代表实际影响到的行数
f、操作完成后需要关闭Connection、Statement、ResultSet资源
Object
所有的类都直接或者间接的继承自Object,Object没有copy方法
Arrays
位于java.util下
Arrays.sort
,还可以自定义比较器,升序排列,快排,O(nlogn)
1 | Arrays.sort(intervals, new Comparator<int[]>() { |
Arrays.equals
,比较两个数组是否相同
1 | Arrays.equals(pcount, scount) |
Arrays.asList
,把里边的元素转化为list
1 | Arrays.asList(nums[i], nums[left], nums[right]) |
Arrays.fill(dp, Integer.MAX_VALUE)
,将dp中的所有元素都填充为最大值。
ArrayList
插入 add
获取 get
长度 size
通过流将Integer类型的转化为int类型,并且转成int数组
1 | list.stream().mapToInt(Integer::intValue).toArray(); |
HashMap
map.put(key, value)
,如果key值不存在,则返回值是null,但是key值如果存在,则会返回原先被替换掉的value值
map.remove()
,返回被删除元素,如果不存在,返回null
containsKey,查询是否含有该key
map.keySet(),将map的key转化为set集合,可以用来迭代
Stack
Stack插入 push
Stack栈顶 peek
Stack弹出 pop
Queue
queue.poll() 移除最先进入的元素,并且返回
Deque
Deque的push相当于addFirst,是在头部添加。
队列转列表
1 | list = new LinkedList<>(queue) |
Math
Math.ceil()天花板,向上取整
Math.floor()地板,向下取整,-1.6取整为-2,浮点数为-2.0
Math.round(),四舍五入,是在+0.5后再向下取整,Math.round(11.5)的结果是12,Math.round(-11.5)的结果为-11。
数组创建方式
一二行为声明空间并赋值,第三行为声明空间,等待赋值。
声明阶段[]均为空
二维数组至少要确定一个维度的大小,并且要是从最左边开始
输入输出
Scanner
1 | Scanner sc = new Scanner(System.in); |
hasNext()
,判断是否还有输入,这个方法会去除空格,并且默认以空格作为分隔;
hasNextLine()
,判断是否还有下一行;
nextLine()
,获取包括空格回车的内容,作为一个字符串;
next()
,获取有效字符直到空格;
nextInt()
,获取数字直到空格,需要注意的是回车不会停止读取,也就是会读取到多行的字符。
一行读取结束,必须用nextLine来切换到下一行
BufferReader
1 | BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); |
输出的时候要注意,write进缓冲区后,要用flush才能输出
1 | bw.write(" ");bw.flush(); |
String
toCharArray()
,字符串转数组
charAt()
,寻找对应下标的Character
subString()
,前闭后开,只有一个参数代表的是切割字符,从输入值开始,并且返回切割后的结果。
如果有两个参数,代表切割范围内的字符。这些操作不改变原有字符,只是返回切割后的结果
1 | String str = "12,3"; |
split返回结果为数组,如果没有分割,那值为1
String.join(str, list)
,以str,分隔list
1 | String.join(" ", deque); |
String.indexOf(String str)
,返回第一个在String中的str的下标
String.indexOf(String str, int startIndex)
,从指定索引位置开始,若无则返回-1
System.out.println(“hello” + 1 + 1); 返回hello11
System.out.println(1 + 1 + “hello”); 返回2hello
System.out.println(‘1’ + 1 + 1); 返回51,因为char类型1会转化为49(ASCII码)
String.startsWith()
,返回布尔值,用于检测字符串是否以指定的前缀开始。
String.equalsIgnoreCase(String str)
,判定相等,忽略大小写
String.toUpperCase()
,小写转大写。
StringBuilder
builder.reverse().toString()
,翻转字符串
* 增: append(xxx)
* 删: delete(int start,int end)
* 改: setCharAt(int n ,char ch) / replace(int start, int end,string str)
* 查: charAt(int n )
* 插: insert(int offset, xxx)
* 长度: Length();
Integer
intValue()
是把Integer对象类型变成int的基础数据类型;
parseInt()
是把String 变成int的基础数据类型;
Valueof()
是把String 转化成Integer对象类型;
compareTo()
用于将 Number 对象与方法的参数进行比较;返回相等返回0,大于返回1,小于返回-1
Collections
这是java.util.collections(JUC)里边的方法,
Collections.reverse()
,翻转list,没有返回值
Collections.swap(output, first, i),将output中的first和i值交换
PriorityQueue
数组复制
1 | System.arraycopy(源, 开始位置, 复制位置, 要复制的长度) |
计算机网络
OSI模型
RARP(Reverse Address Resolution Protocol):RARP以与ARP相反的方式工作,允许局域网的物理机器从网关服务器的 ARP 表或者缓存上请求其 IP 地址。
OSPF(开放最短路径优先)
ARP(地址解析协议)
TCP(传输控制协议)
HTTP(超文本传输协议)
物理层主要设备:中继器、集线器;
数据链路层主要设备:二层交换机、网桥;
网络层主要设备:路由器;
后四层主要是计算机软件控制;
IP协议头部
第一个4字节: 版本号;首部长度; 服务类型;总长度;
第二个4字节:标识;标志;片偏移;
第三个4字节:生存时间;协议;校验和;
第四个4字节:源ip地址;
第五个4字节:目的ip地址;
NAT
网络地址转换,传输层协议,用来解决网络地址不足的问题,但是IPv6同样需要NAT,因为它具有内网保护的能力
子网掩码
是为了解决lP地址分配而产生的虚拟lP技术,但是也有别的用途,就是划分子网增强安全性。所以IPV6就不需要子网掩码是错误的。
IP地址与子网掩码与运算的结果为该网络的网络号,在同一个子网,网络号也必然相同
TCP/IP模型
分为应用层、传输层、网际层、网络接口层
TCP/IP协议族
创建一个tcp服务程序的顺序
1)创建一个服务线程socket
2)创建一个服务线程处理新的连接
3)从服务器socket接受客户连接请求
4)在服务线程中,从socket中获得I/O流
5)对I/O流进行读写操作,完成与客户的交互
6)关闭I/O流
7)关闭socket
阻塞模式
对于TCP套接字(默认情况下),当使用 write()/send() 发送数据时:
- 首先会检查缓冲区,如果缓冲区的可用空间长度小于要发送的数据,那么 write()/send() 会被阻塞(暂停执行),直到缓冲区中的数据被发送到目标机器,腾出足够的空间,才唤醒 write()/send() 函数继续写入数据。
- 如果TCP协议正在向网络发送数据,那么输出缓冲区会被锁定,不允许写入,write()/send() 也会被阻塞,直到数据发送完毕缓冲区解锁,write()/send() 才会被唤醒。
- 如果要写入的数据大于缓冲区的最大长度,那么将分批写入。
- 直到所有数据被写入缓冲区 write()/send() 才能返回。
当使用 read()/recv() 读取数据时:
- 首先会检查缓冲区,如果缓冲区中有数据,那么就读取,否则函数会被阻塞,直到网络上有数据到来。
- 如果要读取的数据长度小于缓冲区中的数据长度,那么就不能一次性将缓冲区中的所有数据读出,剩余数据将不断积压,直到有 read()/recv() 函数再次读取。
- 直到读取到数据后 read()/recv() 函数才会返回,否则就一直被阻塞。
这就是TCP套接字的阻塞模式。所谓阻塞,就是上一步动作没有完成,下一步动作将暂停,直到上一步动作完成后才能继续,以保持同步性。
bind:绑定ip和端口号
accept:接收连接的套接字
socket
socket是对TCP/IP协议的封装,它的出现只是使得程序员更方便地使用TCP/IP协议栈而已。socket本身并不是协议,它是应用层与TCP/IP协议族通信的中间软件抽象层,是一组调用接口(TCP/IP网络的API函数)
IPV6地址表示
128位的无符号整数,分为8段,每段16位,用四个十六进制数来表示。
特殊形式:如果连续有一段都是0,可以直接压缩为一个0或者:表示,但是压缩0只能出现一次。
2001:0db8:85a3:0000:1319:8a2e:0370:7344
等价于
2001:0db8:85a3:0:1319:8a2e:0370:7344
2001:0db8:85a3::1319:8a2e:0370:7344
HTTP和HTTPS
HTTP 协议,全称超文本传输协议,主要是来规范浏览器和服务器端的行为
通信过程
- 服务器在 80 端口等待客户的请求。
- 浏览器发起到服务器的 TCP 连接(创建套接字 Socket)。
- 服务器接收来自浏览器的 TCP 连接。
- 浏览器(HTTP 客户端)与 Web 服务器(HTTP 服务器)交换 HTTP 消息。
- 关闭 TCP 连接。
SSL和TLS
SSL 指安全套接字协议,由于有设计缺陷,升级为TLS1.0,但由于习惯叫法,通常把 HTTPS 中的核心加密协议混成为 SSL/TLS。
内容传输使用对称加密,证书验证用非对称,公钥包含CA证书的认证
因为非对称加密慢,效率低,所以只用在证书验证上
HTTP和HTTPS的区别
1.端口:HTTP为80,HTTPS为443
2.安全性:HTTPS由于有SSL/TLS加密,安全性高(仍然有可能被中间人劫持)
DNS一定安全吗?
不一定
中间人攻击
在用户和服务器之间构建中间层,通过该层转发数据,例如常用的抓包软件Fiddler和wireShark都是这种形式
DNS和SSL证书
DNS明文传输,在DNS到客户端这一段可能会被篡改,如果黑客拥有SSL证书则同样可以发起攻击。
HTTPS中嵌入HTTP
在HTTPS网页中发起HTTP请求,现在浏览器会报:Mixed Content
错误,但是对于以前的浏览器可能没有相应的防护机制。
TCP
传输控制协议,是全双工通信,面向可靠连接。信道传输是不可靠的,利用tcp协议保证传输可靠
三次握手
三次握手的本质是为了保证传输可靠
客户端发送SYN(同步标志)表示要建立连接。服务端接收到发送SYN+ACK(确认标志)表示同意连接,客户端再发送ACK表示确认收到了能够连接。
为什么三次
因为信道不可靠,要在不可靠信道中建立可靠连接。由于网络阻塞等原因,在规定时间内没有收到服务端的ACK确认,客户端重新发送SYN2,这时如果是两次握手,那么会出现连接不对等问题。
解决丢包
发送报文时构建请求头,包括序列号、长度、偏移量等等,比如1600分了三个包,分别是600+600+400,那么第二个包偏移量就是600,接收端用ACK进行回复确认。
四次挥手
第一次挥手发送FIN(结束标志)包,在收到ACK确认后进入等待1状态,此时仍旧可以发送数据。发送数据完成后,服务端发送FIN包,客户端进入FIN-WAIT2状态,并且发送ACK确认。此时客户端连接关闭,但是客户端需要设置一定的超时时长后才能关闭。因为可能会存在最后客户端发送的ACK丢失,服务端一直保持着未关闭的状态,使用超时可以保证如果服务端没收到ACK,那么再次发送FIN包,客户端能继续响应。
第四次挥手后,会有time-wait状态,要等待两个MSL(最长报文段寿命)后才进入close
因为如果在LAST-ACK过程中报文丢失,客户端会重传,如果直接关闭会导致服务端一直传FIN
TCP如何保证传输可靠
-
校验和: TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。
-
流量控制: TCP 连接的每一方都有固定大小的缓冲空间,TCP 的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议。 (TCP 利用滑动窗口实现流量控制)
-
拥塞控制: 当网络拥塞时,减少数据的发送。主要是慢开始、拥塞避免、快重传与快恢复
-
ARQ(自动重传) 协议: 也是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。超时重传、丢失确认、迟到确认。还有其他的实现方式,例如流水线方式,只需要累计确认,但是这种情况下,网络质量不佳,容易回退N帧。重传协议窗口大小为:1< 发送窗口尺寸 <= 2的n-1次方
-
超时重传: 当 TCP 发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。
拥塞控制与流量控制的区别:拥塞控制是防止过多的数据注入到网络中,可以使网络中的路由器或链路不致过载,是一个全局性的过程。 流量控制是点对点通信量的控制,是一个端到端的问题,主要就是抑制发送端发送数据的速率,以便接收端来得及接收。
粘包问题
指发送方发送的若干数据包到接收方时变成一个,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。
沾包的原因
TCP是面向流,没有边界,而操作系统在发送TCP数据时,会通过缓冲区来进行优化,就会导致多个包合并发送。
解决方案
- 长度固定:发送端将每个包都封装成固定的长度,比如100字节大小。如果不足100字节可通过补0或空等进行填充到指定长度;
- 末尾分隔:发送端在每个包的末尾使用固定的分隔符,例如\r\n。如果发生拆包需等待多个包发送过来之后再找到其中的\r\n进行合并;例如,FTP协议;
- 消息结构:将消息分为头部和消息体,头部中保存整个消息的长度,只有读取到足够长度的消息之后才算是读到了一个完整的消息;
- 自定义协议:通过自定义协议进行粘包和拆包的处理。
UDP
面向无连接,速度更快,效率更高,例如QQ语音通话、打游戏。
DNS就是基于UDP和TCP并用,还是需要TCP的原因是,UDP最大只能支持512字节的数据返回,多余的会丢弃。因此权威DNS主备之间同步、响应包大时还是会使用TCP
TCP和UDP的区别
- TCP 面向连接(如打电话要先拨号建立连接);UDP 是无连接的,即发送数据之前不需要建立连接。
- TCP 提供可靠的服务。也就是说,通过 TCP 连接传送的数据,无差错、不丢失、不重复,且按序到达,UDP 尽最大努力交付,即不保证可靠交付。
- TCP 面向字节流,实际上是 TCP 把数据看成一连串无结构的字节流;UDP是面向报文的
- UDP 没有拥塞控制,因此网络出现拥塞不会使源主机的发送速率降低(对实时应用很有用,如 IP 电话、实时视频会议等)。
- 每一条 TCP 连接只能是点到点的(全双工),UDP 支持一对一、一对多、多对一和多对多的交互通信。
- TCP 首部开销 20 字节;UDP 的首部开销小,只有 8 个字节。
- TCP 的逻辑通信信道是全双工的可靠信道,UDP 则是不可靠信道。
DNS为什么用UDP?
TCP有连接建立的时间,如果是冷门网站,需要访问多级服务器查询,会有多次TCP连接建立时间的损耗
输入网址到显示页面的过程
Session和Cookie
HTTP无状态,利用Session,服务端可以记录用户状态,服务端会保留一定时间的Session
cookie主要是保存到客户端,存放用户信息,存放token
响应号
100~199 信息性状态码
- 100 Continue:客户端想向服务器发送实体,但不确定服务器能不能接受,所以首先会向服务器发送一个携带了100 continue的Except,服务器受到这个请求之后如果能接收客户端发来的实体,那就返回一个100 Continue响应,如果不能就返回一个错误码。
- 101 Switching Protocols:服务器正在根据客户端的指定,将协议切换成Update首部所列的协议。
200~299 成功
服务器有一组用来表示成功的状态码,分别对应于不同类型的请求。
- 200 OK:从客户端发来的请求在服务器端被正常处理了,实体的主体部分包含了所请求的资源。 表示正常返回信息
- 201 Created:用于创建服务器对象的请求(比如:PUT),响应的实体主体部分中应该包含各种引用了已经创建好的资源的URL,Location首部包含的则是具体的引用。
- 202 Accepted:请求已经被接收,但服务器还没有执行任何操作。并不意味着服务器会完成这个请求。
- 203 Non-Authoritative-Information:实体首部包含的信息不是来自于源端服务器,而是来自资源的一份副本
- 204 No Content:服务器成功处理了请求,但没有返回任何内容。主要用于在浏览器不转为显示新文档的情况下,对其进行更新(比如刷新表单页面)。
- 205 Reset Content:用于浏览器的代码,告诉浏览器清除当前页面中所有HTML表单元素。
- 206 Partial Content:成功执行了一个部分或者Range请求,因为客户端可以通过一些特殊的首部来获取部分或者范围内的文档。响应报文中包含由 Content-Range 指定范围的实体内容。
300~399 重定向
重定向状态码要么告诉客户端使用代替位置来访问他们所感兴趣的资源,要么就提供一个替代的响应而不是资源的内容。如果资源已被移动,可以发送一个重定向状态码和一个可选的Location首部来告知客户端资源已被移走。以及现在可以在那里找到它。这样浏览器就可以自己转向新的位置了。
- 300 Multiple Choise:客户端请求一个指向多个资源的URL时会返回这个状态码,比如服务器上有某个HTML文档的英语和发育版本,返回这个状态码时会有一个选项列表,这样客户端就可以选择了。
- 301 Moved Permanently:永久性重定向。该状态码表示请求的资源已被分配了新的 URI(该URL存在Location首部中),以后应使用资源现在所指的 URI。
- 302 Found:临时性重定向。该状态码表示请求的资源已被分配了新的 URI(该URL存在Location首部中),希望用户(本次)能使用新的 URL 访问,将来的请求还应使用老的URL。注意:刚开始客户端发送POST请求,在收到302状态码后,使用GET请求访问新给的URL。在HTTP1.0生效。
- 303 See Other:告知客户端应该用另一个URL(该URL存在Location首部中)来获取资源,其主要目的是允许POST请求的响应将客户端定向到某个资源上去。在HTTP1.1生效。
- 304 Not Modified:此状态码适用于客户端发送了一个有条件的请求( If-Match,If-ModifiedSince,If-None-Match,If-Range,If-Unmodified-Since )。比如客户端想获取某个资源,并且是在XXX时间修改过的新的资源,如果这个资源没有修改,服务端就返回304给客户端。
- 305 Use Proxy:用来告诉客户端必须通过一个代理来访问资源,代理的位置在Location里。
- 306:还没用这个状态码
- 307 Temporary Redirect:临时重定向。该状态码与 302 Found 有着相同的含义。307 会遵照浏览器标准,不会从 POST 变成 GET。
400~499 客户端错误
4XX 的响应结果表明客户端是发生错误的原因所在。但很多4xx错误都被浏览器解决了,所以用户经常看到的也就是404了。
- 400 Bad Request:该状态码表示请求报文中存在语法错误。
- 401 Unauthorized:告诉客户端,要想获取资源的访问权,首先要对自己认证。
- 402 Payment Required:此状态码还未被使用,保留中。
- 403 Forbidden:表明服务器拒绝了这个来自客户端的请求。一般不会说明缘由。
- 404 Not Found:表明服务器上无法找到请求的资源。一般还会包含一个实体(比如404页面),以便客户端给用户看。
- 405 Method Not Allowed:客户端发起的请求中带有所有请求的URL不支持的方法。同时应该在响应中包含Allow首部,以告诉客户端可以使用什么方法。
- 406 Not Accepted:客户端可以在请求首部中指明自己愿意接收什么类型的实体,但是当服务器没有这种类型实体的时候,会发送406.
- 407 proxy Authentication Required:与401类似,但是用于要求对资源进行认证的代理服务器。
- 408 Request TImeout:如果客户端完成请求所话的时间太长,服务器返回此代码并关闭连接。
- 409 Conflict:用于说明请求可能在资源上引发一些冲突。服务器担心请求会引发冲突时,发送此代码。并在响应的主体中描述冲突。
- 410 Gone:与404类似,只是服务器曾经拥有过此资源。
- 411 Length Required:服务器要求客户端发请求的时候包含Content-Length首部的时候发送此代码。
- 412 Precondition Failed:客户端发起了条件请求,且其中一个条件失败了的时候会收到此状态码。
- 413 Request Entity Too large:客户端发送的实体主体比服务器所能希望处理的要大时,使用此代码。
- 414 Request URL Too Long:客户端发送的请求URL比服务器所能希望处理的要长时,使用此代码。
- 415 Unsupported Media Type:服务器无法理解或无法支持客户端所发实体内容类型时,使用此状态码。
- 416 Request Range Not Satisfiable:请求报文所请求的是指定资源的某个范围,而此范围无效或者无法满足时,使用此状态码
- 417 Expectation Failed:请求的Expect请求首部包含了一个期望,但是服务器无法满足此期望时,使用此状态码。
500~599 服务器错误
5XX 的响应结果表明服务器本身发生错误。
- 500 Internal Server Error:该状态码表明服务器端在执行请求时遇到了一个妨碍它为请求提供服务的错误,也有可能是 Web 应用存在的 bug 或某些临时的故障。
- 501 Not Implemented:客户端发起的请求超出服务器的能力范围(比如使用了服务器不支持的请求方法)
- 502 Bad Gateway:作为代理或网关使用的服务器从请求响应链的下一条链路上受到了一条**伪响应(**比如,它无法连接到其他父网关)时,使用此码。
- 503 Service Unavailable:该状态码表明服务器暂时处于超负载或正在进行停机维护,现在无法处理请求,但是将来可以。如果服务器知道什么时候能回复,可以在响应首部中添加Retry-After
- 504 Gateway Timeout:与408类似,只是这里的响应来自一个网关或者代理,他们等待另一个服务器对齐请求进行响应超时了。
- 505HTTP Version Not Supported:服务器收的请求使用了它无法或者不愿支持的协议版本时,使用此状态码。
IP地址
127.0.0.1是回送地址,可以测试本地TCP/IP协议是否可用
E类地址范围:240.0.0.0 - 255.255.255.255。其中240.0.0.0-255.255.255.254作为保留地址,主要用于Internet试验和开发
操作系统
什么是操作系统
- 操作系统(Operating System,简称 OS)是管理计算机硬件与软件资源的程序,是计算机的基石。
- 操作系统本质上是一个运行在计算机上的软件程序 ,用于管理计算机硬件和软件资源。 举例:运行在你电脑上的所有应用程序都通过操作系统来调用系统内存以及磁盘等等硬件。
- 操作系统存在屏蔽了硬件层的复杂性。 操作系统就像是硬件使用的负责人,统筹着各种相关事项。
- 操作系统的内核(Kernel)是操作系统的核心部分,它负责系统的内存管理,硬件设备的管理,文件系统的管理以及应用程序的管理。 内核是连接应用程序和硬件的桥梁,决定着系统的性能和稳定性。
计算机存储系统
系统调用
进程级别
- 用户态(user mode) : 用户态运行的进程可以直接读取用户程序的数据。(用户级线程不需要任何硬件支持)
- 内核态(kernel mode):可以简单的理解系统态运行的进程或程序几乎可以访问计算机的任何资源,不受限制。
系统调用是在我们运行的用户程序中,凡是与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等),都必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。
大致分类:
- 设备管理。完成设备的请求或释放,以及设备启动等功能。
- 文件管理。完成文件的读、写、创建及删除等功能。
- 进程控制。完成进程的创建、撤销、阻塞及唤醒等功能。
- 进程通信。完成进程之间的消息传递或信号传递等功能。
- 内存管理。完成内存的分配、回收以及获取作业占用内存区大小及地址等功能。
写与创建线程都需要从用户态进入内核态,所以在user space里不能直接进行。
user space和kernel space的运行空间是相互隔离的
进程的几种状态
- 创建状态(new) :进程正在被创建,尚未到就绪状态。
- 就绪状态(ready) :进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。
- 运行状态(running) :进程正在处理器上上运行(单核 CPU 下任意时刻只有一个进程处于运行状态)。
- 阻塞状态(waiting) :又称为等待、挂起状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。
- 结束状态(terminated) :进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行。
java线程的几种状态:
如果是因为缺少资源退出那么会进入等待状态而不是进入就绪
新建(NEW)、运行(RUNABLE,包含就绪态和运行态)、终止(TERMINATED)、阻塞(BLOCKED)、等待(WAITING)、超时等待(TIMED_WAITING)
竞态条件
竞态条件 是指一个在设备或者系统试图同时执行两个操作的时候出现的不希望的状况,但是由于设备和系统的自然特性,为了正确地执行,操作必须按照合适顺序进行。
进程、线程、协程
协程:轻量级线程,一个线程可以对应多个协程,并且协程切换时代价较小。
进程:资源分配的最小单位。
线程:运行调度的最小单位。
线程和协程的区别
- 线程是操作系统的资源,线程的创建、切换、停止等都非常消耗资源,而创建协程不需要调用操作系统的功能,编程语言自身就能完成,所以协程也被称为用户态线程,协程比线程轻量很多;
- 线程在多核环境下是能做到真正意义上的并行,而协程是为并发而产生的;
- 一个具有多个线程的程序可以同时运行几个线程,而协同程序却需要彼此协作的运行;
- 线程进程都是同步机制,而协程则是异步;
- 线程是抢占式,而协程是非抢占式的,所以需要用户自己释放使用权来切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力;
- 操作系统对于线程开辟数量限制在千的级别,而协程可以达到上万的级别。
进程和线程的区别
- 地址空间:进程有独立的地址空间,线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间
- 开销:进程上下文切换代价更大
- 执行入口:进程有程序入口和出口,线程没有
- 健壮性:进程崩溃不会对其他进程产生影响,线程崩溃进程会跟着崩溃
操作系统中的进程间通信方式
- 管道/匿名管道(Pipes) :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。
- 有名管道(Names Pipes) : 匿名管道由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道。有名管道严格遵循先进先出(first in first out)。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。
- 信号(Signal) :信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生;
- 消息队列(Message Queuing) :消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。管道和消息队列的通信数据都是先进先出的原则。与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显式地删除一个消息队列时,该消息队列才会被真正的删除。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势。消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺点。
- 信号量(Semaphores) :信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。信号量的值为最大允许进入的值,每进入一个减一,范围为[-1,最大允许]
- 共享内存(内存映射,Shared memory) :使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。
- 套接字(Sockets) : 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。
线程同步方式
- 互斥量(Mutex):采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的 synchronized 关键词和各种 Lock 都是这种机制。
- 信号量(Semaphore) :它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。
- 事件(Event) :Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作。
进程调度算法
- 先到先服务(FCFS)调度算法 : 从就绪队列中选择一个最先进入该队列的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
- 短作业优先(SJF)的调度算法 : 从就绪队列中选出一个估计运行时间最短的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
- 时间片轮转调度算法 : 时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法,又称 RR(Round robin)调度。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。
- 多级反馈队列调度算法 :前面介绍的几种进程调度的算法都有一定的局限性。如短进程优先的调度算法,仅照顾了短进程而忽略了长进程 。多级反馈队列调度算法既能使高优先级的作业得到响应又能使短作业(进程)迅速完成。,因而它是目前被公认的一种较好的进程调度算法,UNIX 操作系统采取的便是这种调度算法。大致思路是有多个优先级队列,优先级越高,时间片越小(大家都着急执行)
- 优先级调度 : 为每个流程分配优先级,首先执行具有最高优先级的进程,依此类推。具有相同优先级的进程以 FCFS 方式执行。可以根据内存要求,时间要求或任何其他资源要求来确定优先级。
死锁
定义:多个进程/线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于进程/线程被无限期地阻塞,因此程序不可能正常终止。
产生的四个条件:
- 互斥:资源必须处于非共享模式,即一次只有一个进程可以使用。如果另一进程申请该资源,那么必须等待直到该资源被释放为止。
- 占有并等待:一个进程至少应该占有一个资源,并等待另一资源,而该资源被其他进程所占有。
- 循环等待:有一组等待进程
{P0, P1,..., Pn}
,P0
等待的资源被P1
占有,P1
等待的资源被P2
占有,……,Pn-1
等待的资源被Pn
占有,Pn
等待的资源被P0
占有。 - 不可剥夺(非抢占):资源不能被抢占。只能在持有资源的进程完成任务后,该资源才会被释放。
解决方法:
- 预防 是采用某种策略,限制并发进程对资源的请求,从而使得死锁的必要条件在系统执行的任何时间上都不满足。是操作系统对用户程序限制的(限制其申请资源)
- 避免则是系统在分配资源时,根据资源的使用情况提前做出预测,从而避免死锁的发生。是操作系统对进程和进程之间的(对用户程序不加限制)
- 检测是指系统设有专门的机构,当死锁发生时,该机构能够检测死锁的发生,并精确地确定与死锁有关的进程和资源。
- 解除 是与检测相配套的一种措施,用于将进程从死锁状态下解脱出来。
死锁的预防:
破坏任意一个条件,但是互斥条件不方便破坏,非抢占可以改为抢占,但是这种方式会降低资源利用率
破坏占有并等待:使用静态分配策略,有足够资源才能开始执行。降低了资源利用率
破坏循环等待:层次分配策略,同一层的资源只能拥有一个,并且只能向更高层申请,必须从更高层释放
死锁的避免:
银行家算法
死锁的检测:
- 如果进程-资源分配图中有环路,且每个资源类仅有一个资源,则系统中已经发生了死锁。
- 如果进程-资源分配图中有环路,且涉及到的资源类有多个资源,此时系统未必会发生死锁。如果能在进程-资源分配图中找出一个 既不阻塞又非独立的进程 ,该进程能够在有限的时间内归还占有的资源,也就是把边给消除掉了,重复此过程,直到能在有限的时间内 消除所有的边 ,则不会发生死锁,否则会发生死锁。(消除边的过程类似于 拓扑排序)
死锁的解除:
- 立即结束所有进程的执行,重新启动操作系统 :这种方法简单,但以前所在的工作全部作废,损失很大。
- 撤销涉及死锁的所有进程,解除死锁后继续运行 :这种方法能彻底打破死锁的循环等待条件,但将付出很大代价,例如有些进程可能已经计算了很长时间,由于被撤销而使产生的部分结果也被消除了,再重新执行时还要再次进行计算。
- 逐个撤销涉及死锁的进程,回收其资源直至死锁解除。
- 抢占资源 :从涉及死锁的一个或几个进程中抢占资源,把夺得的资源再分配给涉及死锁的进程直至死锁解除。
内存管理
什么是内存管理:软件运行时对计算机内存资源的分配和使用的技术。主要负责内存的分配与回收(malloc 函数:申请内存,free 函数:释放内存),另外地址转换也就是将逻辑地址转换成相应的物理地址等功能也是操作系统内存管理做的事情。
使用逻辑地址的好处:
- 程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区。
- 程序可以使用一系列虚拟地址来访问大于可用物理内存的内存缓冲区。当物理内存的供应量变小时,内存管理器会将物理内存页(通常大小为 4 KB)保存到磁盘文件。数据或代码页会根据需要在物理内存与磁盘之间移动。
- 不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存。
内存管理机制:
块式管理是连续分配管理,页式管理和段式管理是非连续分配管理
- 块式管理 : 远古时代的计算机操作系统的内存管理方式。将内存分为几个固定大小的块,每个块中只包含一个进程。如果程序运行需要内存的话,操作系统就分配给它一块,如果程序运行只需要很小的空间的话,分配的这块内存很大一部分几乎被浪费了。这些在每个块中未被利用的空间,我们称之为碎片。
- 页式管理 :把主存分为大小相等且固定的一页一页的形式,页较小,相比于块式管理的划分粒度更小,提高了内存利用率,减少了碎片。页式管理通过页表(页面映射表)对应逻辑地址和物理地址。
- 段式管理 : 页式管理虽然提高了内存利用率,但是页式管理其中的页并无任何实际意义。 段式管理把主存分为一段段的,段是有实际意义的,每个段定义了一组逻辑信息,例如,有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。 段式管理通过段表对应逻辑地址和物理地址。
- 段页式管理:把主存先分成若干段,每个段又分成若干页,也就是说 段页式管理机制 中段与段之间以及段的内部的都是离散的。
分页和分段的异同:都是为了提高内存利用率,减少内存碎片;都是离散存储;不同点在于页的大小是固定的,由操作系统决定;而段的大小不固定,取决于我们当前运行的程序。分页仅仅是为了满足操作系统内存管理的需求,而段是逻辑信息的单位,在程序中可以体现为代码段,数据段,能够更好满足用户的需要。
快表:
快表是一种特殊的高速缓冲存储器,降低了虚地址转化为物理地址的时间。流程如下:
- 根据虚拟地址中的页号查快表;
- 如果该页在快表中,直接从快表中读取相应的物理地址;
- 如果该页不在快表中,就访问内存中的页表,再从页表中得到物理地址,同时将页表中的该映射表项添加到快表中;
- 当快表填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中的一个页。
多级页表:避免把全部页表一直放在内存中占用过多空间,特别是那些根本就不需要的页表就不需要保留在内存中。多级页表属于时间换空间的典型场景
局部性原理:局部性原理是指CPU访问存储器时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。
- 时间局部性 :如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据被访问过,不久以后该数据可能再次被访问。产生时间局部性的典型原因,是由于在程序中存在着大量的循环操作。(一般使用高速缓存)
- 空间局部性 :一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令通常是顺序存放、顺序执行的,数据也一般是以向量、数组、表等形式簇聚存储的。(提供较大空间)
虚拟内存技术实现:与离散的内存管理机制几乎相同,不同点在于请求分页存储管理多了请求两个字,也就是说可以把部分的地址空间装进主存,其他需要再利用中断进行添加。所以才能提供虚拟内存,这也得益于局部性原理,其具有如下特性:
- 一定容量的内存和外存:在载入程序的时候,只需要将程序的一部分装入内存,而将其他部分留在外存,然后程序就可以执行了;
- 缺页中断:如果需执行的指令或访问的数据尚未在内存(称为缺页或缺段),则由处理器通知操作系统将相应的页面或段调入到内存,然后继续执行程序;
- 虚拟地址空间 :逻辑地址到物理地址的变换。
页面置换算法:
- OPT (最佳页面置换算法) :最佳(Optimal, OPT)置换算法所选择的被淘汰页面将是以后永不使用的,或者是在最长时间内不再被访问的页面,这样可以保证获得最低的缺页率。但由于人们目前无法预知进程在内存下的若千页面中哪个是未来最长时间内不再被访问的,因而该算法无法实现。一般作为衡量其他置换算法的方法。
- FIFO(First In First Out)(先进先出页面置换算法) : 总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。
- LRU (Least Recently Used)(最近最久未使用页面置换算法) :LRU 算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 T,当须淘汰一个页面时,选择现有页面中其 T 值最大的,即最近最久未使用的页面予以淘汰。
- LFU (Least Frequently Used)(最少使用页面置换算法) : 该置换算法选择在之前时期使用最少的页面作为淘汰页。
系统为每一个进程建立一张段表,每个分段有一张页表。段表表项中至少包括段号、页表长度和页表始址,页表表项中至少包括页号和块号。在进行地址转换时,首先通过段表查到页表始址,然后通过页表找到页帧号,最终形成物理地址。
堆栈替换型算法
随着分配给程序的主存页面数增加,主存的命中率也提高,至少不下降。
FIFO不符合这样的理念
分页概念
页:虚拟内存中的分段(有两个东西,一个是页码,一个是偏移量)
页框:物理内存中的分段(有两个东西,一个是页框码,一个是偏移量)
页表(页面映射表):在虚拟和物理内存之间创建映射关系的表(有两个东西,一个是页码,一个是页框码,只保留页码或者页框码)
页内偏移量:反映页的大小,通过偏移量也能算出实际的位置
寻址方式
处理器都是虚拟地址,拿到虚拟地址中的页码,去页表中找对应的映射关系,找到实际的页框,再加上偏移量,得到实际的物理地址。
并发与并行
并发
:同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
并行
:同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的
大端小端
由于内存是从低位向高位读取,所以使用大小端可能会导致读取顺序不同
小端字节序:高位数据放高内存地址处,低位数据放低内存地址处
大端字节序:低地址存放高字节数(顺序存)
基于TCP/IP都是大端模式
MySQL
特性
limit 左开右闭,15,5是找第16到20条记录
类型
int:长度为1-255,设置为0的话默认转化为11
char:固定长度,0-255,长度不足会用空格在尾部补齐,检索时去除。
varchar:可变长度,默认65535字节,会用1-2个字节标识长度(取决于列长度,2的8次方是256,所以超过255,就会用2*8,也就是2字节)
视图
是一种虚拟存在的表,没有实际的物理记录
范式
第一范式:原子性,属性不可再分,比如收货地址,可以拆分成省、市、区,拆分到不可以拆分就是原子性
第二范式:消除非主属性对主属性的部分依赖,也就是非主属性每一列都要与主键相关。例如购书表中不需要学院的具体信息,有学院表就可以了,然后加个在购书表加一个学院的ID
第三范式:消除非主属性对主属性的传递依赖,数量依赖于书目,书目依赖于班级,那么将班级和书目拆成一个表,书目和数量拆成一个表
DML 语句和 DDL 语句区别:
- DML 是数据库操作语言(Data Manipulation Language)的缩写,是指对数据库中表记录的操作,主要包括表记录的插入(insert)、更新(update)、删除(delete)和查询(select),是开发人员日常使用最频繁的操作。
- DDL (Data Definition Language)是数据定义语言的缩写,简单来说,就是对数据库内部的对象进行创建、删除、修改的操作语言。它和 DML 语言的最大区别是 DML 只是对表内部数据的操作,而不涉及到表的定义、结构的修改,更不会涉及到其他对象。DDL 语句更多的被数据库管理员(DBA)所使用,一般的开发人员很少使用。
ACID(事务的四大特性)
- 原子性(
Atomicity
) : 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; - 一致性(
Consistency
): 执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的; - 隔离性(
Isolation
): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的; - 持久性(
Durability
): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
锁
全局锁
MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。
一般用来做全局备份,因为这时全局只可读,避免了数据不一致的情况。比如增加订单和扣余额操作。
但是InnoDB有可重复读事务,也可以避免数据不一致。mysqldump备份工具使用 -single-transaction参数,将隔离级别设置为RR(可重复读)
set global readonly=true
也可以全库只读,但是执行 FTWRL 命令之后由于客户端发生异常断开,那么 MySQL 会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高。
表级锁
因为InnoDB有行级锁,这个锁粒度大,使用较少。
但是在同时有表锁和行锁的情况下,可能存在冲突,因此引入了意向锁,它的主要作用是表明某个事务正在或者即将锁定表中的数据行。具体体现是必须先申请该表的意向共享锁,成功后再申请数据行的行锁。此时有另一个申请意向锁则会被阻塞。
表锁
表锁的语法是 lock tables … read/write。与 FTWRL 类似,可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。
元数据锁(meta data lock,MDL)
MDL 的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。
因此,在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。
这种结构读不互斥,但是读写互斥。
行级锁
在 InnoDB 事务中,行锁是在需要的时候才加上的(加锁阶段),但并不是不需要了就立刻释放,而是要等到事务结束时才释放(衰退阶段)。这个就是两阶段锁协议。
对于最可能造成锁冲突、最可能影响并发度的锁尽量往后放,这样可以最大程度的减少阻塞对于性能的影响。
记录锁(Record Lock)
对表中的记录加锁,是排它锁,会阻塞其他事务对其插入、更新、删除。存在于唯一索引
1 | -- id 列必须为唯一索引列或主键列,查询语句必须为精准匹配(=) |
间隙锁(Gap Lock)
间隙锁 是 Innodb 在 RR(可重复读) 隔离级别 下为了解决幻读问题
时引入的锁机制。间隙锁是innodb中行锁的一种。使用间隙锁锁住的是一个区间,而不仅仅是这个区间中的每一条数据。存在于非唯一索引
如果此时有数据插入,并且在间隙锁区间内,也同样会阻塞操作。
1 | -- 与记录锁的精准匹配不同,只需要一个范围即可 |
临键锁(Next-Key Lock)
Next-key锁是记录锁和间隙锁的组合,它指的是加在某条记录以及这条记录前面间隙上的锁。存在于非唯一索引,是特殊的间隙锁。
每个数据行上的非唯一索引列上都会存在一把临键锁,当某个事务持有该数据行的临键锁时,会锁住一段左开右闭区间的数据。需要强调的一点是,InnoDB 中行级锁是基于索引实现的,只有通过索引检索数据才能使用行级锁。临键锁只与非唯一索引列有关,在唯一索引列(包括主键列)上不存在临键锁
。
1 | -- 根据非唯一索引列 UPDATE 某条记录 |
锁读写性质
共享锁
S锁,不会阻塞其他事务对同一行的读请求,但会阻塞对同一行的写请求。只有当读锁释放后,才会执行其它事物的写操作。
排它锁
X锁,会阻塞其他事务对同一行的读和写操作,只有当写锁释放后,才会执行其它事务的读写操作。
死锁和死锁检测
事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。这时产生了死锁。有两种解决方案:
- 设置
innodb_lock_wait_timeout
超时参数,这个参数默认值是50s - 发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数
innodb_deadlock_detect
设置为 on,表示开启这个逻辑。(杀死小事务,指更改最少的,代价最小)
一般用第二种,但是第二种时间复杂度是O(n^2),因为每个线程要检测其他所有线程请求的资源。CPU利用率极高。
这个也有解决方案,明确不出现死锁就可以关闭这个设置,或者把一行拆分为多行(比如影院账户可以由多行累加),再或者控制并发。
事务
InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id
。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。
而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。
也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。
U1、U2、U3就是undo log中的内容,如果要恢复到V1、V2版本,就要利用undo log回滚。
由于事务ID严格递增的特性,可重复读只需要寻找开始事务之前的最新版本,并且中途一直沿用。
但是只针对于一致性的读,更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。也就是需要获取最新的值,并且做update,保证其他提交的事务不会丢失。特殊情况,如select加锁,那也是当前读。
加锁有两种,一种是lock in,一种是for update
1 | select k from t where id=1 lock in share mode; # S 读锁,共享锁 |
隔离级别
事务是逻辑上的一组操作,要么都执行,要么都不执行
一共有四类问题:
- 脏读(Dirty read): 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据。因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的。
- 丢失修改(Lost to modify): 指在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。 例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 修改 A=A-1,事务 2 也修改 A=A-1,最终结果 A=19,事务 1 的修改被丢失。
- 不可重复读(Unrepeatable read): 指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。(重点在于值被修改)。解决方案在InnoDB中使用MVCC,也可以加读锁,因为读读不互斥,读写互斥。
- 幻读(Phantom read): 幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。(重点在于新增或者删除了一些记录)。解决方案是加间隙锁。
从上到下依次为,读取未提交、读取已提交、可重复读、可串行化。InnoDB 默认的隔离级别是可重复读(repeatable-read)
- 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
- 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。查询只承认在语句启动前就已经提交完成的数据。
- 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。事务 T 启动的时候会创建一个视图 read-view,之后事务 T 执行期间,即使有其他事务修改了数据,事务 T 看到的仍然跟在启动时看到的一样。可重复读针对的是行级,因为有row trx_id。对于表来说没有这个关键字段
- 串行化,顾名思义是对于同一行记录,“写”会加“写锁”(排它锁),“读”会加“读锁”(共享锁,读写互斥)。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
内存表
1 | 创建表时,引擎选为ENGINE=MEMORY |
两百万数据借助存储过程程序放进内存表花了十分钟,但是从内存表插入只花了13秒
存储过程:是为以后的使用而保存的一条或多条 MySQL 语句的集合。可将其视为批处理文件。虽然他们的作用不仅限于批处理。
索引
类似目录
优点 :
- 使用索引可以大大加快,数据的检索速度(大大减少检索的数据量), 这也是创建索引的最主要的原因。
- 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。
缺点 :
- 创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL 执行效率。
- 索引需要使用物理文件存储,也会耗费一定空间。
索引实现分类
Hash索引
哈希表是键值对的集合,通过键(key)即可快速取出对应的值(value),因此哈希表可以快速检索数据(接近 O(1)),但是存在两个问题:
1.Hash 冲突问题 :对于数据库来说这还不算最大的缺点。
2.Hash 索引不支持顺序和范围查询(Hash 索引不支持顺序和范围查询是它最大的缺点: 假如我们要对表中的数据进行排序或者进行范围查询,那 Hash 索引可就不行了。
B+和B-树
B 树也称 B-树,全称为 多路平衡查找树,适合范围查询。 InnoDB 引擎是使用 B+Tree 作为索引结构
异同:
- B 树的所有节点既存放键(key) 也存放 数据(data),而 B+树只有叶子节点存放 key 和 data,其他内节点只存放 key并且叶子结点之间通过链表连接。
- B 树的叶子节点都是独立的;B+树的叶子节点有一条引用链指向与它相邻的叶子节点。
- B 树的检索的过程相当于对范围内的每个节点的关键字做二分查找,可能还没有到达叶子节点,检索就结束了。而 B+树的检索效率就很稳定了,任何查找都是从根节点到叶子节点的过程,叶子节点的顺序检索很明显。
一个叶子结点就是一个页,页之间用的是双向链表连接。在页的内部有多条记录,用的是单链表连接
MyISAM 引擎中,B+Tree 叶节点的 data 域存放的是数据记录的地址。在索引检索的时候,首先按照 B+Tree 搜索算法搜索索引,如果指定的 Key 存在,则取出其 data 域的值,然后以 data 域的值为地址读取相应的数据记录。这被称为“非聚簇索引”。
非聚集索引的叶子节点并不一定存放数据的指针,因为二级索引的叶子节点就存放的是主键,根据主键再回表查数据。优点是更新代价小,缺点是会产生回表(二次查询)操作。但是也并不一定产生回表操作,例如直接查主键的key,直接返回key就可以了,不需要取data(这个操作就叫做覆盖索引,索引包含所有需要查询的字段的值)。
InnoDB 引擎中,树的叶节点 data 域保存了完整的数据记录。索引的 key 是数据表的主键,因此 InnoDB 表数据文件本身就是主索引。这被称为“聚簇索引(或聚集索引),只有InnoDB才有聚簇索引。而其余的索引都作为辅助索引,辅助索引的 data 域存储相应记录主键的值而不是地址,这也是和 MyISAM 不同的地方。在根据主索引搜索时,直接找到 key 所在的节点即可取出数据;在根据辅助索引查找时,则需要先取出主键的值,再走一遍主索引。 因此,在设计表的时候,不建议使用过长的字段作为主键,也不建议使用非单调的字段作为主键,这样会造成主索引频繁分裂。
InnoDB主键索引是聚簇索引,辅助索引是非聚簇索引。
InnoDB 表数据文件本身就是主索引并且为一个文件,MyISAM在磁盘上存储成三个文件(表定义、数据、索引)
聚集索引的查询速度非常的快,因为整个 B+树本身就是一颗多叉平衡树,叶子节点也都是有序的,定位到索引的节点,就相当于定位到了数据。但是依赖有序数据并且更新代价大。
索引分类
从物理存储角度,聚簇索引(主键索引)、二级索引(辅助索引)
从特性角度,主键索引、唯一索引、普通索引、前缀索引
从组成角度,单列索引、联合索引
主键索引
默认添加,数据表的主键列使用的就是主键索引。
主键长度会存在于二级索引中,长度越短,二级索引的叶子结点越小,占用空间也越小。
二级索引
下列均属于二级索引,也叫辅助索引、普通索引,目的是定义主键索引位置
如果语句是 select * from T where k=5,即普通索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表。
- 唯一索引(Unique Key) :唯一索引也是一种约束。唯一索引的属性列不能出现重复的数据,但是允许数据为 NULL,一张表允许创建多个唯一索引。 建立唯一索引的目的大部分时候都是为了该属性列的数据的唯一性,而不是为了查询效率。
- 普通索引(Index) :普通索引的唯一作用就是为了快速查询数据,一张表允许创建多个普通索引,并允许数据重复和 NULL。
- 前缀索引(Prefix) :前缀索引只适用于字符串类型的数据。前缀索引是对文本的前几个字符创建索引,相比普通索引建立的数据更小, 因为只取前几个字符。
- 全文索引(Full Text) :全文索引主要是为了检索大文本数据中的关键字的信息,是目前搜索引擎数据库使用的一种技术。Mysql5.6 之前只有 MYISAM 引擎支持全文索引,5.6 之后 InnoDB 也支持了全文索引。
组合索引
一个索引包含多个列,好处是可以做覆盖索引。并且还具有最左前缀匹配原则,会从左到右匹配联合索引中的字段直到遇到范围查询。
覆盖索引:如果查询条件使用的是普通索引(或是联合索引的最左原则字段),查询结果是联合索引的字段或是主键,不用回表操作,直接返回结果,减少IO。
最左前缀原则
最左前缀可以是联合索引的最左 N 个字段,也可以是字符串索引的最左 M 个字符。
坏处是如果组合索引是(a,b),那在只有b的情况下,联合索引不生效。就像下图一样,b为age,在这个联合索引当中,b的出现是根据a来确定的,不具有顺序性。
索引下推(ICP)
索引下推(Index Condition Pushdown),MySQL 5.6 引入的索引下推优化,可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。如果查询利用到了索引下推ICP技术,在Explain输出的Extra字段中会有“Using index condition”。即代表本次查询会利用到索引,且会利用到索引下推。
如果一张表建立a和b两个字段的索引,然后查询条件是like a和b,则根据索引下推可以在搜索a的时候同时比对b,不需要回到主键索引中比较(like是较为特殊的查询,会用到索引下推,范围查询则后边不走索引)
索引失效场景
1.有or必全有索引;
举例:idx_name_age
(name
,age
)。查询语句select * from user where name=’jack’ or age = 18
这个or会要求取并集,会导致只有name走索引,age还是会全表扫描,因此优化器会选择直接全表扫描
2.复合索引未用左列字段;
3.like以%开头;
4.需要类型转换;
5.where中索引列有运算;
6.where中索引列使用了函数;
7.如果mysql觉得全表扫描更快时(数据少);
MySQL基本架构
- 连接器:身份认证和权限相关功能,主要负责用户登录数据库,进行用户的身份认证,包括校验账户密码,权限等操作,如果用户账户密码已通过,连接器会到权限表中查询该用户的所有权限,之后在这个连接里的权限逻辑判断都是会依赖此时读取到的权限数据,也就是说,后续只要这个连接不断开,即使管理员修改了该用户的权限,该用户也是不受影响的。
- 查询缓存(8.0后移除,跟后续的Buffer Pool不是同一个东西。):连接建立后,执行查询语句的时候,会先查询缓存,MySQL 会先校验这个 sql 是否执行过,以 Key-Value 的形式缓存在内存中,Key 是查询预计,Value 是结果集。如果缓存 key 被命中,就会直接返回给客户端,如果没有命中,就会执行后续的操作,完成后也会把结果缓存起来,方便下一次调用。当然在真正执行缓存查询的时候还是会校验用户的权限,是否有该表的查询条件。查询缓存在Server层,Buffer Pool在存储引擎层
- 分析器:分为词法和语法分析,词法分析提取关键字、表、要查询的字段等信息;语法分析检验SQL是否正确
- 优化器:按照它认为的最优的执行方案去执行
- 执行器:首先执行前会校验该用户有没有权限,如果没有权限,就会返回错误信息,如果有权限,就会去调用引擎的接口,返回接口执行的结果。
Buffer Pool
缓冲池Buffer Pool,它会从磁盘加载数据并且放到缓冲池,缓冲池中还有个Change Buffer,对于普通索引而言,如果数据页没有在缓冲池中,则直接将更新数据写入Change Buffer,语句执行结束。对于唯一索引,需要将数据页读入内存,判断到没有冲突,插入这个值,语句执行才能结束。Change Buffer有惰性和主动merge两种方式,惰性是指访问的时候merge,主动是server正常关闭或者定期merge。
将数据从磁盘读入内存涉及随机 IO 的访问,是数据库里面成本最高的操作之一。change buffer 因为减少了随机磁盘访问,所以对更新性能的提升是会很明显的。
对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时 change buffer 的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。反过来,假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在 change buffer,但之后由于马上要访问这个数据页,会立即触发 merge 过程。这样随机访问 IO 的次数不会减少,反而增加了 change buffer 的维护代价。所以,对于这种业务模式来说,change buffer 反而起到了副作用。
日志
MySQL
日志 主要包括错误日志、查询日志general log
(通用日志,语法正确与否都会记录,生产环境不建议开启)、慢查询日志(默认不开启)、事务日志redo log
、二进制日志binary log
几大类。其中,比较重要的还要属二进制日志 bin log
(归档日志)和事务日志 redo log
(重做日志)和 undo log
(回滚日志)。
redo log
(重做日志)是InnoDB
存储引擎独有的,它让MySQL
拥有了崩溃恢复能力。它是物理日志,记录内容是“在某个数据页上做了什么修改”,是存储引擎层
binlog
是逻辑日志,记录内容是语句的原始逻辑,类似于“给 ID=2 这一行的 c 字段加 1”,属于MySQL Server
层。
binlog
有三种模式,statement 格式的话是记sql语句, row格式会记录行的内容,mixed就是两种模式的结合,会选一种较为合适的。
流程如下,还涉及到刷盘(刷盘是把数据写到硬盘中,由于数据页位置随机,所以速度慢,加进redolog能够提高并发)的操作,分为三类,0是每次事务提交时不进行刷盘操作;1是每次事务提交时都将进行刷盘操作(默认值);2是每次事务提交时都只把 redo log buffer 内容写入 page cache。这种先写进redo log,等特定时机再刷盘的行为就叫做Write-Ahead Logging(WAL)
除此之外,还有个后台线程,每隔1
秒,就会把 redo log buffer
中的内容写到文件系统缓存(page cache
),然后调用 fsync
刷盘。
redolog文件一共有4个,每个1G,类似于环。
redolog有prepare和commit两个阶段,这样可以保证数据的一致性。
如果不采用这种方式,先写redo log,再写bin log,中途crash:redo log可以恢复完整的数据,但是由于bin log中语句的丢失,后续利用binlog备份恢复时出来的值就是之前的值。但如果有prepare,并且bin log失败,这时prepare就会回滚。
如果先写bin log,再写redo log,中途crash:redo log中就缺少了一个事务,值没有被更改。但是bin log中有更改的日志,数据库就不统一。
值得注意的是,redo log中的内容会被刷盘,binlog更多只是用来做备份,所有的恢复都是以bin log为基础。
回滚日志:如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可!并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚将之前未完成的事务。
undo log
的存储由InnoDB存储引擎实现,数据保存在InnoDB的数据文件中。在InnoDB存储引擎中,undo log是采用分段(segment)的方式进行存储的。rollback segment称为回滚段,每个回滚段中有1024个undo log segment。在MySQL5.5之前,只支持1个rollback segment,也就是只能记录1024个undo操作。在MySQL5.5之后,可以支持128个rollback segment,分别从resg slot0 - resg slot127,每一个resg slot,也就是每一个回滚段,内部由1024个undo segment 组成,即总共可以记录128 * 1024个undo操作。
undo log日志里面不仅存放着数据更新前的记录,还记录着RowID、事务ID、回滚指针。其中事务ID每次递增,回滚指针第一次如果是insert语句的话,回滚指针为NULL,第二次update之后的undo log的回滚指针就会指向刚刚那一条undo log日志,依次类推,就会形成一条undo log的回滚链,方便找到该条记录的历史版本。
undo log
还有另一个作用,就是MVCC。当读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过 undo log
读取之前的版本数据,以此实现快照读(非锁定读)。
总结:redo log(重做日志) 保证事务的持久性,使用 undo log(回滚日志) 来保证事务的原子性。
一条语句执行的流程
1.在内存中,直接更新内存;
2.没有在内存中,就将旧数据写进undo log
,便于回滚并且在内存的Buffer Pool
中的change buffer
区域,记录下“我要往 Page 2 插入一行”这个信息;
3.写进redo log buffer
4.redo log
写进磁盘,有三个阶段,redo log prepare
(预提交)、bin log
、redo log commit
(提交)。
5.在某个时间,IO线程会将缓冲池的内容刷进磁盘文件。
在查询的时候:
1.在内存中,直接从内存返回;
2.不在内存中,要先将数据页从磁盘读进内存中,然后应用 change buffer 里面的操作日志,生成一个正确的版本并返回结果。
redo log buffer主要节省的是随机写磁盘的 IO 消耗(转成顺序写),而 change buffer 主要节省的则是随机读磁盘的 IO 消耗。
SQL约束
1、not null 非空约束;
2、unique 唯一性约束;
3、primary key约束 :约束唯一标识数据库表中的每条记录(主键),主键必须包含唯一的值,且不为空;
4、foreign key约束:用于预防破坏表之间连接的动作;
5、check 约束 :用于限制列中的值的范围;
6、default约束 :用于向列中插入默认值 。
MVCC
多版本并发控制,主要是为了提高数据库的并发性能。MVCC为事务分配单向增长的时间戳。为每个数据修改保存一个版本,版本与事务时间戳相关联,读操作只读取
该事务开始前
的数据库快照
。
读-读
:不存在任何问题,也不需要并发控制读-写
:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读写-写
:有线程安全问题,可能会存在更新丢失问题
因此就可以解决读写的线程安全问题,因为用的是快照读,读取历史版本。如果再加上悲观锁或者乐观锁解决写写冲突,就能有效的提高并发。
在InnoDB中,MVCC
的实现依赖于:隐藏字段(事务id、回滚指针、row_id)、Read View(快照读时产生的视图)、undo log。在内部实现中,InnoDB
通过数据行的 事务id
和 Read View
(读视图,有当前读和快照读)来判断数据的可见性,如不可见,则通过数据行的 回滚指针
找到 undo log
中的历史版本。每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建 Read View
之前已经提交的修改和该事务本身做的修改。
主键或唯一重复值的解决办法
1.IGNORE:有则忽略,无则插入
2.REPLACE:有则删除再插入,无则插入
3.ON DUPLIACATE KEY UPDATE:有则更新,无则插入
InnoDB和MyISAM的区别
- InnoDB支持表、行(默认)级锁,而MyISAM支持表级锁
- Innodb不支持全文索引,而MyISAM支持全文索引
- InnoDB表必须有唯一索引(如主键)(用户没有指定的话会自己找/生产一个隐藏列Row_id来充当默认主键),而Myisam可以没有
- InnoDB支持事务,MyISAM不支持,对于InnoDB每一条SQL语言都默认封装成事务,自动提交,这样会影响速度,所以最好把多条SQL语言放在begin和commit之间,组成一个事务;
- InnoDB支持外键,而MyISAM不支持。对一个包含外键的InnoDB表转为MyISAM会失败;
- InnoDB是聚集索引,使用B+Tree作为索引结构,数据文件是和(主键)索引绑在一起的(表数据文件本身就是按B+Tree组织的一个索引结构),必须要有主键,通过主键索引效率很高。但是辅助索引需要两次查询,先查询到主键,然后再通过主键查询到数据。因此,主键不应该过大,因为主键太大,其他索引也都会很大。MyISAM是非聚集索引,也是使用B+Tree作为索引结构,索引和数据文件是分离的,索引保存的是数据文件的指针(表定义、数据、索引)。主键索引和辅助索引是独立的。
如果是读多写少的项目,可以考虑使用MyISAM,MYISAM索引和数据是分开的,而且其索引是压缩的,可以更好地利用内存。所以它的查询性能明显优于INNODB。压缩后的索引也能节约一些磁盘空间。MYISAM拥有全文索引的功能,这可以极大地优化LIKE查询的效率。
MySQL主从同步
- 主服务器(master)把数据更改记录到二进制日志(binlog)中。
- 从服务器(slave)把主服务器的二进制日志复制到自己的中继日志(
relay log
)中。 - 从服务器重做中继日志中的日志,把更改应用到自己的数据库上,以达到数据的最终一致性。
ER模型
基础数据模型:层次模型、网状模型、关系模型、面向对象数据模型。
Decimal
DECIMAL(M,D),M为精度,其范围为“1~65”,默认值是10;
D是小数点右侧数字的数目(标度),其范围是“0~30”,但不得超过M。
SQL语句
DDL(Data Definition Language)
数据库定义语句
ALTER TABLE student MODIFY class CHAR(10) DEFAULT ‘暂未输入’;
ALTER TABLE 表名 add constraint FK_ID foreign key(你的外键字段名) REFERENCES 外表表名(对应的表的主键字段名);
DML
group by
打组,在select中使用到的,全都要放在group by之后
having
是对group by聚合后的结果做筛选,可以使用聚合函数,中间用and
连接
where
是在聚合前做筛选,在where中不能使用聚合函数
desc
是用在order by之后,降序排列,默认是升序ASC
left join
用在连接表的情况,比子查询效率高,子查询是笛卡尔积。如果是一对多关系,则左侧记录可能会出现多次,这时候需要用group by打组。
inner join
,相比left join,它不会连接右表没有数据的记录,达到只查询答过题的用户的目的
distinct
,去重,可以用来统计类目数量
1 | # 统计每个学校的平均刷题数 |
is not null
,不能用不等于
1 | select device_id from user_profile where age is not null |
order by
,排序的字段是select的字段
union
,去重的合并,union all
,不去重
case when
条件 then
显示内容 else
除了上述when和then的部分(可省略), end
实际装载的字段
1 | # 查看不同年龄段的用户明细 |
year、month、day
函数
1 | # 计算用户8月每天的练题数量 |
DATE_ADD(date,INTERVAL expr unit)
date是时间,可以用时间函数now(),INTERVAL是固定的,expr可以为正数或者负数,unit可以为year、month、day、hour
substring_index(profile, ",", -1)
,分隔字符串,最后的数字表示,获取哪一截
“www.dubai.com” 的话,为1返回www,为2返回www.dubai,-1返回com,-2返回dubai.com。嵌套使用可以获取某个特定的位置。
if (布尔值, true为该值, false为该值)
,判断,比较特殊的地方是只需要一个等号
to_days(日期字段) = to_days(now())
,将时间转化为天数(从年份0开始),可以实现获取当日数据
1 | min、max、sum、avg、count |
left join + where的组合可以用 inner join 并且在on后边直接接条件实现
1 | -- 题目:浙江大学用户题目回答情况 |
rollback
,在事务里边(执行一条 START TRANSACTION 命令之后),可以用该注解回滚。
Redis
Redis 就是一个使用 C 语言开发的高可用性数据库,高可用性体现在数据少丢失(AOF、RDB),服务少中断(增加副本冗余量),不过与传统数据库不同的是 Redis 的数据是存在内存中的 ,也就是它是内存数据库,所以读写速度非常快,因此 Redis 被广泛应用于缓存方向。另外,Redis 除了做缓存之外,也经常用来做分布式锁,甚至是消息队列。
这类的非关系型数据库还有个统称,叫NoSQL。
本质是通过hash函数,快速的在数组(全局哈希表)中找到对应的元素(哈希桶),桶中存放了entry,包含key、value以及next的指针,指向了键和几种数据类型,也就是value。解决冲突的办法就是拉链法,在哈希桶中通过链表连接多个元素。
对于redis来讲,修改数据采用写时复制,复制的粒度为一个内存页,所以在使用大内存页并且修改小数据的时候,会出现读写放大(指磁盘上实际读写的数据量 / 用户需要的数据量)。
常用指令
启动redis
可以通过如下命令指定conf文件,配置开机自启、端口、最大缓存等
1 | redis-server [xx/xx/redis.conf] |
停止redis
1 | redis-cli shutdownkill redis-pid |
键操作
获取所有键
1 | keys * |
获取键总数
1 | dbsize |
查询键是否存在,返回存在的个数
1 | exists key 可以查多个 |
删除键
1 | del key 可以删除多个 |
查询生命周期,-1为永不过期
1 | ttl key |
设置过期时间
1 | 秒语法:expire key seconds毫秒语法:pexpire key milliseconds |
值递增,要求string的编码要为int
1 | 递增:incr 递减:decr |
setnx,全称是SET if Not eXists,如果不存在则设置,可以用做锁
ReHash
随着数据增多,哈希表元素碰撞的可能性增大,哈希桶中元素也相应增大,会影响查询速度,因此会使用rehash的方法。
Redis维护了两个全局哈希表,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:
- 给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;
- 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;
- 释放哈希表 1 的空间。
哈希表1就会留到下次rehash,这个操作类似于JVM内存回收中的标记整理法。
但是对于大量的数据,一次完成复制会造成阻塞,Redis 采用了渐进式 rehash,流程如下:
- 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
- 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
- 随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。
在渐进式 rehash 进行期间, 字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行: 比如说, 要在字典里面查找一个键的话, 程序会先在 ht[0] 里面进行查找, 如果没找到的话, 就会继续到 ht[1] 里面进行查找。并且如果有新增,则直接存到ht[1]
触发条件
扩容:每次插入键值对时,都会检查是否需要扩容。需要满足的条件如下任意一个:
- 哈希表中保存的key数量超过了哈希表的大小(可以看出size既是哈希表大小,同时也是扩容阈值)
- 或者保存的节点数与哈希表大小的比例超过了安全阈值(默认值为5)
前提条件是当前没有子进程在执行AOF文件重写或者生成RDB文件。
缩容:当哈希表的负载因子(已保存节点数量 / 哈希表大小)小于 0.1 时, 程序自动开始对哈希表执行收缩操作。缩容后的大小为第一个大于等于当前key数量的2的幂,最小容量为4。
数据结构
string
string 数据结构是简单的 key-value 类型。虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(simple dynamic string,SDS)。相比于 C 的原生字符串,Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N)),除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出。可以用做访客计数、转发量等
String有三种存储方式(encoding)int、raw、embstr
int
:如果一个字符串内容可转为 long(64位有符号整数),那么该字符串会被转化为 long 类型,对象 ptr 指向该 long,并且对象类型也用 int 类型表示。
raw
:大于44字节的用raw,要分配两次对象,一次为sds分配对象,另一次为redisObject分配对象,redisObject会留个指针指向sds。RedisObject
有五种对象:String、List、Hash、Set和Zset。
embstr
:Redis 3.0 后引入了embstr,本来是以39字节为分界线,但是Redis 3.2 之后,优化了SDS头部信息,多了5个字节出来(使用了uint8_t代替int,原来是int 占4字节*2=8字节,变为了uint8_t占1字节 * 2 + char flags占1字节)。所以小于等于44 字节的字符串用该类型,是对短字符的优化。只分配和删除一次内存,因为只需要一起分配对象。在效率更高。但是embstr是只读的,如果要修改实际还是转为raw。
SDS
有三个主要的字段,len
记录buf中已使用的数量,alloc
是分配的长度(一般大于len,因为有幂次方还有空间预分配),buf[]
是c语言中的char数组,用’\0'
代表结束,所以C语言的char数组中某些特殊字符有歧义,二进制存储不安全。但是Redis中,不会对存入的数据进行编码和序列化操作,也不会产生乱码。
分配内存时,寻找最接近N的 2的幂次方作为分配的空间,比如申请6字节,那最接近的是2^3=8。
空间预分配:字符串变化时,会额外分配空闲空间,以1M为分界线,小于1M时,分配与len相等的未使用空间。大于1M时,就在满足所需后分配1M的未使用空间。
1、Redis实现的SDS支持扩容
2、包含长度len,获取长度复杂度O(1)
3、空间预分配
4、惰性空间释放(指字符串被缩短后,内存不会被立即回收,而是将使用的数量记录起来,将来需要的时候再回收)
5、缓冲不会溢出,因为有len和alloc属性,可以先做判断
list
编码分为ziplist、linkedlist、quicklist(3.2以前版本没有quicklist)。ziplist
底层实现为压缩列表,当元素数量小于512且所有元素长度都小于64字节时,使用这种结构来存储。linkedlist底层实现为双端链表,当数据不符合ziplist条件时,使用这种结构存储。3.2版本之后list采用quicklist的快速列表结构来代替前两种(混合)。可以用做发布与订阅或者说消息队列、慢查询。
hash
当存储的数据量较少的时,hash 采用 ziplist
作为底层存储结构。哈希对象保存的键值对数量要小于 512 个,哈希对象保存的所有键值对(键和值)的字符串长度小于 64 个字节。
当存储量较大时,则类似于 JDK1.8 前的 HashMap,使用数组 + 链表,叫dict
(字典结构),用链地址法解决冲突,特别适合用于存储对象。可以用做存储用户信息,商品信息
set
无序集合,可以基于 set 轻易实现交集、并集、差集的操作。使用的数据结构是哈希表和整数数组
。可以用做共同关注、共同粉丝、共同喜好
zset(sorted set)
和 set 相比,sorted set 增加了一个权重参数 score(可以重复),使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。zset使用跳表和ziplist
作为数据结构。可以用做排行榜
在同时满足有序集合保存的元素数量小于128个和有序集合保存的所有元素的长度小于64字节的时候使用ziplist,其他时候使用skiplist
**跳表(skiplist)**是一种有序数据结构,它通过在每个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的,插入和查询的时间复杂度都是 O(logN)。创建过程是从有序链表中选取部分节点,组成一个新链表,并以此作为原始链表的一级索引。再从一级索引中选取部分节点,组成一个新链表,并以此作为原始链表的二级索引。
在查找时,优先从高层开始查找,若next节点值大于目标值,或next指针指向NULL,则从当前节点下降一层继续向后查找,这样便可以提高查找的效率了。
bitmap
存储的是连续的二进制数字(0 和 1),可以用做保存状态信息,例如签到、登录。还有一个典型应用是布隆过滤器
HyperLogLog
基数统计,比如说一个集合中有很多数,寻找公共的,并且占用小,只需要12K
内存就能统计2^64
个数据。但是不会存储元素本身,是用了概率的数学方法来计算的
线程模型
Redis 单线程是指它对网络 IO 和数据读写的操作采用了一个线程,通过非阻塞的IO 多路复用程序 (非阻塞指调用而不等待执行结果,多路复用指监听多路的Socket)来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。**这种方式不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗。**它的原理是通过回调机制,事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来,Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。
IO多路复用
单个线程同时操作多个IO请求。Linux底层API提供了三种方式
select调用:查询有多少个文件描述符需要进行IO操作,特点:轮询次数多,内存开销大,支持文件描述符的个数有限,最大1024。
poll调用:和select几乎差不多。但是它的底层数据结构为链表,所以支持文件描述符的个数无上限。
epoll:修改主动轮询为被动通知,由事件驱动,底层的数据结构为红黑树。避免大内存分配和轮询,时间复杂度从O(n)变为O(1)。
epoll原理:
- 调用 epoll_create() 会在内核中创建一个 eventpoll 结构体数据,称之为 epoll 对象,在这个结构体中有 2 个比较重要的数据成员,一个是需要检测的文件描述符的信息 struct_root rbr(红黑树),还有一个是就绪列表struct list_head rdlist,存放检测到数据发送改变的文件描述符信息(双向链表);
- 调用 epoll_ctrl() 可以向 epoll 对象中添加、删除、修改要监听的文件描述符及事件;
- 调用 epoll_wt() 可以让内核去检测就绪的事件,并将就绪的事件放到就绪列表中并返回,通过返回的事件数组做进一步的事件处理。
epoll两种模式:
- LT模式(默认使用,支持非阻塞IO):LT是缺省的工作方式,并且同时支持block和non-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪,然后你可以对就绪的文件描述符进行IO操作。如果不作任何操作,内核还是会继续通知你
- ET模式:ET是高速工作方式,只会支持non-block socket。在这种模式下,当描述符就绪之后,内核通过epoll告诉应用程序。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态,但是如果一直不对fd做IO操作,内核不会再发送更多的通知(only once)。
epoll原理如下:
客户端 向 redis 的 server socket 请求建立连接,IO多路复用程序检测到后,将请求压入队列,文件事件分派器将其交给连接应答处理器,如果是一个set请求进来,那就交给命令请求处理器,完成后命令回复处理器将处理结果回复给客户端。
redis6.0之前使用单线程,因为:
- 单线程编程容易并且更容易维护;
- Redis 的性能瓶颈不在 CPU ,主要在内存和网络;
- 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。
但是redis瓶颈在内存和网络,因此6.0之后引入多线程,解决网络问题
AOF日志
与数据库的WAL(Write Ahead Log)相反,Redis先写数据,再记录日志。
为了避免额外的检查开销,Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。并且通过这种方式,不会阻塞当前的写操作。
缺点:1 写后如果发生宕机,会造成数据丢失。2 日志写回磁盘可能阻塞主线程。
对于这两个缺点,其实都是跟持久化的时机有关,见持久化中AOF的三个追加方式。
AOF重写
AOF中存的是命令,重写其实是将多个命令,合成为最新的一版。并且重写过程是由后台子进程来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。
在重写的过程中,会有一次拷贝,主进程会fork一个重写的子进程并将主线程的内存也拷贝过去,子进程再执行重写操作。fork过程是会产生阻塞的,内核需要创建用于管理子进程的相关数据结构,这些数据结构在操作系统中通常叫作进程控制块(Process Control Block,简称为 PCB)。内核要把主线程的 PCB 内容拷贝给子进程。这个创建和拷贝过程由内核执行,是会阻塞主线程的。并且还需要拷贝页表。
与此同时,有原日志和重写日志,在重写过程中,新数据会写入原日志和重写日志,保证如果重写失败,原日志的数据完整,也能保证重写日志中的内容是最新。
持久化
Redis 的一种持久化方式叫快照(Redis DataBase,RDB),另一种方式是只追加文件(append-only file, AOF)。
快照:Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。快照RDB是一种二进制文件,适合网络传输,在主从库复制也会使用。
有两种方式:
- save:在主线程中执行,会导致阻塞;
- bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。
如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据就会被复制一份,生成该数据的副本(键值对 C’)。然后,主线程在这个数据副本上进行修改。同时,bgsave 子进程可以继续把原来的数据(键值对 C)写入 RDB 文件,这个模式叫做写时复制模式(Copy On Write,拷贝推迟到写操作真正发生),保证数据统一。
只追加文件:开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到内存缓存 server.aof_buf
中,然后再根据 appendfsync
配置来决定何时将其同步到硬盘中的 AOF 文件。
appendfsync
有三个可选的值:
Redis 是不支持 roll back 的,因而不满足原子性的(而且不满足持久性)。
在Redis4.0 中提出了一个混合使用 AOF 日志和内存快照的方法,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。如果第二次做全量快照时,就可以清空 AOF 日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。
过期和删除
Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。
删除分为两种,定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除
- 惰性删除 :只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
- 定期删除 : 每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。
但是还是会存在内存溢出的情况,因此还有淘汰机制,有最近最久未使用(LRU)、将要过期的淘汰、随机淘汰、禁止淘汰
LRU实现原理
将全局排序转化成了局部性的,从随机的几个元素当中挑一个最久未使用的抛弃,RedisObject里边存了这个关键词,叫idle time,然后使用了待淘汰的pooling缓冲池,把比较之后的一些元素放进缓冲池,避免重复比较。
LFU
主从库复制
主从库的读写是分离的:
- 读操作:主库、从库都可以接收;
- 写操作:首先到主库执行,然后,主库将写操作同步给从库。
如果不采用这种方式,要维护数据一致性会有巨额的开销。
同步建立
- 从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync 命令包含了主库的
runID
(Redis 实例启动时都会自动生成的一个随机 ID,第一次从库不知道主库id,用?表示)和复制进度offset
(第一次建立,进度为-1)两个参数。 - 主库收到 psync 命令后,会用
FULLRESYNC
(全量复制)响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。 - 主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。这个过程中主库不会阻塞,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作,保证数据一致性。
- 主库会把第二阶段执行过程中新收到的写命令,再发送给从库。具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。
Redis还拥有级联特性,让从库给从库同步记录,通过这种方式可以减轻主库全盘快照和传输的压力。在部署主从集群的时候,可以手动选择一个从库(比如选择内存资源配置较高的从库),用于级联其他的从库。一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。
主从库连接断开的处理
从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步。主库会把收到的写操作命令,写入 replication buffer
,同时也会把这些操作命令也写入 repl_backlog_buffer
这个缓冲区。
repl_backlog_buffer
是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。
主库有一个偏移量master_repl_offset
记录在repl_backlog_buffer
中的位置,从库已复制的位置也会用偏移量 slave_repl_offset
,正常情况下二者几乎相等
在网络断连阶段,主库可能会收到新的写操作命令,所以,一般来说,master_repl_offset
会大于 slave_repl_offset
。此时,主库只用把 master_repl_offset
和 slave_repl_offset
之间的命令操作同步给从库就行。
但是repl_backlog_buffer
是一个环形缓冲区,在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。所以要设置repl_backlog_size的大小
哨兵机制
哨兵其实就是一个运行在特殊模式下的 Redis 进程,主从库实例运行的同时,它也在运行。哨兵主要负责的就是三个任务:监控、选主(选择主库)和通知。这个机制可以实现Redis的高可用。
监控:哨兵进程在运行时,周期性地给所有的主从库发送 PING 命令,检测它们是否仍然在线运行。如果从库没有在规定时间内响应哨兵的 PING 命令,哨兵就会把它标记为“下线状态”;同样,如果主库也没有在规定时间内响应哨兵的 PING 命令,哨兵就会判定主库下线,然后开始自动切换主库的流程。
选择主库:主库挂了以后,哨兵就需要从很多个从库里,按照一定的规则选择一个从库实例,把它作为新的主库。
通知:在执行通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执行 replicaof 命令,和新主库建立连接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。
为了避免误判产生的开销,会有哨兵集群,当有N个哨兵时,有N/2 + 1个哨兵认定下线才能客观认定主库下线。
新库选举
有三个维度:
- 从库是否在线
- 网络连接状态:在设定的最大连接超时时间外就会产生断连,断连超过10次就会认定网络状态不好
- 从库优先级高(数字低,通过
slave-priority
)、从库复制进度(偏移量)以及从库 ID 号小。前一轮分平才会进入下一轮。
哨兵集群
Redis 提供的 pub/sub
(发布-订阅)机制,在和主库建立连接后就可以发布自己或者订阅到其它哨兵的连接信息。在主库中,会有一个有一个名为“__sentinel__:hello
”的频道,不同哨兵就是通过它来相互发现,实现互相通信的。并且在连接上主库后,可以通过INFO
命令获取从库列表。并且基于发布订阅机制,哨兵还可以监听主库切换、主库下线、从库重新配置等事件。
哨兵Leader选举
哨兵Leader会执行主从切换的过程,需要拿到半数以上的票且值大于设定的quorum
(该值过大会造成服务不可用时间长,过小会导致出现误判,主从频繁切换)。这个过程通过投票选举,想要成为leader,就在开始时给自己投一张票,每个哨兵只能投一张赞成票,可以投0到多张拒绝票。
哨兵的定时器一般为100ms一次,但是会加上一定的偏移量,避免同时选取leader。哨兵如果没有投自己,那会默认给第一个发送请求的哨兵投票。如果出现都投自己,就会停留一段时间再进行下一次投票。
集群和切片
在数据量极大的情况下,RDB需要复制的内容极多,fork子线程会造成较长时间的卡顿。所以可以通过集群(cluster)的形式分散数据。
原理:在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,先通过key按照CRC16算法,计算16bit的值,再用这个 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。映射到对应的slot(哈希槽),然后在对应的实例中通过全局哈希表查找key对应的value。
如果集群中有 N 个实例,那么,每个实例上的槽个数为 16384/N 个。如果服务器配置不一致,可以手动的分配哈希槽。手动分配的话,需要分配完16384个槽才能正常工作。
数据定位
Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。
但是如果出现负载均衡或者实例增删的情况,会重新分配哈希槽,这时会使用重定向机制。
重定向机制:当客户端把一个键值对的操作请求发给一个实例时,如果这个实例上并没有这个键值对映射的哈希槽,那么,这个实例就会给客户端返回 MOVED 命令响应结果,这个结果中就包含了新实例的访问地址,客户端会记住新的地址。
还有迁移时,未迁移完全导致两个实例都有同一个哈希槽,会临时的响应ASK
指令,告诉客户端要访问另一个实例,但是这个操作是临时的,客户端缓存的哈希槽分配信息不会更新。
缓存击穿
当一份访问量非常大的热点数据缓存失效的瞬间,大量的请求直达存储层,导致服务崩溃。
解决方案:
- 热点数据不设置过期时间
- 加互斥锁:缓存失效时先有一个线程抢锁并且重新设置缓存,避免了多个线程请求落到数据库。
缓存穿透
大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。比如要看热点文章,可能会在数据库堆积大量的请求。
解决方案:
- 缓存无效的key:只针对于key相同且穿透的情况
- 布隆过滤器:原理是使用一个bitmap数组,计算放置的内容的hash值并且把数组对应位置置为1。但是可能存在误判,因为存在hash碰撞的情况。可能会把不存在的判定成存在,但是不会把存在的判定为不存在。
缓存雪崩
缓存在同一时间大面积的失效,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求。有两种情况,一种是由于redis宕机,请求超时;另一种是缓存失效。
针对redis服务
- 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。
- 限流,避免同时处理大量的请求。
针对缓存失效
- 设置不同的失效时间比如随机设置缓存的失效时间。
- 缓存永不失效。
解决缓存同步
- 同步直写策略:写缓存时,也同步写数据库,缓存和数据库中的数据一致
- 异步写回策略:写缓存时不同步写数据库,等到数据从缓存中淘汰时,再写回数据库。使用这种策略时,如果数据还没有写回数据库,缓存就发生了故障。那么,此时,数据库就没有最新数据了
数据结构
逻辑结构:分线性结构和非线性结构(例如图和链表)
物理结构:主要分为顺序结构和链式结构(数组和链表)
各个算法优劣分析
插入排序(Insertion Sort):左侧有序,将每一个元素抽出来,插入到左边
1 | for (int i = 0; i < a.length; i++) { |
希尔排序(Shell Sort):直接插入的改良版,分组,并且多个元素同时插入
快速排序(Quick Sort):如果用传统的写法,选取最左边的元素作为锚点,递归次数与初始数据的排列次序有关,越有序,递归调用次数越多。
递归次数与每次划分后得到的分区处理顺序无关,使用随机锚点也是这个原因。
1 | static class Quicksort { |
冒泡排序(Bubble Sort):两两比较,大的数移动到右边,都操作一遍就得到完整的排序了
1 | for (int i = 0; i < a.length-1; i++) { |
选择排序(Selection Sort):选最小值,跟左侧有序的后一个交换
1 | for (int i = 0; i < a.length-1; i++) { |
堆排序(Heap Sort):建立大顶堆或者小顶堆,跟二叉树有关系。建立和插入的时候会产生比较大的开销,但是在算前n大的数时比较适用。
归并排序(Merge Sort):生成子序列,利用递归的形式排序。最后就是类似于合并两个有序数组。
1 | class SortArray { |
基数排序(Radix Sort):利用各个位数和10个队列,一位一位的按顺序放进队列
树
二叉树的度
度是指出度,也就是是否有子节点,度为0表示没有
度的和 = 总结点数 - 1;
度的和 = 度为1的节点数 + 2倍的度为2的节点数;
度为0的节点数 = 度为2的节点数 + 1
平衡二叉树(AVL)
平衡二叉树由**二叉查找树(二叉排序树、二叉搜索树,BST)**优化而来,极端情况,二叉查找树会退化为单链表
平衡二叉树是平衡因子绝对值不超过1的二叉树
平衡因子:它的左子树与右子树的深度之差
插入规则:优先给左节点、插入要以最深层次的作为根节点、左孩子变父节点的右孩子、RL旋转是下层R型旋转,上层L型旋转
在哪里开始处理取决于离根节点最远且具有最小的平衡因子的节点,例如下图中5的平衡因子是-2,8的平衡因子是1,所以RL型是从5开始得到的
特殊情况:8的负载因子是(2-1)为1,所以要追溯到5,它的负载因子是(1-3),开始失衡,所以从5下去判断失衡类型。
完全二叉树
完全二叉树是由满二叉树而引出来的,若设二叉树的深度为h
,除第 h 层外
,其它各层 (1~h-1) 的结点数都达到最大个数(即1~h-1层为一个满二叉树)
,第 h 层所有的结点都连续集中在最左边,这就是完全二叉树。
堆
堆是一个完全二叉树
- 如果每个节点的值都大于等于左右孩子节点的值,这样的堆叫 大顶堆;
- 如果每个节点的值都小于等于左右孩子节点的值,这样的堆叫 小顶堆。
哈夫曼树(最优、最佳二叉树)
最佳情况下,哈夫曼树是平衡二叉树,但是平衡二叉树不一定是哈夫曼树
当用 n 个结点(都做叶子结点且都有各自的权值)试图构建一棵树时,如果构建的这棵树的带权路径长度最小,称这棵树为“最优二叉树”,有时也叫“赫夫曼树”或者“哈夫曼树”。
哈夫曼树并不唯一,但带权路径长度一定是相同的
思路是选树或者森林中最小的两个(权值较小)
如果一个数字是二叉树中的根节点,就直接成为该根节点的兄弟
两个数字都不是已经构造好的二叉树里面的结点,就要另外开一棵二叉树
若一个哈夫曼树有N个叶子节点(权值),则其节点总数为2N-1
空指针数为2N
红黑树
1.左子树上所有结点的值均小于或等于它的根结点的值。
2.右子树上所有结点的值均大于或等于它的根结点的值。
基于二分查找的思路,最大的查找次数等于树的深度
红黑树则是自平衡的二叉查找树
1.节点是红色或黑色。
2.根节点是黑色。
3.每个叶子节点都是黑色的空节点(NIL节点)。
4 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
5.从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
加入节点让规则被打破,那就需要通过变色、自旋来重新构建平衡
TreeMap、TreeSet、HashMap底层就是红黑树。
HashMap是在jdk1.8中,由数组+链表的形式,变成了当链表长度大于8且数组长度大于64,就会由链表变成红黑树
红黑树和AVL(二叉查找树)的区别
AVL是严格的平衡二叉树,对插入和删除操作,都要满足子树高度差绝对值不超过1,否则需要旋转,旋转过程耗时,因此,AVL适合插入和删除次数少,而查询次数多的情况。
红黑树确保没有一条路径会比其它路径长出两倍**,因此,红黑树是一种弱平衡二叉树**(由于是弱平衡,可以看到,在相同的节点情况下,AVL树的高度低于红黑树),相对于要求严格的AVL树来说,它的旋转次数少,所以对于搜索,插入,删除操作较多的情况下,我们就用红黑树。
DFS和BFS
deep,深搜,前、中、后序遍历
breadth,广搜,层序遍历
Trie树
树形结构,是一种哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。
图
无向图
无向图邻接矩阵是对称的,因为没有方向性,所以A指向B,一定有B指向A
图的表示形式
邻接表
针对每个顶点设置一个邻居表
有向图中有n个顶点,则该有向图对应的邻接表中有n个表头节点
有向图的邻接表中有m个表结点,则该图中有m条有向边
邻接矩阵
是一个二维的矩阵,特点如下:
1.方阵的维度就是图中顶点的数量,其该矩阵对称。
2.对角线表示的是顶点与顶点自身的关系,对角线元素全为0
3.可以用来表示带权值的图
强连通图
必须从任何一点出发都可以回到原处。如果是有向图,则m个顶点需要m条边,如果是无向图,m个顶点只需要m-1条。
拓扑排序
AOV:把顶点表示活动、边表示活动间先后关系的有向图称做顶点活动网
AOV网构造拓扑序列的过程就是拓扑排序
- 在有向图中选一个没有前驱的顶点并且输出
- 从图中删除该顶点和所有以它为尾的弧(白话就是:删除所有和它有关的边)
- 重复上述两步,直至所有顶点输出,或者当前图中不存在无前驱的顶点为止,后者代表我们的有向图是有环的,因此,也可以通过拓扑排序来判断一个图是否有环。
Prim算法
从1个起点0条边出发,不断扩充顶点,直到包含所有顶点,适用于求边稠密的最小生成树。
Kruskal算法
n个顶点n条边出发,不断扩充边,直到包括n-1条边为止,适用于求边稀疏的最小生成树。
进制转换
稀疏图和稠密图
稀疏图可以用邻接表来表示
稠密图用邻接矩阵来表示
Spring
Spring 是一款开源的轻量级 Java 开发框架,旨在提高开发人员的开发效率以及系统的可维护性。我们一般说 Spring 框架指的都是 Spring Framework,它是很多模块的集合,使用这些模块可以很方便地协助我们进行开发。
模块
Spring Core:核心模块, Spring 其他所有的功能基本都需要依赖于该模块,主要提供 IoC 依赖注入功能的支持。
Spring Aspects:该模块为AspectJ(AOP 框架) 的集成提供支持。
Spring AOP:提供了面向切面的编程实现。
**Spring Data Access/Integration :**有JDBC、ORM(对象关系映射,比如Hibernate,还有半orm的mybatis)、jms(消息服务)
Spring Web:包含spring-web(提供基础web支持)、WebSocket 、webflux(代替portlet)异步响应式框架
Spring Test:有了控制反转 (IoC)的帮助,单元测试和集成测试变得更简单。对JUnit等测试框架支持好
Spring,Spring MVC,Spring Boot的关系
Spring包含了很多基础模块,其中最重要的是 Spring-Core(主要提供 IoC 依赖注入功能的支持) 模块,Spring 中的其他模块(比如 Spring MVC)的功能实现基本都需要依赖于该模块。
Spring MVC 是 基于Spring的MVC框架,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。
Spring Boot 只是简化了配置,如果需要构建 MVC 架构的 Web 程序,你还是需要使用 Spring MVC 作为 MVC 框架,只是说 Spring Boot 帮你简化了 Spring MVC 的很多配置,真正做到开箱即用。
IOC
IoC(Inverse of Control:控制反转) 是一种设计思想,而不是一个具体的技术实现。IoC 的思想就是将原本在程序中手动创建对象的控制权,交由 Spring 框架来管理。可以通过配置或者xml文件的方式将bean所依赖的对象通过name(名字)或者type(类别)注入进这个beanFactory中。它可以帮我们维护对象与对象之间的依赖关系,并且降低对象之间的耦合度。
- 控制 :指的是对象创建(实例化、管理)的权力
- 反转 :控制权交给外部环境(Spring 框架、IoC 容器)
IOC的具体实现方式就是DI,依赖注入就是将实例变量传入到一个对象中去
在 Spring 中, IoC 容器是 Spring 用来实现 IoC 的载体, IoC 容器实际上就是个 Map(key,value),Map 中存放的是各种对象。
**BeanFactory **:定义了 IoC 容器的基本功能。它是一个接口,具体的实现交给了子类。主要是获取bean、判断IOC容器中是否存在该bean、判断bean是单例还是多例模式、bean的class类型匹配等。通常来说属于低级容器。如果没有特殊指定,默认采用延迟初始化策略。只有当客户端对象需要访问容器中的某个受管对象的时候,才对该受管对象进行初始化以及依赖注入操作。所以,相对来说,容器启动初期速度较快,所需要的资源有限。对于资源有限,并且功能要求不是很严格的场景,BeanFactory是比较合适的IoC容器选择。
**ApplicationContext **:在 BeanFactory 基础上通过继承其他接口来实现高级容器特征,还有事件发布、国际化信息支持等高级特性。ApplicationContext所管理的对象,在该类型容器启动之后,默认全部初始化并绑定完成。所以,相对于BeanFactory来说,ApplicationContext要求更多的系统资源,同时,因为在启动时就完成所有初始化,容器启动时间较之BeanFactory也会长一些。在那些系统资源充足,并且要求更多功能的场景中,ApplicationContext类型的容器是比较合适的选择。
注入方式有三种:
- 构造方法注入:就是被注入对象可以在它的构造方法中声明依赖对象的参数列表,让外部知道它需要哪些依赖对象。然后,IoC Service Provider会检查被注入的对象的构造方法,取得它所需要的依赖对象列表,进而为其注入相应的对象。构造方法注入方式比较直观,对象被构造完成后,即进入就绪状态,可以马上使用。
- setter方法注入:通过setter方法,可以更改相应的对象属性。所以,当前对象只要为其依赖对象所对应的属性添加setter方法,就可以通过setter方法将相应的依赖对象设置到被注入对象中。setter方法注入虽不像构造方法注入那样,让对象构造完成后即可使用,但相对来说更宽松一些,可以在对象构造完成后再注入。
- 接口注入:相对于前两种注入方式来说,接口注入没有那么简单明了。被注入对象如果想要IoC Service Provider为其注入依赖对象,就必须实现某个接口。这个接口提供一个方法,用来为其注入依赖对象。IoC Service Provider最终通过这些接口来了解应该为被注入对象注入什么依赖对象。相对于前两种依赖注入方式,接口注入比较死板和繁琐。
总体来说,构造方法注入和setter方法注入因为其侵入性较低,且易于理解和使用,所以是现在使用最多的注入方式。而接口注入因为侵入性较强,近年来已经不流行了。
IOC容器的创建过程:
AOP
AOP是一种编程思想,是通过预编译方式和运行期动态代理的方式实现不修改源代码的情况下给程序动态统一添加功能的技术。切面就是将影响多个类的公共行为封装到一个可重用的模块中。
AOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等jwt和shiro)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。AOP也是以IOC为基础,它利用JDK Proxy、cglib来代理。
- JDK动态代理:这是Java提供的动态代理技术,可以在运行时创建接口的代理实例。Spring AOP默认采用这种方式,在接口的代理实例中织入代码。
- CGLib动态代理:采用底层的字节码技术,在运行时创建子类代理的实例。当目标对象不存在接口时,Spring AOP就会采用这种方式,在子类实例中织入代码。
**Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。**Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。
AOP有很多实际场景:可以打印日志、权限处理
AOP不能增强的类
- Spring AOP只能对IoC容器中的Bean进行增强,对于不受容器管理的对象不能增强。
- 由于CGLib采用动态创建子类的方式生成代理对象,所以不能对final修饰的类进行代理。
用做事务
@Transactional
的工作机制是基于 AOP 实现的,AOP 又是使用动态代理实现的。如果目标对象实现了接口,默认情况下会采用 JDK 的动态代理,如果目标对象没有实现了接口,会使用 CGLIB 动态代理。如果一个类或者一个类中的 public 方法上被标注@Transactional
注解的话,Spring 容器就会在启动的时候为其创建一个代理类,在调用被@Transactional
注解的 public 方法的时候,实际调用的是,TransactionInterceptor
类中的 invoke()
方法。这个方法的作用就是在目标方法之前开启事务,方法执行过程中如果遇到异常的时候回滚事务,方法调用完成之后提交事务。
通知类别
切面当中的方法,声明通知方法在目标业务层的执行位置,通知类型如下:
前置通知:@Before
在目标业务方法执行之前执行
后置通知:@After
在目标业务方法执行之后执行
返回通知:@AfterReturning
在目标业务方法返回结果之后执行
异常通知:@AfterThrowing
在目标业务方法抛出异常之后
环绕通知:@Around
功能强大,可代替以上四种通知,还可以控制目标业务方法是否执行以及何时执行
AOP举例
自定义注解
1 | // 注解作用的范围,这里声明为函数 |
定义切面类
1 | // 该注解声明这个类为一个切面类 |
使用
1 | // 当执行到删除学生方法时,切面类就会起作用了,当学生正常删除后就会执行记录方法,我们就可以看到记录方法生成的数据 |
事务的传播
源码在org.springframework.transaction.annotation包下的Propagation(传播),一共有七个枚举类。
@Transactional注解就要使用Propagation枚举类来指定传播行为类型。
Spring中事务的默认实现使用的是AOP(代理模式),同一个Service类中的方法相互调用需要使用注入的对象来调用,不要直接使用this.方法名来调用,spring事务只对运行时异常奏效,编译时异常Exception是不会回滚的。
REQUIRED(required):如果当前没有事务,则自己新建一个事务,如果当前存在事务,则加入这个事务(默认是这个传播等级)
SUPPORTS(supports):当前存在事务,则加入当前事务,如果当前没有事务,就以非事务方法执行
MANDATORY(mandatory,强制的):当前存在事务,则加入当前事务,如果当前事务不存在,则抛出异常。
REQUIRES_NEW(requires_new):创建一个新事务,如果存在当前事务,则挂起该事务(不影响)。
*NOT_SUPPORTED(not_supported)**:*始终以非事务方式执行,如果当前存在事务,则挂起当前事务。
*NEVER(never)**:*不使用事务,如果当前事务存在,则抛出异常
*NESTED(nested,嵌套)**:*如果当前事务存在,则在嵌套事务中执行(相当于子事务,父事务影响子事务,子事务不影响父事务),否则REQUIRED的操作一样(开启一个事务)
将类声明为Bean的注解
Bean就是交给IOC容器管理的对象
@Component
:通用的注解,可标注任意类为Spring
组件。如果一个 Bean 不知道属于哪个层,可以使用@Component
注解标注。@Repository
: 对应持久层即 Dao 层,主要用于数据库相关操作。@Service
: 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。@Controller
: 对应 Spring MVC 控制层,主要用户接受用户请求并调用 Service 层返回数据给前端页面。
@Component 和 @Bean 的区别是什么?
@Component
注解作用于类,而@Bean
注解作用于方法。@Component
通常是通过自动扫描和装配到 Spring 容器中,@Bean
通过标有该注解的方法中定义产生这个 bean。@Bean
注解比@Component
注解的自定义性更强,而且很多地方我们只能通过@Bean
注解来注册 bean。比如当我们引用第三方库中的类需要装配到Spring
容器时,则只能通过@Bean
来实现。
注入bean的注解
Annotaion | Package | Source |
---|---|---|
@Autowired |
org.springframework.bean.factory |
Spring 2.5+ |
@Resource |
javax.annotation |
Java JSR-250 |
@Inject |
javax.inject |
Java JSR-330 |
@Autowired
注解是Spring提供的,而@Resource
注解是JDK本身提供的@Autowird
注解默认通过byType方式注入,而@Resource
注解默认通过byName方式注入@Autowired
注解注入的对象需要在IOC容器中存在,否则需要加上属性required=false,表示忽略当前要注入的bean,如果有直接注入,没有跳过,不会报错- 当一个接口存在多个实现类的情况下,
@Autowired
和@Resource
都需要通过名称才能正确匹配到对应的 Bean。Autowired
可以通过@Qualifier
注解来显示指定名称,@Resource
可以通过name
属性来显示指定名称。
Bean的作用域
- singleton : 唯一 bean 实例,Spring 中的 bean 默认都是单例的,对单例设计模式的应用。
- prototype : 每次请求都会创建一个新的 bean 实例。
- request : 每一次 HTTP 请求都会产生一个新的 bean,该 bean 仅在当前 HTTP request 内有效。
- session : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean,该 bean 仅在当前 HTTP session 内有效。
- global-session : 全局 session 作用域,仅仅在基于 portlet 的 web 应用中才有意义,Spring5 已经没有了。Portlet 是能够生成语义代码(例如:HTML)片段的小型 Java Web 插件。它们基于 portlet 容器,可以像 servlet 一样处理 HTTP 请求。但是,与 servlet 不同,每个 portlet 都有不同的会话。
session和global-session可以成员变量共享
Bean的生命周期
简单来说分为4步:实例化,属性注入,初始化,销毁
- Bean 容器找到配置文件中 Spring Bean 的定义。
- Bean 容器利用 Java Reflection API 创建一个 Bean 的实例。
- 如果涉及到一些属性值 利用
set()
方法设置一些属性值。 - 如果 Bean 实现了
BeanNameAware
接口,调用setBeanName()
方法,传入 Bean 的名字。 - 如果 Bean 实现了
BeanClassLoaderAware
接口,调用setBeanClassLoader()
方法,传入ClassLoader
对象的实例。 - 如果 Bean 实现了
BeanFactoryAware
接口,调用setBeanFactory()
方法,传入BeanFactory
对象的实例。 - 与上面的类似,如果实现了其他
*.Aware
接口,就调用相应的方法。 - 如果有和加载这个 Bean 的 Spring 容器相关的
BeanPostProcessor
对象,执行postProcessBeforeInitialization()
方法 - 如果 Bean 实现了
InitializingBean
接口,执行afterPropertiesSet()
方法。 - 如果 Bean 在配置文件中的定义包含 init-method 属性,执行指定的方法。
- 如果有和加载这个 Bean 的 Spring 容器相关的
BeanPostProcessor
对象,执行postProcessAfterInitialization()
方法 - 当要销毁 Bean 的时候,如果 Bean 实现了
DisposableBean
接口,执行destroy()
方法。 - 当要销毁 Bean 的时候,如果 Bean 在配置文件中的定义包含 destroy-method 属性,执行指定的方法。
SpringMVC工作原理
- 客户端(浏览器)发送请求,直接请求到
DispatcherServlet
。 DispatcherServlet
根据请求信息调用HandlerMapping
,解析请求对应的Handler
。- 解析到对应的
Handler
(也就是我们平常说的Controller
控制器)后,开始由HandlerAdapter
适配器处理。 HandlerAdapter
会根据Handler
来调用真正的处理器开处理请求,并处理相应的业务逻辑。- 处理器处理完业务后,会返回一个
ModelAndView
对象,Model
是返回的数据对象,View
是个逻辑上的View
。 ViewResolver
会根据逻辑View
查找实际的View
。DispaterServlet
把返回的Model
传给View
(视图渲染)。- 把
View
返回给请求者(浏览器)
handleMapping返回到dispatcherservlet的时候进行拦截
Spring框架中的设计模式
工厂设计模式
工厂模式是不暴露创建对象的具体逻辑,而是将逻辑封装在一个函数中,提高灵活性,这个函数被视为一个工厂。 具体有简单工厂、工厂方法、抽象工厂。
简单工厂:根据传入的参数不同返回不同的实例,被创建的实例具有共同的父类或接口。简单工厂适用于需要创建的对象较少或客户端不关心对象的创建过程的情况。
比如有女娲接口,有男人和女人两个类,在工厂类中通过判断传入的是男人还是女人来生成对应的产品。
这种形式,如果要新增人妖类,产生人妖产品,那就需要修改工厂类,违反了开闭原则。
工厂方法:是定义一个工厂接口,但创建过程让子类去实现,去决定哪一个产品被实例化,适用于创建对象少。
例子同上,但是工厂类也需要抽象,抽象出一个造人工厂,男人工厂和女人工厂继承自造人工厂。需要人妖就创建一个人妖工厂。这样就实现了面向修改关闭,面向拓展打开。
抽象工厂:在工厂类中可以创建一组对象。实现方式是提供一个创建一系列相关或相互依赖对象的接口而无需指定具体的类。
例子同上,但抽象工厂中不仅造人还造车,男人工厂不仅造男人还造火车,女人工厂不仅造女人还造汽车。
工厂方法和抽象工厂的区别是,工厂方法有一个抽象产品类,抽象工厂有多个抽象产品类
Spring 使用工厂模式通过 BeanFactory
、ApplicationContext
创建 bean 对象。
1 | public class BeanFactory { |
简单工厂会违反开闭原则,工厂方法修复了这个问题,抽象工厂则是面向一系列的产品。
代理设计模式
Spring AOP 功能的实现。
1、拿到被代理对象的引用,并且获取到它的所有的接口,反射获取
2、JDK Proxy类重新生成一个新的类、同时新的类要实现被代理类所有实现的所有的接口
3、动态生成Java代码,把新加的业务逻辑方法由一定的逻辑代码去调用(在代码中体现)
4、编译新生成的Java代码.class
5、再重新加载到JVM中运行
单例设计模式
Spring 中的 Bean 默认都是单例的。
模板方法模式
定义一个操作中的骨架,而将操作的一些步骤延迟到子类中,使得子类可以不改变该结构的情况下重定义该算法的某些特定步骤。
比如打游戏的启动、加载、显示界面步骤不变,但是可以修改游戏风格。
Spring 中 jdbcTemplate
、hibernateTemplate
、编程式事务、redisTemplate
等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。
包装器(装饰器)设计模式
包装器包装某个构件,除了提供构件的接口(可能经过改造),还会附加一些其他接口。
我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。Spring中用到的包装器模式在类名上有两种表现:一种是类名中含有Wrapper
(MyBatis-Plus中Wrapper用的多一些),另一种是类名中含有Decorator。
观察者模式
当一个对象被修改时,则会自动通知依赖它的对象。
ApplicationContext
是spring中的全局容器,翻译过来是”应用上下文”。实现了ApplicationEventPublisher
接口。
适配器模式
Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller
。
SpringBoot自动装配
Spring Boot通过@EnableAutoConfiguration
注解开启自动配置,加载spring.factories中注册的各种AutoConfiguration类,当某个AutoConfiguration类满足其注解@Conditional
指定的生效条件(Starters提供的依赖、配置或Spring容器中是否存在某个Bean等)时,实例化该AutoConfiguration类中定义的Bean(组件等),并注入Spring容器,就可以完成依赖框架的自动配置。
注解总结
SpringBoot启动相关
@EnableAutoConfiguration
:启用 SpringBoot 的自动配置机制,它会尝试猜测并配置项目可能需要的Bean。自动配置通常是基于项目classpath中引入的类和已定义的Bean来实现的。@Import
注解:@EnableAutoConfiguration
的关键功能是通过@Import
注解导入的ImportSelector来完成的。@ComponentScan
: 扫描被@Component
(@Service
,@Controller
)注解的 bean,注解默认会扫描该类所在的包下所有的类。@Configuration
:允许在 Spring 上下文中注册额外的 bean 或导入其他配置类@SpringBootApplication
:把@SpringBootApplication
看作是@Configuration
、@EnableAutoConfiguration
、@ComponentScan
注解的集合。@Conditional
注解:@Conditional
注解是由Spring 4.0版本引入的新特性,可根据是否满足指定的条件来决定是否进行Bean的实例化及装配。比如,设定当类路径下包含某个jar包的时候才会对注解的类进行实例化操作。总之,就是根据一些特定条件来控制Bean实例化的行为。
自动装配的过程:Spring Boot通过@EnableAutoConfiguration
注解开启自动配置,加载spring.factories中注册的各种AutoConfiguration类,当某个AutoConfiguration类满足其注解@Conditional
指定的生效条件(Starters提供的依赖、配置或Spring容器中是否存在某个Bean等)时,实例化该AutoConfiguration类中定义的Bean(组件等),并注入Spring容器,就可以完成依赖框架的自动配置。
Spring Bean相关
@Autowired
:自动导入对象到类中,被注入进的类同样要被 Spring 容器管理@Component
:通用的注解,可标注任意类为Spring
组件。如果一个 Bean 不知道属于哪个层,可以使用@Component
注解标注。@Repository
: 对应持久层即 Dao 层,主要用于数据库相关操作。@Service
: 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。@Controller
: 对应 Spring MVC 控制层,主要用于接受用户请求并调用 Service 层返回数据给前端页面。@RestController
:单独使用@Controller
不加@ResponseBody
的话一般是用在要返回一个视图的情况,这种情况属于比较传统的 Spring MVC 的应用,对应于前后端不分离的情况。@Controller
+@ResponseBody
返回 JSON 或 XML 形式数据@Scope
:声明bean的作用域@Configuration
:一般用来声明配置类,可以使用@Component
注解替代,不过使用@Configuration
注解声明配置类更加语义化。
传值相关
@PathVariable
:用于获取路径参数@RequestParam
:用于获取查询参数。
读取配置信息
@value("${property}")
:读取yml里的数据,并且赋值给变量@ConfigurationProperties
:读取配置信息并与 bean 绑定
事务
1 |
- 作用于类:当把
@Transactional
注解放在类上时,表示所有该类的 public 方法都配置相同的事务属性信息。 - 作用于方法:当类配置了
@Transactional
,方法也配置了@Transactional
,方法的事务会覆盖类的事务配置信息。
JPA(Java数据库连接)
@Entity
声明一个类对应一个数据库实体。@Table
设置表名@Id
:声明一个字段为主键。@Column
声明字段。@Transient
:声明不需要与数据库映射的字段,在保存的时候不需要保存进数据库 。
一个请求过来在 Spring 中发生了哪些事情
Spring的Web框架——Spring MVC就是基于Servlet规范实现的,而SpringBoot内置的容器就是Tomcat,是基于Servlet规范开发的。
按照 Servlet 规范,所有请求都会被tomcat容器交到 dispatchServlet 的 doService 方法中去处理。通过 HandlerMapping 接口对象的集合对象来操作Handler映射,它底层注册了一个 url -> handler方法的 map,每当请求过来,就会根据请求的url 去 map 中匹配,匹配到对应的handler 方法并且找到合适的HandlerAdapter
。调用前面选择的HandlerAdapter
的applyPreHandle
方法,取到所有拦截器后,for循环调用每个拦截器HandlerInterceptor
的preHandle()
方法。然后调用控制器Controller,再调业务层Service,Mapper,查询并且装载到对象,返回查看ModelAndView
的视图是不是null。如果前后端分离,到这里直接返回JSON。如果JSP,或者用了Thymeleaf语法,会把数据装载进页面。
SpringBoot 启动过程
0.启动main方法开始
1.初始化配置:通过SpringFactoriesLoader
从META-INF/Spring.factories
中获取需要的对象并实例化;通知监听者应用程序启动开始,创建环境对象environment,用于读取环境配置 如 application.yml
2.创建应用程序上下文:创建ApplicationContext,创建 bean工厂对象
3.刷新上下文(启动核心)
3.1 配置工厂对象,包括上下文类加载器,对象发布处理器,beanFactoryPostProcessor
3.2 注册并实例化bean工厂发布处理器,并且调用这些处理器,对包扫描解析(主要是class文件)
3.3 注册并实例化bean发布处理器 beanPostProcessor
3.4 初始化一些与上下文有特别关系的bean对象(创建tomcat服务器)
3.5 实例化所有bean工厂缓存的bean对象(剩下的)
3.6 发布通知-通知上下文刷新完成(启动tomcat服务器)
4.通知监听者-启动程序完成
启动中,大部分对象都是BeanFactory对象通过反射创建
Spring事务管理
- 编程式事务 Spring提供了
TransactionTemplate
模板,利用该模板我们可以通过编程的方式实现事务管理,而无需关注资源获取、复用、释放、事务同步及异常处理等操作。相对于声明式事务来说,这种方式相对麻烦一些,但是好在更为灵活,我们可以将事务管理的范围控制的更为精确。 - 声明式事务 Spring事务管理的亮点在于声明式事务管理,它允许我们通过声明的方式,在IoC配置中指定事务的边界和事务属性,Spring会自动在指定的事务边界上应用事务属性。相对于编程式事务来说,这种方式十分的方便,只需要在需要做事务管理的方法上,增加
@Transactional
注解,以声明事务特征即可。可以使用isolation
属性声明事务的隔离级别,使用propagation
属性声明事务的传播机制。
SpringBoot的起步依赖
以 spring-boot-starter-web 为例,它能够为提供 Web 开发场景所需要的几乎所有依赖,因此在使用 Spring Boot 开发 Web 项目时,只需要引入该 Starter 即可,而不需要额外导入 Web 服务器和其他的 Web 依赖。
有时在引入starter时,我们并不需要指明版本(version),这是因为starter版本信息是由 spring-boot-starter-parent(版本仲裁中心) 统一控制的。
拦截器和过滤器比较
拦截器 :是在面向切面编程的就是在你的service或者一个方法,前调用一个方法,或者在方法后调用一个方法比如动态代理就是拦截器的简单实现,在你调用方法前打印出字符串(或者做其它业务逻辑的操作),也可以在你调用方法后打印出字符串,甚至在你抛出异常的时候做业务逻辑的操作。
过滤器:是在javaweb中,你传入的request、response提前过滤掉一些信息,或者提前设置一些参数,然后再传入servlet或者struts的action进行业务逻辑,比如过滤掉非法url(不是login.do的地址请求,如果用户没有登陆都过滤掉),或者在传入servlet或者 struts的action前统一设置字符集,或者去除掉一些非法字符。
比较
- 拦截器是基于Java的反射机制的,而过滤器是基于函数回调。
- 拦截器不依赖与servlet容器,依赖于web框架,在SpringMVC中就是依赖于SpringMVC框架。过滤器依赖与servlet容器。
- 拦截器只能对action(也就是controller)请求起作用,而过滤器则可以对几乎所有的请求起作用,并且可以对请求的资源进行起作用,但是缺点是一个过滤器实例只能在容器初始化时调用一次。
- 拦截器可以访问action上下文、值栈里的对象,而过滤器不能访问。
- 在action的生命周期中,拦截器可以多次被调用,而过滤器只能在容器初始化时被调用一次。
- 拦截器可以获取IOC容器中的各个bean,而过滤器就不行,这点很重要,在拦截器里注入一个service,可以调用业务逻辑
DAO和DTO
- PO 是 Persistant Object 的缩写,用于表示数据库中的一条记录映射成的java 对象。PO 仅仅用于表示数据,没有任何数据操作。通常遵守 Java Bean 的规范,拥有 getter/setter 方法。
- DAO 是 Data Access Object 的缩写,用于表示一个数据访问对象。使用 DAO 访问数据库,包括插入、更新、删除、查询等操作,与 PO 一起使用。DAO 一般在持久层,完全封装数据库操作,对外暴露的方法使得上层应用不需要关注数据库相关的任何信息。
- VO 是 Value Object 的缩写,用于表示一个与前端进行交互的 java 对象。有的朋友也许有疑问,这里可不可以使用 PO 传递数据?实际上,这里的 VO 只包含前端需要展示的数据即可,对于前端不需要的数据,比如数据创建和修改的时间等字段,出于减少传输数据量大小和保护数据库结构不外泄的目的,不应该在 VO 中体现出来。通常遵守 Java Bean 的规范,拥有 getter/setter 方法。
- DTO 是 Data Transfer Object 的缩写,用于表示一个数据传输对象。DTO 通常用于不同服务或服务不同分层之间的数据传输。DTO 与 VO 概念相似,并且通常情况下字段也基本一致。但 DTO 与 VO 又有一些不同,这个不同主要是设计理念上的,比如 API 服务需要使用的 DTO 就可能与 VO 存在差异。通常遵守 Java Bean 的规范,拥有 getter/setter 方法。
- BO 是 Business Object 的缩写,用于表示一个业务对象。BO 包括了业务逻辑,常常封装了对 DAO、RPC 等的调用,可以进行 PO 与 VO/DTO 之间的转换。BO 通常位于业务层,要区别于直接对外提供服务的服务层:BO 提供了基本业务单元的基本业务操作,在设计上属于被服务层业务流程调用的对象,一个业务流程可能需要调用多个 BO 来完成。
- POJO 是 Plain Ordinary Java Object 的缩写,表示一个简单 java 对象。上面说的 PO、VO、DTO 都是典型的 POJO。而 DAO、BO 一般都不是 POJO,只提供一些调用方法。
网络安全
CSRF
跨站请求伪造,攻击者盗用的身份,以受攻击者名义发送恶意请求
HTTP Referer是header的一部分,当浏览器向web服务器发送请求的时候,会带上Referer,通过验证Referer,可以判断请求的合法性,如果Referer是其他网站的话,就有可能是CSRF攻击,则拒绝该请求。
可以先渗透进有漏洞的网站,通过该网站向有CSRF的网站发送恶意请求。
注入攻击
在请求后携带SQL语句,达到获取或者破坏数据库的目的
解决方案是尽量不使用动态SQL或者以参数形式获取输入参数
跨站脚本攻击(XSS)
恶意攻击者往Web页面里插入恶意Script代码,当用户浏览该页面时,嵌入Web里面的Script代码会被执行,从而达到恶意攻击用户的目的。
解决方案是过滤单引号或尖括号或者cookie关键字段设置HttpOnly属性(这种方式避免了脚本访问cookie)
计算题
数组位置计算
二维数组有i行j列,即A[i] [j],按列存储,求第A[m] [n]的地址
(i * n + m )* 大小 + 起始位置
CRC校验
原理:发送帧规定一串二进制数(即多项式、CRC除数),并且先除了这串数字,得到的CRC校验码添加到尾部(去除余数处理),接收端收到后用异或(相同为0,不同为1)的方式除以CRC除数,发送正确的话就没有余数,如果存在余数就是发送错误。
为0可以直接移动一位或多位
分页计算
页表项大小为4字节,若使用一级页表的分页存储管理方式,逻辑地址结构为页号(20位),页内偏移量(12位)
偏移量表示页的大小:2^12=4k
页号20位,则说明最大的寻址空间为2^20,允许存在1M的页。又因为一个页表项4字节,所以页表最大占用为4 * 1M = 4M
出栈可能性
C(2n,n)/(n+1)
如果n为4,则有 ((8 * 7 * 6 * 5)/(4 * 3 * 2 * 1))/5 = 12
二叉排序树(BST)
小的放左边,大的放右边,跟平衡二叉树不一样的是,节点的插入从最上层往下比较,像多层漏斗。
海明码
校验位的个数:字节在2-4之间用3位校验,在5-11之间用4位校验
或者使用公式:2的k次方>= k+n+1(其中n位信息字节数目)
检验位放置的位置为,1,2,4,8,16…
奇校验:实际数据中“1”的个数为奇数时,这个检测码是“0”。
哈夫曼树
最小的两个构成子树,根节点为两数相加,并且把这个和加入进待构建的数组
算带权路径就是各个层深*数字
图
某无向图中有n个节点e条边,则建立该图邻接表的时间复杂度为O(n+e)
有向图的邻接表节点数 = 节点 + 边数
有向图的非零元素等于边数
二叉树
度是指出度,也就是是否有子节点,度为0表示没有
度的和 = 总结点数 - 1;
度的和 = 度为1的节点数 + 2倍的度为2的节点数;
度为0的节点数 = 度为2的节点数 + 1
深度为n的二叉树至多有(2^n)-1个结点,最少有n个
在二叉树第n层上至多有2^(n-1)个节点
子网掩码计算
主机IP为200.15.13.13/23,其子网掩码是:
/23的意思是前23位是1,即最后(4*8-23)=9位是0,所以最后16位是11111110 00000000,可知是255.255.254.0
与运算
-5 & 6,负数要以补码形式参加运算,也就是取反加一。最后结果是2
顺序表插入、删除平均移动次数
插入平均移动n/2次
删除平均移动(n-1)/2
Kafka
Kafka 是一个分布式流式处理平台。
流平台具有三个关键功能:
- 消息队列:发布和订阅消息流,这个功能类似于消息队列,这也是 Kafka 也被归类为消息队列的原因。
- 容错的持久方式存储记录消息流: Kafka 会把消息持久化到磁盘,有效避免了消息丢失的风险。
- 流式处理平台: 在消息发布的时候进行处理,Kafka 提供了一个完整的流式处理类库。
Kafka 主要有两大应用场景:
- 消息队列 :建立实时流数据管道,以可靠地在系统或应用程序之间获取数据。
- 数据处理: 构建实时的流数据处理程序来转换或处理数据流。
Kafka的好处:
- 极致的性能 :基于 Scala 和 Java 语言开发,设计中大量使用了批量处理和异步的思想,最高可以每秒处理千万级别的消息。
- 生态系统兼容性无可匹敌 :Kafka 与周边生态系统的兼容性是最好的没有之一,尤其在大数据和流计算领域。
常用场景
消息系统、网站活动跟踪、监控数据、日志搜集
发布-订阅模型
Kafka 采用的就是发布 - 订阅模型。发布订阅模型(Pub-Sub) 使用主题(Topic) 作为消息通信载体,类似于广播模式;发布者发布一条消息,该消息通过主题传递给所有的订阅者,在一条消息广播之后才订阅的用户则是收不到该条消息的。
在发布 - 订阅模型中,如果只有一个订阅者,那它和队列模型就基本是一样的了。所以说,发布 - 订阅模型在功能层面上是可以兼容队列模型的。
名词解释
-
Producer(生产者) : 产生消息的一方。
-
Consumer(消费者) : 消费消息的一方。
-
Broker(代理) : 可以看作是一个独立的 Kafka 实例。多个 Kafka Broker 组成一个 Kafka Cluster。
Broker中又包含了topic和partition
- Topic(主题) : Producer 将消息发送到特定的主题,Consumer 通过订阅特定的 Topic(主题) 来消费消息。
- Partition(分区) : Partition 属于 Topic 的一部分。一个 Topic 可以有多个 Partition ,并且同一 Topic 下的 Partition 可以分布在不同的 Broker 上,这也就表明一个 Topic 可以横跨多个 Broker 。这正如我上面所画的图一样。
partition和消费者的关系
消费者多于partition
1个partition只能接纳一个消费者
消费者小于等于partition
消息会被同组的消费者均分
如果有多个消费组,可能会被重复消费
Kafka 的多副本机制
Kafka 为分区(Partition)引入了多副本(Replica)机制。分区(Partition)中的多个副本之间会有一个叫做 leader ,其他副本称为 follower。我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。
生产者和消费者只与 leader 副本交互。其他副本只是 leader 副本的拷贝,它们的存在只是为了保证消息存储的安全性。当 leader 副本发生故障时会从 follower 中选举出一个 leader,但是 follower 中如果有和 leader 同步程度达不到要求的参加不了 leader 的竞选。
- Kafka 通过给特定 Topic 指定多个 Partition, 而各个 Partition 可以分布在不同的 Broker 上, 这样便能提供比较好的并发能力(负载均衡)。
- Partition 可以指定对应的 Replica 数, 这也极大地提高了消息存储的安全性, 提高了容灾能力,不过也相应的增加了所需要的存储空间。
Zookeeper
- Broker 注册 :在 Zookeeper 上会有一个专门用来进行 Broker 服务器列表记录的节点。每个 Broker 在启动时,都会到 Zookeeper 上进行注册,即到
/brokers/ids
下创建属于自己的节点。每个 Broker 就会将自己的 IP 地址和端口等信息记录到该节点中去 - Topic 注册 : 在 Kafka 中,同一个Topic 的消息会被分成多个分区并将其分布在多个 Broker 上,这些分区信息及与 Broker 的对应关系也都是由 Zookeeper 在维护。比如我创建了一个名字为 my-topic 的主题并且它有两个分区,对应到 zookeeper 中会创建这些文件夹:
/brokers/topics/my-topic/Partitions/0
、/brokers/topics/my-topic/Partitions/1
- 负载均衡 :上面也说过了 Kafka 通过给特定 Topic 指定多个 Partition, 而各个 Partition 可以分布在不同的 Broker 上, 这样便能提供比较好的并发能力。 对于同一个 Topic 的不同 Partition,Kafka 会尽力将这些 Partition 分布到不同的 Broker 服务器上。当生产者产生消息后也会尽量投递到不同 Broker 的 Partition 里面。当 Consumer 消费的时候,Zookeeper 可以根据当前的 Partition 数量以及 Consumer 数量来实现动态负载均衡。
如果某个分区所在的服务器除了问题,不可用,kafka会从该分区的其他的副本中选择一个作为新的Leader。之后所有的读写就会转移到这个新的Leader上。现在的问题是应当选择哪个作为新的Leader。显然,只有那些跟Leader保持同步的Follower才应该被选作新的Leader。
Kafka会在Zookeeper上针对每个Topic维护一个称为ISR(in-sync replica,已同步的副本)的集合,该集合中是一些分区的副本。只有当这些副本都跟Leader中的副本同步了之后,kafka才会认为消息已提交,并反馈给消息的生产者。如果这个集合有增减,kafka会更新zookeeper上的记录。
如果某个分区的Leader不可用,Kafka就会从ISR集合中选择一个副本作为新的Leader。
Zookeeper具体机制会单独开一章来讲。
如何保证消息的消费顺序
- 1 个 Topic 只对应一个 Partition,违背了Kafka 的设计初衷
- (推荐)发送消息的时候指定 key/Partition。Kafka 中发送 1 条消息的时候,可以指定 topic, partition, key,data(数据) 4 个参数。如果你发送消息的时候指定了 Partition 的话,所有消息都会被发送到指定的 Partition。并且,同一个 key 的消息可以保证只发送到同一个 partition,这个我们可以采用表/对象的 id 来作为 key 。
如何保证消息不丢失
生产者丢失
生产者(Producer) 调用send
方法发送消息之后,消息可能因为网络问题并没有发送过去。send
方法是异步的,可以通过 get()
方法获取调用结果,但是这样也让它变为了同步操作。
1 | SendResult<String, Object> sendResult = kafkaTemplate.send(topic, o).get(); |
更推荐的方式是使用回调:
1 | ListenableFuture<SendResult<String, Object>> future = kafkaTemplate.send(topic, o); |
推荐为 Producer 的retries
(重试次数)设置一个比较合理的值,一般是 3 ,但是为了保证消息不丢失的话一般会设置比较大一点。设置完成之后,当出现网络问题之后能够自动重试消息发送,避免消息丢失。另外,建议还要设置重试间隔,因为间隔太小的话重试的效果就不明显了,网络波动一次3次一下子就重试完了
消费者丢失
消息在被追加到 Partition(分区)的时候都会分配一个特定的偏移量(offset)。偏移量(offset)表示 Consumer 当前消费到的 Partition(分区)的所在的位置。Kafka 通过偏移量(offset)可以保证消息在分区内的顺序性。
当消费者拉取到了分区的某个消息之后,消费者会自动提交了 offset。自动提交的话会有一个问题。但是,当消费者刚拿到这个消息准备进行真正消费的时候,突然挂掉了,消息实际上并没有被消费,但是 offset 却被自动提交了。
解决办法是手动关闭自动提交 offset,每次在真正消费完消息之后再自己手动提交 offset 。 但是这样会带来消息被重新消费的问题。比如刚刚消费完消息之后,还没提交 offset,结果,那么这个消息理论上就会被消费两次。
Kafka丢失
假如 leader 副本所在的 broker 突然挂掉,那么就要从 follower 副本重新选出一个 leader ,但是 leader 的数据还有一些没有被 follower 副本的同步的话,就会造成消息丢失。
解决办法就是我们设置 acks = all。acks 是 Kafka 生产者(Producer) 很重要的一个参数。
acks 的默认值即为1,代表我们的消息被leader副本接收之后就算被成功发送。当我们配置 acks = all 代表则所有副本都要接收到该消息之后该消息才算真正成功被发送。
设置 replication.factor >= 3
为了保证 leader 副本能有 follower 副本能同步消息,我们一般会为 topic 设置 replication.factor >= 3。这样就可以保证每个 分区(partition) 至少有 3 个副本。虽然造成了数据冗余,但是带来了数据的安全性。
设置 min.insync.replicas > 1
一般情况下我们还需要设置 min.insync.replicas> 1 ,这样配置代表消息至少要被写入到 2 个副本才算是被成功发送。min.insync.replicas 的默认值为 1 ,在实际生产中应尽量避免默认值 1。
但是,为了保证整个 Kafka 服务的高可用性,你需要确保 replication.factor > min.insync.replicas 。为什么呢?设想一下假如两者相等的话,只要是有一个副本挂掉,整个分区就无法正常工作了。这明显违反高可用性!一般推荐设置成 replication.factor = min.insync.replicas + 1。
设置 unclean.leader.election.enable = false
当 leader 副本发生故障时就不会从 follower 副本中和 leader 同步程度达不到要求的副本中选择出 leader ,这样降低了消息丢失的可能性。
消费端分区分配策略
Range
按照消费者总数和分区总数进行整除运算来获得一个跨度,然后将分区按照跨度进行平均分配,以保证分区尽可能均匀地分配给所有的消费者。对于每一个主题,RangeAssignor 策略会将消费组内所有订阅这个主题的消费者按照名称的字典序排序,然后为每个消费者划分固定的分区范围,如果不够平均分配,那么字典序靠前的消费者会被多分配一个分区。
- 不存在轮询分区消费者消费到没有订阅 topic 的问题。
- 当消费者组中的消费者订阅了两个不同的topic时,由于按范围进行分区,可能会导消费者漏掉订阅topic中一个分区的消息,数据量大的时候还会数据倾斜。
RoundRobin
每个主题的分区底层都是一个topicPartition对象,然后获取每个对象的hashCode,按hash值对每个对象进行排序,最后以轮询的方式将数据发送给消费者
- 轮询分区的方式的好处:负载均衡,消费的最大差值为1
- 只能让一个消费者组的消费者必须订阅同一个topic。
sticky
黏性分配策略,目的是让分区的分配要尽可能均匀,分区的分配尽可能与上次分配的保持相同。当两者发生冲突时,第一个目标优先于第二个目标。
如何保证消息不重复消费
kafka出现消息重复消费的原因:
- 服务端侧已经消费的数据没有成功提交 offset(根本原因)。
- 触发了 rebalance
解决方案:
-
消费消息服务做幂等校验,比如 Redis 的set、MySQL 的主键等天然的幂等功能。这种方法最有效。
-
将
enable.auto.commit
参数设置为 false,关闭自动提交,开发者在代码中手动提交 offset。那么这里会有个问题:什么时候提交offset合适?
- 处理完消息再提交:依旧有消息重复消费的风险,和自动提交一样
- 拉取到消息即提交:会有消息丢失的风险。允许消息延时的场景,一般会采用这种方式。然后,通过定时任务在业务不繁忙(比如凌晨)的时候做数据兜底。
rebalance(重新分发机制)
1.消费组成员发生了变更。
2.消费者无法在规定时间发送心跳包,以为Consumer已经宕机。
3.消费组订阅的Topic发生了变化。
4.订阅的Topic的partition发生了变化
Coordinator发生Rebalance的时候,Coordinator并不会主动通知组内的所有Consumer重新加入组,而是当Consumer向Coordinator发送心跳的时候,Coordinator将Rebalance的状况通过心跳响应告知Consumer。Rebalance机制整体可以分为两个步骤,一个是Joining the Group,另外一个是分配Synchronizing Group State
Coordinator(调解员)
每个消费组都会有一个coordinator,Coordinator负责处理管理组内的消费者和位移管理,Coordinator并不负责消费组内的partition分配。消费者通过心跳的方式告知Coordinator自己仍然处于存活状态,Coordinator以session. timeout. ms参数的频率检测消费组group内消费者存活情况,该参数的默认值是10s,如果该值太大,那么coordinator需要非常长时间才能检测到消费者宕机
多处选举机制
Broker(控制器)选举
在一个kafka集群中,有多个broker节点,但是它们之间需要选举出一个leader,其他的broker充当follower角色。集群中第一个启动的broker会通过在zookeeper中创建临时节点/controller来让自己成为控制器,其他broker启动时也会在zookeeper中创建临时节点,但是发现节点已经存在,所以它们会收到一个异常,意识到控制器已经存在,那么就会在zookeeper中创建watch对象,便于它们收到控制器变更的通知。
如果集群中有一个broker发生异常退出了,那么控制器就会检查这个broker是否有分区的副本leader,如果有那么这个分区就需要一个新的leader,此时控制器就会去遍历其他副本,决定哪一个成为新的leader,同时更新分区的ISR集合。
如果有一个broker加入集群中,那么控制器就会通过Broker ID去判断新加入的broker中是否含有现有分区的副本,如果有,就会从分区副本中去同步数据。
防止脑裂
集群中每选举一次控制器,就会通过zookeeper创建一个controller epoch
,每一个选举都会创建一个更大,包含最新信息的epoch
,如果有broker收到比这个epoch
[ˈepək](纪元、时代) 旧的数据,就会忽略它们,kafka也通过这个epoch
来防止集群产生“脑裂”。
分区副本选举
这就是kafka丢失的情况,通过设置多个副本,两个以上副本写入才算成功等策略,避免数据丢失。选举副本时,找数据最完整的,数据不完整没有资格参加选举。
消费者Leader选举
与Coordinator有关,Coordinator就是调解员,负责处理管理组内的消费者和位移管理,也负责处理选举。如果消费组内还没有leader,那么第一个加入消费组的消费者即为消费组的leader,如果某一个时刻leader消费者由于某些原因退出了消费组,那么就会重新选举leader。
Kafka速度快的原因
生产者写入:
- 硬盘顺序写:每一个Partition其实都是一个文件,收到消息后Kafka会把数据插入到文件的末尾。然后用offset表示读取到了哪一条。这个offset由客户端保存,其实是放在Zookeeper中。
- Memory Mapped Files:内存映射文件,在适当的时侯,对物理内存的操作会被同步到硬盘上。Kafka提供了prducer.type来控制是不是主动flush(刷盘),刷完盘再返回就是同步,写到Memory Mapped Files再刷盘就是异步。
消费者读取:
- Zero Copy(零拷贝):基于sendfile的Zero Copy提高Web Server静态文件的速度。传统传输方式会经过4次copy,硬盘—>内核buf—>用户buf—>socket相关缓冲区—>网卡接口NLC。Kafka引用了
sendfile
系统调用,绕过用户态减少一次copy,并且通过DMA(Direct Memory Access,让某些硬件子系统去直接访问系统主内存),绕过了CPU,让硬件直接访问,这样的话流程就变为了利用sendfile系统调用,然后DMA直接访问(socket也不需要了)。零拷贝指的是不需要将文件内容拷贝到用户态。 - 批量压缩:消息是一个一个的文件,多个消息一起压缩。支持lz4、snappy、gzip。
多个 Partitions 有什么好处
- 可以分布到不同的Broker上,实现负载均衡
- 多个订阅者可以从一个或者多个partition中同时消费数据,并且多个partition有效避免了重复消费问题,以支撑海量数据处理能力
Dubbo
RPC
RPC(Remote Procedure Call) 即远程过程调用,RPC 的出现就是为了调用远程方法像调用本地方法一样简单。
原理:
- 服务消费端(client)以本地调用的方式调用远程服务;
- 客户端 Stub(client stub) 接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体(序列化):
RpcRequest
; - 客户端 Stub(client stub) 找到远程服务的地址,并将消息发送到服务提供端;
- 服务端 Stub(桩)收到消息将消息反序列化为Java对象:
RpcRequest
; - 服务端 Stub(桩)根据
RpcRequest
中的类、方法、方法参数等信息调用本地的方法; - 服务端 Stub(桩)得到方法执行结果并将组装成能够进行网络传输的消息体:
RpcResponse
(序列化)发送至消费方; - 客户端 Stub(client stub)接收到消息并将消息反序列化为Java对象:
RpcResponse
,这样也就得到了最终结果。
Dubbo中的核心角色
- Container: 服务运行容器,负责加载、运行服务提供者。必须。
- Provider: 暴露服务的服务提供方,会向注册中心注册自己提供的服务。必须。
- Consumer: 调用远程服务的服务消费方,会向注册中心订阅自己所需的服务。必须。
- Registry: 服务注册与发现的注册中心。注册中心会返回服务提供者地址列表给消费者。非必须。
- Monitor: 统计服务的调用次数和调用时间的监控中心。服务消费者和提供者会定时发送统计数据到监控中心。 非必须。
Invoker
invoker可以屏蔽远程调用的细节,实现真正的远程服务调用。
架构
- config 配置层:Dubbo相关的配置。支持代码配置,同时也支持基于 Spring 来做配置,以
ServiceConfig
,ReferenceConfig
为中心 - proxy 服务代理层:调用远程方法像调用本地的方法一样简单的一个关键,真实调用过程依赖代理类,以
ServiceProxy
为中心。 - registry 注册中心层:封装服务地址的注册与发现。
- cluster 路由层:封装多个提供者的路由及负载均衡,并桥接注册中心,以
Invoker
为中心。 - monitor 监控层:RPC 调用次数和调用时间监控,以
Statistics
为中心。 - protocol 远程调用层:封装 RPC 调用,以
Invocation
,Result
为中心。 - exchange 信息交换层:封装请求响应模式,同步转异步,以
Request
,Response
为中心。 - transport 网络传输层:抽象 mina 和 netty 为统一接口,以
Message
为中心。 - serialize 数据序列化层 :对需要在网络传输的数据进行序列化。
ElasticSearch
分布式、高扩展、高实时的搜索与数据分析引擎。MySQL模糊查询效率低,在百万级别数据量情况下ElasticSearch有更好的效率。可以把商品名,描述、价格还有id这些字段我们放入我们索引库里,可以提高查询速度。
倒排索引
每个文档都有一个对应的文档 ID,倒排索引就是关键词到文档 ID 的映射,每个关键词都对应着一系列的文件,这些文件中都出现了关键词。
分片机制
分片就是把数据分成单元块,方便处理,ES中所有数据均衡的存储在集群中各个节点的分片中,
副本
ES默认为一个索引创建5个主分片,并分别为其创建一个副本分片。也就是说每个索引都由5个主分片成本, 而每个主分片都相应的有一个copy。
Zookeeper
分布式和微服务
分布式:若干独立计算机的集合,这些计算机对于用户来说就像单个相关系统。行为是将业务进行拆分,通过Dubbo这样的RPC框架远程调用
微服务:服务微化拆分,SpringCloud就是一套目前生态圈比较完善的微服务框架。
CAP理论
Consistency :一致性
Availability:可用性
Partition tolerance:分区容错
Spring Cloud核心组件
Eureka
微服务架构中的注册中心,专门负责服务的注册与发现。
也可以使用阿里巴巴的Nacos,是注册和配置中心
Feign
会在底层根据注解,跟指定的服务建立连接、构造请求、发起请求、获取响应、解析响应等等。
Ribbon
负载均衡,默认使用Round Robin轮询算法
Hystrix
隔离、熔断以及降级的一个框架
也可以使用阿里巴巴的Sentinel,是服务容错框架(限流、降级、熔断)
Seata
在微服务架构下,由于数据库和应用服务的拆分,导致原本一个事务单元中的多个DML操作,变成了跨进程或者跨数据库的多个事务单元的多个DML操作,而传统的数据库事务无法解决这类的问题,所以就引出了分布式事务的概念。分布式事务本质上要解决的就是跨网络节点的多个事务的数据一致性问题
解决方案:
(1)强一致性,就是所有的事务参与者要么全部成功,要么全部失败,全局事务协调者需要知道每个事务参与者的执行状态,再根据状态来决定数据的提交或者回滚!可以通过基于XA协议下的二阶段提交来实现
(2)最终一致性,也叫弱一致性,也就是多个网络节点的数据允许出现不一致的情况,但是在最终的某个时间点会达成数据一致。基于CAP定理我们可以知道,强一致性方案对于应用的性能和可用性会有影响,所以对于数据一致性要求不高的场景,就会采用最终一致性算法。可以通过基于TCC事务模型、可靠性消息模型等方案来实现。
分布式事务模式
AT:是一种基于本地事务+二阶段协议来实现的最终数据一致性方案,默认方案
TCC:Try、Confirm、Cancel,把一个完整的业务逻辑拆分成三个阶段,然后通过事务管理器在业务逻辑层面根据每个分支事务的执行情况分别调用该业务的 Confirm 或者 Cacel 方法。
Saga:Saga 模式是 SEATA 提供的长事务解决方案,在 Saga 模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者。
XA 模式:XA 可以认为是一种强一致性的事务解决方法,它利用事务资源(数据库、消息服务等)对 XA 协议的支持,以 XA 协议的机制来管理分支事务的一种事务模式。
Gateway
Zuul也是网关,不过被弃用了
由于业务的拆分,需要一个和平台无关的服务协议作为各单元的通信方式。
网关的角色是作为一个 API 架构,用来保护、增强和控制对于 API 服务的访问。API 网关是一个处于应用程序或服务之前的系统,用来管理授权,访问控制和流量限制等,这样 REST API 接口服务就被 API 网关保护起来,对所有的调用者透明。因此隐藏在 API 网关后面的业务系统就可以专注于创建和管理服务,而不用去处理这些策略性地基础设施。
主要用来做聚合、统一管理、验证、安全、流量控制等功能。
Route(路由)
网关基本构建模块,断言为真则路由匹配
Predicate(断言)
匹配模块,传入ServerWebExchange
filter(过滤器)
分为Global Filter和Gateway Filter,
分布式存储
中间控制节点架构(HDFS)
先去请求name-node(主从),再找对应的data-node(集群)
完全无中心架构
计算模式
客户端通过计算得知要从哪一个服务器获取数据
一致性哈希
将设备做成一个哈希环,然后根据数据名称计算出的哈希值映射到哈希环的某个位置,从而实现数据的定位。
Netty
Quartz
Linux
cat
-n 或 –number:由 1 开始对所有输出的行数编号。行编号
-b 或 –number-nonblank:和 -n 相似,只不过对于空白行不编号。
-s 或 –squeeze-blank:当遇到有连续两行以上的空白行,就代换为一行的空白行。压缩空行
-T 或 –show-tabs: 将 TAB 字符显示为 ^I。显示tab
head
head是打印前多少行,tail是打印最后多少行,用管道符组合就是打印第七行
1 | head -7 file |tail -1 |
sed
打印第5-7行,n为忽略执行过程的输出
1 | sed -n ''5,7p'' file |
awk
打印第七行
1 | awk '7 == NR' file |
以:做分隔,打印第一个和第五个域
1 | awk -F: '{print $1,$5}' file.txt |
linux可以用来查阅全部文件的三种命令:cat more less
umask
卸载权限,umask 111,去掉可执行权限
kill
- 1 (HUP):重新加载进程。
- 9 (KILL):杀死一个进程。
- 15 (TERM):正常停止一个进程。
也可以用kill -9
arp
arp -a,查看ARP缓存记录中的命令
开关机
reboot是重启
shutdown -s是关机
shutdown -r是重启
half关机
netstat是显示网络状态
查看进程
PS
当前所在位置
pwd
常见问题
limit 1000000 加载很慢的话,你是怎么解决的呢?
偏移
如果id是连续的,可以这样,返回上次查询的最大记录(偏移量),再往下limit
select id,name from employee where id>1000000 limit 10.
限制页数
在业务允许的情况下限制页数:
建议跟业务讨论,有没有必要查这么后的分页啦。因为绝大多数用户都不会往后翻太多页。
索引
order by + 索引(id为索引)
select id,name from employee order by id limit 1000000,10
分次查询
利用延迟关联或者子查询优化超多分页场景。(先快速定位需要获取的id段,然后再关联)
正则表达式
{n},匹配n次,前边加字符,就是前边的字符匹配n次
[str],表示匹配str
^,反转
+,1次到多次出现
.,匹配任意字符
*,匹配前边的表达式0次到多次
\,转义字符
ORM的优缺点
对象关系映射(Object Relational Mapping,简称ORM)使用描述对象和数据库之间映射的元数据,将程序中的对象自动持久化到关系数据库中。
缺点
拓展性不足:如果业务变更,需要直接修改持久化层接口
多层次带来的低效率:ORM的系统一般都是多层系统,系统的层次多了,效率就会降低。
优点
提高开发效率:由于ORM可以自动对Entity对象与数据库中的Table进行字段与属性的映射,所以我们实际可能已经不需要一个专用的、庞大的数据访问层。
不需要SQL编码:ORM提供了对数据库的映射,不用sql直接编码,能够像操作对象一样从数据库获取数据。
http缓存怎么实现
强缓存
强缓存返回的是200
Expires
当服务器返回响应时,在Response Headers中将过期时间写入Expires字段。对时间同步要求高,优先度较低。
Cache-Control
是HTTP1.1提出的特性,为了弥补Expires缺陷提出的,提供了更精确细致的缓存功能。
协商缓存
浏览器需要向服务器去询问缓存的相关信息,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源。
如果服务端提示缓存资源未改动(Not Modified),资源会被重定向到浏览器缓存,这种情况下网络请求对应的状态码是 304(如下图)。
怎么拷贝一个对象
浅拷贝:复制地址,直接用object.clone方法
深拷贝:序列化和反序列化(Serializable)、BeanUtils.copyProperties()
nginx负载均衡怎么配置
weight 代表权重,默认为1,权重越高被分配的客户端越多
ip_hash:可以保证客户端每次都访问固定的一台服务器
least_conn:web请求会被转发到连接数最少的服务器上
Get和Post的区别
- get一般为数据的获取,post一般为数据的提交
- get请求有长度限制,根据服务器和浏览器来决定,一般为2-8kb
- get请求回退不影响,post会重新提交
- get可以被缓存和加入书签,post不行
雪花算法的原理?
0 - 0000000000 0000000000 0000000000 0000000000 0 - 0000000000 - 000000000000
符号位 时间戳 机器码 序列号
一共有64位,最高位为符号位,一般都是0
41位时间戳,存储的是时间截的差值(当前时间截 - 开始时间截) * 得到的值)
10位存储机器码,最多支持2的10次方,即1024台机器
12位存序列号,每毫秒可以产生2的12次方,4096个,因此可以在高并发的场景下保证ID不重复
为什么数组下标从0开始?
分配的是整块内存区域,并且数组每个元素大小相同,所以寻址为:基础地址+偏移地址,如果从1开始,会多一次减法操作
字符串String的Hash算法
以31为权,每一位为字符的ASCII值进行运算,用自然溢出来等效取模。因为31是一个奇质数,所以31i=32i-i=(i<<5)-i,这种位移与减法结合的计算相比一般的运算快很多。
但是仅从hashcode判断不严谨,因为存在Hash碰撞,所以还是要通过被重写过的equals方法判定对象是否相等。
重写hashcode方法时,要用到equals方法中用到的属性值。
String的不可变性
字段被final修饰且私有,不能提供修改对象的方法。
原因:
- 它被保存到常量池中,作为常量可变会增大设计难度。
- 它的使用频繁,要最大程度保证线程安全。使用频繁也是被保存到常量池的原因。
- 安全性,密码、数据库连接信息等大都是通过String传入,如果可变则会导致黑客修改其中的值。
String中的equals方法
若当前对象和比较的对象是同一个对象,即return true。也就是Object中的equals方法(用关键字instanceof
)。
若当前传入的对象是String类型,则比较两个字符串的长度,即value.length的长度。
若长度不相同,则return false
若长度相同,则按照数组value中的每一位进行比较,不同,则返回false。若每一位都相同,则返回true。
若当前传入的对象不是String类型,则直接返回false
子查询和关联查询有什么区别
子查询是select的嵌套,会利用笛卡尔积,效率较低
关联查询是join,分为内连接(inner join)和外连接(left join、right join)
MySQL没有full join但是可以利用 left join union right join来实现。
为什么重写equals必须重写Hashcode?
因为hashcode会和equals配套使用,hashcode的执行先于equals,只重写equals的话可能会出现两个对象具有相同属性但是地址不同而被hashcode认为不相同的情况。
为什么Java没有多重继承?
钻石问题,此时有D,应该继承哪一个父类的方法呢?所以这样的不确定性导致多重继承不适合存在并且容易导致混淆。
但是Java可以多接口,由于接口中只是定义了方法,具体实现还是要自己处理,所以可以避免这个问题,如果是多个接口中存在相同成员变量,那编译就不会通过。
除此之外,内部类也可以一定程度上解决 不支持多重继承。
为什么ArrayList初始化不能用基础数据类型?
泛型只能是引用的类型,也就是继承自Object的类。
基础数据类型不属于引用类型,但是它们具有包装类,int对应Integer,char对应Character
HTTPS有哪些问题未解决?
- 传输耗时长:HTTP是基于TCP协议的,在网络层的传输耗时比较长,https没有解决这个问题
- HTTP头是不能压缩的,每次要传递很大的数据包,每个连接也只能支持一个请求
- 加密算法影响:非对称加密+对称加密的形式非常影响速率
内存溢出问题该如何解决?
内存溢出,简单地说内存溢出就是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。引起内存溢出的原因有很多种,常见的有以下几种:
- 加载的数据量过大
- 内存泄露的积压
- 死循环
- 过多的对象
- JVM启动的内存参数过小
解决方案:
- 修改JVM参数,增加内存
- 查看错误日志
- 代码分析,找出可能存在的BUG
- 使用内存查看工具分析内存使用情况
程序计数器是唯一不会溢出的区域。溢出通常有Java堆溢出(内存泄露或者对象过多)、虚拟机栈和本地方法栈溢出(递归调用)、方法区和运行时常量池溢出(大量方法)、本地直接内存溢出。
你知道哪些线程安全的集合?
java.util
包下的集合类中,大部分都是非线程安全的,但也有少数的线程安全的集合类,例如Vector、Hashtable,它们都是非常古老的API。虽然它们是线程安全的,但是性能很差,不推荐使用。
对于java.util
包下的集合类可以用Collections
工具类Collections.synchronizedXxx
,例如:
1 | List<String> list = Collections.synchronizedList(new ArrayList<String>()); |
可以将非线程安全的类转化为线程安全。
从JDK 1.5开始,并发包下新增了大量高效的并发的容器,这些容器按照实现机制可以分为三类。
- 以降低锁粒度来提高并发性能的容器,它们的类名以Concurrent开头,如
ConcurrentHashMap
。用法和HashMap一致 - 采用写时复制技术实现的并发容器,它们的类名以CopyOnWrite开头,如
CopyOnWriteArrayList
。 - 采用Lock实现的阻塞队列,内部创建两个Condition分别用于生产者和消费者的等待,这些类都实现了BlockingQueue接口,如
ArrayBlockingQueue
。
抽象类和接口的区别
1.抽象类多用于在同类事物中有无法具体描述的方法的场景,而接口多用于不同类之间,定义不同类之间的通信规则。
2.接口只有定义,而抽象类可以有定义和实现。
3.接口需要实现implement,抽象类只能被继承extends,一个类可以实现多个接口,但一个类只能继承一个抽象类。
4.抽象类倾向于充当公共类的角色,当功能需要累积时,用抽象类;接口被运用于实现比较常用的功能,功能不需要累积时,用接口。
构造方法可以被重写吗?
不可以,比如说People(),那子类Xiaoming的构造方法应该与类名相同,如果能重写,就不满足子类构造方法的定义了。
Java哪些地方用到了CAS?
原子类
以AtomicInteger为例,它的内部提供了诸多原子操作的方法。如原子替换整数值、增加指定的值、加1,这些方法的底层便是采用操作系统提供的CAS原子指令来实现的。
AQS
向同步队列的尾部追加节点时(获取锁),它首先会以CAS的方式尝试一次,如果失败则进入自旋状态,并反复以CAS的方式进行尝试。
其实描述的就是修改state变量的操作。
并发容器(JUC)
java.util.concurrent
以ConcurrentHashMap为例,它的内部多次使用了CAS操作。
- 在初始化数组时,它会以CAS的方式修改初始化状态,避免多个线程同时进行初始化。
- 在执行put方法初始化头节点时,它会以CAS的方式将初始化好的头节点设置到指定槽(node)的首位,避免多个线程同时设置头节点。
- 在数组扩容时,每个线程会以CAS方式修改任务序列号来争抢扩容任务,避免和其他线程产生冲突。
- 在执行get方法时,它会以CAS的方式获取头指定槽的头节点,避免其他线程同时对头节点做出修改。
Java 8的新特性
-
Lambda 表达式 − Lambda 允许把函数作为一个方法的参数(函数作为参数传递到方法中)。
1
2// 接受两个参数,并且返回 x + y
(int x, int y) -> x + y -
方法引用 − 方法引用提供了非常有用的语法,可以直接引用已有Java类或对象(实例)的方法或构造器。与lambda联合使用,方法引用可以使语言的构造更紧凑简洁,减少冗余代码。使用“ :: ”
-
默认方法 − 默认方法就是一个在接口里面有了一个实现的方法。
1
2
3
4
5public interface Vehicle {
default void print(){
System.out.println("我是一辆车!");
}
} -
Stream API −新添加的Stream API(java.util.stream) 把真正的函数式编程风格引入到Java中。
1
2
3
4
5
6
7
8
9list.stream().mapToInt(Integer::intValue).toArray();//
count long count = list.stream().count();
System.out.println("集合中的元素个数是:" + count);
// filter过滤出姓张的元素并且打印出前三个
stream.filter((String name)->{
return name.startsWith("张");
}).limit(3).forEach((String name)->{
System.out.println("流中的元素" + name);
}); -
Date Time API − 加强对日期与时间的处理。
1
LocalDateTime currentTime = LocalDateTime.now();
-
Optional 类 − Optional 类已经成为 Java 8 类库的一部分,用来解决空指针异常。
1
Optional<Integer> b = Optional.of(value2);
RESTful
安全性:方法不会修改资源状态,即读的操作为安全的,写的操作为非安全的。
幂等性:操作一次和操作多次的最终效果相同,客户端重复调用也只返回同一个结果。
请求类型
form-data: 就是form表单中的multipart/form-data,会将表单数据处理为一条信息,用特定标签符将一条条信息分割开,而这个文件类型通常用来上传二进制文件。
x-www-form-urlencoded:就是application/x-www-form-urlencoded,是form表单默认的encType,form表单会将表单内的数据转换为键值对,这种格式不能上传文件。
raw:可以上传任意格式的文本,可以上传Text,JSON,XML等,但目前大部分还是上传JSON格式数据。当后端需要接收JSON格式数据处理的时候,可以采用这种格式来测试。
注解
@GetMapping、@PostMapping、@PutMapping、@DeleteMapping
也可以使用**@RequestMapping指定method**
有哪些原子操作
“++” 这个操作要先读入寄存器,再加,所以是两步,不是原子操作。
1、除long和double之外的基本类型的赋值操作
2、所有引用reference的赋值操作
3、java.concurrent.Atomic.* 包中所有类的一切操作
什么情况不会触发子类初始化
属于被动引用不会出发子类初始化
1.子类引用父类的静态字段,只会触发子类的加载、父类的初始化,不会导致子类初始化
2.通过数组定义来引用类,不会触发此类的初始化
3.常量在编译阶段会进行常量优化,将常量存入调用类的常量池中, 本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
基本类型默认值
成员变量有默认值,局部变量没有默认值
1 | boolean 类型的默认值是false; |
MySQL获取当前时间
current_timestamp,完整的年月日时分秒(可以用这个做默认值)
now(),完整的年月日时分秒
current_time,时分秒
curdate(),年月日
左连接、右连接、内连接有什么异同
1.内连接:显示两个表中有联系的所有数据
2.左链接:以左表为参照,显示所有数据,右表中没有则以null显示
3.右链接:以右表为参照显示数据,左表中没有则以null显示
主键索引是唯一索引吗?可以为null吗?
主键索引是一种特殊的唯一索引,不能为null;
唯一索引可以为null,一个表可以有多个唯一索引,但是只能有一个主键
自定义一个String类会怎么样?
会报错,基于JVM的双亲委派机制,类加载器收到了加载类的请求,会把这个请求委派给他的父类加载器。
而只有父类加载器自己无法完成加载请求时,子类才会自己加载。
这样用户自定义的String类的加载请求就会最终达到顶层的BootStrap ClassLoader启动类加载器,
启动类加载器加载的是系统中的String对象,而用户编写的java.lang.String不会被加载。
会报NoSuchMethodError,因为用户编写的String没有被加载。
如何保证三个线程顺序执行
方案1:在线程B中调用了线程A的Join()方法,直到线程A执行完毕后,才会继续执行线程B。beforeThread是new的Thread里边传的参数。
1 | if (beforeThread != null) { |
方案2:CountDownLatch
CountDownLatch(闭锁)是一个很有用的工具类,利用它我们可以拦截一个或多个线程使其在某个条件成熟后再执行。它的内部提供了一个计数器,在构造闭锁时必须指定计数器的初始值,且计数器的初始值必须大于0。另外它还提供了一个countDown方法来操作计数器的值,每调用一次countDown方法计数器都会减1,直到计数器的值减为0时就代表条件已成熟,所有因调用await方法而阻塞的线程都会被唤醒。
对象可以作为hashMap的key吗?需要做什么?
可以,但由于取值和插入会调用equals和hashcode方法,所以需要重写这两个方法。
但是不推荐,重写equals和hashcode还是相当于以字符串比较,并且属性多的话,重写很麻烦。
MyBatis缓存机制
一级缓存也称为本地缓存,它默认启用且不能关闭。一级缓存存在于SqlSession的生命周期中,即它是SqlSession级别的缓存,在同一个SqlSession中查询时,MyBatis会把执行的方法和参数通过算法生成缓存的键值,将键值和查询结果存入一个Map对象中,如果同一个SqlSession中执行的方法和参数完全一致,则会将缓存的对象返回;
二级缓存则为SqlSessionFactory,mybaits的全局配置setting有一个参数cacheEnabled,这个参数是二级缓存的全局开关,默认值是true,初始状态为启用状态,映射语句文件中的所有SELECT 语句将会被缓存。 - 映射语句文件中的所有时INSERT 、UPDATE 、DELETE 语句会刷新缓存。 - 缓存会使用Least Recently Used ( LRU ,最近最少使用的)算法来收回
一级缓存
在应用运行过程中,在一次数据库会话中,执行多次查询条件完全相同的SQL,会优先命中一级缓存,避免直接对数据库中直接查询。
每个SqlSession中都持有Excutor,每个Excutor中有一个LocalCache。当用户发起询问时,MyBatis根据当前执行的语句生成MappedStatement,在Local Cache进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,查询数据库,结果写入Local Cache,最后返回结果给用户。
一级缓存失效原因
- SqlSession对象不同
- 查询条件不同
- 数据修改
- 手动刷新缓存
二级缓存
开启二级缓存后,会使用CachingExecutor装饰Executor,进入一级缓存的查询流程前,先在CachingExecutor进行二级缓存的查询。二级缓存开启后,同一个namespace下的所有操作语句,都影响着同一个Cache,即二级缓存被多个SqlSession共享,是一个全局的变量。
Spring的循环依赖和三级缓存
初始化bean时,A引用了B,A初始化暂停去先初始化B,而B又引用了A,产生了死循环。会有两个前提,(1)setter方法注入(2)bean要是单例
三级缓存本质上是三个HashMap
一级缓存,singletonObjects,用于存放完全初始化好的bean
二级缓存,earlySingletonObjects,存放原始bean对象,尚未填充属性的,用来解决循环依赖
三级缓存,singletonFactory,存放bean工厂对象,解决循环依赖
JVM启动参数
-d32 运行在32位环境,不支持会报错,默认选项
-d64 运行在64位环境,不支持会报错
-version 版本号
-jar 运行jar包
-server:启动慢,性能和内存管理效率高
-client:反之
-classpath:JVM搜索的目录名
CAP和ACID
ACID,数据库中的事务四大特性
A(Atomic)原子性、C(Consistent)一致性、I(Isolate)隔离性、D(Durable)持久性
CAP,分布式系统中的平衡理论
C(Consistent)一致性、A(Available)可用性、P(Partition Tolerant)分区容错性
登录、鉴权、跨域
cookie和session
- 作用范围不同,Cookie 保存在客户端(浏览器),Session 保存在服务器端。
- 存取方式的不同,Cookie 只能保存 ASCII,Session 可以存任意数据类型,一般情况下我们可以在 Session 中保持一些常用变量信息,比如说 UserId 等。
- 有效期不同,Cookie 可设置为长时间保持,比如我们经常使用的默认登录功能,Session 一般失效时间较短,客户端关闭或者 Session 超时都会失效。
- 隐私策略不同,Cookie 存储在客户端,比较容易遭到不法获取,早期有人将用户的登录名和密码存储在 Cookie 中导致信息被窃取;Session 存储在服务端,安全性相对 Cookie 要好一些。
- 存储大小不同, 单个 Cookie 保存的数据不能超过 4K,Session 可存储数据远高于 Cookie。
用户第一次请求服务器的时候,服务器根据用户提交的相关信息,创建创建对应的 Session ,请求返回时将此 Session 的唯一标识信息 SessionID 返回给浏览器,浏览器接收到服务器返回的 SessionID 信息后,会将此信息存入到 Cookie 中,同时 Cookie 记录此 SessionID 属于哪个域名。
当用户第二次访问服务器的时候,请求会自动判断此域名下是否存在 Cookie 信息,如果存在自动将 Cookie 信息也发送给服务端,服务端会从 Cookie 中获取 SessionID,再根据 SessionID 查找对应的 Session 信息,如果没有找到说明用户没有登录或者登录失效,如果找到 Session 证明用户已经登录可执行后面操作。
禁止Cookie如何解决?
第一种方案,每次请求中都携带一个 SessionID 的参数,也可以 Post 的方式提交,也可以在请求的地址后面拼接 xxx?SessionID=123456...
。
第二种方案,Token 机制。Token 机制多用于 App 客户端和服务器交互的模式,也可以用于 Web 端做用户状态管理。
Token 的意思是“令牌”,是服务端生成的一串字符串,作为客户端进行请求的一个标识。Token 机制和 Cookie 和 Session 的使用机制比较类似。
当用户第一次登录后,服务器根据提交的用户信息生成一个 Token,响应时将 Token 返回给客户端,以后客户端只需带上这个 Token 前来请求数据即可,无需再次登录验证。
如何考虑分布式 Session 问题?
- Hash:请求按访问 IP 的 hash 分配,这样来自同一 IP 固定访问一个后台服务器
- 同步:任何一个服务器上的 Session 发生改变(增删改),该节点会把这个 Session 的所有内容序列化,然后广播给所有其它节点。
- 中间件共享:共享 Session,服务端无状态话,将用户的 Session 等信息使用缓存中间件来统一管理,保障分发到每一个服务器的响应结果都一致。
JWT
JWT是JSON Web Token的缩写,token替代session和cookie方案。
JWT有三部分:
Header
:描述 JWT 的元数据。定义了生成签名的算法以及 Token 的类型。Payload
(负载):用来存放实际需要传递的数据Signature
(签名):服务器通过Payload、Header和一个密钥(secret)使用 Header 里面指定的签名算法(默认是 HMAC SHA256)生成。
在基于 Token 进行身份验证的的应用程序中,服务器通过Payload、Header和一个密钥(secret)创建令牌(Token)并将 Token 发送给客户端,客户端将 Token 保存在 Cookie 或者 localStorage 里面,以后客户端发出的所有请求都会携带这个令牌。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP Header 的 Authorization字段中:Authorization: Bearer Token。
跨域
浏览器的同源策略限制,域名、协议、端口相同即为同源。
CORS
跨域资源分享
1、普通跨域请求:只需服务器端设置Access-Control-Allow-Origin
2、带cookie跨域请求:前后端都需要进行设置
Nginx反向代理
将代理的请求转发到真实的请求地址。
VUE设置代理
proxy设置代理
JSONP
网页通过添加一个<script>元素
,向服务器请求 JSON 数据,服务器收到请求后,将数据放在一个指定名字的回调函数的参数位置传回来。只支持get请求,不支持post请求。
算法奇技淫巧
素数筛选法
已知1-100个素数,求1-10000的素数
方案1是做遍历,找到素数添加即可
方案2是素数筛选,如果一个数是素数,那么它的倍数就一定不是素数,并且维护一个prime数组,保证只有 2能筛选到6,3则不能(6的因数是3和2)。也就是说一个大的数组中,所有元素只会被遍历一遍,就能获取所有的素数,再挑选出没有被筛掉的元素,就是结果。
贪心和动态规划的区别
贪心是局部最优解,像1,4,5凑出8块钱,贪心会先用5,发现凑不出来8块就结束了,但是动态规划会挨个尝试,用1,4,5都试一遍并且把结果放进数组,下次直接用Math.min(dp[i - coin] + 1, dp[i])就行了
前缀、中缀、后缀表达式
中缀表达式(中缀记法)
中缀表达式是一种通用的算术或逻辑公式表示方法,操作符以中缀形式处于操作数的中间。中缀表达式是人们常用的算术表示方法。
虽然人的大脑很容易理解与分析中缀表达式,但对计算机来说中缀表达式却是很复杂的,因此计算表达式的值时,通常需要先将中缀表达式转换为前缀或后缀表达式,然后再进行求值。对计算机来说,计算前缀或后缀表达式的值非常简单。
前缀表达式(前缀记法、波兰式)
前缀表达式的运算符位于操作数之前。
从右至左扫描表达式,遇到数字时,将数字压入堆栈,遇到运算符时,弹出栈顶的两个数,用运算符对它们做相应的计算(栈顶元素 op 次顶元素),并将结果入栈;重复上述过程直到表达式最左端,最后运算得出的值即为表达式的结果