八股文集合
八股文集合
计算机网络
介绍一下TCP/IP模型和OSI模型的区别
OSI和TCP/IP都是一个计算机进行通信的一种体系,而OSI模型师国际组织制定的一个标准体系,TCP/IP是实际网络通信中的实际的体系结构。
OSI自底向上分为物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。
TCP/IP分四层,每个层负责特定的网络功能。
自底向上是网络接口层,这层对应OSI的数据链路层和物理层,,这层负责物理传输媒介的定义和管理,比如有线的以太网传输,无线的WIFI传输,此外,网络接口层还管理硬件地址(MAC地址)的管理。
然后是网络层,这层对应OSI的网络层,主要协议是IP,他负责数据包的路由和转发,选择一个最佳路径来将数据包从源主机传送到目标主机。IP协议使用IP地址来进行逻辑地址寻址。
传输层对应OSI的传输层,这层负责应用与应用间的数据传输。主要的传输协议是TCP和UDP。TCP提供可靠的数据传输,UDP提供不可靠的实时传输。
应用层对应OSI的应用层、表示层和会话层,他用于网络上的各种应用提供服务,比如网页浏览,文件传输等。
以上就是osi和tcp/ip的区别了。
从输入URL到页面展示到底发生了什么
个人版:
首先是用户输入url当按下回车键的时候浏览器会先解析url
把url分成
1、协议:http还是https
2、域名:类似于www.baidu.com
3、端口:如果是http的话默认是80端口https默认是443端口
4、路径:/index.html类似这种访问域名服务器下的哪个路径
5、还有一些请求参数等类似于?id=1这种
当解析完url之后再进行DNS解析,就是将域名转换成对应的ip地址就称为DNS解析了,转换的流程是先从浏览器中查看是否有之前的浏览缓存记录,如果浏览器中没有就从操作系统的host文件中查找,如果hosts文件也没有就从ISP的DNS服务器中查找域名对应的IP地址,就是中国移动那种运营商的DNS服务器中查找,如果还没有就从根DNS服务器中查找,直到找到对应的ip地址。找到对应的IP地址后客户端和服务器会开始建立三次握手根服务器建立tcp连接,客户端向服务端发送同步报文的信息,类似于跟服务器说我想跟你建立连接了,然后服务器向客户端发送确认收到连接信息的响应信息,然后客户端再向服务器发送我收到连接建立成功的信息了,
- 第一次握手:客户端发送
SYN=1,随机生成初始序号ISN=x; - 第二次握手:服务器确认
SYN=1,并回复SYN=1, ACK=1,同时生成自己的 ISN=y; - 第三次握手:客户端发送
ACK=1,确认服务器的 ISN=y+1。
如果是https连接的话在建立完tcp连接后还需要
- 开始进行 TLS/SSL 握手,包括:
- 客户端发送
ClientHello(支持的加密套件、协议版本等) - 服务器回应
ServerHello+ 证书(包含公钥) - 双方协商密钥、验证身份、完成加密通道建立
- 加密通道建立成功后,才开始发送加密的 HTTP 请求
- 客户端发送
然后这样tcp连接就建立成功了,tcp建立成功后,客户端会向服务器发送请求,包含请求头,请求体等信息,然后服务端就会响应信息,包含响应头,响应体,还有cookie等信息,然后连接保持的字段有个keep-alive这个字段来确认保持连接的状态,燃弧响应后浏览器会解析响应信息,会先解析HTML文件然后形成DOM树,然后再解析CSS文件生成CSSOM,最后将两个图层合并就形成一个好看的页面了,之后再解析JS文件,解析JS文件的过程中会导致页面的重排和重绘,也会有一些外部的资源文件的加载,异步的请求等。
然后会连接可能保持也可能断开,如果数据传输完成了,断开的是四次挥手,客户端向服务器发送我要断开连接的消息,然后服务端向客户端发送我收到断开连接申请的信息,然后再发送我已经断开连接的信息,然后客户端向服务端发送我收到已经断开连接的信息了,以上就是所有。
- 在 HTTP 请求结束后,如果设置了
Connection: close,则立即断开; - 如果是
keep-alive,则 TCP 连接暂时保留,等待下一次复用; - 如果一段时间没有新请求(例如服务器设置 idle=60s),连接会被关闭。
HTTP请求报文和响应报文是怎样的,有哪些常见的字段?
HTTP报文分为请求报文和响应报文。
1、请求报文主要由请求行、请求头、空行和请求体构成
请求行包括:
方法:指定要执行的操作,如GET、POST、PUT、DELETE等
资源路径:请求的资源的URI(统一资源标识符)
HTTP版本:使用的HTTP协议版本如HTTP/1.1或HTTP/2.0
请求头字段比较多,通常包含:
Host:请求的服务器域名
Accept: 客户端能够处理的媒体类型
Accept-Encoding:客户端能够解码的内容编码
Authorization:用于认证的凭证信息,比如token数据
Content-Length:请求体长度
Content-Type:请求体的媒体类型
Cookie: 存储在客户端的cookie数据
If-None-Match:资源的ETag值,用于缓存控制
Connection:管理连接的选项比如keep-alive
空行是请求头和请求体之间的空行,主要用于分隔请求头和请求体。请求体通常用于POST和PUT请求,包含发送给服务器的JSON数据。
2、响应报文,HTTP响应报文是服务器向客户端返回的数据格式用于传达服务器对客户端请求的处理结果以及相关数据。通常包含状态行、响应头、空行、响应体。
状态行:HTTP版本、状态码和状态消息比如HTTP/1.1 200 OK
响应头也是以键值对的形式提供信息,一些常见的相应头包括
Content-Type:指定相应主题的媒体类型
Content-Length:指定响应主题的长度(字节数)。
Server:指定服务器的信息
Expires:响应的过期时间,之后内容被认为是过时的。
Etag:响应体的实体标签,用于缓存和条件请求。
Last-Modified:资源最后被修改的日期和时间。
Location:在重定向时指定新的资源位置
Set-Cookie:在响应中设置Cookie
Access-Control-Allow-Origin:夸资源共享CORS策略,指定哪些域可以访问资源。
空行是在响应头和响应体之间表示响应头结束。而响应体式服务端实际传输的数据,可以是文本、HTML页面、图片、视频等,也可能是空。
HTTP有哪些请求方式
- GET:请求指定的资源。
- POST:向指定资源提交数据进行处理请求(例如表单提交)。
- PUT:更新指定资源。
- DELETE:删除指定资源。
- HEAD:获取报文首部,不返回报文主体。
- OPTIONS:查询服务器支持的请求方法。
- PATCH:对资源进行部分更新。
GET请求和POST请求的区别
- 用途:GET请求通常用于获取数据,POST请求用于提交数据。
- 数据传输:GET请求将参数附加在URL之后,POST请求将数据放在请求体中。
- 安全性:GET请求由于参数暴露在URL中,安全性较低;POST请求参数不会暴露在URL中,相对更安全。
- 数据大小:GET请求受到URL长度限制,数据量有限;POST请求理论上没有大小限制。
- 幂等性:GET请求是幂等的,即多次执行相同的GET请求,资源的状态不会改变;POST请求不是幂等的,因为每次提交都可能改变资源状态。
- 缓存:GET请求可以被缓存,POST请求默认不会被缓存。
HTTP中常见的状态码有哪些
1xx:表示加载信息很少见
200 :表示客户端请求成功
201:创建了新资源
204:无内容,服务器成功处理请求,但未返回任何内容
301:永久重定向
302:临时重定向
304:请求的内容没有修改过,所以服务器返回响应时,不会返回网页内容,而是使用缓存。
401:请求需要身份验证
403:请求的对应资源禁止被访问
404:服务器无法找到对应的资源
500:服务器内部错误
503:服务不可用
什么是强缓存和协商缓存
强缓存和协商缓存是HTTP缓存机制的两种类型,他们用于减少服务器的负担和提高网页加载速度。
1、强缓存:客户端在没用向服务发送请求的情况下,直接从本地缓存中获取资源。
Expires强缓存:这个是用于设置强缓存时间,此时间范围内,从内存中读取缓存并返回。但是因为Expires判断强缓存过期的机制是获取本地时间戳,与之前拿到的资源文件中的Expires字段的时间做比较来判断是否需要对服务器发起请求。这里有一个巨大的漏洞,如果我本地时间不准怎么办?所以目前已经被废弃了。
Cachee-Control强缓存:目前使用的强缓存是通过HTTP响应头中的Cache-Control字段实现,通过max-age来告诉浏览器在指定时间内可以直接使用缓存数据,无需再次请求。
2、协商缓存:当强缓存失效时,浏览器会发送请求到服务器,通过ETag或Last-Modified等HTTP响应头与服务器进行验证,以确定资源是否被修改。如果资源未修改,服务器服务器返回304 Not Modified状态码,告知浏览器使用本地缓存。如果资源已经修改,则返回新的资源,浏览器更新本地缓存。这种凡是需要与服务器通信,但可以确保用户总是获取最新的内容。
如果是基于缓存的资源获取的话一个是基于Last-Modified一个是基于ETag。
- 如果是基于Last-Modified的协商缓存,Last-Modified是资源的最后修改时间,服务器在响应头部中返回。当客户端读取到Last-modified的时候,会在下次的请求标头中携带一个字段If-Modified-Since,而这个请求头中的If-Modified-Since就是服务器第一次修改时候给他的时间服务器比较请求中的If-Modified-Since值与当前资源的Last-Modified值,如果比对的结果是没有变化,表示资源未发生变化,返回状态码304 Not Modified,如果比对的结果说资源已经更新了,就会给浏览器正常资源,返回200状态。
但是这种协商缓存有两个缺点:
- 因为是更改文件修改时间来判断的,所以在文件内容本身不修改的情况下,依然有可能更新文件修改时间(比如修改文件名再改回来),这样有可能文件内容明明没修改但是缓存依然失效了。
- 当文件在极短时间内完成修改的时候(比如几百毫秒)。因为文件修改事件记录的最小单位是秒,所以,如果文件在几百毫秒内完成修改的话,文件修改时间不会改变,这样即使文件内容修改了,依然不会返回新的文件。
- 基于ETag的协商缓存:将原先协商缓存的比较时间戳的形式修改成了比较文件指纹(根据文件内容计算出的唯一哈希值)
- ETag是服务器为资源生成的唯一标识符(文件指纹),可以是根据文件内容计算出的哈希值,服务端将其和资源一起放回给客户端。
- 客户端在请求头部的If-None-Match字段中携带上次响应的ETag值。
- 服务器比较请求中的If-None-Match值与当前资源的ETag值,如果匹配,表示资源未发生变化,返回状态码
304 Not Modified。如果两个文件指纹不吻合,则说明文件被更改,那么将新的文件指纹重新存储到响应头的ETag中并返回给客户端
HTTPS和HTTP有哪些区别
两者的主要区别在于安全性和数据加密:
- 加密层:
HTTPS在HTTP的基础上增加了SSL/TLS协议作为加密层,确保数据传输的安全性。而HTTP数据传输是明文的,容易受到攻击。 - HTTP 连接建立相对简单, TCP 三次握手之后便可进行 HTTP 的报文传输。而 HTTPS 在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。
- 端口:
HTTPS通常使用端口443,而HTTP使用端口80。 - HTTPS 协议需要向 CA 申请数字证书,来保证服务器的身份是可信的。
HTTPS的工作原理(HTTPS建立连接的过程)
1、客户端向服务端发送HTTPS请求Client Hello发送过去,这里包含了客户端支持的TLS版本、加密套件以及生成了第一个随机数给服务端
2、服务端打招呼,发送了Server Hello发送给客户端(这里包含确认TLS版本、选择使用的加密套件,以及生成第2个随机数),然后还把证书和公钥发送给客户端
3、这时候客户端生成了第三个随机数,这里称为预主密钥,并用之前接受到的公钥进行加密发送给服务器
4、这时候服务端接收到公钥加密后的字符串之后,利用自己的密钥进行解密,这样客户端和服务端都知道了预主密钥了。
5、这时候客户端和服务端利用第一个随机数和第二个随机数和预主密钥通过加密算法计算得出会话密钥。后面传输的数据都是用会话密钥进行加密。后面的是对称加密的,前面是非对称加密的,也就是说知道会话密钥之后就能自动解密会话密钥加密的字符串了。
https真的安全吗?
假设服务器的网址是http://www.baidu.com那么如果用户输入了baidu.com的话这个是http请求然后会返回302再重定向到https://www.baidu.com那么这时候就会有问题了,假设有一个第三方的人,在302重定向的时候拦截你的请求然后返回给你一个很像的网站,这样就使用不了https了,那这种一般会给浏览器一个警告,警告用户你访问的不是一个https的网站以此来把这个责任推给用户,这个问题还解决不了。此外国际组织还设置了一个新的状态码307状态码,之前重定向是302,现在这个307状态码限制用户访问的location不能改变就比如访问baidu.com那么后面的网址不能更改,只能往前面加https://www.baidu.com来限制重定向到别的网站,但是还是解决不了之前那个拦截请求的问题。最终的解决方案是HSTS 预加载列表,类似于在浏览器设置白名单如果输入baidu.com,浏览器也会自动跳转到 HTTPS,不会发出明文请求。
HTTPS 主要基于SSL/TLS 协议,确保了数据传输的安全性和完整性, 其建立连接并传输数据的过程如下:
- 密钥交换:客户端发起HTTPS请求后,服务器会发送其公钥证书给客户端。
- 证书验证:客户端会验证服务器的证书是否由受信任的证书颁发机构(
CA)签发,并检查证书的有效性。 - 加密通信:一旦证书验证通过,客户端会生成一个随机的对称加密密钥,并使用服务器的公钥加密这个密钥,然后发送给服务器。
- 建立安全连接:服务器使用自己的私钥解密得到对称加密密钥,此时客户端和服务器都有了相同的密钥,可以进行加密和解密操作。
- 数据传输:使用对称加密密钥对所有传输的数据进行加密,确保数据在传输过程中的安全性。
- 完整性校验:SSL/TLS协议还包括消息完整性校验机制,如消息认证码,确保数据在传输过程中未被篡改。
- 结束连接:数据传输完成后,通信双方会进行会话密钥的销毁,以确保不会留下安全隐患。
TCP和UDP的区别
- TCP是面向连接的协议,需要在数据传输前建立连接;UDP是无连接的,不需要建立连接。
- TCP提供可靠的数据传输,保证数据包的顺序和完整性;UDP不保证数据包的顺序或完整性。
- TCP具有拥塞控制机制,可以根据网络状况调整数据传输速率;UDP没有拥塞控制,发送速率通常固定。
- TCP通过滑动窗口机制进行流量控制,避免接收方处理不过来;UDP没有流量控制。
- TCP能够检测并重传丢失或损坏的数据包;UDP不提供错误恢复机制。
- TCP有复杂的报文头部,包含序列号、确认号等信息;UDP的报文头部相对简单。
- 由于TCP的连接建立、数据校验和重传机制,其性能开销通常比UDP大;UDP由于简单,性能开销小。
- 适用场景:TCP适用于需要可靠传输的应用,如网页浏览、文件传输等;UDP适用于对实时性要求高的应用,如语音通话、视频会议等。
TCP连接如何确保可靠性
TCP通过差错控制(序列号、确认应答、数据校验)、超时重传、流量控制、拥塞控制等机制,确保了数据传输的可靠性和效率。
- 序列号:每个TCP段都有一个序列号,确保数据包的顺序正确。
- 数据校验:TCP使用校验和来检测数据在传输过程中是否出现错误,如果检测到错误,接收方会丢弃该数据包,并等待重传。
- 确认应答:接收方发送ACK确认收到的数据,如果发送方在一定时间内没有收到确认,会重新发送数据。
- 超时重传:发送方设置一个定时器,如果在定时器超时之前没有收到确认,发送方会重传数据。
- 流量控制:TCP通过滑动窗口机制进行流量控制,确保接收方能够处理发送方的数据量。
- 拥塞控制:TCP通过算法如慢启动、拥塞避免、快重传和快恢复等,来控制数据的发送速率,防止网络拥塞。
既然提到了拥塞控制,那你能说说说拥塞控制是怎么实现的嘛
TCP拥塞控制可以在网络出现拥塞时动态地调整数据传输的速率,以防止网络过载。TCP拥塞控制的主要机制包括以下几个方面:
- 慢启动(Slow Start): 初始阶段,TCP发送方会以较小的发送窗口开始传输数据。随着每次成功收到确认的数据,发送方逐渐增加发送窗口的大小,实现指数级的增长,这称为慢启动。这有助于在网络刚开始传输时谨慎地逐步增加速率,以避免引发拥塞。
- 拥塞避免(Congestion Avoidance): 一旦达到一定的阈值(通常是慢启动阈值),TCP发送方就会进入拥塞避免阶段。在拥塞避免阶段,发送方以线性增加的方式增加发送窗口的大小,而不再是指数级的增长。这有助于控制发送速率,以避免引起网络拥塞。
- 快速重传(Fast Retransmit): 如果发送方连续收到相同的确认,它会认为发生了数据包的丢失,并会快速重传未确认的数据包,而不必等待超时。这有助于更快地恢复由于拥塞引起的数据包丢失。
- 快速恢复(Fast Recovery): 在发生快速重传后,TCP进入快速恢复阶段。在这个阶段,发送方不会回到慢启动阶段,而是将慢启动阈值设置为当前窗口的一半,并将拥塞窗口大小设置为慢启动阈值加上已确认但未被快速重传的数据块的数量。这有助于更快地从拥塞中恢复。
TCP流量控制是怎么实现的?
流量控制就是让发送方发送速率不要过快,让接收方来得及接收。利用滑动窗口机制就可以实施流量控制,主要方法就是动态调整发送方和接收方之间数据传输速率。
- 滑动窗口大小: 在TCP通信中,每个TCP报文段都包含一个窗口字段,该字段指示发送方可以发送多少字节的数据而不等待确认。这个窗口大小是动态调整的。
- 接收方窗口大小: 接收方通过TCP报文中的窗口字段告诉发送方自己当前的可接收窗口大小。这是接收方缓冲区中还有多少可用空间。
- 流量控制的目标: 流量控制的目标是确保发送方不要发送超过接收方缓冲区容量的数据。如果接收方的缓冲区快满了,它会减小窗口大小,通知发送方暂停发送,以防止溢出。
- 动态调整: 发送方会根据接收方的窗口大小动态调整发送数据的速率。如果接收方的窗口大小增加,发送方可以加速发送数据。如果窗口大小减小,发送方将减缓发送数据的速率。
- 确认机制: 接收方会定期发送确认(ACK)报文,告知发送方已成功接收数据。这也与流量控制密切相关,因为接收方可以通过ACK报文中的窗口字段来通知发送方它的当前窗口大小。
UDP怎么实现可靠传输
UDP它不属于连接型协议,因而具有资源消耗小,处理速度快的优点,所以通常音频、视频和普通数据在传送时使用UDP较多,因为它们即使偶尔丢失一两个数据包,也不会对接收结果产生太大影响。传输层无法保证数据的可靠传输,只能通过应用层来实现了。实现的方式可以参照tcp可靠性传输的方式,只是实现不在传输层,实现转移到了应用层。关键在于两点,从应用层角度考虑:
(1)提供超时重传,能避免数据报丢失。
(2)提供确认序列号,可以对数据报进行确认和排序。
本端:首先在UDP数据报定义一个首部,首部包含确认序列号和时间戳,时间戳是用来计算RTT(数据报传输的往返时间),计算出合适的RTO(重传的超时时间)。然后以等-停的方式发送数据报,即收到对端的确认之后才发送下一个的数据报。当时间超时,本端重传数据报,同时RTO扩大为原来的两倍,重新开始计时。
对端:接受到一个数据报之后取下该数据报首部的时间戳和确认序列号,并添加本端的确认数据报首部之后发送给对段。根据此序列号对已收到的数据报进行排序并丢弃重复的数据报。
为什么是三次握手?
- 三次握手的过程
- 第一次握手:客户端向服务端发送一个SYN(同步序列号)报文,请求建立连接,客户端进入SYN_SENT状态
- 第二次握手:服务器收到SYN报文后,如果同意建立连接,则会发送一个SYN-ACK(同步确认)报文作为相应,同时进入SYN_RCVD状态
- 第三次握手:客户端收到服务器的SYN-ACK报文后,会发送一个ACK(确认)报文作为最终相应,之后客户端和服务器都进入ESTABLISHED状态,连接建立成功。
- 为什么需要三次握手
通过三次握手,客户端和服务端都能够确认对方的接收和发送能力。第一次握手确认了客户端到服务器的通道是开放的;
第二次握手确认了服务器到客户端的通道是开放的;
第三次握手则确认了客户端接收到服务器的确认,从而确保了双方的通道都是可用的。
如果是两次握手的话,现在客户端向服务端发送SYN报文(同步序列号),这时候如果网络不好导致SYN报文没发送过去,就会进行重传,这时候新的SYN包发过去了,如果同意连接服务端就会发送客户端SYN-ACK包进行同步确认,然后客户端接收到SYN-ACK报文后会发送ACK确认报文给服务端,这时候对于客户端来说认为是建立了一个TCP连接,然后这时候之前旧的SYN报文网络好了,又发送给了服务端,这时候服务端又发送ACK给客户端,但是客户端由于没有发送SYN报文就把这个ACK过滤掉了,但是对于服务端来说就认为建立了两个TCP连接,客户端和服务端的连接个数就不匹配了。所以两次握手不行。
如果是多次握手的话,会造成资源的浪费最终可以优化成三次握手。
为什么是四次挥手?
- 四次挥手的过程
- 第一次挥手:客户端发送一个FIN报文给服务端,表示自己要断开数据传送,报文中会制定一个序列号(seq=x)。然后,客户端进入FIN-WAIT-1状态。
- 第二次挥手:服务端收到FIN报文后,回复ACK报文给客户端,且把客户端的序列号值+1,作为ACK+1报文的序列号(seq=x+1)。然后,服务端进入CLOSE-WAIT``(seq=x+1)状态,客户端进入FIN-WAIT-2状态。
- 第三次挥手:服务端也要断开连接时,发送FIN报文给客户端,且指定一个序列号(seq=y+1),随后服务端进入LAST-ACK状态。
- 第四次挥手:客户端收到FIN报文后,发出ACK报文进行应答,并把服务端的序列号值+1作为ACK报文序列号(seq=y+2)。此时客户端进入TIME-WAIT状态。服务端在收到客户端的ACK报文后进入CLOSE状态。如果客户端等待2MSL没有收到回复,也就是等待一段时间后,才关闭连接。(这是由于如果一段时间后服务端没有收到客户端的ACK包就会重新发送FIN包,然后重新发送ACK包,刷新超时时间,确保服务端收到最后的ACK包)
- 为什么需要四次挥手
TCP是全双工通信,可以双向传输数据。任何一方都可以在数据传送结束后发出连接释放的通知,待对方确认后进入半关闭状态。 当另一方也没有数据再发送的时候,则发出连接释放通知,对方确认后才会完全关闭TCP 连接。因此两次挥手可以释放一端到另一端的TCP连接,完全释放连接一共需要四次挥手。
只有通过四次挥手,才可以确保双方都能接收到对方的最后一个数据段的确认,主动关闭方在发送完最后一个ACK后进入TIME-WAIT 状态,这是为了确保被动关闭方接收到最终的ACK ,如果被动关闭方没有接收到,它可以重发FIN 报文,主动关闭方可以再次发送ACK 。
而如果使用三次挥手,被动关闭方可能在发送最后一个数据段后立即关闭连接,而主动关闭方可能还没有接收到这个数据段的确认。
HTTP的Keep-Alive是什么?TCP 的 Keepalive 和 HTTP 的 Keep-Alive 是一个东西吗?
1、HTTP的Keep-Alive是叫HTTP长连接,因为由于TCP连接一次只能发送一次请求和响应,每次请求和响应都会重新连接,这种称为HTTP短连接,就会很耗费资源,这时候HTTP1.1之后就实现了HTTP的Keep-Alive实现了一使用同一个TCP连接来发送和接受多个HTTP的请求和响应,避免了多次连接建立和释放的开销,这是HTTP的长连接。通过设置HTTP头Connection:keep-alive来实现。
那这个长连接怎么关闭?是在接收端会设置一个定时器,如果超过了这个时间没再次发送请求,就会在HTTP头中设置Connection:close来关闭长连接。
2、而TCP的Keepalive是由TCP层(内核态)实现的,称为TCP的保活机制,是在TCP连接的时候,有一段时间没发送连接和请求了,通过TCP的保活机制来确认另一端是否还存活,是否有效。这个机制会发送一个小的探测包来检查连接是否仍然有效。
这里的TCP的keepalive不只是支持http,还支持ftp和smtp的,他是一个能力,类似于gc(内存管理机制,自动回收不再使用的内存空间)。
DNS的查询过程
DNS 用来将主机名和域名转换为IP地址, 其查询过程一般通过以下步骤:
- 本地DNS缓存检查:首先查询本地DNS缓存,如果缓存中有对应的IP地址,则直接返回结果。
- 如果本地缓存中没有,则会向本地的DNS服务器(通常由你的互联网服务提供商(ISP)提供, 比如中国移动)发送一个DNS查询请求。
- 如果本地DNS解析器有该域名的ip地址,就会直接返回,如果没有缓存该域名的解析记录,它会向根DNS服务器发出查询请求。根DNS服务器并不负责解析域名,但它能告诉本地DNS解析器应该向哪个顶级域(.com/.net/.org)的DNS服务器继续查询。
- 本地DNS解析器接着向指定的顶级域名DNS服务器发出查询请求。顶级域DNS服务器也不负责具体的域名解析,但它能告诉本地DNS解析器应该前往哪个权威DNS服务器查询下一步的信息。
- 本地DNS解析器最后向权威DNS服务器发送查询请求。 权威DNS服务器是负责存储特定域名和IP地址映射的服务器。当权威DNS服务器收到查询请求时,它会查找
"example.com"域名对应的IP地址,并将结果返回给本地DNS解析器。 - 本地DNS解析器将收到的IP地址返回给浏览器,并且还会将域名解析结果缓存在本地,以便下次访问时更快地响应。
- 浏览器发起连接: 本地DNS解析器已经将IP地址返回给您的计算机,您的浏览器可以使用该IP地址与目标服务器建立连接,开始获取网页内容。
CDN是什么,有什么作用?
CDN是一种分布式网络服务,通过将内容存储在分布式的服务器上,使用户可以从距离较近的服务器获取所需的内容,从而加速互联网上的内容传输。
- 就近访问:CDN 在全球范围内部署了多个服务器节点,用户的请求会被路由到距离最近的 CDN 节点,提供快速的内容访问。
- 内容缓存:CDN 节点会缓存静态资源,如图片、样式表、脚本等。当用户请求访问这些资源时,CDN 会首先检查是否已经缓存了该资源。如果有缓存,CDN 节点会直接返回缓存的资源,如果没有缓存所需资源,它会从源服务器(原始服务器)回源获取资源,并将资源缓存到节点中,以便以后的请求。通过缓存内容,减少了对原始服务器的请求,减轻了源站的负载。
- 可用性:即使某些节点出现问题,用户请求可以被重定向到其他健康的节点。
Cookie和Session是什么?区别是什么?
Cookie和 Session都用于管理用户的状态和身份, Cookie通过在客户端记录信息确定用户身份,Session通过在服务器端记录信息确定用户身份。
- Cookie
- 通常,服务器会将一个或多个
Cookie发送到用户浏览器,然后浏览器将这些Cookie存储在本地。 - 服务器在接收到来自客户端浏览器的请求之后,就能够通过分析存放于请求头的
Cookie得到客户端特有的信息,从而动态生成与该客户端相对应的内容。
- Session
客户端浏览器访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上。这就是Session。Session 主要用于维护用户登录状态、存储用户的临时数据和上下文信息等。服务器为每个用户分配一个唯一的Session ID,通常存储在Cookie中。
由于HTTP是无状态的所以需要Cokkie和Session来记录用户的状态信息,比如账号、用户昵称等。
(2) Cookie和Session的区别?
- 存储位置:
Cookie数据存储在用户的浏览器中,而Session数据存储在服务器上。 - 数据容量:
Cookie存储容量较小,一般为几 KB。Session存储容量较大,通常没有固定限制,取决于服务器的配置和资源。 - 安全性:由于
Cookie存储在用户浏览器中,因此可以被用户读取和篡改。相比之下,Session 数据存储在服务器上,更难被用户访问和修改。 - 生命周期:
Cookie可以设置过期时间,Session依赖于会话的持续时间或用户活动。 - 传输方式:
Cookie在每次HTTP请求中都会被自动发送到服务器,而Session ID通常通过Cookie或 URL 参数传递。
操作系统
进程和线程之间有什么区别
进程是资源分配和调度的基本单位。
线程是程序执行的最小单位,线程是进程的子任务,是进程内的执行单元。 一个进程至少有一个线程,一个进程可以运行多个线程,这些线程共享同一块内存。
资源开销:
- 进程:由于每个进程都有独立的内存空间,创建和销毁进程的开销较大。进程间切换需要保存和恢复整个进程的状态,因此上下文切换的开销较高。
- 线程:线程共享相同的内存空间,创建和销毁线程的开销较小。线程间切换只需要保存和恢复少量的线程上下文,因此上下文切换的开销较小。
通信与同步:
- 进程:由于进程间相互隔离,进程之间的通信需要使用一些特殊机制,如管道、消息队列、共享内存等。
- 线程:由于线程共享相同的内存空间,它们之间可以直接访问共享数据,线程间通信更加方便。
安全性:
- 进程:由于进程间相互隔离,一个进程的崩溃不会直接影响其他进程的稳定性。
- 线程:由于线程共享相同的内存空间,一个线程的错误可能会影响整个进程的稳定性。
并行和并发有什么区别
- 并行是在同一时刻执行多个任务。
- 并发是在相同的时间段内执行多个任务,任务可能交替执行,通过调度实现。
并行是指在同一时刻执行多个任务,这些任务可以同时进行,每个任务都在不同的处理单元(如多个CPU核心)上执行。在并行系统中,多个处理单元可以同时处理独立的子任务,从而加速整体任务的完成。
并发是指在相同的时间段内执行多个任务,这些任务可能不是同时发生的,而是交替执行,通过时间片轮转或者事件驱动的方式。并发通常与任务之间的交替执行和任务调度有关。
解释一下用户态和核心态,什么场景下,会发生内核态和用户态的切换?
- 用户态和内核态的区别
用户态和内核态是操作系统为了保护系统资源和实现权限控制而设计的两种不同的CPU运行级别,可以控制进程或程序对计算机硬件资源的访问权限和操作范围。
- 用户态:在用户态下,进程或程序只能访问受限的资源和执行受限的指令集,不能直接访问操作系统的核心部分,也不能直接访问硬件资源。
- 核心态:核心态是操作系统的特权级别,允许进程或程序执行特权指令和访问操作系统的核心部分。在核心态下,进程可以直接访问硬件资源,执行系统调用,管理内存、文件系统等操作。
- 在什么场景下,会发生内核态和用户态的切换
- 系统调用:当用户程序需要请求操作系统提供的服务时,会通过系统调用进入内核态。
- 异常:当程序执行过程中出现错误或异常情况时,CPU会自动切换到内核态,以便操作系统能够处理这些异常。
- 中断:外部设备(如键盘、鼠标、磁盘等)产生的中断信号会使CPU从用户态切换到内核态。操作系统会处理这些中断,执行相应的中断处理程序,然后再将CPU切换回用户态。
什么是死锁,如何避免?
死锁是当系统中有两个或者多个进程在进程中,因为争夺资源而造成的情况,当每个进程都持有一定的资源并且等待其他进程释放他们所需要的资源,如果这个资源被其他进程占有并且不释放,就会导致死锁。
死锁只有同时满足下面四个条件才会发生:
- 互斥条件:一个进程占用了某个资源时,其他进程无法同时占用该资源。
- 请求保持条件:一个进程因为请求资源而阻塞的时候,不会释放自己的资源。
- 不可剥夺条件:资源不能被强制性地从一个进程中剥夺,只能由持有者自愿释放。
- 循环等待条件:多个进程之间形成一个循环等待资源的链,每个进程都在等待下一个进程所占有的资源。
避免死锁:通过破坏死锁的四个必要条件之一来预防死锁。比如破坏循环等待条件,让所有进程按照相同的顺序请求资源。 检测死锁:通过检测系统中的资源分配情况来判断是否存在死锁。例如,可以使用资源分配图或银行家算法进行检测。 解除死锁:一旦检测到死锁存在,可以采取一些措施来解除死锁。例如,可以通过抢占资源、终止某些进程或进行资源回收等方式来解除死锁。
介绍一下几种经典的锁
- 互斥锁:互斥锁是一种最常见的锁类型,用于实现互斥访问共享资源。在任何时刻,只有一个线程可以持有互斥锁,其他线程必须等待直到锁被释放。这确保了同一时间只有一个线程能够访问被保护的资源。
- 自旋锁:自旋锁是一种基于忙等待的锁,即线程在尝试获取锁时会不断轮询,直到锁被释放。
其他的锁都是基于这两个锁的
- 读写锁:允许多个线程同时读共享资源,只允许一个线程进行写操作。分为读(共享)和写(排他)两种状态。
- 悲观锁:认为多线程同时修改共享资源的概率比较高,所以访问共享资源时候要上锁
- 乐观锁:先不管,修改了共享资源再说,如果出现同时修改的情况,再放弃本次操作。
讲一讲我理解的虚拟内存
虚拟内存是指在每一个进程创建加载的过程中,会分配一个连续虚拟地址空间,它不是真实存在的,\而是*通过映射与实际物理地址空间*对应,这样就可以使每个进程看起来都有自己独立的连续地址空间,并允许程序访问比物理内存RAM更大的地址空间, 每个程序都可以认为它拥有足够的内存来运行。
需要虚拟内存的原因:
- 内存扩展: 虚拟内存使得每个程序都可以使用比实际可用内存更多的内存,从而允许运行更大的程序或处理更多的数据。
- 内存隔离:虚拟内存还提供了进程之间的内存隔离。每个进程都有自己的虚拟地址空间,因此一个进程无法直接访问另一个进程的内存。
- 物理内存管理:虚拟内存允许操作系统动态地将数据和程序的部分加载到物理内存中,以满足当前正在运行的进程的需求。当物理内存不足时,操作系统可以将不常用的数据或程序暂时移到硬盘上,从而释放内存,以便其他进程使用。
- 页面交换:当物理内存不足时,操作系统可以将一部分数据从物理内存写入到硬盘的虚拟内存中,这个过程被称为页面交换。当需要时,数据可以再次从虚拟内存中加载到物理内存中。这样可以保证系统可以继续运行,尽管物理内存有限。
- 内存映射文件:虚拟内存还可以用于将文件映射到内存中,这使得文件的读取和写入可以像访问内存一样高效。
你知道的线程同步的方式有哪些
线程同步机制是指在多线程编程中,为了保证线程之间的互不干扰,而采用的一种机制。常见的线程同步机制有以下几种:
- 互斥锁:互斥锁是最常见的线程同步机制。它允许只有一个线程同时访问被保护的临界区(共享资源)
- 条件变量:条件变量用于线程间通信,允许一个线程等待某个条件满足,而其他线程可以发出信号通知等待线程。通常与互斥锁一起使用。
- 读写锁: 读写锁允许多个线程同时读取共享资源,但只允许一个线程写入资源。
- 信号量:用于控制多个线程对共享资源进行访问的工具。
有哪些页面置换算法
页面置换算法(Page Replacement Algorithm)是操作系统用于管理内存的一种关键技术,特别是在虚拟内存系统中。当程序运行时,其所需的数据和指令可能不会全部同时驻留在物理内存中,而是根据需要在磁盘和内存之间进行交换。当一个进程访问的页面当前不在内存中(即发生缺页中断),操作系统需要从磁盘加载所需的页面到内存中。如果此时内存已经满了,则必须选择一个或多个已经在内存中的页面将其移出以腾出空间,这个过程就是页面置换。
当一个进程访问的数据不在内存中,需要从磁盘加载进来,但如果内存已满,就必须先替换掉一个内存中的页面,这个替换过程就叫做页面置换。
常见页面置换算法有最佳置换算法(OPT)、先进先出(FIFO)、最近最久未使用算法(LRU)、时钟算法(Clock) 等。
- 最近最久未使用算法
LRU:LRU算法基于页面的使用历史,通过选择最长时间未被使用的页面进行置换。 - 先进先出
FIFO算法:也就是最先进入内存的页面最先被置换出去。 - 最不经常使用
LFU:淘汰访问次数最少的页面,考虑页面的访问频率。 - 时钟算法
CLOCK:Clock算法的核心思想是通过使用一个指针(称为时钟指针)在环形链表上遍历,检查页面是否被访问过, 当需要进行页面置换时,Clock算法从时钟指针的位置开始遍历环形链表。 如果当前页面的访问位为0,表示该页面最久未被访问,可以选择进行置换。将访问位设置为1,继续遍历下一个页面。 如果当前页面的访问位为1,表示该页面最近被访问过,它仍然处于活跃状态。将访问位设置为0,并继续遍历下一个页面如果遍历过程中找到一个访问位为0的页面,那么选择该页面进行置换。 - 最佳置换算法: 该算法根据未来的页面访问情况,选择最长时间内不会被访问到的页面进行置换。那么就有一个问题了,未来要访问什么页面,操作系统怎么知道的呢?操作系统当然不会知道,所以这种算法只是一种理想情况下的置换算法,通常是无法实现的。
熟悉哪些Linux命令
1、文件操作:
- ls:列出目录内容。
- cd:进入指定目录
- pwd:显示当前工作目录
- cp:复制文件或目录
- mv:移动或重命名文件
- rm:删除文件或目录
- touch:创建空文件或更新文件时间戳
2、文件内容查看
- cat:查看文件内容
- head:查看文件的前几行
- tail:查看文件的后几行,常用于查看日志文件
3、文件编辑
- vi或vim:强大的文本编辑器
4、权限管理
- chmod:更改文件或目录的访问权限
- chown:更改文件或目录的所有者或所属组
5、磁盘管理
- df:查看磁盘空间使用情况。
6、网络管理
- ifconfig或ip addr :查看和配置网络接口
- ping:查看网络状态和统计信息
- netstat:查看网络状态和统计信息。
- ssh:安全远程登录
7、进程管理
- ps:查看当前运行的进程。
- kill:杀掉某进程。
8、软件包管理(根据Linux发行版不同,命令可能有所不同)
- apt-get(Ubuntu):安装、更新和删除软件包
- npm install(CentOS的)
Linux中如何查看一个进程,如何杀死一个进程,如何查看某个端口有没有被占用
- 查看进程:用ps命令查看当前运行的进程,比如ps aux可以列出所有进程及其详细信息。
- 杀死进程:首先用ps或top命令找到进程的pid(进程ID)。然后用kill命令加上进程ID来结束进程,例如kill -9 PID. -9是强制杀死进程的信号。
- 查看端口占用:使用Isof -i: 端口号 可以查看占用特定端口的进程。或者用netstat -tulnp | grep 端口号,这会显示监听在该端口的服务及其进程ID。
说一下select\poll\epoll
什么是I/O多路复用?
简单来说,就是:
一个线程同时监听多个网络连接(文件描述符),一旦某个连接有数据可读或可写,就通知程序去处理它。
这在服务器开发中非常有用,比如一个 Web 服务器要同时处理成千上万个客户端的连接请求。
- select:这个是一个最古老的I/O多路复用机制,他可以监视多个文件描述符的可读、可写和错误状态。然而,但是它的效率可能随着监视的文件描述符的数量的增加而降低。
- poll:poll是select的一种改进,他使用轮询方式来检查多个文件描述符的状态,避免了select中文件描述符数量有限的问题。但对于大量的文件描述符,poll的性能也可能变得不足够高校。
- epoll:epoll是Linux特有的I/O多路复用机制,相较于select和poll,它在处理大量文件描述符时更加高校。epoll使用时间通知的方式,只有在文件描述符就绪时才会通知应用程序,而不需要应用程序的轮询。
总结:select是最早的 I/O 多路复用技术,但受到文件描述符数量和效率方面的限制。poll克服了文件描述符数量的限制,但仍然存在一定的效率问题。epoll是一种高效的I/O多路复用技术,尤其适用于高并发场景,但它仅在 Linux 平台上可用。一般来说,epoll 的效率是要比 select 和 poll 高的,但是对于活动连接较多的时候,由于回调函数触发的很频繁,其效率不一定比 select 和 poll 高。所以 epoll 在连接数量很多,但活动连接较小的情况性能体现的比较明显。
| 技术 | 类比 |
|---|---|
| Blocking I/O | 每个快递员都直接送到你家 |
| select/poll | 你每天去快递柜一个一个找有没有你的包裹 |
| epoll | 包裹到了,快递柜自动发短信提醒你取件 |
MySQL
MySQL中的数据排序是怎么实现的
首先,排序过程中,如果排序字段命中索引,会直接用索引来排序,比如sort by id,这种,直接输出聚集索引即可,因为聚集索引本来就是有序的,这种排序性能最高。
如果没有命中的话会升级为filesort,那么这时候如果需要排序的数据比较少,则在sort_buffer中进行排序,如果数据量大,就需要利用磁盘临时文件来排序了,性能就比较差了,具体是通过sort_buffer_size来控制排序缓冲池的大小。
那么如果在内存中排序也就是在排序缓冲池中排序的话,一般是单路排序或者双路排序。
然后有一个叫max_length_for_sort_data参数,默认是4096字节,如果select列的数据长度超过他,则MySQL会采用row_id排序,即把主键和排序字段防止到sort_buffer中排序。
通过b排序完之后,在通过id回表查询得到其他需要查询的列,最终将结果集返回给客户端。
如果没有超过max_length_for_sort_data,就会进行单路排序,就是将select的字段都放入sort_buffer中,排序后直接返回结果集,减少了回表操作,效率更高。
那么如果比sort_buffer_size还要大就会需要磁盘文件临时排序了,一般会使用归并排序,就是先把数据分为多分文件,然后单独对文件进行排序,最后在合并成一个有序的大文件。
MySQL的Change Buffer是什么?有什么用?
Change Buffer:更改缓冲区 针对于非唯一二级索引页,在执行DML(增删改)语句时,如果这些数据Page没有在缓冲池中,不会直接操作磁盘,而会将数据变更 存在 更改缓冲区中,在未来数据被读取时,再将数据合并恢复到缓冲池中,再将合并后的数据刷新到磁盘中。
Change Buffer的意义是什么?
和聚集索引不同,二级索引通常是非唯一的,并且以相对随机的顺序插入二级索引,同样,删除和更新可能会影响索引树中不相邻的二级索引页,如果每一次都操作磁盘,会造成大量的磁盘IO,有了ChangeBuffer之后,我们可以在缓冲池中进行合并处理,减少磁盘IO
为什么InnoDB存储引擎选择使用B+树索引结构
二叉树?二叉树顺序插入的时候会形成链表。这时候利用红黑树,红黑树层级多,所以用B+树。
那B树呢?B树叶子节点和非叶子节点会存储数据和指针,但是由于索引是存在页里面的,页空间有限,所以一个页里面存储的指针就会少,层级就会高,那么B+树由于只在叶子节点存储数据,其他都存储指针,相对来说层级就会少,搜索效率高。
那hash索引,不支持范围排序和范围匹配所以最终使用B+树索引结构
一条SQL查询语句是如何执行的?
- 连接器:连接器负责跟客户端建立连接、获取权限、维持和管理连接。
- 查询缓存(8.0后被移出):MYSQL拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执行过的语句及其结果可能会以key-value对的形式,被直接缓存在内存中。
- 分析器:你输入的是由多个字符串和空格组成的一条SQL语句,MYSQL需要识别出里面的字符串分别是什么,代表什么。
- 优化器:优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序。
- 执行器:MYSQL通过分析器知道了你要作什么,通过优化器知道了该怎么做,于是就进入了执行器阶段,开始执行语句。
事务的四大特性 有哪些?
事务的四大特性通常被称为ACID特性
- 原子性:确保事务的所有操作要么全部执行成功,要么全部失败回滚 ,不存在部分策划给你共的情况。
- 一致性:事务在执行前后,数据库从一个一致性状态转变到另一个一致性状态。(例子:银行转账,A=100,B=100,那么一致性就是A=50,B=150,而不是中间态A=50,B=100)
- 隔离性:多个事务并发执行时,每个事务都应该被隔离开来,一个事务的执行不应该影响其他事务的执行。
- 持久性:一旦事务被提交,它对数据库的改变就是永久性的,即使在系统故障或崩溃后也能够保持。
数据库的事务隔离级别有哪些?
读未提交:
- 允许一个事务读取另一个事务尚未提交的数据修改。
- 最低的隔离级别,存在脏读,不可重复读和幻读的问题。
读已提交
- 一个事务只能读取已经提交的数据。其他事物的修改在该事务提交之后才可见。
- 解决了脏读问题,但仍可能出现不可重复读和幻读。
可重复读
- 事务执行期间,多次读取同一数据会得到相同的结果,即在事务开始和结束之间,其他事务对数据的修改不可见。
- 解决了不可重复读问题,但仍可能出现幻读
序列化
- 最高的隔离级别,确保事物之间的并发执行效果与串行执行的效果相同,即不会出现脏读,不可重复读和幻读。
这里顺便讲一下
脏读,不可重复读和幻读是什么
脏读就是读到了脏数据,举个例子A和B转账,A一开始查询有100块然后B给A转账转了50,然后B转账失败了,但是这时候A读取数据读到了150,B回滚了,所以其实A并没有150还是100,所以读到了脏数据。
不可重复读还是刚刚的例子,A在一个事务里面查询两次,第一次查询有100然后B给A转账成功并且提交了事务,这时候A的事务还没有提交,A再次查询发现有150了,这就是不可重复读,因为B的事务影响到了A的事务,所以违反了原子性。
幻读是针对数据个数的,A在一个事务中有两个查询,第一次查询商品个数有50个商品,然后B在数据库中增加了一个商品,并且B提交了事务,然后A的事务中再次查询商品个数,发现有51条数据,这就叫幻读。
不可重复读针对的是update,幻读针对的是insert或者delete.
MySQL的 执行引擎有哪些
MySQL的执行引擎主要负责查询的执行和数据的存储, 其执行引擎主要有MyISAM、InnoDB、Memory 等。
InnoDB引擎提供了对事务ACID的支持,还提供了行级锁和外键的约束,是目前MySQL的默认存储引擎,适用于需要事务和高并发的应用。MyISAM引擎是早期的默认存储引擎,支持全文索引,但是不支持事务,也不支持行级锁和外键约束,适用于快速读取且数据量不大的场景。Memory就是将数据放在内存中,访问速度快,但数据在数据库服务器重启后会丢失。
行级锁的工作原理
当一个事务需要修改某一行数据时,InnoDB会在这行记录上加上锁。这意味着:
- 其他事务可以同时访问和修改同一张表中的不同行,因为它们不会相互干扰。
- 只有当两个或更多的事务试图访问或修改相同的行时,这些事务之间才会产生竞争,导致其中一个事务必须等待另一个事务完成并释放锁之后才能继续执行。
行级锁的好处
- 提高并发性:由于锁定的是单个行而非整个表,因此多个事务可以同时对不同的行进行读写操作,大大提高了系统的并发处理能力。
- 减少冲突:在高并发环境下,如果使用表级锁,那么只要有一个事务正在修改表中的任何一行,其他所有尝试访问该表的操作都将被阻塞。而行级锁只会影响特定行上的操作,减少了不必要的等待时间。
- 增强隔离性:行级锁有助于实现更高级别的事务隔离,比如可重复读(Repeatable Read),这有助于防止脏读、不可重复读等问题。
InnoDB中的行级锁类
- 共享锁(Shared Locks, S锁):允许事务读取一行数据,但阻止其他事务对该行进行修改。多个事务可以同时获得同一行的共享锁。
- 排他锁(Exclusive Locks, X锁):允许事务更新或删除一行数据,并且阻止其他任何事务获取该行的任何类型的锁(包括共享锁)。也就是说,一旦一行被某个事务加上了排他锁,其他的事务就不能再对该行加任何锁。
MySQL为什么使用B+树来作索引
B+树是一个B树的变种,提供了高效的数据检索、插入、删除和范围查询性能。
- 单点查询:B 树进行单个索引查询时,时间代价为O(logn)。从平均时间代价来看,会比 B+ 树稍快一些。但是 B 树的查询波动会比较大,因为每个节点既存索引又存记录,所以有时候访问到了非叶子节点就可以找到索引,而有时需要访问到叶子节点才能找到索引。B+树的非叶子节点不存放实际的记录数据,仅存放索引,所以数据量相同的情况下,相比存储即存索引又存记录的 B 树,B+树的非叶子节点可以存放更多的索引,因此 B+ 树可以比 B 树更「矮胖」,查询底层节点的磁盘 I/O次数会更少。
- 插入和删除效率:B+ 树有大量的冗余节点,删除一个节点的时候,可以直接从叶子节点中删除,甚至可以不动非叶子节点,删除非常快。B+ 树的插入也是一样,有冗余节点,插入可能存在节点的分裂(如果节点饱和),但是最多只涉及树的一条路径。B 树没有冗余节点,删除节点的时候非常复杂,可能涉及复杂的树的变形。
- 范围查询:B+ 树所有叶子节点间有一个链表进行连接,而 B 树没有将所有叶子节点用链表串联起来的结构,因此只能通过树的遍历来完成范围查询,这会涉及多个节点的磁盘 I/O 操作,范围查询效率不如 B+ 树。存在大量范围检索的场景,适合使用 B+树,比如数据库。而对于大量的单个索引查询的场景,可以考虑 B 树,比如nosql的MongoDB。
说一下索引失效的场景
索引失效意味着查询操作不能有效利用索引进行数据检索,从而导致性能下降,下面一些场景会发生索引失效。
- 使用OR条件:当使用OR连接多个条件,并且每个条件用到不同的索引列时,索引可能不会被使用。
- 使用非等值查询:当使用
!=或<>操作符时,索引可能不会被使用,特别是当非等值条件在WHERE子句的开始部分时。 - 对列进行类型转换: 如果在查询中对列进行类型转换,例如将字符列转换为数字或日期,索引可能会失效。
- 使用LIKE语句:以通配符
%开头的LIKE查询会导致索引失效。 - 函数或表达式:在列上使用函数或表达式作为查询条件,通常会导致索引失效。
- 表连接中的列类型不匹配: 如果在连接操作中涉及的两个表的列类型不匹配,索引可能会失效。例如,一个表的列是整数,另一个表的列是字符,连接时可能会导致索引失效。
补充知识:
索引(Index)在数据库管理系统(DBMS)中是一种用于加速数据检索的数据结构。它就像是书本末尾的索引部分,可以帮助你快速找到所需的信息,而不需要逐页阅读整本书。同样地,在数据库中,索引允许数据库系统更快地定位到特定的数据行,而不必扫描整个表。
索引的基本概念
- 目的:提高查询效率,减少查找所需的时间。
- 实现方式:通过创建一个指向数据库表中数据记录的结构化参考,使得数据库引擎可以迅速定位到所需的数据行。
- 代价:虽然索引能加快读取操作的速度,但它们也会占用额外的存储空间,并且在进行插入、更新或删除操作时需要维护索引结构,这可能会稍微降低这些操作的速度。
索引的工作原理
当你为一个表中的某个列(或多个列组合)创建索引时,数据库会为该列的所有值构建一种特殊的数据结构(如B树、哈希等)。当执行查询时,数据库首先使用这个索引来确定哪些行满足查询条件,然后直接访问那些具体的行,而不是遍历整个表。
示例
假设有一个包含数百万条记录的employees表,其中有一列是last_name。如果你经常根据姓氏搜索员工信息,那么在这个列上创建一个索引将会非常有用。
1 | CREATE INDEX idx_last_name ON employees(last_name); |
有了这个索引后,当你执行如下查询:
1 | SELECT * FROM employees WHERE last_name = 'Smith'; |
数据库就可以利用idx_last_name索引来快速定位所有姓氏为’Smith’的记录,而不需要检查每一行。
索引类型
单列索引:基于单个字段创建的索引。
复合索引(多列索引):基于两个或更多字段创建的索引。例如,可以在
first_name和last_name两列上创建一个复合索引。唯一索引:确保索引列中的所有值都是唯一的。这对于主键来说特别重要。
全文索引:用于全文搜索,适用于文本字段,能够支持复杂的文本匹配查询。
聚集索引与非聚集索引
:
- 聚集索引:决定了表中数据的实际物理存储顺序。每个表只能有一个聚集索引。
- 非聚集索引:不改变数据的物理存储顺序,而是创建一个独立的索引结构来指向实际的数据位置。一个表可以有多个非聚集索引。
使用索引的好处
- 加速查询:显著提高
SELECT语句的性能。 - 优化排序和分组:对于
ORDER BY和GROUP BY子句也有帮助。 - 约束支持:比如唯一性约束可以通过唯一索引来实现。
使用索引的注意事项
尽管索引有很多优点,但也有一些潜在的问题需要注意:
- 增加存储开销:每个索引都需要额外的磁盘空间。
- 影响写入性能:每次插入、更新或删除数据时,相关的索引也需要被更新,这会带来一定的性能损耗。
- 过度索引的风险:并非所有的查询都需要索引,过多的索引不仅浪费资源,还可能降低整体性能。
因此,在设计数据库时,应该根据具体的业务需求和查询模式来合理选择和设计索引。
MySQL二级索引有MVCC快照吗?
MySQL和Redis的区别是什么
- Redis基于键值对,支持多种数据结构,而MySQL是一种关系型数据库,使用表来组织数据。
- Redis将数据存在内存中,通过持久化机制将数据写入磁盘,MySQL通常将数据存储在磁盘上。
- Redis不使用SQL,而是使用自己的命令集,MySQL使用SQL来进行数据查询和操作。
- Redis以高性能和低延迟为目标,适用于读多写少的应用场景,MySQL适用于需要支持复杂查询、事务处理、拥有大规模数据集的场景。
Redis更适合处理高速、高并发的数据访问,以及需要复杂数据结构和功能的场景,在实际应用中,很多系统会同时使用MySQL和Redis。
Redis有什么优缺点?为什么用Redis查询会比较快
1、Redis有什么优缺点?
Redis是一个基于内存的数据库,读写速度非常快,通常被用作,缓存,消息队列,分布式锁,和键值对存储数据库。它支持多种数据结构,如字符串、哈希表、列表、集合、有序集合等,Redis还提供了分布式特性,可以将数据分布在多个节点上,以提高可扩展性和可用性。但是Redis受限于物理内存的大小,不适合存储超大量数据,并且需要大量内存,相比磁盘存储成本更高。
2、为什么Redis查询快
- 基于内存操作:传统的磁盘文件操作相比减少了IO,提高了操作的速度。
- 高效的数据结构:Redis专门设计了STRING、LIST、HASH等高效的数据结构,依赖各种数据结构提升了读写的效率。
- 单线程:单线程操作省去了上下文切换带来的开销和CPU的消耗,同时不存在资源竞争,避免了死锁现象的发生。
- I/O多路复用:采用I/O多路复用机制同时监听多个Socket,根据Socket上的事件来选择对应的时间处理器进行处理。
Redis的数据类型有哪些?
edis 常见的五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)及 Zset(sorted set:有序集合)。
- 字符串
STRING:存储字符串数据,最基本的数据类型。 - 哈希表
HASH:存储字段和值的映射,用于存储对象。 - 列表
LIST:存储有序的字符串元素列表。 - 集合
SET:存储唯一的字符串元素,无序。 - 有序集合
ZSET:类似于集合,但每个元素都关联一个分数,可以按分数进行排序。
Redis版本更新,又增加了几种数据类型,
BitMap: 存储位的数据结构,可以用于处理一些位运算操作。HyperLogLog:用于基数估算的数据结构,用于统计元素的唯一数量。GEO: 存储地理位置信息的数据结构。Stream:专门为消息队列设计的数据类型。
Redis是单线程还是多线程的?为什么?
Redis在其传统的实现中是单线程的(网络请求模块使用单线程进行处理,其他模块仍用多个线程),这意味着它使用单个线程来处理所有的客户端请求。这样的设计选择有几个关键原因:
- 简化模型:单线程模型简化了并发控制,避免了复杂的多线程同步问题。
- 性能优化:由于大多数操作是内存中的,单线程避免了线程间切换和锁竞争的开销。
- 原子性保证:单线程执行确保了操作的原子性,简化了事务和持久化的实现。
- 顺序执行:单线程保证了请求的顺序执行。
但是Redis的单线程模型并不意味着它在处理客户端请求时不高效。实际上,由于其操作主要在内存中进行,Redis能够提供极高的吞吐量和低延迟的响应。
此外,Redis 6.0 引入了多线程的功能,用来处理网络I/O这部分,充分利用CPU资源,减少网络I/O阻塞带来的性能损耗。
Redis的持久化机制有哪些?
- AOF 日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里;
- RDB 快照:将某一时刻的内存数据,以二进制的方式写入磁盘;
- 混合持久化方式:Redis 4.0 新增的方式,集成了 AOF 和 RBD 的优点;
介绍一下Redis缓存雪崩和缓存穿透,如何解决这些问题?
- 缓存雪崩是指在某个时间点,大量缓存同时失效,导致请求直接访问数据库或其他后端系统,增加了系统负载。
对于缓存雪崩,可以通过合理设置缓存的过期时间,分散缓存失效时间点,或者采用永不过期的策略,再结合定期更新缓存。
- 缓存击穿是指一个缓存中不存在但是数据库中存在的数据,当有大量并发请求查询这个缓存不存在的数据时,导致请求直接访问数据库,增加数据库的负载。典型的场景是当一个缓存中的数据过期或被清理,而此时有大量请求访问这个缓存中不存在的数据,导致大量请求直接访问底层存储系统。
对于缓存击穿,可以采用互斥锁(例如分布式锁)或者在查询数据库前先检查缓存是否存在,如果不存在再允许查询数据库,并将查询结果写入缓存。
- 缓存穿透是指查询一个在缓存和数据库都不存在的数据,这个数据始终无法被缓存,导致每次请求都直接访问数据库,增加数据库的负载。典型的情况是攻击者可能通过构造不存在的 key 大量访问缓存,导致对数据库的频繁查询。
对于缓存穿透,可以采用布隆过滤器等手段来过滤掉恶意请求,或者在查询数据库前先进行参数的合法性校验。
如何保证数据库和缓存的一致性
Cache Aside
- 原理:先从缓存中读取数据,如果没有就再去数据库里面读数据,然后把数据放回缓存中,如果缓存中可以找到数据就直接返回数据;更新数据的时候先把数据持久化到数据库,然后再让缓存失效。
- 问题:假如有两个操作一个更新一个查询,第一个操作先更新数据库,还没来及删除缓存,查询操作可能拿到的就是旧的数据;更新操作马上让缓存失效了,所以后续的查询可以保证数据的一致性;还有的问题就是有一个是读操作没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,也会造成脏数据。
- 可行性:出现上述问题的概率其实非常低,需要同时达成读缓存时缓存失效并且有并发写的操作。数据库读写要比缓存慢得多,所以读操作在写操作之前进入数据库,并且在写操作之后更新,概率比较低。
Read/Write Through
- 原理:Read/Write Through原理是把更新数据库(Repository)的操作由缓存代理,应用认为后端是一个单一的存储,而存储自己维护自己的缓存。
- Read Through:就是在查询操作中更新缓存,也就是说,当缓存失效的时候,Cache Aside策略是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对调用方是透明的。
- Write Through:当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由缓存自己更新数据库(这是一个同步操作)。
Write Behind
- 原理:在更新数据的时候,只更新缓存,不更新数据库,而缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作非常快,带来的问题是,数据不是强一致性的,而且可能会丢。
- 第二步失效问题:这种可能性极小,缓存删除只是标记一下无效的软删除,可以看作不耗时间。如果会出问题,一般程序在写数据库那里就没有完成:故意在写完数据库后,休眠很长时间再来删除缓存。
一、回顾三种常见缓存更新策略
- Cache Aside(旁路缓存)
原理:
查询:先查缓存,命中则返回;未命中则查数据库,并写入缓存。
更新:先更新数据库,再删除缓存。
存在的问题:
并发读写问题
- A 读取缓存未命中 → 查询数据库(旧数据)
- B 更新数据库 + 删除缓存
- A 写入缓存 → 缓存中是旧数据,导致不一致
解决方案建议:
- 使用 分布式锁 或 延迟双删
- 设置一个合理的缓存过期时间,容忍短时间不一致
Read/Write Through(穿透式缓存)
原理
- 应用只和缓存交互,缓存负责和数据库打交道。
- Read Through:缓存失效时自动从 DB 加载
- Write Through:更新缓存后,缓存同步更新 DB
优点:
- 对应用层透明,简化逻辑
- 更容易实现一致性
缺点
- 实现复杂,需要中间件支持(如 Caffeine、Ehcache、Redis Modules 等)
Write Behind(异步回写)
原理:
- 只更新缓存,缓存异步批量写入数据库
- 极大提高性能,但牺牲一致性
优点:
- 高性能,适用于日志、计数器等场景
缺点
- 数据可能丢失(如果缓存宕机)
- 不适合金融、订单等强一致性场景
二、你提到的“第二步失效问题”详细解释
❓“这种可能性极小,缓存删除只是标记一下无效的软删除,可以看作不耗时间。如果会出问题,一般程序在写数据库那里就没有完成:故意在写完数据库后,休眠很长时间再来删除缓存。解释一下再”
问题背景
你描述的是这样一种场景:
1 | 线程 A: |
这就是典型的 “延迟双删” 场景下可能出现的缓存脏读问题。
如何解决这个问题?
方法一:延迟双删(Delay Double Delete)
1 | // 第一次删除缓存 |
作用
- 防止在更新数据库和删除缓存之间有请求读到旧缓存
- 第二次删除确保缓存一定被清空,下次读操作重新加载最新数据
方法二:使用分布式锁
1 | try { |
作用
- 保证更新数据库和删除缓存的操作是原子的
- 避免并发请求拿到脏数据
方法三:基于 Binlog 的异步更新
通过监听数据库的 binlog 日志,在数据变更后触发缓存更新或删除。
优点
- 完全解耦业务逻辑和缓存维护
- 可以保证最终一致性
- 避免因网络、代码错误等导致缓存不一致
缺点
- 架构复杂,需引入 Canal、Debezium 等组件
- 成本较高,适合大型系统
三、不同场景下的推荐策
| 场景 | 推荐策略 | 是否强一致性 | 说明 |
|---|---|---|---|
| 读多写少,容忍短时不一致 | Cache Aside + 延迟双删 | 最终一致 | 最常用,简单有效 |
| 对一致性要求高 | Cache Aside + 分布式锁 / Write Through | 强一致 | 性能略差,但安全 |
| 高吞吐量,可容忍短暂丢失 | Write Behind | 最终一致 | 适合非关键数据 |
| 大型分布式系统 | 基于 Binlog 异步更新 | 最终一致 | 架构复杂但稳定 |
四、总结一句话
没有完美的缓存一致性解决方案,只有根据业务场景选择最合适的策略。
如果你的应用是电商、金融类系统,建议优先考虑 Cache Aside + 延迟双删 + 分布式锁; 如果是日志、统计类系统,可以用 Write Behind 提高性能。
Java
String\StringBuffer\StringBuilder的区别
String, StringBuilder, 和 StringBuffer 都是 Java 中用于处理字符串的类,但它们之间有一些重要的区别:
- 不可变性
String内部的字符数组使用final修饰,为不可变的字符串类,每当我们对 String 对象进行改变时,实际上都会创建一个新的 String 对象,旧的 String 对象会被 JVM 回收, 容易触发 gc,引起系统内存抖动。StringBuilder和StringBuffer是可变的。即它们都允许修改字符串,而不会创建新的对象
- 线程安全
String:由于 String 是不可变的,所以是线程安全的。StringBuffer中的方法均使用**synchronized**关键字修饰,线程安全。- 而
StringBuilder线程不安全。
- 性能
对于复杂的字符串操作(例如多次的拼接,插入,删除),StringBuilder 和 StringBuffer 效率高于 String,因为它们是可变的,不需要创建新的对象。
- 使用场景
String: 字符串不经常变化的场景中可以使用String类,例如常量的声明、少量的变量运算。StringBuilder: 在频繁进行字符串运算(如拼接、替换、和删除等),并且运行在单线程的环境中,则可以考虑使用,如SQL语句的拼装、JSON封装等。StringBuffer: 在频繁进行字符串运算(如拼接、替换、删除等),并且运行在多线程环境中,则可以考虑使用StringBuffer,例如XML解析、HTTP参数解析和封装。
接口和抽象类的区别
- 从定义上来说
- 接口是一种抽象类型,它定义了一组方法,但没有实现任何方法的具体代码。接口中的方法默认是抽象的,且接口中只能包含常量(
static final变量)和抽象方法,不能包含成员变量。 - 抽象类是一个类,可以包含抽象方法和具体方法,也可以包含成员变量和常量。抽象类中的抽象方法是没有实现的方法,而具体方法则包含实现代码。抽象类不能直接实例化,通常需要子类继承并实现其中的抽象方法。
- 继承
- 接口支持多继承,一个类可以实现多个接口。
Java中不支持多继承,一个类只能继承一个抽象类。如果一个类已经继承了一个抽象类,就不能再继承其他类。
- 构造器
- 接口不能包含构造器,因为接口不能被实例化。类实现接口时,必须实现接口中定义的所有方法。
- 抽象类可以包含构造器,用于初始化抽象类的子类实例。
- 访问修饰符
- 接口中的方法默认是
public abstract的。接口中的变量默认是public static final的。 - 抽象类中的抽象方法默认是
protected的,具体方法的访问修饰符可以是public、protected或private。
- 实现限制
- 类可以同时实现多个接口,接口中的方法默认为抽象方法,不包含方法体。实现接口时必须要实现这些方法。
- 一个类只能继承一个抽象类,继承抽象类的子类必须提供抽象类中定义的所有抽象方法的实现。
- 设计目的
- 接口用于定义规范,强调“行为”或“能力”。
- 抽象类用于代码复用,提供通用的实现或基础功能,并且可以包含方法的具体实现。
java常见的异常类有哪些
Java的异常都是派生于Throwable类的一个实例,所有的异常都是由Throwable继承而来的。- 异常又分为
RuntimeException和其他异常: - 由程序错误导致的异常属于
RuntimeException - 而程序本身没有问题,但由于像
I/O错误这类问题导致的异常属于其他异常。
- 由程序错误导致的异常属于
- 运行时异常
RuntimeException:
顾名思义,运行时才可能抛出的异常,编译器不会处理此类异常。比如数组索引越界 ArrayIndexOutOfBoundsException、使用的对象为空 NullPointException、强制类型转换错误 ClassCastException、除 0 等等。出现了运行时异常,一般是程序的逻辑有问题,是程序自身的问题而非外部因素。
- 其他异常:
Exception 中除了运行时异常之外的,都属于其他异常。也可以称之为编译时异常,这部分异常编译器要求必须处置。这部分异常常常是因为外部运行环境导致,因为程序可能运行在各种环境中,如打开一个不存在的文件,此时抛出 FileNotFoundException。编译器要求Java程序必须捕获或声明所有的编译时异常,强制要求程序为可能出现的异常做准备工作。
说一说Java面向对象的三大特性
Java面向对象编程的三大特性是封装、继承和多态:
- 封装:封装是将对象的数据(属性)和行为(方法)结合在一起,并隐藏内部的实现细节,只暴露出一个可以被外界访问的接口。通常使用关键字
private、protected、public等来定义访问权限,以实现封装。 - 继承:允许一个类(子类)继承另一个类(父类)的属性和方法的机制。子类可以重用父类的代码,并且可以通过添加新的方法或修改(重写)已有的方法来扩展或改进功能,提高了代码的可重用性和可扩展性。
Java支持单继承,一个类只能直接继承一个父类。 - 多态:多态是指允许不同类的对象对同一消息做出响应,但具体的行为会根据对象的实际类型而有所不同。这通常通过方法重载和重写实现。
说一说对java多态的理解
- 当把一个子类对象直接赋给父类引用变量,而运行时调用该引用变量的方法时,其方法行为总是表现出子类方法的行为特征,而不是父类方法的行为特征,这就可能出现:相同类型的变量、调用同一个方法时呈现出多种不同的行为特征,这就是多态。
- 多态有两种形式:编译时多态(静态多态)和运行时多态(动态多态)。
- 编译时多态:指在编译阶段,编译器就能够确定调用哪个方法,这是通过方法的重载来实现的。编译器在编译时根据方法的参数数量、类型或顺序来选择调用合适的方法。
- 运行时多态:在程序运行时,根据实际对象的类型来确定调用的方法,这是通过方法的重写来实现的。运行时多态主要依赖于对象的实际类型,而不是引用类型。
Java重写和重载的区别
Java中的重载和重写是实现多态的两种不同方式。
方法的重载是编译时多态,指的是在同一个类中,可以有多个方法具有相同的名称,但是它们的参数列表不同(参数的类型、个数、顺序),可以有不同的返回类型和访问修饰符,通过静态绑定(编译时决定)实现。
方法的重写是运行时多态,指的是在子类中重新定义父类中已经定义的方法,方法名、参数列表和返回类型都必须相同。重写的方法的访问级别不能低于被重写的父类方法,虚拟机在运行时根据对象的实际类型来确定调用哪个方法。
总结来说,重载关注的是方法的多样性,允许同一个类中存在多个同名方法;而重写关注的是方法的一致性,允许子类提供特定于其自己的行为实现。
final关键字有什么用
final 就是不可变的意思,可以修饰变量、方法和类。
- 修饰类:
final修饰的类不可被继承,是最终类. - 修饰方法: 明确禁止该方法在子类中被覆盖的情况下才将方法设置为
final - 修饰变量:
final修饰 基本数据类型的变量,其数值一旦在初始化之后便不能更改, 称为常量;final修饰 引用类型的变量,则在对其初始化之后便不能再让其指向另一个对象。虽然不能再指向其他对象,但是它指向的对象的内容是可变的。
==和equals的区别
在Java中,==和equals方法用来比较对象,但它们在语义和使用上仍有一定的差别:
==运算符:对于原始数据类型,==比较的是值是否相等,对于引用类型,==比较的是两个引用是否指向内存中的同一位置,即它们是否是同一个对象。equals是一个方法,定义在Object类中,默认情况下,equals()方法比较的是对象的引用,与==类似。但在子类中通常被重写,比如String、 Integer等,已经重写了equals()方法以便比较对象的内容是否相等。- 一般来说,是使用
==比较对象的引用(内存地址),用equals()比较对象的内容。 - 需要注意的是,在重写
equals方法时,应同时重写hashCode方法,以保持equals和hashCode的一致性。
Java的集合类有哪些,哪些是线程安全的,哪些是线程不安全的
Java中的集合类主要由Collection和Map这两个接口派生而出,其中Collection 接口又派生出三个子接口,分别是Set、List、Queue。所有的Java集合类,都是Set、List、Queue、Map
这四个接口的实现类
List接口: 有序集合,允许重复元素。常见的实现类有ArrayList、LinkedList等。Set接口:不允许重复元素的集合。常见的实现类有HashSet、LinkedHashSet、TreeSet等。Queue接口: 用于表示队列的数据结构。 常见的实现类有LinkedList、PriorityQueue等。Map接口: 表示键值对的集合。常见的实现类有HashMap、LinkedHashMap、TreeMap 等。
- 线程不安全的集合类
ArrayList、LinkedList、HashSet、HashMap:这些集合类是非线程安全的。在多线程环境中,如果没有适当的同步措施,对这些集合的并发操作可能导致不确定的结果。TreeMap、TreeSet: 虽然TreeMap和、TreeSet是有序的集合,但它们也是非线程安全的。
- 线程安全的集合类
Vector:类似于ArrayList, 它的方法都是同步的,因此是线程安全的。然而,它相对较重,不够灵活,现在通常建议使用ArrayList。HashTable:类似于HashMap,但它是线程安全的,通过同步整个对象实现。但它的使用已经不太推荐,通常建议使用HashMap。ConcurrentHashMap:提供更好的并发性能,通过锁分离技术实现线程安全。Collections.synchronizedList、Collections.synchronizedSet、Collections.synchronizedMap: 这些方法可以将非线程安全的集合包装成线程安全的集合。
ArrayList 和 Array 有什么区别?ArrayList 和 LinkedList 的区别是什么?
- ArrayList vs Array
ArrayList是动态数组的实现,而Array是固定长度的数组。ArrayList提供了更多的功能,比如自动扩容、增加和删除元素等,Array则没有这些功能。Array可以直接存储基本类型数据,也可以存储对象。ArrayList中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如Integer、Double等)- 在随机访问时,
Array由于其连续内存存储,性能通常优于ArrayList。
- ArrayList vs LinkedList
ArrayList基于动态数组,LinkedList基于双向链表。- 随机访问:
ArrayList在随机访问时性能更好,而LinkedList访问元素时效率较低,因为需要从头开始或从尾开始通过链接遍历,时间复杂度为O(n)。 - 删除/添加元素:在
ArrayList末尾添加元素通常很快,但在ArrayList中间或开始插入或删除元素时,可能需要移动后续元素,时间复杂度为O(n)。而LinkedList添加和删除元素时性能更佳, 只需改变节点的引用。 - 扩容:当容量不足以容纳更多元素时,
ArrayList会扩容,这个过程涉及创建新数组和复制旧数组的内容,有一定的开销。 - 使用场景:选择
ArrayList或者LinkedList通常取决于你的Java应用是否需要频繁的随机访问操作,还是更多的插入和删除操作。
总结来说,ArrayList和Array的主要区别在于动态大小和功能,而ArrayList和LinkedList的主要区别在于底层数据结构和它们对元素操作的性能特点。选择使用哪一个取决于具体的应用场景和性能需求。
ArrayList的扩容机制
ArrayList扩容的本质就是计算出新的扩容数组的size后实例化,并将原有数组内容复制到新数组中去。(不是原数组,而是新数组然后给予数组对象地址)。- 当创建一个ArrayList对象时,它会分配一定的初始容量,通常为10。
- 当ArrayList中的元素数量达到当前容量时,ArrayList会自动增加其容量。
ArrayList扩容的计算是在一个grow()方法里面,grow方法先尝试将数组扩大为原数组的1.5倍。(默新容量=旧容量右移一位(相当于除于2)在加上旧容量) - 若新的容量满足需求,会调用一个
Arrays.copyof方法, 将所有的元素从旧数组复制到新数组中,这个方法是真正实现扩容的步骤。如果扩容后的新容量还是不满足需求,那新容量大小为当前所需的容量加1。
HashMap的底层实现是什么?
在JDK 1.8之前,HashMap 由数组和链表组成,当发生哈希冲突时,多个元素会以链表的形式存储在同一个数组位置。JDK 1.8开始引入了红黑树,当链表长度超过一定阈值(TREEIFY_THRESHOLD,默认为8)时,链表会转换成红黑树,以提高搜索效率。
- 为什么链表大小超过 8 会自动转为红黑树,小于 6 时重新变成链表
根据泊松分布 ,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分 之一,所以将7作为一个分水岭, 等于 7 的时候不转换,大于等于 8 的时候才转换成红黑树,小于等于 6 的时候转化为链表。
为什么要引入红黑树,而不是其他树?
是因为红黑树具有以下几点性质
- 不追求绝对的平衡,插入或删除节点时,允许有一定的局部不平衡,相较于AVL树的绝对自平衡,减少了很多性能开销;
- 红黑树是一种自平衡的二叉搜索树,因此插入和删除操作的时间复杂度都是O(log n)
HashMap读和写的时间复杂度是多少?
- 读:
- 在最佳情况下:读操作的时间复杂度是 O(1)
- 最坏情况下:发生哈希冲突,链表为O(n), 红黑树为O(log n)。
- 写:
- 理想情况:与读操作类似,如果哈希函数分布均匀,写操作的时间复杂度也是 O(1)。
- 处理哈希冲突:如果发生哈希冲突,需要在链表尾部添加新元素或将链表转换为红黑树。在这种情况下,写操作的时间复杂度可能达到 O(n),其中 n 是链表的长度。
解决Hash冲突的方法有哪些?HashMap 是如何解决 hash 冲突的
解决哈希冲突的方法主要有以下两种:
- 链地址法:在数组的每个位置维护一个链表。当发生冲突时,新的元素会被添加到链表的尾部。
- 开放寻址法:当发生冲突时,根据某种探测算法在哈希表中寻找下一个空闲位置来存储元素。
Java中的 HashMap使用链地址法解决hash冲突。
HashMap和put方法流程
- 判断键值对数组是否为空或为null,否则执行resize()进行扩容;
- 根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向步骤6,如果table[i]不为空,转向步骤3;
- 判断数组的首个元素是否和key一样,如果相同直接覆盖value,否则转向4,这里的相同指的是hashCode以及equals;
- 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向5;
- 遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
- 插入成功后,判断实际存在的键值对数量size是否超多了最大容量,如果超过,进行扩容。
HashMap 的扩容机制
- Java1.7 扩容机制
- 生成新数组;
- 遍历老数组中的每个位置上的链表上的每个元素;
- 获取每个元素的key,并基于新数组长度,计算出每个元素在新数组中的下标;
- 将元素添加到新数组中去;
- 所有元素转移完之后,将新数组赋值给HashMap对象的table属性。
- JDK1.8版本扩容
- 生成新数组;
- 遍历老数组中的每个位置上的链表或红黑树;
- 如果是链表,则直接将链表中的每个元素重新计算下标,并添加到新数组中去;
- 如果是红黑树,则先遍历红黑树,先计算出红黑树中每个元素对应在新数组中的下标位置;
- 统计每个下标位置的元素个数;
- 如果该位置下的元素个数超过了8,则生成一个新的红黑树,并将根节点添加到新数组的对应位置;
- 如果该位置下的元素个数没有超过8,那么则生成一个链表,并将链表的头节点添加到新数组的对应位置;
- 所有元素转移完了之后,将新数组赋值给HashMap对象的table属性。
HashMap为什么是线程不安全的?如何实现线程安全
(1) 为什么是线程不安全的
主要原因是它的操作不是原子的,即在多个线程同时进行读写操作时,可能会导致数据不一致性或抛出异常.
- 并发修改:当一个线程进行写操作(插入、删除等)时,另一个线程进行读操作,可能会导致读取到不一致的数据,甚至抛出
ConcurrentModificationException异常。 - 非原子性操作:
HashMap的一些操作不是原子的,例如,检查是否存在某个键、获取某个键对应的值等,这样在多线程环境中可能发生竞态条件。
(2)如何实现线程安全
为了实现线程安全的 HashMap,有以下几种方式:
- 使用
Collections.synchronizedMap()方法:可以通过Collections.synchronizedMap()方法创建一个线程安全的HashMap,该方法返回一个同步的Map包装器,使得所有对Map的操作都是同步的。 - 使用
ConcurrentHashMap:ConcurrentHashMap是专门设计用于多线程环境的哈希表实现。它使用分段锁机制,允许多个线程同时进行读操作,提高并发性能。 - 使用锁机制:可以在自定义的
HashMap操作中使用显式的锁(例如ReentrantLock)来保证线程安全。
concurrentHashMap 如何保证线程安全
ConcurrentHashMap在JDK 1.7中使用的数组 加 链表的结构,其中数组分为两类,大树组Segment和 小数组HashEntry,ConcurrentHashMap的线程安全是建立在Segment加ReentrantLock重入锁来保证ConcurrentHashMap在JDK1.8中使用的是数组 加 链表 加 红黑树的方式实现,它是通过CAS或者synchronized来保证线程安全的,并且缩小了锁的粒度,查询性能也更高。
HashMap和ConcurrentHashMap的区别
- 线程安全性:
HashMap不是线程安全的。在多线程环境中,如果同时进行读写操作,可能会导致数据不一致或抛出异常。ConcurrentHashMap是线程安全的,它使用了分段锁(Segment Locking)的机制,将整个数据结构分成多个段(Segment),每个段都有自己的锁。这样,不同的线程可以同时访问不同的段,提高并发性能。
- 同步机制:
HashMap在实现上没有明确的同步机制,需要在外部进行同步,例如通过使用Collections.synchronizedMap()方法。ConcurrentHashMap内部使用了一种更细粒度的锁机制,因此在多线程环境中具有更好的性能。
- 迭代时是否需要加锁:
- 在
HashMap中,如果在迭代过程中有其他线程对其进行修改,可能抛出ConcurrentModificationException异常。 ConcurrentHashMap允许在迭代时进行并发的插入和删除操作,而不会抛出异常。但是,它并不保证迭代器的顺序,因为不同的段可能会以不同的顺序完成操作。
- 初始化容量和负载因子:
HashMap可以通过构造方法设置初始容量和负载因子。ConcurrentHashMap在Java 8及之后版本中引入了ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel)构造方法,允许设置初始容量、负载因子和并发级别。
- 性能:
- 在低并发情况下,
HashMap的性能可能会比ConcurrentHashMap稍好,因为ConcurrentHashMap需要维护额外的并发控制。 - 在高并发情况下,
ConcurrentHashMap的性能通常更好,因为它能够更有效地支持并发访问。
总的来说,如果需要在多线程环境中使用哈希表,而且需要高性能的并发访问,通常会选择使用 ConcurrentHashMap。如果在单线程环境中使用,或者能够手动进行外部同步管理,那么 HashMap 可能是更简单的选择。
HashSet和HashMap的区别
HashMap 适用于需要存储键值对的情况,而 HashSet 适用于只关心元素唯一性的情况。在某些情况下,可以使用 HashMap 来模拟 HashSet 的行为,只使用键而将值设为固定的常量。
- 使用
HashMap用于存储键值对,其中每个键都唯一,每个键关联一个值。HashSet用于存储唯一的元素,不允许重复。
- 内部实现:
HashMap使用键值对的方式存储数据,通过哈希表实现。HashSet实际上是基于HashMap实现的,它只使用了HashMap的键部分,将值部分设置为一个固定的常量。
- 元素类型:
HashMap存储键值对,可以通过键获取对应的值。HashSet存储单一元素,只能通过元素本身进行操作。
- 允许 null:
HashMap允许键和值都为 null。HashSet允许存储一个 null 元素。
- 迭代方式:
HashMap的迭代是通过迭代器或增强型 for 循环遍历键值对。HashSet的迭代是通过迭代器或增强型 for 循环遍历元素。
- 关联关系:
HashMap中的键与值是一一对应的关系。HashSet中的元素没有关联的值,只有元素本身。
- 性能影响:
HashMap的性能受到键的哈希分布和哈希冲突的影响。HashSet的性能也受到元素的哈希分布和哈希冲突的影响,但由于它只存储键,通常比HashMap的性能稍好。
HashMap和HashTable的区别
- 同步
Hashtable 是同步的,即它的方法是线程安全的。这是通过在每个方法上添加同步关键字来实现的,但这也可能导致性能下降。
HashMap 不是同步的,因此它不保证在多线程环境中的线程安全性。如果需要同步,可以使用 Collections.synchronizedMap() 方法来创建一个同步的 HashMap。
- 性能
- 由于
Hashtable是同步的,它在多线程环境中的性能可能较差。 HashMap在单线程环境中可能比Hashtable更快,因为它没有同步开销。
- 空值
Hashtable不允许键或值为null。HashMap允许键和值都为null。
- 继承关系
Hashtable 是 Dictionary 类的子类,而 HashMap 是 AbstractMap 类的子类,实现了 Map 接口。
- 迭代器
Hashtable的迭代器是通过Enumerator实现的。HashMap的迭代器是通过Iterator实现的。
- 初始容量和加载因子
Hashtable的初始容量和加载因子是固定的。HashMap允许通过构造方法设置初始容量和加载因子,以便更好地调整性能。
Java创建线程有哪几种方式
在 Java 中,创建线程有四种方式,分别是 继承Thread类,实现Runnable接口,使用Callable和Future, 使用线程池.
- 继承Thread类: 通过创建
Thread类的子类,并重写其run方法来定义线程执行的任务。 - 实现Runnable接口: 创建一个实现了
Runnable接口的类,并实现其run方法。然后创建该类的实例,并将其作为参数传递给Thread对象。 - 使用Callable和Future接口:创建一个实现了
Callable接口的类,并实现其call方法,该方法可以返回结果并抛出异常。使用ExecutorService来管理线程池,并提交Callable任务获取Future对象,以便在未来某个时刻获取Callable任务的计算结果。 - 使用线程池:通过使用
Executors类创建线程池,并通过线程池来管理线程的创建和复用。
线程start和run 的区别
在Java多线程中,run 方法和 start 方法的区别在于:
run方法是线程的执行体,包含线程要执行的代码,当直接调用run方法时,它会在当前线程的上下文中执行,而不会创建新的线程。start方法用于启动一个新的线程,并在新线程中执行run方法的代码。调用start方法会为线程分配系统资源,并将线程置于就绪状态,当调度器选择该线程时,会执行run方法中的代码。
因此,虽然可以直接调用 run 方法,但这并不会创建一个新的线程,而是在当前线程中执行 run 方法的代码。如果需要实现多线程执行,则应该调用 start 方法来启动新线程。
Java中有哪些锁
- 公平锁/非公平锁:公平锁指多个线程按照申请锁的顺序来获取锁,非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优 先获取锁。有可能,会造成优先级反转或者饥饿现象。 对于
Java ReentrantLock而言,默认是非公平锁,对于Synchronized而言,也是一种非公平锁。 - 可重入锁(递归锁):在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。对于
Java ReentrantLock而言,是可重入锁,对于Synchronized而言,也是一个可重入锁。 - 独享锁/共享锁:独享锁是指该锁一次只能被一个线程所持有。共享锁是指该锁可被多个线程所持有。对于
Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁 是共享锁,其写锁是独享锁。 对于Synchronized而言,当然是独享锁。 - 互斥锁/读写锁:上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。 互斥锁在Java中的具体实现就是
ReentrantLock。读写锁在Java中的具体实现就是ReadWriteLock - 乐观锁/悲观锁:乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加 锁会带来大量的性能提升。
- 分段锁:分段锁其实是一种锁的设计,并不是具体的一种锁,对于
ConcurrentHashMap而言,其并发的实现就 是通过分段锁的形式来实现高效的并发操作。 - 偏向锁/轻量级锁/重量级锁:这三种锁是指锁的状态,并且是针对
Synchronized。在Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。 - 自选锁:在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好 处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
说说对synchronized的理解
synchronized是Java中的一个关键字,用于实现同步和线程安全。
- 当一个方法或代码块被
synchronized修饰时,它将成为一个临界区,同一时刻只能由一个线程访问。其他线程必须等待当前线程退出临界区才能进入。确保多个线程在访问共享资源时不会产生冲突 synchronized可以应用于方法或代码块。当它应用于方法时,整个方法被锁定;当它应用于代码块时,只有该代码块被锁定。这样做的好处是,可以选择性地锁定对象的一部分,而不是整个方法。synchronized实现的机理依赖于软件层面上的JVM,因此其性能会随着Java版本的不断升级而提高。 到了Java1.6,synchronized进行了很多的优化,有适应自旋、锁消除、锁粗化、轻量级锁及偏向锁等,效率有了本质上的提高。在之后推出的Java1.7与1.8中,均对该关键字的实现机理做了优化。 需要说明的是,当线程通过synchronized等待锁时是不能被Thread.interrupt()中断的,因此程序设计时必须检查确保合理,否则可能会造成线程死锁的尴尬境地。- 最后,尽管
Java实现的锁机制有很多种,并且有些锁机制性能也比synchronized高,但还是强烈推荐在 多线程应用程序中使用该关键字,因为实现方便,后续工作由JVM来完成,可靠性高。只有在确定锁机 制是当前多线程程序的性能瓶颈时,才考虑使用其他机制,如ReentrantLock等。
synchronized和lock的区别是什么
synchronized和Lock都是Java中用于实现线程同步的手段,synchronized是Java的关键字,基于JVM的内置锁实现,可以用于修饰方法或代码块,使用起来比较简单直接。而Lock是一个接口,是Java提供的显式锁机制,需要手动获取和释放锁,通过实现类(如ReentrantLock)来创建锁对象,然后主动调用锁的获取和释放方法。特性
synchronized:灵活性相对较低,只能用于方法或代码块。而且synchronized方法一旦开始执行,即使线程被阻塞,也不能中断。没有超时机制,一旦获取不到锁就会一直等待,也没有公平性的概念,线程调度由JVM控制。lock:提供了更多的灵活性,例如可以尝试获取锁,如果锁已被其他线程持有,可以选择等待或者中断等待。提供了超时获取锁的能力,可以在指定时间内尝试获取锁,也可以设置为公平锁,按照请求锁的顺序来获取锁。
等待与通知:
synchronized:与wait()和notify()/notifyAll()方法一起使用,用于线程的等待和通知。lock:可以与Condition接口结合,实现更细粒度的线程等待和通知机制。
使用场景:
总结来说,
synchronized使用简单,适合锁的粒度较小、竞争不激烈、实现简单的场景。而Lock提供了更多的灵活性和控制能力,适用于需要更复杂同步控制的场景。
synchronized和ReentrantLock的区别是什么
synchronized和ReentrantLock都是Java中用于实现线程同步的手段,synchronized是Java的关键字,基于JVM的内置锁实现,可以用于修饰方法或代码块,使用起来比较简单直接。而ReentrantLock是java.util.concurrent.locks包中的一个锁实现,需要显式创建,并通过调用lock()和unlock()方法来管理锁的获取和释放。特性
synchronized:灵活性相对较低,只能用于方法或代码块。而且synchronized方法一旦开始执行,即使线程被阻塞,也不能中断。没有超时机制,一旦获取不到锁就会一直等待,也没有公平性的概念,线程调度由JVM控制。ReentrantLock:支持中断操作,可以在等待锁的过程中响应中断, 提供了尝试获取锁的超时机制,可以通过tryLock()方法设置超时时间。可以设置为公平锁,按照请求的顺序来获取锁,提供了isLocked()、isFair()等方法,可以检查锁的状态。
条件变量:
synchronized可以通过wait()、notify()、notifyAll()与对象的监视器方法配合使用来实现条件变量。ReentrantLock可以通过Condition新API实现更灵活的条件变量控制。
锁绑定多个条件:
synchronized与单个条件关联,需要使用多个方法调用来实现复杂的条件判断。ReentrantLock可以与多个Condition对象关联,每个对象可以有不同的等待和唤醒逻辑。
使用场景:
总结来说,
synchronized适合简单的同步需求,而ReentrantLock提供了更丰富的控制能力和灵活性,适用于需要复杂同步控制的场景。
为什么要有线程池
- 资源管理: 在多线程应用中,每个线程都需要占用内存和CPU资源,如果不加限制地创建线程,会导致系统资源耗尽,可能引发系统崩溃。线程池通过限制并控制线程的数量,帮助避免这个问题。
- 提高性能:通过重用已存在的线程,线程池可以减少创建和销毁线程的开销。
- 任务排队:线程池通过任务队列和工作线程的配合,合理分配任务,确保任务按照一定的顺序执行,避免线程竞争和冲突
- 统一管理:线程池提供了统一的线程管理方式,可以对线程进行监控、调度和管理。
总结:采用多线程编程的时候如果线程过多会造成系统资源的大量占用,降低系统效率。如果有些线程存活的时间很短但是又不得不创建很多这种线程也会造成资源的浪费。线程池的作用就是创造并且管理一部分线程,当系统需要处理任务时直接将任务添加到线程池的任务队列中,由线程池决定由哪个空闲且存活线程来处理,当线程池中线程不够时会适当创建一部分线程,线程冗余时会销毁一部分线程。这样提高线程的利用率,降低系统资源的消耗。
说一说线程池有哪些常用参数
corePoolSize核心线程数:线程池中长期存活的线程数。maximumPoolSize最大线程数:线程池允许创建的最大线程数量,当线程池的任务队列满了之后,可以创建的最大线程数。keepAliveTime空闲线程存活时间:当线程数大于corePoolSize时,多余的空闲线程能等待新任务的最长时间。TimeUnit: 与keepAliveTime一起使用,指定keepAliveTime的时间单位,如秒、分钟等。workQueue线程池任务队列: 线程池存放任务的队列,用来存储线程池的所有待执行任务。ThreadFactory:创建线程的工厂: 线程池创建线程时调用的工厂方法,通过此方法可以设置线程的优先级、线程命名规则以及线程类型(用户线程还是守护线程)等。RejectedExecutionHandler拒绝策略: 当线程池的任务超出线程池队列可以存储的最大值之后,执行的策略。
Java内存区域有哪些划分
【java】jvm内存模型全面解析_哔哩哔哩_bilibili
Java的内存区域主要分为以下几个部分:
- 程序计数器:程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。在多线程环境下,每个线程都有自己独立的程序计数器。当线程执行Java方法时,程序计数器记录的是正在执行的虚拟机字节码指令的地址。
- Java虚拟机栈:每个Java线程都有一个私有的Java虚拟机栈,与线程同时创建。每个方法在执行时都会创建一个栈帧,用于存储局部变量、操作数栈、动态链接、方法出口等信息。栈帧在方法调用时入栈,方法返回时出栈。
- 本地方法栈: 本地方法栈与Java虚拟机栈类似,但它为本地方法服务。本地方法是用其他编程语言(如C/C++)编写的,通过
JNI与Java代码进行交互。 - 堆:Java堆是Java虚拟机中最大的一块内存区域,用于存储对象实例。所有的对象实例和数组都在堆上分配内存。堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。堆可以分为新生代和老年代等不同的区域,其中新生代又包括Eden空间、Survivor空间(From和To)。
- 方法区: 方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。在HotSpot虚拟机中,方法区也被称为永久代,但在较新的JVM版本中,永久代被元空间所取代。
- 运行时常量池:是方法区的一部分,用于存储编译期生成的类、方法和常量等信息。
- 字符串常量池: 字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
- 直接内存:不是Java虚拟机运行时数据区的一部分,但Java可以通过NIO操作直接内存,提高IO性能。
介绍一下什么是强引用、软引用、弱引用、虚引用
这四种引用决定了对象的生命周期以及垃圾收集器如何收集垃圾。
- 强引用:最常见的引用类型。如果一个对象具有强引用,那么垃圾收集器绝不会回收它。
- 软引用:软引用用于描述一些还有用但非必需的对象。如果一个对象只有软引用指向它,那么在系统内存不足时,垃圾回收器会尝试回收这些对象。软引用通常用于实现内存敏感的缓存,可以在内存不足时释放缓存中的对象。
- 弱引用:弱引用比软引用的生命周期更短暂。如果一个对象只有弱引用指向它,在进行下一次垃圾回收时,不论系统内存是否充足,这些对象都会被回收。弱引用通常用于实现对象缓存,但不希望缓存的对象影响垃圾回收的情况。
- 虚引用:虚引用是Java中最弱的引用类型。如果一个对象只有虚引用指向它,那么无论何时都可能被垃圾回收器回收,但在对象被回收之前,虚引用会被放入一个队列中,供程序员进行处理。虚引用主要用于跟踪对象被垃圾回收的时机,进行一些必要的清理或记录。
有哪些垃圾回收算法
- 标记-清除算法
标记清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。 在标记阶段首先通过根节点(GC Roots),标记所有从根节点开始的对象,未被标记的对象就是未被引 用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。 适用场合:
- 存活对象较多的情况下比较高效
- 适用于年老代(即旧生代)
- 复制算法
从根集合节点进行扫描,标记出所有的存活对象,并将这些存活的对象复制到一块儿新的内存上去,之后将原来的那一块儿内存全部回收掉 现在的商业虚拟机都采用这种收集算法来回收新生代。 适用场合:
- 存活对象较少的情况下比较高效
- 扫描了整个空间一次(标记存活对象并复制移动)
- 适用于年轻代(即新生代):基本上98%的对象是”朝生夕死”的,存活下来的会很少
缺点:
- 需要一块儿空的内存空间
- 需要复制移动对象
- 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活的对象较多,复制的成本也将很高。
- 标记整理
标记-压缩算法是一种老年代的回收算法,它在标记-清除算法的基础上做了一些优化。 首先也需要从根节点开始对所有可达对象做一次标记,但之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。
- 分代收集算法
分代收集算法就是目前虚拟机使用的回收算法,它解决了标记整理不适用于新年代的问题,将内存分 为各个年代。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。 在不同年代使用不同的算法,从而使用最合适的算法,新生代存活率低,可以使用复制算法。而老年代对象存活率搞,没有额外空间对它进行分配担保,所以只能使用标记清除或者标记整理算法。
有哪些垃圾回收器
- 新生代垃圾收集器
Serial收集器(复制算法)是新生代单线程收集器,优点是简单高效,算是最基本、发展历史最悠久的收集器。它在进 行垃圾收集时,必须暂停其他所有的工作线程,直到它收集完成。ParNew收集器(复制算法)是新生代并行收集器,其实就是Serial收集器的多线程版本。Parallel Scavenge收集器是新生代并行收集器,追求高吞吐量,高效利用 CPU。
- 老年代垃圾收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程(串行)收集器,使用标记整理算法。这个收 集器的主要意义也是在于给Client模式下的虚拟机使用。
Parallel Old 是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器在 1.6中才开始提供。
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务器的响应速 度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对前面几种收集器来说更复杂一些,整个过 程分为4个步骤:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
- 新生代和老年代垃圾收集器
G1收集器-标记整理算法 :JDK1.7后全新的回收器, 用于取代CMS收集器。
G1收集器的优势:- 独特的分代垃圾回收器,分代GC: 分代收集器, 同时兼顾年轻代和老年代
- 使用分区算法, 不要求eden, 年轻代或老年代的空间都连续
- 并行性: 回收期间, 可由多个线程同时工作, 有效利用多核cpu资源
- 空间整理: 回收过程中, 会进行适当对象移动, 减少空间碎片
- 可预⻅性: G1可选取部分区域进行回收, 可以缩小回收范围, 减少全局停顿
G1收集器的阶段分以下几个步骤:
- 初始标记(它标记了从GC Root开始直接可达的对象)
- 并发标记(从GC Roots开始对堆中对象进行可达性分析,找出存活对象)
- 最终标记(标记那些在并发标记阶段发生变化的对象,将被回收)
- 筛选回收(首先对各个Regin的回收价值和成本进行排序,根据用户所期待的GC停顿时间指定回收计 划,回收一部分Region)
类加载机制介绍一下
类加载机制是Java虚拟机运行Java程序时负责将类加载到内存中的过程。它包括以下几个步骤:
- 加载: 在此阶段,类加载器负责查找类的字节码文件,并将其加载到内存中。字节码可以来自文件系统、网络等位置。加载阶段不会执行类中的静态初始化代码。
- 连接:连接阶段包括三个子阶段:
- 验证:确保加载的类文件格式正确,并且不包含不安全的构造。
- 准备:在内存中为类的静态变量分配内存空间,并设置默认初始值。这些变量在此阶段被初始化为默认值,比如数值类型为0,引用类型为null。
- 解析:将类、接口、字段和方法的符号引用解析为直接引用,即内存地址。这一步骤可能包括将常量池中的符号引用解析为直接引用。
- 初始化:在此阶段,执行类的静态初始化代码,包括静态字段的赋值和静态代码块的执行。静态初始化在类的首次使用时进行,可以是创建实例、访问静态字段或调用静态方法。
介绍一下双亲委派机制
双亲委派机制是Java类加载器中的一种设计模式,用于确定类的加载方式和顺序。这个机制确保了Java核心库的安全性和一致性。该机制的核心思想是:如果一个类加载器收到了类加载请求,默认先将该请求委托给其父类加载器处理。只有当父级加载器无法加载该类时,才会尝试自行加载。
双亲委派机制能够提高安全性,防止核心库的类被篡改。因为所有的类最终都会通过顶层的启动类加载器进行加载。另外由于类加载器直接从父类加载器那里加载类,也避免了类的重复加载。
说一说你对Spring AOP的了解
面向切面编程,可以说是面向对象编程的补充和完善。OOP 引入封装、继承、多态等概念来建立一种对象层次结构。不过 OOP允许开发者定义纵向的关系,但并不适合定义横向的 关系,例如日志功能。 AOP 技术恰恰相反,它利用一种称为”横切”的技术,剖解开封装的对象内部,并将那些影响了多个类的 公共行为封装到一个可重用模块,并将其命名为切面。所谓”切面”,简单说就是那些与业务无关,却为业务模块所共同调用的逻辑或责任封装起来,便于减少系统的重复代码,降低模块之间的 耦合度,并有利于未来的可操作性和可维护性。
Spring 中AOP 代理由 Spring 的IOC 容器负责生成、管理,其依赖关系也由 IOC 容器负责管理。因此, AOP 代
理可以直接使用容器中的其它 bean 实例作为目标,这种关系可由 IOC 容器的依赖注入提供。Spring 创建代理的规则为:
- 默认使用 JDK 动态代理来创建AOP代理,这样就可以为任何接口实例创建代理了
- 当需要代理类,而不是代理接口的时候,Spring 会切换为使用 CGLIB代理 ,也可强制使用 CGLIB
AOP 编程其实是很简单的事情,纵观 AOP 编程,程序员只需要参与三个部分:
- 定义普通业务组件
- 定义切入点,一个切入点可能横切多个业务组件
- 定义增强处理,增强处理就是在 AOP 框架为普通业务组件织入的处理动作
所以进行 AOP 编程的关键就是定义切入点和定义增强处理,一旦定义了合适的切入点和增强处理,AOP 框架将自动生成 AOP 代理,即: 代理对象的方法=增强处理+被代理对象的方法。
说一说你对 Spring中IOC的理解
- 什么是
IOC
Spring的IOC,也就是控制反转,它的核心思想是让对象的创建和依赖关系由容器来控制,不是我们自己new出来的,这样各个组件之间就能保持松散的耦合。
这里的容器实际上就是个Map, Map 中存放的是各种对象。通过DI依赖注入,Spring容器可以在运行时动态地将依赖注入到需要它们的对象中,而不是对象自己去寻找或创建依赖。这样可以很大程度上简化应用的开发,把应用从复杂的依赖关系中解放出来。举例来说,在实际项目中一个 Service 类可能有几百甚至上千个类作为它的底层,假如我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,如果利用 IoC 的话,你只需要配置好,然后在需要的地方引用就行了。
- 如何配置
Spring 时代我们一般通过 XML 文件来配置,后来开发人员觉得 XML 文件来配置不太好,于是 SpringBoot 注解配置就慢慢开始流行起来。使用配置可以告诉Spring容器如何创建对象、如何管理对象的生命周期。
总结来说,Spring的IOC容器是一个中央化的、负责管理应用中所有对象生命周期的强大工具
Bean的作用域
在 Spring 中,那些组成应用程序的主体及由 Spring IOC 容器所管理的对象,被称之为 Bean。而 Bean 的作用域定义了在应用程序中创建的 Bean 实例的生命周期和可见范围,主要有以下几种。
- 单例:这是默认的作用域,当一个
Bean的作用域为Singleton,那么Spring IoC容器中只会存在一个共享的Bean实例,并且所有对Bean的请求,只要id与该bean定义相匹配,则只会返回bean的同一实例。 - 原型:当一个
bean的作用域为prototype,表示一个bean定义对应多个对象实例。prototype作用域的bean会导致在每次对该bean请求时都会创建一个新的bean实例。因此,每次请求都会得到一个新的Bean实例。 - 请求:一个HTTP请求对应一个Bean实例,每个请求都有自己的Bean实例,且该Bean仅在请求期间有效。
- 会话:一个HTTP会话对应一个Bean实例,Bean的生命周期与用户的会话周期相同。
- 应用程序:对于定义在ServletContext中的Bean,整个Web应用程序共享一个Bean实例。
- Websocket: WebSocket生命周期内,每个WebSocket会话拥有一个Bean实例。
Bean的生命周期
Spring Bean的生命周期,其实就是Spring容器从创建Bean到销毁Bean的整个过程。这里面有几个关键步骤:
- 实例化Bean: Spring容器通过构造器或工厂方法创建Bean实例。
- 设置属性:容器会注入Bean的属性,这些属性可能是其他Bean的引用,也可能是简单的配置值。
- 检查Aware接口并设置相关依赖:如果Bean实现了
BeanNameAware或BeanFactoryAware接口,容器会调用相应的setBeanName或setBeanFactory方法。 - BeanPostProcessor:在Bean的属性设置之后,Spring会调用所有注册的
BeanPostProcessor的postProcessBeforeInitialization方法。 - 初始化
Bean: 如果Bean实现了InitializingBean接口,容器会调用其afterPropertiesSet方法。同时,如果Bean定义了init-method,容器也会调用这个方法。 - BeanPostProcessor的第二次调用**:容器会再次调用所有注册的
BeanPostProcessor的postProcessAfterInitialization方法,这次是在Bean初始化之后。 - 使用Bean:此时,Bean已经准备好了,可以被应用程序使用了。
- 处理DisposableBean和destroy-method:当容器关闭时,如果Bean实现了
DisposableBean接口,容器会调用其destroy方法。如果Bean定义了destroy-method,容器也会调用这个方法。 - Bean销毁:最后,Bean被Spring容器销毁,结束了它的生命周期。
Spring循环依赖是怎么解决的
- 什么是循环依赖
两个或者两个以上的 bean 互相持有对方,最终形成闭环。比如 Bean A 依赖于 Bean B,而 Bean B 又依赖于 Bean A,形成了一个循环依赖关系。这种情况下,如果不处理,会导致 Spring 容器无法完成 Bean 的初始化,从而抛出循环依赖异常。
- 怎么检测是否存在循环依赖
检测循环依赖相对比较容易,Bean在创建的时候可以给该Bean打标,如果递归调用回来发现正在创建中的话,即说明了循环依赖了。
- 如何解决
构造器循环依赖:Spring容器在创建Bean时,如果遇到循环依赖,通常是无法处理的,因为这会导致无限递归创建Bean实例。所以,构造器注入是不支持循环依赖的。
字段注入或Setter注入:使用了
三级缓存
来解决循环依赖问题。
- 首先,Spring容器会创建一个Bean的原始实例,但此时Bean的属性尚未设置,这个实例被存放在一级缓存中。
- 当Bean的属性被设置时,如果属性值是其他Bean的引用,Spring会去检查二级缓存,看是否已经有该Bean的引用存在。
- 如果二级缓存中没有,Spring会尝试创建这个被引用的Bean,并将其放入三级缓存。
- 最后,当Bean的属性设置完成后,原始的Bean实例会被放入二级缓存,供其他Bean引用
使用
@Lazy注解:通过@Lazy注解,可以延迟Bean的加载,直到它被实际使用时才创建,这可以避免一些循环依赖的问题。
Spring 中用到了那些设计模式
- 工厂设计模式 :
Spring使用工厂模式通过BeanFactory、ApplicationContext创建bean对象。 - 代理设计模式 :
Spring AOP功能的实现。 - 单例设计模式 :
Spring中的Bean默认都是单例的。 - 模板方法模式 :
Spring中jdbcTemplate、hibernateTemplate等以Template结尾的对数据库操 作的类,它们就使用到了模板模式。 - 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访 问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
- 观察者模式:
Spring事件驱动模型就是观察者模式很经典的一个应用。 - 适配器模式 :
Spring AOP的增强或通知(Advice)使用到了适配器模式、Spring MVC中也是用到了 适配器模式适配Controller。
描述一下SpringMVC的执行流程
- 用户发送请求至前端控制器
DispatcherServlet。 DispatcherServlet收到请求调用HandlerMapping处理器映射器。- 处理器映射器找到具体的处理器(可以根据
xml配置、注解进行查找),生成处理器对象及处理器拦截 器(如果有则生成)一并返回给DispatcherServlet。 DispatcherServlet调用HandlerAdapter处理器适配器。HandlerAdapter经过适配调用具体的处理器(Controller,也叫后端控制器)。Controller执行完成返回ModelAndView。HandlerAdapter将controller执行结果ModelAndView返回给DispatcherServlet。DispatcherServlet将ModelAndView传给ViewReslover视图解析器。ViewReslover解析后返回具体View。DispatcherServlet根据View进行渲染视图(即将模型数据填充至视图中)DispatcherServlet响应用户。
SpringBoot Starter有什么用
Spring Boot Starter 的作用是简化和加速项目的配置和依赖管理。
Spring Boot Starter可以理解为一种预配置的模块,它封装了特定功能的依赖项和配置, ,开发者只需引入相关的Starter依赖,无需手动配置大量的参数和依赖项。常用的启动器包括spring-boot-starter-web(用于Web应用)、spring-boot-starter-data-jpa(用于数据库访问)等。 引入这些启动器后,Spring Boot会自动配置所需的组件和Bean,无需开发者手动添加大量配置。- Starter还管理了相关功能的依赖项,包括其他Starter和第三方库,确保它们能够良好地协同工作,避免版本冲突和依赖问题。
Spring Boot Starter的设计使得应用可以通过引入不同的Starter来实现模块化的开发。每个Starter都关注一个特定的功能领域,如数据库访问、消息队列、Web开发等。- 开发者可以创建自定义的
Starter,以便在项目中共享和重用特定功能的配置和依赖项。
SpringBoot的常用注解
@SpringBootApplication: 用于标识主应用程序类,通常位于项目的顶级包中。这个注解包含了@Configuration、@EnableAutoConfiguration和@ComponentScan。@Controller: 用于标识类作为Spring MVC的Controller。@RestController: 类似于@Controller,但它是专门用于RESTful Web服务的。它包含了@Controller和@ResponseBody。@RequestMapping: 用于将HTTP请求映射到Controller的处理方法。可以用在类级别和方法级别。@Autowired: 用于自动注入Spring容器中的Bean,可以用在构造方法、字段、Setter方法上。@Service: 用于标识类作为服务层的Bean。@Repository: 用于标识类作为数据访问层的Bean,通常用于与数据库交互。@Component: 通用的组件注解,用于标识任何Spring托管的Bean。@Configuration: 用于定义配置类,类中可能包含一些@Bean注解用于定义Bean。@EnableAutoConfiguration: 用于启用Spring Boot的自动配置机制,根据项目的依赖和配置自动配置Spring应用程序。@Value: 用于从属性文件或配置中读取值,将值注入到成员变量中。@Qualifier: 与@Autowired一起使用,指定注入时使用的Bean名称。@ConfigurationProperties: 用于将配置文件中的属性映射到Java Bean。@Profile: 用于定义不同环境下的配置,可以标识在类或方法上。@Async: 用于将方法标记为异步执行。
Java基础
Java中的序列化和反序列化是什么?
Java序列化,大多数时候指的是JDK自带的序列化机制
序列化
是将对象转换为字节流的过程,这样对象可以通过网络传输、持久化存储或者缓存。
Java提供了Serializable接口来支持序列化,只要实现了这个接口,就可以将该类的对象进行序列化
反序列化
将字节流重新转换为对象的过程,即从存储中读取数据并重新创建对象。
其他
- transient关键字:在序列化过程中,有些字段不需要被序列化,例如敏感数据,可以使用transeient关键字标记不需要序列化的字段。
- serialVersionUID:每个Serializable类都应该定义一个UID,用于在反序列化时候验证版本一致性。如果没有明确指定,Java会根据类的定义自动生成一个UID,版本不匹配可能导致反序列化失败。
- 序列化性能问题:Java的默认序列化机制可能比较慢,尤其是对于大规模分布式系统,可能会选择更加高效的序列化框架Kryo、Protobuf
- 安全性:反序列化是一个潜在的安全风险,因为通过恶意构造的字节流,可能会加载不安全的类或者执行不期望的代码,因此反序列化过程需要进行输入验证,避免反序列化漏洞
序列化和反序列化理解
序列化其实就是将对象转化成可传输的字节序列格式,便于存储和传输。
因为对象在JVM中可以认为是立体的,会有各种引用,比如在内存地址中引用了某某对象,那此时这个对象要传输到网络的另一端时候就需要把这写引用压扁,因为网络的另一端内存地址可能没有某某对象,所以传输的对象需要包含这些信息,然后接收端讲这些扁平的信息再反序列化得到对象。
所以,反序列化就是将字节序列格式转换为对象的过程。
实现Serializable接口的作用
这个接口本身没什么功能,只是一个标记作用,源码中会检测是否使用接口,没使用就报错。
那么UID有什么用呢?
这个ID其实就是用来验证序列化的对象和反序列化对应的对象的ID是否是一致的。
所以这个ID的数字其实不重要,无论是1L还是自动申城的,只要序列化的时候对象的UID和反序列化的时候对象的UID一致的话就行。如果没有显示的指定UID,编译器会根据类的相关信息自动生成一个,可以认为是一个指纹。
所以如果没有定义一个UID然后反序列化之前把对象的类的结构改了,比如增加了一个成员变量,那么此时的反序列化会失败。
因为类的结构变了,生成的指纹就变了,UID就不一样了。
所以UID是验证作用,验证前后是否一致
Java序列化不包含静态变量
简单来说就是序列化之后存储的内容不包含静态变量的值
Java中Exception和Error的区别
Exception和Error都是Throwable的子类,在Java中只有继承了Throwable类的实力才可以被throw或者被catch,他们表示在程序运行时发生的异常或错误情况。
总结来看Exception表示可以被处理的程序异常,Error表示系统级的不可恢复错误
然后Exception又分为编译器异常和运行时异常,编译器异常时编译时必须显示处理的比如IOEXCEPTION,运行时异常,是运行的时候才会发现的异常比如NullPointerException等。
Error表示严重的错误,通常是JVM层级内系统级别的,无法预料的错误,程序无法通过代码进行处理或恢复,例如内存耗尽,栈溢出。
什么是Java的多态特性
多态是指同一个接口或父类引用变量可以指向不同的对象实例,并根据实际只想的对象类型执行相应的方法。
它允许同一个方法在不同对象上表现出不同的行为,是OOP的核心特性之一。
通过多态,程序可以灵活的处理不同类型的喜爱你个,降低代码耦合度,增强系统的可扩展性,新增子类或实现类时,无需修改原有代码,只需要通过接口或父类引用调用即可。
理解版本
多态其实是一种抽象行为,他的主要作用是让程序员可以面对抽象变成而不是具体的实现类,这样写出来的代码扩展性会更强。
举个例子:比如某个人很喜欢吃苹果,我们描述他的时候可以写他很喜欢吃水果。
水果就是抽象,苹果就是具体的实现类。
假设某天换口味了, 那么之前的文章写的是水果,那么完全不用修改。这就是多态的意义。
编译时多态和运行时多态
编译时多态:通过方法重载实现,在编译时确定方法的调用。
运行时多态:通过方法重写实现,在运行时确定方法的调用。
编译时多态:是在编译阶段确定方法的调用。重载:在同一个类中定义多个方法,这些方法的名称相同但形参列表不同。Java编译器在编译时会根据方法,调用时传入的参数类型和数量,决定调用哪一个重载方法。
运行时多态: 是在运行时确定方法的调用,运行时多态是通过方法重写实现。
重写:子类重写父类的一个或多个方法。通过父类引用调用方法时,实际执行的屎子类重写后的方法。这种多态性是在运行时根据对象的实际类型决定的。
Java中的参数传递是按值还是按引用?
在Java中,参数传递只有按值传递,不论是基本类型还是引用类型
基本数据类型:传递的是值的副本,即基本类型的数值本身,因此,对方法参数的任何修改都不会影响原始变量。
引用数据类型:传递的是引用的副本,即对象引用的内存地址。因此,方法内可以通过引用 修改对象的属性,但是不能改变引用本身,让他指向另一个对象。
为什么Java不支持多重继承
主要是因为多继承会产生灵性继承问题,Java之父就是吸取C++的教训,因此不支持多继承,
BC继承了A,然后D继承了BC,假设此时D要调用A的方法,因为B和C有不同的实现,此时就会出现歧义,不知道用哪个了。
那为什么可以多实现,因为如果多个接口内有相同的默认方法,子类必须重写这个方法。也就是说如果D实现了A接口,必须重写,不会发生歧义。
Java8有什么新特性
1、用元空间代替了永久代:因为永久代回收率比较低
2、引入了Lambda:简化匿名内部类方法,用(参数)->表达式 语法实现代码块传递,大幅减少样板代码。
3、函数式接口:仅含一个抽象方法的接口,是Lambda表达式的载体,Java8内置多种常用接口,支持函数组合
4、引入了日期类:代替现成不安全的Data和Calendar提供LocalDate和LocalDateTime等不可变类
5、接口默认方法、静态方法:允许接口用default关键字定义方法实现,解决接口升级时对实现类的加绒性问题,如Collection新增stream方法
6、新增Stream流式接口:提供生命石集合处理能力,通过链式调用实现过滤,映射,排序等操作,支持并行流,利用多核CPU加速处理
7、引入Optional类:封装可能为null的值
8、新增了CompletableFuture\StampedLock等并发实现类
Java的StringBuilder是怎么实现的?
StringBuilder主要是为了解决String对象的不可变性问题。提供搞笑动态的字符串拼接和修改操作。大致需要append和insert 等功能
大致核心实现如下:
内部使用字符数组 char[] value 来存储字符序列
通过方法如append() insert() 等操作,直接修改内部的字符数组,而不会像String那样创建新的对象。
每次进行字符串操作时,如果当前容量不足,他会通过扩展数组容量来容纳新的字符,按2倍+2的容量扩展,以减少扩展次数,提高性能。
为什么是需要+2,这是历史遗留问题,主要为了处理初始容量为0和1的问题。
那String底层也是用了char数组存放的,但是String被final修饰,内部的char也被private和final修饰了。
JDK和JRE有什么区别?
JRE是Java运行环境,包含了JVM、核心类库和其他支持运行Java程序的文件
JDK是用于开发Java程序的完整开发环境,包含了JRE,以及用于开发,调试,监控Java应用程序的工具,比JRE多了比如编译器javac,调试器jdb,打包工具jar等,以及文档生成等其他附加库
常用的Java工具:javac 编译器,java 运行, jar 打包,jdb 打断点调试,jps 线程分析工具,jmap 内存映射工具,jhat 堆分析工具,jstack 生成现成栈信息工具 jstat JVM统计监控
Java中hashCode和equals方法是什么他们和==操作符有什么区别
这三个都是Java中比较对象的三种方式,但是他们的用途和实现很大区别
hashCode用于三列存储结构中确定对象的存储位置。可用于快速比较两个对象是否不同,因为如果他们的哈希码不同,那么他们肯定不相等。
equals用于比较两个对象的内容是否相等,通常需要重写自定义比较逻辑
== 用于比较两个引用是否指向同一个对象即内存地址。对于基本数据类型,比较他们的值
hashCode方法返回对象的哈希码,主要用于支持基于哈希表的集合,用来确定对象的存储位置,在Java中,hashCode方法和equals方法之间有一个合约:
如果两个对象根据equals方法被认为是相等的,那么他们必须具有相同的哈希码。
如果两个对象具有相同的哈希码,他们不一定相等,但会被放在同一个哈希桶中。
equals 用于比较两个对象的内容是否相等。Object类中的默认实现会使 == 操作符来比较对象的内存地址。如果equals返回true,那么他们的hashcode方法必须返回相同的值,反之不需要。
什么是Java中的动态代理
Java中的动态代理是一种 在运行时 创建代理对象的机制。
动态代理 是指:在程序 运行时(runtime) 动态生成一个代理类,并创建代理对象,用来拦截对目标对象的方法调用,从而实现功能增强(如日志、权限、事务等),而无需修改目标类代码。
代理可以看做是调用目标的一个包装,通常用来在调用真是的目标之前进行一些逻辑处理,消除一些重复的代码。
静态代理指的是我们预先编码好一个代理类,而动态代理指的是运行时生成代理类。
核心作用:
- 不修改原代码的前提下,增强对象行为
- 实现 AOP(面向切面编程) 的基础
- 广泛应用于:Spring AOP、RPC 框架(如 Dubbo)、事务管理、日志记录等
Java 提供了两种动态代理机制:
| 方式 | 说明 |
|---|---|
| JDK 动态代理 | 基于接口,使用 java.lang.reflect.Proxy |
| CGLIB 动态代理 | 基于继承,可以代理类(无接口也可) |
JDK动态代理是基于接口,所以要求代理类一定是有定义接口的。
JDK实现原理:首先有一个被代理对象,一个接口定义中介业务的,一个中介类
中介类通过实现InvocationHandler接口(这是一个功能接口,你必须实现他的Invoke()方法,它的作用是定义代理对象(中介)在调用方法时的处理逻辑,传入三个参数,一个是生成的代理对象本身,一个是被调用的方法的Method对象,一个是调用的参数)然后一个是Proxy类(中介生成器),用于运行时生成代理类和代理实例,一个是目标类,被代理的真是对象,必须实现至少一个接口
CGLIB基于ASM字节码生成工具,他是通过继承的方式生成目标类的子类来实现代理类,所以要注意final方法(不可继承)
Java中的注解原理是什么?
注解其实就是一个标记,是一种提供元数据的机制,用于给代码添加说明信息。可以标记在类上,方法上,属性上,标记自身也可以设置一些值。注解本身不影响程序的逻辑执行,但可以通过工具或框架利用这些信息进行特定的处理,如代码生成,运行时处理等。
注解是一种特殊的接口以@interface关键字
1 | MyAnnotation{ |
使用注解可以在类、方法、字段等待马上
1 |
|
元注解就是注解的注解,如@Retention 指定注解的有效范围源码,编译后,运行时、@Target 可以用在哪些元素上、@Inherited表示注解是否可以被继承
如何应用反射?
Java的反射机制是在指运行时获取类的的结构信息,如方法、字段、构造函数,并操作对象的一种机制,反射机制提供了在运行时动态创建对象、调用方法、访问字段等功能,而无需再编译时知道这些类的具体信息。
反射机制的优点:可以动态的获取类的信息,不需要在编译时就知道类的信息,可以动态的创建对象,不需要在编译时就知道对象的类型,可以动态的调用对象的属性和方法,在运行时动态的改变对象的行为。
反射操作相比直接代码调用具有较高的性能开销,因为他设计到动态解析和方法调用。
所以在性能敏感的场景中,尽量避免频繁使用反射,通过缓存反射结果,例如吧第一次得到的Method缓存起来后续就不需要再动态加载了,这样就可以避免反射性能问题。
反射中通过Class.newInstance()或Constructor.newInstance()创建对象实例。使用Field类访问和修改对象的字段,通过Method类调用对象的方法。还能获取类的名称,父类,接口等信息。
什么是Java中的不可变类
不可变诶是指在创建后其状态 对象的字段 无法被修改的类。一旦对象被创建,他的素有属性都不能被更改,这种累的实例在整个生命周期内保持不变。
关键特征就是生命类为final,类的所有字段都是private和final,通过构造函数初始化所有字段。不提供任何修改对象状态的方法。
如果类包含可变对象的吟游,确保这些引用在对象外部无法被修改,例如getter方法中返回对象的副本(new一个新的对象)来保护可变对象。举个例子String就算修改String用了replace方法,返回的也是一个新对象。
什么是Java的SPI(Service Provider Interface)机制
SPI是一种插件机制,用于在运行时动态加载服务的实现,它通过定义接口,并提供一种可扩展的方式来让服务的提供者在运行时注入,实现解耦和模块化设计、
SPI机制的核心概念:
- 服务接口:就扣或抽象类,定义某个服务的规范或功能
- 服务提供者:实现了服务接口的具体实现类
- 服务加载器ServiceLoader:Java提供的工具类,负责动态加载服务的实现类,通过加载器可以在运行时发现和加载多个服务提供者。
- 配置文件:服务提供者在META-INF/services/ 目录下配置服务接口的文件来声明自己,这些文件的内容是实现接口的类的完全限定名。
如何实现一个SPI
1、创建一个服务接口
2、创建一个服务提供者(接口的实现类)
3、创建配置文件(需要再META-INF/service 目录下创建文件,文件名为接口的全限定名)
文件内容就是实现类的全限定名:
1 | com.example.MyserviceImpl |
4、通过ServiceLoader load接口类型即可加载配置文件中的实现类:
1 | ServiceLoad<MyService> serviceLoader = ServiceLoader.load(MyService.class); |
5、如果要替换实现类,仅需新建一个实现类,然后修改配置文件中的全限定名即可替换,无需修改使用代码
例如新建了一个实现类,那么只需要将配置文件的全限定名改为新建的实现类即可。
Java泛型的作用是什么?
Java泛型的作用是通过在编译时!!!!!!!!检查类型安全。
在编译时检查类型,泛型使代码可以适用于多种不同类型,提升可读性和维护性,泛型允许在编译时指定类型检查,从而消除了运行时需要显式类型转换的麻烦。
但是Java的泛型是伪泛型,只在编译时生效,JVM运行时没有泛型
Java泛型擦除是什么?
泛型擦除指的是Java编译器在编译时将所有泛型信息删除的过程。
泛型参数在运行时会被替换为其上界(Object),这样一来在运行时无法获取泛型的实际类型。
作用:泛型擦除确保了代码的向后兼容性
影响:由于类型擦除,无法在运行时获取泛型的实际类型,也不能创建泛型类型的数组或对泛型类型使用instanceof检查.
既然擦除了类型,为什么在运行期可以通过反射获得类型
1 | List<String> list = new ArrayList<>(); |
因为编译器隐性的帮我们插入了强转的代码!
那为什么反射获取list类型的时候还是能获得String类型,因为擦除是部分擦除+元数据保留,这个类型存储在.class 文件中。
但是在局部变量中,不保留类型,所以如果是局部变量的类型就获取不了了
成员变量的泛型,方法入参的泛型,方法返回值的泛型。
什么是Java泛型的上下界限定符
1、上界限定符(? extends T)
表示通配符类型必须是 T类型 或者 T 的子类
? extends Number
允许使用T 或者子类型作为泛型参数,通常用于读取操作,确保可以读取为T或者T的子类的对象。
所以上界限定符,可以安全的读取,不能写入
2、下界限定符(? super T)
表示通配符类型必须是T类型或T的父类
通常用于写入操作但是不能读取
为什么需要上下界限定符
泛型提供了类型安全性,但有时我们希望泛型参数的类型在某个范围内,这样可以确保在不同场景下使用泛型时技能获得灵活性,又能保证类型安全上下界限定符正是为此设计的,允许我们定义类型的范围,而不是具体类型。
上界限定符:常用于协变场景,允许我们队泛型集合进行只读操作、
下界限定符:常用于逆变场景,允许我们队泛型集合进行写入操作。
什么是协变和逆变
他们主要用于描述类型之间的兼容性关系
协变:子类型可以替换父类型,类型的方向是一致的从父类到子类,关键注意的是输出方向,比如方法的返回值。
逆变:父类型可以替换子类型,类型的方向是相反的从子类到父类,关键注意的屎输入方向,比如方法的参数。
为什么需要协变和逆变
协变:主要解决返回值的灵活性问题,允许更具体的类型返回
逆变:主要解决参数传递的灵活性问题,允许更广泛的类型输入
PECS原则
PECS原则是producer Extends,Consumer Super 的缩写,帮助理解什么时候使用上界和下界限定符
Producer Extends:如果某个对象提供数据 即生产者,使用extends 上界限定符
Consumer Super: 如果某个对象使用数据 即消费者,使用super 下界限定符
类型擦除与泛型边界
Java泛型是通过类型擦除实现的,即在编译时会将泛型信息移出,用实际类型代替泛型参数。上下界限定符通过 边界限制,确保在擦除是可以限制类型的范围,保证了类型的安全性和灵活性。
上界使用案例
1 | import java.util.ArrayList; |
下界使用案例
1 | import java.util.ArrayList; |
Java中的深拷贝和浅拷贝有什么区别?
深拷贝:深拷贝不仅复制对象本身,还递归复制对象中所有引用对象。这样新对象和原对象完全独立,修改新对象不会影响到原对象,即包括基本类型和引用类型,堆内的引用对象也会复制一份。
浅拷贝:拷贝支付至对象的引用,而不复制引用指向的实际对象。也就是说,浅拷贝创建一个新对象,但它的字段(若是对象类型)指向的是原对象中的相同内存地址。
深拷贝创建的新对象和原对象完全独立,任何一个对象的修改都不会影响原对象。
浅拷贝对象中是共享相同的引用,如果修改引用对象会影响原对象。
如何实现浅拷贝
1 | Object.clone() |
clone()方法只是对对象的字段进行字段拷贝,对于基本类型的字段会复制值,对于引用类型的字段则复制引用。
如何实现深拷贝
深拷贝可以通过递归调用clone()方法手动实现,也可以通过序列化和反序列化实现。序列化方式简单易用,但是性能相对较低,尤其是在深层嵌套对象或大对象的情况下。
1 | class Address implements Cloneable { |
序列化方式
1 | public static Object deepCopy(Object object) { |
什么是Java的Integer缓存池
Java的Integer缓存池,是为了提升性能和节省内存,根据实践发现大部分的数据操作都集中在值比较小的范围,因此缓存这些对象可以减少内存分配和垃圾回收的负担,提升性能。
在-128到127范围内的Integer对象会被缓存和复用。
原理:Java在自动装箱时,对于值在-128到127之间的int类型,会直接返回一个已经缓存的Integer对象,而不是创造新的对象。
其他包装类型的缓存机制
Long、Short、Byte这3种包装类缓存范围也是-128到127
Float和Double没有缓存池
Character是0到127
Boolean只缓存两个值,即true和false
Java的类加载过程是什么?
类加载就是指把类加载到JVM中,把二进制流存储到内存中,之后经过一番解析
、处理转化成可用的class类。
二进制流可以来源于class文件,或通过字节码工具生成的字节码或来源于网络。只要符合格式的二进制流,JVM来者不拒。
类加载流程分为:
1、加载
2、连接
3、初始化
连接还能拆分为:验证、准备、解析三个阶段
所以总的来看可以分为5个阶段。
1、加载:将二进制流读入内存中,生成一个Class对象。
2、验证:主要是验证加载进来的二进制流是否符合一定格式,是否规范,是否符合当前JVM版本等之类的验证
3、准备:为静态变量(类变量)赋初始值,即为他们在方法区划分内存空间。这里注意是静态变量,并且是初始值,比如int的初始值是0
4、解析:将常量池的符号引用转化为直接引用。符号引用可以理解为只是替代的标签,比如此时要做一个计划,暂时还没有人选,你设定了个A去做这件事,然后等计划真要落地的时候肯定要找到一个确定的人选,到时候就是小明去做一件事。解析就是把A(符号引用)替换成小明(直接引用)。符号引用就是一个字面量,没有什么实质性的意义,只是一个代表,直接引用指的是一个真实引用,在内存中可以通过这个引用查找到目标。
5、初始化:这时候执行一些静态代码块,为静态变量赋值,这里的赋值才是代码里面的赋值,准备阶段只是设置初始值占个坑。
什么是Java的BigDecimal?
BigDecimal是Java 中提供的一个用于高精度计算的类,属于java.math包。它提供对浮点数和定点数的精度控制,特别适用于金融和科学计算等需要高精度的领域。
主要特点:
高精度:这个类可以处理任意精度的数值,而不像float和double一样存在精度限制
不可变性:BigDecimal是不可变类,所有的算数运算都会返回新的BigDecimal。
丰富的功能:提供了加减乘除,取余,舍入,比较等多个方法,并支持各种舍入模式。
BigDecimal为什么能保证精度不丢失
BigDecimal能保证精度,是因为它使用了任意精度的整数表示法,而不是浮动的二进制表示。
BigDecimal内部使用两个字段存储数字,一个是整数部分inVal,另一个是用来表示小数点的位置scale,避免了浮点数转化过程中可能得精度丢失。计算时通过整数计算,再结合小数点位置和设置的精度与舍入行为,控制结果精度,避免了由默认浮点数舍入导致的误差。
使用new String(“oyy”)语句在java中会创建多少个对象?
会创建1或者2个对象。
主要由两种情况:
1、如果字符串常量池中不存在字符串对象oyy的引用,那么他会 在堆上创建两个字符串对象,其中一个字符串对象的引用会被保存在字符串常量池中。
2、如果字符串常量池中已经存在字符串对象Oyy的引用,则只会在堆中创建1个字符串对象Oyy
Java中final、finally、finalize各有什么区别
1、final用于修饰类、方法、和变量,主要用来设计不可变类,确保类的安全性、优化性能
2、finally与try-catch语句块结合使用,用于确保无论是否发生异常,finally代码块都会执行。
主要用于释放资源,以保及时发生异常,资源也会被正确释放。
3、finalize()是Object类中的方法,允许对象在被垃圾回收前进行清理操作。
较少使用,通常用于回收非内存资源,但不建议依赖他,因为JVM不保证finalize()会被执行。
在JDK9之后被标记为飞出,因为提供了更好的替代方案,如AutoCloseable接口和try-with-resources语句
不推荐在finally中使用return,这样会覆盖try中的返回值,容易引发难以发现的错误。
finalize()的替代方案
当JVM检测到对象不可达时,会标记对象,标记后将调用finalize() 方法进行清理(如果重写了该方法),之后才会真正回收对象。
但JVM并不承诺一定会等待finalize()运行结束,因此可能会造成内存泄露或性能问题,所以在实际开发中,尽量避免用finalize()进行清理操作。
为什么JDK9中将String的char数组改为byte数组
主要是为了节省内存空间,提高内存利用率
在JDK9之前,String类是基于char[]实现的,内部采用UTF-16编码,每个字符占用两个字节,但是,如果当前的字符仅需要一个字节的空间,这就造成了浪费,例如一些latin-1字符用一个字节表示即可
因此JDK9做了优化采用byte[] 数组来实现,ASCII字符串 单字节符 通过byte[] 存储,仅需1字节,减少了内存占用。
并引入了coder变量来表示编码方式,Latin-1或UTF-16,如果只包含范围内的字符,则使用单字节编码,否则使用UTF-16。这种机制在保持兼容性的同时,又减少了内存占用。
如果一个线程在Java中被两次调用start()方法,会发生什么
会报错。一旦线程已经开始执行,他的状态不能再回到初始状态,现成的生命周期不允许他从终止状态回到可运行状态。
线程的生命周期
在Java中,线程的生命周期可以细化为几个状态
1、New 初始状态:线程对象创建后,但未调用start()方法
2、Runnable 可运行状态:调用start()方法后,现成进入就绪状态,等待CPU调度。
3、Blocked 阻塞状态: 现成视图获取一个对象锁而被阻塞
4、Waiting 等待状态: 现成进入等待状态,需要被显式唤醒才能继续执行。
5、Timed Waiting 含等待时间的等待状态: 线程进入等待状态,但指定了等待时间,超时后会被唤醒。
6、Terminated 终止状态:线程执行完成或因异常退出
而Blocked、Waiting、Timed Waiting 其实都属于休眠状态。
常见的操作线程的常用方法:
1、sleep方法 用于延迟线程的执行,不释放锁
2、join方法 用于等待另一个线程完成,不涉及锁的释放。
3、wait方法 用于线程间通信和同步,释放锁并等待其他线程唤醒。
4、park方法 用于暂停当前线程,提供更灵活的等待选项,不涉及锁的释放但可以被unpark方法唤醒。
栈和队列的区别
栈:先进后出,操作的是同一端
队列:先进先出,一端进一端出
栈在Java中的实现:Stack,双端队列Deque,LinkedList,ArrayDeque
队列在Java中的实现:Queue(LinkedList)
栈的使用场景:函数调用
队列的使用场景:消息队列
Java的Optional类是什么?它有什么用?
Optional是Java8 引入的一个容器类,用于表示可能为空的值。它通过提供更为清晰的API,来减少程序中出现Null的情况,避免空指针异常的发生。
Java IO流体系
什么是Java的网络编程
这题一般用于笔试题,写一个基于Java实现网络通信的代码
Java的网络主要利用java.net包,它提供了用于网络通信的基本类和接口
Java网络编程的基本概念
IP地址:用于标识网络中的计算机
端口号:用于表示计算机上的具体应用程序或进程。
Socket:套接字,网络通信的基本单位,通过IP地址和端口号标识。
协议:网络通信的规则,如TCP和UDP
Java网络编程的核心类
Socket:用于创建客户端套接字
ServerSocket:用于创建服务器套接字
DatagramSocket:用于创建支持UDP协议的套接字
URL:用于处理统一资源定位符
URLConnection:用于读取和写入URL引用的资源。
下面的代码是参考tcp通信的
1 | import java.io.*; |
客户端代码
1 | import java.io.*; |
什么是Java中的自动装箱和拆箱
常见的地方是集合,List
它是通过调用包装类型的valueOf()和xxxValue()实现的
自动装箱调用Integer.valueOf(int i)
自动拆箱调用Integer.intValue()
什么是Java中的迭代器(Iterator)
Iterator是Java集合框架中用于遍历集合元素的接口,允许开发者一次访问集合中的每一个元素,而不需要关心集合的具体实现它提供了一种统一的方式来遍历List \ Set 等集合类型,通常与Collection类接口一起使用。
Iterator 的核心方法:
- hasNext() 返回ture 表示集合中还有下一个元素,返回false则表示遍历完毕。
- next() 返回集合中的下一个元素,如果没有更多元素则抛出NoSuchElementException
- remove() 从集合中移除最近一次通过next()方法返回的元素,执行时只能在调用 next() 之后使用,这个方法是可选的,不是所有的视线都支持该操作,如果不支持,调用时会抛出UnsupprotedOperationException
for-each 循环实际上是对Iterator的一种简化形式,背后是通过 Iterator 实现的
封装的特性
它指的是将对象的状态和方法封装在一个类的内部,并通过公开的接口与外部进行交互。
隐藏内部实现细节,从而保护数据的完整性和减少系统的复杂性。
访问控制修饰符
private:只允许类内部访问,无法被外部访问
protected:允许同一包内的类以及子类访问
public :允许任何类访问
默认包级别:只允许同一包内的类访问
什么是Java中的双亲委派模型
双亲委派模型是Java类加载机制的设计模式之一。他的核心思想是:类加载器在加载某个类时,会先委派给父类加载器去加载,父类加载器无法加载时,才由当前类加载器自行加载。
工作流程:当一个类加载器试图加载某个类时,把.class文件加载进内存,先将加载请求向上委派给父类加载器,父类加载器再向上委派给父类,直到根类加载器。
为什么要用双亲委派?(核心价值)
防止核心类库被篡改(安全性),就比如你写了一个String类,
- 请求先被委派到
Bootstrap Bootstrap发现java.lang.String在rt.jar中已存在- 直接返回官方的
String类 - 你的“伪造”类根本不会被加载!
避免类的重复加载(唯一性)同一个类不会被多个类加载器重复加载,确保了 JVM 中一个类的唯一性。
什么时候需要打破双亲委派,为什么需要打破
父类加载器无法加载子类路径中的类,
DriverManager是java.sql包下的类 → 由 Bootstrap ClassLoader 加载- 但真正的数据库驱动(如
com.mysql.cj.jdbc.Driver)是你放在classpath的第三方 jar 包 → 由 Application ClassLoader 加载
问题来了:
Bootstrap加载的DriverManager,怎么去加载Application加载器路径下的 MySQL 驱动?
按照双亲委派,父类加载器不能委托子类加载器去加载类,所以标准模型行不通!
三、如何打破双亲委派?——使用 线程上下文类加载器(ContextClassLoader)
Java 提供了一个“后门”机制来解决这个问题:
让父类加载器“借用”子类加载器的能力,即“反向委托”。
实现方式:Thread.currentThread().getContextClassLoader()
JVM 允许为每个线程设置一个“上下文类加载器”(Context ClassLoader),它通常就是 Application ClassLoader。
1 | ClassLoader contextLoader = Thread.currentThread().getContextClassLoader(); |
Java中sleep()和wait()的区别
1、sleep方法 属于Thread类中的方法 释放cpu给其他线程 不释放锁资源
2、wait方法 属于Object类中的方法 释放cpu给其他线程,同时释放锁资源 如果是wait(1000) 1s后自动唤醒,如果是wait() 线程会一直等待,需要通过notify或者notifyAll进行唤醒 wait方法必须配合synchronized一起使用,不然在运行时会抛出异常。
wait方法必须在同步块或同步方法内调用,而sleep()方法可以在任何上下文中使用。
BIO、NIO、AIO 的区别
BIO、AIO和NIO是Java中不同的I/O模型,它们在处理输入输出操作时有不同的特点。
BIO: 阻塞式的I/O模型。当一个线程执行I/O操作时,如果数据还没准备好,这个线程会被阻塞,直到数据到达。适合连接数较少且固定的场景,但扩展性较差。在Java中用ServerSocket和Socket的accpet方法,用来阻塞,等待客户端连接。NIO: 非阻塞的I/O模型。NIO使用缓冲区和通道来处理数据,提高了I/O操作的效率。支持面向缓冲区的读写操作,可以处理大量并发的连接。在java.nio包中提供了Selector、Channel等类实现高效的非阻塞IOAIO: 异步I/O模型,从Java 7开始引入。在AIO中,I/O操作被发起后,线程可以继续执行其他任务,一旦I/O操作完成,操作系统会通知线程。适合需要处理大量并发I/O操作,且希望避免I/O操作阻塞线程的场景。在Java中通过AsynchronousSocketChannel类来实现异步IO- 使用场景:
BIO适合低并发、连接数较少的应用。NIO适合高并发、需要处理大量连接的应用。AIO适合需要高性能、异步处理I/O操作的场景。
BIO就是上面网络编程中的样子,一个连接一个线程,
NIO通过使用selector实现多路复用,一个线程管理多个连接,通过事件驱动(accept,read)
一般使用Netty框架,基于NIO,即时通讯,网关。
AIO的话:Java中使用较少,Linux下性能不如NIO
什么是Channel?
Channel是Java NIO中的一个核心概念,用于数据的读写操作。
Channel是双向的,可以同时支持读取和写入,常用于非阻塞IO操作,可以结合Selector来实现多路复用,从而处理多个并发连接
什么是Selector
Float经过一系列的操作后,如何判断是否和另一个数相等?
浮点数经过一系列运算后,不能直接用 == 来比较是否相等。
常用的以下几个方案:
1、阈值比较法:就是设置一个容忍范围,精度阈值取决于具体的业务场景
1 | public static boolean floatEquals(double a , double b , double epsilon){ |
2、BigDecimal
1 | BigDecimal a = new BigDecimal("0.1"); |
Java集合
说说Java中HashMap的原理
1、HashMap底层是数组+链表+红黑树的结构
2、当创建对象时,将加载因子初始化为0.75
3、向HashMap中添加一个元素是,通过元素Key的哈希值运算得到一个table表的索引
4、查看索引处是否有元素
1、没有,则直接添加
2、有,则判断该位置的元素和要加入的元素的key的内容是否相同
1、相同:替换value
2、不相同,需要判断是需要红黑树还是链表结构做出相应的处理
1、红黑树进行添加元素
· 2、链表进行添加元素
1、整个链表没有与当前key相同,则加入到链表的最后,判断当前链表个数是否超过8,在判断table大小是否》=64,是,则转变为红黑树,否则就扩容table表
5、第一次添加table容量为16,临界值为12(16*0.75)
6、再扩容table容量为32,临界值为24
通过n-1 & hash的方式来确定 存放的数组位置,n是数组长度,hash是key的hash值 ,这样的操作相当于hash % n但是这么写更快
添加到链表最后是用的尾插法,如果使用头插法,容在多线程环境下容易造成链表形成环路。
举个例子:假设有两个线程A和B,然后A用头插法要put操作并且要扩容,B然后也是要put操作并且扩容,那么这时候如果A 先拿到时间片,然后原来的链表是A - 》 B - 》 null,那么这时候A开始扩容,扩容的话需要一个旧数组和一个新数组,然后需要把旧数组的数据搬到新数组里面,然后由于是头插法,首先拿到A然后暂存一下,然后拿到B,本来需要先把A存入到新数组中,但是这时候A中断了,然后B线程,也开始扩容,然后旧数组还是A -> B -> null 然后B扩容完之后变成B -> A -> null然后这时候A 又回来了,然后这时候线程A就把A放到新数组的头了,这样就变成了A -> B - A -> null这样就造成循环了
使用HashMap时,有什么提升性能的方法
1、合理设置初始容量,默认初始容量是16,可以预估HashMap存储的数据量大小。以避免频繁的扩容操作
2、调整负载因子,默认的负载因子是0.75,可以根据具体的应用场景调整,较低的负载因子会减少冲突,但会占用更多内存,较高的可能会增加冲突的概率,降低查找效率
3、确保hashCode均匀分布,对应key的hashCode方法生成的hash值均匀分布
如果需要保留元素的插入顺序,可以使用LinkedHashMap替换 HashMap 它基于HashMap但维护了一个链表,记录元素的插入顺序
这样我们就不需要从HashMap中获取数据,然后再排序
如果需要保留有序的键值对,则可以使用TreeMap
如果是线程安全的场景,则可以使用ConcurrentHashMap
扩容是扩大两倍然后hash值重新分布,所以要避免频繁扩容
什么是Hash碰撞?怎么解决哈希碰撞?
Hash碰撞是指在使用哈希算法时,不同的输入数据通过哈希函数计算过后,形成了相同的哈希值,因为哈希值相同,所以这些键会被映射到哈希表的同一个位置,从而引发碰撞。
常见的有以下几个方法解决:
1、拉链法:将哈希表中每个槽的位置变成一个链表,当多个键的哈希值相同时,将他们存储在同一个链表中。
2、开放寻址法:如果出现碰撞,寻找哈希表中的下一个可用位置。
3、再哈希法:在出现碰撞时,使用第二个哈希函数计算新的索引位置。
Java的CopyOnWriteArrayList和Collections.synchronizedList有什么区别?分别有什么优缺点
CopyOnWriteArrayList
是一个线程安全的List实现,特性就是 写时复刻
每次对List的修改操作,都会复制创建一个新的底层数组。读操作不需要加锁,写操作需要加锁。
优点:
读操作无锁:每次写操作都会创建并复制新数组,所以读写之间不冲突,因此读操作不需要加锁,能够提供过很高效的并发性能。
缺点:
写操作开销大,每次写操作都会创建并复制新数组,且要将数据复制到新数组中,在写操作频繁的场景性能会降低
内存消耗大,每次写操作都会创建并复制新数组,在数据量大的情况下,同一时刻会存在两倍List大小的内存占用,开销较大
CopyOnWriteArrayList适合读多写少的场景。
Collections.synchronizedList
是一个包装方法,可以将任何List转换为线程安全的版本,他会对每个访问方法进行同步加synchronized锁,从而保证线程安全。
优点:方便,简单一个方法就可以将List变成线程安全的版本,非常方便。
缺点:并发低,读写操作都需要加锁,高并发场景下性能不高。
Java中有哪些集合类
Java中的集合类主要分为两个大类,Collection接口和Map接口,前者是存储对象的集合类,后者存储的是键值对。
Collection接口下又分为List、Set、和Queue接口。每个接口有具体的实现类,
List接口
ArrayList 动态数组,查询速度快,插入、删除缓慢
LinkedList 双向链表,插入删除快,查询慢
Vector 现成安全的动态数组,类似于ArrayList但是开销大
Set接口
HashSet 基于哈希表,元素无序,不允许重复
LinkedHashSet 基于链表和哈希表
TreeSet: 基于红黑树,元素有序,不允许重复
Queue接口
PriorityQueue 基于优先级堆,元素按照自然顺序或者指定比较器排序
LinkedList 可以作为队列使用,支持先进先出操作
Map接口
存储的是键值对,也就是给对象 value 设置了一个key ,这样通过key 可以找到那个value
HashMap 基于哈希表,键值对无序,不允许键重复
LinkedHashMap 基于链表和哈希表,维护插入顺序,不允许键重复
TreeMap 基于红黑树,键值对有序,不允许键重复
Hashtable 线程安全的哈希表,不允许键或值为null
ConcurrentHashMap 线程安全的哈希表,适合高并发环境,不允许键或值为Null
Java中的List接口有哪些实现类
List接口主要包含ArrayList、LinkedList、Vector、Stack、CopyOnWriteArrayList几个实现类
常见的操作方法,add,remove,isEmpty,contains
最常见的屎ArrayList和LinkedList,这两个都不是并发容器,所以线程不安全
ArrayList就是动态数组,随机访问快,插入和删除慢
LinkedList就是双向链表,随机访问慢,插入和删除快
LinkedList还是实现了Deque接口,然后平时常用的还有ArrayDeque,这个通常用来实现双端队列,线程不安全。
Vector是基于动态数组实现的,与ArrayList类似,但是他是线程安全的,所有方法都是同步的
CopyOnWriteArrayList是基于动态数组,所有可变操作,比如增加修改删除这些,都会创建一个新的数组,写时复制,当写操作的时候就会复制,他是线程安全的,适合在多线程中频繁的读取,很少修改。
ArrayList的扩容机制
当ArrayList中的元素数量超过当前容量时,会触发扩容机制,默认情况下,ArrayList的初始容量是10.
当发生扩容的时候,ArrayList会创建一个新的数组,容量是原数组的1.5倍,然后将原数组中的元素复制到新的数组中。
复制过程是通过Arrays.copyOf方法实现的。
Java中的HashMap 和 HashTable有什么区别
1、HashMap 非线程安全,HashTable 线程安全
2、HashMap允许一个null键和多个null值,HashTable不允许null键和null值
3、HashMap的迭代器类型使用的是快速失败(一检测到失败立刻结束)的Iterator,在迭代过程中如果对Map进行结构性修改(简而言之就是不能遍历的过程中进行修改操作,添加删除那些统统不行),会抛出ConcurrentModificationException
Hashtable 的迭代器是弱一致性的Enumerator,虽然不建议在迭代的过程中修改Map,但是不会抛出异常。
说到这个就会提到ConcurrentHashMap,这个是Hashtable的替代方案。他在实现线程安全的同时,通过分段锁的机制提高了并发性能,避免了全局锁导致的性能瓶颈。并适用于高并发环境。
ConcurrentHashMap的读操作无锁化,写操作则使用了局部锁分段,使得并发性能大大优于Hashtable.
ConcurrentHashMap和HashTable 有什么区别
他们都是Java中常用的现成安全的哈希表实现,区别在性能上,
因为在线程安全性的实现方式不同,导致了性能的差别
HashTable使用的是单一的锁机制,全表锁,对整个哈希表进行同步,所有的操作,插入,查找,删除等都必须通过一个锁synchronized来保证线程安全。这种方式导致在多线程环境下侠侣较低
ConcurrentHashMap 说到底也是数组+链表/红黑树 采用了CAS 和synchronized 的方式进行现成安全控制。CAS用于无锁的写入操作。如果某个节点为空,则通过CAS将数据插入节点。如果不为空则会退化到synchronized,使用synchronized锁定冲突节点的头结点就是数组地址会被锁住,这种锁的粒度更细,仅锁住特定的冲突节点,而不是整张表,因此在并发访问性能较好,高的并发性能。
cas特点就是将a修改成b,如果原来是a就开始修改,如果是b就不用修改了,这种特点就是不用加锁,但是会造成aba问题就是你的女朋友说我从来 没有跟别人在一起过,,但她其实谈了个恋爱又分手了,她没有撒谎,但是你被坑了。如果要解决的话可以加个版本号就没事了。
但是concurrenthashmap用cas的时候原本是空的,所以就算中间怎么样无所谓,不影响数据的一致性。反正原本就是空的了,如果不为空就不会使用cas了。
HashSet和HashMap有什么区别
HashSet 是基于HashMap的实现,不允许重复元素,用于存储一组无顺序的唯一元素。
HashMap 基于哈希表的数据结构,存储的是键值对,键必须唯一,值可以重复
HashSet其实就是HashMap的值部分
添加元素用的是Put方法
HashMap 的扩容机制
HashMap中的扩容是基于负载因子来决定的,默认情况下负载因子是0.75,当已存储元素超过当前容量的75%,就会开始扩容。
出发扩容时,容量会扩大为当前容量的两倍,扩容时,所有元素重新分配到新的哈希桶中,也就是数组地址,这个过程称为rehashing。
java中的treemap是什么
treemap实际就是红黑树,可以让key实现comparable接口或者自定义实现一个comparator传入构造函数,这样塞入的节点就会根据你的定义规则来进行排序。
红黑树是一种自平衡二叉搜索树,他通过引入颜色 红色和黑色来标记每个节点,确保在最坏环境下的时候时间复杂度仍然是Log n ,红黑树常用于需要搞笑插入、删除和查找操作的场景。
红黑树的性质,节点是红色或者黑色,根节点是黑色,所有叶子节点是黑色,红色节点的两个子节点都是黑色,从任一个节点到其每个叶子的所有路径都包含相同数目的黑色节点。
可以通过这个网站了解彻底Red/Black Tree Visualization
Java并发
什么是java中的线程同步
线程同步是指在多线程环境下,为了避免多个线程对共享资源进行同时访问,从而引发数据不一致或其他问题的一种机制,它通过对关键代码加锁,使得同一时刻只有一个线程能够访问共享资源。
当多个线程共享同一个资源,如果没有同步机制,可能会导致竞态条件,即线程对共享资源的操作是非原子性的,多个线程之间可能会同时修改数据,导致结果不符合预期。
java中常见的同步方式
synchronized 用于在方法或代码块上加锁,以确保同一时刻只有一个线程能执行被同步的方法或代码块
在synchroinzed可以使用wait()、notify()、notifyAll() 实现条件等待通知。
wait方法,当前线程进入等待状态,直到被其他线程唤醒,必须在同步块或同步方法中调用。
notify方法,唤醒一个等待的线程,如果多个线程在等待,同一时刻只能唤醒一个。
notifyAll方法,唤醒所有等待的线程。
ReentrantLock 是JUC提供的可重入锁,相比synchronized它更加灵活
reentrantlock使用condition对象来提供更灵活的等待/通知机制,每个reentrantlock可以创建一个或多个condition对象,通过newCondition方法创建。
在reentrantlock中使用singnalAll方法来唤醒所有等待的线程的。
java中的线程安全
线程安全是指多个线程访问某一共享资源时,能够保证一致性和正确性,无论线程如何交替执行,程序都能够产生预期的结果,且不会出现数据竞争或内存冲突。在java中的视线通常依赖于同步机制和线程隔离技术。
常用的线程安全措施:
同步锁 通过synchronized或者reentrantlock实现对共享资源的同步控制。
原子操作类 java提供 AtomicInteger、atomicreference等类确保多线程环境下的原子性操作。
线程安全容器 如concurrenthashmap、copyonwritearraylist等,避免手动加锁
局部变量 线程内独立的局部变量天然是线程安全的,因为每个线程都有自己的栈空间 线程隔离
ThreadLocal 类似于局部变量,属于线程本地资源,通过线程隔离保证了线程安全。
什么是协程?java支持协程吗
协程 是一种轻量级的线程,它允许在执行中暂停并在之后恢复执行,而无需阻塞线程。与线程相比,协程是用户态调度,效率更高,因为它不涉及操作系统内核调度。
Java 一开始没有原生支持协程,但在java 19中引入了 虚拟线程 ,最终在java 21中确认。它提供了类似协程的功能,虚拟线程可以被认为是协程的一种实现,虽然实现原理和传统的协程略有不同,但是它实现了高效并发。
协程的特点
轻量级:与传统线程不同,协程在用户态切换,不依赖内核态的上下文切换,避免了线程创建、销毁和切换的高昂成本。
非抢占式调度:协程的切换由程序员控制,可以通过显式的yield或await来暂停和恢复执行,避免了线程中断问题。
异步化编程:协程可以让异步代码写得像同步代码一样,使代码结构更加简介清晰。
线程的生命周期在java中是如何定义的
在java中,线程的生命周期可以细化为以下几个状态:
1、new 初始状态:线程对象创建后,但未调用start方法。
2、runnable 可运行状态:调用start方法后,线程进入就绪状态,等待cpu调度
3、blocked 阻塞状态:线程视图获取一个对象锁而被阻塞
4、waiting 等待状态:线程进入等待状态,需要被显式唤醒才能继续执行。
5、timed waiting 线程进入等待状态,但指定了等待时间,超时后会被唤醒。
6、terminated 终止状态:线程执行完成或因为异常退出
Java中线程如何通信
在java中,线程之间的通信是指多个线程协同工作,主要实现方式包括
1、共享变量 线程可以通过访问共享内存变量来交换信息,需要注意同步问题,防止数据竞争和不一致。
共享的也可以是文件,例如写入同一个文件来进行通信。
2、同步机制
synchronized : java中同步关键字,用于确保同一时刻只有一个线程可以访问共享资源,利用Object类提供的wait方法,notify方法,notifyAll方法实现线程之间的等待/通知机制
reentrantlock:配合Condition提供了类似于wait方法、notify方法的等待/通知机制
BlockingQueue:通过阻塞队列实现生产者- 消费者模式,是线程安全的阻塞队列,广泛应用在生产者-消费者模型,生产者通过put方法将元素放入队列,如果队列满了,生产者线程就会被阻塞,消费者通过take方法,如果为空消费者会阻塞。
CountDownLatch:可以允许一个或者多个线程等待,直到到达某个公共屏障点。
是一个同步工具,可以让一个或者多个线程等待,直到其他线程完成一些列操作后再继续执行。类似于运动会裁判员发枪。
CyclicBarrier: 也是一个或者多个线程等待,直到达到某个屏障点的一个同步工具,但是这个区别是,CyclicBarrier是一组线程相互等待,比如等人,所有人到齐了才能触发,并且这个等待需要重复进行,而CountDownLatch是一个或多个线程等待其他线程完成一系列独立的任务,并且这个等待过程只发生一次,比如餐厅开业,所有准备工作完成了才能开门让客户进来。
Volatile:Java中的关键字,确保变量的可见性,防止指令重排
写操作立刻写回主内存,读操作直接从主内存读取,修改后其他线程能立刻看到新的值。
直接读取主内存,而不是读的缓存,一个线程修改数据后,另一个线程能立刻看到。防止指令重排,是因为cpu和jvm为了优化性能,可能会重排代码的执行顺序。比如new一个对象的时候,实际分三步,1、分配内存,2、初始化对象3、指向新对象。
如果被修改,那么可能会按照132的顺序执行,导致为空,在另一个线程中判断对象是否被创建的时候就会出错了。
Semaphore:信号量,可以控制对特定资源的访问线程数。
java中如何创建多线程
有5中方法实现多线程。
1、实现Runnable接口:实现Runnable接口的run方法,使用Thread类的构造函数传入Runnable对象,调用start方法启动线程。使用run方法不会返回结果,不会处理异常。
Thread thread = new Thread(new MyRunnable()); thread.start();
2、继承Thread类
继承Thread类并重写run方法,直接创建Thread子类对象并调用start方法启动线程。
MyThread thread = new MyThread(); thread.start();
3、使用Callable和FutureTask:
实现Callable接口和call方法,使用FutureTask包装Callable对象,在通过Thread启动。
FutureTask<Integer> task = new FutureTask<>(new MyCallable); Thread thread = new Thread(task); thread.start();
4、使用线程池
通过ExecutorService提交Runnable或Callable任务,不直接创建和管理线程,适合管理大量并发任务。
ExecutoreService executor = Executors.newFixedThreadPool(10); executor.submit(new MyRunnable());
5、CompletableFuture 本质也是线程池
Java8引入的功能,非常方便的进行异步任务调用,通过thenApply、thenAccept等方法可以轻松处理一步任务之间的依赖关系。
CompletableFuture
Mybatis
mybatis执行原理
要说这个首先要知道JDBC的原理:首先JDBC其实也是远程调用的一种应用,然后Sun公司已经提供好对应的API了,然后JDBC访问数据库编码步骤就是先加载驱动Driver,然后创建数据库链接Connection,然后创建一个发送Sql的发送器Statement,然后通过Statement发送sql语句,然后Statement返回结果集,然后处理然后关闭资源就好了。
那么mybatis的流程也是类似的,
首先是初始化阶段,在这个阶段首先会加载配置文件,这里是先加载mybatis-config.xml全局配置文件和Mapper XML文件,比如xxxMapper.xml这种,这些文件包含了数据库链接信息,书屋管理器配置,SQL映射语句等关键信息。然后创建Configuration全局配置对象 在加载全局配置文件的过程中,会初始化Configuration对象,这个对象是全局配置对象,拥有MyBatis运行的所有配置信息。
然后注册Mapper接口 在创建好全局配置对象后,MyBatis会开始扫描Mapper xml文件或者注解,根据其中的信息,注册Mapper接口,这个步骤不会直接创建Mapper接口的实例,而是创建了MapperProxyFactory实例,并将他们和Mapper接口关联起来,存储在全局配置对象的某个结构中,当需要执行某个Mapper接口中的方法是,MyBatis会使用对应的MapperProxyFactory实例来创建Mapper接口的代理对象MapperProxy。
然后创建MappedStatement对象 在扫描Mapper XML文件或者注解的时候,对于Mapper XML文件中的每个SQL映射语句
然后创建SqlSessionFactory 完成这些后,全局配置对象来创建SQL会话工厂实例,这个工厂用于生成会话实例,进而执行SQL语句。
然后是获取SqlSession阶段
创建SqlSession,通过调用SqlSessionFactory的openSession方法,可以获得一个Sql会话实例,SqlSession是MyBatis提供的操作数据库的主要接口,它包含了执行SQL语句,管理事务和获取Mapper代理对象等方法。
然后是获取Mapper代理对象MapperProxy
当调用SqlSession的getMapper方法时,MyBatis会根据传入的Mapper接口类型,从MapperRegistry中找到对应的MapperProxyFactory实例。然后通过该工厂实例创建一个MapperProxy对象作为Mapper接口的代理对象。
然后是执行MapperProxy中的方法
调用Mapper方法,当通过Mapper接口代理对象MapperProxy调用某个方法时,实际上是调用了MapperProxy的invoke方法。
查找MappedStatement,在invoke方法内部,MyBatis会根据传来的方法名和参数类型,从Configuration对象中查找到对应的MappedStatement对象。
创建Executor ,MyBatis会根据当前事务的状态和配置信息,创建一个Executor执行器对象,它负责处理SQL语句的编译、缓存、执行以及结果映射等工作。
执行SQL语句,这个就跟JDBC一样了,Executor获取连接池或JDBC中的数据库链接Connection创造Statement对象,使用Statement对象的execute方法执行SQL语句,通过resultSetHandler获取到执行结果,然后将结果映射到Java对象中。












