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

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


  • 首页

  • 归档

  • 搜索

第一个基于Dubbo+Zookeeper搭建的项目

发表于 2020-10-15

文 | 平哥 日期 | 20201013

具体代码详见GitHub仓库:点我跳转

项目简介

此项目为自己学习Dubbo+Zookeeper,搭建的第一个项目,主要架构就是一个父项目、三个子Module:dubbo_provider、 dubbo_consumer 和 dubbo_api,三个子Module分别继承父项目。

Dubbo的远程访问是基于接口的。Consumer和Provider使用同一个接口,可以实现远程访问。

  • Provider给接口写实现,提供服务。
  • Consumer使用接口,并通过Dubbo创建的动态代理对象,远程访问Provider。
  • 把接口独立定义在一个工程(dubbo_api)中,做依赖管理。

p.s. 此项目重点是练习Dubbo+Zookeeper,故没有写连接数据库的内容,只是模拟了访问数据库。

Step 1 创建项目并编写父项目Maven依赖

1.1 创建项目

用IDEA创建项目,创建完项目目录如下:
image

1.2 编写父项目Maven依赖

直接上代码:

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
xml复制代码<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.gcp</groupId>
<artifactId>dubbo_pro01</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<modules>
<module>dubbo_provider</module>
<module>dubbo_consumer</module>
<module>dubbo_api</module>
</modules>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.4.RELEASE</version>
</parent>
<dependencies>
<!--springboot 启动器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!--lombok依赖-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!--dubbo springboot启动器-->
<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo-spring-boot-starter</artifactId>
<version>2.7.8</version>
</dependency>
<!--curator依赖-->
<!--Curator提供了一套Java类库, 可以更容易的使用ZooKeeper。 -->
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>5.1.0</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>5.1.0</version>
</dependency>
</dependencies>
</project>

Step 2 编写dubbo_api

2.1 定义POJO类

1
2
3
4
5
6
7
8
java复制代码package com.gcp.pojo;
import lombok.Data;
@Data
public class User {
private Long id;
private String name;
private String password;
}

2.2 定义Service接口

1
2
3
4
5
6
7
8
9
java复制代码package com.gcp.service;
import com.gcp.pojo.User;
/**
* 用户服务接口
*/
public interface UserService {
void register(User user);
User getUserById(Long id);
}

Step 3 编写dubbo_provider

说明

  • Dubbo服务提供者Provider,是不需要定义Controller的,是直接通过Service提供服务的。
  • 使用Dubbo框架提供的注解@DubboService,声明当前的类型的对象,是一个Dubbo服务提供者。
  • 让Spring容器初始化、管理,并通过curator框架,把服务信息注册到Zookeeper中。
  • 在老版本的Dubbo(2.6(含)以前)中,注解命名是@Service。所以使用老版本开发的时候,注意导入的注解的包。
  • 需要提供一个application.yml配置文件,说明Dubbo使用的注册中心是什么,地址是什么。

3.1 配置pom.xml

在pom.xml文件中添加依赖:

1
2
3
4
5
6
7
xml复制代码<dependencies>
<dependency>
<groupId>com.gcp</groupId>
<artifactId>dubbo_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

3.2 定义Mapper接口和类

UserMapper接口:

1
2
3
4
5
6
7
8
9
java复制代码import com.gcp.pojo.User;

/**
* 模拟数据库访问
*/
public interface UserMapper {
void insert(User user);
User selectById(Long id);
}

UserMapperImpl实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码@Repository
public class UserMapperImpl implements UserMapper {
@Override
public void insert(User user) {
System.out.println("数据库访问:新增用户 - " + user);
}

@Override
public User selectById(Long id) {
User user = new User();
user.setId(id);
user.setName("name" + id);
user.setPassword("password" + id);
System.out.println("数据库访问:主键查询用户 - " + user);
return user;
}
}

3.3 定义Service实现类

@DubboService(loadbalance = "roundrobin"), 其中 loadbalance = "roundrobin" 意思是如果此服务有集群将采用轮询的方式进行负载均衡访问,
默认是 random:随机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码
@DubboService(loadbalance = "roundrobin")
public class UserServiceImpl implements UserService {

@Autowired
private UserMapper userMapper;

@Override
public void register(User user) {
System.out.println("UserService 实现类中:注册用户");
userMapper.insert(user);
}

@Override
public User getUserById(Long id) {
System.out.println("UserService 实现类中:根据id查询用户");
return userMapper.selectById(id);
}
}

3.4 配置application.yml

说明:

  • dubbo中,对每个服务提供者和消费者的管理,都是基于应用级别的。
  • 都是使用命名作为唯一标记的。
  • 同名的服务提供者或消费者,自动组成集群。
  • dubbo应用默认使用的协议是dubbo协议,使用的端口默认为20880。协议是可以配置的。

在 resources 文件夹下创建application.yml:

1
2
3
4
5
6
7
8
yml复制代码dubbo: # dubbo配置根节点
registry: # 配置dubbo的注册中心 registry
address: zookeeper://192.168.40.170:2181 # 提供注册中心的访问地址。
application: # 配置本dubbo应用信息
name: gcp-dubbo-first-provider # 配置本dubbo的应用名称,名称组成是:字母,数字,'-',字母开头
protocol: # 协议,协议自定义配置的时候,所有的默认值失效。
name: dubbo # 协议名
port: 20880 # 端口,默认20880

如何安装Zookeeper可以详见我另一篇文章

3.5 创建SpringBoot启动程序

  • 启动的每个Dubbo应用,一定会在注册中心中注册信息。
  • 服务提供者注册的是/dubbo/xxx/providers
  • 服务消费者注册的是/dubbo/xxx/consumers
  • Dubbo启动器(dubbo-spring-boot-starter)默认不生效。
  • 必须通过@EnableDubbo让启动器生效。
  • 在2.7.2-以前版本中,部分配置默认不加载,需要使用@EnableDubboConfig让全部配置生效。
  • 负载均衡策略:
  • 设置负载均衡策略,可以在@DubboService或者@DubboReference注解上
  • 加属性loadbalance进行配置。
  • 消费者默认是不考虑负载均衡策略的,是使用提供者定义的负载均衡策略。
  • 如果消费者配置了负载均衡策略,则忽略提供者配置的负载均衡策略。
    在 com.gcp 包下创建SpringBoot启动类:
1
2
3
4
5
6
7
8
java复制代码@SpringBootApplication
@EnableDubbo
// @EnableDubboConfig
public class DbProviderApplication {
public static void main(String[] args) {
SpringApplication.run(DbProviderApplication.class,args);
}
}

此时可启动,测试看Zookeeper中是否成功注册上服务,访问Linux服务器,利用Zookeeper客户端工具查看:
image

Step 4 编写dubbo_consumer

4.1 配置pom.xml

在pom.xml文件中添加依赖:

1
2
3
4
5
6
7
8
9
10
11
xml复制代码<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.gcp</groupId>
<artifactId>dubbo_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

4.2 创建consumer本地Service接口和实现类

有人可能会疑惑,Zookeeper注册中心中已经有提供的Service服务了,怎么本地还需要Service?
说明: 其实,dubbo远程服务调用,是为了 封装通用规则,但各个子项目有自己的个性逻辑
如:用户的注册逻辑,在一个企业中是统一的。提供一个Provider实现注册逻辑;
对于consumer来说,是不同的,可以提供若干入口。如腾讯的用户注册,可以通过QQ、QQ音乐、QQ空间等实现注册或登录。底层的用户是相同的。

LocalUserService接口:

1
2
3
4
5
6
7
8
9
java复制代码package com.gcp.service;
import com.gcp.pojo.User;
/**
* consumer子Module的本地Service
*/
public interface LocalUserService {
void register(User user);
User getById(Long id);
}

LocalUserServiceImpl实现类:

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复制代码@Service
public class LocalUserServiceImpl implements LocalUserService {

/**
* 远程服务的接口。通过注解@DubboReference实现动态代理创建
* 规则:
* 1、 通知Dubbo框架,根据配置找注册中心,发现服务的地址。
* 拿接口名称作为zookeeper中节点的命名规则,获取地址。
* 2、 通知Spring,根据Dubbo框架的特性,创建接口的动态代理对象,并维护
* 在Spring容器中。
* 3、 类似@Autowired,把代理对象注入到当前的变量中。
*/
@DubboReference
private UserService userService;
@Override
public void register(User user) {
System.out.println("准备调用远程服务,服务对象类型是:" + userService.getClass().getName());
System.out.println("注册的用户是: " + user);
userService.register(user);
}
@Override
public User getById(Long id) {
System.out.println("根据主键查询用户,主键是: " + id);
return userService.getUserById(id);
}
}

4.3 创建Controller类

UserController类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@RestController
public class UserController {

@Autowired
private LocalUserService localUserService;
@RequestMapping("findUser")
public User findUser(Long id){
return localUserService.getById(id);
}
@RequestMapping("registerUser")
public String register(User user){
localUserService.register(user);
return "注册用户成功";
}
}

4.4 配置注册中心地址

在 resources 文件夹下创建 application.yml:

1
2
3
yml复制代码dubbo:
registry:
address: zookeeper://192.168.40.170:2181

4.5 创建Springboot启动类

在 com.gcp 包下创建 DbConsumerApplication:

1
2
3
4
5
6
7
java复制代码@SpringBootApplication
@EnableDubbo
public class DbConsumerApplication {
public static void main(String[] args) {
SpringApplication.run(DbConsumerApplication.class,args);
}
}

4.6 启动测试

启动consumer的启动类,查看Zookeeper注册中心是否注册成功:
image

Step 5 项目整体测试

打开浏览器,输入consumer访问地址,测试是否可以正常访问:
image

后台输出:
consumer 子 Module:
image

provider 子 Module:
image

至此,项目搭建创建成功,大家可以自己多复制几个provider和consumer子Module的启动类,把端口都改成不一样试试集群轮询、随机访问的效果。

具体代码详见GitHub仓库:点我跳转

本文转载自: 掘金

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

【高并发】面试官:讲讲高并发场景下如何优化加锁方式?

发表于 2020-10-15

写在前面

很多时候,我们在并发编程中,涉及到加锁操作时,对代码块的加锁操作真的合理吗?还有没有需要优化的地方呢?

前言

在《【高并发】优化加锁方式时竟然死锁了!!》一文中,我们介绍了产生死锁时的四个必要条件,只有四个条件同时具备时才能发生死锁。其中,我们在阻止请求与保持条件时,采用了一次性申请所有的资源的方式。例如在我们完成转账操作的过程中,我们一次性申请账户A和账户B,两个账户都申请成功后,再执行转账的操作。其中,在我们实现的转账方法中,使用了死循环来循环获取资源,直到同时获取到账户A和账户B为止,核心代码如下所示。

1
2
3
4
5
java复制代码//一次申请转出账户和转入账户,直到成功
while(!requester.applyResources(this, target)){
//循环体为空
;
}

如果ResourcesRequester类的applyResources()方法执行的时间非常短,并且程序并发带来的冲突不大,程序循环几次到几十次就可以同时获取到转出账户和转入账户,这种方案就是可行的。

但是,如果ResourcesRequester类的applyResources()方法执行的时间比较长,或者说,程序并发带来的冲突比较大,此时,可能需要循环成千上万次才能同时获取到转出账户和转入账户。这样就太消耗CPU资源了,此时,这种方案就是不可行的了。

那么,有没有什么方式对这种方案进行优化呢?

问题分析

既然使用死循环一直获取资源这种方案存在问题,那我们换位思考一下。当线程执行时,发现条件不满足,是不是可以让线程进入等待状态?当条件满足的时候,通知等待的线程重新执行?

也就是说,如果线程需要的条件不满足,我们就让线程进入等待状态;如果线程需要的条件满足时,我们再通知等待的线程重新执行。这样,就能够避免程序进行循环等待进而消耗CPU的问题。

那么,问题又来了!当条件不满足时,如何实现让线程等待?当条件满足时,又如何唤醒线程呢?

不错,这是个问题!不过这个问题解决起来也非常简单。简单的说,就是使用线程的等待与通知机制。

线程的等待与通知机制

我们可以使用线程的等待与通知机制来优化阻止请求与保持条件时,循环获取账户资源的问题。具体的等待与通知机制如下所示。

执行的线程首先获取互斥锁,如果线程继续执行时,需要的条件不满足,则释放互斥锁,并进入等待状态;当线程继续执行需要的条件满足时,就通知等待的线程,重新获取互斥锁。

那么,说了这么多,Java支持这种线程的等待与通知机制吗?其实,这个问题问的就有点废话了,Java这么优秀(牛逼)的语言肯定支持啊,而且实现起来也比较简单。

用Java实现线程的等待与通知机制

实现方式

其实,使用Java语言实现线程的等待与通知机制有多种方式,这里我就简单的列举一种方式,其他的方式大家可以自行思考和实现,有不懂的地方也可以问我!

在Java语言中,实现线程的等待与通知机制,一种简单的方式就是使用synchronized并结合wait()、notify()和notifyAll()方法来实现。

实现原理

我们使用synchronized加锁时,只允许一个线程进入synchronized保护的代码块,也就是临界区。如果一个线程进入了临界区,则其他的线程会进入阻塞队列里等待,这个阻塞队列和synchronized互斥锁是一对一的关系,也就是说,一把互斥锁对应着一个独立的阻塞队列。

在并发编程中,如果一个线程获得了synchronized互斥锁,但是不满足继续向下执行的条件,则需要进入等待状态。此时,可以使用Java中的wait()方法来实现。当调用wait()方法后,当前线程就会被阻塞,并且会进入一个等待队列中进行等待,这个由于调用wait()方法而进入的等待队列也是互斥锁的等待队列。而且,线程在进入等待队列的同时,会释放自身获得的互斥锁,这样,其他线程就有机会获得互斥锁,进而进入临界区了。整个过程可以表示成下图所示。

当线程执行的条件满足时,可以使用Java提供的notify()和notifyAll()方法来通知互斥锁等待队列中的线程,我们可以使用下图来简单的表示这个过程。

这里,需要注意如下事项:

(1)使用notify()和notifyAll()方法通知线程时,调用notify()和notifyAll()方法时,满足线程的执行条件,但是当线程真正执行的时候,条件可能已经不再满足了,可能有其他线程已经进入临界区执行。

(2)被通知的线程继续执行时,需要先获取互斥锁,因为在调用wait()方法等待时已经释放了互斥锁。

(3)wait()、notify()和notifyAll()方法操作的队列是互斥锁的等待队列,如果synchronized锁定的是this对象,则一定要使用this.wait()、this.notify()和this.notifyAll()方法;如果synchronized锁定的是target对象,则一定要使用target.wait()、target.notify()和target.notifyAll()方法。

(4)wait()、notify()和notifyAll()方法调用的前提是已经获取了相应的互斥锁,也就是说,wait()、notify()和notifyAll()方法都是在synchronized方法中或代码块中调用的。如果在synchronized方法外或代码块外调用了三个方法,或者锁定的对象是this,使用target对象调用三个方法的话,JVM会抛出java.lang.IllegalMonitorStateException异常。

具体实现

实现逻辑

在实现之前,我们还需要考虑以下几个问题:

  • 选择哪个互斥锁

在之前的程序中,我们在TansferAccount类中,存在一个ResourcesRequester 类的单例对象,所以,我们是可以使用this作为互斥锁的。这一点大家需要重点理解。

  • 线程执行转账操作的条件

转出账户和转入账户都没有被分配过。

  • 线程什么时候进入等待状态

线程继续执行需要的条件不满足的时候,进入等待状态。

  • 什么时候通知等待的线程执行

当存在线程释放账户的资源时,通知等待的线程继续执行。

综上,我们可以得出以下核心代码。

1
2
3
java复制代码while(不满足条件){
wait();
}

那么,问题来了!为何是在while循环中调用wait()方法呢?因为当wait()方法返回时,有可能线程执行的条件已经改变,也就是说,之前条件是满足的,但是现在已经不满足了,所以要重新检验条件是否满足。

实现代码

我们优化后的ResourcesRequester类的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码public class ResourcesRequester{
//存放申请资源的集合
private List<Object> resources = new ArrayList<Object>();
//一次申请所有的资源
public synchronized void applyResources(Object source, Object target){
while(resources.contains(source) || resources.contains(target)){
try{
wait();
}catch(Exception e){
e.printStackTrace();
}
}
resources.add(source);
resources.add(targer);
}

//释放资源
public synchronized void releaseResources(Object source, Object target){
resources.remove(source);
resources.remove(target);
notifyAll();
}
}

生成ResourcesRequester单例对象的Holder类ResourcesRequesterHolder的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码public class ResourcesRequesterHolder{
private ResourcesRequesterHolder(){}

public static ResourcesRequester getInstance(){
return Singleton.INSTANCE.getInstance();
}
private enum Singleton{
INSTANCE;
private ResourcesRequester singleton;
Singleton(){
singleton = new ResourcesRequester();
}
public ResourcesRequester getInstance(){
return singleton;
}
}
}

执行转账操作的类的代码如下所示。

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
java复制代码public class TansferAccount{
//账户的余额
private Integer balance;
//ResourcesRequester类的单例对象
private ResourcesRequester requester;

public TansferAccount(Integer balance){
this.balance = balance;
this.requester = ResourcesRequesterHolder.getInstance();
}
//转账操作
public void transfer(TansferAccount target, Integer transferMoney){
//一次申请转出账户和转入账户,直到成功
requester.applyResources(this, target))
try{
//对转出账户加锁
synchronized(this){
//对转入账户加锁
synchronized(target){
if(this.balance >= transferMoney){
this.balance -= transferMoney;
target.balance += transferMoney;
}
}
}
}finally{
//最后释放账户资源
requester.releaseResources(this, target);
}
}
}

可以看到,我们在程序中通知处于等待状态的线程时,使用的是notifyAll()方法而不是notify()方法。那notify()方法和notifyAll()方法两者有什么区别呢?

notify()和notifyAll()的区别

  • notify()方法

随机通知等待队列中的一个线程。

  • notifyAll()方法

通知等待队列中的所有线程。

在实际工作过程中,如果没有特殊的要求,尽量使用notifyAll()方法。因为使用notify()方法是有风险的,可能会导致某些线程永久不会被通知到!

重磅福利

微信搜一搜【冰河技术】微信公众号,关注这个有深度的程序员,每天阅读超硬核技术干货,公众号内回复【PDF】有我准备的一线大厂面试资料和我原创的超硬核PDF技术文档,以及我为大家精心准备的多套简历模板(不断更新中),希望大家都能找到心仪的工作,学习是一条时而郁郁寡欢,时而开怀大笑的路,加油。如果你通过努力成功进入到了心仪的公司,一定不要懈怠放松,职场成长和新技术学习一样,不进则退。如果有幸我们江湖再见!

另外,我开源的各个PDF,后续我都会持续更新和维护,感谢大家长期以来对冰河的支持!!

写在最后

如果你觉得冰河写的还不错,请微信搜索并关注「 冰河技术 」微信公众号,跟冰河学习高并发、分布式、微服务、大数据、互联网和云原生技术,「 冰河技术 」微信公众号更新了大量技术专题,每一篇技术文章干货满满!不少读者已经通过阅读「 冰河技术 」微信公众号文章,吊打面试官,成功跳槽到大厂;也有不少读者实现了技术上的飞跃,成为公司的技术骨干!如果你也想像他们一样提升自己的能力,实现技术能力的飞跃,进大厂,升职加薪,那就关注「 冰河技术 」微信公众号吧,每天更新超硬核技术干货,让你对如何提升技术能力不再迷茫!

本文转载自: 掘金

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

Kotlin Jetpack 实战 09 图解协程原理

发表于 2020-10-15

往期文章

《Kotlin Jetpack 实战:开篇》

《00. 写给 Java 开发者的 Kotlin 入坑指南》

《03. Kotlin 编程的三重境界》

《04. Kotlin 高阶函数》

《05. Kotlin 泛型》

《06. Kotlin 扩展》

《07. Kotlin 委托》

前言

协程(Coroutines),是 Kotlin 最神奇的特性,没有之一。

本文将简单介绍 Kotlin 的协程,然后会以图解 + 动画的形式解释 Kotlin 协程的原理。看完本文后,你会发现,原来协程也没有那么难。

  1. 一边看文章,一边跑 Demo

本文Demo:
github.com/chaxiu/Kotl…

  1. 线程 & 协程

有的人会将协程比喻成:线程的封装框架。从宏观角度看,这有一定道理,当然,Kotlin 官方并没有这么宣传过。

从微观角度上看,协程有点像轻量级的线程。协程能轻量到什么程度?就算你在一个线程中创建1000个协程,也不会有什么影响。

从包含关系上看,协程跟线程的关系,有点像“线程与进程的关系”,毕竟,协程不可能脱离线程运行。

协程虽然不能脱离线程而运行,但可以在不同的线程之间切换。看到这,大家应该能理解本文最开始放的那张动图的含义了吧?

说了这么多协程的好,但就凭它的”高效“,”轻量“我们就要用吗?汇编语言也很高效啊。C 语言也能写出轻量的程序啊。

高效和轻量,都不是 Kotlin 协程的核心竞争力。

Kotlin 协程的核心竞争力在于:它能简化异步并发任务。

作为 Java 开发者,我们很清楚线程并发是多么的危险,写出来的异步代码是多么的难以维护。

  1. 异步代码 & 回调地狱

以一段简单的 Java 代码为例,我们发起了一个异步请求,从服务端查询用户的信息,通过 CallBack 返回 response:

1
2
3
4
5
6
7
8
java复制代码getUserInfo(new CallBack() {
@Override
public void onSuccess(String response) {
if (response != null) {
System.out.println(response);
}
}
});

到目前为止,我们的代码看起来并没有什么问题,但如果我们的需求变成了这样呢?

查询用户信息 –> 查找该用户的好友列表 –>拿到好友列表后,查找该好友的动态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码getUserInfo(new CallBack() {
@Override
public void onSuccess(String user) {
if (user != null) {
System.out.println(user);
getFriendList(user, new CallBack() {
@Override
public void onSuccess(String friendList) {
if (friendList != null) {
System.out.println(friendList);
getFeedList(friendList, new CallBack() {
@Override
public void onSuccess(String feed) {
if (feed != null) {
System.out.println(feed);
}
}
});
}
}
});
}
}
});

有点恶心了,是不是?这还是仅包含 onSuccess 的情况,实际情况会更复杂,因为我们还要处理异常,处理重试,处理线程调度,甚至还可能涉及多线程同步。

  1. 地狱到天堂:协程

今天的主角是协程,上面的代码用协程应该写?很简单,核心就是三行代码:

1
2
3
kotlin复制代码val user = getUserInfo()
val friendList = getFriendList(user)
val feedList = getFeedList(friendList)

是不是简洁到了极致?这就是 Kotlin 协程的魅力:以同步的方式完成异步任务。

4-1 使用协程的要点

上面的代码之所以能写成类似同步的方式,关键还是在于那三个请求函数的定义。与普通函数不同的地方在于,它们都被 suspend 修饰,这代表它们都是:挂起函数。

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
kotlin复制代码// delay(1000L)用于模拟网络请求

//挂起函数
// ↓
suspend fun getUserInfo(): String {
withContext(Dispatchers.IO) {
delay(1000L)
}
return "BoyCoder"
}
//挂起函数
// ↓
suspend fun getFriendList(user: String): String {
withContext(Dispatchers.IO) {
delay(1000L)
}
return "Tom, Jack"
}
//挂起函数
// ↓
suspend fun getFeedList(list: String): String {
withContext(Dispatchers.IO) {
delay(1000L)
}
return "{FeedList..}"
}

那么,挂起函数到底是什么?

4-2 挂起函数

挂起函数(Suspending Function),从字面上理解,就是可以被挂起的函数。suspend 有:挂起,暂停的意思。在这个语境下,也有点暂停的意思。暂停更容易被理解,但挂起更准确。

挂起函数,能被挂起,当然也能恢复,他们一般是成对出现的。

我们来看看挂起函数的执行流程,注意动画当中出现的闪烁,这代表正在请求网络。

一定要多看几遍,确保没有遗漏其中的细节。

从上面的动画,我们能知道:

  • 表面上看起来是同步的代码,实际上也涉及到了线程切换。
  • 一行代码,切换了两个线程。
  • =左边:主线程
  • =右边:IO线程
  • 每一次从主线程到IO线程,都是一次协程挂起(suspend)
  • 每一次从IO线程到主线程,都是一次协程恢复(resume)。
  • 挂起和恢复,这是挂起函数特有的能力,普通函数是不具备的。
  • 挂起,只是将程序执行流程转移到了其他线程,主线程并未被阻塞。
  • 如果以上代码运行在 Android 系统,我们的 App 是仍然可以响应用户的操作的,主线程并不繁忙,这也很容易理解。

挂起函数的执行流程我们已经很清楚了,那么,Kotlin 协程到底是如何做到一行代码切换两个线程的?

这一切的魔法都藏在了挂起函数的suspend关键字里。

  1. suspend 的本质

suspend 的本质,就是 CallBack。

1
2
3
4
5
6
kotlin复制代码suspend fun getUserInfo(): String {
withContext(Dispatchers.IO) {
delay(1000L)
}
return "BoyCoder"
}

有的小伙伴要问了,哪来的 CallBack?明明没有啊。确实,我们写出来的代码没有 CallBack,但 Kotlin 的编译器检测到 suspend 关键字修饰的函数以后,会自动将挂起函数转换成带有 CallBack 的函数。

如果我们将上面的挂起函数反编译成 Java,结果会是这样:

1
2
3
4
5
6
java复制代码//                              Continuation 等价于 CallBack
// ↓
public static final Object getUserInfo(Continuation $completion) {
...
return "BoyCoder";
}

从反编译的结果来看,挂起函数确实变成了一个带有 CallBack 的函数,只是这个 CallBack 的真实名字叫 Continuation。毕竟,如果直接叫 CallBack 那就太 low,对吧?

我们看看 Continuation 在 Kotlin 中的定义:

1
2
3
4
5
6
kotlin复制代码public interface Continuation<in T> {
public val context: CoroutineContext
// 相当于 onSuccess 结果
// ↓ ↓
public fun resumeWith(result: Result<T>)
}

对比着看看 CallBack 的定义:

1
2
3
java复制代码interface CallBack {
void onSuccess(String response);
}

从上面的定义我们能看到:Continuation 其实就是一个带有泛型参数的 CallBack,除此之外,还多了一个 CoroutineContext,它就是协程的上下文。对于熟悉 Android 开发的小伙伴来说,不就是 context 嘛!也没什么难以理解的,对吧?

以上这个从挂起函数转换成CallBack 函数的过程,被称为:CPS 转换(Continuation-Passing-Style Transformation)。

看,Kotlin 官方用 Continuation 而不用 CallBack 的原因出来了:Continuation 道出了它的实现原理。当然,为了理解挂起函数,我们用 CallBack 会更加的简明易懂。

下面用动画演示挂起函数在 CPS 转换过程中,函数签名的变化:

这个转换看着简单,其中也藏着一些细节。

函数类型的变化

上面 CPS 转换过程中,函数的类型发生了变化:suspend ()->String 变成了 (Continuation)-> Any?。

这意味着,如果你在 Java 访问一个 Kotlin 挂起函数getUserInfo(),在 Java 看到 getUserInfo() 的类型会是:(Continuation)-> Object。(接收 Continuation 为参数,返回值是 Object)

在这个 CPS 转换中,suspend () 变成 (Continuation) 我们前面已经解释了,不难。那么函数的返回值为什么会从:String变成Any?

挂起函数的返回值

挂起函数经过 CPS 转换后,它的返回值有一个重要作用:标志该挂起函数有没有被挂起。

这听起来有点绕:挂起函数,就是可以被挂起的函数,它还能不被挂起吗?是的,挂起函数也能不被挂起。

让我们来理清几个概念:

只要有 suspend 修饰的函数,它就是挂起函数,比如我们前面的例子:

1
2
3
4
5
6
kotlin复制代码suspend fun getUserInfo(): String {
withContext(Dispatchers.IO) {
delay(1000L)
}
return "BoyCoder"
}

当 getUserInfo() 执行到 withContext的时候,就会返回 CoroutineSingletons.COROUTINE_SUSPENDED 表示函数被挂起了。

现在问题来了,请问下面这个函数是挂起函数吗:

1
2
3
4
5
6
kotlin复制代码// suspend 修饰
// ↓
suspend fun noSuspendFriendList(user: String): String{
// 函数体跟普通函数一样
return "Tom, Jack"
}

答案:它是挂起函数。但它跟一般的挂起函数有个区别:它在执行的时候,并不会被挂起,因为它就是普通函数。当你写出这样的代码后,IDE 也会提示你,suspend 是多余的:

当 noSuspendFriendList() 被调用的时候,不会挂起,它会直接返回 String 类型:"no suspend"。这样的挂起函数,你可以把它看作伪挂起函数

返回类型是 Any?的原因

由于 suspend 修饰的函数,既可能返回 CoroutineSingletons.COROUTINE_SUSPENDED,也可能返回实际结果"no suspend",甚至可能返回 null,为了适配所有的可能性,CPS 转换后的函数返回值类型就只能是 Any?了。

小结

  • suspend 修饰的函数就是挂起函数
  • 挂起函数,在执行的时候并不一定都会挂起
  • 挂起函数只能在其他挂起函数中被调用
  • 挂起函数里包含其他挂起函数的时候,它才会真正被挂起

以上就是 CPS 转换过程中,函数签名的细节。

然而,这并不是 CPS 转换的全部,因为我们还不知道 Continuation 到底是什么。

  1. CPS 转换

Continuation 这个单词,如果你查词典和维基百科,可能会一头雾水,太抽象了。

通过我们文章的例子来理解 Continuation,会更容易一些。

首先,我们只需要把握住 Continuation 的词源 Continue 即可。Continue 是继续的意思,Continuation 则是继续下去要做的事情,接下来要做的事情。

放到程序中,Continuation 则代表了,程序继续运行下去需要执行的代码,接下来要执行的代码 或者 剩下的代码。

以上面的代码为例,当程序运行 getUserInfo() 的时候,它的 Continuation则是下图红框的代码:

Continuation 就是接下来要运行的代码,剩余未执行的代码。

理解了 Continuation,以后,CPS就容易理解了,它其实就是:将程序接下来要执行的代码进行传递的一种模式。

而CPS 转换,就是将原本的同步挂起函数转换成CallBack 异步代码的过程。这个转换是编译器在背后做的,我们程序员对此无感知。

也许会有小伙伴嗤之以鼻:这么简单粗暴?三个挂起函数最终变成三个 Callback 吗?

当然不是。思想仍然是 CPS 的思想,但要比 Callback 高明很多。

接下来,我们一起看看挂起函数反编译后的代码是什么样吧。前面铺垫了这么多,全都是为了下一个部分准备的。

  1. 字节码反编译

字节码反编译成 Java 这种事,我们干过很多次了。跟往常不同的是,这次我不会直接贴反编译后的代码,因为如果我直接贴出反编译后的 Java 代码,估计会吓退一大波人。协程反编译后的代码,逻辑实在是太绕了,可读性实在太差了。这样的代码,CPU 可能喜欢,但真不是人看的。

所以,为了方便大家理解,接下来我贴出的代码是我用 Kotlin 翻译后大致等价的代码,改善了可读性,抹掉了不必要的细节。如果你能把这篇文章里所有内容都弄懂,你对协程的理解也已经超过大部分人了。

进入正题,这是我们即将研究的对象,testCoroutine 反编译前的代码:

1
2
3
4
5
6
7
8
9
kotlin复制代码suspend fun testCoroutine() {
log("start")
val user = getUserInfo()
log(user)
val friendList = getFriendList(user)
log(friendList)
val feedList = getFeedList(friendList)
log(feedList)
}

反编译后,testCoroutine函数的签名变成了这样:

1
2
kotlin复制代码// 没了 suspend,多了 completion
fun testCoroutine(completion: Continuation<Any?>): Any? {}

由于其他几个函数也是挂起函数,所以它们的函数签名也会改变:

1
2
3
kotlin复制代码fun getUserInfo(completion: Continuation<Any?>): Any?{}
fun getFriendList(user: String, completion: Continuation<Any?>): Any?{}
fun getFeedList(friendList: String, completion: Continuation<Any?>): Any?{}

接下来我们分析 testCoroutine() 的函数体,因为它相当复杂,涉及到三个挂起函数的调用。

首先,在 testCoroutine 函数里,会多出一个 ContinuationImpl 的子类,它的是整个协程挂起函数的核心。代码里的注释非常详细,请仔细看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
kotlin复制代码fun testCoroutine(completion: Continuation<Any?>): Any? {

class TestContinuation(completion: Continuation<Any?>?) : ContinuationImpl(completion) {
// 表示协程状态机当前的状态
var label: Int = 0
// 协程返回结果
var result: Any? = null

// 用于保存之前协程的计算结果
var mUser: Any? = null
var mFriendList: Any? = null

// invokeSuspend 是协程的关键
// 它最终会调用 testCoroutine(this) 开启协程状态机
// 状态机相关代码就是后面的 when 语句
// 协程的本质,可以说就是 CPS + 状态机
override fun invokeSuspend(_result: Result<Any?>): Any? {
result = _result
label = label or Int.Companion.MIN_VALUE
return testCoroutine(this)
}
}
}

接下来则是要判断 testCoroutine 是不是初次运行,如果是初次运行,就要创建一个 TestContinuation 的实例对象。

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码//                    ↓
fun testCoroutine(completion: Continuation<Any?>): Any? {
...
val continuation = if (completion is TestContinuation) {
completion
} else {
// 作为参数
// ↓
TestContinuation(completion)
}
}
  • invokeSuspend 最终会调用 testCoroutine,然后走到这个判断语句
  • 如果是初次运行,会创建一个 TestContinuation 对象,completion 作为了参数
  • 这相当于用一个新的 Continuation 包装了旧的 Continuation
  • 如果不是初次运行,直接将 completion 赋值给 continuation
  • 这说明 continuation 在整个运行期间,只会产生一个实例,这能极大的节省内存开销(对比 CallBack)

接下来是几个变量的定义,代码里会有详细的注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码// 三个变量,对应原函数的三个变量
lateinit var user: String
lateinit var friendList: String
lateinit var feedList: String

// result 接收协程的运行结果
var result = continuation.result

// suspendReturn 接收挂起函数的返回值
var suspendReturn: Any? = null

// CoroutineSingletons 是个枚举类
// COROUTINE_SUSPENDED 代表当前函数被挂起了
val sFlag = CoroutineSingletons.COROUTINE_SUSPENDED

然后就到了我们的状态机的核心逻辑了,具体看注释吧:

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
kotlin复制代码when (continuation.label) {
0 -> {
// 检测异常
throwOnFailure(result)

log("start")
// 将 label 置为 1,准备进入下一次状态
continuation.label = 1

// 执行 getUserInfo
suspendReturn = getUserInfo(continuation)

// 判断是否挂起
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
//go to next state
}
}

1 -> {
throwOnFailure(result)

// 获取 user 值
user = result as String
log(user)
// 将协程结果存到 continuation 里
continuation.mUser = user
// 准备进入下一个状态
continuation.label = 2

// 执行 getFriendList
suspendReturn = getFriendList(user, continuation)

// 判断是否挂起
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
//go to next state
}
}

2 -> {
throwOnFailure(result)

user = continuation.mUser as String

// 获取 friendList 的值
friendList = result as String
log(friendList)

// 将协程结果存到 continuation 里
continuation.mUser = user
continuation.mFriendList = friendList

// 准备进入下一个状态
continuation.label = 3

// 执行 getFeedList
suspendReturn = getFeedList(friendList, continuation)

// 判断是否挂起
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
//go to next state
}
}

3 -> {
throwOnFailure(result)

user = continuation.mUser as String
friendList = continuation.mFriendList as String
feedList = continuation.result as String
log(feedList)
loop = false
}
}
  • when 表达式实现了协程状态机
  • continuation.label 是状态流转的关键
  • continuation.label 改变一次,就代表协程切换了一次
  • 每次协程切换后,都会检查是否发生异常
  • testCoroutine 里的原本的代码,被拆分到状态机里各个状态中,分开执行
  • getUserInfo(continuation),getFriendList(user, continuation),getFeedList(friendList, continuation) 三个函数调用传的同一个 continuation 实例。
  • 一个函数如果被挂起了,它的返回值会是:CoroutineSingletons.COROUTINE_SUSPENDED
  • 切换协程之前,状态机会把之前的结果以成员变量的方式保存在 continuation 中。

警告:以上的代码是我用 Kotlin 写出的改良版反编译代码,协程反编译后真实的代码后面我也会放出来,请继续看。

  1. 协程状态机动画演示

上面一大串文字和代码看着是不是有点晕?请看看这个动画演示,看完动画演示了,回过头再看上面的文字,你会有更多收获。

是不是完了呢?并不,因为上面的动画仅演示了每个协程正常挂起的情况。
如果协程并没有真正挂起呢?协程状态机会怎么运行?

协程未挂起的情况

要验证也很简单,我们将其中一个挂起函数改成伪挂起函数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kotlin复制代码// “伪”挂起函数
// 虽然它有 suspend 修饰,但执行的时候并不会真正挂起,因为它函数体里没有其他挂起函数
// ↓
suspend fun noSuspendFriendList(user: String): String{
return "Tom, Jack"
}

suspend fun testNoSuspend() {
log("start")
val user = getUserInfo()
log(user)
// 变化在这里
// ↓
val friendList = noSuspendFriendList(user)
log(friendList)
val feedList = getFeedList(friendList)
log(feedList)
}

testNoSuspend()这样的一个函数体,它的反编译后的代码逻辑怎么样的?

答案其实很简单,它的结构跟前面的testCoroutine()是一致的,只是函数名字变了而已,Kotlin 编译器 CPS 转换的逻辑只认 suspend 关键字。就算是“伪”挂起函数,Kotlin 编译器也照样会进行 CPS 转换。

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
kotlin复制代码when (continuation.label) {
0 -> {
...
}

1 -> {
...
// 变化在这里
// ↓
suspendReturn = noSuspendFriendList(user, continuation)

// 判断是否挂起
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
//go to next state
}
}

2 -> {
...
}

3 -> {
...
}
}

testNoSuspend()的协程状态机是怎么运行的?

其实很容易能想到,continuation.label = 0,2,3 的情况都是不变的,唯独在 label = 1 的时候,suspendReturn == sFlag这里会有区别。

具体区别我们通过动画来看吧:

通过动画我们很清楚的看到了,对于“伪”挂起函数,suspendReturn == sFlag是会走 else 分支的,在 else 分支里,协程状态机会直接进入下一个状态。

现在只剩最后一个问题了:

1
2
3
4
5
6
kotlin复制代码if (suspendReturn == sFlag) {
} else {
// 具体代码是如何实现的?
// ↓
//go to next state
}

答案其实也很简单:如果你去看协程状态机的字节码反编译后的 Java,会看到很多 label。协程状态机底层字节码是通过 label 来实现这个go to next state的。由于 Kotlin 没有类似 goto 的语法,下面我用伪代码来表示go to next state的逻辑。

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
kotlin复制代码// 伪代码
// Kotlin 没有这样的语法
// ↓ ↓
label: whenStart
when (continuation.label) {
0 -> {
...
}

1 -> {
...
suspendReturn = noSuspendFriendList(user, continuation)
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
// 让程序跳转到 label 标记的地方
// 从而再执行一次 when 表达式
goto: whenStart
}
}

2 -> {
...
}

3 -> {
...
}
}

注意:以上是伪代码,它只是跟协程状态机字节码逻辑上等价,为了不毁掉你钻研协程的乐趣,我不打算在这里解释协程原始的字节码。我相信如果你理解了我的文章以后,再去看协程反编译的真实代码,一定会游刃有余。

下面的建议会有助于你看协程真实的字节码:
协程状态机真实的原理是:通过 label 代码段嵌套,配合 switch 巧妙构造出一个状态机结构,这种逻辑比较复杂,相对难懂一些。(毕竟 Java 的 label 在实际开发中用的很少。)

真实的协程反编译代码大概长这样:

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
java复制代码@Nullable
public static final Object testCoroutine(@NotNull Continuation $completion) {
Object $continuation;
label37: {
if ($completion instanceof <TestSuspendKt$testCoroutine$1>) {
$continuation = (<TestSuspendKt$testCoroutine$1>)$completion;
if ((((<TestSuspendKt$testCoroutine$1>)$continuation).label & Integer.MIN_VALUE) != 0) {
((<TestSuspendKt$testCoroutine$1>)$continuation).label -= Integer.MIN_VALUE;
break label37;
}
}

$continuation = new ContinuationImpl($completion) {
// $FF: synthetic field
Object result;
int label;
Object L$0;
Object L$1;

@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return TestSuspendKt.testCoroutine(this);
}
};
}

Object var10000;
label31: {
String user;
String friendList;
Object var6;
label30: {
Object $result = ((<TestSuspendKt$testCoroutine$1>)$continuation).result;
var6 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(((<TestSuspendKt$testCoroutine$1>)$continuation).label) {
case 0:
ResultKt.throwOnFailure($result);
log("start");
((<TestSuspendKt$testCoroutine$1>)$continuation).label = 1;
var10000 = getUserInfo((Continuation)$continuation);
if (var10000 == var6) {
return var6;
}
break;
case 1:
ResultKt.throwOnFailure($result);
var10000 = $result;
break;
case 2:
user = (String)((<TestSuspendKt$testCoroutine$1>)$continuation).L$0;
ResultKt.throwOnFailure($result);
var10000 = $result;
break label30;
case 3:
friendList = (String)((<TestSuspendKt$testCoroutine$1>)$continuation).L$1;
user = (String)((<TestSuspendKt$testCoroutine$1>)$continuation).L$0;
ResultKt.throwOnFailure($result);
var10000 = $result;
break label31;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}

user = (String)var10000;
log(user);
((<TestSuspendKt$testCoroutine$1>)$continuation).L$0 = user;
((<TestSuspendKt$testCoroutine$1>)$continuation).label = 2;
var10000 = getFriendList(user, (Continuation)$continuation);
if (var10000 == var6) {
return var6;
}
}

friendList = (String)var10000;
log(friendList);
((<TestSuspendKt$testCoroutine$1>)$continuation).L$0 = user;
((<TestSuspendKt$testCoroutine$1>)$continuation).L$1 = friendList;
((<TestSuspendKt$testCoroutine$1>)$continuation).label = 3;
var10000 = getFeedList(friendList, (Continuation)$continuation);
if (var10000 == var6) {
return var6;
}
}

String feedList = (String)var10000;
log(feedList);
return Unit.INSTANCE;
}
  1. 结尾

回过头看线程和协程之间的关系:

线程

  • 线程是操作系统级别的概念
  • 我们开发者通过编程语言(Thread.java)创建的线程,本质还是操作系统内核线程的映射
  • JVM 中的线程与内核线程的存在映射关系,有“一对一”,“一对多”,“M对N”。JVM 在不同操作系统中的具体实现会有差别,“一对一”是主流
  • 一般情况下,我们说的线程,都是内核线程,线程之间的切换,调度,都由操作系统负责
  • 线程也会消耗操作系统资源,但比进程轻量得多
  • 线程,是抢占式的,它们之间能共享内存资源,进程不行
  • 线程共享资源导致了多线程同步问题
  • 有的编程语言会自己实现一套线程库,从而能在一个内核线程中实现多线程效果,早期 JVM 的“绿色线程” 就是这么做的,这种线程被称为“用户线程”

有的人会将线程比喻成:轻量级的进程。

协程

  • Kotlin 协程,不是操作系统级别的概念,无需操作系统支持
  • Kotlin 协程,有点像上面提到的“绿色线程”,一个线程上可以运行成千上万个协程
  • Kotlin 协程,是用户态的(userlevel),内核对协程无感知
  • Kotlin 协程,是协作式的,由开发者管理,不需要操作系统进行调度和切换,也没有抢占式的消耗,因此它更加高效
  • Kotlin 协程,它底层基于状态机实现,多协程之间共用一个实例,资源开销极小,因此它更加轻量
  • Kotlin 协程,本质还是运行于线程之上,它通过协程调度器,可以运行到不同的线程上

挂起函数是 Kotlin 协程最关键的内容,一定要理解透彻。

文中所有代码都已在 Demo 中提供,看完文章以后,你一定会有不少疑惑,一定要去实际调试运行:github.com/chaxiu/Kotl…

本文转载自: 掘金

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

