0%

什么是OpenResty

在官方文档中,对Openresty的定义是——通过 Lua 扩展 NGINX 实现的可伸缩的 Web 平台。

OpenResty 并不是 NGINX 的 fork,也不是在 NGINX 的基础上加了一些常用库重新打包,而只是把 NGINX 当作底层的网络库来使用。这里有必要说明,OpenResty 的两个基石是:NGINX 和 LuaJIT。

一个简单的使用案例

对于OpenResty的使用,最简单的方式就是在安装目录下,执行:

1
resty -e "ngx.say('hello world')"

便可以看到输出 hello world,从这里,可以看到 ngx 这个词,也很容易想到OpenResty是通过nginx实现的这些效果,我们可以验证一下,使用命令加了一行 sleep 休眠的代码,让 resty 运行的程序打印出字符串后,并不退出:

1
resty -e "ngx.say('hello world'); ngx.sleep(10)" &

可以看到如下输出:

1
2
$ ps -ef | grep nginx
501 25468 25462 0 7:24下午 ttys000 0:00.01 /usr/local/Cellar/openresty/''1.13.6.2/nginx/sbin/nginx -p /tmp/resty_AfNwigQVOB/ -c conf/nginx.conf

终于看了熟悉的 NGINX 进程,看来,resty 本质上是启动了一个 NGINX 服务。

更为正式的 hello world

最开始我们使用resty写的第一个 OpenResty 程序,没有 master 进程,也不会监听端口。

下面再看下正式一点的怎么来写,要写出这样的程序需要三步:

  • 创建工作目录,创建lua文件,在里面插入代码;
1
2
3
$ mkdir lua
$ cat lua/hello.lua
ngx.say("hello, world")
  • 修改 NGINX 的配置文件,配置 Lua 文件位置;
1
2
3
4
5
6
7
8
9
10
11
12
13
pid logs/nginx.pid;
events {
worker_connections 1024;
}

http {
server {
listen 8080;
location / {
content_by_lua_file lua/hello.lua;
}
}
}
  • 重启 OpenResty 服务。

包管理工具

如果把 OpenResty 用于生产环境,OpenResty 安装包自带的这些库是远远不够的,比如没有 lua-resty 库来发起 HTTP 请求,也没有办法和 Kafka 交互。

在使用OpenResty的过程中,我们不应该使用任何 Lua 的库来解决问题,而是应该使用 cosocket 的 lua-resty-* 库。Lua 的库很可能会带来阻塞,让原本高性能的服务,直接下降几个数量级。

包管理工具主要是 OPMLUAROCKS ,对此不过多记录。

Nginx Lua的执行原理

在OpenResty中,每个Worker进程使用一个Lua VM(Lua虚拟机),当请求被分配到Worker时,将在这个Lua VM中创建一个协程,协程之间数据隔离,每个协程都具有独立的全局变量。

ngx_lua是将Lua嵌入Nginx,让Nginx执行Lua脚本,并且高并发、非阻塞地处理各种请求。Lua内置协程可以很好地将异步回调转换成顺序调用的形式。ngx_lua在Lua中进行的IO操作都会委托给Nginx的事件模型,从而实现非阻塞调用。开发者可以采用串行的方式编写程序,ngx_lua会在进行阻塞的IO操作时自动中断,保存上下文,然后将IO操作委托给Nginx事件处理机制,在IO操作完成后,ngx_lua会恢复上下文,程序继续执行,这些操作对用户程序都是透明的。

每个Nginx的Worker进程持有一个Lua解释器或LuaJIT实例,被这个Worker处理的所有请求共享这个实例。每个请求的context上下文会被Lua轻量级的协程分隔,从而保证各个请求是独立的。

标准 Lua 和 LuaJIT 的关系

标准 Lua 和 LuaJIT 是两回事儿,LuaJIT 只是兼容了 Lua 5.1 的语法。

标准 Lua 出于性能考虑,也内置了虚拟机,所以 Lua 代码并不是直接被解释执行的,而是先由 Lua 编译器编译为字节码(Byte Code),然后再由 Lua 虚拟机执行。

而 LuaJIT 的运行时环境,除了一个汇编实现的 Lua 解释器外,还有一个可以直接生成机器代码的 JIT 编译器。

开始的时候,LuaJIT 和标准 Lua 一样,Lua 代码被编译为字节码,字节码被 LuaJIT 的解释器解释执行。但不同的是,LuaJIT 的解释器会在执行字节码的同时,记录一些运行时的统计信息,比如每个 Lua 函数调用入口的实际运行次数,还有每个 Lua 循环的实际执行次数。当这些次数超过某个随机的阈值时,便认为对应的 Lua 函数入口或者对应的 Lua 循环足够热,这时便会触发 JIT 编译器开始工作。

JIT 编译器会从热函数的入口或者热循环的某个位置开始,尝试编译对应的 Lua 代码路径。编译的过程,是把 LuaJIT 字节码先转换成 LuaJIT 自己定义的中间码(IR),然后再生成针对目标体系结构的机器码。

所以,所谓 LuaJIT 的性能优化,本质上就是让尽可能多的 Lua 代码可以被 JIT 编译器生成机器码,而不是回退到 Lua 解释器的解释执行模式。明白了这个道理,你才能理解后面学到的 OpenResty 性能优化的本质。

为什么不支持下提前强制编译成机器码?

因为 Lua 是动态语言,动态语言无法在未运行时推断出变量类型,因此只能在 runtime 中进行 type checking 的工作。

慎用阻塞函数

OpenResty 编程中有一个重要原则:避免使用阻塞函数

OpenResty 之所以可以保持很高的性能,简单来说,是因为它借用了 Nginx 的事件处理和 Lua 的协程机制,所以:

  • 在遇到网络 I/O 等需要等待返回才能继续的操作时,就会先调用 Lua 协程的 yield 把自己挂起,然后在 Nginx 中注册回调;
  • 在 I/O 操作完成(也可能是超时或者出错)后,由 Nginx 回调 resume,来唤醒 Lua 协程。

这样的流程,保证了 OpenResty 可以一直高效地使用 CPU 资源,来处理所有的请求。在这个处理流程中,如果没有使用 cosocket 这种非阻塞的方式,而是用阻塞的函数来处理 I/O,那么 LuaJIT 就不会把控制权交给 Nginx 的事件循环。

这就会导致,其他的请求要一直排队等待阻塞的事件处理完,才会得到响应。综上所述,在 OpenResty 的编程中,对于可能出现阻塞的函数调用,也要特别谨慎;否则,一行阻塞的代码,就会把整个服务的性能拖垮。

阻塞函数举例

比如杀掉某个进程:

1
os.execute("kill -HUP " .. pid) 

或者是拷贝文件、使用 OpenSSL 生成密钥等耗时更久的一些操作:

1
2
os.execute(" cp test.exe /tmp ")
os.execute(" openssl genrsa -des3 -out private.pem 2048 ")

表面上看, os.execute 是 Lua 的内置函数,而在 Lua 中,也确实是用这种方式来调用外部命令的。但是,Lua 是一种嵌入式语言,它在不同的上下文环境中,会有完全不同的推荐用法。

在 OpenResty 的环境中,os.execute 会阻塞当前请求。所以,如果这个命令的执行时间特别短,那么影响还不是很大;可如果这个命令,需要执行几百毫秒甚至几秒钟的时间,那么性能就会有急剧的下降。

诸如此类的例子也非常多,不一一举例。

实际使用

要让请求走nginx的Lua脚本,需要使用nginx的Lua模块。以下是一些步骤:

  1. 安装nginx的Lua模块。可以使用openresty的版本,它已经包含了Lua模块以及其他常用模块。也可以通过源代码编译nginx并添加Lua模块。
  2. 配置nginx的配置文件,使其支持Lua脚本。可以在nginx配置文件中使用“lua_package_path”和“lua_package_cpath”指令设置Lua库路径。
  3. 在nginx配置文件中编写Lua脚本。可以使用“location”指令指定需要执行Lua脚本的URL路径,并使用“content_by_lua_block”或“content_by_lua_file”指令加载脚本。
  4. 在Lua脚本中编写处理请求的代码。可以使用nginx提供的Lua API,例如“ngx.req”和“ngx.say”等来处理请求和响应。

下面是一个简单的示例nginx配置文件,演示了如何使用Lua脚本处理请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
arduinoCopy codeworker_processes 1;

events {
worker_connections 1024;
}

http {
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
lua_package_cpath "/usr/local/openresty/lualib/?.so;;";

server {
listen 8080;
server_name localhost;

location /hello {
content_by_lua_block {
ngx.say("Hello, world!")
}
}
}
}

在上面的示例中,“/hello”路径的请求将会被Lua脚本处理,并输出“Hello, world!”。

参考引用:

【1】https://time.geekbang.org/column/article/98416

  1. 基于OpenResty Nginx + lua 写业务逻辑
  2. 预约 + 抢购
  3. 验证码预削峰
  4. nginx限流、线程池限流、API 限流
  5. nginx + lua 黑名单机制
  6. 如何防止超卖 lua(EVALSHA预加载lua脚本)
  7. 基于硬件的优化(网卡中断优化)
  8. 基于Java的优化
  9. 业务思考
  10. 在高并发下在高并发下,对于读热点数据最好放到本地缓存,这样可以快速的返回
  11. 避免单点,将服务无状态化,可以将参数通过Nacos配置中心来推送(比如服务A里面不能存储状态相关的数据(比如static 修饰的数据存储) )

前言

OpenArt项目和商城类的项目很像,本质上就是数字藏品售卖。和普通商品不同的是,当用户稍微具有一点规模时,他的售卖场景,将全部是秒杀场景,所以秒杀也是本项目最为核心的功能。

因为数字藏品目前十分火热,很多新台子一开出来,便有成千上万用户注册使用,甚至有平台注册接口都能崩了。因此用户数量拥有极大的不确定性,在设计时应该将用户量往稍微多一些考虑。

秒杀系统面临的挑战

巨大的瞬时流量

秒杀活动的特点,就是将用户全部集中到同一个时刻,然后一起开抢某个热门商品,而热门商品的库存往往又非常少,所以持续的时间也比较短,快的话可能一两秒内就结束了。而数字藏品由于近期火热,抢到发售通常可以为用户带来收益,因此所有的商品都属于热门商品。

热点数据问题

高并发下一个无法避开的问题,就是热点数据问题。特别是对于秒杀活动,大家抢购的都是同一个商品,所以这个商品直接就被推到了热点的位置,不管你是用的数据库,还是分布式缓存,都无法支持几十万、上百万对同一个 key 的读写,以 Redis 的写为例,最高仅可支持几万的 TPS。

刷子流量

由于数字藏品的火热,非常多的平台都会被非法流量盯上,因此,面对刷子流量也是一个巨大的挑战。

一般我们提供的秒杀对外服务,都是 HTTP 的服务。不管是用 H5 实现的页面,还是通过安卓或是 iOS 实现的原生页面,特别是 H5,都可以直接通过浏览器或是抓包工具拿到请求数据,这样刷子便可以自己通过程序实现接口的直接调用,并可以设置请求的频率。

这样高频次的请求,会挤占正常用户的抢购通道,同时,刷子也获得了更高的秒杀成功率。这不仅破坏了公平的抢购环境,也给系统服务带来了巨大的额外负担。

其实总结来说,瞬时的大流量就是最大的挑战,当业务系统流量成几何增长时,有些业务接口加机器便可以支持。但考虑到成本与收益,在有限的资源下,如何通过合理的系统设计来达到预期的业务目标,就显得格外重要了。

秒杀HTTP请求链路分析

DNS:负责域名解析,会将你的域名请求指定一个实际的 IP 来处理(事先配置好处理请求的 IP,DNS 按顺序指定),并且一般客户端浏览器会缓存这个 IP 一段时间,当下次再请求时就直接用这个 IP 来建立连接,当然如果指定的 IP 挂了,DNS 并不会自动剔除,下次依然会使用它。

Nginx:也就是上面的被 DNS 指定来处理请求的 IP,一般都会被用来当做反向代理和负载均衡器使用,因为它具有良好的吞吐性能,所以一般也可以用来做静态资源服务器。当 Nginx 接收到客户端请求后,根据负载均衡算法(默认是轮询)将请求分发给下游的 Web 服务。

Web 服务:这个就是我们都比较熟知的领域了,一般我们写业务接口的地方就是这了,还有我们的 H5 页面,也都可以放到这里,这里是我们做业务聚合的地方,提供页面需要的数据以及元素。

RPC 服务:一般提供支撑业务的基础服务,服务功能相对单一,可灵活、快速部署,复用性高。RPC 服务一般都是内部服务,仅供内部服务间调用,不对外开放,安全性高。

从哪里进行优化

我们一个个来看下,对于DNS层一般会做一些和网络相关的防攻击措施,可以拦截一些攻击请求,但是成本有些高,所以暂不考虑。

对于Nginx层,Nginx 不仅可以作为反向代理和负载均衡器,也可以做大流量的 Web 服务器,同时也是一款非常优秀的静态资源服务器。所以我们可以在这层做文章,在经过了解后,我也发现可以在这层写一些业务逻辑,做一些前置校验,过滤大量请求,因此列入考虑。

接下来就到了 Web 服务了。我们在这里做业务的聚合,提供结算页页面渲染所需要的数据以及下单数据透传,同时也负责流量的筛选与控制,保证下游系统的安全。

最后就是 RPC 服务。它提供基础服务,一般经过上面 3 层的严格把关,到这里的请求,量已经小很多了,我们写业务逻辑,在技术上也有更多的发挥空间。

基于OpenResty 的 Nginx + lua

对于OpenResty的加简单介绍和使用,可以看我的另一篇文章—— 初探OpenResty

OpenResty 是一个基于 Nginx 与 Lua 的高性能 Web 平台,它使我们具备在 Nginx 上使用 Lua 语言来开发业务逻辑的能力,并充分利用 Nginx 的非阻塞 IO 模型,来帮助我们非常方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。

为什么要用 Lua 语言来做 Nginx 开发呢?这就要说到 Lua 语言的特点了,Lua 的线程模型是单线程多协程的模式,而 Nginx 刚好是单进程单线程,天生的完美搭档。

搭建OpenResty 首先需要下载,这里吐槽下Windows下有很多奇奇怪怪的问题,还需要装Perl等环境,最好使用Docker安装。

职责划分

对于此层,在我的规划中,主要用于以下功能:

  • 流量筛选:根据黑白名单、登录态和参数有效性等来筛选流量。
  • 流量分发:通过设置的负载均衡算法进行流量分发,也可以自定义算法,比如根据 IP 做 hash,或者根据用户 ID 做 hash 等。
  • 简单业务以及校验:提供活动数据、活动有效性校验、库存数量校验和其他业务相关的简单校验等。
  • 限流:根据 IP 或者自定义关键入参做限流。
  • 异常提示页面:主要是进结算页失败的提示页,可能是被限流,被业务校验拦截或者是后端服务异常等。

秒杀的流量管控

对于流量管控,其实我看了很多台子,有些台子简单粗暴,通过抽签的方式来抽取藏品,这种方式可以使流量平滑均匀分配,但是这种方式只有一个目前这一块非常大的一个台子在用,而且抽签算法也较为复杂,因此我们不考虑这种方式。

而另一种考虑的方式就是预约抢购,只有预约的用户能够参与抢购,这样也可以过滤非常多的流量,这种方式也有非常多的平台在使用,不过基本都是头部互联网公司建设的平台。因为他们自带的公信力和影响力,所以可以采用这种方式,对于我们新开发的平台,则不太适合,因此前期暂不考虑使用。

综上,我们需要从流量削峰和限流考虑了。

秒杀的削峰和限流和防刷

削峰的方法有很多,可以通过业务手段来削峰,比如秒杀流程中设置验证码或者问答题环节;也可以通过技术手段削峰,比如采用消息队列异步化用户请求,或者采用限流漏斗对流量进行层层过滤。削峰又分为无损和有损削峰。本质上,限流是一种有损技术削峰;而引入验证码、问答题以及异步化消息队列可以归为无损削峰。

削峰

削峰的一般手段就是验证码、问答题、消息队列、分层过滤和限流。

秒杀交易流程中,引入验证码和问答题,有两个目的:一是快速拦截掉部分刷子流量,防止机器作弊,起到防刷的作用;二是平滑秒杀的毛刺请求,延缓并发,对流量进行削峰。

校验的逻辑比较简单,从前端的 HTTP 请求里,取得 skuId、user、timestamp 和 newCode,首先验证 timestamp 是否已经过期,然后根据用户输入的验证码内容 newCode ,并和 Redis 里的 newCode 进行比对,比对一致表示验证码校验通过。然后我们需要删掉 Redis 的内容,避免被重复验证,这样的话一个验证码就只会被验证一次了。

限流

限流是系统自我保护的最直接手段,再厉害的系统,总有所能承载的能力上限,一旦流量突破这个上限,就会引起实例宕机,进而发生系统雪崩,带来灾难性后果。

限流常用的算法有令牌桶和漏桶,有关这两个算法的专业介绍,可以参考:https://hansliu.com/posts/20。

限流也有网关层Web层的限流。

网关层限流

这里的 Nginx 限流,主要是依赖 Nginx 自带的限流功能,针对请求的来源 IP 或者自定义的一个关键参数来做限流,比如用户 ID。其配置限流规则的语法为:

1
limit_req_zone <变量名> zone=<限流规则名称>:<内存大小> rate=<速率阈值>r/s;
  • 以上 limit_req_zone 是关键字,< 变量名 > 是指定根据什么来限流;
  • zone 是关键字,< 限流规则名称 > 是定义规则名称,后续代码中可以指定使用哪个规则;
  • < 内存大小 > 是指声明多大内存来支撑限流的功能;
  • rate 是关键字,可以指定限流的阈值,单位 r/s 意为每秒允许通过的请求,这个算法是使用令牌漏桶的思想来实现的。

其实在一些云存储服务中,也特别容易被盗刷流量,因此,在我们七牛云也利用nginx和防盗链reference做了简单的防刷。

Web层限流

线程池限流

我们可以通过自定义线程池,配置最大连接数,以请求处理队列长度以及拒绝策略等参数来达到限流的目的。当处理队列满,而且最大线程都在处理时,多余的请求就会被拒绝策略丢弃,也就是被限流了。

API 限流

