Java面试
1. Jvm
1.Thread是如何解决内存泄漏问题的
- ThreadLocal持有着对ThreadLocalMap的引用。
- ThreadLocalMap持有着对各个值(Value)的引用。
- 如果Thread退出而ThreadLocal和ThreadLocalMap还存在,就会造成内存泄漏。
ThreadLocal内部提供了一个remove()方法用于解决此问题:
public void remove() {
Map<ThreadLocal<T>, T> m = getMap(this.threadLocalMap);
m.remove(ThreadLocal.this);
}
remove()方法会从ThreadLocalMap中移除当前ThreadLocal对象对应的引用。
此外,ThreadLocal也实现了ThreadLocal.ThreadLocalMap.Entry类的lazySet()和get()方法:
void lazySet(ThreadLocal<?> key, Object value) {
Entry e = table[indexFor(hash, table.length)];
...
// Overrides AbstractMap.SimpleEntry.setValue().
e.value = value;
}
@SuppressWarnings("unchecked")
T get() {
Entry[] tab = table;
int len = tab.length;
...
Entry e = tab[index];
if (e != null) {
return (T)e.value;
}
}
这就保证了,只有在调用get()方法时,ThreadLocalMap才会创建Value,而不会在调用set()方法时创建。
总的来说,ThreadLocal通过这些机制来:
- 在Thread退出时自动清理ThreadLocalMap引用以避免内存泄漏
- 惰性地创建Value,避免大量ThreadLocal内存管理开销
2. 计算机网络
网络分层模型
OSI七层模型(物链网输会示用)
是什么?每一层的作用?
OSI 七层模型 是国际标准化组织提出一个网络分层模型,其大体结构以及每一层提供的功能如下图所示:

每一层都专注做一件事情,并且每一层都需要使用下一层提供的功能。
虽然体系完整,但是太复杂,不实用,有些功能在多个层重复出现。
更生动的图:

TCP/IP四层模型
是什么?每一层的作用
是七层模型的精简版,把会话层、表示层、应用层合并为应用层,把物理层、数据链路层合并为网络接口层,由以下四层组成:
- 应用层(定义了不同设备应用程序间信息交换的格式,消息会交给下一层传输层来传输)
- 传输层(负责向两台终端设备进程之间的通信提供通用的数据传输服务)
- 网络层(为分组交换网上的不同主机提供通信服务)
- 网络接口层(封装成数据帧发送到网络上)
为什么网络要分层?
- 各层之间相互独立,只需要顾好自己。
- 提高整体灵活性,高内聚,低耦合。
- 大问题小化。
OSI 和 TCP/IP 网络分层模型详解(基础)
应用层常见协议:
应用层常见协议
- HTTP(Hypertext Transfer Protocol,超文本传输协议):基于 TCP 协议,是一种用于传输超文本和多媒体内容的协议,主要是为 Web 浏览器与 Web 服务器之间的通信而设计的。当我们使用浏览器浏览网页的时候,我们网页就是通过 HTTP 请求进行加载的。
- SMTP(Simple Mail Transfer Protocol,简单邮件发送协议):基于 TCP 协议,是一种用于发送电子邮件的协议。注意 ⚠️:SMTP 协议只负责邮件的发送,而不是接收。要从邮件服务器接收邮件,需要使用 POP3 或 IMAP 协议。
- POP3/IMAP(邮件接收协议):基于 TCP 协议,两者都是负责邮件接收的协议。IMAP 协议是比 POP3 更新的协议,它在功能和性能上都更加强大。IMAP 支持邮件搜索、标记、分类、归档等高级功能,而且可以在多个设备之间同步邮件状态。几乎所有现代电子邮件客户端和服务器都支持 IMAP。
- FTP(File Transfer Protocol,文件传输协议) : 基于 TCP 协议,是一种用于在计算机之间传输文件的协议,可以屏蔽操作系统和文件存储方式。注意 ⚠️:FTP 是一种不安全的协议,因为它在传输过程中不会对数据进行加密。建议在传输敏感数据时使用更安全的协议,如 SFTP。
- Telnet(远程登陆协议):基于 TCP 协议,用于通过一个终端登陆到其他服务器。Telnet 协议的最大缺点之一是所有数据(包括用户名和密码)均以明文形式发送,这有潜在的安全风险。这就是为什么如今很少使用 Telnet,而是使用一种称为 SSH 的非常安全的网络传输协议的主要原因。
- SSH(Secure Shell Protocol,安全的网络传输协议):基于 TCP 协议,通过加密和认证机制实现安全的访问和文件传输等业务
- RTP(Real-time Transport Protocol,实时传输协议):通常基于 UDP 协议,但也支持 TCP 协议。它提供了端到端的实时传输数据的功能,但不包含资源预留存、不保证实时传输质量,这些功能由 WebRTC 实现。
- DNS(Domain Name System,域名管理系统): 基于 UDP 协议,用于解决域名和 IP 地址的映射问题。
关于这些协议的详细介绍请看 应用层常见协议总结(应用层) 这篇文章。
传输层常见协议:

- TCP(Transmisson Control Protocol,传输控制协议 ):提供 面向连接 的,可靠 的数据传输服务。
- UDP(User Datagram Protocol,用户数据协议):提供 无连接 的,尽最大努力 的数据传输服务(不保证数据传输的可靠性),简单高效。
网络层常见协议:

- IP(Internet Protocol,网际协议):TCP/IP 协议中最重要的协议之一,主要作用是定义数据包的格式、对数据包进行路由和寻址,以便它们可以跨网络传播并到达正确的目的地。目前 IP 协议主要分为两种,一种是过去的 IPv4,另一种是较新的 IPv6,目前这两种协议都在使用,但后者已经被提议来取代前者。
- ARP(Address Resolution Protocol,地址解析协议):ARP 协议解决的是网络层地址和链路层地址之间的转换问题。因为一个 IP 数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但 IP 地址属于逻辑地址,而 MAC 地址才是物理地址,ARP 协议解决了 IP 地址转 MAC 地址的一些问题。
- ICMP(Internet Control Message Protocol,互联网控制报文协议):一种用于传输网络状态和错误消息的协议,常用于网络诊断和故障排除。例如,Ping 工具就使用了 ICMP 协议来测试网络连通性。
- NAT(Network Address Translation,网络地址转换协议):NAT 协议的应用场景如同它的名称——网络地址转换,应用于内部网到外部网的地址转换过程中。具体地说,在一个小的子网(局域网,LAN)内,各主机使用的是同一个 LAN 下的 IP 地址,但在该 LAN 以外,在广域网(WAN)中,需要一个统一的 IP 地址来标识该 LAN 在整个 Internet 上的位置。
- OSPF(Open Shortest Path First,开放式最短路径优先) ):一种内部网关协议(Interior Gateway Protocol,IGP),也是广泛使用的一种动态路由协议,基于链路状态算法,考虑了链路的带宽、延迟等因素来选择最佳路径。
- RIP(Routing Information Protocol,路由信息协议):一种内部网关协议(Interior Gateway Protocol,IGP),也是一种动态路由协议,基于距离向量算法,使用固定的跳数作为度量标准,选择跳数最少的路径作为最佳路径。
- BGP(Border Gateway Protocol,边界网关协议):一种用来在路由选择域之间交换网络层可达性信息(Network Layer Reachability Information,NLRI)的路由选择协议,具有高度的灵活性和可扩展性。
HTTP
从输入 URL 到页面展示到底发生了什么?
- DNS 解析
- TCP 连接
- 发送 HTTP 请求
- 服务器处理请求并返回 HTTP 报文
- 浏览器解析渲染页面
- 连接结束
具体可以参考浏览器从输入网址到页面展示的过程
常见HTTP状态码

- 200 OK(ok)
- 301 Moved Permanently(永久重定向)
- 302 Found(临时重定向)
- 400 Bad Request(HTTP请求有问题,比如参数不合法,请求方法错误等)
- 401 Unauthorized(未认证)
- 403 Forbidden(直接拒绝,针对非法请求,没有权限等)
- 500 Internal Server Error(服务器问题,抛异常且未处理)
- 502 Bad Gateway:(我们的网关将请求转发到服务端,但是服务端返回的却是一个错误的响应)
请求头Header中常见的字段
| 字段名 | 说明 | 示例 |
|---|---|---|
| Accept | 能够接受的回应内容类型 | Accept: text/plain |
| Authorization | 用于超文本传输协议的认证的认证信息 | Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== |
| Content-Length | 以八位字节数组 (8 位的字节)表示的请求体的长度 | Content-Length: 348 |
| Content-Type | 请求体的 多媒体类型 (用于 POST 和 PUT 请求中) | Content-Type: application/x-www-form-urlencoded |
| Cookie | 之前由服务器通过 Set- Cookie 发送的一个超文本传输协议 Cookie | Cookie: $Version=1; Skin=new; |
| Host | 服务器的域名(用于虚拟主机 ),以及服务器所监听的传输控制协议端口号。 | Host: en.wikipedia.org:80 |
| Origin | 发起一个针对 跨来源资源共享 的请求。 | Origin: http://www.example-social-network.com |
| Upgrade | 要求服务器升级到另一个协议。 | Upgrade: HTTP/2.0, SHTTP/1.3, IRC/6.9, RTA/x11 |
| User-Agent | 浏览器的浏览器身份标识字符串 | User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:12.0) Gecko/20100101 Firefox/21.0 |
HTTP和HTTPS的区别
- 端口:80和443
- URL前缀:http和https
- 安全性:http基于tcp,明文传输,不安全;https基于SSL/TSL(基于TCP),内容对称加密,安全但耗费更多资源
- SEO(搜索引擎优化):https会被优先展示。
HTTP/1.0和HTTP/1.1的区别

