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

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


  • 首页

  • 归档

  • 搜索

松哥手把手教你在 SpringBoot 中防御 CSRF 攻

发表于 2020-05-19

CSRF 就是跨域请求伪造,英文全称是 Cross Site Request Forgery。

这是一种非常常见的 Web 攻击方式,其实是很好防御的,但是由于经常被很多开发者忽略,进而导致很多网站实际上都存在 CSRF 攻击的安全隐患。

今天松哥就来和大家聊一聊什么是 CSRF 攻击以及 CSRF 攻击该如何防御。

本文是本系列第 18 篇,阅读本系列前面文章有助于更好的理解本文:

  1. 挖一个大坑,Spring Security 开搞!
  2. 松哥手把手带你入门 Spring Security,别再问密码怎么解密了
  3. 手把手教你定制 Spring Security 中的表单登录
  4. Spring Security 做前后端分离,咱就别做页面跳转了!统统 JSON 交互
  5. Spring Security 中的授权操作原来这么简单
  6. Spring Security 如何将用户数据存入数据库?
  7. Spring Security+Spring Data Jpa 强强联手,安全管理只有更简单!
  8. Spring Boot + Spring Security 实现自动登录功能
  9. Spring Boot 自动登录,安全风险要怎么控制?
  10. 在微服务项目中,Spring Security 比 Shiro 强在哪?
  11. SpringSecurity 自定义认证逻辑的两种方式(高级玩法)
  12. Spring Security 中如何快速查看登录用户 IP 地址等信息?
  13. Spring Security 自动踢掉前一个登录用户,一个配置搞定!
  14. Spring Boot + Vue 前后端分离项目,如何踢掉已登录用户?
  15. Spring Security 自带防火墙!你都不知道自己的系统有多安全!
  16. 什么是会话固定攻击?Spring Boot 中要如何防御会话固定攻击?
  17. 集群化部署,Spring Security 要如何处理 session 共享?

1.CSRF原理

想要防御 CSRF 攻击,那我们得先搞清楚什么是 CSRF 攻击,松哥通过下面一张图,来和大家梳理 CSRF 攻击流程:

其实这个流程很简单:

  1. 假设用户打开了招商银行网上银行网站,并且登录。
  2. 登录成功后,网上银行会返回 Cookie 给前端,浏览器将 Cookie 保存下来。
  3. 用户在没有登出网上银行的情况下,在浏览器里边打开了一个新的选项卡,然后又去访问了一个危险网站。
  4. 这个危险网站上有一个超链接,超链接的地址指向了招商银行网上银行。
  5. 用户点击了这个超链接,由于这个超链接会自动携带上浏览器中保存的 Cookie,所以用户不知不觉中就访问了网上银行,进而可能给自己造成了损失。

CSRF 的流程大致就是这样,接下来松哥用一个简单的例子和小伙伴们展示一下 CSRF 到底是怎么回事。

2.CSRF实践

接下来,我创建一个名为 csrf-1 的 Spring Boot 项目,这个项目相当于我们上面所说的网上银行网站,创建项目时引入 Web 和 Spring Security 依赖,如下:

创建成功后,方便起见,我们直接将 Spring Security 用户名/密码 配置在 application.properties 文件中:

1
2
复制代码spring.security.user.name=javaboy
spring.security.user.password=123

然后我们提供两个测试接口:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码@RestController
public class HelloController {
@PostMapping("/transfer")
public void transferMoney(String name, Integer money) {
System.out.println("name = " + name);
System.out.println("money = " + money);
}
@GetMapping("/hello")
public String hello() {
return "hello";
}
}

假设 /transfer 是一个转账接口(这里是假设,主要是给大家演示 CSRF 攻击,真实的转账接口比这复杂)。

最后我们还需要配置一下 Spring Security,因为 Spring Security 中默认是可以自动防御 CSRF 攻击的,所以我们要把这个关闭掉:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf()
.disable();
}
}

配置完成后,我们启动 csrf-1 项目。

接下来,我们再创建一个 csrf-2 项目,这个项目相当于是一个危险网站,为了方便,这里创建时我们只需要引入 web 依赖即可。

项目创建成功后,首先修改项目端口:

1
复制代码server.port=8081

然后我们在 resources/static 目录下创建一个 hello.html ,内容如下:

1
2
3
4
5
6
7
复制代码<body>
<form action="http://localhost:8080/transfer" method="post">
<input type="hidden" value="javaboy" name="name">
<input type="hidden" value="10000" name="money">
<input type="submit" value="点击查看美女图片">
</form>
</body>

这里有一个超链接,超链接的文本是点击查看美女图片,当你点击了超链接之后,会自动请求 http://localhost:8080/transfer 接口,同时隐藏域还携带了两个参数。

配置完成后,就可以启动 csrf-2 项目了。

接下来,用户首先访问 csrf-1 项目中的接口,在访问的时候需要登录,用户就执行了登录操作,访问完整后,用户并没有执行登出操作,然后用户访问 csrf-2 中的页面,看到了超链接,好奇这美女到底长啥样,一点击,结果钱就被人转走了。

3.CSRF防御

先来说说防御思路。

CSRF 防御,一个核心思路就是在前端请求中,添加一个随机数。

因为在 CSRF 攻击中,黑客网站其实是不知道用户的 Cookie 具体是什么的,他是让用户自己发送请求到网上银行这个网站的,因为这个过程会自动携带上 Cookie 中的信息。

所以我们的防御思路是这样:用户在访问网上银行时,除了携带 Cookie 中的信息之外,还需要携带一个随机数,如果用户没有携带这个随机数,则网上银行网站会拒绝该请求。黑客网站诱导用户点击超链接时,会自动携带上 Cookie 中的信息,但是却不会自动携带随机数,这样就成功的避免掉 CSRF 攻击了。

Spring Security 中对此提供了很好的支持,我们一起来看下。

3.1 默认方案

Spring Security 中默认实际上就提供了 csrf 防御,但是需要开发者做的事情比较多。

首先我们来创建一个新的 Spring Boot 工程,创建时引入 Spring Security、Thymeleaf 和 web 依赖。

项目创建成功后,我们还是在 application.properties 中配置用户名/密码:

1
2
复制代码spring.security.user.name=javaboy
spring.security.user.password=123

接下来,我们提供一个测试接口:

1
2
3
4
5
6
7
8
复制代码@Controller
public class HelloController {
@PostMapping("/hello")
@ResponseBody
public String hello() {
return "hello";
}
}

注意,这个测试接口是一个 POST 请求,因为默认情况下,GET、HEAD、TRACE 以及 OPTIONS 是不需要验证 CSRF 攻击的。

然后,我们在 resources/templates 目录下,新建一个 thymeleaf 模版,如下:

1
2
3
4
5
6
复制代码<body>
<form action="/hello" method="post">
<input type="hidden" th:value="${_csrf.token}" th:name="${_csrf.parameterName}">
<input type="submit" value="hello">
</form>
</body>

注意,在发送 POST 请求的时候,还额外携带了一个隐藏域,隐藏域的 key 是 ${_csrf.parameterName},value 则是 ${_csrf.token}。

这两个值服务端会自动带过来,我们只需要在前端渲染出来即可。

接下来给前端 hello.html 页面添加一个控制器,如下:

1
2
3
4
复制代码@GetMapping("/hello")
public String hello2() {
return "hello";
}

添加完成后,启动项目,我们访问 hello 页面,在访问时候,需要先登录,登录成功之后,我们可以看到登录请求中也多了一个参数,如下:

可以看到,这里也多了 _csrf 参数。

这里我们用了 Spring Security 的默认登录页面,如果大家使用自定义登录页面,可以参考上面 hello.html 的写法,通过一个隐藏域传递 _csrf 参数。

访问到 hello 页面之后,再去点击按钮,就可以访问到 hello 接口了。

小伙伴们可以自行尝试在 hello.html 页面中,去掉 _csrf 参数,看看访问 hello 接口的效果。

这是 Spring Security 中默认的方案,通过 Model 将相关的数据带到前端来。

如果你的项目是前后端不分项目,这种方案就可以了,如果你的项目是前后端分离项目,这种方案很明显不够用。

3.2 前后端分离方案

如果是前后端分离项目,Spring Security 也提供了解决方案。

这次不是将 _csrf 放在 Model 中返回前端了,而是放在 Cookie 中返回前端,配置方式如下:

1
2
3
4
5
6
7
8
9
10
11
复制代码@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
}

有小伙伴可能会说放在 Cookie 中不是又被黑客网站盗用了吗?其实不会的,大家注意如下两个问题:

  1. 黑客网站根本不知道你的 Cookie 里边存的啥,他也不需要知道,因为 CSRF 攻击是浏览器自动携带上 Cookie 中的数据的。
  2. 我们将服务端生成的随机数放在 Cookie 中,前端需要从 Cookie 中自己提取出来 _csrf 参数,然后拼接成参数传递给后端,单纯的将 Cookie 中的数据传到服务端是没用的。

理解透了上面两点,你就会发现 _csrf 放在 Cookie 中是没有问题的,但是大家注意,配置的时候我们通过 withHttpOnlyFalse 方法获取了 CookieCsrfTokenRepository 的实例,该方法会设置 Cookie 中的 HttpOnly 属性为 false,也就是允许前端通过 js 操作 Cookie(否则你就没有办法获取到 _csrf)。

配置完成后,重启项目,此时我们就发现返回的 Cookie 中多了一项:

接下来,我们通过自定义登录页面,来看看前端要如何操作。

首先我们在 resources/static 目录下新建一个 html 页面叫做 login.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="js/jquery.min.js"></script>
<script src="js/jquery.cookie.js"></script>
</head>
<body>
<div>
<input type="text" id="username">
<input type="password" id="password">
<input type="button" value="登录" id="loginBtn">
</div>
<script>
$("#loginBtn").click(function () {
let _csrf = $.cookie('XSRF-TOKEN');
$.post('/login.html',{username:$("#username").val(),password:$("#password").val(),_csrf:_csrf},function (data) {
alert(data);
})
})
</script>
</body>
</html>

这段 html 我给大家解释下:

  1. 首先引入 jquery 和 jquery.cookie ,方便我们一会操作 Cookie。
  2. 定义三个 input,前两个是用户名和密码,第三个是登录按钮。
  3. 点击登录按钮之后,我们先从 Cookie 中提取出 XSRF-TOKEN,这也就是我们要上传的 csrf 参数。
  4. 通过一个 POST 请求执行登录操作,注意携带上 _csrf 参数。