上面说的的线程池限流可以看做是一种并发数限流,对于并发数限流来说,实际上服务提供的 QPS 能力是和后端处理的响应时长有关系的,在并发数恒定的情况下,TP99 越低,QPS 就越高。然而大部分情况是,我们希望根据 QPS 多少来进行限流,这时就不能用线程池策略了。不过,我们可以用 Google 提供的 RateLimiter 开源包,自己手写一个基于令牌桶的限流注解和实现,在业务 API 代码里使用。

具体来说,TP99表示在特定时间段内,有99%的观察值低于或等于这个数值。换句话说,TP99是一个经验法则,它告诉我们如何评估系统的稳定性和响应时间。

例如,当我们测试一个网络应用程序的性能时,我们可能会记录每个请求的响应时间,并计算出TP99。如果TP99为3秒,则说明99%的请求在3秒内完成处理,而只有1%的请求需要更长的时间。因此,TP99可以帮助我们了解系统的瓶颈和性能瓶颈所在,并为我们提供优化系统的线索。

防刷

目前可以采用的防刷手段就是利用Token,简单说,就是在进入下个接口之前,要在上个接口获得Token,不然就认定为非法请求。同时这种方式也可以防止多端操作对数据的篡改,如果我们在 Nginx 层做 Token 的生成与校验,可以做到对业务流程主数据的无侵入。

我们在返回的 header 里增加流程 Token。这里 st 的生成只是简单地将用户 ID+ 步骤编号做了 MD5,后期需要更严格一些,需要加入商品编号、活动开始时间、自定义加密 key 等。

1
2
3
4
5
6
7
8
9
10
11
12
location /activity/query{
limit_req zone=limit_by_user nodelay;
content_by_lua_file lua/activity_query.lua;
#设置返回的header,并将security token放在header中
header_filter_by_lua_block{
ngx.header["st"] = ngx.md5(ngx.var.user_id.."1")
--这里为了解决跨域问题设置的,不存在跨域时不需要设置以下header
ngx.header["Access-Control-Expose-Headers"] = "st"
ngx.header["Access-Control-Allow-Origin"] = "http://localhost:8080"
ngx.header["Access-Control-Allow-Credentials"] = "true"
}
}

流程如下图所示:

image-20230411222549690

热点数据处理

秒杀的核心问题是要解决单个商品的高并发读和高并发写问题,也就是要处理好热点数据问题。对于这个问题,需要分为两类:读热点问题和写热点问题。

读热点

对于读热点有两种解决思路:

  1. 增加热点数据的副本数;
  2. 让热点数据离用户越近越好。

第一个解决方案,就是增加 Redis 从的副本数,然后业务层(Tomcat 集群)轮询查询不同的副本,提高同一数据的 QPS。一般情况下,单个 Redis 从,可提供 8~10 万的查询,所以如果我们增加 12 个副本,就可以提供百万 QPS 的热点查询。当然,本项目是暂时不需要考虑那么大的并发的,而这么多副本,成本必然也高。

第二个解决方案,是把热点数据再上移,在 Tomcat 集群做热点数据的本地缓存,也就是让业务层的每个实例里都有份数据副本,读请求数据的时候,无需去 Redis 获取,直接从本地缓存里取。这时候,数据的副本数和 Tomcat 实例一样多,另外请求链路减少了一层,而且也减少了对 Redis 单片 QPS 上限的依赖,具有更高的可靠性和更高的性能。本地缓存的实现比较简单,可以用 HashMap、Ehcache,或者 Google 提供的 Guava 组件。

但是这种方式也有问题,本地缓存数据同步延迟肯定是更高的,需要考虑这种延迟在不同的业务场景下是否能够接受。

容灾

容灾其实也非常重要,如果服务器出现问题,用户数据丢失将十分危险,而目前我们能想到的相对经济省力的手段就是定时备份了。

其次就是避免单点,将服务无状态化,可以将参数通过Nacos配置中心来推送(比如服务A里面不能存储状态相关的数据(比如static 修饰的数据存储) )

防止超卖

对于超卖,也无需过多介绍。举个简单的例子,现在活动藏品有 2 件库存,此时有两个并发请求过来,其中请求 A 要抢购 1 件,请求 B 要抢购 2 件,然后大家都去调用活动查询接口,发现库存都够,紧接着就都去调用对应的库存扣减接口,这个时候,两个都会扣减成功,但库存却变成了 -1,也就是超卖了。

超卖的问题主要是由两个原因引起的,一个是查询和扣减不是原子操作,另一个是并发引起的请求无序。

所以要解决这个问题,我们就得做到库存扣减的原子性和有序性。

怎么解决

首先我想到利用数据库的行锁机制。这种方式的优点是简单安全,但是其性能比较差,无法适用于本项目的业务场景。

既然数据库不行,那能使用分布式锁吗?通过 Redis 或者 ZooKeeper 来实现一个分布式锁,以藏品维度来加锁,在获取到锁的线程中,按顺序去执行商品库存的查询和扣减,这样就同时实现了顺序性和原子性。这个思路是可以的,只是不管通过哪种方式实现的分布式锁,都是有弊端的。

以 Redis 的实现来说,仅仅在设置锁的有效期问题上,就让人头大。如果时间太短,那么业务程序还没有执行完,锁就自动释放了,这就失去了锁的作用;而如果时间偏长,一旦在释放锁的过程中出现异常,没能及时地释放,那么所有的业务线程都得阻塞等待直到锁自动失效,这与我们要实现高性能的秒杀系统是相悖的。所以通过分布式锁的方式可以实现,但我们不使用。

这时候,我们再考虑Redis执行Lua脚本去实现,Redis本身就是单线程的,天生就可以支持操作的顺序性。如果能在一次 Redis 的执行中,同时包含查询和扣减两个命令,就可以做到保证操作执行的原子性。

当然这里的原子性说法可能不是很准确,因为 Lua 脚本并不会自动帮你完成回滚操作,所以如果我们的脚本逻辑中包含两步写操作,需要自己去做回滚。但是我们库存扣减的逻辑针对 Redis 的命令就两种,一个读一个写,并且写命令在最后,这样就不存在需要回滚的问题了。

Redis 执行 Lua 脚本

Redis 执行 Lua 脚本的命令有两个,一个是 EVAL,另一个是 EVALSHA。

原生 EVAL 方法的使用语法如下:

1
EVAL script numkeys key [key ...] arg [arg ...]

其中 EVAL 是命令,script 是我们 Lua 脚本的字符串形式,numkeys 是我们要传入的参数数量,key 是我们的入参,可以传入多个,arg 是额外的入参。但这种方式需要每次都传入 Lua 脚本字符串,不仅浪费网络开销,同时 Redis 需要每次重新编译 Lua 脚本,对于我们追求性能极限的系统来说,不是很完美。

所以这里就要说到另一个命令 EVALSHA 了,原生语法如下:

1
EVALSHA sha1 numkeys key [key ...] arg [arg ...]

可以看到其语法与 EVAL 类似,不同的是这里传入的不是脚本字符串,而是一个加密串 sha1。这个 sha1 是从哪来的呢?它是通过另一个命令 SCRIPT LOAD 返回的,该命令是预加载脚本用的,语法为:

1
SCRIPT LOAD script

这样的话,我们通过预加载命令,将 Lua 脚本先存储在 Redis 中,并返回一个 sha1,下次要执行对应脚本时,只需要传入 sha1 即可执行对应的脚本。这完美地解决了 EVAL 命令存在的弊端,所以我们这里也是基于 EVALSHA 方式来实现的。

实现

在Web服务中,我们可以定义一个RedisTools,这里列出和此例相关的部分代码如下:

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
@Component
public class RedisTools {
/**
* lua逻辑:首先判断活动库存是否存在,以及库存余量是否够本次购买数量,如果不够,则返回0,如果够则完成扣减并返回1
* 两个入参,KEYS[1] : 活动库存的key
* KEYS[2] : 活动库存的扣减数量
*/
private String STORE_DEDUCTION_SCRIPT_LUA =
"local c_s = redis.call('get', KEYS[1])\n" +
"if not c_s or tonumber(c_s) < tonumber(KEYS[2]) then\n" +
"return 0\n" +
"end\n" +
"redis.call('decrby',KEYS[1], KEYS[2])\n" +
"return 1";

@PostConstruct
public void init(){
try (Jedis jedis = jedisPool.getResource()) {
String sha1 = jedis.scriptLoad(STORE_DEDUCTION_SCRIPT_LUA);
logger.error("生成的sha1:" + sha1);
STORE_DEDUCTION_SCRIPT_SHA1 = sha1;
}
}

/**
* 调用Lua脚本,不需要每次都传入Lua脚本,只需要传入预编译返回的sha1即可
* String-evalsha
* @param key
*/
public Long evalsha(String key,String buyNum){
try (Jedis jedis = jedisPool.getResource()) {
Object obj = jedis.evalsha(STORE_DEDUCTION_SCRIPT_SHA1,2,key,buyNum);
//脚本中返回的结果是0或1,表示失败或者成功
return (Long)obj;
}
}
}

硬件的优化

对于硬件层面的优化,我们也准备从以下几个方面进行优化。

CPU 模式的优化

所谓 CPU 模式的调整,就是调整 CPU 的工作频率,使其呈现出不同的性能表现,以满足特定的业务使用场景。

因为考虑到业务的特殊性,基本是秒杀场景,所以可以让CPU 一直处于超频状态,当然这种状态也是比较耗电的,但是为了更好地开展活动,还是需要打开的。

网卡中断优化

中断其实就是硬件和CPU交换的基本方式的,硬件是通过中断信号,陷入内核态,去让CPU执行相关指令。

而秒杀的瞬时流量非常高,带来的问题就是一下子会有非常多的网络请求进来。网卡在收到网络信号后,会通知 CPU 来处理,这时如果我们没有调整过相关配置,那么很有可能处理网卡中断的 CPU 都集中在一个核上。

如果这个时候该 CPU 也在承担处理应用进程的任务,那么就有可能出现单核 CPU 飙升的问题,同时网络数据的处理也会受到影响,导致大量 TCP 重传现象的发生。所以这个时候,我们要做的就是合理分配多核 CPU 资源,专门拿出一个核来处理网卡中断。

注意这里的区别:调整前,是某一个核既要执行其他相关指令操作,也要执行网卡中断操作;而调整后,某一个核只执行网卡中断操作。

对于如何执行这一操作,可以参考我的另一篇文章 —— linux将网卡的-IRQ-与一个特定的-CPU-核进行绑定

JVM层面的优化

常见的垃圾回收器有以下几种:

  • Parallel GC

Parallel GC 是一种基于标记-清除算法的并行垃圾回收器,它使用多个线程来加速垃圾回收过程。Parallel GC 通过标记所有活动对象,然后清除未被标记的对象来实现垃圾回收。Parallel GC 的主要优点是速度快,能够处理大量的垃圾数据。但是,它也有一些缺点,例如会导致长时间的停顿时间,并且会浪费一定的内存空间。

  • G1

G1 是一种全新的垃圾回收器,它使用分代算法和复制算法来管理内存空间。G1 可以将整个堆空间分为多个区域,并按需进行回收。G1 的主要优点是可预测性好,并且可以避免长时间的停顿时间。同时,G1 还使用了一些优化技术来提高回收效率和减少内存浪费。但是,G1 的启动时间较长,并且可能会导致一定的系统开销。

  • CMS

CMS 是针对低延迟垃圾回收而设计的一种垃圾回收器,它使用标记-清除算法和并发性算法来管理内存空间。CMS 的主要优点是能够实现低延迟垃圾回收,并且减少长时间的停顿时间。但是,CMS 也有一些缺点,例如需要大量的 CPU 资源,并且可能会导致碎片化问题。

对于垃圾回收器的选择,是需要分业务场景的。

如果我们提供的服务对响应时间敏感,并且堆内存能够给到 8G 以上的,那可以选择 G1;堆内存较小或 JDK 版本较低的,可以选择 CMS。相反如果对响应时间不敏感,追求一定的吞吐量的,则建议选择 ParallelGC,同时这也是 JDK8 的默认垃圾回收器。

综合来看,秒杀的业务场景更适合选择 G1 来做垃圾回收器。

业务思考

在看过很多书,特别是《实现领域驱动设计》后,我非常认同一句话——技术为业务服务,脱离业务的技术,将毫无意义。

其实在本项目中,也有很多值得去思考的业务问题,这里简单总结一下:

  1. 在抢购的过程中,如果用户是因为已经买到了上限被限购拦住了,那我们可以直接文案提示“您的购买数量已经达到上限”,这个文案对用户比较直观也能接受,因为规则就是这样定的。如果用户被识别为流量刷子,被拦截是不能下单的,那我们不能直接提示“您是风险用户,不能购买”。这样的文案会招来用户的投诉,即使他是刷子。因此,这种刷子用户,我们的文案可以直接提示“很抱歉没有抢到,下次再来”。
  2. 系统在运行过程中,也会出现抖动、接口超时以及流量过大被限流的情况,那我们要怎样友好地提示用户呢?在普通售卖场景,我们这样提示“系统开了小差,请你稍后重试”,“当前参与人数多,系统繁忙,请你稍后重试”,一般也不会有太大问题。但是在我们的场景,就可能招来大量投诉,毕竟这是有利可图的东西。所以我们直接返回“很抱歉没有抢到,下次再接再厉”,可能会觉得这样修改文案,对用户不公平,实际上你仔细想想,如果用户被限流,本身就表示他的手速还是慢了,提示“没有抢到”也合理。

1. Redis为什么早期选择单线程?

其实就是历史原因,开发者嫌多线程麻烦,后来这个CPU的利用问题就被抛给了使用者。

Redis 4.0 之后开始变成多线程,除了主线程外,它也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大 Key 的删除等等。

2. Redis6.0使用多线程是怎么回事?

其实Redis在执行命令时,使用的还是单线程;在执行I/O和协议解析等操作时,执行的是多线程。

这是因为Redis的性能瓶颈在网络I/O而非CPU。

Redis6.0多线程

3. Redis持久化方式有哪些?有什么区别?

Redis持久化方式有AOF和RDB。

  • AOF:以独立日志的方式记录每次写命令, 重启时再重新执行AOF文件中的命令达到恢复数据的目的。AOF的主要作用是解决了数据持久化的实时性,目前已经是Redis持久化的主流方式。
  • RDB:RDB方式则是将当前数据快照存储到磁盘,分手动触发和自动触发,RDB文件是一个压缩的二进制文件。

4. RDB 和 AOF 各自有什么优缺点?

RDB:

  • 优点:
    • 数据恢复快
    • 容灾性好,可以将备份数据远程拷贝到其他机器,用于容灾恢复
  • 缺点:
    • 实时性低,RDB并不能做到秒级持久化,而是会间隔一段时间再持久化

AOF:

  • 优点:
    • 实时性好
  • 缺点:
    • AOF文件较大,并且恢复速度慢

5. Redis的主从复制原理了解吗?

  1. 首先在从节点(slave)保存主节点的ip和端口号
  2. 从节点(slave)发现主节点(master)后,向主节点(master)发送ping请求进行首次通信,本次通信是检测主从之间网络套接字是否可用、主节点当前是否可以接受处理命令。
  3. 若主节点要求密码验证,从节点需要发送账号密码用于验证
  4. 建立连接后,主节点会把数据全部发送给从节点
  5. 之后的主节点会持续把写命令发送给从节点,保证数据一致性

6. 主从复制存在哪些问题呢?

主从复制虽好,但也存在一些问题:

  • 一旦主节点出现故障,需要手动将一个从节点晋升为主节点,同时需要修改应用方的主节点地址,还需要命令其他从节点去复制新的主节点,整个过程都需要人工干预。
  • 主节点的写能力受到单机的限制。
  • 主节点的存储能力受到单机的限制。

第一个问题是Redis的高可用问题,第二、三个问题属于Redis的分布式问题。

7. Redis Sentinel(哨兵)了解吗?

主从复制存在一个问题,没法完成自动故障转移。所以我们需要一个方案来完成自动故障转移,它就是Redis Sentinel(哨兵)。

Redis Sentinel ,它由两部分组成,哨兵节点和数据节点:

  • 哨兵节点: 哨兵系统由一个或多个哨兵节点组成,哨兵节点是特殊的 Redis 节点,不存储数据,对数据节点进行监控。
  • 数据节点: 主节点和从节点都是数据节点;
Redis Sentinel

8. 什么是缓存击穿、缓存穿透、缓存雪崩?

  • 缓存击穿:缓存击穿是指访问量很大的某个key突然失效,请求一下全部打到数据库。解决方案如下:
    • 若缓存是基本不更新的,可以设置为永不过期
    • 若缓存中数据经常使用,且为热点数据,那么可以每查询一次便重新更新他的失效时间(我在项目中就是这样做的)
    • 加锁更新,比如请求查询A,发现缓存中没有,对A这个key加锁,同时去数据库查询数据,写入缓存,再返回给用户,这样后面的请求就可以从缓存中拿到数据了(感觉这样性能比较差)
  • 缓存穿透:缓存穿透是指查询的是缓存中和数据库都不存在的数据,这样每次请求都打到数据库上。解决方案如下:
    • 每次都数据库查数据时,如果没查到数据,就写一个空值到缓存中去,再设置一个失效时间,这样再有相同请求走的都是缓存。
  • 缓存雪崩:缓存雪崩是指大量缓存突然失效,造成大量请求打到数据库
    • 事前:Redis 高可用,主从+哨兵,Redis cluster,避免全盘崩溃。
    • 事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。
    • 事后:Redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。

9. 什么是布隆过滤器?

布隆过滤器,它是一个连续的数据结构,每个存储位存储都是一个bit,即0或者1, 来标识数据是否存在。

存储数据的时时候,使用K个不同的哈希函数将这个变量映射为bit列表的的K个点,把它们置为1。

我们判断缓存key是否存在,同样,K个哈希函数,映射到bit列表上的K个点,判断是不是1:

  • 如果全不是1,那么key不存在;
  • 如果都是1,也只是表示key可能存在。

简单说,布隆过滤器就是判断某个数据在不在布隆过滤器内,使用方式如下:

Redis实现布隆过滤器的底层是通过bitmap数据结构。

1
2
3
4
5
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.4</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:26379");
config.useSingleServer().setPassword("myredis");
config.useSingleServer().setDatabase(0);
RedissonClient client = Redisson.create(config);
RBloomFilter<Object> bloomFilter = client.getBloomFilter("bloomnumber");
// 初始化布隆过滤器,设计预计元素数量为1000000L, 误差率为1%
int n = 1000000;
bloomFilter.tryInit(1000000L, 0.01);
for (int i = 0; i < n; i++) {
bloomFilter.add(String.valueOf(i));
}
int count = 0;
for (int i = 0; i < (n*2); i++) {
if (bloomFilter.contains(String.valueOf(i))) {
count++;
}
}
System.out.println("过滤器误判率:" + (count - n)/Double.valueOf(n));
}

