开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

【评论抽掘金好礼】全方位解读服务网格(Service Mes

发表于 2021-10-08

本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。

一直以来“微服务”都是一个热门的词汇,在各种技术文章、大会上,关于微服务的讨论和主题都很多。对于基于 Dubbo、SpringCloud 技术体系的微服务架构,已经相当成熟并被大家所知晓,但伴随着互联网场景的复杂度提升、业务快速变更以及快速响应,如何快速、稳定、高效的应对变幻莫测的业务市场需求,这类技术体系(如:Spring Cloud)的传统微服务架构就变得力不从心,此时微服务架构再次升级,将服务网格作为了新一代微服务架构。

本文将从传统微服务架构出发,为大家阐述新一代微服务架构–服务网格,它能解决什么问题,为用户带来什么,可作为你对服务网格的认知文章。

1、背景

微服务,也称之为微服务架构,是一种架构风格,相比单体应用,它将应用程序拆分为一组服务,并将这些服务组合起来来完成整个复杂的业务功能。下面这些特征就能高度反映出它的价值所在:

  • 高度可维护和可测试性
  • 松耦合
  • 独立部署
  • 围绕业务能力进行组织
  • 小团队拥有

简单的回顾完微服务架构的概念,我们一起看看新一代微服务架构是如何诞生的。

1.1 基于 Spring Cloud 的微服务体系

下面这张图是基于 Spring Cloud 技术体系的微服务架构图:

Spring Cloud的微服务体系

针对上图,从以下两个方面解读:

  • 技术栈:众所周知,Spring Cloud 相关技术组件很多,虽说大部分都是开箱即用,一旦深度使用,学习成本是很高的,对于初学者(非 Spring Cloud 使用者)来讲门槛很高。(太多,而且还都要掌握,太难了!)

Spring Cloud组件

  • 实现:所有微服务都需要将自身注册到注册中心(如:Consul、Eureka 等),来完成服务间的相互调用。每个微服务都必须依赖 Spring Cloud 组件(即:在 pom.xml 中引入),业务逻辑和 Spring Cloud 组件共生在同一个服务中。

还记得 Spring Cloud 相关组件版本升级时的烦恼么?为了使用新版本中的某个特性,或者解决旧版本中存在的漏洞,Spring Cloud 版本升级屡见不鲜,一不留神就会出现版本依赖冲突、启动不了等等问题,升级完还得安排测试人员测试验证。技术含量不高,但确实招人烦啊。

再完美的程序,也避免不了零 bug。上线之后,随着系统使用场景的多样性,将逐步会暴露出一些问题,而出现问题就得解决问题,并小心翼翼安排上线,这一系列过程,想必各位肯定深有感触,各有故事。用“小心翼翼”来形容这一过程决不夸张,因为一个小小的改动可能会影响到其它,甚至整个系统,这锅谁都不太想背,能不改打死都不改的原则一直是不愿被打破的壁垒。

在传统行业(如:银行),由于系统的多样性、庞大、复杂性,全部加入微服务行列是不现实的,新老系统共存是一种最为常见的现象。而共存系统间的治理、运维等成了老大难问题。

面对升级、维护、新老系统共存等这些问题,难道就束手无策了吗?

1.2 传统微服务架构面临的挑战

面对上述暴露出的问题,并在传统微服务架构下,经过实践的不断冲击,面临了更多新的挑战,综上所述,产生这些问题的原因有以下这几点:

  • 过于绑定特定技术栈 当面对异构系统时,需要花费大量精力来进行代码的改造,不同异构系统可能面临不同的改造。
  • 代码侵入度过高 开发者往往需要花费大量的精力来考虑如何与框架或 SDK 结合,并在业务中更好的深度融合,对于大部分开发者而言都是一个高曲线的学习过程。
  • 多语言支持受限 微服务提倡不同组件可以使用最适合它的语言开发,但是传统微服务框架,如 Spring Cloud 则是 Java 的天下,多语言的支持难度很大。这也就导致在面对异构系统对接时的无奈,或选择退而求其次的方案了。
  • 老旧系统维护难 面对老旧系统,很难做到统一维护、治理、监控等,在过度时期往往需要多个团队分而管之,维护难度加大。

上述这些问题都是在所难免,我们都知道技术演进来源于实践中不断的摸索,将功能抽象、解耦、封装、服务化。 随着传统微服务架构暴露出的这些问题,将迎来新的挑战,让大家纷纷寻找其他解决方案。

1.3 迎来新一代微服务架构

为了解决传统微服务面临的问题,以应对全新的挑战,微服务架构也进一步演化,最终催生了服务网格(Service Mesh)的出现,迎来了新一代微服务架构,也被称为下一代微服务。为了更好地理解 Service Mesh 的概念和存在的意义,让我们我们来回顾一下这一演进过程中的四个阶段。

Service Mesh演进历程

  • 耦合阶段:高度耦合、重复实现、维护困难,在耦合架构设计中体现的最为突出,单体架构就是典型的代表。
  • 公共 SDK:让基础设施功能设计成为公共 SDK,提高利用率,是解藕最有效的途径,比如 Spring Cloud 就是类似的方式。但学习成本高、特定语言实现,却将一部分人拦在了门外。
  • Sidecar 模式:再次深度解藕,不单单功能解藕,更从跨语言、更新发布和运维等方面入手,实现对业务服务的零侵入,更解藕于开发语言和单一技术栈,实现了完全隔离,为部署、升级带来了便利,做到了真正的基础设施层与业务逻辑层的彻底解耦。另一方面,Sidecar 可以更加快速地为应用服务提供更灵活的扩展,而不需要应用服务的大量改造。
  • Service Mesh:把 Sidecar 模式充分应用到一个庞大的微服务架构系统中来,为每个应用服务配套部署一个 Sidecar 代理,完成服务间复杂的通信,最终就会得到一个的网络拓扑结构,这就是 Service Mesh,又称之为“服务网格“。它从本质上解决了传统微服务所面临的问题。

2、服务网格介绍

本节从服务网格的定义、核心价值、架构等方面介绍,让你对它有个充分的认知、了解。

2.1 服务网格的定义

服务网格(Service Mesh),作为服务间通信的基础设施层。是轻量级高性能网络代理,提供安全的、快速的、可靠地服务间通讯,与实际应用部署一起,但对应用透明。应用作为服务的发起方,只需要用最简单的方式将请求发送给本地的服务网格代理,然后网格代理会进行后续的操作,如服务发现,负载均衡,最后将请求转发给目标服务。

归纳起来,如下图:

Service Mesh定义

2.2 服务网格的核心价值

实现业务逻辑和非业务逻辑的分离。

  • 为下沉到基础设施提供可能:将微服务通信下沉到基础设施层,屏蔽了微服务处理各种通信问题的复杂度。
  • 帮助应用轻量化,专注业务:开发者无需关心通信层和服务治理的具体实现,真正像本地调用一样使用微服务,通信相关的一切工作直接交给 Service Mesh,让开发者更关注于业务的开发。
  • 实现应用的云原生化:加速应用上云,实现云原生化

2.3 主要功能特性

那么服务网格到底能带来哪些实用的功能呢?

服务网格的主要功能特性

  • 流量控制:为应用提供智能路由(如,金丝雀发布、A/B 测试等)、超时重试、熔断、故障注入、流量镜像等各种控制能力。
  • 策略:可以为流量设置配额、黑白名单等策略。
  • 网络安全:提供服务间访问控制、 TLS 加密通信。
  • 可观测性:为所有通信生成详细的遥测数据,包括指标数据、日志、追踪,提供给应用系统完整的监控能力。

2.4 整体架构

服务网格整体架构如下图所示:

服务网格整体架构

主要核心内容分为:

  • 控制平面: 控制和管理数据平面中的 Sidecar 代理,完成配置分发、服务发现、流量路由、授权鉴权等功能,以达到对数据平面的统一管理。
  • 数据平面: 由整个网格内的 Sidecar 代理组成,这些代理以 Sidecar 的形式和应用服务一起部署。这些代理负责协调和控制应用服务之间的所有网络通信。每一个 Sidecar 会接管进入和离开服务的流量,并配合控制平面完成流量控制等方面的功能。

2.5 Istio 框架

Istio 是由 Google 、IBM 和 Lyft 发起的开源的服务网格框架。该项目在 2017 年推出,截止目前已发布了 1.10.1 版本。

Istio 提供了一个完整的解决方案,为整个服务网格提供行为洞察和操作控制,以满足微服务应用程序的多样化需求。

2.5.1 Istio 数据平面

Istio 的数据平面默认使用 Envoy ,是基于 Envoy 新增了一些扩展(即:istio-proxy),C++ 语言编写。

Istio数据平面

主要职责:

  • 服务发现:探测所有可用的上游或下游服务实例。
  • 健康检测:探测上游或下游服务实例是否健康,是否准备好接收网络流量。
  • 流量路由:将网络请求路由到正确的上游或下游服务。
  • 负载均衡:在对上游或下游服务进行请求时,选择合适的服务实例接收请求,同时负责处理超时、断路、重试等情况。
  • 身份验证和授权:在 istio-agent 与 istiod 配合下,对网络请求进行身份验证、权限验证,以决定是否响应以及如何响应,使用 mTLS 或其他机制对链路进行加密等。
  • 链路追踪:对于每个请求,生成详细的统计信息、日志记录和分布式追踪数据,以便操作人员能够理解调用路径并在出现问题时进行调试。

2.5.2 Istio 控制平面

自 Istio 1.5 版本开始,控制平面由原来分散、独立部署的三个组件(Pilot、Citadel、Galley)整合为一个独立的 istiod,变成了一个单进程、多模块的组织形态(下图右图),极大的降低了原来部署的复杂度。

Istio控制平面

  • Pilot:负责 Istio 数据平面的 xDS 配置管理,具体包括服务发现、配置规则发现、xDS 配置下发。
  • Citadel:负责安全证书的管理和发放,实现授权和认证等操作。
  • Galley:负责配置的验证、提取和处理等功能,将 Istio 和底层平台(如,Kubernetes)进行解耦。

其中,Citadel、Galley 组件逐步在弱化,在 Istio 版本迭代中,已经基本看不见它们的踪迹了。(已经不断整合在其它组件中)

接下来,着重看看 Pilot 组件。

Pilot 是 Istio 中的核心组件,用于管理和配置部署在特定 Istio 服务网格中的所有 Sidecar 代理实例。它管理 Sidecar 代理之间的路由流量规则,并配置故障恢复功能,如超时、重试和熔断等。

Pilot组件

关键模块:

  • 抽象模型(Abstract model):为了实现对不同服务注册中心 (如,Kubernetes、Consul) 的支持,完成对不同输入来源数据的抽象,形成统一的存储格式。
  • 平台适配器 (Platform adapters):借助平台适配器 Pilot 实现服务注册中心数据到抽象模型之间的数据转换。
  • xDS API:是源于 Envoy 项目的标准数据平面 API, 将服务信息和流量规则下发到数据平面的 Sidecar。通过采用该标准 API, Istio 将控制平面和数据平面进行了解耦,为多种数据平面 Sidecar 实现提供了可能性,如:蚂蚁金服开源的 Golang 版本的 MOSN。
  • 用户 API(User API):提供了面向业务的高层抽象,可以被运维人员理解和使用。

3、总结

让我们一起回顾下,Spring Cloud 微服务架构和 Service Mesh 微服务架构:

Spring Cloud技术体系向Service Mesh的转变

为了解决微服务框架的侵入性问题,我们引入服务网格。

评论抽奖

欢迎在评论区讨论,掘金官方将在掘力星计划活动结束后,在评论区抽送100份掘金周边

抽奖礼物:100份,掘金官方提供,包括掘金徽章、拖鞋、马克杯、帆布袋等,随机发放

抽奖时间:「掘力星计划」活动结束后,预计3个工作日内,官方将在所有符合规则的活动文章评论区抽出

抽奖方式:掘金官方随机抽奖+人工核实

评论内容:与文章内容相关的评论、建议、讨论等,「踩踩」「学习了」等泛泛类的评论无法获奖

参考资料:

  1. microservices.io/
  2. 构建基于 Spring Cloud 向 Service Mesh 框架迁移的解决方案及思路

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

国庆假期,整整七天,我使用SpringBoot终于做出了即时

发表于 2021-10-08

小知识,大挑战!本文正在参与「程序员必备小知识」创作活动

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

前言:(本文用于辅助Flutter WebSocket的理解) 在这个假期,我完成了一个小Demo,Flutter 与 Springboot 进行websocket的通讯,为啥想要去做这个Demo呢,主要是在各大平台以及google搜索后发现,没有一个详细的例子来教大家进行一对一、一对多的通讯,大多数都是教你怎么连接,却没有教你怎么去进行下一步的功能实现,于是我利用了五天的假期,踩了无数的坑,终于是完成了它,所以,点个赞吧,不容易啊,兄弟们😭

github仓库还没有创建(可以看一下文末通知),前端界面也写好了,如果想要移动端的效果的话需要使用Flutter😎

Flutter移动端分析:Flutter WebSocket 即时通讯

先上效果图(我自己搜索这样功能性的问题时,没有效果图基本上都是不想看的):

屏幕截图 2021-10-08 122313.jpg

这个效果图为Flutter:

tt0.top-039531.gif

即时通讯最重要的功能是完成了(发送文字信息)

阅读本文的注意点:

本文参考www.zhihu.com/column/p/32…,在其基础上进行二次开发

1.需要一点WebSocket的原理知识

2.springboot使用WebSocket的方法,本章就是最普通的原生方法

WebSocket的原理知识在Flutter WebSocket这篇文章中已经讲了,这里就不再重复了

正文:

1.Springboot使用WebSocket的方法

juejin.cn/post/684490… 掘金里已经有大神详细的讲解了

这里推荐spring封装或者STOMP两种方式

讲几个注意的点(详细的步骤掘金里的大神都已经写过啦~):

  • pom.xml 配置websocket
1
2
3
4
xml复制代码<dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

如果需要用户存储功能可以使用Spring Security,用户授权非常方便

  • STOMP这个协议是非常优秀的,是一种简单的基于文本的消息传递协议

如果想学习的,我推荐这篇大佬的文章:juejin.cn/post/684490…

2.Springboot实现点对点通信

现在步入正文

  • 配置pom.xml

在默认的环境下,加上一个websocket即可,这里使用的是JDK8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
xml复制代码<properties>
  <java.version>1.8</java.version>
</properties>
<dependencies>
  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter</artifactId>
  </dependency>
  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-websocket</artifactId>
  </dependency>
​
  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
  </dependency>
</dependencies>
  • 封装前端传入的信息,以及return的值:

因为使用json进行消息的发送,所以需要先创建一个消息对象,包含了消息发送者,消息接受者,消息类型

1
2
3
4
5
6
7
8
9
arduino复制代码//数据类型
public class SocketMsg {
  private int type;//聊天类型0:群聊,1:单聊.
  private String fromUser;//发送者.
  private String toUser;//接受者.
  private String msg;//消息
​
  ...省略了get 和 set方法,不想写的话可以尝试lombook
}
  • WebSocketConfig处理

这里就简单举了个例子,文章中其实没有怎么用到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
​
@Configuration
public class WebSocketConfig {
   @Bean
   public ServletServerContainerFactoryBean createWebSocketContainer() {
       ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
       // ws 传输数据的时候,数据过大有时候会接收不到,所以在此处设置bufferSize
       container.setMaxTextMessageBufferSize(512000);
       container.setMaxBinaryMessageBufferSize(512000);
       container.setMaxSessionIdleTimeout(15 * 60000L);
       return container;
  }
}
  • 最重要的内容,逻辑处理部分MyWebSocket来啦!
  • 设置websocket连接点映射:
1
ini复制代码@ServerEndpoint(value = "/websocket/{nickname}")
  • 我们需要一个变量来存储每个客户端对应的MyWebSocket对象.
1
swift复制代码private static CopyOnWriteArraySet<MyWebSocket> webSocketSet = new CopyOnWriteArraySet<MyWebSocket>();

一个变量用来记录sessionId和该session之间的绑定关系.

1
arduino复制代码private static Map<String, Session> map = new HashMap<String, Session>();
  • 成功建立连接时
1
2
3
4
5
6
7
8
less复制代码@OnOpen
public void onOpen(Session session, @PathParam("nickname") String nickname) {
   this.session = session;
   this.nickname = nickname;
   map.put(session.getId(), session);
   webSocketSet.add(this);//加入set中.
   this.session.getAsyncRemote().sendText(nickname + "上线了,(我的频道号是" + session.getId() + ")");
}
  • 收到客户端消息后调用
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
scss复制代码@OnMessage
public void onMessage(String message, Session session, @PathParam("nickname") String nickname) {
//message 不是普通的string ,而是我们定义的SocketMsg json字符串.
try {
SocketMsg socketMsg = new ObjectMapper().readValue(message, SocketMsg.class);
//一对一聊天
if (socketMsg.getType() == 1) {
//只需要找到发送者和接受者即可.
socketMsg.setFromUser(session.getId());//发送者.
//socketMsg.setToUser(toUser);//这个是由客户端进行设置.
Session fromSession = map.get(socketMsg.getFromUser());
Session toSession = map.get(socketMsg.getToUser());
if (toSession != null) {
//发送消息
fromSession.getAsyncRemote().sendText(nickname + ":" + socketMsg.getMsg());
toSession.getAsyncRemote().sendText(nickname + ":" + socketMsg.getMsg());
} else {
fromSession.getAsyncRemote().sendText("系统消息:对方不在线或者您输入的频道号有误");
}
} else {
//群发给每个客户端
broadcast(socketMsg, nickname);
}

} catch (JsonParseException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (JsonMappingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
  • 发生错误时
1
2
3
4
csharp复制代码public void onError(Session session, Throwable error) {
System.out.println("发生错误");
error.printStackTrace();
}
  • 如果选择群发时(每个在线的客户都可以收到信息)
1
2
3
4
5
6
typescript复制代码private void broadcast(SocketMsg socketMsg, String nickname) {
for (MyWebSocket item : webSocketSet) {
//发送消息.
item.session.getAsyncRemote().sendText(nickname + ":" + socketMsg.getMsg());
}
}
  • 连接关闭
1
2
3
4
5
csharp复制代码@OnClose
public void onClose(Session session) {
webSocketSet.remove(this);//从set中移除.
map.remove(session.getId());
}

使用源码时注意,本章使用JDK8,websocket的版本可能略有不同,端口为9090,具体的使用方法可以参考Flutter WebSocket的文章最后~

通知:juejin.cn/pin/7034450…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Docker 部署Node服务

发表于 2021-10-08

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

前言

以前部署项目时候总会遇到本地环境和线上环境不一致产生排查困难的问题。在使用window系统的电脑开发学习时,但想要学习linux系统的一些操作或者项目的部署时,还需要一台服务器或者一个虚拟机,而Docker不仅部署方便而且更安全,Docker容器是个比较轻量的,占用资源少,成本低等等众多优势。所以,Docker 势必是程序员必会的一个工具之一。

以下通过使用Docker部署Node服务来加深学习

Docker部署Node服务

  • 创建Node项目,构建一个index.js文件
+ 
1
2
3
4
5
6
7
8
9
10
ini复制代码const Koa = require('koa');
const app = new Koa();
​
app.use(async ctx => {
 ctx.body = 'Hello Jasen Docker';
});
​
const port = 8004;
app.listen(port);
console.log(`http://localhost:${port}`);
+ 添加Dockerfile, Docker通过读取`Dockerfile`中的指令自动生成镜像 - vscode 安装docker插件 - 按F1 输入:Docker:add Docker Files into Workspace,然后根据相应的选项进入选择,enter 确认 - Dockfile文件中常用指令: * FROM 基础镜像 * MAINTAINER 维护者信息 * ENV 设置环境变量 * RUN 构建镜像时要执行的命令 * ADD/COPY 文件添加到容器中 * WORKDIR ==cd ,工作目录 * VOLUME 指定持久化目录 * EXPOSE 指定与外界交互的端口 * CMD:运行命令,容器启动时调用
  • 构建镜像:进入 Dockerfile 所在项目当前目录下,执行以下命令:docker build -t node-proj .
  • 创建一个该镜像的容器:运行命令: docker run -d [--name [name]] [-p [对外端口]:[对内端口]] [业务镜像名]
+ -d : 容器后台执行;--name: 容器名称;-p:端口映射;业务镜像名:image Id 或 仓库REPOSITORY

执行完毕后,通过在浏览器中访问: http://localhost:8004 可以正常在页面显示“ Hello Jasen Docker ”,整个过程成功了。

Docker Compose管理服务

本地项目更新时,镜像怎么更新?

一开始我使用的方式是,先停止容器运行再删除容器,删除镜像,重新 build 然后再重新执行 run。整个操作过于繁琐,如果我有多容器时,更加时增加了工作量。

通过 Compose,您可以使用 YML 文件来配置应用程序需要的所有服务。然后,使用一个命令,就可以从 YML 文件配置中创建并启动所有服务。

docker-compose配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
yaml复制代码version: '3.4'
​
services:
dockerNode:
  container_name: docker_web
  image: dockerproj
  build:
    context: .
    dockerfile: ./Dockerfile
  environment:
    NODE_ENV: production
  depends_on:
    - redis
    - db
  ports:
    - 8004:8004
redis:
image: redis
db:
image: postgres

执行docker-compose命令,可以对配置的服务创建并启动

1
2
arduino复制代码docker-compose stop
docker-compose up -d --build

其中,depends_on 是用于设置依赖关系。

  • docker-compose up :以依赖性顺序启动服务。在以下示例中,先启动 db 和 redis ,才会启动 web。
  • docker-compose up SERVICE :自动包含 SERVICE 的依赖项。在以下示例中,docker-compose up web 还将创建并启动 db 和 redis。
  • docker-compose stop :按依赖关系顺序停止服务。在以下示例中,web 在 db 和 redis 之前停止。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JSON Web Token(缩写 JWT) 目前最流行、最

发表于 2021-10-08

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

JSON Web Token(缩写 JWT)是目前最流行,也是最常见的跨域认证解决方案。无论是咱们后端小伙伴,还是前端小伙伴对都是需要了解。
本文介绍它的原理、使用场景、用法。

一、跨域认证的问题

1.1、常见的前后端认证方式

  • Session-Cookie
  • Token 验证(包括JWT,SSO)
  • OAuth2.0(开放授权)

1.2、Session-Cookie实现方式

流程大致如下:

1、用户向服务器发送用户名和密码。

2、服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。

3、服务器向用户返回一个 session_id,写入用户的 Cookie。

4、用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。

5、服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。

这种模式在单机时不存在什么问题,但是一旦服务器变为集群模式时,或者是跨域的服务器时,这个时候Session就必须实现数据共享。

这个时候就要考虑每台服务器如何实现对 Session 的数据共享呢??

  1. 第一种解决方式就是实现 Session 数据的持久化。各种服务收到请求时,都向数据持久层请求数据,来验证是否是正确的用户。但其实无论我们将 Session 存放在服务器哪里,都会增加服务器的负担。这种方案优点就是简单,缺点就是扩展性不好,安全性较差,容易增加服务器的负担。
  2. 第二种解决方式其实就是 JWT 的方式实现的,所有的数据不在保存到服务器端,而是保存到客户端,每次请求时都携带上 Token 令牌。

二、什么是 JWT ?

根据官网介绍:

JSON Web Token (JWT) 是一个开放标准,它定义了一种紧凑且自包含的方式,用于在各方之间作为 JSON 对象安全地传输信息。该信息可以被验证和信任,因为它是经过数字签名的。JWT 可以使用秘密(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对进行签名。

简单来理解就是 JWT 就是一个JSON对象经过加密和签名的,可以在网络中安全的传输信息,并且可以被验证和信任。

2.1、什么时候应该使用 JWT ?

我目前用的最多的地方就是在授权方面,这也是 JWT 最常见的场景,其次还可以用来交换信息。

授权例子:

用户登录后,服务器端返回一个JWT,用户保存在本地,之后的每次请求都将包含JWT,服务器验证用户携带的JWT,来判断是否允许访问服务和资源。

另外,单点登录(SSO) 也是当今广泛使用JWT的一项功能,就是在A网站登录后,在B网站也能够实现自动登录,而不需要重复登录,如你在淘宝登录了,在身份没有过期前,你去看天猫网站,也会发现你已经登录了。

简而言之:用户只需要登录一次就可以访问所有相互信任的应用系统。并且能够轻松跨不同域使用,服务器也不需要存储session相关信息,减轻了负担。

2.2、JWT 原理

其实 JWT 的原理就是,服务器认证以后,将一个 JSON 对象加密成一个紧凑的字符串(Token),发回给用户,就像下面这样。

1
2
3
4
5
6
7
8
json复制代码// JSON 对象
{
"姓名": "王五",
"角色": "管理员",
"到期时间": "2021年9月21日0点0分"
}
//加密后
eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VybmFtZSIsIm5iZiI6MTYzMjI3NzU1NCwiaXNzIjoiY3J1c2giLCJleHAiOjE2MzIyNzc2NTQsImRlbW8iOiLlj6_lrZjlgqjkv6Hmga8iLCJpYXQiOjE2MzIyNzc1NTQsImRlbW8yIjoi5Y-v5a2Y5YKo5L-h5oGvMiJ9.OuqG5Ha_Ofmh5R9Et1vqLYSAlIO85oW9D9Jq9cKKYODO643ZLiDTyQs8dl3PLsZ-_5t0xv6kfKhCzCkCYznBNA

在认证之后,用户和服务器通信时,每次都会携带上这个Token。服务器端不再存储session信息,完全依靠用户携带的Token来判断用户身份。为了安全,服务器在生成Token的时候,都会加上一个数字签名。

这样做的优势:服务器不需要再保存 session数据,减轻了服务器负担,并且基于 JWT 认证机制的应用不需要去考虑用户在哪一台服务器登录,为应用的扩展提供了便利。

2.3、JWT 数据结构

JSON Web Tokens 由用点 ( .)分隔的三个部分组成,它们是:

  • Header(头部)
  • Payload(负载)
  • Signature(签名)

因此,JWT 通常如下所示。注意:实际上是未分行的,这里是为了便于展示。

1
2
3
4
5
cmd复制代码xxxxx.yyyyy.zzzzz
如:
eyJhbGciOiJIUzUxMiJ9.
eyJzdWIiOiJ1c2VybmFtZSIsIm5iZiI6MTYzMjI3NzU1NCwiaXNzIjoiY3J1c2giLCJleHAiOjE2MzIyNzc2NTQsImRlbW8iOiLlj6_lrZjlgqjkv6Hmga8iLCJpYXQiOjE2MzIyNzc1NTQsImRlbW8yIjoi5Y-v5a2Y5YKo5L-h5oGvMiJ9.
OuqG5Ha_Ofmh5R9Et1vqLYSAlIO85oW9D9Jq9cKKYODO643ZLiDTyQs8dl3PLsZ-_5t0xv6kfKhCzCkCYznBNA

2.3.1、Header (标题)

jwt的头部承载两部分信息:

  • 声明类型,这里是jwt
  • 声明加密的算法 通常直接使用 HMAC SHA256

Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子。

1
2
3
4
5
6
> javascript复制代码{
> "alg": "HS256",
> "typ": "JWT"
> }
>
>

上面代码中,alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT。

2.3.2、Payload(有效载荷)

Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题 jwt所面向的用户
  • aud (audience):受众 接收jwt的一方
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号,jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。

除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。

1
2
3
4
5
json复制代码{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。

2.3.3、Signature(签名)

Signature 部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

1
2
3
4
5
6
javascript复制代码HMACSHA256
(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用”点”(.)分隔,就可以返回给用户。

注意:签名用于验证消息在此过程中没有更改,并且在使用私钥签名的令牌的情况下,它还可以验证 JWT 的发送者是它所说的人。secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证的关键,所以,它就是我们服务端的私钥,在任何场景都不应该泄露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了,那么安全将不复存在。

2.3.4、 Base64URL

前面提到,Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。

JWT 作为一个令牌(token),有些场合可能会 放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+、/和=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成_ 。这就是 Base64URL 算法。

2.4、JWT工具类

相关依赖:

1
2
3
4
5
xml复制代码<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>

如果是Jdk11使用的话,可能会报这样的一个错误:

1
2
3
4
5
6
7
8
9
10
cmd复制代码Exception in thread "main" java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter
at io.jsonwebtoken.impl.Base64Codec.decode(Base64Codec.java:26)
at io.jsonwebtoken.impl.DefaultJwtBuilder.signWith(DefaultJwtBuilder.java:99)
at com.crush.jwt.utils.JwtUtils.createJwt(JwtUtils.java:47)
at com.crush.jwt.utils.JwtUtils.main(JwtUtils.java:127)
Caused by: java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:581)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:521)
... 4 more

好像是因为Jdk11中没有这个类了,得加上下面这样的一个依赖:

1
2
3
4
5
xml复制代码<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>

工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
java复制代码import io.jsonwebtoken.*;

import java.util.Date;
import java.util.HashMap;

/**
* @Author: crush
* @Date: 2021-09-21 22:18
* version 1.0
*/
public class JwtUtils {

/**
* 服务器端密钥
*/
private static final String SECRET = "jwtsecretdemo";

/**
* 颁发者
*/
private static final String ISS = "crush";


/**
* 这里创建用到的时间、用户名、应该是传入进来的,
* 登录时选择是否记住我,过期时间应当是不一致的。
* @return
*/
public static String createJwt() {
HashMap<String, Object> map = new HashMap<>();
map.put("demo", "可存储信息");
map.put("demo2","可存储信息2");
String jwt = Jwts.builder()
.setClaims(map)
// jwt所面向的用户
.setSubject("username")
//设置颁发者
.setIssuer(ISS)
// 定义在什么时间之前,该jwt都是不可用的.
.setNotBefore(new Date())
//签发时间
.setIssuedAt(new Date())
//设置 JWT 声明exp (到期)值
.setExpiration(new Date(System.currentTimeMillis() + 100000))
.signWith(SignatureAlgorithm.HS512, SECRET)
//实际构建 JWT 并根据JWT 紧凑序列化 规则将其序列化为紧凑的、URL 安全的字符串。
.compact();
return jwt;
}

/**
* 获取 Claims 实例
* Claims :一个 JWT声明集 。
* 这最终是一个 JSON 映射,可以向其中添加任何值,但为了方便起见,JWT 标准名称作为类型安全的 getter 和 setter 提供。
* 因为这个接口扩展了Map&lt;String, Object&gt; , 如果您想添加自己的属性,只需使用 map 方法,
* 例如:
* claims.put("someKey", "someValue");
*
* @param jwt
* @return
*/
public static Claims getBody(String jwt) {
return Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(jwt)
.getBody();
}


/**
* 判断 JWT 是否已过期
*
* @param jwt
* @return
*/
public static boolean isExpiration(String jwt) {
return getBody(jwt)
//返回 JWT exp (到期)时间戳,如果不存在则返回null 。
.getExpiration()
//测试此日期是否在指定日期之前。
.before(new Date());
}

/**
* Subject:获取 jwt 所面向的用户
*
* @param jwt
* @return
*/
public static String getSubject(String jwt) {
return getBody(jwt).getSubject();
}

/**
* Issuer:获取颁发者
*
* @param jwt
* @return
*/
public static String getIssuer(String jwt) {
return getBody(jwt).getIssuer();
}

/**
* getClaimsValue
*
* @param jwt
* @return
*/
public static String getClaimsValue(String jwt) {
return (String) getBody(jwt).get("demo");
}

/**
* getClaimsValue
*
* @param jwt
* @return
*/
public static String getClaimsValue2(String jwt) {
return (String) getBody(jwt).get("demo2");
}

public static void main(String[] args) {
String jwt = createJwt();
System.out.println(jwt);
System.out.println("jwt 是否已经过期:"+isExpiration(jwt));
System.out.println("Claims 中所存储信息:"+getBody(jwt).toString());
System.out.println("jwt 所面向的用户:"+getSubject(jwt));
System.out.println("jwt 颁发者:"+getIssuer(jwt));
System.out.println("通过键值,取出我们自己放进 Jwt 中的信息:"+getClaimsValue(jwt));
System.out.println("通过键值,取出我们自己放进 Jwt 中的信息2:"+getClaimsValue2(jwt));
}
}

三、如何应用

此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面。

1
javascript复制代码Authorization: Bearer <token>

一般是在请求头里加入Authorization,并加上Bearer标注:

1
2
3
4
5
bash复制代码fetch('api/user/1', {
headers: {
'Authorization': 'Bearer ' + token
}
})

服务端会验证token,如果验证通过就会返回相应的资源。整个流程就是这样的:

image-20210921214504657

3.1、实际使用

实际使用过程中,我们通常是结合着Security安全框架一起使用的,大家感兴趣的话,可以来一起看看我写的这篇文章。

SpringBoot整合Security安全框架、控制权限

也可以直接看源码:Security-Gitee

四、总结

4.1、优点:

  • 因为json的通用性,JWT支持多语言,像JAVA,JavaScript,NodeJS,PHP等很多语言都可以使用。
  • 因为有了payload部分,所以JWT可以在自身存储一些其他业务逻辑所必要的非敏感信息。
  • 可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。
  • 便于传输,jwt的构成非常简单,字节占用很小,所以它是非常便于传输的。
  • 它不需要在服务端保存会话信息, 所以它易于应用的扩展

4.2、安全相关:

  • 保护好secret私钥,该私钥非常重要。如果密钥泄露,用户自己即可颁布JWT令牌,安全将不复存在。
  • 如果条件允许,JWT 不应该使用 HTTP 协议明码传输,而是要使用 HTTPS 协议传输。Https协议更安全。
  • JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。

4.3、缺点:

  • JWT 的最大优点是不需要在服务端保存会话信息,最大的缺点也是如此,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效。

五、自言自语

本文就是简单介绍了,具体使用具体情况具体分析啦。

你好,我是博主宁在春:主页

希望本篇文章能让你感到有所收获!!!

祝 我们:待别日相见时,都已有所成。

参考:

jwt

JSON Web Token 入门教程

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Interrupted Exception异常可能没你想的那

发表于 2021-10-08

摘要: 当我们在调用Java对象的wait()方法或者线程的sleep()方法时,需要捕获并处理InterruptedException异常。如果我们对InterruptedException异常处理不当,则会发生我们意想不到的后果!

本文分享自华为云社区《【高并发】由InterruptedException异常引发的思考》,作者:冰 河。

前言

当我们在调用Java对象的wait()方法或者线程的sleep()方法时,需要捕获并处理Interrupted Exception异常。如果我们对Interrupted Exception异常处理不当,则会发生我们意想不到的后果!

程序案例

例如,下面的程序代码,Interrupted Task类实现了Runnable接口,在run()方法中,获取当前线程的句柄,并在while(true)循环中,通过isInterrupted()方法来检测当前线程是否被中断,如果当前线程被中断就退出while(true)循环,同时,在while(true)循环中,还有一行Thread.sleep(100)代码,并捕获了Interrupted Exception异常。整个代码如下所示。

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
java复制代码package io.binghe.concurrent.lab08;

/**
* @author binghe
* @version 1.0.0
* @description 线程测试中断
*/
public class Interrupted Task implements Runnable{

@Override
public void run() {

Thread currentThread = Thread.currentThread();
while (true){
if(currentThread.isInterrupted()){
break;
}

try {
Thread.sleep(100);
} catch (Interrupted Exception e) {
e.printStack Trace();
}
}
}
}

上述代码的本意是通过isInterrupted()方法检查线程是否被中断了,如果中断了就退出while循环。其他线程通过调用执行线程的interrupt()方法来中断执行线程,此时会设置执行线程的中断标志位,从而使currentThread.isInterrupted()返回true,这样就能够退出while循环。

这看上去没啥问题啊!但真的是这样吗?我们创建一个Interrupted Test类用于测试,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码package io.binghe.concurrent.lab08;

/**
* @author binghe
* @version 1.0.0
* @description 测试线程中断
*/
public class InterruptedTest {
public static void main(String[] args){
InterruptedTask interruptedTask = new InterruptedTask();
Thread interruptedThread = new Thread(interruptedTask);
interruptedThread.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
interruptedThread.interrupt();
}
}

我们运行main方法,如下所示。

这竟然跟我们想象的不一样!不一样!不一样!这是为什么呢?

问题分析

上述代码明明调用了线程的interrupt()方法来中断线程,但是却并没有起到啥作用。原因是线程的run()方法在执行的时候,大部分时间都是阻塞在sleep(100)上,当其他线程通过调用执行线程的interrupt()方法来中断执行线程时,大概率的会触发Interrupted Exception异常,在触发Interrupted Exception异常的同时,JVM会同时把线程的中断标志位清除,所以,这个时候在run()方法中判断的current Thread.isInterrupted()会返回false,也就不会退出当前while循环了。

既然问题分析清除了,那如何中断线程并退出程序呢?

问题解决

正确的处理方式应该是在Interrupted Task类中的run()方法中的while(true)循环中捕获异常之后重新设置中断标志位,所以,正确的Interrupted Task类的代码如下所示。

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
java复制代码package io.binghe.concurrent.lab08;

/**
* @author binghe
* @version 1.0.0
* @description 中断线程测试
*/
public class InterruptedTask implements Runnable{

@Override
public void run() {

Thread currentThread = Thread.currentThread();
while (true){
if(currentThread.isInterrupted()){
break;
}

try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
currentThread.interrupt();
}
}
}
}

可以看到,我们在捕获Interrupted Exception异常的catch代码块中新增了一行代码。

1
ini复制代码currentThread.interrupt();

这就使得我们捕获到Interrupted Exception异常后,能够重新设置线程的中断标志位,从而中断当前执行的线程。

我们再次运行Interrupted Test类的main方法,如下所示。

总结

处理Interrupted Exception异常时要小心,如果在调用执行线程的interrupt()方法中断执行线程时,抛出了Interrupted Exception异常,则在触发Interrupted Exception异常的同时,JVM会同时把执行线程的中断标志位清除,此时调用执行线程的isInterrupted()方法时,会返回false。此时,正确的处理方式是在执行线程的run()方法中捕获到Interrupted Exception异常,并重新设置中断标志位(也就是在捕获Interrupted Exception异常的catch代码块中,重新调用当前线程的interrupt()方法)。

点击关注,第一时间了解华为云新鲜技术~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

netty系列之 使用netty搭建websocket客户端

发表于 2021-10-08

小知识,大挑战!本文正在参与「程序员必备小知识」创作活动

本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金

简介

在网速快速提升的时代,浏览器已经成为我们访问各种服务的入口,很难想象如果离开了浏览器,我们的网络世界应该如何运作。现在恨不得把操作系统都搬上浏览器。但是并不是所有的应用都需要浏览器来执行,比如服务器和服务器之间的通信,就需要使用到自建客户端来和服务器进行交互。

本文将会介绍使用netty客户端连接websocket的原理和具体实现。

浏览器客户端

在介绍netty客户端之前,我们先看一个简单的浏览器客户端连接websocket的例子:

1
2
3
4
5
6
7
8
9
10
11
12
javascript复制代码// 创建连接
const socket = new WebSocket('ws://localhost:8000');

// 开启连接
socket.addEventListener('open', function (event) {
socket.send('没错,开启了!');
});

// 监听消息
socket.addEventListener('message', function (event) {
console.log('监听到服务器的消息 ', event.data);
});

这里使用了浏览器最通用的语言javascript,并使用了浏览器提供的websocket API进行操作,非常的简单。

那么用netty客户端实现websocket的连接是否和javascript使用一样呢?我们一起来探索。

netty对websocket客户端的支持

先看看netty对websocket的支持类都有哪些,接着我们看下怎么具体去使用这些工具类。

WebSocketClientHandshaker

和websocket server一样,client中最核心的类也是handshaker,这里叫做WebSocketClientHandshaker。这个类有什么作用呢?一起来看看。

这个类主要实现的就是client和server端之间的握手。

我们看一下它的最长参数的构造类:

1
2
3
arduino复制代码   protected WebSocketClientHandshaker(URI uri, WebSocketVersion version, String subprotocol,
HttpHeaders customHeaders, int maxFramePayloadLength,
long forceCloseTimeoutMillis, boolean absoluteUpgradeUrl)

参数中有websocket连接的URI,像是:”ws://flydean.com/mypath”。

有请求子协议的类型subprotocol,有自定义的HTTP headers:customHeaders,有最大的frame payload的长度:maxFramePayloadLength,有强制timeout关闭的时间,有使用HTTP协议进行升级的URI地址。

怎么创建handshaker呢?同样的,netty提供了一个WebSocketClientHandshakerFactory方法。

WebSocketClientHandshakerFactory提供了一个newHandshaker方法,可以方便的创建各种不同版本的handshaker:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ini复制代码        if (version == V13) {
return new WebSocketClientHandshaker13(
webSocketURL, V13, subprotocol, allowExtensions, customHeaders,
maxFramePayloadLength, performMasking, allowMaskMismatch, forceCloseTimeoutMillis);
}
if (version == V08) {
return new WebSocketClientHandshaker08(
webSocketURL, V08, subprotocol, allowExtensions, customHeaders,
maxFramePayloadLength, performMasking, allowMaskMismatch, forceCloseTimeoutMillis);
}
if (version == V07) {
return new WebSocketClientHandshaker07(
webSocketURL, V07, subprotocol, allowExtensions, customHeaders,
maxFramePayloadLength, performMasking, allowMaskMismatch, forceCloseTimeoutMillis);
}
if (version == V00) {
return new WebSocketClientHandshaker00(
webSocketURL, V00, subprotocol, customHeaders, maxFramePayloadLength, forceCloseTimeoutMillis);
}

可以看到,根据传入协议版本的不同,可以分为WebSocketClientHandshaker13、WebSocketClientHandshaker08、WebSocketClientHandshaker07、WebSocketClientHandshaker00这几种。

WebSocketClientCompressionHandler

通常来说,对于webSocket协议,为了提升传输的性能和速度,降低网络带宽占用量,在使用过程中通常会带上额外的压缩扩展。为了处理这样的压缩扩展,netty同时提供了服务器端和客户端的支持。

对于服务器端来说对应的handler叫做WebSocketServerCompressionHandler,对于客户端来说对应的handler叫做WebSocketClientCompressionHandler。

通过将这两个handler加入对应pipline中,可以实现对websocket中压缩协议扩展的支持。

对于协议的扩展有两个级别分别是permessage-deflate和perframe-deflate,分别对应PerMessageDeflateClientExtensionHandshaker和DeflateFrameClientExtensionHandshaker。

至于具体怎么压缩的,这里就不详细进行讲解了, 感兴趣的小伙伴可以自行了解。

netty客户端的处理流程

前面讲解了netty对websocket客户端的支持之后,本节将会讲解netty到底是如何使用这些工具进行消息处理的。

首先是按照正常的逻辑创建客户端的Bootstrap,并添加handler。这里的handler就是专门为websocket定制的client端handler。

除了上面提到的WebSocketClientCompressionHandler,就是自定义的handler了。

在自定义handler中,我们需要处理两件事情,一件事情就是在channel ready的时候创建handshaker。另外一件事情就是具体websocket消息的处理了。

创建handshaker

首先使用WebSocketClientHandshakerFactory创建handler:

1
2
3
4
java复制代码TestSocketClientHandler handler =
new TestSocketClientHandler(
WebSocketClientHandshakerFactory.newHandshaker(
uri, WebSocketVersion.V13, null, true, new DefaultHttpHeaders()));

然后在channel active的时候使用handshaker进行握手连接:

1
2
3
typescript复制代码    public void channelActive(ChannelHandlerContext ctx) {
handshaker.handshake(ctx.channel());
}

然后在进行消息接收处理的时候还需要判断handshaker的状态是否完成,如果未完成则调用handshaker.finishHandshake方法进行手动完成:

1
2
3
4
5
6
7
8
9
10
11
scss复制代码        if (!handshaker.isHandshakeComplete()) {
try {
handshaker.finishHandshake(ch, (FullHttpResponse) msg);
log.info("websocket Handshake 完成!");
handshakeFuture.setSuccess();
} catch (WebSocketHandshakeException e) {
log.info("websocket连接失败!");
handshakeFuture.setFailure(e);
}
return;
}

当handshake完成之后,就可以进行正常的websocket消息读写操作了。

websocket消息的处理

websocket的消息处理比较简单,将接收到的消息转换成为WebSocketFrame进行处理即可。

1
2
3
4
5
6
7
8
9
10
ini复制代码        WebSocketFrame frame = (WebSocketFrame) msg;
if (frame instanceof TextWebSocketFrame) {
TextWebSocketFrame textFrame = (TextWebSocketFrame) frame;
log.info("接收到TXT消息: " + textFrame.text());
} else if (frame instanceof PongWebSocketFrame) {
log.info("接收到pong消息");
} else if (frame instanceof CloseWebSocketFrame) {
log.info("接收到closing消息");
ch.close();
}

总结

本文讲解了netty提供的websocket客户端的支持和具体的对接流程,大家可以再次基础上进行扩展,以实现自己的业务逻辑。

本文的例子可以参考:learn-netty4

本文已收录于 www.flydean.com/25-netty-we…

最通俗的解读,最深刻的干货,最简洁的教程,众多你不知道的小技巧等你来发现!

欢迎关注我的公众号:「程序那些事」,懂技术,更懂你!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

微服务SpringCloud项目(四):整合MinIo实现文

发表于 2021-10-08

小知识,大挑战!本文正在参与「程序员必备小知识」创作活动

本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。

📖前言

1
复制代码心态好了,就没那么累了。心情好了,所见皆是明媚风景。

“一时解决不了的问题,那就利用这个契机,看清自己的局限性,对自己进行一场拨乱反正。”正如老话所说,一念放下,万般自在。如果你正被烦心事扰乱心神,不妨学会断舍离。断掉胡思乱想,社区垃圾情绪,离开负面能量。心态好了,就没那么累了。心情好了,所见皆是明媚风景。

🚓进入正题

不多说了:用的mybatis-plus具体的实现类什么的就不写了,毕竟复制粘贴谁都会希望你不是复制粘贴一把梭呵呵,进入正题吧

结构如下

1632966052.png

1. 创建子模块单独存放配置读取和工具类(为了演示方便我就放在一个项目里了别介意)

引入如下依赖:

1
2
3
4
5
6
xml复制代码<!-- https://mvnrepository.com/artifact/io.minio/minio -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>${minio.version}</version>
</dependency>

yml增加配置如下,其他配置自行搞定

1
2
3
4
5
6
7
8
yaml复制代码# Minio配置
minio:
server:
url: mini访问地址
accessKey: 密钥
secretKey: 密钥
originFileBucKetValue: dream-cloud-auth # 存储桶需要验证
allowOriginFileBucKetValue: dream-cloud-allow # 存储桶放行上传

2. MinIoProperties.java – 用于 Minio 配置信息获取

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
java复制代码package com.cyj.dream.file.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;

/**
* @Description: Minio配置信息获取
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.file.config
* @Author: ChenYongJia
* @CreateTime: 2021-09-27
* @Email: chen87647213@163.com
* @Version: 1.0
*/
@Data
@RefreshScope
@Configuration
@ConfigurationProperties("minio.server")
public class MinIoProperties {

/**
* minio地址--url+端口号
*/
private String url;

/**
* 账号
*/
private String accessKey;

/**
* 密码
*/
private String secretKey;

/**
* 分区配置
*/
private String chunkBucKetValue;

/**
* 桶名配置(限权的)
*/
private String originFileBucKetValue;

/**
* 桶名配置(放行的)
*/
private String allowOriginFileBucKetValue;

}

3. MinIoUtils.java – 用于操作 MinIo 工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
java复制代码package com.cyj.dream.minio.util;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.StrUtil;
import com.cyj.dream.minio.config.MinIoProperties;
import com.google.common.io.ByteStreams;
import io.minio.*;
import io.minio.errors.MinioException;
import io.minio.http.Method;
import io.minio.messages.Item;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.io.*;
import java.util.*;
import java.util.stream.Collectors;

/**
* @Description: MinIo工具类
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.minio.util
* @Author: ChenYongJia
* @CreateTime: 2021-09-26
* @Email: chen87647213@163.com
* @Version: 1.0
*/
@Slf4j
@Component
public class MinIoUtils {

@Autowired
private MinIoProperties minIoProperties;

private static String url;

private static String accessKey;

private static String secretKey;

public static String chunkBucKet;

public static String originFileBucKet;

public static String allowOriginFileBucKet;

private static MinioClient minioClient;

/**
* 排序
*/
public final static boolean SORT = true;

/**
* 不排序
*/
public final static boolean NOT_SORT = false;

/**
* 默认过期时间(分钟)
*/
private final static Integer DEFAULT_EXPIRY = 60;

/**
* 初始化MinIo对象
*/
@PostConstruct
public void init() {
url = minIoProperties.getUrl();
accessKey = minIoProperties.getAccessKey();
secretKey = minIoProperties.getSecretKey();
chunkBucKet = minIoProperties.getChunkBucKetValue();
originFileBucKet = minIoProperties.getOriginFileBucKetValue();
allowOriginFileBucKet = minIoProperties.getAllowOriginFileBucKetValue();
}

public static void afterPropertiesSet() throws Exception {
log.info("url ====>{}", url);
minioClient = MinioClient.builder().endpoint(url).credentials(accessKey, secretKey).build();
//方便管理分片文件,则单独创建一个分片文件的存储桶
if (!StrUtil.isEmpty(chunkBucKet) && !isBucketExist(chunkBucKet)) {
createBucket(chunkBucKet);
}
if (!StrUtil.isEmpty(originFileBucKet) && !isBucketExist(originFileBucKet)) {
createBucket(originFileBucKet);
}
if (!StrUtil.isEmpty(allowOriginFileBucKet) && !isBucketExist(allowOriginFileBucKet)) {
createBucket(allowOriginFileBucKet);
}
}

/**
* 获得实例
* @author ChenYongJia
* @date 2021-9-26 09:33:18
* @return io.minio.MinioClient
*/
public static MinioClient getInstance() throws Exception {

if (minioClient == null) {
synchronized (MinIoUtils.class) {
if (minioClient == null) {
afterPropertiesSet();
}
}
}
return minioClient;
}

/**
* 存储桶是否存在
*
* @param bucketName 存储桶名称
* @return true/false
*/
public static boolean isBucketExist(String bucketName) throws Exception {
return getInstance().bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
}

/**
* 上传对象到minio,目标位置和存储名称存在重复时会覆盖
*
* @param bucketName 目标桶
* @param filePath 目标位置和存储名称,如 tempdir/123.txt
* @param file
*/
public static void putObject(String bucketName, String filePath, File file) throws Exception {
getInstance().putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(removeSlash(filePath))
.stream(new FileInputStream(file), file.length(), -1)
.build()
);
}

/**
* 桶内文件复制
*
* @param bucketName 目标桶
* @param sourceFilePath 目标位置
* @param targetFilePath 复制到
*/
public static boolean copyObject(String bucketName, String sourceFilePath, String targetFilePath) throws Exception {

try {
getInstance().copyObject(CopyObjectArgs.builder()
.bucket(bucketName)
.object(targetFilePath)
.source(CopySource
.builder()
.bucket(bucketName)
.object(sourceFilePath)
.build()
)
.build());
return true;
} catch (MinioException e) {
System.out.println("Error occurred: " + e);
return false;
}

}

/**
* 上传对象到minio,目标位置和存储名称存在重复时会覆盖
*
* @param bucketName 目标桶
* @param filePath 目标位置和存储名称,如 tempdir/123.txt
* @param file
*/
public static void putObject(String bucketName, String filePath, InputStream file) throws Exception {
getInstance().putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(removeSlash(filePath))
.stream(file, file.available(), -1)
.build()
);
}

/**
* 删除削减
*
* @param str 入参
* @author ChenYongJia
* @date 9:33 2021/9/26
* @return * @return java.lang.String
*/
private static String removeSlash(String str) {
if (str.substring(0, 1).equals("/")) {
return str.substring(1);
}
return str;
}

/**
* 从minio下载指定路径对象,目标位置和存储名称存在重复时会覆盖
*
* @param bucketName 目标桶
* @param filePath 目标位置和存储名称,如 tempdir/123.txt
*/
public static byte[] getObject(String bucketName, String filePath) throws Exception {

InputStream inputStream = null;
try {
inputStream = getInstance().getObject(
GetObjectArgs.builder()
.bucket(bucketName)
.object(removeSlash(filePath))
.build()
);
} catch (MinioException e) {
System.out.println("Error occurred: " + e);
return null;
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
{
ByteStreams.copy(inputStream, outputStream);
byte[] buffer = outputStream.toByteArray();
return buffer;
}
}

/**
* 获取文件状态信息
*
* @param bucketName 目标桶
* @param filePath 目标位置和存储名称,如 tempdir/123.txt
*/
public static StatObjectResponse statObject(String bucketName, String filePath) throws Exception {

try {
return getInstance().statObject(
StatObjectArgs.builder()
.bucket(bucketName)
.object(removeSlash(filePath))
.build()
);
} catch (MinioException e) {
System.out.println("Error occurred: " + e);
return null;
}
}

/**
* 移除文件
*
* @param bucketName 目标桶
* @param filePath 目标位置和存储名称,如 tempdir/123.txt
*/
public static void removeObject(String bucketName, String filePath) throws Exception {
getInstance().removeObject(
RemoveObjectArgs.builder()
.bucket(bucketName)
.object(removeSlash(filePath))
.build()
);
}

/**
* 创建存储桶
*
* @param bucketName 存储桶名称
* @return true/false
*/
public static boolean createBucket(String bucketName) throws Exception {
getInstance().makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
return true;
}

/**
* 获取访问对象的外链地址
*
* @param objectName 对象名称
* @param expiry 过期时间(分钟) 最大为7天 超过7天则默认最大值
* @return viewUrl
*/
public static String getOriginalObjectUrl(String objectName, Integer expiry) throws Exception {
return getObjectUrl(originFileBucKet, objectName, expiry);
}

/**
* 获取访问对象的外链地址
*
* @param bucketName 存储桶名称
* @param objectName 对象名称
* @param expiry 过期时间(分钟) 最大为7天 超过7天则默认最大值
* @return viewUrl
*/
public static String getObjectUrl(String bucketName, String objectName, Integer expiry) throws Exception {
expiry = expiryHandle(expiry);
return getInstance().getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucketName)
.object(removeSlash(objectName))
.expiry(expiry)
.build()
);
}

/**
* 创建上传文件对象的外链
*
* @param bucketName 存储桶名称
* @param objectName 欲上传文件对象的名称
* @param expiry 过期时间(分钟) 最大为7天 超过7天则默认最大值
* @return uploadUrl
*/
public static String createUploadUrl(String bucketName, String objectName, Integer expiry) throws Exception {
expiry = expiryHandle(expiry);
return getInstance().getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket(bucketName)
.object(removeSlash(objectName))
.expiry(expiry)
.build()
);
}

/**
* 创建上传文件对象的外链
*
* @param bucketName 存储桶名称
* @param objectName 欲上传文件对象的名称
* @return uploadUrl
*/
public static String createUploadUrl(String bucketName, String objectName) throws Exception {
return createUploadUrl(bucketName, objectName, DEFAULT_EXPIRY);
}

/**
* 批量创建分片上传外链
*
* @param bucketName 存储桶名称
* @param objectMD5 欲上传分片文件主文件的MD5
* @param chunkCount 分片数量
* @return uploadChunkUrls
*/
public static List<String> createUploadChunkUrlList(String bucketName, String objectMD5, Integer chunkCount) throws Exception {
if (null == bucketName) {
bucketName = chunkBucKet;
}
if (null == objectMD5) {
return null;
}
objectMD5 += "/";
if (null == chunkCount || 0 == chunkCount) {
return null;
}
List<String> urlList = new ArrayList<>(chunkCount);
for (int i = 1; i <= chunkCount; i++) {
String objectName = objectMD5 + i + ".chunk";
urlList.add(createUploadUrl(bucketName, objectName, DEFAULT_EXPIRY));
}
return urlList;
}

/**
* 创建指定序号的分片文件上传外链
*
* @param bucketName 存储桶名称
* @param objectMD5 欲上传分片文件主文件的MD5
* @param partNumber 分片序号
* @return uploadChunkUrl
*/
public static String createUploadChunkUrl(String bucketName, String objectMD5, Integer partNumber) throws Exception {
if (null == bucketName) {
bucketName = chunkBucKet;
}
if (null == objectMD5) {
return null;
}
objectMD5 += "/" + partNumber + ".chunk";
return createUploadUrl(bucketName, objectMD5, DEFAULT_EXPIRY);
}

/**
* 获取对象文件名称列表
*
* @param bucketName 存储桶名称
* @param prefix 对象名称前缀
* @param sort 是否排序(升序)
* @return objectNames
*/
public static List<String> listObjectNames(String bucketName, String prefix, Boolean sort) throws Exception {
ListObjectsArgs listObjectsArgs;
if (null == prefix) {
listObjectsArgs = ListObjectsArgs.builder()
.bucket(bucketName)
.recursive(true)
.build();
} else {
listObjectsArgs = ListObjectsArgs.builder()
.bucket(bucketName)
.prefix(prefix)
.recursive(true)
.build();
}
Iterable<Result<Item>> chunks = getInstance().listObjects(listObjectsArgs);
List<String> chunkPaths = new ArrayList<>();
for (Result<Item> item : chunks) {
chunkPaths.add(item.get().objectName());
}
if (sort) {
return chunkPaths.stream().distinct().collect(Collectors.toList());
}
return chunkPaths;
}

/**
* 获取对象文件名称列表
*
* @param bucketName 存储桶名称
* @param prefix 对象名称前缀
* @return objectNames
*/
public static List<String> listObjectNames(String bucketName, String prefix) throws Exception {
return listObjectNames(bucketName, prefix, NOT_SORT);
}

/**
* 获取分片文件名称列表
*
* @param bucketName 存储桶名称
* @param ObjectMd5 对象Md5
* @return objectChunkNames
*/
public static List<String> listChunkObjectNames(String bucketName, String ObjectMd5) throws Exception {
if (null == bucketName) {
bucketName = chunkBucKet;
}
if (null == ObjectMd5) {
return null;
}
return listObjectNames(bucketName, ObjectMd5, SORT);
}

/**
* 获取分片名称地址HashMap key=分片序号 value=分片文件地址
*
* @param bucketName 存储桶名称
* @param ObjectMd5 对象Md5
* @return objectChunkNameMap
*/
public static Map<Integer, String> mapChunkObjectNames(String bucketName, String ObjectMd5) throws Exception {
if (null == bucketName) {
bucketName = chunkBucKet;
}
if (null == ObjectMd5) {
return null;
}
List<String> chunkPaths = listObjectNames(bucketName, ObjectMd5);
if (null == chunkPaths || chunkPaths.size() == 0) {
return null;
}
Map<Integer, String> chunkMap = new HashMap<>(chunkPaths.size());
for (String chunkName : chunkPaths) {
Integer partNumber = Integer.parseInt(chunkName.substring(chunkName.indexOf("/") + 1, chunkName.lastIndexOf(".")));
chunkMap.put(partNumber, chunkName);
}
return chunkMap;
}

/**
* 合并分片文件成对象文件
*
* @param chunkBucKetName 分片文件所在存储桶名称
* @param composeBucketName 合并后的对象文件存储的存储桶名称
* @param chunkNames 分片文件名称集合
* @param objectName 合并后的对象文件名称
* @return true/false
*/
public static boolean composeObject(String chunkBucKetName, String composeBucketName, List<String> chunkNames, String objectName) throws Exception {
if (null == chunkBucKetName) {
chunkBucKetName = chunkBucKet;
}
List<ComposeSource> sourceObjectList = new ArrayList<>(chunkNames.size());
for (String chunk : chunkNames) {
sourceObjectList.add(
ComposeSource.builder()
.bucket(chunkBucKetName)
.object(chunk)
.build()
);
}
getInstance().composeObject(
ComposeObjectArgs.builder()
.bucket(composeBucketName)
.object(removeSlash(objectName))
.sources(sourceObjectList)
.build()
);
return true;
}

/**
* 合并分片文件成对象文件
*
* @param chunkNames 分片文件名称集合
* @param objectName 合并后的对象文件名称
* @return true/false
*/
public static boolean composeObject(List<String> chunkNames, String objectName) throws Exception {
return composeObject(chunkBucKet, originFileBucKet, chunkNames, objectName);
}

/**
* 直接上传
*
* @param fileName 文件名
* @param inputStream 文件流
* @return
* @throws Exception
*/
public static String upload(String fileName, InputStream inputStream, String fileBucketName) throws Exception {
String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
return upload(inputStream, suffix, fileBucketName);
}

/**
* 直接上传
*
* @param inputStream 文件流
* @param suffix 文件后缀
* @return
* @throws Exception
*/
public static String upload(InputStream inputStream, String suffix, String fileBucketName) throws Exception {
String uuid = UUID.randomUUID().toString();
String savePath = getSavePath(uuid + "." + suffix);
putObject(fileBucketName, savePath, inputStream);
return savePath;
}

private static String getSavePath(String fileName) {

String dayStr = DateUtil.now();
String days = dayStr.substring(0, dayStr.lastIndexOf(" "));
String[] dayArr = days.split("-");

String path = dayArr[0] + "/" + dayArr[1] + "/" + dayArr[2] + "/" + fileName;

return path;
}

/**
* 将分钟数转换为秒数
*
* @param expiry 过期时间(分钟数)
* @return expiry
*/
private static int expiryHandle(Integer expiry) {
expiry = expiry * 60;
if (expiry > 604800) {
return 604800;
}
return expiry;
}

}

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
java复制代码package com.cyj.dream.file.contacts;