Spring Bean 加载流程分析(通过 XML 方式加载

发表于 2020-10-14

Spring Bean 装配过程是一个老生常谈的话题,从最开始的通过 xml 形式装配 bean,到后来使用注解来装配 bean 以及使用 SpringBoot 和 SpringCloud 来进行开发,Spring 在整个过程中也进行了不断的演化和进步。不管是最初的 Spring 还是基于 Spring 开源的 SpringBoot、亦或是 SpringCloud,它们都是基于 Spring 的转变过来的,可能在 Spring 的基础上做了一些封装,但是本质上还是 Spring。

原本就没有那么多自动化的事情,只是 Spring 将实现的细节全部都隐藏在框架内部了。只有真正理解了 Spring,那么其实理解 SpringBoot 或者是 SpringCloud 只是一个水到渠成的事情。

起步

基于 Spring 版本 5.2.5

新建一个 maven 工程,导入如下几个 jar 包

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
xml复制代码<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.2.5.RELEASE</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>5.2.5.RELEASE</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
<version>5.2.5.RELEASE</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>5.2.5.RELEASE</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>5.2.5.RELEASE</version>
</dependency>

在 classpath 路径下新建 applicationContext.xml 文件,配置一个新的 bean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
xml复制代码<?xml version="1.0" encoding="UTF-8" ?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:aop="http://www.springframework.org/schema/aop"
xmlns:tx="http://www.springframework.org/schema/tx" xmlns:p="http://www.springframework.org/schema/p"
xsi:schemaLocation="
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context.xsd
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop.xsd
http://www.springframework.org/schema/util
http://www.springframework.org/schema/util/spring-util.xsd">

<bean id="user" class="com.liqiwen.spring.bean.User">
<property name="id" value="23" />
<property name="name" value="zhangsan" />
</bean>
</beans>

添加完毕后,工程示意图如下

通过 XML 形式装载 Bean

一个简单且基础获取 Bean 对象的代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public static void main(String[] args) {

// Spring Bean 加载流程
Resource resource = new ClassPathResource("applicationContext.xml");
// 获取一个 BeanFactory
DefaultListableBeanFactory defaultListableBeanFactory = new DefaultListableBeanFactory();
// 定义 Bean 定义读取器
BeanDefinitionReader beanDefinitionReader = new XmlBeanDefinitionReader(defaultListableBeanFactory);
// 从资源文件中读取 bean
beanDefinitionReader.loadBeanDefinitions(resource);
// 从工厂中获取 bean
User user = (User) defaultListableBeanFactory.getBean("user");

System.out.println(user.getId() + " =" + user.getName());
}

接下来我们逐行来分析 Bean 是如何被 Spring 容器所装载并缓存的。

1. 定义资源对象

1
java复制代码Resource resource = new ClassPathResource("applicationContext.xml");

将 classpath 下的 applicationContext.xml 文件转换成 Resource 文件。Resource 也是 Spring 提供的一种资源接口,除了我们示例中使用的 ClassPathResource 外,Spring 也提供了其他形式的 Resource 实现类。

进入 new ClassPathResource(“applicationContext.xml”) 构造方法,看看构造方法做了什么处理?

1
2
3
java复制代码public ClassPathResource(String path) {
this(path, (ClassLoader) null);
}

发现该构造方法调用了自身的另一个构造方法

1
2
3
4
5
6
7
8
9
10
java复制代码public ClassPathResource(String path, @Nullable ClassLoader classLoader) {
Assert.notNull(path, "Path must not be null");
String pathToUse = StringUtils.cleanPath(path);
if (pathToUse.startsWith("/")) {
pathToUse = pathToUse.substring(1);
}
this.path = pathToUse;
// 获取了一个类加载器
this.classLoader = (classLoader != null ? classLoader : ClassUtils.getDefaultClassLoader());
}

通过该构造方法,我们可以知道该构造方法初始了一个类加载器。如果类加载器不为空,则赋值成默认的类加载器。如果类加载器为空,则通过 ClassUtils.getDefaultClassLoader() 方法获取一个默认的类加载器。而我们传入的类加载器显然为 null,则 Spring 会去自动获取默认的类加载器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
java复制代码public static ClassLoader getDefaultClassLoader() {
ClassLoader cl = null;
try {
// 1.获取当前线程的类加载器
cl = Thread.currentThread().getContextClassLoader();
}
catch (Throwable ex) {
// Cannot access thread context ClassLoader - falling back...
}
if (cl == null) {
// No thread context class loader -> use class loader of this class.
// 2. 获取当前类的类加载器
cl = ClassUtils.class.getClassLoader();
if (cl == null) {
// getClassLoader() returning null indicates the bootstrap ClassLoader
try {
//获取系统级的类加载器/应用类加载器 AppClassLoader
cl = ClassLoader.getSystemClassLoader();
}
catch (Throwable ex) {
// Cannot access system ClassLoader - oh well, maybe the caller can live with null...
}
}
}
return cl;
}

通过 getDefaultClassLoader 方法我们可以知道,Spring 在获取类加载器做了如下三件事:

  • 获取当前线程的类加载器,如果存在,则返回。不存在则往下执行
  • 获取加载当前类的类加载器,如果存在,则返回。不存在则往下执行
  • 如果以上两步均没有获取到类加载器,则获取系统级类加载器/应用类加载器。

这里很好的利用了一个回退机制,用一个通俗的话语来解释回退机制就是退而求其次。先获取最合适的 xxx。如果获取不到,再获取其次合适的 xx。如果还是获取不到,就再退一步获取 x。

2. 初始化 BeanFactory

接下来我们看示例代码中的第二行代码的实现

1
java复制代码DefaultListableBeanFactory defaultListableBeanFactory = new DefaultListableBeanFactory();

看看 new DefaultListableBeanFactory() 方法做了什么?

1
2
3
4
5
6
7
java复制代码/**
* 创建一个默认的 BeanFactory
* Create a new DefaultListableBeanFactory.
*/
public DefaultListableBeanFactory() {
super();
}

一个空的实现,但是调用了父类的构造方法,跟进一步,看看父类的构造方法做了什么事情。

1
2
3
4
5
6
java复制代码public AbstractAutowireCapableBeanFactory() {
super();
ignoreDependencyInterface(BeanNameAware.class);
ignoreDependencyInterface(BeanFactoryAware.class);
ignoreDependencyInterface(BeanClassLoaderAware.class);
}

同样,该构造方法也调用了父类的构造方法,跟进父类的构造方法一探究竟。

1
2
java复制代码public AbstractBeanFactory() {
}

空实现,没啥好看的。看看 AbstractAutowireCapableBeanFactory 里面的另外三个方法。通过方法的名称我们可以大致猜出,这是为了忽略某些特定的依赖接口。

1
2
3
4
java复制代码private final Set<Class<?>> ignoredDependencyInterfaces = new HashSet<>();
public void ignoreDependencyInterface(Class<?> ifc) {
this.ignoredDependencyInterfaces.add(ifc);
}

没有太多的复杂逻辑,只是将某些特定的 class 对象放进了一个 set 集合中,标记这些接口应该被忽略,或许这个 set 集合会在后面的某一处使用到。但是注意,只有 BeanFactory 的接口应该被忽略。

3. 定义 BeanDefinitionReader 对象

接下来我们看第三行代码的实现

1
java复制代码BeanDefinitionReader beanDefinitionReader = new XmlBeanDefinitionReader(defaultListableBeanFactory);

通过该行代码,我们定义了一个 Bean 定义的读取器,将第二步生成的 defaultListableBeanFactory 对象传入我们定义的读取器。

1
2
3
4
5
6
7
8
java复制代码/**
* Create new XmlBeanDefinitionReader for the given bean factory.
* @param registry the BeanFactory to load bean definitions into,
* in the form of a BeanDefinitionRegistry
*/
public XmlBeanDefinitionReader(BeanDefinitionRegistry registry) {
super(registry);
}

该方法是为给定的 BeanFactory 创建一个 BeanDefinitionReader。这里我们可以看到构造方法的入参类型是 BeanDefinitionRegistry 类型,为什么我们定义的 DefaultListableBeanFactory 也能传入进去?很显然我们的 DefaultListableBeanFactory 实现了该接口。我们看看 DefaultListBeanFactory 的继承图。

XMLBeanDefinitionReader 的构造方法调用了父类的构造方法,跟进去父类的构造方法看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码protected AbstractBeanDefinitionReader(BeanDefinitionRegistry registry) {
Assert.notNull(registry, "BeanDefinitionRegistry must not be null");
this.registry = registry;

// Determine ResourceLoader to use. 决定要使用的 ResourceLoader
// 1. 根据传入进来的 BeanDefinitionRegistry 来获取 ResourceLoader
if (this.registry instanceof ResourceLoader) {
this.resourceLoader = (ResourceLoader) this.registry;
} else {
this.resourceLoader = new PathMatchingResourcePatternResolver();
}

// Inherit Environment if possible 继承环境如果存在的话
// 2. 根据传入进来的 BeanDefinitionRegistry 来获取当前的环境
if (this.registry instanceof EnvironmentCapable) {
this.environment = ((EnvironmentCapable) this.registry).getEnvironment();
} else {
this.environment = new StandardEnvironment();
}
}

该构造方法做了如下的几件事情

  • 给当前类的 registry 类型赋值
  • 根据传入进来的参数来获取对象的 ResourceLoader
  • 根据传入进来的参数来获取当前的环境
1
2
3
csharp复制代码public PathMatchingResourcePatternResolver() {
this.resourceLoader = new DefaultResourceLoader();
}

显然传入进来的 BeanDefinitionRegistry 不是 ResourceLoader 的实现类,这个我们从类的继承图中可以看出来。因此当前类的 ResourceLoader 为 new PathMatchingResourcePatternResolver();,该方法获取了默认的 ResourceLoader。

1
2
3
4
5
6
7
java复制代码public PathMatchingResourcePatternResolver() {
this.resourceLoader = new DefaultResourceLoader();
}
public DefaultResourceLoader() {
// 返回了一个默认的 ResourceLoader,并且赋值当前类的 classLoader
this.classLoader = ClassUtils.getDefaultClassLoader();
}

前面做了这么多的准备工作,接下来开始真正从我们定义好的 applicationContext.xml 来加载 Bean 的定义。该功能通过 beanDefinitionReader.loadBeanDefinitions(resource); 来实现。

4. 从给定的资源文件中加载 BeanDefinition

看看 beanDefinitionReader.loadBeanDefinitions(resource); 是如何加载 Bean 的定义的。

Spring 对方法名称的命名比较有讲究,基本上可以做到见名之意。通过方法名我们就可以知道 loadBeanDefinitions 是加载 BeanDefinition,但是注意:这里使用了复数,说明这里加载的 BeanDefinition 可能会存在多个。

1
2
3
4
java复制代码@Override
public int loadBeanDefinitions(Resource resource) throws BeanDefinitionStoreException {
return loadBeanDefinitions(new EncodedResource(resource));
}

该方法的主要功能是为了从指定的 Resource 文件中加载 BeanDefinition,该方法的返回值是返回 BeanDefinition 的数量。此处 Spring 将传入的 Resource 对象封装成了一个 EncodedResource 对象。顾名思义我们知道该对象只是对 Resource 进行了封装,其中除了包含指定的 Resource 资源外,还包含了编码信息,进入 EncodedResource 源码看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public EncodedResource(Resource resource) {
this(resource, null, null);
}
public EncodedResource(Resource resource, @Nullable String encoding) {
this(resource, encoding, null);
}
public EncodedResource(Resource resource, @Nullable Charset charset) {
this(resource, null, charset);
}
/**
* 私有构造方法
**/
private EncodedResource(Resource resource, @Nullable String encoding, @Nullable Charset charset) {
super();
Assert.notNull(resource, "Resource must not be null");
this.resource = resource;
this.encoding = encoding;
this.charset = charset;
}

可以看到,Charset 和 encoding 是互斥的属性。显然我们这里仅仅只传入了 Resource 对象,那么默认的 encoding 了 charset 均为空。

接下来看看 loadBeanDefinitions(EncodedResource e) 的具体实现。

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
java复制代码public int loadBeanDefinitions(EncodedResource encodedResource) throws BeanDefinitionStoreException {
// .... 省略无效代码
Set<EncodedResource> currentResources = this.resourcesCurrentlyBeingLoaded.get();
if (currentResources == null) {
currentResources = new HashSet<>(4);
this.resourcesCurrentlyBeingLoaded.set(currentResources);
}
if (!currentResources.add(encodedResource)) {
throw new BeanDefinitionStoreException(
"Detected cyclic loading of " + encodedResource + " - check your import definitions!");
}

try (InputStream inputStream = encodedResource.getResource().getInputStream()) {
InputSource inputSource = new InputSource(inputStream);
if (encodedResource.getEncoding() != null) {
inputSource.setEncoding(encodedResource.getEncoding());
}
return doLoadBeanDefinitions(inputSource, encodedResource.getResource());
}
catch (IOException ex) {
throw new BeanDefinitionStoreException(
"IOException parsing XML document from " + encodedResource.getResource(), ex);
}
finally {
currentResources.remove(encodedResource);
if (currentResources.isEmpty()) {
this.resourcesCurrentlyBeingLoaded.remove();
}
}
}

resourcesCurrentlyBeingLoaded 是一个 ThreadLocal 对象,首先先从 resourcesCurrentlyBeingLoaded 中获取当前的 encodedResource,如果获取出来的为空,则初始化一个 new HashSet<EncodedResource> 对象,将其放置到 resourcesCurrentlyBeingLoaded 对象中。接下来判断该对象是否在 resourcesCurrentlyBeingLoaded 中的 set 集合中已经存在,如果存在,则抛出 BeanDefinitionStoreException 异常,那么这个异常会在何时出现呢?我们可以尝试将 applicationContext.xml 进行改造一下。

1
2
3
4
5
6
7
xml复制代码<!-- 通过 import 组件导入自身的配置文件 -->
<import resource="applicationContext.xml" />

<bean id="user" class="com.liqiwen.spring.bean.User">
<property name="id" value="23" />
<property name="name" value="zhangsan" />
</bean>

很显然这里面造成了一个循环引用,执行至此必然会抛出异常。

这里使用了一种巧妙的方式,通过 Set 集合不能有重复数据的特性来判断 applicationContext.xml 文件中的定义是否出现了循环导入。

接下来看看 doLoadBeanDefinitions 的具体实现

1
2
3
4
5
6
7
8
9
java复制代码protected int doLoadBeanDefinitions(InputSource inputSource, Resource resource)
throws BeanDefinitionStoreException {
try {
Document doc = doLoadDocument(inputSource, resource);
int count = registerBeanDefinitions(doc, resource);
return count;
}
// ... 省略部分代码
}

显然 doLoadBeanDefinitions 做了两件事情

  • 从给定的 Resource 资源中读取 XML 文件中的内容,该方法返回了一个 Document 对象
  • 通过 Document 对象和给定的 Resource 资源中注册 bean 的定义

对于 doLoadDocument 方法的读取,实际上就是读取 XML 里面的内容,并返回一个 Document 对象。这部分就不跟源码进去看了,有兴趣可以自己搜索一下 XML 解析相关的内容。

下面看看 registerBeanDefinitions 相关的源码,看看是如何从 document 中获取到注册到 bean 的定义的。

1
2
3
4
5
6
7
8
9
10
java复制代码public int registerBeanDefinitions(Document doc, Resource resource) throws BeanDefinitionStoreException {
// 创建了一个 Bean 定义文档读取器
BeanDefinitionDocumentReader documentReader = createBeanDefinitionDocumentReader();
// 获取到工厂中已经获取到 Bean 定义的数量
int countBefore = getRegistry().getBeanDefinitionCount();
// 注册 Bean 定义
documentReader.registerBeanDefinitions(doc, createReaderContext(resource));
// 返回本次要注册 bean 定义的数量
return getRegistry().getBeanDefinitionCount() - countBefore;
}

看看如何获取一个 Bean 定义文档读取器(BeanDefinitionDocumentReader)

1
2
3
4
5
java复制代码private Class<? extends BeanDefinitionDocumentReader> documentReaderClass =
DefaultBeanDefinitionDocumentReader.class;
protected BeanDefinitionDocumentReader createBeanDefinitionDocumentReader() {
return BeanUtils.instantiateClass(this.documentReaderClass);
}

这里看到了调用了 Spring 自身提供的一个 BeanUtils.instantiateClass 方法。传入了 DefaultBeanDefinitionDocumentReader 的 class 文件,稍加思考我们便可以知道该方法是通过反射的方式生成了 BeanDefinitionDocumentReader 这个对象的实例。下面去 BeanUtils.instantiateClass 源码验证一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public static <T> T instantiateClass(Class<T> clazz) throws BeanInstantiationException {
Assert.notNull(clazz, "Class must not be null");
// 如果传入的是一个接口,则抛异常
if (clazz.isInterface()) {
throw new BeanInstantiationException(clazz, "Specified class is an interface");
}
try {
// 实例化类,获取构造器
return instantiateClass(clazz.getDeclaredConstructor());
}
catch (NoSuchMethodException ex) {
// 对 Kotlin 的支持
Constructor<T> ctor = findPrimaryConstructor(clazz);
if (ctor != null) {
return instantiateClass(ctor);
}
throw new BeanInstantiationException(clazz, "No default constructor found", ex);
}
catch (LinkageError err) {
throw new BeanInstantiationException(clazz, "Unresolvable class definition", err);
}
}

看看重载的 instantiateClass 方法,一目了然,全部都是反射相关的内容。

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复制代码public static <T> T instantiateClass(Constructor<T> ctor, Object... args) throws BeanInstantiationException {
Assert.notNull(ctor, "Constructor must not be null");
try {
// 设置 makeAccessible 属性为 true,
ReflectionUtils.makeAccessible(ctor);
if (KotlinDetector.isKotlinReflectPresent() && KotlinDetector.isKotlinType(ctor.getDeclaringClass())) {
return KotlinDelegate.instantiateClass(ctor, args);
}
else {
Class<?>[] parameterTypes = ctor.getParameterTypes();
Assert.isTrue(args.length <= parameterTypes.length, "Can't specify more arguments than constructor parameters");
Object[] argsWithDefaultValues = new Object[args.length];
for (int i = 0 ; i < args.length; i++) {
if (args[i] == null) {
Class<?> parameterType = parameterTypes[i];
argsWithDefaultValues[i] = (parameterType.isPrimitive() ? DEFAULT_TYPE_VALUES.get(parameterType) : null);
}
else {
argsWithDefaultValues[i] = args[i];
}
}
return ctor.newInstance(argsWithDefaultValues);
}
}
// ... 省略部分代码
}

注意在该方法的头部调用了 ReflectionUtils.makeAccessible(ctor); 方法,该方法即表明了即使你提供了私有的构造方法,Spring 也能帮你将对象创建出来(反射的内容),看到最后的 return,很明显 BeanUtils.instantiateClass 就是通过反射的方式生成了对象。

  • 如果没有提供构造方法,则采用默认的构造方法
  • 如果提供了私有的构造方法,则设置 accessible 属性为 true,再调用反射生成对象的实例

通过以上的方式可以看出,Spring 是想尽了一切办法在帮我们正常创建一个对象。
看看传入的 DefaultBeanDefinitionDocumentReader 的声明

看到这些红框中的内容,是不是感觉到非常熟悉,这不就是我们在 applicationContext.xml 中定义的一个个标签么?原来这些东西都被 DefaultBeanDefinitionDocumentReader 写死在代码中了。

接下来我们回到 registerBeanDefinitions 这个方法的实现。

我们已经知道 createBeanDefinitionDocumentReader 是通过反射的方式生成了一个 BeanDefinitionDocumentReader 对象。下面我们看看方法的第二行做了什么事情。

getRegistry().getBeanDefinitionCount(); 先看看 getRegistry() 这个方法。这个方法基本上都不用考虑,肯定是获取到了我们传入进来的 defaultListableBeanFactory 对象。

1
2
3
4
5
java复制代码private final BeanDefinitionRegistry registry;
@Override
public final BeanDefinitionRegistry getRegistry() {
return this.registry;
}

返回了成员变量 registry,那么这个 registry 在哪里赋值的呢?看看我们在示例代码中的第三步 new XMLBeanDefinitionReader() 中就知道了,在该类的构造方法中,我们赋值了 registry 这个成员变量的值。

接着看看 getBeanDefinitionCount 的实现

1
2
3
4
5
6
java复制代码/** Map of bean definition objects, keyed by bean name. */
private final Map<String, BeanDefinition> beanDefinitionMap = new ConcurrentHashMap<>(256);
@Override
public int getBeanDefinitionCount() {
return this.beanDefinitionMap.size();
}

就是返回了 beanDefinitionMap 这个 concurrentHashMap 的大小。该变量的定义为 Map<String, BeanDefinition> 类型,是一个以 bean 名称为 key,BeanDefinition 为 value 的 Map 对象。

结合上面的分析,那么 int countBefore = getRegistry().getBeanDefinitionCount(); 返回的实际上是未加载之前的 BeanDefinition 的数量。

接着看 registerBeanDefinitions 的第三行实现。documentReader.registerBeanDefinitions(doc, createReaderContext(resource));
通过文档读取器开始从文档中注册 bean 的定义。
看看具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码@Override
public void registerBeanDefinitions(Document doc, XmlReaderContext readerContext) {
this.readerContext = readerContext;
//doc.getDocumentElement() 获取文档中的 element 元素
doRegisterBeanDefinitions(doc.getDocumentElement());
}

protected void doRegisterBeanDefinitions(Element root) {
// 任何嵌套的 <beans> 标签在这个方法中将会导致递归
// Any nested <beans> elements will cause recursion in this method. In
// order to propagate and preserve <beans> default-* attributes correctly,
// keep track of the current (parent) delegate, which may be null. Create
// the new (child) delegate with a reference to the parent for fallback purposes,
// then ultimately reset this.delegate back to its original (parent) reference.
// this behavior emulates a stack of delegates without actually necessitating one.
BeanDefinitionParserDelegate parent = this.delegate;
this.delegate = createDelegate(getReaderContext(), root, parent);
// ... 省略部分代码
preProcessXml(root);
parseBeanDefinitions(root, this.delegate);
postProcessXml(root);

this.delegate = parent;
}

从 doRegisterBeanDefinitions 中的注释我们知道,

  • 该方法可能会导致递归,如果我们在 applicationContext.xml 配置了引用其他 <beans>
  • 该方法使用了典型的 delegate。就是我自己要做某件事,我自己不做,让其他类帮我去做。

看看方法的 preProcessXml(root),这个方法是一个空实现,
接着看看 postProcessXml(root),这个方法也是一个空实现

这是一种典型的模板方法设计模式。可以看到该方法被定义成了 protected,留作子类去实现。核心的解析逻辑在 parseBeanDefinitions 方法中。

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
java复制代码/**
* Parse the elements at the root level in the document:
* "import", "alias", "bean".
* @param root the DOM root element of the document
*/
protected void parseBeanDefinitions(Element root, BeanDefinitionParserDelegate delegate) {
if (delegate.isDefaultNamespace(root)) {
NodeList nl = root.getChildNodes();
for (int i = 0; i < nl.getLength(); i++) {
Node node = nl.item(i);
if (node instanceof Element) {
Element ele = (Element) node;
if (delegate.isDefaultNamespace(ele)) {
parseDefaultElement(ele, delegate);
}
else {
delegate.parseCustomElement(ele);
}
}
}
}
else {
delegate.parseCustomElement(root);
}
}

该方法解析了文档最顶层的标签元素,例如 bean,import 等等,除了解析 Spring 规定的标签节点外,还解析了自定义的标签元素。自定义的标签我们很少用到,着重看一下解析默认的标签元素。跟到 parseDefaultElement 中看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码private void parseDefaultElement(Element ele, BeanDefinitionParserDelegate delegate) {
// IMPORT_ELEMENT = "import"
if (delegate.nodeNameEquals(ele, IMPORT_ELEMENT)) {
importBeanDefinitionResource(ele);
}
// ALIAS_ELEMENT = "alias"
else if (delegate.nodeNameEquals(ele, ALIAS_ELEMENT)) {
processAliasRegistration(ele);
}
// BEAN_ELEMENT = "bean"
else if (delegate.nodeNameEquals(ele, BEAN_ELEMENT)) {
processBeanDefinition(ele, delegate);
}
// NESTED_BEANS_ELEMENT = "beans"
else if (delegate.nodeNameEquals(ele, NESTED_BEANS_ELEMENT)) {
// recurse 递归
doRegisterBeanDefinitions(ele);
}
}

之前我们说到 doRegisterBeanDefinitions 方法会导致递归,在该方法的最后一行得到了验证。如果里面定义了 <beans> 类型的标签的话(嵌套 beans)

这里说明一下,早在我们介绍 loadBeanDefinitions 方法中,Spring 利用了一个 Set 集合来探测是否存在循环的 import 导入配置文件,如果出现了循环的 import 导入,Spring 会在 loadBeanDefinitions 中抛出异常。这种出现必然是有原因的,我们跟到 importBeanDefinitionResource 中看看 Spring 是如何处理 import 这种标签的。

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
java复制代码protected void importBeanDefinitionResource(Element ele) {
// 获取元素中的 resource 属性
String location = ele.getAttribute(RESOURCE_ATTRIBUTE);
if (!StringUtils.hasText(location)) {
getReaderContext().error("Resource location must not be empty", ele);
return;
}

// Resolve system properties: e.g. "${user.dir}"
location = getReaderContext().getEnvironment().resolveRequiredPlaceholders(location);

Set<Resource> actualResources = new LinkedHashSet<>(4);

// Discover whether the location is an absolute or relative URI
boolean absoluteLocation = false;
try {
// 判断 resource 的值是否为绝对路径
absoluteLocation = ResourcePatternUtils.isUrl(location) || ResourceUtils.toURI(location).isAbsolute();
}
catch (URISyntaxException ex) {
// cannot convert to an URI, considering the location relative
// unless it is the well-known Spring prefix "classpath*:"
}

// Absolute or relative?
if (absoluteLocation) { //绝对路径
try {
// 调用了 loadBeanDefinitions 方法
int importCount = getReaderContext().getReader().loadBeanDefinitions(location, actualResources);
if (logger.isTraceEnabled()) {
logger.trace("Imported " + importCount + " bean definitions from URL location [" + location + "]");
}
}
catch (BeanDefinitionStoreException ex) {
getReaderContext().error(
"Failed to import bean definitions from URL location [" + location + "]", ele, ex);
}
}
else { // 相对路径
// No URL -> considering resource location as relative to the current file.
try {
int importCount;
Resource relativeResource = getReaderContext().getResource().createRelative(location);
if (relativeResource.exists()) {
// 调用了 loadBeanDefinitions 方法
importCount = getReaderContext().getReader().loadBeanDefinitions(relativeResource);
actualResources.add(relativeResource);
}
else {
String baseLocation = getReaderContext().getResource().getURL().toString();
// 调用了 loadBeanDefinitions 方法
importCount = getReaderContext().getReader().loadBeanDefinitions(
StringUtils.applyRelativePath(baseLocation, location), actualResources);
}
}
catch (IOException ex) {
getReaderContext().error("Failed to resolve current resource location", ele, ex);
}
catch (BeanDefinitionStoreException ex) {
getReaderContext().error(
"Failed to import bean definitions from relative location [" + location + "]", ele, ex);
}
}
Resource[] actResArray = actualResources.toArray(new Resource[0]);
getReaderContext().fireImportProcessed(location, actResArray, extractSource(ele));
}

不管 import 标签的 resource 属性配置的是绝对路径还是相对路径,我们在代码中不难发现,两个分支中都调用了 loadBeanDefinitions 这个方法。者都会导致 Spring 在解析 import 标签的同时去判断是否 import 循环的 xml 文件引用,也从侧面验证了如果循环 import 了,Spring 将会抛出异常。

对于 alias 标签的处理我们并不关心,在实际应用中这样处理少之又少,我们这里选择跳过。直接看 processBeanDefinition 这个方法的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码protected void processBeanDefinition(Element ele, BeanDefinitionParserDelegate delegate) {
BeanDefinitionHolder bdHolder = delegate.parseBeanDefinitionElement(ele);
if (bdHolder != null) {
bdHolder = delegate.decorateBeanDefinitionIfRequired(ele, bdHolder);
try {
// Register the final decorated instance.
BeanDefinitionReaderUtils.registerBeanDefinition(bdHolder, getReaderContext().getRegistry());
}
catch (BeanDefinitionStoreException ex) {
getReaderContext().error("Failed to register bean definition with name '" +
bdHolder.getBeanName() + "'", ele, ex);
}
// Send registration event.
getReaderContext().fireComponentRegistered(new BeanComponentDefinition(bdHolder));
}
}

该方法的主要作用便是从给定的 Bean Element 标签中解析出 BeanDefinition 并将其放入到给定的 registry 中,也就是我们声明的 DefaultListableBeanFactory 中。看看 delegate.parseBeanDefinitionElement 是如何解析的。

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
java复制代码@Nullable
public BeanDefinitionHolder parseBeanDefinitionElement(Element ele, @Nullable BeanDefinition containingBean) {
// 从标签中获取到 id 属性
String id = ele.getAttribute(ID_ATTRIBUTE);
// 从标签中获取到 name 属性
String nameAttr = ele.getAttribute(NAME_ATTRIBUTE);

List<String> aliases = new ArrayList<>();
// 对别名的处理
if (StringUtils.hasLength(nameAttr)) {
String[] nameArr = StringUtils.tokenizeToStringArray(nameAttr, MULTI_VALUE_ATTRIBUTE_DELIMITERS);
aliases.addAll(Arrays.asList(nameArr));
}
// 将 id 作为 bean 的名称
String beanName = id;
// 如果 beanName 为空,则从别名的数组中取出第一个元素作为 beanName
if (!StringUtils.hasText(beanName) && !aliases.isEmpty()) {
beanName = aliases.remove(0);
}
// 检查名称的唯一性
if (containingBean == null) {
checkNameUniqueness(beanName, aliases, ele);
}
// 解析出 bean 的定义
AbstractBeanDefinition beanDefinition = parseBeanDefinitionElement(ele, beanName, containingBean);
if (beanDefinition != null) {
// 如果解析出的 Bean 没有 beanName,那么会自动给该 bean 生成一个名称
if (!StringUtils.hasText(beanName)) {
try {
if (containingBean != null) {
beanName = BeanDefinitionReaderUtils.generateBeanName(
beanDefinition, this.readerContext.getRegistry(), true);
}
else {
beanName = this.readerContext.generateBeanName(beanDefinition);
// Register an alias for the plain bean class name, if still possible,
// if the generator returned the class name plus a suffix.
// This is expected for Spring 1.2/2.0 backwards compatibility.
String beanClassName = beanDefinition.getBeanClassName();
if (beanClassName != null &&
beanName.startsWith(beanClassName) && beanName.length() > beanClassName.length() &&
!this.readerContext.getRegistry().isBeanNameInUse(beanClassName)) {
aliases.add(beanClassName);
}
}
if (logger.isTraceEnabled()) {
logger.trace("Neither XML 'id' nor 'name' specified - " +
"using generated bean name [" + beanName + "]");
}
}
catch (Exception ex) {
error(ex.getMessage(), ele);
return null;
}
}
String[] aliasesArray = StringUtils.toStringArray(aliases);
// 返回一个 BeanDefinitionHolder 对象
return new BeanDefinitionHolder(beanDefinition, beanName, aliasesArray);
}

return null;
}

该方法做了如下几件事

  • 获取 id 属性和别名属性以及 class 属性,如果没有名称,则将别名的第个元素作为 bean 的名称
  • 解析 BeanDefinition,返回一个 AbstractBeanDefinition 对象
  • 判断 AbstractBeanDefinition 中是否包含 bean 的名称,如果不包含,则给该 bean 生成一个 bean 的名称
  • 返回包装好的一个 BeanDefinitionHolder 对象,该对象包含了 xml 中配置的 bean 的所有属性,以及 bean 的名称和别名数组。

显然重点在第二步中,如何返回一个 AbstractBeanDefinition 对象。看看 parseBeanDefinitionElement 这个方法的实现。

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
java复制代码@Nullable
public AbstractBeanDefinition parseBeanDefinitionElement(
Element ele, String beanName, @Nullable BeanDefinition containingBean) {
//是否有 class 属性
String className = null;
if (ele.hasAttribute(CLASS_ATTRIBUTE)) {
className = ele.getAttribute(CLASS_ATTRIBUTE).trim();
}
// 是否有 parent 属性
String parent = null;
if (ele.hasAttribute(PARENT_ATTRIBUTE)) {
parent = ele.getAttribute(PARENT_ATTRIBUTE);
}

try {
// 创建一个 bean 的定义
AbstractBeanDefinition bd = createBeanDefinition(className, parent);
// 解析 beanDefinitionAttributes 属性,包括 init-method, destroy-method 属性等等
parseBeanDefinitionAttributes(ele, beanName, containingBean, bd);
bd.setDescription(DomUtils.getChildElementValueByTagName(ele, DESCRIPTION_ELEMENT));

parseMetaElements(ele, bd);
parseLookupOverrideSubElements(ele, bd.getMethodOverrides());
parseReplacedMethodSubElements(ele, bd.getMethodOverrides());

//解析构造参数
parseConstructorArgElements(ele, bd);
// 解析属性参数
parsePropertyElements(ele, bd);
parseQualifierElements(ele, bd);

bd.setResource(this.readerContext.getResource());
bd.setSource(extractSource(ele));

return bd;
}
//省略部分代码...

return null;
}

看看如何创建一个 BeanDefinition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public static AbstractBeanDefinition createBeanDefinition(
@Nullable String parentName, @Nullable String className, @Nullable ClassLoader classLoader) throws ClassNotFoundException {

GenericBeanDefinition bd = new GenericBeanDefinition();
bd.setParentName(parentName);
if (className != null) {
if (classLoader != null) {
bd.setBeanClass(ClassUtils.forName(className, classLoader));
}
else {
bd.setBeanClassName(className);
}
}
return bd;
}

首先通过 new 出来了一个 GenericBeanDefinition 对象,然后根据是否存在 classLoader 对象来判断是否应该给该对象设置 class 对象或者 className 名称,最后将 GenericBeanDefinition 返回。

parseBeanDefinitionAttributes 方法源码如下:

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
java复制代码public AbstractBeanDefinition parseBeanDefinitionAttributes(Element ele, String beanName,
@Nullable BeanDefinition containingBean, AbstractBeanDefinition bd) {

if (ele.hasAttribute(SINGLETON_ATTRIBUTE)) { //是否有 singleton 属性,在早前的版本存在,2.x 以后就不存在了,如果你设置了该属性,spring 会提示升级成 scope 属性
error("Old 1.x 'singleton' attribute in use - upgrade to 'scope' declaration", ele);
}
else if (ele.hasAttribute(SCOPE_ATTRIBUTE)) { //是否有 scope 属性
bd.setScope(ele.getAttribute(SCOPE_ATTRIBUTE));
}
else if (containingBean != null) {
// Take default from containing bean in case of an inner bean definition.
bd.setScope(containingBean.getScope());
}

if (ele.hasAttribute(ABSTRACT_ATTRIBUTE)) {//是否有 abstract 属性
bd.setAbstract(TRUE_VALUE.equals(ele.getAttribute(ABSTRACT_ATTRIBUTE)));
}

String lazyInit = ele.getAttribute(LAZY_INIT_ATTRIBUTE); //是否有 lazy-init 属性
if (isDefaultValue(lazyInit)) {
lazyInit = this.defaults.getLazyInit();
}
bd.setLazyInit(TRUE_VALUE.equals(lazyInit));

String autowire = ele.getAttribute(AUTOWIRE_ATTRIBUTE); //是否有自动装配属性
bd.setAutowireMode(getAutowireMode(autowire));

if (ele.hasAttribute(DEPENDS_ON_ATTRIBUTE)) { //是否有 depends-on 属性
String dependsOn = ele.getAttribute(DEPENDS_ON_ATTRIBUTE);
bd.setDependsOn(StringUtils.tokenizeToStringArray(dependsOn, MULTI_VALUE_ATTRIBUTE_DELIMITERS));
}

String autowireCandidate = ele.getAttribute(AUTOWIRE_CANDIDATE_ATTRIBUTE);
if (isDefaultValue(autowireCandidate)) {
String candidatePattern = this.defaults.getAutowireCandidates();
if (candidatePattern != null) {
String[] patterns = StringUtils.commaDelimitedListToStringArray(candidatePattern);
bd.setAutowireCandidate(PatternMatchUtils.simpleMatch(patterns, beanName));
}
}
else {
bd.setAutowireCandidate(TRUE_VALUE.equals(autowireCandidate));
}

if (ele.hasAttribute(PRIMARY_ATTRIBUTE)) {
bd.setPrimary(TRUE_VALUE.equals(ele.getAttribute(PRIMARY_ATTRIBUTE)));
}

if (ele.hasAttribute(INIT_METHOD_ATTRIBUTE)) {
String initMethodName = ele.getAttribute(INIT_METHOD_ATTRIBUTE);
bd.setInitMethodName(initMethodName);
}
else if (this.defaults.getInitMethod() != null) {
bd.setInitMethodName(this.defaults.getInitMethod());
bd.setEnforceInitMethod(false);
}

if (ele.hasAttribute(DESTROY_METHOD_ATTRIBUTE)) {
String destroyMethodName = ele.getAttribute(DESTROY_METHOD_ATTRIBUTE);
bd.setDestroyMethodName(destroyMethodName);
}
else if (this.defaults.getDestroyMethod() != null) {
bd.setDestroyMethodName(this.defaults.getDestroyMethod());
bd.setEnforceDestroyMethod(false);
}

if (ele.hasAttribute(FACTORY_METHOD_ATTRIBUTE)) {
bd.setFactoryMethodName(ele.getAttribute(FACTORY_METHOD_ATTRIBUTE));
}
if (ele.hasAttribute(FACTORY_BEAN_ATTRIBUTE)) {
bd.setFactoryBeanName(ele.getAttribute(FACTORY_BEAN_ATTRIBUTE));
}

return bd;
}

其实也是很简单,就是解析 bean 标签中的其他属性,分别为 createBeanDefinition 返回的 BeanDefinition 对象的属性赋值。可能有人有疑问了,我们在 bean 标签中并没有配置其他的属性,但是部分属性还是存在默认值的。
这里的属性定义其实就是跟 applicationContext.xml 中的 bean 标签是对应上的。

另外还有其他的两个方法我们要关心一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码/**
* Parse constructor-arg sub-elements of the given bean element.
* 解析 bean 标签中的子元素 constructor-arg 参数
*/
public void parseConstructorArgElements(Element beanEle, BeanDefinition bd) {
NodeList nl = beanEle.getChildNodes();
for (int i = 0; i < nl.getLength(); i++) {
Node node = nl.item(i);
if (isCandidateElement(node) && nodeNameEquals(node, CONSTRUCTOR_ARG_ELEMENT)) {
parseConstructorArgElement((Element) node, bd);
}
}
}

/**
* Parse property sub-elements of the given bean element.
* 解析 bean 标签中的 property 参数
*/
public void parsePropertyElements(Element beanEle, BeanDefinition bd) {
NodeList nl = beanEle.getChildNodes();
for (int i = 0; i < nl.getLength(); i++) {
Node node = nl.item(i);
if (isCandidateElement(node) && nodeNameEquals(node, PROPERTY_ELEMENT)) {
// 其中包含对 value 的处理和对 ref 的处理
parsePropertyElement((Element) node, bd);
}
}
}

至此,我们这里便返回了一个完整的 BeanDefinitionHolder 对象。

该 BeanDefinitionHolder 中包含了从 xml 文件中解析出来的 BeanDefinition 对象和 beanName 属性。

bdHolder = delegate.decorateBeanDefinitionIfRequired(ele, bdHolder) 该行就是对我们返回的 BeanDefinitionHolder 装饰一下,也就是看看是否需要添加其他额外的属性,最后返回依然是一个 BeanDefinitionHolder 对象。

最后重头戏来了,通过了 BeanDefinitionReaderUtils 的 registerBeanDefinition 方法向 registry 中注册了一个 BeanDefinitionHolder 对象,看看是如何注册的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码public static void registerBeanDefinition(
BeanDefinitionHolder definitionHolder, BeanDefinitionRegistry registry)
throws BeanDefinitionStoreException {

// Register bean definition under primary name.
String beanName = definitionHolder.getBeanName();
// 重点在这里
registry.registerBeanDefinition(beanName, definitionHolder.getBeanDefinition());

// Register aliases for bean name, if any.
String[] aliases = definitionHolder.getAliases();
if (aliases != null) {
for (String alias : aliases) {
registry.registerAlias(beanName, alias);
}
}
}

重点在于我们调用了 registry 的 registerBeanDefinition 方法,registerBeanDefinition 有多个实现,而显然我们应该查看 DefaultListableBeanFactory 的实现。

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
java复制代码@Override
public void registerBeanDefinition(String beanName, BeanDefinition beanDefinition) throws BeanDefinitionStoreException {
// 判断 BeanDefinition 是否是 AbstractBeanDefinition 的实例,显然这里是的。这里是只是对 beanDefinition 做了校验
if (beanDefinition instanceof AbstractBeanDefinition) {
try {
((AbstractBeanDefinition) beanDefinition).validate();
} catch (BeanDefinitionValidationException ex) {
throw new BeanDefinitionStoreException(beanDefinition.getResourceDescription(), beanName,
"Validation of bean definition failed", ex);
}
}
// 从 beanDefinitionMap 中获取 BeanDefinition 的定义
BeanDefinition existingDefinition = this.beanDefinitionMap.get(beanName);
if (existingDefinition != null) {
// 判断是否有相同名称的 bean
if (!isAllowBeanDefinitionOverriding()) {
throw new BeanDefinitionOverrideException(beanName, beanDefinition, existingDefinition);
} else if (existingDefinition.getRole() < beanDefinition.getRole()) {
// 空实现
} else if (!beanDefinition.equals(existingDefinition)) {
// 空实现
} else {
// 空实现
}
// 重新放入到 concurrentHashMap 中
this.beanDefinitionMap.put(beanName, beanDefinition);
} else {
if (hasBeanCreationStarted()) { //检查工厂 bean 的创建阶段是否已经开始了,创建阶段已经开始了
synchronized (this.beanDefinitionMap) { //在这里对 beanDefinitionMap 这个 concurrentHashMap 做了同步处理,其实是为了防止并发的情况产生,导致 bean 没有注册上去。
// 将 beanDefinition 放置到缓存中去
this.beanDefinitionMap.put(beanName, beanDefinition);
// 定义一个 updated 集合
List<String> updatedDefinitions = new ArrayList<>(this.beanDefinitionNames.size() + 1);
//将所有的 beanDefinitionNames 放到 updatedDefinitions 中
updatedDefinitions.addAll(this.beanDefinitionNames);
// 将要添加的 beanName 放置到 updatedDefinitions
updatedDefinitions.add(beanName);
// 重新给 beanDefinitionNames 赋值
this.beanDefinitionNames = updatedDefinitions;
removeManualSingletonName(beanName);
}
} else {
// 仍然在启动注册阶段
this.beanDefinitionMap.put(beanName, beanDefinition);
this.beanDefinitionNames.add(beanName);
removeManualSingletonName(beanName);
}
this.frozenBeanDefinitionNames = null;
}

// 重置 BeanDefinition 的缓存
if (existingDefinition != null || containsSingleton(beanName)) {
resetBeanDefinition(beanName);
}
}

到这里,我们所有的在 xml 中定义的 bean 对象都已经被解析出来了,所有的 bean 都被存放在 registry 中的 beanDefinitionMap 中,它是一个 concurrentHashMap,它的 key 是 beanName,value 是关于该 bean 的全部定义,其中包含 className/class, scope, init-method … 等等属性。至此整个 bean 的加载过程也就结束了。但是注意:此时我们的 bean 并没有被创建。那么该 bean 是在什么时候被创建的呢?

Bean 的创建过程

通过上面的过程,我们可以知道以上的三行代码 Spring 从 applicationContext.xml 文件中加载了 bean 的定义,并存放到了 beanDefinitionMap 中,此时我们的 bean 对象并没有被初始化。

下面来看看 defaultListableBeanFactory.getBean 方法。看看是如何实现的。点进去我们发现 Spring 的 BeanFactory 为我们提供了各种各样的 getBean 方法。但是他们的本质都是调用了 doGetBean 方法。我们直接去看 doGetBean 方法做了什么事情。

看看 doGetBean 的源码实现 (doGetBean 的源码非常多,因为源码太多的原因,这里删除了一些无用的日志逻辑)

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
java复制代码protected <T> T doGetBean(final String name, @Nullable final Class<T> requiredType, @Nullable final Object[] args, boolean typeCheckOnly) throws BeansException {
// 转换 bean 的 beanName
final String beanName = transformedBeanName(name);
Object bean; //这里定义成了一个 Object 对象,因为 Spring 并不知道我们要获取对象的类型,直接使用了 Object 对象来接收

// 检查已经注册在 Spring 容器中是否存在这样的 bean
Object sharedInstance = getSingleton(beanName); //第一次访问 sharedInstance 为 null,这里面判断了 scope = singleton 形式时是否会出现循环引用
if (sharedInstance != null && args == null) {
bean = getObjectForBeanInstance(sharedInstance, name, beanName, null);
} else {

if (isPrototypeCurrentlyInCreation(beanName)) {
throw new BeanCurrentlyInCreationException(beanName);
}

// 判断 beanDefinition 是否已经存在了。目前根据 parentBeanFactory 返回为 null
BeanFactory parentBeanFactory = getParentBeanFactory();
if (parentBeanFactory != null && !containsBeanDefinition(beanName)) {
// Not found -> check parent.
String nameToLookup = originalBeanName(name);
if (parentBeanFactory instanceof AbstractBeanFactory) {
return ((AbstractBeanFactory) parentBeanFactory).doGetBean(
nameToLookup, requiredType, args, typeCheckOnly);
} else if (args != null) {
// Delegation to parent with explicit args.
return (T) parentBeanFactory.getBean(nameToLookup, args);
} else if (requiredType != null) {
// No args -> delegate to standard getBean method.
return parentBeanFactory.getBean(nameToLookup, requiredType);
} else {
return (T) parentBeanFactory.getBean(nameToLookup);
}
}
// 标记当前对象为已经创建,实际上就是将该 beanName 添加到 Set 集合中
if (!typeCheckOnly) {
markBeanAsCreated(beanName);
}

try {
// 将 BeanDefinitionMap 中的 beanDefinition 转换成 RootBeanDefinition
final RootBeanDefinition mbd = getMergedLocalBeanDefinition(beanName);
checkMergedBeanDefinition(mbd, beanName, args);

// 获取合并后的 bean 的依赖信息
String[] dependsOn = mbd.getDependsOn();
if (dependsOn != null) {
for (String dep : dependsOn) {
if (isDependent(beanName, dep)) {
throw new BeanCreationException(mbd.getResourceDescription(), beanName,
"Circular depends-on relationship between '" + beanName + "' and '" + dep + "'");
}
registerDependentBean(dep, beanName);
try {
getBean(dep);
}
catch (NoSuchBeanDefinitionException ex) {
throw new BeanCreationException(mbd.getResourceDescription(), beanName,
"'" + beanName + "' depends on missing bean '" + dep + "'", ex);
}
}
}

// 创建 bean 的实例
if (mbd.isSingleton()) {
sharedInstance = getSingleton(beanName, () -> {
try {
return createBean(beanName, mbd, args);
}
catch (BeansException ex) {
// Explicitly remove instance from singleton cache: It might have been put there
// eagerly by the creation process, to allow for circular reference resolution.
// Also remove any beans that received a temporary reference to the bean.
destroySingleton(beanName);
throw ex;
}
});
bean = getObjectForBeanInstance(sharedInstance, name, beanName, mbd);
} else if (mbd.isPrototype()) {
// It's a prototype -> create a new instance.
Object prototypeInstance = null;
try {
beforePrototypeCreation(beanName);
prototypeInstance = createBean(beanName, mbd, args);
}
finally {
afterPrototypeCreation(beanName);
}
bean = getObjectForBeanInstance(prototypeInstance, name, beanName, mbd);
} else {
String scopeName = mbd.getScope();
final Scope scope = this.scopes.get(scopeName);
if (scope == null) {
throw new IllegalStateException("No Scope registered for scope name '" + scopeName + "'");
}
try {
Object scopedInstance = scope.get(beanName, () -> {
beforePrototypeCreation(beanName);
try {
return createBean(beanName, mbd, args);
}
finally {
afterPrototypeCreation(beanName);
}
});
bean = getObjectForBeanInstance(scopedInstance, name, beanName, mbd);
}
catch (IllegalStateException ex) {
throw new BeanCreationException(beanName,
"Scope '" + scopeName + "' is not active for the current thread; consider " +
"defining a scoped proxy for this bean if you intend to refer to it from a singleton",
ex);
}
}
} catch (BeansException ex) {
cleanupAfterBeanCreationFailure(beanName);
throw ex;
}
}

// Check if required type matches the type of the actual bean instance.
if (requiredType != null && !requiredType.isInstance(bean)) {
try {
T convertedBean = getTypeConverter().convertIfNecessary(bean, requiredType);
if (convertedBean == null) {
throw new BeanNotOfRequiredTypeException(name, requiredType, bean.getClass());
}
return convertedBean;
}
catch (TypeMismatchException ex) {
if (logger.isTraceEnabled()) {
logger.trace("Failed to convert bean '" + name + "' to required type '" +
ClassUtils.getQualifiedName(requiredType) + "'", ex);
}
throw new BeanNotOfRequiredTypeException(name, requiredType, bean.getClass());
}
}
return (T) bean;
}

以上的代码做了如下几件事情

1.根据 beanName 去单例的缓存中检查是否已经存在该 Bean 对象

那么检查是如何进行的呢?通过 getSingleton 可以一窥究竟。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码// 传入的 allowEarlyReference 为 true

/** Cache of singleton objects: bean name to bean instance. */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
@Nullable
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
// 将 singletonObjects 作为一个同步块,防止出现并发
synchronized (this.singletonObjects) {
singletonObject = this.earlySingletonObjects.get(beanName);
if (singletonObject == null && allowEarlyReference) {
ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
if (singletonFactory != null) {
singletonObject = singletonFactory.getObject();
this.earlySingletonObjects.put(beanName, singletonObject);
this.singletonFactories.remove(beanName);
}
}
}
}
return singletonObject;
}