- 连接方式:短链接(连一次用完就断)和长连接
- 状态码:1.1新增了很多状态码
- 缓存策略:1.1更多样(基于请求头)
- 带宽:1.1可以请求资源的一部分
HTTP/1.1 和 HTTP/2.0的区别

- IO 多路复用(Multiplexing):HTTP/2.0 在同一连接上可以同时传输多个请求和响应;1.1是串行
- 二进制帧(Binary Frames):HTTP/2.0 使用二进制帧进行数据传输,而 HTTP/1.1 则使用文本格式的报文。二进制帧更加紧凑和高效,减少了传输的数据量和带宽消耗
- 头部压缩(Header Compression):2.0支持Header和body压缩,1.1只支持body压缩
- 服务器推送(Server Push):HTTP/2.0 支持服务器推送,可以在客户端请求一个资源时,将其他相关资源一并推送给客户端,从而减少了客户端的请求次数和延迟
HTTP/2.0 和 HTTP/3.0的区别
- 传输协议:HTTP/3.0 新增了 QUIC(Quick UDP Internet Connections) 协议(UDP增强版,有加密、重传等功能)来实现可靠的传输
- 连接建立:由于 QUIC 协议的特性,HTTP/3.0 可以避免 TCP 三次握手的延迟,允许在第一次连接时发送数据(0 个 RTT ,零往返时间)。
- 其他
HTTP 是不保存状态的协议, 如何保存用户状态?
HTTP 是一种不保存状态,即无状态(stateless)协议。也就是说 HTTP 协议自身不对请求和响应之间的通信状态进行保存。那么我们保存用户状态呢?Session 机制的存在就是为了解决这个问题,Session 的主要作用就是通过服务端记录用户的状态。典型的场景是购物车,当你要添加商品到购物车的时候,系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了(一般情况下,服务器会在一定时间内保存这个 Session,过了时间限制,就会销毁这个 Session)。
在服务端保存 Session 的方法很多,最常用的就是内存和数据库(比如是使用内存数据库 redis 保存)。既然 Session 存放在服务器端,那么我们如何实现 Session 跟踪呢?大部分情况下,我们都是通过在 Cookie 中附加一个 Session ID 来方式来跟踪。
如果Cookie被禁用,可以直接把Session ID放在请求URL后面。
URL和URI的区别
-
URI(Uniform Resource Identifier) 是统一资源标志符,可以唯一标识一个资源。
-
URL(Uniform Resource Locator) 是统一资源定位符,可以提供该资源的路径。它是一种具体的 URI,即 URL 可以用来标识一个资源,而且还指明了如何 locate 这个资源。
可以将URI理解为身份证,而URL理解为家庭住址
Cookie和Session的区别
DNS
DNS的作用
DNS(Domain Name System)域名管理系统,是当用户使用浏览器访问网址之后,使用的第一个重要协议。DNS 要解决的是域名和 IP 地址的映射问题。是应用层协议,基于UDP。

DNS服务器有哪些?
自底向上:
- 根 DNS 服务器。根 DNS 服务器提供 TLD 服务器的 IP 地址。目前世界上只有 13 组根服务器,我国境内目前仍没有根服务器。
- 顶级域 DNS 服务器(TLD 服务器)。顶级域是指域名的后缀,如com、org、net和edu等。
- 权威 DNS 服务器。在因特网上具有公共可访问主机的每个组织机构必须提供公共可访问的 DNS 记录,这些记录将这些主机的名字映射为 IP 地址。
- 本地 DNS 服务器。每个 ISP(互联网服务提供商)都有一个自己的本地 DNS 服务器。当主机发出 DNS 请求时,该请求被发往本地 DNS 服务器,它起着代理的作用,并将该请求转发到 DNS 层次结构中。
DNS解析过程
TCP 与 UDP
TCP和UDP的区别
- 是否面向连接:UDP 在传送数据之前不需要先建立连接。而 TCP 在传送数据之前必须先建立连接,数据传送结束后要释放连接。
- 是否是可靠传输:远地主机在收到 UDP 报文后,不需要给出任何确认,并且不保证数据不丢失,不保证是否顺序到达。TCP 提供可靠的传输服务,TCP 在传递数据之前,会有三次握手来建立连接,而且在数据传递时,有确认、窗口、重传、拥塞控制机制。通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。
- 是否有状态:这个和上面的“是否可靠传输”相对应。TCP 传输是有状态的,这个有状态说的是 TCP 会去记录自己发送消息的状态比如消息是否发送了、是否被接收了等等。为此 ,TCP 需要维持复杂的连接状态表。而 UDP 是无状态服务,简单来说就是不管发出去之后的事情了。
- 传输效率:TCP 的传输效率要比 UDP 低很多。
- 传输形式:TCP 是面向字节流的,UDP 是面向报文的。
- 首部开销:TCP 首部开销比 UDP 首部开销要大。
- 是否提供广播或多播服务:TCP 只支持点对点通信,UDP 支持一对一、一对多、多对一、多对多
| TCP | UDP | |
|---|---|---|
| 是否面向连接 | 是 | 否 |
| 是否可靠 | 是 | 否 |
| 是否有状态 | 是 | 否 |
| 传输效率 | 较慢 | 较快 |
| 传输形式 | 字节流 | 数据报文段 |
| 首部开销 | 20 ~ 60 bytes | 8 bytes |
| 是否提供广播或多播服务 | 否 | 是 |
TCP和UDP怎么选择
UDP 一般用于即时通信,比如:语音、 视频、直播等等。
TCP 用于对传输准确性要求特别高的场景,比如文件传输、发送和接收邮件、远程登录等。
HTTP基于TCP还是UDP?
HTTP/3.0 之前是基于 TCP 协议的,而 HTTP/3.0 将弃用 TCP,改用 基于 UDP 的 QUIC 协议 。此变化解决了 HTTP/2 中存在的队头阻塞问题。由于 HTTP/2 在单个 TCP 连接上使用了多路复用,受到 TCP 拥塞控制的影响,少量的丢包就可能导致整个 TCP 连接上的所有流被阻塞。另外,HTTP/2.0 需要经过经典的 TCP 三次握手过程(一般是 3 个 RTT)。由于 QUIC 协议的特性,HTTP/3.0 可以避免 TCP 三次握手的延迟,允许在第一次连接时发送数据(0 个 RTT ,零往返时间)。
相关证明可以参考下面这两个链接:
使用TCP的协议有哪些?
HTTP、HTTPS、FTP、SMTP、POP3/IMAP、Telnet、SSH
使用UDP的协议有哪些?
DNS、DHCP(动态主机配置协议,动态配置 IP 地址)
TCP的三次握手和四次挥手
TCP 如何保证传输的可靠性?
基于数据块传输:应用数据被分割成 TCP 认为最适合发送的数据块,再传输给网络层,数据块被称为报文段或段。
对失序数据包重新排序以及去重:TCP 为了保证不发生丢包,就给每个包一个序列号,有了序列号能够将接收到的数据根据序列号排序,并且去掉重复序列号的数据就可以实现数据包去重。
校验和 : TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。
超时重传 : 当发送方发送数据之后,它启动一个定时器,等待目的端确认收到这个报文段。接收端实体对已成功收到的包发回一个相应的确认信息(ACK)。如果发送端实体在合理的往返时延(RTT)内未收到确认消息,那么对应的数据包就被假设为已丢失open in new window并进行重传。
流量控制 : TCP 连接的每一方都有固定大小的缓冲空间,TCP 的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议(TCP 利用滑动窗口实现流量控制)。
拥塞控制 : 当网络拥塞时,减少数据的发送。
IP
IP协议的作用
属于网络层协议,主要作用是定义数据包的格式、对数据包进行路由和寻址,以便它们可以跨网络传播并到达正确的目的地。分为IPv4和IPv6
什么是 IP 地址?IP 寻址如何工作?
每个连入互联网的设备或域(如计算机、服务器、路由器等)都被分配一个 IP 地址(Internet Protocol address),作为唯一标识符。
当网络设备发送 IP 数据包时,数据包中包含了 源 IP 地址 和 目的 IP 地址 。
网络设备根据目的 IP 地址来判断数据包的目的地,并将数据包转发到正确的目的地网络或子网络,从而实现了设备间的通信。
什么是IP地址过滤?
IP 地址过滤(IP Address Filtering) 简单来说就是限制或阻止特定 IP 地址或 IP 地址范围的访问。例如,你有一个图片服务突然被某一个 IP 地址攻击,那我们就可以禁止这个 IP 地址访问图片服务。
IPv4 和 IPv6 有什么区别?
IPv4是目前广泛使用的 IP 地址版本,其格式是四组由点分隔的数字,例如:123.89.46.72。IPv4 使用 32 位地址作为其 Internet 地址,这意味着共有约 2^32个可用 IP 地址。
不够用就有了IPv6,由单或双冒号分隔的一组数字和字母,例如:2001:0db8:85a3:0000:0000:8a2e:0370:7334 。IPv6 使用 128 位互联网地址,这意味着越有 2^128 个可用 IP 地址。还有一些其他优势:
- 无状态地址自动配置(Stateless Address Autoconfiguration,简称 SLAAC):主机可以直接通过根据接口标识和网络前缀生成全局唯一的 IPv6 地址,而无需依赖 DHCP(Dynamic Host Configuration Protocol)服务器,简化了网络配置和管理。
- NAT(Network Address Translation,网络地址转换) 成为可选项:IPv6 地址资源充足,可以给全球每个设备一个独立的地址。
- 对标头结构进行了改进:IPv6 标头结构相较于 IPv4 更加简化和高效,减少了处理开销,提高了网络性能。
- 可选的扩展头:允许在 IPv6 标头中添加不同的扩展头(Extension Headers),用于实现不同类型的功能和选项。
- ......
NAT的作用
NAT(Network Address Translation,网络地址转换) 主要用于在不同网络之间转换 IP 地址。它允许将私有 IP 地址(如在局域网中使用的 IP 地址)映射为公有 IP 地址(在互联网中使用的 IP 地址)或者反向映射,从而实现局域网内的多个设备通过单一公有 IP 地址访问互联网。
NAT 不光可以缓解 IPv4 地址资源短缺的问题,还可以隐藏内部网络的实际拓扑结构,使得外部网络无法直接访问内部网络中的设备,从而提高了内部网络的安全性。

