编程满天星题解分享

根据chatgpt采集

Java 后端开发工程师、React 前端开发工程师、全栈开发工程师、iOS 开发工程师、Android 开发工程师等。

1
我想让你担任 Java 后端开发工程师候选人。我成为面试官,将向你询问 Java 后端开发工程师面试问题。我希望你只作为候选者回答。

char 和 varchar 的区别,哪个索引效率更高?

1
2
3
4
5
6
char 和 varchar 都是用来存储字符串的数据类型,在索引效率上通常 char 会比 varchar 更高。
char 是固定长度的数据类型。意味着存储在 char 类型字段中的字符串长度是固定的。如果存储的字符串小于字段的设定长度,它将使用额外的空格来填满剩余的空间。因此,它在存取速度上往往更快,因为数据库精确知道每个条目的存储长度。
varchar 类型则是可变长度的字符串。只存储实际需要的空间加上一个额外的长度前缀来记录字符串的长度。varchar 类型更节省空间,对于长度差异较大的字段更加灵活和经济。
在选择使用 char 还是 varchar 作为索引时,如果数据项长度固定或变化很小,且长度较短,使用 char 可能会有轻微的性能优势,因为固定长度的数据型态使得索引更容易计算位置。但如果长度变化较大,则推荐使用 varchar,以节约存储空间和减少碎片,这对总体性能可能更有好处。
实际选择时,还需要考虑数据的使用模式,如果频繁更新的字段使用 char 类型可能会造成更多的空间浪费,并且导致碎片化,这种情况下 varchar 是更理想的选择。最终应该根据实际使用场景和性能测试的结果来选择最适合的数据类型。

JDK 和 JRE 的区别

1
2
3
4
5
6
7
8
9
JDK 和 JRE 是 Java 平台的两个主要组件,它们的区别如下:
JRE(Java Runtime Environment,Java 运行时环境):
JRE 包含了运行 Java 程序所需的环境,包括 Java 虚拟机(JVM)、核心类库以及其他支撑文件。
它主要用于运行 Java 程序,不包含编译程序的工具。如果用户只需要运行一个 Java 程序,而不需要开发新的程序,那么就只需安装 JRE 就可以了。
JDK(Java Development Kit,Java 开发工具包):
JDK 包括了 JRE,同时还包括了开发 Java 程序时需要的编译器(javac)、各种开发工具(如JavaDoc、Java Debugger等)以及库管理和开发用的文件。
它是为了 Java 程序的开发而设计的,开发人员需要使用 JDK 来编写、调试和编译他们的 Java 程序。
简而言之,JRE 是运行 Java 程序的用户的运行时环境,而 JDK 包含了 JRE,并且额外提供了一套完整的 Java 程序开发工具包。如果你是 Java 开发人员,那么需要安装 JDK;如果只是需要运行 Java 程序,那么安装 JRE 就足够了。

datetime 和 timestamp 的区别

1
2
3
4
5
6
7
8
9
10
11
12
13
在数据库系统中,datetime 和 timestamp 都用来存储日期和时间信息,但它们在行为和用途上有一些关键的区别:
存储格式:
datetime 存储的是日期和时间,它没有包含任何时区信息。datetime 字段的值与时区无关。
timestamp 存储的是从UNIX纪元(1970年1月1日00:00:00 UTC)起的秒数,它通常用来表示时间戳,并且与时区相关联。
时区处理:
datetime 在存储和检索时不考虑时区。当你存储一个 datetime 值时,得到的就是你存储的原始值。
timestamp 在存储时将依据数据库的时区设置进行转换,并将时间转化为UTC时间存储。在检索时,timestamp 值又会被转换回当前数据库设置的时区。
取值范围:
datetime 和 timestamp 支持的日期范围可能不同,这取决于数据库系统的规范。通常 datetime 的取值范围比 timestamp 要宽广。
应用场景:
由于 datetime 提供的是绝对时间,并且与时区无关,所以适合用于存储需要确切时间表示的事件,例如出生日期。
timestamp 由于其时区转换的特性,通常用于存储记录创建或修改的时间,它可以自动更新到记录被更改时的当前时间。
具体使用哪个数据类型取决于应用场景以及具体的数据库系统要求。在涉及多时区数据时,timestamp 通常是更合适的选择,因为它可以根据数据库设置自动调整时区。而在无需考虑时区影响的情况下,datetime 可能会是更简单的选择

为什么说 MyBatis 是半自动 ORM 映射工具?它与全自动的区别在哪里

1
2
3
MyBatis 被称为半自动ORM(Object-Relational Mapping)映射工具的原因在于,它在对象与数据库表之间的映射处理上,需要开发者明确指定映射规则。在MyBatis中,你通常需要写SQL语句,并将它们与对象的属性手动映射。这为开发者提供了更高的控制权,但也意味着不能像全自动ORM工具那样自动完成映射。
全自动ORM工具(如Hibernate、JPA等),通常能够通过注解或XML配置文件自动生成SQL语句,并自动将数据库表映射到对象属性,不需要开发者手动编写大量SQL代码。这降低了代码的复杂性,并加快了开发速度,但同时可能会降低一些运行时的效率和控制精度。
因此,MyBatis给予开发者更多的灵活性去优化SQL语句,对于那些对数据库操作有着高度个性化需求的开发场景十分有利。而全自动ORM工具则更多地适用于那些希望快速开发,并愿意牺牲一定的运行时性能和控制能力以换取开发效率的场景

MyBatis 是如何实现对象与数据库表之间的映射的?

1
2
3
4
5
6
7
8
MyBatis 实现对象与数据库表之间的映射主要通过以下机制:
SQL映射文件:在MyBatis中,每个数据库操作通常对应一个映射文件中的元素(如<select><insert><update><delete>)。映射文件定义了具体的SQL语句和对应的返回类型或参数类型。
参数映射:在SQL映射文件中,可以使用占位符引用传入的参数。MyBatis 通过使用#{}和${}占位符来替换实际的参数值。#{}用于预处理参数(防止SQL注入),${}则会直接替换为参数值。
结果映射:当执行查询操作时,MyBatis通过<resultMap>标签定义如何将结果集映射到Java对象的属性中。可以显式地指定列名和对象属性之间的映射关系。
输入映射:对于传入的复杂对象,MyBatis 允许你在<parameterMap>中定制参数类型和结构,虽然这种方式现在已经比较少用,更多是通过注解或直接在SQL语句中引用对象属性。
会话管理:MyBatis通过SqlSessionFactory创建SqlSession, 在这个会话中会执行映射文件中定义的SQL语句。
动态SQL:MyBatis支持动态SQL语句,这允许在运行时根据不同的条件构造不同的SQL语句,例如使用<if><choose><when><otherwise><foreach>等标签。
通过这些机制,MyBatis 将Java对象的属性和数据库表的列连接起来,实现了对象与数据库表之间的映射关系。开发者需要为每种数据库操作提供映射配置,确保数据正确地从数据库转移到应用程序对象中,以及相反方向的转移。

MySQL 索引底层结构(B/B+ 树原理与区别)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
索引有哪几种底层实现?
1)哈希索引:得益于哈希算法的快速查找特性,不过哈希索引的致命缺点就是无法范围查询。

2)B 树索引:B 代表平衡(Balance),而不是二叉(Binary)。B 树的特点就是

节点同时保存 key 和 data
B 树在找到具体的数值以后就结束,查询复杂度不稳定,最好为 O(1),数据就在第一层(所以 B 树适合把热点数据存在上层,适用于缓存)


3)B+ 树索引:B+ 树索引是目前关系型数据库系统中查找最为常用的索引。B+ 树的特点是

非叶子节点只保存 key,而叶子节点保存 key 和 data

并且各叶子节点之间通过双向链表按照大小进行连接。也就是说,<u>B+ 树的叶子节点存储真正的记录,而非叶子节点的存在是为了更快速的找到对应记录所在的叶子节点</u>。所以时间复杂度稳定,每次都需要走到叶子节点,时间复杂度为 O(LogN)。

并且由于所有数据都在叶子节点且顺序连接,所以 B+ 树可使用在范围查询,而 B 树无法进行范围查询



B+ 树的叶子节点并不是只存一 行 数据,而是存一 页 数据!每个页面中有很多行记录

InnoDB 不是按行的来操作的,它可操作的最小粒度是页,页加载进内存后才会通过扫描页来获取行记录。具体查找流程就是先在 B+ 树上通过二分查找找到某页,再进入该页进行查询

InnoDB使用B+树索引作为其主要数据结构,因为B+树提供了几个关键优势,特别适合数据库存储系统的需求:
高效的范围查询:由于B+树的所有叶子结点都是相互连接的,这使得进行范围查询时特别高效,因为可以在找到范围的起点后顺序遍历叶子结点链表,迅速访问所有在这个范围内的数据。
更低的树高:B+树只有叶子结点保存数据,并且内部结点更多地用于索引,这使得B+树有更多的分支并且层级更低,从而减少了查找时需要访问的磁盘页面数,这对于I/O访问是非常关键的,因为磁盘访问速度是限制数据库性能的一个重要因素。
磁盘I/O优化:数据库系统通常将数据存储在磁盘上,而磁盘读写比内存访问要慢得多。由于B+树具有较低的高度,可以减少在查询过程中所需的磁盘I/O操作,从而在大型数据库系统中提高效率。
存储效率:在B+树中,内部结点不存储数据,只存储键,这意味着内部结点可以存储更多的键,使得B+树可以更加紧凑,提高数据的存储效率。
全表扫描性能:B+树的叶子结点连成一条链,对于全表扫描操作,可以只通过顺序访问叶子结点链来完成,而不需要遍历整个树。
由于这些优势,B+树使得InnoDB存储引擎在处理大数据量时具有很好的性能和稳定性,这就是它被广泛采用的原因。
B+树的时间复杂度是O(log n)

有哪些隔离级别,分别解决什么问题(丢失更新、脏读、不可重复读、幻读)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
数据库的事务隔离级别通常分为以下四种,每种隔离级别解决了不同的并发问题:
Read Uncommitted (读未提交):
最低的隔离级别,只能防止丢失更新。
允许脏读,即事务可以读取未提交的数据变更。
不能解决不可重复读和幻读的问题。
Read Committed (读已提交):
防止丢失更新和脏读。
在这个级别,一旦事务提交,其变更就会被其他事务看到,从而避免脏读。
但是它允许不可重复读,即同一事务的两次读操作可能会看到不同的数据,因为其他事务在这两次读操作中间提交了更新。
同样也不能解决幻读问题。
Repeatable Read (可重复读):
解决了脏读和不可重复读的问题。
这种隔离级别确保在同一事务中多次读取同样的记录结果是一致的。
但在MySQL的InnoDB存储引擎中,Repeatable Read也能防止幻读,因为它使用了Next-Key Locking技术。然而,按照标准SQL的定义,Repeatable Read可能无法解决幻读问题,即一个事务读取到另外一个事务新插入的数据行。
Serializable (可串行化):
最高的隔离级别,解决了丢失更新、脏读、不可重复读和幻读的所有问题。
它通过对参与事务的所有数据行加锁来防止其他事务访问它们,从而保证事务能够串行化执行。
这种隔离级别确保了完全的数据一致性,但代价是可能会严重影响数据库的性能,并增加锁争用的可能性。
不同的数据库隔离级别对并发控制的严格程度不同,隔离级别越高,数据一致性越好,但并发能力越弱。因此在实践中需要根据应用的具体需求来选择合适的隔离级别。

在MySQL的InnoDB存储引擎中,为什么Repeatable Read能够防止幻读问题?

1
2
3
在MySQL的InnoDB存储引擎中,Repeatable Read隔离级别能够防止幻读问题主要是因为它使用了一种称为Next-Key Locking的锁机制。Next-Key Locking是一种组合了行锁定和间隙锁定的锁机制,它不仅锁定了满足查询条件的索引记录,还锁定了这些记录之间的间隙。
这意味着,当一个事务在Repeatable Read隔离级别下读取了一组数据后,不仅这些数据本身无法被其他事务修改或删除(避免不可重复读),同时也不能在这个范围内插入新的数据(避免幻读)。因此,即使在当前事务执行期间,其他事务也无法插入新行,所以事务可以防止幻读的出现。
通过这种方式,InnoDB保证在Repeatable Read隔离级别下执行的事务能够在事务内部多次执行同一查询而得到相同的结果,即便有其他事务同时也在进行写操作。是的,这是一个特别设计的机制,确保在大多数情况下Repeatable Read隔离级别能够在InnoDB存储引擎中避免幻读,尽管在标准的SQL定义中,只有最高级别的可串行化(Serializable)才能完全保证避免幻读

四种隔离级别是怎么实现的(详细说说 MVCC 的原理)

1
2
3
4
5
6
7
8
9
10
11
12
13
数据库中提供了四种不同的事务隔离级别,它们是为了平衡在多用户环境下并发事务带来的问题(如脏读、不可重复读和幻读)与系统性能之间的关系。隔离级别由高到低分别是:
串行化(SERIALIZABLE):这是最高的隔离级别,事务串行执行,防止了脏读、不可重复读及幻读。但它的并发性最差,因为它通常会通过锁定表中的记录、页或整个表来实现。
可重复读(REPEATABLE READ):保证在事务执行期间,看到的数据不会改变,即无法看到其他事务修改后的数据,避免了脏读和不可重复读,但仍可能有幻读。在MySQL的InnoDB存储引擎中,这个级别通过MVCC以及间隙锁来实现。
读已提交(READ COMMITTED):只能读到已经提交的数据,解决脏读问题,但不可重复读和幻读仍可能发生。通常,会用到锁或MVCC来实现该隔离级别,以确保事务只看到已经提交的数据。
读未提交(READ UNCOMMITTED):最低级别,一个事务可以看到其他事务未提交的修改,可能会读到脏数据,若其他事务回滚,读到的数据就是无效的。
MVCC(多版本并发控制)原理:
MVCC是一种用于实现事务隔离的技术,主要用在可重复读和读已提交级别。其原理如下:
版本控制:策略之一是为每一次写入都创建数据记录的一个新版本,而不是原地更新记录。每个版本都会有一个时间戳(版本号)。
事务时间戳:事务开始时获取一个唯一的时间戳(版本号)。
读操作:当事务试图读取数据时,它只能看到时间戳早于该事务时间戳的数据版本。这样可以保证在事务读取过程中数据的一致性视图。
写操作:当事务进行写操作时,它会创建数据的新版本,并将版本号设置为事务的时间戳,如果有更高时间戳的事务正在读取同样的数据,他们依然能够看到旧数据。
垃圾回收:随着时间的推移,不再被任何事务需要的旧版本数据会被系统清理掉。
通过MVCC,数据库能够同时处理多个活跃的读写事务,而无需进行昂贵的锁定资源,大大提高了并发性能。

TCP 三次握手的过程

TCP 三次握手示例图

三次握手的第一个报文:SYN 报文

1
2
3
4
5
6
7
第一次握手(客户端 -> 服务端):客户端会随机初始化序号(client_isn),将此序号置于 TCP 首部的「序列号」字段中,同时把 SYN 标志位置为 1 ,表示 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态。


第二次握手(服务端 -> 客户端):服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(server_isn),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入 client_isn + 1, 接着把 SYN 和 ACK 标志位置为 1。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。

第三次握手(客户端 -> 服务端):客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认应答号」字段填入 server_isn + 1 ,最后把报文发送给服务端,这次报文可以携带客户到服务器的数据,之后客户端处于 ESTABLISHED 状态。

为什么TCP三次握手 不是2次 或者4次

1
2
3
4
5
确保双方准备就绪:第一次握手是客户端告诉服务器它想建立连接;第二次握手是服务器告诉客户端它也准备好了,可以接受客户端的数据;第三次握手是客户端再次确认之后,数据传输才会开始。
防止失效的连接请求到达服务器:如果只有两次握手,那么可能会因为网络延迟导致的失效的连接请求被服务器建立。这样的话,服务器端可能开辟资源等待接收数据,但实际上是一个失效的连接请求,这会浪费服务器资源。第三次握手使客户端有机会确认连接请求是否依然有效。
同步序列号:TCP是基于序列号的协议,通过三次握手,双方都可以确认各自的初始序列号,为后续的可靠数据传输做好准备。

至于为什么不是四次或者更多次握手,因为三次已经足够用来建立一个可靠的会话,并且额外的握手会增加延迟和不必要的复杂性。过程中的每一步都是有目的的,没有冗余最终保证了能够高效并且可靠地建立连接。

TCP的2MSL等待时间有什么作用,它是如何保持可靠性和网络数据唯一性的?

1
2
3
4
确保最后一个ACK被对方收到:等待足够长的时间可以使得最后发送的ACK确认包能够到达对方。如果对方没有收到这个ACK,它会重新发送FIN包。而在这个2MSL窗口内,主动关闭的一方还可以接收并对这些重新传输的FIN包做出响应。
防止之前的数据包出现在新连接中:等待2MSL可以确保在相同的(source IP, source port, destination IP, destination port)四元组的旧连接的所有报文都从网络中消失,这样新建立的连接就不会收到这些延迟的报文片段。
总的来说,2MSL等待时间是TCP保持可靠性和网络数据唯一性的机制,用以处理网络中可能延迟的数据包和连接断开的数据逻辑关闭。

TCP的四次挥手的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
TCP的四次挥手过程是指在TCP/IP网络通信协议中关闭一个已经建立的连接时所采用的过程。四次挥手包括以下步骤:
第一次挥手 - FIN从客户端到服务端:
客户端决定关闭连接,发送一个FIN(finish)包给服务端,用以表示客户端已经没有数据发送了,但是客户端仍然可以接收数据。
第二次挥手 - ACK从服务端到客户端:
服务端接收到这个FIN后,发送一个ACK(acknowledgement)包作为回应,确认收到客户端的FIN。此时,服务端仍然可以发送数据给客户端。
第三次挥手 - FIN从服务端到客户端:
当服务端也决定不再发送数据时,它也需要发送一个FIN包给客户端,以表示服务端的数据也发送完了,即服务端也想关闭连接。
第四次挥手 - ACK从客户端到服务端:
客户端接收到服务端的FIN包后,还要发送一个ACK包给服务端,确认收到了服务端的FIN包。此时,客户端会进入TIME_WAIT状态,等待足够的时间以确保服务端接收到这个ACK包。通常这个时间是2MSL(Maximum Segment Lifetime,最大报文段生存时间)。
在这四次挥手后,当最后的ACK包被服务端接收,这个TCP连接就被彻底关闭了。四次挥手是必要的,因为TCP连接是全双工的,这意味着数据传输是双向独立的。因此,每个方向上的终止都是独立的,需要单独的FIN和ACK包。


在TCP四次挥手的过程中,涉及到几个不同的状态:
FIN_WAIT_1:在第一次挥手阶段,当客户端发送FIN报文并希望关闭连接时,它首先进入FIN_WAIT_1状态。在这个状态下,客户端等待服务器的ACK确认。
CLOSE_WAIT:服务器收到FIN之后,它回复ACK,并且进入CLOSE_WAIT状态。在这个状态下,服务器知道客户端想要关闭连接,但是服务器在发送完待处理数据后才会关闭连接。
FIN_WAIT_2:客户端收到服务器的ACK后,进入FIN_WAIT_2状态。在这个状态下,客户端等待服务器发送它的FIN报文,这将标志着服务器已经准备好关闭连接。
LAST_ACK:服务器准备好关闭连接并发送了自己的FIN报文后,它进入LAST_ACK状态。在这个状态下,服务器等待客户端的最后一个ACK报文。
TIME_WAIT:客户端发送最后一个ACK报文后,它进入TIME_WAIT状态。在这个状态下,客户端等待足够的时间以确保服务器收到ACK报文(这个时间通常是2MSL)。这个状态确保了客户端不会太快重用相同的端口,避免了可能的TCP报文混淆。
CLOSED:当服务器收到最后一个ACK后,它关闭连接,进入CLOSED状态。同样的,当客户端在TIME_WAIT状态下完成了2MSL等待时间,没有接收到重传的FIN报文,那么连接也将被视为终止,客户端也将进入CLOSED状态。
这些状态是为了确保TCP连接能够可靠和有序地终止,防止数据混乱和可能的连接重用问题。

2MSL等待时间如何确保对方收到最终的ACK?

