Java
字符串匹配
在Java中,正则表达式使用java.util.regex
包中的类进行匹配。下面是一些常用的正则表达式匹配规则:
- 字符类:
[abc]
:匹配字符a
、b
或c
。[^abc]
:匹配除了a
、b
和c
之外的任意字符。[a-z]
:匹配任意小写字母。[A-Z]
:匹配任意大写字母。[0-9]
:匹配任意数字。
- 元字符:
.
:匹配任意单个字符(除了换行符\n
)。^
:匹配输入字符串的开始位置。$
:匹配输入字符串的结束位置。\d
:匹配任意数字(等同于[0-9]
)。\D
:匹配任意非数字字符(等同于[^0-9]
)。\w
:匹配任意字母、数字或下划线字符(等同于[a-zA-Z0-9_]
)。\W
:匹配任意非字母、数字或下划线字符(等同于[^a-zA-Z0-9_]
)。\s
:匹配任意空白字符(包括空格、制表符、换页符等)。\S
:匹配任意非空白字符。
- 重复次数:
*
:匹配前面的元素零次或多次。+
:匹配前面的元素一次或多次。?
:匹配前面的元素零次或一次。{n}
:匹配前面的元素恰好出现 n 次。{n,}
:匹配前面的元素至少出现 n 次。{n,m}
:匹配前面的元素至少出现 n 次但不超过 m 次。
- 转义字符:
\
:用于转义具有特殊意义的字符,如\.
用于匹配真实的点号。
- 分组和捕获:
()
:用于分组,并可以通过索引编号或命名获取匹配的子串。
组合和继承
组合和继承是面向对象编程中两种不同的关系实现方式。
组合(Composition)是一种"有一个"(has-a)的关系,在这种关系中,一个对象包含其他对象作为其组成部分,它们之间是整体与部分的关系。这意味着一个对象的存在不依赖于另一个对象的存在,它们可以独立存在或被共同使用。当一个对象被销毁时,它所包含的其他对象不会被销毁。组合关系通常通过将其他对象作为成员变量来实现。
继承(Inheritance)是一种"是一个"(is-a)的关系,在这种关系中,一个类(子类)可以继承另一个类(父类)的属性和方法,并且可以在此基础上进行扩展或修改。子类继承了父类的行为,可以重用父类的代码,并且可以添加新的成员变量和方法。继承关系中,子类的存在依赖于父类的存在。
区别:
- 关系:组合是整体与部分的关系,而继承是父类与子类的关系。
- 独立性:在组合关系中,包含对象和被包含对象可以独立存在。在继承关系中,子类依赖于父类,子类的存在和行为都受到父类的影响。
- 代码重用:继承可以实现代码的重用,子类可以继承父类的属性和方法。而组合关系一般不涉及代码的重用,只是通过组合对象来实现功能的组合。
在设计中,应根据具体需求和关系来选择合适的方式。如果存在整体与部分的关系,并且需要独立管理部分对象,则使用组合。如果存在"是一个"的关系,并且需要使用已有类的行为和属性,并进行扩展和修改,则使用继承。
异常
在Java中,当try块中有return语句时,finally块中的代码仍然会执行。
无论是否有异常抛出,都会执行finally块中的代码。当try块中有return语句时,程序会在执行return之前先执行finally块中的代码,然后再执行return语句。
请注意,如果finally块中也有return语句,那么finally块中的return语句会覆盖try块中的return语句,即最终返回的是finally块中的返回值。
抽象出对象
在面向对象设计中,抽象是一种重要的概念,它指的是从具体的实例中提取出共性的特征和行为,形成一个抽象的类或接口。下面是一些常用的方法来抽象出对象:
- 识别共性:观察问题领域中的不同实例,找出它们之间存在的共同特征和行为。这些共性可以是属性、操作或者行为模式。
- 定义类的属性和方法:根据所识别出的共性特征和行为,定义一个抽象类或接口,并将这些特征和行为作为类的属性和方法进行抽象描述。
- 封装数据和行为:将类的属性和方法封装起来,隐藏内部实现细节,只暴露必要的接口给外部使用。
- 继承关系:通过继承机制,将多个具有相同特征和行为的类组织成一个类层次结构,提高代码的复用性和灵活性。
- 多态性:利用多态的特性,通过一个统一的接口调用不同子类的同名方法,使得程序可以在运行时根据实际对象的类型选择合适的方法执行。
- 迭代优化:在设计过程中,根据实际需求和反馈进行迭代优化,不断提炼和完善对象的抽象模型。
需要注意的是,对象抽象的目的是为了将现实世界中的复杂问题简化为易于理解和实现的模型,同时提高代码的可维护性和扩展性。因此,在进行对象抽象时,要深入理解问题领域,合理地确定抽象的层次和粒度,并遵循面向对象设计的原则和准则。
AQS
https://mp.weixin.qq.com/s/jEx-4XhNGOFdCo4Nou5tqg
AQS 为构建锁和同步器提供了一些通用功能的实现,从clh开始讲,aqs是它的升级版避免cas空旋,其实就是clh fifo队列的升级版,首节点独占锁,后面的结点排序等候,状态state为0表示初始状态可以占有锁,state=1表示已有线程占有,大于1是重入锁,设置的state和入队都是通过cas保证原子操作。首节点结点状态为signal时释放锁将state置为0且唤醒后续结点,后续结点开始自旋判断state获取锁。大概讲这些就好了
公平锁,每次判断state为0时不能立即抢占,而是判断是否还有前置结点,也就是判断首节点后面是否还有结点排队等候,如果有通过cas入队非公平锁,新线程来了,不管有没有其他结点在排队先cas抢占,如果两次强锁失败,那么就进入到队列中,其实这和公平锁一样啦,需要排队。当没有新的线程抢占时,强锁也是按照队列先后顺序来抢的,类似公平锁。
非公平锁和公平锁 最大的区别就是新来的线程是不是插队抢占,如果它没抢到那后面就是老老实实排队,后面唤醒也要等前面的结点出队了才能唤醒,而且入队的结点可能存在饥饿。
static和final
static
和final
是Java中两个不同的关键字,它们有不同的作用和用途。下面是它们的区别:
- 作用范围:
static
关键字用于修饰类的成员(字段、方法、内部类、初始化块等),使其成为类级别的成员,属于整个类而不是具体的实例。final
关键字可以用于修饰类、方法和变量(字段、局部变量、形参等)。
- 可变性:
- 使用
static
修饰的成员是静态的,意味着只存在一份副本,无论创建多少个对象,这些对象共享同一个静态成员。静态成员可以通过类名直接访问。 - 使用
final
修饰的成员表示不可变的,即其值无法被修改。对于变量,一旦被赋值后就无法再次改变;对于方法,它不能被子类重写;对于类,它不能被继承。
- 使用
- 内存分配:
- 静态成员在类加载时分配内存,生命周期与整个程序运行周期相同。
final
成员在每个对象创建时分配内存,但一旦赋值后就不可修改。
- 使用场景:
static
适用于定义类级别的常量、共享数据和工具方法,以及在没有实例对象的情况下进行操作。final
适用于定义常量、不可变的方法和类,以及在继承关系中防止修改或覆盖。
需要注意的是,static
和final
可以同时用于修饰同一个成员,例如public static final int MAX_COUNT = 100;
,这样的常量表示在整个程序运行期间都不可修改,且属于类级别的。
多态的实现
在Java中,多态是通过方法重写和动态绑定实现的。以下是Java中多态的底层实现原理:
- 方法重写:子类可以重写(覆盖)父类的方法,即在子类中定义与父类相同签名的方法。子类重写的方法必须具有相同的返回类型、方法名和参数列表。
- 动态绑定:在运行时确定调用哪个方法的过程称为动态绑定。Java中默认使用动态绑定来实现多态。当调用一个基类引用的方法时,实际执行的是对象的实际类型对应的方法。
为了实现方法的动态绑定,Java使用了虚方法表(Virtual Method Table)的概念。每个对象都有一个指向其所属类的虚方法表的指针。虚方法表是一个包含了该类中所有虚方法的地址的表格。
当调用一个方法时,Java虚拟机根据对象的实际类型,在虚方法表中查找该方法的地址。这样,不需要在编译时就确定调用哪个方法,而是在运行时根据实际类型进行动态绑定。
权限修饰符
private | default | protect | public | |
---|---|---|---|---|
同一个类中 | Y | Y | Y | Y |
同一个包中 | Y | Y | Y | |
子类中 | Y | Y | ||
全局范围内 | Y |
double精度丢失
在大多数计算机系统上,double
类型占用 8 个字节(64 位),其中 1 位用于符号位,11 位用于指数,剩下的 52 位用于尾数。
double
类型的浮点数会出现精度丢失的原因主要有两个:
- 有限的存储空间:
double
类型只能表示有限的数字范围和精度。尽管它具有更高的精度和范围比float
类型(占用 4 字节)更适合处理大多数应用程序中的浮点数,但仍然会遇到无法精确表示的数字。 - 二进制表示: 计算机使用二进制表示浮点数,而不是使用人类常用的十进制。一些十进制小数无法精确转换为二进制。例如,0.1 的十进制表示无法精确转换为二进制,所以在计算机中会近似表示。这样的近似表示可能会引起舍入误差,导致精度丢失。
这种二进制表示方式也会导致一些数学运算问题,如浮点数的累加和减法容易引起舍入误差的累积,进而导致精度损失。
为了解决精度丢失的问题,可以考虑以下几点:
- 选择更高精度的数据类型: 在需要更高精度的场景中,可以考虑使用
decimal
数据类型,它可以表示固定小数点数,并提供更高的精度和可控的四舍五入行为。 - 避免直接比较浮点数: 浮点数之间的直接比较可能会因为微小的舍入误差导致不准确的结果。可以使用范围判断或者定义一个误差范围来进行比较。
- 了解浮点数运算的特性: 深入了解浮点数运算中的舍入规则、舍入误差累积等特性,合理安排计算顺序和运算方式。
总而言之,double
类型占用 8 个字节,在计算机中以二进制形式表示浮点数。由于有限的存储空间和二进制表示的限制,double
类型可能会出现精度丢失的情况。为了减少精度丢失,可以选择更高精度的数据类型,避免直接比较浮点数,并深入了解浮点数运算的特性。
红黑树
- 每个节点或者是黑色,或者是红色。
- 根节点是黑色。
- 每个叶子节点(NIL)是黑色。
- 如果一个节点是红色的,则它的子节点必须是黑色的。
- 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
B 树与B+树
b树
1)B 树是所有节点的平衡因子等于 0 的多路平衡查找树;
2)节点的孩子个数为结点关键字数加一;
3)根节点无关键字相当于没有子树,也即是空树;若根节点有关键字,则子树必大于两棵;
4)根节点外的分支节点至少有 ⌈m/2⌉-1 个关键字,即有⌈m/2⌉ 棵子树;
5)节点中关键字自左向右递增,关键字两侧均有指向子树的指针,左边指针所指子树的所有关键字均小于该关键字,右边指针所指的子树的关键字均大于该关键字。
6)所有叶节点在最深一层,代表查找失败的位置。
区别:
B+ 树的主要差异主要体现在叶子节点的关键字、节点关键字个数与其子树数目的关系和节点关键字数目的上下限,具体如下:
① B+ 树中结点的关键字数目与结点子树一比一;B 树中 n 个关键字的结点可以有 n+1 棵子树;
② B+ 树的非根节点关键字数目:(⌈m/2⌉, m),根节点关键字数目:(1, m);B 树的非根结点的关键字数目:(⌈m/2⌉-1, m-1),根节点关键字数目:(1, m-1)。
③ B+ 树的叶节点包含信息,所有非叶节点仅起到索引作用,非叶节点中的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有关键字对应记录的存储地址。
④ B+ 树的叶子节点包含了全部关键字,即非叶节点的关键字也会在叶节点重复出现;B 树中,叶节点中的关键字不会与其他结点的关键字重复。
乐观锁和悲观锁
乐观锁和悲观锁是并发控制的两种常见策略,用于解决多线程或多进程环境下的数据访问冲突问题。
**悲观锁(Pessimistic Locking)**是一种保守的策略,假设在整个事务过程中会发生冲突。它在读取或修改数据之前就会加锁,确保其他线程无法同时操作相同的数据。悲观锁常用的实现方式是通过数据库锁机制(如行级锁、表级锁)或使用同步机制(如synchronized关键字、ReentrantLock等)来实现。
**乐观锁(Optimistic Locking)**是一种乐观的策略,假设在整个事务过程中不会发生冲突。它在读取数据时不加锁,只在更新数据时进行加锁和校验。具体地,在更新数据前,乐观锁会先获取数据的版本号或标识,然后在写回数据时比较版本号是否发生了变化。如果版本号未变,说明期间没有其他线程修改过数据,则继续更新;如果版本号已变,说明有其他线程修改了数据,则放弃更新或重试。乐观锁通常不会引发阻塞,因此适用于并发冲突较少的情况。
乐观锁的实现方式有以下几种常见的方式:
- 版本号(Versioning): 在数据表中增加一个版本号字段,每次更新时都会对版本号进行更新。当执行更新操作时,需要同时比较当前数据版本号与更新前读取的版本号是否一致,如果不一致则表示有其他线程已经更新过数据,此时更新操作失败,需要重新获取最新数据并重试更新。
- 时间戳(Timestamp): 在数据表中增加一个时间戳字段,用于记录最后更新的时间。类似于版本号方式,在执行更新操作时,需要比较当前数据的时间戳与更新前读取的时间戳是否一致,以判断是否有其他线程更新过数据。
- CAS操作(Compare and Swap): CAS是一种原子操作,可以在多线程环境下实现乐观锁。CAS操作包括三个参数:内存地址、旧的预期值和新的值。通过比较内存地址上的值与旧的预期值是否一致,若一致则将内存地址上的值更新为新的值,否则不做任何操作。CAS操作需要硬件或特定指令的支持,常见的应用是Atomic类。
乐观锁的实现方式相对悲观锁更加灵活和高效,但需要注意处理并发冲突的情况,例如重试机制和异常处理。选择使用悲观锁还是乐观锁取决于具体的业务场景和并发访问情况。
ThreadLocal
ThreadLocal是Java中的一个类,它提供了线程局部变量的支持。线程局部变量是指每个线程都拥有自己独立的变量副本,互不干扰。
通常情况下,多个线程共享同一个变量时可能会出现并发访问的问题。但是有些情况下,我们希望每个线程都能够独立地使用自己的变量,而不受其他线程的影响。这时就可以使用ThreadLocal来实现。
ThreadLocal通过在每个线程内部维护一个独立的变量副本,使得每个线程对该变量的操作互不干扰。每个线程对ThreadLocal对象进行get、set等操作时,实际上是操作自己线程内部的变量副本,不会影响其他线程的变量。
使用ThreadLocal需要注意以下几点:
- 每个线程都需要通过ThreadLocal对象的get和set方法来访问自己的变量副本。
- 如果每个线程都调用了ThreadLocal的set方法,则每个线程都会有自己的变量副本。如果某个线程没有调用过set方法,则会使用初始值或者默认值。
- ThreadLocal并不能解决线程安全问题。虽然每个线程都有自己的变量副本,但是在多线程环境下,仍然需要注意对共享资源的并发访问。
- 使用完ThreadLocal后,应该及时调用remove方法进行清理,避免内存泄漏问题。
ThreadLocal常见的使用场景包括但不限于以下情况:
- 在多线程环境下,每个线程需要独立保存自己的上下文信息,如用户身份、请求参数等。
- 在Web开发中,使用ThreadLocal可以方便地在拦截器、过滤器等组件中传递变量,而不需要每个方法都显式传参。
- 在线程池或异步任务处理中,保证每个任务拥有独立的变量副本,避免数据混乱。
总之,ThreadLocal提供了一种简单而有效的方式来实现线程局部变量,使得每个线程都能够独立地操作自己的变量,从而简化了多线程编程的复杂性。
线程池的工作原理
方式一:通过ThreadPoolExecutor
构造函数来创建(推荐)。
方式二:通过 Executor
框架的工具类 Executors
来创建。
我们可以创建多种类型的 ThreadPoolExecutor
:
FixedThreadPool
:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。SingleThreadExecutor
: 该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。CachedThreadPool
: 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。ScheduledThreadPool
:该返回一个用来在给定的延迟后运行任务或者定期执行任务的线程池。
Java线程池是一种用于管理和复用线程的机制,它有助于提高多线程应用程序的性能和效率。Java中的线程池通过使用一些底层原理来实现其功能。
线程池的底层原理可以概括如下:
- 线程池的创建:在创建线程池时,会初始化一定数量的工作线程,并将其放入一个线程池中。这些工作线程处于等待状态,准备接收任务。
- 任务提交:当有任务需要执行时,线程池接收到任务后会选择一个空闲的工作线程分配给该任务。任务可以通过submit()或execute()等方法提交给线程池。
- 任务队列:当线程池中的所有工作线程都在执行任务时,新的任务会被放入一个任务队列中等待执行。线程池会按照队列的顺序逐个取出任务进行处理。
- 线程复用:线程池中的工作线程在完成一个任务后,并不会立即销毁,而是继续等待下一个任务的到来。这样可以避免频繁地创建和销毁线程,提高线程的复用性和性能。
- 控制线程数量:线程池中的工作线程数量是有限的,通过设置线程池的核心线程数和最大线程数,可以控制工作线程的数量。核心线程数是线程池中一直保持的最小线程数,最大线程数是线程池能够容纳的最大线程数。
- 线程管理:线程池会周期性地检查工作线程的状态,如果某个线程长时间空闲或超出设定的空闲时间,线程池可能会终止该线程,以控制线程数量和资源的消耗。
- 异常处理:线程池在执行任务过程中,如果有任务抛出异常,线程池会捕获并记录异常,并根据配置的策略进行处理,例如终止整个线程池或忽略异常继续执行。
通过以上底层原理,Java线程池能够提供高效的线程管理和调度功能,有效地降低线程创建和销毁的开销,提高多线程应用程序的性能和稳定性。
/**
* 用给定的初始参数创建一个新的ThreadPoolExecutor。
*/
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
int maximumPoolSize,//线程池的最大线程数
long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
TimeUnit unit,//时间单位
BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
-
容量为
Integer.MAX_VALUE
的LinkedBlockingQueue
(无界队列):FixedThreadPool
和SingleThreadExector
。由于队列永远不会被放满,因此FixedThreadPool
最多只能创建核心线程数的线程。 -
SynchronousQueue
(同步队列):CachedThreadPool
。SynchronousQueue
没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool
的最大线程数是Integer.MAX_VALUE
,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。 -
DelayedWorkQueue
(延迟阻塞队列):ScheduledThreadPool
和SingleThreadScheduledExecutor
。DelayedWorkQueue
的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue
添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达Integer.MAX_VALUE
,所以最多只能创建核心线程数的线程
ThreadPoolExecutor
3 个最重要的参数:
corePoolSize
: 任务队列未达到队列容量时,最大可以同时运行的线程数量。maximumPoolSize
: 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
ThreadPoolExecutor
其他常见参数 :
-
keepAliveTime
:线程池中的线程数量大于corePoolSize
的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁。 -
unit
:keepAliveTime
参数的时间单位。 -
threadFactory
:executor 创建新线程的时候会用到。 -
handler
:饱和策略。 -
ThreadPoolExecutor.AbortPolicy
: 抛出RejectedExecutionException
来拒绝新任务的处理。ThreadPoolExecutor.CallerRunsPolicy
: 调用执行自己的线程运行任务,也就是直接在调用execute
方法的线程中运行(run
)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。ThreadPoolExecutor.DiscardPolicy
: 不处理新任务,直接丢弃掉。ThreadPoolExecutor.DiscardOldestPolicy
: 此策略将丢弃最早的未处理的任务请求。
核心线程设置
- IO密集型通常设置为2n+1,其中n为CPU核数
- CPU密集型通常设置为 n+1。
实际情况往往复杂的多,并不会按照这个进行设置,上面的公式通常适合框架类,例如netty,dubbo这种底层通讯框架通常会参考上述标准进行设置。
关于在实际业务开发中,如何为一个线程池设置合适的线程呢?
其实对于IO密集型类型的应用,网上还有一个公式:线程数 = CPU核心数/(1-阻塞系数)
引入了阻塞系数的概念,一般为0.8~0.9之间,
在我们的业务开发中,基本上都是IO密集型,因为往往都会去操作数据库,访问redis,es等存储型组件,涉及到磁盘IO,网络IO。
那什么场景下是CPU密集型呢?纯计算类,例如计算圆周率的位数,当然我们基本接触不到。
IO密集型,可以考虑多设置一些线程,主要目的是可以增加IO的并发度,CPU密集型不宜设置过多线程,因为是会造成线程切换,反而损耗性能。
接下来我们以一个实际的场景来说明如何设置线程数量。
一个4C8G的机器上部署了一个MQ消费者,在RocketMQ的实现中,消费端也是用一个线程池来消费线程的,那这个线程数要怎么设置呢?
如果按照 2n + 1 的公式,线程数设置为 9个,但在我们实践过程中发现如果增大线程数量,会显著提高消息的处理能力,说明 2n + 1 对于业务场景来说,并不太合适。
如果套用 线程数 = CPU核心数/(1-阻塞系数) 阻塞系数取 0.8 ,线程数为 20 。阻塞系数取 0.9,大概线程数40,20个线程数我觉得可以。
重写和重载
一、重写(Override)
重写是子类对父类的允许访问的方法的实现过程进行重新编写, 返回值和形参都不能改变。即外壳不变,核心重写!
二、重载(Overload)
重载(overloading) 是在一个类里面,方法名字相同,而参数不同。返回类型可以相同也可以不同。每个重载的方法(或者构造函数)都必须有一个独一无二的参数类型列表,区分重载的唯一区别就是参数。
三、在 Java 中重载是由静态类型确定的,在类加载的时候即可确定,属于静态分派;而重写是由动态类型确定,是在运行时确定的,属于动态分派,动态分派是由虚方法表实现的,虚方法表中存在着各个方法的实际入口地址,如若父类中某个方法子类没有重写,则父类与子类的方法表中的方法地址相同,如若重写了,则子类方法表的地址指向重写后的地址;
一般重写针对于子类继承父类,重写父类的方法,通过动态绑定实现的;而重载时同一个方法名,但是参数类型或者个数不同,重载可以理解为是一个类中的多态。
若子类中的方法与父类的某一方法具有相同的方法名、返回类型和参数表,则新方法将覆盖原有的方法,如需使用父类中原有的方法,可以使用 super 关键字,该关键字引用了当前类的父类。子类函数的访问修饰权限不能少于父类的。
区别
区别时子类继承父类的方法,涉及到两个类;
1、重载是同一个类方法之间的关系,只是参数列表不同,在一个类中。
2、重写的参数列表相同,重载的参数列表不同。
3、方法重写时会有@Override的标签修饰,而方法重载没有。
4、重载的返回值类型可以相同也可以不同,与返回值类型没有关系,而重写返回值类型必须相同。
5、重载的权限修饰符可以不同,但是重写中,子类的权限修饰必须大于等于父类的权限修饰。
JVM
Gc Roots
-
在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
-
在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
-
在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
-
本地方法栈中 JNI(Native方法)引用的对象
Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。 -
所有被同步锁(synchronized关键字)持有的对象。
-
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
类加载过程
-
加载(Loading)是类加载(Class Loading)过程的其中一个阶段。
- 通过类的全限定名来获取这个类的二进制字节流。
- 将字节流转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为这个类在方法区的访问入口。
-
验证
验证是连接阶段的第一步,目的是保证加载的字节码是合法的。- 文件格式验证
- 是否以魔数 0xCAFEBABE 开头
- 版本号是否跟 JVM 兼容
- 检查常量池的常量tag标志
- 元数据验证
- 是否有父类,除了 java.lang.Object,所有的类都有父类
- 是否继承了不允许被继承的类,例如:final 修饰的类
- 如果不是抽象类,是否实现了其父类或接口中的所有方法
- 字节码验证
- 验证程序语义是合法的、符合逻辑的,对类的方法体(Class 文件中的 Code 属性)进行校验分析。
- 符号引用验证
- 验证类中引用的资源(外部类、方法、变量)是否存在,访问权限是否合法。
- 文件格式验证
-
准备是连接阶段的第二步,目的是为静态变量(被 static 修饰的变量)分配内存,初始化默认值。
-
这时候进行内存分配的仅包括类变量( Class Variables ,即静态变量,被
static
关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。不过有一点需要注意的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。
这里所设置的初始值"通常情况"下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了
public static int value=111
,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111
,那么准备阶段 value 的值就被赋值为 111。
-
-
解析是连接阶段的第二步,目的是将接口、变量、方法的符号引用转换为直接引用。
-
初始化是类加载过程的最后一个步骤,就是执行类构造器 () 方法的过程。在此阶段,JVM 会执行执行类中编写的Java程序代码,对类的静态变量,静态代码块执行初始化操作。
- init与clinit方法执行时机不同
init是对象构造器方法,在程序执行new 一个对象类的constructor方法时才会执行init方法。
clinit是类构造器方法,在JVM进行类加载–验证–解析–初始化中的初始化阶段才会调用clinit方法。 - init和clinit方法执行目的不同
init是 (instance) 实例构造器,对非静态变量解析和初始化。
而clinit是 (class) 类构造器对静态变量、静态代码块进行初始化。
- init与clinit方法执行时机不同
-
使用
-
卸载
Java垃圾回收
在 Java 中,垃圾回收(Garbage Collection)是自动进行的,用于管理堆内存中不再使用的对象,并释放它们所占用的内存空间。Java 的垃圾回收算法主要包括以下几种:
- 标记-清除算法(Mark and Sweep): 这是最基本的垃圾回收算法。它分为两个阶段,首先通过根对象(如栈中的引用、静态变量等)遍历并标记所有存活的对象,然后清除没有被标记的对象,释放它们所占用的内存空间。该算法存在内存碎片化的问题。
- 复制算法(Copying): 复制算法将堆内存划分为两个区域,每次只使用其中一个区域。当这个区域中的对象存活时,将其复制到另一个区域中,然后对整个区域进行简单的内存清理。这样可以避免内存碎片化的问题,但需要更多的内存空间。
- 标记-整理算法(Mark and Compact): 标记-整理算法结合了标记-清除和复制算法的优点。首先标记存活对象,然后将存活对象向一端移动,然后直接清理边界外的所有对象。这样可以保持对象的连续性,减少内存碎片。
- 分代算法(Generational): 分代算法基于分代假设,即大部分对象在内存中存活的时间都很短。根据这个假设,堆内存被划分为不同的代(Generation),一般分为新生代(Young Generation)和老年代(Old Generation)。新生代中进行频繁的垃圾回收,而老年代中进行相对较少的垃圾回收。
在实际应用中,Java 虚拟机会根据具体情况选择合适的垃圾回收算法,其中主要使用的是分代算法。Java 虚拟机提供了不同的垃圾回收器,如串行回收器(Serial)、并行回收器(Parallel)、并发标记清除回收器(CMS)和 G1 回收器(G1 Garbage Collector),它们各自采用了不同的垃圾回收算法来满足不同的需求。
需要注意的是,垃圾回收是 Java 虚拟机负责的任务,开发人员通常不需要显式地进行垃圾回收操作。然而,了解垃圾回收算法的工作原理可以帮助我们编写更高效、更可靠的代码,并避免一些潜在的内存泄漏和性能问题。
垃圾分代回收
垃圾回收(Garbage Collection)在Java中是自动进行的内存管理机制。它的设计目标是减轻开发人员对内存管理的负担,并最大程度地提供了程序运行时内存的自动分配和释放。
新生代(Young Generation)和老年代(Old Generation)是针对堆内存进行划分的不同区域,其设计的目的有以下几点原因:
- 对象生命周期假设:根据研究和观察,大多数对象在其创建后只经历短暂的生命周期,然后就会被垃圾回收。因此,将堆内存划分为新生代和老年代,可以根据对象的生命周期进行更精细的管理和回收。
- 不同的垃圾回收策略:新生代和老年代通常采用不同的垃圾回收算法和策略。新生代中的对象一般比较年轻,因此可以使用基于复制(Copying)或标记-清除(Mark-Sweep)算法等效率较高的回收方式。而老年代中的对象存活时间较长,采用标记-整理(Mark-Compact)等算法可以更有效地回收内存。
- 避免全局垃圾回收停顿:当进行垃圾回收时,应用程序的执行会停顿(Stop-The-World)。通过将堆内存划分为新生代和老年代,可以将垃圾回收的影响范围限制在某个特定的区域,从而减少全局垃圾回收的停顿时间。
- 优化性能:新生代中的对象频繁地进行创建和销毁,因此使用较小的区域进行对象分配有助于提高分配和回收的效率。同时,老年代中的对象生命周期较长,使用较大的区域可以减少对象的移动和复制,提高垃圾回收效率。
综上所述,新生代和老年代的设计能够根据对象的生命周期,采用不同的垃圾回收算法和策略,并优化程序的执行性能和内存回收效率。
垃圾回收器
-
Serial的意思是单线程,顾名思义这是一个单线程收集器。使用Serial收集器,在垃圾回收时,会有短暂的STW(stop the world),此时应用程序线程是完全停止的。年轻代使用的是复制算法, Serial Old是Serial的老年代版本,使用的垃圾回收算法是标记-整理算法。
-
Parallel可以对比Serial学习,Parallel在stw期间是多线程处理的,默认线程数与CPU核数相关。相比于Serial,他的优势应该是吞吐量高,一个八核的机器,同一时间跑八个线程利用率肯定是比跑一个线程高的。所谓的吞吐量就是CPU中用于运行用户的代码的时间与CPU总消耗时间的比值。年轻代使用复制算法。
Parallel Old是Parallel Scavenge的老年代版本,使用标记-整理算法。Parallel Old与Parallel Scavenge是JDK8默认的垃圾收集器
-
ParNew收集器与Parallel是一样的过程,特别之处在于ParNew可以与CMS收集器一起使用,Parallel不行。 新生代代用复制算法,老年代采用标记整理算法
-
上面说到Parallel收集器考虑的是CPU的吞吐量,那么CMS考虑的点是用户体验,简单说就是减少STW的时间。尽量减少应用程序线程的停止时间。CMS收集器的大概执行流程如下:老年代对象
从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:
- 初始标记: 暂停所有的其他线程(STW),并记录下gc roots直接能引用的对象,速度很快。
- 并发标记: 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。
- 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新算法(见下面详解)做重新标记。
- 并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理(见下面三色标记算法详解)
- 并发重置:重置本次GC过程中的标记数据。
-
G1的内存结构如上,与其他垃圾收集器相比,G1将整个内存划分成一块块的区域,成为region。通常会划分为2048个region,假设堆内存是2048M。那么每个region就是1M。当然也可以通过参数自己设置region大小。 G1保留了年轻代,幸存0区,幸存1区,并且比例也是8:1:1。另外新增了一个Humongus区域,这个区域是用来存放大对象的,当有对象超过region的50%时,这个对象就被认定为大对象。当大对象超过1M时就会使用连续的几个region来存放。 相比于其他垃圾收集器来讲,我觉得G1的好处在于,年轻代与老年代没有严格的逻辑区分。并且内存区域是共用的,比如之前一块region是年轻代,回收后可能用于存放老年代对象。这就大大提高内存分配的灵活性。
G1在进行垃圾回收时,通常会经过以下几阶段:
- 初始标记:STW,会停止所有的应用程序线程,记录gc roots直接能引用的对象,与cms类似
- 并发标记:与cms的并发标记一致
- 最终标记:与cms的重新标记一致
- 筛选回收:
- 回收采用的算法是复制算法:G1会将存活的对象从一个region转移到另一个region。这个过程不会产生碎片化的空间,相比于CMS产生的碎片化空间,G1省下了整理碎片化空间的时间
- G1停顿时间:G1回收并不像CMS没有固定时间,用户可以通过参数**(** -XX:MaxGCPauseMillis指定)指定回收计划。假设一共有100个region要被回收,回收计划时间是20ms,假定20ms只能50个region,那么一次回收就只会回收一部分region
- G1回收优先级列表:G1会维护一个优先级列表,在回收时会根据优先级回收。优先级的大概规则是单位时间内回收垃圾的大小。比如5ms回收5M垃圾,与10ms回收100M垃圾,G1会优先花10ms回收后者
数据库
MySQL主从复制
MySQL主从复制(Master-Slave Replication)是一种常见的数据库复制技术,用于将一个MySQL数据库的更改操作(如插入、更新、删除等)自动地同步到其他MySQL实例上,实现数据的备份、负载均衡和高可用性。
在MySQL主从复制中,有两个角色:主服务器(Master)和从服务器(Slave)。主服务器负责接收客户端的写操作,并将这些操作记录在二进制日志(Binary Log)中。从服务器通过连接到主服务器,获取二进制日志,并将其应用到本地数据库,从而保持与主服务器上的数据保持同步。
主从复制的工作原理如下:
- 配置主服务器:在主服务器上,需要开启二进制日志记录功能,并设置一个唯一的服务器ID。
- 配置从服务器:在从服务器上,需要设置服务器ID,并指定要连接的主服务器。
- 启动复制过程:从服务器连接到主服务器后,开始复制过程。主服务器将二进制日志中的更改操作发送给从服务器。
- 应用二进制日志:从服务器接收到二进制日志后,将其应用到本地数据库,实现数据的同步。
主从复制可以带来以下好处:
- 数据备份:从服务器可以作为主服务器的备份,当主服务器发生故障时,可以快速切换到从服务器提供服务。
- 负载均衡:从服务器可以分担主服务器的读请求,提高系统的处理能力和响应速度。
- 数据分析:可以使用从服务器进行数据分析、报表生成等操作,避免对主服务器的影响。
- 高可用性:通过设置多个从服务器,并结合自动故障转移技术,可以实现数据库的高可用性和容错能力。
需要注意的是,MySQL主从复制是异步的过程,所以在复制过程中可能会存在一定的延迟。并且,在主服务器上的更改操作必须满足一致性和安全性的要求,以避免数据同步过程中的问题。
分表分库
https://blog.csdn.net/BASK2311/article/details/128312076
正确建立数据库索引
不为 NULL 的字段:索引字段的数据应该尽量不为 NULL,因为对于数据为 NULL 的字段,数据库较难优化。如果字段频繁被查询,但又避免不了为 NULL,建议使用 0,1,true,false 这样语义较为清晰的短值或短字符作为替代。
被频繁查询的字段:我们创建索引的字段应该是查询操作非常频繁的字段。
被作为条件查询的字段:被作为 WHERE 条件查询的字段,应该被考虑建立索引。
频繁需要排序的字段:索引已经排序,这样查询可以利用索引的排序,加快排序查询时间。
被经常频繁用于连接的字段:经常用于连接的字段可能是一些外键列,对于外键列并不一定要建立外键,只是说该列涉及到表与表的关系。对于频繁被连接查询的字段,可以考虑建立索引,提高多表连接查询的效率。
事务
事务(Transaction)是指作为单个逻辑单位执行的一系列操作,这些操作要么全部成功执行,要么全部失败回滚,保证数据的一致性和完整性。
事务通常在数据库系统中使用,它是对一组数据库操作的逻辑封装。常见的数据库操作包括插入、更新、删除等。事务将这些操作绑定在一起,以确保在一个事务中的所有操作要么全部成功地被提交,要么全部失败并回滚到之前的状态。
事务具备以下四个特性,通常被称为 ACID 特性:
- 原子性(Atomicity): 事务是一个不可分割的最小执行单元,要么全部执行成功,要么全部失败回滚,不存在部分执行的情况。
- 一致性(Consistency): 数据库的一致性是指数据库中存储的数据与事务执行前设定的约束条件和规则相一致,不发生数据冲突、数据丢失或数据混乱的状态。
- 隔离性(Isolation): 不同事务之间应该相互隔离,使得每个事务感觉到自己在独立执行,不受其他事务的影响。隔离性解决了并发访问数据库时可能出现的问题,如脏读、不可重复读和幻读。
- 持久性(Durability): 一旦事务被提交,其对数据库的修改将永久保存,即使系统发生故障或重启,数据库也可以恢复到提交事务后的状态。
通过事务的特性和支持,数据库能够保证数据的完整性和一致性,同时提供并发控制和故障恢复机制,确保多个用户并发访问数据库时的数据正确性和可靠性。事务在许多应用中都起到了至关重要的作用,例如银行系统、电子商务和在线支付等。
范围索引
对于范围查找,如果查询条件涉及到了一个连续的范围,那么可以使用索引来提高查询性能。在这种情况下,使用范围索引(Range Index)可以很好地支持这种查询。
范围索引是一种特殊类型的索引,它允许在索引结构中存储并快速查找连续的范围值。范围索引通常用于处理诸如日期范围、数字范围等连续值的查询。
当进行范围查找时,数据库查询优化器可以利用范围索引快速定位满足范围条件的数据,而不需要全表扫描。
需要注意的是,对于某些数据库管理系统,范围索引可能需要手动创建和管理,具体取决于数据库的实现和版本。此外,范围索引适用于范围查询,但可能对其他类型的查询性能产生负面影响。
总之,范围查找可以使用范围索引来提高查询性能,但在具体实现时需要根据数据库的要求和支持情况进行相应的配置和优化。
优化查询
优化器是数据库管理系统(DBMS)的组件之一,它负责分析和优化查询语句的执行计划,以提高查询性能。优化器的主要目标是选择最佳的执行计划,使查询在给定的约束条件下尽可能快速地执行。
下面是优化器的一般工作原理:
- 查询解析:优化器首先对查询语句进行解析,识别其中的表名、列名和查询条件等元素。这一步骤有助于构建查询语法树或其他内部表示形式。
- 查询优化:优化器会根据查询语句的结构和语义信息,以及系统中可用的统计信息和索引信息等,生成多个可能的执行计划。
- 估算成本:对于每个生成的执行计划,优化器会估算其执行所需的成本。这些成本包括IO成本、CPU成本、内存使用等。成本估算通常基于统计信息、索引选择因子和启发式规则等。
- 执行计划选择:优化器会比较各个执行计划的成本估算结果,选择具有最低成本的执行计划作为最佳执行计划。优化器使用启发式算法、基于规则的转换以及动态规划等技术来完成此任务。
- 执行计划生成:一旦最佳执行计划确定,优化器将生成实际的执行计划,该计划描述了查询执行的详细步骤。执行计划可以包括索引选择、连接顺序、聚合操作等。
- 执行计划执行:生成的执行计划将被传递给执行引擎,执行引擎将根据执行计划中的指令和操作来执行查询,并返回结果。
需要注意的是,优化器的工作原理会因不同的数据库管理系统而有所不同。不同的DBMS可能采用不同的优化算法、数据结构和启发式规则。此外,优化器还受到统计信息的准确性、查询复杂性和系统资源约束等因素的影响。
总体而言,优化器的目标是在给定的查询语句和系统约束下,选择最佳的执行计划以提高查询性能和效率。
InnoDB 和MyISAM
InnoDB 支持行级别的锁粒度,MyISAM 不支持,只支持表级别的锁粒度。
MyISAM 不提供事务支持。InnoDB 提供事务支持,实现了 SQL 标准定义了四个隔离级别。
MyISAM 不支持外键,而 InnoDB 支持。
MyISAM 不支持 MVCC,而 InnoDB 支持。
虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样。
MyISAM 不支持数据库异常崩溃后的安全恢复,而 InnoDB 支持。
InnoDB 的性能比 MyISAM 更强大。
唯一索引和非唯一索引的行级锁的加锁规则
唯一索引等值查询:
- 当查询的记录是「存在」的,在索引树上定位到这一条记录后,将该记录的索引中的 next-key lock 会退化成「记录锁」。
- 当查询的记录是「不存在」的,在索引树找到第一条大于该查询记录的记录后,将该记录的索引中的 next-key lock 会退化成「间隙锁」。
非唯一索引等值查询:
- 当查询的记录「存在」时,由于不是唯一索引,所以肯定存在索引值相同的记录,于是非唯一索引等值查询的过程是一个扫描的过程,直到扫描到第一个不符合条件的二级索引记录就停止扫描,然后在扫描的过程中,对扫描到的二级索引记录加的是 next-key 锁,而对于第一个不符合条件的二级索引记录,该二级索引的 next-key 锁会退化成间隙锁。同时,在符合查询条件的记录的主键索引上加记录锁。
- 当查询的记录「不存在」时,扫描到第一条不符合条件的二级索引记录,该二级索引的 next-key 锁会退化成间隙锁。因为不存在满足查询条件的记录,所以不会对主键索引加锁。
非唯一索引和主键索引的范围查询的加锁规则不同之处在于:
- 唯一索引在满足一些条件的时候,索引的 next-key lock 退化为间隙锁或者记录锁。
- 非唯一索引范围查询,索引的 next-key lock 不会退化为间隙锁和记录锁。
数据库索引
按照存储结构
- 聚簇索引(聚集索引):索引结构和数据一起存放的索引,InnoDB 中的主键索引就属于聚簇索引。
- 非聚簇索引(非聚集索引):索引结构和数据分开存放的索引,二级索引(辅助索引)就属于非聚簇索引。MySQL 的 MyISAM 引擎,不管主键还是非主键,使用的都是非聚簇索引。
按照应用维度划分:
-
主键索引:加速查询 + 列值唯一(不可以有 NULL)+ 表中只有一个。
-
普通索引:仅加速查询。
-
唯一索引:加速查询 + 列值唯一(可以有 NULL)。
-
覆盖索引:一个索引包含(或者说覆盖)所有需要查询的字段的值。
-
联合索引:多列值组成一个索引,专门用于组合搜索,其效率大于索引合并。
-
全文索引:对文本的内容进行分词,进行搜索。目前只有
CHAR
、VARCHAR
,TEXT
列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。 -
数据库隔离级别
数据库隔离级别(Isolation Level)是指在并发环境中,多个事务之间相互隔离的程度。隔离级别定义了一组规则,用于控制并发事务对数据的读取和修改,以确保数据的一致性、隔离性和并发性。
常见的数据库隔离级别包括以下四个级别:
- 读未提交(Read Uncommitted): 最低的隔离级别,允许一个事务读取另一个事务尚未提交的数据。可能导致脏读(Dirty Read)问题,即读到其他事务更新但未提交的数据。
- 读已提交(Read Committed): 保证一个事务只能读取到已经提交的数据。解决了脏读问题,但可能导致不可重复读(Non-Repeatable Read)问题,即在同一个事务中,两次读取同一数据得到的结果不一致。
- 可重复读(Repeatable Read): 确保一个事务在执行期间多次读取同一数据时,能够得到一致的结果。解决了不可重复读问题,但可能导致幻读(Phantom Read)问题,即在同一个事务中,第二次读取到了新增或删除的数据。
- 串行化(Serializable): 最高的隔离级别,通过强制事务串行执行来避免并发问题。保证了数据的完全隔离性,但可能导致并发性能下降。
在选择数据库隔离级别时,需要根据应用场景的需求权衡一致性和并发性。较低的隔离级别可以提高并发性能,但可能导致数据不一致的问题。较高的隔离级别可以保证数据的一致性,但可能降低并发性能。
除了以上四个标准隔离级别,部分数据库还支持其他自定义的隔离级别或扩展的隔离机制,如快照隔离(Snapshot Isolation)等,以满足更具体的应用需求。在实际应用中,需要根据具体情况选择合适的隔离级别,并在实现事务并发控制时注意处理可能出现的并发问题。
计算机网络
TCP和UDP
TCP | UDP | |
---|---|---|
是否面向连接 | 是 | 否 |
是否可靠 | 是 | 否 |
是否有状态 | 是 | 否 |
传输效率 | 较慢 | 较快 |
传输形式 | 字节流 | 数据报文段 |
首部开销 | 20 ~ 60 bytes | 8 bytes |
是否提供广播或多播服务 | 否 | 是 |
Cookie和Session
Cookie和Session是用于在Web应用中跟踪用户状态和存储数据的两种常见机制。它们有以下区别:
- 存储位置:Cookie是存储在客户端(浏览器)中的小型文本文件,而Session数据存储在服务器端。
- 数据存储方式:Cookie以键值对的形式存储数据,以文本格式存放在浏览器的Cookie文件中。Session将数据存储在服务器端的内存或持久化存储介质中(如数据库),并为每个用户分配一个唯一的Session ID。
- 安全性:由于Cookie存储在客户端,因此可以被用户篡改、删除或伪造。为了增加安全性,可以对Cookie进行加密、签名或设置HttpOnly属性防止脚本访问。而Session数据存储在服务器端,相对更安全。
- 存储容量:Cookie的存储容量较小,通常为几KB。而Session可以存储更大量的数据,受服务器端的配置和资源限制。
- 生命周期:Cookie具有指定的过期时间,在过期时间之前一直存在于客户端。而Session的生命周期由用户访问网站开始至用户关闭浏览器或超过一定时间的非活动状态(会话超时)。
- 跨域支持:Cookie可以被指定域名和路径下的页面共享和访问。而Session通常与特定域名绑定,不能跨域直接共享。
三次握手的原因
https://www.xiaolincoding.com/network/3_tcp/tcp_interview.html#%E4%B8%BA%E4%BB%80%E4%B9%88%E6%98%AF%E4%B8%89%E6%AC%A1%E6%8F%A1%E6%89%8B-%E4%B8%8D%E6%98%AF%E4%B8%A4%E6%AC%A1%E3%80%81%E5%9B%9B%E6%AC%A1
- 三次握手才可以阻止重复历史连接的初始化(主要原因)
- 一个「旧 SYN 报文」比「最新的 SYN」 报文早到达了服务端,那么此时服务端就会回一个
SYN + ACK
报文给客户端,此报文中的确认号是 91(90+1)。 - 客户端收到后,发现自己期望收到的确认号应该是 100 + 1,而不是 90 + 1,于是就会回 RST 报文。
- 服务端收到 RST 报文后,就会释放连接。
- 后续最新的 SYN 抵达了服务端后,客户端与服务端就可以正常的完成三次握手了。
- 一个「旧 SYN 报文」比「最新的 SYN」 报文早到达了服务端,那么此时服务端就会回一个
- 三次握手才可以同步双方的初始序列号
- 三次握手才可以避免资源浪费
- 建立连接浪费
跨域
跨域就是当在页面上发送ajax请求时,由于浏览器同源策略的限制,要求当前页面和服务端必须同源,也就是协议、域名和端口号必须一致。
如果协议、域名和端口号中有其中一个不一致,则浏览器视为跨域,进行拦截。
解决方法:
-
jsonp的原理就是利用了script标签不受浏览器同源策略的限制,然后和后端一起配合来解决跨域问题的。
具体的实现就是在客户端创建一个script标签,然后把请求后端的接口拼接一个回调函数名称作为参数传给后端,并且赋值给script标签的src属性,然后把script标签添加到body中,当后端接收到客户端的请求时,会解析得到回调函数名称,然后把数据和回调函数名称拼接成函数调用的形式返回,客户端解析后会调用定义好的回调函数,然后在回调函数中就可以获取到后端返回的数据了。
-
CORS方式解决跨域:
cors是跨域资源共享,是一种基于 HTTP 头的机制,该机制通过允许服务器标示除了它自己以外的其它 origin(域,协议和端口),使得浏览器允许这些 origin 访问加载自己的资源。服务端设置了Access-Control-Allow-Origin就开启了CORS,所以这种方式只要后端实现了CORS,就解决跨域问题,前端不需要配置。 -
搭建Node代理服务器解决跨域:
因为同源策略是浏览器限制的,所以服务端请求服务器是不受浏览器同源策略的限制的,因此我们可以搭建一个自己的node服务器来代理访问服务器。大概的流程就是:我们在客户端请求自己的node代理服务器,然后在node代理服务器中转发客户端的请求访问服务器,服务器处理请求后给代理服务器响应数据,然后在代理服务器中把服务器响应的数据再返回给客户端。客户端和自己搭建的代理服务器之间也存在跨域问题,所以需要在代理服务器中设CORS。
-
Nginx反向代理解决跨域:
nginx通过反向代理解决跨域也是利用了服务器请求服务器不受浏览器同源策略的限制实现的。客户端请求nginx服务器,在nginx.conf配置文件中配置server监听客户端的请求,然后把location匹配的路径代理到真实的服务器,服务器处理请求后返回数据,nginx再把数据给客户端返回。
-
postMessage方式解决跨域:
window.postMessage() 方法可以安全地实现跨源通信,此方法一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。主要的用途是实现多窗口,多文档之间通信:页面和其打开的新窗口的数据传递
多窗口之间消息传递
页面与嵌套的 iframe 消息传递
。 -
Websocket方式解决跨域:
使用Websocket也可以解决跨域问题,因为WebSocket本身不存在跨域问题,所以我们可以利用webSocket来进行非同源之间的通信,WebSocket 规范定义了一个在 Web 浏览器和服务器之间建立“套接字”连接的 API。 简单来说:客户端和服务器之间存在持久连接,双方可以随时开始发送数据。
GET 和 POST
主要区别如下:
- 参数位置:GET 请求通过 URL 的查询字符串传递参数,参数会附加在 URL 后面,例如:
http://example.com/path?param1=value1¶m2=value2
。POST 请求的参数则通过请求体传递,不会直接暴露在 URL 中。 - 参数长度限制:GET 请求的参数长度一般有限制,具体限制取决于浏览器或服务器的设置,超过限制可能会被截断或拒绝;POST 请求的参数长度理论上没有限制。
- 安全性:GET 请求的参数暴露在 URL 中,容易被拦截和截获,因此不适合发送敏感信息,例如密码;POST 请求的参数不会直接暴露在 URL 中,相对更安全。
- 请求语义:GET 请求用于获取资源,不应该产生副作用,多次请求相同的 URL 应该返回相同的结果;POST 请求用于向服务器提交数据,可能会修改服务器的状态。
- 缓存:GET 请求可以被浏览器缓存,下次请求相同的 URL 时可以直接从缓存中获取;POST 请求默认不可缓存。
总之,GET 适用于获取数据,不涉及数据的修改,不包含敏感信息;而 POST 适用于更新数据、上传文件等需要对服务器状态进行修改的操作,可能包含敏感信息。在实际应用中,需要根据具体场景选择使用 GET 或 POST 方法。
POST和PUT
- 功能目的:POST用于在服务器上创建新的资源,通常会导致服务器生成一个新的唯一标识符来标识创建的资源。而PUT用于更新或替换服务器上的现有资源,客户端必须提供完整的资源表示。
- 幂等性:幂等性是指对同一请求的多次执行不会产生不一致的结果。POST请求不是幂等的,多次执行相同的POST请求可能会导致服务器上创建多个相同的资源。而PUT请求是幂等的,多次执行相同的PUT请求只会更新或替换服务器上的同一个资源。
- 语义:POST用于非幂等操作,可以用来执行各种操作,如创建资源、提交表单数据、发送消息等。PUT用于幂等操作,强调将一个资源的状态完全替换为新的表示,或者在指定的URI上创建一个资源。
- 请求数据:在POST请求中,客户端通常将数据作为请求的主体发送给服务器,通常以JSON或表单形式进行编码。而在PUT请求中,客户端通常将完整的资源表示作为请求的主体发送给服务器,以覆盖或更新现有的资源。
- URI使用:POST请求通常使用无参URI,服务器负责为新资源生成适当的URI。而PUT请求通常使用有参URI,指定要更新或替换的资源的唯一标识符。
拥塞算法
拥塞发生
当网络拥塞发生丢包时,会有两种情况:
- RTO超时重传
- 快速重传
如果是发生了RTO超时重传,就会使用拥塞发生算法
- 慢启动阀值sshthresh = cwnd /2
- cwnd 重置为 1
- 进入新的慢启动过程
这真的是辛辛苦苦几十年,一朝回到解放前。其实还有更好的处理方式,就是快速重传。发送方收到3个连续重复的ACK时,就会快速地重传,不必等待RTO超时再重传。
慢启动阀值ssthresh 和 cwnd 变化如下:
- 拥塞窗口大小 cwnd = cwnd/2
- 慢启动阀值 ssthresh = cwnd
- 进入快速恢复算法
快速恢复
快速重传和快速恢复算法一般同时使用。快速恢复算法认为,还有3个重复ACK收到,说明网络也没那么糟糕,所以没有必要像RTO超时那么强烈。
正如前面所说,进入快速恢复之前,cwnd 和 sshthresh已被更新:
然后,真正的快速算法如下:
- cwnd = sshthresh + 3
- 重传重复的那几个ACK(即丢失的那几个数据包)
- 如果再收到重复的 ACK,那么 cwnd = cwnd +1
- 如果收到新数据的 ACK 后, cwnd = sshthresh。因为收到新数据的 ACK,表明恢复过程已经结束,可以再次进入了拥塞避免的算法了。
输入URL到页面展示经历步骤
从输入URL到页面展示,经历了以下主要步骤:
- URL解析: 浏览器接收到用户输入的URL后,首先对URL进行解析。该过程包括解析协议(如HTTP、HTTPS)、解析域名(如www.example.com)、解析路径(如/page.html)等。
- DNS解析: 在解析完域名后,浏览器需要将域名转换成对应的IP地址。为此,浏览器会向本地DNS解析器发送DNS查询请求,以获取域名对应的IP地址。如果本地DNS缓存中存在对应的记录,则直接返回IP地址;否则,本地DNS解析器会向上级DNS服务器发送请求,逐级向上查询,直到获取到对应的IP地址。
- 建立TCP连接: 通过解析得到的IP地址,浏览器与Web服务器之间建立TCP连接。建立连接时,使用三次握手的过程进行通信的初始化,确保双方能够正常通信。
- 发送HTTP请求: TCP连接建立后,浏览器向Web服务器发送HTTP请求。请求中包含了请求方法(如GET、POST)、请求头部、请求体等信息。请求头部包含了用户代理信息、Cookie、Accept等。
- 服务器处理请求: Web服务器接收到浏览器发送的HTTP请求后,开始处理请求。这个过程包括路由解析、执行后端处理逻辑、访问数据库等。根据请求的URL和参数,服务器会生成相应的数据或者执行一些操作。
- 返回HTTP响应: 服务器处理完请求后,会生成相应的HTTP响应。响应包含了状态码(如200表示成功、404表示未找到等)、响应头部和响应体等信息。响应头部包含了服务器信息、内容类型、Cookie等。
- 接收响应数据: 浏览器接收到HTTP响应后,开始接收响应数据。首先会解析响应头部,获取响应的内容类型和长度等信息,然后按照相应的方式解析响应体的数据。
- 渲染页面: 浏览器使用接收到的响应数据,根据HTML、CSS、JavaScript等内容进行页面渲染。渲染过程包括解析HTML结构、加载和解析CSS样式、构建DOM树、执行JavaScript代码等。
- 展示页面: 页面渲染完成后,浏览器将渲染好的页面展示给用户。包括显示文本内容、加载并显示图片、播放视频等。
time-wait 状态
https://www.xiaolincoding.com/network/3_tcp/tcp_interview.html#%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81-time-wait-%E7%8A%B6%E6%80%81
需要 TIME-WAIT 状态,主要是两个原因:
- 防止历史连接中的数据,被后面相同四元组的连接错误的接收;
- 保证「被动关闭连接」的一方,能被正确的关闭;
time-wait 状态是 TCP/IP 协议栈中的一种连接状态,它发生在 TCP 连接的关闭过程中。当一条 TCP 连接被主动关闭或双方都已经关闭连接时,通常会进入 time-wait 状态。
主动发起关闭连接的一方,才会有 TIME-WAIT
状态。
在 time-wait 状态中,TCP 连接处于一种等待状态,保留着已关闭连接的信息。这个时间段的持续时间由两个因素决定:MSL(Maximum Segment Lifetime)和2MSL。
- MSL(Maximum Segment Lifetime): MSL 是指网络中数据报文能够存活的最长时间,通常为约2分钟。这个时间间隔是 TCP 延迟传输和重复分段的影响时间。
- 2MSL: 2MSL 是两倍的 MSL 值,用于确保所有可能与该连接相关的数据报文都从网络中消失。在等待时间结束后,才能释放占用的资源。
time-wait 状态的主要目的有以下几个:
- 确保完整的数据传输: 在 time-wait 状态期间,可以处理可能在网络中延迟到达的数据分段,以确保数据的完整性。
- 防止旧连接的数据干扰: time-wait 状态可以防止旧的连接数据包对新的连接产生干扰。如果一个新连接使用了与之前连接相同的本地端口和远程端口,而旧连接的数据包仍在网络中存在,会导致数据包混淆和冲突。
- 防止在网络中重复分段: time-wait 状态可以防止已经关闭的连接的数据包在网络中重复传输,以避免冲突和不一致性。
- 确保可靠的连接终止: time-wait 状态确保了双方都能够正确地关闭连接,释放资源并清理状态。
需要注意的是,time-wait 状态对系统资源有一定的占用,因此在某些情况下,可能需要调整 TCP 参数,以减少 time-wait 状态的持续时间。例如,可以修改操作系统的 TCP 规则,允许更快地回收 time-wait 状态以释放资源。
总结来说,time-wait 状态是为了确保数据传输的完整性、防止数据干扰和重复分段,并提供可靠的连接终止机制。
HTTP/HTTPS
端口号:HTTP 默认是 80,HTTPS 默认是 443。
URL 前缀:HTTP 的 URL 前缀是 http://
,HTTPS 的 URL 前缀是 https://
。
安全性和资源消耗:HTTP 协议运行在 TCP 之上,所有传输的内容都是明文,客户端和服务器端都无法验证对方的身份。HTTPS 是运行在 SSL/TLS 之上的 HTTP 协议,SSL/TLS 运行在 TCP 之上。所有传输的内容都经过加密,加密采用对称加密,但对称加密的密钥用服务器方的证书进行了非对称加密。所以说,HTTP 安全性没有 HTTPS 高,但是 HTTPS 比 HTTP 耗费更多服务器资源。
HTTPS概念和加密流程
HTTPS(Hypertext Transfer Protocol Secure)是一种通过加密和认证来保护网络通信安全的HTTP协议。与普通的HTTP协议相比,HTTPS在传输过程中使用了SSL(Secure Socket Layer)或TLS(Transport Layer Security)协议来进行数据加密和身份验证,提供了更高的安全性。
HTTPS的加密流程如下:
- 握手过程: 客户端发送一个HTTPS请求到服务器,服务器将自己的数字证书(包含公钥)返回给客户端。客户端收到证书后,会验证其合法性和有效性。
- 证书验证: 客户端会检查服务器返回的数字证书是否由受信任的证书颁发机构(Certificate Authority)签发,以确保服务器身份的合法性。如果证书无效或不受信任,客户端会发出警告,用户可以选择继续或中止连接。
- 生成会话密钥: 客户端验证通过后,生成一个随机的对称密钥(会话密钥),用于加密后续的通信。该密钥使用服务器的公钥进行加密,只有服务器拥有相应的私钥才能解密获得会话密钥。
- 密钥交换: 客户端将使用服务器的公钥加密后的会话密钥发送给服务器。由于会话密钥已经使用服务器的公钥进行了加密,只有服务器的私钥才能解密得到会话密钥。
- 加密通信: 客户端和服务器使用协商好的对称密钥(会话密钥)来加密和解密传输的数据。这样,通信过程中的数据就能够被加密保护,防止被窃听、篡改和伪造。
通过上述加密流程,HTTPS协议实现了数据的保密性和完整性,确保通信双方的身份认证和数据的安全传输。
需要注意的是,为了确保数字证书的可信性,客户端需要内置受信任的证书颁发机构的根证书。因此,证书颁发机构的选择和证书的更新维护对于建立可信的HTTPS连接至关重要。
操作系统
同步和互斥
同步:
同步是指在多个线程或进程之间协调其执行顺序的过程,当多个线程或进程需要共享资源或相互依赖时,需要进行同步操作,以确保它们按照预定的顺序执行,避免出现竟态条件(Race Condition)或其他并发问题。常见的同步机制有互斥量、信号量、条件变量等。
互斥:
互斥是指一次只允许一个线程或进程访问共享资源的机制。当某个线程或进程获得了对共享资源的访问权限时,其他线程或进程必须等待其释放资源后才能访问。互斥机制通过加锁和解锁来控制资源的访问。
常见的互斥机制有互斥量(Mutex)和临界区(Critical Section)
在并发编程中,同步和互斥通常是配合使用的。通过同步机制确保线程或进程的执行顺序,避免数据竞争和并发问题的发生而互斥机制则用于保护共享资源,确保同时只有一个线程或进程能够访问共享资源,避免多个线程或进程同时修改资源导致的数据一致性问题
虚拟内存
操作系统的虚拟内存是一种技术,它允许程序使用比物理内存更大的地址空间。通过虚拟内存,每个程序都可以认为自己独占整个内存空间,而不受物理内存的限制。
虚拟内存的实现依赖于操作系统的内存管理单元(MMU)和硬件机制。下面是虚拟内存的基本思想和工作原理:
- 分页机制:物理内存和虚拟内存被划分为固定大小的页面(通常为4KB或者其他大小)。虚拟内存中的每个页面与物理内存中的一个页面相对应。
- 页面置换:当物理内存不足时,操作系统会将不常用的页面从物理内存移到磁盘上的交换空间中,以腾出空间给其他页面使用。这个过程被称为页面置换。
- 页面映射:操作系统通过页表将虚拟内存中的页面映射到物理内存中的页面。当程序访问虚拟内存时,MMU会根据页表将虚拟地址转换为对应的物理地址。
- 内存保护:通过设置页表项的访问权限,操作系统可以实现对内存的保护。例如,将某些页面标记为只读,以防止程序对其进行写操作。
虚拟内存的好处包括:
- 允许每个程序使用更大的地址空间。
- 提供了内存保护机制,防止程序越界访问内存或者修改其他程序的数据。
- 允许多个程序共享相同的代码和数据,减少物理内存的占用。
- 对于较大的程序,可以将一部分页面置换到磁盘上,从而腾出物理内存供其他程序使用。
操作系统通过虚拟内存管理,提供了更好的内存利用率、更高的系统可靠性和更好的用户体验。但是,虚拟内存也会引入一定的开销,例如页表的维护和页面置换的开销。因此,在设计和使用虚拟内存时需要综合考虑各种因素。
进程、线程、协程
进程: 进程是操作系统中执行的一个程序实例。每个进程都有自己独立的内存空间、执行状态和资源,可以与其他进程并发执行。进程之间通过进程间通信(IPC)来进行数据交换和协调。每个进程都由操作系统分配一个唯一的进程标识符(PID)。
线程: 线程是在进程内执行的一个独立流程。一个进程可以包含多个线程,它们共享进程的地址空间和资源。线程之间可以并发执行,并可以通过共享内存进行通信和数据共享。线程通常比进程更轻量级,创建和销毁线程的开销较小。
协程: 协程是一种轻量级的线程,也被称为用户级线程或纤程。与线程不同,协程是由程序员主动控制和调度的,而非由操作系统调度。协程可以在同一个线程中切换执行,避免了线程上下文切换的开销。协程通常用于处理高并发、事件驱动或协作式任务。
进程和线程
- 资源占用:每个进程都有独立的地址空间和系统资源(如文件句柄、内存等),进程之间相互隔离,各自拥有一份资源副本。而线程是在同一个进程内部执行的,共享相同的地址空间和系统资源。
- 切换开销:切换进程的开销较大,需要保存和恢复进程的上下文,并且可能涉及地址空间的切换。而线程切换的开销相对较小,因为线程共享进程的资源和地址空间,只需切换执行栈和部分寄存器即可。
- 并发性:由于进程之间相互独立,进程可以并发地执行,互不干扰。而线程共享进程的资源,多个线程在同一个进程内可以并发地执行。线程的并发性高于进程,因为线程的切换开销小。
- 通信和同步:进程间通信(IPC)相对复杂,需要通过特定的机制实现(如管道、消息队列、共享内存等)。而线程之间共享进程的地址空间,可以直接读写共享内存,实现通信和同步更加简单高效。
- 可靠性:进程之间相互独立,一个进程的崩溃不会影响其他进程的正常运行。而线程共享进程的地址空间,一个线程的错误可能导致整个进程崩溃。
- 调度:进程是操作系统中的基本调度单位,操作系统可以为每个进程分配时间片并进行调度。而线程是在进程内部进行调度,由进程自身负责线程的创建、销毁和调度。
综上所述,进程和线程在资源占用、切换开销、并发性、通信和同步、可靠性以及调度等方面存在差异。选择使用进程还是线程取决于具体的应用场景和需求。
进程通信
进程通信是指在操作系统中,不同进程之间进行数据和信息交换的方式。以下是几种常见的进程通信方式:
- 管道(Pipe):管道是一种半双工的通信方式,分为匿名管道和有名管道。匿名管道用于具有亲缘关系的父子进程之间的通信,而有名管道可用于无亲缘关系的进程间通信。
- 命名管道(Named Pipe):也称为FIFO(First In, First Out),是一种特殊的文件类型。多个进程可以通过读写FIFO文件实现通信。
- 信号(Signal):信号是一种异步通信机制,用于通知进程发生了特定事件。一个进程可以向另一个进程发送信号,接收到信号的进程可以相应地采取措施。
- 消息队列(Message Queue):消息队列是一种存放在内核中的消息链表,用于进程间的通信。消息队列提供了异步的、有格式的通信方式,进程可以通过消息队列发送和接收消息。
- 共享内存(Shared Memory):共享内存是一种高效的进程通信方式,多个进程可以访问同一块内存区域。进程可以将共享内存映射到它们的地址空间,从而实现数据的直接读写。
- 信号量(Semaphore):信号量是一种用于进程之间同步和互斥的机制。通过使用信号量来控制对共享资源的访问,进程可以实现临界区的同步、互斥和进程间的同步操作。
- 套接字(Socket):套接字是一种通信机制,用于在不同主机上的进程之间进行网络通信。套接字提供了一种可靠的、跨网络的进程间通信方式。
这些是常见的进程通信方式,每种方式都有其适用的场景和特点。在选择进程通信方式时,需要根据具体需求考虑通信的复杂度、性能要求、数据格式等因素。
死锁概念及产生条件
死锁(Deadlock)是指在并发环境中,两个或多个进程(线程)因竞争相互持有的资源而无法继续执行的状态。在死锁状态下,进程永远等待其他进程释放资源,导致系统无法继续正常运行。
死锁通常是由于多个进程同时出现以下四个条件造成的:
-
互斥条件(Mutual Exclusion): 资源一次只能由一个进程使用,当一个进程已经获得了某个资源时,其他进程必须等待,直到该进程释放资源。
-
请求和保持条件(Hold and Wait): 当一个进程获得了某个资源后,仍然持有该资源的同时又请求其他资源,而此时已经获得的资源不会被释放,也不会被其他进程强制抢占。
-
不可剥夺条件(No Preemption): 已经分配给进程的资源不能被强制性地剥夺,只能由进程自己释放。其他进程无法将其抢占,这样就可能导致其他进程等待该资源。
-
循环等待条件(Circular Wait): 多个进程之间形成一个循环等待资源的链,即每个进程都在等待下一个进程所持有的资源,从而形成了一个闭环。
只有同时满足这四个条件时,才会发生死锁。如果其中任何一个条件不满足,死锁就不会发生。
为了避免死锁的发生或解决已经发生的死锁,可以采取以下措施:
- 破坏互斥条件: 可以通过引入共享资源、剖分资源等方式来破坏互斥条件。
- 破坏请求和保持条件: 在申请资源时,要求进程一次性获取所需的全部资源,或者通过预先分配资源来避免死锁。
- 破坏不可剥夺条件: 引入资源剥夺机制,例如设置优先级,当某个进程请求资源无法满足时,可以剥夺它持有的资源。
- 破坏循环等待条件: 可以通过对资源进行排序、编号,要求进程按照一定的顺序获取资源,以避免循环等待。
综上所述,死锁是多个进程因为竞争资源而陷入互相等待的状态。为避免死锁,需要破坏死锁产生的四个必要条件之一。
哈希散列
哈希散列(Hashing)是一种常用的数据存储和查找技术,它通过将键映射到唯一的散列值(hash value)来实现快速的数据访问。然而,哈希散列在范围查找方面存在一些限制,主要有以下几个原因:
- 无序性: 哈希散列是基于散列函数的,它将键映射到散列值,并将散列值作为索引进行存储。由于散列函数的特性,不同键的散列值分布是无序的,无法按照某种顺序进行排列。因此,对于哈希散列来说,范围查找并不直接支持。
- 散列冲突: 在哈希散列中,多个键可能映射到相同的散列值,这就是所谓的散列冲突。散列冲突会导致多个键被存储在同一个散列值所对应的位置上,这种情况下,在范围查找时很难准确地确定键所在的位置范围。
- 散列函数设计: 散列函数的设计目标是尽量将键均匀地映射到散列值空间,以减少冲突的发生。为了满足这个目标,散列函数通常会在键的一部分或全部上进行计算,使得两个相似的键在散列值上有较大的差异。这种设计不适合直接支持范围查找。
尽管哈希散列在范围查找方面有限制,但可以通过其他数据结构或技术来支持范围查找需求,比如使用有序数组、平衡二叉搜索树(如红黑树、AVL树)、B树、跳表等。这些数据结构可以按照某种有序方式存储键,并提供高效的范围查找操作。选择适当的数据结构取决于具体的应用场景和性能要求。
操作系统中断
在操作系统中,中断是指在程序执行期间发生的意外事件或特定条件触发的事件,它会打断程序的正常执行流程,并且通知操作系统需要处理某些特殊情况。中断机制是操作系统与硬件设备进行交互和协调的重要方式之一。
中断可以分为多种类型,每种类型对应不同的事件或情况,例如:
-
硬件中断(Hardware Interrupt): 硬件中断是由计算机硬件设备发出的信号,用于通知操作系统有一个事件需要处理,比如输入输出设备的数据准备好、定时器到达时间等。常见的硬件中断包括时钟中断、键盘中断、鼠标中断等。
-
软件中断(Software Interrupt): 软件中断是由程序主动触发的中断,常通过系统调用(System Call)来实现。通过软件中断,用户程序可以请求操作系统提供某种服务或执行特定的操作,如文件操作、进程管理等。
异常(Exception): 异常是由程序执行期间发生的错误或异常情况引起的中断。它们可能是由于非法的指令、访问受限内存区域、算术溢出等情况引发的。异常提供了一种机制,使操作系统能够捕获并处理这些错误,以防止程序崩溃或系统崩溃。
当中断事件发生时,操作系统会暂停当前正在执行的程序,并保存当前执行状态(如寄存器状态、程序计数器等)。然后,操作系统会根据中断类型确定需要执行的处理程序(中断处理程序或中断服务程序),以处理中断事件。处理程序完成后,操作系统会恢复被中断的程序的执行状态,使其继续执行。
中断机制使得操作系统能够及时响应外部事件、管理硬件设备、提供服务等,同时保证了系统的稳定性和可靠性。它是操作系统实现多任务处理、资源管理和用户交互的基础。
操作系统并发
在操作系统中,并发是指多个任务(进程或线程)在同一时间段内执行的能力。操作系统通过实现并发来使得多个任务能够有效地共享系统资源,并以合理的方式分配处理器时间,从而提高系统的效率和吞吐量。
下面是几个与操作系统并发相关的重要概念:
- 进程(Process): 进程是正在执行的程序的实例。每个进程都有自己的地址空间、程序计数器、寄存器等状态信息。操作系统通过上下文切换来实现多进程之间的切换,使得多个进程能够交替执行。
- 线程(Thread): 线程是进程内的独立执行流。一个进程可以包含多个线程,共享进程的资源,但拥有自己的栈和执行状态。线程的创建和切换比进程的创建和切换更轻量级,因此多线程在并发处理中被广泛使用。
- 并行(Parallelism): 并行是指同时执行多个任务,利用多个处理器核心或多台计算机来加速任务的执行。并行性可以通过多进程或多线程来实现,它需要硬件的支持和操作系统的调度。
- 调度(Scheduling): 调度是操作系统决定哪个任务在某个时间点执行的过程。调度算法决定了任务间的优先级和执行顺序,以最大程度地利用系统资源并满足用户的需求。
- 同步(Synchronization): 同步是指协调多个并发任务之间的执行顺序或操作,以避免数据竞争和不一致的问题。常见的同步机制包括互斥量、信号量和条件变量等。
通过并发,操作系统能够使多个任务在同一时间段内交替执行,提高系统的吞吐量和响应能力。然而,并发也引入了一些挑战,如竞态条件、死锁和资源争用等问题。因此,设计高效的并发控制机制和合理的调度算法对于操作系统来说至关重要。
临界区
在操作系统中,临界区(Critical Section)是指一段代码或程序片段,其中包含对共享资源进行访问或修改的操作。由于多个并发任务可能同时访问共享资源,而且并发执行的顺序是不确定的,所以在临界区内需要采取措施来保证对共享资源的安全访问。
临界区问题是并发编程中一个重要的挑战,如果不加以适当控制,在共享资源上进行的并发访问可能会导致数据竞争、不一致性或其他错误的结果。因此,需要使用同步机制来确保线程或进程在临界区内的互斥访问。
常见的同步机制包括:
- 互斥量(Mutex): 互斥量是一种线程同步机制,通过对临界区进行加锁和解锁的操作来实现互斥访问。只有获取到互斥量的线程才能进入临界区,其他线程需要等待互斥量释放后才能进入。
- 信号量(Semaphore): 信号量是一种计数器,用来控制对临界区的访问权限。它可以允许多个线程同时进入临界区,但需要控制同时进入的线程数量。
- 条件变量(Condition Variable): 条件变量用于线程之间的等待和通知,在进入临界区前检查某个条件,如果条件不满足,则线程将在条件变量上等待,直到其他线程发出通知后再继续执行。
- 读写锁(Read-Write Lock): 读写锁允许对共享资源进行并发读取,但在写操作时需要互斥访问。多个线程可以同时读取共享资源,但只有一个线程可以进行写操作。
以上同步机制都可以用来实现临界区的互斥访问,保证共享资源的安全。在设计临界区时,需要合理地选择和使用适当的同步机制来确保数据的一致性、避免竞态条件和死锁等问题,并提高系统的并发性能和效率。
Redis
Redis为什么快
基于内存实现
- 数据都存储在内存里,减少了一些不必要的 I/O 操作,操作速率很快。
高效的数据结构
-
底层多种数据结构支持不同的数据类型,支持 Redis 存储不同的数据;
-
例如下图就是 Redis 5.0 的 SDS 的数据结构:
-
-
不同数据结构的设计,使得数据存储时间复杂度降到最低。
合理的数据编码
- 根据字符串的长度及元素的个数适配不同的编码格式。
合适的线程模型
- I/O 多路复用模型同时监听客户端连接;
- 单线程在执行过程中不需要进行上下文切换,减少了耗时。
Redis Cluster
Redis Cluster 是 Redis 数据库的分布式解决方案,它被设计用于提供高可用性和扩展性,以处理大规模的数据存储和访问需求。下面是 Redis Cluster 的主要作用:
- 高可用性:Redis Cluster 通过数据的分片和复制来提供高可用性。数据被分散存储在多个节点上,并且每个数据片段都有多个副本。当一个节点故障时,系统可以自动进行故障转移,将故障节点上的数据迁移到其他正常节点上,保证服务的持续性。
- 扩展性:Redis Cluster 允许将数据平均分布到多个节点上,从而实现横向扩展。当数据量增长或负载增加时,可以简单地添加新的节点来扩展整个集群的处理能力,而不需要修改应用程序代码。
- 分布式处理:Redis Cluster 使用哈希槽(hash slot)将数据分散存储在多个节点上。每个节点负责管理一部分哈希槽,通过计算键的哈希值将键值对映射到相应的槽位上。这样,数据的读写请求可以被分发到不同的节点上进行处理,提高了并发处理能力。
- 自动数据迁移:当添加或删除节点时,Redis Cluster 会自动进行数据迁移,将数据从旧节点迁移到新节点上。这样可以实现负载均衡和数据的平衡存储,保证每个节点负载相对均衡。
- 故障检测和恢复:Redis Cluster 通过使用 Gossip 协议来进行节点间的通信,各个节点之间互相交换信息以实现故障检测和快速恢复。当一个节点发生故障时,其他节点会迅速感知到,并且自动进入故障转移流程,确保服务的可用性。
总的来说,Redis Cluster 提供了一个分布式和高可用的解决方案,可以轻松扩展 Redis 数据库的容量和性能,提供稳定可靠的数据存储和访问服务。
SDS
struct sdshdr{
//字符串长度,即buf已用字节的数量
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
}
- SDS 不仅可以保存文本数据,还可以保存二进制数据。因为
SDS
使用len
属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在buf[]
数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。 - SDS 获取字符串长度的时间复杂度是 O(1)。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用
len
属性记录了字符串长度,所以复杂度为O(1)
。 - Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。
跳表
我们知道如果一个数组是有序的,查询的时候可以使用二分法进行查询,时间复杂度可以降到 O(logn) ,但如果链表是有序的,我们仍然是从前往后一个个查找,这样显然很慢,这个时候我们可以使用跳表(Skip list),跳表就是多层链表,每一层链表都是有序的,最下面一层是原始链表,包含所有数据,从下往上节点个数逐渐减少,如下图所示。
跳表的特性:
- 一个跳表有若干层链表组成;
- 每一层链表都是有序的;
- 跳表最下面一层的链表包含所有数据;
- 如果一个元素出现在某一次层,那么该层下面的所有层都必须包含该元素;
- 上一层的元素指向下层的元素必须是相同的;
- 头指针 head 指向最上面一层的第一个元素;
Redis 和 Memcached
共同点:
- 都是基于内存的数据库,一般都用来当做缓存使用。
- 都有过期策略。
- 两者的性能都非常高。
区别:
- Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。
- Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 把数据全部存在内存之中。
- Redis 有灾难恢复机制。 因为可以把缓存中的数据持久化到磁盘上。
- Redis 在服务器内存使用完之后,可以将不用的数据放到磁盘上。但是,Memcached 在服务器内存使用完之后,就会直接报异常。
- Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 目前是原生支持 cluster 模式的。
- Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。 (Redis 6.0 针对网络数据的读写引入了多线程)
- Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。
- Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。
为什么要用 Redis/为什么要用缓存?
下面我们主要从“高性能”和“高并发”这两点来回答这个问题。
1、高性能
假如用户第一次访问数据库中的某些数据的话,这个过程是比较慢,毕竟是从硬盘中读取的。但是,如果说,用户访问的数据属于高频数据并且不会经常改变的话,那么我们就可以很放心地将该用户访问的数据存在缓存中。
这样有什么好处呢? 那就是保证用户下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快。
2、高并发
一般像 MySQL 这类的数据库的 QPS 大概都在 1w 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 10w+,甚至最高能达到 30w+(就单机 Redis 的情况,Redis 集群的话会更高)。
QPS(Query Per Second):服务器每秒可以执行的查询次数;
由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高了系统整体的并发
String 还是 Hash 存储对象数据更好呢?
- String 存储的是序列化后的对象数据,存放的是整个对象。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合。
- String 存储相对来说更加节省内存,缓存相同数量的对象数据,String 消耗的内存约是 Hash 的一半。并且,存储具有多层嵌套的对象时也方便很多。如果系统对性能和资源消耗非常敏感的话,String 就非常适合。
Redis 内存淘汰策略
- noeviction(默认值): 当内存达到限制时,如果没有设置内存淘汰策略,或者将其设置为
noeviction
,则 Redis 将不接受新的写入操作,并返回错误提示。这种策略保持内存使用在限制范围内,但可能导致写入操作失败。 - allkeys-lru: Redis 会优先淘汰最近最少使用的键(LRU 算法)来释放空闲内存。这是 Redis 默认的内存淘汰策略。通过该策略,可以尽量保留最常用的键而淘汰较少使用的键。
- volatile-lru: 类似于
allkeys-lru
策略,但只对设置了过期时间(TTL)的键进行淘汰。这样可以确保不会丢失设置了过期时间的键,同时按照 LRU 算法淘汰不常用的键。 - allkeys-random 和 volatile-random: 这两种策略会随机选择要淘汰的键,无论其使用频率和过期时间如何。这种策略简单而快速,但可能会导致较常用或设置了过期时间的键被淘汰。
- volatile-ttl: Redis 会优先淘汰剩余 TTL(Time To Live)较短的键,即将过期的键。这样可以确保尽量保留剩余存活时间较长的数据。
- volatile-lfu 和 allkeys-lfu: 这两种策略会根据访问频率来淘汰键。LFU(Least Frequently Used)算法会优先淘汰访问频率最低的键,保留访问频率较高的键。
以上是 Redis 的常见内存淘汰策略,每种策略都有不同的适用场景和权衡。可以根据实际需求和数据特性选择合适的策略来平衡内存使用和数据访问效率。
常用的缓存读写策略
Cache Aside Pattern(旁路缓存模式)
Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。
Cache Aside Pattern 中服务端需要同时维系 db 和 cache,并且是以 db 的结果为准。
下面我们来看一下这个策略模式下的缓存读写步骤。
写:
- 先更新 db
- 然后直接删除 cache 。
简单画了一张图帮助大家理解写的步骤。
读 :
- 从 cache 中读取数据,读取到就直接返回
- cache 中读取不到的话,就从 db 中读取数据返回
- 再把数据放到 cache 中。
简单画了一张图帮助大家理解读的步骤。
你仅仅了解了上面这些内容的话是远远不够的,我们还要搞懂其中的原理。
比如说面试官很可能会追问:“在写数据的过程中,可以先删除 cache ,后更新 db 么?”
答案: 那肯定是不行的!因为这样可能会造成 数据库(db)和缓存(Cache)数据不一致的问题。
举例:请求 1 先写数据 A,请求 2 随后读数据 A 的话,就很有可能产生数据不一致性的问题。
这个过程可以简单描述为:
请求 1 先把 cache 中的 A 数据删除 -> 请求 2 从 db 中读取数据->请求 1 再把 db 中的 A 数据更新
当你这样回答之后,面试官可能会紧接着就追问:“在写数据的过程中,先更新 db,后删除 cache 就没有问题了么?”
答案: 理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多。
举例:请求 1 先读数据 A,请求 2 随后写数据 A,并且数据 A 在请求 1 请求之前不在缓存中的话,也有可能产生数据不一致性的问题。
这个过程可以简单描述为:
请求 1 从 db 读数据 A-> 请求 2 更新 db 中的数据 A(此时缓存中无数据 A ,故不用执行删除缓存操作 ) -> 请求 1 将数据 A 写入 cache
现在我们再来分析一下 Cache Aside Pattern 的缺陷。
缺陷 1:首次请求数据一定不在 cache 的问题
解决办法:可以将热点数据可以提前放入 cache 中。
缺陷 2:写操作比较频繁的话导致 cache 中的数据会被频繁被删除,这样会影响缓存命中率 。
解决办法:
- 数据库和缓存数据强一致场景:更新 db 的时候同样更新 cache,不过我们需要加一个锁/分布式锁来保证更新 cache 的时候不存在线程安全问题。
- 可以短暂地允许数据库和缓存数据不一致的场景:更新 db 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。
Read/Write Through Pattern(读写穿透)
Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。
这种缓存读写策略小伙伴们应该也发现了在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入 db 的功能。
写(Write Through):
- 先查 cache,cache 中不存在,直接更新 db。
- cache 中存在,则先更新 cache,然后 cache 服务自己更新 db(同步更新 cache 和 db)。
简单画了一张图帮助大家理解写的步骤。
读(Read Through):
- 从 cache 中读取数据,读取到就直接返回 。
- 读取不到的话,先从 db 加载,写入到 cache 后返回响应。
简单画了一张图帮助大家理解读的步骤。
Read-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。
和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中。
异步缓存写入
Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 db 的读写。
但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。
很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。
这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。
Write Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。
框架
在传统的 Spring 框架中,配置通常使用 XML 文件进行,但是在 Spring Boot 中,推荐使用基于 Java 的配置方式,而不是 XML 文件。这种方式称为基于注解的配置。下面是一些替代 XML 的常见方法:
- 使用注解:Spring Boot 提供了大量的注解,用于简化配置和管理组件之间的依赖关系。例如,使用
@Component
注解来标记一个类作为组件,使用@Autowired
注解进行依赖注入等。 - 使用配置类:可以创建一个专门的配置类,使用
@Configuration
注解标记,并在类中使用@Bean
注解来定义和配置需要的组件。这样可以将原本在 XML 文件中的配置转换为相应的 Java 代码。 - 使用自动配置:Spring Boot 提供了自动配置机制,根据类路径上的依赖自动配置应用程序。只需在项目中添加所需的依赖,并符合约定的目录结构,许多配置都会被自动处理。
- 使用属性文件:Spring Boot 支持使用属性文件(如 application.properties 或 application.yml)来配置应用程序。可以在这些文件中设置各种属性和参数,以及自定义配置。
- 使用注解驱动的 Bean 定义:使用
@Bean
注解,将原本在 XML 中定义的 Bean 转换为通过 Java 代码配置的形式。
通过以上方式,可以以更直观和简洁的方式配置和管理 Spring Boot 应用程序的组件和依赖关系,避免了繁琐的 XML 配置文件。这也是 Spring Boot 框架的设计哲学之一,旨在提供更便捷的开发体验。
autowird注解和resource注解
@Autowired
注解和@Resource
注解是 Java Spring 框架中用于依赖注入的注解,它们有以下区别:
- 来源:
@Autowired
注解是 Spring 框架提供的注解,而@Resource
注解是 Java EE 提供的注解。因此,如果你在使用 Spring 框架的话,通常会选择使用@Autowired
注解。 - 查找方式:
@Autowired
注解默认根据类型(byType)进行查找和装配依赖关系,即根据被注入对象的类型来查找并注入相应的依赖对象;如果存在多个匹配的候选依赖对象,则可以通过@Qualifier
注解或者@Primary
注解来指定具体要注入的对象。而@Resource
注解默认根据名称(byName)进行查找和装配依赖关系,即根据被注入对象的名称与容器中的 bean 名称进行匹配,找到对应的 bean 并进行注入。 - 支持的装配方式:
@Autowired
注解在 Spring 框架中支持通过构造函数、属性、方法参数以及集合类型进行依赖注入。而@Resource
注解在 Java EE 中仅支持通过属性名进行依赖注入,不支持构造函数注入和方法参数注入。 - 注解的包路径:
@Autowired
注解位于org.springframework.beans.factory.annotation.Autowired
包下,而@Resource
注解位于javax.annotation.Resource
包下。
总的来说,@Autowired
注解是 Spring 框架中用于依赖注入的主要注解,更加灵活且功能更强大。而@Resource
注解则是 Java EE 中提供的注解,相对而言功能较为简单,仅支持通过名称进行依赖注入。在使用 Spring 框架时,通常更推荐使用@Autowired
注解。
拦截器和过滤器
拦截器(Interceptor)和过滤器(Filter)都是在Java Web开发中用于对请求进行处理的组件,但它们有不同的工作原理和应用场景。
拦截器是基于面向切面编程(AOP)的概念,通过定义拦截器类来拦截请求,并在请求处理之前、之后或异常发生时执行一些额外的操作。拦截器可以对请求进行精细化的处理,例如身份验证、日志记录、性能监控等。它通常依赖于框架或容器的支持,如Spring MVC框架中的拦截器。
拦截器的工作原理是使用代理模式,通过在目标方法的前后插入特定的逻辑,来实现对请求的拦截和处理。拦截器可以拦截多个请求,并可以根据条件选择是否中断请求的继续执行。
过滤器是基于Servlet规范的一种组件,用于在请求进入Servlet容器之前或响应返回客户端之前对请求或响应进行处理。过滤器操作的是请求和响应的内容,可以修改请求的参数、请求头或响应的内容等。它独立于应用程序框架,运行在Web容器中。常见的应用场景包括字符编码转换、请求参数预处理、请求日志记录等。
过滤器的工作原理是在请求进入Servlet容器之前或响应返回客户端之前,通过定义过滤器类来对请求或响应进行预处理或后处理。过滤器按照配置的顺序依次执行,可以链式调用多个过滤器对请求进行处理。
在比较拦截器和过滤器时,主要的区别如下:
- 触发时机:拦截器是在框架或容器调用方法之前、之后或异常发生时触发,而过滤器是在请求进入Servlet容器之前或响应返回客户端之前触发。
- 应用范围:拦截器通常用于针对特定业务逻辑的处理,如身份验证、日志记录等。过滤器则更为通用,可以对请求和响应进行全局性的处理。
- 依赖关系:拦截器通常依赖于框架或容器的支持,如Spring MVC框架中的拦截器。过滤器独立于应用程序框架,运行在Web容器中,不依赖于特定的框架。
- 灵活度:拦截器可以根据条件中断请求的继续执行,具有更高的灵活度。过滤器则无法中断请求的执行,只能对请求进行处理。
总结来说,拦截器和过滤器在Web开发中都扮演着重要的角色,但拦截器更加侧重于业务逻辑处理和框架支持,而过滤器更加通用、独立于框架,用于对请求和响应进行预处理或后处理。
Spring是如何简化开发
基于POJO的轻量级和最小侵入性编程
- 对象交给容器,框架不会对编写造成影响,只需要核心的点
通过依赖注入和面向接口实现松耦合
- 容器注入对象,源码提供了接口,轻松扩展。
基于切面和惯例进行声明式编程
- 声明式事务
通过切面和模板减少样板式代码
- AOP添加日志
Spring AOP
任何一个系统都是由不同的组件组成的,每个组件负责一块特定的功能,当然会存在很多组件是跟业务无关的,例如日志、事务、权限等核心服务组件,这些核心服务组件经常融入到具体的业务逻辑中,如果我们为每一个具体业务逻辑操作都添加这样的代码,很明显代码冗余太多,因此我们需要将这些公共的代码逻辑抽象出来变成一个切面,然后注入到目标对象(具体业务)中去,AOP正是基于这样的一个思路实现的,通过动态代理的方式,将需要注入切面的对象进行代理,在进行调用的时候,将公共的逻辑直接添加进去,而不需要修改原有业务的逻辑代码,只需要在原来的业务逻辑基础之上做一些增强功能即可。
AOP全称叫做 Aspect Oriented Programming 面向切面编程。它是为解而生的,解耦是程序员编码开发过程中一直追求的境界,AOP在业务类的隔离上,绝对是做到了解耦,在这里面有几个核心的概念:
切面(Aspect):指关注点模块化,这个关注点可能会横切多个对象。事务管理是企业级/ava应用中有关横切关注点的例子。在Spring AOP中,切面可以使用通用类基于模式的方式 (schema-based approach) 或者在普通类中以@Aspect注解 (@Aspect] 注解方式)来实现。
连接点(Join point):在程序执行过程中某个特定的点,例如某个方法调用的时间点或者处理异常的时间点。在SpringAOP中,一个连接点总是代表一个方法的执行
通知(Advice):在切面的某个特定的连接点上执行的动作。通知有多种类型,包括around”“before”and“after"等等。通知的类型将在后面的章节进行讨论。许多AOP框架,包括Spring在内,都是以拦截器做通知模型的,并维护着一个以连接点为中心的拦截器链。
切点(Pointcut):匹配连接点的断言。通知和切点表达式相关联,并在满足这个切点的连接点上运行(例如,当执行某个特定名称的方法时)。切点表达式如何和连接点匹配是AOP的核心: Spring默认使用Aspect切点语义。
引入(Introduction):声明额外的方法或者某个类型的字段。Spring允许引入新的接口(以及一个对应的实现)到任何被通知的对象上。例如,可以使用引入来使bean雾现 ISModified接口,以便简化缓存机制(在Aspectj社区,引入也被称为内部类型声明 (inter))。
目标对象(Target object):被一个或者多个切面所通知的对象。也被称作被通知 (advised)对象。既然SpringAOP是通过运行时代理实现的,那么这个对象永远是一个被代理(proxied)的对象
AOP代理(AOP proxy):AOP框架创建的对象,用来实现切面契约 (aspect contract) (包括通知方法执行等功能)。在Spring中,AOP代理可以是JDK动态代理或CGLIB代理。
织入(Weaving):把切面连接到其它的应用程序类型或者对象上,并创建一个被被通知的对象的过程。这个过程可以在编译时(例如使用Aspect)编译器)、类加载时或运行时中完成。Spring和其他纯/ava AOP框架样,是在运行时完成织入的。
事务传播
spring的事务传播机制是什么?
多个事务方法相互调用时,事务如何在这些方法之间进行传播spring中提供了7中不同的传播特性,来保证事务的正常执行:REQUIRED:默认的传播特性,如果当面没有事务,则新建一个事务,如果当前存在事务,则加入这个事务
REQUIRED_NEW: 创建一个新事务,如果存在当前事务,则挂起改事务
NESTED: 如果当前事务存在,则在嵌套事务中执行,否则REQUIRED的操作一样
SUPPORTS: 当前存在事务,则加入当前事务,如果当前没有事务,则以非事务的方式执行
MANDATORY: 当前存在事务,则加入当前事务,如果当前事务不存在,则抛出异常
NOT_SUPPORTED: 以非事务方式执行,如果存在当前事务,则挂起当前事务
NEVER:不使用事务,如果当前事务存在,则抛出异常
NESTED和REQUIRED NEW的区别:
REQUIRED_NEW是新建一个事务并且新开始的这个事务与原有事务无关,而NESTED则是当前存在事务时会开启一个嵌套事务,在NESTED情况下,父事务回滚时,子事务也会回滚,而REQUIRED_NEW情况下,原有事务回滚,不会影响新开启的事务。
spring框架中使用了哪些设计模式及应用场景
1.工厂模式,在各种BeanFactory以及ApplicationContext创建中都用到了
2.模版模式,在各种BeanFactory以及ApplicationContext实现中也都用到了
3.代理模式,Spring AOP 利用了 Aspect] AOP实现的! Aspect] AOP 的底层用了动态代理
4.策略模式,加载资源文件的方式,使用了不同的方法,比如: ClassPathResourece,FileSystemResource,ServletContextResource,UrlResource但他们都有共同的借口Resource; 在Aop的实现中,采用了两种不同的方式,JDK动态代理和CGLIB代理
5.单例模式,比如在创建bean的时候。
6.观察者模式,spring中的ApplicationEvent,ApplicationListener,ApplicationEventPublisher
7.适配器模式,MethodBeforeAdviceAdapter,ThrowsAdviceAdapter,AfterReturningAdapter
8.装饰者模式,源码中类型带Wrapper或者Decorator的都是
SpringBean生命周期
- Spring 启动,查找并加载需要被 Spring 管理的 Bean,进行 Bean 的实例化;
- Bean 实例化后,对 Bean 的引入和值注入到 Bean 的属性中;
- 如果 Bean 实现了 BeanNameAware 接口的话,Spring 将 Bean 的 Id 传递给 setBeanName() 方法;
- 如果 Bean 实现了 BeanFactoryAware 接口的话,Spring 将调用 setBeanFactory() 方法,将 BeanFactory 容器实例传入;
- 如果 Bean 实现了 ApplicationContextAware 接口的话,Spring 将调用 Bean 的 setApplicationContext() 方法,将 Bean 所在应用上下文引用传入进来;
- 如果 Bean 实现了 BeanPostProcessor 接口,Spring 就将调用它们的postProcessBeforeInitialization() 方法;
- 如果 Bean 实现了 InitializingBean 接口,Spring 将调用它们的 afterPropertiesSet() 方法。类似地,如果 Bean 使用 init-method 声明了初始化方法,该方法也会被调用;
- 如果 Bean 实现了 BeanPostProcessor 接口,Spring 就将调用它们的 postProcessAfterInitialization() 方法;
- 此时,Bean 已经准备就绪,可以被应用程序使用了。它们将一直驻留在应用上下文中,直到应用上下文被销毁;
- 如果 Bean 实现了 DisposableBean 接口,Spring 将调用它的 destory() 接口方法,同样,如果 Bean 使用了 destory-method 声明销毁方法,该方法也会被调用。
三级缓存
一级缓存:用于存储被完整创建了的bean。也就是完成了初始化之后,可以直接被其他对象使用的bean。
二级缓存:用于存储提前暴露的Bean。也就是刚实例化但是还没有进行初始化的Bean。这些Bean只能用于解决循环依赖的问题。因为还没有被初始化,对象里面的数据还不完整,无法被正常使用。所以,只能用于那些需要先持有这个Bean但不会使用这个Bean的对象,也就是正在创建过程中的对象了。
三级缓存:三级缓存存储的是工厂对象。工厂对象可以产生对象提前暴露的引用。在spring中,抽象工厂设计模式用到的地方有很多。我们不妨大胆假设一下它的作用:只有我们真正调用工厂对象的getObject()方法时,才会真正去执行创建对象的逻辑。讲到这里,有的小伙伴可能有点晕了。因为二级缓存就可以直接存储对象提前暴露的引用了。为什么还要一个储存工厂方法的三级缓存。那是因为三级缓存不是针对不同的循环依赖,而是针对有动态代理的循环依赖,同样是在填充属性阶段,如果依赖的是动态代理的对象,那么,我们需要提前暴露的就不是原来刚实例化的对象,而是这个对象的动态代理对象。但是,创建动态代理的成本是很高的,因此,我们使用工厂方法,在真正要获取动态代理对象的时候才去创建对象,将这种开销比较大的任务尽量延迟做,能尽量保证我们的性能。
Spring不能解决构造器的循环依赖
因为构造器是在实例化时调用的,此时bean还没有实例化完成,如果此时出现了循环依赖,一二三级缓存并没有Bean实例的任何相关信息,在实例化之后才放入三级缓存中,因此当getBean的时候缓存并没有命中,这样就抛出了循环依赖的异常了。
原型bean不能解决循环依赖
如果是原型bean,那么就意味着每次都要去创建对象,无法利用缓存;
SPRING在创建BEAN的时候,创建的动态代理
①:如果没有循环依赖的话,在bean初始化完成后创建动态代理
②:如果有循环依赖,在bean实例化之后创建!
ES
是一个基于Lucene框架的搜索引擎产品,提供了resultful风格的操作接口
核心概念:
- 索引 index:类似关系型数据库的table
- 文档 document:row
- 字段 field:列
- 映射 Mapping: 表Schema
- 查询方式 DSL:新版本也支持SQL
- 分片 sharding和副本 replicas:index都是sharding组成的。每个sharding都有一个或多个备份。ES集群健康状态:
ES的使用场景:可以大数据量的搜索场景下有很强大的计算能力。用户画像
中文分词:IK分词器
ES写入数据的原理:
- 客户端发写数据的请求时,可以发任何节点。这个节点就会成为coordinating node协调节点。
- 计算的点文档要写入的分片计算时采用hash取模的方式来计算;
- 协调节点就会进行路由,将请求转发给对应的primary sharding所在的DataNode;
- DataNode节点的primary sharding处理请求,写入数据到索引库,并同步数据到对应的replica sharding
- 等primary sharding和replica sharding都保存好文档之后,返回客户端响应。
查询数据的原理:
- 客户端发请求可发给任意节点,节点就成为了协调节点;
- 协调节点将查询请求广播到每一个数据节点,这些数据节点的分片就会处理该查询请求;
- 每个分片进行数据查询,将符合条件的数据放在一个队列中,并将数据的文档ID、**(倒排索引根据记录找ID)**节点信息、分片信息都返回给协调节点;
- 由协调节点将所有的结果进行汇总排序;
- 协调节点向包含这些文档ID的分片发送get请求,对应的分片将文档数据返回给协调节点,最后协调节点将数据整合返回给客户端;
ES部署时,要如何进行优化?
-
集群部署优化
调整ES的一些重要参数。path.data目录尽量使用SSD
关于ES的参数,大部分情况下是不需要调优的,如有性能问题,最好的办法是安排更合理的sharding布局并且增加节点数量。
-
更合理的sharding布局
让sharding和对应的replica sharding尽量在同一个机房。
-
Linux服务器的优化策略
不需要root用户;修改虚拟内存的大小;修改普通用户可以创建的最大线程数。
场景题
秒杀系统
秒杀系统主要是有三个特点高性能、高并发、高可用。
从一次秒杀的流程出发,考虑秒杀系统的三个特点,那么就可以设计一个秒杀系统。
- 秒杀页面获取
优化方案:
-
动静分离。将页面的静态资源等部署到Nginx或者CDN,这样可以加快秒杀页面获取。
-
静态资源合并获取。通过将多个请求合并为单个请求,一次获取多个静态资源,这样可以加快秒杀页面获取。
-
服务降级。秒杀页面做服务降级处理,将商品推荐列表、评论等做降级处理,少显示或者不显示。秒杀页面需要登录才能查看,对未登录用户直接返回登录界面。
-
服务监控。对流量进行监控,使用令牌桶算法等限流算法对流量进行控制。有必要时将部分任务进行熔断。
-
页面数据缓存。将页面数据缓存到Redis中,减少数据库操作。
-
秒杀连接加盐。使URL动态化,可以减少非法用户操作。
-
商品下单
优化方案:
-
前端/后端限流。前端/客户端防抖。限制时间间隔内的下单次数。
-
防机器人刷单。对下单操作增加填写验证码步骤,如:55+44=?、“你好”的小写拼音、选出所有飞机等问题,将非法请求过滤掉。
-
商品下单预扣库存。数据库表设计的时候需要设置锁库存字段。进行秒杀的时候,减少库存将在Redis中使用分布式锁进行操作。其它后续操作可以使用RabbitMQ进行操作。
-
商品下单预扣库存(库存预热)可以添加延时队列。将超时商品转发到死信路由,然后进行操作。
-
商品下单可以进行异步操作,如双次验价等操作可以使用多线程。
-
支付
优化方案:
- 将支付划分为一个单独的系统,只开放对应的支付接口。因为支付系统是金融敏感的,所以应该保证支付系统的高可用。
- 回滚机制。建议使用分布式事务,对支付业务进行TCC事务,因为支付系统是金融敏感的。
于是,秒杀系统一般会引入MQ、Redis、MySQL、Nginx等中间件,需要对每个中间件进行高性能、高并发、高可用的分析。
MQ
优化方案:
- 集群部署。MQ系统一般都是集群部署的,进行镜像集群部署,可以提升系统的可用性。
- 开启持久化。对MQ系统中的信息开启持久化,将其刷到硬盘内,防止宕机。
- 关闭消费自动ACK,需要进行手动ACK。防止信息消费异常。
Redis
优化方案:
- Redis进行读写分离,Master节点进行写操作,其他节点进行读操作。
- Redis进行哨兵部署,让某一个节点宕机后可以迅速有机器顶替上。
- Redis进行分片集群部署,让请求分布到每一台Redis机器上。
- 开启持久化日志。AOF和RDB根据业务状况进行调整。
- 一个系统可以有多个Redis集群,例如页面数据和商品下单两个方面的Redis可以用多个集群的Redis。
MySQL
优化方案:
- 根据业务建立索引。唯一索引、普通索引、联合索引等。
- 看业务是否有优化的地方,减少回表操作。
- 分库分表。MySQL应该进行集群部署,单台Redis一般只有2000QPS左右。
- 分库。使用MyCat或者ShardingSphere等进行分库,将操作通过算法分配到相对应的机器上面。
- 分表。分表有垂直划分和水平划分两种。垂直划分是将部分字段分割到其它表上面。水平划分是将数据水平划分到同一数据库中的不同表上面,避免一个表上面的数据过大。
- 一般来说,建议分32个库,每个库分32张表,这样完全能够满足大部分企业的需求。
- MySQL的瓶颈是磁盘IO,可以更换固态硬盘。
Nginx
优化方案:
- 动静分离。将静态资源部署到Nginx中,无需到其它中间件中查询。
- Nginx可以开启限流操作。令牌桶和露铜算法都支持。
- Nginx开启负载均衡,将服务请求打到不同的服务器上,降低单台服务器压力。
除了上面列出来的,还有很多的优化操作。
热点数据分离
热点商品和普通商品使用的系统可以隔离开来,这样即使秒杀系统宕机了,普通的商品下单也不会有任何问题。
- 秒杀商品放到热点数据系统内。
- 直播商品也可以放到热点数据系统内。
- 流量监控。可以将下单比较多的商品放到热点数据系统内。
- 商家上报。商家可以将未来可能售卖较多的商品上报,放到热点数据系统内。
- 数据分析。分析以往数据,得出一些未来可能售卖较多的商品,放到热点数据系统内。
性能优化
最后可以进行机器上面的性能优化。
- 更换CPU
- 更换内存
- 更换速度更快的硬盘
- 更新Linux系统内核
- 更新软件系统稳定版本
- 关闭Linux上面一些无用的服务
设计模式
软件的7大原则
1、开闭原则
开闭原则的含义是:当应用的需求改变时,在不修改软件实体的源代码或者二进制代码的前提下,可以扩展模块的功能,使其满足新的需求。
2、里氏替换原则
子类可以扩展父类的功能,但不能改变父类原有的功能。也就是说:子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。
3、依赖倒置原则
依赖倒置原则的原始定义为:高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。其核心思想是:要面向接口编程,不要面向实现编程。
4、单一职责原则
单一职责原则规定一个类应该有且仅有一个引起它变化的原因,否则类应该被拆分。
单一职责同样也适用于方法。一个方法应该尽可能做好一件事情。如果一个方法处理的事情太多,其颗粒度会变得很粗,不利于重用。
5、接口隔离原则
接口隔离原则要求程序员尽量将臃肿庞大的接口拆分成更小的和更具体的接口,让接口中只包含客户感兴趣的方法。要为各个类建立它们需要的专用接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。
6、迪米特法则
如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。
7、合成复用原则
要求在软件复用时,要尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。如果要使用继承关系,则必须严格遵循里氏替换原则。合成复用原则同里氏替换原则相辅相成的,两者都是开闭原则的具体实现规范。
创建型模式
- 简单工厂模式(Simple Factory Pattern):通过一个静态方法或工厂类来创建实例,隐藏了对象创建的复杂度。
- 工厂方法模式(Factory Method Pattern):定义一个创建对象的接口,让子类决定实例化哪一个类,将对象的实例化延迟到子类中进行。
- 抽象工厂模式(Abstract Factory Pattern):提供一个接口,用于创建一系列相关或相互依赖的对象,而不需要指定它们的具体类。
- 数据库连接和数据库命令接口组成数据库接口规则,不同的数据库连接去实现他
- 单例模式(Singleton Pattern):保证一个类只有一个实例,并提供全局访问点。
- 数据库连接池
- 建造者模式(Builder Pattern):将一个复杂对象的构建过程分解为若干个简单的步骤,使得同样的构建过程可以创建不同的表示。
结构型模式
- 配器模式(Adapter Pattern):将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的类可以一起工作。
- 桥接模式(Bridge Pattern):将抽象部分和实现部分分离开来,使它们可以独立变化。
- 组合模式(Composite Pattern):将对象组合成树形结构来表示“部分-整体”的层次结构。
- 装饰器模式(Decorator Pattern):动态地将责任附加到对象上,是一种比继承更加灵活的扩展方式。
- 外观模式(Facade Pattern):为一组复杂的子系统提供一个一致的接口,使得子系统更容易被使用。
- 享元模式(Flyweight Pattern):运用共享技术来有效地支持大量细粒度的对象。
- 代理模式(Proxy Pattern):为其他对象提供一种代理以控制对这个对象的访问。
行为型模式
- 责任链模式(Chain of Responsibility Pattern):将请求的发送者和接收者解耦,使多个对象都有机会处理这个请求。
- 命令模式(Command Pattern):将请求封装成一个对象,使得可以用不同的请求来参数化其他对象,同时也支持撤销操作。
- 解释器模式(Interpreter Pattern):给定一个语言,定义它的文法的一种表示,并定义一个解释器,该解释器使用该表示来解释语言中的句子。
- 迭代器模式(Iterator Pattern):提供一种方法访问一个容器对象中各个元素,而又不需要暴露该对象的内部细节。
- 中介者模式(Mediator Pattern):用一个中介对象来封装一系列的对象交互,使得各对象不需要显式地互相引用,从而降低耦合度。
- 备忘录模式(Memento Pattern):在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便以后可以将对象恢复到原先保存的状态。
- 观察者模式(Observer Pattern):定义对象之间的一种一对多的依赖关系,使得每当一个对象状态发生改变时,所有依赖于它的对象都得到通知并自动更新。
- 状态模式(State Pattern):允许一个对象在其内部状态发生改变时改变它的行为。
- 策略模式(Strategy Pattern):定义一系列的算法,将每个算法封装起来,并使它们可以互换。
- 模板方法模式(Template Method Pattern):定义一个操作中的算法骨架,将一些步骤延迟到子类中,使得子类可以不改变算法结构的情况下重新定义该算法的某些特定步骤。
- 访问者模式(Visitor Pattern):在不改变对象结构的前提下,定义作用于对象结构中的各元素的操作,使得可以在不改变各元素的类的前提下定义作用于这些元素的新操作。
分布式事务
分布式事务是指在分布式系统中跨多个数据库或服务进行的事务操作。在传统的单节点事务处理中,事务要么全部执行成功,要么全部回滚。而在分布式系统中,由于数据存储在不同的节点上,事务的一部分操作可能在一个节点上执行成功,但在其他节点上失败,导致整体事务的一致性受到挑战。
为了保证分布式系统的数据一致性,需要引入分布式事务管理机制。其中,最经典和常用的分布式事务管理协议是两阶段提交(Two-Phase Commit,2PC)协议。下面是两阶段提交协议的工作原理:
- 准备阶段(Prepare Phase):
- 协调者(Coordinator)向所有参与者(Participants)发送事务准备请求。
- 参与者执行事务的操作,并将操作结果和准备就绪(Prepared)或者中断(Aborted)状态反馈给协调者。
- 提交阶段(Commit Phase):
- 协调者根据参与者的反馈情况决定是否提交或者中断事务。
- 若所有参与者都准备就绪,则协调者向所有参与者发送事务提交请求。
- 参与者收到提交请求后,将事务进行提交并释放相关资源,然后向协调者发送确认提交的消息。
- 最后,协调者收到所有参与者的确认消息后,宣布事务提交成功。
两阶段提交协议的优点是能够保证在分布式环境中的事务一致性,确保所有参与者都要么提交,要么回滚。然而,该协议也存在一些问题:
- 阻塞问题:在等待参与者响应的时候,整个系统的资源可能被锁定,导致延迟和性能下降。
- 单点故障:若协调者节点发生故障,会导致整个事务无法进行提交或回滚。
- 数据不一致问题:即使参与者在准备阶段返回了准备就绪状态,但在提交阶段失败时,可能会出现数据不一致的情况。
为了解决上述问题,还有其他的分布式事务管理方案,如三阶段提交(Three-Phase Commit,3PC)、基于消息的事务补偿机制(Saga)等。这些方案根据实际需求和系统特点,提供了不同的权衡和解决方案。
总之,分布式事务是保证分布式系统数据一致性的重要机制,通过使用适当的分布式事务管理协议,可以确保事务的正确执行和数据的一致性。
程序排查
当程序出现问题时,我们可以按照以下排查流程逐步分析和解决问题:
- 确定问题的现象:仔细观察程序的错误行为,记录下具体的错误信息、异常堆栈轨迹或其他异常现象。这有助于理解问题的本质。
- 追踪错误位置:根据错误信息或异常堆栈轨迹,确定问题发生的代码位置。通常,异常信息会指示具体的类、方法和行号。
- 检查错误原因:仔细审查错误发生的位置,检查可能导致问题的原因。这可能包括逻辑错误、边界条件错误、变量未初始化、循环或条件错误等。
- 使用调试工具:使用调试工具(如IDE集成的调试器)来逐步执行代码并观察变量的值和程序的运行流程。通过观察程序执行的状态,可以找到错误的具体原因。
- 检查输入和输出:检查程序的输入和输出是否符合预期。确保输入数据的正确性,以及程序的输出是否满足预期的逻辑或业务需求。
- 日志记录:在关键的代码段或发生错误的地方添加日志记录语句,记录程序执行的关键信息。这有助于追踪程序的执行流程和数据状态,并可以帮助找到问题的所在。
- 单元测试:编写针对问题代码的单元测试,验证代码的正确性。通过单元测试可以更快地定位问题,并确保修复后不会再次出现类似的问题。
- 与他人交流:如果以上步骤无法解决问题,可以与团队成员、论坛或社区寻求帮助。描述问题的细节、提供相关代码和错误信息,以便其他人可以理解并给出有效的建议。
请注意,在排查问题时需要耐心和仔细,逐步分析每个可能的原因,并进行实验和验证。及时记录和整理解决问题的经验,有助于提高自己的调试能力。