ARP
Mac地址
MAC 地址的全称是 媒体访问控制地址(Media Access Control Address)。如果说,互联网中每一个资源都由 IP 地址唯一标识(IP 协议内容),那么一切网络设备都由 MAC 地址唯一标识。
MAC 地址具有可携带性、永久性,身份证号永久地标识一个人的身份,不论他到哪里都不会改变。而 IP 地址不具有这些性质,当一台设备更换了网络,它的 IP 地址也就可能发生改变,也就是它在互联网中的定位发生了变化。
最后,记住,MAC 地址有一个特殊地址:FF-FF-FF-FF-FF-FF(全 1 地址),该地址表示广播地址。
ARP 协议解决了什么问题地位如何?
ARP 协议,全称 地址解析协议(Address Resolution Protocol),它解决的是网络层地址和链路层地址之间的转换问题。因为一个 IP 数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但 IP 地址属于逻辑地址,而 MAC 地址才是物理地址,ARP 协议解决了 IP 地址转 MAC 地址的一些问题。
3. Java基础
Java字节码的一些事
什么是字节码?
在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以, Java 程序运行时相对来说还是高效的(不过,和 C++,Rust,Go 等语言还是有一定差距的),而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。
为什么叫字节码?
因为 Java 字节码指令的操作码(opcode)被固定为一个字节。
首先明确几个关系(从左到右为包含关系):
JDK->JRE->JVM->JIT
- JDK(Java Development Kit),它是功能齐全的 Java SDK,是提供给开发者使用的,能够创建和编译 Java 程序。他包含了 JRE,同时还包含了编译 java 源码的编译器 javac 以及一些其他工具比如 javadoc(文档注释工具)、jdb(调试器)、jconsole(基于 JMX 的可视化监控⼯具)、javap(反编译工具)等等。
- JRE(Java Runtime Environment) 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,主要包括 Java 虚拟机(JVM)、Java 基础类库(Class Library)。

- JVM:Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。

- JIT(just-in-time compilation):运行时编译器,将字节码文件编译成机器码
为什么不全部使用 AOT 呢?
AOT 可以提前编译节省启动时间,那为什么不全部使用这种编译方式呢?
长话短说,这和 Java 语言的动态特性有千丝万缕的联系了。举个例子,CGLIB 动态代理使用的是 ASM 技术,而这种技术大致原理是运行时直接在内存中生成并加载修改后的字节码文件也就是 .class 文件,如果全部使用 AOT 提前编译,也就不能使用 ASM 技术了。为了支持类似的动态特性,所以选择使用 JIT 即时编译器。
Java代码的运行流程(以HotSpot为例):
Java代码通过编译产生.class文件,能被JVM理解的代码就叫字节码(.class)文件,因为多个平台都有各自的JVM,所以应对同样的class文件时,能产生相同的运行效果,所以Java有了“一次编写,随处运行”的特点。
但.class文件仅仅局限于让JVM能够理解,如果真的想被计算机执行,需要再转化为机器码(01串)。而Java提供了两种转化方式:通过解释器或JIT(just-in-time compilation)编译器。
- 解释器:要运行字节码文件,JVM类加载器先加载字节码文件,然后逐行解释执行,因为这样逐行解释速度较慢且每次执行前都要重新再解释一遍,执行效率较低(但启动效率高),所以就有了JIT编译器
- JIT即时编译器:将一个方法中的所有代码通过JIT提前编译成机器码并持久化下来,在接下来每次运行热点代码的时候就直接执行机器码,速度快
那什么样的代码应该采用解释执行,什么样的采用编译执行呢?
即时编译建立在二八定律的基础上,即20%的代码占了80%的运行资源。
对于大部分的不常用代码,JVM认为无需费劲把它们编译成机器码,采用解释执行的方式;而剩下的小部分热点代码,JVM采用JIT即时编译的方式提前编译成机器码。
实际上,标准 JDK 中的 HotSpot 虚拟机采用的是一种混合执行的策略。它会解释执行 Java 字节码,然后会将其中反复执行的热点代码,以方法为单位进行即时编译,翻译成机器码后直接运行在底层硬件之上。
再实际上,HotSpot内置了多个即时编译器:C1、C2 和 Graal,编译过程更加复杂。。。
那为什么说Java语言是“编译与解释并存”呢?
体现在两个方面:
- Java代码编译成字节码文件和解释执行
- 解释执行和JIT即时编译执行的混合执行策略
移位运算符
移位运算符包含三种,左移<< 、有符号右移>>、无符号右移>>>(移动时不考虑符号位,高位补0)。
double和float不能进行移位操作。
实际上,只有int和long有移位操作,byte、short、char移位时先转换成int再移动。
当移动的位数大于对应类型的最大位数时,采用先取模再移动的策略。例如:当int类型的a<<33时,会先33%32=1,再进行a<<1操作。
包装类型的缓存机制
Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False,而Float、Double没有缓存。
调用包装类型.valueOf()方法时才会先检查缓存,new出来的不会查缓存。
基本类型和包装类型的区别
用途:除了定义一些常量和局部变量之外,我们在其他地方比如方法参数、对象属性中很少会使用基本类型来定义变量。并且,包装类型可用于泛型,而基本类型不可以。
存储方式:基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被 static 修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。
占用空间:相比于包装类型(对象类型), 基本数据类型占用的空间往往非常小。
默认值:成员变量包装类型不赋值就是 null ,而基本类型有默认值且不是 null。
比较方式:对于基本数据类型来说,== 比较的是值。对于包装数据类型来说,== 比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用 equals() 方法
装箱or拆箱:从字节码中,我们发现装箱其实就是调用了 包装类的valueOf()方法,拆箱其实就是调用了 xxxValue()方法。
引用拷贝&浅拷贝&深拷贝
-
引用拷贝:两个引用指向同一个对象
-
浅拷贝:两个引用指向不同的对象,但两个不同的对象里的引用指向的对象相同,Object#clone()方法就是浅拷贝
-
深拷贝:两个引用指向不同的对象,且两个不同的对象里的引用指向的对象不同