可以看到从当前的 singletonObjects 对象中获取了 singletonObject 对象,singletonObject 对象为一个 ConcurrentHashMap 对象,用来缓存 SpringIOC 容器初始化过后的 bean。并且只会缓存 scope 属性为单例的 bean,prototype 属性的 bean 不会缓存。

如果缓存对象为空,并且当前对象处于正在创建的时候,就开始处理循环引用的问题。如果当前缓存的对象不为空,那么直接返回当前缓存的 singletonObject;

这里涉及到一个循环引用的问题,后面单开文章来进行讲解。此处我们主要分析 bean 的创建过程。

显然我们这里是第一次获取,所以 singletonObjects 这个 ConcurrentHashMap 中并不存在该对象的实例。

本文转载自: 掘金

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

微服务架构初识-----Spring Cloud Aliba

发表于 2020-10-14

写在前面

学习技术的脚步永远都不能停歇,在我们接下来的一个项目当中,将会涉及到分布式的内容。在技术栈的选择时,我们大体上从两个角度去考量。一个是Dubbo+zookeeper,一个是Spring Cloud。

由于我们接下来的这个系统是一个异构的系统,后端的整体骨架还是由Java实现,但其中部分需要使用Python实现。为了方便服务调用,因此,我们选择了Spring Cloud。

