面试题知识库大全
计网篇
1.TCP/IP 网络分层模型
TCP/IP模型由以下 4 层组成:
- 应用层
- 传输层
- 网络层
- 网络接口层
2.udp怎么解决数据包顺序错乱和丢包的问题/怎么保证可靠传输?
UDP(用户数据报协议)是一种无连接的传输层协议,它提供了不可靠的数据报服务。这意味着UDP本身并不保证数据包的顺序或防止数据包丢失。然而,可以在应用层上实现一些机制来解决这些问题。以下是一些常用的方法:
解决数据包顺序错乱的问题
- 序列号机制: 给每个发送的数据包分配一个唯一的序列号。接收端可以根据序列号重新排序接收到的数据包,确保它们按照正确的顺序处理。
- 确认应答(ACK)机制: 发送方为每个数据包设置一个定时器,并等待接收方的确认。如果在定时器超时前没有收到确认,则可以假设数据包已丢失,并重新发送该数据包。接收方在接收到数据包后,会根据序列号发送确认信息给发送方,以告知哪些数据包已经正确接收。
- 滑动窗口协议: 滑动窗口协议允许发送方连续发送多个数据包而不需要为每一个数据包等待确认。接收方可以使用序列号来识别乱序到达的数据包,并通过累积确认告诉发送方哪些数据包已经被成功接收。
解决数据包丢失的问题
- 超时重传: 结合上述的序列号和确认机制,如果发送方在一定时间内未收到确认,则认为数据包丢失并进行重传。
- 冗余编码: 在发送数据时加入一定的冗余信息(如前向纠错码FEC),这样即使某些数据包丢失,接收端也可以利用剩余的数据包和冗余信息恢复原始数据。
- 混合ARQ(自动重传请求): 结合前向纠错(FEC)与传统的ARQ技术,允许接收方在检测到丢失或错误时尝试自行纠正错误,同时对于无法纠正的情况请求重传。
这些方法都需要在应用程序层面实现,因为UDP本身并没有提供这样的功能。实际上,许多实时通信系统,比如VoIP和视频流媒体服务,都会在其协议栈中包含类似的机制来应对UDP的不可靠性。
3.TCP和UDP的区别?
TCP:传输控制协议,UDP:用户数据报协议。
区别:
1.TCP面向连接,即使用TCP通信双方传输前要三次握手来简历TCP连接;UDP是无连接的,即发送数据之前不需要建立连接,可以随时发送数据。
2.TCP仅支持单播(即一对一通信);UDP支持单播、多播和广播;
3.TCP提供可靠的服务(即不会出现无码、丢失等传输差错);UDP提供不可靠服务。因此,TCP适用于要求可靠传输且对实时性要求不高的应用,如文件传输和电子邮件;而UDP适合视频会议等实时应用。
4.TCP面向字节流,即把应用报文看成一连串无结构的字节流;UDP是面向报文的,即对应用报文既不合并也不拆分而是保留报文的边界。
5.TCP有拥塞控制;UDP没有拥塞控制,因此网络出现拥塞不会使源主机的发送速率降低(对实时应用很有用,如实时视频会议等)。
6.TCP首部开销20字节;UDP的首部开销小,只有8个字节。
4.TCP 协议是如何保证可靠传输的?
- 数据包校验:目的是检测数据在传输过程中的任何变化,若校验出包有错,则丢弃报文段并且不给出响应,这时 TCP 发送数据端超时后会重发数据;
- 对失序数据包重排序:既然 TCP 报文段作为 IP 数据报来传输,而 IP 数据报的到达可能会失序,因此 TCP 报文段的到达也可能会失序。TCP 将对失序数据进行重新排序,然后才交给应用层;
- 丢弃重复数据:对于重复数据,能够丢弃重复数据;
- 应答机制:当 TCP 收到发自 TCP 连接另一端的数据,它将发送一个确认。这个确认不是立即发送,通常将推迟几分之一秒;
- 超时重传:TCP在发送一个数据之后,就开启一个定时器,若是在这个时间内没有收到发送数据的ACK确认报文,则对该报文进行重传,在达到一定次数还没有成功时放弃并发送一个复位信号。
- 流量控制:TCP 连接的每一方都有固定大小的缓冲空间。TCP 的接收端只允许另一端发送接收端缓冲区所能接纳的数据,这可以防止较快主机致使较慢主机的缓冲区溢出,这就是流量控制。TCP 使用的流量控制协议是可变大小的滑动窗口协议。
- 拥塞控制 : 当网络拥塞时,减少数据的发送。TCP 在发送数据的时候,需要考虑两个因素:一是接收方的接收能力,二是网络的拥塞程度。接收方的接收能力由滑动窗口表示,表示接收方还有多少缓冲区可以用来接收数据。网络的拥塞程度由拥塞窗口表示,它是发送方根据网络状况自己维护的一个值,表示发送方认为可以在网络中传输的数据量。发送方发送数据的大小是滑动窗口和拥塞窗口的最小值,这样可以保证发送方既不会超过接收方的接收能力,也不会造成网络的过度拥塞。
5.TCP怎么实现流量控制(滑动窗口)
TCP 利用滑动窗口实现流量控制。流量控制是为了控制发送方发送速率,保证接收方来得及接收。接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。
以下是TCP滑动窗口流量控制的基本工作原理:
- 窗口大小:在TCP连接建立时,双方会协商一个初始窗口大小。这个窗口大小是以字节为单位的,并且在数据传输过程中可以动态调整。接收方会在每个ACK(确认)报文中包含当前可用窗口大小的信息。
- 累积确认:当接收方收到数据段后,它不会立即对每一个数据段都发送一个确认,而是等待一段时间看是否能接收到后续的数据段,然后一次性确认所有已经正确到达的数据。这种方式提高了效率,减少了网络上的确认报文数量。
- 流量控制:发送方根据接收到的接收方的窗口大小来决定接下来可以发送多少数据。如果接收方处理不过来,它可以减小窗口大小,甚至设置为0来暂停发送方的数据发送。当接收方处理完一些数据后,它可以再次增加窗口大小,允许发送方继续发送数据。
- 选择性确认(SACK):除了基本的累积确认外,TCP还支持选择性确认。这意味着接收方可以告诉发送方哪些特定的数据块已经成功接收,这样发送方就只需要重传那些确实丢失的数据段,而不是从最后一个确认的序列号开始重传所有数据。
- 快速重传与恢复:当发送方检测到数据包丢失时,它不需要等到超时重传计时器到期就可以重传丢失的数据包。这是通过接收到三个重复的ACK来触发的,表明接收方正在等待某个特定的数据包。发送方会立即重传丢失的数据包,并进入快速恢复算法以调整其拥塞窗口大小。
6.TCP 的拥塞控制是怎么实现的?
拥塞控制是为了防止过多的数据注入到网络中让网络过载
为了进行拥塞控制,TCP 发送方要维持一个 拥塞窗口(cwnd) 的状态变量。拥塞控制窗口的大小取决于网络的拥塞程度,并且动态变化。发送方让自己的发送窗口取为拥塞窗口和接收方的接受窗口中较小的一个。
TCP 的拥塞控制采用了四种算法,即 慢开始、 拥塞避免、快重传 和 快恢复。在网络层也可以使路由器采用适当的分组丢弃策略(如主动队列管理 AQM),以减少网络拥塞的发生。
- 慢开始: 慢开始算法的思路是当主机开始发送数据时,如果立即把大量数据字节注入到网络,那么可能会引起网络阻塞,因为现在还不知道网络的符合情况。经验表明,较好的方法是先探测一下,即由小到大逐渐增大发送窗口,也就是由小到大逐渐增大拥塞窗口数值。cwnd 初始值为 1,每经过一个传播轮次,cwnd 加倍。
- 拥塞避免: 拥塞避免算法的思路是让拥塞窗口 cwnd 缓慢增大,即每经过一个往返时间 RTT 就把发送方的 cwnd 加 1.
- 快重传与快恢复: 在 TCP/IP 中,快速重传和恢复(fast retransmit and recovery,FRR)是一种拥塞控制算法,它能快速恢复丢失的数据包。没有 FRR,如果数据包丢失了,TCP 将会使用定时器来要求传输暂停。在暂停的这段时间内,没有新的或复制的数据包被发送。有了 FRR,如果接收机接收到一个不按顺序的数据段,它会立即给发送机发送一个重复确认。如果发送机接收到三个重复确认,它会假定确认件指出的数据段丢失了,并立即重传这些丢失的数据段。有了 FRR,就不会因为重传时要求的暂停被耽误。 当有单独的数据包丢失时,快速重传和恢复(FRR)能最有效地工作。当有多个数据信息包在某一段很短的时间内丢失时,它则不能很有效地工作。
7.说说TCP的三次握手
(序号字段seq:本TCP报文段数据载荷的第一个字节的序号;
确认号字段ack:指出希望受到对方下一个TCP报文段的数据载荷的第一个字节的序号,同时也是对之前收到的数据的确认;
确认标志位ACK:值为1表示确认号字段有效;
同步标志位SYN:SYN=1且ACK=0表示是个TCP连接请求报文段;SYN=1且ACK=1表示同意连接请求报文段)
假设发送端为客户端,接收端为服务端。开始时客户端和服务端的状态都是CLOSED。

- 第一次握手:客户端向服务端发起建立连接请求,客户端会随机生成一个起始序列号x,客户端向服务端发送连接请求报文,其中包含标志位
SYN=1,序列号seq=x。第一次握手前客户端的状态为CLOSE,第一次握手后客户端的状态为SYN-SENT。此时服务端的状态为LISTEN。 - 第二次握手:服务端在收到客户端发来的连接请求报文后,会随机生成一个服务端的起始序列号y,然后给客户端回复确认报文段,其中包括标志位
SYN=1,确认标志位ACK=1(表示这是个TCP连接请求确认报文段),序列号seq=y,确认号ack=x+1。第二次握手前服务端的状态为LISTEN,第二次握手后服务端的状态为SYN-RCVD,此时客户端的状态为SYN-SENT。 - 第三次握手:客户端收到服务端发来的报文后,会再向服务端发送确认报文段,其中包含确认标志位
ACK=1,序列号seq=x+1,确认号ack=y+1。第三次握手前客户端的状态为SYN-SENT,第三次握手后客户端和服务端的状态都为ESTABLISHED。此时连接建立完成。
为什么不是两次握手?
之所以需要第三次握手,主要为了防止已失效的连接请求报文段突然又传输到了服务端,导致产生问题。
- 比如客户端A发出连接请求,可能因为网络阻塞原因,A没有收到确认报文,于是A再重传一次连接请求。
- 然后连接成功,等待数据传输完毕后,就释放了连接。
- A发出的第一个连接请求等到连接释放以后的某个时间才到达服务端B,此时B误认为A又发出一次新的连接请求,于是就向A发出确认报文段。
- 如果不采用三次握手,只要B发出确认,就建立新的连接了,此时A不会响应B的确认且A不发送数据,这时候B处于SYN-RECV状态一直等待A发送数据,浪费资源。
8.TCP的4次挥手