服务端我们也稍作修改,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/js/**");
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.successHandler((req,resp,authentication)->{
resp.getWriter().write("success");
})
.permitAll()
.and()
.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
}

一方面这里给 js 文件放行。

另一方面配置一下登录页面,以及登录成功的回调,这里简单期间,登录成功的回调我就给一个字符串就可以了。大家感兴趣的话,可以查看本系列前面文章,有登录成功后回调的详细解释。

OK,所有事情做完之后,我们访问 login.html 页面,输入用户名密码进行登录,结果如下:

可以看到,我们的 _csrf 配置已经生效了。

小伙伴们可以自行尝试从登录参数中去掉 _csrf,然后再看看效果。

4.小结

好了,今天主要和小伙伴们介绍了 csrf 攻击以及如何防御的问题。大家看到,csrf 攻击主要是借助了浏览器默认发送 Cookie 的这一机制,所以如果你的前端是 App、小程序之类的应用,不涉及浏览器应用的话,其实可以忽略这个问题,如果你的前端包含浏览器应用的话,这个问题就要认真考虑了。

好了 ,本文就说到这里,本文相关案例我已经上传到 GitHub ,大家可以自行下载:github.com/lenve/sprin…

好啦,不知道小伙伴们有没有 GET 到呢?如果有收获,记得点个在看鼓励下松哥哦~

本文转载自: 掘金

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

【漫画】CAS原理分析!无锁原子类也能解决并发问题! 温故

发表于 2020-05-18

本文来源于微信公众号【胖滚猪学编程】、转载请注明出处

在漫画并发编程系统博文中,我们讲了N篇关于锁的知识,确实,锁是解决并发问题的万能钥匙,可是并发问题只有锁能解决吗?今天要出场一个大BOSS:CAS无锁算法,可谓是并发编程核心中的核心!

_1

温故

首先我们再回顾一下原子性问题的原因,参考【漫画】JAVA并发编程 如何解决原子性问题。

image.png

两个线程同时把count=0加载到自己的工作内存,线程B先执行count++操作,此时主内存已经变化成了1,但是线程A依旧以为count=0,这是导致问题的根源。

所以解决方案就是:不能让线程A以为count=0,而是要和主内存进行一次compare(比较),如果内存中的值是0,说明没有其他线程更新过count值,那么就swap(交换),把新值写回主内存。如果内存中的值不是0,比如本案例中,内存中count就已经被线程B更新成了1,比较0!=1,因此compare失败,不把新值写回主内存。

_2

本文来源于微信公众号【胖滚猪学编程】。一个集颜值与才华于一身的女程序媛、以漫画形式让编程so easy and interesting!转载请注明出处

CAS概念

CAS (compareAndSwap),中文叫比较交换,一种无锁原子算法。

CAS算法包含 3 个参数 CAS(V,E,N),V表示要更新变量在内存中的值,E表示旧的预期值,N表示新值。
仅当 V值等于E值时,才会将V的值设为N。
如果V值和E值不同,则说明已经有其他线程做两个更新,那么当前线程不做更新,而是自旋。

_3

模拟CAS实现

既然我们了解了CAS的思想,那可以手写一个简单的CAS模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码    // count必须用volatile修饰 保证不同线程之间的可见性
private volatile static int count;

public void addOne() {
int newValue;
do {
newValue = count++;
} while (!compareAndSwapInt(expectCount, newValue)); //自旋 循环
}

public final boolean compareAndSwapInt(int expectCount, int newValue) {
// 读目前 count 的值
int curValue = count;
// 比较目前 count 值是否 == 期望值
if (curValue == expectCount) {
// 如果是,则更新 count 的值
count = newValue;
return true;

}
//否则返回false 然后循环
return false;
}

这个简单的模拟代码,其实基本上把CAS的思想体现出来了,但实际上CAS原理可要复杂很多哦,我们还是看看JAVA是怎么实现CAS的吧!

原子类

要了解JAVA中CAS的实现,那不得不提到大名鼎鼎的原子类,原子类的使用非常简单,而其中深奥的原理就是CAS无锁算法。

Java 并发包里提供的原子类内容很丰富,我们可以将它们分为五个类别:原子化的基本数据类型、原子化的对象引用类型、原子化数组、原子化对象属性更新器和原子化的累加器。

image.png

原子类的使用可谓非常简单,相信只要看一下api就知道如何使用,因此不过多解释,如有需要可以参考本人github代码。
此处只以AtomicInteger为例子,测试一下原子类是否名副其实可以保证原子性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码    private static AtomicInteger count = new AtomicInteger(0);
private static int count1 = 0;
//省略代码 同时启动10个线程 分别测试AtomicInteger和普通int的输出结果
private static void add10K() {
int idx = 0;
while (idx++ < 10000) {
//使用incrementAndGet实现i++功能
count.incrementAndGet();
}
countDownLatch.countDown();
}
private static void add10K1() {
int idx = 0;
while (idx++ < 10000) {
count1++;
}
countDownLatch.countDown();
}

通过测试可以发现,使用AtomicInteger可以保证输出结果为100000,而普通int则不能保证。

本文来源于微信公众号【胖滚猪学编程】。一个集颜值与才华于一身的女程序媛、以漫画形式让编程so easy and interesting!转载请注明出处

CAS源码分析

据此,我们又可以回归正题,JAVA是怎么实现CAS的呢?跟踪一下AtomicInteger中的incrementAndGet()方法,相信就会有答案了。
首先关注一下AtomicInteger.java中这么几个东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码    private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;//数据在内存中的地址偏移量,通过偏移地址可以获取数据原值

static {
try {
//计算变量 value 在类对象中的偏移量
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;//要修改的值 volatile保证可见性

public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

Unsafe,是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据。
变量valueOffset,表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。
变量value必须用volatile修饰,保证了多线程之间的内存可见性。

当然具体实现我们还是得瞧瞧getAndAddInt方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码    //内部使用自旋的方式进行CAS更新(while循环进行CAS更新,如果更新失败,则循环再次重试)
public final int getAndAddInt(Object var1, long var2, int var4) {
//var1为当前这个对象,如count.getAndIncrement(),则var1为count这个对象
//第二个参数为AtomicInteger对象value成员变量在内存中的偏移量
//第三个参数为要增加的值
int var5;
do {
//var5 获取对象内存地址偏移量上的数值v 即预期旧值
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));//循环判断内存位置的值与预期原值是否相匹配

return var5;
}

此时我们还想继续了解compareAndSwapInt的实现,点进去看,首先映入眼帘的是四个参数:1、当前的实例 2、实例变量的内存地址偏移量 3、预期的旧值 4、要更新的值

1
复制代码    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

还想继续刨根问底,会发现点不动了。因为用native修饰的方法代表是底层方法,当然如果你非得一探究竟你也可以找找对应的unsafe.cpp 文件进行深度解析C代码:

image.png

image.png

个人认为没必要深究,毕竟术业有专攻,你只需要知道其实核心代码就是一条 cmpxchg 指令。
cmpxchg: 即“比较并交换”指令。与我们上面说的思想是一样的:将 eax 寄存器中的值(compare_value)与 [edx] 双字内存单元中的值进行对比,如果相同,则将 ecx 寄存器中的值(exchange_value)存入 [edx] 内存单元中。

总之:你只需要记住:CAS是靠硬件实现的,从而在硬件层面提升效率。实现方式是基于硬件平台的汇编指令,在intel的CPU中,使用的是cmpxchg指令。 核心思想就是:比较要更新变量的值V和预期值E(compare),相等才会将V的值设为新值N(swap)。

CAS真有这么好吗?

CAS和锁都解决了原子性问题,和锁相比,由于其非阻塞的,它对死锁问题天生免疫,并且,线程间的相互影响也非常小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,他要比基于锁的方式拥有更优越的性能。

但是,CAS真的有那么好吗?又到挑刺时间了!

要让我们失望了,CAS并没有那么好,主要表现在三个方面:

  • 1、循环时间太长
  • 2、只能保证一个共享变量原子操作
  • 3、ABA问题。

循环时间太长
如果CAS长时间地不成功,我们知道会持续循环、自旋。必然会给CPU带来非常大的开销。在JUC中有些地方就限制了CAS自旋的次数,例如BlockingQueue的SynchronousQueue。

只能保证一个共享变量原子操作
看了CAS的实现就知道这只能针对一个共享变量,如果是多个共享变量就只能使用锁了,当然如果你有办法把多个变量整成一个变量,利用CAS也不错。例如读写锁中state的高低位。

ABA问题
这可是个面试重点问题哦!认真听好!

CAS需要检查操作值有没有发生改变,如果没有发生改变则更新。但是存在这样一种情况:如果一个值原来是A,变成了B,然后又变成了A,那么在CAS检查的时候会发现没有改变,但是实质上它已经发生了改变,这就是所谓的ABA问题。
某些情况我们并不关心 ABA 问题,例如数值的原子递增,但也不能所有情况下都不关心,例如原子化的更新对象很可能就需要关心 ABA 问题,因为两个 A 虽然相等,但是第二个 A 的属性可能已经发生变化了。

对于ABA问题其解决方案是加上版本号,即在每个变量都加上一个版本号,每次改变时加1,即A —> B —> A,变成1A —> 2B —> 3A。

原子类之AtomicStampedReference可以解决ABA问题,它内部不仅维护了对象值,还维护了一个Stamp(可把它理解为版本号,它使用整数来表示状态值)。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新版本号。当AtomicStampedReference设置对象值时,对象值以及版本号都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要版本号发生变化,就能防止不恰当的写入。

1
2
3
4
5
6
7
8
9
10
11
12
复制代码    // 参数依次为:期望值 写入新值 期望版本号 新版本号
public boolean compareAndSet(V expectedReference, V
newReference, int expectedStamp, int newStamp);

//获得当前对象引用
public V getReference();

//获得当前版本号
public int getStamp();

//设置当前对象引用和版本号
public void set(V newReference, int newStamp);

说理论太多也没用,还是亲自实验它是否能解决ABA问题吧:

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
复制代码    private static AtomicStampedReference<Integer> count = new AtomicStampedReference<>(10, 0);

public static void main(String[] args) {
Thread main = new Thread(() -> {
int stamp = count.getStamp(); //获取当前版本

log.info("线程{} 当前版本{}",Thread.currentThread(),stamp);
try {
Thread.sleep(1000); //等待1秒 ,以便让干扰线程执行
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean isCASSuccess = count.compareAndSet(10, 12, stamp, stamp + 1); //此时expectedReference未发生改变,但是stamp已经被修改了,所以CAS失败
log.info("CAS是否成功={}",isCASSuccess);
}, "主操作线程");

Thread other = new Thread(() -> {
int stamp = count.getStamp(); //获取当前版本
log.info("线程{} 当前版本{}",Thread.currentThread(),stamp);
count.compareAndSet(10, 12, stamp, stamp + 1);
log.info("线程{} 增加后版本{}",Thread.currentThread(),count.getStamp());

// 模拟ABA问题 先更新成12 又更新回10
int stamp1 = count.getStamp(); //获取当前版本
count.compareAndSet(12, 10, stamp1, stamp1 + 1);
log.info("线程{} 减少后版本{}",Thread.currentThread(),count.getStamp());
}, "干扰线程");

main.start();
other.start();
}

输出结果如下:

1
2
3
4
5
复制代码线程Thread[主操作线程,5,main] 当前版本0
[干扰线程] INFO - 线程Thread[干扰线程,5,main] 当前版本0
[干扰线程] INFO - 线程Thread[干扰线程,5,main] 增加后版本1
[干扰线程] INFO - 线程Thread[干扰线程,5,main] 减少后版本2
[主操作线程] INFO - CAS是否成功=false

总结

JAVA博大精深,解决并发问题可不仅仅是锁才能担此大任。CAS无锁算法对于解决原子性问题同样是势在必得。而原子类,则是无锁工具类的典范,原子类包括五大类型(原子化的基本数据类型、原子化的对象引用类型、原子化数组、原子化对象属性更新器和原子化的累加器)。

CAS 是一种乐观锁,乐观锁会以一种更加乐观的态度对待事情,认为自己可以操作成功。而悲观锁会让线程一直阻塞。因此CAS具有很多优势,比如性能佳、可以避免死锁。但是它没有那么好,你应该考虑到ABA问题、循环时间长的问题。因此需要综合选择,适合自己的才是最好的。

附录:并发编程全系列代码github

本文来源于微信公众号【胖滚猪学编程】。一个集颜值与才华于一身的女程序媛、以漫画形式让编程so easy and interesting!欢迎关注与我一起交流!

本文转载自公众号【胖滚猪学编程】 用漫画让编程so easy and interesting!欢迎关注!

本文转载自: 掘金

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

Go的50坑:新Golang开发者要注意的陷阱、技巧和常见错

发表于 2020-05-18

Go是一门简单有趣的语言,但与其他语言类似,它会有一些技巧。。。这些技巧的绝大部分并不是Go的缺陷造成的。如果你以前使用的是其他语言,那么这其中的有些错误就是很自然的陷阱。其它的是由错误的假设和缺少细节造成的。

如果你花时间学习这门语言,阅读官方说明、wiki、邮件列表讨论、大量的优秀博文和Rob Pike的展示,以及源代码,这些技巧中的绝大多数都是显而易见的。尽管不是每个人都是以这种方式开始学习的,但也没关系。如果你是Go语言新人,那么这里的信息将会节约你大量的调试代码的时间。

目录

  • 初级篇
  • 开大括号不能放在单独的一行
  • 未使用的变量
  • 未使用的Imports
  • 简式的变量声明仅可以在函数内部使用
  • 使用简式声明重复声明变量
  • 偶然的变量隐藏Accidental Variable Shadowing
  • 不使用显式类型,无法使用“nil”来初始化变量
  • 使用“nil” Slices and Maps
  • Map的容量
  • 字符串不会为“nil”
  • Array函数的参数
  • 在Slice和Array使用“range”语句时的出现的不希望得到的值
  • Slices和Arrays是一维的
  • 访问不存在的Map Keys
  • Strings无法修改
  • String和Byte Slice之间的转换
  • String和索引操作
  • 字符串不总是UTF8文本
  • 字符串的长度
  • 在多行的Slice、Array和Map语句中遗漏逗号
  • log.Fatal和log.Panic不仅仅是Log
  • 内建的数据结构操作不是同步的
  • String在“range”语句中的迭代值
  • 对Map使用“for range”语句迭代
  • “switch”声明中的失效行为
  • 自增和自减
  • 按位NOT操作
  • 操作优先级的差异
  • 未导出的结构体不会被编码
  • 有活动的Goroutines下的应用退出
  • 向无缓存的Channel发送消息,只要目标接收者准备好就会立即返回
  • 向已关闭的Channel发送会引起Panic
  • 使用”nil” Channels
  • 传值方法的接收者无法修改原有的值
  • 进阶篇
  • 关闭HTTP的响应
  • 关闭HTTP的连接
  • 比较Structs, Arrays, Slices, and Maps
  • 从Panic中恢复
  • 在Slice, Array, and Map “range”语句中更新引用元素的值
  • 在Slice中”隐藏”数据
  • Slice的数据“毁坏”
  • “走味的”Slices
  • 类型声明和方法
  • 从”for switch”和”for select”代码块中跳出
  • “for”声明中的迭代变量和闭包
  • Defer函数调用参数的求值
  • 被Defer的函数调用执行
  • 失败的类型断言
  • 阻塞的Goroutine和资源泄露
  • 高级篇
  • 使用指针接收方法的值的实例
  • 更新Map的值
  • “nil” Interfaces和”nil” Interfaces的值
  • 栈和堆变量
  • GOMAXPROCS, 并发, 和并行
  • 读写操作的重排顺序
  • 优先调度

初级篇

开大括号不能放在单独的一行

  • level: beginner

在大多数其他使用大括号的语言中,你需要选择放置它们的位置。Go的方式不同。你可以为此感谢下自动分号的注入(没有预读)。是的,Go中也是有分号的:-)

失败的例子:

1
2
3
4
5
6
go复制代码package main

import"fmt"

func main(){//error, can't have the opening brace on a separate line
fmt.Println("hello there!")}

编译错误:

1
maxima复制代码/tmp/sandbox826898458/main.go:6: syntax error: unexpected semicolon or newline before {

有效的例子:

1
2
3
4
5
6
go复制代码package main

import"fmt"

func main(){
fmt.Println("works!")}

未使用的变量

  • level: beginner

如果你有未使用的变量,代码将编译失败。当然也有例外。在函数内一定要使用声明的变量,但未使用的全局变量是没问题的。

如果你给未使用的变量分配了一个新的值,代码还是会编译失败。你需要在某个地方使用这个变量,才能让编译器愉快的编译。

Fails:

1
2
3
4
5
6
7
go复制代码package main

var gvar int//not an error

func main(){var one int//error, unused variable
two :=2//error, unused variablevar three int//error, even though it's assigned 3 on the next line
three =3}

Compile Errors:

1
awk复制代码/tmp/sandbox473116179/main.go:6: one declared andnot used /tmp/sandbox473116179/main.go:7: two declared andnot used /tmp/sandbox473116179/main.go:8: three declared andnot used

Works:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
go复制代码package main

import"fmt"

func main(){var one int
_ = one

two :=2
fmt.Println(two)var three int
three =3
one = three

var four int
four = four
}

另一个选择是注释掉或者移除未使用的变量 :-)

未使用的Imports

  • level: beginner

如果你引入一个包,而没有使用其中的任何函数、接口、结构体或者变量的话,代码将会编译失败。

如果你真的需要引入的包,你可以添加一个下划线标记符, _,来作为这个包的名字,从而避免编译失败。下滑线标记符用于引入,但不使用。

Fails:

1
2
3
4
5
stylus复制代码package main

import("fmt""log""time")

func main(){}

Compile Errors:

1
awk复制代码/tmp/sandbox627475386/main.go:4: imported andnot used:"fmt"/tmp/sandbox627475386/main.go:5: imported andnot used:"log"/tmp/sandbox627475386/main.go:6: imported andnot used:"time"

Works:

1
2
3
4
5
6
7
haxe复制代码package main

import(
_ "fmt""log""time")var _ = log.Println

func main(){
_ = time.Now}

另一个选择是移除或者注释掉未使用的imports :-)

简式的变量声明仅可以在函数内部使用

  • level: beginner

Fails:

1
2
3
4
5
go复制代码package main

myvar :=1//error

func main(){}

Compile Error:

1
awk复制代码/tmp/sandbox265716165/main.go:3: non-declaration statement outside function body

Works:

1
2
3
4
5
go复制代码package main

var myvar =1

func main(){}

使用简式声明重复声明变量

  • level: beginner

你不能在一个单独的声明中重复声明一个变量,但在多变量声明中这是允许的,其中至少要有一个新的声明变量。

重复变量需要在相同的代码块内,否则你将得到一个隐藏变量。

Fails:

1
2
3
4
5
go复制代码package main

func main(){
one :=0
one :=1//error}

Compile Error:

1
stylus复制代码/tmp/sandbox706333626/main.go:5:nonew variables on left side of :=

Works:

1
2
3
4
5
6
7
8
stata复制代码package main

func main(){
one :=0
one, two :=1,2

one,two = two,one
}

偶然的变量隐藏Accidental Variable Shadowing

  • level: beginner

短式变量声明的语法如此的方便(尤其对于那些使用过动态语言的开发者而言),很容易让人把它当成一个正常的分配操作。如果你在一个新的代码块中犯了这个错误,将不会出现编译错误,但你的应用将不会做你所期望的事情。

1
2
3
4
5
6
7
8
9
10
11
go复制代码package main

import"fmt"

func main(){
x :=1
fmt.Println(x)//prints 1{
fmt.Println(x)//prints 1
x :=2
fmt.Println(x)//prints 2}
fmt.Println(x)//prints 1 (bad if you need 2)}

即使对于经验丰富的Go开发者而言,这也是一个非常常见的陷阱。这个坑很容易挖,但又很难发现。

不使用显式类型,无法使用“nil”来初始化变量

  • level: beginner

“nil”标志符用于表示interface、函数、maps、slices和channels的“零值”。如果你不指定变量的类型,编译器将无法编译你的代码,因为它猜不出具体的类型。

Fails:

1
2
3
4
5
6
go复制代码package main

func main(){var x =nil//error

_ = x
}

Compile Error:

1
awk复制代码/tmp/sandbox188239583/main.go:4:use of untyped nil

Works:

1
2
3
4
5
6
go复制代码package main

func main(){var x interface{}=nil

_ = x
}

使用“nil” Slices and Maps

  • level: beginner

在一个“nil”的slice中添加元素是没问题的,但对一个map做同样的事将会生成一个运行时的panic。

Works:

1
2
3
4
go复制代码package main

func main(){var s []int
s = append(s,1)}

Fails:

1
2
3
4
go复制代码package main

func main(){var m map[string]int
m["one"]=1//error}

Map的容量

  • level: beginner

你可以在map创建时指定它的容量,但你无法在map上使用cap()函数。

Fails:

1
2
3
4
5
go复制代码package main

func main(){
m := make(map[string]int,99)
cap(m)//error}

Compile Error:

1
go复制代码/tmp/sandbox326543983/main.go:5: invalid argument m (type map[string]int)for cap

字符串不会为“nil”

  • level: beginner

这对于经常使用“nil”分配字符串变量的开发者而言是个需要注意的地方。

Fails:

1
2
3
4
go复制代码package main

func main(){var x string=nil//errorif x ==nil{//error
x ="default"}}

Compile Errors:

1
awk复制代码/tmp/sandbox630560459/main.go:4: cannot usenilas type stringin assignment /tmp/sandbox630560459/main.go:6: invalid operation: x ==nil(mismatched types stringandnil)

Works:

1
2
3
4
go复制代码package main

func main(){var x string//defaults to "" (zero value)if x ==""{
x ="default"}}

Array函数的参数

-level: beginner

如果你是一个C或则C++开发者,那么数组对你而言就是指针。当你向函数中传递数组时,函数会参照相同的内存区域,这样它们就可以修改原始的数据。Go中的数组是数值,因此当你向函数中传递数组时,函数会得到原始数组数据的一份复制。如果你打算更新数组的数据,这将会是个问题。

1
2
3
4
5
6
7
8
9
10
11
12
go复制代码package main

import"fmt"

func main(){
x :=[3]int{1,2,3}

func(arr [3]int){
arr[0]=7
fmt.Println(arr)//prints [7 2 3]}(x)

fmt.Println(x)//prints [1 2 3] (not ok if you need [7 2 3])}

如果你需要更新原始数组的数据,你可以使用数组指针类型。

1
2
3
4
5
6
7
8
9
10
11
go复制代码package main

import"fmt"

func main(){
x :=[3]int{1,2,3}

func(arr *[3]int){(*arr)[0]=7
fmt.Println(arr)//prints &[7 2 3]}(&x)

fmt.Println(x)//prints [7 2 3]}

另一个选择是使用slice。即使你的函数得到了slice变量的一份拷贝,它依旧会参照原始的数据。

1
2
3
4
5
6
7
8
9
10
11
12
go复制代码package main

import"fmt"

func main(){
x :=[]int{1,2,3}

func(arr []int){
arr[0]=7
fmt.Println(arr)//prints [7 2 3]}(x)

fmt.Println(x)//prints [7 2 3]}

在Slice和Array使用“range”语句时的出现的不希望得到的值

  • level: beginner

如果你在其他的语言中使用“for-in”或者“foreach”语句时会发生这种情况。Go中的“range”语法不太一样。它会得到两个值:第一个值是元素的索引,而另一个值是元素的数据。

Bad:

1
2
3
4
5
6
7
go复制代码package main

import"fmt"

func main(){
x :=[]string{"a","b","c"}for v := range x {
fmt.Println(v)//prints 0, 1, 2}}

Good:

1
2
3
4
5
6
7
go复制代码package main

import"fmt"

func main(){
x :=[]string{"a","b","c"}for _, v := range x {
fmt.Println(v)//prints a, b, c}}

Slices和Arrays是一维的

  • level: beginner

看起来Go好像支持多维的Array和Slice,但不是这样的。尽管可以创建数组的数组或者切片的切片。对于依赖于动态多维数组的数值计算应用而言,Go在性能和复杂度上还相距甚远。

你可以使用纯一维数组、“独立”切片的切片,“共享数据”切片的切片来构建动态的多维数组。

如果你使用纯一维的数组,你需要处理索引、边界检查、当数组需要变大时的内存重新分配。

使用“独立”slice来创建一个动态的多维数组需要两步。首先,你需要创建一个外部的slice。然后,你需要分配每个内部的slice。内部的slice相互之间独立。你可以增加减少它们,而不会影响其他内部的slice。

1
2
3
4
5
6
7
8
go复制代码package main

func main(){
x :=2
y :=4

table := make([][]int,x)for i:= range table {
table[i]= make([]int,y)}}

使用“共享数据”slice的slice来创建一个动态的多维数组需要三步。首先,你需要创建一个用于存放原始数据的数据“容器”。然后,你再创建外部的slice。最后,通过重新切片原始数据slice来初始化各个内部的slice。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
stylus复制代码package main

import"fmt"

func main(){
h, w :=2,4

raw := make([]int,h*w)for i := range raw {
raw[i]= i
}
fmt.Println(raw,&raw[4])//prints: [0 1 2 3 4 5 6 7] <ptr_addr_x>

table := make([][]int,h)for i:= range table {
table[i]= raw[i*w:i*w + w]}

fmt.Println(table,&table[1][0])//prints: [[0 1 2 3] [4 5 6 7]] <ptr_addr_x>}

关于多维array和slice已经有了专门申请,但现在看起来这是个低优先级的特性。

访问不存在的Map Keys

-level: beginner

这对于那些希望得到“nil”标示符的开发者而言是个技巧(和其他语言中做的一样)。如果对应的数据类型的“零值”是“nil”,那返回的值将会是“nil”,但对于其他的数据类型是不一样的。检测对应的“零值”可以用于确定map中的记录是否存在,但这并不总是可信(比如,如果在二值的map中“零值”是false,这时你要怎么做)。检测给定map中的记录是否存在的最可信的方法是,通过map的访问操作,检查第二个返回的值。

Bad:

1
2
3
4
5
6
7
go复制代码package main

import"fmt"

func main(){
x := map[string]string{"one":"a","two":"","three":"c"}if v := x["two"]; v ==""{//incorrect
fmt.Println("no entry")}}

Good:

1
2
3
4
5
6
7
go复制代码package main

import"fmt"

func main(){
x := map[string]string{"one":"a","two":"","three":"c"}if _,ok := x["two"];!ok {
fmt.Println("no entry")}}

Strings无法修改

  • level: beginner

尝试使用索引操作来更新字符串变量中的单个字符将会失败。string是只读的byte slice(和一些额外的属性)。如果你确实需要更新一个字符串,那么使用byte slice,并在需要时把它转换为string类型。

Fails:

1
2
3
4
5
6
7
8
9
go复制代码package main

import"fmt"

func main(){
x :="text"
x[0]='T'

fmt.Println(x)}

Compile Error:

1
stylus复制代码/tmp/sandbox305565531/main.go:7: cannot assign to x[0]

Works:

1
2
3
4
5
6
7
8
9
10
go复制代码package main

import"fmt"

func main(){
x :="text"
xbytes :=[]byte(x)
xbytes[0]='T'

fmt.Println(string(xbytes))//prints Text}

需要注意的是:这并不是在文字string中更新字符的正确方式,因为给定的字符可能会存储在多个byte中。如果你确实需要更新一个文字string,先把它转换为一个rune slice。即使使用rune slice,单个字符也可能会占据多个rune,比如当你的字符有特定的重音符号时就是这种情况。这种复杂又模糊的“字符”本质是Go字符串使用byte序列表示的原因。

String和Byte Slice之间的转换

  • level: beginner

当你把一个字符串转换为一个byte slice(或者反之)时,你就得到了一个原始数据的完整拷贝。这和其他语言中cast操作不同,也和新的slice变量指向原始byte slice使用的相同数组时的重新slice操作不同。

Go在 []byte到 string和 string到 []byte的转换中确实使用了一些优化来避免额外的分配(在todo列表中有更多的优化)。

第一个优化避免了当 []bytekey用于在 map[string]集合中查询时的额外分配: m[string(key)]。

第二个优化避免了字符串转换为 []byte后在 for range语句中的额外分配: for i,v := range []byte(str){...}。

String和索引操作

  • level: beginner

字符串上的索引操作返回一个byte值,而不是一个字符(和其他语言中的做法一样)。

1
2
3
4
5
6
7
8
stylus复制代码package main

import"fmt"

func main(){
x :="text"
fmt.Println(x[0])//print 116
fmt.Printf("%T",x[0])//prints uint8}

如果你需要访问特定的字符串“字符”(unicode编码的points/runes),使用 for range。官方的“unicode/utf8”包和实验中的utf8string包(golang.org/x/exp/utf8string)也可以用。utf8string包中包含了一个很方便的 At()方法。把字符串转换为rune的切片也是一个选项。

字符串不总是UTF8文本

  • level: beginner

字符串的值不需要是UTF8的文本。它们可以包含任意的字节。只有在string literal使用时,字符串才会是UTF8。即使之后它们可以使用转义序列来包含其他的数据。

为了知道字符串是否是UTF8,你可以使用“unicode/utf8”包中的 ValidString()函数。

1
2
3
4
5
6
7
8
9
10
stylus复制代码package main

import("fmt""unicode/utf8")

func main(){
data1 :="ABC"
fmt.Println(utf8.ValidString(data1))//prints: true

data2 :="A\xfeC"
fmt.Println(utf8.ValidString(data2))//prints: false}

字符串的长度

  • level: beginner

让我们假设你是Python开发者,你有下面这段代码:

1
python复制代码data = u'♥'print(len(data))#prints: 1

当把它转换为Go代码时,你可能会大吃一惊。

1
2
3
4
5
6
7
go复制代码package main

import"fmt"

func main(){
data :="♥"
fmt.Println(len(data))//prints: 3}

内建的 len()函数返回byte的数量,而不是像Python中计算好的unicode字符串中字符的数量。

要在Go中得到相同的结果,可以使用“unicode/utf8”包中的 RuneCountInString()函数。

1
2
3
4
5
6
7
stylus复制代码package main

import("fmt""unicode/utf8")

func main(){
data :="♥"
fmt.Println(utf8.RuneCountInString(data))//prints: 1}

理论上说 RuneCountInString()函数并不返回字符的数量,因为单个字符可能占用多个rune。

1
2
3
4
5
6
7
8
stylus复制代码package main

import("fmt""unicode/utf8")

func main(){
data :="é"
fmt.Println(len(data))//prints: 3
fmt.Println(utf8.RuneCountInString(data))//prints: 2}

在多行的Slice、Array和Map语句中遗漏逗号

  • level: beginner

Fails:

1
2
3
4
5
6
go复制代码package main

func main(){
x :=[]int{1,2//error}
_ = x
}

Compile Errors:

1
awk复制代码/tmp/sandbox367520156/main.go:6: syntax error: need trailing comma before newline in composite literal /tmp/sandbox367520156/main.go:8: non-declaration statement outside function body /tmp/sandbox367520156/main.go:9: syntax error: unexpected }

Works:

1
2
3
4
5
6
7
8
9
go复制代码package main

func main(){
x :=[]int{1,2,}
x = x

y :=[]int{3,4,}//no error
y = y
}

当你把声明折叠到单行时,如果你没加末尾的逗号,你将不会得到编译错误。

log.Fatal和log.Panic不仅仅是Log

  • level: beginner

Logging库一般提供不同的log等级。与这些logging库不同,Go中log包在你调用它的 Fatal*()和 Panic*()函数时,可以做的不仅仅是log。当你的应用调用这些函数时,Go也将会终止应用 :-)

1
2
3
4
5
6
7
go复制代码package main

import"log"

func main(){
log.Fatalln("Fatal Level: log entry")//app exits here
log.Println("Normal Level: log entry")}

内建的数据结构操作不是同步的

  • level: beginner

即使Go本身有很多特性来支持并发,并发安全的数据集合并不是其中之一 :-)确保数据集合以原子的方式更新是你的职责。Goroutines和channels是实现这些原子操作的推荐方式,但你也可以使用“sync”包,如果它对你的应用有意义的话。

String在“range”语句中的迭代值

  • level: beginner

索引值(“range”操作返回的第一个值)是返回的第二个值的当前“字符”(unicode编码的point/rune)的第一个byte的索引。它不是当前“字符”的索引,这与其他语言不同。注意真实的字符可能会由多个rune表示。如果你需要处理字符,确保你使用了“norm”包(golang.org/x/text/unicode/norm)。

string变量的 for range语句将会尝试把数据翻译为UTF8文本。对于它无法理解的任何byte序列,它将返回0xfffd runes(即unicode替换字符),而不是真实的数据。如果你任意(非UTF8文本)的数据保存在string变量中,确保把它们转换为byte slice,以得到所有保存的数据。

1
2
3
4
5
6
7
8
9
10
go复制代码package main

import"fmt"

func main(){
data :="A\xfe\x02\xff\x04"for _,v := range data {
fmt.Printf("%#x ",v)}//prints: 0x41 0xfffd 0x2 0xfffd 0x4 (not ok)

fmt.Println()for _,v := range []byte(data){
fmt.Printf("%#x ",v)}//prints: 0x41 0xfe 0x2 0xff 0x4 (good)}

对Map使用“for range”语句迭代

  • level: beginner

如果你希望以某个顺序(比如,按key值排序)的方式得到元素,就需要这个技巧。每次的map迭代将会生成不同的结果。Go的runtime有心尝试随机化迭代顺序,但并不总会成功,这样你可能得到一些相同的map迭代结果。所以如果连续看到5个相同的迭代结果,不要惊讶。

1
2
3
4
5
6
7
go复制代码package main

import"fmt"

func main(){
m := map[string]int{"one":1,"two":2,"three":3,"four":4}for k,v := range m {
fmt.Println(k,v)}}

而且如果你使用Go的游乐场(play.golang.org/),你将总会得到同样的结果,因为除非你修改代码,否则它不会重新编译代码。

“switch”声明中的失效行为

  • level: beginner

在“switch”声明语句中的“case”语句块在默认情况下会break。这和其他语言中的进入下一个“next”代码块的默认行为不同。

1
2
3
4
5
6
7
8
9
pgsql复制代码package main

import"fmt"

func main(){
isSpace := func(ch byte)bool{switch(ch){case' '://errorcase'\t':returntrue}returnfalse}

fmt.Println(isSpace('\t'))//prints true (ok)
fmt.Println(isSpace(' '))//prints false (not ok)}

你可以通过在每个“case”块的结尾使用“fallthrough”,来强制“case”代码块进入。你也可以重写switch语句,来使用“case”块中的表达式列表。

1
2
3
4
5
6
7
8
9
go复制代码package main

import"fmt"

func main(){
isSpace := func(ch byte)bool{switch(ch){case' ','\t':returntrue}returnfalse}

fmt.Println(isSpace('\t'))//prints true (ok)
fmt.Println(isSpace(' '))//prints true (ok)}

自增和自减

  • level: beginner

许多语言都有自增和自减操作。不像其他语言,Go不支持前置版本的操作。你也无法在表达式中使用这两个操作符。

Fails:

1
2
3
4
5
6
7
8
go复制代码package main

import"fmt"

func main(){
data :=[]int{1,2,3}
i :=0++i //error
fmt.Println(data[i++])//error}

Compile Errors:

1
awk复制代码/tmp/sandbox101231828/main.go:8: syntax error: unexpected ++/tmp/sandbox101231828/main.go:9: syntax error: unexpected ++, expecting :

Works:

1
2
3
4
5
6
7
8
9
go复制代码package main

import"fmt"

func main(){
data :=[]int{1,2,3}
i :=0
i++
fmt.Println(data[i])}

按位NOT操作

  • level: beginner

许多语言使用 ~ 作为一元的NOT操作符(即按位补足),但Go为了这个重用了XOR操作符( ^)。

Fails:

1
2
3
4
5
6
go复制代码package main

import"fmt"

func main(){
fmt.Println(~2)//error}

Compile Error:

1
awk复制代码/tmp/sandbox965529189/main.go:6: the bitwise complement operatoris^

Works:

1
2
3
4
5
6
go复制代码package main

import"fmt"

func main(){var d uint8 =2
fmt.Printf("%08b\n",^d)}

Go依旧使用 ^作为XOR的操作符,这可能会让一些人迷惑。

如果你愿意,你可以使用一个二元的XOR操作(如, 0x02 XOR 0xff)来表示一个一元的NOT操作(如,NOT 0x02)。这可以解释为什么 ^被重用来表示一元的NOT操作。

Go也有特殊的‘AND NOT’按位操作( &^),这也让NOT操作更加的让人迷惑。这看起来需要特殊的特性/hack来支持 A AND (NOT B),而无需括号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
reasonml复制代码package main

import"fmt"

func main(){var a uint8 =0x82var b uint8 =0x02
fmt.Printf("%08b [A]\n",a)
fmt.Printf("%08b [B]\n",b)

fmt.Printf("%08b (NOT B)\n",^b)
fmt.Printf("%08b ^ %08b = %08b [B XOR 0xff]\n",b,0xff,b ^0xff)

fmt.Printf("%08b ^ %08b = %08b [A XOR B]\n",a,b,a ^ b)
fmt.Printf("%08b & %08b = %08b [A AND B]\n",a,b,a & b)
fmt.Printf("%08b &^%08b = %08b [A 'AND NOT' B]\n",a,b,a &^ b)
fmt.Printf("%08b&(^%08b)= %08b [A AND (NOT B)]\n",a,b,a &(^b))}

操作优先级的差异

  • level: beginner

除了”bit clear“操作( &^),Go也一个与许多其他语言共享的标准操作符的集合。尽管操作优先级并不总是一样。

1
2
3
4
5
6
7
8
9
10
apache复制代码package main

import"fmt"

func main(){
fmt.Printf("0x2 & 0x2 + 0x4 -> %#x\n",0x2&0x2+0x4)//prints: 0x2 & 0x2 + 0x4 -> 0x6//Go: (0x2 & 0x2) + 0x4//C++: 0x2 & (0x2 + 0x4) -> 0x2

fmt.Printf("0x2 + 0x2 << 0x1 -> %#x\n",0x2+0x2<<0x1)//prints: 0x2 + 0x2 << 0x1 -> 0x6//Go: 0x2 + (0x2 << 0x1)//C++: (0x2 + 0x2) << 0x1 -> 0x8

fmt.Printf("0xf | 0x2 ^ 0x2 -> %#x\n",0xf|0x2^0x2)//prints: 0xf | 0x2 ^ 0x2 -> 0xd//Go: (0xf | 0x2) ^ 0x2//C++: 0xf | (0x2 ^ 0x2) -> 0xf}

未导出的结构体不会被编码

  • level: beginner

以小写字母开头的结构体将不会被(json、xml、gob等)编码,因此当你编码这些未导出的结构体时,你将会得到零值。

Fails:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
stylus复制代码package main

import("fmt""encoding/json")

type MyDatastruct{Oneint
two string}

func main(){in:=MyData{1,"two"}
fmt.Printf("%#v\n",in)//prints main.MyData{One:1, two:"two"}

encoded,_ := json.Marshal(in)
fmt.Println(string(encoded))//prints {"One":1}varoutMyData
json.Unmarshal(encoded,&out)

fmt.Printf("%#v\n",out)//prints main.MyData{One:1, two:""}}

有活动的Goroutines下的应用退出

  • level: beginner

应用将不会得带所有的goroutines完成。这对于初学者而言是个很常见的错误。每个人都是以某个程度开始,因此如果犯了初学者的错误也没神马好丢脸的 :-)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
go复制代码package main

import("fmt""time")

func main(){
workerCount :=2for i :=0; i < workerCount; i++{
go doit(i)}
time.Sleep(1* time.Second)
fmt.Println("all done!")}

func doit(workerId int){
fmt.Printf("[%v] is running\n",workerId)
time.Sleep(3* time.Second)
fmt.Printf("[%v] is done\n",workerId)}

你将会看到:

1
2
3
angelscript复制代码[0]is running
[1]is running
all done!

一个最常见的解决方法是使用“WaitGroup”变量。它将会让主goroutine等待所有的worker goroutine完成。如果你的应用有长时运行的消息处理循环的worker,你也将需要一个方法向这些goroutine发送信号,让它们退出。你可以给各个worker发送一个“kill”消息。另一个选项是关闭一个所有worker都接收的channel。这是一次向所有goroutine发送信号的简单方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
go复制代码package main

import("fmt""sync")

func main(){var wg sync.WaitGroupdone:= make(chan struct{})
workerCount :=2for i :=0; i < workerCount; i++{
wg.Add(1)
go doit(i,done,wg)}

close(done)
wg.Wait()
fmt.Println("all done!")}

func doit(workerId int,done<-chan struct{},wg sync.WaitGroup){
fmt.Printf("[%v] is running\n",workerId)
defer wg.Done()<-done
fmt.Printf("[%v] is done\n",workerId)}

如果你运行这个应用,你将会看到:

1
2
3
inform7复制代码[0]is running
[0]isdone[1]is running
[1]isdone

看起来所有的worker在主goroutine退出前都完成了。棒!然而,你也将会看到这个:

1
nginx复制代码fatal error: all goroutines are asleep - deadlock!

这可不太好 :-) 发送了神马?为什么会出现死锁?worker退出了,它们也执行了 wg.Done()。应用应该没问题啊。

死锁发生是因为各个worker都得到了原始的“WaitGroup”变量的一个拷贝。当worker执行 wg.Done()时,并没有在主goroutine上的“WaitGroup”变量上生效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
go复制代码package main

import("fmt""sync")

func main(){var wg sync.WaitGroupdone:= make(chan struct{})
wq := make(chan interface{})
workerCount :=2for i :=0; i < workerCount; i++{
wg.Add(1)
go doit(i,wq,done,&wg)}for i :=0; i < workerCount; i++{
wq <- i
}

close(done)
wg.Wait()
fmt.Println("all done!")}

func doit(workerId int, wq <-chan interface{},done<-chan struct{},wg *sync.WaitGroup){
fmt.Printf("[%v] is running\n",workerId)
defer wg.Done()for{select{case m :=<- wq:
fmt.Printf("[%v] m => %v\n",workerId,m)case<-done:
fmt.Printf("[%v] is done\n",workerId)return}}}

现在它会如预期般工作 :-)

向无缓存的Channel发送消息,只要目标接收者准备好就会立即返回

  • level: beginner

发送者将不会被阻塞,除非消息正在被接收者处理。根据你运行代码的机器的不同,接收者的goroutine可能会或者不会有足够的时间,在发送者继续执行前处理消息。

1
2
3
4
5
6
7
8
9
10
11
12
go复制代码package main

import"fmt"

func main(){
ch := make(chan string)

go func(){for m := range ch {
fmt.Println("processed:",m)}}()

ch <-"cmd.1"
ch <-"cmd.2"//won't be processed}

向已关闭的Channel发送会引起Panic

  • level: beginner

从一个关闭的channel接收是安全的。在接收状态下的 ok的返回值将被设置为 false,这意味着没有数据被接收。如果你从一个有缓存的channel接收,你将会首先得到缓存的数据,一旦它为空,返回的 ok值将变为 false。

向关闭的channel中发送数据会引起panic。这个行为有文档说明,但对于新的Go开发者的直觉不同,他们可能希望发送行为与接收行为很像。

1
2
3
4
5
6
7
8
9
10
11
go复制代码package main

import("fmt""time")

func main(){
ch := make(chan int)for i :=0; i <3; i++{
go func(idx int){
ch <-(idx +1)*2}(i)}//get the first result
fmt.Println(<-ch)
close(ch)//not ok (you still have other senders)//do other work
time.Sleep(2* time.Second)}

根据不同的应用,修复方法也将不同。可能是很小的代码修改,也可能需要修改应用的设计。无论是哪种方法,你都需要确保你的应用不会向关闭的channel中发送数据。

上面那个有bug的例子可以通过使用一个特殊的废弃的channel来向剩余的worker发送不再需要它们的结果的信号来修复。

1
2
3
4
5
6
7
8
9
10
go复制代码package main

import("fmt""time")

func main(){
ch := make(chan int)done:= make(chan struct{})for i :=0; i <3; i++{
go func(idx int){select{case ch <-(idx +1)*2: fmt.Println(idx,"sent result")case<-done: fmt.Println(idx,"exiting")}}(i)}//get first result
fmt.Println("result:",<-ch)
close(done)//do other work
time.Sleep(3* time.Second)}

使用”nil” Channels

  • level: beginner

在一个 nil的channel上发送和接收操作会被永久阻塞。这个行为有详细的文档解释,但它对于新的Go开发者而言是个惊喜。

1
2
3
4
5
6
7
8
9
go复制代码package main

import("fmt""time")

func main(){var ch chan intfor i :=0; i <3; i++{
go func(idx int){
ch <-(idx +1)*2}(i)}//get first result
fmt.Println("result:",<-ch)//do other work
time.Sleep(2* time.Second)}

如果运行代码你将会看到一个runtime错误:

1
nginx复制代码fatal error: all goroutines are asleep - deadlock!

这个行为可以在 select声明中用于动态开启和关闭 case代码块的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
go复制代码package main

import"fmt"import"time"

func main(){
inch := make(chan int)
outch := make(chan int)

go func(){varin<- chan int= inch
varout chan <-intvar val intfor{select{caseout<- val:out=nilin= inch
case val =<-in:out= outch
in=nil}}}()

go func(){for r := range outch {
fmt.Println("result:",r)}}()

time.Sleep(0)
inch <-1
inch <-2
time.Sleep(3* time.Second)}

传值方法的接收者无法修改原有的值

  • level: beginner

方法的接收者就像常规的函数参数。如果声明为值,那么你的函数/方法得到的是接收者参数的拷贝。这意味着对接收者所做的修改将不会影响原有的值,除非接收者是一个map或者slice变量,而你更新了集合中的元素,或者你更新的域的接收者是指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
stylus复制代码package main

import"fmt"

type data struct{
num int
key *string
items map[string]bool}

func (this*data) pmethod(){this.num =7}

func (this data) vmethod(){this.num =8*this.key ="v.key"this.items["vmethod"]=true}

func main(){
key :="key.1"
d := data{1,&key,make(map[string]bool)}

fmt.Printf("num=%v key=%v items=%v\n",d.num,*d.key,d.items)//prints num=1 key=key.1 items=map[]

d.pmethod()
fmt.Printf("num=%v key=%v items=%v\n",d.num,*d.key,d.items)//prints num=7 key=key.1 items=map[]

d.vmethod()
fmt.Printf("num=%v key=%v items=%v\n",d.num,*d.key,d.items)//prints num=7 key=v.key items=map[vmethod:true]}

原文地址: levy.at/blog/11

本文转载自: 掘金

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

Shell脚本关于循环的一些总结

发表于 2020-05-18

不管是哪一门计算机语言,循环都是不可绕开的一个话题,Shell 当然也不是例外。下面总结一些 Shell 脚本里常用的循环相关的知识点,新手朋友可以参考。

for 循环

Shell 脚本里最简单的循环当属 for 循环,有编程基础的朋友应该都有使用过 for 循环。最简单的 for 循环如下所示,你只需将变量值依次写在 in 后面即可:

1
2
3
4
5
6
复制代码#!/bin/bash

for num in 1 2 3 4
do
echo $num
done

如果要循环的内容是字母表里的连续字母或连续数字,那么就可以按以下语法来写脚本:

1
2
3
4
5
6
复制代码#!/bin/bash

for x in {a..z}
do
echo $x
done

while 循环

除了 for 循环,Shell 同样提供了 while 循环。对于其它语言,如果你见过 for 循环却没见过 while 循环,那么你一定是学了个假语言。

在 while 循环里,每进行一次循环,条件都会被判断一次,来确定本次循环是否该继续。其实在循环次数比较少的情况下,for 循环与 while 循环效果差不多,但如果循环次数比较多,比如 10 万次,那么 while 循环的优势就体现出来了。

1
2
3
4
5
6
7
8
9
复制代码#!/bin/bash

n=1

while [ $n -le 4 ]
do
echo $n
((n++))
done

循环套循环

像其它高级语言一样,循环是可以互相嵌套的。比如下面这个例子,我们在 while 循环里再套入一个 for 循环:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码#!/bin/bash

n=1

while [ $n -lt 6 ]
do
for l in {a..d}
do
echo $n$l
done
((n++))
done

这个脚本执行的结果应该是 1a, 1b, 1c, 1d, 2a, 2b … 5d。

循环的内容是变化的

我们上面提到的 for 循环,循环变量要赋的值都列在了 in 后面的列表里了。但这样灵活性太差,因为在很多情况下,循环变量要获得的值是不固定的。

就比如,有个变量要获得当前系统上所有用户,但因为每台电脑用户都不一样,我们根本就没办法将这个变量写死。

在这种情况下,我们可以使用 ls 命令将 /home 目录下所有用户都列出来,然后用循环变量依次获取它们。完整代码如下:

1
2
3
4
5
6
复制代码#!/bin/bash

for user in `ls /home`
do
echo $user
done

当然,除了 ls ,Shell 还支持其它命令。比如我们可以使用 date 命令获取当前系统时间,再依次打印出来:

1
2
3
4
5
6
7
8
9
10
复制代码$ for word in `date`
> do
> echo $word
> done
Thu
Apr
9
08:12:09
CST
2020

变量值检查

我们在使用 while 循环时,经常需要判断一个变量的值是否大于或者小于某个数。有时候这个数也是用另一个变量来表示,那么我们就需要判断这个变量的值是否是数字。有三种判断方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码#!/bin/bash

echo -n "How many times should I say hello? "
read ans

if [ "$ans" -eq "$ans" ]; then
echo ok1
fi

if [[ $ans = *[[:digit:]]* ]]; then
echo ok2
fi

if [[ "$ans" =~ ^[0-9]+$ ]]; then
echo ok3
fi

第一种方法看起来似乎是个废话,但实际上,-eq 只能用于数值间判断,如果是字符串则判断不通过,所以这就保证了 ans 是个数值型变量。

第二种方法是直接使用 Shell 的通配符对变量进行判断。

第三种方法就更直接了,使用正则表达式对变量进行判断。

我们直接来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码#!/bin/bash

echo -n "How many times should I say hello? "
read ans

if [ "$ans" -eq "$ans" ]; then
n=1
while [ $n -le $ans ]
do
echo hello
((n++))
done
fi

在这个脚本里,我将要循环的次数传入到 ans 变量,然后脚本就具体打印几次 hello 。为了保证我们传入的内容是数字,我们使用了 if [ "$ans" -eq "$ans" ] 语句来判断。如果我们传入的不是数字,则不会进入 while 循环。

2020 精选 阿里/腾讯等一线大厂 面试、简历、进阶、电子书 公众号「良许Linux」后台回复「资料」免费获取

循环输出文本文件内容

如果你想按行依次循环输出文本文件的内容,可以这样操作:

1
2
3
4
5
6
7
8
9
10
复制代码#!/bin/bash

echo -n "File> "
read file
n=0

while read line; do
((n++))
echo "$n: $line"
done < $file

在这里,我们使用 read 命令将文本文件的内容读取存入 file 变量,然后再使用重定向(上述脚本最后一行)将 file 内容依次传入 while 循环处理再打印出来。

死循环

有时候我们需要一直永远循环做某件事,那么我们就可以使用死循环。达到这个目的很简单,只需使用 while true 即可。

1
2
3
4
5
6
7
8
复制代码#!/bin/bash

while true
do
echo -n "Still running at "
date
sleep 1
done

在以上这个脚本里,将每隔 1 秒打印一次 Still running at 具体时间 ,直到你按 Ctrl + C 终止这个脚本。

看完的都是真爱,点个赞再走呗?您的「三连」就是良许持续创作的最大动力!

  1. 关注原创公众号「良许Linux」,第一时间获取最新Linux干货!
  2. 公众号后台回复【资料】【面试】【简历】获取精选一线大厂面试、自我提升、简历等资料。
  3. 关注我的博客:lxlinux.net

本文转载自: 掘金

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

Spring的IOC,你真的能解释清楚吗?

发表于 2020-05-18

一直以来,SpringFramework 作为 Java 企业级开发的老大哥,面试中也常被问到。虽说有些基础性的问题可能不那么特别被面试官和求职者重视,但如果真的问起来,能不能回答的准确、全面、有深度,还是很容易体现出水平的。

在接下来的一个系列中,我会慢慢盘点一些 Spring 中常见但不好回答全面的问题,跟小伙伴们分享。

本文主题:Spring 的 IOC 如何回答的尽可能全面、准确、有深度。

问:什么是IOC?

这是一个看上去特别简单、感觉很无脑的问题,很多求职者在回答这个问题时会不加思考、快速回答出一个很简单但同时也没有什么含量的答案。

草率的回答

  • IOC 是控制反转,Inverse of Control 。

试问一句,亲,你在做名词翻译吗?

就算真的是在做名词翻译,也不应该只是把这个简称的全称解释出来就完事吧,好歹的展开解释点东西也好吧,作为面试官不应该只想听到这么一点点吧。

方向偏了

  • IOC 是控制反转,它把对象间的依赖关系的维护权利交给了 Spring ,程序本身不再维护。

这里面大体上把 IOC 的核心思想解释出来了:对象间的依赖关系的维护权利发生了转移,但是请小伙伴们注意,我们在问 IOC ,这个问题仅仅是问 IOC 本身,与具体的技术无关。IOC 不止有 Spring ,只是当下最强大的、使用最广的是 Spring 而已。

所以小伙伴们在回答理论、概念等问题时,不要直接在概念解释中提到具体的技术,技术都是概念和理论的落地实现,不止一种。单把一种拉出来,面试官可能会觉得:你是不是只知道这个?

一种参考回答

该答案仅供参考,可根据自身的知识储备动态调整。

IOC 全名控制反转 Inverse of Control,它是一种编程原则,它的设计和架构可以实现组件间的解耦,核心思想是将控制权转移出去。

这里面提到了几个点:

  • 编程原则:它是一种理论,而非具体的某种技术落地
  • 组件间的解耦:所谓耦合,就是上面提到的对象间的依赖关系;解耦,就是解除了对象间的依赖关系。
    • 提到解耦,有可能面试官会继续问“什么是解耦”,也有可能不会问 “为什么用 Spring ”了
  • 控制权的转移:IOC 为了实现解耦,将原有的对象间的主动依赖改为被动接收型依赖(由直接 new 变为 set )

问:IOC与DI的区别

如果对 IOC 的实现不是特别了解,或者只是用 SpringFramework 用的太习惯了,亦或是刻板的学习 SpringFramework ,那这个答案通常会是这样的:

  • IOC 就是 DI 。

如果回答出这个答案,而面试官碰巧也跟你一样,那恭喜你“瞎猫碰到死耗子”了!因为这个回答真的大错特错啊,IOC 不止有 DI 的!

正确的回答应该是:

  • IOC 是一种思想、编程原则,DI 是 IOC 思想的一种实现方式。
  • IOC 的实现方式有依赖查找( Dependency lookup )和依赖注入( Dependency Injection )。

上面已经介绍过了,IOC 仅仅是一种思想,它的意图是想让对象间的依赖控制发生转换。用过 SpringFramework 的小伙伴都知道,你没有在哪个地方见过直接写 IOC 的代码,都是由一些实现方式来体现 IOC 的。

如果按照上面这样回答,可能会引来下面一个问题:

依赖查找和依赖注入分别都是什么?如何区分它们?

针对这个问题,最好不要一上来就搬出代码解释,最好是先理论后代码。

一般情况下,对比依赖查找和依赖注入,通常可以从以下几个维度对比:

依赖查找 依赖注入
实现方式 使用上下文(容器)主动获取 依赖上下文被动接收
作用目标 通常是方法体内的局部变量,也可以是对象成员 通常是对象成员
API依赖 依赖 IOC 框架的 API(必须操纵容器的 API ) 可以不依赖(暴露 setter 方法即可)
applicationContext.getBean(beanName) public void setXXX() { … }

问:SpringFramework中实现的IOC有什么?

真的不会有小伙伴只能答出 ApplicationContext 吧,一开始学的时候应该知道还有个 BeanFactory 吧。这是 SpringFramework 中两个核心的 IOC 容器的抽象。BeanFactory 仅仅是提供了一个容器管理的基本能力,ApplicationContext 在此基础上做了更加完善、强大的扩展。具体的对比可以参照下表:

Feature BeanFactory ApplicationContext
Bean instantiation/wiring —— Bean的实例化和属性注入 Yes Yes
Integrated lifecycle management —— 生命周期管理 No Yes
Automatic BeanPostProcessor registration —— Bean后置处理器的支持 No Yes
Automatic BeanFactoryPostProcessor registration —— BeanFactory后置处理器的支持 No Yes
Convenient MessageSource access (for internalization) —— 消息转换服务(国际化) No Yes
Built-in ApplicationEvent publication mechanism —— 事件发布机制(事件驱动) No Yes

下面提供一个比较完整的示例答案,小伙伴们可以根据自己的知识储备和理解,调整这里面的一些描述细节:

BeanFactory 接口提供了一个抽象的配置和对象的管理机制,ApplicationContext 是 BeanFactory 的子接口,它简化了与 AOP 的整合、消息机制、事件机制,以及对 Web 环境的扩展( WebApplicationContext 等),BeanFactory 是没有这些扩展的。

ApplicationContext 主要扩展了以下功能:(括号内的部分是解释扩展功能的一些简单描述或者原理底层实现,能回答出来更好)

  • AOP的支持( AnnotationAwareAspectJAutoProxyCreator 作用于 Bean 的初始化之后 )
  • 配置元信息( BeanDefinition 、Environment 、注解等 )
  • 资源管理( Resource 抽象 )
  • 事件驱动机制( ApplicationEvent 、ApplicationListener )
  • 消息与国际化( LocaleResolver )
  • Environment 抽象(SpringFramework 3.1以后)

问:依赖注入的注入方式?有什么区别?

注意这个问题也是与 SpringFramework 无关的,注入的方式本身就应该是依赖注入的实现,至于框架的代码,那是人家对于这个方式的落地。

可从以下几个维度对比:

注入方式 被注入成员是否可变 是否依赖IOC框架的API 使用场景
构造器注入 不可变 否(xml、编程式注入不依赖) 不可变的固定注入
参数注入 不可变 是(只能通过标注注解来侵入式注入) 通常用于不可变的固定注入
setter注入 可变 否(xml、编程式注入不依赖) 可选属性的注入

基本上问这个问题的话,还可能会继续问另一个问题:

你觉得哪种方式好?为什么?

“莽夫”应聘者一看终于来开放式问题了,赶紧开始自由发挥了:

我觉得参数注入好,因为我写习惯了,给参数打注解多舒服啊!

你是这么说了,面试官咋想:就这?就这???进而对你的印象可能就会有所减分了。

这种问题,除了要表述主观看法之外,更多的是要根据一些既有的论述来辅助你的观点,最好的论述那一定是官方文档了。

SpringFramework 的官方文档在不同的版本推荐的注入方式是不同的:

  • SpringFramework 4.0.2 及之前是推荐 setter 注入,理由是一个 Bean 有多个依赖时,构造器的参数列表会很长;而且如果 Bean 中依赖的属性不都是必需的话,注入会变得更麻烦;
  • 4.0.3 及以后官方推荐构造器注入,理由是构造器注入的依赖是不可变的、完全初始化好的,且可以保证不为 null ;
  • 当然 4.0.3 及以后的官方文档中也说了,如果真的出现构造器参数列表过长的情况,可能是这个 Bean 承担的责任太多,应该考虑组件的责任拆解。

问:组件注入的注解有什么?有什么区别?

相信大多数小伙伴都能答出 @Autowired 和 @Resource 吧,如果答出这两个,那证明你应该用过,也会用。但你能回答出 @Inject ,证明你对这些注入的注解确实有了解。作为应聘者,在回答问题时一定是回答的尽可能全面为好,下面对这几种注解作一个对比:

注解 注入方式 是否支持@Primary 来源 Bean不存在时处理
@Autowired 根据类型注入 是 SpringFramework原生注解 可指定 required=false 来避免注入失败
@Resource 根据名称注入 否 JSR250规范 容器中不存在指定Bean会抛出异常
@Inject 根据类型注入 是 JSR330规范 ( 需要导jar包 ) 容器中不存在指定Bean会抛出异常

跟上面差不多,如果问到了这个问题,那就有可能继续被问到下面一个问题:

存在多个相同类型Bean时如何解决注入问题?

可能大多数小伙伴都能答出以下几种解决方案:

  • @Resource :根据名称指定注入的 Bean
  • @Qualifier :配合 @Autowired 注解使用,如果被标注的成员 / 方法在根据类型注入时发现有多个相同类型的 Bean ,则会根据该注解声明的 name 寻找特定的 bean
  • @Primary :配合 @Bean 注解使用,如果有多个相同类型的 Bean 同时注册到 IOC 容器中,使用 @Autowired 、@Inject 注解时会注入标注 @Primary 注解的 bean

其实你还可以提另外一种方案:把注入的字段名与 bean 的名称保持一致,这样也可以解决注入时报不唯一 Bean 的问题。

以上几个问题是关于 SpringFramework 与 IOC 部分的一些常见问题,倒是问题都不太陌生,但是小伙伴们想回答的全面、有深度,还是需要下下功夫的。希望小伙伴们能有所收获,在面试中流利回答,斩获 offer !


问题整理不易,不点个赞支持一下作者吗?(可怜巴巴)

本文转载自: 掘金

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

如何设计一个百万级的消息推送系统? 前言 技术选型 协议解析

发表于 2020-05-18

前言

首先迟到的祝大家中秋快乐。

最近一周多没有更新了。其实我一直想憋一个大招,分享一些大家感兴趣的干货。

鉴于最近我个人的工作内容,于是利用这三天小长假憋了一个出来(其实是玩了两天🤣)。


先简单说下本次的主题,由于我最近做的是物联网相关的开发工作,其中就不免会遇到和设备的交互。

最主要的工作就是要有一个系统来支持设备的接入、向设备推送消息;同时还得满足大量设备接入的需求。

所以本次分享的内容不但可以满足物联网领域同时还支持以下场景:

  • 基于 WEB 的聊天系统(点对点、群聊)。
  • WEB 应用中需求服务端推送的场景。
  • 基于 SDK 的消息推送平台。

技术选型

要满足大量的连接数、同时支持双全工通信,并且性能也得有保障。

在 Java 技术栈中进行选型首先自然是排除掉了传统 IO。

那就只有选 NIO 了,在这个层面其实选择也不多,考虑到社区、资料维护等方面最终选择了 Netty。

最终的架构图如下:

现在看着蒙没关系,下文一一介绍。

协议解析

既然是一个消息系统,那自然得和客户端定义好双方的协议格式。

常见和简单的是 HTTP 协议,但我们的需求中有一项需要是双全工的交互方式,同时 HTTP 更多的是服务于浏览器。我们需要的是一个更加精简的协议,减少许多不必要的数据传输。

因此我觉得最好是在满足业务需求的情况下定制自己的私有协议,在我这个场景下其实有标准的物联网协议。

如果是其他场景可以借鉴现在流行的 RPC 框架定制私有协议,使得双方通信更加高效。

不过根据这段时间的经验来看,不管是哪种方式都得在协议中预留安全相关的位置。

协议相关的内容就不过讨论了,更多介绍具体的应用。

简单实现

首先考虑如何实现功能,再来思考百万连接的情况。

注册鉴权

在做真正的消息上、下行之前首先要考虑的就是鉴权问题。

就像你使用微信一样,第一步怎么也得是登录吧,不能无论是谁都可以直接连接到平台。

所以第一步得是注册才行。

如上面架构图中的 注册/鉴权 模块。通常来说都需要客户端通过 HTTP 请求传递一个唯一标识,后台鉴权通过之后会响应一个 token,并将这个 token 和客户端的关系维护到 Redis 或者是 DB 中。

客户端将这个 token 也保存到本地,今后的每一次请求都得带上这个 token。一旦这个 token 过期,客户端需要再次请求获取 token。

鉴权通过之后客户端会直接通过TCP 长连接到图中的 push-server 模块。

这个模块就是真正处理消息的上、下行。

保存通道关系

在连接接入之后,真正处理业务之前需要将当前的客户端和 Channel 的关系维护起来。

假设客户端的唯一标识是手机号码,那就需要把手机号码和当前的 Channel 维护到一个 Map 中。

这点和之前 SpringBoot 整合长连接心跳机制 类似。

同时为了可以通过 Channel 获取到客户端唯一标识(手机号码),还需要在 Channel 中设置对应的属性:

123 public static void putClientId(Channel channel, String clientId) { channel.attr(CLIENT_ID).set(clientId);}

获取时手机号码时:

123 public static String getClientId(Channel channel) { return (String)getAttribute(channel, CLIENT_ID);}

这样当我们客户端下线的时便可以记录相关日志:

123 String telNo = NettyAttrUtil.getClientId(ctx.channel());NettySocketHolder.remove(telNo);log.info(“客户端下线,TelNo=” + telNo);

这里有一点需要注意:存放客户端与 Channel 关系的 Map 最好是预设好大小(避免经常扩容),因为它将是使用最为频繁同时也是占用内存最大的一个对象。

消息上行

接下来则是真正的业务数据上传,通常来说第一步是需要判断上传消息输入什么业务类型。

在聊天场景中,有可能上传的是文本、图片、视频等内容。

所以我们得进行区分,来做不同的处理;这就和客户端协商的协议有关了。

  • 可以利用消息头中的某个字段进行区分。
  • 更简单的就是一个 JSON 消息,拿出一个字段用于区分不同消息。

不管是哪种只有可以区分出来即可。

消息解析与业务解耦

消息可以解析之后便是处理业务,比如可以是写入数据库、调用其他接口等。

我们都知道在 Netty 中处理消息一般是在 channelRead() 方法中。

在这里可以解析消息,区分类型。

但如果我们的业务逻辑也写在里面,那这里的内容将是巨多无比。

甚至我们分为好几个开发来处理不同的业务,这样将会出现许多冲突、难以维护等问题。

所以非常有必要将消息解析与业务处理完全分离开来。

这时面向接口编程就发挥作用了。

这里的核心代码和 「造个轮子」——cicada(轻量级 WEB 框架) 是一致的。

都是先定义一个接口用于处理业务逻辑,然后在解析消息之后通过反射创建具体的对象执行其中的处理函数即可。

这样不同的业务、不同的开发人员只需要实现这个接口同时实现自己的业务逻辑即可。

伪代码如下:

想要了解 cicada 的具体实现请点击这里:

github.com/TogetherOS/…

上行还有一点需要注意;由于是基于长连接,所以客户端需要定期发送心跳包用于维护本次连接。同时服务端也会有相应的检查,N 个时间间隔没有收到消息之后将会主动断开连接节省资源。

这点使用一个 IdleStateHandler 就可实现,更多内容可以查看 Netty(一) SpringBoot 整合长连接心跳机制。

消息下行

有了上行自然也有下行。比如在聊天的场景中,有两个客户端连上了 push-server,他们直接需要点对点通信。

这时的流程是:

  • A 将消息发送给服务器。
  • 服务器收到消息之后,得知消息是要发送给 B,需要在内存中找到 B 的 Channel。
  • 通过 B 的 Channel 将 A 的消息转发下去。

这就是一个下行的流程。

甚至管理员需要给所有在线用户发送系统通知也是类似:

遍历保存通道关系的 Map,挨个发送消息即可。这也是之前需要存放到 Map 中的主要原因。

伪代码如下:

具体可以参考:

github.com/crossoverJi…

分布式方案

单机版的实现了,现在着重讲讲如何实现百万连接。

百万连接其实只是一个形容词,更多的是想表达如何来实现一个分布式的方案,可以灵活的水平拓展从而能支持更多的连接。

再做这个事前首先得搞清楚我们单机版的能支持多少连接。影响这个的因素就比较多了。

  • 服务器自身配置。内存、CPU、网卡、Linux 支持的最大文件打开数等。
  • 应用自身配置,因为 Netty 本身需要依赖于堆外内存,但是 JVM 本身也是需要占用一部分内存的,比如存放通道关系的大 Map。这点需要结合自身情况进行调整。

结合以上的情况可以测试出单个节点能支持的最大连接数。

单机无论怎么优化都是有上限的,这也是分布式主要解决的问题。

架构介绍

在将具体实现之前首先得讲讲上文贴出的整体架构图。

先从左边开始。

上文提到的 注册鉴权 模块也是集群部署的,通过前置的 Nginx 进行负载。之前也提过了它主要的目的是来做鉴权并返回一个 token 给客户端。

但是 push-server 集群之后它又多了一个作用。那就是得返回一台可供当前客户端使用的 push-server。

右侧的 平台 一般指管理平台,它可以查看当前的实时在线数、给指定客户端推送消息等。

推送消息则需要经过一个推送路由(push-server)找到真正的推送节点。

其余的中间件如:Redis、Zookeeper、Kafka、MySQL 都是为了这些功能所准备的,具体看下面的实现。

注册发现

首先第一个问题则是 注册发现,push-server 变为多台之后如何给客户端选择一台可用的节点是第一个需要解决的。

这块的内容其实已经在 分布式(一) 搞定服务注册与发现 中详细讲过了。

所有的 push-server 在启动时候需要将自身的信息注册到 Zookeeper 中。

注册鉴权 模块会订阅 Zookeeper 中的节点,从而可以获取最新的服务列表。结构如下:

以下是一些伪代码:

应用启动注册 Zookeeper。

对于注册鉴权模块来说只需要订阅这个 Zookeeper 节点:

路由策略

既然能获取到所有的服务列表,那如何选择一台刚好合适的 push-server 给客户端使用呢?

这个过程重点要考虑以下几点:

  • 尽量保证各个节点的连接均匀。
  • 增删节点是否要做 Rebalance。

首先保证均衡有以下几种算法:

  • 轮询。挨个将各个节点分配给客户端。但会出现新增节点分配不均匀的情况。
  • Hash 取模的方式。类似于 HashMap,但也会出现轮询的问题。当然也可以像 HashMap 那样做一次 Rebalance,让所有的客户端重新连接。不过这样会导致所有的连接出现中断重连,代价有点大。
  • 由于 Hash 取模方式的问题带来了一致性 Hash算法,但依然会有一部分的客户端需要 Rebalance。
  • 权重。可以手动调整各个节点的负载情况,甚至可以做成自动的,基于监控当某些节点负载较高就自动调低权重,负载较低的可以提高权重。

还有一个问题是:

当我们在重启部分应用进行升级时,在该节点上的客户端怎么处理?

由于我们有心跳机制,当心跳不通之后就可以认为该节点出现问题了。那就得重新请求注册鉴权模块获取一个可用的节点。在弱网情况下同样适用。

如果这时客户端正在发送消息,则需要将消息保存到本地等待获取到新的节点之后再次发送。

有状态连接

在这样的场景中不像是 HTTP 那样是无状态的,我们得明确的知道各个客户端和连接的关系。

在上文的单机版中我们将这个关系保存到本地的缓存中,但在分布式环境中显然行不通了。

比如在平台向客户端推送消息的时候,它得首先知道这个客户端的通道保存在哪台节点上。

借助我们以前的经验,这样的问题自然得引入一个第三方中间件用来存放这个关系。

也就是架构图中的存放路由关系的 Redis,在客户端接入 push-server 时需要将当前客户端唯一标识和服务节点的 ip+port 存进 Redis。

同时在客户端下线时候得在 Redis 中删掉这个连接关系。

这样在理想情况下各个节点内存中的 map 关系加起来应该正好等于 Redis 中的数据。

伪代码如下:

这里存放路由关系的时候会有并发问题,最好是换为一个 lua 脚本。

推送路由

设想这样一个场景:管理员需要给最近注册的客户端推送一个系统消息会怎么做?

结合架构图

假设这批客户端有 10W 个,首先我们需要将这批号码通过平台下的 Nginx 下发到一个推送路由中。

为了提高效率甚至可以将这批号码再次分散到每个 push-route 中。

拿到具体号码之后再根据号码的数量启动多线程的方式去之前的路由 Redis 中获取客户端所对应的 push-server。

再通过 HTTP 的方式调用 push-server 进行真正的消息下发(Netty 也很好的支持 HTTP 协议)。

推送成功之后需要将结果更新到数据库中,不在线的客户端可以根据业务再次推送等。

消息流转

也许有些场景对于客户端上行的消息非常看重,需要做持久化,并且消息量非常大。

在 push-sever 做业务显然不合适,这时完全可以选择 Kafka 来解耦。

将所有上行的数据直接往 Kafka 里丢后就不管了。

再由消费程序将数据取出写入数据库中即可。

其实这块内容也很值得讨论,可以先看这篇了解下:强如 Disruptor 也发生内存溢出?

后续谈到 Kafka 再做详细介绍。

分布式问题

分布式解决了性能问题但却带来了其他麻烦。

应用监控

比如如何知道线上几十个 push-server 节点的健康状况?

这时就得监控系统发挥作用了,我们需要知道各个节点当前的内存使用情况、GC。

以及操作系统本身的内存使用,毕竟 Netty 大量使用了堆外内存。

同时需要监控各个节点当前的在线数,以及 Redis 中的在线数。理论上这两个数应该是相等的。

这样也可以知道系统的使用情况,可以灵活的维护这些节点数量。

日志处理

日志记录也变得异常重要了,比如哪天反馈有个客户端一直连不上,你得知道问题出在哪里。

最好是给每次请求都加上一个 traceID 记录日志,这样就可以通过这个日志在各个节点中查看到底是卡在了哪里。

以及 ELK 这些工具都得用起来才行。

总结

本次是结合我日常经验得出的,有些坑可能在工作中并没有踩到,所有还会有一些遗漏的地方。

就目前来看想做一个稳定的推送系统其实是比较麻烦的,其中涉及到的点非常多,只有真正做过之后才会知道。

看完之后觉得有帮助的还请不吝转发分享。

往期文章

Nginx系列教程(1)nginx基本介绍和安装入门

Nginx系列教程(2)nginx搭建静态资源web服务器

Nginx系列教程(3)nginx缓存服务器上的静态文件

Nginx系列教程(4)nginx处理web应用负载均衡问题以保证高并发

Nginx系列教程(5)如何保障nginx的高可用性(keepalived)

Nginx系列教程(6)nginx location 匹配规则详细解说

Nginx系列教程(7)nginx rewrite配置规则详细说明

Nginx系列教程(8)nginx配置安全证书SSL

Nginx系列教程(9)nginx 解决session一致性

Nginx系列教程(10)基于nginx解决前端访问后端服务跨域问题(Session和cookie无效)

本文转载自: 掘金

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

一个月面了几家大厂 总结了5W字的后端面经(持续更新)

发表于 2020-05-18

点赞再看,养成习惯,微信搜索【三太子敖丙】关注这个互联网苟且偷生的工具人。

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

前言

前段时间敖丙不是在复习嘛,很多小伙伴也想要我的复习路线,以及我自己笔记里面的一些知识点,好了,丙丙花了一个月的时间,整整一个月啊,给大家整理出来了。

一上来我就放个大招好吧,我的复习脑图,可以说是全得不行,为了防止被盗图,我加了水印哈。

这期看下去你会发现很硬核,而且我会持续更新,啥也不说了,看在我熬夜一个月满脸痘痘的份上,你可以点赞了哈哈。

噗此不知道为啥掘金这么小了,别的都还挺大的

注:如果图被压缩了,可以去公众号【三太子敖丙】回复【复习】获取原图

Spring

Spring框架的七大模块

Spring Core:框架的最基础部分,提供 IoC 容器,对 bean 进行管理。

Spring Context:继承BeanFactory,提供上下文信息,扩展出JNDI、EJB、电子邮件、国际化等功能。

Spring DAO:提供了JDBC的抽象层,还提供了声明性事务管理方法。

Spring ORM:提供了JPA、JDO、Hibernate、MyBatis 等ORM映射层.

Spring AOP:集成了所有AOP功能

Spring Web:提供了基础的 Web 开发的上下文信息,现有的Web框架,如JSF、Tapestry、Structs等,提供了集成

Spring Web MVC:提供了 Web 应用的 Model-View-Controller 全功能实现。

Bean定义5种作用域

singleton(单例)
prototype(原型)
request
session
global session

spring ioc初始化流程?

resource定位
即寻找用户定义的bean资源,由 ResourceLoader通过统一的接口Resource接口来完成
beanDefinition载入
BeanDefinitionReader读取、解析Resource定位的资源 成BeanDefinition 载入到ioc中(通过HashMap进行维护BD)
BeanDefinition注册
即向IOC容器注册这些BeanDefinition, 通过BeanDefinitionRegistery实现

BeanDefinition加载流程?

定义BeanDefinitionReader解析xml的document
BeanDefinitionDocumentReader解析document成beanDefinition

DI依赖注入流程? (实例化,处理Bean之间的依赖关系)

过程在Ioc初始化后,依赖注入的过程是用户第一次向IoC容器索要Bean时触发

  • 如果设置lazy-init=true,会在第一次getBean的时候才初始化bean, lazy-init=false,会容器启动的时候直接初始化(singleton bean);
  • 调用BeanFactory.getBean()生成bean的;
  • 生成bean过程运用装饰器模式产生的bean都是beanWrapper(bean的增强);

依赖注入怎么处理bean之间的依赖关系?

其实就是通过在beanDefinition载入时,如果bean有依赖关系,通过占位符来代替,在调用getbean时候,如果遇到占位符,从ioc里获取bean注入到本实例来

Bean的生命周期?

  • 实例化Bean: Ioc容器通过获取BeanDefinition对象中的信息进行实例化,实例化对象被包装在BeanWrapper对象中
  • 设置对象属性(DI):通过BeanWrapper提供的设置属性的接口完成属性依赖注入;
  • 注入Aware接口(BeanFactoryAware, 可以用这个方式来获取其它 Bean,ApplicationContextAware):Spring会检测该对象是否实现了xxxAware接口,并将相关的xxxAware实例注入给bean
  • BeanPostProcessor:自定义的处理(分前置处理和后置处理)
  • InitializingBean和init-method:执行我们自己定义的初始化方法
  • 使用
  • destroy:bean的销毁

IOC:控制反转:将对象的创建权,由Spring管理.
DI(依赖注入):在Spring创建对象的过程中,把对象依赖的属性注入到类中。

Spring的IOC注入方式

构造器注入
setter方法注入
注解注入
接口注入

怎么检测是否存在循环依赖?

Bean在创建的时候可以给该Bean打标,如果递归调用回来发现正在创建中的话,即说明了循环依赖了。

Spring如解决Bean循环依赖问题?

Spring中循环依赖场景有:

  • 构造器的循环依赖
  • 属性的循环依赖
  • singletonObjects:第一级缓存,里面放置的是实例化好的单例对象;
    earlySingletonObjects:第二级缓存,里面存放的是提前曝光的单例对象;
    singletonFactories:第三级缓存,里面存放的是要被实例化的对象的对象工厂
  • 创建bean的时候Spring首先从一级缓存singletonObjects中获取。如果获取不到,并且对象正在创建中,就再从二级缓存earlySingletonObjects中获取,如果还是获取不到就从三级缓存singletonFactories中取(Bean调用构造函数进行实例化后,即使属性还未填充,就可以通过三级缓存向外提前暴露依赖的引用值(提前曝光),根据对象引用能定位到堆中的对象,其原理是基于Java的引用传递),取到后从三级缓存移动到了二级缓存完全初始化之后将自己放入到一级缓存中供其他使用,
  • 因为加入singletonFactories三级缓存的前提是执行了构造器,所以构造器的循环依赖没法解决。
  • 构造器循环依赖解决办法:在构造函数中使用@Lazy注解延迟加载。在注入依赖时,先注入代理对象,当首次使用时再创建对象说明:一种互斥的关系而非层次递进的关系,故称为三个Map而非三级缓存的缘由
    完成注入;

Spring 中使用了哪些设计模式?

  • 工厂模式:
    spring中的BeanFactory就是简单工厂模式的体现,根据传入唯一的标识来获得bean对象;
  • 单例模式:
    提供了全局的访问点BeanFactory;
  • 代理模式:
    AOP功能的原理就使用代理模式(1、JDK动态代理。2、CGLib字节码生成技术代理。)
  • 装饰器模式:
    依赖注入就需要使用BeanWrapper;
  • 观察者模式:
    spring中Observer模式常用的地方是listener的实现。如ApplicationListener。
  • 策略模式:
    Bean的实例化的时候决定采用何种方式初始化bean实例(反射或者CGLIB动态字节码生成)

AOP 核心概念

1、切面(aspect):类是对物体特征的抽象,切面就是对横切关注点的抽象

2、横切关注点:对哪些方法进行拦截,拦截后怎么处理,这些关注点称之为横切关注点。

3、连接点(joinpoint):被拦截到的点,因为 Spring 只支持方法类型的连接点,所以在Spring 中连接点指的就是被拦截到的方法,实际上连接点还可以是字段或者构造器。

4、切入点(pointcut):对连接点进行拦截的定义

5、通知(advice):所谓通知指的就是指拦截到连接点之后要执行的代码,通知分为前置、后置、异常、最终、环绕通知五类。

6、目标对象:代理的目标对象

7、织入(weave):将切面应用到目标对象并导致代理对象创建的过程

8、引入(introduction):在不修改代码的前提下,引入可以在运行期为类动态地添加方法或字段。

解释一下AOP

传统oop开发代码逻辑自上而下的,这个过程中会产生一些横切性问题,这些问题与我们主业务逻辑关系不大,会散落在代码的各个地方,造成难以维护,aop思想就是把业务逻辑与横切的问题进行分离,达到解耦的目的,提高代码重用性和开发效率;

AOP 主要应用场景有:

  • 记录日志
  • 监控性能
  • 权限控制
  • 事务管理

AOP源码分析

  • @EnableAspectJAutoProxy给容器(beanFactory)中注册一个AnnotationAwareAspectJAutoProxyCreator对象;
  • AnnotationAwareAspectJAutoProxyCreator对目标对象进行代理对象的创建,对象内部,是封装JDK和CGlib两个技术,实现动态代理对象创建的(创建代理对象过程中,会先创建一个代理工厂,获取到所有的增强器(通知方法),将这些增强器和目标类注入代理工厂,再用代理工厂创建对象);
  • 代理对象执行目标方法,得到目标方法的拦截器链,利用拦截器的链式机制,依次进入每一个拦截器进行执行

AOP应用场景

+ 日志记录
+ 事务管理
+ 线程池关闭等

AOP使用哪种动态代理?

  • 当bean的是实现中存在接口或者是Proxy的子类,—jdk动态代理;不存在接口,spring会采用CGLIB来生成代理对象;
  • JDK 动态代理主要涉及到 java.lang.reflect 包中的两个类:Proxy 和 InvocationHandler。
  • Proxy 利用 InvocationHandler(定义横切逻辑) 接口动态创建 目标类的代理对象。

jdk动态代理

  • 通过bind方法建立代理与真实对象关系,通过Proxy.newProxyInstance(target)生成代理对象
  • 代理对象通过反射invoke方法实现调用真实对象的方法

动态代理与静态代理区别

  • 静态代理,程序运行前代理类的.class文件就存在了;
  • 动态代理:在程序运行时利用反射动态创建代理对象<复用性,易用性,更加集中都调用invoke>

CGLIB与JDK动态代理区别

  • Jdk必须提供接口才能使用;
  • C不需要,只要一个非抽象类就能实现动态代理

SpringMVC

springMVC流程:

(1):用户请求发送给DispatcherServlet,DispatcherServlet调用HandlerMapping处理器映射器;

(2):HandlerMapping根据xml或注解找到对应的处理器,生成处理器对象返回给DispatcherServlet;

(3):DispatcherServlet会调用相应的HandlerAdapter;

(4):HandlerAdapter经过适配调用具体的处理器去处理请求,生成ModelAndView返回给DispatcherServlet

(5):DispatcherServlet将ModelAndView传给ViewReslover解析生成View返回给DispatcherServlet;

(6):DispatcherServlet根据View进行渲染视图;

->DispatcherServlet->HandlerMapping->Handler
->DispatcherServlet->HandlerAdapter处理handler->ModelAndView
->DispatcherServlet->ModelAndView->ViewReslover->View
->DispatcherServlet->返回给客户

Mybatis

Mybatis原理

  • sqlsessionFactoryBuilder生成sqlsessionFactory(单例)
  • 工厂模式生成sqlsession执行sql以及控制事务
  • Mybatis通过动态代理使Mapper(sql映射器)接口能运行起来即为接口生成代理对象将sql查询到结果映射成pojo

sqlSessionFactory构建过程

  • 解析并读取配置中的xml创建Configuration对象 (单例)
  • 使用Configruation类去创建sqlSessionFactory(builder模式)

Mybatis一级缓存与二级缓存

默认情况下一级缓存是开启的,而且是不能关闭的。

  • 一级缓存是指 SqlSession 级别的缓存
    原理:使用的数据结构是一个 map,如果两次中间出现 commit 操作 (修改、添加、删除),本 sqlsession 中的一级缓存区域全部清空
  • 二级缓存是指可以跨 SqlSession 的缓存。是 mapper 级别的缓存;
    原理: 是通过 CacheExecutor 实现的。CacheExecutor其实是 Executor 的代理对象

Zookeeper+eureka+springcloud

SpringBoot启动流程

  • new springApplication对象,利用spi机制加载applicationContextInitializer,
    applicationLister接口实例(META-INF/spring.factories);
  • 调run方法准备Environment,加载应用上下文(applicationContext),发布事件 很多通过lister实现
  • 创建spring容器, refreshContext() ,实现starter自动化配置,spring.factories文件加载, bean实例化

SpringBoot自动配置的原理

+ @EnableAutoConfiguration找到META-INF/spring.factories(需要创建的bean在里面)配置文件
+ 读取每个starter中的spring.factories文件

Spring Boot 的核心注解

核心注解是@SpringBootApplication 由以下三种组成

  • @SpringBootConfiguration:组合了 @Configuration 注解,实现配置文件的功能。
  • @EnableAutoConfiguration:打开自动配置的功能。
  • @ComponentScan:Spring组件扫描。

SpringBoot常用starter都有哪些

spring-boot-starter-web - Web 和 RESTful 应用程序;
spring-boot-starter-test - 单元测试和集成测试;
spring-boot-starter-jdbc - 传统的 JDBC;
spring-boot-starter-security - 使用 SpringSecurity 进行身份验证和授权;
spring-boot-starter-data-jpa - 带有 Hibernate 的 Spring Data JPA;
spring-boot-starter-data-rest - 使用 Spring Data REST 公布简单的 REST 服务

Spring Boot 的核心配置文件

(1):Application.yml
一般用来定义单个应用级别的,如果搭配 spring-cloud-config 使用

(2).Bootstrap.yml(先加载)
系统级别的一些参数配置,这些参数一般是不变的

Zuul与Gateway区别

(1):zuul则是netflix公司的项目集成在spring-cloud中使用而已, Gateway是spring-cloud的
一个子项目;

(2):zuul不提供异步支持流控等均由hystrix支持, gateway提供了异步支持,提供了抽象负载均衡,提供了抽象流控; 理论上gateway则更适合于提高系统吞吐量(但不一定能有更好的性能),最终性能还需要通过严密的压测来决定

(3):两者底层实现都是servlet,但是gateway多嵌套了一层webflux框架

(4): zuul可用至其他微服务框架中,内部没有实现限流、负载均衡;gateway只能用在springcloud中;

Zuul原理分析

(1):请求给zuulservlet处理(HttpServlet子类) zuulservlet中有一个zuulRunner对象,该对象中初始化了RequestContext(存储请求的数据),RequestContext被所有的zuulfilter共享;

(2): zuulRunner中有 FilterProcessor(zuulfilter的管理器),其从filterloader 中获取zuulfilter;

(3):有了这些filter之后, zuulservelet执行的Pre-> route-> post 类型的过滤器,如果在执行这些过滤器有错误的时候则会执行error类型的过滤器,执行完后把结果返回给客户端.

Gateway原理分析

(1):请求到达DispatcherHandler, DispatchHandler在IOC容器初始化时会在容器中实例化HandlerMapping接口

(2):用handlerMapping根据请求URL匹配到对应的Route,然后有对应的filter做对应的请求转发最终response返回去

Zookeeper 工作原理(待查)

Zookeeper 的核心是原子广播,这个机制保证了各个 server 之间的同步。实现这个机制的协议叫做 Zab 协议。Zab 协议有两种模式,它们分别是恢复模式和广播模式。

zoo与eur区别

  • zookeeper保证cp(一致性)
  • eureka保证ap(可用性)
  • zoo在选举期间注册服务瘫痪,期间不可用
  • eur各个节点平等关系,只要有一台就可保证服务可用,而查询到的数据可能不是最新的,可以很好应对网络故障导致部分节点失联情况
  • zoo有leader和follower角色,eur各个节点平等
  • zoo采用半数存活原则(避免脑裂),eur采用自我保护机制来解决分区问题
  • eur本质是个工程,zoo只是一个进程
    ZooKeeper基于CP,不保证高可用,如果zookeeper正在选主,或者Zookeeper集群中半数以上机器不可用,那么将无法获得数据。
    Eureka基于AP,能保证高可用,即使所有机器都挂了,也能拿到本地缓存的数据。作为注册中心,其实配置是不经常变动的,只有发版(发布新的版本)和机器出故障时会变。对于不经常变动的配置来说,CP是不合适的,而AP在遇到问题时可以用牺牲一致性来保证可用性,既返回旧数据,缓存数据。
    所以理论上Eureka是更适合做注册中心。而现实环境中大部分项目可能会使用ZooKeeper,那是因为集群不够大,并且基本不会遇到用做注册中心的机器一半以上都挂了的情况。所以实际上也没什么大问题。

Hystrix原理(待查)

通过维护一个自己的线程池,当线程池达到阈值的时候,就启动服务降级,返回fallback默认值

为什么需要hystrix熔断

防止雪崩,及时释放资源,防止系统发生更多的额级联故障,需要对故障和延迟进行隔离,防止单个依赖关系的失败影响整个应用程序;

微服务优缺点

  • 每个服务高内聚,松耦合,面向接口编程;
  • 服务间通信成本,数据一致性,多服务运维难度增加,http传输效率不如rpc

eureka自我保护机制

  • eur不移除长时间没收到心跳而应该过期的服务
  • 仍然接受新服务注册和查询请求,但是不会同步到其它节点(高可用)
  • 当网络稳定后,当前实例新注册信息会同步到其它节点(最终一致性)

MQ对比

ActiveMQ:Apache出品,最早使用的消息队列产品,时间比较长了,最近版本更新比较缓慢。
RabbitMQ:erlang语言开发,支持很多的协议,非常重量级,更适合于企业级的开发。性能较好,但是不利于做二次开发和维护。
RocketMQ:阿里开源的消息中间件,纯Java开发,具有高吞吐量、高可用性、适合大规模分布式系统应用的特点,分布式事务。
ZeroMQ:号称最快的消息队列系统,尤其针对大吞吐量的需求场景,采用 C 语言实现。
消息队列的选型需要根据具体应用需求而定,ZeroMQ 小而美,RabbitMQ 大而稳,Kakfa 和 RocketMQ 快而强劲

JAVA基础

AVL树与红黑树(R-B树)的区别与联系

  • AVL是严格的平衡树,因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多;
  • 红黑树是用非严格的平衡来换取增删节点时候旋转次数的降低开销;
  • 所以简单说,查询多选择AVL树,查询更新次数差不多选红黑树
  • AVL树顺序插入和删除时有20%左右的性能优势,红黑树随机操作15%左右优势,现实应用当然一般都是随机情况,所以红黑树得到了更广泛的应用
    索引为B+树
    Hashmap为红黑树

为啥redis zset使用跳跃链表而不用红黑树实现

  • skiplist的复杂度和红黑树一样,而且实现起来更简单。
  • 在并发环境下红黑树在插入和删除时需要rebalance,性能不如跳表。

JAVA基本数据类型

(1个字节是8个bit)
整数型:byte(1字节)、short(2字节)、int(4字节)、long(8字节)
浮点型:float(4字节)、double(8字节)
布尔型:boolean(1字节)
字符型:char(2字节)

IO与NIO

包括 类File,outputStream,inputStream,writer,readerseralizable(5类1接口)

NIO三大核心内容 selector(选择器,用于监听channel),channel(通道),buffer(缓冲区)

NIO与IO区别,IO面向流,NIO面向缓冲区;io阻塞,nio非阻塞

异常类

throwable为父类,子为error跟exception,exception分runtime(空指针,越界等)跟checkexception(sql,io,找不到类等异常)

LVS(4层与7层)原理

  • 由前端虚拟负载均衡器和后端真实服务器群组成;
  • 请求发送给虚拟服务器后其根据包转发策略以及负载均衡调度算法转发给真实服务器
  • 所谓四层(lvs,f5)就是基于IP+端口的负载均衡;七层(nginx)就是基于URL等应用层信息的负载均衡

StringBuilder与StringBuffer

  • StringBuilder 更快;
  • StringBuffer是线程安全的

interrupt/isInterrupted/interrupt区别

  • interrupt() 调用该方法的线程的状态为将被置为”中断”状态(set操作)
  • isinterrupted() 是作用于调用该方法的线程对象所对应的线程的中断信号是true还是false(get操作)。例如我们可以在A线程中去调用B线程对象的isInterrupted方法,查看的是A
  • interrupted()是静态方法:内部实现是调用的当前线程的isInterrupted(),并且会重置当前线程的中断状态(getandset)

sleep与wait区别

sleep属于线程类,wait属于object类;sleep不释放锁

CountDownLatch和CyclicBarrier区别

  • con用于主线程等待其他子线程任务都执行完毕后再执行,cyc用于一组线程相互等待大家都达到某个状态后,再同时执行;
  • CountDownLatch是不可重用的,CyclicBarrier可重用

终止线程方法

  • 使用退出标志,说线程正常退出;
  • 通过判断this.interrupted() throw new InterruptedException()来停止
    使用String常量池作为锁对象会导致两个线程持有相同的锁,另一个线程不执行,改用其他如new Object()

ThreadLocal的原理和应用

原理:

线程中创建副本,访问自己内部的副本变量,内部实现是其内部类名叫ThreadLocalMap的成员变量threadLocals,key为本身,value为实际存值的变量副本

应用:

  • 用来解决数据库连接,存放connection对象,不同线程存放各自session;
  • 解决simpleDateFormat线程安全问题;
  • 会出现内存泄漏,显式remove..不要与线程池配合,因为worker往往是不会退出的;

threadLocal 内存泄漏问题

如果是强引用,设置tl=null,但是key的引用依然指向ThreadLocal对象,所以会有内存泄漏,而使用弱引用则不会;
但是还是会有内存泄漏存在,ThreadLocal被回收,key的值变成null,导致整个value再也无法被访问到;
解决办法:在使用结束时,调用ThreadLocal.remove来释放其value的引用;

如果我们要获取父线程的ThreadLocal值呢

ThreadLocal是不具备继承性的,所以是无法获取到的,但是我们可以用InteritableThreadLocal来实现这个功能。InteritableThreadLocal继承来ThreadLocal,重写了createdMap方法,已经对应的get和set方法,不是在利用了threadLocals,而是interitableThreadLocals变量。

这个变量会在线程初始化的时候(调用init方法),会判断父线程的interitableThreadLocals变量是否为空,如果不为空,则把放入子线程中,但是其实这玩意没啥鸟用,当父线程创建完子线程后,如果改变父线程内容是同步不到子线程的。。。同样,如果在子线程创建完后,再去赋值,也是没啥鸟用的

线程状态

线程池有5种状态:running,showdown,stop,Tidying,TERMINATED。

  • running:线程池处于运行状态,可以接受任务,执行任务,创建线程默认就是这个状态了
  • showdown:调用showdown()函数,不会接受新任务,但是会慢慢处理完堆积的任务。
  • stop:调用showdownnow()函数,不会接受新任务,不处理已有的任务,会中断现有的任务。
  • Tidying:当线程池状态为showdown或者stop,任务数量为0,就会变为tidying。这个时候会调用钩子函数terminated()。
  • TERMINATED:terminated()执行完成。

在线程池中,用了一个原子类来记录线程池的信息,用了int的高3位表示状态,后面的29位表示线程池中线程的个数。

Java中的线程池是如何实现的?

  • 线程中线程被抽象为静态内部类Worker,是基于AQS实现的存放在HashSet中;
  • 要被执行的线程存放在BlockingQueue中;
  • 基本思想就是从workQueue中取出要执行的任务,放在worker中处理;

如果线程池中的一个线程运行时出现了异常,会发生什么

如果提交任务的时候使用了submit,则返回的feature里会存有异常信息,但是如果数execute则会打印出异常栈。但是不会给其他线程造成影响。之后线程池会删除该线程,会新增加一个worker。

线程池原理

  • 提交一个任务,线程池里存活的核心线程数小于corePoolSize时,线程池会创建一个核心线程去处理提交的任务
  • 如果线程池核心线程数已满,即线程数已经等于corePoolSize,一个新提交的任务,会被放进任务队列workQueue排队等待执行。
  • 当线程池里面存活的线程数已经等于corePoolSize了,并且任务队列workQueue也满,判断线程数是否达到maximumPoolSize,即最大线程数是否已满,如果没到达,创建非核心线程执行提交的任务。
  • 如果当前的线程数达到了maximumPoolSize,还有新的任务过来的话,直接采用拒绝策略处理。

拒绝策略

  • AbortPolicy直接抛出异常阻止线程运行;
  • CallerRunsPolicy如果被丢弃的线程任务未关闭,则执行该线程;
  • DiscardOldestPolicy移除队列最早线程尝试提交当前任务
  • DiscardPolicy丢弃当前任务,不做处理

newFixedThreadPool (固定数目线程的线程池)

  • 阻塞队列为无界队列LinkedBlockingQueue
  • 适用于处理CPU密集型的任务,适用执行长期的任务

newCachedThreadPool(可缓存线程的线程池)

  • 阻塞队列是SynchronousQueue
  • 适用于并发执行大量短期的小任务

newSingleThreadExecutor(单线程的线程池)

  • 阻塞队列是LinkedBlockingQueue
  • 适用于串行执行任务的场景,一个任务一个任务地执行

newScheduledThreadPool(定时及周期执行的线程池)

  • 阻塞队列是DelayedWorkQueue
  • 周期性执行任务的场景,需要限制线程数量的场景

java锁相关

synchronized实现原理

contentionList(请求锁线程队列)
entryList(有资格的候选者队列)
waitSet(wait方法后阻塞队列)
onDeck(竞争候选者)
ower(竞争到锁线程)
!ower(执行成功释放锁后状态);
Synchronized 是非公平锁。

Synchronized 在线程进入 ContentionList 时,等待的线程会先尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁资源。

底层是由一对monitorenter和monitorexit指令实现的(监视器锁)

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程:

  • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
  • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

ReentrantLock 是如何实现可重入性的 ?

内部自定义了同步器 Sync,加锁的时候通过CAS 算法 ,将线程对象放到一个双向链表 中,每次获取锁的时候 ,看下当前维 护的那个线程ID和当前请求的线程ID是否一样,一样就可重入了;

ReentrantLock如何避免死锁?

  • 响应中断lockInterruptibly()
  • 可轮询锁tryLock()
  • 定时锁tryLock(long time)

tryLock 和 lock 和 lockInterruptibly 的区别

(1):tryLock 能获得锁就返回 true,不能就立即返回 false,

(2):tryLock(long timeout,TimeUnit unit),可以增加时间限制,如果超过该时间段还没获得锁,返回 false

(3):lock 能获得锁就返回 true,不能的话一直等待获得锁

(4):lock 和 lockInterruptibly,如果两个线程分别执行这两个方法,但此时中断这两个线程, lock 不会抛出异常,而 lockInterruptibly 会抛出异常。

CountDownLatch和CyclicBarrier的区别是什么

CountDownLatch是等待其他线程执行到某一个点的时候,在继续执行逻辑(子线程不会被阻塞,会继续执行),只能被使用一次。最常见的就是join形式,主线程等待子线程执行完任务,在用主线程去获取结果的方式(当然不一定),内部是用计数器相减实现的(没错,又特么是AQS),AQS的state承担了计数器的作用,初始化的时候,使用CAS赋值,主线程调用await()则被加入共享线程等待队列里面,子线程调用countDown的时候,使用自旋的方式,减1,知道为0,就触发唤醒。

CyclicBarrier回环屏障,主要是等待一组线程到底同一个状态的时候,放闸。CyclicBarrier还可以传递一个Runnable对象,可以到放闸的时候,执行这个任务。CyclicBarrier是可循环的,当调用await的时候如果count变成0了则会重置状态,如何重置呢,CyclicBarrier新增了一个字段parties,用来保存初始值,当count变为0的时候,就重新赋值。还有一个不同点,CyclicBarrier不是基于AQS的,而是基于RentrantLock实现的。存放的等待队列是用了条件变量的方式。

synchronized与ReentrantLock区别

  • 都是可重入锁; R是显示获取和释放锁,s是隐式;
  • R更灵活可以知道有没有成功获取锁,可以定义读写锁,是api级别,s是JVM级别;
  • R可以定义公平锁;Lock是接口,s是java中的关键字

什么是信号量Semaphore

信号量是一种固定资源的限制的一种并发工具包,基于AQS实现的,在构造的时候会设置一个值,代表着资源数量。信号量主要是应用于是用于多个共享资源的互斥使用,和用于并发线程数的控制(druid的数据库连接数,就是用这个实现的),信号量也分公平和非公平的情况,基本方式和reentrantLock差不多,在请求资源调用task时,会用自旋的方式减1,如果成功,则获取成功了,如果失败,导致资源数变为了0,就会加入队列里面去等待。调用release的时候会加一,补充资源,并唤醒等待队列。

Semaphore 应用

  • acquire() release() 可用于对象池,资源池的构建,比如静态全局对象池,数据库连接池;
  • 可创建计数为1的S,作为互斥锁(二元信号量)

可重入锁概念

(1):可重入锁是指同一个线程可以多次获取同一把锁,不会因为之前已经获取过还没释放而阻塞;

(2):reentrantLock和synchronized都是可重入锁

(3):可重入锁的一个优点是可一定程度避免死锁

ReentrantLock原理(CAS+AQS)

CAS+AQS队列来实现

(1):先通过CAS尝试获取锁, 如果此时已经有线程占据了锁,那就加入AQS队列并且被挂起;

(2): 当锁被释放之后, 排在队首的线程会被唤醒CAS再次尝试获取锁,

(3):如果是非公平锁, 同时还有另一个线程进来尝试获取可能会让这个线程抢到锁;

(4):如果是公平锁, 会排到队尾,由队首的线程获取到锁。

AQS 原理

Node内部类构成的一个双向链表结构的同步队列,通过控制(volatile的int类型)state状态来判断锁的状态,对于非可重入锁状态不是0则去阻塞;

对于可重入锁如果是0则执行,非0则判断当前线程是否是获取到这个锁的线程,是的话把state状态+1,比如重入5次,那么state=5。 而在释放锁的时候,同样需要释放5次直到state=0其他线程才有资格获得锁

AQS两种资源共享方式

  • Exclusive:独占,只有一个线程能执行,如ReentrantLock
  • Share:共享,多个线程可以同时执行,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier

CAS原理

内存值V,旧的预期值A,要修改的新值B,当A=V时,将内存值修改为B,否则什么都不做;

CAS的缺点:

(1):ABA问题;
(2):如果CAS失败,自旋会给CPU带来压力;
(3):只能保证对一个变量的原子性操作,i++这种是不能保证的

CAS在java中的应用:

(1):Atomic系列

公平锁与分公平锁

(1):公平锁指在分配锁前检查是否有线程在排队等待获取该锁,优先分配排队时间最长的线程,非公平直接尝试获取锁
(2):公平锁需多维护一个锁线程队列,效率低;默认非公平

独占锁与共享锁

(1):ReentrantLock为独占锁(悲观加锁策略)
(2):ReentrantReadWriteLock中读锁为共享锁
(3): JDK1.8 邮戳锁(StampedLock), 不可重入锁
读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁, 乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行

4种锁状态

  • 无锁
  • 偏向锁
    会偏向第一个访问锁的线程,当一个线程访问同步代码块获得锁时,会在对象头和栈帧记录里存储锁偏向的线程ID,当这个线程再次进入同步代码块时,就不需要CAS操作来加锁了,只要测试一下对象头里是否存储着指向当前线程的偏向锁
    如果偏向锁未启动,new出的对象是普通对象(即无锁,有稍微竞争会成轻量级锁),如果启动,new出的对象是匿名偏向(偏向锁)
    对象头主要包括两部分数据:Mark Word(标记字段, 存储对象自身的运行时数据)、class Pointer(类型指针, 是对象指向它的类元数据的指针)
  • 轻量级锁(自旋锁)
    (1):在把线程进行阻塞操作之前先让线程自旋等待一段时间,可能在等待期间其他线程已经 解锁,这时就无需再让线程执行阻塞操作,避免了用户态到内核态的切换。(自适应自旋时间为一个线程上下文切换的时间)
  • (2):在用自旋锁时有可能造成死锁,当递归调用时有可能造成死锁
  • (3):自旋锁底层是通过指向线程栈中Lock Record的指针来实现的
  • 重量级锁

轻量级锁与偏向锁的区别

(1):轻量级锁是通过CAS来避免进入开销较大的互斥操作

(2):偏向锁是在无竞争场景下完全消除同步,连CAS也不执行

自旋锁升级到重量级锁条件

(1):某线程自旋次数超过10次;

(2):等待的自旋线程超过了系统core数的一半;

读写锁了解嘛,知道读写锁的实现方式嘛

常用的读写锁ReentrantReanWritelock,这个其实和reentrantLock相似,也是基于AQS的,但是这个是基于共享资源的,不是互斥,关键在于state的处理,读写锁把高16为记为读状态,低16位记为写状态,就分开了,读读情况其实就是读锁重入,读写/写读/写写都是互斥的,只要判断低16位就好了。

zookeeper实现分布式锁

(1):利用节点名称唯一性来实现,加锁时所有客户端一起创建节点,只有一个创建成功者获得锁,解锁时删除节点。

(2):利用临时顺序节点实现,加锁时所有客户端都创建临时顺序节点,创建节点序列号最小的获得锁,否则监视比自己序列号次小的节点进行等待

(3):方案2比1好处是当zookeeper宕机后,临时顺序节点会自动删除释放锁,不会造成锁等待;

(4):方案1会产生惊群效应(当有很多进程在等待锁的时候,在释放锁的时候会有很多进程就过来争夺锁)。

(5):由于需要频繁创建和删除节点,性能上不如redis锁

volatile变量

(1):变量可见性

(2):防止指令重排序

(3):保障变量单次读,写操作的原子性,但不能保证i++这种操作的原子性,因为本质是读,写两次操作

volatile如何保证线程间可见和避免指令重排

volatile可见性是有指令原子性保证的,在jmm中定义了8类原子性指令,比如write,store,read,load。而volatile就要求write-store,load-read成为一个原子性操作,这样子可以确保在读取的时候都是从主内存读入,写入的时候会同步到主内存中(准确来说也是内存屏障),指令重排则是由内存屏障来保证的,由两个内存屏障:

  • 一个是编译器屏障:阻止编译器重排,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。
  • 第二个是cpu屏障:sfence保证写入,lfence保证读取,lock类似于锁的方式。java多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个lock指令,就是增加一个完全的内存屏障指令。

JVM

jre、jdk、jvm的关系:

jdk是最小的开发环境,由jre++java工具组成。

jre是java运行的最小环境,由jvm+核心类库组成。

jvm是虚拟机,是java字节码运行的容器,如果只有jvm是无法运行java的,因为缺少了核心类库。

JVM内存模型

(1):堆<对象,静态变量,共享

(2):方法区<存放类信息,常量池,共享>(java8移除了永久代(PermGen),替换为元空间(Metaspace))

(3):虚拟机栈<线程执行方法的时候内部存局部变量会存堆中对象的地址等等数据>

(4):本地方法栈<存放各种native方法的局部变量表之类的信息>

(5):程序计数器<记录当前线程执行到哪一条字节码指令位置>

对象4种引用

(1):强(内存泄露主因)

(2):软(只有软引用的话,空间不足将被回收),适合缓存用

(3):弱(只,GC会回收)

(4):虚引用(用于跟踪GC状态)用于管理堆外内存

对象的构成:

一个对象分为3个区域:对象头、实例数据、对齐填充

对象头:主要是包括两部分,1.存储自身的运行时数据比如hash码,分代年龄,锁标记等(但是不是绝对哦,锁状态如果是偏向锁,轻量级锁,是没有hash码的。。。是不固定的)2.指向类的元数据指针。还有可能存在第三部分,那就是数组类型,会多一块记录数组的长度(因为数组的长度是jvm判断不出来的,jvm只有元数据信息)

实例数据:会根据虚拟机分配策略来定,分配策略中,会把相同大小的类型放在一起,并按照定义顺序排列(父类的变量也会在哦)

对齐填充:这个意义不是很大,主要在虚拟机规范中对象必须是8字节的整数,所以当对象不满足这个情况时,就会用占位符填充

如果判断一个对象是否存活:

一般判断对象是否存活有两种算法,一种是引用计数,另外一种是可达性分析。在java中主要是第二种

java是根据什么来执行可达性分析的:

根据GC ROOTS。GC ROOTS可以的对象有:虚拟机栈中的引用对象,方法区的类变量的引用,方法区中的常量引用,本地方法栈中的对象引用。

JVM 类加载顺序

(1):加载
获取类的二进制字节流,将其静态存储结构转化为方法区的运行时数据结构

(2):校验
文件格式验证,元数据验证,字节码验证,符号引用验证

(3):准备
在方法区中对类的static变量分配内存并设置类变量数据类型默认的初始值,不包括实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中

(4):解析
将常量池内的符号引用替换为直接引用的过程

(5):初始化
为类的静态变量赋予正确的初始值(Java代码中被显式地赋予的值)

JVM三种类加载器

(1):启动类加载器(home)
加载jvm核心类库,如java.lang.*等

(2):扩展类加载器(ext),
父加载器为启动类加载器,从jre/lib/ext下加载类库

(3):应用程序类加载器(用户classpath路径)
父加载器为扩展类加载器,从环境变量中加载类

双亲委派机制

(1):类加载器收到类加载的请求

(2):把这个请求委托给父加载器去完成,一直向上委托,直到启动类加载器

(3):启动器加载器检查能不能加载,能就加载(结束);否则,抛出异常,通知子加载器进行加载

(4):保障类的唯一性和安全性以及保证JDK核心类的优先加载

双亲委派模型有啥作用:

保证java基础类在不同的环境还是同一个Class对象,避免出现了自定义类覆盖基础类的情况,导致出现安全问题。还可以避免类的重复加载。

如何打破双亲委派模型?

(1):自定义类加载器,继承ClassLoader类重写loadClass方法;

(2):SPI

tomcat是如何打破双亲委派模型:

tomcat有着特殊性,它需要容纳多个应用,需要做到应用级别的隔离,而且需要减少重复性加载,所以划分为:/common 容器和应用共享的类信息,/server容器本身的类信息,/share应用通用的类信息,/WEB-INF/lib应用级别的类信息。整体可以分为:boostrapClassLoader->ExtensionClassLoader->ApplicationClassLoader->CommonClassLoader->CatalinaClassLoader(容器本身的加载器)/ShareClassLoader(共享的)->WebAppClassLoader。虽然第一眼是满足双亲委派模型的,但是不是的,因为双亲委派模型是要先提交给父类装载,而tomcat是优先判断是否是自己负责的文件位置,进行加载的。

SPI: (Service Provider interface)

(1):服务提供接口(服务发现机制):

(2):通过加载ClassPath下META_INF/services,自动加载文件里所定义的类

(3):通过ServiceLoader.load/Service.providers方法通过反射拿到实现类的实例

SPI应用?

(1):应用于JDBC获取数据库驱动连接过程就是应用这一机制

(2):apache最早提供的common-logging只有接口.没有实现..发现日志的提供商通过SPI来具体找到日志提供商实现类

双亲委派机制缺陷?

(1):双亲委派核心是越基础的类由越上层的加载器进行加载, 基础的类总是作为被调用代码调用的API,无法实现基础类调用用户的代码….

(2):JNDI服务它的代码由启动类加载器去加载,但是他需要调独立厂商实现的应用程序,如何解决?
线程上下文件类加载器(Thread Context ClassLoader), JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC

导致fullGC的原因

(1):老年代空间不足

(2):永久代(方法区)空间不足

(3):显式调用system.gc()

堆外内存的优缺点

Ehcache中的一些版本,各种 NIO 框架,Dubbo,Memcache 等中会用到,NIO包下ByteBuffer来创建堆外内存
堆外内存,其实就是不受JVM控制的内存。

相比于堆内内存有几个优势:

减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作。
加快了复制的速度。因为堆内在 flush 到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了复制这项工作。
可以扩展至更大的内存空间。比如超过 1TB 甚至比主存还大的空间。

缺点总结如下:

堆外内存难以控制,如果内存泄漏,那么很难排查,通过-XX:MaxDirectMemerySize来指定,当达到阈值的时候,调用system.gc来进行一次full gc
堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适合
jstat查看内存回收概况,实时查看各个分区的分配回收情况,
jmap查看内存栈,查看内存中对象占用大小,
jstack查看线程栈,死锁,性能瓶颈

JVM七种垃圾收集器

(1): Serial 收集器 复制算法,单线程,新生代)

(2): ParNew 收集器(复制算法,多线程,新生代)

(3): Parallel Scavenge 收集器(多线程,复制算法,新生代,高吞吐量)

(4):Serial Old 收集器(标记-整理算法,老年代)

(5):Parallel Old 收集器(标记-整理算法,老年代,注重吞吐量的场景下,jdk8默认采用 Parallel Scavenge + Parallel Old 的组合)

(6):CMS 收集器(标记-清除算法,老年代,垃圾回收线程几乎能做到与用户线程同时工作,吞吐量低,内存碎片)以牺牲吞吐量为代价来获得最短回收停顿时间-XX:+UseConcMarkSweepGC
jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk1.9 默认垃圾收集器G1

使用场景:

(1):应用程序对停顿比较敏感

(2):在JVM中,有相对较多存活时间较长的对象(老年代比较大)会更适合使用CMS

cms垃圾回收过程:

(1):初始标识<找到gcroot(stw)>

GC Roots有以下几种:

1:系统类加载器加载的对象

2:处于激活状态的线程

3:JNI栈中的对象

4:正在被用于同步的各种锁对象

5:JVM自身持有的对象,比如系统类加载器等。

(2):并发标记(三色标记算法)
三色标记算法处理并发标记出现对象引用变化情况:
黑:自己+子对象标记完成
灰:自己完成,子对象未完成
白:未标记;
并发标记 黑->灰->白
重新标记 灰->白引用消失,黑引用指向->白,导致白漏标
cms处理办法是incremental update方案 (增量更新)把黑色变成灰色
多线程下并发标记依旧会产生漏标问题,所以cms必须remark一遍(jdk1.9以后不用cms了)

G1 处理方案:

SATB(snapshot at the begining)把白放入栈中,标记过程是和应用程序并发运行的(不需要Stop-The-World)
这种方式会造成某些是垃圾的对象也被当做是存活的,所以G1会使得占用的内存被实际需要的内存大。不过下一次就回收了
ZGC 处理方案: 颜色指针(color pointers) 2*42方=4T

(3):重新标记(stw)

(4)并发清理

备注:重新标记是防止标记成垃圾之后,对象被引用

(5):G1 收集器(新生代 + 老年代,在多 CPU 和大内存的场景下有很好的性能)
G1在java9 便是默认的垃圾收集器,是cms 的替代者
逻辑分代,用分区(region)的思想(默认分2048份) 还是有stw
为解决CMS算法产生空间碎片HotSpot提供垃圾收集器,通过-XX:+UseG1GC来启用

G1中提供了三种模式垃圾回收模式

(1):young gc(eden region被耗尽无法申请内存时,就会触发)

(2):mixed gc(当老年代大小占整个堆大小百分比达到该阈值时,会触发)

(3):full gc(对象内存分配速度过快,mixed gc来不及回收,导致老年代被填满,就会触发)

(8):ZGC和shenandoah (oracle产收费) no stw

arthas 监控工具

(1):dashboard命令查看总体jvm运行情况

(2):jvm显示jvm详细信息

(3):thread 显示jvm里面所有线程信息(类似于jstack) 查看死锁线程命令thread -b

(4):sc * 显示所有类(search class)

(5):trace 跟踪方法

定位频繁full GC,堆内存满 oom

第一步:jps获取进程号
第二步:jmap -histo pid | head -20 得知有个对象在不断创建
备注:jmap如果线上服务器堆内存特别大,,会卡死需堆转存(一般会说在测试环境压测,导出转存)
-XX:+HeapDumpOnOutOfMemoryError或jmap -dumpLformat=b,file=xxx pid 转出文件进行分析
(arthas没有实现jmap命令)heapdump –live /xxx/xx.hprof导出文件

G1垃圾回收器(重点)

回收过程
(1):young gc(年轻代回收)–当年轻代的Eden区用尽时–stw
第一阶段,扫描根。
根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等
第二阶段,更新RS(Remembered Sets)。
处理dirty card queue中的card,更新RS。此阶段完成后,RS可以准确的反映老年代对所在的内存分段中对象的引用
第三阶段,处理RS。
识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
第四阶段,复制对象。
此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段
第五阶段,处理引用。
处理Soft,Weak,Phantom,Final,JNI Weak 等引用。

(2):concrruent marking(老年代并发标记)
当堆内存使用达到一定值(默认45%)时,不需要Stop-The-World,在并发标记前先进行一次young gc

(3):混合回收(mixed gc)
并发标记过程结束以后,紧跟着就会开始混合回收过程。混合回收的意思是年轻代和老年代会同时被回收

(4):Full GC?
Full GC是指上述方式不能正常工作,G1会停止应用程序的执行,使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。要避免Full GC的发生,一旦发生需要进行调整。

什么时候发生Full GC呢?

比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc,这种情况可以通过增大内存解决

尽管G1堆内存仍然是分代的,但是同一个代的内存不再采用连续的内存结构

年轻代分为Eden和Survivor两个区,老年代分为Old和Humongous两个区

新分配的对象会被分配到Eden区的内存分段上

Humongous区用于保存大对象,如果一个对象占用的空间超过内存分段Region的一半;

如果对象的大小超过一个甚至几个分段的大小,则对象会分配在物理连续的多个Humongous分段上。

Humongous对象因为占用内存较大并且连续会被优先回收

为了在回收单个内存分段的时候不必对整个堆内存的对象进行扫描(单个内存分段中的对象可能被其他内存分段中的对象引用)引入了RS数据结构。RS使得G1可以在年轻代回收的时候不必去扫描老年代的对象,从而提高了性能。每一个内存分段都对应一个RS,RS保存了来自其他分段内的对象对于此分段的引用

JVM会对应用程序的每一个引用赋值语句object.field=object进行记录和处理,把引用关系更新到RS中。但是这个RS的更新并不是实时的。G1维护了一个Dirty Card Queue

那为什么不在引用赋值语句处直接更新RS呢?

这是为了性能的需要,使用队列性能会好很多。

线程本地分配缓冲区(TLAB: Thread Local Allocation Buffer)?

栈上分配->tlab->堆上分配
由于堆内存是应用程序共享的,应用程序的多个线程在分配内存的时候需要加锁以进行同步。为了避免加锁,提高性能每一个应用程序的线程会被分配一个TLAB。TLAB中的内存来自于G1年轻代中的内存分段。当对象不是Humongous对象,TLAB也能装的下的时候,对象会被优先分配于创建此对象的线程的TLAB中。这样分配会很快,因为TLAB隶属于线程,所以不需要加锁

PLAB: Promotion Thread Local Allocation Buffer

G1会在年轻代回收过程中把Eden区中的对象复制(“提升”)到Survivor区中,Survivor区中的对象复制到Old区中。G1的回收过程是多线程执行的,为了避免多个线程往同一个内存分段进行复制,那么复制的过程也需要加锁。为了避免加锁,G1的每个线程都关联了一个PLAB,这样就不需要进行加锁了

OOM问题定位方法

(1):jmap -heap 10765如上图,可以查看新生代,老生代堆内存的分配大小以及使用情况;

(2):jstat 查看GC收集情况

(3):jmap -dump:live,format=b,file=到本地

(4):通过MAT工具打开分析

DUBBO

dubbo流程

(1):生产者(Provider)启动,向注册中心(Register)注册

(2):消费者(Consumer)订阅,而后注册中心通知消费者

(3):消费者从生产者进行消费

(4):监控中心(Monitor)统计生产者和消费者

Dubbo推荐使用什么序列化框架,还有哪些?

推荐使用Hessian序列化,还有Duddo、FastJson、Java自带序列化

Dubbo默认使用的是什么通信框架,还有哪些?

默认使用 Netty 框架,也是推荐的选择,另外内容还集成有Mina、Grizzly。

Dubbo有哪几种负载均衡策略,默认是哪种?

(1):随机调用<默认>

(2):权重轮询

(3):最少活跃数

(4):一致性Hash

RPC流程

(1)消费者调用需要消费的服务,

(2):客户端存根将方法、入参等信息序列化发送给服务端存根

(3):服务端存根反序列化操作根据解码结果调用本地的服务进行相关处理

(4):本地服务执行具体业务逻辑并将处理结果返回给服务端存根

(5):服务端存根序列化

(6):客户端存根反序列化

(7):服务消费方得到最终结果

RPC框架的实现目标PC框架的实现目标是把调用、编码/解码的过程给封装起来,让用户感觉上像调用本地服务一样的调用远程服务

服务暴露、服务引用、服务调用(TODO)

Redis

redis单线程为什么执行速度这么快?

(1):纯内存操作,避免大量访问数据库,减少直接读取磁盘数据,redis将数据储存在内存里面,读写数据的时候都不会受到硬盘 I/O 速度的限制,所以速度快

(2):单线程操作,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗

(3):采用了非阻塞I/O多路复用机制

Redis数据结构底层实现

String:

(1)Simple dynamic string(SDS)的数据结构

1
2
3
4
5
6
7
8
9
复制代码struct sdshdr{  
 //记录buf数组中已使用字节的数量
 //等于 SDS 保存字符串的长度
 int len;
 //记录 buf 数组中未使用字节的数量
 int free;
 //字节数组,用于保存字符串
 char buf[];
}

它的优点:
(1)不会出现字符串变更造成的内存溢出问题

(2)获取字符串长度时间复杂度为1

(3)空间预分配, 惰性空间释放free字段,会默认留够一定的空间防止多次重分配内存

应用场景:
String 缓存结构体用户信息,计数

Hash:

数组+链表的基础上,进行了一些rehash优化;
1.Reids的Hash采用链地址法来处理冲突,然后它没有使用红黑树优化。

2.哈希表节点采用单链表结构。

3.rehash优化 (采用分而治之的思想,将庞大的迁移工作量划分到每一次CURD中,避免了服务繁忙)

应用场景:
保存结构体信息可部分获取不用序列化所有字段

List:

应用场景:
(1):比如twitter的关注列表,粉丝列表等都可以用Redis的list结构来实现

(2):list的实现为一个双向链表,即可以支持反向查找和遍历

Set:

内部实现是一个 value为null的HashMap,实际就是通过计算hash的方式来快速排重的,这也是set能提供判断一个成员 是否在集合内的原因。
应用场景:
去重的场景,交集(sinter)、并集(sunion)、差集(sdiff),实现如共同关注、共同喜好、二度好友等功能

Zset:

内部使用HashMap和跳跃表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。
跳表:每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的
应用场景:
实现延时队列

redis事务

(1):Multi开启事务

(2):Exec执行事务块内命令

(3):Discard 取消事务

(4):Watch 监视一个或多个key,如果事务执行前key被改动,事务将打断

redis事务的实现特征

(1):所有命令都将会被串行化的顺序执行,事务执行期间,Redis不会再为其它客户端的请求提供任何服务,从而保证了事物中的所有命令被原子的执行。

(2):Redis事务中如果有某一条命令执行失败,其后的命令仍然会被继续执行

(3):在事务开启之前,如果客户端与服务器之间出现通讯故障并导致网络断开,其后所有待执行的语句都将不会被服务器执行。然而如果网络中断事件是发生在客户端执行EXEC命令之后,那么该事务中的所有命令都会被服务器执行

(4):当使用Append-Only模式时,Redis会通过调用系统函数write将该事务内的所有写操作在本次调用中全部写入磁盘。

然而如果在写入的过程中出现系统崩溃,如电源故障导致的宕机,那么此时也许只有部分数据被写入到磁盘,而另外一部分数据却已经丢失。

Redis服务器会在重新启动时执行一系列必要的一致性检测,一旦发现类似问题,就会立即退出并给出相应的错误提示。此时,我们就要充分利用Redis工具包中提供的redis-check-aof工具,该工具可以帮助我们定位到数据不一致的错误,并将已经写入的部分数据进行回滚。修复之后我们就可以再次重新启动Redis服务器了

Redis的同步机制?

(1):全量拷贝,
1.slave第一次启动时,连接Master,发送PSYNC命令,

2.master会执行bgsave命令来生成rdb文件,期间的所有写命令将被写入缓冲区。

1
2
3
4
复制代码  3. master bgsave执行完毕,向slave发送rdb文件
`- slave收到rdb文件,丢弃所有旧数据,开始载入rdb文件

- rdb文件同步结束之后,slave执行从master缓冲区发送过来的所以写命令。`5. `此后 master 每执行一个写命令,就向slave发送相同的写命令。`

(2):增量拷贝
如果出现网络闪断或者命令丢失等异常情况,从节点之前保存了自身已复制的偏移量和主节点的运行ID

  1. 主节点根据偏移量把复制积压缓冲区里的数据发送给从节点,保证主从复制进入正常状态。

redis集群模式性能优化

(1) Master最好不要做任何持久化工作,如RDB内存快照和AOF日志文件

(2) 如果数据比较重要,某个Slave开启AOF备份数据,策略设置为每秒同步一次

(3) 为了主从复制的速度和连接的稳定性,Master和Slave最好在同一个局域网内

(4) 尽量避免在压力很大的主库上增加从库

(5) 主从复制不要用图状结构,用单向链表结构更为稳定,即:Master <- Slave1 <- Slave2 <- Slave3…这样的结构方便解决单点故障问题,实现Slave对Master的替换。如果Master挂了,可以立刻启用Slave1做Master,其他不变。

Redis集群方案

(1):官方cluster方案

(2):twemproxy

代理方案twemproxy是一个单点,很容易对其造成很大的压力,所以通常会结合keepalived来实twemproy的高可用

(3):codis
基于客户端来进行分片

集群不可用场景

(1):master挂掉,且当前master没有slave

(2):集群超过半数以上master挂掉,无论是否有slave集群进入fail状态

redis 最适合的场景

(1):会话缓存session cache

(2):排行榜/计数器ZRANGE

(3):发布/订阅

缓存淘汰策略

(1):先进先出算法(FIFO)

(2):最近使用最少Least Frequently Used(LFU)

(3):最长时间未被使用的Least Recently Used(LRU)

当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重

redis过期key删除策略

(1):惰性删除,cpu友好,但是浪费cpu资源

(2):定时删除(不常用)

(3):定期删除,cpu友好,节省空间

缓存雪崩以及处理办法

同一时刻大量缓存失效;

处理方法:

(1):缓存数据增加过期标记

(2):设置不同的缓存失效时间

(3):双层缓存策略C1为短期,C2为长期

(4):定时更新策略

缓存击穿原因以及处理办法

频繁请求查询系统中不存在的数据导致;

处理方法:

(1):cache null策略,查询反馈结果为null仍然缓存这个null结果,设置不超过5分钟过期时间

(2):布隆过滤器,所有可能存在的数据映射到足够大的bitmap中
google布隆过滤器:基于内存,重启失效不支持大数据量,无法在分布式场景
redis布隆过滤器:可扩展性,不存在重启失效问题,需要网络io,性能低于google

redis阻塞原因

(1):数据结构使用不合理bigkey

(2):CPU饱和

(3):持久化阻塞,rdb fork子线程,aof每秒刷盘等

hot key出现造成集群访问量倾斜解决办法

(1):使用本地缓存

(2): 利用分片算法的特性,对key进行打散处理(给hot key加上前缀或者后缀,把一个hotkey 的数量变成 redis 实例个数N的倍数M,从而由访问一个 redis key 变成访问 N * M 个redis key)

Redis分布式锁

2.6版本以后lua脚本保证setnx跟setex进行原子性(setnx之后,未setex,服务挂了,锁不释放)
a获取锁,超过过期时间,自动释放锁,b获取到锁执行,a代码执行完remove锁,a和b是一样的key,导致a释放了b的锁。
解决办法:remove之前判断value(高并发下value可能被修改,应该用lua来保证原子性)

Redis如何做持久化

bgsave做镜像全量持久化,aof做增量持久化。因为bgsave会耗费较长时间,不够实时,在停机的时候会导致大量丢失数据 ,所以需要aof来配合使用。在redis实例重启时,会使用bgsave持久化文件重新构建内存,再使用aof重放近期的操作指令来 实 现完整恢复重启之前的状态。

对方追问那如果突然机器掉电会怎样?

取决于aof日志sync属性的配置,如果不要求性能,在每条写指令时都sync一下磁盘,就不会丢失数据。但是在高性能的要求下每次都sync是不现实的,一般都使用定时sync,比如1s1次,这个时候最多就会丢失1s的数据.

redis锁续租问题?

(1):基于redis的redission分布式可重入锁RLock,以及配合java集合中lock;

(2):Redission 内部提供了一个监控锁的看门狗,不断延长锁的有效期,默认检查锁的超时时间是30秒

(3):此方案的问题:如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master ,slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。

接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。
此时就会导致多个客户端对一个分布式锁完成了加锁
解决办法:只需要将新的redis实例,在一个TTL时间内,对客户端不可用即可,在这个时间内,所有客户端锁将被失效或者自动释放.

bgsave的原理是什么?

fork和cow。fork是指redis通过创建子进程来进行bgsave操作,cow指的是copy on write,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写进的页面数据会逐渐和子进程分离开来。

RDB与AOF区别

(1):R文件格式紧凑,方便数据恢复,保存rdb文件时父进程会fork出子进程由其完成具体持久化工作,最大化redis性能,恢复大数据集速度更快,只有手动提交save命令或关闭命令时才触发备份操作;

(2):A记录对服务器的每次写操作(默认1s写入一次),保存数据更完整,在redis重启是会重放这些命令来恢复数据,操作效率高,故障丢失数据更少,但是文件体积更大;

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

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

如何使用Redis做异步队列?

一般使用list结构作为队列,rpush生产消息,lpop消费消息。当lpop没有消息的时候,要适当sleep一会再重试。

可不可以不用sleep呢?

list还有个指令叫blpop,在没有消息的时候,它会阻塞住直到消息到来。

能不能生产一次消费多次呢?

使用pub/sub主题订阅者模式,可以实现1:N的消息队列。

pub/sub有什么缺点?

在消费者下线的情况下,生产的消息会丢失,得使用专业的消息队列如rabbitmq等。

redis如何实现延时队列?

使用sortedset,想要执行时间的时间戳作为score,消息内容作为key调用zadd来生产消息,消费者用zrangebyscore指令获取N秒之前的数据轮询进行处理。

为啥redis zset使用跳跃链表而不用红黑树实现?

(1):skiplist的复杂度和红黑树一样,而且实现起来更简单。

(2):在并发环境下红黑树在插入和删除时需要rebalance,性能不如跳表。

MYSQL

数据库三范式

一: 确保每列的原子性

二:非主键列不存在对主键的部分依赖 (要求每个表只描述一件事情)

三: 满足第二范式,并且表中的列不存在对非主键列的传递依赖

数据库主从复制原理

(1):主库db的更新事件(update、insert、delete)被写到binlog

(2):主库创建一个binlog dump thread线程,把binlog的内容发送到从库

(3):从库创建一个I/O线程,读取主库传过来的binlog内容并写入到relay log.

(4):从库还会创建一个SQL线程,从relay log里面读取内容写入到slave的db.

复制方式分类

(1):异步复制(默认)
主库写入binlog日志后即可成功返回客户端,无须等待binlog日志传递给从库的过程,但是一旦主库宕机,就有可能出现丢失数据的情况。

(2)半同步复制:( 5.5版本之后)
(安装半同步复制插件)确保从库接收完成主库传递过来的binlog内容已经写入到自己的relay log(传送log)后才会通知主库上面的等待线程。如果等待超时,则关闭半同步复制,并自动转换为异步复制模式,直到至少有一台从库通知主库已经接收到binlog信息为止

存储引擎

(1):Myiasm是mysql默认的存储引擎,不支持数据库事务,行级锁,外键;插入更新需锁表,效率低,查询速度快,Myisam使用的是非聚集索引

(2):innodb 支持事务,底层为B+树实现,适合处理多重并发更新操作,普通select都是快照读,快照读不加锁。InnoDb使用的是聚集索引

聚集索引

(1):聚集索引就是以主键创建的索引

(2):每个表只能有一个聚簇索引,因为一个表中的记录只能以一种物理顺序存放,实际的数据页只能按照一颗 B+ 树进行排序

(3):表记录的排列顺序和与索引的排列顺序一致

(4):聚集索引存储记录是物理上连续存在

(5):聚簇索引主键的插入速度要比非聚簇索引主键的插入速度慢很多

(6):聚簇索引适合排序,非聚簇索引不适合用在排序的场合,因为聚簇索引叶节点本身就是索引和数据按相同顺序放置在一起,索引序即是数据序,数据序即是索引序,所以很快。非聚簇索引叶节点是保留了一个指向数据的指针,索引本身当然是排序的,但是数据并未排序,数据查询的时候需要消耗额外更多的I/O,所以较慢

(7):更新聚集索引列的代价很高,因为会强制innodb将每个被更新的行移动到新的位置

非聚集索引

(1):除了主键以外的索引

(2):聚集索引的叶节点就是数据节点,而非聚簇索引的叶节点仍然是索引节点,并保留一个链接指向对应数据块

(3):聚簇索引适合排序,非聚簇索引不适合用在排序的场合

(4):聚集索引存储记录是物理上连续存在,非聚集索引是逻辑上的连续。

使用聚集索引为什么查询速度会变快?

使用聚簇索引找到包含第一个值的行后,便可以确保包含后续索引值的行在物理相邻

建立聚集索引有什么需要注意的地方吗?

在聚簇索引中不要包含经常修改的列,因为码值修改后,数据行必须移动到新的位置,索引此时会重排,会造成很大的资源浪费

InnoDB 表对主键生成策略是什么样的?

优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个Unique键作为主键,如果表中连Unique键都没有定义的话,则InnoDB会为表默认添加一个名为row_id隐藏列作为主键。

非聚集索引最多可以有多少个?

每个表你最多可以建立249个非聚簇索引。非聚簇索引需要大量的硬盘空间和内存

BTree 与 Hash 索引有什么区别?

(1):BTree索引可能需要多次运用折半查找来找到对应的数据块
(2):HASH索引是通过HASH函数,计算出HASH值,在表中找出对应的数据
(3):大量不同数据等值精确查询,HASH索引效率通常比B+TREE高
(4):HASH索引不支持模糊查询、范围查询和联合索引中的最左匹配规则,而这些Btree索引都支持

数据库索引优缺点

(1):需要查询,排序,分组和联合操作的字段适合建立索引

(2):索引多,数据更新表越慢,尽量使用字段值不重复比例大的字段作为索引,联合索引比多个独立索引效率高

(3):对数据进行频繁查询进建立索引,如果要频繁更改数据不建议使用索引

(4):当对表中的数据进行增加、删除和修改的时候,索引也要动态的维护,降低了数据的维护速度。

索引的底层实现是B+树,为何不采用红黑树,B树?

(1):B+Tree非叶子节点只存储键值信息,降低B+Tree的高度,所有叶子节点之间都有一个链指针,数据记录都存放在叶子节点中

(2): 红黑树这种结构,h明显要深的多,效率明显比B-Tree差很多

(3):B+树也存在劣势,由于键会重复出现,因此会占用更多的空间。但是与带来的性能优势相比,空间劣势往往可以接受,因此B+树的在数据库中的使用比B树更加广泛

索引失效条件

(1):条件是or,如果还想让or条件生效,给or每个字段加个索引

(2):like开头%

(3):如果列类型是字符串,那一定要在条件中将数据使用引号引用起来,否则不会使用索引

(4):where中索引列使用了函数或有运算

数据库事务特点

ACID 原子性,一致性,隔离性,永久性

数据库事务说是如何实现的?

(1):通过预写日志方式实现的,redo和undo机制是数据库实现事务的基础

(2):redo日志用来在断电/数据库崩溃等状况发生时重演一次刷数据的过程,把redo日志里的数据刷到数据库里,保证了事务 的持久性(Durability)

(3):undo日志是在事务执行失败的时候撤销对数据库的操作,保证了事务的原子性

数据库事务隔离级别

(1):读未提交read-uncommitted– 脏,不可重复读–幻读
A读取了B未提交的事务,B回滚,A 出现脏读;

(2):不可重复读read-committed– 不可重复读–幻读
A只能读B已提交的事务,但是A还没结束,B又更新数据隐式提交,然后A又读了一次出现不可重复读;

(3):可重复读repeatable-read<默认>– 幻读
事务开启,不允许其他事务的UPDATE修改操作
A读取B已提交的事务,然而B在该表插入新的行,之后A在读取的时候多出一行,出现幻读;

(4):串行化serializable–

七种事务传播行为

(1)Propagation.REQUIRED<默认>
如果当前存在事务,则加入该事务,如果当前不存在事务,则创建一个新的事务。

(2)Propagation.SUPPORTS
如果当前存在事务,则加入该事务;如果当前不存在事务,则以非事务的方式继续运行。

(3)Propagation.MANDATORY
如果当前存在事务,则加入该事务;如果当前不存在事务,则抛出异常。

(4)Propagation.REQUIRES_NEW
重新创建一个新的事务,如果当前存在事务,延缓当前的事务。

(5)Propagation.NOT_SUPPORTED
以非事务的方式运行,如果当前存在事务,暂停当前的事务。

(6)Propagation.NEVER
以非事务的方式运行,如果当前存在事务,则抛出异常。

(7)Propagation.NESTED
如果没有,就新建一个事务;如果有,就在当前事务中嵌套其他事务。

产生死锁的四个必要条件

(1):互斥: 资源x的任意一个时刻只能被一个线程持有
(2):占有且等待:线程1占有资源x的同时等待资源y,并不释放x
(3):不可抢占:资源x一旦被线程1占有,其他线程不能抢占x
(4):循环等待:线程1持有x,等待y,线程2持有y,等待x
当全部满足时才会死锁

用于掘金字数限制2W字,原文大家去我的Git或者公众号看吧,我掐准了2W字发的

总结

内容过于硬核了,导致很多排版细节,我没办法做得像其他期一样精致了,大家见谅。

涉及的内容和东西太多了,可能很多都是点到为止,也有很多不全的,也有很多错误的点,已经快3W字了,我校验实在困难,我会放在GitHub上面,大家可以跟我一起更新这个文章,造福后人吧。

搞不好下次我需要看的时候,我都得看着这个复习了。

我是敖丙,一个在互联网苟且偷生的工具人。

你知道的越多,你不知道的越多,人才们的 【三连】 就是丙丙创作的最大动力,我们下期见!

注:如果本篇博客有任何错误和建议,欢迎人才们留言,你快说句话啊!


文章持续更新,可以微信搜索「 三太子敖丙 」第一时间阅读,回复【资料】【面试】【简历】有我准备的一线大厂面试资料和简历模板,本文 GitHub github.com/JavaFamily 已经收录,有大厂面试完整考点,欢迎Star。

本文转载自: 掘金

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

人人都能看懂的 6 种限流实现方案!(纯干货)

发表于 2020-05-18

为了上班方便,去年我把自己在北郊的房子租出去了,搬到了南郊,这样离我上班的地方就近了,它为我节约了很多的时间成本,我可以用它来做很多有意义的事,最起码不会因为堵车而闹心了,幸福感直线上升。

但即使这样,生活也有其他的烦恼。南郊的居住密度比较大,因此停车就成了头痛的事,我租的是路两边的非固定车位,每次只要下班回来,一定是没有车位停了,因此我只能和别人的车并排停着,但这样带来的问题是,我每天早上都要被挪车的电话给叫醒,心情自然就不用说了。

但后来几天,我就慢慢变聪明了,我头天晚上停车的时候,会找第二天限行的车并排停着,这样我第二天就不用挪车了,这真是限行给我带来的“巨大红利”啊。

而车辆限行就是一种生活中很常见的限流策略,他除了给我带来了以上的好处之外,还给我们美好的生活环境带来了一丝改善,并且快速增长的私家车已经给我们的交通带来了巨大的“负担”,如果再不限行,可能所有的车都要被堵在路上,这就是限流给我们的生活带来的巨大好处。

从生活回到程序中,假设一个系统只能为 10W 人提供服务,突然有一天因为某个热点事件,造成了系统短时间内的访问量迅速增加到了 50W,那么导致的直接结果是系统崩溃,任何人都不能用系统了,显然只有少人数能用远比所有人都不能用更符合我们的预期,因此这个时候我们要使用「限流」了。

限流分类

限流的实现方案有很多种,磊哥这里稍微理了一下,限流的分类如下所示:

  1. 合法性验证限流:比如验证码、IP 黑名单等,这些手段可以有效的防止恶意攻击和爬虫采集;
  2. 容器限流:比如 Tomcat、Nginx 等限流手段,其中 Tomcat 可以设置最大线程数(maxThreads),当并发超过最大线程数会排队等待执行;而 Nginx 提供了两种限流手段:一是控制速率,二是控制并发连接数;
  3. 服务端限流:比如我们在服务器端通过限流算法实现限流,此项也是我们本文介绍的重点。

合法性验证限流为最常规的业务代码,就是普通的验证码和 IP 黑名单系统,本文就不做过多的叙述了,我们重点来看下后两种限流的实现方案:容器限流和服务端限流。

容器限流

Tomcat 限流

Tomcat 8.5 版本的最大线程数在 conf/server.xml 配置中,如下所示:

1
2
3
4
复制代码<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
maxThreads="150"
redirectPort="8443" />

其中 maxThreads 就是 Tomcat 的最大线程数,当请求的并发大于此值(maxThreads)时,请求就会排队执行,这样就完成了限流的目的。

小贴士:maxThreads 的值可以适当的调大一些,此值默认为 150(Tomcat 版本 8.5.42),但这个值也不是越大越好,要看具体的硬件配置,需要注意的是每开启一个线程需要耗用 1MB 的 JVM 内存空间用于作为线程栈之用,并且线程越多 GC 的负担也越重。最后需要注意一下,操作系统对于进程中的线程数有一定的限制,Windows 每个进程中的线程数不允许超过 2000,Linux 每个进程中的线程数不允许超过 1000。

Nginx 限流

Nginx 提供了两种限流手段:一是控制速率,二是控制并发连接数。

控制速率

我们需要使用 limit_req_zone 用来限制单位时间内的请求数,即速率限制,示例配置如下:

1
2
3
4
5
6
复制代码limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {
limit_req zone=mylimit;
}
}

以上配置表示,限制每个 IP 访问的速度为 2r/s,因为 Nginx 的限流统计是基于毫秒的,我们设置的速度是 2r/s,转换一下就是 500ms 内单个 IP 只允许通过 1 个请求,从 501ms 开始才允许通过第 2 个请求。

我们使用单 IP 在 10ms 内发并发送了 6 个请求的执行结果如下:

img

从以上结果可以看出他的执行符合我们的预期,只有 1 个执行成功了,其他的 5 个被拒绝了(第 2 个在 501ms 才会被正常执行)。

速率限制升级版

上面的速率控制虽然很精准但是应用于真实环境未免太苛刻了,真实情况下我们应该控制一个 IP 单位总时间内的总访问次数,而不是像上面那么精确但毫秒,我们可以使用 burst 关键字开启此设置,示例配置如下:

1
2
3
4
5
6
复制代码limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {
limit_req zone=mylimit burst=4;
}
}

burst=4 表示每个 IP 最多允许4个突发请求,如果单个 IP 在 10ms 内发送 6 次请求的结果如下:

img

从以上结果可以看出,有 1 个请求被立即处理了,4 个请求被放到 burst 队列里排队执行了,另外 1 个请求被拒绝了。

控制并发数

利用 limit_conn_zone 和 limit_conn 两个指令即可控制并发数,示例配置如下:

1
2
3
4
5
6
7
复制代码limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;
server {
...
limit_conn perip 10;
limit_conn perserver 100;
}

其中 limit_conn perip 10 表示限制单个 IP 同时最多能持有 10 个连接;limit_conn perserver 100 表示 server 同时能处理并发连接的总数为 100 个。

小贴士:只有当 request header 被后端处理后,这个连接才进行计数。

服务端限流

服务端限流需要配合限流的算法来执行,而算法相当于执行限流的“大脑”,用于指导限制方案的实现。

有人看到「算法」两个字可能就晕了,觉得很深奥,其实并不是。算法就相当于操作某个事务的具体实现步骤汇总,其实并不难懂,不要被它的表象给吓到哦~

限流的常见算法有以下三种:

  1. 时间窗口算法
  2. 漏桶算法
  3. 令牌算法

接下来我们分别看来。

1.时间窗口算法

所谓的滑动时间算法指的是以当前时间为截止时间,往前取一定的时间,比如往前取 60s 的时间,在这 60s 之内运行最大的访问数为 100,此时算法的执行逻辑为,先清除 60s 之前的所有请求记录,再计算当前集合内请求数量是否大于设定的最大请求数 100,如果大于则执行限流拒绝策略,否则插入本次请求记录并返回可以正常执行的标识给客户端。

滑动时间窗口如下图所示:


其中每一小个表示 10s,被红色虚线包围的时间段则为需要判断的时间间隔,比如 60s 秒允许 100 次请求,那么红色虚线部分则为 60s。

我们可以借助 Redis 的有序集合 ZSet 来实现时间窗口算法限流,实现的过程是先使用 ZSet 的 key 存储限流的 ID,score 用来存储请求的时间,每次有请求访问来了之后,先清空之前时间窗口的访问量,统计现在时间窗口的个数和最大允许访问量对比,如果大于等于最大访问量则返回 false 执行限流操作,负责允许执行业务逻辑,并且在 ZSet 中添加一条有效的访问记录,具体实现代码如下。

我们借助 Jedis 包来操作 Redis,实现在 pom.xml 添加 Jedis 框架的引用,配置如下:

1
2
3
4
5
6
复制代码<!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.3.0</version>
</dependency>

具体的 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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
复制代码import redis.clients.jedis.Jedis;

public class RedisLimit {
// Redis 操作客户端
static Jedis jedis = new Jedis("127.0.0.1", 6379);

public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 15; i++) {
boolean res = isPeriodLimiting("java", 3, 10);
if (res) {
System.out.println("正常执行请求:" + i);
} else {
System.out.println("被限流:" + i);
}
}
// 休眠 4s
Thread.sleep(4000);
// 超过最大执行时间之后,再从发起请求
boolean res = isPeriodLimiting("java", 3, 10);
if (res) {
System.out.println("休眠后,正常执行请求");
} else {
System.out.println("休眠后,被限流");
}
}

/**
* 限流方法(滑动时间算法)
* @param key 限流标识
* @param period 限流时间范围(单位:秒)
* @param maxCount 最大运行访问次数
* @return
*/
private static boolean isPeriodLimiting(String key, int period, int maxCount) {
long nowTs = System.currentTimeMillis(); // 当前时间戳
// 删除非时间段内的请求数据(清除老访问数据,比如 period=60 时,标识清除 60s 以前的请求记录)
jedis.zremrangeByScore(key, 0, nowTs - period * 1000);
long currCount = jedis.zcard(key); // 当前请求次数
if (currCount >= maxCount) {
// 超过最大请求次数,执行限流
return false;
}
// 未达到最大请求数,正常执行业务
jedis.zadd(key, nowTs, "" + nowTs); // 请求记录 +1
return true;
}
}

