这是一篇加密文章,需要密码才能继续阅读。
阅读全文 »
l

java 名词

“吃人”的那些Java名词:对象、引用、堆、栈️

经验都是慢慢积累的,天才不多| 第170篇

图片

记得中学的课本上,有一篇名为《狂人日记》课文;那时候根本理解不了鲁迅写这篇文章要表达的中心思想,只觉得满篇的“吃人”令人心情压抑;老师在讲台上慷慨激昂的讲,大多数的同学同我一样,在课本面前“痴痴”的发呆。

作为一个有着8年Java编程经验的IT老兵,说起来很惭愧,我被Java当中的四五个名词一直困扰着:对象、引用、堆、栈、堆栈(栈可同堆栈,因此是四个名词,也是五个名词)。每次我看到这几个名词,都隐隐约约觉得自己在被一只无形的大口慢慢地吞噬,只剩下满地的衣服碎屑(为什么不是骨头,因为骨头也好吃)。

十几年后,再读《狂人日记》,恍然如梦:

鲁迅先生以狂人的口吻,再现了动乱时期下中国人的精神状态,视角新颖,文笔细腻又不乏辛辣之味。
当时的中国,混乱成了主色调。以清廷和孔教为主的封建旧思想还在潜移默化地影响着人们的思想,与此同时以革命和新思潮为主的现代思想已经开始了对大众灵魂的洗涤和冲击。

最近,和沉默王二技术交流群(120926808)的群友们交流后,Java中那四五个会吃人的名词:对象、引用、堆、栈、堆栈,似乎在脑海中也清晰了起来,尽管疑惑有时候仍然会在阴云密布时跑出来——正鉴于此,这篇文章恰好做一下归纳。

一、对象和引用

在Java中,尽管一切都可以看做是对象,但计算机操作的并非对象本身,而是对象的引用。 这话乍眼一看,似懂非懂。究竟什么是对象,什么又是引用呢?

先来看对象的定义:按照通俗的说法,每个对象都是某个类(class)的一个实例(instance)。那么,实例化的过程怎么描述呢?来看代码(类是String):

1
2
new String("我是对象张三");
new String("我是对象李四");

在Java中,实例化指的就是通过关键字“new”来创建对象的过程。以上代码在运行时就会创建两个对象——“我是对象张三”和”我是对象李四”;现在,该怎么操作他们呢?

我们都去过公园,见过几个大爷,他们很有一番本领——个个都能把风筝飞得老高老高,徒留我们眼馋的份!风筝飞那么高,没办法直接用手拽着飞啊,全要靠一根长长的看不见的结实的绳子来牵引!操作Java对象也是这个理,得有一根绳——也就是接下来要介绍的“引用”(我们肉眼也常常看不见它)。

1
2
3
String zhangsan, lisi;
zhangsan = new String("我是对象张三");
lisi = new String("我是对象李四");

这三行代码该怎么理解呢?

先来看第一行代码:String zhangsan, lisi;——声明了两个变量zhangsan和lisi,他们的类型为String。

①、歧义:zhangsan和lisi此时被称为引用。

你也许听过这样一句古文:“神之于形,犹利之于刀;未闻刀没而利存,岂容形亡而神在?”这是无神论者范缜(zhen)的名言,大致的意思就是:灵魂对于肉体来说,就像刀刃对于刀身;从没听说过刀身都没了刀刃还存在,那么怎么可能允许肉体死亡了而灵魂还在呢?

“引用”之于对象,就好比刀刃之于刀身,对象还没有创建,又怎么存在对象的“引用”呢?

如果zhangsan和lisi此时不能被称为“引用”,那么他们是什么呢?答案很简单,就是变量啊!(鄙人理解)

②、误解:zhangsan和lisi此时的默认值为null

应该说zhangsan和lisi此时的值为undefined——借用JavaScript的关键字;也就是未定义;或者应该是一个新的关键字uninitialized——未初始化。但不管是undefined还是uninitialized,都与null不同。

既然没有初始化,zhangsan和lisi此时就不能被使用。假如强行使用的话,编译器就会报错,提醒zhangsan和lisi还没有出生(初始化);见下图。

图片

如果把zhangsan和lisi初始化为null,编译器是认可的(见下图);由此可见,zhangsan和lisi此时的默认值不为null

图片

再来看第二行代码:zhangsan = new String("我是对象张三");——创建“我是对象张三”的String类对象,并将其赋值给zhangsan这个变量。

此时,zhangsan就是”我是对象张三”的引用;“=”操作符赋予了zhangsan这样神圣的权利。

第三行代码lisi = new String("我是对象李四");和第二行代码zhangsan = new String("我是对象张三");同理。

现在,我可以下这样一个结论了——对象是通过new关键字创建的;引用是依赖于对象的;=操作符把对象赋值给了引用

我们再来看这样一段代码:

1
2
3
4
String zhangsan, lisi;
zhangsan = new String("我是对象张三");
lisi = new String("我是对象李四");
zhangsan = lisi;

zhangsan = lisi;执行过后,zhangsan就不再是”我是对象张三”的引用了;zhangsan和lisi指向了同一个对象(”我是对象李四”);因此,你知道System.out.println(zhangsan == lisi);打印的是false还是true了吗?

二、堆、栈、堆栈

谁来告诉我,为什么有很多地方(书、博客等等)把栈叫做堆栈,把堆栈叫做栈?搞得我都头晕目眩了——绕着门柱估计转了80圈,不晕才怪!

我查了一下金山词霸,结果如下:

图片

我的天呐,更晕了,有没有!怎么才能不晕呢?我这里有几招武功秘籍,你们尽管拿去一睹为快:

1)以后再看到堆、栈、堆栈三个在一起打牌的时候,直接把“堆栈”踢出去;这仨人不适合在一起玩,因为堆和栈才是老相好;你“堆栈”来这插一脚算怎么回事;这世界上只存在“堆、栈”或者“堆栈”(标点符号很重要哦)。

2)堆是在程序运行时在内存中申请的空间(可理解为动态的过程);切记,不是在编译时;因此,Java中的对象就放在这里,这样做的好处就是:

当需要一个对象时,只需要通过new关键字写一行代码即可,当执行这行代码时,会自动在内存的“堆”区分配空间——这样就很灵活。

另外,需要记住,堆遵循“先进后出”的规则。就好像,一个和尚去挑了一担水,然后把一担水装缸里面,等到他口渴的时候他再用瓢舀出来喝。请放肆地打开你的脑洞脑补一下这个流程:缸底的水是先进去的,但后出来的。所以,我建议这位和尚在缸上贴个标签——保质期90天,过期饮用,后果自负!

还是记不住,看下图:

图片不好意思,这是鼎,不是缸,将就一下哈

3)栈,又名堆栈(简直了,完全不符合程序员的思维啊,我们陈许愿习惯说一就是一,说二就是二嘛),能够和处理器(CPU,也就是脑子)直接关联,因此访问速度更快;举个十分不恰当的例子哈——眼睛相对嘴巴是离脑子近的一方,因此,你可以一目十行,但绝对做不到一开口就读十行字,哪怕十个字也做不到

既然访问速度快,要好好利用啊!Java就把对象的引用放在栈里。为什么呢?因为引用的使用频率高吗?

不是的,因为Java在编译程序时,必须明确的知道存储在栈里的东西的生命周期,否则就没法释放旧的内存来开辟新的内存空间存放引用——空间就那么大,前浪要把后浪拍死在沙滩上啊。

现在清楚堆、栈和堆栈了吧?

三、特殊的“对象”

先来看《Java编程思想》中的一段话:

在程序设计中经常用到一系列类型,他们需要特殊对待。之所以特殊对待,是因为new将对象存储于“堆”中,故用new创建一个对象──特别小、简单的变量,往往不是很有效。因此,不用new来创建这类变量,而是创建一个并非是引用的变量,这个变量直接存储值,并置于栈中,因此更加高效。

在Java中,这些基本类型有:boolean、char、byte、short、int、long、float、double和void;还有与之对应的包装器:Boolean、Character、Byte、Short、Integer、Long、Float、Double和Void;他们之间涉及到装箱和拆箱,我们有机会再聊。

看两行简单的代码:

1
2
int a = 3;
int b = 3;

这两行代码在编译的时候是什么样子呢?

编译器当然是先处理int a = 3;,不然还能跳过吗?编译器在处理int a = 3;时在栈中创建了一个变量为a的内存空间,然后查找有没有字面值为3的地址,没找到,就开辟一个存放3这个字面值的地址,然后将a指向3的地址。

编译器忙完了int a = 3;,就来接着处理int b = 3;;在创建完b的变量后,由于栈中已经有3这个字面值,就将b直接指向3的地址;就不需要再开辟新的空间了。

依据上面的概述,我们假设在定义完a与b的值后,再令a=4,此时b是等于3呢,还是4呢?

思考一下,再看答案哈。

答案揭晓:当编译器遇到a = 4;时,它会重新搜索栈中是否有4的字面值,如果没有,重新开辟地址存放4的值;如果已经有了,则直接将a指向4这个地址;因此a值的改变不会影响到b的值哦。

最后,留个作业吧,下面这段代码在运行时会输出什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class Test1 {
public static void main(String args[]) {
int a = 1;
int b = 1;

a = 2;

System.out.println(a);
System.out.println(b);

TT t = new TT("T");
TT t1 = t;
t.setName("TT");


System.out.println(t.getName());
System.out.println(t1.getName());
}
}

class TT{
private String name;

public TT (String name) {
this.name = name;
}

public String getName() {
return this.name;
}

public void setName(String name1) {
this.name = name1;
}
}
l

Java泛型类型擦除以及类型擦除带来的问题

1.Java泛型的实现方法:类型擦除

大家都知道,Java的泛型是伪泛型,这是因为Java在编译期间,所有的泛型信息都会被擦掉,正确理解泛型概念的首要前提是理解类型擦除。Java的泛型基本上都是在编译器这个层次上实现的,在生成的字节码中是不包含泛型中的类型信息的,使用泛型的时候加上类型参数,在编译器编译的时候会去掉,这个过程成为类型擦除。

如在代码中定义 List<Object>List<String>等类型,在编译后都会变成 List,JVM看到的只是List,而由泛型附加的类型信息对JVM是看不到的。Java编译器会在编译时尽可能的发现可能出错的地方,但是仍然无法在运行时刻出现的类型转换异常的情况,类型擦除也是Java的泛型与C++模板机制实现方式之间的重要区别。

1-1.通过两个例子证明Java类型的类型擦除

例1.原始类型相等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Test {

public static void main(String[] args) {

ArrayList<String> list1 = new ArrayList<String>();
list1.add("abc");

ArrayList<Integer> list2 = new ArrayList<Integer>();
list2.add(123);

System.out.println(list1.getClass() == list2.getClass());
}

}

在这个例子中,我们定义了两个 ArrayList数组,不过一个是 ArrayList<String>泛型类型的,只能存储字符串;一个是 ArrayList<Integer>泛型类型的,只能存储整数,最后,我们通过 list1对象和 list2对象的 getClass()方法获取他们的类的信息,最后发现结果为 true。说明泛型类型 StringInteger都被擦除掉了,只剩下原始类型。

例2.通过反射添加其它类型元素

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

public static void main(String[] args) throws Exception {

ArrayList<Integer> list = new ArrayList<Integer>();

list.add(1); //这样调用add方法只能存储整形,因为泛型类型的实例为Integer

list.getClass().getMethod("add", Object.class).invoke(list, "asd");

for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
}

}

在程序中定义了一个 ArrayList泛型类型实例化为 Integer对象,如果直接调用 add()方法,那么只能存储整数数据,不过当我们利用反射调用 add()方法的时候,却可以存储字符串,这说明了 Integer泛型实例在编译之后被擦除掉了,只保留了原始类型。

2.类型擦除后保留的原始类型

在上面,两次提到了原始类型,什么是原始类型?

原始类型 就是擦除去了泛型信息,最后在字节码中的类型变量的真正类型,无论何时定义一个泛型,相应的原始类型都会被自动提供,类型变量擦除,并使用其限定类型(无限定的变量用Object)替换。

例3.原始类型Object

1
2
3
4
5
6
7
8
9
class Pair<t> {
private T value;
public T getValue() {
return value;
}
public void setValue(T value) {
this.value = value;
}
}

Pair的原始类型为:

1
2
3
4
5
6
7
8
9
class Pair {
private Object value;
public Object getValue() {
return value;
}
public void setValue(Object value) {
this.value = value;
}
}

因为在 Pair<t></t>中,T 是一个无限定的类型变量,所以用 Object替换,其结果就是一个普通的类,如同泛型加入Java语言之前的已经实现的样子。在程序中可以包含不同类型的 Pair,如 Pair<String>Pair<Integer>,但是擦除类型后他们的就成为原始的 Pair类型了,原始类型都是 Object

从上面的例2中,我们也可以明白 ArrayList<Integer>被擦除类型后,原始类型也变为 Object,所以通过反射我们就可以存储字符串了。

如果类型变量有限定,那么原始类型就用第一个边界的类型变量类替换。

比如: Pair这样声明的话

1
public class Pair<t extends comparable> {}

那么原始类型就是 Comparable

要区分原始类型和泛型变量的类型。

在调用泛型方法时,可以指定泛型,也可以不指定泛型。

  • 在不指定泛型的情况下,泛型变量的类型为该方法中的几种类型的同一父类的最小级,直到Object
  • 在指定泛型的情况下,该方法的几种类型必须是该泛型的实例的类型或者其子类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Test {
public static void main(String[] args) {

/**不指定泛型的时候**/
int i = Test.add(1, 2); //这两个参数都是Integer,所以T为Integer类型
Number f = Test.add(1, 1.2); //这两个参数一个是Integer,以风格是Float,所以取同一父类的最小级,为Number
Object o = Test.add(1, "asd"); //这两个参数一个是Integer,以风格是Float,所以取同一父类的最小级,为Object

/**指定泛型的时候*/
int a = Test.<Integer>add(1, 2); //指定了Integer,所以只能为Integer类型或者其子类
int b = Test.<Integer>add(1, 2.2); //编译错误,指定了Integer,不能为Float
Number c = Test.<number>add(1, 2.2); //指定为Number,所以可以为Integer和Float
}

//这是一个简单的泛型方法
public static <t> T add(T x,T y){
return y;
}
}

其实在泛型类中,不指定泛型的时候,也差不多,只不过这个时候的泛型为 Object,就比如 ArrayList中,如果不指定泛型,那么这个 ArrayList可以存储任意的对象。

例4.Object泛型

1
2
3
4
5
6
public static void main(String[] args) {
ArrayList list = new ArrayList();
list.add(1);
list.add("121");
list.add(new Date());
}

3.类型擦除引起的问题及解决方法

因为种种原因,Java不能实现真正的泛型,只能使用类型擦除来实现伪泛型,这样虽然不会有类型膨胀问题,但是也引起来许多新问题,所以,SUN对这些问题做出了种种限制,避免我们发生各种错误。

3-1.先检查,再编译以及编译的对象和引用传递问题

Q: 既然说类型变量会在编译的时候擦除掉,那为什么我们往 ArrayList 创建的对象中添加整数会报错呢?不是说泛型变量String会在编译的时候变为Object类型吗?为什么不能存别的类型呢?既然类型擦除了,如何保证我们只能使用泛型变量限定的类型呢?

A: Java编译器是通过先检查代码中泛型的类型,然后再进行类型擦除,再进行编译。

例如:

1
2
3
4
5
6
public static  void main(String[] args) {

ArrayList<String> list = new ArrayList<String>();
list.add("123");
list.add(123);//编译错误
}

在上面的程序中,使用 add方法添加一个整型,在IDE中,直接会报错,说明这就是在编译之前的检查,因为如果是在编译之后检查,类型擦除后,原始类型为 Object,是应该允许任意引用类型添加的。可实际上却不是这样的,这恰恰说明了关于泛型变量的使用,是会在编译之前检查的。

那么,这个类型检查是针对谁的呢?我们先看看参数化类型和原始类型的兼容。

以 ArrayList举例子,以前的写法:

1
ArrayList list = new ArrayList();

现在的写法:

1
2
ArrayList<String> list = new ArrayList<String>();

如果是与以前的代码兼容,各种引用传值之间,必然会出现如下的情况:

1
2
ArrayList<String> list1 = new ArrayList(); //第一种情况
ArrayList list2 = new ArrayList<String>(); //第二种情况

这样是没有错误的,不过会有个编译时警告。

不过在第一种情况,可以实现与完全使用泛型参数一样的效果,第二种则没有效果。

因为类型检查就是编译时完成的, new ArrayList()只是在内存中开辟了一个存储空间,可以存储任何类型对象,而真正设计类型检查的是它的引用,因为我们是使用它引用 list1来调用它的方法,比如说调用 add方法,所以 list1引用能完成泛型类型的检查。而引用 list2没有使用泛型,所以不行。

举例子:

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

public static void main(String[] args) {

ArrayList<String> list1 = new ArrayList();
list1.add("1"); //编译通过
list1.add(1); //编译错误
String str1 = list1.get(0); //返回类型就是String

ArrayList list2 = new ArrayList<String>();
list2.add("1"); //编译通过
list2.add(1); //编译通过
Object object = list2.get(0); //返回类型就是Object

new ArrayList<String>().add("11"); //编译通过
new ArrayList<String>().add(22); //编译错误

String str2 = new ArrayList<String>().get(0); //返回类型就是String
}
}

通过上面的例子,我们可以明白,类型检查就是针对引用的,谁是一个引用,用这个引用调用泛型方法,就会对这个引用调用的方法进行类型检测,而无关它真正引用的对象。

泛型中参数化类型为什么不考虑继承关系

在Java中,像下面形式的引用传递是不允许的:

1
2
ArrayList<String> list1 = new ArrayList<object>(); //编译错误 ArrayList<Object>
ArrayList<object> list2 = new ArrayList<String>(); //编译错误 ArrayList<String>

我们先看第一种情况,将第一种情况拓展成下面的形式:

1
2
3
4
ArrayList<object> list1 = new ArrayList<object>();
list1.add(new Object());
list1.add(new Object());
ArrayList<String> list2 = list1; //编译错误