1
2
3
4
在TCP连接的四次挥手过程中,2MSL等待时间被用来确保对方收到最终的ACK的方法是:
当主动关闭连接的一方(通常被称为客户端)收到被动关闭方(通常被称为服务器)的FIN报文后,它会发送一个ACK报文作为回应,并启动2MSL计时器。客户端在这段时间内不会立即关闭连接,而是保持状态为TIME_WAIT,以确保服务器接收到了这个最终的ACK报文。如果由于网络问题导致这个ACK报文丢失,服务器没有收到ACK,它将等待一段时间后重新发送FIN报文。
如果客户端在2MSL等待期间内再次收到服务器的FIN报文(这意味着服务端没有收到最终的ACK),客户端将再次发送一个ACK报文作为回应,并且重新启动2MSL计时器。通过这种方式,2MSL等待时间为失去的ACK提供了一个重传窗口,并确保所有的FIN报文都能被确认,使得双方都能准确地了解连接何时正式结束。
此外,2MSL等待时间也确保了连接被彻底关闭之前网络中的所有重复的报文都已经消失,防止了可能的旧连接数据报文在新建立的连接中出现的情况。这样可以确保后续建立的连接不会收到延迟的、属于旧连接的报文。
1
2
3
4
客户端进入TIME_WAIT状态的目的主要是为了确保最后一个ACK报文能够被服务器收到,这样服务器就可以正常地关闭连接。如果该ACK在网络中丢失,服务器将会重传FIN报文,客户端在TIME_WAIT状态会期待这种情况,并且可以重新发送ACK报文以响应服务器的FIN重传。
此外,TIME_WAIT状态还确保了足够的时间以使得网络中该连接的所有旧数据包都能够在新的连接建立前到期,从而避免了因网络延迟导致的数据包在新连接建立后到达,可能会对新连接造成混淆。这个时间周期通常定为2MSL(Maximum Segment Lifetime),即允许的最大报文在网络中存在的时间。这样做可以保持TCP连接的可靠性和唯一性。


创建线程池时的基本参数?如何合理配置这些参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
创建线程池时的基本参数主要包括:
核心线程数(Core Pool Size):
线程池默认活跃的线程数量,即使这些核心线程处于空闲状态,也不会被回收。
最大线程数(Maximum Pool Size):
线程池允许创建的最大线程数量。当工作队列满了,并且核心线程数已经达到了核心线程大小后,线程池会创建新线程来处理任务,直到线程数达到最大线程数。
空闲线程存活时间(Keep-Alive Time):
当线程池中空闲线程数量超过核心线程数量时,多余的空闲线程会在这个时间后被销毁,以释放资源。
时间单位(Time Unit):
用于指定keep-alive time参数的时间单位,如TimeUnit.SECONDS。
工作队列(Work Queue):
用于在任务量超过线程池的处理能力时暂存待处理任务的队列。这可以是LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue等。
线程工厂(Thread Factory):
用于创建新线程的工厂。你可以设置线程的名字、优先级等属性。
拒绝策略(Rejected Execution Handler):
当任务太多,无法再由线程池处理时,需要有策略来处理这些不被执行的任务。常见的拒绝策略如ThreadPoolExecutor.AbortPolicy(丢弃任务并抛出RejectedExecutionException异常)、CallerRunsPolicy(用调用者所在的线程来运行任务)、DiscardPolicy(默默地丢弃无法处理的任务)、DiscardOldestPolicy(丢弃队列头部(最先请求执行的)任务)等。
合理配置这些参数需要根据任务特性(CPU密集型、IO密集型、混合型)、系统资源(CPU核数等)和业务要求(响应时间、吞吐量)来决定。一般情况下:
对于计算密集型任务,核心线程数设置与处理器数量相近的值可以使CPU的利用率最高;
对于I/O密集型和响应时间要求较为重要的任务,可以设置更多的线程,因为线程会因I/O操作而阻塞;
工作队列的大小和类型应该根据任务提交的频率和处理速度来设置,避免资源耗尽;
空闲线程的存活时间可根据系统负载动态调整,但一般不会设置得太短,以免频繁创建和销毁线程带来的开销;
拒绝策略应根据业务要求决定,确保当系统超负荷时,业务还能平稳运行或优雅退化

Java提供了java.util.concurrent.Executors类,它包含了多种方法来创建不同类型的线程池。以下是几种常用的线程池构造方法及其特点:

  1. Fixed Thread Pool: 使用Executors.newFixedThreadPool(int nThreads)方法创建。这个线程池有固定的线程数量,如果所有线程都在工作,新任务会在队列中等待。

    1
    ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5);
  2. Single Thread Executor: 使用Executors.newSingleThreadExecutor()方法创建。这个线程池只有一个线程,它确保所有的任务都在同一个线程中按序执行。

    1
    ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
  3. Cached Thread Pool: 使用Executors.newCachedThreadPool()方法创建。这个线程池会根据需要创建新线程,并且如果线程空闲60秒以上则将其销毁。这是一个灵活回收空闲线程的线程池。

    1
    ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
  4. Scheduled Thread Pool: 使用Executors.newScheduledThreadPool(int corePoolSize)方法创建。这个线程池可以延迟或定期执行任务。

    1
    ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(5);
  5. Single Thread Scheduled Pool: 使用Executors.newSingleThreadScheduledExecutor()方法创建。这个线程池支持单个后台线程执行定期或延迟任务,并保证任务按顺序执行。

    1
    ScheduledExecutorService singleThreadScheduledExecutor = Executors.newSingleThreadScheduledExecutor();

Executors创建的线程池便于快速使用,但它们有默认的行为和限制,例如:

  • FixedSingle Thread Executor具有无界的任务队列。
  • Cached Thread PoolScheduled Thread Pool 允许创建的线程数量几乎没有上限。

这些特性可能导致线程或内存资源耗尽,尤其是在处理大量任务时。因此,在生产环境中,通常推荐根据具体需求创建ThreadPoolExecutor的实例,它是Executors类中线程池的底层实现,并且提供更多的配置选项,使得你可以自定义线程池的参数,如:最大线程数、存活时间、工作队列和拒绝策略等,以更好地控制资源消耗和任务执行的行为。

说下这几种线程池发生OOM的场景’

线程池可能发生OOM(Out Of Memory,内存溢出)的情况主要与两个因素有关:线程数量的管理和任务队列的大小。以下是这些线程池发生OOM的一些可能场景:

  1. Fixed Thread Pool: 由于有固定数量的线程,如果任务提交速度远大于消耗速度,那么未执行的任务会在无界队列里积压,这可能会导致内存溢出。
  2. Single Thread Executor: 这实际上是固定线程池大小为1的特例。它使用无界队列来保存待执行任务,如果任务提交速度持续大于其处理速度,同样会积压任务,造成内存溢出。
  3. Cached Thread Pool: 因为线程数量理论上是没有限制的(实际上受限于系统资源和整数最大值),如果持续提交任务而没有限制,可能会创建大量线程,每个线程都有自己的栈内存等资源,因此也可能导致内存耗尽。
  4. Scheduled Thread Pool: 即使这个线程池有核心线程数的限制,但其所使用的延迟队列也是无界的,这意味着大量的延迟/周期性任务堆积也可能导致内存耗尽。

在使用线程池避免OOM时应该注意:

  • 合理设置最大线程数,避免创建过多线程;
  • 使用有界队列来限制任务积压量;
  • 为线程池设置合理的拒绝策略,如在队列满时采取某种策略来处理新进的任务;
  • 监控线程池和系统的健康状况,及时处理可能的内存溢出风险;
  • 在系统设计时考虑反压机制,当系统负载过高时减缓任务的提交速率。

说明下几种队列,并列举使用场景

  1. ArrayBlockingQueue: 一个由数组结构组成的有界阻塞队列。此队列按FIFO(先进先出)原则对元素进行排序。适用于固定大小的线程池,你可以明确知道队列所需的最大容量。
  2. LinkedBlockingQueue: 一个由链表结构组成的可选有界阻塞队列。此队列同样按FIFO排序元素,吞吐量通常要高于ArrayBlockingQueue。用于任务队列时,如果不设置容量,则为无界队列,可能导致内存溢出。有界时适用于任务较多的情况,需要在内存和CPU使用之间进行权衡。
  3. PriorityBlockingQueue: 一个支持优先级排序的无界阻塞队列。元素按照自然顺序或者构造时提供的Comparator进行排序。适用于需要按照给定优先级处理任务的情况。
  4. DelayQueue: 一个使用优先级队列实现的无界阻塞队列,其中的元素只能在其指定的延迟时间之后取出。适用于实现任务延迟执行的场景。
  5. SynchronousQueue: 一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,反之亦然。适用于传递性的任务执行场景,如线程池的交付机制。
  6. ConcurrentLinkedQueue: 一个基于链接节点的无界非阻塞队列,此队列按FIFO(先进先出)排序元素。适合多线程并发操作,数据一致性要求不是非常严格的场景。
  7. ConcurrentLinkedDeque: 一个基于链接节点的无界非阻塞双端队列,允许线程安全地在队列的两端插入和移除元素。适合需要快速响应的场景,能够从不同的端进行操作。

这些队列按照不同的需求进行选择和使用,例如任务调度、资源共享、生产者消费者问题等。在实际应用中,还需要根据具体需求和系统资源合理地选择或者自定义队列类型

说一下 MySQL 的事务(ACID 特性)

1
2
3
4
5
6
7
8
9
10
11
MySQL的事务是一组操作,要么全部执行,要么全部不执行,是数据库管理系统执行过程中的一个逻辑单位。事务具有以下四个基本特性,通常称为ACID特性:
原子性(Atomicity):
事务是最小的执行单位,不可再分。原子性确保事务中的操作要么全部完成,要么全部不做,不会出现只执行了一部分操作的情况。如果一个事务中的一部分操作失败,整个事务将回滚,之前的操作就如同没有执行过一样。
一致性(Consistency):
数据库总是从一个一致的状态转移到另一个一臀的状态。事务在完成时,必须保留数据库的一致性,即数据库的完整性约束不会被破坏。
隔离性(Isolation):
通常情况下,一个事务所做的修改在最终提交之前,对其他事务是不可见的。隔离性可以防止多个事务并发执行时互相干扰。不同的隔离级别可以应用于控制事务可见性的程度,这些隔离级别包括读未提交(Read uncommitted)、读已提交(Read committed)、可重复读(Repeatable read)和串行化(Serializable)。
持久性(Durability):
一旦事务提交,则其所做的更改就是永久性的,即使系统发生故障也能保持。后续的系统故障不应影响已经提交的事务执行的结果。
MySQL通过各种存储引擎提供对事务的支持,其中InnoDB是最常用的支持事务的存储引擎,它完整实现了ACID特性。正确的使用事务可以保证数据库操作的安全性和一致性,是数据库设计中非常重要的一个部分。

数据库三大范式(Normalization)是指数据库设计的三个层次的规范要求,目的是减少数据冗余、提高数据的逻辑一致性。这些范式分别是:

  1. 第一范式(1NF): 数据表的每一列都是不可再分的基本数据项,同一个数据表中不存在重复的记录。这意味着表是具有行和列的矩阵结构,并且每个表中的字段值都是单一的,不可分割的。
  2. 第二范式(2NF): 在满足第一范式的基础上,消除非主属性对于码的部分函数依赖。一个表要达到第二范式,它必须首先是满足第一范式的标准,而且它的所有非主属性都完全依赖于主关键字。也就是说,表中不可能出现仅与主键的一部分相关联的字段。
  3. 第三范式(3NF): 在满足第二范式的基础上,消除非主属性对于码的传递函数依赖。这意味着除主键外的所有字段都应该只依赖于主键,不依赖于其他非主键字段(非主属性不依赖于其他非主属性)。

基本上,数据库设计的目标是为了避免冗余数据和更新异常,而这些范式提供了避免这些问题的指导原则。当然,有时为了查询性能、柔性的需求等,设计人员可能会有意识地对某些要求进行放松,这种设计上的权衡称为反规范化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
让我们通过一个假设的学校数据库表设计的例子来解释这三大范式吧。
假设我们有一个记录学生信息、选课情况和教师安排的表:
未规范化的表:StudentCourses
| StudentID | StudentName | CourseID | CourseName | InstructorID | InstructorName |
|-----------|-------------|----------|------------|--------------|----------------|
| 001 | Alice | MATH1010 | Math | I01 | Dr. Smith |
| 002 | Bob | CS1021 | Computing | I02 | Dr. Johnson |
| 001 | Alice | CS1021 | Computing | I02 | Dr. Johnson |
| 003 | Charlie | MATH1010 | Math | I01 | Dr. Smith |
这个表包含了学生的课程选择,但未遵守任何规范化原则。
第一范式(1NF):表中的每个字段值都必须是不可分的。
要满足第一范式,表的每一列都需要是原子性的,但我们的表已经符合了这一点。
第二范式(2NF):表必须在第一范式的基础上,且没有部分依赖。
部分依赖是指表中非主键列只依赖于组成主键的一部分。在上面的表中,StudentName 只依赖于 StudentID,CourseName 和 InstructorName 只依赖于 CourseID,所以我们需要将数据拆分为多个表来消除部分依赖。
Student表 (只依赖于学生ID)
| StudentID | StudentName |
|-----------|-------------|
| 001 | Alice |
| 002 | Bob |
| 003 | Charlie |
Course表 (只依赖于课程ID)
| CourseID | CourseName | InstructorID |
|----------|------------|--------------|
| MATH1010 | Math | I01 |
| CS1021 | Computing | I02 |
Enrollment表 (记录学生和课程的连接信息)
| StudentID | CourseID |
|-----------|----------|
| 001 | MATH1010 |
| 002 | CS1021 |
| 001 | CS1021 |
| 003 | MATH1010 |
现在,我们的设计满足了2NF,因为所有非主要信息都完全依赖于主键,并且没有部分依赖。
第三范式(3NF):表必须在第一、二范式基础上,且没有传递依赖。
传递依赖是指非主键字段依赖于另一个非主键字段。在Course表中,InstructorName依赖于InstructorID,这是一种传递依赖。我们需要进一步分离那些依赖于课程ID的信息。
Course表
| CourseID | CourseName |
|----------|------------|
| MATH1010 | Math |
| CS1021 | Computing |
Instructor表
| InstructorID | InstructorName |
|--------------|----------------|
| I01 | Dr. Smith |
| I02 | Dr. Johnson |
现在,每个表都只包含那些直接依赖于其主键的信息,因此满足第三范式。这样的设计最大化地减少了数据冗余并使得数据维护更加高效。

知道哪些线程安全的 Map(synchronizedMap,Hashtable,ConcurrentHashMap)

1
2
3
4
5
6
7
8
9
10
在Java中,有几种线程安全的Map实现,它们通过不同的方式来保证线程安全:
Hashtable:Hashtable是一个古老的线程安全的Map实现,所有的方法都是同步的。不过,由于它的线程安全是通过锁住整个Map来实现的,这会导致在多线程环境中的性能下降。
Map<K, V> hashtable = new Hashtable<>();
synchronizedMap:Collections.synchronizedMap是一个包装器,它取一个不是线程安全的Map并添加同步。和Hashtable类似,synchronizedMap为访问Map的每个方法提供了一个同步块,这意味着每次只有一个线程能够修改Map,性能上不适合高并发场景。
Map<K, V> synchronizedMap = Collections.synchronizedMap(new HashMap<>());
ConcurrentHashMap:ConcurrentHashMap是Java 5中加入的一个线程安全的Map实现。它提供了更好的并发性能,因为它采用了分段锁(在Java 8及以后版本中使用CAS操作和synchronized来保证安全性)。多个线程可以同时读写Map的不同部分,因此在高并发环境下通常比Hashtable和synchronizedMap表现得更好。
Map<K, V> concurrentHashMap = new ConcurrentHashMap<>();
不同的线程安全Map适用于不同的应用场景,选择哪种取决于具体的性能需求和线程安全级别。在现代Java应用中,ConcurrentHashMap由于其高效的并行性,是首选的线程安全Map实现。


在Java中,有几种线程安全的Map实现,它们通过不同的方式来保证线程安全:

  1. HashtableHashtable是一个古老的线程安全的Map实现,所有的方法都是同步的。不过,由于它的线程安全是通过锁住整个Map来实现的,这会导致在多线程环境中的性能下降。

    1
    Map<K, V> hashtable = new Hashtable<>();
  2. synchronizedMapCollections.synchronizedMap是一个包装器,它取一个不是线程安全的Map并添加同步。和Hashtable类似,synchronizedMap为访问Map的每个方法提供了一个同步块,这意味着每次只有一个线程能够修改Map,性能上不适合高并发场景。

    1
    Map<K, V> synchronizedMap = Collections.synchronizedMap(new HashMap<>());
  3. ConcurrentHashMapConcurrentHashMap是Java 5中加入的一个线程安全的Map实现。它提供了更好的并发性能,因为它采用了分段锁(在Java 8及以后版本中使用CAS操作和synchronized来保证安全性)。多个线程可以同时读写Map的不同部分,因此在高并发环境下通常比HashtablesynchronizedMap表现得更好。

    1
    Map<K, V> concurrentHashMap = new ConcurrentHashMap<>();

不同的线程安全Map适用于不同的应用场景,选择哪种取决于具体的性能需求和线程安全级别。在现代Java应用中,ConcurrentHashMap由于其高效的并行性,是首选的线程安全Map实现。

ConcurrentHashMap是如何通过分段锁来提高并发性能的?

ConcurrentHashMap通过分段锁提高并发性能的过程(特指Java 7及之前版本)可以这样理解:ConcurrentHashMap内部将存储的数据分成一些小的段,这称为segments,每个segment拥有自己的锁。 当需要进行操作(如添加、删除、更新元素)时,并非锁定整个Map,而只是锁定包含被操作数据的那个segment。因此,如果有多个线程访问ConcurrentHashMap,只要它们访问的是不同的segment,线程间就不会产生锁的竞争,它们可以同时进行访问。这大大增加了在高并发情况下的Map的性能,理论上能够同时支持并发的segment数量的线程。 这种设计相较于单一锁机制(例如Hashtable或synchronizedMap)来说,锁的粒度更细,因此在多线程环境中,它能够提供更高的并发性,并减少线程间的竞争和阻塞。这也就是为什么ConcurrentHashMap通常作为首选的线程安全Map用于高并发环境。

在Java 8中,ConcurrentHashMap的实现方式相较于Java 7及之前版本发生了显著的变化,移除了原有的Segment分段锁,而采用了一种新的并发策略,主要变化包括:

  1. 数据结构的改变:Java 8的ConcurrentHashMap放弃了Segment的结构,而是直接使用节点数组(Node array),每个节点包含了键值对。在链表的基础上,进一步使用了红黑树来优化性能。
  2. CAS操作:大量使用了基于CAS(Compare-And-Swap)的无锁操作来实现线程安全的更新,例如在扩容或插入新元素时使用CAS来减少需要同步的地方。
  3. synchronized:在需要同步的情况下,比如链表转换为红黑树或者更新链表中的节点时,使用了细粒度的锁,即直接对节点对象进行加锁。
  4. 红黑树的引入:当链表中的元素数目超过一定阈值(默认为8)时,会将链表转换为红黑树,提高在大量哈希冲突情况下的查询效率。

这些改变使得Java 8及之后版本的ConcurrentHashMap在并发性和性能上相对于旧版本有了很大提升,它减小了锁的粒度,并有效降低了锁竞争,特别适合读多写少的并发场景。同时,这种设计还改善了内存占用和扩展性,从而使得Java 8的ConcurrentHashMap在实际应用中更加高效。

详细说明下put 及hash寻址的过程

在 Java 8 的 ConcurrentHashMap 中,put 操作及哈希寻址的过程可以分为以下几个步骤:

  1. 计算哈希: 当你尝试向 ConcurrentHashMapput 一个键值对时,首先会为键计算哈希值。Java 8 中的 ConcurrentHashMap 对键的 hashCode() 返回的哈希值进行了进一步的扰动处理,以降低哈希碰撞的几率。
  2. 定位桶位置: 使用计算出的哈希值确定该键值对在内部数组(Node 数组)中的索引位置。这通过将哈希值和(表长度 - 1)进行按位与操作完成,这假设表的长度总是 2 的幂。
  3. 检查是否需要初始化表: 如果数组为空,则需要先进行初始化。
  4. 无锁快速路径: 如果定位到的桶位置为空(没有发生哈希碰撞),使用 CAS 操作直接尝试在该桶位置设立一个新的 Node,添加键值对,如果 CAS 操作成功则完成了 put 操作。
  5. 锁定桶的首个节点: 如果在桶位置已经有节点存在(发生了哈希碰撞),则锁定该桶的首个节点,这是为了确保任何更新操作的线程安全。
  6. 链表操作: 在桶中,你将遍历链表,检查键是否已经存在:如果键已存在,更新该键对应的值。如果键不存在,将新的节点添加到链表的末端。如果链表的长度超过一定阈值(TREEIFY_THRESHOLD),可能会将链表转换为红黑树以提高后续的查找效率。
  7. 树形结构操作: 如果该桶已经转变成了一个红黑树,那么进行相应的树节点插入或更新操作。
  8. 检查是否需要扩容: 在添加节点后,如果发现当前的桶位数量太多,或者整个数组需要扩容来保证性能,可能会触发一个扩容操作。在 Java 8 中,扩容是通过多个线程共同协作完成,每个线程负责迁移数组中的一部分元素到新数组。

以上就是 ConcurrentHashMapput 方法及其哈希寻址过程的大致步骤,在不同的情况下可能会有一些变化或优化,但核心原理基本相同。这些操作确保了并发环境下的安全性和有效性,同时还能提供相对较高的性能。