布隆过滤器也有一些缺点:

  1. 它在判断元素是否在集合中时是有一定错误几率,因为哈希算法有一定的碰撞的概率。
  2. 不支持删除元素。

在openArt项目中,我们在判断用户短信验证码是否重复验证时使用了。

10. Redis的过期淘汰策略?

Redis的过期淘汰策略是:定时删除 + 惰性删除。

Redis的默认定时100ms拿出一批数据来删除其中过期的数据。

但是如果数据量过大,有10wkey,就不可能每次删除都遍历一遍看是否过期然后删除,那样性能消耗过高。所以Redis执行的是定时随机抽取一批数据,看里面是否有过期的数据,如果过期,便删除。

但是这样会有大量过期的Key没被删除,这样就会走内存淘汰策略

Redis的内存淘汰策略有以下几种:

  • 当内存不足以容纳新写入数据时,新写入操作会报错(很少有人用)
  • 当内存不足以容纳新写入数据时,在键空间内,删除最近最久未使用的key(常用)
  • 当内存不足以容纳新写入数据时,随机干掉某个key(很少有人用)
  • 当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近 最少使用的 key(这个一般不太合适)
  • 在设置了过期时间的键空间中,随机 移除某个 key

11. Redis报内存不足怎么处理?

  • 增加Redis可用内存
  • 修改内存淘汰策略
  • 使用 Redis 集群模式,进行横向扩容

12. 大key问题了解吗?

大key的成因:

  • 单个的key的value很大,超过10kb
  • list、set、zset或hash存储了过多元素

大key会造成什么问题呢?

  • 客户端耗时增加,甚至超时

  • 对大key进行IO操作时,会严重占用带宽和CPU

  • 造成Redis集群中数据倾斜

  • 主动删除、被动删等,可能会导致阻塞

如何找到大key?

  • bigkeys命令:使用bigkeys命令以遍历的方式分析Redis实例中的所有Key,并返回整体统计信息与每个数据类型中Top1的大Key
  • redis-rdb-tools:redis-rdb-tools是由Python写的用来分析Redis的rdb快照文件用的工具,它可以把rdb快照文件生成json文件或者生成报表用来分析Redis的使用详情。

怎么处理大key?

  • 删除大key
    • 当Redis版本大于4.0时,可使用UNLINK命令安全地删除大Key,该命令能够以非阻塞的方式,逐步地清理传入的Key。
    • 当Redis版本小于4.0时,避免使用阻塞式命令KEYS,而是建议通过SCAN命令执行增量迭代扫描key,然后判断进行删除。
  • 压缩和拆分key
    • 当vaule是string时,比较难拆分,则使用序列化、压缩算法将key的大小控制在合理范围内,但是序列化和反序列化都会带来更多时间上的消耗。
    • 当value是string,压缩之后仍然是大key,则需要进行拆分,一个大key分为不同的部分,记录每个部分的key,使用multiget等操作实现事务读取。
    • 当value是list/set等集合类型时,根据预估的数据规模来进行分片,不同的元素计算后分到不同的片。

13. Redis底层数据结构有哪些,编码方式有哪些?

  • 底层数据结构:ZipList、SDS、SkipList、ht(字典)、list、intset(整数集合)
类型-编码-结构

14. 假如Redis里面有1亿个key,其中有10w个key是以某个固定的已知的前缀开头的,如何将它们全部找出来?

使用 keys 指令可以扫出指定模式的 key 列表。但是要注意 keys 指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用 scan 指令,scan 指令可以无阻塞的提取出指定模式的 key 列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用 keys 指令长。

1. MyBatis使用过程?生命周期?

  • 1)创建SqlSessionFactory

可以从配置或者直接编码来创建SqlSessionFactory

1
2
3
String resource = "org/mybatis/example/mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
  • 2)通过SqlSessionFactory创建SqlSession

SqlSession(会话)可以理解为程序和数据库之间的桥梁

1
SqlSession session = sqlSessionFactory.openSession();
  • 3)通过sqlsession执行数据库操作

可以通过 SqlSession 实例来直接执行已映射的 SQL 语句:

1
Blog blog = (Blog)session.selectOne("org.mybatis.example.BlogMapper.selectBlog", 101);

更常用的方式是先获取Mapper(映射),然后再执行SQL语句:

1
2
BlogMapper mapper = session.getMapper(BlogMapper.class);
Blog blog = mapper.selectBlog(101);
  • 4)调用session.commit()提交事务

如果是更新、删除语句,我们还需要提交一下事务。

  • 5)调用session.close()关闭会话
Mybatis基本使用步骤

MyBatis生命周期:上面提到了几个MyBatis的组件,一般说的MyBatis生命周期就是这些组件的生命周期

  • SqlSessionBuilder:生命周期是方法级的,因为创建完成SqlSeesionFactory后就不需要了
  • SqlSeesionFactory:生命周期是贯穿整个应用生命周期的,相当于数据库连接池,并且单例
  • SqlSession:非线程安全,不能共享,所以最佳生命周期是一次请求或一个方法
  • Mapper:映射器接口的实例是从 SqlSession 中获得的,它的生命周期在sqlsession事务方法之内,一般会控制在方法级。

2. #{}和${}的区别

#{} 匹配的是一个占位符,相当于 JDBC 中的一个?,会对一些敏感字符进行过滤,编译过后会对传递的值加上双引号,因此可以防止 SQL 注入问题。

${} 匹配的是真实传递的值,传递过后,会与 SQL 语句进行字符串拼接。${} 会与其他 SQL 进行字符串拼接,无法防止 SQL 注入问题。

  • #{}会进行预编译处理,${}是拼接符,字符串替换,不进行预编译处理

  • #{}可以防止Sql注入

  • #{}替换在DBMS内,${}替换在DBMS外

  • Mybatis在处理#{}时,#{}传入参数是以字符串传入,会将SQL中的#{}替换为?号,调用PreparedStatement的set方法来赋值。

3. MyBatis是否支持延迟加载?原理?

支持,并且Hibernate等也支持,原理如下:

首先Cglib生成目标对象的代理对象,当调用目标方法时进入拦截,比如调用的是A.getB().getName,拦截器如果发现A.getB()为空,则会首先保存好需要B对象才能查询的Sql,然后等查询到B后,调用 A.setB(b),将其注入A中,此时B不为空,再执行A.getB().getName。这就是延迟加载的基本原理。

4. 说说Mybatis的一级、二级缓存?

  • 一级缓存是 SqlSession 级别的缓存。在操作数据库时需要构造 SqlSession 对象,在对象中有一个数据结构(HashMap)用于存储缓存数据。不同的是 SqlSession 之间的缓存数据区(HashMap)是互相不影响,MyBatis默认开启一级缓存。
  • 二级缓存是 Mapper 级别的缓存,多个 SqlSession 去操作同一个 Mapper 的 sql 语句,多个 SqlSession 可以共用二级缓存,二级缓存是跨 SqlSession 的。

5. 能说说MyBatis的工作原理吗?

  1. 读取 MyBatis 配置文件——mybatis-config.xml 、加载映射文件——映射文件即 SQL 映射文件,文件中配置了操作数据库的 SQL 语句。最后生成一个配置对象。
  2. 构造会话工厂:通过 MyBatis 的环境等配置信息构建会话工厂 SqlSessionFactory。
  3. 创建会话对象:由会话工厂创建 SqlSession 对象,该对象中包含了执行 SQL 语句的所有方法。
  4. Executor 执行器:MyBatis 底层定义了一个 Executor 接口来操作数据库,它将根据 SqlSession 传递的参数动态地生成需要执行的 SQL 语句,同时负责查询缓存的维护。
  5. StatementHandler:数据库会话器,串联起参数映射的处理和运行结果映射的处理。
  6. 参数处理:对输入参数的类型进行处理,并预编译。
  7. 结果处理:对返回结果的类型进行处理,根据对象映射规则,返回相应的对象

6. PageHelper分页原理?

https://blog.csdn.net/a578977626/article/details/122355025

1. 从浏览器地址栏输入 url 到显示主页的过程?

  1. DNS解析。首先将域名发到DNS服务器,解析获取IP地址。
  2. TCP连接。与服务器进行三次TCP握手,获取连接。
  3. 向目标服务器发起HTTP请求。
  4. 目标服务器处理HTTP请求,返回响应内容。
  5. 拿到响应内容后,浏览器解析响应内容渲染到网页上。
  6. TCP四次回收,结束连接。

2. 说说DNS解析的过程?

假如要查询www.baidu.com的IP地址:

  1. 首先主机向本地DNS服务器发起查询,假如本地DNS服务器有缓存,则之间返回,否则进行下一步。
  2. 主机向根服务器发起请求,根服务器返回所有带com顶级域名服务器的IP地址列表
  3. 本地DNS服务器再向一个负责com的顶级域名服务器发送请求,返回负责baidu.com权限域名服务器的IP地址列表
  4. 本地DNS服务器再向一个负责 baidu.com 的权限域名服务器发送请求,返回 www.baidu.com 对应的IP地址。

3. WebSocket和Socket的区别?

  • Socket相当于IP+端口+协议,屏蔽了网络的细节,是一个接口。
  • 而WebSocket则是解决HTTP不能持久化连接的问题,用于通信层,是一个通信协议。

4. Get和Post请求的区别?

Get能被CDN缓存,Post不会。

Get具有幂等性,更安全。

Get有长度限制,Post没有。

5. URL和URI的区别?

  • URI:统一资源定位符。URI的重点是在于标识资源,它其实是一个抽象定义,无论用什么方式来实现,只要可以唯一标识一个资源,都可以叫做URI。
  • URL:它是 URI 的一种子集,主要作用是提供资源的路径。

它们的主要区别在于,URL 除了提供了资源的标识,还提供了资源访问的方式。这么比喻,URI 像是身份证,可以唯一标识一个人,而 URL 更像一个住址,可以通过 URL 找到这个人——人类住址协议://地球/中国/北京市/海淀区/xx 职业技术学院/14 号宿舍楼/525 号寝/张三.男。

6. 说下 HTTP/1.0,1.1,2.0 以及HTTP3的区别?

关键需要记住 HTTP/1.0 默认是短连接,可以强制开启,HTTP/1.1 默认长连接,HTTP/2.0 采用多路复用

  • HTTP/1.0:
  • 默认短连接,可以开启长连接。
  • HTTP 1.1:
  • 引入了持久化连接,即 TCP 连接默认不关闭,可以被多个请求复用。
  • 分块传输编码,即服务端每产生一块数据,就发送一块,用” 流模式” 取代” 缓存模式”。
  • 管道机制,即在同一个 TCP 连接里面,客户端可以同时发送多个请求。
  • HTTP 2.0:
  • 报头压缩,每次请求都必须附上所有信息。HTTP/2.0 引入了头信息压缩机制,使用 gzip 或 compress 压缩后再发送
  • 完全多路复用,客户端和服务端都可以同时发送多个请求,而且不用按照顺序一一对应
  • 服务端推送,运行服务端不经请求,主动向客户端发送消息
  • HTTP 3:
    • 使用UDP传输,并且使用QUIC协议保证安全性,在传输的过程中就完成了 TLS 加密握手
    • HTTPS 要建立⼀个连接,要花费 6 次交互,先是建立三次握手,然后是 TLS/1.3 的三次握手。QUIC 直接把以往的 TCP 和 TLS/1.3 的 6 次交互合并成了 3 次,减少了交互次数。
    • QUIC 有自己的⼀套机制可以保证传输的可靠性的。当某个流发生丢包时,只会阻塞这个流,其他流不会受到影响。
HTTP 协议变迁

7. HTTPS 工作流程是怎样的?

  1. 客户端向服务端发起HTTPS请求,连接到服务端的443端口
  2. 服务端收到请求后,将证书发送给客户端(公钥在证书中,私钥服务端持有)
  3. 客户端收到后,会验证证书的合法性。验证通过后,生成一个随机对称密匙,使用证书中的公钥进行加密,加密后发送给服务端
  4. 服务端收到后,使用自己的私钥进行非对称解密,解密后得到客户端的密匙,这样用客户端密匙对称加密传输数据,传输的数据就是密文
  5. 服务端收到客户端用密文加密过的数据后,再使用自己的密匙解密就得到了数据

8. 客户端怎么去校验证书的合法性?

CA证书的形成过程如下:

  • 在CA证书的签发过程中给,客户端会将密匙、有效时间、用途、颁发者等信息打成一个包,然后将这些信息进行Hash计算,得到一个Hash值。
  • 然后使用自己的私匙对这些信息加密,加密后生成 Certificate Signature,也就是对证书做了签名,
  • 然后将其添加在证书上,形成了数字证书。

客户端校验服务端的数字证书的过程如下:

客户端首先会根据相同的Hash算法获取该证书的Hash值H1

浏览器使用自身集成的CA证书的公钥信息,解密证书,得到H2

比较H1和H2就可以知道证书是否合法

证书签名和客户端校验-来源参考

8. 对称加密和非对称加密?

密匙不同:对称加密加密和解密用的都是一个密匙,非对称加密加密和解密的密匙不一样

安全性不同:对称加密加密解密密匙都是同一个,安全性不够。而非对称加密有公匙和私匙,公匙是可以给别人看的,私匙是对方持有用来解密的。

数字签名不同:对称加密不可以用于数字签名和数字鉴别,非对称加密可以用于数字签名和数字鉴别

9. MD5属于那种加密算法?

MD5加密既不是对称加密,也不是非对称加密。

MD5具有计算速度快,加密速度快,不需要密匙等优点。可以检查文件的完整性,一旦文件被更改,MD5值会发生改变。

但是MD5也有缺点,比如很多公司数据库存放的密码都是MD5加密后的,假如密码较为简单,攻击者会将一些常见的密码进行MD5计算,然后与数据库中的MD5比较,相同便获取了密码。

***10. TCP的三次握手?

最开始,客户端和服务端都处于close状态,服务端监听客户端请求,服务端进入 LISTEN 状态

首先,客户端向服务端发送一个请求,包含随机生成初始序号(称为seq = client_isn)和SYN = 1,客户端进入SYN_SENT状态

服务端接收到后,向客户端答复SYN=1,服务端随机初始序号(称为seq = server_isn)和 ack = client_isn + 1,服务端进入SYN_RCV 状态

客户端收到服务端答复后,再向服务端发送 SYN = 0,ack = server_isn +1 和 seq = client_isn + 1 ,两边都进入ESTABLISHED 状态

11. TCP 握手为什么是三次,为什么不能是两次?不能是四次?

  • 为了防止服务端产生不必要的连接开销:假如客户端向服务端发送请求,服务端返回的一端信息丢失,客户端收不到自然也没有响应不会继续发送消息,这样服务端会一直保持连接,浪费开销
  • 同步初始化序列化:在连接中有一个seq序号是为了保证连接的稳定性,他能告诉服务端和客户端他们发的消息是否收到了,如果是两次连接,将不能保证。

TCP三次连接已经够了,四次只会产生不必要的开销。

12. 三次握手中各次没收到报文会发生什么情况?

  • 第一次服务端没收到客户端消息:服务端将不会有任何动作;客户端会重新发送SYN报文,如果超出次数限制,将会返回建立连接失败。
  • 第二次客户端没收到服务端消息:客户端将重新发送消息,直到超出次数限制;服务端将阻塞在 accept()处,等待客户端的消息。
  • 第三次服务端没收到客户端消息:服务端会重传消息,直到超出次数限制,超出后accept()将返回-1;而客户端会误以为建立了连接,会向服务端发送消息,但是服务端收到数据时会返回RST 报文给客户端,消除客户端单方面建立连接的状态

13. 第二次握手传回了 ACK,为什么还要传回 SYN?

第二次握手是服务器端收到SYN报文后,回复一个ACK报文,表示服务器端已经收到了客户端的请求。但是这时服务器端还需要向客户端发送一个SYN报文,用来表示服务器端也想要与客户端建立连接。

14. 第 3 次握手可以携带数据吗?

可以携带。此时客户端已经处于 ESTABLISHED 状态。对于客户端来说,它已经建立连接成功,并且确认服务端的接收和发送能力是正常的。

第一次握手不能携带数据是出于安全的考虑,因为如果允许携带数据,攻击者每次在 SYN 报文中携带大量数据,就会导致服务端消耗更多的时间和空间去处理这些报文,会造成 CPU 和内存的消耗。

15. 什么是半连接队列?

服务端会从 CLOSED 状态变为 LISTEN 状态,同时在内部创建了两个队列:半连接队列(SYN 队列)和全连接队列(ACCEPT 队列)。

顾名思义,半连接队列存放的是三次握手未完成的连接,全连接队列存放的是完成三次握手的连接。

***16. TCP的四次挥手?

数据传输结束后,客户端和服务端都可以主动发起断开请求,假如客户端先发起:

  • 第一次挥手客户端发送FIN=1,seq=u,发送后客户端进入FIN_WAIT_1状态
  • 第二次挥手服务端发送ack=u+1,seq=v,发送后服务端进入CLOSE_WAIT状态,客户端进入FIN_EAIT_2状态
  • 第三次挥手,服务端发送ack=u+1,seq=w,发送后服务端进入LAST_ACK状态
  • 第四次挥手,客户端发送ack=w+1,seq=u+1,发送后进入客户端TIME_WAIT状态,等待固定的一段时间(2MSL2 Maximum Segment Lifetime)后没有收到服务端的ACK后,认为服务器已经关闭,进入CLOSE状态,服务端收到后也进入CLOSE状态

17. TCP 挥手为什么需要四次呢?

  • 在关闭连接时,客户端向服务端发送 FIN 时,表示客户端不再向服务端发送消息,但是还可以接收消息
  • 当服务端收到后,发送 ACK 表示收到消息了,而此时服务端可能还有消息需要处理,等到处理完成后再发送 FIN 表示也不再发送消息了

因此,主要是因为在服务端收到消息后,可能还有消息需要处理,因此先答复收到了,等处理完成再答复可以关闭连接。

18. TCP 四次挥手过程中,为什么需要等待 2MSL, 才进入 CLOSED 关闭状态?

为什么要等待:为了保证客户端发送的最后一个 ACK 报文段能够到达服务端。

因为在发送之后,可能因为网络等原因服务端收不到客户端的最后一次确认。而服务端则会超时重传,这样就需要客户等待后,再进入CLOSE状态。如果在这里时间内收到了服务端的消息,则需要重新发送消息给服务端通知其进入CLOSE状态。