实际上,在第4行代码的时候,就会有编译错误。那么,我们先假设它编译没错。那么当我们使用 list2引用用 get()方法取值的时候,返回的都是 String类型的对象(上面提到了,类型检测是根据引用来决定的),可是它里面实际上已经被我们存放了 Object类型的对象,这样就会有 ClassCastException了。所以为了避免这种极易出现的错误,Java不允许进行这样的引用传递。(这也是泛型出现的原因,就是为了解决类型转换的问题,我们不能违背它的初衷)。

再看第二种情况,将第二种情况拓展成下面的形式:

1
2
3
4
5
ArrayList<String> list1 = new ArrayList<String>();
list1.add(new String());
list1.add(new String());

ArrayList<object> list2 = list1; //编译错误

没错,这样的情况比第一种情况好的多,最起码,在我们用 list2取值的时候不会出现 ClassCastException,因为是从 String转换为 Object。可是,这样做有什么意义呢,泛型出现的原因,就是为了解决类型转换的问题。我们使用了泛型,到头来,还是要自己强转,违背了泛型设计的初衷。所以java不允许这么干。再说,你如果又用 list2往里面 add()新的对象,那么到时候取得时候,我怎么知道我取出来的到底是 String类型的,还是 Object类型的呢?

所以,要格外注意,泛型中的引用传递的问题。

3-2.自动类型转换

因为类型擦除的问题,所以所有的泛型类型变量最后都会被替换为原始类型。

既然都被替换为原始类型,那么为什么我们在获取的时候,不需要进行强制类型转换呢?

看下 ArrayList.get()方法:

1
2
3
4
5
6
7
public E get(int index) {

RangeCheck(index);

return (E) elementData[index];

}

可以看到,在 return之前,会根据泛型变量进行强转。假设泛型类型变量为 Date,虽然泛型信息会被擦除掉,但是会将 (E) elementData[index],编译为 (Date)elementData[index]。所以我们不用自己进行强转。当存取一个泛型域时也会自动插入强制类型转换。假设 Pair类的 value域是 public的,那么表达式:

1
Date Date = pair.value;

也会自动地在结果字节码中插入强制类型转换。

3-3.类型擦除与多态的冲突和解决方法

现在有这样一个泛型类:

1
2
3
4
5
6
7
8
9
10
11
12
class Pair<t> {

private T value;

public T getValue() {
return value;
}

public void setValue(T value) {
this.value = value;
}
}

然后我们想要一个子类继承它。

1
2
3
4
5
6
7
8
9
10
11
12
class DateInter extends Pair<Date> {

@Override
public void setValue(Date value) {
super.setValue(value);
}

@Override
public Date getValue() {
return super.getValue();
}
}

在这个子类中,我们设定父类的泛型类型为 Pair<Date>,在子类中,我们覆盖了父类的两个方法,我们的原意是这样的:将父类的泛型类型限定为 Date,那么父类里面的两个方法的参数都为 Date类型。

1
2
3
4
5
6
7
public Date getValue() {
return value;
}

public void setValue(Date value) {
this.value = value;
}

所以,我们在子类中重写这两个方法一点问题也没有,实际上,从他们的 @Override标签中也可以看到,一点问题也没有,实际上是这样的吗?

分析:实际上,类型擦除后,父类的的泛型类型全部变为了原始类型 Object,所以父类编译之后会变成下面的样子:

1
2
3
4
5
6
7
8
9
10
11
class Pair {
private Object value;

public Object getValue() {
return value;
}

public void setValue(Object value) {
this.value = value;
}
}

再看子类的两个重写的方法的类型:

1
2
3
4
5
6
7
8
@Override
public void setValue(Date value) {
super.setValue(value);
}
@Override
public Date getValue() {
return super.getValue();
}

先来分析 setValue方法,父类的类型是 Object,而子类的类型是 Date,参数类型不一样,这如果是在普通的继承关系中,根本就不会是重写,而是重载

我们在一个main方法测试一下:

1
2
3
4
5
public static void main(String[] args) throws ClassNotFoundException {
DateInter DateInter = new DateInter();
DateInter.setValue(new Date());
DateInter.setValue(new Object()); //编译出错
}

如果是重载,那么子类中两个 setValue方法,一个是参数 Object类型,一个是 Date类型,可是我们发现,根本就没有这样的一个子类继承自父类的Object类型参数的方法。所以说,确实是重写了,而不是重载了。

为什么会这样呢?

原因是这样的,我们传入父类的泛型类型是 Date, Pair<Date>,我们的本意是将泛型类变为如下:

1
2
3
4
5
6
7
8
9
class Pair {
private Date value;
public Date getValue() {
return value;
}
public void setValue(Date value) {
this.value = value;
}
}

然后在子类中重写参数类型为Date的那两个方法,实现继承中的多态。

可是由于种种原因,虚拟机并不能将泛型类型变为 Date,只能将类型擦除掉,变为原始类型 Object。这样,我们的本意是进行重写,实现多态。可是类型擦除后,只能变为了重载。这样,类型擦除就和多态有了冲突。JVM知道你的本意吗?知道!!!可是它能直接实现吗,不能!!!如果真的不能的话,那我们怎么去重写我们想要的 Date类型参数的方法啊。

于是JVM采用了一个特殊的方法,来完成这项功能,那就是桥方法

首先,我们用 javap -c className的方式反编译下 DateInter子类的字节码,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class com.tao.test.DateInter extends com.tao.test.Pair<java.util.Date> {
com.tao.test.DateInter();
Code:
0: aload_0
1: invokespecial #8 // Method com/tao/test/Pair."<init>":()V
4: return

public void setValue(java.util.Date); //我们重写了setValue方法
Code:
0: aload_0
1: aload_1
2: invokespecial #16 // Method com/tao/test/Pair.setValue:(Ljava/lang/Object;)V
5: return

public java.util.Date getValue(); //我们重写了getValue方法
Code:
0: aload_0
1: invokespecial #23 // Method com/tao/test/Pair.getValue:()Ljava/lang/Object;
4: checkcast #26 // class java/util/Date
7: areturn

public java.lang.Object getValue(); //编译时有编译器生成的桥方法
Code:
0: aload_0
1: invokevirtual #28 // Method getValue:()Ljava/util/Date;去调用我们重写的setValue方法;
4: areturn

public void setValue(java.lang.Object); //编译时有编译器生成的桥方法
Code:
0: aload_0
1: aload_1
2: checkcast #26 // class java/util/Date
5: invokevirtual #30 // Method setValue:(Ljava/util/Date;去调用我们重写的setValue方法)V
8: return
}

从编译的结果来看,我们本意重写 setValuegetValue方法的子类,竟然有4个方法,其实不用惊奇,最后的两个方法,就是编译器自己生成的桥方法。可以看到桥方法的参数类型都是Object,也就是说,子类中真正覆盖父类两个方法的就是这两个我们看不到的桥方法。而打在我们自己定义的 setvaluegetValue方法上面的 @Override只不过是假象。而桥方法的内部实现,就只是去调用我们自己重写的那两个方法

所以,虚拟机巧妙的使用了桥方法,来解决了类型擦除和多态的冲突。

不过,要提到一点,这里面的 setValuegetValue这两个桥方法的意义又有不同。

setValue方法是为了解决类型擦除与多态之间的冲突。

getValue却有普遍的意义,怎么说呢,如果这是一个普通的继承关系:

那么父类的 setValue方法如下:

1
2
3
public Object getValue() {
return super.getValue();
}

而子类重写的方法是:

1
2
3
public Date getValue() {
return super.getValue();
}

其实这在普通的类继承中也是普遍存在的重写,这就是协变。

关于协变:。。。。。。

并且,还有一点也许会有疑问,子类中的桥方法 Object getValue()Date getValue()是同 时存在的,可是如果是常规的两个方法,他们的方法签名是一样的,也就是说虚拟机根本不能分别这两个方法。如果是我们自己编写Java代码,这样的代码是无法通过编译器的检查的,但是虚拟机却是允许这样做的,因为虚拟机通过参数类型和返回类型来确定一个方法,所以编译器为了实现泛型的多态允许自己做这个看起来”不合法”的事情,然后交给虚拟器去区别。

3-4.泛型类型变量不能是基本数据类型

不能用类型参数替换基本类型。就比如,没有 ArrayList<double>,只有 ArrayList<Double>。因为当类型擦除后, ArrayList的原始类型变为 Object,但是 Object类型不能存储 double值,只能引用 Double的值。

3-5.编译时集合的instanceof

1
ArrayList<String> arrayList = new ArrayList<String>();

因为类型擦除之后, ArrayList<String>只剩下原始类型,泛型信息 String不存在了。

那么,编译时进行类型查询的时候使用下面的方法是错误的

1
if( arrayList instanceof ArrayList<String>)

3-6.泛型在静态方法和静态类中的问题

泛型类中的静态方法和静态变量不可以使用泛型类所声明的泛型类型参数

举例说明:

1
2
3
4
5
6
public class Test2<T> {
public static T one; //编译错误
public static T show(T one){ //编译错误
return null;
}
}

因为泛型类中的泛型参数的实例化的时候确定,而静态变量和静态方法不需要使用对象来调用。对象都没有创建,如何确定这个泛型参数是何种类型,所以当然是错误的。

但是要注意区分下面的一种情况:

1
2
3
4
5
6
public class Test2<T> {

public static <T>T show(T one){ //这是正确的
return null;
}
}

因为这是一个泛型方法,在泛型方法中使用的T是自己在方法中定义的 T,而不是泛型类中的T。

l

Java内存模型2

Spring、Netty、Mybatis 等框架的代码中大量运用了 Java 多线程编程技巧。并发编程处理的恰当与否,将直接影响架构的性能。本章通过对 这些框架源码 的分析,结合并发编程的常用技巧,来讲解多线程编程在这些主流框架中的应用。

Java 内存模型

JVM 规范 定义了 Java 内存模型 来屏蔽掉各种操作系统、虚拟机实现厂商和硬件的内存访问差异,以确保 Java 程序 在所有操作系统和平台上能够达到一致的内存访问效果。

工作内存和主内存

Java 内存模型 规定所有的变量都存储在主内存中,每个线程都有自己独立的工作内存,工作内存保存了 对应该线程使用的变量的主内存副本拷贝。线程对这些变量的操作都在自己的工作内存中进行,不能直接操作主内存 和 其他工作内存中存储的变量或者变量副本。线程间的变量传递需通过主内存来完成,三者的关系如下图所示。 在这里插入图片描述

Java 内存操作协议

Java 内存模型定义了 8 种操作来完成主内存和工作内存的变量访问,具体如下。 在这里插入图片描述

  • read:把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load:把从主内存中读取的变量值载入工作内存的变量副本中。
  • use:把工作内存中一个变量的值传递给 Java 虚拟机执行引擎。
  • assign:把从执行引擎接收到的变量的值赋值给工作内存中的变量。
  • store:把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作。
  • write:工作内存传递过来的变量值放入主内存中。
  • lock:把主内存的一个变量标识为某个线程独占的状态。
  • unlock:把主内存中 一个处于锁定状态的变量释放出来,被释放后的变量才可以被其他线程锁定。

内存模型三大特性

1、原子性

这个概念与事务中的原子性大概一致,表明此操作是不可分割,不可中断的,要么全部执行,要么全部不执行。 Java 内存模型直接保证的原子性操作包括 read、load、use、assign、store、write、lock、unlock 这八个。

2、可见性

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java 内存模型 是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量 都是如此,普通变量与 volatile 变量 的区别是,volatile 的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。除了 volatile 外,synchronized 也提供了可见性,synchronized 的可见性是由 “对一个变量执行 unlock 操作 之前,必须先把此变量同步回主内存中(执行 store、write 操作)” 这条规则获得。

3、有序性

单线程环境下,程序会 “有序的”执行,即:线程内表现为串行语义。但是在多线程环境下,由于指令重排,并发执行的正确性会受到影响。在 Java 中使用 volatile 和 synchronized 关键字,可以保证多线程执行的有序性。volatile 通过加入内存屏障指令来禁止内存的重排序。synchronized 通过加锁,保证同一时刻只有一个线程来执行同步代码。

volatile 的应用

打开 NioEventLoop 的代码中,有一个控制 IO 操作 和 其他任务运行比例的,用 volatile 修饰的 int 类型字段 ioRatio,代码如下。

1
private volatile int ioRatio = 50;

这里为什么要用 volatile 修饰呢?我们首先对 volatile 关键字进行说明,然后再结合 Netty 的代码进行分析。