/**
* @Description: 文件中心常量
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.file.contacts
* @Author: ChenYongJia
* @CreateTime: 2021-09-26
* @Email: chen87647213@163.com
* @Version: 1.0
*/
public class FileConstant {

/**
* 包目录
*/
public static final String SCAN_BASE_PACKAGES_URL = "com.cyj.dream.file";

/**
* mapper文件目录
*/
public static final String MAPPER_URL = "com.cyj.dream.file.mapper";

/**
* 上传
*/
public final static String UPLOAD_TYPE_AUTH="auth";

/**
*
*/
public final static String UPLOAD_TYPE_ALLOW="allow";

}

5. 创建操作数据库实体

FileUploadPieceRecord – 文件分片断点续传记录表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
java复制代码package com.cyj.dream.file.model;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.cyj.dream.core.constant.TreeEntity;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;

/**
* @Description: 文件分片断点续传记录表
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.file.model
* @Author: ChenYongJia
* @CreateTime: 2021-09-13
* @Email: chen87647213@163.com
* @Version: 1.0
*/
@Data
@Entity
@ToString
@Table(name = "file_upload_piece_record")
@TableName("file_upload_piece_record")
@ApiModel(value = "FileUploadPieceRecord", description = "file_upload_piece_record 分片上传记录表")
public class FileUploadPieceRecord extends TreeEntity {

@Id
@TableId(value = "piece_id", type = IdType.AUTO)
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, columnDefinition = "bigint(20) unsigned COMMENT '分片上传记录表主键--自增'")
@ApiModelProperty(value = "文件路径id", example = "1")
private Long pieceId;