客户端先向其TCP发出连接释放报文段(FIN=1,seq=u),并停止再发送数据,主动关闭TCP连接,进入FIN-WAIT-1(终止等待1)状态,等待服务端的确认。
服务端收到连接释放报文段后即发出确认报文段(ACK=1,ack=u+1,seq=v),服务端进入CLOSE-WAIT(关闭等待)状态,此时的TCP处于半关闭状态,A到B的连接释放。
A收到B的确认后,进入FIN-WAIT-2(终止等待2)状态,等待B发出的连接释放报文段。
B发送完数据,就会发出连接释放报文段(FIN=1,ACK=1,seq=w,ack=u+1),B进入LAST-ACK(最后确认)状态,等待A的确认。
A收到B的连接释放报文段后,对此发出确认报文段(ACK=1,seq=u+1,ack=w+1),A进入TIME-WAIT(时间等待)状态。此时TCP未释放掉,需要经过时间等待计时器设置的时间2MSL(最大报文段生存时间)后,A才进入CLOSED状态。B收到A发出的确认报文段后关闭连接,若没收到A发出的确认报文段,B就会重传连接释放报文段。
第四次挥手为什么客户端要等待2MSL才进入Closed状态?
保证A发送的最后一个ACK报文段能够到达B。这个ACK报文段有可能丢失,B收不到这个确认报文,就会超时重传连接释放报文段,然后A可以在2MSL时间内收到这个重传的连接释放报文段,接着A重传一次确认,重新启动2MSL计时器,最后A和B都进入到CLOSED状态,若A在TIME-WAIT状态不等待一段时间,而是发送完ACK报文段后直接进入Closed状态,则A无法收到B重传的连接释放报文段,然后B就会反复重传连接释放报文段而不会进入Closed状态。
为什么是四次挥手?
在关闭连接时,当Server端收到Client端发出的连接释放报文时,很可能并不会立即关闭SOCKET,即服务端的报文还没有发完,所以Server端先回复一个ACK确认报文,告诉Client端我收到你的连接释放报文了。只有等到Server端所有的报文都发送完了,这时Server端才能发送连接释放报文,之后两边才会真正的断开连接。故需要四次挥手。
9.有很多 TIME-WAIT 状态如何解决
当系统中有大量连接进入 TIME_WAIT 状态时,可以通过多种方法来减轻这种状态带来的负面影响。以下是一些常见的解决方案:
- 优化应用逻辑: 减少短连接:尽可能将短连接转换成长连接。例如,Web 应用可以使用 HTTP/1.1 的 keep-alive 特性,这样客户端和服务器之间可以保持一个长期的连接,多次请求复用同一个连接,减少了连接建立和关闭的次数。批量处理请求:在客户端,可以尝试将多个请求打包成一个较大的请求,从而减少连接建立和关闭的次数。
- 调整内核参数: 增大端口范围:在 Linux 中,可以增加 ephemeral port(临时端口)的范围,这样可以有更多的端口可用于新的连接。通过修改 /proc/sys/net/ipv4/ip_local_port_range 文件来实现这一点。减小 TIME_WAIT 超时时间:可以尝试减小 TIME_WAIT 的超时时间。虽然这可以更快地回收端口,但也增加了旧数据包干扰新连接的风险。可以通过调整 /proc/sys/net/ipv4/tcp_fin_timeout 参数来设置 TIME_WAIT 的超时时间。启用 TIME_WAIT sockets 快速回收:Linux 内核提供了一个选项 net.ipv4.tcp_tw_reuse,当设置为 1 时,可以启用 TIME_WAIT sockets 的快速回收。这可以让服务器更快地复用本地地址,但同样需要注意旧数据包干扰的风险。
- 使用 SO_REUSEADDR 或 SO_REUSEPORT: 服务器可以设置 SO_REUSEADDR 套接字选项来通知内核,如果端口被占用,但 TCP 连接位于 TIME_WAIT 状态时可以重用端口。
10.TCP的粘包和拆包问题及其解决方案
TCP是面向流,没有界限的一串数据。TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。
为什么会产生粘包和拆包呢?
- 要发送的数据小于TCP发送缓冲区的大小,TCP将多次写入缓冲区的数据一次发送出去,将会发生粘包;
- 接收数据端的应用层没有及时读取接收缓冲区中的数据,将发生粘包;
- 要发送的数据大于TCP发送缓冲区剩余空间大小,将会发生拆包;
- 待发送数据大于MSS(最大报文长度),TCP在传输前将进行拆包。即TCP报文长度-TCP头部长度>MSS。
解决方案:
- 发送端将每个数据包封装为固定长度
- 在数据尾部增加特殊字符进行分割
- 将数据分为两部分,一部分是头部,一部分是内容体;其中头部结构大小固定,且有一个字段声明内容体的大小
11.什么是SYN攻击
我们都知道 TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到 一个 SYN 报文,就进入 SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的ACK 应答,久而久之就会占满服务端的 SYN 接收队列(未连接队列),使得服务器不能为正常用户服务。
12.如何唯一确定一个TCP连接呢?
TCP 四元组可以唯一的确定一个连接,四元组包括如下: 源地址 源端口 目的地址 目的端口。
13.IP为什么要分类?
根据IP地址访问终端是通过路由器,路由设备当中有一张路由表,该路由表记录了所有IP地址的位 置,这样就可以进行包的转发了,如果我们不区分网络地址,那么这张路由表当中就要保存有所有IP地 址的方向,这张路由表就会很大,就像下面说的那样:如果不分网络位和主机位,路由器的路由表就是 都是32位的地址,那所有的路由器维护的路由表会很大,转发速度会变慢(因为查询变慢)。而且所有 的路由器都要有全Internet的地址,所有人的路由器都要有足够的性能来存下全网地址。估计建造这样 的Internet成本是现在的几万倍,甚至更高。
有了网络地址,就可以限定拥有相同网络地址的终端都在同一个范围内,那么路由表只需要维护这个 网络地址的方向,就可以找到相应的终端了。
14.种HTTP报文结构
请求报文结构:
- 请求行:由请求方法(GET/POST/PUT)、请求URL(不包括域名 | 、HTTP协议版本组成
- 请求头部 Header:请求头部由关键字/值对组成,每行一对**;主要包含Content-Length标头:实体的长度,Content-Tyep标头:实体的媒体类型
- 空行:一个空行用来分隔首部和内容主体 Body
- 请求体body为空
响应报文结构:
- 状态行:包含http协议版本、状态码和状态描述,最常见的是 200 OK 表示请求成功了
- 响应头部
- 空行:一个空行分隔首部和内容主体
- 响应体body
15.URI 和 URL 的区别是什么?
- URI(Uniform Resource Identifier) 是统一资源标志符,可以唯一标识一个资源。
- URL(Uniform Resource Locator) 是统一资源定位符,可以提供该资源的路径。URL是URI的子集,它是一种具体的 URI,即 URL 可以用来标识一个资源,而且还指明了如何 定位这个资源。
16.HTTP状态码

101 切换请求协议,从 HTTP 切换到 WebSocket
200 请求成功,有响应体


什么是重定向?
重定向(Redirect)是指通过各种方式将网络请求重新定义,使其转向其他位置的过程。这种机制在互联网中广泛应用,例如:
- 网页重定向:当用户访问的URL已经不再使用或内容已迁移至新的URL时,服务器会返回一个重定向状态码(如301永久重定向或302临时重定向),并告知浏览器新的URL,浏览器会自动跳转到新地址。
- 域名重定向:当一个域名被设置为另一个域名的别名时,所有对该域名的请求都会被自动转发到主域名。
- 路由重定向:在网络路由中,数据包的路径选择变化也可以视为一种重定向,即数据包被重新导向至新的路径以到达目的地。
302和304有什么区别?
302和304是网页请求的两个不同的响应状态码。302 (临时移动)表示 服务器目前从不同位置的网 页响应请求,但请求者应继续使用原有位置来进行以后的请求。 304 (未修改)表示 自从上次请求 后,请求的网页未修改过。 服务器返回此响应时,不会返回网页内容。
17.HTTP方法
1.有哪些HTTP方法
| 1 | GET | 请求指定的页面信息,并返回实体主体。 |
|---|---|---|
| 2 | HEAD | 类似于 GET 请求,只不过返回的响应中没有具体的内容,用于获取报头 |
| 3 | POST | 向指定资源提交数据进行处理请求(例如提交表单或者上传文件)。数据被包含在请求体中。POST 请求可能会导致新的资源的建立和/或已有资源的修改。 |
| 4 | PUT | 从客户端向服务器传送的数据取代指定的文档的内容。 |
| 5 | DELETE | 请求服务器删除指定的页面。 |
| 6 | CONNECT | HTTP/1.1 协议中预留给能够将连接改为管道方式的代理服务器。 |
| 7 | OPTIONS | 允许客户端查看服务器的性能。 |
| 8 | TRACE | 回显服务器收到的请求,主要用于测试或诊断。 |
| 9 | PATCH | 是对 PUT 方法的补充,用来对已知资源进行局部更新 。 |
2.POST和GET有哪些区别?各自应用场景?
1.使用场景:GET 用于获取资源,而 POST 用于传输实体主体。
2.参数:GET 和 POST 的请求都能使用额外的参数,但是 GET 的参数是以查询字符串出现在 URL 中,而 POST 的参数存储在实体主体中。
3.安全性:安全的 HTTP 方法不会改变服务器状态,也就是说它只是可读的。GET 方法是安全的,而 POST 却不是,因为 POST 的目的是传送实体主体内容,这个内容可能是用户上传的表单数据,上传成功之后,服务器可能把这个数据存储到数据库中,因此状态也就发生了改变。
4.幂等性:幂等的 HTTP 方法,同样的请求被执行一次与连续执行多次的效果是一样的,服务器的状态也是一样的。换句话说就是,幂等方法不应该具有副作用(统计用途除外)。所有的安全方法也都是幂等的。即GET幂等,POST不幂等。
18.HTTP各版本的比较
1.HTTP/1.0 和 HTTP/1.1 有什么区别?
- 连接方式 : HTTP/1.0 为短连接,HTTP/1.1 支持长连接。
- 状态响应码 : HTTP/1.1 中新加入了大量的状态码,光是错误响应状态码就新增了 24 种。比如说,
100 (Continue)——在请求大资源前的预热请求,206 (Partial Content)——范围请求的标识码,409 (Conflict)——请求与当前资源的规定冲突,410 (Gone)——资源已被永久转移,而且没有任何已知的转发地址。 - 缓存机制 : 在 HTTP/1.0 中主要使用 Header 里的 If-Modified-Since,Expires 来做为缓存判断的标准,HTTP/1.1 则引入了更多的缓存控制策略例如 Entity tag,If-Unmodified-Since, If-Match, If-None-Match 等更多可供选择的缓存头来控制缓存策略。
- 带宽:HTTP/1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP/1.1 则在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。
- Host 头(Host Header)处理 :HTTP/1.1 引入了 Host 头字段,允许在同一 IP 地址上托管多个域名,从而支持虚拟主机的功能。而 HTTP/1.0 没有 Host 头字段,无法实现虚拟主机。
长连接和短连接的区别
HTTP1.0协议不支持长连接,从HTTP1.1协议以后,连接默认都是长连接。
1.什么是长连接和短连接
长连接:客户端与服务端先建立连接,连接建立后不断开,然后再进行报文交易。适用于操作频繁、点对点通讯,如数据库的连接。
短连接:客户端与服务端每进行一次报文收发交易时才进行通讯连接,交易完毕后立即断开连接。此方式常用于一点对多点通讯,如web网站的http服务·。
2.操作步骤区别:
短连接的操作步骤是:建立连接——数据传输——关闭连接…建立连接——数据传输——关闭连接长连接的操作步骤是:建立连接——数据传输…(保持连接)…数据传输——关闭连接
。
3.使用场景区别:
长连接多用于操作频繁,点对点的通讯,而且连接数不能太多情况。每个 TCP 连接都需要三步握手,这需要时间,如果每个操作都是先连接,再操作的话那么处理速度会降低很多,所以每个操作完后都不断开,次处理时直接发送数据包就 OK 了,不用建立 TCP 连接。例如:数据库的连接用长连接, 如果用短连接频繁的通信会造成 socket 错误,而且频繁的 socket 创建也是对资源的浪费。
而像 WEB 网站的 http 服务一般都用短链接,因为长连接对于服务端来说会耗费一定的资源,而像 WEB 网站这么频繁的成千上万甚至上亿客户端的连接用短连接会更省一些资源,如果用长连接,而且同时有成千上万的用户,如果每个用户都占用一个连接的话,那可想而知吧。所以并发量大,但每个用户无需频繁操作情况下需用短连好。
2.HTTP/1.1 和 HTTP/2.0 有什么区别?
HTTP/1.0 和 HTTP/1.1 对比
- 多路复用(Multiplexing):HTTP/2.0 在同一连接上可以同时传输多个请求和响应(可以看作是 HTTP/1.1 中长链接的升级版本),互不干扰。HTTP/1.1 则使用串行方式,每个请求和响应都需要独立的连接,而浏览器为了控制资源会有 6-8 个 TCP 连接的限制。。这使得 HTTP/2.0 在处理多个请求时更加高效,减少了网络延迟和提高了性能。
- 二进制帧(Binary Frames):HTTP/2.0 使用二进制帧进行数据传输,而 HTTP/1.1 则使用文本格式的报文。二进制帧更加紧凑和高效,减少了传输的数据量和带宽消耗。
- 头部压缩(Header Compression):HTTP/1.1 支持
Body压缩,Header不支持压缩。HTTP/2.0 支持对Header压缩,使用了专门为Header压缩而设计的 HPACK 算法,减少了网络开销。 - 服务器推送(Server Push):HTTP/2.0 支持服务器推送,可以在客户端请求一个资源时,将其他相关资源一并推送给客户端,从而减少了客户端的请求次数和延迟。而 HTTP/1.1 需要客户端自己发送请求来获取相关资源。
3.HTTP/2.0 和 HTTP/3.0 有什么区别?
HTTP/2.0 和 HTTP/3.0 对比
- 传输协议:HTTP/2.0 是基于 TCP 协议实现的,HTTP/3.0 新增了 QUIC(Quick UDP Internet Connections) 协议来实现可靠的传输,提供与 TLS/SSL 相当的安全性,具有较低的连接和传输延迟。你可以将 QUIC 看作是 UDP 的升级版本,在其基础上新增了很多功能比如加密、重传等等。HTTP/3.0 之前名为 HTTP-over-QUIC,从这个名字中我们也可以发现,HTTP/3 最大的改造就是使用了 QUIC。
- 连接建立:HTTP/2.0 需要经过经典的 TCP 三次握手过程(由于安全的 HTTPS 连接建立还需要 TLS 握手,共需要大约 3 个 RTT)。由于 QUIC 协议的特性(TLS 1.3,TLS 1.3 除了支持 1 个 RTT 的握手,还支持 0 个 RTT 的握手)连接建立仅需 0-RTT 或者 1-RTT。这意味着 QUIC 在最佳情况下不需要任何的额外往返时间就可以建立新连接。
- 队头阻塞:HTTP/2.0 多请求复用一个 TCP 连接,一旦发生丢包,就会阻塞住所有的 HTTP 请求。由于 QUIC 协议的特性,HTTP/3.0 在一定程度上解决了队头阻塞(Head-of-Line blocking, 简写:HOL blocking)问题,一个连接建立多个不同的数据流,这些数据流之间独立互不影响,某个数据流发生丢包了,其数据流不受影响(本质上是多路复用+轮询)。错误恢复:HTTP/3.0 具有更好的错误恢复机制,当出现丢包、延迟等网络问题时,可以更快地进行恢复和重传。而 HTTP/2.0 则需要依赖于 TCP 的错误恢复机制。安全性:HTTP/2.0 和 HTTP/3.0 在安全性上都有较高的要求,支持加密通信,但在实现上有所不同。HTTP/2.0 使用 TLS 协议进行加密,而 HTTP/3.0 基于 QUIC 协议,包含了内置的加密和身份验证机制,可以提供更强的安全性。
19.在浏览器中输入 URL 地址到显示主页的过程?
- DNS 解析:浏览器查询DNS把域名解析成 IP 地址
- TCP 连接:浏览器获得域名对应的 IP 地址以后,浏览器向服务器请求建立链接,发起三次握手
- 发送 HTTP 请求:TCP 连接建立起来后,浏览器向服务器发送 HTTP 请求
- 服务器处理请求并返回 HTTP 报文:服务器接收到这个请求,并根据路径参数映射到特定的请求处理器进行处理,并将处理结果及相应的视图返回给浏览器;
- 浏览器解析渲染页面
- 断开连接:TCP 四次挥手断开连接
在浏览器中输入 https的URL 地址到显示主页的过程?
当在浏览器中输入一个以https开头的URL地址后,会经过以下一系列过程才会显示主页:
- DNS解析:浏览器首先会对输入的URL进行解析,然后通过DNS(域名系统)将域名解析成对应的IP地址。这一过程确定了将要访问的服务器的具体位置。
- 建立TCP连接:浏览器与服务器之间建立一个TCP连接,这个过程通常被称为TCP三次握手。这个连接确保了数据能够安全地在用户和服务器之间传输。
- SSL/TLS协议:由于是https连接,所以在数据传输前会进行SSL/TLS握手,以确保通信加密和服务器身份验证。这是为了保障用户信息的安全,防止数据在传输过程中被窃取或篡改。
- 发送HTTP请求:一旦安全连接建立,浏览器会向服务器发送一个HTTP请求,请求中包含了想要获取的资源(如HTML页面、图片、脚本文件等)。
- 服务器处理请求:服务器接收到请求后,根据请求的内容进行处理,并返回相应的HTTP响应,这通常包括了请求的网页内容以及可能关联的资源文件。
- 浏览器解析渲染:浏览器接收到从服务器返回的数据后,开始解析这些数据,并将其渲染成用户可见的页面。这包括了解析HTML、CSS以及执行JavaScript代码等。
- 断开连接:页面渲染完成后,浏览器和服务器之间的TCP连接不会立即关闭,而是会在一段时间无活动后通过TCP四次挥手来断开连接。
20.Cookie 和 Session 有什么区别?
Session 的主要作用就是通过服务端记录用户的状态。 典型的场景是购物车,当你要添加商品到购物车的时候,系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了。
Cookie 数据保存在客户端(浏览器端),Session 数据保存在服务器端。相对来说 Session 安全性更高。如果使用 Cookie 的一些敏感信息不要写入 Cookie 中,最好能将 Cookie 信息加密然后使用到的时候再去服务器端解密。
具体区别:
①Cookie可以存储在浏览器或者本地,Session只能存在服务器②session 能够存储任意的 java 对象,cookie 只能存储 String 类型的对象③Session比Cookie更具有安全性(Cookie有安全隐患,通过拦截或本地文件找得到你的cookie后可以进行攻击)④Session占用服务器性能,Session过多,增加服务器压力⑤单个Cookie保存的数据不能超过4K,很多浏览器都限制一个站点最多保存20个Cookie,Session是没有大小限制和服务器的内存大小有关。
21.WebSocket 和 HTTP 有什么区别?
WebSocket 和 HTTP 两者都是基于 TCP 的应用层协议,都可以在网络中传输数据。
下面是二者的主要区别:
- WebSocket 是一种双向实时通信协议,而 HTTP 是一种单向通信协议。并且,HTTP 协议下的通信只能由客户端发起,服务器无法主动通知客户端。
- WebSocket 使用 ws:// 或 wss://(使用 SSL/TLS 加密后的协议,类似于 HTTP 和 HTTPS 的关系) 作为协议前缀,HTTP 使用 http:// 或 https:// 作为协议前缀。
- WebSocket 可以支持扩展,用户可以扩展协议,实现部分自定义的子协议,如支持压缩、加密等。
- WebSocket 通信数据格式比较轻量,用于协议控制的数据包头部相对较小,网络开销小,而 HTTP 通信每次都要携带完整的头部,网络开销较大(HTTP/2.0 使用二进制帧进行数据传输,还支持头部压缩,减少了网络开销)。
22.WebSocket 的工作过程是什么样的?
WebSocket 的工作过程可以分为以下几个步骤:
- 客户端向服务器发送一个 HTTP 请求,请求头中包含
Upgrade: websocket和Sec-WebSocket-Key等字段,表示要求升级协议为 WebSocket; - 服务器收到这个请求后,会进行升级协议的操作,如果支持 WebSocket,它将回复一个 HTTP 101 状态码,响应头中包含 ,
Connection: Upgrade和Sec-WebSocket-Accept: xxx等字段、表示成功升级到 WebSocket 协议。 - 客户端和服务器之间建立了一个 WebSocket 连接,可以进行双向的数据传输。数据以帧(frames)的形式进行传送,WebSocket 的每条消息可能会被切分成多个数据帧(最小单位)。发送端会将消息切割成多个帧发送给接收端,接收端接收消息帧,并将关联的帧重新组装成完整的消息。
- 客户端或服务器可以主动发送一个关闭帧,表示要断开连接。另一方收到后,也会回复一个关闭帧,然后双方关闭 TCP 连接。
23.IP地址编码方式
IP 地址的编址方式经历了三个历史阶段:
- 分类
- 子网划分
- 无分类
分类
IP地址由两部分组成,网络号和主机号。

子网划分通过在主机号字段中拿一部分作为子网号,把两级 IP 地址划分为三级 IP 地址。
IP 地址 ::= {< 网络号 >, < 子网号 >, < 主机号 >}
要使用子网,必须配置子网掩码。一个 B 类地址的默认子网掩码为 255.255.0.0,如果 B 类地址的子网占两个比特,那么子网掩码为 11111111 11111111 11000000 00000000,也就是 255.255.192.0。
注意,外部网络看不到子网的存在。
无分类
无分类编址 CIDR 消除了传统 A 类、B 类和 C 类地址以及划分子网的概念,使用网络前缀和主机号来对 IP 地址进行编码,网络前缀的长度可以根据需要变化。
IP 地址 ::= {< 网络前缀号 >, < 主机号 >}
CIDR 的记法上采用在 IP 地址后面加上网络前缀长度的方法,例如 128.14.35.7/20 表示前 20 位为网络前缀。
CIDR 的地址掩码可以继续称为子网掩码,子网掩码首 1 长度为网络前缀的长度。
一个 CIDR 地址块中有很多地址,一个 CIDR 表示的网络就可以表示原来的很多个网络,并且在路由表中只需要一个路由就可以代替原来的多个路由,减少了路由表项的数量。把这种通过使用网络前缀来减少路由表项的方式称为路由聚合,也称为 构成超网 。
在路由表中的项目由“网络前缀”和“下一跳地址”组成,在查找时可能会得到不止一个匹配结果,应当采用最长前缀匹配来确定应该匹配哪一个。
24.ARP 协议有什么用?
ARP 协议,全称 地址解析协议(Address Resolution Protocol),它解决的是网络层地址和链路层地址之间的转换问题。因为一个 IP 数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但 IP 地址属于逻辑地址,而 MAC 地址才是物理地址,ARP 协议解决了 IP 地址转 MAC 地址的一些问题。
25.5种I/O模型
一个输入操作通常包括两个阶段:
- 等待数据准备好
- 从内核向进程复制数据
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待数据到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
Unix 有五种 I/O 模型:
- BIO(同步阻塞I/O blocking I/O)
- NIO(同步非阻塞I/O noblocking I/O)
- 多路复用I/O(也是同步的,select 和 poll)
- 信号驱动 I/O(SIGIO)
- AIO(异步非阻塞I/O)
1.阻塞/非阻塞和同步/异步
阻塞就是线程发起一个IO操作请求,比如读取数据,当内核数据还没准备就绪的时候,这时请求是即刻返回,还是在这里等待数据的就绪,如果需要等待的话就是阻塞,反之如果即刻返回就是非阻塞。
在IO模型里面如果请求方从发起IO请求到操作完成的这一段过程中都需要自己参与,那么这种我们就称为同步请求;反之,如果应用发送完IO请求后就不再参与过程了,只需要等待最终操作是否成功结果的通知,那么这就属于异步。
2.BIO、NIO 和 AIO 的区别?
BIO、NIO和AIO是Java编程语言中用于处理输入输出(IO)操作的三种不同的机制,它们分别代表同步阻塞I/O,同步非阻塞I/O和异步非阻塞I/O。
BIO(同步阻塞I/O blocking I/O):线程发起IO请求,不管内核是否准备好IO操作,从发起请求起,线程一直阻塞,直到操作完成。NIO(同步非阻塞I/O noblocking I/O):线程发起IO请求,立即返回;内核在做好IO操作的准备之后,通过调用注册的回调函数通知线程做IO操作,线程开始阻塞,直到操作完成。AIO(异步非阻塞I/O):线程发起IO请求,立即返回;内存做好IO操作的准备之后,做IO操作,直到操作完成或者失败,通过调用注册的回调函数通知线程做IO操作完成或者失败。
- BIO是一个连接一个线程。
- NIO是一个请求一个线程。
- AIO是一个有效请求一个线程
3.多路复用I/O(也是同步的)
IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪, 就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出cpu。多路 是指网络连接,复用指的是同一个线程。
4.信号驱动I/O
应用进程告诉内核:当数据报准备好的时候,给我发送一个信号,对SIGIO信号进行捕捉,并且调用我的信号处理函数来获取数据报。
异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。
26.IO多路复用
IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪, 就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出cpu。多路 是指网络连接,复用指的是同一个线程。
O多路复用模型的思路就是:系统提供了select、poll、epoll函数可以同时监控多个fd(文件描述符)的操作,有了这个函数后,应用线程通过调用select函数就可以同时监控多个fd,一旦某个描述符就绪(一般是读就绪或者写就绪),select函数就会返回可读/可写状态,这时询问线程再去通知想请求IO操作的线程,对应线程此时再发起IO请求去读/写数据。
文件描述符是一个非负整数,用于标识被进程打开的文件,是操作系统为了高效管理这些文件所创建的索引。
1.select和poll、epoll以及3者的区别
select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
- select的时间复杂度O(n)。select() 函数通过轮询方式来监视文件描述符的变化,它仅仅知道有I/O事件发生了,却并不知道是哪那几个流,只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的时间复杂度,同时处理的流越多,轮询时间就越长。缺点:文件描述符数量受限(通常最大为1024),每次调用需重新设置文件描述符集,在文件描述符较多的情况下效率较低,需要遍历整个描述符集合
- poll的时间复杂度O(n)。poll() 函数使用链表来管理文件描述符,解决了select中文件描述符数量限制的问题.
优点:没有文件描述符数量的限制,相比select更灵活,无需每次重新设置文件描述符集。
缺点:虽然改进了select的一些不足,但当文件描述符数量非常多时,性能仍然会受影响,因为仍需遍历文 件描述符数组来查找就绪的描述符。
- epoll的时间复杂度O(1)。epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动的。
2.epoll
epoll允许单个进程监控多个文件描述符(通常是网络套接字),以便在它们准备好进行读取或写入时得到通知。
- 核心组件:epoll使用三个主要系统调用:
epoll_create、epoll_ctl和epoll_wait。epoll_create用于创建epoll实例,epoll_ctl用于注册要监视的文件描述符及其相关事件,而epoll_wait则用于等待并收集发生的事件。
1.epoll执行流程
epoll的执行流程主要包括以下几个步骤:
- 创建epoll实例:通过调用
epoll_create函数,在内核中创建一个epoll实例,并且建立一个内核高速缓存区,用于存储事件信息。这个过程中会建立一个红黑树和一个双向链表,双向链表用于存储准备就绪的事件。 - 注册事件:使用
epoll_ctl函数向epoll实例中添加、修改或删除感兴趣的事件,这些事件通常是与文件描述符相关的,如读、写等。 - 事件就绪:当被监控的文件描述符上的事件发生时(如数据到达、连接建立等),内核会将这些事件加入到epoll的就绪队列中。这个过程是通过中断通知内核完成的。
- 等待事件:通过
epoll_wait函数等待感兴趣的事件发生。这个函数会阻塞直到有事件发生或者达到预设的超时时间。在此期间,如果有多个文件描述符上的事件发生,它们都会被收集起来一并返回给用户空间处理。 - 处理事件:用户空间的程序收到
epoll_wait返回的事件列表后,会遍历列表并针对每个就绪的事件进行处理。 - 释放资源:当不再需要epoll实例时,可以通过
close系统调用来释放相关资源。
总的来说,epoll相比于传统的select和poll机制,能够更高效地处理大量并发连接,因为它只关注那些真正发生了事件的文件描述符,而不是轮询所有的文件描述符。此外,epoll使用内核事件表来管理事件,避免了频繁的用户空间和内核空间之间的数据拷贝,从而提高了I/O效率。
2.epoll的2种触发模式
epoll有两种触发模式——水平触发(LT)和边缘触发(ET)
- 触发条件 LT水平触发:当被监控的文件描述符状态满足某种条件(如可读、可写)时,它将不断给线程发送就绪通知。即使应用程序没有完全处理完这些操作,内核会继续通知这一状态。ET边沿触发:仅在状态发生改变时发送一次就绪通知。例如,当描述符从未就绪变为就绪状态时,epoll会通知一次,之后如果未发生状态改变,不会再发送通知。
- 数据处理 LT水平触发:不要求一次性处理完所有数据,内核将继续通知同一事件直到应用显式地处理完毕。ET边沿触发:需要及时且完整地处理数据,否则可能会错过某些事件的通知。
3.怎么用epoll实现多路复用?
以下是使用epoll实现多路复用的基本步骤:
- 创建
epoll实例:使用epoll_create函数创建一个epoll实例,并获取一个文件描述符来引用该实例。 - 注册文件描述符:使用
epoll_ctl函数将需要监听的文件描述符添加到epoll实例中,并指定需要监听的事件类型。 - 等待事件:使用
epoll_wait函数阻塞等待事件发生,当有文件描述符上的事件发生时,epoll_wait会返回触发事件的文件描述符列表。 - 处理事件:根据返回的文件描述符列表,处理相应的I/O事件。
3.select/poll/epoll各自的应用场景
select 应用场景
select 的 timeout 参数精度为微秒,而 poll 和 epoll 为毫秒,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。
select 可移植性更好,几乎被所有主流平台所支持。
poll 应用场景
poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。
epoll 应用场景
只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。
需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。
需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试。
4.IO多路复用适用的场合
IO多路复用适用如下场合:
- 当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用I/O复用。
- 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
- 如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
- 如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
- 如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。
- 与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
java集合
1.Java集合有哪几种?
Java集合类主要由两个接口Collection和Map派生出来的,
一个是 Collection接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。对于Collection 接口,下面又有三个主要的子接口:List、Set 和 Queue(念q)。

哪些是线程安全哪些线程不安全?
**java.util包下的集合类大部分都是线程不安全的,例如我们常用的HashSet、TreeSet、ArrayList、LinkedList、ArrayDeque、HashMap、TreeMap,这些都是线程不安全的集合类,但是它们的优点是性能好。**如果需要使用线程安全的集合类,则可以使用Collections工具类提供的synchronizedXxx()方法,将这些集合类包装成线程安全的集合类。**java.util包下也有线程安全的集合类,例如Vector、Hashtable。这些集合类都是比较古老的API,虽然实现了线程安全,但是性能很差。**所以即便是需要使用线程安全的集合类,也建议将线程不安全的集合类包装成线程安全集合类的方式,而不是直接使用这些古老的API。从Java5开始,Java在java.util.concurrent包下提供了大量支持高效并发访问的集合类,它们既能包装良好的访问性能,有能包装线程安全。这些集合类可以分为两部分,它们的特征如下:
- 以Concurrent开头的集合类: 以Concurrent开头的集合类代表了支持并发访问的集合,它们可以支持多个线程并发写入访问, 这些写入线程的所有操作都是线程安全的,但读取操作不必锁定。以Concurrent开头的集合类采 用了更复杂的算法来保证永远不会锁住整个集合,因此在并发写入时有较好的性能。
- 以CopyOnWrite开头的集合类: 以CopyOnWrite开头的集合类采用复制底层数组的方式来实现写操作。当线程对此类集合执行读 取操作时,线程将会直接读取集合本身,无须加锁与阻塞。当线程对此类集合执行写入操作时,集 合会在底层复制一份新的数组,接下来对新的数组执行写入操作。由于对集合的写入操作都是对数 组的副本执行操作,因此它是线程安全的。
2.集合的具体实现类
List
ArrayList:Object[]数组。Vector:Object[]数组。LinkedList:双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)。
Map
HashMap:JDK1.8 之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。LinkedHashMap:LinkedHashMap继承自HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。Hashtable:数组+链表组成的,数组是Hashtable的主体,链表则是主要为了解决哈希冲突而存在的。TreeMap:红黑树(自平衡的排序二叉树)。
Set
HashSet(无序,唯一): 基于HashMap实现的,底层采用HashMap来保存元素。LinkedHashSet:LinkedHashSet是HashSet的子类,并且其内部是通过LinkedHashMap来实现的。TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)。
Queue
PriorityQueue:Object[]数组来实现小顶堆。DelayQueue:PriorityQueue。ArrayDeque: 可扩容动态双向数组。
3.说说 List, Set, Queue, Map 四者的区别?
Java中的集合类主要由Collection和Map这两个接口派生而出,其中Collection接口又派生出三个子接口,分别是Set、List、Queue。所有的Java集合类,都是Set、List、Queue、Map这四个接口的实现类,这四个接口将集合分成了四大类,其中
- Set代表无序的,元素不可重复的集合;
- List代表有序的,元素可以重复的集合;
- Queue代表先进先出(FIFO)的队列;
- Map代表具有映射关系(key-value)的集合。
4.为什么要使用集合?
因为在实际开发中,存储的数据类型多种多样且数量不确定。这时,Java 集合就派上用场了。与数组相比,Java 集合提供了更灵活、更有效的方法来存储多个数据对象。Java 集合框架中的各种集合类和接口可以存储不同类型和数量的对象,同时还具有多样化的操作方式。相较于数组,Java 集合的优势在于它们的大小可变、支持泛型、具有内建算法等。总的来说,Java 集合提高了数据的存储和处理灵活性,可以更好地适应现代软件开发中多样化的数据需求,并支持高质量的代码编写。
5.什么是fail fast快速失败机制?
快速失败(fail-fast)是Java集合框架中的一种错误检测机制。
Fail-fast 机制主要用于确保集合在遍历过程中不被修改,从而保证数据的一致性和稳定性。
当多个线程对同一个ArrayList进行操作时,如果一个线程在遍历该集合的过程中,另一个线程同时尝试修改它(例如添加、删除元素),那么遍历线程会抛出ConcurrentModificationException异常。这种机制旨在防止并发修改导致的数据不一致问题。
6.什么是fail safe安全失败机制?
采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。
缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。
7.如何让一个集合不能被修改?
可以采用Collections包下的unmodifiableMap/unmodifiableList/unmodifiableSet方法,通过这个方法返回的集合,是不可以修改的。如果修改的话,会抛出 java.lang.UnsupportedOperationException异常。
List<String> list = new ArrayList<>();
list.add("x");
Collection<String> clist = Collections.unmodifiableCollection(list);
clist.add("y"); // 运行时此行报错
System.out.println(list. size());
对于List/Set/Map集合,Collections包都有相应的支持。
那使用final关键字进行修饰可以实现吗?
答案是不可以。
final关键字修饰的成员变量如果是是引用类型的话,则表示这个引用的地址值是不能改变的,但是这个引用所指向的对象里面的内容还是可以改变的。
而集合类都是引用类型,用final修饰的话,集合里面的内容还是可以修改的。
引用类型有哪些?
在Java中,引用类型主要包括类(Class)、接口(Interface)、数组(Array)以及基于这些结构的其他数据类型如字符串(String)和枚举类型(Enum)。
Java将数据类型主要分为两大类:基本数据类型和引用数据类型。基本数据类型包括byte、short、int、long、float、double、char和boolean,它们直接存储值而非对象的引用。引用类型的变量则存储的是对象在内存中的地址,即引用了对象的位置。
8.List有哪些类?