在ConcurrentHashMap中,扩容操作是一个精心设计的过程,用于在元素太多、造成过多哈希冲突时增加桶的数量。扩容操作通常包括以下步骤:

  1. 触发扩容条件:当某个线程在插入元素时发现整个HashMap的容量达到了阈值(load factor * 当前容量),或者某个桶中元素太多,超过了特定的阈值,就会触发扩容。
  2. 初始化新数组:扩容首先会创建一个容量是原数组两倍的新数组。
  3. 迁移数据:之后,要将旧数组中的所有元素迁移到新数组。从Java 8开始,ConcurrentHashMap采用了分段迁移,即不是一次性迁移所有元素,而是将数组分成多段,每个线程负责迁移一部分数据。
  4. 并发迁移:在迁移数据时,多个线程可以并发执行,每个线程可以独立地将旧数组的一个或多个段迁移到新数组。通过使用前向指针(forwarding pointers),算法确保了即使在迁移过程中,仍然可以进行查询和插入操作。
  5. 桶迁移:迁移一个桶时,将会处理整个链表或红黑树中的所有节点,计算它们在新数组中的位置,并在新位置重新链接。
  6. 迁移完成:当所有线程完成自己负责的段后,迁移完成。ConcurrentHashMap会切换引用,让所有操作都使用新的数组。

这个扩容机制配合其他并发控制手段,如CAS和synchronized,确保了在并发环境下扩容操作的安全性和有效性。

volatile 的作用、原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在 Java 中,`volatile` 关键字用于声明一个变量为 "易变",其作用是确保变量在多线程环境中的可见性和有序性。

### 作用

1. **可见性**: 当一个变量被声明为 `volatile`,这意味着每次访问变量时都会直接从主内存读取,而每次修改变量时都会立即写回主内存。这避免了线程在自己的工作内存中缓存变量值,并保证了变量在所有线程中的可见性。
2. **有序性**: `volatile` 防止指令重排序,它确保在读写 `volatile` 变量的操作前后,程序不会对其他操作进行重排序,从而在一定程度上保证了代码的执行顺序。

### 原理

`volatile` 关键字的实现原理依赖于 Java 内存模型(JMM)。在底层,`volatile` 变量的读写操作通常会伴随着内存屏障(memory barriers):

- **读屏障(Load Barrier)**: 在读取 `volatile` 变量后,确保该操作之后的读写操作不会被重排序到内存屏障之前。
- **写屏障(Store Barrier)**: 在写入 `volatile` 变量前,确保该操作之前的所有写操作都完成,以便后续的读操作可以看到最新的值。

这些内存屏障指令确保了在不同线程中对 `volatile` 变量的读写操作具有一致性和顺序性,从而提供了一种轻量级的同步机制。然而,需要注意的是 `volatile` 并不能保证原子性,对于复合操作(如自增),仍然需要额外的同步措施,比如使用 `synchronized` 块或 `java.util.concurrent` 包中的原子类

JMM模型和消息总线机制说明

Java 内存模型(JMM)是一个抽象的概念,它描述了Java虚拟机(JVM)在计算机内存中的工作方式,以及处理器和内存交互时如何实现多线程之间的通信。JMM主要处理内存的可见性、原子性、顺序性等问题,确保在多线程环境中程序的正确执行。

TCP/IP 五层模型

​ 从下到上依次是:

1)物理层:主要是指具体的物理媒介和物理设备

  • 任务:物理层的主要功能是利用传输介质为数据链路层提供物理联接,负责数据流的物理传输工作(主要定义了系统的电气、机械、过程和功能标准。如:电压、物理数据速率、最大传输距离、物理联接器和其他的类似特性)。基本单位是比特流,即 0 和 1,也就是最基本的电信号或光信号
  • 传输单位:比特
  • 所实现的硬件:集线器,中继器

2)数据链路层:负责在物理层面上传输数据

  • 任务:物理层只是简单的把计算机连接起来并在上面传输比特流,仅仅靠物理层是无法保证数据传输的正确性的,对于发送端来说,数据链路层会把网络层传下来的 IP 数据报封装成帧(添加一些控制信息),这样,接收端接收到这个帧的时候,就可以根据其中的控制信息来判断是否出现了差错,另外,还可以根据这些控制信息知道这个帧从哪个比特开始从哪个比特结束
  • 传输单位:帧
  • 所实现的硬件:交换机、网桥

3)网络层:负责在不同网络之间传输数据,实现了不同网络之间的互联

  • 任务:对于发送端来说,网络层会将传输层传下来的 TCP 报文段或 UDP 用户数据报封装成 IP 数据报进行传输;通过路由选择协议选中合适的路由,使得源主机运输层所传下来的分组能够通过网络中的路由器找到目的主机
  • 传输单位:分组(也叫 IP 数据报、数据报)。为了提供通信性能和可靠性,体积较大的 TCP 报文段或 UDP 用户数据报可能会被分成多个更小的部分,在每个部分的前面加上 TCP 或 UDP 首部,就构成了一个个较小的分组
  • 所实现的硬件:路由器
  • 代表协议:IP 协议、ARP 地址解析协议、ICMP 网际报文控制协议

4)传输层:提供端到端的可靠数据传输和错误恢复功能

  • 任务:负责为两个主机中进程之间的通信提供服务。对于发送端来说,传输层会将应用层传下来的报文封装成 TCP 报文段或者 UDP 用户数据报进行传输。由于一个主机可同时运行多个进程,因此运输层有复用和分用的功能

    • 复用,就是多个应用层进程可同时使用下面传输层的服务
    • 分用,就是传输层把收到的信息分别交付给上面应用层中相应的进程

    通过物理层、数据链路层以及网络层的互相作用,我们已经把数据成功从计算机 A 传送到计算机 B 了,可是,计算机 B 里面有各种各样的应用程序,计算机 B 该如何知道这些数据是给哪个应用程序的呢

    所以,我们在从计算机 A 传数据给计算表 B 的时候,还得指定一个端口(Port),以供特定的应用程序来接受处理。即 IP 地址 + 端口号就可以唯一确定某个主机上的某个应用进程

    也就是说,网络层的功能是建立主机到主机的通信,而传输层的功能就是建立端口到端口的通信(也可以说是进程到进程之间的通信)

  • 传输单位:报文段(TCP)或用户数据报(UDP)

  • 代表协议:TCP、UDP

5)应用层:直接为应用程序提供服务的层

  • 任务:直接为用户的应用进程提供服务

  • 传输单位:报文。报文包含了将要发送的完整的数据信息,其长短不需一致

  • 代表协议:DHC 动态主机配置协议、DNS 域名解析协议、HTTP、HTTPS、FTP、SMTP

OSI 七层模型

OSI 七层网络协议模型就是把应用层继续细分成了:会话层 + 表示层 + 应用层

  • 会话层(Session layer):负责建立、管理和终止会话。代表协议有:RPC(Remote Procedure Call)、NFS(Network File System)等。
  • 表示层(Presentation layer):负责数据的加密、压缩、格式转换等。代表协议有:JPEG(Joint Photographic Experts Group)、MPEG(Moving Picture Experts Group)等。
  • 应用层(Application layer):提供各种服务,如电子邮件、文件传输、远程登录等。代表协议有:HTTP(HyperText Transfer Protocol)、FTP(File Transfer Protocol)、SMTP(Simple Mail Transfer Protocol)等

Redis ZSet 的原理和使用场景(延迟队列)

Redis中的ZSet(有序集合)是一种存储唯一元素的集合,但每个元素都会关联一个double类型的分数。Redis根据这些分数来为集合中的元素进行从小到大的排序。ZSet保证了元素的唯一性,因为它是通过HashMap实现的,同时元素的排序则是通过跳表(Skip List)来实现的。

ZSet的原理

  1. HashMap:保证了元素的唯一性,并能够快速地进行元素的访问和查找,时间复杂度为O(1)。
  2. 跳表(Skip List):一个跳表是由多层链表组成的数据结构,它允许快速的节点访问,并能够保持元素有序。插入、删除、搜索操作的平均时间复杂度和最坏时间复杂度都是O(logN)。

ZSet实现延迟队列

延迟队列是一种先进先出的队列,不过队列中的元素会在指定时间后才能被消费。ZSet可以方便地实现延迟队列的功能:

  • 元素:队列中的元素可以存储实际要执行的任务信息。
  • 分数:元素关联的分数可以设置为任务需要被执行的具体时间戳。

当需要从队列中获取任务执行时,可以使用ZSet的范围查询命令,比如ZRANGEBYSCORE

Redis ZSet (有序集合) 保证元素唯一性的关键在于其内部数据结构的使用。ZSet 的实现包含两个主要的数据结构:一个是哈希表(hash table),另一个是跳跃表(skip list)。

  1. 哈希表:ZSet 中的哈希表用来存储元素及其对应的分数。哈希表的键(key)是集合中的元素,而值(value)是元素的分数。由于哈希表的特性,键必须是唯一的,这就确保了ZSet中的每个元素都是唯一的。如果尝试插入一个已经存在的元素,那么Redis将会更新这个元素的分数而不是添加一个新元素。
  2. 跳跃表:用于维护元素的排序顺序。跳跃表是一种可以进行高效搜索的数据结构,它通过多层链表来实现快速访问。在ZSet中,跳跃表的节点是元素和它们的分数,节点按分数进行排序。由于每个元素只能有一个分数,而且元素本身是唯一的,跳跃表也就保持了元素的唯一性。

通过这两种数据结构的结合使用,Redis ZSet能够保证集合中元素的唯一性,同时提供高效的元素插入、删除、查找和有序访问操作。

Redis String 原理和使用场景(分布式锁)

Redis 的 String 类型是其最基本的数据类型,它能够存储任何形式的字符串,包括二进制数据。每一个 Redis String 可以存储的数据大小最多可以是 512MB。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
### Redis String 原理:

Redis 是一个基于内存的键值存储数据库,String 类型的数据将会直接存储在内存中,以确保快速的读写性能。由于 Redis 还支持持久化,这些 String 类型的数据也可以被保存到磁盘上,确保了数据的持久性。

当请求一个 String 类型的键值时,Redis 会直接从内存中查找对应的键,并迅速返回其值。String 类型的数据结构简单,这也是 Redis 能够提供高性能的一个原因。

### edis String 使用场景:

1. **缓存**:由于 Redis 的高性能读写能力,String 常用来缓存频繁读取但不常修改的数据,比如网站的页面数据、用户会话(Session)信息等。

2. **计数器**:Redis String 的原子操作可以用来做各种计数器应用,例如网页计数、粉丝数等。

3. **分布式锁**:在分布式系统中,我们经常需要确保某个操作在同一时间只能由一个进程执行,这时可以使用 Redis String 来实现一个简单的分布式锁。

例如使用 `SETNX`(Set if not exists)命令,只有当键不存在时,才会设置键的值,并返回成功;如果键已存在,则不做任何操作,返回失败。结合 `EXPIRE` 命令为锁设置一个过期时间,可以防止死锁的发生。

4. **消息队列**:虽然 Redis 有专门的消息队列数据类型 List,但在某些简单场景下,也可以使用 String 类型实现发布/订阅模式。

5. **共享 Session**:在分布式系统中,多个应用实例需要共享用户会话信息,可以将 Session 数据保存到 Redis String 中,实现会话共享。

在使用 Redis String 作为分布式锁时需要注意的是,为了防止因为某些原因(如进程崩溃)导致锁没有正确释放,造成死锁,应该为锁设置一个合理的过期时间,并在锁的持有者完成操作后主动释放锁。此外,还需要处理好锁的可重入性、锁的延时释放等高级特性,以确保分布式锁的正确性和高效性。

Redis 字符串的底层数据结构是简单动态字符串(Simple Dynamic String,SDS)。SDS 是 Redis 为了克服传统 C 字符串(以 `\0` 结尾的字符数组)的缺点而设计的。

为什么使用 SDS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
1. 常数时间复杂度获取字符串长度**:C 字符串需要遍历整个字符串来确定长度,时间复杂度为 O(N)。而 SDS 有一个 `len` 属性来记录字符串长度,获取长度的复杂度为 O(1)。
2. **减少修改字符串时的内存重新分配次数**:SDS 使用 `free` 属性记录字符串分配的未使用空间(空间预分配和惰性空间释放)。当 SDS 需要增长时,可以利用这些预分配的空间,减少重新分配内存的次数。当 SDS 缩短时,也不会立即释放多余的内存空间,避免频繁的内存分配操作。
3. **二进制安全**:C 字符串以 `\0` 作为结束符,不能包含 `\0`。SDS 由于使用 `len` 属性记录长度,可包含任何二进制数据,包括 `\0`。
4. **兼容部分 C 字符串函数**:虽然 SDS 是 Redis 的自定义类型,但是它保留了字符串的数组形式,并且以 `\0` 结尾,这使得 SDS 可以重用一部分 C 标准库中的字符串函数,无需进行修改。

综上所述,SDS 提供了性能优势,且更加安全和灵活。这也是为什么 Redis 选择 SDS 作为字符串的底层实现,以满足其高性能和高可用性的需求。

SDS(Simple Dynamic String)的具体结构体在 Redis 的源代码中定义如下


struct sdshdr {
// 记录 buf 数组中已使用字节的数量,等于 SDS 所保存字符串的长度。
int len;
// 记录 buf 数组中未使用字节的数量。
int free;
// 字节数组,用于实际保存字符串数据,这是一个柔性数组。
// C99标准之后允许在结构体中定义柔性数组成员,其大小可以在运行时决定。
char buf[];
};


- `len` 表示 buf 数组中已使用的字节数,即实际字符串的长度。
- `free` 表示 buf 数组中未使用的字节数,这部分内存是预先分配但尚未使用的,可以在字符串追加操作时使用,以减少内存重新分配的频率。
- `buf` 是一个柔性数组,其大小不固定,可以根据需要存储实际的字符串数据。实际上,由于 C 语言的字符串以 `\0` 结尾,`buf` 会比 `len` 大 1,以便在字符串的最后加上 `\0`。

SDS的扩容过程

1
2
3
4
5
6
7
8
9
10
1. **计算新长度**:首先计算出添加新字符串后,SDS 需要的总长度。
2. **判断是否需要扩容**:检查 SDS 的 `free` 字段,即未使用的字节,来确定是否有足够的空间存储新增的数据。如果 `free` 大于或等于所需新增的长度,则无需扩容;否则,执行下一步。
3. **内存分配策略**:当 SDS 需要扩容时,Redis 采用的策略如下:
- 如果新增数据后的总长度小于 1MB,那么通常会分配与所需长度相同大小的额外空间。这样做的目的是为了未来可能的追加操作,避免频繁的内存分配。
- 如果新增数据后的总长度超过 1MB,则通常只额外分配 1MB 的空间作为预留,因为大对象的扩展通常不会像小对象那样频繁。
4. **内存重新分配**:根据上述策略,使用 `realloc` 函数来重新分配内存。如果原内存后面有足够的连续空间,则 `realloc` 可能会扩展原内存区域,否则它会分配一块新的内存,并将原数据复制过去,然后释放原内存。
5. **更新 SDS 属性**:在分配了新的内存空间后,需要更新 SDS 的属性:
- 更新 `len` 属性为新的字符串长度。
- 更新 `free` 属性为重新分配后,减去新的字符串长度后的剩余空间。
6. **数据追加**:在新分配的内存空间中追加新的数据,并在最后加上 `\0` 作为字符串结束标志。

SDS惰性空间

1
2
3
4
1. **保留多余空间**:在缩短 SDS 后,例如删除或截断字符串的一部分,多余的内存不会被立即释放。相反,这部分内存会被标记为未使用,并更新 SDS 的 `free` 属性。
2. **利用预留空间**:下次当 SDS 需要扩展,例如追加更多数据时,可以直接利用这些预留的空间,而不是进行新的内存分配。这可以减少内存分配和释放的次数,提高效率。
3. **惰性空间的大小限制**:Redis 实施了一定的限制,以防止过多的惰性空间浪费内存。如果 SDS 的 `free` 属性太大(通常是 SDS 当前长度的一半以上),Redis 可能会通过 `realloc` 函数来调整内存大小,释放部分未使用的空间。
4. **内存重分配时机**:通常情况下,Redis 只会在内存紧张时或进行某些特定的内存整理操作时,才会考虑释放这些惰性空间

redis 渐进式rehash的 过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
1. 每次操作迁移一部分**:在进行rehash时,Redis并不是一次性将旧哈希表中的所有键值对迁移到新哈希表,而是在每次执行写入、删除或查找操作时,顺带迁移一小部分键值对。
2. **调整迁移步长**:Redis可以通过调整每次操作时迁移的槽(slot)的数量来控制迁移速度。通常,Redis会逐个槽地迁移,这意味着每次操作会迁移一个或几个槽中的所有键值对。
3. **利用定时任务**:Redis还会利用后台任务定时去迁移键值对。即使没有客户端请求,后台任务也会周期性地将旧哈希表中的一些键值对迁移到新哈希表。
4. **负载因子和扩容因子**:Redis通过监控哈希表的负载因子(即元素数量与槽位数量的比例)来决定何时开始和结束rehash。此外,它会根据扩容因子(通常是翻倍或缩减一半)来设置新哈希表的大小,从而影响迁移的速度和频率。

Redis的渐进式rehash过程是Redis在哈希表扩容或缩容时使用的一种优化技术。在Redis中,哈希表是用来存储键值对的数据结构,当哈希表中的元素太多或太少时,为了保持操作的效率,需要对哈希表的大小进行调整。渐进式rehash就是这个调整过程的具体实现方式。

1. **负载因子**:负载因子 = 元素数量 / 槽位数量
2. **扩容条件**:**当哈希表的负载因子大于1时,即每个槽位平均已经有超过一个元素,Redis就会触发扩容操作。在字典未使用渐进式rehash时,如果负载因子超过5,也会触发扩容。**
3. **缩容条件**:在使用渐进式rehash的情况下,如果负载因子小于0.1,即槽位数量是元素数量的10倍以上,Redis会触发缩容操作。
4. **元素数量和槽位数量**:Redis的哈希表元素数量没有特定的默认值,它取决于实际存储的键值对数量。而哈希表的槽位数量是动态调整的,它会根据当前存储的键值对数量和负载因子来决定何时进行扩容或缩容。哈希表的槽位数量总是2的幂次,这样可以使得rehash时只需要计算索引的位操作,而不需要进行取模操作,这可以提高操作效率。

总之,Redis通过动态调整哈希表大小和监控负载因子来保证哈希表操作的效率,避免出现过度的哈希碰撞和空间浪费。

### Redis Master选举过程:

Redis的主从复制功能允许一个Redis服务器(master)将数据同步到一个或多个Redis服务器(slave)。如果master宕机,Redis的Sentinel系统或Redis Cluster可以用来自动执行master选举。

#### 使用Sentinel进行Master选举:

Redis Sentinel是Redis的高可用性解决方案,它可以监控Redis master和slave实例,并在master故障时自动进行故障转移。

1. **监控**:Sentinel不断检查master和slave的健康状态。
2. **故障检测**:如果master无响应,多个Sentinel会相互协商确认master确实已宕机。
3. **选举Sentinel**:确认master宕机后,Sentinel之间会选举出一个领导者来负责启动故障转移过程。
4. **选举新的master**:领导者Sentinel会选择一个slave来晋升为新的master。选择依据可能包括slave的健康状态、同步延迟、优先级等因素。
5. **配置更新**:新的master被选出后,其他slave将被配置为同步新的master。客户端的配置也会被更新为指向新的master。

#### 使用Redis Cluster进行Master选举:

Redis Cluster是Redis的分布式解决方案,它能够在多个Redis节点之间进行自动分片和复制。

1. **故障检测**:Redis Cluster中的节点会相互监控,通过Gossip协议交换信息。如果一个master节点失效,其他节点会检测到这一点。
2. **选举过程**:当足够多的节点(超过半数)同意master失效后,会触发故障转移流程。
3. **晋升slave为master**:失效master的某个slave将被自动选举为新的master。
4. **配置更新**:一旦slave被晋升为master,集群的状态会更新,其他slave将开始同步新的master。

在任何情况下,为了保证高可用性和数据的完整性,建议在生产环境中配置适当的持久化策略,并使用Sentinel或Redis Cluster来管理Redis实例。