/**
* varchar(128) 分片存储桶名称
*/
@ApiModelProperty(value = "分片存储桶名称")
@Column(columnDefinition = "varchar(128) COMMENT '文件路径'")
private String chunkBucketName;

/**
* varchar(100) 源文件存储桶名称
*/
@ApiModelProperty(value = "源文件存储桶名称")
@Column(columnDefinition = "varchar(100) COMMENT '源文件存储桶名称'")
private String fileBucketName;

/**
* 分片数量
*/
@ApiModelProperty(value = "分片数量")
@Column(columnDefinition = "bigint(20) COMMENT '分片数量'")
private Long chunkCount;

/**
* varchar(255) 上传文件的md5
*/
@ApiModelProperty(value = "上传文件的md5")
@Column(columnDefinition = "varchar(255) COMMENT '上传文件的md5'")
private String fileMd5;

/**
* varchar(100) 上传文件/合并文件的格式
*/
@ApiModelProperty(value = "上传文件/合并文件的格式")
@Column(columnDefinition = "varchar(64) COMMENT '上传文件/合并文件的格式'")
private String fileSuffix;

/**
* 文件名称
*/
@ApiModelProperty(value = "文件名称")
@Column(columnDefinition = "varchar(128) COMMENT '文件名称'")
private String fileName;