关键字 volatile 是 Java 提供的最轻量级的同步机制,Java 内存模型对 volatile 专门定义了一些特殊的访问规则。下面我们就看它的规则。当一个变量被 volatile 修饰后,它将具备以下两种特性。

  • 线程可见性:当一个线程修改了被 volatile 修饰的变量后,无论是否加锁,其他线程都可以立即看到最新的修改(什么叫立即看到最新的修改?感觉这句话太口语化且模糊,搞不太懂!),而普通变量却做不到这点。
  • 禁止指令重排序优化:普通的变量仅仅保证在该方法的执行过程中所有依赖赋值结果的地方都能获取正确的结果,而不能保证变量赋值操作的顺序与程序代码的执行顺序一致。举个简单的例子说明下指令重排序优化问题,代码如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ThreadStopExample {

private static boolean stop;

public static void main(String[] args) throws InterruptedException {
Thread workThread = new Thread(new Runnable() {
public void run() {
int i= 0;
while (!stop) {
i++;
try{
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
workThread.start();
TimeUnit.SECONDS.sleep(3);
stop = true;
}
}

我们预期程序会在 3s 后停止,但是实际上它会一直执行下去,原因就是虚拟机对代码进行了指令重排序和优化,优化后的指令如下。

1
2
3
if (!stop)
While(true)
......

workThread 线程 在执行重排序后的代码时,是无法发现 变量 stop 被其它线程修改的,因此无法停止运行。要解决这个问题,只要将 stop 前增加 volatile 修饰符即可。volatile 解决了如下两个问题。第一,主线程对 stop 的修改在 workThread 线程 中可见,也就是说 workThread 线程 立即看到了其他线程对于 stop 变量 的修改。第二,禁止指令重排序,防止因为重排序导致的并发访问逻辑混乱。

一些人认为使用 volatile 可以代替传统锁,提升并发性能,这个认识是错误的。volatile 仅仅解决了可见性的问题,但是它并不能保证互斥性,也就是说多个线程并发修改某个变量时,依旧会产生多线程问题。因此,不能靠 volatile 来完全替代传统的锁。根据经验总结,volatile 最适用的场景是 “ 一个线程写,其他线程读 ”,如果有多个线程并发写操作,仍然需要使用锁或者线程安全的容器或者原子变量来代替。下面我们继续对 Netty 的源码做分析。上面讲到了 ioRatio 被定义成 volatile,下面看看代码为什么要这样定义。

1
2
final long ioTime = System.nanoTime() - ioStartTime;
runAllTasks(ioTime * (100 - ioRatio) / ioRatio);

通过代码分析我们发现,在 NioEventLoop 线程 中,ioRatio 并没有被修改,它是只读操作。既然没有修改,为什么要定义成 volatile 呢?继续看代码,我们发现 NioEventLoop 提供了重新设置 IO 执行时间比例的公共方法。

1
2
3
4
5
6
public void setIoRatio(int ioRatio) {
if (ioRatio <= 0 || ioRatio > 100) {
throw new IllegalArgumentException("ioRatio: " + ioRatio + " (expected: 0 < ioRatio <= 100)");
}
this.ioRatio = ioRatio;
}

首先,NioEventLoop 线程 没有调用该 set 方法,说明调整 IO 执行时间比例 是外部发起的操作,通常是由业务的线程调用该方法,重新设置该参数。这样就形成了一个线程写、一个线程读。根据前面针对 volatile 的应用总结,此时可以使用 volatile 来代替传统的 synchronized 关键字,以提升并发访问的性能。

ThreadLocal 的应用及源码解析

ThreadLocal 又称为线程本地存储区(Thread Local Storage,简称为 TLS),每个线程都有自己的私有的本地存储区域,不同线程之间彼此不能访问对方的 TLS 区域。使用 ThreadLocal 变量 的 set(T value)方法 可以将数据存入 该线程本地存储区,使用 get() 方法 可以获取到之前存入的值。

ThreadLocal 的常见应用

不使用 ThreadLocal。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class SessionBean {
public static class Session {
private String id;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}

public Session createSession() {
return new Session();
}

public void setId(Session session, String id) {
session.setId(id);
}
public String getId(Session session) {
return session.getId();
}
public static void main(String[] args) {
//没有使用ThreadLocal,在方法间共享session需要进行session在方法间的传递
new Thread(() -> {
SessionBean bean = new SessionBean();
Session session = bean.createSession();
bean.setId(session, "susan");
System.out.println(bean.getId(session));
}).start();
}
}

上述代码中,session 需要在方法间传递才可以修改和读取,保证线程中各方法操作的是一个。下面看一下使用 ThreadLocal 的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class SessionBean {
//定义一个静态ThreadLocal变量session,就能够保证各个线程有自己的一份,并且方法可以方便获取,不用传递
private static ThreadLocal<Session> session = new ThreadLocal<>();
public static class Session {
private String id;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}

public void createSession() {
session.set(new Session());
}

public void setId(String id) {
session.get().setId(id);
}

public String getId() {
return session.get().getId();
}

public static void main(String[] args) {
new Thread(() -> {
SessionBean bean = new SessionBean();
bean.createSession();
bean.setId("susan");
System.out.println(bean.getId());
}).start();
}
}

在方法的内部实现中,直接可以通过 session.get() 获取到当前线程的 session,省掉了参数在方法间传递的环节。

ThreadLocal 的实现原理

一般,类属性中的数据是多个线程共享的,但 ThreadLocal 类型的数据 声明为类属性,却可以为每一个使用它(通过 set(T value)方法)的线程存储 线程私有的数据,通过其源码我们可以发现其中的原理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class ThreadLocal<T> {

/**
* 下面的 getMap()方法 传入当前线程,获得一个ThreadLocalMap对象,说明每一个线程维护了
* 自己的一个 map,保证读取出来的value是自己线程的。
*
* ThreadLocalMap 是ThreadLocal静态内部类,存储value的键值就是ThreadLocal本身。
*
* 因此可以断定,每个线程维护一个ThreadLocalMap的键值对映射Map。不同线程的Map的 key值 是一样的,
* 都是ThreadLocal,但 value 是不同的。
*/
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
}

ThreadLocal 在 Spring 中的使用

Spring 事务处理的设计与实现中大量使用了 ThreadLocal 类,比如,TransactionSynchronizationManager 维护了一系列的 ThreadLocal 变量,用于存储线程私有的 事务属性及资源。源码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/**
* 管理每个线程的资源和事务同步的中心帮助程序。供资源管理代码使用,但不供典型应用程序代码使用。
*
* 资源管理代码应该检查线程绑定的资源,如,JDBC连接 或 Hibernate Sessions。
* 此类代码通常不应该将资源绑定到线程,因为这是事务管理器的职责。另一个选项是,
* 如果事务同步处于活动状态,则在首次使用时延迟绑定,以执行跨任意数量资源的事务。
*/
public abstract class TransactionSynchronizationManager {

/**
* 一般是一个线程持有一个 独立的事务,以相互隔离地处理各自的事务。
* 所以这里使用了很多 ThreadLocal对象,为每个线程绑定 对应的事务属性及资源,
* 以便后续使用时能直接获取。
*/
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<Map<Object, Object>>("Transactional resources");

private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
new NamedThreadLocal<Set<TransactionSynchronization>>("Transaction synchronizations");

private static final ThreadLocal<String> currentTransactionName =
new NamedThreadLocal<String>("Current transaction name");

private static final ThreadLocal<Boolean> currentTransactionReadOnly =
new NamedThreadLocal<Boolean>("Current transaction read-only status");

private static final ThreadLocal<Integer> currentTransactionIsolationLevel =
new NamedThreadLocal<Integer>("Current transaction isolation level");

private static final ThreadLocal<Boolean> actualTransactionActive =
new NamedThreadLocal<Boolean>("Actual transaction active");

/**
* 为当前线程 绑定 对应的resource资源
*/
public static void bindResource(Object key, Object value) throws IllegalStateException {
Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key);
Assert.notNull(value, "Value must not be null");
Map<Object, Object> map = resources.get();
// 如果当前线程的 resources中,绑定的数据map为空,则为 resources 绑定 map
if (map == null) {
map = new HashMap<Object, Object>();
resources.set(map);
}
Object oldValue = map.put(actualKey, value);
if (oldValue instanceof ResourceHolder && ((ResourceHolder) oldValue).isVoid()) {
oldValue = null;
}
if (oldValue != null) {
throw new IllegalStateException("Already value [" + oldValue + "] for key [" +
actualKey + "] bound to thread [" + Thread.currentThread().getName() + "]");
}
if (logger.isTraceEnabled()) {
logger.trace("Bound value [" + value + "] for key [" + actualKey + "] to thread [" +
Thread.currentThread().getName() + "]");
}
}

/**
* 返回当前线程绑定的所有资源
*/
public static Map<Object, Object> getResourceMap() {
Map<Object, Object> map = resources.get();
return (map != null ? Collections.unmodifiableMap(map) : Collections.emptyMap());
}
}

ThreadLocal 在 Mybatis 中的使用

Mybatis 的 SqlSession 对象 也是各线程私有的资源,所以对其的管理也使用到了 ThreadLocal 类。源码如下。

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

private final ThreadLocal<SqlSession> localSqlSession = new ThreadLocal<>();

public void startManagedSession() {
this.localSqlSession.set(openSession());
}

public void startManagedSession(boolean autoCommit) {
this.localSqlSession.set(openSession(autoCommit));
}

public void startManagedSession(Connection connection) {
this.localSqlSession.set(openSession(connection));
}

public void startManagedSession(TransactionIsolationLevel level) {
this.localSqlSession.set(openSession(level));
}

public void startManagedSession(ExecutorType execType) {
this.localSqlSession.set(openSession(execType));
}

public void startManagedSession(ExecutorType execType, boolean autoCommit) {
this.localSqlSession.set(openSession(execType, autoCommit));
}

public void startManagedSession(ExecutorType execType, TransactionIsolationLevel level) {
this.localSqlSession.set(openSession(execType, level));
}

public void startManagedSession(ExecutorType execType, Connection connection) {
this.localSqlSession.set(openSession(execType, connection));
}

public boolean isManagedSessionStarted() {
return this.localSqlSession.get() != null;
}

@Override
public Connection getConnection() {
final SqlSession sqlSession = localSqlSession.get();
if (sqlSession == null) {
throw new SqlSessionException("Error: Cannot get connection. No managed session is started.");
}
return sqlSession.getConnection();
}

@Override
public void clearCache() {
final SqlSession sqlSession = localSqlSession.get();
if (sqlSession == null) {
throw new SqlSessionException("Error: Cannot clear the cache. No managed session is started.");
}
sqlSession.clearCache();
}

@Override
public void commit() {
final SqlSession sqlSession = localSqlSession.get();
if (sqlSession == null) {
throw new SqlSessionException("Error: Cannot commit. No managed session is started.");
}
sqlSession.commit();
}

@Override
public void commit(boolean force) {
final SqlSession sqlSession = localSqlSession.get();
if (sqlSession == null) {
throw new SqlSessionException("Error: Cannot commit. No managed session is started.");
}
sqlSession.commit(force);
}

@Override
public void rollback() {
final SqlSession sqlSession = localSqlSession.get();
if (sqlSession == null) {
throw new SqlSessionException("Error: Cannot rollback. No managed session is started.");
}
sqlSession.rollback();
}

@Override
public void rollback(boolean force) {
final SqlSession sqlSession = localSqlSession.get();
if (sqlSession == null) {
throw new SqlSessionException("Error: Cannot rollback. No managed session is started.");
}
sqlSession.rollback(force);
}

@Override
public List<BatchResult> flushStatements() {
final SqlSession sqlSession = localSqlSession.get();
if (sqlSession == null) {
throw new SqlSessionException("Error: Cannot rollback. No managed session is started.");
}
return sqlSession.flushStatements();
}

@Override
public void close() {
final SqlSession sqlSession = localSqlSession.get();
if (sqlSession == null) {
throw new SqlSessionException("Error: Cannot close. No managed session is started.");
}
try {
sqlSession.close();
} finally {
localSqlSession.set(null);
}
}
}

J.U.C 包的实际应用

线程池 ThreadPoolExecutor

首先通过 ThreadPoolExecutor 的源码 看一下线程池的主要参数及方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
public class ThreadPoolExecutor extends AbstractExecutorService {

/**
* 核心线程数
* 当向线程池提交一个任务时,若线程池已创建的线程数小于corePoolSize,即便此时存在空闲线程,
* 也会通过创建一个新线程来执行该任务,直到已创建的线程数大于或等于corePoolSize
*/
private volatile int corePoolSize;

/**
* 最大线程数
* 当队列满了,且已创建的线程数小于maximumPoolSize,则线程池会创建新的线程来执行任务。
* 另外,对于无界队列,可忽略该参数
*/
private volatile int maximumPoolSize;
/**
* 线程存活保持时间
* 当线程池中线程数 超出核心线程数,且线程的空闲时间也超过 keepAliveTime时,
* 那么这个线程就会被销毁,直到线程池中的线程数小于等于核心线程数
*/
private volatile long keepAliveTime;

/**
* 任务队列
* 用于传输和保存等待执行任务的阻塞队列
*/
private final BlockingQueue<Runnable> workQueue;

/**
* 线程工厂
* 用于创建新线程。threadFactory 创建的线程也是采用 new Thread() 方式,threadFactory
* 创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池中线程的编号
*/
private volatile ThreadFactory threadFactory;

/**
* 线程饱和策略
* 当线程池和队列都满了,再加入的线程会执行此策略
*/
private volatile RejectedExecutionHandler handler;

/**
* 构造方法提供了多种重载,但实际上都使用了最后一个重载 完成了实例化
*/
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), defaultHandler);
}

public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
threadFactory, defaultHandler);
}

public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), handler);
}

public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}

/**
* 执行一个任务,但没有返回值
*/
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}

/**
* 提交一个线程任务,有返回值。该方法继承自其父类 AbstractExecutorService,有多种重载,这是最常用的一个。
* 通过future.get()获取返回值(阻塞直到任务执行完)
*/
public <T> Future<T> submit(Callable<T> task) {
if (task == null) throw new NullPointerException();
RunnableFuture<T> ftask = newTaskFor(task);
execute(ftask);
return ftask;
}

/**
* 关闭线程池,不再接收新的任务,但会把已有的任务执行完
*/
public void shutdown() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
advanceRunState(SHUTDOWN);
interruptIdleWorkers();
onShutdown(); // hook for ScheduledThreadPoolExecutor
} finally {
mainLock.unlock();
}
tryTerminate();
}

/**
* 立即关闭线程池,已有的任务也会被抛弃
*/
public List<Runnable> shutdownNow() {
List<Runnable> tasks;
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
advanceRunState(STOP);
interruptWorkers();
tasks = drainQueue();
} finally {
mainLock.unlock();
}
tryTerminate();
return tasks;
}

public boolean isShutdown() {
return ! isRunning(ctl.get());
}
}

线程池执行流程,如下图所示。

avatar

Executors 提供的 4 种线程池

Executors 类 通过 ThreadPoolExecutor 封装了 4 种常用的线程池:CachedThreadPool,FixedThreadPool,ScheduledThreadPool 和 SingleThreadExecutor。其功能如下。

  1. CachedThreadPool:用来创建一个几乎可以无限扩大的线程池(最大线程数为 Integer.MAX_VALUE),适用于执行大量短生命周期的异步任务。
  2. FixedThreadPool:创建一个固定大小的线程池,保证线程数可控,不会造成线程过多,导致系统负载更为严重。
  3. SingleThreadExecutor:创建一个单线程的线程池,可以保证任务按调用顺序执行。
  4. ScheduledThreadPool:适用于执行 延时 或者 周期性 任务。

如何配置线程池

  • CPU 密集型任务
    尽量使用较小的线程池,一般为 CPU 核心数+1。 因为 CPU 密集型任务 使得 CPU 使用率 很高,若开过多的线程数,会造成 CPU 过度切换。
  • IO 密集型任务
    可以使用稍大的线程池,一般为 2*CPU 核心数。 IO 密集型任务 CPU 使用率 并不高,因此可以让 CPU 在等待 IO 的时候有其他线程去处理别的任务,充分利用 CPU 时间。

线程池的实际应用

Tomcat 在分发 web 请求 时使用了线程池来处理。

BlockingQueue

核心方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public interface BlockingQueue<E> extends Queue<E> {

// 将给定元素设置到队列中,如果设置成功返回true, 否则返回false。如果是往限定了长度的队列中设置值,推荐使用offer()方法。
boolean add(E e);

// 将给定的元素设置到队列中,如果设置成功返回true, 否则返回false. e的值不能为空,否则抛出空指针异常。
boolean offer(E e);

// 将元素设置到队列中,如果队列中没有多余的空间,该方法会一直阻塞,直到队列中有多余的空间。
void put(E e) throws InterruptedException;

// 将给定元素在给定的时间内设置到队列中,如果设置成功返回true, 否则返回false.
boolean offer(E e, long timeout, TimeUnit unit)
throws InterruptedException;

// 从队列中获取值,如果队列中没有值,线程会一直阻塞,直到队列中有值,并且该方法取得了该值。
E take() throws InterruptedException;

// 在给定的时间里,从队列中获取值,时间到了直接调用普通的 poll()方法,为null则直接返回null。
E poll(long timeout, TimeUnit unit)
throws InterruptedException;

// 获取队列中剩余的空间。
int remainingCapacity();

// 从队列中移除指定的值。
boolean remove(Object o);

// 判断队列中是否拥有该值。
public boolean contains(Object o);

// 将队列中值,全部移除,并发设置到给定的集合中。
int drainTo(Collection<? super E> c);

// 指定最多数量限制将队列中值,全部移除,并发设置到给定的集合中。
int drainTo(Collection<? super E> c, int maxElements);
}

主要实现类

  • ArrayBlockingQueue
    基于数组的阻塞队列实现,在 ArrayBlockingQueue 内部,维护了一个定长数组,以便缓存队列中的数据对象,这是一个常用的阻塞队列,除了一个定长数组外,ArrayBlockingQueue 内部还保存着两个整形变量,分别标识着队列的头部和尾部在数组中的位置。
    ArrayBlockingQueue 在生产者放入数据 和 消费者获取数据时,都是共用同一个锁对象,由此也意味着两者无法真正并行运行,这点尤其不同于 LinkedBlockingQueue。ArrayBlockingQueue 和 LinkedBlockingQueue 间还有一个明显的不同之处在于,前者在插入或删除元素时不会产生或销毁任何额外的对象实例,而后者则会生成一个额外的 Node 对象。这在长时间内需要高效并发地处理大批量数据的系统中,其对于 GC 的影响还是存在一定的区别。而在创建 ArrayBlockingQueue 时,我们还可以控制对象的内部锁是否采用公平锁,默认采用非公平锁。
  • LinkedBlockingQueue
    基于链表的阻塞队列,同 ArrayListBlockingQueue 类似,其内部也维持着一个数据缓冲队列(该队列由一个链表构成),当生产者往队列中放入一个数据时,队列会从生产者手中获取数据,并缓存在队列内部,而生产者立即返回;只有当队列缓冲区达到最大值缓存容量时(LinkedBlockingQueue 可以通过构造函数指定该值),才会阻塞生产者队列,直到消费者从队列中消费掉一份数据,生产者线程会被唤醒,反之对于消费者这端的处理也基于同样的原理。而 LinkedBlockingQueue 之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
    需要注意的是,如果构造一个 LinkedBlockingQueue 对象,而没有指定其容量大小,LinkedBlockingQueue 会默认一个类似无限大小的容量(Integer.MAX_VALUE),这样的话,如果生产者的速度一旦大于消费者的速度,也许还没有等到队列满阻塞产生,系统内存就有可能已被消耗殆尽了。
  • PriorityBlockingQueue
    基于优先级的阻塞队列(优先级的判断通过构造函数传入的 Compator 对象来决定),但需要注意的是 PriorityBlockingQueue 并不会阻塞数据生产者,而只会在没有可消费的数据时,阻塞数据的消费者。因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。在实现 PriorityBlockingQueue 时,内部控制线程同步的锁采用的是公平锁。

CAS 指令和原子类(应用比较多的就是计数器)

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能的额外损耗,因此这种同步被称为阻塞同步,它属于一种悲观的并发策略,我们称之为悲观锁。随着硬件和操作系统指令集的发展和优化,产生了非阻塞同步,被称为乐观锁。简单地说,就是先进行操作,操作完成之后再判断操作是否成功,是否有并发问题,如果有则进行失败补偿,如果没有就算操作成功,这样就从根本上避免了同步锁的弊端。

目前,在 Java 中应用最广泛的非阻塞同步就是 CAS。从 JDK1.5 以后,可以使用 CAS 操作,该操作由 sun.misc.Unsafe 类里的 compareAndSwapInt() 和 compareAndSwapLong() 等方法实现。通常情况下 sun.misc.Unsafe 类 对于开发者是不可见的,因此,JDK 提供了很多 CAS 包装类 简化开发者的使用,如 AtomicInteger。使用 Java 自带的 Atomic 原子类,可以避免同步锁带来的并发访问性能降低的问题,减少犯错的机会。

l

Spring SchedulingConfigurer 实现动态定时任务

一、前言
大家在日常工作中,一定使用过 Spring 的 @Scheduled 注解吧,通过该注解可以非常方便的帮助我们实现任务的定时执行。

但是该注解是不支持运行时动态修改执行间隔的,不知道你在业务中有没有这些需求和痛点:

在服务运行时能够动态修改定时任务的执行频率和执行开关,而无需重启服务和修改代码
能够基于配置,在不同环境/机器上,实现定时任务执行频率的差异化
这些都可以通过 Spring 的 SchedulingConfigurer 注解来实现。

这个注解其实大家并不陌生,如果有使用过 @Scheduled 的话,因为 @Scheduled 默认是单线程执行的,因此如果存在多个任务同时触发,可能触发阻塞。使用 SchedulingConfigurer 可以配置用于执行 @Scheduled 的线程池,来避免这个问题。

JAVA
@Configuration
public class ScheduleConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
//设定一个长度10的定时任务线程池
taskRegistrar.setScheduler(Executors.newScheduledThreadPool(10));
}
}
但其实这个接口,还可以实现动态定时任务的功能,下面来演示如何实现。

二、功能实现
后续定义的类开头的 DS 是 Dynamic Schedule 的缩写。

使用到的依赖,除了 Spring 外,还包括:

XML

org.apache.commons
commons-lang3

org.apache.commons commons-collections4 4.4 org.projectlombok lombok provided 1.18.18 2.1 @EnableScheduling 首先需要开启 @EnableScheduling 注解,直接在启动类添加即可:

JAVA
@EnableScheduling
@SpringBootApplication
public class DSApplication {
public static void main(String[] args) {
SpringApplication.run(DSApplication.class, args);
}
}
2.2 IDSTaskInfo
定义一个任务信息的接口,后续所有用于动态调整的任务信息对象,都需要实现该接口。

id:该任务信息的唯一 ID,用于唯一标识一个任务
cron:该任务执行的 cron 表达式。
isValid:任务开关
isChange:用于标识任务参数是否发生了改变
JAVA
public interface IDSTaskInfo {
/**
* 任务 ID
*/
long getId();

/**
 * 任务执行 cron 表达式
 */
String getCron();

/**
 * 任务是否有效
 */
boolean isValid();

/**
 * 判断任务是否发生变化
 */
boolean isChange(IDSTaskInfo oldTaskInfo);

}
2.3 DSContainer
顾名思义,是存放 IDSTaskInfo 的容器。

具有以下成员变量:

scheduleMap:用于暂存 IDSTaskInfo 和实际任务 ScheduledTask 的映射关系。其中:
task_id:作为主键,确保一个 IDSTaskInfo 只会被注册进一次
T:暂存当初注册时的 IDSTaskInfo,用于跟最新的 IDSTaskInfo 比较参数是否发生变化
ScheduledTask:暂存当初注册时生成的任务,如果需要取消任务的话,需要拿到该对象
Semaphore:确保每个任务实际执行时只有一个线程执行,不会产生并发问题
taskRegistrar:Spring 的任务注册管理器,用于注册任务到 Spring 容器中
name:调用方提供的类名
具有以下成员方法:

void checkTask(final T taskInfo, final TriggerTask triggerTask):检查 IDSTaskInfo,判断是否需要注册/取消任务。具体的逻辑包括:
如果任务已经注册:
如果任务无效:则取消任务
如果任务有效:
如果任务配置发生了变化:则取消任务并重新注册任务
如果任务没有注册:
如果任务有效:则注册任务
Semaphore getSemaphore():获取信号量属性。
JAVA
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.scheduling.config.ScheduledTask;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.scheduling.config.TriggerTask;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;

/**

  • 存放 IDSTaskInfo 容器
  • @author jitwxs
  • @date 2021年03月27日 16:29

/
@Slf4j
public class DSContainer {
/
*
* IDSTaskInfo和真实任务的关联关系
*
* <task_id, <Task, <Scheduled, Semaphore>>>
*/
private final Map<Long, Pair<T, Pair<ScheduledTask, Semaphore>>> scheduleMap = new ConcurrentHashMap<>();

private final ScheduledTaskRegistrar taskRegistrar;

private final String name;

public DSContainer(ScheduledTaskRegistrar scheduledTaskRegistrar, final String name) {
    this.taskRegistrar = scheduledTaskRegistrar;
    this.name = name;
}

/**
 * 注册任务
 * @param taskInfo 任务信息
 * @param triggerTask 任务的触发规则
 */
  public void checkTask(final T taskInfo, final TriggerTask triggerTask) {
    final long taskId = taskInfo.getId();

    if (scheduleMap.containsKey(taskId)) {
        if (taskInfo.isValid()) {
            final T oldTaskInfo = scheduleMap.get(taskId).getLeft();

            if(oldTaskInfo.isChange(taskInfo)) {
                log.info("DSContainer will register {} again because task config change, taskId: {}", name, taskId);
                cancelTask(taskId);
                registerTask(taskInfo, triggerTask);
            }
        } else {
            log.info("DSContainer will cancelTask {} because task not valid, taskId: {}", name, taskId);
            cancelTask(taskId);
        }
    } else {
        if (taskInfo.isValid()) {
            log.info("DSContainer will register {} task, taskId: {}", name, taskId);
            registerTask(taskInfo, triggerTask);
        }
    }
  }

/**
 * 获取 Semaphore,确保任务不会被多个线程同时执行
 */
  public Semaphore getSemaphore(final long taskId) {
    return this.scheduleMap.get(taskId).getRight().getRight();
  }

private void registerTask(final T taskInfo, final TriggerTask triggerTask) {
    final ScheduledTask latestTask = taskRegistrar.scheduleTriggerTask(triggerTask);
    this.scheduleMap.put(taskInfo.getId(), Pair.of(taskInfo, Pair.of(latestTask, new Semaphore(1))));
}

private void cancelTask(final long taskId) {
    final Pair<T, Pair<ScheduledTask, Semaphore>> pair = this.scheduleMap.remove(taskId);
    if (pair != null) {
        pair.getRight().getLeft().cancel();
    }
}

}
2.4 AbstractDSHandler
下面定义实际的动态线程池处理方法,这里采用抽象类实现,将共用逻辑封装起来,方便扩展。

具有以下抽象方法:

List listTaskInfo():获取所有的任务信息。
void doProcess(T taskInfo):实现实际执行任务的业务逻辑。
具有以下公共方法:

void configureTasks(ScheduledTaskRegistrar taskRegistrar):创建 DSContainer 对象,并创建一个单线程的任务定时执行,调用 scheduleTask() 方法处理实际逻辑。
void scheduleTask():首先加载所有任务信息,然后基于 cron 表达式生成 TriggerTask 对象,调用 checkTask() 方法确认是否需要注册/取消任务。当达到执行时间时,调用 execute() 方法,执行任务逻辑。
void execute(final T taskInfo):获取信号量,成功后执行任务逻辑。
JAVA
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.scheduling.config.TriggerTask;
import org.springframework.scheduling.support.CronTrigger;

import java.util.List;
import java.util.Objects;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

/**

  • 抽象 Dynamic Schedule 实现,基于 SchedulingConfigurer 实现
  • @author jitwxs
  • @date 2021年03月27日 16:41

*/
@Slf4j
public abstract class AbstractDSHandler implements SchedulingConfigurer {

private DSContainer<T> dsContainer;

private final String CLASS_NAME = getClass().getSimpleName();

/**
 * 获取所有的任务信息
 */
  protected abstract List<T> listTaskInfo();

/**
 * 做具体的任务逻辑
 *
 * <p/> 该方法执行时位于跟 SpringBoot @Scheduled 注解相同的线程池内。如果内部仍需要开子线程池执行,请务必同步等待子线程池执行完毕,否则可能会影响预期效果。
 */
  protected abstract void doProcess(T taskInfo) throws Throwable;

@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
    dsContainer = new DSContainer<>(taskRegistrar, CLASS_NAME);
    // 每隔 100ms 调度一次,用于读取所有任务
    taskRegistrar.addFixedDelayTask(this::scheduleTask, 1000);
}

/**
 * 调度任务,加载所有任务并注册
 */
  private void scheduleTask() {
    CollectionUtils.emptyIfNull(listTaskInfo()).forEach(taskInfo ->
            dsContainer.checkTask(taskInfo, new TriggerTask(() ->
                    this.execute(taskInfo), triggerContext -> new CronTrigger(taskInfo.getCron()).nextExecutionTime(triggerContext)
            ))
    );
  }

private void execute(final T taskInfo) {
    final long taskId = taskInfo.getId();

    try {
        Semaphore semaphore = dsContainer.getSemaphore(taskId);
        if (Objects.isNull(semaphore)) {
            log.error("{} semaphore is null, taskId: {}", CLASS_NAME, taskId);
            return;
        }
        if (semaphore.tryAcquire(3, TimeUnit.SECONDS)) {
            try {
                doProcess(taskInfo);
            } catch (Throwable throwable) {
                log.error("{} doProcess error, taskId: {}", CLASS_NAME, taskId, throwable);
            } finally {
                semaphore.release();
            }
        } else {
            log.warn("{} too many executor, taskId: {}", CLASS_NAME, taskId);
        }
    } catch (InterruptedException e) {
        log.warn("{} interruptedException error, taskId: {}", CLASS_NAME, taskId);
    } catch (Exception e) {
        log.error("{} execute error, taskId: {}", CLASS_NAME, taskId, e);
    }
}

}
三、快速测试
至此就完成了动态任务的框架搭建,下面让我们来快速测试下。为了尽量减少其他技术带来的复杂度,本次测试不涉及数据库和真实的定时任务,完全采用模拟实现。

3.1 模拟定时任务
为了模拟一个定时任务,我定义了一个 foo() 方法,其中只输出一句话。后续我将通过定时调用该方法,来模拟定时任务。

JAVA
import lombok.extern.slf4j.Slf4j;

import java.time.LocalTime;

@Slf4j
public class SchedulerTest {
public void foo() {
log.info(“{} Execute com.github.jitwxs.sample.ds.test.SchedulerTest#foo”, LocalTime.now());
}
}
3.2 实现 IDSTaskInfo
首先定义 IDSTaskInfo,我这里想通过反射来实现调用 foo() 方法,因此 reference 表示的是要调用方法的全路径。另外我实现了 isChange() 方法,只要 cron、isValid、reference 发生了变动,就认为该任务的配置发生了改变。

JAVA
import com.github.jitwxs.sample.ds.config.IDSTaskInfo;
import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class SchedulerTestTaskInfo implements IDSTaskInfo {
private long id;

private String cron;

private boolean isValid;

private String reference;

@Override
public boolean isChange(IDSTaskInfo oldTaskInfo) {
    if(oldTaskInfo instanceof SchedulerTestTaskInfo) {
        final SchedulerTestTaskInfo obj = (SchedulerTestTaskInfo) oldTaskInfo;
        return !this.cron.equals(obj.cron) || this.isValid != obj.isValid || !this.reference.equals(obj.getReference());
    } else {
        throw new IllegalArgumentException("Not Support SchedulerTestTaskInfo type");
    }
}

}
3.3 实现 AbstractDSHandler
有几个需要关注的:

(1)listTaskInfo() 返回值我使用了 volatile 变量,便于我修改它,模拟任务信息数据的改变。

(2)doProcess() 方法中,读取到 reference 后,使用反射进行调用,模拟定时任务的执行。

(3)额外实现了 ApplicationListener 接口,当服务启动后,每隔一段时间修改下任务信息,模拟业务中调整配置。

服务启动后,foo() 定时任务将每 10s 执行一次。
10s 后,将 foo() 定时任务执行周期从每 10s 执行调整为 1s 执行。
10s 后,关闭 foo() 定时任务执行。
10s 后,开启 foo() 定时任务执行。
JAVA
import com.github.jitwxs.sample.ds.config.AbstractDSHandler;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;

/**

  • @author jitwxs
  • @date 2021年03月27日 21:54

*/
@Component
public class SchedulerTestDSHandler extends AbstractDSHandler implements ApplicationListener {
public volatile List taskInfoList = Collections.singletonList(
SchedulerTestTaskInfo.builder()
.id(1)
.cron(“0/10 * * * * ? “)
.isValid(true)
.reference(“com.github.jitwxs.sample.ds.test.SchedulerTest#foo”)
.build()
);

@Override
protected List<SchedulerTestTaskInfo> listTaskInfo() {
    return taskInfoList;
}

@Override
protected void doProcess(SchedulerTestTaskInfo taskInfo) throws Throwable {
    final String reference = taskInfo.getReference();
    final String[] split = reference.split("#");
    if(split.length != 2) {
        return;
    }

   try {
       final Class<?> clazz = Class.forName(split[0]);
       final Method method = clazz.getMethod(split[1]);
       method.invoke(clazz.newInstance());
   } catch (Exception e) {
       e.printStackTrace();
   }
}

@Override
public void onApplicationEvent(ApplicationEvent applicationEvent) {
    Executors.newScheduledThreadPool(1).scheduleAtFixedRate(() -> {
        LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(10));

        // setting 1 seconds execute
        taskInfoList = Collections.singletonList(
                SchedulerTestTaskInfo.builder()
                        .id(1)
                        .cron("0/1 * * * * ? ")
                        .isValid(true)
                        .reference("com.github.jitwxs.sample.ds.test.SchedulerTest#foo")
                        .build()
        );
     
        LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(10));
     
        // setting not valid
        taskInfoList = Collections.singletonList(
                SchedulerTestTaskInfo.builder()
                        .id(1)
                        .cron("0/1 * * * * ? ")
                        .isValid(false)
                        .reference("com.github.jitwxs.sample.ds.test.SchedulerTest#foo")
                        .build()
        );
     
        LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(10));
     
        // setting valid
        taskInfoList = Collections.singletonList(
                SchedulerTestTaskInfo.builder()
                        .id(1)
                        .cron("0/1 * * * * ? ")
                        .isValid(true)
                        .reference("com.github.jitwxs.sample.ds.test.SchedulerTest#foo")
                        .build()
        );
    }, 12, 86400, TimeUnit.SECONDS);
}

}
3.4 运行程序
整个应用包结构如下:

包结构

运行程序后,在控制台可以观测到如下输出:

运行结果

四、后记
以上完成了动态定时任务的介绍,你能够根据本篇文章,实现以下需求吗:

本文基于 cron 表达式实现了频率控制,你能改用 fixedDelay 或 fixedRate 实现吗?
基于数据库/配置文件/配置中心,实现对服务中定时任务的动态频率调整和任务的启停。
开发一个数据表历史数据清理功能,能够动态配置要清理的表、清理的规则、清理的周期。
开发一个数据表异常数据告警功能,能够动态配置要扫描的表、告警的规则、扫描的周期。

l

java内存模型1

一、为什么有java内存模型?

  • 背景

    1. 现有计算机往往是多核的,每个核心下会有高速缓存。高速缓存的诞生是由于「CPU 与内存(主存)的速度存在差异」,L1 和 L2 缓存一般是「每个核心独占」一份的。
    2. 为了让 CPU 提高运算效率,处理器可能会对输入的代码进行「乱序执行」,也就是所谓的「指令重排序」。
    3. 一次对数值的修改操作往往是非原子性的(比如计实际上在计算机执行时就会分成多个指令)

    即:可见性、有序性、原子性

  • 单线程不存在以上问题

    1. 在永远单线程下,上面所讲的均不会存在什么问题,因为单线程意味着无并发。并且在单线程下,编译器/runtime/处理器都必须遵守as-if-serial语义,遵守as-if-serial意味着它们不会对「数据依赖关系的操作」做重排序。
    2. CPU为了效率,有了高速缓存、有了指令重排序等等,整块架构都变得复杂了。我们写的程序肯定也想要「充分」利用CPU的资源啊!于是乎,我们使用起了多线程
  • 多线程线程安全问题

    1. 缓存数据不一致:多个线程同时修改「共享变量」,CPU核心下的高速缓存是「不共享」的,那多个cache与内存之间的数据同步该怎么做?
    2. CPU指令重排序在多线程下会导致代码在非预期下执行,最终会导致结果存在错误的情况。
  • 缓存不一致问题

    1. 使用「总线锁」:某个核心在修改数据的过程中,其他核心均无法修改内存中的数据。(类似于独占内存的概念,只要有CPU在修改,那别的CPU就得等待当前CPU释放)
    2. 缓存一致性协议(MESI协议,其实协议有很多,只是举个大家都可能见过的)。MESI拆开英文是(Modified(修改状态)、Exclusive(独占状态)、Share(共享状态)、Invalid(无效状态))

    缓存一致性协议我认为可以理解为「缓存锁」,它针对的是「缓存行」(CacheIine)进行”加锁”,所谓「缓存行」其实就是高速缓存存储的最小单位。MESI协议的原理大概就是:当每个CPU读取共享变量之前,会先识别数据的「对象状态」(是修改、还是共享、还是独占、还是无效)。如果是独占,说明当前CPU将要得到的变量数据是最新的,没有被其他CPU所同时读取。如果是共享,说明当前CPU将要得到的变量数据还是最新的,有其他的CPU在同时读取,但还没被修改。如果是修改,说明当前CPU正在修改该变量的值,同时会向其他CPU发送该数据状态为invalid(无效)的通知,得到其他CPU响应后(其他CPU将数据状态从共享(share)变成invalid(无效)),会当前CPU将高速缓存的数据写到主存,并把自己的状态从modify(修改)变成exclusive (独占)。如果是无效,说明当前数据是被改过了,需 要从主存重新读取最新的数据。

    其实MESI协议做的就是判断「对象状态」,根据「对象状态」做不同的策略

    关键就在于某个CPU在对数据进行修改时,需要「同步」通知其他CPU,表示这个数据被我修改了,你们不能用了。
    比较于「总线锁」,MESI协议的”锁粒度”更小了,性能那肯定会更高咯

  • cpu还有优化

    1. 优化思路就是从「同步」变成「异步」。
      在修改时会「同步」告诉其他CPU,而现在则把最新修改的值写到「store buffe r」中,并通知其他CPU记得要改状态,随后CPU就直接返回干其他事了。等到收到其它CPU发过来的响应消息,再将数据更新到高速缓存中。其他CPU接收到invalid(无效)通知时,也会把接收到的消息放入「invalid queue」中,只要写到「invalid queue」就会直接返回告诉修改数据的CPU已经将状态置为「invalid」

二、什么是java内存模型

  1. 由于不同CPU架构的缓存体系不一样、缓存一致性协议不一样、重排序的策略不一样、所提供的内存屏障指令也有差异,为了简化Java开发人员的工作。Java封装了一套规范,这套规范就是「Java 内存模型」
  2. 再详细地说,「Java内存模型」希望屏蔽各种硬件和操作系统的访问差异,保证了Java程序在各种平台下对内存的访问都能得到一致效果。
  3. 目的是解决多线程存在的原子性、可见性(缓存一致性)以及有序性问题。

java内存模型它是一种规范,java虚拟机会实现这种规范

