关于变量的值传递还是引用传递;

一 golang
结论:golang中都是采用值传递,即拷贝传递,也就是深拷贝。没有引用传递。之所有有些看起来像是引用传递的场景,是因为Golang中存在着引用类型,如slice、map、channel、function、pointer这些天生就是指针的类型,在传递的时候是复制的地址。引用类型作为参数时,称为浅拷贝,形参改变,实参数跟随变化。因为传递的是地址,形参和实参都指向同一块地址。(切片是引用类型,数组是值类型)
例子:


package main
import (
 "fmt"
)

func modify(fp *Person) {
 fp.Age = 80
}

func modifyValue(fp Person) {
 fp.Age = 80
}

type AutoC struct {
 URL string `json:"url"`
 Name int `json:"name"`
}

func main() {
 p := Person{
 Age:10,
 Name:"wang",
 }
 modifyValue(p)
 fmt.Println(p) //结果不变
 modify(&p) 
 fmt.Println(p) //结果改变
}

上面结果可以看出,默认的结构体传递是深拷贝,是值传递,而如果强制使用引用传递,也是可以的;对于slice、map、channel、function、pointer默认就是指针类型,这些传的值其实是『引用』,所以在代码中容易出现case;

二 python

python其实最好理解,

python中统一都是引用传递,同时要注意类型是属于对象的,而不是变量。而对象有两种,“可更改”(mutable)与“不可更改”(immutable)对象。在python中,strings, tuples, 和numbers是不可更改的对象,而list,dict等则是可以修改的对象。
“不可更改”的对象
当我们写下面语句时:

a = “hello world”
Python解释器其实顺序干了两件事情:

在内存中创建一个字符串“hello world”;
在内存中创建一个名为“a”的变量,并将“a”指向字符串“hello world”(将“hello world”的地址保存到“a”中)。
这样我们就能通过操作“a”而改变内存中的“hello world”。
a = “123”
b = a
a = “xyz”
执行第一句Python解释器创建字符串“123”和变量“a”,并把“a”指向“123”。
执行第二句,因为“a”已经存在,并不会创建新的对象,但会创建变量“b”,并把“b”指向“a”指向的字符串“123“。 b和a同时指向“123”
执行第三句,首先会创建字符串“xyz”,然后把“xyz”的地址赋予“a“(“a”指向字符串“xyz”)。
“可更改”的对象
a = [1, 2, 3]
b = a
a[0], a[1], a[2] = 4, 5, 6 //改变原来 list 中的元素
>>> a
[4, 5, 6]
>>> b
[4, 5, 6]
从这里可以看出strings类型是不可变量,不可变实际上指的是不会更该字符串,比如把a = ‘123’ 变为 a =’1234′ 实际上是先创建了 “1234” 再用a去指向它。
但是,像list,dict等“可更改”的变量,他们会直接再本地更改,不会进行副本拷贝。
简言之,当在 Python 中 a = sth 应该理解为给 sth 贴上了一个标签 a。当再赋值给 a 的时候,就好象把 a 这个标签从原来的 sth 上拿下来,贴到其他对象上,建立新的”引用”。

既然Python只允许引用传递,那有没有办法可以让两个变量不再指向同一内存地址呢?

浅拷贝(copy)和深拷贝(deepcopy)
import copy
a = [1, 2, 3, 4, [‘a’, ‘b’]]#原始对象

b = a #赋值,传对象的引用
c = copy.copy(a) #对象拷贝,浅拷贝
d = copy.deepcopy(a) #对象拷贝,深拷贝

a.append(5) #修改对象a

a[4].append(‘c’) #修改对象a中的[‘a’, ‘b’]数组对象

print ‘a = ‘, a
print ‘b = ‘, b
print ‘c = ‘, c
print ‘d = ‘, d

输出结果:
a = [1, 2, 3, 4, [‘a’, ‘b’, ‘c’], 5]
b = [1, 2, 3, 4, [‘a’, ‘b’, ‘c’], 5]
c = [1, 2, 3, 4, [‘a’, ‘b’, ‘c’]]
d = [1, 2, 3, 4, [‘a’, ‘b’]]
copy对于一个复杂对象的子对象并不会完全复制,什么是复杂对象的子对象呢?就比如序列里的嵌套序列,字典里的嵌套序列等都是复杂对象的子对象。对于子对象,python会把它当作一个公共镜像存储起来,所有对他的复制都被当成一个引用,所以说当其中一个引用将镜像改变了之后另一个引用使用镜像的时候镜像已经被改变了。

deepcopy的时候会将复杂对象的每一层复制一个单独的个体出来。 当然其中主要的操作还是地址问题。

函数参数传递
a = 1
def fun(a):
a = 2
fun(a)
print(a) # 1
a = []
def fun(a):
a.append(1)
fun(a)
print(a) # [1]
当一个引用传递给函数的时候,函数自动复制一份引用,这个函数里的引用和外边的引用没有半毛关系了.所以第一个例子里函数把引用指向了一个不可变对象,当函数返回的时候,外面的引用没半毛感觉.而第二个例子就不一样了,函数内的引用指向的是可变对象,对它的操作就和定位了指针地址一样,在内存里进行修改.

Python垃圾回收机制中的“引用”
引用计数
PyObject是每个对象必有的内容,其中ob_refcnt就是做为引用计数。当一个对象有新的引用时,它的ob_refcnt就会增加,当引用它的对象被删除,它的ob_refcnt就会减少.引用计数为0时,该对象生命就结束了。

优点:

1.简单
2.实时性
缺点:

1.维护引用计数消耗资源
2.循环引用
三 JAVA
java也是值传递
当传的是基本类型时,传的是值的拷贝,对拷贝变量的修改不影响原变量;当传的是引用类型时,传的是引用地址的拷贝,但是拷贝的地址和真实地址指向的都是同一个真实数据,因此可以修改原变量中的值;当传的是String类型时,虽然拷贝的也是引用地址,指向的是同一个数据,但是String的值不能被修改,因此无法修改原变量中的值。

首先来解释一下什么是引用传递,什么是值传递。

引用传递(pass by reference)是指在调用方法时将实际参数的地址直接传递到方法中,那么在方法中对参数所进行的修改,将影响到实际参数。

值传递(pass by value)是指在调用方法时将实际参数拷贝一份传递到方法中,这样在方法中如果对参数进行修改,将不会影响到实际参数。

所谓的值类型指的是在赋值时,直接在栈中(Java 虚拟机栈)生成值的类型。
二值类型和引用类型
值类型:整数型 浮点型 字符类型 布尔类型
所谓的值类型指的是在赋值时;直接在栈中;Java 虚拟机栈)生成值的类型
引用类型是指除值类型之外的数据类型
类、接口 数组 字符串包装类 所谓的引用类型是指在初始化时将引用生成栈上;而值生成在堆上的这些数据类型。
注意这里跟golang的区别,类,数组均为引用类型;golang中切片是引用,struct本身不是,struct如果包含了map或者切片,也是引用类型;
四 php
php万能的array均为深拷贝,除非特意标明&引用传递,否则均不会改变原值,这里做的比较统一和容易理解;

java spring程序安装及第一个helloworld程序;
一 前期准备
1)最好有一个合适的IDEA,https://www.jetbrains.com/idea/download/other.html
2)下载maven https://maven.apache.org/download.cgi ,maven是java的包管理工具,可以对安装的程序包管理及启动程序
3)安装jdk:jdk-11.0.19_macos-aarch64_bin.dmg
二 创建第一个demo程序
1)mvn archetype:generate -DgroupId=com.example -DartifactId=my-spring-app -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
2)最新考虑配置的是pom.xml,是对依赖包的配置
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>my-spring-app</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>1.8</java.version>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <spring-boot.version>2.5.2</spring-boot.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>${spring-boot.version}</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring-boot.version}</version>
                <configuration>
                    <mainClass>com.example.Application</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
2)/my-spring-app/src/main/java/com/example 增加文件
下创建一个新的Java类(例如HelloWorldApplication.java):
java
package com.example; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class HelloWorldApplication { public static void main(String[] args) { SpringApplication.run(HelloWorldApplication.class, args); } }

这将创建一个简单的Spring Boot应用程序,它将使用Spring Boot的自动配置功能启动Web服务器

创建一个Controller类,用于处理HTTP请求并返回响应。在src/main/java/com/example目录下创建一个新的Java类(例如HelloController.java):

java
package com.example; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class HelloController { @GetMapping("/") public String hello() { return "Hello, World!"; } }

该Controller类定义了一个处理根路径(“/”)的GET请求,并返回”Hello, World!”作为响应。

3)mvn spring-boot:run

即可以运行;运行的时候是tomcat执行spring servlet

从 MongoDB 及 Mysql 谈B/B+树

原文链接:https://blog.csdn.net/wwh578867817/article/details/50493940

前两天有位朋友邀请我回答个问题,为什么 MongoDB (索引)使用B-树而 Mysql 使用 B+树?我觉得这个问题非常好,从实际应用的角度来学习数据结构,没有比这更好的方法了。因为像 Mysql 和 MongoDB 这种经久考验的大型软件在设计上都是精益求精的,它们为什么选择这些数据结构?:)

本文从实际应用的角度来介绍以及分析B-树和B+树。

B-树由来
定义:B-树是一类树,包括B-树、B+树、B*树等,是一棵自平衡的搜索树,它类似普通的平衡二叉树,不同的一点是B-树允许每个节点有更多的子节点。B-树是专门为外部存储器设计的,如磁盘,它对于读取和写入大块数据有良好的性能,所以一般被用在文件系统及数据库中。

定义只需要知道B-树允许每个节点有更多的子节点即可。子节点数量一般在上千,具体数量依赖外部存储器的特性。

先来看看为什么会出现B-树这类数据结构。

传统用来搜索的平衡二叉树有很多,如 AVL 树,红黑树等。这些树在一般情况下查询性能非常好,但当数据非常大的时候它们就无能为力了。原因当数据量非常大时,内存不够用,大部分数据只能存放在磁盘上,只有需要的数据才加载到内存中。一般而言内存访问的时间约为 50 ns,而磁盘在 10 ms 左右。速度相差了近 5 个数量级,磁盘读取时间远远超过了数据在内存中比较的时间。这说明程序大部分时间会阻塞在磁盘 IO 上。那么我们如何提高程序性能?减少磁盘 IO 次数,像 AVL 树,红黑树这类平衡二叉树从设计上无法“迎合”磁盘。
关于磁盘可参考 浅谈计算机中的存储模型(四)磁盘

上图是一颗简单的平衡二叉树,平衡二叉树是通过旋转来保持平衡的,而旋转是对整棵树的操作,若部分加载到内存中则无法完成旋转操作。其次平衡二叉树的高度相对较大为 log n(底数为2),这样逻辑上很近的节点实际可能非常远,无法很好的利用磁盘预读(局部性原理),所以这类平衡二叉树在数据库和文件系统上的选择就被 pass 了。

空间局部性原理:如果一个存储器的某个位置被访问,那么将它附近的位置也会被访问。

我们从“迎合”磁盘的角度来看看B-树的设计。

索引的效率依赖与磁盘 IO 的次数,快速索引需要有效的减少磁盘 IO 次数,如何快速索引呢?索引的原理其实是不断的缩小查找范围,就如我们平时用字典查单词一样,先找首字母缩小范围,再第二个字母等等。平衡二叉树是每次将范围分割为两个区间。为了更快,B-树每次将范围分割为多个区间,区间越多,定位数据越快越精确。那么如果节点为区间范围,每个节点就较大了。所以新建节点时,直接申请页大小的空间(磁盘是按 block 分的,一般为 512 Byte。磁盘 IO 一次读取若干个 block,我们称为一页,具体大小和操作系统有关,一般为 4 k,8 k或 16 k),计算机内存分配是按页对齐的,这样就实现了一个节点只需要一次 IO。

上图是一棵简化的B-树,多叉的好处非常明显,有效的降低了B-树的高度,为底数很大的 log n,底数大小与节点的子节点数目有关,一般一棵B-树的高度在 3 层左右。层数低,每个节点区确定的范围更精确,范围缩小的速度越快。上面说了一个节点需要进行一次 IO,那么总 IO 的次数就缩减为了 log n 次。B-树的每个节点是 n 个有序的序列(a1,a2,a3…an),并将该节点的子节点分割成 n+1 个区间来进行索引(X1< a1, a2 < X2 < a3, … , an+1 < Xn < anXn+1 > an)。

B-树
上图是一颗B-树,B-树的每个节点有 d~2d 个 key,2 这个因子指明了树的分裂及合并的规则,这个规则维持了B-树的平衡。

B-树的插入和删除就不具体介绍了,很多资料都描述了这一过程。在普通平衡二叉树中,插入删除后若不满足平衡条件则进行 旋转 操作,而在B-树中,插入删除后不满足条件则进行分裂及合并操作。

简单叙述下分裂及合并操作。

分裂:如果有一个节点有 2d 个 key,增加一个后为 2d+1 个 key,不符合上述规则 B-树的每个节点有 d~2d 个 key,大于 2d,则将该节点进行分裂,分裂为两个 d 个 key 的节点并将中值 key 归还给父节点。
合并:如果有一个节点有 d 个 key,删除一个后为 d-1 个 key,不符合上述规则 B-树的每个节点有 d~2d 个 key,小于 d,则将该节点进行合并,合并后若满足条件则合并完成,不满足则均分为两个节点。

B-树的查找

我们来看看B-树的查找,假设每个节点有 n 个 key值,被分割为 n+1 个区间,注意,每个 key 值紧跟着 data 域,这说明B-树的 key 和 data 是聚合在一起的。一般而言,根节点都在内存中,B-树以每个节点为一次磁盘 IO,比如上图中,若搜索 key 为 25 节点的 data,首先在根节点进行二分查找(因为 keys 有序,二分最快),判断 key 25 小于 key 50,所以定位到最左侧的节点,此时进行一次磁盘 IO,将该节点从磁盘读入内存,接着继续进行上述过程,直到找到该 key 为止。

查找伪代码

Data* BTreeSearch(Root *node, Key key)
{
Data* data;

if(root == NULL)
return NULL;
data = BinarySearch(node);
if(data->key == key)
{
return data;
}else{
node = ReadDisk(data->next);
BTreeSearch(node, key);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
B+树
B+树是B-树的变种,它与B-树的不同之处在于:

在B+树中,key 的副本存储在内部节点,真正的 key 和 data 存储在叶子节点上 。
n 个 key 值的节点指针域为 n 而不是 n+1。
如下图为一颗B+树:

因为内节点并不存储 data,所以一般B+树的叶节点和内节点大小不同,而B-树的每个节点大小一般是相同的,为一页。

为了增加 区间访问性,一般会对B+树做一些优化。
如下图带顺序访问的B+树。

B-树和B+树的区别
1.B+树内节点不存储数据,所有 data 存储在叶节点导致查询时间复杂度固定为 log n。而B-树查询时间复杂度不固定,与 key 在树中的位置有关,最好为O(1)。

如下所示B-树/B+树查询节点 key 为 50 的 data。

B-树

从上图可以看出,key 为 50 的节点就在第一层,B-树只需要一次磁盘 IO 即可完成查找。所以说B-树的查询最好时间复杂度是 O(1)。

B+树

由于B+树所有的 data 域都在根节点,所以查询 key 为 50的节点必须从根节点索引到叶节点,时间复杂度固定为 O(log n)。

2.B+树叶节点两两相连可大大增加区间访问性,可使用在范围查询等,而B-树每个节点 key 和 data 在一起,则无法区间查找。

根据空间局部性原理:如果一个存储器的某个位置被访问,那么将它附近的位置也会被访问。

B+树可以很好的利用局部性原理,若我们访问节点 key为 50,则 key 为 55、60、62 的节点将来也可能被访问,我们可以利用磁盘预读原理提前将这些数据读入内存,减少了磁盘 IO 的次数。
当然B+树也能够很好的完成范围查询。比如查询 key 值在 50-70 之间的节点。

3.B+树更适合外部存储。由于内节点无 data 域,每个节点能索引的范围更大更精确

这个很好理解,由于B-树节点内部每个 key 都带着 data 域,而B+树节点只存储 key 的副本,真实的 key 和 data 域都在叶子节点存储。前面说过磁盘是分 block 的,一次磁盘 IO 会读取若干个 block,具体和操作系统有关,那么由于磁盘 IO 数据大小是固定的,在一次 IO 中,单个元素越小,量就越大。这就意味着B+树单次磁盘 IO 的信息量大于B-树,从这点来看B+树相对B-树磁盘 IO 次数少。

从上图可以看出相同大小的区域,B-树仅有 2 个 key,而B+树有 3 个 key。

为什么 MongoDB 索引选择B-树,而 Mysql 索引选择B+树
这些内容了解后,我们来看为什么 MongoDB 索引选择B-树,而 Mysql (InooDB 引擎)索引选择B+树。

Mysql 大家应该比较熟悉,传统的关系型数据库,下面介绍下 MongoDB。

来看下 wiki 百科上 MongoDB 的定义:

MongoDB (from humongous) is a cross-platform document-oriented database. Classified as a NoSQL database, MongoDB eschews the traditional table-based relational database structure in favor of JSON-like documents with dynamic schemas (MongoDB calls the format BSON)

这段话的大致意思是 MongoDB 是文档型的数据库,是一种 nosql,它使用类 Json 格式保存数据。

文档型数据库和我们常见的关系型数据库不同,一般使用 XML 或 Json 格式来保存数据,归属于聚合型数据库。

键值数据库也属于聚合型数据库,熟悉 Redis 的同学应该很好理解。

举个例子:

加入我们要建立一个电子商务网站,类似淘宝这种将商品销售给用户,那么必须存储用户信息、商品目录、订单、收货地址、账单地址、付款方式等。

看下传统的关系型数据库是如何存储的:

聚合型数据库存储模型:

用类似 Json 的格式表示如下:

//Customer
{
“id”:1,
“name”:Tom,
“billingAddress”:[{“city”:”China”}]
}

//Orders
{
“id”:99,
“orderItem”:[
“productId”27,
“price”:100,
“productName”:book
],
“shippingAddress”:[{“city”:”china”}],
“orderPayment”:[

]
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
相对于 Mysql 关系型数据库,MongoDB 这类 nosql 适用于数据模型简单,性能要求高的场合

为什么 MongoDB 使用B-树
MongoDB 是一种 nosql,也存储在磁盘上,被设计用在 数据模型简单,性能要求高的场合。性能要求高,看看B/B+树的区别第一点:

B+树内节点不存储数据,所有 data 存储在叶节点导致查询时间复杂度固定为 log n。而B-树查询时间复杂度不固定,与 key 在树中的位置有关,最好为O(1)

我们说过,尽可能少的磁盘 IO 是提高性能的有效手段。MongoDB 是聚合型数据库,而 B-树恰好 key 和 data 域聚合在一起。

为什么 Mysql 使用B+树
Mysql 是一种关系型数据库,区间访问是常见的一种情况,而 B-树并不支持区间访问(可参见上图),而B+树由于数据全部存储在叶子节点,并且通过指针串在一起,这样就很容易的进行区间遍历甚至全部遍历。见B/B+树的区别第二点:

B+树叶节点两两相连可大大增加区间访问性,可使用在范围查询等,而B-树每个节点 key 和 data 在一起,则无法区间查找。

其次B+树的查询效率更加稳定,数据全部存储在叶子节点,查询时间复杂度固定为 O(log n)。

最后第三点:

B+树更适合外部存储。由于内节点无 data 域,每个节点能索引的范围更大更精确
————————————————
版权声明:本文为CSDN博主「夏天的技术博客」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/wwh578867817/article/details/50493940

[转]Java并发,夺命 60 问,抗住

原文链接:https://mp.weixin.qq.com/s/QEwviUcxwIhWCHrdWGbV3Q

这节我们来盘一盘另一个面试必问知识点——Java并发。

这篇文章有点长,四万字,图文详解六十道Java并发面试题。人已经肝麻了,大家可以点赞收藏慢慢看!扶我起来,我还能肝!

Java充电社

Java充电社,专注分享Java技术干货,包括多线程、JVM、SpringBoot、SpringCloud、Dubbo、Zookeeper、Redis、架构设计、微服务、消息队列、Git、面试题、程序员攻略、最新动态等。
28篇原创内容

基础

1.并行跟并发有什么区别?

从操作系统的角度来看,线程是CPU分配的最小单位。

  • 并行就是同一时刻,两个线程都在执行。这就要求有两个CPU去分别执行两个线程。
  • 并发就是同一时刻,只有一个执行,但是一个时间段内,两个线程都执行了。并发的实现依赖于CPU切换线程,因为切换的时间特别短,所以基本对于用户是无感知的。
图片
并行和并发

就好像我们去食堂打饭,并行就是我们在多个窗口排队,几个阿姨同时打菜;并发就是我们挤在一个窗口,阿姨给这个打一勺,又手忙脚乱地给那个打一勺。

图片
并行并发和食堂打饭

2.说说什么是进程和线程?

要说线程,必须得先说说进程。

  • 进程:进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位。
  • 线程:线程是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的资源。

操作系统在分配资源时是把资源分配给进程的, 但是 CPU 资源比较特殊,它是被分配到线程的,因为真正要占用CPU运行的是线程,所以也说线程是 CPU分配的基本单位。

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

图片
程序进程线程关系

一个进程中有多个线程,多个线程共用进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈。

3.说说线程有几种创建方式?

Java中创建线程主要有三种方式,分别为继承Thread类、实现Runnable接口、实现Callable接口。

图片
线程创建三种方式
  • 继承Thread类,重写run()方法,调用start()方法启动线程
public class ThreadTest {

    /**
     * 继承Thread类
     */
    public static class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println("This is child thread");
        }
    }

    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start();
    }
}

  • 实现 Runnable 接口,重写run()方法
public class RunnableTask implements Runnable {
    public void run() {
        System.out.println("Runnable!");
    }

    public static void main(String[] args) {
        RunnableTask task = new RunnableTask();
        new Thread(task).start();
    }
}

上面两种都是没有返回值的,但是如果我们需要获取线程的执行结果,该怎么办呢?

  • 实现Callable接口,重写call()方法,这种方式可以通过FutureTask获取任务执行的返回值
public class CallerTask implements Callable<String> {
    public String call() throws Exception {
        return "Hello,i am running!";
    }