/**
* varchar(255) 文件大小(b)
*/
@ApiModelProperty(value = "文件大小(b)")
@Column(columnDefinition = "varchar(255) COMMENT '文件大小(b)'")
private String fileSize;

/**
* 文件地址 varchar(500)
*/
@ApiModelProperty(value = "文件地址")
@Column(columnDefinition = "varchar(500) COMMENT '文件地址'")
private String filePath;

/**
* 上传状态 0.上传完成 1.已上传部分 int(11)
*/
@ApiModelProperty(value = "上传状态")
@Column(columnDefinition = "int(2) COMMENT '上传状态 0.上传完成 1.已上传部分 int(2)'")
private Integer uploadStatus;

/**
* 分片序号
*/
@Transient
private Integer partNumber;

/**
* 上传地址
*/
@Transient
private String uploadUrl;

}

FileUploadRecord – 文件上传记录

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
java复制代码package com.cyj.dream.file.model;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.cyj.dream.core.constant.BaseEntity;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;

/**
* @Description: 文件上传记录
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.file.model
* @Author: ChenYongJia
* @CreateTime: 2021-09-13
* @Email: chen87647213@163.com
* @Version: 1.0
*/
@Data
@Entity
@ToString
@Table(name = "file_upload_record")
@TableName("file_upload_record")
@ApiModel(value = "FileUploadRecord", description = "file_upload_record 文件上传记录")
public class FileUploadRecord extends BaseEntity {

@ApiModelProperty(value = "文件路径id", example = "1")
@Id
@TableId(value = "file_id", type = IdType.AUTO)
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, columnDefinition = "bigint(20) unsigned COMMENT '文件上传记录主键--自增'")
private Long fileId;

@ApiModelProperty(value = "文件路径 varchar(500)")
@Column(columnDefinition = "varchar(500) COMMENT '文件路径'")
private String filePath;

@ApiModelProperty(value = "文件名称 varchar(255)")
@Column(columnDefinition = "varchar(255) COMMENT '文件名称'")
private String fileName;

@ApiModelProperty(value = "上传文件/合并文件的格式 varchar(64)")
@Column(columnDefinition = "varchar(64) COMMENT '上传文件/合并文件的格式'")
private String fileSuffix;

@ApiModelProperty(value = "源文件存储桶名称 varchar(128)")
@Column(columnDefinition = "varchar(128) COMMENT '源文件存储桶名称'")
private String fileBucketName;

@ApiModelProperty(value = "类型(allow 是放行,auth 是需要鉴权的 ) varchar(128)")
@Column(columnDefinition = "varchar(128) COMMENT '类型(allow 是放行,auth 是需要鉴权的 )'")
private String fileType;

}

6. FileManagementServiceImpl – 列一下文件管理实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
java复制代码package com.cyj.dream.file.service.impl;

import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.cyj.dream.core.constant.pagemodel.ResponseUtil;
import com.cyj.dream.core.util.date.DateUtils;
import com.cyj.dream.file.contacts.FileConstant;
import com.cyj.dream.file.mapper.FileUploadPieceRecordMapper;
import com.cyj.dream.file.mapper.FileUploadRecordMapper;
import com.cyj.dream.file.model.FileUploadPieceRecord;
import com.cyj.dream.file.model.FileUploadRecord;
import com.cyj.dream.file.service.FileManagementService;
import com.cyj.dream.file.util.MinIoUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.*;

/**
* @Description: 文件管理实现类
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.file.service.impl
* @Author: ChenYongJia
* @CreateTime: 2021-09-13
* @Email: chen87647213@163.com
* @Version: 1.0
*/
@Slf4j
@Service
public class FileManagementServiceImpl implements FileManagementService {

/**
* 上传成功
*/
private final Integer UPLOAD_SUCCESS = 1;

/**
* 部分上传
*/
private final Integer UPLOAD_PART = 0;

@Resource
private FileUploadRecordMapper fileUploadRecordMapper;

@Resource
private FileUploadPieceRecordMapper fileUploadPieceRecordMapper;

@Override
public Object initChunkUpload(String md5, String chunkCount, String fileSize) {
try {
FileUploadPieceRecord uploadDto = new FileUploadPieceRecord();
uploadDto.setFileSize(fileSize);
uploadDto.setFileMd5(md5);
uploadDto.setChunkCount(Long.valueOf(chunkCount));
QueryWrapper<FileUploadPieceRecord> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(FileUploadPieceRecord::getFileMd5, md5);
FileUploadPieceRecord mysqlFileData = fileUploadPieceRecordMapper.selectOne(queryWrapper);
if (ObjectUtil.isNotEmpty(mysqlFileData)) {
//秒传
if (UPLOAD_SUCCESS.equals(mysqlFileData.getUploadStatus())) {
return mysqlFileData;
}
//续传
//获取到该文件已上传分片
Map<Integer, String> okChunkMap = MinIoUtils.mapChunkObjectNames(MinIoUtils.chunkBucKet, uploadDto.getFileMd5());
List<FileUploadPieceRecord> chunkUploadUrls = new ArrayList<>();
if (ObjectUtil.isNotEmpty(okChunkMap) && okChunkMap.size() > 0) {
for (int i = 1; i <= uploadDto.getChunkCount(); i++) {
//判断当前分片是否已经上传过了
if (!okChunkMap.containsKey(i)) {
//生成分片上传url
FileUploadPieceRecord url = new FileUploadPieceRecord();
url.setPartNumber(i);
url.setUploadUrl(MinIoUtils.createUploadChunkUrl(MinIoUtils.chunkBucKet, uploadDto.getFileMd5(), i));
chunkUploadUrls.add(url);
}
}
if (chunkUploadUrls.size() == 0) {
return "所有分片已经上传完成,仅需要合并文件";
}
return chunkUploadUrls;
}
}
//初次上传和已有文件信息但未上传任何分片的情况下则直接生成所有上传url
List<String> uploadUrls = MinIoUtils.createUploadChunkUrlList(MinIoUtils.chunkBucKet, uploadDto.getFileMd5(),
Integer.valueOf(uploadDto.getChunkCount().toString()));
List<FileUploadPieceRecord> chunkUploadUrls = new ArrayList<>();
for (int i = 1; i <= uploadUrls.size(); i++) {
FileUploadPieceRecord url = new FileUploadPieceRecord();
url.setPartNumber(i);
url.setUploadUrl(uploadUrls.get(i - 1));
chunkUploadUrls.add(url);
}
//向数据库中记录该文件的上传信息
uploadDto.setUploadStatus(UPLOAD_PART);
if (ObjectUtil.isEmpty(mysqlFileData)) {
uploadDto.setChunkBucketName(MinIoUtils.chunkBucKet);
uploadDto.setFileBucketName(MinIoUtils.originFileBucKet);
uploadDto.setCreateTime(DateUtils.toLocalDateTime(new Date()));
uploadDto.setUpdateTime(DateUtils.toLocalDateTime(new Date()));
fileUploadPieceRecordMapper.insert(uploadDto);
}
return chunkUploadUrls;
} catch (Exception ex) {
log.error("发生异常,异常信息为:{}", ex);
return ResponseUtil.error("初始化文件失败");
}
}

@Override
public Object composeFile(String md5, String fileName) {
try {
//根据md5获取所有分片文件名称(minio的文件名称 = 文件path)
List<String> chunks = MinIoUtils.listObjectNames(MinIoUtils.chunkBucKet, md5);
//获取过来的分片文件名称是乱序的,所以重新组合排序一下
List<String> newChunks = new ArrayList<>(chunks.size());
for (int i = 1; i <= chunks.size(); i++) {
String newChunkName = md5 + "/" + i + ".chunk";
newChunks.add(newChunkName);
}
//自定义文件名称
String suffix = fileName.substring(fileName.lastIndexOf("."));
String filePath = md5 + "/" + fileName;
//合并文件
if (!MinIoUtils.composeObject(newChunks, filePath)) {
return ResponseUtil.error("合并文件失败");
}
String url = null;
try {
//获取文件访问外链(1小时过期)
url = MinIoUtils.getOriginalObjectUrl(filePath, 60);
} catch (Exception e) {
log.error("发生异常,异常信息为:{}", e);
return ResponseUtil.error("获取文件下载连接失败");
}
//获取数据库里记录的文件信息,修改数据并返回文件信息
QueryWrapper<FileUploadPieceRecord> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(FileUploadPieceRecord::getFileMd5, md5);
FileUploadPieceRecord dbData = fileUploadPieceRecordMapper.selectOne(queryWrapper);
dbData.setUploadStatus(UPLOAD_SUCCESS);
dbData.setFileName(fileName);
dbData.setFilePath(url);
dbData.setFileSuffix(suffix);

dbData.setCreateTime(DateUtils.toLocalDateTime(new Date()));
dbData.setUpdateTime(DateUtils.toLocalDateTime(new Date()));
//更新数据库中的附件上传状态
fileUploadPieceRecordMapper.updateById(dbData);
return dbData;
} catch (Exception ex) {
log.error("发生异常,异常信息为:{}", ex);
return ResponseUtil.error("合并失败");
}
}

@Override
public Object authUpload(MultipartFile file, String fileName) {
return this.upload(file, fileName, FileConstant.UPLOAD_TYPE_AUTH, MinIoUtils.originFileBucKet);
}

@Override
public Object allowUpload(MultipartFile file, String fileName) {
return this.upload(file, fileName, FileConstant.UPLOAD_TYPE_ALLOW, MinIoUtils.allowOriginFileBucKet);
}

@Override
public Object getPicBase64(FileUploadRecord fileUploadRecord) {
Map<String, String> result = new HashMap<>(16);
try {
byte[] file = new byte[0];
if (!StrUtil.isEmpty(fileUploadRecord.getFilePath())) {
file = MinIoUtils.getObject(fileUploadRecord.getFileBucketName(), fileUploadRecord.getFilePath());
}
String encoded = Base64.getEncoder().encodeToString(file);
String type = "";
if (fileUploadRecord.getFilePath().toLowerCase().contains(".jpg")) {
type = "data:image/jpeg;base64,";
} else if (fileUploadRecord.getFilePath().toLowerCase().contains(".png")) {
type = "data:image/png;base64,";
} else if (fileUploadRecord.getFilePath().toLowerCase().contains(".gif")) {
type = "data:image/gif;base64,";
}
result.put("base64", type + encoded);
} catch (Exception ex) {
log.error("发生异常,异常信息为:{}", ex);
return ResponseUtil.error("获取异常");
}
return result;
}

/**
* 上传
*
* @param file
* @param fileName
* @param type
* @param fileBucketName
* @return
*/
private Object upload(MultipartFile file, String fileName, String type, String fileBucketName) {
try {
String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
String saveUrl = MinIoUtils.upload(file.getInputStream(), suffix, fileBucketName);
FileUploadRecord fileUploadRecord = new FileUploadRecord();
fileUploadRecord.setFileBucketName(fileBucketName);
fileUploadRecord.setFileName(fileName);
fileUploadRecord.setFileSuffix(suffix);
fileUploadRecord.setFilePath(saveUrl);
fileUploadRecord.setFileType(type);

fileUploadRecord.setCreateTime(DateUtils.toLocalDateTime(new Date()));
fileUploadRecord.setUpdateTime(DateUtils.toLocalDateTime(new Date()));
fileUploadRecordMapper.insert(fileUploadRecord);
return fileUploadRecord;
} catch (Exception ex) {
log.error("发生异常,异常信息为:{}", ex);
return ResponseUtil.error("上传失败");
}
}

}