接口和抽象类有什么区别?
接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
一个类只能继承一个类,但是可以实现多个接口。
接口中的成员变量只能是 public static final 类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。
Object里的一些方法
/**
* native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。
*/
public final native Class<?> getClass()
/**
* native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。
*/
public native int hashCode()
/**
* 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。
*/
public boolean equals(Object obj)
/**
* native 方法,用于创建并返回当前对象的一份拷贝。
*/
protected native Object clone() throws CloneNotSupportedException
/**
* 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。
*/
public String toString()
/**
* native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。
*/
public final native void notify()
/**
* native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。
*/
public final native void notifyAll()
/**
* native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。
*/
public final native void wait(long timeout) throws InterruptedException
/**
* 多了 nanos 参数,这个参数表示额外时间(以毫微秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 毫秒。。
*/
public final void wait(long timeout, int nanos) throws InterruptedException
/**
* 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念
*/
public final void wait() throws InterruptedException
/**
* 实例被垃圾回收器回收的时候触发的操作
*/
protected void finalize() throws Throwable { }
关于hashCode和equals的思考
hashCode用于获取一个对象的哈希码/散列码(int),即通过一些特定转换规则把一个对象转化成一个数字。其存在于Object方法中,被native修饰,未被重写时由C语言实现。
hashCode的作用:
以HashSet为例,向set中put对象A时,会先计算A.hashCode,根据获得的hashCode判断是否set中已存在相同hashCode的对象,没有就放入,有的话又存在两种情况:
- 两个对象相同,A会覆盖前者
- 两个对象不同,但通过hashCode计算得到的hashCode值相等(哈希冲突/哈希碰撞),A不会覆盖前者
其中判断对象是否真正相同,需要调用两个对象的equals方法。
为什么不只提供equals方法呢?
只用equals方法也能实现上述功能,但hashCode方法运算速度大于equals方法。
为什么重写equals时必须重写hashCode方法?
如果不重写,会出现一种情况:两个对象A和B,A.equals(B)==true但是A.hashCode()!=B.hashCode(),二者得出的关于A和B是否相等的结论相悖。
String为什么说是不可变的?
“String不可变”准确来说指某个String对象的字符串内容不可变。
首先String内部存储字符串的方式是定义了一个byte数组,用byte数组来存储字符串的每一位。
String类由final关键字修饰,这保证了String不会被继承,也就不会有子类来修改其中char数组的引用,同时String也没有提供方法来修改char数组的引用。
到这里问题就已经解释清楚了,但还有一个细节,String内部的byte数组也由final关键字修饰,表示它不可被修改。但在String层面就已经能够确保byte数组不会发生变化,为什么它前面还要加final?
实际上这种设计不是必须的,但通过加上final可以再次强调byte数组的不可变性,提供了清晰的编码约定。
字符串常量池
在JDK6.0及之前版本,字符串常量池存放在方法区中在JDK7.0版本以后,字符串常量池被移到了堆中了。至于为什么移到堆内,大概是由于方法区的内存空间太小了。
剩下的建议看:
https://zhuanlan.zhihu.com/p/107781993
小问题: String s4 = new String("3") + new String("3");一共创建了几个对象 ?
第一个new String("3")会在堆里创建一个"3"的对象A,同时会在堆里再创建一个"3"的对象B,并在字符串常量池中加入"3"的引用,指向后创建的"3"的对象B;
第二个new String("3")会创建一个新的"3"的对象C,但由于字符串常量池中已经包含"3"对应的引用,所以不会再创建第二个;
"+"拼接字符串时,实际会new StringBuilder(A).append(B).toString(),其中new StringBuilder创建一个新StringBuilder对象D,toString创建一个"33"的String对象E,同时在堆中
再创建一个"33"的String对象F,并在字符串常量池中加入"33"的引用,指向后创建的"33"的对象F。
一共6个。
String#intern 方法有什么作用?
String.intern() 是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:
- 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。
- 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回
// 在堆中创建字符串对象”Java“
// 将字符串对象”Java“的引用保存在字符串常量池中
String s1 = "Java";
// 直接返回字符串常量池中字符串对象”Java“对应的引用
String s2 = s1.intern();
// 会在堆中在单独创建一个字符串对象
String s3 = new String("Java");
// 直接返回字符串常量池中字符串对象”Java“对应的引用
String s4 = s3.intern();
// s1 和 s2 指向的是堆中的同一个对象
System.out.println(s1 == s2); // true
// s3 和 s4 指向的是堆中不同的对象
System.out.println(s3 == s4); // false
// s1 和 s4 指向的是堆中的同一个对象
System.out.println(s1 == s4); //true
Java异常体系
异常类结构层次图:

其中,Unchecked Exception即为RuntimeException及其子类;Checked Exception属于编译时异常,如果没被catch或throw就会报错没法编译。但通常不建议通过catch捕获Error.
Throwable类常用的方法:
String getMessage(): 返回异常发生时的简要描述String toString(): 返回异常发生时的详细信息String getLocalizedMessage(): 返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 getMessage()返回的结果相同void printStackTrace(): 在控制台上打印 Throwable 对象封装的异常信息
try-catch-finally的细节
执行顺序:
- try/catch中先计算返回值, 并将返回值存储起来, 等待返回
- 执行finally代码块
- 将之前存储的返回值, 返回出去
注意:
-
返回值是在finally运算之前就确定了,并且缓存了,不管finally对该值做任何的改变,返回的值都不会改变
-
finally中有return时,会直接执行finally中的return,忽略try/catch中的return
-
try/catch中的操作能影响finally中return的结果,而finally中的操作影响不了try/catch中返回的结果
-
以下两种情况finally中的代码不会执行:
-
- 程序所在的线程死亡
- 关闭 CPU
泛型的细节
Java 泛型(Generics) 是 JDK 5 中引入的一个新特性。
泛型一般有三种使用方式:泛型类、泛型接口、泛型方法。
反射的细节
通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。
反射让我们在运行时有了分析操作类的能力的同时,也增加了安全问题,比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。
注解的解析方法
编译期直接扫描:编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用@Override 注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。
运行期通过反射处理:像框架中自带的注解(比如 Spring 框架的 @Value、@Component)都是通过反射来进行处理的。
SPI与API
当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API ,这种接口和实现都是放在实现方的。
当接口存在于调用方这边时,就是 SPI ,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务。
缺点:
- 需要遍历加载所有的实现类,不能做到按需加载,这样效率还是相对较低的。
- 当多个
ServiceLoader同时load时,会有并发问题。
序列化与反序列化
-
序列化:将数据结构或对象转换成二进制字节流的过程
-
反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程