以上程序的执行结果为:

正常执行请求:0

正常执行请求:1

正常执行请求:2

正常执行请求:3

正常执行请求:4

正常执行请求:5

正常执行请求:6

正常执行请求:7

正常执行请求:8

正常执行请求:9

被限流:10

被限流:11

被限流:12

被限流:13

被限流:14

休眠后,正常执行请求

此实现方式存在的缺点有两个:

  • 使用 ZSet 存储有每次的访问记录,如果数据量比较大时会占用大量的空间,比如 60s 允许 100W 访问时;
  • 此代码的执行非原子操作,先判断后增加,中间空隙可穿插其他业务逻辑的执行,最终导致结果不准确。

2.漏桶算法

漏桶算法的灵感源于漏斗,如下图所示:


滑动时间算法有一个问题就是在一定范围内,比如 60s 内只能有 10 个请求,当第一秒时就到达了 10 个请求,那么剩下的 59s 只能把所有的请求都给拒绝掉,而漏桶算法可以解决这个问题。

漏桶算法类似于生活中的漏斗,无论上面的水流倒入漏斗有多大,也就是无论请求有多少,它都是以均匀的速度慢慢流出的。当上面的水流速度大于下面的流出速度时,漏斗会慢慢变满,当漏斗满了之后就会丢弃新来的请求;当上面的水流速度小于下面流出的速度的话,漏斗永远不会被装满,并且可以一直流出。