    public static void main(String[] args) {
        //创建异步任务
        FutureTask<String> task=new FutureTask<String>(new CallerTask());
        //启动线程
        new Thread(task).start();
        try {
            //等待执行完成,并获取返回结果
            String result=task.get();
            System.out.println(result);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

4.为什么调用start()方法时会执行run()方法,那怎么不直接调用run()方法?

JVM执行start方法,会先创建一条线程,由创建出来的新线程去执行thread的run方法,这才起到多线程的效果。

图片
start方法

**为什么我们不能直接调用run()方法?**也很清楚, 如果直接调用Thread的run()方法,那么run方法还是运行在主线程中,相当于顺序执行,就起不到多线程的效果。

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

图片
线程常用调度方法

线程等待与通知

在Object类中有一些函数可以用于线程的等待与通知。

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

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

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

  • wait(long timeout) :这个方法相比 wait() 方法多了一个超时参数,它的不同之处在于,如果线程A调用共享对象的wait(long timeout)方法后,没有在指定的 timeout ms时间内被其它线程唤醒,那么这个方法还是会因为超时而返回。

  • wait(long timeout, int nanos),其内部调用的是 wait(long timout)函数。

上面是线程等待的方法,而唤醒线程主要是下面两个方法:

  • notify() : 一个线程A调用共享对象的 notify() 方法后,会唤醒一个在这个共享变量上调用 wait 系列方法后被挂起的线程。一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的。
  • notifyAll() :不同于在共享变量上调用 notify() 函数会唤醒被阻塞到该共享变量上的一个线程,notifyAll()方法则会唤醒所有在该共享变量上由于调用 wait 系列方法而被挂起的线程。

Thread类也提供了一个方法用于等待的方法:

  • join():如果一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才

    从thread.join()返回。

线程休眠

  • sleep(long millis)  :Thread类中的静态方法,当一个执行中的线程A调用了Thread 的sleep方法后,线程A会暂时让出指定时间的执行权,但是线程A所拥有的监视器资源,比如锁还是持有不让出的。指定的睡眠时间到了后该函数会正常返回,接着参与 CPU 的调度,获取到 CPU 资源后就可以继续运行。

让出优先权

  • yield() :Thread类中的静态方法,当一个线程调用 yield 方法时,实际就是在暗示线程调度器当前线程请求让出自己的CPU ,但是线程调度器可以无条件忽略这个暗示。

线程中断

Java 中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程根据中断状态自行处理。

  • void interrupt() :中断线程,例如,当线程A运行时,线程B可以调用钱程interrupt() 方法来设置线程的中断标志为true 并立即返回。设置标志仅仅是设置标志, 线程A实际并没有被中断, 会继续往下执行。
  • boolean isInterrupted() 方法:检测当前线程是否被中断。
  • boolean interrupted() 方法:检测当前线程是否被中断,与 isInterrupted 不同的是,该方法如果发现当前线程被中断,则会清除中断标志。

6.线程有几种状态?

在Java中,线程共有六种状态:

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

线程在自身的生命周期中, 并不是固定地处于某个状态,而是随着代码的执行在不同的状态之间进行切换,Java线程状态变化如图示:

图片
Java线程状态变化

7.什么是线程上下文切换?

使用多线程的目的是为了充分利用CPU,但是我们知道,并发其实是一个CPU来应付多个线程。

图片
线程切换-2020-12-16-2107

为了让用户感觉多个线程是在同时执行的, CPU 资源的分配采用了时间片轮转也就是给每个线程分配一个时间片,线程在时间片内占用 CPU 执行任务。当线程使用完时间片后,就会处于就绪状态并让出 CPU 让其他线程占用,这就是上下文切换。

图片
上下文切换时机

8.守护线程了解吗?

Java中的线程分为两类,分别为 daemon 线程(守护线程)和 user 线程(用户线程)。

在JVM 启动时会调用 main 函数,main函数所在的钱程就是一个用户线程。其实在 JVM 内部同时还启动了很多守护线程, 比如垃圾回收线程。

那么守护线程和用户线程有什么区别呢?区别之一是当最后一个非守护线程束时, JVM会正常退出,而不管当前是否存在守护线程,也就是说守护线程是否结束并不影响 JVM退出。换而言之,只要有一个用户线程还没结束,正常情况下JVM就不会退出。

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

图片
线程间通信方式
  • volatile和synchronized关键字

关键字volatile可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。

关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性。

  • 等待/通知机制

可以通过Java内置的等待/通知机制(wait()/notify())实现一个线程修改一个对象的值,而另一个线程感知到了变化,然后进行相应的操作。

  • 管道输入/输出流

管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。

管道输入/输出流主要包括了如下4种具体实现:PipedOutputStream、PipedInputStream、 PipedReader和PipedWriter,前两种面向字节,而后两种面向字符。

  • 使用Thread.join()

如果一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才从thread.join()返回。。线程Thread除了提供join()方法之外,还提供了join(long millis)和join(long millis,int nanos)两个具备超时特性的方法。

  • 使用ThreadLocal

ThreadLocal,即线程变量,是一个以ThreadLocal对象为键、任意对象为值的存储结构。这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。

可以通过set(T)方法来设置一个值,在当前线程下再通过get()方法获取到原先设置的值。

关于多线程,其实很大概率还会出一些笔试题,比如交替打印、银行转账、生产消费模型等等,后面老三会单独出一期来盘点一下常见的多线程笔试题。

ThreadLocal

ThreadLocal其实应用场景不是很多,但却是被炸了千百遍的面试老油条,涉及到多线程、数据结构、JVM,可问的点比较多,一定要拿下。

10.ThreadLocal是什么?

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

图片
ThreadLocal线程副本
  • 创建

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

//创建一个ThreadLocal变量
public static ThreadLocal<String> localVariable = new ThreadLocal<>();
  • 写入

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

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

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

localVariable.get();

11.你在工作中用到过ThreadLocal吗?

有用到过的,用来做用户信息上下文的存储。

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

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

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

图片
ThreadLoca存放用户上下文

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

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

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

12.ThreadLocal怎么实现的呢?

我们看一下ThreadLocal的set(T)方法,发现先获取到当前线程,再获取ThreadLocalMap,然后把元素存到这个map中。

    public void set(T value) {
        //获取当前线程
        Thread t = Thread.currentThread();
        //获取ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        //讲当前元素存入map
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

ThreadLocal实现的秘密都在这个ThreadLocalMap了,可以Thread类中定义了一个类型为ThreadLocal.ThreadLocalMap的成员变量threadLocals

public class Thread implements Runnable {
   //ThreadLocal.ThreadLocalMap是Thread的属性
   ThreadLocal.ThreadLocalMap threadLocals = null;
}

ThreadLocalMap既然被称为Map,那么毫无疑问它是<key,value>型的数据结构。我们都知道map的本质是一个个<key,value>形式的节点组成的数组,那ThreadLocalMap的节点是什么样的呢?

        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            //节点类
            Entry(ThreadLocal<?> k, Object v) {
                //key赋值
                super(k);
                //value赋值
                value = v;
            }
        }

这里的节点,key可以简单低视作ThreadLocal,value为代码中放入的值,当然实际上key并不是ThreadLocal本身,而是它的一个弱引用,可以看到Entry的key继承了 WeakReference(弱引用),再来看一下key怎么赋值的:

    public WeakReference(T referent) {
        super(referent);
    }

key的赋值,使用的是WeakReference的赋值。

图片
ThreadLoca结构图

所以,怎么回答ThreadLocal原理?要答出这几个点:

  • Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,每个线程都有一个属于自己的ThreadLocalMap。
  • ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal的弱引用,value是ThreadLocal的泛型值。
  • 每个线程在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。
  • ThreadLocal本身不存储值,它只是作为一个key来让线程往ThreadLocalMap里存取值。

13.ThreadLocal 内存泄露是怎么回事?

我们先来分析一下使用ThreadLocal时的内存,我们都知道,在JVM中,栈内存线程私有,存储了对象的引用,堆内存线程共享,存储了对象实例。

所以呢,栈中存储了ThreadLocal、Thread的引用,堆中存储了它们的具体实例。

图片
ThreadLocal内存分配

ThreadLocalMap中使用的 key 为 ThreadLocal 的弱引用。

“弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否充足,都会回收该对象占用的内存。”

那么现在问题就来了,弱引用很容易被回收,如果ThreadLocal(ThreadLocalMap的Key)被垃圾回收器回收了,但是ThreadLocalMap生命周期和Thread是一样的,它这时候如果不被回收,就会出现这种情况:ThreadLocalMap的key没了,value还在,这就会造成了内存泄漏问题

那怎么解决内存泄漏问题呢?

很简单,使用完ThreadLocal后,及时调用remove()方法释放内存空间。

ThreadLocal<String> localVariable = new ThreadLocal();
try {
    localVariable.set("鄙人三某”);
    ……
} finally {
    localVariable.remove();
}

那为什么key还要设计成弱引用?

key设计成弱引用同样是为了防止内存泄漏。

假如key被设计成强引用,如果ThreadLocal Reference被销毁,此时它指向ThreadLoca的强引用就没有了,但是此时key还强引用指向ThreadLoca,就会导致ThreadLocal不能被回收,这时候就发生了内存泄漏的问题。

14.ThreadLocalMap的结构了解吗?

ThreadLocalMap虽然被叫做Map,其实它是没有实现Map接口的,但是结构还是和HashMap比较类似的,主要关注的是两个要素:元素数组散列方法

图片
ThreadLocalMap结构示意图
  • 元素数组

    一个table数组,存储Entry类型的元素,Entry是ThreaLocal弱引用作为key,Object作为value的结构。

 private Entry[] table;
  • 散列方法

    散列方法就是怎么把对应的key映射到table数组的相应下标,ThreadLocalMap用的是哈希取余法,取出key的threadLocalHashCode,然后和table数组长度减一&运算(相当于取余)。

int i = key.threadLocalHashCode & (table.length - 1);

这里的threadLocalHashCode计算有点东西,每创建一个ThreadLocal对象,它就会新增0x61c88647,这个值很特殊,它是斐波那契数  也叫 黄金分割数hash增量为 这个数字,带来的好处就是 hash 分布非常均匀

    private static final int HASH_INCREMENT = 0x61c88647;
    
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

15.ThreadLocalMap怎么解决Hash冲突的?

我们可能都知道HashMap使用了链表来解决冲突,也就是所谓的链地址法。

ThreadLocalMap没有使用链表,自然也不是用链地址法来解决冲突了,它用的是另外一种方式——开放定址法。开放定址法是什么意思呢?简单来说,就是这个坑被人占了,那就接着去找空着的坑。

图片
ThreadLocalMap解决冲突

如上图所示,如果我们插入一个value=27的数据,通过 hash计算后应该落入第 4 个槽位中,而槽位 4 已经有了 Entry数据,而且Entry数据的key和当前不相等。此时就会线性向后查找,一直找到 Entry为 null的槽位才会停止查找,把元素放到空的槽中。

在get的时候,也会根据ThreadLocal对象的hash值,定位到table中的位置,然后判断该槽位Entry对象中的key是否和get的key一致,如果不一致,就判断下一个位置。

16.ThreadLocalMap扩容机制了解吗?

在ThreadLocalMap.set()方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中Entry的数量已经达到了列表的扩容阈值(len*2/3),就开始执行rehash()逻辑:

if (!cleanSomeSlots(i, sz) && sz >= threshold)
    rehash();

再着看rehash()具体实现:这里会先去清理过期的Entry,然后还要根据条件判断size >= threshold - threshold / 4 也就是size >= threshold* 3/4来决定是否需要扩容。

private void rehash() {
    //清理过期Entry
    expungeStaleEntries();

    //扩容
    if (size >= threshold - threshold / 4)
        resize();
}

//清理过期Entry
private void expungeStaleEntries() {
    Entry[] tab = table;
    int len = tab.length;
    for (int j = 0; j < len; j++) {
        Entry e = tab[j];
        if (e != null && e.get() == null)
            expungeStaleEntry(j);
    }
}

接着看看具体的resize()方法,扩容后的newTab的大小为老数组的两倍,然后遍历老的table数组,散列方法重新计算位置,开放地址解决冲突,然后放到新的newTab,遍历完成之后,oldTab中所有的entry数据都已经放入到newTab中了,然后table引用指向newTab

图片
ThreadLocalMap扩容

具体代码:

图片

ThreadLocalMap resize

17.父子线程怎么共享数据?

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

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

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

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

那原理是什么呢?

原理很简单,在Thread类里还有另外一个变量:

ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

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

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

Java内存模型

18.说一下你对Java内存模型(JMM)的理解?

Java内存模型(Java Memory Model,JMM),是一种抽象的模型,被定义出来屏蔽各种硬件和操作系统的内存访问差异。

JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。

Java内存模型的抽象图:

图片
Java内存模型

本地内存是JMM的 一个抽象概念,并不真实存在。它其实涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

图片
实际线程工作模型

图里面的是一个双核 CPU 系统架构 ,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辅运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有 CPU 共享的二级缓存。那么 Java 内存模型里面的工作内存,就对应这里的 Ll 缓存或者 L2 缓存或者 CPU 寄存器。

19.说说你对原子性、可见性、有序性的理解?

原子性、有序性、可见性是并发编程中非常重要的基础概念,JMM的很多技术都是围绕着这三大特性展开。

  • 原子性:原子性指的是一个操作是不可分割、不可中断的,要么全部执行并且执行的过程不会被任何因素打断,要么就全不执行。
  • 可见性:可见性指的是一个线程修改了某一个共享变量的值时,其它线程能够立即知道这个修改。
  • 有序性:有序性指的是对于一个线程的执行代码,从前往后依次执行,单线程下可以认为程序是有序的,但是并发时有可能会发生指令重排。

分析下面几行代码的原子性?

int i = 2;
int j = i;
i++;
i = i + 1;
  • 第1句是基本类型赋值,是原子性操作。
  • 第2句先读i的值,再赋值到j,两步操作,不能保证原子性。
  • 第3和第4句其实是等效的,先读取i的值,再+1,最后赋值到i,三步操作了,不能保证原子性。

原子性、可见性、有序性都应该怎么保证呢?

  • 原子性:JMM只能保证基本的原子性,如果要保证一个代码块的原子性,需要使用synchronized
  • 可见性:Java是利用volatile关键字来保证可见性的,除此之外,finalsynchronized也能保证可见性。
  • 有序性:synchronized或者volatile都可以保证多线程之间操作的有序性。

20.那说说什么是指令重排?

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如图:

图片
多级指令重排

我们比较熟悉的双重校验单例模式就是一个经典的指令重排的例子,Singleton instance=new Singleton();对应的JVM指令分为三步:分配内存空间–>初始化对象—>对象指向分配的内存空间,但是经过了编译器的指令重排序,第二步和第三步就可能会重排序。

图片
双重校验单例模式异常情形

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

21.指令重排有限制吗?happens-before了解吗?

指令重排也是有一些限制的,有两个规则happens-beforeas-if-serial来约束。

happens-before的定义:

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照 happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法

happens-before和我们息息相关的有六大规则:

图片
happens-before六大规则
  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的 ThreadB.start()操作happens-before于线程B中的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作 happens-before于线程A从ThreadB.join()操作成功返回。

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

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例。

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

上面3个操作的数据依赖关系:

图片
image-20210812200646364

A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。

所以最终,程序可能会有两种执行顺序:

图片
两种执行结果

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同编织了这么一个“楚门的世界”:单线程程序是按程序的“顺序”来执行的。as- if-serial语义使单线程情况下,我们不需要担心重排序的问题,可见性的问题。

23.volatile实现原理了解吗?

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

volatile怎么保证可见性的呢?

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

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

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

图片
volatile内存可见性

volatile怎么保证有序性的呢?

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

图片
volatile重排序规则表

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

  1. 在每个volatile写操作的前面插入一个StoreStore屏障
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障
  4. 在每个volatile读操作的后面插入一个LoadStore屏障
图片
volatile写插入内存屏障后生成的指令序列示意图
图片
volatile写插入内存屏障后生成的指令序列示意图

24.synchronized用过吗?怎么使用?

synchronized经常用的,用来保证代码的原子性。

synchronized主要有三种用法:

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

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

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

25.synchronized的实现原理?

synchronized是怎么加锁的呢?

我们使用synchronized的时候,发现不用自己去lock和unlock,是因为JVM帮我们把这个事情做了。

  1. synchronized修饰代码块时,JVM采用monitorentermonitorexit两个指令来实现同步,monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指向同步代码块的结束位置。

    反编译一段synchronized修饰代码块代码,javap -c -s -v -l SynchronizedDemo.class,可以看到相应的字节码指令。

图片
monitorenter和monitorexit
  1. synchronized修饰同步方法时,JVM采用ACC_SYNCHRONIZED标记符来实现同步,这个标识指明了该方法是一个同步方法。

    同样可以写段代码反编译看一下。

图片
synchronized修饰同步方法

synchronized锁住的是什么呢?

monitorenter、monitorexit或者ACC_SYNCHRONIZED都是基于Monitor实现的。

实例对象结构里有对象头,对象头里面有一块结构叫Mark Word,Mark Word指针指向了monitor

所谓的Monitor其实是一种同步工具,也可以说是一种同步机制。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,可以叫做内部锁,或者Monitor锁。

ObjectMonitor的工作原理:

  • ObjectMonitor有两个队列:_WaitSet、_EntryList,用来保存ObjectWaiter 对象列表。
  • _owner,获取 Monitor 对象的线程进入 _owner 区时, _count + 1。如果线程调用了 wait() 方法,此时会释放 Monitor 对象, _owner 恢复为空, _count – 1。同时该等待线程进入 _WaitSet 中,等待被唤醒。
ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录线程获取锁的次数
    _waiters      = 0,
    _recursions   = 0;  //锁的重入次数
    _object       = NULL;
    _owner        = NULL;  // 指向持有ObjectMonitor对象的线程
    _WaitSet      = NULL;  // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

可以类比一个去医院就诊的例子[18]:

  • 首先,患者在门诊大厅前台或自助挂号机进行挂号

  • 随后,挂号结束后患者找到对应的诊室就诊

    • 诊室每次只能有一个患者就诊;
    • 如果此时诊室空闲,直接进入就诊;
    • 如果此时诊室内有其它患者就诊,那么当前患者进入候诊室,等待叫号;
  • 就诊结束后,走出就诊室,候诊室的下一位候诊患者进入就诊室。

图片
就诊-图片来源参考[18]

这个过程就和Monitor机制比较相似:

  • 门诊大厅:所有待进入的线程都必须先在入口Entry Set挂号才有资格;
  • 就诊室:就诊室**_Owner**里里只能有一个线程就诊,就诊完线程就自行离开
  • 候诊室:就诊室繁忙时,进入等待区(Wait Set),就诊室空闲的时候就从**等待区(Wait Set)**叫新的线程
图片
Java Montior机制

所以我们就知道了,同步是锁住的什么东西:

  • monitorenter,在判断拥有同步标识 ACC_SYNCHRONIZED 抢先进入此方法的线程会优先拥有 Monitor 的 owner ,此时计数器 +1。
  • monitorexit,当执行完退出后,计数器 -1,归 0 后被其他进入的线程获得。

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

synchronized怎么保证可见性?

  • 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
  • 线程加锁后,其它线程无法获取主内存中的共享变量。
  • 线程解锁前,必须把共享变量的最新值刷新到主内存中。

synchronized怎么保证有序性?

synchronized同步的代码块,具有排他性,一次只能被一个线程拥有,所以synchronized保证同一时刻,代码是单线程执行的。

因为as-if-serial语义的存在,单线程的程序能保证最终结果是有序的,但是不保证不会指令重排。

所以synchronized保证的有序是执行结果的有序性,而不是防止指令重排的有序性。

synchronized怎么实现可重入的呢?

synchronized 是可重入锁,也就是说,允许一个线程二次请求自己持有对象锁的临界资源,这种情况称为可重入锁。

synchronized 锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。

之所以,是可重入的。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1,直到清零释放锁。

27.锁升级?synchronized优化了解吗?

了解锁升级,得先知道,不同锁的状态是什么样的。这个状态指的是什么呢?

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

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

图片
Mark Word变化

Mark Word存储对象自身的运行数据,如哈希码、GC分代年龄、锁状态标志、偏向时间戳(Epoch) 等。

synchronized做了哪些优化?

在JDK1.6之前,synchronized的实现直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁。从JDK6开始,HotSpot虚拟机开发团队对Java中的锁进行优化,如增加了适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等优化策略,提升了synchronized的性能。

  • 偏向锁:在无竞争的情况下,只是在Mark Word里存储当前线程指针,CAS操作都不做。

  • 轻量级锁:在没有多线程竞争时,相对重量级锁,减少操作系统互斥量带来的性能消耗。但是,如果存在锁竞争,除了互斥量本身开销,还额外有CAS操作的开销。

  • 自旋锁:减少不必要的CPU上下文切换。在轻量级锁升级为重量级锁时,就使用了自旋加锁的方式

  • 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

  • 锁消除:虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

锁升级的过程是什么样的?

锁升级方向:无锁–>偏向锁—> 轻量级锁—->重量级锁,这个方向基本上是不可逆的。

图片
锁升级方向

我们看一下升级的过程:

偏向锁:

偏向锁的获取:

  1. 判断是否为可偏向状态–MarkWord中锁标志是否为‘01’,是否偏向锁是否为‘1’
  2. 如果是可偏向状态,则查看线程ID是否为当前线程,如果是,则进入步骤’5’,否则进入步骤‘3’
  3. 通过CAS操作竞争锁,如果竞争成功,则将MarkWord中线程ID设置为当前线程ID,然后执行‘5’;竞争失败,则执行‘4’
  4. CAS获取偏向锁失败表示有竞争。当达到safepoint时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块
  5. 执行同步代码

偏向锁的撤销:

  1. 偏向锁不会主动释放(撤销),只有遇到其他线程竞争时才会执行撤销,由于撤销需要知道当前持有该偏向锁的线程栈状态,因此要等到safepoint时执行,此时持有该偏向锁的线程(T)有‘2’,‘3’两种情况;
  2. 撤销—-T线程已经退出同步代码块,或者已经不再存活,则直接撤销偏向锁,变成无锁状态—-该状态达到阈值20则执行批量重偏向
  3. 升级—-T线程还在同步代码块中,则将T线程的偏向锁升级为轻量级锁,当前线程执行轻量级锁状态下的锁获取步骤—-该状态达到阈值40则执行批量撤销

轻量级锁:

轻量级锁的获取:

  1. 进行加锁操作时,jvm会判断是否已经时重量级锁,如果不是,则会在当前线程栈帧中划出一块空间,作为该锁的锁记录,并且将锁对象MarkWord复制到该锁记录中
  2. 复制成功之后,jvm使用CAS操作将对象头MarkWord更新为指向锁记录的指针,并将锁记录里的owner指针指向对象头的MarkWord。如果成功,则执行‘3’,否则执行‘4’
  3. 更新成功,则当前线程持有该对象锁,并且对象MarkWord锁标志设置为‘00’,即表示此对象处于轻量级锁状态
  4. 更新失败,jvm先检查对象MarkWord是否指向当前线程栈帧中的锁记录,如果是则执行‘5’,否则执行‘4’
  5. 表示锁重入;然后当前线程栈帧中增加一个锁记录第一部分(Displaced Mark Word)为null,并指向Mark Word的锁对象,起到一个重入计数器的作用。
  6. 表示该锁对象已经被其他线程抢占,则进行自旋等待(默认10次),等待次数达到阈值仍未获取到锁,则升级为重量级锁

大体上省简的升级过程:

图片
锁升级简略过程

完整的升级过程:

图片
synchronized 锁升级过程-来源参考[14]

28.说说synchronized和ReentrantLock的区别?

可以从锁的实现、功能特点、性能等几个维度去回答这个问题:

  • 锁的实现: synchronized是Java语言的关键字,基于JVM实现。而ReentrantLock是基于JDK的API层面实现的(一般是lock()和unlock()方法配合try/finally 语句块来完成。)
  • 性能: 在JDK1.6锁优化以前,synchronized的性能比ReenTrantLock差很多。但是JDK6开始,增加了适应性自旋、锁消除等,两者性能就差不多了。
  • 功能特点: ReentrantLock 比 synchronized 增加了一些高级功能,如等待可中断、可实现公平锁、可实现选择性通知。
    • ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制
    • ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
    • synchronized与wait()和notify()/notifyAll()方法结合实现等待/通知机制,ReentrantLock类借助Condition接口与newCondition()方法实现。
    • ReentrantLock需要手工声明来加锁和释放锁,一般跟finally配合释放锁。而synchronized不用手动释放锁。

下面的表格列出出了两种锁之间的区别:

图片
synchronized和ReentrantLock的区别

29.AQS了解多少?

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

  • AQS是基于一个FIFO的双向队列,其内部定义了一个节点类Node,Node 节点内部的 SHARED 用来标记该线程是获取共享资源时被阻挂起后放入AQS 队列的, EXCLUSIVE 用来标记线程是 取独占资源时被挂起后放入AQS 队列
  • AQS 使用一个 volatile 修饰的 int 类型的成员变量 state 来表示同步状态,修改同步状态成功即为获得锁,volatile 保证了变量在多线程之间的可见性,修改 State 值时通过 CAS 机制来保证修改的原子性
  • 获取state的方式分为两种,独占方式和共享方式,一个线程使用独占方式获取了资源,其它线程就会在获取失败后被阻塞。一个线程使用共享方式获取了资源,另外一个线程还可以通过CAS的方式进行获取。
  • 如果共享资源被占用,需要一定的阻塞等待唤醒机制来保证锁的分配,AQS 中会将竞争共享资源失败的线程添加到一个变体的 CLH 队列中。

图片先简单了解一下CLH:Craig、Landin and Hagersten 队列,是 单向链表实现的队列。申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现 前驱节点释放了锁就结束自旋

图片
CLH队列

AQS 中的队列是 CLH 变体的虚拟双向队列,通过将每条请求共享资源的线程封装成一个节点来实现锁的分配:

图片
AQS变种CLH队列

AQS 中的 CLH 变体等待队列拥有以下特性:

  • AQS 中队列是个双向链表,也是 FIFO 先进先出的特性
  • 通过 Head、Tail 头尾两个节点来组成队列结构,通过 volatile 修饰保证可见性
  • Head 指向节点为已获得锁的节点,是一个虚拟节点,节点本身不持有具体线程
  • 获取不到同步状态,会将节点进行自旋获取锁,自旋一定次数失败后会将线程阻塞,相对于 CLH 队列性能较好

ps:AQS源码里面有很多细节可问,建议有时间好好看看AQS源码。

30.ReentrantLock实现原理?

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

看看ReentrantLock的加锁操作:

    // 创建非公平锁
    ReentrantLock lock = new ReentrantLock();
    // 获取锁操作
    lock.lock();
    try {
        // 执行代码逻辑
    } catch (Exception ex) {
        // ...
    } finally {
        // 解锁操作
        lock.unlock();
    }

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

公平锁 FairSync

  1. 公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁
  2. 公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU 唤醒阻塞线程的开销比非公平锁大

非公平锁 NonfairSync

  • 非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁
  • 非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU 不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁

默认创建的对象lock()的时候:

  • 如果锁当前没有被其它线程占用,并且当前线程之前没有获取过该锁,则当前线程会获取到该锁,然后设置当前锁的拥有者为当前线程,并设置 AQS 的状态值为1 ,然后直接返回。如果当前线程之前己经获取过该锁,则这次只是简单地把 AQS 的状态值加1后返回。
  • 如果该锁己经被其他线程持有,非公平锁会尝试去获取锁,获取失败的话,则调用该方法线程会被放入 AQS 队列阻塞挂起。
图片
ReentrantLock 非公平锁加锁流程简图

31.ReentrantLock怎么实现公平锁的?

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

public ReentrantLock() {
    sync = new NonfairSync();
}

同时也可以在创建锁构造函数中传入具体参数创建公平锁 FairSync

ReentrantLock lock = new ReentrantLock(true);
--- ReentrantLock
// true 代表公平锁,false 代表非公平锁
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

FairSync、NonfairSync 代表公平锁和非公平锁,两者都是 ReentrantLock 静态内部类,只不过实现不同锁语义。

非公平锁和公平锁的两处不同:

  1. 非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,那么直接就获取到锁返回了。
  2. 非公平锁在 CAS 失败后,和公平锁一样都会进入到 tryAcquire 方法,在 tryAcquire 方法中,如果发现锁这个时候被释放了(state == 0),非公平锁会直接 CAS 抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面。
图片
公平锁tryAcquire

相对来说,非公平锁会有更好的性能,因为它的吞吐量比较大。当然,非公平锁让获取锁的时间变得更加不确定,可能会导致在阻塞队列中的线程长期处于饥饿状态。

32.CAS呢?CAS了解多少?

CAS叫做CompareAndSwap,⽐较并交换,主要是通过处理器的指令来保证操作的原⼦性的。

CAS 指令包含 3 个参数:共享变量的内存地址 A、预期的值 B 和共享变量的新值 C。

只有当内存中地址 A 处的值等于 B 时,才能将内存中地址 A 处的值更新为新值 C。作为一条 CPU 指令,CAS 指令本身是能够保证原子性的 。

33.CAS 有什么问题?如何解决?

CAS的经典三大问题:

图片
CAS三大问题

ABA 问题

并发环境下,假设初始条件是A,去修改数据时,发现是A就会执行修改。但是看到的虽然是A,中间可能发生了A变B,B又变回A的情况。此时A已经非彼A,数据即使成功修改,也可能有问题。

怎么解决ABA问题?

  • 加版本号

每次修改变量,都在这个变量的版本号上加1,这样,刚刚A->B->A,虽然A的值没变,但是它的版本号已经变了,再判断版本号就会发现此时的A已经被改过了。参考乐观锁的版本号,这种做法可以给数据带上了一种实效性的检验。

Java提供了AtomicStampReference类,它的compareAndSet方法首先检查当前的对象引用值是否等于预期引用,并且当前印戳(Stamp)标志是否等于预期标志,如果全部相等,则以原子方式将引用值和印戳标志的值更新为给定的更新值。

循环性能开销

自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销。

怎么解决循环性能开销问题?

在Java中,很多使用自旋CAS的地方,会有一个自旋次数的限制,超过一定次数,就停止自旋。

只能保证一个变量的原子操作

CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的。

怎么解决只能保证一个变量的原子操作问题?

  • 可以考虑改用锁来保证操作的原子性
  • 可以考虑合并多个变量,将多个变量封装成一个对象,通过AtomicReference来保证原子性。

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

图片
Java保证原子性方法
  • 使用循环原子类,例如AtomicInteger,实现i++原子操作
  • 使用juc包下的锁,如ReentrantLock ,对i++操作加锁lock.lock()来实现原子性
  • 使用synchronized,对i++操作加锁

35.原子操作类了解多少?

当程序更新一个变量时,如果多线程同时更新这个变量,可能得到期望之外的值,比如变量i=1,A线程更新i+1,B线程也更新i+1,经过两个线程操作之后可能i不等于3,而是等于2。因为A和B线程在更新变量i的时候拿到的i都是1,这就是线程不安全的更新操作,一般我们会使用synchronized来解决这个问题,synchronized会保证多线程不会同时更新变量i。

其实除此之外,还有更轻量级的选择,Java从JDK 1.5开始提供了java.util.concurrent.atomic包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。

因为变量的类型有很多种,所以在Atomic包里一共提供了13个类,属于4种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。

图片
原子操作类

Atomic包里的类基本都是使用Unsafe实现的包装类。

使用原子的方式更新基本类型,Atomic包提供了以下3个类:

  • AtomicBoolean:原子更新布尔类型。

  • AtomicInteger:原子更新整型。

  • AtomicLong:原子更新长整型。

通过原子的方式更新数组里的某个元素,Atomic包提供了以下4个类:

  • AtomicIntegerArray:原子更新整型数组里的元素。

  • AtomicLongArray:原子更新长整型数组里的元素。

  • AtomicReferenceArray:原子更新引用类型数组里的元素。

  • AtomicIntegerArray类主要是提供原子的方式更新数组里的整型

原子更新基本类型的AtomicInteger,只能更新一个变量,如果要原子更新多个变量,就需要使用这个原子更新引用类型提供的类。Atomic包提供了以下3个类:

  • AtomicReference:原子更新引用类型。

  • AtomicReferenceFieldUpdater:原子更新引用类型里的字段。

  • AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和引用类型。构造方法是AtomicMarkableReference(V initialRef,boolean initialMark)。

如果需原子地更新某个类里的某个字段时,就需要使用原子更新字段类,Atomic包提供了以下3个类进行原子字段更新:

  • AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
  • AtomicLongFieldUpdater:原子更新长整型字段的更新器。
  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的 ABA问题。

36.AtomicInteger 的原理?

一句话概括:使用CAS实现

以AtomicInteger的添加方法为例:

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

通过Unsafe类的实例来进行添加操作,来看看具体的CAS操作:

    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

compareAndSwapInt 是一个native方法,基于CAS来操作int类型变量。其它的原子操作类基本都是大同小异。

37.线程死锁了解吗?该如何避免?

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。

图片
死锁示意图

那么为什么会产生死锁呢?死锁的产生必须具备以下四个条件:

图片
死锁产生必备四条件
  • 互斥条件:指线程对己经获取到的资源进行它性使用,即该资源同时只由一个线程占用。如果此时还有其它线程请求获取获取该资源,则请求者只能等待,直至占有资源的线程释放该资源。
  • 请求并持有条件:指一个 线程己经持有了至少一个资源,但又提出了新的资源请求,而新资源己被其它线程占有,所以当前线程会被阻塞,但阻塞 的同时并不释放自己已经获取的资源。
  • 不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其它线程抢占,只有在自己使用完毕后才由自己释放该资源。
  • 环路等待条件:指在发生死锁时,必然存在一个线程——资源的环形链,即线程集合 {T0,T1,T2,…… ,Tn} 中 T0 正在等待一 T1 占用的资源,Tl1正在等待 T2用的资源,…… Tn 在等待己被 T0占用的资源。

该如何避免死锁呢?答案是至少破坏死锁发生的一个条件

  • 其中,互斥这个条件我们没有办法破坏,因为用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?

  • 对于“请求并持有”这个条件,可以一次性请求所有的资源。

  • 对于“不可剥夺”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。

  • 对于“环路等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后就不存在环路了。

38.那死锁问题怎么排查呢?

可以使用jdk自带的命令行工具排查:

  1. 使用jps查找运行的Java进程:jps -l
  2. 使用jstack查看线程堆栈信息:jstack -l  进程id

基本就可以看到死锁的信息。

还可以利用图形化工具,比如JConsole。出现线程死锁以后,点击JConsole线程面板的检测到死锁按钮,将会看到线程的死锁信息。

图片
线程死锁检测

并发工具类

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

CountDownLatch,倒计数器,有两个常见的应用场景[18]:

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

CountDownLatch允许一个或多个线程等待其他线程完成操作。

例如,我们很多人喜欢玩的王者荣耀,开黑的时候,得等所有人都上线之后,才能开打。

图片
王者荣耀等待玩家确认-来源参考[18]

CountDownLatch模仿这个场景(参考[18]):

创建大乔、兰陵王、安其拉、哪吒和铠等五个玩家,主线程必须在他们都完成确认后,才可以继续运行。

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

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(5);

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

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

场景2. 协调子线程开始动作:统一各线程动作开始的时机

王者游戏中也有类似的场景,游戏开始时,各玩家的初始状态必须一致。不能有的玩家都出完装了,有的才降生。

所以大家得一块出生,在

图片
王者荣耀-来源参考[18]

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

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);

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

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

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

CountDownLatch的核心方法也不多:

  • await():等待latch降为0;
  • boolean await(long timeout, TimeUnit unit):等待latch降为0,但是可以设置超时时间。比如有玩家超时未确认,那就重新匹配,总不能为了某个玩家等到天荒地老。
  • countDown():latch数量减1;
  • getCount():获取当前的latch数量。

40.CyclicBarrier(同步屏障)了解吗?

CyclicBarrier的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一 组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。

它和CountDownLatch类似,都可以协调多线程的结束动作,在它们结束后都可以执行特定动作,但是为什么要有CyclicBarrier,自然是它有和CountDownLatch不同的地方。

不知道你听没听过一个新人UP主小约翰可汗,小约翰生平有两大恨——“想结衣结衣不依,迷爱理爱理不理。”我们来还原一下事情的经过:小约翰在亲政后认识了新垣结衣,于是决定第一次选妃,向结衣表白,等待回应。然而新垣结衣回应嫁给了星野源,小约翰伤心欲绝,发誓生平不娶,突然发现了铃木爱理,于是小约翰决定第二次选妃,求爱理搭理,等待回应。

图片
想结衣结衣不依,迷爱理爱理不理。

我们拿代码模拟这一场景,发现CountDownLatch无能为力了,因为CountDownLatch的使用是一次性的,无法重复利用,而这里等待了两次。此时,我们用CyclicBarrier就可以实现,因为它可以重复利用。

图片
小约翰可汗选妃模拟代码

运行结果:

图片
运行结果

CyclicBarrier最最核心的方法,仍然是await():

  • 如果当前线程不是第一个到达屏障的话,它将会进入等待,直到其他线程都到达,除非发生被中断屏障被拆除屏障被重设等情况;

上面的例子抽象一下,本质上它的流程就是这样就是这样:

图片
CyclicBarrier工作流程

41.CyclicBarrier和CountDownLatch有什么区别?

两者最核心的区别[18]:

  • CountDownLatch是一次性的,而CyclicBarrier则可以多次设置屏障,实现重复利用;
  • CountDownLatch中的各个子线程不可以等待其他线程,只能完成自己的任务;而CyclicBarrier中的各个线程可以等待其他线程

它们区别用一个表格整理:

CyclicBarrier CountDownLatch
CyclicBarrier是可重用的,其中的线程会等待所有的线程完成任务。届时,屏障将被拆除,并可以选择性地做一些特定的动作。 CountDownLatch是一次性的,不同的线程在同一个计数器上工作,直到计数器为0.
CyclicBarrier面向的是线程数 CountDownLatch面向的是任务数
在使用CyclicBarrier时,你必须在构造中指定参与协作的线程数,这些线程必须调用await()方法 使用CountDownLatch时,则必须要指定任务数,至于这些任务由哪些线程完成无关紧要
CyclicBarrier可以在所有的线程释放后重新使用 CountDownLatch在计数器为0时不能再使用
在CyclicBarrier中,如果某个线程遇到了中断、超时等问题时,则处于await的线程都会出现问题 在CountDownLatch中,如果某个线程出现问题,其他线程不受影响

42.Semaphore(信号量)了解吗?

Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。

听起来似乎很抽象,现在汽车多了,开车出门在外的一个老大难问题就是停车 。停车场的车位是有限的,只能允许若干车辆停泊,如果停车场还有空位,那么显示牌显示的就是绿灯和剩余的车位,车辆就可以驶入;如果停车场没位了,那么显示牌显示的就是绿灯和数字0,车辆就得等待。如果满了的停车场有车离开,那么显示牌就又变绿,显示空车位数量,等待的车辆就能进停车场。

图片
停车场空闲车位提示-图片来源网络

我们把这个例子类比一下,车辆就是线程,进入停车场就是线程在执行,离开停车场就是线程执行完毕,看见红灯就表示线程被阻塞,不能执行,Semaphore的本质就是协调多个线程对共享资源的获取

图片
Semaphore许可获取-来源参考[18]

我们再来看一个Semaphore的用途:它可以用于做流量控制,特别是公用资源有限的应用场景,比如数据库连接。

假如有一个需求,要读取几万个文件的数据,因为都是IO密集型任务,我们可以启动几十个线程并发地读取,但是如果读到内存后,还需要存储到数据库中,而数据库的连接数只有10个,这时我们必须控制只有10个线程同时获取数据库连接保存数据,否则会报错无法获取数据库连接。这个时候,就可以使用Semaphore来做流量控制,如下:

public class SemaphoreTest {
    private static final int THREAD_COUNT = 30;
    private static ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_COUNT);
    private static Semaphore s = new Semaphore(10);

    public static void main(String[] args) {
        for (int i = 0; i < THREAD_COUNT; i++) {
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        s.acquire();
                        System.out.println("save data");
                        s.release();
                    } catch (InterruptedException e) {
                    }
                }
            });
        }
        threadPool.shutdown();
    }
}