为什么是2MSL: MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。

为什么时2MSL比较靠谱的说法是客户端发送最后一次消息时,如果出现丢包等情况,服务端会超时重试,超时重试的消息也要发送给客户端,这样一来一回就是2MSL最多(而超时重传的时间则和客户端发送最后一次消息时间重合)。

***19. TCP 是如何保证可靠性的?

TCP通过拥塞控制、超时重传、确认应答、序列号、流量控制、校验和、连接管理、最大消息长度 保证可靠性

  • 连接管理:即TCP三次握手和四次挥手
  • 拥塞控制:在通信过程中,会先发送一部分消息出去“探路”,根据当前网络状况决定以多大速率发送数据,这也被称为慢启动机制
  • 超时重传:当接收方长时间无应答时,发送方会启动超时重传
  • 序列号/确认应答:当接收方收到消息后,会给发送方发送确认消息,一般是根据序列号应答
  • 流量控制:在接收方和发送方首部都有一块缓冲区,如果数据传输速率过大,接收方将提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议。
  • 校验和:TCP传输过程中,如果数据又变化,校验和将改变,接收方将丢弃或拒收该消息
  • 最大消息长度:在建立 TCP 连接的时候,双方约定一个最大的长度(MSS)作为发送的单位,重传的时候也是以这个单位来进行重传。理想的情况下是该长度的数据刚好不被网络层分块。

20. 说说 TCP 的粘包和拆包?

TCP传输过程中包大小会根据缓冲区实际情况进行包的划分:

  • 要发送的数据大于TCP发送缓冲区剩余大小,将拆包
  • 要发送的数据小于TCP缓冲区大小,多条数据将一起发送,发生粘包
  • 要发送的数据大于 最大报文长度,将发生拆包
  • 接收端的应用层,没有及时读取接收缓冲区中的数据,将发生粘包

21. TCP和UDP的区别?

类型 TCP UDP
是否面向连接
传输形式 字节流 数据段报文
应用场景 文件传输、邮件传输 即时通讯、语音通话
首部字节 20-60 8
可靠性 可靠 不可靠
所需资源
传输速率

22. UDP 协议为什么不可靠?

UDP 在传输数据之前不需要先建立连接,远地主机的运输层在接收到 UDP 报文后,不需要确认,提供不可靠交付。总结就以下四点:

  • 不保证消息交付
  • 不保证交付顺序
  • 不提供拥塞控制
  • 不跟踪连接状态

23. DNS使用的是什么协议?

DNS使用的既有TCP协议,也有UDP协议。

当进行区域传输(主域名服务器到辅助域名服务器)时,采用TCP保证可靠性。

当客户端使用DNS服务器查询域名时,使用的是UDP。

24. IP协议有哪些作用?

寻址和路由:在IP数据报中包含了源地址和目标地址。IP数据报在传输的过程中,如果中间节点碰到路由器,则会会根据路由器中的路由表进行数据转发,会提供最合适的路径。

分段和重组:IP数据报在传输过程中,可能经过不同的网络,不同的网络对传输的数据报最大长度大小限制是不一样的,IP协议会给每段被拆分的数据报分配一个标识符用于后续组装,等传送到目的地址,在根据这些标识符对IP数据报进行重组,得到完整的数据报。

1. 什么是僵尸进程?

一般在有父子关系的进程中会存在。在子进程退出后,如果父进程没有调用wait()等方式,子进程将不会主动退出,从而成为僵尸进程。

2. 什么是孤儿进程?

当父进程退出后,子进程还没有退出,这些子进程会成为孤儿进程。此时这些子进程将过继给init根进程,并由 init 进程对它们完成状态收集工作。

3. 进程有哪些调度算法?

  • 先来先服务算法
  • 优先级调度算法
  • 时间片轮转算法
  • 短作业优先算法
  • 最短剩余时间优先算法

4. 进程有哪些通信方式?

  • 管道所谓的管道就是内核中的一串缓存,从管道的一端写入数据,就是缓存在了内核里,另一端读取,也是从内核中读取这段数据。管道可以分为两类:匿名管道命名管道。匿名管道是单向的,只能在有亲缘关系的进程间通信;命名管道是双向的,可以实现本机任意两个进程通信。
  • 消息队列消息队列就是保存在内核中的消息链表,包括Posix消息队列和System V消息队列。
  • 共享内存就是拿出⼀块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写⼊的东西,另外的进程⻢上就能看到。共享内存是最快的 IPC 方式,
  • 信号量它本质上是一个整数计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。
  • 信号:信号可以理解成一种电报,发送方发送内容,指定接收进程,然后发出特定的软件中断,操作系统接到中断请求后,找到接收进程,通知接收进程处理信号。比如kill -9 1050就是一种信号。
  • socket:与其他通信机制不同的是,它可用于不同机器间的进程通信。

5. 线程有哪些实现方式?

内核态线程实现⽤户态线程实现混合线程实现

现代操作系统基本都是将两种方式结合起来使用。用户态的执行系统负责进程内部线程在非阻塞时的切换;内核态的操作系统负责阻塞线程的切换。即我们同时实现内核态和用户态线程管理。其中内核态线程数量较少,而用户态线程数量较多。每个内核态线程可以服务一个或多个用户态线程。

6. 什么是快表?

同样利用了局部性原理,即在⼀段时间内,整个程序的执行仅限于程序中的某⼀部分。相应地,执行所访问的存储空间也局限于某个内存区域。

利用这⼀特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了⼀个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。

7. 页面置换算法有哪些?

  • 先进先出置换算法:
  • 最佳页面置换算法:
  • 最近最久未使用置换算法:
  • 时钟页面置换算法:这个算法的思路是,把所有的页面都保存在⼀个类似钟面的环形链表中,⼀个表针指向最老的页面。当发生缺页中断时,算法首先检查表针指向的页面:如果它的访问位位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移⼀个位置;如果访问位是 1 就清除访问位,并把表针前移⼀个位置,重复这个过程直到找到了⼀个访问位为 0 的页面为止;
时钟页面置换算法
  • 最近未使用置换算法:

8. 硬链接和软链接有什么区别?

硬链接与原文件在磁盘上实际上指向同一个数据块,因此修改其中一个文件,另一个文件也会被修改。而软链接只是一个指向原文件的路径名,因此修改原文件不会影响软链接,软链接也可以指向其他路径的文件。

9. 介绍一下零拷贝技术?

在数据传输过程中,普通拷贝一般有四步,涉及到四次用户态和内核态之间的切换,开销较大。

所以为了减少用户态和核心态之间的切换以及内存拷贝的次数,使用了零拷贝技术。

传统文件传输示意图-来源参考[3]
  • mmap + write

本技术是将内核缓冲区里的数据“映射”到用户空间,这样,操作系统内核和用户空间就不需要再进行任何数据拷贝。

mmap示意图-来源参考[3]
  • sendfile

在 Linux 内核版本 2.1 中,提供了⼀个专门发送文件的系统调⽤函数 sendfile() 。用这个函数代替read() 和 write() 两个函数,可以减少一次系统调用,也就是减少了两次上下文切换开销。

另外,可以把内核缓冲区内的数据直接拷贝到socket缓冲区,而不是先拷贝到用户态中。这样总共就只有两次线程上下文切换的开销。

sendfile示意图-来源参考[3]

在Kafka和Rocket中,都有这些零拷贝技术的运用。

10. 讲讲几种I/O模型?

I/O是面向流的,NIO是面向缓冲区的

内核利用文件描述符来访问文件。文件描述符是非负整数。打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。

  • 阻塞I/O:阻塞I/O模型是最常见的I/O模型,它的特点是当应用程序进行I/O操作时,操作系统会一直等待I/O操作完成,直到返回结果,期间应用程序会被阻塞,无法进行其他操作。这种模型在面对高并发和大量I/O操作时可能导致性能下降。
阻塞I/O
  • 非阻塞I/O:非阻塞I/O模型是一种优化阻塞I/O的方式,它的特点是当应用程序进行I/O操作时,操作系统不会一直等待I/O操作完成,而是立即返回一个错误码,表示操作正在进行中。这样应用程序可以不停地进行轮询,直到I/O操作完成。这种模型可以提高应用程序的响应速度和吞吐量。(多个调用可以同时执行,没好的话只是都返回错误码,提高吞吐量)
非阻塞I/O
  • 基于非阻塞的I/O复用:基于非阻塞I/O的多路复用是一种更高效的I/O模型,它的特点是通过一个系统调用同时监听多个文件描述符的读写状态,一旦有数据可读或可写,就会通知应用程序进行处理。这种模型避免了不必要的轮询,提高了应用程序的效率和性能。
基于非阻塞的I/O多路复用

无论是阻塞 I/O、还是非阻塞 I/O、非阻塞I/O多路复用,都是同步调用。因为它们在read调用时,内核将数据从内核空间拷贝到应用程序空间,过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read调用就会在这个同步过程中等待比较长的时间。

  • 异步I/O:真正的异步 I/O内核数据准备好数据从内核态拷贝到⽤户态这两个过程都不用等待。

    发起 aio_read 之后,就立即返回,内核自动将数据从内核空间拷贝到应用程序空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。

异步/IO

11. 详细讲讲I/O多路复用?

I/O多路复用,指的是一个进程维护多个socket,也就是多个连接复用一个进程/线程。

I/O多路复用有三种实现机制

  • select

select执行过程如下:

假如进程A启动时,要监听的socket三个文件描述符为3、4、5,此时网卡还没有接收到有数据到达,进程A便会让出CPU,阻塞线程。此时select会将三个socket连接从用户态拷贝进内核态的等待队列。

等到有数据到达时,网卡通过中断信号告诉CPU,执行中断程序,中断程序做了以下几件事:

  1. 将数据写入到对应的socket数据连接
  2. 唤醒进程A,重新放入CPU的运行队列

数据到达后,select执行结束,文件描述符集合拷贝到用户态。

深入理解NIO多路复用_多路复用_35

select缺点如下:

  1. 开销过大。刚开始时select会将文件描述符集从用户态拷贝到内核态,收到数据,select执行完成后又会将其从内核态拷贝到用户态。有两次用户态和内核态之间的上下文切换。(epoll优化为不拷贝)
  2. 每次调用select都需要在内核遍历传递进来的所有fd_set。进程被唤醒后,不知道哪些连接已就绪(收到了数据),需要遍历文件描述符集。(epoll优化为异步事件通知)
  3. select只返回就绪文件的个数,具体哪个可读还要遍历。(epoll优化为返回就绪的文件描述符)
  4. 同时能够监听的连接数太少,在编译内核时就确定并且无法修改,一般是 32 位操作系统是 1024,64 位是 2048。(poll、epoll 优化为适应链表方式)
  • poll

poll主要解决的就是文件描述符集合数量限制的问题,它采用链表进行存储。

但是在使用过程中还是需要遍历整个链表获取哪些socket可读以及哪些socket就绪,时间复杂度太高。

  • epoll

epoll解决了select的“性能开销大”和“文件描述符数量少”这两个缺点,是性能最高的多路复用实现方式,能支持的并发量也是最大。解决的方法具体如下:

通过维护一个红黑树结构存储所有待检测的文件描述符,在每次有新的socket连接时,通过函数调用将其加入红黑树内。而不是像select/poll一样每次都传入一个集合,减少了用户空间和内核之间的大量数据拷贝和内存分配。

epoll 使用事件驱动的机制,在内核态维护一个链表记录就绪事件,当socket有事件发生时,通过回调函数,内核会将其加入就绪事件列表。而不是进程唤醒后,每次去遍历去找哪个可读,然后标记。

当用户态需要时会调用epoll_wait()函数,会返回有事件发生的文件描述符。而不是只返回就绪文件个数,然后一个个去遍历找哪个可读。

假设一个 work process 处理了 1000 个连接,但其中只有 10 个 IO 完成了,并可以继续往下执行,select/poll 的做法是遍历这 1000 个 FD(File Description,可以理解成每个建立了连接的一个标识),找到那 10 个就绪状态的,并把没做完的事情继续做完,这样检索的效率明显很低。所以 epoll 的做法是当这 10 个 IO 准备就绪时,通过系统的回调函数将 FD 放到一个专门的就绪列表中,这样系统只需要去找这个就绪列表就可以了,这大大提高了系统的响应效率。

深入理解NIO多路复用_多路复用_37

可以看讲的较为细的文章:https://mp.weixin.qq.com/s/5xj42JPKG8o5T7hjXIKywg

1. Spring中用到了哪些设计模式?

  • 工厂模式:使用工程模式通过BeanFactory和ApplicationContext创建对象。
  • 单例模式:Spring的容器中Bean默认都是单例的。
  • 策略模式:继承自Resource下的有不同的实现类,会根据不同实现类访问资源。

ByteArrayResource、ClassPathResource、FileSystemResource、UrlResource、InputStreamResource

  • 观察者模式:Spring的事件驱动模型使用的是观察者模式

Spring的事件驱动模型是基于观察者设计模式实现的。在该模型中,事件源(如应用程序、框架或第三方库)生成事件,事件由事件监听器(观察者)处理。Spring的事件机制包括一个事件发布者(ApplicationEventPublisher)和一个事件监听器(ApplicationListener),发布者负责发布事件,监听器负责处理事件。通过这种模型,Spring框架实现了松耦合、可扩展的应用程序架构。

  • 代理模式:Spring的AOP就是通过代理实现,分为动态代理和静态代理
  • 适配器模式:Spring AOP 的增强或通知 (Advice) 使用到了适配器模式、Spring MVC 中也是用到了适配器模式适配 Controller

2. FactoryBean与BeanFactory的区别

  1. BeanFactory是Spring框架最基本的接口,它是一个工厂,负责创建和管理Bean实例。FactoryBean是BeanFactory的一个扩展接口,它允许开发人员自定义Bean对象的创建过程。
  2. BeanFactory接口是Spring容器的核心,负责管理Bean对象的生命周期和依赖关系。而FactoryBean接口则是在BeanFactory接口的基础上提供了更多的灵活性,可以通过FactoryBean接口来实现AOP代理、动态代理等高级功能。
  3. 当Spring容器需要创建一个Bean对象时,它首先会检查这个Bean是否是一个FactoryBean。如果是,Spring容器会调用FactoryBean的getObject()方法来获取Bean对象实例,而不是直接调用Bean对象的构造方法或工厂方法。

下面截取了两者的部分代码用于分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface BeanFactory {
String FACTORY_BEAN_PREFIX = "&";
Object getBean(String var1) throws BeansException;
<T> T getBean(String var1, Class<T> var2) throws BeansException;
<T> ObjectProvider<T> getBeanProvider(Class<T> var1);
boolean containsBean(String var1);
boolean isSingleton(String var1) throws NoSuchBeanDefinitionException;
boolean isPrototype(String var1) throws NoSuchBeanDefinitionException;
boolean isTypeMatch(String var1, ResolvableType var2) throws NoSuchBeanDefinitionException;
@Nullable
Class<?> getType(String var1) throws NoSuchBeanDefinitionException;
String[] getAliases(String var1);
}
1
2
3
4
5
6
7
8
9
10
public interface FactoryBean<T> {
String OBJECT_TYPE_ATTRIBUTE = "factoryBeanObjectType";
@Nullable
T getObject() throws Exception;
@Nullable
Class<?> getObjectType();
default boolean isSingleton() {
return true;
}
}

其中FactoryBean的getObject()方法会返回该FactoryBean“生产”的对象实例,简单说就是有些对象实例化逻辑比较复杂,而我们又想自己实现的时候,就可以继承该接口并且重写getObject()方法。

3. 为什么要FactoryBean

当某些对象的实例化过程过于烦琐,通过XML配置过于复杂,使我们宁愿使用Java代码来完成这个实例化过程的时候,或者,某些第三方库不能直接注册到Spring容器的时候,就可以实现。org.springframework.beans.factory.FactoryBean接口,给出自己的对象实例化逻辑代码。当然,不使用FactoryBean,而像通常那样实现自定义的工厂方法类也是可以的。

这样说可能不好理解,我们来看一个例子:

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
/**
* Bean
*/
public class Mapper {
private Integer id;
public Mapper(Integer id) {
this.id = id;
}
public Integer getId() {
return id;
}
}

public class MapperFactoryBean implements FactoryBean<Mapper> {
private Integer id;
private Mapper mapper;
public void setId(Integer id) {
this.id = id;
}
@Override
public Mapper getObject() {
if (mapper == null) {
mapper = new Mapper(id);
}
return mapper;
}
// 这里是getObjectType() 和 isSingleton() 实现
}
1
2
3
<bean id="mapper" class="com.wangtao.spring.bean.MapperFactoryBean">
<property name="id" value="1"/>
</bean>
1
2
3
4
5
6
7
8
9
10
public class BaseTest {
@Test
public void application() {
ApplicationContext context = new ClassPathXmlApplicationContext("spring.xml");
// 下面这句将抛出异常
// MapperFactoryBean mapper = context.getBean("mapper", MapperFactoryBean.class);
Mapper mapper = context.getBean("mapper", Mapper.class);
Assert.assertEquals(1, mapper.getId().intValue());
}
}

从测试结果中得知,我们虽然配置的是MapperFactoryBean的实例,但是根据id拿到的是getObject方法创建的对象。其实在容器中创建的对象仍然是MapperFactoryBean的实例,只是在获取的时候会判断这个结果对象是不是派生于FactoryBean,如果是的话则返回getObject方法创建的对象,并且这个对象并不是容器初始化时创建的,而是使用context.getBean()方法时才创建。

如果想要获取FactoryBean实例,需要这样写:

MapperFactoryBean mapper = context.getBean("&mapper", MapperFactoryBean.class)

即在bean的名字ID前加上&符号。

4. 能简单说一下 Spring IOC 的实现机制吗?

简单的Spring IOC流程大致如下:

mini版本Spring IOC

5. 说说 BeanFactory 和 ApplicationContext?

简单说,ApplicantContext在BeanFactory的基础上,提供了很多扩展。

  • BeanFactory(Bean 工厂)是 Spring 框架的基础设施,面向 Spring 本身。
  • ApplicantContext(应用上下文)建立在 BeanFactory 基础上,面向使用 Spring 框架的开发者

BeanFactory是用于Bean的创建,管理Bean的生命周期等,并提供了从容器中获取Bean的诸多方法。