三、java内存模型的内容

  1. java内存模型的抽象结构

    定义:

    • java内存模型定义了:java线程对内存数据进行交互的规范。

    • 线程之间的「共享变量」存储在「主内存」中,每个线程都有自己私有的「本地内存」,「本地内存」存储了该线程以读/写共享变量的副本。

    • 本地内存是Java内存模型的抽象概念,并不是真实存在的。

    规定:

    • Java内存模型规定了:线程对变量的所有操作都必须在「本地内存」进行,「不能直接读写主内存」的变量
    • Java内存模型定义了8种操作来完成「变量如何从主内存到本地内存,以及变量如何从本地内存到主内存」,分别是read/load/use/assign/store/write/lock/unlock操作
    • 看着8个操作很多,对变量的一次读写就涵盖了这些操作了,我再画个图给你讲讲

  2. happen-beforeguize

    • 按我的理解下,happen-before实际上也是一套「规则」。Java内存模型定义了这套规则,目的是为了阐述「操作之间」的内存「可见性」。

    • 从上次讲述「指令重排」就提到了,在CPU和编译器层面上都有指令重排的问题。

      指令重排虽然是能提高运行的效率,但在并发编程中,我们在兼顾「效率」的前提下,还希望「程序结果」能由我们掌控的。
      说白了就是:在某些重要的场景下,这一组操作都不能进行重排序,「前面一个操作的结果对后续操作必须是可见的」

    • 于是,Java内存模型 happen-就提出了-b efore这套规则,规则总共有8条:比如传递性、 volatile变量规则、程序顺序规则、监视器锁的规则(具体看规则的含义就好了,这块不难)

    • 只要记住,有了 happen-before-这些规则。我们写的代码只要在这些规则下,前一个操作的结果对后续操作是可见的,是不会发生重排序的。

  3. 对volatile内存语义的探讨

  • 嗯,volatile是Java的一个关键字
    为什么讲Java内存模型往往就会讲到volatile这个关键字呢,我觉得主要是它的特性:可见性和有序性(禁止重排序

  • Java内存模型这个规范,很大程度下就是为了解决可见性和有序性的问题。

  • Java内存模型为了实现volatile有序性和可见性,定义了4种内存屏障的「规范」,分别是LoadLoad/LoadStore/StoreLoad/StoreStore

  • 回到volatile上,说白了,就是在volatile「前后」加上「内存屏障」,使得编译器和CPU无法进行重排序,致使有序,并且写volatile变量对其他线程可见。

  • Java内存模型定义了规范,那Java虚拟机就得实现啊,是不是?

    • 之前看过Hotspot虚拟机的实现,在「汇编」层面上实际是通过Lock前缀指令来实现的,而不是各种fence指令(主要原因就是简便。因为大部分平台都支持lock指令,而fence指令是x86平台的)。
    • lock指令能保证:禁止CPU和编译器的重排序(保证了有序性)、保证CPU写核心的指令可以立即生效且其他核心的缓存数据失效(保证了可见性)
  • volatile和MESI协议有啥关系?

    • 没啥关系:Java内存模型关注的是编程语言层面上,它是高维度的抽象。

    • MESI是CPU缓存一致性协议,不同的CPU架构都不一样,可能有的CPU压根就没用MESI协议..

    • 只不过MESI名声大,大家就都拿他来举例子了。

    • MESI可能只是在「特定的场景下」为实现volatile的可见性/有序性而使用到的一部分罢了

    • 为了让Java程序员屏蔽上面这些底层知识,快速地入门使用volatile变量

    • Java内存模型的happen-before规则中就有对volatile变量规则的定义:
      这条规则的内容其实就是:对一个volatil e变量的写操作相对于后续对这个volatile变量的读操作可见

      它通过happen-before规则来规定:只要变量声明了volatile关键字,写后再读,读必须可见写的值。(可见性、有序性)

l

Java线程池实现原理及其在美团业务中的实践

随着计算机行业的飞速发展,摩尔定律逐渐失效,多核CPU成为主流。使用多线程并行计算逐渐成为开发人员提升服务器性能的基本武器。J.U.C提供的线程池:ThreadPoolExecutor类,帮助开发人员管理线程并方便地执行并行任务。了解并合理使用线程池,是一个开发人员必修的基本功。

本文开篇简述线程池概念和用途,接着结合线程池的源码,帮助读者领略线程池的设计思路,最后回归实践,通过案例讲述使用线程池遇到的问题,并给出了一种动态化线程池解决方案。

一、写在前面

1.1 线程池是什么

线程池(Thread Pool)是一种基于池化思想管理线程的工具,经常出现在多线程服务器中,如MySQL。

线程过多会带来额外的开销,其中包括创建销毁线程的开销、调度线程的开销等等,同时也降低了计算机的整体性能。线程池维护多个线程,等待监督管理者分配可并发执行的任务。这种做法,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用。

而本文描述线程池是JDK中提供的ThreadPoolExecutor类。

当然,使用线程池可以带来一系列好处:

  • 降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗。
  • 提高响应速度:任务到达时,无需等待线程创建即可立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。
  • 提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行。

1.2 线程池解决的问题是什么

线程池解决的核心问题就是资源管理问题。在并发环境下,系统不能够确定在任意时刻中,有多少任务需要执行,有多少资源需要投入。这种不确定性将带来以下若干问题:

  1. 频繁申请/销毁资源和调度资源,将带来额外的消耗,可能会非常巨大。
  2. 对资源无限申请缺少抑制手段,易引发系统资源耗尽的风险。
  3. 系统无法合理管理内部的资源分布,会降低系统的稳定性。

为解决资源分配这个问题,线程池采用了“池化”(Pooling)思想。池化,顾名思义,是为了最大化收益并最小化风险,而将资源统一在一起管理的一种思想。

Pooling is the grouping together of resources (assets, equipment, personnel, effort, etc.) for the purposes of maximizing advantage or minimizing risk to the users. The term is used in finance, computing and equipment management.——wikipedia

“池化”思想不仅仅能应用在计算机领域,在金融、设备、人员管理、工作管理等领域也有相关的应用。

在计算机领域中的表现为:统一管理IT资源,包括服务器、存储、和网络资源等等。通过共享资源,使用户在低投入中获益。除去线程池,还有其他比较典型的几种使用策略包括:

  1. 内存池(Memory Pooling):预先申请内存,提升申请内存速度,减少内存碎片。
  2. 连接池(Connection Pooling):预先申请数据库连接,提升申请连接的速度,降低系统的开销。
  3. 实例池(Object Pooling):循环使用对象,减少资源在初始化和释放时的昂贵损耗。

在了解完“是什么”和“为什么”之后,下面我们来一起深入一下线程池的内部实现原理。

二、线程池核心设计与实现

在前文中,我们了解到:线程池是一种通过“池化”思想,帮助我们管理线程而获取并发性的工具,在Java中的体现是ThreadPoolExecutor类。那么它的的详细设计与实现是什么样的呢?我们会在本章进行详细介绍。

2.1 总体设计

Java中的线程池核心实现类是ThreadPoolExecutor,本章基于JDK 1.8的源码来分析Java线程池的核心设计与实现。我们首先来看一下ThreadPoolExecutor的UML类图,了解下ThreadPoolExecutor的继承关系。

图1 ThreadPoolExecutor UML类图

图1 ThreadPoolExecutor UML类图

ThreadPoolExecutor实现的顶层接口是Executor,顶层接口Executor提供了一种思想:将任务提交和任务执行进行解耦。用户无需关注如何创建线程,如何调度线程来执行任务,用户只需提供Runnable对象,将任务的运行逻辑提交到执行器(Executor)中,由Executor框架完成线程的调配和任务的执行部分。ExecutorService接口增加了一些能力:(1)扩充执行任务的能力,补充可以为一个或一批异步任务生成Future的方法;(2)提供了管控线程池的方法,比如停止线程池的运行。AbstractExecutorService则是上层的抽象类,将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可。最下层的实现类ThreadPoolExecutor实现最复杂的运行部分,ThreadPoolExecutor将会一方面维护自身的生命周期,另一方面同时管理线程和任务,使两者良好的结合从而执行并行任务。

ThreadPoolExecutor是如何运行,如何同时维护线程和执行任务的呢?其运行机制如下图所示:\

图2 ThreadPoolExecutor运行流程

图2 ThreadPoolExecutor运行流程

线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。线程池的运行主要分成两部分:任务管理、线程管理。任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:(1)直接申请线程执行该任务;(2)缓冲到队列中等待线程执行;(3)拒绝该任务。线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。

接下来,我们会按照以下三个部分去详细讲解线程池运行机制:

  1. 线程池如何维护自身状态。
  2. 线程池如何管理任务。
  3. 线程池如何管理线程。

2.2 生命周期管理

线程池运行的状态,并不是用户显式设置的,而是伴随着线程池的运行,由内部来维护。线程池内部使用一个变量维护两个值:运行状态(runState)和线程数量 (workerCount)。在具体实现中,线程池将运行状态(runState)、线程数量 (workerCount)两个关键参数的维护放在了一起,如下代码所示:

1
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

ctl这个AtomicInteger类型,是对线程池的运行状态和线程池中有效线程的数量进行控制的一个字段, 它同时包含两部分的信息:线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount),高3位保存runState,低29位保存workerCount,两个变量之间互不干扰。用一个变量去存储两个值,可避免在做相关决策时,出现不一致的情况,不必为了维护两者的一致,而占用锁资源。通过阅读线程池源代码也可以发现,经常出现要同时判断线程池运行状态和线程数量的情况。线程池也提供了若干方法去供用户获得线程池当前的运行状态、线程个数。这里都使用的是位运算的方式,相比于基本运算,速度也会快很多。

关于内部封装的获取生命周期状态、获取线程池线程数量的计算方法如以下代码所示:

1
2
3
private static int runStateOf(int c)     { return c & ~CAPACITY; } //计算当前运行状态
private static int workerCountOf(int c) { return c & CAPACITY; } //计算当前线程数量
private static int ctlOf(int rs, int wc) { return rs | wc; } //通过状态和线程数生成ctl

ThreadPoolExecutor的运行状态有5种,分别为:

img

其生命周期转换如下入所示:

图3 线程池生命周期

图3 线程池生命周期

2.3 任务执行机制

2.3.1 任务调度

任务调度是线程池的主要入口,当用户提交了一个任务,接下来这个任务将如何执行都是由这个阶段决定的。了解这部分就相当于了解了线程池的核心运行机制。

首先,所有任务的调度都是由execute方法完成的,这部分完成的工作是:检查现在线程池的运行状态、运行线程数、运行策略,决定接下来执行的流程,是直接申请线程执行,或是缓冲到队列中执行,亦或是直接拒绝该任务。其执行过程如下:

  1. 首先检测线程池运行状态,如果不是RUNNING,则直接拒绝,线程池要保证在RUNNING的状态下执行任务。
  2. 如果workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。
  3. 如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。
  4. 如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。
  5. 如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。

其执行流程如下图所示:

图4 任务调度流程

图4 任务调度流程

2.3.2 任务缓冲

任务缓冲模块是线程池能够管理任务的核心部分。线程池的本质是对任务和线程的管理,而做到这一点最关键的思想就是将任务和线程两者解耦,不让两者直接关联,才可以做后续的分配工作。线程池中是以生产者消费者模式,通过一个阻塞队列来实现的。阻塞队列缓存任务,工作线程从阻塞队列中获取任务。

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用。阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

下图中展示了线程1往阻塞队列中添加元素,而线程2从阻塞队列中移除元素:

图5 阻塞队列

图5 阻塞队列

使用不同的队列可以实现不一样的任务存取策略。在这里,我们可以再介绍下阻塞队列的成员:

img

2.3.3 任务申请

由上文的任务分配部分可知,任务的执行有两种可能:一种是任务直接由新创建的线程执行。另一种是线程从任务队列中获取任务然后执行,执行完任务的空闲线程会再次去从队列中申请任务再去执行。第一种情况仅出现在线程初始创建的时候,第二种是线程获取任务绝大多数的情况。

线程需要从任务缓存模块中不断地取任务执行,帮助线程从阻塞队列中获取任务,实现线程管理模块和任务管理模块之间的通信。这部分策略由getTask方法实现,其执行流程如下图所示:

图6 获取任务流程图

图6 获取任务流程图

getTask这部分进行了多次判断,为的是控制线程的数量,使其符合线程池的状态。如果线程池现在不应该持有那么多线程,则会返回null值。工作线程Worker会不断接收新任务去执行,而当工作线程Worker接收不到任务的时候,就会开始被回收。

2.3.4 任务拒绝

任务拒绝模块是线程池的保护部分,线程池有一个最大的容量,当线程池的任务缓存队列已满,并且线程池中的线程数目达到maximumPoolSize时,就需要拒绝掉该任务,采取任务拒绝策略,保护线程池。

拒绝策略是一个接口,其设计如下:

1
2
3
public interface RejectedExecutionHandler {
void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}

用户可以通过实现这个接口去定制拒绝策略,也可以选择JDK提供的四种已有拒绝策略,其特点如下:

img

2.4 Worker线程管理

2.4.1 Worker线程

线程池为了掌握线程的状态并维护线程的生命周期,设计了线程池内的工作线程Worker。我们来看一下它的部分代码:

1
2
3
4
private final class Worker extends AbstractQueuedSynchronizer implements Runnable{
final Thread thread;//Worker持有的线程
Runnable firstTask;//初始化的任务,可以为null
}

Worker这个工作线程,实现了Runnable接口,并持有一个线程thread,一个初始化的任务firstTask。thread是在调用构造方法时通过ThreadFactory来创建的线程,可以用来执行任务;firstTask用它来保存传入的第一个任务,这个任务可以有也可以为null。如果这个值是非空的,那么线程就会在启动初期立即执行这个任务,也就对应核心线程创建时的情况;如果这个值是null,那么就需要创建一个线程去执行任务列表(workQueue)中的任务,也就是非核心线程的创建。

Worker执行任务的模型如下图所示:

图7 Worker执行任务

图7 Worker执行任务

线程池需要管理线程的生命周期,需要在线程长时间不运行的时候进行回收。线程池使用一张Hash表去持有线程的引用,这样可以通过添加引用、移除引用这样的操作来控制线程的生命周期。这个时候重要的就是如何判断线程是否在运行。

Worker是通过继承AQS,使用AQS来实现独占锁这个功能。没有使用可重入锁ReentrantLock,而是使用AQS,为的就是实现不可重入的特性去反应线程现在的执行状态。

1.lock方法一旦获取了独占锁,表示当前线程正在执行任务中。 2.如果正在执行任务,则不应该中断线程。 3.如果该线程现在不是独占锁的状态,也就是空闲的状态,说明它没有在处理任务,这时可以对该线程进行中断。 4.线程池在执行shutdown方法或tryTerminate方法时会调用interruptIdleWorkers方法来中断空闲的线程,interruptIdleWorkers方法会使用tryLock方法来判断线程池中的线程是否是空闲状态;如果线程是空闲状态则可以安全回收。

在线程回收过程中就使用到了这种特性,回收过程如下图所示:

图8 线程池回收过程

图8 线程池回收过程

2.4.2 Worker线程增加

增加线程是通过线程池中的addWorker方法,该方法的功能就是增加一个线程,该方法不考虑线程池是在哪个阶段增加的该线程,这个分配线程的策略是在上个步骤完成的,该步骤仅仅完成增加线程,并使它运行,最后返回是否成功这个结果。addWorker方法有两个参数:firstTask、core。firstTask参数用于指定新增的线程执行的第一个任务,该参数可以为空;core参数为true表示在新增线程时会判断当前活动线程数是否少于corePoolSize,false表示新增线程前需要判断当前活动线程数是否少于maximumPoolSize,其执行流程如下图所示:

图9 申请线程执行流程图

图9 申请线程执行流程图

2.4.3 Worker线程回收

线程池中线程的销毁依赖JVM自动的回收,线程池做的工作是根据当前线程池的状态维护一定数量的线程引用,防止这部分线程被JVM回收,当线程池决定哪些线程需要回收时,只需要将其引用消除即可。Worker被创建出来后,就会不断地进行轮询,然后获取任务去执行,核心线程可以无限等待获取任务,非核心线程要限时获取任务。当Worker无法获取到任务,也就是获取的任务为空时,循环会结束,Worker会主动消除自身在线程池内的引用。

1
2
3
4
5
6
7
try {
while (task != null || (task = getTask()) != null) {
//执行任务
}
} finally {
processWorkerExit(w, completedAbruptly);//获取不到任务时,主动回收自己
}

线程回收的工作是在processWorkerExit方法完成的。

图10 线程销毁流程

图10 线程销毁流程

事实上,在这个方法中,将线程引用移出线程池就已经结束了线程销毁的部分。但由于引起线程销毁的可能性有很多,线程池还要判断是什么引发了这次销毁,是否要改变线程池的现阶段状态,是否要根据新状态,重新分配线程。

2.4.4 Worker线程执行任务

在Worker类中的run方法调用了runWorker方法来执行任务,runWorker方法的执行过程如下:

1.while循环不断地通过getTask()方法获取任务。 2.getTask()方法从阻塞队列中取任务。 3.如果线程池正在停止,那么要保证当前线程是中断状态,否则要保证当前线程不是中断状态。 4.执行任务。 5.如果getTask结果为null则跳出循环,执行processWorkerExit()方法,销毁线程。

执行流程如下图所示:

图11 执行任务流程

图11 执行任务流程

三、线程池在业务中的实践

3.1 业务背景

在当今的互联网业界,为了最大程度利用CPU的多核性能,并行运算的能力是不可或缺的。通过线程池管理线程获取并发性是一个非常基础的操作,让我们来看两个典型的使用线程池获取并发性的场景。

场景1:快速响应用户请求

描述:用户发起的实时请求,服务追求响应时间。比如说用户要查看一个商品的信息,那么我们需要将商品维度的一系列信息如商品的价格、优惠、库存、图片等等聚合起来,展示给用户。

分析:从用户体验角度看,这个结果响应的越快越好,如果一个页面半天都刷不出,用户可能就放弃查看这个商品了。而面向用户的功能聚合通常非常复杂,伴随着调用与调用之间的级联、多级级联等情况,业务开发同学往往会选择使用线程池这种简单的方式,将调用封装成任务并行的执行,缩短总体响应时间。另外,使用线程池也是有考量的,这种场景最重要的就是获取最大的响应速度去满足用户,所以应该不设置队列去缓冲并发任务,调高corePoolSize和maxPoolSize去尽可能创造多的线程快速执行任务。

图12 并行执行任务提升任务响应速度

图12 并行执行任务提升任务响应速度

场景2:快速处理批量任务

描述:离线的大量计算任务,需要快速执行。比如说,统计某个报表,需要计算出全国各个门店中有哪些商品有某种属性,用于后续营销策略的分析,那么我们需要查询全国所有门店中的所有商品,并且记录具有某属性的商品,然后快速生成报表。

分析:这种场景需要执行大量的任务,我们也会希望任务执行的越快越好。这种情况下,也应该使用多线程策略,并行计算。但与响应速度优先的场景区别在于,这类场景任务量巨大,并不需要瞬时的完成,而是关注如何使用有限的资源,尽可能在单位时间内处理更多的任务,也就是吞吐量优先的问题。所以应该设置队列去缓冲并发任务,调整合适的corePoolSize去设置处理任务的线程数。在这里,设置的线程数过多可能还会引发线程上下文切换频繁的问题,也会降低处理任务的速度,降低吞吐量。

图13 并行执行任务提升批量任务执行速度

图13 并行执行任务提升批量任务执行速度

3.2 实际问题及方案思考

线程池使用面临的核心的问题在于:线程池的参数并不好配置。一方面线程池的运行机制不是很好理解,配置合理需要强依赖开发人员的个人经验和知识;另一方面,线程池执行的情况和任务类型相关性较大,IO密集型和CPU密集型的任务运行起来的情况差异非常大,这导致业界并没有一些成熟的经验策略帮助开发人员参考。

关于线程池配置不合理引发的故障,公司内部有较多记录,下面举一些例子:

Case1:2018年XX页面展示接口大量调用降级:

事故描述:XX页面展示接口产生大量调用降级,数量级在几十到上百。

事故原因:该服务展示接口内部逻辑使用线程池做并行计算,由于没有预估好调用的流量,导致最大核心数设置偏小,大量抛出RejectedExecutionException,触发接口降级条件,示意图如下:

图14 线程数核心设置过小引发RejectExecutionException

图14 线程数核心设置过小引发RejectExecutionException

Case2:2018年XX业务服务不可用S2级故障