说下哨兵选主的详细实现流程

  1. 监控
    每个 Sentinel 节点定期发送PING命令来监控所有的Master和Slave节点的状态。
  2. 主观下线
    如果一个 Sentinel 节点在指定的时间内没能从 Master 接收到有效回复(通常是PONG响应),它会将该 Master 标记为主观下线(SDOWN)。
  3. 客观下线
    当一个 Sentinel 节点将 Master 标记为SDOWN后,它会询问其他 Sentinel 节点它们是否也认为该 Master 下线。如果超过配置的quorum数量的 Sentinel 节点同意,那么 Master 将被标记为客观下线(ODOWN)。
  4. 领导者选举
    一旦Master被标记为ODOWN,Sentinel节点之间会进行领导者选举来决定哪个Sentinel节点负责执行故障转移过程。这通过Raft算法中的选举过程进行。
  5. 选择Slave节点
    领导者Sentinel会从所有可用的Slave节点中选择一个来晋升为新的Master。选择基于的条件包括:Slave与Master的数据同步延迟、Slave的运行时间、是否处于SDOWN状态以及Slave的配置优先级等。
  6. 晋升Slave为Master
    选定的 Slave 将接收到 SLAVEOF NO ONE 命令使其成为新的 Master 节点。
  7. 配置其他Slave节点
    其他 Slave 节点将被重新配置,使它们复制新的 Master 节点。这是通过向它们发送 SLAVEOF 新Master的IP和端口号实现的。
  8. 更新Sentinel配置
    所有 Sentinel 节点将更新它们的配置,将新晋升的节点作为当前的 Master。
  9. 通知客户端
    Sentinel 可以配置为在故障转移完成后通知客户端应用程序。这可以通过发布/订阅机制或者配置特定的通知脚本来完成。

哨兵选主的命令

  1. SENTINEL get-master-addr-by-name <master-name>
    这个命令用于获取当前被监控的 Master 节点的地址。
  2. SENTINEL is-master-down-by-addr <ip> <port> <current-epoch> <runid>
    Sentinel 节点使用这个命令来确认一个 Master 是否被其他 Sentinel 节点视为下线。这个命令的响应会包含是否所有 Sentinel 节点都同意该 Master 下线的信息。
  3. SENTINEL failover <master-name>
    这个命令用于手动触发对指定 Master 节点的故障转移。通常情况下,故障转移是自动发生的,但是在特定情况下,管理员可能会选择手动触发这个过程。
  4. SENTINEL slaves <master-name>
    使用这个命令可以列出所有属于指定 Master 节点的 Slave 节点。这有助于 Sentinel 在选举新的 Master 时做出决策。
  5. INFO REPLICATION
    这不是 Sentinel 命令,但 Sentinel 会在内部对 Redis 节点使用此命令来检查复制状态,包括各个 Slave 节点与 Master 的偏移量,以决定哪个 Slave 最适合被晋升为新的 Master。
  6. SLAVEOF <new-master-ip> <new-master-port>
    当 Sentinel 决定晋升一个 Slave 为新的 Master 后,它会向该 Slave 发送 SLAVEOF NO ONE 命令,让它成为新的 Master。对于其他 Slave,它们会收到 SLAVEOF <new-master-ip> <new-master-port> 命令,以开始复制新的 Master。

Sentinel 如何确定一个 Master 节点是否被其他 Sentinel 节点视为下线?

  1. 主观下线(Subjective Down)
    当一个 Sentinel 节点无法从一个 Master 节点接收到期望的 PONG 响应,超出了配置的超时时间,它会认为这个 Master 节点是主观下线(SDOWN)。这个状态是“主观”的,因为它只基于当前 Sentinel 的观察。
  2. 询问其他 Sentinel 节点
    当 Sentinel 节点将一个 Master 标记为 SDOWN 后,它会开始询问其他 Sentinel 节点它们是否也认为该 Master 节点是不可达的。它使用 SENTINEL is-master-down-by-addr 命令来执行这个询问操作。
  3. 客观下线(Objective Down)
    如果足够多的 Sentinel 节点(达到配置的 quorum 数量)回复确认它们也观察到了相同的 Master 不可达状态,那么该 Master 节点会被标记为客观下线(ODOWN)。这个状态是“客观”的,因为它是基于多个 Sentinel 节点的共识。
  4. 选举领导 Sentinel
    一旦一个 Master 被标记为 ODOWN,Sentinel 节点会通过一个选举过程来选出一个领导 Sentinel,该领导节点负责协调故障转移过程。
  5. 开始故障转移
    领导 Sentinel 开始故障转移过程,包括选举新的 Master 节点,并指示其他 Sentinel 节点和 Redis 节点更新他们的配置以识别新的 Master。

Redis Sentinel 和 Redis Cluster 都使用一种形式的 Gossip 协议来交换信息,但它们在细节上有所不同,特别是在故障检测和处理方面。

Redis Sentinel 的 Gossip 协议

Redis Sentinel 使用 Gossip-like 的通信机制来交换关于主观下线状态的信息。Sentinel 节点之间不断地交换关于它们监控的 Redis 服务器(Master 和 Slaves)的状态信息。

  • 故障检测:每个 Sentinel 节点独立地检测 Master 或 Slave 的可达性。当一个 Sentinel 节点认为一个 Master 节点主观下线时,它会询问其他 Sentinel 节点他们是否也认为同样的 Master 下线了。
  • 共识构建:当超过配置的 quorum 数量的 Sentinel 节点同意某个 Master 主观下线时,该 Master 节点会被标记为客观下线,并且将开始故障转移流程。

Sentinel 的 Gossip-like 机制主要用来达成是否进行故障转移的共识,而不是用来广播状态信息。

Redis Cluster 的 Gossip 协议

Redis Cluster 使用真正的 Gossip 协议来维护集群元数据的一致性和集群成员之间的通信。

  • 状态交换:Redis Cluster 中的每个节点定期向其他节点发送 Gossip 信息,这包括它们自己的状态和它们知道的其他节点的状态。
  • 故障检测:如果一个节点在指定的时间段内没有响应,则发送节点会标记该节点为 PFAIL(可能失败)。当足够多的节点都报告一个节点 PFAIL 时,该节点会被标记为 FAIL。
  • 集群拓扑维护:Gossip 消息还包含了集群配置的更改,如新节点的加入、现有节点的移除或者 slots 的重新分配。这些信息帮助集群成员维护当前的集群状态。

Redis Cluster 的 Gossip 协议涉及更多的节点状态和集群拓扑信息的交换,用于维护整个集群的健康和元数据的一致性。

区别总结

Redis Sentinel 的通信机制更偏向于监控和故障转移决策,而 Redis Cluster 的 Gossip 协议更全面,涵盖了节点状态交换、故障检测、配置更新等多个方面。Sentinel 聚焦于高可用性,主要用于故障检测和触发自动故障转移,而 Redis Cluster 的 Gossip 协议是为了维护分布式数据库的整体健康和一致性。

redis Cluster 故障转移过程

  1. 故障检测
    每个 Redis 节点都会定期向其他节点发送 PING 消息,并期待 PONG 响应。如果一个 Master 节点在指定的超时时间内没有响应,则会被认为是疑似失败(PFAIL)。
  2. 故障声明
    当足够多的节点(至少需要集群节点的半数加一)都认为某个 Master 节点已经失败(即收到了关于该 Master 的 PFAIL 消息),这个 Master 就会被标记为失败(FAIL)。
  3. Slave 观察
    与被标记为 FAIL 的 Master 节点相关联的 Slave 节点会持续观察这个状态,并在 Master 被标记为 FAIL 一段时间后(这个时间可以配置),开始选举过程。这个延迟时间是为了确保所有的 Slave 都有机会接收到关于 Master 故障的消息。
  4. Slave 选举
    当选举开始时,每个 Slave 都会评估自己是否有资格成为新的 Master。这包括检查自己的数据是否足够新,以及是否有足够的连接到其他节点。最有资格的 Slave 会被选举为新的 Master。
  5. 晋升为 Master
    选出的 Slave 会执行 CLUSTER FAILOVER 命令,将自己晋升为 Master。
  6. 配置更新
    一旦 Slave 节点晋升为 Master,集群的状态会更新。其他节点会感知到新 Master 的存在,并且开始接受来自它的命令。原 Master 的其他 Slave 节点(如果有的话)会重新配置自己,开始复制新的 Master。
  7. 客户端重定向
    客户端连接到集群时,如果尝试向失败的 Master 节点发送命令,它们将会收到重定向错误,指示它们连接到新的 Master 节点。
  8. 持久化新配置
    新的配置将被持久化到节点的磁盘上,以便在重启后仍能记住新的 Master。

Sentinel适用于那些需要故障转移但不需要数据分片的场景,Redis Cluster适用于那些需要自动数据分片和高可用性的场景。

搭建 Redis 集群的详细步骤

准备工作

  1. 获取 Redis:确保你有 Redis 的最新稳定版本,因为集群功能是在 Redis 3.0 及以上版本提供的。
  2. 服务器准备:至少需要三个 Redis 节点来搭建一个基本的集群,每个节点运行在不同的服务器或者虚拟机上。为了高可用性,你可能需要六个节点,三个作为主节点,另外三个作为相应的从节点。

配置 Redis 实例

对于每个 Redis 实例,你需要进行以下配置:

  1. redis.conf:对每个 Redis 实例创建一个配置文件 redis.conf,并设置以下参数:
    • port <port>:设置每个 Redis 实例监听的端口号。
    • cluster-enabled yes:启用集群模式。
    • cluster-config-file nodes-<port>.conf:指定集群节点的配置文件,它会被 Redis 自动维护。
    • cluster-node-timeout <milliseconds>:设置节点超时时间,这会影响到故障检测的灵敏度。
    • appendonly yes:启用 AOF 持久化模式。
    • appendfilename "appendonly-<port>.aof":为每个实例指定不同的 AOF 文件。
  2. 启动实例:为每个 Redis 实例指定配置文件并启动:

sh

1
redis-server /path/to/redis.conf

创建 Redis 集群

使用 Redis 的 redis-cli 工具创建集群:

  1. 创建集群:使用 redis-cli--cluster create 命令,指定所有 Redis 实例的 IP 地址和端口号,以及 --cluster-replicas 参数来指定每个主节点的从节点数量。例如,如果你有六个节点,命令可能如下:

sh

1
2
3
redis-cli --cluster create 192.168.1.1:7000 192.168.1.2:7000 192.168.1.3:7000 \
192.168.1.1:7001 192.168.1.2:7001 192.168.1.3:7001 \
--cluster-replicas 1

这个命令会创建一个集群,其中包含三个主节点和三个从节点。

  1. 检查集群:使用 redis-cli --cluster check <IP>:<port> 命令来检查集群状态。

测试集群

  1. 数据操作:尝试在集群上执行一些基本的 Redis 命令,比如 SETGET,来验证数据能够被正确分配到不同的节点。
  2. 集群信息:可以在任何节点上运行 CLUSTER INFOCLUSTER NODES 命令来获取集群的状态和节点信息。

监控和维护

  1. 监控:使用 redis-cli 或其他管理工具监控集群状态和性能。
  2. 维护:对于集群的扩展和维护,你可能需要使用 redis-cli --cluster 命令来添加或移除节点。

这些步骤提供了搭建基本 Redis 集群的框架。在实际部署时,你可能需要考虑额外的因素,如网络安全、持久化策略、备份和灾难恢复计划等。确保在生产环境中进行充分的测试,以验证集群的性能和容错能力。

搭建redis sentinel的步骤

. 安装 Redis

确保你已经安装了 Redis。Redis Sentinel 是 Redis 的一部分,自 Redis 2.8 版本起就已经包含了 Sentinel 功能。

2. 配置 Redis 主节点和从节点

在你配置 Sentinel 之前,确保你的 Redis 主节点和从节点已经按照正常的方式配置好并运行。

1
2
3
4
bind 0.0.0.0
port 6379

redis-server /path/to/redis.conf

1
2
3
bind 0.0.0.0
port 6379
slaveof <master-ip> <master-port>
1
2
redis-cli
info replication
  • 在复制过程中,从节点是只读的。所有写命令必须在主节点上执行。
  • 确保所有节点的配置文件和持久化策略(RDB 或 AOF)是一致的,以避免数据不一致。
  • 为了高可用性和容错,可以配置多个从节点。
  • 从节点在启动时会完整同步主节点的数据,这可能会占用大量带宽和时间,具体取决于数据量的大小。
  • 如果需要,可以配置 Redis Sentinel 来监控主从节点,并在主节点失效时自动进行故障转移。

3. 配置 Redis Sentinel

对于每个 Sentinel 实例,你需要创建一个配置文件。通常,这个配置文件被命名为 sentinel.conf。以下是 sentinel.conf 文件中的一些基础配置项:

sh

1
2
3
4
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 30000
sentinel parallel-syncs mymaster 1
sentinel failover-timeout mymaster 180000
  • mymaster 是你监控的 Redis 主节点的名称。
  • 127.0.0.1 6379 是主节点的 IP 地址和端口号。
  • 2 是要求的最小 Sentinel 投票数,用于执行故障转移。
  • down-after-milliseconds 配置 Sentinel 多久没有收到主节点的响应时,就认为该节点是下线的。
  • parallel-syncs 配置在故障转移过程中,最多有多少个从节点同时进行同步。
  • failover-timeout 配置故障转移超时的时间。

这些配置项是 Sentinel 的基本设置。你可能需要根据实际情况调整配置参数。

4. 启动 Redis Sentinel

一旦配置好了 sentinel.conf 文件,就可以启动 Sentinel 实例了。启动 Sentinel 的命令如下:

sh

1
redis-sentinel /path/to/sentinel.conf

你需要至少三个 Sentinel 实例来形成一个容错系统,这样即使一个 Sentinel 实例出现问题,集群仍然可以正常运行。

5. 验证 Sentinel 状态

启动 Sentinel 后,可以使用 Redis 命令行工具 redis-cli 来检查 Sentinel 状态和配置。

sh

1
redis-cli -p <sentinel-port> INFO

6. 测试 Sentinel 故障转移

一旦 Sentinel 启动,你可以通过停止 Redis 主节点的服务,来测试 Sentinel 是否会自动将从节点提升为新的主节点。

7. 调整和优化配置(可选)

根据实际运营中的观察和需求,你可能需要调整 Sentinel 的配置参数,以优化性能和响应时间。

8. 设置持久化和日志记录(可选)

在生产环境中,可能还需要配置 Sentinel 的持久化和日志记录,以便于故障排查和系统的稳定运行。

注意事项

  • 确保 Sentinel 的配置文件对于所有 Sentinel 实例都是可访问的,且每个 Sentinel 实例都有其唯一的配置文件。
  • Sentinel 默认使用 26379 端口,确保这个端口在防火墙上是开放的。
  • Sentinel 集群中的节点数量应为奇数,以避免脑裂(split-brain)问题。
  • 在生产环境中,Sentinel 应该分布在不同的物理服务器上,以提供高可用性。

搭建完成后,Redis Sentinel 会监控主从节点的状态,并在主节点不可用时自动进行故障转移,将一个从节点提升为新的主节点。这样可以确保 Redis 的高可用性。

  • 写命令:在故障转移过程中,集群暂时没有主节点,因此新的写命令会被拒绝。应用程序在这种情况下可能会收到错误,应当实现重试逻辑来处理这种暂时的写入失败。
  • 读命令:如果应用程序只从主节点读取,那么在没有主节点的情况下,读命令也会失败。如果应用程序配置为可以从从节点读取,那么读命令可以继续被处理,前提是从节点上的数据满足应用程序的需求(即从节点的数据足够新)。

Redis Sentinel

  1. 自动重新连接:大多数 Redis 客户端库支持自动重新连接机制。当客户端检测到主节点不可用时,它会尝试重新连接。
  2. 订阅 Sentinel 通知:客户端可以订阅 Sentinel 的 +switch-master 事件,当 Sentinel 完成故障转移后,客户端会收到新主节点的地址。客户端可以根据这个地址更新自己的配置并重新连接。
  3. 查询 Sentinel:在故障转移期间,如果客户端不能写入数据,它可以定期(例如每隔几秒)向 Sentinel 查询当前的主节点地址。一旦 Sentinel 返回新主节点的地址,客户端就可以使用这个地址来恢复写操作。

Redis Cluster

  1. 自动重定向:Redis Cluster 客户端可以处理特殊的错误代码 -MOVED-ASK,这些错误代码表明客户端需要向集群中的其他节点重新发起请求。客户端库通常会自动处理这些重定向。
  2. 更新集群状态:如果主节点发生变化,客户端会收到一个 -MOVED 错误。客户端库会使用这个信息来请求集群当前的状态,并更新本地的节点映射。
  3. 周期性刷新:一些客户端库可能会定期刷新它们对集群状态的认知,即使没有收到 -MOVED-ASK 重定向错误。

通用步骤

  • 捕获异常:在客户端代码中,捕获连接异常或写入失败的情况,并尝试重新连接或重试写操作。
  • 重试逻辑:实现重试逻辑以处理暂时的网络分区或节点宕机。在重试之前,可能需要等待一个合理的时间间隔,以给故障转移足够的时间完成。
  • 配置更新:在某些情况下,可能需要手动更新客户端配置文件中的主节点地址。

正确配置的客户端在 Redis Sentinel 或 Redis Cluster 完成选主后,通常能够无需人工干预,自动恢复写操作。然而,不同的客户端库可能有不同的行为和配置选项,因此开发者应该参阅所使用的客户端库的文档,以确保最佳实践被遵循。

集群状态出现问题时,如何进行故障定位

  1. 集群状态检查

    • 使用 redis-cli 连接到集群中的任何节点,并执行 CLUSTER INFO 命令查看集群的总体状态。
    • 执行 CLUSTER NODESCLUSTER SLOTS 命令来查看每个节点的状态和数据槽(slot)分配情况。
  2. 配置一致性检查

    • 检查所有节点的配置文件,确保 cluster-enabled 被设置为 yes,并且所有节点使用相同的 cluster-config-filecluster-node-timeout 参数。

    性能指标分析

    • 使用 INFO 命令查看性能相关的指标,如命令处理速率、网络输入/输出、已连接客户端数等。

你知道哪些垃圾回收器

在Java中,垃圾回收器(Garbage Collector, GC)是负责回收无用对象内存的一部分。垃圾回收器的选择对于Java应用程序的性能有显著影响。不同的垃圾回收器适用于不同类型的应用和工作负荷。以下是截至目前Java虚拟机(JVM)提供的一些主要垃圾回收器:

  1. Serial GC:这是最简单的GC,适用于单线程环境。它对堆内存进行单线程的垃圾回收,适合于小型应用和单处理器机器。
  2. Parallel GC(也称为Throughput Collector):Parallel GC使用多线程进行垃圾回收,主要关注提高吞吐量。它适用于需要大量内存和多CPU的多线程应用程序。
  3. Concurrent Mark Sweep (CMS) GC:CMS GC是一种以最小化应用程序暂停时间为目标的垃圾回收器,适用于互联网或者服务端应用。它在垃圾回收时尝试最小化停顿,但是可能会产生较多的内存碎片。
  4. **G1 GC (Garbage-First Collector)**:G1 GC是一种面向服务端应用的垃圾回收器,旨在兼顾高吞吐量和低延迟。G1将堆划分为多个区域,并优先回收垃圾最多的区域,逐渐保持整个堆的垃圾回收。
  5. **ZGC (Z Garbage Collector)**:ZGC是一种可伸缩的低延迟垃圾回收器,旨在针对大堆内存(几十GB或更多)的系统提供低延迟的GC停顿(目标在10ms以内)。
  6. Shenandoah GC:与ZGC类似,Shenandoah GC也是一种低停顿时间的垃圾回收器,采用了先进的算法来减少GC引起的停顿时间,特别适合对响应时间敏感的应用。
  7. Epsilon GC(No-Op GC):这是一个实验性的垃圾回收器,它基本上不进行任何回收。它可以用来测试最佳性能场景或内存压力测试。

每种垃圾回收器都有其特点和使用场景,选择合适的垃圾回收器可以帮助提高Java应用的性能和响应能力。随着Java版本的更新,可能会引入新的GC算法或对现有GC进行改进。

Concurrent Mark Sweep (CMS) GC介绍下回收过程及回收原理

Concurrent Mark Sweep (CMS) GC是一种以最小化应用程序暂停时间为目标的垃圾回收器,主要用于需要快速响应时间的应用,比如Web服务器和交互式应用程序。CMS的设计目的是尽可能减少应用程序的停顿时间。

CMS回收过程

CMS GC的回收过程主要分为以下几个阶段:

  1. 初始标记(Initial Mark)
    • 这是一个STW(Stop-The-World)阶段,意味着在这个阶段所有的应用线程都会暂停。
    • 在此阶段,GC会标记所有从根对象直接可达的对象。这包括全局静态对象和活跃线程的局部变量等。
  2. 并发标记(Concurrent Mark)
    • 应用线程和GC线程同时运行,没有停顿。
    • 在此阶段,GC遍历初始标记阶段找到的对象,并标记所有从这些对象可达的对象。
  3. 预清理(Pre-Cleanup)
    • 这个阶段是为了准备最终清理阶段,可能会有短暂的停顿,但通常比初始标记阶段的要短。
    • 该阶段主要是为了处理并发标记阶段期间因应用程序继续运行而产生的变动。
  4. 最终标记(Remark)
    • 也是一个STW阶段,但CMS GC尝试尽量缩短这个阶段的时间。
    • 该阶段完成了剩余的标记工作,包括处理并发标记阶段遗留下来的变动。
  5. 并发清除(Concurrent Sweep)
    • 应用线程和GC线程同时运行,没有停顿。
    • GC遍历堆内存,清理掉未被标记的对象,回收它们占用的内存。