漏桶算法的实现步骤是,先声明一个队列用来保存请求,这个队列相当于漏斗,当队列容量满了之后就放弃新来的请求,然后重新声明一个线程定期从任务队列中获取一个或多个任务进行执行,这样就实现了漏桶算法。

上面我们演示 Nginx 的控制速率其实使用的就是漏桶算法,当然我们也可以借助 Redis 很方便的实现漏桶算法。

我们可以使用 Redis 4.0 版本中提供的 Redis-Cell 模块,该模块使用的是漏斗算法,并且提供了原子的限流指令,而且依靠 Redis 这个天生的分布式程序就可以实现比较完美的限流了。

Redis-Cell 实现限流的方法也很简单,只需要使用一条指令 cl.throttle 即可,使用示例如下:

1
2
3
4
5
6
复制代码> cl.throttle mylimit 15 30 60
1)(integer)0 # 0 表示获取成功,1 表示拒绝
2)(integer)15 # 漏斗容量
3)(integer)14 # 漏斗剩余容量
4)(integer)-1 # 被拒绝之后,多长时间之后再试(单位:秒)-1 表示无需重试
5)(integer)2 # 多久之后漏斗完全空出来

其中 15 为漏斗的容量,30 / 60s 为漏斗的速率。

3.令牌算法