在代码中,虽然有30个线程在执行,但是只允许10个并发执行。Semaphore的构造方法Semaphore(int permits)接受一个整型的数字,表示可用的许可证数量。Semaphore(10)表示允许10个线程获取许可证,也就是最大并发数是10。Semaphore的用法也很简单,首先线程使用 Semaphore的acquire()方法获取一个许可证,使用完之后调用release()方法归还许可证。还可以用tryAcquire()方法尝试获取许可证。

43.Exchanger 了解吗?

Exchanger(交换者)是一个用于线程间协作的工具类。Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。

图片
英雄交换猎物-来源参考[18]

这两个线程通过 exchange方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也执行exchange方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。

Exchanger可以用于遗传算法,遗传算法里需要选出两个人作为交配对象,这时候会交换两人的数据,并使用交叉规则得出2个交配结果。Exchanger也可以用于校对工作,比如我们需要将纸制银行流水通过人工的方式录入成电子银行流水,为了避免错误,采用AB岗两人进行录入,录入到Excel之后,系统需要加载这两个Excel,并对两个Excel数据进行校对,看看是否录入一致。

public class ExchangerTest {
    private static final Exchanger<String> exgr = new Exchanger<String>();
    private static ExecutorService threadPool = Executors.newFixedThreadPool(2);

    public static void main(String[] args) {
        threadPool.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    String A = "银行流水A"; // A录入银行流水数据 
                    exgr.exchange(A);
                } catch (InterruptedException e) {
                }
            }
        });
        threadPool.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    String B = "银行流水B"; // B录入银行流水数据 
                    String A = exgr.exchange("B");
                    System.out.println("A和B数据是否一致:" + A.equals(B) + ",A录入的是:"
                            + A + ",B录入是:" + B);
                } catch (InterruptedException e) {
                }
            }
        });
        threadPool.shutdown();
    }
}

假如两个线程有一个没有执行exchange()方法,则会一直等待,如果担心有特殊情况发生,避免一直等待,可以使用exchange(V x, long timeOut, TimeUnit unit)设置最大等待时长。

线程池

44.什么是线程池?

线程池: 简单理解,它就是一个管理线程的池子。

图片
管理线程的池子
  • 它帮我们管理线程,避免增加创建线程和销毁线程的资源损耗。因为线程其实也是一个对象,创建一个对象,需要经过类加载过程,销毁一个对象,需要走GC垃圾回收流程,都是需要资源开销的。
  • 提高响应速度。 如果任务到达了,相对于从线程池拿线程,重新去创建一条线程执行,速度肯定慢很多。
  • 重复利用。 线程用完,再放回池子,可以达到重复利用的效果,节省资源。

45.能说说工作中线程池的应用吗?

之前我们有一个和第三方对接的需求,需要向第三方推送数据,引入了多线程来提升数据推送的效率,其中用到了线程池来管理线程。

图片
业务示例

主要代码如下:

图片
主要代码

完整可运行代码地址:https://gitee.com/fighter3/thread-demo.git

线程池的参数如下:

  • corePoolSize:线程核心参数选择了CPU数×2

  • maximumPoolSize:最大线程数选择了和核心线程数相同

  • keepAliveTime:非核心闲置线程存活时间直接置为0

  • unit:非核心线程保持存活的时间选择了 TimeUnit.SECONDS 秒

  • workQueue:线程池等待队列,使用 LinkedBlockingQueue阻塞队列

同时还用了synchronized 来加锁,保证数据不会被重复推送:

  synchronized (PushProcessServiceImpl.class) {}

ps:这个例子只是简单地进行了数据推送,实际上还可以结合其他的业务,像什么数据清洗啊、数据统计啊,都可以套用。

46.能简单说一下线程池的工作流程吗?

用一个通俗的比喻:

有一个营业厅,总共有六个窗口,现在开放了三个窗口,现在有三个窗口坐着三个营业员小姐姐在营业。

老三去办业务,可能会遇到什么情况呢?

  1. 老三发现有空间的在营业的窗口,直接去找小姐姐办理业务。
图片
直接办理
  1. 老三发现没有空闲的窗口,就在排队区排队等。
图片
排队等待
  1. 老三发现没有空闲的窗口,等待区也满了,蚌埠住了,经理一看,就让休息的小姐姐赶紧回来上班,等待区号靠前的赶紧去新窗口办,老三去排队区排队。小姐姐比较辛苦,假如一段时间发现他们可以不用接着营业,经理就让她们接着休息。
图片
排队区满
  1. 老三一看,六个窗口都满了,等待区也没位置了。老三急了,要闹,经理赶紧出来了,经理该怎么办呢?
图片
等待区,排队区都满
  1. 我们银行系统已经瘫痪

  2. 谁叫你来办的你找谁去

  3. 看你比较急,去队里加个塞

  4. 今天没办法,不行你看改一天

上面的这个流程几乎就跟 JDK 线程池的大致流程类似,

  1. 营业中的 3个窗口对应核心线程池数:corePoolSize
  2. 总的营业窗口数6对应:maximumPoolSize
  3. 打开的临时窗口在多少时间内无人办理则关闭对应:unit
  4. 排队区就是等待队列:workQueue
  5. 无法办理的时候银行给出的解决方法对应:RejectedExecutionHandler
  6. threadFactory 该参数在 JDK 中是 线程工厂,用来创建线程对象,一般不会动。

所以我们线程池的工作流程也比较好理解了:

  1. 线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
  2. 当调用 execute() 方法添加一个任务时,线程池会做如下判断:
  • 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
  • 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
  • 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
  • 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会根据拒绝策略来对应处理。
图片
线程池执行流程
  1. 当一个线程完成任务时,它会从队列中取下一个任务来执行。

  2. 当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。

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

图片
线程池参数