7. FilesManagementController 控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
java复制代码package com.cyj.dream.file.controller.minio;

import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.cyj.dream.core.aspect.annotation.ResponseResult;
import com.cyj.dream.core.constant.pagemodel.ResponseUtil;
import com.cyj.dream.file.model.FileUploadRecord;
import com.cyj.dream.file.service.FileManagementService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

/**
* @Description: 文件管理
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.file.controller.minio
* @Author: ChenYongJia
* @CreateTime: 2021-09-13
* @Email: chen87647213@163.com
* @Version: 1.0
*/
@Slf4j
@ResponseResult
@RestController
@RequestMapping(value = "/file/manager", name = "文件管理")
@Api(value = "/file/manager", tags = "文件管理")
public class FilesManagementController {

@Autowired
private FileManagementService fileManagementService;

/**
* 初始化大文件上传
*
* @author ChenYongJia
* @date 2021-9-13
*/
@ApiOperation("初始化大文件上传")
@ApiImplicitParams({
@ApiImplicitParam(name = "md5", value = "文件信息的md5值", dataType = "String", required = true),
@ApiImplicitParam(name = "chunkCount", value = "分片计数", dataType = "String", required = true),
@ApiImplicitParam(name = "fileSize", value = "文件大小", dataType = "String", required = true)
})
@RequestMapping(value = "/initChunkUpload", name = "初始化大文件上传", method = RequestMethod.POST)
public Object initChunkUpload(String md5, String chunkCount, String fileSize) {
log.info("进入 初始化大文件上传 控制器方法,md5:{},chunkCount:{},fileSize:{}", md5, chunkCount, fileSize);
if(StrUtil.isEmpty(md5) || StrUtil.isEmpty(chunkCount) || StrUtil.isEmpty(fileSize)){
log.error("参数缺失请检查参数后重新提交~");
return ResponseUtil.error("参数缺失请检查参数后重新提交");
}
return fileManagementService.initChunkUpload(md5, chunkCount, fileSize);
}

/**
* 合并文件并返回文件信息
*
* @author ChenYongJia
* @date 2021-9-13
*/
@ApiOperation("合并文件并返回文件信息")
@ApiImplicitParams({
@ApiImplicitParam(name = "md5", value = "文件信息的md5值", dataType = "String", required = true),
@ApiImplicitParam(name = "fileName", value = "文件名称", dataType = "String", required = true)
})
@RequestMapping(value = "/composeFile", name = "合并文件并返回文件信息", method = RequestMethod.POST)
public Object composeFile(String md5, String fileName) {
log.info("进入 合并文件并返回文件信息 控制器方法,md5:{},fileName:{}", md5, fileName);
if(StrUtil.isEmpty(md5) || StrUtil.isEmpty(fileName)){
log.error("参数缺失请检查参数后重新提交~");
return ResponseUtil.error("参数缺失请检查参数后重新提交");
}
return fileManagementService.composeFile(md5, fileName);
}

/**
* 限制上传直接上传
*
* @author ChenYongJia
* @date 2021-9-13
*/
@ApiOperation("限制上传直接上传")
@ApiImplicitParams({
@ApiImplicitParam(name = "file", value = "文件信息", dataType = "MultipartFile", required = true),
@ApiImplicitParam(name = "fileName", value = "文件名称", dataType = "String", required = true)
})
@RequestMapping(value = "/authUpload", name = "限制上传直接上传", method = RequestMethod.POST)
public Object authUpload(MultipartFile file, String fileName) {
log.info("进入 限制上传直接上传 控制器方法,fileSize:{},fileName:{}", file.getSize(), fileName);
if(file == null || file.getSize() == 0 || StrUtil.isEmpty(fileName)){
log.error("参数缺失请检查参数后重新提交~");
return ResponseUtil.error("参数缺失请检查参数后重新提交");
}
return fileManagementService.authUpload(file, fileName);
}

/**
* 放行上传直接上传

* @author ChenYongJia
* @date 2021-9-13
*/
@ApiOperation("放行上传直接上传")
@ApiImplicitParams({
@ApiImplicitParam(name = "file", value = "文件信息", dataType = "MultipartFile", required = true),
@ApiImplicitParam(name = "fileName", value = "文件名称", dataType = "String", required = true)
})
@RequestMapping(value = "/allowUpload", name = "放行上传直接上传", method = RequestMethod.POST)
public Object allowUpload(MultipartFile file, String fileName) {
log.info("进入 放行上传直接上传--如何测试-- 控制器方法,fileSize:{},fileName:{}", file.getSize(), fileName);
if(file == null || file.getSize() == 0 || StrUtil.isEmpty(fileName)){
log.error("参数缺失请检查参数后重新提交~");
return ResponseUtil.error("参数缺失请检查参数后重新提交");
}
return fileManagementService.allowUpload(file, fileName);
}

/**
* 获取到图片文件的base64
*
* @author ChenYongJia
* @date 2021-9-13
*/
@ApiOperation("获取到图片文件的base64")
@ApiImplicitParams({
@ApiImplicitParam(name = "fileUploadRecord", value = "文件上传记录对象", dataType = "FileUploadRecord", required = true)
})
@RequestMapping(value = "/getPicBase64", name = "获取到图片文件的base64", method = RequestMethod.POST)
public Object getPicBase64(@RequestBody FileUploadRecord fileUploadRecord) {
log.info("进入 获取到图片文件的base64 控制器方法,fileUploadRecord:{},", JSONObject.toJSONString(fileUploadRecord));
if(fileUploadRecord == null){
log.error("参数缺失请检查参数后重新提交~");
return ResponseUtil.error("参数缺失请检查参数后重新提交");
}
return fileManagementService.getPicBase64(fileUploadRecord);
}

}

8. 结果如下

1632966052.jpg

1632966053(1).jpg

最后感谢大家耐心观看完毕,原创不易,留个点赞收藏是您对我最大的鼓励!


🎉总结:

  • 更多参考精彩博文请看这里:《陈永佳的博客》
  • 喜欢博主的小伙伴可以加个关注、点个赞哦,持续更新嘿嘿!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JVM 面试题,安排上了!!!

发表于 2021-10-08

肝了一篇非常硬核的 JVM 基础总结,写作不易,小伙伴们赶紧点赞、转发安排起来!

原文链接 据说看完这篇 JVM 要一小时

JVM 的主要作用是什么?

JVM 就是 Java Virtual Machine(Java虚拟机)的缩写,JVM 屏蔽了与具体操作系统平台相关的信息,使 Java 程序只需生成在 Java 虚拟机上运行的目标代码 (字节码),就可以在不同的平台上运行。

请你描述一下 Java 的内存区域?

JVM 在执行 Java 程序的过程中会把它管理的内存分为若干个不同的区域,这些组成部分有些是线程私有的,有些则是线程共享的,Java 内存区域也叫做运行时数据区,它的具体划分如下:

image-20210909232300925

  • 虚拟机栈 : Java 虚拟机栈是线程私有的数据区,Java 虚拟机栈的生命周期与线程相同,虚拟机栈也是局部变量的存储位置。方法在执行过程中,会在虚拟机栈中创建一个 栈帧(stack frame)。每个方法执行的过程就对应了一个入栈和出栈的过程。

image-20210817204550728

  • 本地方法栈: 本地方法栈也是线程私有的数据区,本地方法栈存储的区域主要是 Java 中使用 native 关键字修饰的方法所存储的区域。
  • 程序计数器:程序计数器也是线程私有的数据区,这部分区域用于存储线程的指令地址,用于判断线程的分支、循环、跳转、异常、线程切换和恢复等功能,这些都通过程序计数器来完成。
  • 方法区:方法区是各个线程共享的内存区域,它用于存储虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 堆:堆是线程共享的数据区,堆是 JVM 中最大的一块存储区域,所有的对象实例都会分配在堆上。JDK 1.7后,字符串常量池从永久代中剥离出来,存放在堆中。

堆空间的内存分配(默认情况下):

+ 老年代 : 三分之二的堆空间
+ 年轻代 : 三分之一的堆空间
    - eden 区: 8/10 的年轻代空间
    - survivor 0 : 1/10 的年轻代空间
    - survivor 1 : 1/10 的年轻代空间命令行上执行如下命令,会查看默认的 JVM 参数。
1
java复制代码java -XX:+PrintFlagsFinal -version

输出的内容非常多,但是只有两行能够反映出上面的内存分配结果

image-20210817184720097

image-20210817184754351

image-20210817184629515

  • 运行时常量池:运行时常量池又被称为 Runtime Constant Pool,这块区域是方法区的一部分,它的名字非常有意思,通常被称为 非堆。它并不要求常量一定只有在编译期才能产生,也就是并非编译期间将常量放在常量池中,运行期间也可以将新的常量放入常量池中,String 的 intern 方法就是一个典型的例子。

请你描述一下 Java 中的类加载机制?

Java 虚拟机负责把描述类的数据从 Class 文件加载到系统内存中,并对类的数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称之为 Java 的类加载机制。

一个类从被加载到虚拟机内存开始,到卸载出内存为止,一共会经历下面这些过程。

image-20210823222909485

类加载机制一共有五个步骤,分别是加载、链接、初始化、使用和卸载阶段,这五个阶段的顺序是确定的。

其中链接阶段会细分成三个阶段,分别是验证、准备、解析阶段,这三个阶段的顺序是不确定的,这三个阶段通常交互进行。解析阶段通常会在初始化之后再开始,这是为了支持 Java 语言的运行时绑定特性(也被称为动态绑定)。

下面我们就来聊一下这几个过程。

加载

关于什么时候开始加载这个过程,《Java 虚拟机规范》并没有强制约束,所以这一点我们可以自由实现。加载是整个类加载过程的第一个阶段,在这个阶段,Java 虚拟机需要完成三件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流表示的一种存储结构转换为运行时数据区中方法区的数据结构。
  • 在内存中生成一个 Class 对象,这个对象就代表了这个数据结构的访问入口。

《Java 虚拟机规范》并未规定全限定名是如何获取的,所以现在业界有很多获取全限定名的方式:

  • 从 ZIP 包中读取,最终会改变为 JAR、EAR、WAR 格式。
  • 从网络中获取,最常见的应用就是 Web Applet。
  • 运行时动态生成,使用最多的就是动态代理技术。
  • 由其他文件生成,比如 JSP 应用场景,由 JSP 文件生成对应的 Class 文件。
  • 从数据库中读取,这种场景就比较小了。
  • 可以从加密文件中获取,这是典型的防止 Class 文件被反编译的保护措施。

加载阶段既可以使用虚拟机内置的引导类加载器来完成,也可以使用用户自定义的类加载器来完成。程序员可以通过自己定义类加载器来控制字节流的访问方式。

数组的加载不需要通过类加载器来创建,它是直接在内存中分配,但是数组的元素类型(数组去掉所有维度的类型)最终还是要靠类加载器来完成加载。

验证

加载过后的下一个阶段就是验证,因为我们上一步讲到在内存中生成了一个 Class 对象,这个对象是访问其代表数据结构的入口,所以这一步验证的工作就是确保 Class 文件的字节流中的内容符合《Java 虚拟机规范》中的要求,保证这些信息被当作代码运行后,它不会威胁到虚拟机的安全。

验证阶段主要分为四个阶段的检验:

  • 文件格式验证。
  • 元数据验证。
  • 字节码验证。
  • 符号引用验证。

文件格式验证

这一阶段可能会包含下面这些验证点:

  • 魔数是否以 0xCAFEBABE 开头。
  • 主、次版本号是否在当前 Java 虚拟机接受范围之内。
  • 常亮池的常量中是否有不支持的常量类型。
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
  • CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据。
  • Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息。

实际上验证点远远不止有这些,上面这些只是从 HotSpot 源码中摘抄的一小段内容。

元数据验证

这一阶段主要是对字节码描述的信息进行语义分析,以确保描述的信息符合《Java 语言规范》,验证点包括

  • 验证的类是否有父类(除了 Object 类之外,所有的类都应该有父类)。
  • 要验证类的父类是否继承了不允许继承的类。
  • 如果这个类不是抽象类,那么这个类是否实现了父类或者接口中要求的所有方法。
  • 是否覆盖了 final 字段,是否出现了不符合规定的重载等。

需要记住这一阶段只是对《Java 语言规范》的验证。

字节码验证

字节码验证阶段是最复杂的一个阶段,这个阶段主要是确定程序语意是否合法、是否是符合逻辑的。这个阶段主要是对类的方法体(Class 文件中的 Code 属性)进行校验分析。这部分验证包括

  • 确保操作数栈的数据类型和实际执行时的数据类型是否一致。
  • 保证任何跳转指令不会跳出到方法体外的字节码指令上。
  • 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,但是不能把父类数据类型赋值给子类等诸如此不安全的类型转换。
  • 其他验证。

如果没有通过字节码验证,就说明验证出问题。但是不一定通过了字节码验证,就能保证程序是安全的。

符号引用验证

最后一个阶段的校验行为发生在虚拟机将符号引用转换为直接引用的时候,这个转化将在连接的第三个阶段,即解析阶段中发生。符号引用验证可以看作是对类自身以外的各类信息进行匹配性校验,这个验证主要包括

  • 符号引用中的字符串全限定名是否能找到对应的类。
  • 指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
  • 符号引用的类、字段方法的可访问性是否可被当前类所访问。
  • 其他验证。

这一阶段主要是确保解析行为能否正常执行,如果无法通过符号引用验证,就会出现类似 IllegalAccessError、NoSuchFieldError、NoSuchMethodError 等错误。

验证阶段对于虚拟机来说非常重要,如果能通过验证,就说明你的程序在运行时不会产生任何影响。

准备

准备阶段是为类中的变量分配内存并设置其初始值的阶段,这些变量所使用的内存都应当在方法区中进行分配,在 JDK 7 之前,HotSpot 使用永久代来实现方法区,是符合这种逻辑概念的。而在 JDK 8 之后,变量则会随着 Class 对象一起存放在 Java 堆中。

下面通常情况下的基本类型和引用类型的初始值

image-20210823223020677

除了”通常情况”下,还有一些”例外情况”,如果类字段属性中存在 ConstantValue 属性,那就这个变量值在初始阶段就会初始化为 ConstantValue 属性所指定的初始值,比如

1
java复制代码public static final int value = "666";

编译时就会把 value 的值设置为 666。

解析

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

  • 符号引用:符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用和虚拟机的布局无关。
  • 直接引用:直接引用可以直接指向目标的指针、相对便宜量或者一个能间接定位到目标的句柄。直接引用和虚拟机的布局是相关的,不同的虚拟机对于相同的符号引用所翻译出来的直接引用一般是不同的。如果有了直接引用,那么直接引用的目标一定被加载到了内存中。

这样说你可能还有点不明白,我再换一种说法:

在编译的时候一个每个 Java 类都会被编译成一个 class 文件,但在编译的时候虚拟机并不知道所引用类的地址,所以就用符号引用来代替,而在这个解析阶段就是为了把这个符号引用转化成为真正的地址的阶段。