在令牌桶算法中有一个程序以某种恒定的速度生成令牌,并存入令牌桶中,而每个请求需要先获取令牌才能执行,如果没有获取到令牌的请求可以选择等待或者放弃执行,如下图所示:


我们可以使用 Google 开源的 guava 包,很方便的实现令牌桶算法,首先在 pom.xml 添加 guava 引用,配置如下:

1
2
3
4
5
6
复制代码<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.2-jre</version>
</dependency>

具体实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码import com.google.common.util.concurrent.RateLimiter;

import java.time.Instant;

/**
* Guava 实现限流
*/
public class RateLimiterExample {
public static void main(String[] args) {
// 每秒产生 10 个令牌(每 100 ms 产生一个)
RateLimiter rt = RateLimiter.create(10);
for (int i = 0; i < 11; i++) {
new Thread(() -> {
// 获取 1 个令牌
rt.acquire();
System.out.println("正常执行方法,ts:" + Instant.now());
}).start();
}
}
}

以上程序的执行结果为:

正常执行方法,ts:2020-05-15T14:46:37.175Z

正常执行方法,ts:2020-05-15T14:46:37.237Z

正常执行方法,ts:2020-05-15T14:46:37.339Z

正常执行方法,ts:2020-05-15T14:46:37.442Z