而我之前并没有将Spring Cloud Netflix系统的(入门)学习一遍,因此,打算在接下来的时间里,系统的学习一下Spring Cloud Alibaba。

什么是微服务架构

面向服务的体系结构

说起微服务就不得不说起SOA。面向服务体系架构(Sercie-Oriented Architectuce,SOA)是一种使用Web服务构建分布式应用程序的方法。分布式系统由各个单机组件构成,它们基于XML标准的协议进行信息交换,而无需关注某一组件的实现细节(比如是用Python实现的还是Java实现的),从而做到了平台无关性。

一个简单的SOA系统由服务注册处、服务请求者和服务提供者组成

服务发现标准UDDI定义了服务描述组件,这种组件可用来发现服务是否存在。里面包含了服务提供者、所提供的服务、服务接口等。

简单对象访问协议SOAP是一种轻量的、简单的、基于XML的协议。它是一个支持服务之间通信的消息交换标准,定义服务之间消息传递的必需和可选组件。

其实,我们可以从SOA当中看到了微服务的雏形。因此,我认为微服务架构是在SOA的实践当中演化而来的一种架构模式。

那么,微服务架构体系是什么呢?

微服务架构定义

微服务架构是由Martin Fowler在2014年提出的。

微服务架构是一种架构模式,它提倡将单一应用程序划分为一组小的服务。服务之间相互协调、相互配合,为用户提供最终价值。每个服务运行在独立的进程中,服务与服务之间采用轻量级的通信机制相互协作(通常是基于http协议的rest ful,这非常有助于异构系统的实现)。每个服务都围绕着具体的业务去构建,并且能被独立地部署到生产环境、类生产环境等。另外,应当避免统一的、集中式的服务管理机制。对于一个具体的服务而言,应当根据其业务上下文,选择合适的语言和工具去构建。

微服务的技术维度

服务注册与发现

  1. 服务注册

服务实例将自身服务信息注册到注册中心。这部分服务信息包括服务所在主机IP和提供服务的Port,以及暴露服务自身状态以及访问协议等信息。比较流行的一种协议就是HTTP协议和RPC协议。
2. 服务发现

服务实例请求注册中心获取所依赖服务信息。服务实例通过注册中心,获取到注册到其中的服务实例的信息,通过这些信息去请求它们提供的服务。

服务调用

服务消费者根据服务注册中心的服务信息,通过通信机制调用远程服务的一个过程。

服务熔断

服务熔断一般是指软件系统中,由于某些原因使得服务出现了过载现象,为防止造成整个系统故障,从而采用的一种保护措施,所以很多地方把熔断亦称为过载保护。在一定时间内,如果服务提供方所提供的服务在调用时累计出现错误达到了设定的阈(yu,第四声)值,则直接返回失败提示,释放资源,不再远程调用。从而避免出现服务雪崩.

服务降级

这里有两种场景:

  1. 当下游的服务因为某种原因响应过慢,下游服务主动停掉一些不太重要的业务,释放出服务器资源,增加响应速度.
  2. 当下游的服务因为某种原因不可用,上游主动调用本地的一些降级逻辑,例如直接返回“服务繁忙,请稍后重试”的消息提示。

服务熔断是服务降级的一种方式。

负载均衡

对于同一个服务,可能存在多个服务实例。负载均衡采取一定的策略,对实例进行流量分发,从而提高系统的性能和可靠性。负载均衡本身也是一种服务。

服务消息队列

消息队列是一种异步的服务间通信方式,适用于无服务器和微服务架构。消息在被处理和删除之前一直存储在队列上。每条消息仅可被一位用户处理一次。消息队列可被用于分离重量级处理、缓冲或批处理工作以及缓解高峰期工作负载。

配置中心管理

它提供我们可以动态修改程序运行能力。将零散的配置文件集中化管理。

服务网关(包括流量网关和业务网关)

  1. 流量网关

流量网关负责全局性的流量控制,负责日志统计、防止SQL注入、脚本攻击、屏蔽工具扫描、黑白IP名单以及证书/加解密处理等功能。总的来说,它关注的是与系统业务无关的安全部分。
2. 业务网关

业务网关负责系统服务的健壮性。负责服务级别的流量控制、服务降级与熔断、路由与负载均衡、灰度策略、服务过滤、聚合与发现,负责授权与认证、业务规则与参数校验以及多级缓存策略。

服务监控

服务监控从整体上感知系统的运行状态,包括硬件和软件两部分内容。

全链路追踪

我们需要追踪每一个请求的完整调用链路,以便排查问题。

自动化构建部署

在微服务项目当中,模块众多,配置复杂,我们需要通过自动化构建和自动化部署工具来构建部署我们的项目。

服务定时任务调度

即使是在传统的整体架构的应用当中,也会存在定时任务需求。在微服务架构当中,便是分布式定时任务调用。一个典型而易理解的场景便是整点发放优惠券。

微服务特性

自主性

可以对微服务架构中的每个组件服务进行开发、部署、运营和扩展,而不影响其他服务的功能。这些服务不需要与其他服务共享任何代码或实施。各个组件之间的任何通信都是通过明确定义的 API 进行的。

专用性

每项服务都是针对一组功能而设计的,并专注于解决特定的问题。如果开发人员逐渐将更多代码增加到一项服务中并且这项服务变得复杂,那么可以将其拆分成多项更小的服务。

微服务的优势

敏捷性

微服务促进若干小型独立团队形成一个组织,这些团队负责自己的服务。各团队在小型且易于理解的环境中行事,并且可以更独立、更快速地工作。这缩短了开发周期时间。

灵活扩展

通过微服务,我们可以独立扩展各项服务以满足其支持的应用程序功能的需求。这使团队能够适当调整基础设施需求,准确衡量功能成本,并在服务需求激增时保持可用性。

轻松部署

微服务支持持续集成和持续交付,可以轻松尝试新想法,并可以在无法正常运行时回滚。由于故障成本较低,因此可以大胆试验,更轻松地更新代码,并缩短新功能的上市时间。

技术自由

微服务架构不遵循“一刀切”的方法。团队可以自由选择最佳工具来解决他们的具体问题。因此,构建微服务的团队可以为每项作业选择最佳工具。

现在一个微服务架构下的系统,可能其邮件通知部分是由Golang团队实现的,其商品推荐部分是由Python团队实现的,而其用户管理部分是由Java团队实现的。而对外而言,各个团队之间只需要遵循统一的标准去定义其对外暴露的接口就好,并不需要过多的关系其内部的实现细节。

Spring Cloud Alibaba简介

Spring Cloud Alibaba 致力于提供微服务开发的一站式解决方案。此项目包含开发分布式应用服务的必需组件,方便开发者通过 Spring Cloud 编程模型轻松使用这些组件来开发分布式应用服务。

目前 Spring Cloud Alibaba 提供了如下功能:

  1. 服务限流降级:支持 WebServlet、WebFlux, OpenFeign、RestTemplate、Dubbo 限流降级功能的接入,可以在运行时通过控制台实时修改限流降级规则,还支持查看限流降级 Metrics 监控。
  2. 服务注册与发现:适配 Spring Cloud 服务注册与发现标准,默认集成了 Ribbon 的支持。
  3. 分布式配置管理:支持分布式系统中的外部化配置,配置更改时自动刷新。
  4. Rpc服务:扩展 Spring Cloud 客户端 RestTemplate 和 OpenFeign,支持调用 Dubbo RPC 服务
  5. 消息驱动能力:基于 Spring Cloud Stream 为微服务应用构建消息驱动能力。
  6. 分布式事务:使用 @GlobalTransactional 注解, 高效并且对业务零侵入地解决分布式事务问题。
  7. 阿里云对象存储:阿里云提供的海量、安全、低成本、高可靠的云存储服务。支持在任何应用、任何时间、任何地点存储和访问任意类型的数据。
  8. 分布式任务调度:提供秒级、精准、高可靠、高可用的定时(基于 Cron 表达式)任务调度服务。同时提供分布式的任务执行模型,如网格任务。网格任务支持海量子任务均匀分配到所有 Worker(schedulerx-client)上执行。
  9. 阿里云短信服务:覆盖全球的短信服务,友好、高效、智能的互联化通讯能力,帮助企业迅速搭建客户触达通道。

如何使用

为了方便演示,我搭建了一个父工程,在这个系列的文章当中,所有演示代码都将围绕着这个父工程展开。文末提供这个demo的链接。

我们如果需要spring cloud alibaba这套解决方案的话,首先需要在项目的父工程引入对应的依赖

1
2
3
4
5
6
7
8
9
10
11
xml复制代码<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2.2.3.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

需要注意springCloud Alibaba与SpringBoot版本之间的对于关系

  • 1.5.x 版本适用于 Spring Boot 1.5.x
  • 2.0.x 版本适用于 Spring Boot 2.0.x
  • 2.1.x 版本适用于 Spring Boot 2.1.x
  • 2.2.x 版本适用于 Spring Boot 2.2.x

写在最后

这篇文章相当于Spring Cloud Alibaba的系列修仙功法的序言部分。在接下来的文章当中,我们将对Spring Cloud Alibaba当中的组件,逐一进行学习。当然,也会涉及到第三方的优秀组件。

如果您觉得这篇文章能给您带来帮助,那么可以点赞鼓励一下。如有错误之处,还请不吝赐教。在此,谢过各位乡亲父老!

本文Demo链接:github.com/code81192/a…

本文转载自: 掘金

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

Java虚拟机——JVM内存区域管理

发表于 2020-10-14

对于Java来说,在虚拟机自动内存管理机制的帮助下,不容易出现内存泄漏和内存溢出问题,看起来由虚拟机管理内存一切都很美好。不过,也正是因为Java程序员把控制内存的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题,那排查错误、修正问题将会成为一项异常艰难的工作。

运行时数据区域

JVM会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

程序计数器(Program Counter Register)

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。

此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈(Java Virtual Machine Stack)

Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个[栈帧(Stack Frame)](https://dev.newban.cn/6881882853458919437#heading-23)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。而“栈”通常就是指这里讲的虚拟机栈,或者更多的情况下只是指虚拟机栈中局部变量表部分。

在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:

1.如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;

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
php复制代码 /**
* 线程请求的栈深度大于虚拟机所允许的最大深度
* VM Args:-Xss128k
*/
static class JavaVMStackSOF {
private int stackLength = 1;

public void stackLeak() {
stackLength++;
stackLeak();
}

}
public static void main(String[] args) {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + oom.stackLength);
throw e;
}
}
------------------------------输出结果---------------------------------
stack length:2402
Exception in thread "main" java.lang.StackOverflowError
at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:20)
at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:21)
at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:21)
……后续异常堆栈信息省略

2.如果Java虚拟机栈容量可以动态扩展 ,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

本地方法栈(Native Method Stacks)

本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

Java堆(Java Heap)

  Java堆是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。 Java堆也被称作“GC堆”(Garbage Collected Heap)。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“From Survivor空 间”“To Survivor空间”等。 Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。


如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
arduino复制代码/**
* Java堆内存溢出异常测试
* VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while (true) {
list.add(new OOMObject());
}
}
}
--------------------------输出结果-----------------------------------------------
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid3404.hprof ...
Heap dump file created [22045981 bytes in 0.663 secs]

方法区(Method Area)

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。


 到了JDK 7的 HotSpot ,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了 JDK 8,终于完全废弃了永久代的概念,改用在本地内存中实现的元空间(Meta- space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError异常。

运行时常量池(Runtime Constant Pool)

运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是[常量池表(Constant Pool Table)](https://dev.newban.cn/6881882853458919437#heading-6)用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。


自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆之中。当常量池无法再申请到内存时会抛出OutOfMemoryError异常与Java堆异常信息一致。

HotSpot虚拟机对象探秘

1.对象的创建

当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的[类加载过程](https://dev.newban.cn/6882548162171518989)


在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那 个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“**指针碰撞**”(Bump The Pointer)。


 但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称 为“**空闲列表**”(Free List)。


 选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用 的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除 (Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。


对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种可选方案:
  • 一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;
  • 一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完 了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,通过**- XX:+/-UseTLAB** 参数来设定。
 内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。


接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的GC分代年龄等信息。


 从虚拟机的视角来看,一个新的对象产生了。但是从Java程序看来,对象创建才刚刚开始——构造函数,即Class文件中的init()方法还没有执行,所有的字段都为默认的零值。一般来说(由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行init()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

2.对象的内存布局

HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头(Header)

HotSpot虚拟机对象的对象头部分包括两类信息。


第一部分是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,官方称它 为“Mark Word”。这部分是实现**轻量级锁和偏向锁**的关键。

HotSpot虚拟机对象头Mark Word

由于对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到Java虚拟机的空间使用效率,Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间。对象除了未被锁定的正常状态外,还有轻量级锁定、重量级锁定、GC标记、可偏向等几种不同状态。

对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针。如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

实例数据(Instance Data)

实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

对齐填充(Padding)

对齐填充,不是必然存在的,起着占位符的作用。HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍。因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

3.对象的访问定位

在Java程序会通过栈上的reference数据来操作堆上的具体对象。虚拟机HotSpot使用的是直接指针访问:


 reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销。

通过直接指针访问对象

对象回收策略

  对面上面的Java内存区域中,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性, 在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。


Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

对象是否存活

 Java的内存管理子系统,通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过 一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,则证明此对象是不可能再被使用的。


对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的, 因此它们将会被判定为可回收的对象。

Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

对象引用

Java将引用分为\*\*强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)\*\*4种,这4种引用强度依次逐渐减弱。 
  • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。
  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

对象回收

 进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。 


 如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的 队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。 这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导 致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。


finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
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
csharp复制代码/**
* 此代码演示了两点:
* 1.对象可以在被GC时自我拯救。
* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes, i am still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
// 下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
}
}
-----------------------------------------输出结果---------------------------------
finalize method executed!
yes, i am still alive :)
no, i am dead :(

Java虚拟机的内存管理与垃圾收集是虚拟机结构体系中最重要的组成部分,对程序的性能和稳定有着非常大的影响。

参考

深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)

本文转载自: 掘金

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

Spring Boot 第十一弹,这类注解都不知道,还好意思

发表于 2020-10-14

前言

Spring Boot专栏文章+1

不知道大家在使用Spring Boot开发的日常中有没有用过@Conditionalxxx注解,比如@ConditionalOnMissingBean。相信看过Spring Boot源码的朋友一定不陌生。

@Conditionalxxx这类注解表示某种判断条件成立时才会执行相关操作。掌握该类注解,有助于日常开发,框架的搭建。

今天这篇文章就从前世今生介绍一下该类注解。

Spring Boot 版本

本文基于的Spring Boot的版本是2.3.4.RELEASE。

@Conditional

@Conditional注解是从Spring4.0才有的,可以用在任何类型或者方法上面,通过@Conditional注解可以配置一些条件判断,当所有条件都满足的时候,被@Conditional标注的目标才会被Spring容器处理。

@Conditional的使用很广,比如控制某个Bean是否需要注册,在Spring Boot中的变形很多,比如@ConditionalOnMissingBean、@ConditionalOnBean等等,如下:

该注解的源码其实很简单,只有一个属性value,表示判断的条件(一个或者多个),是org.springframework.context.annotation.Condition类型,源码如下:

1
2
3
4
5
6
7
8
9
10
11
java复制代码@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Conditional {

/**
* All {@link Condition} classes that must {@linkplain Condition#matches match}
* in order for the component to be registered.
*/
Class<? extends Condition>[] value();
}

@Conditional注解实现的原理很简单,就是通过org.springframework.context.annotation.Condition这个接口判断是否应该执行操作。

Condition接口

@Conditional注解判断条件与否取决于value属性指定的Condition实现,其中有一个matches()方法,返回true表示条件成立,反之不成立,接口如下:

1
2
3
4
java复制代码@FunctionalInterface
public interface Condition {
boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata);
}

matches中的两个参数如下:

  1. context:条件上下文,ConditionContext接口类型的,可以用来获取容器中上下文信息。
  2. metadata:用来获取被@Conditional标注的对象上的所有注解信息

ConditionContext接口

这个接口很重要,能够从中获取Spring上下文的很多信息,比如ConfigurableListableBeanFactory,源码如下:

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
java复制代码public interface ConditionContext {

/**
* 返回bean定义注册器,可以通过注册器获取bean定义的各种配置信息
*/
BeanDefinitionRegistry getRegistry();

/**
* 返回ConfigurableListableBeanFactory类型的bean工厂,相当于一个ioc容器对象
*/
@Nullable
ConfigurableListableBeanFactory getBeanFactory();

/**
* 返回当前spring容器的环境配置信息对象
*/
Environment getEnvironment();

/**
* 返回资源加载器
*/
ResourceLoader getResourceLoader();

/**
* 返回类加载器
*/
@Nullable
ClassLoader getClassLoader();
}