线程池有七大参数,需要重点关注corePoolSizemaximumPoolSizeworkQueuehandler这四个。

  1. corePoolSize

此值是用来初始化线程池中核心线程数,当线程池中线程池数< corePoolSize时,系统默认是添加一个任务才创建一个线程池。当线程数 = corePoolSize时,新任务会追加到workQueue中。

  1. maximumPoolSize

maximumPoolSize表示允许的最大线程数 = (非核心线程数+核心线程数),当BlockingQueue也满了,但线程池中总线程数 < maximumPoolSize时候就会再次创建新的线程。

  1. keepAliveTime

非核心线程 =(maximumPoolSize – corePoolSize ) ,非核心线程闲置下来不干活最多存活时间。

  1. unit

线程池中非核心线程保持存活的时间的单位

  • TimeUnit.DAYS;天
  • TimeUnit.HOURS;小时
  • TimeUnit.MINUTES;分钟
  • TimeUnit.SECONDS;秒
  • TimeUnit.MILLISECONDS;  毫秒
  • TimeUnit.MICROSECONDS;  微秒
  • TimeUnit.NANOSECONDS;  纳秒
  1. workQueue

线程池等待队列,维护着等待执行的Runnable对象。当运行当线程数= corePoolSize时,新的任务会被添加到workQueue中,如果workQueue也满了则尝试用非核心线程执行任务,等待队列应该尽量用有界的。

  1. threadFactory

创建一个新线程时使用的工厂,可以用来设定线程名、是否为daemon线程等等。

  1. handler

corePoolSizeworkQueuemaximumPoolSize都不可用的时候执行的饱和策略。

48.线程池的拒绝策略有哪些?

类比前面的例子,无法办理业务时的处理方式,帮助记忆:

图片
四种策略
  • AbortPolicy :直接抛出异常,默认使用此策略
  • CallerRunsPolicy:用调用者所在的线程来执行任务
  • DiscardOldestPolicy:丢弃阻塞队列里最老的任务,也就是队列里靠前的任务
  • DiscardPolicy :当前任务直接丢弃

想实现自己的拒绝策略,实现RejectedExecutionHandler接口即可。

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

常用的阻塞队列主要有以下几种:

图片
线程池常用阻塞队列
  • ArrayBlockingQueue:ArrayBlockingQueue(有界队列)是一个用数组实现的有界阻塞队列,按FIFO排序量。
  • LinkedBlockingQueue:LinkedBlockingQueue(可设置容量队列)是基于链表结构的阻塞队列,按FIFO排序任务,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE,吞吐量通常要高于ArrayBlockingQuene;newFixedThreadPool线程池使用了这个队列
  • DelayQueue:DelayQueue(延迟队列)是一个任务定时周期的延迟执行的队列。根据指定的执行时间从小到大排序,否则根据插入到队列的先后排序。newScheduledThreadPool线程池使用了这个队列。
  • PriorityBlockingQueue:PriorityBlockingQueue(优先级队列)是具有优先级的无界阻塞队列
  • SynchronousQueue:SynchronousQueue(同步队列)是一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene,newCachedThreadPool线程池使用了这个队列。

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

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

51.线程池怎么关闭知道吗?

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

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

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

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

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

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

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

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

线程在Java中属于稀缺资源,线程池不是越大越好也不是越小越好。任务分为计算密集型、IO密集型、混合型。

  1. 计算密集型:大部分都在用CPU跟内存,加密,逻辑操作业务处理等。
  2. IO密集型:数据库链接,网络通讯传输等。
图片
常见线程池参数配置方案-来源美团技术博客

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

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

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

53.有哪几种常见的线程池?

面试常问,主要有四种,都是通过工具类Excutors创建出来的,需要注意,阿里巴巴《Java开发手册》里禁止使用这种方式来创建线程池。

图片
四大线程池
  • newFixedThreadPool  (固定数目线程的线程池)

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

  • newSingleThreadExecutor (单线程的线程池)

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

54.能说一下四种常见线程池的原理吗?

前三种线程池的构造直接调用ThreadPoolExecutor的构造方法。

newSingleThreadExecutor

  public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>(),
                                    threadFactory));
    }

线程池特点

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

工作流程:

  • 提交任务
  • 线程池是否有一条线程在,如果没有,新建线程执行任务
  • 如果有,将任务加到阻塞队列
  • 当前的唯一线程,从队列取任务,执行完一个,再继续取,一个线程执行任务。

适用场景

适用于串行执行任务的场景,一个任务一个任务地执行。

newFixedThreadPool

  public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>(),
                                      threadFactory);
    }

线程池特点:

  • 核心线程数和最大线程数大小一样
  • 没有所谓的非空闲时间,即keepAliveTime为0
  • 阻塞队列为无界队列LinkedBlockingQueue,可能会导致OOM
图片
FixedThreadPool

工作流程:

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

使用场景

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

newCachedThreadPool

   public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>(),
                                      threadFactory);
    }

线程池特点:

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

当提交任务的速度大于处理任务的速度时,每次提交一个任务,就必然会创建一个线程。极端情况下会创建过多的线程,耗尽 CPU 和内存资源。由于空闲 60 秒的线程会被终止,长时间保持空闲的 CachedThreadPool 不会占用任何资源。

图片
CachedThreadPool执行流程

工作流程:

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

适用场景

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

newScheduledThreadPool

    public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
    }

线程池特点

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

工作机制

  • 线程从DelayQueue中获取已到期的ScheduledFutureTask(DelayQueue.take())。到期任务是指ScheduledFutureTask的time大于等于当前时间。
  • 线程执行这个ScheduledFutureTask。
  • 线程修改ScheduledFutureTask的time变量为下次将要被执行的时间。
  • 线程把这个修改time之后的ScheduledFutureTask放回DelayQueue中(DelayQueue.add())。
图片
ScheduledThreadPoolExecutor执行流程

使用场景

周期性执行任务的场景,需要限制线程数量的场景

使用无界队列的线程池会导致什么问题吗?

例如newFixedThreadPool使用了无界的阻塞队列LinkedBlockingQueue,如果线程获取一个任务后,任务的执行时间比较长,会导致队列的任务越积越多,导致机器内存使用不停飙升,最终导致OOM。

55.线程池异常怎么处理知道吗?

在使用线程池处理任务的时候,任务代码可能抛出RuntimeException,抛出异常后,线程池可能捕获它,也可能创建一个新的线程来代替异常的线程,我们可能无法感知任务出现了异常,因此我们需要考虑线程池异常情况。

常见的异常处理方式:

图片
线程池异常处理

56.能说一下线程池有几种状态吗?

线程池有这几个状态:RUNNING,SHUTDOWN,STOP,TIDYING,TERMINATED。

   //线程池状态
   private static final int RUNNING    = -1 << COUNT_BITS;
   private static final int SHUTDOWN   =  0 << COUNT_BITS;
   private static final int STOP       =  1 << COUNT_BITS;
   private static final int TIDYING    =  2 << COUNT_BITS;
   private static final int TERMINATED =  3 << COUNT_BITS;

线程池各个状态切换图:

图片
线程池状态切换图

RUNNING

  • 该状态的线程池会接收新任务,并处理阻塞队列中的任务;
  • 调用线程池的shutdown()方法,可以切换到SHUTDOWN状态;
  • 调用线程池的shutdownNow()方法,可以切换到STOP状态;

SHUTDOWN

  • 该状态的线程池不会接收新任务,但会处理阻塞队列中的任务;
  • 队列为空,并且线程池中执行的任务也为空,进入TIDYING状态;

STOP

  • 该状态的线程不会接收新任务,也不会处理阻塞队列中的任务,而且会中断正在运行的任务;
  • 线程池中执行的任务为空,进入TIDYING状态;

TIDYING

  • 该状态表明所有的任务已经运行终止,记录的任务数量为0。
  • terminated()执行完毕,进入TERMINATED状态

TERMINATED

  • 该状态表示线程池彻底终止

57.线程池如何实现参数的动态修改?

线程池提供了几个  setter方法来设置线程池的参数。

图片
JDK 线程池参数设置接口来源参考[7]

这里主要有两个思路:

图片
动态修改线程池参数
  • 在我们微服务的架构下,可以利用配置中心如Nacos、Apollo等等,也可以自己开发配置中心。业务服务读取线程池配置,获取相应的线程池实例来修改线程池的参数。

  • 如果限制了配置中心的使用,也可以自己去扩展ThreadPoolExecutor,重写方法,监听线程池参数变化,来动态修改线程池参数。

线程池调优了解吗?

线程池配置没有固定的公式,通常事前会对线程池进行一定评估,常见的评估方案如下:

图片
线程池评估方案 来源参考[7]

上线之前也要进行充分的测试,上线之后要建立完善的线程池监控机制。

事中结合监控告警机制,分析线程池的问题,或者可优化点,结合线程池动态参数配置机制来调整配置。

事后要注意仔细观察,随时调整。

图片
线程池调优

具体的调优案例可以查看参考[7]美团技术博客。

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

⭐这道题在阿里的面试中出现频率比较高

线程池实现原理可以查看 要是以前有人这么讲线程池,我早就该明白了!  ,当然,我们自己实现, 只需要抓住线程池的核心流程-参考[6]:

图片
线程池主要实现流程

我们自己的实现就是完成这个核心流程:

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

实现代码[6]:

图片
自定义线程池

这样,一个实现了线程池主要流程的类就完成了。

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

我们可以对正在处理和阻塞队列的任务做事务管理或者对阻塞队列中的任务持久化处理,并且当断电或者系统崩溃,操作无法继续下去的时候,可以通过回溯日志的方式来撤销正在处理的已经执行成功的操作。然后重新执行整个阻塞队列。

也就是说,对阻塞队列持久化;正在处理任务事务控制;断电之后正在处理任务的回滚,通过日志恢复该次操作;服务器重启后阻塞队列中的数据再加载。

并发容器和框架

关于一些并发容器,可以去看看 面渣逆袭:Java集合连环三十问  ,里面有CopyOnWriteListConcurrentHashMap这两种线程安全容器类的问答。。

60.Fork/Join框架了解吗?

Fork/Join框架是Java7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。

要想掌握Fork/Join框架,首先需要理解两个点,分而治之工作窃取算法

分而治之

Fork/Join框架的定义,其实就体现了分治思想:将一个规模为N的问题分解为K个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求出子问题的解,就可得到原问题的解。

图片
Fork/Join分治算法

工作窃取算法

大任务拆成了若干个小任务,把这些小任务放到不同的队列里,各自创建单独线程来执行队列里的任务。

那么问题来了,有的线程干活块,有的线程干活慢。干完活的线程不能让它空下来,得让它去帮没干完活的线程干活。它去其它线程的队列里窃取一个任务来执行,这就是所谓的工作窃取

工作窃取发生的时候,它们会访问同一个队列,为了减少窃取任务线程和被窃取任务线程之间的竞争,通常任务会使用双端队列,被窃取任务线程永远从双端队列的头部拿,而窃取任务的线程永远从双端队列的尾部拿任务执行。

图片
工作窃取

看一个Fork/Join框架应用的例子,计算1~n之间的和:1+2+3+…+n

  • 设置一个分割阈值,任务大于阈值就拆分任务
  • 任务有结果,所以需要继承RecursiveTask
public class CountTask extends RecursiveTask<Integer> {
    private static final int THRESHOLD = 16; // 阈值
    private int start;
    private int end;

    public CountTask(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    protected Integer compute() {
        int sum = 0;
        // 如果任务足够小就计算任务
        boolean canCompute = (end - start) <= THRESHOLD;
        if (canCompute) {
            for (int i = start; i <= end; i++) {
                sum += i;
            }
        } else {
            // 如果任务大于阈值,就分裂成两个子任务计算
            int middle = (start + end) / 2;
            CountTask leftTask = new CountTask(start, middle);
            CountTask rightTask = new CountTask(middle + 1, end);
            // 执行子任务
            leftTask.fork();
            rightTask.fork(); // 等待子任务执行完,并得到其结果
            int leftResult = leftTask.join();
            int rightResult = rightTask.join(); // 合并子任务
            sum = leftResult + rightResult;
        }
        return sum;
    }

    public static void main(String[] args) {
        ForkJoinPool forkJoinPool = new ForkJoinPool(); // 生成一个计算任务,负责计算1+2+3+4
        CountTask task = new CountTask(1, 100); // 执行一个任务
        Future<Integer> result = forkJoinPool.submit(task);
        try {
            System.out.println(result.get());
        } catch (InterruptedException e) {
        } catch (ExecutionException e) {
        }
    }
    
}

ForkJoinTask与一般Task的主要区别在于它需要实现compute方法,在这个方法里,首先需要判断任务是否足够小,如果足够小就直接执行任务。如果比较大,就必须分割成两个子任务,每个子任务在调用fork方法时,又会进compute方法,看看当前子任务是否需要继续分割成子任务,如果不需要继续分割,则执行当前子任务并返回结果。使用join方法会等待子任务执行完并得到其结果。

Python 3.11 正式版发布,比 3.10 快 10-60%,官方:这或许是最好的版本

终于,Python 3.11 正式版发布了!

2020 年 1 月 1 日,Python 官方结束了对 Python 2 的维护,这意味着 Python 2 已完全退休,进入了 Python 3 时代。打从进入 3 版本以来,Python 官方已经发布了众多修改分支,现在来到了最新的版本 Python 3.11。
其实研究界有个不公开的秘密,那就是 Python 运行速度并不快但容易上手,因此使用人数超级多,在众多最受欢迎语言榜单中 Python 多次位列第一。很多开发者都期待这门语言的性能有所提升,还有人畅想 Python 4 会不会在某个不经意的时刻到来,有这种想法的人可以放一放了,Python 之父 Van Rossum 都说了,Python 4.0 可能不会来了。
Van Rossum 曾表示:「我和 Python 核心开发团队的成员对 Python 4.0 没什么想法,提不起兴趣,估计至少会一直编号到 3.33。Python 的加速是渐进式的,3.11 版本会有新的速度提升,预计会比 3.10 快得多。」
正如 Van Rossum 所说,根据官方资料显示最新发布的 Python 3.11 比 Python 3.10 快 10-60%,对用户更友好。这一版本历经 17 个月的开发,现在公开可用。
Python 3.11 的具体改进主要表现在:更详实的 Error Tracebacks、更快的代码执行、更好的异步任务语法、改进类型变量、支持 TOML 配置解析以及一些其他非常酷的功能(包括快速启动、Zero-Cost 异常处理、异常组等)。

图片

Python 指导委员会成员和核心开发者、Python3.10/3.11 发布管理者 Pablo Galindo Salgado 表示,为了使 3.11 成为最好的 Python 版本,我们付出了很多努力。

图片

Python 3.11 新特性
Error Tracebacks
Python 这门编程语言对初学者非常友好,它具有易于理解的语法和强大的数据结构。但对于刚刚接触 Python 的人来说却存在一个难题,即如何解释当 Python 遇到错误时显示的 traceback。
Python 3.11 将 Decorative annotation 添加到 tracebacks 中,以帮助用户更快地解释错误消息。想要获得这种功能,可以将以下代码添加到 inverse.py 文件中。

图片

举例来说,你可以使用 inverse() 来计算一个数的倒数。因为 0 没有倒数,所以在运行下列代码时会抛出一个错误。

图片

注意嵌入在 traceback 中的 ^ 和~ 符号,它们指向导致错误的代码。与此前的 tracebacks 一样,你应该从底层开始,然后逐步向上。这种操作对发现错误非常有用,但如果代码过于复杂,带注释的 tracebacks 会更好。
更快的代码执行
Python 以速度慢著称,例如在 Python 中,常规循环比 C 中的类似循环慢几个数量级。
Python 官方正在着手改进这一缺陷。2020 年秋,Mark Shannon 提出了关于 Python 的几个性能改进。这个提议被称为香农计划 (Shannon Plan),他们希望通过几个版本的更新将 Python 的速度提高 5 倍。不久之后微软正式加入该计划,该公司正在支持包括 Mark Shannon、Guido van Rossum 在内的开发人员,致力于「Faster CPython」项目的研究。
「Faster CPython」项目中的一个重要提案是 PEP 659,在此基础上,Python 3.11 有了许多改进。
PEP 659 描述了一种「specializing adaptive interpreter」。主要思想是通过优化经常执行的操作来加快代码运行速度, 这类似于 JIT(just-in-time)编译。只是它不影响编译,相反,Python 的字节码是动态调整或可更改的。

图片

研究人员在字节码生成中添加了一个名为「quickening」的新步骤,从而可以在运行时优化指令,并将它们替换为 adaptive 指令。
一旦函数被调用了一定次数,quickening 指令就会启动。在 CPython 3.11 中,八次调用之后就会启动 quickening。你可以通过调用 dis() 并设置 adaptive 参数来观察解释器如何适应字节码。
在基准测试中,CPython 3.11 比 CPython 3.10 平均快 25%。Faster CPython 项目是一个正在进行的项目,已经有几个优化计划在 2023 年 10 月与 Python 3.12 一起发布。你可以在 GitHub 上关注该项目。
项目地址:https://github.com/faster-cpython/ideas

更好的异步任务语法
Python 中对异步编程的支持已经发展了很长时间。Python 2 时代添加了生成器,asyncio 库最初是在 Python 3.4 中添加的,而 async 和 await 关键字是在 Python 3.5 中添加的。在 Python 3.11 中,你可以使用任务组(task groups),它为运行和监视异步任务提供了更简洁的语法。
改进的类型变量
Python 是一种动态类型语言,但它通过可选的类型提示支持静态类型。Python 静态类型系统的基础在 2015 年的 PEP 484 中定义。自 Python 3.5 以来,每个 Python 版本都引入了几个与类型相关的新提案。
Python 3.11 发布了 5 个与类型相关的 PEP,创下新高:
  • PEP 646: 可变泛型
  • PEP 655: 根据需要或可能丢失的情况标记单个 TypedDict 项
  • PEP 673: Self 类型
  • PEP 675: 任意文字字符串类型
  • PEP 681: 数据类转换
支持 TOML 配置解析
TOML 是 Tom’s Obvious Minimal Language 的缩写。这是一种在过去十年中流行起来的配置文件格式。在为包和项目指定元数据时,Python 社区已将 TOML 作为首选格式。
虽然 TOML 已被使用多年,但 Python 并没有内置的 TOML 支持。当 tomllib 添加到标准库时,Python 3.11 中的情况发生了变化。这个新模块建立在 toml 第三方库之上,允许解析 TOML 文件。
以下是名为 units.toml 的 TOML 文件示例:

图片

其他功能
除了以上主要更新和改进之外,Python 3.11 还有更多值得探索的功能,比如更快的程序启动速度、对异常的更多改变以及对字符串格式的小幅改进。
更快的程序启动速度
Faster CPython 项目的一大成果是实现了更快的启动时间。当你运行 Python 脚本时,解释器初始化需要一些操作。这就导致即便是最简单的程序也需要几毫秒才能运行。

图片

在很多情况下,与运行代码所需时间相比,启动程序需要的时间可以忽略不计。但是在运行时间较短的脚本中,如典型的命令行应用程序,启动时间可能会显著影响程序性能。比如考虑如下脚本,它受到了经典 cowsay 程序的启发。

图片

在 snakesay.py 中,你从命令行读取一条消息,然后将这条消息打印在带有一条可爱蛇的对话气泡中。你可以让蛇说任何话。这是命令行应用程序的基本示例,它运行得很快,但仍需要几毫秒。这一开销的很大部分发生在 Python 导入模块时。

图片

你可以使用 – X importtime 选项来显示导入模块所用的时间。表中的数字为微秒为单位,最后一列是模块名称的格式。

图片

该示例分别运行在 Python 3.11 和 3.10 上,结果如下图所示,Python 3.11 的导入速度更快,有助于 Python 程序更快地启动。

图片

零成本异常
异常的内部表示在 Python 3.11 中有所不同。异常对象更轻量级,并且异常处理发生了变化。因此只要不触发 except 字句,try … except 块中的开销就越小。
所谓的零成本异常受到了 C++ 和 Java 等其他语言的启发。当你的源代码被编译为字节码时,编译器创建跳转表,由此来实现零成本异常。如果引发异常,查询这些跳转表。如果没有异常,则 try 块中的代码没有运行时开销。
异常组
此前,你了解到了任务组以及它们如何同时处理多个错误。这都要归功于一个被称为异常组的新功能。
我们可以这样考虑异常组,它们是包装了其他几种常规异常的常规异常。虽然异常组在很多方面表现得像常规异常,但它们也支持特殊语法,帮助你有效地处理每个包装异常。如下所示,你可以通过给出一个描述并列出包装的异常来创建一个异常组。

图片

异常 Notes
常规异常具有添加任意 notes 的扩展能力。你可以使用. add_note() 向任何异常添加一个 note,并通过检查.__notes__属性来查看现有 notes。

图片

负零格式化
使用浮点数进行计算时可能会遇到一个奇怪概念——负零。你可以观察到负零和 regular zero 在 REPL 中呈现不同,如下所示。

图片

 

原文链接:https://mp.weixin.qq.com/s/E-HmgUAQy88ySzo8fJuF5Q

微服务之服务监控和治理、容错隔离与 Docker 部署

.微服务监控系统

1.1 什么是监控系统

  • 一旦请求服务出现异常,我们必须得知道是在哪个服务环节出了故障,就需要对每一个服务,以及各个指标都进行全面的监控;

  • 监控系统能为我们提供具体的指标数据进行追踪和跟进。

在微服务架构中,监控系统按照原理和作用大致可以分为三类:

  • 日志监控(Log)
  • 调用链调用监控(Tracing)
  • 度量监控(Metrics)

1.2 日志监控

  • 日志类比较常见,我们的框架代码、系统环境、以及业务逻辑中一般都会产出一些日志,这些日志我们通常把它记录后统一收集起来,方便在跟踪和丁文问题的需要时进行查询;
  • 日志类记录的信息一般是一些事件、非结构化的一些文本内容;
  • 日志的输出和处理的解决方案常用的有 ELK Stack 方案,用于实时搜索,分析和可视化日志数据;
  • 开源实时日志分析 ELK 平台能够完美的解决我们上述的问题,ELK 由ElasticSearch、Logstash和Kiabana 三个开源工具组成。

图片

组件介绍

  • Elasticsearch 是个开源分布式搜索引擎,它具备分布式、零配置、自动发现、索引自动分片、索引副本机制、RESTful 风格接口、多数据源、自动搜索负载等特性;
  • Logstash 是一个完全开源的工具,它可以对你的日志进行收集、过滤,并将其存储供以后使用(如搜索);
  • Kibana 也是一个开源和免费的工具,可以为 Logstash 和 ElasticSearch 提供的日志分析友好的 Web 界面,可以帮助您汇总、分析和搜索重要数据日志;
  • Kafka 用来接收用户日志的消息队列。

工作流程图

图片

