并发、Java与Spring重点概念

分为三类Java基础知识,集合,JVM,多线程并发相关以及Spring,SpringBoot,SpringCloud分布式了解.

操作系统与计算机网络关键知识

进程线程

死锁

TCP/IP

TCP/IP协议被组织成四个概念层,其中有三层对应于ISO参考模型中的相应层。ICP/IP协议族并不包含物理层和数据链路层,因此它不能独立完成整个计算机网络系统的功能,必须与许多其他的协议协同工作。TCP/IP 网络通常是由上到下分成 4 层,分别是应用层,传输层,网络层和网络接口层

  • 应用层 支持 HTTP、SMTP 等最终用户进程
  • 传输层 处理主机到主机的通信(TCP、UDP)
  • 网络层 寻址和路由数据包(IP 协议)
  • 链路层 通过网络的物理电线、电缆或无线信道移动比特

img

序列号:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。

确认应答号:指下一次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题。

控制位:

  • ACK:该位为 1 时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的 SYN 包之外该位必须设置为 1 。
  • RST:该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接。
  • SYN:该位为 1 时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。
  • FIN:该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位为 1 的 TCP 段。

TCP协议主要通过以下几点来保证传输可靠性:连接管理、序列号、确认应答、超时重传、流量控制、拥塞控制。

  • 连接管理:即三次握手和四次挥手。连接管理机制能够建立起可靠的连接,这是保证传输可靠性的前提。
  • 序列号:TCP将每个字节的数据都进行了编号,这就是序列号。序列号的具体作用如下:能够保证可靠性,既能防止数据丢失,又能避免数据重复。能够保证有序性,按照序列号顺序进行数据包还原。能够提高效率,基于序列号可实现多次发送,一次确认。
  • 确认应答:接收方接收数据之后,会回传ACK报文,报文中带有此次确认的序列号,用于告知发送方此次接收数据的情况。在指定时间后,若发送端仍未收到确认应答,就会启动超时重传。
  • 超时重传:超时重传主要有两种场景:数据包丢失:在指定时间后,若发送端仍未收到确认应答,就会启动超时重传,向接收端重新发送数据包。确认包丢失:当接收端收到重复数据(通过序列号进行识别)时将其丢弃,并重新回传ACK报文。
  • 流量控制:接收端处理数据的速度是有限的,如果发送方发送数据的速度过快,就会导致接收端的缓冲区溢出,进而导致丢包。为了避免上述情况的发生,TCP支持根据接收端的处理能力,来决定发送端的发送速度。这就是流量控制。流量控制是通过在TCP报文段首部维护一个滑动窗口来实现的。
  • 拥塞控制:拥塞控制就是当网络拥堵严重时,发送端减少数据发送。拥塞控制是通过发送端维护一个拥塞窗口来实现的。可以得出,发送端的发送速度,受限于滑动窗口和拥塞窗口中的最小值。拥塞控制方法分为:慢开始,拥塞避免、快重传和快恢复

TPC为什么需要三次握手建立连接

三次握手的原因:

  • 三次握手才可以阻止重复历史连接的初始化(主要原因)
  • 三次握手才可以同步双方的初始序列号
  • 三次握手才可以避免资源浪费

三次握手的首要原因是为了防止旧的重复连接初始化造成混乱。

UDP

  • 连接:TCP 是面向连接的传输层协议,传输数据前先要建立连接;UDP 是不需要连接,即刻传输数据。
  • 服务对象:TCP 是一对一的两点服务,即一条连接只有两个端点。UDP 支持一对一、一对多、多对多的交互通信
  • 可靠性:TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按序到达。UDP 是尽最大努力交付,不保证可靠交付数据。但是我们可以基于 UDP 传输协议实现一个可靠的传输协议,比如 QUIC 协议
  • 拥塞控制、流量控制:TCP 有拥塞控制和流量控制机制,保证数据传输的安全性。UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率。
  • 首部开销:TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是 20 个字节,如果使用了「选项」字段则会变长的。UDP 首部只有 8 个字节,并且是固定不变的,开销较小。
  • 传输方式:TCP 是流式传输,没有边界,但保证顺序和可靠。UDP 是一个包一个包的发送,是有边界的,但可能会丢包和乱序

HTTP

HTTP报文部分

HTTP、HTTPS、CDN、DNS、FTP 都是应用层协议

分请求报文和响应报文来说明。

img