常用的序列化协议:
- Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。
- 像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。
Kryo 是一个高性能的序列化/反序列化工具,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。
Protobuf 出自于 Google(也称为
protocol buffers),性能还比较优秀,也支持多种语言,同时还是跨平台的。就是在使用中过于繁琐,因为你需要自己定义 IDL(interface definition language) 文件和生成对应的序列化代码。这样虽然不灵活,但是,另一方面导致 protobuf 没有序列化漏洞的风险。Protobuf也是gRpc的序列化机制,后续会在gRpc章节进行讲述。
序列化协议对应OSI七层模型中的表示层;对应TCP/IP四层模型中的应用层。
JDK自带的序列化只需要让类实现Serializable接口。同时,可以指定一个private static final long类型的serialVersionUID(推荐手动指定),它用来在反序列化时进行检查,起到版本控制的作用,如果文件中存储的serialVersionUID和当前类中的相等,就能序列化,否则会抛出java.io.InvalidClassException。如果不显示指定serialVersionUID,jdk会自动生成一个,当类的结构改变时,它也会发生变化。
被static修饰的serialVersionUid为什么还会被"序列化"?
static 修饰的变量是静态变量,位于方法区,本身是不会被序列化的。 static 变量是属于类的而不是对象。你反序列之后,static 变量的值就像是默认赋予给了对象一样,看着就像是 static 变量被序列化,实际只是假象罢了。也就是说,serialVersionUID 只是用来被 JVM 识别,实际并没有被序列化。
对于不想进行序列化的变量,可以使用 transient 关键字修饰。transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0。
为什么不推荐使用jdk自带的序列化方式?
- 不支持跨语言调用,只支持Java
- 性能差,主要体现在序列化后的体积大,传输慢
- 存在安全问题:反序列化的数据可以被用户控制,导致产生非预期的对象
I/O流基础知识
InputStream用于从源头(通常是文件)读取数据(字节信息)到内存中,java.io.InputStream抽象类是所有字节输入流的父类。
从 Java 9 开始,InputStream 新增加了多个实用的方法:
readAllBytes():读取输入流中的所有字节,返回字节数组。readNBytes(byte[] b, int off, int len):阻塞直到读取len个字节。transferTo(OutputStream out):将所有字节从一个输入流传递到一个输出流。
常见用法:
FileInputStream是一个比较常用的字节输入流对象,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中。不过,一般我们是不会直接单独使用FileInputStream,通常会配合BufferedInputStream(字节缓冲输入流,后文会讲到)来使用。DataInputStream用于读取指定类型数据,不能单独使用,必须结合FileInputStream。ObjectInputStream用于从输入流中读取 Java 对象(反序列化),ObjectOutputStream用于将对象写入到输出流(序列化)。另外,用于序列化和反序列化的类必须实现Serializable接口,对象中如果有属性不想被序列化,使用transient修饰。
OutputStream用于将数据(字节信息)写入到目的地(通常是文件),java.io.OutputStream抽象类是所有字节输出流的父类。
常见用法:
FileOutputStream是最常用的字节输出流对象,可直接指定文件路径,可以直接输出单字节数据,也可以输出指定的字节数组。类似于FileInputStream,FileOutputStream通常也会配合BufferedOutputStream(字节缓冲输出流,后文会讲到)来使用。DataOutputStream用于写入指定类型数据,不能单独使用,必须结合FileOutputStreamObjectInputStream用于从输入流中读取 Java 对象(ObjectInputStream,反序列化),ObjectOutputStream将对象写入到输出流(ObjectOutputStream,序列化)。
-
Reader用于从源头(通常是文件)读取数据(字符信息)到内存中,java.io.Reader抽象类是所有字符输入流的父类。Writer用于将数据(字符信息)写入到目的地(通常是文件),java.io.Writer抽象类是所有字符输出流的父类。 -
BufferedInputStream从源头(通常是文件)读取数据(字节信息)到内存的过程中不会一个字节一个字节的读取,而是会先将读取到的字节存放在缓存区(这个缓冲区实际就是一个字节数组,默认为8192字节),并从内部缓冲区中单独读取字节。这样大幅减少了 IO 次数,提高了读取效率。BufferedOutputStream同理。 -
字符缓冲流与字节缓冲流类似
I/O流设计模式及模型详解
Java语法糖
语法糖(Syntactic sugar) 代指的是编程语言为了方便程序员开发程序而设计的一种特殊语法,这种语法对编程语言的功能并没有影响。
不过,JVM 其实并不能识别语法糖,Java 语法糖要想被正确执行,需要先通过编译器进行解糖,也就是在程序编译阶段将其转换成 JVM 认识的基本语法。这也侧面说明,Java 中真正支持语法糖的是 Java 编译器而不是 JVM。如果去看com.sun.tools.javac.main.JavaCompiler的源码,会发现在compile()中有一个步骤就是调用desugar(),这个方法就是负责解语法糖的实现的。
Java集合
ArrayList 与 LinkedList 区别?
- 底层数据结构:
ArrayList底层使用的是Object数组;LinkedList底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!) - 是否支持快速随机访问:
LinkedList不支持高效的随机元素访问,而ArrayList(实现了RandomAccess接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。 - 内存空间占用:
ArrayList的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
ArrayList的扩容机制
ArrayList 的扩容机制主要涉及两个因素:容量 (capacity) 和负载因子 (load factor)。
- 容量 (capacity):ArrayList 内部维护了一个数组来存储元素,其初始容量由构造函数或默认值确定。当元素数量超过当前容量时,ArrayList 就需要进行扩容。
- 负载因子 (load factor):ArrayList 还维护了一个负载因子,用于确定何时触发扩容操作。当实际元素数量超过容量乘以负载因子时,就会触发扩容操作。具体的负载因子值可以在构造函数中指定,如果没有指定,默认为 0.75。
在扩容时,ArrayList 的扩容机制通常会按照以下步骤执行:
- 创建一个新的数组,新数组的容量通常会比当前容量大一些,具体增长策略可以是原容量的两倍或其他增长因子。这是为了避免每次添加元素都触发扩容操作,以提高性能。
- 将原数组中的元素复制到新数组中。
- 将新数组设置为 ArrayList 的内部数组,用于存储后续的元素。
在预知需要添加大量元素的情况下,可以通过构造函数指定一个较大的初始容量,以提高性能。
说一说 PriorityQueue
PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
这里列举其相关的一些要点:
PriorityQueue利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据PriorityQueue通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。PriorityQueue是非线程安全的,且不支持存储NULL和non-comparable的对象。PriorityQueue默认是小顶堆,但可以接收一个Comparator作为构造参数,从而来自定义元素优先级的先后。
PriorityQueue 在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第 K 大的数、带权图的遍历等,所以需要会熟练使用才行。
HashMap 和 Hashtable 的区别
- 线程是否安全:
HashMap是非线程安全的,Hashtable是线程安全的; - 效率:
HashMap更高 - 对 Null key 和 Null value 的支持:
HashMap可以存储 null 的 key 和 value;Hashtable 不允许有 null 键和 null 值。 - 初始容量大小和每次扩充容量大小的不同: ① 创建时如果不指定容量初始值,
Hashtable默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么Hashtable会直接使用你给定的大小,而HashMap会将其扩充为 2 的幂次方大小。 - 底层数据结构: JDK1.8 以后的
HashMap在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)。Hashtable没有这样的机制。
HashMap 的长度为什么是 2 的幂次方
-
效率: 当哈希表的长度是2的幂次方时,通过使用位运算来计算元素在哈希表中的索引,可以将取模运算(%)转换为位运算,这比取模运算更高效。
-
哈希冲突的处理: 当哈希函数发生冲突时,HashMap使用链表或红黑树(Java 8及以上版本)来解决冲突。采用链表或红黑树的方式,可以将相同散列值的元素链接在一起,避免了大量元素在同一个位置形成线性链表的问题。
HashSet与HashMap区别
HashSet底层是基于HashMap来实现的。
HashMap的底层实现
JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。
JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
HashMap 和 TreeMap 区别
实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。
实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。
HashMap 多线程操作导致死循环问题
JDK1.7:当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表。
JDK1.8:使用尾插法来避免链表倒置,但还是不建议在多线程下使用HashMap,因为会存在数据覆盖的问题。
HashMap 为什么线程不安全?
JDK1.7 及之前版本,在多线程环境下,HashMap 扩容时会造成死循环和数据丢失的问题。
数据丢失这个在 JDK1.7 和 JDK 1.8 中都存在
举个例子:
- 两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。
- 不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。
- 随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。
ConcurrentHashMap如何保证线程安全
JDK 1.7 中的 ConcurrentHashMap:
- 分段锁:JDK 1.7 的 ConcurrentHashMap 采用了分段锁的机制,将整个哈希表分成多个段(Segment),每个段都有一个锁。每次只有一个线程能够获得段的锁,从而允许多个线程同时访问不同的段,提高并发性能。
HashEntry数组:ConcurrentHashMap 使用HashEntry数组来存储键值对。每个段都维护一个HashEntry数组,不同的键值对可以存储在不同的段中,减少了锁的粒度。- 使用
volatile修饰的读操作:读操作不需要获取锁,但需要保证读取到最新的值。使用了volatile关键字来保证可见性。
JDK 1.8 中的 ConcurrentHashMap:
- CAS 操作:JDK 1.8 的 ConcurrentHashMap 在实现上采用了
CAS(Compare and Swap)操作来保证线程安全。可以避免使用锁的开销。 - 锁分段粒度细化:JDK 1.8 在分段锁的基础上进行了细化,将每个段中的锁粒度进一步缩小为每个节点(HashEntry)级别的锁。
- 使用
synchronized保证原子性:JDK 1.8 中 ConcurrentHashMap 在某些操作上使用了synchronized关键字来保证原子性,例如putVal、remove等方法。这些操作需要保证数据的一致性,因此使用synchronized来进行互斥,确保操作的原子性。
总结:
ConcurrentHashMap 在 JDK 1.7 和JDK 1.8 中都通过分段锁和锁粒度细化来实现线程安全。JDK 1.7 使用分段锁,将整个哈希表划分成多个段,每个段维护一个锁,通过减小锁的粒度提高并发性能。JDK 1.8 则引入了 CAS 操作和更细粒度的锁,使用数组+链表+红黑树的数据结构,并在一些操作上使用 synchronized 关键字保证原子性。这些改进都旨在提高并发性能和线程安全性。
4. 操作系统
操作系统基础
什么是操作系统?
- 操作系统(Operating System,简称 OS)是管理计算机硬件与软件资源的程序,是计算机的基石。
- 操作系统存在屏蔽了硬件层的复杂性。
- 操作系统的内核(Kernel)是操作系统的核心部分,它负责系统的内存管理,硬件设备的管理,文件系统的管理以及应用程序的管理。
内核与CPU的区别:
- 操作系统的内核(Kernel)属于操作系统层面,而 CPU 属于硬件。
- CPU 主要提供运算,处理各种指令的能力。内核(Kernel)主要负责系统管理。
操作系统的六大功能
- 进程和线程的管理:进程的创建、撤销、阻塞、唤醒,进程间的通信等。
- 存储管理:内存,外存的分配和管理等。
- 文件管理:文件的读、写、创建及删除等。
- 设备管理:完成设备(输入输出设备和外部存储设备等)的请求或释放,以及设备启动等功能。
- 网络管理:操作系统负责管理计算机网络的使用。网络是计算机系统中连接不同计算机的方式,操作系统需要管理计算机网络的配置、连接、通信和安全等,以提供高效可靠的网络服务。
- 安全管理:用户的身份认证、访问控制、文件加密等,以防止非法用户对系统资源的访问和操作。
用户态和内核态
-
用户态(User Mode) : 用户态运行的进程可以直接读取用户程序的数据,拥有较低的权限。当应用程序需要执行某些需要特殊权限的操作,例如读写磁盘、网络通信等,就需要向操作系统发起系统调用请求,进入内核态。
-
内核态(Kernel Mode):内核态运行的进程几乎可以访问计算机的任何资源,拥有非常高的权限。当操作系统接收到进程的系统调用请求时,就会从用户态切换到内核态,执行相应的系统调用,并将结果返回给进程,最后再从内核态切换回用户态。内核态相比用户态拥有更高的特权级别,因此能够执行更底层、更敏感的操作。

为什么不能只有一个内核态
-
在CPU指令中,有些指令如果可以被所有程序调用,会对系统的正常运行造成灾难性的影响,因此需要限制这些特权指令只能在内核态运行。
-
只有内核态会引起资源竞争与冲突以及安全性降低。
总结:同时具有用户态和内核态主要是为了保证计算机系统的安全性、稳定性和性能。
用户态切换到内核态的 3 种方式

- 系统调用(Trap):用户态进程 主动 要求切换到内核态的一种方式,主要是为了使用内核态才能做的事情比如读取磁盘资源。系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现。
- 中断(Interrupt):当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。
- 异常(Exception):当 CPU 在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
系统调用的过程
- 用户态的程序发起系统调用,因为系统调用中涉及一些特权指令,用户态程序权限不足,因此会中断执行,也就是
Trap。 - 发生中断后,当前 CPU 执行的程序会中断,跳转到中断处理程序。内核程序开始执行,也就是开始处理系统调用。
- 内核处理完成后,主动触发 Trap,这样会再次发生中断,切换回用户态工作。