ApplicationContext除了拥有 BeanFactory支持的所有功能之外,还进一步扩展了基本容器的功能,包括BeanFactoryPostProcessorBeanPostProcessor以及其他特殊类型bean的自动识别、容器启动后bean实例的自动初始化、 国际化的信息支持、容器内事件发布等。

6. BeanPostProcessor是什么?

它是用来拦截所有 bean 的初始化的,在 bean 的初始化之前,和初始化之后做一些事情。这点从 BeanPostProcessor 接口的定义也可以看出来:

1
2
3
4
5
6
7
8
9
10
public interface BeanPostProcessor {
@Nullable
default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
@Nullable
default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
}

7. 你知道 Spring 容器启动阶段会干什么吗?

Spring IOC容器工作过程分为两部分,一个是启动阶段,一个是Bean实例化阶段。

容器启动和Bean实例化阶段

容器启动开始,首先会通过某种途径加载 Congiguration MetaData,在大部分情况下,容器需要依赖某些工具类(BeanDefinitionReader)对加载的 Configuration MetaData 进行解析和分析,并将分析后的信息组为相应的 BeanDefinition。

最后把这些保存了 Bean 定义必要信息的 BeanDefinition,注册到相应的 BeanDefinitionRegistry,这样容器启动就完成了。

8. Spring Bean的生命周期?

实例化——》属性赋值——》初始化——》使用——》销毁

SpringBean生命周期
  • 实例化:图中第一步是实例化Bean,
  • 属性赋值:图中第二步设置对象属性
  • 初始化:5、6 步是真正的初始化,第 3、4 步为在初始化前执行,第 7 步在初始化后执行,初始化完成之后,Bean 就可以被使用了
  • 销毁:第 8~10 步,第 8 步其实也可以算到销毁阶段,但不是真正意义上的销毁,而是先在使用前注册了销毁的相关调用接口,为了后面第 9、10 步真正销毁 Bean 时再执行相应的方法

spring为bean提供了两种初始化bean的方式,实现InitializingBean接口,实现afterPropertiesSet方法,或者在配置文件中同过init-method指定,两种方式可以同时使用。

9. Spring中Bean的作用域有哪些?

  • singleton:单例的,在Spring容器中只存在一个实例,是Bean默认的作用域。
  • prototype:每次创建都会返回新的的实例。

以下三个只存在Web容器中

  • request:每次Http请求都会产生一个Bean,并且只在当前的Http Request中有效
  • session:同一个Http Session共享一个Bean,不同的Http Session使用不同的Bean
  • globalSession:同一个全局 Session 共享一个 Bean,只用于基于 Protlet 的 Web 应用,Spring5 中已经不存在了。

10. Spring 中的单例 Bean 会存在线程安全问题吗?

会存在。即便Spring IoC容器中的Bean是单例的,但是是线程共享的,所以不是线程安全的。

如果要解决单例Bean的线程安全问题,可以将变量保存在ThreadLocal中,实现变量在线程间的隔离。

11. Spring如何解决循环依赖?

最好的方式就是在设计时避免循环依赖。

但是如果有循环依赖,就需要像Spring一样依靠三层缓存区解决。

比如当A和B之间有循环依赖时,大致解决步骤如下:

  1. 首先将A放入三级缓存,表示A要开始实例化了,虽然还不完整,但是此时的目的就是曝光出来让大家知道。
  2. A在属性注入时,发现依赖了B,此时就会去实例化B
  3. 而B在属性注入的过程中,发现又依赖了A,就会去缓存中寻找A,在三级缓存中找到了A,虽然A不完善,但是存在,于是将A放入二级缓存,同时删除三级缓存的A,与此同时B也实例化完成,B放入一级缓存
  4. 接着A继续属性注入赋值,并在一级缓存拿到了B,于是A也进入一级缓存,删除二级缓存的A

这就是为什么Spring能解决Setter注入的循环依赖,因为分为实例化和属性注入几个步骤,简单说就是有一个时间差,所以能用缓存去解决。而构造器就不行了,直接在实例化时就注入了。

详细解答参考(有必要看):https://tobebetterjavaer.com/sidebar/sanfene/spring.html#_16-%E9%82%A3-spring-%E6%80%8E%E4%B9%88%E8%A7%A3%E5%86%B3%E5%BE%AA%E7%8E%AF%E4%BE%9D%E8%B5%96%E7%9A%84%E5%91%A2

详细步骤:

1、getSingleton(“a”, true) 获取 a:会依次从 3 个级别的缓存中找 a,此时 3 个级别的缓存中都没有 a

2、将 a 丢到正在创建的 beanName 列表中(Set singletonsCurrentlyInCreation)

3、实例化 a:A a = new A();这个时候 a 对象是早期的 a,属于半成品

4、将早期的 a 丢到三级缓存中(Map<String, ObjectFactory<?> > singletonFactories)

5、调用 populateBean 方法,注入依赖的对象,发现 setB 需要注入 b

6、调用 getSingleton(“b”, true) 获取 b:会依次从 3 个级别的缓存中找 a,此时 3 个级别的缓存中都没有 b

7、将 b 丢到正在创建的 beanName 列表中

8、实例化 b:B b = new B();这个时候 b 对象是早期的 b,属于半成品

9、将早期的 b 丢到三级缓存中(Map<String, ObjectFactory<?> > singletonFactories)

10、调用 populateBean 方法,注入依赖的对象,发现 setA 需要注入 a

11、调用 getSingleton(“a”, true) 获取 a:此时 a 会从第 3 级缓存中被移到第 2 级缓存,然后将其返回给 b 使用,此时 a 是个半成品(属性还未填充完毕)

12、b 通过 setA 将 11 中获取的 a 注入到 b 中

13、b 被创建完毕,此时 b 会从第 3 级缓存中被移除,然后被丢到 1 级缓存

14、b 返回给 a,然后 b 被通过 A 类中的 setB 注入给 a

15、a 的 populateBean 执行完毕,即:完成属性填充,到此时 a 已经注入到 b 中了

16、调用a= initializeBean("a", a, mbd)对 a 进行处理,这个内部可能对 a 进行改变,有可能导致 a 和原始的 a 不是同一个对象了

17、调用getSingleton("a", false)获取 a,注意这个时候第二个参数是 false,这个参数为 false 的时候,只会从前 2 级缓存中尝试获取 a,而 a 在步骤 11 中已经被丢到了第 2 级缓存中,所以此时这个可以获取到 a,这个 a 已经被注入给 b 了

18、此时判断注入给 b 的 a 和通过initializeBean方法产生的 a 是否是同一个 a,不是同一个,则弹出异常

12. 为什么要三级缓存?二级不行吗?

  • singletonObjects:第一级缓存,里面存放的都是创建好的成品Bean。
  • earlySingletonObjects : 第二级缓存,里面存放的都是半成品的Bean。
  • singletonFactories :第三级缓存, 不同于前两个存的是 Bean对象引用,此缓存存的bean 工厂对象,也就存的是 专门创建Bean的一个工厂对象。此缓存用于解决循环依赖

在Spring中需要三级缓存的主要原因是因为Spring AOP需要生成代理对象。如果不是Spring框架的话,使用二级缓存也是可以的。

简单说,Bean在初始化后(创建Bean的最后一个阶段),会判断是否需要创建代理对象。如果创建了代理,那么最终返回的就是代理实例的引用。

假如A和B存在循环依赖需要解决,三级缓存是会提前暴露对象(不完整的没有属性注入的对象),但是如果需要代理对象的话,在填充完属性后会创建代理对象的(代理对象需要在最后一个阶段创建),此时的Bean和提前暴露对象的Bean应该要一样,但是实际是不一样的,一个拥有属性,一个则是不完整的Bean。所以三级缓存才能保证不管什么时候使用的都是⼀个对象。

详细解答可看:

或者这个

13. 说说 JDK 动态代理和 CGLIB 代理 ?

  • JDK动态代理:
    • JDK动态代理需要实现InvocationHandler
    • 在实现该接口后,可以在前后写横切逻辑,调用invoke方法执行
    • Proxy利用 InvocationHandler 动态创建一个符合目标类实现的接口的实例,生成目标类的代理对象。
1
2
3
4
// 接口
public interface ISolver {
void solve();
}
1
2
3
4
5
6
7
// 目标类
public class Solver implements ISolver {
@Override
public void solve() {
System.out.println("疯狂掉头发解决问题……");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ProxyFactory {
// 维护一个目标对象
private Object target;
public ProxyFactory(Object target) {
this.target = target;
}
// 为目标对象生成代理对象
public Object getProxyInstance() {
return Proxy.newProxyInstance(target.getClass().getClassLoader(), target.getClass().getInterfaces(),
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("请问有什么可以帮到您?");
// 调用目标对象方法
Object returnValue = method.invoke(target, args);
System.out.println("问题已经解决啦!");
return null;
}
});
}
}
1
2
3
4
5
6
7
8
9
10
11
// 客户端
public class Client {
public static void main(String[] args) {
//目标对象:程序员
ISolver developer = new Solver();
//代理:客服小姐姐
ISolver csProxy = (ISolver) new ProxyFactory(developer).getProxyInstance();
//目标方法:解决问题
csProxy.solve();
}
}
  • Cglib动态代理:
    • Cglib原理是生成一个子类,并在采用方法拦截父类执行,并织入横切逻辑在其中
    • 相比JDK动态代理只能通过继承接口实现,Cglib则没有这个限制
    • 如果目标类使用了final方法,Cglib则无法使用,因为他是通过创建子类进而实现的
    • CgLib 创建的动态代理对象性能比 JDK 创建的动态代理对象的性能高不少,但是 CGLib 在创建代理对象时所花费的时间却比 JDK 多得多,所以对于单例的对象,因为无需频繁创建对象,用 CGLib 合适,反之,使用 JDK 方式要更为合适一些。
1
2
3
4
5
6
// 目标类
public class Solver {
public void solve() {
System.out.println("疯狂掉头发解决问题……");
}
}
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 ProxyFactory implements MethodInterceptor {
//维护一个目标对象
private Object target;
public ProxyFactory(Object target) {
this.target = target;
}
//为目标对象生成代理对象
public Object getProxyInstance() {
//工具类
Enhancer en = new Enhancer();
//设置父类
en.setSuperclass(target.getClass());
//设置回调函数
en.setCallback(this);
//创建子类对象代理
return en.create();
}
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("请问有什么可以帮到您?");
// 执行目标对象的方法
Object returnValue = method.invoke(target, args);
System.out.println("问题已经解决啦!");
return null;
}

}
1
2
3
4
5
6
7
8
9
10
public class Client {
public static void main(String[] args) {
//目标对象:程序员
Solver developer = new Solver();
//代理:客服小姐姐
Solver csProxy = (Solver) new ProxyFactory(developer).getProxyInstance();
//目标方法:解决问题
csProxy.solve();
}
}

14. 说说 Spring AOP 和 AspectJ AOP 区别?

Spring AOP(运行时增强):

  • 基于动态代理实现,默认如果使用接口的,用 JDK 提供的动态代理实现,如果是方法则使用 CGLIB 实现。
  • 需要依赖IoC容器来管理,使用纯Java实现
  • 由于动态代理时会生成代理实例对象,相应的方法调用也会增加栈调用的深度,因此性能相对没有AspectJ好

AspectJ AOP(编译时增强):

  • 属于编译时增强,可以单独使用,也可以整合到其他框架,但是需要依赖单独的编译器ajc
  • 属于静态织入,有以下几个织入时机:
    • 编译期织入:在编译时织入
    • 编译后织入:在已经编译成class文件,并且打成Jar后,需要织入就要使用这种方式
    • 类加载后织入:在加载类的时候

15. Spring的事务种类?

编程式事务和声明式事务。

编程式事务:

  • 编程式事务管理使用 TransactionTemplate,需要显式执行事务

声明式事务:

  • 基于AOP实现的,本质就是在方法执行前添加一个事务,在执行完成后,根据执行结果决定回滚或者提交
  • 只需要在代码中添加@Transation注解即可

16. Spring 的事务隔离级别?

  1. DEFAULT(默认):使用数据库默认的隔离级别。对于大多数数据库,这通常是 READ_COMMITTED 级别。
  2. READ_UNCOMMITTED(读未提交):最低的隔离级别,在该级别下,一个事务可以读取另一个事务未提交的数据,这可能导致脏读、不可重复读和幻读等问题。
  3. READ_COMMITTED(读已提交):一个事务只能读取另一个事务已经提交的数据。这种隔离级别可以防止脏读,但是在并发情况下可能会导致不可重复读和幻读等问题。
  4. REPEATABLE_READ(可重复读):一个事务在多次执行相同的查询时,结果都是相同的。在该级别下,读取的数据是事务开始时的一致性视图,可以防止脏读和不可重复读,但是仍然存在幻读问题。
  5. SERIALIZABLE(串行化):最高的隔离级别,通过强制事务串行执行来避免并发问题。在该级别下,事务之间完全隔离,可以防止脏读、不可重复读和幻读等问题,但是会影响性能。

在 Spring 中,可以通过在事务注解 @Transactional 中指定 isolation 属性来设置隔离级别,例如:

1
2
3
4
@Transactional(isolation = Isolation.READ_COMMITTED)
public void doSomething() {
// ...
}

17. 声明式事务的原理?

主要就是通过AOP动态代理。

简单说分三步:

  1. 查找@Transation注解,如果目标类是接口使用JDK代理,否则使用Cglib代理。
  2. 在执行该方法时,会调用相关的事务处理接口进行处理

**18. 声明式事务在哪些情况下会失效?

  1. 事务传播级别设置错误。有些事务传播会以非事务状态运行
  2. 被本类的其他方法调用。比如同一个类A调用了B,此时在B上加了@Transation注解,而A没有,此时调用A事务会失效,本质是因为AOP的原因,因为只有当事务方法被当前类以外的代码调用时,才会由 Spring 生成的代理对象来管理。
  3. rollBackFor设置出错时。因为Java中默认抛出Error或uncheck异常时才回滚事务。
  4. 应用在非public方法时。是因为代理对象(JDK和Cglib都会)会调用一个方法获取@Transation注解的信息,而此方法会检查目标方法的修饰符是否为 public,不是 public 则不会获取@Transactional 的属性配置信息。

1. 什么是JVM?

JVM是指Java虚拟机,在JVM上可以运行Java编译后的字节码文件,也正是因为JVM使得Java具有跨平台性。

2. 简单介绍下JVM的内存区域?

JVM内存区域有堆、栈、本地方法栈、虚拟机栈和程序计数器。

Java虚拟机运行时数据区

其中方法区和堆是线程共享的。

虚拟机栈:每个线程都有其自己的虚拟机栈,是其私有的,方法执行时,栈帧会存储其局部变量、操作数栈和动态链接。

本地方法栈:本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

程序计数器:也被称为 PC 寄存器,是一块较小的内存空间。它可以看作是当前线程所执行的字节码的行号指示器。

Java堆:Java堆一般都是最大的区域,里面还细分了很多区域,一般垃圾回收都在堆内进行,并且几乎所有的对象创建也在堆上。

方法区:方法区是比较特别的一块区域,和堆类似,它也是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

它特别在 Java 虚拟机规范对它的约束非常宽松,所以方法区的具体实现历经了许多变迁,例如 jdk1.7 之前使用永久代作为方法区的实现。

Java虚拟机栈 Java 堆内存结构

3. 说一下 JDK1.6、1.7、1.8 内存区域的变化?

JDK1.6时使用永久代作为方法区:

JDK 1.6内存区域

JDK1.7时将字符串常量池、静态变量,存放在堆上:

JDK 1.7内存区域

在 JDK1.8 时彻底干掉了永久代,而在直接内存中划出一块区域作为元空间,运行时常量池、类常量池都移动到元空间:

JDK 1.8内存区域

4. 运行时常量池、类常量池和字符串常量池分别是什么?

运行时常量池(Runtime Constant Pool)是 Java 虚拟机内存中的一块区域,它存储着在类文件的常量池(Class Constant Pool)中的所有常量,并且在运行时期间可以在程序中被引用到。运行时常量池中存储的内容包括了字符串常量、整型常量、浮点型常量等类型。

类常量池(Class Constant Pool)是在编译期间预先在 class 文件中创建的一个常量池,它存储着类的相关信息,如类名、方法名、字符串常量、整型常量等。

字符串常量池(String Constant Pool)是一个特殊的常量池,它存储着字符串常量,并且使用字符串常量池可以节省内存,因为所有的字符串常量在内存中只有一份拷贝,避免了创建多份字符串常量对象所带来的内存浪费。

5. 为什么用元空间代替永久代?

有主观和客观两方面的原因。

  • 主观上,使用永久代容易产生内存溢出等问题。
  • 客观上,在 Oracle 收购 BEA 获得了 JRockit 的所有权后,为了和JRockit 虚拟机更好兼容,综合考虑替换了。

6. 对象的加载过程?

容易和类的加载过程混淆。

  1. 首先查看类是否加载、解析或初始化过。如果没有,则先执行类的加载过程。
  2. 类检查完成后,JVM将为新的对象分配内存。
  3. 内存分配完成后,JVM将分配到的内存空间初始化为0
  4. 接下来初始化对象头,里面包含了该对象时哪个类的实例、如何找到元数据链接、对象的哈希码以及对象的GC分代年龄等信息。

7. 类的加载过程

  1. 加载。将类通过类加载器从硬盘里的.class文件加载到内存中
  2. 链接。链接包括三个步骤。
    • 验证:验证语法是否正确
    • 准备:为类变量(静态变量)分配内存,并将其初始化为默认值
    • 解析:此时将符号引用转换为直接引用,符号引用是一种指向类或方法的引用,而直接引用是指向实际内存位置的引用。
  3. 初始化:为类变量分配内存,并为其赋值,如果没有赋值则为其默认值。

8. 什么是指针碰撞?什么是空闲列表?

指针碰撞和空闲列表都是分配内存的方式。

  • 指针碰撞:在内存中有一个指针,指针两侧分别是已分配区域和未分配区域,当有新的对象要分配时,指针向未分配区域移动相应距离。
  • 空闲列表:内存中已分配区域和未分配区域是不规整的,由一个表来记录每块区域的相关信息,在需要分配时根据记录表查找合适的区域进行分配,分配完成后再更新记录表。

两种方式的选择由Java堆是否规整决定,而Java堆是否规整由选择的垃圾回收器是否具有压缩整理能力决定。

9. JVM 里 new 对象时,堆会发生抢占吗?JVM 是怎么设计来保证线程安全的?