  • Logstash 收集 AppServer 产生的日志记录 Log;
  • 将日志 log 存放到 ElasticSearch 集群中,而 Kibana 则从 ES 集群中查询数据生成图表;
  • 生成的日志图表返回给 Browser 进行渲染显示,分别支持各种终端进行显示。

1.3 调用链监控

1.3.1 什么是调用链监控

  • 调用链监控是用来追踪微服务之前依赖的路径和问题定位;
  • 主要原理就是子节点会记录父节点的 id 信息。例如阿里的鹰眼系统就是一个调用链监控系统;
  • 一个请求从开始进入,在微服务中调用不同的服务节点后,再返回给客户端,在这个过程中通过调用链参数来追寻全链路的调用行程。通过这个方式可以很方便的知道请求在哪个环节出了故障,系统的瓶颈出现在哪一个环节,定位出优化点。

1.3.2 为什么需要调用链监控

  • 「调用链监控」是在微服务架构中非常重要的一环。它除了能帮助我们定位问题以外,还能帮助项目成员清晰的去了解项目部署结构。
  • 微服务如果达到了成百个服务调用,时间久了之后,项目的结构很可能就会出现超级混乱的调用,见下图:

图片

  • 在这种情况下,团队开发者甚至是架构师都不一定能对项目的网络结构有很清晰的了解,那就更别谈系统优化了。

1.3.3 调用链监控的作用

生成项目网络拓扑图

  • 根据「调用链监控」中记录的链路信息,给项目生成一张网络调用的拓扑图;
  • 通过这张图,我们就可以知道系统中的各个服务之间的调用关系是怎样的,以及系统依赖了哪些服务;
  • 可以让架构师监控全局服务状态,便于架构师掌握系统的调用结构。

快速定位问题

  • 微服务架构下,问题定位就变得非常复杂了,一个请求可能会涉及到多个服务节点;
  • 有了调用链监控系统就能让开发人员快速的定位到问题和相应模块,提升解决问题效率。

优化系统

  • 通过记录了请求在调用链上每一个环节的信息,可以通过得出的服务信息找出系统的瓶颈,做出针对性的优化;
  • 还可以分析这个调用路径是否合理,是否调用了不必要的服务节点,是否有更近、响应更快的服务节点;
  • 通过对调用链路的分析,我们就可以找出最优质的调用路径,从而提高系统的性能。

1.3.4 调用链监控的原理

主要原理就是子节点会记录父节点的 id 信息,要理解好三个核心的概念 Trace、Span 和 Annotation。

Trace

  • Trace 是指一次请求调用的链路过程,trace id 是指这次请求调用的 ID;
  • 在一次请求中,会在网络的最开始生成一个全局唯一的用于标识此次请求的 trace id。这个 trace id 在这次请求调用过程中无论经过多少个节点都会保持不变,并且在随着每一层的调用不停的传递;
  • 最终,可以通过 trace id 将这一次用户请求在系统中的路径全部串起来。

Span

  • Span 是指一个模块的调用过程,一般用 span id 来标识。在一次请求的过程中会调用不同的节点、模块、服务,每一次调用都会生成一个新的 span id 来记录;
  • 这样就可以通过 span id 来定位当前请求在整个系统调用链中所处的位置,以及它的上下游节点分别是什么。

Annotation

指附属信息,可以用于附属在每一个 Span 上自定义的数据。

具体流程:

图片

  • 从图中可见,一次请求只有一个唯一的 trace id=12345,在请求过程中的任何环节都不会改变;
  • 在这个请求的调用链中,Span A 调用了 Span B,然后 Span B 又调用了 Span C 和 Span D,每一次 Span 调用都会生成一个自己的 span id,并且还会记录自己的上级 span id 是谁;
  • 通过这些 id,整个链路基本上就都能标识出来,记录了调用过程。

1.3.5 调用链监控开源应用

CAT

  • CAT 是由大众点评开源的一款调用链监控系统,基于JAVA开发,有很多互联网企业在使用,热度非常高;
  • 它有一个非常强大和丰富的可视化报表界面,这一点其实对于一款调用链监控系统而来非常的重要;
  • 在 CAT 提供的报表界面中有非常多的功能,几乎能看到你想要的任何维度的报表数据;
  • CAT 有个很大的优势就是处理的实时性,CAT 里大部分系统是分钟级统计。

Open Zipkin

  • Zipkin 由 Twitter 开源,支持的语言非常多。它基于 Google Dapper 的论文设计而来,国内外很多公司都在用,文档资料也很丰富。
  • 用于收集服务的定时数据,以解决微服务架构中的延迟问题,包括数据的收集、存储、查找和展现。

Pinpoint

  • Pinpoint 中的服务关系依赖图做得非常棒,超出市面上任何一款产品;
  • Pinpoint 运用 JavaAgent 字节码增强技术,只需要加启动参数即可。因为采用字节码增强方式去埋点,所以在埋点的时候是不需要修改业务代码的,是非侵入式的。非常适合项目已经完成之后再增加调用链监控的时候去使用的方案;
  • 但是也是由于采用字节码增强的方式,所以它目前仅支持 Java 语言。

方案选型比较

图片

1.4 度量监控

1.4.1 什么是度量监控

  • 度量类监控主要采用时序数据库的解决方案;
  • 它是以事件发生时间以及当前数值的角度来记录的监控信息,是可以聚合运算的,用于查看一些指标数据和指标趋势;
  • 所以这类监控主要不是用来查问题的,主要是用来看趋势的,基于时间序列数据库的监控系统是非常适合做监控告警使用的。

Metrics 一般有 5 种基本的度量类型:

  • Gauges(度量)
  • Counters(计数器)
  • Histograms(直方图)
  • Meters(TPS计算器)
  • Timers(计时器)

1.4.2 时序数据库有哪些

Prometheus

  • Promethes 是一款 2012 年开源的监控框架,其本质是时间序列数据库,由 Google 前员工所开发;
  • Promethes 采用拉的模式(Pull)从应用中拉取数据,并还支持 Alert 模块可以实现监控预警。它的性能非常强劲,单机可以消费百万级时间序列。

图片

从图的左下角可以看到,Prometheus 可以通过在应用里进行埋点后 Pull 到 Prometheus Server 里。如果应用不支持埋点,也可以采用 exporter 方式进行数据采集。

从图的左上角可以看到,对于一些定时任务模块,因为是周期性运行的,所以采用拉的方式无法获取数据,那么 Prometheus 也提供了一种推数据的方式,但是并不是推送到 Prometheus Server 中,而是中间搭建一个 Pushgateway。定时任务模块将 metrics 信息推送到这个 Pushgateway 中,然后 Prometheus Server 再依然采用拉的方式从 Pushgateway 中获取数据。

需要拉取的数据既可以采用静态方式配置在 Prometheus Server 中,也可以采用服务发现的方式(即图的中间上面的 Service discovery 所示)。

PromQL 是 Prometheus 自带的查询语法,通过编写 PromQL 语句可以查询 Prometheus 里面的数据。Alertmanager 是用于数据的预警模块,支持通过多种方式去发送预警。WebU 用来展示数据和图形,但是一般大多数是与 Grafana 结合,采用 Grafana 来展示。

OpenTSDB

  • OpenTSDB 是在 2010 年开源的一款分布式时序数据库,当然其主要用于监控方案中;
  • OpenTSDB 采用的是 Hbase 的分布式存储,它获取数据的模式与 Prometheus 不同,它采用的是推模式(Push);
  • 在展示层,OpenTSDB 自带有 WebUI 视图,也可以与 Grafana 很好的集成,提供丰富的展示界面;
  • 但 OpenTSDB 并没有自带预警模块,需要自己去开发或者与第三方组件结合使用。

图片

InfluxDB

  • InfluxDB 是在 2013 年开源的一款时序数据库,在这里我们主要还是用于做监控系统方案;
  • 它收集数据也是采用推模式(Push)。在展示层,InfluxDB 也是自带 WebUI,也可以与 Grafana 集成。

图片

1.5 微服务监控体系

监控是微服务治理的重要环节,架构采用分层监控,一般分为以下监控层次。如下图所示:

图片

系统层

系统层主要是指 CPU、磁盘、内存、网络等服务器层面的监控,这些一般也是运维同学比较关注的对象。

应用层

应用层指的是服务角度的监控,比如接口、框架、某个服务的健康状态等,一般是服务开发或框架开发人员关注的对象。

用户层

这一层主要是与用户、与业务相关的一些监控,属于功能层面的,大多数是项目经理或产品经理会比较关注的对象。

监控指标

  • 延迟时间:主要是响应一个请求所消耗的延迟,比如某接口的 HTTP 请求平均响应时间为 100ms;
  • 请求量:是指系统的容量吞吐能力,例如每秒处理多少次请求(QPS)作为指标。
  • 错误率:主要是用来监控错误发生的比例,比如将某接口一段时间内调用时失败的比例作为指标。

2. 微服务容错隔离

2.1 什么是容错隔离

单体应用的架构下一旦程序发生了故障,那么整个应用可能就没法使用了,所以我们要把单体应用拆分成具有多个服务的微服务架构,来减少故障的影响范围。

但是在微服务架构下,有一个新的问题就是,由于服务数变多了,假设单个服务的故障率是不变的,那么整体微服务系统的故障率其实是提高了的。

假设单个服务的故障率是 0.01%,也就是可用性是 99.99%,如果我们总共有 10 个微服务,那么我们整体的可用性就是 99.99% 的十次方,得到的就是 99.90% 的可用性(也就是故障率为 0.1%)。可见,相对于之前的单体应用,整个系统可能发生故障的风险大幅提升。

当某个服务出现故障,我们要做的就是最大限度的隔离单个服务的风险,也就是「 容错隔离 」的方法。不仅保证了微服务架构的正常运行,也保证了系统的可用性和健壮性。

2.2 常见的可用性风险有哪些

单机可用性风险

  • 单机可用性风险指的是微服务部署所在的某一台机器出现了故障,造成的可用性风险;
  • 这种风险发生率很高,因为单机器在运维中本身就容易发生各种故障,例如硬盘坏了、机器电源故障等等,这些都是时有发生的事情;
  • 不过虽然这种风险发生率高,但危害有限,因为我们大多数服务并不只部署在一台机器上,可能多台都有,因此只需要做好监控,发现故障之后,及时的将这台故障机器从服务集群中剔除即可,等修复后再重新上线到集群里。

单机房可用性风险

  • 这种风险的概率比单机器的要低很多,但是也不是完全不可能发生,在实际情况中,还是有一定概率的。比如最为常见的就是通往机房的光纤被挖断,造成机房提供不了服务;
  • 如果我们的服务全部都部署在单个机房,而机房又出故障了,但是现在大多数中大型项目都会采用多机房部署的方案,比如同城双活、异地多活等;
  • 一旦某个机房出现了故障不可用了,立即采用切换路由的方式,把这个机房的流量切到其它机房里就能正常提供服务了。

跨机房集群可用性风险

  • 跨机房集群只是保证了物理层面可用性的问题,如果存在代码故障问题,或者因为特殊原因用户流量激增,导致我们的服务扛不住了,那在跨机房集群的情况下一样会导致系统服务不可用;
  • 所以我们就需要提前做好了「容错隔离」的一些方案,比如限流、熔断等等,用上这些方法还是可以保证一部分服务或者一部分用户的访问是正常。

2.3 容错隔离的方案有哪些

超时

  • 这是简单的容错方式。指在服务之间调用时,设置一个主动超时时间作为时间阈值;
  • 超过了这个时间阈值后,如果“被依赖的服务”还没有返回数据的话,“调用者”就主动放弃,防止因“被依赖的服务”故障无法返回结果造成服务无法处理请求的问题。

限流

  • 顾名思义,就是限制最大流量。系统能提供的最大并发有限,同时来的请求又太多,服务无法处理请求,就只好排队限流;
  • 类比就跟生活中去景点排队买票、去商场吃饭排队等号的道理;
  • 常见的限流算法有:计算器限流、漏桶算法、令牌漏桶算法、集群限流算法。

降级

  • 与限流类似,一样是流量太多,系统服务不过来。这个时候可将不是那么重要的功能模块进行降级处理,停止服务,这样可以释放出更多的资源供给核心功能的去用;
  • 同时还可以对用户分层处理,优先处理重要用户的请求,比如 VIP 收费用户等;
  • 例如淘宝双十一活动会对订单查询服务降级来保证购买下单服务的可用性。

延迟异步处理

  • 这个方式是指设置一个流量缓冲池,所有的请求先进入这个缓冲池等待处理;
  • 真正的服务处理方按顺序从这个缓冲池中取出请求依次处理,这种方式可以减轻后端服务的压力,但是对用户来说体验上有延迟;
  • 技术上一般会采用消息队列的异步和削峰作用来实现。

熔断

  • 可以理解成就像电闸的保险丝一样,当流量过大或者错误率过大的时候,保险丝就熔断了,链路就断开了,不提供服务了;
  • 当流量恢复正常,或者后端服务稳定了,保险丝会自动街上(熔断闭合),服务又可以正常提供了。这是一种很好的保护后端微服务的一种方式;
  • 熔断技术中有个很重要的概念就是:断路器,可以参考下图:

图片

断路器其实就是一个状态机原理,有三种状态:

  • Closed:闭合状态,也就是正常状态;
  • Open:开启状态,也就是当后端服务出故障后链路断开,不提供服务的状态;
  • Half-Open:半闭合状态,就是允许一小部分流量进行尝试,尝试后发现服务正常就转为Closed状态,服务依旧不正常就转为Open状态。

2.4 开源容错隔离应用

Hystrix 原理图

图片

  • 当我们使用了 Hystrix 之后,请求会被封装到 HystrixCommand 中,这也就是第一步;
  • 第二步就是开始执行请求,Hystrix 支持同步执行(图中 .execute 方法)、异步执行(图中 .queue 方法)和响应式执行(图中 .observer);
  • 第三步判断缓存,如果存在与缓存中,则直接返回缓存结果;
  • 如果不在缓存中,则走第四步,判断断路器的状态是否为开启,如果是开启状态,也就是短路了,那就进行失败返回,跳到第八步;
  • 第八步需要对失败返回的处理也需要再做一次判断,要么正常失败返回,返回相应信息,要么根本没有实现失败返回的处理逻辑,就直接报错;
  • 如果断路器不是开启状态,那请求就继续走,进行第五步,判断线程、队列是否满了。如果满了,那么同样跳到第八步;
  • 如果线程没满,则走到第六步,执行远程调用逻辑,然后判断远程调用是否成功。调用发生异常了就挑到第八步,调用正常就挑到第九步正常返回信息。
  • 图中的第七步,非常牛逼的一个模块,是来收集 Hystrix 流程中的各种信息来对系统做监控判断的。

Hystrix 断路器的原理图

图片

  • Hystrix 通过滑动时间窗口算法来实现断路器的,是以秒为单位的滑桶式统计。它总共包含 10 个桶,每秒钟一个生成一个新的桶,往前推移,旧的桶就废弃掉;
  • 每一个桶中记录了所有服务调用的状态,调用次数、是否成功等信息,断路器的开关就是把这 10 个桶进行聚合计算后,来判断当前是应该开启还是闭合的。

3. 微服务的访问安全

3.1 什么是访问安全

  • 访问安全就是要保证符合系统要求的请求才可以正常访问服务来响应数据,避免非正常服务对系统进行攻击和破坏;
  • 微服务会进行服务的拆分,服务也会随业务分为内部服务和外部服务,同时需要保证哪些服务可以直接访问,哪些不可以;
  • 总的来说,访问安全本质上就是要作为访问的认证。

3.2 传统单机服务的访问安全机制

传统单体应用的访问示意图:

图片

  • 在应用服务器里面,会有一个 auth 模块(一般采用过滤器来实现)。当有客户端请求进来时,所有的请求都必须首先经过这个 auth 来做身份验证,验证通过后,才将请求发到后面的业务逻辑;
  • 通常客户端在第一次请求的时候会带上身份校验信息(用户名和密码),auth 模块在验证信息无误后,就会返回 Cookie 存到客户端,之后每次客户端只需要在请求中携带 Cookie 来访问,而 auth 模块也只需要校验 Cookie 的合法性后决定是否放行;
  • 可见,在传统单体应用中的安全架构还是蛮简单的,对外也只有一个入口,通过 auth 校验后,内部的用户信息都是内存、线程传递,逻辑并不是复杂,所以风险也在可控范围内。

3.3 微服务如何实现访问安全

在微服务架构下,一般有以下三种方案:

  • 网关鉴权模式(API Gateway)
  • 服务自主鉴权模式
  • API Token模式(OAuth2.0)

3.3.1 网关鉴权模式(API Gateway)

图片

  • 通过上图可见,因为在微服务的最前端一般会有一个 API 网关模块(API Gateway)。所有外部请求访问微服务集群时,都会首先通过这个API Gateway。可以在这个模块里部署 auth 逻辑,实现统一集中鉴权。鉴权通过后,再把请求转发给后端各个服务;
  • 这种模式的优点就是,由 API Gateway 集中处理了鉴权的逻辑,使得后端各微服务节点自身逻辑就简单了,只需要关注业务逻辑,无需关注安全性事宜;
  • 这个模式的问题就是,API Gateway 适用于身份验证和简单的路径授权(基于 URL),对于复杂数据、角色的授权访问权限,通过 API Gateway 很难去灵活的控制。毕竟这些逻辑都是存在后端服务上的,并非存储在 API Gateway 里。

3.3.2 服务自主鉴权模式

图片

  • 服务自主鉴权就是指不通过前端的 API Gateway 来控制,而是由后端的每一个微服务节点自己去鉴权;
  • 它的优点就是可以由更为灵活的访问授权策略,并且相当于微服务节点完全无状态化了。同时还可以避免 API Gateway 中 auth 模块的性能瓶颈;
  • 缺点就是由于每一个微服务都自主鉴权,当一个请求要经过多个微服务节点时,会进行重复鉴权,增加了很多额外的性能开销。

3.3.3 API Token 模式(OAuth2.0)

图片

如图,这是一种采用基于令牌 Token 的授权方式。在这个模式下,是由授权服务器(图中 Authorization Server)、API 网关(图中 API Gateway)、内部的微服务节点几个模块组成。

流程如下:

  1. 客户端应用首先使用账号密码或者其它身份信息去访问授权服务器(Authorization Server)获取 访问令牌(Access Token);

  2. 拿到访问令牌(Access Token)后带着它再去访问API网关(图中 API Gateway),API Gateway 自己是无法判断这个 Access Token 是否合法,所以走第 3 步;

  3. API Gateway 去调用 Authorization Server 校验 Access Token 的合法性;

  4. 如果验证完 Access Token 是合法的,那 API Gateway 就将 Access Token 换成 JWT 令牌返回;

    注意:此处也可以不换成 JWT,而是直接返回原 Access Token。但是换成 JWT 更好,因为 Access Token 是一串不可读无意义的字符串,每次验证 Access Token 是否合法都需要去访问 Authorization Server 才知道。但是 JWT 令牌是一个包含 JSON 对象,有用户信息和其它数据的一个字符串,后面微服务节点拿到 JWT 之后,自己就可以做校验,减少了交互次数。

  5. API Gateway 有了JWT之后,就将请求向后端微服务节点进行转发,同时会带上这个 JWT;

  6. 微服务节点收到请求后,读取里面的 JWT,然后通过加密算法验证这个 JWT,验证通过后,就处理请求逻辑。

    这里面就使用到了 OAuth2.0 的原理,不过这只是 OAuth2.0 各类模式中的一种。

3.4 OAuth2.0 的访问安全 

3.4.1 什么是 OAuth2.0

OAuth2.0 是一种访问授权协议框架。它是基于 Token 令牌的授权方式,在不暴露用户密码的情况下,使应用方能够获取到用户数据的访问权限。

例如:你开发了一个视频网站,可以采用第三方微信登陆,那么只要用户在微信上对这个网站授权了,那这个网站就可以在无需用户密码的情况下获取用户在微信上的头像。

OAuth2.0 的流程如下图:

图片

3.4.2 OAuth2.0 主要名词解释

  • 资源服务器:用户数据、资源存放的地方,在微服务架构中,服务就是资源服务器。在上面的例子中,微信头像存放的服务就是资源服务器;
  • 资源拥有者:是指用户,资源的拥有人。在上面的例子中某个微信头像的用户就是资源拥有者;
  • 授权服务器:是一个用来验证用户身份并颁发令牌的服务器;
  • 客户端应用:想要访问用户受保护资源的客户端、Web应用。在上面的例子中的视频网站就是客户端应用;
  • 访问令牌:Access Token,授予对资源服务器的访问权限额度令牌;
  • 刷新令牌:客户端应用用于获取新的 Access Token 的一种令牌;
  • 客户凭证:用户的账号密码,用于在 授权服务器 进行验证用户身份的凭证。

3.4.3 OAuth2.0 有四种授权模式

授权码(Authorization Code)

授权码模式是指客户端应用先去申请一个授权码,然后再拿着这个授权码去获取令牌的模式。这也是目前最为常用的一种模式,安全性比较高,适用于我们常用的前后端分离项目。通过前端跳转的方式去访问授权服务器获取授权码,然后后端再用这个授权码访问授权服务器以获取访问令牌。

工作流程图

图片

第一步,客户端的前端页面(图中 UserAgent)将用户跳转到授权服务器(Authorization Server)里进行授权,授权完成后,返回授权码(Authorization Code)。

第二步,客户端的后端服务(图中 Client)携带授权码(Authorization Code)去访问 授权服务器,然后获得正式的访问令牌(Access Token)。

面的前端和后端分别做不同的逻辑,前端接触不到 Access Token,保证了 Access Token 的安全性。

简化模式(Implicit)