进程和线程
什么是线程和进程
进程(Process) 是指计算机中正在运行的一个程序实例。
线程(Thread) 也被称为轻量级进程,更加轻量。共享进程的资源比如内存空间、文件句柄、网络连接等。
例如:打开微信就是一个进程,而微信中有一个线程用于拉取最新消息
从JVM的角度
一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)*资源,但是每个线程有自己的*程序计数器、虚拟机栈 和 本地方法栈。
有了进程为什么还需要线程?
- 进程切换是一个开销很大的操作,线程切换的成本较低。
- 线程更轻量,一个进程可以创建多个线程。
- 多线程并发处理不同的工作效率更高。
- 同一进程内的线程相互通信无须调用内核(共享内存)。
为什么要使用多线程?
- 在单核时代,多线程可以解决cpu和io的阻塞问题,提高cpu利用率
- 线程更加轻量化,切换调度成本低,多核情况下多个线程可以同时执行,减少线程的切换,提高cpu利用率
线程间的同步的方式有哪些?
- 互斥锁(Mutex):可以保证公共资源不会被多个线程同时访问。比如 Java 中的
synchronized关键词和各种Lock都是这种机制。 - 读写锁(Read-Write Lock):允许多个线程同时读取共享资源,但只有一个线程可以对共享资源进行写操作。
- 信号量(Semaphore):它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。
- 屏障(Barrier):屏障是一种同步原语,用于等待多个线程到达某个点再一起继续执行。当一个线程到达屏障时,它会停止执行并等待其他线程到达屏障,直到所有线程都到达屏障后,它们才会一起继续执行。比如 Java 中的
CyclicBarrier是这种机制。 - 事件(Event) :Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作。
PCB 是什么?包含哪些信息?
PCB(Process Control Block) 即进程控制块,是操作系统中用来管理和跟踪进程的数据结构,每个进程都对应着一个独立的 PCB。包含进程的描述信息、调度信息等。
java线程有哪几种状态?
六种:NEW、RUNNABLE、WAITING、TIMEED_WAINTING、BLOCKED、TERMINALED
进程有哪几种状态
- 创建状态(new):进程正在被创建,尚未到就绪状态。
- 就绪状态(ready):进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。
- 运行状态(running):进程正在处理器上运行(单核 CPU 下任意时刻只有一个进程处于运行状态)。
- 阻塞状态(waiting):又称为等待状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。
- 结束状态(terminated):进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行。

进程间通信的方式有哪些?
- 管道/匿名管道(Pipes):用于具有亲缘关系的父子进程间或者兄弟进程之间的通信
- 有名管道(Named Pipes) : 可以实现本机任意两个进程通信
- 信号(Signal):信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生
- 消息队列(Message Queuing):消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺点。
- 信号量(Semaphores):信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。
- 共享内存(Shared memory):使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。
- 套接字(Sockets) : 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。
进程的调度算法有哪些?
- 先到先服务调度算法(FCFS,First Come, First Served) : 从就绪队列中选择一个最先进入该队列的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
- 短作业优先的调度算法(SJF,Shortest Job First) : 从就绪队列中选出一个估计运行时间最短的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
- 时间片轮转调度算法(RR,Round-Robin) : 时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。
- 多级反馈队列调度算法(MFQ,Multi-level Feedback Queue):前面介绍的几种进程调度的算法都有一定的局限性。如短进程优先的调度算法,仅照顾了短进程而忽略了长进程 。多级反馈队列调度算法既能使高优先级的作业得到响应又能使短作业(进程)迅速完成。,因而它是目前被公认的一种较好的进程调度算法,UNIX 操作系统采取的便是这种调度算法。
- 优先级调度算法(Priority):为每个流程分配优先级,首先执行具有最高优先级的进程,依此类推。具有相同优先级的进程以 FCFS 方式执行。可以根据内存要求,时间要求或任何其他资源要求来确定优先级。
僵尸进程or孤儿进程
僵尸进程:子进程已经结束但PCB仍然存在,父进程通过此PCB拿不到子进程信息
孤儿进程:父进程结束了子进程仍在运行
死锁
产生死锁的四个必要条件
- 互斥:资源必须处于非共享模式,即一次只有一个进程可以使用。如果另一进程申请该资源,那么必须等待直到该资源被释放为止。
- 占有并等待:一个进程至少应该占有一个资源,并等待另一资源,而该资源被其他进程所占有。
- 非抢占:资源不能被抢占。只能在持有资源的进程完成任务后,该资源才会被释放。
- 循环等待:有一组等待进程 {P0, P1,..., Pn}, P0 等待的资源被 P1 占有,P1 等待的资源被 P2 占有,......,Pn-1 等待的资源被 Pn 占有,Pn 等待的资源被 P0 占有。
解决死锁的方法
- 破坏占有且等待:一次性申请全部所需的资源
- 破坏非抢占:如java.util.concurrent包下的Lock
- 破坏循环等待:按照相同的顺序请求资源
如何避免死锁
内存管理
内存管理主要做什么?

- 内存的分配与回收:对进程所需的内存进行分配和释放,malloc 函数:申请内存,free 函数:释放内存。
- 地址转换:将程序中的虚拟地址转换成内存中的物理地址。
- 内存扩充:当系统没有足够的内存时,利用虚拟内存技术或自动覆盖技术,从逻辑上扩充内存。
- 内存映射:将一个文件直接映射到进程的进程空间中,这样可以通过内存指针用读写内存的办法直接存取文件内容,速度更快。
- 内存优化:通过调整内存分配策略和回收算法来优化内存使用效率。
- 内存安全:保证进程之间使用内存互不干扰,避免一些恶意程序通过修改内存来破坏系统的安全性。
什么是内存碎片?
内存碎片是由内存的申请和释放产生的,通常分为下面两种:
- 内部内存碎片(Internal Memory Fragmentation,简称为内存碎片):已经分配给进程使用但未被使用的内存。举个例子,一个进程只需要 65 字节的内存,但为其分配了 128(2^7) 大小的内存,那 63 字节的内存就成为了内部内存碎片。
- 外部内存碎片(External Memory Fragmentation,简称为外部碎片):由于未分配的连续内存区域太小,以至于不能满足任意进程所需要的内存分配请求,这些小片段且不连续的内存空间被称为外部碎片。也就是说,外部内存碎片指的是那些并为分配给进程但又不能使用的内存。后面的分段机制就会导致外部内存碎片。
常见的内存管理方式
连续内存管理:为一个用户程序分配一个连续的内存空间,内存利用率一般不高。
非连续内存管理:允许一个程序使用的内存分布在离散或者说不相邻的内存中,相对更加灵活一些。
虚拟内存
什么是虚拟内存?有什么用?
虚拟内存(Virtual Memory) 是计算机系统内存管理非常重要的一个技术,本质上来说它只是逻辑存在的,是一个假想出来的内存空间,主要作用是作为进程访问主存(物理内存)的桥梁并简化内存管理。

没有虚拟内存会出现什么问题?
- 用户可任意访问物理空间,有安全问题
- 多个应用程序之间的内存可能会冲突,导致应用崩溃
- 程序运行时会向物理空间写入很多没用的内容,浪费空间
什么是虚拟地址和物理地址?
物理地址(Physical Address) 是真正的物理内存中地址,更具体点来说是内存地址寄存器中的地址。程序中访问的内存地址不是物理地址,而是 虚拟地址(Virtual Address) 。编程时操作的都是虚拟地址。
操作系统一般通过 CPU 芯片中的一个重要组件 MMU(Memory Management Unit,内存管理单元) 将虚拟地址转换为物理地址,这个过程被称为 地址翻译/地址转换(Address Translation) 。