《Java 虚拟机规范》并未规定解析阶段发生的时间,只要求了在 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield 和 putstatic 这 17 个用于操作符号引用的字节码指令之前,先对所使用的符号引用进行解析。

解析也分为四个步骤

  • 类或接口的解析
  • 字段解析
  • 方法解析
  • 接口方法解析

初始化

初始化是类加载过程的最后一个步骤,在之前的阶段中,都是由 Java 虚拟机占主导作用,但是到了这一步,却把主动权移交给应用程序。

对于初始化阶段,《Java 虚拟机规范》严格规定了只有下面这六种情况下才会触发类的初始化。

  • 在遇到 new、getstatic、putstatic 或者 invokestatic 这四条字节码指令时,如果没有进行过初始化,那么首先触发初始化。通过这四个字节码的名称可以判断,这四条字节码其实就两个场景,调用 new 关键字的时候进行初始化、读取或者设置一个静态字段的时候、调用静态方法的时候。
  • 在初始化类的时候,如果父类还没有初始化,那么就需要先对父类进行初始化。
  • 在使用 java.lang.reflect 包的方法进行反射调用的时候。
  • 当虚拟机启动时,用户需要指定执行主类的时候,说白了就是虚拟机会先初始化 main 方法这个类。
  • 在使用 JDK 7 新加入的动态语言支持时,如果一个 jafva.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getstatic、REF_putstatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,需要先对其进行初始化。
  • 当一个接口中定义了 JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个借口的实现类发生了初始化,那该接口要在其之前被初始化。

其实上面只有前四个大家需要知道就好了,后面两个比较冷门。

如果说要答类加载的话,其实聊到这里已经可以了,但是为了完整性,我们索性把后面两个过程也来聊一聊。

使用

这个阶段没什么可说的,就是初始化之后的代码由 JVM 来动态调用执行。

卸载

当代表一个类的 Class 对象不再被引用,那么 Class 对象的生命周期就结束了,对应的在方法区中的数据也会被卸载。

⚠️但是需要注意一点:JVM 自带的类加载器装载的类,是不会卸载的,由用户自定义的类加载器加载的类是可以卸载的。

在 JVM 中,对象是如何创建的?

如果要回答对象是怎么创建的,我们一般想到的回答是直接 new 出来就行了,这个回答不仅局限于编程中,也融入在我们生活中的方方面面。

但是遇到面试的时候你只回答一个”new 出来就行了”显然是不行的,因为面试更趋向于让你解释当程序执行到 new 这条指令时,它的背后发生了什么。

所以你需要从 JVM 的角度来解释这件事情。

当虚拟机遇到一个 new 指令时(其实就是字节码),首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已经被加载、解析和初始化。

因为此时很可能不知道具体的类是什么,所以这里使用的是符号引用。

如果发现这个类没有经过上面类加载的过程,那么就执行相应的类加载过程。

类检查完成后,接下来虚拟机将会为新生对象分配内存,对象所需的大小在类加载完成后便可确定(我会在下面的面试题中介绍)。

分配内存相当于是把一块固定的内存块从堆中划分出来。划分出来之后,虚拟机会将分配到的内存空间都初始化为零值,如果使用了 TLAB(本地线程分配缓冲),这一项初始化工作可以提前在 TLAB 分配时进行。这一步操作保证了对象实例字段在 Java 代码中可以不赋值就能直接使用。

接下来,Java 虚拟机还会对对象进行必要的设置,比如确定对象是哪个类的实例、对象的 hashcode、对象的 gc 分代年龄信息。这些信息存放在对象的对象头(Object Header)中。

如果上面的工作都做完后,从虚拟机的角度来说,一个新的对象就创建完毕了;但是对于程序员来说,对象创建才刚刚开始,因为构造函数,即 Class 文件中的 <init>() 方法还没有执行,所有字段都为默认的零值。new 指令之后才会执行 <init>() 方法,然后按照程序员的意愿对对象进行初始化,这样一个对象才可能被完整的构造出来。

内存分配方式有哪些呢?

在类加载完成后,虚拟机需要为新生对象分配内存,为对象分配内存相当于是把一块确定的区域从堆中划分出来,这就涉及到一个问题,要划分的堆区是否规整。

假设 Java 堆中内存是规整的,所有使用过的内存放在一边,未使用的内存放在一边,中间放着一个指针,这个指针为分界指示器。那么为新对象分配内存空间就相当于是把指针向空闲的空间挪动对象大小相等的距离,这种内存分配方式叫做指针碰撞(Bump The Pointer)。

如果 Java 堆中的内存并不是规整的,已经被使用的内存和未被使用的内存相互交错在一起,这种情况下就没有办法使用指针碰撞,这里就要使用另外一种记录内存使用的方式:空闲列表(Free List),空闲列表维护了一个列表,这个列表记录了哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

所以,上述两种分配方式选择哪个,取决于 Java 堆是否规整来决定。在一些垃圾收集器的实现中,Serial、ParNew 等带压缩整理过程的收集器,使用的是指针碰撞;而使用 CMS 这种基于清除算法的收集器时,使用的是空闲列表,具体的垃圾收集器我们后面会聊到。

请你说一下对象的内存布局?

在 hotspot 虚拟机中,对象在内存中的布局分为三块区域:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

这三块区域的内存分布如下图所示

image-20210823223037637

我们来详细介绍一下上面对象中的内容。

对象头 Header

对象头 Header 主要包含 MarkWord 和对象指针 Klass Pointer,如果是数组的话,还要包含数组的长度。

image-20210823223045677

在 32 位的虚拟机中 MarkWord ,Klass Pointer 和数组长度分别占用 32 位,也就是 4 字节。

如果是 64 位虚拟机的话,MarkWord ,Klass Pointer 和数组长度分别占用 64 位,也就是 8 字节。

在 32 位虚拟机和 64 位虚拟机的 Mark Word 所占用的字节大小不一样,32 位虚拟机的 Mark Word 和 Klass Pointer 分别占用 32 bits 的字节,而 64 位虚拟机的 Mark Word 和 Klass Pointer 占用了64 bits 的字节,下面我们以 32 位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的。

image-20210823223455786

用中文翻译过来就是

image-20210823223519871

  • 无状态也就是无锁的时候,对象头开辟 25 bit 的空间用来存储对象的 hashcode ,4 bit 用于存放分代年龄,1 bit 用来存放是否偏向锁的标识位,2 bit 用来存放锁标识位为 01。
  • 偏向锁 中划分更细,还是开辟 25 bit 的空间,其中 23 bit 用来存放线程ID,2bit 用来存放 epoch,4bit 存放分代年龄,1 bit 存放是否偏向锁标识, 0 表示无锁,1 表示偏向锁,锁的标识位还是 01。
  • 轻量级锁中直接开辟 30 bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为 00。
  • 重量级锁中和轻量级锁一样,30 bit 的空间用来存放指向重量级锁的指针,2 bit 存放锁的标识位,为 11
  • GC标记开辟 30 bit 的内存空间却没有占用,2 bit 空间存放锁标志位为 11。

其中无锁和偏向锁的锁标志位都是 01,只是在前面的 1 bit 区分了这是无锁状态还是偏向锁状态。

关于为什么这么分配的内存,我们可以从 OpenJDK 中的markOop.hpp类中的枚举窥出端倪

image-20210823223531938

来解释一下

  • age_bits 就是我们说的分代回收的标识,占用4字节
  • lock_bits 是锁的标志位,占用2个字节
  • biased_lock_bits 是是否偏向锁的标识,占用1个字节。
  • max_hash_bits 是针对无锁计算的 hashcode 占用字节数量,如果是 32 位虚拟机,就是 32 - 4 - 2 -1 = 25 byte,如果是 64 位虚拟机,64 - 4 - 2 - 1 = 57 byte,但是会有 25 字节未使用,所以 64 位的 hashcode 占用 31 byte。
  • hash_bits 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取 31,否则取真实的字节数
  • cms_bits 我觉得应该是不是 64 位虚拟机就占用 0 byte,是 64 位就占用 1byte
  • epoch_bits 就是 epoch 所占用的字节大小,2 字节。

在上面的虚拟机对象头分配表中,我们可以看到有几种锁的状态:无锁(无状态),偏向锁,轻量级锁,重量级锁,其中轻量级锁和偏向锁是 JDK1.6 中对 synchronized 锁进行优化后新增加的,其目的就是为了大大优化锁的性能,所以在 JDK 1.6 中,使用 synchronized 的开销也没那么大了。其实从锁有无锁定来讲,还是只有无锁和重量级锁,偏向锁和轻量级锁的出现就是增加了锁的获取性能而已,并没有出现新的锁。

所以我们的重点放在对 synchronized 重量级锁的研究上,当 monitor 被某个线程持有后,它就会处于锁定状态。在 HotSpot 虚拟机中,monitor 的底层代码是由 ObjectMonitor 实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++ 实现的)

image-20210823223547587

这段 C++ 中需要注意几个属性:_WaitSet 、 _EntryList 和 _Owner,每个等待获取锁的线程都会被封装称为 ObjectWaiter 对象。

image-20210823223558339

_Owner 是指向了 ObjectMonitor 对象的线程,而 _WaitSet 和 _EntryList 就是用来保存每个线程的列表。

那么这两个列表有什么区别呢?这个问题我和你聊一下锁的获取流程你就清楚了。

锁的两个列表

当多个线程同时访问某段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 之后,就会进入 _Owner 区域,并把 ObjectMonitor 对象的 _Owner 指向为当前线程,并使 _count + 1,如果调用了释放锁(比如 wait)的操作,就会释放当前持有的 monitor ,owner = null, _count - 1,同时这个线程会进入到 _WaitSet 列表中等待被唤醒。如果当前线程执行完毕后也会释放 monitor 锁,只不过此时不会进入 _WaitSet 列表了,而是直接复位 _count 的值。

image-20210823223605628

Klass Pointer 表示的是类型指针,也就是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

你可能不是很理解指针是个什么概念,你可以简单理解为指针就是指向某个数据的地址。

image-20210823223616085

实例数据 Instance Data

实例数据部分是对象真正存储的有效信息,也是代码中定义的各个字段的字节大小,比如一个 byte 占 1 个字节,一个 int 占用 4 个字节。

对齐 Padding

对齐不是必须存在的,它只起到了**占位符(%d, %c 等)**的作用。这就是 JVM 的要求了,因为 HotSpot JVM 要求对象的起始地址必须是 8 字节的整数倍,也就是说对象的字节大小是 8 的整数倍,不够的需要使用 Padding 补全。

对象访问定位的方式有哪些?

我们创建一个对象的目的当然就是为了使用它,但是,一个对象被创建出来之后,在 JVM 中是如何访问这个对象的呢?一般有两种方式:通过句柄访问和 通过直接指针访问。

  • 如果使用句柄访问方式的话,Java 堆中可能会划分出一块内存作为句柄池,引用(reference)中存储的是对象的句柄地址,而句柄中包含了对象的实例数据与类型数据各自具体的地址信息。如下图所示。

image-20210821225508905

  • 如果使用直接指针访问的话,Java 堆中对象的内存布局就会有所区别,栈区引用指示的是堆中的实例数据的地址,如果只是访问对象本身的话,就不会多一次直接访问的开销,而对象类型数据的指针是存在于方法区中,如果定位的话,需要多一次直接定位开销。如下图所示

image-20210821225705281

这两种对象访问方式各有各的优势,使用句柄最大的好处就是引用中存储的是句柄地址,对象移动时只需改变句柄的地址就可以,而无需改变对象本身。

使用直接指针来访问速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因为这类的开销也是值得优化的地方。

上面聊到了对象的两种数据,一种是对象的实例数据,这没什么好说的,就是对象实例字段的数据,一种是对象的类型数据,这个数据说的是对象的类型、父类、实现的接口和方法等。

如何判断对象已经死亡?

我们大家知道,基本上所有的对象都在堆中分布,当我们不再使用对象的时候,垃圾收集器会对无用对象进行回收♻️,那么 JVM 是如何判断哪些对象已经是”无用对象”的呢?

这里有两种判断方式,首先我们先来说第一种:引用计数法。

引用计数法的判断标准是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就会加一;当引用失效时,计数器的值就会减一;只要任何时刻计数器为零的对象就是不会再被使用的对象。虽然这种判断方式非常简单粗暴,但是往往很有用,不过,在 Java 领域,主流的 Hotspot 虚拟机实现并没有采用这种方式,因为引用计数法不能解决对象之间的循环引用问题。

循环引用问题简单来讲就是两个对象之间互相依赖着对方,除此之外,再无其他引用,这样虚拟机无法判断引用是否为零从而进行垃圾回收操作。

还有一种判断对象无用的方法就是可达性分析算法。

当前主流的 JVM 都采用了可达性分析算法来进行判断,这个算法的基本思路就是通过一系列被称为GC Roots的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径被称为引用链(Reference Chain),如果某个对象到 GC Roots 之间没有任何引用链相连接,或者说从 GC Roots 到这个对象不可达时,则证明此这个对象是无用对象,需要被垃圾回收。

这种引用方式如下

image-20210822230043691

如上图所示,从枚举根节点 GC Roots 开始进行遍历,object 1 、2、3、4 是存在引用关系的对象,而 object 5、6、7 之间虽然有关联,但是它们到 GC Roots 之间是不可大的,所以被认为是可以回收的对象。

在 Java 技术体系中,可以作为 GC Roots 进行检索的对象主要有

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象,比如 Java 类的引用类型静态变量。
  • 方法区中常量引用的对象,比如字符串常量池中的引用。
  • 在本地方法栈中 JNI 引用的对象。
  • JVM 内部的引用,比如基本数据类型对应的 Class 对象,一些异常对象比如 NullPointerException、OutOfMemoryError 等,还有系统类加载器。
  • 所有被 synchronized 持有的对象。
  • 还有一些 JVM 内部的比如 JMXBean、JVMTI 中注册的回调,本地代码缓存等。
  • 根据用户所选的垃圾收集器以及当前回收的内存区域的不同,还可能会有一些对象临时加入,共同构成 GC Roots 集合。

虽然我们上面提到了两种判断对象回收的方法,但无论是引用计数法还是判断 GC Roots 都离不开引用这一层关系。

这里涉及到到强引用、软引用、弱引用、虚引用的引用关系,你可以阅读作者的这一篇文章

小心点,别被当成垃圾回收了。

如何判断一个不再使用的类?

判断一个类型属于”不再使用的类”需要满足下面这三个条件

  • 这个类所有的实例已经被回收,也就是 Java 堆中不存在该类及其任何这个类字累的实例
  • 加载这个类的类加载器已经被回收,但是类加载器一般很难会被回收,除非这个类加载器是为了这个目的设计的,比如 OSGI、JSP 的重加载等,否则通常很难达成。
  • 这个类对应的 Class 对象没有任何地方被引用,无法在任何时刻通过反射访问这个类的属性和方法。

虚拟机允许对满足上面这三个条件的无用类进行回收操作。

JVM 分代收集理论有哪些?

一般商业的虚拟机,大多数都遵循了分代收集的设计思想,分代收集理论主要有两条假说。

第一个是强分代假说,强分代假说指的是 JVM 认为绝大多数对象的生存周期都是朝生夕灭的;

第二个是弱分代假说,弱分代假说指的是只要熬过越多次垃圾收集过程的对象就越难以回收(看来对象也会长心眼)。

就是基于这两个假说理论,JVM 将堆区划分为不同的区域,再将需要回收的对象根据其熬过垃圾回收的次数分配到不同的区域中存储。

JVM 根据这两条分代收集理论,把堆区划分为**新生代(Young Generation)和老年代(Old Generation)**这两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,剩下没有死去的对象会直接晋升到老年代中。

上面这两个假说没有考虑对象的引用关系,而事实情况是,对象之间会存在引用关系,基于此又诞生了第三个假说,即跨代引用假说(Intergeneration Reference Hypothesis),跨代引用相比较同代引用来说仅占少数。

正常来说存在相互引用的两个对象应该是同生共死的,不过也会存在特例,如果一个新生代对象跨代引用了一个老年代的对象,那么垃圾回收的时候就不会回收这个新生代对象,更不会回收老年代对象,然后这个新生代对象熬过一次垃圾回收进入到老年代中,这时候跨代引用才会消除。

根据跨代引用假说,我们不需要因为老年代中存在少量跨代引用就去直接扫描整个老年代,也不用在老年代中维护一个列表记录有哪些跨代引用,实际上,可以直接在新生代中维护一个记忆集(Remembered Set),由这个记忆集把老年代划分称为若干小块,标识出老年代的哪一块会存在跨代引用。

记忆集的图示如下

image-20210903223603191

从图中我们可以看到,记忆集中的每个元素分别对应内存中的一块连续区域是否有跨代引用对象,如果有,该区域会被标记为“脏的”(dirty),否则就是“干净的”(clean)。这样在垃圾回收时,只需要扫描记忆集就可以简单地确定跨代引用的位置,是个典型的空间换时间的思路。

聊一聊 JVM 中的垃圾回收算法?

在聊具体的垃圾回收算法之前,需要明确一点,哪些对象需要被垃圾收集器进行回收?也就是说需要先判断哪些对象是”垃圾”?

判断的标准我在上面如何判断对象已经死亡的问题中描述了,有两种方式,一种是引用计数法,这种判断标准就是给对象添加一个引用计数器,引用这个对象会使计数器的值 + 1,引用失效后,计数器的值就会 -1。但是这种技术无法解决对象之间的循环引用问题。

还有一种方式是 GC Roots,GC Roots 这种方式是以 Root 根节点为核心,逐步向下搜索每个对象的引用,搜索走过的路径被称为引用链,如果搜索过后这个对象不存在引用链,那么这个对象就是无用对象,可以被回收。GC Roots 可以解决循环引用问题,所以一般 JVM 都采用的是这种方式。

解决循环引用代码描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public class test{
public static void main(String[]args){
A a = new A();
B b = new B();
a=null;
b=null;
}
}
class A {

public B b;
}
class B {
public A a;
}

基于 GC Roots 的这种思想,发展出了很多垃圾回收算法,下面我们就来聊一聊这些算法。

标记-清除算法