  • 简化模式是在项目是一个纯前端应用,在没有后端的情况下,采用的一种模式;
  • 因为这种方式令牌是直接存在前端的,所以非常不安全,因此令牌的有限期设置就不能太长。

工作流程图

图片

第一步,应用(纯前端的应用)将用户跳转到授权服务器(Authorization Server)里进行授权。授权完成后,授权服务器直接将 Access Token 返回给 前端应用,令牌存储在前端页面。

第二步,应用(纯前端的应用)携带访问令牌(Access Token)去访问资源,获取资源。

在整个过程中,虽然令牌是在前端 URL 中直接传递,但令牌不是放在 HTTP 协议中 URL 参数字段中的,而是放在 URL 锚点里。因为锚点数据不会被浏览器发到服务器,因此有一定的安全保障。

用户名密码(Resource Owner Credentials)

图片

这种方式最容易理解了,直接使用用户名、密码作为授权方式去访问授权服务器,从而获取 Access Token。

这个方式因为需要用户给出自己的密码,所以非常的不安全性。一般仅在客户端应用与授权服务器、资源服务器是归属统一公司、团队,互相非常信任的情况下采用。

客户端凭证(Client Credentials)

图片

这是适用于服务器间通信的场景。客户端应用拿一个用户凭证去找授权服务器获取Access Token。

4. 容器技术

4.1 为什么需要容器技术

传统的 PaaS 技术虽然也可以一键将本地应用部署到云上,并且也是采用隔离环境的形式去部署,但是其兼容性非常的不好。

因为其主要原理就是将本地应用程序和启停脚本一同打包,然后上传到云服务器上,然后再在云服务器里通过脚本启动这个应用程序。

这样的做法,看起来很理想。但是在实际情况下,由于本地与云端的环境差异,导致上传到云端的应用运行的时候经常各种报错,需要各种修改配置和参数来做兼容服务环境。甚至在项目迭代过程中不同的版本代码都需要重新去做适配,非常耗费精力。

然而以Docker为代表的容器技术却通过一个小创新完美的解决了这个问题。

在 Docker 的方案中,它不仅打包了本地应用程序,而且还将本地环境(操作系统的一部分)也打包了,组成一个叫做「 Docker镜像 」的文件包。所以这个「 Docker镜像 」就包含了应用运行所需的全部依赖,我们可以直接基于这个「 Docker镜像 」在本地进行开发与测试,完成之后,再直接将这个「 Docker镜像 」一键上传到云端运行即可。

Docker 实现了本地与云端的环境完全一致,做到了真正的一次开发随处运行,避免了类似“我在本地正常运行,传到云端就不可以了”的说辞。

4.2 什么是容器

先来看一下容器与虚拟机的对比区别:

图片

虚拟机是在宿主机上基于 Hypervisor 软件虚拟出一套操作系统所需的硬件设备,再在这些虚拟硬件上安装操作系统 Guest OS,然后不同的应用程序就可以运行在不同的 Guest OS 上,应用之间也就相互独立、资源隔离。但是由于需要 Hypervisor 来创建虚拟机,且每个虚拟机里需要完整的运行一套操作系统 Guest OS,因此这个方式会带来很多额外资源的开销。

Docker容器中却没有 Hypervisor 这一层,虽然它需要在宿主机中运行 Docker Engine,但它的原理却完全不同于 Hypervisor,它并没有虚拟出硬件设备,更没有独立部署全套的操作系统 Guest OS。

Docker容器没有那么复杂的实现原理,它其实就是一个普通进程而已,只不过它是一种经过特殊处理过的普通进程。我们启动容器的时候(docker run …),Docker Engine 只不过是启动了一个进程,这个进程就运行着我们容器里的应用。

但 Docker Engine 对这个进程做了一些特殊处理,通过这些特殊处理之后,这个进程所看到的外部环境就不再是宿主机的环境(它看不到宿主机中的其它进程了,以为自己是当前操作系统唯一一个进程),并且 Docker Engine 还对这个进程所使用得资源进行了限制,防止它对宿主机资源的无限使用。

对比下来就是,容器比虚拟机更加轻量级,花销也更小,更好地利用好主机的资源。

4.3 容器如何做到资源隔离和限制

Docker 容器对这个进程的隔离主要采用两个核心技术点 Namespace 和 Cgroups。

图片

总结来说就是,Namespace 为容器进程开辟隔离进程,Cgroups 限制容器进程之间抢夺资源,从此保证了容器之间独立运行和隔离。

Namespace 技术

Namespace 是 Linux 操作系统默认提供的 API,包括 PID Namespace、Mount Namespace、IPC Namespace、Network Namespace 等。

以 PID Namespace 举例,它的功能是可以让我们在创建进程的时候,告诉Linux系统,我们要创建的进程需要一个新的独立的进程空间,并且这个进程在这个新的进程空间里的 PID=1。也就是说这个进程只看得到这个新进程空间里的东西,看不到外面宿主机环境里的东西,也看不到其它进程。

不过这只是一个虚拟空间,事实上这个进程在宿主机里 PID 该是啥还是啥,没有变化,只不过在这个进程空间里,该进程以为自己的 PID=1。

打个比方,就像是一个班级,每个人在这个班里都有一个编号。班里有 90 人,然后来了一位新同学,那他在班里的编号就是 91。可是老师为了给这位同学特别照顾,所以在班里开辟了一块独立的看不到外面的小隔间,并告诉这个同学他的编号是 1。由于这位同学在这个小空间里隔离着,所以他真的以为自己就是班上的第一位同学且编号为 1。当然了,事实上这位同学在班上的编号依然是 91。

Network Namespace 也是类似的技术原理,让这个进程只能看到当前 Namespace 空间里的网络设备,看不到宿主机真实情况。同理,其它 Mount、IPC 等 Namespace 也是这样。Namespace 技术其实就是修改了应用进程的视觉范围,但应用进程的本质却没有变化。

不过,Docker容器里虽然带有一部分操作系统(文件系统相关),但它并没有内核,因此多个容器之间是共用宿主机的操作系统内核的。这一点与虚拟机的原理是完全不一样的。

Cgroups 技术

Cgroup 全称是 Control Group,其功能就是限制进程组所使用的最大资源(这些资源可以是 CPU、内存、磁盘等等)。

既然 Namespace 技术 只能改变一下进程组的视觉范围,并不能真实的对资源做出限制。那么为了防止容器(进程)之间互相抢资源,甚至某个容器把宿主机资源全部用完导致其它容器也宕掉的情况发生。因此,必须采用 Cgroup 技术对容器的资源进行限制。

Cgroup 技术也是 Linux 默认提供的功能,在 Linux 系统的 /sys/fs/cgroup 下面有一些子目录 cpu、memory 等。Cgroup 技术提供的功能就是可以基于这些目录实现对这些资源进行限制。

例如,在 /sys/fs/cgroup/cpu 下面创建一个 dockerContainer 子目录,系统就会自动在这个新建的目录下面生成一些配置文件,这些配置文件就是用来控制资源使用量的。例如可以在这些配置文件里面设置某个进程ID对CPU的最大使用率。

Cgroup 对其它内存、磁盘等资源也是采用同样原理做限制。

4.4 什么是容器镜像

一个基础的容器镜像其实就是一个 rootfs,它包含操作系统的文件系统(文件和目录),但并不包含操作系统的内核。rootfs 是在容器里根目录上挂载的一个全新的文件系统,此文件系统与宿主机的文件系统无关,是一个完全独立的,用于给容器进行提供环境的文件系统。

对于一个Docker容器而言,需要基于 pivot_root 指令,将容器内的系统根目录切换到rootfs上。这样,有了这个 rootfs,容器就能够为进程构建出一个完整的文件系统,且实现了与宿主机的环境隔离。也正是有了rootfs,才能实现基于容器的本地应用与云端应用运行环境的一致。

另外,为了方便镜像的复用,Docker 在镜像中引入了层(Layer)的概念,可以将不同的镜像一层一层的迭在一起。这样,如果我们要做一个新的镜像,就可以基于之前已经做好的某个镜像的基础上继续做。

图片

如上图,这个例子中最底层是操作系统引导。往上一层就是基础镜像层(Linux 的文件系统),再往上就是我们需要的各种应用镜像,Docker 会把这些镜像联合挂载在一个挂载点上,这些镜像层都是只读的。只有最上面的容器层是可读可写的。

这种分层的方案其实是基于联合文件系统 UnionFS(Union File System)的技术实现的。它可以将不同的目录全部挂载在同一个目录下。

举个例子,假如有文件夹 test1 和 test2 ,这两个文件夹里面有相同的文件,也有不同的文件。然后我们可以采用联合挂载的方式,将这两个文件夹挂载到 test3 上,那么 test3 目录里就有了 test1 和 test2 的所有文件(相同的文件有去重,不同的文件都保留)。

这个原理应用在 Docker 镜像中。比如有 2 个同学,同学 A 已经做好了一个基于 Linux 的 Java 环境的镜像,同学 S 想搭建一个 Java Web 环境,那么他就不必再去做 Java 环境的镜像了,可以直接基于同学 A 的镜像在上面增加 Tomcat 后生成新镜像即可。

4.5 容器技术在微服务的实践

随着微服务的流行,容器技术也相应的被大家重视起来。容器技术主要解决了以下两个问题。

环境一致性问题

例如 Java 的 jar/war 包部署会依赖于环境的问题(操着系统的版本,JDK 版本问题)。

镜像部署问题

例如 Java、Ruby、NodeJS 等等的发布系统是不一样的,每个环境都得很麻烦的部署一遍,采用 Docker 镜像,就屏蔽了这类问题。

部署实践

下图是 Docker 容器部署的一个完整过程:

图片

更重要的是,拥有如此多服务的集群环境迁移、复制也非常轻松,只需选择好各服务对应的 Docker 服务镜像、配置好相互之间访问地址就能很快搭建出一份完全一样的新集群。

4.6 容器调度

目前基于容器的调度平台有 Kubernetes(K8S)、Mesos、Omega。

总结

本文主要介绍了微服务架构下的服务监控、容错隔离、访问安全以及结合容器技术实现服务发布和部署。初窥了微服务架构的模块,相信对微服务架构会有所帮助。再深入就需要有针对性的技术实践才能加深了解。

转自:Joyo,

链接:joyohub.com/micro-server-pro/

[转]一张“无脑”清单告诉你分布式系统代码有多复杂

https://mp.weixin.qq.com/s/abohPG3ACJD34BsGszvrNA

开篇

微服务架构在当今的软件工程领域被广泛采用。同时,采用分布式架构的组织也发现需要考虑分布式故障的附加复杂性,而这种复杂性往往超出实际业务逻辑。

虽然分布式计算的谬误是有据可查的,但对于组织而言并不是一件容易的事情。因此,构建大规模、可靠的分布式系统架构就成为一个难题。作为推论,当我们将网络交互的复杂性引入其中时,在原先非分布式系统中看起来很好的代码就有可能成为一个大问题。

在生产代码中摸爬滚打几年后,遭遇了各种故障模式并且发现导致故障的根源之后,我逐渐能够识别一些更常见的故障模式。由于不同公司以及使用不同的语言堆栈之间存在差异(取决于内部基础设施和工具的成熟度),但是可以从产生问题的原因中总结出一些具有共性的经验。

下面就是我从这些经验中总结出来的一些代码审查指南,这个指南可以形成一份清单,并用来审查分布式环境中与系统间通信相关的代码。虽然这份清单上提到的问题并不适用所有情况,但它们覆盖了代码审查的基本面,可以按照这个清单将问题走查一遍,在此过程中标记缺失的项目以供进一步讨论,利用这种方式发现系统中的问题是非常行之有效的。从这个意义上来说,可以通过这个“无脑清单”来发现大多数问题。

图片

如何调用远程系统

1、当远程系统发生故障时会发生什么?

无论系统设计的多么谨慎,它都会出现故障 – 这是在生产中被印证的事实。故障的发生可能源于代码错误,基础设施问题,流量激增,系统疏于管理等,总之结果是引发故障。调用者如何处理故障将决定整个架构的弹性和健壮性。

定义错误处理路径:必须在代码中明确错误处理路径,而不是让系统在最终用户面前崩溃。这里需要向用户明确指出错误,例如:设计良好的错误页面、带有错误信息的异常日志,以及带有回退机制的断路器等。

制定恢复计划:考虑代码中的每一次远程交互,并弄清楚如何恢复被中断的工作。思考如下价格问题:工作流程是否需要有状态才能从故障点触发?是否将所有失败的有效请求发布到重试队列/数据库表,并在远程系统恢复时重试请求?是否有脚本来比较两个系统的数据库并以某种方式使它们同步?在部署系统之前,是否有一个明确的系统的恢复计划?

2、当远程系统变慢时会发生什么?

这种情况比彻底失败更难办,因为我们不知道远程系统是否在工作。因此需要检查以下事项从而处理这种情况。如果我们使用类似 Istio 的服务网格技术,其中一些问题可以轻松搞定而不需要修改应用程序代码。即便如此,我们也应该关注这些问题。

为远程系统调用设置超时:这包括远程 API 调用、事件发布和数据库调用的超时时间。我在很多代码中发现过这个问题,因此需要检查远程系统是否设置了合理的超时时间,从而避免该系统在无响应时调用者因为等待而浪费资源的情况发生。

超时重试:网络和系统并不是100%可靠的,重试对于系统恢复是非常必要的。重试机制会消除系统交互中的许多“问题”。如果可能,在重试中使用某种补偿机制(固定的、指数的)。在重试机制中添加一点抖动(这里的抖动可以理解为随机重试,例如设置随机的重试时间3-5s重试一次,避免所有调用者一起不断地对被调用者进行重试,导致被调用者的负载增大),这样做可以给被调用系统一些喘息的空间,通过能够保证调用者在负载下获得更好的调用成功率。重试的另一面是幂等性,我们将在本文后面介绍。

使用断路器:一些应用程序并没有预先打包这个功能,但我看到公司内部会编写自己的包装器。如果你有这个需求,一定要实现它,对断路器的投入会让你获益。它会提供明确的框架来定义错误情况下的回退策略。

不要把超时当作请求失败来处理——超时不是失败,而是一种不确定的场景,应该通过一种处理方式来应对这种不确定性。因此需要建立明确的处理机制,允许系统在发生超时的情况下进行同步。处理机制可以是简单的协调脚本,也可以是有状态的工作流,或者是通过死信队列(消息被拒绝、消息TTL过期、队列达到最大长度)实现。

不要在事务中调用远程系统——当远程系统访问速度变慢时,依旧会长时间保持数据库连接,如果访问持续而因为速度的问题一直无法完成系统的访问,会导致数据库的连接也无法释放,也就将数据库连接用完,最终造成系统中断的后果。

使用智能批处理:如果处理大量数据请求,可以逐个进行批量远程调用(API 调用、数据库读取)从而消除网络开销。每个批量处理的量越大,整体延迟就会越大,可能失败的工作单元也会越多。因此需要针对性能和容错性优化批量大小。

如何面对调用方请求

所有 API 必须保证幂等性:幂等性是为了实现调用方 API 的超时重试功能。只有 API 能够支持安全重试且不会有副作用时,调用者才能安心使用重试功能。这里的 API 是指同步 API 和任何消息传递接口——调用者可能会发布两次相同的消息(或者代理可能会发送两次)给到该 API。

图片

明确定义响应时间和吞吐量 SLA 以及遵守定义的规则:在分布式系统中,快速失败比让调用者等待要好得多。诚然,吞吐量 SLA 很难实现(分布式速率限制一个难题),但我们需要确保 SLA 在主动呼叫失败时做好准备。另一个重要方面是了解下游系统的响应时间,以确定系统最快的速度。

定义和限制批处理 API:如果公开批处理 API ,则应明确定义最大批处理的数量,这个数量需要受到 SLA 的限制,也就是需要遵守 SLA 的规则定义。

预先考虑可观察性:可观察性意味着能够分析系统的行为,而无需通过查看API或组件的内部来实现。预先考虑你关心的系统指标以及需要收集的数据,帮助你回答以前未提出的问题。再对系统进行检测并获得这些数据。执行此操作的一个强大机制是识别系统的域模型,当域中发生某个事件时进行发布事件的操作。(例如收到请求id 123,返回请求 123 的响应——注意如何使用这两个“域”事件会导出一个称为“响应时间”的新指标。将原始数据转换到预先确定的聚合中)。

一般性原则

尽量使用缓存:网络变化无常,因此尽可能多地使用缓存,并不断讲最新的数据保存其中。当然,有可能会使用远程缓存机制(例如,Redis服务器运行在单独的服务器上),但至少通过缓存的方式可以将数据带入控制域并减少系统的负载。

考虑单元故障:如果一个 API 或一条消息代表多个工作单元(批处理),那么需要思考单元故障意味着什么?如果有效载荷都失败一次意味着什么?又或者单个单元独立成功或失败意味着什么?部分成功呢,API 是否响应成功或失败代码?

这里的意思是一个 API 调用多个工作单元,这里的工作单元可以是一个组件或者是一个 API 。有可能在调用多个工作单元的时候,其中一个工作单元失败了,或者有的工作单元成功了,这个时候作为最外层调用这些工作单元的 API 来说要考虑好是成功还是失败,如果失败如何返回失败信息。

在系统边缘隔离外部域对象:不允许以重用的名义在系统中使用其他系统的域对象。这将会加剧我们的系统与其他系统的实体建模的耦合,在其他系统发生更改时,我们的系统都会进行大量重构。我们应该始终构建自己的实体表示并将外部有效负载转换为此我们系统内的模式,然后我们的系统中使用它。

安全性

在每个边缘清理输入:在分布式环境中,系统的任何部分都可能受到损害(从安全角度来看)。因此,在系统边界处会对进入系统的数据进行“消毒”处理,这里有一个假设就是这些进入系统的数据有可能不是干净或安全的。

永远不要提交凭证(Credentials):永远不要将凭证(数据库用户名/密码或 API 密钥)提交到代码库。虽然提交凭证到代码库对于某些人来说是常规操作,但我们需要摒弃这种陋习。始终遵守“凭证必须始终从外部(有安全存储保证)加载到系统”的规则。

译者介绍

崔皓,51CTO社区编辑,资深架构师,拥有18年的软件开发和架构经验,10年分布式架构经验。曾任惠普技术专家。乐于分享,撰写了很多热门技术文章,阅读量超过60万。《分布式架构原理与实践》作者。

原文链接:
https://kislayverma.com/programming/code-review-checklist-for-distributed-systems/

[转]向死而生:面向失败设计之道、术、技

一、序

1.1 从两个故事说起

2015 年 5 月,杭州市萧山区某地光缆被挖断,某公司支付软件受到影响,用户反复登录却无法使用,一时间#XXX炸了#成为微博热词;2021 年 7 月 ,某视频网站深夜宕机,各系产品所有功能似乎全崩,直至次日凌晨才恢复服务。这两个故事,导致吃瓜群众对企业技术实力产生了质疑和误解,影响颇深……

1.2 关于我

讲完两个故事,说说我自己,前抖音电商 C 端营销&大促方向 POC,阿里巴巴 2020 年货节&后年货节大促集团技术总执行 PM,广告和电商领域六年后端开发经验,久经大数据量、高并发、巨额资金场景下的技术考验。

1.3 关于选题

从两个故事可以看出,对于失败场景考虑不充分对于企业声誉的打击有多大。站在程序员个体角度,面向失败设计对于个人的影响也同样巨大,企业的事故责任终究要落到程序员个人头上,而事故也往往会消耗组织对于个人的信任,直接或者间接地影响个人的发展。在字节跳动,事故对个人的影响不算太大,但在其他一些公司,一次事故往往意味着程序员“一年白干”。

不同年限的程序员差异到底在哪里?这个问题,我的理解是,除了架构设计能力、项目管理能力、技术规划能力、技术领导力之外,面向失败设计能力也是极其重要的一环。

业务开发的新同学有时候可能会有迷之自信,觉得自己写的代码与老鸟们没有什么不同。实际上,编写正常流程的业务代码大家的差异不会太大,但是针对异常、边界、不确定性的处理才真正体现一个程序员的功力。老鸟们往往在长期的训练下已经形成多种肌肉记忆,遇到具体问题就会举一反三脑海里冒出诸多面向失败的设计点,从而写出高可用的业务代码。如何去学习面向失败设计的方法论,并慢慢形成自己独有的肌肉记忆,才是新手向老鸟蜕变的康庄大道。

基于这样的考量,我写了这篇文章,对自己这些年来的一些经验和教训做了一些总结,希望能够抛砖引玉,让更多的老鸟们把自己的经验 share 出来,相互学习共同进步。

二、道

道的层面,我想讲讲面向失败设计的世界观。

2.1 失败无处不在

理想中,机器硬件永不老化、系统软件永不过期、流量总在预期范围内、自己写的代码没有 bug、产品经理永不改需求,但现实往往给你饱以老拳,给你社会的毒打:硬件一定会在某个时间点故障、软件总在一个时间节点跟不上时代潮流、流量总在你意想不到的时候突增——即使你在婚礼上、没有程序员不写 bug、产品经理不但天天改需求,甚至还给你提自相矛盾或者存在逻辑漏洞的需求。

图片

无论是在传统软件时代还是在互联网、云时代,系统终究会在某个时间点失败。面向失败设计不是消除失败,而是减少乃至消除失败造成的影响,守着企业和个人的钱袋子。

2.2 唯一不变的是变化

不但失败无处不在,变化也无处不在。

2.2.1 不要写死——你的 PM 为改需求而生

“不要写死|你的 PM 为改需求而生”,这句话是我对口的一个产品经理的飞书个性签名,它深得我心。永远对代码写死保持不安,根据墨菲定律,你越是认为不会改变的字段或功能,就越会发生改变。所以,多配置、少写死,让你在产品改需求时快速响应从而令别人刮目相看,也能让你在发生故障时有更多的手段做快速恢复。

2.2.2 隔离可变性——程序员应软件变化而生

如果系统软件永不变化,我们还需要设计模式么?还需要面向对象么?面向过程一把梭不是又快又好么?但是,永不变化的系统软件,要程序员何用?抖音已经如此强大,什么都不改也能给字节挣很多钱,那抖音的程序员都可以下岗了么?好像并非如此。

设计模式,是前辈们总结的应对变化的利器。23 种设计模式,一言以蔽之,曰:隔离可变性。无论是创建型模式,还是结构性模型,又或者是行为型模式,设计的目的都是为了把变化关进设计模式的笼子里。

2.2.3 定期回归——功能在演化中变质

定期回归,也是应对失败的重要原则。互联网的迭代实在是太快了,传统软件往往以年月为维度迭代,而互联网往往以周乃至日迭代。每一天,系统的功能都可能在演化中变质,快速的迭代不但让业务代码迅速腐化变成屎山,也让内部逻辑日益臃肿,乃至相互冲突。终有一天,原本运行良好无 bug 的代码,会变成事故的导火索。

2.3 对代码的世界保持警惕

对代码的世界保持警惕吧,不然总有一天你会经历血泪教训。

2.3.1 不要相信合作方的“鬼话”

对合作方给你的所有接口、方案保持怀疑,也不要相信合作方任何一个未经你亲身验证的论断。实践才是检验真理的唯一标准,对世界始终保持怀疑是工程师的核心素质。不要在出现故障之后跟合作方相互甩锅时才追悔莫及,前期多做些验证,保护了你也保护了他,更是保护了你们之间的塑料友情。

2.3.2 不要相信代码注释

一行错误的代码注释,把我从阿里带到了字节,亲身经历的血泪教训。错误的代码注释不如没有注释,不要再用错误的注释给后来人埋坑了,救救孩子吧。

2.3.3 不要相信函数输入

NPE(NullPointerException 空指针异常)也许是程序员职业生涯中遇到过的最多的错误,这一点颇令人困惑,因为程序员从刷 LeetCode 第一道题开始,就知道需要对函数参数做检查。

之所以出现这样的结果,是因为线上生产环境所能遭遇的场景远比一道代码题复杂,这其实也是工业界与学术界的区别,学术界的问题是确定的,工业界的问题是不确定的。即使上游传递参数的是一个你认为极为可靠的系统,即使你遍览程序上下文确定不会出现空参数,也最好去做一些防御性的设计,因为可靠的系统也会给你返回不合规范的参数,当前不存在空参数的代码在未来的某一天也会被改得面目全非。

2.3.4 不要相信基础设施

即使是支付宝也会崩溃,即使是可用性 6 个 9 的系统,全年也有 31 秒中断。不要相信基础设施,做好灾备,搞好混沌工程,才能让你每个晚上睡得安稳,避免被报警电话打醒。

2.4 设计原则

2.4.1 简洁的方案最优雅

如果你设计的技术方案没有太多的花里胡哨,整体透露着一种大道至简的美感,也许你就离成功很近了。简洁的方案代表着更小的理解成本、更小的维护成本、更好的扩展性。

如果你的方案里面到处都是花里胡哨的炫技,看起来复杂而严谨,那么也许你离让自己头疼也让别人头疼不远了,一顿操作猛如虎,一看月薪两千五。

当然,并不是最简洁的方案就是最合适的方案,举个栗子,核心交易链路的服务必然会比数据展示的服务稳定性要求更高,因而做了较多高可用设计之后方案会更加复杂,因而在满足稳定性的前提下选用尽可能简洁的方案才是推荐的做法。

2.4.2 开闭原则是设计模式的总纲

开闭原则是设计模式的总纲,大部分设计模式里面都有开闭原则的影子,软件实体应当对扩展开放,对修改关闭,可以通过“抽象约束、封装变化”来实现开闭原则。开闭原则可以使软件实体拥有一定的适应性和灵活性的同时具备稳定性和延续性。

基于开闭原则,很多常见的设计问题都有了答案:

(1)大量 if-else 的屎山代码问题。 大量的 if-else 肯定是不符合开闭原则的,每一个 if-else 的代码支路都是对原有代码结构的破坏,这里就可以应用工厂+策略设计模式对 if-else 进行剥离,把逻辑的新增和修改限制在工厂模式子类的内部。

(2)冗长的业务工作流处理问题。 业务流程代码往往非常冗长,封装得不好的话阅读和维护代码都非常困难,可以考虑用命令+职责链设计模式对工作流做封装。封装的好处在于,整体的工作流读起来将非常清晰,主流程代码往往能从数百行精简到十行以内,并且,对流程的修改仅仅是简单的断链或者增加链节点的操作,从而把修改的影响减到最低。

(3)历史字段类型修改问题。 互联网开发过程中经常需要修改历史字段的类型,根据开闭原则,我们不该去修改原有字段的类型,而应该新增一个字段,这样才能保证对上下游链路的影响最小。

(4)对象属性中途篡改问题。 举个实际的业务场景,在某些业务请求中,抖音极速版需要做与抖音相同的处理,把抖音极速版的 APPID 改成抖音的 APPID 是最简单的方法,但是这种做法是不符合开闭原则的,对对象属性中途的篡改,会改变对象在程序中的语义,总有一天它会有不符合预期的表现,很多事故因此而起。正确的做法是,在上下文中传递一个新的字段,下游的每一步处理都可以选择正确的字段做正确的处理,而不会被中途篡改的字段蒙蔽。

2.4.3 懒惰是程序员最大的美德

懒惰是程序员最大的美德,好的程序员往往是默默无闻的,越是在团队里面滋哇乱叫到处救火刷存在感的程序员越可能是团队的慢性毒药。

为了让自己懒惰,安安稳稳躺平就把业务做好,程序员必须掌握平台化、工具化、自动化三板斧。平台化,把程序员从无穷尽的重复劳动中解救出来;工具化,把程序员从水深火热的人肉运维和 oncall 中解救出来;自动化,让程序如流水线般顺滑,从而提升程序员的人效。能将这三板斧挥舞到什么层次,也体现了程序员能力到达了什么层次。有了平台化、工具化、自动化,就可以做标准化、规模化,助力公司和业务持续往上走。

三、术

术的层面,我想讲讲在组织和流程角度如何面向失败设计。

3.1 组织

3.1.1 面向失败设计的工种

测试工程师、测试开发工程师、风控&安全合规工程师都是开发工程师最可靠的合作伙伴,也是企业为了面向失败设计而设置的工种。

测试工程师是软件质量的把关者,他们是线上质量的卫士,对开发工程师代码的质量和性能负责。测试开发工程师是一个技术型的软件测试工种,除了做常规的测试工作之外,还会写一些测试工具和自动化脚本,用自动化的手段来提高测试的质量和效率。风控和反作弊工程师对业务的生态负责,监测业务的异常问题,提高业务风控的效果。安全合规工程师,则是对信息安全负责,能够对于项目提供合规咨询、信息安全风险评估。

3.1.2 面向失败设计的组织形式

安全生产小组是一种面向失败设计的组织形式。安全生产小组往往是横向的技术团队,对多个业务团队提供规范制定和推行、生产过程管控、事故复盘组织等技术支持,为线上质量负责,通常还会在每个业务团队设置系统稳定性负责人,作为接口人来有效推行他们制定的制度。

结对编程,也是一种面向失败设计的组织形式。严格意义的结对编程,要求两个程序员在一个计算机上共同工作。一个人输入代码,而另一个人审查他输入的每一行代码。结对编程可以让程序员写出更短的程序,更好的设计,以及更少的缺陷,同时,结对编程也可以促进知识的传播,让新人快速进步,也让老人在带新的过程中总结自己的知识和经验,还可以规避在相应开发人员请假或者离职带来的工作交接的问题。

严格意义的结对编程,在互联网行业极为罕见,很少有团队会真正这样实操,也许是因为在管理者看来,两个人干同一件事情大大增加了人力的成本。但是,结对编程的一些思想和理念,也值得我们借鉴,比如我们可以让两个程序员结对做业务 owner,互为 backup,相互 code review,从而在一定程度上获得结对编程的好处。

3.2 流程

假设不做面向失败设计,那么软件开发流程也许可以简化为编码+发布两步。但是成熟企业的开发流程大致如下:

图片

需求提出阶段,需要先期做一些合规评估、反作弊评估、安全评估,在前期就把一些潜在的安全合规风险排除。

编码阶段,在设计技术方案时需要考虑止血/降级/回滚措施,并组织技术评审和安全技术评审,针对技术方案中的安全风险做一些评估。除此之外,最好做一些单元测试,可以大大提高代码的质量。

测试阶段,需要开发人员先做自测,再让测试工程师参与功能测试、安全工程师做安全检查,针对代码改动可能造成的额外影响,做好做一次更大范围的回归测试,以排除一些预期外的影响。

发布阶段,需要采用灰度发布的机制,先发布小部分机器,或者仅针对部分地区用户灰度,在灰度发布之后做灰度测试验证功能正常,在继续分批发布、全量发布。

验证阶段,可以让测试同学在发布完成之后做一次线上回归,保证功能在线上环境稳定可用。对于大型活动,往往还需要组织内部用户线上预演或众测。针对非预期内流量可能把系统打挂的风险,可以做单链路压测和全链路压测。在大型活动开始前,如果条件允许,或者在小范围做一次线上试玩,提前暴露一些风险。

运行阶段,需要开发人员做好监控报警和离在线数据对账。对于项目的效果,可以用 AB 测试来量化收益。

故障发生时,第一时间必须做好故障快速恢复,尽可能减少线上损失,之后再考虑定位故障原因。

在项目结束或者故障处理结束之后,需要组织一次有效的复盘,并对过程中的问题做一些总结,形成有效的改进方案,并持续跟进改进方案的落地

3.3 一些观点

3.3.1 测试同学的重要性,怎么吹都不为过

测试工程师是线上质量最重要的卫士,他们的重要性,怎么吹都不为过。一个优秀的测试同学,可以做到以下事情:

  • 非黑盒测试,具备读懂开发代码的能力,根据代码针对性地设计测试用例
  • 设计完备的测试用例,覆盖所有测试场景
  • 编写数据对账脚本,能够做离线数据对账和实时数据对账
  • 编写自动化测试工具
  • 编写数据一致性监控脚本、资损防控工具

3.3.2 单元测试最省时间

编写单元测试用例,看似费时间,实则是最省时间的做法。单元测试保证了代码的行为与我们期望一致,从而省下了大量的发布、自测、联调、修改代码的返工时间,另外,可以做单元测试的代码往往职责更加清晰、分层分块更加合理、稳定性更好。

3.3.3 复盘是对齐做事高标准的一个必要方式

复盘是不断优化组织,对齐做事高标准的一个必要方式。通过 PDCA(Plan-Do-Check-Action,戴明环)这样的一个循环,工作在不断的改善后,最终形成知识沉淀,作用于下一次计划执行,团队于是变得越来越有执行力,个人则成为 Better Me。

3.3.4 研发红线是程序员的保护伞

研发红线是企业面向失败设计行之有效的暴力机器,它由无数零件(规范和条目)组成、冰冷、机械、运行起来无法阻挡,不以个人意志为转移。研发红线强制要求程序员遵守企业的流程和规范,警告程序员不犯低级错误,看似冰冷无情,实则是程序员的保护伞。

四、技

在技的层面,我想谈谈面向失败设计的具体技术细节。但是技术细节实在太多,限于篇幅,此处只列举一些经典技术问题的解法。

4.1 将面向失败当做系统设计的一部分

  • 针对非预期流量,可以做系统限流、系统过载保护、自适应扩缩容;
  • 针对依赖服务超时或错误,需要对依赖系统设置超时时间,并对所有依赖做强弱依赖梳理,关键时刻降级非核心依赖;
  • 针对预期外的情况,可以提前准备好紧急预案,并做好预案演练;
  • 针对瞬时高流量,需要敏锐地判断系统的极限,做好流量打散,并避免 DB 和缓存热 key;
  • 针对可能出现的机房问题,做好同城双(多)活和异地多活;
  • 针对人为失误,可以使用平台化、工具化、自动化的方法减少人肉操作;
  • 避免出现单点问题,做冗余设计来降低局部失败对系统的影响;
  • 失败重试时需谨慎,避免踩踏雪崩;
  • 故障只能减少,不能消除,做好监控报警、故障演练、攻防演练,锤炼风险应急能力;

4.2 分布式锁的六个层次

你只看到了第二层,你把我想成了第一层。实际上,我在第五层。

——芜湖大司马

Redis 实现分布式锁有六个层次,看看大家平常用的分布式锁处在第几个层次。

分布式锁设计原则:

  • 互斥性。在任意时刻,只有一个客户端持有锁。
  • 不死锁。分布式锁本质上是一个基于租约(Lease)的租借锁,如果客户端获得锁后自身出现异常,锁能够在一段时间后自动释放,资源不会被锁死。
  • 一致性。硬件故障或网络异常等外部问题,以及慢查询、自身缺陷等内部因素都可能导致 Redis 发生高可用切换,replica 提升为新的 master。此时,如果业务对互斥性的要求非常高,锁需要在切换到新的 master 后保持原状态。

层次一:

redis.SetNX(ctx, key, "1")
defer redis.del(ctx, key)

使用 SetNx 命令,可以解决互斥性的问题,但不能做到不死锁。

层次二:

redis.SetNX(ctx, key, "1", expiration)
defer redis.del(ctx, key)

使用 lua 脚本保证 SetNX 与 Expire 的原子性,做到了不死锁,但是做不到一致性。

层次三:

redis.SetNX(ctx, key, randomValue, expiration)
defer redis.del(ctx, key, randomValue)

// 以下为del的lua脚本
if redis.call("get",KEYS[1]) == ARGV[1] then
   return redis.call("del",KEYS[1])
else
   return 0
end

分布式锁的值设定一个随机数,删除时只删除当前线程/协程抢到的锁,避免在程序运行过慢锁过期时删除别的线程/协程的锁,能做到一定程度的一致性。

层次四:

func myFunc() (errCode *constant.ErrorCode) {
    errCode := DistributedLock(ctx, key, randomValue, LockTime)
    defer DelDistributedLock(ctx, key, randomValue)
    if errCode != nil {
       return errCode
    }
    // doSomeThing
}

func DistributedLock(ctx context.Context, key, value string, expiration time.Duration) (errCode *constant.ErrorCode) {

   ok, err := redis.SetNX(ctx, key, value, expiration)
   if err == nil {
      if !ok {
         return constant.ERR_MISSION_GOT_LOCK
      }
      return nil
   }

   // 应对超时且成功场景,先get一下看看情况
   time.Sleep(DistributedRetryTime)
   v, err := redis.Get(ctx, key)
   if err != nil {
      return constant.ERR_CACHE
   }
   if v == value {
      // 说明超时且成功
      return nil
   } else if v != "" {
      // 说明被别人抢了
      return constant.ERR_MISSION_GOT_LOCK
   }

   // 说明锁还没被别人抢,那就再抢一次
   ok, err = redis.SetNX(ctx, key, value, expiration)
   if err != nil {
      return constant.ERR_CACHE
   }
   if !ok {
      return constant.ERR_MISSION_GOT_LOCK
   }
   return nil
}

// 以下为del的lua脚本
if redis.call("get",KEYS[1]) == ARGV[1] then
   return redis.call("del",KEYS[1])
else
   return 0
end


// 如果你的Redis版本已经支持CAD命令,那么以上lua脚本可以改为以下代码
func DelDistributedLock(ctx context.Context, key, value string) (errCode *constant.ErrorCode) {
   v, err := redis.Cad(ctx, key, value)
   if err != nil {
      return constant.ERR_CACHE
   }
   return nil
}

解决超时且成功的问题,写入超时且成功是偶现的、灾难性的经典问题。

还存在的问题是:

  • 单点问题,单 master 有问题,如果有主从,那主从复制过程有问题时,也存在问题
  • 锁过期然后没完成流程怎么办

层次五:

启动定时器,在锁过期却没完成流程时续租,只能续租当前线程/协程抢占的锁。

// 以下为续租的lua脚本,实现CAS(compare and set)
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("expire",KEYS[1], ARGV[2])
else
    return 0
end

// 如果你的Redis版本已经支持CAS命令,那么以上lua脚本可以改为以下代码
redis.Cas(ctx, key, value, value)

能保障锁过期的一致性,但是解决不了单点问题。

同时,可以发散思考一下,如果续租的方法失败怎么办?我们如何解决“为了保证高可用而使用的高可用方法的高可用问题”这种套娃问题?开源类库 Redisson 使用了看门狗的方式一定程度上解决了锁续租的问题,但是这里,个人建议不要做锁续租,更简洁优雅的方式是延长过期时间,由于我们分布式锁锁住代码块的最大执行时长是可控的(依赖于 RPC、DB、中间件等调用都设定超时时间),因而我们可以把超时时间设得大于最大执行时长即可简洁优雅地保障锁过期的一致性。

层次六:

Redis 的主从同步(replication)是异步进行的,如果向 master 发送请求修改了数据后 master 突然出现异常,发生高可用切换,缓冲区的数据可能无法同步到新的 master(原 replica)上,导致数据不一致。如果丢失的数据跟分布式锁有关,则会导致锁的机制出现问题,从而引起业务异常。针对这个问题介绍两种解法:

(1)使用红锁(RedLock)。红锁是 Redis 作者提出的一致性解决方案。红锁的本质是一个概率问题:如果一个主从架构的 Redis 在高可用切换期间丢失锁的概率是 k%,那么相互独立的 N 个 Redis 同时丢失锁的概率是多少?如果用红锁来实现分布式锁,那么丢锁的概率是(k%)^N。鉴于 Redis 极高的稳定性,此时的概率已经完全能满足产品的需求。

红锁的问题在于:

  • 加锁和解锁的延迟较大。
  • 难以在集群版或者标准版(主从架构)的 Redis 实例中实现。
  • 占用的资源过多,为了实现红锁,需要创建多个互不相关的云 Redis 实例或者自建 Redis。

(2)使用 WAIT 命令。Redis 的 WAIT 命令会阻塞当前客户端,直到这条命令之前的所有写入命令都成功从 master 同步到指定数量的 replica,命令中可以设置单位为毫秒的等待超时时间。客户端在加锁后会等待数据成功同步到 replica 才继续进行其它操作。执行 WAIT 命令后如果返回结果是 1 则表示同步成功,无需担心数据不一致。相比红锁,这种实现方法极大地降低了成本。

4.3 热点库存扣减

秒杀是非常常见的面试题,很多面试官上来就让面试者设计一个秒杀系统,面试者当然也是“身经百战”,很快可以给出熟背的“标准答案”。

但是,秒杀还是相对简单的热点库存扣减问题,因为扣减的库存量不大。更加典型的热点库存扣减问题是春节红包雨,同一个资金池数亿人抢红包。对于春节红包雨介绍两种方案:

方案一:

图片

存在问题:

  • 不同分桶之间,库存消耗不均,可能导致部分用户无法扣减库存,但其他用户可扣减库存,从而引发用户投诉。

方案二:

图片

小量多次地分派库存,从而缓解分桶库存消耗不均问题。

2021 年抖音春节红包,将用户进入的时间打散,减少瞬时请求峰值,也是一个很好的技术思路。

如何体现面向失败设计:

(1)为何用定时任务调度主动分配库存,而不是在分桶库存不足时被动拉库存?

答:因为主动分配库存 QPS 比被动拉库存低几个量级。

(2)如何应对超大流量?

答:流量不触达 DB、分桶、打散。

(3)Redis 库存总池为何不用某个 master 机器维护,而用定时任务调度随机挑选机器?

答:防单点。

五、跋

编程之美,蔚为大观。好的代码,往往结构清晰,表意明确,设计精巧,无论是读代码还是写代码都可以给程序员一种直击心灵的美感,甚至让读者爱不释手,让作者引以为傲,引之为自己的代表作。但是,为了留住这种美,我们还需要去做面向失败的设计,充分考虑失败场景,才能减少失败的概率,向死而得生。

本文对面向失败设计做了一些浅显的思考,欢迎探讨、补充和指正。

六、引