通过 MMU 将虚拟地址转换为物理地址后,再通过总线传到物理内存设备,进而完成相应的物理内存读写请求。
MMU 将虚拟地址翻译为物理地址的主要机制有两种: 分段机制 和 分页机制 。现代操作系统广泛采用分页机制。
什么是虚拟地址空间和物理地址空间?
- 虚拟地址空间是虚拟地址的集合,是虚拟内存的范围。每一个进程都有一个一致且私有的虚拟地址空间。
- 物理地址空间是物理地址的集合,是物理内存的范围。
文件系统
文件系统主要功能
存储管理:将文件数据存储到物理存储介质中,并且管理空间分配,以确保每个文件都有足够的空间存储,并避免文件之间发生冲突。
文件管理:文件的创建、删除、移动、重命名、压缩、加密、共享等等。
目录管理:目录的创建、删除、移动、重命名等等。
文件访问控制:管理不同用户或进程对文件的访问权限,以确保用户只能访问其被授权访问的文件,以保证文件的安全性和保密性。
硬链接和软链接有什么区别?
- 文件类型:
- 硬链接是一个指向inode的指针,它创建了一个与原始文件相同的文件目录项,这意味着硬链接与原始文件是完全相同的文件,它们共享相同的inode和文件内容。
- 软链接是一个特殊类型的文件,它包含了指向原始文件或目录的路径,它创建了一个新的文件目录项,但实际上并不包含原始文件的内容。
- 跨文件系统:
- 硬链接只能在同一文件系统中的文件之间创建链接,因为硬链接是通过inode来实现的。
- 软链接可以跨越不同文件系统,因为它们是通过路径来引用文件的。
- 文件大小:
- 硬链接与原始文件共享相同的文件内容和inode,它们在磁盘上占用相同的空间。
- 软链接只是一个指向原始文件的路径,它自身的大小非常小,只占用一小块磁盘空间。
- 修改和删除:
- 当原始文件被修改时,所有硬链接和原始文件都会反映出这些修改,因为它们指向相同的inode。
- 当原始文件被删除时,硬链接仍然保持有效,因为它们指向相同的inode。只有当所有硬链接都被删除时,才会释放磁盘空间。
- 当原始文件被删除时,软链接仍然存在,但它指向的是一个不存在的文件路径,称为死链接。
- 目录链接:
- 硬链接不能创建目录的链接,只能创建文件的链接。
- 软链接可以创建目录的链接,可以指向其他目录,这样可以方便地创建文件或目录的快捷方式。
在给文件创建一个硬链接后,删除原来的文件,我们仍可以通过硬链接查看原来的文件内容,只有当所有硬链接都被删除时,才会释放磁盘空间。
硬链接为什么不能跨文件系统?
我们之前提到过,硬链接是通过 inode 节点号建立连接的,而硬链接和源文件共享相同的 inode 节点号。
然而,每个文件系统都有自己的独立 inode 表,且每个 inode 表只维护该文件系统内的 inode。如果在不同的文件系统之间创建硬链接,可能会导致 inode 节点号冲突的问题,即目标文件的 inode 节点号已经在该文件系统中被使用。
提高文件系统性能的方式有哪些?
- 优化硬件:使用高速硬件设备(如 SSD、NVMe)替代传统的机械硬盘,使用 RAID(Redundant Array of Inexpensive Disks)等技术提高磁盘性能。
- 选择合适的文件系统选型:不同的文件系统具有不同的特性,对于不同的应用场景选择合适的文件系统可以提高系统性能。
- 运用缓存:访问磁盘的效率比较低,可以运用缓存来减少磁盘的访问次数。不过,需要注意缓存命中率,缓存命中率过低的话,效果太差。
- 避免磁盘过度使用:注意磁盘的使用率,避免将磁盘用满,尽量留一些剩余空间,以免对文件系统的性能产生负面影响。
- 对磁盘进行合理的分区:合理的磁盘分区方案,能够使文件系统在不同的区域存储文件,从而减少文件碎片,提高文件读写性能。
常见的磁盘调度算法有哪些?
磁盘调度算法是操作系统中对磁盘访问请求进行排序和调度的算法,其目的是提高磁盘的访问效率。
一次磁盘读写操作的时间由磁盘寻道/寻找时间、延迟时间和传输时间决定。磁盘调度算法可以通过改变到达磁盘请求的处理顺序,减少磁盘寻道时间和延迟时间。
常见的磁盘调度算法有下面这 6 种(其他还有很多磁盘调度算法都是基于这些算法改进得来的):
常
- 先来先服务算法(First-Come First-Served,FCFS):按照请求到达磁盘调度器的顺序进行处理,先到达的请求的先被服务。FCFS 算法实现起来比较简单,不存在算法开销。不过,由于没有考虑磁头移动的路径和方向,平均寻道时间较长。同时,该算法容易出现饥饿问题,即一些后到的磁盘请求可能需要等待很长时间才能得到服务。
- 最短寻道时间优先算法(Shortest Seek Time First,SSTF):也被称为最佳服务优先(Shortest Service Time First,SSTF)算法,优先选择距离当前磁头位置最近的请求进行服务。SSTF 算法能够最小化磁头的寻道时间,但容易出现饥饿问题,即磁头附近的请求不断被服务,远离磁头的请求长时间得不到响应。
- 扫描算法(SCAN):也被称为电梯(Elevator)算法,基本思想和电梯非常类似。磁头沿着一个方向扫描磁盘,如果经过的磁道有请求就处理,直到到达磁盘的边界,然后改变移动方向,依此往复。SCAN 算法能够保证所有的请求得到服务,解决了饥饿问题。但是,如果磁头从一个方向刚扫描完,请求才到的话。这个请求就需要等到磁头从相反方向过来之后才能得到处理。
- 循环扫描算法(Circular Scan,C-SCAN):SCAN 算法的变体,只在磁盘的一侧进行扫描,并且只按照一个方向扫描,直到到达磁盘边界,然后回到磁盘起点,重新开始循环。
- 边扫描边观察算法(LOOK):LOOK 算法对 SCAN 算法进行了改进,如果磁头移动方向上已经没有别的请求,就可以立即改变磁头移动方向,依此往复。也就是边扫描边观察指定方向上还有无请求,因此叫 LOOK。
- 均衡循环扫描算法(C-LOOK):C-LOOK 算法对 C-SCAN 算法进行了改进,如果磁头移动的方向上已经没有磁道访问请求了,就可以立即让磁头返回,并且磁头只需要返回到有磁道访问请求的位置即可。
Shell编程
Hello World
touch helloworld.shchmod +x helloworld.shvim helloworld.sh
#!/bin/bash
echo "hello world!"
./helloworld.sh
Shell变量
#!/bin/bash
#自定义变量hello
hello="hello world"
echo $hello
echo "helloworld!"
Shell字符串
可以用单引号和双引号,双引号中可以加变量,如:
#!/bin/bash
name='SnailClimb'
hello="Hello, I am "$name"!"
echo $hello
拼接字符串
#!/bin/bash
name="SnailClimb"
# 使用双引号拼接
greeting="hello, "$name" !"
greeting_1="hello, ${name} !"
echo $greeting $greeting_1
# 使用单引号拼接
greeting_2='hello, '$name' !'
greeting_3='hello, ${name} !'
echo $greeting_2 $greeting_3