如何自定义Condition?

举个栗子:假设有这样一个需求,需要根据运行环境注入不同的Bean,Windows环境和Linux环境注入不同的Bean。

实现很简单,分别定义不同环境的判断条件,实现org.springframework.context.annotation.Condition即可。

windows环境的判断条件源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码/**
* 操作系统的匹配条件,如果是windows系统,则返回true
*/
public class WindowsCondition implements Condition {
@Override
public boolean matches(ConditionContext conditionContext, AnnotatedTypeMetadata metadata) {
//获取当前环境信息
Environment environment = conditionContext.getEnvironment();
//获得当前系统名
String property = environment.getProperty("os.name");
//包含Windows则说明是windows系统,返回true
if (property.contains("Windows")){
return true;
}
return false;

}
}

Linux环境判断源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码/**
* 操作系统的匹配条件,如果是windows系统,则返回true
*/
public class LinuxCondition implements Condition {
@Override
public boolean matches(ConditionContext conditionContext, AnnotatedTypeMetadata metadata) {
Environment environment = conditionContext.getEnvironment();

String property = environment.getProperty("os.name");
if (property.contains("Linux")){
return true;
}
return false;

}
}

配置类中结合@Bean注入不同的Bean,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码@Configuration
public class CustomConfig {

/**
* 在Windows环境下注入的Bean为winP
* @return
*/
@Bean("winP")
@Conditional(value = {WindowsCondition.class})
public Person personWin(){
return new Person();
}

/**
* 在Linux环境下注入的Bean为LinuxP
* @return
*/
@Bean("LinuxP")
@Conditional(value = {LinuxCondition.class})
public Person personLinux(){
return new Person();
}

简单的测试一下,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码@SpringBootTest
class SpringbootInterceptApplicationTests {

@Autowired(required = false)
@Qualifier(value = "winP")
private Person winP;

@Autowired(required = false)
@Qualifier(value = "LinuxP")
private Person linP;

@Test
void contextLoads() {
System.out.println(winP);
System.out.println(linP);
}
}

Windows环境下执行单元测试,输出如下:

1
2
java复制代码com.example.springbootintercept.domain.Person@885e7ff
null

很显然,判断生效了,Windows环境下只注入了WINP。

条件判断在什么时候执行?

条件判断的执行分为两个阶段,如下:

  1. 配置类解析阶段(ConfigurationPhase.PARSE_CONFIGURATION):在这个阶段会得到一批配置类的信息和一些需要注册的Bean。
  2. Bean注册阶段(ConfigurationPhase.REGISTER_BEAN):将配置类解析阶段得到的配置类和需要注册的Bean注入到容器中。

默认都是配置解析阶段,其实也就够用了,但是在Spring Boot中使用了ConfigurationCondition,这个接口可以自定义执行阶段,比如@ConditionalOnMissingBean都是在Bean注册阶段执行,因为需要从容器中判断Bean。

这个两个阶段有什么不同呢?:其实很简单的,配置类解析阶段只是将需要加载配置类和一些Bean(被@Conditional注解过滤掉之后)收集起来,而Bean注册阶段是将的收集来的Bean和配置类注入到容器中,如果在配置类解析阶段执行Condition接口的matches()接口去判断某些Bean是否存在IOC容器中,这个显然是不行的,因为这些Bean还未注册到容器中。

什么是配置类,有哪些?:类上被@Component、 @ComponentScan、@Import、@ImportResource、@Configuration标注的以及类中方法有@Bean的方法。如何判断配置类,在源码中有单独的方法:org.springframework.context.annotation.ConfigurationClassUtils#isConfigurationCandidate。

ConfigurationCondition接口

这个接口相比于@Condition接口就多了一个getConfigurationPhase()方法,可以自定义执行阶段。源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码public interface ConfigurationCondition extends Condition {

/**
* 条件判断的阶段,是在解析配置类的时候过滤还是在创建bean的时候过滤
*/
ConfigurationPhase getConfigurationPhase();


/**
* 表示阶段的枚举:2个值
*/
enum ConfigurationPhase {

/**
* 配置类解析阶段,如果条件为false,配置类将不会被解析
*/
PARSE_CONFIGURATION,

/**
* bean注册阶段,如果为false,bean将不会被注册
*/
REGISTER_BEAN
}
}

这个接口在需要指定执行阶段的时候可以实现,比如需要根据某个Bean是否在IOC容器中来注入指定的Bean,则需要指定执行阶段为Bean的注册阶段(ConfigurationPhase.REGISTER_BEAN)。

多个Condition的执行顺序

@Conditional中的Condition判断条件可以指定多个,默认是按照先后顺序执行,如下:

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
java复制代码class Condition1 implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
System.out.println(this.getClass().getName());
return true;
}
}

class Condition2 implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
System.out.println(this.getClass().getName());
return true;
}
}

class Condition3 implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
System.out.println(this.getClass().getName());
return true;
}
}

@Configuration
@Conditional({Condition1.class, Condition2.class, Condition3.class})
public class MainConfig5 {
}

上述例子会依次按照Condition1、Condition2、Condition3执行。

默认按照先后顺序执行,但是当我们需要指定顺序呢?很简单,有如下三种方式:

  1. 实现PriorityOrdered接口,指定优先级
  2. 实现Ordered接口接口,指定优先级
  3. 使用@Order注解来指定优先级

例子如下:

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
java复制代码@Order(1) 
class Condition1 implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
System.out.println(this.getClass().getName());
return true;
}
}

class Condition2 implements Condition, Ordered {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
System.out.println(this.getClass().getName());
return true;
}

@Override
public int getOrder() {
return 0;
}
}

class Condition3 implements Condition, PriorityOrdered {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
System.out.println(this.getClass().getName());
return true;
}

@Override
public int getOrder() {
return 1000;
}
}

@Configuration
@Conditional({Condition1.class, Condition2.class, Condition3.class})
public class MainConfig6 {
}

根据排序的规则,PriorityOrdered的会排在前面,然后会再按照order升序,最后可以顺序是:Condtion3->Condtion2->Condtion1

Spring Boot中常用的一些注解

Spring Boot中大量使用了这些注解,常见的注解如下:

  1. @ConditionalOnBean:当容器中有指定Bean的条件下进行实例化。
  2. @ConditionalOnMissingBean:当容器里没有指定Bean的条件下进行实例化。
  3. @ConditionalOnClass:当classpath类路径下有指定类的条件下进行实例化。
  4. @ConditionalOnMissingClass:当类路径下没有指定类的条件下进行实例化。
  5. @ConditionalOnWebApplication:当项目是一个Web项目时进行实例化。
  6. @ConditionalOnNotWebApplication:当项目不是一个Web项目时进行实例化。
  7. @ConditionalOnProperty:当指定的属性有指定的值时进行实例化。
  8. @ConditionalOnExpression:基于SpEL表达式的条件判断。
  9. @ConditionalOnJava:当JVM版本为指定的版本范围时触发实例化。
  10. @ConditionalOnResource:当类路径下有指定的资源时触发实例化。
  11. @ConditionalOnJndi:在JNDI存在的条件下触发实例化。
  12. @ConditionalOnSingleCandidate:当指定的Bean在容器中只有一个,或者有多个但是指定了首选的Bean时触发实例化。

比如在WEB模块的自动配置类WebMvcAutoConfiguration下有这样一段代码:

1
2
3
4
5
6
7
8
java复制代码    @Bean
@ConditionalOnMissingBean
public InternalResourceViewResolver defaultViewResolver() {
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setPrefix(this.mvcProperties.getView().getPrefix());
resolver.setSuffix(this.mvcProperties.getView().getSuffix());
return resolver;
}

常见的@Bean和@ConditionalOnMissingBean注解结合使用,意思是当容器中没有InternalResourceViewResolver这种类型的Bean才会注入。这样写有什么好处呢?好处很明显,可以让开发者自定义需要的视图解析器,如果没有自定义,则使用默认的,这就是Spring Boot为自定义配置提供的便利。

总结

@Conditional注解在Spring Boot中演变的注解很多,需要着重了解,特别是后期框架整合的时候会大量涉及。

本文转载自: 掘金

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

RabbitMQ实现即时通讯居然如此简单!连后端代码都省得写

发表于 2020-10-14

SpringBoot实战电商项目mall(40k+star)地址:github.com/macrozheng/…

摘要

有时候我们的项目中会用到即时通讯功能,比如电商系统中的客服聊天功能,还有在支付过程中,当用户支付成功后,第三方支付服务会回调我们的回调接口,此时我们需要通知前端支付成功。最近发现RabbitMQ可以很方便的实现即时通讯功能,如果你没有特殊的业务需求,甚至可以不写后端代码,今天给大家讲讲如何使用RabbitMQ来实现即时通讯!

MQTT协议

MQTT(Message Queuing Telemetry Transport,消息队列遥测传输协议),是一种基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,该协议构建于TCP/IP协议上。MQTT最大优点在于,可以以极少的代码和有限的带宽,为连接远程设备提供实时可靠的消息服务。

MQTT相关概念

  • Publisher(发布者):消息的发出者,负责发送消息。
  • Subscriber(订阅者):消息的订阅者,负责接收并处理消息。
  • Broker(代理):消息代理,位于消息发布者和订阅者之间,各类支持MQTT协议的消息中间件都可以充当。
  • Topic(主题):可以理解为消息队列中的路由,订阅者订阅了主题之后,就可以收到发送到该主题的消息。
  • Payload(负载);可以理解为发送消息的内容。
  • QoS(消息质量):全称Quality of Service,即消息的发送质量,主要有QoS 0、QoS 1、QoS 2三个等级,下面分别介绍下:
    • QoS 0(Almost Once):至多一次,只发送一次,会发生消息丢失或重复;
    • QoS 1(Atleast Once):至少一次,确保消息到达,但消息重复可能会发生;
    • QoS 2(Exactly Once):只有一次,确保消息只到达一次。

RabbitMQ启用MQTT功能

RabbitMQ启用MQTT功能,需要先安装然RabbitMQ然后再启用MQTT插件。

  • 首先我们需要安装并启动RabbitMQ,对RabbitMQ不了解的朋友可以参考《花了3天总结的RabbitMQ实用技巧,有点东西!》;
  • 接下来就是启用RabbitMQ的MQTT插件了,默认是不启用的,使用如下命令开启即可;
1
bash复制代码rabbitmq-plugins enable rabbitmq_mqtt
  • 开启成功后,查看管理控制台,我们可以发现MQTT服务运行在1883端口上了。

MQTT客户端

我们可以使用MQTT客户端来测试MQTT的即时通讯功能,这里使用的是MQTTBox这个客户端工具。

  • 首先下载并安装好MQTTBox,下载地址:workswithweb.com/mqttbox.htm…

  • 点击Create MQTT Client按钮来创建一个MQTT客户端;

  • 接下来对MQTT客户端进行配置,主要是配置好协议端口、连接用户名密码和QoS即可;

  • 再配置一个订阅者,订阅者订阅testTopicA这个主题,我们会向这个主题发送消息;

  • 发布者向主题中发布消息,订阅者可以实时接收到。

前端直接实现即时通讯

既然MQTTBox客户端可以直接通过RabbitMQ实现即时通讯,那我们是不是直接使用前端技术也可以实现即时通讯?答案是肯定的!下面我们将通过html+javascript实现一个简单的聊天功能,真正不写一行后端代码实现即时通讯!

  • 由于RabbitMQ与Web端交互底层使用的是WebSocket,所以我们需要开启RabbitMQ的MQTT WEB支持,使用如下命令开启即可;
1
bash复制代码rabbitmq-plugins enable rabbitmq_web_mqtt
  • 开启成功后,查看管理控制台,我们可以发现MQTT的WEB服务运行在15675端口上了;

  • WEB端与MQTT服务进行通讯需要使用一个叫MQTT.js的库,项目地址:github.com/mqttjs/MQTT…

  • 实现的功能非常简单,一个单聊功能,需要注意的是配置好MQTT服务的访问地址为:ws://localhost:15675/ws
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
html复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div>
<label>目标Topic:<input id="targetTopicInput" type="text"></label><br>
<label>发送消息:<input id="messageInput" type="text"></label><br>
<button onclick="sendMessage()">发送</button>
<button onclick="clearMessage()">清空</button>
<div id="messageDiv"></div>
</div>
</body>
<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
<script>
//RabbitMQ的web-mqtt连接地址
const url = 'ws://localhost:15675/ws';
//获取订阅的topic
const topic = getQueryString("topic");
//连接到消息队列
let client = mqtt.connect(url);
client.on('connect', function () {
//连接成功后订阅topic
client.subscribe(topic, function (err) {
if (!err) {
showMessage("订阅topic:" + topic + "成功!");
}
});
});
//获取订阅topic中的消息
client.on('message', function (topic, message) {
showMessage("收到消息:" + message.toString());
});

//发送消息
function sendMessage() {
let targetTopic = document.getElementById("targetTopicInput").value;
let message = document.getElementById("messageInput").value;
//向目标topic中发送消息
client.publish(targetTopic, message);
showMessage("发送消息给" + targetTopic + "的消息:" + message);
}

//从URL中获取参数
function getQueryString(name) {
let reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)", "i");
let r = window.location.search.substr(1).match(reg);
if (r != null) {
return decodeURIComponent(r[2]);
}
return null;
}

//在消息列表中展示消息
function showMessage(message) {
let messageDiv = document.getElementById("messageDiv");
let messageEle = document.createElement("div");
messageEle.innerText = message;
messageDiv.appendChild(messageEle);
}

//清空消息列表
function clearMessage() {
let messageDiv = document.getElementById("messageDiv");
messageDiv.innerHTML = "";
}
</script>
</html>
  • 接下来我们订阅不同的主题开启两个页面测试下功能(页面放在了SpringBoot应用的resource目录下了,需要先启动应用再访问):
+ 第一个订阅主题`testTopicA`,访问地址:<http://localhost:8088/page/index?topic=testTopicA>
+ 第二个订阅主题`testTopicB`,访问地址:<http://localhost:8088/page/index?topic=testTopicB>
  • 之后互相发送消息,让我们来看看效果吧!

在SpringBoot中使用

没有特殊业务需求的时候,前端可以直接和RabbitMQ对接实现即时通讯。但是有时候我们需要通过服务端去通知前端,此时就需要在应用中集成MQTT了,接下来我们来讲讲如何在SpringBoot应用中使用MQTT。

  • 首先我们需要在pom.xml中添加MQTT相关依赖;
1
2
3
4
5
xml复制代码<!--Spring集成MQTT-->
<dependency>
<groupId>org.springframework.integration</groupId>
<artifactId>spring-integration-mqtt</artifactId>
</dependency>
  • 在application.yml中添加MQTT相关配置,主要是访问地址、用户名密码、默认主题信息;
1
2
3
4
5
6
yaml复制代码rabbitmq:
mqtt:
url: tcp://localhost:1883
username: guest
password: guest
defaultTopic: testTopic
  • 编写一个Java配置类从配置文件中读取配置便于使用;
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复制代码/**
* MQTT相关配置
* Created by macro on 2020/9/15.
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Component
@ConfigurationProperties(prefix = "rabbitmq.mqtt")
public class MqttConfig {
/**
* RabbitMQ连接用户名
*/
private String username;
/**
* RabbitMQ连接密码
*/
private String password;
/**
* RabbitMQ的MQTT默认topic
*/
private String defaultTopic;
/**
* RabbitMQ的MQTT连接地址
*/
private String url;
}
  • 添加MQTT消息订阅者相关配置,使用@ServiceActivator注解声明一个服务激活器,通过MessageHandler来处理订阅消息;
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
java复制代码/**
* MQTT消息订阅者相关配置
* Created by macro on 2020/9/15.
*/
@Slf4j
@Configuration
public class MqttInboundConfig {
@Autowired
private MqttConfig mqttConfig;

@Bean
public MessageChannel mqttInputChannel() {
return new DirectChannel();
}

@Bean
public MessageProducer inbound() {
MqttPahoMessageDrivenChannelAdapter adapter =
new MqttPahoMessageDrivenChannelAdapter(mqttConfig.getUrl(), "subscriberClient",
mqttConfig.getDefaultTopic());
adapter.setCompletionTimeout(5000);
adapter.setConverter(new DefaultPahoMessageConverter());
//设置消息质量:0->至多一次;1->至少一次;2->只有一次
adapter.setQos(1);
adapter.setOutputChannel(mqttInputChannel());
return adapter;
}

@Bean
@ServiceActivator(inputChannel = "mqttInputChannel")
public MessageHandler handler() {
return new MessageHandler() {

@Override
public void handleMessage(Message<?> message) throws MessagingException {
//处理订阅消息
log.info("handleMessage : {}",message.getPayload());
}

};
}
}
  • 添加MQTT消息发布者相关配置;
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
java复制代码/**
* MQTT消息发布者相关配置
* Created by macro on 2020/9/15.
*/
@Configuration
public class MqttOutboundConfig {

@Autowired
private MqttConfig mqttConfig;

@Bean
public MqttPahoClientFactory mqttClientFactory() {
DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory();
MqttConnectOptions options = new MqttConnectOptions();
options.setServerURIs(new String[] { mqttConfig.getUrl()});
options.setUserName(mqttConfig.getUsername());
options.setPassword(mqttConfig.getPassword().toCharArray());
factory.setConnectionOptions(options);
return factory;
}

@Bean
@ServiceActivator(inputChannel = "mqttOutboundChannel")
public MessageHandler mqttOutbound() {
MqttPahoMessageHandler messageHandler =
new MqttPahoMessageHandler("publisherClient", mqttClientFactory());
messageHandler.setAsync(true);
messageHandler.setDefaultTopic(mqttConfig.getDefaultTopic());
return messageHandler;
}

@Bean
public MessageChannel mqttOutboundChannel() {
return new DirectChannel();
}
}
  • 添加MQTT网关,用于向主题中发送消息;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码/**
* MQTT网关,通过接口将数据传递到集成流
* Created by macro on 2020/9/15.
*/
@Component
@MessagingGateway(defaultRequestChannel = "mqttOutboundChannel")
public interface MqttGateway {
/**
* 发送消息到默认topic
*/
void sendToMqtt(String payload);

/**
* 发送消息到指定topic
*/
void sendToMqtt(String payload, @Header(MqttHeaders.TOPIC) String topic);

/**
* 发送消息到指定topic并设置QOS
*/
void sendToMqtt(@Header(MqttHeaders.TOPIC) String topic, @Header(MqttHeaders.QOS) int qos, String payload);
}
  • 添加MQTT测试接口,使用MQTT网关向特定主题中发送消息;
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复制代码/**
* MQTT测试接口
* Created by macro on 2020/9/15.
*/
@Api(tags = "MqttController", description = "MQTT测试接口")
@RestController
@RequestMapping("/mqtt")
public class MqttController {

@Autowired
private MqttGateway mqttGateway;

@PostMapping("/sendToDefaultTopic")
@ApiOperation("向默认主题发送消息")
public CommonResult sendToDefaultTopic(String payload) {
mqttGateway.sendToMqtt(payload);
return CommonResult.success(null);
}

@PostMapping("/sendToTopic")
@ApiOperation("向指定主题发送消息")
public CommonResult sendToTopic(String payload, String topic) {
mqttGateway.sendToMqtt(payload, topic);
return CommonResult.success(null);
}
}
  • 调用接口向主题中发送消息进行测试;

  • 后台成功接收到消息并进行打印。
1
2
3
bash复制代码2020-09-17 14:29:01.689  INFO 11192 --- [ubscriberClient] c.m.mall.tiny.config.MqttInboundConfig   : handleMessage : 来自网页上的消息
2020-09-17 14:29:06.101 INFO 11192 --- [ubscriberClient] c.m.mall.tiny.config.MqttInboundConfig : handleMessage : 来自网页上的消息
2020-09-17 14:29:07.384 INFO 11192 --- [ubscriberClient] c.m.mall.tiny.config.MqttInboundConfig : handleMessage : 来自网页上的消息

总结

消息中间件应用越来越广泛,不仅可以实现可靠的异步通信,还可以实现即时通讯,掌握一个消息中间件还是很有必要的。如果没有特殊业务需求,客户端或者前端直接使用MQTT对接消息中间件即可实现即时通讯,有特殊需求的时候也可以使用SpringBoot集成MQTT的方式来实现,总之消息中间件是实现即时通讯的一个好选择!

项目源码地址

github.com/macrozheng/…

本文 GitHub github.com/macrozheng/… 已经收录,欢迎大家Star!

本文转载自: 掘金

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

打工四年总结的数据库知识点

发表于 2020-10-14

有情怀,有干货,微信搜索【三太子敖丙】关注这个不一样的程序员。

本文 GitHub github.com/JavaFamily 已收录,有一线大厂面试完整考点、资料以及我的系列文章。

国庆在家无聊,我随手翻了一下家里数据库相关的书籍,这一翻我就看上瘾了,因为大学比较熟悉的一些数据库范式我居然都忘了,怀揣着好奇心我就看了一个小国庆。

看的过程中我也做了一些小笔记,可能没我之前系统文章那么有趣,但是绝对也是干货十足,适合大家去回顾或者面试突击的适合看看,也不多说先放图。

存储引擎

InnoDB

InnoDB 是 MySQL 默认的事务型存储引擎,只要在需要它不支持的特性时,才考虑使用其他存储引擎。

InnoDB 采用 MVCC 来支持高并发,并且实现了四个标准隔离级别(未提交读、提交读、可重复读、可串行化)。其默认级别时可重复读(REPEATABLE READ),在可重复读级别下,通过 MVCC + Next-Key Locking 防止幻读。

主索引时聚簇索引,在索引中保存了数据,从而避免直接读取磁盘,因此对主键查询有很高的性能。

InnoDB 内部做了很多优化,包括从磁盘读取数据时采用的可预测性读,能够自动在内存中创建 hash 索引以加速读操作的自适应哈希索引,以及能够加速插入操作的插入缓冲区等。

InnoDB 支持真正的在线热备份,MySQL 其他的存储引擎不支持在线热备份,要获取一致性视图需要停止对所有表的写入,而在读写混合的场景中,停止写入可能也意味着停止读取。

MyISAM

设计简单,数据以紧密格式存储。对于只读数据,或者表比较小、可以容忍修复操作,则依然可以使用它。

提供了大量的特性,包括压缩表、空间数据索引等。

不支持事务。