正常执行方法,ts:2020-05-15T14:46:37.542Z

正常执行方法,ts:2020-05-15T14:46:37.640Z

正常执行方法,ts:2020-05-15T14:46:37.741Z

正常执行方法,ts:2020-05-15T14:46:37.840Z

正常执行方法,ts:2020-05-15T14:46:37.942Z

正常执行方法,ts:2020-05-15T14:46:38.042Z

正常执行方法,ts:2020-05-15T14:46:38.142Z

从以上结果可以看出令牌确实是每 100ms 产生一个,而 acquire() 方法为阻塞等待获取令牌,它可以传递一个 int 类型的参数,用于指定获取令牌的个数。它的替代方法还有 tryAcquire(),此方法在没有可用令牌时就会返回 false 这样就不会阻塞等待了。当然 tryAcquire() 方法也可以设置超时时间,未超过最大等待时间会阻塞等待获取令牌,如果超过了最大等待时间,还没有可用的令牌就会返回 false。

注意:使用 guava 实现的令牌算法属于程序级别的单机限流方案,而上面使用 Redis-Cell 的是分布式的限流方案。

总结

本文提供了 6 种具体的实现限流的手段,他们分别是:Tomcat 使用 maxThreads 来实现限流;Nginx 提供了两种限流方式,一是通过 limit_req_zone 和 burst 来实现速率限流,二是通过 limit_conn_zone 和 limit_conn 两个指令控制并发连接的总数。最后我们讲了时间窗口算法借助 Redis 的有序集合可以实现,还有漏桶算法可以使用 Redis-Cell 来实现,以及令牌算法可以解决 Google 的 guava 包来实现。

