4、【对线面试官】Java反射 & 动态代理.md

今天要不来聊聊Java反射?你对Java反射了解多少?

  1. 嗯,Java反射在JavaSE基础中还是很重要的。
  2. 简单来说,反射就是Java可以给我们在运行时获取类的信息
    在初学的时候可能看不懂、又或是学不太会反射,因为初学的时候往往给的例子都是用反射创建对象,用反射去获取对象上的方法/属性什么的,感觉没多大用
  3. 但毕竟我已经不是以前的我了,跟以前的看法就不一样了。
  4. 理解反射重点就在于理解什么是「运行时」,为什么我们要在「运行时」获取类的信息
  5. 在当时学注解的时候,我们可以发现注解的生命周期有三个枚举值(当时我还告诉过面试官你呢~)
  6. 分别是SOURCE、CLASS和RUNTIME,其实一样的,RUNTIME就是对标着运行时
  7. 我们都知道:我们在编译器写的代码是j ava文件,经过javac编译会变成.class文件,class文件会被JVM装载运行(这里就是真正运行着我们所写的代码(虽然是被编译过的),也就所谓的运行时。

嗯,你说了那么多,就讲述了什么是运行时,还是快点进入重点吧

  1. 在运行时获取类的信息,其实就是为了让我们所写的代码更具有「通用性」和「灵活性」
  2. 要理解反射,需要抛开我们日常写的业务代码。以更高的维度或者说是抽象的思维去看待我们所写的“工具”
  3. 所谓的“工具”:在单个系统使用叫做“Utils”、被多个系统使用打成jar包叫做“组件”、组件继续发展壮大就叫做“框架”
  4. 一个好用的“工具”是需要兼容各种情况的。
  5. 你肯定是不知道用该“工具“的用户传入的是什么对象,但你需要帮他们得到需要的结果。
  6. 例如SpringMVC你在方法上写上对象,传入的参数就会帮你封装到对象上
  7. Mybatis可以让我们只写接口,不写实现类,就可以执行SQL
  8. 你在类上加上@Component注解,Sprin g就帮你创建对象
  9. 这些统统都有反射的身影:约定大于配置,配置大于硬编码。
  10. 通过”约定“使用姿势,使用反射在运行时获取相应的信息(毕竟作为一个”工具“是真的不知道你是怎么用的),实现代码功能的「通用性」和「灵活性」

结合之前说的泛型,想问下:你应该知道泛型是会擦除的,那为什么反射能获取到泛型的信息呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 抽象类,定义泛型<T>
public abstract class BaseDao<T> {
public BaseDao(){
Class clazz = this.getClass();
ParameterizedType pt = (ParameterizedType) clazz.getGenericSuperclass();
clazz = (Class) pt.getActualTypeArguments()[0];
System.out.println(clazz);
}
}

// 实现类
public class UserDao extends BaseDao<User> {
public static void main(String[] args) {
BaseDao<User> userDao = new UserDao();

}
}
// 执行结果输出
class com.entity.User
  1. 嗯,这个问题我在学习的时候也想过
  2. 其实是这样的,可以理解为泛型擦除是有范围的,定义在类上的泛型信息是不会被擦除的。
  3. Java编译器仍在class文件以Signature 属性的方式保留了泛型信息
  4. Type作为顶级接口,Type下还有几种类型,比如TypeVariable、 ParameterizedT ype、 WildCardType、 GenericArrayType、以及Class。通过这些接口我们就可以在运行时获取泛型相关的信息。

你了解动态代理吗?

  1. 嗯,了解的。动态代理其实就是代理模式的一种,代理模式是设计模式之一。
  2. 代理模型有静态代理和动态代理。静态代理需要自己写代理类,实现对应的接口,比较麻烦。
  3. 在Java中,动态代理常见的又有两种实现方式:JDK动态代理和CGLIB代理
  4. JDK动态代理其实就是运用了反射的机制,而CGLIB代理则用的是利用ASM框架,通过修改其字节码生成子类来处理。
  5. JDK动态代理会帮我们实现接口的方法,通过invokeHandler对所需要的方法进行增强。
  6. 动态代理这一技术在实际或者框架原理中是非常常见的
  7. 像上面所讲的Mybatis不用写实现类,只写接口就可以执行SQL,又或是SpringAOP等等好用的技术,实际上用的就是动态代理。
l

一、基础篇

网络基础

TCP三次握手

三次握手过程:

​ 客户端——发送带有SYN标志的数据包——服务端 一次握手 Client进入syn_sent状态

​ 服务端——发送带有SYN/ACK标志的数据包——客户端 二次握手 服务端进入syn_rcvd

​ 客户端——发送带有ACK标志的数据包——服务端 三次握手 连接就进入Established状态

为什么三次:

​ 主要是为了建立可靠的通信信道,保证客户端与服务端同时具备发送、接收数据的能力

为什么两次不行?

​ 1、防止已失效的请求报文又传送到了服务端,建立了多余的链接,浪费资源

​ 2、 两次握手只能保证单向连接是畅通的。(为了实现可靠数据传输, TCP 协议的通信双方, 都必须维 护一个序列号, 以标识发送出去的数据包中, 哪些是已经被对方收到的。 三次握手的过程即是通信双方 相互告知序列号起始值, 并确认对方已经收到了序列号起始值的必经步骤;如果只是两次握手, 至多只有连接发起方的起始序列号能被确认, 另一方选择的序列号则得不到确认)

**TCP四次挥手过程 **

四次挥手过程:

​ 客户端——发送带有FIN标志的数据包——服务端,关闭与服务端的连接 ,客户端进入FIN-WAIT-1状态

​ 服务端收到这个 FIN,它发回⼀ 个 ACK,确认序号为收到的序号加1,服务端就进入了CLOSE-WAIT状态

​ 服务端——发送⼀个FIN数据包——客户端,关闭与客户端的连接,客户端就进入FIN-WAIT-2状态

​ 客户端收到这个 FIN,发回 ACK 报⽂确认,并将确认序号设置为收到序号加1,TIME-WAIT状态

为什么四次:

​ 因为需要确保客户端与服务端的数据能够完成传输。

CLOSE-WAIT:

​ 这种状态的含义其实是表示在等待关闭

TIME-WAIT:

​ 为了解决网络的丢包和网络不稳定所带来的其他问题,确保连接方能在时间范围内,关闭自己的连接

如何查看TIME-WAIT状态的链接数量?

​ netstat -an |grep TIME_WAIT|wc -l 查看连接数等待time_wait状态连接数

为什么会TIME-WAIT过多?解决方法是怎样的?

可能原因: 高并发短连接的TCP服务器上,当服务器处理完请求后立刻按照主动正常关闭连接

解决:负载均衡服务器;Web服务器首先关闭来自负载均衡服务器的连接

1、OSI与TCP/IP 模型

​ OSI七层:物理层、数据链路层、网络层、传输层、会话层、表示层、应用层

​ TCP/IP五层:物理层、数据链路层、网络层、传输层、应用层

2、常见网络服务分层

​ 应用层:HTTP、SMTP、DNS、FTP

​ 传输层:TCP 、UDP

​ 网络层:ICMP 、IP、路由器、防火墙

​ 数据链路层:网卡、网桥、交换机

​ 物理层:中继器、集线器

3、TCP与UDP区别及场景

类型 特点 性能 应用过场景 首部字节
TCP 面向连接、可靠、字节流 传输效率慢、所需资源多 文件、邮件传输 20-60
UDP 无连接、不可靠、数据报文段 传输效率快、所需资源少 语音、视频、直播 8个字节

基于TCP的协议:HTTP、FTP、SMTP

基于UDP的协议:RIP、DNS、SNMP

4、TCP滑动窗口,拥塞控制

TCP通过:应用数据分割、对数据包进行编号、校验和、流量控制、拥塞控制、超时重传等措施保证数据的可靠传输;

拥塞控制目的:为了防止过多的数据注入到网络中,避免网络中的路由器、链路过载

拥塞控制过程:TCP维护一个拥塞窗口,该窗口随着网络拥塞程度动态变化,通过慢开始、拥塞避免等算法减少网络拥塞的发生。

5、TCP粘包原因和解决方法

TCP粘包是指:发送方发送的若干包数据到接收方接收时粘成一包

发送方原因:

​ TCP默认使用Nagle算法(主要作用:减少网络中报文段的数量):

​ 收集多个小分组,在一个确认到来时一起发送、导致发送方可能会出现粘包问题

接收方原因:

​ TCP将接收到的数据包保存在接收缓存里,如果TCP接收数据包到缓存的速度大于应用程序从缓存中读取数据包的速度,多个包就会被缓存,应用程序就有可能读取到多个首尾相接粘到一起的包。

解决粘包问题:

​ 最本质原因在与接收对等方无法分辨消息与消息之间的边界在哪,通过使用某种方案给出边界,例如:

  • 发送定长包。每个消息的大小都是一样的,接收方只要累计接收数据,直到数据等于一个定长的数值就将它作为一个消息。

  • 包尾加上\r\n标记。FTP协议正是这么做的。但问题在于如果数据正文中也含有\r\n,则会误判为消息的边界。

  • 包头加上包体长度。包头是定长的4个字节,说明了包体的长度。接收对等方先接收包体长度,依据包体长度来接收包体。

6、TCP、UDP报文格式

TCP报文格式:

源端口号和目的端口号

​ 用于寻找发端和收端应用进程。这两个值加上ip首部源端ip地址和目的端ip地址唯一确定一个tcp连接。

序号字段:

​ 序号用来标识从T C P发端向T C P收端发送的数据字节流,它表示在这个报文段中的的第一个数据字节。如果将字节流看作在两个应用程序间的单向流动,则 T C P用序号对每个字节进行计数。序号是32 bit的无符号数,序号到达 2^32-1后又从0开始。

  当建立一个新的连接时,SYN标志变1。序号字段包含由这个主机选择的该连接的初始序号ISN(Initial Sequence Number)。该主机要发送数据的第一个字节序号为这个ISN加1,因为SYN标志消耗了一个序号

确认序号

​ 既然每个传输的字节都被计数,确认序号包含发送确认的一端所期望收到的下一个序号。因此,确认序号应当是上次已成功收到数据字节序号加 1。只有ACK标志为 1时确认序号字段才有效。发送ACK无需任何代价,因为 32 bit的确认序号字段和A C K标志一样,总是T C P首部的一部分。因此,我们看到一旦一个连接建立起来,这个字段总是被设置, ACK标志也总是被设置为1。TCP为应用层提供全双工服务。这意味数据能在两个方向上独立地进行传输。因此,连接的每一端必须保持每个方向上的传输数据序号。

首都长度

​ 首部长度给出首部中 32 bit字的数目。需要这个值是因为任选字段的长度是可变的。这个字段占4 bit,因此T C P最多有6 0字节的首部。然而,没有任选字段,正常的长度是 2 0字节。

标志字段:在T C P首部中有 6个标志比特。它们中的多个可同时被设置为1.
  URG紧急指针(u rgent pointer)有效
  ACK确认序号有效。
  PSH接收方应该尽快将这个报文段交给应用层。
  RST重建连接。
  SYN同步序号用来发起一个连接。这个标志和下一个标志将在第 1 8章介绍。
  FIN发端完成发送任务。

窗口大小

​ T C P的流量控制由连接的每一端通过声明的窗口大小来提供。窗口大小为字节数,起始于确认序号字段指明的值,这个值是接收端期望接收的字节。窗口大小是一个 16 bit字段,因而窗口大小最大为 65535字节。

检验和:

​ 检验和覆盖了整个的 T C P报文段:T C P首部和T C P数据。这是一个强制性的字段,一定是由发端计算和存储,并由收端进行验证。

紧急指针

​ 只有当URG标志置1时紧急指针才有效。紧急指针是一个正的偏移量,和序号字段中的值相加表示紧急数据最后一个字节的序号。 T C P的紧急方式是发送端向另一端发送紧急数据的一种方式。

选项

​ 最常见的可选字段是最长报文大小,又称为 MSS (Maximum Segment Size)。每个连接方通常都在通信的第一个报文段(为建立连接而设置 S Y N标志的那个段)中指明这个选项。它指明本端所能接收的最大长度的报文段。

UDP报文格式:

端口号

​ 用来表示发送和接受进程。由于 I P层已经把I P数据报分配给T C P或U D P(根据I P首部中协议字段值),因此T C P端口号由T C P来查看,而 U D P端口号由UDP来查看。T C P端口号与UDP端口号是相互独立的。

长度

​ UDP长度字段指的是UDP首部和UDP数据的字节长度。该字段的最小值为 8字节(发送一份0字节的UDP数据报是 O K)。

检验和

​ UDP检验和是一个端到端的检验和。它由发送端计算,然后由接收端验证。其目的是为了发现UDP首部和数据在发送端到接收端之间发生的任何改动。

IP报文格式:普通的IP首部长为20个字节,除非含有可选项字段。

4位版本

​ 目前协议版本号是4,因此IP有时也称作IPV4.

4位首部长度

​ 首部长度指的是首部占32bit字的数目,包括任何选项。由于它是一个4比特字段,因此首部长度最长为60个字节。

服务类型(TOS)

​ 服务类型字段包括一个3bit的优先权字段(现在已经被忽略),4bit的TOS子字段和1bit未用位必须置0。4bit的TOS分别代表:最小时延,最大吞吐量,最高可靠性和最小费用。4bit中只能置其中1比特。如果所有4bit均为0,那么就意味着是一般服务。

总长度

​ 总长度字段是指整个IP数据报的长度,以字节为单位。利用首部长度和总长度字段,就可以知道IP数据报中数据内容的起始位置和长度。由于该字段长16bit,所以IP数据报最长可达65535字节。当数据报被分片时,该字段的值也随着变化。

标识字段

​ 标识字段唯一地标识主机发送的每一份数据报。通常每发送一份报文它的值就会加1。

生存时间

​ TTL(time-to-live)生存时间字段设置了数据报可以经过的最多路由器数。它指定了数据报的生存时间。TTL的初始值由源主机设置(通常为 3 2或6 4),一旦经过一个处理它的路由器,它的值就减去 1。当该字段的值为 0时,数据报就被丢弃,并发送 ICMP 报文通知源主机。

首部检验和

​ 首部检验和字段是根据 I P首部计算的检验和码。它不对首部后面的数据进行计算。 ICMP、IGMP、UDP和TCP在它们各自的首部中均含有同时覆盖首部和数据检验和码。

以太网报文格式:

目的地址和源地址:

​ 是指网卡的硬件地址(也叫MAC 地址),长度是48 位,是在网卡出厂时固化的。

数据:

​ 以太网帧中的数据长度规定最小46 字节,最大1500 字节,ARP 和RARP 数据包的长度不够46 字节,要在后面补填充位。最大值1500 称为以太网的最大传输单元(MTU),不同的网络类型有不同的MTU,如果一个数据包从以太网路由到拨号链路上,数据包度大于拨号链路的MTU了,则需要对数据包进行分片fragmentation)。ifconfig 命令的输出中也有“MTU:1500”。注意,MTU 个概念指数据帧中有效载荷的最大长度,不包括帧首部的长度。

HTTP协议

1、HTTP协议1.0_1.1_2.0

HTTP1.0:服务器处理完成后立即断开TCP连接(无连接),服务器不跟踪每个客户端也不记录过去的请求(无状态

HTTP1.1:KeepAlived长连接避免了连接建立和释放的开销;通过Content-Length来判断当前请求数据是否已经全部接受(有状态

HTTP2.0:引入二进制数据帧和流的概念,其中帧对数据进行顺序标识;因为有了序列,服务器可以并行的传输数据。

http1.0和http1.1的主要区别如下:
​ 1、缓存处理:1.1添加更多的缓存控制策略(如:Entity tag,If-Match)
​ 2、网络连接的优化:1.1支持断点续传
​ 3、错误状态码的增多:1.1新增了24个错误状态响应码,丰富的错误码更加明确各个状态
​ 4、Host头处理:支持Host头域,不在以IP为请求方标志
​ 5、长连接:减少了建立和关闭连接的消耗和延迟。

http1.1和http2.0的主要区别:
​ 1、新的传输格式:2.0使用二进制格式,1.0依然使用基于文本格式
​ 2、多路复用:连接共享,不同的request可以使用同一个连接传输(最后根据每个request上的id号组合成正常的请求)
​ 3、header压缩:由于1.X中header带有大量的信息,并且得重复传输,2.0使用encoder来减少需要传输的hearder大小
​ 4、服务端推送:同google的SPDUY(1.0的一种升级)一样

2、HTTP与HTTPS之间的区别

HTTP与HTTPS之间的区别:

HTTP HTTPS
默认端口80 HTTPS默认使用端口443
明文传输、数据未加密、安全性差 传输过程ssl加密、安全性较好
响应速度快、消耗资源少 响应速度较慢、消耗资源多、需要用到CA证书

HTTPS链接建立的过程:

​ 1.首先客户端先给服务器发送一个请求

​ 2.服务器发送一个SSL证书给客户端,内容包括:证书的发布机构、有效期、所有者、签名以及公钥

​ 3.客户端对发来的公钥进行真伪校验,校验为真则使用公钥对对称加密算法以及对称密钥进行加密

​ 4.服务器端使用私钥进行解密并使用对称密钥加密确认信息发送给客户端

​ 5.随后客户端和服务端就使用对称密钥进行信息传输

对称加密算法:

​ 双方持有相同的密钥,且加密速度快,典型对称加密算法:DES、AES

非对称加密算法:

​ 密钥成对出现(私钥、公钥),私钥只有自己知道,不在网络中传输;而公钥可以公开。相比对称加密速度较慢,典型的非对称加密算法有:RSA、DSA

3、Get和Post请求区别

HTTP请求:

方法 描述
GET 向特定资源发送请求,查询数据,并返回实体
POST 向指定资源提交数据进行处理请求,可能会导致新的资源建立、已有资源修改
PUT 向服务器上传新的内容
HEAD 类似GET请求,返回的响应中没有具体的内容,用于获取报头
DELETE 请求服务器删除指定标识的资源
OPTIONS 可以用来向服务器发送请求来测试服务器的功能性
TRACE 回显服务器收到的请求,用于测试或诊断
CONNECT HTTP/1.1协议中预留给能够将连接改为管道方式的代理服务器

get和Post区别:

GET POST
可见性 数据在URL中对所有人可见 数据不会显示在URL中
安全性 与post相比,get的安全性较差,因为所
发送的数据是URL的一部分
安全,因为参数不会被保存在浏览器
历史或web服务器日志中
数据长度 受限制,最长2kb 无限制
编码类型 application/x-www-form-urlencoded multipart/form-data
缓存 能被缓存 不能被缓存

4、HTTP常见响应状态码

​ 100:Continue — 继续。客户端应继续其请求。

​ 200:OK — 请求成功。一般用于GET与POST请求。

​ 301:Moved Permanently — 永久重定向。

​ 302:Found — 暂时重定向。

​ 400:Bad Request — 客户端请求的语法错误,服务器无法理解。

​ 403:Forbideen — 服务器理解请求客户端的请求,但是拒绝执行此请求。

​ 404:Not Found — 服务器无法根据客户端的请求找到资源(网页)。

​ 500:Internal Server Error — 服务器内部错误,无法完成请求。

​ 502:Bad Gateway — 作为网关或者代理服务器尝试执行请求时,从远程服务器接收到了无效的响应。

5、重定向和转发区别

重定向:redirect:

​ 地址栏发生变化

​ 重定向可以访问其他站点(服务器)的资源

​ 重定向是两次请求。不能使用request对象来共享数据

转发:forward:

​ 转发地址栏路径不变

​ 转发只能访问当前服务器下的资源

​ 转发是一次请求,可以使用request对象共享数据

6、Cookie和Session区别。

​ Cookie 和 Session都是用来跟踪浏览器用户身份的会话方式,但两者有所区别:

​ Cookie 数据保存在客户端(浏览器端),Session 数据保存在服务器端。

​ cookie不是很安全,别人可以分析存放在本地的COOKIE并进行欺骗,考虑到安全应当使用session。

​ Cookie ⼀般⽤来保存⽤户信息,Session 的主要作⽤就是通过服务端记录⽤户的状态

浏览器输入URL过程

过程:DNS解析、TCP连接、发送HTTP请求、服务器处理请求并返回HTTP报文、浏览器渲染、结束

过程 使用的协议
1、浏览器查找域名DNS的IP地址
DNS查找过程(浏览器缓存、路由器缓存、DNS缓存)
DNS:获取域名对应的ip
2、根据ip建立TCP连接 TCP:与服务器建立连接
3、浏览器向服务器发送HTTP请求 HTTP:发送请求
4、服务器响应HTTP响应 HTTP
5、浏览器进行渲染

操作系统基础

进程和线程的区别

进程:是资源分配的最小单位,一个进程可以有多个线程,多个线程共享进程的堆和方法区资源,不共享栈、程序计数器

线程:是任务调度和执行的最小单位,线程并行执行存在资源竞争和上下文切换的问题

协程:是一种比线程更加轻量级的存在,正如一个进程可以拥有多个线程一样,一个线程可以拥有多个协程。

1、进程间通信方式IPC

管道pipe:

​ 亲缘关系使用匿名管道,非亲缘关系使用命名管道,管道遵循FIFO,半双工,数据只能单向通信;

信号:

​ 信号是一种比较复杂的通信方式,用户调用kill命令将信号发送给其他进程。

消息队列:

​ 消息队列克服了信号传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等特点。

共享内存(share memory):

  • 使得多个进程可以可以直接读写同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。
  • 由于多个进程共享一段内存,因此需要依靠某种同步机制(如信号量)来达到进程间的同步及互斥。

信号量(Semaphores) :

​ 信号量是⼀个计数器,⽤于多进程对共享数据的访问,这种通信⽅式主要⽤于解决与同步相关的问题并避免竞争条件。

套接字(Sockets) :

​ 简单的说就是通信的两⽅的⼀种约定,⽤套接字中的相关函数来完成通信过程。

2、用户态和核心态

用户态:只能受限的访问内存,运行所有的应用程序

核心态:运行操作系统程序,cpu可以访问内存的所有数据,包括外围设备

为什么要有用户态和内核态:

​ 由于需要限制不同的程序之间的访问能力, 防止他们获取别的程序的内存数据, 或者获取外围设备的数据, 并发送到网络

用户态切换到内核态的3种方式:

a. 系统调用

​ 主动调用,系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。

b. 异常

​ 当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,比如缺页异常,这时会触发切换内核态处理异常。

c. 外围设备的中断

​ 当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会由用户态到内核态的切换。

3、操作系统的进程空间

​ 栈区(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。

​ 堆区(heap)— 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。

​ 静态区(static)—存放全局变量和静态变量的存储

​ 代码区(text)—存放函数体的二进制代码。

线程共享堆区、静态区

操作系统内存管理

存管理方式:页式管理、段式管理、段页式管理

分段管理:

​ 将程序的地址空间划分为若干段(segment),如代码段,数据段,堆栈段;这样每个进程有一个二维地址空间,相互独立,互不干扰。段式管理的优点是:没有内碎片(因为段大小可变,改变段大小来消除内碎片)。但段换入换出时,会产生外碎片(比如4k的段换5k的段,会产生1k的外碎片)

分页管理:

​ 在页式存储管理中,将程序的逻辑地址划分为固定大小的页(page),而物理内存划分为同样大小的页框,程序加载时,可以将任意一页放入内存中任意一个页框,这些页框不必连续,从而实现了离散分离。页式存储管理的优点是:没有外碎片(因为页的大小固定),但会产生内碎片(一个页可能填充不满)

段页式管理:

​ 段⻚式管理机制结合了段式管理和⻚式管理的优点。简单来说段⻚式管理机制就是把主存先分成若⼲段,每个段⼜分成若⼲⻚,也就是说 段⻚式管理机制 中段与段之间以及段的内部的都是离散的

1、页面置换算法FIFO、LRU

置换算法:先进先出FIFO、最近最久未使用LRU、最佳置换算法OPT

先进先出FIFO:

​ 缺点:没有考虑到实际的页面使用频率,性能差、与通常页面使用的规则不符合,实际应用较少

最近最久未使用LRU:

​ 原理:选择最近且最久未使用的页面进行淘汰

​ 优点:考虑到了程序访问的时间局部性,有较好的性能,实际应用也比较多

​ 缺点:没有合适的算法,只有适合的算法,lFU、random都可以

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
/**
* @program: Java
* @description: LRU最近最久未使用置换算法,通过LinkedHashMap实现
* @author: Mr.Li
* @create: 2020-07-17 10:29
**/
public class LRUCache {
private LinkedHashMap<Integer,Integer> cache;
private int capacity; //容量大小

/**
*初始化构造函数
* @param capacity
*/
public LRUCache(int capacity) {
cache = new LinkedHashMap<>(capacity);
this.capacity = capacity;
}

public int get(int key) {
//缓存中不存在此key,直接返回
if(!cache.containsKey(key)) {
return -1;
}

int res = cache.get(key);
cache.remove(key); //先从链表中删除
cache.put(key,res); //再把该节点放到链表末尾处
return res;
}

public void put(int key,int value) {
if(cache.containsKey(key)) {
cache.remove(key); //已经存在,在当前链表移除
}
if(capacity == cache.size()) {
//cache已满,删除链表头位置
Set<Integer> keySet = cache.keySet();
Iterator<Integer> iterator = keySet.iterator();
cache.remove(iterator.next());
}
cache.put(key,value); //插入到链表末尾
}
}

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
/**
* @program: Java
* @description: LRU最近最久未使用置换算法,通过LinkedHashMap内部removeEldestEntry方法实现
* @author: Mr.Li
* @create: 2020-07-17 10:59
**/
class LRUCache {
private Map<Integer, Integer> map;
private int capacity;

/**
*初始化构造函数
* @param capacity
*/
public LRUCache(int capacity) {
this.capacity = capacity;
map = new LinkedHashMap<Integer, Integer>(capacity, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > capacity; // 容量大于capacity 时就删除
}
};
}
public int get(int key) {
//返回key对应的value值,若不存在,返回-1
return map.getOrDefault(key, -1);
}

public void put(int key, int value) {
map.put(key, value);
}
}

最佳置换算法OPT:

​ 原理:每次选择当前物理块中的页面在未来长时间不被访问的或未来不再使用的页面进行淘汰

​ 优点:具有较好的性能,可以保证获得最低的缺页率

​ 缺点:过于理想化,但是实际上无法实现(没办法预知未来的页面)

2、死锁条件、解决方式。

​ 死锁是指两个或两个以上进程在执行过程中,因争夺资源而造成的相互等待的现象;

死锁的条件:

​ 互斥条件:进程对所分配到的资源不允许其他进程访问,若其他进程访问该资源,只能等待至占有该资源的进程释放该资源;

​ 请求与保持条件:进程获得一定的资源后,又对其他资源发出请求,阻塞过程中不会释放自己已经占有的资源

​ 非剥夺条件:进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用后自己释放

​ 循环等待条件:系统中若干进程组成环路,环路中每个进程都在等待相邻进程占用的资源

解决方法:破坏死锁的任意一条件

​ 乐观锁,破坏资源互斥条件,CAS

​ 资源一次性分配,从而剥夺请求和保持条件、tryLock

​ 可剥夺资源:即当进程新的资源未得到满足时,释放已占有的资源,从而破坏不可剥夺的条件,数据库deadlock超时

​ 资源有序分配法:系统给每类资源赋予一个序号,每个进程按编号递增的请求资源,从而破坏环路等待的条件,转账场景

Java基础

面向对象三大特性

特性:封装、继承、多态

封装:对抽象的事物抽象化成一个对象,并对其对象的属性私有化,同时提供一些能被外界访问属性的方法;

继承:子类扩展新的数据域或功能,并复用父类的属性与功能,单继承,多实现;

多态:通过继承(多个⼦类对同⼀⽅法的重写)、也可以通过接⼝(实现接⼝并覆盖接⼝)

1、Java与C++区别

​ 不同点:c++支持多继承,并且有指针的概念,由程序员自己管理内存;Java是单继承,可以用接口实现多继承,Java 不提供指针来直接访问内存,程序内存更加安全,并且Java有JVM⾃动内存管理机制,不需要程序员⼿动释放⽆⽤内存

2、多态实现原理

多态的底层实现是动态绑定,即在运行时才把方法调用与方法实现关联起来。

静态绑定与动态绑定:

​ 一种是在编译期确定,被称为静态分派,比如方法的重载;

​ 一种是在运行时确定,被称为动态分派,比如方法的覆盖(重写)和接口的实现。

多态的实现

​ 虚拟机栈中会存放当前方法调用的栈帧(局部变量表、操作栈、动态连接 、返回地址)。多态的实现过程,就是方法调用动态分派的过程,如果子类覆盖了父类的方法,则在多态调用中,动态绑定过程会首先确定实际类型是子类,从而先搜索到子类中的方法。这个过程便是方法覆盖的本质。

3、static和final关键字

static:可以修饰属性、方法

static修饰属性:

​ 类级别属性,所有对象共享一份,随着类的加载而加载(只加载一次),先于对象的创建;可以使用类名直接调用。

static修饰方法:

​ 随着类的加载而加载;可以使用类名直接调用;静态方法中,只能调用静态的成员,不可用this;

final:关键字主要⽤在三个地⽅:变量、⽅法、类。

final修饰变量:

​ 如果是基本数据类型的变量,则其数值⼀旦在初始化之后便不能更改;

​ 如果是引⽤类型的变量,则在对其初始化之后便不能再让其指向另⼀个对象。

final修饰方法:

​ 把⽅法锁定,以防任何继承类修改它的含义(重写);类中所有的 private ⽅法都隐式地指定为 final。

final修饰类:

​ final 修饰类时,表明这个类不能被继承。final 类中的所有成员⽅法都会被隐式地指定为 final ⽅法。

一个类不能被继承,除了final关键字之外,还有可以私有化构造器。(内部类无效)

4、抽象类和接口

抽象类:包含抽象方法的类,即使用abstract修饰的类;抽象类只能被继承,所以不能使用final修饰,抽象类不能被实例化,

接口:接口是一个抽象类型,是抽象方法的集合,接口支持多继承,接口中定义的方法,默认是public abstract修饰的抽象方法

相同点:

​ ① 抽象类和接口都不能被实例化

​ ② 抽象类和接口都可以定义抽象方法,子类/实现类必须覆写这些抽象方法

不同点:

​ ① 抽象类有构造方法,接口没有构造方法

​ ③抽象类可以包含普通方法,接口中只能是public abstract修饰抽象方法(Java8之后可以)

​ ③ 抽象类只能单继承,接口可以多继承

​ ④ 抽象类可以定义各种类型的成员变量,接口中只能是public static final修饰的静态常量

抽象类的使用场景:

​ 既想约束子类具有共同的行为(但不再乎其如何实现),又想拥有缺省的方法,又能拥有实例变量

接口的应用场景:

​ 约束多个实现类具有统一的行为,但是不在乎每个实现类如何具体实现;实现类中各个功能之间可能没有任何联系

5、泛型以及泛型擦除

参考:https://blog.csdn.net/baoyinwang/article/details/107341997

泛型:

​ 泛型的本质是参数化类型。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。

泛型擦除:

​ Java的泛型是伪泛型,使用泛型的时候加上类型参数,在编译器编译生成的字节码的时候会去掉,这个过程成为类型擦除。

​ 如List等类型,在编译之后都会变成 List。JVM 看到的只是 List,而由泛型附加的类型信息对 JVM 来说是不可见的。

可以通过反射添加其它类型元素

6、反射原理以及使用场景

Java反射:

​ 是指在运行状态中,对于任意一个类都能够知道这个类所有的属性和方法;并且都能够调用它的任意一个方法;

反射原理:

​ 反射首先是能够获取到Java中的反射类的字节码,然后将字节码中的方法,变量,构造函数等映射成 相应的 Method、Filed、Constructor 等类

如何得到Class的实例:

     1.类名.class(就是一份字节码)
     2.Class.forName(String className);根据一个类的全限定名来构建Class对象
     3.每一个对象多有getClass()方法:obj.getClass();返回对象的真实类型

使用场景:

  • 开发通用框架 - 反射最重要的用途就是开发各种通用框架。很多框架(比如 Spring)都是配置化的(比如通过 XML 文件配置 JavaBean、Filter 等),为了保证框架的通用性,需要根据配置文件运行时动态加载不同的对象或类,调用不同的方法。

  • 动态代理 - 在切面编程(AOP)中,需要拦截特定的方法,通常,会选择动态代理方式。这时,就需要反射技术来实现了。

    JDK:spring默认动态代理,需要实现接口

    CGLIB:通过asm框架序列化字节流,可配置,性能差

  • 自定义注解 - 注解本身仅仅是起到标记作用,它需要利用反射机制,根据注解标记去调用注解解释器,执行行为。

7、Java异常体系

Throwable 是 Java 语言中所有错误或异常的超类。下一层分为 Error 和 Exception

Error :

​ 是指 java 运行时系统的内部错误和资源耗尽错误。应用程序不会抛出该类对象。如果出现了这样的错误,除了告知用户,剩下的就是尽力使程序安全的终止。

Exception 包含:RuntimeException 、CheckedException

编程错误可以分成三类:语法错误、逻辑错误和运行错误。

语法错误(也称编译错误)是在编译过程中出现的错误,由编译器检查发现语法错误

逻辑错误指程序的执行结果与预期不符,可以通过调试定位并发现错误的原因

运行错误是引起程序非正常终端的错误,需要通过异常处理的方式处理运行错误

RuntimeException: 运行时异常,程序应该从逻辑角度尽可能避免这类异常的发生。

​ 如 NullPointerException 、 ClassCastException ;

CheckedException:受检异常,程序使用trycatch进行捕捉处理

​ 如IOException、SQLException、NotFoundException;

数据结构

JavaCollection

1、ArrayList和LinkedList

ArrayList:

​ 底层基于数组实现,支持对元素进行快速随机访问,适合随机查找和遍历,不适合插入和删除。(提一句实际上)
​ 默认初始大小为10,当数组容量不够时,会触发扩容机制(扩大到当前的1.5倍),需要将原来数组的数据复制到新的数组中;当从 ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。

LinkedList:

​ 底层基于双向链表实现,适合数据的动态插入和删除;
​ 内部提供了 List 接口中没有定义的方法,用于操作表头和表尾元素,可以当作堆栈、队列和双向队列使用。(比如jdk官方推荐使用基于linkedList的Deque进行堆栈操作)

ArrayList与LinkedList区别:

​ 都是线程不安全的,ArrayList 适用于查找的场景,LinkedList 适用于增加、删除多的场景

实现线程安全:

​ 可以使用原生的Vector,或者是Collections.synchronizedList(List list)函数返回一个线程安全的ArrayList集合。
​ 建议使用concurrent并发包下的CopyOnWriteArrayList的。

​ ①Vector: 底层通过synchronize修饰保证线程安全,效率较差

​ ②CopyOnWriteArrayList:写时加锁,使用了一种叫写时复制的方法;读操作是可以不用加锁的

2、List遍历快速和安全失败

①普通for循环遍历List删除指定元素

1
2
3
4
for(int i=0; i < list.size(); i++){
if(list.get(i) == 5)
list.remove(i);
}

② 迭代遍历,用list.remove(i)方法删除元素

1
2
3
4
5
6
7
Iterator<Integer> it = list.iterator();
while(it.hasNext()){
Integer value = it.next();
if(value == 5){
list.remove(value);
}
}

③foreach遍历List删除元素

1
2
3
for(Integer i:list){
if(i==3) list.remove(i);
}

fail—fast:快速失败

​ 当异常产生时,直接抛出异常,程序终止;

​ fail-fast主要是体现在当我们在遍历集合元素的时候,经常会使用迭代器,但在迭代器遍历元素的过程中,如果集合的结构(modCount)被改变的话,就会抛出异常ConcurrentModificationException,防止继续遍历。这就是所谓的快速失败机制。

fail—safe:安全失败

    采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。由于在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发ConcurrentModificationException。

    缺点:基于拷贝内容的优点是避免了ConcurrentModificationException,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。

    场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。

3、详细介绍HashMap

角度:数据结构+扩容情况+put查找的详细过程+哈希函数+容量为什么始终都是2^N,JDK1.7与1.8的区别。

参考:https://www.jianshu.com/p/9fe4cb316c05

数据结构:

​ HashMap在底层数据结构上采用了数组+链表+红黑树,通过散列映射来存储键值对数据

扩容情况:

​ 默认的负载因子是0.75,如果数组中已经存储的元素个数大于数组长度的75%,将会引发扩容操作。

​ 【1】创建一个长度为原来数组长度两倍的新数组

​ 【2】1.7采用Entry的重新hash运算,1.8采用高于与运算。

put操作步骤:

img

​ 1、判断数组是否为空,为空进行初始化;

​ 2、不为空,则计算 key 的 hash 值,通过(n - 1) & hash计算应当存放在数组中的下标 index;

​ 3、查看 table[index] 是否存在数据,没有数据就构造一个Node节点存放在 table[index] 中;

​ 4、存在数据,说明发生了hash冲突(存在二个节点key的hash值一样), 继续判断key是否相等,相等,用新的value替换原数据;

​ 5、若不相等,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中;

​ 6、若不是红黑树,创建普通Node加入链表中;判断链表长度是否大于 8,大于则将链表转换为红黑树;

​ 7、插入完成之后判断当前节点数是否大于阈值,若大于,则扩容为原数组的二倍

哈希函数:

​ 通过hash函数(优质因子31循环累加)先拿到 key 的hashcode,是一个32位的值,然后让hashcode的高16位和低16位进行异或操作。该函数也称为扰动函数,做到尽可能降低hash碰撞,通过尾插法进行插入。

容量为什么始终都是2^N:

​ 先做对数组的⻓度取模运算,得到的余数才能⽤来要存放的位置也就是对应的数组下标。这个数组下标的计算⽅法是“ (n - 1) & hash ”。(n代表数组⻓度)。方便数组的扩容和增删改时的取模。

JDK1.7与1.8的区别:

JDK1.7 HashMap:

​ 底层是 数组和链表 结合在⼀起使⽤也就是链表散列。如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。扩容翻转时顺序不一致使用头插法会产生死循环,导致cpu100%

JDK1.8 HashMap:

​ 底层数据结构上采用了数组+链表+红黑树;当链表⻓度⼤于阈值(默认为 8-泊松分布),数组的⻓度大于 64时,链表将转化为红⿊树,以减少搜索时间。(解决了tomcat臭名昭著的url参数dos攻击问题)

**4、ConcurrentHashMap **

​ 可以通过ConcurrentHashMapHashtable来实现线程安全;Hashtable 是原始API类,通过synchronize同步修饰,效率低下;ConcurrentHashMap 通过分段锁实现,效率较比Hashtable要好;

ConcurrentHashMap的底层实现:

JDK1.7的 ConcurrentHashMap 底层采⽤ 分段的数组+链表 实现;采用 分段锁(Sagment) 对整个桶数组进⾏了分割分段(Segment默认16个),每⼀把锁只锁容器其中⼀部分数据,多线程访问容器⾥不同数据段的数据,就不会存在锁竞争,提⾼并发访问率。

JDK1.8的 ConcurrentHashMap 采⽤的数据结构跟HashMap1.8的结构⼀样,数组+链表/红⿊树;摒弃了Segment的概念,⽽是直接⽤ Node 数组+链表+红⿊树的数据结构来实现,通过并发控制 synchronized 和CAS来操作保证线程的安全。

5、序列化和反序列化

​ 序列化的意思就是将对象的状态转化成字节流,以后可以通过这些值再生成相同状态的对象。对象序列化是对象持久化的一种实现方法,它是将对象的属性和方法转化为一种序列化的形式用于存储和传输。反序列化就是根据这些保存的信息重建对象的过程。

序列化:将java对象转化为字节序列的过程。

反序列化:将字节序列转化为java对象的过程。

优点:

​ a、实现了数据的持久化,通过序列化可以把数据永久地保存到硬盘上(通常存放在文件里)Redis的RDB

​ b、利用序列化实现远程通信,即在网络上传送对象的字节序列。 Google的protoBuf

反序列化失败的场景:

​ 序列化ID:serialVersionUID不一致的时候,导致反序列化失败

6、String

String 使用数组存储内容,数组使用 final 修饰,因此 String 定义的字符串的值也是不可变的

StringBuffer 对方法加了同步锁,线程安全,效率略低于 StringBuilder

设计模式与原则

1、单例模式

​ 某个类只能生成一个实例,该实例全局访问,例如Spring容器里一级缓存里的单例池。

优点

唯一访问:如生成唯一序列化的场景、或者spring默认的bean类型。

提高性能:频繁实例化创建销毁或者耗时耗资源的场景,如连接池、线程池。

缺点

​ 不适合有状态且需变更的

实现方式

饿汉式:线程安全速度快

懒汉式:双重检测锁,第一次减少锁的开销、第二次防止重复、volatile防止重排序导致实例化未完成

为什么要 double-check?

我们先来看第二次的 check,这时你需要考虑这样一种情况,有两个线程同时调用 getInstance 方法,由于 singleton 是空的 ,因此两个线程都可以通过第一重的 if 判断;然后由于锁机制的存在,会有一个线程先进入同步语句,并进入第二重 if 判断 ,而另外的一个线程就会在外面等待。

不过,当第一个线程执行完 new Singleton() 语句后,就会退出 synchronized 保护的区域,这时如果没有第二重 if (singleton == null) 判断的话,那么第二个线程也会创建一个实例,此时就破坏了单例,这肯定是不行的。

而对于第一个 check 而言,如果去掉它,那么所有线程都会串行执行,效率低下,所以两个 check 都是需要保留的。

在双重检查锁模式中为什么需要使用 volatile 关键字?

在java内存模型中,volatile 关键字作用可以是保证可见性或者禁止指令重排。这里是因为 singleton = new Singleton() ,它并非是一个原子操作,事实上,在 JVM 中上述语句至少做了以下这 3 件事:

  • 第一步是给 singleton 分配内存空间;
  • 第二步开始调用 Singleton 的构造函数等,来初始化 singleton;
  • 第三步,将 singleton 对象指向分配的内存空间(执行完这步 singleton 就不是 null 了)。

这里需要留意一下 1-2-3 的顺序,因为存在指令重排序的优化,也就是说第 2 步和第 3 步的顺序是不能保证的,最终的执行顺序,可能是 1-2-3,也有可能是 1-3-2。

如果是 1-3-2,那么在第 3 步执行完以后,singleton 就不是 null 了,可是这时第 2 步并没有执行,singleton 对象未完成初始化,它的属性的值可能不是我们所预期的值。假设此时线程 2 进入 getInstance 方法,由于 singleton 已经不是 null 了,所以会通过第一重检查并直接返回,但其实这时的 singleton 并没有完成初始化,所以使用这个实例的时候会报错,详细流程如下图所示:

线程 1 首先执行新建实例的第一步,也就是分配单例对象的内存空间,由于线程 1 被重排序,所以执行了新建实例的第三步,也就是把 singleton 指向之前分配出来的内存地址,在这第三步执行之后,singleton 对象便不再是 null。

这时线程 2 进入 getInstance 方法,判断 singleton 对象不是 null,紧接着线程 2 就返回 singleton 对象并使用,由于没有初始化,所以报错了。最后,线程 1 “姗姗来迟”,才开始执行新建实例的第二步——初始化对象,可是这时的初始化已经晚了,因为前面已经报错了。

使用了 volatile 之后,相当于是表明了该字段的更新可能是在其他线程中发生的,因此应确保在读取另一个线程写入的值时,可以顺利执行接下来所需的操作。在 JDK 5 以及后续版本所使用的 JMM 中,在使用了 volatile 后,会一定程度禁止相关语句的重排序,从而避免了上述由于重排序所导致的读取到不完整对象的问题的发生。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton{
private Singleton(){}
private volatile static Singleton singleton;
public static Singleton getInstance(){
if(singleton == null){
synchronized(Singleton.class){
if(singleton == null){
singleton = new Singleton();
}
}
}
return singleton;
}
}

静态内部类:线程安全利用率高

1
2
3
4
5
6
7
8
9
public class Singleton{
private Singleton(){}
private static class SingletonHolder{
private final static Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance(){
return SingletonHolder.INSTANCE;
}
}

枚举:effictiveJAVA推荐,反射也无法破坏

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {
private Singleton(){}
public static enum SingletonEnum {
SINGLETON;
private Singleton instance = null;
private SingletonEnum(){
instance = new Singleton();
}
public Singleton getInstance(){
return instance;
}
}
}

2、工厂模式

​ 定义一个用于创建产品的接口,由子类决定生产何种产品。

优点:解耦:提供参数即可获取产品,通过配置文件可以不修改代码增加具体产品。

缺点:每增加一个产品就得新增一个产品类

3、抽象工厂模式

​ 提供一个接口,用于创建相关或者依赖对象的家族,并由此进行约束。

优点:可以在类的内部对产品族进行约束

缺点:假如产品族中需要增加一个新的产品,则几乎所有的工厂类都需要进行修改。

4、设计模式中工厂方法与抽象工厂之间的区别联系

首先来看看两者的定义区别:

  • 工厂模式 定义一个用于创建对象的接口,让子类决定实例化哪一个类
  • 抽象工厂模式 为创建一组相关或相互依赖的对象提供一个接口,而且无需指定他们的具体类

个人觉得这个区别在于产品,如果产品单一,最合适用工厂模式,但是如果有多个业务品种、业务分类时,通过抽象工厂模式产生需要的对象是一种非常好的解决方式。 再通俗深化理解下:工厂模式针对的是一个产品等级结构 ,抽象工厂模式针对的是面向多个产品等级结构的。

再来看看工厂方法模式与抽象工厂模式对比:

工厂方法模式 抽象工厂模式
针对的是单个产品等级结构 针对的是面向多个产品等级结构
一个抽象产品类 多个抽象产品类
可以派生出多个具体产品类 每个抽象产品类可以派生出多个具体产品类
一个抽象工厂类,可以派生出多个具体工厂类 一个抽象工厂类,可以派生出多个具体工厂类
每个具体工厂类只能创建一个具体产品类的实例 每个具体工厂类可以创建多个具体产品类的实例

面试题

构造方法

构造方法可以被重载,只有当类中没有显性声明任何构造方法时,才会有默认构造方法。

构造方法没有返回值,构造方法的作用是创建新对象。

初始化块

静态初始化块的优先级最高,会最先执行,在非静态初始化块之前执行。

静态初始化块会在类第一次被加载时最先执行,因此在 main 方法之前。

This

关键字 this 代表当前对象的引用。当前对象指的是调用类中的属性或方法的对象

关键字 this 不可以在静态方法中使用。静态方法不依赖于类的具体对象的引用

重写和重载的区别

重载指在同一个类中定义多个方法,这些方法名称相同,签名不同。

重写指在子类中的方法的名称和签名都和父类相同,使用override注解

Object类方法

toString 默认是个指针,一般需要重写

equals 比较对象是否相同,默认和==功能一致

hashCode 散列码,equals则hashCode相同,所以重写equals必须重写hashCode

**finalize ** 用于垃圾回收之前做的遗嘱,默认空,子类需重写

clone 深拷贝,类需实现cloneable的接口

getClass 反射获取对象元数据,包括类名、方法、

notify、wait 用于线程通知和唤醒

基本数据类型和包装类

image-20210309224910999

类型 缓存范围
Byte,Short,Integer,Long [-128, 127]
Character [0, 127]
Boolean [false, true]

二、JVM篇

JVM内存划分

1、JVM运行时数据区域

​ 堆、方法区(元空间)、虚拟机栈、本地方法栈、程序计数器

xxx

Heap(堆):

​ 对象的实例以及数组的内存都是要在堆上进行分配的,堆是线程共享的一块区域,用来存放对象实例,也是垃圾回收(GC)的主要区域;开启逃逸分析后,某些未逃逸的对象可以通过标量替换的方式在栈中分配

​ 堆细分:新生代、老年代,对于新生代又分为:Eden区Surviver1Surviver2区;

方法区:

​ 对于JVM的方法区也可以称之为永久区,它储存的是已经被java虚拟机加载的类信息、常量、静态变量;Jdk1.8以后取消了方法区这个概念,称之为元空间(MetaSpace);

​ 当应用中的 Java 类过多时,比如 Spring 等一些使用动态代理的框架生成了很多类,如果占用空间超出了我们的设定值,就会发生元空间溢出

虚拟机栈:

​ 虚拟机栈是线程私有的,他的生命周期和线程的生命周期是一致的。里面装的是一个一个的栈帧,每一个方法在执行的时候都会创建一个栈帧,栈帧中用来存放(局部变量表操作数栈动态链接返回地址);在Java虚拟机规范中,对此区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverflowError异常;如果虚拟机栈动态扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

  • 局部变量表:局部变量表是一组变量值存储空间,用来存放方法参数、方法内部定义的局部变量。底层是变量槽(variable slot)(注意:java分成员变量、局部变量)

  • 操作数栈:是用来记录一个方法在执行的过程中,字节码指令向操作数栈中进行入栈和出栈的过程。大小在编译的时候已经确定了,当一个方法刚开始执行的时候,操作数栈中是空发的,在方法执行的过程中会有各种字节码指令往操作数栈中入栈和出栈

  • 动态链接:因为字节码文件中有很多符号的引用,这些符号引用一部分会在类加载的解析阶段第一次使用的时候转化成直接引用,这种称为静态解析;另一部分会在运行期间转化为直接引用,称为动态链接

  • 返回地址(returnAddress):类型(指向了一条字节码指令的地址)

    JIT即时编译器(Just In Time Compiler),简称 JIT 编译器:

    为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,比如锁粗化等

本地方法栈:

​ 本地方法栈和虚拟机栈类似,不同的是虚拟机栈服务的是Java方法,而本地方法栈服务的是Native方法。在HotSpot虚拟机实现中是把本地方法栈和虚拟机栈合二为一的,同理它也会抛出StackOverflowErrorOOM异常。

PC程序计数器:

​ PC,指的是存放下一条指令的位置的一个指针。它是一块较小的内存空间,且是线程私有的。由于线程的切换,CPU在执行的过程中,需要记住原线程的下一条指令的位置,所以每一个线程都需要有自己的PC。

2、堆内存分配策略

img

  • 对象优先分配在Eden区,如果Eden区没有足够的空间进行分配时,虚拟机执行一次MinorGC。而那些无需回收的存活对象,将会进到 Survivor 的 From 区(From 区内存不足时,直接进入 Old 区)。

  • 大对象直接进入老年代(需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。

  • 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄(Age Count)计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,直到达到阀值(默认15次),对象进入老年区。

    动态对象年龄判定:程序从年龄最小的对象开始累加,如果累加的对象大小,大于幸存区的一半,则将当前的对象 age 作为新的阈值,年龄大于此阈值的对象则直接进入老年代)

  • 每次进行Minor GC或者大对象直接进入老年区时,JVM会计算所需空间大小如小于老年区的剩余值大小,则进行一次Full GC

3、创建一个对象的步骤

步骤:类加载检查、分配内存、初始化对象(包括:初始化零值、设置对象头、执行init方法)、将创建的对象指向分配的内存

①类加载检查:

​ 虚拟机遇到 new 指令时,⾸先去检查是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。

②分配内存:

​ 在类加载检查通过后,接下来虚拟机将为新⽣对象分配内存,分配⽅式有 “指针碰撞”“空闲列表” 两种,选择那种分配⽅式由 Java 堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定。

③初始化对象

  • 初始化零值:

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

  • 设置对象头:

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

  • 执⾏ init ⽅法:

​ 从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从Java 程序的视⻆来看, ⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说(除循环依赖),执⾏ new 指令之后会接着执⾏ ⽅法,这样⼀个真正可⽤的对象才算产⽣出来。

④将创建的对象指向分配的内存

4、对象引用

普通的对象引用关系就是强引用

软引用用于维护一些可有可无的对象。只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。

弱引用对象相比软引用来说,要更加无用一些,它拥有更短的生命周期,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。

虚引用是一种形同虚设的引用,在现实场景中用的不是很多,它主要用来跟踪对象被垃圾回收的活动。

JVM类加载过程

过程:加载、验证、准备、解析、初始化

img

加载阶段:

​ 1.通过一个类的全限定名来获取定义此类的二进制字节流。

​ 2.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

​ 3.在Java堆中生成一个代表这个类的java.lang.class对象,作为访问方法区这些数据的入口。

验证阶段:

​ 1.文件格式验证(是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理)

​ 2.元数据验证(对字节码描述的信息进行语意分析,以保证其描述的信息符合Java语言规范要求)

​ 3.字节码验证(保证被校验类的方法在运行时不会做出危害虚拟机安全的行为)

​ 4.符号引用验证(虚拟机将符号引用转化为直接引用时,解析阶段中发生)

准备阶段:

​ 准备阶段是正式为类变量(成员变量)分配内存并设置类变量初始值的阶段。将对象初始化为“零”值。

这一步只会给那些静态变量设置一个初始的值,而那些实例变量是在实例化对象时进行分配的。

解析阶段:

​ 解析阶段时虚拟机将常量池内的符号引用替换为直接引用的过程。

字符串常量池:堆上,默认class文件的静态常量池

运行时常量池:在方法区,属于元空间

初始化阶段:

​ 初始化阶段时加载过程的最后一步,而这一阶段也是真正意义上开始执行类中定义的Java程序代码。

1、双亲委派机制

​ 每⼀个类都有⼀个对应它的类加载器。系统中的 ClassLoder 在协同⼯作的时候会默认使⽤ 双亲委派模型 。即在类加载的时候,系统会⾸先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,⾸先会把该请求委派该⽗类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当⽗类加载器⽆法处理时,才由⾃⼰来处理。当⽗类加载器为null时,会使⽤启动类加载器 BootstrapClassLoader 作为⽗类加载器。

img

使用好处:

​ 此机制保证JDK核心类的优先加载;使得Java程序的稳定运⾏,可以避免类的重复加载,也保证了 Java 的核⼼ API 不被篡改。如果不⽤使⽤双亲委派模型,⽽是每个类加载器加载⾃⼰的话就会出现⼀些问题,⽐如我们编写⼀个称为 java.lang.Object 类的话,那么程序运⾏的时候,系统就会出现多个不同的Object 类。

破坏双亲委派机制:

  • 可以⾃⼰定义⼀个类加载器,重写loadClass方法;

  • Tomcat 可以加载自己目录下的 class 文件,并不会传递给父类的加载器;

  • Java 的 SPI,发起者 BootstrapClassLoader 已经是最上层了,它直接获取了 AppClassLoader 进行驱动加载,和双亲委派是相反的。

2、tomcat的类加载机制

步骤:

  1. 先在本地cache查找该类是否已经加载过,看看 Tomcat 有没有加载过这个类。
  2. 如果Tomcat 没有加载过这个类,则从系统类加载器的cache中查找是否加载过。
  3. 如果没有加载过这个类,尝试用ExtClassLoader类加载器类加载,重点来了,这里并没有首先使用 AppClassLoader 来加载类。这个Tomcat 的 WebAPPClassLoader 违背了双亲委派机制,直接使用了 ExtClassLoader来加载类。这里注意 ExtClassLoader 双亲委派依然有效,ExtClassLoader 就会使用 Bootstrap ClassLoader 来对类进行加载,保证了 Jre 里面的核心类不会被重复加载。 比如在 Web 中加载一个 Object 类。WebAppClassLoader → ExtClassLoader → Bootstrap ClassLoader,这个加载链,就保证了 Object 不会被重复加载。
  4. 如果 BoostrapClassLoader,没有加载成功,就会调用自己的 findClass 方法由自己来对类进行加载,findClass 加载类的地址是自己本 web 应用下的 class。
  5. 加载依然失败,才使用 AppClassLoader 继续加载。
  6. 都没有加载成功的话,抛出异常。

总结一下以上步骤,WebAppClassLoader 加载类的时候,故意打破了JVM 双亲委派机制,绕开了 AppClassLoader,直接先使用 ExtClassLoader 来加载类。

JVM垃圾回收

1、存活算法和两次标记过程

引用计数法:

​ 给对象添加一个引用计数器,每当由一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

​ 优点:实现简单,判定效率也很高

​ 缺点:他很难解决对象之间相互循环引用的问题,基本上被抛弃

可达性分析法:

​ 通过一系列的成为“GC Roots”(活动线程相关的各种引用,虚拟机栈帧引用静态变量引用JNI引用)的对象作为起始点,从这些节点ReferenceChains开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC ROOTS没有任何引用链相连时,则证明此对象时不可用的;

两次标记过程:

​ 对象被回收之前,该对象的finalize()方法会被调用;两次标记,即第一次标记不在“关系网”中的对象。第二次的话就要先判断该对象有没有实现finalize()方法了,如果没有实现就直接判断该对象可回收;如果实现了就会先放在一个队列中,并由虚拟机建立的一个低优先级的线程去执行它,随后就会进行第二次的小规模标记,在这次被标记的对象就会真正的被回收了。

2、垃圾回收算法

垃圾回收算法:复制算法、标记清除、标记整理、分代收集

复制算法:(young)

​ 将内存分为⼤⼩相同的两块,每次使⽤其中的⼀块。当这⼀块的内存使⽤完后,就将还存活的对象复制到另⼀块去,然后再把使⽤的空间⼀次清理掉。这样就使每次的内存回收都是对内存区间的⼀半进⾏回收;

​ 优点:实现简单,内存效率高,不易产生碎片

​ 缺点:内存压缩了一半,倘若存活对象多,Copying 算法的效率会大大降低

标记清除:(cms)

​ 标记出所有需要回收的对象,在标记完成后统⼀回收所有被标记的对象

​ 缺点:效率低,标记清除后会产⽣⼤量不连续的碎⽚,需要预留空间给分配阶段的浮动垃圾

标记整理:(old)

​ 标记过程仍然与“标记-清除”算法⼀样,再让所有存活的对象向⼀端移动,然后直接清理掉端边界以外的内存;解决了产生大量不连续碎片问题

分代收集:

​ 根据各个年代的特点选择合适的垃圾收集算法。

​ 新生代采用复制算法,新生代每次垃圾回收都要回收大部分对象,存活对象较少,即要复制的操作比较少,一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。

​ 老年代的对象存活⼏率是⽐较⾼的,⽽且没有额外的空间对它进⾏分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进⾏垃圾收集。

Safepoint 当发生 GC 时,用户线程必须全部停下来,才可以进行垃圾回收,这个状态我们可以认为 JVM 是安全的(safe),整个堆的状态是稳定的。如果在 GC 前,有线程迟迟进入不了 safepoint,那么整个 JVM 都在等待这个阻塞的线程,造成了整体 GC 的时间变长

img

MinorGC、MajorGC、FullGC

MinorGC 在年轻代空间不足的时候发生,

MajorGC 指的是老年代的 GC,出现 MajorGC 一般经常伴有 MinorGC。

FullGC 1、当老年代无法再分配内存的时候;2、元空间不足的时候;3、显示调用 System.gc 的时候。另外,像 CMS 一类的垃圾回收器,在 MinorGC 出现 promotion failure 的时候也会发生 FullGC。

对象优先在 Eden 区分配
大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

大对象直接进入老年代
大对象是指需要连续内存空间的对象,比如很长的字符串以及数组。老年代直接分配的目的是避免在 Eden 区和 Survivor 区之间出现大量内存复制。

长期存活的对象进入老年代
虚拟机给每个对象定义了年龄计数器,对象在 Eden 区出生之后,如果经过一次 Minor GC 之后,将进入 Survivor 区,同时对象年龄变为 1,增加到一定阈值时则进入老年代(阈值默认为 15)

动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到阈值才能进入老年代。如果在 Survivor 区中相同年龄的所有对象的空间总和大于 Survivor 区空间的一半,则年龄大于或等于该年龄的对象直接进入老年代。

空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的空间总和,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立则进行 Full GC。

3、垃圾收集器

img

JDK3:Serial Parnew 关注效率

Serial:

​ Serial 是一个单线程的收集器,它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。适合用于客户端垃圾收集器。

Parnew:

​ ParNew 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样,ParNew 垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。

JDK5:parallel Scavenge+(Serial old/parallel old)关注吞吐量

parallel Scavenge:(关注吞吐量)

​ Parallel Scavenge收集器关注点是吞吐量(⾼效率的利⽤CPU)。CMS等垃圾收集器的关注点更多的是⽤户线程的停顿时间(提⾼⽤户体验);高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。

Serial old:

Serial收集器的⽼年代版本,它同样是⼀个单线程收集器,使用标记-整理算法。主要有两个用途:

  • 在 JDK1.5 之前版本中与新生代的 Parallel Scavenge 收集器搭配使用。

  • 作为年老代中使用 CMS 收集器的后备垃圾收集方案。

parallel old:

​ Parallel Scavenge收集器的⽼年代版本。使⽤多线程和“标记-整理”算法。

JDK8-CMS:(关注最短垃圾回收停顿时间)

​ CMS收集器是一种年老代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,和其他年老代使用标记-整理算法不同,它使用多线程的标记-清除算法。最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下 4 个阶段:

初始标记:只是标记一下 GC Roots 能直接关联的对象,速度很快,STW。

并发标记:进行 ReferenceChains跟踪的过程,和用户线程一起工作,不需要暂停工作线程。

重新标记:为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,STW。

并发清除:清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。

​ 由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。

优点:并发收集、低停顿

缺点:对CPU资源敏感;⽆法处理浮动垃圾;使⽤“标记清除”算法,会导致⼤量空间碎⽚产⽣。

JDK9-G1:(精准控制停顿时间,避免垃圾碎片)

​ 是⼀款⾯向服务器的垃圾收集器,主要针对配备多颗处理器及⼤容量内存的机器.以极⾼概率满⾜GC停顿时间要求的同时,还具备⾼吞吐量性能特征;相比与 CMS 收集器,G1 收集器两个最突出的改进是:

​ 【1】基于标记-整理算法,不产生内存碎片。

​ 【2】可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。

​ G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率。

  • 初始标记Stop The World,仅使用一条初始标记线程对GC Roots关联的对象进行标记

  • 并发标记:使用一条标记线程与用户线程并发执行。此过程进行可达性分析,速度很慢

  • 最终标记Stop The World,使用多条标记线程并发执行

  • 筛选回收:回收废弃对象,此时也要 Stop The World,并使用多条筛选回收线程并发执行

**JDK11-ZGC:**(在不关注容量的情况获取最小停顿时间5TB/10ms)

​ 着色笔技术:加快标记过程

​ 读屏障:解决GC和应用之间并发导致的STW问题

  • 支持 TB 级堆内存(最大 4T, JDK13 最大16TB)

  • 最大 GC 停顿 10ms

  • 对吞吐量影响最大,不超过 15%

4、配置垃圾收集器

  • 首先是内存大小问题,基本上每一个内存区域我都会设置一个上限,来避免溢出问题,比如元空间。
  • 通常,堆空间我会设置成操作系统的 2/3,超过 8GB 的堆,优先选用 G1
  • 然后我会对 JVM 进行初步优化,比如根据老年代的对象提升速度,来调整年轻代和老年代之间的比例
  • 依据系统容量、访问延迟、吞吐量等进行专项优化,我们的服务是高并发的,对 STW 的时间敏感
  • 我会通过记录详细的 GC 日志,来找到这个瓶颈点,借用 GCeasy 这样的日志分析工具,定位问题

4、JVM性能调优

对应进程的JVM状态以定位问题和解决问题并作出相应的优化

常用命令:jps、jinfo、jstat、jstack、jmap

jps:查看java进程及相关信息

1
2
3
jps -l 输出jar包路径,类全名
jps -m 输出main参数
jps -v 输出JVM参数

jinfo:查看JVM参数

1
2
3
jinfo 11666
jinfo -flags 11666
Xmx、Xms、Xmn、MetaspaceSize

jstat:查看JVM运行时的状态信息,包括内存状态、垃圾回收

1
2
3
4
5
6
7
8
9
jstat [option] LVMID [interval] [count]
其中LVMID是进程id,interval是打印间隔时间(毫秒),count是打印次数(默认一直打印)

option参数解释:
-gc 垃圾回收堆的行为统计
-gccapacity 各个垃圾回收代容量(young,old,perm)和他们相应的空间统计
-gcutil 垃圾回收统计概述
-gcnew 新生代行为统计
-gcold 年老代和永生代行为统计

jstack:查看JVM线程快照,jstack命令可以定位线程出现长时间卡顿的原因,例如死锁,死循环

1
2
3
4
5
6
jstack [-l] <pid> (连接运行中的进程)

option参数解释:
-F 当使用jstack <pid>无响应时,强制输出线程堆栈。
-m 同时输出java和本地堆栈(混合模式)
-l 额外显示锁信息

jmap:可以用来查看内存信息(配合jhat使用)

1
2
3
4
5
jmap [option] <pid> (连接正在执行的进程)

option参数解释:
-heap 打印java heap摘要
-dump:<dump-options> 生成java堆的dump文件

5、JDK新特性

JDK8

支持 Lamda 表达式、集合的 stream 操作、提升HashMap性能

JDK9

1
2
//Stream API中iterate方法的新重载方法,可以指定什么时候结束迭代
IntStream.iterate(1, i -> i < 100, i -> i + 1).forEach(System.out::println);

默认G1垃圾回收器

JDK10

其重点在于通过完全GC并行来改善G1最坏情况的等待时间。

JDK11

ZGC (并发回收的策略) 4TB

用于 Lambda 参数的局部变量语法

JDK12

Shenandoah GC (GC 算法)停顿时间和堆的大小没有任何关系,并行关注停顿响应时间。

JDK13

增加ZGC以将未使用的堆内存返回给操作系统,16TB

JDK14

删除cms垃圾回收器、弃用ParallelScavenge+SerialOldGC垃圾回收算法组合

将ZGC垃圾回收器应用到macOS和windows平台

线上故障排查

1、硬件故障排查

如果一个实例发生了问题,根据情况选择,要不要着急去重启。如果出现的CPU、内存飙高或者日志里出现了OOM异常

第一步是隔离,第二步是保留现场,第三步才是问题排查

隔离

就是把你的这台机器从请求列表里摘除,比如把 nginx 相关的权重设成零。

现场保留

瞬时态和历史态

img

查看比如 CPU、系统内存等,通过历史状态可以体现一个趋势性问题,而这些信息的获取一般依靠监控系统的协作。

保留信息

(1)系统当前网络连接

1
ss -antp > $DUMP_DIR/ss.dump 2>&1

使用 ss 命令而不是 netstat 的原因,是因为 netstat 在网络连接非常多的情况下,执行非常缓慢。

后续的处理,可通过查看各种网络连接状态的梳理,来排查 TIME_WAIT 或者 CLOSE_WAIT,或者其他连接过高的问题,非常有用。

(2)网络状态统计

1
netstat -s > $DUMP_DIR/netstat-s.dump 2>&1

它能够按照各个协议进行统计输出,对把握当时整个网络状态,有非常大的作用。

1
sar -n DEV 1 2 > $DUMP_DIR/sar-traffic.dump 2>&1

在一些速度非常高的模块上,比如 Redis、Kafka,就经常发生跑满网卡的情况。表现形式就是网络通信非常缓慢。

(3)进程资源

1
lsof -p $PID > $DUMP_DIR/lsof-$PID.dump

通过查看进程,能看到打开了哪些文件,可以以进程的维度来查看整个资源的使用情况,包括每条网络连接、每个打开的文件句柄。同时,也可以很容易的看到连接到了哪些服务器、使用了哪些资源。这个命令在资源非常多的情况下,输出稍慢,请耐心等待。

(4)CPU 资源

1
2
3
4
mpstat > $DUMP_DIR/mpstat.dump 2>&1
vmstat 1 3 > $DUMP_DIR/vmstat.dump 2>&1
sar -p ALL > $DUMP_DIR/sar-cpu.dump 2>&1
uptime > $DUMP_DIR/uptime.dump 2>&1

主要用于输出当前系统的 CPU 和负载,便于事后排查。

(5)I/O 资源

1
iostat -x > $DUMP_DIR/iostat.dump 2>&1

一般,以计算为主的服务节点,I/O 资源会比较正常,但有时也会发生问题,比如日志输出过多,或者磁盘问题等。此命令可以输出每块磁盘的基本性能信息,用来排查 I/O 问题。在第 8 课时介绍的 GC 日志分磁盘问题,就可以使用这个命令去发现。

(6)内存问题

1
free -h > $DUMP_DIR/free.dump 2>&1

free 命令能够大体展现操作系统的内存概况,这是故障排查中一个非常重要的点,比如 SWAP 影响了 GC,SLAB 区挤占了 JVM 的内存。

(7)其他全局

1
2
3
ps -ef > $DUMP_DIR/ps.dump 2>&1
dmesg > $DUMP_DIR/dmesg.dump 2>&1
sysctl -a > $DUMP_DIR/sysctl.dump 2>&1

dmesg 是许多静悄悄死掉的服务留下的最后一点线索。当然,ps 作为执行频率最高的一个命令,由于内核的配置参数,会对系统和 JVM 产生影响,所以我们也输出了一份。

(8)进程快照,最后的遗言(jinfo)

1
${JDK_BIN}jinfo $PID > $DUMP_DIR/jinfo.dump 2>&1

此命令将输出 Java 的基本进程信息,包括环境变量和参数配置,可以查看是否因为一些错误的配置造成了 JVM 问题。

(9)dump 堆信息

1
2
${JDK_BIN}jstat -gcutil $PID > $DUMP_DIR/jstat-gcutil.dump 2>&1
${JDK_BIN}jstat -gccapacity $PID > $DUMP_DIR/jstat-gccapacity.dump 2>&1

jstat 将输出当前的 gc 信息。一般,基本能大体看出一个端倪,如果不能,可将借助 jmap 来进行分析。

(10)堆信息

1
2
3
4
${JDK_BIN}jmap $PID > $DUMP_DIR/jmap.dump 2>&1
${JDK_BIN}jmap -heap $PID > $DUMP_DIR/jmap-heap.dump 2>&1
${JDK_BIN}jmap -histo $PID > $DUMP_DIR/jmap-histo.dump 2>&1
${JDK_BIN}jmap -dump:format=b,file=$DUMP_DIR/heap.bin $PID > /dev/null 2>&1

jmap 将会得到当前 Java 进程的 dump 信息。如上所示,其实最有用的就是第 4 个命令,但是前面三个能够让你初步对系统概况进行大体判断。因为,第 4 个命令产生的文件,一般都非常的大。而且,需要下载下来,导入 MAT 这样的工具进行深入分析,才能获取结果。这是分析内存泄漏一个必经的过程。

(11)JVM 执行栈

1
${JDK_BIN}jstack $PID > $DUMP_DIR/jstack.dump 2>&1

jstack 将会获取当时的执行栈。一般会多次取值,我们这里取一次即可。这些信息非常有用,能够还原 Java 进程中的线程情况。

1
top -Hp $PID -b -n 1 -c >  $DUMP_DIR/top-$PID.dump 2>&1

为了能够得到更加精细的信息,我们使用 top 命令,来获取进程中所有线程的 CPU 信息,这样,就可以看到资源到底耗费在什么地方了。

(12)高级替补

1
kill -3 $PID

有时候,jstack 并不能够运行,有很多原因,比如 Java 进程几乎不响应了等之类的情况。我们会尝试向进程发送 kill -3 信号,这个信号将会打印 jstack 的 trace 信息到日志文件中,是 jstack 的一个替补方案。

1
gcore -o $DUMP_DIR/core $PID

对于 jmap 无法执行的问题,也有替补,那就是 GDB 组件中的 gcore,将会生成一个 core 文件。我们可以使用如下的命令去生成 dump:

1
${JDK_BIN}jhsdb jmap --exe ${JDK}java  --core $DUMP_DIR/core --binaryheap
  1. 内存泄漏的现象

稍微提一下 jmap 命令,它在 9 版本里被干掉了,取而代之的是 jhsdb,你可以像下面的命令一样使用。

1
2
3
4
jhsdb jmap  --heap --pid  37340
jhsdb jmap --pid 37288
jhsdb jmap --histo --pid 37340
jhsdb jmap --binaryheap --pid 37340

一般内存溢出,表现形式就是 Old 区的占用持续上升,即使经过了多轮 GC 也没有明显改善。比如ThreadLocal里面的GC Roots,内存泄漏的根本就是,这些对象并没有切断和 GC Roots 的关系,可通过一些工具,能够看到它们的联系。

2、报表异常 | JVM调优

有一个报表系统,频繁发生内存溢出,在高峰期间使用时,还会频繁的发生拒绝服务,由于大多数使用者是管理员角色,所以很快就反馈到研发这里。

业务场景是由于有些结果集的字段不是太全,因此需要对结果集合进行循环,并通过 HttpClient 调用其他服务的接口进行数据填充。使用 Guava 做了 JVM 内缓存,但是响应时间依然很长。

初步排查,JVM 的资源太少。接口 A 每次进行报表计算时,都要涉及几百兆的内存,而且在内存里驻留很长时间,有些计算又非常耗 CPU,特别的“吃”资源。而我们分配给 JVM 的内存只有 3 GB,在多人访问这些接口的时候,内存就不够用了,进而发生了 OOM。在这种情况下,没办法,只有升级机器。把机器配置升级到 4C8G,给 JVM 分配 6GB 的内存,这样 OOM 问题就消失了。但随之而来的是频繁的 GC 问题和超长的 GC 时间,平均 GC 时间竟然有 5 秒多。

进一步,由于报表系统和高并发系统不太一样,它的对象,存活时长大得多,并不能仅仅通过增加年轻代来解决;而且,如果增加了年轻代,那么必然减少了老年代的大小,由于 CMS 的碎片和浮动垃圾问题,我们可用的空间就更少了。虽然服务能够满足目前的需求,但还有一些不太确定的风险。

第一,了解到程序中有很多缓存数据和静态统计数据,为了减少 MinorGC 的次数,通过分析 GC 日志打印的对象年龄分布,把 MaxTenuringThreshold 参数调整到了 3(特殊场景特殊的配置)。这个参数是让年轻代的这些对象,赶紧回到老年代去,不要老呆在年轻代里。

第二,我们的 GC 时间比较长,就一块开了参数 CMSScavengeBeforeRemark,使得在 CMS remark 前,先执行一次 Minor GC 将新生代清掉。同时配合上个参数,其效果还是比较好的,一方面,对象很快晋升到了老年代,另一方面,年轻代的对象在这种情况下是有限的,在整个 MajorGC 中占的时间也有限。

第三,由于缓存的使用,有大量的弱引用,拿一次长达 10 秒的 GC 来说。我们发现在 GC 日志里,处理 weak refs 的时间较长,达到了 4.5 秒。这里可以加入参数 ParallelRefProcEnabled 来并行处理Reference,以加快处理速度,缩短耗时。

优化之后,效果不错,但并不是特别明显。经过评估,针对高峰时期的情况进行调研,我们决定再次提升机器性能,改用 8core16g 的机器。但是,这带来另外一个问题。

高性能的机器带来了非常大的服务吞吐量,通过 jstat 进行监控,能够看到年轻代的分配速率明显提高,但随之而来的 MinorGC 时长却变的不可控,有时候会超过 1 秒。累积的请求造成了更加严重的后果。

这是由于堆空间明显加大造成的回收时间加长。为了获取较小的停顿时间,我们在堆上改用了 G1 垃圾回收器,把它的目标设定在 200ms。G1 是一款非常优秀的垃圾收集器,不仅适合堆内存大的应用,同时也简化了调优的工作。通过主要的参数初始和最大堆空间、以及最大容忍的 GC 暂停目标,就能得到不错的性能。修改之后,虽然 GC 更加频繁了一些,但是停顿时间都比较小,应用的运行较为平滑。

到目前为止,也只是勉强顶住了已有的业务,但是,这时候领导层面又发力,要求报表系统可以支持未来两年业务10到100倍的增长,并保持其可用性,但是这个“千疮百孔”的报表系统,稍微一压测,就宕机,那如何应对十倍百倍的压力呢 ? 硬件即使可以做到动态扩容,但是毕竟也有极限。

使用 MAT 分析堆快照,发现很多地方可以通过代码优化,那些占用内存特别多的对象:

1、select * 全量排查,只允许获取必须的数据

2、报表系统中cache实际的命中率并不高,将Guava 的 Cache 引用级别改成弱引用(WeakKeys)

3、限制报表导入文件大小,同时拆分用户超大范围查询导出请求。

每一步操作都使得JVM使用变得更加可用,一系列优化以后,机器相同压测数据性能提升了数倍。

3、大屏异常 | JUC调优

有些数据需要使用 HttpClient 来获取进行补全。提供数据的服务提供商有的响应时间可能会很长,也有可能会造成服务整体的阻塞。

img

接口 A 通过 HttpClient 访问服务 2,响应 100ms 后返回;接口 B 访问服务 3,耗时 2 秒。HttpClient 本身是有一个最大连接数限制的,如果服务 3 迟迟不返回,就会造成 HttpClient 的连接数达到上限,概括来讲,就是同一服务,由于一个耗时非常长的接口,进而引起了整体的服务不可用

这个时候,通过 jstack 打印栈信息,会发现大多数竟然阻塞在了接口 A 上,而不是耗时更长的接口 B,这个现象起初十分具有迷惑性,不过经过分析后,我们猜想其实是因为接口 A 的速度比较快,在问题发生点进入了更多的请求,它们全部都阻塞住的同时被打印出来了。

为了验证这个问题,我搭建了一个demo 工程,模拟了两个使用同一个 HttpClient 的接口。fast 接口用来访问百度,很快就能返回;slow 接口访问谷歌,由于众所周知的原因,会阻塞直到超时,大约 10 s。 利用ab对两个接口进行压测,同时使用 jstack 工具 dump 堆栈。首先使用 jps 命令找到进程号,然后把结果重定向到文件(可以参考 10271.jstack 文件)。

过滤一下 nio 关键字,可以查看 tomcat 相关的线程,足足有 200 个,这和 Spring Boot 默认的 maxThreads 个数不谋而合。更要命的是,有大多数线程,都处于 BLOCKED 状态,说明线程等待资源超时。通过grep fast | wc -l 分析,确实200个中有150个都是blocked的fast的进程。

问题找到了,解决方式就顺利成章了。

1、fast和slow争抢连接资源,通过线程池限流或者熔断处理

2、有时候slow的线程也不是一直slow,所以就得加入监控

3、使用带countdownLaunch对线程的执行顺序逻辑进行控制

4、接口延迟 | SWAP调优

有一个关于服务的某个实例,经常发生服务卡顿。由于服务的并发量是比较高的,每多停顿 1 秒钟,几万用户的请求就会感到延迟。

我们统计、类比了此服务其他实例的 CPU、内存、网络、I/O 资源,区别并不是很大,所以一度怀疑是机器硬件的问题。

接下来我们对比了节点的 GC 日志,发现无论是 Minor GC,还是 Major GC,这个节点所花费的时间,都比其他实例长得多。

通过仔细观察,我们发现在 GC 发生的时候,vmstat 的 si、so 飙升的非常严重,这和其他实例有着明显的不同。

使用 free 命令再次确认,发现 SWAP 分区,使用的比例非常高,引起的具体原因是什么呢?

更详细的操作系统内存分布,从 /proc/meminfo 文件中可以看到具体的逻辑内存块大小,有多达 40 项的内存信息,这些信息都可以通过遍历 /proc 目录的一些文件获取。我们注意到 slabtop 命令显示的有一些异常,dentry(目录高速缓冲)占用非常高。

问题最终定位到是由于某个运维工程师删除日志时,定时执行了一句命令:

find / | grep “xxx.log”

他是想找一个叫做 要被删除 的日志文件,看看在哪台服务器上,结果,这些老服务器由于文件太多,扫描后这些文件信息都缓存到了 slab 区上。而服务器开了 swap,操作系统发现物理内存占满后,并没有立即释放 cache,导致每次 GC 都要和硬盘打一次交道。

解决方式就是关闭 SWAP 分区。

swap 是很多性能场景的万恶之源,建议禁用。在高并发 SWAP 绝对能让你体验到它魔鬼性的一面:进程倒是死不了了,但 GC 时间长的却让人无法忍受。

5、内存溢出 | Cache调优

有一次线上遇到故障,重新启动后,使用 jstat 命令,发现 Old 区一直在增长。我使用 jmap 命令,导出了一份线上堆栈,然后使用 MAT 进行分析,通过对 GC Roots 的分析,发现了一个非常大的 HashMap 对象,这个原本是其他同事做缓存用的,但是做了一个无界缓存,没有设置超时时间或者 LRU 策略,在使用上又没有重写key类对象的hashcode和equals方法,对象无法取出也直接造成了堆内存占用一直上升,后来,将这个缓存改成 guava 的 Cache,并设置了弱引用,故障就消失了。

关于文件处理器的应用,在读取或者写入一些文件之后,由于发生了一些异常,close 方法又没有放在 finally 块里面,造成了文件句柄的泄漏。由于文件处理十分频繁,产生了严重的内存泄漏问题。

内存溢出是一个结果,而内存泄漏是一个原因。内存溢出的原因有内存空间不足、配置错误等因素。一些错误的编程方式,不再被使用的对象、没有被回收、没有及时切断与 GC Roots 的联系,这就是内存泄漏。

举个例子,有团队使用了 HashMap 做缓存,但是并没有设置超时时间或者 LRU 策略,造成了放入 Map 对象的数据越来越多,而产生了内存泄漏。

再来看一个经常发生的内存泄漏的例子,也是由于 HashMap 产生的。代码如下,由于没有重写 Key 类的 hashCode 和 equals 方法,造成了放入 HashMap 的所有对象都无法被取出来,它们和外界失联了。所以下面的代码结果是 null。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//leak example
import java.util.HashMap;
import java.util.Map;
public class HashMapLeakDemo {
public static class Key {
String title;
public Key(String title) {
this.title = title;
}
}

public static void main(String[] args) {
Map<Key, Integer> map = new HashMap<>();
map.put(new Key("1"), 1);
map.put(new Key("2"), 2);
map.put(new Key("3"), 2);
Integer integer = map.get(new Key("2"));
System.out.println(integer);
}
}

即使提供了 equals 方法和 hashCode 方法,也要非常小心,尽量避免使用自定义的对象作为 Key。

再看一个例子,关于文件处理器的应用,在读取或者写入一些文件之后,由于发生了一些异常,close 方法又没有放在 finally 块里面,造成了文件句柄的泄漏。由于文件处理十分频繁,产生了严重的内存泄漏问题。

6:CPU飙高 | 死循环

我们有个线上应用,单节点在运行一段时间后,CPU 的使用会飙升,一旦飙升,一般怀疑某个业务逻辑的计算量太大,或者是触发了死循环(比如著名的 HashMap 高并发引起的死循环),但排查到最后其实是 GC 的问题。

(1)使用 top 命令,查找到使用 CPU 最多的某个进程,记录它的 pid。使用 Shift + P 快捷键可以按 CPU 的使用率进行排序。

1
top

(2)再次使用 top 命令,加 -H 参数,查看某个进程中使用 CPU 最多的某个线程,记录线程的 ID。

1
top -Hp $pid

(3)使用 printf 函数,将十进制的 tid 转化成十六进制。

1
printf %x $tid

(4)使用 jstack 命令,查看 Java 进程的线程栈。

1
jstack $pid >$pid.log

(5)使用 less 命令查看生成的文件,并查找刚才转化的十六进制 tid,找到发生问题的线程上下文。

1
less $pid.log

我们在 jstack 日志搜关键字DEAD,以及中找到了 CPU 使用最多的几个线程id。

可以看到问题发生的根源,是我们的堆已经满了,但是又没有发生 OOM,于是 GC 进程就一直在那里回收,回收的效果又非常一般,造成 CPU 升高应用假死。接下来的具体问题排查,就需要把内存 dump 一份下来,使用 MAT 等工具分析具体原因了。

三、多线程篇

线程调度

1、线程状态

​ 线程是cpu任务调度的最小执行单位,每个线程拥有自己独立的程序计数器、虚拟机栈、本地方法栈

线程状态:创建、就绪、运行、阻塞、死亡

img

2、线程状态切换

方法 作用 区别
start 启动线程,由虚拟机自动调度执行run()方法 线程处于就绪状态
run 线程逻辑代码块处理,JVM调度执行 线程处于运行状态
sleep 让当前正在执行的线程休眠(暂停执行) 不释放锁
wait 使得当前线程等待 释放同步锁
notify 唤醒在此对象监视器上等待的单个线程 唤醒单个线程
notifyAll 唤醒在此对象监视器上等待的所有线程 唤醒多个线程
yiled 停止当前线程,让同等优先权的线程运行 用Thread类调用
join 使当前线程停下来等待,直至另一个调用join方法的线程终止 用线程对象调用
img

3、阻塞唤醒过程

阻塞:

​ 这三个方法的调用都会使当前线程阻塞。该线程将会被放置到对该Object的请求等待队列中,然后让出当前对Object所拥有的所有的同步请求。线程会一直暂停所有线程调度,直到下面其中一种情况发生:

    ① 其他线程调用了该Object的notify方法,而该线程刚好是那个被唤醒的线程;

    ② 其他线程调用了该Object的notifyAll方法;

唤醒:

​ 线程将会从等待队列中移除,重新成为可调度线程。它会与其他线程以常规的方式竞争对象同步请求。一旦它重新获得对象的同步请求,所有之前的请求状态都会恢复,也就是线程调用wait的地方的状态。线程将会在之前调用wait的地方继续运行下去。

为什么要出现在同步代码块中:

​ 由于wait()属于Object方法,调用之后会强制释放当前对象锁,所以在wait() 调用时必须拿到当前对象的监视器monitor对象。因此,wait()方法在同步方法/代码块中调用。

4、wait和sleep区别

  • wait 方法必须在 synchronized 保护的代码中使用,而 sleep 方法并没有这个要求。

  • wait 方法会主动释放 monitor 锁,在同步代码中执行 sleep 方法时,并不会释放 monitor 锁。

  • wait 方法意味着永久等待,直到被中断或被唤醒才能恢复,不会主动恢复,sleep 方法中会定义一个时间,时间到期后会主动恢复。

  • wait/notify 是 Object 类的方法,而 sleep 是 Thread 类的方法。

5、创建线程方式

实现 Runnable 接口(优先使用)

1
2
3
4
public class RunnableThread implements Runnable {
@Override
public void run() {System.out.println('用实现Runnable接口实现线程');}
}

实现Callable接口(有返回值可抛出异常)

1
2
3
4
class CallableTask implements Callable<Integer> {
    @Override
    public Integer call() throws Exception { return new Random().nextInt();}
}

继承Thread类(java不支持多继承)

1
2
3
4
public class ExtendsThread extends Thread {
    @Override
    public void run() {System.out.println('用Thread类实现线程');}
}

使用线程池(底层都是实现run方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
static class DefaultThreadFactory implements ThreadFactory {
    DefaultThreadFactory() {
        SecurityManager s = System.getSecurityManager();
        group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup();
        namePrefix = "pool-" + poolNumber.getAndIncrement() +"-thread-";
    }
    public Thread newThread(Runnable r) {
        Thread t = new Thread(group, r,namePrefix + threadNumber.getAndIncrement(),0);
        if (t.isDaemon()) t.setDaemon(false); //是否守护线程
        if (t.getPriority() != Thread.NORM_PRIORITY) t.setPriority(Thread.NORM_PRIORITY); //线程优先级
        return t;
    }
}

线程池

优点:通过复用已创建的线程,降低资源损耗、线程可以直接处理队列中的任务加快响应速度、同时便于统一监控和管理

1、线程池构造函数

1
2
3
4
5
6
/**
* 线程池构造函数7大参数
*/
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,
TimeUnit unit,BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,
RejectedExecutionHandler handler) {}

参数介绍:

参数 作用
corePoolSize 核心线程池大小
maximumPoolSize 最大线程池大小
keepAliveTime 线程池中超过 corePoolSize 数目的空闲线程最大存活时间;
TimeUnit keepAliveTime 时间单位
workQueue 阻塞任务队列
threadFactory 新建线程工厂
RejectedExecutionHandler 拒绝策略。当提交任务数超过 maxmumPoolSize+workQueue 之和时,任务会交给RejectedExecutionHandler 来处理

2、线程处理任务过程:

img
  1. 当线程池小于corePoolSize,新提交任务将创建一个新线程执行任务,即使此时线程池中存在空闲线程。
  2. 当线程池达到corePoolSize时,新提交任务将被放入 workQueue 中,等待线程池中任务调度执行。
  3. 当workQueue已满,且 maximumPoolSize 大于 corePoolSize 时,新提交任务会创建新线程执行任务。
  4. 当提交任务数超过 maximumPoolSize 时,新提交任务由 RejectedExecutionHandler 处理。
  5. 当线程池中超过corePoolSize 线程,空闲时间达到 keepAliveTime 时,关闭空闲线程 。

3、线程拒绝策略

​ 线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列也已经排满了,再也塞不下新任务了。这时候我们就需要拒绝策略机制合理的处理这个问题。

JDK 内置的拒绝策略如下:

AbortPolicy:直接抛出异常,阻止系统正常运行。可以根据业务逻辑选择重试或者放弃提交等策略。

CallerRunsPolicy :只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。

​ 不会造成任务丢失,同时减缓提交任务的速度,给执行任务缓冲时间。

DiscardOldestPolicy :丢弃最老的一个请求,也就是即将被执行的任务,并尝试再次提交当前任务。

DiscardPolicy :该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,这是最好的一种方案。

4、Execuors类实现线程池

img
  • newSingleThreadExecutor():只有一个线程的线程池,任务是顺序执行,适用于一个一个任务执行的场景
  • newCachedThreadPool():线程池里有很多线程需要同时执行,60s内复用,适用执行很多短期异步的小程序或者负载较轻的服务
  • newFixedThreadPool():拥有固定线程数的线程池,如果没有任务执行,那么线程会一直等待,适用执行长期的任务。
  • newScheduledThreadPool():用来调度即将执行的任务的线程池
  • **newWorkStealingPool()**:底层采用forkjoin的Deque,采用独立的任务队列可以减少竞争同时加快任务处理
  • img

因为以上方式都存在弊端:

​ FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列⻓度为 Integer.MAX_VALUE,会导致OOM。
​ CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE,会导致OOM。

手动创建的线程池底层使用的是ArrayBlockingQueue可以防止OOM。

5、线程池大小设置

  • CPU 密集型(n+1)

​ CPU 密集的意思是该任务需要大量的运算,而没有阻塞,CPU 一直全速运行。

​ CPU 密集型任务尽可能的少的线程数量,一般为 CPU 核数 + 1 个线程的线程池。

  • IO 密集型(2*n)

​ 由于 IO 密集型任务线程并不是一直在执行任务,可以多分配一点线程数,如 CPU * 2

​ 也可以使用公式:CPU 核心数 *(1+平均等待时间/平均工作时间)。

线程安全

1、乐观锁,CAS思想

java乐观锁机制:

​ 乐观锁体现的是悲观锁的反面。它是一种积极的思想,它总是认为数据是不会被修改的,所以是不会对数据上锁的。但是乐观锁在更新的时候会去判断数据是否被更新过。乐观锁的实现方案一般有两种(版本号机制和CAS)。乐观锁适用于读多写少的场景,这样可以提高系统的并发量。在Java中 java.util.concurrent.atomic下的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

  乐观锁,大多是基于数据版本 (Version)记录机制实现。即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来 实现。 读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提 交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据 版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。

CAS思想:

​ CAS就是compare and swap(比较交换),是一种很出名的无锁的算法,就是可以不使用锁机制实现线程间的同步。使用CAS线程是不会被阻塞的,所以又称为非阻塞同步。CAS算法涉及到三个操作:

​ 需要读写内存值V;进行比较的值A;准备写入的值B

​ 当且仅当V的值等于A的值等于V的值的时候,才用B的值去更新V的值,否则不会执行任何操作(比较和替换是一个原子操作-A和V比较,V和B替换),一般情况下是一个自旋操作,即不断重试

缺点:

ABA问题-知乎

​ 高并发的情况下,很容易发生并发冲突,如果CAS一直失败,那么就会一直重试,浪费CPU资源

原子性:

​ 功能限制CAS是能保证单个变量的操作是原子性的,在Java中要配合使用volatile关键字来保证线程的安全;当涉及到多个变量的时候CAS无能为力;除此之外CAS实现需要硬件层面的支持,在Java的普通用户中无法直接使用,只能借助atomic包下的原子类实现,灵活性受到了限制

2、synchronized底层实现

使用方法:主要的三种使⽤⽅式

修饰实例⽅法: 作⽤于当前对象实例加锁,进⼊同步代码前要获得当前对象实例的锁

修饰静态⽅法: 也就是给当前类加锁,会作⽤于类的所有对象实例,因为静态成员不属于任何⼀个实例对象,是类成员。

修饰代码块: 指定加锁对象,对给定对象加锁,进⼊同步代码库前要获得给定对象的锁。

总结:synchronized锁住的资源只有两类:一个是对象,一个是

底层实现:

​ 对象头是我们需要关注的重点,它是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。对象头主要结构是由Mark Word 组成,其中Mark Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息

​ 锁也分不同状态,JDK6之前只有两个状态:无锁、有锁(重量级锁),而在JDK6之后对synchronized进行了优化,新增了两种状态,总共就是四个状态:无锁状态、偏向锁、轻量级锁、重量级锁,其中无锁就是一种状态了。锁的类型和状态在对象头Mark Word中都有记录,在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word数据。

​ 同步代码块是利用 monitorenter 和 monitorexit 指令实现的,而同步方法则是利用 flags 实现的。

3、ReenTrantLock底层实现

​ 由于ReentrantLock是java.util.concurrent包下提供的一套互斥锁,相比Synchronized,ReentrantLock类提供了一些高级功能

使用方法:

​ 基于API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成

底层实现:

​ ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。

和synchronized区别:

​ 1、底层实现:synchronized 是JVM层面的锁,是Java关键字,通过monitor对象来完成(monitorenter与monitorexit),ReentrantLock 是从jdk1.5以来(java.util.concurrent.locks.Lock)提供的API层面的锁。

​ 2、实现原理****:synchronized 的实现涉及到锁的升级,具体为无锁、偏向锁、自旋锁、向OS申请重量级锁;ReentrantLock实现则是通过利用CAS**(CompareAndSwap)自旋机制保证线程操作的原子性和volatile保证数据可见性以实现锁的功能。

​ 3、是否可手动释放:synchronized 不需要用户去手动释放锁,synchronized 代码执行完后系统会自动让线程释放对锁的占用; ReentrantLock则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象

​ 4、是否可中断synchronized是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成; ReentrantLock则可以中断,可通过trylock(long timeout,TimeUnit unit)设置超时方法或者将lockInterruptibly()放到代码块中,调用interrupt方法进行中断。

​ 5、是否公平锁synchronized为非公平锁 ReentrantLock则即可以选公平锁也可以选非公平锁,通过构造方法new ReentrantLock时传入boolean值进行选择,为空默认false非公平锁,true为公平锁,公平锁性能非常低。

4、公平锁和非公平锁区别

公平锁:

​ 公平锁自然是遵循FIFO(先进先出)原则的,先到的线程会优先获取资源,后到的会进行排队等待

优点:所有的线程都能得到资源,不会饿死在队列中。适合大任务

缺点:吞吐量会下降,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销大

非公平锁:

​ 多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。

缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁

img

公平锁效率低原因:

​ 公平锁要维护一个队列,后来的线程要加锁,即使锁空闲,也要先检查有没有其他线程在 wait,如果有自己要挂起,加到队列后面,然后唤醒队列最前面线程。这种情况下相比较非公平锁多了一次挂起和唤醒

线程切换的开销,其实就是非公平锁效率高于公平锁的原因,因为非公平锁减少了线程挂起的几率,后来的线程有一定几率逃离被挂起的开销。

5、使用层面锁优化

​ 【1】减少锁的时间:
​ 不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放;

​ 【2】减少锁的粒度:
​ 它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间;java中很多数据结构都是采用这种方法提高并发操作的效率,比如:

ConcurrentHashMap:

​ java中的ConcurrentHashMap在jdk1.8之前的版本,使用一个Segment 数组:Segment< K,V >[] segments

​ Segment继承自ReenTrantLock,所以每个Segment是个可重入锁,每个Segment 有一个HashEntry< K,V >数组用来存放数据,put操作时,先确定往哪个Segment放数据,只需要锁定这个Segment,执行put,其它的Segment不会被锁定;所以数组中有多少个Segment就允许同一时刻多少个线程存放数据,这样增加了并发能力。

​ 【3】锁粗化:
​ 大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度;

​ 假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;

​ 【4】使用读写锁:

​ ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可并发读,写操作使用写锁,只能单线程写;

​ 【5】使用CAS:

​ 如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用cas效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用volatiled+cas操作会是非常高效的选择;

6、系统层面锁优化

自适应自旋锁:

​ 自旋锁可以避免等待竞争锁进入阻塞挂起状态被唤醒造成的内核态和用户态之间的切换的损耗,它们只需要等一等(自旋),但是如果锁被其他线程长时间占用,一直不释放CPU,死等会带来更多的性能开销;自旋次数默认值是10

​ 对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点

锁消除:

​ 锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。Netty中无锁化设计pipeline中channelhandler会进行锁消除的优化。

锁升级:

偏向锁:

​ 如果线程已经占有这个锁,当他在次试图去获取这个锁的时候,他会已最快的方式去拿到这个锁,而不需要在进行一些monitor操作,因为在大部分情况下是没有竞争的,所以使用偏向锁是可以提高性能的;

轻量级锁:

​ 在竞争不激烈的情况下,通过CAS避免线程上下文切换,可以显著的提高性能。

重量级锁:

​ 重量级锁的加锁、解锁过程造成的损耗是固定的,重量级锁适合于竞争激烈、高并发、同步块执行时间长的情况。

7、ThreadLocal原理

ThreadLocal简介:

​ 通常情况下,我们创建的变量是可以被任何⼀个线程访问并修改的。如果想实现每⼀个线程都有⾃⼰的
专属本地变量该如何解决呢? JDK中提供的 ThreadLocal 类正是为了解决这样的问题。类似操作系统中的TLAB

原理:

​ 首先 ThreadLocal 是一个泛型类,保证可以接受任何类型的对象。因为一个线程内可以存在多个 ThreadLocal 对象,所以其实是 ThreadLocal 内部维护了一个 Map ,是 ThreadLocal 实现的一个叫做 ThreadLocalMap 的静态内部类。

​ 最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。

​ 我们使用的 get()、set() 方法其实都是调用了这个ThreadLocalMap类对应的 get()、set() 方法。例如下面的

如何使用:

​ 1)存储用户Session

1
private static final ThreadLocal threadSession = new ThreadLocal();

​ 2)解决线程安全的问题

1
private static ThreadLocal<SimpleDateFormat> format1 = new ThreadLocal<SimpleDateFormat>()

ThreadLocal内存泄漏的场景

​ 实际上 ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,⽽ value 是强引⽤。弱引用的特点是,如果这个对象持有弱引用,那么在下一次垃圾回收的时候必然会被清理掉。

​ 所以如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候会被清理掉的,这样一来 ThreadLocalMap中使用这个 ThreadLocal 的 key 也会被清理掉。但是,value 是强引用,不会被清理,这样一来就会出现 key 为 null 的 value。 假如我们不做任何措施的话,value 永远⽆法被GC 回收,如果线程长时间不被销毁,可能会产⽣内存泄露。

img

​ ThreadLocalMap实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。如果说会出现内存泄漏,那只有在出现了 key 为 null 的记录后,没有手动调用 remove() 方法,并且之后也不再调用 get()、set()、remove() 方法的情况下。因此使⽤完ThreadLocal ⽅法后,最好⼿动调⽤ remove() ⽅法

8、HashMap线程安全

死循环造成 CPU 100%

​ HashMap 有可能会发生死循环并且造成 CPU 100% ,这种情况发生最主要的原因就是在扩容的时候,也就是内部新建新的 HashMap 的时候,扩容的逻辑会反转散列桶中的节点顺序,当有多个线程同时进行扩容的时候,由于 HashMap 并非线程安全的,所以如果两个线程同时反转的话,便可能形成一个循环,并且这种循环是链表的循环,相当于 A 节点指向 B 节点,B 节点又指回到 A 节点,这样一来,在下一次想要获取该 key 所对应的 value 的时候,便会在遍历链表的时候发生永远无法遍历结束的情况,也就发生 CPU 100% 的情况。

​ 所以综上所述,HashMap 是线程不安全的,在多线程使用场景中推荐使用线程安全同时性能比较好的 ConcurrentHashMap。

9、String不可变原因

  1. 可以使用字符串常量池,多次创建同样的字符串会指向同一个内存地址

  2. 可以很方便地用作 HashMap 的 key。通常建议把不可变对象作为 HashMap的 key

  3. hashCode生成后就不会改变,使用时无需重新计算

  4. 线程安全,因为具备不变性的对象一定是线程安全的

内存模型

​ Java 内存模型(Java Memory Model,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

img

​ JMM 是一种规范,是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性。

原子性:

​ 在 Java 中,为了保证原子性,提供了两个高级的字节码指令 Monitorenter 和 Monitorexit。这两个字节码,在 Java 中对应的关键字就是 Synchronized。因此,在 Java 中可以使用 Synchronized 来保证方法和代码块内的操作是原子性的。

可见性:

​ Java 中的 Volatile 关键字修饰的变量在被修改后可以立即同步到主内存。被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用 Volatile 来保证多线程操作时变量的可见性。除了 Volatile,Java 中的 Synchronized 和 Final 两个关键字也可以实现可见性。只不过实现方式不同

有序性

​ 在 Java 中,可以使用 Synchronized 和 Volatile 来保证多线程之间操作的有序性。区别:Volatile 禁止指令重排。Synchronized 保证同一时刻只允许一条线程操作。

1、volatile底层实现

作用:

​ 保证数据的“可见性”:被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。

​ 禁止指令重排:在多线程操作情况下,指令重排会导致计算结果不一致

底层实现:

​ “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

  lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;

  2)它会强制将对缓存的修改操作立即写入主存;

  3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

单例模式中volatile的作用:

防止代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Singleton{
private volatile static Singleton instance = null; //禁止指令重排
private Singleton() {

}
public static Singleton getInstance() {
if(instance==null) { //减少加锁的损耗
synchronized (Singleton.class) {
if(instance==null) //确认是否初始化完成
instance = new Singleton();
}
}
return instance;
}
}

2、AQS思想

​ AQS的全称为(AbstractQueuedSynchronizer)抽象的队列式的同步器,是⼀个⽤来构建锁和同步器的框架,使⽤AQS能简单且⾼效地构造出应⽤⼴泛的⼤量的同步器,如:基于AQS实现的lock, CountDownLatch、CyclicBarrier、Semaphore需解决的问题:

1
2
3
状态的原子性管理
线程的阻塞与解除阻塞
队列的管理

​ AQS核⼼思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的⼯作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占⽤,那么就需要⼀套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是⽤CLH(虚拟的双向队列)队列锁实现的,即将暂时获取不到锁的线程加⼊到队列中。

lock:

​ 是一种可重入锁,除了能完成 synchronized 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。默认为非公平锁,但可以初始化为公平锁; 通过方法 lock()与 unlock()来进行加锁与解锁操作;

CountDownLatch:

​ 通过计数法(倒计时器),让一些线程堵塞直到另一个线程完成一系列操作后才被唤醒;该⼯具通常⽤来控制线程等待,它可以让某⼀个线程等待直到倒计时结束,再开始执⾏。具体可以使用countDownLatch.await()来等待结果。多用于多线程信息汇总。

CompletableFuture:

​ 通过设置参数,可以完成CountDownLatch同样的多平台响应问题,但是可以针对其中部分返回结果做更加灵活的展示。

CyclicBarrier:

​ 字面意思是可循环(Cyclic)使用的屏障(Barrier)。他要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活,线程进入屏障通过CyclicBarrier的await()方法。可以用于批量发送消息队列信息、异步限流。

Semaphore:

​ 信号量主要用于两个目的,一个是用于多个共享资源的互斥作用,另一个用于并发线程数的控制。SpringHystrix限流的思想

3、happens-before

​ 用来描述和可见性相关问题:如果第一个操作 happens-before 第二个操作,那么我们就说第一个操作对于第二个操作是可见的

​ 常见的happens-before:volatile 、锁、线程生命周期。

四、MySQL篇

WhyMysql?

NoSQL数据库四大家族

  • 列存储 Hbase
  • K-V存储 Redis
  • 图像存储 Neo4j
  • 文档存储 MongoDB

云存储OSS

海量Aerospike

​ Aerospike(简称AS)是一个分布式,可扩展的键值存储的NoSQL数据库。T级别大数据高并发的结构化数据存储,采用混合架构,索引存储在内存中,而数据可存储在机械硬盘(HDD)或固态硬盘(SSD) 上,读写操作达微妙级,99%的响应可在1毫秒内实现。

Aerospike Redis
类型 Nosql数据库 缓存
线程数 多线程 单线程
数据分片 自动处理相当于分片 提供分片算法、平衡各分片数据
数据扩容 动态增加数据卷平衡流量 需停机
数据同步 设置复制因子后可以透明的完成故障转移 手动故障转移和数据同步
载体 内存存储索引+SSD存储数据 内存

​ Aerospike作为一个大容量的NoSql解决方案,适合对容量要求比较大,QPS相对低一些的场景,主要用在广告行业,个性化推荐厂告是建立在了和掌握消费者独特的偏好和习性的基础之上,对消费者的购买需求做出准确的预测或引导,在合适的位置、合适的时间,以合适的形式向消费者呈现与其需求高度吻合的广告,以此来促进用户的消费行为。

image-20210103170039711

​ (ETL数据仓库技术)抽取(extract)、转换(transform)、加载(load)

  • 用户行为日志收集系统收集日志之后推送到ETL做数据的清洗和转换

  • 把ETL过后的数据发送到推荐引擎计算每个消费者的推荐结果,其中推荐逻辑包括规则和算法两部分

  • 收集用户最近浏览、最长停留等特征,分析商品相似性、用户相似性、相似性等算法。

  • 把推荐引擎的结果存入Aerospike集群中,并提供给广告投放引擎实时获取

    分别通过HDFS和HBASE对日志进行离线和实时的分析,然后把用户画像的标签(tag : 程序猿、宅男…)结果存入高性能的Nosql数据库Aerospike中,同时把数据备份到异地数据中心。前端广告投放请求通过决策引擎(投放引擎)向用户画像数据库中读取相应的用户画像数据,然后根据竞价算法出价进行竞价。竞价成功之后就可以展现广告了。而在竞价成功之后,具体给用户展现什么样的广告,就是有上面说的个性化推荐广告来完成的。

Aerospike Mysql
库名 Namespace Database
表名 Set Table
记录 Bin Column
字段 Record Row
索引 key 、 pk 、kv pk

图谱Neo4j

Neo4j是一个开源基于java开发的图形noSql数据库,它将结构化数据存储在图中而不是表中。它是一个嵌入式的、基于磁盘的、具备完全的事务特性的Java持久化引擎。程序数据是在一个面向对象的、灵活的网络结构下,而不是严格的表中,但具备完全的事务特性、企业级的数据库的所有好处。

一种基于图的数据结构,由节点(Node)和边(Edge)组成。其中节点即实体,由一个全局唯一的ID标示,边就是关系用于连接两个节点。通俗地讲,知识图谱就是把所有不同种类的信息,连接在一起而得到的一个关系网络。知识图谱提供了从“关系”的角度去分析问题的能力。

互联网、大数据的背景下,谷歌、百度、搜狗等搜索引擎纷纷基于该背景,创建自己的知识图Knowledge Graph(谷歌)、知心(百度)知立方(搜狗),主要用于改进搜索质量。

自己项目主要用作好友推荐,图数据库(Graph database)指的是以图数据结构的形式来存储和查询数据的数据库。关系图谱中,关系的组织形式采用的就是图结构,所以非常适合用图库进行存储。

  • image-20210103191540372

    优势总结:

  • 性能上,使用cql查询,对长程关系的查询速度快

  • 擅于发现隐藏的关系,例如通过判断图上两点之间有没有走的通的路径,就可以发现事物间的关联

image-20210103192653004

1
2
3
4
5
6
7
// 查询三层级关系节点如下:with可以将前面查询结果作为后面查询条件
match (na:Person)-[re]-(nb:Person) where na.name="林婉儿" WITH na,re,nb match (nb:Person)- [re2:Friends]->(nc:Person) return na,re,nb,re2,nc
// 直接拼接关系节点查询
match data=(na:Person{name:"范闲"})-[re]->(nb:Person)-[re2]->(nc:Person) return data
// 使用深度运算符
显然使用以上方式比较繁琐,可变数量的关系->节点可以使用-[:TYPE*minHops..maxHops]-。
match data=(na:Person{name:"范闲"})-[*1..2]-(nb:Person) return data

文档MongoDB

MongoDB 是一个基于分布式文件存储的数据库,是非关系数据库中功能最丰富、最像关系数据库的。在高负载的情况下,通过添加更多的节点,可以保证服务器性能。由 C++ 编写,可以为 WEB 应用提供可扩展、高性能、易部署的数据存储解决方案。

image-20210103194830654

什么是BSON

{key:value,key2:value2}和Json类似,是一种二进制形式的存储格式,支持内嵌的文档对象和数组对象,但是BSON有JSON没有的一些数据类型,比如 value包括字符串,double,Array,DateBSON可以做为网络数据交换的一种存储形式,它的优点是灵活性高,但它的缺点是空间利用率不是很理想。

BSON有三个特点:轻量性、可遍历性、高效性

1
2
3
4
5
6
/* 查询 find() 方法可以传入多个键(key),每个键(key)以逗号隔开*/
db.collection.find({key1:value1, key2:value2}).pretty()
/* 更新 $set :设置字段值 $unset :删除指定字段 $inc:对修改的值进行自增*/
db.collection.update({where},{$set:{字段名:值}},{multi:true})
/* 删除 justOne :如果设为true,只删除一个文档,默认false,删除所有匹配条件的文档*/
db.collection.remove({where}, {justOne: <boolean>, writeConcern: <回执> } )

优点:

  • 文档结构的存储方式,能够更便捷的获取数据。

    对于一个层级式的数据结构来说,使用扁平式的,表状的结构来查询保存数据非常的困难。

  • 内置GridFS,支持大容量的存储。

    GridFS是一个出色的分布式文件系统,支持海量的数据存储,满足对大数据集的快速范围查询。

  • 性能优越

    千万级别的文档对象,近10G的数据,对有索引的ID的查询 不会比mysql慢,而对非索引字段的查询,则是全面胜出。 mysql实际无法胜任大数据量下任意字段的查询,而mongodb的查询性能实在牛逼。写入性能同样很令人满意,同样写入百万级别的数据,mongodb基本10分钟以下可以解决。

缺点:

  • 不支持事务
  • 磁盘占用空间大

MySQL 8.0 版本

1. 性能:MySQL 8.0 的速度要比 MySQL 5.7 快 2 倍。

2. NoSQL:MySQL 从 5.7 版本开始提供 NoSQL 存储功能,在 8.0 版本中nosql得到了更大的改进。

3. 窗口函数:实现若干新的查询方式。窗口函数与 SUM()、COUNT() 这种集合函数类似,但它不会将多行查询结果合并为一行,而是将结果放回多行当中,即窗口函数不需要 GROUP BY。

4. 隐藏索引:在 MySQL 8.0 中,索引可以被“隐藏”和“显示”。当对索引进行隐藏时,它不会被查询优化器所使用。我们可以使用这个特性用于性能调试,例如我们先隐藏一个索引,然后观察其对数据库的影响。如果数据库性能有所下降,说明这个索引是有用的,然后将其“恢复显示”即可;如果数据库性能看不出变化,说明这个索引是多余的,可以考虑删掉。

云存储

OSS 自建
可靠性 可用性不低于99.995%
数据设计持久性不低于99.9999999999%(12个9)
受限于硬件可靠性,易出问题,一旦出现磁盘坏道,容易出现不可逆转的数据丢失。人工数据恢复困难、耗时、耗力。
安全 服务端加密、客户端加密、防盗链、IP黑白名单等。多用户资源隔离机制,支持异地容灾机制。 需要另外购买清洗和黑洞设备。需要单独实现安全机制。
成本 多线BGP骨干网络,无带宽限制,上行流量免费。无需运维人员与托管费用,0成本运维。 单线或双线接入速度慢,有带宽限制,峰值时期需人工扩容。需专人运维,成本高。

使用步骤

​ 1、开通服务

​ 2、创建存储空间

​ 3、上传文件、下载文件、删除文件

​ 4、域名绑定、日志记录

​ 5、根据开放接口进行鉴权访问

功能

​ 图片编辑(裁剪、模糊、水印)

​ 视频截图

​ 音频转码、视频修复

CDN加速

​ 对象存储OSS与阿里云CDN服务结合,可优化静态热点文件下载加速的场景(即同一地区大量用户同时下载同一个静态文件的场景)。可以将OSS的存储空间(Bucket)作为源站,利用阿里云CDN将源内容发布到边缘节点。当大量终端用户重复访问同一文件时,可以直接从边缘节点获取已缓存的数据,提高访问的响应速度

FastDFS

开源的轻量级分布式文件系统。它对文件进行管理,功能包括:文件存储、文件同步、文件访问(文件上传、文件下载)等,解决了大容量存储和负载均衡的问题。使用FastDFS很容易搭建一套高性能的文件服务器集群提供文件上传、下载等服务。如相册网站、视频网站

扩展能力: 支持水平扩展,可以动态扩容;

高可用性: 一是整个文件系统的可用性,二是数据的完整和一致性;

弹性存储: 可以根据业务需要灵活地增删存储池中的资源,而不需要中断系统运行。

image-20210107221022658

特性

  • 和流行的web server无缝衔接,FastDFS已提供apache和nginx扩展模块
  • 文件ID由FastDFS生成,作为文件访问凭证,FastDFS不需要传统的name server
  • 分组存储,灵活简洁、对等结构,不存在单点
  • 文件不分块存储,上传的文件和OS文件系统中的文件一一对应
  • 中、小文件均可以很好支持,支持海量小文件存储
  • 支持相同内容的文件只保存一份,节约磁盘空间
  • 支持多块磁盘,支持单盘数据恢复
  • 支持在线扩容 支持主从文件
  • 下载文件支持多线程方式,支持断点续传

组成

  • 客户端(client)

    通过专有接口,使用TCP/IP协议与跟踪器服务器或存储节点进行数据交互。

  • 跟踪器(tracker)

    Trackerserver作用是负载均衡和调度,通过Tracker server在文件上传时可以根据策略找到文件上传的地址。Tracker在访问上起负载均衡的作用。

  • 存储节点(storage)

    Storageserver作用是文件存储,客户端上传的文件最终存储在Storage服务器上,Storage server没有实现自己的文件系统而是利用操作系统的文件系统来管理文件。存储节点中的服务器均可以随时增加或下线而不会影响线上服务

上传

image-20210107222155291

下载

image-20210107222312338

断点续传

​ 续传涉及到的文件大小MD5不会改变。续传流程与文件上传类似,先定位到源storage,完成完整或部分上传,再通过binlog进行同group内server文件同步

配置优化

配置文件:tracker.conf 和 storage.conf

1
2
3
4
5
6
7
8
// FastDFS采用内存池的做法。 
// v5.04对预分配采用增量方式,tracker一次预分配 1024个,storage一次预分配256个。
max_connections = 10240
// 根据实际需要将 max_connections 设置为一个较大的数值,比如 10240 甚至更大。
// 同时需要将一个进程允许打开的最大文件数调大
vi /etc/security/limits.conf 重启系统生效
* soft nofile 65535
* hard nofile 65535
1
2
3
work_threads = 4 
// 说明:为了避免CPU上下文切换的开销,以及不必要的资源消耗,不建议将本参数设置得过大。
// 公式为: work_threads + (reader_threads + writer_threads) = CPU数
1
2
3
4
5
// 对于单盘挂载方式,磁盘读写线程分 别设置为 1即可 
// 如果磁盘做了RAID,那么需要酌情加大读写线程数,这样才能最大程度地发挥磁盘性能
disk_rw_separated:磁盘读写是否分离
disk_reader_threads:单个磁盘读线程数
disk_writer_threads:单个磁盘写线程数

避免重复

​ 如何避免文件重复上传 解决方案 上传成功后计算文件对应的MD5然后存入MySQL,添加文件时把文件MD5和之前存入MYSQL中的存储的信息对比 。DigestUtils.md5DigestAsHex(bytes)。

事务

1、事务4大特性

事务4大特性:原子性、一致性、隔离性、持久性

原⼦性: 事务是最⼩的执⾏单位,不允许分割。事务的原⼦性确保动作要么全部完成,要么全不执行

一致性: 执⾏事务前后,数据保持⼀致,多个事务对同⼀个数据读取的结果是相同的;

隔离性: 并发访问数据库时,⼀个⽤户的事务不被其他事务所⼲扰,各并发事务之间数据库是独⽴的;

持久性: ⼀个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发⽣故障也不应该对其有任何影响。

实现保证:

​ MySQL的存储引擎InnoDB使用重做日志保证一致性与持久性,回滚日志保证原子性,使用各种锁来保证隔离性。

2、事务隔离级别

读未提交:最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。

读已提交:允许读取并发事务已经提交的数据,可以阻⽌脏读,但是幻读或不可重复读仍有可能发⽣。

可重复读:同⼀字段的多次读取结果都是⼀致的,除⾮数据是被本身事务⾃⼰所修改,可以阻⽌脏读和不可重复读,会有幻读。

串行化:最⾼的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执⾏,这样事务之间就完全不可能产⽣⼲扰。

隔离级别 并发问题
读未提交 可能会导致脏读、幻读或不可重复读
读已提交 可能会导致幻读或不可重复读
可重复读 可能会导致幻读
可串行化 不会产⽣⼲扰

3、默认隔离级别-RR

默认隔离级别:可重复读;

​ 同⼀字段的多次读取结果都是⼀致的,除⾮数据是被本身事务⾃⼰所修改;

​ 可重复读是有可能出现幻读的,如果要保证绝对的安全只能把隔离级别设置成SERIALIZABLE;这样所有事务都只能顺序执行,自然不会因为并发有什么影响了,但是性能会下降许多。

​ 第二种方式,使用MVCC解决快照读幻读问题(如简单select),读取的不是最新的数据。维护一个字段作为version,这样可以控制到每次只能有一个人更新一个版本。

1
2
select id from table_xx where id = ? and version = V
update id from table_xx where id = ? and version = V+1

​ 第三种方式,如果需要读最新的数据,可以通过GapLock+Next-KeyLock可以解决当前读幻读问题

1
2
select id from table_xx where id > 100 for update;
select id from table_xx where id > 100 lock in share mode;

4、RR和RC使用场景

​ 事务隔离级别RC(read commit)和RR(repeatable read)两种事务隔离级别基于多版本并发控制MVCC(multi-version concurrency control)来实现。

RC RR
实现 多条查询语句会创建多个不同的ReadView 仅需要一个版本的ReadView
粒度 语句级读一致性 事务级读一致性
准确性 每次语句执行时间点的数据 第一条语句执行时间点的数据

5、行锁,表锁,意向锁

InnoDB⽀持⾏级锁(row-level locking)和表级锁,默认为⾏级锁

​ InnoDB按照不同的分类的锁:

​ 共享/排它锁(Shared and Exclusive Locks):行级别锁,

​ 意向锁(Intention Locks),表级别锁

​ 间隙锁(Gap Locks),锁定一个区间

​ 记录锁(Record Locks),锁定一个行记录

表级锁:(串行化)

​ Mysql中锁定 粒度最大的一种锁,对当前操作的整张表加锁,实现简单 ,资源消耗也比较少,加锁快,不会出现死锁 。其锁定粒度最大,触发锁冲突的概率最高,并发度最低,MyISAM和 InnoDB引擎都支持表级锁。

行级锁:(RR、RC)

​ Mysql中锁定 粒度最小 的一种锁,只针对当前操作的行进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。 InnoDB支持的行级锁,包括如下几种:

记录锁(Record Lock): 对索引项加锁,锁定符合条件的行。其他事务不能修改和删除加锁项;

间隙锁(Gap Lock): 对索引项之间的“间隙”加锁,锁定记录的范围,不包含索引项本身,其他事务不能在锁范围内插入数据。

Next-key Lock: 锁定索引项本身和索引范围。即Record Lock和Gap Lock的结合。可解决幻读问题。

InnoDB 支持多粒度锁(multiple granularity locking),它允许行级锁与表级锁共存,而意向锁就是其中的一种表锁。

共享锁( shared lock, S )锁允许持有锁读取行的事务。加锁时将自己和子节点全加S锁,父节点直到表头全加IS锁

排他锁( exclusive lock, X )锁允许持有锁修改行的事务。 加锁时将自己和子节点全加X锁,父节点直到表头全加IX锁

意向共享锁(intention shared lock, IS):事务有意向对表中的某些行加共享锁(S锁)

意向排他锁(intention exclusive lock, IX):事务有意向对表中的某些行加排他锁(X锁)

互斥性 共享锁(S) 排它锁(X) 意向共享锁IS 意向排他锁IX
共享锁(S)
排它锁(X)
意向共享锁IS
意向排他锁IX

6、MVCC多版本并发控制

​ MVCC是一种多版本并发控制机制,通过事务的可见性看到自己预期的数据,能降低其系统开销.(RC和RR级别工作)

​ InnoDB的MVCC,是通过在每行记录后面保存系统版本号(可以理解为事务的ID),每开始一个新的事务,系统版本号就会自动递增,事务开始时刻的系统版本号会作为事务的ID。这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的,防止幻读的产生。

​ 1.MVCC手段只适用于Msyql隔离级别中的读已提交(Read committed)和可重复读(Repeatable Read).

​ 2.Read uncimmitted由于存在脏读,即能读到未提交事务的数据行,所以不适用MVCC.

​ 3.简单的select快照度不会加锁,删改及select for update等需要当前读的场景会加锁

​ 原因是MVCC的创建版本和删除版本只要在事务提交后才会产生。客观上,mysql使用的是乐观锁的一整实现方式,就是每行都有版本号,保存时根据版本号决定是否成功。Innodb的MVCC使用到的快照存储在Undo日志中,该日志通过回滚指针把一个数据行所有快照连接起来。

版本链

在InnoDB引擎表中,它的聚簇索引记录中有两个必要的隐藏列:

trx_id

这个id用来存储的每次对某条聚簇索引记录进行修改的时候的事务id。

roll_pointer

每次对哪条聚簇索引记录有修改的时候,都会把老版本写入undo日志中。这个roll_pointer就是存了一个指针,它指向这条聚簇索引记录的上一个版本的位置,通过它来获得上一个版本的记录信息。(注意插入操作的undo日志没有这个属性,因为它没有老版本)

每次修改都会在版本链中记录。SELECT可以去版本链中拿记录,这就实现了读-写,写-读的并发执行,提升了系统的性能。

索引

1、Innodb和Myisam引擎

Myisam:支持表锁,适合读密集的场景,不支持外键,不支持事务,索引与数据在不同的文件

Innodb:支持行、表锁,默认为行锁,适合并发场景,支持外键,支持事务,索引与数据同一文件

2、哈希索引

​ 哈希索引用索引列的值计算该值的hashCode,然后在hashCode相应的位置存执该值所在行数据的物理位置,因为使用散列算法,因此访问速度非常快,但是一个值只能对应一个hashCode,而且是散列的分布方式,因此哈希索引不支持范围查找和排序的功能

3、B+树索引

优点:

​ B+树的磁盘读写代价低,更少的查询次数,查询效率更加稳定,有利于对数据库的扫描

​ B+树是B树的升级版,B+树只有叶节点存放数据,其余节点用来索引。索引节点可以全部加入内存,增加查询效率,叶子节点可以做双向链表,从而提高范围查找的效率,增加的索引的范围

​ 在大规模数据存储的时候,红黑树往往出现由于树的深度过大而造成磁盘IO读写过于频繁,进而导致效率低下的情况。所以,只要我们通过某种较好的树结构减少树的结构尽量减少树的高度,B树与B+树可以有多个子女,从几十到上千,可以降低树的高度。

磁盘预读原理:将一个节点的大小设为等于一个页,这样每个节点只需要一次I/O就可以完全载入。为了达到这个目的,在实际实现B-Tree还需要使用如下技巧:每次新建节点时,直接申请一个页的空间,这样就保证一个节点物理上也存储在一个页里,加之计算机存储分配都是按页对齐的,就实现了一个node只需一次I/O。

4、创建索引

1
2
3
4
5
6
7
8
9
CREATE  [UNIQUE | FULLTEXT]  INDEX  索引名 ON  表名(字段名) [USING 索引方法];

说明:
UNIQUE:可选。表示索引为唯一性索引。
FULLTEXT:可选。表示索引为全文索引。
INDEX和KEY:用于指定字段为索引,两者选择其中之一就可以了,作用是一样的。
索引名:可选。给创建的索引取一个新名称。
字段名1:指定索引对应的字段的名称,该字段必须是前面定义好的字段。
注:索引方法默认使用B+TREE。

5、聚簇索引和非聚簇索引

聚簇索引:将数据存储与索引放到了一块,索引结构的叶子节点保存了行数据(主键索引

非聚簇索引:将数据与索引分开存储,索引结构的叶子节点指向了数据对应的位置(辅助索引

​ 聚簇索引的叶子节点就是数据节点,而非聚簇索引的叶子节点仍然是索引节点,只不过有指向对应数据块的指针。

6、最左前缀问题

​ 最左前缀原则主要使用在联合索引中,联合索引的B+Tree是按照第一个关键字进行索引排列的。

​ 联合索引的底层是一颗B+树,只不过联合索引的B+树节点中存储的是键值。由于构建一棵B+树只能根据一个值来确定索引关系,所以数据库依赖联合索引最左的字段来构建。

​ 采用>、<等进行匹配都会导致后面的列无法走索引,因为通过以上方式匹配到的数据是不可知的。

SQL查询

1、SQL语句的执行过程

查询语句:

1
select * from student  A where A.age='18' and A.name='张三';
img

结合上面的说明,我们分析下这个语句的执行流程:

①通过客户端/服务器通信协议与 MySQL 建立连接。并查询是否有权限

②Mysql8.0之前开看是否开启缓存,开启了 Query Cache 且命中完全相同的 SQL 语句,则将查询结果直接返回给客户端;

③由解析器进行语法语义解析,并生成解析树。如查询是select、表名tb_student、条件是id=’1’

④查询优化器生成执行计划。根据索引看看是否可以优化

⑤查询执行引擎执行 SQL 语句,根据存储引擎类型,得到查询结果。若开启了 Query Cache,则缓存,否则直接返回。

2、回表查询和覆盖索引

普通索引(唯一索引+联合索引+全文索引)需要扫描两遍索引树

(1)先通过普通索引定位到主键值id=5;

(2)在通过聚集索引定位到行记录;

这就是所谓的回表查询,先定位主键值,再定位行记录,它的性能较扫一遍索引树更低。

覆盖索引:主键索引==聚簇索引==覆盖索引

​ 覆盖索引(covering index ,或称为索引覆盖)就是把单列的非主键 索引 修改为多字段的联合索引, 在一棵索引数上 就找到了想要的数据, 不需要去主键索引树上,再检索一遍 这个现象,称之为 索引覆盖.

​ 如果where条件的列和返回的数据在一个索引中,那么不需要回查表,那么就叫覆盖索引。

实现覆盖索引:常见的方法是,将被查询的字段,建立到联合索引里去。

3、Explain及优化

参考:https://www.jianshu.com/p/8fab76bbf448

1
2
3
4
5
6
7
mysql> explain select * from staff;
+----+-------------+-------+------+---------------+------+---------+------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+------+-------+
| 1 | SIMPLE | staff | ALL | NULL | 索引 | NULL | NULL | 2 | NULL |
+----+-------------+-------+------+---------------+------+---------+------+------+-------+
1 row in set

索引优化:

​ ①最左前缀索引:like只用于’string%’,语句中的=和in会动态调整顺序

​ ②唯一索引:唯一键区分度在0.1以上

​ 区分度的公式是count(distinct col)/count(*),表示字段不重复的比例,比例越大我们扫描的记录数越少,唯一键的区分度是1,而一些状态、性别字段可能在大数据面前区分度就 是0,那可能有人会问,这个比例有什么经验值吗?使用场景不同,这个值也很难确定,一般需要join的字段我们都要求是0.1以上,即平均1条扫描10条 记录

​ ③无法使用索引:!= 、is null 、 or、>< 、(5.7以后根据数量自动判定)in 、not in

​ ④联合索引:避免select * ,查询列使用覆盖索引

1
2
SELECT uid From user Where gid = 2 order by ctime asc limit 10
ALTER TABLE user add index idx_gid_ctime_uid(gid,ctime,uid) #创建联合覆盖索引,避免回表查询

语句优化:

​ ①char固定长度查询效率高,varchar第一个字节记录数据长度

​ ②应该针对Explain中Rows增加索引

​ ③group/order by字段均会涉及索引

​ ④Limit中分页查询会随着start值增大而变缓慢,通过子查询+表连接解决

1
2
3
select * from mytbl order by id limit 100000,10  改进后的SQL语句如下:
select * from mytbl where id >= ( select id from mytbl order by id limit 100000,1 ) limit 10
select * from mytbl inner ori join (select id from mytbl order by id limit 100000,10) as tmp on tmp.id=ori.id;

​ ⑤count会进行全表扫描,如果估算可以使用explain

​ ⑥delete删除表时会增加大量undo和redo日志, 确定删除可使用trancate

表结构优化:

​ ①单库不超过200张表

​ ②单表不超过500w数据

​ ③单表不超过40列

​ ④单表索引不超过5个

数据库范式

​ ①第一范式(1NF)列不可分割

​ ②第二范式(2NF)属性完全依赖于主键 [ 消除部分子函数依赖 ]

​ ③第三范式(3NF)属性不依赖于其它非主属性 [ 消除传递依赖 ]

配置优化:

​ 配置连接数、禁用Swap、增加内存、升级SSD硬盘

4、JOIN查询

left join(左联接) 返回包括左表中的所有记录和右表中关联字段相等的记录

right join(右联接) 返回包括右表中的所有记录和左表中关联字段相等的记录

inner join(等值连接) 只返回两个表中关联字段相等的行

集群

1、主从复制过程

MySQl主从复制:

  • 原理:将主服务器的binlog日志复制到从服务器上执行一遍,达到主从数据的一致状态。
  • 过程:从库开启一个I/O线程,向主库请求Binlog日志。主节点开启一个binlog dump线程,检查自己的二进制日志,并发送给从节点;从库将接收到的数据保存到中继日志(Relay log)中,另外开启一个SQL线程,把Relay中的操作在自身机器上执行一遍
  • 优点
    • 作为备用数据库,并且不影响业务
    • 可做读写分离,一个写库,一个或多个读库,在不同的服务器上,充分发挥服务器和数据库的性能,但要保证数据的一致性

binlog记录格式:statement、row、mixed

​ 基于语句statement的复制、基于行row的复制、基于语句和行(mix)的复制。其中基于row的复制方式更能保证主从库数据的一致性,但日志量较大,在设置时考虑磁盘的空间问题

2、数据一致性问题

“主从复制有延时”,这个延时期间读取从库,可能读到不一致的数据。

缓存记录写key法:

​ 在cache里记录哪些记录发生过的写请求,来路由读主库还是读从库

异步复制:

​ 在异步复制中,主库执行完操作后,写入binlog日志后,就返回客户端,这一动作就结束了,并不会验证从库有没有收到,完不完整,所以这样可能会造成数据的不一致

半同步复制:

​ 当主库每提交一个事务后,不会立即返回,而是等待其中一个从库接收到Binlog并成功写入Relay-log中才返回客户端,通过一份在主库的Binlog,另一份在其中一个从库的Relay-log,可以保证了数据的安全性和一致性。

全同步复制:

​ 指当主库执行完一个事务,所有的从库都执行了该事务才返回给客户端。因为需要等待所有从库执行完该事务才能返回,所以全同步复制的性能必然会收到严重的影响

3、集群架构

Keepalived + VIP + MySQL 主从/双主

​ 当写节点 Master db1 出现故障时,由 MMM Monitor 或 Keepalived 触发切换脚本,将 VIP 漂移到可用的 Master db2 上。当出现网络抖动或网络分区时,MMM Monitor 会误判,严重时来回切换写 VIP 导致集群双写,当数据复制延迟时,应用程序会出现数据错乱或数据冲突的故障。有效避免单点失效的架构就是采用共享存储,单点故障切换可以通过分布式哨兵系统监控。

img

架构选型:MMM 集群 -> MHA集群 -> MHA+Arksentinel。

img

4、故障转移和恢复

转移方式及恢复方法

1. 虚拟IP或DNS服务 (Keepalived +VIP/DNS  和 MMM 架构)

​ 问题:在虚拟 IP 运维过程中,刷新ARP过程中有时会出现一个 VIP 绑定在多台服务器同时提供连接的问题。这也是为什么要避免使用 Keepalived+VIP 和 MMM 架构的原因之一,因为它处理不了这类问题而导致集群多点写入。

2. 提升备库为主库(MHA、QMHA)

​ 尝试将原 Master 设置 read_only 为 on,避免集群多点写入。借助 binlog server 保留 Master 的 Binlog;当出现数据延迟时,再提升 Slave 为新 Master 之前需要进行数据补齐,否则会丢失数据。

面试题

分库分表

如何进行分库分表

分表用户id进行分表,每个表控制在300万数据。

分库根据业务场景和地域分库,每个库并发不超过2000

Sharding-jdbc 这种 client 层方案的优点在于不用部署,运维成本低,不需要代理层的二次转发请求,性能很高,但是各个系统都需要耦合 Sharding-jdbc 的依赖,升级比较麻烦

Mycat 这种 proxy 层方案的缺点在于需要部署,自己运维一套中间件,运维成本高,但是好处在于对于各个项目是透明的,如果遇到升级之类的都是自己中间件那里搞就行了

水平拆分:一个表放到多个库,分担高并发,加快查询速度

  • id保证业务在关联多张表时可以在同一库上操作
  • range方便扩容和数据统计
  • hash可以使得数据更加平均

垂直拆分:一个表拆成多个表,可以将一些冷数据拆分到冗余库中

不是写瓶颈优先进行分表

  • 分库数据间的数据无法再通过数据库直接查询了。会产生深分页的问题

  • 分库越多,出现问题的可能性越大,维护成本也变得更高。

  • 分库后无法保障跨库间事务,只能借助其他中间件实现最终一致性。

分库首先需考虑满足业务最核心的场景:

1、订单数据按用户分库,可以提升用户的全流程体验

2、超级客户导致数据倾斜可以使用最细粒度唯一标识进行hash拆分

3、按照最细粒度如订单号拆分以后,数据库就无法进行单库排重了

三个问题:

  • 富查询:采用分库分表之后,如何满足跨越分库的查询?使用ES的宽表

    借助分库网关+分库业务虽然能够实现多维度查询的能力,但整体上性能不佳且对正常的写入请求有一定的影响。业界应对多维度实时查询的最常见方式便是借助 ElasticSearch

  • 数据倾斜:数据分库基础上再进行分表

  • 分布式事务:跨多库的修改及多个微服务间的写操作导致的分布式事务问题?

  • 深分页问题:按游标查询,或者叫每次查询都带上上一次查询经过排序后的最大 ID

如何将老数据进行迁移

双写不中断迁移

  • 线上系统里所有写库的地方,增删改操作,除了对老库增删改,都加上对新库的增删改
  • 系统部署以后,还需要跑程序读老库数据写新库,写的时候需要判断updateTime
  • 循环执行,直至两个库的数据完全一致,最后重新部署分库分表的代码就行了

系统性能的评估及扩容

和家亲目前有1亿用户:场景 10万写并发,100万读并发,60亿数据量

设计时考虑极限情况,32库*32表~64个表,一共1000 ~ 2000张表

  • 支持3万的写并发,配合MQ实现每秒10万的写入速度

  • 读写分离6万读并发,配合分布式缓存每秒100读并发

  • 2000张表每张300万,可以最多写入60亿的数据

  • 32张用户表,支撑亿级用户,后续最多也就扩容一次

动态扩容的步骤

  1. 推荐是 32 库 * 32 表,对于我们公司来说,可能几年都够了。
  2. 配置路由的规则,uid % 32 = 库,uid / 32 % 32 = 表
  3. 扩容的时候,申请增加更多的数据库服务器,呈倍数扩容
  4. 由 DBA 负责将原先数据库服务器的库,迁移到新的数据库服务器上去
  5. 修改一下配置,重新发布系统,上线,原先的路由规则变都不用变
  6. 直接可以基于 n 倍的数据库服务器的资源,继续进行线上系统的提供服务。

如何生成自增的id主键

  • 使用redis可以
  • 并发不高可以单独起一个服务,生成自增id
  • 设置数据库step自增步长可以支撑水平伸缩
  • UUID适合文件名、编号,但是不适合做主键
  • snowflake雪花算法,综合了41时间(ms)、10机器12序列号(ms内自增)

其中机器预留的10bit可以根据自己的业务场景配置

线上故障及优化

更新失败 | 主从同步延时

以前线上确实处理过因为主从同步延时问题而导致的线上的 bug,属于小型的生产事故。

是这个么场景。有个同学是这样写代码逻辑的。先插入一条数据,再把它查出来,然后更新这条数据。在生产环境高峰期,写并发达到了 2000/s,这个时候,主从复制延时大概是在小几十毫秒。线上会发现,每天总有那么一些数据,我们期望更新一些重要的数据状态,但在高峰期时候却没更新。用户跟客服反馈,而客服就会反馈给我们。

我们通过 MySQL 命令:

1
show slave status

查看 Seconds_Behind_Master ,可以看到从库复制主库的数据落后了几 ms。

一般来说,如果主从延迟较为严重,有以下解决方案:

  • 分库,拆分为多个主库,每个主库的写并发就减少了几倍,主从延迟可以忽略不计。
  • 重写代码,写代码的同学,要慎重,插入数据时立马查询可能查不到。
  • 如果确实是存在必须先插入,立马要求就查询到,然后立马就要反过来执行一些操作,对这个查询设置直连主库或者延迟查询。主从复制延迟一般不会超过50ms

应用崩溃 | 分库分表优化

​ 我们有一个线上通行记录的表,由于数据量过大,进行了分库分表,当时分库分表初期经常产生一些问题。典型的就是通行记录查询中使用了深分页,通过一些工具如MAT、Jstack追踪到是由于sharding-jdbc内部引用造成的。

​ 通行记录数据被存放在两个库中。如果没有提供切分键,查询语句就会被分发到所有的数据库中,比如查询语句是 limit 10、offset 1000,最终结果只需要返回 10 条记录,但是数据库中间件要完成这种计算,则需要 (1000+10)*2=2020 条记录来完成这个计算过程。如果 offset 的值过大,使用的内存就会暴涨。虽然 sharding-jdbc 使用归并算法进行了一些优化,但在实际场景中,深分页仍然引起了内存和性能问题。

​ 这种在中间节点进行归并聚合的操作,在分布式框架中非常常见。比如在 ElasticSearch 中,就存在相似的数据获取逻辑,不加限制的深分页,同样会造成 ES 的内存问题。

业界解决方案:

方法一:全局视野法

(1)将order by time offset X limit Y,改写成order by time offset 0 limit X+Y

(2)服务层对得到的N*(X+Y)条数据进行内存排序,内存排序后再取偏移量X后的Y条记录

这种方法随着翻页的进行,性能越来越低。

方法二:业务折衷法-禁止跳页查询

(1)用正常的方法取得第一页数据,并得到第一页记录的time_max

(2)每次翻页,将order by time offset X limit Y,改写成order by time where time>$time_max limit Y

以保证每次只返回一页数据,性能为常量。

方法三:业务折衷法-允许模糊数据

(1)将order by time offset X limit Y,改写成order by time offset X/N limit Y/N

方法四:二次查询法

(1)将order by time offset X limit Y,改写成order by time offset X/N limit Y

(2)找到最小值time_min

(3)between二次查询,order by time between $time_min and $time_i_max

(4)设置虚拟time_min,找到time_min在各个分库的offset,从而得到time_min在全局的offset

(5)得到了time_min在全局的offset,自然得到了全局的offset X limit Y

查询异常 | SQL 调优

分库分表前,有一段用用户名来查询某个用户的 SQL 语句:

1
select * from user where name = "xxx" and community="other";

为了达到动态拼接的效果,这句 SQL 语句被一位同事进行了如下修改。他的本意是,当 name 或者 community 传入为空的时候,动态去掉这些查询条件。这种写法,在 MyBaits 的配置文件中,也非常常见。大多数情况下,这种写法是没有问题的,因为结果集合是可以控制的。但随着系统的运行,用户表的记录越来越多,当传入的 name 和 community 全部为空时,悲剧的事情发生了:

1
select * from user where 1=1

数据库中的所有记录,都会被查询出来,载入到 JVM 的内存中。由于数据库记录实在太多,直接把内存给撑爆了。由于这种原因引起的内存溢出,发生的频率非常高,比如导入Excel文件时。

通常的解决方式是强行加入分页功能,或者对一些必填的参数进行校验

img

Controller 层

现在很多项目都采用前后端分离架构,所以 Controller 层的方法,一般使用 @ResponseBody 注解,把查询的结果,解析成 JSON 数据返回。这在数据集非常大的情况下,会占用很多内存资源。假如结果集在解析成 JSON 之前,占用的内存是 10MB,那么在解析过程中,有可能会使用 20M 或者更多的内存

因此,保持结果集的精简,是非常有必要的,这也是 DTO(Data Transfer Object)存在的必要。互联网环境不怕小结果集的高并发请求,却非常恐惧大结果集的耗时请求,这是其中一方面的原因。

Service 层

Service 层用于处理具体的业务,更加贴合业务的功能需求。一个 Service,可能会被多个 Controller 层所使用,也可能会使用多个 dao 结构的查询结果进行计算、拼装。

1
2
3
4
int getUserSize() {
        List<User> users = dao.getAllUser();
        return null == users ? 0 : users.size();
}

代码review中发现了定时炸弹,这种在数据量达到一定程度后,才会暴露问题。

ORM 层

比如使用Mybatis时,有一个批量导入服务,在 MyBatis 执行批量插入的时候,竟然产生了内存溢出,按道理这种插入操作是不会引起额外内存占用的,最后通过源码追踪到了问题。

这是因为 MyBatis 循环处理 batch 的时候,操作对象是数组,而我们在接口定义的时候,使用的是 List;当传入一个非常大的 List 时,它需要调用 List 的 toArray 方法将列表转换成数组(浅拷贝);在最后的拼装阶段,又使用了 StringBuilder 来拼接最终的 SQL,所以实际使用的内存要比 List 多很多。

事实证明,不论是插入操作还是查询动作,只要涉及的数据集非常大,就容易出现问题。由于项目中众多框架的引入,想要分析这些具体的内存占用,就变得非常困难。所以保持小批量操作和结果集的干净,是一个非常好的习惯。

五、Redis篇

WhyRedis

​ 速度快,完全基于内存,使用C语言实现,网络层使用epoll解决高并发问题,单线程模型避免了不必要的上下文切换及竞争条件;

GuavaCache Tair EVCache Aerospike
类别 本地JVM缓存 分布式缓存 分布式缓存 分布式nosql数据库
应用 本地缓存 淘宝 Netflix、AWS 广告
性能 非常高 较高 很高 较高
持久化
集群 灵活配置 自动扩容

​ 与传统数据库不同的是 Redis 的数据是存在内存中的,所以读写速度非常快,因此 redis 被广泛应用于缓存方向,每秒可以处理超过 10万次读写操作,是已知性能最快的Key-Value DB。另外,Redis 也经常用来做分布式锁。除此之外,Redis 支持事务 、持久化、LUA脚本、LRU驱动事件、多种集群方案。

1、简单高效

​ 1)完全基于内存,绝大部分请求是纯粹的内存操作。数据存在内存中,类似于 HashMap,查找和操作的时间复杂度都是O(1);

​ 2)数据结构简单,对数据操作也简单,Redis 中的数据结构是专门进行设计的;

​ 3)采用单线程,避免了多线程不必要的上下文切换和竞争条件,不存在加锁释放锁操作,减少了因为锁竞争导致的性能消耗;(6.0以后多线程)

​ 4)使用EPOLL多路 I/O 复用模型,非阻塞 IO;

​ 5)使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis 直接自己构建了 VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求;

2、Memcache

redis Memcached
内存高速数据库 高性能分布式内存缓存数据库
支持hash、list、set、zset、string结构 只支持key-value结构
将大部分数据放到内存 全部数据放到内存中
支持持久化、主从复制备份 不支持数据持久化及数据备份
数据丢失可通过AOF恢复 挂掉后,数据不可恢复
单线程(2~4万TPS) 多线程(20-40万TPS)

使用场景:

​ 1、如果有持久方面的需求或对数据类型和处理有要求的应该选择redis。
​ 2、如果简单的key/value 存储应该选择memcached。

3、Tair

​ Tair(Taobao Pair)是淘宝开发的分布式Key-Value存储引擎,既可以做缓存也可以做数据源(三种引擎切换)

  • MDB(Memcache)属于内存型产品,支持kv和类hashMap结构,性能最优
  • RDB(Redis)支持List.Set.Zset等复杂的数据结构,性能次之,可提供缓存和持久化存储两种模式
  • LDB(levelDB)属于持久化产品,支持kv和类hashmap结构,性能较前两者稍低,但持久化可靠性最高

分布式缓存

大访问少量临时数据的存储(kb左右)

用于缓存,降低对后端数据库的访问压力

session场景

高速访问某些数据结构的应用和计算(rdb)

数据源存储

快速读取数据(fdb)

持续大数据量的存入读取(ldb),交易快照

高频度的更新读取(ldb),库存

痛点:redis集群中,想借用缓存资源必须得指明redis服务器地址去要。这就增加了程序的维护复杂度。因为redis服务器很可能是需要频繁变动的。所以人家淘宝就想啊,为什么不能像操作分布式数据库或者hadoop那样。增加一个中央节点,让他去代理所有事情。在tair中程序只要跟tair中心节点交互就OK了。同时tair里还有配置服务器概念。又免去了像操作hadoop那样,还得每台hadoop一套一模一样配置文件。改配置文件得整个集群都跟着改。

4、Guava

​ 分布式缓存一致性更好一点,用于集群环境下多节点使用同一份缓存的情况;有网络IO,吞吐率与缓存的数据大小有较大关系;

​ 本地缓存非常高效,本地缓存会占用堆内存,影响垃圾回收、影响系统性能。

本地缓存设计:

​ 以 Java 为例,使用自带的 map 或者 guava 实现的是本地缓存,最主要的特点是轻量以及快速,生命周期随着 jvm 的销毁而结束,并且在多实例的情况,每个实例都需要各自保存一份缓存,缓存不具有一致性。

解决缓存过期:

​ 1、将缓存过期时间调为永久

​ 2、将缓存失效时间分散开,不要将缓存时间长度都设置成一样;比如我们可以在原有的失效时间基础上增加一个随机值,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

解决内存溢出:

第一步,修改JVM启动参数,直接增加内存。(-Xms,-Xmx参数一定不要忘记加。)

 第二步,检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。

 第三步,对代码进行走查和分析,找出可能发生内存溢出的位置。

Google Guava Cache

自己设计本地缓存痛点:

  • 不能按照一定的规则淘汰数据,如 LRU,LFU,FIFO 等。
  • 清除数据时的回调通知
  • 并发处理能力差,针对并发可以使用CurrentHashMap,但缓存的其他功能需要自行实现
  • 缓存过期处理,缓存数据加载刷新等都需要手工实现

Guava Cache 的场景:

  • 对性能有非常高的要求
  • 不经常变化,占用内存不大
  • 有访问整个集合的需求
  • 数据允许不实时一致

Guava Cache 的优势

  • 缓存过期和淘汰机制

在GuavaCache中可以设置Key的过期时间,包括访问过期和创建过期。GuavaCache在缓存容量达到指定大小时,采用LRU的方式,将不常使用的键值从Cache中删除

  • 并发处理能力

GuavaCache类似CurrentHashMap,是线程安全的。提供了设置并发级别的api,使得缓存支持并发的写入和读取,采用分离锁机制,分离锁能够减小锁力度,提升并发能力,分离锁是分拆锁定,把一个集合看分成若干partition, 每个partiton一把锁。更新锁定

  • 防止缓存击穿

一般情况下,在缓存中查询某个key,如果不存在,则查源数据,并回填缓存。(Cache Aside Pattern)在高并发下会出现,多次查源并重复回填缓存,可能会造成源的宕机(DB),性能下降 GuavaCache可以在CacheLoader的load方法中加以控制,对同一个key,只让一个请求去读源并回填缓存,其他请求阻塞等待。(相当于集成数据源,方便用户使用)

  • 监控缓存加载/命中情况

统计

问题:

​ OOM->设置过期时间、使用弱引用、配置过期策略

5、EVCache

EVCache是一个Netflflix(网飞)公司开源、快速的分布式缓存,是基于Memcached的内存存储实现的,用以构建超大容量、高性能、低延时、跨区域的全球可用的缓存数据层。

E:Ephemeral:数据存储是短暂的,有自身的存活时间

V:Volatile:数据可以在任何时候消失

EVCache典型地适合对强一致性没有必须要求的场合

典型用例:Netflflix向用户推荐用户感兴趣的电影

image-20210103185340548

EVCache集群在峰值每秒可以处理200kb的请求,

Netflflix生产系统中部署的EVCache经常要处理超过每秒3000万个请求,存储数十亿个对象,

跨数千台memcached服务器。整个EVCache集群每天处理近2万亿个请求。

EVCache集群响应平均延时大约是1-5毫秒,最多不会超过20毫秒。

EVCache集群的缓存命中率在99%左右。

典型部署

EVCache 是线性扩展的,可以在一分钟之内完成扩容,在几分钟之内完成负载均衡和缓存预热。

image-20210103185611516

1、集群启动时,EVCache向服务注册中心(Zookeeper、Eureka)注册各个实例

2、在web应用启动时,查询命名服务中的EVCache服务器列表,并建立连接。

3、客户端通过key使用一致性hash算法,将数据分片到集群上。

6、ETCD

和Zookeeper一样,CP模型追求数据一致性,越来越多的系统开始用它保存关键数据。比如,秒杀系统经常用它保存各节点信息,以便控制消费 MQ 的服务数量。还有些业务系统的配置数据,也会通过 etcd 实时同步给业务系统的各节点,比如,秒杀管理后台会使用 etcd 将秒杀活动的配置数据实时同步给秒杀 API 服务各节点

![image-20210418174251742](/Users/suhongliu/Library/Application Support/typora-user-images/image-20210418174251742.png)

Redis底层

1、redis数据类型

类型 底层 应用场景 编码类型
String SDS数组 帖子、评论、热点数据、输入缓冲 RAW << EMBSTR << INT
List QuickList 评论列表、商品列表、发布与订阅、慢查询、监视器 LINKEDLIST << ZIPLIST
Set intSet 适合交集、并集、查集操作,例如朋友关系 HT << INSET
Zset 跳跃表 去重后排序,适合排名场景 SKIPLIST << ZIPLIST
Hash 哈希 结构化数据,比如存储对象 HT << ZIPLIST
Stream 紧凑列表 消息队列

2、相关API

http://redisdoc.com

String SET SETNX SETEX GET GETSET INCR DECR MSET MGET
Hash HSET HSETNX HGET HDEL HLEN HMSET HMGET HKEYS HGETALL
LIST LPUSH LPOP RPUSH RPOP LINDEX LREM LRANGE LLEN RPOPLPUSH
ZSET ZADD ZREM ZSCORE ZCARD ZRANGE ZRANK ZREVRANK ZREVRANGE
SET SADD SREM SISMEMBER SCARD SINTER SUNION SDIFF SPOP SMEMBERS
事务 MULTI EXEC DISCARD WATCH UNWATCH

3、redis底层结构

SDS数组结构,用于存储字符串和整型数据及输入缓冲。

1
2
3
4
5
struct sdshdr{ 
int len;//记录buf数组中已使用字节的数量
int free; //记录 buf 数组中未使用字节的数量
char buf[];//字符数组,用于保存字符串
}

跳跃表:将有序链表中的部分节点分层,每一层都是一个有序链表。

​ 1、可以快速查找到需要的节点 O(logn) ,额外存储了一倍的空间

​ 2、可以在O(1)的时间复杂度下,快速获得跳跃表的头节点、尾结点、长度和高度。

字典dict: 又称散列表(hash),是用来存储键值对的一种数据结构。

​ Redis整个数据库是用字典来存储的(K-V结构) —Hash+数组+链表

​ Redis字典实现包括:**字典(dict)、Hash表(dictht)、Hash表节点(dictEntry)**。

​ 字典达到存储上限(阈值 0.75),需要rehash(扩容)

​ 1、初次申请默认容量为4个dictEntry,非初次申请为当前hash表容量的一倍。

​ 2、rehashidx=0表示要进行rehash操作。

​ 3、新增加的数据在新的hash表h[1] 、修改、删除、查询在老hash表h[0]

​ 4、将老的hash表h[0]的数据重新计算索引值后全部迁移到新的hash表h[1]中,这个过程称为 rehash。

渐进式rehash

 由于当数据量巨大时rehash的过程是非常缓慢的,所以需要进行优化。 可根据服务器空闲程度批量rehash部分节点

压缩列表zipList

​ 压缩列表(ziplist)是由一系列特殊编码的连续内存块组成的顺序型数据结构,节省内容

sorted-set和hash元素个数少且是小整数或短字符串(直接使用)

​ list用快速链表(quicklist)数据结构存储,而快速链表是双向列表与压缩列表的组合。(间接使用)

整数集合intSet

​ 整数集合(intset)是一个有序的(整数升序)、存储整数的连续存储结构。

​ 当Redis集合类型的元素都是整数并且都处在64位有符号整数范围内(2^64),使用该结构体存储。

快速列表quickList

​ 快速列表(quicklist)是Redis底层重要的数据结构。是Redis3.2列表的底层实现。

​ (在Redis3.2之前,Redis采 用双向链表(adlist)和压缩列表(ziplist)实现。)

Redis Stream的底层主要使用了listpack(紧凑列表)和Rax树(基数树)。

listpack表示一个字符串列表的序列化,listpack可用于存储字符串或整数。用于存储stream的消息内 容。

Rax树是一个有序字典树 (基数树 Radix Tree),按照 key 的字典序排列,支持快速地定位、插入和删除操 作。

4、Zset底层实现

​ 跳表(skip List)是一种随机化的数据结构,基于并联的链表,实现简单,插入、删除、查找的复杂度均为O(logN)。简单说来跳表也是链表的一种,只不过它在链表的基础上增加了跳跃功能,正是这个跳跃的功能,使得在查找元素时,跳表能够提供O(logN)的时间复杂度

​ Zset数据量少的时候使用压缩链表ziplist实现,有序集合使用紧挨在一起的压缩列表节点来保存,第一个节点保存member,第二个保存score。ziplist内的集合元素按score从小到大排序,score较小的排在表头位置。 数据量大的时候使用跳跃列表skiplist和哈希表hash_map结合实现,查找删除插入的时间复杂度都是O(longN)

​ Redis使用跳表而不使用红黑树,是因为跳表的索引结构序列化和反序列化更加快速,方便持久化。

搜索

​ 跳跃表按 score 从小到大保存所有集合元素,查找时间复杂度为平均 O(logN),最坏 O(N) 。

插入

  选用链表作为底层结构支持,为了高效地动态增删。因为跳表底层的单链表是有序的,为了维护这种有序性,在插入前需要遍历链表,找到该插入的位置,单链表遍历查找的时间复杂度是O(n),同理可得,跳表的遍历也是需要遍历索引数,所以是O(logn)。

删除

  如果该节点还在索引中,删除时不仅要删除单链表中的节点,还要删除索引中的节点;单链表在知道删除的节点是谁时,时间复杂度为O(1),但针对单链表来说,删除时都需要拿到前驱节点O(logN)才可改变引用关系从而删除目标节点。

Redis可用性

1、redis持久化

持久化就是把内存中的数据持久化到本地磁盘,防止服务器宕机了内存数据丢失

Redis 提供两种持久化机制 RDB(默认)AOF 机制,Redis4.0以后采用混合持久化,用 AOF 来保证数据不丢失,作为数据恢复的第一选择; 用 RDB 来做不同程度的冷备

RDB:是Redis DataBase缩写快照

​ RDB是Redis默认的持久化方式。按照一定的时间将内存的数据以快照的形式保存到硬盘中,对应产生的数据文件为dump.rdb。通过配置文件中的save参数来定义快照的周期。

优点:

​ 1)只有一个文件 dump.rdb,方便持久化;

​ 2)容灾性好,一个文件可以保存到安全的磁盘。

​ 3)性能最大化,fork 子进程来进行持久化写操作,让主进程继续处理命令,只存在毫秒级不响应请求。

​ 4)相对于数据集大时,比 AOF 的启动效率更高。

缺点:

​ 数据安全性低,RDB 是间隔一段时间进行持久化,如果持久化之间 redis 发生故障,会发生数据丢失。

AOF:持久化

​ AOF持久化(即Append Only File持久化),则是将Redis执行的每次写命令记录到单独的日志文件中,当重启Redis会重新将持久化的日志中文件恢复数据。

优点:

​ 1)数据安全,aof 持久化可以配置 appendfsync 属性,有 always,每进行一次 命令操作就记录到 aof 文件中一次。

​ 2)通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。

缺点:

​ 1)AOF 文件比 RDB 文件大,且恢复速度慢。

​ 2)数据集大的时候,比 rdb 启动效率低。

2、redis事务

​ 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。

Redis事务的概念

​ Redis 事务的本质是通过MULTI、EXEC、WATCH等一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。总结说:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

Redis的事务总是具有ACID中的一致性和隔离性,其他特性是不支持的。当服务器运行在AOF持久化模式下,并且appendfsync选项的值为always时,事务也具有耐久性。

Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH 四个原语实现的

事务命令:

MULTI:用于开启一个事务,它总是返回OK。MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。

EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。当操作被打断时,返回空值 nil 。

WATCH :是一个乐观锁,可以为 Redis 事务提供 check-and-set (CAS)行为。可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到EXEC命令。(秒杀场景

DISCARD:调用该命令,客户端可以清空事务队列,并放弃执行事务,且客户端会从事务状态中退出。

UNWATCH:命令可以取消watch对所有key的监控。

3、redis失效策略

内存淘汰策略

1)全局的键空间选择性移除

noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。(字典库常用)

allkeys-lru:在键空间中,移除最近最少使用的key。(缓存常用)

allkeys-random:在键空间中,随机移除某个key。

2)设置过期时间的键空间选择性移除

volatile-lru:在设置了过期时间的键空间中,移除最近最少使用的key。

volatile-random:在设置了过期时间的键空间中,随机移除某个key。

volatile-ttl:在设置了过期时间的键空间中,有更早过期时间的key优先移除。

缓存失效策略

定时清除:针对每个设置过期时间的key都创建指定定时器

惰性清除:访问时判断,对内存不友好

定时扫描清除:定时100ms随机20个检查过期的字典,若存在25%以上则继续循环删除。

4、redis读写模式

CacheAside旁路缓存

写请求更新数据库后删除缓存数据。读请求不命中查询数据库,查询完成写入缓存

在这里插入图片描述

​ 业务端处理所有数据访问细节,同时利用 Lazy 计算的思想,更新 DB 后,直接删除 cache 并通过 DB 更新,确保数据以 DB 结果为准,则可以大幅降低 cache 和 DB 中数据不一致的概率

​ 如果没有专门的存储服务,同时是对数据一致性要求比较高的业务,或者是缓存数据更新比较复杂的业务,适合使用 Cache Aside 模式。如微博发展初期,不少业务采用这种模式

1
2
3
4
5
6
7
// 延迟双删,用以保证最终一致性,防止小概率旧数据读请求在第一次删除后更新数据库
public void write(String key,Object data){
redis.delKey(key);
db.updateData(data);
Thread.sleep(1000);
redis.delKey(key);
}

高并发下保证绝对的一致,先删缓存再更新数据,需要用到内存队列做异步串行化。非高并发场景,先更新数据再删除缓存,延迟双删策略基本满足了

  • 先更新db后删除redis:删除redis失败则出现问题
  • 先删redis后更新db:删除redis瞬间,旧数据被回填redis
  • 先删redis后更新db休眠后删redis:同第二点,休眠后删除redis 可能宕机
  • java内部jvm队列:不适用分布式场景且降低并发

Read/Write Though(读写穿透)

先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据.

在这里插入图片描述

​ 先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中。

在这里插入图片描述

​ 用户读操作较多.相较于Cache aside而言更适合缓存一致的场景。使用简单屏蔽了底层数据库的操作,只是操作缓存.

场景:

微博 Feed 的 Outbox Vector(即用户最新微博列表)就采用这种模式。一些粉丝较少且不活跃的用户发表微博后,Vector 服务会首先查询 Vector Cache,如果 cache 中没有该用户的 Outbox 记录,则不写该用户的 cache 数据,直接更新 DB 后就返回,只有 cache 中存在才会通过 CAS 指令进行更新。

Write Behind Caching(异步缓存写入)

img

比如对一些计数业务,一条 Feed 被点赞 1万 次,如果更新 1万 次 DB 代价很大,而合并成一次请求直接加 1万,则是一个非常轻量的操作。但这种模型有个显著的缺点,即数据的一致性变差,甚至在一些极端场景下可能会丢失数据。

5、多级缓存

浏览器本地内存缓存:专题活动,一旦上线,在活动期间是不会随意变更的。

浏览器本地磁盘缓存:Logo缓存,大图片懒加载

服务端本地内存缓存:由于没有持久化,重启时必定会被穿透

服务端网络内存缓存:Redis等,针对穿透的情况下可以继续分层,必须保证数据库不被压垮

为什么不是使用服务器本地磁盘做缓存?

​ 当系统处理大量磁盘 IO 操作的时候,由于 CPU 和内存的速度远高于磁盘,可能导致 CPU 耗费太多时间等待磁盘返回处理的结果。对于这部分 CPU 在 IO 上的开销,我们称为 iowait

Redis七大经典问题

1、缓存雪崩

​ 指缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决方案:

  • Redis 高可用,主从+哨兵,Redis cluster,避免全盘崩溃

  • 本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死

  • 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。

  • 逻辑上永不过期给每一个缓存数据增加相应的缓存标记,缓存标记失效则更新数据缓存

  • 多级缓存,失效时通过二级更新一级,由第三方插件更新二级缓存。

2、缓存穿透

https://blog.csdn.net/lin777lin/article/details/105666839

​ 缓存穿透是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决方案:

​ 1)接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;

​ 2)从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒。这样可以防止攻击用户反复用同一个id暴力攻击;

​ 3)采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。(宁可错杀一千不可放过一人)

3、缓存击穿

​ 这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库

解决方案:

​ 1)设置热点数据永远不过期,异步线程处理。

​ 2)加写回操作加互斥锁,查询失败默认值快速返回。

​ 3)缓存预热

​ 系统上线后,将相关可预期(例如排行榜)热点数据直接加载到缓存。

​ 写一个缓存刷新页面,手动操作热点数据(例如广告推广)上下线。

4、数据不一致

​ 在缓存机器的带宽被打满,或者机房网络出现波动时,缓存更新失败,新数据没有写入缓存,就会导致缓存和 DB 的数据不一致。缓存 rehash 时,某个缓存机器反复异常,多次上下线,更新请求多次 rehash。这样,一份数据存在多个节点,且每次 rehash 只更新某个节点,导致一些缓存节点产生脏数据。

  • Cache 更新失败后,可以进行重试,则将重试失败的 key 写入mq,待缓存访问恢复后,将这些 key 从缓存删除。这些 key 在再次被查询时,重新从 DB 加载,从而保证数据的一致性

  • 缓存时间适当调短,让缓存数据及早过期后,然后从 DB 重新加载,确保数据的最终一致性。

  • 不采用 rehash 漂移策略,而采用缓存分层策略,尽量避免脏数据产生。

5、数据并发竞争

​ 数据并发竞争在大流量系统也比较常见,比如车票系统,如果某个火车车次缓存信息过期,但仍然有大量用户在查询该车次信息。又比如微博系统中,如果某条微博正好被缓存淘汰,但这条微博仍然有大量的转发、评论、赞。上述情况都会造成并发竞争读取的问题。

  • ​ 加写回操作加互斥锁,查询失败默认值快速返回。
  • ​ 对缓存数据保持多个备份,减少并发竞争的概率

6、热点key问题

​ 明星结婚、离婚、出轨这种特殊突发事件,比如奥运、春节这些重大活动或节日,还比如秒杀、双12、618 等线上促销活动,都很容易出现 Hot key 的情况。

如何提前发现HotKey?

  • 对于重要节假日、线上促销活动这些提前已知的事情,可以提前评估出可能的热 key 来。
  • 而对于突发事件,无法提前评估,可以通过 Spark,对应流任务进行实时分析,及时发现新发布的热点 key。而对于之前已发出的事情,逐步发酵成为热 key 的,则可以通过 Hadoop 对批处理任务离线计算,找出最近历史数据中的高频热 key。

解决方案:

  • 这 n 个 key 分散存在多个缓存节点,然后 client 端请求时,随机访问其中某个后缀的 hotkey,这样就可以把热 key 的请求打散,避免一个缓存节点过载

  • 缓存集群可以单节点进行主从复制和垂直扩容

  • 利用应用内的前置缓存,但是需注意需要设置上限

  • 延迟不敏感,定时刷新,实时感知用主动刷新

  • 和缓存穿透一样,限制逃逸流量,单请求进行数据回源并刷新前置

  • 无论如何设计,最后都要写一个兜底逻辑,千万级流量说来就来

7、BigKey问题

​ 比如互联网系统中需要保存用户最新 1万 个粉丝的业务,比如一个用户个人信息缓存,包括基本资料、关系图谱计数、发 feed 统计等。微博的 feed 内容缓存也很容易出现,一般用户微博在 140 字以内,但很多用户也会发表 1千 字甚至更长的微博内容,这些长微博也就成了大 key

  • 首先Redis底层数据结构里,根据Value的不同,会进行数据结构的重新选择
  • 可以扩展新的数据结构,进行序列化构建,然后通过 restore 一次性写入
  • 将大 key 分拆为多个 key,设置较长的过期时间

Redis分区容错

1、redis数据分区

Hash:(不稳定)

​ 客户端分片:哈希+取余

​ 节点伸缩:数据节点关系变化,导致数据迁移

​ 迁移数量和添加节点数量有关:建议翻倍扩容

​ 一个简单直观的想法是直接用Hash来计算,以Key做哈希后对节点数取模。可以看出,在key足够分散的情况下,均匀性可以获得,但一旦有节点加入或退出,所有的原有节点都会受到影响,稳定性无从谈起。

一致性Hash:(不均衡)

​ 客户端分片:哈希+顺时针(优化取余)

​ 节点伸缩:只影响邻近节点,但是还是有数据迁移

​ 翻倍伸缩:保证最小迁移数据和负载均衡

​ 一致性Hash可以很好的解决稳定问题,可以将所有的存储节点排列在收尾相接的Hash环上,每个key在计算Hash后会顺时针找到先遇到的一组存储节点存放。而当有节点加入或退出时,仅影响该节点在Hash环上顺时针相邻的后续节点,将数据从该节点接收或者给予。但这又带来均匀性的问题,即使可以将存储节点等距排列,也会在存储节点个数变化时带来数据的不均匀

Codis的Hash槽

​ Codis 将所有的 key 默认划分为 1024 个槽位(slot),它首先对客户端传过来的 key 进行 crc32 运算计算 哈希值,再将 hash 后的整数值对 1024 这个整数进行取模得到一个余数,这个余数就是对应 key 的槽位。

RedisCluster

​ Redis-cluster把所有的物理节点映射到[0-16383]个slot上,对key采用crc16算法得到hash值后对16384取模,基本上采用平均分配和连续分配的方式。

2、主从模式=简单

​ 主从模式最大的优点是部署简单,最少两个节点便可以构成主从模式,并且可以通过读写分离避免读和写同时不可用。不过,一旦 Master 节点出现故障,主从节点就无法自动切换,直接导致 SLA 下降。所以,主从模式一般适合业务发展初期,并发量低,运维成本低的情况

Drawing 1.png

主从复制原理:

​ ①通过从服务器发送到PSYNC命令给主服务器

​ ②如果是首次连接,触发一次全量复制。此时主节点会启动一个后台线程,生成 RDB 快照文件

​ ③主节点会将这个 RDB 发送给从节点,slave 会先写入本地磁盘,再从本地磁盘加载到内存中

​ ④master会将此过程中的写命令写入缓存,从节点实时同步这些数据

​ ⑤如果网络断开了连接,自动重连后主节点通过命令传播增量复制给从节点部分缺少的数据

缺点

​ 所有的slave节点数据的复制和同步都由master节点来处理,会照成master节点压力太大,使用主从从结构来解决,redis4.0中引入psync2 解决了slave重启后仍然可以增量同步。

3、哨兵模式=读多

​ 由一个或多个sentinel实例组成sentinel集群可以监视一个或多个主服务器和多个从服务器。哨兵模式适合读请求远多于写请求的业务场景,比如在秒杀系统中用来缓存活动信息。 如果写请求较多,当集群 Slave 节点数量多了后,Master 节点同步数据的压力会非常大。

image-20201220231241725

当主服务器进入下线状态时,sentinel可以将该主服务器下的某一从服务器升级为主服务器继续提供服务,从而保证redis的高可用性。

检测主观下线状态

​ Sentinel每秒一次向所有与它建立了命令连接的实例(主服务器、从服务器和其他Sentinel)发送PING命 令

​ 实例在down-after-milliseconds毫秒内返回无效回复Sentinel就会认为该实例主观下线(SDown)

检查客观下线状态

​ 当一个Sentinel将一个主服务器判断为主观下线后 ,Sentinel会向监控这个主服务器的所有其他Sentinel发送查询主机状态的命令

​ 如果达到Sentinel配置中的quorum数量的Sentinel实例都判断主服务器为主观下线,则该主服务器就会被判定为客观下线(ODown)。

选举Leader Sentinel

​ 当一个主服务器被判定为客观下线后,监视这个主服务器的所有Sentinel会通过选举算法(raft),选出一个Leader Sentinel去执行**failover(故障转移)**操作。

Raft算法

​ Raft协议是用来解决分布式系统一致性问题的协议。 Raft协议描述的节点共有三种状态:Leader, Follower, Candidate。 Raft协议将时间切分为一个个的Term(任期),可以认为是一种“逻辑时间”。 选举流程:
①Raft采用心跳机制触发Leader选举系统启动后,全部节点初始化为Follower,term为0

​ ②节点如果收到了RequestVote或者AppendEntries,就会保持自己的Follower身份

​ ③节点如果一段时间内没收到AppendEntries消息,在该节点的超时时间内还没发现Leader,Follower就会转换成Candidate,自己开始竞选Leader。 一旦转化为Candidate,该节点立即开始下面几件事情:
​ –增加自己的term,启动一个新的定时器
​ –给自己投一票,向所有其他节点发送RequestVote,并等待其他节点的回复。

​ ④如果在计时器超时前,节点收到多数节点的同意投票,就转换成Leader。同时通过 AppendEntries,向其他节点发送通知。

​ ⑤每个节点在一个term内只能投一票,采取先到先得的策略,Candidate投自己, Follower会投给第一个收到RequestVote的节点。

​ ⑥Raft协议的定时器采取随机超时时间(选举的关键),先转为Candidate的节点会先发起投票,从而获得多数票。

主服务器的选择

​ 当选举出Leader Sentinel后,Leader Sentinel会根据以下规则去从服务器中选择出新的主服务器。

  1. 过滤掉主观、客观下线的节点
  2. 选择配置slave-priority最高的节点,如果有则返回没有就继续选择
  3. 选择出复制偏移量最大的系节点,因为复制偏移量越大则数据复制的越完整
  4. 选择run_id最小的节点,因为run_id越小说明重启次数越少

故障转移

​ 当Leader Sentinel完成新的主服务器选择后,Leader Sentinel会对下线的主服务器执行故障转移操作,主要有三个步骤:

​ 1、它会将失效 Master 的其中一个 Slave 升级为新的 Master , 并让失效 Master 的其他 Slave 改为复制新的 Master ;

​ 2、当客户端试图连接失效的 Master 时,集群会向客户端返回新 Master 的地址,使得集群当前状态只有一个Master。

​ 3、Master 和 Slave 服务器切换后, Master 的 redis.conf 、 Slave 的 redis.conf 和 sentinel.conf 的配置文件的内容都会发生相应的改变,即 Master 主服务器的 redis.conf配置文件中会多一行 replicaof 的配置, sentinel.conf 的监控目标会随之调换。

4、集群模式=写多

​ 为了避免单一节点负载过高导致不稳定,集群模式采用一致性哈希算法或者哈希槽的方法将 Key 分布到各个节点上。其中,每个 Master 节点后跟若干个 Slave 节点,用于出现故障时做主备切换,客户端可以连接任意 Master 节点,集群内部会按照不同 key 将请求转发到不同的 Master 节点

​ 集群模式是如何实现高可用的呢?集群内部节点之间会互相定时探测对方是否存活,如果多数节点判断某个节点挂了,则会将其踢出集群,然后从 Slave 节点中选举出一个节点替补挂掉的 Master 节点。整个原理基本和哨兵模式一致

​ 虽然集群模式避免了 Master 单节点的问题,但集群内同步数据时会占用一定的带宽。所以,只有在写操作比较多的情况下人们才使用集群模式,其他大多数情况,使用哨兵模式都能满足需求

5、分布式锁

利用Watch实现Redis乐观锁

​ 乐观锁基于CAS(Compare And Swap)比较并替换思想,不会产生锁等待而消耗资源,但是需要反复的重试,但也是因为重试的机制,能比较快的响应。因此我们可以利用redis来实现乐观锁(秒杀)。具体思路如下:

1、利用redis的watch功能,监控这个redisKey的状态值
2、获取redisKey的值,创建redis事务,给这个key的值+1
3、执行这个事务,如果key的值被修改过则回滚,key不加1

利用setnx防止库存超卖
分布式锁是控制分布式系统之间同步访问共享资源的一种方式。 利用Redis的单线程特性对共享资源进行串行化处理

1
2
3
// 获取锁推荐使用set的方式
String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
String result = jedis.setnx(lockKey, requestId); //如线程死掉,其他线程无法获取到锁
1
2
3
4
5
6
7
// 释放锁,非原子操作,可能会释放其他线程刚加上的锁
if (requestId.equals(jedis.get(lockKey))) {
jedis.del(lockKey);
}
// 推荐使用redis+lua脚本
String lua = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
Object result = jedis.eval(lua, Collections.singletonList(lockKey),

分布式锁存在的问题

  • 客户端长时间阻塞导致锁失效问题

​ 计算时间内异步启动另外一个线程去检查的问题,这个key是否超时,当锁超时时间快到期且逻辑未执行完,延长锁超时时间。

  • **Redis服务器时钟漂移问题导致同时加锁
    redis的过期时间是依赖系统时钟的,如果时钟漂移过大时 理论上是可能出现的 **会影响到过期时间的计算。

  • 单点实例故障,锁未及时同步导致丢失

    RedLock算法

  1. 获取当前时间戳T0,配置时钟漂移误差T1

  2. 短时间内逐个获取全部N/2+1个锁,结束时间点T2

  3. 实际锁能使用的处理时长变为:TTL - (T2 - T0)- T1

    该方案通过多节点来防止Redis的单点故障,效果一般,也无法防止:

  • 主从切换导致的两个客户端同时持有锁

    大部分情况下持续时间极短,而且使用Redlock在切换的瞬间获取到节点的锁,也存在问题。已经是极低概率的时间,无法避免。Redis分布式锁适合幂等性事务,如果一定要保证安全,应该使用Zookeeper或者DB,但是,性能会急剧下降

与zookeeper分布式锁对比

  • redis 分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
  • zk 分布式锁,注册个监听器即可,不需要不断主动尝试获取锁,ZK获取锁会按照加锁的顺序,所以是公平锁,性能和mysql差不多,和redis差别大

Redission生产环境的分布式锁

​ Redisson是基于NIO的Netty框架上的一个Java驻内存数据网格(In-Memory Data Grid)分布式锁开源组件。

image-20201221000119586

但当业务必须要数据的强一致性,即不允许重复获得锁,比如金融场景(重复下单,重复转账),请不要使用redis分布式锁。可以使用CP模型实现,比如:zookeeper和etcd。

Redis zookeeper etcd
一致性算法 paxos(ZAB) raft
CAP AP CP CP
高可用 主从集群 n+1 n+1
实现 setNX createNode restfulAPI

6、redis心跳检测

在命令传播阶段,从服务器默认会以每秒一次的频率向主服务器发送ACK命令:

​ 1、检测主从的连接状态 检测主从服务器的网络连接状态

​ lag的值应该在0或1之间跳动,如果超过1则说明主从之间的连接有 故障。

​ 2、辅助实现min-slaves,Redis可以通过配置防止主服务器在不安全的情况下执行写命令

1
2
3
min-slaves-to-write 3 (min-replicas-to-write 3 )

min-slaves-max-lag 10 (min-replicas-max-lag 10)

​ 上面的配置表示:从服务器的数量少于3个,或者三个从服务器的延迟(lag)值都大于或等于10 秒时,主服务器将拒绝执行写命令。

​ 3、检测命令丢失,增加重传机制

​ 如果因为网络故障,主服务器传播给从服务器的写命令在半路丢失,那么当从服务器向主服务器发 送REPLCONF ACK命令时,主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量, 然后主服务器就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据重新发送给从服务器。

Redis实战

1、Redis优化

img

读写方式
简单来说就是不用keys等,用range、contains之类。比如,用户粉丝数,大 V 的粉丝更是高达几千万甚至过亿,因此,获取粉丝列表只能部分获取。另外在判断某用户是否关注了另外一个用户时,也只需要关注列表上进行检查判断,然后返回 True/False 或 0/1 的方式更为高效。

KV size
如果单个业务的 KV size 过大,需要分拆成多个 KV 来缓存。拆分时应考虑访问频率

key 的数量
如果数据量巨大,则在缓存中尽可能只保留频繁访问的热数据,对于冷数据直接访问 DB。

读写峰值
如果小于 10万 级别,简单分拆到独立 Cache 池即可
如果达到 100万 级的QPS,则需要对 Cache 进行分层处理,可以同时使用 Local-Cache 配合远程 cache,甚至远程缓存内部继续分层叠加分池进行处理。(多级缓存)

命中率
缓存的命中率对整个服务体系的性能影响甚大。对于核心高并发访问的业务,需要预留足够的容量,确保核心业务缓存维持较高的命中率。比如微博中的 Feed Vector Cache(热点资讯),常年的命中率高达 99.5% 以上。为了持续保持缓存的命中率,缓存体系需要持续监控,及时进行故障处理或故障转移。同时在部分缓存节点异常、命中率下降时,故障转移方案,需要考虑是采用一致性 Hash 分布的访问漂移策略,还是采用数据多层备份策略。

过期策略

​ 可以设置较短的过期时间,让冷 key 自动过期;也可以让 key 带上时间戳,同时设置较长的过期时间,比如很多业务系统内部有这样一些 key:key_20190801。

缓存穿透时间
平均缓存穿透加载时间在某些业务场景下也很重要,对于一些缓存穿透后,加载时间特别长或者需要复杂计算的数据,而且访问量还比较大的业务数据,要配置更多容量,维持更高的命中率,从而减少穿透到 DB 的概率,来确保整个系统的访问性能。

缓存可运维性
对于缓存的可运维性考虑,则需要考虑缓存体系的集群管理,如何进行一键扩缩容,如何进行缓存组件的升级和变更,如何快速发现并定位问题,如何持续监控报警,最好有一个完善的运维平台,将各种运维工具进行集成。

缓存安全性
对于缓存的安全性考虑,一方面可以限制来源 IP,只允许内网访问,同时加密鉴权访问。

2、Redis热升级

在 Redis 需要升级版本或修复 bug 时,如果直接重启变更,由于需要数据恢复,这个过程需要近 10 分钟的时间,时间过长,会严重影响系统的可用性。面对这种问题,可以对 Redis 扩展热升级功能,从而在毫秒级完成升级操作,完全不影响业务访问。

热升级方案如下,首先构建一个 Redis 壳程序,将 redisServer 的所有属性(包括redisDb、client等)保存为全局变量。然后将 Redis 的处理逻辑代码全部封装到动态连接库 so 文件中。Redis 第一次启动,从磁盘加载恢复数据,在后续升级时,通过指令,壳程序重新加载 Redis 新的 redis-4.so 到 redis-5.so 文件,即可完成功能升级,毫秒级完成 Redis 的版本升级。而且整个过程中,所有 Client 连接仍然保留,在升级成功后,原有 Client 可以继续进行读写操作,整个过程对业务完全透明。

六、Kafka篇

Why kafka

消息队列的作用:异步、削峰填谷、解耦

中小型公司,技术实力较为一般,技术挑战不是特别高,用 RabbitMQ (开源、社区活跃)是不错的选择;大型公司,基础架构研发实力较强,用 RocketMQ(Java二次开发) 是很好的选择。

如果是大数据领域的实时计算、日志采集等场景,用 Kafka 是业内标准的,绝对没问题,社区活跃度很高,绝对不会黄,何况几乎是全世界这个领域的事实性规范。

image-20210107225921930

RabbitMQ

RabbitMQ开始是用在电信业务的可靠通信的,也是少有的几款支持AMQP协议的产品之一。

优点:

  • 轻量级,快速,部署使用方便
  • 支持灵活的路由配置。RabbitMQ中,在生产者和队列之间有一个交换器模块。根据配置的路由规则,生产者发送的消息可以发送到不同的队列中。路由规则很灵活,还可以自己实现。
  • RabbitMQ的客户端支持大多数的编程语言,支持AMQP协议。
image-20210107231826261

缺点:

  • 如果有大量消息堆积在队列中,性能会急剧下降
  • 每秒处理几万到几十万的消息。如果应用要求高的性能,不要选择RabbitMQ。
  • RabbitMQ是Erlang开发的,功能扩展和二次开发代价很高。

RocketMQ

借鉴了Kafka的设计并做了很多改进,几乎具备了消息队列应该具备的所有特性和功能

  • RocketMQ主要用于有序,事务,流计算,消息推送,日志流处理,binlog分发等场景。
  • 经过了历次的双11考验,性能,稳定性可靠性没的说。
  • java开发,阅读源代码、扩展、二次开发很方便。
  • 对电商领域的响应延迟做了很多优化。
  • 每秒处理几十万的消息,同时响应在毫秒级。如果应用很关注响应时间,可以使用RocketMQ。
  • 性能比RabbitMQ高一个数量级,。
  • 支持死信队列,DLX 是一个非常有用的特性。它可以处理异常情况下,消息不能够被消费者正确消费而被置入死信队列中的情况,后续分析程序可以通过消费这个死信队列中的内容来分析当时所遇到的异常情况,进而可以改善和优化系统

缺点

​ 跟周边系统的整合和兼容不是很好。

Kafka

高可用,几乎所有相关的开源软件都支持,满足大多数的应用场景,尤其是大数据和流计算领域,

  • Kafka高效,可伸缩,消息持久化。支持分区、副本和容错。
  • 对批处理和异步处理做了大量的设计,因此Kafka可以得到非常高的性能。
  • 每秒处理几十万异步消息消息,如果开启了压缩,最终可以达到每秒处理2000w消息的级别。
  • 但是由于是异步的和批处理的,延迟也会高,不适合电商场景。

What Kafka

  • Producer API:允许应用程序将记录流发布到一个或多个Kafka主题。
  • Consumer API:允许应用程序订阅一个或多个主题并处理为其生成的记录流。
  • Streams API:允许应用程序充当流处理器,将输入流转换为输出流。
image-20210106203420526

消息Message

​ Kafka的数据单元称为消息。可以把消息看成是数据库里的一个“数据行”或一条“记录”。

批次

​ 为了提高效率,消息被分批写入Kafka。提高吞吐量却加大了响应时间

主题Topic

​ 通过主题进行分类,类似数据库中的表,

分区Partition

​ Topic可以被分成若干分区分布于kafka集群中,方便扩容

​ 单个分区内是有序的,partition设置为一才能保证全局有序

副本Replicas

​ 每个主题被分为若干个分区,每个分区有多个副本。

生产者Producer

​ 生产者在默认情况下把消息均衡地分布到主题的所有分区上:

  • 直接指定消息的分区
  • 根据消息的key散列取模得出分区
  • 轮询指定分区。

消费者Comsumer

​ 消费者通过偏移量来区分已经读过的消息,从而消费消息。把每个分区最后读取的消息偏移量保存在Zookeeper 或Kafka上,如果消费者关闭或重启,它的读取状态不会丢失

消费组ComsumerGroup

​ 消费组保证每个分区只能被一个消费者使用,避免重复消费。如果群组内一个消费者失效,消费组里的其他消费者可以接管失效消费者的工作再平衡,重新分区

节点Broker

​ 连接生产者和消费者,单个broker可以轻松处理数千个分区以及每秒百万级的消息量。

  • broker接收来自生产者的消息,为消息设置偏移量,并提交消息到磁盘保存
  • broker为消费者提供服务,响应读取分区的请求,返回已经提交到磁盘上的消息

集群

​ 每隔分区都有一个首领,当分区被分配给多个broker时,会通过首领进行分区复制

生产者Offset

​ 消息写入的时候,每一个分区都有一个offset,即每个分区的最新最大的offset。

消费者Offset

​ 不同消费组中的消费者可以针对一个分区存储不同的Offset,互不影响

LogSegment

  • 一个分区由多个LogSegment组成,
  • 一个LogSegment由.log .index .timeindex组成
  • .log追加是顺序写入的,文件名是以文件中第一条message的offset来命名的
  • .Index进行日志删除的时候和数据查找的时候可以快速定位。
  • .timeStamp则根据时间戳查找对应的偏移量

How Kafka

优点

  • 高吞吐量:单机每秒处理几十上百万的消息量。即使存储了TB及消息,也保持稳定的性能。
    • 零拷贝 减少内核态到用户态的拷贝,磁盘通过sendfile实现DMA 拷贝Socket buffer
    • 顺序读写 充分利用磁盘顺序读写的超高性能
    • 页缓存mmap,将磁盘文件映射到内存, 用户通过修改内存就能修改磁盘文件。
  • 高性能:单节点支持上千个客户端,并保证零停机和零数据丢失。
  • 持久化:将消息持久化到磁盘。通过将数据持久化到硬盘以及replication防止数据丢失。
  • 分布式系统,易扩展。所有的组件均为分布式的,无需停机即可扩展机器。
  • 可靠性 - Kafka是分布式,分区,复制和容错的。
  • 客户端状态维护:消息被处理的状态是在Consumer端维护,当失败时能自动平衡。

应用场景

  • 日志收集:用Kafka可以收集各种服务的Log,通过大数据平台进行处理;
  • 消息系统:解耦生产者和消费者、缓存消息等;
  • 用户活动跟踪:Kafka经常被用来记录Web用户或者App用户的各种活动,如浏览网页、搜索、点击等活动,这些活动信息被各个服务器发布到Kafka的Topic中,然后消费者通过订阅这些Topic来做运营数据的实时的监控分析,也可保存到数据库;

生产消费基本流程

image-20210106213944461
  1. Producer创建时,会创建一个Sender线程并设置为守护线程。

  2. 生产的消息先经过拦截器->序列化器->分区器,然后将消息缓存在缓冲区。

  3. 批次发送的条件为:缓冲区数据大小达到batch.size或者linger.ms达到上限。

  4. 批次发送后,发往指定分区,然后落盘到broker;

    • acks=0只要将消息放到缓冲区,就认为消息已经发送完成。

    • acks=1表示消息只需要写到主分区即可。在该情形下,如果主分区收到消息确认之后就宕机了,而副本分区还没来得及同步该消息,则该消息丢失。

    • acks=all (默认)首领分区会等待所有的ISR副本分区确认记录。该处理保证了只要有一个ISR副本分区存活,消息就不会丢失。

  5. 如果生产者配置了retrires参数大于0并且未收到确认,那么客户端会对该消息进行重试。

  6. 落盘到broker成功,返回生产元数据给生产者。

Leader选举

  • Kafka会在Zookeeper上针对每个Topic维护一个称为ISR(in-sync replica)的集合

  • 当集合中副本都跟Leader中的副本同步了之后,kafka才会认为消息已提交

  • 只有这些跟Leader保持同步的Follower才应该被选作新的Leader

  • 假设某个topic有N+1个副本,kafka可以容忍N个服务器不可用,冗余度较低

    如果ISR中的副本都丢失了,则:

    • 可以等待ISR中的副本任何一个恢复,接着对外提供服务,需要时间等待
    • 从OSR中选出一个副本做Leader副本,此时会造成数据丢失

副本消息同步

​ 首先,Follower 发送 FETCH 请求给 Leader。接着,Leader 会读取底层日志文件中的消 息数据,再更新它内存中的 Follower 副本的 LEO 值,更新为 FETCH 请求中的 fetchOffset 值。最后,尝试更新分区高水位值。Follower 接收到 FETCH 响应之后,会把消息写入到底层日志,接着更新 LEO 和 HW 值。

相关概念LEOHW

  • LEO:即日志末端位移(log end offset),记录了该副本日志中下一条消息的位移值。如果LEO=10,那么表示该副本保存了10条消息,位移值范围是[0, 9]
  • HW:水位值HW(high watermark)即已备份位移。对于同一个副本对象而言,其HW值不会大于LEO值。小于等于HW值的所有消息都被认为是“已备份”的(replicated)

Rebalance

  • 组成员数量发生变化
  • 订阅主题数量发生变化
  • 订阅主题的分区数发生变化

leader选举完成后,当以上三种情况发生时,Leader根据配置的RangeAssignor开始分配消费方案,即哪个consumer负责消费哪些topic的哪些partition。一旦完成分配,leader会将这个方案封装进SyncGroup请求中发给coordinator,非leader也会发SyncGroup请求,只是内容为空。coordinator接收到分配方案之后会把方案塞进SyncGroup的response中发给各个consumer。这样组内的所有成员就都知道自己应该消费哪些分区了。

分区分配算法RangeAssignor

  • 原理是按照消费者总数和分区总数进行整除运算平均分配给所有的消费者。

  • 订阅Topic的消费者按照名称的字典序排序,分均分配,剩下的字典序从前往后分配

增删改查

1
2
3
4
5
6
kafka-topics.sh --zookeeper localhost:2181/myKafka --create --topic topic_x 
--partitions 1 --replication-factor 1
kafka-topics.sh --zookeeper localhost:2181/myKafka --delete --topic topic_x
kafka-topics.sh --zookeeper localhost:2181/myKafka --alter --topic topic_x
--config max.message.bytes=1048576
kafka-topics.sh --zookeeper localhost:2181/myKafka --describe --topic topic_x

如何查看偏移量为23的消息?

通过查询跳跃表ConcurrentSkipListMap,定位到在00000000000000000000.index ,通过二分法在偏移量索引文件中找到不大于 23 的最大索引项,即offset 20 那栏,然后从日志分段文件中的物理位置为320 开始顺序查找偏移量为 23 的消息。

img

切分文件

  • 大小分片 当前日志分段文件的大小超过了 broker 端参数 log.segment.bytes 配置的值
  • 时间分片 当前日志分段中消息的最大时间戳与系统的时间戳的差值大于log.roll.ms配置的值
  • 索引分片 偏移量或时间戳索引文件大小达到broker端 log.index.size.max.bytes配置的值
  • 偏移分片 追加的消息的偏移量与当前日志分段的偏移量之间的差值大于 Integer.MAX_VALUE

一致性

幂等性

保证在消息重发的时候,消费者不会重复处理。即使在消费者收到重复消息的时候,重复处理,也

保证最终结果的一致性。所谓幂等性,数学概念就是: f(f(x)) = f(x)

image-20210107000942286

如何实现?

​ 添加唯一ID,类似于数据库的主键,用于唯一标记一个消息。

1
2
ProducerID:#在每个新的Producer初始化时,会被分配一个唯一的PID
SequenceNumber:#对于每个PID发送数据的每个Topic都对应一个从0开始单调递增的SN值
image-20210107001546404

如何选举

  1. 使用 Zookeeper 的分布式锁选举控制器,并在节点加入集群或退出集群时通知控制器。
  2. 控制器负责在节点加入或离开集群时进行分区Leader选举。
  3. 控制器使用epoch忽略小的纪元来避免脑裂:两个节点同时认为自己是当前的控制器。

可用性

  • 创建Topic的时候可以指定 –replication-factor 3 ,表示不超过broker的副本数
  • 只有Leader是负责读写的节点,Follower定期地到Leader上Pull数据。
  • ISR是Leader负责维护的与其保持同步的Replica列表,即当前活跃的副本列表。如果一个Follow落后太多,Leader会将它从ISR中移除。选举时优先从ISR中挑选Follower。
  • 设置 acks=all 。Leader收到了ISR中所有Replica的ACK,才向Producer发送ACK。

面试题

线上问题rebalance

因集群架构变动导致的消费组内重平衡,如果kafka集内节点较多,比如数百个,那重平衡可能会耗时导致数分钟到数小时,此时kafka基本处于不可用状态,对kafka的TPS影响极大

产生的原因:

  • 组成员数量发生变化

  • 订阅主题数量发生变化

  • 订阅主题的分区数发生变化

    组成员崩溃和组成员主动离开是两个不同的场景。因为在崩溃时成员并不会主动地告知coordinator此事,coordinator有可能需要一个完整的session.timeout周期(心跳周期)才能检测到这种崩溃,这必然会造成consumer的滞后。可以说离开组是主动地发起rebalance;而崩溃则是被动地发起rebalance。

    img

解决方案:

1
2
3
加大超时时间 session.timout.ms=6s
加大心跳频率 heartbeat.interval.ms=2s
增长推送间隔 max.poll.interval.ms=t+1 minutes

ZooKeeper 的作用

目前,Kafka 使用 ZooKeeper 存放集群元数据、成员管理、Controller 选举,以及其他一些管理类任务。之后,等 KIP-500 提案完成后,Kafka 将完全不再依赖于 ZooKeeper。

  • 存放元数据是指主题分区的所有数据都保存在 ZooKeeper 中,其他“人”都要与它保持对齐。
  • 成员管理是指 Broker 节点的注册、注销以及属性变更等 。
  • Controller 选举是指选举集群 Controller,包括但不限于主题删除、参数配置等。

一言以蔽之:KIP-500 ,是使用社区自研的基于 Raft 的共识算法,实现 Controller 自选举

同样是存储元数据,这几年基于Raft算法的etcd认可度越来越高

​ 越来越多的系统开始用它保存关键数据。比如,秒杀系统经常用它保存各节点信息,以便控制消费 MQ 的服务数量。还有些业务系统的配置数据,也会通过 etcd 实时同步给业务系统的各节点,比如,秒杀管理后台会使用 etcd 将秒杀活动的配置数据实时同步给秒杀 API 服务各节点

Replica副本的作用

Kafka 只有 Leader 副本才能 对外提供读写服务,响应 Clients 端的请求。Follower 副本只是采用拉(PULL)的方 式,被动地同步 Leader 副本中的数据,并且在 Leader 副本所在的 Broker 宕机后,随时准备应聘 Leader 副本。

  • 自 Kafka 2.4 版本开始,社区可以通过配置参数,允许 Follower 副本有限度地提供读服务。
  • 之前确保一致性的主要手段是高水位机制, 但高水位值无法保证 Leader 连续变更场景下的数据一致性,因此,社区引入了 Leader Epoch 机制,来修复高水位值的弊端。

为什么不支持读写分离?

  • 自 Kafka 2.4 之后,Kafka 提供了有限度的读写分离。

  • 场景不适用。读写分离适用于那种读负载很大,而写操作相对不频繁的场景。

  • 同步机制。Kafka 采用 PULL 方式实现 Follower 的同步,同时复制延迟较大。

如何防止重复消费

  • 代码层面每次消费需提交offset
  • 通过Mysql的唯一键约束,结合Redis查看id是否被消费,存Redis可以直接使用set方法
  • 量大且允许误判的情况下,使用布隆过滤器也可以

如何保证数据不会丢失

  • 生产者生产消息可以通过comfirm配置ack=all解决
  • Broker同步过程中leader宕机可以通过配置ISR副本+重试解决
  • 消费者丢失可以关闭自动提交offset功能,系统处理完成时提交offset

如何保证顺序消费

  • 单 topic,单partition,单 consumer,单线程消费,吞吐量低,不推荐
  • 如只需保证单key有序,为每个key申请单独内存 queue,每个线程分别消费一个内存 queue 即可,这样就能保证单key(例如用户id、活动id)顺序性。

【线上】如何解决积压消费

  • 修复consumer,使其具备消费能力,并且扩容N台
  • 写一个分发的程序,将Topic均匀分发到临时Topic中
  • 同时起N台consumer,消费不同的临时Topic

如何避免消息积压

  • 提高消费并行度
  • 批量消费
  • 减少组件IO的交互次数
  • 优先级消费
1
2
3
4
5
6
7
if (maxOffset - curOffset > 100000) {
// TODO 消息堆积情况的优先处理逻辑
// 未处理的消息可以选择丢弃或者打日志
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
// TODO 正常消费过程
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;

如何设计消息队列

需要支持快速水平扩容,broker+partition,partition放不同的机器上,增加机器时将数据根据topic做迁移,分布式需要考虑一致性、可用性、分区容错性

  • 一致性:生产者的消息确认、消费者的幂等性、Broker的数据同步
  • 可用性:数据如何保证不丢不重、数据如何持久化、持久化时如何读写
  • 分区容错:采用何种选举机制、如何进行多副本同步
  • 海量数据:如何解决消息积压、海量Topic性能下降

性能上,可以借鉴时间轮、零拷贝、IO多路复用、顺序读写、压缩批处理

七、Spring篇

设计思想&Beans

1、IOC 控制反转

​ IoC(Inverse of Control:控制反转)是⼀种设计思想,就是将原本在程序中⼿动创建对象的控制权,交由Spring框架来管理。 IoC 在其他语⾔中也有应⽤,并⾮ Spring 特有。

​ IoC 容器是 Spring⽤来实现 IoC 的载体, IoC 容器实际上就是个Map(key,value),Map 中存放的是各种对象。将对象之间的相互依赖关系交给 IoC 容器来管理,并由 IoC 容器完成对象的注⼊。这样可以很⼤程度上简化应⽤的开发,把应⽤从复杂的依赖关系中解放出来。 IoC 容器就像是⼀个⼯⼚⼀样,当我们需要创建⼀个对象的时候,只需要配置好配置⽂件/注解即可,完全不⽤考虑对象是如何被创建出来的。

DI 依赖注入

​ DI:(Dependancy Injection:依赖注入)站在容器的角度,将对象创建依赖的其他对象注入到对象中。

2、AOP 动态代理

​ AOP(Aspect-Oriented Programming:⾯向切⾯编程)能够将那些与业务⽆关,却为业务模块所共同调⽤的逻辑或责任(例如事务处理、⽇志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。

​ Spring AOP就是基于动态代理的,如果要代理的对象,实现了某个接⼝,那么Spring AOP会使⽤JDKProxy,去创建代理对象,⽽对于没有实现接⼝的对象,就⽆法使⽤ JDK Proxy 去进⾏代理了,这时候Spring AOP会使⽤基于asm框架字节流的Cglib动态代理 ,这时候Spring AOP会使⽤ Cglib ⽣成⼀个被代理对象的⼦类来作为代理。

3、Bean生命周期

单例对象: singleton

总结:单例对象的生命周期和容器相同

多例对象: prototype

出生:使用对象时spring框架为我们创建

活着:对象只要是在使用过程中就一直活着

死亡:当对象长时间不用且没有其它对象引用时,由java的垃圾回收机制回收

img

IOC容器初始化加载Bean流程:

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
@Override
public void refresh() throws BeansException, IllegalStateException { synchronized (this.startupShutdownMonitor) {
// 第一步:刷新前的预处理
prepareRefresh();
//第二步: 获取BeanFactory并注册到 BeanDefitionRegistry
ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
// 第三步:加载BeanFactory的预准备工作(BeanFactory进行一些设置,比如context的类加载器等)
prepareBeanFactory(beanFactory);
try {
// 第四步:完成BeanFactory准备工作后的前置处理工作
postProcessBeanFactory(beanFactory);
// 第五步:实例化BeanFactoryPostProcessor接口的Bean
invokeBeanFactoryPostProcessors(beanFactory);
// 第六步:注册BeanPostProcessor后置处理器,在创建bean的后执行
registerBeanPostProcessors(beanFactory);
// 第七步:初始化MessageSource组件(做国际化功能;消息绑定,消息解析);
initMessageSource();
// 第八步:注册初始化事件派发器
initApplicationEventMulticaster();
// 第九步:子类重写这个方法,在容器刷新的时候可以自定义逻辑
onRefresh();
// 第十步:注册应用的监听器。就是注册实现了ApplicationListener接口的监听器
registerListeners();
//第十一步:初始化所有剩下的非懒加载的单例bean 初始化创建非懒加载方式的单例Bean实例(未设置属性)
finishBeanFactoryInitialization(beanFactory);
//第十二步: 完成context的刷新。主要是调用LifecycleProcessor的onRefresh()方法,完成创建
finishRefresh();
}
……
}

总结:

四个阶段

  • 实例化 Instantiation
  • 属性赋值 Populate
  • 初始化 Initialization
  • 销毁 Destruction

多个扩展点

  • 影响多个Bean
    • BeanPostProcessor
    • InstantiationAwareBeanPostProcessor
  • 影响单个Bean
    • Aware

完整流程

  1. 实例化一个Bean--也就是我们常说的new
  2. 按照Spring上下文对实例化的Bean进行配置--也就是IOC注入
  3. 如果这个Bean已经实现了BeanNameAware接口,会调用它实现的setBeanName(String)方法,也就是根据就是Spring配置文件中Bean的id和name进行传递
  4. 如果这个Bean已经实现了BeanFactoryAware接口,会调用它实现setBeanFactory(BeanFactory)也就是Spring配置文件配置的Spring工厂自身进行传递
  5. 如果这个Bean已经实现了ApplicationContextAware接口,会调用setApplicationContext(ApplicationContext)方法,和4传递的信息一样但是因为ApplicationContext是BeanFactory的子接口,所以更加灵活
  6. 如果这个Bean关联了BeanPostProcessor接口,将会调用postProcessBeforeInitialization()方法,BeanPostProcessor经常被用作是Bean内容的更改,由于这个是在Bean初始化结束时调用那个的方法,也可以被应用于内存或缓存技
  7. 如果Bean在Spring配置文件中配置了init-method属性会自动调用其配置的初始化方法。
  8. 如果这个Bean关联了BeanPostProcessor接口,将会调用postProcessAfterInitialization(),打印日志或者三级缓存技术里面的bean升级
  9. 以上工作完成以后就可以应用这个Bean了,那这个Bean是一个Singleton的,所以一般情况下我们调用同一个id的Bean会是在内容地址相同的实例,当然在Spring配置文件中也可以配置非Singleton,这里我们不做赘述。
  10. 当Bean不再需要时,会经过清理阶段,如果Bean实现了DisposableBean这个接口,或者根据spring配置的destroy-method属性,调用实现的destroy()方法

4、Bean作用域

名称 作用域
singleton 单例对象,默认值的作用域
prototype 每次获取都会创建⼀个新的 bean 实例
request 每⼀次HTTP请求都会产⽣⼀个新的bean,该bean仅在当前HTTP request内有效。
session 在一次 HTTP session 中,容器将返回同一个实例
global-session 将对象存入到web项目集群的session域中,若不存在集群,则global session相当于session

默认作用域是singleton,多个线程访问同一个bean时会存在线程不安全问题

保障线程安全方法:

  1. 在Bean对象中尽量避免定义可变的成员变量(不太现实)。

  2. 在类中定义⼀个ThreadLocal成员变量,将需要的可变成员变量保存在 ThreadLocal 中

ThreadLocal

​ 每个线程中都有一个自己的ThreadLocalMap类对象,可以将线程自己的对象保持到其中,各管各的,线程可以正确的访问到自己的对象。

​ 将一个共用的ThreadLocal静态实例作为key,将不同对象的引用保存到不同线程的ThreadLocalMap中,然后在线程执行的各处通过这个静态ThreadLocal实例的get()方法取得自己线程保存的那个对象,避免了将这个对象作为参数传递的麻烦。

5、循环依赖

​ 循环依赖其实就是循环引用,也就是两个或者两个以上的 Bean 互相持有对方,最终形成闭环。比如A 依赖于B,B又依赖于A

Spring中循环依赖场景有:

  • prototype 原型 bean循环依赖

  • 构造器的循环依赖(构造器注入)

  • Field 属性的循环依赖(set注入)

    其中,构造器的循环依赖问题无法解决,在解决属性循环依赖时,可以使用懒加载,spring采用的是提前暴露对象的方法。

懒加载@Lazy解决循环依赖问题

​ Spring 启动的时候会把所有bean信息(包括XML和注解)解析转化成Spring能够识别的BeanDefinition并存到Hashmap里供下面的初始化时用,然后对每个 BeanDefinition 进行处理。普通 Bean 的初始化是在容器启动初始化阶段执行的,而被lazy-init=true修饰的 bean 则是在从容器里第一次进行context.getBean() 时进行触发

三级缓存解决循环依赖问题

循环依赖问题
  1. Spring容器初始化ClassA通过构造器初始化对象后提前暴露到Spring容器中的singletonFactorys(三级缓存中)。

  2. ClassA调用setClassB方法,Spring首先尝试从容器中获取ClassB,此时ClassB不存在Spring 容器中。

  3. Spring容器初始化ClassB,ClasssB首先将自己暴露在三级缓存中,然后从Spring容器一级、二级、三级缓存中一次中获取ClassA 。

  4. 获取到ClassA后将自己实例化放入单例池中,实例 ClassA通过Spring容器获取到ClassB,完成了自己对象初始化操作。

  5. 这样ClassA和ClassB都完成了对象初始化操作,从而解决了循环依赖问题。

Spring注解

1、@SpringBoot

声明bean的注解

@Component 通⽤的注解,可标注任意类为 Spring 组件

@Service 在业务逻辑层使用(service层)

@Repository 在数据访问层使用(dao层)

@Controller 在展现层使用,控制器的声明(controller层)

注入bean的注解

@Autowired:默认按照类型来装配注入,**@Qualifier**:可以改成名称

@Resource:默认按照名称来装配注入,JDK的注解,新版本已经弃用

@Autowired注解原理

​ @Autowired的使用简化了我们的开发,

​ 实现 AutowiredAnnotationBeanPostProcessor 类,该类实现了 Spring 框架的一些扩展接口。
​ 实现 BeanFactoryAware 接口使其内部持有了 BeanFactory(可轻松的获取需要依赖的的 Bean)。
​ 实现 MergedBeanDefinitionPostProcessor 接口,实例化Bean 前获取到 里面的 @Autowired 信息并缓存下来;
​ 实现 postProcessPropertyValues 接口, 实例化Bean 后从缓存取出注解信息,通过反射将依赖对象设置到 Bean 属性里面。

@SpringBootApplication

1
2
3
4
5
6
@SpringBootApplication
public class JpaApplication {
public static void main(String[] args) {
SpringApplication.run(JpaApplication.class, args);
}
}

@SpringBootApplication注解等同于下面三个注解:

  • @SpringBootConfiguration: 底层是Configuration注解,说白了就是支持JavaConfig的方式来进行配置
  • @EnableAutoConfiguration:开启自动配置功能
  • @ComponentScan:就是扫描注解,默认是扫描当前类下的package

其中@EnableAutoConfiguration是关键(启用自动配置),内部实际上就去加载META-INF/spring.factories文件的信息,然后筛选出以EnableAutoConfiguration为key的数据,加载到IOC容器中,实现自动配置功能!

它主要加载了@SpringBootApplication注解主配置类,这个@SpringBootApplication注解主配置类里边最主要的功能就是SpringBoot开启了一个@EnableAutoConfiguration注解的自动配置功能。

@EnableAutoConfiguration作用:

它主要利用了一个

EnableAutoConfigurationImportSelector选择器给Spring容器中来导入一些组件。

1
2
@Import(EnableAutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration

2、@SpringMVC

1
2
3
4
5
6
@Controller 声明该类为SpringMVC中的Controller
@RequestMapping 用于映射Web请求
@ResponseBody 支持将返回值放在response内,而不是一个页面,通常用户返回json数据
@RequestBody 允许request的参数在request体中,而不是在直接连接在地址后面。
@PathVariable 用于接收路径参数
@RequestMapping("/hello/{name}")申明的路径,将注解放在参数中前,即可获取该值,通常作为Restful的接口实现方法。

SpringMVC原理

  1. 客户端(浏览器)发送请求,直接请求到 DispatcherServlet 。
  2. DispatcherServlet 根据请求信息调⽤ HandlerMapping ,解析请求对应的 Handler 。
  3. 解析到对应的 Handler (也就是 Controller 控制器)后,开始由HandlerAdapter 适配器处理。
  4. HandlerAdapter 会根据 Handler 来调⽤真正的处理器开处理请求,并处理相应的业务逻辑。
  5. 处理器处理完业务后,会返回⼀个 ModelAndView 对象, Model 是返回的数据对象
  6. ViewResolver 会根据逻辑 View 查找实际的 View 。
  7. DispaterServlet 把返回的 Model 传给 View (视图渲染)。
  8. 把 View 返回给请求者(浏览器)

3、@SpringMybatis

1
2
3
4
5
6
7
8
@Insert : 插入sql ,和xml insert sql语法完全一样
@Select : 查询sql, 和xml select sql语法完全一样
@Update : 更新sql, 和xml update sql语法完全一样
@Delete : 删除sql, 和xml delete sql语法完全一样
@Param : 入参
@Results : 设置结果集合@Result : 结果
@ResultMap : 引用结果集合
@SelectKey : 获取最新插入id

mybatis如何防止sql注入?

​ 简单的说就是#{}是经过预编译的,是安全的,**${}是未经过预编译的,仅仅是取变量的值,是非安全的,存在SQL注入。在编写mybatis的映射语句时,尽量采用“#{xxx}”这样的格式。如果需要实现动态传入表名、列名,还需要做如下修改:添加属性statementType=”STATEMENT”,同时sql里的属有变量取值都改成${xxxx}**

Mybatis和Hibernate的区别

Hibernate 框架:

Hibernate是一个开放源代码的对象关系映射框架,它对JDBC进行了非常轻量级的对象封装,建立对象与数据库表的映射。是一个全自动的、完全面向对象的持久层框架。

Mybatis框架:

Mybatis是一个开源对象关系映射框架,原名:ibatis,2010年由谷歌接管以后更名。是一个半自动化的持久层框架。

区别:

开发方面

​ 在项目开发过程当中,就速度而言:

​ hibernate开发中,sql语句已经被封装,直接可以使用,加快系统开发;

​ Mybatis 属于半自动化,sql需要手工完成,稍微繁琐;

​ 但是,凡事都不是绝对的,如果对于庞大复杂的系统项目来说,复杂语句较多,hibernate 就不是好方案。

sql优化方面

​ Hibernate 自动生成sql,有些语句较为繁琐,会多消耗一些性能;

​ Mybatis 手动编写sql,可以避免不需要的查询,提高系统性能;

对象管理比对

​ Hibernate 是完整的对象-关系映射的框架,开发工程中,无需过多关注底层实现,只要去管理对象即可;

​ Mybatis 需要自行管理映射关系;

4、@Transactional

1
2
@EnableTransactionManagement 
@Transactional

注意事项:

​ ①事务函数中不要处理耗时任务,会导致长期占有数据库连接。

​ ②事务函数中不要处理无关业务,防止产生异常导致事务回滚。

事务传播属性

1) REQUIRED(默认属性) 如果存在一个事务,则支持当前事务。如果没有事务则开启一个新的事务。

  1. MANDATORY 支持当前事务,如果当前没有事务,就抛出异常。

  2. NEVER 以非事务方式执行,如果当前存在事务,则抛出异常。

  3. NOT_SUPPORTED 以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。

  4. REQUIRES_NEW 新建事务,如果当前存在事务,把当前事务挂起。

  5. SUPPORTS 支持当前事务,如果当前没有事务,就以非事务方式执行。

7) NESTED局部回滚) 支持当前事务,新增Savepoint点,与当前事务同步提交或回滚。 嵌套事务一个非常重要的概念就是内层事务依赖于外层事务。外层事务失败时,会回滚内层事务所做的动作。而内层事务操作失败并不会引起外层事务的回滚。

Spring源码阅读

1、Spring中的设计模式

参考:spring中的设计模式

单例设计模式 : Spring 中的 Bean 默认都是单例的。

⼯⼚设计模式 : Spring使⽤⼯⼚模式通过 BeanFactory 、 ApplicationContext 创建bean 对象。

代理设计模式 : Spring AOP 功能的实现。

观察者模式: Spring 事件驱动模型就是观察者模式很经典的⼀个应⽤。

适配器模式:Spring AOP 的增强或通知(Advice)使⽤到了适配器模式、spring MVC 中也是⽤到了适配器模式适配 Controller 。

八、SpringCloud篇

Why SpringCloud

​ Spring cloud 是一系列框架的有序集合。它利用 spring boot 的开发便利性巧妙地简化了分布式系统基础设施的开发,如服务发现注册配置中心消息总线负载均衡断路器数据监控等,都可以用 spring boot 的开发风格做到一键启动和部署。

SpringCloud(微服务解决方案) Dubbo(分布式服务治理框架)
Rest API (轻量、灵活、swagger) RPC远程调用(高效、耦合)
Eureka、Nacos Zookeeper
使用方便 性能好
即将推出SpringCloud2.0 断档5年后17年重启

​ SpringBoot是Spring推出用于解决传统框架配置文件冗余,装配组件繁杂的基于Maven的解决方案,旨在快速搭建单个微服务,SpringCloud是依赖于SpringBoot的,而SpringBoot并不是依赖与SpringCloud,甚至还可以和Dubbo进行优秀的整合开发

​ MartinFlower 提出的微服务之间是通过RestFulApi进行通信,具体实现

  • RestTemplate:基于HTTP协议
  • Feign:封装了ribbon和Hystrix 、RestTemplate 简化了客户端开发工作量
  • RPC:基于TCP协议,序列化和传输效率提升明显
  • MQ:异步解耦微服务之间的调用
img

Spring Boot

Spring Boot 通过简单的步骤就可以创建一个 Spring 应用。

Spring Boot 为 Spring 整合第三方框架提供了开箱即用功能

Spring Boot 的核心思想是约定大于配置

Spring Boot 解决的问题

  • 搭建后端框架时需要手动添加 Maven 配置,涉及很多 XML 配置文件,增加了搭建难度和时间成本。

  • 将项目编译成 war 包,部署到 Tomcat 中,项目部署依赖 Tomcat,这样非常不方便。

  • 应用监控做的比较简单,通常都是通过一个没有任何逻辑的接口来判断应用的存活状态。

Spring Boot 优点

自动装配:Spring Boot 会根据某些规则对所有配置的 Bean 进行初始化。可以减少了很多重复性的工作。

​ 比如使用 MongoDB 时,只需加入 MongoDB 的 Starter 包,然后配置 的连接信息,就可以直接使用 MongoTemplate 自动装配来操作数据库了。简化了 Maven Jar 包的依赖,降低了烦琐配置的出错几率。

内嵌容器:Spring Boot 应用程序可以不用部署到外部容器中,比如 Tomcat。

​ 应用程序可以直接通过 Maven 命令编译成可执行的 jar 包,通过 java-jar 命令启动即可,非常方便。

应用监控:Spring Boot 中自带监控功能 Actuator,可以实现对程序内部运行情况进行监控,

​ 比如 Bean 加载情况、环境变量、日志信息、线程信息等。当然也可以自定义跟业务相关的监控,通过Actuator 的端点信息进行暴露。

1
2
3
4
spring-boot-starter-web          //用于快速构建基于 Spring MVC 的 Web 项目。
spring-boot-starter-data-redis //用于快速整合并操作 Redis。
spring-boot-starter-data-mongodb //用于对 MongoDB 的集成。
spring-boot-starter-data-jpa //用于操作 MySQL。

自定义一个Starter

  1. 创建 Starter 项目,定义 Starter 需要的配置(Properties)类,比如数据库的连接信息;

  2. 编写自动配置类,自动配置类就是获取配置,根据配置来自动装配 Bean;

  3. 编写 spring.factories 文件加载自动配置类,Spring 启动的时候会扫描 spring.factories 文件,;

  4. 编写配置提示文件 spring-configuration-metadata.json(不是必须的),在添加配置的时候,我们想要知道具体的配置项是什么作用,可以通过编写提示文件来提示;

  5. 在项目中引入自定义 Starter 的 Maven 依赖,增加配置值后即可使用。

Spring Boot Admin(将 actuator 提供的数据进行可视化)

  • 显示应用程序的监控状态、查看 JVM 和线程信息

  • 应用程序上下线监控

  • 可视化的查看日志、动态切换日志级别

  • HTTP 请求信息跟踪等实用功能

GateWay / Zuul

GateWay⽬标是取代Netflflix Zuul,它基于Spring5.0+SpringBoot2.0+WebFlux等技术开发,提供统⼀的路由⽅式(反向代理)并且基于 Filter(定义过滤器对请求过滤,完成⼀些功能) 链的⽅式提供了⽹关基本的功能,例如:鉴权、流量控制、熔断、路径重写、⽇志监控。

组成:

  • 路由route: ⽹关最基础的⼯作单元。路由由⼀个ID、⼀个⽬标URL、⼀系列的断⾔(匹配条件判断)和Filter过滤器组成。如果断⾔为true,则匹配该路由。

  • 断⾔predicates:参考了Java8中的断⾔Predicate,匹配Http请求中的所有内容(类似于nginx中的location匹配⼀样),如果断⾔与请求相匹配则路由。

  • 过滤器filter:标准的Spring webFilter,使⽤过滤器在请求之前或者之后执⾏业务逻辑。

    请求前pre类型过滤器:做参数校验权限校验流量监控⽇志输出协议转换等,

    请求前post类型的过滤器:做响应内容响应头的修改、⽇志的输出流量监控等。

image-20210105001419761

GateWayFilter 应⽤到单个路由路由上 、GlobalFilter 应⽤到所有的路由上

Eureka / Zookeeper

服务注册中⼼本质上是为了解耦服务提供者和服务消费者,为了⽀持弹性扩缩容特性,⼀个微服务的提供者的数量和分布往往是动态变化的。

image-20210103231405882
区别 Zookeeper Eureka Nacos
CAP CP AP CP/AP切换
可用性 选举期间不可用 自我保护机制,数据不是最新的
组成 Leader和Follower 节点平等
优势 分布式协调 注册与发现 注册中心和配置中心
底层 进程 服务 Jar包

Eureka通过⼼跳检测健康检查客户端缓存等机制,提⾼系统的灵活性、可伸缩性和可⽤性。

image-20210103232900353
  1. us-east-1c、us-east-1d,us-east-1e代表不同的机房,每⼀个Eureka Server都是⼀个集群
  2. Service作为服务提供者向Eureka中注册服务,Eureka接受到注册事件会在集群和分区中进⾏数据同步,Client作为消费端(服务消费者)可以从Eureka中获取到服务注册信息,进⾏服务调⽤。
  3. 微服务启动后,会周期性地向Eureka发送⼼跳(默认周期为30秒)以续约⾃⼰的信息
  4. Eureka在⼀定时间内(默认90秒)没有接收到某个微服务节点的⼼跳,Eureka将会注销该微服务节点
  5. Eureka Client会缓存Eureka Server中的信息。即使所有的Eureka Server节点都宕掉,服务消费者依然可以使⽤缓存中的信息找到服务提供者

Eureka缓存

新服务上线后,服务消费者不能立即访问到刚上线的新服务,需要过⼀段时间后才能访问?或是将服务下线后,服务还是会被调⽤到,⼀段时候后才彻底停⽌服务,访问前期会导致频繁报错!

image-20210103233902439

​ 服务注册到注册中⼼后,服务实例信息是存储在Registry表中的,也就是内存中。但Eureka为了提⾼响应速度,在内部做了优化,加⼊了两层的缓存结构,将Client需要的实例信息,直接缓存起来,获取的时候直接从缓存中拿数据然后响应给 Client。

  • 第⼀层缓存是readOnlyCacheMap,采⽤ConcurrentHashMap来存储数据的,主要负责定时与readWriteCacheMap进⾏数据同步,默认同步时间为 30 秒⼀次。

  • 第⼆层缓存是readWriteCacheMap,采⽤Guava来实现缓存。缓存过期时间默认为180秒,当服务下线、过期、注册、状态变更等操作都会清除此缓存中的数据。

  • 如果两级缓存都无法查询,会触发缓存的加载,从存储层拉取数据到缓存中,然后再返回给 Client。

    Eureka之所以设计⼆级缓存机制,也是为了提⾼ Eureka Server 的响应速度,缺点是缓存会导致 Client获取不到最新的服务实例信息,然后导致⽆法快速发现新的服务和已下线的服务。

解决方案

  • 我们可以缩短读缓存的更新时间让服务发现变得更加及时,或者直接将只读缓存关闭,同时可以缩短客户端如ribbon服务的定时刷新间隔,多级缓存也导致C层⾯(数据⼀致性)很薄弱。
  • Eureka Server 中会有定时任务去检测失效的服务,将服务实例信息从注册表中移除,也可以将这个失效检测的时间缩短,这样服务下线后就能够及时从注册表中清除。

自我保护机制开启条件

  • 期望最小每分钟能够续租的次数(实例* 频率 * 比例==10* 2 *0.85)
  • 期望的服务实例数量(10)

健康检查

  • Eureka Client 会定时发送心跳给 Eureka Server 来证明自己处于健康的状态

  • 集成SBA以后可以把所有健康状态信息一并返回给eureka

Feign / Ribbon

  • Feign 可以与 Eureka 和 Ribbon 组合使用以支持负载均衡,
  • Feign 可以与 Hystrix 组合使用,支持熔断回退
  • Feign 可以与ProtoBuf实现快速的RPC调用
img
  • InvocationHandlerFactory 代理

    采用 JDK 的动态代理方式生成代理对象,当我们调用这个接口,实际上是要去调用远程的 HTTP API

  • Contract 契约组件

    比如请求类型是 GET 还是 POST,请求的 URI 是什么

  • Encoder 编码组件 \ Decoder 解码组件

    通过该组件我们可以将请求信息采用指定的编码方式进行编解码后传输

  • Logger 日志记录

    负责 Feign 中记录日志的,可以指定 Logger 的级别以及自定义日志的输出

  • Client 请求执行组件

    负责 HTTP 请求执行的组件,Feign 中默认的 Client 是通过 JDK 的 HttpURLConnection 来发起请求的,在每次发送请求的时候,都会创建新的 HttpURLConnection 链接,Feign 的性能会很差,可以通过扩展该接口,使用 Apache HttpClient 等基于连接池的高性能 HTTP 客户端。

  • Retryer 重试组件

    负责重试的组件,Feign 内置了重试器,当 HTTP 请求出现 IO 异常时,Feign 会限定一个最大重试次数来进行重试操作。

  • RequestInterceptor 请求拦截器

    可以为 Feign 添加多个拦截器,在请求执行前设置一些扩展的参数信息。

Feign最佳使用技巧

  • 继承特性

  • 拦截器

    比如添加指定的请求头信息,这个可以用在服务间传递某些信息的时候。

  • GET 请求多参数传递

  • 日志配置

    FULL 会输出全部完整的请求信息。

  • 异常解码器

    异常解码器中可以获取异常信息,而不是简单的一个code,然后转换成对应的异常对象返回。

  • 源码查看是如何继承Hystrix

    HystrixFeign.builder 中可以看到继承了 Feign 的 Builder,增加了 Hystrix的SetterFactory, build 方法里,对 invocationHandlerFactory 进行了重写, create 的时候返回HystrixInvocationHandler, 在 invoke 的时候会将请求包装成 HystrixCommand 去执行,这里就自然的集成了 Hystrix

Ribbon

img

使用方式

  • 原生 API,Ribbon 是 Netflix 开源的,没有使用 Spring Cloud,需要使用 Ribbon 的原生 API。

  • Ribbon + RestTemplate,整合Spring Cloud 后,可以基于 RestTemplate 提供负载均衡的服务

  • Ribbon + Feign

    img

负载均衡算法

  • RoundRobinRule 是轮询的算法,A和B轮流选择。

  • RandomRule 是随机算法,这个就比较简单了,在服务列表中随机选取。

  • BestAvailableRule 选择一个最小的并发请求 server

自定义负载均衡算法

  • 实现 Irule 接口
  • 继承 AbstractLoadBalancerRule 类

自定义负载均衡使用场景(核心)

  • 灰度发布

    灰度发布是能够平滑过渡的一种发布方式,在发布过程中,先发布一部分应用,让指定的用户使用刚发布的应用,等到测试没有问题后,再将其他的全部应用发布。如果新发布的有问题,只需要将这部分恢复即可,不用恢复所有的应用。

  • 多版本隔离

    多版本隔离跟灰度发布类似,为了兼容或者过度,某些应用会有多个版本,这个时候如何保证 1.0 版本的客户端不会调用到 1.1 版本的服务,就是我们需要考虑的问题。

  • 故障隔离

    当线上某个实例发生故障后,为了不影响用户,我们一般都会先留存证据,比如:线程信息、JVM 信息等,然后将这个实例重启或直接停止。然后线下根据一些信息分析故障原因,如果我能做到故障隔离,就可以直接将出问题的实例隔离,不让正常的用户请求访问到这个出问题的实例,只让指定的用户访问,这样就可以单独用特定的用户来对这个出问题的实例进行测试、故障分析等。

Hystrix / Sentinel

服务雪崩场景

自己即是服务消费者,同时也是服务提供者,同步调用等待结果导致资源耗尽

解决方案

服务方:扩容、限流,排查代码问题,增加硬件监控

消费方:使用Hystrix资源隔离,熔断降级,快速失败

img

Hystrix断路保护器的作用

  • 封装请求会将用户的操作进行统一封装,统一封装的目的在于进行统一控制。
  • 资源隔离限流会将对应的资源按照指定的类型进行隔离,比如线程池信号量
    • 计数器限流,例如5秒内技术1000请求,超数后限流,未超数重新计数
    • 滑动窗口限流,解决计数器不够精确的问题,把一个窗口拆分多滚动窗口
    • 令牌桶限流,类似景区售票,售票的速度是固定的,拿到令牌才能去处理请求
    • 漏桶限流,生产者消费者模型,实现了恒定速度处理请求,能够绝对防止突发流量
  • 失败回退其实是一个备用的方案,就是说当请求失败后,有没有备用方案来满足这个请求的需求。
  • 断路器这个是最核心的,,如果断路器处于打开的状态,那么所有请求都将失败,执行回退逻辑。如果断路器处于关闭状态,那么请求将会被正常执行。有些场景我们需要手动打开断路器强制降级
  • 指标监控会对请求的生命周期进行监控,请求成功、失败、超时、拒绝等状态,都会被监控起来。

Hystrix使用上遇到的坑

  • 配置可以对接配置中心进行动态调整

    Hystrix 的配置项非常多,如果不对接配置中心,所有的配置只能在代码里修改,在集群部署的难以应对紧急情况,我们项目只设置一个 CommandKey,其他的都在配置中心进行指定,紧急情况如需隔离部分请求时,只需在配置中心进行修改以后,强制更新即可。

  • 回退逻辑中可以手动埋点或者通过输出日志进行告警

    当请求失败或者超时,会执行回退逻辑,如果有大量的回退,则证明某些服务出问题了,这个时候我们可以在回退的逻辑中进行埋点操作,上报数据给监控系统,也可以输出回退的日志,统一由日志收集的程序去进行处理,这些方式都可以将问题暴露出去,然后通过实时数据分析进行告警操作

  • ThreadLocal配合线程池隔离模式需当心

    当我们用了线程池隔离模式的时候,被隔离的方法会包装成一个 Command 丢入到独立的线程池中进行执行,这个时候就是从 A 线程切换到了 B 线程,ThreadLocal 的数据就会丢失

  • Gateway中多用信号量隔离

    网关是所有请求的入口,路由的服务数量会很多,几十个到上百个都有可能,如果用线程池隔离,那么需要创建上百个独立的线程池,开销太大,用信号量隔离开销就小很多,还能起到限流的作用。

[^常见问题]: Hystrix的超时时间要⼤于Ribbon的超时时间,因为Hystrix将请求包装了起来,特别需要注意的是,如果Ribbon开启了重试机制,⽐如重试3 次,Ribbon 的超时为 1 秒,那么Hystrix 的超时时间应该⼤于 3 秒,否则就会出现 Ribbon 还在重试中,⽽ Hystrix 已经超时的现象。

Sentinel

Sentinel是⼀个⾯向云原⽣微服务的流量控制、熔断降级组件。

替代Hystrix,针对问题:服务雪崩、服务降级、服务熔断、服务限流

Hystrix区别:

  • 独⽴可部署Dashboard(基于 Spring Boot 开发)控制台组件
  • 不依赖任何框架/库,减少代码开发,通过UI界⾯配置即可完成细粒度控制
image-20210104212151598

丰富的应⽤场景:Sentinel 承接了阿⾥巴巴近 10 年的双⼗⼀⼤促流量的核⼼场景,例如秒杀、消息削峰填⾕、集群流量控制、实时熔断下游不可⽤应⽤等。

完备的实时监控:可以看到500 台以下规模的集群的汇总也可以看到单机的秒级数据。

⼴泛的开源⽣态:与 SpringCloud、Dubbo的整合。您只需要引⼊相应的依赖并进⾏简单的配置即可快速地接⼊ Sentinel。

区别:

  • Sentinel不会像Hystrix那样放过⼀个请求尝试⾃我修复,就是明明确确按照时间窗⼝来,熔断触发后,时间窗⼝内拒绝请求,时间窗⼝后就恢复。
  • Sentinel Dashboard中添加的规则数据存储在内存,微服务停掉规则数据就消失,在⽣产环境下不合适。可以将Sentinel规则数据持久化到Nacos配置中⼼,让微服务从Nacos获取。
# Sentinel Hystrix
隔离策略 信号量隔离 线程池隔离/信号量隔离
熔断降级策略 基于响应时间或失败比率 基于失败比率
实时指标实现 滑动窗口 滑动窗口(基于 RxJava)
扩展性 多个扩展点 插件的形式
限流 基于 QPS,支持基于调用关系的限流 不支持
流量整形 支持慢启动、匀速器模式 不支持
系统负载保护 支持 不支持
控制台 开箱即用,可配置规则、查看秒级监控、机器发现等 不完善
常见框架的适配 Servlet、Spring Cloud、Dubbo、gRPC Servlet、Spring Cloud Netflix

Config / Nacos

Nacos是阿⾥巴巴开源的⼀个针对微服务架构中服务发现配置管理服务管理平台

Nacos就是注册中⼼+配置中⼼的组合(Nacos=Eureka+Confifig+Bus)

Nacos功能特性

  • 服务发现与健康检查
  • 动态配置管理
  • 动态DNS服务
  • 服务和元数据管理

保护阈值:

当服务A健康实例数/总实例数 < 保护阈值 的时候,说明健康实例真的不多了,这个时候保护阈值会被触发(状态true),nacos将会把该服务所有的实例信息(健康的+不健康的)全部提供给消费者,消费者可能访问到不健康的实例,请求失败,但这样也⽐造成雪崩要好,牺牲了⼀些请求,保证了整个系统的⼀个可⽤。

Nacos 数据模型(领域模型)

  • Namespace 代表不同的环境,如开发dev、测试test、⽣产环境prod
  • Group 代表某项⽬,⽐如爪哇云项⽬
  • Service 某个项⽬中具体xxx服务
  • DataId 某个项⽬中具体的xxx配置⽂件

可以通过 Spring Cloud 原⽣注解 @RefreshScope 实现配置⾃动更新

Bus / Stream

Spring Cloud Stream 消息驱动组件帮助我们更快速,更⽅便的去构建消息驱动微服务的

本质:屏蔽掉了底层不同MQ消息中间件之间的差异,统⼀了MQ的编程模型,降低了学习、开发、维护MQ的成本,⽬前⽀持Rabbit、Kafka两种消息

Sleuth / Zipkin

全链路追踪

image-20210104234058218

Trace ID:当请求发送到分布式系统的⼊⼝端点时,Sleuth为该请求创建⼀个唯⼀的跟踪标识Trace ID,在分布式系统内部流转的时候,框架始终保持该唯⼀标识,直到返回给请求⽅

Span ID:为了统计各处理单元的时间延迟,当请求到达各个服务组件时,也是通过⼀个唯⼀标识SpanID来标记它的开始,具体过程以及结束。

Spring Cloud Sleuth (追踪服务框架)可以追踪服务之间的调⽤,Sleuth可以记录⼀个服务请求经过哪些服务、服务处理时⻓等,根据这些,我们能够理清各微服务间的调⽤关系及进⾏问题追踪分析。

耗时分析:通过 Sleuth 了解采样请求的耗时,分析服务性能问题(哪些服务调⽤⽐较耗时)

链路优化:发现频繁调⽤的服务,针对性优化等

聚合展示:数据信息发送给 Zipkin 进⾏聚合,利⽤ Zipkin 存储并展示数据。

安全认证

  • Session

    认证中最常用的一种方式,也是最简单的。存在多节点session丢失的情况,可通过nginx粘性Cookie和Redis集中式Session存储解决

  • HTTP Basic Authentication

    服务端针对请求头中base64加密的Authorization 和用户名和密码进行校验

  • Token

    Session 只是一个 key,会话信息存储在后端。而 Token 中会存储用户的信息,然后通过加密算法进行加密,只有服务端才能解密,服务端拿到 Token 后进行解密获取用户信息

  • JWT认证

JWT(JSON Web Token)用户提供用户名和密码给认证服务器,服务器验证用户提交信息的合法性;如果验证成功,会产生并返回一个 Token,用户可以使用这个 Token 访问服务器上受保护的资源。

img
  1. 认证服务提供认证的 API,校验用户信息,返回认证结果
  2. 通过JWTUtils中的RSA算法,生成JWT token,token里封装用户id和有效期
  3. 服务间参数通过请求头进行传递,服务内部通过 ThreadLocal 进行上下文传递。
  4. Hystrix导致ThreadLocal失效的问题可以通过,重写 Hystrix 的 Callable 方法,传递需要的数据。

Token最佳实践

  • 设置较短(合理)的过期时间

  • 注销的 Token 及时清除(放入 Redis 中做一层过滤)。

    虽然不能修改 Token 的信息,但是能在验证层面做一层过滤来进行处理。

  • 监控 Token 的使用频率

    为了防止数据被别人爬取,最常见的就是监控使用频率,程序写出来的爬虫程序访问频率是有迹可循的

  • 核心功能敏感操作可以使用动态验证(验证码)。

    比如提现的功能,要求在提现时再次进行验证码的验证,防止不是本人操作。

  • 网络环境、浏览器信息等识别。

    银行 APP 对环境有很高的要求,使用时如果断网,APP 会自动退出,重新登录,因为网络环境跟之前使用的不一样了,还有一些浏览器的信息之类的判断,这些都是可以用来保证后端 API 的安全。

  • 加密密钥支持动态修改。

    如果 Token 的加密密钥泄露了,也就意味着别人可以伪造你的 Token,可以将密钥存储在配置中心,以支持动态修改刷新,需要注意的是建议在流量低峰的时候去做更换的操作,否则 Token 全部失效,所有在线的请求都会重新申请 Token,并发量会比较大。

灰度发布

痛点:

  • 服务数量多,业务变动频繁,一周一发布

  • 灰度发布能降低发布失败风险,减少影响范围

    通过灰度发布,先让一部分用户体验新的服务,或者只让测试人员进行测试,等功能正常后再全部发布,这样能降低发布失败带来的影响范围。

  • 当发布出现故障时,可以快速回滚,不影响用户

    灰度后如果发现这个节点有问题,那么只需回滚这个节点即可,当然不回滚也没关系,通过灰度策略隔离,也不会影响正常用户

可以通过Ribbon的负载均衡策略进行灰度发布,可以使用更可靠的Discovery

Discovery

基于Discovery 服务注册发现、Ribbon 负载均衡、Feign 和 RestTemplate 调用等组件的企业级微服务开源解决方案,包括灰度发布、灰度路由、服务隔离等功能

img
  1. 首先将需要发布的服务从转发过程中移除,等流量剔除之后再发布。

  2. 部分机器中的版本进行升级,用户默认还是请求老的服务,通过版本来支持测试请求。

  3. 测试完成之后,让新的版本接收正常流量,然后部署下一个节点,以此类推。

1
grayVersions = {"discovery-article-service":["1.01"]}

多版本隔离

img

本地复用测试服务-Eureka Zone亮点

region 地理上的分区,比如北京、上海等

zone 可以简单理解为 region 内的具体机房

​ 在调用的过程中会优先选择相同的 zone 发起调用,当找不到相同名称的 zone 时会选择其他的 zone 进行调用,我们可以利用这个特性来解决本地需要启动多个服务的问题。

[^]: 当你访问修改的服务 A 时,这个服务依赖了 B、C 两个服务,B 和 C 本地没有启动,B 和 C 找不到相同的 zone 就会选择其他的 zone 进行调用,也就是会调用到测试环境部署的 B 和 C 服务,这样一来就解决了本地部署多个服务的问题。

各组件调优

当你对网关进行压测时,会发现并发量一直上不去,错误率也很高。因为你用的是默认配置,这个时候我们就需要去调整配置以达到最优的效果。

首先我们可以对容器进行调优,最常见的就是内置的 Tomcat 容器了,

1
2
3
server.tomcat.accept-count //请求队列排队数
server.tomcat.max-threads //最大线程数
server.tomcat.max-connections //最大连接数

Hystrix 的信号量(semaphore)隔离模式,并发量上不去很大的原因都在这里,信号量默认值是 100,也就是最大并发只有 100,超过 100 就得等待。

1
2
3
4
5
6
//信号量
zuul.semaphore.max-semaphores //信号量:最大并发数
//线程池
hystrix.threadpool.default.coreSize //最大线程数
hystrix.threadpool.default.maximumSize //队列的大
hystrix.threadpool.default.maxQueueSize //等参数

配置Gateway并发信息,

1
2
gateway.host.max-per-route-connections //每个路由的连接数 
gateway.host.max-total-connections //总连接数

调整Ribbon 的并发配置,

1
2
ribbon.MaxConnectionsPerHost //单服务并发数
ribbon.MaxTotalConnections //总并发数

修改Feign默认的HttpURLConnection 替换成 httpclient 来提高性能

1
2
feign.httpclient.max-connections-per-route//每个路由的连接数
feign.httpclient.max-connections //总连接数

Gateway+配置中心实现动态路由

Feign+配置中心实现动态日志

九、分布式篇

分布式系统是一个硬件或软件组件分布在不同的网络计算机上,彼此之间仅仅通过消息传递进行通信和协调的系统。

发展历程

  • 入口级负载均衡

    • 网关负载均衡
    • 客户端负载均衡
  • 单应用架构

    • 应用服务和数据服务分离
    • 应用服务集群
    • 应用服务中心化SAAS
  • 数据库主备读写分离

    • 全文搜索引擎加快数据统计
    • 缓存集群缓解数据库读压力
    • 分布式消息中间件缓解数据库写压力
    • 数据库水平拆分适应微服务
    • 数据库垂直拆分解决慢查询
  • 划分上下文拆分微服务

    • 服务注册发现(Eureka、Nacos)
    • 配置动态更新(Config、Apollo)
    • 业务灰度发布(Gateway、Feign)
    • 统一安全认证(Gateway、Auth)
    • 服务降级限流(Hystrix、Sentinel)
    • 接口检查监控(Actuator、Prometheus)
    • 服务全链路追踪(Sleuth、Zipkin)

CAP

  • 一致性(2PC、3PC、Paxos、Raft)
    • 强一致性:数据库一致性,牺牲了性能
      • ACID:原子性、一致性、隔离性、持久性
    • 弱一致性:数据库和缓存延迟双删、重试
    • 单调读一致性:缓存一致性,ID或者IP哈希
    • 最终一致性:边缘业务,消息队列
  • 可用性(多级缓存、读写分离)
    • BASE 基本可用:限流导致响应速度慢、降级导致用户体验差
      • Basically Availabe 基本可用
      • Soft state 软状态
      • Eventual Consistency 最终一致性
  • 分区容忍性(一致性Hash解决扩缩容问题)

一致性

XA方案

2PC协议:两阶段提交协议,P是指准备阶段,C是指提交阶段

  • 准备阶段:询问是否可以开始,写Undo、Redo日志,收到响应
  • 提交阶段:执行Redo日志进行Commit,执行Undo日志进行Rollback

3PC协议:将提交阶段分为CanCommitPreCommitDoCommit三个阶段

CanCommit:发送canCommit请求,并开始等待

PreCommit:收到全部Yes,写Undo、Redo日志。超时或者No,则中断

DoCommit:执行Redo日志进行Commit,执行Undo日志进行Rollback

区别是第二步,参与者自身增加了超时,如果失败可以及时释放资源

Paxos算法

如何在一个发生异常的分布式系统中,快速且正确地在集群内部对某个数据的值达成一致

​ 参与者(例如Kafka)的一致性可以由协调者(例如Zookeeper)来保证,协调者的一致性就只能由Paxos保证了

Paxos算法中的角色:

  • Client:客户端、例如,对分布式文件服务器中文件的写请求。
  • Proposer:提案发起者,根据Accept返回选择最大N对应的V,发送[N+1,V]
  • Acceptor:决策者,Accept以后会拒绝小于N的提案,并把自己的[N,V]返回给Proposer
  • Learners:最终决策的学习者、学习者充当该协议的复制因素
1
2
3
4
5
6
7
8
9
//算法约束
P1:一个Acceptor必须接受它收到的第一个提案。
//考虑到半数以上才作数,一个Accpter得接受多个相同v的提案
P2a:如果某个v的提案被accept,那么被Acceptor接受编号更高的提案必须也是v
P2b:如果某个v的提案被accept,那么从Proposal提出编号更高的提案必须也是v
//如何确保v的提案Accpter被选定后,Proposal都能提出编号更高的提案呢
针对任意的[Mid,Vid],有半数以上的Accepter集合S,满足以下二选一:
S中接受的提案都大于Mid
S中接受的提案若小于Mid,编号最大的那个值为Vid

image-20210112225118095

面试题:如何保证Paxos算法活性

​ 假设存在这样一种极端情况,有两个Proposer依次提出了一系列编号递增的提案,导致最终陷入死循环,没有value被选定

  • 通过选取主Proposer,规定只有主Proposer才能提出议案。只要主Proposer和过半的Acceptor能够正常网络通信,主Proposer提出一个编号更高的提案,该提案终将会被批准。
  • 每个Proposer发送提交提案的时间设置为一段时间内随机,保证不会一直死循环

ZAB算法

Raft算法

Raft 是一种为了管理复制日志的一致性算法

Raft使用心跳机制来触发选举。当server启动时,初始状态都是follower。每一个server都有一个定时器,超时时间为election timeout(一般为150-300ms),如果某server没有超时的情况下收到来自领导者或者候选者的任何消息,定时器重启,如果超时,它就开始一次选举

Leader异常:异常期间Follower会超时选举,完成后Leader比较彼此步长

Follower异常:恢复后直接同步至Leader当前状态

多个Candidate:选举时失败,失败后超时继续选举

数据库和Redis的一致性

全量缓存保证高效读取

image-20210418185425386

所有数据都存储在缓存里,读服务在查询时不会再降级到数据库里,所有的请求都完全依赖缓存。此时,因降级到数据库导致的毛刺问题就解决了。但全量缓存并没有解决更新时的分布式事务问题,反而把问题放大了。因为全量缓存对数据更新要求更加严格,要求所有数据库已有数据和实时更新的数据必须完全同步至缓存,不能有遗漏。对于此问题,一种有效的方案是采用订阅数据库的 Binlog 实现数据同步

image-20210418185457610

​ 现在很多开源工具(如阿里的 Canal等)可以模拟主从复制的协议。通过模拟协议读取主数据库的 Binlog 文件,从而获取主库的所有变更。对于这些变更,它们开放了各种接口供业务服务获取数据。

image-20210418185516743

​ 将 Binlog 的中间件挂载至目标数据库上,就可以实时获取该数据库的所有变更数据。对这些变更数据解析后,便可直接写入缓存里。优点还有:

  • 大幅提升了读取的速度,降低了延迟

  • Binlog 的主从复制是基于 ACK 机制, 解决了分布式事务的问题

    如果同步缓存失败了,被消费的 Binlog 不会被确认,下一次会重复消费,数据最终会写入缓存中

缺点不可避免:1、增加复杂度 2、消耗缓存资源 3、需要筛选和压缩数据 4、极端情况数据丢失

image-20210418185549520

可以通过异步校准方案进行补齐,但是会损耗数据库性能。但是此方案会隐藏中间件使用错误的细节,线上环境前期更重要的是记录日志排查在做后续优化,不能本末倒置。

可用性

心跳检测

固定的频率向其他节点汇报当前节点状态的方式。收到心跳,说明网络和节点的状态是健康的。心跳汇报时,一般会携带一些附加的状态、元数据,以便管理

周期检测心跳机制:超时未返回

累计失效检测机制:重试超次数

多机房实时热备

6.png

两套缓存集群可以分别部署到不同城市的机房。读服务也相应地部署到不同城市或不同分区。在承接请求时,不同机房或分区的读服务只依赖同样属性的缓存集群。此方案有两个好处。

  1. 提升了性能。读服务不要分层,读服务要尽可能地和缓存数据源靠近。
  2. 增加了可用。当单机房出现故障时,可以秒级将所有流量都切换至存活的机房或分区

此方案虽然带来了性能和可用性的提升,但代价是资源成本的上升。

分区容错性

分布式系统对于错误包容的能力

通过限流、降级、兜底、重试、负载均衡等方式增强系统的健壮性

日志复制

image-20210114154435003

  1. Leader把指令添加到日志中,发起 RPC 给其他的服务器,让他们复制这条信息
  2. Leader会不断的重试,直到所有的 Follower响应了ACK并复制了所有的日志条目
  3. 通知所有的Follower提交,同时Leader该表这条日志的状态,并返回给客户端

主备(Master-Slave)

​ 主机宕机时,备机接管主机的一切工作,主机恢复正常后,以自动(热备)或手动(冷备)方式将服务切换到主机上运行,MysqlRedis中常用。

​ MySQL之间数据复制的基础是二进制日志文件(binary log fifile)。它的数据库中所有操作都会以“事件”的方式记录在二进制日志中,其他数据库作为slave通过一个I/O线程与主服务器保持通信,并监控master的二进制日志文件的变化,如果发现master二进制日志文件发生变化,则会把变化复制到自己的中继日志中,然后slave的一个SQL线程会把相关的“事件”执行到自己的数据库中,以此实现从数据库和主数据库的一致性,也就实现了主从复制

互备(Active-Active)

​ 指两台主机同时运行各自的服务工作且相互监测情况。在数据库高可用部分,常见的互备是MM模式。MM模式即Multi-Master模式,指一个系统存在多个master,每个master都具有read-write能力,会根据时间戳业务逻辑合并版本。

集群(Cluster)模式

​ 是指有多个节点在运行,同时可以通过主控节点分担服务请求。如Zookeeper。集群模式需要解决主控节点本身的高可用问题,一般采用主备模式。

分布式事务

XA方案

两阶段提交 | 三阶段提交

  • 准备阶段的资源锁定,存在性能问题,严重时会造成死锁问题
  • 提交事务请求后,出现网络异常,部分数据收到并执行,会造成一致性问

TCC方案

Try Confirm Cancel / 短事务

  • Try 阶段:这个阶段说的是对各个服务的资源做检测以及对资源进行锁定或者预留

  • Confirm 阶段:这个阶段说的是在各个服务中执行实际的操作

  • Cancel 阶段:如果任何一个服务的业务方法执行出错,那么就需要进行补偿/回滚

Saga方案

事务性补偿 / 长事务

  • 流程、流程、调用第三方业务

本地消息表(eBay)

MQ最终一致性

image-20210117220405706

比如阿里的 RocketMQ 就支持消息事务(核心:双端确认,重试幂等

  1. A**(订单)** 系统先发送一个 prepared 消息到 mq,prepared 消息发送失败则取消操作不执行了
  2. 发送成功后,那么执行本地事务,执行成功和和失败发送确认和回滚消息到mq
  3. 如果发送了确认消息,那么此时 B**(仓储)** 系统会接收到确认消息,然后执行本地的事务
  4. mq 会自动定时轮询所有 prepared 消息回调的接口,确认事务执行状态
  5. B 的事务失败后自动不断重试直到成功,达到一定次数后发送报警由人工来手工回滚补偿

最大努力通知方案(订单 -> 积分)

  1. 系统 A 本地事务执行完之后,发送个消息到 MQ;
  2. 这里会有个专门消费 MQ 的最大努力通知服务,接着调用系统 B 的接口;
  3. 要是系统 B 执行失败了,就定时尝试重新调用系统 B,反复 N 次,最后还是不行就放弃

你找一个严格资金要求绝对不能错的场景,你可以说你是用的 TCC 方案

如果是一般的分布式事务场景,例如积分数据,可以用可靠消息最终一致性方案

如果分布式场景允许不一致,可以使用最大努力通知方案

面试题

分布式Session实现方案

  • 基于JWT的Token,数据从cache或者数据库中获取
  • 基于Tomcat的Redis,简单配置conf文件
  • 基于Spring的Redis,支持SpringCloud和Springb
l

6、【对线面试官】CAS

今天我们来聊聊CAS吧?你对CAS了解多少?

  1. CAS的全称为compare and swap,比较并交换
  2. 虽然翻译过来是「比较并交换」,但它是一个原子性的操作,对应到CPU指令为cmpxchg
  3. cpu指令你都知道?->这没什么,都是背的。
  4. 回到CAS上吧,CAS的操作其实非常简单。
  5. CAS有三个操作数:当前值A、内存值V、要修改的新值B
  6. 假设当前值A跟内存值V相等,那就将内存值V改成B
  7. 假设当前值A跟内存值V不相等,要么就重试,要么就放弃更新
  8. 将当前值与内存值进行对比,判断是否有被修改过,这就是CAS的核心

确实,那为什么要用CAS呢?

  1. 嗯,要讲到CAS就不得不说synchronized锁了,它是Java锁..然后..
  2. ok,其实就是synchronized锁每次只会让一个线程去操作共享资源
  3. 而CAS相当于没有加锁,多个线程都可以直接操作共享资源,在实际去修改的时候才去判断能否修改成功
  4. 在很多的情况下会synchronized锁要高效很多
  5. 比如,对一个值进行累加,就没必要使用synchronized锁,使用juc包下的Atomic类就足以。

了解,那你知道CAS会有什么缺点吗?

  1. CAS有个缺点就是会带来ABA的问题
  2. 从CAS更新的时候,我们可以发现它只比对当前值和内存值是否相等,这会带来个问题,下面我举例说明下:
  3. 假设线程A读到当前值是10,可能线程B把值修改为100,然后线程C又把值修改为10。
  4. 等到线程A拿到执行权时,因为当前值和内存值是一致的,线程A是可以修改的!
  5. 站在线程A的角度来说,这个值是从未被修改的(:
  6. 这是不合理的,因为我们从上帝的角度来看,这个变量已经被线程B和线程C修改过了。
  7. 这就是所谓的ABA问题
  8. 要解决ABA的问题,Java也提供了AtomicStampedReference类供我们用,说白了就是加了个版本,比对的就是内存值+版本是否一致

阿里巴巴开发手册提及到推荐使用LongAdder对象,比AtomicLong性能更好(减少乐观锁的重试次数)

  1. AtomicLong做累加的时候实际上就是多个线程操作同一个目标资源
  2. 在高并发时,只有一个线程是执行成功的,其他的线程都会失败,不断自旋(重试),自旋会成为瓶颈
  3. 而LongAdder的思想就是把要操作的目标资源「分散」到数组Cell中
  4. 每个线程对自己的Cell变量的value进行原子操作,大大降低了失败的次数
  5. 这就是为什么在高并发场景下,推荐使用LongAdder的原因
l

3、【对线面试官】Java NIO

这次咱们就来聊聊Java的NIO呗?你对NIO有多少了解?

  1. 嗯,我对Java NIO还是有一定的了解的,NIO是JDK1.4开始有的,其目的是为了提高速度。NIO翻译成no-blockingo或者newio都无所谓啦,反正都说得通

在真实项目中写过NIO相关

  1. 这块在我所负责的系统中,一般用不上N10,要不我跟你讲讲NIO相关的知识点呗?

可以吧,你先来讲讲NIO和传统IO有什么区别吧

  1. 传统IO是一次一个字节地处理数据,NIO是以块(缓冲区)的形式处理数据。最主要的是,NIO可以实现非阻塞,而传统IO只能是阻塞的。
  2. IO的实际场景是文件IO和网络IO,NIO 在网络IO场景下提升就尤其明显了。
  3. 在Java NIO有三个核心部分组成。分别是Buffer(缓冲区)、Channel(管道)以及Selector(选择器)
  4. 可以简单的理解为:Buffer是存储数据的地方,Channel是运输数据的载体,而Selector用于检查多个Channel的状态变更情况,

有写过相关的Demo代码吗?

  1. 我曾经写过一个NIO Demo,面试官可以看看。

  2. 大概的实现就是:服务端接收图片后保存,能够通知客户端已经收到图片。而客户端发送图片给客户端,并接收服务端的响应

    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
    //服务端
    public class NoBlockServer {

    public static void main(String[] args) throws IOException {

    // 1.获取通道
    ServerSocketChannel server = ServerSocketChannel.open();

    // 2.切换成非阻塞模式
    server.configureBlocking(false);

    // 3. 绑定连接
    server.bind(new InetSocketAddress(6666));

    // 4. 获取选择器
    Selector selector = Selector.open();

    // 4.1将通道注册到选择器上,指定接收“监听通道”事件
    server.register(selector, SelectionKey.OP_ACCEPT);

    // 5. 轮训地获取选择器上已“就绪”的事件--->只要select()>0,说明已就绪
    while (selector.select() > 0) {
    // 6. 获取当前选择器所有注册的“选择键”(已就绪的监听事件)
    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();

    // 7. 获取已“就绪”的事件,(不同的事件做不同的事)
    while (iterator.hasNext()) {

    SelectionKey selectionKey = iterator.next();

    // 接收事件就绪
    if (selectionKey.isAcceptable()) {

    // 8. 获取客户端的链接
    SocketChannel client = server.accept();

    // 8.1 切换成非阻塞状态
    client.configureBlocking(false);

    // 8.2 注册到选择器上-->拿到客户端的连接为了读取通道的数据(监听读就绪事件)
    client.register(selector, SelectionKey.OP_READ);

    } else if (selectionKey.isReadable()) { // 读事件就绪

    // 9. 获取当前选择器读就绪状态的通道
    SocketChannel client = (SocketChannel) selectionKey.channel();

    // 9.1读取数据
    ByteBuffer buffer = ByteBuffer.allocate(1024);

    // 9.2得到文件通道,将客户端传递过来的图片写到本地项目下(写模式、没有则创建)
    FileChannel outChannel = FileChannel.open(Paths.get("2.png"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);

    while (client.read(buffer) > 0) {
    // 在读之前都要切换成读模式
    buffer.flip();

    outChannel.write(buffer);

    // 读完切换成写模式,能让管道继续读取文件的数据
    buffer.clear();
    }
    }
    // 10. 取消选择键(已经处理过的事件,就应该取消掉了)
    iterator.remove();
    }
    }

    }
    }

    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
    //客户端
    public class NoBlockClient {

    public static void main(String[] args) throws IOException {

    // 1. 获取通道
    SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 6666));

    // 1.1切换成非阻塞模式
    socketChannel.configureBlocking(false);

    // 1.2获取选择器
    Selector selector = Selector.open();

    // 1.3将通道注册到选择器中,获取服务端返回的数据
    socketChannel.register(selector, SelectionKey.OP_READ);

    // 2. 发送一张图片给服务端吧
    FileChannel fileChannel = FileChannel.open(Paths.get("X:\\Users\\ozc\\Desktop\\面试造火箭\\1.png"), StandardOpenOption.READ);

    // 3.要使用NIO,有了Channel,就必然要有Buffer,Buffer是与数据打交道的呢
    ByteBuffer buffer = ByteBuffer.allocate(1024);

    // 4.读取本地文件(图片),发送到服务器
    while (fileChannel.read(buffer) != -1) {

    // 在读之前都要切换成读模式
    buffer.flip();

    socketChannel.write(buffer);

    // 读完切换成写模式,能让管道继续读取文件的数据
    buffer.clear();
    }


    // 5. 轮训地获取选择器上已“就绪”的事件--->只要select()>0,说明已就绪
    while (selector.select() > 0) {
    // 6. 获取当前选择器所有注册的“选择键”(已就绪的监听事件)
    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();

    // 7. 获取已“就绪”的事件,(不同的事件做不同的事)
    while (iterator.hasNext()) {

    SelectionKey selectionKey = iterator.next();

    // 8. 读事件就绪
    if (selectionKey.isReadable()) {

    // 8.1得到对应的通道
    SocketChannel channel = (SocketChannel) selectionKey.channel();

    ByteBuffer responseBuffer = ByteBuffer.allocate(1024);

    // 9. 知道服务端要返回响应的数据给客户端,客户端在这里接收
    int readBytes = channel.read(responseBuffer);

    if (readBytes > 0) {
    // 切换读模式
    responseBuffer.flip();
    System.out.println(new String(responseBuffer.array(), 0, readBytes));
    }
    }

    // 10. 取消选择键(已经处理过的事件,就应该取消掉了)
    iterator.remove();
    }
    }
    }

    }

    就考考相关的概念原理呗

    你知道IO模型有几种吗

    1. 在Unix下IO模型分别有:阻塞IO、非阻塞IO、IO复用、信号驱动以及异步I/0。在开发中碰得最多的就是阻塞I0、非阻塞IO以及IO复用。

    来重点讲讲|O复用模型吧

    1. 我就以Linux系统为例好了,我们都知道Linux对文件的操作实际上就是通过文件描述符(fd)
    2. 1O复用模型指的就是:通过一个进程监听多个文件描述符,一旦某个文件描述符准备就绪,就去通知程序做相对应的处理
    3. 这种以通知的方式,优势并不是对于单个连接能处理得更快,而是在于它能处理更多的连接。
    4. 在Linux下IO复用模型用的函数有select/poll和epoll

    那你来讲讲这select和epll函数的区别呗?

    • select

      1. select函数它支持最大的连接数是1024或 2048,因为在select函数下要传入fd_set参数,这个fd_set的大小要么1024或2048(其实就看操作系统的位数)
      2. fd_set就是bitmap的数据结构,可以简单理解为只要位为0,那说明还没数据到缓冲区,只要位为1,那说明数据已经到缓冲区。
      3. 而select函数做的就是每次将fd_set遍历,判断标志位有没有发现变化,如果有变化则通知程序做中断处理。
    • epoll

      1. epoll是在Linux2.6内核正式提出,完善了select的一些缺点。
      2. 它定义了epoll_event结构体来处理,不存在最大连接数的限制。
      3. 并且它不像select函数每次把所有的文件描述符(fd)都遍历,简单理解就是epoll把就绪的文件描述符(fd)专门维护了一块空间,每次从就绪列表里边拿就好了,不再进行对所有文件描述符(fd)进行遍历。

嗯,了解了,另外你知道什么叫做零拷贝吗?

  1. 知道的。我们以读操作为例,假设用户程序发起一次读请求。
  2. 其实会调用read相关的「系统函数」,然后会从用户态切换到内核态,随后CPU会告诉DMA去磁盘把数据拷贝到内核空间。
  3. 等到「内核缓冲区」真正有数据之后,CPU会把「内核缓存区」数据拷贝到「用户缓冲区」,最终用户程序才能获取到。
  4. 稍微解释一下上面的意思~
  5. 为了保证内核的安全,操作系统将虚拟空间划分为「用户空间」和「内核空间」,所以在读系统数据的时候会有状态切换
  6. 因为应用程序不能直接去读取硬盘的数据。从上面描述可知读写需要依赖「内核缓冲区」
  7. 一次读操作会让DMA拷贝(direct memory access 直接内存拷贝,不使用cpu)将磁盘数据拷贝到内核缓冲区,CPU将内核缓冲区数据拷贝到用户缓冲区。
  8. 所谓的零拷贝就是将「CPU将内核缓冲区数据拷贝到用户缓冲区」这次CPU拷贝给省去,来提高效率和性能
  9. 常见的零拷贝技术有mmap(内核缓冲区与用户缓冲区的共享) 、sendfile(系统底层函数支持)。
  10. 零拷贝可以提高数据传输的性能,这块在Kafka等框架也有相关的实践。
l

5、【对线面试官】多线程基础

首先你来讲讲进程和线程的区别吧?

  1. 进程是系统进行资源分配和调度的独立单位,每一个进程都有它自己的内存空间和系统资源
  2. 进程实现多处理机环境下的进程调度,分派,切换时,都需要花费较大的时间和空间开销
  3. 为了提高系统的执行效率,减少处理机的空转时间和调度切换的时间,以及便于系统管理,所以有了线程,线程取代了进程了调度的基本功能
  4. 简单来说,进程作为资源分配的基本单位,线程作为资源调度的基本单位

那我们为什么要用多线程呢?你平时工作中用得多吗?

  1. 使用多线程最主要的原因是提高系统的资源利用率。
  2. 现在CPU基本都是多核的,如果你只用单线程,那就是只用到了一个核心,其他的核心就相当于空闲在那里了。
  3. 在平时工作中多线程是随时都可见的。
  4. 比如说,我们系统Web服务器用的是Tomcat,Tomcat处理每一个请求都会从线程连接池里边用一个线程去处理。
  5. 又比如说,我们用连接数据库会用对应的连接池Druid/C3P0/DBCP等等
  6. 等等这些都用了多线程的。
  7. 上面这些框架已经帮我们屏蔽掉「手写」多线程的问题

嗯,了解,那你实际开发中有用过吗?

  1. 当然有了,在我所负责的系统也会用到多线程的。

  2. 比如说,现在要跑一个定时任务,该任务的链路执行时间和过程都非常长,我这边就用一个线程池将该定时任务的请求进行处理。

  3. 这样做的好处就是可以及时返回结果给调用方,能够提高系统的吞吐量。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 请求直接交给线程池来处理
    public void push(PushParam pushParam) {
    try {
    pushServiceThreadExecutor.submit(() -> {
    handler(pushParam);
    });
    } catch (Exception e) {
    logger.error("pushServiceThreadExecutor error, exception{}:", e);
    }
    }
  4. 还有就是我的系统中用了很多生产者与消费者模式,会用多个线程去消费队列的消息,来提高并发度

你如果在项目中用到了多线程,那肯定得考虑线程安全的问题的吧

  1. 在我的理解下,在Java世界里边,所谓线程安全就是多个线程去执行某类,这个类始终能表现出正确的行为,那么这个类就是线程安全的。
  2. 比如我有一个count变量,在service方法不断的累加这个count变量。
  3. 假设相同的条件下,count变量每次执行的结果都是相同,那我们就可以说是线程安全的干
  4. 显然下面的代码肯定不是线程安全的
1
2
3
4
5
6
7
8
9
10
11
12
13
public class UnsafeCountingServlet extends GenericServlet implements Servlet {
private long count = 0;

public long getCount() {
return count;
}

public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {

++count;
// To something else...
}
}

那你平时是怎么解决,或者怎么思考线程安全问题的呢?

  1. 其实大部分时间我们在代码里边都没有显式去处理线程安全问题,因为这大部分都由框架所做了。
  2. 正如上面提到的Tomcat、Druid、SpringMVC等等。
  3. 很多时候,我们判断是否要处理线程安全问题,就看有没有多个线程同时访问一个共享变量。
  4. 像SpringMVC这种,我们日常开发时,不涉及到操作同一个成员变量,那我们就很少需要考虑线程安全问题。
  5. 我个人解决线程安全问题的思路有以下:
    • 能不能保证操作的原子性,考虑atomi c包下的类够不够我们使用。
    • 能不能保证操作的可见性,考虑volatil e关键字够不够我们使用
    • 如果涉及到对线程的控制(比如一次能使用多少个线程,当前线程触发的条件是否依赖其他线程的结果),考虑CountDownLatch/Semaphore等等。
    • 如果是集合,考虑java.util.concurrent 包下的集合类。
    • 如果synchronized无法满足,考虑lock 包下的类
  6. 总的来说,就是先判断有没有线程安全问题,如果存在则根据具体的情况去判断使用什么方式去处理线程安全的问题。
  7. 虽然synchronized很牛逼,但无脑使用synchronized会影响我们程序的性能的。

死锁你了解吗?什么情况会造成死锁?要是你能给我讲清楚死锁,我就录取你了

  1. 要是你录取我,我就给你讲清楚死锁
  2. 造成死锁的原因可以简单概括为:当前线程拥有其他线程需要的资源,当前线程等待其他线程已拥有的资源,都不放弃自己拥有的资源。
  3. 避免死锁的方式一般有以下方案:
    • 固定加锁的顺序,比如我们可以使用Hash值的大小来确定加锁的先后
    • 尽可能缩减加锁的范围,等到操作共享变量的时候才加锁。
    • 使用可释放的定时锁(一段时间申请不到锁的权限了,直接释放掉)

价值体现

嗯,其实我想问,就是我要是..去到贵公司是做什么内容?还有就是..

l

7、【对线面试官】synchronized

今天我们来聊聊synchronized吧?

  1. synchronized是一种互斥锁,一次只能允许一个线程进入被锁住的代码块
  2. synchronized是Java的一个关键字,它能够将代码块/方法锁起来
  3. 如果synchronized修饰的是实例方法,对应的锁则是对象实例
  4. 如果synchronized修饰的是静态方法,对应的锁则是当前类的Class实例
  5. 如果synchronized修饰的是代码块,对应的锁则是传入synchronized的对象实例

嗯,要不你来讲讲synchronized的原理呗?

  1. 通过反编译可以发现
  2. 当修饰方法时,编译器会生成ACC_SYNCHRONIZED关键字用来标识
  3. 当修饰代码块时,会依赖monitorenter和monitorexit指令
  4. 但前面已经说了,无论synchronized修饰的是方法还是代码块,对应的锁都是一个实例(对象)
  5. 在内存中,对象一般由三部分组成,分别是对象头、对象实际数据和对齐填充
  6. 重点在于对象头,对象头又由几部分组成,但我们重点关注对象头Mark Word的信息就好了
  7. Mark Word会记录对象关于锁的信息
  8. 又因为每个对象都会有一个与之对应的monitor对象,monitor对象中存储着当前持有锁的线程以及等待锁的线程队列
  9. 了解Mark Word和monitor对象是理解synchronized原理的前提

嗯,听说synchronized锁在JDK1.6之后做了很多的优化,这块你了解多少呢?

  1. 其实是这样的,在JDK1.6之前是重量级锁,线程进入同步代码块/方法时
  2. monitor对象就会把当前进入线程的Id进行存储,设置Mark Word的monitor对象地址,并把阻塞的线程存储到monitor的等待线程队列中
  3. 它加锁是依赖底层操作系统的mutex相关指令实现,所以会有用户态和内核态之间的切换,性能损耗十分明显
  4. 而JDK1.6以后引入偏向锁和轻量级锁在JVM层面实现加锁的逻辑,不依赖底层操作系统,就没有切换的消耗
  5. 所以,Mark Word对锁的状态记录一共有4种:无锁、偏向锁、轻量级锁和重量级锁

简单来说说偏向锁、轻量级锁和重量级锁吧

  1. 偏向锁指的就是JVM会认为只有某个线程才会执行同步代码(没有竞争的环境)
  2. 所以在Mark Word会直接记录线程ID,只要线程来执行代码了,会比对线程ID是否相等,相等则当前线程能直接获取得到锁,执行同步代码
  3. 如果不相等,则用CAS来尝试修改当前的线程ID,如果CAS修改成功,那还是能获取得到锁,执行同步代码
  4. 如果CAS失败了,说明有竞争环境,此时会对偏向锁撤销,升级为轻量级锁。
  5. 在轻量级锁状态下,当前线程会在栈帧下创建Lock Record,LockRecord会把 Mark Word的信息拷贝进去,且有个Owner指针指向加锁的对象由由
  6. 线程执行到同步代码时,则用CAS试图将Mark Word的指向到线程栈帧的LockRecord,假设CAS修改成功,则获取得到轻量级锁
  7. 假设修改失败,则自旋(重试),自旋一定次数后,则升级为重量级锁
  8. 简单总结一下
    • synchronized锁原来只有重量级锁,依赖操作系统的mutex指令,需要用户态和内核态切换,性能损耗十分明显
    • 重量级锁用到monitor对象而偏向锁则在Mark Word记录线程ID进行比对、轻量级锁则是拷贝Mark Word到Lock Record,用CAS+自旋的方式获取。
  9. 引入了偏向锁和轻量级锁,就是为了在不同的使用场景使用不同的锁,进而提高效率。
    锁只有升级,没有降级
    • 只有一个线程进入临界区,偏向锁
    • 多个线程交替进入临界区,轻量级锁
    • 多线程同时进入临界区,重量级锁
l

8、【对线面试官】AQS & ReentrantLock

今天我们来聊聊lock锁吧?

你知道什么叫做公平和非公平锁吗

  1. 公平锁指的就是:在竞争环境下,先到临界区的线程比后到的线程一定更快地获取得到锁
  2. 那非公平就很好理解了:先到临界区的线程未必比后到的线程更快地获取得到锁

如果让你实现的话,你怎么实现公平和非公平锁?

  1. 公平锁可以把竞争的线程放在一个先进先出的队列上
  2. 只要持有锁的线程执行完了,唤醒队列的下一个线程去获取锁就好了
  3. 非公平锁的概念上面已经提到了:后到的线程可能比前到临界区的线程获取得到锁
  4. 那实现也很简单,线程先尝试能不能获取得到锁,如果获取得到锁了就执行同步代码了
  5. 如果获取不到锁,那就再把这个线程放到队列呗
  6. 所以公平和非公平的区别就是:线程执行同步代码块时,是否会去尝试获取锁。
  7. 如果会尝试获取锁,那就是非公平的。如果不会尝试获取锁,直接进队列,再等待唤醒,那就是公平的。

为什么要进队列呢?线程一直尝试获取锁不就行了么?

  1. 一直尝试获取锁,专业点就叫做自旋,需要耗费资源的。
  2. 多个线程一直在自旋,而且大多数都是竞争失败的,哪有人会这样实现的
  3. 不会吧,不会吧,你不会就是这样实现的吧

那上次面试所问的synchronized锁是公平的还是非公平的?

  1. 非公平的。
  2. 偏向锁很好理解,如果当前线程ID与markword存储的不相等,则CAS尝试更换线程ID,CAS成功就获取得到锁了
  3. CAS失败则升级为轻量级锁
  4. 轻量级锁实际上也是通过CAS来抢占锁资源(只不过多了拷贝Mark Word到Lock Record的过程)
  5. 抢占成功到锁就归属给该线程了,但自旋失败一定次数后升级重量级锁
  6. 重量级锁通过monitor对象中的队列存储线程,但线程进入队列前,还是会先尝试获取得到锁,如果能获取不到才进入线程等待队列中
  7. 综上所述,synchronized无论处理哪种锁,都是先尝试获取,获取不到才升级||放到队列上的,所以是非公平的

嗯,讲得挺仔细的。AQS你了解吗?

  1. 嗯嗯,AQS全称叫做AbstractQueuedSynchronizer
  2. 是可以给我们实现锁的一个 「框架」,内部实现的关键就是维护了一个先进先出的队列以及state状态变量
  3. 先进先出队列存储的载体叫做Node节点,该节点标识着当前的状态值、是独占还是共享模式以及它的前驱和后继节点等等信息
  4. 简单理解就是:AQS定义了模板,具体实现由各个子类完成。
  5. 总体的流程可以总结为:会把需要等待的线程以Node的形式放到这个先进先出的队列上,state变量则表示为当前锁的状态。
  6. 像ReentrantLock、 ReentrantReadWrite Lock、 CountDownLatch、 Semaphore 这些常用的实现类都是基于AQS实现的
  7. AQS支持两种模式:独占(锁只会被一个线程独占)和共享(多个线程可同时执行)

你以ReentrantLock来讲讲加锁和解锁的过程呗

  • 以非公平锁为了,我们在外界调用lock方法的时候,源码是这样实现的
    1. CAS尝试获取锁,获取成功则可以执行同步代码
    2. CAS获取失败,则调用acquire方法acquire方法实际上就是AQS的模板方法
    3. acquire首先会调用子类的tryAcquire 方法(又回到了ReentrantLock中)
    4. tryAcquire方法实际上会判断当前的state是否等于0,等于0说明没有线程持有锁,则又尝试CAS直接获取锁
    5. 如果CAS获取成功,则可以执行同步代码
    6. 如果CAS获取失败,那判断当前线程是否就持有锁,如果是持有的锁,那更新state的值,获取得到锁(这里其实就是处理可重入的逻辑)
    7. CAS失败&&非重入的情况,则回到try Acquire方法执行「入队列」的操作
    8. 将节点入队列之后,会判断「前驱节点」是不是头节点,如果是头结点又会用CAS尝试获取锁
    9. 如果是「前驱节点」是头节点并获取得到锁,则把当前节点设置为头结点,并且将前驱节点置空(实际上就是原有的头节点已经释放锁了)
    10. 没获取得到锁,则判断前驱节点的状态是否为SIGNAL,如果不是,则找到合法的前驱节点,并使用CAS将状态设置为SIGNAL
    11. 最后调用park将当前线程挂起

你说了一大堆,麻烦使用压缩算法压缩下加锁的过程。

压缩后:当线程CAS获取锁失败,将当前线程入队列,把前驱节点状态设置为SIGNAL状态,并将自己挂起。

为什么要设置前驱节点为 SIGNAL状态,有啥用?

  1. 其实就是表示后继节点需要被唤醒,你咋啥都不知道啊?跟你沟通有点烦.我先把解锁的过程说下吧
    • 外界调用unlock方法时,实际上会调用AQS的release方法,而release方法会调用子类tryRelease方法(又回到了ReentrantLock中)
    • tryRelease会把state一直减(锁重入可使state>1),直至到0,说明当前线程已经把锁释放了
    • 随后从队尾往前找节点状态需要<0,并离头节点最近的节点进行唤醒
  2. 唤醒之后,被唤醒的线程则尝试使用CAS获取锁,假设获取锁得到则把头节点给干掉,把自己设置为头节点成
  3. 解锁的逻辑非常简单哈
  4. 压缩一下:把state置0,唤醒头结点下一个合法的节点,被唤醒的节点线程自然就会去获取锁
  5. 回到上一个问题,为什么要设置前驱节点为SIGNAL状态
  6. 其实归终结底就是为了判断节点的状态,去做些处理。
  7. Node中节点的状态有4种,分别是:CA NCELLED(1)、 SIGNAL(-1)、 CONDITI ON(-2)、 PROPAGATE(-3)和0。
  8. 在ReentrantLock解锁的时候,会判断节点的状态是否小于0,小于等于0才说明需要被唤醒
  9. 另外一提的是:公平锁的实现与非公平锁是很像的,只不过在获取锁时不会直接尝试使用CAS来获取锁。
  10. 只有当队列没节点并且state为0时才会去获取锁,不然都会把当前线程放到队列中

流程图

l

9、【对线面试官】线程池

今天来聊聊线程池呗,你对Java线程池了解多少?

或者换个问法:为什么需要线程池?

  1. JVM在HotSpot的线程模型下,Java线程会一对一映射为内核线程
  2. 这意味着,在Java中每次创建以及回收线程都会去内核创建以及回收
  3. 这就有可能导致:创建和销毁线程所花费的时间和资源可能比处理的任务花费的时间和资源要更多
  4. 线程池的出现是为了提高线程的复用性以及固定线程的数量!!!

你在项目中用到了线程池吗?

  1. 嗯,用到的。我先说下背景吧
  2. 我所负责的项目是消息管理平台,提供其中一个功能就是:运营会圈定人群,然后群发消息
  3. 主要流程大致就是:创建模板-》定时-》群发消息-》用户收到消息
  4. 运营圈定的人群实际上在模板上只是一个ID,我这边要通过ID去获取到HDFS文件
  5. 对HDFS文件进行遍历,然后继续往下发
  6. 「接收到定时任务,再对HDFS进行遍历」这里的处理,我用的就是线程池处理

为什么选择用线程池呢?

  1. HDFS遍历其实就是IO的操作,我把这个过程给异步化,为了提高系统的吞吐量,于是我这里用的线程池。
  2. 即便遍历HDFS出现问题,我这边都有完备的监控和告警可以及时发现。

那你是怎么用线程池的呢?用Executors去创建的吗?

  1. 不是的,我这边用的ThreadPoolExecutor去创建线程池
  2. 其实看阿里巴巴开发手册就有提到,不要使用Executors去创建线程。
  3. 最主要的目的就是:使用ThreadPoolExecutor创建的线程你是更能了解线程池运行的规则,避免资源耗尽的风险
  4. ThreadPoolExecutor在构造的时候有几个重要的参数,分别是:
    corePoolSize (核心线程数量) 、maxim umPoolSize(最大线程数量)、keepAli veTime(线程空余时间) 、workQueue(阻塞队列)、handler(任务拒绝策略)
  5. 这几个参数应该很好理解哈,我就说下任务提交的流程,分别对应着几个参数的作用吧。
    • 首先会判断运行线程数是否小于corePoolSize,如果小于,则直接创建新的线程执行任务
    • 如果大于corePoolSize,判断workQueue阻塞队列是否已满,如果还没满,则将任务放到阻塞队列中
    • 如果workQueue阻塞队列已经满了,则判断当前线程数是否大于maximumPoolSize,如果没大于则创建新的线程执行任务
    • 如果大于maximumPoolSize,则执行任务拒绝策略(具体就是你自己实现的handler)
  6. 这里有个点需要注意下,就是workQueu e阻塞队列满了,但当前线程数小于maximumPoolSize,这时候会创建新的线程执行任务
  7. 源码就是这样实现的
  8. 不过一般我们都是将corePoolSize和maximumPoolSize设置相同数量
  9. keepAliveTime指的就是,当前运行的线程数大于核心线程数了,只要空闲时间达到了,就会对线程进行回收

那我再问一个问题,你创建线程池肯定会指定线程数的嘛,你这块是怎么考量的。

  1. 线程池指定线程数这块,首先要考量自己的业务是什么样的
  2. 是cpu密集型的还是io密集型的,假设运行应用的机器CPU核心数是N
  3. 那cpu密集型的可以先给到N+1,io密集型的可以给到2N去试试
  4. 上面这个只是一个常见的经验做法,具体究竟开多少线程,需要压测才能比较准确地定下来
  5. 线程不是说越大越好,在之前的面试我也提到过,多线程是为了充分利用CPU的资源
  6. 如果设置的线程过多,线程大量有上下文切换,这一部分也会带来系统的开销,这就得不偿失了

ThreadPoolExecutor你看过源码吗?

  1. 看过的,其实上面说的ThreadPoolExecutor几个参数,在源码的顶部注释都有
  2. 在执行的时候,重点就在于它维护了一个ctl参数,这个ctl参数的用高3位表示线程池的状态,低29位来表示线程的数量
  3. 里边用到了大量的位运算符操作,具体细节我就忘了,但是流程还是上面所讲的
l

面试官:“谈谈Spring中都用到了那些设计模式?”。

JDK 中用到了那些设计模式?Spring 中用到了那些设计模式?这两个问题,在面试中比较常见。我在网上搜索了一下关于 Spring 中设计模式的讲解几乎都是千篇一律,而且大部分都年代久远。所以,花了几天时间自己总结了一下,由于我的个人能力有限,文中如有任何错误各位都可以指出。另外,文章篇幅有限,对于设计模式以及一些源码的解读我只是一笔带过,这篇文章的主要目的是回顾一下 Spring 中的常见的设计模式。

Design Patterns(设计模式) 表示面向对象软件开发中最好的计算机编程实践。 Spring 框架中广泛使用了不同类型的设计模式,下面我们来看看到底有哪些设计模式?

控制反转(IoC)和依赖注入(DI)

IoC(Inversion of Control,控制翻转) 是Spring 中一个非常非常重要的概念,它不是什么技术,而是一种解耦的设计思想。它的主要目的是借助于“第三方”(Spring 中的 IOC 容器) 实现具有依赖关系的对象之间的解耦(IOC容易管理对象,你只管使用即可),从而降低代码之间的耦合度。IOC 是一个原则,而不是一个模式,以下模式(但不限于)实现了IoC原则。

图片

ioc-patterns

Spring IOC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。 IOC 容器负责创建对象,将对象连接在一起,配置这些对象,并从创建中处理这些对象的整个生命周期,直到它们被完全销毁。

在实际项目中一个 Service 类如果有几百甚至上千个类作为它的底层,我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,这可能会把人逼疯。如果利用 IOC 的话,你只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。关于Spring IOC 的理解,推荐看这一下知乎的一个回答:https://www.zhihu.com/question/23277575/answer/169698662 ,非常不错。

控制翻转怎么理解呢? 举个例子:”对象a 依赖了对象 b,当对象 a 需要使用 对象 b的时候必须自己去创建。但是当系统引入了 IOC 容器后, 对象a 和对象 b 之前就失去了直接的联系。这个时候,当对象 a 需要使用 对象 b的时候, 我们可以指定 IOC 容器去创建一个对象b注入到对象 a 中”。 对象 a 获得依赖对象 b 的过程,由主动行为变为了被动行为,控制权翻转,这就是控制反转名字的由来。

DI(Dependecy Inject,依赖注入)是实现控制反转的一种设计模式,依赖注入就是将实例变量传入到一个对象中去。

工厂设计模式

Spring使用工厂模式可以通过 BeanFactoryApplicationContext 创建 bean 对象。

两者对比:

  • BeanFactory :延迟注入(使用到某个 bean 的时候才会注入),相比于BeanFactory来说会占用更少的内存,程序启动速度更快。
  • ApplicationContext :容器启动的时候,不管你用没用到,一次性创建所有 bean 。BeanFactory 仅提供了最基本的依赖注入支持,ApplicationContext 扩展了 BeanFactory ,除了有BeanFactory的功能还有额外更多功能,所以一般开发人员使用ApplicationContext会更多。

ApplicationContext的三个实现类:

  1. ClassPathXmlApplication:把上下文文件当成类路径资源。
  2. FileSystemXmlApplication:从文件系统中的 XML 文件载入上下文定义信息。
  3. XmlWebApplicationContext:从Web系统中的XML文件载入上下文定义信息。

Example:

1
2
3
4
5
6
7
8
9
10
11
12
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.FileSystemXmlApplicationContext;

public class App {
public static void main(String[] args) {
ApplicationContext context = new FileSystemXmlApplicationContext(
"C:/work/IOC Containers/springframework.applicationcontext/src/main/resources/bean-factory-config.xml");

HelloApplicationContext obj = (HelloApplicationContext) context.getBean("helloApplicationContext");
obj.getMsg();
}
}

单例设计模式

在我们的系统中,有一些对象其实我们只需要一个,比如说:线程池、缓存、对话框、注册表、日志对象、充当打印机、显卡等设备驱动程序的对象。事实上,这一类对象只能有一个实例,如果制造出多个实例就可能会导致一些问题的产生,比如:程序的行为异常、资源使用过量、或者不一致性的结果。

使用单例模式的好处:

  • 对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销;
  • 由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 压力,缩短 GC 停顿时间。

Spring 中 bean 的默认作用域就是 singleton(单例)的。 除了 singleton 作用域,Spring 中 bean 还有下面几种作用域:

  • prototype : 每次请求都会创建一个新的 bean 实例。
  • request : 每一次HTTP请求都会产生一个新的bean,该bean仅在当前HTTP request内有效。
  • session : 每一次HTTP请求都会产生一个新的 bean,该bean仅在当前 HTTP session 内有效。
  • global-session: 全局session作用域,仅仅在基于portlet的web应用中才有意义,Spring5已经没有了。Portlet是能够生成语义代码(例如:HTML)片段的小型Java Web插件。它们基于portlet容器,可以像servlet一样处理HTTP请求。但是,与 servlet 不同,每个 portlet 都有不同的会话

Spring 实现单例的方式:

  • xml:``
  • 注解:@Scope(value = "singleton")

Spring 通过 ConcurrentHashMap 实现单例注册表的特殊方式实现单例模式。Spring 实现单例的核心代码如下:

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
// 通过 ConcurrentHashMap(线程安全) 实现单例注册表
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<String, Object>(64);

public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) {
Assert.notNull(beanName, "'beanName' must not be null");
synchronized (this.singletonObjects) {
// 检查缓存中是否存在实例
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null) {
//...省略了很多代码
try {
singletonObject = singletonFactory.getObject();
}
//...省略了很多代码
// 如果实例对象在不存在,我们注册到单例注册表中。
addSingleton(beanName, singletonObject);
}
return (singletonObject != NULL_OBJECT ? singletonObject : null);
}
}
//将对象添加到单例注册表
protected void addSingleton(String beanName, Object singletonObject) {
synchronized (this.singletonObjects) {
this.singletonObjects.put(beanName, (singletonObject != null ? singletonObject : NULL_OBJECT));

}
}
}

代理设计模式

代理模式在 AOP 中的应用

AOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。

Spring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么Spring AOP会使用JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候Spring AOP会使用Cglib ,这时候Spring AOP会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:

图片

SpringAOPProcess

当然你也可以使用 AspectJ ,Spring AOP 已经集成了AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。

使用 AOP 之后我们可以把一些通用功能抽象出来,在需要用到的地方直接使用即可,这样大大简化了代码量。我们需要增加新功能时也方便,这样也提高了系统扩展性。日志功能、事务管理等等场景都用到了 AOP 。

Spring AOP 和 AspectJ AOP 有什么区别?

Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。

Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,

如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比Spring AOP 快很多。

模板方法

模板方法模式是一种行为设计模式,它定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。 模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤的实现方式。

图片

模板方法UML图
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
public abstract class Template {
//这是我们的模板方法
public final void TemplateMethod(){
PrimitiveOperation1();
PrimitiveOperation2();
PrimitiveOperation3();
}

protected void PrimitiveOperation1(){
//当前类实现
}

//被子类实现的方法
protected abstract void PrimitiveOperation2();
protected abstract void PrimitiveOperation3();

}
public class TemplateImpl extends Template {

@Override
public void PrimitiveOperation2() {
//当前类实现
}

@Override
public void PrimitiveOperation3() {
//当前类实现
}
}

Spring 中 jdbcTemplatehibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。一般情况下,我们都是使用继承的方式来实现模板模式,但是 Spring 并没有使用这种方式,而是使用Callback 模式与模板方法模式配合,既达到了代码复用的效果,同时增加了灵活性。

观察者模式

观察者模式是一种对象行为型模式。它表示的是一种对象与对象之间具有依赖关系,当一个对象发生改变的时候,这个对象所依赖的对象也会做出反应。Spring 事件驱动模型就是观察者模式很经典的一个应用。Spring 事件驱动模型非常有用,在很多场景都可以解耦我们的代码。比如我们每次添加商品的时候都需要重新更新商品索引,这个时候就可以利用观察者模式来解决这个问题。

Spring 事件驱动模型中的三种角色

事件角色

ApplicationEvent (org.springframework.context包下)充当事件的角色,这是一个抽象类,它继承了java.util.EventObject并实现了 java.io.Serializable接口。

Spring 中默认存在以下事件,他们都是对 ApplicationContextEvent 的实现(继承自ApplicationContextEvent):

  • ContextStartedEventApplicationContext 启动后触发的事件;
  • ContextStoppedEventApplicationContext 停止后触发的事件;
  • ContextRefreshedEventApplicationContext 初始化或刷新完成后触发的事件;
  • ContextClosedEventApplicationContext 关闭后触发的事件。

图片

ApplicationEvent-Subclass

事件监听者角色

ApplicationListener 充当了事件监听者角色,它是一个接口,里面只定义了一个 onApplicationEvent()方法来处理ApplicationEventApplicationListener接口类源码如下,可以看出接口定义看出接口中的事件只要实现了 ApplicationEvent就可以了。所以,在 Spring中我们只要实现 ApplicationListener 接口实现 onApplicationEvent() 方法即可完成监听事件

1
2
3
4
5
6
package org.springframework.context;
import java.util.EventListener;
@FunctionalInterface
public interface ApplicationListener<E extends ApplicationEvent> extends EventListener {
void onApplicationEvent(E var1);
}

事件发布者角色

ApplicationEventPublisher 充当了事件的发布者,它也是一个接口。

1
2
3
4
5
6
7
8
@FunctionalInterface
public interface ApplicationEventPublisher {
default void publishEvent(ApplicationEvent event) {
this.publishEvent((Object)event);
}

void publishEvent(Object var1);
}

ApplicationEventPublisher 接口的publishEvent()这个方法在AbstractApplicationContext类中被实现,阅读这个方法的实现,你会发现实际上事件真正是通过ApplicationEventMulticaster来广播出去的。具体内容过多,就不在这里分析了,后面可能会单独写一篇文章提到。

Spring 的事件流程总结

  1. 定义一个事件: 实现一个继承自 ApplicationEvent,并且写相应的构造函数;
  2. 定义一个事件监听者:实现 ApplicationListener 接口,重写 onApplicationEvent() 方法;
  3. 使用事件发布者发布消息: 可以通过 ApplicationEventPublisherpublishEvent() 方法发布消息。

Example:

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
// 定义一个事件,继承自ApplicationEvent并且写相应的构造函数
public class DemoEvent extends ApplicationEvent{
private static final long serialVersionUID = 1L;

private String message;

public DemoEvent(Object source,String message){
super(source);
this.message = message;
}

public String getMessage() {
return message;
}


// 定义一个事件监听者,实现ApplicationListener接口,重写 onApplicationEvent() 方法;
@Component
public class DemoListener implements ApplicationListener<DemoEvent>{

//使用onApplicationEvent接收消息
@Override
public void onApplicationEvent(DemoEvent event) {
String msg = event.getMessage();
System.out.println("接收到的信息是:"+msg);
}

}
// 发布事件,可以通过ApplicationEventPublisher 的 publishEvent() 方法发布消息。
@Component
public class DemoPublisher {

@Autowired
ApplicationContext applicationContext;

public void publish(String message){
//发布事件
applicationContext.publishEvent(new DemoEvent(this, message));
}
}

当调用 DemoPublisherpublish() 方法的时候,比如 demoPublisher.publish("你好") ,控制台就会打印出:接收到的信息是:你好

适配器模式

适配器模式(Adapter Pattern) 将一个接口转换成客户希望的另一个接口,适配器模式使接口不兼容的那些类可以一起工作,其别名为包装器(Wrapper)。

spring AOP中的适配器模式

我们知道 Spring AOP 的实现是基于代理模式,但是 Spring AOP 的增强或通知(Advice)使用到了适配器模式,与之相关的接口是AdvisorAdapter 。Advice 常用的类型有:BeforeAdvice(目标方法调用前,前置通知)、AfterAdvice(目标方法调用后,后置通知)、AfterReturningAdvice(目标方法执行结束后,return之前)等等。每个类型Advice(通知)都有对应的拦截器:MethodBeforeAdviceInterceptorAfterReturningAdviceAdapterAfterReturningAdviceInterceptor。Spring预定义的通知要通过对应的适配器,适配成 MethodInterceptor接口(方法拦截器)类型的对象(如:MethodBeforeAdviceInterceptor 负责适配 MethodBeforeAdvice)。

spring MVC中的适配器模式

在Spring MVC中,DispatcherServlet 根据请求信息调用 HandlerMapping,解析请求对应的 Handler。解析到对应的 Handler(也就是我们平常说的 Controller 控制器)后,开始由HandlerAdapter 适配器处理。HandlerAdapter 作为期望接口,具体的适配器实现类用于对目标类进行适配,Controller 作为需要适配的类。

为什么要在 Spring MVC 中使用适配器模式? Spring MVC 中的 Controller 种类众多,不同类型的 Controller 通过不同的方法来对请求进行处理。如果不利用适配器模式的话,DispatcherServlet 直接获取对应类型的 Controller,需要的自行来判断,像下面这段代码一样:

1
2
3
4
5
6
7
if(mappedHandler.getHandler() instanceof MultiActionController){  
((MultiActionController)mappedHandler.getHandler()).xxx
}else if(mappedHandler.getHandler() instanceof XXX){
...
}else if(...){
...
}

假如我们再增加一个 Controller类型就要在上面代码中再加入一行 判断语句,这种形式就使得程序难以维护,也违反了设计模式中的开闭原则 – 对扩展开放,对修改关闭。

装饰者模式

装饰者模式可以动态地给对象添加一些额外的属性或行为。相比于使用继承,装饰者模式更加灵活。简单点儿说就是当我们需要修改原有的功能,但我们又不愿直接去修改原有的代码时,设计一个Decorator套在原有代码外面。其实在 JDK 中就有很多地方用到了装饰者模式,比如 InputStream家族,InputStream 类下有 FileInputStream (读取文件)、BufferedInputStream (增加缓存,使读取文件速度大大提升)等子类都在不修改InputStream 代码的情况下扩展了它的功能。

图片

装饰者模式示意图

Spring 中配置 DataSource 的时候,DataSource 可能是不同的数据库和数据源。我们能否根据客户的需求在少修改原有类的代码下动态切换不同的数据源?这个时候就要用到装饰者模式(这一点我自己还没太理解具体原理)。Spring 中用到的包装器模式在类名上含有 Wrapper或者 Decorator。这些类基本上都是动态地给一个对象添加一些额外的职责

总结

Spring 框架中用到了哪些设计模式:

  • 工厂设计模式 : Spring使用工厂模式通过 BeanFactoryApplicationContext 创建 bean 对象。
  • 代理设计模式 : Spring AOP 功能的实现。
  • 单例设计模式 : Spring 中的 Bean 默认都是单例的。
  • 模板方法模式 : Spring 中 jdbcTemplatehibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。
  • 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
  • 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。
  • 适配器模式 :Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller
  • ……

参考

l

java学习路线问题整理

imgimg

Java 基础

为了能让自己写出更优秀的代码,《Effective Java》、《重构》 这两本书没事也可以看

并发

一些关于并发的小问题,拿来自测:

一、什么是线程和进程? 线程与进程的关系,区别及优缺点?

*Linux***的进程、线程、文件描述符是 什么

**答案: Linux 系统中,进程和线程几乎没有区别

Linux 中的进程就是一个数据结构,看明白就可以理解文件描述符、重定 向、管道命令的底层工作原理,最后我们从操作系统的角度看看为什么说线 程和进程基本没有区别。

1、进程是什么

首先,抽象地来说,我们的计算机就是这个东⻄:

进程

这个大的矩形表示计算机的内存空间,其中的小矩形代表进程,左下角的圆 形表示磁盘,右下角的图形表示一些输入输出设备,比如鼠标键盘显示器等 等。另外,注意到内存空间被划分为了两块,上半部分表示用户空间,下半部分表示内核空间

用户空间装着用户进程需要使用的资源,比如你在程序代码里开一个数组, 这个数组肯定存在用户空间;内核空间存放内核进程需要加载的系统资源, 这一些资源一般是不允许用户访问的。但是注意有的用户进程会共享一些内 核空间的资源,比如一些动态链接库等等。

我们用 C 语言写一个 hello 程序,编译后得到一个可执行文件,在命令行运 行就可以打印出一句 hello world,然后程序退出。在操作系统层面,就是新 建了一个进程,这个进程将我们编译出来的可执行文件读入内存空间,然后 执行,最后退出。

你编译好的那个可执行程序只是一个文件,不是进程,可执行文件必须要载 入内存,包装成一个进程才能真正跑起来。进程是要依靠操作系统创建的, 每个进程都有它的固有属性,比如进程号(PID)、进程状态、打开的文件 等等,进程创建好之后,读入你的程序,你的程序才被系统执行。

那么,操作系统是如何创建进程的呢?对于操作系统,进程就是一个数据结 构,我们直接来看 Linux 的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct task_struct { 
// 进程状态
long state;
// 虚拟内存结构体
struct mm_struct *mm;
// 进程号
pid_t pid;
// 指向父进程的指针
struct task_struct __rcu *parent;
// 子进程列表
struct list_head children;
// 存放文件系统信息的指针
struct fs_struct *fs;
// 一个数组,包含该进程打开的文件指针
struct files_struct *files;
};

task_struct 就是Linux内核对于一个进程的描述,也可以称为「进程描述符」。源码比较复杂,我这里就截取了一小部分比较常⻅的。

其中比较有意思的是 mm 指针和 files 指针。 mm 指向的是进程的虚拟内 存,也就是载入资源和可执行文件的地方; files 指针指向一个数组,这 个数组里装着所有该进程打开的文件的指针。

2、文件描述符是什么

先说 files ,它是一个文件指针数组。一般来说,一个进程会 从 files[0] 读取输入,将输出写入 files[1] ,将错误信息写 入 files[2] 。

举个例子,以我们的角度 C 语言的 printf 函数是向命令行打印字符,但是 从进程的角度来看,就是向 files[1] 写入数据;同理, scanf函数就是进程试图从files[0] 这个文件中读取数据。

每个进程被创建时, files 的前三位被填入默认值,分别指向标准输入 流、标准输出流、标准错误流。我们常说的「文件描述符」就是指这个文件 指针数组的索引,所以程序的文件描述符默认情况下 0 是输入,1 是输出, 2 是错误。

我们可以重新画一幅图:

对于一般的计算机,输入流是键盘,输出流是显示器,错误流也是显示器, 所以现在这个进程和内核连了三根线。因为硬件都是由内核管理的,我们的 进程需要通过「系统调用」让内核进程访问硬件资源。

PS:不要忘了,Linux 中一切都被抽象成文件,设备也是文件,可以进行读 和写。

如果我们写的程序需要其他资源,比如打开一个文件进行读写,这也很简单,进行系统调用,让内核把文件打开,这个文件就会被放到 files 的第 4 个位置:

明白了这个原理,输入重定向就很好理解了,程序想读取数据的时候就会 去 files[0] 读取,所以我们只要把 files[0] 指向一个文件,那么程序就会 从这个文件中读取数据,而不是从键盘:

1
$ command < file.txt

同理,输出重定向就是把 files[1] 指向一个文件,那么程序的输出就不会写入到显示器,而是写入到这个文件中:

错误重定向也是一样的,就不再赘述。
管道符其实也是异曲同工,把一个进程的输出流和另一个进程的输入流接起 一条「管道」,数据就在其中传递,不得不说这种设计思想真的很优美:

$ command > file.txt

1
$ cmd1 | cmd2 | cmd3

到这里,你可能也看出「Linux 中一切皆文件」设计思路的高明了,不管是 设备、另一个进程、socket 套接字还是真正的文件,全部都可以读写,统一 装进一个简单的 files 数组,进程通过简单的文件描述符访问相应资源, 具体细节交于操作系统,有效解耦,优美高效。

3、线程是什么

首先要明确的是,多进程和多线程都是并发,都可以提高处理器的利用效率,所以现在的关键是,多线程和多进程有啥区别。

为什么说 Linux 中线程和进程基本没有区别呢,因为从 Linux 内核的角度来 看,并没有把线程和进程区别对待。

我们知道系统调用 fork() 可以新建一个子进程,函数 pthread() 可以新建 一个线程。但无论线程还是进程,都是用task_struct结构表示的,唯一的 区别就是共享的数据区域不同

换句话说,线程看起来跟进程没有区别,只是线程的某些数据区域和其父进 程是共享的,而子进程是拷⻉副本,而不是共享。就比如说, mm 结构 和 files 结构在线程中都是共享的,我画两张图你就明白了:

所以说,我们的多线程程序要利用锁机制,避免多个线程同时往同一区域写 入数据,否则可能造成数据错乱。

那么你可能问,既然进程和线程差不多,而且多进程数据不共享,即不存在 数据错乱的问题,为什么多线程的使用比多进程普遍得多呢?

因为现实中数据共享的并发更普遍呀,比如十个人同时从一个账户取十元, 我们希望的是这个共享账户的余额正确减少一百元,而不是希望每人获得一 个账户的拷⻉,每个拷⻉账户减少十元。

当然,必须要说明的是,只有 Linux 系统将线程看做共享数据的进程,不对 其做特殊看待,其他的很多操作系统是对线程和进程区别对待的,线程有其 特有的数据结构,我个人认为不如 Linux 的这种设计简洁,增加了系统的复 杂度。

在 Linux 中新建线程和进程的效率都是很高的,对于新建进程时内存区域拷 ⻉的问题,Linux 采用了 copy-on-write 的策略优化,也就是并不真正复制父 进程的内存空间,而是等到需要写操作时才去复制。所以 Linux 中新建进 程和新建线程都是很迅速的

二、说说并发与并行的区别?

  • 并发:一个处理器同时处理多个任务。
  • 并行:多个处理器或者是多核的处理器同时处理多个不同的任务.

前者是逻辑上的同时发生(simultaneous),而后者是物理上的同时发生.

  • 并发性(concurrency),又称共行性,是指能处理多个同时性活动的能力,并发事件之间不一定要同一时刻发生。
  • 并行(parallelism)是指同时发生的两个并发事件,具有并发的含义,而并发则不一定并行。

来个比喻:并发和并行的区别就是一个人同时吃三个馒头和三个人同时吃三个馒头。

img

并发与并行的区别

下图反映了一个包含8个操作的任务在一个有两核心的CPU中创建四个线程运行的情况。假设每个核心有两个线程,那么每个CPU中两个线程会交替并发,两个CPU之间的操作会并行运算。单就一个CPU而言两个线程可以解决线程阻塞造成的不流畅问题,其本身运行效率并没有提高,多CPU的并行运算才真正解决了运行效率问题,这也正是并发和并行的区别。

img

双核四线程运行示意图

三、为什么要使用多线程呢?

从系统应用上来思考:
  • 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间切换和调度的成本远远小于进程。另外,多核 CPU 时代,意味着多个线程可以同时运行,这减少了线程上下文切换的开销;
  • 如今的系统,动不动就要求百万级甚至亿万级的并发量,而多线程并发编程,正是开发高并发系统的基础,利用好多线程机制,可以大大提高系统整体的并发能力以及性能。
从计算机背后来探讨:

单核时代: 在单核时代,多线程主要是为了提高 CPU 和 IO 设备的综合利用率。举个例子:当只有一个线程工作的时候,会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。可以简单地理解成,这两者的利用率最高都是 50%左右。但是当有两个线程的时候就不一样了,一个线程执行 CPU 计算时,另外一个线程就可以进行 IO 操作,这样 CPU 和 IO 设备两个的利用率就可以在理想情况下达到 100%;

多核时代: 多核时代多线程主要是为了提高 CPU 利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU 只有一个 CPU 核心被利用到,而创建多个线程,就可以让多个 CPU 核心被利用到,这样就提高了 CPU 的利用率。

四、创建线程有哪几种方式?(a.继承 Thread 类;b.实现 Runnable 接口;c. 使用 Executor 框架;d.使用 FutureTask)

五、说说线程的生命周期和状态?

那么现在我们来了解线程一个完整的生命周期的运行过程,与下图可以看出有:新建 - 就绪 - 运行 - 阻塞 - 死亡五个过程。

img

下面我们一个一个的来介绍:

  • 新建:刚刚创建还在内存当中,还没有在可调度线程池中,还不能被cpu调度执行工作。
  • 就绪:进入调度池,可被调度。
  • 运行:CPU负责调度”可调度线程池”中的处于”就绪状态”的线程,线程执行结束之前,状态可能会在”就绪”和”运行”之间来回的切换。“就绪”和”运行”之间的状态切换由CPU来完成,程序员无法干涉
  • 阻塞:正在运行的线程,当满足某个条件时,可以用休眠或者锁来阻塞线程的执行,被移出调度池,进入内存,不可执行。
  • 死亡:分为两种情况。正常死亡,线程执行结束。非正常死亡,程序突然崩溃/当满足某个条件后,在线程内部强制线程退出,调用exit方法。
    exit方法的作用和总结
  1. 使当前线程退出.

  2. 不能在主线程中调用该方法,会使主线程退出.

  3. 当前线程死亡之后,这个线程中的代码都不会被执行.

  4. 在调用此方法之前一定要注意释放之前由C语言框架创建的对象.

六、什么是上下文切换?

现在linux是大多基于抢占式,CPU给每个任务一定的服务时间,当时间片轮转的时候,需要把当前状态保存下来,同时加载下一个任务,这个过程叫做上下文切换。时间片轮转的方式,使得多个任务利用一个CPU执行成为可能,但是保存现场和加载现场,也带来了性能消耗。 那线程上下文切换的次数和时间以及性能消耗如何看呢?

上下文切换的性能消耗在哪里呢?
context switch过高,会导致CPU像个搬运工,频繁在寄存器和运行队列直接奔波 ,更多的时间花在了线程切换,而不是真正工作的线程上。直接的消耗包括CPU寄存器需要保存和加载,系统调度器的代码需要执行。间接消耗在于多核cache之间的共享数据。

引起上下文切换的原因有哪些?
对于抢占式操作系统而言, 大体有几种:
1、当前任务的时间片用完之后,系统CPU正常调度下一个任务;
2、当前任务碰到IO阻塞,调度线程将挂起此任务,继续下一个任务;
3、多个任务抢占锁资源,当前任务没有抢到,被调度器挂起,继续下一个任务;
4、用户代码挂起当前任务,让出CPU时间;
5、硬件中断;
监测Linux的应用的时候,当CPU的利用率非常高,但是系统的性能却上不去的时候,不妨监控一下线程/进程的切换,看看是不是context switching导致的overhead过高。
常用命令: pidstat vmstat

七、什么是线程死锁?如何避免死锁?

死锁

当线程A持有独占锁a,并尝试去获取独占锁b的同时,线程B持有独占锁b,并尝试获取独占锁a的情况下,就会发生AB两个线程由于互相持有对方需要的锁,而发生的阻塞现象,我们称为死锁。

下面用一个非常简单的死锁示例来帮助你理解死锁的定义。

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
public class DeadLockDemo {

public static void main(String[] args) {
// 线程a
Thread td1 = new Thread(new Runnable() {
public void run() {
DeadLockDemo.method1();
}
});
// 线程b
Thread td2 = new Thread(new Runnable() {
public void run() {
DeadLockDemo.method2();
}
});

td1.start();
td2.start();
}

public static void method1() {
synchronized (String.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程a尝试获取integer.class");
synchronized (Integer.class) {

}

}
}

public static void method2() {
synchronized (Integer.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程b尝试获取String.class");
synchronized (String.class) {

}

}
}

}

----------------
线程b尝试获取String.class
线程a尝试获取integer.class
....
...
..
.
无限阻塞下去
如何避免死锁?

教科书般的回答应该是,结合“哲学家就餐“模型,分析并总结出以下死锁的原因,最后得出“避免死锁就是破坏造成死锁的,若干条件中的任意一个”的结论。

造成死锁必须达成的4个条件(原因):

  1. 互斥条件:一个资源每次只能被一个线程使用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源,在未使用完之前,不能强行剥夺。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

但是,“哲学家就餐”光看名字就很讨厌,然后以上这4个条件看起来也很绕口,再加上笔者又是个懒人,所以要让我在面试时把这些“背诵”出来实在是太难了!必须要想办法把这4个条件简化一下!
于是,通过对4个造成死锁的条件进行逐条分析,我们可以得出以下4个结论。

  1. 互斥条件 —> 独占锁的特点之一。
  2. 请求与保持条件 —> 独占锁的特点之一,尝试获取锁时并不会释放已经持有的锁
  3. 不剥夺条件 —> 独占锁的特点之一。
  4. 循环等待条件 —> 唯一需要记忆的造成死锁的条件。

不错!复杂的死锁条件经过简化,现在需要记忆的仅只有独占锁与第四个条件而已。

所以,面对如何避免死锁这个问题,我们只需要这样回答!
: 在并发程序中,避免了逻辑中出现复数个线程互相持有对方线程所需要的独占锁的的情况,就可以避免死锁。

下面我们通过“破坏”第四个死锁条件,来解决第一个小节中的死锁示例并证明我们的结论。

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
public class DeadLockDemo2 {

public static void main(String[] args) {
// 线程a
Thread td1 = new Thread(new Runnable() {
public void run() {
DeadLockDemo2.method1();
}
});
// 线程b
Thread td2 = new Thread(new Runnable() {
public void run() {
DeadLockDemo2.method2();
}
});

td1.start();
td2.start();
}

public static void method1() {
synchronized (String.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程a尝试获取integer.class");
synchronized (Integer.class) {
System.out.println("线程a获取到integer.class");
}

}
}

public static void method2() {
// 不再获取线程a需要的Integer.class锁。
synchronized (String.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程b尝试获取Integer.class");
synchronized (Integer.class) {
System.out.println("线程b获取到Integer.class");
}

}
}

}
-----------------
线程a尝试获取integer.class
线程a获取到integer.class
线程b尝试获取Integer.class
线程b获取到Integer.class

在上面的例子中,由于已经不存在线程a持有线程b需要的锁,而线程b持有线程a需要的锁的逻辑了,所以Demo顺利执行完毕。

总结

是否能够简单明了的在面试中阐述清楚死锁产生的原因,并给出解决死锁的方案,可以体现程序员在面对对并发问题时思路是否清晰,对并发的基础掌握是否牢固等等。
而且在实际项目中并发模块的逻辑往往比本文的示例复杂许多,所以写并发应用之前一定要充分理解本文所总结的要点,并切记,并发程序编程在不显著影响程序性能的情况下,一定要尽可能的保守。

八、说说 sleep() 方法和 wait() 方法区别和共同点?

1.方法来源区别

wait方法定义在Object上,Thread.sleep()定义在Thread上(这很重要,定义决定作用范围)

2.关于锁和cpu

结论:二者都释放cpu,wait()释放锁,Thread.sleep()不会释放锁.
解释如下:
1.别管是Object.wait()还是Thread.sleep(),都是暂停执行,所以这里都会释放cpu.
2.Object.wait()方法是对象拥有,然后对象锁又是在synchronized同步代码块中使用,所以Object.wait()方法拥有锁的控制权,所以他会释放锁资源.而Thread.sleep()是Thread上的静态方法,所以只能使当前线程睡眠,但是它和锁没有任何关系,所以就没有锁的释放这一问题.

3.作用范围
  • Object.wait()方法只能在synchronized快中调用,并且需要和notify和notifyAll配合使用.

  • Thread.sleep()是可以在任何上下文调用的,注意是暂停当前的线程
    所以就方法而言,Object.wait()主要用在多线程之间的协同工作,Thread.sleep()主要是控制一个线程的执行时间长短.

    4 关于异常

    Object.wait()方法和Thread.sleep()都抛出 InterruptedException,并且方法定义为final,
    所以方法不能被重写,那么在使用 该方法时就只能 try()catch(){}异常,(为什么说只能try,因为如果不捕获异常,那么也意味着你的方法抛出的异常就只能是InterruptedException,或者它的子类,所以这里一般都是捕获异常并处理异常,可以在catch中抛出其他异常)

    九、synchronized 关键字、volatile 关键字

  • volatile是通知jvm当前变量在寄存器或者cpu中的值是不确定的,需要从主存中读取。不会阻塞线程。

  • synchronized则是通过锁机制来控制变量是否可以访问。当变量被锁时,其他线程访问变量将被阻塞,直至锁释放。

volatile
  1. volatile保证其他线程对这个变量操作时是立即可见的,即操作的是从内存中读取的最新值
  2. 无法保证原子性
  3. 只能修饰变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Test {
private volatile int count;
public void increase() {
count++;
System.out.println("----" + count);
}
public static void main(String[] args) throws Exception {
Test test = new Test();

for (int i = 0; i < 10; i++) {
new Thread() {
public void run() {
for (int j = 0; j < 10; j++) {
test.increase();
}
};
}.start();
}
}
}
  • 控制台输出:

img

控制台输出

  • 使用场景(DCL双重检测锁):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Singleton{
private volatile static Singleton instance = null;

private Singleton() {}

public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
synchronized
  1. 保证原子性
  2. 即可修饰变量也可修饰方法
  3. 会阻塞线程
    1)synchronized非静态方法
    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
    public class Test {
    public synchronized void increase1() {
    for (int i = 0; i < 5; i++) {
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
    }
    System.out.println("increase1---->" + i);
    }
    }

    public synchronized void increase2() {
    for (int i = 0; i < 5; i++) {
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
    }
    System.out.println("increase2---->" + i);
    }
    }

    public static void main(String[] args) throws Exception {
    Test test = new Test();


    new Thread() {
    public void run() {
    test.increase1();
    };
    }.start();
    new Thread() {
    public void run() {
    test.increase2();
    };
    }.start();
    }
    }
  • 控制台输出:

img

  • 结论:

如果一个对象有多个synchronized方法,多个线程同时调用该对象的方法,将会同步执行,即同时只能有一个synchronized方法被调用,其他调用将被阻塞直至该方法执行完

2)synchronized静态方法

懒。。 直接给结论了

synchronized静态方法和非静态方法的区别在于给方法上锁的对象不一样,非静态方法是给调用的对象上锁,静态方法是给类的Class对象上锁

3)synchronized块
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
public class Test {
public void increase1() {
System.out.println("increase1----------> start");
synchronized (this) {
for (int i = 0; i < 5; i++) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("increase1---->" + i);
}
}
System.out.println("increase1----------> end");
}

public void increase2() {
System.out.println("increase2----------> start");
synchronized(this) {
for (int i = 0; i < 5; i++) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("increase2---->" + i);
}
}
System.out.println("increase2----------> end");
}

public static void main(String[] args) throws Exception {
Test test = new Test();


new Thread() {
public void run() {
test.increase1();
};
}.start();
new Thread() {
public void run() {
test.increase2();
};
}.start();
}
}
  • 控制台输出:

img

  • 结论

synchronized方法是控制同时只能有一个线程执行synchronized方法;synchronized块是控制同时只能有一个线程执行synchronized块中的内容

十、ThreadLocal 有啥用(解决了什么问题)?怎么用?原理了解吗?内存泄露问题了解吗?

ThreadLoacl是什么

在了解ThreadLocal之前,我们先了解下什么是线程封闭

把对象封闭在一个线程里,即使这个对象不是线程安全的,也不会出现并发安全问题。

实现线程封闭大致有三种方式:

  • Ad-hoc线程封闭:维护线程封闭性的职责完全由程序来承担,不推荐使用
  • 栈封闭:就是用(stack)来保证线程安全
1
2
3
4
public void testThread() {
StringBuilder sb = new StringBuilder();
sb.append("Hello");
}

StringBuilder是线程不安全的,但是它只是个局部变量,局部变量存储在虚拟机栈虚拟机栈是线程隔离的,所以不会有线程安全问题

  • ThreadLocal线程封闭:简单易用

第三种方式就是通过ThreadLocal来实现线程封闭,线程封闭的指导思想是封闭,而不是共享。所以说ThreadLocal是用来解决变量共享的并发安全问题,多少有些不精确。

使用

JDK1.2开始提供的java.lang.ThreadLocal的使用方式非常简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ThreadLocalDemo {

public static void main(String[] args) throws InterruptedException {

final ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("main-thread : Hello");

Thread thread = new Thread(() -> {
// 获取不到主线程设置的值,所以为null
System.out.println(threadLocal.get());
threadLocal.set("sub-thread : World");
System.out.println(threadLocal.get());
});
// 启动子线程
thread.start();
// 让子线程先执行完成,再继续执行主线
thread.join();
// 获取到的是主线程设置的值,而不是子线程设置的
System.out.println(threadLocal.get());
threadLocal.remove();
System.out.println(threadLocal.get());
}
}

运行结果

1
2
3
4
null
sub-thread : World
main-thread : Hello
null

运行结果说明了ThreadLocal只能获取本线程设置的值,也就是线程封闭。基本上,ThreadLocal对外提供的方法只有三个get()、set(T)、remove()。

原理

使用方式非常简单,所以我们来看看ThreadLocal的源码。ThreadLocal内部定义了一个静态ThreadLocalMap类,ThreadLocalMap内部又定义了一个Entry类,这里只看一些主要的属性和方法

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
public class ThreadLocal<T> {

public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}

// 从这里可以看出ThreadLocalMap对象是被Thread类持有的
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

// 内部类ThreadLocalMap
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
// 内部类Entity,实际存储数据的地方
// Entry的key是ThreadLocal对象,不是当前线程ID或者名称
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// 注意这里维护的是Entry数组
private Entry[] table;
}
}

根据上面的源码,可以大致画出ThreadLocal在虚拟机内存中的结构

img

实线箭头表示强引用,虚线箭头表示弱引用。需要注意的是:

  • ThreadLocalMap虽然是在ThreadLocal类中定义的,但是实际上被Thread持有。
  • Entry的key是(虚引用的)ThreadLocal对象,而不是当前线程ID或者线程名称。
  • ThreadLocalMap中持有的是Entry数组,而不是Entry对象。

对于第一点,ThreadLocalMap被Thread持有是为了实现每个线程都有自己独立的ThreadLocalMap对象,以此为基础,做到线程隔离。第二点和第三点理解,我们先来想一个问题,如果同一个线程中定义了多个ThreadLocal对象,内存结构应该是怎样的?此时再来看一下ThreadLocal.set(T)方法:

1
2
3
4
5
6
7
8
9
10
11
public void set(T value) {
// 获取当前线程对象
Thread t = Thread.currentThread();
// 根据线程对象获取ThreadLocalMap对象(ThreadLocalMap被Thread持有)
ThreadLocalMap map = getMap(t);
// 如果ThreadLocalMap存在,则直接插入;不存在,则新建ThreadLocalMap
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

也就是说,如果程序定义了多个ThreadLocal,会共用一个ThreadLocalMap对象,所以内存结构应该是这样

img

这个内存结构图解释了第二点和第三点。假设Entry中key为当前线程ID或者名称的话,那么程序中定义多个ThreadLocal对象时,Entry数组中的所有Entry的key都一样(或者说只能存一个value)。ThreadLocalMap中持有的是Entry数组,而不是Entry,则是因为程序可定义多个ThreadLocal对象,自然需要一个数组。

内存泄漏

ThreadLocal会发生内存泄漏吗?

仔细看下ThreadLocal内存结构就会发现,Entry数组对象通过ThreadLocalMap最终被Thread持有,并且是强引用。也就是说Entry数组对象的生命周期和当前线程一样。即使ThreadLocal对象被回收了,Entry数组对象也不一定被回收,这样就有可能发生内存泄漏。ThreadLocal在设计的时候就提供了一些补救措施:

  • Entry的key是弱引用的ThreadLocal对象,很容易被回收,导致key为null(但是value不为null)。所以在调用get()、set(T)、remove()等方法的时候,会自动清理key为null的Entity。
  • remove()方法就是用来清理无用对象,防止内存泄漏的。所以每次用完ThreadLocal后需要手动remove()。

有些文章认为是弱引用导致了内存泄漏,其实是不对的。假设把弱引用变成强引用,这样无用的对象key和value都不为null,反而不利于GC,只能通过remove()方法手动清理,或者等待线程结束生命周期。也就是说ThreadLocalMap的生命周期由持有它的线程来决定,线程如果不进入terminated状态,ThreadLocalMap就不会被GC回收,这才是ThreadLocal内存泄露的原因。

应用场景
  • 维护JDBC的java.sql.Connection对象,因为每个线程都需要保持特定的Connection对象。
  • Web开发时,有些信息需要从controller传到service传到dao,甚至传到util类。看起来非常不优雅,这时便可以使用ThreadLocal来优雅的实现。
  • 包括线程不安全的工具类,比如Random、SimpleDateFormat等
与synchronized的关系

有些文章拿ThreadLocal和synchronized比较,其实它们的实现思想不一样。

  • synchronized是同一时间最多只有一个线程执行,所以变量只需要存一份,算是一种时间换空间的思想
  • ThreadLocal是多个线程互不影响,所以每个线程存一份变量,算是一种空间换时间的思想
总结

ThreadLocal是一种隔离的思想,当一个变量需要进行线程隔离时,就可以考虑使用ThreadLocal来优雅的实现。

十一、为什么要用线程池?ThreadPoolExecutor 类的重要参数了解吗?ThreadPoolExecutor 饱和策略了解吗?线程池原理了解吗?几种常见的线程池了解吗?为什么不推荐使用FixedThreadPool?如何设置线程池的大小?

十二、AQS 了解么?原理?AQS 常用组件:Semaphore (信号量)、CountDownLatch (倒计时器) CyclicBarrier(循环栅栏)

1 AQS 概述

AQS 的全称为(AbstractQueuedSynchronizer),中文即“队列同步器”,这个类放在 java.util.concurrent.locks 包下面。

img

AQS是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如上篇文章写的ReentrantLock与ReentrantReadWriteLock。除此之外,AQS还能构造出Semaphore,FutureTask(jdk1.7) 等同步器。

2 AQS 原理
2.1 同步队列

AQS 是依赖 CLH 队列锁来完成同步状态的管理。当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构建为一个**节点(Node)**并将其加入同步队列,同步会阻塞当前线程,当同步状态释放时,会将首节点中的线程唤醒,使其再次尝试获取同步状态。

CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(FIFO双向队列)(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配

同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点信息。

属性类型与名称 描述
int waitStatus 等待状态(如CANCELLED=1、SIGNAL=-1、CONDITION=-2、PROPAGATE=-3、INITIAL=0)
Node prev 前驱节点(当节点加入同步队列时被设置,在尾部添加)
Node next 后继节点
Thread thread 当前获取同步状态的线程

节点源码如下:

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
static final class Node {
// 表示该节点等待模式为共享式,通常记录于nextWaiter,
// 通过判断nextWaiter的值可以判断当前结点是否处于共享模式
static final Node SHARED = new Node();
// 表示节点处于独占式模式,与SHARED相对
static final Node EXCLUSIVE = null;
// waitStatus的不同状态
// 当前结点是因为超时或者中断取消的,进入该状态后将无法恢复
static final int CANCELLED = 1;
// 当前结点的后继结点是(或者将要)由park导致阻塞的,当结点被释放或者取消时,需要通过unpark唤醒后继结点
static final int SIGNAL = -1;
// 表明结点在等待队列中,结点线程等待在Condition上
// 当其他线程对Condition调用了signal()方法时,会将其加入到同步队列中
static final int CONDITION = -2;
// 下一次共享式同步状态的获取将会无条件地向后继结点传播
static final int PROPAGATE = -3;
volatile int waitStatus;
// 记录前驱结点
volatile Node prev;
// 记录后继结点
volatile Node next;
// 记录当前的线程
volatile Thread thread;
// 用于记录共享模式(SHARED), 也可以用来记录CONDITION队列
Node nextWaiter;
// 通过nextWaiter的记录值判断当前结点的模式是否为共享模式
final boolean isShared() { return nextWaiter == SHARED;}
// 获取当前结点的前置结点
final Node predecessor() throws NullPointerException { ... }
// 用于初始化时创建head结点或者创建SHARED结点
Node() {}
// 在addWaiter方法中使用,用于创建一个新的结点
Node(Thread thread, Node mode) {
this.nextWaiter = mode;
this.thread = thread;
}
// 在CONDITION队列中使用该构造函数新建结点
Node(Thread thread, int waitStatus) {
this.waitStatus = waitStatus;
this.thread = thread;
}
}
// 记录头结点
private transient volatile Node head;
// 记录尾结点
private transient volatile Node tail;

节点是构成同步队列的基础,同步器拥有首节点(Head)和尾节点(Tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部。同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect, Node update),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。

img

首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。设置首节点是通过获取同步状态成功的线程来完成的,不需要使用CAS来保证,只需将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可。

img

2.2 同步状态
1) 独占式(EXCLUSIVE)

独占式(EXCLUSIVE)获取需重写tryAcquiretryRelease方法,并访问acquirerelease方法实现相应的功能。

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
public final void acquire(int arg) {
// 如果线程直接获取成功,或者再尝试获取成功后都是直接工作,
// 如果是从阻塞状态中唤醒开始工作的线程,将当前的线程中断
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
// 封装线程,新建结点并加入到同步队列中
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
// 尝试入队, 成功返回
if (pred != null) {
node.prev = pred;
// CAS操作设置队尾
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 通过CAS操作自旋完成node入队操作
enq(node);
return node;
}
// 在同步队列中等待获取同步状态
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
// 自旋
for (;;) {
final Node p = node.predecessor();
// 前驱节点是否为头节点&&tryAcquire获取同步状态
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null;
failed = false;
return interrupted;
}
// 获取不到同步状态,将前置结点标为SIGNAL状态并且通过park操作将Node封装的线程阻塞
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
// 如果获取失败,将node标记为CANCELLED
cancelAcquire(node);
}
}

独占式获取同步状态流程:

img

通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。

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
public final boolean release(int arg) {
// 首先尝试释放并更新同步状态
if (tryRelease(arg)) {
Node h = head;
// 检查是否需要唤醒后置结点
if (h != null && h.waitStatus != 0)
// 唤醒后置结点
unparkSuccessor(h);
return true;
}
return false;
}
// 唤醒后继结点
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
// 通过CAS操作将waitStatus更新为0
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
// 检查后置结点,若为空或者状态为CANCELLED,找到后置非CANCELLED结点
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 唤醒后继结点
if (s != null)
LockSupport.unpark(s.thread);
}
2)共享式(SHARED)

共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态

共享式(SHARED)获取需重写tryAcquireSharedtryReleaseShared方法,并访问acquireSharedreleaseShared方法实现相应的功能。与独占式相对,共享式支持多个线程同时获取到同步状态并进行工作,如 Semaphore、CountDownLatch、 CyclicBarrier等。ReentrantReadWriteLock 可以看成是组合式,因为 ReentrantReadWriteLock 也就是读写锁允许多个线程同时对某一资源进行读。

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
public final void acquireShared(int arg) {
// 尝试共享式获取同步状态,如果成功获取则可以继续执行,否则执行doAcquireShared
if (tryAcquireShared(arg) < 0)
// 以共享式不停得尝试获取同步状态
doAcquireShared(arg);
}

private void doAcquireShared(int arg) {
// 向同步队列中新增一个共享式的结点
final Node node = addWaiter(Node.SHARED);
// 标记获取失败状态
boolean failed = true;
try {
// 标记中断状态(若在该过程中被中断是不会响应的,需要手动中断)
boolean interrupted = false;
// 自旋
for (;;) {
// 获取前置结点
final Node p = node.predecessor();
// 若前置结点为头结点
if (p == head) {
// 尝试获取同步状态
int r = tryAcquireShared(arg);
// 若获取到同步状态。
if (r >= 0) {
// 此时,当前结点存储的线程恢复执行,需要将当前结点设置为头结点并且向后传播,
// 通知符合唤醒条件的结点一起恢复执行
setHeadAndPropagate(node, r);
p.next = null;
// 需要中断,中断当前线程
if (interrupted)
selfInterrupt();
// 获取成功
failed = false;
return;
}
}
// 获取同步状态失败,需要进入阻塞状态
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
// 获取失败,CANCELL node
if (failed)
cancelAcquire(node);
}
}
// 将node设置为同步队列的头结点,并且向后通知当前结点的后置结点,完成传播
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head;
setHead(node);
// 向后传播
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if(s == null || s.isShared())
doReleaseShared();
}
}

与独占式一样,共享式获取也需要释放同步状态,通过调用releaseShared(intarg)方法可以释放同步状态,释放同步状态成功后,会唤醒后置结点,并且保证传播性。

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
public final boolean releaseShared(int arg) {
// 尝试释放同步状态
if (tryReleaseShared(arg)) {
// 成功后唤醒后置结点
doReleaseShared();
return true;
}
return false;
}
// 唤醒后置结点
private void doReleaseShared() {
// 循环的目的是为了防止新结点在该过程中进入同步队列产生的影响,同时要保证CAS操作的完成
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue;
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue;
}
if (h == head)
break;
}
}
3)超时获取方式

通过调用同步器的doAcquireNanos(int arg, long nanosTimeout)方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回true,否则,返回false。该方法提供了传统Java同步操作(比如synchronized关键字)所不具备的特性。

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
private boolean doAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
if (nanosTimeout <= 0L)
return false;
// 计算超时的时间=当前虚拟机的时间+设置的超时时间
final long deadline = System.nanoTime() + nanosTimeout;
// 调用addWaiter将当前线程封装成独占模式的节点,并且加入到同步队列尾部。
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
// 自旋
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
// 如果当前节点的前驱节点为头结点,则让当前节点去尝试获取锁。
setHead(node);
p.next = null;
failed = false;
return true;
}
// 如果当前节点的前驱节点不是头结点,或当前节点获取锁失败,
// 则再次判断当前线程是否已经超时。
nanosTimeout = deadline - System.nanoTime();
if (nanosTimeout <= 0L)
return false;
// 调用shouldParkAfterFailedAcquire方法,告诉当前节点的前驱节点,马上进入
// 等待状态了,即做好进入等待状态前的准备。
if (shouldParkAfterFailedAcquire(p, node) &&
nanosTimeout > spinForTimeoutThreshold)
// 调用LockSupport.parkNanos方法,将当前线程设置成超时等待的状态。
LockSupport.parkNanos(this, nanosTimeout);
if (Thread.interrupted())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}

由上面代码可知,超时获取也是调用addWaiter将当前线程封装成独占模式的节点,并且加入到同步队列尾部。

超时获取与独占式获取同步状态区别在于获取同步状态失败后的处理。如果当前线程获取同步状态失败,则判断是否超时(nanosTimeout小于等于0表示已经超时);如果没有超时,重新计算超时间隔nanosTimeout,然后使当前线程等待nanosTimeout纳秒(当已到设置的超时时间,该线程会从LockSupport.parkNanos(Object blocker, long nanos)方法返回)。

独占式超时获取同步状态流程:

img

2.3 模板方法

AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。

1
private volatile int state;// 共享变量,使用volatile修饰保证线程可见性

同步状态state通过 protected 类型的getStatesetStatecompareAndSetState方法进行操作

1
2
3
4
5
6
7
8
9
10
11
12
// 返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
// CAS更新同步状态,该方法能够保证状态设置的原子性
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

同步器的设计是基于模板方法模式的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。

自定义同步器时需要重写下面几个 AQS 提供的模板方法:

1
2
3
4
5
isHeldExclusively()// 该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)// 独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)// 独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)// 共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)// 共享方式。尝试释放资源,成功则返回true,失败则返回false。

同步器提供的模板方法基本上分为3类:独占式获取与释放同步状态、共享式获取与释放同步状态和查询同步队列中的等待线程情况。

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryReleasetryAcquireShared-tryReleaseShared中的一种即可。

以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程 lock()时,会调用 tryAcquire()独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态的。

再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown()一次,state 会 CAS(Compare and Swap)减 1。等到所有子线程都执行完后(即 state=0),会 unpark()主调用线程,然后主调用线程就会从 await()函数返回,继续后续动作。

但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock

下面就来学习几个常用的并发同步工具。

3 Semaphore(信号量)

Semaphore(信号量)用来控制 同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以指定多个线程同时访问某个资源

以停车场为例。假设一个停车场只有10个车位,这时如果同时来了15辆车,则只允许其中10辆不受阻碍的进入。剩下的5辆车则必须在入口等待,此后来的车也都不得不在入口处等待。这时,如果有5辆车离开停车场,放入5辆;如果又离开2辆,则又可以放入2辆,如此往复。

在这个停车场系统中,车位即是共享资源,每辆车就好比一个线程,信号量就是空车位的数目。

Semaphore中包含了一个实现了AQS的同步器Sync,以及它的两个子类FairSync和NonFairSync。查看Semaphore类结构:

img

可见Semaphore也是区分公平模式和非公平模式的。

  • 公平模式: 调用 acquire 的顺序就是获取许可证的顺序,遵循 FIFO。
  • 非公平模式: 抢占式的。

Semaphore 对应的两个构造方法如下:

1
2
3
4
5
6
7
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}

public Semaphore(int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}

这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。

Semaphore实现原理这里就不分析了,可以参考死磕 java同步系列之Semaphore源码解析这篇文章。

需要明白的是,Semaphore也是共享锁的一种实现。它默认构造AQS的state为permits。当执行任务的线程数量超出permits,那么多余的线程将会被放入阻塞队列Park,并自旋判断state是否大于0。只有当state大于0的时候,阻塞的线程才能继续执行,此时先前执行任务的线程继续执行release方法,release方法使得state的变量会加1,那么自旋的线程便会判断成功。如此,每次只有最多不超过permits数量的线程能自旋成功,便限制了执行任务线程的数量。

Semaphore常用于做流量控制,特别是公用资源有限的应用场景。

常用方法 描述
acquire()/acquire(int permits) 获取许可证。获取许可失败,会进入AQS的队列中排队。
tryAcquire()/tryAcquire(int permits) 获取许可证。获取许可失败,直接返回false。
tryAcquire(long timeout, TimeUnit unit)/ tryAcquire(int permits, long timeout, TimeUnit unit) 超时等待获取许可证。
release() 归还许可证。
intavailablePermits() 返回此信号量中当前可用的许可证数。
intgetQueueLength() 返回正在等待获取许可证的线程数。
booleanhasQueuedThreads() 是否有线程正在等待获取许可证。
void reducePermits(int reduction) 减少reduction个许可证,是个protected方法。
Collection getQueuedThreads() 返回所有等待获取许可证的线程集合,是个protected方法。

使用示例:

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
public class SemaphoreTest {
private static final int THREAD_COUNT = 50;

public static void main(String[] args) throws InterruptedException {
// 创建一个具有固定线程数量的线程池对象
ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_COUNT);
// 一次只能允许执行的线程数量
final Semaphore semaphore = new Semaphore(10);

for (int i = 0; i < THREAD_COUNT; i++) {
final int threadNum = i;
threadPool.execute(() -> {
try {
semaphore.acquire();// 获取1个许可,所以可运行线程数量为10/1=10
test(threadNum);
semaphore.release();// 释放1个许可,所以可运行线程数量为10/1=10
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
threadPool.shutdown();
}

public static void test(int threadNum) throws InterruptedException {
Thread.sleep(1000);// 模拟请求的耗时操作
System.out.println("threadNum:" + threadNum);
Thread.sleep(1000);// 模拟请求的耗时操作
}
}

在代码中,虽然有50个线程在执行,但是只允许10个并发执行。Semaphore的构造方法Semaphore(int permits)接受一个整型的数字,表示可用的许可证数量。Semaphore(10)表示允许10个线程获取许可证,也就是最大并发数是10。Semaphore的用法也很简单,首先线程使用Semaphore的acquire()方法获取一个许可证,使用完之后调用release()方法归还许可证。

除了 acquire方法之外,另一个比较常用的与之对应的方法是tryAcquire方法,该方法如果获取不到许可就立即返回 false。

4 CountDownLatch (倒计时器)
4.1 概述

在日常开发中经常会遇到需要在主线程中开启多个线程去并行执行任务,并且主线程需要等待所有子线程执行完毕后再进行汇总的场景。jdk 1.5之前一般都使用线程的join()方法来实现这一点,但是join方法不够灵活,难以满足不同场景的需要,所以jdk 1.5之后concurrent包提供了CountDownLatch这个类。

CountDownLatch是一种同步辅助工具,它允许一个或多个线程等待其他线程完成操作

CountDownLatch是通过一个计数器来实现的,计数器的初始化值为线程的数量。每当一个线程完成了自己的任务后,计数器的值就相应得减1。当计数器到达0时,表示所有的线程都已完成任务,然后在闭锁上等待的线程就可以恢复执行任务。

img

CountDownLatch的方法:

方法 描述
await() 调用该方法的线程等到构造方法传入的 N 减到 0 的时候,才能继续往下执行。
await(long timeout, TimeUnit unit) 调用该方法的线程等到指定的 timeout 时间后,不管 N 是否减至为 0,都会继续往下执行。
countDown() 使 CountDownLatch 初始值 N 减 1。
getCount() 获取当前 CountDownLatch 维护的值,也就是AQS的state的值。

CountDownLatch的实现原理,可以查看 【JUC】JDK1.8源码分析之CountDownLatch(五)一文。

根据源码分析可知,CountDownLatch是AQS中共享锁的一种实现。AbstractQueuedSynchronizer中维护了一个volatile类型的整数state,volatile可以保证多线程环境下该变量的修改对每个线程都可见,并且由于该属性为整型,因而对该变量的修改也是原子的。

CountDownLatch默认构造 AQS 的 state 值为 count。创建一个CountDownLatch对象时,所传入的整数N就会赋值给state属性。

当调用countDown()方法时,其实是调用了tryReleaseShared方法以CAS的操作来对state减1;而调用await()方法时,当前线程就会判断state属性是否为0。如果为0,阻塞线程被唤醒继续往下执行;如果不为0,则使当前线程放入阻塞队列Park,直至最后一个线程调用了countDown()方法使得state == 0,再唤醒在await()方法中等待的线程。

特别注意的是

CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。如果需要能重置计数,可以使用CyclicBarrier

4.2 应用场景

CountDownLatch主要应用场景:

  1. 实现最大的并行性:同时启动多个线程,实现最大程度的并行性。例如110跨栏比赛中,所有运动员准备好起跑姿势,进入到预备状态,等待裁判一声枪响。裁判开了枪,所有运动员才可以开跑。
  2. 开始执行前等待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
public class CountDownLatchTest {
private static final int THREAD_COUNT = 30;

public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(10);

final CountDownLatch countDownLatch = new CountDownLatch(THREAD_COUNT);
for (int i = 0; i < THREAD_COUNT; i++) {
final int threadNum = i;
threadPool.execute(() -> {
try {
Thread.sleep(1000);// 模拟请求的耗时操作
System.out.println("子线程:" + threadNum);
Thread.sleep(1000);// 模拟请求的耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();// 表示一个请求已经被完成
}
});
}
System.out.println("主线程启动...");
countDownLatch.await();
threadPool.shutdown();
System.out.println("子线程执行完毕...");
System.out.println("主线程执行完毕...");
}
}

上面的代码中,我们定义了请求的数量为30,当这 30 个请求被处理完成之后,才会打印子线程执行完毕

主线程在启动其他线程后调用 CountDownLatch.await() 方法进入阻塞状态,直到其他线程完成各自的任务才被唤醒。

开启的30个线程必须引用闭锁对象,因为他们需要通知 CountDownLatch 对象,他们已经完成了各自的任务。这种通知机制是通过 CountDownLatch.countDown()方法来完成的;每调用一次这个方法,在构造函数中初始化的 count 值就减 1。所以当30个线程都调用了这个方法后,count 的值才等于0,然后主线程就能通过 await()方法,继续执行自己的任务。

5 CyclicBarrier(循环栅栏)
5.1 概述

CyclicBarrier的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。CyclicBarrier 的功能和应用场景与CountDownLatch都非常类似。

img

CyclicBarrier常用方法:

常用方法 描述
await() 在所有线程都已经在此 barrier上并调用 await 方法之前,将一直等待。
await(long timeout, TimeUnit unit) 所有线程都已经在此屏障上调用 await 方法之前将一直等待,或者超出了指定的等待时间。
getNumberWaiting() 返回当前在屏障处等待的线程数目。
getParties() 返回要求启动此 barrier 的线程数目。
isBroken() 查询此屏障是否处于损坏状态。
reset() 将屏障重置为其初始状态。
5.2 源码分析

构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 创建一个新的 CyclicBarrier,它将在给定数量的参与者(线程)处于等待状态时启动,但它不会在启动 barrier 时执行预定义的操作。
public CyclicBarrier(int parties) {
this(parties, null);
}

// 创建一个新的 CyclicBarrier,它将在给定数量的参与者(线程)处于等待状态时启动,并在启动 barrier 时执行给定的屏障操作。
// 该操作由最后一个进入 barrier 的线程执行。
public CyclicBarrier(int parties, Runnable barrierAction) {
if (parties <= 0) throw new IllegalArgumentException();
this.parties = parties;
this.count = parties;
this.barrierCommand = barrierAction;
}

其中,parties 就表示屏障拦截的线程数量

CyclicBarrier 的最重要的方法就是 await 方法,await() 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行

1
2
3
4
5
6
7
public int await() throws InterruptedException, BrokenBarrierException {
try {
return dowait(false, 0L);
} catch (TimeoutException toe) {
throw new Error(toe);
}
}

当调用 await() 方法时,实际上调用的是dowait(false, 0L)方法。查看dowait(boolean timed, long nanos)

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
// 当线程数量或者请求数量达到 count 时 await 之后的方法才会被执行。
private int count;

private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException,
TimeoutException {
final ReentrantLock lock = this.lock;
// 获取”独占锁“
lock.lock();
try {
// 保存“当前的generation”
final Generation g = generation;
// 如果当前代损坏,抛出异常
if (g.broken)
throw new BrokenBarrierException();
// 如果线程中断,抛出异常
if (Thread.interrupted()) {
// 将损坏状态设置为 true,并唤醒所有阻塞在此栅栏上的线程
breakBarrier();
throw new InterruptedException();
}
// 将“count计数器”-1
int index = --count;
// 当 count== 0,说明最后一个线程已经到达栅栏
if (index == 0) {
boolean ranAction = false;
try {
final Runnable command = barrierCommand;
// 执行栅栏任务
if (command != null)
command.run();
ranAction = true;
// 将 count 重置为 parties 属性的初始化值
// 唤醒之前等待的线程,并更新generation。
nextGeneration();
// 结束,等价于return index
return 0;
} finally {
if (!ranAction)
breakBarrier();
}
}

// 当前线程一直阻塞,直到“有parties个线程到达barrier” 或 “当前线程被中断” 或 “超时”这3者条件之一发生
// 当前线程才继续执行。
for (;;) {
try {
// 如果没有时间限制,则直接等待,直到被唤醒。
if (!timed)
trip.await();
// 如果有时间限制,则等待指定时间再唤醒(超时等待)。
else if (nanos > 0L)
nanos = trip.awaitNanos(nanos);
} catch (InterruptedException ie) {
// 当前代没有损坏
if (g == generation && ! g.broken) {
// 让栅栏失效
breakBarrier();
throw ie;
} else {
// 上面条件不满足,说明这个线程不是这代的。
// 就不会影响当前这代栅栏执行逻辑。中断。
Thread.currentThread().interrupt();
}
}
// 如果“当前generation已经损坏”,则抛出异常。
if (g.broken)
throw new BrokenBarrierException();
// 如果“generation已经换代”,则返回index。
if (g != generation)
return index;
// 如果是“超时等待”,并且时间已到,则通过breakBarrier()终止CyclicBarrier
// 唤醒CyclicBarrier中所有等待线程。
if (timed && nanos <= 0L) {
breakBarrier();
throw new TimeoutException();
}
}
} finally {
// 释放“独占锁(lock)”
lock.unlock();
}
}

generation是CyclicBarrier的一个成员变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* Generation一代的意思。
* CyclicBarrier是可以循环使用的,用它来标志本代和下一代。
* broken:当前代是否损坏的标志。标志有线程发生了中断,或者异常,就是任务没有完成。
*/
private static class Generation {
boolean broken = false;
}

// 实现独占锁
private final ReentrantLock lock = new ReentrantLock();
// 实现多个线程之间相互等待通知,就是满足某些条件之后,线程才能执行,否则就等待
private final Condition trip = lock.newCondition();
// 初始化时屏障数量
private final int parties;
// 当条件满足(即屏障数量为0)之后,会回调这个Runnable
private final Runnable barrierCommand;
//当前代
private Generation generation = new Generation();

// 剩余的屏障数量count
private int count;

在CyclicBarrier中,同一批的线程属于同一代,即同一个generation;CyclicBarrier中通过generation对象,记录属于哪一代。
当有parties个线程到达barrier,generation就会被更新换代。

总结
  1. CyclicBarrier 内部通过一个 count 变量作为计数器,cout 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏,那么就将计数器减1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就会将代更新并重置计数器,并唤醒所有之前等待在栅栏上的线程。
  2. 如果在等待的过程中,线程中断都也会抛出BrokenBarrierException异常,并且这个异常会传播到其他所有的线程,CyclicBarrier会被损坏。
  3. 如果超出指定的等待时间,当前线程会抛出 TimeoutException 异常,其他线程会抛出BrokenBarrierException异常,CyclicBarrier会被损坏。
5.3 应用场景

CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。比如我们用一个 Excel 保存了用户所有银行流水,每个 Sheet 保存一个帐户近一年的每笔银行流水,现在需要统计用户的日均银行流水,先用多线程处理每个 sheet 里的银行流水,都执行完之后,得到每个 sheet 的日均银行流水,最后,再用 barrierAction 用这些线程的计算结果,计算出整个 Excel 的日均银行流水。

示例 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
public class CyclicBarrierTest1 {
private static final int THREAD_COUNT = 30;
// 需要同步的线程数量
private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5);

public static void main(String[] args) throws InterruptedException {
// 创建线程池
ExecutorService threadPool = Executors.newFixedThreadPool(10);

for (int i = 0; i < THREAD_COUNT; i++) {
final int threadNum = i;
Thread.sleep(1000);
threadPool.execute(() -> {
System.out.println("childThread:" + threadNum + " is ready");
try {
// 等待60秒,保证子线程完全执行结束
cyclicBarrier.await(60, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
} catch (TimeoutException e) {
e.printStackTrace();
}
System.out.println("childThread:" + threadNum + " is finish");
});
}
threadPool.shutdown();
}
}

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
childThread:0 is ready
childThread:1 is ready
childThread:2 is ready
childThread:3 is ready
childThread:4 is ready
childThread:4 is finish
childThread:0 is finish
childThread:1 is finish
childThread:3 is finish
childThread:2 is finish
childThread:5 is ready
childThread:6 is ready
childThread:7 is ready
childThread:8 is ready
childThread:9 is ready
childThread:9 is finish
childThread:8 is finish
... ...

可以看到当线程数量也就是请求数量达定义的 5 个的时候, await方法之后的方法才被执行。

另外,CyclicBarrier 还提供一个更高级的构造函数CyclicBarrier(int parties, Runnable barrierAction),用于在线程到达屏障时,优先执行barrierAction,方便处理更复杂的业务场景。示例代码如下:

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
public class CyclicBarrierTest2 {
private static final int THREAD_COUNT = 30;
// 需要同步的线程数量
private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5, () -> {
System.out.println("------优先执行------");
});

public static void main(String[] args) throws InterruptedException {
// 创建线程池
ExecutorService threadPool = Executors.newFixedThreadPool(10);

for (int i = 0; i < THREAD_COUNT; i++) {
final int threadNum = i;
Thread.sleep(1000);
threadPool.execute(() -> {
System.out.println("childThread:" + threadNum + " is ready");
try {
// 等待60秒,保证子线程完全执行结束
cyclicBarrier.await(60, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
} catch (TimeoutException e) {
e.printStackTrace();
}
System.out.println("childThread:" + threadNum + " is finish");
});
}
threadPool.shutdown();
}
}

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
childThread:0 is ready
childThread:1 is ready
childThread:2 is ready
childThread:3 is ready
childThread:4 is ready
------优先执行------
childThread:4 is finish
childThread:0 is finish
childThread:1 is finish
childThread:3 is finish
childThread:2 is finish
childThread:5 is ready
childThread:6 is ready
childThread:7 is ready
childThread:8 is ready
childThread:9 is ready
------优先执行------
childThread:9 is finish
childThread:6 is finish
... ...
5.4 CyclicBarrier和CountDownLatch的区别
  1. CountDownLatch的计数器只能使用一次。而CyclicBarrier的计数器可以使用reset()方法重置,可多次使用。
  2. 侧重点不同。CountDownLatch多用于某一个线程等待若干个其他线程执行完任务之后,它才执行;而CyclicBarrier一般用于多个线程互相等待至一个同步点,然后这些线程再继续一起执行。
  3. CyclicBarrier还提供其他有用的方法,比如getNumberWaiting方法可以获得Cyclic-Barrier阻塞的线程数量;isBroken()方法用来了解阻塞的线程是否被中断。

十三、ReentrantLock 、 ReentrantReadWriteLock 、StampedLock(JDK8)

JDK8的一种新的读写锁StampedLock

JDK8新增一种新的读写锁StampedLock。一个最重要的功能改进就是读写锁中解决写线程饥饿的问题

StampedLock与ReentrantReadWriteLock

以StampedLock与ReentrantReadWriteLock两者比较为线索介绍StampedLock锁。关于ReentrantReadWriteLock锁参考文档ReentrantReadWriteLock源码解析

StampedLock不基于AQS实现

之前包括ReentrantReadWriteLock,ReentrantLock和信号量等同步工具,都是基于AQS同步框架实现的。而在StampedLock中摒弃了AQS框架,为StampedLock实现提供了更多的灵活性。

StampedLock增加乐观读锁机制

先获取记录下当前锁的版本号stamp,执行读取操作后,要验证这个版本号是否改变,如果没有改变继续执行接下来的逻辑。乐观读锁机制基于在系统中大多数时间线程并发竞争不严重,绝大多数读操作都可以在没有竞争的情况下完成的论断。

1
2
3
4
5
6
//乐观读锁
stamp = lock.tryOptimisticRead();
//do some reading
if (lock.validate(stamp)) {
//do somethinng
}

实际中,乐观读的实现是通过判断state的高25位是否有变化来实现的,获取乐观读锁也仅仅是返回当前锁的版本号

1
2
3
4
public long tryOptimisticRead() {
long s;
return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}
StampedLock锁的状态和版本号

基于AQS实现实现的ReentrantReadWriteLock,高16位存储读锁被获取的次数,低16位存储写锁被获取的次数。
而摒弃了AQS的StampedLock,自身维护了一个状态变量state。

1
private transient volatile long state;

StampedLock的状态变量state被分成3段:

  1. 高24位存储版本号,只有写锁增加其版本号,而读锁不会增加其版本号;
  2. 低7位存储读锁被获取的次数;
  3. 第8位存储写锁被获取的次数,因为只有一位用于表示写锁,所以StampedLock不是可重入锁

关于状态变量state操作的变量设置:

1
2
3
4
5
6
7
8
9
10
11
private static final int LG_READERS = 7; //读线程的个数占有低7位

// Values for lock state and stamp operations
private static final long RUNIT = 1L; //读线程个数每次增加的单位
private static final long WBIT = 1L << LG_READERS;//写线程个数所在的位置 1000 0000
private static final long RBITS = WBIT - 1L;//读线程个数的掩码 111 1111
private static final long RFULL = RBITS - 1L;//最大读线程个数
private static final long ABITS = RBITS | WBIT;//读线程个数和写线程个数的掩码 1111 1111
// Initial value for lock state; avoid failure value zero
//state的初始值。 1 0000 0000,也就是高24位最后一位为1,版本号初始值为1 0000 0000。锁获取失败返回版本号0。
private static final long ORIGIN = WBIT << 1;
StampedLock自旋

如下代码片段,可以看到StampedLock锁获取时存在大量自旋逻辑(for循环)。自旋是一种锁优化技术,在并发程序中大多数的锁持有时间很短暂,通过自旋可以避免线程被阻塞和唤醒产生的开销。
自旋技术对于系统中持有锁时间短暂的任务比较高效,但是对于持有锁时间长的任务是对CPU的浪费。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private long acquireWrite(boolean interruptible, long deadline) {
for (int spins = -1;;) { // spin while enqueuing
....省略代码逻辑....
}
for (int spins = -1;;) {
....省略代码逻辑....
for (int k = spins;;) { // spin at head
....省略代码逻辑....
}
private long acquireRead(boolean interruptible, long deadline) {
for (int spins = -1;;) {
....省略代码逻辑....
for (long m, s, ns;;) {
....省略代码逻辑....
}
....省略代码逻辑....
for (;;) {
....省略代码逻辑....
for (int spins = -1;;) {
for (int k = spins;;) { // spin at head
....省略代码逻辑....
}
StampedLock的CLH队列

StampedLock的CLH队列是一个经过改良的队列,在ReentrantReadWriteLock的等待队列中每个线程节点是依次排队,然后责任链设计模式依次唤醒,这样就可能导致读线程全部唤醒,而写线程处于饥饿状态。StampedLock的等待队列,连续的读线程只有首个节点存储在队列中,其它的节点存储在首个节点的cowait队列中。

StampedLock唤醒读锁是一次性唤醒连续的读锁节点。

类WNode是StampedLock等待队列的节点,cowait存放连续的读线程。

1
2
3
4
5
6
7
8
9
10
static final class WNode {
volatile WNode prev;
volatile WNode next;
//cowait存放连续的读线程
volatile WNode cowait; // list of linked readers
volatile Thread thread; //non-null while possibly parked
volatile int status; // 0, WAITING, or CANCELLED
final int mode; // RMODE or WMODE
WNode(int m, WNode p) { mode = m; prev = p; }
}

如果两个读节点之间有一个写节点,那么这两个读节点就不是连续的,会分别排队。正是因为这样的机制,会按照到来顺序让先到的写线程先于它后面的读线程执行。

StampedLock只有非公平模式,线程到来就会尝试获取锁。

十四、CAS 了解么?原理?

1、什么是CAS?

CAS:Compare and Swap,即比较再交换。

jdk5增加了并发包java.util.concurrent.*,其下面的类使用CAS算法实现了区别于synchronouse同步锁的一种乐观锁。JDK 5之前Java语言是靠synchronized关键字保证同步的,这是一种独占锁,也是是悲观锁。

2、CAS算法理解

对CAS的理解,CAS是一种无锁算法,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

CAS比较与交换的伪代码可以表示为:

do{

备份旧数据;

基于旧数据构造新数据;

}while(!CAS( 内存地址,备份的旧数据,新数据 ))

img

注:t1,t2线程是同时更新同一变量56的值

因为t1和t2线程都同时去访问同一变量56,所以他们会把主内存的值完全拷贝一份到自己的工作内存空间,所以t1和t2线程的预期值都为56。

假设t1在与t2线程竞争中线程t1能去更新变量的值,而其他线程都失败。(失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次发起尝试)。t1线程去更新变量值改为57,然后写到内存中。此时对于t2来说,内存值变为了57,与预期值56不一致,就操作失败了(想改的值不再是原来的值)。

(上图通俗的解释是:CPU去更新一个值,但如果想改的值不再是原来的值,操作就失败,因为很明显,有其它操作先改变了这个值。)

就是指当两者进行比较时,如果相等,则证明共享数据没有被修改,替换成新值,然后继续往下运行;如果不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作。容易看出 CAS 操作是基于共享数据不会被修改的假设,采用了类似于数据库的commit-retry 的模式。当同步冲突出现的机会很少时,这种假设能带来较大的性能提升。

3、CAS开销

前面说过了,CAS(比较并交换)是CPU指令级的操作,只有一步原子操作,所以非常快。而且CAS避免了请求操作系统来裁定锁的问题,不用麻烦操作系统,直接在CPU内部就搞定了。但CAS就没有开销了吗?不!有cache miss的情况。这个问题比较复杂,首先需要了解CPU的硬件体系结构:

img

上图可以看到一个8核CPU计算机系统,每个CPU有cache(CPU内部的高速缓存,寄存器),管芯内还带有一个互联模块,使管芯内的两个核可以互相通信。在图中央的系统互联模块可以让四个管芯相互通信,并且将管芯与主存连接起来。数据以“缓存线”为单位在系统中传输,“缓存线”对应于内存中一个 2 的幂大小的字节块,大小通常为 32 到 256 字节之间。当 CPU 从内存中读取一个变量到它的寄存器中时,必须首先将包含了该变量的缓存线读取到 CPU 高速缓存。同样地,CPU 将寄存器中的一个值存储到内存时,不仅必须将包含了该值的缓存线读到 CPU 高速缓存,还必须确保没有其他 CPU 拥有该缓存线的拷贝。

比如,如果 CPU0 在对一个变量执行“比较并交换”(CAS)操作,而该变量所在的缓存线在 CPU7 的高速缓存中,就会发生以下经过简化的事件序列:

CPU0 检查本地高速缓存,没有找到缓存线。

请求被转发到 CPU0 和 CPU1 的互联模块,检查 CPU1 的本地高速缓存,没有找到缓存线。

请求被转发到系统互联模块,检查其他三个管芯,得知缓存线被 CPU6和 CPU7 所在的管芯持有。

请求被转发到 CPU6 和 CPU7 的互联模块,检查这两个 CPU 的高速缓存,在 CPU7 的高速缓存中找到缓存线。

CPU7 将缓存线发送给所属的互联模块,并且刷新自己高速缓存中的缓存线。

CPU6 和 CPU7 的互联模块将缓存线发送给系统互联模块。

系统互联模块将缓存线发送给 CPU0 和 CPU1 的互联模块。

CPU0 和 CPU1 的互联模块将缓存线发送给 CPU0 的高速缓存。

CPU0 现在可以对高速缓存中的变量执行 CAS 操作了

以上是刷新不同CPU缓存的开销。最好情况下的 CAS 操作消耗大概 40 纳秒,超过 60 个时钟周期。这里的“最好情况”是指对某一个变量执行 CAS 操作的 CPU 正好是最后一个操作该变量的CPU,所以对应的缓存线已经在 CPU 的高速缓存中了,类似地,最好情况下的锁操作(一个“round trip 对”包括获取锁和随后的释放锁)消耗超过 60 纳秒,超过 100 个时钟周期。这里的“最好情况”意味着用于表示锁的数据结构已经在获取和释放锁的 CPU 所属的高速缓存中了。锁操作比 CAS 操作更加耗时,是因深入理解并行编程

为锁操作的数据结构中需要两个原子操作。缓存未命中消耗大概 140 纳秒,超过 200 个时钟周期。需要在存储新值时查询变量的旧值的 CAS 操作,消耗大概 300 纳秒,超过 500 个时钟周期。想想这个,在执行一次 CAS 操作的时间里,CPU 可以执行 500 条普通指令。这表明了细粒度锁的局限性。

以下是cache miss cas 和lock的性能对比:

img

4、CAS算法在JDK中的应用

在原子类变量中,如java.util.concurrent.atomic中的AtomicXXX,都使用了这些底层的JVM支持为数字类型的引用类型提供一种高效的CAS操作,而在java.util.concurrent中的大多数类在实现时都直接或间接的使用了这些原子变量类。

Java 1.7中AtomicInteger.incrementAndGet()的实现源码为:

img

由此可见,AtomicInteger.incrementAndGet的实现用了乐观锁技术,调用了类sun.misc.Unsafe库里面的 CAS算法,用CPU指令来实现无锁自增。所以,AtomicInteger.incrementAndGet的自增比用synchronized的锁效率倍增。

十五、Atomic 原子类

Atomic概览

整个atomic包包含了17个类,如下图所示:根据其功能及其实现原理,可将其分为五个部分。本文主要针对图中序号1都部分进行源码阅读和分析。

img

核心对象——Unsafe

整个atomic都是基于Unsafe实现的,Unsafe通过通过单例模式来提供实例对象,这里我们主要关注它提供的几个方法:

1
2
3
4
5
6
7
8
9
# 清单1 sun.misc.Unsafe.class
public final native boolean compareAndSwapInt(Object var1, long var2,
int var4, int var5); // 核心方法CAS
// 参数释义:var1为类对象,参数var2为Field的偏移量,var4为旧值,var5为更新后的值
//(对象和偏移量构成唯一的内存地址,如果对源码JVM有兴趣,可下载源码参考,非本文范畴,不赘述)。

// 计算偏移量
public native long staticFieldOffset(Field var1);
public native long objectFieldOffset(Field var1);

Unsafe提供的大多是native方法,compareAndSwapInt()通过原子的方式将期望值和内存中的值进行对比,如果两者相等,则执行更新操作。
staticFieldOffset()objectFieldOffset()两方法分别提供两静态、非静态域的偏移量计算方法。

注意:之所以命名为Unsafe,因为该对于大部分Java开发者来说是不安全的,它像C一样,拥有操作指针、分配和回收内存的能力,由该对象申请的内存是无法被JVM回收的,因此轻易别用。当然,如果对并发有非常浓厚的兴趣,就要好好研究下它,许多高性能的框架都使用它作为底层实现,如Netty、Kafka。

AtomicInteger的基本实现

接着再来看AtomicInteger的源码:

1
2
3
4
5
6
7
8
9
10
# 清单2 AtomicInteger
private static final Unsafe unsafe = Unsafe.getUnsafe(); // 获取单例对象
private static final long valueOffset; // 偏移量
static {
try{
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value")) // 计算偏移量
} catch(Exception ex){ throw new Error(ex);}
}
private volatile int value; // 使用volatile修饰,保证可见性

私有的静态域Unsafe对象和偏移量都是final修饰的,在静态代码块中,通过Unsafe实例计算出域value的偏移地址。
value使用volatile来修饰,保证了其可见性。

1
2
3
4
5
6
7
8
9
# 清单3 getAndSetInt的实现
public final int getAndSetInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2); // 原子获取变量的值
} while(!this.compareAndSwapInt(var1, var2, var5, var4));
// CAS操作,失败重试
return var5;
}

通过方法名可知清单3中的方法getAndSetInt()为获取旧值并赋予新值的操作,通过CAS失败重试的机制来实现原子操作,这就是乐观锁的思想,也是整个并发包的核心思想。

扩展-灵活的函数式编程

img

AtomicInteger方法

AtomicInteger的方法中,除了简单的加、减、更新和获取的原子操作外,在JDK1.8中增加了4个方法,即图上标红的方法。通过函数式编程,可以灵活的实现更加复杂的原子操作。

1
2
3
4
5
6
7
8
9
10
11
12
# 清单5 IntUnaryOperator接口
int applyAsInt(int operand);

default IntUnaryOperator compose(IntUnaryOperator before) {
Objects.requireNonNull(before);
return (int v) -> applyAsInt(before.applyAsInt(v));
}

default IntUnaryOperator andThen(IntUnaryOperator after) {
Objects.requireNonNull(after);
return (int t) -> after.applyAsInt(applyAsInt(t));
}

该接口定义了一个待实现方法和两个默认方法,通过compose和andThen即可实现多个IntUnaryOperator的组合调用。在AtomicInteger中做如下调用:

1
2
3
4
5
6
7
8
9
# 清单6 AtomicInteger代码片段
public final int getAndUpdate(IntUnaryOperator updateFunction) {
int prev, next;
do {
prev = get(); // 获取当前值
next = updateFunction.applyAsInt(prev); // 函数调用计算
} while (!compareAndSet(prev, next)); // CAS更新操作
return prev;
}

如同代码清单7,通过函数式编程,可以轻易地完成复杂计算的原子操作。除了IntUnaryOperator接口,还有一个IntBinaryOperator接口,该接口支持额外增加的参数参与计算,两者有相似之处

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 清单7 IntUnaryOperatorTest
public static void main(String[] args) {
IntOperatorAdd add = new IntOperatorAdd();
IntOperatorMul mul = new IntOperatorMul();
int result = new AtomicInteger(3).updateAndGet(add); // 结果为6 -> 3+3
int result2 = new AtomicInteger(3).updateAndGet(mul); // 结果为9 -> 3*3
int result3 = new AtomicInteger(3).updateAndGet(add.andThen(mul));
// 结果为36 -> 3+3=6, 6*6=36
}

private static class IntOperatorAdd implements IntUnaryOperator {
@Override
public int applyAsInt(int operand) {
return operand + operand;
}
}

private static class IntOperatorMul implements IntUnaryOperator {
@Override
public int applyAsInt(int operand) {
return operand * operand;
}
}
其他原子操作类

除了AtomicInteger外,还有AtomicLong、AtomicReference以及AtomicBoolean三个原子包装类。其实现原理都是一致的,均可举一反三。

十六、并发容器:ConcurrentHashMap 、 CopyOnWriteArrayList 、 ConcurrentLinkedQueue BlockingQueue 、ConcurrentSkipListMap

前言

不考虑多线程并发的情况下,容器类一般使用ArrayList、HashMap等线程不安全的类,效率更高。在并发场景下,常会用到ConcurrentHashMap、ArrayBlockingQueue等线程安全的容器类,虽然牺牲了一些效率,但却得到了安全。

上面提到的线程安全容器都在java.util.concurrent包下,这个包下并发容器不少,今天全部翻出来鼓捣一下。

仅做简单介绍,后续再分别深入探索。

并发容器介绍
  1. ConcurrentHashMap:并发版HashMap
  2. CopyOnWriteArrayList:并发版ArrayList
  3. CopyOnWriteArraySet:并发Set
  4. ConcurrentLinkedQueue:并发队列(基于链表)
  5. ConcurrentLinkedDeque:并发队列(基于双向链表)
  6. ConcurrentSkipListMap:基于跳表的并发Map
  7. ConcurrentSkipListSet:基于跳表的并发Set
  8. ArrayBlockingQueue:阻塞队列(基于数组)
  9. LinkedBlockingQueue:阻塞队列(基于链表)
  10. LinkedBlockingDeque:阻塞队列(基于双向链表)
  11. PriorityBlockingQueue:线程安全的优先队列
  12. SynchronousQueue:读写成对的队列
  13. LinkedTransferQueue:基于链表的数据交换队列
  14. DelayQueue:延时队列
1.ConcurrentHashMap 并发版HashMap

最常见的并发容器之一,可以用作并发场景下的缓存。底层依然是哈希表,但在JAVA 8中有了不小的改变,而JAVA 7和JAVA 8都是用的比较多的版本,因此经常会将这两个版本的实现方式做一些比较(比如面试中)。

一个比较大的差异就是,JAVA 7中采用分段锁来减少锁的竞争,JAVA 8中放弃了分段锁,采用CAS(一种乐观锁),同时为了防止哈希冲突严重时退化成链表(冲突时会在该位置生成一个链表,哈希值相同的对象就链在一起),会在链表长度达到阈值(8)后转换成红黑树(比起链表,树的查询效率更稳定)。

2.CopyOnWriteArrayList 并发版ArrayList

并发版ArrayList,底层结构也是数组,和ArrayList不同之处在于:当新增和删除元素时会创建一个新的数组,在新的数组中增加或者排除指定对象,最后用新增数组替换原来的数组。

适用场景:由于读操作不加锁,写(增、删、改)操作加锁,因此适用于读多写少的场景。

局限:由于读的时候不会加锁(读的效率高,就和普通ArrayList一样),读取的当前副本,因此可能读取到脏数据。如果介意,建议不用。

看看源码感受下:

img

3.CopyOnWriteArraySet 并发Set

基于CopyOnWriteArrayList实现(内含一个CopyOnWriteArrayList成员变量),也就是说底层是一个数组,意味着每次add都要遍历整个集合才能知道是否存在,不存在时需要插入(加锁)。

适用场景:在CopyOnWriteArrayList适用场景下加一个,集合别太大(全部遍历伤不起)。

4.ConcurrentLinkedQueue 并发队列(基于链表)

基于链表实现的并发队列,使用乐观锁(CAS)保证线程安全。因为数据结构是链表,所以理论上是没有队列大小限制的,也就是说添加数据一定能成功。

5.ConcurrentLinkedDeque 并发队列(基于双向链表)

基于双向链表实现的并发队列,可以分别对头尾进行操作,因此除了先进先出(FIFO),也可以先进后出(FILO),当然先进后出的话应该叫它栈了。

6.ConcurrentSkipListMap 基于跳表的并发Map

SkipList即跳表,跳表是一种空间换时间的数据结构,通过冗余数据,将链表一层一层索引,达到类似二分查找的效果

img

7.ConcurrentSkipListSet 基于跳表的并发Set

类似HashSet和HashMap的关系,ConcurrentSkipListSet里面就是一个ConcurrentSkipListMap,就不细说了。

8.ArrayBlockingQueue 阻塞队列(基于数组)

基于数组实现的可阻塞队列,构造时必须制定数组大小,往里面放东西时如果数组满了便会阻塞直到有位置(也支持直接返回和超时等待),通过一个锁ReentrantLock保证线程安全。

img

乍一看会有点疑惑,读和写都是同一个锁,那要是空的时候正好一个读线程来了不会一直阻塞吗?

答案就在notEmpty、notFull里,这两个出自lock的小东西让锁有了类似synchronized + wait + notify的功能。传送门 → 终于搞懂了sleep/wait/notify/notifyAll

9.LinkedBlockingQueue 阻塞队列(基于链表)

基于链表实现的阻塞队列,想比与不阻塞的ConcurrentLinkedQueue,它多了一个容量限制,如果不设置默认为int最大值。

10.LinkedBlockingDeque 阻塞队列(基于双向链表)

类似LinkedBlockingQueue,但提供了双向链表特有的操作。

11.PriorityBlockingQueue 线程安全的优先队列

构造时可以传入一个比较器,可以看做放进去的元素会被排序,然后读取的时候按顺序消费。某些低优先级的元素可能长期无法被消费,因为不断有更高优先级的元素进来。

12.SynchronousQueue 数据同步交换的队列

一个虚假的队列,因为它实际上没有真正用于存储元素的空间,每个插入操作都必须有对应的取出操作,没取出时无法继续放入。

img

可以看到,写入的线程没有任何sleep,可以说是全力往队列放东西,而读取的线程又很不积极,读一个又sleep一会。输出的结果却是读写操作成对出现。

JAVA中一个使用场景就是Executors.newCachedThreadPool(),创建一个缓存线程池。

img

13.LinkedTransferQueue 基于链表的数据交换队列

实现了接口TransferQueue,通过transfer方法放入元素时,如果发现有线程在阻塞在取元素,会直接把这个元素给等待线程。如果没有人等着消费,那么会把这个元素放到队列尾部,并且此方法阻塞直到有人读取这个元素。和SynchronousQueue有点像,但比它更强大。

14.DelayQueue 延时队列

可以使放入队列的元素在指定的延时后才被消费者取出,元素需要实现Delayed接口。

总结

上面简单介绍了JAVA并发包下的一些容器类,知道有这些东西,遇到合适的场景时就能想起有个现成的东西可以用了。想要知其所以然,后续还得再深入探索一番。

十七、Future 和 CompletableFuture

CompletableFuture是java 8引入的,用于Java异步编程。异步编程是一种通过在与主应用程序线程不同的线程上运行任务并通知主线程其进度,完成或失败的方法来编写非阻塞代码的方法。
这样,您的主线程就不会阻塞/等待任务完成,并且可以并行执行其他任务。具有这种并行性可以大大提高程序的性能。

Future

Future被用作异步计算结果的参考。它提供了一个isDone()方法来检查计算是否完成,以及一个get()方法来检索计算完成后的结果。

Future VS CompletableFuture:
1.手动完成

Future提供了一个isDone()方法来检查计算是否完成,以及get()方法来检索计算结果。但是,Future不提供手动完成的方法。CompletableFuture的complete()方法可帮助我们手动完成Future

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
/**
* Manual Completion 手动完成
*/
private static void manualCompletion() throws ExecutionException, InterruptedException {
ExecutorService executorService = Executors.newSingleThreadExecutor();
CompletableFuture<String> future = CompletableFuture.supplyAsync(()->{
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "1111";
});
executorService.submit(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//手动结束核心
future.complete("手动完成");
});
System.out.println(future.get());
executorService.shutdown();
}

因为Future的get()方法在完成计算的之前是阻塞的,我们可以使用complete()方法来手动完成计算。

2.多个Future组成调用链
1
2
3
4
5
6
7
8
9
10
/**
* 调用链
*/
public static void callbackChain() throws ExecutionException, InterruptedException {
CompletableFuture completableFuture
= CompletableFuture
.supplyAsync(() -> "Knolders!")
.thenRun(() -> System.out.println("Example with thenRun()."));
System.out.println(completableFuture.get());
}
3.组合多个CompletableFuture结果

如果是Future,则无法创建异步工作流程,即长时间运行的计算。但是CompletableFuture为我们提供了方法来实现此功能:

1
2
3
4
5
6
7
8
9
10
11
/**
* 组合
*/
private static void thenCompose() {
CompletableFuture<String> completableFuture =
CompletableFuture.supplyAsync(() -> "Hello")
.thenCompose(value ->
CompletableFuture.supplyAsync(
() -> value + " Knolders! Its thenCompose"));
completableFuture.thenAccept(System.out::println); // Hello Knolders! Its thenCompose
}

如果你希望合并要并行运行的100种不同的Future,然后在所有这些Future完成后再运行某些功能。可是使用如下方法:

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
    /**
* 组合所有的结果
*/
private static void allOf() throws ExecutionException, InterruptedException {
CompletableFuture<String> completableFuture1
= CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> completableFuture2
= CompletableFuture.supplyAsync(() -> "lv!");
CompletableFuture<String> completableFuture3
= CompletableFuture.supplyAsync(() -> "Its allOf");
/*
这个方法并不直接返回结果只是返回一个CompletableFuture
*/
CompletableFuture<Void> combinedFuture
= CompletableFuture.allOf(completableFuture1, completableFuture2, completableFuture3);

System.out.println(combinedFuture.get()); //输出null

assert (completableFuture1.isDone());
assert (completableFuture2.isDone());
assert (completableFuture3.isDone());
//使用以下两种方法获取最终结果
CompletableFuture<List<String>> listCompletableFuture = combinedFuture.thenApply(v ->
Stream.of(completableFuture1, completableFuture2, completableFuture3).
map(CompletableFuture::join).
collect(Collectors.toList()));
System.out.println(listCompletableFuture.get());

String combined = Stream.of(completableFuture1, completableFuture2, completableFuture3)
.map(CompletableFuture::join)
.collect(Collectors.joining(" "));
System.out.println(combined);
}
4.异常处理

如果发生异常,调用链将会停止调用。

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
private static void exception() {
Integer age = -1;
CompletableFuture<String> exceptionFuture = CompletableFuture.supplyAsync(() -> {
if (age < 0) {
throw new IllegalArgumentException("Age can not be negative");
}
if (age > 18) {
return "Adult";
} else {
return "Child";
}
}).exceptionally(ex -> {
System.out.println("Oops! We have an exception - " + ex.getMessage());
return "Unknown!";
});
exceptionFuture.thenAccept(System.out::println); //Unknown!
}

private static void exceptionUsingHandle() {
Integer age = -1;
CompletableFuture<String> exceptionFuture = CompletableFuture.supplyAsync(() -> {
if (age < 0) {
throw new IllegalArgumentException("Age can not be negative");
}
if (age > 18) {
return "Adult";
} else {
return "Child";
}
}).handle((result, ex) -> {
if (ex != null) {
System.out.println("Oops! We have an exception - " + ex.getMessage());
return "Unknown!";
}
return result;
});
exceptionFuture.thenAccept(System.out::println); // Unknown!
}

十八、……

JVM

对于 Java 程序员来说,JVM 帮助我们做了很多事情比如内存管理、垃圾回收等等。在 JVM 的帮助下,我们的程序出现内存泄漏这些问题的概率相对来说是比较低的。但是,这并不代表我们在日常开发工作中不会遇到。万一你在工作中遇到了 OOM 问题,你至少要知道如何去排查和解决问题吧!
并且,就单纯从面试角度来说,JVM 是 Java 后端面试(大厂)中非常重要的一环。不论是应届还是社招,面试国内的一些大厂,你都会被问到很多 JVM 相关的问题(应届的话侧重理论,社招实践)。

只有搞懂了 JVM 才有可能真正把 Java 语言“吃透”。学习 JVM 这部分的内容,一定要注意要实战和理论结合。

书籍的话,**《深入理解 Java 虚拟机》** 这本书是首先要推荐的。

下面是我总结的一些关于 JVM 的小问题,你可以拿来自测:

一、什么是虚拟机?

1、 什么是JVM?

  JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。 JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

  Java语言的一个非常重要的特点就是与平台的无关性。而使用Java虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java的能够“一次编译,到处运行”的原因。

2、JRE/JDK/JVM是什么关系?

JRE(JavaRuntimeEnvironment,Java运行环境),也就是Java平台。所有的Java 程序都要在JRE下才能运行。普通用户只需要运行已开发好的java程序,安装JRE即可。

JDK(Java Development Kit)是程序开发者用来来编译、调试java程序用的开发工具包。JDK的工具也是Java程序,也需要JRE才能运行。为了保持JDK的独立性和完整性,在JDK的安装过程中,JRE也是 安装的一部分。所以,在JDK的安装目录下有一个名为jre的目录,用于存放JRE文件。

JVM(JavaVirtualMachine,Java虚拟机)是JRE的一部分。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java语言最重要的特点就是跨平台运行。使用JVM就是为了支持与操作系统无关,实现跨平台。

3、JVM原理

img

  Java编译器只要面向JVM,生成JVM能理解的代码或字节码文件。Java源文件经编译成字节码程序,通过JVM将每一条指令翻译成不同平台机器码,通过特定平台运行。

我刚整理了一套2018最新的0基础入门和进阶教程,无私分享,加Java学习裙 :678-241-563 即可获取,内附:开发工具和安装包,以及系统学习路线图

4、JVM的体系结构

img

(1)java栈内存,它等价于C语言中的栈, 栈的内存地址是不连续的, 每个线程都拥有自己的栈。 栈里面存储着的是StackFrame,在《JVM Specification》中文版中被译作java虚拟机框架,也叫做栈帧。StackFrame包含三类信息:局部变量,执行环境,操作数栈。局部变量用来存储一个类的方法中所用到的局部变量。执行环境用于保存解析器对于java字节码进行解释过程中需要的信息,包括:上次调用的方法、局部变量指针和 操作数栈的栈顶和栈底指针。操作数栈用于存储运算所需要的操作数和结果。StackFrame在方法被调用时创建,在某个线程中,某个时间点上,只有一个 框架是活跃的,该框架被称为Current Frame,而框架中的方法被称为Current Method,其中定义的类为Current Class。局部变量和操作数栈上的操作总是引用当前框架。当Stack Frame中方法被执行完之后,或者调用别的StackFrame中的方法时,则当前栈变为另外一个StackFrame。Stack的大小是由两种类 型,固定和动态的,动态类型的栈可以按照线程的需要分配。 下面两张图是关于栈之间关系以及栈和非堆内存的关系基本描述:

img

(2) Java堆是用来存放对象信息的,和Stack不同,Stack代表着一种运行时的状态。换句话说,栈是运行时单位,解决程序该如何执行的问题,而堆是存储的单位, 解决数据存储的问题。Heap是伴随着JVM的启动而创建,负责存储所有对象实例和数组的。堆的存储空间和栈一样是不需要连续的。

(3)程序计数寄存器,程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  由于Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。如果线程正在执行的是一个Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在Java 虚拟机规范中没有规定任何OutOfMemoryError 情况的区域。

(4)方法区域(Method Area),在Sun JDK中这块区域对应的为PermanetGeneration,又称为持久代。方法区域存放了所加载的类的信息(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息,当开发人员在程序中通过Class对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区域,同时方法区域也是全局共享的,在一定的条件下它也会被GC,当方法区域需要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息。

(5)运行时常量池(Runtime Constant Pool),存放的为类中的固定的常量信息、方法和Field的引用信息等,其空间从方法区域中分配。

(6)本地方法堆栈(Native Method Stacks),JVM采用本地方法堆栈来支持native方法的执行,此区域用于存储每个native方法调用的状态。

二、Java 内存区域是怎么划分的?大对象放在哪个内存区域?

一、运行时数据区域

img

程序计数器

记录正在执行的虚拟机字节码指令地址(如果正在执行的是本地的方法则为空)。

虚拟机栈

每个Java方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在Java虚拟机栈中入栈和出栈的过程。

img

可以通过-Xss这个虚拟机参数来指定一个Java虚拟机栈内存大小:

1
java -Xss = 512M HackTheJava

该区域可能抛出以下异常:
 1、当线程请求的栈深度超过最大值,会抛出StackOverflowError异常;
 2、栈进行动态扩展时如果无法申请到足够内存,会抛出OutOfMemoryError异常。

本地方法栈

本地方法不是用Java实现,对待这些方法需要特别处理。
与Java虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。

img

所有对象实例都在这里分配内存。

是垃圾收集器的主要区域(”GC堆”),现代的垃圾收集器基本都是采用分代收集算法,该算法的思想是针对不同的对象采取不同的垃圾回收算法,因此虚拟机把Java堆分成以下三块:

  • 新生代(Young Generation)
  • 老年代(Tenured Generation
  • 永久代(Permanent Generation)

当一个对象被创建时,首先进入新生代,之后有可能被转移到老年代中。新生代存放着大量的生命很短的对象,因此新生代在三个区域中垃圾回收的频率最高。为了更高效率地进行垃圾回收,把新生代分成以下三个空间:

  • Eden
  • From Survivor
  • To Survivor

img

Java堆不需要连续内存,并且可以动态增加其内存,增加失败会抛出OutOfMenmoryError异常。
可以通过-Xms和-Xmx两个虚拟机参数来指定一个程序的Java堆内存大小,第一个参数设置初始值,第二个参数设置最大值。

1
java -Xms = 1M -Xmx = 2M HackTheJava
方法区

用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
和Java堆不一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出OutOfMemoryError异常。
对这块区域进行垃圾回收的主要目标是对常量池的回收和类的卸载,但是一般比较难实现,HotSpot把它当成永久代来进行垃圾回收。

运行常量池

运行常量池是方法区的一部分。
Class文件中的常量池(编译器生成的各种字面量和符号引用)会在类加载后被放入这个区域。
除了在编译期生成的常量,还允许动态生成,例如String类的intern()。这部分常量也会被放入运行时常量池。

直接内存

在 JDK 1.4 中新加入了 NIO 类,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

二、垃圾收集

重拾Markdown,一些用法的使用记录

1、空行

 起作用的:正文内容。
 不起作用的:各级标题、分隔线、代码框编辑前后。使用前后都添加空行

2、缩进控制

&ensp;缩进一个空格。
&emsp;缩进两个空格。
https://www.jianshu.com/p/9d94660a96f1

3、代码展示

img

```和`两者包含的代码框有什么不同?

```代码框。

`代码片。

https://www.jianshu.com/p/b9b582bb6760

4、Markdown是否有转义字符的使用?

 比如代码框符号```,引用符号>等。这个转义字符就是反斜杠 \。
https://www.jianshu.com/p/b9b582bb6760

5、简数编辑区域的Markdown怎么设置图片的位置?

暂时没有解决。

6、一些说明:

图片连接地址前后都设置一个空行。简书的markdown文章中的图片不用做其他设置都是默认居中,而在个人博客中却默认左对齐,具体效果如下图。对于有轻微强迫症的笔者决定将所有图片修改为居中对齐,搜索了一下,只需在markdown文件中的图片引用前后加上 HTML

标签即可实现居中。

1
2
3
4
5
-----空行----
<div align=center>
![]()
</div>
-----空行----

http://www.php-master.com/post/68996.html

7、强制换行

 markdown编辑器下直接回车,预览时换行是显示不了的。这时就需要强制换行了。
 强制换行语法:<br>。可以直接使用,在简书编辑区域同样有效。

8、杂

<br><br/>
 不同的标准下的产物,使用上没什么差异,相互兼容。

  分隔符还是统一使用***;使用—如果它的上面没有空行,文字将会被误解析成标题。

  Markdown编辑的文本在不同的解析器下面,换行的长度会不同。

三、垃圾回收有哪些算法?GC 的流程

导读

1、一个对象一生经历了什么?
2、如何判断对象是否可用?
3、引用计数法和可达性分析算法各自优缺点?
4、哪些对象可以作为GC ROOT?
5、垃圾回收的时候如何快速寻找根节点?
6、垃圾回收算法有哪些?各自优缺点?
7、有哪些垃圾回收器?各自优缺点?适用什么场景?

1、对象回收处理过程

img

2、判断用户是否可用计算

2.1、引用计数算法

img

如上图,给对象一个引用技术refCount。每有一个对象引用它,计时器加1,当它为0时,表示对象补课在用。

缺点。

很难解决循环引用的问题。

objA.instance = objB

objB.instance = objA

img

如上,即使objA和objB 都不在被访问后,但是它们还在 相互引用,所以计数器不会为0

2.2、可达性分析算法

img

如上图,从GC Roots开始向下搜索,连接的路径为引用链;

GC Roots不可达的对象被判为不可用;
可作为GC Root的对象

如上图,虚拟机栈帧中本地变量表引用的对象,本地方法栈中,JNI引用的对象,方法区中的类静态属性引入的对象和常量引用对象都可以作为GC Root。

引用类型

  • 强引用:
    类似 object a = new object();
  • 软引用:
    SoftReference ref = new SoftReference(“Hello World”);OOM前,JVM会把这些对象列入回收范围进行二次回收,如果回收后内存还是不做,则OOM。
  • 弱引用:
    WeakReference weakCar = new WeakReference(car);每次垃圾收集,弱引用的对象就会被清理
  • 虚引用:
    幽灵引用,不能用来获取一个对象的实例,唯一用途:当一个虚引用引用的对象被回收,系统会收到这个对象被回收的通知。

3、HotSpot中如何实现判断是否存在与GC Roots相连接的引用链

img

第一小节流程图里的是否存在与GC Roots相连接的引用链 这个判断子流程是怎么实现的呢,这节我们来仔细探讨下。

一般的,我们都是选取可达性分析算法,这里主要阐述怎么寻找GC Root以及如何检查引用链。

3.1、枚举根节点

img

如上图,在一个调用关系为:

ClassA.invokeA() –> ClassB.invokeB() –>doinvokeB() –>ClassC.execute()

的情况下,每个调用对应一个栈帧,栈帧里面的本地变量表存储了GC Roots的引用。

如果直接遍历所有的栈去查找GC Roots,效率太低了。为此我们引入了OopMap和安全点的概念。

安全点和OopMap

img

如上图,在源代码编译的时候,会在特定位置下记录安全点,一般为:

1、循环的末尾

2、方法返回前 或者调用方法的call指令后

3、可能抛出异常的位置

通过安全点把代码分成几段,每段代码一个OopMap。

OopMap记录栈上本地变量到堆上对象的引用关系,每当触发GC的时候,程序都先跑的最近的安全点,然后自动挂起,然后在触发更新OopMap,然后进行枚举类GC ROOT,进行垃圾回收:

img

安全区域:在一段代码片段之中,引用关系不会发生变化,因此在这个区域中的任意位置开始 GC 都是安全的。如处于Sleep或者Blocked状态的线程。

为了在枚举GC Roots的过程中,对象的引用关系不会变更,所以需要一个GC停顿。

还有一种抢先式中断的方式,几乎没有虚拟机采用:先中断所有线程,发现线程没中断在安全点,恢复它,继续执行到安全点。

找到了该回收的对象,下一步就是清掉这些对象了,HotSpot将去交给CG收集器。

4、垃圾回收算法

概览图

img

4.1、标记-清除算法
4.1.1、算法描述

标记阶段:标记处所有需要回收的对象;

清除阶段:标记成功后,统一回收所有被标记的对象;

img

4.1.2、不足

效率不高:标记和清除两个过程效率都不高;

空间问题:产生大量不连续的内存碎片,进而无法容纳大对象提早触发另一次GC.。

4.2、复制算法
4.2.1、算法描述

将可用内存分为容量大小相等的两块,每次只使用其中一块;

当一块用完,就将存活着的对象复制到另一块,然后将这块全部内存清理掉;

img

4.2.2、优点

不会产生不连续的内存碎片

提高效率:

​ 回收:每次都是对整个半区进行回收;

​ 分配:分配时也不用考虑内存碎片的问题,只要移动指针,按顺序分配内存即可。

4.2.3、缺点

可用内存缩小为原来的一半了,适合GC过后只有少量存活的新生代,可以根据实际情况,将内存块大小比例适当调整;

如果存活对象数量比较大,复制性能会变得很差。

4.2.4、JVM中新生代的垃圾回收

如下图,分为新生代和老年代。其中新生代又分为一个Eden区和两个Survivor去(from区和to区),默认Eden : from : to 比例为8:1:1。

可通过JVM参数:-XX:SurvivorRatio配置比例,-XX:SurvivorRatio=8 表示 Eden区大小 / 1块Survivor区大小 = 8。

第一次Young GC

img

再次触发Young GC,扫描Eden区和from区,把存活的对象复制到To区,清空Eden区和from区。如果此时Survivor区的空间不够了,就会提前把对象放入老年代。

默认的,这样来回交换15次后,如果对象最终还是存活,就放入老年代。

交换次数可以通过JVM参数MaxTenuringThreshold进行设置。

4.2.5、JVM内存模型

JDK8之前

img

JDK8

img

如上图,JDK8的方法区实现变成了元空间,元空间在本地内存中。

4.3、标记-整理算法
4.3.1、算法描述

标记过程与标记-清楚算法一样;

标记完成后,将存活对象向一端移动,然后直接清理掉边界以外的内存。

img

4.3.2、优点

不会产生内存碎片;

不需要浪费额外的空间进行分配担保;

4.3.3、不足

整理阶段存在效率问题,适合老年代这种垃圾回收频率不是很高的场景;

4.4、分代收集算法

当前商业虚拟机都采用该算法。

新生代:复制算法(CG后只有少量的对象存活)

老年代:标记-整理算法 或者 标记-清理算法(GC后对象存活率高)

5、垃圾回收器

这一步就是我们真正进行垃圾回收的过程了。

img

本节概念约定:并发:用户线程与垃圾收集线程同时执行,但不一定是并行,可能交替执行;并行:多条垃圾收集线程并行工作,单用户线程仍处于等待状态。

以下是垃圾收集器概览图

img

5.1、Serial收集器
5.1.1、特点

串行化:在垃圾回收时,必须赞同其他所有工作线程,知道收集结束,Stop The World;

在单CPU模式下无线程交互开销,专心做垃圾收集,简单高效。

img

5.1.2、适用场景

特别适合限定单CPU的环境;

Client模式下的默认新生代收集器,用户桌面应用场景分配给虚拟机的内存一般不会很大,所以停顿时间也是在一百多毫秒以内,影响不大。

5.2、ParNew收集器

Parallel New?

5.2.1、特点

Serial收集器的多线程版本;

img

5.2.2、适用场景

许多运行在Server模式下的虚拟机中的首选新生代收集器;

除了Serial收集器外,只有它能和CMS收集器搭配使用。

-XX:+UseConcMarkSweepGC选型默认使用ParNew收集器。也可以使用-XX:+UseParNewGC选项强制指定它。

ParNew收集器在单CPU环境比Serial收集器效果差(存在线程交互开销)。

CPU数量越多,ParNew效果越好,默认开启收集线程数=CPU数量。可以使用-XX:ParallelGCThreads参数限制垃圾收集器的线程数。

5.3、Parallel Scavenge收集器
5.3.1、特点

新生代收集器,使用复制算法,并行多线程;

吞吐量优先收集器:CMS等收集器会关注如何缩短停顿时间,而这个收集器是为了吞吐量而设计的。

吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )

也就是说整体垃圾收集时间越短,吞吐量越高。

img

5.3.2、适用场景

可以高效利用CPU时间,尽快完成程序的运算任务,适合后台运算不需要太多交互的任务;

5.3.3、相关参数

-XXMaxGCPauseMillis:设置最大垃圾收集停顿时间,大于0的毫秒数;

缩短GC停顿时间会牺牲吞吐量和新生代空间。新生代空间小,GC回收就快,但是同时会导致GC更加频繁,整体垃圾回收时间更长。

-XX:GCTimeRatio:设置吞吞量大小。0~100的整数,垃圾收集时间占总时间的比率,相当于吞吐量的倒数。

19: 1/(1+19)= 5%,即最大GC时间占比5%;

99: 1/(1+99)=1%,即最大GC时间占比1%;

-XX:+UseAdaptiveSizePolicy:GC自适应调节策略开关,打开开关,无需手工指定-Xmn(新生代大小)、-XX:SurvivorRatio(Eden与Survivor区比例)、-XX:PretenureSizeThreshold(晋升老年代对象年龄)等参数,虚拟机会收集性能监控信息,动态调整这些参数,确保提供最合适的 停顿时间或者最大吞吐量。

5.4、Serial Old收集器
5.4.1、特点

Serial收集器的老年代版本。使用单线程,标记-整理算法。

5.4.2、适用场景

主要给Client模式下的虚拟机使用;

Server模式下,量大用途:

JDK1.5版本之前的版本与Parallel Scavenge收集器搭配使用;

作为CMS收集器的后备预案,发生Concurrent Mode Failure时使用。

5.5、Parallel Olde收集器
5.5.1、特点

Parallel Scavenge收集器的老年代版本,使用多线程,标记整理算法。

5.5.2、使用场景

主要配合Parallel Scavenge使用,提高吞吐量。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑这个组合。

JDK1.6之后提供,之前Parallel Scavenge只能与Serial Old配合使用,老年代Serial Old无法充分利用服务器多CPU处理器能力,拖累了实际的吞吐量,效果不如ParNew+CMS组合;

5.6、CMS收集器

Concurrent Mark Sweep

5.6.1、特点

设计目标:获得最短回收停顿时间;

注重服务响应速度;

标记-清除算法;

img

5.6.2、缺点

对CPU资源敏感,虽然不会导致用户线程停顿,但是会占用一部分线程(CPU资源)而导致应用程序变慢,吞吐量降低;

CMS收集器无法处理浮动垃圾。在CMS并发清理阶段,用户线程会产生垃圾。如果出现Concurrent Mode Failure失败,会启动后备预案:临时启动Serial Old收集器重新进行老年代垃圾收集,停顿时间更长了。-XX:CM SInitiatingOccupancyFraction设置的太高容易导致这个问题;

基于标记-清除算法,会产生大量空间碎片。

5.6.3、使用场景

互联网网站或者B/S系统的服务器;

5.6.4、相关参数

-XX:+UseCMSCompactAtFullCollection:在CMS要进行Full GC时进行内存碎片整理(默认开启)。内存整理过程无法并发,会增加停顿时间;

-XX:CMSFullGCsBeforeCompaction:在多少次 Full GC 后进行一次空间整理(默认0,即每一次 Full GC 后都进行一次空间整理);

-XX:CM SInitiatingOccupancyFraction:触发GC的内存百分比,设置的太高容易导致Concurrent Mode Failure失败(GC过程中,用户线程新增的浮动垃圾,导致触发另一个Full GC)。

CMS为什么要采用标记-清除算法?

CMS主要关注低延迟,所以采用并发方式清理垃圾,此时程序还在运行,如果采用压缩算法,则会涉及到移动应用程序的存活对象,这种场景下不做停顿是很难处理的,一般需要停顿下来移动存活对象,再让应用程序继续运行,但是这样停顿时间就边长了,延迟变长。CMS是容忍了空间碎片来换取回收的低延迟。

5.7、G1收集器

G1:Garbage-First,即优先回收价值最大的Region(注1)。

注1:G1与收集器将整个Java堆换分为多个代销相等的独立区域,跟踪各个Region里面的垃圾堆积的价值大小,优先回收价值最大的Region。

img

如上图,G1收集器分为四个阶段:

初始标记:只标记GC Roots能直接关联到的对象,速度很快。并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能够在正确可用的Region中创建新对象,这阶段需要停顿线程;

并发标记:GC RootsTracing过程。该阶段对象变化记录在线程Remembered Set Logs中。

最终标记:修正并发期间因用户程序运作而导致标记产生变动的部分对象的标记记录。把Remembered Set Logs数据合并到Remembered Set中。这个阶段需要停顿,但是可并行执行;

筛选回收:对各个Region回收价值和成本进行排序,根据用户期望Gc停顿时间制定回收计划。与CMS不一样,这里不用和用户线程并发执行,提高收集效率,使用标记-整理算法,不产生空间碎片。

5.7.1、特点

并行与并发:并发标记,并行最终标记与筛选回收;

分代收集

空间整合:基于标记-整理算法,不会产生碎片。

可预测的停顿:G与收集器将整个Java堆换分为多个代销相等的独立区域,避免在整个Java堆中进行全区域的垃圾回收,跟踪各个Region里面垃圾堆积的价值大小,后台维护一个优先列表,每次根据运行的收集时间,优先回收价值最大的Region。

四、什么是类加载?何时类加载?类加载流程?

类的加载过程介绍

  1. 介绍
    • 类的加载指的是将类的 .class 文件中的二进制数据读入到 JVM 内存中,将其放在运行时数据区的 方法区 内,然后在 堆区 创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结构。类的加载的最终是位于堆区中的 Class 对象,Class 对象封装了类在方法区内的数据结构,并且提供了访问方法区内的数据结构的接口。

    • 类的加载过程分为 3 个步骤:加载;连接(验证、准备、解析);初始化,一般情况下 JVM 会连续完成 3 个步骤,有时也会只完成前两步。

    • 如图

      img

      类加载过程.jpg

  2. 类加载器介绍
    • 类加载介绍
    • 系统可能在第一次使用某一个类时,加载该类,但也可能采用 预先加载机制 来加载该类,不管怎样类的加载必须由 类加载器 来完成。通常类加载器是由 JVM 提供。
    • 类的加载必须由类加载器完成,通常情况下类加载器由 JVM 提供,但也可以通过自定义。
      1. JVM 提供的类加载器被称之为 系统类加载器
      2. 开发者还可以通过继承 ClassLoader 接口来创建 自定义类加载器
    • 通过不同的类加载器,可以从不同的 “来源” 加载类的 .class 文件(二进制文件)
      1. 从本地系统中直接读取 .class 文件,大部分的加载方式。
      2. 从 ZIP、JAR 等归档文件中加载 .class 文件,很常见。
      3. 从网络下载 .class 文件数据。
      4. 从专有数据库中读取 .class 文件
      5. 将 Java 的源文件数据,上传到服务器中,进行动态编译产生 .class 文件,并加以执行。
    • 但是不管 .class 文件数据来源何处,加载的结果都是相同的
      1. 将字节码文件数据加载到 JVM 内存中,将其放在运行时数据区的 方法区 内,然后在 堆区 创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结构。类的加载的最终是位于堆区中的 Class 对象,Class 对象封装了类在方法区内的数据结构,并且提供了访问方法区内的数据结构的接口。

加载

  1. 作用
    • 通过一个类的全限定名来获取其定义的字节码(二进制字节流),将字节码文件加载到 JVM 内存中,此过程由类加载器完成(可控)
  2. 特点
    • 加载阶段是可控性最强的阶段,既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

连接

  1. 验证
    • 校验 .class 文件是否合法,遵循 .class 文件格式 参考地址
  2. 准备
    • 为类变量(static 修饰的变量)在 JVM 方法区中分配内存,并进行 默认初始化
      1. int 默认初始化为 0
      2. 引用默认初始化为 null
      3. 等等
    • 静态常量(static final) ,有所不同,直接在 JVM 方法去中 显示初始化
  3. 解析
    • JVM 将常量池的符号引用。替换为直接(地址)引用
  4. 特点
    • 此时在堆区中已经创建一个 java.lang.Class 对象,指向方法区中的数据

初始化

  1. 作用
    • 主要是对类静态的类变量进行 显示初始化 参考地址
      1. init 对非静态变量解析初始化
      2. clinit 是 java.lang.class 类构造器对静态变量,静态代码块进行初始化
    • 类构造器方法(clinit)由编译器收集类中所有类变量的 显示赋值和静态代码块中的语句合并产生
  2. 特点
    • 当初始化某个类时,如果其父类没有初始化,则先触发父类的初始化动作
    • JVM 保证一个类的初始化,在多线中中正确加锁和同步

何时会或者不会触发类初始化动作呢?

  1. 介绍
    • 上面已经介绍,类的加载过程分为 3 步,大部分 3 步按顺序完成,有时也会只完成前两步
  2. 如何区分会不会触发类的初始化
    • 如表

      会触发类的初始化 不会触发类的初始化
      当虚拟机启动时,先初始化 main() 方法所在的类 引用静态常量不会触发此类的初始化
      一次 new 一个类的对象(在 JVM 中一个类的 Class 对象只有一个) 当访问一个静态域时,只有真正声明该域的类才会被初始化(子类继承父类的静态变量,在子类使用该静态变量时,只有父类会初始化,子类不会初始化)
      调用该类的静态变量(static final 除外,因为其在连接时已经显示初始化完成)和静态方法 通过数组定义类引用时,不会触发类初始化(A[] as = new A[2] A 是类,此时不会初始化 A类)
      当初始化某个类时,其父类没有被初始化时,则会先初始化其父类

五、知道哪些类加载器。类加载器之间的关系?

一、三种类加载器

当 JVM 启动的时候,Java 缺省开始使用如下三种类型的类加载器:

启动(Bootstrap)类加载器:引导类加载器是用 本地代码实现的类加载器,它负责将 /lib 下面的核心类库 或 -Xbootclasspath 选项指定的 jar 包等 虚拟机识别的类库 加载到内存中。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以 不允许直接通过引用进行操作。

扩展(Extension)类加载器:扩展类加载器是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的,它负责将 /lib/ext 或者由系统变量 - Djava.ext.dir 指定位置中的类库 加载到内存中。开发者可以直接使用标准扩展类加载器。

系统(System)类加载器:系统类加载器是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的,它负责将 用户类路径 (java -classpath 或 - Djava.class.path 变量所指的目录,即当前类所在路径及其引用的第三方类库的路径。开发者可以直接使用系统类加载器。

通过这两张图我们可以看出,扩展类加载器和系统类加载器均是继承自 java.lang.ClassLoader 抽象类。

二、类加载器的关系

关系如下:

上面图片给人的直观印象是:系统类加载器的父类加载器是标准扩展类加载器,标准扩展类加载器的父类加载器是启动类加载器。

事实上,由于启动类加载器无法被 Java 程序直接引用,因此 JVM 默认直接使用 null 代表启动类加载器。

此外:

1.系统类加载器(AppClassLoader)调用 ClassLoader (ClassLoader parent) 构造函数将父类加载器设置为标准扩展类加载器 (ExtClassLoader)。(因为如果不强制设置,默认会通过调用 getSystemClassLoader () 方法获取并设置成系统类加载器。)

2.扩展类加载器(ExtClassLoader)调用 ClassLoader (ClassLoader parent) 构造函数将父类加载器设置为 null(null 本身就代表着引导类加载器)。(因为如果不强制设置,默认会通过调用 getSystemClassLoader () 方法获取并设置成系统类加载器,。)

六、类加载器的双亲委派了解么? 结合 Tomcat 说一下双亲委派(Tomcat 如何打破双亲委托机制?…)。

这是我们研究Tomcat的第四篇文章,前三篇文章我们搭建了源码框架,了解了tomcat的大致的设计架构, 还写了一个简单的服务器。按照我们最初订的计划,今天,我们要开始研究tomcat的几个主要组件(组件太多,无法一一解析,解析几个核心),包括核心的类加载器,连接器和容器,还有生命周期,还有pipeline 和 valve。一个一个来,今天来研究类加载器。

我们分为4个部分来探讨:

1
2
3
4
1. 什么是类加载机制?
2. 什么是双亲委任模型?
3. 如何破坏双亲委任模型?
4. Tomcat 的类加载器是怎么设计的?

我想,在研究tomcat 类加载之前,我们复习一下或者说巩固一下java 默认的类加载器。楼主以前对类加载也是懵懵懂懂,借此机会,也好好复习一下。

楼主翻开了神书《深入理解Java虚拟机》第二版,p227, 关于类加载器的部分。请看:

1. 什么是类加载机制?

代码编译的结果从本地机器码转变成字节码,是存储格式的一小步,却是编程语言发展的一大步。

Java虚拟机把描述类的数据从Class文件加载进内存,并对数据进行校验,转换解析和初始化,最终形成可以呗虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这动作的代码模块成为“类加载器”。

类与类加载器的关系

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载他的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类命名空间。这句话可以表达的更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来自同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这个两个类就必定不相等。

2. 什么是双亲委任模型
  1. 从Java虚拟机的角度来说,只存在两种不同类加载器:一种是**启动类加载器(Bootstrap ClassLoader)**,这个类加载器使用C++语言实现(只限HotSpot),是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader.
  2. 从Java开发人员的角度来看,类加载还可以划分的更细致一些,绝大部分Java程序员都会使用以下3种系统提供的类加载器:
    • 启动类加载器(Bootstrap ClassLoader):这个类加载器复杂将存放在 JAVA_HOME/lib 目录中的,或者被-Xbootclasspath 参数所指定的路径种的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录下也不会重载)。
    • 扩展类加载器(Extension ClassLoader):这个类加载器由sun.misc.Launcher$ExtClassLoader实现,它负责夹杂JAVA_HOME/lib/ext 目录下的,或者被java.ext.dirs 系统变量所指定的路径种的所有类库。开发者可以直接使用扩展类加载器。
    • 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader 实现。由于这个类加载器是ClassLoader 种的getSystemClassLoader方法的返回值,所以也成为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库。开发者可以直接使用这个类加载器,如果应用中没有定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

这些类加载器之间的关系一般如下图所示:

img

图中各个类加载器之间的关系成为 类加载器的双亲委派模型(Parents Dlegation Mode)。双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器加载,这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。

类加载器的双亲委派模型在JDK1.2 期间被引入并被广泛应用于之后的所有Java程序中,但他并不是个强制性的约束模型,而是Java设计者推荐给开发者的一种类加载器实现方式。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,而是把这个请求委派父类加载器去完成。每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个请求(他的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

为什么要这么做呢?

如果没有使用双亲委派模型,由各个类加载器自行加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统将会出现多个不同的Object类, Java类型体系中最基础的行为就无法保证。应用程序也将会变得一片混乱。

双亲委任模型时如何实现的?

非常简单:所有的代码都在java.lang.ClassLoader中的loadClass方法之中,代码如下:

img

逻辑清晰易懂:先检查是否已经被加载过,若没有加载则调用父加载器的loadClass方法, 如父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出ClassNotFoundException 异常后,再调用自己的findClass方法进行加载。

3. 如何破坏双亲委任模型?

刚刚我们说过,双亲委任模型不是一个强制性的约束模型,而是一个建议型的类加载器实现方式。在Java的世界中大部分的类加载器都遵循者模型,但也有例外,到目前为止,双亲委派模型有过3次大规模的“被破坏”的情况。
第一次:在双亲委派模型出现之前—–即JDK1.2发布之前。
第二次:是这个模型自身的缺陷导致的。我们说,双亲委派模型很好的解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API, 但没有绝对,如果基础类调用会用户的代码怎么办呢?

这不是没有可能的。一个典型的例子就是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器去加载(在JDK1.3时就放进去的rt.jar),但它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI, Service Provider Interface)的代码,但启动类加载器不可能“认识“这些代码啊。因为这些类不在rt.jar中,但是启动类加载器又需要加载。怎么办呢?

为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader方法进行设置。如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过多的话,那这个类加载器默认即使应用程序类加载器。

嘿嘿,有了线程上下文加载器,JNDI服务使用这个线程上下文加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则。但这无可奈何,Java中所有涉及SPI的加载动作基本胜都采用这种方式。例如JNDI,JDBC,JCE,JAXB,JBI等。

第三次:为了实现热插拔,热部署,模块化,意思是添加一个功能或减去一个功能不用重启,只需要把这模块连同类加载器一起换掉就实现了代码的热替换。

书中还说到:

Java 程序中基本有一个共识:OSGI对类加载器的使用时值得学习的,弄懂了OSGI的实现,就可以算是掌握了类加载器的精髓。

牛逼啊!!!

现在,我们已经基本明白了Java默认的类加载的作用了原理,也知道双亲委派模型。说了这么多,差点把我们的tomcat给忘了,我们的题目是Tomcat 加载器为何违背双亲委派模型?下面就好好说说我们的tomcat的类加载器。

4. Tomcat 的类加载器是怎么设计的?

首先,我们来问个问题:

Tomcat 如果使用默认的类加载机制行不行?

我们思考一下:Tomcat是个web容器, 那么它要解决什么问题:

  1. 一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
  2. 部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机,这是扯淡的。
  3. web容器也有自己依赖的类库,不能于应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
  4. web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情,否则要你何用? 所以,web容器需要支持 jsp 修改后不用重启。

再看看我们的问题:Tomcat 如果使用默认的类加载机制行不行?
答案是不行的。为什么?我们看,第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的累加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。第二个问题,默认的类加载器是能够实现的,因为他的职责就是保证唯一性。第三个问题和第一个问题一样。我们再看第四个问题,我们想我们要怎么实现jsp文件的热修改(楼主起的名字),jsp 文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。

Tomcat 如何实现自己独特的类加载机制?

所以,Tomcat 是怎么实现的呢?牛逼的Tomcat团队已经设计好了。我们看看他们的设计图:

img

我们看到,前面3个类加载和默认的一致,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader则是Tomcat自己定义的类加载器,它们分别加载/common/*/server/*/shared/*(在tomcat 6之后已经合并到根目录下的lib目录下)和/WebApp/WEB-INF/*中的Java类库。其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。

  • commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
  • catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
  • sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
  • WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见;

从图中的委派关系中可以看出:

CommonClassLoader能加载的类都可以被Catalina ClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和Shared ClassLoader自己能加载的类则与对方相互隔离。

WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。

而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能。

好了,至此,我们已经知道了tomcat为什么要这么设计,以及是如何设计的,那么,tomcat 违背了java 推荐的双亲委派模型了吗?答案是:违背了。 我们前面说过:

双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器加载。

很显然,tomcat 不是这样实现,tomcat 为了实现隔离性,没有遵守这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。

我们扩展出一个问题:如果tomcat 的 Common ClassLoader 想加载 WebApp ClassLoader 中的类,该怎么办?

看了前面的关于破坏双亲委派模型的内容,我们心里有数了,我们可以使用线程上下文类加载器实现,使用线程上下文加载器,可以让父类加载器请求子类加载器去完成类加载的动作。牛逼吧。

总结

好了,终于,我们明白了Tomcat 为何违背双亲委派模型,也知道了tomcat的类加载器是如何设计的。顺便复习了一下 Java 默认的类加载器机制,也知道了如何破坏Java的类加载机制。这一次收获不小哦!!! 嘿嘿。

七、常见调优参数有哪些?

1
2
3
4
5
6
7
8
9
-Xms2g:初始化推大小为 2g;
-Xmx2g:堆最大内存为 2g;
-XX:NewRatio=4:设置年轻的和老年代的内存比例为 1:4
-XX:SurvivorRatio=8:设置新生代 Eden 和 Survivor 比例为 8:2
–XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器组合;
-XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器组合;
-XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器组合;
-XX:+PrintGC:开启打印 gc 信息;
-XX:+PrintGCDetails:打印 gc 详细信息。

八、……

数据库

学习了 MySQL 之后,务必确保自己掌握下面这些知识点:

一、MySQL 常用命令 :

1、MySQL常用命令

1
2
3
4
5
6
7
8
9
create database name; 创建数据库
use databasename; 选择数据库
drop database name 直接删除数据库,不提醒
show tables; 显示表
describe tablename; 表的详细描述
select 中加上distinct去除重复字段
mysqladmin drop databasename 删除数据库前,有提示。
显示当前mysql版本和当前日期
select version(),current_date;

2、修改mysql中root的密码:

1
2
3
4
5
6
7
shell>mysql -u root -p
mysql> update user set password=password(”xueok654123″) where user=’root’;
mysql> flush privileges //刷新数据库
mysql>use dbname; 打开数据库:
mysql>show databases; 显示所有数据库
mysql>show tables; 显示数据库mysql中所有的表:先use mysql;然后
mysql>describe user; 显示表mysql数据库中user表的列信息);

3、grant

1
2
3
4
5
6
7
8
9
10
11
12
13
14
创建一个可以从任何地方连接服务器的一个完全的超级用户,但是必须使用一个口令something做这个
mysql> grant all privileges on *.* to user@localhost identified by ’something’ with
增加新用户
格式:grant select on 数据库.* to 用户名@登录主机 identified by “密码”
GRANT ALL PRIVILEGES ON *.* TO monty@localhost IDENTIFIED BY ’something’ WITH GRANT OPTION;
GRANT ALL PRIVILEGES ON *.* TO monty@”%” IDENTIFIED BY ’something’ WITH GRANT OPTION;
删除授权:
mysql> revoke all privileges on *.* from root@”%”;
mysql> delete from user where user=”root” and host=”%”;
mysql> flush privileges;
创建一个用户custom在特定客户端it363.com登录,可访问特定数据库fangchandb
mysql >grant select, insert, update, delete, create,drop on fangchandb.* to custom@ [it363.com](https://link.jianshu.com?t=http://it363.com) identified by ‘ passwd’
重命名表:
mysql > alter table t1 rename t2;

4、mysqldump

1
2
3
4
5
6
7
8
9
10
11
12
备份数据库
shell> mysqldump -h host -u root -p dbname >dbname_backup.sql
恢复数据库
shell> mysqladmin -h myhost -u root -p create dbname
shell> mysqldump -h host -u root -p dbname < dbname_backup.sql
如果只想卸出建表指令,则命令如下:
shell> mysqladmin -u root -p -d databasename > a.sql
如果只想卸出插入数据的sql命令,而不需要建表命令,则命令如下:
shell> mysqladmin -u root -p -t databasename > a.sql
那么如果我只想要数据,而不想要什么sql命令时,应该如何操作呢?
  mysqldump -T./ phptest driver
其中,只有指定了-T参数才可以卸出纯文本文件,表示卸出数据的目录,./表示当前目录,即与mysqldump同一目录。如果不指定driver 表,则将卸出整个数据库的数据。每个表会生成两个文件,一个为.sql文件,包含建表执行。另一个为.txt文件,只包含数据,且没有sql指令。

5、可将查询存储在一个文件中并告诉mysql从文件中读取查询而不是等待键盘输入。可利用外壳程序键入重定向实用程序来完成这项工作。

1
2
3
4
例如,如果在文件my_file.sql 中存放有查
询,可如下执行这些查询:
例如,如果您想将建表语句提前写在sql.txt中:
mysql > mysql -h myhost -u root -p database < sql.txt

二、MySQL 中常用的数据类型、字符集编码

MySQL 支持多种数据类型,主要有数值类型、日期/时间类型和字符串类型。

  • 数值类型:包括整数类型 TINYINTSMALLINTMEDIUMINTINTBIGINT、浮点小数数据类型 FLOATDOUBLE、定点小数类型 DECIMAL
  • 日期/时间类型:YEARTIMEDATEDATETIMETIMESTAMP
  • 字符串类型: CHARVARCHARBINARYVARBINARYBLOBTEXTENUMSET

整数类型

MySQL中的整数型数据类型:

类型名称 存储需求
TINYINT 1个字节
SMALLINT 2个字节
MEDIUMINT 3个字节
INT(INTEGER) 4个字节
BIGINT 8个字节

日期与时间类型

类型名称 日期格式 日期范围 存储需求
YEAR YYYY 1901~2155 1个字节
TIME HH:MM:SS -838:59:59~838:59:59 3个字节
DATE YYYY-MM-DD 1000-01-01~9999-12-3 3个字节
DATETIME YYYY-MM-DD HH:MM:SS 1000-01-01 00:00:00~9999-12-31 23:59:59 8个字节
TIMESTAMP YYYY-MM-DD HH:MM:SS 1970-01-01 00:00:01 UTC ~2038-01-19 03:14:07 UTC 4个字节

在这里提一下** CURRENT_DATE 和 NOW() 的区别**:CURRENT_DATE 返回当前日期值,不包括时间部分,NOW() 函数返回日期和时间值。

提示:TIMESTAMP 和 DATATIME 除了存储字节和支持的范围不同外,还有一个最大的区别就是:DATETIME 在存储日期数据时,按实际输入的格式存储,即输入什么就存储什么,与时区无关;而 TIMESTAMP 值的存储是以 UTC (世界标准时间)格式保存的,存储时对当前时区进行转换,检索时再转换回当前时区。即查询时,根据当前时区的不同,显示的时间值是不同的。

字符串类型

类型名称 说明 存储需求
CHAR(M) 固定长度非二进制字符串 M字节,1<=M<= 255
VARCHAR(M) 变长非二进制字符串 L+1字节,L<=M和1<=M<=255
TINYTEXT 非常小的非二进制字符串 L+1字节,在此L<2^8
TEXT 小的非二进制字符串 L+2字节,在此L<2^16
MEDIUMTEXT 中等大小的非二进制字符串 L+3字节,在此L<2^32
LONGTEXT 大的非二进制字符串 L+4字节,在此L<2^32
ENUM 枚举类型,只能存一个枚举字符串值 1或2个字节,取决于枚举值的数目(最大值65535)
SET 一个设置,字符串对象可以有零个或多个 SET 成员 1、2、3、4或8个字节,取决于集合成员的数量(最多64个成员)

三、MySQL 简单查询、条件查询、模糊查询、多表查询以及如何对查询结果排序、过滤、分组……

image-20210607163257520

四、MySQL 中使用索引、视图、存储过程、游标、触发器

1 索引
2 触发器
3 存储过程和函数
4 视图
5 基本的数据库建表语句练习

1 索引

(1)基本概念

https://blog.csdn.net/buhuikanjian/article/details/52966039

(2)建立索引的原则

https://www.cnblogs.com/aspwebchh/p/6652855.html

(3)具体操作语句

步骤1 创建表test_table1,添加三个索引

1
2
3
4
5
6
7
8
9
CREATE TABLE test_table1(
id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
NAME CHAR(100) NOT NULL,
address CHAR(100) NOT NULL,
description CHAR(100) NOT NULL,
UNIQUE INDEX UniqIdx(id),
INDEX MultiColIdx(NAME(20), address(30)),
INDEX ComIdx(description(30))
);

步骤2 创建表test_table1,添加三个索引创建表test_table2,存储引擎为MyISAM

1
2
3
4
5
6
7
8
CREATE TABLE test_table2(
id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
firstname CHAR(100) NOT NULL,
middlename CHAR(100) NOT NULL,
lastname CHAR(100) NOT NULL,
birth DATE NOT NULL,
title CHAR(100) NULL
) ENGINE=MYISAM;

步骤3 创建表test_table1,添加三个索引使用ALTER TABLE语句在表test_table2的birth字段上,建立名称为ComDateIdx的普通索引

1
ALTER TABLE test_table2 ADD INDEX ComDateIdx(birth);

步骤4 创建表test_table1,添加三个索引使用ALTER TABLE语句在表test_table2的id字段上,添加名称为UniqIdx2的唯一索引,并以降序排列

1
ALTER TABLE test_table2 ADD UNIQUE INDEX UniqIdx2 (id DESC);

步骤5 创建表test_table1,添加三个索引使用CREATE INDEX在firstname、middlename和lastname3个字段上建立名称为MultiColIdx2的组合索引

1
CREATE INDEX MultiColIdx2 ON test_table2(firstname, middlename, lastname);

步骤6 创建表test_table1,添加三个索引使用CREATE INDEX在title字段上建立名称为FTIdx的全文索引

1
CREATE FULLTEXT INDEX FTIdx ON test_table2(title);

步骤7 创建表test_table1,添加三个索引使用ALTER TABLE语句删除表test_table1中名称为UniqIdx的唯一索引

1
ALTER TABLE test_table1 DROP INDEX UniqIdx;

步骤8 创建表test_table1,添加三个索引使用DROP INDEX语句删除表test_table2中名称为MultiColIdx2的组合索引

1
DROP INDEX MultiColIdx2 ON test_table2;

2 触发器

img

(2)触发器使用

https://www.cnblogs.com/yank/p/4193820.html

3 存储过程和函数

(1)存储过程优缺点

https://blog.csdn.net/jackmacro/article/details/5688687

(2)存储过程、函数、游标

https://www.cnblogs.com/doudouxiaoye/p/5811836.html
https://www.cnblogs.com/jacketlin/p/7874009.html

(3)代码详解

1 创建查看fruits表的存储过程,创建了一个查看fruits表的存储过程,每次调用这个存储过程的时候都会执行SELECT语句查看表的内容。

1
2
3
4
CREATE PROCEDURE Proc()
BEGIN
SELECT * FROM fruits;
END ;

2 创建名称为CountProc的存储过程,获取fruits表记录条数。COUNT(*) 计算后把结果放入参数param1中。
当使用DELIMITER命令时,应该避免使用反斜杠(’\’)字符,因为反斜线是MySQL的转义字符。

1
2
3
4
CREATE PROCEDURE CountProc (OUT param1 INT)
BEGIN
SELECT COUNT(*) INTO param1 FROM fruits;
END;

3 创建存储函数NameByZip,该函数返回SELECT语句的查询结果,数值类型为字符串型。

1
2
3
CREATE FUNCTION NameByZip ()
RETURNS CHAR(50)
RETURN (SELECT s_name FROM suppliers WHERE s_call= '48075');

4 定义名称为myparam的变量,类型为INT类型,默认值为100。

1
DECLARE  myparam  INT  DEFAULT 100;

5 声明3个变量,分别为var1、var2和var3,数据类型为INT,使用SET为变量赋值。

1
2
3
DECLARE var1, var2, var3 INT;
SET var1 = 10, var2 = 20;
SET var3 = var1 + var2;

MySQL中还可以通过SELECT … INTO为一个或多个变量赋值,语法如下:

1
SELECT col_name[,...] INTO var_name[,...] table_expr;

这个SELECT语法把选定的列直接存储到对应位置的变量。col_name表示字段名称;var_name表示定义的变量名称;table_expr表示查询条件表达式,包括表名称和WHERE子句。

6 声明变量fruitname和fruitprice,通过SELECT,INTO语句查询指定记录并为变量赋值。

1
2
3
4
5
DECLARE fruitname CHAR(50);
DECLARE fruitprice DECIMAL(8,2);

SELECT f_name,f_price INTO fruitname, fruitprice
FROM fruits WHERE f_id ='a1';

7 声明名称为cursor_fruit的光标

1
DECLARE cursor_fruit CURSOR FOR SELECT f_name, f_price FROM fruits ;

8 使用名称为cursor_fruit的光标。将查询出来的数据存入fruit_name和fruit_price这两个变量中。

1
FETCH  cursor_fruit INTO fruit_name, fruit_price ;

9 IF语句的示例

1
2
3
4
IF val IS NULL
THEN SELECT 'val is NULL';
ELSE SELECT 'val is not NULL';
END IF;

10 使用CASE流程控制语句的第1种格式,判断val值等于1、等于2,或者两者都不等。

1
2
3
4
5
CASE val
WHEN 1 THEN SELECT 'val is 1';
WHEN 2 THEN SELECT 'val is 2';
ELSE SELECT 'val is not 1 or 2';
END CASE;

11 使用LOOP语句进行循环操作,id值小于等于10之前,将重复执行循环过程。

1
2
3
4
5
6
DECLARE id INT DEFAULT 0;
add_loop: LOOP
SET id = id + 1;
IF id >= 10 THEN LEAVE add_loop;
END IF;
END LOOP add_ loop;

12 使用LEAVE语句退出循环。循环执行count加1的操作。当count的值等于50时,使用LEAVE语句跳出循环。

1
2
3
4
add_num: LOOP  
SET @count=@count+1;
IF @count=50 THEN LEAVE add_num ;
END LOOP add_num ;

13 ITERATE语句示例。

1
2
3
4
5
6
7
8
9
10
11
CREATE PROCEDURE doiterate()
BEGIN
DECLARE p1 INT DEFAULT 0;
my_loop: LOOP
SET p1= p1 + 1;
IF p1 < 10 THEN ITERATE my_loop;
ELSEIF p1 > 20 THEN LEAVE my_loop;
END IF;
SELECT 'p1 is between 10 and 20';
END LOOP my_loop;
END

14 REPEAT语句示例,id值小于等于10之前,将重复执行循环过程。
该示例循环执行id加1的操作。当id值小于10时,循环重复执行;当id值大于或者等于10时,使用LEAVE语句退出循环。REPEAT循环都以END REPEAT结束。

1
2
3
4
5
DECLARE id INT DEFAULT 0;
REPEAT
SET id = id + 1;
UNTIL id >= 10
END REPEAT;

15 WHILE语句示例,id值小于等于10之前,将重复执行循环过程。

1
2
3
4
DECLARE i INT DEFAULT 0;
WHILE i < 10 DO
SET i = i + 1;
END WHILE;

16 定义名为CountProc1的存储过程,然后调用这个存储过程。

1
2
3
4
CREATE PROCEDURE CountProc1 (IN sid INT, OUT num INT)
BEGIN
SELECT COUNT(*) INTO num FROM fruits WHERE s_id = sid;
END //

4 视图

(1)视图的含义和作用

视图是数据库中的一个虚拟表。同真实的表一样,视图包含一系列的行和列数据。行和列数据来源于自由定义视图查询所引用的表,并且在引用视图是动态生成。

(2)视图和表的联系、区别

img

(3)视图基本操作

步骤1:创建学生表stu,插入3条记录。

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE stu 
(
s_id INT PRIMARY KEY,
s_name VARCHAR(20),
addr VARCHAR(50),
tel VARCHAR(50)
);
INSERT INTO stu
VALUES(1,'XiaoWang','Henan','0371-12345678'),
(2,'XiaoLi','Hebei','13889072345'),
(3,'XiaoTian','Henan','0371-12345670');

步骤2:创建报名表sign,插入3条记录。

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE sign 
(
s_id INT PRIMARY KEY,
s_name VARCHAR(20),
s_sch VARCHAR(50),
s_sign_sch VARCHAR(50)
);
INSERT INTO sign
VALUES(1,'XiaoWang','Middle School1','Peking University'),
(2,'XiaoLi','Middle School2','Tsinghua University'),
(3,'XiaoTian','Middle School3','Tsinghua University');

步骤3:创建成绩表stu_mark,插入3条记录。

1
2
CREATE TABLE stu_mark (s_id INT PRIMARY KEY ,s_name VARCHAR(20) ,mark int ); 
INSERT INTO stu_mark VALUES(1,'XiaoWang',80),(2,'XiaoLi',71),(3,'XiaoTian',70);

步骤4:创建考上Peking University的学生的视图

1
2
3
4
5
CREATE VIEW beida (id,name,mark,sch)
AS SELECT stu_mark.s_id,stu_mark.s_name,stu_mark.mark, sign.s_sign_sch
FROM stu_mark ,sign
WHERE stu_mark.s_id=sign.s_id AND stu_mark.mark>=41
AND sign.s_sign_sch='Peking University';

步骤5:创建考上qinghua University的学生的视图

1
2
3
4
5
CREATE VIEW qinghua (id,name,mark,sch) 
AS SELECT stu_mark.s_id, stu_mark.s_name, stu_mark.mark, sign.s_sign_sch
FROM stu_mark ,sign
WHERE stu_mark.s_id=sign.s_id AND stu_mark.mark>=40
AND sign.s_sign_sch='Tsinghua University';

步骤6:XiaoTian的成绩在录入的时候录入错误多录了50分,对其录入成绩进行更正。

1
UPDATE stu_mark SET mark = mark-50 WHERE stu_mark.s_name ='XiaoTian';

步骤7:查看更新过后视图和表的情况。

1
2
3
SELECT * FROM stu_mark;
SELECT * FROM qinghua;
SELECT * FROM beida;

步骤8:查看视图的创建信息。

1
SELECT * FROM information_schema.views

步骤9:删除创建的视图。

1
2
DROP VIEW beida;
DROP VIEW qinghua;

5 基本的数据库建表语句练习(这个好像我真的是不会……)

建立一个数据库,逻辑名称为Student,包含1个数据文件和1个日志文件。数据文件初始大小为10M

1
2
3
4
5
6
7
8
9
10
11
12
if exists(select * from sys.sysdatabases where name='Student')
begin
use master
drop database Student
end
go
create database Student
on
--路径根据实际情况自行修改
(name=N'Student',filename=N'E:\Student.mdf',size=10mb,maxsize=unlimited,filegrowth=1)
log on
(name=N'Student',filename=N'E:\Student_log.ldf',size=10mb,maxsize=unlimited,filegrowth=1)

https://www.cnblogs.com/accumulater/p/6158294.html

创建表,增加约束。包括:主键约束、非空约束、性别范围约束、出生日期约束、年龄约束、外键约束、唯一性约束、评论约束、默认关键词约束

下面的语句可能不通顺,但是这些约束都有。check也可以使用enum代替。

1
2
3
4
5
6
7
8
9
CREATE TABLE tblstudent(
stuID BIGINT PRIMARY KEY NOT NULL,
stuName NVARCHAR(10) NOT NULL,
stuSex NCHAR(1) NOT NULL DEFAULT '男' CHECK (stuSex IN ('男','女')),
stuBirth DATETIME CHECK (stuBirth < getdate()) COMMENT '出生日期',
stuNum NVARCHAR(18) UNIQUE
Math INT CHECK(Sage > 18 AND Sage < 30) COMMENT '数学'
stuID BIGINT REFERENCES tblstudent(stuID)
)

https://www.cnblogs.com/ghost-xyx/p/3795679.html

drop,alter,insert,update,delete

https://blog.csdn.net/leftwukaixing/article/details/44415875


这个东西属于基础知识,可能不需要深入了解,但是不知道一定会有问题。

如果你想让自己更加了解 MySQL ,同时也是为了准备面试的话,下面这些知识点要格外注意:

一、索引:索引优缺点、B 树和 B+树、聚集索引与非聚集索引、覆盖索引

什么是索引?

索引是一种用于快速查询和检索数据的数据结构。常见的索引结构有: B树, B+树和Hash。

索引的作用就相当于目录的作用。打个比方: 我们在查字典的时候,如果没有目录,那我们就只能一页一页的去找我们需要查的那个字,速度很慢。如果有目录了,我们只需要先去目录里查找字的位置,然后直接翻到那一页就行了。

为什么要用索引?索引的优缺点分析

索引的优点

可以大大加快 数据的检索速度(大大减少的检索的数据量), 这也是创建索引的最主要的原因。毕竟大部分系统的读请求总是大于写请求的。 另外,通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。

索引的缺点

创建索引和维护索引需要耗费许多时间:当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低SQL执行效率。

占用物理存储空间 :索引需要使用物理文件存储,也会耗费一定空间。

B树和B+树区别

B树的所有节点既存放 键(key) 也存放 数据(data);而B+树只有叶子节点存放 key 和 data,其他内节点只存放key。

B树的叶子节点都是独立的;B+树的叶子节点有一条引用链指向与它相邻的叶子节点。

B树的检索的过程相当于对范围内的每个节点的关键字做二分查找,可能还没有到达叶子节点,检索就结束了。而B+树的检索效率就很稳定了,任何查找都是从根节点到叶子节点的过程,叶子节点的顺序检索很明显。

img

Hash索引和 B+树索引优劣分析

Hash索引定位快

Hash索引指的就是Hash表,最大的优点就是能够在很短的时间内,根据Hash函数定位到数据所在的位置,这是B+树所不能比的。

Hash冲突问题

知道HashMap或HashTable的同学,相信都知道它们最大的缺点就是Hash冲突了。不过对于数据库来说这还不算最大的缺点。

Hash索引不支持顺序和范围查询(Hash索引不支持顺序和范围查询是它最大的缺点。

试想一种情况:

SELECT * FROM tb1 WHERE id < 500;

B+树是有序的,在这种范围查询中,优势非常大,直接遍历比500小的叶子节点就够了。而Hash索引是根据hash算法来定位的,难不成还要把 1 - 499的数据,每个都进行一次hash计算来定位吗?这就是Hash最大的缺点了。

索引类型

  • 主键索引(Primary Key)

数据表的主键列使用的就是主键索引。

一张数据表有只能有一个主键,并且主键不能为null,不能重复。

在mysql的InnoDB的表中,当没有显示的指定表的主键时,InnoDB会自动先检查表中是否有唯一索引的字段,如果有,则选择该字段为默认的主键,否则InnoDB将会自动创建一个6Byte的自增主键。

  • 二级索引(辅助索引)

二级索引又称为辅助索引,是因为二级索引的叶子节点存储的数据是主键。也就是说,通过二级索引,可以定位主键的位置。

唯一索引,普通索引,前缀索引等索引属于二级索引。

PS:不懂的同学可以暂存疑,慢慢往下看,后面会有答案的,也可以自行搜索。

唯一索引(Unique Key) :唯一索引也是一种约束。唯一索引的属性列不能出现重复的数据,但是允许数据为NULL,一张表允许创建多个唯一索引。建立唯一索引的目的大部分时候都是为了该属性列的数据的唯一性,而不是为了查询效率。

普通索引(Index)普通索引的唯一作用就是为了快速查询数据,一张表允许创建多个普通索引,并允许数据重复和NULL。

前缀索引(Prefix) :前缀索引只适用于字符串类型的数据。前缀索引是对文本的前几个字符创建索引,相比普通索引建立的数据更小,因为只取前几个字符。

全文索引(Full Text) :全文索引主要是为了检索大文本数据中的关键字的信息,是目前搜索引擎数据库使用的一种技术。Mysql5.6之前只有MYISAM引擎支持全文索引,5.6之后InnoDB也支持了全文索引。

二级索引:

img

聚集索引与非聚集索引

  • 聚集索引

聚集索引即索引结构和数据一起存放的索引。主键索引属于聚集索引。

在 Mysql 中,InnoDB引擎的表的 .ibd文件就包含了该表的索引和数据,对于 InnoDB 引擎表来说,该表的索引(B+树)的每个非叶子节点存储索引,叶子节点存储索引和索引对应的数据。

聚集索引的优点

聚集索引的查询速度非常的快,因为整个B+树本身就是一颗多叉平衡树,叶子节点也都是有序的,定位到索引的节点,就相当于定位到了数据。

聚集索引的缺点

依赖于有序的数据 :因为B+树是多路平衡树,如果索引的数据不是有序的,那么就需要在插入时排序,如果数据是整型还好,否则类似于字符串或UUID这种又长又难比较的数据,插入或查找的速度肯定比较慢。

更新代价大 : 如果对索引列的数据被修改时,那么对应的索引也将会被修改,而且况聚集索引的叶子节点还存放着数据,修改代价肯定是较大的,所以对于主键索引来说,主键一般都是不可被修改的。

  • 非聚集索引

非聚集索引即索引结构和数据分开存放的索引。

二级索引属于非聚集索引。

MYISAM引擎的表的.MYI文件包含了表的索引,该表的索引(B+树)的每个叶子非叶子节点存储索引,叶子节点存储索引和索引对应数据的指针,指向.MYD文件的数据。

非聚集索引的叶子节点并不一定存放数据的指针,因为二级索引的叶子节点就存放的是主键,根据主键再回表查数据。

非聚集索引的优点

更新代价比聚集索引要小 。非聚集索引的更新代价就没有聚集索引那么大了,非聚集索引的叶子节点是不存放数据的

非聚集索引的缺点

跟聚集索引一样,非聚集索引也依赖于有序的数据

可能会二次查询(回表) :这应该是非聚集索引最大的缺点了。 当查到索引对应的指针或主键后,可能还需要根据指针或主键再到数据文件或表中查询。

这是Mysql的表的文件截图:

img

聚集索引和非聚集索引:

img

非聚集索引一定回表查询吗(覆盖索引)?

非聚集索引不一定回表查询。

试想一种情况,用户准备使用SQL查询用户名,而用户名字段正好建立了索引。

SELECT name FROM table WHERE username=’guang19’;

那么这个索引的key本身就是name,查到对应的name直接返回就行了,无需回表查询。

即使是MYISAM也是这样,虽然MYISAM的主键索引确实需要回表,因为它的主键索引的叶子节点存放的是指针。但是如果SQL查的就是主键呢?

SELECT id FROM table WHERE id=1;

主键索引本身的key就是主键,查到返回就行了。这种情况就称之为覆盖索引了。

覆盖索引

如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为“覆盖索引”。我们知道在InnoDB存储引擎中,如果不是主键索引,叶子节点存储的是主键+列值。最终还是要“回表”,也就是要通过主键再查找一次。这样就会比较慢覆盖索引就是把要查询出的列和索引是对应的,不做回表操作!

覆盖索引即需要查询的字段正好是索引的字段,那么直接根据该索引,就可以查到数据了,而无需回表查询。

如主键索引,如果一条SQL需要查询主键,那么正好根据主键索引就可以查到主键。

再如普通索引,如果一条SQL需要查询name,name字段正好有索引,那么直接根据这个索引就可以查到数据,也无需回表。

覆盖索引:

img

索引创建原则
  • 单列索引

单列索引即由一列属性组成的索引。

联合索引(多列索引)

联合索引即由多列属性组成索引。

  • 最左前缀原则

假设创建的联合索引由三个字段组成:

ALTER TABLE table ADD INDEX index_name (num,name,age)

那么当查询的条件有为:num / (num AND name) / (num AND name AND age)时,索引才生效。所以在创建联合索引时,尽量把查询最频繁的那个字段作为最左(第一个)字段。查询的时候也尽量以这个字段为第一条件。

但可能由于版本原因(我的mysql版本为8.0.x),我创建的联合索引,相当于在联合索引的每个字段上都创建了相同的索引:

img

无论是否符合最左前缀原则,每个字段的索引都生效:

img

索引创建注意点

  • 最左前缀原则

虽然我目前的Mysql版本较高,好像不遵守最左前缀原则,索引也会生效。但是我们仍应遵守最左前缀原则,以免版本更迭带来的麻烦。

  • 选择合适的字段

1.不为NULL的字段

索引字段的数据应该尽量不为NULL,因为对于数据为NULL的字段,数据库较难优化。如果字段频繁被查询,但又避免不了为NULL,建议使用0,1,true,false这样语义较为清晰的短值或短字符作为替代。

2.被频繁查询的字段

我们创建索引的字段应该是查询操作非常频繁的字段。

3.被作为条件查询的字段

被作为WHERE条件查询的字段,应该被考虑建立索引。

4.被经常频繁用于连接的字段

经常用于连接的字段可能是一些外键列,对于外键列并不一定要建立外键,只是说该列涉及到表与表的关系。对于频繁被连接查询的字段,可以考虑建立索引,提高多表连接查询的效率。

  • 不合适创建索引的字段

1.被频繁更新的字段应该慎重建立索引

虽然索引能带来查询上的效率,但是维护索引的成本也是不小的。如果一个字段不被经常查询,反而被经常修改,那么就更不应该在这种字段上建立索引了。

2.不被经常查询的字段没有必要建立索引

3.尽可能的考虑建立联合索引而不是单列索引

因为索引是需要占用磁盘空间的,可以简单理解为每个索引都对应着一颗B+树。如果一个表的字段过多,索引过多,那么当这个表的数据达到一个体量后,索引占用的空间也是很多的,且修改索引时,耗费的时间也是较多的。如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。

4.注意避免冗余索引

冗余索引指的是索引的功能相同,能够命中 就肯定能命中 ,那么 就是冗余索引如(name,city )和(name )这两个索引就是冗余索引,能够命中后者的查询肯定是能够命中前者的 在大多数情况下,都应该尽量扩展已有的索引而不是创建新索引。

5.考虑在字符串类型的字段上使用前缀索引代替普通索引

前缀索引仅限于字符串类型,较普通索引会占用更小的空间,所以可以考虑使用前缀索引带替普通索引。

使用索引一定能提高查询性能吗?

大多数情况下,索引查询都是比全表扫描要快的。但是如果数据库的数据量不大,那么使用索引也不一定能够带来很大提升。

二、事务:事务、数据库事务、ACID、并发事务、事务隔离级别

事务具有四大特征,分别是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),简称为事务的ACID特性。

原子性(Atomicity)

原子性是指事务必须是一个原子的操作序列单元。事务中包含的各项操作在一次执行过程中,只允许出现以下两种状态之一。

  • 全部成功执行
  • 全部不执行

任何一项操作失败都将导致整个事务失败,同时其他已经被执行的操作都将被撤消并回滚,只有所有的操作全部成功,整个事务才算是成功完成。

一致性(Consistency)

一致性是指事务的执行不能破坏数据库数据的完整性和一致性,一个事务在执行之前和执行之后,数据库都必须处于一致性状态。也就是说事务的执行结果必须是使数据库从一个一致性状态转变到另一个一致性状态。因此当事务全部成功提交时,就能说数据库处于一致性状态,如果数据库运行过程中出现故障,导致有些事务尚未完成就被迫中断,这些未完成的事务中的部分操作已经写入的物理数据库,这时数据库就处于一种不一致的状态。

隔离性(Isolation)

隔离性是指在并发环境中,并发的事务是相互隔离的,一个事务的执行不能被其它事务干扰。也就是说,不同的事务并发操作相同的数据时,每个事务都有各自完整的数据空间,即一个事务内部的操作及使用的数据对其它并发事务是隔离的,并发执行的各个事务之间不能互相干扰。

在标准的SQL规范中,定义了四个事务隔离级别,不同的隔离级别对事务的处理不同

  • 未授权读取
    也称读未提交(Read Uncommitted),该隔离级别允许脏读,隔离级别最低。

什么是脏读?

如果一个事务正在处理某一数据,对其进行了更新,但尚未提交事务,与此同时,另一个事务能够访问该事务更新后的数据。也就是说,事务的中间状态对其它事务是可见的。

举例说明:
用户A原工资为1000元
第一步:事务1执行修改操作,为用户A增加工资1000元,事务1尚未提交事务
第二步:事务2执行读取操作,查询到用户A的工资为2000元。

事务2读取到了事务1中尚未提交的修改结果,但是事务1尚未提交,有可能会因为后续操作失败而产生回滚。

  • 授权读取
    也称读已提交(Read Committed),该隔离级别禁止脏读,允许不可重复读。

什么是不可重复读?

一个事务过程中,对同一数据进行多次查询,查询的数据可能不一样,原因可能是两次查询过程中,另一个事务对该数据进行了修改并成功提交事务。也就是说,事务的结束状态对其它事务是可见的。

举例说明:
用户A原工资为1000元
第一步:事务1执行查询操作,查询到用户A的工资为1000元,事务1尚未提交事务。
第二步:事务2执行修改操作,为用户A的增加工资1000元。
第三步:事务1执行查询操作,查询到用户A的工资为2000元。

对于事务1来说,同一个事务里的多次查询,结果并不稳定。

  • 可重复读取
    可重复读取(Repeatable Read),该隔离级别禁止不可重复读和脏读,允许幻读。保证在事务处理过程中,多次读取同一数据,其值都和事务开始时刻是一致的。

什么是幻读?

不可重复读针对的是数据的修改,而幻读针对的是数据的新增。

举例说明:
用户表,用户名为唯一键
第一步:事务1执行查询操作,查询是否存在用户名为aaa的数据,事务1尚未提交

1
select 1 from user where username = 'aaa'

第二步:事务2执行插入操作,插入用户名为aaa的数据
第三步:事务1执行插入操作,插入用户为为aaa的数据,提示唯一键错误,插入失败。

对于事务一来说,查询用户aaa不存在,保存却报唯一键错误,如梦如幻,故名幻读。

  • 串行化
    串行化(Serializable)是最严格的事务隔离级别,它要求所有事务都被串行执行,即事务只能一个一个地进行处理,不能并发执行。

mysql 默认使用 Repeatable Read 级别,其它数据库大部分默认使用 Read Committed 级别

隔离级别 脏读 不可重复读 幻读
未授权读取 存在 存在 存在
授权读取 不存在 存在 存在
可重复读取 不存在 不存在 存在
串行化 不存在 不存在 不存在

持久性(Durability)

持久性是指一个事务一旦提交,它对数据库中对应数据的状态变更就应该是永久性的。也就是说,它对数据库所做的更新就必须被永久保存一下,即使发生系统崩溃或机器宕机等故障,只要数据库能够重新启动,那么一定能够将其恢复到事务成功结束时的状态。

三、存储引擎(MyISAM 和 InnoDB)

1、MySQL默认存储引擎的变迁

在MySQL 5.1之前的版本中,默认的搜索引擎是MyISAM,从MySQL 5.5之后的版本中,默认的搜索引擎变更为InnoDB。

2、MyISAM与InnoDB存储引擎的主要特点

MyISAM存储引擎的特点是:表级锁、不支持事务和全文索引,适合一些CMS内容管理系统作为后台数据库使用,但是使用大并发、重负荷生产系统上,表锁结构的特性就显得力不从心;

以下是MySQL 5.7 MyISAM存储引擎的版本特性:

img

InnoDB存储引擎的特点是:行级锁、事务安全(ACID兼容)、支持外键、不支持FULLTEXT类型的索引(5.6.4以后版本开始支持FULLTEXT类型的索引)。InnoDB存储引擎提供了具有提交、回滚和崩溃恢复能力的事务安全存储引擎。InnoDB是为处理巨大量时拥有最大性能而设计的。它的CPU效率可能是任何其他基于磁盘的关系数据库引擎所不能匹敌的。

以下是MySQL 5.7 InnoDB存储引擎的版本特性:

img

注意:

InnoDB表的行锁也不是绝对的,假如在执行一个SQL语句时MySQL不能确定要扫描的范围,InnoDB表同样会锁全表,例如update table set num=1 where name like “a%”

两种类型最主要的差别就是InnoDB支持事务处理与外键和行级锁。而MyISAM不支持。所以MyISAM往往就容易被人认为只适合在小项目中使用。

3、MyISAM与InnoDB性能测试

下边两张图是官方提供的MyISAM与InnoDB的压力测试结果

img

img

可以看出,随着CPU核数的增加,InnoDB的吞吐量反而越好,而MyISAM,其吞吐量几乎没有什么变化,显然,MyISAM的表锁定机制降低了读和写的吞吐量。

4、事务支持与否

MyISAM是一种非事务性的引擎,使得MyISAM引擎的MySQL可以提供高速存储和检索,以及全文搜索能力,适合数据仓库等查询频繁的应用;

InnoDB是事务安全的;

事务是一种高级的处理方式,如在一些列增删改中只要哪个出错还可以回滚还原,而MyISAM就不可以了。

5、MyISAM与InnoDB构成上的区别

(1)每个MyISAM在磁盘上存储成三个文件:

第一个文件的名字以表的名字开始,扩展名指出文件类型,.frm文件存储表定义。
第二个文件是数据文件,其扩展名为.MYD (MYData)。
第三个文件是索引文件,其扩展名是.MYI (MYIndex)。

(2)基于磁盘的资源是InnoDB表空间数据文件和它的日志文件,InnoDB 表的 大小只受限于操作系统文件的大小,一般为 2GB。

6、MyISAM与InnoDB表锁和行锁的解释

MySQL表级锁有两种模式:表共享读锁(Table Read Lock)和表独占写锁(Table Write Lock)。什么意思呢,就是说对MyISAM表进行读操作时,它不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写操作;而对MyISAM表的写操作,则会阻塞其他用户对同一表的读和写操作。

InnoDB行锁是通过给索引项加锁来实现的,即只有通过索引条件检索数据,InnoDB才使用行级锁,否则将使用表锁!行级锁在每次获取锁和释放锁的操作需要消耗比表锁更多的资源。在InnoDB两个事务发生死锁的时候,会计算出每个事务影响的行数,然后回滚行数少的那个事务。当锁定的场景中不涉及Innodb的时候,InnoDB是检测不到的。只能依靠锁定超时来解决。

7、是否保存数据库表中表的具体行数

InnoDB 中不保存表的具体行数,也就是说,执行select count(*) from table时,InnoDB要扫描一遍整个表来计算有多少行,但是MyISAM只要简单的读出保存好的行数即可。

注意的是,当count(*)语句包含where条件时,两种表的操作是一样的。也就是 上述“6”中介绍到的InnoDB使用表锁的一种情况。

8、如何选择

MyISAM适合:

  • (1)做很多count 的计算;
  • (2)插入不频繁,查询非常频繁,如果执行大量的SELECT,MyISAM是更好的选择;
  • (3)没有事务。

InnoDB适合:

  • (1)可靠性要求比较高,或者要求事务;
  • (2)表更新和查询都相当的频繁,并且表锁定的机会比较大的情况指定数据引擎的创建;
  • (3)如果你的数据执行大量的INSERT或UPDATE,出于性能方面的考虑,应该使用InnoDB表;
  • (4)DELETE FROM table时,InnoDB不会重新建立表,而是一行一行的 删除;
  • (5)LOAD TABLE FROM MASTER操作对InnoDB是不起作用的,解决方法是首先把InnoDB表改成MyISAM表,导入数据后再改成InnoDB表,但是对于使用的额外的InnoDB特性(例如外键)的表不适用。

要注意,创建每个表格的代码是相同的,除了最后的 TYPE参数,这一参数用来指定数据引擎。

其他区别:

1、对于AUTO_INCREMENT类型的字段,InnoDB中必须包含只有该字段的索引,但是在MyISAM表中,可以和其他字段一起建立联合索引。

2、DELETE FROM table时,InnoDB不会重新建立表,而是一行一行的删除。

3、LOAD TABLE FROMMASTER操作对InnoDB是不起作用的,解决方法是首先把InnoDB表改成MyISAM表,导入数据后再改成InnoDB表,但是对于使用的额外的InnoDB特性(例如外键)的表不适用。

4、 InnoDB存储引擎被完全与MySQL服务器整合,InnoDB存储引擎为在主内存中缓存数据和索引而维持它自己的缓冲池。

5、对于自增长的字段,InnoDB中必须包含只有该字段的索引,但是在MyISAM表中可以和其他字段一起建立联合索引。

6、清空整个表时,InnoDB是一行一行的删除,效率非常慢。MyISAM则会重建表。

四、锁机制与 InnoDB 锁算法

1、 锁分类(按粒度分)

解决并发、数据安全的问题,用锁。

(1)表级锁

粒度最大,对整表加锁,实现简单 ,资源消耗也比较少,加锁快,不会出现死锁 。其锁定粒度最大,触发锁冲突的概率最高,并发度最低,MyISAM和 InnoDB引擎都支持表级锁。

(2)行级锁

粒度最小减少数据库冲突。加锁粒度最小,并发度高,加锁开销最大,加锁慢,会出现死锁。 InnoDB支持的行级锁:

1)行锁 Record Lock:索引加锁,锁定符合条件的行。其他事务不能修改和删除加锁项;

2)间隙锁 Gap Lock:索引项之间的“间隙”加锁,锁定记录的范围(对第一条记录前的间隙或最后一条将记录后的间隙加锁),不包含索引项本身。其他事务不能在锁范围内插入数据,这样就防止了别的事务新增幻影行。

3)行锁和间隙锁组合 Next-key Lock: 锁定索引项本身索引范围。解决幻读问题。

虽行级索粒度小、并发度高等特点,但表级锁有时候非常必要

事务更新大表中的大部分数据直接使用表级锁效率更高;用行级索很可能引起死锁导致回滚。

2、另外两个表级锁:IS和IX

当一个事务需要给自己需要的某个资源加锁的时候,如果遇到一个共享锁正锁定着自己需要的资源的时候,自己可以再加一个共享锁,不过不能加排他锁。但是,如果遇到自己需要锁定的资源已经被一个排他锁占有之后,则只能等待该锁定释放资源之后自己才能获取锁定资源并添加自己的锁定。而意向锁的作用就是当一个事务在需要获取资源锁定的时候,如果遇到自己需要的资源已经被排他锁占用的时候,该事务可以需要锁定行的表上面添加一个合适的意向锁。如果自己需要一个共享锁,那么就在表上面添加一个意向共享锁。而如果自己需要的是某行(或者某些行)上面添加一个排他锁的话,则先在表上面添加一个意向排他锁。意向共享锁可以同时并存多个,但是意向排他锁同时只能有一个存在。

InnoDB另外的两个表级锁:

意向共享锁(IS): 事务准备给数据行记入共享锁,事务在一个数据行加共享锁前必须先取得该表的IS锁。

意向排他锁(IX): 事务准备给数据行加入排他锁,事务在一个数据行加排他锁前必须先取得该表的IX锁。

注意:

这里的意向锁是表级锁,表示的是一种意向,仅仅表示事务正在读或写某一行记录,在真正加行锁时才会判断是否冲突。意向锁是InnoDB自动加的,不需要用户干预。

IX,IS是表级锁,不会和行级的X,S锁发生冲突,只会和表级的X,S发生冲突。

InnoDB的锁机制兼容情况如下:

img

3、死锁和避免死锁

InnoDB的行级锁是基于索引实现的,如果查询语句为命中任何索引,用表级锁. 对索引加的锁,不针对数据记录,访问不同行记录,如用相同索引键仍锁冲突

SELECT…LOCKINSHARE MODE 或 SELECT…FORUPDATE;

用锁时,如没有定义索引InnoDB****创建隐藏聚簇索引加记录锁

InnoDB锁逐步获得,两个事务都要获得对方持有的锁,都等待,产生死锁。检测到,并使一个事务释放锁回退,另一个获取锁完成事务,避免死锁:

(1)表级锁来减少死锁

(2)多个程序尽量相同顺序访问表(解决并发理论中哲学家就餐问题一种思路)

(3)同一个事务尽可能一次锁定所有资源。

4、总结与补充

页级锁: 介于行级锁和表级锁中间。表级锁速度快,但冲突多,行级冲突少,但速度慢。页级折衷,一次锁定相邻一组记录。BDB支持页级锁。开销和加锁时间界于表锁和行锁之间,会出现死锁。并发度一般。

Redis

下面是我总结的一些关于并发的小问题,你可以拿来自测:

一、Redis 和 Memcached 的区别和共同点

1.背景介绍

在大多数Web应用都将数据保存到关系型数据库中,WWW服务器从中读取数据并在浏览器中显示。但随着数据量的增大、访问的集中,就会出现关系型数据的负担加重、数据库响应缓慢、网站打开延迟等问题。

通过在内存中缓存数据库的查询结果,减少数据访问次数,以提高动态Web应用的速度,提高网站架构的并发能力和可扩展性

传统开发中用的数据库最多的就是MySQL了,随着数据量上千万或上亿级后,它的关系型数据库的读取速度可能并不能满足我们对数据的需求,所以内存式的缓存系统就出现了

2.知识剖析

Memcache 是一个高性能的分布式内存对象缓存系统,用于动态Web应用以减轻数据库负载。它通过在内存中缓存数据和对象来减少读取数据库的次数,从而提高动态、数据库驱动网站的速度。

Memcache基于一个存储键/值对的hashmap。其守护进程(daemon )是用C写的,但是客户端可以用任何语言来编写,并通过memcache协议与守护进程通信。

Redis有着更为复杂的数据结构并且提供对他们的原子性操作,这是一个不同于其他数据库的进化路径。Redis的数据类型都是基于基本数据结构的同时对程序员透明,无需进行额外的抽象。

Redis运行在内存中但是可以持久化到磁盘,所以在对不同数据集进行高速读写时需要权衡内存,应用数据量不能大于硬件内存。

在内存数据库方面的另一个优点是, 相比在磁盘上相同的复杂的数据结构,在内存中操作起来非常简单,这样Redis可以做很多内部复杂性很强的事情。

同时,在磁盘格式方面他们是紧凑的以追加的方式产生的,因为他们并不需要进行随机访问。

3.常见问题

Redis与memcached有什么不同

什么是原子性,什么是原子性操作?

4.解决方案

Memcached

内部的数据存储,使用基于Slab的内存管理方式,有利于减少内存碎片和频繁分配销毁内存所带来的开销。各个Slab按需动态分配一个page的内存

(和4Kpage的概念不同,这里默认page为1M),page内部按照不同slab class的尺寸再划分为内存chunk供服务器存储KV键值对使用

Redis内部的数据结构最终也会落实到key-Value对应的形式,不过从暴露给用户的数据结构来看,

要比memcached丰富,除了标准的通常意义的键值对,Redis还支持List,Set, Hashes,Sorted Set等数据结构

基本命令

Memcached的命令或者说通讯协议非常简单,Server所支持的命令基本就是对特定key的添加,删除,替换,原子更新,读取等,具体包括 Set, Get, Add, Replace, Append, Inc/Dec 等等

Memcached的通讯协议包括文本格式和二进制格式,用于满足简单网络客户端工具(如telnet)和对性能要求更高的客户端的不同需求

Redis的命令在KV(String类型)上提供与Memcached类似的基本操作,在其它数据结构上也支持基本类似的操作(当然还有这些数据结构所特有的操作,如Set的union,List的pop等)而支持更多的数据结构,在一定程度上也就意味着更加广泛的应用场合

除了多种数据结构的支持,

Redis相比Memcached还提供了许多额外的特性,比如Subscribe/publish命令,以支持发布/订阅模式这样的通知机制等等,这些额外的特性同样有助于拓展它的应用场景Redis的客户端-服务器通讯协议完全采用文本格式(在将来可能的服务器间通讯会采用二进制格式)

分布式实现:

(1)memcached的分布式由客户端实现,通过一致性哈希算法来保证访问的缓存命中率;Redis的分布式由服务器端实现,通过服务端配置来实现分布式;

(2)事务性,memcached没有事务的概念,但是可以通过CAS协议来保证数据的完整性,一致性。Redis引入数据库中的事务概念来保证数据的完整性和一致性。

(3)简单性,memcached是纯KV缓存,协议简单,学习和使用成本比redis小很多

Memcached也不做数据的持久化工作,但是有许多基于memcached协议的项目实现了数据的持久化,例如memcacheDB使用BerkeleyDB进行数据存储,但本质上它已经不是一个Cache Server,而只是一个兼容Memcached的协议key-valueData Store了

Redis可以以master-slave的方式配置服务器,Slave节点对数据进行replica备份,Slave节点也可以充当Read only的节点分担数据读取的工作

Redis内建支持两种持久化方案,snapshot快照和AOF 增量Log方式。快照顾名思义就是隔一段时间将完整的数据Dump下来存储在文件中。AOF增量Log则是记录对数据的修改操作(实际上记录的就是每个对数据产生修改的命令本身).

二、为什么要用 Redis/为什么要用缓存?

简单,来说使用缓存主要是为了提升用户体验以及应对更多的用户。

下面我们主要从“高性能”和“高并发”这两点来看待这个问题。

img

高性能 :
对照上面
假如用户第一次访问数据库中的某些数据的话,这个过程是比较慢,毕竟是从硬盘中读取的。但是,如果说,用户访问的数据属于高频数据并且不会经常改变的话,那么我们就可以很放心地将该用户访问的数据存在缓存中。
这样有什么好处呢? 那就是保证用户下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快。
不过,要保持数据库和缓存中的数据的一致性。 如果数据库中的对应数据改变的之后,同步改变缓存中相应的数据即可!

高并发:
一般像 MySQL 这类的数据库的 QPS 大概都在 1w 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 10w+,甚至最高能达到 30w+(就单机 redis 的情况,redis 集群的话会更高)。
QPS(Query Per Second):服务器每秒可以执行的查询次数;
所以,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高的系统整体的并发

三、Redis 常见数据结构以及使用场景分析

  • string
    1、介绍 :string 数据结构是简单的 key-value 类型。虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(simple dynamic string,SDS)。相比于 C 的原生字符串,Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N)),除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出。
    2、常用命令: set,get,strlen,exists,dect,incr,setex 等等。
    3、应用场景 :一般常用在需要计数的场景,比如用户的访问次数、热点文章的点赞转发数量等等。
    下面我们简单看看它的使用!

普通字符串的基本操作:

1
2
3
4
5
6
7
8
9
10
11
12
127.0.0.1:6379> set key value #设置 key-value 类型的值
OK
127.0.0.1:6379> get key # 根据 key 获得对应的 value
"value"
127.0.0.1:6379> exists key # 判断某个 key 是否存在
(integer) 1
127.0.0.1:6379> strlen key # 返回 key 所储存的字符串值的长度。
(integer) 5
127.0.0.1:6379> del key # 删除某个 key 对应的值
(integer) 1
127.0.0.1:6379> get key
(nil)

批量设置 :

1
2
3
4
5
127.0.0.1:6379> mset key1 value1 key2 value2 # 批量设置 key-value 类型的值
OK
127.0.0.1:6379> mget key1 key2 # 批量获取多个 key 对应的 value
1) "value1"
2) "value2"Copy to clipboardErrorCopied

计数器(字符串的内容为整数的时候可以使用):

1
2
3
4
5
6
7
8
9
10
127.0.0.1:6379> set number 1
OK
127.0.0.1:6379> incr number # 将 key 中储存的数字值增一
(integer) 2
127.0.0.1:6379> get number
"2"
127.0.0.1:6379> decr number # 将 key 中储存的数字值减一
(integer) 1
127.0.0.1:6379> get number
"1"Copy to clipboardErrorCopied

过期:

1
2
3
4
5
6
127.0.0.1:6379> expire key  60 # 数据在 60s 后过期
(integer) 1
127.0.0.1:6379> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
OK
127.0.0.1:6379> ttl key # 查看数据还有多久过期
(integer) 56
  • list
    1.介绍 :list 即是 链表。链表是一种非常常见的数据结构,特点是易于数据元素的插入和删除并且且可以灵活调整链表长度,但是链表的随机访问困难。许多高级编程语言都内置了链表的实现比如 Java 中的 LinkedList,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。Redis 的 list 的实现为一个 双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。
    2.常用命令: rpush,lpop,lpush,rpop,lrange、llen 等。
    3.应用场景: 发布与订阅或者说消息队列、慢查询。
    下面我们简单看看它的使用

通过 rpush/lpop 实现队列:

1
2
3
4
5
6
7
8
9
10
11
12
127.0.0.1:6379> rpush myList value1 # 向 list 的头部(右边)添加元素
(integer) 1
127.0.0.1:6379> rpush myList value2 value3 # 向list的头部(最右边)添加多个元素
(integer) 3
127.0.0.1:6379> lpop myList # 将 list的尾部(最左边)元素取出
"value1"
127.0.0.1:6379> lrange myList 0 1 # 查看对应下标的list列表, 0 为 start,1为 end
1) "value2"
2) "value3"
127.0.0.1:6379> lrange myList 0 -1 # 查看列表中的所有元素,-1表示倒数第一
1) "value2"
2) "value3"Copy to clipboardErrorCopied

通过 rpush/rpop 实现栈:

1
2
3
4
127.0.0.1:6379> rpush myList2 value1 value2 value3
(integer) 3
127.0.0.1:6379> rpop myList2 # 将 list的头部(最右边)元素取出
"value3"Copy to clipboardErrorCopied

我专门花了一个图方便小伙伴们来理解:

img

通过 lrange 查看对应下标范围的列表元素:

1
2
3
4
5
6
7
8
9
127.0.0.1:6379> rpush myList value1 value2 value3
(integer) 3
127.0.0.1:6379> lrange myList 0 1 # 查看对应下标的list列表, 0 为 start,1为 end
1) "value1"
2) "value2"
127.0.0.1:6379> lrange myList 0 -1 # 查看列表中的所有元素,-1表示倒数第一
1) "value1"
2) "value2"
3) "value3"Copy to clipboardErrorCopied

通过 lrange 命令,你可以基于 list 实现分页查询,性能非常高!

通过 llen 查看链表长度:

1
2
127.0.0.1:6379> llen myList
(integer) 3
  • hash
  1. 介绍 :hash 类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表)。不过,Redis 的 hash 做了更多优化。另外,hash 是一个 string 类型的 field 和 value 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接仅仅修改这个对象中的某个字段的值。 比如我们可以 hash 数据结构来存储用户信息,商品信息等等。
  2. 常用命令: hset,hmset,hexists,hget,hgetall,hkeys,hvals 等。
  3. 应用场景: 系统中对象数据的存储。
    下面我们简单看看它的使用!
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
127.0.0.1:6379> hset userInfoKey name "guide" description "dev" age "24"
OK
127.0.0.1:6379> hexists userInfoKey name # 查看 key 对应的 value中指定的字段是否存在。
(integer) 1
127.0.0.1:6379> hget userInfoKey name # 获取存储在哈希表中指定字段的值。
"guide"
127.0.0.1:6379> hget userInfoKey age
"24"
127.0.0.1:6379> hgetall userInfoKey # 获取在哈希表中指定 key 的所有字段和值
1) "name"
2) "guide"
3) "description"
4) "dev"
5) "age"
6) "24"
127.0.0.1:6379> hkeys userInfoKey # 获取 key 列表
1) "name"
2) "description"
3) "age"
127.0.0.1:6379> hvals userInfoKey # 获取 value 列表
1) "guide"
2) "dev"
3) "24"
127.0.0.1:6379> hset userInfoKey name "GuideGeGe" # 修改某个字段对应的值
127.0.0.1:6379> hget userInfoKey name
"GuideGeGe"
  • Set
    1.介绍 : set 类似于 Java 中的 HashSet 。Redis 中的 set 类型是一种无序集合,集合中的元素没有先后顺序。当你需要存储一个列表数据,又不希望出现重复数据时,set 是一个很好的选择,并且 set 提供了判断某个成员是否在一个 set 集合内的重要接口,这个也是 list 所不能提供的。可以基于 set 轻易实现交集、并集、差集的操作。比如:你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。
    2.常用命令: sadd,spop,smembers,sismember,scard,sinterstore,sunion 等。
    3.应用场景: 需要存放的数据不能重复以及需要获取多个数据源交集和并集等场景
    下面我们简单看看它的使用!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
127.0.0.1:6379> sadd mySet value1 value2 # 添加元素进去
(integer) 2
127.0.0.1:6379> sadd mySet value1 # 不允许有重复元素
(integer) 0
127.0.0.1:6379> smembers mySet # 查看 set 中所有的元素
1) "value1"
2) "value2"
127.0.0.1:6379> scard mySet # 查看 set 的长度
(integer) 2
127.0.0.1:6379> sismember mySet value1 # 检查某个元素是否存在set 中,只能接收单个元素
(integer) 1
127.0.0.1:6379> sadd mySet2 value2 value3
(integer) 2
127.0.0.1:6379> sinterstore mySet3 mySet mySet2 # 获取 mySet 和 mySet2 的交集并存放在 mySet3 中
(integer) 1
127.0.0.1:6379> smembers mySet3
1) "value2"
  • sorted set
    1.介绍: 和 set 相比,sorted set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap 和 TreeSet 的结合体。
    2.常用命令: zadd,zcard,zscore,zrange,zrevrange,zrem 等。
    3.应用场景: 需要对数据根据某个权重进行排序的场景。比如在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息。
    下面我们简单看看它的使用!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
127.0.0.1:6379> zadd myZset 3.0 value1 # 添加元素到 sorted set 中 3.0 为权重
(integer) 1
127.0.0.1:6379> zadd myZset 2.0 value2 1.0 value3 # 一次添加多个元素
(integer) 2
127.0.0.1:6379> zcard myZset # 查看 sorted set 中的元素数量
(integer) 3
127.0.0.1:6379> zscore myZset value1 # 查看某个 value 的权重
"3"
127.0.0.1:6379> zrange myZset 0 -1 # 顺序输出某个范围区间的元素,0 -1 表示输出所有元素
1) "value3"
2) "value2"
3) "value1"
127.0.0.1:6379> zrange myZset 0 1 # 顺序输出某个范围区间的元素,0 为 start 1 为 stop
1) "value3"
2) "value2"
127.0.0.1:6379> zrevrange myZset 0 1 # 逆序输出某个范围区间的元素,0 为 start 1 为 stop
1) "value1"
2) "value2"

四、Redis 没有使用多线程?为什么不使用多线程?Redis6.0 之后为何引入了多线程?

Redis 6.0 新特性,多线程连环 13 问!

Redis 6.0 来了

在全国一片祥和IT民工欢度五一节假日的时候,Redis 6.0不声不响地于5 月 2 日正式发布了,吓得我赶紧从床上爬起来,学无止境!学无止境!

对于6.0版本,Redis之父Antirez在RC1版本发布时(2019-12-19)在他的博客上连续用了几个“EST”词语来评价:

the most “enterprise” Redis version to date // 最”企业级”的

the largest release of Redis ever as far as I can tell // 最大的

the one where the biggest amount of people participated // 参与人数最多的

这个版本提供了诸多令人心动的新特性及功能改进,比如新网络协议RESP3,新的集群代理,ACL等,其中关注度最高的应该是“多线程”了,笔者也第一时间体验了一下,带着众多疑问,我们来一起开始“Redis 6.0 新特性-多线程连环13问”。

Redis 6.0 多线程连环13问

1.Redis6.0之前的版本真的是单线程吗?

Redis在处理客户端的请求时,包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。

但如果严格来讲从Redis4.0之后并不是单线程,除了主线程外,它也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大 key 的删除等等。

2.Redis6.0之前为什么一直不使用多线程?

官方曾做过类似问题的回复:使用Redis时,几乎不存在CPU成为瓶颈的情况, Redis主要受限于内存和网络。例如在一个普通的Linux系统上,Redis通过使用pipelining每秒可以处理100万个请求,所以如果应用程序主要使用O(N)或O(log(N))的命令,它几乎不会占用太多CPU。

使用了单线程后,可维护性高。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。

Redis通过AE事件模型以及IO多路复用等技术,处理性能非常高,因此没有必要使用多线程。单线程机制使得 Redis 内部实现的复杂度大大降低,Hash 的惰性 Rehash、Lpush 等等 “线程不安全” 的命令都可以无锁进行。

3.Redis6.0为什么要引入多线程呢?

Redis将所有数据放在内存中,内存的响应时长大约为100纳秒,对于小数据包,Redis服务器可以处理80,000到100,000 QPS,这也是Redis处理的极限了,对于80%的公司来说,单线程的Redis已经足够使用了。

但随着越来越复杂的业务场景,有些公司动不动就上亿的交易量,因此需要更大的QPS。常见的解决方案是在分布式架构中对数据进行分区并采用多个服务器,但该方案有非常大的缺点,例如要管理的Redis服务器太多,维护代价大;某些适用于单个Redis服务器的命令不适用于数据分区;数据分区无法解决热点读/写问题;数据偏斜,重新分配和放大/缩小变得更加复杂等等。

从Redis自身角度来说,因为读写网络的read/write系统调用占用了Redis执行期间大部分CPU时间,瓶颈主要在于网络的 IO 消耗, 优化主要有两个方向:

• 提高网络 IO 性能,典型的实现比如使用 DPDK 来替代内核网络栈的方式

• 使用多线程充分利用多核,典型的实现比如 Memcached。

协议栈优化的这种方式跟 Redis 关系不大,支持多线程是一种最有效最便捷的操作方式。所以总结起来,redis支持多线程主要就是两个原因:

• 可以充分利用服务器 CPU 资源,目前主线程只能利用一个核

• 多线程任务可以分摊 Redis 同步 IO 读写负荷

4.Redis6.0默认是否开启了多线程?

Redis6.0的多线程默认是禁用的,只使用主线程。如需开启需要修改redis.conf配置文件:io-threads-do-reads yes

5.Redis6.0多线程开启时,线程数如何设置?

开启多线程后,还需要设置线程数,否则是不生效的。同样修改redis.conf配置文件

关于线程数的设置,官方有一个建议:4核的机器建议设置为2或3个线程,8核的建议设置为6个线程,线程数一定要小于机器核数。还需要注意的是,线程数并不是越大越好,官方认为超过了8个基本就没什么意义了。

6.Redis6.0采用多线程后,性能的提升效果如何?

Redis 作者 antirez 在 RedisConf 2019分享时曾提到:Redis 6 引入的多线程 IO 特性对性能提升至少是一倍以上。国内也有大牛曾使用unstable版本在阿里云esc进行过测试,GET/SET 命令在4线程 IO时性能相比单线程是几乎是翻倍了。

测试环境:

Redis Server: 阿里云 Ubuntu 18.04,8 CPU 2.5 GHZ, 8G 内存,主机型号 ecs.ic5.2xlarge

Redis Benchmark Client: 阿里云 Ubuntu 18.04,8 2.5 GHZ CPU, 8G 内存,主机型号 ecs.ic5.2xlarge

测试结果:

说明1:这些性能验证的测试并没有针对严谨的延时控制和不同并发的场景进行压测。数据仅供验证参考而不能作为线上指标。

说明2:如果开启多线程,至少要4核的机器,且Redis实例已经占用相当大的CPU耗时的时候才建议采用,否则使用多线程没有意义。所以估计80%的公司开发人员看看就好。


7.Redis6.0多线程的实现机制?


流程简述如下:

1、主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列

2、主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程

3、主线程阻塞等待 IO 线程读取 socket 完毕

4、主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行

5、主线程阻塞等待 IO 线程将数据回写 socket 完毕

6、解除绑定,清空等待队列

该设计有如下特点:

1、IO 线程要么同时在读 socket,要么同时在写,不会同时读或写

2、IO 线程只负责读写 socket 解析命令,不负责命令处理

8.开启多线程后,是否会存在线程并发安全问题?

从上面的实现机制可以看出,Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。所以我们不需要去考虑控制 key、lua、事务,LPUSH/LPOP 等等的并发及线程安全问题。

9.Linux环境上如何安装Redis6.0.1(6.0的正式版是6.0.1)?

这个和安装其他版本的redis没有任何区别,整个流程跑下来也没有任何的坑,所以这里就不做描述了。唯一要注意的就是配置多线程数一定要小于cpu的核心数,查看核心数量命令:

[root@centos7.5 ~]# lscpuArchitecture: x86_64CPU op-mode(s): 32-bit, 64-bitByte Order: Little EndianCPU(s): 4On-line CPU(s) list: 0-3

10.Redis6.0的多线程和Memcached多线程模型进行对比

前些年memcached 是各大互联网公司常用的缓存方案,因此redis 和 memcached 的区别基本成了面试官缓存方面必问的面试题,最近几年memcached用的少了,基本都是 redis。

不过随着Redis6.0加入了多线程特性,类似的问题可能还会出现,接下来我们只针对多线程模型来简单比较一下。

如上图所示:Memcached 服务器采用 master-woker 模式进行工作,服务端采用 socket 与客户端通讯。主线程、工作线程 采用 pipe管道进行通讯。主线程采用 libevent 监听 listen、accept 的读事件,事件响应后将连接信息的数据结构封装起来,根据算法选择合适的工作线程,将连接任务携带连接信息分发出去,相应的线程利用连接描述符建立与客户端的socket连接 并进行后续的存取数据操作。

Redis6.0与Memcached多线程模型对比:

相同点:都采用了 master线程-worker 线程的模型

不同点:Memcached 执行主逻辑也是在 worker 线程里,模型更加简单,实现了真正的线程隔离,符合我们对线程隔离的常规理解。而 Redis 把处理逻辑交还给 master 线程,虽然一定程度上增加了模型复杂度,但也解决了线程并发安全等问题。

11.Redis作者是如何点评 “多线程”这个新特性的?

关于多线程这个特性,在6.0 RC1时,Antirez曾做过说明:

Redis支持多线程有2种可行的方式:第一种就是像“memcached”那样,一个Redis实例开启多个线程,从而提升GET/SET等简单命令中每秒可以执行的操作。这涉及到I/O、命令解析等多线程处理,因此,我们将其称之为“I/O threading”。另一种就是允许在不同的线程中执行较耗时较慢的命令,以确保其它客户端不被阻塞,我们将这种线程模型称为“Slow commands threading”。

经过深思熟虑,Redis不会采用“I/O threading”,redis在运行时主要受制于网络和内存,所以提升redis性能主要是通过在多个redis实例,特别是redis集群。接下来我们主要会考虑改进两个方面:

\1. Redis集群的多个实例通过编排能够合理地使用本地实例的磁盘,避免同时重写AOF。

2.提供一个Redis集群代理,便于用户在没有较好的集群协议客户端时抽象出一个集群。

补充说明一下,Redis和memcached一样是一个内存系统,但不同于Memcached。多线程是复杂的,必须考虑使用简单的数据模型,执行LPUSH的线程需要服务其他执行LPOP的线程。

我真正期望的实际是“slow operations threading”,在redis6或redis7中,将提供“key-level locking”,使得线程可以完全获得对键的控制以处理缓慢的操作。

12.Redis线程中经常提到IO多路复用,如何理解?

这是IO模型的一种,即经典的Reactor设计模式,有时也称为异步阻塞IO。

多路指的是多个socket连接,复用指的是复用一个线程。多路复用主要有三种技术:select,poll,epoll。epoll是最新的也是目前最好的多路复用技术。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),且Redis在内存中操作数据的速度非常快(内存内的操作不会成为这里的性能瓶颈),主要以上两点造就了Redis具有很高的吞吐量。

五、Redis 给缓存数据设置过期时间有啥用?

六、Redis 是如何判断数据是否过期的呢?

七、过期的数据的删除策略了解么?

定时删除

定时删除是指在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。

定时删除策略对内存是最友好的:通过使用定时器,定时删除策略可以保证过期键会尽可能快的被删除,并释放过期键所占用的内存。

定时删除策略的缺点是,他对CPU时间是最不友好的:再过期键比较多的情况下,删除过期键这一行为可能会占用相当一部分CPU时间。

除此之外,创建一个定时器需要用到Redis服务器中的时间事件。而当前时间事件的实现方式—-无序链表,查找一个事件的时间复杂度为O(N)—-并不能高效地处理大量时间事件。

惰性删除

惰性删除是指放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话就删除该键,如果没有过期就返回该键。

惰性删除策略对CPU时间来说是最友好的,但对内存是最不友好的。如果数据库中有非常多的过期键,而这些过期键又恰好没有被访问到的话,那么他们也许永远也不会被删除。

定期删除

定期删除是指每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。

定期删除策略是前两种策略的一种整合和折中:

  • 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响。
  • 除此之外,通过定期删除过期键,定期删除策略有效地减少了因为过期键带来的内存浪费。

定期删除策略的难点是确定删除操作执行的时长和频率:

  • 如果删除操作执行的太频繁或者执行的时间太长,定期删除策略就会退化成定时删除策略,以至于将CPU时间过多的消耗在删除过期键上面。
  • 如果删除操作执行的太少,或者执行的时间太短,定期删除策略又会和惰性删除策略一样,出现浪费内存的情况。

Redis的过期键删除策略

Redis服务器实际使用的是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,服务器可以很好的在合理使用CPU时间和避免浪费内存空间之间取得平衡。

定期删除策略的实现

过期键的定期删除策略由函数redis.c/activeExpireCycle实现,每当Redis服务器周期性操作redis.c/serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定的时间内分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键。

八、Redis 内存淘汰机制了解么?

内存淘汰策略

内存淘汰只是 Redis 提供的一个功能,为了更好地实现这个功能,必须为不同的应用场景提供不同的策略,内存淘汰策略讲的是为实现内存淘汰我们具体怎么做,要解决的问题包括淘汰键空间如何选择?在键空间中淘汰键如何选择?

Redis 提供了下面几种淘汰策略供用户选择,其中默认的策略为 noeviction 策略:

  • noeviction:当内存使用达到阈值的时候,所有引起申请内存的命令会报错
  • allkeys-lru:在主键空间中,优先移除最近未使用的key
  • volatile-lru:在设置了过期时间的键空间中,优先移除最近未使用的 key
  • allkeys-random:在主键空间中,随机移除某个 key
  • volatile-random:在设置了过期时间的键空间中,随机移除某个 key
  • volatile-ttl:在设置了过期时间的键空间中,具有更早过期时间的 key 优先移除

这里补充一下主键空间和设置了过期时间的键空间,举个例子,假设我们有一批键存储在Redis中,则有那么一个哈希表用于存储这批键及其值,如果这批键中有一部分设置了过期时间,那么这批键还会被存储到另外一个哈希表中,这个哈希表中的值对应的是键被设置的过期时间。设置了过期时间的键空间为主键空间的子集。

如何选择淘汰策略

我们了解了 Redis 大概提供了这么几种淘汰策略,那么如何选择呢?淘汰策略的选择可以通过下面的配置指定:

1
# maxmemory-policy noeviction

但是这个值填什么呢?为解决这个问题,我们需要了解我们的应用请求对于 Redis 中存储的数据集的访问方式以及我们的诉求是什么。同时 Redis 也支持 Runtime 修改淘汰策略,这使得我们不需要重启 Redis 实例而实时的调整内存淘汰策略。

下面看看几种策略的适用场景:

  • allkeys-lru:如果我们的应用对缓存的访问符合幂律分布(也就是存在相对热点数据),或者我们不太清楚我们应用的缓存访问分布状况,我们可以选择 allkeys-lru 策略
  • allkeys-random:如果我们的应用对于缓存 key 的访问概率相等,则可以使用这个策略
  • volatile-ttl:这种策略使得我们可以向 Redis 提示哪些 key 更适合被 eviction

另外,volatile-lru 策略和 volatile-random 策略适合我们将一个Redis实例既应用于缓存和又应用于持久化存储的时候,然而我们也可以通过使用两个 Redis 实例来达到相同的效果,值得一提的是将key设置过期时间实际上会消耗更多的内存,因此我们建议使用 allkeys-lru 策略从而更有效率的使用内存。

非精准的 LRU

上面提到的 LRU(Least Recently Used)策略,实际上 Redis 实现的 LRU 并不是可靠的 LRU,也就是名义上我们使用 LRU 算法淘汰键,但是实际上被淘汰的键并不一定是真正的最久没用的,这里涉及到一个权衡的问题,如果需要在全部键空间内搜索最优解,则必然会增加系统的开销,Redis 是单线程的,也就是同一个实例在每一个时刻只能服务于一个客户端,所以耗时的操作一定要谨慎。为了在一定成本内实现相对的 LRU,早期的 Redis 版本是基于采样的 LRU,也就是放弃全部键空间内搜索解改为采样空间搜索最优解。自从 Redis3.0 版本之后,Redis 作者对于基于采样的 LRU 进行了一些优化,目的是在一定的成本内让结果更靠近真实的 LRU。

九、Redis 持久化机制(怎么保证 Redis 挂掉之后再重启数据可以进行恢复)

Redis是常用的基于内存的缓存服务,能为我们缓存数据减少数据库访问从而提升性能,也能作为NoSQL数据库存储数据或借助有序队列做排队系统等。当仅作为数据缓存用时,Redis服务的可用性要求没那么高, 毕竟挂了还能从数据库获取, 但如果作为数据库或队列使用时,Redis挂了可能会影响到业务。本文整理了Redis的持久化方案,使用它们来对Redis的内存数据进行持久化,保障数据的安全性。

Redis支持RDB与AOF两种持久化机制,持久化可以避免因进程异常退出或down机导致的数据丢失问题,在下次重启时能利用之前的持久化文件实现数据恢复。

RDB持久化

RDB持久化即通过创建快照(压缩的二进制文件)的方式进行持久化,保存某个时间点的全量数据。RDB持久化是Redis默认的持久化方式。RDB持久化的触发包括手动触发与自动触发两种方式。

手动触发
  1. save, 在命令行执行save命令,将以同步的方式创建rdb文件保存快照,会阻塞服务器的主进程,生产环境中不要用
  2. bgsave, 在命令行执行bgsave命令,将通过fork一个子进程以异步的方式创建rdb文件保存快照,除了fork时有阻塞,子进程在创建rdb文件时,主进程可继续处理请求
自动触发
  1. 在redis.conf中配置 save m n 定时触发,如 save 900 1表示在900s内至少存在一次更新就触发
  2. 主从复制时,如果从节点执行全量复制操作,主节点自动执行bgsave生成RDB文件并发送给从节点
  3. 执行debug reload命令重新加载Redis时
  4. 执行shutdown且没有开启AOF持久化

redis.conf中RDB持久化配置

1
2
3
4
5
6
7
8
9
10
11
12
# 只要满足下列条件之一,则会执行bgsave命令
save 900 1 # 在900s内存在至少一次写操作
save 300 10
save 60 10000


# 禁用RBD持久化,可在最后加 save ""

# 当备份进程出错时主进程是否停止写入操作
stop-writes-on-bgsave-error yes
# 是否压缩rdb文件 推荐no 相对于硬盘成本cpu资源更贵
rdbcompression no
AOF持久化

AOF(Append-Only-File)持久化即记录所有变更数据库状态的指令,以append的形式追加保存到AOF文件中。在服务器下次启动时,就可以通过载入和执行AOF文件中保存的命令,来还原服务器关闭前的数据库状态。

redis.conf中AOF持久化配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 默认关闭AOF,若要开启将no改为yes
appendonly no

# append文件的名字
appendfilename "appendonly.aof"

# 每隔一秒将缓存区内容写入文件 默认开启的写入方式
appendfsync everysec

# 当AOF文件大小的增长率大于该配置项时自动开启重写(这里指超过原大小的100%)。
auto-aof-rewrite-percentage 100

# 当AOF文件大小大于该配置项时自动开启重写
auto-aof-rewrite-min-size 64mb

AOF持久化的实现包括3个步骤:

  1. 命令追加:将命令追加到AOF缓冲区
  2. 文件写入:缓冲区内容写到AOF文件
  3. 文件保存:AOF文件保存到磁盘

其中后两步的频率通过appendfsync来配置,appendfsync的选项包括

  • always, 每执行一个命令就保存一次,安全性最高,最多只丢失一个命令的数据,但是性能也最低(频繁的磁盘IO)
  • everysec,每一秒保存一次,推荐使用,在安全性与性能之间折中,最多丢失一秒的数据
  • no, 依赖操作系统来执行(一般大概30s一次的样子),安全性最低,性能最高,丢失操作系统最后一次对AOF文件触发SAVE操作之后的数据

AOF通过保存命令来持久化,随着时间的推移,AOF文件会越来越大,Redis通过AOF文件重写来解决AOF文件不断增大的问题(可以减少文件的磁盘占有量,加快数据恢复的速度),原理如下:

  1. 调用fork,创建一个子进程
  2. 子进程读取当前数据库的状态来“重写”一个新的AOF文件(这里虽然叫“重写”,但实际并没有对旧文件进行任何读取,而是根据数据库的当前状态来形成指令)
  3. 主进程持续将新的变动同时写到AOF重写缓冲区与原来的AOF缓冲区中
  4. 主进程获取到子进程重写AOF完成的信号,调用信号处理函数将AOF重写缓冲区内容写入新的AOF文件中,并对新文件进行重命名,原子地覆盖原有AOF文件,完成新旧文件的替换

AOF的重写也分为手动触发与自动触发

  • 手动触发:直接调用bgrewriteaof命令
  • 自动触发:根据auto-aof-rewrite-min-size和auto-aof-rewrite-percentage参数确定自动触发时机。其中auto-aof-rewrite-min-size表示运行AOF重写时文件最小体积,默认为64MB。auto-aof-rewrite-percentage表示当前AOF文件大小(aof_current_size)和上一次重写后AOF文件大小(aof_base_size)的比值。自动触发时机为 aof_current_size > auto-aof-rewrite-min-size &&(aof_current_size - aof_base_size)/aof_base_size> = auto-aof-rewrite-percentage
RDB vs AOF

RDB与AOF两种方式各有优缺点。

RDB的优点:与AOF相比,RDB文件相对较小,恢复数据比较快(原因见数据恢复部分)
RDB的缺点:服务器宕机,RBD方式会丢失掉上一次RDB持久化后的数据;使用bgsave fork子进程时会耗费内存。

AOF的优点:AOF只是追加文件,对服务器性能影响较小,速度比RDB快,消耗内存也少,同时可读性高。
AOF的缺点:生成的文件相对较大,即使通过AOF重写,仍然会比较大;恢复数据的速度比RDB慢。

数据库的恢复

服务器启动时,如果没有开启AOF持久化功能,则会自动载入RDB文件,期间会阻塞主进程。如果开启了AOF持久化功能,服务器则会优先使用AOF文件来还原数据库状态,因为AOF文件的更新频率通常比RDB文件的更新频率高,保存的数据更完整。

redis数据库恢复的处理流程如下,

img

在数据恢复方面,RDB的启动时间会更短,原因有两个:

  1. RDB 文件中每一条数据只有一条记录,不会像AOF日志那样可能有一条数据的多次操作记录。所以每条数据只需要写一次就行了,文件相对较小。
  2. RDB 文件的存储格式和Redis数据在内存中的编码格式是一致的,不需要再进行数据编码工作,所以在CPU消耗上要远小于AOF日志的加载。

但是在进行RDB持久化时,fork出来进行dump操作的子进程会占用与父进程一样的内存,采用的copy-on-write机制,对性能的影响和内存的消耗都是比较大的。比如16G内存,Redis已经使用了10G,这时save的话会再生成10G,变成20G,大于系统的16G。这时候会发生交换,要是虚拟内存不够则会崩溃,导致数据丢失。所以在用redis的时候一定对系统内存做好容量规划。

RDB、AOF混合持久化

Redis从4.0版开始支持RDB与AOF的混合持久化方案。首先由RDB定期完成内存快照的备份,然后再由AOF完成两次RDB之间的数据备份,由这两部分共同构成持久化文件。该方案的优点是充分利用了RDB加载快、备份文件小及AOF尽可能不丢数据的特性。缺点是兼容性差,一旦开启了混合持久化,在4.0之前的版本都不识别该持久化文件,同时由于前部分是RDB格式,阅读性较低。

开启混合持久化

1
aof-use-rdb-preamble yes

数据恢复加载过程就是先按照RDB进行加载,然后把AOF命令追加写入。

持久化方案的建议

  1. 如果Redis只是用来做缓存服务器,比如数据库查询数据后缓存,那可以不用考虑持久化,因为缓存服务失效还能再从数据库获取恢复。
  2. 如果你要想提供很高的数据保障性,那么建议你同时使用两种持久化方式。如果你可以接受灾难带来的几分钟的数据丢失,那么可以仅使用RDB。
  3. 通常的设计思路是利用主从复制机制来弥补持久化时性能上的影响。即Master上RDB、AOF都不做,保证Master的读写性能,而Slave上则同时开启RDB和AOF(或4.0以上版本的混合持久化方式)来进行持久化,保证数据的安全性。

十、Redis 缓存穿透、缓存雪崩?

1. 缓存穿透

通过某个key比如A进行查询时,每次都不能从缓存中获取到数据,因此每次都是访问数据库进行查询(数据库中也没有)。

解决方案
当key值A从数据库未查询到数据时,在缓存中将A的值设为空并设置过期时间。

2. 缓存击穿

某个热点key A在高并发的请求的情况下,缓存失效的瞬间,大量请求击穿缓存访问数据库。

解决方案

  • 1.业务允许的情况下,热点数据不过期;
  • 2.构建互斥锁,在第一个请求创建完成缓存后再释放锁,从而其他请求可以通过key访问缓存;

3. 缓存雪崩

雪崩是指缓存中大量数据过期导致系统涌入大量查询请求时,因大部分数据在Redis层已经失效,请求渗透到数据库层,大批量请求引起数据库压力造成查询堵塞甚至宕机。

代码层面,设置数据过期时间:

  • 1.数据失效时间分散,不要在同一个时间大量缓存数据失效;
  • 2.业务允许的情况下,数据不失效;

架构层面:

  • 1.redis高可用,Redis Cluster,主从同步,避免redis全盘奔溃;
  • 2.缓存分级,ehcache + redis + mysql模式,本地内存中无数据再从redis中查找;再者,MySQL实现限流和降级,避免宕机。
  • 3.redis必须要持久化,重启后从磁盘加载数据,快速恢复缓存数据;

十一、如何保证缓存和数据库数据的一致性?

看到好些人在写更新缓存数据代码时,先删除缓存,然后再更新数据库,而后续的操作会把数据再装载的缓存中。然而,这个是逻辑是错误的。试想,两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。

这篇文章说一下几个缓存更新的Design Pattern(让我们多一些套路吧)。

这里,我们先不讨论更新缓存和更新数据这两个事是一个事务的事,或是会有失败的可能,我们先假设更新数据库和更新缓存都可以成功的情况(我们先把成功的代码逻辑先写对)。

更新缓存的的Design Pattern有四种:

  • Cache aside (旁路缓存 )
  • Read through
  • Write through
  • Write behind caching

Cache Aside Pattern

这是最常用最常用的pattern了。其具体逻辑如下:

  • 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从cache中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效

注意,我们的更新是先更新数据库,成功后,让缓存失效。那么,这种方式是否可以没有文章前面提到过的那个问题呢?我们可以脑补一下。

一个是查询操作,一个是更新操作的并发,首先,没有了删除cache数据的操作了,而是先更新了数据库中的数据,此时,缓存依然有效,所以,并发的查询操作拿的是没有更新的数据,但是,更新操作马上让缓存的失效了,后续的查询操作再把数据从数据库中拉出来。而不会像文章开头的那个逻辑产生的问题,后续的查询操作一直都在取老的数据。

这是标准的design pattern,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。为什么不是写完数据库后更新缓存?你可以看一下Quora上的这个问答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕两个并发的写操作导致脏数据。

那么,是不是Cache Aside这个就不会有并发问题了?不是的,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。

但,这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。

所以,这也就是Quora上的那个答案里说的,要么通过2PC或是Paxos协议保证一致性,要么就是拼命的降低并发时脏数据的概率,而Facebook使用了这个降低概率的玩法,因为2PC太慢,而Paxos太复杂。当然,最好还是为缓存设置上过期时间。

Read/Write Through Pattern

我们可以看到,在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。

Read Through

Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。

Write Through

Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)
下图自来Wikipedia的Cache词条。其中的Memory你可以理解为就是我们例子里的数据库。

img

Write Behind Caching Pattern

Write Behind 又叫 Write Back。一些了解Linux操作系统内核的同学对write back应该非常熟悉,这不就是Linux文件系统的Page Cache的算法吗?是的,你看基础这玩意全都是相通的。所以,基础很重要,我已经不是一次说过基础很重要这事了。

Write Back套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是取舍Trade-Off。

另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。

在wikipedia上有一张write back的流程图,基本逻辑如下:

img

再多唠叨一些

1)上面讲的这些Design Pattern,其实并不是软件架构里的mysql数据库和memcache/redis的更新策略,这些东西都是计算机体系结构里的设计,比如CPU的缓存,硬盘文件系统中的缓存,硬盘上的缓存,数据库中的缓存。基本上来说,这些缓存更新的设计模式都是非常老古董的,而且历经长时间考验的策略,所以这也就是,工程学上所谓的Best Practice,遵从就好了。

2)有时候,我们觉得能做宏观的系统架构的人一定是很有经验的,其实,宏观系统架构中的很多设计都来源于这些微观的东西。比如,云计算中的很多虚拟化技术的原理,和传统的虚拟内存不是很像么?Unix下的那些I/O模型,也放大到了架构里的同步异步的模型,还有Unix发明的管道不就是数据流式计算架构吗?TCP的好些设计也用在不同系统间的通讯中,仔细看看这些微观层面,你会发现有很多设计都非常精妙……所以,请允许我在这里放句观点鲜明的话——如果你要做好架构,首先你得把计算机体系结构以及很多老古董的基础技术吃透了。

3)在软件开发或设计中,我非常建议在之前先去参考一下已有的设计和思路,看看相应的guideline,best practice或design pattern,吃透了已有的这些东西,再决定是否要重新发明轮子。千万不要似是而非地,想当然的做软件设计。

4)上面,我们没有考虑缓存(Cache)和持久层(Repository)的整体事务的问题。比如,更新Cache成功,更新数据库失败了怎么吗?或是反过来。关于这个事,如果你需要强一致性,你需要使用“两阶段提交协议”——prepare, commit/rollback,比如Java 7 的XAResource,还有MySQL 5.7的 XA Transaction,有些cache也支持XA,比如EhCache。当然,XA这样的强一致性的玩法会导致性能下降,关于分布式的事务的相关话题,你可以看看《分布式系统的事务处理》一文。

常用工具

非常重要!非常重要!特别是 Git 和 Docker。

Docker

传统的开发流程中,我们的项目通常需要使用 MySQL、Redis、FastDFS 等等环境,这些环境都是需要我们手动去进行下载并配置的,安装配置流程极其复杂,而且不同系统下的操作也不一样。

Docker 的出现完美地解决了这一问题,我们可以在容器中安装 MySQL、Redis 等软件环境,使得应用和环境架构分开,它的优势在于:

  1. 一致的运行环境,能够更轻松地迁移
  2. 对进程进行封装隔离,容器与容器之间互不影响,更高效地利用系统资源
  3. 可以通过镜像复制多个一致的容器

Docker 常见概念解读,可以看这篇 Github 上开源的这篇《Docker 基本概念解读》 ,从零到上手实战可以看《Docker 从入门到上手干事》这篇文章,内容非常详细!

另外,再给大家推荐一本质量非常高的开源书籍《Docker 从入门到实践》 ,这本书的内容非常新,毕竟书籍的内容是开源的,可以随时改进。

imgimg

常用框架

2021 最新Java实战项目源码打包下载t.1yb.co图标

Spring/SpringBoot

Spring 和 SpringBoot 真的很重要!

一定要搞懂 AOP 和 IOC 这两个概念。Spring 中 bean 的作用域与生命周期、SpringMVC 工作原理详解等等知识点都是非常重要的,一定要搞懂。

企业中做 Java 后端,你一定离不开 SpringBoot ,这个是必备的技能了!一定一定一定要学好!

像 SpringBoot 和一些常见技术的整合你也要知识怎么做,比如 SpringBoot 整合 MyBatis、 ElasticSearch、SpringSecurity、Redis 等等。

学习 Spring 的话,可以多看看 **《Spring 的官方文档》**,写的很详细。你可以在这里找到 Spring 全家桶的学习资源。

imgimg

你也可以把 《Spring 实战》 这本书作为学习 Spring 的参考资料。 这本书还是比较新的,目前已经出到了第 5 版,基于 Spring 5 来讲。

imgimg

了解了 Spring 中的一些常见概念和基本用法之后,你就可以开始学习 Spring Boot 了。

当然了,Spring 其实并不是学习 Spring Boot 的前置基础,相比于 Spring 来说,Spring Boot 要更容易上手一些!如果你只是想使用 Spring Boot 来做项目的话,直接学 Spring Boot 就可以了。

不过,我建议你在学习 Spring Boot 之前,可以看看 《Spring 常见问题总结》 。这些问题都是 Spring 比较重要的知识点,也是面试中经常会被问到的。

学习 Spring Boot 的话,还是建议可以多看看 **《Spring Boot 的官方文档》**,写的很详细。

你也可以把 《Spring Boot 实战》 这本书作为学习 Spring Boot 的参考资料。

imgimg

这本书的整体质量实际一般,你当做参考书来看就好了!

相比于 《Spring Boot 实战》这本书,我更推荐国人写的 《Spring Boot 实战派》

imgimg

这本书使用的 Spring Boot 2.0+的版本,还算比较新。整本书采用“知识点+实例”的形式编写,书籍的最后两章还有 2 个综合性的企业实战项目:

  • 开发企业级通用的后台系统
  • 实现一个类似“京东”的电子商务商城

作者在注意实战的过程中还不忘记对于一些重要的基础知识的讲解。

如果你想专研 Spring Boot 底层原理的话,可以看看 《Spring Boot 编程思想(核心篇)》

imgimg

这本书稍微有点啰嗦,不过,原理介绍的比较清楚(不适合初学者)。

如果你比较喜欢看视频的话,推荐尚硅谷雷神的**《2021 版 Spring Boot2 零基础入门》** 。

imgimg

这可能是全网质量最高并且免费的 Spring Boot 教程了,好评爆炸!

另外,Spring Boot 这块还有很多优质的开源教程,我已经整理好放到 awesome-java@SpringBoot 中了。

imgimg

Netty

但凡涉及到网络通信就必然必然离不开网络编程。 Netty 目前作为 Java 网络编程最热门的框架,毫不夸张地说是每个 Java 程序员必备的技能之一。

为什么说学好 Netty 很有必要呢?

  1. Netty 基于 NIO (NIO 是一种同步非阻塞的 I/O 模型,在 Java 1.4 中引入了 NIO )。使用 Netty 可以极大地简化并简化了 TCP 和 UDP 套接字服务器等网络编程,并且性能以及安全性等很多方面都非常优秀。
  2. 我们平常经常接触的 Dubbo、RocketMQ、Elasticsearch、gRPC、Spark、Elasticsearch 等等热门开源项目都用到了 Netty。
  3. 大部分微服务框架底层涉及到网络通信的部分都是基于 Netty 来做的,比如说 Spring Cloud 生态系统中的网关 Spring Cloud Gateway 。

下面是一些比较推荐的书籍/专栏。

《Netty 实战》

imgimg

这本书可以用来入门 Netty ,内容从 BIO 聊到了 NIO、之后才详细介绍为什么有 Netty 、Netty 为什么好用以及 Netty 重要的知识点讲解。

这本书基本把 Netty 一些重要的知识点都介绍到了,而且基本都是通过实战的形式讲解。

《Netty 进阶之路:跟着案例学 Netty》

imgimg

内容都是关于使用 Netty 的实践案例比如内存泄露这些东西。如果你觉得你的 Netty 已经完全入门了,并且你想要对 Netty 掌握的更深的话,推荐你看一下这本书。

《Netty 入门与实战:仿写微信 IM 即时通讯系统》

imgimg

通过一个基于 Netty 框架实现 IM 核心系统为引子,带你学习 Netty。整个小册的质量还是很高的,即使你没有 Netty 使用经验也能看懂。

搜索引擎

搜索引擎用于提高搜索效率,功能和浏览器搜索引擎类似。比较常见的搜索引擎是 Elasticsearch(推荐) 和 Solr。

如果你要学习 Elasticsearch 的话,Elastic 中文社区 以及 Elastic 官方博客 都是非常不错的资源,上面会分享很多具体的实践案例。

除此之外,极客时间的《Elasticsearch 核心技术与实战》这门课程非常赞!这门课基于 Elasticsearch 7.1 版本讲解,比较新。并且,作者是 eBay 资深技术专家,有 20 年的行业经验,课程质量有保障!

imgimg

如果你想看书的话,可以考虑一下 《Elasticsearch 实战》 这本书。不过,需要说明的是,这本书中的 Elasticsearch 版本比较老,你可以将其作为一个参考书籍来看,有一些原理性的东西可以在上面找找答案。

imgimg

如果你想进一步深入研究 Elasticsearch 原理的话,可以看看张超老师的《Elasticsearch 源码解析与优化实战》这本书。这是市面上唯一一本写 Elasticsearch 源码的书。

imgimg

分布式

下面我们开始学习分布式以及高并发、高可用了。

这块内容的话,对于每一个知识点没有特定的书籍。我就推荐 2 本我觉得还不错的书籍吧!这两把书籍基本把下面涉及到的知识点给涵盖了。

第一本是李运华老师的**《从零开始学架构》** 。

imgimg

这本书对应的有一个极客时间的专栏—《从零开始学架构》,里面的很多内容都是这个专栏里面的,两者买其一就可以了。

第二本是余老师的 《软件架构设计:大型网站技术架构与业务架构融合之道》

imgimg

事务与锁、分布式(CAP、分布式事务……)、高并发、高可用这本书都有介绍到。值得推荐!良心好书!

理论

CAP 理论

CAP 也就是 Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性) 这三个单词首字母组合。

关于 CAP 的详细解读请看:《CAP 理论解读》

BASE 理论

BASEBasically Available(基本可用)Soft-state(软状态)Eventually Consistent(最终一致性) 三个短语的缩写。BASE 理论是对 CAP 中一致性和可用性权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于 CAP 定理逐步演化而来的,它大大降低了我们对系统的要求。

关于 CAP 的详细解读请看:《BASE 理论解读》

Paxos 算法和 Raft 算法

Paxos 算法诞生于 1990 年,这是一种解决分布式系统一致性的经典算法 。但是,由于 Paxos 算法非常难以理解和实现,不断有人尝试简化这一算法。到了 2013 年才诞生了一个比 Paxos 算法更易理解和实现的分布式一致性算法—Raft 算法

RPC

RPC 让调用远程服务调用像调用本地方法那样简单。

Dubbo 是一款国产的 RPC 框架,由阿里开源。相关阅读:

服务注册与发现

Eureka、Zookeeper、Consul、Nacos 都可以提供服务注册与发现的功能。

imgimg

API 网关

网关主要用于请求转发、安全认证、协议转换、容灾。

SpringCloud Gateway 是 Spring Cloud 的一个全新项目,为了取代 Netflix Zuul。

配置中心

微服务下,业务的发展一般会导致服务数量的增加,进而导致程序配置(服务地址、数据库参数等等)增多。

传统的配置文件的方式已经无法满足当前需求,主要有两点原因:一是安全性得不到保障(配置放在代码库中容易泄露);二是时效性不行 (修改配置需要重启服务才能生效)。

Spring Cloud Config、Nacos 、Apollo、K8s ConfigMap 都可以用来做配置中心。

Apollo 和 Nacos 我个人更喜欢。Nacos 使用起来更加顺手,Apollo 在配置管理方面做的更加全面。

分布式 id

日常开发中,我们需要对系统中的各种数据使用 ID 唯一表示,比如用户 ID 对应且仅对应一个人,商品 ID 对应且仅对应一件商品,订单 ID 对应且仅对应一个订单。

imgimg

简单来说,ID 就是数据的唯一标识

分布式 ID 是分布式系统下的 ID。分布式 ID 不存在与现实生活中,属于计算机系统中的一个概念。

我简单举一个分库分表的例子。

我司的一个项目,使用的是单机 MySQL 。但是,没想到的是,项目上线一个月之后,随着使用人数越来越多,整个系统的数据量将越来越大。

单机 MySQL 已经没办法支撑了,需要进行分库分表(推荐 Sharding-JDBC)。

在分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?

imgimg

这个时候就需要生成分布式 ID了。

分布式 ID 的解决方案有很多比如 :

  • 算法 :UUID、Snowflake
  • 开源框架 : UidGenerator、Leaf 、Tinyid

分布式事务

微服务架构下,一个系统被拆分为多个小的微服务。

每个微服务都可能存在不同的机器上,并且每个微服务可能都有一个单独的数据库供自己使用。这种情况下,一组操作可能会涉及到多个微服务以及多个数据库。

举个例子:电商系统中,你创建一个订单往往会涉及到订单服务(订单数加一)、库存服务(库存减一)等等服务,这些服务会有供自己单独使用的数据库。

imgimg

那么如何保证这一组操作要么都执行成功,要么都执行失败呢?

这个时候单单依靠数据库事务就不行了!我们就需要引入 分布式事务 这个概念了!

常用分布式事务解决方案有 Seata 和 Hmily。

  1. Seata :Seata 是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务。
  2. Hmily : 金融级分布式事务解决方案

分布式链路追踪

不同于单体架构,在分布式架构下,请求需要在多个服务之间调用,排查问题会非常麻烦。我们需要分布式链路追踪系统来解决这个痛点。

目前分布式链路追踪系统基本都是根据谷歌的《Dapper 大规模分布式系统的跟踪系统》这篇论文发展而来,主流的有 Pinpoint,Skywalking ,CAT(当然也有其他的例如 Zipkin,Jaeger 等产品,不过总体来说不如前面选取的 3 个完成度高)等。

Zipkin 是 Twitter 公司开源的一个分布式链路追踪工具,Spring Cloud Sleuth 实际是基于 Zipkin 的。

SkyWalking 是国人吴晟(华为)开源的一款分布式追踪,分析,告警的工具,现在是 Apache 旗下开源项目

微服务

微服务的很多东西实际在分布式这一节已经提到了。

我这里就再补充一些微服务架构中,经常使用到的一些组件。

  • 声明式服务调用 : Feign
  • 负载均衡 : Ribbon
  • ……

高并发

消息队列

imgimg

消息队列在分布式系统中主要是为了解耦和削峰。相关阅读:消息队列常见问题总结

常用的消息队列如下:

  1. RocketMQ :阿里巴巴开源的一款高性能、高吞吐量的分布式消息中间件。
  2. Kafaka: Kafka 是一种分布式的,基于发布 / 订阅的消息系统。关于它的入门可以查看:Kafka 入门看这一篇就够了
  3. RabbitMQ :由 erlang 开发的基于 AMQP(Advanced Message Queue 高级消息队列协议)协议实现的消息队列。

读写分离&分库分表

读写分离主要是为了将数据库的读和写操作分不到不同的数据库节点上。主服务器负责写,从服务器负责读。另外,一主一从或者一主多从都可以。

读写分离可以大幅提高读性能,小幅提高写的性能。因此,读写分离更适合单机并发读请求比较多的场景。

imgimg

分库分表是为了解决由于库、表数据量过大,而导致数据库性能持续下降的问题。

常见的分库分表工具有:sharding-jdbc(当当)、TSharding(蘑菇街)、MyCAT(基于 Cobar)、Cobar(阿里巴巴)…。 推荐使用 sharding-jdbc。 因为,sharding-jdbc 是一款轻量级 Java 框架,以 jar 包形式提供服务,不要我们做额外的运维工作,并且兼容性也很好。

imgimg

相关阅读: 读写分离&分库分表常见问题总结

负载均衡

负载均衡系统通常用于将任务比如用户请求处理分配到多个服务器处理以提高网站、应用或者数据库的性能和可靠性。

常见的负载均衡系统包括 3 种:

  1. DNS 负载均衡 :一般用来实现地理级别的均衡。
  2. 硬件负载均衡 : 通过单独的硬件设备比如 F5 来实现负载均衡功能(硬件的价格一般很贵)。
  3. 软件负载均衡 :通过负载均衡软件比如 Nginx 来实现负载均衡功能。

高可用

高可用描述的是一个系统在大部分时间都是可用的,可以为我们提供服务的。高可用代表系统即使在发生硬件故障或者系统升级的时候,服务仍然是可用的 。

相关阅读: 如何设计一个高可用系统?要考虑哪些地方?

限流&降级&熔断

限流是从用户访问压力的角度来考虑如何应对系统故障。限流为了对服务端的接口接受请求的频率进行限制,防止服务挂掉。比如某一接口的请求限制为 100 个每秒, 对超过限制的请求放弃处理或者放到队列中等待处理。限流可以有效应对突发请求过多。相关阅读:限流算法有哪些?

降级是从系统功能优先级的角度考虑如何应对系统故障。服务降级指的是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行。

熔断和降级是两个比较容易混淆的概念,两者的含义并不相同。

降级的目的在于应对系统自身的故障,而熔断的目的在于应对当前系统依赖的外部系统或者第三方系统的故障。

HystrixSentinel 都能实现限流、降级、熔断。

Hystrix 是 Netflix 开源的熔断降级组件,Sentinel 是阿里中间件团队开源的一款不光具有熔断降级功能,同时还支持系统负载保护的组件。

两者都是主要做熔断降级 ,那么两者到底有啥异同呢?该如何选择呢?

Sentinel 的 wiki 中已经详细描述了其与 Hystrix 的区别,你可以看看。

排队

另类的一种限流,类比于现实世界的排队。玩过英雄联盟的小伙伴应该有体会,每次一有活动,就要经历一波排队才能进入游戏。

集群

相同的服务部署多份,避免单点故障。

超时和重试机制

一旦用户的请求超过某个时间得不到响应就结束此次请求并抛出异常。 如果不进行超时设置可能会导致请求响应速度慢,甚至导致请求堆积进而让系统无法在处理请求。

另外,重试的次数一般设为 3 次,再多次的重试没有好处,反而会加重服务器压力(部分场景使用失败重试机制会不太适合)。

l