CMS回收原理

CMS的主要原理是“标记-清除”(Mark-Sweep)算法。在“标记”阶段,它通过遍历对象图来标记所有活着的对象。在“清除”阶段,它遍历堆内存,清除掉未被标记的对象,释放它们占用的空间。

CMS的特点是大部分工作和应用线程同时进行,从而减少停顿时间。但是,CMS也存在一些缺点,比如在清除阶段不会压缩或整理内存,这可能会导致内存碎片化问题。此外,在高负载情况下,CMS GC可能会由于无法及时清除垃圾而导致“并发模式失败”,这时候它会退化为Serial Old GC来处理剩余的垃圾收集工作,这会引起较长时间的停顿。

那你知道三色标记和 cms关系吗

是的,三色标记是一种垃圾回收算法中用于标记对象的一种方法,它在CMS(Concurrent Mark Sweep)垃圾回收器中得到了应用。三色标记算法涉及将对象标记为三种颜色:

  1. 白色:表示对象尚未被访问或标记。在算法开始时,所有对象都被视为白色。
  2. 灰色:表示对象已经被标记为存活,但是该对象引用的其他对象尚未被完全检查。换句话说,如果一个对象是灰色的,意味着垃圾回收器已经发现了这个对象,但还没有检查它引用的其他对象。
  3. 黑色:表示该对象及其所有引用的对象都已经被访问和标记。黑色对象是安全的,因为它们已经被确认为存活对象,并且不会在接下来的标记过程中被清除。

在CMS的并发标记阶段,垃圾回收器遍历对象图,按照三色标记的规则来标记对象。初始标记阶段会将直接可达的对象标记为灰色(从白色变为灰色),然后在并发标记阶段中,它继续遍历这些灰色对象,将它们引用的对象标记为灰色,同时将已经检查过的对象标记为黑色。整个过程中,应用线程依然在运行,这可能会改变对象之间的引用关系。

为了处理在并发标记过程中因应用程序继续运行而产生的变动(即对象引用的变化),CMS使用了一种叫做“写入屏障”(Write Barrier)的机制来记录这些改动。这样,在最终标记阶段,垃圾回收器可以快速地重新检查这些变化,确保所有存活的对象都被正确标记。

三色标记算法在CMS中的应用是为了能在应用程序运行的同时进行垃圾回收,减小停顿时间。不过,如果在并发标记过程中无法跟上对象引用的变化速度,可能会引起“并发模式失败”,导致GC不得不停止应用线程来完成垃圾回收,从而产生较长的停顿时间

CMS中的并发标记阶段如何处理对象引用的变化?

在CMS垃圾回收器的并发标记阶段,垃圾回收器需要记录所有存活的对象。由于该阶段是与应用程序的执行并发进行的,因此对象的引用关系可能会在标记过程中发生变化。为了正确处理这些变化,CMS采用了以下机制:

写入屏障(Write Barrier)

写入屏障是一种机制,用于监控和记录对象引用的变化。当应用程序更改一个对象的引用时,写入屏障会触发并记录这次写操作。这些记录会在垃圾回收过程中被检查,以确保所有的存活对象都被正确标记。

快照式写入屏障(Snapshot At The Beginning, SATB)

CMS使用的是一种名为快照式写入屏障的特殊机制。在并发标记的初始阶段(也就是并发标记开始之前的初始标记阶段),它会记录下当前所有对象的状态。然后,在并发标记过程中,如果一个应用程序线程修改了对象的引用,写入屏障将记录下修改前的引用。这样,即使对象的引用被修改了,垃圾回收器也能基于最初的快照来找到所有的存活对象。

最终标记(Remark)

尽管CMS的大部分标记工作是并发进行的,但是在并发标记阶段结束时,它需要一个短暂的STW(Stop The World)暂停来完成标记。这个阶段被称为最终标记(Remark)阶段。在这个阶段,垃圾回收器处理所有在并发阶段收集的信息,这主要包括写入屏障记录的对象引用变化。这确保了所有因应用程序的运行而变化的对象引用都得到处理

HTTP/HTTPS 什么区别?HTTP 特性有哪些

HTTP(超文本传输协议)和HTTPS(HTTP Secure)之间的主要区别在于安全性。以下是它们之间的关键差异:

HTTP和HTTPS的区别:

  1. 加密
    • HTTP:不使用加密,数据以明文形式传输,容易被第三方截获和篡改。
    • HTTPS:通过SSL/TLS协议提供了数据传输的加密,保护了数据的安全性,防止了数据在传输过程中被截获和篡改。
  2. 端口
    • HTTP:默认使用端口80。
    • HTTPS:默认使用端口443。
  3. 性能
    • HTTP:由于没有加密处理的开销,性能上略优于HTTPS。
    • HTTPS:由于加密和解密过程需要额外的计算,可能会稍微降低性能。
  4. URL前缀
    • HTTP:网址以http://开头。
    • HTTPS:网址以https://开头,表示安全链接。
  5. 证书
    • HTTP:不需要证书。
    • HTTPS:需要SSL/TLS证书,这个证书由证书颁发机构(CA)签发,用于验证网站的身份。

HTTP的特性:

  1. 简单快速:客户端向服务器请求服务时,只需传送请求方法和路径。
  2. 灵活:HTTP允许传输任意类型的数据对象。正在传输的类型由Content-Type头部指定。
  3. 无状态:HTTP协议是无状态的,即服务器不会记住任何客户端信息。为了解决这个问题,引入了Cookie技术。
  4. 无连接:默认情况下,HTTP使用“无连接”的方式,即每次连接只处理一个请求。服务器处理完客户端的请求并收到客户端的应答后,即断开连接。但现代HTTP协议支持持久连接来优化这个过程。
  5. 支持B/S模型:HTTP协议定义了请求和响应的结构,使其成为浏览器/服务器模型中的理想选择。

HTTP由于其无状态和无连接的特性,虽然在某些情况下可以提高传输速度,但也带来了如需保持连接状态时的不便。HTTPS则在HTTP的基础上加入SSL/TLS,提供了数据的加密传输和完整性校验等安全特性,适用于需要保护数据安全的场景,如网上银行和在线购物。

TTP相对于HTTPS的主要性能优势在于其传输过程中不涉及数据的加密和解密。这种差异导致在某些情况下,HTTP的传输速度和响应时间可能会稍微快于HTTPS。具体来说,HTTP的性能优势体现在以下几个方面:

  1. 无加密开销:HTTP通信不需要加密数据,这意味着不需要进行加密和解密的计算,从而节省了处理时间和CPU资源。
  2. 简化的握手过程:在建立一个HTTPS连接之前,客户端和服务器之间需要进行一个TLS握手过程,以协商加密参数,并验证服务器的证书。这个握手过程涉及多个往返通信和密钥的计算,增加了延迟。HTTP不需要这个握手过程。
  3. 无需处理证书:HTTPS需要使用证书来验证服务器的身份,这个过程包括了证书的传输、验证和可能的证书撤销检查。HTTP不涉及证书处理,进一步减少了连接建立时的时间。
  4. 较低的数据传输量:由于HTTPS需要额外的头信息和加密后的数据通常会比原始数据略大,因此HTTPS可能需要传输更多的数据。HTTP没有这些额外的数据负担。
  5. 更少的服务器资源消耗:因为HTTPS需要服务器进行加密处理,所以服务器端的CPU和内存资源消耗相对较大。对于HTTP来说,服务器端的资源消耗较少,能够处理更多的并发请求。

然而,随着近年来计算机处理能力的提升和现代浏览器以及服务器对HTTPS的优化,这些性能差异已经大大减小。现在,对于大多数现代网站和Web应用程序而言,推荐使用HTTPS来确保数据的安全性,尤其是隐私数据和敏感操作。实际上,许多现代Web技术,如HTTP/2,都要求使用HTTPS来实现性能改进。此外,搜索引擎也倾向于更高地排名使用HTTPS的网站,使得HTTPS成为了一种Web安全的最佳实践。

HTTPS加密过程涉及到对称加密和非对称加密两种机制,并且使用了TLS(传输层安全协议)或其前身SSL(安全套接层协议)。整个过程主要包括以下几个步骤:

  1. 客户端发起连接
    客户端(通常是Web浏览器)向服务器发送一个HTTPS请求,这通常是通过在URL中使用https://作为前缀来实现的。
  2. 服务器响应
    服务器响应客户端的请求,并提供其TLS证书。这个证书包含了服务器的公钥以及由证书颁发机构(CA)对服务器身份的验证。
  3. 证书验证
    客户端验证服务器证书的合法性。它会检查证书是否由受信任的CA签发,是否在有效期内,以及证书中的域名是否与正在访问的服务器域名匹配。
  4. 密钥交换
    客户端使用服务器的公钥来加密一个随机生成的对称密钥(会话密钥),并将加密后的会话密钥发送给服务器。服务器使用其私钥来解密这个信息,从而获得会话密钥。这个过程利用了非对称加密。
  5. 对称加密通信
    一旦双方都有了会话密钥,它们就使用这个密钥来对通信内容进行对称加密。从这一点开始,客户端和服务器之间发送的所有数据都将使用这个会话密钥进行加密和解密。这意味着即使数据在传输过程中被截获,没有密钥的第三方也无法解密数据内容。
  6. 安全通信
    客户端和服务器现在可以开始进行安全的通信。发送的每个消息都会被加密,接收端收到消息后使用会话密钥进行解密,这样就保证了数据的机密性和完整性。

这个过程不仅保证了数据在客户端和服务器之间传输的安全性,还通过证书和密钥交换过程确保了通信双方的身份验证。这种机制使得HTTPS非常适合保护用户在互联网上的隐私和数据安全。

##

MySQL 聚集索引 (主键索引) 和非聚集索引 (辅助索引/普通索引) 的区别

MySQL中的聚集索引和非聚集索引是两种不同类型的索引,它们在数据存储和查询优化方面有各自的特点和用途。

聚集索引(Clustered Index):

  • 数据存储:在聚集索引中,表中行的物理顺序和键值的逻辑(索引)顺序相同。实际的数据行直接存储在索引的叶子节点上。
  • 主键索引:在MySQL的InnoDB存储引擎中,聚集索引通常是主键索引,表中的数据按照主键的顺序存储。
  • 唯一性:一个表只能有一个聚集索引,因为数据只能以一种顺序物理存储。
  • 访问速度:由于数据行和索引是紧密绑定的,聚集索引通常可以提供最快的查询速度,尤其是对主键的查询和范围查询。

非聚集索引(Non-Clustered Index):

  • 数据存储:非聚集索引有一个单独的索引结构,索引项包含索引键值和指向数据行的指针,而不是数据本身。
  • 辅助索引:这些索引是对表中非主键列的索引,可以有多个非聚集索引。
  • 额外的查找步骤:查询非聚集索引通常需要两个步骤:首先在索引中查找键值,然后通过索引中的指针找到实际的数据行。
  • 覆盖索引:如果一个索引包含了查询所需的所有列,那么它可以直接返回结果,而无需回表查询数据行,这被称为“覆盖索引”。

区别总结:

  1. 物理存储:聚集索引决定了表数据的物理顺序,而非聚集索引则是一个单独的结构,不影响数据的物理存储。
  2. 索引数量:一个表只能有一个聚集索引,但可以有多个非聚集索引。
  3. 查找效率:聚集索引通常在查找速度上更优,特别是进行主键查找和范围查找时。
  4. 空间使用:非聚集索引可能会占用更多空间,因为它们需要存储额外的指针信息。
  5. 插入速度:聚集索引可能会使插入操作变慢,特别是在插入值导致页分裂时。
  6. 更新操作:如果更新操作涉及聚集索引键的变动,可能会导致数据行移动,这比更新非聚集索引更耗时。

选择合适的索引类型和设计索引策略对于数据库性能优化至关重要。通常,聚集索引用于频繁查询的主键列,而非聚集索引用于优化那些涉及非主键列的查询。

常见排序算法及其时间复杂度、各种排序算法对比

各种排序算法的比较:

  • 时间复杂度:对于小数据集,冒泡排序、选择排序和插入排序可能足够好,但对于大数据集,归并排序、快速排序和堆排序更优,因为它们提供了 O(n log n) 的平均时间复杂度。
  • 空间复杂度:对于空间敏感的应用,原地排序算法如冒泡排序、选择排序、插入排序和堆排序更有优势,因为它们的空间复杂度是 O(1)。
  • 稳定性:稳定的排序算法可以保持相同元素的相对顺序不变。归并排序、插入排序、计数排序和基数排序在这方面表现良好。
  • 适用场景:计数排序和基数排序适用于数字范围较小或者可以转换为整数的场景。归并排序适用于需要稳定排序的场景。快速排序在大多数情况下效率很高,但在极端情况下可能会退化到 O(n^2)。

冒泡算法

  • 最好时间复杂度: O(n)
  • 平均时间复杂度: O(n^2)
  • 最坏时间复杂度: O(n^2)
  • 空间复杂度: O(1)
  • 稳定性: 稳定
1
2
3
4
5
6
7
8
9
10
public void bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n-1; i++)
for (int j = 0; j < n-i-1; j++)
if (arr[j] > arr[j+1]) {
int temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}

选择排序

  • 最好时间复杂度: O(n^2)
  • 平均时间复杂度: O(n^2)
  • 最坏时间复杂度: O(n^2)
  • 空间复杂度: O(1)
  • 稳定性: 不稳定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class SelectionSort { 
public static void selectionSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n-1; ++i) {
// 初始化最小元素索引为当前位置
int minIndex = i;

// 遍历从当前位置到数组结尾的元素,查找最小值及其索引
for (int j = i+1; j < n; ++j) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}

// 将最小值与当前位置交换
swap(arr, i, minIndex);
}
}

private static void swap(int[] arr, int a, int b) {
int temp = arr[a];
arr[a] = arr[b];
arr[b] = temp;
}

插入排序(Insertion Sort)

  • 最好时间复杂度: O(n)
  • 平均时间复杂度: O(n^2)
  • 最坏时间复杂度: O(n^2)
  • 空间复杂度: O(1)
  • 稳定性: 稳定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class InsertionSortExample {

/**
* 使用插入排序算法对整数数组进行排序。
* @param arr 待排序的数组
*/
public static void insertionSort(int[] arr) {
int n = arr.length; // 数组的长度

// 从第二个元素开始遍历,因为我们假设第一个元素是已经排序的
for (int i = 1; i < n; i++) {
// 选取要插入的元素
int key = arr[i];
// 从当前元素开始向前遍历已排序的部分
int j = i - 1;

// 如果我们遇到一个元素比当前元素大,那么我们就把它往后移
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j = j - 1;
}
// 把当前元素放到找到的正确位置上
arr[j + 1] = key;
}
}

// 可以使用 main 方法来测试排序函数
public static void main(String[] args) {
int[] array = { 9, 5, 1, 4, 3 };
insertionSort(array);
for (int i = 0; i < array.length; i++) {
System.out.print(array[i] + " ");
}
}
}

归并排序(Merge Sort)

  • 最好时间复杂度: O(n log n)
  • 平均时间复杂度: O(n log n)
  • 最坏时间复杂度: O(n log n)
  • 空间复杂度: O(n)
  • 稳定性: 稳定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
public class MergeSort {

/**
* 归并排序算法的实现。
*
* @param arr 要排序的数组
*/
public static void mergeSort(int[] arr, int left, int right) {
if (left < right) { // 如果子序列有多于一个元素,则继续分解
// 找到中间位置
int middle = left + (right - left) / 2;

// 对左半部分进行归并排序
mergeSort(arr, left, middle);
// 对右半部分进行归并排序
mergeSort(arr, middle + 1, right);

// 合并两个有序的子序列
merge(arr, left, middle, right);
}
}

/**
* 合并两个有序子序列的函数。
*
* @param arr 要排序的数组
* @param left 左子序列的起始索引
* @param middle 左子序列的结束索引,middle+1 是右子序列的起始索引
* @param right 右子序列的结束索引
*/
private static void merge(int[] arr, int left, int middle, int right) {
// 计算左右子序列的长度
int n1 = middle - left + 1;
int n2 = right - middle;

// 创建临时数组存放左右子序列
int[] L = new int[n1];
int[] R = new int[n2];

// 复制数据到临时数组中
for (int i = 0; i < n1; ++i)
L[i] = arr[left + i];
for (int j = 0; j < n2; ++j)
R[j] = arr[middle + 1 + j];

// 合并临时数组,回填到原数组
int i = 0, j = 0;
int k = left; // 初始索引位置
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
arr[k] = L[i];
i++;
} else {
arr[k] = R[j];
j++;
}
k++;
}

// 复制剩余的左子序列元素
while (i < n1) {
arr[k] = L[i];
i++;
k++;
}

// 复制剩余的右子序列元素
while (j < n2) {
arr[k] = R[j];
j++;
k++;
}
}

// 测试归并排序的方法
public static void main(String[] args) {
int[] array = {12, 11, 13, 5, 6, 7};
mergeSort(array, 0, array.length - 1);
System.out.println("排序后的数组:");
for (int i = 0; i < array.length; ++i) {
System.out.print(array[i] + " ");
}
}
}

归并排序是一种分而治之的算法,通过递归地将数组分成两半进行排序,然后合并两个有序的子列表来构建最终的排序列表。它包括两个主要的步骤:

  1. 分解步骤:将当前序列分成两个子序列,递归地对这两个子序列进行归并排序。
  2. 合并步骤:将两个排序好的子序列合并成一个最终的排序序列。