获取字符串长度
#!/bin/bash
#获取字符串长度
name="SnailClimb"
# 第一种方式
echo ${#name} #输出 10
# 第二种方式
expr length "$name";
使用 expr 命令时,表达式中的运算符左右必须包含空格,如果不包含空格,将会输出表达式本身:
expr 5+6 // 直接输出 5+6
expr 5 + 6 // 输出 11
某些运算符还需要转义:
expr 5 * 6 // 输出错误
expr 5 \* 6 // 输出30
截取子字符串
简单截取:
#从字符串第 1 个字符开始往后截取 10 个字符
str="SnailClimb is a great man"
echo ${str:0:10} #输出:SnailClimb
根据表达式截取:
#!bin/bash
var="https://www.runoob.com/linux/linux-shell-variable.html"
# %表示删除从后匹配, 最短结果
# %%表示删除从后匹配, 最长匹配结果
# #表示删除从头匹配, 最短结果
# ##表示删除从头匹配, 最长匹配结果
# 注: *为通配符, 意为匹配任意数量的任意字符
s1=${var%%t*} #h
s2=${var%t*} #https://www.runoob.com/linux/linux-shell-variable.h
s3=${var%%.*} #http://www
s4=${var#*/} #/www.runoob.com/linux/linux-shell-variable.html
s5=${var##*/} #linux-shell-variable.html
Shell数组
只支持一维数组:
#!/bin/bash
array=(1 2 3 4 5);
# 获取数组长度
length=${#array[@]}
# 或者
length2=${#array[*]}
#输出数组长度
echo $length #输出:5
echo $length2 #输出:5
# 输出数组第三个元素
echo ${array[2]} #输出:3
unset array[1]# 删除下标为1的元素也就是删除第二个元素
for i in ${array[@]};do echo $i ;done # 遍历数组,输出:1 3 4 5
unset array; # 删除数组中的所有元素
for i in ${array[@]};do echo $i ;done # 遍历数组,数组元素为空,没有任何输出内容
Shell基本运算符
算数运算符

#!/bin/bash
a=3;b=3;
val=`expr $a + $b`
#输出:Total value : 6
echo "Total value : $val"
关系运算符

#!/bin/bash
score=90;
maxscore=100;
if [ $score -eq $maxscore ]
then
echo "A"
else
echo "B"
fi
逻辑运算符

#!/bin/bash
a=$(( 1 && 0))
# 输出:0;逻辑与运算只有相与的两边都是1,与的结果才是1;否则与的结果是0
echo $a;
布尔运算符

字符串运算符

文件运算符

Shell流程控制
if-else
#!/bin/bash
a=3;
b=9;
if [ $a -eq $b ]
then
echo "a 等于 b"
elif [ $a -gt $b ]
then
echo "a 大于 b"
else
echo "a 小于 b"
fi
for
for loop in 1 2 3 4 5
do
echo "The value is: $loop"
done
#!/bin/bash
for i in {0..9};
do
echo $RANDOM;
done
#!/bin/bash
for((i=1;i<=5;i++));do
echo $i;
done;
while
#!/bin/bash
int=1
while(( $int<=5 ))
do
echo $int
let "int++"
done
读取键盘信息:
echo '按下 <CTRL-D> 退出'
echo -n '输入你最喜欢的电影: '
while read FILM
do
echo "是的!$FILM 是一个好电影"
done
Shell函数
不带参数没有返回值
#!/bin/bash
hello(){
echo "这是我的第一个 shell 函数!"
}
echo "-----函数开始执行-----"
hello
echo "-----函数执行完毕-----"
不带参数有返回值
#!/bin/bash
funWithReturn(){
echo "输入第一个数字: "
read aNum
echo "输入第二个数字: "
read anotherNum
echo "两个数字分别为 $aNum 和 $anotherNum !"
return $(($aNum+$anotherNum))
}
funWithReturn
echo "输入的两个数字之和为 $?"
带参数
#!/bin/bash
funWithParam(){
echo "第一个参数为 $1 !"
echo "第二个参数为 $2 !"
echo "第十个参数为 $10 !"
echo "第十个参数为 ${10} !"
echo "第十一个参数为 ${11} !"
echo "参数总数有 $# 个!"
echo "作为一个字符串输出所有参数 $* !"
}
funWithParam 1 2 3 4 5 6 7 8 9 34 73
5. 数据结构
图与图的搜索
堆与堆的排序
树与树的遍历
红黑树
特点:
- 每个节点非红即黑;
- 根节点总是黑色的;
- 每个叶子节点都是黑色的空节点(NIL 节点);
- 如果节点是红色的,则它的子节点必须是黑色的(反之不一定);
- 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)。
红黑树的应用:TreeMap、TreeSet 以及 JDK1.8 的 HashMap 底层都用到了红黑树。
为什么要用红黑树? 简单来说红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。详细了解可以查看 漫画:什么是红黑树?open in new window(也介绍到了二叉查找树,非常推荐)
布隆过滤器
它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。(参考自维基百科)
参考布隆过滤器
6. JUC并发
程序计数器
执行的是 native 方法,那么程序计数器记录的是 undefined 地址。
执行的是 Java 代码时,程序计数器会记录下一条指令的地址。
程序计数器私有主要是为了线程切换后能恢复到正确的执行位置
虚拟机栈和本地方法栈为什么是私有的?
- 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
为什么私有:保证线程中的局部变量不被别的线程访问到
什么是线程上下文切换?
上下文:线程在执行过程中会有自己的运行条件和状态
某些情况下,线程会从占用CPU状态退出,这意味着需要保存当前线程的上下文,留待当前线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
总结:保存当前线程上下文,加载下一线程上下文
ps:会发生上下文切换的线程切换条件:
- 主动让出 CPU,比如调用了
sleep(),wait()等。- 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
- 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
volatile 关键字
-
保证变量可见性
在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
-
禁止指令重排序
如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
经常在双重校验锁实现单例模式时使用
volatile关键字,如果不是用该关键字,由于JVM具有指令重排的特性,单线程不会出现问题,但是多线程环境下会导致线程获得还没有初始化的实例。具体查看:双重校验锁实现对象单例(线程安全)
乐观锁的常见实现及问题
版本号机制
在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
CAS算法
CAS 涉及到三个操作数:
- V:要更新的变量值(Var)
- E:预期值(Expected)
- N:拟写入的新值(New)
当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。
常见问题
- ABA问题
- 循环时间长开销大:CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。
- 只能保证一个共享变量的原子操作,当设计多个共享变量时CAS无效。
从JDK1.5后,提供了
AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。
synchronized关键字
在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。
在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
synchronized如何使用
- 修饰实例方法 (锁当前对象实例)
- 修饰静态方法 (锁当前类)
- 修饰代码块 (锁指定对象/类)
总结:
-
加到
static静态方法和synchronized(class)代码块上都是是给 Class 类上锁; -
synchronized关键字加到实例方法上是给对象实例上锁; -
尽量不要使用
synchronized(String a)因为 JVM 中,字符串常量池具有缓存功能。 -
构造方法不能使用
synchronized关键字,因为构造方法本身就是线程安全的。
synchronized底层原理
当一个线程执行到 synchronized 块或方法时,JVM 会首先检查对象的对象头中的监视器状态。如果监视器状态为空,表示该对象没有被锁定,线程将尝试获取锁并将对象的监视器状态设置为锁定状态,此时其他线程无法进入同步块。 如果监视器状态已经被其他线程占用,则线程会进入阻塞状态,直到获得锁。
当线程执行完 synchronized 块或方法后,JVM 会将对象的监视器状态重置为未锁定状态,并唤醒可能正在等待锁的其他线程。
这种基于对象头和监视器的机制确保了同一时刻只有一个线程可以获得对象的锁,并进入临界区,从而保证了线程安全。
需要注意的是,JVM 还会进行优化,例如偏向锁、轻量级锁和重量级锁等概念,旨在提高同步性能和减少锁竞争的开销。
synchronized和volatile区别
- 性能方面:volatile的性能更高
- 修饰区域:volatile只能用于变量,而synchronized还可以修饰方法和代码块。
- 可见性与原子性:volatile只能保证前者,synchronized都能保证
- 解决问题:volatile解决多个线程之间的可见性,而synchronized解决了多个线程之间访问资源的同步性
ReentrantLock
实现了Lock接口,是一个可重入且独占式的锁。相对synchronized增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
ReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。
公平锁:锁释放后,先申请的线程先得到锁,性能差
非公平锁:锁释放后,后申请的线程可能先得到锁,可能导致某些线程永远无法获取锁
synchronized 和 ReentrantLock 有什么区别?
-
两者都是可重入锁
可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。
-
synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
synchronized 是依赖于 JVM 实现的,因此我们无法查看底层源代码,ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),可以查看源代码。
-
ReentrantLock 比 synchronized 增加了一些高级功能
等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制。也就是说正在等待锁的线程可以选择放弃等待,改为处理其他事情。
可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁
可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。
笔者初读时不太理解可实现选择性通知(锁可以绑定多个条件),下面以示例方式展示
import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; class Buffer { private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); private int count; private final int capacity; public Buffer(int capacity) { this.capacity = capacity; } public void put(int value) throws InterruptedException { lock.lock(); try { while (count == capacity) { notFull.await(); // 缓冲区已满,等待非满条件 } // 执行放入操作 // ... count++; notEmpty.signal(); // 通知非空条件 } finally { lock.unlock(); } } public int take() throws InterruptedException { lock.lock(); try { while (count == 0) { notEmpty.await(); // 缓冲区为空,等待非空条件 } // 执行取出操作 // ... count--; notFull.signal(); // 通知非满条件 } finally { lock.unlock(); } } }在上面的例子中,Buffer 类代表了一个有界缓冲区。生产者通过 put() 方法将数据放入缓冲区,消费者通过 take() 方法从缓冲区中取出数据。
在生产者的 put() 方法中,首先获取锁,并使用 while 循环检查缓冲区是否已满。如果缓冲区已满,则调用 notFull.await() 方法将线程置于等待状态,直到非满条件发生变化。一旦非满条件满足(即缓冲区有空闲位置),线程被唤醒,并继续执行放入操作,然后调用 notEmpty.signal() 方法通知消费者线程缓冲区非空。
在消费者的 take() 方法中,类似地,首先获取锁,并使用 while 循环检查缓冲区是否为空。如果缓冲区为空,则调用 notEmpty.await() 方法将线程置于等待状态,直到非空条件发生变化。一旦非空条件满足(即缓冲区有数据可供消费),线程被唤醒,并继续执行取出操作,然后调用 notFull.signal() 方法通知生产者线程缓冲区非满。
通过使用 ReentrantLock 的多条件机制,生产者和消费者可以有选择地等待和通知,只在满足特定条件时进行操作,从而实现了更精确的线程同步和线程间通信。
ReentrantReadWriteLock
可以将它理解为两把锁,及共享的读锁和不共享的写锁。
读锁为什么不能升级为写锁?
- 会引起线程的争夺,影响性能。
- 会导致死锁。假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。
StampedLock
适合读多写少的业务场景,性能比ReentrantReadWriteLock更好的读写锁,不可重入且不支持条件变量 Conditon。
StampedLock 提供了三种模式的读写控制模式:读锁、写锁和乐观读。
- 写锁:独占锁,一把锁只能被一个线程获得。当一个线程获取写锁后,其他请求读锁和写锁的线程必须等待。类似于
ReentrantReadWriteLock的写锁,不过这里的写锁是不可重入的。 - 读锁 (悲观读):共享锁,没有线程获取写锁的情况下,多个线程可以同时持有读锁。如果己经有线程持有写锁,则其他线程请求获取该读锁会被阻塞。类似于
ReentrantReadWriteLock的读锁,不过这里的读锁是不可重入的。 - 乐观读:允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。
StampedLock 的性能为什么更好?
StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。
ThreadLocal
原理:
- 在
ThreadLocal类中声明一个静态内部类ThreadLocalMap,用于存储线程本地变量。 - 在
Thread类中定义一个ThreadLocalMap类型的成员变量threadLocals,用于存储当前线程的线程本地变量。 - 当一个线程首次访问
ThreadLocal变量时,它会调用Thread类中的currentThread方法获取当前线程对象,然后通过该对象获取ThreadLocalMap对象。 - 如果
ThreadLocalMap对象不存在,则创建一个新的ThreadLocalMap对象,并将其保存在当前线程的ThreadLocalMap变量中。 - 如果
ThreadLocalMap对象已存在,则直接使用该对象。 - 接下来,线程将使用
ThreadLocal对象作为键,在ThreadLocalMap对象中查找对应的值。如果值不存在,则调用ThreadLocal的initialValue方法创建一个新的局部变量,并将其保存在ThreadLocalMap对象中;如果值已存在,则直接返回该值。 - 当线程结束时,它所持有的
ThreadLocalMap对象也将被回收,从而避免了内存泄漏问题。
AQS
介绍
AQS 的全称为 AbstractQueuedSynchronizer ,即抽象队列同步器。
使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue等等皆是基于 AQS 的。
AQS核心思想
如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并锁定共享资源;如果共享资源被占用,则使用一套线程阻塞,等待以及被唤醒时锁分配的机制,该机制AQS是基于CLH锁来实现的。
其中,state表示同步状态,A线程lock()时,会调用 tryAcquire() 独占该锁并将 state+1 。A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。
CLH 锁是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系),暂时获取不到锁的线程将被加入到该队列中。AQS 将每条请求共享资源的线程封装成一个 CLH 队列锁的一个结点(Node)来实现锁的分配。
在 CLH 队列锁中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。
AQS 资源共享方式
Exclusive(独占,只有一个线程能执行,如ReentrantLock)。
Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
一般来说,自定义同步器的共享方式要么是独占,要么是共享。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
