分为三类Java基础知识,集合,JVM,多线程并发相关以及Spring,SpringBoot,SpringCloud分布式了解.
操作系统与计算机网络关键知识
进程线程
资源分配和独立性,进程是操作系统资源分配的基本单位,进程拥有独立的内存空间,比如堆,栈,代码段以及文件句柄等,进程之间相互隔离,一个进程崩溃不会影响其他进程.
线程是CPU调度基本单位,属于进程内部执行流,同一进程下多个线程共享进程的内存和资源(全局变量、文件描述符).
在创建与切换开销方面,每个进程都有独立的代码和数据空间,程序切换会有较大开销(内存页表,寄存器).同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换开销较小.
通信与同步机制上,进程之间通信需要复杂机制,需要管道、消息队列、共享内存等,由操作系统支持,而线程之间可以直接通过tong一进程的共享变量通信,但需要相关的锁、信号量机制进行同步.
进程、线程与协程
- 进程是操作系统中进行资源分配和调度的基本单位,它拥有自己的独立内存空间和系统资源。每个进程都有独立的地址空间,不与其他进程共享。进程间通信需要通过特定的机制,如管道、消息队列、信号量等。由于进程拥有独立的内存空间,因此其稳定性和安全性相对较高,但同时上下文切换的开销也较大,因为需要保存和恢复整个进程的状态。
- 接下来是线程。线程是进程内的一个执行单元,也是CPU调度和分派的基本单位。与进程不同,线程共享进程的内存空间,包括堆和全局变量。线程之间通信更加高效,因为它们可以直接读写共享内存。线程的上下文切换开销较小,因为只需要保存和恢复线程的上下文,而不是整个进程的状态。然而,由于多个线程共享内存空间,因此存在数据竞争和线程安全的问题,需要通过同步和互斥机制来解决。
最后是协程。协程是一种用户态的轻量级线程,其调度完全由用户程序控制,而不需要内核的参与。协程拥有自己的寄存器上下文和栈,但与其他协程共享堆内存。协程的切换开销非常小,因为只需要保存和恢复协程的上下文,而无需进行内核级的上下文切换。这使得协程在处理大量并发任务时具有非常高的效率。然而,协程需要程序员显式地进行调度和管理,相对于线程和进程来说,其编程模型更为复杂
多线程比单线程的优势:提高程序的运行效率,可以充分利用多核处理器的资源,同时处理多个任务,加快程序的执行速度。
- 多线程比单线程的劣势:存在多线程数据竞争访问的问题,需要通过锁机制来保证线程安全,增加了加锁的开销,并且还会有死锁的风险。多线程会消耗更多系统资源,如CPU和内存,因为每个线程都需要占用一定的内存和处理时间
多线程不一定越多越好,过多的线程可能会导致一些问题。
- 切换开销:线程的创建和切换会消耗系统资源,包括内存和CPU。如果创建太多线程,会占用大量的系统资源,导致系统负载过高,某个线程崩溃后,可能会导致进程崩溃。
- 死锁的问题:过多的线程可能会导致竞争条件和死锁。竞争条件指的是多个线程同时访问和修改共享资源,如果没有合适的同步机制,可能会导致数据不一致或错误的结果。而死锁则是指多个线程相互等待对方释放资源,导致程序无法继续执行
- 进程切换:进程切换涉及到更多的内容,包括整个进程的地址空间、全局变量、文件描述符等。因此,进程切换的开销通常比线程切换大。
- 线程切换:线程切换只涉及到线程的堆栈、寄存器和程序计数器等,不涉及进程级别的资源,因此线程切换的开销较小
线程切换比进程切换快是因为线程共享同一进程的地址空间和资源,线程切换时只需切换堆栈和程序计数器等少量信息,而不需要切换地址空间,避免了进程切换时需要切换内存映射表等大量资源的开销,从而节省了时间和系统资源
线程切换的详细过程可以分为以下几个步骤:
- 上下文保存:当操作系统决定切换到另一个线程时,它首先会保存当前线程的上下文信息。上下文信息包括寄存器状态、程序计数器、堆栈指针等,用于保存线程的执行状态。
- 切换到调度器:操作系统将执行权切换到调度器(Scheduler)。调度器负责选择下一个要执行的线程,并根据调度算法做出决策。
- 上下文恢复:调度器选择了下一个要执行的线程后,它会从该线程保存的上下文信息中恢复线程的执行状态。
- 切换到新线程:调度器将执行权切换到新线程,使其开始执行。
上下文信息的保存通常由操作系统负责管理,具体保存在哪里取决于操作系统的实现方式。一般情况下,上下文信息会保存在线程的控制块(Thread Control Block,TCB)中。
TCB是操作系统用于管理线程的数据结构,包含了线程的状态、寄存器的值、堆栈信息等。当发生线程切换时,操作系统会通过切换TCB来保存和恢复线程的上下文信息。
进程的五种状态
- NULL -> 创建状态:一个新进程被创建时的第一个状态;
- 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的;
- 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;
- 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理;
- 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行;
- 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件;
- 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态
进程通信方式
Linux 内核提供了不少进程间通信的方式:
- 管道
- 消息队列
- 共享内存
- 信号
- 信号量
- socket
Linux 内核提供了不少进程间通信的方式,其中最简单的方式就是管道,管道分为「匿名管道」和「命名管道」。
匿名管道顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「|」竖线就是匿名管道,通信的数据是无格式的流并且大小受限,通信的方式是单向的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道,再来匿名管道是只能用于存在父子关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。
命名管道突破了匿名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。
消息队列克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。
共享内存可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,它直接分配一个共享空间,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。
那么,就需要信号量来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。信号量不仅可以实现访问的互斥性,还可以实现进程间的同步,信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作。
与信号量名字很相似的叫信号,它俩名字虽然相似,但功能一点儿都不一样。信号是异步通信机制,信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令),一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SIGSTOP,这是为了方便我们能在任何时候结束或停止某个进程。
前面说到的通信机制,都是工作于同一台主机,如果要与不同主机的进程间通信,那么就需要 Socket 通信了。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。
管道在Linux中有两种方式:匿名管道和命名管道。
- 匿名管道:是一种在父子进程或者兄弟进程之间进行通信的机制,只能用于具有亲缘关系的进程间通信,通常通过pipe系统调用创建。
命名管道:是一种允许无关的进程间进行通信的机制,基于文件系统,可以在不相关的进程之间进行通信。
信号:一种处理异步事件的方式。信号是比较复杂的通信方式,用于通知接收进程有某种事件发生,除了用于进程外,还可以发送信号给进程本身。
- 信号量:进程间通信处理同步互斥的机制。是在多线程环境下使用的一种设施,它负责协调各个线程,以保证它们能够正确,合理的使用公共资源。
共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。
进程的上下文切换到底是切换什么呢?
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,
线程间的通信方式
Linux系统提供了五种用于线程通信的方式:互斥锁、读写锁、条件变量、自旋锁和信号量。
- 互斥锁(Mutex):互斥量(mutex)从本质上说是一把锁,在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量上的锁。对互斥量进行加锁以后,任何其他试图再次对互斥锁加锁的线程将会阻塞直到当前线程释放该互斥锁。如果释放互斥锁时有多个线程阻塞,所有在该互斥锁上的阻塞线程都会变成可运行状态,第一个变为运行状态的线程可以对互斥锁加锁,其他线程将会看到互斥锁依然被锁住,只能回去再次等待它重新变为可用。
- 条件变量(Condition Variables):条件变量(cond)是在多线程程序中用来实现”等待—》唤醒”逻辑常用的方法。条件变量利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待”条件变量的条件成立”而挂起;另一个线程使“条件成立”。为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起。线程在改变条件状态前必须首先锁住互斥量,函数pthread_cond_wait把自己放到等待条件的线程列表上,然后对互斥锁解锁(这两个操作是原子操作)。在函数返回时,互斥量再次被锁住。
- 自旋锁(Spinlock):自旋锁通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。一般加锁的过程,包含两个步骤:第一步,查看锁的状态,如果锁是空闲的,则执行第二步;第二步,将锁设置为当前线程持有;使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
- 信号量(Semaphores):信号量可以是命名的(有名信号量)或无名的(仅限于当前进程内的线程),用于控制对资源的访问次数。通常信号量表示资源的数量,对应的变量是一个整型(sem)变量。另外,还有两个原子操作的系统调用函数来控制信号量的,分别是:P 操作:将 sem 减 1,相减后,如果 sem < 0,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞;V 操作:将 sem 加 1,相加后,如果 sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;
- 读写锁(Read-Write Locks):读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。所以,读写锁适用于能明确区分读操作和写操作的场景。读写锁的工作原理是:当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。知道了读写锁的工作原理后,我们可以发现,读写锁在读多写少的场景,能发挥出优势。
进程调度策略
非抢占式的先来先服务(*First Come First Severd, FCFS*)算法。先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。 FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。
最短作业优先(*Shortest Job First, SJF*)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。这显然对长作业不利,很容易造成一种极端现象。
比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
高响应比优先 (*Highest Response Ratio Next, HRRN*)调度算法主要是权衡了短作业和长作业。
每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:
从上面的公式,可以发现:
- 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行;
- 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会;
时间片轮转(*Round Robin, RR*)调度算法**,每个进程被分配一个时间段,称为时间片(*Quantum*),即允许该进程在该时间段中运行。**
- 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程;
- 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;
另外,时间片的长度就是一个很关键的点:
- 如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;
- 如果设得太长又可能引起对短作业进程的响应时间变长。将
最高优先级调度算法
希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(*Highest Priority First,HPF*)调度算法。 进程的优先级可以分为,静态优先级或动态优先级:
- 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
- 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。
该算法也有两种处理优先级高的方法,非抢占式和抢占式:
- 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
- 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。
但是依然有缺点,可能会导致低优先级的进程永远不会运行。
多级反馈队列调度算法
多级反馈队列(*Multilevel Feedback Queue*)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。
顾名思义:
- 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
- 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;
设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短;
- 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
- 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行; 可以发现,对于短作业可能可以在第一级队列很快被处理完。
对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。
用户态与内核态区别
用户态和内核态是操作系统两种运行模式,差别是权限和可执行的操作:
内核态可以访问所有指令和所有硬件资源,这种模式下操作具有更高权限,用于内核运行
用户态cpu只能执行部分指令,无法直接访问硬件资源,操作权限较低,主要用于运行用户程序.
内核态的底层操作主要包括:内存管理、进程管理、设备驱动程序控制、系统调用等。这些操作涉及到操作系统的核心功能,需要较高的权限来执行。
分为内核态和用户态的原因主要有以下几点:
- 安全性:通过对权限的划分,用户程序无法直接访问硬件资源,从而避免了恶意程序对系统资源的破坏。
- 稳定性:用户态程序出现问题时,不会影响到整个系统,避免了程序故障导致系统崩溃的风险。
- 隔离性:内核态和用户态的划分使得操作系统内核与用户程序之间有了明确的边界,有利于系统的模块化和维护。
内核态和用户态的划分有助于保证操作系统的安全性、稳定性和易维护性
在操作系统中,用户态和内核态之间的切换通常有三种方式。
第一种是通过系统调用。当应用程序需要访问一些受限资源,比如文件操作、网络访问或进程管理时,它会执行系统调用,触发 CPU 切换到内核态。内核会根据中断向量表找到对应的内核函数并执行,完成后再返回用户态,继续运行用户程序。
第二种是异常处理。当程序在用户态中发生非法操作,例如除零错误或者访问非法内存时,会触发异常,CPU 会进入内核态,保存当前状态并查找异常处理程序。异常处理完毕后,系统会根据情况返回用户态,或者如果错误严重,则终止进程。
最后一种是硬件中断。当硬件设备完成某些任务,比如磁盘读写、网络数据接收或定时器中断时,硬件会向 CPU 发送中断信号,迫使 CPU 进入内核态进行处理。处理完中断后,系统会恢复到用户态,继续执行原来的程序。
锁
为什么并发执行要加锁
并发执行线程需要加锁主要是为了保护共享数据,防止出现”竞态条件”。
“竞态条件”是指当多个线程同时访问和操作同一块数据时,最终结果依赖于线程的执行顺序,这可能导致数据的不一致性。
通过加锁,我们可以确保在任何时刻只有一个线程能够访问共享数据,从而避免”竞态条件”,确保数据的一致性和完整性。
自旋锁是什么?应用在哪些场景?
自旋锁加锁失败后,线程会忙等待,直到它拿到锁。
自旋锁是通过 CPU 提供的 CAS
函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
一般加锁的过程,包含两个步骤:
- 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
- 第二步,将锁设置为当前线程持有;
CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。
比如,设锁为变量 lock,整数 0 表示锁是空闲状态,整数 pid 表示线程 ID,那么 CAS(lock, 0, pid) 就表示自旋锁的加锁操作,CAS(lock, pid, 0) 则表示解锁操作。
使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while
循环等待实现,不过最好是使用 CPU 提供的 PAUSE
指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系,我们需要清楚的知道这一点。
自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。
如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。
死锁发生条件以及如何避免
死锁只有同时满足以下四个条件才会发生:
- 互斥条件:互斥条件是指多个线程不能同时使用同一个资源。
- 持有并等待条件:持有并等待条件是指,当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2 已经被线程 C 持有了,所以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1。
- 不可剥夺条件:不可剥夺条件是指,当线程已经持有了资源 ,在自己使用完之前不能被其他线程获取,线程 B 如果也想使用此资源,则只能在线程 A 使用完并释放后才能获取。
- 环路等待条件:环路等待条件指的是,在死锁发生的时候,两个线程获取资源的顺序构成了环形链
避免死锁问题就只需要破环其中一个条件就可以,最常见的并且可行的就是使用资源有序分配法,来破环环路等待条件。
那什么是资源有序分配法呢?线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。
打破互斥条件:将资源设计为可共享的(但很多资源无法做到)。
打破请求与保持条件:进程在开始时一次性申请所有需要的资源,或者在申请新资源时,必须释放所有已持有的资源。
打破不可抢占条件:当一个进程/线程申请新资源失败时,强制释放其已有的资源。
打破循环等待条件:对资源进行有序编号,要求进程/线程按照递增的顺序申请资源
银行家算法解决死锁
银行家算法的核心思想,就是在分配给进程资源前,首先判断这个进程的安全性,也就是预执行,判断分配后是否产生死锁现象。如果系统当前资源能满足其执行,则尝试分配,如果不满足则让该进程等待。
通过不断检查剩余可用资源是否满足某个进程的最大需求,如果可以则加入安全序列,并把该进程当前持有的资源回收;不断重复这个过程,看最后能否实现让所有进程都加入安全序列。安全序列一定不会发生死锁,但没有死锁不一定是安全序列。
乐观锁与悲观锁
乐观锁:
- 基本思想:乐观锁假设多个事务之间很少发生冲突,因此在读取数据时不会加锁,而是在更新数据时检查数据的版本(如使用版本号或时间戳),如果版本匹配则执行更新操作,否则认为发生了冲突。
- 使用场景:乐观锁适用于读多写少的场景,可以减少锁的竞争,提高并发性能。例如,数据库中的乐观锁机制可以用于处理并发更新同一行数据的情况。
悲观锁:
- 基本思想:悲观锁假设多个事务之间会频繁发生冲突,因此在读取数据时会加锁,防止其他事务对数据进行修改,直到当前事务完成操作后才释放锁。
- 使用场景:悲观锁适用于写多的场景,通过加锁保证数据的一致性。例如,数据库中的行级锁机制可以用于处理并发更新同一行数据的情况。
乐观锁适用于读多写少的场景,通过版本控制来处理冲突;而悲观锁适用于写多的场景,通过加锁来避免冲突。
乐观锁并不关心读取的数据是否最新,它关心的是在提交时能否成功。当线程 A 最终完成购买操作并尝试提交时,数据库会发现版本号不匹配(v1
!= v2
),从而拒绝线程 A 的更新。
这种设计使得乐观锁非常适合读多写少的场景。它用“失败-重试”的机制来处理小概率的冲突,避免了对读操作加锁带来的性能损耗。
内存管理
操作系统内存管理
操作系统设计了虚拟内存,每个进程都有自己的独立的虚拟内存,用户所写的程序不会直接与物理内存打交道。
有了虚拟内存之后,它带来了这些好处:
- 第一,虚拟内存可以使得进程的运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
- 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
- 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。
Linux 是通过对内存分页的方式来管理内存,分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。
虚拟地址与物理地址之间通过页表来映射,页表是存储在内存里的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的工作。而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
- 虚拟内存:是操作系统提供给每个运行中程序的一种地址空间,每个程序在运行时认为自己拥有的内存空间就是虚拟内存,其大小可以远远大于物理内存的大小。虚拟内存通过将程序的地址空间划分成若干个固定大小的页或段,并将这些页或者段映射到物理内存中的不同位置,从而使得程序在运行时可以更高效地利用物理内存。
- 物理内存:物理内存是计算机实际存在的内存,是计算机中的实际硬件部件.
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。
虚拟地址与物理地址之间通过页表来映射
页表是存储在内存里的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的工作。
而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
内存分页由于内存空间都是预先划分好的,也就不会像内存分段一样,在段与段之间会产生间隙非常小的内存,这正是分段会产生外部内存碎片的原因。而采用了分页,页与页之间是紧密排列的,所以不会有外部碎片。
但是,因为内存分页机制分配内存的最小单位是一页,即使程序不足一页大小,我们最少只能分配一个页,所以页内会出现内存浪费,所以针对内存分页机制会有内部内存碎片的现象。
在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址
所以虚拟内存地址通过页号和页内偏移,先利用页号通过页表找到对应物理页号,直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
虚拟地址也可以通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,
虚拟地址如何转化为物理地址的
虚拟地址转化为物理地址是通过内存管理单元(Memory Management Unit,MMU)来完成的。MMU是计算机系统中的硬件组件,负责虚拟地址和物理地址之间的转换。
在虚拟地址转换的过程中,通常会使用页表(Page Table)来进行映射。页表是一种数据结构,它将虚拟地址空间划分为固定大小的页(Page),对应于物理内存中的页框(Page Frame)。每个页表项(Page Table Entry)记录了虚拟页和物理页的对应关系。
当程序访问一个虚拟地址时,MMU会将虚拟地址分解为页号和页内偏移量。然后,MMU会查找页表,根据页号找到对应的页表项。页表项中包含了物理页的地址或页框号。最后,MMU将物理页的地址与页内偏移量组合,得到对应的物理地址。
虚拟地址转化为物理地址的过程中,还可能涉及到多级页表、TLB(Translation Lookaside Buffer)缓存等机制,以提高地址转换的效率。
程序内存布局
用户空间内存,从低到高分别是 6 种不同的内存段:
- 代码段,包括二进制可执行代码;
- 数据段,包括已初始化的静态常量和全局变量;
- BSS 段,包括未初始化的静态变量和全局变量;
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 文件映射段,包括动态库、共享内存等;
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是
8 MB
。当然系统也提供了参数,以便我们自定义大小。
代码段下面还有一段内存空间的(灰色部分),这一块区域是「保留区」,之所以要有保留区这是因为在大多数的系统里,比较小数值的地址不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL。因此,这里会出现一段不可访问的内存保留区,防止程序因为出现 bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。
在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc()
或者 mmap()
,就可以分别在堆和文件映射段动态分配内存。
堆和栈的区别
- 分配方式:堆是动态分配内存,由程序员手动申请和释放内存,通常用于存储动态数据结构和对象。栈是静态分配内存,由编译器自动分配和释放内存,用于存储函数的局部变量和函数调用信息。
- 内存管理:堆需要程序员手动管理内存的分配和释放,如果管理不当可能会导致内存泄漏或内存溢出。栈由编译器自动管理内存,遵循后进先出的原则,变量的生命周期由其作用域决定,函数调用时分配内存,函数返回时释放内存。
大小和速度:堆通常比栈大,内存空间较大,动态分配和释放内存需要时间开销。栈大小有限,通常比较小,内存分配和释放速度较快,因为是编译器自动管理。
fork 阶段会复制父进程的页表(虚拟内存)
- fork 之后,如果发生了写时复制,就会复制物理内存
主进程在执行 fork 的时候,操作系统会把主进程的”页表“复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。
子进程就共享了父进程的物理内存数据了,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为只读。
不过,当父进程或者子进程在向这个内存发起写操作时,CPU 就会触发写保护中断,这个写保护中断是由于违反权限导致的,然后操作系统会在”写保护中断处理函数”里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为「写时复制(Copy On Write)」。
写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。
写时复制机制节省了物理内存的资源,因为 fork 的时候,子进程不需要复制父进程的物理内存,避免了不必要的内存复制开销,子进程只需要复制父进程的页表,这时候父子进程的页表指向的都是共享的物理内存。只有当父子进程任何有一方对这片共享的物理内存发生了修改操作,才会触发写时复制机制,这时候才会复制发生修改操作的物理内存。
malloc内存分配
malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。malloc() 源码里默认定义了一个阈值:
- 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
- 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;
注意,不同的 glibc 版本定义的阈值也是不同的
malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。
- 方式一:通过 brk() 系统调用从堆分配内存
- 方式二:通过 mmap() 系统调用在文件映射区域分配内存
方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间.
方式二通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存
应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。
当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。
缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。
如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,回收的方式主要是两种:直接内存回收和后台内存回收。
- 后台内存回收(kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
- 直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。
如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——触发 OOM (Out of Memory)机制。
OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。
系统内存紧张的时候,就会进行回收内存的工作,那具体哪些内存是可以被回收的呢?
主要有两类内存可以被回收,而且它们的回收方式也不同。
- 文件页(File-backed Page):内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页。大部分文件页,都可以直接释放内存,以后有需要时,再从磁盘重新读取就可以了。而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),就得先写入磁盘,然后才能进行内存释放。所以,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存。
- 匿名页(Anonymous Page):这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等。这部分内存很可能还要再次被访问,所以不能直接释放内存,它们回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。
文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。LRU 回收算法,实际上维护着 active 和 inactive 两个双向链表,其中:
- active_list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
- inactive_list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;
越接近链表尾部,就表示内存页越不常访问。这样,在回收内存时,系统就可以根据活跃程度,优先回收不活跃的内存
页面置换算法
那其算法目标则是,尽可能减少页面的换入换出的次数,常见的页面置换算法有如下几种:
- 最佳页面置换算法(OPT)
- 先进先出置换算法(FIFO)
- 最近最久未使用的置换算法(LRU)
- 时钟页面置换算法(Lock)
- 最不常用置换算法(LFU)
最近最久未使用(LRU)的置换算法的基本思路是,发生缺页时,选择最长时间没有被访问的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。这种算法近似最优置换算法,最优置换算法是通过「未来」的使用情况来推测要淘汰的页面,而 LRU 则是通过「历史」的使用情况来推测要淘汰的页面。
最不常用(LFU)算法是当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。
它的实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。
看起来很简单,每个页面加一个计数器就可以实现了,但是在操作系统中实现的时候,我们需要考虑效率和硬件成本的。
要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。
但还有个问题,LFU 算法只考虑了频率问题,没考虑时间的问题,比如有些页面在过去时间里访问的频率很高,但是现在已经没有访问了,而当前频繁访问的页面由于没有这些页面访问的次数高,在发生缺页中断时,就会可能会误伤当前刚开始频繁访问,但访问次数还不高的页面。
那这个问题的解决的办法还是有的,可以定期减少访问的次数,比如当发生时间中断时,把过去时间访问的页面的访问次数除以 2,也就说,随着时间的流失,以前的高访问次数的页面会慢慢减少,相当于加大了被置换的概率
中断
CPU停下当前的工作任务,去处理其他事情,处理完后回来继续执行刚才的任务,这一过程便是中断。
中断是计算机系统中一种机制,用于在处理器执行指令时暂停当前任务,并转而执行其他任务或处理特定事件。以下是中断的基本流程:
- 发生中断:当外部设备或者软件程序需要处理器的注意或者响应时,会发出中断信号。处理器在接收到中断信号后,会停止当前执行的指令,保存当前执行现场,并跳转到中断处理程序执行。
- 中断响应:处理器接收到中断信号后,会根据中断向量表找到对应的中断处理程序的入口地址。 处理器会保存当前执行现场(如程序计数器、寄存器状态等),以便在中断处理完成后能够恢复执行。
- 中断处理:处理器跳转到中断处理程序的入口地址开始执行中断处理程序。中断处理程序会根据中断类型进行相应的处理,可能涉及到保存现场、处理中断事件、执行特定任务等
中断分为外部中断和内部中断:
外部中断分为可屏蔽中断和不可屏蔽中断:
- 可屏蔽中断:通过INTR线向CPU请求的中断,主要来自外部设备如硬盘,打印机,网卡等。此类中断并不会影响系统运行,可随时处理,甚至不处理,所以名为可屏蔽。
- 不可屏蔽中断:通过NMI线向CPU请求的中断,如电源掉电,硬件线路故障等。这里不可屏蔽的意思不是不可以屏蔽,不建议屏蔽,而是问题太大,屏蔽不了,不能屏蔽的意思。注:INTR和NMI都是CPU的引脚
内部中断分为陷阱、故障、终止:
- 陷阱:是一种有意的,预先安排的异常事件,一般是在编写程序时故意设下的陷阱指令,而后执行到陷阱指令后,CPU将会调用特定程序进行相应的处理,处理结束后返回到陷阱指令的下一条指令。如系统调用,程序调试功能等。如printf函数,最底层的实现中会有一条int 0x80指令,这就是一条陷阱指令,使用0x80号中断进行系统调用。
- 故障:故障是在引起故障的指令被执行,但还没有执行结束时,CPU检测到的一类的意外事件。出错时交由故障处理程序处理,如果能处理修正这个错误,就将控制返回到引起故障的指令即CPU重新执这条指令。如果不能处理就报错。常见的故障为缺页,当CPU引用的虚拟地址对应的物理页不存在时就会发生故障。缺页异常是能够修正的,有着专门的缺页处理程序,它会将缺失的物理页从磁盘中重新调进主存。而后再次执行引起故障的指令时便能够顺利执行了。
- 终止:执行指令的过程中发生了致命错误,不可修复,程序无法继续运行,只能终止,通常会是一些硬件的错误。终止处理程序不会将控制返回给原程序,而是直接终止原程序
中断按事件来源分类,可以分为外部中断和内部中断。中断事件来自于CPU外部的被称为外部中断,来自于CPU内部的则为内部中断。
进一步细分,外部中断还可分为可屏蔽中断(maskable interrupt)和不可屏蔽中断(non-maskable interrupt)两种,而内部中断按事件是否正常来划分可分为软中断和异常两种。
- 外部中断的中断事件来源于CPU外部,必然是某个硬件产生的,所以外部中断又被称为硬件中断(hardware interrupt)。计算机的外部设备,如网卡、声卡、显卡等都能产生中断。外部设备的中断信号是通过两根信号线通知CPU的,一根是INTR,另一根是NMI。CPU从INTR收到的中断信号都是不影响系统运行的,CPU可以选择屏蔽(通过设置中断屏蔽寄存器中的IF位),而从NMI中收到的中断信号则是影响系统运行的严重错误,不可屏蔽,因为屏蔽的意义不大,系统已经无法运行。
- 内部中断来自于处理器内部,其中软中断是由软件主动发起的中断,常被用于系统调用(system call);而异常则是指令执行期间CPU内部产生的错误引起的。异常也和不可屏蔽中断一样不受eflags寄存器的IF位影响,区别在于不可屏蔽中断发生的事件会导致处理器无法运行(如断电、电源故障等),而异常则是影响系统正常运行的中断(如除0、越界访问等)。
中断使得计算机系统具备应对对处理突发事件的能力,提高了CPU的工作效率,如果没有中断系统,CPU就只能按照原来的程序编写的先后顺序,对各个外设进行查询和处理,即轮询工作方式,轮询方法貌似公平,但实际工作效率却很低,却不能及时响应紧急事件。
网络IO
IO模型
- 阻塞I/O模型:应用程序发起I/O操作后会被阻塞,直到操作完成才返回结果。适用于对实时性要求不高的场景。
- 非阻塞I/O模型:应用程序发起I/O操作后立即返回,不会被阻塞,但需要不断轮询或者使用select/poll/epoll等系统调用来检查I/O操作是否完成。适合于需要进行多路复用的场景,例如需要同时处理多个socket连接的服务器程序。
- I/O复用模型:通过select、poll、epoll等系统调用,应用程序可以同时等待多个I/O操作,当其中任何一个I/O操作准备就绪时,应用程序会被通知。适合于需要同时处理多个I/O操作的场景,比如高并发的服务端程序。
- 信号驱动I/O模型:应用程序发起I/O操作后,可以继续做其他事情,当I/O操作完成时,操作系统会向应用程序发送信号来通知其完成。适合于需要异步I/O通知的场景,可以提高系统的并发能力。
- 异步I/O模型:应用程序发起I/O操作后可以立即做其他事情,当I/O操作完成时,应用程序会得到通知。异步I/O模型由操作系统内核完成I/O操作,应用程序只需等待通知即可。适合于需要大量并发连接和高性能的场景,能够减少系统调用次数,提高系统效率
服务器处理并发请求方式
- 单线程web服务器方式:web服务器一次处理一个请求,结束后读取并处理下一个请求,性能比较低,一次只能处理一个请求。
- 多进程/多线程web服务器:web服务器生成多个进程或线程并行处理多个用户请求,进程或线程可以按需或事先生成。有的web服务器应用程序为每个用户请求生成一个单独的进程或线程来进行响应,不过,一旦并发请求数量达到成千上万时,多个同时运行的进程或线程将会消耗大量的系统资源。(即每个进程只能响应一个请求,并且一个进程对应一个线程)
- I/O多路复用web服务器:web服务器可以I/O多路复用,达到只用一个线程就能监听和处理多个客户端的 i/o 事件。
- 多路复用多线程web服务器:将多进程和多路复用的功能结合起来形成的web服务器架构,其避免了让一个进程服务于过多的用户请求,并能充分利用多CPU主机所提供的计算能力。(这种架构可以理解为有多个进程,并且一个进程又生成多个线程,每个线程处理一个请求)
IO多路复用
select/poll/epoll 都是内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
select/poll
select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
epoll
epoll 通过两个方面,很好解决了 select/poll 的问题。
- 第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
- 第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数.
epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。
这两个术语还挺抽象的,其实它们的区别还是很好理解的。
- 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
- 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。
如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。
Redis,Nginx以及Netty的高性能原因
依赖Reactor 模式实现了高性能网络模式,这个是在i/o多路复用接口基础上实现的了网络模型。Reactor 翻译过来的意思是「反应堆」,这里的反应指的是「对事件反应」,也就是来了一个事件,Reactor 就有相对应的反应/响应。
Reactor 模式主要由 Reactor 和处理资源池这两个核心部分组成,它俩负责的事情如下:
- Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;
- 处理资源池负责处理事件,如 read -> 业务逻辑 -> send;
Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于:
- Reactor 的数量可以只有一个,也可以有多个;
- 处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程;
添加fd到epoll实例时,会加入epoll_event,包括event要监听的事件类型和event.data.ptr和数据,一般会封装一个Channel类放在event.data.ptr,在这个类中有epoll_fd,fd以及回调函数方便进行事件处理
Redis 6.0 之前使用的 Reactor 模型就是单 Reactor 单进程模式。单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争
但是,这种方案存在 2 个缺点:
- 第一个缺点,因为只有一个进程,无法充分利用 多核 CPU 的性能;
- 第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟;
所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景。
Redis 是由 C 语言实现的,在 Redis 6.0 版本之前采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。
Netty 是采用了多 Reactor 多线程方案,多 Reactor 多线程的方案优势:
- 主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。
- 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端
nginx 是多 Reactor 多进程方案,不过方案与标准的多 Reactor 多进程有些差异.具体差异表现在主进程中仅仅用来初始化 socket,并没有创建 mainReactor 来 accept 连接,而是由子进程的 Reactor 来 accept 连接,通过锁来控制一次只有一个子进程进行 accept(防止出现惊群现象),子进程 accept 新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程
零拷贝
传统 IO 的工作方式,从硬盘读取数据,然后再通过网卡向外发送,我们需要进行 4 上下文切换,和 4 次数据拷贝,其中 2 次数据拷贝发生在内存里的缓冲区和对应的硬件设备之间,这个是由 DMA 完成,另外 2 次则发生在内核态和用户态之间,这个数据搬移工作是由 CPU 完成的。
为了理解 sendfile
的优势,我们先来看看传统 read()
和 write()
的过程。假设我们要将一个文件通过网络发送出去,通常需要经过以下四个步骤,伴随着四次数据拷贝和两次用户态与内核态的切换:
- 第一次拷贝:
read()
系统调用触发,DMA(直接内存访问)引擎将磁盘上的数据拷贝到内核空间的读缓冲区。 - 第二次拷贝:CPU 将数据从内核空间的读缓冲区拷贝到用户空间的缓冲区。此时,用户态和内核态发生了一次切换。
- 第三次拷贝:
write()
系统调用触发,CPU 将数据从用户空间的缓冲区拷贝到内核空间的 socket 缓冲区。又一次用户态与内核态的切换。 - 第四次拷贝:DMA 引擎将数据从内核空间的 socket 缓冲区拷贝到网络协议栈,最终发送出去。
为了提高文件传输的性能,于是就出现了零拷贝技术,它通过一次系统调用(sendfile 方法)合并了磁盘读取与网络发送两个操作,降低了上下文切换次数。另外,拷贝数据都是发生在内核中的,天然就降低了数据拷贝的次数。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
TCP/IP
TCP/IP协议被组织成四个概念层,其中有三层对应于ISO参考模型中的相应层。ICP/IP协议族并不包含物理层和数据链路层,因此它不能独立完成整个计算机网络系统的功能,必须与许多其他的协议协同工作。TCP/IP 网络通常是由上到下分成 4 层,分别是应用层,传输层,网络层和网络接口层。
- 应用层 支持 HTTP、SMTP 等最终用户进程
- 传输层 处理主机到主机的通信(TCP、UDP)
- 网络层 寻址和路由数据包(IP 协议)
- 链路层 通过网络的物理电线、电缆或无线信道移动比特
序列号:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。
确认应答号:指下一次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题。
控制位:
- ACK:该位为 1 时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的 SYN 包之外该位必须设置为 1 。
- RST:该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接。
- SYN:该位为 1 时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。
- FIN:该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位为 1 的 TCP 段。
三次握手
一开始,客户端和服务端都处于 CLOSE 状态。先是服务端主动监听某个端口,处于 LISTEN 状态
客户端会随机初始化序号(client_isn),将此序号置于 TCP 首部的「序号」字段中,同时把 SYN 标志位置为 1,表示 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态。
服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(server_isn),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入 client_isn + 1, 接着把 SYN 和 ACK 标志位置为 1。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。
- 客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认应答号」字段填入 server_isn + 1 ,最后把报文发送给服务端,这次报文可以携带客户到服务端的数据,之后客户端处于 ESTABLISHED 状态。
- 服务端收到客户端的应答报文后,也进入 ESTABLISHED 状态。
第三次握手是可以携带数据的,前两次握手是不可以携带数据的,这也是面试常问的题。
TCP为什么需要三次握手建立连接
三次握手的原因:
- 三次握手才可以阻止重复历史连接的初始化(主要原因)
- 三次握手才可以同步双方的初始序列号
- 三次握手才可以避免资源浪费
三次握手的首要原因是为了防止旧的重复连接初始化造成混乱。
TCP三次握手后,客户端第三次发送的确认包丢失会发生什么
客户端收到服务端的 SYN-ACK 报文后,就会给服务端回一个 ACK 报文,也就是第三次握手,此时客户端状态进入到 ESTABLISH 状态。
因为这个第三次握手的 ACK 是对第二次握手的 SYN 的确认报文,所以当第三次握手丢失了,如果服务端那一方迟迟收不到这个确认报文,就会触发超时重传机制,重传 SYN-ACK 报文,直到收到第三次握手,或者达到最大重传次数。
当服务端超时重传 2 次 SYN-ACK 报文后,由于 tcp_synack_retries 为 2,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第三次握手(ACK 报文),那么服务端就会断开连接.
三次握手和 accept 是什么关系? accept 做了哪些事情?
tcp 完成三次握手后,连接会被保存到内核的全连接队列,调用 accpet 就是从把连接取出来给用户程序使用。
客户端发送的第一个 SYN 报文,服务器没有收到怎么办
当客户端想和服务端建立 TCP 连接的时候,首先第一个发的就是 SYN 报文,然后进入到 SYN_SENT 状态。
在这之后,如果客户端迟迟收不到服务端的 SYN-ACK 报文(第二次握手),就会触发「超时重传」机制,重传 SYN 报文,而且重传的 SYN 报文的序列号都是一样的。
不同版本的操作系统可能超时时间不同,有的 1 秒的,也有 3 秒的,这个超时时间是写死在内核里的,如果想要更改则需要重新编译内核,比较麻烦。
当客户端在 1 秒后没收到服务端的 SYN-ACK 报文后,客户端就会重发 SYN 报文,那到底重发几次呢?
在 Linux 里,客户端的 SYN 报文最大重传次数由 tcp_syn_retries内核参数控制,这个参数是可以自定义的,默认值一般是 5。
通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。没错,每次超时的时间是上一次的 2 倍。
当第五次超时重传后,会继续等待 32 秒,如果服务端仍然没有回应 ACK,客户端就不再发送 SYN 包,然后断开 TCP 连接。
所以,总耗时是 1+2+4+8+16+32=63 秒,大约 1 分钟左右。
服务器收到第一个 SYN 报文,回复的 SYN + ACK 报文丢失了怎么办
当服务端收到客户端的第一次握手后,就会回 SYN-ACK 报文给客户端,这个就是第二次握手,此时服务端会进入 SYN_RCVD 状态。
第二次握手的 SYN-ACK 报文其实有两个目的 :
- 第二次握手里的 ACK, 是对第一次握手的确认报文;
- 第二次握手里的 SYN,是服务端发起建立 TCP 连接的报文;
所以,如果第二次握手丢了,就会发生比较有意思的事情,具体会怎么样呢?
因为第二次握手报文里是包含对客户端的第一次握手的 ACK 确认报文,所以,如果客户端迟迟没有收到第二次握手,那么客户端就觉得可能自己的 SYN 报文(第一次握手)丢失了,于是客户端就会触发超时重传机制,重传 SYN 报文。
然后,因为第二次握手中包含服务端的 SYN 报文,所以当客户端收到后,需要给服务端发送 ACK 确认报文(第三次握手),服务端才会认为该 SYN 报文被客户端收到了。
那么,如果第二次握手丢失了,服务端就收不到第三次握手,于是服务端这边会触发超时重传机制,重传 SYN-ACK 报文。
在 Linux 下,SYN-ACK 报文的最大重传次数由 tcp_synack_retries内核参数决定,默认值是 5。
因此,当第二次握手丢失了,客户端和服务端都会重传:
- 客户端会重传 SYN 报文,也就是第一次握手,最大重传次数由 tcp_syn_retries内核参数决定;
- 服务端会重传 SYN-ACK 报文,也就是第二次握手,最大重传次数由 tcp_synack_retries 内核参数决定。
所以:
- 当客户端超时重传 1 次 SYN 报文后,由于 tcp_syn_retries 为 1,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次握手(SYN-ACK 报文),那么客户端就会断开连接。
- 当服务端超时重传 2 次 SYN-ACK 报文后,由于 tcp_synack_retries 为 2,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第三次握手(ACK 报文),那么服务端就会断开连接。
假设客户端重传了 SYN 报文,服务端这边又收到重复的 SYN 报文怎么办?
会继续发送第二次握手报文。
第一次握手,客户端发送SYN报后,服务端回复ACK报,那这个过程中服务端内部做了哪些工作?
服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。
不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,内核会直接丢弃,或返回 RST 包。
大量SYN包发送给服务端服务端会发生什么事情?
有可能会导致TCP 半连接队列打满,这样当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃,导致客户端无法和服务端建立连接。
避免 SYN 攻击方式,可以有以下四种方法:
- 调大 netdev_max_backlog;
- 增大 TCP 半连接队列;
- 开启 tcp_syncookies;
- 减少 SYN+ACK 重传次数
方式一:调大 netdev_max_backlog
当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数,默认值是 1000,我们要适当调大该参数的值,比如设置为 10000:1
net.core.netdev_max_backlog = 10000
方式二:增大 TCP 半连接队列
增大 TCP 半连接队列,要同时增大下面这三个参数:
- 增大 net.ipv4.tcp_max_syn_backlog
- 增大 listen() 函数中的 backlog
- 增大 net.core.somaxconn
方式三:开启 net.ipv4.tcp_syncookies
开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接,相当于绕过了 SYN 半连接来建立连接。
具体过程:
- 当 「 SYN 队列」满之后,后续服务端收到 SYN 包,不会丢弃,而是根据算法,计算出一个
cookie
值; - 将 cookie 值放到第二次握手报文的「序列号」里,然后服务端回第二次握手给客户端;
- 服务端接收到客户端的应答报文时,服务端会检查这个 ACK 包的合法性。如果合法,将该连接对象放入到「 Accept 队列」。
- 最后应用程序通过调用
accpet()
接口,从「 Accept 队列」取出的连接。
可以看到,当开启了 tcp_syncookies 了,即使受到 SYN 攻击而导致 SYN 队列满时,也能保证正常的连接成功建立。
net.ipv4.tcp_syncookies 参数主要有以下三个值:
- 0 值,表示关闭该功能;
- 1 值,表示仅当 SYN 半连接队列放不下时,再启用它;
- 2 值,表示无条件开启功能;
方式四:减少 SYN+ACK 重传次数
当服务端受到 SYN 攻击时,就会有大量处于 SYN_REVC 状态的 TCP 连接,处于这个状态的 TCP 会重传 SYN+ACK ,当重传超过次数达到上限后,就会断开连接。
那么针对 SYN 攻击的场景,我们可以减少 SYN-ACK 的重传次数,以加快处于 SYN_REVC 状态的 TCP 连接断开。
TCP四次挥手
具体过程:
- 客户端主动调用关闭连接的函数,于是就会发送 FIN 报文,这个 FIN 报文代表客户端不会再发送数据了,进入 FIN_WAIT_1 状态;
- 服务端收到了 FIN 报文,然后马上回复一个 ACK 确认报文,此时服务端进入 CLOSE_WAIT 状态。在收到 FIN 报文的时候,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,服务端应用程序可以通过 read 调用来感知这个 FIN 包,这个 EOF 会被放在已排队等候的其他已接收的数据之后,所以必须要得继续 read 接收缓冲区已接收的数据;
- 接着,当服务端在 read 数据的时候,最后自然就会读到 EOF,接着 read() 就会返回 0,这时服务端应用程序如果有数据要发送的话,就发完数据后才调用关闭连接的函数,如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数,这时服务端就会发一个 FIN 包,这个 FIN 报文代表服务端不会再发送数据了,之后处于 LAST_ACK 状态;
- 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;
- 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态;
- 客户端经过 2MSL 时间之后,也进入 CLOSE 状态;
为什么4次握手中间两次不能变成一次
服务器收到客户端的 FIN 报文时,内核会马上回一个 ACK 应答报文,但是服务端应用程序可能还有数据要发送,所以并不能马上发送 FIN 报文,而是将发送 FIN 报文的控制权交给服务端应用程序:
- 如果服务端应用程序有数据要发送的话,就发完数据后,才调用关闭连接的函数;
- 如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数,
从上面过程可知,是否要发送第三次挥手的控制权不在内核,而是在被动关闭方(上图的服务端)的应用程序,因为应用程序可能还有数据要发送,由应用程序决定什么时候调用关闭连接的函数,当调用了关闭连接的函数,内核就会发送 FIN 报文了,所以服务端的 ACK 和 FIN 一般都会分开发送
当被动关闭方在 TCP 挥手过程中,「没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。
第三次挥手一直没发,会发生什么?
当主动方收到 ACK 报文后,会处于 FIN_WAIT2 状态,就表示主动方的发送通道已经关闭,接下来将等待对方发送 FIN 报文,关闭对方的发送通道。
这时,如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态,因为它可能还可以发送或接收数据。但对于 close 函数关闭的孤儿连接,由于无法再发送和接收数据,所以这个状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒:
第二次和第三次挥手之间,主动断开的那端能干什么
如果主动断开的一方,是调用了 shutdown 函数来关闭连接,并且只选择了关闭发送能力且没有关闭接收能力的话,那么主动断开的一方在第二次和第三次挥手之间还可以接收数据.
断开连接时客户端 FIN 包丢失,服务端的状态是什么?
当客户端(主动关闭方)调用 close 函数后,就会向服务端发送 FIN 报文,试图与服务端断开连接,此时客户端的连接进入到 FIN_WAIT_1 状态。
正常情况下,如果能及时收到服务端(被动关闭方)的 ACK,则会很快变为 FIN_WAIT2状态。
如果第一次挥手丢失了,那么客户端迟迟收不到被动方的 ACK 的话,也就会触发超时重传机制,重传 FIN 报文,重发次数由 tcp_orphan_retries 参数控制。
当客户端重传 FIN 报文的次数超过 tcp_orphan_retries 后,就不再发送 FIN 报文,则会在等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到第二次挥手,那么客户端直接进入到 close 状态,而服务端还是ESTABLISHED状态
为什么四次挥手之后要等2MSL
MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。
MSL 与 TTL 的区别: MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。
TTL 的值一般是 64,Linux 将 MSL 设置为 30 秒,意味着 Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了。
TIME_WAIT 等待 2 倍的 MSL,比较合理的解释是: 网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以一来一回需要等待 2 倍的时间。
比如,如果被动关闭方没有收到断开连接的最后的 ACK 报文,就会触发超时重发 FIN 报文,另一方接收到 FIN 后,会重发 ACK 给被动关闭方, 一来一去正好 2 个 MSL。
可以看到 2MSL时长 这其实是相当于至少允许报文丢失一次。比如,若 ACK 在一个 MSL 内丢失,这样被动方重发的 FIN 会在第 2 个 MSL 内到达,TIME_WAIT 状态的连接可以应对
服务端出现大量的timewait有哪些原因?
如果一个服务器端出现了大量的 TIME_WAIT
状态,通常意味着服务器主动关闭了许多 TCP 连接。这在高并发、短连接的应用场景下非常常见,例如 Web 服务器处理大量的 HTTP/1.0 请求
TIME_WAIT
状态发生在主动关闭连接的一方。它的主要作用是:
- 可靠地终止连接:确保最后一个
ACK
包能够被对端(被动关闭方)接收。如果这个ACK
包在传输过程中丢失,对端会重发FIN
包。处于TIME_WAIT
状态的服务器依然能够接收到这个重发的FIN
包,并再次发送ACK
,从而保证连接的正常关闭。 - 避免旧连接的串扰:
TIME_WAIT
状态会持续一个固定的时间(通常是2MSL
,即 2 倍最大报文生存时间),在这个时间内,连接的四元组(源 IP、源端口、目的 IP、目的端口)不能被新连接复用。这可以防止旧连接的迟到数据包被新连接错误地接收,造成数据混乱
问题来了,什么场景下服务端会主动断开连接呢?
- 第一个场景:HTTP 没有使用长连接
- 第二个场景:HTTP 长连接超时
- 第三个场景:HTTP 长连接的请求数量达到上限
在 HTTP/1.0 中默认是关闭的,如果浏览器要开启 Keep-Alive,它必须在请求的 header 中添加:1
Connection: Keep-Alive
然后当服务器收到请求,作出回应的时候,它也被添加到响应中 header 里:1
Connection: Keep-Alive
这样做,TCP 连接就不会中断,而是保持连接。当客户端发送另一个请求时,它会使用同一个 TCP 连接。这一直继续到客户端或服务器端提出断开连接。
从 HTTP/1.1 开始, 就默认是开启了 Keep-Alive,现在大多数浏览器都默认是使用 HTTP/1.1,所以 Keep-Alive 都是默认打开的。一旦客户端和服务端达成协议,那么长连接就建立好了。
如果要关闭 HTTP Keep-Alive,需要在 HTTP 请求或者响应的 header 里添加 Connection:close 信息,也就是说,只要客户端和服务端任意一方的 HTTP header 中有 Connection:close 信息,那么就无法使用 HTTP 长连接的机制。
关闭 HTTP 长连接机制后,每次请求都要经历这样的过程:建立 TCP -> 请求资源 -> 响应资源 -> 释放连接,那么此方式就是 HTTP 短连接,只要任意一方的 HTTP header 中有 Connection:close 信息,就无法使用 HTTP 长连接机制,这样在完成一次 HTTP 请求/处理后,就会关闭连接。
问题来了,这时候是客户端还是服务端主动关闭连接呢?
在 RFC 文档中,并没有明确由谁来关闭连接,请求和响应的双方都可以主动关闭 TCP 连接。
不过,根据大多数 Web 服务的实现,不管哪一方禁用了 HTTP Keep-Alive,都是由服务端主动关闭连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。
如果使用了 HTTP 长连接,如果客户端完成一个 HTTP 请求后,就不再发起新的请求,此时这个 TCP 连接一直占用着不是挺浪费资源的吗?对没错,所以为了避免资源浪费的情况,web 服务软件一般都会提供一个参数,用来指定 HTTP 长连接的超时时间,比如 nginx 提供的 keepalive_timeout 参数。
假设设置了 HTTP 长连接的超时时间是 60 秒,nginx 就会启动一个「定时器」,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,nginx 就会触发回调函数来关闭该连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。当服务端出现大量 TIME_WAIT 状态的连接时,如果现象是有大量的客户端建立完 TCP 连接后,很长一段时间没有发送数据,那么大概率就是因为 HTTP 长连接超时,导致服务端主动关闭连接,产生大量处于 TIME_WAIT 状态的连接。
Web 服务端通常会有个参数,来定义一条 HTTP 长连接上最大能处理的请求数量,当超过最大限制时,就会主动关闭连接。
比如 nginx 的 keepalive_requests 这个参数,这个参数是指一个 HTTP 长连接建立之后,nginx 就会为这个连接设置一个计数器,记录这个 HTTP 长连接上已经接收并处理的客户端请求的数量。如果达到这个参数设置的最大值时,则 nginx 会主动关闭这个长连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。
keepalive_requests 参数的默认值是 100 ,意味着每个 HTTP 长连接最多只能跑 100 次请求,这个参数往往被大多数人忽略,因为当 QPS (每秒请求数) 不是很高时,默认值 100 凑合够用。
但是,对于一些 QPS 比较高的场景,比如超过 10000 QPS,甚至达到 30000 , 50000 甚至更高,如果 keepalive_requests 参数值是 100,这时候就 nginx 就会很频繁地关闭连接,那么此时服务端上就会出大量的 TIME_WAIT 状态。
针对这个场景下,解决的方式也很简单,调大 nginx 的 keepalive_requests 参数就行。
TCP为什么可靠传输
TCP协议主要通过以下几点来保证传输可靠性:连接管理、序列号、确认应答、超时重传、流量控制、拥塞控制。
- 连接管理:即三次握手和四次挥手。连接管理机制能够建立起可靠的连接,这是保证传输可靠性的前提。
- 序列号:TCP将每个字节的数据都进行了编号,这就是序列号。序列号的具体作用如下:能够保证可靠性,既能防止数据丢失,又能避免数据重复。能够保证有序性,按照序列号顺序进行数据包还原。能够提高效率,基于序列号可实现多次发送,一次确认。
- 确认应答:接收方接收数据之后,会回传ACK报文,报文中带有此次确认的序列号,用于告知发送方此次接收数据的情况。在指定时间后,若发送端仍未收到确认应答,就会启动超时重传。
- 超时重传:超时重传主要有两种场景:数据包丢失:在指定时间后,若发送端仍未收到确认应答,就会启动超时重传,向接收端重新发送数据包。确认包丢失:当接收端收到重复数据(通过序列号进行识别)时将其丢弃,并重新回传ACK报文。
- 流量控制:接收端处理数据的速度是有限的,如果发送方发送数据的速度过快,就会导致接收端的缓冲区溢出,进而导致丢包。为了避免上述情况的发生,TCP支持根据接收端的处理能力,来决定发送端的发送速度。这就是流量控制。流量控制是通过在TCP报文段首部维护一个滑动窗口来实现的。
- 拥塞控制:拥塞控制就是当网络拥堵严重时,发送端减少数据发送。拥塞控制是通过发送端维护一个拥塞窗口来实现的。可以得出,发送端的发送速度,受限于滑动窗口和拥塞窗口中的最小值。拥塞控制方法分为:慢开始,拥塞避免、快重传和快恢复
TCP粘包问题
粘包的问题出现是因为不知道一个用户消息的边界在哪,如果知道了边界在哪,接收方就可以通过边界来划分出有效的用户消息。
一般有三种方式分包的方式:
- 固定长度的消息;
- 特殊字符作为边界;
- 自定义消息结构。
固定长度的消息
这种是最简单方法,即每个用户消息都是固定长度的,比如规定一个消息的长度是 64 个字节,当接收方接满 64 个字节,就认为这个内容是一个完整且有效的消息。
但是这种方式灵活性不高,实际中很少用。
特殊字符作为边界
我们可以在两个用户消息之间插入一个特殊的字符串,这样接收方在接收数据时,读到了这个特殊字符,就把认为已经读完一个完整的消息。
HTTP 是一个非常好的例子。
HTTP 通过设置回车符、换行符作为 HTTP 报文协议的边界。
有一点要注意,这个作为边界点的特殊字符,如果刚好消息内容里有这个特殊字符,我们要对这个字符转义,避免被接收方当作消息的边界点而解析到无效的数据。
自定义消息结构
我们可以自定义一个消息结构,由包头和数据组成,其中包头包是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大。
比如这个消息结构体,首先 4 个字节大小的变量来表示数据长度,真正的数据则在后面。1
2
3
4struct {
u_int32_t message_length;
char message_data[];
} message;
当接收方接收到包头的大小(比如 4 个字节)后,就解析包头的内容,于是就可以知道数据的长度,然后接下来就继续读取数据,直到读满数据的长度,就可以组装成一个完整到用户消息来处理了。
TCP拥塞控制
当网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大….
网络发送拥塞时,TCP 会自我牺牲,降低发送的数据量。
于是,就有了拥塞控制,控制的目的就是避免「发送方」的数据填满整个网络。
为了在「发送方」调节所要发送数据的量,定义了一个叫做「拥塞窗口」的概念。
拥塞窗口 cwnd是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,那么由于加入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。
拥塞窗口 cwnd 变化的规则:
- 只要网络中没有出现拥塞,cwnd 就会增大;
- 但网络中出现了拥塞,cwnd 就减少
其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了拥塞。拥塞控制有哪些控制算法?拥塞控制主要是四个算法:
- 慢启动
当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。
慢启动算法,发包的个数是指数性的增长。那慢启动涨到什么时候是个头呢?
有一个叫慢启动门限 ssthresh (slow start threshold)状态变量。当 cwnd < ssthresh 时,使用慢启动算法。当 cwnd >= ssthresh 时,就会使用「拥塞避免算法」。
- 拥塞避免
当拥塞窗口 cwnd 「超过」慢启动门限 ssthresh 就会进入拥塞避免算法。
一般来说 ssthresh 的大小是 65535 字节。
那么进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd。
接上前面的慢启动的栗子,现假定 ssthresh 为 8:
当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个 MSS 大小的数据,变成了线性增长。
拥塞发生
当网络出现拥塞,也就是会发生数据包重传,重传机制主要有两种:
- 超时重传
- 快速重传
这两种使用的拥塞发送算法是不同的,接下来分别来说说。
发生超时重传的拥塞发生算法
当发生了「超时重传」,则就会使用拥塞发生算法。
这个时候,ssthresh 和 cwnd 的值会发生变化:
- ssthresh 设为 cwnd/2,
- cwnd 重置为 1 (是恢复为 cwnd 初始化值,我这里假定 cwnd 初始化值 1)
快速重传算法。当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。
TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 ssthresh 和 cwnd 变化如下:
- cwnd = cwnd/2 ,也就是设置为原来的一半;
- ssthresh = cwnd;
进入快速恢复算法
快速恢复
快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像 RTO 超时那么强烈。
正如前面所说,进入快速恢复之前,cwnd 和 ssthresh 已被更新了:
- cwnd = cwnd/2 ,也就是设置为原来的一半;
- ssthresh = cwnd;
然后,进入快速恢复算法如下:
- 拥塞窗口 cwnd = ssthresh + 3 ( 3 的意思是确认有 3 个数据包被收到了);
- 重传丢失的数据包;
- 如果再收到重复的 ACK,那么 cwnd 增加 1;
- 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态;
UDP
UDP 报文中每个字段的含义如下:
- 源端口:这个字段占据 UDP 报文头的前 16 位,通常包含发送数据报的应用程序所使用的 UDP 端口。接收端的应用程序利用这个字段的值作为发送响应的目的地址。这个字段是可选的,所以发送端的应用程序不一定会把自己的端口号写入该字段中。如果不写入端口号,则把这个字段设置为 0。这样,接收端的应用程序就不能发送响应了。
- 目的端口:接收端计算机上 UDP 软件使用的端口,占据 16 位。
- 长度:该字段占据 16 位,表示 UDP 数据报长度,包含 UDP 报文头和 UDP 数据长度。因为 UDP 报文头长度是 8 个字节,所以这个值最小为 8。
- 校验值:该字段占据 16 位,可以检验数据在传输过程中是否被损坏。
UDP 是不可靠传输的,但基于 UDP 的 QUIC 协议 可以实现类似 TCP 的可靠性传输,在http3 就用了 quic 协议。
- 连接迁移:QUIC支持在网络变化时快速迁移连接,例如从WiFi切换到移动数据网络,以保持连接的可靠性。
- 重传机制:QUIC使用重传机制来确保丢失的数据包能够被重新发送,从而提高数据传输的可靠性。
- 前向纠错:QUIC可以使用前向纠错技术,在接收端修复部分丢失的数据,降低重传的需求,提高可靠性和传输效率。
- 拥塞控制:QUIC内置了拥塞控制机制,可以根据网络状况动态调整数据传输速率,以避免网络拥塞和丢包,提高可靠性。
TCP与UDP差异
- 连接:TCP 是面向连接的传输层协议,传输数据前先要建立连接;UDP 是不需要连接,即刻传输数据。
- 服务对象:TCP 是一对一的两点服务,即一条连接只有两个端点。UDP 支持一对一、一对多、多对多的交互通信
- 可靠性:TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按序到达。UDP 是尽最大努力交付,不保证可靠交付数据。但是我们可以基于 UDP 传输协议实现一个可靠的传输协议,比如 QUIC 协议
- 拥塞控制、流量控制:TCP 有拥塞控制和流量控制机制,保证数据传输的安全性。UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率。
- 首部开销:TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是 20 个字节,如果使用了「选项」字段则会变长的。UDP 首部只有 8 个字节,并且是固定不变的,开销较小。
- 传输方式:TCP 是流式传输,没有边界,但保证顺序和可靠。UDP 是一个包一个包的发送,是有边界的,但可能会丢包和乱序
TCP实现可靠传输原理
TCP通过差错控制(序列号、确认应答、数据校验)、超时重传、流量控制、拥塞控制等机制,确保了数据传输的可靠性和效率。
- 序列号:每个TCP段都有一个序列号,确保数据包的顺序正确。
- 数据校验:TCP使用校验和来检测数据在传输过程中是否出现错误,如果检测到错误,接收方会丢弃该数据包,并等待重传。
- 确认应答:接收方发送ACK确认收到的数据,如果发送方在一定时间内没有收到确认,会重新发送数据。
- 超时重传:发送方设置一个定时器,如果在定时器超时之前没有收到确认,发送方会重传数据。
- 流量控制:TCP通过滑动窗口机制进行流量控制,确保接收方能够处理发送方的数据量。
- 拥塞控制:TCP通过算法如慢启动、拥塞避免、快重传和快恢复等,来控制数据的发送速率,防止网络拥塞。
IP
IP 在 TCP/IP 参考模型中处于第三层,也就是网络层。
网络层的主要作用是:实现主机与主机之间的通信,也叫点对点(end to end)通信
- 首部长度
表示 IP 首部的大小,单位是 4字节(32bit)。IP 头部的长度也就是 length * 4 的字节数. 当没有可选项时,length 是 5,也就是20字节。 - 区分服务(TOS)
用来表示服务的质量。可划分为 DSCP 和 ECN,DSCP 用来进行质量控制,ECN 用来报告网络拥堵情况。 - 总长度
表示的是 IP 首部与数据部分合起来的总字节数,最大长度是 65535 字节 - 标识
用于分片重组。同一个分片的标识值是相同的,不同分片的标识值不同。每发送一个 IP 包,它的值也会逐渐递增。 - 标志
表示包被分片的相关信息 - 片偏移
用来标识被分片的每一个分段相对于原始数据的位置。 - 生存时间(TTL)
这个不是一个时间的概念,实际上是指可以中转多少个路由器的意思,每经过一个路由器,TTL会减少 1,直到变成0 则丢弃该包。 - 协议
表示 IP 首部的下一个首部属于哪个协议。 - 首部校验和
该字段只会校验数据包的首部,不会去校验数据部分。这个字段主要目的是用来确保 IP 数据包不被破坏。 - 源地址
表示发送端的 IP 地址。 - 目标地址
表示接收端的 IP 地址。 - 可选字段
长度可变,通常只在进行实验或诊断时使用。 - 填充
在有可选字段的情况下,首部长度可能不是 32 比特的整数倍。通过向字段填充 0,调整为 32 比特的整数倍。 - 数据
用来存入实际要传输的数据,同时将 IP 上层协议的首部也作为数据进行处理。
IP地址分类方法
IP地址分类
无分类地址CIDR 网络号/主机号 子网掩码
分离网络号和主机号
划分子网 实际上子网掩码还有一个作用,那就是划分子网。子网划分实际上是将主机地址分为两个部分:子网网络地址和子网主机地址
公有IP地址和私有IP地址
IP地址与路由控制
IP 地址的主要作用是定位和寻址。一个完整的 IP 地址由两部分组成:
- 网络号 (Network ID):标识设备所在的网络。所有在同一个网络中的设备,其 IP 地址的网络号部分是相同的。
- 主机号 (Host ID):标识网络中的特定设备。每个设备的主机号是唯一的。
子网掩码(Subnet Mask)用于将 IP 地址划分为网络号和主机号。通过将 IP 地址和子网掩码进行逻辑与运算,路由器可以快速判断一个 IP 数据包是发送给本地网络中的设备,还是需要转发到另一个网络。IP地址的网络地址这一部分是用于进行路由控制。
路由(Routing)是选择数据包在网络中传输路径的过程。路由器是执行路由任务的关键设备,它的核心功能是根据数据包的目的 IP 地址来决定如何转发它。
路由控制依赖于一个名为路由表 (Routing Table) 的数据结构。路由表存储了网络中的“地图”,包含了以下关键信息:
- 目的地址 (Destination):数据包要到达的网络或主机。
- 子网掩码 (Mask):与目的地址配合使用,以确定网络范围。
- 下一跳 (Next Hop):数据包应该被发送到的下一个路由器或网关的 IP 地址。
- 出接口 (Outgoing Interface):数据包将从哪个网络接口离开本路由器。
- 度量值 (Metric):衡量到达目的地的“成本”,用于在有多条路径可选时选择最优路径。
当一个数据包到达路由器时,路由器会执行以下步骤:
- 检查目的 IP 地址:路由器读取数据包头部中的目的 IP 地址。
- 路由表查找:路由器在路由表中查找与目的 IP 地址最匹配的条目(这被称为最长匹配原则)。
- 转发数据包:根据匹配到的路由表条目,路由器将数据包发送到指定的下一跳和出接口。
- 丢弃数据包:如果没有找到匹配的路由条目,也没有默认路由,路由器会丢弃该数据包。
路由控制表中记录着网络地址与下一步应该发送至路由器的地址。在主机和路由器上都会有各自的路由器控制表。在发送 IP 包时,首先要确定 IP 包首部中的目标地址,再从路由控制表中找到与该地址具有相同网络地址的记录,根据该记录将 IP 包转发给相应的下一个路由器。如果路由控制表中存在多条相同网络地址的记录,就选择相同位数最多的网络地址,也就是最长匹配。
例如一个主机要发送一个数据包,
- 主机 A 要发送一个 IP 包,其源地址是
10.1.1.30
和目标地址是10.1.2.10
,由于没有在主机 A 的路由表找到与目标地址10.1.2.10
相同的网络地址,于是包被转发到默认路由(路由器1
) - 路由器
1
收到 IP 包后,也在路由器1
的路由表匹配与目标地址相同的网络地址记录,发现匹配到了,于是就把 IP 数据包转发到了10.1.0.2
这台路由器2
- 路由器
2
收到后,同样对比自身的路由表,发现匹配到了,于是把 IP 包从路由器2
的10.1.2.1
这个接口出去,最终经过交换机把 IP 数据包转发到了目标主机
环回地址是在同一台计算机上的程序之间进行网络通信时所使用的一个默认地址。
计算机使用一个特殊的 IP 地址 127.0.0.1 作为环回地址。与该地址具有相同意义的是一个叫做 localhost
的主机名。使用这个 IP 或主机名时,数据包不会流向网络。
寻址基础:IP 地址是路由控制的基础。路由控制的所有决策都是基于数据包的目的 IP 地址做出的。没有 IP 地址,路由器就无法知道数据包的最终目的地。
网络划分:IP 地址通过网络号和主机号的划分,定义了网络边界。路由器正是通过识别这些边界,来决定是进行本地转发(在同一个网络内)还是跨网路由。
路由表构建:路由表中的每一个条目都与一个 IP 网络地址相关联。路由协议(如 OSPF、BGP 等)正是利用 IP 地址信息来相互交换网络拓扑数据,从而动态地构建和更新路由表。
路由策略:IP 地址也可以用于实施特定的路由策略,例如根据源 IP 地址或目的 IP 地址来过滤、重定向或改变数据包的转发路径,以满足特定的网络安全或性能需求。
IP分片与重组
每种数据链路的最大传输单元 MTU
都是不相同的,如 FDDI 数据链路 MTU 4352、以太网的 MTU 是 1500 字节等。每种数据链路的 MTU 之所以不同,是因为每个不同类型的数据链路的使用目的不同。使用目的不同,可承载的 MTU 也就不同。
常用的数据链路就是以太网,当 IP 数据包大小大于 MTU 时, IP 数据包就会被分片。
经过分片之后的 IP 数据报在被重组的时候,只能由目标主机进行,路由器是不会进行重组的。
假设发送方发送一个 4000 字节的大数据报,若要传输在以太网链路,则需要把数据报分片成 3 个小数据报进行传输,再交由接收方重组成大数据报。
在分片传输中,一旦某个分片丢失,则会造成整个 IP 数据报作废,所以 TCP 引入了 MSS
也就是在 TCP 层进行分片不由 IP 层分片,那么对于 UDP 我们尽量不要发送一个大于 MTU
的数据报文
IP分片是网络层(IP协议)的一种机制,它允许一个大的IP数据包被分割成多个较小的片段,以便通过无法传输整个大包的网络链路。
简单来说,当一个IP数据包的大小超过了它所要通过的网络链路的最大传输单元(MTU)时,路由器或主机就会对这个数据包进行分片。
MSS 是一个设备在一次 TCP 通信中能接收的最大数据量。这个数据量指的是数据包的有效载荷部分,不包含任何 TCP 或 IP 的头部信息。
最大分段大小(MSS)和 发送窗口大小(Send Window Size)是 TCP 流控和拥塞控制中两个非常重要的概念,它们之间有着紧密的联系,但作用的层面不同。
MSS(最大分段大小)
- 作用: 限制单个 TCP 报文段中数据部分的最大字节数。
- 目的: 确保发送的报文段在 IP 层不会被分片。
- 协商: MSS 是在 TCP 三次握手期间协商的。发送方和接收方都会通告自己能接收的最大 MSS,并选择两者中较小的值作为本次连接的 MSS。
- 单位: 字节。
你可以将 MSS 看作是卡车装载货物的单次限制,每辆卡车最多只能装载 MSS 大小的货物。
发送窗口大小
- 作用: 限制发送方未确认数据的总字节数。
- 目的: 实现流量控制和拥塞控制。
- 协商: 窗口大小是由接收方在每个 TCP 报文段的头部字段中通告的。它告诉发送方:“我的接收缓冲区还有这么大的空间,你可以发送这么多数据,不用等待确认。”
- 单位: 字节。
发送窗口就像一个总配额,发送方可以连续发送多个报文段,只要这些报文段的总数据量不超过当前窗口大小。
MSS 和发送窗口大小协同工作,共同决定了 TCP 连接的数据传输效率和稳定性。
- 窗口大小是 MSS 的倍数:在许多 TCP 实现中,为了提高效率,接收方通告的窗口大小通常是 MSS 的整数倍。这样做可以确保发送方发送的每个报文段都是满载的(即达到 MSS 大小),从而减少网络中传输的报文段总数,提高吞吐量。
- MSS 决定了“每包”大小,窗口决定了“批量”大小:
- MSS 设定了单个报文段的上限。无论窗口多大,单个报文段的数据部分都不能超过 MSS。
- 窗口大小则决定了可以连续发送多少个报文段。发送方可以连续发送多个大小为 MSS 的报文段,直到所有报文段的总大小达到了窗口限制。
- MSS 是静态的,窗口大小是动态的:
- MSS 一旦在连接建立时协商确定,在整个连接生命周期内通常不会改变。
- 发送窗口大小是动态变化的,它由接收方的接收缓冲区情况和网络拥塞状况决定。接收方可能会增大或减小窗口,甚至将其设为 0(零窗口),以控制发送速率。
- 接收窗口(Receive Window):由接收方通告,它告诉发送方自己当前的可用缓冲区大小。这是 TCP 头部的固定字段,因为接收方需要实时地向发送方报告这个关键的流控信息。
- 发送窗口(Send Window):由发送方维护,它决定了发送方可以发送的未确认数据的最大字节数。发送窗口的大小不是一个独立的头部字段,因为它由发送方根据两个因素动态计算得出:
发送窗口=min(接收窗口,拥塞窗口)
- 接收窗口(Receive Window):接收方通告的窗口,用于流量控制。
- 拥塞窗口(Congestion Window):发送方根据对网络拥塞状况的估计,在内部维护的变量,用于拥塞控制。
IP分片的工作原理
- 分片(Fragmentation): 当路由器收到一个大于出接口 MTU 的 IP 数据包时,它会检查 IP 头部中的标志位。如果数据包允许分片(Don’t Fragment, DF, 标志位为0),路由器会将其分解成多个小的数据包。
- 头部复制: 每个分片都会拥有一个完整的IP头部,其中包含:
- 标识(Identification):所有分片都使用相同的标识号,以便接收方知道它们属于同一个原始数据包。
- 偏移量(Fragment Offset):这个字段告诉接收方,当前这个分片在原始数据包中的相对位置。
- 标志(Flags):其中有一个“更多分片”(More Fragments, MF)标志位。除最后一个分片外,所有分片的MF位都会被设置为1。
- 重组(Reassembly): 所有分片到达最终目的地后,接收方会根据它们的标识号、偏移量和更多分片标志位来将它们重新组合成原始的数据包,然后将其交给上层协议(如TCP或UDP)。
IP分片虽然解决了不同MTU网络之间通信的问题,但它也带来了显著的负面影响,因此在现代网络中通常被尽可能避免。
- 增加开销:分片和重组都需要消耗路由器和接收主机的CPU和内存资源,这会增加网络延迟并降低处理效率。
- 传输不稳定:如果任何一个分片在传输过程中丢失,整个原始数据包就无法被重组,导致所有分片都需要重新传输。
- 安全风险:分片可能会被恶意利用,例如进行分片攻击,通过发送恶意分片来使目标系统崩溃或消耗其资源。
IPV6
IPv6 的地址是 128
位的,可分配地址更多,但是因为 IPv4 和 IPv6 不能相互兼容,所以不但要我们电脑、手机之类的设备支持,还需要网络运营商对现有的设备进行升级
IPv6 不仅仅只是可分配的地址变多了,它还有非常多的亮点。
- IPv6 可自动配置,即使没有 DHCP 服务器也可以实现自动分配IP地址,便捷到即插即用。
- IPv6 包头包首部长度采用固定的值
40
字节,去掉了包头校验和,简化了首部结构,减轻了路由器负荷,大大提高了传输的性能。 - IPv6 有应对伪造 IP 地址的网络安全功能以及防止线路窃听的功能,大大提升了安全性。
IPv4 地址长度共 32 位,是以每 8 位作为一组,并用点分十进制的表示方式。
IPv6 地址长度是 128 位,是以每 16 位作为一组,共8组.
IPv6 相比 IPv4 的首部改进:
- 取消了首部校验和字段。 因为在数据链路层和传输层都会校验,因此 IPv6 直接取消了 IP 的校验。
- 取消了分片/重新组装相关字段。 分片与重组是耗时的过程,IPv6 不允许在中间路由器进行分片与重组,这种操作只能在源与目标主机,这将大大提高了路由器转发的速度。
- 取消选项字段。 选项字段不再是标准 IP 首部的一部分了,但它并没有消失,而是可能出现在 IPv6 首部中的「下一个首部」指出的位置上。删除该选项字段使的 IPv6 的首部成为固定长度的
40
字节
IP协议相关技术
DNS 域名解析
DNS 可以将域名网址自动转换为具体的 IP 地址。
DNS 中的域名都是用句点来分隔的,比如 www.server.com
,这里的句点代表了不同层次之间的界限。
在域名中,越靠右的位置表示其层级越高。
毕竟域名是外国人发明,所以思维和中国人相反,比如说一个城市地点的时候,外国喜欢从小到大的方式顺序说起(如 XX 街道 XX 区 XX 市 XX 省),而中国则喜欢从大到小的顺序(如 XX 省 XX 市 XX 区 XX 街道)。
根域是在最顶层,它的下一层就是 com 顶级域,再下面是 server.com。
所以域名的层级关系类似一个树状结构:
- 根 DNS 服务器
- 顶级域 DNS 服务器(com)
- 权威 DNS 服务器(server.com)
根域的 DNS 服务器信息保存在互联网中所有的 DNS 服务器中。这样一来,任何 DNS 服务器就都可以找到并访问根域 DNS 服务器了。
因此,客户端只要能够找到任意一台 DNS 服务器,就可以通过它找到根域 DNS 服务器,然后再一路顺藤摸瓜找到位于下层的某台目标 DNS 服务器。
- ARP 与 RARP 协议
在传输一个 IP 数据报的时候,确定了源 IP 地址和目标 IP 地址后,就会通过主机「路由表」确定 IP 数据包下一跳。然而,网络层的下一层是数据链路层,所以我们还要知道「下一跳」的 MAC 地址。
由于主机的路由表中可以找到下一跳的 IP 地址,所以可以通过 ARP 协议,求得下一跳的 MAC 地址。
ARP 是借助 ARP 请求与 ARP 响应两种类型的包确定 MAC 地址的。
- 主机会通过广播发送 ARP 请求,这个包中包含了想要知道的 MAC 地址的主机 IP 地址。
- 当同个链路中的所有设备收到 ARP 请求时,会去拆开 ARP 请求包里的内容,如果 ARP 请求包中的目标 IP 地址与自己的 IP 地址一致,那么这个设备就将自己的 MAC 地址塞入 ARP 响应包返回给主机
操作系统通常会把第一次通过 ARP 获取的 MAC 地址缓存起来,以便下次直接从缓存中找到对应 IP 地址的 MAC 地址。MAC 地址的缓存是有一定期限的,超过这个期限,缓存的内容将被清除。
ARP 协议是已知 IP 地址求 MAC 地址,那 RARP 协议正好相反,它是已知 MAC 地址求 IP 地址。例如将打印机服务器等小型嵌入式设备接入到网络时就经常会用得到。
通常这需要架设一台 RARP
服务器,在这个服务器上注册设备的 MAC 地址及其 IP 地址。然后再将这个设备接入到网络,接着:
- 该设备会发送一条「我的 MAC 地址是XXXX,请告诉我,我的IP地址应该是什么」的请求信息。
- RARP 服务器接到这个消息后返回「MAC地址为 XXXX 的设备,IP地址为 XXXX」的信息给这个设备。
最后,设备就根据从 RARP 服务器所收到的应答信息设置自己的 IP 地址。
- DHCP 动态获取 IP 地址
DHCP 客户端进程监听的是 68 端口号,DHCP 服务端进程监听的是 67 端口号。
这 4 个步骤:
- 客户端首先发起 DHCP 发现报文(DHCP DISCOVER) 的 IP 数据报,由于客户端没有 IP 地址,也不知道 DHCP 服务器的地址,所以使用的是 UDP 广播通信,其使用的广播目的地址是 255.255.255.255(端口 67) 并且使用 0.0.0.0(端口 68) 作为源 IP 地址。DHCP 客户端将该 IP 数据报传递给链路层,链路层然后将帧广播到所有的网络中设备。
- DHCP 服务器收到 DHCP 发现报文时,用 DHCP 提供报文(DHCP OFFER) 向客户端做出响应。该报文仍然使用 IP 广播地址 255.255.255.255,该报文信息携带服务器提供可租约的 IP 地址、子网掩码、默认网关、DNS 服务器以及 IP 地址租用期。
- 客户端收到一个或多个服务器的 DHCP 提供报文后,从中选择一个服务器,并向选中的服务器发送 DHCP 请求报文(DHCP REQUEST进行响应,回显配置的参数。
- 最后,服务端用 DHCP ACK 报文对 DHCP 请求报文进行响应,应答所要求的参数。
一旦客户端收到 DHCP ACK 后,交互便完成了,并且客户端能够在租用期内使用 DHCP 服务器分配的 IP 地址。
如果租约的 DHCP IP 地址快期后,客户端会向服务器发送 DHCP 请求报文:
- 服务器如果同意继续租用,则用 DHCP ACK 报文进行应答,客户端就会延长租期。
- 服务器如果不同意继续租用,则用 DHCP NACK 报文,客户端就要停止使用租约的 IP 地址。
DHCP 交互中,全程都是使用 UDP 广播通信。
用的是广播,如果 DHCP 服务器和客户端不是在同一个局域网内,路由器又不会转发广播包,那不是每个网络都要配一个 DHCP 服务器?
所以,为了解决这一问题,就出现了 DHCP 中继代理。有了 DHCP 中继代理以后,对不同网段的 IP 地址分配也可以由一个 DHCP 服务器统一进行管理。
- DHCP 客户端会向 DHCP 中继代理发送 DHCP 请求包,而 DHCP 中继代理在收到这个广播包以后,再以单播的形式发给 DHCP 服务器。
- 服务器端收到该包以后再向 DHCP 中继代理返回应答,并由 DHCP 中继代理将此包广播给 DHCP 客户端 。
- NAT 网络地址转换
提出了一种网络地址转换 NAT 的方法,再次缓解了 IPv4 地址耗尽的问题。
简单的来说 NAT 就是同个公司、家庭、教室内的主机对外部通信时,把私有 IP 地址转换成公有 IP 地址
绝大多数的网络应用都是使用传输层协议 TCP 或 UDP 来传输数据的。
因此,可以把 IP 地址 + 端口号一起进行转换。
这样,就用一个全球 IP 地址就可以了,这种转换技术就叫网络地址与端口转换 NAPT。
如果有有两个客户端 192.168.1.10 和 192.168.1.11 同时与服务器 183.232.231.172 进行通信,并且这两个客户端的本地端口都是 1025。
此时,两个私有 IP 地址都转换 IP 地址为公有地址 120.229.175.121,但是以不同的端口号作为区分。
于是,生成一个 NAPT 路由器的转换表,就可以正确地转换地址跟端口的组合,令客户端 A、B 能同时与服务器之间进行通信。这种转换表在 NAT 路由器上自动生成。例如,在 TCP 的情况下,建立 TCP 连接首次握手时的 SYN 包一经发出,就会生成这个表。而后又随着收到关闭连接时发出 FIN 包的确认应答从表中被删除。
由于 NAT/NAPT 都依赖于自己的转换表,因此会有以下的问题:
- 外部无法主动与 NAT 内部服务器建立连接,因为 NAPT 转换表没有转换记录。
- 转换表的生成与转换操作都会产生性能开销。
- 通信过程中,如果 NAT 路由器重启了,所有的 TCP 连接都将被重置。
解决的方法主要有两种方法。
第一种就是改用 IPv6
IPv6 可用范围非常大,以至于每台设备都可以配置一个公有 IP 地址,就不搞那么多花里胡哨的地址转换了,但是 IPv6 普及速度还需要一些时间。
第二种 NAT 穿透技术
NAT 穿越技术拥有这样的功能,它能够让网络应用程序主动发现自己位于 NAT 设备之后,并且会主动获得 NAT 设备的公有 IP,并为自己建立端口映射条目,注意这些都是 NAT设备后的应用程序自动完成的。也就是说,在 NAT 穿透技术中,NAT设备后的应用程序处于主动地位,它已经明确地知道 NAT 设备要修改它外发的数据包,于是它主动配合 NAT 设备的操作,主动地建立好映射,这样就不像以前由 NAT 设备来建立映射了。就是客户端主动从 NAT 设备获取公有 IP 地址,然后自己建立端口映射条目,然后用这个条目对外通信,就不需要 NAT 设备来进行转换了。
- ICMP 互联网控制报文协议
ICMP
主要的功能包括:确认 IP 包是否成功送达目标地址、报告发送过程中 IP 包被废弃的原因和改善网络设置等。在 IP
通信中如果某个 IP
包因为某种原因未能达到目标地址,那么这个具体的原因将由 ICMP 负责通知。
主机 A
向主机 B
发送了数据包,由于某种原因,途中的路由器 2
未能发现主机 B
的存在,这时,路由器 2
就会向主机 A
发送一个 ICMP
目标不可达数据包,说明发往主机 B
的包未能成功。ICMP 的这种通知消息会使用 IP
进行发送
CMP 大致可以分为两大类:
- 一类是用于诊断的查询消息,也就是「查询报文类型」
- 另一类是通知出错原因的错误消息,也就是「差错报文类型」
- IGMP 因特网组管理协议
IGMP 是因特网组管理协议,工作在主机(组播成员)和最后一跳路由之间
- IGMP 报文向路由器申请加入和退出组播组,默认情况下路由器是不会转发组播包到连接中的主机,除非主机通过 IGMP 加入到组播组,主机申请加入到组播组时,路由器就会记录 IGMP 路由器表,路由器后续就会转发组播包到对应的主机了。
- IGMP 报文采用 IP 封装,IP 头部的协议号为 2,而且 TTL 字段值通常为 1,因为 IGMP 是工作在主机与连接的路由器之间。
常规查询与响应工作机制
- 路由器会周期性发送目的地址为
224.0.0.1
(表示同一网段内所有主机和路由器) IGMP 常规查询报文。 - 主机1 和 主机 3 收到这个查询,随后会启动「报告延迟计时器」,计时器的时间是随机的,通常是 0~10 秒,计时器超时后主机就会发送 IGMP 成员关系报告报文(源 IP 地址为自己主机的 IP 地址,目的 IP 地址为组播地址)。如果在定时器超时之前,收到同一个组内的其他主机发送的成员关系报告报文,则自己不再发送,这样可以减少网络中多余的 IGMP 报文数量。
- 路由器收到主机的成员关系报文后,就会在 IGMP 路由表中加入该组播组,后续网络中一旦该组播地址的数据到达路由器,它会把数据包转发出去。
离开组播组工作机制
离开组播组的情况一,网段中仍有该组播组:
- 主机 1 要离开组 224.1.1.1,发送 IGMPv2 离组报文,报文的目的地址是 224.0.0.2(表示发向网段内的所有路由器)
- 路由器 收到该报文后,以 1 秒为间隔连续发送 IGMP 特定组查询报文(共计发送 2 个),以便确认该网络是否还有 224.1.1.1 组的其他成员。
- 主机 3 仍然是组 224.1.1.1 的成员,因此它立即响应这个特定组查询。路由器知道该网络中仍然存在该组播组的成员,于是继续向该网络转发 224.1.1.1 的组播数据包。
离开组播组的情况二,网段中没有该组播组:
- 主机 1 要离开组播组 224.1.1.1,发送 IGMP 离组报文。
- 路由器收到该报文后,以 1 秒为间隔连续发送 IGMP 特定组查询报文(共计发送 2 个)。此时在该网段内,组 224.1.1.1 已经没有其他成员了,因此没有主机响应这个查询。
- 一定时间后,路由器认为该网段中已经没有 224.1.1.1 组播组成员了,将不会再向这个网段转发该组播地址的数据包。
组播地址不是用于机器ip地址的,因为组播地址没有网络号和主机号,所以跟dhcp没关系。组播地址一般是用于udp协议,机器发送UDP组播数据时,目标地址填的是组播地址,那么在组播组内的机器都能收到数据包。是否加入组播组和离开组播组,是由socket一个接口实现的,主机ip是不用改变的。
特殊IP地址
大部分 IP 地址都是用来标识特定设备的。然而,有一些 IP 地址被赋予了特殊的用途,不能像普通地址那样随意分配给设备使用。
这些特殊的 IP 地址可以大致分为以下几类:
1. 私有地址(Private Addresses)
私有地址是专门为内部网络设计的,不能在公共互联网上使用。这些地址的出现是为了缓解 IPv4 地址枯竭的问题。
- A 类私有地址:
10.0.0.0
到10.255.255.255
- B 类私有地址:
172.16.0.0
到172.31.255.255
- C 类私有地址:
192.168.0.0
到192.168.255.255
这些地址在你的家庭或公司网络中很常见,例如你的路由器 IP 地址通常是 192.168.1.1
。这些地址在路由器上会被网络地址转换(NAT)技术转换为公共 IP 地址,才能访问互联网。
2. 环回地址(Loopback Address)
这个地址用于本地主机的自测,也被称为“本地回环地址”。
- 地址范围:
127.0.0.0
到127.255.255.255
- 最常用:
127.0.0.1
当你向 127.0.0.1
发送数据包时,它不会离开你的电脑,而是直接在内部回环,这样可以用来测试网络程序或服务是否正常运行,而无需依赖外部网络连接。
3. 广播地址(Broadcast Addresses)
广播地址用于向特定网络中的所有主机发送数据,通常用于网络发现或诊断。
- 受限广播地址:
255.255.255.255
- 这个地址用于向本地网络上的所有主机发送广播,路由器不会转发带有该地址的数据包。
- 直接广播地址:
网络地址 + 全 1 的主机地址
- 例如,如果一个网络的地址是
192.168.1.0
,那么其广播地址是192.168.1.255
。数据包发送到这个地址时,会被路由到该网络,然后广播给网络中的所有主机。
- 例如,如果一个网络的地址是
4. 组播地址(Multicast Addresses)
组播地址用于一对多的通信,通常用于流媒体、在线会议和游戏等场景。
- 地址范围:
224.0.0.0
到239.255.255.255
这些地址不分配给单个主机,而是代表一个兴趣组。只有加入了该组的主机才能接收发送到这个组播地址的数据包。
5. 保留地址(Reserved Addresses)
一些 IP 地址范围被保留用于将来的协议开发、测试或文档编写。
0.0.0.0
:“本网主机”,代表本地网络中的任何主机,通常作为默认路由或 DHCP 服务器的地址。169.254.0.0
到169.254.255.255
:APIPA 地址,当 DHCP 服务器不可用时,Windows 系统会自动分配这个范围内的地址,实现本地通信。192.0.2.0
到192.0.2.255
:测试网络(TEST-NET),这个地址段专门用于文档和示例,不会在公共网络中使用
HTTP
HTTP报文部分
HTTP、HTTPS、CDN、DNS、FTP 都是应用层协议
分请求报文和响应报文来说明。
请求报文:
- 请求行:包含请求方法、请求目标(URL或URI)和HTTP协议版本。
- 请求头部:包含关于请求的附加信息,如Host、User-Agent、Content-Type等。
- 空行:请求头部和请求体之间用空行分隔。
- 请求体:可选,包含请求的数据,通常用于POST请求等需要传输数据的情况。
响应报文:
- 状态行:包含HTTP协议版本、状态码和状态信息。
- 响应头部:包含关于响应的附加信息,如Content-Type、Content-Length等。
- 空行:响应头部和响应体之间用空行分隔。
- 响应体:包含响应的数据,通常是服务器返回的HTML、JSON等内容。
HTTP不同版本
HTTP/1.1
HTTP/1.1 是一个基于文本的协议,是 Web 长期以来的主流标准。它的核心特点是简单易懂,但也存在一些严重的性能问题。
- 队头阻塞(Head-of-Line Blocking):在 HTTP/1.1 中,一个连接在同一时间只能处理一个请求。如果上一个请求的响应没有返回,后续的请求就会被阻塞。即使使用了 Pipelining(管道化,允许多个请求连续发送,无需等待响应),如果第一个响应丢失,后面的所有响应也会被延迟,从而导致严重的性能问题。
- 不必要的开销:每个请求和响应都带有重复的头部信息,增加了数据传输的开销。
- 连接效率低:尽管支持长连接(Persistent Connection),允许在一个 TCP 连接上发送多个请求,但由于队头阻塞问题,效率仍然不高。
HTTP/2
HTTP/2 是为了解决 HTTP/1.1 的性能问题而诞生的,它在语义上兼容 HTTP/1.1,但底层做了彻底的革新。
- 二进制分帧(Binary Framing):HTTP/2 将所有请求和响应都拆分为二进制帧,并在一个 TCP 连接上进行传输。这使得协议的解析更高效、更健壮。
- 多路复用(Multiplexing):这是 HTTP/2 最大的优势。它允许在一个 TCP 连接上同时发送多个请求和接收多个响应,解决了 HTTP/1.1 的队头阻塞问题。因为数据被拆分成了独立的帧,即使某个数据流很慢,也不会影响到其他数据流。
- 头部压缩(Header Compression):HTTP/2 使用 HPACK 算法对头部进行压缩。它维护了一个静态和动态的头部表,并使用霍夫曼编码,避免了重复发送相同的头部信息,大大减少了数据传输量。
- 服务器推送(Server Push):允许服务器在客户端请求之前,主动推送它认为客户端可能需要的资源(如 CSS、JavaScript 文件),从而减少客户端的等待时间。
HTTP/3
HTTP/3 的出现是为了解决 HTTP/2 仍然存在的底层问题——TCP 的队头阻塞。
- 基于 QUIC 协议:HTTP/3 没有使用 TCP,而是选择了基于 UDP 的 QUIC 协议。
- 解决 TCP 队头阻塞:在 TCP 中,如果一个数据包丢失,整个连接的所有数据流都会被阻塞,直到丢失的数据包被重传。而 QUIC 协议基于 UDP,它在应用层实现了类似 TCP 的可靠传输和拥塞控制。这意味着即使某个数据流的数据包丢失,也只会阻塞该数据流本身,而不会影响到同一连接上的其他数据流,从而彻底解决了底层协议的队头阻塞问题。
- 更快的连接建立:QUIC 协议将 TCP 的三次握手和 TLS 的加密握手合并在一起。在大多数情况下,它只需要一次往返(1-RTT)就能建立安全连接,甚至在连接缓存后可以实现 0-RTT,大大减少了连接延迟。
- 更好的网络切换能力:QUIC 协议通过连接 ID 来识别连接,而不是 IP 地址和端口号。这使得在网络切换时(例如从 Wi-Fi 切换到移动数据),连接可以无缝迁移,而无需重新建立。
特性 | HTTP/1.1 | HTTP/2 | HTTP/3 |
---|---|---|---|
底层协议 | TCP | TCP | UDP (QUIC) |
传输形式 | 文本 | 二进制帧 | 二进制帧 |
多路复用 | 不支持(有管道化但效果不佳) | 支持(在一个 TCP 连接上) | 支持(在 QUIC 连接上,从根本上解决队头阻塞) |
头部压缩 | 不支持 | 支持 (HPACK) | 支持(QUIC 自带) |
服务器推送 | 不支持 | 支持 | 支持 |
队头阻塞 | 应用层阻塞 | TCP 层阻塞 | 无队头阻塞 |
连接建立 | TCP 三次握手 | TCP 三次握手 + TLS 握手 | QUIC 握手(1-RTT 或 0-RTT) |
常用状态码
HTTP 状态码分为 5 大类
- 1xx 类状态码属于提示信息,是协议处理中的一种中间状态,实际用到的比较少。
- 2xx 类状态码表示服务器成功处理了客户端的请求,也是我们最愿意看到的状态。
- 3xx 类状态码表示客户端请求的资源发生了变动,需要客户端用新的 URL 重新发送请求获取资源,也就是重定向。
- 4xx 类状态码表示客户端发送的报文有误,服务器无法处理,也就是错误码的含义。
- 5xx 类状态码表示客户端请求报文正确,但是服务器处理时内部发生了错误,属于服务器端的错误码。
其中常见的具体状态码有:
- 200:请求成功;
- 301:永久重定向;302:临时重定向;
- 404:无法找到此页面;405:请求的方法类型不支持;
- 500:服务器内部出错
3xx 类状态码表示客户端请求的资源发生了变动,需要客户端用新的 URL 重新发送请求获取资源,也就是重定向。
- 「301 Moved Permanently」表示永久重定向,说明请求的资源已经不存在了,需改用新的 URL 再次访问。
- 「302 Found」表示临时重定向,说明请求的资源还在,但暂时需要用另一个 URL 来访问。
301 和 302 都会在响应头里使用字段 Location,指明后续要跳转的 URL,浏览器会自动重定向新的 URL。
- 502 Bad Gateway:作为网关或者代理工作的服务器尝试执行请求时,从上游服务器接收到无效的响应。
- 504 Gateway Time-out:作为网关或者代理工作的服务器尝试执行请求时,未能及时从上游服务器收到响应。
举一个例子,假设 nginx 是代理服务器,收到客户端的请求后,将请求转发到后端服务器(tomcat 等)。
- 当nginx收到了无效的响应时,就返回502。
- 当nginx超过自己配置的超时时间,还没有收到请求时,就返回504错误。
HTTP请求类型
- GET:用于请求获取指定资源,通常用于获取数据。
- POST:用于向服务器提交数据,通常用于提交表单数据或进行资源的创建。
- PUT:用于向服务器更新指定资源,通常用于更新已存在的资源。
- DELETE:用于请求服务器删除指定资源。
HEAD:类似于GET请求,但只返回资源的头部信息,用于获取资源的元数据而不获取实际内容
RFC 规范定义的语义来看:
GET 方法就是安全且幂等的,因为它是「只读」操作,无论操作多少次,服务器上的数据都是安全的,且每次的结果都是相同的。所以,可以对 GET 请求的数据做缓存,这个缓存可以做到浏览器本身上(彻底避免浏览器发请求),也可以做到代理上(如nginx),而且在浏览器中 GET 请求可以保存为书签。
- POST 因为是「新增或提交数据」的操作,会修改服务器上的资源,所以是不安全的,且多次提交数据就会创建多个资源,所以不是幂等的。所以,浏览器一般不会缓存 POST 请求,也不能把 POST 请求保存为书签。
HTTP对请求和响应拆包
在HTTP/1.1中,请求的拆包是通过”Content-Length”头字段来进行的。该字段指示了请求正文的长度,服务器可以根据该长度来正确接收和解析请求。
具体来说,当客户端发送一个HTTP请求时,会在请求头中添加”Content-Length”字段,该字段的值表示请求正文的字节数。
服务器在接收到请求后,会根据”Content-Length”字段的值来确定请求的长度,并从请求中读取相应数量的字节,直到读取完整个请求内容。
这种基于”Content-Length”字段的拆包机制可以确保服务器正确接收到完整的请求,避免了请求的丢失或截断问题
HTTP的断点重传
断点续传是HTTP/1.1协议支持的特性。实现断点续传的功能,需要客户端记录下当前的下载进度,并在需要续传的时候通知服务端本次需要下载的内容片段。
一个最简单的断点续传流程如下:
- 客户端开始下载一个1024K的文件,服务端发送Accept-Ranges: bytes来告诉客户端,其支持带Range的请求
- 假如客户端下载了其中512K时候网络突然断开了,过了一会网络可以了,客户端再下载时候,需要在HTTP头中申明本次需要续传的片段:Range:bytes=512000-这个头通知服务端从文件的512K位置开始传输文件,直到文件内容结束
- 服务端收到断点续传请求,从文件的512K位置开始传输,并且在HTTP头中增加:Content-Range:bytes 512000-/1024000,Content-Length: 512000。并且此时服务端返回的HTTP状态码应该是206 Partial Content。如果客户端传递过来的Range超过资源的大小,则响应416 Requested Range Not Satisfiable
通过上面流程可以看出:断点续传中4个HTTP头不可少的,分别是Range头、Content-Range头、Accept-Ranges头、Content-Length头。其中第一个Range头是客户端发过来的,后面3个头需要服务端发送给客户端。下面是它们的说明:
- Accept-Ranges: bytes:这个值声明了可被接受的每一个范围请求, 大多数情况下是字节数 bytes
- Range: bytes=开始位置-结束位置:Range是浏览器告知服务器所需分部分内容范围的消息头。
HTTP为什么不安全
HTTP 由于是明文传输,所以安全上存在以下三个风险:
- 窃听风险,比如通信链路上可以获取通信内容,用户号容易没。
- 篡改风险,比如强制植入垃圾广告,视觉污染,用户眼容易瞎。
- 冒充风险,比如冒充淘宝网站,用户钱容易没。
HTTPS 在 HTTP 与 TCP 层之间加入了 SSL/TLS 协议,可以很好的解决了上述的风险:
- 信息加密:交互信息无法被窃取,但你的号会因为「自身忘记」账号而没。
- 校验机制:无法篡改通信内容,篡改了就不能正常显示,但百度「竞价排名」依然可以搜索垃圾广告。
- 身份证书:证明淘宝是真的淘宝网,但你的钱还是会因为「剁手」而没。
HTTPS相比于HTTP更加安全,区别主要有以下四点:
- HTTP 是超文本传输协议,信息是明文传输,存在安全风险的问题。HTTPS 则解决 HTTP 不安全的缺陷,在 TCP 和 HTTP 网络层之间加入了 SSL/TLS 安全协议,使得报文能够加密传输。
- HTTP 连接建立相对简单, TCP 三次握手之后便可进行 HTTP 的报文传输。而 HTTPS 在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。
- 两者的默认端口不一样,HTTP 默认端口号是 80,HTTPS 默认端口号是 443。
- HTTPS 协议需要向 CA(证书权威机构)申请数字证书,来保证服务器的身份是可信的。
HTTPS握手过程
传统的 TLS 握手基本都是使用 RSA 算法来实现密钥交换的,在将 TLS 证书部署服务端时,证书文件其实就是服务端的公钥,会在 TLS 握手阶段传递给客户端,而服务端的私钥则一直留在服务端,一定要确保私钥不能被窃取。
在 RSA 密钥协商算法中,客户端会生成随机密钥,并使用服务端的公钥加密后再传给服务端。根据非对称加密算法,公钥加密的消息仅能通过私钥解密,这样服务端解密后,双方就得到了相同的密钥,再用它加密应用消息。
主要通过加密和身份校验机制来防范中间人攻击的:
- 加密:https 握手期间会通过非对称加密的方式来协商出对称加密密钥。
- 身份校验:服务器会向证书颁发机构申请数字证书,证书中包含了服务器的公钥和其他相关信息。当客户端与服务器建立连接时,服务器会将证书发送给客户端。客户端会验证证书的合法性,包括检查证书的有效期、颁发机构的信任等。如果验证通过,客户端会使用证书中的公钥来加密通信数据,并将加密后的数据发送给服务器,然后由服务端用私钥解密。
中间人攻击的关键在于攻击者冒充服务器与客户端建立连接,并同时与服务器建立连接。
但由于攻击者无法获得服务器的私钥,因此无法正确解密客户端发送的加密数据。同时,客户端会在建立连接时验证服务器的证书,如果证书验证失败或存在问题,客户端会发出警告或中止连接
HTTP进行TCP连接后什么情况下会断开
- 当服务端或者客户端执行 close 系统调用的时候,会发送FIN报文,就会进行四次挥手的过程
- 当发送方发送了数据之后,接收方超过一段时间没有响应ACK报文,发送方重传数据达到最大次数的时候,就会断开TCP连接
- 当HTTP长时间没有进行请求和响应的时候,超过一定的时间,就会释放连接
HTTP是应用层协议,定义了客户端和服务器之间交换的数据格式和规则;Socket是通信的一端,提供了网络通信的接口;TCP是传输层协议,负责在网络中建立可靠的数据传输连接。它们在网络通信中扮演不同的角色和层次。
- HTTP是一种用于传输超文本数据的应用层协议,用于在客户端和服务器之间传输和显示Web页面。
- Socket是计算机网络中的一种抽象,用于描述通信链路的一端,提供了底层的通信接口,可实现不同计算机之间的数据交换。
- TCP是一种面向连接的、可靠的传输层协议,负责在通信的两端之间建立可靠的数据传输连接。
DNS以及域名解析过程
DNS的全称是Domain Name System(域名系统),它是互联网中用于将域名转换为对应IP地址的分布式数据库系统。DNS扮演着重要的角色,使得人们可以通过易记的域名访问互联网资源,而无需记住复杂的IP地址。域名的层级关系类似一个树状结构:
- 根 DNS 服务器(.)
- 顶级域 DNS 服务器(.com)
- 权威 DNS 服务器(server.com)
根域的 DNS 服务器信息保存在互联网中所有的 DNS 服务器中。
这样一来,任何 DNS 服务器就都可以找到并访问根域 DNS 服务器了。
因此,客户端只要能够找到任意一台 DNS 服务器,就可以通过它找到根域 DNS 服务器,然后再一路顺藤摸瓜找到位于下层的某台目标 DNS 服务器
- 客户端首先会发出一个 DNS 请求,问 www.server.com 的 IP 是啥,并发给本地 DNS 服务器(也就是客户端的 TCP/IP 设置中填写的 DNS 服务器地址)。
- 本地域名服务器收到客户端的请求后,如果缓存里的表格能找到 www.server.com,则它直接返回 IP 地址。如果没有,本地 DNS 会去问它的根域名服务器:“老大, 能告诉我 www.server.com 的 IP 地址吗?” 根域名服务器是最高层次的,它不直接用于域名解析,但能指明一条道路。
- 根 DNS 收到来自本地 DNS 的请求后,发现后置是 .com,说:“www.server.com 这个域名归 .com 区域管理”,我给你 .com 顶级域名服务器地址给你,你去问问它吧。”
- 本地 DNS 收到顶级域名服务器的地址后,发起请求问“老二, 你能告诉我 www.server.com 的 IP 地址吗?”
- 顶级域名服务器说:“我给你负责 www.server.com 区域的权威 DNS 服务器的地址,你去问它应该能问到”。
- 本地 DNS 于是转向问权威 DNS 服务器:“老三,www.server.com对应的IP是啥呀?” server.com 的权威 DNS 服务器,它是域名解析结果的原出处。为啥叫权威呢?就是我的域名我做主。
- 权威 DNS 服务器查询后将对应的 IP 地址 X.X.X.X 告诉本地 DNS。
- 本地 DNS 再将 IP 地址返回客户端,客户端和目标建立连接。
无状态HTTP含义
HTTP是无状态的,这意味着每个请求都是独立的,服务器不会在多个请求之间保留关于客户端状态的信息。在每个HTTP请求中,服务器不会记住之前的请求或会话状态,因此每个请求都是相互独立的。
虽然HTTP本身是无状态的,但可以通过一些机制来实现状态保持,其中最常见的方式是使用Cookie和Session来跟踪用户状态。通过在客户端存储会话信息或状态信息,服务器可以识别和跟踪特定用户的状态,以提供一定程度的状态保持功能
cookie与session
jwt令牌
JWT令牌由三个部分组成:头部(Header)、载荷(Payload)和签名(Signature)。其中,头部和载荷均为JSON格式,使用Base64编码进行序列化,而签名部分是对头部、载荷和密钥进行签名后的结果。
- 无状态性:JWT是无状态的令牌,不需要在服务器端存储会话信息。相反,JWT令牌中包含了所有必要的信息,如用户身份、权限等。这使得JWT在分布式系统中更加适用,可以方便地进行扩展和跨域访问。
- 安全性:JWT使用密钥对令牌进行签名,确保令牌的完整性和真实性。只有持有正确密钥的服务器才能对令牌进行验证和解析。这种方式比传统的基于会话和Cookie的验证更加安全,有效防止了CSRF(跨站请求伪造)等攻击。
- 跨域支持:JWT令牌可以在不同域之间传递,适用于跨域访问的场景。通过在请求的头部或参数中携带JWT令牌,可以实现无需Cookie的跨域身份验证
在传统的基于会话和Cookie的身份验证方式中,会话信息通常存储在服务器的内存或数据库中。但在集群部署中,不同服务器之间没有共享的会话信息,这会导致用户在不同服务器之间切换时需要重新登录,或者需要引入额外的共享机制(如Redis),增加了复杂性和性能开销。
而JWT令牌通过在令牌中包含所有必要的身份验证和会话信息,使得服务器无需存储会话信息,从而解决了集群部署中的身份验证和会话管理问题。当用户进行登录认证后,服务器将生成一个JWT令牌并返回给客户端。客户端在后续的请求中携带该令牌,服务器可以通过对令牌进行验证和解析来获取用户身份和权限信息,而无需访问共享的会话存储。
由于JWT令牌是自包含的,服务器可以独立地对令牌进行验证,而不需要依赖其他服务器或共享存储。这使得集群中的每个服务器都可以独立处理请求,提高了系统的可伸缩性和容错性。
JWT 一旦派发出去,在失效之前都是有效的,没办法即使撤销JWT。
要解决这个问题的话,得在业务层增加判断逻辑,比如增加黑名单机制。使用内存数据库比如 Redis 维护一个黑名单,如果想让某个 JWT 失效的话就直接将这个 JWT 加入到 黑名单 即可。然后,每次使用 JWT 进行请求的话都会先判断这个 JWT 是否存在于黑名单中。
- 及时失效令牌:当检测到JWT令牌泄露或存在风险时,可以立即将令牌标记为失效状态。服务器在接收到带有失效标记的令牌时,会拒绝对其进行任何操作,从而保护用户的身份和数据安全。
- 刷新令牌:JWT令牌通常具有一定的有效期,过期后需要重新获取新的令牌。当检测到令牌泄露时,可以主动刷新令牌,即重新生成一个新的令牌,并将旧令牌标记为失效状态。这样,即使泄露的令牌被恶意使用,也会很快失效,减少了被攻击者滥用的风险。
- 使用黑名单:服务器可以维护一个令牌的黑名单,将泄露的令牌添加到黑名单中。在接收到令牌时,先检查令牌是否在黑名单中,如果在则拒绝操作。这种方法需要服务器维护黑名单的状态,对性能有一定的影响,但可以有效地保护泄露的令牌不被滥用。
localStorage和SessionStorage
- 存储容量: Cookie 的存储容量通常较小,每个 Cookie 的大小限制在几 KB 左右。而 LocalStorage 的存储容量通常较大,一般限制在几 MB 左右。因此,如果需要存储大量数据,LocalStorage 通常更适合;
- 数据发送: Cookie 在每次 HTTP 请求中都会自动发送到服务器,这使得 Cookie 适合用于在客户端和服务器之间传递数据。而 localStorage 的数据不会自动发送到服务器,它仅在浏览器端存储数据,因此 LocalStorage 适合用于在同一域名下的不同页面之间共享数据;
- 生命周期:Cookie 可以设置一个过期时间,使得数据在指定时间后自动过期。而 LocalStorage 的数据将永久存储在浏览器中,除非通过 JavaScript 代码手动删除;
- 安全性:Cookie 的安全性较低,因为 Cookie 在每次 HTTP 请求中都会自动发送到服务器,存在被窃取或篡改的风险。而 LocalStorage 的数据仅在浏览器端存储,不会自动发送到服务器,相对而言更安全一些
HTTP长连接与WebSocket关系 与RPC关系
- 全双工和半双工:TCP 协议本身是全双工的,但我们最常用的 HTTP/1.1,虽然是基于 TCP 的协议,但它是半双工的,对于大部分需要服务器主动推送数据到客户端的场景,都不太友好,因此我们需要使用支持全双工的 WebSocket 协议。
- 应用场景区别:在 HTTP/1.1 里,只要客户端不问,服务端就不答。基于这样的特点,对于登录页面这样的简单场景,可以使用定时轮询或者长轮询的方式实现服务器推送(comet)的效果。对于客户端和服务端之间需要频繁交互的复杂场景,比如网页游戏,都可以考虑使用 WebSocket 协议。
- RPC 本质上不算是协议,而是一种调用方式,而像 gRPC 和 Thrift 这样的具体实现,才是协议,它们是实现了 RPC 调用的协议。目的是希望程序员能像调用本地方法那样去调用远端的服务方法。同时 RPC 有很多种实现方式,不一定非得基于 TCP 协议。
- 从发展历史来说,HTTP 主要用于 B/S 架构,而 RPC 更多用于 C/S 架构。但现在其实已经没分那么清了,B/S 和 C/S 在慢慢融合。很多软件同时支持多端,所以对外一般用 HTTP 协议,而内部集群的微服务之间则采用 RPC 协议进行通讯。
- RPC 其实比 HTTP 出现的要早,且比目前主流的 HTTP/1.1 性能要更好,所以大部分公司内部都还在使用 RPC。
- HTTP/2.0在 HTTP/1.1的基础上做了优化,性能可能比很多 RPC 协议都要好,但由于是这几年才出来的,所以也不太可能取代掉 RPC。
DNS解析过程
当你输入一个域名并按下回车键时,你的电脑(客户端)会首先检查自己的本地缓存。
- 浏览器缓存:浏览器会检查最近是否查询过这个域名。
- 操作系统缓存:如果浏览器缓存中没有,操作系统会检查它的 hosts 文件和 DNS 缓存。
如果缓存中找到了 IP 地址,解析过程就到此结束,客户端直接使用缓存的 IP 地址。
计算机会向本地 DNS 服务器(通常由网络提供商 ISP 提供)发送查询请求。本地 DNS 服务器会检查自身的缓存,如果缓存中存在目标域名的 IP 地址,则直接返回解析结果;如果本地 DNS 服务器也没有相关记录,则进入递归查询阶段。
第四步是向根域名服务器查询,
本地 DNS 服务器会向根域名服务器(Root DNS)发起查询请求。根域名服务器不会直接返回目标 IP 地址,而是返回负责 .com 域的顶级域名服务器(TLD DNS)的地址。
第五步是向顶级域名服务器查询,
本地 DNS 服务器收到根服务器的响应后,向返回的 .com 顶级域名服务器发起查询请求。顶级域名服务器负责返回域名的权威 DNS 服务器的地址。
第六步是进行权威 DNS 服务器解析,
本地 DNS 服务器向域名的权威 DNS 服务器发起查询。权威 DNS 服务器返回对应的 IP 地址。本地 DNS 服务器将解析结果返回给客户端,并将其存入缓存,以便下次查询时能更快响应。
第七步是返回解析结果,
本地 DNS 服务器将最终解析得到的 IP 地址返回给用户的计算机。然后,操作系统将解析结果存入缓存,并返回给浏览器。最后,浏览器使用该 IP 地址向目标服务器发起 HTTP/HTTPS 请求,建立连接并加载网页内容。
设计模式
单例设计模式
它的核心思想是确保一个类在整个应用程序中只有一个实例,并提供一个全局访问点来获取这个唯一的实例。单例模式主要用于以下场景:
- 资源共享:当某个对象的创建开销很大,或者该对象需要被频繁访问时,例如数据库连接池、线程池、配置对象等。通过单例模式,可以避免重复创建,节省资源。
- 全局唯一:当某个类只需要一个实例,且该实例需要被全局共享时,例如日志记录器、缓存、窗口管理器等
在实际开发中,单例模式有多种实现方式,每种方式都有其优缺点。
饿汉式(Eager Initialization)
在类加载时就创建好实例。1
2
3
4
5
6
7
8
9
10
11
12public class EagerSingleton {
// 在类加载时就创建好实例
private static final EagerSingleton INSTANCE = new EagerSingleton();
// 私有构造函数
private EagerSingleton() {}
// 公有静态方法返回实例
public static EagerSingleton getInstance() {
return INSTANCE;
}
}
- 优点:线程安全,实现简单。
- 缺点:无论是否使用,都会在类加载时创建实例,可能造成资源浪费。
懒汉式(Lazy Initialization)
在第一次调用时才创建实例。1
2
3
4
5
6
7
8
9
10
11
12
13public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {}
public static synchronized LazySingleton getInstance() {
// 在第一次调用时创建实例
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
- 优点:按需创建,节省资源。
- 缺点:在多线程环境下,不加锁会导致线程不安全。为了解决这个问题,需要使用
synchronized
关键字,但它会带来性能开销。
双重检查锁(Double-Checked Locking, DCL)
这是懒汉式的优化版本,旨在兼顾性能和线程安全。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public class DCLSingleton {
// 使用 volatile 关键字保证可见性和有序性
private static volatile DCLSingleton instance;
private DCLSingleton() {}
public static DCLSingleton getInstance() {
// 第一次检查,避免不必要的同步
if (instance == null) {
synchronized (DCLSingleton.class) {
// 第二次检查,确保只有一个线程创建实例
if (instance == null) {
instance = new DCLSingleton();
}
}
}
return instance;
}
}
- 优点:线程安全,并且只有在第一次创建实例时才需要同步,性能较高。
- 缺点:实现相对复杂,需要使用
volatile
关键字来防止指令重排,确保正确性。
静态内部类(Static Inner Class)
这是目前公认的最佳实现方式。1
2
3
4
5
6
7
8
9
10
11
12
13public class InnerClassSingleton {
private InnerClassSingleton() {}
// 静态内部类
private static class SingletonHolder {
private static final InnerClassSingleton INSTANCE = new InnerClassSingleton();
}
public static InnerClassSingleton getInstance() {
// 只有第一次调用时,才会加载 SingletonHolder 类,从而创建实例
return SingletonHolder.INSTANCE;
}
}
- 优点:线程安全,延迟加载,性能高。JVM 保证了类的加载是线程安全的,并且只有在
getInstance()
方法被调用时,才会加载内部类,从而实现懒加载。 - 缺点:无明显缺点,是推荐的单例实现方式。
工厂设计模式
当我们需要创建一个产品对象时,
首先,我们会定义一个抽象的产品接口或者抽象类,明确规定产品的公共行为和属性。这样,无论后续添加多少具体产品,客户端都可以通过同一接口来操作它们。
其次,我们实现具体的产品类,这些类分别实现了抽象产品接口,包含各自独特的业务逻辑和功能。
接着,我们定义一个工厂接口或者抽象工厂类,声明一个创建产品对象的方法。该方法的职责是隐藏具体产品对象的实例化过程,客户端只需要调用这个方法即可获得产品实例。
然后,我们实现具体的工厂类,它们根据传入的参数或内部逻辑,决定创建哪一种具体的产品对象。这样,具体产品的创建细节完全被封装在工厂内部,客户端无需关心对象的创建过程。
最后,当客户端需要一个产品时,它只需调用工厂提供的创建方法,获得对应的产品对象,并直接使用。这种方式不仅降低了客户端与具体产品实现之间的耦合,也方便了系统的扩展和维护
工厂模式主要解决了以下几个问题:
- 解耦:将对象的创建与使用分离。你的业务逻辑代码不需要关心如何创建对象,只需要向工厂请求即可。
- 可扩展性:当需要增加新的产品时,只需增加一个具体工厂和产品类,而不需要修改原有的代码。这符合“开闭原则”(对扩展开放,对修改关闭)。
- 统一管理:工厂可以统一管理对象的创建,例如在创建对象时进行一些初始化操作,或者根据不同的参数创建不同的对象。
工厂模式主要有三种常见的实现方式,复杂度逐级递增。
简单工厂模式(Simple Factory Pattern)
也被称为静态工厂模式,它不属于 GoF(Gang of Four)的 23 种设计模式之一,但非常常用。
- 定义:一个工厂类负责创建所有产品类的实例。
- 结构:一个工厂类,一个抽象产品类,多个具体产品类。
- 缺点:工厂类承担了所有产品的创建逻辑,职责过重。当增加新产品时,需要修改工厂类的代码,违反了开闭原则。
示例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public interface Product {
void use();
}
public class ConcreteProductA implements Product {
public void use() {
System.out.println("使用产品A");
}
}
public class SimpleFactory {
public static Product createProduct(String type) {
if ("A".equals(type)) {
return new ConcreteProductA();
} else if ("B".equals(type)) {
return new ConcreteProductB();
}
return null;
}
}
// 使用
Product product = SimpleFactory.createProduct("A");
product.use();
工厂方法模式(Factory Method Pattern)
- 定义:定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法模式将对象的创建延迟到子类。
- 结构:一个抽象工厂,多个具体工厂,一个抽象产品,多个具体产品。
- 优点:符合开闭原则。当增加新产品时,只需增加一个对应的具体工厂,不需要修改任何已有的工厂代码。
- 缺点:每增加一个产品,就需要增加一个具体工厂,类的数量会增加。
示例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26public interface Product {
void use();
}
public class ConcreteProductA implements Product {
public void use() {
System.out.println("使用产品A");
}
}
public interface Factory {
Product createProduct();
}
public class ConcreteFactoryA implements Factory {
public Product createProduct() {
return new ConcreteProductA();
}
}
// 使用
Factory factory = new ConcreteFactoryA();
Product product = factory.createProduct();
product.use();
抽象工厂模式(Abstract Factory Pattern)
- 定义:提供一个接口,用于创建一系列相关或相互依赖的对象,而无需指定它们具体的类。
- 结构:一个抽象工厂,多个具体工厂,多个抽象产品,多个具体产品。
- 优点:可以创建一组相关联的对象,方便管理。
- 缺点:当需要增加新的产品系列时,需要修改抽象工厂接口和所有具体工厂,扩展起来比较复杂。
示例: 假设我们有产品A和产品B两个系列,每个系列都有不同的实现。
Java1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32// 抽象产品A
public interface AbstractProductA {
void useA();
}
// 抽象产品B
public interface AbstractProductB {
void useB();
}
// 抽象工厂
public interface AbstractFactory {
AbstractProductA createProductA();
AbstractProductB createProductB();
}
// 具体工厂1
public class ConcreteFactory1 implements AbstractFactory {
public AbstractProductA createProductA() {
return new ConcreteProductA1();
}
public AbstractProductB createProductB() {
return new ConcreteProductB1();
}
}
// 使用
AbstractFactory factory = new ConcreteFactory1();
AbstractProductA productA = factory.createProductA();
productA.useA();
生产者消费者设计模式
这个模式包含三个核心角色:
- 生产者(Producer):负责生成数据并将其放入共享的缓冲区中。
- 消费者(Consumer):负责从缓冲区中取出数据进行处理。
- 缓冲区(Buffer):一个共享的、线程安全的数据结构,用于连接生产者和消费者。它通常有容量限制。
生产者和消费者之间通过缓冲区进行通信,它们彼此独立,互不影响,从而实现了解耦。
(1)基于 synchronized 和 wait/notify 的实现
这是最基础的实现方式,使用 Java 内置的同步机制来控制线程间的协作。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65import java.util.LinkedList;
import java.util.Queue;
public class ProducerConsumer {
private static final int MAX_SIZE = 5; // 缓冲区最大容量
private final Queue<Integer> buffer = new LinkedList<>();
public void produce() throws InterruptedException {
int value = 0;
while (true) {
synchronized (this) {
// 如果缓冲区满了,生产者等待
while (buffer.size() == MAX_SIZE) {
wait();
}
// 生产数据并放入缓冲区
System.out.println("Produced: " + value);
buffer.add(value++);
// 唤醒消费者
notifyAll();
}
Thread.sleep(1000); // 模拟生产耗时
}
}
public void consume() throws InterruptedException {
while (true) {
synchronized (this) {
// 如果缓冲区为空,消费者等待
while (buffer.isEmpty()) {
wait();
}
// 消费数据
int value = buffer.poll();
System.out.println("Consumed: " + value);
// 唤醒生产者
notifyAll();
}
Thread.sleep(1500); // 模拟消费耗时
}
}
public static void main(String[] args) {
ProducerConsumer pc = new ProducerConsumer();
Thread producerThread = new Thread(() -> {
try {
pc.produce();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumerThread = new Thread(() -> {
try {
pc.consume();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producerThread.start();
consumerThread.start();
}
}
特点:简单直观,适合初学者理解线程间协作的基本原理。
缺点:synchronized 和 wait/notify 的粒度较粗,性能可能较低。
(2)基于 BlockingQueue 的实现
Java 提供了线程安全的阻塞队列(如 LinkedBlockingQueue),可以简化生产者-消费者的实现。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class ProducerConsumerWithBlockingQueue {
private static final int MAX_SIZE = 5;
private final BlockingQueue<Integer> buffer = new LinkedBlockingQueue<>(MAX_SIZE);
public void produce() throws InterruptedException {
int value = 0;
while (true) {
buffer.put(value); // 如果缓冲区满,自动阻塞
System.out.println("Produced: " + value);
value++;
Thread.sleep(1000); // 模拟生产耗时
}
}
public void consume() throws InterruptedException {
while (true) {
int value = buffer.take(); // 如果缓冲区空,自动阻塞
System.out.println("Consumed: " + value);
Thread.sleep(1500); // 模拟消费耗时
}
}
public static void main(String[] args) {
ProducerConsumerWithBlockingQueue pc = new ProducerConsumerWithBlockingQueue();
Thread producerThread = new Thread(() -> {
try {
pc.produce();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumerThread = new Thread(() -> {
try {
pc.consume();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producerThread.start();
consumerThread.start();
}
}
特点:BlockingQueue 内部实现了同步机制,代码更简洁。
优点:减少了手动管理锁和条件变量的复杂性,性能更高
JAVA基础与集合
面向对象编程
第一,封装(Encapsulation)。
封装是指将数据(属性)和行为(方法)捆绑在一起,并对外隐藏对象的内部实现细节。通过访问修饰符(如 private、protected 和 public),我们可以控制哪些部分是对外可见的,哪些是内部私有的。这种机制提高了代码的安全性和可维护性。例如,在 Java 中,我们通常会将类的属性设置为 private,并通过 getter 和 setter 方法提供受控的访问方式。
第二,继承(Inheritance)。
继承允许一个类(子类)基于另一个类(父类)来构建,从而复用父类的属性和方法。通过继承,子类不仅可以拥有父类的功能,还可以扩展或重写父类的行为。Java 中使用 extends 关键字实现继承。例如,我们可以通过定义一个通用的 Animal 类,然后让 Dog 和 Cat 类继承它,这样就避免了重复编写相同的代码。继承体现了“is-a”的关系,比如“狗是一个动物”。
第三,多态(Polymorphism)。
多态是指同一个方法调用可以根据对象的实际类型表现出不同的行为。多态分为两种形式:编译时多态(方法重载)和运行时多态(方法重写)。运行时多态是通过动态绑定实现的,即程序在运行时决定调用哪个方法。例如,如果父类 Animal 有一个 makeSound() 方法,子类 Dog 和 Cat 可以分别重写这个方法,当调用 animal.makeSound() 时,具体执行的是 Dog 或 Cat 的实现。多态使得代码更加灵活和可扩展。
接口、普通类和抽象类区别和共同点
第一个是定义上的区别。
普通类是一个完整的、具体的类,可以直接实例化为对象。它包含属性和方法,并且可以有构造方法。
抽象类是一个不能直接实例化的类,通常用来作为其他类的基类。它可以包含抽象方法(没有实现的方法)和具体方法(有实现的方法)。
接口是一种完全抽象的结构,用于定义行为规范。它只包含抽象方法(Java 8 之后可以包含默认方法和静态方法)。
第二个是方法实现上的区别。
普通类的所有方法都可以有具体实现(即方法体)。
抽象类可以包含具体方法和抽象方法。
接口默认只包含抽象方法(Java 8 后可以包含默认方法和静态方法)。
第三是继承关系上的区别。
普通类支持单继承(一个类只能继承一个父类)。
抽象类也支持单继承(一个类只能继承一个抽象类)。
接口支持多实现(一个类可以实现多个接口)。
第四是成员变量上的区别。
普通类和抽象类都可以有各种类型的成员变量(实例变量、静态变量等)。
接口只能有常量(public static final)。
接下来讲一下共同点,一共有3点。
首先,它们都是面向对象编程的基础结构,都可以用来组织代码,实现封装、继承和多态等特性。
其次,它们都可以包含方法,尽管接口中的方法默认是抽象的。
最后,它们都可以被继承或实现,普通类可以通过继承扩展功能,抽象类和接口则需要子类继承或实现后才能使
深拷贝和浅拷贝
深拷贝和浅拷贝的核心区别在于是否递归地复制对象内部的引用类型数据,接下来,我会从定义、实现方式以及使用场景三个方面详细讲解它们的区别。
首先是定义上的区别,
浅拷贝是指创建一个新对象,但新对象中的引用类型字段仍然指向原对象中引用类型的内存地址。换句话说,浅拷贝只复制了对象本身,而没有复制对象内部的引用类型数据。修改新对象中的引用类型数据会影响原对象。
深拷贝是指创建一个新对象,并且递归地复制对象内部的所有引用类型数据。换句话说,深拷贝不仅复制了对象本身,还复制了对象内部的所有引用类型数据。修改新对象中的引用类型数据不会影响原对象。
其次是实现方式上的区别,
浅拷贝可以使用 Object 类的 clone() 方法,也可以使用实现 Cloneable 接口并重写 clone() 的方法。
深拷贝可以手动对引用类型字段进行递归拷贝,也可以使用序列化(Serialization)的方式将对象序列化为字节流,再反序列化为新对象。
最后是使用场景上的区别,
浅拷贝适用于当对象内部的引用类型数据不需要独立复制的情况。
深拷贝适用于当对象内部的引用类型数据需要完全独立的情况。
int和Integer的区别
第一个是定义上的区别,
int 是 Java 的基本数据类型,直接存储数值,占用固定的 4 字节内存空间,范围是从 -2,147,483,648 到 2,147,483,647。
而 Integer 是 int 的包装类,它是一个对象,通过引用指向存储的数值,因此除了存储数值本身外,还需要额外的内存开销。
第二个是使用方式上的区别,
int 是一种原始类型,可以直接声明和赋值。
而 Integer 必须实例化后才能使用,它提供了更多的功能,比如支持泛型、序列化、缓存以及一些实用方法。
第三个是使用场景上的区别,
当需要高效处理整数时,优先使用 int。
当需要将整数作为对象使用时,选择 Integer
什么是自动拆箱和装箱
自动拆箱和装箱是为了提高代码的简洁性,它简化了基本数据类型与对应的包装类之间的转换。接下来我会详细解释什么是自动装箱和自动拆箱,以及它们的注意事项。
首先说一下自动装箱,
自动装箱是指将基本数据类型(如 int、double、boolean 等)自动转换为对应的包装类对象(如 Integer、Double、Boolean 等)。这个过程由编译器自动完成,无需手动调用包装类的构造方法或静态方法。
当存储一个基本数据类型到需要用到对象的场景中(例如集合),Java 编译器会检测到基本数据类型需要被转换为包装类对象,编译器会自动调用包装类的 valueOf() 方法来创建对应的包装类对象,生成的对象会被存储到目标位置。
接下来说一下自动拆箱,
自动拆箱是指将包装类对象(如 Integer、Double、Boolean 等)自动转换为对应的基本数据类型(如 int、double、boolean 等)。同样,这个过程也是由编译器自动完成的。
当你从一个需要对象的场景中取出值并赋给基本数据类型时,Java 编译器会检测到目标变量是一个基本数据类型。编译器会自动调用包装类的 xxxValue() 方法,比如 intValue()、doubleValue() 等,来获取基本数据类型的值。返回的基本数据类型值会被赋给目标变量。
最后说一下注意事项,一共有3点需要注意
第一个是性能问题,频繁的自动装箱和拆箱可能会导致额外的性能开销,因为每次都需要创建或转换对象。
第二个是空指针异常,如果对一个 null 的包装类对象进行自动拆箱操作,会抛出 NullPointerException。
第三个是缓存机制,某些包装类(如 Integer、Boolean 等)会对常用值进行缓存。
重载和重写的区别
第一是发生位置的不同,重载发生在同一个类中,而重写发生在父子类之间 。
第二是方法签名的不同,重载要求方法名相同,但参数列表必须不同。重写要求方法名和参数列表完全相同。
第三是返回值类型的不同,重载的返回值类型可以不同,而重写的返回值类型必须相同或是父类返回值类型的子类型。
第四是访问修饰符的不同,重载对访问修饰符没有限制,而重写的访问修饰符不能比父类更严格。
第五是异常声明的不同,重载对异常声明没有限制,而重写时,子类方法抛出的异常不能比父类方法抛出的异常范围更大。
第六是绑定关系的不同,重载是静态绑定 ,编译时确定调用哪个方法,而重写是动态绑定 ,运行时根据对象的实际类型决定调用哪个方法
==和queals的区别
== 和 equals 是 Java 中用于比较的两种方式,
第一个是比较内容上,== 比较的是内存地址(引用类型)或实际值(基本数据类型),而equals 比较的是逻辑上的相等性,具体取决于类是否重写了 equals 方法。
第二个是适用范围上,== 可用于基本数据类型和引用数据类型,而 equals 只能用于引用数据类型。
第三个是默认行为上,== 始终比较的是内存地址或实际值,而equals 在未重写时与 == 行为一致,但在某些类中(如 String、Integer 等)被重写以实现内容比较。
第四个是可扩展性上,== 是操作符,无法被修改或扩展,而equals 是方法,可以在自定义类中重写以实现特定的比较逻辑。
第五个是性能上,== 性能更高,因为它直接比较内存地址或值,而equals 性能可能较低,尤其是在复杂对象中需要逐个比较属性值
泛型以及作用
第一点是提高代码的复用性,它允许我们编写与类型无关的通用代码。
第二点是增强类型安全性,在没有泛型的情况下,集合类(如 ArrayList)默认存储的是 Object 类型,取出元素时需要手动进行类型转换,容易引发 ClassCastException。而泛型在编译时就会进行类型检查,避免了运行时的类型错误。
第三点是简化代码,使用泛型后,我们无需显式地进行类型转换,减少了冗余代码,提高了代码的可读性和维护性。
第四点是支持复杂的类型约束,泛型可以通过通配符(如 ? extends T 和 ? super T)实现更复杂的类型限制,满足特定场景下的需求。
什么是反射以及应用
反射(Reflection)是 Java 中一种强大的机制,它允许程序在运行时动态地获取类的信息并操作类的属性、方法和构造器。
首先说一下什么是反射,
反射是一种在运行时动态获取类信息的能力。通过反射,我们可以在程序运行时加载类、获取类的结构(如字段、方法、构造器等),甚至可以调用类的方法或修改字段的值。
其次,反射主要应用在这5个场景,
第一个是框架开发,很多 Java 框架都有使用反射,比如如 Spring、Hibernate 等。
第二个是动态代理,动态代理是反射的一个重要应用,常用于 AOP(面向切面编程)。通过反射,我们可以在运行时动态生成代理类,拦截方法调用并添加额外逻辑。
第三个是注解处理,注解本身不会对程序产生任何影响,但通过反射,我们可以在运行时读取注解信息并执行相应的逻辑。
第四个是插件化开发,在某些场景下,我们需要动态加载外部的类或模块。反射可以帮助我们在运行时加载这些类并调用其方法,从而实现插件化开发。
第五个是测试工具,单元测试框架(如 JUnit)利用反射来发现和运行测试方法,而无需手动指定每个测试用例。
String,StringBuilder以及StringBuffer
StringBuilder以及StringBuffer是一个可变的字符序列,与 String 不同,StringBuffer 的内容是可以被修改的。它的核心特点是线程安全和高效的字符串操作。
1、可变性 :String
是不可变的(Immutable),一旦创建,内容无法修改,每次修改都会生成一个新的对象。StringBuilder
和 StringBuffer
是可变的(Mutable),可以直接对字符串内容进行修改而不会创建新对象。
2、线程安全性 :String
因为不可变,天然线程安全。StringBuilder
不是线程安全的,适用于单线程环境。StringBuffer
是线程安全的,其方法通过 synchronized
关键字实现同步,适用于多线程环境。
3、性能 :String
性能最低,尤其是在频繁修改字符串时会生成大量临时对象,增加内存开销和垃圾回收压力。StringBuilder
性能最高,因为它没有线程安全的开销,适合单线程下的字符串操作。StringBuffer
性能略低于 StringBuilder
,因为它的线程安全机制引入了同步开销。
4、使用场景 :如果字符串内容固定或不常变化,优先使用 String
。如果需要频繁修改字符串且在单线程环境下,使用 StringBuilder
。如果需要频繁修改字符串且在多线程环境下,使用 StringBuffer
StringBuffer 的4个特点,
第一个是它具有可变性,可以在原有对象上直接修改字符串内容,而无需创建新的对象。
第二个它是线程安全的,StringBuffer 的所有方法都通过 synchronized 关键字修饰,因此它是线程安全的。 在多线程环境下,多个线程可以同时操作同一个 StringBuffer 对象,而不会引发数据竞争或不一致问题。
第三个是性能相对较好,StringBuffer 内部使用一个可扩容的字符数组来存储数据,当容量不足时会自动扩展。相比于 String 的不可变性(每次修改都会生成新对象),StringBuffer 在频繁修改字符串时性能更高。而相比于非线程安全的 StringBuilder ,性能略低。
第四个是包含丰富的 API,比如:append():追加内容到字符串末尾。 insert():在指定位置插入内容。delete():删除指定范围的内容。 reverse():反转字符串内容。 toString():将 StringBuffer 转换为 String。
特性 | String | StringBuilder | StringBuffer |
---|---|---|---|
不可变性 | 不可变 | 可变 | 可变 |
线程安全 | 是(因不可变) | 否 | 是(同步方法) |
性能 | 低(频繁修改时) | 高(单线程) | 中(多线程安全) |
适用场景 | 静态字符串 | 单线程动态字符串 | 多线程动态字符串 |
数组与集合
数组和集合的区别:
- 数组是固定长度的数据结构,一旦创建长度就无法改变,而集合是动态长度的数据结构,可以根据需要动态增加或减少元素。
- 数组可以包含基本数据类型和对象,而集合只能包含对象。
- 数组可以直接访问元素,而集合需要通过迭代器或其他方法访问元素。
- ArrayList: 动态数组,实现了List接口,支持动态增长。
- LinkedList: 双向链表,也实现了List接口,支持快速的插入和删除操作。
- HashMap: 基于哈希表的Map实现,存储键值对,通过键快速查找值。
- HashSet: 基于HashMap实现的Set集合,用于存储唯一元素。
- TreeMap: 基于红黑树实现的有序Map集合,可以按照键的顺序进行排序。
- LinkedHashMap: 基于哈希表和双向链表实现的Map集合,保持插入顺序或访问顺序。
- PriorityQueue: 优先队列,可以按照比较器或元素的自然顺序进行排序。
Java 集合框架分为单列集合和双列集合两大类。首先是单列集合,它以 Collection 为核心接口,主要分为三种:第一种是 List,用于存储有序且可重复的元素,比如 ArrayList 基于动态数组,访问快但插入删除慢;LinkedList 基于双向链表,插入删除快但访问慢;Vector 是线程安全的老版本实现。
第二种是 Set,用于存储无序且不可重复的元素,比如 HashSet 基于哈希表,查找快但无序;LinkedHashSet 保留插入顺序;TreeSet 基于红黑树,按顺序存储。
第三种是 Queue,用于队列操作,比如 PriorityQueue 基于堆实现按优先级处理,ArrayDeque 是双端队列支持栈和队列操作。
接下来是双列集合,它以 Map 为核心接口,用于存储键值对,比如:HashMap 基于哈希表,键无序且查找快;LinkedHashMap 保留插入顺序;TreeMap 基于红黑树,按键的自然顺序排序;Hashtable 是线程安全的老版本实现。
线程安全的集合
在 java.util 包中的线程安全的类主要 2 个,其他都是非线程安全的。
- Vector:线程安全的动态数组,其内部方法基本都经过synchronized修饰,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。Vector 内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。
- Hashtable:线程安全的哈希表,HashTable 的加锁方法是给每个方法加上 synchronized 关键字,这样锁住的是整个 Table 对象,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用,如果要保证线程安全的哈希表,可以用ConcurrentHashMap。
java.util.concurrent 包提供的都是线程安全的集合:
并发Map:
- ConcurrentHashMap:它与 HashTable 的主要区别是二者加锁粒度的不同,在JDK1.7,ConcurrentHashMap加的是分段锁,也就是Segment锁,每个Segment 含有整个 table 的一部分,这样不同分段之间的并发操作就互不影响。在JDK 1.8 ,它取消了Segment字段,直接在table元素上加锁,实现对每一行进行加锁,进一步减小了并发冲突的概率。对于put操作,如果Key对应的数组元素为null,则通过CAS操作(Compare and Swap)将其设置为当前值。如果Key对应的数组元素(也即链表表头或者树的根元素)不为null,则对该元素使用 synchronized 关键字申请锁,然后进行操作。如果该 put 操作使得当前链表长度超过一定阈值,则将该链表转换为红黑树,从而提高寻址效率。
- ConcurrentSkipListMap:实现了一个基于SkipList(跳表)算法的可排序的并发集合,SkipList是一种可以在对数预期时间内完成搜索、插入、删除等操作的数据结构,通过维护多个指向其他元素的“跳跃”链接来实现高效查找。
并发Set:
- ConcurrentSkipListSet:是线程安全的有序的集合。底层是使用ConcurrentSkipListMap实现。
- CopyOnWriteArraySet:是线程安全的Set实现,它是线程安全的无序的集合,可以将它理解成线程安全的HashSet。有意思的是,CopyOnWriteArraySet和HashSet虽然都继承于共同的父类AbstractSet;但是,HashSet是通过“散列表”实现的,而CopyOnWriteArraySet则是通过“动态数组(CopyOnWriteArrayList)”实现的,并不是散列表。
并发List:
- CopyOnWriteArrayList:它是 ArrayList 的线程安全的变体,其中所有写操作(add,set等)都通过对底层数组进行全新复制来实现,允许存储 null 元素。即当对象进行写操作时,使用了Lock锁做同步处理,内部拷贝了原数组,并在新数组上进行添加操作,最后将新数组替换掉旧数组;若进行的读操作,则直接返回结果,操作过程中不需要进行同步。
并发 Queue:
- ConcurrentLinkedQueue:是一个适用于高并发场景下的队列,它通过无锁的方式(CAS),实现了高并发状态下的高性能。通常,ConcurrentLinkedQueue 的性能要好于 BlockingQueue 。
- BlockingQueue:与 ConcurrentLinkedQueue 的使用场景不同,BlockingQueue 的主要功能并不是在于提升高并发时的队列性能,而在于简化多线程间的数据共享。BlockingQueue 提供一种读写阻塞等待的机制,即如果消费者速度较快,则 BlockingQueue 则可能被清空,此时消费线程再试图从 BlockingQueue 读取数据时就会被阻塞。反之,如果生产线程较快,则 BlockingQueue 可能会被装满,此时,生产线程再试图向 BlockingQueue 队列装入数据时,便会被阻塞等待。
并发 Deque:
- LinkedBlockingDeque:是一个线程安全的双端队列实现。它的内部使用链表结构,每一个节点都维护了一个前驱节点和一个后驱节点。LinkedBlockingDeque 没有进行读写锁的分离,因此同一时间只能有一个线程对其进行操作
- ConcurrentLinkedDeque:ConcurrentLinkedDeque是一种基于链接节点的无限并发链表。可以安全地并发执行插入、删除和访问操作。当许多线程同时访问一个公共集合时,ConcurrentLinkedDeque是一个合适的选择
ArrayList与LinkedList区别
ArrayList
基于动态数组实现,它允许快速的随机访问,即通过索引访问元素的时间复杂度为 O (1)。在添加和删除元素时,如果操作位置不是列表末尾,可能需要移动大量元素,性能相对较低。适用于需要频繁随机访问元素,而对插入和删除操作性能要求不高的场景,如数据的查询和展示等。LinkedList
基于双向链表实现,在插入和删除元素时,只需修改链表的指针,不需要移动大量元素,时间复杂度为 O (1)。但随机访问元素时,需要从链表头或链表尾开始遍历,时间复杂度为 O (n)。适用于需要频繁进行插入和删除操作的场景,如队列、栈等数据结构的实现,以及需要在列表中间频繁插入和删除元素的情况。
常见的List集合(线程安全):
Vector
和ArrayList
类似,也是基于数组实现。**Vector
中的方法大多是同步的,这使得它在多线程环境下可以保证数据的一致性**,但在单线程环境下,由于同步带来的开销,性能会略低于ArrayList
。CopyOnWriteArrayList
在对列表进行修改(如添加、删除元素)时,会创建一个新的底层数组,将修改操作应用到新数组上,而读操作仍然在原数组上进行,这样可以保证读操作不会被写操作阻塞,实现了读写分离,提高了并发性能。适用于读操作远远多于写操作的并发场景,如事件监听列表等,在这种场景下可以避免大量的锁竞争,提高系统的性能和响应速度。
大多数 Java 集合类(如 ArrayList
、HashMap
)的迭代器都内置了一个修改计数器。
- 初始化:当你创建一个迭代器时,它会记录当前集合的修改次数。
- 遍历:在
foreach
循环中,每次调用迭代器的next()
方法时,它都会检查集合当前的修改次数是否与它初始化时记录的次数一致。 - 异常:如果你在循环体内通过集合自身的方法(如
list.add()
或list.remove()
)修改了集合,修改计数器就会增加。下次迭代器检查时,会发现计数不匹配,立即抛出ConcurrentModificationException
。
如果你需要在循环中删除元素,有以下两种安全的方法:
- 使用迭代器自身的
remove()
方法:这是最安全、最推荐的方法。迭代器的remove()
方法知道如何安全地从底层集合中删除元素,同时会更新修改计数器。 - 使用
for
循环和索引:如果你从后往前遍历,那么删除元素时不会影响后续的索引
ArrayList和LinkedList都是Java中常见的集合类,它们都实现了List接口。
- 底层数据结构不同:ArrayList使用数组实现,通过索引进行快速访问元素。LinkedList使用链表实现,通过节点之间的指针进行元素的访问和操作。
- 插入和删除操作的效率不同:ArrayList在尾部的插入和删除操作效率较高,但在中间或开头的插入和删除操作效率较低,需要移动元素。LinkedList在任意位置的插入和删除操作效率都比较高,因为只需要调整节点之间的指针,但是LinkedList是不支持随机访问的,所以除了头结点外插入和删除的时间复杂度都是0(n),效率也不是很高所以LinkedList基本没人用。
- 随机访问的效率不同:ArrayList支持通过索引进行快速随机访问,时间复杂度为O(1)。LinkedList需要从头或尾开始遍历链表,时间复杂度为O(n)。
- 空间占用:ArrayList在创建时需要分配一段连续的内存空间,因此会占用较大的空间。LinkedList每个节点只需要存储元素和指针,因此相对较小。
- 使用场景:ArrayList适用于频繁随机访问和尾部的插入删除操作,而LinkedList适用于频繁的中间插入删除操作和不需要随机访问的场景。
- 线程安全:这两个集合都不是线程安全的,Vector是线程安全的
第一点是存储空间上的差异
ArrayList:底层是基于数组实现的,要求物理内存上必须是连续的。
LinkedList:底层是基于双向链表实现的,逻辑上是连续的,但物理内存不需要连续。
第二点是随机访问效率的差异
ArrayList:由于底层是数组,支持按索引进行 O(1) 的随机访问。
LinkedList:由于是链表结构,随机访问需要从头遍历到目标位置,时间复杂度为 O(N)。
第三点是头插效率的差异
ArrayList:头插时,需要移动大量元素来腾出空间,效率较低,时间复杂度为 O(N)。
LinkedList:头插时,只需修改指针的指向,效率高,时间复杂度为 O(1)。
第四点是扩容效率的差异
ArrayList:插入时,如果空间不足需要扩容,扩容会新建一个更大的数组,并将旧数据复制过去,影响性能。
LinkedList:没有容量的概念,插入时只需更新节点指针,不涉及扩容操作。
第五点是应用场景的差异
ArrayList:适合用于对元素的高效存储和频繁访问的场景。
LinkedList:适合任意位置插入和删除频繁的场景。
如何使得ArrayList安全
不是线程安全的,ArrayList变成线程安全的方式有:
- 使用Collections类的synchronizedList方法将ArrayList包装成线程安全的List:
1 | List<String> synchronizedList = Collections.synchronizedList(arrayList); |
- 使用CopyOnWriteArrayList类代替ArrayList,它是一个线程安全的List实现:
1 | CopyOnWriteArrayList<String> copyOnWriteArrayList = new CopyOnWriteArrayList<>(arrayList); |
- 使用Vector类代替ArrayList,Vector是线程安全的List实现:
为什么ArrayList不是线程安全的,具体来说是哪里不安全
ArrayList,add 增加元素的代码如下:1
2
3
4
5public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
在高并发添加数据下,ArrayList会暴露三个问题;
- 部分值为null:当线程1走到了扩容那里发现当前size是9,而数组容量是10,所以不用扩容,这时候cpu让出执行权,线程2也进来了,发现size是9,而数组容量是10,所以不用扩容,这时候线程1继续执行,将数组下标索引为9的位置set值了,还没有来得及执行size++,这时候线程2也来执行了,又把数组下标索引为9的位置set了一遍,这时候两个先后进行size++,导致下标索引10的地方就为null了。
- 索引越界异常:线程1走到扩容那里发现当前size是9,数组容量是10不用扩容,cpu让出执行权,线程2也发现不用扩容,这时候数组的容量就是10,而线程1 set完之后size++,这时候线程2再进来size就是10,数组的大小只有10,而你要设置下标索引为10的就会越界(数组的下标索引从0开始);
- size与我们add的数量不符:这个基本上每次都会发生,这个理解起来也很简单,因为size++本身就不是原子操作,可以分为三步:获取size的值,将size的值加1,将新的size值覆盖掉原来的,线程1和线程2拿到一样的size值加完了同时覆盖,就会导致一次没有加上,所以肯定不会与我们add的数量保持一致的;
ArrayList扩容机制
ArrayList在添加元素时,如果当前元素个数已经达到了内部数组的容量上限,就会触发扩容操作。ArrayList的扩容操作主要包括以下几个步骤:
- 计算新的容量:一般情况下,新的容量会扩大为原容量的1.5倍(在JDK 10之后,扩容策略做了调整),然后检查是否超过了最大容量限制。
- 创建新的数组:根据计算得到的新容量,创建一个新的更大的数组。
- 将元素复制:将原来数组中的元素逐个复制到新数组中。
- 更新引用:将ArrayList内部指向原数组的引用指向新数组。
- 完成扩容:扩容完成后,可以继续添加新元素。
ArrayList的扩容操作涉及到数组的复制和内存的重新分配,所以在频繁添加大量元素时,扩容操作可能会影响性能。为了减少扩容带来的性能损耗,可以在初始化ArrayList时预分配足够大的容量,避免频繁触发扩容操作。之所以扩容是 1.5 倍,是因为 1.5 可以充分利用移位操作,减少浮点数或者运算时间和运算次数1
2// 新容量计算
int newCapacity = oldCapacity + (oldCapacity >> 1);
CopyonWriteArraylist是如何实现线程安全的
CopyOnWriteArrayList底层也是通过一个数组保存数据,使用volatile关键字修饰数组,保证当前线程对数组对象重新赋值后,其他线程可以及时感知到。
在写入操作时,加了一把互斥锁ReentrantLock以保证线程安全。
写入新元素时,首先会先将原来的数组拷贝一份并且让原来数组的长度+1后就得到了一个新数组,新数组里的元素和旧数组的元素一样并且长度比旧数组多一个长度,然后将新加入的元素放置都在新数组最后一个位置后,用新数组的地址替换掉老数组的地址就能得到最新的数据了。
在执行替换地址操作之前,读取的是老数组的数据,数据是有效数据;执行替换地址操作之后,读取的是新数组的数据,同样也是有效数据,而且使用该方式能比读写都加锁要更加的效率。读是没有加锁的,所以读是一直都能读
Map
常见的Map集合(非线程安全):
HashMap
是基于哈希表实现的Map
,它根据键的哈希值来存储和获取键值对,JDK 1.8中是用数组+链表+红黑树来实现的。HashMap
是非线程安全的,在多线程环境下,当多个线程同时对HashMap
进行操作时,可能会导致数据不一致或出现死循环等问题。比如在扩容时,多个线程可能会同时修改哈希表的结构,从而破坏数据的完整性。LinkedHashMap
继承自HashMap
,它在HashMap
的基础上,使用双向链表维护了键值对的插入顺序或访问顺序,使得迭代顺序与插入顺序或访问顺序一致。由于它继承自HashMap
,在多线程并发访问时,同样会出现与HashMap
类似的线程安全问题。TreeMap
是基于红黑树实现的Map
,它可以对键进行排序,默认按照自然顺序排序,也可以通过指定的比较器进行排序。TreeMap
是非线程安全的,在多线程环境下,如果多个线程同时对TreeMap
进行插入、删除等操作,可能会破坏红黑树的结构,导致数据不一致或程序出现异常。
常见的Map集合(线程安全):
Hashtable
是早期 Java 提供的线程安全的Map
实现,它的实现方式与HashMap
类似,但在方法上使用了synchronized
关键字来保证线程安全。通过在每个可能修改Hashtable
状态的方法上加上synchronized
关键字,使得在同一时刻,只能有一个线程能够访问Hashtable
的这些方法,从而保证了线程安全。ConcurrentHashMap
在 JDK 1.8 以前采用了分段锁等技术来提高并发性能。在ConcurrentHashMap
中,将数据分成多个段(Segment),每个段都有自己的锁。在进行插入、删除等操作时,只需要获取相应段的锁,而不是整个Map
的锁,这样可以允许多个线程同时访问不同的段,提高了并发访问的效率。在 JDK 1.8 以后是通过 volatile + CAS 或者 synchronized 来保证线程安全的。
HashMap
HashMap 是一种基于哈希表的键值对存储数据结构,它的查找很高效,存储性能较好,被广泛用于高效管理和访问数据。HashMap 的实现基于数组和链表或红黑树,JDK 8之前是数组+链表,JDK 8及之后是数组+链表+红⿊树。
HashMap
底层的数据结构是数组(Node<K,V>[] table
)+ 链表 + 红黑树。 (JDK8之前称之为HashEntry,与Node本质相同)
- 数组:
HashMap
的主体是一个数组,数组的每个元素都是一个Node
对象。 - 链表:当多个键的哈希值(
hash
)冲突时,这些Node
对象会以链表的形式存储在数组的同一个位置。 - 红黑树:为了解决链表过长导致的查找效率下降问题,JDK 1.8 引入了红黑树。当链表长度超过8,并且数组长度达到64时,链表会转换为红黑树。这样,查找的平均时间复杂度从 O(n) 降为 O(logn)。
当插入一个数据时,看hashmap是否初始化(默认长度为16,插入数据时扩容为16),根据它的hashCode计算对应索引,然后看对应索引位置是否包含值,如果不包含则直接在该位置创建一个新的Entry对象来存储键值对。将要添加的键值对作为该Entry的键和值,并保存在数组的对应位置。将HashMap的修改次数(modCount)加1,以便在进行迭代时发现并发修改。如果相同,表示找到了相同的键,直接将新的值替换旧值,如果第一个键值对的哈希码和键不相同,则需要遍历链表或红黑树来查找是否有相同的键:
如果键值对集合是链表结构,从链表的头部开始逐个比较键的哈希码和equals()方法,直到找到相同的键或达到链表末尾。
- 如果找到了相同的键,则使用新的值取代旧的值,即更新键对应的值。
- 如果没有找到相同的键,则将新的键值对添加到链表的头部。
如果键值对集合是红黑树结构,在红黑树中使用哈希码和equals()方法进行查找。根据键的哈希码,定位到红黑树中的某个节点,然后逐个比较键,直到找到相同的键或达到红黑树末尾。
- 如果找到了相同的键,则使用新的值取代旧的值,即更新键对应的值。
- 如果没有找到相同的键,则将新的键值对添加到红黑树中。
插入值后,如果链表长度超过阈值,且HashMap的数组长度大于等于64,则会将链表转换为红黑树,以提高查询效率。如果键值对的数量(size)与数组的长度的比值大于阈值,则需要进行扩容操作.
为了进一步减少哈希冲突,HashMap
并没有直接使用 hashCode()
的结果,而是对其进行了二次处理。在 Java 8 之后,这个处理方法如下:1
2
3
4static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这个 h ^ (h >>> 16)
操作,将哈希值的高 16 位与低 16 位进行异或运算。这样做的好处是,即使哈希值的高位发生变化,低位也能被影响,从而让哈希值的分布更加均匀,进一步降低哈希冲突的概率。
- hashMap虽然支持key和value为null,但是null作为key只能有一个,null作为value可以有多个;
- 因为hashMap中,如果key值一样,那么会覆盖相同key值的value为最新,所以key为null只能有一个
哈希冲突的解决方法
- 链接法:使用链表或其他数据结构来存储冲突的键值对,将它们链接在同一个哈希桶中。
- 开放寻址法:在哈希表中找到另一个可用的位置来存储冲突的键值对,而不是存储在链表中。常见的开放寻址方法包括线性探测、二次探测和双重散列。
- 再哈希法(Rehashing):当发生冲突时,使用另一个哈希函数再次计算键的哈希值,直到找到一个空槽来存储键值对。
- 哈希桶扩容:当哈希冲突过多时,可以动态地扩大哈希桶的数量,重新分配键值对,以减少冲突的概率
为什么使用红黑树而不是B+树
在 HashMap 的实现中,链表长度超过一定阈值(默认 8)时会转换为红黑树,而不是 B+ 树,主要原因如下:
时间复杂度:红黑树是一种平衡二叉搜索树,查找、插入、删除的时间复杂度是 O(log n)。B+ 树虽然在数据库中有优势,但其设计更适合磁盘存储和范围查询,不适合频繁的动态操作。
内存占用:红黑树的内存使用率较低,而 B+ 树由于每个节点存储更多指针,内存开销较大,且不必要。HashMap 的设计目标:HashMap 的目标是高效查找,而红黑树能很好地满足这一点,并且它与内存结构更契合。B+ 树则更适合大规模外存数据管理
为什么用红黑树而不是平衡二叉树
- 平衡二叉树追求的是一种 “完全平衡” 状态:任何结点的左右子树的高度差不会超过 1,优势是树的结点是很平均分配的。这个要求实在是太严了,导致每次进行插入/删除节点的时候,几乎都会破坏平衡树的第二个规则,进而我们都需要通过左旋和右旋来进行调整,使之再次成为一颗符合要求的平衡树。
- 红黑树不追求这种完全平衡状态,而是追求一种 “弱平衡” 状态:整个树最长路径不会超过最短路径的 2 倍。优势是虽然牺牲了一部分查找的性能效率,但是能够换取一部分维持树平衡状态的成本。与平衡树不同的是,红黑树在插入、删除等操作,不会像平衡树那样,频繁破坏红黑树的规则,所以不需要频繁调整,这也是我们为什么大多数情况下使用红黑树的原因。
HashMap为什么线程不安全
hashmap不是线程安全的,hashmap在多线程会存在下面的问题:
JDK 1.7 HashMap 采用数组 + 链表的数据结构,多线程背景下,在数组扩容的时候,存在 Entry 链死循环和数据丢失问题。
JDK 1.8 HashMap 采用数组 + 链表 + 红黑二叉树的数据结构,优化了 1.7 中数组扩容的方案,解决了 Entry 链死循环和数据丢失问题。但是多线程背景下,put 方法存在数据覆盖的问题。
类似答案
- JDK1.7中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
- 多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在JDK 1.7和 JDK 1.8 中都存在。
如果要保证线程安全,可以通过这些方法来保证:
- 多线程环境可以使用Collections.synchronizedMap同步加锁的方式,还可以使用HashTable,但是同步的方式显然性能不达标,而ConurrentHashMap更适合高并发场景使用。
- ConcurrentHashmap在JDK1.7和1.8的版本改动比较大,1.7使用Segment+HashEntry分段锁的方式实现,1.8则抛弃了Segment,改为使用CAS+synchronized+Node实现,同样也加入了红黑树,避免链表过长导致性能的问题。
HashMap重写equal与hashCode方法
HashMap使用Key对象的hashCode()和equals方法去决定key-value对的索引。当我们试着从HashMap中获取值的时候,这些方法也会被用到。如果这些方法没有被正确地实现,在这种情况下,两个不同Key也许会产生相同的hashCode()和equals()输出,HashMap将会认为它们是相同的,然后覆盖它们,而非把它们存储到不同的地方。
同样的,所有不允许存储重复数据的集合类都使用hashCode()和equals()去查找重复,所以正确实现它们非常重要。equals()和hashCode()的实现应该遵循以下规则:
- 如果o1.equals(o2),那么o1.hashCode() == o2.hashCode()总是为true的。
- 如果o1.hashCode() == o2.hashCode(),并不意味着o1.equals(o2)会为true。
HashMap在比较元素时,会先通过hashCode进行比较,相同的情况下再通过equals进行比较。
所以 equals相等的两个对象,hashCode一定相等。hashCode相等的两个对象,equals不一定相等(比如散列冲突的情况)
重写了equals方法,不重写hashCode方法时,可能会出现equals方法返回为true,而hashCode方法却返回false,这样的一个后果会导致在hashmap等类中存储多个一模一样的对象,导致出现覆盖存储的数据的问题,这与hashmap只能有唯一的key的规范不符合
HashMap扩容操作
- 创建一个新的两倍大小的数组。
- 将旧数组中的键值对重新计算哈希码并分配到新数组中的位置。
- 更新HashMap的数组引用和阈值参数。
关键问题
扩容条件,达到容量的阈值(负载因子是否超过0.75)
为什么扩容两倍,方便进行按位与运算,以及扩容量增加. 不需要重新计算哈希,只需要看原来的哈希值在新容量的最高位是1还是0,是0的话索引没变,是1的话索引变成“原索引+旧容量”。
新的索引位置如何确定,通过哈希值与新容量相与,看最高位是否为1,如果为1,表示需要移动,新的索引位置变为“原索引+旧容量”.
多线程下出现的问题,JDK1.7时使用头插法插入元素,并发情况下可能出现环形链表,而JDK1.8使用尾插法避免了这个问题. 但是HashMap还是会出现数据覆盖丢失的问题.多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失
HashMap的key可以为null,hashMap中使用hash()方法来计算key的哈希值,当key为空时,直接另key的哈希值为0,不走key.hashCode()方法.
- hashMap虽然支持key和value为null,但是null作为key只能有一个,null作为value可以有多个;
- 因为hashMap中,如果key值一样,那么会覆盖相同key值的value为最新,所以key为null只能有一个。
HashMap的put与get操作
- 存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。
- 获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度
调用 get 方法有几点需要注意的地方:
- 空指针异常(NullPointerException):如果你尝试用
null
作为键调用get
方法,而HashMap
没有被初始化(即为null
),那么会抛出空指针异常。不过,如果HashMap
已经初始化,使用null
作为键是允许的,因为HashMap
支持null
键。 - 线程安全:
HashMap
本身不是线程安全的。如果在多线程环境中,没有适当的同步措施,同时对HashMap
进行读写操作可能会导致不可预测的行为。例如,在一个线程中调用get
方法读取数据,而另一个线程同时修改了结构(如增加或删除元素),可能会导致读取操作得到错误的结果或抛出ConcurrentModificationException
。如果需要在多线程环境中使用类似HashMap
的数据结构,可以考虑使用ConcurrentHashMap
。
HashMap多线程可能出现的问题
- JDK1.7中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
- 多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在JDK 1.7和 JDK 1.8 中都存在。(数据丢失)
HashMap大小为什么是2的n次方大小
在 JDK1.7 中,HashMap 整个扩容过程就是分别取出数组元素,一般该元素是最后一个放入链表中的元素,然后遍历以该元素为头的单向链表元素,依据每个被遍历元素的 hash 值计算其在新数组中的下标,然后进行交换。这样的扩容方式会将原来哈希冲突的单向链表尾部变成扩容后单向链表的头部。
而在 JDK 1.8 中,HashMap 对扩容操作做了优化。由于扩容数组的长度是 2 倍关系,所以对于假设初始 tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变化(左移一位就是 2 倍),在扩容中只用判断原来的 hash 值和左移动的一位(newtable 的值)按位与操作是 0 或 1 就行,0 的话索引不变,1 的话索引变成原索引加上扩容前数组。
之所以能通过这种“与运算“来重新分配索引,是因为 hash 值本来就是随机的,而 hash 按位与上 newTable 得到的 0(扩容前的索引位置)和 1(扩容前索引位置加上扩容前数组长度的数值索引处)就是随机的,所以扩容的过程就能把之前哈希冲突的元素再随机分布到不同的索引中去
工作原理
put(K key, V value)
:- 计算
key
的hashCode
。 - 通过高位运算(
key.hashCode() ^ (key.hashCode() >>> 16)
)来混合哈希值,减少哈希冲突。 - 使用混合后的哈希值和数组长度的
&
运算(hash & (table.length - 1)
)来确定键值对在数组中的索引位置。 - 如果该位置没有元素,直接存入。
- 如果有元素,遍历链表或红黑树进行查找:如果找到相同的
key
,则更新value
;如果找不到,则插入到链表或红黑树的末尾。
- 计算
get(K key)
:- 计算
key
的哈希值并确定索引位置。 - 遍历该位置的链表或红黑树,比较
key
的哈希值和equals()
方法来找到对应的Node
,返回value
。
- 计算
HashMap 是一种基于哈希表的键值对存储数据结构,它的查找很高效,存储性能较好,被广泛用于高效管理和访问数据。HashMap 的实现基于数组和链表或红黑树,JDK 8之前是数组+链表,JDK 8及之后是数组+链表+红⿊树。
当存储一个键值对时,首先,HashMap 会用哈希函数计算出键的哈希值(hash code)。其次,然后用哈希值决定键值对存放的位置(也就是数组的索引)。然后,如果对应位置的“桶”(数组位置)是空的,键值对会直接存进去。
最后,如果“桶”里已经有其他键值对了(发生哈希冲突),HashMap 会用链表或红黑树来存储多个键值对。当用键去查找对应的值时,首先,根据键计算哈希值,快速定位到对应的“桶”(数组索引)。其次,如果桶内只有一个键值对,直接返回其值。然后,如果桶内有多个键值对(链表或红黑树),逐一比对键,找到匹配的目标键。最后,返回匹配键对应的值.
put/get方法一定安全吗
- 存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。
获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度
空指针异常(NullPointerException):如果你尝试用
null
作为键调用get
方法,而HashMap
没有被初始化(即为null
),那么会抛出空指针异常。不过,如果HashMap
已经初始化,使用null
作为键是允许的,因为HashMap
支持null
键。- 线程安全:
HashMap
本身不是线程安全的。如果在多线程环境中,没有适当的同步措施,同时对HashMap
进行读写操作可能会导致不可预测的行为。例如,在一个线程中调用get
方法读取数据,而另一个线程同时修改了结构(如增加或删除元素),可能会导致读取操作得到错误的结果或抛出ConcurrentModificationException
。如果需要在多线程环境中使用类似HashMap
的数据结构,可以考虑使用ConcurrentHashMap
。
HashMap一般使用 string 做 key,因为 String对象是不可变的,一旦创建就不能被修改,这确保了Key的稳定性。如果Key是可变的,可能会导致hashCode和equals方法的不一致,进而影响HashMap的正确性
HashMap负载因子
HashMap 负载因子 loadFactor 的默认值是 0.75,当 HashMap 中的元素个数超过了容量的 75% 时,就会进行扩容。
默认负载因子为 0.75,是因为它提供了空间和时间复杂度之间的良好平衡。
负载因子太低会导致大量的空桶浪费空间,负载因子太高会导致大量的碰撞,降低性能。0.75 的负载因子在这两个因素之间取得了良好的平衡
HashMap与HashTable
- HashMap线程不安全,效率高一点,可以存储null的key和value,null的key只能有一个,null的value可以有多个。默认初始容量为16,每次扩充变为原来2倍。创建时如果给定了初始容量,则扩充为2的幂次方大小。底层数据结构为数组+链表,插入元素后如果链表长度大于阈值(默认为8),先判断数组长度是否小于64,如果小于,则扩充数组,反之将链表转化为红黑树,以减少搜索时间。
- HashTable线程安全,效率低一点,其内部方法基本都经过synchronized修饰,不可以有null的key和value。默认初始容量为11,每次扩容变为原来的2n+1。创建时给定了初始容量,会直接用给定的大小。底层数据结构为数组+链表。它基本被淘汰了,要保证线程安全可以用ConcurrentHashMap。
- 怎么用:HashMap主要用来存储键值对,可以调用put方法向其中加入元素,调用get方法获取某个键对应的值,也可以通过containsKey方法查看某个键是否存在等
JDK1.7和1.8的HashMap差异
为了提升性能,JDK 1.8 对 HashMap 进行了显著优化,使得其查找效率更高,并有效解决了一些潜在的多线程问题。以下从数据结构、数据重哈希方式、扩容机制的改进、重新计算存储位置的方式和扩容触发机制优化这5个方面来进行详细说明。
1.数据结构的变化:
在 JDK 1.7 及之前,HashMap 的数据结构是“数组+链表”。当发生哈希冲突时,多个键值对以链表形式存储在同一个“桶”中。 在 JDK 1.8 中,引入了红黑树优化。当链表长度超过 8 时,会自动转换为红黑树,这将时间复杂度从 O(n) 降低到 O(logN),大幅提高查找效率。
2.数据重哈希的优化:
JDK 1.7:直接使用 hashCode 的低位进行哈希运算,这样当 table 长度较小时,高位没有参与计算,导致哈希分布不均。
JDK 1.8:在重新计算哈希值时,引入高 16 位和低 16 位的异或运算,让高位也参与运算,从而提高了哈希分布的均匀性,减少了哈希冲突。
3.扩容机制的改进:
JDK 1.7:在扩容时使用“头插法”插入新元素。这种方式会导致链表顺序反转,在多线程环境下,可能引发环形链表问题,导致程序死循环。
JDK 1.8:改为“尾插法”插入,保证链表顺序一致,避免了扩容中的死循环问题。此外,尾插法与红黑树的结合,也提高了整体的扩容效率。
4.重新计算存储位置的方式:
JDK 1.7:扩容时需要为每个元素重新计算哈希值,直接使用 hash & (table.length - 1)。
JDK 1.8:利用位运算的特性,判断元素是否需要迁移,如果 hash & oldCapacity == 0,元素保持在原位置;否则,迁移到原位置 + oldCapacity。这种方法避免了重新计算哈希值,提升了扩容效率。
5.扩容触发机制优化:
JDK 1.7:扩容是“先扩容后插入”,无论是否发生哈希冲突,都会执行扩容操作,可能导致无效扩容。
JDK 1.8:扩容变为“先插入再扩容”,仅在插入后元素总量超过阈值,或某条链表因哈希冲突长度超过 8 且当前数组容量未达到 64 时,才会触发扩容,减少了不必要的操作
HashMap
与 Hashtable
、ConcurrentHashMap
的区别
特性 | HashMap | Hashtable | ConcurrentHashMap |
---|---|---|---|
线程安全 | 非线程安全 | 线程安全(通过synchronized 实现) | 线程安全(通过CAS+synchronized 实现) |
性能 | 高性能(单线程下) | 性能差(全局锁) | 高性能(分段锁/CAS) |
null 值 | 允许key 和value 为null | 不允许key 和value 为null | 不允许key 和value 为null |
继承 | AbstractMap | Dictionary | AbstractMap |
JDK版本 | 1.2引入 | 1.0引入 | 1.5引入 |
ConcurrentHashMap
在JDK1.7中 ,它使用的是数组加链表的形式实现的,而数组又分为:大数组 Segment 和小数组 HashEntry。 Segment 是一种可重入锁(ReentrantLock),在 ConcurrentHashMap 里扮演锁的角色;HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素
Segment 是一种特殊的分段锁,继承了 ReentrantLock,每个 Segment 对应一个 HashMap 子集
JDK 1.7 ConcurrentHashMap 分段锁技术将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。在 JDK 1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组 + 链表的形式,所以在数据比较多的情况下访问是很慢的,因为要遍历整个链表,而 JDK 1.8 则使用了数组 + 链表/红黑树的方式优化了 ConcurrentHashMap 的实现
JDK 1.8 ConcurrentHashMap JDK 1.8 ConcurrentHashMap 主要通过 volatile + CAS 或者 synchronized 来实现的线程安全的。添加元素时首先会判断容器是否为空:
- 如果为空则使用 volatile 加 CAS 来初始化
- 如果容器不为空,则根据存储的元素计算该位置是否为空。
- 如果根据存储的元素计算结果为空,则利用 CAS 设置该节点;
- 如果根据存储的元素计算结果不为空,则使用 synchronized ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。
如果把上面的执行用一句话归纳的话,就相当于是ConcurrentHashMap通过对头结点加锁来保证线程安全的,锁的粒度相比 Segment 来说更小了,发生冲突和加锁的频率降低了,并发操作的性能就提高了。而且 JDK 1.8 使用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询性能也得到了很大的提升,从之前的 O(n) 优化到了 O(logn) 的时间复杂度。
使用了synchronized为什么还要使用CAS
ConcurrentHashMap使用这两种手段来保证线程安全主要是一种权衡的考虑,在某些操作中使用synchronized,还是使用CAS,主要是根据锁竞争程度来判断的。
比如:在putVal中,如果计算出来的hash槽没有存放元素,那么就可以直接使用CAS来进行设置值,这是因为在设置元素的时候,因为hash值经过了各种扰动后,造成hash碰撞的几率较低,那么可以预测使用较少的自旋来完成具体的hash落槽操作。
当发生了hash碰撞的时候说明容量不够用了或者已经有大量线程访问了,因此这时候使用synchronized来处理hash碰撞比CAS效率要高,因为发生了hash碰撞大概率来说是线程竞争比较强烈。
concurrentHashMap使用了悲观锁和乐观锁。添加元素时首先会判断容器是否为空:
- 如果为空则使用 volatile 加 CAS (乐观锁) 来初始化。
- 如果容器不为空,则根据存储的元素计算该位置是否为空。
- 如果根据存储的元素计算结果为空,则利用 CAS(乐观锁) 设置该节点;
- 如果根据存储的元素计算结果不为空,则使用 synchronized(悲观锁) ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了
HashTable实现
- Hashtable的底层数据结构主要是数组加上链表,数组是主体,链表是解决hash冲突存在的。
- HashTable是线程安全的,实现方式是Hashtable的所有公共方法均采用synchronized关键字,当一个线程访问同步方法,另一个线程也访问的时候,就会陷入阻塞或者轮询的状态
因为它的put,get做成了同步方法,保证了Hashtable的线程安全性,每个操作数据的方法都进行同步控制之后,由此带来的问题任何一个时刻只能有一个线程可以操纵Hashtable,所以其效率比较低
可以使用synchronized关键字来标记一个方法或者代码块,当某个线程调用该对象的synchronized方法或者访问synchronized代码块时,这个线程便获得了该对象的锁,其他线程暂时无法访问这个方法,只有等待这个方法执行完毕或者代码块执行完毕,这个线程才会释放该对象的锁,其他线程才能执行这个方法或者代码块.
HashTable与concurrentHashMap差别
底层数据结构:
- jdk7之前的ConcurrentHashMap底层采用的是分段的数组+链表实现,jdk8之后采用的是数组+链表/红黑树;
- HashTable采用的是数组+链表,数组是主体,链表是解决hash冲突存在的
实现线程安全的方式:
- jdk8以前,ConcurrentHashMap采用分段锁,对整个数组进行了分段分割,每一把锁只锁容器里的一部分数据,多线程访问不同数据段里的数据,就不会存在锁竞争,提高了并发访问;jdk8以后,直接采用数组+链表/红黑树,并发控制使用CAS和synchronized操作,更加提高了速度。
- HashTable:所有的方法都加了锁来保证线程安全,但是效率非常的低下,当一个线程访问同步方法,另一个线程也访问的时候,就会陷入阻塞或者轮询的状态。
HashMap,HashTable与ConcurrentMap区别
- HashMap线程不安全,效率高一点,可以存储null的key和value,null的key只能有一个,null的value可以有多个。默认初始容量为16,每次扩充变为原来2倍。创建时如果给定了初始容量,则扩充为2的幂次方大小。底层数据结构为数组+链表,插入元素后如果链表长度大于阈值(默认为8),先判断数组长度是否小于64,如果小于,则扩充数组,反之将链表转化为红黑树,以减少搜索时间。
- HashTable线程安全,效率低一点,其内部方法基本都经过synchronized修饰,不可以有null的key和value。默认初始容量为11,每次扩容变为原来的2n+1。创建时给定了初始容量,会直接用给定的大小。底层数据结构为数组+链表。它基本被淘汰了,要保证线程安全可以用ConcurrentHashMap。
- ConcurrentHashMap是Java中的一个线程安全的哈希表实现,它可以在多线程环境下并发地进行读写操作,而不需要像传统的HashTable那样在读写时加锁。ConcurrentHashMap的实现原理主要基于分段锁和CAS操作。它将整个哈希表分成了多Segment(段),每个Segment都类似于一个小的HashMap,它拥有自己的数组和一个独立的锁。在ConcurrentHashMap中,读操作不需要锁,可以直接对Segment进行读取,而写操作则只需要锁定对应的Segment,而不是整个哈希表,这样可以大大提高并发性能。
Set集合
- set集合特点:Set集合中的元素是唯一的,不会出现重复的元素。
set实现原理:Set集合通过内部的数据结构(如哈希表、红黑树等)来实现key的无重复。当向Set集合中插入元素时,会先根据元素的hashCode值来确定元素的存储位置,然后再通过equals方法来判断是否已经存在相同的元素,如果存在则不会再次插入,保证了元素的唯一性
有序的 Set 是TreeSet和LinkedHashSet。TreeSet是基于红黑树实现,保证元素的自然顺序。LinkedHashSet是基于双重链表和哈希表的结合来实现元素的有序存储,保证元素添加的自然顺序
- 记录插入顺序的集合通常指的是LinkedHashSet,它不仅保证元素的唯一性,还可以保持元素的插入顺序。当需要在Set集合中记录元素的插入顺序时,可以选择使用LinkedHashSet来实现。
JUC
多线程需要注意哪些问题
要保证多线程的程序是安全,不要出现数据竞争造成的数据混乱的问题。
Java的线程安全在三个方面体现:
- 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,在Java中使用了atomic包(这个包提供了一些支持原子操作的类,这些类可以在多线程环境下保证操作的原子性)和synchronized关键字来确保原子性;
- 可见性:一个线程对主内存的修改可以及时地被其他线程看到,在Java中使用了synchronized和volatile这两个关键字确保可见性;
- 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,在Java中使用了happens-before原则来确保有序性。
保证数据一致性方案
- 事务管理:使用数据库事务来确保一组数据库操作要么全部成功提交,要么全部失败回滚。通过ACID(原子性、一致性、隔离性、持久性)属性,数据库事务可以保证数据的一致性。
- 锁机制:使用锁来实现对共享资源的互斥访问。在 Java 中,可以使用 synchronized 关键字、ReentrantLock 或其他锁机制来控制并发访问,从而避免并发操作导致数据不一致。
- 版本控制:通过乐观锁的方式,在更新数据时记录数据的版本信息,从而避免同时对同一数据进行修改,进而保证数据的一致性。
Java中如何创建线程
第一种是通过继承 Thread 类并重写其 run() 方法来创建线程。在run() 方法中定义线程需要执行的任务逻辑,然后
创建该类的实例,调用 start() 方法启动线程,start() 方法会自动调用 run() 方法中的代码逻辑。这种方式简单直观,但由于 Java 不支持多重继承,因此限制了类的扩展性。
第二种是实现 Runnable 接口并将其传递给 Thread 构造器来创建线程。Runnable 是一个函数式接口,其中的 run() 方法定义了任务逻辑。这种方式更加灵活,因为它不占用类的继承关系,同时可以更好地支持资源共享,可以让多个线程共享同一个 Runnable 实例。这种方式适用于需要解耦任务逻辑与线程管理的场景。
第三种是通过实现 Callable 接口来创建有返回值的线程。Callable 接口类似于 Runnable,但它可以返回结果并抛出异常。Callable 的 call() 方法需要通过 FutureTask 包装后传递给 Thread 构造器。通过 Future 对象可以获取线程执行的结果或捕获异常。这种方式适用于需要获取线程执行结果或处理复杂任务的场景。
第四种是通过 Executor 框架创建线程池来管理线程。Executor 框架提供了更高级的线程管理功能,例如线程复用、任务调度等。通过 submit() 或 execute() 方法提交任务,避免频繁创建和销毁线程的开销。它作为最常被使用的方式,广泛用于需要高效管理大量线程的场景。
如何停止一个线程
主要有这些方法:
- 异常法停止:线程调用interrupt()方法后,在线程的run方法中判断当前对象的interrupted()状态,如果是中断状态则抛出异常,达到中断线程的效果。
- 在沉睡中停止:先将线程sleep,然后调用interrupt标记中断状态,interrupt会将阻塞状态的线程中断。会抛出中断异常,达到停止线程的效果
- stop()暴力停止:线程调用stop()方法会被暴力停止,方法已弃用,该方法会有不好的后果:强制让线程停止有可能使一些请理性的工作得不到完成。
- 使用return停止线程:调用interrupt标记为中断状态后,在run方法中判断当前线程状态,如果为中断状态则return,能达到停止线程的效果
其他答案
- 通过共享标志位主动终止。定义一个 可见的 状态变量,由主线程控制其值,工作线程循环检测该变量以决定是否退出。
- 通过
Thread.interrupt()
触发线程中断状态,结合中断检测逻辑实现安全停止。
每个线程都一个与之关联的布尔属性来表示其中断状态,中断状态的初始值为false,当一个线程被其它线程调用Thread.interrupt()
方法中断时,会根据实际情况做出响应。
- 如果该线程正在执行低级别的可中断方法(如
Thread.sleep()
、Thread.join()
或Object.wait()
),则会解除阻塞并抛出InterruptedException
异常。 否则
Thread.interrupt()
仅设置线程的中断状态,在该被中断的线程中稍后可通过轮询中断状态来决定是否要停止当前正在执行的任务interrupt()
不会立刻终止线程,只是设置中断标志位。- 线程需手动检查中断状态(
isInterrupted()
)或触发可中断操作(如sleep()
,wait()
,join()
)响应中断。 - 阻塞操作中收到中断请求时,会抛出
InterruptedException
并清除中断状态。
第三种方式通过 Future
取消任务。使用线程池提交任务,并通过 Future.cancel()
停止线程,依赖中断机制。
第四种方式处理不可中断的阻塞操作。某些 I/O 或同步操作(如 Socket.accept()
、Lock.lock()
)无法通过中断直接响应。此时需结合资源关闭操作。比如,关闭 Socket 释放阻塞。
Java线程的状态
java.lang.Thread.State枚举类中定义了六种线程的状态,可以调用线程Thread中的getState()方法获取当前线程的状态。
线程状态 | 解释 |
---|---|
NEW | 尚未启动的线程状态,即线程创建,还未调用start方法 |
RUNNABLE | 就绪状态(调用start,等待调度)+正在运行 |
BLOCKED | 等待监视器锁时,陷入阻塞状态 |
WAITING | 等待状态的线程正在等待另一线程执行特定的操作(如notify) |
TIMED_WAITING | 具有指定等待时间的等待状态 |
TERMINATED | 线程完成执行,终止状态 |
sleep和wait差别
特性 | sleep() | wait() |
---|---|---|
所属类 | Thread 类(静态方法) | Object 类(实例方法) |
锁释放 | ❌ | ✅ |
使用前提 | 任意位置调用 | 必须在同步块内(持有锁) |
唤醒机制 | 超时自动恢复 | 需 notify() /notifyAll() 或超时 |
设计用途 | 暂停线程执行,不涉及锁协作 | 线程间协调,释放锁让其他线程工作 |
- 所属分类的不同:sleep 是
Thread
类的静态方法,可以在任何地方直接通过Thread.sleep()
调用,无需依赖对象实例。wait 是Object
类的实例方法,这意味着必须通过对象实例来调用。 - 锁释放的情况:
Thread.sleep()
在调用时,线程会暂停执行指定的时间,但不会释放持有的对象锁。也就是说,在sleep
期间,其他线程无法获得该线程持有的锁。Object.wait()
:调用该方法时,线程会释放持有的对象锁,进入等待状态,直到其他线程调用相同对象的notify()
或notifyAll()
方法唤醒它 - 使用条件:sleep 可在任意位置调用,无需事先获取锁。 wait 必须在同步块或同步方法内调用(即线程需持有该对象的锁),否则抛出
IllegalMonitorStateException
。 - 唤醒机制:sleep 休眠时间结束后,线程 自动恢复 到就绪状态,等待CPU调度。wait 需要其他线程调用相同对象的
notify()
或notifyAll()
方法才能被唤醒。notify()
会随机唤醒一个在该对象上等待的线程,而notifyAll()
会唤醒所有在该对象上等待的线程
是的,调用 Thread.sleep()
时,线程会释放 CPU,但不会释放持有的锁。
当线程调用 sleep()
后,会主动让出 CPU 时间片,进入 TIMED_WAITING
状态。此时操作系统会触发调度,将 CPU 分配给其他处于就绪状态的线程。这样其他线程(无论是需要同一锁的线程还是不相关线程)便有机会执行。
sleep()
不会释放线程已持有的任何锁(如 synchronized
同步代码块或方法中获取的锁)。因此,如果有其他线程试图获取同一把锁,它们仍会被阻塞,直到原线程退出同步代码块。
blocked和waiting差别
触发条件:线程进入BLOCKED状态通常是因为试图获取一个对象的锁(monitor lock),但该锁已经被另一个线程持有。这通常发生在尝试进入synchronized块或方法时,如果锁已被占用,则线程将被阻塞直到锁可用。线程进入WAITING状态是因为它正在等待另一个线程执行某些操作,例如调用Object.wait()方法、Thread.join()方法或LockSupport.park()方法。在这种状态下,线程将不会消耗CPU资源,并且不会参与锁的竞争。
唤醒机制:当一个线程被阻塞等待锁时,一旦锁被释放,线程将有机会重新尝试获取锁。如果锁此时未被其他线程获取,那么线程可以从BLOCKED状态变为RUNNABLE状态。线程在WAITING状态中需要被显式唤醒。例如,如果线程调用了Object.wait(),那么它必须等待另一个线程调用同一对象上的Object.notify()或Object.notifyAll()方法才能被唤醒。
所以,BLOCKED和WAITING两个状态最大的区别有两个:
- BLOCKED是锁竞争失败后被被动触发的状态,WAITING是人为的主动触发的状态
- BLCKED的唤醒时自动触发的,而WAITING状态是必须要通过特定的方法来主动唤醒
线程从 等待(WAIT)
状态恢复到 运行(RUNNING)
状态的核心机制是 通过外部事件触发或资源可用性变化,比如等待的线程被其他线程对象唤醒,notify()
和notifyAll()
。
notify和notifyAll作用
同样是唤醒等待的线程,同样最多只有一个线程能获得锁,同样不能控制哪个线程获得锁。
区别在于:
- notify:唤醒一个线程,其他线程依然处于wait的等待唤醒状态,如果被唤醒的线程结束时没调用notify,其他线程就永远没人去唤醒,只能等待超时,或者被中断
- notifyAll:所有线程退出wait的状态,开始竞争锁,但只有一个线程能抢到,这个线程执行完后,其他线程又会有一个幸运儿脱颖而出得到锁
不同线程间如何进行通信
共享变量是最基本的线程间通信方式。多个线程可以访问和修改同一个共享变量,从而实现信息的传递。为了保证线程安全,通常需要使用 synchronized
关键字或 volatile
关键字。
java.util.concurrent.locks
包中的 Lock
和 Condition
接口提供了比 synchronized 更灵活的线程间通信方式。Condition
接口的 await()
方法类似于 wait()
方法,signal()
方法类似于 notify()
方法,signalAll()
方法类似于 notifyAll()
方法。
Synchronized和object.wait,object.notify
lock和condition.await condition.signal
java.util.concurrent
包中的 BlockingQueue接口提供了线程安全的队列操作,当队列满时,插入元素的线程会被阻塞;当队列为空时,获取元素的线程会被阻塞。
volatile关键字。volatile
关键字用于保证变量的可见性,即当一个变量被声明为 volatile
时,它会保证对该变量的写操作会立即刷新到主内存中,而读操作会从主内存中读取最新的值。
CountDownLatch。CountDownLatch
是一个同步辅助类,它允许一个或多个线程等待其他线程完成操作。
CountDownLatch(int count)
:构造函数,指定需要等待的线程数量。countDown()
:减少计数器的值。await()
:使当前线程等待,直到计数器的值为 0
CyclicBarrier。CyclicBarrier
是一个同步辅助类,它允许一组线程相互等待,直到所有线程都到达某个公共屏障点。
CyclicBarrier(int parties, Runnable barrierAction)
:构造函数,指定参与的线程数量和所有线程到达屏障点后要执行的操作。await()
:使当前线程等待,直到所有线程都到达屏障点。
Semaphore。Semaphore
是一个计数信号量,它可以控制同时访问特定资源的线程数量。
Semaphore(int permits)
:构造函数,指定信号量的初始许可数量。acquire()
:获取一个许可,如果没有可用许可则阻塞。release()
:释放一个许可
如何保证多线程安全
synchronized关键字:可以使用
synchronized
关键字来同步代码块或方法,确保同一时刻只有一个线程可以访问这些代码。对象锁是通过synchronized
关键字锁定对象的监视器(monitor)来实现的。volatile关键字:
volatile
关键字用于变量,确保所有线程看到的是该变量的最新值,而不是可能存储在本地寄存器中的副本。- Lock接口和ReentrantLock类:
java.util.concurrent.locks.Lock
接口提供了比synchronized
更强大的锁定机制,ReentrantLock
是一个实现该接口的例子,提供了更灵活的锁管理和更高的性能。 - 原子类:Java并发库(
java.util.concurrent.atomic
)提供了原子类,如AtomicInteger
、AtomicLong
等,这些类提供了原子操作,可以用于更新基本类型的变量而无需额外的同步。 - 线程局部变量:
ThreadLocal
类可以为每个线程提供独立的变量副本,这样每个线程都拥有自己的变量,消除了竞争条件。 - 并发集合:使用
java.util.concurrent
包中的线程安全集合,如ConcurrentHashMap
、ConcurrentLinkedQueue
等,这些集合内部已经实现了线程安全的逻辑。 - JUC工具类: 使用
java.util.concurrent
包中的一些工具类可以用于控制线程间的同步和协作。例如:Semaphore
和CyclicBarrier
等。
Java中并发工具
Java 中一些常用的并发工具,它们位于 java.util.concurrent
包中,常见的有:
线程池相关:
ThreadPoolExecutor
:最核心的线程池类,用于创建和管理线程池。通过它可以灵活地配置线程池的参数,如核心线程数、最大线程数、任务队列等,以满足不同的并发处理需求。Executors
:线程池工厂类,提供了一系列静态方法来创建不同类型的线程池,如newFixedThreadPool
(创建固定线程数的线程池)、newCachedThreadPool
(创建可缓存线程池)、newSingleThreadExecutor
(创建单线程线程池)等,方便开发者快速创建线程池
并发集合类:
ConcurrentHashMap
:线程安全的哈希映射表,用于在多线程环境下高效地存储和访问键值对。它采用了分段锁等技术,允许多个线程同时访问不同的段,提高了并发性能,在高并发场景下比传统的Hashtable
性能更好。CopyOnWriteArrayList
:线程安全的列表,在对列表进行修改操作时,会创建一个新的底层数组,将修改操作应用到新数组上,而读操作仍然可以在旧数组上进行,从而实现了读写分离,提高了并发读的性能,适用于读多写少的场景。
同步工具类:
- CountDownLatch:CountDownLatch 是一个同步辅助类,它允许一个或多个线程等待其他线程完成操作。它使用一个计数器进行初始化,调用
countDown()
方法会使计数器减一,当计数器的值减为 0 时,等待的线程会被唤醒。可以把它想象成一个倒计时器,当倒计时结束(计数器为 0)时,等待的事件就会发生。示例代码: - CyclicBarrier:CyclicBarrier 允许一组线程互相等待,直到到达一个公共的屏障点。当所有线程都到达这个屏障点后,它们可以继续执行后续操作,并且这个屏障可以被重置循环使用。与
CountDownLatch
不同,CyclicBarrier
侧重于线程间的相互等待,而不是等待某些操作完成。 - Semaphore:Semaphore 是一个计数信号量,用于控制同时访问某个共享资源的线程数量。通过
acquire()
方法获取许可,使用release()
方法释放许可。如果没有许可可用,线程将被阻塞,直到有许可被释放。可以用来限制对某些资源(如数据库连接池、文件操作等)的并发访问量 - Future 和 Callable:Callable 是一个类似于
Runnable
的接口,但它可以返回结果,并且可以抛出异常。Future 用于表示一个异步计算的结果,可以通过它来获取Callable
任务的执行结果或取消任务。 - ConcurrentHashMap:ConcurrentHashMap 是一个线程安全的哈希表,它允许多个线程同时进行读操作,在一定程度上支持并发的修改操作,避免了
HashMap
在多线程环境下需要使用synchronized
或Collections.synchronizedMap()
进行同步的性能问题。
原子类:
AtomicInteger
:原子整数类,提供了对整数类型的原子操作,如自增、自减、比较并交换等。通过硬件级别的原子指令来保证操作的原子性和线程安全性,避免了使用锁带来的性能开销,在多线程环境下对整数进行计数、状态标记等操作非常方便。AtomicReference
:原子引用类,用于对对象引用进行原子操作。可以保证在多线程环境下,对对象的更新操作是原子性的,即要么全部成功,要么全部失败,不会出现数据不一致的情况。常用于实现无锁数据结构或需要对对象进行原子更新的场景。
Java中常用锁
Java中的锁是用于管理多线程并发访问共享资源的关键机制。锁可以确保在任意给定时间内只有一个线程可以访问特定的资源,从而避免数据竞争和不一致性。Java提供了多种锁机制,可以分为以下几类:
- 内置锁(synchronized):Java中的
synchronized
关键字是内置锁机制的基础,可以用于方法或代码块。当一个线程进入synchronized
代码块或方法时,它会获取关联对象的锁;当线程离开该代码块或方法时,锁会被释放。如果其他线程尝试获取同一个对象的锁,它们将被阻塞,直到锁被释放。其中,syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级的互斥锁。 - ReentrantLock:
java.util.concurrent.locks.ReentrantLock
是一个显式的锁类,提供了比synchronized
更高级的功能,如可中断的锁等待、定时锁等待、公平锁选项等。ReentrantLock
使用lock()
和unlock()
方法来获取和释放锁。其中,公平锁按照线程请求锁的顺序来分配锁,保证了锁分配的公平性,但可能增加锁的等待时间。非公平锁不保证锁分配的顺序,可以减少锁的竞争,提高性能,但可能造成某些线程的饥饿。 - 读写锁(ReadWriteLock):
java.util.concurrent.locks.ReadWriteLock
接口定义了一种锁,允许多个读取者同时访问共享资源,但只允许一个写入者。读写锁通常用于读取远多于写入的情况,以提高并发性。 - 乐观锁和悲观锁:悲观锁(Pessimistic Locking)通常指在访问数据前就锁定资源,假设最坏的情况,即数据很可能被其他线程修改。
synchronized
和ReentrantLock
都是悲观锁的例子。乐观锁(Optimistic Locking)通常不锁定资源,而是在更新数据时检查数据是否已被其他线程修改。乐观锁常使用版本号或时间戳来实现。 自旋锁:自旋锁是一种锁机制,线程在等待锁时会持续循环检查锁是否可用,而不是放弃CPU并阻塞。通常可以使用CAS来实现。这在锁等待时间很短的情况下可以提高性能,但过度自旋会浪费CPU资源。
Synchronized
synchronized是Java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁,
使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。
执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且由于Java中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时时会从用户态切换到内核态,这种转换非常消耗性能。
从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存。
synchronized实际上有两个队列waitSet和entryList。
- 当多个线程进入同步代码块时,首先进入entryList
- 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1
- 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁
- 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null
synchronized锁对象
synchronized可以修饰实例方法,静态方法以及代码块.当 synchronized 修饰实例方法时,锁的对象是当前实例(即 this)。也就是说,同一时间只有一个线程可以访问该实例的同步方法。
当 synchronized 修饰静态方法时,锁的对象是类的 Class 对象(即 Counter.class)。这意味着所有线程在访问该类的静态同步方法时都会被同步控制。
当 synchronized 修饰代码块时,可以显式指定锁的对象。这种方式更加灵活,允许开发者选择具体的锁对象。
锁的对象不同:
- 普通方法:锁的是当前对象实例(
this
)。同一对象实例的synchronized
普通方法,同一时间只能被一个线程访问;不同对象实例间互不影响,可被不同线程同时访问各自的同步普通方法。 - 静态方法:锁的是当前类的
Class
对象。由于类的Class
对象全局唯一,无论多少个对象实例,该静态同步方法同一时间只能被一个线程访问。
作用范围不同:
- 普通方法:仅对同一对象实例的同步方法调用互斥,不同对象实例的同步普通方法可并行执行。
- 静态方法:对整个类的所有实例的该静态方法调用都互斥,一个线程进入静态同步方法,其他线程无法进入同一类任何实例的该方法。
多实例场景影响不同:
- 普通方法:多线程访问不同对象实例的同步普通方法时,可同时执行。
- 静态方法:不管有多少对象实例,同一时间仅一个线程能执行该静态同步方法
syncronized锁升级的过程
具体的锁升级的过程是:无锁->偏向锁->轻量级锁->重量级锁。
- 无锁:这是没有开启偏向锁的时候的状态,在JDK1.6之后偏向锁的默认开启的,但是有一个偏向延迟,需要在JVM启动之后的多少秒之后才能开启,这个可以通过JVM参数进行设置,同时是否开启偏向锁也可以通过JVM参数设置。
- 偏向锁:这个是在偏向锁开启之后的锁的状态,如果还没有一个线程拿到这个锁的话,这个状态叫做匿名偏向,当一个线程拿到偏向锁的时候,下次想要竞争锁只需要拿线程ID跟MarkWord当中存储的线程ID进行比较,如果线程ID相同则直接获取锁(相当于锁偏向于这个线程),不需要进行CAS操作和将线程挂起的操作。
- 轻量级锁:在这个状态下线程主要是通过CAS操作实现的。将对象的MarkWord存储到线程的虚拟机栈上,然后通过CAS将对象的MarkWord的内容设置为指向Displaced Mark Word的指针,如果设置成功则获取锁。在线程出临界区的时候,也需要使用CAS,如果使用CAS替换成功则同步成功,如果失败表示有其他线程在获取锁,那么就需要在释放锁之后将被挂起的线程唤醒。
- 重量级锁:当有两个以上的线程获取锁的时候轻量级锁就会升级为重量级锁,因为CAS如果没有成功的话始终都在自旋,进行while循环操作,这是非常消耗CPU的,但是在升级为重量级锁之后,线程会被操作系统调度然后挂起,这可以节约CPU资源。
线程A进入 synchronized 开始抢锁,JVM 会判断当前是否是偏向锁的状态,如果是就会根据 Mark Word 中存储的线程 ID 来判断,当前线程A是否就是持有偏向锁的线程。如果是,则忽略 check,线程A直接执行临界区内的代码。
但如果 Mark Word 里的线程不是线程 A,就会通过自旋尝试获取锁,如果获取到了,就将 Mark Word 中的线程 ID 改为自己的;如果竞争失败,就会立马撤销偏向锁,膨胀为轻量级锁。
后续的竞争线程都会通过自旋来尝试获取锁,如果自旋成功那么锁的状态仍然是轻量级锁。然而如果竞争失败,锁会膨胀为重量级锁,后续等待的竞争的线程都会被阻塞
synchronized 核心优化方案主要包含以下 4 个:
- 锁膨胀:synchronized 从无锁升级到偏向锁,再到轻量级锁,最后到重量级锁的过程,它叫做锁膨胀也叫做锁升级。JDK 1.6 之前,synchronized 是重量级锁,也就是说 synchronized 在释放和获取锁时都会从用户态转换成内核态,而转换的效率是比较低的。但有了锁膨胀机制之后,synchronized 的状态就多了无锁、偏向锁以及轻量级锁了,这时候在进行并发操作时,大部分的场景都不需要用户态到内核态的转换了,这样就大幅的提升了 synchronized 的性能。
- 锁消除:指的是在某些情况下,JVM 虚拟机如果检测不到某段代码被共享和竞争的可能性,就会将这段代码所属的同步锁消除掉,从而到底提高程序性能的目的。
- 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
- 自适应自旋锁:指通过自身循环,尝试获取锁的一种方式,优点在于它避免一些线程的挂起和恢复操作,因为挂起线程和恢复线程都需要从用户态转入内核态,这个过程是比较慢的,所以通过自旋的方式可以一定程度上避免线程挂起和恢复所造成的性能开销
ReentrantLock
ReentrantLock 的底层实现主要依赖于 AbstractQueuedSynchronizer(AQS)这个抽象类。AQS 是一个提供了基本同步机制的框架,其中包括了队列、状态值等。
ReentrantLock 在 AQS 的基础上通过内部类 Sync 来实现具体的锁操作。不同的 Sync 子类实现了公平锁和非公平锁的不同逻辑:
可中断性: ReentrantLock 实现了可中断性,这意味着线程在等待锁的过程中,可以被其他线程中断而提前结束等待。在底层,ReentrantLock 使用了与 LockSupport.park() 和 LockSupport.unpark() 相关的机制来实现可中断性。
设置超时时间: ReentrantLock 支持在尝试获取锁时设置超时时间,即等待一定时间后如果还未获得锁,则放弃锁的获取。这是通过内部的 tryAcquireNanos 方法来实现的。
公平锁和非公平锁: 在直接创建 ReentrantLock 对象时,默认情况下是非公平锁。公平锁是按照线程等待的顺序来获取锁,而非公平锁则允许多个线程在同一时刻竞争锁,不考虑它们申请锁的顺序。公平锁可以通过在创建 ReentrantLock 时传入 true 来设置
多个条件变量: ReentrantLock 支持多个条件变量,每个条件变量可以与一个 ReentrantLock 关联。这使得线程可以更灵活地进行等待和唤醒操作,而不仅仅是基于对象监视器的 wait() 和 notify()。多个条件变量的实现依赖于 Condition 接口
可重入性: ReentrantLock 支持可重入性,即同一个线程可以多次获得同一把锁,而不会造成死锁。这是通过内部的 holdCount 计数来实现的。当一个线程多次获取锁时,holdCount 递增,释放锁时递减,只有当 holdCount 为零时,其他线程才有机会获取锁
可重入锁
可重入锁是指同一个线程在获取了锁之后,可以再次重复获取该锁而不会造成死锁或其他问题。当一个线程持有锁时,如果再次尝试获取该锁,就会成功获取而不会被阻塞。
ReentrantLock实现可重入锁的机制是基于线程持有锁的计数器。
- 当一个线程第一次获取锁时,计数器会加1,表示该线程持有了锁。在此之后,如果同一个线程再次获取锁,计数器会再次加1。每次线程成功获取锁时,都会将计数器加1。
- 当线程释放锁时,计数器会相应地减1。只有当计数器减到0时,锁才会完全释放,其他线程才有机会获取锁。
这种计数器的设计使得同一个线程可以多次获取同一个锁,而不会造成死锁或其他问题。每次获取锁时,计数器加1;每次释放锁时,计数器减1。只有当计数器减到0时,锁才会完全释放。
ReentrantLock通过这种计数器的方式,实现了可重入锁的机制。它允许同一个线程多次获取同一个锁,并且能够正确地处理锁的获取和释放,避免了死锁和其他并发问题。
synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。
synchronized底层是利用计算机系统mutex Lock实现的。每一个可重入锁都会关联一个线程ID和一个锁状态status。
当一个线程请求方法时,会去检查锁状态。
- 如果锁状态是0,代表该锁没有被占用,使用CAS操作获取锁,将线程ID替换成自己的线程ID。
- 如果锁状态不是0,代表有线程在访问该方法。此时,如果线程ID是自己的线程ID,如果是可重入锁,会将status自增1,然后获取到该锁,进而执行相应的方法;如果是非重入锁,就会进入阻塞队列等待。
在释放锁时,
- 如果是可重入锁的,每一次退出方法,就会将status减1,直至status的值为0,最后释放该锁。
- 如果非可重入锁的,线程退出方法,直接就会释放该锁
Synchronized与ReentrantLock
synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁:
- 用法不同:synchronized 可用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用在代码块上。
- 获取锁和释放锁方式不同:synchronized 会自动加锁和释放锁,当进入 synchronized 修饰的代码块之后会自动加锁,当离开 synchronized 的代码段之后会自动释放锁。而 ReentrantLock 需要手动加锁和释放锁
- 锁类型不同:synchronized 属于非公平锁,而 ReentrantLock 既可以是公平锁也可以是非公平锁。
- 响应中断不同:ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中断。
- 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。
synchronized:
- 简单同步需求: 当你需要对代码块或方法进行简单的同步控制时,
synchronized
是一个很好的选择。它使用起来简单,不需要额外的资源管理,因为锁会在方法退出或代码块执行完毕后自动释放。 - 代码块同步: 如果你想对特定代码段进行同步,而不是整个方法,可以使用
synchronized
代码块。这可以让你更精细地控制同步的范围,从而减少锁的持有时间,提高并发性能。 - 内置锁的使用:
synchronized
关键字使用对象的内置锁(也称为监视器锁),这在需要使用对象作为锁对象的情况下很有用,尤其是在对象状态与锁保护的代码紧密相关时。
ReentrantLock:
- 高级锁功能需求:
ReentrantLock
提供了synchronized
所不具备的高级功能,如公平锁、响应中断、定时锁尝试、以及多个条件变量。当你需要这些功能时,ReentrantLock
是更好的选择。 - 性能优化: 在高度竞争的环境中,
ReentrantLock
可以提供比synchronized
更好的性能,因为它提供了更细粒度的控制,如尝试锁定和定时锁定,可以减少线程阻塞的可能性。 - 复杂同步结构: 当你需要更复杂的同步结构,如需要多个条件变量来协调线程之间的通信时,
ReentrantLock
及其配套的Condition
对象可以提供更灵活的解决方案。
综上,synchronized
适用于简单同步需求和不需要额外锁功能的场景,而ReentrantLock
适用于需要更高级锁功能、性能优化或复杂同步逻辑的情况。选择哪种同步机制取决于具体的应用需求和性能考虑
synchronized 和 ReentrantLock 是 Java 中实现线程同步的两种主要方式,它们都能保证多线程环境下的数据一致性,
第一个是基本概念上的区别,synchronized 是 Java 的内置关键字,它是隐式的,通过 JVM 提供的监视器锁机制实现同步,使用简单,无需手动管理锁的获取和释放;而 ReentrantLock 是 java.util.concurrent.locks 包中的一个类,它是显式的,提供了更灵活的锁机制,需要开发者手动调用 lock() 和 unlock() 方法来控制锁的生命周期。
第二个是功能特性上的区别,ReentrantLock 提供了比 synchronized 更丰富的功能,比如:ReentrantLock 支持在等待锁的过程中响应中断,而 synchronized 不支持中断;还有ReentrantLock 提供了 tryLock() 方法,允许线程尝试获取锁并在指定时间内返回结果,而 synchronized 必须一直等待锁释放。
第三个是性能上的区别,synchronized 和 ReentrantLock 在不同场景下各有优势。
对于低竞争场景,由于synchronized 经过多次优化(如偏向锁、轻量级锁),一般与 ReentrantLock 相当甚至更好。
对于高竞争场景,ReentrantLock 提供了更多的灵活性(如公平锁、可中断锁等),更适合复杂需求。
第四个是锁的释放与异常处理上的区别,synchronized 在退出同步代码块时会自动释放锁,即使发生异常也不会导致死锁;而ReentrantLock 需要开发者手动调用 unlock() 方法释放锁,因此必须在 finally 块中确保锁的释放,否则可能导致死锁。
乐观锁与悲观锁 CAS
- 乐观锁: 就像它的名字一样,对于并发间操作产生的线程安全问题持乐观状态,乐观锁认为竞争不总 是会发生,因此它不需要持有锁,将比较-替换这两个动作作为一个原子操作尝试去修改内存中的变量,如果失败则表示发生冲突,那么就应该有相应的重试逻辑。
- 悲观锁: 还是像它的名字一样,对于并发间操作产生的线程安全问题持悲观状态,悲观锁认为竞争总 是会发生,因此每次对某资源进行操作时,都会持有一个独占的锁,就像 synchronized,不管三七二十一,直接上了锁就操作资源了
CAS 是一种乐观锁机制,它包含三个操作数:内存位置(V)、预期值(A)和新值(B)。CAS 操作的逻辑是,如果内存位置 V 的值等于预期值 A,则将其更新为新值 B,否则不做任何操作。整个过程是原子性的,通常由硬件指令支持,如在现代处理器上,cmpxchg
指令可以实现 CAS 操作。
实现一个乐观锁都有哪些方式?
- CAS(Compare and Swap)操作: CAS 是乐观锁的基础。Java 提供了 java.util.concurrent.atomic 包,包含各种原子变量类(如 AtomicInteger、AtomicLong),这些类使用 CAS 操作实现了线程安全的原子操作,可以用来实现乐观锁。
- 版本号控制:增加一个版本号字段记录数据更新时候的版本,每次更新时递增版本号。在更新数据时,同时比较版本号,若当前版本号和更新前获取的版本号一致,则更新成功,否则失败。
- 时间戳:使用时间戳记录数据的更新时间,在更新数据时,在比较时间戳。如果当前时间戳大于数据的时间戳,则说明数据已经被其他线程更新,更新失败。
CAS的缺点
- ABA问题:ABA的问题指的是在CAS更新的过程中,当读取到的值是A,然后准备赋值的时候仍然是A,但是实际上有可能A的值被改成了B,然后又被改回了A,这个CAS更新的漏洞就叫做ABA。只是ABA的问题大部分场景下都不影响并发的最终效果。Java中有AtomicStampedReference来解决这个问题,他加入了预期标志和更新后标志两个字段,更新时不光检查值,还要检查当前的标志是否等于预期标志,全部相等的话才会更新。
- 循环时间长开销大:自旋CAS的方式如果长时间不成功,会给CPU带来很大的开销。
- 只能保证一个共享变量的原子操作:只对一个共享变量操作可以保证原子性,但是多个则不行,多个可以通过AtomicReference来处理或者使用锁synchronized实现。
CAS操作是基于循环重试的机制,如果CAS操作一直未能成功,线程会一直自旋重试,占用CPU资源。在高并发情况下,大量线程自旋会导致CPU资源浪费
会有 ABA 的问题,变量值在操作过程中先被其他线程从 A 修改为 B,又被改回 A,CAS 无法感知中途变化,导致操作误判为“未变更”。比如:
- 线程1读取变量为
A
,准备改为C
。 - 此时线程2将变量
A
→B
→A
。 - 线程1的CAS执行时发现仍是
A
,但状态已丢失中间变化。
Java 提供的工具类会在 CAS 操作中增加版本号(Stamp)或标记,每次修改都更新版本号,使得即使值相同也能识别变更历史。比如,可以用 AtomicStampedReference 来解决 ABA 问题,通过比对值和版本号识别ABA问题
AQS是什么
AQS全称为AbstractQueuedSynchronizer,是Java中的一个抽象类。 AQS是一个用于构建锁、同步器、协作工具类的工具类(框架)。
AQS核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。
CLH:Craig、Landin and Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。
AQS 是一个用于构建锁和同步器的框架,许多同步器如 ReentrantLock
、Semaphore
、CountDownLatch
等都是基于 AQS 构建的。AQS 使用一个 volatile
的整数变量 state
来表示同步状态,通过内置的 FIFO
队列来管理等待线程。它提供了一些基本的操作,如 acquire
(获取资源)和 release
(释放资源),这些操作会修改 state
的值,并根据 state
的值来判断线程是否可以获取或释放资源。AQS 的 acquire
操作通常会先尝试获取资源,如果失败,线程将被添加到等待队列中,并阻塞等待。release
操作会释放资源,并唤醒等待队列中的线程。
AQS使用一个Volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对State值的修改。
其中Sync
是这些semaphore,reentrantlock等类中都有的内部类,Sync
是AQS
的实现。 AQS
主要完成的任务:
- 同步状态(比如说计数器)的原子性管理;
- 线程的阻塞和解除阻塞;
- 队列的管理。
AQS最核心的就是三大部分:
- 状态:state;
- 控制线程抢锁和配合的FIFO队列(双向链表);
- 期望协作工具类去实现的获取/释放等重要方法(重写)。
状态state
- 这里state的具体含义,会根据具体实现类的不同而不同:比如在Semapore里,他表示剩余许可证的数量;在CountDownLatch里,它表示还需要倒数的数量;在ReentrantLock中,state用来表示“锁”的占有情况,包括可重入计数,当state的值为0的时候,标识该Lock不被任何线程所占有。
- state是volatile修饰的,并被并发修改,所以修改state的方法都需要保证线程安全,比如getState、setState以及compareAndSetState操作来读取和更新这个状态。这些方法都依赖于unsafe类。
FIFO队列
- 这个队列用来存放“等待的线程,AQS就是“排队管理器”,当多个线程争用同一把锁时,必须有排队机制将那些没能拿到锁的线程串在一起。当锁释放时,锁管理器就会挑选一个合适的线程来占有这个刚刚释放的锁。
- AQS会维护一个等待的线程队列,把线程都放到这个队列里,这个队列是双向链表形式。
实现获取/释放等方法
- 这里的获取和释放方法,是利用AQS的协作工具类里最重要的方法,是由协作类自己去实现的,并且含义各不相同;
- 获取方法:获取操作会依赖state变量,经常会阻塞(比如获取不到锁的时候)。在Semaphore中,获取就是acquire方法,作用是获取一个许可证; 而在CountDownLatch里面,获取就是await方法,作用是等待,直到倒数结束;
- 释放方法:在Semaphore中,释放就是release方法,作用是释放一个许可证; 在CountDownLatch里面,获取就是countDown方法,作用是将倒数的数减一;
- 需要每个实现类重写tryAcquire和tryRelease等方法。
AQS 实现一个可重入的公平锁的详细步骤:
- 继承 AbstractQueuedSynchronizer:创建一个内部类(内部类Sync)继承自
AbstractQueuedSynchronizer
,重写tryAcquire
、tryRelease
、isHeldExclusively
等方法,这些方法将用于实现锁的获取、释放和判断锁是否被当前线程持有。 - 实现可重入逻辑:在
tryAcquire
方法中,检查当前线程是否已经持有锁,如果是,则增加锁的持有次数(通过state
变量);如果不是,尝试使用 CAS操作来获取锁。 - 实现公平性:在
tryAcquire
方法中,按照队列顺序来获取锁,即先检查等待队列中是否有线程在等待,如果有,当前线程必须进入队列等待,而不是直接竞争锁。 - 创建锁的外部类:创建一个外部类,内部持有
AbstractQueuedSynchronizer
的子类对象,并提供lock
和unlock
方法,这些方法将调用AbstractQueuedSynchronizer
子类中的方法。
CAS 和 AQS 两者的区别
- CAS 是一种乐观锁机制,它包含三个操作数:内存位置(V)、预期值(A)和新值(B)。CAS 操作的逻辑是,如果内存位置 V 的值等于预期值 A,则将其更新为新值 B,否则不做任何操作。整个过程是原子性的,通常由硬件指令支持,如在现代处理器上,
cmpxchg
指令可以实现 CAS 操作。 - AQS 是一个用于构建锁和同步器的框架,许多同步器如
ReentrantLock
、Semaphore
、CountDownLatch
等都是基于 AQS 构建的。AQS 使用一个volatile
的整数变量state
来表示同步状态,通过内置的FIFO
队列来管理等待线程。它提供了一些基本的操作,如acquire
(获取资源)和release
(释放资源),这些操作会修改state
的值,并根据state
的值来判断线程是否可以获取或释放资源。AQS 的acquire
操作通常会先尝试获取资源,如果失败,线程将被添加到等待队列中,并阻塞等待。release
操作会释放资源,并唤醒等待队列中的线程。
CAS 和 AQS 两者的联系:
- CAS 为 AQS 提供原子操作支持:AQS 内部使用 CAS 操作来更新
state
变量,以实现线程安全的状态修改。在acquire
操作中,当线程尝试获取资源时,会使用 CAS 操作尝试将state
从一个值更新为另一个值,如果更新失败,说明资源已被占用,线程会进入等待队列。在release
操作中,当线程释放资源时,也会使用 CAS 操作将state
恢复到相应的值,以保证状态更新的原子性。
ConcurrentHashMap
在 JDK 1.7 中它使用的是数组加链表的形式实现的,而数组又分为:大数组 Segment 和小数组 HashEntry。 Segment 是一种可重入锁(ReentrantLock),在 ConcurrentHashMap 里扮演锁的角色;HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。
JDK 1.7 ConcurrentHashMap 分段锁技术将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。
在 JDK 1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组 + 链表的形式,所以在数据比较多的情况下访问是很慢的,因为要遍历整个链表,而 JDK 1.8 则使用了数组 + 链表/红黑树的方式优化了 ConcurrentHashMap 的实现
JDK 1.8 ConcurrentHashMap JDK 1.8 ConcurrentHashMap 主要通过 volatile + CAS 或者 synchronized 来实现的线程安全的。添加元素时首先会判断容器是否为空:
- 如果为空则使用 volatile 加 CAS 来初始化
- 如果容器不为空,则根据存储的元素计算该位置是否为空。
- 如果根据存储的元素计算结果为空,则利用 CAS 设置该节点;
- 如果根据存储的元素计算结果不为空,则使用 synchronized ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。
如果把上面的执行用一句话归纳的话,就相当于是ConcurrentHashMap通过对头结点加锁来保证线程安全的,锁的粒度相比 Segment 来说更小了,发生冲突和加锁的频率降低了,并发操作的性能就提高了。
而且 JDK 1.8 使用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询性能也得到了很大的提升,从之前的 O(n) 优化到了 O(logn) 的时间复杂度.
第一,ConcurrentHashMap 的实现方式采用了 数组 + Segment + 分段锁 的方式。Segment 是一种特殊的分段锁,继承了 ReentrantLock,每个 Segment 对应一个 HashMap 子集。
第二,它通过对某个 Segment 加锁实现线程安全。这样多个线程可以同时访问不同的 Segment,提高了并发性能。
第三,它内部结构是 数组 + Segment + 分段锁,每个 Segment 里面包含一个 Entry 数组,Entry 数组中的元素以链表形式存储。
第四,它的锁颗粒度相对较小,只对需要操作的 Segment 加锁,其他 Segment 不受影响,从而降低锁竞争。
第五,从查询时间复杂度来说,在最坏情况下需要遍历链表,时间复杂度为 O(n)。
第六,从并发性能来说,默认有 16 个 Segment,也就是支持 16 线程同时操作,不会发生锁冲突。
然后再说一下 JDK 1.8版本:
第一,JDK 1.8 摒弃了分段锁的实现方式,改用 synchronized + CAS + 红黑树,更加高效。
第二,它采用 CAS 操作(Compare-And-Swap)保证并发安全,必要时使用 Synchronized 来解决并发冲突。
第三,它采用了 数组 + 链表 + 红黑树 的数据结构。链表长度超过阈值(默认 8)时,会转化为红黑树,从而优化查询性能。
第四,它锁的颗粒度细化到桶(Node),并且 value 和 next 使用 volatile 修饰,保证并发的可见性。
第五,从查询时间复杂度来说,使用链表时为 O(n),使用红黑树后降为 O(logN)。
第六,从并发性能来讲,并发粒度与数组长度相关,每个桶可以独立加锁,支持更高的并发度。
ConcurrentHashMap如何实现线程安全
主要通过 volatile + CAS 或者 synchronized 来实现的线程安全的。添加元素时首先会判断容器是否为空:
- 如果为空则使用 volatile 加 CAS 来初始化
- 如果容器不为空,则根据存储的元素计算该位置是否为空。
- 如果根据存储的元素计算结果为空,则利用 CAS 设置该节点;
- 如果根据存储的元素计算结果不为空,则使用 synchronized ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。
如果把上面的执行用一句话归纳的话,就相当于是ConcurrentHashMap通过对头结点加锁来保证线程安全的,锁的粒度相比 Segment 来说更小了,发生冲突和加锁的频率降低了,并发操作的性能就提高了。
- Hashtable的底层数据结构主要是数组加上链表,数组是主体,链表是解决hash冲突存在的。
- HashTable是线程安全的,实现方式是Hashtable的所有公共方法均采用synchronized关键字,当一个线程访问同步方法,另一个线程也访问的时候,就会陷入阻塞或者轮询的状态。
ThreadLocal
ThreadLocal
用于解决线程安全问题的一种机制,为每个线程提供独立的变量副本。
- Thread类中,有个ThreadLocal.ThreadLocalMap 的成员变量。
- ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型对象值。
线程隔离:ThreadLocal
为每个线程提供了独立的变量副本,这意味着线程之间不会相互影响,可以安全地在多线程环境中使用这些变量而不必担心数据竞争或同步问题。
降低耦合度:在同一个线程内的多个函数或组件之间,使用ThreadLocal
可以减少参数的传递,降低代码之间的耦合度,使代码更加清晰和模块化。
性能优势:由于ThreadLocal
避免了线程间的同步开销,所以在大量线程并发执行时,相比传统的锁机制,它可以提供更好的性能。
ThreadLocal
的实现依赖于Thread
类中的一个ThreadLocalMap
字段,这是一个存储ThreadLocal
变量本身和对应值的映射。每个线程都有自己的ThreadLocalMap
实例,用于存储该线程所持有的所有ThreadLocal
变量的值。
当你创建一个ThreadLocal
变量时,它实际上就是一个ThreadLocal
对象的实例。每个ThreadLocal
对象都可以存储任意类型的值,这个值对每个线程来说是独立的。
- 当调用
ThreadLocal
的get()
方法时,ThreadLocal
会检查当前线程的ThreadLocalMap
中是否有与之关联的值。 - 如果有,返回该值;
- 如果没有,会调用
initialValue()
方法(如果重写了的话)来初始化该值,然后将其放入ThreadLocalMap
中并返回。 - 当调用
set()
方法时,ThreadLocal
会将给定的值与当前线程关联起来,即在当前线程的ThreadLocalMap
中存储一个键值对,键是ThreadLocal
对象自身,值是传入的值。 - 当调用
remove()
方法时,会从当前线程的ThreadLocalMap
中移除与该ThreadLocal
对象关联的条目。
为什么 ThreadLocalMap
的键是弱引用
- 目的:为了防止内存泄漏。
- 原因:
ThreadLocal
实例通常是static
变量,其生命周期与应用程序相同。如果ThreadLocalMap
的键是强引用,即使ThreadLocal
实例在其他地方不再被引用,ThreadLocalMap
也会一直持有对它的强引用,导致它永远无法被垃圾回收。 - 弱引用如何解决:使用弱引用后,如果
ThreadLocal
实例没有其他强引用,垃圾回收器就会回收它。此时ThreadLocalMap
中的键就会失效(变为null
)。ThreadLocalMap
在进行get()
、set()
或remove()
等操作时,会顺带清理这些键为null
的Entry
,从而避免了内存泄漏。
如果使用强引用,当ThreadLocal
对象的引用(强引用)被回收了,ThreadLocalMap
本身依然还持有ThreadLocal
的强引用,如果没有手动删除这个key ,则ThreadLocal
不会被回收,所以只要当前线程不消亡,ThreadLocalMap
引用的那些对象就不会被回收, 可以认为这导致Entry
内存泄漏。
ThreadLocal存在的问题
内存泄漏: 弱引用只能保证 ThreadLocal
实例本身会被回收。但 ThreadLocalMap
中的值(Value)依然是强引用。
在线程池等线程复用的场景下,线程执行完任务后,ThreadLocalMap
依然存在。如果 ThreadLocal
实例被回收,但我们没有调用 remove()
,那么值对象就会一直被 ThreadLocalMap
强引用着,无法被回收,导致内存泄漏。在任何使用了 ThreadLocal
的场景下,尤其是在线程池中,必须在任务结束时调用 remove()
方法,以确保内存被正确回收。
其他回答:当一个线程结束时,其ThreadLocalMap
也会随之销毁,但是ThreadLocal
对象本身不会立即被垃圾回收,直到没有其他引用指向它为止。
因此,在使用ThreadLocal
时需要注意,如果不显式调用remove()
方法,或者线程结束时未正确清理ThreadLocal
变量,可能会导致内存泄漏,因为ThreadLocalMap
会持续持有ThreadLocal
变量的引用,即使这些变量不再被其他地方引用。
因此,实际应用中需要在使用完ThreadLocal
变量后调用remove()
方法释放资源
ThreadLocal
的适用场景
- 数据库连接管理:每个线程都可以有自己的数据库连接,避免了连接共享和竞争。
- 会话管理:在 Web 应用中,可以在一个请求的生命周期内,将用户信息存入
ThreadLocal
,供不同方法或组件访问。 - 事务管理:将事务对象绑定到
ThreadLocal
,确保一个线程中的所有数据库操作都在同一个事务中执行。
InheritableThreadLocal
子线程默认不会继承父线程的 ThreadLocal
变量。 当子线程启动时,它自己的 ThreadLocalMap
是空的,不会包含父线程设置的任何 ThreadLocal
值。
InheritableThreadLocal
: 当父线程创建子线程时,子线程会默认获取父线程所有 InheritableThreadLocal
变量的副本。 这些副本在子线程创建时被初始化,之后父子线程对各自副本的修改互不影响。
值拷贝:InheritableThreadLocal
传递的是一个拷贝。如果父线程和子线程都对这个值进行修改,它们各自的修改是独立的,不会互相影响。
内存泄漏:和 ThreadLocal
一样,使用 InheritableThreadLocal
也存在内存泄漏风险,尤其是在线程池中。子线程从父线程继承的值,会一直存在于该线程的 ThreadLocalMap
中,如果线程被复用,下次可能获取到旧值。因此,务必在任务结束时调用 remove()
方法。
InheritableThreadLocal
在线程池场景下存在局限性。如果线程池中的线程是复用的,而不是每次都新建的,那么子线程在被复用执行任务时,不会再次执行父线程的数据复制逻辑,从而无法继承新的 InheritableThreadLocal
值。为了解决这个问题,需要手动在任务提交或执行前后进行数据的传递和清理.
类似于 ThreadLocal
,InheritableThreadLocal
也可能导致内存泄漏。即使子线程继承了父线程的值,当这些值不再需要时,也应该调用 remove()
方法进行清理,尤其是在线程池环境中。
Thread类中,有个ThreadLocal.ThreadLocalMap 的成员变量。
ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型对象值
Volatile
volatite作用有 2 个:
- 保证变量对所有线程的可见性。当一个变量被声明为volatile时,它会保证对这个变量的写操作会立即刷新到主存中,而对这个变量的读操作会直接从主存中读取,从而确保了多线程环境下对该变量访问的可见性。这意味着一个线程修改了volatile变量的值,其他线程能够立刻看到这个修改,不会受到各自线程工作内存的影响。
- 禁止指令重排序优化。volatile关键字在Java中主要通过内存屏障来禁止特定类型的指令重排序。
- 1)写-写(Write-Write)屏障:在对volatile变量执行写操作之前,会插入一个写屏障。这确保了在该变量写操作之前的所有普通写操作都已完成,防止了这些写操作被移到volatile写操作之后。
- 2)读-写(Read-Write)屏障:在对volatile变量执行读操作之后,会插入一个读屏障。它确保了对volatile变量的读操作之后的所有普通读操作都不会被提前到volatile读之前执行,保证了读取到的数据是最新的。
- 3)写-读(Write-Read)屏障:这是最重要的一个屏障,它发生在volatile写之后和volatile读之前。这个屏障确保了volatile写操作之前的所有内存操作(包括写操作)都不会被重排序到volatile读之后,同时也确保了volatile读操作之后的所有内存操作(包括读操作)都不会被重排序到volatile写之前。
volatile关键字可以保证可见性,但不能保证原子性,因此不能完全保证线程安全。volatile关键字用于修饰变量,当一个线程修改了volatile修饰的变量的值,其他线程能够立即看到最新的值,从而避免了线程之间的数据不一致。
但是,volatile并不能解决多线程并发下的复合操作问题,比如i++这种操作不是原子操作,如果多个线程同时对i进行自增操作,volatile不能保证线程安全。对于复合操作,需要使用synchronized关键字或者Lock来保证原子性和线程安全。
Synchronized解决了多线程访问共享资源时可能出现的竞态条件和数据不一致的问题,保证了线程安全性。Volatile解决了变量在多线程环境下的可见性和有序性问题,确保了变量的修改对其他线程是可见的。
- Synchronized: Synchronized是一种排他性的同步机制,保证了多个线程访问共享资源时的互斥性,即同一时刻只允许一个线程访问共享资源。通过对代码块或方法添加Synchronized关键字来实现同步。
- Volatile: Volatile是一种轻量级的同步机制,用来保证变量的可见性和禁止指令重排序。当一个变量被声明为Volatile时,线程在读取该变量时会直接从内存中读取,而不会使用缓存,同时对该变量的写操作会立即刷回主内存,而不是缓存在本地内存中。
指令重排序
在执行程序时,为了提高性能,处理器和编译器常常会对指令进行重排序,但是重排序要满足下面 2 个条件才能进行:
- 在单线程环境下不能改变程序运行的结果
- 存在数据依赖关系的不允许重排序。
所以重排序不会对单线程有影响,只会破坏多线程的执行语义。
我们看这个例子,A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面,如果C排到A和B的前面,那么程序的结果将会被改变。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。
公平锁与非公平锁
- 公平锁: 指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点在于各个线程公平平等,每个线程等待一段时间后,都有执行的机会,而它的缺点就在于整体执行速度更慢,吞吐量更小。
- 非公平锁: 多个线程加锁时直接尝试获取锁,能抢到锁到直接占有锁,抢不到才会到等待队列的队尾等待。非公平锁的优势就在于整体执行速度更快,吞吐量更大,但同时也可能产生线程饥饿问题,也就是说如果一直有线程插队,那么在等待队列中的线程可能长时间得不到运行。
公平锁在获取锁时多了一个限制条件:hasQueuedPredecessors() 为 false,这个方法就是判断在等待队列中是否已经有线程在排队了。
这也就是公平锁和非公平锁的核心区别,如果是公平锁,那么一旦已经有线程在排队了,当前线程就不再尝试获取锁;对于非公平锁而言,无论是否已经有线程在排队,都会尝试获取一下锁,获取不到的话,再去排队。这里有一个特例需要我们注意,针对 tryLock() 方法,它不遵守设定的公平原则。
例如,当有线程执行 tryLock() 方法的时候,一旦有线程释放了锁,那么这个正在 tryLock 的线程就能获取到锁,即使设置的是公平锁模式,即使在它之前已经有其他正在等待队列中等待的线程,简单地说就是 tryLock 可以插队。
非公平锁的吞吐量更大
- 公平锁执行流程:获取锁时,先将线程自己添加到等待队列的队尾并休眠,当某线程用完锁之后,会去唤醒等待队列中队首的线程尝试去获取锁,锁的使用顺序也就是队列中的先后顺序,在整个过程中,线程会从运行状态切换到休眠状态,再从休眠状态恢复成运行状态,但线程每次休眠和恢复都需要从用户态转换成内核态,而这个状态的转换是比较慢的,所以公平锁的执行速度会比较慢。
- 非公平锁执行流程:当线程获取锁时,会先通过 CAS 尝试获取锁,如果获取成功就直接拥有锁,如果获取锁失败才会进入等待队列,等待下次尝试获取锁。这样做的好处是,获取锁不用遵循先到先得的规则,从而避免了线程休眠和恢复的操作,这样就加速了程序的执行效率。
线程安全的集合
CopyOnWriteArrayList
:
- 特点: 线程安全的
ArrayList
实现。在写操作(添加、删除、修改)时,会复制一份底层数组,在新数组上进行修改,完成后再替换旧数组的引用。读操作无需加锁。 - 适用场景: 读操作远多于写操作的场景。写操作的开销较大,因为需要复制整个数组。
- 注意事项: 迭代器是“弱一致性”的,即迭代器看到的是创建时集合的快照,不反映后续的写操作。
CopyOnWriteArraySet
:
- 特点: 线程安全的
HashSet
实现,其内部就是基于CopyOnWriteArrayList
实现的。 - 适用场景: 同
CopyOnWriteArrayList
,适用于读多写少的场景。
Collections
工具类包装的同步集合
这些集合通过在每个公共方法上添加 synchronized
关键字来实现同步。
Collections.synchronizedList(new ArrayList<>())
: 线程安全的List
。Collections.synchronizedMap(new HashMap<>())
: 线程安全的Map
。Collections.synchronizedSet(new HashSet<>())
: 线程安全的Set
。- 特点: 简单易用,但同步粒度较粗,每次访问集合都需要获取对象锁。这意味着即使是读操作也会被阻塞。在高并发场景下,性能往往较差,容易成为性能瓶颈。
适用场景: 并发程度较低的场景,或者对性能要求不高、需要快速实现线程安全的场景
ConcurrentHashMap
:线程安全的哈希映射表,用于在多线程环境下高效地存储和访问键值对。它采用了分段锁等技术,允许多个线程同时访问不同的段,提高了并发性能,在高并发场景下比传统的Hashtable
性能更好。CopyOnWriteArrayList
:线程安全的列表,在对列表进行修改操作时,会创建一个新的底层数组,将修改操作应用到新数组上,而读操作仍然可以在旧数组上进行,从而实现了读写分离,提高了并发读的性能,适用于读多写少的场景
线程池
线程池是为了减少频繁的创建线程和销毁线程带来的性能损耗
线程池分为核心线程池,线程池的最大容量,还有等待任务的队列,提交一个任务,如果核心线程没有满,就创建一个线程,如果满了,就是会加入等待队列,如果等待队列满了,就会增加线程,如果达到最大线程数量,如果都达到最大线程数量,就会按照一些丢弃的策略进行处理。
线程池的七大参数
在 Java 中,线程池的核心实现类是 ThreadPoolExecutor,它提供了七个重要的参数来配置线程池的行为。
第一个是核心线程数(corePoolSize)线程池核心线程数量。默认情况下,线程池中线程的数量如果 <= corePoolSize,那么即使这些线程处于空闲状态,那也不会被销毁。
当提交一个新任务时,如果当前线程数小于核心线程数,线程池会优先创建新线程来处理任务,而不是将任务放入队列。例如,设置 corePoolSize=5 表示线程池会始终维护至少 5 个线程。
第二个是最大线程数(maximumPoolSize),它是指线程池中允许的最大线程数量。当任务队列已满且当前线程数小于最大线程数时,线程池会继续创建新线程来处理任务。如果线程数已经达到最大值,则任务会被拒绝。例如,设置 maximumPoolSize=10 表示线程池最多可以创建 10 个线程。但是当 corePoolSize
满 并且 队列满 并且 线程数已达 maximumPoolSize
并且 又有新任务提交时,就会触发拒绝策略。
第三个是线程空闲时间(keepAliveTime),它是指非核心线程在空闲状态下保持存活的时间。当线程池中的线程数超过核心线程数时,多余的空闲线程会在指定的空闲时间后被回收。例如,设置 keepAliveTime=60 表示非核心线程在空闲 60 秒后会被销毁。当线程池中线程的数量大于corePoolSize,并且某个线程的空闲时间超过了keepAliveTime,那么这个线程就会被销毁
第四个是时间单位(unit),它用于指定线程空闲时间的计量单位。常见的单位包括 TimeUnit.SECONDS(秒)、TimeUnit.MILLISECONDS(毫秒)等。例如,unit=TimeUnit.SECONDS 表示空闲时间以秒为单位。
第五个是任务队列(workQueue),它是一个阻塞队列,用于存放等待执行的任务。当线程池中的线程数达到核心线程数时,新提交的任务会被放入任务队列中等待执行。常见的队列类型包括:
ArrayBlockingQueue:有界队列,适用于控制资源使用。
LinkedBlockingQueue:无界队列,适用于任务量较大的场景。
SynchronousQueue:不存储任务的队列,适用于直接传递任务给线程的场景。
第六个是线程工厂(threadFactory),它用于创建线程池中的线程。通过自定义线程工厂,可以为线程设置名称、优先级或其他属性,便于调试和管理。例如,使用 Executors.defaultThreadFactory() 创建默认线程工厂。
第七个是拒绝策略(handler),它用于处理当线程池无法接受新任务时的情况(例如线程数达到最大值且任务队列已满)。常见的拒绝策略包括:
AbortPolicy:抛出异常,拒绝任务。
CallerRunsPolicy:由调用线程执行任务。
DiscardPolicy:直接丢弃任务。
DiscardOldestPolicy:丢弃队列中最旧的任务,并尝试重新提交新任务。
有哪些拒绝策略
当一个新任务交给线程池,如果此时线程池中有空闲的线程,就会直接执行,如果没有空闲的线程,就会将该任务加入到阻塞队列中,如果阻塞队列满了,就会创建一个新线程,从阻塞队列头部取出一个任务来执行,并将新任务加入到阻塞队列末尾。如果当前线程池中线程的数量等于maximumPoolSize,就不会创建新线程,就会去执行拒绝策略。
当线程池的任务队列满了之后,线程池会执行指定的拒绝策略来应对,常用的四种拒绝策略包括:CallerRunsPolicy、AbortPolicy、DiscardPolicy、DiscardOldestPolicy,此外,还可以通过实现RejectedExecutionHandler接口来自定义拒绝策略。
四种预置的拒绝策略:
- CallerRunsPolicy,使用线程池的调用者所在的线程去执行被拒绝的任务,除非线程池被停止或者线程池的任务队列已有空缺。
- AbortPolicy,直接抛出一个任务被线程池拒绝的异常。
- DiscardPolicy,不做任何处理,静默拒绝提交的任务。
- DiscardOldestPolicy,抛弃最老的任务,然后执行该任务。
自定义拒绝策略,通过实现接口可以自定义任务拒绝策略
AbortPolicy(中止策略),它线程池的默认拒绝策略。当线程池无法接受新任务时,它会直接抛出 RejectedExecutionException 异常,终止任务的提交。这种策略适用于对任务执行有严格要求的场景,例如不允许任务丢失的情况。
然后是 CallerRunsPolicy(调用者运行策略),它会将被拒绝的任务回退给提交任务的线程执行。也就是说,任务不会被丢弃,而是由调用线程(通常是主线程)直接运行该任务。这种策略可以减缓任务提交的速度,从而缓解线程池的压力,但可能会导致调用线程阻塞。在这种情况下,主线程会承担部分任务的执行工作。
接下来是 DiscardPolicy(丢弃策略),它会直接丢弃无法处理的任务,并且不会抛出任何异常。这种策略适用于对任务执行要求不高的场景,例如允许部分任务丢失的情况。在这种情况下,被拒绝的任务会被静默丢弃,调用方不会收到任何通知。
最后是 DiscardOldestPolicy(丢弃最旧任务策略),它会丢弃任务队列中最旧的任务(即等待时间最长的任务),然后尝试重新提交当前任务。这种策略可以确保较新的任务有机会被执行,但可能会导致某些任务被重复提交或丢失。在这种情况下,队列中最旧的任务会被移除,为新任务腾出空间。
线程池参数设置
核心线程数(corePoolSize)设置的经验:
- CPU密集型:corePoolSize = CPU核数 + 1(避免过多线程竞争CPU)
- IO密集型:corePoolSize = CPU核数 x 2(或更高,具体看IO等待时间)
当核心线程数为0的时候,会创建一个非核心线程进行执行,来了一个任务之后,会先将任务添加到任务队列,同时也会判断当前工作的线程数是否为 0,如果为 0,则会创建线程来执行线程池的任务
线程池种类
Executors类提供的线程池
- ScheduledThreadPool:可以设置定期的执行任务,它支持定时或周期性执行任务,比如每隔 10 秒钟执行一次任务,通过这个实现类设置定期执行任务的策略。
- FixedThreadPool:它的核心线程数和最大线程数是一样的,所以可以把它看作是固定线程数的线程池,它的特点是线程池中的线程数除了初始阶段需要从 0 开始增加外,之后的线程数量就是固定的,就算任务数超过线程数,线程池也不会再创建更多的线程来处理任务,而是会把超出线程处理能力的任务放到任务队列中进行等待。而且就算任务队列满了,到了本该继续增加线程数的时候,由于它的最大线程数和核心线程数是一样的,所以也无法再增加新的线程了。
- CachedThreadPool:可以称作可缓存线程池,它的特点在于线程数是几乎可以无限增加的(实际最大可以达到 Integer.MAX_VALUE,为 2^31-1,这个数非常大,所以基本不可能达到),而当线程闲置时还可以对线程进行回收。也就是说该线程池的线程数量不是固定不变的,当然它也有一个用于存储提交任务的队列,但这个队列是 SynchronousQueue,队列的容量为0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高。
- SingleThreadExecutor:它会使用唯一的线程去执行任务,原理和 FixedThreadPool 是一样的,只不过这里线程只有一个,如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务。这种线程池由于只有一个线程,所以非常适合用于所有任务都需要按被提交的顺序依次执行的场景,而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序,因为它们是多线程并行执行的。
- SingleThreadScheduledExecutor:它实际和 ScheduledThreadPool 线程池非常相似,它只是 ScheduledThreadPool 的一个特例,内部只有一个线程。
不建议使用 Executors 提供的两种快捷的线程池,原因如下:
- 我们需要根据自己的场景、并发情况来评估线程池的几个核心参数,包括核心线程数、最大线程数、线程回收策略、工作队列的类型,以及拒绝策略,确保线程池的工作行为符合需求,一般都需要设置有界的工作队列和可控的线程数。
- 任何时候,都应该为自定义线程池指定有意义的名称,以方便排查问题。当出现线程数量暴增、线程死锁、线程占用大量 CPU、线程执行出现异常等问题时,往往会抓取线程栈。此时,有意义的线程名称,就可以方便我们定位问题。
除了建议手动声明线程池以外,建议用一些监控手段来观察线程池的状态。如果能提前观察到线程池队列的积压,或者线程数量的快速膨胀,往往可以提早发现并解决问题
线程池shutdown和shutdownNow差别
- shutdown使用了以后会置状态为SHUTDOWN,正在执行的任务会继续执行下去,没有被执行的则中断。此时,则不能再往线程池中添加任何任务,否则将会抛出 RejectedExecutionException 异常
- 而 shutdownNow 为STOP,并试图停止所有正在执行的线程,不再处理还在池队列中等待的任务,当然,它会返回那些未执行的任务。 它试图终止线程的方法是通过调用 Thread.interrupt() 方法来实现的,但是这种方法的作用有限,如果线程中没有sleep 、wait、Condition、定时锁等应用, interrupt()方法是无法中断当前的线程的。所以,ShutdownNow()并不代表线程池就一定立即就能退出,它可能必须要等待所有正在执行的任务都执行完成了才能退出。
撤回提交给线程池的任务
当向线程池提交任务时,会得到一个Future
对象。这个Future
对象提供了几种方法来管理任务的执行,包括取消任务。
取消任务的主要方法是Future
接口中的cancel(boolean mayInterruptIfRunning)
方法。这个方法尝试取消执行的任务。参数mayInterruptIfRunning
指示是否允许中断正在执行的任务。如果设置为true
,则表示如果任务已经开始执行,那么允许中断任务;如果设置为false
,任务已经开始执行则不会被中断。
JVM
JVM 运行时内存共分为虚拟机栈、堆、元空间、程序计数器、本地方法栈五个部分。还有一部分内存叫直接内存,属于操作系统的本地内存,也是可以直接操作的。
JVM内存模型
按照线程是否共享,可以分为
堆,元空间
Java 堆是 JVM 所管理的内存中最大的一块,也是被所有线程共享的内存区域。其唯一的目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
特性:
- 线程共享: 所有线程共享这一块内存。
- 垃圾收集器主要工作区域: 堆是 Java 垃圾收集器(Garbage Collector,GC)管理的主要区域,因此也被称为“GC 堆”(Garbage Collected Heap)。
- 分代思想: 为了更好地进行垃圾回收,现代 JVM 的堆通常被细分为新生代(Young Generation)和老年代(Old Generation)。新生代又分为 Eden 空间、From Survivor 空间和 To Survivor 空间。
- 内存分配: 大多数新创建的对象首先在新生代的 Eden 区分配
可能抛出异常:
OutOfMemoryError
:当堆中没有足够的内存完成实例分配,并且堆也无法再扩展时
方法区(Method Area)
作用: 方法区(在 Java 8 之前是永久代,Java 8 及之后是元空间)是所有线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
特性:
- 线程共享。
- 垃圾回收: 方法区(元空间)也会进行垃圾回收,但条件比较苛刻,主要是对常量池的回收和对类型的卸载。
- 元空间与永久代:
- 永久代 (PermGen, Java 8 之前): 属于堆的一部分,因此受到 GC 管理,大小固定或有限。容易发生
OutOfMemoryError
。 - 元空间 (Metaspace, Java 8 及之后): 不在 JVM 内存中,而是使用本地内存(Native Memory),理论上只受限于系统可用内存,因此默认情况下 OOM 风险降低。
- 永久代 (PermGen, Java 8 之前): 属于堆的一部分,因此受到 GC 管理,大小固定或有限。容易发生
运行时常量池(Runtime Constant Pool):
- 它是方法区的一部分(在 Java 8 元空间中,常量池位于元空间内部)。
用于存放编译期生成的各种字面量和符号引用,这些内容在类加载后进入运行时常量池。
StringTable (字符串常量池):虽然名字里有“常量池”,但它在 JVM 8 之前和之后的位置有所变化。
当你在 Java 代码中直接使用字符串字面量时(例如
String s = "hello";
),JVM 会首先检查字符串常量池中是否已经存在一个内容为"hello"
的字符串对象。- 如果存在,
s
就会直接引用池中已有的对象。 如果不存在,JVM 就会在堆中创建一个新的
String
对象,并将其引用放入字符串常量池,然后s
再引用这个新的对象。 这种机制保证了常量池中每个唯一的字符串字面量只存在一份对应的String
对象实例Java 7 及之前:位于永久代。
- Java 8 及之后:被挪到了堆中。
- 如果存在,
可能抛出异常:
OutOfMemoryError
:当方法区(元空间)无法满足内存分配需求时。
虚拟机栈,本地方法栈以及程序计数器
- 程序计数器(Program Counter Register)
- 作用: 程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
- 特性:
- 线程私有: 每条 JVM 线程都有自己独立的程序计数器。
- 唯一不抛出
OutOfMemoryError
的区域: 这是 JVM 规范中唯一一个没有规定任何OutOfMemoryError
情况的区域。 - 执行引擎的指示器: 如果线程正在执行的是一个 Java 方法,这个计数器就记录着正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。
- Java 虚拟机栈(Java Virtual Machine Stacks)
- 作用: Java 虚拟机栈是线程私有的,它的生命周期与线程相同。每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。\
虚拟机栈中“动态链接”区域的实际内容是一个指向运行时常量池的引用。它的作用是:
- 桥梁: 作为当前执行方法与该方法所属类的运行时常量池之间的桥梁。
入口: 为方法提供一个入口,使其在运行时能够根据需要(例如,调用其他方法、访问字段)去运行时常量池中查找并解析相应的符号引用。
特性:
- 线程私有: 每个线程都有独立的 Java 虚拟机栈。
- 局部变量表: 存储方法参数和方法内部定义的局部变量(基本数据类型和对象引用)。
- 操作数栈: 存储方法执行过程中需要操作的数据。
- 栈帧: 随着方法调用而创建,随着方法结束而销毁。
- 可能抛出异常:
StackOverflowError
:如果线程请求的栈深度大于虚拟机所允许的深度(例如,无限递归)。OutOfMemoryError
:如果 JVM 栈可以动态扩展,并且在扩展时无法申请到足够的内存。
- 本地方法栈(Native Method Stacks)
- 作用: 本地方法栈与 Java 虚拟机栈的作用类似,只不过它服务于 Native 方法(即用 C/C++ 等语言编写的本地方法)。
- 特性:
- 线程私有: 与 Java 虚拟机栈一样,也是线程私有的。
- 可能抛出异常:
StackOverflowError
和OutOfMemoryError
:与 Java 虚拟机栈类似。
虚拟机栈中存储的内容
在JVM内存模型中,栈(Stack)主要用于管理线程的局部变量和方法调用的上下文,而堆(Heap)则是用于存储所有类的实例和数组。
当我们在栈中讨论“存储”时,实际上指的是存储基本类型的数据(如int, double等)和对象的引用,而不是对象本身。
这里的关键点是,栈中存储的不是对象,而是对象的引用。也就是说,当你在方法中声明一个对象,比如MyObject obj = new MyObject();
,这里的obj
实际上是一个存储在栈上的引用,指向堆中实际的对象实例。这个引用是一个固定大小的数据(例如在64位系统上是8字节),它指向堆中分配给对象的内存区域。
堆分为哪几个部分
JVM 堆主要分为以下两个大的部分:
- 新生代 (Young Generation / Young Space)
老年代 (Old Generation / Tenured Generation / Old Space)
新生代 (Young Generation)
- 特点: 绝大多数新创建的对象都首先在新生代中分配内存。新生代中的对象生命周期通常比较短。
- 组成: 新生代又进一步划分为三个子区域:
- Eden 空间 (Eden Space): 这是对象最初被创建和放置的区域。当
new
一个对象时,如果 Eden 空间足够,对象就会在这里分配。 - Survivor 空间 (Survivor Space): 通常有两个,分别为 From Survivor Space 和 To Survivor Space。这两个空间在同一时间只有一个是活跃的(用作对象存活区),另一个是空的(用作下一次复制的清理区)。它们用于存放那些在 Eden 区经过一次垃圾回收后仍然存活的对象。
- Eden 空间 (Eden Space): 这是对象最初被创建和放置的区域。当
- 垃圾回收(Minor GC): 新生代进行的垃圾回收被称为 Minor GC(或 Young GC)。
- 当 Eden 空间不足以分配新对象时,会触发 Minor GC。
- Minor GC 会将 Eden 区和 From Survivor 区中仍然存活的对象复制到 To Survivor 区。
- 然后清空 Eden 区和 From Survivor 区。
- To Survivor 区和 From Survivor 区的角色会互换,以便下一次 Minor GC 使用。
- 对象在 Survivor 区中每熬过一次 Minor GC,其年龄(age)就会增加一岁。当对象的年龄达到一定阈值(默认为 15,可以通过
-XX:MaxTenuringThreshold
参数调整)时,它就会被晋升(晋级)到老年代。
- 老年代 (Old Generation / Tenured Generation)
- 特点: 用于存放那些在新生代中多次垃圾回收后仍然存活的对象,或者是一些生命周期较长、占用内存较大的对象。老年代中的对象通常比较稳定,存活时间长。
- 垃圾回收(Major GC / Full GC): 老年代进行的垃圾回收被称为 Major GC(或 Full GC)。
- 当老年代空间不足时,会触发 Major GC。
- Major GC 的回收效率通常比 Minor GC 低很多,耗时也更长,因为它需要扫描整个老年代,并且可能会触发新生代的 Minor GC。因此,应该尽量避免频繁的 Major GC。
大对象通常会直接分配到老年代。
新生代主要用于存放生命周期较短的对象,并且其内存空间相对较小。如果将大对象分配到新生代,可能会很快导致新生代空间不足,从而频繁触发 Minor GC。而每次 Minor GC 都需要进行对象的复制和移动操作,这会带来一定的性能开销。将大对象直接分配到老年代,可以减少新生代的内存压力,降低 Minor GC 的频率。
大对象通常需要连续的内存空间,如果在新生代中频繁分配和回收大对象,容易产生内存碎片,导致后续分配大对象时可能因为内存不连续而失败。老年代的空间相对较大,更适合存储大对象,有助于减少内存碎片的产生
当程序中通过对象或类直接调用某个方法时,主要包括以下几个步骤:
- 解析方法调用:JVM会根据方法的符号引用找到实际的方法地址(如果之前没有解析过的话)。
- 栈帧创建:在调用一个方法前,JVM会在当前线程的Java虚拟机栈中为该方法分配一个新的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 执行方法:执行方法内的字节码指令,涉及的操作可能包括局部变量的读写、操作数栈的操作、跳转控制、对象创建、方法调用等。
- 返回处理:方法执行完毕后,可能会返回一个结果给调用者,并清理当前栈帧,恢复调用者的执行环境。
方法区于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
- 类信息:包括类的结构信息、类的访问修饰符、父类与接口等信息。
- 常量池:存储类和接口中的常量,包括字面值常量、符号引用,以及运行时常量池。
- 静态变量:存储类的静态变量,这些变量在类初始化的时候被赋值。
- 方法字节码:存储类的方法字节码,即编译后的代码。
- 符号引用:存储类和方法的符号引用,是一种直接引用不同于直接引用的引用类型。
- 运行时常量池:存储着在类文件中的常量池数据,在类加载后在方法区生成该运行时常量池。
- 常量池缓存:用于提升类加载的效率,将常用的常量缓存起来方便使用。
String 保存在字符串常量池中,不同于其他对象,它的值是不可变的,且可以被多个引用共享.
String s = new String(“abc”)执行过程中分别对应哪些内存区域?
首先,我们看到这个代码中有一个new关键字,我们知道new指令是创建一个类的实例对象并完成加载初始化的,因此这个字符串对象是在运行期才能确定的,创建的字符串对象是在堆内存上。
其次,在String的构造方法中传递了一个字符串abc,由于这里的abc是被final修饰的属性,所以它是一个字符串常量。在首次构建这个对象时,JVM拿字面量”abc”去字符串常量池试图获取其对应String对象的引用。于是在堆中创建了一个”abc”的String对象,并将其引用保存到字符串常量池中,然后返回;
所以,如果abc这个字符串常量不存在,则创建两个对象,分别是abc这个字符串常量,以及new String这个实例对象。如果abc这字符串常量存在,则只会创建一个对象。
引用类型
引用类型主要分为强软弱虚四种:
- 强引用指的就是代码中普遍存在的赋值方式,比如A a = new A()这种。强引用关联的对象,永远不会被GC回收。
- 软引用可以用SoftReference来描述,指的是那些有用但是不是必须要的对象。系统在发生内存溢出前会对这类引用的对象进行回收。
- 弱引用可以用WeakReference来描述,他的强度比软引用更低一点,弱引用的对象下一次GC的时候一定会被回收,而不管内存是否足够。
- 虚引用也被称作幻影引用,是最弱的引用关系,可以用PhantomReference来描述,他必须和ReferenceQueue一起使用,同样的当发生GC的时候,虚引用也会被回收。可以用虚引用来管理堆外内存。
在Java中,弱引用是通过Java.lang.ref.WeakReference
类实现的。弱引用的一个主要用途是创建非强制性的对象引用,这些引用可以在内存压力大时被垃圾回收器清理,从而避免内存泄露。
弱引用的使用场景:
- 缓存系统:弱引用常用于实现缓存,特别是当希望缓存项能够在内存压力下自动释放时。如果缓存的大小不受控制,可能会导致内存溢出。使用弱引用来维护缓存,可以让JVM在需要更多内存时自动清理这些缓存对象。
- 对象池:在对象池中,弱引用可以用来管理那些暂时不使用的对象。当对象不再被强引用时,它们可以被垃圾回收,释放内存。
- 避免内存泄露:当一个对象不应该被长期引用时,使用弱引用可以防止该对象被意外地保留,从而避免潜在的内存泄露。
内存泄露:内存泄漏是指程序在运行过程中不再使用的对象仍然被引用,而无法被垃圾收集器回收,从而导致可用内存逐渐减少。虽然在Java中,垃圾回收机制会自动回收不再使用的对象,但如果有对象仍被不再使用的引用持有,垃圾收集器无法回收这些内存,最终可能导致程序的内存使用不断增加。
内存泄露常见原因:
- 静态集合:使用静态数据结构(如
HashMap
或ArrayList
)存储对象,且未清理。 - 事件监听:未取消对事件源的监听,导致对象持续被引用。
- 线程:未停止的线程可能持有对象引用,无法被回收。
内存溢出:内存溢出是指Java虚拟机(JVM)在申请内存时,无法找到足够的内存,最终引发OutOfMemoryError
。这通常发生在堆内存不足以存放新创建的对象时。
内存溢出常见原因:
- 大量对象创建:程序中不断创建大量对象,超出JVM堆的限制。
- 持久引用:大型数据结构(如缓存、集合等)长时间持有对象引用,导致内存累积。
- 递归调用:深度递归导致栈溢出。
内存泄漏案例
1、静态属性导致内存泄露
静态强引用变量作用域是全局的.
留意static的变量,如果集合或大量的对象定义为static的,它们会停留在整个应用程序的生命周期当中。而它们所占用的内存空间,本可以用于其他地方。
那么如何优化呢?第一,进来减少静态变量;第二,如果使用单例,尽量采用懒加载。
2.ThreadLocal提供了线程本地变量,它可以保证访问到的变量属于当前线程,每个线程都保存有一个变量副本,每个线程的变量都不同。ThreadLocal相当于提供了一种线程隔离,将变量与线程相绑定,从而实现线程安全的特性。
ThreadLocal的实现中,每个Thread维护一个ThreadLocalMap映射表,key是ThreadLocal实例本身,value是真正需要存储的Object。
ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统GC时,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value。
如果当前线程迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。
如何解决此问题?
- 第一,使用ThreadLocal提供的remove方法,可对当前线程中的value值进行移除;
- 第二,不要使用ThreadLocal.set(null) 的方式清除value,它实际上并没有清除值,而是查找与当前线程关联的Map并将键值对分别设置为当前线程和null。
- 第三,最好将ThreadLocal视为需要在finally块中关闭的资源,以确保即使在发生异常的情况下也始终关闭该资源。
类初始化与类加载
对象创建过程
一个 new
操作背后包含了 JVM 复杂的内部协作:
- 类加载检查: 确保类已准备就绪。
- 分配内存: 在堆上为对象实例开辟空间,考虑并发问题。
- 初始化零值: 为所有成员变量赋默认值。
- 设置对象头: 填充对象的元数据信息。
- 执行构造函数: 按照代码逻辑完成对象的初始化。
- 返回引用: 将新对象的内存地址交给程序使用。
在Java中创建对象的过程包括以下几个步骤:
类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。 执行类的
<clinit>()
方法(类构造器方法),这是首次对类进行主动使用时触发的。该方法会执行静态代码块中的内容,并为静态变量赋明确指定的值。如果类没有被加载、链接和初始化,JVM 会先执行这些必要的类加载过程。
分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
进行必要设置,比如对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- ‘’执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始——构造函数,即class文件中的方法还没有执行,所有的字段都还为零,对象需要的其他资源和状态信息还没有按照预定的意图构造好。所以一般来说,执行 new 指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全被构造出来。
对象的生命周期包括创建、使用和销毁三个阶段:
创建:对象通过关键字new在堆内存中被实例化,构造函数被调用,对象的内存空间被分配。
使用:对象被引用并执行相应的操作,可以通过引用访问对象的属性和方法,在程序运行过程中被不断使用。
销毁:当对象不再被引用时,通过垃圾回收机制自动回收对象所占用的内存空间。垃圾回收器会在适当的时候检测并回收不再被引用的对象,释放对象占用的内存空间,完成对象的销毁过程。
类加载器
- 启动类加载器(Bootstrap Class Loader):这是最顶层的类加载器,负责加载Java的核心库(如位于jre/lib/rt.jar中的类),它是用C++编写的,是JVM的一部分。启动类加载器无法被Java程序直接引用。 这是最顶层的类加载器,由 C++ 实现,是 JVM 自身的一部分,因此在 Java 代码中无法直接获取到它的引用。它负责加载
JAVA_HOME/jre/lib
目录下(或被-Xbootclasspath
参数所指定的路径中)所有 Java 核心 API 的.jar
文件,例如rt.jar
(运行时类库)、charsets.jar
等。基于安全考虑,它只能加载指定路径下的核心库。 - 扩展类加载器(Extension Class Loader):它是Java语言实现的,继承自ClassLoader类,负责加载Java扩展目录(jre/lib/ext或由系统变量Java.ext.dirs指定的目录)下的jar包和类库。扩展类加载器由启动类加载器加载,并且父加载器就是启动类加载器。可以加载开发者在
ext
目录下放置的自定义.jar
包。 - 系统类加载器(System Class Loader)/ 应用程序类加载器(Application Class Loader):这也是Java语言实现的,负责加载用户类路径(ClassPath)上的指定类库,是我们平时编写Java程序时默认使用的类加载器。系统类加载器的父加载器是扩展类加载器。它可以通过ClassLoader.getSystemClassLoader()方法获取到。是用户自定义类加载器的默认父加载器,也是我们平时最常用的一个类加载器
- 自定义类加载器(Custom Class Loader):开发者可以根据需求定制类的加载方式,比如从网络加载class文件、数据库、甚至是加密的文件中加载类等。自定义类加载器可以用来扩展Java应用程序的灵活性和安全性,是Java动态性的一个重要体现。
这些类加载器之间的关系形成了双亲委派模型,其核心思想是当一个类加载器收到类加载的请求时,首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中。
只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
双亲委派模型作用
双亲委派模型工作原理:
- 当一个类加载器收到类加载请求时,它并不会立即尝试加载这个类。
- 它首先会把这个请求委派给它的父加载器去执行。
- 只有当父加载器反馈它无法完成这个加载请求时(即在它的搜索范围内没有找到该类),子加载器才会尝试自己去加载。
双亲委派模型的优点:
- 避免重复加载: 确保一个类在 JVM 中只会被加载一次,因为总是由顶层的父加载器优先加载。
安全性: 防止核心 API 类被恶意替换。例如,如果有人尝试编写一个名为
java.lang.Object
的类并替换核心库,由于启动类加载器会优先加载真正的rt.jar
中的Object
类,恶意类就不会被加载。保证类的唯一性:通过委托机制,确保了所有加载请求都会传递到启动类加载器,避免了不同类加载器重复加载相同类的情况,保证了Java核心类库的统一性,也防止了用户自定义类覆盖核心类库的可能。
- 保证安全性:由于Java核心库被启动类加载器加载,而启动类加载器只加载信任的类路径中的类,这样可以防止不可信的类假冒核心类,增强了系统的安全性。例如,恶意代码无法自定义一个Java.lang.System类并加载到JVM中,因为这个请求会被委托给启动类加载器,而启动类加载器只会加载标准的Java库中的类。
- 支持隔离和层次划分:双亲委派模型支持不同层次的类加载器服务于不同的类加载需求,如应用程序类加载器加载用户代码,扩展类加载器加载扩展框架,启动类加载器加载核心库。这种层次化的划分有助于实现沙箱安全机制,保证了各个层级类加载器的职责清晰,也便于维护和扩展。
- 简化了加载流程:通过委派,大部分类能够被正确的类加载器加载,减少了每个加载器需要处理的类的数量,简化了类的加载过程,提高了加载效率。
双亲委派模型的工作流程,主要分为四步,
第一步是检查缓存,当前类加载器会先检查是否已经加载过目标类,如果已加载,则直接返回对应的 Class 对象。
第二步是委派父加载器,如果没有加载过,当前类加载器会将加载请求委派给父加载器处理。
第三步是递归向上,父加载器继续将请求委派给它的父加载器,直到到达 Bootstrap ClassLoader。
第四步是尝试加载,如果父加载器无法加载目标类,则子加载器会尝试自己加载。
类加载过程
类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括以下 7 个阶段:
- 加载:通过类的全限定名(包名 + 类名),获取到该类的.class文件的二进制字节流,将二进制字节流所代表的静态存储结构,转化为方法区运行时的数据结构,在内存中生成一个代表该类的Java.lang.Class对象,作为方法区这个类的各种数据的访问入口
- 链接:验证、准备、解析 3 个阶段统称为连接。
- 验证:确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。验证阶段大致会完成以下四个阶段的检验动作:文件格式校验、元数据验证、字节码验证、符号引用验证
- 准备:为类中的静态字段分配内存,并设置默认的初始值,比如int类型初始值是0。被final修饰的static字段不会设置,因为final在编译的时候就分配了
- 解析:解析阶段是虚拟机将常量池的「符号引用」直接替换为「直接引用」的过程。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用, 那引用的目标必定已经存在在内存中了。
- 初始化:初始化是整个类加载过程的最后一个阶段,初始化阶段简单来说就是执行类的初始化代码(括静态变量赋值和静态代码块的执行),要注意的是这里的初始化方法并不是开发者写的,而是编译器自动生成的。初始化的顺序遵循“父类优先”的原则,即先初始化父类,再初始化子类。
- 使用:使用类或者创建对象
- 卸载:如果有下面的情况,类就会被卸载:1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。2. 加载该类的ClassLoader已经被回收。 3. 类对应的Java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾回收机制
垃圾回收(Garbage Collection, GC)是自动管理内存的一种机制,它负责自动释放不再被程序引用的对象所占用的内存,这种机制减少了内存泄漏和内存管理错误的可能性。
垃圾回收(Garbage Collection, GC)是自动管理内存的一种机制,它负责自动释放不再被程序引用的对象所占用的内存,这种机制减少了内存泄漏和内存管理错误的可能性。垃圾回收可以通过多种方式触发,具体如下:
- 内存不足时:当JVM检测到堆内存不足,无法为新的对象分配内存时,会自动触发垃圾回收。
- 手动请求:虽然垃圾回收是自动的,开发者可以通过调用
System.gc()
或Runtime.getRuntime().gc()
建议 JVM 进行垃圾回收。不过这只是一个建议,并不能保证立即执行。 - JVM参数:启动 Java 应用时可以通过 JVM 参数来调整垃圾回收的行为,比如:
-Xmx
(最大堆大小)、-Xms
(初始堆大小)等。 - 对象数量或内存使用达到阈值:垃圾收集器内部实现了一些策略,以监控对象的创建和内存使用,达到某个阈值时触发垃圾回收
判断垃圾的方式
引用计数与可达性分析.
引用计数法(Reference Counting)
- 原理:为每个对象分配一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1。当计数器为0时,表示对象不再被任何变量引用,可以被回收。
- 缺点:不能解决循环引用的问题,即两个对象相互引用,但不再被其他任何对象引用,这时引用计数器不会为0,导致对象无法被回收。
可达性分析
Java虚拟机主要采用此算法来判断对象是否为垃圾。
- 原理:从一组称为GC Roots(垃圾收集根)的对象出发,向下追溯它们引用的对象,以及这些对象引用的其他对象,以此类推。如果一个对象到GC Roots没有任何引用链相连(即从GC Roots到这个对象不可达),那么这个对象就被认为是不可达的,可以被回收。GC Roots对象包括:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、本地方法栈中JNI(Java Native Interface)引用的对象、活跃线程的引用等
垃圾回收算法
- 标记-清除算法:标记-清除算法分为“标记”和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象。标记-清除算法有两个缺陷,一个是效率问题,标记和清除的过程效率都不高,另外一个就是,清除结束后会造成大量的碎片空间。有可能会造成在申请大块内存的时候因为没有足够的连续空间导致再次 GC。
- 复制算法:为了解决碎片空间的问题,出现了“复制算法”。复制算法的原理是,将内存分成两块,每次申请内存时都使用其中的一块,当内存不够时,将这一块内存中所有存活的复制到另一块上。然后将然后再把已使用的内存整个清理掉。复制算法解决了空间碎片的问题。但是也带来了新的问题。因为每次在申请内存时,都只能使用一半的内存空间。内存利用率严重不足。
- 标记-整理算法:复制算法在 GC 之后存活对象较少的情况下效率比较高,但如果存活对象比较多时,会执行较多的复制操作,效率就会下降。而老年代的对象在 GC 之后的存活率就比较高,所以就有人提出了“标记-整理算法”。标记-整理算法的“标记”过程与“标记-清除算法”的标记过程一致,但标记之后不会直接清理。而是将所有存活对象都移动到内存的一端。移动结束后直接清理掉剩余部分。
- 分代回收算法:分代收集是将内存划分成了新生代和老年代。分配的依据是对象的生存周期,或者说经历过的 GC 次数。对象创建时,一般在新生代申请内存,当经历一次 GC 之后如果对还存活,那么对象的年龄 +1。当年龄超过一定值(默认是 15,可以通过参数 -XX:MaxTenuringThreshold 来设定)后,如果对象还存活,那么该对象会进入老年代。
对于新生代,大多数对象朝生夕灭,采用复制算法进行垃圾回收。新生代进一步划分为 Eden 区和两个 Survivor 区(From 和 To);对于老年代,存活时间较长的对象存储在此,采用标记-清除或标记-整理算法进行垃圾回收。
垃圾回收器有哪些
- CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。
- G1(Garbage First)收集器 (标记-整理算法): Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代
第一个是 CMS 收集器,CMS(Concurrent Mark Sweep)是以最小化停顿时间为目标的垃圾收集器,适用于需要高响应的应用场景(如 Web 应用)。其基于“标记-清除算法”,回收流程包括以下阶段:
首先停止所有用户线程,启用一个GC线程进行初始标记(Stop The World),标记 GC Roots 能直接引用的对象,停顿时间短。
其次由用户线程和 GC 线程并发执行,进行并发标记,用户线程和 GC 线程并发执行,完成从 GC Roots 开始的对象引用分析。
然后,启动多个GC 线程进行重新标记(Stop The World),修正并发标记期间用户线程对对象引用的变动,停顿时间稍长但可控。
最后,启动多个用户线程和一个GC 线程,进行并发清除,清理不可达对象,清理完成后把GC线程进行重置。
CMS 的优点是以响应时间优先,停顿时间短,但也有两个缺点,一个是由于CMS采用“标记-清除”,会导致内存碎片积累,另一个是由于在并发清理过程中仍有用户线程运行,可能生成新的垃圾对象,需在下次 GC 处理。
第二个是 G1 收集器,G1(Garbage-First)收集器以控制 GC 停顿时间为目标,兼具高吞吐量和低延迟性能,适用于大内存、多核环境。其基于“标记-整理”和“标记-复制算法”,回收流程包括以下阶段:
首先,停止所有用户线程,启用一个GC线程进行初始标记(Stop The World),标记从 GC Roots 可达的对象,时间短。
其次,让用户线程和一个GC 线程并发工作,用GC 线程进行并发标记,分析整个堆中对象的存活情况。
然后,停止所有用户线程,让多个GC 线程进行最终标记(Stop The World),修正并发标记阶段产生的引用变动,识别即将被回收的对象。
最后,让多个GC 线程进行筛选回收,根据收集时间预算,优先回收回收价值最高的 Region。回收完成后把GC线程进行重置。这是 G1 的核心优化,基于堆分区,将回收工作集中于垃圾最多的区域,避免全堆扫描。
G1 具有三个优点,
其一,将堆内存划分为多个 Region,可分别执行标记、回收,提升效率。
第二,采用“标记-整理”和“标记-复制”,实现内存紧凑化。
第三,方便控制停顿时间,通过后台维护的优先队列,动态选择高价值 Region,极大减少了全堆停顿的频率。
但G1缺点是:调优复杂,对硬件资源要求较高。
Spring与SpringBoot
重要考点:Bean的声明周期与扩展方法 IoC和AOP理解
动态代理 动态代理与静态代理区别 AOP执行流程
Spring事务,传播行为以及什么时候会失效
核心思想
Spring框架核心特性包括:
- IoC容器:Spring通过控制反转实现了对象的创建和对象间的依赖关系管理。开发者只需要定义好Bean及其依赖关系,Spring容器负责创建和组装这些对象。
- AOP:面向切面编程,允许开发者定义横切关注点,例如事务管理、安全控制等,独立于业务逻辑的代码。通过AOP,可以将这些关注点模块化,提高代码的可维护性和可重用性。
- 事务管理:Spring提供了一致的事务管理接口,支持声明式和编程式事务。开发者可以轻松地进行事务管理,而无需关心具体的事务API。
- MVC框架:Spring MVC是一个基于Servlet API构建的Web框架,采用了模型-视图-控制器(MVC)架构。它支持灵活的URL到页面控制器的映射,以及多种视图技术
核心思想 | 解决的问题 | 实现手段 | 典型应用场景 |
---|---|---|---|
IOC | 对象创建与依赖管理的高耦合 | 容器管理Bean生命周期 | 动态替换数据库实现、服务组装 |
DI | 依赖关系的硬编码问题 | Setter/构造器/注解注入 | 注入数据源、服务层依赖DAO层 |
AOP | 横切逻辑分散在业务代码中 | 动态代理与切面配置 | 日志、事务、权限校验统一处理 |
- IoC:即控制反转的意思,它是一种创建和获取对象的技术思想,依赖注入(DI)是实现这种技术的一种方式。传统开发过程中,我们需要通过new关键字来创建对象。使用IoC思想开发方式的话,我们不通过new关键字创建对象,而是通过IoC容器来帮我们实例化对象。 通过IoC的方式,可以大大降低对象之间的耦合度。
- AOP:是面向切面编程,能够将那些与业务无关,却为业务模块所共同调用的逻辑封装起来,以减少系统的重复代码,降低模块间的耦合度。Spring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理。
Spring中的AOP
在面向切面编程的思想里面,把功能分为两种
- 核心业务:登陆、注册、增、删、改、查、都叫核心业务
- 周边功能:日志、事务管理这些次要的为周边业务
在面向切面编程中,核心业务功能和周边功能是分别独立进行开发,两者不是耦合的,然后把切面功能和核心业务功能 “编织” 在一起,这就叫AOP。
AOP能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。
在 AOP 中有以下几个概念:
- AspectJ:切面,只是一个概念,没有具体的接口或类与之对应,是 Join point,Advice 和 Pointcut 的一个统称。
- Join point:连接点,指程序执行过程中的一个点,例如方法调用、异常处理等。在 Spring AOP 中,仅支持方法级别的连接点。
- Advice:通知,即我们定义的一个切面中的横切逻辑,有“around”,“before”和“after”三种类型。在很多的 AOP 实现框架中,Advice 通常作为一个拦截器,也可以包含许多个拦截器作为一条链路围绕着 Join point 进行处理。
- Pointcut:切点,用于匹配连接点,一个 AspectJ 中包含哪些 Join point 需要由 Pointcut 进行筛选。
- Introduction:引介,让一个切面可以声明被通知的对象实现任何他们没有真正实现的额外的接口。例如可以让一个代理对象代理两个目标类。
- Weaving:织入,在有了连接点、切点、通知以及切面,如何将它们应用到程序中呢?没错,就是织入,在切点的引导下,将通知逻辑插入到目标方法上,使得我们的通知逻辑在方法调用时得以执行。
- AOP proxy:AOP 代理,指在 AOP 实现框架中实现切面协议的对象。在 Spring AOP 中有两种代理,分别是 JDK 动态代理和 CGLIB 动态代理。
- Target object:目标对象,就是被代理的对象。
Spring的中IoC
Spring IoC(Inversion of Control,控制反转)是 Spring 框架的核心机制之一,负责管理对象的创建、依赖关系和生命周期,从而实现组件解耦,提升代码的可维护性和扩展性。
首先,IoC 的核心思想 是将对象的管理权从应用程序代码中转移到 Spring 容器。传统方式下,类 A 依赖于类 B,A 需要自己创建 B 的实例,而在 IoC 模式下,Spring 负责实例化和注入 B,A 只需要声明依赖即可。
其次,Spring IoC 主要通过依赖注入(DI)来实现。Spring 通过 XML 配置、Java 注解(@Autowired、@Resource)或 Java 代码(@Bean)定义 Bean 及其依赖关系,容器会在运行时自动解析并注入相应的对象。
接着,Spring IoC 的工作流程 可以分为三个阶段:
第一个阶段是IOC 容器初始化,
Spring 解析 XML 配置或注解,获取所有 Bean 的定义信息,生成 BeanDefinition。
BeanDefinition 存储了 Bean 的基本信息(类名、作用域、依赖等),并注册到 IOC 容器的 BeanDefinitionMap 中。
这个阶段完成了 IoC 容器的初始化,但还未实例化 Bean。
第二个阶段是Bean 实例化及依赖注入
Spring 通过反射实例化那些 未设置 lazy-init 且是单例模式 的 Bean。
依赖注入(DI)发生在这个阶段,Spring 根据 BeanDefinition 解析 Bean 之间的依赖关系,并通过构造方法、setter 方法或字段注入(@Autowired)完成对象的注入。
第三个阶段是Bean 的使用
业务代码可以通过 @Autowired 或 BeanFactory.getBean() 获取 Bean。
对于 设置了 lazy-init 的 Bean 或非单例 Bean,它们的实例化不会在 IoC 容器初始化时完成,而是在 第一次调用 getBean() 时 进行创建和初始化,且 Spring 不会长期管理它们。
最后,Spring IoC 主要解决三个问题,
第一个是降低耦合,组件之间通过接口和依赖注入解耦,增强了代码的灵活性。
第二个是简化对象管理,开发者无需手动创建对象,Spring 统一管理 Bean 生命周期。
第三个是提升维护性,当需要修改依赖关系时,只需调整配置,而无需修改业务代码。
IOC和AOP实现机制
IOC实现机制
- 反射:Spring IOC容器利用Java的反射机制动态地加载类、创建对象实例及调用对象方法,反射允许在运行时检查类、方法、属性等信息,从而实现灵活的对象实例化和管理。
- 依赖注入:IOC的核心概念是依赖注入,即容器负责管理应用程序组件之间的依赖关系。Spring通过构造函数注入、属性注入或方法注入,将组件之间的依赖关系描述在配置文件中或使用注解。
- 设计模式 - 工厂模式:Spring IOC容器通常采用工厂模式来管理对象的创建和生命周期。容器作为工厂负责实例化Bean并管理它们的生命周期,将Bean的实例化过程交给容器来管理。
- 容器实现:Spring IOC容器是实现IOC的核心,通常使用BeanFactory或ApplicationContext来管理Bean。BeanFactory是IOC容器的基本形式,提供基本的IOC功能;ApplicationContext是BeanFactory的扩展,并提供更多企业级功能。
所谓控制就是对象的创建、初始化、销毁。
- 创建对象:原来是 new 一个,现在是由 Spring 容器创建。
- 初始化对象:原来是对象自己通过构造器或者 setter 方法给依赖的对象赋值,现在是由 Spring 容器自动注入。
- 销毁对象:原来是直接给对象赋值 null 或做一些销毁操作,现在是 Spring 容器管理生命周期负责销毁对象。
控制反转与依赖注入
- 控制反转:“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架。
- 依赖注入:依赖注入和控制反转恰恰相反,它是一种具体的编码技巧。不通过 new 的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类来使用。
依赖注入则是将对象的创建和依赖关系的管理交给 Spring 容器来完成,类只需要声明自己所依赖的对象,容器会在运行时将这些依赖对象注入到类中,从而降低了类与类之间的耦合度,提高了代码的可维护性和可测试性。常见的依赖注入的实现方式,比如构造器注入、Setter方法注入,还有字段注入
Spring IOC实现需要考虑的问题
- Bean的生命周期管理:需要设计Bean的创建、初始化、销毁等生命周期管理机制,可以考虑使用工厂模式和单例模式来实现。
- 依赖注入:需要实现依赖注入的功能,包括属性注入、构造函数注入、方法注入等,可以考虑使用反射机制和XML配置文件来实现。
- Bean的作用域:需要支持多种Bean作用域,比如单例、原型、会话、请求等,可以考虑使用Map来存储不同作用域的Bean实例。
- AOP功能的支持:需要支持AOP功能,可以考虑使用动态代理机制和切面编程来实现。
- 异常处理:需要考虑异常处理机制,包括Bean创建异常、依赖注入异常等,可以考虑使用try-catch机制来处理异常。
- 配置文件加载:需要支持从不同的配置文件中加载Bean的相关信息,可以考虑使用XML、注解或者Java配置类来实现
AOP实现机制
Spring AOP的实现依赖于动态代理技术。动态代理是在运行时动态生成代理对象,而不是在编译时。它允许开发者在运行时指定要代理的接口和行为,从而实现在不修改源码的情况下增强方法的功能。
Spring AOP支持两种动态代理:
- 基于JDK的动态代理:使用java.lang.reflect.Proxy类和java.lang.reflect.InvocationHandler接口实现。这种方式需要代理的类实现一个或多个接口。
- 基于CGLIB的动态代理:当被代理的类没有实现接口时,Spring会使用CGLIB库生成一个被代理类的子类作为代理。CGLIB(Code Generation Library)是一个第三方代码生成库,通过继承方式实现代理。
Java的动态代理是一种在运行时动态创建代理对象的机制,主要用于在不修改原始类的情况下对方法调用进行拦截和增强。
Java动态代理主要分为两种类型:
- 基于接口的代理(JDK动态代理): 这种类型的代理要求目标对象必须实现至少一个接口。Java动态代理会创建一个实现了相同接口的代理类,然后在运行时动态生成该类的实例。这种代理的实现核心是
java.lang.reflect.Proxy
类和java.lang.reflect.InvocationHandler
接口。每一个动态代理类都必须实现InvocationHandler
接口,并且每个代理类的实例都关联到一个handler
。当通过代理对象调用一个方法时,这个方法的调用会被转发为由InvocationHandler
接口的invoke()
方法来进行调用。 - 基于类的代理(CGLIB动态代理): CGLIB(Code Generation Library)是一个强大的高性能的代码生成库,它可以在运行时动态生成一个目标类的子类。CGLIB代理不需要目标类实现接口,而是通过继承的方式创建代理类。因此,如果目标对象没有实现任何接口,可以使用CGLIB来创建动态代理。
代理是一种常用的设计模式,目的是:为其他对象提供一个代理以控制对某个对象的访问,将两个类的关系解耦。代理类和委托类都要实现相同的接口,因为代理真正调用的是委托类的方法。
区别:
- 静态代理:由程序员创建或者是由特定工具创建,在代码编译时就确定了被代理的类是一个静态代理。静态代理通常只代理一个类;
- 动态代理:在代码运行期间,运用反射机制动态创建生成。动态代理代理的是一个接口下的多个实现类。
如何解决循环依赖问题
循环依赖指的是两个类中的属性相互依赖对方:例如 A 类中有 B 属性,B 类中有 A属性,从而形成了一个依赖闭环
循环依赖问题在Spring中主要有三种情况:
- 第一种:通过构造方法进行依赖注入时产生的循环依赖问题。
- 第二种:通过setter方法进行依赖注入且是在多例(原型)模式下产生的循环依赖问题。
- 第三种:通过setter方法进行依赖注入且是在单例模式下产生的循环依赖问题。
只有第三种方式的循环依赖问题被 Spring 解决了,其他两种方式在遇到循环依赖问题时,Spring都会产生异常。
假设 Spring 尝试创建 Bean A:
- Spring 实例化
BeanA
(调用构造器,此时BeanA
的依赖beanB
尚未设置)。 - Spring 将一个 Bean A 的早期引用(一个 ObjectFactory)放入三级缓存。这个引用在将来可以暴露一个尚未完全初始化的
BeanA
实例。 - Spring 尝试填充
BeanA
的属性,发现它需要BeanB
。 - Spring 开始创建
BeanB
。 - Spring 实例化
BeanB
。 - Spring 将一个
BeanB
的早期引用放入三级缓存。 - Spring 尝试填充
BeanB
的属性,发现它需要BeanA
。 - Spring 检查一级缓存(没有
BeanA
),检查二级缓存(没有BeanA
)。 - Spring 检查三级缓存,发现有
BeanA
的早期引用工厂。它通过这个工厂获取到一个尚未完全初始化(但已实例化)的BeanA
实例。 - Spring 将这个早期
BeanA
实例注入到BeanB
中。此时BeanB
可以继续完成初始化。 BeanB
初始化完成后,被放入一级缓存。- Spring 回到
BeanA
的初始化过程,将完全初始化的BeanB
实例注入到BeanA
中。 BeanA
初始化完成后,被放入一级缓存。
通过三级缓存,Spring 能够在 Bean 被完全初始化之前,就将其“半成品”的引用暴露给其他 Bean,从而打破了循环。
注意事项:
- 这种解决方案只适用于单例 (Singleton) 作用域的 Bean。
- 它不适用于原型 (Prototype) 作用域的 Bean,因为原型 Bean 每次获取都是新实例,无法缓存“半成品”状态。
- 它不适用于构造器注入,因为构造器是在 Bean 实例创建时就要求所有依赖必须到位,而此时没有“半成品”可供缓存
动态代理与静态代理
动态代理是一种在运行时动态生成代理对象,并在代理对象中增强目标对象方法的技术。它被广泛用于 AOP(面向切面编程)、权限控制、日志记录等场景,使得程序更加灵活、可维护。动态代理可以通过 JDK 原生的 Proxy 机制或 CGLIB 方式实现。
Java动态代理主要分为两种类型:
基于接口的代理(JDK动态代理): 这种类型的代理要求目标对象必须实现至少一个接口。Java动态代理会创建一个实现了相同接口的代理类,然后在运行时动态生成该类的实例。这种代理的实现核心是
java.lang.reflect.Proxy
类和java.lang.reflect.InvocationHandler
接口。每一个动态代理类都必须实现InvocationHandler
接口,并且每个代理类的实例都关联到一个handler
。当通过代理对象调用一个方法时,这个方法的调用会被转发为由InvocationHandler
接口的invoke()
方法来进行调用。基于类的代理(CGLIB动态代理): CGLIB(Code Generation Library)是一个强大的高性能的代码生成库,它可以在运行时动态生成一个目标类的子类。CGLIB代理不需要目标类实现接口,而是通过继承的方式创建代理类。因此,如果目标对象没有实现任何接口,可以使用CGLIB来创建动态代理.
CGLIB 通过子类继承目标类,适用于没有实现接口的类,当使用 CGLIB 动态代理时,主要分为四步,
第一步是通过 Enhancer 创建代理对象。
第二步是设置父类,CGL IB 代理基于子类继承,因此代理对象是目标类的子类。
第三步是定义并实现 MethodInterceptor 接口,在 intercept 方法中增强目标方法。
第四步是调用代理方法,当调用代理对象的方法时,intercept 方法会被触发,执行增强逻辑,并最终调用目标方法.
可以通过配置强制 Spring 始终使用 CGLIB 代理,即使目标 Bean 实现了接口。这通常通过设置@EnableAspectJAutoProxy(proxyTargetClass = true)
动态代理和静态代理的区别
代理是一种常用的设计模式,目的是:为其他对象提供一个代理以控制对某个对象的访问,将两个类的关系解耦。代理类和委托类都要实现相同的接口,因为代理真正调用的是委托类的方法。
区别:
- 静态代理:由程序员创建或者是由特定工具创建,在代码编译时就确定了被代理的类是一个静态代理。静态代理通常只代理一个类;
- 动态代理:在代码运行期间,运用反射机制动态创建生成。动态代理代理的是一个接口下的多个实现类。
Spring的事务
事务的ACID特性:
- 原子性 (Atomicity): 事务是一个不可分割的工作单元,要么全部提交,要么全部回滚。
- 一致性 (Consistency): 事务完成后,数据必须处于一致状态,满足所有预设规则。
- 隔离性 (Isolation): 并发事务的执行互不干扰,一个事务的中间状态对其他事务不可见。
- 持久性 (Durability): 事务提交后,对数据的修改是永久性的,即使系统故障也不会丢失。
Spring事务实现
Spring 定义了 PlatformTransactionManager
接口,它是 Spring 事务策略的核心。不同的数据访问技术有不同的实现类:
DataSourceTransactionManager
: 用于纯 JDBC 或 MyBatis。JpaTransactionManager
: 用于 JPA。HibernateTransactionManager
: 用于 Hibernate (已过时,通常用JpaTransactionManager
配合 Hibernate)。JtaTransactionManager
: 用于分布式事务,通过 JTA (Java Transaction API) 实现。这个管理器负责与底层事务资源(如数据库连接)进行交互,执行事务的提交、回滚等操作。
事务同步管理器 (TransactionSynchronizationManager):
- 这是一个内部类,Spring 用它来管理线程本地(ThreadLocal)的事务上下文,确保同一个线程中的所有操作都在同一个事务中执行
Spring 事务本身不实现事务。它是一个高级的、声明式的事务管理框架,它通过其事务管理器 (PlatformTransactionManager
的不同实现) 来委托和协调底层数据访问技术(如 JDBC、JPA)和数据库(DBMS)来执行真正的事务操作。
Spring 的 PlatformTransactionManager
实现类就是连接 Spring 事务抽象和底层数据库事务的桥梁。例如,DataSourceTransactionManager: 当你的应用使用纯 JDBC 或 MyBatis 时,你会配置 DataSourceTransactionManager
。这个管理器会通过 Java 的 JDBC API 来与数据库进行事务操作。
事务传播行为与隔离级别
@Transactional
注解提供了丰富的属性来控制事务行为:
propagation
(事务传播行为): 定义事务方法如何加入到现有事务中。REQUIRED
(默认): 如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新事务。SUPPORTS
: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务方式执行。MANDATORY
: 如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。REQUIRES_NEW
: 总是创建一个新事务,并挂起当前存在的事务(如果存在)。NOT_SUPPORTED
: 以非事务方式执行操作,并挂起当前存在的事务(如果存在)。NEVER
: 以非事务方式执行操作;如果当前存在事务,则抛出异常。NESTED
: 如果当前存在事务,则在嵌套事务中执行。如果当前没有事务,则行为与REQUIRED
类似。嵌套事务(例如 JDBC savepoints)可以在外部事务回滚时回滚到保存点,但不能独立提交。
isolation
(事务隔离级别): 定义多个事务并发执行时,一个事务对另一个事务的影响程度。DEFAULT
(默认): 使用底层数据库的默认隔离级别。READ_UNCOMMITTED
(读未提交): 最低的隔离级别,允许读取尚未提交的数据。可能导致脏读、不可重复读和幻读。READ_COMMITTED
(读已提交): 只能读取已提交的数据。避免脏读,但可能出现不可重复读和幻读。REPEATABLE_READ
(可重复读): 保证在同一个事务中多次读取同一数据时,结果总是一致的。避免脏读和不可重复读,但可能出现幻读。SERIALIZABLE
(串行化): 最高的隔离级别,完全隔离所有并发事务。避免脏读、不可重复读和幻读,但并发性能最低。
readOnly
(只读事务):true
:表示该事务只进行读取操作,不修改数据。优化数据库性能(数据库可能会进行一些优化,如不加锁)。false
(默认):读写事务。
timeout
(事务超时):- 定义事务在强制回滚之前可以运行的最大秒数。
- 默认值为 -1,表示不超时。
rollbackFor
/rollbackForClassName
:- 指定哪些异常类型会导致事务回滚。
- 默认情况下,只有运行时异常 (
RuntimeException
) 及其子类会导致事务回滚,检查型异常 (Checked Exception
) 不会。
noRollbackFor
/noRollbackForClassName
:- 指定哪些异常类型不会导致事务回滚。
事务失效场景
Spring Boot通过Spring框架的事务管理模块来支持事务操作。事务管理在Spring Boot中通常是通过 @Transactional 注解来实现的。事务可能会失效的一些常见情况包括:
- 未捕获异常: 如果一个事务方法中发生了未捕获的异常,并且异常未被处理或传播到事务边界之外,那么事务会失效,所有的数据库操作会回滚。
- 非受检异常: 默认情况下,Spring对非受检异常(RuntimeException或其子类)进行回滚处理,这意味着当事务方法中抛出这些异常时,事务会回滚。
- 事务传播属性设置不当: 如果在多个事务之间存在事务嵌套,且事务传播属性配置不正确,可能导致事务失效。特别是在方法内部调用有 @Transactional 注解的方法时要特别注意。
- 多数据源的事务管理: 如果在使用多数据源时,事务管理没有正确配置或者存在多个 @Transactional 注解时,可能会导致事务失效。
- 跨方法调用事务问题: 如果一个事务方法内部调用另一个方法,而这个被调用的方法没有 @Transactional 注解,这种情况下外层事务可能会失效。
- 事务在非公开方法中失效: 如果 @Transactional 注解标注在私有方法上或者非 public 方法上,事务也会失效。
具体来说:
即使使用了 @Transactional
注解,事务也可能因为一些常见的原因而失效:
@Transactional
注解在非 public 方法上:- Spring AOP 默认是基于 JDK 动态代理或 CGLIB 代理来实现的,它只对
public
方法有效。如果你在private
或protected
方法上加@Transactional
,事务将不会生效。
- Spring AOP 默认是基于 JDK 动态代理或 CGLIB 代理来实现的,它只对
- 同一个类中方法互调 (Self-invocation):
- 如果一个
@Transactional
方法被同一个 Bean 内部的另一个方法调用,而该调用没有经过 Spring 代理,事务也不会生效。 - 解决方案:
- 将调用逻辑拆分到另一个服务 Bean 中。
- 通过 Spring AOP 的
AopContext.currentProxy()
获取当前代理对象进行调用(需要暴露代理)。 - 注入自身 Bean(如上例中的
self
)。
- 如果一个
- 异常被捕获但未抛出或未标记回滚:
- 如果事务方法内部抛出了一个异常,但你将其
try-catch
捕获了,并且没有重新抛出(或者抛出的不是RuntimeException
且未配置rollbackFor
),Spring 就不会知道需要回滚事务。 - 解决方案: 确保事务方法抛出
RuntimeException
或配置rollbackFor
。
- 如果事务方法内部抛出了一个异常,但你将其
- 数据库不支持事务:
- 例如,MySQL 的 MyISAM 存储引擎不支持事务,即使配置了事务,也不会生效。必须使用 InnoDB 等支持事务的存储引擎。
- 没有配置事务管理器:
- Spring 需要
PlatformTransactionManager
Bean 才能启用事务功能。 - 解决方案: 确保你的配置类中有类似
DataSourceTransactionManager
的 Bean。
- Spring 需要
@Transactional
注解所在的类没有被 Spring 管理:@Transactional
只能作用于 Spring 容器管理的 Bean。- 解决方案: 确保类上带有
@Component
,@Service
,@Repository
等 Spring 注解。
事务方法的自调用与解决方法
Spring的事务,使用this调用是否生效?
不能生效。
因为Spring事务是通过代理对象来控制的,只有通过代理对象的方法调用才会应用事务管理的相关规则。当使用this
直接调用时,是绕过了Spring的代理机制,因此不会应用事务设置。
解决方法:
- 将调用逻辑拆分到另一个服务 Bean 中。
- 通过 Spring AOP 的
AopContext.currentProxy()
获取当前代理对象进行调用(需要暴露代理@EnableAspectJAutoProxy(exposeProxy = true))。 - 注入自身 Bean(如上例中的
self
)。
SpringMVC的handlermapping和handleradapter
Spring MVC的工作流程如下:
- 用户发送请求至前端控制器DispatcherServlet
- DispatcherServlet收到请求调用处理器映射器HandlerMapping。
- 处理器映射器根据请求url找到具体的处理器,生成处理器执行链HandlerExecutionChain(包括处理器对象和处理器拦截器)一并返回给DispatcherServlet。
- DispatcherServlet根据处理器Handler获取处理器适配器HandlerAdapter执行HandlerAdapter处理一系列的操作,如:参数封装,数据格式转换,数据验证等操作
- 执行处理器Handler(Controller,也叫页面控制器)。
- Handler执行完成返回ModelAndView
- HandlerAdapter将Handler执行结果ModelAndView返回到DispatcherServlet
- DispatcherServlet将ModelAndView传给ViewReslover视图解析器
- ViewReslover解析后返回具体View
- DispatcherServlet对View进行渲染视图(即将模型数据model填充至视图中)。
- DispatcherServlet响应用户。
HandlerMapping:
- 作用:HandlerMapping负责将请求映射到处理器(Controller)。
- 功能:根据请求的URL、请求参数等信息,找到处理请求的 Controller。
- 类型:Spring提供了多种HandlerMapping实现,如BeanNameUrlHandlerMapping、RequestMappingHandlerMapping等。
- 工作流程:根据请求信息确定要请求的处理器(Controller)。HandlerMapping可以根据URL、请求参数等规则确定对应的处理器。
HandlerAdapter:
- 作用:HandlerAdapter负责调用处理器(Controller)来处理请求。
- 功能:处理器(Controller)可能有不同的接口类型(Controller接口、HttpRequestHandler接口等),HandlerAdapter根据处理器的类型来选择合适的方法来调用处理器。
- 类型:Spring提供了多个HandlerAdapter实现,用于适配不同类型的处理器。
- 工作流程:根据处理器的接口类型,选择相应的HandlerAdapter来调用处理器。
工作流程:
- 当客户端发送请求时,HandlerMapping根据请求信息找到对应的处理器(Controller)。
- HandlerAdapter根据处理器的类型选择合适的方法来调用处理器。
- 处理器执行相应的业务逻辑,生成ModelAndView。
- HandlerAdapter将处理器的执行结果包装成ModelAndView。
- 视图解析器根据ModelAndView找到对应的视图进行渲染。
- 将渲染后的视图返回给客户端。
Bean的生命周期与作用域
一个 Bean 的完整生命周期大致可以分为以下几个主要阶段:
- 实例化 (Instantiation)
Spring IoC 容器根据 Bean 定义(如 XML 配置、Java Config 或组件扫描)找到对应的 Bean 类,并使用其构造函数创建 Bean 的一个实例。此时,实例仅仅是一个“裸对象”,其内部的依赖属性尚未被填充。
属性填充 (Populating Properties)
在 Bean 实例被创建之后,Spring 容器会根据 Bean 定义中声明的依赖关系,对 Bean 的属性进行填充。这包括通过 Setter 方法注入依赖(Setter 注入)或通过反射直接设置字段(属性注入)。
初始化 (Initialization)
在所有属性都被填充后,Bean 可能需要执行一些初始化逻辑(如打开文件、建立数据库连接、加载配置等)。Spring 提供了多种方式来定义初始化方法。
- 使用中 (In Use)
Bean 已经被完全初始化并准备就绪,可以从容器中获取并用于业务逻辑。这是 Bean 生命周期中最长的阶段。
- 销毁 (Destruction)
当容器关闭时(例如,Web 应用停止,或者 ApplicationContext
手动关闭),单例 Bean 会被销毁,以释放资源。
生命期介入的扩展方法
经常在初始化阶段通过注解、实现接口或者xml实现某些逻辑.
例如在初始化阶段
你可以介入的扩展点(按执行顺序):
BeanNameAware
接口:setBeanName(String name)
:如果 Bean 实现了此接口,Spring 会将 Bean 的 ID 或名称传递给它。
BeanFactoryAware
接口:setBeanFactory(BeanFactory beanFactory)
:如果 Bean 实现了此接口,Spring 会将创建它的BeanFactory
实例传递给它。
ApplicationContextAware
接口 (如果是 ApplicationContext 容器):setApplicationContext(ApplicationContext applicationContext)
:如果 Bean 实现了此接口,Spring 会将创建它的ApplicationContext
实例传递给它。
BeanPostProcessor
(前置处理):postProcessBeforeInitialization(Object bean, String beanName)
:在任何初始化回调(如@PostConstruct
或InitializingBean.afterPropertiesSet
)之前调用。
@PostConstruct
注解:- 被
@PostConstruct
标记的方法会在依赖注入完成后、所有初始化回调方法之前自动调用。这是最常用的初始化方式。
- 被
InitializingBean
接口:afterPropertiesSet()
:如果 Bean 实现了此接口,在所有属性设置完成之后,此方法会被调用。
- 自定义
init-method
:- 在 Bean 定义中指定一个初始化方法名(如
@Bean(initMethod = "myInitMethod")
或 XML 中的init-method="myInitMethod"
)。
- 在 Bean 定义中指定一个初始化方法名(如
BeanPostProcessor
(后置处理):postProcessAfterInitialization(Object bean, String beanName)
:在所有初始化回调(如@PostConstruct
、afterPropertiesSet
、init-method
)之后调用。AOP 代理通常是在这个阶段创建的。这意味着如果你在这个方法中获取 Bean 的引用,你将得到的是代理对象。
而在销毁阶段,
@PreDestroy
注解:
- 被
@PreDestroy
标记的方法会在 Bean 销毁之前自动调用。常用于资源清理(如关闭数据库连接、文件句柄等)。
DisposableBean
接口:
destroy()
:如果 Bean 实现了此接口,在容器关闭时,此方法会被调用。
自定义 destroy-method
:
- 在 Bean 定义中指定一个销毁方法名(如
@Bean(destroyMethod = "myDestroyMethod")
或 XML 中的destroy-method="myDestroyMethod"
)。
Spring框架中的Bean作用域(Scope)定义了Bean的生命周期和可见性。不同的作用域影响着Spring容器如何管理这些Bean的实例,包括它们如何被创建、如何被销毁以及它们是否可以被多个用户共享。
Spring支持几种不同的作用域,以满足不同的应用场景需求。以下是一些主要的Bean作用域:
- Singleton(单例):在整个应用程序中只存在一个 Bean 实例。默认作用域,Spring 容器中只会创建一个 Bean 实例,并在容器的整个生命周期中共享该实例。
- Prototype(原型):每次请求时都会创建一个新的 Bean 实例。次从容器中获取该 Bean 时都会创建一个新实例,适用于状态非常瞬时的 Bean。
- Request(请求):每个 HTTP 请求都会创建一个新的 Bean 实例。仅在 Spring Web 应用程序中有效,每个 HTTP 请求都会创建一个新的 Bean 实例,适用于 Web 应用中需求局部性的 Bean。
- Session(会话):Session 范围内只会创建一个 Bean 实例。该 Bean 实例在用户会话范围内共享,仅在 Spring Web 应用程序中有效,适用于与用户会话相关的 Bean。
- Application:当前 ServletContext 中只存在一个 Bean 实例。仅在 Spring Web 应用程序中有效,该 Bean 实例在整个 ServletContext 范围内共享,适用于应用程序范围内共享的 Bean。
- WebSocket(Web套接字):在 WebSocket 范围内只存在一个 Bean 实例。仅在支持 WebSocket 的应用程序中有效,该 Bean 实例在 WebSocket 会话范围内共享,适用于 WebSocket 会话范围内共享的 Bean。
- Custom scopes(自定义作用域):Spring 允许开发者定义自定义的作用域,通过实现 Scope 接口来创建新的 Bean 作用域。
Bean的作用域
Spring 中的 Bean 默认都是单例的。就是说,每个Bean的实例只会被创建一次,并且会被存储在Spring容器的缓存中,以便在后续的请求中重复使用。这种单例模式可以提高应用程序的性能和内存效率。
但是,Spring也支持将Bean设置为多例模式,即每次请求都会创建一个新的Bean实例。要将Bean设置为多例模式,可以在Bean定义中通过设置scope属性为”prototype”来实现。
需要注意的是,虽然Spring的默认行为是将Bean设置为单例模式,但在一些情况下,使用多例模式是更为合适的,例如在创建状态不可变的Bean或有状态Bean时。此外,需要注意的是,如果Bean单例是有状态的,那么在使用时需要考虑线程安全性问题
作用域为单例和非单例的bean的声明周期
Spring Bean 的生命周期完全由 IoC 容器控制。Spring 只帮我们管理单例模式 Bean 的完整生命周期,对于 prototype
的 Bean,Spring 在创建好交给使用者之后,则不会再管理后续的生命周期。
具体区别如下:
阶段 | 单例(Singleton) | 非单例(如Prototype) |
---|---|---|
创建时机 | 容器启动时创建(或首次请求时,取决于配置)。 | 每次请求时创建新实例。 |
初始化流程 | 完整执行生命周期流程(属性注入、Aware接口、初始化方法等)。 | 每次创建新实例时都会完整执行生命周期流程(仅到初始化完成)。 |
销毁时机 | 容器关闭时销毁,触发DisposableBean 或destroy-method 。 | 容器不管理销毁,需由调用者自行释放资源(Spring不跟踪实例)。 |
内存占用 | 单实例常驻内存,高效但需注意线程安全。 | 每次请求生成新实例,内存开销较大,需手动管理资源释放。 |
适用场景 | 无状态服务(如Service、DAO层)。 | 有状态对象(如用户会话、临时计算对象)。 |
Bean是线程安全的吗
Spring 框架中的 Bean 是否具备线程安全性,主要取决于它的作用域以及是否包含可变状态。
Bean 线程安全性的影响因素
Spring 默认的 Bean 作用域是 singleton,即在 IoC 容器中只会创建一个实例,并被多个线程共享。如果这个 Bean 维护了可变的成员变量,就可能在并发访问时引发数据不一致的问题,从而导致线程安全风险。
而 prototype 作用域 下,每次获取 Bean 都会创建新的实例,因此不会发生资源竞争,自然也就没有线程安全问题。
单例 Bean 是否一定不安全?
不一定!
无状态 Bean 是线程安全的:例如常见的 Service 或 Dao 层 Bean,它们通常不存储可变数据,仅执行业务逻辑,因此不会受到并发影响。
有状态 Bean 可能会引发线程安全问题:如果 Bean 存储了可变成员变量,比如用户会话信息、计数器等,可能会因多个线程同时访问导致数据不一致。
解决有状态 Bean 的线程安全问题
如果一个单例 Bean 需要维护状态,可通过以下方式确保线程安全:
设计为无状态 Bean:尽量避免定义可变成员变量,或在方法内部使用局部变量。
使用 ThreadLocal:让每个线程拥有独立的变量副本,防止数据共享导致冲突。
同步控制:在访问共享资源时,使用 synchronized 或 ReentrantLock 进行加锁,确保线程互斥访问。
Spring Bean 默认不是线程安全的,因为它们是单例的,所有线程共享同一个实例。要确保 Bean 的线程安全,你需要:
- 优先设计无状态的 Bean。
- 在 Bean 内部使用局部变量。
- 使用线程安全的集合。
- 应用适当的同步机制。
- 考虑使用
ThreadLocal
。 - 或者,更改 Bean 的作用域为
prototype
(但要理解其语义)。
Spring的其他扩展方法
Spring框架提供了许多扩展点,使得开发者可以根据需求定制和扩展Spring的功能。以下是一些常用的扩展点:
- BeanFactoryPostProcessor:允许在Spring容器实例化bean之前修改bean的定义。常用于修改bean属性或改变bean的作用域。
- BeanPostProcessor:可以在bean实例化、配置以及初始化之后对其进行额外处理。常用于代理bean、修改bean属性等。
- PropertySource:用于定义不同的属性源,如文件、数据库等,以便在Spring应用中使用。
- ImportSelector和ImportBeanDefinitionRegistrar:用于根据条件动态注册bean定义,实现配置类的模块化。
- Spring MVC中的HandlerInterceptor:用于拦截处理请求,可以在请求处理前、处理中和处理后执行特定逻辑。
- Spring MVC中的ControllerAdvice:用于全局处理控制器的异常、数据绑定和数据校验。
- Spring Boot的自动配置:通过创建自定义的自动配置类,可以实现对框架和第三方库的自动配置。
- 自定义注解:创建自定义注解,用于实现特定功能或约定,如权限控制、日志记录等。
SpringBoot
- Spring Boot 提供了自动化配置,大大简化了项目的配置过程。通过约定优于配置的原则,很多常用的配置可以自动完成,开发者可以专注于业务逻辑的实现。
- Spring Boot 提供了快速的项目启动器,通过引入不同的 Starter,可以快速集成常用的框架和库(如数据库、消息队列、Web 开发等),极大地提高了开发效率。
- Spring Boot 默认集成了多种内嵌服务器(如Tomcat、Jetty、Undertow),无需额外配置,即可将应用打包成可执行的 JAR 文件,方便部署和运行
SpringBoot自动装配原理
自动装配就是 Spring Boot 根据你项目中引入的 Maven/Gradle 依赖(JAR 包),自动判断你可能需要哪些 Bean,并替你自动创建和配置这些 Bean,把它们注册到 Spring IoC 容器中
SpringBoot 的自动装配原理是基于Spring Framework的条件化配置和@EnableAutoConfiguration注解实现的。这种机制允许开发者在项目中引入相关的依赖,SpringBoot 将根据这些依赖自动配置应用程序的上下文和功能。
SpringBoot 定义了一套接口规范,这套规范规定:SpringBoot 在启动时会扫描外部引用 jar 包中的META-INF/spring.factories文件,将文件中配置的类型信息加载到 Spring 容器(此处涉及到 JVM 类加载机制与 Spring 的容器知识),并执行类中定义的各种操作。对于外部 jar 来说,只需要按照 SpringBoot 定义的标准,就能将自己的功能装置进 SpringBoot。
通俗来讲,自动装配就是通过注解或一些简单的配置就可以在SpringBoot的帮助下开启和配置各种功能,比如数据库访问、Web开发。
自动装配的工作流程总结
- 启动应用: 你的 Spring Boot 应用从
main
方法开始,运行SpringApplication.run()
。 - 找到
@SpringBootApplication
:SpringApplication
会扫描主启动类上的@SpringBootApplication
注解。 - 开启组件扫描:
@ComponentScan
会扫描主启动类所在的包及其子包,发现并注册自定义的 Bean。 - 开启自动装配:
@EnableAutoConfiguration
激活自动装配机制。- 它通过
AutoConfigurationImportSelector
去扫描所有 classpath 下的 JAR 包。 - 查找每个 JAR 包中的
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
文件。 - 读取这些文件中列出的所有自动配置类。
- 它通过
- 按条件加载配置: 对每一个找到的自动配置类,Spring Boot 会根据其上的
@Conditional
注解家族进行条件判断。- 如果条件满足(例如,classpath 中有某个类,或者容器中没有某个 Bean),那么这个自动配置类就会被激活。
- 激活的自动配置类中的
@Bean
方法会被执行,从而创建和配置相关的 Bean,并注册到 Spring IoC 容器中。
- 完成启动: 至此,所有的自动配置和自定义 Bean 都已就绪,应用程序可以开始接收请求和执行业务逻辑。
常用注解
Spring Boot 中一些常用的注解包括:
- @SpringBootApplication:用于标注主应用程序类,标识一个Spring Boot应用程序的入口点,同时启用自动配置和组件扫描。
- @Controller:标识控制器类,处理HTTP请求。
- @RestController:结合@Controller和@ResponseBody,返回RESTful风格的数据。
- @Service:标识服务类,通常用于标记业务逻辑层。
- @Repository:标识数据访问组件,通常用于标记数据访问层。
- @Component:通用的Spring组件注解,表示一个受Spring管理的组件。
- @Autowired:用于自动装配Spring Bean。
- @Value:用于注入配置属性值。
- @RequestMapping:用于映射HTTP请求路径到Controller的处理方法。
- @GetMapping、@PostMapping、@PutMapping、@DeleteMapping:简化@RequestMapping的GET、POST、PUT和DELETE请求。
另外,一个与配置相关的重要注解是:
- @Configuration:用于指定一个类为配置类,其中定义的bean会被Spring容器管理。通常与@Bean配合使用,@Bean用于声明一个Bean实例,由Spring容器进行管理
微服务与SpringCloud
分布式系统是一个广泛的概念,它指的是将一个大型软件系统拆分成多个独立的组件,这些组件部署在不同的计算机(节点)上,并通过网络进行通信和协作,共同完成一个目标。
核心特征:
- 多节点: 系统由多台独立的计算机组成。
- 网络通信: 节点之间通过网络(如 TCP/IP)进行消息传递。
- 协作完成任务: 各个节点协同工作,共同提供一个整体服务或功能。
- 透明性(理想目标): 用户和客户端在理想情况下感觉不到系统是由多个独立部分组成的。
微服务是一种架构风格(Architectural Style),它是分布式系统的一种具体实现方式。微服务将一个单一的应用程序拆分成一组小型、独立、松耦合的服务。每个服务都运行在自己的进程中,并围绕着特定的业务功能进行构建,通过轻量级机制(通常是 HTTP API)进行通信。
核心特征:
- 服务独立性: 每个微服务都是一个独立的、可部署的单元。
- 围绕业务能力构建: 服务边界清晰,专注于解决一个具体的业务问题。
- 松耦合: 服务之间依赖性低,一个服务的变更通常不会直接影响其他服务。
- 独立部署: 各个服务可以独立开发、独立部署、独立扩展。
- 技术异构性: 不同微服务可以使用不同的编程语言、数据库和技术栈。
- 去中心化治理: 服务团队拥有自治权,可以自主选择技术栈和开发流程。
Spring Boot是用于构建单个Spring应用的框架,而Spring Cloud则是用于构建分布式系统中的微服务架构的工具,Spring Cloud提供了服务注册与发现、负载均衡、断路器、网关等功能。
两者可以结合使用,通过Spring Boot构建微服务应用,然后用Spring Cloud来实现微服务架构中的各种功能。
微服务组件
注册中心, 微服务框架核心组件,作用是对新节点进行注册以及状态维护,解决了”如何发现新节点以及检查各节点的运行状态的问题“. 微服务节点在启动时将自己服务的ip,端口等信息在服务中心登记,注册中心会定时检查该节点的运行状态. 注册中心通常会采用心跳机制最大程度保证已登记过的服务节点都是可用的。
负载均衡,负载均衡解决了如何发现服务及负载均衡如何实现的问题」,通常微服务在互相调用时,并不是直接通过IP、端口进行访问调用。而是先通过服务名在注册中心查询该服务拥有哪些节点,注册中心将该服务可用节点列表返回给服务调用者,这个过程叫服务发现,因服务高可用的要求,服务调用者会接收到多个节点,必须要从中进行选择。因此服务调用者一端必须内置负载均衡器,通过负载均衡策略选择合适的节点发起实质性的通信请求。
服务通信,服务通信组件解决了服务间如何进行消息通信的问题,服务间通信采用轻量级协议,通常是HTTP RESTful风格。但因为RESTful风格过于灵活,必须加以约束,通常应用时对其封装。例如在SpringCloud中就提供了Feign和RestTemplate两种技术屏蔽底层的实现细节,所有开发者都是基于封装后统一的SDK进行开发,有利于团队间的相互合作。
配置中心:配置中心主要解决了如何集中管理各节点配置文件的问题,在微服务架构下,所有的微服务节点都包含自己的各种配置文件,如jdbc配置、自定义配置、环境配置、运行参数配置等。要知道有的微服务可能可能有几十个节点,如果将这些配置文件分散存储在节点上,发生配置更改就需要逐个节点调整,将给运维人员带来巨大的压力。配置中心便由此而生,通过部署配置中心服务器,将各节点配置文件从服务中剥离,集中转存到配置中心。一般配置中心都有UI界面,方便实现大规模集群配置调整。
集中式日志管理:集中式日志主要是解决了如何收集各节点日志并统一管理的问题。微服务架构默认将应用日志分别保存在部署节点上,当需要对日志数据和操作数据进行数据分析和数据统计时,必须收集所有节点的日志数据。那么怎么高效收集所有节点的日志数据呢?业内常见的方案有ELK、EFK。通过搭建独立的日志收集系统,定时抓取各节点增量日志形成有效的统计报表,为统计和分析提供数据支撑。
分布式链路追踪:分布式链路追踪解决了如何直观的了解各节点间的调用链路的问题。系统中一个复杂的业务流程,可能会出现连续调用多个微服务,我们需要了解完整的业务逻辑涉及的每个微服务的运行状态,通过可视化链路图展现,可以帮助开发人员快速分析系统瓶颈及出错的服务。
服务保护:服务保护主要是解决了如何对系统进行链路保护,避免服务雪崩的问题。在业务运行时,微服务间互相调用支撑,如果某个微服务出现高延迟导致线程池满载,或是业务处理失败。这里就需要引入服务保护组件来实现高延迟服务的快速降级,避免系统崩溃。
- SpringCloud Alibaba中使用Alibaba Nacos组件实现注册中心,Nacos提供了一组简单易用的特性集,可快速实现动态服务发现、服务配置、服务元数据及流量管理。
- SpringCloud Alibaba 使用Nacos服务端均衡实现负载均衡,与Ribbon在调用端负载不同,Nacos是在服务发现的同时利用负载均衡返回服务节点数据。
- SpringCloud Alibaba 使用Netflix Feign和Alibaba Dubbo组件来实现服务通行,前者与SpringCloud采用了相同的方案,后者则是对自家的RPC 框架Dubbo也给予支持,为服务间通信提供另一种选择。
- SpringCloud Alibaba 在API服务网关组件中,使用与SpringCloud相同的组件,即:SpringCloud Gateway。
- SpringCloud Alibaba在配置中心组件中使用Nacos内置配置中心,Nacos内置的配置中心,可将配置信息存储保存在指定数据库中
- SpringCloud Alibaba在原有的ELK方案外,还可以使用阿里云日志服务(LOG)实现日志集中式管理。
- SpringCloud Alibaba在分布式链路组件中采用与SpringCloud相同的方案,即:Sleuth/Zipkin Server。
- SpringCloud Alibaba使用Alibaba Sentinel实现系统保护,Sentinel不仅功能更强大,实现系统保护比Hystrix更优雅,而且还拥有更好的UI界面。
负载均衡算法
- 简单轮询:将请求按顺序分发给后端服务器上,不关心服务器当前的状态,比如后端服务器的性能、当前的负载。
- 加权轮询:根据服务器自身的性能给服务器设置不同的权重,将请求按顺序和权重分发给后端服务器,可以让性能高的机器处理更多的请求
- 简单随机:将请求随机分发给后端服务器上,请求越多,各个服务器接收到的请求越平均
- 加权随机:根据服务器自身的性能给服务器设置不同的权重,将请求按各个服务器的权重随机分发给后端服务器
- 一致性哈希:根据请求的客户端 ip、或请求参数通过哈希算法得到一个数值,利用该数值取模映射出对应的后端服务器,这样能保证同一个客户端或相同参数的请求每次都使用同一台服务器
- 最小活跃数:统计每台服务器上当前正在处理的请求数,也就是请求活跃数,将请求分发给活跃数最少的后台服务器
可以通过「一致性哈希算法」来实现一直均衡给一个用户,根据请求的客户端 ip、或请求参数通过哈希算法得到一个数值,利用该数值取模映射出对应的后端服务器,这样能保证同一个客户端或相同参数的请求每次都使用同一台服务器。
服务熔断与服务降级
服务熔断是应对微服务雪崩效应的一种链路保护机制,类似股市、保险丝。
比如说,微服务之间的数据交互是通过远程调用来完成的。服务A调用服务,服务B调用服务c,某一时间链路上对服务C的调用响应时间过长或者服务C不可用,随着时间的增长,对服务C的调用也越来越多,然后服务C崩溃了,但是链路调用还在,对服务B的调用也在持续增多,然后服务B崩溃,随之A也崩溃,导致雪崩效应。
服务熔断是应对雪崩效应的一种微服务链路保护机制。例如在高压电路中,如果某个地方的电压过高,熔断器就会熔断,对电路进行保护。同样,在微服务架构中,熔断机制也是起着类似的作用。当调用链路的某个微服务不可用或者响应时间太长时,会进行服务熔断,不再有该节点微服务的调用,快速返回错误的响应信息。当检测到该节点微服务调用响应正常后,恢复调用链路。
所以,服务熔断的作用类似于我们家用的保险丝,当某服务出现不可用或响应超时的情况时,为了防止整个系统出现雪崩,暂时停止对该服务的调用。
在Spring Cloud框架里,熔断机制通过Hystrix实现。Hystrix会监控微服务间调用的状况,当失败的调用到一定阈值,缺省是5秒内20次调用失败,就会启动熔断机制
服务降级一般是指在服务器压力剧增的时候,根据实际业务使用情况以及流量,对一些服务和页面有策略的不处理或者用一种简单的方式进行处理,从而释放服务器资源的资源以保证核心业务的正常高效运行。
服务器的资源是有限的,而请求是无限的。在用户使用即并发高峰期,会影响整体服务的性能,严重的话会导致宕机,以至于某些重要服务不可用。故高峰期为了保证核心功能服务的可用性,就需要对某些服务降级处理。可以理解为舍小保大
服务降级是从整个系统的负荷情况出发和考虑的,对某些负荷会比较高的情况,为了预防某些功能(业务场景)出现负荷过载或者响应慢的情况,在其内部暂时舍弃对一些非核心的接口和数据的请求,而直接返回一个提前准备好的fallback(退路)错误处理信息。这样,虽然提供的是一个有损的服务,但却保证了整个系统的稳定性和可用性。
分布式
分布式理论
CAP 原则又称 CAP 定理, 指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性), 三者不可得兼
一致性(C) : 在分布式系统中的所有数据备份, 在同一时刻是否同样的值(等同于所有节点访问同一份最新的数据副本)
所有客户端在任何时候看到的数据都是一致的。这意味着所有对数据的读操作都应该返回最新写入的数据,或者返回一个错误。在分布式环境中,这通常通过同步数据来实现。
可用性(A): 在集群中一部分节点故障后, 集群整体是否还能响应客户端的读写请求(对数据更新具备高可用性).
系统中的所有非故障节点都能在有限的时间内响应任何请求。这意味着系统总是能处理请求并返回一个(可能是旧的)结果,即使某些节点出现故障。
分区容错性(P): 以实际效果而言, 分区相当于对通信的时限要求. 系统如果不能在时限内达成数据一致性, 就意味着发生了分区的情况, 必须就当前操作在 C 和 A 之间做出选择.
即使网络中出现任意数量的消息丢失或延迟,导致系统被分割成多个不相交的子系统(即“网络分区”),系统仍然能够继续运行。
分布式锁
分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用
可以通过redis实现分布式锁,set key value nx ex threadId
Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来实现分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景。Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」,所以可以用它来实现分布式锁:
- 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
如果 key 存在,则会显示插入失败,可以用来表示加锁失败。
加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁;而Redis单挑指令的执行本身保证了原子性
- 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
- 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端
Zookeeper实现分布式锁
zookeeper是一个为分布式应用提供一致性服务的软件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。
它提供了一个分布式协调服务。可以把它想象成一个高可用的、高性能的、分布式的文件系统(或者说是一个树形结构的数据存储),但它专门为存储和管理分布式应用程序的配置信息、命名服务、提供分布式同步以及组服务而设计。
ZooKeeper 的设计目标是解决分布式系统中的各种协调问题,使得开发者无需从头开始构建复杂的分布式原语。它提供了一组简单的 API,让分布式应用能够在其上构建更高级的功能
ZooKeeper 实现分布式锁通常采用的是排他锁(Exclusive Lock或共享锁(Shared Lock),其核心思想是利用 ZooKeeper 的临时顺序节点(Ephemeral Sequential ZNodes)和监视器特性。
数据模型:
- 永久节点:节点创建后,不会因为会话失效而消失
- 临时节点:与永久节点相反,如果客户端连接失效,则立即删除节点
- 顺序节点:与上述两个节点特性类似,如果指定创建这类节点时,zk会自动在节点名后加一个数字后缀,并且是有序的。
基本原理(排他锁):
- 定义锁路径: 首先在 ZooKeeper 中定义一个持久的根节点,作为所有锁的父节点,例如
/locks
。 - 创建临时顺序节点: 当客户端 A 想要获取锁时,它会在
/locks
目录下创建一个临时的、顺序的子节点。例如,如果它是第一个创建的,可能得到/locks/lock-0000000001
。 - 判断是否获得锁: 客户端 A 创建节点后,会获取
/locks
目录下所有子节点的列表,并检查自己创建的节点是否是所有子节点中序号最小的那个。- 如果是: 客户端 A 成功获得了锁。
- 如果不是: 客户端 A 并没有获得锁。它会找到比自己节点序号小一级的那个节点(也就是紧邻在它前面的那个节点),并在这个前一个节点上设置一个 Watch 监听器。
- 等待通知(Watch 机制):
- 如果客户端 A 没有获得锁,它会进入等待状态。当它监听的前一个节点被删除时(这意味着持有该锁的客户端释放了锁或崩溃了),ZooKeeper 会通过 Watch 机制通知客户端 A。
- 客户端 A 收到通知后,会再次执行步骤 3:获取子节点列表,检查自己是否是最小的。如果是,则获得锁;否则,继续监听它前面新的节点。
- 释放锁: 当客户端 A 完成对共享资源的操作后,它会删除自己创建的那个临时节点(例如
/locks/lock-0000000001
)。由于是临时节点,如果客户端 A 崩溃或会话断开,这个节点也会被自动删除,从而释放锁。
为什么使用临时顺序节点?
- 排他性: 保证了在任何时刻,只有一个客户端持有的临时顺序节点是最小的,从而实现排他性。
- 公平性(避免饥饿): 由于是顺序节点,每个请求锁的客户端都会获得一个唯一的、递增的序号。客户端只需要关注紧邻它前面的那个节点,当那个节点被删除时,它就知道轮到自己了。这保证了所有请求锁的客户端都能按照请求的先后顺序获得锁,避免了饥饿现象。
- 死锁避免: 临时节点的特性是防止死锁的关键。如果持有锁的客户端崩溃了,它的会话会过期,ZooKeeper 会自动删除它创建的临时节点,从而释放锁,避免了因客户端崩溃导致的死锁问题。
- 高性能 Watch: 客户端只需要监听它前面一个节点的删除事件,而不是监听整个父节点的子节点列表变化。这样可以减少 Watch 的触发次数,提高性能。
共享锁的实现(读写锁):
共享锁的实现与排他锁类似,只是在判断是否获得锁的逻辑上有所不同:
- 创建临时顺序节点: 读锁和写锁请求都会在
/locks
目录下创建临时的顺序节点,例如/locks/read-000000000X
或/locks/write-000000000Y
。 - 判断是否获得锁:
- 获取写锁(Write Lock): 必须确保自己创建的节点是所有子节点中序号最小的。如果不是,则监听前面所有比自己序号小的节点(读锁或写锁)。
- 获取读锁(Read Lock): 必须确保在所有比自己节点序号小的节点中,没有写锁节点。如果存在写锁节点,则监听最靠近自己的写锁节点。如果没有写锁节点,但存在读锁节点,则可以获得读锁(因为读锁之间不互斥)。
分布式事务
在单体应用时代,一个业务操作通常只涉及一个数据库,通过本地事务(ACID 特性)就能轻松保证数据的原子性、一致性、隔离性和持久性。然而,随着系统向分布式架构(特别是微服务架构)演进,一个完整的业务操作可能需要调用多个独立的服务,而每个服务又可能操作不同的数据库。这时,传统的本地事务就无能为力了,因为它们无法跨越服务的边界。
分布式事务应运而生,它的核心目标是:确保分布式系统中,多个独立服务或数据库的原子性操作能够像一个单一操作一样,要么全部成功,要么全部失败。
方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
---|---|---|---|---|
2PC | 强一致性 | 低 | 中 | 传统数据库、XA协议 |
3PC | 强一致性 | 中低 | 高 | 需减少阻塞的强一致场景 |
TCC | 最终一致性 | 高 | 高 | 高并发业务(支付、库存) |
Saga | 最终一致性 | 中 | 高 | 长事务、跨服务流程 |
消息队列 | 最终一致性 | 高 | 中 | 事件驱动架构 |
本地消息表 | 最终一致性 | 中 | 低 | 异步通知(订单-积分 |
- 两阶段提交协议(2PC):为准备阶段和提交阶段。准备阶段,协调者向参与者发送准备请求,参与者执行事务操作并反馈结果。若所有参与者准备就绪,协调者在提交阶段发送提交请求,参与者执行提交;否则发送回滚请求。实现简单,能保证事务强一致性。存在单点故障,协调者故障会影响事务流程;性能低,多次消息交互增加延迟;资源锁导致资源长时间占用,降低并发性能。适用于对数据一致性要求高、并发度低的场景,如金融系统转账业务。
原理: 2PC 是最经典的分布式事务协议,它将事务的提交过程分为两个阶段:准备阶段 (Prepare Phase) 和 提交阶段 (Commit Phase)。它有一个事务协调者 (Transaction Coordinator) 和多个事务参与者 (Transaction Participant)。
流程:
- 准备阶段 (Vote Request): 协调者通知所有参与者准备提交事务。每个参与者执行事务操作,并锁定所需资源,但不真正提交。如果一切顺利,参与者返回“同意”;如果遇到问题,返回“拒绝”。
- 提交阶段 (Execution Phase): 协调者根据所有参与者的响应做出决定。
- 如果所有参与者都同意,协调者发出“提交”指令,所有参与者执行真正的提交操作并释放资源。
- 如果有任何一个参与者拒绝或超时,协调者发出“回滚”指令,所有参与者回滚之前的操作并释放资源。
优点: 强一致性,保证事务的原子性
- 三阶段提交协议(3PC):在 2PC 基础上,将准备阶段拆分为询问阶段和准备阶段,形成询问、准备和提交三个阶段。询问阶段协调者询问参与者能否执行事务,后续阶段与 2PC 类似。降低参与者阻塞时间,提高并发性能,引入超时机制一定程度解决单点故障问题。无法完全避免数据不一致,极端网络情况下可能出现部分提交部分回滚。用于对并发性能有要求、对数据一致性要求相对较低的场景。
3PC 是在 2PC 基础上进行的改进,引入了“预提交(Pre-Commit)”阶段和超时机制,旨在解决 2PC 的同步阻塞和单点故障问题。
TCC:将业务操作拆分为 Try、Confirm、Cancel 三个阶段。Try 阶段预留业务资源,Confirm 阶段确认资源完成业务操作,Cancel 阶段在失败时释放资源回滚操作。可根据业务场景定制开发,性能较高,减少资源占用时间。开发成本高,需实现三个方法,要处理异常和补偿逻辑,实现复杂度大。适用于对性能要求高、业务逻辑复杂的场景,如电商系统订单处理、库存管理
TCC 是一种业务层面的分布式事务解决方案。它将一个完整的业务逻辑拆分为三个独立的操作:
- Try: 尝试执行,对业务资源做预留(锁定)。确保资源在事务提交前可用。
- Confirm: 确认执行,真正提交业务操作。
- Cancel: 撤销执行,当任何一个 Try 操作失败时,执行补偿操作,释放预留资源或回滚已执行的部分。
Try 阶段: 协调者依次调用所有参与服务的 Try 接口。如果所有 Try 都成功,进入 Confirm 阶段。
Confirm 阶段: 协调者调用所有参与服务的 Confirm 接口,完成实际业务提交。
Cancel 阶段: 如果在 Try 阶段有任何一个服务返回失败,协调者会调用所有已 Try 成功的服务的 Cancel 接口进行补偿。
Saga:将长事务拆分为多个短事务,每个短事务有对应的补偿事务。某个短事务失败,按相反顺序执行补偿事务回滚系统状态。性能较高,短事务可并行执行减少时间,对业务侵入性小,只需实现补偿事务。只能保证最终一致性,部分补偿事务失败可能导致系统状态不一致。适用于业务流程长、对数据一致性要求为最终一致性的场景,如旅游系统订单、航班、酒店预订。
Saga 是一种更宽松的最终一致性模式,它将一个长事务分解为一系列短事务(本地事务)。每个短事务都有一个对应的补偿操作。当任何一个短事务失败时,会触发之前已成功完成的短事务的补偿操作,从而实现回滚。
流程:
- 协调器(Choreography 或 Orchestration): Saga 可以通过事件驱动(无中心协调器)或中心协调器来管理。
- 正向操作序列: 业务操作按顺序调用各个服务,每个服务完成自己的本地事务。
补偿操作序列: 如果某个服务在执行时失败,Saga 会从该失败点开始,逆序调用之前已成功完成的服务的补偿操作,以撤销之前的操作。
可靠消息最终一致性方案:基于消息队列,业务系统执行本地事务时将业务操作封装成消息发至消息队列,下游系统消费消息并执行操作,失败则消息队列重试。实现简单,对业务代码修改小,系统耦合度低,能保证数据最终一致性。消息队列可靠性和性能影响大,可能出现消息丢失或延迟,需处理消息幂等性。适用于对数据一致性要求为最终一致性、系统耦合度低的场景,如电商订单支付、库存扣减。
利用消息队列作为中间件,确保业务操作的可靠传递。
流程(以事务消息为例):
- 本地事务与消息发送: 服务 A 在其本地事务中执行业务操作,同时向消息队列发送一条“事务消息”(处于“待确认”状态)。两者要么同时成功(由消息队列提供的事务消息机制保证),要么同时失败。
- 消息投递: 消息队列将消息投递给服务 B。
- 服务 B 处理: 服务 B 接收到消息,执行自己的本地事务,并向消息队列发送一个确认消息。
- 最终一致: 如果服务 B 处理失败,消息队列会根据配置进行重试。如果最终无法处理,可以通过人工干预或补偿机制来解决。
- 本地消息表:业务与消息存储在同一个数据库,利用本地事务保证一致性,后台任务轮询消息表,通过MQ通知下游服务,下游服务消费成功后确认消息,失败则重试。简单可靠,无外部依赖。消息可能重复消费,需幂等设计。适用场景是异步最终一致性(如订单创建后通知积分服务)。
分布式场景的限流算法
- 滑动窗口限流算法是对固定窗口限流算法的改进,有效解决了窗口切换时可能会产生两倍于阈值流量请求的问题。
- 漏桶限流算法能够对流量起到整流的作用,让随机不稳定的流量以固定的速率流出,但是不能解决流量突发的问题。
- 令牌桶算法作为漏斗算法的一种改进,除了能够起到平滑流量的作用,还允许一定程度的流量突发。
固定窗口限流算法就是对一段固定时间窗口内的请求进行计数,如果请求数超过了阈值,则舍弃该请求;如果没有达到设定的阈值,则接受该请求,且计数加1。当时间窗口结束时,重置计数器为0。
固定窗口限流优点是实现简单,但是会有“流量吐刺”的问题,假设窗口大小为1s,限流大小为100,然后恰好在某个窗口的第999ms来了100个请求,窗口前期没有请求,所以这100个请求都会通过。再恰好,下一个窗口的第1ms有来了100个请求,也全部通过了,那也就是在2ms之内通过了200个请求,而我们设定的阈值是100,通过的请求达到了阈值的两倍,这样可能会给系统造成巨大的负载压力。
改进固定窗口缺陷的方法是采用滑动窗口限流算法,滑动窗口就是将限流窗口内部切分成一些更小的时间片,然后在时间轴上滑动,每次滑动,滑过一个小时间片,就形成一个新的限流窗口,即滑动窗口。然后在这个滑动窗口内执行固定窗口算法即可。
滑动窗口可以避免固定窗口出现的放过两倍请求的问题,因为一个短时间内出现的所有请求必然在一个滑动窗口内,所以一定会被滑动窗口限流。
令牌桶是另一种桶限流算法,模拟一个特定大小的桶,然后向桶中以特定的速度放入令牌(token),请求到达后,必须从桶中取出一个令牌才能继续处理。如果桶中已经没有令牌了,那么当前请求就被限流。如果桶中的令牌放满了,令牌桶也会溢出。
放令牌的动作是持续不断进行的,如果桶中令牌数达到上限,则丢弃令牌,因此桶中可能一直持有大量的可用令牌。此时请求进来可以直接拿到令牌执行。比如设置 qps 为 100,那么限流器初始化完成 1 秒后,桶中就已经有 100 个令牌了,如果此前还没有请求过来,这时突然来了 100 个请求,该限流器可以抵挡瞬时的 100 个请求。由此可见,只有桶中没有令牌时,请求才会进行等待,最终表现的效果即为以一定的速率执行
分布式一致性算法
Raft 和 Paxos 是两种经典的分布式一致性算法,旨在实现多节点状态机的高可靠一致性。两者核心目标相同(保证分布式系统数据一致性),但设计理念和实现方式有区别。
Raft协议
Raft算法由leader节点来处理一致性问题。leader节点接收来自客户端的请求日志数据,然后同步到集群中其它节点进行复制,当日志已经同步到超过半数以上节点的时候,leader节点再通知集群中其它节点哪些日志已经被复制成功,可以提交到raft状态机中执行。
通过以上方式,Raft算法将要解决的一致性问题分为了以下几个子问题。
- leader选举:集群中必须存在一个leader节点。
- 日志复制:leader节点接收来自客户端的请求然后将这些请求序列化成日志数据再同步到集群中其它节点。
- 安全性:如果某个节点已经将一条提交过的数据输入raft状态机执行了,那么其它节点不可能再将相同索引 的另一条日志数据输入到raft状态机中执行。
Paxos协议
Paxos算法的核心思想是将一致性问题分解为多个阶段,每个阶段都有一个专门的协议来处理。Paxos算法的主要组成部分包括提议者(Proposer)、接受者(Acceptor)和投票者(Voter)。
- 提议者:提议者是负责提出一致性问题的节点,它会向接受者发送提议,并等待接受者的回复。
- 接受者:接受者是负责处理提议的节点,它会接收提议者发送的提议,并对提议进行判断。如果接受者认为提议是有效的,它会向投票者发送请求,并等待投票者的回复。
- 投票者:投票者是负责决定提议是否有效的节点,它会接收接受者发送的请求,并对请求进行判断。如果投票者认为请求是有效的,它会向接受者发送投票,表示支持或反对提议。
Paxos算法的流程如下(以Basic Paxos 算法为例子):
- 准备阶段:提议者选择一个提案编号,并向所有接受者发送准备请求。提案编号是一个全局唯一的、单调递增的数字。接受者收到准备请求后,如果提案编号大于它之前接受过的任何提案编号,它会承诺不再接受编号小于该提案编号的提案,并返回它之前接受过的最大编号的提案信息(如果有)。
- 接受阶段:如果提议者收到了超过半数接受者的响应,它会根据这些响应确定要提议的值。如果接受者返回了之前接受过的提案信息,提议者会选择编号最大的提案中的值作为要提议的值;如果没有,提议者可以选择自己的值。提议者向所有接受者发送接受请求,包含提案编号和要提议的值。
学习阶段:当提议者收到超过半数接受者对某个提案的接受响应时,该提案被认为达成共识。学习者通过接受者的通知得知达成共识的值。
Raft 更易于理解和实现,它将共识过程分解为选举和日志复制两个相对独立的子问题,并且对选举超时时间等参数进行了明确的定义和限制,降低了算法的复杂度。
- Paxos 是一种更通用、更基础的共识算法,它的理论性更强,在学术界有广泛的研究和应用。但 Paxos 的实现相对复杂,理解和调试难度较大。