需要注意的是借助 Redis 实现的限流方案可用于分布式系统,而 guava 实现的限流只能应用于单机环境。如果你嫌弃服务器端限流麻烦,甚至可以在不改代码的情况下直接使用容器限流(Nginx 或 Tomcat),但前提是能满足你的业务需求。

好了,文章到这里就结束了,期待我们下期再会~

最后的话

原创不易,如果觉得本文对你有用,请随手点击一个「赞」,这是对作者最大的支持与鼓励,谢谢你!

参考 & 鸣谢

www.cnblogs.com/biglittlean…

关注公众号「Java中文社群」回复“干货”,获取 50 篇原创干货 Top 榜。

本文转载自: 掘金

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

集群化部署,Spring Security 要如何处理 se

发表于 2020-05-18

前面和大家聊了 Spring Security 如何像 QQ 一样,自动踢掉已登录用户(Spring Boot + Vue 前后端分离项目,如何踢掉已登录用户?),但是前面我们是基于单体应用的,如果我们的项目是集群化部署,这个问题该如何解决呢?

今天我们就来看看集群化部署,Spring Security 要如何处理 session 并发。

本文是 Spring Security 系列第 17 篇,阅读前面的文章有助于更好的理解本文:

  1. 挖一个大坑,Spring Security 开搞!
  2. 松哥手把手带你入门 Spring Security,别再问密码怎么解密了
  3. 手把手教你定制 Spring Security 中的表单登录
  4. Spring Security 做前后端分离,咱就别做页面跳转了!统统 JSON 交互
  5. Spring Security 中的授权操作原来这么简单
  6. Spring Security 如何将用户数据存入数据库?
  7. Spring Security+Spring Data Jpa 强强联手,安全管理只有更简单!
  8. Spring Boot + Spring Security 实现自动登录功能
  9. Spring Boot 自动登录,安全风险要怎么控制?
  10. 在微服务项目中,Spring Security 比 Shiro 强在哪?
  11. SpringSecurity 自定义认证逻辑的两种方式(高级玩法)
  12. Spring Security 中如何快速查看登录用户 IP 地址等信息?
  13. Spring Security 自动踢掉前一个登录用户,一个配置搞定!
  14. Spring Boot + Vue 前后端分离项目,如何踢掉已登录用户?
  15. Spring Security 自带防火墙!你都不知道自己的系统有多安全!
  16. 什么是会话固定攻击?Spring Boot 中要如何防御会话固定攻击?