**标记-清除(Mark-Sweep)**这个算法可以说是最早最基础的算法了,标记-清除顾名思义分为两个阶段,即标记和清除阶段:首先标记处所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。当然也可以标记存活的对象,回收未被标记的对象。这个标记的过程就是垃圾判定的过程。

后续大部分垃圾回收算法都是基于标记-算法思想衍生的,只不过后续的算法弥补了标记-清除算法的缺点,那么它由什么缺点呢?主要有两个

  • 执行效率不稳定,因为假如说堆中存在大量无用对象,而且大部分需要回收的情况下,这时必须进行大量的标记和清除,导致标记和清除这两个过程的执行效率随对象的数量增长而降低。
  • 内存碎片化,标记-清除算法会在堆区产生大量不连续的内存碎片。碎片太多会导致在分配大对象时没有足够的空间,不得不进行一次垃圾回收操作。

标记算法的示意图如下

image-20210904182457721

标记-复制算法

由于标记-清除算法极易产生内存碎片,研究人员提出了标记-复制算法,标记-复制算法也可以简称为复制算法,复制算法是一种半区复制,它会将内存大小划分为相等的两块,每次只使用其中的一块,用完一块再用另外一块,然后再把用过的一块进行清除。虽然解决了部分内存碎片的问题,但是复制算法也带来了新的问题,即复制开销,不过这种开销是可以降低的,如果内存中大多数对象是无用对象,那么就可以把少数的存活对象进行复制,再回收无用的对象。

不过复制算法的缺陷也是显而易见的,那就是内存空间缩小为原来的一半,空间浪费太明显。标记-复制算法示意图如下

image-20210904182444311

现在 Java 虚拟机大多数都是用了这种算法来回收新生代,因为经过研究表明,新生代对象由 98% 都熬不过第一轮收集,因此不需要按照 1 : 1 的比例来划分新生代的内存空间。

基于此,研究人员提出了一种 Appel 式回收,Appel 式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块 Survivor 空间,每次分配内存都只使用 Eden 和其中的一块 Survivor 空间,发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已使用过的 Survivor 空间。

在主流的 HotSpot 虚拟机中,默认的 Eden 和 Survivor 大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%,只有一个 Survivor 空间,所以会浪费掉 10% 的空间。这个 8:1 只是一个理论值,也就是说,不能保证每次都有不超过 10% 的对象存活,所以,当进行垃圾回收后如果 Survivor 容纳不了可存活的对象后,就需要其他内存空间来进行帮助,这种方式就叫做内存担保(Handle Promotion) ,通常情况下,作为担保的是老年代。

标记-整理算法

标记-复制算法虽然解决了内存碎片问题,但是没有解决复制对象存在大量开销的问题。为了解决复制算法的缺陷,充分利用内存空间,提出了标记-整理算法。该算法标记阶段和标记-清除一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:

image-20210904232102284

什么是记忆集,什么是卡表?记忆集和卡表有什么关系?

为了解决跨代引用问题,提出了记忆集这个概念,记忆集是一个在新生代中使用的数据结构,它相当于是记录了一些指针的集合,指向了老年代中哪些对象存在跨代引用。

记忆集的实现有不同的粒度

  • 字长精度:每个记录精确到一个字长,机器字长就是处理器的寻址位数,比如常见的 32 位或者 64 位处理器,这个精度决定了机器访问物理内存地址的指针长度,字中包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,区域内含有跨代指针。

其中卡精度是使用了卡表作为记忆集的实现,关于记忆集和卡表的关系,大家可以想象成是 HashMap 和 Map 的关系。

什么是卡页?

卡表其实就是一个字节数组

1
java复制代码CARD_TABLE[this address >> 9] = 0;

字节数组 CARD_TABLE 的每一个元素都对应着内存区域中一块特定大小的内存块,这个内存块就是卡页,一般来说,卡页都是 2 的 N 次幂字节数,通过上面的代码我们可以知道,卡页一般是 2 的 9 次幂,这也是 HotSpot 中使用的卡页,即 512 字节。

一个卡页的内存通常包含不止一个对象,只要卡页中有一个对象的字段存在跨代指针,那就将对应卡表的数组元素的值设置为 1,称之为这个元素变脏了,没有标示则为 0 。在垃圾收集时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,然后把他们加入 GC Roots 进行扫描。

所以,卡页和卡表主要用来解决跨代引用问题的。

什么是写屏障?写屏障带来的问题?

如果有其他分代区域中对象引用了本区域的对象,那么其对应的卡表元素就会变脏,这个引用说的就是对象赋值,也就是说卡表元素会变脏发生在对象赋值的时候,那么如何在对象赋值的时候更新维护卡表呢?

在 HotSpot 虚拟机中使用的是写屏障(Write Barrier) 来维护卡表状态的,这个写屏障和我们内存屏障完全不同,希望读者不要搞混了。

这个写屏障其实就是一个 Aop 切面,在引用对象进行赋值时会产生一个环形通知(Around),环形通知就是切面前后分别产生一个通知,因为这个又是写屏障,所以在赋值前的部分写屏障叫做写前屏障,在赋值后的则叫做写后屏障。

写屏障会带来两个问题

无条件写屏障带来的性能开销

每次对引用的更新,无论是否更新了老年代对新生代对象的引用,都会进行一次写屏障操作。显然,这会增加一些额外的开销。但是,扫描整个老年代相比较,这个开销就低得多了。

不过,在高并发环境下,写屏障又带来了伪共享(false sharing)问题。

高并发下伪共享带来的性能开销

在高并发情况下,频繁的写屏障很容易发生伪共享(false sharing),从而带来性能开销。

假设 CPU 缓存行大小为 64 字节,由于一个卡表项占 1 个字节,这意味着,64 个卡表项将共享同一个缓存行。

HotSpot 每个卡页为 512 字节,那么一个缓存行将对应 64 个卡页一共 64*512 = 32K B。

如果不同线程对对象引用的更新操作,恰好位于同一个 32 KB 区域内,这将导致同时更新卡表的同一个缓存行,从而造成缓存行的写回、无效化或者同步操作,间接影响程序性能。

一个简单的解决方案,就是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表项未被标记过才将其标记为脏的。

这就是 JDK 7 中引入的解决方法,引入了一个新的 JVM 参数 -XX:+UseCondCardMark,在执行写屏障之前,先简单的做一下判断。如果卡页已被标识过,则不再进行标识。

简单理解如下:

1
2
bash复制代码if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;

与原来的实现相比,只是简单的增加了一个判断操作。

虽然开启 -XX:+UseCondCardMark 之后多了一些判断开销,但是却可以避免在高并发情况下可能发生的并发写卡表问题。通过减少并发写操作,进而避免出现伪共享问题(false sharing)。

什么是三色标记法?三色标记法会造成哪些问题?

根据可达性算法的分析可知,如果要找出存活对象,需要从 GC Roots 开始遍历,然后搜索每个对象是否可达,如果对象可达则为存活对象,在 GC Roots 的搜索过程中,按照对象和其引用是否被访问过这个条件会分成下面三种颜色:

  • 白色:白色表示 GC Roots 的遍历过程中没有被访问过的对象,出现白色显然在可达性分析刚刚开始的阶段,这个时候所有对象都是白色的,如果在分析结束的阶段,仍然是白色的对象,那么代表不可达,可以进行回收。
  • 灰色:灰色表示对象已经被访问过,但是这个对象的引用还没有访问完毕。
  • 黑色:黑色表示此对象已经被访问过了,而且这个对象的引用也已经呗访问了。

注:如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用。

现代的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。

三色标记法会造成两种问题,这两种问题所出现的环境都是由于用户环境和收集器并行工作造成的 。当用户线程正在修改引用关系,此时收集器在回收引用关系,此时就会造成把原本已经消亡的对象标记为存活,如果出现这种状况的话,问题不大,下次再让收集器重新收集一波就完了,但是还有一种情况是把存活的对象标记为死亡,这种状况就会造成不可预知的后果。

针对上面这两种对象消失问题,业界有两种处理方式,一种是增量更新(Incremental Update) ,一种是原是快照(Snapshot At The Beginning, SATB)。

请你介绍一波垃圾收集器

垃圾收集器是面试的常考,也是必考点,只要涉及到 JVM 的相关问题,都会围绕着垃圾收集器来做一波展开,所以,有必要了解一下这些垃圾收集器。

垃圾收集器有很多,不同商家、不同版本的J VM 所提供的垃圾收集器可能会有很在差别,我们主要介绍 HotSpot 虚拟机中的垃圾收集器。

垃圾收集器是垃圾回收算法的具体实现,我们上面提到过,垃圾回收算法有标记-清除算法、标记-整理、标记-复制,所以对应的垃圾收集器也有不同的实现方式。

我们知道,HotSpot 虚拟机中的垃圾收集都是分代回收的,所以根据不同的分代,可以把垃圾收集器分为

新生代收集器:Serial、ParNew、Parallel Scavenge;

老年代收集器:Serial Old、Parallel Old、CMS;

整堆收集器:G1;

Serial 收集器

Serial 收集器是一种新生代的垃圾收集器,它是一个单线程工作的收集器,使用复制算法来进行回收,单线程工作不是说这个垃圾收集器只有一个,而是说这个收集器在工作时,必须暂停其他所有工作线程,这种暴力的暂停方式就是 Stop The World,Serial 就好像是寡头垄断一样,只要它一发话,其他所有的小弟(线程)都得给它让路。Serial 收集器的示意图如下:

image-20210921224244386

SefePoint 全局安全点:它就是代码中的一段特殊的位置,在所有用户线程到达 SafePoint 之后,用户线程挂起,GC 线程会进行清理工作。

虽然 Serial 有 STW 这种显而易见的缺点,不过,从其他角度来看,Serial 还是很讨喜的,它还有着优于其他收集器的地方,那就是简单而高效,对于内存资源首先的环境,它是所有收集器中额外内存消耗最小的,对于单核处理器或者处理器核心较少的环境来说,Serial 收集器由于没有线程交互开销,所以 Serial 专心做垃圾回收效率比较高。

ParNew 收集器

ParNew 是 Serial 的多线程版本,除了同时使用多条线程外,其他参数和机制(STW、回收策略、对象分配规则)都和 Serial 完全一致,ParNew 收集器的示意图如下:

image-20210921234313336

虽然 ParNew 使用了多条线程进行垃圾回收,但是在单线程环境下它绝对不会比 Serial 收集效率更高,因为多线程存在线程交互的开销,但是随着可用 CPU 核数的增加,ParNew 的处理效率会比 Serial 更高效。

Parallel Scavenge 收集器

Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的,而且它也能够并行收集,这么看来,表面上 Parallel Scavenge 于 ParNew 非常相似,那么它们之间有什么区别呢?

Parallel Scavenge 的关注点主要在达到一个可控制的吞吐量上面。吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比。也就是

image-20210922205128446

这里给大家举一个吞吐量的例子,如果执行用户代码的时间 + 运行垃圾收集的时间总共耗费了 100 分钟,其中垃圾收集耗费掉了 1 分钟,那么吞吐量就是 99%。停顿时间越短就越适合需要与用户交互或需要保证服务响应质量,良好的响应速度可以提升用户体验,而高吞吐量可以最高效率利用处理器资源。

Serial Old 收集器

前面介绍了一下 Serial,我们知道它是一个新生代的垃圾收集,使用了标记-复制算法。而这个 Serial Old 收集器却是 Serial 的老年版本,它同样也是一个单线程收集器,使用的是标记-整理算法,Serial Old 收集器有两种用途:一种是在 JDK 5 和之前的版本与 Parallel Scavenge 收集器搭配使用,另外一种用法就是作为 CMS 收集器的备选,CMS 垃圾收集器我们下面说,Serial Old 的收集流程如下

image-20210922212732454

Parallel Old 收集器

前面我们介绍了 Parallel Scavenge 收集器,现在来介绍一下 Parallel Old 收集器,它是 Parallel Scavenge 的老年版本,支持多线程并发收集,基于标记 - 整理算法实现,JDK 6 之后出现,吞吐量优先可以考虑 Parallel Scavenge + Parallel Old 的搭配

image-20210922213221449

CMS 收集器

CMS收集器的主要目标是获取最短的回收停顿时间,它的全称是 Concurrent Mark Sweep,从这个名字就可以知道,这个收集器是基于标记 - 清除算法实现的,而且支持并发收集,它的运行过程要比上面我们提到的收集器复杂一些,它的工作流程如下:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

对于上面这四个步骤,初始标记和并发标记都需要 Stop The World,初始标记只是标记一下和 GC Roots 直接关联到的对象,速度较快;并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程。这个过程时间比较长但是不需要停顿用户线程,也就是说与垃圾收集线程一起并发运行。并发标记的过程中,可能会有错标或者漏标的情况,此时就需要在重新标记一下,最后是并发清除阶段,清理掉标记阶段中判断已经死亡的对象。

CMS 的收集过程如下

image-20210922223723196

CMS 是一款非常优秀的垃圾收集器,但是没有任何收集器能够做到完美的程度,CMS 也是一样,CMS 至少有三个缺点:

  • CMS 对处理器资源非常敏感,在并发阶段,虽然不会造成用户线程停顿,但是却会因为占用一部分线程而导致应用程序变慢,降低总吞吐量。
  • CMS 无法处理浮动垃圾,有可能出现Concurrent Mode Failure失败进而导致另一次完全 *Stop The World的 *Full GC 产生。

什么是浮动垃圾呢?由于并发标记和并发清理阶段,用户线程仍在继续运行,所以程序自然而然就会伴随着新的垃圾不断出现,而且这一部分垃圾出现在标记结束之后,CMS 无法处理这些垃圾,所以只能等到下一次垃圾回收时在进行清理。这一部分垃圾就被称为浮动垃圾。

  • CMS 最后一个缺点是并发-清除的通病,也就是会有大量的空间碎片出现,这将会给分配大对象带来困难。

Garbage First 收集器

Garbage First 又被称为 G1 收集器,它的出现意味着垃圾收集器走过了一个里程碑,为什么说它是里程碑呢?因为 G1 这个收集器是一种面向局部的垃圾收集器,HotSpot 团队开发这个垃圾收集器为了让它替换掉 CMS 收集器,所以到后来,JDK 9 发布后,G1 取代了 Parallel Scavenge + Parallel Old 组合,成为服务端默认的垃圾收集器,而 CMS 则不再推荐使用。

之前的垃圾收集器存在回收区域的局限性,因为之前这些垃圾收集器的目标范围要么是整个新生代、要么是整个老年代,要么是整个 Java 堆(Full GC),而 G1 跳出了这个框架,它可以面向堆内存的任何部分来组成回收集(Collection Set,CSet),衡量垃圾收集的不再是哪个分代,这就是 G1 的 Mixed GC 模式。

G1 是基于 Region 来进行回收的,Region 就是堆内存中任意的布局,每一块 Region 都可以根据需要扮演 Eden 空间、Survivor 空间或者老年代空间,收集器能够对不同的 Region 角色采用不同的策略来进行处理。Region 中还有一块特殊的区域,这块区域就是 Humongous 区域,它是专门用来存储大对象的,G1 认为只要大小超过了 Region 容量一半的对象即可判定为大对象。如果超过了 Region 容量的大对象,将会存储在连续的 Humongous Region 中,G1 大多数行为都会吧 Humongous Region 作为老年代来看待。

G1 保留了新生代(Eden Suvivor)和老年代的概念,但是新生代和老年代不再是固定的了。它们都是一系列区域的动态集合。

G1 收集器的运作过程可以分为以下四步:

  • 初始标记:这个步骤也仅仅是标记一下 GC Roots 能够直接关联到的对象;并修改 TAMS 指针的值(每一个 Region 都有两个 RAMS 指针),似的下一阶段用户并发运行时,能够在可用的 Region 中分配对象,这个阶段需要暂停用户线程,但是时间很短。这个停顿是借用 Minor GC 的时候完成的,所以可以忽略不计。
  • 并发标记:从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆中的对象图,找出要回收的对象。当对象图扫描完成后,重新处理 SATB 记录下的在并发时有引用的对象;
  • 最终标记:对用户线程做一个短暂的暂停,用于处理并发阶段结束后遗留下来的少量 SATB 记录(一种原始快照,用来记录并发标记中某些对象)
  • 筛选回收:负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择多个 Region 构成回收集,然后把决定要回收的那一部分 Region 存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作设计对象的移动,所以必须要暂停用户线程,由多条收集器线程并行收集

从上面这几个步骤可以看出,除了并发标记外,其余三个阶段都需要暂停用户线程,所以,这个 G1 收集器并非追求低延迟,官方给出的设计目标是在延迟可控的情况下尽可能的提高吞吐量,担任全功能收集器的重任。

下面是 G1 回收的示意图

image-20210923221512041

G1 收集器同样也有缺点和问题:

  • 第一个问题就是 Region 中存在跨代引用的问题,我们之前知道可以用记忆集来解决跨代引用问题,不过 Region 中的跨代引用要复杂很多;
  • 第二个问题就是如何保证收集线程与用户线程互不干扰的运行?CMS 使用的是增量更新算法,G1 使用的是原始快照(SATB),G1 为 Region 分配了两块 TAMS 指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时心分配的对象地址都必须在这两个指针位置以上。如果内存回收速度赶不上内存分配速度,G1 收集器也要冻结用户线程执行,导致 Full GC 而产生长时间的 STW。
  • 第三个问题是无法建立可预测的停顿模型。

JVM 常用命令介绍

下面介绍一下 JVM 中常用的调优、故障处理等工具。

  1. jps :虚拟机进程工具,全称是 JVM Process Status Tool*,它的功能和 Linux 中的 *ps 类似,可以列出正在运行的虚拟机进程,并显示虚拟机执行主类 Main Class 所在的本地虚拟机唯一 ID,虽然功能比较单一,但是这个命令绝对是使用最高频的一个命令。
  2. jstat:虚拟机统计信息工具,用于监视虚拟机各种运行状态的信息的命令行工具,它可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据。
  3. jinfo:Java 配置信息工具,全称是 Configuration Info for Java,它的作用是可以事实调整虚拟机各项参数。
  4. jmap:Java 内存映像工具,全称是 Memory Map For Java,它用于生成转储快照,用来排查内存占用情况
  5. jhat:虚拟机堆转储快照分析工具,全称是 JVM Heap Analysis Tool,这个指令通常和 jmap 一起搭配使用,jhat 内置了一个 HTTP/Web 服务器,生成转储快照后可以在浏览器中查看。不过,一般还是 jmap 命令使用的频率比较高。
  6. jstack*:Java 堆栈跟踪工具,全称是 *Stack Trace for Java ,顾名思义,这个命令用来追踪堆栈的使用情况,用于虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条正在执行的方法堆栈的集合。