不支持行级锁,只能对整张表加锁,读取时会对需要读到的所有表加共享锁,写入时则对表加排它锁。但在表有读取操作的同时,也可以往表中插入新的记录,这被称为并发插入(CONCURRENT INSERT)。

可以手工或者自动执行检查和修复操作,但是和事务恢复以及崩溃恢复不同,可能导致一些数据丢失,而且修复操作是非常慢的。

如果指定了 DELAY_KEY_WRITE 选项,在每次修改执行完成时,不会立即将修改的索引数据写入磁盘,而是会写到内存中的键缓冲区,只有在清理键缓冲区或者关闭表的时候才会将对应的索引块写入磁盘。这种方式可以极大的提升写入性能,但是在数据库或者主机崩溃时会造成索引损坏,需要执行修复操作。

InnoDB 和 MyISAM 的比较

  • 事务:InnoDB 是事务型的,可以使用 Commit 和 Rollback 语句。
  • 并发:MyISAM 只支持表级锁,而 InnoDB 还支持行级锁。
  • 外键:InnoDB 支持外键。
  • 备份:InnoDB 支持在线热备份。
  • 崩溃恢复:MyISAM 崩溃后发生损坏的概率比 InnoDB 高很多,而且恢复的速度也更慢。
  • 其它特性:MyISAM 支持压缩表和空间数据索引。

索引

B+ Tree 原理

数据结构

B Tree 指的是 Balance Tree,也就是平衡树,平衡树是一颗查找树,并且所有叶子节点位于同一层。

B+ Tree 是 B 树的一种变形,它是基于 B Tree 和叶子节点顺序访问指针进行实现,通常用于数据库和操作系统的文件系统中。

B+ 树有两种类型的节点:内部节点(也称索引节点)和叶子节点,内部节点就是非叶子节点,内部节点不存储数据,只存储索引,数据都存在叶子节点。

内部节点中的 key 都按照从小到大的顺序排列,对于内部节点中的一个 key,左子树中的所有 key 都小于它,右子树中的 key 都大于等于它,叶子节点的记录也是按照从小到大排列的。

每个叶子节点都存有相邻叶子节点的指针。

操作

查找

查找以典型的方式进行,类似于二叉查找树。起始于根节点,自顶向下遍历树,选择其分离值在要查找值的任意一边的子指针。在节点内部典型的使用是二分查找来确定这个位置。

插入

  • Perform a search to determine what bucket the new record should go into.
  • If the bucket is not full(a most b - 1 entries after the insertion,b 是节点中的元素个数,一般是页的整数倍),add tht record.
  • Otherwise,before inserting the new record
+ split the bucket.
    - original node has 「(L+1)/2」items
    - new node has 「(L+1)/2」items
+ Move 「(L+1)/2」-th key to the parent,and insert the new node to the parent.
+ Repeat until a parent is found that need not split.
  • If the root splits,treat it as if it has an empty parent ans split as outline above.

B-trees grow as the root and not at the leaves.

删除

和插入类似,只不过是自下而上的合并操作。

树的常见特性

AVL 树

平衡二叉树,一般是用平衡因子差值决定并通过旋转来实现,左右子树树高差不超过1,那么和红黑树比较它是严格的平衡二叉树,平衡条件非常严格(树高差只有1),只要插入或删除不满足上面的条件就要通过旋转来保持平衡。由于旋转是非常耗费时间的。所以 AVL 树适用于插入/删除次数比较少,但查找多的场景。

红黑树

通过对从根节点到叶子节点路径上各个节点的颜色进行约束,确保没有一条路径会比其他路径长2倍,因而是近似平衡的。所以相对于严格要求平衡的AVL树来说,它的旋转保持平衡次数较少。适合,查找少,插入/删除次数多的场景。(现在部分场景使用跳表来替换红黑树,可搜索“为啥 redis 使用跳表(skiplist)而不是使用 red-black?”)

B/B+ 树

多路查找树,出度高,磁盘IO低,一般用于数据库系统中。

B + 树与红黑树的比较

红黑树等平衡树也可以用来实现索引,但是文件系统及数据库系统普遍采用 B+ Tree 作为索引结构,主要有以下两个原因:

(一)磁盘 IO 次数

B+ 树一个节点可以存储多个元素,相对于红黑树的树高更低,磁盘 IO 次数更少。

(二)磁盘预读特性

为了减少磁盘 I/O 操作,磁盘往往不是严格按需读取,而是每次都会预读。预读过程中,磁盘进行顺序读取,顺序读取不需要进行磁盘寻道。每次会读取页的整数倍。

操作系统一般将内存和磁盘分割成固定大小的块,每一块称为一页,内存与磁盘以页为单位交换数据。数据库系统将索引的一个节点的大小设置为页的大小,使得一次 I/O 就能完全载入一个节点。

B + 树与 B 树的比较

B+ 树的磁盘 IO 更低

B+ 树的内部节点并没有指向关键字具体信息的指针。因此其内部节点相对 B 树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了。

B+ 树的查询效率更加稳定

由于非叶子结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。

B+ 树元素遍历效率高

B 树在提高了磁盘IO性能的同时并没有解决元素遍历的效率低下的问题。正是为了解决这个问题,B+树应运而生。B+树只要遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而 B 树不支持这样的操作(或者说效率太低)。

MySQL 索引

索引是在存储引擎层实现的,而不是在服务器层实现的,所以不同存储引擎具有不同的索引类型和实现。

B+ Tree 索引

是大多数 MySQL 存储引擎的默认索引类型。

  • 因为不再需要进行全表扫描,只需要对树进行搜索即可,所以查找速度快很多。
  • 因为 B+ Tree 的有序性,所以除了用于查找,还可以用于排序和分组。
  • 可以指定多个列作为索引列,多个索引列共同组成键。
  • 适用于全键值、键值范围和键前缀查找,其中键前缀查找只适用于最左前缀查找。如果不是按照索引列的顺序进行查找,则无法使用索引。

InnoDB 的 B+Tree 索引分为主索引和辅助索引。主索引的叶子节点 data 域记录着完整的数据记录,这种索引方式被称为聚簇索引。因为无法把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索引。

辅助索引的叶子节点的 data 域记录着主键的值,因此在使用辅助索引进行查找时,需要先查找到主键值,然后再到主索引中进行查找,这个过程也被称作回表。

哈希索引

哈希索引能以 O(1) 时间进行查找,但是失去了有序性:

  • 无法用于排序与分组;
  • 只支持精确查找,无法用于部分查找和范围查找。

InnoDB 存储引擎有一个特殊的功能叫“自适应哈希索引”,当某个索引值被使用的非常频繁时,会在 B+Tree 索引之上再创建一个哈希索引,这样就让 B+Tree 索引具有哈希索引的一些优点,比如快速的哈希查找。

全文索引

MyISAM 存储引擎支持全文索引,用于查找文本中的关键词,而不是直接比较是否相等。

查找条件使用 MATCH AGAINST,而不是普通的 WHERE。

全文索引使用倒排索引实现,它记录着关键词到其所在文档的映射。

InnoDB 存储引擎在 MySQL 5.6.4 版本中也开始支持全文索引。

空间数据索引

MyISAM 存储引擎支持空间数据索引(R-Tree),可以用于地理数据存储。空间数据索引会从所有维度来索引数据,可以有效地使用任意维度来进行组合查询。

必须使用 GIS 相关的函数来维护数据。

索引优化

独立的列

在进行查询时,索引列不能是表达式的一部分,也不能是函数的参数,否则无法使用索引。

例如下面的查询不能使用 actor_id 列的索引:

1
sql复制代码SELECT actor_id FROM sakila.actor WHERE actor_id + 1 = 5;

多列索引

在需要使用多个列作为条件进行查询时,使用多列索引比使用多个单列索引性能更好。例如下面的语句中,最好把 actor_id 和 film_id 设置为多列索引。

1
2
sql复制代码SELECT film_id, actor_ id FROM sakila.film_actor
WHERE actor_id = 1 AND film_id = 1;

索引列的顺序

让选择性最强的索引列放在前面。

索引的选择性是指:不重复的索引值和记录总数的比值。最大值为 1,此时每个记录都有唯一的索引与其对应。选择性越高,每个记录的区分度越高,查询效率也越高。

例如下面显示的结果中 customer_id 的选择性比 staff_id 更高,因此最好把 customer_id 列放在多列索引的前面。

1
2
3
4
sql复制代码SELECT COUNT(DISTINCT staff_id)/COUNT(*) AS staff_id_selectivity,
COUNT(DISTINCT customer_id)/COUNT(*) AS customer_id_selectivity,
COUNT(*)
FROM payment;
1
2
3
html复制代码   staff_id_selectivity: 0.0001
customer_id_selectivity: 0.0373
COUNT(*): 16049

前缀索引

对于 BLOB、TEXT 和 VARCHAR 类型的列,必须使用前缀索引,只索引开始的部分字符。

前缀长度的选取需要根据索引选择性来确定。

覆盖索引

索引包含所有需要查询的字段的值。

具有以下优点:

  • 索引通常远小于数据行的大小,只读取索引能大大减少数据访问量。
  • 一些存储引擎(例如 MyISAM)在内存中只缓存索引,而数据依赖于操作系统来缓存。因此,只访问索引可以不使用系统调用(通常比较费时)。
  • 对于 InnoDB 引擎,若辅助索引能够覆盖查询,则无需访问主索引。

索引的优点

  • 大大减少了服务器需要扫描的数据行数。
  • 帮助服务器避免进行排序和分组,以及避免创建临时表(B+Tree 索引是有序的,可以用于 ORDER BY 和 GROUP BY 操作。临时表主要是在排序和分组过程中创建,不需要排序和分组,也就不需要创建临时表)。
  • 将随机 I/O 变为顺序 I/O(B+Tree 索引是有序的,会将相邻的数据都存储在一起)。

索引的使用条件

  • 对于非常小的表、大部分情况下简单的全表扫描比建立索引更高效;
  • 对于中到大型的表,索引就非常有效;
  • 但是对于特大型的表,建立和维护索引的代价将会随之增长。这种情况下,需要用到一种技术可以直接区分出需要查询的一组数据,而不是一条记录一条记录地匹配,例如可以使用分区技术。

为什么对于非常小的表,大部分情况下简单的全表扫描比建立索引更高效?

如果一个表比较小,那么显然直接遍历表比走索引要快(因为需要回表)。

注:首先,要注意这个答案隐含的条件是查询的数据不是索引的构成部分,否也不需要回表操作。其次,查询条件也不是主键,否则可以直接从聚簇索引中拿到数据。

查询性能优化

使用 explain 分析 select 查询语句

explain 用来分析 SELECT 查询语句,开发人员可以通过分析 Explain 结果来优化查询语句。

select_type

常用的有 SIMPLE 简单查询,UNION 联合查询,SUBQUERY 子查询等。

table

要查询的表

possible_keys

The possible indexes to choose

可选择的索引

key

The index actually chosen

实际使用的索引

rows

Estimate of rows to be examined

扫描的行数

type

索引查询类型,经常用到的索引查询类型:

const:使用主键或者唯一索引进行查询的时候只有一行匹配
ref:使用非唯一索引
range:使用主键、单个字段的辅助索引、多个字段的辅助索引的最后一个字段进行范围查询
index:和all的区别是扫描的是索引树
all:扫描全表:

system

触发条件:表只有一行,这是一个 const type 的特殊情况

const

触发条件:在使用主键或者唯一索引进行查询的时候只有一行匹配。

1
2
3
4
sql复制代码SELECT * FROM tbl_name WHERE primary_key=1;

SELECT * FROM tbl_name
WHERE primary_key_part1=1 AND primary_key_part2=2;

eq_ref

触发条件:在进行联接查询的,使用主键或者唯一索引并且只匹配到一行记录的时候

1
2
3
4
5
6
sql复制代码SELECT * FROM ref_table,other_table
WHERE ref_table.key_column=other_table.column;

SELECT * FROM ref_table,other_table
WHERE ref_table.key_column_part1=other_table.column
AND ref_table.key_column_part2=1;

ref

触发条件:使用非唯一索引

1
2
3
4
5
6
7
8
sql复制代码SELECT * FROM ref_table WHERE key_column=expr;

SELECT * FROM ref_table,other_table
WHERE ref_table.key_column=other_table.column;

SELECT * FROM ref_table,other_table
WHERE ref_table.key_column_part1=other_table.column
AND ref_table.key_column_part2=1;

range

触发条件:只有在使用主键、单个字段的辅助索引、多个字段的辅助索引的最后一个字段进行范围查询才是 range

1
2
3
4
5
6
7
8
9
10
11
sql复制代码SELECT * FROM tbl_name
WHERE key_column = 10;

SELECT * FROM tbl_name
WHERE key_column BETWEEN 10 and 20;

SELECT * FROM tbl_name
WHERE key_column IN (10,20,30);

SELECT * FROM tbl_name
WHERE key_part1 = 10 AND key_part2 IN (10,20,30);

index

The index join type is the same as ALL, except that the index tree is scanned. This occurs two ways:

触发条件:

只扫描索引树

1)查询的字段是索引的一部分,覆盖索引。
2)使用主键进行排序

all

触发条件:全表扫描,不走索引

优化数据访问

减少请求的数据量

  • 只返回必要的列:最好不要使用 SELECT * 语句。
  • 只返回必要的行:使用 LIMIT 语句来限制返回的数据。
  • 缓存重复查询的数据:使用缓存可以避免在数据库中进行查询,特别在要查询的数据经常被重复查询时,缓存带来的查询性能提升将会是非常明显的。

减少服务器端扫描的行数

最有效的方式是使用索引来覆盖查询。

重构查询方式

切分大查询

一个大查询如果一次性执行的话,可能一次锁住很多数据、占满整个事务日志、耗尽系统资源、阻塞很多小的但重要的查询。

1
sql复制代码DELETE FROM messages WHERE create < DATE_SUB(NOW(), INTERVAL 3 MONTH);
1
2
3
4
5
sql复制代码rows_affected = 0
do {
rows_affected = do_query(
"DELETE FROM messages WHERE create < DATE_SUB(NOW(), INTERVAL 3 MONTH) LIMIT 10000")
} while rows_affected > 0

分解大连接查询

将一个大连接查询分解成对每一个表进行一次单表查询,然后在应用程序中进行关联,这样做的好处有:

  • 让缓存更高效。对于连接查询,如果其中一个表发生变化,那么整个查询缓存就无法使用。而分解后的多个查询,即使其中一个表发生变化,对其它表的查询缓存依然可以使用。
  • 分解成多个单表查询,这些单表查询的缓存结果更可能被其它查询使用到,从而减少冗余记录的查询。
  • 减少锁竞争;
  • 在应用层进行连接,可以更容易对数据库进行拆分,从而更容易做到高性能和可伸缩。
  • 查询本身效率也可能会有所提升。例如下面的例子中,使用 IN() 代替连接查询,可以让 MySQL 按照 ID 顺序进行查询,这可能比随机的连接要更高效。
1
2
3
4
sql复制代码SELECT * FROM tag
JOIN tag_post ON tag_post.tag_id=tag.id
JOIN post ON tag_post.post_id=post.id
WHERE tag.tag='mysql';
1
2
3
sql复制代码SELECT * FROM tag WHERE tag='mysql';
SELECT * FROM tag_post WHERE tag_id=1234;
SELECT * FROM post WHERE post.id IN (123,456,567,9098,8904);

事务

事务是指满足 ACID 特性的一组操作,可以通过 Commit 提交一个事务,也可以使用 Rollback 进行回滚。

ACID

事务最基本的莫过于 ACID 四个特性了,这四个特性分别是:

  • Atomicity:原子性
  • Consistency:一致性
  • Isolation:隔离性
  • Durability:持久性

原子性

事务被视为不可分割的最小单元,事务的所有操作要么全部成功,要么全部失败回滚。

一致性

数据库在事务执行前后都保持一致性状态,在一致性状态下,所有事务对一个数据的读取结果都是相同的。

隔离性

一个事务所做的修改在最终提交以前,对其他事务是不可见的。

持久性

一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢。

ACID 之间的关系

事务的 ACID 特性概念很简单,但不好理解,主要是因为这几个特性不是一种平级关系:

  • 只有满足一致性,事务的结果才是正确的。
  • 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
  • 事务满足持久化是为了能应对数据库崩溃的情况。

隔离级别

未提交读(READ UNCOMMITTED)

事务中的修改,即使没有提交,对其他事务也是可见的。

提交读(READ COMMITTED)

一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其他事务是不可见的。

可重复读(REPEATABLE READ)

保证在同一个事务中多次读取同样数据的结果是一样的。

可串行化(SERIALIZABLE)

强制事务串行执行。

需要加锁实现,而其它隔离级别通常不需要。

隔离级别 脏读 不可重复读 幻影读
未提交读 √ √ √
提交读 × √ √
可重复读 × × √
可串行化 × × ×

锁

锁是数据库系统区别于文件系统的一个关键特性。锁机制用于管理对共享资源的并发访问。

锁类型

共享锁(S Lock)

允许事务读一行数据

排他锁(X Lock)

允许事务删除或者更新一行数据

意向共享锁(IS Lock)

事务想要获得一张表中某几行的共享锁

意向排他锁

事务想要获得一张表中某几行的排他锁

MVCC

多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。

基础概念

版本号

  • 系统版本号:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
  • 事务版本号:事务开始时的系统版本号。

隐藏的列

MVCC 在每行记录后面都保存着两个隐藏的列,用来存储两个版本号:

  • 创建版本号:指示创建一个数据行的快照时的系统版本号;
  • 删除版本号:如果该快照的删除版本号大于当前事务版本号表示该快照有效,否则表示该快照已经被删除了。

Undo 日志

MVCC 使用到的快照存储在 Undo 日志中,该日志通过回滚指针把一个数据行(Record)的所有快照连接起来。

实现过程

以下实现过程针对可重复读隔离级别。

当开始一个事务时,该事务的版本号肯定大于当前所有数据行快照的创建版本号,理解这一点很关键。数据行快照的创建版本号是创建数据行快照时的系统版本号,系统版本号随着创建事务而递增,因此新创建一个事务时,这个事务的系统版本号比之前的系统版本号都大,也就是比所有数据行快照的创建版本号都大。

SELECT

多个事务必须读取到同一个数据行的快照,并且这个快照是距离现在最近的一个有效快照。但是也有例外,如果有一个事务正在修改该数据行,那么它可以读取事务本身所做的修改,而不用和其它事务的读取结果一致。

把没有对一个数据行做修改的事务称为 T,T 所要读取的数据行快照的创建版本号必须小于等于 T 的版本号,因为如果大于 T 的版本号,那么表示该数据行快照是其它事务的最新修改,因此不能去读取它。除此之外,T 所要读取的数据行快照的删除版本号必须是未定义或者大于 T 的版本号,因为如果小于等于 T 的版本号,那么表示该数据行快照是已经被删除的,不应该去读取它。

INSERT

将当前系统版本号作为数据行快照的创建版本号。

DELETE

将当前系统版本号作为数据行快照的删除版本号。

UPDATE

将当前系统版本号作为更新前的数据行快照的删除版本号,并将当前系统版本号作为更新后的数据行快照的创建版本号。可以理解为先执行 DELETE 后执行 INSERT。

快照读与当前读

在可重复读级别中,通过MVCC机制,虽然让数据变得可重复读,但我们读到的数据可能是历史数据,是不及时的数据,不是数据库当前的数据!这在一些对于数据的时效特别敏感的业务中,就很可能出问题。

对于这种读取历史数据的方式,我们叫它快照读 (snapshot read),而读取数据库当前版本数据的方式,叫当前读 (current read)。很显然,在MVCC中:

快照读

MVCC 的 SELECT 操作是快照中的数据,不需要进行加锁操作。

1
sql复制代码select * from table ….;

当前读

MVCC 其它会对数据库进行修改的操作(INSERT、UPDATE、DELETE)需要进行加锁操作,从而读取最新的数据。可以看到 MVCC 并不是完全不用加锁,而只是避免了 SELECT 的加锁操作。

1
2
3
sql复制代码INSERT;
UPDATE;
DELETE;

在进行 SELECT 操作时,可以强制指定进行加锁操作。以下第一个语句需要加 S 锁,第二个需要加 X 锁。

1
2
sql复制代码- select * from table where ? lock in share mode;
- select * from table where ? for update;

事务的隔离级别实际上都是定义的当前读的级别,MySQL为了减少锁处理(包括等待其它锁)的时间,提升并发能力,引入了快照读的概念,使得select不用加锁。而update、insert这些“当前读”的隔离性,就需要通过加锁来实现了。

锁算法

Record Lock

锁定一个记录上的索引,而不是记录本身。

如果表没有设置索引,InnoDB 会自动在主键上创建隐藏的聚簇索引,因此 Record Locks 依然可以使用。

Gap Lock

锁定索引之间的间隙,但是不包含索引本身。例如当一个事务执行以下语句,其它事务就不能在 t.c 中插入 15。

1
sql复制代码SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE;

Next-Key Lock

它是 Record Locks 和 Gap Locks 的结合,不仅锁定一个记录上的索引,也锁定索引之间的间隙。例如一个索引包含以下值:10, 11, 13, and 20,那么就需要锁定以下区间:

1
2
3
4
5
sql复制代码(-∞, 10]
(10, 11]
(11, 13]
(13, 20]
(20, +∞)

在 InnoDB 存储引擎中,SELECT 操作的不可重复读问题通过 MVCC 得到了解决,而 UPDATE、DELETE 的不可重复读问题通过 Record Lock 解决,INSERT 的不可重复读问题是通过 Next-Key Lock(Record Lock + Gap Lock)解决的。

锁问题

脏读

脏读指的是不同事务下,当前事务可以读取到另外事务未提交的数据。

例如:

T1 修改一个数据,T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据。

不可重复读

不可重复读指的是同一事务内多次读取同一数据集合,读取到的数据是不一样的情况。

例如:

T2 读取一个数据,T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。

在 InnoDB 存储引擎中,SELECT 操作的不可重复读问题通过 MVCC 得到了解决,而 UPDATE、DELETE 的不可重复读问题是通过 Record Lock 解决的,INSERT 的不可重复读问题是通过 Next-Key Lock(Record Lock + Gap Lock)解决的。

Phantom Proble(幻影读)

The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a SELECT is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.

Phantom Proble 是指在同一事务下,连续执行两次同样的 sql 语句可能返回不同的结果,第二次的 sql 语句可能会返回之前不存在的行。

幻影读是一种特殊的不可重复读问题。

丢失更新

一个事务的更新操作会被另一个事务的更新操作所覆盖。

例如:

T1 和 T2 两个事务都对一个数据进行修改,T1 先修改,T2 随后修改,T2 的修改覆盖了 T1 的修改。