事故描述:XX业务提供的服务执行时间过长,作为上游服务整体超时,大量下游服务调用失败。

事故原因:该服务处理请求内部逻辑使用线程池做资源隔离,由于队列设置过长,最大线程数设置失效,导致请求数量增加时,大量任务堆积在队列中,任务执行时间过长,最终导致下游服务的大量调用超时失败。示意图如下:

图15 线程池队列长度设置过长、corePoolSize设置过小导致任务执行速度低

图15 线程池队列长度设置过长、corePoolSize设置过小导致任务执行速度低

业务中要使用线程池,而使用不当又会导致故障,那么我们怎样才能更好地使用线程池呢?针对这个问题,我们下面延展几个方向:

1. 能否不用线程池?

回到最初的问题,业务使用线程池是为了获取并发性,对于获取并发性,是否可以有什么其他的方案呢替代?我们尝试进行了一些其他方案的调研:

img

综合考虑,这些新的方案都能在某种情况下提升并行任务的性能,然而本次重点解决的问题是如何更简易、更安全地获得的并发性。另外,Actor模型的应用实际上甚少,只在Scala中使用广泛,协程框架在Java中维护的也不成熟。这三者现阶段都不是足够的易用,也并不能解决业务上现阶段的问题。

2. 追求参数设置合理性?

有没有一种计算公式,能够让开发同学很简易地计算出某种场景中的线程池应该是什么参数呢?

带着这样的疑问,我们调研了业界的一些线程池参数配置方案:

img

调研了以上业界方案后,我们并没有得出通用的线程池计算方式。并发任务的执行情况和任务类型相关,IO密集型和CPU密集型的任务运行起来的情况差异非常大,但这种占比是较难合理预估的,这导致很难有一个简单有效的通用公式帮我们直接计算出结果。

3. 线程池参数动态化?

尽管经过谨慎的评估,仍然不能够保证一次计算出来合适的参数,那么我们是否可以将修改线程池参数的成本降下来,这样至少可以发生故障的时候可以快速调整从而缩短故障恢复的时间呢?基于这个思考,我们是否可以将线程池的参数从代码中迁移到分布式配置中心上,实现线程池参数可动态配置和即时生效,线程池参数动态化前后的参数修改流程对比如下:

图16 动态修改线程池参数新旧流程对比

图16 动态修改线程池参数新旧流程对比

基于以上三个方向对比,我们可以看出参数动态化方向简单有效。

3.3 动态化线程池

3.3.1 整体设计

动态化线程池的核心设计包括以下三个方面:

  1. 简化线程池配置:线程池构造参数有8个,但是最核心的是3个:corePoolSize、maximumPoolSize,workQueue,它们最大程度地决定了线程池的任务分配和线程分配策略。考虑到在实际应用中我们获取并发性的场景主要是两种:(1)并行执行子任务,提高响应速度。这种情况下,应该使用同步队列,没有什么任务应该被缓存下来,而是应该立即执行。(2)并行执行大批次任务,提升吞吐量。这种情况下,应该使用有界队列,使用队列去缓冲大批量的任务,队列容量必须声明,防止任务无限制堆积。所以线程池只需要提供这三个关键参数的配置,并且提供两种队列的选择,就可以满足绝大多数的业务需求,Less is More。
  2. 参数可动态修改:为了解决参数不好配,修改参数成本高等问题。在Java线程池留有高扩展性的基础上,封装线程池,允许线程池监听同步外部的消息,根据消息进行修改配置。将线程池的配置放置在平台侧,允许开发同学简单的查看、修改线程池配置。
  3. 增加线程池监控:对某事物缺乏状态的观测,就对其改进无从下手。在线程池执行任务的生命周期添加监控能力,帮助开发同学了解线程池状态。

图17 动态化线程池整体设计

图17 动态化线程池整体设计

3.3.2 功能架构

动态化线程池提供如下功能:

动态调参:支持线程池参数动态调整、界面化操作;包括修改线程池核心大小、最大核心大小、队列长度等;参数修改后及时生效。 任务监控:支持应用粒度、线程池粒度、任务粒度的Transaction监控;可以看到线程池的任务执行情况、最大任务执行时间、平均任务执行时间、95/99线等。 负载告警:线程池队列任务积压到一定值的时候会通过大象(美团内部通讯工具)告知应用开发负责人;当线程池负载数达到一定阈值的时候会通过大象告知应用开发负责人。 操作监控:创建/修改和删除线程池都会通知到应用的开发负责人。 操作日志:可以查看线程池参数的修改记录,谁在什么时候修改了线程池参数、修改前的参数值是什么。 权限校验:只有应用开发负责人才能够修改应用的线程池参数。

图18 动态化线程池功能架构

图18 动态化线程池功能架构

参数动态化

JDK原生线程池ThreadPoolExecutor提供了如下几个public的setter方法,如下图所示:

图19 JDK 线程池参数设置接口

图19 JDK 线程池参数设置接口

JDK允许线程池使用方通过ThreadPoolExecutor的实例来动态设置线程池的核心策略,以setCorePoolSize为方法例,在运行期线程池使用方调用此方法设置corePoolSize之后,线程池会直接覆盖原来的corePoolSize值,并且基于当前值和原始值的比较结果采取不同的处理策略。对于当前值小于当前工作线程数的情况,说明有多余的worker线程,此时会向当前idle的worker线程发起中断请求以实现回收,多余的worker在下次idel的时候也会被回收;对于当前值大于原始值且当前队列中有待执行任务,则线程池会创建新的worker线程来执行队列任务,setCorePoolSize具体流程如下:

图20 setCorePoolSize方法执行流程

图20 setCorePoolSize方法执行流程

线程池内部会处理好当前状态做到平滑修改,其他几个方法限于篇幅,这里不一一介绍。重点是基于这几个public方法,我们只需要维护ThreadPoolExecutor的实例,并且在需要修改的时候拿到实例修改其参数即可。基于以上的思路,我们实现了线程池参数的动态化、线程池参数在管理平台可配置可修改,其效果图如下图所示:

图21 可动态修改线程池参数

图21 可动态修改线程池参数

用户可以在管理平台上通过线程池的名字找到指定的线程池,然后对其参数进行修改,保存后会实时生效。目前支持的动态参数包括核心数、最大值、队列长度等。除此之外,在界面中,我们还能看到用户可以配置是否开启告警、队列等待任务告警阈值、活跃度告警等等。关于监控和告警,我们下面一节会对齐进行介绍。

线程池监控

除了参数动态化之外,为了更好地使用线程池,我们需要对线程池的运行状况有感知,比如当前线程池的负载是怎么样的?分配的资源够不够用?任务的执行情况是怎么样的?是长任务还是短任务?基于对这些问题的思考,动态化线程池提供了多个维度的监控和告警能力,包括:线程池活跃度、任务的执行Transaction(频率、耗时)、Reject异常、线程池内部统计信息等等,既能帮助用户从多个维度分析线程池的使用情况,又能在出现问题第一时间通知到用户,从而避免故障或加速故障恢复。

1. 负载监控和告警

线程池负载关注的核心问题是:基于当前线程池参数分配的资源够不够。对于这个问题,我们可以从事前和事中两个角度来看。事前,线程池定义了“活跃度”这个概念,来让用户在发生Reject异常之前能够感知线程池负载问题,线程池活跃度计算公式为:线程池活跃度 = activeCount/maximumPoolSize。这个公式代表当活跃线程数趋向于maximumPoolSize的时候,代表线程负载趋高。事中,也可以从两方面来看线程池的过载判定条件,一个是发生了Reject异常,一个是队列中有等待任务(支持定制阈值)。以上两种情况发生了都会触发告警,告警信息会通过大象推送给服务所关联的负责人。

图22 大象告警通知

图22 大象告警通知

2. 任务级精细化监控

在传统的线程池应用场景中,线程池中的任务执行情况对于用户来说是透明的。比如在一个具体的业务场景中,业务开发申请了一个线程池同时用于执行两种任务,一个是发消息任务、一个是发短信任务,这两类任务实际执行的频率和时长对于用户来说没有一个直观的感受,很可能这两类任务不适合共享一个线程池,但是由于用户无法感知,因此也无从优化。动态化线程池内部实现了任务级别的埋点,且允许为不同的业务任务指定具有业务含义的名称,线程池内部基于这个名称做Transaction打点,基于这个功能,用户可以看到线程池内部任务级别的执行情况,且区分业务,任务监控示意图如下图所示:

图23 线程池任务执行监控

图23 线程池任务执行监控

3. 运行时状态实时查看

用户基于JDK原生线程池ThreadPoolExecutor提供的几个public的getter方法,可以读取到当前线程池的运行状态以及参数,如下图所示:

图24 线程池实时运行情况

图24 线程池实时运行情况

动态化线程池基于这几个接口封装了运行时状态实时查看的功能,用户基于这个功能可以了解线程池的实时状态,比如当前有多少个工作线程,执行了多少个任务,队列中等待的任务数等等。效果如下图所示:

图25 线程池实时运行情况

图25 线程池实时运行情况

3.4 实践总结

面对业务中使用线程池遇到的实际问题,我们曾回到支持并发性问题本身来思考有没有取代线程池的方案,也曾尝试着去追求线程池参数设置的合理性,但面对业界方案具体落地的复杂性、可维护性以及真实运行环境的不确定性,我们在前两个方向上可谓“举步维艰”。最终,我们回到线程池参数动态化方向上探索,得出一个且可以解决业务问题的方案,虽然本质上还是没有逃离使用线程池的范畴,但是在成本和收益之间,算是取得了一个很好的平衡。成本在于实现动态化以及监控成本不高,收益在于:在不颠覆原有线程池使用方式的基础之上,从降低线程池参数修改的成本以及多维度监控这两个方面降低了故障发生的概率。希望本文提供的动态化线程池思路能对大家有帮助。

四、参考资料

l

jvm调优

一、优化系统的思路

没有jvm调优经历

  1. 一般来说关系型数据库是先到瓶颈,首先排查是否为数据库的问题
    (这个过程中就需要评估自己建的索引是否合理、是否需要引入分布式缓存、是否需要分库分表等等
  2. 然后,我们会考虑是否需要扩容(横向和纵向都会考虑)
    (这个过程中我们会怀疑是系统的压力过大或者是系统的硬件能力不足导致系统频繁出现问题)
  3. 接着,应用代码层面上排查并优化
    (扩容是不能无止境的,里头里外都是钱阿,这个过程中我们会审视自己写的代码是否存在资源浪费的问题,又或者是在逻辑上可存在优化的地方,比如说通过并行的方式处理某些请求)
  4. 再接着,JVM层面上排查并优化
    (审视完代码之后,这个过程我们观察J VM是否存在多次GC问题等等)
  5. 最后,网络和操作系统层面排查
    (这个过程查看内存/CPU/网络/硬盘读写指标是否正常等等)

绝大多数情况下,到第三步就结束了,一般经过「运维团队」给我们设置的JVM和机器上的参数,已经满足绝大多数的需求了。

二、举例子

之前有过其他团队在「大促」发现接口处理超时的问题,那时候查各种监控怀疑是FULLGC频率稍大所导致的

  1. 第一想法不是说去调节各种JVM参数来进行优化,而是直接加机器(用最粗暴的方法,解决问题是最简单的,扩容YYDS)
  2. 不过,我是学过JVM相关的调优命令和思路的。
    在我的理解下,调优JVM其实就是在「理解」JVM内存结构以及各种垃圾收集器前提下,结合自己的现有的业务来「调整参数」,使自己的应用能够正常稳定运行。
    • 一般调优JVM我们认为会有几种指标可以参考:「吞吐量」、「停顿时间」和「垃圾回收频率」
    • 基于这些指标,我们就有可能需要调整:
      1. 内存区域大小以及相关策略(比如整块堆内存占多少、新生代占多少、老年代占多少、Survivor占多少、晋升老年代的条件等等)
        比如(-Xmx:设置堆的最大值、-Xms:设置堆的初始值、-Xmn:表示年轻代的大小、-XX:SurvivorRatio:伊甸区和幸存区的比例等等)
        按经验来说:I0密集型的可以稍微把「年轻代」空间加大些,因为大多数对象都是在年轻代就会灭亡。内存计算密集型的可以稍微把「老年代」空间加大些,对象存活时间会更长些)
      2. 垃圾回收器(选择合适的垃圾回收器,以及各个垃圾回收器的各种调优参数)
        比如(-XX:+UseG1GC:指定JVM使用的垃圾回收器为G1、-XX:MaxGCPause Millis:设置目标停顿时间、-XX:InitiatingHeapOccupancyPercent:当整个堆内存使用达到一定比例,全局并发标记阶段就会被启动等等)
        没错,这些都是因地制宜,具体问题具体分析(前提是得懂JVM的各种基础知识,基础知识都不懂,谈何调优)
        在大多数场景下,JVM已经能够达到「开箱即用」

三、调优工具

一般我们是「遇到问题」之后才进行调优的,而遇到问题后需要利用各种的 「工具」 进行排查

  1. 通过jps命令查看Java进程「基础」信息(进程号、主类)。这个命令很常用的就是用来看当前服务器有多少Java进程在运行,它们的进程号和加载主类是啥
  2. 通过jstat命令查看Java进程「统计类」相关的信息(类加载、编译相关信息统计,各个内存区域GC概况和统计)。这个命令很常用于看GC的情况
  3. 通过jinfo命令来查看和调整Java进程的「运行参数」
  4. 通过jmap命令来查看Java进程的「内存信息」。这个命令很常用于把JVM内存信息dump到文件,然后再用MAT(Memory Analyzer tool内存解析工具)把文件进行分析
  5. 通过jstack命令来查看JVM「线程信息」。这个命令用常用语排查死锁相关的问题
  6. 还有近期比较热门的Arthas(阿里开源的诊断工具),涵盖了上面很多命令的功能且自带图形化界面。这也是我这边常用的排查和分析工具

四、jvm的jit优化技术

JIT优化技术比较出名的有两种:方法内联和逃逸分析

  1. 所谓方法内联就是把「目标方法」的代码复制到「调用的方法」中,避免发生真实的方法调用因为每次方法调用都会生成栈帧(压栈出栈记录方法调用位置等等)会带来一定的性能损耗,所以「方法内联」的优化可以提高一定的性能
    在JVM中也有相关的参数给予我们指定 (-XX:MaxFreqlnlineSize、 -XX:MaxInli neSize等等)
  2. 而「逃逸分析」则是判断一个对象是否被外部方法引用或外部线程访问的分析技术,如果「没有被引用」,就可以对其进行优化
    下面我举几个可优化的例子(思路):
    • 1.锁消除(同步忽略):该对象只在方法内部被访问,不会被别的地方引用,那么就一定是线程安全的,可以把锁相关的代码给忽略掉0
    • 2.栈上分配:该对象只会在方法内部被访问,直接将对象分配在「栈」中(Java默认是将对象分配在「堆」中,是需要通过JVM垃圾回收期进行回收,需要损耗一定的性能,而栈内分配则快很多)
    • 3.标量替换/分离对象:当程序真正执行的时候可以不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了

不过扯了这么多,不同的JVM版本对JIT的优化都不太相同

这里也只能算是一个参考

线上在使用的JVM也不知道有没有做了这么些优化.

l

java垃圾回收

一、jvm内存结构

嗯,前面提到了堆分了「新生代」和 「老年代」,「新生代」又分为「Eden」和「Survivor」区,「Survivor」区又分为「From Survivor」和「To Survivor」区

二、垃圾回收机制

  • 背景

我们使用Java的时候,会创建很多对象,但我们未曾「手动」将这些对象进行清除;
而如果用C/C++语言的时候,用完是需要自己free(释放)掉的;
那为什么在写Java的时候不用我们自己手动释放”垃圾”呢?原因很简单,JVM帮我们做了(自动回收垃圾)

  • 垃圾定义

我个人对垃圾的定义:只要对象不再被使用了,那我们就认为该对象就是垃圾,对象所占用的空间就可以被回收

  • 判断垃圾不再被使用

    常用算法有2个:引用计数法、可达性分析法

  1. 引用计数法思路很简单:当对象被引用则+1,但对象引用失败则-1。当计数器为0时,说明对象不再被引用,可以被可回收
    缺点:引用计数法最明显的缺点就是:如果对象存在循环依赖,那就无法定位该对象是否应该被回收(A依赖B,B依赖A)

  2. 另一种就是可达性分析法:它从「GCR oots」开始向下搜索,当对象到「GCRoots」都没有任何引用相连时,说明对象是不可用的,可以被回收。
    「GC Roots」是一组必须「活跃」的引用。
    从「GC Root」出发,程序通过直接引用或者间接引用,能够找到可能正在被使用的对象。

    例子:比如我们上次不是聊到JVM内存结构中的虚拟机栈吗,虚拟机栈里不是有栈帧吗,栈帧不是有局部变量吗?局部变量不就存储着引用嘛。
    那如果栈帧位于虚拟机栈的栈顶,是不是说明这个栈帧是活跃的(换言之,是线程正在被调用的)
    既然是线程正在调用的,那栈帧里的指向「堆」的对象引用,是不是一定是「活跃」的引用?

    所以,当前活跃的栈帧指向堆里的对象引用就可以是「GCRoots」

    当然,比如类的静态变量引用是「GCRootS」,被「Java本地方法」所引用的对象也是「GCRoots」等等。。。
    :回到理解的重点:「GCRoots」是一组必须「活跃」的「引用」,只要跟「GCRoots」没有直接或者间接引用相连,那就是垃圾
    JVM用的就是「可达性分析算法」来判断对象是否为垃圾

  • 垃圾回收第一步就是标记