什么是双亲委派模型?

JVM 类加载默认使用的是双亲委派模型,那么什么是双亲委派模型呢?

这里我们需要先介绍一下三种类加载器:

  • 启动类加载器,Bootstrap Class Loader*,这个类加载器是 C++ 实现的,它是 JVM 的一部分,这个类加载器负责加载存放在 *<JAVA_HOME>\lib 目录,启动类加载器无法被 Java 程序直接引用。这也就是说,JDK 中的常用类的加载都是由启动类加载器来完成的。
  • 扩展类加载器,Extension Class Loader*,这个类加载器是 Java 实现的,它负责加载 *<JAVA_HOME>\lib\ext 目录。
  • 应用程序类加载器,Application Class Loader*,这个类加载器是由 *sum.misc.Launcher$AppClassLoader 来实现,它负责加载 ClassPath 上所有的类库,如果应用程序中没有定义自己的类加载器,默认使用就是这个类加载器。

所以,我们的 Java 应用程序都是由这三种类加载器来相互配合完成的,当然,用户也可以自己定义类加载器,即 User Class Loader,这几个类加载器的模型如下

image-20210924231418026

上面这几类类加载器构成了不同的层次结构,当我们需要加载一个类时,子类加载器并不会马上去加载,而是依次去请求父类加载器加载,一直往上请求到最高类加载器:启动类加载器。当启动类加载器加载不了的时候,依次往下让子类加载器进行加载。这就是双亲委派模型。

双亲委派模型的缺陷?

在双亲委派模型中,子类加载器可以使用父类加载器已经加载的类,而父类加载器无法使用子类加载器已经加载的。这就导致了双亲委派模型并不能解决所有的类加载器问题。

Java 提供了很多外部接口,这些接口统称为 Service Provider Interface, SPI,允许第三方实现这些接口,而这些接口却是 Java 核心类提供的,由 Bootstrap Class Loader 加载,而一般的扩展接口是由 Application Class Loader 加载的,Bootstrap Class Loader 是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。它也不能代理给 Application Class Loader,因为它是最顶层的类加载器。

双亲委派机制的三次破坏

虽然双亲委派机制是 Java 强烈推荐给开发者们的类加载器的实现方式,但是并没有强制规定你必须就要这么实现,所以,它一样也存在被破坏的情况,实际上,历史上一共出现三次双亲委派机制被破坏的情况:

  • 双亲委派机制第一次被破坏发生在双亲委派机制出现之前,由于双亲委派机制 JDK 1.2 之后才引用的,但类加载的概念在 Java 刚出现的时候就有了,所以引用双亲委派机制之前,设计者们必须兼顾开发者们自定义的一些类加载器的代码,所以在 JDK 1.2 之后的 java.lang.ClassLoader 中添加了一个新的 findClass 方法,引导用户编写类加载器逻辑的时候重写这个 findClass 方法,而不是基于 loadClass编写。
  • 双亲委派机制第二次被破坏是由于它自己模型导致的,由于它只能向上(基础)加载,越基础的类越由上层加载器加载,所以如果基础类型又想要调用用户的代码,该怎么办?这也就是我们上面那个问题所说的 SPI 机制。那么 JDK 团队是如何做的呢?它们引用了一个 线程上下文类加载器(Thread Context ClassLoader),这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader 进行设置,如果创建时线程还未设置,它将会从父线程中继承,如果全局没有设置类加载器的话,这个 ClassLoader 就是默认的类加载器。这种行为虽然是一种犯规行为,但是 Java 代码中的 JNDI、JDBC 等都是使用这种方式来完成的。直到 JDK 6 ,引用了 java.util.ServiceLoader,使用 META-INF/services + 责任链的设计模式,才解决了 SPI 的这种加载机制。
  • 双亲委派机制第三次被破坏是由于用户对程序的动态需求使热加载、热部署的引入所致。由于时代的变化,我们希望 Java 能像鼠标键盘一样实现热部署,即时加载(load class),引入了 OSGI,OSGI 实现热部署的关键在于它自定义类加载器机制的实现,OSGI 中的每一个 Bundle 也就是模块都有一个自己的类加载器。当需要更换 Bundle 时,就直接把 Bundle 连同类加载器一起替换掉就能够实现热加载。在 OSGI 环境下,类加载器不再遵从双亲委派机制,而是使用了一种更复杂的加载机制。

常见的 JVM 调优参数有哪些?

  • -Xms256m:初始化堆大小为 256m;
  • -Xmx2g:堆最大内存为 2g;
  • -Xmn50m:新生代的大小50m;
  • -XX:+PrintGCDetails 打印 gc 详细信息;
  • -XX:+HeapDumpOnOutOfMemoryError 在发生OutOfMemoryError错误时,来 dump 出堆快照;
  • -XX:NewRatio=4 设置年轻的和老年代的内存比例为 1:4;
  • -XX:SurvivorRatio=8 设置新生代 Eden 和 Survivor 比例为 8:2;
  • -XX:+UseSerialGC 新生代和老年代都用串行收集器 Serial + Serial Old
  • -XX:+UseParNewGC 指定使用 ParNew + Serial Old 垃圾回收器组合;
  • -XX:+UseParallelGC 新生代使用 Parallel Scavenge,老年代使用 Serial Old
  • -XX:+UseParallelOldGC:新生代 ParallelScavenge + 老年代 ParallelOld 组合;
  • -XX:+UseConcMarkSweepGC:新生代使用 ParNew,老年代的用 CMS;
  • -XX:NewSize:新生代最小值;
  • -XX:MaxNewSize:新生代最大值
  • -XX:MetaspaceSize 元空间初始化大小
  • -XX:MaxMetaspaceSize 元空间最大值

如果对你有帮助,可以关注一下 公众号:程序员cxuan, 有更多的硬核文章等着你。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

高并发中的 限流、熔断、降级、预热、背压!

发表于 2021-10-08

原创:小姐姐味道(微信公众号ID:xjjdog),欢迎分享,转载请保留出处。

首先,我们需要明确一下这几个名词出现的场景:分布式高并发环境。如果你的产品卖相不好,没人鸟它,那它就用不着这几个属性。不需要任何加成,低并发系统就能工作的很好。

分布式系统是一个整体,调用关系错综复杂,其中某个资源异常,大概率会造成级联故障。当系统处于超负荷的压力之下,容器或者宿主机,将表现的异乎寻常的脆弱。load飙升、拒绝相应,甚至于雪崩,造成的后果都比较严重。

鉴于分布式系统病娇娘样式的反应,我们有各种手段来处理这些异常状况。接下来,我们将简要介绍一下这些场景,还有常用的手段。

  1. 限流

“我的贴子被限流了!” 即使不是互联网从业人员,也能言之凿凿的说出这样的话。当他这么说的时候,他并不是在说高并发中的限流,它只是逻辑意义上的。

web开发中,tomcat默认是200个线程池,当更多的请求到来,没有新的线程能够去处理这个请求,那这个请求将会一直等待在浏览器方。表现的形式是,浏览器一直在转圈(还没超过acceptCount),即使你请求的是一个简单的Hello world。

你可以把这个过程,也看作是限流。它在本质上,是设置一个资源数量上限,超出这个上限的请求,将被缓冲,或者直接失败。

对于高并发场景下的限流来说,它有特殊的含义:它主要是用来保护底层资源的。如果你想要调用某些服务,你需要首先获取调用它的许可。限流一般由服务提供方来提供,对调用方能够做事的能力进行限制。

比如,某个服务为A、B、C都提供了服务,但根据提前申请的流量预估,限制A服务的请求为1000/秒、B服务2000/秒,C服务1w/秒。在同一时刻,某些客户端可能会出现被拒绝的请求,而某些客户端能够正常运行,限流被看作是服务端的自我保护能力。

常见的限流算法有:计数器、漏桶、令牌桶等。但计数器算法无法实现平滑的限流,在实际应用中使用较少。

《高并发之限流,到底限的什么鬼》

《信号量限流,高并发场景不得不说的秘密》

  1. 熔断

通常来说,皇帝在微服务里想夜生活过得舒服,能够大刀阔斧单刀直入,不因私事丢江山,就不得不靠熔断大总管。熔断的作用,主要是为了避免服务的雪崩。

如图,A→B→C互相依次调用,但C项目很可能出现问题(流量过大或者报错等),就会引发线程一直进行等待,导致拖垮整个链路层,线程资源耗尽。

image.png

意如其名,熔断就像是保险丝,超过负载了保险丝就烧掉了。当然,当后端服务缓和的时候,我们还可以再把它接上。熔断功能一般由调用端提供,用在不太重要的旁路请求上,避免这些不重要的服务因为异常或者超时,影响正常的、重要的业务逻辑

在实现上,我们可以把熔断看作是一种代理模式。当熔断打开的时候,服务将暂停对其保护资源的访问,并返回固定的或者不产生远程调用的默认结果。

《轻拢慢捻,微服务熔断大总管》

  1. 降级

降级是一个比较模糊的说法。限流、熔断,在一定程度上,也可以看作是降级的一种。但通常所说的降级,切入的层次更加高级一些。

降级一般考虑的是分布式系统的整体性,从源头上切断流量的来源。比如在双11的时候,为了保证交易系统,将会暂停一些不重要的服务,以免产生资源争占。服务降级有人工参与,人为使得某些服务不可用,多属于一种业务降级方式。

在什么地方最适合做降级呢?就是入口。比如Nginx,比如DNS等。

在某些互联网应用中,会存在MVP(Minimum Viable Product)这个概念,意为最小化可行产品,它的SLA要求非常高。围绕着最小可行性产品,会有一系列的服务拆分操作,当然某些情况甚至需要重写。

比如,一个电商系统,在极端情况下,只需要把商品显示出来,把商品卖出去就行。其他一些支撑性的系统,比如评论、推荐等,都可以临时关掉。在物理部署和调用关系上,就要考虑这些情况。

  1. 预热

请看下面一种情况。

一个高并发环境下的DB,进程死亡后进行重启。由于业务处在高峰期间,上游的负载均衡策略发生了重分配。刚刚启动的DB瞬间接受了1/3的流量,然后load疯狂飙升,直至再无响应。

原因就是:新启动的DB,各种Cache并没有准备完毕,系统状态与正常运行时截然不同。可能平常1/10的量,就能够把它带入死亡。

同理,一个刚刚启动的JVM进程,由于字节码并未被JIT编译器优化,在刚启动的时候,所有接口的响应时间都比较慢。如果调用它的负载均衡组件,并没有考虑这种刚启动的情况,1/n的流量被正常路由到这个节点,就很容易出现问题。

所以,我们希望负载均衡组件,能够依据JVM进程的启动时间,动态的慢慢加量,进行服务预热,直到达到正常流量水平。

《没有预热,不叫高并发,叫并发高》

  1. 背压

考虑一下下面两种场景:

  1. 没有限流。请求量过高,有多少收多少,极容易造成后端服务崩溃或者内存溢出
  2. 传统限流。你强行规定了某个接口最大的承受能力,超出了直接拒绝,但此时后端服务是有能力处理这些请求的

如何动态的修改限流的值?这就需要一套机制。调用方需要知道被调用方的处理能力,也就是被调用方需要拥有反馈的能力。背压,英文Back Pressure,其实是一种智能化的限流,指的是一种策略。

背压思想,被请求方不会直接将请求端的流量直接丢掉,而是不断的反馈自己的处理能力。请求端根据这些反馈,实时的调整自己的发送频率。比较典型的场景,就是TCP/IP中使用滑动窗口来进行流量控制。

反应式编程(Reactive)是观察者模式的集大成者。它们大多使用事件驱动,多是非阻塞的弹性应用,基于数据流进行弹性传递。在这种场景下,背压实现就简单的多。

背压,让系统更稳定,利用率也更高,它本身拥有更高的弹性和智能。

总结

简单总结一下:

  • 限流 规定一个上限,流量超过系统承载能力时,会直接拒绝服务
  • 熔断 不因底层旁路应用的故障,造成系统雪崩。欲练此功,必先自宫
  • 降级 从请求入口,大范围的灭掉过载请求
  • 预热 给系统一些启动预热时间,加载缓存,避免资源死锁
  • 背压 被调用方反馈自己的能力给调用方。温柔的调用,需要坚实的沟通

简单来讲,只要流量不进系统,什么都好说,降级是最威猛最霸道的手段;一旦流量进入系统,就要接受系统内一系列规则的制约,其中限流是最直接的手段,将请求拦在外面。虽然用户的请求失败了,但我的系统还能活;没有熔断的系统就很凶残,很容易让三流功能影响主要功能,所以要在合适的时候打开它;至于预热,不过是在爱情火花前的一系列前戏,直到服务的巅峰状态;当然,相对与请求扔出去就不管的模式,如果被调用方能够反馈自己的状态,那么请求方就可以根据需要加大或者缩减马力,这就是背压的思想。

这些手段,都是在有限的资源下,有效的处理手段。但如果公司有钱,有弹性处理手段,这些都会变成辅助手段。毕竟,当所有的服务,能够将自己的状态,反馈到监控中心,监控中心能够实现弹性扩容。只要服务拆分的满足水平扩展,我们只需要增加实例就够了。

作者简介:小姐姐味道 (xjjdog),一个不允许程序员走弯路的公众号。聚焦基础架构和Linux。十年架构,日百亿流量,与你探讨高并发世界,给你不一样的味道。我的个人微信xjjdog0,欢迎添加好友,进一步交流。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

❤️ Typora + PicGo + Gitee/GitH

发表于 2021-10-08

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

前言

写了将近一年多博客,之前半年都是用 富文本 的方式来写博客。直到遇到了一些博友,听说他们都是用 Markdown 格式来写博客。结果,我就放弃了富文本模式,见仁见智,我是觉得真的很难用。

接触到 Markdown 模式的第一个问题就是图的问题,因为都是要贴网址的。当时没多想,因为都是用的博客平台,只需要将图片复制进去就行,也很方便。

后来,听说还有 图床 这个东西,但是由于懒,就一直没有去玩。最近,出于种种原因,还是尝试搭建了一下,这里分享一下搭建过程。

搭建准备

本次搭建过程需要以下介质:Typora + PicGo + Gitee/GitHub ,免费!

Typora

Typora 是一款 markdown 编辑器,支持几乎所有的 markdown 格式,神器!

支持 macOS、Windows、Linux 三种操作系统,下载地址:www.typora.io/。

macOS 也可以直接通过 Homebrew 安装:brew install typora。

PicGo

PicGo 是用于快速上传图片并获取图片 URL 链接的工具,也是神器!

image-20210810174810560

支持 macOS、Windows、Linux 三种操作系统,下载地址:github.com/Molunerfinn…

macOS 也可以直接通过 Homebrew 安装:brew install picgo。

Gitee/GitHub

Gitee/GitHub 都是代码托管平台,免费的私有仓库,没有容量限制,写代码的都知道吧?

image-20210810160746165

你要问我孰优孰劣?我只能说,国内的就老老实实用 Gitee 吧!

搭建步骤

接下来我们开始搭建,确保已经安装上述软件和注册 Gitee/GitHub 账号。

Gitee/GitHub 创建图床仓库

在 Gitee/GitHub 创建一个图床仓库,用来存放你的图片。

Gitee步骤

1、创建图床仓库

首先打开你的 Gitee 主页,点击 新建仓库 ,创建一个仓库。

image-20210810174335872

如下图,输入你的仓库信息,必须为 开源,否则无法上传图片。

image-20210810161941077

2、生成私人令牌 Token

点击个人设置–>私人令牌:

image-20210810162720938

点击 +生成新令牌 生成一个私人令牌:

image-20210810162911778

填写描述,然后提交,输入你的 Gitee 账号密码即可,Token 关闭页面后将不再显示,因此需要记录 Token。

image-20210810163126822

至此,Gitee 的配置已经完成。

GitHub步骤

前提:GitHub 与 Gitee 大同小异,最好有 🪜 ,不然不建议使用 Github,以下只是演示下如何使用。

1、创建图床仓库

image-20210810163858499

这里和 Gitee 大同小异,填写相关信息即可。

image-20210810164240474

2、生成私人令牌 Token

打开 github.com/settings/to… Generate new token 创建一个私人令牌:

image-20210810164905375

选择 无限期,勾选 repo 即可:

image-20210810165146963

同样的,保存好你的 Token:

image-20210810165432798

至此,Github已配置完成。

配置 PicGo

GitHub 图床配置

打开 PicGo 详细窗口,根据提示填入 GitHub 图床仓库的相关信息:

image-20210810165805839

这个很简单,不做解释,相信以你的聪明才智,分分钟搞定。

Gitee 图床配置

由于 PicGo 原生不支持 Gitee,因此需要通过插件安装。

点击插件设置,搜索 gitee-upload ,点击安装。

image-20210810170110623

注意:这里需要提前安装 node.js,否则无法安装!

插件安装成功后,重启 PicGo ,然后配置 gitee:

image-20210810170358801

配置方式与 GitHub 大同小异。

经过如上配置,图床已经搭建成功,可以通过上传图片测试:

image-20210810170522129

点击上传图片,然后复制到 markdown 编辑器中查看。

配置 Typora

为什么要配置 Typora ?当然是为了方便,不能每次都手动去上传图片,然后复制链接吧,只需要简单配置 Typora 就可以实现复制图片自动上传。

image-20210810172414616

配置如上,选择插入图片时选择 PicGo.app 即可!就这么简单~

测试一下最终效果,直接截图当前界面,上传粘贴:

test

image-20210810172807755

可以发现,已经自动实现上传了!

写在最后

总的来说,搭建过程不算难,只需要了解这些软件的简单使用即可。

终于拥有自己的图床了,再也不怕网站挂了图片不显示,最主要的是还去水印!


本次分享到此结束啦~

如果觉得文章对你有帮助,点赞、收藏、关注、评论,一键四连支持,你的支持就是我创作最大的动力。

❤️ 技术交流可以 关注公众号:Lucifer三思而后行 ❤️

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…505506507…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%