这类型问题可以通过给 SELECT 操作加上排他锁来解决,不过这可能会引入性能问题,具体使用要视业务场景而定。

分库分表数据切分

水平切分

水平切分又称为 Sharding,它是将同一个表中的记录拆分到多个结构相同的表中。

当一个表的数据不断增多时,Sharding 是必然的选择,它可以将数据分布到集群的不同节点上,从而缓存单个数据库的压力。

垂直切分

垂直切分是将一张表按列分成多个表,通常是按照列的关系密集程度进行切分,也可以利用垂直气氛将经常被使用的列喝不经常被使用的列切分到不同的表中。

在数据库的层面使用垂直切分将按数据库中表的密集程度部署到不通的库中,例如将原来电商数据部署库垂直切分称商品数据库、用户数据库等。

Sharding 策略

  • 哈希取模:hash(key)%N
  • 范围:可以是 ID 范围也可以是时间范围
  • 映射表:使用单独的一个数据库来存储映射关系

Sharding 存在的问题

事务问题

使用分布式事务来解决,比如 XA 接口

连接

可以将原来的连接分解成多个单表查询,然后在用户程序中进行连接。

唯一性

  • 使用全局唯一 ID (GUID)
  • 为每个分片指定一个 ID 范围
  • 分布式 ID 生成器(如 Twitter 的 Snowflake 算法)

复制

主从复制

主要涉及三个线程:binlog 线程、I/O 线程和 SQL 线程。

  • binlog 线程 :负责将主服务器上的数据更改写入二进制日志(Binary log)中。
  • I/O 线程 :负责从主服务器上读取- 二进制日志,并写入从服务器的中继日志(Relay log)。
  • SQL 线程 :负责读取中继日志,解析出主服务器已经执行的数据更改并在从服务器中重放(Replay)。

读写分离

主服务器处理写操作以及实时性要求比较高的读操作,而从服务器处理读操作。

读写分离能提高性能的原因在于:

  • 主从服务器负责各自的读和写,极大程度缓解了锁的争用;
  • 从服务器可以使用 MyISAM,提升查询性能以及节约系统开销;
  • 增加冗余,提高可用性。

读写分离常用代理方式来实现,代理服务器接收应用层传来的读写请求,然后决定转发到哪个服务器。

JSON

在实际业务中经常会使用到 JSON 数据类型,在查询过程中主要有两种使用需求:

  1. 在 where 条件中有通过 json 中的某个字段去过滤返回结果的需求
  2. 查询 json 字段中的部分字段作为返回结果(减少内存占用)

JSON_CONTAINS

JSON_CONTAINS(target, candidate[, path])

如果在 json 字段 target 指定的位置 path,找到了目标值 condidate,返回 1,否则返回 0

如果只是检查在指定的路径是否存在数据,使用JSON_CONTAINS_PATH()

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
sql复制代码mysql> SET @j = '{"a": 1, "b": 2, "c": {"d": 4}}';
mysql> SET @j2 = '1';
mysql> SELECT JSON_CONTAINS(@j, @j2, '$.a');
+-------------------------------+
| JSON_CONTAINS(@j, @j2, '$.a') |
+-------------------------------+
| 1 |
+-------------------------------+
mysql> SELECT JSON_CONTAINS(@j, @j2, '$.b');
+-------------------------------+
| JSON_CONTAINS(@j, @j2, '$.b') |
+-------------------------------+
| 0 |
+-------------------------------+

mysql> SET @j2 = '{"d": 4}';
mysql> SELECT JSON_CONTAINS(@j, @j2, '$.a');
+-------------------------------+
| JSON_CONTAINS(@j, @j2, '$.a') |
+-------------------------------+
| 0 |
+-------------------------------+
mysql> SELECT JSON_CONTAINS(@j, @j2, '$.c');
+-------------------------------+
| JSON_CONTAINS(@j, @j2, '$.c') |
+-------------------------------+
| 1 |
+-------------------------------+

JSON_CONTAINS_PATH

JSON_CONTAINS_PATH(json_doc, one_or_all, path[, path] …)

如果在指定的路径存在数据返回 1,否则返回 0

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
sql复制代码mysql> SET @j = '{"a": 1, "b": 2, "c": {"d": 4}}';
mysql> SELECT JSON_CONTAINS_PATH(@j, 'one', '$.a', '$.e');
+---------------------------------------------+
| JSON_CONTAINS_PATH(@j, 'one', '$.a', '$.e') |
+---------------------------------------------+
| 1 |
+---------------------------------------------+
mysql> SELECT JSON_CONTAINS_PATH(@j, 'all', '$.a', '$.e');
+---------------------------------------------+
| JSON_CONTAINS_PATH(@j, 'all', '$.a', '$.e') |
+---------------------------------------------+
| 0 |
+---------------------------------------------+
mysql> SELECT JSON_CONTAINS_PATH(@j, 'one', '$.c.d');
+----------------------------------------+
| JSON_CONTAINS_PATH(@j, 'one', '$.c.d') |
+----------------------------------------+
| 1 |
+----------------------------------------+
mysql> SELECT JSON_CONTAINS_PATH(@j, 'one', '$.a.d');
+----------------------------------------+
| JSON_CONTAINS_PATH(@j, 'one', '$.a.d') |
+----------------------------------------+
| 0 |
+----------------------------------------+

实际使用:

1
2
3
4
5
6
7
8
php复制代码        $conds = new Criteria();
$conds->andWhere('dept_code', 'in', $deptCodes);
if (!empty($aoiAreaId)) {
$aoiAreaIdCond = new Criteria();
$aoiAreaIdCond->orWhere("JSON_CONTAINS_PATH(new_aoi_area_ids,'one', '$.\"$aoiAreaId\"')", '=', 1);
$aoiAreaIdCond->orWhere("JSON_CONTAINS_PATH(old_aoi_area_ids,'one', '$.\"$aoiAreaId\"')", '=', 1);
$conds->andWhere($aoiAreaIdCond);
}

column->path、column->>path

获取指定路径的值

-> vs ->>

Whereas the -> operator simply extracts a value, the ->> operator in addition unquotes the extracted result.

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
sql复制代码mysql> SELECT * FROM jemp WHERE g > 2;
+-------------------------------+------+
| c | g |
+-------------------------------+------+
| {"id": "3", "name": "Barney"} | 3 |
| {"id": "4", "name": "Betty"} | 4 |
+-------------------------------+------+
2 rows in set (0.01 sec)

mysql> SELECT c->'$.name' AS name
-> FROM jemp WHERE g > 2;
+----------+
| name |
+----------+
| "Barney" |
| "Betty" |
+----------+
2 rows in set (0.00 sec)

mysql> SELECT JSON_UNQUOTE(c->'$.name') AS name
-> FROM jemp WHERE g > 2;
+--------+
| name |
+--------+
| Barney |
| Betty |
+--------+
2 rows in set (0.00 sec)

mysql> SELECT c->>'$.name' AS name
-> FROM jemp WHERE g > 2;
+--------+
| name |
+--------+
| Barney |
| Betty |
+--------+
2 rows in set (0.00 sec)

实际使用:

1
php复制代码$retTask = AoiAreaTaskOrm::findRows(['status', 'extra_info->>"$.new_aoi_area_infos" as new_aoi_area_infos', 'extra_info->>"$.old_aoi_area_infos" as old_aoi_area_infos'], $cond);

关系数据库设计理论

函数依赖

记 A->B 表示 A 函数决定 B,也可以说 B 函数依赖于 A。

如果 {A1,A2,… ,An} 是关系的一个或多个属性的集合,该集合函数决定了关系的其它所有属性并且是最小的,那么该集合就称为键码。

对于 A->B,如果能找到 A 的真子集 A’,使得 A’-> B,那么 A->B 就是部分函数依赖,否则就是完全函数依赖。

对于 A->B,B->C,则 A->C 是一个传递函数依赖

异常

以下的学生课程关系的函数依赖为 {Sno, Cname} -> {Sname, Sdept, Mname, Grade},键码为 {Sno, Cname}。也就是说,确定学生和课程之后,就能确定其它信息。

Sno Sname Sdept Mname Cname Grade
1 学生-1 学院-1 院长-1 课程-1 90
2 学生-2 学院-2 院长-2 课程-2 80
2 学生-2 学院-2 院长-2 课程-1 100
3 学生-3 学院-2 院长-2 课程-2 95

不符合范式的关系,会产生很多异常,主要有以下四种异常:

  • 冗余数据:例如 学生-2 出现了两次。
  • 修改异常:修改了一个记录中的信息,但是另一个记录中相同的信息却没有被修改。
  • 删除异常:删除一个信息,那么也会丢失其它信息。例如删除了 课程-1 需要删除第一行和第三行,那么 学生-1 的信息就会丢失。
  • 插入异常:例如想要插入一个学生的信息,如果这个学生还没选课,那么就无法插入。

范式

范式理论是为了解决以上提到四种异常。

高级别范式的依赖于低级别的范式,1NF 是最低级别的范式。

第一范式 (1NF)

属性不可分。

第二范式 (2NF)

每个非主属性完全函数依赖于键码。

可以通过分解来满足。

分解前

Sno Sname Sdept Mname Cname Grade
1 学生-1 学院-1 院长-1 课程-1 90
2 学生-2 学院-2 院长-2 课程-2 80
2 学生-2 学院-2 院长-2 课程-1 100
3 学生-3 学院-2 院长-2 课程-2 95

以上学生课程关系中,{Sno, Cname} 为键码,有如下函数依赖:

  • Sno -> Sname, Sdept
  • Sdept -> Mname
  • Sno, Cname-> Grade

Grade 完全函数依赖于键码,它没有任何冗余数据,每个学生的每门课都有特定的成绩。

Sname, Sdept 和 Mname 都部分依赖于键码,当一个学生选修了多门课时,这些数据就会出现多次,造成大量冗余数据。

分解后

关系-1

Sno Sname Sdept Mname
1 学生-1 学院-1 院长-1
2 学生-2 学院-2 院长-2
3 学生-3 学院-2 院长-2

有以下函数依赖:

  • Sno -> Sname, Sdept
  • Sdept -> Mname

关系-2

Sno Cname Grade
1 课程-1 90
2 课程-2 80
2 课程-1 100
3 课程-2 95

有以下函数依赖:

  • Sno, Cname -> Grade

第三范式 (3NF)

非主属性不传递函数依赖于键码。

上面的 关系-1 中存在以下传递函数依赖:

  • Sno -> Sdept -> Mname

可以进行以下分解:

关系-11

Sno Sname Sdept
1 学生-1 学院-1
2 学生-2 学院-2
3 学生-3 学院-2

关系-12

Sdept Mname
学院-1 院长-1
学院-2 院长-2

ER 图

Entity-Relationship,有三个组成部分:实体、属性、联系。

用来进行关系型数据库系统的概念设计。

实体的三种联系

包含一对一,一对多,多对多三种。

  • 如果 A 到 B 是一对多关系,那么画个带箭头的线段指向 B;
  • 如果是一对一,画两个带箭头的线段;
  • 如果是多对多,画两个不带箭头的线段。

下图的 Course 和 Student 是一对多的关系。

表示出现多次的关系

一个实体在联系出现几次,就要用几条线连接。

下图表示一个课程的先修关系,先修关系出现两个 Course 实体,第一个是先修课程,后一个是后修课程,因此需要用两条线来表示这种关系。

联系的多向性

虽然老师可以开设多门课,并且可以教授多名学生,但是对于特定的学生和课程,只有一个老师教授,这就构成了一个三元联系。

表示子类

用一个三角形和两条线来连接类和子类,与子类有关的属性和联系都连到子类上,而与父类和子类都有关的连到父类上。

参考资料

  • 姜承尧. MySQL 技术内幕: InnoDB 存储引擎 [M]. 机械工业出版社, 2011.
  • CS-Notes-MySQL
  • B+ tree
  • 红黑树、B(+)树、跳表、AVL等数据结构,应用场景及分析,以及一些英文缩写
  • B树、B+树、红黑树、AVL树比较
  • 8.8.2 EXPLAIN Output Format
  • 最官方的 mysql explain type 字段解读
  • 12.18.3 Functions That Search JSON Values

总结

这都是些基础知识,我没想到再次回顾大半我都已忘却了,也庆幸有这样的假期能够重新拾起来。

说实话做自媒体后我充电的时间少了很多,也少了很多时间研究技术栈深度,国庆假期我也思考反思了很久,后面准备继续压缩自己业余时间,比如看手机看B站的时间压缩一下,还是得按时充电,目前作息还算规律早睡早起都做到了,我们一起加油哟。


文章持续更新,可以微信搜一搜「 三太子敖丙 」第一时间阅读,回复【资料】有我准备的一线大厂面试资料和简历模板,本文 GitHub github.com/JavaFamily 已经收录,有大厂面试完整考点,欢迎Star。

本文转载自: 掘金

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

3千字详细讲解OpenFeign的使用姿势! 思维导图 前言

发表于 2020-10-13

思维导图

文章已收录Github精选,欢迎Star:github.com/yehongzhi/l…

前言

目前在SpringCloud技术栈中,调用服务用得最多的就是OpenFeign,所以这篇文章讲一下OpenFeign,希望对大家有所帮助。

一、构建工程

使用Nacos作为注册中心,不会搭建Nacos的话,可以参考上一篇注册中心的文章。

首先父工程parent引入依赖。

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
xml复制代码<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Finchley.SR1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>0.2.2.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-openfeign</artifactId>
<version>2.0.1.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency><!-- SpringCloud nacos服务发现的依赖 -->
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.nacos</groupId>
<artifactId>nacos-client</artifactId>
<version>1.2.0</version>
</dependency>
</dependencies>

搭建提供者provider工程和消费者consumer工程。

provider工程继承父工程的pom文件,编写启动类如下:

1
2
3
4
5
6
7
java复制代码@SpringBootApplication
@EnableDiscoveryClient//注册中心
public class ProviderApplication {
public static void main(String[] args) throws Exception {
SpringApplication.run(ProviderApplication.class, args);
}
}

provider工程的配置文件如下:

1
2
3
4
5
6
7
8
9
10
yaml复制代码server:
port: 8080
spring:
application:
name: provider
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
service: ${spring.application.name}

提供接口,Controller如下:

1
2
3
4
5
6
7
8
9
10
11
java复制代码@RestController
public class ProviderController {
@RequestMapping("/provider/list")
public List<String> list() {
List<String> list = new ArrayList<>();
list.add("java技术爱好者");
list.add("SpringCloud");
list.add("没有人比我更懂了");
return list;
}
}

消费者consumer工程也继承parent的pom文件,加上Feign依赖:

1
2
3
4
5
6
7
xml复制代码<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<!-- 版本在parent的pom文件中指定了 -->
</dependency>
</dependencies>

编写启动类,如下:

1
2
3
4
5
6
7
8
9
java复制代码@SpringBootApplication
@EnableDiscoveryClient
//开启feign接口扫描,指定扫描的包
@EnableFeignClients(basePackages = {"com.yehongzhi.springcloud"})
public class ConsumerApplication {
public static void main(String[] args) throws Exception {
SpringApplication.run(ConsumerApplication.class, args);
}
}

环境搭建完成后,接下来讲两种实现使用方式。

二、声明式

这种很简单,消费者consumer工程增加一个ProviderClient接口。

1
2
3
4
5
6
7
8
java复制代码@FeignClient(name = "provider")
//会扫描指定包下,标记FeignClient注解的接口
//会根据服务名,从注册中心找到对应的IP地址
public interface ProviderClient {
//这里跟提供者接口的URL一致
@RequestMapping("/provider/list")
String list();
}

然后再用消费者工程的ConsumerController接口来测试。

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@RestController
public class ConsumerController {
//引入Feign客户端
@Resource
private ProviderClient providerClient;

@RequestMapping("/consumer/callProvider")
public String callProvider() {
//使用Feign客户端调用其他服务的接口
return providerClient.list();
}
}

最后我们启动提供者工程,消费者工程,注册中心,测试。

然后调用消费者的ConsumerController接口。

三、继承式

细心的同学可能发现,其实声明式会写多一次提供者接口的定义,也就是有重复的代码,既然有重复的定义,那我们就可以抽取出来,所以就有了继承式。

第一步,创建一个普通的Maven项目api工程,把接口定义在api中。

第二步,服务提供者工程的ProviderController实现Provider接口。

1
2
3
4
5
6
7
8
9
10
11
java复制代码@RestController
public class ProviderController implements ProviderApi {

public String list() {
List<String> list = new ArrayList<>();
list.add("java技术爱好者");
list.add("SpringCloud");
list.add("没有人比我更懂了");
return list.toString();
}
}

第三步,消费者工程的ProviderClient无需定义,只需要继承ProviderApi,然后加上@FeignClient即可。

1
2
3
java复制代码@FeignClient(name = "provider")
public interface ProviderClient extends ProviderApi {
}

其他不用变了,最后启动服务提供者,消费者,注册中心测试一下。

测试成功!上面继承式的好处就在于,只需要在api工程定义一次接口,服务提供者去实现具体的逻辑,消费者则继承接口贴个注解即可,非常方便快捷。

缺点就在于如果有人动了api的接口,则会导致很多服务消费者、提供者出现报错,耦合性比较强。api工程相当于一个公共的工程,消费者和服务者都会依赖此工程,所以一般要求不能随便删api上面的接口。

四、Feign的相关配置

下面讲一下Feign的一些常用的相关配置。

4.1 请求超时设置

Feign底层其实还是使用Ribbon,默认是1秒。所以超过1秒就报错。

接下来试验一下。我在服务提供者的接口加上一段休眠1.5秒的代码,然后用消费者去消费。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@RestController
public class ProviderController implements ProviderApi {
public String list() {
List<String> list = new ArrayList<>();
list.add("java技术爱好者");
list.add("SpringCloud");
list.add("没有人比我更懂了");
try {
//休眠1.5秒
Thread.sleep(1500);
} catch (Exception e) {
e.printStackTrace();
}
return list.toString();
}
}

消费者调用后,由于超过1秒,可以看到控制台报错。

如果想调整超时时间,可以在消费者这边,加上配置:

1
2
3
yaml复制代码ribbon:
ReadTimeout: 5000 #请求时间5秒
ConnectTimeout: 5000 #连接时间5秒

为了显示出效果,我们在消费者的代码里加上耗时计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码@RestController
public class ConsumerController {

@Resource
private ProviderClient providerClient;

@RequestMapping("/consumer/callProvider")
public String callProvider() throws Exception {
long star = System.currentTimeMillis();
String list = providerClient.list();
long end = System.currentTimeMillis();
return "响应结果:" + list + ",耗时:" + (end - star) / 1000 + "秒";
}
}

最后启动测试,可以看到,超过1秒也能请求成功。

4.2 日志打印功能

首先需要配置Feign的打印日志的级别。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Configuration
public class FeignConfig {
/**
* NONE:默认的,不显示任何日志
* BASIC:仅记录请求方法、URL、响应状态码及执行时间
* HEADERS:出了BASIC中定义的信息之外,还有请求和响应的头信息
* FULL:除了HEADERS中定义的信息之外,还有请求和响应的正文及元素
*/
@Bean
public Logger.Level feginLoggerLevel() {
return Logger.Level.FULL;
}
}

第二步,需要设置打印的Feign接口。Feign为每个客户端创建一个logger。默认情况下,logger的名称是Feigh接口的完整类名。需要注意的是,Feign的日志打印只会对DEBUG级别做出响应。

1
2
3
4
yaml复制代码#与server同级
logging:
level:
com.yehongzhi.springcloud.consumer.feign.ProviderClient: debug

设置完成后,控制台可以看到详细的请求信息。

4.3 Feign实现熔断

openFeign实际上是已经引入了hystrix的相关jar包,所以可以直接使用,设置超时时间,超时后调用FallBack方法,实现熔断机制。

首先在消费者工程添加Maven依赖。

1
2
3
4
xml复制代码<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>

第二步,在配置中开启熔断机制,添加超时时间。

1
2
3
4
5
6
7
8
9
10
11
yaml复制代码#默认是不支持的,所以这里要开启,设置为true
feign:
hystrix:
enabled: true
hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMilliseconds: 3000

第三步,编写FallBack类。

1
2
3
4
5
6
7
8
java复制代码//ProviderClient是贴了@FeignClient注解的接口
@Component
public class ProviderClientFallBack implements ProviderClient {
@Override
public String list() {
return Arrays.asList("调用fallBack接口", "返回未知结果").toString();
}
}

第四步,在对应的Feign接口添加fallback属性。

1
2
3
4
5
java复制代码//fallback属性,填写刚刚编写的FallBack回调类
@Component
@FeignClient(name = "provider", fallback = ProviderClientFallBack.class)
public interface ProviderClient extends ProviderApi {
}

最后可以测试一下,超过设置的3秒,则会熔断,调用FallBack方法返回。

4.4 设置负载均衡

前面说过OpenFeign底层是使用Ribbon,Ribbon是负责做负载均衡的组件。所以是可以通过配置设置负载均衡的策略。

默认的是轮询策略。如果要换成其他策略,比如随机,怎么换呢。

很简单,改一下配置即可:

1
2
3
4
5
6
7
8
9
yaml复制代码#服务名称
provider:
ribbon:
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule
#NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule #配置规则 随机
#NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RoundRobinRule #配置规则 轮询
#NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RetryRule #配置规则 重试
#NFLoadBalancerRuleClassName: com.netflix.loadbalancer.WeightedResponseTimeRule #配置规则 响应时间权重
#NFLoadBalancerRuleClassName: com.netflix.loadbalancer.BestAvailableRule #配置规则 最空闲连接策略

总结

OpenFeign把RestTemplete,Ribbon,Hystrix糅合在了一起,在使用时就可以更加方便,优雅地完成整个服务的暴露,调用等。避免做一些重复的复制粘贴接口URL,或者重复定义接口等。还是非常值得去学习的。

以前我在的公司搭建的SpringCloud微服务就没有使用Feign,架构师自己写了一个AOP代理类进行服务调用,超时时间5秒写死在代码里,当时有个微服务接口要上传文件,总是超时,又改不了超时时间,一超时就调熔断方法返回服务请求超时,导致非常痛苦。

如果当时使用Feign,插拔式,可配置的方式,也许就没那么麻烦了。

那么feign就讲到这里了,上面所有例子的代码都上传Github了:

github.com/yehongzhi/e…

觉得有用就点个赞吧,你的点赞是我创作的最大动力~

拒绝做一条咸鱼,我是一个努力让大家记住的程序员。我们下期再见!!!

能力有限,如果有什么错误或者不当之处,请大家批评指正,一起学习交流!

本文转载自: 掘金

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

1…774775776…956

开发者博客

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