标记哪些没有被「GC Roots」引用的对象

  • 标记完后就可以清除了

    • 标记完之后,我们就可以选择直接「清除」,只要不被「GCRoots」关联的,都可以干掉过程非常简单粗暴。

      但也存在很明显的问题
      直接清除会有「内存碎片」的问题:可能我有10M的空余内存,但程序申请9M内存空间却申请不下来(10M的内存空间是垃圾清除后的,不连续的)

    • 那解决「内存碎片」的问题也比较简单粗暴,「标记」完,不直接「清除」
      我把「标记」存活的对象「复制」到另一块空间,复制完了之后,直接把原有的整块空间给干掉!这样就没有内存碎片的问题了
      这种做法缺点又很明显内存利用率低,得有一块新的区域给我复制(移动)过去

    • 还有一种「折中」的办法,我未必要有一块「大的完整空间」才能解决内存碎片的问题,我只要能在「当前区域」内进行移动
      把存活的对象移到一边,把垃圾移到一边,那再将垃圾一起删除掉,不就没有内存碎片了嘛,专业术语叫做整理

  • 年轻代、老年代
    「垃圾回收」是会导致「stop the word」 (应用停止访问)
    理解「stop the word」应该很简单吧:回收垃圾的时候,程序是有短暂的时间不能正常继续运作啊。不然JVM在回收的时候,用户线程还「不停止」继续分配修改引用,JVM怎么搞(:

    经过研究表明:大部分对象的生命周期都很短,而只有少部分对象可能会存活很长时间

    • 为了使「stop the word」持续的时间尽可能短以及提高并发式GC所能应付的内存分配速率
      在很多的垃圾收集器上都会在「物理」或者「逻辑」上,把这两类对象进行区分司P
      死得快的对象所占的区域叫做「年轻代」,活得久的对象所占的区域叫做「老年代」

      (jdk8及以下分年轻代、老年代;高版本的垃圾收集器ZGC,是没有分代的概念的)

  • 垃圾回收过程:对应几种垃圾回收算法

    其实在前面更前面提到了垃圾回收的过程,其实就对应着几种「垃圾回收算法」分别是:
    标记清除算法、标记复制算法和标记整理算法【「标记」「复制」「整理」】

  • jdk8生产环境下常见垃圾回收器

    「年轻代」的垃圾收集器有:Serial、Parallel Scavenge、 ParNew
    「老年代」的垃圾收集器有:Serial Old、 Parallel Old、CMS

    • 看着垃圾收集器有很多,其实还是非常好理解的。Serial是单线程的,Parallel是多线程
    • 这些垃圾收集器实际上就是「实现了」垃圾回收算法(标记复制、标记整理以及标记清除算法)
    • CMS是比较新的垃圾收集器,它的特点是能够尽可能减少「stopthe word」时间。在垃圾回收时让用户线程和GC线程能够并发执行!
    • 「年轻代」的垃圾收集器使用的都是「标记复制算法」
      所以在「堆内存」划分中,将年轻代划分出Survivor区(Survivor From和Survivor To),目的就是为了有一块完整的内存空间供垃圾回收器进行拷贝(移动),而新对象都是放入Eden区的。
    • 我下面重新画下「堆内存」的图,因为它们的大小是有默认的比例的

  • 新生代何时会变老年代
    分2种情况:

    1. 如果对象太大了,就会直接进入老年代(对象创建时就很大 || Survivor区没办法存下该对象)
    2. 如果对象太老了,那就会晋升至老年代(每发生一次MinorGC,存活的对象年龄+1,达到默认值15则晋升老年代 || 动态对象年龄判定可以进入老年代)
      • 当Eden区空间不足时,就会触发MinorGC
      • 那在「年轻代」GC的时候,从GC Roots出发,那不也会扫描到「老年代」的对象吗?那那那..不就相当于全堆扫描吗?
        • 这JVM里也有解决办法的。下我的看法
          HotSpot虚拟机「老的GC」(G1以下) 是要求整个GC堆在连续的地址空间上
          所以会有一条分界线(一侧是老年代,另一侧是年轻代),所以可以通过「地址」就可以判断对象在哪个分代上如
        • 当做MonorGC的时候,从GCRoots出发,如果发现「老年代」的对象,那就不往下走了(MonorGC对老年代的区域毫无兴趣)
      • 但又有个问题,那如果年轻代」的对象被「老年代」引用了呢?(老年代对象持有年轻代对象的引用),这时候肯定是不能回收掉「年轻代」的对象的?
        • HotSpot虚拟机下有「card table」(卡表)来避免全局扫描「老年代」对象
        • 「堆内存」的每一小块区域形成「卡页」,卡表实际上就是卡页的集合。当判断一个卡页中有存在对象的跨代引用时,将这个页标记为「脏页」
        • 那知道了「卡表」之后,就很好办了。每次MinorGC的时候只需要去「卡表」找到「脏页」,找到后加入至GCRoot,而不用去遍历整个「老年代」的对象了。
l

java学习路线图

项目地址:

这篇回答不会涉及到学习方法以及学习网站的推荐,我不想让篇幅太长,内容太杂,我希望留着下一篇文章专门来谈一谈。

另外,这篇回答也不会涉及到计算机基础,也是为了保证内容的不至于太杂。计算基础相关的内容我也已经写好,预计会在五一同步给小伙伴们。

imgimg

多说一句,对于编程初学者,我不太建议上来通过做项目学习。实践确实很重要,如果你没有编程基础的话,直接上手实战,很容易最后学个四不像。

建议你在学习编程的初期尽量多看一些优质视频。跟着视频一步一步走,可以让你少踩很多坑,学习编程的信心也会增加。

概览:

imgimg

回答中的相关书籍我也已经同步到了 Github和Gitee上(有PDF版本可供下载),欢迎小伙伴们一起完善。

Java 基础

如果你之前没有学习过编程的话,我建议你可以看看视频教程。

像尚硅谷的 《 Java 基础教程系列》和韩顺平老师的《零基础 30 天学会 Java》就很不错。

imgimg

学习过 Java 的朋友,大部分应该都看过韩老师的课程吧!韩老师毕业于清华大学,录制的视频课程质量都非常高。内容易懂,并且不失严谨。

韩老师的《零基础 30 天学会 Java》系列不光会教你 Java 基础,还会帮你建立编程思想,让你知道学习了 Java 之后,你可以从事什么工作。

看视频的同时,配套一本好书也是非常有作用的。

《Head First Java》 这本书在是入门 Java 的很不错的书籍 。

imgimg

《Head First Java》这本书的内容很轻松有趣,可以说是我学习编程初期最喜欢的几本书之一了。同时,这本书也是我的 Java 启蒙书籍。我在学习 Java 的初期多亏了这本书的帮助,自己才算是跨进 Java 语言的大门。

我觉得我在 Java 这块能够坚持下来,这本书有很大的功劳。我身边的的很多朋友学习 Java 初期都是看的这本书。

有很多小伙伴就会问了:这本书适不适合编程新手阅读呢?

我个人觉得这本书还是挺适合编程新手阅读的,毕竟是 “Head First” 系列。

imgimg

《Java 核心技术卷 1+卷 2》 这两本书也非常不错。不过,这两本书的内容很多,全看的话比较费时间。我现在是把这两本书当做工具书来用,就比如我平时写文章的时候,碰到一些 Java 基础方面的问题,经常就翻看这两本来当做参考!

我当时在大学的时候就买了两本放在寝室,没事的时候就翻翻。建议有点 Java 基础之后再读,介绍的还是比较深入和全面的,非常推荐。

imgimg

《Java 编程思想 》 这本书被很多人称之为 Java 领域的圣经(*感觉有点过了~~~*)。不太推荐编程初学者阅读,有点劝退的味道,稍微有点基础后阅读更好。

我第一次看的时候还觉得有点枯燥,那时候还在上大二,看了 1/3 就没看下去了。

imgimg

另外,Java 8 算是一个里程碑式的版本,现在一般企业还是用 Java 8 比较多。掌握 Java 8 的一些新特性比如 Lambda、Strean API 还是挺有必要的。这块的话,我推荐 《Java 8 实战》 这本书。

学完 Java 基础之后,你可以用自己学的东西实现一个简单的 Java 程序,也可以尝试用 Java 解决一些编程问题,以此来将自己学到的东西付诸于实践。

不太建议学习 Java 基础的之后通过做游戏来巩固。为什么培训班喜欢通过这种方式呢?说白点就是为了找到你的 G 点。新手学习完 Java 基础后做游戏一般是不太现实的,还不如找一些简单的程序问题解决一下比如简单的算法题。

记得多总结!打好基础!把自己重要的东西都记录下来。 API 文档放在自己可以看到的地方,以备自己可以随时查阅。为了能让自己写出更优秀的代码,《Effective Java》、《重构》 这两本书没事也可以看

并发

多线程这部分内容稍微会比较难以理解和实践。如果你刚学完 Java 基础的话,我建议你学习并发这部分内容的时候,可以先简单地了解一下基础知识比如线程和进程的对比。到了后面,你对于 Java 了解的更深了之后,再回来仔细看看这部分的内容。

下面是一些我比较推荐的书籍。

《Java 并发编程之美》

imgimg

这本书还是非常适合我们用来学习 Java 多线程的。这本书的讲解非常通俗易懂,作者从并发编程基础到实战都是信手拈来。

另外,这本书的作者加多自身也会经常在网上发布各种技术文章。这本书也是加多大佬这么多年在多线程领域的沉淀所得的结果吧!他书中的内容基本都是结合代码讲解,非常有说服力!

《实战 Java 高并发程序设计》

imgimg

这个是我第二本要推荐的书籍,比较适合作为多线程入门/进阶书籍来看。这本书内容同样是理论结合实战,对于每个知识点的讲解也比较通俗易懂,整体结构也比较清。

《深入浅出 Java 多线程》

imgimg

这本书是几位大厂的大佬开源的。

这几位作者为了写好《深入浅出 Java 多线程》这本书阅读了大量的 Java 多线程方面的书籍和博客,然后再加上他们的经验总结、Demo 实例、源码解析,最终才形成了这本书。

这本书的质量也是非常过硬!给作者们点个赞!这本书有统一的排版规则和语言风格、清晰的表达方式和逻辑。并且每篇文章初稿写完后,作者们就会互相审校,合并到主分支时所有成员会再次审校,最后再通篇修订了三遍。

《Java 并发实现原理:JDK 源码剖析》

imgimg

这本书是去年也就是 2020 年新出的,所以,现在知道的人还不是很多。

这本书主要是对 Java Concurrent 包中一些比较重要的源码进行了讲解,另外,像 JMM、happen-before、CAS 等等比较重要的并发知识这本书也都会一并介绍到。

不论是你想要深入研究 Java 并发,还是说要准备面试,你都可以看看这本书。

下面是我总结的一些关于并发的小问题,你可以拿来自测:

  1. 什么是线程和进程? 线程与进程的关系,区别及优缺点?
  2. 说说并发与并行的区别?
  3. 为什么要使用多线程呢?
  4. 使用多线程可能带来什么问题?(内存泄漏、死锁、线程不安全等等)
  5. 创建线程有哪几种方式?(a.继承 Thread 类;b.实现 Runnable 接口;c. 使用 Executor 框架;d.使用 FutureTask)
  6. 说说线程的生命周期和状态?
  7. 什么是上下文切换?
  8. 什么是线程死锁?如何避免死锁?
  9. 说说 sleep() 方法和 wait() 方法区别和共同点?
  10. Java 内存模型(JMM)、重排序与 happens-before 原则了解吗?
  11. synchronized 关键字、volatile 关键字
  12. ThreadLocal 有啥用(解决了什么问题)?怎么用?原理了解吗?内存泄露问题了解吗?
  13. 为什么要用线程池?ThreadPoolExecutor 类的重要参数了解吗?ThreadPoolExecutor 饱和策略了解吗?线程池原理了解吗?几种常见的线程池了解吗?为什么不推荐使用FixedThreadPool?如何设置线程池的大小?
  14. AQS 了解么?原理?AQS 常用组件:Semaphore (信号量)、CountDownLatch (倒计时器) CyclicBarrier(循环栅栏)
  15. ReentrantLock 、 ReentrantReadWriteLock 、StampedLock(JDK8)
  16. CAS 了解么?原理?
  17. Atomic 原子类
  18. 并发容器:ConcurrentHashMap 、 CopyOnWriteArrayList 、 ConcurrentLinkedQueue BlockingQueue 、ConcurrentSkipListMap
  19. Future 和 CompletableFuture
  20. ……

JVM

对于 Java 程序员来说,JVM 帮助我们做了很多事情比如内存管理、垃圾回收等等。在 JVM 的帮助下,我们的程序出现内存泄漏这些问题的概率相对来说是比较低的。但是,这并不代表我们在日常开发工作中不会遇到。万一你在工作中遇到了 OOM 问题,你至少要知道如何去排查和解决问题吧!

并且,就单纯从面试角度来说,JVM 是 Java 后端面试(大厂)中非常重要的一环。不论是应届还是社招,面试国内的一些大厂,你都会被问到很多 JVM 相关的问题(应届的话侧重理论,社招实践)。

只有搞懂了 JVM 才有可能真正把 Java 语言“吃透”。学习 JVM 这部分的内容,一定要注意要实战和理论结合。

书籍的话,**《深入理解 Java 虚拟机》** 这本书是首先要推荐的。

imgimg

这本书就一句话形容:国产书籍中的战斗机,实实在在的优秀! (真心希望国内能有更多这样的优质书籍出现!加油! )

这本书的第三版去年年底已经出来了,新增了很多实在的内容比如 ZGC 等新一代 GC 的原理剖析。目前豆瓣上是 9.6 的高分, 不 我就不多说了!

不论是你面试还是你想要在 Java 领域学习的更深,你都离不开这本书籍。这本书不光要看,你还要多看几遍,里面都是干货。这本书里面还有一些需要自己实践的东西,我建议你也跟着实践一下。

类似的书籍还有 《实战 Java 虚拟机》《虚拟机设计与实现:以 JVM 为例》 ,这两本都是非常不错的!

imgimg

如果你对实战比较感兴趣,想要自己动手写一个简易的 JVM 的话,可以看看 《自己动手写 Java 虚拟机》 这本书。

imgimg

书中的代码是基于 Go 语言实现的,搞懂了原理之后,你可以使用 Java 语言模仿着写一个,也算是练练手! 如果你当前没有能力独立使用 Java 语言模仿着写一个的话,你也可以在网上找到很多基于 Java 语言版本的实现,比如《zachaxy 的手写 JVM 系列》

这本书目前在豆瓣有 8.2 的评分,我个人觉得张秀宏老师写的挺好的,这本书值得更高的评分。

另外,R 大在豆瓣发的《从表到里学习 JVM 实现》这篇文章中也推荐了很多不错的 JVM 相关的书籍,推荐小伙伴们去看看。

再推荐两个视频给喜欢看视频学习的小伙伴。

第 1 个是尚硅谷的宋红康老师讲的《JVM 全套教程》。这个课程的内容非常硬,一共有接近 400 小节。

课程的内容分为 3 部分:

  1. 《内存与垃圾回收篇》
  2. 《字节码与类的加载篇》
  3. 《性能监控与调优篇》

imgimg

第 2 个是你假笨大佬的 《JVM 参数【Memory 篇】》 教程,很厉害了!

imgimg

下面是我总结的一些关于 JVM 的小问题,你可以拿来自测:

  1. 什么是虚拟机?
  2. Java 内存区域是怎么划分的?大对象放在哪个内存区域?
  3. 垃圾回收有哪些算法?GC 的流程
  4. 什么是类加载?何时类加载?类加载流程?
  5. 知道哪些类加载器。类加载器之间的关系?
  6. 类加载器的双亲委派了解么? 结合 Tomcat 说一下双亲委派(Tomcat 如何打破双亲委托机制?…)。
  7. 常见调优参数有哪些?
  8. ……

数据库

我们网站或 者 APP 的数据都是需要使用数据库来存储数据的。

MySQL

一般企业项目开发中,使用 MySQL 比较多。如果你要学习 MySQL 的话,可以看下面这 3 本书籍:

  • 《MySQL 必知必会》 :非常薄!非常适合 MySQL 新手阅读,很棒的入门教材。
  • 《高性能 MySQL》 : MySQL 领域的经典之作!学习 MySQL 必看!属于进阶内容,主要教你如何更好地使用 MySQL 。既有有理论,又有实践!如果你没时间都看一遍的话,拿我建议第 5 章(创建高性能的索引) 、第 6 章(查询性能优化) 你你一定要认真看一下。
  • 《MySQL 技术内幕》 :你想深入了解 MySQL 存储引擎的话,看这本书准没错!

imgimg

视频的话,你可以看看动力节点的 《MySQL 数据库教程视频》。这个视频基本上把 MySQL 的相关一些入门知识给介绍完了。

学习了 MySQL 之后,务必确保自己掌握下面这些知识点:

  1. MySQL 常用命令 :
  • 安全:登录、增加/删除用户、备份数据和还原数据
  • 数据库操作: 建库建表/删库删表、用户权限分配
  • ……
  1. MySQL 中常用的数据类型、字符集编码
  2. MySQL 简单查询、条件查询、模糊查询、多表查询以及如何对查询结果排序、过滤、分组……
  3. MySQL 中使用索引、视图、存储过程、游标、触发器
  4. ……

如果你想让自己更加了解 MySQL ,同时也是为了准备面试的话,下面这些知识点要格外注意:

  1. 索引:索引优缺点、B 树和 B+树、聚集索引与非聚集索引、覆盖索引
  2. 事务:事务、数据库事务、ACID、并发事务、事务隔离级别
  3. 存储引擎(MyISAM 和 InnoDB)
  4. 锁机制与 InnoDB 锁算法

Redis

Redis 就是一个使用 C 语言开发的数据库,不过与传统数据库不同的是 Redis 的数据是存在内存中的 ,也就是它是内存数据库,所以读写速度非常快,因此 Redis 被广泛应用于缓存方向。

如果你要学习 Redis 的话,强烈推荐 《Redis 设计与实现》《Redis 实战》 这两本书。另外,**《Redis 开发与运维》** 这本书也非常不错,既有基础介绍,又有一线开发运维经验分享。

imgimg

下面是我总结的一些关于并发的小问题,你可以拿来自测:

  1. Redis 和 Memcached 的区别和共同点
  2. 为什么要用 Redis/为什么要用缓存?
  3. Redis 常见数据结构以及使用场景分析
  4. Redis 没有使用多线程?为什么不使用多线程?Redis6.0 之后为何引入了多线程?
  5. Redis 给缓存数据设置过期时间有啥用?
  6. Redis 是如何判断数据是否过期的呢?
  7. 过期的数据的删除策略了解么?
  8. Redis 内存淘汰机制了解么?
  9. Redis 持久化机制(怎么保证 Redis 挂掉之后再重启数据可以进行恢复)
  10. Redis 缓存穿透、缓存雪崩?
  11. 如何保证缓存和数据库数据的一致性?
  12. ……

常用工具

非常重要!非常重要!特别是 Git 和 Docker。

除了下面这些工具之外,我强烈建议你一定要搞懂 Github 的使用。一些使用 Github 的小技巧,你可以看《Github 小技巧》这篇文章。

IDEA

俗话说:“工欲善其事,必先利其器 !”。选择一款好的开发工具对于我们高效率编码非常有帮助!

常用的 Java 开发工具就 Eclipse 和 IDEA。就我个人而言 IDEA 是最适合 Java 开发者的 IDE 。

建议你要熟悉 IDEA 的基本操作以及常用快捷。你可以通过 Github 上的开源教程 《IntelliJ IDEA 简体中文专题教程》 来学习 IDEA 的相关使用。

除了 IDEA 自身对编码优秀的支持(比如智能上下文提示)之外,IDEA 中还有丰富的插件来帮助我们高效开发。《IDEA 插件》 这个系列专辑中推荐了很多实用 IDEA 必备的插件!

Maven

强烈建议学习常用框架之前可以提前花几天时间学习一下Maven的使用。(到处找 Jar 包,下载 Jar 包是真的麻烦费事,使用 Maven 可以为你省很多事情)。

Git

Git 技能对于程序员来说也是必备的!试着在学习的过程中将自己的代码托管在 Github 上,有一个漂亮的 Github 主页在求职面试中是十分加分的。并且,现在的企业都是基于 Git 在 GitHub 或 GitLab 平台上做版本控制。

学习 Git 的话,强烈推荐给大家一个可以交互式学习 Git 的网站 Learn Git Branching。效果真的非常非常棒,通过游戏的方式让你学习 Git 的常见操作。

整个教程分为很多关,每一关都有非常详细的指导,还会有详细的动图展示结果。并且,你做错了之后还可以使用 reset 命令从头开始。

imgimg

如果你是在不知道答案的话,还可以使用 show solution 命令查看答案。

imgimg

这种即时反馈的学习让过程变得有趣!真心感谢这个网站的作者,太爱了!

另外,你可以看看 Github 上开源的这篇 《Git 极简入门》 ,像版本控制和 Git 的相关概念、Git 常见操作这篇文章都有介绍到。

如果想要详细了解 Git 的话,Git 官方文档教程是肯定要看的,介绍的非常全面,并且有中文版!

imgimg

《Pro Git》这本书也非常不错,还有中文版,内容非常全面,硬核!

imgimg

imgimg

如果你比较喜欢看视频教程的话,可以看看极客时间的《玩转 Git 三剑客》,课程的作者是携程代码平台负责人苏玲,讲的挺不错的!

Docker

传统的开发流程中,我们的项目通常需要使用 MySQL、Redis、FastDFS 等等环境,这些环境都是需要我们手动去进行下载并配置的,安装配置流程极其复杂,而且不同系统下的操作也不一样。

Docker 的出现完美地解决了这一问题,我们可以在容器中安装 MySQL、Redis 等软件环境,使得应用和环境架构分开,它的优势在于:

  1. 一致的运行环境,能够更轻松地迁移
  2. 对进程进行封装隔离,容器与容器之间互不影响,更高效地利用系统资源
  3. 可以通过镜像复制多个一致的容器

Docker 常见概念解读,可以看这篇 Github 上开源的这篇《Docker 基本概念解读》 ,从零到上手实战可以看《Docker 从入门到上手干事》这篇文章,内容非常详细!

另外,再给大家推荐一本质量非常高的开源书籍《Docker 从入门到实践》 ,这本书的内容非常新,毕竟书籍的内容是开源的,可以随时改进。

imgimg

常用框架

2021 最新Java实战项目源码打包下载t.1yb.co图标

Spring/SpringBoot

Spring 和 SpringBoot 真的很重要!

一定要搞懂 AOP 和 IOC 这两个概念。Spring 中 bean 的作用域与生命周期、SpringMVC 工作原理详解等等知识点都是非常重要的,一定要搞懂。

企业中做 Java 后端,你一定离不开 SpringBoot ,这个是必备的技能了!一定一定一定要学好!

像 SpringBoot 和一些常见技术的整合你也要知识怎么做,比如 SpringBoot 整合 MyBatis、 ElasticSearch、SpringSecurity、Redis 等等。

学习 Spring 的话,可以多看看 **《Spring 的官方文档》**,写的很详细。你可以在这里找到 Spring 全家桶的学习资源。

imgimg

你也可以把 《Spring 实战》 这本书作为学习 Spring 的参考资料。 这本书还是比较新的,目前已经出到了第 5 版,基于 Spring 5 来讲。

imgimg

了解了 Spring 中的一些常见概念和基本用法之后,你就可以开始学习 Spring Boot 了。

当然了,Spring 其实并不是学习 Spring Boot 的前置基础,相比于 Spring 来说,Spring Boot 要更容易上手一些!如果你只是想使用 Spring Boot 来做项目的话,直接学 Spring Boot 就可以了。

不过,我建议你在学习 Spring Boot 之前,可以看看 《Spring 常见问题总结》 。这些问题都是 Spring 比较重要的知识点,也是面试中经常会被问到的。

学习 Spring Boot 的话,还是建议可以多看看 **《Spring Boot 的官方文档》**,写的很详细。

你也可以把 《Spring Boot 实战》 这本书作为学习 Spring Boot 的参考资料。

imgimg

这本书的整体质量实际一般,你当做参考书来看就好了!

相比于 《Spring Boot 实战》这本书,我更推荐国人写的 《Spring Boot 实战派》

imgimg

这本书使用的 Spring Boot 2.0+的版本,还算比较新。整本书采用“知识点+实例”的形式编写,书籍的最后两章还有 2 个综合性的企业实战项目:

  • 开发企业级通用的后台系统
  • 实现一个类似“京东”的电子商务商城

作者在注意实战的过程中还不忘记对于一些重要的基础知识的讲解。

如果你想专研 Spring Boot 底层原理的话,可以看看 《Spring Boot 编程思想(核心篇)》

imgimg

这本书稍微有点啰嗦,不过,原理介绍的比较清楚(不适合初学者)。

如果你比较喜欢看视频的话,推荐尚硅谷雷神的**《2021 版 Spring Boot2 零基础入门》** 。

imgimg

这可能是全网质量最高并且免费的 Spring Boot 教程了,好评爆炸!

另外,Spring Boot 这块还有很多优质的开源教程,我已经整理好放到 awesome-java@SpringBoot 中了。

imgimg

Netty

但凡涉及到网络通信就必然必然离不开网络编程。 Netty 目前作为 Java 网络编程最热门的框架,毫不夸张地说是每个 Java 程序员必备的技能之一。

为什么说学好 Netty 很有必要呢?

  1. Netty 基于 NIO (NIO 是一种同步非阻塞的 I/O 模型,在 Java 1.4 中引入了 NIO )。使用 Netty 可以极大地简化并简化了 TCP 和 UDP 套接字服务器等网络编程,并且性能以及安全性等很多方面都非常优秀。
  2. 我们平常经常接触的 Dubbo、RocketMQ、Elasticsearch、gRPC、Spark、Elasticsearch 等等热门开源项目都用到了 Netty。
  3. 大部分微服务框架底层涉及到网络通信的部分都是基于 Netty 来做的,比如说 Spring Cloud 生态系统中的网关 Spring Cloud Gateway 。

下面是一些比较推荐的书籍/专栏。

《Netty 实战》

imgimg

这本书可以用来入门 Netty ,内容从 BIO 聊到了 NIO、之后才详细介绍为什么有 Netty 、Netty 为什么好用以及 Netty 重要的知识点讲解。

这本书基本把 Netty 一些重要的知识点都介绍到了,而且基本都是通过实战的形式讲解。

《Netty 进阶之路:跟着案例学 Netty》

imgimg

内容都是关于使用 Netty 的实践案例比如内存泄露这些东西。如果你觉得你的 Netty 已经完全入门了,并且你想要对 Netty 掌握的更深的话,推荐你看一下这本书。

《Netty 入门与实战:仿写微信 IM 即时通讯系统》

imgimg

通过一个基于 Netty 框架实现 IM 核心系统为引子,带你学习 Netty。整个小册的质量还是很高的,即使你没有 Netty 使用经验也能看懂。

搜索引擎

搜索引擎用于提高搜索效率,功能和浏览器搜索引擎类似。比较常见的搜索引擎是 Elasticsearch(推荐) 和 Solr。

如果你要学习 Elasticsearch 的话,Elastic 中文社区 以及 Elastic 官方博客 都是非常不错的资源,上面会分享很多具体的实践案例。

除此之外,极客时间的《Elasticsearch 核心技术与实战》这门课程非常赞!这门课基于 Elasticsearch 7.1 版本讲解,比较新。并且,作者是 eBay 资深技术专家,有 20 年的行业经验,课程质量有保障!

imgimg

如果你想看书的话,可以考虑一下 《Elasticsearch 实战》 这本书。不过,需要说明的是,这本书中的 Elasticsearch 版本比较老,你可以将其作为一个参考书籍来看,有一些原理性的东西可以在上面找找答案。

imgimg

如果你想进一步深入研究 Elasticsearch 原理的话,可以看看张超老师的《Elasticsearch 源码解析与优化实战》这本书。这是市面上唯一一本写 Elasticsearch 源码的书。

imgimg

分布式

下面我们开始学习分布式以及高并发、高可用了。

这块内容的话,对于每一个知识点没有特定的书籍。我就推荐 2 本我觉得还不错的书籍吧!这两把书籍基本把下面涉及到的知识点给涵盖了。

第一本是李运华老师的**《从零开始学架构》** 。

imgimg

这本书对应的有一个极客时间的专栏—《从零开始学架构》,里面的很多内容都是这个专栏里面的,两者买其一就可以了。

第二本是余老师的 《软件架构设计:大型网站技术架构与业务架构融合之道》

imgimg

事务与锁、分布式(CAP、分布式事务……)、高并发、高可用这本书都有介绍到。值得推荐!良心好书!

理论

CAP 理论

CAP 也就是 Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性) 这三个单词首字母组合。