9.什么是ArrayList?
ArrayList 的底层是动态数组,它的容量能动态增长。在添加大量元素前,应用可以使用ensureCapacity操作增加 ArrayList 实例的容量。ArrayList 继承了 AbstractList ,并实现了 List 接口
10.有哪些线程安全的List?
- Vector Vector是比较古老的API,虽然保证了线程安全,但是由于效率低一般不建议使用。
- Collections.SynchronizedList SynchronizedList是Collections的内部类,Collections提供了synchronizedList方法,可以将一个 线程不安全的List包装成线程安全的List,即SynchronizedList。它比Vector有更好的扩展性和兼 容性,但是它所有的方法都带有同步锁,也不是性能最优的List。
- CopyOnWriteArrayList CopyOnWriteArrayList是Java 1.5在java.util.concurrent包下增加的类,它采用复制底层数组的方 式来实现写操作。当线程对此类集合执行读取操作时,线程将会直接读取集合本身,无须加锁与阻 塞。当线程对此类集合执行写入操作时,集合会在底层复制一份新的数组,接下来对新的数组执行 写入操作。由于对集合的写入操作都是对数组的副本执行操作,因此它是线程安全的。在所有线程 安全的List中,它是性能最优的方案。
11.ArrayList 和 Array(数组)的区别?
ArrayList 内部基于动态数组实现,比 Array(静态数组) 使用起来更加灵活:
ArrayList会根据实际存储的元素动态地扩容或缩容,而Array被创建之后就不能改变它的长度了。ArrayList允许你使用泛型来确保类型安全,Array则不可以。ArrayList中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)。Array可以直接存储基本类型数据,也可以存储对象。ArrayList支持插入、删除、遍历等常见操作,并且提供了丰富的 API 操作方法,比如add()、remove()等。Array只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。ArrayList创建时不需要指定大小,而Array创建时必须指定大小
12.ArrayList 和 Vector 的区别?
ArrayList是List的主要实现类,底层使用Object[]存储,适用于频繁的查找工作,线程不安全 。Vector是List的古老实现类,底层使用Object[]存储,线程安全。- ArrayList在内存不够时扩容为原来的1.5倍,Vector是扩容为原来的2倍。
13.ArrayList 可以添加 null 值吗?
ArrayList 中可以存储任何类型的对象,包括 null 值。
14.Arraylist 与 LinkedList的区别
- ArrayList的实现是基于数组,LinkedList的实现是基于双向链表;
- 对于随机访问ArrayList要优于LinkedList,ArrayList可以根据下标以O(1)时间复杂度对元素进行随 机访问,而LinkedList的每一个元素都依靠地址指针和它后一个元素连接在一起,查找某个元素的 时间复杂度是O(N);
- 对于插入和删除操作,LinkedList要优于ArrayList,因为当元素被添加到LinkedList任意位置的时 候,不需要像ArrayList那样重新计算大小或者是更新索引;
- LinkedList比ArrayList更占内存,因为LinkedList的节点除了存储数据,还存储了两个引用,一个 指向前一个元素,一个指向后一个元素
15.ArrayList扩容原理
ArrayList有三种构造方法,无参构造方法将创建一个空的ArrayList,其内部使用一个默认容量为10的空数组初始化。如果通过指定初始容量来构造ArrayList,那么会创建一个具有该初始容量的数组。第三种构造方法允许传入一个集合,并将其所有元素添加到ArrayList中。
- 无参构造方法扩容过程如下
ArrayList的底层是动态数组,默认第一次插入元素时创建大小为10的数组。当调用add方法添加一个元素时,首先会确保当前ArrayList维护的数组具有存储新元素的能力。如果数组的容量不足以存储新元素,那么就会通过grow方法进行扩容。扩容的方式是将数组的容量扩大到原来的1.5倍,然后将原数组的数据复制到新的数组中。最后,将新元素添加到数组的末尾
16.ArrayList list=new ArrayList(10)中的list扩容几次
在ArrayList的源码中提供了一个带参数的构造方法,这个参数就是指定的集合初始长度,所以给了一个10的参数,就是指定了集合的初始长度是10,这里面并没有扩容。
17.谈谈CopyOnWriteArrayList的原理
CopyOnWriteArrayList是Java并发包里提供的并发类,简单来说它就是一个线程安全且读操作无锁的ArrayList。CopyOnWriteArrayList允许线程并发访问读操作,这个时候是没有加锁限制的,性能较高。正如其名字一样,在写操作时会复制一份新的List,在新的List上完成写操作,然后再将原引用指向新的List。这样就保证了写操作的线程安全。
- 优点:读操作性能很高,因为无需任何同步措施,比较适用于读多写少的并发场景。在遍历传统的 List时,若中途有别的线程对其进行修改,则会抛出ConcurrentModificationException异常。而 CopyOnWriteArrayList由于其"读写分离"的思想,遍历和修改操作分别作用在不同的List容器,所 以在使用迭代器进行遍历时候,也就不会抛出ConcurrentModificationException异常了。
- 缺点:一是内存占用问题,毕竟每次执行写操作都要将原容器拷贝一份,数据量大时,对内存压力 较大,可能会引起频繁GC。二是无法保证实时性,Vector对于读写操作均加锁同步,可以保证读 和写的强一致性。而CopyOnWriteArrayList由于其实现策略的原因,写和读分别作用在新老不同 容器上,在写操作执行过程中,读不会阻塞但读取到的却是老容器的数据。
18.怎么在遍历 ArrayList 时移除一个元素?
foreach删除会导致快速失败问题,可以使用迭代器的 remove() 方法。
Iterator itr = list.iterator();
while(itr.hasNext()) {
if(itr.next().equals("jay") {
itr.remove();
}
}
19.什么是ArrayList的快速失败fail fast机制
ArrayList的快速失败(fail-fast)是Java集合框架中的一种错误检测机制。
当多个线程对同一个ArrayList进行操作时,如果一个线程在遍历该集合的过程中,另一个线程同时尝试修改它(例如添加、删除元素),那么遍历线程会抛出ConcurrentModificationException异常。Fail-fast 机制主要用于确保集合在遍历过程中不被修改,从而保证数据的一致性和稳定性。
20.如何实现数组和List之间的转换
数组转list,可以使用jdk自动的一个工具类Arrars,里面有一个asList方法可以转换为数组
List 转数组,可以直接调用list中的toArray方法、,需要给一个参数,指定数组的类型,需要指定数组的长度。
21.用Arrays.asList转List后,如果修改了数组内容,list受影响吗?List用toArray转数组后,如果修改了List内容,数组受影响吗
Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址
list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响。
22.ArrayList 和 LinkedList 不是线程安全的,你们在项目中是如何解决这个的线程安全问题的?
第一:我们使用这个集合,优先在方法内使用,定义为局部变量,这样的话,就不会出现线程安全问题。
第二:如果非要在成员变量中使用的话,可以使用线程安全的集合来替代
ArrayList可以用CopyOnWriteArrayList
LinkedList 换成ConcurrentLinkedQueue来使用
23.Map有哪些类?

Map接口有很多实现类,其中比较常用的有HashMap、LinkedHashMap、TreeMap、ConcurrentHashMap。
对于不需要排序的场景,优先考虑使用HashMap,因为它是性能最好的Map实现。如果需要保证线程安全,则可以使用ConcurrentHashMap。它的性能好于Hashtable,因为它在put时采用分段锁/CAS的加锁机制,而不是像Hashtable那样,无论是put还是get都做同步处理。对于需要排序的场景,如果需要按插入顺序排序则可以使用LinkedHashMap,如果需要将key按自然顺序排列甚至是自定义顺序排列,则可以选择TreeMap。如果需要保证线程安全,则可以使用Collections工具类将上述实现类包装成线程安全的Map。
TreeMap 基于红黑树实现。
HashMap 1.7基于哈希表实现,1.8基于数组+链表+红黑树。
HashTable 和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程可以同时写入 HashTable 并且不会导致数据不一致。它是遗留类,不应该去使用它。现在可以使用 ConcurrentHashMap 来支持线程安全,并且 ConcurrentHashMap 的效率会更高(1.7 ConcurrentHashMap 引入了分段锁, 1.8 引入了红黑树)。
LinkedHashMap 使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。
24.HashMap 和 Hashtable 的区别
- **线程是否安全:**
HashMap是非线程安全的,Hashtable是线程安全的,因为Hashtable内部的方法基本都经过synchronized修饰。(如果你要保证线程安全的话就使用ConcurrentHashMap吧!); - 效率: 因为线程安全的问题,
HashMap要比Hashtable效率高一点。另外,Hashtable基本被淘汰,不要在代码中使用它; - 对 Null key 和 Null value 的支持:
HashMap可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出NullPointerException。 - 初始容量大小和每次扩充容量大小的不同: ① 创建时如果不指定容量初始值,
Hashtable默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么Hashtable会直接使用你给定的大小,而HashMap会将其扩充为 2 的幂次方大小(HashMap中的tableSizeFor()方法保证,下面给出了源代码)。也就是说HashMap总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。 - 底层数据结构: JDK1.8 以后的
HashMap在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。Hashtable没有这样的机制。
25.HashMap的底层实现(jdk1.7和jdk1.8有区别)
- jdk1.7
JDK7中的HashMap,是基于数组+链表来实现的,它的底层维护一个Entry数组。它会根据计算的hashCode将对应的KV键值对存储到该数组中,一旦发生hashCode冲突,那么就会将该KV键值对放到对应的已有元素的后面, 此时便形成了一个链表式的存储结构。
JDK7中HashMap的实现方案有一个明显的缺点,即当Hash冲突严重时,在桶上形成的链表会变得越来越长,这样在查询时的效率就会越来越低,其时间复杂度为O(N)。
- jdk1.8
JDK8中的HashMap,是基于数组+链表+红黑树来实现的,它的底层维护一个Node数组。当链表长度大于阈值(默认为 8)外加数组长度大于64时时,将链表转化为红黑树,以减少搜索时间。这么做主要是在查询 的时间复杂度上进行优化,链表为O(N),而红黑树一直是O(logN),可以大大的提高查找性能。
1.线性探测法和链地址法
线性探测法是开放地址法的一种,用于处理哈希表中的冲突问题。
线性探测法的核心在于解决哈希冲突时不采用链表,而是通过探测散列表中的下一个位置来寻找空位。具体来说,如果一个关键字通过散列函数计算的地址已经被占用,它会尝试下一个地址,即当前地址加一(也可以考虑为负数或固定步长,视情况而定),直到找到一个空位为止。这种方法在实际操作中简单且易于实现,但当哈希表较为拥挤时,可能会导致很多空闲位置之间出现很多“堆积”,增加了平均查找时间。
链地址法则是将具有相同哈希值的所有元素链接在同一个链表中。
链地址法也被称为拉链法,它采用了不同的策略来解决哈希冲突。在这个方法中,每个哈希到同一个值的元素都会被插入到对应哈希值下的链表中。这意味着如果多个元素的哈希值相同,它们会被放在同一个链表里,以此来区分这些具有相同哈希值的元素。链地址法的优点是可以减少在插入和查找过程中的平均比较次数,因为每次比较的都是同义词节点。然而,这种方法需要额外的空间来存储指针信息。
总之,这两种方法各有利弊,在不同的场景下可能会选择不同的方法来优化性能。线性探测法更适合于哈希冲突较少时使用,而链地址法适合于处理大量冲突的情况。在实际应用中,选择哪种方法取决于具体的需求和场景。
2.红黑树
1.红黑树的介绍
(1)概述
红黑树(Red Black Tree):也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树(Symmetric Binary B-Tree)
(2)红黑树的特质
性质1:节点要么是红色,要么是黑色
性质2:根节点是黑色
性质3:叶子节点都是黑色的空节点
性质4:红黑树中红色节点的子节点都是黑色
性质5:从任一节点到叶子节点的所有路径都包含相同数目的黑色节点
在添加或删除节点的时候,如果不符合这些性质会发生旋转,以达到所有的性质,保证红黑树的平衡
(3)红黑树的复杂度
- 查找: 红黑树也是一棵BST(二叉搜索树)树,查找操作的时间复杂度为:O(log n)
- 添加: 添加先要从根节点开始找到元素添加的位置,时间复杂度O(log n)添加完成后涉及到复杂度为O(1)的旋转调整操作故整体复杂度为:O(log n)
- 删除: 首先从根节点开始找到被删除元素的位置,时间复杂度O(log n)删除完成后涉及到复杂度为O(1)的旋转调整操作故整体复杂度为:O(log n)
2.为什么选红黑树,和二叉搜索树、AVL树(平衡二叉树)有什么区别?
- 二叉搜索树:在二叉搜索树中,左子节点的值小于根节点的值,右子节点的值大于根节点的值。这使得二叉搜索树在查找操作上具有优势。然而**,二叉搜索树可能退化为线性结构,即链**表,当数据插入顺序有序或接近有序时,其查找效率会大大降低,时间复杂度可能达到O(n)。
- AVL树:是一种高度平衡的二叉搜索树,它要求每个节点的左右子树的高度差不超过1。这种严格的平衡条件使得AVL树在查找操作上具有很高的效率,时间复杂度为O(log n)。然而,为了维护这种严格的平衡,AVL树在插入和删除操作时需要进行频繁的旋转调整,这增加了维护成本。因此,AVL树适合用于查找操作频繁但插入和删除操作较少的场景。
- 红黑树:**红黑树是一种近似平衡的二叉搜索树,它通过一系列性质(如节点颜色、黑高)来维护树的平衡。与AVL树相比,红黑树的平衡条件相对宽松,因此在插入和删除操作时的维护成本较低。**虽然红黑树的查找效率略低于AVL树,但其综合性能较好,适用于各种操作(插入、删除和查找)都较频繁的场景。此外,红黑树的高度近似为2log n,在实际应用中表现出良好的性能。
3.在解决 hash 冲突的时候,为什么选择先用链表,再转红黑树?
**因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要,所以元素的插入操作非常高效。所以,当元素个数小于8个的时候,采用链表结构可以保证查询性能。**而当元素个数大于8个的时候并且数组容量大于等于64,会采用红黑树结构。因为红黑树搜索时间复杂度是 O(logn),而链表是 O(n),在n比较大的时候,使用红黑树可以加快查询速度。
4.为什么链表改为红黑树的阈值是 8?
理想情况下使用随机的哈希码,容器中节点分布在 hash 桶中的频率遵循泊松分布,按照泊松分布的计算公式计算出了桶中元素个数和概率的对照表,可以看到链表中元素个数为 8 时的概率已经非常小,再多的就更少了,所以原作者在选择链表元素个数时选择了 8,是根据概率统计而选择的。
5.红黑树会退化为O(n)的查找时间复杂度吗
红黑树理论上不会退化为O(n)的查找时间复杂度。红黑树是一种自平衡二叉查找树,它通过对树中的节点进行着色和旋转维护了特定的性质,从而确保了其查找、插入和删除操作的时间复杂度在最坏情况下仍为O(log n)。
以下是确保红黑树效率的关键要素:
- 着色规则:红黑树中的每一个节点要么是红色,要么是黑色。其中,红色的节点不能相邻,这意味着没有两个红色节点可以成为对方的父子关系。
- 树的平衡:通过旋转操作来保持树的平衡。如果添加或删除节点后破坏了红黑树的性质,可以通过左旋或右旋来调整节点的位置,重新满足红黑树的性质。
- 黑高度相等:从根节点到任何叶子节点的路径上,黑色节点的数量都相等,这个性质保证了最长路径不会超过最短路径的两倍长度。
- 节点插入策略:新插入的节点总是作为红色节点,并遵循一定的规则来保证插入后仍是一棵有效的红黑树,必要时会通过旋转和重新着色来维护树的结构。
- 修复机制:当执行删除操作时,可能会有一些红黑树性质被破坏,但算法提供了修复机制来重新调整树的结构,以恢复其有效性。
然而,尽管红黑树设计得旨在防止性能退化,但在极端情况下(例如,频繁的序列性插入和删除导致多次调整后仍出现不平衡),如果没有适当的旋转和重平衡,极端不平衡的二叉查找树可能会接近链表的性能。这种情况在实际中很少见,因为红黑树的操作本身就是为了防止这种极端情况的发生。
总的来说,红黑树是设计用来在各种操作下维持对数级性能的,即使在最坏的情况下也保证了O(log n)的时间复杂度,不会出现 O(n) 的查找时间复杂度。
26.HashMap读写时间复杂度是多少?
HashMap的读取(get)和写入(put)操作的平均时间复杂度通常为O(1)。
在理想情况下,即哈希函数分布均匀且没有冲突时,HashMap的读取和写入操作可以直接定位到数组的相应位置,因此时间复杂度为O(1)。但在最坏的情况下,如果出现大量的哈希冲突,这些操作的时间复杂度可能会退化为O(n),其中n是HashMap中元素的数量。
27.HashMap 的长度为什么是 2 的幂次方
key的Hash 值是int型(32位),范围值-很大,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。
如果数组大小是2的n次方的化,我们也可以用hash值位与运算&(数组大小-1),这样和取模效果一样,而且二进制的位运算比取模效率高得多。
28.解决hash冲突的办法有哪些?HashMap用的哪种?(链地址法 )
解决Hash冲突方法有:开放定址法、再哈希法、链地址法。HashMap中采用的是 链地址法 。
- 开放定址法基本思想就是,如果
p=H(key)出现冲突时,则以p为基础,再次hash,p1=H(p),如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址pi。 因此开放定址法所需要的hash表的长度要大于等于所需要存放的元素,而且因为存在再次hash,所以只能在删除的节点上做标记,而不能真正删除节点。 - 再哈希法提供多个不同的hash函数,当
R1=H1(key1)发生冲突时,再计算R2=H2(key1),直到没有冲突为止。 这样做虽然不易产生堆集,但增加了计算的时间。 - 链地址法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
29.HashMap的put方法的具体流程
- 判断键值对数组table是否为空或为null,若位空第一次扩容(resize:默认情况下,如果没有指定初始容量,则扩容的大小为16)
- 通过hash算法根据键值key计算hash值得到数组索引i
- 判断索引处有没有存在元素,没有就直接插入
- 如果存在元素且该key已存在,则直接覆盖其value;
- 如果索引处存在元素且该key不存z在,则遍历插入,有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入
- 链表的数量大于阈值8且数组长度大于64,就要转换成红黑树的结构
- 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。
30.HashMap的get方法的具体流程
HashMap的get方法的具体流程涉及查找哈希表以定位键值对。具体步骤如下:
- 计算哈希值:首先,通过传入的
key调用其hashCode()方法获取哈希值。这个值将用于确定键值对在内部数组中的位置。 - 定位数组索引:使用哈希值与数组长度减一(length - 1)进行位与运算(&),得到数组中对应的索引位置。
- 检查节点:在得到的索引位置上,首先检查是否有节点存在。如果该位置为空,则直接返回
null,表示没有找到对应的value。 - 比较键值:如果有节点存在,接着比较节点的
key是否与传入的key相等。如果相等,则返回该节点的value。 - 处理链表或红黑树:如果节点的
key不相等,且该索引位置是一个链表或红黑树(当链表长度超过一定阈值时会转换为红黑树以提高搜索效率),则需要遍历这个数据结构,继续比较key直到找到匹配的键值对或者遍历结束。 - 返回结果:如果在链表或红黑树中找到了对应的
key,则返回其value;如果没有找到,则返回null。
综上所述,HashMap的get方法是通过计算哈希值和比较键值来快速定位并返回对应value的过程。
31.HashMap的扩容机制
- 在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(当前数组长度 * 0.75)
- 每次扩容的时候,都是扩容之前容量的2倍;
- 扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
- 没有hash冲突节点,则直接使用 e.hash & (newCap - 1) (值等于e.hash%newCap)计算新数组的索引位置
- 如果是红黑树,走红黑树的添加
- 如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为0,若为0该元素的位置停留在原始位置,否则移动到原始位置+增加的数组大小这个位置上
0.为什么容量是以2的次方扩充的?
是为了提高性能使用足够大的数组,二是为了能使用位运算代替取模预算(据说提升了5~8倍)。
1.为什么要判断e.hash & oldCap==0
举例:原数组容量为16,存有3个元素,他们hash值分别为5,21,37,他们都存在下标为5的链表里,扩容后数组容量为32,他们对应的新数组下标分别为5,21,5,可用发现,hash值为5和37的都满足e.hash & oldCap==0,因此他们下标不变;而hash为21的不满足e.hash & oldCap==0,此时他的下标变为原下标+oldCap。这样可以省去重新计算hash值的时间
2.100个数据放入hashmap要扩容到多少
256;应为扩容到128时128*0.75<100,所以要再扩容一次
32.HashMap的散列/寻址算法
这个哈希方法首先通过key的hashcode()方法计算出key的hashCode值,然后通过这个hashcode值右移16位后的二进制与自己本身进行按位异或运算得到最后的hash值。
int idx = (len-1) & (hash ^ (hash>>>16))
1.JDK 8 为什么要 hashcode 异或其右移十六位的值?
因为在JDK 7 中扰动了 4 次,计算 hash 值的性能会稍差一点点。
从速度、功效、质量来考虑,JDK 8 优化了高位运算的算法,通过hashCode()的高16位异或低16位实现:(h = k.hashCode()) ^ (h >>> 16)。
这么做可以在数组 table 的 length 比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
2.为什么 hash 值要与length-1相与?
- 把 hash 值对数组长度取模运算,模运算的消耗很大,没有位运算快。
- 当 length 总是 2 的n次方时,
h& (length-1)运算等价于对length取模,也就是 h%length,但是 位与运算比 取模运算 具有更高的效率。
3.hashCode()方法是啥?
一个Object类的方法,将与对象相关的信息映射成一个哈希值,默认的实现hashCode值是根据内存地址换算出来。
33.HashMap为什么线程不安全?
- 扩容造成死循环:在jdk1.7版本的HashMap中,扩容操作可能会引发链表形成环状结构,导致无限循环的问题。具体来说,当两个线程同时对一个链表进行put操作,并且触发了扩容,那么在转移元素到新数组的过程中,由于头插法的使用,链表元素顺序会被反转。如果两个线程交替执行,就可能出现环形链表的情况,进而在遍历或操作该链表时进入死循环。在JDK1.7及之前版本的HashMap中,链表节点的插入采用头插法。当有多个线程同时对链表进行操作时,可能会发生以下情况:线程A和线程B同时访问一个桶位,发现需要进行扩容操作。线程A获取到锁,开始进行扩容操作,将链表中的节点重新定位到新的桶位。线程B等待线程A释放锁,然后获取到锁,也开始进行扩容操作。线程A在重新定位节点时,可能会将节点插入到链表头部,这时线程B可能已经将部分节点重新定位到了新的桶位。由于头插法的特性,线程A在插入节点时,可能会将已经重新定位的节点再次插入到链表头部,导致链表中的节点指向错误的位置。这样,链表中的节点形成了一个环形结构,使得查询元素的操作陷入死循环无法结束。因此,头插法在多线程环境下容易导致链表中的节点指向错误的位置,从而形成环形链表。为了避免这个问题,从JDK1.8开始,HashMap的实现采用了尾插法,并且在扩容操作时使用了分段锁技术,提高了多线程环境下的性能和稳定性。
- 扩容造成数据丢失:除了死循环问题外,jdk1.7版本在多线程环境下扩容还可能导致数据丢失。在多线程同时进行put操作和扩容操作的情况下,某些元素可能因为并发操作的错误而被覆盖或者遗漏。
- 数据覆盖:当两个线程计算得出相同的哈希值并且要插入相同的位置时,后来的操作可能会覆盖先前线程的插入结果,导致数据一致性问题。数据覆盖举个例子: 两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。
综上所述,HashMap的线程不安全主要体现在jdk1.7中的死循环和数据丢失问题以及jdk1.8中的数据覆盖问题上。这些问题的根本原因在于HashMap的设计并未考虑多线程并发操作的情况,导致多个线程之间的操作互相干扰,从而产生错误
34. LinkedHashMap底层原理?
LinkedHashMap使用双向链表来维护key-value对的顺序(其实只需要考虑key的顺序),该链表负责维护Map的迭代顺序,迭代顺序与key-value对的插入顺序保持一致。LinkedHashMap可以避免对HashMap、Hashtable里的key-value对进行排序**(只要插入key-value对****时保持顺序即可**),同时又可避免使用TreeMap所增加的成本。LinkedHashMap需要维护元素的插入顺序,因此性能略低于HashMap的性能。但因为它以链表来维护内部顺序,所以在迭代访问Map里的全部元素时将有较好的性能。

35.HashMap与ConcurrentHashMap有什么区别?
HashMap和ConcurrentHashMap都是Java中常用的哈希表实现,它们在多线程环境下的行为和性能存在显著差异。以下是两者的具体分析:
- 线程安全性HashMap:不是线程安全的,当多个线程同时对HashMap进行修改时可能会导致不可预见的结果[^1^]。ConcurrentHashMap:是线程安全的,可以在多线程环境中使用而不会出现数据不一致的问题[^1^]。
- 锁机制HashMap:没有内置锁机制,适用于单线程环境或只读多线程环境[^1^]。ConcurrentHashMap:采用分段锁设计,每个段可以独立加锁,提高了并发性能[^1^][^2^]。
- 内部结构HashMap:JDK1.8之前是数组+链表结构,JDK1.8之后引入了红黑树来提高性能(链表长度超过阈值时转换为红黑树)[^2^]。ConcurrentHashMap:JDK1.8之前是segment数组+数组+链表,JDK1.8之后结构与HashMap类似,但增加了线程安全的特性[^2^]。
- 性能特点HashMap:由于不需要考虑同步操作,通常在单线程应用中具有较好的性能[^1^]。ConcurrentHashMap:适用于多线程环境下频繁的读写操作,特别是写操作较多时,能提供更好的并发性能[^1^]。
综上所述,HashMap适用于单线程或只读多线程场景,因为它提供了较高的性能;而ConcurrentHashMap则专为多线程并发读写操作设计,通过分段锁等机制确保线程安全,并在高并发情况下表现出色。选择哪种取决于具体应用场景,特别是在涉及多线程时,ConcurrentHashMap通常是更合适的选择。
36.ConcurrentHashMap 和 Hashtable 的区别
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
实现线程安全的方式(重要):
- 在 JDK1.7 的时候,
ConcurrentHashMap对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 - 到了 JDK1.8 的时候,
ConcurrentHashMap已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用synchronized和 CAS 来操作。(JDK1.6 以后synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的HashMap,虽然在 JDK1.8 中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本; Hashtable**(同一把锁)** :使用synchronized来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
37.ConcurrentHashMap 线程安全的具体实现方式/底层具体实现
jdk 1.7
ConcurrentHashMap 是由 Segment 数组结构和+****HashEntry 数组结构组成。
jdk 1.7 中,ConcurrentHashMap 是由 Segment 数据结构和 HashEntry 数组结构构成,**采取分段****锁(Segment lock,继承自Reenteantlock)**来保证安全性。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个Segment 里包含一个 HashEntry 数组,HashEntry 数组的结构和 HashMap 类似,是一个数组和链表结构。
首先将数据分为一段一段(这个“段”就是 Segment**)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问,就可以提高并发性**。**Segment** **继承了** **ReentrantLock****,扮演锁的角色**。HashEntry 用于存储键值对数据。
当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的。

jdk 1.8
JDK1.8 的实现已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 Synchronized 和 CAS 来操作,Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。整个看起来就像是优化过且线程安全的 HashMap
JDK1.8 放弃了 Segment 分段锁的设计,采用 CAS + synchronized **保证线程安全,锁粒度更细,**synchronized 只锁定当前链表或红黑二叉树的首节点。
它摒弃了原有的Segment分段锁,而是采用了一种粒度更细的锁机制来实现线程安全。ConcurrentHashMap内部维护了一个Node数组。Node对象包含了键值对以及指向下一个Node的指针,用于解决hash冲突问题。在JDK 1.8版本的ConcurrentHashMap中,每个Node对象都有一个lock字节用于存储锁状态。当对ConcurrentHashMap进行put、get等操作时,会尝试获取对应Node的锁,如果获取成功则进行操作,操作完成后再释放锁。由于不同Node之间的锁是独立的,因此不同线程可以同时访问不同Node,从而实现并发操作。

38.JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
线程安全实现方式:JDK 1.7 采用 Segment 分段锁来保证安全, Segment 是继承自 ReentrantLock**。JDK1.8 放弃了** **Segment** **分段锁的设计,采用** **CAS + synchronized** **保证线程安全,锁粒度更细,****synchronized** **只锁定当前链表或红黑二叉树的首节点。**
Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
39.ConcurrentHashMap是怎么分段分组的?
- put操作: 当执行put操作时,会经历两个步骤: 1.判断是否需要扩容; 2.定位到添加元素的位置,将其放入 HashEntry 数组中。 插入过程会进行第一次 key 的 hash 来定位 Segment 的位置,如果该 Segment 还没有初始化,即通过 CAS 操作进行赋值,然后进行第二次 hash 操作,找到相应的 HashEntry 的位置,这里会利用继承过来 的锁的特性,在将数据插入指定的 HashEntry 位置时(尾插法),会通过继承 ReentrantLock 的 tryLock() 方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该 Segment的锁,那当前线程会以自旋的方式去继续的调用 tryLock() 方法去获取锁,超过指定次数就 挂起,等待唤醒。
40.ConcurrentHashMap 为什么 key 和 value 不能为 null?
根据Java 8的ConcurrentHashMap实现,不允许使用null作为键或值。这样做的原因主要有以下几点:
- 并发问题:ConcurrentHashMap是为高并发环境设计的,如果允许null作为键或值,那么在并发环境下可能会出现问题。例如,如果一个线程在检查一个键是否存在,而另一个线程正在将这个键的值设置为null,那么第一个线程可能会错误地认为这个键不存在。
- 区分不存在的键和值为null的键:在ConcurrentHashMap中,get方法返回null表示映射中不存在指定的键。如果允许值为null,那么就无法区分映射中不存在这个键,还是这个键的值就是null。
HashMap 为什么 key 和 value 能为 null?
HashMap可以存储null作为键或值。以下是关于HashMap存储null的详细说明:
- 存储null为键:HashMap允许将null作为一个键存储,但需要注意的是,HashMap中只能有一个null键。这是因为HashMap使用hash算法来存储和检索键值对,如果有两个null键,它们将具有相同的hash值(即0),这会导致键的冲突。
- 存储null为值:与键不同,HashMap允许有多个值为null的条目。这意味着可以将null作为值与任何非空键关联。
- 内部处理机制:当HashMap中的键为null时,其hash值会被计算为0。这是为了简化逻辑和减少歧义,因为在HashMap的设计初衷中,它是为单线程环境优化的。
- 操作方法:可以使用HashMap的put方法将包含null键或值的条目添加到映射中,使用get方法可以通过键来检索值,即使这些键是null。遍历HashMap时,可以通过keySet()方法获取所有的键,然后逐一检索对应的值。
总的来说,HashMap在设计上允许存储null键和值,但要注意null键的唯一性。在实际应用中,应谨慎使用null键,以避免潜在的混淆和错误。
41.Set有哪些实现类?

42.HashSet底层原理?
HashSet 基于 HashMap 实现。放入HashSet中的元素实际上由HashMap的key来保存,而HashMap的value则存储了一个静态的Object对象。
43.HashSet、LinkedHashSet 和 TreeSet 的异同?
HashSet、LinkedHashSet和TreeSet都是Set接口的实现类,都能保证元素唯一,并且都不是线程安全的。HashSet、LinkedHashSet和TreeSet的主要区别在于底层数据结构不同。HashSet的底层数据结构是哈希表(基于HashMap**实现)。**LinkedHashSet**的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。**TreeSet底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。- 底层数据结构不同又导致这三者的应用场景不同。
HashSet**用于不需要保证元素插入和取出顺序的场景,**LinkedHashSet**用于保证元素的插入和取出顺序满足 FIFO 的场景,**TreeSet用于支持对元素自定义排序规则的场景。
44.HashMap 和 HashSet 区别
如果你看过 HashSet 源码的话就应该知道:HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone()、writeObject()、readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。
45.说一说TreeSet和HashSet的区别
HashSet、TreeSet中的元素都是不能重复的,并且它们都是线程不安全的,二者的区别是:
- HashSet中的元素可以是null,但TreeSet中的元素不能是null;
- HashSet不能保证元素的排列顺序,而TreeSet支持自然排序、定制排序两种排序的方式;
- HashSet底层是采用哈希表实现的,而TreeSet底层是采用红黑树实现的。
46.Queue 与 Deque 的区别
Queue 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。
Queue 扩展了 Collection 的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值
Deque 是双端队列,在队列的两端均可以插入或删除元素。
Deque 扩展了 Queue 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:
47.ArrayDeque 与 LinkedList 的区别
ArrayDeque 和 LinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?
ArrayDeque是基于可变长的数组和双指针来实现,而LinkedList则通过链表来实现。ArrayDeque不支持存储NULL数据,但LinkedList支持。ArrayDeque是在 JDK1.6 才被引入的,而LinkedList早在 JDK1.2 时就已经存在。ArrayDeque插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然LinkedList不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。
从性能的角度上,选用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈
48.PriorityQueue
PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
这里列举其相关的一些要点:
PriorityQueue利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据PriorityQueue通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。PriorityQueue是非线程安全的,且不支持存储NULL和non-comparable的对象。PriorityQueue默认是小顶堆,但可以接收一个Comparator作为构造参数,从而来自定义元素优先级的先后。
49.什么是 BlockingQueue?
BlockingQueue 是 Java util.concurrent 包下的一种重要数据结构,它提供了线程安全的队列访问方式。当队列为空时,取数据的线程会阻塞等待直到队列非空;当队列已满时,插入数据的线程会阻塞等待直到队列非满。这种特性使得 BlockingQueue 非常适合用于生产者消费者模式,在多线程环境中协调生产与消费速率不一致的问题。
BlockingQueue 是一个接口,它有几个具体的实现类,如 ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue 等。这些实现类在不同使用场景中各有优势。例如,ArrayBlockingQueue 是基于一个固定长度的数组实现的有界队列,而 LinkedBlockingQueue 则是基于链表实现的,其容量可以是 Integer.MAX_VALUE,即无界队列。
BlockingQueue是怎么实现的?
BlockingQueue 是通过 ReentrantLock锁和条件(Condition)来实现的。
BlockingQueue 的工作原理主要依赖于 ReentrantLock 和 Condition。其中,ReentrantLock 是 java.util.concurrent.locks 包下的一个类,它实现了 Lock 接口并允许以排他模式持有和释放锁。Condition 的await()/signal()机制则是用来代替传统的 Object 中的 wait()/notify() 机制,可以更加灵活地控制线程之间的交互。
BlockingDeque 使用 ReentrantLock 来实现显式锁定。当一个线程尝试向已满的队列中添加元素时,它首先需要获取锁。如果成功获取锁,则可以进一步操作;否则,线程将被阻塞。当线程无法向已满的队列中添加元素时,它将被加入到 Condition 维护的等待队列中。这个队列是通过一个内部类 Node 实现的单向链表。当线程被加入等待队列后,调用 await() 方法,该线程将释放锁并进入等待状态。当其他线程从队列中移除元素,使空间可用时,会调用 signal() 或 signalAll() 方法唤醒等待队列中的一个或所有线程。
从使用者的角度看,BlockingQueue 提供四种不同的行为方式:抛异常、返回特殊值、阻塞以及超时。当操作无法立即执行时,比如向满队列中添加元素或从空队列中获取元素,这些操作会根据具体情况选择不同的行为。例如,add(E e) 方法在队列满时抛出 IllegalStateException 异常;而 offer(E e) 方法则在无法立即执行时返回 false。
综上所述,BlockingQueue 通过锁和条件机制解决了多线程环境下数据安全传递问题,其不同的实现类根据具体需求提供了多种选择,广泛应用于生产者消费者等并发模式中
什么是 Condition 的 await() 方法
在Condition中使用await()方法时,当前线程会释放锁并进入等待状态,直到其他线程调用Condition的signal()或者signalAll()方法唤醒它。
具体来说,当一个线程调用await()方法时,它首先会将自己放入条件队列中,并释放持有的锁。此时,该线程被阻塞,不会消耗CPU资源。当其他线程调用Condition的signal或signalAll方法时,等待在条件队列上的线程将被唤醒,重新尝试获取锁,并在获取成功后从await()方法返回,继续执行后续操作。
综上所述,Condition的await()方法通过将线程加入条件队列并释放锁,实现对共享资源访问的精确控制,从而解决线程间的同步问题。
await()
await() 方法是在 java.util.concurrent.locks.Condition 接口中定义的,它要求一个锁(Lock 实例)和一个与之关联的条件变量。当一个线程调用某个条件变量的 await() 方法时,它会释放这个锁,并将自己阻塞,直到其他线程调用了相应的 条件变量的****signal() 或 signalAll() **方法唤醒它。**await() 方法会一直等待,直到满足以下条件之一:
- 另一个线程调用了相同的
Condition对象上的signal()或signalAll()方法。 - 等待线程被中断。
signal()
signal() 方法同样是在 java.util.concurrent.locks.Condition 接口中定义的。当一个线程持有锁并调用 signal() 方法时,它会从等待队列中唤醒一个线程(如果有多个线程在等待,具体唤醒哪个线程由实现决定)。被唤醒的线程将会获得锁并继续执行。调用 signal() 并不会立即让另一个线程开始运行;被唤醒的线程必须等待当前线程释放锁。
50.ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别?
ArrayBlockingQueue 和 LinkedBlockingQueue 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:
- 底层实现:
ArrayBlockingQueue基于数组实现,而LinkedBlockingQueue基于链表实现。 - 是否有界:
ArrayBlockingQueue是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。 - 锁是否分离:
ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。 - 内存占用:
ArrayBlockingQueue需要提前分配数组内存,而LinkedBlockingQueue则是动态分配链表节点内存。这意味着,ArrayBlockingQueue在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue则是根据元素的增加而逐渐占用内存空间。
多线程
1.并发编程的3个重要特性
Java并发编程三大特性
- 原子性
- 可见性
- 有序性
(1)原子性
一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行
若不保证原子性,可能出现订单超卖问题
解决方案:
1.synchronized:同步加锁
2.JUC里面的lock:加锁
(2)内存可见性
内存可见性:让一个线程对共享变量的修改对另一个线程可见
解决方案:
- synchronized
- volatile(推荐)
- LOCK
(3)有序性
指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的
解决方案:
- volatile
2.Thread的join()方法
1.join()的作用
join()是 Thread 类中的一个方法,当我们需要让线程按照自己指定的顺序执行的时候,就可以利用这个方法。「Thread.join()方法表示调用此方法的线程被阻塞,仅当该方法完成以后,才能继续运行」。
❝ 作用于 main( )主线程时,会等待其他线程结束后再结束主线程。 ❞
public static void main(String[] args){
System.out.println("MainThread run start.");
//启动一个子线程
Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("threadA run start.");
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("threadA run finished.");
}
});
//没加join()的情况
threadA.start();
System.out.println("MainThread join before");
System.out.println("MainThread run finished.");
}
//输出
MainThread run start.
threadA run start.
MainThread join before
MainThread run finished.
threadA run finished.
因为上述子线程执行时间相对较长,所以是在主线程执行完毕之后才结束。
//加上join()方法的情况
threadA.start();
System.out.println("MainThread join before");
try {
threadA.join(); //调用join()
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("MainThread run finished.");
//输出
MainThread run start.
threadA run start.
MainThread join before
threadA run finished.
MainThread run finished.
对子线程threadA使用了join()方法之后,我们发现主线程会等待子线程执行完成之后才往后执行。
2.join()的原理
首先join() 是一个synchronized方法, 里面调用了wait(),这个过程的目的是让持有这个同步锁的线程进入等待,那么谁持有了这个同步锁呢?答案是主线程,因为主线程调用了threadA.join()方法,相当于在threadA.join()代码这块写了一个同步代码块,谁去执行了这段代码呢,是主线程,所以主线程被wait()了。然后在子线程threadA执行完毕之后,JVM会调用lock.notify_all(thread);唤醒持有threadA这个对象锁的线程,也就是主线程,会继续执行。
3.线程和进程是什么及其区别?
什么是进程?
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。在 Windows 中通过查看任务管理器的方式,我们就可以清楚看到 Windows 当前运行的进程(.exe 文件的运行)。当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
什么是线程?
一个进程之内可以分为一到多个线程。
一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。
二者对比
- 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
- 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
- 线程作为最小调度单位,进程作为资源分配的最小单位。
4.并行和并发有什么区别?
- 并发:两个及两个以上的作业在同一 时间段 内执行。
- 并行:两个及两个以上的作业在同一 时刻 执行。
并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU
并行是同一时间动手做多件事情的能力,比如4核CPU同时执行4个线程
5.同步和异步的区别
- 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
- 异步:调用在发出之后,不用等待返回结果,该调用直接返回。
6.创建线程的四种方式
共有四种方式可以创建线程,分别是:继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程
详细创建方式参考下面代码:
① 继承Thread类
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("MyThread...run...");
}
public static void main(String[] args) {
// 创建MyThread对象
MyThread t1 = new MyThread() ;
MyThread t2 = new MyThread() ;
// 调用start方法启动线程
t1.start();
t2.start();
}
}
② 实现runnable接口
public class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println("MyRunnable...run...");
}
public static void main(String[] args) {
// 创建MyRunnable对象
MyRunnable mr = new MyRunnable() ;
// 创建Thread对象
Thread t1 = new Thread(mr) ;
Thread t2 = new Thread(mr) ;
// 调用start方法启动线程
t1.start();
t2.start();
}
}
③ 实现Callable接口
public class MyCallable implements Callable<String> {
//MyCallable类实现了Callable<String>接口,该接口是一个泛型接口,指定了call()方法的返回类型为String
@Override
public String call() throws Exception {//call方法可以抛出异常
System.out.println("MyCallable...call...");
return "OK";
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 创建MyCallable对象
MyCallable mc = new MyCallable() ;
//创建FutureTask<String>对象ft,并将mc作为参数传入构造方法,用于封装可调用对象
FutureTask<String> ft = new FutureTask<String>(mc) ;
//通过将Callable对象封装在FutureTask中,可以在多线程环境下执行任务,并获取任务执行结果。
// 创建Thread对象
Thread t1 = new Thread(ft) ;
Thread t2 = new Thread(ft) ;
// 调用start方法启动线程
t1.start();
//调用ft.get()方法获取执行结果,此方法会阻塞当前线程直到结果返回。
String result = ft.get();
// 输出
System.out.println(result);
}
}
//输出结果
MyCallable...call...
OK
④ 线程池创建线程
public class MyExecutors implements Runnable{
@Override
public void run() {
System.out.println("MyRunnable...run...");
}
public static void main(String[] args) {
// 创建线程池对象。获取ExecutorService实例,生产禁用,需要手动创建线程池
ExecutorService threadPool = Executors.newFixedThreadPool(3);
//通过threadPool.submit(new MyExecutors())来向线程池提交任务。
threadPool.submit(new MyExecutors()) ;
//调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法。
threadPool.shutdown();
}
}
runnable 和 callable 两个接口创建线程有什么不同呢?
Runnable 接口run方法无返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
还有一个就是,他们异常处理也不一样。Runnable接口run方法只能抛出运行时异常,也无法捕获处理;Callable接口call方法允许抛出异常,可以获取异常信息
7.线程的 run()和 start()有什么区别?
start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
run(): 封装了要被线程执行的代码,可以被调用多次。
可以直接调用 Thread 类的 run 方法吗?
new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。
8.线程的生命周期和状态
初始(NEW):线程被构建,还没有调用 start()。
运行(RUNNABLE):包括操作系统的就绪和运行两种状态。
阻塞(BLOCKED):一般是被动的,在抢占资源中得不到资源,被动的挂起在内存,等待资源释放将其唤醒。线程被阻塞会释放CPU,不释放内存。
等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
终止(TERMINATED):表示该线程已经执行完毕。
9.线程状态之间是如何变化的
当一个线程对象被创建,但还未调用 start 方法时处于新建状态,调用了 start 方法,就会由新建进入可运行状态。如果线程内代码已经执行完毕,由可运行进入终结状态。当然这些是一个线程正常执行情况。
如果线程获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
如果线程获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁等待状态,当其它持锁线程调用 notify() 或 notifyAll() 方法,会恢复为可运行状态
还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,不需要主动唤醒,超时时间到自然恢复为可运行状态
10.新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。
代码举例:
为了确保三个线程的顺序你应该先启动最后一个(T3调用T2,T2调用T1),这样T1就会先完成而T3最后完成
public class JoinTest {
public static void main(String[] args) {
// 创建线程对象
Thread t1 = new Thread(() -> {
System.out.println("t1");
}) ;
Thread t2 = new Thread(() -> {
try {
t1.join(); // 加入线程t1,只有t1线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2");
}) ;
Thread t3 = new Thread(() -> {
try {
t2.join(); // 加入线程t2,只有t2线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t3");
}) ;
// 启动线程
t1.start();
t2.start();
t3.start();
}
}
11.什么是线程上下文切换?
线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。
- 主动让出 CPU,比如调用了
sleep(),wait()等。 - 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
- 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
- 被终止或结束运行
这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
12.线程死锁
0.什么是线程死锁?
死锁:线程死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法推进下去。1.线程死锁怎么产生?怎么避免?
死锁产生的四个必要条件:
- 互斥:一个资源每次只能被一个进程使用
- 请求与保持:一个进程因请求资源而阻塞时,不释放获得的资源
- 不剥夺:进程已获得的资源,在未使用之前,不能强行剥夺
- 循环等待:进程之间循环等待着资源
避免死锁的方法:
- 互斥条件不能破坏,因为加锁就是为了保证互斥
- 一次性申请所有的资源,避免线程占有资源而且在等待其他资源
- 占有部分资源的线程进一步申请其他资源时,如果申请不到,主动释放它占有的资源
- 按序申请资源
2.如何进行死锁诊断?
通过jdk自动的工具就能搞定
我们可以先通过jps来查看当前java程序运行的进程id
然后通过jstack来查看这个进程id,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。
13.notify()和 notifyAll()有什么区别?
notifyAll:唤醒所有wait的线程
notify:只随机唤醒一个 wait 线程
14.在 java 中 wait 和 sleep 方法的不同?
共同点
- wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
- 任何线程在调用wait()和sleep()之后,在等待期间被中断都会抛出
InterruptedException
不同点
- 方法归属不同
- sleep(long) 是 Thread 的静态方法
- 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
- 醒来时机不同
- 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
- wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒
- 锁特性不同(重点)
- wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
- wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
- 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
15. 如何停止一个正在运行的线程?
有3种方式可以停止线程
- 使用stop方法
- 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
- 使用interrupt方法中断线程
代码参考如下:
0.使用stop方法:
虽然Thread类提供了一个stop()方法来强行终止线程,但这个方法已经被弃用,因为它是不安全的。使用stop()方法可能会导致资源无法正确释放,或者导致应用程序状态不一致。
1.使用退出标志,使线程正常退出。
public class MyInterrupt1 extends Thread {
volatile boolean flag = false ; // 线程执行的退出标记
@Override
public void run() {
while(!flag) {
System.out.println("MyThread...run...");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
// 创建MyThread对象
MyInterrupt1 t1 = new MyInterrupt1() ;
t1.start();
// 主线程休眠6秒
Thread.sleep(6000);
// 更改标记为true
t1.flag = true ;
}
}
2.使用interrupt方法中断线程。
通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。
对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。
public class InterruptExample {
private static class MyThread1 extends Thread {
@Override
public void run() {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new MyThread1();
thread1.start();
thread1.interrupt();
System.out.println("Main run");
}
//输出结果
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at InterruptExample.lambda$main$0(InterruptExample.java:5)
at InterruptExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)
interrupted()
如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。
但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。
public class InterruptExample {
private static class MyThread2 extends Thread {
@Override
public void run() {
while (!Thread.currentThread().interrupted()) {
// ..
}
System.out.println("Thread end");
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread2 = new MyThread2();
thread2.start();
thread2.interrupt();
}
//输出结果
Thread end
16.线程间通信方式
1、使用 Object 类的 wait()/notify()。Object 类提供了线程间通信的方法:wait()、notify()、notifyAll(),它们是多线程通信的基础。其中,wait/notify 必须配合 synchronized 使用,wait 方法释放锁,notify 方法不释放锁。wait 是指在一个已经进入了同步锁的线程内,让自己暂时让出同步锁,以便其他正在等待此锁的线程可以得到同步锁并运行,只有其他线程调用了notify(),notify并不释放锁,只是告诉调用过wait()的线程可以去参与获得锁的竞争了,但不是马上得到锁,因为锁还在别人手里,别人还没释放,调用 wait() 的一个或多个线程就会解除 wait 状态,重新参与竞争对象锁,程序如果可以再次得到锁,就可以继续向下运行。
2、使用 volatile 关键字。基于volatile关键字实现线程间相互通信,其底层使用了共享内存。简单来说,就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。
3、使用JUC工具类 CountDownLatch。jdk1.5 之后在java.util.concurrent包下提供了很多并发编程相关的工具类,简化了并发编程开发,CountDownLatch 基于 AQS 框架,相当于也是维护了一个线程间共享变量 state。
4、基于 LockSupport 实现线程间的阻塞和唤醒。LockSupport 是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字
17.进程间的通讯方式
进程间通信(IPC,InterProcess Communication)是指在不同进程之间传播或交换信息。Linux 内核提供了多种进程间通信机制,包括管道、消息队列、共享内存、信号量和 PV 操作、SysV 和 POSIX 信号量以及套接字(Socket)。
- 管道:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
- 消息队列:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
- 共享内存:共享内存就是映射一段能被其他进程所访问的内存,这段内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它可以被用来实现高速数据传输和大量数据的传输,因此它往往用于进程间的数据共享或者是进程间的通信。
- 信号量和 PV 操作:主要作为互斥锁。
- SysV 和 POSIX 信号量:包括计数器和 semaphore 结构体。
- 套接字(Socket):主要用于不同主机间的进程通信。
18.锁的分类
-1.可中断锁和不可中断锁
- 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。
ReentrantLock就属于是可中断锁。 - 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。
synchronized就属于是不可中断锁。
0.可重入锁
可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。
JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。
1. 公平锁与非公平锁
公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
synchronized是非公平锁,Lock默认是非公平锁,可以设置为公平锁,公平锁会影响性能。
2. 共享式与独占式锁
- 共享锁:一把锁可以被多个线程同时获得。比如semaphore信号量
- 独占锁:一把锁只能被一个线程获得。比如sychronized,reentrantlock
3.悲观锁与乐观锁
1.两者的区别
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证数据是否被其它线程修改。乐观锁最常见的实现就是CAS。
适用场景:
- 悲观锁适合写操作多的场景。
- 乐观锁适合读操作多的场景,不加锁可以提升读操作的性能。
2.乐观锁是怎么实现的?
可以用版本号机制或CAS算法。
版本号机制
一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
3.什么是CAS
CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。
原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。
CAS 涉及到三个操作数:
- V:要更新的变量值(Var)
- E:预期值(Expected)
- N:拟写入的新值(New)
当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。
4. CAS的自旋
自旋: 就是不停的判断比较,看能否将值交换
我们都知道,多个线程在访问共享资源的时候,会产生同步问题,所以需要加锁来保证安全。但是,一旦加了锁,同一时刻只能有一个线程获取锁对象,效率自然变低了。
那在多线程场景下,不加锁的情况下来修改值,CAS是怎么自旋的呢?
现在Data中存放的是num=0,线程A将num=0拷贝到自己的工作内存中计算(做+1操作)E=0,计算的结果为V=1 由于是在多线程不加锁的场景下操作,所以可能此时num会被别的线程修改为其他值。此时需要再次读取num看其是否被修改,记再次读取的值为N 如果被修改,即E != N,说明被其他线程修改过。那么此时工作内存中的E已经和主存中的num不一致了,根据EMSI协议,保证安全需要重新读取num的值。直到E = N才能修改 如果没被修改,即E = N,说明没被其他线程修改过。那门将工作内存中的E=0改为E=1,同时写回主存。将num=0改为num=1
5.CAS/乐观锁存在的问题(ABA)?
CAS 三大问题:
-
ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从
A-B-A变成了1A-2B-3A。JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,原子更新带有版本号的引用类型。
-
循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
-
只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
java基础
1.面向对象和面向过程
面向对象和面向过程是一种软件开发思想。
两者的主要区别在于解决问题的方式不同:
- 面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
- 面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。
2.创建一个对象用什么运算符?对象实体与对象引用有何不同?
new 运算符,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。
- 一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);
- 一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)
3.对象的相等和引用相等的区别
- 对象的相等一般比较的是内存中存放的内容是否相等。
- 引用相等一般比较的是他们指向的内存地址是否相等。
4.方法重载和重写的区别?
同个类中的多个方法可以有相同的方法名称,但是有不同的参数列表,这就称为方法重载。参数列表又叫参数签名,包括参数的类型、参数的个数、参数的顺序,只要有一个不同就叫做参数列表不同。
重载是面向对象的一个基本特性。
方法的重写描述的是父类和子类之间的。当父类的功能无法满足子类的需求,可以在子类对方法进行重写。方法重写时, 方法名与形参列表必须一致。
5.构造方法有哪些特点?是否可被 override?
构造方法特点如下:
- 名字与类名相同。
- 没有返回值,但不能用 void 声明构造函数。
- 生成类的对象时自动执行,无需调用。
构造方法不能被 override(重写),但是可以 overload(重载),所以你可以看到一个类中有多个构造函数的情况。
6.面向对象三大特性
面向对象三大特性:封装,继承,多态
1.封装
1、封装就是将类的信息隐藏在类内部,不允许外部程序直接访问,而是通过该类的方法实现对隐藏信息的操作和访问。 良好的封装能够减少耦合。
2.继承
2、继承是从已有的类中派生出新的类,新的类继承父类的属性和行为,并能扩展新的能力,大大增加程序的重用性和易维护性。在Java中是单继承的,也就是说一个子类只有一个父类。
3.多态
多态是同一个行为具有多个不同表现形式的能力。在不修改程序代码的情况下改变程序运行时绑定的代码。实现多态的三要素:继承、重写、父类引用指向子类对象。
- 静态多态性:通过重载实现,相同的方法有不同的參数列表,可以根据参数的不同,做出不同的处理。
- 动态多态性:在子类中重写父类的方法。运行期间判断所引用对象的实际类型,根据其实际类型调用相应的方法。
举例:有一个动物类和他的2个子类猫和狗,他们都有一个名为makesound的方法,但是每种动物发出的声音是不同的。
7.接口和抽象类有什么共同点和区别?
接口和抽象类都是用于实现多态性的机制,它们在定义上有一些共同点,但也有一些关键的区别。具体分析如下:
- 共同点:
- 两者都不能直接实例化,需要由具体的子类或实现类来进行实例化。
- 都可以包含抽象方法,这些抽象方法需要由继承或实现它们的类来提供具体的实现。
- 不同点:
- 语法和声明方式:接口使用
interface关键字进行声明,而抽象类使用abstract关键字进行声明。 - 成员变量:接口只能定义公共静态最终字段(即常量),不能定义普通成员变量;而抽象类可以定义各种类型的成员变量。
- 构造函数:接口没有构造函数,而抽象类可以有构造函数。
- 方法实现:接口中的方法默认都是公共的抽象方法,不包含具体的实现代码;而抽象类可以包含抽象方法和非抽象方法,其中抽象方法没有具体的实现,而非抽象方法有具体的实现代码。
- 继承与实现:一个类可以实现多个接口,但只能继承一个抽象类。
- 访问修饰符:抽象方法必须为public或者protected,默认为public;接口中的方法默认为public abstract。
- 静态成员:接口中可以定义默认方法和静态方法(JDK 8及以上版本),而抽象类可以有静态代码块和静态方法。
总的来说,接口主要用于定义全局的规范,而抽象类则用于描述具有部分实现的共性。选择使用接口还是抽象类取决于具体的设计需求。
8.面向对象编程的六大原则?
- 对象单一职责:我们设计创建的对象,必须职责明确,比如商品类,里面相关的属性和方法都必须跟商品相关,不能出现订单等不相关的内容。这里的类可以是模块、类库、程序集,而不单单指类。
- 里式替换原则:子类能够完全替代父类,反之则不行。通常用于实现接口时运用。因为子类能够完全替代基(父)类,那么这样父类就拥有很多子类,在后续的程序扩展中就很容易进行扩展,程序完全不需要进行修改即可进行扩展。比如IA的实现为A,因为项目需求变更,现在需要新的实现,直接在容器注入处更换接口即可.
- 迪米特法则,也叫最小原则,或者说最小耦合。通常在设计程序或开发程序的时候,尽量要高内聚,低耦合。当两个类进行交互的时候,会产生依赖。而迪米特法则就是建议这种依赖越少越好。就像构造函数注入父类对象时一样,当需要依赖某个对象时,并不在意其内部是怎么实现的,而是在容器中注入相应的实现,既符合里式替换原则,又起到了解耦的作用。
- 开闭原则:开放扩展,封闭修改。当项目需求发生变更时,要尽可能的不去对原有的代码进行修改,而在原有的基础上进行扩展。
- 依赖倒置原则:高层模块不应该直接依赖于底层模块的具体实现,而应该依赖于底层的抽象。接口和抽象类不应该依赖于实现类,而实现类依赖接口或抽象类。
- 接口隔离原则:一个对象和另外一个对象交互的过程中,依赖的内容最小。也就是说在接口设计的时候,在遵循对象单一职责的情况下,尽量减少接口的内容
9.深拷贝和浅拷贝区别了解吗?
浅拷贝
被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。即对象的浅拷贝会对“主”对象进行拷贝,但不会复制主对象里面的对象。”里面的对象“会在原来的对象和它的副本之间共享。
简而言之,浅拷贝仅仅复制所考虑的对象,而不复制它所引用的对象。
深拷贝
深拷贝是一个整个独立的对象拷贝,深拷贝会拷贝所有的属性,并拷贝属性指向的动态分配的内存。当对象和它所引用的对象一起拷贝时即发生深拷贝。深拷贝相比于浅拷贝速度较慢并且花销较大。
简而言之,深拷贝把要复制的对象所引用的对象都复制了一遍。
浅拷贝(Shallow Copy): 可以使用Object.clone()方法进行浅拷贝。需要注意的是,要使一个类支持浅拷贝,必须实现Cloneable接口并重写clone()方法。
10.什么是值传递和引用传递?
- 值传递是对基本型变量而言的,传递的是该变量的一个副本,改变副本不影响原变量。
- 引用传递一般是对于对象型变量而言的,传递的是该对象地址的一个副本,并不是原对象本身,两者指向同一片内存空间。所以对引用对象进行操作会同时改变原对象。
java中不存在引用传递,只有值传递。即不存在变量a指向变量b,变量b指向对象的这种情况。
11.c++和java的区别
C++和Java都是广泛使用的编程语言,但它们在多个方面存在显著差异。
- 面向对象性:Java是一种纯粹的面向对象编程语言,而C++既支持面向过程编程,也支持面向对象编程。在C++中,并非所有元素都必须是对象,你仍然可以有全局函数和全局变量。相比之下,Java中的所有代码(包括函数、变量)都必须在类中定义。
- 内存管理:C++允许程序员直接管理内存,包括使用指针和手动分配及释放内存。这提供了更高的控制能力,但也增加了出错的风险,如内存泄露和空悬指针等问题。相反,Java自动进行垃圾回收(GC),负责监控并自动回收不再被引用的对象的内存空间,简化了内存管理,但也可能导致不可预测的GC暂停时间。
- 跨平台能力:Java的一个主要优点是其跨平台能力,Java代码在任何安装了Java虚拟机(JVM)的系统上都可以运行不变。C++代码则通常需要针对每个目标平台重新编译,虽然C++代码也可以跨平台,但需要更多的工作来适应不同的系统和编译器。
- 执行速度:C++通常提供比Java更快的执行速度,因为Java代码在运行时需要通过JVM转换为机器码,这一中间步骤会引入性能开销。C++代码直接编译成机器码,因此通常执行更快,但这也意味着C++程序更接近硬件层面,给开发者更多优化的空间。
- 语言特性:C++拥有一些Java不支持的特性,例如运算符重载、多重继承和强制类型转换等。而Java设计上避免了这些可能导致混淆和错误的特性。
- 易用性和学习曲线:由于Java简化了内存管理和垃圾回收机制,通常认为Java比C++更容易学习和使用。C++的学习曲线更陡峭,因为它涉及更多底层的概念和复杂性。
综上所述,两种语言各有优势和用途。选择使用哪种语言通常取决于项目需求、开发环境和目标平台。C++适合需要高性能和紧密硬件集成的系统级应用,而Java适合快速开发跨平台的应用程序。
12.Java的特点
Java是一门面向对象的编程语言。
Java具有平台独立性和移植性。
- Java有一句口号:
Write once, run anywhere,一次编写、到处运行。这也是Java的魅力所在。而实现这种特性的正是Java虚拟机JVM。已编译的Java程序可以在任何带有JVM的平台上运行。你可以在windows平台编写代码,然后拿到linux上运行。只要你在编写完代码后,将代码编译成.class文件,再把class文件打成Java包,这个jar包就可以在不同的平台上运行了。
Java具有稳健性。
- Java是一个强类型语言,它允许扩展编译时检查潜在类型不匹配问题的功能。Java要求显式的方法声明,它不支持C风格的隐式声明。这些严格的要求保证编译程序能捕捉调用错误,这就导致更可靠的程序。
- 异常处理是Java中使得程序更稳健的另一个特征。异常是某种类似于错误的异常条件出现的信号。使用
try/catch/finally语句,程序员可以找到出错的处理代码,这就简化了出错处理和恢复的任务
13.Java创建对象有几种方式?
Java创建对象有以下几种方式:
- 用new语句创建对象。
- 使用反射,使用Class.newInstance()创建对象。
- 调用对象的clone()方法。
- 运用反序列化手段,调用java.io.ObjectInputStream对象的readObject()方法。
14.说说类实例化的顺序
Java中类实例化顺序:
- 静态属性,静态代码块。
- 普通属性,普通代码块。
- 构造方法。
15.Java的4种引用类型
在java1.2之后,java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用,这4种引用强度依次逐渐减弱。
- 强引用(Strong Reference):在程序代码之中普遍存在的,类似
Object obj = new Object()这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象 - 软引用(Soft Reference):用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存才会抛出内存溢出异常
- 弱引用(Weak Reference):用来描述非必需对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
- 虚引用(Phantom Reference):它是最弱的一种引用关系。一个对象是否有虚引用存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知
16.Java的基本数据类型有哪些?每个占多少位?
- byte,8bit
- char,16bit
- short,16bit
- int,32bit
- float,32bit
- long,64bit
- double,64bit
- boolean,只有两个值:true、false,可以使⽤用 1 bit 来存储
17.了解Java的包装类型吗?为什么需要包装类?
Java包装类型有8种,分别是:1、Byte;2、Integer;3、Short;4、Long;5、Float;6、Double;7、Boolean;8、Character。
Java 是一种面向对象语言,很多地方都需要使用对象而不是基本数据类型。比如,在集合类中,我们是无法将 int 、double 等类型放进去的。因为集合的容器要求元素是 Object 类型。
为了让基本类型也具有对象的特征,就出现了包装类型。相当于将基本类型包装起来,使得它具有了对象的性质,并且为其添加了属性和方法,丰富了基本类型的操作。
18.自动装箱与拆箱了解吗?原理是什么?
什么是自动拆装箱?
- 装箱:将基本类型用它们对应的引用类型包装起来;
- 拆箱:将包装类型转换为基本数据类型;
原理方面,自动装箱时编译器会调用包装类的valueOf方法将原始类型的值转换成对象。而在自动拆箱的过程中,编译器则通过调用类似intValue()、doubleValue()这样的方法将对象转换回原始类型的值。
19.为什么浮点数运算的时候会有精度丢失的风险?
为什么会出现这个问题呢?
这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。
如何解决浮点数运算的精度丢失问题?
BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。
20.包装类型的缓存机制了解么?
Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False。
如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。
举例1
Integer a = 100;//自动装箱
Integer b = 100;
System.out.println(a == b);//true
Integer c = 200;
Integer d = 200;
System.out.println(c == d);//false
这是因为Java虚拟机(JVM)对Integer对象进行了缓存优化。对于在-128到127之间的整数,JVM会缓存这些对象的实例,以便重复使用。因此,当我们声明a和b并赋值为100时,它们都指向相同的缓存对象,所以a == b的结果为true。
然而,对于超出-128到127范围的整数,JVM不会进行缓存优化。因此,当我们声明c和d并赋值为200时,它们分别创建了两个不同的对象,所以c == d的结果为false。
举例2
下面我们来看一个问题:下面的代码的输出结果是 true 还是 false 呢?
Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2);
Integer i1=40 这一行代码会发生装箱,也就是说这行代码等价于 Integer i1=Integer.valueOf(40) 。因此,i1 直接使用的是缓存中的对象。而Integer i2 = new Integer(40) 会直接创建新的对象。
21.Object常用方法有哪些?
Object常用方法有:toString()、equals()、hashCode()、clone()等。
toString
默认输出对象地址。可以重写toString方法,按照重写逻辑输出对象值。
equals
默认比较两个引用变量是否指向同一个对象(内存地址)。可以重写equals方法,按照属性是否相等来判断
hashCode
将与对象相关的信息映射成一个哈希值,默认的实现hashCode值是根据内存地址换算出来。
clone
Java赋值是复制对象引用,如果我们想要得到一个对象的副本,使用赋值操作是无法达到目的的。Object对象有个clone()方法,实现了对象中各个属性的复制,但它的可见范围是protected的。
protected native Object clone() throws CloneNotSupportedException;
所以实体类使用克隆的前提是:
- 实现Cloneable接口,这是一个标记接口,自身没有方法,这应该是一种约定。调用clone方法时,会判断有没有实现Cloneable接口,没有实现Cloneable的话会抛异常CloneNotSupportedException。
- 覆盖clone()方法,可见性提升为public。
getClass
返回此 Object 的运行时类,常用于java反射机制。
22.equals和引用相等==的区别
-
对于基本数据类型,==比较的是他们的值。基本数据类型没有equal方法;
-
对于复合数据类型,==比较的是它们的存放地址(是否是同一个对象)。
equals()默认比较地址值等价于==,重写的话一般按照属性是否相等去比较。String中的equals方法是被重写过的,因为Object的equals方法是比较的对象的内存地址,而String的equals方法比较的是对象的值。
23.hashCode() 有什么用?
hashCode() 的作用是获取哈希码(int 整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。
24.为什么要有 hashCode?
我们以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode?
当你把对象加入
HashSet时,HashSet会先计算对象的hashCode值来判断对象加入的位置,同时也会与其他已经加入的对象的hashCode值作比较,如果没有相符的hashCode,HashSet会假设对象没有重复出现。但是如果发现有相同hashCode值的对象,这时会调用equals()方法来检查hashCode相等的对象是否真的相同。如果两者相同,HashSet就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了equals的次数,相应就大大提高了执行速度。
其实, hashCode() 和 equals()都是用于比较两个对象是否相等。
25.为什么重写 equals 时一定要重写 hashCode?
因为两个相等的对象的 hashCode 值必须是相等。也就是说如果 equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。
如果重写 equals() 时没有重写 hashCode() 方法的话就可能会导致 equals 方法判断是相等的两个对象,hashCode 值却不相等。这样,当用其中的一个对象作为键保存到hashMap、hashTable或hashSet中,再以另一个对象作为键值去查找他们的时候,则会查找不到。
26.两个对象的hashCode()相同,则 equals()是否也一定为 true?
equals方法判断两个对象是相等的,那这两个对象的hashCode值也要相等。- 两个对象有相同的
hashCode值,他们也不一定是相等的(哈希碰撞)。
hashcode方法主要是用来提升对象比较的效率,先进行hashcode()的比较,如果不相同,那就不必在进行equals的比较,这样就大大减少了equals比较的次数,当比较对象的数量很大的时候能提升效率。
27.String, StringBuffer 和 StringBuilder区别
1. 可变性
- String 不可变
- StringBuffer 和 StringBuilder 可变
2. 线程安全
- String 不可变,因此是线程安全的
- StringBuilder 不是线程安全的
- StringBuffer 是线程安全的,内部使用 synchronized 进行同步
对于三者使用的总结:
- 操作少量的数据: 适用
String - 单线程操作字符串缓冲区下操作大量数据: 适用
StringBuilder - 多线程操作字符串缓冲区下操作大量数据: 适用
StringBuffer
28.String 为什么不可变?
先看看什么是不可变的对象。
如果一个对象,在它创建完成之后,不能再改变它的状态,那么这个对象就是不可变的。不能改变状态的意思是,不能改变对象内的成员变量,包括基本数据类型的值不能改变,引用类型的变量不能指向其他的对象,引用类型指向的对象的状态也不能改变。
接着来看Java8 String类的源码:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
}
从源码可以看出,String对象其实在内部就是一个个字符,存储在这个value数组里面的。
(1)value数组用final修饰,final 修饰的变量,值不能被修改。因此value不可以指向其他对象。
(2)String类内部所有的字段都是私有的,也就是被private修饰。而且String没有对外提供修改内部状态的方法,因此value数组不能改变。
所以,String是不可变的。
为什么String要设计成不可变的?
主要有以下几点原因:
- 线程安全。同一个字符串实例可以被多个线程共享,因为字符串不可变,本身就是线程安全的。
- 支持hash映射和缓存。因为String的hash值经常会使用到,比如作为 Map 的键,不可变的特性使得 hash 值也不会变,不需要重新计算。
- 出于安全考虑。网络地址URL、文件路径path、密码通常情况下都是以String类型保存,假若String不是固定不变的,将会引起各种安全隐患。比如将密码用String的类型保存,那么它将一直留在内存中,直到垃圾收集器把它清除。假如String类不是固定不变的,那么这个密码可能会被改变,导致出现安全隐患。
- 字符串常量池优化。String对象创建之后,会缓存到字符串常量池中,下次需要创建同样的对象时,可以直接返回缓存的引用。
既然我们的String是不可变的,它内部还有很多substring, replace, replaceAll这些操作的方法。这些方法好像会改变String对象?怎么解释呢?
其实不是的,我们每次调用replace等方法,其实会在堆内存中创建了一个新的对象。然后其value数组引用指向不同的对象。
29.什么是字符串常量池?
字符串常量池 /(String Pool)是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。在创建字符串时,JVM首先会检查字符串常量池,如果该字符串已经存在池中,则返回其引用,如果不存在,则创建此字符串并放入池中,并返回其引用。
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true
30.String s = new String(“abc”)会创建几个对象?
会创建 1 或 2 个字符串对象。
1、如果字符串常量池中不存在字符串对象“abc”的引用,那么它将首先在字符串常量池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。
示例代码(JDK 1.8):
String s1 = new String("abc");
2、如果字符串常量池中已存在字符串对象“abc”的引用,则只会在堆中创建 1 个字符串对象“abc”。
示例代码(JDK 1.8):
// 字符串常量池中已存在字符串对象“abc”的引用
String s1 = "abc";
// 下面这段代码只会在堆中创建 1 个字符串对象“abc”
String s2 = new String("abc");
31.String 类的常用方法有哪些?
- indexOf():返回指定字符的索引。
- charAt():返回指定索引处的字符。
- replace():字符串替换。
- trim():去除字符串两端空白。
- split():分割字符串,返回一个分割后的字符串数组。
- getBytes():返回字符串的 byte 类型数组。
- length():返回字符串长度。
- toLowerCase():将字符串转成小写字母。
- toUpperCase():将字符串转成大写字符。
- substring():截取字符串。
- equals():字符串比较。
32.为何JDK9要将String的底层实现由char[]改成byte[]?
主要是为了节约String占用的内存。
在大部分Java程序的堆内存中,String占用的空间最大,并且绝大多数String只有Latin-1字符,这些Latin-1字符只需要1个字节就够了。
而在JDK9之前,JVM因为String使用char数组存储,每个char占2个字节,所以即使字符串只需要1字节,它也要按照2字节进行分配,浪费了一半的内存空间。
到了JDK9之后,对于每个字符串,会先判断它是不是只有Latin-1字符,如果是,就按照1字节的规格进行分配内存,如果不是,就按照2字节的规格进行分配,这样便提高了内存使用率,同时GC次数也会减少,提升效率。
不过Latin-1编码集支持的字符有限,比如不支持中文字符,因此对于中文字符串,用的是UTF16编码(两个字节),所以用byte[]和char[]实现没什么区别。
33.try-catch-finally 如何使用?
try块:用于捕获异常。其后可接零个或多个catch块,如果没有catch块,则必须跟一个finally块。catch块:用于处理 try 捕获到的异常。finally块:无论是否捕获或处理异常,finally块里的语句都会被执行。当在try块或catch块中遇到return语句时,finally语句块将在方法返回之前被执行。
34.throw和throws的区别?
(1)throws用于方法头,表示的只是异常的申明,而throw用于方法内部,抛出的是异常对象
(2)throws可以一次性抛出多个异常,而throw只能一个
(3)throws抛出异常时,它的上级(调用者)也要申明抛出异常或者捕获,不然编译报错。而throw的话,可以不申明或不捕获(这是非常不负责任的方式)但编译器不会报错。
35.泛型
Java泛型是JDK 5中引⼊的⼀个新特性, 允许在定义类和接口的时候使⽤类型参数。声明的类型参数在使⽤时⽤具体的类型来替换。
泛型最⼤的好处是可以提⾼代码的复⽤性。以List接口为例,我们可以将String、 Integer等类型放⼊List中, 如不⽤泛型, 存放String类型要写⼀个List接口, 存放Integer要写另外⼀个List接口, 泛型可以很好的解决这个问题
36.什么是反射
动态获取的信息以及动态调用对象的方法的功能称为Java语言的反射机制。 通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。
37.反射应用场景
正是因为反射,你才能这么轻松地使用各种框架。像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。这些框架中也大量使用了动态代理,而动态代理的实现也依赖反射。
Java 中的一大利器 注解 的实现也用到了反射。 为什么你使用 Spring 的时候 ,一个@Component注解就声明了一个类为 Spring Bean 呢?为什么你通过一个 @Value注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?
这些都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。
38.反射的优缺点?
反射可以让我们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利。
不过,反射让我们在运行时有了分析操作类的能力的同时,也增加了安全问题,比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。另外,反射的性能也要稍差点,不过,对于框架来说实际是影响不大的。
反射的优点:
- 可扩展性 :应用程序可以利用全限定名创建可扩展对象的实例,来使用来自外部的用户自定义类。
- 类浏览器和可视化开发环境 :一个类浏览器需要可以枚举类的成员。可视化开发环境(如 IDE)可以从利用反射中可用的类型信息中受益,以帮助程序员编写正确的代码。
- 调试器和测试工具 : 调试器需要能够检查一个类里的私有成员。测试工具可以利用反射来自动地调用类里定义的可被发现的 API 定义,以确保一组测试中有较高的代码覆盖率。
反射的缺点:
尽管反射非常强大,但也不能滥用。如果一个功能可以不用反射完成,那么最好就不用。在我们使用反射技术时,下面几条内容应该牢记于心。
- 性能开销 :反射涉及了动态类型的解析,所以 JVM 无法对这些代码进行优化。因此,反射操作的效率要比那些非反射操作低得多。我们应该避免在经常被执行的代码或对性能要求很高的程序中使用反射。
- 安全限制 :使用反射技术要求程序必须在一个没有安全限制的环境中运行。如果一个程序必须在有安全限制的环境中运行,如 Applet,那么这就是个问题了。
- 内部暴露 :由于反射允许代码执行一些在正常情况下不被允许的操作(比如访问私有的属性和方法),所以使用反射可能会导致意料之外的副作用,这可能导致代码功能失调并破坏可移植性。反射代码破坏了抽象性,因此当平台发生改变的时候,代码的行为就有可能也随着变化。
39.代理
代理模式是一种比较好理解的设计模式。简单来说就是 我们使用代理对象来代替对真实对象(real object)的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。
代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。
40.静态代理
静态代理:代理类在编译阶段生成,在编译阶段将通知织入Java字节码中,也称编译时增强。
静态代理实现步骤:
- 定义一个接口及其实现类;
- 创建一个代理类同样实现这个接口
- 将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且可以在目标方法执行前后做一些自己想做的事情
静态代理优缺点:
优点:
通过静态代理,我们达到了功能增强的目的,而且没有侵入原代码,这是静态代理的一个优点。静态代理实现简单,且不侵入原代码。
缺点
1、 当需要代理多个类的时候,由于代理对象要实现与目标对象一致的接口,有两种方式:
- 只维护一个代理类,由这个代理类实现多个接口,但是这样就导致代理类过于庞大
- 新建多个代理类,每个目标对象对应一个代理类,但是这样会产生过多的代理类
2、 当接口需要增加、删除、修改方法的时候,目标对象与代理类都要同时修改,不易维护。
41.动态代理
相比于静态代理来说,动态代理更加灵活。我们不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类( CGLIB 动态代理机制)。
从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
Spring的AOP使用了两种动态代理,分别是JDK的动态代理,以及CGLib的动态代理。
1.JDK动态代理
如果目标类实现了接口,Spring AOP会选择使用JDK动态代理目标类。代理类根据目标类实现的接口动态生成,不需要自己编写,生成的动态代理类和目标类都实现相同的接口。JDK动态代理的核心是InvocationHandler接口和Proxy类。
缺点:目标类必须有实现的接口。如果某个类没有实现接口,那么这个类就不能用JDK动态代理。
2.CGLIB动态代理
通过继承实现。如果目标类没有实现接口,那么Spring AOP会选择使用CGLIB来动态代理目标类。CGLIB(Code Generation Library)可以在运行时动态生成类的字节码,动态创建目标类的子类对象,在子类对象中增强目标类。
优点:目标类不需要实现特定的接口,更加灵活。
缺点:CGLIB是通过继承的方式做的动态代理,因此如果某个类被标记为final,那么它是无法使用CGLIB做动态代理的。
3.静态代理和动态代理的对比
- 灵活性:动态代理更加灵活,不需要必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都创建一个代理类。另外,静态代理中,接口一旦新增加方法,目标对象和代理对象都要进行修改,这是非常麻烦的!
- JVM 层面:静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。而动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
42.如果有些字段不想进行序列化怎么办?(transient关键字)
Java语言的关键字,变量修饰符,如果用transient声明一个实例变量,当对象存储时,它的值不需要维持。
也就是说被transient修饰的成员变量,在序列化的时候其值会被忽略,在被反序列化后, transient 变量的值被设为初始值, 如 int 型的是 0,对象型的是 null。
43.实现序列化和反序列化为什么要实现 Serializable 接口?
一个对象序列化的接口,一个类只有实现了Serializable接口,它的对象才能被序列化。
如果一个对象既不是字符串、数组、枚举,而且也没有实现Serializable接口的话,在序列化时就会抛出NotSerializableException异常!Serializable接口也仅仅只是做一个标记用!它告诉代码只要是实现了Serializable接口的类都是可以被序列化的!
44.static 属性为什么不会被序列化?
因为序列化是针对对象而言的,而 static 属性优先于对象存在,随着类的加载而加载,所以不会被序列化.
45.Java IO 流了解吗?
IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。
Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
46.I/O 流为什么要分为字节流和字符流呢?
问题本质想问:不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?
首先明确字节流适用于任何场景,而且有字节缓冲流,能提高读取和输入的效率,也就是BufferedOutputStream/BufferedInputStream。其操作与字节流基本都一样。 而字符流是为了应对汉字出现的情况。在GBK中汉字占2个字节,在UTF-8中汉字占3个字节,所以我们通过字节流读取文件的时候一般都是逐个字节转换就会导致乱码,而手动去根据不同编码去拼接则不方便,所以有字符流。
47.常见关键字
abstract和interface
Java 中的抽象类(abstract class)和接口(interface)是两种常见的抽象化机制。
抽象类是指不能直接实例化的类,只能被用来派生其他类,它被设计成为仅包含可继承的方法、属性和变量。抽象类通常用于在类层次结构的根部建立一个适当的上下文语境。常见的抽象类特征如下:
- 抽象类可以包含成员变量和成员方法,也可以包含抽象方法以及非抽象方法。
- 抽象类必须通过关键字 abstract 进行声明,并且如果类中有一个或多个抽象方法,则该类必须被声明为抽象类。
- 抽象类可以被用来给其他类作为父类,抽象类的子类需要实现其中的所有抽象方法,否则子类也必须声明为抽象类。
接口和抽象类一样也是一种特殊类型的类,它仅声明了一组或者多组方法以及常量,可以被看作是一个对外公开的 API 契约。接口在 Java 中属于比抽象类更加抽象的概念。常见的接口特征如下:
- 接口中只能包含常量、方法的声明(而非实现)以及内部定义的其他类型(如枚举类型或内部类)。
- 在接口中声明方法时必须使用关键字 public 或者 default 修饰,并且通常不需要使用 abstract 关键词,因为接口中所有方法都默认为抽象方法。
- 一个类可以实现多个接口,从而得到多个抽象函数的实现,表示它强制要求 Java 类实现该接口的相关方法。
- 除了 java.lang.Object 之外,任何类都可以实现一个接口,而无需拓展任何类。
- 接口中只有常量,没有变量。声明一个常量时必须使用 static 关键字,一般再加上 final 关键字使其成为常量
final
- 基本数据类型用final修饰,则不能修改,是常量;对象引用用final修饰,则引用只能指向该对象,不能指向别的对象,但是对象本身可以修改。
- final修饰的方法不能被子类重写
- final修饰的类不能被继承。
this
this.属性名称指访问类中的成员变量,可以用来区分成员变量和局部变量。
super
super 关键字用于在子类中访问父类的变量和方法。
消息队列
1.什么是消息队列
消息指的是两个应用间传递的数据。数据的类型有很多种形式,可能只包含文本字符串,也可能包含嵌入对象。
“消息队列(Message Queue)”是在消息的传输过程中保存消息的容器。在消息队列中,通常有生产者和消费者两个角色。生产者只负责发送数据到消息队列,谁从消息队列中取出数据处理,他不管。消费者只负责从消息队列中取出数据处理,他不管这是谁发送的数据。

2.为什么使用消息队列
主要有三个作用:
- 解耦。如图所示。假设有系统B、C、D都需要系统A的数据,于是系统A调用三个方法发送数据到B、C、D。这时,系统D不需要了,那就需要在系统A把相关的代码删掉。假设这时有个新的系统E需要数据,这时系统A又要增加调用系统E的代码。为了降低这种强耦合,就可以使用MQ,系统A只需要把数据发送到MQ,其他系统如果需要数据,则从MQ中获取即可。

- 异步。如图所示。一个客户端请求发送进来,系统A会调用系统B、C、D三个系统,同步请求的话,响应时间就是系统A、B、C、D的总和,也就是800ms。如果使用MQ,系统A发送数据到MQ,然后就可以返回响应给客户端,不需要再等待系统B、C、D的响应,可以大大地提高性能。对于一些非必要的业务,比如发送短信,发送邮件等等,就可以采用MQ。

- 削峰。如图所示。这其实是MQ一个很重要的应用。假设系统A在某一段时间请求数暴增,有5000个请求发送过来,系统A这时就会发送5000条SQL进入MySQL进行执行,MySQL对于如此庞大的请求当然处理不过来,MySQL就会崩溃,导致系统瘫痪。如果使用MQ,系统A不再是直接发送SQL到数据库,而是把数据发送到MQ,MQ短时间积压数据是可以接受的,然后由消费者每次拉取2000条进行处理,防止在请求峰值时期大量的请求直接发送到MySQL导致系统崩溃。

3.使用了消息队列会有什么缺点
- 系统可用性降低。引入消息队列之后,如果消息队列挂了,可能会影响到业务系统的可用性。
- 系统复杂性增加。加入了消息队列,要多考虑很多方面的问题,比如:一致性问题、如何保证消息不被重复消费、如何保证消息可靠性传输等。
4.RabbitMQ和Kafka的对比
- 应用场景方面 **RabbitMQ:用于实时的,对可靠性要求较高的消息传递上。**性能极其好,延时很低,达到微秒级。社区成熟且活跃,提供完善的文档和插件支持 Kafka:用于处于活跃的流式数据,大数据量的数据处理上。提供超高的吞吐量,ms 级的延迟,极高的可用性以及可靠性。kafka 唯一的一点劣势是有可能消息重复消费,那么对数据准确性会造成极其轻微的影响,在大数据领域中以及日志采集中,这点轻微影响可以忽略这个特性天然适合大数据实时计算以及日志收集
- 架构模型方面
RabbitMQ:以broker为中心,有消息的确认机制。 Kafka:以consumer为中心,没有消息的确认机制。
- 吞吐量方面 RabbitMQ:支持消息的可靠的传递,支持事务,不支持批量操作,基于存储的可靠性的要求存储 可以采用内存或硬盘,吞吐量小。 Kafka:内部采用消息的批量处理,数据的存储和获取是本地磁盘顺序批量操作,消息处理的效率 高,吞吐量高。
- 集群负载均衡方面 RabbitMQ:本身不支持负载均衡,需要loadbalancer的支持。 Kafka:采用zookeeper对集群中的broker,consumer进行管理,可以注册topic到zookeeper 上,通过zookeeper的协调机制,producer保存对应的topic的broker信息,可以随机或者轮询发 送到broker上,producer可以基于语义指定分片,消息发送到broker的某个分片上。
5.MQ常用协议
- AMQP协议 AMQP即Advanced Message Queuing Protocol,一个提供统一消息服务的应用层标准高级消息队列协议,是应用层协议的一个开放标准,为面向消息的中间件设计。基于此协议的客户端与消息中间件可传递消息,并不受客户端/中间件不同产品,不同开发语言等条件的限制。优点:可靠、通用
- MQTT协议 MQTT(Message Queuing Telemetry Transport,消息队列遥测传输)是IBM开发的一个即时通讯协议,有可能成为物联网的重要组成部分。该协议支持所有平台,几乎可以把所有联网物品和外部连接起来,被用来当做传感器和致动器(比如通过Twitter让房屋联网)的通信协议。优点:格式简洁、占用带宽小、移动端通信、PUSH、嵌入式系统
- STOMP协议 STOMP(Streaming Text Orientated Message Protocol)是流文本定向消息协议,是一种为MOM(Message Oriented Middleware,面向消息的中间件)设计的简单文本协议。STOMP提供一个可互操作的连接格式,允许客户端与任意STOMP消息代理(Broker)进行交互。优点:命令模式(非topic/queue模式)
- XMPP协议 XMPP(可扩展消息处理现场协议,Extensible Messaging and Presence Protocol)是基于可扩展标记语言(XML)的协议,多用于即时消息(IM)以及在线现场探测。适用于服务器之间的准即时操作。核心是基于XML流传输,这个协议可能最终允许因特网用户向因特网上的其他任何人发送即时消息,即使其操作系统和浏览器不同。优点:通用公开、兼容性强、可扩展、安全性高,但XML编码格式占用带宽大
- 其他基于TCP/IP自定义的协议:有些特殊框架(如:redis、kafka、zeroMq等)根据自身需要未严格遵循MQ规范,而是基于TCP\IP自行封装了一套协议,通过网络socket接口进行传输,实现了MQ的功能。
6.MQ的通讯模式
-
点对点通讯:点对点方式是最为传统和常见的通讯方式,它支持一对一、一对多、多对多、多对一等多种配置方式,支持树状、网状等多种拓扑结构。
-
多点广播:MQ适用于不同类型的应用。其中重要的,也是正在发展中的是"多点广播"应用,即能够将消息发送到多个目标站点(Destination List)。可以使用一条MQ指令将单一消息发送到多个目标站点,并确保为每一站点可靠地提供信息。MQ不仅提供了多点广播的功能,而且还拥有智能消息分发功能,在将一条消息发送到同一系统上的多个用户时,MQ将消息的一个复制版本和该系统上接收者的名单发送到目标MQ系统。目标MQ系统在本地复制这些消息,并将它们发送到名单上的队列,从而尽可能减少网络的传输量。
-
发布/订阅(Publish/Subscribe)模式:发布/订阅功能使消息的分发可以突破目的队列地理指向的限制,使消息按照特定的主题甚至内容进行分发,用户或应用程序可以根据主题或内容接收到所需要的消息。发布/订阅功能使得发送者和接收者之间的耦合关系变得更为松散,发送者不必关心接收者的目的地址,而接收者也不必关心消息的发送地址,而只是根据消息的主题进行消息的收发。在MQ家族产品中,MQ Event Broker是专门用于使用发布/订阅技术进行数据通讯的产品,它支持基于队列和直接基于TCP/IP两种方式的发布和订阅。
-
集群(Cluster):为了简化点对点通讯模式中的系统配置,MQ提供 Cluster 的解决方案。集群类似于一个 域(Domain) ,集群内部的队列管理器之间通讯时,不需要两两之间建立消息通道,而是采用 Cluster 通道与其它成员通讯,从而大大简化了系统配置。此外,集群中的队列管理器之间能够自动进行负载均衡,当某一队列管理器出现故障时,其它队列管理器可以接管它的工作,从而大大提高系统的高可靠性
7.多线程异步和MQ的区别
- CPU消耗。多线程异步可能存在CPU竞争,而MQ不会消耗本机的CPU。
- MQ 方式实现异步是完全解耦的,适合于大型互联网项目。
- 削峰或者消息堆积能力。当业务系统处于高并发,MQ可以将消息堆积在Broker实例中,而多线程会创建大量线程,甚至触发拒绝策略。
- 使用MQ引入了中间件,增加了项目复杂度和运维难度。
总的来说,规模比较小的项目可以使用多线程实现异步,大项目建议使用MQ实现异步。
8.MQ幂等性怎么保证/怎么解决消息重复消费问题?
MQ(消息队列)的幂等性可以通过多种措施来保证,确保系统即使在出现重复消息的情况下也能保持数据的一致性和正确性。具体如下:
- 生成全局唯一的inner-msg-id:在消息发送端(上半场),MQ客户端应为每条消息生成一个全局唯一且业务无关的inner-msg-id。这个ID由MQ保证其唯一性,并且对业务透明。这样,即使在网络波动或超时导致的重发情况下,MQ服务器可以利用这个ID进行去重,确保相同的消息不会被处理多次。
- 业务层的去重处理:在消息的消费端(下半场),业务系统需要根据业务特点带入业务相关的biz-id。消费端通过这个biz-id来进行去重,以保证对于同一业务操作,即使收到了多次相同消息,也只会被处理一次。这要求业务系统具备去重逻辑,通常涉及到数据库的唯一约束或者分布式锁等机制来保证操作的幂等性。
- 利用乐观锁机制:借鉴数据库中乐观锁的思想,可以为涉及状态变更的业务数据添加版本号。每次更新前先检查版本号,只有在版本号匹配的情况下才执行更新操作,并更新版本号。这种方式可以防止并发下的重复操作影响数据一致性。
- 消息确认机制:MQ客户端在发送消息给MQ服务器后,等待服务器的确认(ACK)。如果确认丢失或超时,客户端会重发消息。为了避免因此导致的重复消息处理,MQ服务器内部需要有机制识别并丢弃重复的消息,例如利用上述提到的inner-msg-id进行去重。
- 消费端幂等设计:在消费端实现业务逻辑时,需要考虑到消息可能会被重复投递的情况。因此,消费端的业务处理逻辑应当设计成幂等的,即多次执行相同的操作不会对最终结果产生影响。
- 事务性消息:对于需要保证精确一次消费的场景,可以使用事务性消息。这意味着消息的发送和消费是原子性的,要么都成功,要么都失败。这种方式下,系统能更好地控制幂等性,但可能会牺牲一些性能。
- 限流和补偿机制:在高并发场景下,适当的限流策略可以避免系统因过载而产生错误的重复操作。同时,建立补偿机制可以在检测到异常操作时进行修正。
综上所述,保证MQ幂等性是一个系统性工程,既涉及到技术层面的改进,如ID生成、去重、乐观锁等,也需要业务逻辑层面的支持,如业务去重、幂等设计等。
9.MQ 中的消息过期失效了怎么办?
如果使用的是RabbitMQ的话,RabbtiMQ 是可以设置过期时间的(TTL)。如果消息在 Queue 中积压超过一定的时间就会被 RabbitMQ 给清理掉,这个数据就没了。这时的问题就不是数据会大量积压在 MQ 里,而是大量的数据会直接搞丢。这个情况下,就不是说要增加 Consumer 消费积压的消息,因为实际上没啥积压,而是丢了大量的消息。
我们可以采取一个方案,就是批量重导。就是大量积压的时候,直接将数据写到数据库,然后等过了高峰期以后将这批数据一点一点的查出来,然后重新灌入 MQ 里面去,把丢的数据给补回来。
10.消息队列里面拉取模式和推送模式的比较
在消息队列系统中,推送模式(Push)和拉取模式(Pull)是两种基本的消息传输机制。它们之间存在一定的区别。具体分析如下:
- 推送模式:**在MQ中也就是Broker收到消息后主动推送给Consumer的操作,叫做推模式。**推模式的实现是客户端会与服务端(Broker)建立长连接,当有消息时服务端会通过长连接通道将消息主动推送给客户端,这样客户端就能实时消费到最新的消息。优点: 实时性强,有消息立马推送给客户端,吞吐量大。客户端实现简单,只需要监听服务端的推送即可。缺点: 容易导致客户端发生消息堆积的情况,因为每个客户端的消费能力是不同的,如果简单粗暴的有消息就推送,就会会出现堆积情况。
- 拉取模式:在MQ中也就是客户端主动从服务器Broker端获取信息。很多拉模式都是基于长轮询来实现。长轮询就是客户端向服务端发起请求,如果此时有数据就直接返回,如果没有数据就保持连接,等到有数据时就直接返回。如果一直没有数据,超时后客户端再次发起请求,保持连接,这就是长轮询的实现原理。很多的开源框架都是用的这种方式,比如配置中心Apollo的推送优点: 不会造成客户端消息积压,消费完了再去拉取,主动权在自己手中。长轮询实现的拉模式实时性也能够保证。缺点: 实时性较差,针对于服务器端实时更新的信息,客户端难以获取实时信息;
推和拉都有各自的优势和劣势,不过目前主流的消息队列大部分都用的拉模式,比如RocketMQ,Kafka。
11.如果有100万消息堆积在MQ , 如何解决 ?
我在实际的开发中,没遇到过这种情况,不过,如果发生了堆积的问题,解决方案也所有很多的
第一:提高消费者的消费能力 ,可以使用多线程消费任务
第二:增加更多消费者,提高消费速度
使用工作队列模式, 设置多个消费者消费消费同一个队列中的消息
第三:扩大队列容积,提高堆积上限
可以使用RabbitMQ惰性队列,惰性队列的好处主要是
①接收到消息后直接存入磁盘而非内存
②消费者要消费消息时才会从磁盘中读取并加载到内存
③支持数百万条的消息存储
12.说一说生产者与消费者模式
所谓生产者-消费者问题,实际上主要是包含了两类线程。一种是生产者线程用于生产数据,另一种是消费者线程用于消费数据,为了解耦生产者和消费者的关系,通常会采用共享的数据区域,就像是一个仓库。生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为。而消费者只需要从共享数据区中去获取数据,就不再需要关心生产者的行为。但是,这个共享数据区域中应该具备这样的线程间并发协作的功能:
\1. 如果共享数据区已满的话,阻塞生产者继续生产数据放置入内;\2. 如果共享数据区为空的话,阻塞消费者继续消费数据。在Java语言中,实现生产者消费者问题时,可以采用三种方式:\1. 使用 Object 的 wait/notify 的消息通知机制;\2. 使用 Lock 的 Condition 的 await/signal 的消息通知机制;\3. 使用 BlockingQueue 实现。
13.什么是RabbitMQ
RabbitMQ 是一个在 AMQP(Advanced Message Queuing Protocol )基础上实现的整,体上是一个生产者与消费者模型,主要负责接收、存储和转发消息。
什么是AMQP
RabbitMQ 就是 AMQP 协议的 Erlang 的实现(当然 RabbitMQ 还支持 STOMP2、 MQTT3 等协议 ) AMQP 的模型架构 和 RabbitMQ 的模型架构是一样的,生产者将消息发送给交换器,交换器和队列绑定 。
RabbitMQ 中的交换器、交换器类型、队列、绑定、路由键等都是遵循的 AMQP 协议中相 应的概念。目前 RabbitMQ 最新版本默认支持的是 AMQP 0-9-1。
AMQP 协议的三层:
- Module Layer:协议最高层,主要定义了一些客户端调用的命令,客户端可以用这些命令实现自己的业务逻辑。
- Session Layer:中间层,主要负责客户端命令发送给服务器,再将服务端应答返回客户端,提供可靠性同步机制和错误处理。
- TransportLayer:最底层,主要传输二进制数据流,提供帧的处理、信道服用、错误检测和数据表示等。
AMQP 模型的三大组件:
- 交换器 (Exchange):消息代理服务器中用于把消息路由到队列的组件。
- 队列 (Queue):用来存储消息的数据结构,位于硬盘或内存中。
- 绑定 (Binding):一套规则,告知交换器消息应该将消息投递给哪个队列。
14.RabbitMQ 核心概念?
0.Message消息体
Message:由消息头和消息体组成。消息体是不透明的,而消息头则由一系列的可选属性组成,这些属性包括routing-key、priority、delivery-mode(是否持久性存储)等。
1.Producer(生产者) 和 Consumer(消费者)
- Producer(生产者) :生产消息的一方
- Consumer(消费者) :消费消息的一方
消息一般由 2 部分组成:消息头(或者说是标签 Label)和 消息体。消息体也可以称为 payLoad ,消息体是不透明的,而消息头则由一系列的可选属性组成,这些属性包括 routing-key(路由键)、priority(相对于其他消息的优先权)、delivery-mode(指出该消息可能需要持久性存储)等。生产者把消息交由 RabbitMQ 后,RabbitMQ 会根据消息头把消息发送给感兴趣的 Consumer(消费者)。
2.Exchange(交换器)
消息并不是直接被投递到 Queue(消息队列) 中的,中间还必须经过 Exchange(交换器) 这一层,Exchange(交换器) 会把我们的消息分配到对应的 Queue(消息队列) 中。
Exchange(交换器) 用来接收生产者发送的消息并将这些消息路由给服务器中的队列中,如果路由不到,或许会返回给 Producer(生产者) ,或许会被直接丢弃掉 。
RabbitMQ 的 Exchange(交换器) 有 4 种类型,不同的类型对应着不同的路由策略:direct(默认),fanout, topic, 和 headers,不同类型的 Exchange 转发消息的策略有所区别。
生产者将消息发给交换器的时候,一般会指定一个 RoutingKey(路由键),用来指定这个消息的路由规则,而这个 RoutingKey 需要与交换器类型和绑定键(BindingKey)联合使用才能最终生效。
RabbitMQ 中通过 Binding(绑定) 将 Exchange(交换器) 与 Queue(消息队列) 关联起来,在绑定的时候一般会指定一个 BindingKey(绑定建) ,这样 RabbitMQ 就知道如何正确将消息路由到队列了,如下图所示。一个绑定就是基于路由键将交换器和消息队列连接起来的路由规则,所以可以将交换器理解成一个由绑定构成的路由表。Exchange 和 Queue 的绑定可以是多对多的关系。
4种交换机
fanout 类型的 Exchange ,它会把所有发送到该 Exchange 的消息路由到所有与它绑定的 Queue 中,不需要做任何判断操作,所以 fanout 类型是所有的交换机类型里面速度最快的。anout 类型常用来广播消息。
**direct **类型的 Exchange 会把消息路由到那些 Bindingkey 与 RoutingKey 完全匹配的 Queue 中。常用在处理有优先级的任务,根据任务的优先级把消息发送到对应的队列,这样可以指派更多的资源去处理高优先级的队列。
topic 类型的交换器使用routing key和binding key进行模糊匹配,匹配成功则将消息发送到相应的队列。routing key和binding key都是句点号“. ”分隔的字符串,binding key中可以存在两种特殊字符“”与“##”,用于做模糊匹配,其中“”用于匹配一个单词,“##”用于匹配多个单词。
headers 类型的交换器不依赖于路由键的匹配规则来路由消息,而是根据发送的消息内容中的 headers 属性进行匹配。
3.Queue(消息队列)
Queue(消息队列) 用来保存消息直到发送给消费者。队列的特性是先进先出。一个消息可分发到一个或多个队列。
多个消费者可以订阅同一个队列,这时队列中的消息会被平均分摊(Round-Robin,即轮询)给多个消费者进行处理,而不是每个消费者都收到所有的消息并处理,这样避免消息被重复消费。
4.Broker(消息中间件的服务节点)
对于 RabbitMQ 来说,一个 RabbitMQ Broker 可以简单地看作一个 RabbitMQ 服务节点,或者 RabbitMQ 服务实例。大多数情况下也可以将一个 RabbitMQ Broker 看作一台 RabbitMQ 服务器。
消息服务器,作为server提供消息核心服务