快速排序(Quick Sort)

  • 最好时间复杂度: O(n log n)
  • 平均时间复杂度: O(n log n)
  • 最坏时间复杂度: O(n^2)
  • 空间复杂度: O(log n)
  • 稳定性: 不稳定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class QuickSort {

/**
* 快速排序算法的实现。
*
* @param arr 要排序的数组
* @param low 开始索引
* @param high 结束索引
*/
public static void quickSort(int[] arr, int low, int high) {
if (low < high) {
// partitionIndex 是分区操作正确的索引
int partitionIndex = partition(arr, low, high);

// 分别对分区前后的数组进行递归排序
quickSort(arr, low, partitionIndex - 1); // 对左分区进行排序
quickSort(arr, partitionIndex + 1, high); // 对右分区进行排序
}
}

/**
* 对数组进行分区,并返回分区的索引
*
* @param arr 要排序的数组
* @param low 开始索引
* @param high 结束索引
* @return 分区索引
*/
private static int partition(int[] arr, int low, int high) {
// 选择最后一个元素作为基准
int pivot = arr[high];
int i = (low - 1); // 比基准小的元素的索引

// 遍历除了基准外的所有元素
for (int j = low; j < high; j++) {
// 如果当前元素小于或等于基准
if (arr[j] <= pivot) {
i++;

// 交换 arr[i] 和 arr[j]
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}

// 交换 arr[i+1] 和 arr[high](或基准)
int temp = arr[i + 1];
arr[i + 1] = arr[high];
arr[high] = temp;

return i + 1; // 返回分区索引
}

// 测试快速排序的方法
public static void main(String[] args) {
int[] array = {10, 7, 8, 9, 1, 5};
quickSort(array, 0, array.length - 1);
System.out.println("排序后的数组:");
for (int num : array) {
System.out.print(num + " ");
}
}
}

速排序是一个高效的排序算法,通过一个称为“分区”的操作来将数组分为两个(可能不等大小的)子数组,然后递归地对这两个子数组进行快速排序。分区操作选择一个“基准”元素,并重新排列数组中的元素,使得所有比基准小的元素都在基准之前,而所有比基准大的元素都在基准之后。这个分区操作的结果就是基准元素所在的最终位置

堆排序(Heap Sort)

  • 最好时间复杂度: O(n log n)
  • 平均时间复杂度: O(n log n)
  • 最坏时间复杂度: O(n log n)
  • 空间复杂度: O(1)
  • 稳定性: 不稳定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class BucketSort {

/**
* 桶排序算法的实现。
*
* @param arr 要排序的数组
* @param bucketSize 每个桶的大小
*/
public static void bucketSort(float[] arr, int bucketSize) {
if (arr.length <= 0) return;

// 找出数组中的最大值和最小值
float max = arr[0];
float min = arr[0];
for (float value : arr) {
if (value > max) {
max = value;
} else if (value < min) {
min = value;
}
}

// 计算桶的数量
int bucketCount = (int) Math.floor((max - min) / bucketSize) + 1;
List<List<Float>> buckets = new ArrayList<>(bucketCount);

// 初始化桶
for (int i = 0; i < bucketCount; i++) {
buckets.add(new ArrayList<>());
}

// 将数组中的元素分配到各个桶中
for (float value : arr) {
int bucketIndex = (int) Math.floor((value - min) / bucketSize);
buckets.get(bucketIndex).add(value);
}

// 对每个桶内的元素进行排序,并输出到原数组中
int currentIndex = 0;
for (List<Float> bucket : buckets) {
// 使用集合排序方法对桶内元素进行排序
Collections.sort(bucket);

// 将排序好的桶内元素放回到原数组
for (float value : bucket) {
arr[currentIndex++] = value;
}
}
}

public static void main(String[] args) {
float[] array = {0.42f, 0.32f, 0.23f, 0.52f, 0.25f, 0.47f, 0.51f};
bucketSort(array, 5);
for (float v : array) {
System.out.print(v + " ");
}
}
}

计数排序(Counting Sort)

  • 最好时间复杂度: O(n+k)
  • 平均时间复杂度: O(n+k)
  • 最坏时间复杂度: O(n+k)
  • 空间复杂度: O(k)
  • 稳定性: 稳定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class CountingSort {

// 计数排序函数
public static void countingSort(int[] arr) {
// 找出数组中的最大数和最小数
int max = Integer.MIN_VALUE;
int min = Integer.MAX_VALUE;
for (int i : arr) {
if (i > max) {
max = i; // 更新最大值
}
if (i < min) {
min = i; // 更新最小值
}
}

// 创建计数数组,用于统计各个整数的个数
int range = max - min + 1; // 确定计数数组的长度
int[] count = new int[range]; // 计数数组
for (int i : arr) {
count[i - min]++; // 对应元素的计数加1
}
// 已经确定好了数组的排序

// 根据计数数组,重建原始数组,实现排序
int index = 0; // arr的下标
for (int i = 0; i < range; i++) {
while (count[i] > 0) {
arr[index++] = i + min; // 将计数数组中的元素按序放入原数组
count[i]--; // 计数减1
}
}
}

// 主函数
public static void main(String[] args) {
int[] arr = {4, 2, 2, 8, 3, 3, 1}; // 待排序数组
countingSort(arr); // 调用计数排序函数
for (int i : arr) {
System.out.print(i + " "); // 输出排序后的数组
}
}
}

计数排序中的计数数组(Count Array)起着至关重要的作用。它用于统计每个不同的元素在待排序数组中出现的次数。下面是计数数组工作原理的详细解释:

  1. 确定数值范围:首先,我们需要遍历待排序的数组来找出数组中的最大值和最小值。这两个值能够帮助我们确定计数数组的大小,即最大值和最小值之间的范围。
  2. 创建计数数组:根据最大值和最小值的差值(加上1,以包含最大值本身),我们创建一个计数数组。这个数组的每个索引对应原始数组中可能出现的一个值。
  3. 计数:接着,我们再次遍历待排序数组,对于数组中的每个元素x,我们将计数数组中对应于x - min的值增加1。这样做是因为计数数组的索引是从0开始的,而我们的数值可能不是从0开始。通过减去最小值min,我们确保了计数数组的索引正确对应于原始数组的元素。
  4. 排序:最后,我们可以通过遍历计数数组来重建排序后的数组。对于计数数组中的每个索引,如果其对应的计数大于0,我们就将对应的元素(索引值加上最小值min)添加到排序后的数组中,并且次数为计数数组中记录的次数。

计数数组的作用就是作为一个中介,记录原始数组中各个元素的分布情况。因为计数排序不是基于比较的排序算法,它通过这种统计元素出现次数的方式来实现对原始数组的排序。这也是计数排序可以在线性时间内完成排序的关键所在,尤其适用于元素值分布在一个较小范围内的数组。

基数排序(Radix Sort)

  • 最好时间复杂度: O(nk)
  • 平均时间复杂度: O(nk)
  • 最坏时间复杂度: O(nk)
  • 空间复杂度: O(n+k)
  • 稳定性: 稳定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
看不懂
import java.util.Arrays;

public class RadixSort {

// 基数排序方法
public static void radixSort(int[] arr) {
// 找到数组中最大的数,确定最大数的位数
int max = Arrays.stream(arr).max().getAsInt();
// 从个位开始,对数组arr按"指数"进行排序
for (int exp = 1; max / exp > 0; exp *= 10) {
countingSortByDigit(arr, exp);
}
}

// 根据每个位上的数值进行计数排序
private static void countingSortByDigit(int[] arr, int exp) {
int n = arr.length;
int[] output = new int[n]; // 存储"被排序数据"的临时数组
int[] count = new int[10]; // 计数数组,范围0到9,表示位值(0-9)

// 存储位值的频率
for (int i = 0; i < n; i++) {
count[(arr[i] / exp) % 10]++;
}

// 更改count[i]。现在它包含了这个位上小于等于i的数字的个数
for (int i = 1; i < 10; i++) {
count[i] += count[i - 1];
}

// 建立输出数组
for (int i = n - 1; i >= 0; i--) {
output[count[(arr[i] / exp) % 10] - 1] = arr[i];
count[(arr[i] / exp) % 10]--;
}

// 将排序好的数据复制到arr[]
System.arraycopy(output, 0, arr, 0, n);
}

// 主方法
public static void main(String[] args) {
int[] arr = {170, 45, 75, 90, 802, 24, 2, 66};
radixSort(arr);
for (int i : arr) {
System.out.print(i + " ");
}
}
}

在这个实现中:

  • radixSort 方法确定了数组中的最大数,并决定了循环的次数(即最大数的位数)。
  • 对于数组中的每个元素,countingSortByDigit 方法根据当前位(由 exp 确定)进行计数排序。
  • 计数排序不是直接对原数组进行操作,而是使用了一个临时数组 output 来存放排序的结果。
  • 通过迭代每个位,radixSort 最终能够得到一个完全排序的数组。

请注意,上述代码假设我们正在排序的数字都是非负的。如果数组中包含负数,那么基数排序的实现需要额外的逻辑来处理正负数的情况。

希尔排序(Shell Sort)

  • 最好时间复杂度: O(n log n)
  • 平均时间复杂度: O(n(log n)^2)
  • 最坏时间复杂度: O(n(log n)^2)
  • 空间复杂度: O(1)
  • 稳定性: 不稳定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

public class ShellSort {

// 希尔排序方法
public static void shellSort(int[] arr) {
int len = arr.length;
// 初始增量为数组长度的一半
for (int gap = len / 2; gap > 0; gap /= 2) {
// 从第gap个元素开始,逐个对其所在的组进行直接插入排序
for (int i = gap; i < len; i++) {
// 记录当前元素,并准备将其插入到合适的位置
int current = arr[i];
int j = i - gap;
// 寻找当前元素的合适位置
while (j >= 0 && arr[j] > current) {
// 将比当前元素大的元素向后移动gap位
arr[j + gap] = arr[j];
j -= gap;
}
// 将当前元素插入到找到的位置
arr[j + gap] = current;
}
}
}

// 主方法
public static void main(String[] args) {
int[] arr = {72, 6, 57, 88, 60, 42, 83, 73, 48, 85};
shellSort(arr);
for (int i : arr) {
System.out.print(i + " ");
}
}
}

在这段代码中:

  • 我们首先设置一个初始增量gap,通常为数组长度的一半。
  • 然后在外层循环中,逐渐减小gap的值,每次都将gap减半,直到gap为1。
  • 对于每个给定的gap值,我们从下标为gap的元素开始,通过内层循环遍历数组。
  • 我们将当前元素保存在current中,并寻找它在已排序序列中的正确位置。
  • 如果遇到一个大于current的元素,我们就将它向后移动gap位。当找到适合current的位置时,我们将其插入。
  • 最终,当gap减小到1时,整个数组将被完整地排序。

希尔排序比传统的插入排序有更好的性能,因为它允许交换距离较远的元素,这样可以帮助减少总的比较和移动次数。

为什么会出现OOM呢?一般都是由这些问题引起

  1. 分配过少:JVM 初始化内存小,业务使用了大量内存;或者不同 JVM 区域分配内存不合理
  2. 内存泄漏:某一个对象被频繁申请,不用了之后却没有被释放,发生内存泄漏,导致内存耗尽(比如ThreadLocal泄露)****

img

场景一:堆内存OOM(也叫堆内存溢出)

这是最常见的OOM场景了,发生在JVM试图分配对象空间时,却发现剩余的堆内存不足以存储新对象。

例如我们执行下面的代码,就可以模拟出堆内存OOM的场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

// 创建大量对象导致堆内存溢出
public class HeapOOM {
static class OOMObject {
// 假设这里有一些属性
}

public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();

while (true) {
list.add(new OOMObject()); // 不断创建对象并添加到list中
}
}
}

那么当出现线上应用OOM场景时,该如何解决呢?

img

分析方法通常有两种:

  • 类型一:在线分析,属于轻量级的分析:
  • 类型二:离线分析,属于重量级的分析:

类型一:在线OOM分析:

在线分析Java OOM(内存溢出)问题,通常涉及到监控运行中的Java应用,捕获内存溢出时的信息,分析堆转储(Heap Dump)文件,以及利用一些工具和命令来辅助定位问题。下面是一套详细的分析流程和命令,帮助你在线分析和解决Java OOM问题:

1、启用JVM参数以捕获Heap Dump

在Java应用启动命令中加入以下JVM参数,以确保在发生OOM时能自动生成堆转储文件:

1
2
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/heapdump.hprof

这些参数的作用是:

  • -XX:+HeapDumpOnOutOfMemoryError:指示JVM在遇到OOM错误时生成堆转储文件。
  • -XX:HeapDumpPath:指定堆转储文件的存储路径,可以自定义路径和文件名。

2、实时监控内存使用情况

使用jvisualvmjconsole等工具可以实时监控Java应用的内存使用情况。这些工具可以帮助你了解内存消耗的趋势,从而预测和避免OOM的发生。

  • JVisualVM:集成了多个JDK命令行工具,提供了可视化界面,可以监控内存使用、查看线程、分析堆等。
  • JConsole:Java监控和管理控制台,用于对JVM中的内存、线程和类等进行监控。

3、分析Heap Dump文件

当应用抛出OOM并且根据上述设置生成了堆转储文件后,使用Heap Dump分析工具来分析这个文件。常用的工具有:

  • **Eclipse Memory Analyzer (MAT)**:一个强大的Java堆分析工具,可以帮助识别内存泄露和查看内存消耗情况。
  • VisualVM:除了监控功能外,也支持加载和分析Heap Dump文件。

在MAT中打开Heap Dump文件,主要关注以下几点:

  • 查找内存中对象的分布,特别是占用内存最多的对象。
  • 分析这些对象的引用链,确定是哪部分代码引起的内存泄漏或过度消耗。
  • 检查ClassLoader,以确认是否有过多的类被加载导致的元空间(Metaspace)OOM。

4、使用命令行工具

JDK提供了一些命令行工具,如jmap,可以用来生成Heap Dump文件:

1
jmap -dump:live,format=b,file=heapdump.hprof <pid>

其中<pid>是Java进程的ID。-dump:live选项表示只转储活动对象,可以减小Heap Dump文件的大小。

5、分析日志和异常信息

最后,不要忽视应用的日志和抛出的异常信息。OOM之前的日志可能会提供一些导致内存溢出的操作或业务逻辑的线索。

类型二:离线OOM分析,这个属于重量级分析

离线分析Java OOM(OutOfMemoryError)通常是在问题发生后,通过分析JVM生成的堆转储(Heap Dump)文件来进行。这个过程涉及到获取堆转储文件、使用分析工具进行深入分析和解读分析结果

1、获取Heap Dump文件

首先,确保你已经有了一个Heap Dump文件。这个文件可能是在JVM遇到OOM时自动生成的(如果启用了-XX:+HeapDumpOnOutOfMemoryError JVM参数),或者你可以在应用运行期间手动生成:

  • 使用jmap命令生成Heap Dump文件:

    1
    jmap -dump:live,format=b,file=/path/to/heapdump.hprof <pid>

    其中<pid>是Java进程的ID,/path/to/heapdump.hprof是你希望保存Heap Dump文件的位置。

2、使用Heap Dump分析工具

有了Heap Dump文件后,你需要使用专门的工具来进行分析。以下是一些常用的分析工具:

  • **Eclipse Memory Analyzer (MAT)**:非常强大的内存分析工具,能帮助识别内存泄漏和查看内存消耗情况。
  • VisualVM:提供了一个可视化界面,可以用来分析Heap Dump文件。
  • JVisualVM:随JDK一起提供的工具,也支持加载Heap Dump文件进行分析。

3、分析Heap Dump文件

使用MAT(Eclipse Memory Analyzer)作为示例,分析流程如下:

  1. 打开Heap Dump文件:启动MAT并打开Heap Dump文件(.hprof)。
  2. 运行Leak Suspects Report:MAT可以自动生成一个内存泄漏报告(Leak Suspects Report),这个报告会指出可能的内存泄漏路径。
  3. 分析Dominators Tree:这个视图显示了占用最多内存的对象及其引用。通过它,你可以找到最大的内存消耗者。
  4. 查看Histogram:对象Histogram列出了所有对象的实例数和总大小,帮助你识别哪种类型的对象占用了最多的内存。
  5. 检查GC Roots:为了确定对象为什么没有被垃圾回收,可以查看对象到GC Roots的引用链。
  6. 分析引用链:通过分析对象的引用链,你可以确定是什么持有了这些对象的引用,导致它们无法被回收。

下面给大家提供一份Java应用上线前参考的的JVM配置(内存8G),以后系统上线前可以先配置下JVM,不要啥都不配置就上线了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-Xms6g -Xmx6g (按不同容器,4G及以下建议为50%,6G以上,建议设置为70%)
-Xmn2g (以8G内存,年轻代可以设置为2G)
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
-Xss256k
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:AutoBoxCacheMax=20000
-XX:+HeapDumpOnOutOfMemoryError (当JVM发生OOM时,自动生成DUMP文件)
-XX:HeapDumpPath=/usr/local/logs/gc/
-XX:ErrorFile=/usr/local/logs/gc/hs_err_%p.log (当JVM发生崩溃时,自动生成错误日志)
-XX:+PrintGCApplicationStoppedTime
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/usr/local/heap-dump/

场景二:元空间(MetaSpace)OOM

什么是元空间?

Java元空间(Metaspace)是Java虚拟机(JVM)中用于存放类的元数据的区域,从Java 8开始引入,替代了之前的永久代(PermGen)

图中红色箭头所指就是元空间

img

元空间是方法区在HotSpot JVM 中的实现,方法区主要用于存储类的信息、常量池、方法数据、方法代码等。方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。

不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。理论上取决于32位/64位系统可虚拟的内存大小,可见也不是无限制的,需要配置参数。

元空间(Metaspace) 垃圾回收,会对僵死的类及类加载器的垃圾回收会进行回收,元空间(Metaspace) 垃圾回收的时机是,在元数据使用达到“MaxMetaspaceSize”参数的设定值时进行。

元空间OOM的现象

JVM 在启动后或者某个时间点开始,MetaSpace 的已使用大小在持续增长,同时每次 GC 也无法释放,调大 MetaSpace 空间也无法彻底解决

元空间OOM的核心原因:生成了大量动态类

比如:

  1. 使用大量动态生成类的框架(如某些ORM框架、动态代理技术、热部署工具等)
  2. 程序代码中大量使用反射,反射在大量使用时,因为使用缓存的原因,会导致ClassLoader和它引用的Class等对象不能被回收

例如下面的生成大量动态代理类的代码示例,则会导致元空间的OOM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 使用CGLIB动态生成大量类导致元空间溢出
public class MetaspaceOOM {
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
enhancer.create(); // 动态生成类并加载
}
}

static class OOMObject {
// 这里可以是一些业务方法
}
}


元空间(Metaspace) OOM 解决办法:

  1. 减少程序中反射的大量使用
  2. 做好熔断限流措施,对应用做好过载保护,比如阿里的sentinel限流熔断中间件

场景三:堆外内存OOM

Java对外内存(Direct Memory)OOM指的是Java直接使用的非堆内存(off-heap memory)耗尽导致的OutOfMemoryError。这部分内存主要用于Java NIO库,允许Java程序以更接近操作系统的方式管理内存,常用于高性能缓存、大型数据处理等场景

例如下面的代码,如何堆外内存太小,就会导致堆外内存的OOM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 分配大量直接内存导致OOM
import java.nio.ByteBuffer;

public class DirectMemoryOOM {
private static final int ONE_MB = 1024 * 1024;

public static void main(String[] args) {
int count = 1;

try {
while (true) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(ONE_MB);
count++;
}
} catch (Exception e) {
System.out.println("Exception: instance created " + count);
throw e;
}
}
}


堆外内存的原因

  • 分配过量的直接内存:程序中大量使用DirectByteBuffer等直接内存分配方式,而没有相应的释放机制,导致内存迅速耗尽,常见于NIO、Netty等相关组件。
  • 内存泄露:如果分配的直接内存没有被及时释放(例如,ByteBuffer未被回收),就可能发生内存泄露。
  • JVM对外内存限制设置不当:通过-XX:MaxDirectMemorySize参数控制对外内存大小,如果设置过小,可能无法满足应用需求。

堆外内存OOM的解决方案

  • 合理设置对外内存大小:根据应用的实际需求调整-XX:MaxDirectMemorySize参数,给予足够的直接内存空间。
  • 优化内存使用:减少不必要的直接内存分配,重用DirectByteBuffer等资源。
  • 内存泄露排查:使用工具(如VisualVM、JProfiler等)定位和解决内存泄露问题。
  • 代码优化:确保使用完直接内存后显式调用sun.misc.Cleaner.clean()或通过其他机制释放内存。

进程间通信 (同步) 方式

进程间通信(IPC, Inter-Process Communication)是在不同进程之间传递数据或信号的机制。同步IPC指的是在发送和接收数据时,涉及的进程需要协调它们的工作节奏,即一个进程在等待操作完成时会被阻塞。以下是一些常用的同步IPC方式:

  1. **管道 (Pipes)**:
    管道是最古老的IPC机制之一,它允许一个进程和另一个有父子关系的进程进行通信。数据以字节流的形式单向流动。命名管道(也称为FIFO)则允许不相关的进程通信。
  2. **消息队列 (Message Queues)**:
    消息队列允许不同进程发送和接收消息。进程可以在任何时候发送消息到队列,接收者可以同步或异步地接收消息。
  3. **信号量 (Semaphores)**:
    信号量是一个计数器,用于控制多个进程对共享资源的访问。它主要用于实现进程间的同步。
  4. **共享内存 (Shared Memory)**:
    共享内存是一段能够被多个进程访问的内存区域。进程可以直接读写这段内存,但是需要使用同步机制如信号量来防止竞态条件。
  5. **套接字 (Sockets)**:
    套接字是更为通用的IPC机制,支持网络通信。本地套接字(UNIX套接字)可用于在同一台机器上运行的进程间的同步通信。
  6. **文件锁 (File Locking)**:
    进程可以对文件的特定部分加锁,以防止其他进程同时访问该部分。这是一种同步机制,确保只有一个进程能够同时写入文件。
  7. **条件变量 (Condition Variables)**:
    条件变量通常与互斥锁(mutexes)一起使用,允许进程以阻塞的方式等待特定条件的发生。
  8. **事件计数器 (Event Counters)**:
    事件计数器是用来跟踪和同步特定事件发生次数的同步机制。
  9. **信号 (Signals)**:
    信号是一种限制性的IPC,用于通知接收进程某个事件已发生。尽管信号不是同步机制,但它们可以用来在进程之间同步事件。
  10. **远程过程调用 (RPC)**:
    RPC允许一个进程调用另一个进程的函数,就像调用本地函数一样。RPC通常涉及网络通信,但也可以用于本地进程通信。

使用这些同步IPC机制时,设计良好的同步策略是至关重要的,以避免死锁、竞态条件和资源争用等问题。

操作系统中内存分段和分页使用场景

操作系统中的内存分段和分页是两种内存管理技术,它们都旨在使物理内存的使用更加高效和灵活。这两种技术虽然有不同的特点和使用场景,但在现代操作系统中往往是同时使用的。

内存分段(Segmentation)

分段是一种内存管理方案,它将程序的不同部分划分为不同的段,如代码段、数据段、堆栈段等。

使用场景

  • 逻辑组织:分段根据程序的逻辑结构来组织内存,使得每个段都有一个特定的功能或含义,这有助于程序的模块化和保护。
  • 保护和隔离:每个段可以有不同的访问权限,提供内存保护功能,防止程序错误地访问其他段的内存。
  • 动态加载和链接:在支持动态加载和链接的系统中,分段可以让不同的程序段独立加载和替换,便于动态更新程序。

内存分页(Paging)

分页是一种内存管理方案,它将物理内存划分为固定大小的块,称为页(page),同时将虚拟内存空间划分为同样大小的页。

使用场景

  • 物理内存管理:分页简化了物理内存的管理,因为所有的页都是同样大小,所以易于跟踪和分配。
  • 虚拟内存:分页是虚拟内存实现的基础,它允许非连续的物理内存被映射到连续的虚拟地址空间。
  • 内存隔离:每个进程有自己的页表,保证了不同进程间虚拟内存空间的隔离。
  • 换入换出:分页支持将数据从磁盘换入到RAM中,以及将不经常访问的内存页交换到磁盘(换出)以释放RAM。
  • 内存去碎片化:分页通过将内存划分为固定大小的页,有助于减少内存碎片化