会发生抢占,假如内存正在给一个对象分配时,指针移动需要修改还没来得及移动,此时另一个线程进来,也需要分配内存,这样就会产生冲突,此时有两个解决办法:

  • 采用CAS算法保证原子性。
  • 事先为每个线程分配一小段缓存区域(本地线程缓冲区),当有新的线程进来时,先分配到缓冲区,如果缓冲区满了或者放不下,再锁定内存区域同步分配内存区域。

10. 对象的内存布局是怎样的?

对象主要有对象头、实例数据、对齐填充三部分。

对象的存储布局
  • 对象头:
    • 类型指针:表示对象代表哪个类。
  • 实例数据:用来存储对象真正的有效信息,也就是我们在程序代码里所定义的各种类型的字段内容,无论是从父类继承的,还是自己定义的。
  • 对齐填充:没有特别含义,相当于占位符。

11. 内存溢出和内存泄漏是什么意思?

内存泄漏指内存没有被正确释放,使内存被白白占用。

内存溢出指内存超出可用内存限制,而溢出。

12. 能手写内存溢出的例子吗?

  • 堆溢出:堆溢出只要不断创建不可被回收的静态变量、静态对象即可
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* VM参数: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while (true) {
list.add(new OOMObject());
}
}
}
  • 虚拟机栈溢出:虚拟机栈存储的是线程,所以不断创建线程早晚会溢出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* vm参数:-Xss2M
*/
public class JavaVMStackOOM {
private void dontStop() {
while (true) {
}
}
public void stackLeakByThread() {
while (true) {
Thread thread = new Thread(new Runnable() {
public void run() {
dontStop();
}
});
thread.start();
}
}
public static void main(String[] args) throws Throwable {
JavaVMStackOOM oom = new JavaVMStackOOM();
oom.stackLeakByThread();
}
}

*13. 内存泄漏可能由哪些原因造成?

  • 静态集合类:静态集合类生命周期和JVM一样,所以一直不会被释放
1
2
3
4
5
6
7
public class OOM {
static List list = new ArrayList();
public void oomTests(){
Object obj = new Object();
list.add(obj);
}
}
  • 单例模式:和上面的例子原理类似,单例对象在初始化后会以静态变量的方式在 JVM 的整个生命周期中存在。如果单例对象持有外部的引用,那么这个外部对象将不能被 GC 回收,导致内存泄漏。
  • 数据连接、IO、Socket 等连接:创建的连接不再使用时,需要调用 close 方法关闭连接,只有连接被关闭后,GC 才会回收对应的对象(Connection,Statement,ResultSet,Session)。忘记关闭这些资源会导致持续占有内存,无法被 GC 回收。
1
2
3
4
5
6
7
8
9
10
11
try {
Connection conn = null;
Class.forName("com.mysql.jdbc.Driver");
conn = DriverManager.getConnection("url", "", "");
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("....");
} catch (Exception e) {

}finally {
//不关闭连接
}
  • 变量不合理的作用域:一个变量的定义作用域大于其使用范围,很可能存在内存泄漏;或不再使用对象没有及时将对象设置为 null,很可能导致内存泄漏的发生。
1
2
3
4
5
6
7
8
9
public class Simple {
Object object;
public void method1(){
object = new Object();
//...其他代码
//由于作用域原因,method1执行完成之后,object 对象所分配的内存不会马上释放
object = null;
}
}

在《Effective Java》第七条 消除过期的对象引用。在栈弹出操作时,如果是非Java这种没有垃圾回收机制的语言,如果不将其置为空等,就容易发生内存泄漏,因为栈内部还维护着对这些对象的过期引用。所谓过期引用,就是永远不会再被解除的引用。

  • hash值改变:假如在HashMap中的某个值,hash改变后,用相同的key将找不到这个值,从而无法删除。这也是为什么 String 类型被设置成了不可变类型的原因。

String在创建时是会缓存Hash值的,如果改变了就不是其本身了,所以设置成了不可变类型。

  • ThreadLocal使用不当:ThreadLocal 的弱引用导致内存泄漏

14. 解释一下ThreadLocal 的弱引用导致内存泄漏?

ThreadLocal 是 Java 中一种线程级别的数据隔离技术,它可以让每个线程都拥有一份独立的数据副本,从而避免了多个线程之间的数据冲突问题。但是在使用 ThreadLocal 时,如果不注意它的生命周期管理,就可能会导致内存泄漏问题,其中之一就是弱引用导致的内存泄漏。

ThreadLocal 内部维护了一个 Map,用于存储每个线程的数据副本。Map 的 key 是 ThreadLocal 对象的弱引用,value 是对应线程的数据副本。当 ThreadLocal 对象没有被外部强引用时,它就有可能被垃圾回收器回收。但是由于 Map 中的 key 是弱引用,垃圾回收器在回收 ThreadLocal 对象时并不会主动清理对应的 Entry,这就可能导致 Map 中出现 key 为 null 的 Entry,而这些 Entry 对应的 value 就无法被访问,但却一直占用着内存,从而造成内存泄漏。

为了避免这种内存泄漏问题,我们可以通过显式地调用 ThreadLocal 的 remove() 方法来清除当前线程的数据副本,或者在定义 ThreadLocal 变量时使用匿名内部类的方式重写它的 initialValue() 方法,使其返回一个弱引用对象,从而让 ThreadLocal 对象本身成为一个弱引用。这样当 ThreadLocal 对象被垃圾回收时,对应的 Entry 也会被自动清理,避免了内存泄漏问题。

15. 如何判断对象仍然存活?

一般有两种算法:引用计数发和可达性分析法。

引用计数法:简而言之,就是在对象中添加一个引用计数器,当有对象引用时,计数器+1,引用失效时-1,当计数器为0时,则可判断为对象死亡。

可达性分析法:在JVM中主流的垃圾回收器采用的就是本方法,该方法就是将一系列称为 GC Root 的对象作为搜索起始点,由这个起始点出发向下搜索,搜索过的路径称为引用链,当一个对象到 GC Root 没有任何引用链时,则证明此对象是不可用的。

例如object5、object6、object7虽然互有关联,但它们到GC Roots是不可达的,所以它们将会被判定为可回收对象。

GC Root

16. 可作为GC Root的对象有哪些?

  • 方法区中类静态属性的引用的对象
  • 本地方法栈JNI引用的对象
  • 虚拟机栈的引用的对象
  • 方法区中常量对象引用的对象

17. 对象有哪些引用?

强引用、软引用、弱引用、虚引用

18. finalize()方法了解吗?有什么作用?

这个方法用的不多。打个比喻,垃圾回收有点像秋后问斩,而 finalize() 有点像刀下留人。

在垃圾回收时,对象在进行可达性分析后发现没有与 GC Root 关联,则会被第一次标记。之后进行第二次筛选,看是否有必要执行 finalize()——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this 关键字)赋值给某个类变量或者对象的成员变量, ,如果没有,则进行回收。

19. Java的堆分区?

堆分为新生代和老年代。新生代又分伊甸园区、幸存者1区、幸存者2区,比例为8:1:1。

Java堆内存划分

20. 介绍下垃圾回收算法?

  • 标记-清除:这种算法较简单,就是标记需要回收的对象,之后执行清理操作。但是也存在着两个缺点

    • 效率会降低:随着对象和需要清理的对象越来越多,执行效率会降低
    • 空间碎片化问题
  • 标记-复制:本算法则是先将内存区域分为两块,每次只使用一块,等到一块使用完后,将还存活的对象复制到另一块上面,然后把使用过的需要清理的对象一次性清理。

    • 本算法缺点就是空间利用率不高,但是由于新生代存活对象不多,每次复制的也只是少量对象,所以采用这个算法。
  • 标记-整理:顾名思义,每次清理完成后,便将还存活的对象向某一端移动。

    • 这种方式在老年代使用较多。

21. 介绍一下新生代内存划分及垃圾回收?

新生代分伊甸园区、幸存者1区、幸存者2区,比例为8:1:1。每次分配内存时,只使用一块伊甸园区和一块幸存者区,当进行垃圾回收时,便将所有的存活区块复制到另一个未使用的幸存者区,之后再进行垃圾回收。

22. Minor GC/Young GC、Major GC/Old GC、Mixed GC、Full GC 都是什么意思?

部分收集(Partial GC):指目标不是完整收集整个 Java 堆的垃圾收集,其中又分为:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS 收集器会有单独收集老年代的行为。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有 G1 收集器会有这种行为。

整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。

23. Young GC会在什么时候发生?

新创建的对象优先在新生代 Eden 区进行分配,如果 Eden 区没有足够的空间时,就会触发 Young GC 来清理新生代。

24. 什么时候触发Full FC?

  • 老年代目前连续的可用空间大小 < 新生代以往历次Young GC后升入老年代对象大小总和的平均大小
  • 当Young GC后,没有被回收需要升入老年的对象大小 > 老年代可用空间大小
  • 当老年代空间使用率过高达到一定比例的时
  • 当To区放不下从From区和eden区拷贝的内容时,或新生代达到阈值需要升到老年代的对象,而老年代放不下时
  • 当调用 System.gc()
  • 另外,假如方法区还由永久代实现,如果永久代空间不足也会Full GC

25. 对象在什么情况下会进入老年代?

  • 长期存活的对象,在每次Young GC后,会有一个标记移区年龄,当移区年龄大于15(默认,可设置)时,则会进入老年代。
  • 当新生代相同年龄的对象大小总和大于幸存者区空间大小的一半时,大于等于该年龄的都会进入老年代。
  • 当新生代内对象连续占用的空间过大时(可设置参数),会进入老年代。
  • 在Yuong GC后假如新生代还有大量对象存在,到幸存者区无法容纳时,将会进入老年代

26. 常见的垃圾回收器有哪些?

  • serial收集器:单线程执行,在执行时,需要暂停其他工作线程
  • ParNew收集器:多线程执行,实际上就是serial的多线程版本
  • serial Old收集器:属于serial老年版本,单线程执行,采用标记-整理算法
  • Parallel Old收集器:属于Parallel 老年版本,多线程执行,采用标记-整理算法
  • CMS 收集器:在收集过程中可以与用户线程并发操作,CMS牺牲了系统的吞吐量来追求收集速度,适合追求垃圾收集速度的服务器上。
  • Garbage First 收集器:Garbage First(简称 G1)收集器是垃圾收集器的一个颠覆性的产物,它开创了局部收集的设计思路和基于 Region 的内存布局形式。

27. 什么是Stop The World ? 什么是 OopMap ?什么是安全点?

在垃圾回收的过程中,会涉及到对象的移动。而为了保证对象引用更新的正确性,需要暂停所有的用户线程,像这样的停顿称为 Stop The World

在HotSpot中,有个数据结构叫OopMap,它记录了对象偏移量等计算出来。在即时编译的过程中,会在特定的位置上生成OopMap,这些特定的位置就叫安全点。

特定位置可以是:

  1. 循环的末尾
  2. 方法临返回前
  3. 可能抛出异常的位置

用户程序执行时并不是能够随便停下来的,而是在安全点才能停下来进行垃圾回收。

28. 说一下 CMS 收集器的垃圾收集过程?

  • 初始标记:单线程执行,需要 Stop The World ,标记GC Root能直达的对象
  • 并发标记:无停顿、根据初始标记的对象,遍历整个对象图
  • 重新标记:多线程执行,需要 Stop The World ,根据上一步标记需要清除的对象
  • 并发清除:无停顿,和用户线程同时运行,清除掉所有标记的对象和死亡的对象

29. 说一下 G1收集器的垃圾收集过程?

G1收集器是垃圾收集器的一个颠覆性产物,开创了局部收集的设计思路和基于Region的内存布局形式。

G1虽然也遵循分代回收的设计,但是其划分方式和其他不同。其他收集器是划分新生代、老年代和持久代。但是G1回收器将连续的Java堆分成多个大小相等的区域(Region),每个区域都可以是Eden区、Survivor区和老年代,收集器可以对扮演不同角色的区域采取不同的回收策略。

这样避免回收整个堆,而是根据若干个Region集进行收集,同时维护一个优先级列表,跟踪各个Region的回收价值,优先收集价值高的Region。

G1收集器运行大致可以划分成四个步骤:

  • 初始标记:标记GC Root可直达的对象,Stop The World执行
  • 并发标记:根据GC Root可直达的对象,寻找整个堆内要回收的对象,和用户线程并发执行
  • 最终标记:Stop The World执行,标记上一个阶段产生的垃圾
  • 筛选回收:Stop The World执行,选择多个Region构成回收集,把存活的对象复制到空的Region中,再把旧的Region全部回收

30. 有了CMS,为什么还需要G1?

CMS的优点就是——并发收集、低停顿。同时也有缺点:

  • 会产生较多的内存碎片。
  • CMS并发能力比较依赖CPU的性能,并且并发收集阶段用户程序还在运行,可能会影响用户程序的性能。
  • 并发收集时,用户线程仍在运行,会产生所谓的“浮动垃圾”,如果“浮动垃圾”不在此阶段清除,就要到下一阶段,可能会引发再次 Full GC,影响性能。

而G1主要解决了内存碎片过多的问题。

**31. 对象一定分配在堆中吗?有没有了解逃逸分析技术?

不一定。随着JIT发展不断对代码进行优化,其中有一部分优化的目的是减少内存堆的分配压力,其中一项技术叫做逃逸分析。

逃逸分析,通俗讲,就是对象被new出来以后,它可能被外部调用,如果是作为参数传递到了外部,则称为方法逃逸。

逃逸

除此之外,如果对象还有可能被外部线程访问到,例如赋值给可以在其它线程中访问的实例变量,这种就被称为线程逃逸。

逃逸分析的好处:

  • 栈上分配:如果确定一个对象不会逃逸到线程之外,那么久可以考虑将这个对象在栈上分配,对象占用的内存随着栈帧出栈而销毁,这样一来,垃圾收集的压力就降低很多。
  • 同步消除:由于线程同步是一个较为耗时的工作,假如一个对象不会逃逸出当前线程,无法被其他线程访问,则可以不进行同步。
  • 标量替换:如果一个数据是基本数据类型,不可拆分,它就被称之为标量。把一个 Java 对象拆散,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么可以不创建对象,直接用创建若干个成员变量代替,可以让对象的成员变量在栈上分配和读写。

32. 一个类的加载过程是怎样的?

  • 首先根据全限定类名获取此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转换为方法区运行时数据结构。
  • 在内存中生成一个代表该类的java.lang.Class对象,作为方法区这个类各种数据的访问入口。

33. 类加载器有哪些?

  • 启动类(引导类)加载器:用来加载java核心类库。
  • 扩展类加载器:它用来加载 Java 的扩展库。
  • 系统类加载器:它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。
  • 用户自定义类加载器:用户通过继承 java.lang.ClassLoader 类的方式自行实现的类加载器。

34. 什么是双亲委派机制?

如果一个类加载器收到了类的加载请求,一般不会自己先去尝试加载这个类,而是委派给父类,一层层向上委派,当父类表示无法加载时,子加载器才会尝试自己去完成加载。

35. 为什么要使用双亲委派机制?

为了保证系统的稳定有序。

比如用户自己写了一个名为java.lang.Object类,放在程序的ClassPath中,如果由各个类加载器自行去加载的话,那么系统中就会出现多个不同的Object类。

36. 如何自己实现一个热部署功能?

一个类加载首先通过 Java 编译器,将 Java 文件编译成 class 字节码,类加载器读取 class 字节码,再将类转化为实例,对实例 newInstance 就可以生成对象。

类加载器 ClassLoader 功能,也就是将 class 字节码转换到类的实例。在 Java 应用中,所有的实例都是由类加载器,加载而来。

一般在系统中,类的加载都是由系统自带的类加载器完成,而且对于同一个全限定名的 java 类(如 com.csiar.soc.HelloWorld),只能被加载一次,而且无法被卸载。

既然在类加载器中,Java 类只能被加载一次,并且无法卸载。那么我们可以把类加载器去掉并自定义类加载器,重写 ClassLoader 的 findClass 方法(此处可以看下源码,各个类加载器都继承 ClassLoader 类,并重写了findClass方法)

这样实现步骤大致就是:

  1. 销毁原来的类加载器
  2. 更新class类文件
  3. 自定义类加载器并重写findClass方法去加载更新后的文件

37. Tomcat 的类加载机制了解吗?

Tomcat类加载器

Tomcat破坏了双亲委派机制。这是因为Tomcat中可能会部署多个应用,如果多个应用依赖的某一个Jar版本不同,这样使用双亲委派机制,无法加载多个相同的类,因为双亲委派机制就是保证系统的稳定有序,让其加载相同的类。

所以Tomcat提供了隔离机制,为每个 web 容器单独提供一个 WebAppClassLoader 加载器。每一个 WebAppClassLoader 负责加载本身的目录下的 class 文件,加载不到时再交 CommonClassLoader 加载,这和双亲委派刚好相反。

中断

中断在操作系统中非常重要,它是硬件与CPU进行通信的方式,中断使得程序能从用户态陷入内核态进行系统调用。

硬件中断发生频繁,会非常消耗CPU资源。在多核CPU条件下,如果把大量硬件中断分配给不同的CPU核心处理可以很好的平衡性能。通常服务器上会有多个CPU核、多块网卡、多块硬盘,如果能让网卡中断独占1个CPU核心、磁盘IO中断独占1个CPU核心,将会大大减轻单一CPU负载、提高整体处理效率。

什么是IRQ

IRQ指的是“中断请求(Interrupt Request)”,是计算机硬件中的一种机制,用于将CPU的控制权从正在执行的程序转移到与其相关的更高优先级的事件或任务。当外部设备需要处理的事件发生时,它会向CPU发送一个中断请求信号,以引起CPU的注意并立即中断正在执行的程序,转而处理事件或任务。

在PC硬件中,IRQ通常用于处理外设的中断请求,如键盘、鼠标、网卡等。在计算机操作系统中,IRQ的处理通常由设备驱动程序负责管理。不同的IRQ号代表不同的中断请求源和优先级,例如IRQ0通常用于系统计时器,IRQ1用于键盘控制器,IRQ2用于级联PIC(可编程中断控制器),等等。

网卡绑定特定核

在Linux中,可以使用以下命令查看当前IRQ对应CPU核心的情况:

1
cat /proc/interrupts