关于 CAP 的详细解读请看:《CAP 理论解读》

BASE 理论

BASEBasically Available(基本可用)Soft-state(软状态)Eventually Consistent(最终一致性) 三个短语的缩写。BASE 理论是对 CAP 中一致性和可用性权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于 CAP 定理逐步演化而来的,它大大降低了我们对系统的要求。

关于 CAP 的详细解读请看:《BASE 理论解读》

Paxos 算法和 Raft 算法

Paxos 算法诞生于 1990 年,这是一种解决分布式系统一致性的经典算法 。但是,由于 Paxos 算法非常难以理解和实现,不断有人尝试简化这一算法。到了 2013 年才诞生了一个比 Paxos 算法更易理解和实现的分布式一致性算法—Raft 算法

RPC

RPC 让调用远程服务调用像调用本地方法那样简单。

Dubbo 是一款国产的 RPC 框架,由阿里开源。相关阅读:

服务注册与发现

Eureka、Zookeeper、Consul、Nacos 都可以提供服务注册与发现的功能。

imgimg

API 网关

网关主要用于请求转发、安全认证、协议转换、容灾。

SpringCloud Gateway 是 Spring Cloud 的一个全新项目,为了取代 Netflix Zuul。

配置中心

微服务下,业务的发展一般会导致服务数量的增加,进而导致程序配置(服务地址、数据库参数等等)增多。

传统的配置文件的方式已经无法满足当前需求,主要有两点原因:一是安全性得不到保障(配置放在代码库中容易泄露);二是时效性不行 (修改配置需要重启服务才能生效)。

Spring Cloud Config、Nacos 、Apollo、K8s ConfigMap 都可以用来做配置中心。

Apollo 和 Nacos 我个人更喜欢。Nacos 使用起来更加顺手,Apollo 在配置管理方面做的更加全面。

分布式 id

日常开发中,我们需要对系统中的各种数据使用 ID 唯一表示,比如用户 ID 对应且仅对应一个人,商品 ID 对应且仅对应一件商品,订单 ID 对应且仅对应一个订单。

imgimg

简单来说,ID 就是数据的唯一标识

分布式 ID 是分布式系统下的 ID。分布式 ID 不存在与现实生活中,属于计算机系统中的一个概念。

我简单举一个分库分表的例子。

我司的一个项目,使用的是单机 MySQL 。但是,没想到的是,项目上线一个月之后,随着使用人数越来越多,整个系统的数据量将越来越大。

单机 MySQL 已经没办法支撑了,需要进行分库分表(推荐 Sharding-JDBC)。

在分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?

imgimg

这个时候就需要生成分布式 ID了。

分布式 ID 的解决方案有很多比如 :

  • 算法 :UUID、Snowflake
  • 开源框架 : UidGenerator、Leaf 、Tinyid

分布式事务

微服务架构下,一个系统被拆分为多个小的微服务。

每个微服务都可能存在不同的机器上,并且每个微服务可能都有一个单独的数据库供自己使用。这种情况下,一组操作可能会涉及到多个微服务以及多个数据库。

举个例子:电商系统中,你创建一个订单往往会涉及到订单服务(订单数加一)、库存服务(库存减一)等等服务,这些服务会有供自己单独使用的数据库。

imgimg

那么如何保证这一组操作要么都执行成功,要么都执行失败呢?

这个时候单单依靠数据库事务就不行了!我们就需要引入 分布式事务 这个概念了!

常用分布式事务解决方案有 Seata 和 Hmily。

  1. Seata :Seata 是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务。
  2. Hmily : 金融级分布式事务解决方案

分布式链路追踪

不同于单体架构,在分布式架构下,请求需要在多个服务之间调用,排查问题会非常麻烦。我们需要分布式链路追踪系统来解决这个痛点。

目前分布式链路追踪系统基本都是根据谷歌的《Dapper 大规模分布式系统的跟踪系统》这篇论文发展而来,主流的有 Pinpoint,Skywalking ,CAT(当然也有其他的例如 Zipkin,Jaeger 等产品,不过总体来说不如前面选取的 3 个完成度高)等。

Zipkin 是 Twitter 公司开源的一个分布式链路追踪工具,Spring Cloud Sleuth 实际是基于 Zipkin 的。

SkyWalking 是国人吴晟(华为)开源的一款分布式追踪,分析,告警的工具,现在是 Apache 旗下开源项目

微服务

微服务的很多东西实际在分布式这一节已经提到了。

我这里就再补充一些微服务架构中,经常使用到的一些组件。

  • 声明式服务调用 : Feign
  • 负载均衡 : Ribbon
  • ……

高并发

消息队列

imgimg

消息队列在分布式系统中主要是为了解耦和削峰。相关阅读:消息队列常见问题总结

常用的消息队列如下:

  1. RocketMQ :阿里巴巴开源的一款高性能、高吞吐量的分布式消息中间件。
  2. Kafaka: Kafka 是一种分布式的,基于发布 / 订阅的消息系统。关于它的入门可以查看:Kafka 入门看这一篇就够了
  3. RabbitMQ :由 erlang 开发的基于 AMQP(Advanced Message Queue 高级消息队列协议)协议实现的消息队列。

读写分离&分库分表

读写分离主要是为了将数据库的读和写操作分不到不同的数据库节点上。主服务器负责写,从服务器负责读。另外,一主一从或者一主多从都可以。

读写分离可以大幅提高读性能,小幅提高写的性能。因此,读写分离更适合单机并发读请求比较多的场景。

imgimg

分库分表是为了解决由于库、表数据量过大,而导致数据库性能持续下降的问题。

常见的分库分表工具有:sharding-jdbc(当当)、TSharding(蘑菇街)、MyCAT(基于 Cobar)、Cobar(阿里巴巴)…。 推荐使用 sharding-jdbc。 因为,sharding-jdbc 是一款轻量级 Java 框架,以 jar 包形式提供服务,不要我们做额外的运维工作,并且兼容性也很好。

imgimg

相关阅读: 读写分离&分库分表常见问题总结

负载均衡

负载均衡系统通常用于将任务比如用户请求处理分配到多个服务器处理以提高网站、应用或者数据库的性能和可靠性。

常见的负载均衡系统包括 3 种:

  1. DNS 负载均衡 :一般用来实现地理级别的均衡。
  2. 硬件负载均衡 : 通过单独的硬件设备比如 F5 来实现负载均衡功能(硬件的价格一般很贵)。
  3. 软件负载均衡 :通过负载均衡软件比如 Nginx 来实现负载均衡功能。

高可用

高可用描述的是一个系统在大部分时间都是可用的,可以为我们提供服务的。高可用代表系统即使在发生硬件故障或者系统升级的时候,服务仍然是可用的 。

相关阅读: 如何设计一个高可用系统?要考虑哪些地方?

限流&降级&熔断

限流是从用户访问压力的角度来考虑如何应对系统故障。限流为了对服务端的接口接受请求的频率进行限制,防止服务挂掉。比如某一接口的请求限制为 100 个每秒, 对超过限制的请求放弃处理或者放到队列中等待处理。限流可以有效应对突发请求过多。相关阅读:限流算法有哪些?

降级是从系统功能优先级的角度考虑如何应对系统故障。服务降级指的是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行。

熔断和降级是两个比较容易混淆的概念,两者的含义并不相同。

降级的目的在于应对系统自身的故障,而熔断的目的在于应对当前系统依赖的外部系统或者第三方系统的故障。

HystrixSentinel 都能实现限流、降级、熔断。

Hystrix 是 Netflix 开源的熔断降级组件,Sentinel 是阿里中间件团队开源的一款不光具有熔断降级功能,同时还支持系统负载保护的组件。

两者都是主要做熔断降级 ,那么两者到底有啥异同呢?该如何选择呢?

Sentinel 的 wiki 中已经详细描述了其与 Hystrix 的区别,你可以看看。

排队

另类的一种限流,类比于现实世界的排队。玩过英雄联盟的小伙伴应该有体会,每次一有活动,就要经历一波排队才能进入游戏。

集群

相同的服务部署多份,避免单点故障。

超时和重试机制

一旦用户的请求超过某个时间得不到响应就结束此次请求并抛出异常。 如果不进行超时设置可能会导致请求响应速度慢,甚至导致请求堆积进而让系统无法在处理请求。

另外,重试的次数一般设为 3 次,再多次的重试没有好处,反而会加重服务器压力(部分场景使用失败重试机制会不太适合)。

l