  1. 面向失败的设计-概述 https://developer.aliyun.com/article/726333
  2. 高性能分布式锁 https://help.aliyun.com/document_detail/146758.html

原文链接https://mp.weixin.qq.com/s/a-RA9hP400qUjcdsXxjSbg

【转】DDD 到底是银弹还是垃圾

[原文链接:https://mp.weixin.qq.com/s/PjNc7YLKT5JX_Obg2815Bg]

每过一段时间,就会有人跳出来批判 DDD,这东西到底是垃圾还是银弹?

在某某公司干活的时候,有一批人声称要用 DDD 改造老旧系统,彻底解决核心流程规模化之后,项目难以维护的问题。之前某篇文章里的这张图,就是在用 DDD 做项目重构之前的烂摊子:

图片

大家都很聪明,聪明到最后没人知道这新需求到底该往哪里写了。架构师们聚在一起学习 DDD 精神,产出学习报告,大半年过去,终于出了一些成果,有些子项目完成了用 DDD 进行的重构,年底可以拿来在酒会上邀功了,这下我们跟上了业界业务开发的主流方法论,可喜可贺,可喜可贺啊。

年末的时候部门内匿名提问的小纸条却向架构师们发直球:“为什么用了 DDD 以后,代码更难懂了?”,当时引得各位 DDD 推手尴尬无比,只能搪塞过去。

所以你觉得我是要批判么?那倒不是。

在某某司工作期间,到离职前,我把市面上所有 DDD 相关的书全部看了一遍。对其理论体系进行了完整的了解,可以说这套理论还是有些用处的,DDD 的理论诞生时间比较早,微服务的趋势是后来才爆发的。但微服务刚开始没有明确的拆分指导,人们发现 DDD 里的 bounded context 好像看着正好和服务的粒度是可以做个对应的,DDD 就成为了很多公司做业务的绝对主流方法论。

虽然很多技术人员不爱听,但是技术优劣和商业成败其实没什么必然的联系。同样的,方法论的对错和项目的成功与否也没有必然的关系。很多大公司做业务的人出来讲他们的技术方法论,这些人可能连自己的项目为啥成功都不一定知道,你指望能对你的场景产生直接帮助那可能是想多了。只是当听个乐,得个借鉴那可能还没什么问题。真的当金科玉律去执行,那撞一头包也正常。

DDD 和其它的工程方法论一样,没有办法证伪。放眼望去,纯粹堆砌人肉电池,不用 DDD 的项目也那么多成功的,大家的屁股还是在跟着公司的市值跑,哪家公司市值涨到中国第一了,那他们的技术就牛逼,这叫看市值决定价值观。如果一家公司靠 996 成功了,那 996 就是商业致胜的法宝,不学你就落后了。屁股可以决定脑袋嘛。

不过作为一个矜持的技术人员,我们在批判方法论的时候,还是应该要先对敌人有一些了解。

所以这一篇,我就简单带你们看看 DDD 里那些鬼名词都是什么意思。

战术设计与战略设计

整个 DDD 的方法论可以划分为两个大模块,战术设计、战略设计。这个你顾名思义,战术是小,战略是大。

  • 战术设计指的就是单模块级的设计,基本都是纯技术范畴的东西,只DDD 中给代码命名和模块设计给出了一些指导方法
  • 战略设计指的是大项目的模块拆分,这个和一线程序员关系不大,主要是公司内怎么在 bu 之间切蛋糕,bu 内怎么在 team 之间分赃

现在很多校招程序员可能或多或少都会碰到一些 OOP 方面的面试题,比如三大特性五大原则之类的,这些原则是设计项目的时候可以参考的原则, DDD 的战术设计就是在单模块上的各种命名规则和设计方法。只不过 OOP 这些原则的发明人(严格的说应该是汇总人)是 uncle bob,就是 《clean code》,《clean architecture》 的作者,这位白胡子爷爷大概率和 DDD 社区是尿不到一个壶里的,所以 《clean architecture》 这本书里只字未提 DDD。

公司的业务要怎么分派给不同的 bu(部门)去完成,这个一般是公司 CTO 或者 GM 要做的事情,部门内的项目要怎么分,哪些组做哪些事情。这是战略设计的范畴。DDD 声称战略设计也是要有方法的。这部分也是很多程序员认为最没用的一部分,我们后面来批判一下这些程序员。

战术设计

战术设计是纯技术范畴的东西,最让人头痛的就是里面的名词。

贫血模式和充血模式:DDD 推荐你用充血模式写代码,也就是按 OOP 的方式去做抽象,然后把行为挂在对象上,而不是以纯过程式 的方法去写代码。所谓的充血,就是对象本身有很多关联的行为,而不只是一个单纯的数据库的表的字段映射。DDD 声称的充血模式的优势是,大部分的行为被封装到了对象内部,这样我们在阅读流程代码的时候,是一目了然的,直接能看到 step 1,step 2,step 3。但实际即使我们不用 OOP 来组织行为,一样可以把不同的业务 step 做好封装和复用。有些公司的服务粒度拆的特别细,比如只有 5000-10000 行代码,在 DDD 里声称的充血模式的优势没有那么明显。

值对象和实体:这个也挺离谱的,值对象就是纯粹的数值、文本类型,比如:

type person struct {
  age int
  name string
}

就是值对象,如果我们给这个 person 加一个 id,让它能表示 person 的唯一性了;

type person struct {
  id  int
  age int
  name string
}

那它就是实体了。

这两个概念只是给我们日常用的对象们进行了一个简单的分类,没什么大用处。

聚合根:DDD 里所谓的聚合根是事务粒度的 entity,也就是说,如果我们对 db 进行存取,那么我们就需要有一个聚合根,如果在一个事务里需要操作多张表,那么就需要给多张表关联一个单独的聚合根。

图片

聚合根可以由一个 entity 组成,也可以由多个 entity 组成,就是你完成一个 db 事务的时候有多少关联的对象 ,那可能就有多少在同一个聚合根下面的 entity。

六边形架构:这个所谓的六边形架构,就是除了业务以外的所有外部变化都抽象成 adapter interface 做适配。如果你稍微理解一点点点依赖反转,那应该知道怎么样去做这种抽象。如果你一点都不了解,那我建议你去看看 go-micro 的代码。如果看不懂,建议还是尽早转行吧~

图片

六边形架构这东西主要是名字实在起的太奇怪,在 《clean architecture》那本书里,uncle bob 也给过一张图:

图片

《evolutionary architecture》这本书出自造词大本营 thoughtworks 的员工之手,里面有一个 plugin architecture,就是有些人特别喜欢说的插件化架构:

图片

Repo Pattern:DDD 理论认为我们业务项目的存储这一层是可能经常变化的,所以就专门存储层的 interface 设计单独拿出来,称为 Repo Pattern,这东西实在没啥可说的,find,getlist,save,你只要有一点点 orm 经验,里面有啥接口应该自己都可以默写出来。

事实是在 2021 年,我们的存储系统基本是不太可能做切换的了,即使切换,那些新兴的社区存储系统也会支持 MySQL 协议,基础设施想要侵入代码,那简单是大逆不道啊。

领域事件:其实就是做上下游解耦的 kafka message,我们用 domain event 显得会更洋气一些。

领域服务:Domain service,顾名思义,你认为是自己部门或者组内的局部 api gateway 也是可以的。

综上,如果你是在大公司一线工作了两三年的程序员,上面这些东西应该马上就能理解,没有啥值得说的。如果是为了去架构师大会上秀一秀,你总得包装一下让自己显得没那么土吧?

战略设计

Domain:领域,你们公司是干啥的,你都不知道吗?

Core Domain:你们公司的卖货的,那卖货就是你们与其它竞争对手的关键竞争环节。这就是核心域,就是核心业务,为啥聪明人都往核心业务挤?核心业务的汤也比边缘业务的饭好啊。

SubDomain:你们公司的卖货的,但是用户没法付钱,那也没法干,支付就是子领域。

Supporting Domain:你们公司是卖货的,但是客户想看一些指标,你总得有系统能支持吧?可能就是些写写 SQL 的系统。支持域。

Generic Domain:你管你们公司干什么呢?员工的在职离职,工资发放总得有系统能支持吧,这些就是通用域。

除了第一个 Domain ,其余四个 domain 重要性逐级递减,递减的意思是,如果公司要裁员,那是从下面往上面裁。

前面我说有些程序员觉得 DDD 战略设计没用,你连自己所在的组,从事的工作职责对于公司来说重不重要都不清楚,那被裁的时候也别哭哦。

统一语言:这个就更好理解了,比如跳水这个词,你说跳水的时候指的是这个:

图片

而你同事说跳水的时候指的是这个:

图片

这里你们聊的是工作,那说明你们一定不是在同一个上下文里工作,可能你们俩一个在体育赛事部门,另一个可能是在金融部门, DDD 认为可以用统一语言来进行领域划分工作。划分后在同一个上下文内,同一个名词大家说出来意思一致。这就是 Bounded Context,  ain。

既然拆分了,如果我们还在同一个 domain 内,那完成业务流程是需要协作的,这个不同 Context 的协作方式就叫 Context Maps 或者 Integration Type。

名词很恶心,但具体的方法就两种,两个微服务要么通过 RPC 通信,要么通过 MQ 通信。

如果通过 RPC 通信,那 callee 一般是 caller 的爹,很多时候 callee 挂了是要影响 caller 的(当然也有熔断之类的方法避免一起死)。

通过通过 MQ 通信,那上游一般是下游的爹,因为上游一个重构,下游们可能就都炸了,最终一致都是屁话,多少公司的最终一致都是靠人肉修的。

这种爹和儿子的关系就是 Conformist。如果爹能多考虑一下儿子的需求,那就是 Customer-Supplier 关系,毕竟顾客名义上还是上帝。如果跨系统有一些需要共享的定义,比如公司里的业务分类,可能大家都要从某个系统的 PHP 文件里解析出来在自己的系统里去用,那这时候可能得去使用别人的代码,这种叫 Shared-Kernel,Kernel 一改,大家一起死。

最后,有时候我们可以用一个叫 ACL 的东西拦住上游的一些修改对我们的业务逻辑侵入:

防腐层:Anti-Corruption-Layer,就是我要把外部系统的变化拦截在对接层,不要让别人的屎甩到我身上。

讲到这里,基本的概念我们已经都过一遍了,你要说 DDD 一点用处都没有,那我也是不同意的,至少看完了这些书,我知道去哪里能赚到更多的钱了。

额外再说一句,DDD 的书写的都不怎么样。

[转]Golang 调度器 GMP 原理与调度全分析

【原文链接 https://learnku.com/articles/41728】

第一章 Golang 调度器的由来

第二章 Goroutine 调度器的 GMP 模型及设计思想

第三章 Goroutine 调度场景过程全图文解析

一、Golang “调度器” 的由来?
(1) 单进程时代不需要调度器
我们知道,一切的软件都是跑在操作系统上,真正用来干活 (计算) 的是 CPU。早期的操作系统每个程序就是一个进程,直到一个程序运行完,才能进行下一个进程,就是 “单进程时代”

一切的程序只能串行发生。
早期的单进程操作系统,面临 2 个问题:

1. 单一的执行流程,计算机只能一个任务一个任务处理。

2. 进程阻塞所带来的 CPU 时间浪费。

那么能不能有多个进程来宏观一起来执行多个任务呢?

后来操作系统就具有了最早的并发能力:多进程并发,当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把 CPU 利用起来,CPU 就不浪费了。

(2) 多进程 / 线程时代有了调度器需求
在多进程 / 多线程的操作系统中,就解决了阻塞的问题,因为一个进程阻塞 cpu 可以立刻切换到其他进程中去执行,而且调度 cpu 的算法可以保证在运行的进程都可以被分配到 cpu 的运行时间片。这样从宏观来看,似乎多个进程是在同时被运行。

但新的问题就又出现了,进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,CPU 虽然利用起来了,但如果进程过多,CPU 有很大的一部分都被用来进行进程调度了。

怎么才能提高 CPU 的利用率呢?

但是对于 Linux 操作系统来讲,cpu 对进程的态度和线程的态度是一样的。
很明显,CPU 调度切换的是进程和线程。尽管线程看起来很美好,但实际上多线程开发设计会变得更加复杂,要考虑很多同步竞争等问题,如锁、竞争冲突等。

(3) 协程来提高 CPU 利用率
多进程、多线程已经提高了系统的并发能力,但是在当今互联网高并发场景下,为每个任务都创建一个线程是不现实的,因为会消耗大量的内存 (进程虚拟内存会占用 4GB [32 位操作系统], 而线程也要大约 4MB)。

大量的进程 / 线程出现了新的问题

高内存占用
调度的高消耗 CPU
好了,然后工程师们就发现,其实一个线程分为 “内核态 “线程和” 用户态 “线程。

一个 “用户态线程” 必须要绑定一个 “内核态线程”,但是 CPU 并不知道有 “用户态线程” 的存在,它只知道它运行的是一个 “内核态线程”(Linux 的 PCB 进程控制块)。

这样,我们再去细化去分类一下,内核线程依然叫 “线程 (thread)”,用户线程叫 “协程 (co-routine)”.

​ 看到这里,我们就要开脑洞了,既然一个协程 (co-routine) 可以绑定一个线程 (thread),那么能不能多个协程 (co-routine) 绑定一个或者多个线程 (thread) 上呢。

​ 之后,我们就看到了有 3 中协程和线程的映射关系:

N:1 关系
N 个协程绑定 1 个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1 个进程的所有协程都绑定在 1 个线程上

缺点:

某个程序用不了硬件的多核加速能力
一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。
1:1 关系
1 个协程绑定 1 个线程,这种最容易实现。协程的调度都由 CPU 完成了,不存在 N:1 缺点,

缺点:

协程的创建、删除和切换的代价都由 CPU 完成,有点略显昂贵了。
M:N 关系
M 个协程绑定 N 个线程,是 N:1 和 1:1 类型的结合,克服了以上 2 种模型的缺点,但实现起来最为复杂。

​ 协程跟线程是有区别的,线程由 CPU 调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后,才执行下一个协程。

(4) Go 语言的协程 goroutine
Go 为了提供更容易使用的并发方法,使用了 goroutine 和 channel。goroutine 来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被 runtime 调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。

Go 中,协程被称为 goroutine,它非常轻量,一个 goroutine 只占几 KB,并且这几 KB 就足够 goroutine 运行完,这就能在有限的内存空间内支持大量 goroutine,支持了更多的并发。虽然一个 goroutine 的栈只占几 KB,但实际是可伸缩的,如果需要更多内容,runtime 会自动为 goroutine 分配。

Goroutine 特点:

占用内存更小(几 kb)
调度更灵活 (runtime 调度)
(5) 被废弃的 goroutine 调度器
​ 好了,既然我们知道了协程和线程的关系,那么最关键的一点就是调度协程的调度器的实现了。

Go 目前使用的调度器是 2012 年重新设计的,因为之前的调度器性能存在问题,所以使用 4 年就被废弃了,那么我们先来分析一下被废弃的调度器是如何运作的?

大部分文章都是会用 G 来表示 Goroutine,用 M 来表示线程,那么我们也会用这种表达的对应关系。

下面我们来看看被废弃的 golang 调度器是如何实现的?

M 想要执行、放回 G 都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥 / 同步,所以全局 G 队列是有互斥锁进行保护的。

老调度器有几个缺点:

创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M’。
系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
二、Goroutine 调度器的 GMP 模型的设计思想
面对之前调度器的问题,Go 设计了新的调度器。

在新调度器中,除了 M (thread) 和 G (goroutine),又引进了 P (Processor)。

Processor,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。

(1) GMP 模型
在 Go 中,线程是运行 goroutine 的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上。

全局队列(Global Queue):存放等待运行的 G。
P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。
M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。
Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。

有关 P 和 M 的个数问题
1、P 的数量:

由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。
2、M 的数量:

go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略。
runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量
一个 M 阻塞了,会创建新的 M。
M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。

P 和 M 何时会被创建
1、P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。

2、M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。

(2) 调度器的设计策略
复用线程:避免频繁的创建、销毁线程,而是对线程的复用。

1)work stealing 机制

​ 当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程。

2)hand off 机制

​ 当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行。

利用并行:GOMAXPROCS 设置 P 的数量,最多有 GOMAXPROCS 个线程分布在多个 CPU 上同时运行。GOMAXPROCS 也限制了并发的程度,比如 GOMAXPROCS = 核数/2,则最多利用了一半的 CPU 核进行并行。

抢占:在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。

全局 G 队列:在新的调度器中依然有全局 G 队列,但功能已经被弱化了,当 M 执行 work stealing 从其他 P 偷不到 G 时,它可以从全局 G 队列获取 G。

(3) go func () 调度流程
从上图我们可以分析出几个结论:

​ 1、我们通过 go func () 来创建一个 goroutine;

​ 2、有两个存储 G 的队列,一个是局部调度器 P 的本地队列、一个是全局 G 队列。新创建的 G 会先保存在 P 的本地队列中,如果 P 的本地队列已经满了就会保存在全局的队列中;

​ 3、G 只能运行在 M 中,一个 M 必须持有一个 P,M 与 P 是 1:1 的关系。M 会从 P 的本地队列弹出一个可执行状态的 G 来执行,如果 P 的本地队列为空,就会想其他的 MP 组合偷取一个可执行的 G 来执行;

​ 4、一个 M 调度 G 执行的过程是一个循环机制;

​ 5、当 M 执行某一个 G 时候如果发生了 syscall 或则其余阻塞操作,M 会阻塞,如果当前有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除 (detach),然后再创建一个新的操作系统的线程 (如果有空闲的线程可用就复用空闲线程) 来服务于这个 P;

​ 6、当 M 系统调用结束时候,这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列。如果获取不到 P,那么这个线程 M 变成休眠状态, 加入到空闲线程中,然后这个 G 会被放入全局队列中。

(4) 调度器的生命周期
特殊的 M0 和 G0

M0

M0 是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了。

G0

G0 是每次启动一个 M 都会第一个创建的 gourtine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0。

我们来跟踪一段代码

package main

import “fmt”

func main() {
fmt.Println(“Hello world”)
}
接下来我们来针对上面的代码对调度器里面的结构做一个分析。

也会经历如上图所示的过程:

runtime 创建最初的线程 m0 和 goroutine g0,并把 2 者关联。
调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表。
示例代码中的 main 函数是 main.main,runtime 中也有 1 个 main 函数 ——runtime.main,代码经过编译后,runtime.main 会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。
启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境
M 运行 G
G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main 执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。
调度器的生命周期几乎占满了一个 Go 程序的一生,runtime.main 的 goroutine 执行之前都是为调度器做准备工作,runtime.main 的 goroutine 运行,才是调度器的真正开始,直到 runtime.main 结束而结束。

(5) 可视化 GMP 编程
有 2 种方式可以查看一个程序的 GMP 的数据。

方式 1:go tool trace

trace 记录了运行时的信息,能提供可视化的 Web 页面。

简单测试代码:main 函数创建 trace,trace 会运行在单独的 goroutine 中,然后 main 打印”Hello World” 退出。

trace.go

package main

import (
“os”
“fmt”
“runtime/trace”
)

func main() {

//创建trace文件
f, err := os.Create(“trace.out”)
if err != nil {
panic(err)
}

defer f.Close()

//启动trace goroutine
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()

//main
fmt.Println(“Hello World”)
}
运行程序

$ go run trace.go
Hello World
会得到一个 trace.out 文件,然后我们可以用一个工具打开,来分析这个文件。

$ go tool trace trace.out
2020/02/23 10:44:11 Parsing trace…
2020/02/23 10:44:11 Splitting trace…
2020/02/23 10:44:11 Opening browser. Trace viewer is listening on http://127.0.0.1:33479
我们可以通过浏览器打开 http://127.0.0.1:33479 网址,点击 view trace 能够看见可视化的调度流程。

G 信息

点击 Goroutines 那一行可视化的数据条,我们会看到一些详细的信息。

一共有两个G在程序中,一个是特殊的G0,是每个M必须有的一个初始化的G,这个我们不必讨论。
其中 G1 应该就是 main goroutine (执行 main 函数的协程),在一段时间内处于可运行和运行的状态。

M 信息

点击 Threads 那一行可视化的数据条,我们会看到一些详细的信息。

一共有两个 M 在程序中,一个是特殊的 M0,用于初始化使用,这个我们不必讨论。

P 信息
G1 中调用了 main.main,创建了 trace goroutine g18。G1 运行在 P1 上,G18 运行在 P0 上。

这里有两个 P,我们知道,一个 P 必须绑定一个 M 才能调度 G。

我们在来看看上面的 M 信息。

我们会发现,确实 G18 在 P0 上被运行的时候,确实在 Threads 行多了一个 M 的数据,点击查看如下:

多了一个 M2 应该就是 P0 为了执行 G18 而动态创建的 M2.

方式 2:Debug trace

package main

import (
“fmt”
“time”
)

func main() {
for i := 0; i < 5; i++ {
time.Sleep(time.Second)
fmt.Println(“Hello World”)
}
}
编译

$ go build trace2.go
通过 Debug 方式运行

$ GODEBUG=schedtrace=1000 ./trace2
SCHED 0ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=1 idlethreads=1 runqueue=0 [0 0]
Hello World
SCHED 1003ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 2014ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 3015ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 4023ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED:调试信息输出标志字符串,代表本行是 goroutine 调度器的输出;
0ms:即从程序启动到输出这行日志的时间;
gomaxprocs: P 的数量,本例有 2 个 P, 因为默认的 P 的属性是和 cpu 核心数量默认一致,当然也可以通过 GOMAXPROCS 来设置;
idleprocs: 处于 idle 状态的 P 的数量;通过 gomaxprocs 和 idleprocs 的差值,我们就可知道执行 go 代码的 P 的数量;
threads: os threads/M 的数量,包含 scheduler 使用的 m 数量,加上 runtime 自用的类似 sysmon 这样的 thread 的数量;
spinningthreads: 处于自旋状态的 os thread 数量;
idlethread: 处于 idle 状态的 os thread 的数量;
runqueue=0: Scheduler 全局队列中 G 的数量;
[0 0]: 分别为 2 个 P 的 local queue 中的 G 的数量。
下一篇,我们来继续详细的分析 GMP 调度原理的一些场景问题。

三、Go 调度器调度场景过程全解析
(1) 场景 1
P 拥有 G1,M1 获取 P 后开始运行 G1,G1 使用 go func() 创建了 G2,为了局部性 G2 优先加入到 P1 的本地队列。
(2) 场景 2
G1 运行完成后 (函数:goexit),M 上运行的 goroutine 切换为 G0,G0 负责调度时协程的切换(函数:schedule)。从 P 的本地队列取 G2,从 G0 切换到 G2,并开始运行 G2 (函数:execute)。实现了线程 M1 的复用。

(3) 场景 3
假设每个 P 的本地队列只能存 3 个 G。G2 要创建了 6 个 G,前 3 个 G(G3, G4, G5)已经加入 p1 的本地队列,p1 本地队列满了。

(4) 场景 4
G2 在创建 G7 的时候,发现 P1 的本地队列已满,需要执行负载均衡 (把 P1 中本地队列中前一半的 G,还有新创建 G 转移到全局队列)

(实现中并不一定是新的 G,如果 G 是 G2 之后就执行的,会被保存在本地队列,利用某个老的 G 替换新 G 加入全局队列)

这些 G 被转移到全局队列时,会被打乱顺序。所以 G3,G4,G7 被转移到全局队列。

(5) 场景 5
G2 创建 G8 时,P1 的本地队列未满,所以 G8 会被加入到 P1 的本地队列。

G8 加入到 P1 点本地队列的原因还是因为 P1 此时在与 M1 绑定,而 G2 此时是 M1 在执行。所以 G2 创建的新的 G 会优先放置到自己的 M 绑定的 P 上。

(6) 场景 6
规定:在创建 G 时,运行的 G 会尝试唤醒其他空闲的 P 和 M 组合去执行。

假定 G2 唤醒了 M2,M2 绑定了 P2,并运行 G0,但 P2 本地队列没有 G,M2 此时为自旋线程(没有 G 但为运行状态的线程,不断寻找 G)。

(7) 场景 7
M2 尝试从全局队列 (简称 “GQ”) 取一批 G 放到 P2 的本地队列(函数:findrunnable())。M2 从全局队列取的 G 数量符合下面的公式:

n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))
至少从全局队列取 1 个 g,但每次不要从全局队列移动太多的 g 到 p 本地队列,给其他 p 留点。这是从全局队列到 P 本地队列的负载均衡。

假定我们场景中一共有 4 个 P(GOMAXPROCS 设置为 4,那么我们允许最多就能用 4 个 P 来供 M 使用)。所以 M2 只从能从全局队列取 1 个 G(即 G3)移动 P2 本地队列,然后完成从 G0 到 G3 的切换,运行 G3。

(8) 场景 8
假设 G2 一直在 M1 上运行,经过 2 轮后,M2 已经把 G7、G4 从全局队列获取到了 P2 的本地队列并完成运行,全局队列和 P2 的本地队列都空了,如场景 8 图的左半部分。

全局队列已经没有 G,那 m 就要执行 work stealing (偷取):从其他有 G 的 P 哪里偷取一半 G 过来,放到自己的 P 本地队列。P2 从 P1 的本地队列尾部取一半的 G,本例中一半则只有 1 个 G8,放到 P2 的本地队列并执行。

(9) 场景 9
G1 本地队列 G5、G6 已经被其他 M 偷走并运行完成,当前 M1 和 M2 分别在运行 G2 和 G8,M3 和 M4 没有 goroutine 可以运行,M3 和 M4 处于自旋状态,它们不断寻找 goroutine。

为什么要让 m3 和 m4 自旋,自旋本质是在运行,线程在运行却没有执行 G,就变成了浪费 CPU. 为什么不销毁现场,来节约 CPU 资源。因为创建和销毁 CPU 也会浪费时间,我们希望当有新 goroutine 创建时,立刻能有 M 运行它,如果销毁再新建就增加了时延,降低了效率。当然也考虑了过多的自旋线程是浪费 CPU,所以系统中最多有 GOMAXPROCS 个自旋的线程 (当前例子中的 GOMAXPROCS=4,所以一共 4 个 P),多余的没事做线程会让他们休眠。

(10) 场景 10
​ 假定当前除了 M3 和 M4 为自旋线程,还有 M5 和 M6 为空闲的线程 (没有得到 P 的绑定,注意我们这里最多就只能够存在 4 个 P,所以 P 的数量应该永远是 M>=P, 大部分都是 M 在抢占需要运行的 P),G8 创建了 G9,G8 进行了阻塞的系统调用,M2 和 P2 立即解绑,P2 会执行以下判断:如果 P2 本地队列有 G、全局队列有 G 或有空闲的 M,P2 都会立马唤醒 1 个 M 和它绑定,否则 P2 则会加入到空闲 P 列表,等待 M 来获取可用的 p。本场景中,P2 本地队列有 G9,可以和其他空闲的线程 M5 绑定。

(11) 场景 11
G8 创建了 G9,假如 G8 进行了非阻塞系统调用。
​ M2 和 P2 会解绑,但 M2 会记住 P2,然后 G8 和 M2 进入系统调用状态。当 G8 和 M2 退出系统调用时,会尝试获取 P2,如果无法获取,则获取空闲的 P,如果依然没有,G8 会被记为可运行状态,并加入到全局队列,M2 因为没有 P 的绑定而变成休眠状态 (长时间休眠等待 GC 回收销毁)。

四、小结
总结,Go 调度器很轻量也很简单,足以撑起 goroutine 的调度工作,并且让 Go 具有了原生(强大)并发的能力。Go 调度本质是把大量的 goroutine 分配到少量线程上去执行,并利用多核并行,实现更强大的并发。