在我的云服务器上,有如下返回值:

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
IRQ_NUM     CPU0       CPU1       
0: 94 0 IO-APIC-edge timer
1: 10 0 IO-APIC-edge i8042
4: 398 0 IO-APIC-edge serial
6: 3 0 IO-APIC-edge floppy
8: 0 0 IO-APIC-edge rtc0
9: 0 0 IO-APIC-fasteoi acpi
11: 0 0 IO-APIC-fasteoi uhci_hcd:usb1, virtio2
12: 15 0 IO-APIC-edge i8042
14: 12456672 0 IO-APIC-edge ata_piix
15: 0 0 IO-APIC-edge ata_piix
24: 5 0 PCI-MSI-edge virtio0-config
25: 35425610 0 PCI-MSI-edge virtio0-input.0
26: 1 0 PCI-MSI-edge virtio0-output.0
27: 1 31636395 PCI-MSI-edge virtio0-input.1
28: 1 0 PCI-MSI-edge virtio0-output.1
29: 0 0 PCI-MSI-edge virtio1-config
30: 61771517 0 PCI-MSI-edge virtio1-req.0
NMI: 0 0 Non-maskable interrupts
LOC: 889444157 728127731 Local timer interrupts
SPU: 0 0 Spurious interrupts
PMI: 0 0 Performance monitoring interrupts
IWI: 109688740 114792299 IRQ work interrupts
RTR: 0 0 APIC ICR read retries
RES: 1062394876 1083338929 Rescheduling interrupts
CAL: 231 331 Function call interrupts
TLB: 24543172 24525287 TLB shootdowns
TRM: 0 0 Thermal event interrupts
THR: 0 0 Threshold APIC interrupts
DFR: 0 0 Deferred Error APIC interrupts
MCE: 0 0 Machine check exceptions
MCP: 42518 42518 Machine check polls
ERR: 0
MIS: 0
PIN: 0 0 Posted-interrupt notification event
NPI: 0 0 Nested posted-interrupt event
PIW: 0 0 Posted-interrupt wakeup event

假如我们想修改RTC对应的响应处理器核心,那么我们可以先通过下面的命令来获得如下返回值(其中8为RTC对应的中断号,在上面的返回值第一列可以看到):

1
cat /proc/irq/8/smp_affinity

为了说明问题,这里借用一个CPU8核的返回结果:

1
2
3
4
5
6
7
8
9
10
Binary          Hex

CPU0 0000 0001 1
CPU1 0000 0010 2
CPU2 0000 0100 4
CPU3 0000 1000 8
CPU4 0001 0000 10
CPU5 0010 0000 20
CPU6 0100 0000 40
CPU7 1000 0000 80

那么我们要设置IRQ到指定的CPU上面,可以使用如下命令:

1
echo "2" > /proc/irq/8/smp_affinity

上述命令将RTC的中断指定到CPU1核心上处理,也可以指定多个核心,如下:

1
echo "6" > /proc/irq/8/smp_affinity

/proc/irq/${IRQ_NUM}/smp_affinity为中断号为IRQ_NUM的中断绑定的CPU核的情况

而echo 后面的数字以十六进制表示,每一位代表一个CPU核

1
2
3
(00000001)代表CPU0
(00000010)代表CPU1
(00000011)代表CPU0和CPU1

有什么应用

这种方式在性能调优方面有相关应用,“中断”是机器硬件与 CPU 交互的一种方式,即硬件告诉 CPU 有事情要处理了。而网卡中断,就是机器网卡告诉 CPU 要处理网络数据了。

比如在秒杀场景中,瞬时流量非常高,带来的问题就是一下子会有非常多的网络请求进来。网卡在收到网络信号后,会通知 CPU 来处理,这时如果我们没有调整过相关配置,那么很有可能处理网卡中断的 CPU 都集中在一个核上。

如果这个时候该 CPU 也在承担处理应用进程的任务,那么就有可能出现单核 CPU 飙升的问题,同时网络数据的处理也会受到影响,导致大量 TCP 重传现象的发生。所以这个时候,我们要做的就是合理分配多核 CPU 资源,专门拿出一个核来处理网卡中断。

我们这么做的目的,其实就是在多核 CPU 下,让一个进程在某个给定的 CPU 上尽量长时间地运行而不被迁移到其他处理器。这样做的好处就是:一方面可以减少 CPU 调度产生的开销;另一方面可以提高每个 CPU 核的缓存命中率。

CPU核缓存会存哪些数据

CPU核缓存存储的数据是CPU在运行程序时频繁访问的数据和指令,它们被存储在缓存中以提高处理器的性能。缓存的容量比内存小得多,但其访问速度更快。

具体来说,CPU核缓存可以存储以下数据:

  1. 指令:存储下一条指令,以便在需要时能够快速读取。
  2. 数据:存储最近使用的数据,以便在需要时能够快速读取。
  3. 缓存行标记:用于记录缓存行中所包含的内存地址范围,以便在需要时能够快速定位。
  4. 缓存标记:用于标识缓存中的数据是否有效或已失效。
  5. 缓存控制信息:用于管理缓存的读取和写入操作,以及数据的替换和回写操作。

总之,CPU核缓存存储的数据主要是与当前运行程序相关的指令和数据,以及一些用于管理缓存的元数据。由于缓存的容量较小,因此存储在其中的数据必须经过精心的策略和算法来优化性能。

1. 进程和线程有什么区别?

  • 进程是系统进行资源分配和调度的最小单位。
  • 线程是CPU进行调度的最小单位,一个进程可以包含多个线程。

比如在Java中,当我们启动 main 函数其实就启动了一个JVM进程,而 main 函数在的线程就是这个进程中的一个线程,也称主线程。

*2. 进程创建有哪些方式?

  • 继承Runnable接口,重写run()方法(推荐)
1
2
3
4
5
6
7
8
9
public class RunnableTask implements Runnable {
public void run() {
System.out.println("Runnable!");
}
public static void main(String[] args) {
RunnableTask task = new RunnableTask();
new Thread(task).start();
}
}
  • 继承Thread类
1
2
3
4
5
6
7
8
9
10
11
12
public class ThreadTest {
public static class MyThread extends Thread {
@Override
public void run() {
System.out.println("This is child thread");
}
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
}

这里的start()方法和主线程是交替执行的,通过调用Thread类的 start()方法来启动一个线程,这时此线程处于就绪(可运行)状态,并没有运行,一旦得到CPU时间片,就开始执行run()方法。

上面两种都是没有返回值的,但是如果我们需要获取线程的执行结果,可以实现Callable接口

  • 实现Callable接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class CallerTask implements Callable<String> {
public String call() throws Exception {
return "Hello,i am running!";
}
public static void main(String[] args) {
FutureTask<String> task=new FutureTask<String>(new CallerTask());
new Thread(task).start();
try {
String result=task.get();
System.out.println(result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}

3. 什么是CAS算法?

CAS(compare and swap,比较并且交换)算法是通过非阻塞方式避免多线程安全的一种方式,属于乐观锁相关的技术。

简单来说,它维护了三个变量,旧的预期值A、当前内存值V、即将更新的值B,通过while循环不断获取内存中的数值比较并更新。

同时,CAS也存在着一些问题:

  1. ABA问题。当且仅当内存值V等于旧的预期值A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。那么如果先将预期值A给成B,再改回A,那CAS操作就会误认为A的值从来没有被改变过,这时其他线程的CAS操作仍然能够成功,但是很明显是个漏洞,因为预期值A的值变化过了。

在Java并发包中,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性,即在变量前面添加版本号,每次变量更新的时候都把版本号+1,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。

  1. CPU消耗过高的问题,while循环时间过长会极大消耗CPU的性能。

当某一方法比如:getAndAddInt()执行时,如果CAS失败,会一直进行尝试。如果CAS长时间尝试但是一直不成功,可能会给CPU带来很大的开销。

  1. 只能保证一个共享变量的原子操作。

当操作1个共享变量时,我们可以使用循环CAS的方式来保证原子操作,但是操作多个共享变量时,循环CAS就无法保证操作的原子性,这个时候就需要用锁来保证原子性。

在多线程环境中,其他线程可能在循环CAS进行更新操作之前修改了其他共享变量的值,从而导致循环CAS的更新操作失效。

4. Thread.sleep(0)会发生什么?

首先,sleep()指定毫秒数后,是不一定会在指定的毫秒数后立即执行的,关键至于是否分配到了CPU时间片。

这里,就需要区分Windows和Unix操作系统了。Unix操作系统使用的是时间片算法,Windows操作系统使用的是抢占式算法。我们现在基于Unix操作系统来讨论。

假如我们调用Thread.sleep(1000),代表在未来1000毫秒内不参与时间片的竞争,但是在1000毫秒后,如果有优先级更高的线程,那么我们这个休眠了1000毫秒的线程依然不会执行。

而Thread.sleep(0)的作用看上去只是在0毫秒内不参与时间片的竞争,好像写不写没有什么区别,其实不是这样的。Thread.Sleep(0)的作用,就是“触发操作系统立刻重新进行一次CPU竞争”。竞争的结果也许是当前线程仍然获得CPU控制权,也许会换成别的线程获得CPU控制权。这也是我们在大循环里面经常会写一句Thread.Sleep(0) ,因为这样就给了其他线程比如Paint线程获得CPU控制权的权力,这样界面就不会假死在那里。

所谓抢占式操作系统,就是说如果一个进程得到了 CPU 时间,除非它自己放弃使用 CPU ,否则将完全霸占 CPU 。但这个行为仍然是受到制约的——操作系统会监控你霸占CPU的情况,如果发现某个线程长时间霸占CPU,会强制使这个线程挂起,因此在实际上不会出现“一个线程一直霸占着 CPU 不放”的情况。

但这个行为仍然是受到制约的——操作系统会监控你霸占CPU的情况,如果发现某个线程长时间霸占CPU,会强制使这个线程挂起,因此在实际上不会出现“一个线程一直霸占着 CPU 不放”的情况。因此反应到界面上,看起来就好像这个线程一直在霸占着CPU一样。

5. 线程有哪些常用的调度方法?

等待:wait()、wait(long timeout)、join()

通知:notify()、notifyAll()

让出优先权:yield()

中断:interrupt()、interrupted()、isinterrupted()

休眠:sleep()

假设有两个线程 threadAthreadB。如果从 threadA 调用 threadB.join()threadA 将等待 threadB 完成后再继续执行。

如果调用 join 的线程在等待另一个线程完成时被中断,则 join 方法可能会引发 InterruptedException

thread.yield()表示线程主动放弃CPU,它用于暂时暂停当前线程的执行,并允许其他线程运行。它会被移动到等待运行的线程队列的末尾,其他线程将有机会运行。

“thread.yield()”仅在多线程环境中有效,其行为是平台相关的。不能保证在线程调用”thread.yield()”后立即运行其他线程。实际行为可能取决于操作系统使用的调度程序和其他线程在队列中的优先级。

wait():当一个线程A调用一个共享变量的 wait()方法时, 线程A会被阻塞挂起, 发生下面几种情况才会返回:

(1) 线程A调用了共享对象 notify()或者 notifyAll()方法;

(2)其他线程调用了线程A的 interrupt() 方法,线程A抛出InterruptedException异常返回。

*6. 线程有哪几种状态?

状态 说明
NEW 初始状态:线程被创建,但还没有调用start()方法
RUNNABLE 运行状态:Java线程将操作系统中的就绪和运行两种状态笼统的称作“运行”
BLOCKED 阻塞状态:表示线程阻塞于锁
WAITING 等待状态:表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作(通知或中断)
TIME_WAITING 超时等待状态:该状态不同于 WAITIND,它是可以在指定的时间自行返回的
TERMINATED 终止状态:表示当前线程已经执行完毕

7. 什么是守护线程?

线程一般分用户线程和守护线程。

在JVM启动时会调用main函数,main函数所在线程就是用户线程。但是在JVM内部其实还存在其他很多线程,比如垃圾回收线程。在JVM退出后,用户线程会退出,但是守护线程不一定。

8. 线程间有哪些通信方式?

  1. 全局变量:线程可以访问全局变量并对其进行读写操作。
  2. 管道(pipe):管道是一种特殊的文件,它允许不相关进程间的数据交换。
  3. 信号量(semaphore):信号量是一种控制多个线程同时访问共享资源的方法。
  4. 消息队列(message queue):消息队列允许线程通过发送和接收消息来通信。
  5. 共享内存(shared memory):共享内存是一段可供多个线程访问的内存。
  6. 互斥量(mutex):互斥量是一种同步机制,用于防止多个线程同时对共享资源进行访问。
  7. 条件变量(condition variable):条件变量是一种同步机制,用于控制线程的执行顺序。

这些通信方式在不同的操作系统和编程语言中可能会有所差异,请以具体环境为准。

在Java中方式如下:

线程间通信方式

9. 什么是ThreadLocal?

ThreadLocal,也就是线程本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了线程安全问题。

ThreadLocal线程副本

  • 创建

创建了一个ThreadLocal变量localVariable,任何一个线程都能并发访问localVariable。

1
public static ThreadLocal<String> localVariable = new ThreadLocal<>();
  • 写入

线程可以在任何地方使用localVariable,写入变量。

1
localVariable.set("鄙人三某”);
  • 读取

线程在任何地方读取的都是它写入的变量。

1
localVariable.get();

如果修改了ThreadLocal的值,它将在本线程内生效。每个线程都有自己的ThreadLocal副本,并且每个线程只能修改它自己的副本。因此,当您修改了线程的ThreadLocal值时,不会影响其他线程中的ThreadLocal值。

例如,如果使用ThreadLocal来存储当前用户的名称,并在多个线程中处理请求,则每个线程都有自己的副本,存储了当前处理请求的用户的名称。如果某个线程修改了它的ThreadLocal值,则不会影响其他线程的ThreadLocal值。

10. 使用ThreadLocal举例?

可以用来做用户信息上下文的存储。

我们的系统应用是一个典型的MVC架构,登录后的用户每次访问接口,都会在请求头中携带一个token,在控制层可以根据这个token,解析出用户的基本信息。那么问题来了,假如在服务层和持久层都要用到用户信息,比如rpc调用、更新用户获取等等,那应该怎么办呢?

一种办法是显式定义用户相关的参数,比如账号、用户名……这样一来,我们可能需要大面积地修改代码,多少有点瓜皮,那该怎么办呢?

这时候我们就可以用到ThreadLocal,在控制层拦截请求把用户信息存入ThreadLocal,这样我们在任何一个地方,都可以取出ThreadLocal中存的用户数据。

ThreadLoca存放用户上下文

很多其它场景的cookie、session等等数据隔离也都可以通过ThreadLocal去实现。

我们常用的数据库连接池也用到了ThreadLocal:

  • 数据库连接池的连接交给ThreadLocal进行管理,保证当前线程的操作都是同一个Connnection。

ThreadLocal 内存泄露是怎么回事?:https://tobebetterjavaer.com/sidebar/sanfene/javathread.html#_13-threadlocal-%E5%86%85%E5%AD%98%E6%B3%84%E9%9C%B2%E6%98%AF%E6%80%8E%E4%B9%88%E5%9B%9E%E4%BA%8B

11. Java中父子线程如何共享数据?

父线程能用ThreadLocal来给子线程传值吗?毫无疑问,不能。那该怎么办?

这时候可以用到另外一个类——InheritableThreadLocal

使用起来很简单,在主线程的InheritableThreadLocal实例设置值,在子线程中就可以拿到了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class InheritableThreadLocalTest {

public static void main(String[] args) {
final ThreadLocal threadLocal = new InheritableThreadLocal();
// 主线程
threadLocal.set("不擅技术");
//子线程
Thread t = new Thread() {
@Override
public void run() {
super.run();
System.out.println("鄙人三某 ," + threadLocal.get());
}
};
t.start();
}
}

原理:在Thread类里还有另外一个变量:

1
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

在Thread.init的时候,如果父线程的inheritableThreadLocals不为空,就把它赋给当前线程(子线程)的inheritableThreadLocals

1
2
3
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);

12. volatile关键字的作用?

volatile有两个作用,保证可见性有序性

可见性:

相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile就是更轻量的选择,它没有上下文切换的额外开销成本。

关键字volatile可以用来修饰字段(成员变量),volatile可以确保对某个变量的更新对其他线程马上可见,一个变量被声明为volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存 当其它线程读取该共享变量 ,会从主内存重新获取最新值,而不是使用当前线程的本地内存中的值。

例如,我们声明一个 volatile 变量 volatile int x = 0,线程A修改x=1,修改完之后就会把新的值刷新回主内存,线程B读取x的时候,就会清空本地内存变量,然后再从主内存获取最新值。

volatile内存可见性

有序性:

重排序可以分为编译器重排序和处理器重排序,valatile保证有序性,就是通过分别限制这两种类型的重排序。

*13. 简单介绍下synchronized?

Synchronized是用来同步代码,使其在多线程环境下,保证原子性的。

Synchronized有三种用法,一种用于实例方法,一种用于静态代码块,一种用于静态方法。

  • 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
1
2
3
synchronized void method() {
//业务代码
}
  • 修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例 ,进⼊同步代码前要获得当前 class 的锁。因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管 new 了多少个对象,只有⼀份)。

    如果⼀个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调⽤这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

1
2
3
synchronized void staic method() {
//业务代码
}
  • 修饰代码块 :指定加锁对象,对给定对象/类加锁。 synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁。 synchronized(类.class) 表示进⼊同步代码前要获得 当前 class 的锁
1
2
3
synchronized(this) {
//业务代码
}

14. Monitor是什么?

Monitor是一种同步机制,在Java虚拟机(HotSpot)中,Monitor由ObjectMonitor实现的,可以叫内部锁或者Monitor锁。

15. 除了原子性,synchronized可见性,有序性,可重入性怎么实现?

  • 可见性:

在加锁前,将清空线程本地内存中共享变量的值,从而使用共享变量时需要从共享内存中读取。

synchronized的可见性是保证在一个线程访问共享内存时,不让其他线程同时访问,并且在线程修改完某个变量后,将修改后的值,同步刷回到共享内存。

  • 有序性:

synchronized的有序性是保证在一条线程访问时,禁止另一条线程进入。因为as-if-serial语义的存在,单线程的程序能保证最终结果是有序的,但是并不会禁止底层的指令重排。

  • 可重入性:

synchronized的可重入性是指允许一个线程二次请求自己持有对象锁的临界资源,是通过其本身的一个计数器保证的。当一条线程获取到锁时,count计数器加1,线程执行完成后则减1,直到被清零释放锁。

16. as-if-serial又是什么?单线程的程序一定是顺序的吗?

as-if-serial是指:不管底层指令如何重排序,程序的执行结果不能被改变。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。如果不存在数据依赖关系,则可能被重排序。看以下示例:

1
2
3
double pi = 3.14;   // A
double r = 1.0; // B
double area = pi * r * r; // C

在上面的示例中,C依赖于A和B,但是A和B之前不存在依赖关系。因此,执行顺序可能是A-B-C或B-A-C。

17. 什么是指令重排?

指令重排可以分三种情况:

  1. 编译器优化的重排序:在语句执行时,如果不存在依赖关系,则可以进行重排序。
  2. 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,使得加载和存储看上去是乱序执行。
  3. 指令并行的重排序:现代处理器采用指令并行技术,使得多条指令可以并行执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

18. 锁的状态有哪些?Synchronized锁是如何升级的?

状态有无锁、偏向锁、轻量级锁、重量级锁。升级是从 无锁->偏向锁->轻量级锁->重量级锁。

在JDK1.6以前Synchronized实现直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁。从JDK6开始,HotSpot虚拟机开发团队对Java中的锁进行优化。

Java对象头里,有一块结构,叫Mark Word标记字段,这块结构会随着锁的状态变化而变化。

64 位虚拟机 Mark Word 是 64bit,我们来看看它的状态变化:

Mark Word变化

19. 介绍一下各种锁状态?

偏向锁:偏向锁是在单线程环境下使用的,是防止CAS过度消耗资源。引入偏向锁是为了在多线程竞争的情况下尽量减少不必要的轻量级 锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令。(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所 以偏向锁的撤销操作的性能损耗必须小于节省下来的 CAS 原子指令的性能消耗)。

Hotspot 的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线 程多次获得,因此有了偏向锁。

轻量级锁:它的本意是在没有多线程竞争的前提下,减少传统的重量 级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场 景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀 为重量级锁。

轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进 一步提高性能。

重量级锁:Synchronized依赖于对象内部的Monitor锁实现。但是这个Monitor锁本质是依赖于底层操作系统的Mutex Lock实现,而线程从用户态切换到内核态开销巨大,这就是为什么重量级锁耗费资源的原因。

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 适用于只有一个线程访问同步块场景。
轻量级锁 竞争的线程不会阻塞,而是自旋,提高了程序的响应速度。 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 追求响应时间。同步块执行速度非常快。
重量级锁 线程竞争不使用自旋,不会消耗CPU。 线程阻塞,响应时间缓慢。 追求吞吐量。同步块执行速度较长。

20. 介绍一下锁的其他优化?

适应性自旋:在轻量级锁获取不到CPU时,是会通过自旋不断获取的,然而这是比较耗费CPU的,因此做了相关优化。比如某一次自旋获取CPU成功了,那么下一次自旋次数便会增加;如果获取失败了,下一次便会减少。

锁粗化:当连续几段代码有加锁、解锁操作时,将其合并在一起操作。例如:

1
2
3
4
5
6
7
8
public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
}

这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

锁消除:锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。

21. ConcurrentHashMap中的分段锁思想?

分段锁其实是一种思想,而ConcurrentHashMap则是这种思想的最佳实践。

与ConcurrentHashMap相似的有HashTable,但是因为效率低下而被弃用,其效率低下的主要原因就是众多线程竞争同一把锁。

而ConcurrentHashMap则允许容器中有多个锁,每把锁锁一部分数据,这样就提高了并发时的效率。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问

img

Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。

22. 什么是可重入锁?

可重入锁(Reentrant Lock)是一种常用的同步机制,允许同一线程多次获取锁。也就是说,如果一个线程已经获得了锁,它在释放该锁之前可以再次获取该锁。这是一种递归锁,用于保护复杂的代码块,其中一个线程可以在不释放锁的情况下进入该代码块多次。

可重入锁在多线程环境下非常有用,因为它允许线程在获得锁后再次请求锁而不会发生死锁,并且提高了代码的可读性和可维护性。

例如,在 Java 中,可重入锁通常实现为 java.util.concurrent.locks.ReentrantLock 类。

*23. 说说Synchronized和ReentrantLock的区别?

  • 锁的实现: synchronized是Java语言的关键字,基于JVM实现。而ReentrantLock是基于JDK的API层面实现的(一般是lock()和unlock()方法配合try/finally 语句块来完成。)

  • 性能: 在JDK1.6锁优化以前,synchronized的性能比ReenTrantLock差很多。但是JDK6开始,增加了适应性自旋、锁消除等,两者性能就差不多了。

  • 功能特点:

    ReentrantLock 比 synchronized 增加了一些高级功能,如等待可中断、可实现公平锁、可实现选择性通知。

    • ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制
    • ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
    • synchronized与wait()和notify()/notifyAll()方法结合实现等待/通知机制,ReentrantLock类借助Condition接口与newCondition()方法实现。
    • ReentrantLock需要手工声明来加锁和释放锁,一般跟finally配合释放锁。而synchronized不用手动释放锁。

synchronized和ReentrantLock的区别

24. 什么是AQS?

AbstractQueuedSynchronizer 抽象同步队列,简称 AQS ,它是Java并发包的根基,并发包中的锁就是基于AQS实现的。

AQS内容较多,暂不过多关注。

25. 简单介绍下ReentrantLock

ReentrantLock是可重入独占锁,只能有一个线程可以获取该锁,其它获取该锁的线程会被阻塞而被放入该锁的阻塞队列里面。

举例看其加锁操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Example {
private int counter = 0;
private ReentrantLock lock = new ReentrantLock();
public void incrementCounter() {
lock.lock(); // 加锁
try {
counter++;
} finally {
lock.unlock(); // 释放锁
}
}
public int getCounter() {
return counter;
}
}

new ReentrantLock() 构造函数默认创建的是非公平锁 NonfairSync。

26. Java有哪些保证原子性的方法?如何保证多线程下i++ 结果正确?

  • 使用原子操作类,例如AtomicInteger,实现i++原子操作
  • 使用juc包下的ReentrantLock(),对i++加锁
  • 使用Synchronized,对i++加锁

27. 原子操作类有哪些?

原子操作类

28. AtomicInteger的原理?

原理就是基于CAS的。

***29. 什么是线程死锁?如何避免?

死锁是指两个线程同时竞争同一个资源,而造成相互等待的现象。

线程死锁的四个条件:互斥条件、请求与保持条件、不可剥夺条件、环路等待条件。

  • 互斥条件:指一个线程对已经获取到的资源排他性使用,当一个线程持有当前资源时,直至其释放,其他线程只能等待。直到占有资源的线程释放资源。
  • 请求与保持条件:当一个线程持有一个资源时,又请求另一个资源,而另一个资源被占用,从而一直等待造成阻塞,当前线程也不释放资源。
  • 不可剥夺条件:当一个线程持有一个资源时,其他线程不可剥夺其资源,直到自身使用完成后自己释放。
  • 环路等待条件:一个线程集合中的线程,形成了环路等待。

避免死锁需要至少破坏以上四个条件的其中一个:

  • 对于互斥条件,不可以破坏,因为加锁就是为了互斥。
  • 对于请求与保持条件,可以通过一次性申请全部资源来破坏。
  • 对于不可剥夺条件,在请求遇到阻塞时,可以通过释放自身的资源来破坏。
  • 对于环路等待条件,可以通过给线程分配优先级,使其有顺序的执行来破坏。

30. CountDownLatch(倒计数器)了解吗?

场景1:协调子线程结束动作:等待所有子线程运行结束

例如,我们很多人喜欢玩的王者荣耀,得等所有人都点击确认之后,才能到选英雄阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(5);

Thread 大乔 = new Thread(countDownLatch::countDown);
Thread 兰陵王 = new Thread(countDownLatch::countDown);
Thread 安其拉 = new Thread(countDownLatch::countDown);
Thread 哪吒 = new Thread(countDownLatch::countDown);
Thread 铠 = new Thread(() -> {
try {
// 稍等,上个卫生间,马上到...
Thread.sleep(1500);
countDownLatch.countDown();
} catch (InterruptedException ignored) {}
});

大乔.start();
兰陵王.start();
安其拉.start();
哪吒.start();
铠.start();
countDownLatch.await();
System.out.println("所有玩家已经就位!");
}

new CountDownLatch(5)用户创建初始的latch数量,各玩家通过countDownLatch.countDown()完成状态确认,主线程通过countDownLatch.await()等待。

场景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
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(1);

Thread 大乔 = new Thread(() -> waitToFight(countDownLatch));
Thread 兰陵王 = new Thread(() -> waitToFight(countDownLatch));
Thread 安其拉 = new Thread(() -> waitToFight(countDownLatch));
Thread 哪吒 = new Thread(() -> waitToFight(countDownLatch));
Thread 铠 = new Thread(() -> waitToFight(countDownLatch));

大乔.start();
兰陵王.start();
安其拉.start();
哪吒.start();
铠.start();
Thread.sleep(1000);
countDownLatch.countDown();
System.out.println("敌方还有5秒达到战场,全军出击!");
}