申请内存的步骤

  1. 请求内存
    程序通过调用操作系统提供的API(例如C语言中的malloc或C++中的new)来请求内存。
  2. 生成段
    操作系统将程序的不同部分划分成段,如代码段、数据段、堆段和栈段。
  3. 创建段表
    对于每个段,操作系统在内存中创建一个段表,记录每个段的基地址、长度和访问权限。
  4. 分配页框
    当程序请求的内存无法在当前的空闲内存中得到满足时,操作系统将分配页框(物理内存中的页)。
  5. 更新页表
    操作系统更新页表,将虚拟地址映射到新分配的物理页框。页表保存了虚拟页到物理页框的映射。
  6. 返回内存地址
    请求内存的操作完成后,操作系统返回一个指向申请到的内存的指针。

使用内存的步骤

  1. 生成逻辑地址
    当程序需要访问内存时,它生成一个逻辑地址(也称为虚拟地址),逻辑地址由段选择子和段内偏移组成。
  2. 段翻译
    系统使用段选择子来在段表中查找对应的段描述符,获取段的基地址、界限和其他属性。
  3. 计算线性地址
    通过将段的基地址与段内偏移相加,操作系统计算出线性地址(虚拟内存中的地址)。
  4. 分页翻译
    线性地址被分解为页号和页内偏移。操作系统使用页号通过当前进程的页表查找对应的物理页框号。
  5. 计算物理地址
    将物理页框号与页内偏移相结合,计算出物理地址。
  6. 访问物理内存
    一旦计算出物理地址,处理器便可访问对应的物理内存位置。

在整个申请和使用内存的过程中,操作系统通过段表和页表来管理内存,并在访问内存时进行地址转换。如果在分页翻译过程中页表项指示的是不在内存中的页(即页面失效),操作系统会从磁盘中加载所需的页到物理内存,这一过程称为页面换入。这种段页式内存管理为操作系统提供了同时实现内存保护、内存共享和虚拟内存的能力。

操作系统什么时候会发生中断

操作系统会在多种情况下发生中断。中断是指处理器暂停当前的任务去响应某个事件的机制,它是操作系统的一个关键特性,用于处理异步事件。中断可以分为硬件中断和软件中断。

硬件中断

硬件中断通常由系统外部的硬件设备触发。常见的硬件中断包括:

  1. I/O中断
    当输入/输出操作完成时,比如从硬盘读取数据或向打印机发送数据完成时,设备会向处理器发出中断信号。
  2. 时钟中断
    系统时钟周期性地发出中断,通常用于操作系统的调度程序,实现时间片轮转等功能。
  3. 外部信号中断
    如用户按下Ctrl+C键终止程序,或者其他外部事件(如电源键)发出信号。
  4. 硬件故障
    硬件发生错误,如电源故障或内存错误,也会引发中断。

软件中断

软件中断通常由运行在处理器上的程序主动触发。常见的软件中断包括:

  1. 系统调用
    程序执行系统调用指令(如Linux中的int 0x80syscall指令)请求操作系统服务时会触发中断。
  2. 异常
    当程序运行出现错误时,如除零错误、无效的内存访问(段错误)或者执行非法指令,处理器会触发异常中断。
  3. 软件中断指令
    程序中显式包含的中断指令(如int指令)会直接触发中断。

专门的中断情况

还有一些专门的中断情况,如:

  1. 中断请求(IRQ)
    多个设备可能共享一个中断请求线,它们会通过这个线向处理器发送中断请求。
  2. 中断向量
    每种中断类型都会被分配一个中断向量,这是一个索引,用于在中断向量表中查找中断处理程序的地址。
  3. 快速系统调用
    某些系统调用(如获取系统时间)可能会通过专门的快速路径来执行,以减少常规系统调用的开销。

无论是硬件中断还是软件中断,当中断发生时,处理器会立即暂停当前正在执行的任务(除非正在处理更高优先级的中断),并且跳转到一个预先定义的中断处理程序去响应中断。中断处理程序执行完毕后,处理器会返回到被中断的任务继续执行。通过这种方式,操作系统能够及时响应内部和外部的事件,并控制系统资源的分配和任务的执行

JAVA对象组成

在 Java 中,一个对象主要由三部分组成:

  1. 对象头(Object Header):包含了对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。这部分数据的长度在 32 位和 64 位的 JVM(Java Virtual Machine)中分别为 32 bit 和 64 bit。
  2. 实例数据(Instance Data):也就是在类中所定义的各种类型的字段内容。
  3. 对齐填充(Padding):不是必需的,仅仅起到占位符的作用,没有实际意义。主要是为了对象大小满足系统要求。

这三部分组成了 Java 对象在内存中的布局。

对象头中又分为两部分:Mark Word 和 类型指针。

  1. Mark Word:存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
  2. 类型指针:是指向对象的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

死锁

在MySQL中,死锁是指两个或多个事务在互相等待对方释放锁定资源的情况下,导致它们都无法继续执行的一种状态

  1. 查看InnoDB状态输出:
    • 使用 SHOW ENGINE INNODB STATUS; 查询可以提供关于最近发生的死锁的详细信息。这个命令的输出包含了很多信息,你需要查找 LATEST DETECTED DEADLOCK 部分来获取死锁的具体信息。
  2. 错误日志:
    • MySQL将死锁信息写入到错误日志中。你可以检查MySQL的错误日志文件来找到死锁相关的信息。位置和名称可能因安装而异,通常是 /var/log/mysql/error.log 或者 /var/log/mysqld.log
  3. 性能模式表:
    • 如果你的MySQL服务器开启了性能模式(Performance Schema),那么你可以查询 performance_schema.data_locksperformance_schema.data_lock_waits 表来检测死锁。
  4. 使用InnoDB监控工具:
    • 一些第三方监控工具和服务(如Percona Toolkit、MonYog等)可以帮助你检测和分析MySQL服务器的死锁情况。
  5. 启用死锁日志:
    • 你可以设置 innodb_print_all_deadlocks 选项为 ON,这样每个发生死锁的事件都会被记录到MySQL错误日志中。

死锁产生的四大必要条件通常被称为死锁的四个Coffman条件,由E. G. Coffman, Jr.等人在1971年首先提出。这些条件必须同时满足,才会导致系统进入死锁状态:

  1. 互斥条件(Mutual Exclusion):
    至少有一个资源必须处于非共享模式,也就是说,在一段时间内只有一个进程可以使用资源。如果其他进程请求该资源,请求者只能等待直到资源被释放。
  2. 持有并等待条件(Hold and Wait):
    一个进程至少持有一个资源,并且正在等待获取其他进程持有的额外资源,而这些资源可能正在被其他进程持有并等待释放。
  3. 不可抢占条件(No Preemption):
    已经分配给一个进程的资源不能被非自愿地抢占。这意味着资源只能被占有它的进程释放,只有在该进程完成任务后或者主动放弃资源时,资源才会被释放。
  4. 循环等待条件(Circular Wait):
    必须存在一个进程等待链,链中的每一个进程至少持有一个其他进程所需要的资源,形成一个闭合的循环等待链。比如,进程P1等待由进程P2持有的资源,P2等待P3持有的资源,而P3可能又在等待P1持有的资源。

只有当这四个条件同时发生时,才会产生死锁。预防或避免死锁的一些策略就是基于破坏这四个条件中的一个或多个来实现的。例如,可以通过资源的一次性分配来避免持有并等待条件,或者允许资源被抢占来避免不可抢占条件。

HTTP/HTTPS 什么区别?HTTP 特性有哪些

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
HTTP/HTTPS 什么区别?HTTP 特性有哪些

HTTP(超文本传输协议)和HTTPS(安全超文本传输协议)是互联网中最常用的两种协议,用于在web服务器和浏览器之间传输数据。它们的主要区别在于HTTPS提供了对数据传输的加密,而HTTP不提供加密。以下是HTTP和HTTPS的具体区别:

1. **安全性**:
- HTTP不提供数据加密,因此传输的数据可以被任何截获网络数据包的人轻易阅读。
- HTTPS提供了SSL(安全套接层)或TLS(传输层安全性)加密。这意味着所有的数据传输都是加密的,只有客户端和服务器才能解密阅读。
2. **端口**:
- HTTP通常使用80端口。
- HTTPS通常使用443端口。
3. **性能**:
- HTTP请求的开销比HTTPS小,因为HTTPS需要进行加密解密的过程,这可能会稍微降低性能。
- 随着技术的发展,这个性能差异已经变得不那么显著。
4. **证书**:
- HTTPS需要使用SSL/TLS证书。这些证书由证书颁发机构(CA)签发,并用于验证服务器的身份。
- HTTP不需要证书。
5. **URL格式**:
- HTTP的URL以 `http://` 开头。
- HTTPS的URL以 `https://` 开头。

HTTP的特性包括:

- **无状态性**:
- HTTP是无状态的,这意味着服务器不会保存任何有关客户端请求的状态信息。
- **无连接性**:
- HTTP协议对于事务处理是无连接的,即默认情况下,HTTP请求和响应完成后,服务器会关闭TCP连接。这有助于减少服务器的资源消耗。
- **可扩展性**:
- HTTP允许通过请求头和响应头传输任意类型的数据,而不仅仅是HTML。这使得HTTP可以传输图片、视频、JSON、XML等多种媒体类型。
- **灵活性**:
- HTTP协议允许客户端和服务器通过请求方法(如GET、POST、PUT、DELETE等)进行各种类型的交云。
- **简单性**:
- HTTP协议相对简单,易于实现和使用。

由于HTTPS提供了显著的安全优势,现代网站和应用程序越来越倾向于使用HTTPS来保护用户数据和隐私。

http 1.0 1.1 2.0 3.0都说明下区别

HTTP(超文本传输协议)的不同版本提供了不同的功能和优化。以下是HTTP 1.0、HTTP 1.1、HTTP/2和HTTP/3之间的主要区别:

### HTTP 1.0:

- 无连接性

- 每次HTTP请求都会打开一个新的TCP连接,并在请求处理完成后关闭,这导致了额外的延迟和性能问题。

- 无宿主头信息

- HTTP 1.0不支持Host头信息,这意味着每个服务器IP只能托管一个网站。

### HTTP 1.1:

- 持久连接

- 默认情况下支持持久连接(Connection: keep-alive),允许在一个TCP连接上发送和接收多个HTTP请求/响应,减少了建立和关闭连接的开销。

- 流水线处理

- 引入了请求的流水线处理,允许客户端在收到上一个响应之前发送多个请求。

- 增加了Host头
- 支持Host头信息,允许在同一个IP地址和端口上托管多个域名的网站(虚拟主机)。
- 缓存控制
- 引入了更复杂的缓存控制机制,允许更有效地管理缓存数据。

- 区块传输编码
- 支持区块传输编码,允许服务器发送尚未完全生成的响应。

### HTTP/2:

- 二进制协议:- 使用二进制而非文本格式传输数据,提高了解析效率和网络性能。

- 多路复用:- 支持单一连接上的多路复用,允许在一个TCP连接上并行交错地发送多个请求和响应,减少了延迟。

- 服务器推送 :- 服务器可以在客户端明确请求之前向客户端推送资源,这可以进一步提高加载速度。

- 头部压缩:- 引入了HPACK压缩格式,减少了HTTP头部大小,降低了传输开销。

- 流控制和优先级 : - 提供了流控制和请求优先级处理的机制。

### HTTP/3:

- 基于QUIC协议 :- HTTP/3使用基于UDP的QUIC协议代替了TCP,以减少连接和传输时的延迟。

- 内置安全性 : - QUIC协议包括了TLS 1.3加密,提供了更好的安全性和隐私。

- 连接迁移 :- 支持连接迁移,允许连接在网络变化或设备IP地址变动时保持活跃。

- 快速握手: - 通过QUIC实现了减少握手次数的快速连接建立。

- 改进的拥塞控制 :- QUIC提供了更加有效的拥塞控制机制,可以更好地处理网络变化。

每个新版本的HTTP都在性能、效率、安全性等方面进行了改进,以适应不断增长的网络需求和挑战。随着技术的发展,更多的网站和应用程序正在迁移到HTTP/2和HTTP/3,以便为用户提供更好的在线体验。

MySQL 聚集索引 (主键索引) 和非聚集索引 (辅助索引/普通索引) 的区别

MySQL中的聚集索引(主键索引)和非聚集索引(辅助索引/普通索引)是两种不同类型的索引,它们在数据存储和检索上有本质的区别:

聚集索引(主键索引):

  • 数据存储:聚集索引决定了表中数据的物理存储顺序。在聚集索引中,表中的行是按照索引键值的顺序存储的。因此,一个表只能有一个聚集索引。
  • 主键约束:在许多情况下,聚集索引是根据表的主键创建的。如果没有定义主键,MySQL可能会使用唯一索引作为聚集索引。如果这些都不存在,MySQL会自动生成一个隐藏的聚集索引。
  • 性能优势:由于数据行与索引是紧密结合的,聚集索引通常在查找数据时提供更快的检索速度,尤其是对于范围查询。
  • 插入速度:插入速度可能会受到影响,因为要保持数据的物理顺序,新行或更新可能需要移动其他行。

非聚集索引(辅助索引/普通索引)

  • 数据存储:非聚集索引和数据行是分开存储的。索引结构包含索引键值和指向数据行的指针,而不是数据本身。
  • 多个索引:一个表可以有多个非聚集索引,每个索引都是基于不同的列或列组合。
  • 性能考虑:非聚集索引在检索特定数据时可能比聚集索引慢,因为它需要两次查找:首先在索引中查找,然后通过索引中的指针查找实际的数据行。
  • 插入速度:插入速度可能更快,因为它不需要对数据行进行排序,但是更新索引本身可能需要时间,特别是如果索引列经常变化。

总的来说,聚集索引是根据数据的存储顺序建立的,而非聚集索引则是一个单独的结构,它引用了数据的存储位置。聚集索引在执行范围查询时特别有效,而非聚集索引适用于快速查找特定值。在设计数据库和选择索引时,通常需要根据查询模式和性能要求来决定使用哪种类型的索引。

什么是微服务架构?

1
2
3
4
5
6
7
8
9
10
11
12
13
微服务架构是一种设计方法,它将应用程序作为一套小的服务来构建,每个服务都运行在其自己的进程中,并且通常是围绕业务能力构建的。这些服务可以通过轻量级的通信机制(通常是HTTP RESTful API)相互沟通。每个服务都是独立的,并且可以单独部署在自动化部署机制的帮助下。它们可以使用不同的编程语言来编写,可以使用不同的数据存储技术。

微服务架构的主要优点包括:
模块化:微服务使得应用程序更容易理解、开发和测试。
可伸缩性:服务可以独立地扩展,只对系统中需要更多资源的部分进行扩展。
可复用性:功能可以通过不同的组件跨多个项目或业务领域复用。
可替换性和升级性:服务可以独立替换或升级,不会影响应用程序的其他部分。
分布式开发:微服务架构允许分布式团队独立工作。
故障隔离:一个服务的故障不会影响到整个系统。
混合技术栈:可以在不同的服务中使用不同的语言和技术。

尽管微服务架构有很多优点,但也有一些挑战,例如服务之间的通信、数据一致性、复杂的分布式系统管理等。因此,在决定是否采用微服务架构时,需要权衡这些因素。

怎么保证微服务架构的高可用

1
2
3
4
5
6
7
8
实施故障隔离:通过实施故障隔离(如使用bulkheads模式),将组件分隔开,以限制故障的影响范围。
使用队列:使用队列来解耦服务,这样可以处理流量高峰,防止服务过载。
设计容错性:微服务应该设计为具有容错性,能够从部分或间歇性故障中恢复。
自动化部署和扩展:自动化部署和扩展机制可以帮助服务在负载变化时保持可用性。
实施健康检查:定期进行健康检查,以确保服务的正常运行,并及时发现问题。
使用断路器模式:断路器模式可以防止故障在系统中蔓延。
增加冗余:通过在不同层次上增加冗余来移除单点故障,确保服务的连续性。
监控和测试:持续监控服务的性能和健康状况,并进行故障转移测试,以确保系统在出现基础设施问题或组件故障时仍能继续提供服务。

现在需要设计一个开放平台,即提供接口给合作伙伴用,你觉得需要考虑一些什么问题?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
设计一个开放平台API时,应该考虑以下问题:
接受和响应JSON:确保API可以接受和返回JSON格式的数据,因为这是最常见和广泛支持的数据交换格式。
使用名词而不是动词:在端点路径中使用名词而不是动词,并且集合名称使用复数名词。
资源嵌套:对于层次结构对象,使用嵌套资源。
优雅的错误处理:优雅地处理错误,并返回标准错误代码。
过滤、排序和分页:允许API用户对数据进行过滤、排序和分页。
保持良好的安全实践:确保API安全,防止未授权访问。
文档化:提供清晰、详细的API文档,以便开发者能够容易地理解和使用API
简化入门过程:使API容易理解和使用,简化开发者的入门过程。
稳定性:确保API的稳定性,避免频繁的变更。
性能考虑:确保API有良好的响应时间和处理能力,以满足合作伙伴的需求。
版本控制:合理地管理API的版本,确保向后兼容性。
限流和配额:实施适当的限流和配额策略,以防止滥用并保持服务的质量。

这些考虑因素有助于创建一个易于使用、安全、稳定且性能良好的开放平台API

什么是注册中心?

注册中心(Service Registry)在微服务架构中扮演着关键角色,库,维护着分布式系统中所有可用服务的信息。注册中心充当所有服务实例的单一真实来源,提供诸如服务位置、状态和元数据等信息。

在微服务架构中,服务实例在启动时会在服务注册中心注册自己,这样其他服务或客户端就可以通过注册中心发现这些服务实例并与之通信。注册中心需要高度可用并且保持最新状态,以便能够提供准确的服务发现和路由功能。

它的主要职责和特点包括:

服务注册:当微服务实例启动时,它会向注册中心注册自己> 的信息,包括网络地址、端口号、版本号、健康状态等。
服务发现:其他服务或应用程序可以查询注册中心以发现和定位其他微服务实例。这允许服务之间的动态发现和交互,而无需硬编码服务的位置。
健康检查:注册中心通常会定期执行健康检查,以确保注册的服务实例仍然可用。如果服务实例不健康或不再响应,注册中心会将其标记为不可用,并从可用服务列表中移除。
负载均衡:注册中心可以支持负载均衡,通过在多个服务实例之间分配请求,来优化资源利用率和响应时间。
动态配置:注册中心可能还包含服务配置的信息,这样服务就可以在运行时动态地获取配置更新,而不需要重新启动。
高可用性:注册中心自身必须高度可用,以避免成为系统的单点故障。这通常通过部署多个注册中心实例和使用复制来实现。
服务注销:当微服务实例需要下线时,它会从注册中心注销自己,以确保不会有新的请求被发送到该实例。
安全性:注册中心应该实施适当的安全措施,以确保只有授权的服务可以注册和发现服务实例。
元数据管理:注册中心还可以存储有关服务的元数据,例如服务的描述、标签或其他自定义数据。
注册中心的实现可以使用各种技术和平台,如Eureka、Consul、Zookeeper等。在微服务架构中,注册中心是实现服务治理、确保微服务之间能够有效通信的基础设施。

服务注册与发现机制的基本模型是怎样的?

服务注册与发现机制的基本模型在微服务架构中通常遵循以下步骤:

  1. 服务注册:每个微服务实例启动时,都会通过自注册(self-registration)将自己的信息,比如网络地址、端口、运行状态等,注册到服务注册中心。服务注册中心暴露了一个REST API,服务实例可以通过发送POST请求来注册自己,并通过DELETE请求来注销。
  2. 服务发现:当一个服务需要调用另一个服务时,它会查询服务注册中心以获取所需服务的实例信息。服务注册中心包含了所有可用服务实例的地址和其他重要信息。
  3. 客户端侧发现:在客户端侧发现模式中,客户端知道服务注册中心的地址,并直接查询注册中心以获取服务实例的位置信息。
  4. 服务端侧发现:在服务端侧发现模式中,客户端通过一个中间层(例如API网关)发送请求,该中间层负责查询服务注册中心并将请求路由到相应的服务实例。
  5. 负载均衡:服务发现机制通常包括负载均衡的功能,客户端或中间层可以使用一些算法(如轮询、随机、最少连接等)在多个服务实例之间分配请求。
  6. 健康检查和注销:服务注册中心定期对服务实例进行健康检查。如果服务实例不再健康或者主动下线,它会从注册中心注销自己,注册中心随后更新其记录。

这个模型确保了微服务之间可以动态地发现彼此,并且能够根据实时信息进行通信,这对于构建灵活、可伸缩的分布式系统至关重要。

服务上线与服务下线的步骤是什么?