请求报文:

  • 请求行:包含请求方法、请求目标(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,而是选择了基于 UDPQUIC 协议。
  • 解决 TCP 队头阻塞:在 TCP 中,如果一个数据包丢失,整个连接的所有数据流都会被阻塞,直到丢失的数据包被重传。而 QUIC 协议基于 UDP,它在应用层实现了类似 TCP 的可靠传输和拥塞控制。这意味着即使某个数据流的数据包丢失,也只会阻塞该数据流本身而不会影响到同一连接上的其他数据流,从而彻底解决了底层协议的队头阻塞问题。
  • 更快的连接建立:QUIC 协议将 TCP 的三次握手和 TLS 的加密握手合并在一起。在大多数情况下,它只需要一次往返(1-RTT)就能建立安全连接,甚至在连接缓存后可以实现 0-RTT,大大减少了连接延迟。
  • 更好的网络切换能力:QUIC 协议通过连接 ID 来识别连接,而不是 IP 地址和端口号。这使得在网络切换时(例如从 Wi-Fi 切换到移动数据),连接可以无缝迁移,而无需重新建立。
特性HTTP/1.1HTTP/2HTTP/3
底层协议TCPTCPUDP (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协议支持的特性。实现断点续传的功能,需要客户端记录下当前的下载进度,并在需要续传的时候通知服务端本次需要下载的内容片段。

一个最简单的断点续传流程如下:

  1. 客户端开始下载一个1024K的文件,服务端发送Accept-Ranges: bytes来告诉客户端,其支持带Range的请求
  2. 假如客户端下载了其中512K时候网络突然断开了,过了一会网络可以了,客户端再下载时候,需要在HTTP头中申明本次需要续传的片段:Range:bytes=512000-这个头通知服务端从文件的512K位置开始传输文件,直到文件内容结束
  3. 服务端收到断点续传请求,从文件的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 服务器

  1. 客户端首先会发出一个 DNS 请求,问 www.server.com 的 IP 是啥,并发给本地 DNS 服务器(也就是客户端的 TCP/IP 设置中填写的 DNS 服务器地址)。
  2. 本地域名服务器收到客户端的请求后,如果缓存里的表格能找到 www.server.com,则它直接返回 IP 地址。如果没有,本地 DNS 会去问它的根域名服务器:“老大, 能告诉我 www.server.com 的 IP 地址吗?” 根域名服务器是最高层次的,它不直接用于域名解析,但能指明一条道路。
  3. 根 DNS 收到来自本地 DNS 的请求后,发现后置是 .com,说:“www.server.com 这个域名归 .com 区域管理”,我给你 .com 顶级域名服务器地址给你,你去问问它吧。”
  4. 本地 DNS 收到顶级域名服务器的地址后,发起请求问“老二, 你能告诉我 www.server.com 的 IP 地址吗?”
  5. 顶级域名服务器说:“我给你负责 www.server.com 区域的权威 DNS 服务器的地址,你去问它应该能问到”。
  6. 本地 DNS 于是转向问权威 DNS 服务器:“老三,www.server.com对应的IP是啥呀?” server.com 的权威 DNS 服务器,它是域名解析结果的原出处。为啥叫权威呢?就是我的域名我做主。
  7. 权威 DNS 服务器查询后将对应的 IP 地址 X.X.X.X 告诉本地 DNS。
  8. 本地 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。

设计模式

单例设计模式

它的核心思想是确保一个类在整个应用程序中只有一个实例,并提供一个全局访问点来获取这个唯一的实例。单例模式主要用于以下场景:

  • 资源共享:当某个对象的创建开销很大,或者该对象需要被频繁访问时,例如数据库连接池、线程池、配置对象等。通过单例模式,可以避免重复创建,节省资源。
  • 全局唯一:当某个类只需要一个实例,且该实例需要被全局共享时,例如日志记录器、缓存、窗口管理器等

在实际开发中,单例模式有多种实现方式,每种方式都有其优缺点。

饿汉式(Eager Initialization)

在类加载时就创建好实例。

1
2
3
4
5
6
7
8
9
10
11
12
public 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
13
public 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
19
public 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
13
public 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
25
public interface Product {
void use();
}

public class ConcreteProductA implements Product {
@Override
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
26
public interface Product {
void use();
}

public class ConcreteProductA implements Product {
@Override
public void use() {
System.out.println("使用产品A");
}
}

public interface Factory {
Product createProduct();
}

public class ConcreteFactoryA implements Factory {
@Override
public Product createProduct() {
return new ConcreteProductA();
}
}

// 使用
Factory factory = new ConcreteFactoryA();
Product product = factory.createProduct();
product.use();

抽象工厂模式(Abstract Factory Pattern)

  • 定义:提供一个接口,用于创建一系列相关或相互依赖的对象,而无需指定它们具体的类。
  • 结构:一个抽象工厂,多个具体工厂,多个抽象产品,多个具体产品。
  • 优点:可以创建一组相关联的对象,方便管理。
  • 缺点:当需要增加新的产品系列时,需要修改抽象工厂接口和所有具体工厂,扩展起来比较复杂。

示例: 假设我们有产品A产品B两个系列,每个系列都有不同的实现。

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
// 抽象产品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 {
@Override
public AbstractProductA createProductA() {
return new ConcreteProductA1();
}
@Override
public AbstractProductB createProductB() {
return new ConcreteProductB1();
}
}

// 使用
AbstractFactory factory = new ConcreteFactory1();
AbstractProductA productA = factory.createProductA();
productA.useA();

生产者消费者设计模式

这个模式包含三个核心角色:

  1. 生产者(Producer):负责生成数据并将其放入共享的缓冲区中。
  2. 消费者(Consumer):负责从缓冲区中取出数据进行处理。
  3. 缓冲区(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
65
import 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
48
import 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)利用反射来发现和运行测试方法,而无需手动指定每个测试用例。

StringBuild以及特征

StingBuilder 是一个可变的字符序列,与 String 不同,StringBuffer 的内容是可以被修改的。它的核心特点是线程安全和高效的字符串操作。

StringBuffer 的4个特点

第一个是它具有可变性,可以在原有对象上直接修改字符串内容,而无需创建新的对象

第二个它是线程安全的,StringBuffer 的所有方法都通过 synchronized 关键字修饰,因此它是线程安全的。 在多线程环境下,多个线程可以同时操作同一个 StringBuffer 对象,而不会引发数据竞争或不一致问题。

第三个是性能相对较好,StringBuffer 内部使用一个可扩容的字符数组来存储数据,当容量不足时会自动扩展。相比于 String 的不可变性(每次修改都会生成新对象),StringBuffer 在频繁修改字符串时性能更高。而相比于非线程安全的 StringBuilder ,性能略低。

第四个是包含丰富的 API,比如:append():追加内容到字符串末尾。 insert():在指定位置插入内容。delete():删除指定范围的内容。 reverse():反转字符串内容。 toString():将 StringBuffer 转换为 String。

HashMap

JUC

保证数据一致性方案

要保证多线程的程序是安全,不要出现数据竞争造成的数据混乱的问题。

Java的线程安全在三个方面体现:

  • 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,在Java中使用了atomic包(这个包提供了一些支持原子操作的类,这些类可以在多线程环境下保证操作的原子性)和synchronized关键字来确保原子性;
  • 可见性:一个线程对主内存的修改可以及时地被其他线程看到,在Java中使用了synchronized和volatile这两个关键字确保可见性;
  • 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,在Java中使用了happens-before原则来确保有序性。
  • 事务管理:使用数据库事务来确保一组数据库操作要么全部成功提交,要么全部失败回滚。通过ACID(原子性、一致性、隔离性、持久性)属性,数据库事务可以保证数据的一致性。
  • 锁机制:使用锁来实现对共享资源的互斥访问。在 Java 中,可以使用 synchronized 关键字、ReentrantLock 或其他锁机制来控制并发访问,从而避免并发操作导致数据不一致。
  • 版本控制:通过乐观锁的方式,在更新数据时记录数据的版本信息,从而避免同时对同一数据进行修改,进而保证数据的一致性。

如何保证多线程安全

  • synchronized关键字:可以使用synchronized关键字来同步代码块或方法,确保同一时刻只有一个线程可以访问这些代码。对象锁是通过synchronized关键字锁定对象的监视器(monitor)来实现的。

  • volatile关键字:volatile关键字用于变量,确保所有线程看到的是该变量的最新值,而不是可能存储在本地寄存器中的副本。

  • Lock接口和ReentrantLock类:java.util.concurrent.locks.Lock接口提供了比synchronized更强大的锁定机制,ReentrantLock是一个实现该接口的例子,提供了更灵活的锁管理和更高的性能。
  • 原子类:Java并发库(java.util.concurrent.atomic)提供了原子类,如AtomicIntegerAtomicLong等,这些类提供了原子操作,可以用于更新基本类型的变量而无需额外的同步。

Java中常用锁

Java中的锁是用于管理多线程并发访问共享资源的关键机制。锁可以确保在任意给定时间内只有一个线程可以访问特定的资源,从而避免数据竞争和不一致性。Java提供了多种锁机制,可以分为以下几类:

  • 内置锁(synchronized):Java中的synchronized关键字是内置锁机制的基础,可以用于方法或代码块。当一个线程进入synchronized代码块或方法时,它会获取关联对象的锁;当线程离开该代码块或方法时,锁会被释放。如果其他线程尝试获取同一个对象的锁,它们将被阻塞,直到锁被释放。其中,syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级的互斥锁。
  • ReentrantLockjava.util.concurrent.locks.ReentrantLock是一个显式的锁类,提供了比synchronized更高级的功能,如可中断的锁等待、定时锁等待、公平锁选项等。ReentrantLock使用lock()unlock()方法来获取和释放锁。其中,公平锁按照线程请求锁的顺序来分配锁,保证了锁分配的公平性,但可能增加锁的等待时间。非公平锁不保证锁分配的顺序,可以减少锁的竞争,提高性能,但可能造成某些线程的饥饿。
  • 读写锁(ReadWriteLock)java.util.concurrent.locks.ReadWriteLock接口定义了一种锁,允许多个读取者同时访问共享资源,但只允许一个写入者。读写锁通常用于读取远多于写入的情况,以提高并发性。
  • 乐观锁和悲观锁:悲观锁(Pessimistic Locking)通常指在访问数据前就锁定资源,假设最坏的情况,即数据很可能被其他线程修改。synchronizedReentrantLock都是悲观锁的例子。乐观锁(Optimistic Locking)通常不锁定资源,而是在更新数据时检查数据是否已被其他线程修改。乐观锁常使用版本号或时间戳来实现。
  • 自旋锁:自旋锁是一种锁机制,线程在等待锁时会持续循环检查锁是否可用,而不是放弃CPU并阻塞。通常可以使用CAS来实现。这在锁等待时间很短的情况下可以提高性能,但过度自旋会浪费CPU资源。

Java中并发工具

Java 中一些常用的并发工具,它们位于 java.util.concurrent 包中,常见的有:

  • CountDownLatch:CountDownLatch 是一个同步辅助类,它允许一个或多个线程等待其他线程完成操作。它使用一个计数器进行初始化,调用 countDown() 方法会使计数器减一,当计数器的值减为 0 时,等待的线程会被唤醒。可以把它想象成一个倒计时器,当倒计时结束(计数器为 0)时,等待的事件就会发生。示例代码:
  • CyclicBarrier:CyclicBarrier 允许一组线程互相等待,直到到达一个公共的屏障点。当所有线程都到达这个屏障点后,它们可以继续执行后续操作,并且这个屏障可以被重置循环使用。与 CountDownLatch 不同,CyclicBarrier 侧重于线程间的相互等待,而不是等待某些操作完成。
  • Semaphore:Semaphore 是一个计数信号量,用于控制同时访问某个共享资源的线程数量。通过 acquire() 方法获取许可,使用 release() 方法释放许可。如果没有许可可用,线程将被阻塞,直到有许可被释放。可以用来限制对某些资源(如数据库连接池、文件操作等)的并发访问量
  • Future 和 Callable:Callable 是一个类似于 Runnable 的接口,但它可以返回结果,并且可以抛出异常。Future 用于表示一个异步计算的结果,可以通过它来获取 Callable 任务的执行结果或取消任务。
  • ConcurrentHashMap:ConcurrentHashMap 是一个线程安全的哈希表,它允许多个线程同时进行读操作,在一定程度上支持并发的修改操作,避免了 HashMap 在多线程环境下需要使用 synchronizedCollections.synchronizedMap() 进行同步的性能问题。

Synchronized与ReentrantLock

synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁:

  • 用法不同:synchronized 可用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用在代码块上。
  • 获取锁和释放锁方式不同:synchronized 会自动加锁和释放锁,当进入 synchronized 修饰的代码块之后会自动加锁,当离开 synchronized 的代码段之后会自动释放锁。而 ReentrantLock 需要手动加锁和释放锁
  • 锁类型不同:synchronized 属于非公平锁,而 ReentrantLock 既可以是公平锁也可以是非公平锁。
  • 响应中断不同:ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中断。
  • 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。

synchronized 核心优化方案主要包含以下 4 个:

  • 锁膨胀:synchronized 从无锁升级到偏向锁,再到轻量级锁,最后到重量级锁的过程,它叫做锁膨胀也叫做锁升级。JDK 1.6 之前,synchronized 是重量级锁,也就是说 synchronized 在释放和获取锁时都会从用户态转换成内核态,而转换的效率是比较低的。但有了锁膨胀机制之后,synchronized 的状态就多了无锁、偏向锁以及轻量级锁了,这时候在进行并发操作时,大部分的场景都不需要用户态到内核态的转换了,这样就大幅的提升了 synchronized 的性能。
  • 锁消除:指的是在某些情况下,JVM 虚拟机如果检测不到某段代码被共享和竞争的可能性,就会将这段代码所属的同步锁消除掉,从而到底提高程序性能的目的。
  • 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
  • 自适应自旋锁:指通过自身循环,尝试获取锁的一种方式,优点在于它避免一些线程的挂起和恢复操作,因为挂起线程和恢复线程都需要从用户态转入内核态,这个过程是比较慢的,所以通过自旋的方式可以一定程度上避免线程挂起和恢复所造成的性能开销

AQS是什么

AQS全称为AbstractQueuedSynchronizer,是Java中的一个抽象类。 AQS是一个用于构建锁、同步器、协作工具类的工具类(框架)。

AQS核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。

CLH:Craig、Landin and Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。

AQS 是一个用于构建锁和同步器的框架,许多同步器如 ReentrantLockSemaphoreCountDownLatch 等都是基于 AQS 构建的。AQS 使用一个 volatile 的整数变量 state 来表示同步状态,通过内置的 FIFO 队列来管理等待线程。它提供了一些基本的操作,如 acquire(获取资源)和 release(释放资源),这些操作会修改 state 的值,并根据 state 的值来判断线程是否可以获取或释放资源。AQS 的 acquire 操作通常会先尝试获取资源,如果失败,线程将被添加到等待队列中,并阻塞等待。release 操作会释放资源,并唤醒等待队列中的线程。

AQS使用一个Volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对State值的修改。

ThreadLocal

ThreadLocal 为每个线程提供独立的变量副本。子线程默认不会继承父线程的 ThreadLocal 变量。 当子线程启动时,它自己的 ThreadLocalMap 是空的,不会包含父线程设置的任何 ThreadLocal 值。

InheritableThreadLocal 当父线程创建子线程时,子线程会默认获取父线程所有 InheritableThreadLocal 变量的副本。 这些副本在子线程创建时被初始化,之后父子线程对各自副本的修改互不影响。

InheritableThreadLocal线程池场景下存在局限性。如果线程池中的线程是复用的,而不是每次都新建的,那么子线程在被复用执行任务时,不会再次执行父线程的数据复制逻辑,从而无法继承新的 InheritableThreadLocal 值。为了解决这个问题,需要手动在任务提交或执行前后进行数据的传递和清理.

类似于 ThreadLocalInheritableThreadLocal 也可能导致内存泄漏。即使子线程继承了父线程的值,当这些值不再需要时,也应该调用 remove() 方法进行清理,尤其是在线程池环境中。

Synchronized

RetreenLock

ConcurrentHashMap

CopyOnWriteArrayList

  • 特点: 线程安全的 ArrayList 实现。在写操作(添加、删除、修改)时,会复制一份底层数组,在新数组上进行修改,完成后再替换旧数组的引用。读操作无需加锁
  • 适用场景: 读操作远多于写操作的场景。写操作的开销较大,因为需要复制整个数组。
  • 注意事项: 迭代器是“弱一致性”的,即迭代器看到的是创建时集合的快照,不反映后续的写操作。

CopyOnWriteArraySet

  • 特点: 线程安全的 HashSet 实现,其内部就是基于 CopyOnWriteArrayList 实现的。
  • 适用场景:CopyOnWriteArrayList,适用于读多写少的场景。

Collections 工具类包装的同步集合

这些集合通过在每个公共方法上添加 synchronized 关键字来实现同步。

  • Collections.synchronizedList(new ArrayList<>()) 线程安全的 List
  • Collections.synchronizedMap(new HashMap<>()) 线程安全的 Map
  • Collections.synchronizedSet(new HashSet<>()) 线程安全的 Set
  • 特点: 简单易用,但同步粒度较粗,每次访问集合都需要获取对象锁。这意味着即使是读操作也会被阻塞。在高并发场景下,性能往往较差,容易成为性能瓶颈。
  • 适用场景: 并发程度较低的场景,或者对性能要求不高、需要快速实现线程安全的场景

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 风险降低。
  • 运行时常量池(Runtime Constant Pool):

    • 它是方法区的一部分(在 Java 8 元空间中,常量池位于元空间内部)。
    • 用于存放编译期生成的各种字面量和符号引用,这些内容在类加载后进入运行时常量池。

    • StringTable (字符串常量池):虽然名字里有“常量池”,但它在 JVM 8 之前和之后的位置有所变化。

    • 当你在 Java 代码中直接使用字符串字面量时(例如 String s = "hello";),JVM 会首先检查字符串常量池中是否已经存在一个内容为 "hello" 的字符串对象。

      • 如果存在,s 就会直接引用池中已有的对象。
      • 如果不存在,JVM 就会在堆中创建一个新的 String 对象,并将其引用放入字符串常量池,然后 s 再引用这个新的对象。 这种机制保证了常量池中每个唯一的字符串字面量只存在一份对应的 String 对象实例

      • Java 7 及之前:位于永久代

      • Java 8 及之后:被挪到了中。

可能抛出异常:

  • OutOfMemoryError:当方法区(元空间)无法满足内存分配需求时。

虚拟机栈,本地方法栈以及程序计数器

  1. 程序计数器(Program Counter Register)
  • 作用: 程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器
  • 特性:
    • 线程私有: 每条 JVM 线程都有自己独立的程序计数器。
    • 唯一不抛出 OutOfMemoryError 的区域: 这是 JVM 规范中唯一一个没有规定任何 OutOfMemoryError 情况的区域。
    • 执行引擎的指示器: 如果线程正在执行的是一个 Java 方法,这个计数器就记录着正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。
  1. Java 虚拟机栈(Java Virtual Machine Stacks)
  • 作用: Java 虚拟机栈是线程私有的,它的生命周期与线程相同。每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。\

虚拟机栈中“动态链接”区域的实际内容是一个指向运行时常量池的引用。它的作用是:

  • 桥梁: 作为当前执行方法与该方法所属类的运行时常量池之间的桥梁。
  • 入口: 为方法提供一个入口,使其在运行时能够根据需要(例如,调用其他方法、访问字段)去运行时常量池中查找并解析相应的符号引用

  • 特性:

    • 线程私有: 每个线程都有独立的 Java 虚拟机栈。
    • 局部变量表: 存储方法参数和方法内部定义的局部变量(基本数据类型和对象引用)。
    • 操作数栈: 存储方法执行过程中需要操作的数据。
    • 栈帧: 随着方法调用而创建,随着方法结束而销毁。
  • 可能抛出异常:
    • StackOverflowError:如果线程请求的栈深度大于虚拟机所允许的深度(例如,无限递归)。
    • OutOfMemoryError:如果 JVM 栈可以动态扩展,并且在扩展时无法申请到足够的内存。
  1. 本地方法栈(Native Method Stacks)
  • 作用: 本地方法栈与 Java 虚拟机栈的作用类似,只不过它服务于 Native 方法(即用 C/C++ 等语言编写的本地方法)。
  • 特性:
    • 线程私有: 与 Java 虚拟机栈一样,也是线程私有的。
  • 可能抛出异常:
    • StackOverflowErrorOutOfMemoryError:与 Java 虚拟机栈类似。

虚拟机栈中存储的内容

在JVM内存模型中,栈(Stack)主要用于管理线程的局部变量和方法调用的上下文,而堆(Heap)则是用于存储所有类的实例和数组。

当我们在栈中讨论“存储”时,实际上指的是存储基本类型的数据(如int, double等)和对象的引用,而不是对象本身。

这里的关键点是,栈中存储的不是对象,而是对象的引用。也就是说,当你在方法中声明一个对象,比如MyObject obj = new MyObject();,这里的obj实际上是一个存储在栈上的引用,指向堆中实际的对象实例。这个引用是一个固定大小的数据(例如在64位系统上是8字节),它指向堆中分配给对象的内存区域。

堆分为哪几个部分

JVM 堆主要分为以下两个大的部分:

  1. 新生代 (Young Generation / Young Space)
  2. 老年代 (Old Generation / Tenured Generation / Old Space)

  3. 新生代 (Young Generation)

  • 特点: 绝大多数新创建的对象都首先在新生代中分配内存。新生代中的对象生命周期通常比较短。
  • 组成: 新生代又进一步划分为三个子区域:
    • Eden 空间 (Eden Space): 这是对象最初被创建和放置的区域。当 new 一个对象时,如果 Eden 空间足够,对象就会在这里分配。
    • Survivor 空间 (Survivor Space): 通常有两个,分别为 From Survivor SpaceTo Survivor Space。这两个空间在同一时间只有一个是活跃的(用作对象存活区),另一个是空的(用作下一次复制的清理区)。它们用于存放那些在 Eden 区经过一次垃圾回收后仍然存活的对象。
  • 垃圾回收(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 参数调整)时,它就会被晋升(晋级)到老年代。
  1. 老年代 (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中,垃圾回收机制会自动回收不再使用的对象,但如果有对象仍被不再使用的引用持有,垃圾收集器无法回收这些内存,最终可能导致程序的内存使用不断增加。

内存泄露常见原因:

  • 静态集合:使用静态数据结构(如HashMapArrayList)存储对象,且未清理。
  • 事件监听:未取消对事件源的监听,导致对象持续被引用。
  • 线程:未停止的线程可能持有对象引用,无法被回收。

内存溢出:内存溢出是指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块中关闭的资源,以确保即使在发生异常的情况下也始终关闭该资源。

类初始化与类加载

img

对象创建过程

一个 new 操作背后包含了 JVM 复杂的内部协作:

  1. 类加载检查: 确保类已准备就绪。
  2. 分配内存: 在堆上为对象实例开辟空间,考虑并发问题。
  3. 初始化零值: 为所有成员变量赋默认值。
  4. 设置对象头: 填充对象的元数据信息。
  5. 执行构造函数: 按照代码逻辑完成对象的初始化。
  6. 返回引用: 将新对象的内存地址交给程序使用。

在Java中创建对象的过程包括以下几个步骤:

  1. 类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。 执行类的 <clinit>() 方法(类构造器方法),这是首次对类进行主动使用时触发的。该方法会执行静态代码块中的内容,并为静态变量赋明确指定的值

    如果类没有被加载、链接和初始化,JVM 会先执行这些必要的类加载过程。

  2. 分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。

  3. 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

  4. 进行必要设置,比如对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

  5. ‘’执行 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动态性的一个重要体现。

这些类加载器之间的关系形成了双亲委派模型,其核心思想是当一个类加载器收到类加载的请求时,首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中

只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

双亲委派模型作用

双亲委派模型工作原理:

  1. 当一个类加载器收到类加载请求时,它并不会立即尝试加载这个类。
  2. 它首先会把这个请求委派给它的父加载器去执行。
  3. 只有当父加载器反馈它无法完成这个加载请求时(即在它的搜索范围内没有找到该类),子加载器才会尝试自己去加载。

双亲委派模型的优点:

  • 避免重复加载: 确保一个类在 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:

  1. Spring 实例化 BeanA (调用构造器,此时 BeanA 的依赖 beanB 尚未设置)。
  2. Spring 将一个 Bean A 的早期引用(一个 ObjectFactory)放入三级缓存。这个引用在将来可以暴露一个尚未完全初始化的 BeanA 实例。
  3. Spring 尝试填充 BeanA 的属性,发现它需要 BeanB
  4. Spring 开始创建 BeanB
  5. Spring 实例化 BeanB
  6. Spring 将一个 BeanB 的早期引用放入三级缓存。
  7. Spring 尝试填充 BeanB 的属性,发现它需要 BeanA
  8. Spring 检查一级缓存(没有 BeanA),检查二级缓存(没有 BeanA)。
  9. Spring 检查三级缓存,发现有 BeanA 的早期引用工厂。它通过这个工厂获取到一个尚未完全初始化(但已实例化)的 BeanA 实例
  10. Spring 将这个早期 BeanA 实例注入到 BeanB 中。此时 BeanB 可以继续完成初始化。
  11. BeanB 初始化完成后,被放入一级缓存。
  12. Spring 回到 BeanA 的初始化过程,将完全初始化的 BeanB 实例注入到 BeanA 中。
  13. 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 注解提供了丰富的属性来控制事务行为:

  1. propagation (事务传播行为): 定义事务方法如何加入到现有事务中
    • REQUIRED (默认): 如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新事务。
    • SUPPORTS 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务方式执行。
    • MANDATORY 如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。
    • REQUIRES_NEW 总是创建一个新事务,并挂起当前存在的事务(如果存在)。
    • NOT_SUPPORTED 以非事务方式执行操作,并挂起当前存在的事务(如果存在)。
    • NEVER 以非事务方式执行操作;如果当前存在事务,则抛出异常。
    • NESTED 如果当前存在事务,则在嵌套事务中执行。如果当前没有事务,则行为与 REQUIRED 类似。嵌套事务(例如 JDBC savepoints)可以在外部事务回滚时回滚到保存点,但不能独立提交。
  2. isolation (事务隔离级别): 定义多个事务并发执行时,一个事务对另一个事务的影响程度。
    • DEFAULT (默认): 使用底层数据库的默认隔离级别。
    • READ_UNCOMMITTED (读未提交): 最低的隔离级别,允许读取尚未提交的数据。可能导致脏读、不可重复读和幻读。
    • READ_COMMITTED (读已提交): 只能读取已提交的数据。避免脏读,但可能出现不可重复读和幻读。
    • REPEATABLE_READ (可重复读): 保证在同一个事务中多次读取同一数据时,结果总是一致的。避免脏读和不可重复读,但可能出现幻读。
    • SERIALIZABLE (串行化): 最高的隔离级别,完全隔离所有并发事务。避免脏读、不可重复读和幻读,但并发性能最低。
  3. readOnly (只读事务):
    • true:表示该事务只进行读取操作,不修改数据。优化数据库性能(数据库可能会进行一些优化,如不加锁)。
    • false (默认):读写事务。
  4. timeout (事务超时):
    • 定义事务在强制回滚之前可以运行的最大秒数。
    • 默认值为 -1,表示不超时。
  5. rollbackFor / rollbackForClassName
    • 指定哪些异常类型会导致事务回滚。
    • 默认情况下,只有运行时异常 (RuntimeException) 及其子类会导致事务回滚,检查型异常 (Checked Exception) 不会。
  6. noRollbackFor / noRollbackForClassName
    • 指定哪些异常类型不会导致事务回滚。

事务失效场景

Spring Boot通过Spring框架的事务管理模块来支持事务操作。事务管理在Spring Boot中通常是通过 @Transactional 注解来实现的。事务可能会失效的一些常见情况包括:

  1. 未捕获异常: 如果一个事务方法中发生了未捕获的异常,并且异常未被处理或传播到事务边界之外,那么事务会失效,所有的数据库操作会回滚。
  2. 非受检异常: 默认情况下,Spring对非受检异常(RuntimeException或其子类)进行回滚处理,这意味着当事务方法中抛出这些异常时,事务会回滚。
  3. 事务传播属性设置不当: 如果在多个事务之间存在事务嵌套,且事务传播属性配置不正确,可能导致事务失效。特别是在方法内部调用有 @Transactional 注解的方法时要特别注意。
  4. 多数据源的事务管理: 如果在使用多数据源时,事务管理没有正确配置或者存在多个 @Transactional 注解时,可能会导致事务失效。
  5. 跨方法调用事务问题: 如果一个事务方法内部调用另一个方法,而这个被调用的方法没有 @Transactional 注解,这种情况下外层事务可能会失效。
  6. 事务在非公开方法中失效: 如果 @Transactional 注解标注在私有方法上或者非 public 方法上,事务也会失效。

具体来说:

即使使用了 @Transactional 注解,事务也可能因为一些常见的原因而失效:

  1. @Transactional 注解在非 public 方法上:
    • Spring AOP 默认是基于 JDK 动态代理或 CGLIB 代理来实现的,它只对 public 方法有效。如果你在 privateprotected 方法上加 @Transactional,事务将不会生效。
  2. 同一个类中方法互调 (Self-invocation):
    • 如果一个 @Transactional 方法被同一个 Bean 内部的另一个方法调用,而该调用没有经过 Spring 代理,事务也不会生效。
    • 解决方案:
      • 将调用逻辑拆分到另一个服务 Bean 中。
      • 通过 Spring AOP 的 AopContext.currentProxy() 获取当前代理对象进行调用(需要暴露代理)。
      • 注入自身 Bean(如上例中的 self)。
  3. 异常被捕获但未抛出或未标记回滚:
    • 如果事务方法内部抛出了一个异常,但你将其 try-catch 捕获了,并且没有重新抛出(或者抛出的不是 RuntimeException 且未配置 rollbackFor),Spring 就不会知道需要回滚事务。
    • 解决方案: 确保事务方法抛出 RuntimeException 或配置 rollbackFor
  4. 数据库不支持事务:
    • 例如,MySQL 的 MyISAM 存储引擎不支持事务,即使配置了事务,也不会生效。必须使用 InnoDB 等支持事务的存储引擎。
  5. 没有配置事务管理器:
    • Spring 需要 PlatformTransactionManager Bean 才能启用事务功能。
    • 解决方案: 确保你的配置类中有类似 DataSourceTransactionManager 的 Bean。
  6. @Transactional 注解所在的类没有被 Spring 管理:
    • @Transactional 只能作用于 Spring 容器管理的 Bean。
    • 解决方案: 确保类上带有 @Component, @Service, @Repository 等 Spring 注解。

事务方法的自调用与解决方法

Spring的事务,使用this调用是否生效?

不能生效。

因为Spring事务是通过代理对象来控制的,只有通过代理对象的方法调用才会应用事务管理的相关规则。当使用this直接调用时,是绕过了Spring的代理机制,因此不会应用事务设置。

解决方法:

  1. 将调用逻辑拆分到另一个服务 Bean 中。
  2. 通过 Spring AOP 的 AopContext.currentProxy() 获取当前代理对象进行调用(需要暴露代理@EnableAspectJAutoProxy(exposeProxy = true))。
  3. 注入自身 Bean(如上例中的 self)。

SpringMVC的handlermapping和handleradapter

Spring MVC的工作流程如下:

  1. 用户发送请求至前端控制器DispatcherServlet
  2. DispatcherServlet收到请求调用处理器映射器HandlerMapping。
  3. 处理器映射器根据请求url找到具体的处理器,生成处理器执行链HandlerExecutionChain(包括处理器对象和处理器拦截器)一并返回给DispatcherServlet。
  4. DispatcherServlet根据处理器Handler获取处理器适配器HandlerAdapter执行HandlerAdapter处理一系列的操作,如:参数封装,数据格式转换,数据验证等操作
  5. 执行处理器Handler(Controller,也叫页面控制器)。
  6. Handler执行完成返回ModelAndView
  7. HandlerAdapter将Handler执行结果ModelAndView返回到DispatcherServlet
  8. DispatcherServlet将ModelAndView传给ViewReslover视图解析器
  9. ViewReslover解析后返回具体View
  10. DispatcherServlet对View进行渲染视图(即将模型数据model填充至视图中)。
  11. DispatcherServlet响应用户。

HandlerMapping:

  • 作用:HandlerMapping负责将请求映射到处理器(Controller)。
  • 功能:根据请求的URL、请求参数等信息,找到处理请求的 Controller。
  • 类型:Spring提供了多种HandlerMapping实现,如BeanNameUrlHandlerMapping、RequestMappingHandlerMapping等。
  • 工作流程:根据请求信息确定要请求的处理器(Controller)。HandlerMapping可以根据URL、请求参数等规则确定对应的处理器。

HandlerAdapter:

  • 作用:HandlerAdapter负责调用处理器(Controller)来处理请求。
  • 功能:处理器(Controller)可能有不同的接口类型(Controller接口、HttpRequestHandler接口等),HandlerAdapter根据处理器的类型来选择合适的方法来调用处理器。
  • 类型:Spring提供了多个HandlerAdapter实现,用于适配不同类型的处理器。
  • 工作流程:根据处理器的接口类型,选择相应的HandlerAdapter来调用处理器。

工作流程:

  1. 当客户端发送请求时,HandlerMapping根据请求信息找到对应的处理器(Controller)。
  2. HandlerAdapter根据处理器的类型选择合适的方法来调用处理器。
  3. 处理器执行相应的业务逻辑,生成ModelAndView。
  4. HandlerAdapter将处理器的执行结果包装成ModelAndView。
  5. 视图解析器根据ModelAndView找到对应的视图进行渲染。
  6. 将渲染后的视图返回给客户端。

Bean的生命周期与作用域

一个 Bean 的完整生命周期大致可以分为以下几个主要阶段:

  1. 实例化 (Instantiation)

Spring IoC 容器根据 Bean 定义(如 XML 配置、Java Config 或组件扫描)找到对应的 Bean 类,并使用其构造函数创建 Bean 的一个实例。此时,实例仅仅是一个“裸对象”,其内部的依赖属性尚未被填充。

  1. 属性填充 (Populating Properties)

    在 Bean 实例被创建之后,Spring 容器会根据 Bean 定义中声明的依赖关系,对 Bean 的属性进行填充。这包括通过 Setter 方法注入依赖(Setter 注入)或通过反射直接设置字段(属性注入)。

  2. 初始化 (Initialization)

在所有属性都被填充后,Bean 可能需要执行一些初始化逻辑(如打开文件、建立数据库连接、加载配置等)。Spring 提供了多种方式来定义初始化方法。

  1. 使用中 (In Use)

Bean 已经被完全初始化并准备就绪,可以从容器中获取并用于业务逻辑。这是 Bean 生命周期中最长的阶段。

  1. 销毁 (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):在任何初始化回调(如 @PostConstructInitializingBean.afterPropertiesSet之前调用。
  • @PostConstruct 注解:
    • @PostConstruct 标记的方法会在依赖注入完成后、所有初始化回调方法之前自动调用。这是最常用的初始化方式。
  • InitializingBean 接口:
    • afterPropertiesSet():如果 Bean 实现了此接口,在所有属性设置完成之后,此方法会被调用。
  • 自定义 init-method
    • 在 Bean 定义中指定一个初始化方法名(如 @Bean(initMethod = "myInitMethod") 或 XML 中的 init-method="myInitMethod")。
  • BeanPostProcessor (后置处理):
    • postProcessAfterInitialization(Object bean, String beanName):在所有初始化回调(如 @PostConstructafterPropertiesSetinit-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接口、初始化方法等)。每次创建新实例时都会完整执行生命周期流程(仅到初始化完成)。
销毁时机容器关闭时销毁,触发DisposableBeandestroy-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的功能。以下是一些常用的扩展点:

  1. BeanFactoryPostProcessor:允许在Spring容器实例化bean之前修改bean的定义。常用于修改bean属性或改变bean的作用域。
  2. BeanPostProcessor:可以在bean实例化、配置以及初始化之后对其进行额外处理。常用于代理bean、修改bean属性等。
  3. PropertySource:用于定义不同的属性源,如文件、数据库等,以便在Spring应用中使用。
  4. ImportSelector和ImportBeanDefinitionRegistrar:用于根据条件动态注册bean定义,实现配置类的模块化。
  5. Spring MVC中的HandlerInterceptor用于拦截处理请求,可以在请求处理前、处理中和处理后执行特定逻辑
  6. Spring MVC中的ControllerAdvice:用于全局处理控制器的异常、数据绑定和数据校验
  7. Spring Boot的自动配置:通过创建自定义的自动配置类,可以实现对框架和第三方库的自动配置。
  8. 自定义注解:创建自定义注解,用于实现特定功能或约定,如权限控制、日志记录等。

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开发。

自动装配的工作流程总结

  1. 启动应用: 你的 Spring Boot 应用从 main 方法开始,运行 SpringApplication.run()
  2. 找到 @SpringBootApplication SpringApplication 会扫描主启动类上的 @SpringBootApplication 注解。
  3. 开启组件扫描: @ComponentScan 会扫描主启动类所在的包及其子包,发现并注册自定义的 Bean。
  4. 开启自动装配: @EnableAutoConfiguration 激活自动装配机制。
    • 它通过 AutoConfigurationImportSelector 去扫描所有 classpath 下的 JAR 包。
    • 查找每个 JAR 包中的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件。
    • 读取这些文件中列出的所有自动配置类。
  5. 按条件加载配置: 对每一个找到的自动配置类,Spring Boot 会根据其上的 @Conditional 注解家族进行条件判断
    • 如果条件满足(例如,classpath 中有某个类,或者容器中没有某个 Bean),那么这个自动配置类就会被激活。
    • 激活的自动配置类中的 @Bean 方法会被执行,从而创建和配置相关的 Bean,并注册到 Spring IoC 容器中。
  6. 完成启动: 至此,所有的自动配置和自定义 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来实现微服务架构中的各种功能。

微服务组件

img

注册中心, 微服务框架核心组件,作用是对新节点进行注册以及状态维护,解决了”如何发现新节点以及检查各节点的运行状态的问题“. 微服务节点在启动时将自己服务的ip,端口等信息在服务中心登记,注册中心会定时检查该节点的运行状态. 注册中心通常会采用心跳机制最大程度保证已登记过的服务节点都是可用的

负载均衡,负载均衡解决了如何发现服务及负载均衡如何实现的问题」,通常微服务在互相调用时,并不是直接通过IP、端口进行访问调用。而是先通过服务名在注册中心查询该服务拥有哪些节点,注册中心将该服务可用节点列表返回给服务调用者,这个过程叫服务发现,因服务高可用的要求,服务调用者会接收到多个节点,必须要从中进行选择。因此服务调用者一端必须内置负载均衡器,通过负载均衡策略选择合适的节点发起实质性的通信请求。

服务通信,服务通信组件解决了服务间如何进行消息通信的问题,服务间通信采用轻量级协议,通常是HTTP RESTful风格。但因为RESTful风格过于灵活,必须加以约束,通常应用时对其封装。例如在SpringCloud中就提供了Feign和RestTemplate两种技术屏蔽底层的实现细节,所有开发者都是基于封装后统一的SDK进行开发,有利于团队间的相互合作。

配置中心:配置中心主要解决了如何集中管理各节点配置文件的问题,在微服务架构下,所有的微服务节点都包含自己的各种配置文件,如jdbc配置、自定义配置、环境配置、运行参数配置等。要知道有的微服务可能可能有几十个节点,如果将这些配置文件分散存储在节点上,发生配置更改就需要逐个节点调整,将给运维人员带来巨大的压力。配置中心便由此而生,通过部署配置中心服务器,将各节点配置文件从服务中剥离,集中转存到配置中心。一般配置中心都有UI界面,方便实现大规模集群配置调整。

集中式日志管理:集中式日志主要是解决了如何收集各节点日志并统一管理的问题。微服务架构默认将应用日志分别保存在部署节点上,当需要对日志数据和操作数据进行数据分析和数据统计时,必须收集所有节点的日志数据。那么怎么高效收集所有节点的日志数据呢?业内常见的方案有ELK、EFK。通过搭建独立的日志收集系统,定时抓取各节点增量日志形成有效的统计报表,为统计和分析提供数据支撑。

分布式链路追踪:分布式链路追踪解决了如何直观的了解各节点间的调用链路的问题。系统中一个复杂的业务流程,可能会出现连续调用多个微服务,我们需要了解完整的业务逻辑涉及的每个微服务的运行状态,通过可视化链路图展现,可以帮助开发人员快速分析系统瓶颈及出错的服务。

服务保护:服务保护主要是解决了如何对系统进行链路保护,避免服务雪崩的问题。在业务运行时,微服务间互相调用支撑,如果某个微服务出现高延迟导致线程池满载,或是业务处理失败。这里就需要引入服务保护组件来实现高延迟服务的快速降级,避免系统崩溃。

img

  • SpringCloud Alibaba中使用Alibaba Nacos组件实现注册中心,Nacos提供了一组简单易用的特性集,可快速实现动态服务发现、服务配置、服务元数据及流量管理。
  • SpringCloud Alibaba 使用Nacos服务端均衡实现负载均衡,与Ribbon在调用端负载不同,Nacos是在服务发现的同时利用负载均衡返回服务节点数据。
  • SpringCloud Alibaba 使用Netflix FeignAlibaba 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会自动在节点名后加一个数字后缀,并且是有序的。

基本原理(排他锁):

  1. 定义锁路径: 首先在 ZooKeeper 中定义一个持久的根节点,作为所有锁的父节点,例如 /locks
  2. 创建临时顺序节点: 当客户端 A 想要获取锁时,它会在 /locks 目录下创建一个临时的、顺序的子节点。例如,如果它是第一个创建的,可能得到 /locks/lock-0000000001
  3. 判断是否获得锁: 客户端 A 创建节点后,会获取 /locks 目录下所有子节点的列表,并检查自己创建的节点是否是所有子节点中序号最小的那个。
    • 如果是: 客户端 A 成功获得了锁。
    • 如果不是: 客户端 A 并没有获得锁。它会找到比自己节点序号小一级的那个节点(也就是紧邻在它前面的那个节点),并在这个前一个节点上设置一个 Watch 监听器
  4. 等待通知(Watch 机制):
    • 如果客户端 A 没有获得锁,它会进入等待状态。当它监听的前一个节点被删除时(这意味着持有该锁的客户端释放了锁或崩溃了),ZooKeeper 会通过 Watch 机制通知客户端 A。
    • 客户端 A 收到通知后,会再次执行步骤 3:获取子节点列表,检查自己是否是最小的。如果是,则获得锁;否则,继续监听它前面新的节点。
  5. 释放锁: 当客户端 A 完成对共享资源的操作后,它会删除自己创建的那个临时节点(例如 /locks/lock-0000000001)。由于是临时节点,如果客户端 A 崩溃或会话断开,这个节点也会被自动删除,从而释放锁。

为什么使用临时顺序节点?

  • 排他性: 保证了在任何时刻,只有一个客户端持有的临时顺序节点是最小的,从而实现排他性。
  • 公平性(避免饥饿): 由于是顺序节点,每个请求锁的客户端都会获得一个唯一的、递增的序号。客户端只需要关注紧邻它前面的那个节点,当那个节点被删除时,它就知道轮到自己了。这保证了所有请求锁的客户端都能按照请求的先后顺序获得锁,避免了饥饿现象。
  • 死锁避免: 临时节点的特性是防止死锁的关键。如果持有锁的客户端崩溃了,它的会话会过期,ZooKeeper 会自动删除它创建的临时节点,从而释放锁,避免了因客户端崩溃导致的死锁问题。
  • 高性能 Watch: 客户端只需要监听它前面一个节点的删除事件,而不是监听整个父节点的子节点列表变化。这样可以减少 Watch 的触发次数,提高性能。

共享锁的实现(读写锁):

共享锁的实现与排他锁类似,只是在判断是否获得锁的逻辑上有所不同:

  1. 创建临时顺序节点: 读锁和写锁请求都会在 /locks 目录下创建临时的顺序节点,例如 /locks/read-000000000X/locks/write-000000000Y
  2. 判断是否获得锁:
    • 获取写锁(Write Lock): 必须确保自己创建的节点是所有子节点中序号最小的。如果不是,则监听前面所有比自己序号小的节点(读锁或写锁)。
    • 获取读锁(Read Lock): 必须确保在所有比自己节点序号小的节点中,没有写锁节点。如果存在写锁节点,则监听最靠近自己的写锁节点。如果没有写锁节点,但存在读锁节点,则可以获得读锁(因为读锁之间不互斥)。

分布式事务

在单体应用时代,一个业务操作通常只涉及一个数据库,通过本地事务(ACID 特性)就能轻松保证数据的原子性、一致性、隔离性和持久性。然而,随着系统向分布式架构(特别是微服务架构)演进,一个完整的业务操作可能需要调用多个独立的服务,而每个服务又可能操作不同的数据库。这时,传统的本地事务就无能为力了,因为它们无法跨越服务的边界。

分布式事务应运而生,它的核心目标是:确保分布式系统中,多个独立服务或数据库的原子性操作能够像一个单一操作一样,要么全部成功,要么全部失败。

方案一致性性能复杂度适用场景
2PC强一致性传统数据库、XA协议
3PC强一致性中低需减少阻塞的强一致场景
TCC最终一致性高并发业务(支付、库存)
Saga最终一致性长事务、跨服务流程
消息队列最终一致性事件驱动架构
本地消息表最终一致性异步通知(订单-积分
  • 两阶段提交协议(2PC):为准备阶段和提交阶段。准备阶段,协调者向参与者发送准备请求,参与者执行事务操作并反馈结果。若所有参与者准备就绪,协调者在提交阶段发送提交请求,参与者执行提交;否则发送回滚请求。实现简单,能保证事务强一致性。存在单点故障,协调者故障会影响事务流程;性能低,多次消息交互增加延迟;资源锁导致资源长时间占用,降低并发性能。适用于对数据一致性要求高、并发度低的场景,如金融系统转账业务。

原理: 2PC 是最经典的分布式事务协议,它将事务的提交过程分为两个阶段:准备阶段 (Prepare Phase)提交阶段 (Commit Phase)。它有一个事务协调者 (Transaction Coordinator) 和多个事务参与者 (Transaction Participant)

流程:

  1. 准备阶段 (Vote Request): 协调者通知所有参与者准备提交事务。每个参与者执行事务操作,并锁定所需资源,但不真正提交。如果一切顺利,参与者返回“同意”;如果遇到问题,返回“拒绝”。
  2. 提交阶段 (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 会从该失败点开始,逆序调用之前已成功完成的服务的补偿操作,以撤销之前的操作。

  • 可靠消息最终一致性方案:基于消息队列,业务系统执行本地事务时将业务操作封装成消息发至消息队列,下游系统消费消息并执行操作,失败则消息队列重试。实现简单,对业务代码修改小,系统耦合度低,能保证数据最终一致性。消息队列可靠性和性能影响大,可能出现消息丢失或延迟,需处理消息幂等性。适用于对数据一致性要求为最终一致性、系统耦合度低的场景,如电商订单支付、库存扣减。

利用消息队列作为中间件,确保业务操作的可靠传递。

流程(以事务消息为例):

  1. 本地事务与消息发送: 服务 A 在其本地事务中执行业务操作,同时向消息队列发送一条“事务消息”(处于“待确认”状态)。两者要么同时成功(由消息队列提供的事务消息机制保证),要么同时失败。
  2. 消息投递: 消息队列将消息投递给服务 B。
  3. 服务 B 处理: 服务 B 接收到消息,执行自己的本地事务,并向消息队列发送一个确认消息。
  4. 最终一致: 如果服务 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 的实现相对复杂,理解和调试难度较大。

参考资料

  1. https://xiaolincoding.com/interview
  2. https://www.pdai.tech/
  3. JavaGuide(Java学习&面试指南) | JavaGuide
  4. 主页 | 二哥的Java进阶之路
-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道