private static void waitToFight(CountDownLatch countDownLatch) {
try {
countDownLatch.await(); // 在此等待信号再继续
System.out.println("收到,发起进攻!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}

在这个场景中,仍然用五个线程代表大乔、兰陵王、安其拉、哪吒和铠等五个玩家。需要注意的是,各玩家虽然都调用了start()线程,但是它们在运行时都在等待countDownLatch的信号,在信号未收到前,它们不会往下执行。

31. 什么是线程池?

线程池,简单说就是一个拥有很多线程的容器。合理使用线程池有三个好处:

  1. 降低资源消耗
  2. 提高响应速度
  3. 提高线程的可管理性

32. 实际使用线程池的例子?

用户注册时,防止同一时刻用户注册量过大,使用了多线程。

**33. 线程池的工作流程?

  1. 当线程池创建时,是没有线程的,任务队列通过参数的形式传入。
  2. 当ThreadPoolExecutor调用 execute() 方法添加一个任务时,线程池会做如下判断:
  • 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
  • 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
  • 如果队列满了,但是线程数小于最大线程数,那么还是要创建非核心线程立刻运行这个任务;
  • 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会根据拒绝策略来对应处理。
  1. 当一个线程完成任务后,它会从队列中取下一个任务来执行。
  2. 当一个线程闲置时,此时若线程数大于corePoolSize,那么这个线程则会被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。

34. 线程池的主要参数有哪些?

  • corePoolSize:核心线程数。如果线程数小于核心线程数,那么新增一个任务便会创建一个新的线程来处理。如果线程数等于核心线程数,那么新增的任务会放到任务队列中。
  • maximumSize:最大线程数。如果任务队列满了还有新的线程,并且线程总数小于最大线程数,那么会创建新的线程来处理任务。
  • keepAliveTime:非核心线程存活时间。
  • unit:非核心线程存活时间单位
  • workQueue:工作队列。当核心线程满了,队列还没满的时候,任务会先进入队列中。
  • threadFactory:创建线程使用的工厂
  • handler:拒绝策略

*35. 线程池拒绝策略有哪些?

  • AbortPolicy:直接抛出RejectedExecutionException异常。
  • CallerRunsPolicy:用调用者所在的线程来执行任务。
  • DiscardOldestPolicy:丢弃最老的任务
  • DiscardPolicy:直接丢弃任务。

下面是一个配置线程池拒绝策略的代码示例:

1
2
3
Executor executor = new ThreadPoolExecutor(10,100,60,TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadPoolExecutor.CallerRunsPolicy());

如果要实现自己的拒绝策略,则可以实现RejectedExecutionHandler接口。

36. 线程池有哪几种工作队列?

  • ArrayBlockingQueue:是一个有界阻塞队列,按FIFO排序。
  • LinkedBlockingQueue:是可以设置容量的链表结构的阻塞队列,按FIFO进行排序。如果不对容量进行设置,则是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE。吞吐量高于ArrayBlockingQueue,newFixedThreadPool线程池使用的是该队列。
  • DelayQueue:是一个任务定时周期的延迟执行的队列。根据指定的执行时间从小到大排列,否则根据插入的先后顺序排列。newScheduleThreadPool线程池使用了该队列。
  • PriorityBlockingQueue:具有优先级的无阻塞队列。
  • SynchronousQueue:是一个不存储元素的阻塞队列,当一个线程进行插入操作时,必须有另一个线程进行移除操作,否则该插入操作将被一直阻塞。newCacheThreadPool使用了该队列。

***37. 常见的线程池有哪些?

常见的线程池有以下四种:

  • newFixedThreadPool:固定线程数目的线程池。
  • newSingleThreadPool:单线程的线程池。
  • newCacheThreadPool:可缓存的线程池。
  • newScheduleThreadPool:定时及周期执行的线程池。

**38. 四种线程池的原理和适用场景?

newSingleThreadExecutor:
1
2
3
4
5
6
7
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory));
}

线程池特点

  • 核心线程数为1
  • 最大线程数也为1
  • 阻塞队列是无界队列LinkedBlockingQueue,可能会导致OOM
  • keepAliveTime为0

工作流程:

  • 提交任务
  • 线程池中是否有一条线程
  • 如果有,则进入阻塞队列,没有则执行,执行完再取下一个
newFixedThreadPool:
1
2
3
4
5
6
7
public static ExecutorService newFixedThreadExecutor(ThreadFactory threadFactory) {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory));
}

线程池特点

  • 核心线程数和最大线程数一样,自定义
  • 阻塞队列是无界队列LinkedBlockingQueue,可能会导致OOM
  • keepAliveTime为0

工作流程:

  • 提交任务
  • 如果线程数少于核心线程,创建核心线程执行任务
  • 如果线程数等于核心线程,把任务添加到LinkedBlockingQueue阻塞队列
  • 如果线程执行完任务,去阻塞队列取任务,继续执行。

使用场景

FixedThreadPool 适用于处理CPU密集型的任务,确保CPU在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。

newScheduleThreadPool:
1
2
3
4
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue());
}

线程池特点

  • 最大线程数为Integer.MAX_VALUE,也有OOM的风险
  • 阻塞队列是DelayedWorkQueue
  • keepAliveTime为0
  • scheduleAtFixedRate() :按某种速率周期执行
  • scheduleWithFixedDelay():在某个延迟后执行

工作机制

  • 线程从DelayQueue中获取已到期的ScheduledFutureTask(DelayQueue.take())。到期任务是指ScheduledFutureTask的time大于等于当前时间。
  • 线程执行这个ScheduledFutureTask。
  • 线程修改ScheduledFutureTask的time变量为下次将要被执行的时间。
  • 线程把这个修改time之后的ScheduledFutureTask放回DelayQueue中(DelayQueue.add())。
newCacheThreadPool:
1
2
3
4
5
6
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
threadFactory);
}

线程池特点:

  • 核心线程数为0
  • 最大线程数为Integer.MAX_VALUE,即无限大,可能会因为无限创建线程,导致OOM
  • 阻塞队列是SynchronousQueue
  • 非核心线程空闲存活时间为60秒

工作流程:

  • 提交任务
  • 因为没有核心线程,所以任务直接加到SynchronousQueue队列。
  • 判断是否有空闲线程,如果有,就去取出任务执行。
  • 如果没有空闲线程,就新建一个线程执行。
  • 执行完任务的线程,还可以存活60秒,如果在这期间,接到任务,可以继续活下去;否则,被销毁。

适用场景

用于并发执行大量短期的小任务。

39. 线程池提交execute和submit有什么区别?

  1. execute 用于提交不需要返回值的任务
1
2
3
4
5
6
threadsPool.execute(new Runnable() { 
@Override
public void run() {
// TODO Auto-generated method stub }
});
}
  1. submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个 future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值
1
2
3
4
5
6
7
8
9
10
Future<Object> future = executor.submit(harReturnValuetask); 
try {
Object s = future.get();
} catch (InterruptedException e) {
// 处理中断异常
} catch (ExecutionException e) {
// 处理无法执行任务异常
} finally {
// 关闭线程池 executor.shutdown();
}

40. 线程池的线程数应该怎么配置?

一般的经验,不同类型线程池的参数配置:

  1. 计算密集型一般推荐线程池不要过大,一般是CPU数 + 1,+1是因为可能存在页缺失(就是可能存在有些数据在硬盘中需要多来一个线程将数据读入内存)。如果线程池数太大,可能会频繁的 进行线程上下文切换跟任务调度。获得当前CPU核心数代码如下:
1
Runtime.getRuntime().availableProcessors();
  1. IO密集型:线程数适当大一点,机器的CPU核心数*2。
  2. 混合型:可以考虑根绝情况将它拆分成CPU密集型和IO密集型任务,如果执行时间相差不大,拆分可以提升吞吐量,反之没有必要。

当然,实际应用中没有固定的公式,需要结合测试和监控来进行调整。

41. 如何关闭线程池?

可以通过调用线程池的shutdownshutdownNow方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。

shutdown() 将线程池状态置为shutdown,并不会立即停止

  1. 停止接收外部submit的任务
  2. 内部正在跑的任务和队列里等待的任务,会执行完
  3. 等到第二步完成后,才真正停止

shutdownNow() 将线程池状态置为stop。一般会立即停止,事实上不一定

  1. 和shutdown()一样,先停止接收外部提交的任务
  2. 忽略队列里等待的任务
  3. 尝试将正在跑的任务interrupt中断
  4. 返回未执行的任务列表

shutdown 和shutdownnow简单来说区别如下:

  • shutdownNow()能立即停止线程池,正在跑的和正在等待的任务都停下了。这样做立即生效,但是风险也比较大。
  • shutdown()只是关闭了提交通道,用submit()是无效的;而内部的任务该怎么跑还是怎么跑,跑完再彻底停止线程池。

42. 线程池有哪些状态

RUNNING、SHUTDOWM、STOP、TIDYING、TERMINATED

线程池各个状态切换如图所示:

线程池状态切换图

***43. 你能设计实现一个线程池吗?

  • 线程池中有N个工作线程
  • 把任务提交给线程池运行
  • 如果线程池已满,把任务放入队列
  • 最后当有空闲时,获取队列中任务来执行

代码参考Cubox的 “一个简单线程池的实现”

44. 单机线程池执行断电了应该怎么处理?

可以对任务队列和线程池做持久化处理,等待恢复供电后根据回溯日志做出相关处理。

45. 简单介绍下Fork/Join框架?

Fork/Join框架是Java7提供的一个用于并行执行任务的框架,Fork/Join框架采用的是分而治之的思想,当有许多任务时,分割成许多小任务,将小任务放到不同的任务队列中,创建各自的线程处理这些任务。当有线程先完成任务时,它去其它线程的队列里窃取一个任务来执行,这种方式叫做工作窃取。

减少窃取任务线程和被窃取任务线程之间的竞争,通常任务会使用双端队列,被窃取任务线程永远从双端队列的头部拿,而窃取任务的线程永远从双端队列的尾部拿任务执行。