服务上线(启动)步骤:

  1. 初始化:服务实例启动时,首先初始化其配置和所需的本地资源。
  2. 健康检查:服务启动后进行自我健康检查,确保所有组件都能正常工作。
  3. 注册:服务实例将自己的信息(如IP地址、端口、健康状态等)注册到服务注册中心。
  4. 同步元数据:服务实例可能会同步一些元数据到注册中心,如服务版本、API路径等。
  5. 服务就绪:服务完成注册并通过健康检查后,宣告自己已经就绪,可以开始接收外部请求。

服务下线(关闭)步骤:

  1. 停止接收新请求:服务实例停止接收新的外部请求。
  2. 完成处理当前请求:服务实例等待当前正在处理的请求完成,或者在规定时间内强制关闭。
  3. 注销:服务实例从服务注册中心注销,表明自己不再可用。
  4. 清理资源:服务实例释放所有占用的资源,如数据库连接、缓存、临时文件等。
  5. 关闭服务:服务实例完成所有清理工作后,关闭进程。

在整个过程中,服务的平滑启动和优雅关闭是非常重要的,这可以防止在服务启动和关闭过程中出现流量丢失或请求失败的情况。此外,服务注册中心需要能够及时更新服务实例的状态,以保证服务发现机制的准确性。

参考资料: https://developer.aliyun.com/article/910872

蚂蚁金服SOFARegistry之服务上线

一次服务的上线(注册)过程

图 - 代码流转:Publisher 注册

  1. Client 调用 publisher.register 向 SessionServer 注册服务。
  2. SessionServer 收到服务数据 (PublisherRegister) 后,将其写入内存 (SessionServer 会存储 Client 的数据到内存,用于后续可以跟 DataServer 做定期检查),再根据 dataInfoId 的一致性 Hash 寻找对应的 DataServer,将 PublisherRegister 发给 DataServer。
  3. DataServer 接收到 PublisherRegister 数据,首先也是将数据写入内存 ,DataServer 会以 dataInfoId 的维度汇总所有 PublisherRegister。同时,DataServer 将该 dataInfoId 的变更事件通知给所有 SessionServer,变更事件的内容是 dataInfoId 和版本号信息 version。
  4. 同时,异步地,DataServer 以 dataInfoId 维度增量地同步数据给其他副本。因为 DataServer 在一致性 Hash 分片的基础上,对每个分片保存了多个副本(默认是3个副本)。
  5. SessionServer 接收到变更事件通知后,对比 SessionServer 内存中存储的 dataInfoId 的 version,若发现比 DataServer 发过来的小,则主动向 DataServer 获取 dataInfoId 的完整数据,即包含了所有该 dataInfoId 具体的 PublisherRegister 列表。
  6. 最后,SessionServer 将数据推送给相应的 Client,Client 就接收到这一次服务注册之后的最新的服务列表数据。

参考资料:https://www.cnblogs.com/rossiXYZ/p/14226749.html

因为篇幅所限,本文讨论的是前两点,后续会有文章介绍另外几点**。

0xFF 参考

[蚂蚁金服服务注册中心如何实现 DataServer 平滑扩缩容](https://www.sofastack.tech/blog/sofa-registry-dataserver-smooth-expansion-contraction/)

[蚂蚁金服服务注册中心 SOFARegistry 解析 | 服务发现优化之路](https://www.sofastack.tech/blog/sofa-registry-service-discovery-optimization/)

[服务注册中心 Session 存储策略 | SOFARegistry 解析](https://www.sofastack.tech/blog/sofa-registry-session-storage/)

[海量数据下的注册中心 - SOFARegistry 架构介绍](https://www.sofastack.tech/blog/sofa-registry-introduction/)

[服务注册中心数据分片和同步方案详解 | SOFARegistry 解析](https://www.sofastack.tech/blog/sofa-registry-data-fragmentation-synchronization-scheme/)

[蚂蚁金服开源通信框架SOFABolt解析之连接管理剖析](https://www.sofastack.tech/blog/sofa-blot-connection-management-deep-dive/)

[蚂蚁金服开源通信框架SOFABolt解析之超时控制机制及心跳机制](https://www.sofastack.tech/blog/sofa-bolt-timeout-and-heart-beat-deep-dive/)

[蚂蚁金服开源通信框架 SOFABolt 协议框架解析](https://www.sofastack.tech/blog/sofa-bolt-framework-deep-dive/)

[蚂蚁金服服务注册中心数据一致性方案分析 | SOFARegistry 解析](https://blog.csdn.net/SOFAStack/article/details/104645427/)

[蚂蚁通信框架实践](https://blog.csdn.net/weixin_47364682/article/details/sohu.com/a/227222689_609518)

[sofa-bolt 远程调用](https://www.jianshu.com/p/c740810af40c)

[sofa-bolt学习](https://blog.csdn.net/weixin_47364682/article/details/jianshu.com/p/f1fae13c7848)

[SOFABolt 设计总结 - 优雅简洁的设计之道](https://www.jianshu.com/p/65c823de1249)

[SofaBolt源码分析-服务启动到消息处理](https://blog.csdn.net/qq_34088913/article/details/108469517)

[SOFABolt 源码分析](https://www.cnblogs.com/java-zhao/p/9824283.html)

[SOFABolt 源码分析9 - UserProcessor 自定义处理器的设计](https://www.jianshu.com/p/f2b8a2099323)

[SOFARegistry 介绍](https://www.sofastack.tech/projects/sofa-registry/overview/)

[SOFABolt 源码分析13 - Connection 事件处理机制的设计](https://blog.csdn.net/weixin_47364682/article/details/jianshu.com/p/d17b60418c54)

数据库同步迁移双写

你们单库拆分的时候是如何做数据迁移的 / 你们修改大表结构的时候是怎么做数据迁移的?怎么在保持应用不停机的情况下做数据迁移?什么是双写?为什么要引入双写?如果双写的过程中,有一边写失败了,怎么办?你可以用本地事务来保证双写要么都成功,要么都失败吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
在进行单库拆分或修改大表结构时,数据迁移是一个必须要考虑的重要步骤,尤其是在保持应用不停机的情况下。以下是进行数据迁移时常见的一些步骤和概念解释:
数据迁移的步骤:
评估和规划:评估现有数据量,设计迁移计划,包括迁移的时间点、迁移过程中的业务影响评估等。
创建新的数据库结构:在目标数据库中创建新的表结构,或者根据需要进行调整。
同步数据:将数据从原始数据库同步到新的数据库结构中。这通常是通过数据迁移工具或自定义脚本实现的。
数据校验:确保迁移的数据与原始数据一致,没有丢失或损坏。

保持应用不停机的策略:
读写分离:迁移期间,读操作可以继续在旧数据库上进行,而写操作转移到新数据库。
渐进式迁移:可以分批次逐步迁移数据,而非一次性迁移全部数据,以减少系统的停机时间。

双写(Dual Writes):
定义:在数据迁移期间,同时在旧数据库和新数据库上执行写操作的过程称为双写。
目的:引入双写的目的是为了确保在迁移过程中,新旧数据库的数据保持一致,不会因为迁移而丢失任何写入的数据。

双写失败处理:
当双写过程中,如果一边写入失败,需要有回滚机制以保持数据一致性。可以通过在应用层实现重试逻辑来确保写入最终一致。
如果是关键业务,可以进一步使用分布式事务或者可靠消息服务来保证数据的最终一致性。

本地事务保证双写一致性:
本地事务通常指的是在单个数据库管理系统中的事务,因此,它无法跨数据库保证双写的一致性。
要保证跨数据库的双写操作要么都成功,要么都失败,需要使用分布式事务。但是,分布式事务在性能和复杂性上有所牺牲,且不是所有的数据库系统都支持分布式事务。
在实践中,为了保持高可用性和数据一致性,通常会结合使用多种技术和策略。重要的是要根据实际业务需求和数据库特性来设计最合适的迁移方案。

分布式事务呢?为什么有一个阶段是双写,但是以目标表为准?干嘛不直接切换到单写目标表?你们有什么容错方案?

分布式事务是一种确保在多个数据库或服务之间进行的操作要么全部成功,要么全部失败的机制。在执行数据迁移或单库拆分时,使用分布式事务可以帮助保持多个数据库之间数据的一致性。然而,分布式事务的实现通常比较复杂,并且可能会对性能产生较大影响。

在数据迁移的过程中,双写阶段的存在通常有以下几个原因:

  1. 渐进式迁移:由于直接切换到新数据库可能会引入风险,所以通过双写可以在一段时间内确保旧数据库和新数据库都有最新的数据。这样可以在迁移过程中逐步转移流量,并验证新数据库的性能和稳定性。
  2. 数据一致性:在迁移过程中,保持旧数据库和新数据库数据的同步,确保在迁移完成之前,任何数据更改都会反映在两个数据库中,这样即使迁移过程中出现问题,也可以从旧数据库恢复数据。
  3. 业务无感知:使用双写可以让业务方在不感知底层数据迁移的情况下继续正常运作,直到新数据库完全准备好并验证无误后,再进行最终切换。

关于为什么不直接单写目标表,这主要是为了避免在迁移过程中发生故障导致数据不一致或丢失。在完成所有的验证和数据校验之前,双写可以作为一种安全措施。

至于容错方案,除了分布式事务之外,还有一些其他的策略可以用来保障数据迁移的稳定性:

  • 蓝绿部署:通过部署两个完全相同的生产环境,一个运行旧版本(蓝色),一个运行新版本(绿色)。一旦新环境验证稳定,就可以将流量切换到新环境。
  • 金丝雀发布:逐渐将用户流量引导到新的数据库,开始时只有少部分流量,然后根据监控和校验结果逐步增加。
  • 版本标记:在双写期间,可以通过版本标记来跟踪数据项的状态,以此来管理数据同步和回滚。
  • 回滚方案:确保有一个快速可靠的回滚方案,以便在遇到问题时能够迅速切换回旧数据库。

每个容错方案都有其优缺点,适用于不同的场景。在选择方案时,应该充分考虑数据的重要性、业务的容忍度以及迁移的复杂性。

比如说如果在迁移过程中出错了,你们的应用会怎么办?你们是怎么校验数据的?增量数据校验你们是怎么做的?数据迁移你能够做到数据绝对不出错吗?如果数据出错了你们怎么修复?怎么避免并发问题?

在数据迁移过程中,出现错误是常见的风险。以下是一些常见的错误处理和校验方法,以及增量数据校验的策略:

  1. 错误处理
    • 监控和告警:设置实时监控和告警,在迁移过程中监控系统的性能指标和错误日志,一旦发现异常,立即触发告警。
    • 暂停迁移:在检测到错误后,可能需要暂停迁移过程,以避免错误扩散。
    • 日志记录:记录详细的操作日志,以便在出现问题时追踪和诊断。
    • 回滚:准备好回滚机制,以便在发现数据不一致或其他严重问题时,能迅速恢复到迁移前的状态。
  2. 数据校验
    • 全量校验:迁移完成后,可以通过对比源数据库和目标数据库的数据快照来校验数据的完整性和一致性。
    • 抽样校验:在大规模数据迁移中,全量校验可能不实际,此时可以通过抽样校验来确保数据的准确性。
  3. 增量数据校验
    • 时间戳/版本号:为数据记录添加时间戳或版本号,以便跟踪数据变更,并进行增量校验。
    • 校验和:计算数据的校验和,对比迁移前后的校验和来检测数据一致性。
    • 双写验证:在双写阶段,对于每一笔写入,可以在新旧数据库之间进行校验,确保数据的一致性。
  4. 数据绝对不出错的可能性
    • 在实际情况中,很难保证数据迁移绝对不出错。但是,通过上述的监控、校验和错误处理机制,可以将风险降到最低。
  5. 数据出错修复
    • 数据对比和修复:通过数据对比工具检测不一致的数据,并进行修复。
    • 备份恢复:使用迁移前的备份数据来恢复错误数据。
    • 手动修复:在无法自动修复的情况下,可能需要手动介入进行数据修复。
  6. 避免并发问题
    • 锁机制:在迁移期间,对于正在迁移的数据行使用适当的锁机制,避免并发访问导致的数据不一致。
    • 队列和缓冲:使用消息队列和缓冲区来处理并发写入请求,确保按顺序处理。
    • 最终一致性模型:在某些场景下,可以接受短暂的数据不一致,只要系统最终能达到一致状态。

总之,尽管无法保证数据绝对不出错,但是通过综合的监控、校验、错误处理和并发控制机制,可以最大限度地减少迁移风险并确保数据的一致性和完整性。

让你迁移一个 2000 万行的表,你的方案是什么

迁移一个包含2000万行数据的表是一个需要谨慎规划的任务,以确保数据的完整性、迁移的效率以及最小化对生产环境的影响。以下是一个迁移大表的基本步骤和方案:

  1. 准备工作
    • 评估数据:了解表的数据模型、大小、索引以及任何特定的数据类型或结构。
    • 选择迁移时间:选择一个业务低谷期进行迁移,以最小化对用户的影响。
    • 备份数据:在迁移前对原始数据进行完整备份,以便在迁移失败时可以恢复。
  2. 设计迁移方案
    • 迁移策略:确定是一次性迁移还是分批迁移。对于2000万行的数据,分批迁移通常更可靠,可以减轻对生产系统的压力。
    • 迁移工具选择:选择合适的迁移工具,如ETL工具、数据库自带的数据导出导入工具或编写自定义的迁移脚本。
  3. 数据迁移
    • 创建目标表结构:在目标数据库创建与原表结构一致的表,包括索引和约束。
    • 分批迁移数据:将数据分批迁移,每批迁移量根据系统容量和业务需求确定。可以通过主键范围、时间戳等方式进行分批。
    • 双写设置:在迁移过程中,可能需要设置双写机制,确保新写入的数据同步到新旧表。
  4. 同步增量数据
    • 记录日志:在迁移过程中记录变化的数据,以便于后续同步。
    • 增量同步:在分批迁移后,同步迁移期间产生的增量数据。
  5. 数据校验
    • 校验数据完整性:对比源表和目标表的记录数、校验和等,确保数据的一致性和完整性。
    • 业务逻辑校验:可能需要进行更深入的数据校验,以确保迁移后的数据满足业务逻辑要求。
  6. 切换流量
    • 测试验证:在目标数据库上进行充分的测试,确保所有业务功能正常。
    • 流量切换:在确认数据迁移完成并且数据一致性无误后,切换应用流量到新的数据库。
  7. 监控
    • 迁移监控:在迁移过程中监控性能指标和日志,确保迁移顺利进行。
    • 生产监控:在切换流量后,继续监控系统性能和业务指标,确保系统稳定运行。
  8. 回滚计划
    • 准备回滚方案:在迁移过程中遇到不可解决的问题时,需要有计划地回滚到迁移前的状态。

这个方案包含了迁移前的准备、迁移过程中的操作以及迁移后的验证等多个阶段,每个阶段都需要仔细规划和执行。记住,通信和文档也是迁移成功的关键,确保所有参与者都了解迁移计划和状态。

⽹络的七层模型了解过吗?他和5层模型的区别在哪?为什么⽹络的模型这么设计?

网络的七层模型通常指的是OSI(Open Systems Interconnection)模型,而五层模型则是互联网模型。这两种模型都是为了理解和设计网络通信而创建的,但是它们在结构和理念上有所不同。

OSI模型的七层分别是:

  1. 物理层(Physical Layer)
  2. 数据链路层(Data Link Layer)
  3. 网络层(Network Layer)
  4. 传输层(Transport Layer)
  5. 会话层(Session Layer)
  6. 表示层(Presentation Layer)
  7. 应用层(Application Layer)

互联网模型的五层分别是:

  1. 物理层(Physical Layer)
  2. 数据链路层(Data Link Layer)
  3. 网络层(Network Layer)
  4. 传输层(Transport Layer)
  5. 应用层(Application Layer)

两者的区别主要在于:

  1. OSI模型更为详细,它将会话层、表示层和应用层分开考虑,而互联网模型将这三层合并为一个应用层。
  2. OSI模型是一个理论上的模型,它为网络通信提供了一个标准的参考框架。而互联网模型则是基于实践的,它更加贴近现实世界中的互联网结构。

网络模型之所以这么设计,是因为复杂的网络通信需要被分解成不同的层次来理解和管理。每一层都有特定的功能和协议,这样可以简化网络设计,使不同层次之间的通信和接口标准化,便于不同系统和设备之间的互操作性。通过这种分层,可以使网络通信更加灵活,易于开发和维护。

⾮对称加密了解过吗?它主要⽤在哪些地⽅?

非对称加密,也称为公开密钥加密,是密码学中的一种加密方法。它使用一对密钥:公钥和私钥。公钥可以公开分享,用于加密信息,而私钥保持私密,用于解密信息。非对称加密的主要特点是加密和解密使用的是两个不同的密钥。

常见的非对称加密算法包括:

  1. RSA算法:最早且最广泛使用的公钥加密算法,常用于数字签名、数据加密等。
  2. ECC(椭圆曲线加密):基于椭圆曲线数学的加密算法,相比RSA,它可以使用较短的密钥提供相同级别的安全性,因此运算速度更快,更适合移动设备。
  3. Diffie-Hellman:主要用于安全地在线交换密钥,而不直接用于加密或解密信息。
  4. DSA(数字签名算法):主要用于数字签名。

非对称加密主要用在以下几个方面:

  • 数据加密:用于保护数据的隐私,确保只有持有相应私钥的接收方才能解密和阅读数据。
  • 数字签名:确保消息的完整性和发送者的身份,接收方可以用发送者的公钥验证消息是由发送者签名且未被篡改。
  • 安全通信:如SSL/TLS协议中,非对称加密用于安全地交换对称加密的密钥,之后的通信会使用对称加密,因为对称加密在处理大量数据时更高效。
  • 身份认证:通过非对称加密可以验证一个实体的身份,如SSH登录、HTTPS网站等。

非对称加密的设计,使得它在需要安全密钥交换、数据加密和数字签名的任何场景中都非常有用,尤其是在互联网通信和数据保护方面。

mysql事务中的不可复读和幻读的区别

MySQL中,事务是一种机制,用来保证一系列的操作要么全部成功,要么全部失败,它提供了一种称为ACID的属性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。在隔离性这一属性中,不可重复读和幻读是两种可能发生的现象,它们与隔离级别设置有关。

不可重复读(Non-repeatable reads):
这种现象指的是在一个事务中,同一查询被执行了多次,但是由于其他事务的更新操作,返回了不同的结果集。这通常发生在一个事务中多次读取同一数据时,另一个并发事务却修改了这些数据,导致第一个事务两次读取的数据不一致。

幻读(Phantom reads):
幻读指的是在一个事务中执行相同的查询,由于其他事务插入了符合查询条件的新行,导致每次查询返回的行数不同。简单来说,事务A读取了一个范围的数据,事务B在这个范围内插入了新的数据,当事务A再次读取这个范围的数据时,就会发现有之前未见的“幻”行。

区别:

  • 不可重复读的重点是“修改”:即同一数据项的内容被外部事务修改导致数据不一致。
  • 幻读的重点是“新增或删除”:即新的数据行的插入或删除导致一个事务多次执行同一查询时返回的结果集不同。

mvcc怎么实现的

MVCC, Multi-Version Concurrency Control 是多版本并发控制,实现数据库隔离级别的一种技术点,分为两个点,读视图(Read View)及 版本控制。

读视图是针对当前事务有个快照,定义了事务可以看到哪些数据版本。这保证了在整个事务中,读取的数据是一致的,即使其他事务在此期间提交了新的更改。

版本并发控制 是指数据行中有个隐藏的两个字段(创建版本号、删除版本号)、每次事务开启有个事务版本号。根据比较版本号来区分隔离性(事务修改表中的一行数据时,InnoDB会在Undo日志中记录该行的旧版本。如果一个事务需要看到早期版本的数据,InnoDB会使用Undo日志来构建这个旧版本。)

指针和引⽤的区别

  • 指针和引用是编程中用于间接访问变量的两种机制,它们在使用上有着本质的区别。主要体现在C++这类支持指针和引用概念的语言中
  • 指针是一个变量,存储的是内存地址,可以被重新指向另一个对象或者被赋值为nullptr/NULL。指针支持算术运算。
  • 引用是一个已存在变量的别名,必须在定义时初始化,且之后不能再改变引用的关联(指向)。引用更安全,使用起来更接近于普通变量,但功能上没有指针灵活。

引用的引入是为了提供一种比指针更安全、使用更简便的方式来间接访问变量。在某些情况下,如函数参数传递时,使用引用可以避免指针可能引入的错误和复杂性

RAII机制了解过吗

RAII(Resource Acquisition Is Initialization)机制是一种在C++中常用的管理资源(如动态内存、文件描述符、锁等)的方法。RAII的基本思想是利用局部对象的生命周期来管理资源的获取和释放,确保在任何情况下资源都能被正确地管理,核心在与资源的释放和获取,智能指针(如std::unique_ptrstd::shared_ptr)就是典型应用

1
2
3
4
5
6
#include <memory>

void someFunction() {
std::unique_ptr<int> ptr(new int(10)); // 资源获取
// 使用ptr
} // ptr出作用域,资源释放

__END__