1.集群会话方案

在传统的单服务架构中,一般来说,只有一个服务器,那么不存在 Session 共享问题,但是在分布式/集群项目中,Session 共享则是一个必须面对的问题,先看一个简单的架构图:

在这样的架构中,会出现一些单服务中不存在的问题,例如客户端发起一个请求,这个请求到达 Nginx 上之后,被 Nginx 转发到 Tomcat A 上,然后在 Tomcat A 上往 session 中保存了一份数据,下次又来一个请求,这个请求被转发到 Tomcat B 上,此时再去 Session 中获取数据,发现没有之前的数据。

1.1 session 共享

对于这一类问题的解决,目前比较主流的方案就是将各个服务之间需要共享的数据,保存到一个公共的地方(主流方案就是 Redis):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aaFMeebv-1589763397129)(img.itboyhub.com/2020/05/14-…)]

当所有 Tomcat 需要往 Session 中写数据时,都往 Redis 中写,当所有 Tomcat 需要读数据时,都从 Redis 中读。这样,不同的服务就可以使用相同的 Session 数据了。

这样的方案,可以由开发者手动实现,即手动往 Redis 中存储数据,手动从 Redis 中读取数据,相当于使用一些 Redis 客户端工具来实现这样的功能,毫无疑问,手动实现工作量还是蛮大的。

一个简化的方案就是使用 Spring Session 来实现这一功能,Spring Session 就是使用 Spring 中的代理过滤器,将所有的 Session 操作拦截下来,自动的将数据 同步到 Redis 中,或者自动的从 Redis 中读取数据。

对于开发者来说,所有关于 Session 同步的操作都是透明的,开发者使用 Spring Session,一旦配置完成后,具体的用法就像使用一个普通的 Session 一样。

1.2 session 拷贝

session 拷贝就是不利用 redis,直接在各个 Tomcat 之间进行 session 数据拷贝,但是这种方式效率有点低,Tomcat A、B、C 中任意一个的 session 发生了变化,都需要拷贝到其他 Tomcat 上,如果集群中的服务器数量特别多的话,这种方式不仅效率低,还会有很严重的延迟。

所以这种方案一般作为了解即可。

1.3 粘滞会话

所谓的粘滞会话就是将相同 IP 发送来的请求,通过 Nginx 路由到同一个 Tomcat 上去,这样就不用进行 session 共享与同步了。这是一个办法,但是在一些极端情况下,可能会导致负载失衡(因为大部分情况下,都是很多人用同一个公网 IP)。

所以,Session 共享就成为了这个问题目前主流的解决方案了。

2.Session共享

2.1 创建工程

首先 创建一个 Spring Boot 工程,引入 Web、Spring Session、Spring Security 以及 Redis:

创建成功之后,pom.xml 文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>

2.2 配置

1
2
3
4
5
6
7
8
复制代码spring.redis.password=123
spring.redis.port=6379
spring.redis.host=127.0.0.1

spring.security.user.name=javaboy
spring.security.user.password=123

server.port=8080

配置一下 Redis 的基本信息;Spring Security 为了简化,我就将用户名密码直接配置在 application.properties 中了,最后再配置一下项目端口号。

2.3 使用

配置完成后 ,就可以使用 Spring Session 了,其实就是使用普通的 HttpSession ,其他的 Session 同步到 Redis 等操作,框架已经自动帮你完成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码@RestController
public class HelloController {
@Value("${server.port}")
Integer port;
@GetMapping("/set")
public String set(HttpSession session) {
session.setAttribute("user", "javaboy");
return String.valueOf(port);
}
@GetMapping("/get")
public String get(HttpSession session) {
return session.getAttribute("user") + ":" + port;
}
}

考虑到一会 Spring Boot 将以集群的方式启动 ,为了获取每一个请求到底是哪一个 Spring Boot 提供的服务,需要在每次请求时返回当前服务的端口号,因此这里我注入了 server.port 。

接下来 ,项目打包:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hqUwnFQc-1589763397136)(img.itboyhub.com/2020/05/14-…)]

打包之后,启动项目的两个实例:

1
2
复制代码java -jar session-4-0.0.1-SNAPSHOT.jar --server.port=8080
java -jar session-4-0.0.1-SNAPSHOT.jar --server.port=8081

然后先访问 localhost:8080/set 向 8080 这个服务的 Session 中保存一个变量,第一次访问时会自动跳转到登录页面,输入用户名密码进行登录即可。访问成功后,数据就已经自动同步到 Redis 中 了 :

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HKNFuzWj-1589763397137)(img.itboyhub.com/2020/05/202…)]

然后,再调用 localhost:8081/get 接口,就可以获取到 8080 服务的 session 中的数据:

此时关于 session 共享的配置就已经全部完成了,session 共享的效果我们已经看到了。

2.4 Security 配置

Session 共享已经实现了,但是我们发现新的问题,在Spring Boot + Vue 前后端分离项目,如何踢掉已登录用户?一文中我们配置的 session 并发管理失效了。

也就是说,如果我添加了如下配置:

1
2
3
4
5
6
7
复制代码protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest()
...
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true);
}

现在这个配置不起作用,用户依然可以在多个浏览器上同时登录。

这是怎么回事呢?

首先建议大家回忆一下Spring Boot + Vue 前后端分离项目,如何踢掉已登录用户?一文。

在该文中,我们提到,会话注册表的维护默认是由 SessionRegistryImpl 来维护的,而 SessionRegistryImpl 的维护就是基于内存的维护。现在我们虽然启用了 Spring Session+Redis 做 Session 共享,但是 SessionRegistryImpl 依然是基于内存来维护的,所以我们要修改 SessionRegistryImpl 的实现逻辑。

修改方式也很简单,实际上 Spring Session 为我们提供了对应的实现类 SpringSessionBackedSessionRegistry,具体配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
FindByIndexNameSessionRepository sessionRepository;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest()
...
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
.sessionRegistry(sessionRegistry());
}
@Bean
SpringSessionBackedSessionRegistry sessionRegistry() {
return new SpringSessionBackedSessionRegistry(sessionRepository);
}
}

我们在这里只需要提供一个 SpringSessionBackedSessionRegistry 的实例,并且将其配置到 sessionManagement 中去即可。以后,session 并发数据的维护将由 SpringSessionBackedSessionRegistry 来完成,而不是 SessionRegistryImpl,如此,我们关于 session 并发的配置就生效了,在集群环境下,用户也只可以在一台设备上登录。

为了让我们的案例看起更完美一些,接下来我们来引入 Nginx ,实现服务实例自动切换。

3.引入 Nginx

很简单,进入 Nginx 的安装目录的 conf 目录下(默认是在 /usr/local/nginx/conf),编辑 nginx.conf 文件:

在这段配置中:

  1. upstream 表示配置上游服务器
  2. javaboy.org 表示服务器集群的名字,这个可以随意取名字
  3. upstream 里边配置的是一个个的单独服务
  4. weight 表示服务的权重,意味者将有多少比例的请求从 Nginx 上转发到该服务上
  5. location 中的 proxy_pass 表示请求转发的地址,/ 表示拦截到所有的请求,转发转发到刚刚配置好的服务集群中
  6. proxy_redirect 表示设置当发生重定向请求时,nginx 自动修正响应头数据(默认是 Tomcat 返回重定向,此时重定向的地址是 Tomcat 的地址,我们需要将之修改使之成为 Nginx 的地址)。

配置完成后,将本地的 Spring Boot 打包好的 jar 上传到 Linux ,然后在 Linux 上分别启动两个 Spring Boot 实例:

1
2
复制代码nohup java -jar session-4-0.0.1-SNAPSHOT.jar --server.port=8080 &
nohup java -jar session-4-0.0.1-SNAPSHOT.jar --server.port=8081 &

其中

  • nohup 表示当终端关闭时,Spring Boot 不要停止运行
  • & 表示让 Spring Boot 在后台启动

配置完成后,重启 Nginx:

1
复制代码/usr/local/nginx/sbin/nginx -s reload

Nginx 启动成功后,我们首先手动清除 Redis 上的数据,然后访问 192.168.66.128/set 表示向 session 中保存数据,这个请求首先会到达 Nginx 上,再由 Nginx 转发给某一个 Spring Boot 实例:

如上,表示端口为 8081 的 Spring Boot 处理了这个 /set 请求,再访问 /get 请求:

可以看到,/get 请求是被端口为 8080 的服务所处理的。

4.总结

本文主要向大家介绍了 Spring Session 的使用,另外也涉及到一些 Nginx 的使用 ,虽然本文较长,但是实际上 Spring Session 的配置没啥,涉及到的配置也都很简单。

如果大家没有在 SSM 架构中用过 Spring Session ,可能不太好理解我们在 Spring Boot 中使用 Spring Session 有多么方便,因为在 SSM 架构中,Spring Session 的使用要配置三个地方 ,一个是 web.xml 配置代理过滤器,然后在 Spring 容器中配置 Redis,最后再配置 Spring Session,步骤还是有些繁琐的,而 Spring Boot 中直接帮我们省去了这些繁琐的步骤!

好了 ,本文就说到这里,本文相关案例我已经上传到 GitHub ,大家可以自行下载:github.com/lenve/sprin…

如果觉得有收获,记得点个在看鼓励下松哥哦~

本文转载自: 掘金

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

Spring MVC 函数式编程进阶

发表于 2020-05-18

  1. 前言

上一篇对 Spring MVC 的函数式接口编程进行了简单入门,让很多不知道的同学见识了这种新操作。也有反应这种看起来没有传统写法顺眼,其实大家都一样。但是我们还是要敢于尝试新事物。Java Lambada 刚出来也是被人各种吐槽,现在我在很多项目都见到了它的身影。好了转回正题,本文是对上一篇的延伸,我们继续对 Functional Endpoint 进行一些了解和运用。范式转换其实上一篇已经介绍差不多了,但是一旦你初次接触这种方式往往会面临新的问题。

  1. 新的问题

在使用这种风格时我们也会遇到一些新的问题。接下来我们将通过举例来一步步解决这些问题。

2.1 如何异常处理

接口异常处理是必须的。改成函数式风格后异常可以这样处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码    /**
* 接口附带异常处理逻辑.
*
* @param userService the user service
* @return the user by name with error handle
*/
public RouterFunction<ServerResponse> withErrorHandle() {
return RouterFunctions.route()
.GET("/userwitherrorhandle/{username}",
request -> ServerResponse.ok()
.body(userService.getByName(request.pathVariable("username"))))
// 异常处理
.onError(RuntimeException.class,
(e, request) -> EntityResponse.fromObject(e.getMessage())
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.build())
.build();
}

你可以使用上面的 onError 方法及其重载方法进行接口的异常处理。但是传统方法有统一异常处理啊!不要捉急,后面我们也会进行统一异常的处理。

2.2 如何使用过滤器

我还有不少 Spring MVC 在使用过滤器呢,使用这种风格如何编写过滤器,上一篇漏掉了一个处理过滤器的函数式接口HandlerFilterFunction 。我们通过该接口来对请求进行过滤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码    /**
* 对特定接口指定过滤器.
*
* @param userService the user service
* @return the router function
*/
public RouterFunction<ServerResponse> withFilter() {
return RouterFunctions.route().POST("/save",
request -> ServerResponse.ok()
.body(userService.saveUser(request.body(UserInfo.class))))
// 执行了一个过滤器逻辑 参数携带了 save 放行 否则返回 bad request 并附带消息
.filter((request, next) -> request.param("save").isPresent() ?
next.handle(request) :
ServerResponse.status(HttpStatus.BAD_REQUEST).body("no save"))
.build();
}

通过 filter 方法我们可以实现日志、安全策略、跨域等功能。

2.3 如何使用拦截器

使用函数式编程风格时并没有提供 Spring MVC 的拦截器 API,但是提供了类似过滤器前置/后置处理机制以达到同样的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码    public RouterFunction<ServerResponse> getUserByName() {
return RouterFunctions.route()
.GET("/user/{username}",
request -> ServerResponse.ok()
.body(userService.getByName(request.pathVariable("username"))))
// 前置处理 打印 path
.before(serverRequest -> {
log.info(serverRequest.path());
return serverRequest;
})
// 后置处理 如果响应状态为200 则打印 response ok
.after(((serverRequest, serverResponse) -> {
if (serverResponse.statusCode() == HttpStatus.OK) {
log.info("response ok");
}
return serverResponse;
})).build();
}

当你请求/user/{username}时, before 和 after 方法将会分别进行前置和后置处理。

2.4 如何进行统一处理

传统方式我们每个Controller 处理的都是特定单一领域的业务,UserController 处理 User相关业务,我们会给它添加一个统一的前缀标识 /v1/user;OrderController处理 Order 相关业务,给它添加一个统一的前缀标识 /v1/order。对相同得业务接口进行聚合更加有利于维护使用函数式编程我们可以通过以下方式实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码@Bean
RouterFunction<ServerResponse> userEndpoints(UserController userController) {
return RouterFunctions.route()
.path("/v2/user", builder -> builder
// /get/{username} -> /v2/user//get/{username}
.add(userController.getUserByName()
// /del/{username} -> /v2/user//del/{username}
.and(userController.delUser()
// /save -> /v2/user/save
.and(userController.saveUser()
// /update -> /v2/user/update
.and(userController.updateUser())))))
.build();
}

你也可以使用 RouterFunctions.route().nest相关的方法进行实现。而且对这些路由进行分组聚合之后就可以统一过滤器、拦截器、异常处理。例如上一篇提到的统一异常问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码@Bean
RouterFunction<ServerResponse> nestEndpoints(UserController userController) {
return RouterFunctions.route().nest(RequestPredicates.path("/v1/user"),
builder -> builder
.add(userController.getUserByName())
.add(userController.delUser())
.add(userController.saveUser())
.add(userController.updateUser()))
// 对上述路由进行统一的异常处理
.onError(RuntimeException.class,
(throwable, serverRequest) -> ServerResponse
.status(HttpStatus.BAD_REQUEST)
.body("bad req"))
.build();
}
  1. 总结

本文主要对 Spring MVC 函数式开发和传统开发中等效的特性(过滤器、拦截器、分组聚合等)进行了简单的说明,更加贴合于实际运用。函数式风格开发更加灵活,但是同样让习惯命令式编程的开发者有点不适应,但是目前越来越被普遍的应用。所以-/如果有志于长期从事编程开发的同学来说,还是需要掌握的。本文的 demo 可通过关注公众号:Felordcn回复 mvcfun 获取。

关注公众号:Felordcn获取更多资讯

个人博客:https://felord.cn

本文转载自: 掘金

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

1…812813814…956

开发者博客

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