0%

information_schema 这这个数据库中保存了 MySQL 服务器所有数据库的信息。
如数据库名,数据库的表,表栏的数据类型与访问权限等。
再简单点,这台 MySQL 服务器上,到底有哪些数据库、各个数据库有哪些表,
每张表的字段类型是什么,各个数据库要什么权限才能访问,等等信息都保存在 information_schema 里面。

选用 MySQL 版本 5.6.25

1
2
3
4
5
6
7
select version();
+------------+
| version() |
+------------+
| 5.6.25-log |
+------------+
1 row in set (0.00 sec)
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
use information_schema;
show tables;
+---------------------------------------+
| Tables_in_information_schema |
+---------------------------------------+
| CHARACTER_SETS |
| COLLATIONS |
| COLLATION_CHARACTER_SET_APPLICABILITY |
| COLUMNS |
| COLUMN_PRIVILEGES |
| ENGINES |
| EVENTS |
| FILES |
| GLOBAL_STATUS |
| GLOBAL_VARIABLES |
| KEY_COLUMN_USAGE |
| OPTIMIZER_TRACE |
| PARAMETERS |
| PARTITIONS |
| PLUGINS |
| PROCESSLIST |
| PROFILING |
| REFERENTIAL_CONSTRAINTS |
| ROUTINES |
| SCHEMATA |
| SCHEMA_PRIVILEGES |
| SESSION_STATUS |
| SESSION_VARIABLES |
| STATISTICS |
| TABLES |
| TABLESPACES |
| TABLE_CONSTRAINTS |
| TABLE_PRIVILEGES |
| TRIGGERS |
| USER_PRIVILEGES |
| VIEWS |
| INNODB_LOCKS |
| INNODB_TRX |
| INNODB_SYS_DATAFILES |
| INNODB_LOCK_WAITS |
| INNODB_SYS_TABLESTATS |
| INNODB_CMP |
| INNODB_METRICS |
| INNODB_CMP_RESET |
| INNODB_CMP_PER_INDEX |
| INNODB_CMPMEM_RESET |
| INNODB_FT_DELETED |
| INNODB_BUFFER_PAGE_LRU |
| INNODB_SYS_FOREIGN |
| INNODB_SYS_COLUMNS |
| INNODB_SYS_INDEXES |
| INNODB_FT_DEFAULT_STOPWORD |
| INNODB_SYS_FIELDS |
| INNODB_CMP_PER_INDEX_RESET |
| INNODB_BUFFER_PAGE |
| INNODB_CMPMEM |
| INNODB_FT_INDEX_TABLE |
| INNODB_FT_BEING_DELETED |
| INNODB_SYS_TABLESPACES |
| INNODB_FT_INDEX_CACHE |
| INNODB_SYS_FOREIGN_COLS |
| INNODB_SYS_TABLES |
| INNODB_BUFFER_POOL_STATS |
| INNODB_FT_CONFIG |
+---------------------------------------+
59 rows in set (0.01 sec)

information_schema 数据库中有 59 张表, 分别存储了如下的信息:
参考官方文档

| SCHEMATA | 提供了当前 mysql 实例中所有数据库的信息,show databases 的结果取之此表。 |
| ————————————- | ——————————————————————————————————————————————————- | ———————————————————————————— |
| TABLES | 提供了关于数据库中的表的信息(包括视图),详细表述了某个表属于哪个 schema,表类型,表引擎,创建时间等信息,show tables from schemaname 的结果取之此表。 |
| COLUMNS | 提供了表中的列信息,详细表述了某张表的所有列以及每个列的信息,show columns from schemaname.tablename 的结果取之此表。 |
| STATISTICS | 提供了关于表索引的信息,show index from schemaname.tablename 的结果取之此表。 |
| USER_PRIVILEGES(用户权限) | 给出了关于全程权限的信息,该信息源自 mysql.user 授权表,是非标准表。 |
| SCHEMA_PRIVILEGES(方案权限) | 给出了关于方案(数据库)权限的信息,该信息来自 mysql.db 授权表,是非标准表。 |
| TABLE_PRIVILEGES(表权限) | 给出了关于表权限的信息,该信息源自 mysql.tables_priv 授权表,是非标准表。 |
| COLUMN_PRIVILEGES(列权限) | 给出了关于列权限的信息,该信息源自 mysql.columns_priv 授权表,是非标准表。 |
| CHARACTER_SETS(字符集) | 提供了 mysql 实例可用字符集的信息,SHOW CHARACTER SET 结果集取之此表。 |
| COLLATIONS | 提供了关于各字符集的对照信息。 |
| COLLATION_CHARACTER_SET_APPLICABILITY | 指明了可用于校对的字符集,这些列等效于 SHOW COLLATION 的前两个显示字段。 |
| TABLE_CONSTRAINTS | 描述了存在约束的表,以及表的约束类型。 |
| KEY_COLUMN_USAGE | 描述了具有约束的键列。 |
| ROUTINES | 提供了关于存储子程序(存储程序和函数)的信息,此时,ROUTINES 表不包含自定义函数(UDF),名为 | “mysql.proc name”的列指明了对应于 INFORMATION_SCHEMA.ROUTINES 表的 mysql.proc 表列。 |
| VIEWS | 给出了关于数据库中的视图的信息,需要有 show views 权限,否则无法查看视图信息。 |
| TRIGGERS | 提供了关于触发程序的信息,必须有 super 权限才能查看该表。 |

information_schema 的表 schemata 中的列 schema_name 记录了所有数据库的名字
information_schema 的表 tables 中的列 table_schema 记录了所有数据库的名字
information_schema 的表 tables 中的列 table_name 记录了所有数据库的表的名字
information_schema 的表 columns 中的列 table_schema 记录了所有数据库的名字
information_schema 的表 columns 中的列 table_name 记录了所有数据库的表的名字
information_schema 的表 columns 中的列 column_name 记录了所有数据库的表的列的名字

无论是web服务、集群应用还是单机应用,涉及的性能相关的内容很多:

接下来分别从上层到底层介绍所涉及的点:

webpage–>nginx–>io–>cpu–>memory–>JVM–>Java

webpage

nginx

io

cpu

memory

JVM

Java

MySQL

Redis

Synchronized是非常昂贵的锁

Locks are incredibly expensive because they require arbitration when contended.
This arbitration is achieved by a context switch to the operating system kernel which will suspend threads waiting on a lock until it is released.
During such a context switch, as well as releasing control to the operating system which may decide to do other house-keeping tasks while it has control, execution context can lose previously cached data and instructions.
This can have a serious performance impact on modern processors.

锁非常昂贵,因为它们在争用时需要仲裁。这个仲裁是通过上下文切换到操作系统内核来实现的,它将挂起正在等待锁的线程,直到锁被释放。
在这样的上下文切换期间,以及将控制权释放给操作系统(当操作系统拥有控制权时,操作系统可能决定执行其他内务任务),执行上下文可能会丢失以前缓存的数据和指令。
这可能会对现代处理器的性能产生严重影响。

——摘自《Disruptor: High performance alternative to bounded queues for exchanging data between concurrent threads》

转载自深入理解Java并发之synchronized实现原理

线程安全是并发编程中的重要关注点,应该注意到的是,造成线程安全问题的主要诱因有两点:

一是存在共享数据(也称临界资源),
二是存在多条线程共同操作共享数据。

因此为了解决这个问题,我们可能需要这样一个方案,当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行,这种方式有个高尚的名称叫互斥锁,即能达到互斥访问目的的锁,也就是说当一个共享数据被当前正在访问的线程加上互斥锁后,在同一个时刻,其他线程只能处于等待的状态,直到当前线程处理完毕释放该锁。

在 Java 中,关键字 synchronized可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),同时我们还应该注意到synchronized另外一个重要的作用,synchronized可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代Volatile功能),这点确实也是很重要的。

synchronized底层语义原理

Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorentermonitorexit 指令,即同步代码块)还是隐式同步都是如此。同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法 并不是由 monitorentermonitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的,先来了解一个概念Java对象头,这对深入理解synchronized实现原理非常关键。

理解Java对象头与Monitor

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:

实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

而对于顶部,则是Java对象头,它实现synchronized的锁对象的基础,这点我们重点分析它,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark WordClass Metadata Address 组成,其结构说明如下表:

虚拟机位数 头对象结构 说明
32/64bit Mark Word 存储对象的hashCode、锁信息或分代年龄或GC标志等信息
32/64bit Class Metadata Address 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。

其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等

以下是32位JVM的Mark Word默认存储结构

锁状态 25bit 4bit 1bit是否是偏向锁 2bit 锁标志位
无锁状态 对象HashCode 对象分代年龄 0 01

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:

其中轻量级锁和偏向锁是Java 6 对 synchronized 锁进行优化后新增加的,重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}

ObjectMonitor中有两个队列,_WaitSet_EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程

  • 当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合
  • 当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1
  • 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因

synchronized代码块底层原理

现在我们重新定义一个synchronized修饰的同步代码块,在代码块中操作共享变量i,如下

1
2
3
4
5
6
7
8
9
public class SyncCodeBlock {
public int i;
public void syncTask(){
//同步代码库
synchronized (this){
i++;
}
}
}

编译上述代码并使用javap反编译后得到字节码如下(这里我们省略一部分没有必要的信息):

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
Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncCodeBlock.class
Last modified 2017-6-2; size 426 bytes
MD5 checksum c80bc322c87b312de760942820b4fed5
Compiled from "SyncCodeBlock.java"
public class com.zejian.concurrencys.SyncCodeBlock
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
//........省略常量池中数据
//构造函数
public com.zejian.concurrencys.SyncCodeBlock();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 7: 0
//===========主要看看syncTask方法实现================
public void syncTask();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter //注意此处,进入同步方法
4: aload_0
5: dup
6: getfield #2 // Field i:I
9: iconst_1
10: iadd
11: putfield #2 // Field i:I
14: aload_1
15: monitorexit //注意此处,退出同步方法
16: goto 24
19: astore_2
20: aload_1
21: monitorexit //注意此处,退出同步方法
22: aload_2
23: athrow
24: return
Exception table:
//省略其他字节码.......
}
SourceFile: "SyncCodeBlock.java"

我们主要关注字节码中的如下代码

1
2
3
4
5
6
3: monitorenter  //进入同步方法
//..........省略其他
15: monitorexit //退出同步方法
16: goto 24
//省略其他.......
21: monitorexit //退出同步方法

从字节码中可知同步语句块的实现使用的是monitorentermonitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectrefmonitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectrefmonitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectrefmonitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor

值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorentermonitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

synchronized方法底层原理

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor (虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放 monitor 。在方法执行期间,执行线程持有了 monitor ,其他任何线程都无法再获得同一个 monitor 。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的 monitor 将在异常抛到同步方法之外时自动释放。下面我们看看字节码层面如何实现:

1
2
3
4
5
6
public class SyncMethod {
public int i;
public synchronized void syncTask(){
i++;
}
}

使用javap反编译后的字节码如下:

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
Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncMethod.class
Last modified 2017-6-2; size 308 bytes
MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94
Compiled from "SyncMethod.java"
public class com.zejian.concurrencys.SyncMethod
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool;

//省略没必要的字节码
//==================syncTask方法======================
public synchronized void syncTask();
descriptor: ()V
//方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
LineNumberTable:
line 12: 0
line 13: 10
}
SourceFile: "SyncMethod.java"

从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。

同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。

Java虚拟机对synchronized的优化

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级,下面我们将介绍偏向锁和轻量级锁以及JVM的其他优化手段,阐述Java虚拟机所提供的每个锁的核心优化思想,毕竟涉及到具体过程比较繁琐,如需了解详细过程可以查阅《深入理解Java虚拟机原理》。

偏向锁

偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提高了程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果。但对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。

轻量级锁

倘若偏向锁失败,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

加锁过程

锁升级为轻量级锁之后,对象的 Mark word 也会进行相应的的变化。升级为轻量级锁的过程:

  • 线程在自己的栈桢中创建锁记录 LockRecord。
  • 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中。
  • 将锁记录中的 Owner 指针指向锁对象。
  • 将锁对象的对象头的 MarkWord替换为指向锁记录的指针。

通过自旋锁加锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

轻量级锁在加锁过程中,用到了自旋锁所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。注意,锁在原地循环的时候,是会消耗 cpu 的,就相当于在执行一个啥也没有的 for 循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短的时间就能够获得锁了。自旋锁的使用,其实也是有一定的概率背景,在大部分同步代码块执行的时间都是很短的。所以通过看似无异议的循环反而能提升锁的性能。但是自旋必要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而会消耗 CPU 资源。默认情况下自旋的次数是 10 次,可以通过 preBlockSpin 来修改在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* Created by zejian on 2017/6/4.
* Blog : http://blog.csdn.net/javazejian [原文地址,请尊重原创]
* 消除StringBuffer同步锁
*/
public class StringBufferRemoveSync {

public void add(String str1, String str2) {
//StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
//因此sb属于不可能共享的资源,JVM会自动消除内部的锁
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}

public static void main(String[] args) {
StringBufferRemoveSync rmsync = new StringBufferRemoveSync();
for (int i = 0; i < 10000000; i++) {
rmsync.add("abc", "123");
}
}

}

关于synchronized 可能需要了解的关键点

synchronized的可重入性

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。如下:

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
public class AccountingSync implements Runnable{
static AccountingSync instance=new AccountingSync();
static int i=0;
static int j=0;
@Override
public void run() {
for(int j=0;j<1000000;j++){

//this,当前实例对象锁
synchronized(this){
i++;
increase();//synchronized的可重入性
}
}
}

public synchronized void increase(){
j++;
}


public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();t2.start();
t1.join();t2.join();
System.out.println(i);
}
}

在获取当前实例对象锁后进入synchronized代码块执行同步代码,并在代码块中调用了当前实例对象的另外一个synchronized方法,再次请求当前实例锁时,将被允许,进而执行方法体代码,这就是重入锁最直接的体现,需要特别注意另外一种情况,当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。注意由于synchronized是基于monitor实现的,因此每次重入,monitor中的计数器仍会加1。

三种锁的对比

三种锁对比

转自剖析Disruptor为什么会这么快

Disruptor如何解决这些问题。

首先,Disruptor根本就不用锁。

取而代之的是,在需要确保操作是线程安全的(特别是,在多生产者的环境下,更新下一个可用的序列号)地方,我们使用CAS(Compare And Swap/Set)操作。这是一个CPU级别的指令,在我的意识中,它的工作方式有点像乐观锁——CPU去更新一个值,但如果想改的值不再是原来的值,操作就失败,因为很明显,有其它操作先改变了这个值。

img

注意,这可以是CPU的两个不同的核心,但不会是两个独立的CPU。

CAS操作比锁消耗资源少的多,因为它们不牵涉操作系统,它们直接在CPU上操作。但它们并非没有代价——在上面的试验中,单线程无锁耗时300ms,单线程有锁耗时10000ms,单线程使用CAS耗时5700ms。所以它比使用锁耗时少,但比不需要考虑竞争的单线程耗时多。

回到Disruptor,在我讲生产者时讲过ClaimStrategy。在这些代码中,你可以看见两个策略,一个是SingleThreadedStrategy(单线程策略)另一个是MultiThreadedStrategy(多线程策略)。你可能会有疑问,为什么在只有单个生产者时不用多线程的那个策略?它是否能够处理这种场景?当然可以。但多线程的那个使用了AtomicLong(Java提供的CAS操作),而单线程的使用long,没有锁也没有CAS。这意味着单线程版本会非常快,因为它只有一个生产者,不会产生序号上的冲突。

我知道,你可能在想:把一个数字转成AtomicLong不可能是Disruptor速度快的唯一秘密。当然,它不是,否则它不可能称为“为什么这么快(第一部分)”。

但这是非常重要的一点

——在整个复杂的框架中,只有这一个地方出现多线程竞争修改同一个变量值。这就是秘密。还记得所有的访问对象都拥有序号吗?如果只有一个生产者,那么系统中的每一个序列号只会由一个线程写入。这意味着没有竞争、不需要锁、甚至不需要CAS。在ClaimStrategy中,如果存在多个生产者,唯一会被多线程竞争写入的序号就是 ClaimStrategy 对象里的那个。

这也是为什么Entry中的每一个变量都只能被一个消费者写。它确保了没有写竞争,因此不需要锁或者CAS。

回到为什么队列不能胜任这个工作

因此你可能会有疑问,为什么队列底层用RingBuffer来实现,仍然在性能上无法与 Disruptor 相比。队列和最简单的ring buffer只有两个指针——一个指向队列的头,一个指向队尾:

img

如果有超过一个生产者想要往队列里放东西,尾指针就将成为一个冲突点,因为有多个线程要更新它。如果有多个消费者,那么头指针就会产生竞争,因为元素被消费之后,需要更新指针,所以不仅有读操作还有写操作了。

等等,我听到你喊冤了!因为我们已经知道这些了,所以队列常常是单生产者和单消费者(或者至少在我们的测试里是)。
队列的目的就是为生产者和消费者提供一个地方存放要交互的数据,帮助缓冲它们之间传递的消息。这意味着缓冲常常是满的(生产者比消费者快)或者空的(消费者比生产者快)。生产者和消费者能够步调一致的情况非常少见。

所以,这就是事情的真面目。一个空的队列:

img

一个满的队列:

img

(校对注:这应该是一个双向队列)

队列需要保存一个关于大小的变量,以便区分队列是空还是满。否则,它需要根据队列中的元素的内容来判断,这样的话,消费一个节点(Entry)后需要做一次写入来清除标记,或者标记节点已经被消费过了。无论采用何种方式实现,在头、尾和大小变量上总是会有很多竞争,或者如果消费操作移除元素时需要使用一个写操作,那元素本身也包含竞争。

基于以上,这三个变量常常在一个cache line里面,有可能导致伪分享false sharing。因此,不仅要担心生产者和消费者同时写size变量(或者元素),还要注意由于头指针尾指针在同一位置,当头指针更新时,更新尾指针会导致缓存不命中。这篇文章已经很长了,所以我就不再详述细节了。

这就是我们所说的“分离竞争点问题”或者队列的“合并竞争点问题”。通过将所有的东西都赋予私有的序列号,并且只允许一个消费者写Entry对象中的变量来消除竞争,Disruptor 唯一需要处理访问冲突的地方,是多个生产者写入 Ring Buffer 的场景。

总结

Disruptor相对于传统方式的优点:

  1. 没有竞争=没有锁=非常快。
  2. 所有访问者都记录自己的序号的实现方式,允许多个生产者与多个消费者共享相同的数据结构。
  3. 在每个对象中都能跟踪序列号(ring buffer,claim Strategy,生产者和消费者),加上神奇的cache line padding,就意味着没有为伪共享和非预期的竞争。

C10K问题

C10K问题: 一万个客户端同时连接

常识一:文件句柄限制

在linux下每一个tcp连接都要占一个文件描述符,一旦文件描述符使用完了,新的连接到来返回给我们的错误是“Socket/File:Can’t open so many files”。

操作系统可以打开的最大文件数的限制。

1 进程限制

执行 ulimit -n 输出 1024,说明对于一个进程而言最多只能打开1024个文件,所以采用此配置最多可以并发上千个TCP连接。
临时修改:ulimit -n 1000000,但是这种临时修改只对当前登录用户目前的使用环境有效,系统重启或用户退出后就会失效。

重启后失效的修改: (CentOS 6.5下测试,重启后未发现失效),编辑 /etc/security/limits.conf 文件, 修改后内容为:

1
2
soft nofile 1000000
hard nofile 1000000

永久修改:编辑/etc/rc.local,在其后添加如下内容:

1
ulimit -SHn 1000000

2 全局限制

执行cat /proc/sys/fs/file-nr输出9344 0 592026,分别为:

  1. 已经分配的文件句柄数,
  2. 已经分配但没有使用的文件句柄数,
  3. 最大文件句柄数。

但在kernel 2.6版本中第二项的值总为0,这并不是一个错误,它实际上意味着已经分配的文件描述符无一浪费的都已经被使用了。我们可以把这个数值改大些,用 root 权限修改 /etc/sysctl.conf 文件:

  • fs.file-max = 1000000
  • net.ipv4.ip_conntrack_max = 1000000
  • net.ipv4.netfilter.ip_conntrack_max = 1000000

常识二:端口号范围限制?

操作系统上端口号1024以下是系统保留的,从1024-65535是用户使用的。每个TCP连接都要占一个端口号, 但可以创建的最大并发连接不只60000个

如何标识一个TCP连接:

系统用一个4四元组来唯一标识一个TCP连接:{local ip, local port,remote ip,remote port}

《UNIX网络编程:卷一》第四章中对accept的讲解,第二个参数cliaddr代表了客户端的ip地址和端口号。而服务端实际只使用了bind时这一个端口,说明端口号65535并不是并发量的限制。

server最大tcp连接数:

server通常固定在某个本地端口上监听,等待client的连接请求。不考虑地址重用(unix的SO_REUSEADDR选项)的情况下,即使server端有多个ip,本地监听端口也是独占的,因此server端tcp连接4元组中只有remote ip(也就是client ip)和remote port(客户端port)是可变的,因此最大tcp连接为客户端ip数×客户端port数,对IPV4,不考虑ip地址分类等因素,最大tcp连接数约为2的32次方(ip数)×2的16次方(port数),也就是server端单机最大tcp连接数约为2的48次方

I/O Model

最初的服务器都是基于进程/线程模型的,一个TCP连接,就需要分配1个进程(或者线程)。而进程又是操作系统最昂贵的资源,一台机器无法创建很多进程。
如果是C10K就要创建1万个进程,那么单机而言操作系统是无法承受的(往往出现效率低下甚至完全瘫痪)。
如果是采用分布式系统,维持1亿用户在线需要10万台服务器,成本巨大,也只有Facebook、Google、雅虎等巨头才有财力购买如此多的服务器。

传统的同步阻塞I/O模型都是一样的,处理的方式都是requests per second,并发10K和100的区别关键在于CPU。
创建的进程线程多了,数据拷贝频繁(缓存I/O、内核将数据拷贝到用户进程空间、阻塞), 进程/线程上下文切换消耗大, 导致操作系统崩溃,这就是C10K问题的本质!

互联网服务端处理网络请求的原理

典型互联网服务端处理网络请求的典型过程:

高性能网络编程(五):一文读懂高性能网络编程中的I/O模型_1.jpeg

由上图可以看到,主要处理步骤包括:

1)获取请求数据,客户端与服务器建立连接发出请求,服务器接受请求(1-3);
2)构建响应,当服务器接收完请求,并在用户空间处理客户端的请求,直到构建响应完成(4);
3)返回数据,服务器将已构建好的响应再通过内核空间的网络 I/O 发还给客户端(5-7)。

设计服务端并发模型时,主要有如下两个关键点:

1)服务器如何管理连接,获取输入数据;
2)服务器如何处理请求。

“I/O 模型”的基本认识

阻塞调用与非阻塞调用:

  • 阻塞调用是指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回;
  • 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

两者的最大区别在于被调用方在收到请求到返回结果之前的这段时间内,调用方是否一直在等待。阻塞是指调用方一直在等待而且别的事情什么都不做;非阻塞是指调用方先去忙别的事情。

同步处理与异步处理:

同步处理是指被调用方得到最终结果之后才返回给调用方;异步处理是指被调用方先返回应答,然后再计算调用结果,计算完最终结果后再通知并返回给调用方。

阻塞、非阻塞和同步、异步的区别(阻塞、非阻塞和同步、异步其实针对的对象是不一样的):

  • 1)阻塞、非阻塞的讨论对象是调用者;
  • 2)同步、异步的讨论对象是被调用者。

recvfrom 函数:

// FIXME 究竟什么是Socket,操作系统套接字代表的是什么

recvfrom 函数(经 Socket 接收数据),这里把它视为系统调用。

一个输入操作通常包括两个不同的阶段:

  • 1)等待数据准备好;
  • 2)从内核向进程复制数据。

对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。

实际应用程序在系统调用完成上面的 2 步操作时,调用方式的阻塞、非阻塞,操作系统在处理应用程序请求时,处理方式的同步、异步处理的不同,可以分为 5 种 I/O 模型(下面的章节将逐个展开介绍)。(参考《UNIX网络编程卷1》)

阻塞式 I/O(blocking I/O)

高性能网络编程(五):一文读懂高性能网络编程中的I/O模型_2.jpeg

在阻塞式 I/O 模型中,应用程序在从调用 recvfrom 开始到它返回有数据报准备好这段时间是阻塞的,recvfrom 返回成功后,应用进程开始处理数据报。

比喻: 一个人在钓鱼,当没鱼上钩时,就坐在岸边一直等。

优点: 程序简单,在阻塞等待数据期间进程/线程挂起,基本不会占用 CPU 资源。

缺点: 每个连接需要独立的进程/线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大,这种模型在实际生产中很少使用。

非阻塞式 I/O (non-blocking I/O)

高性能网络编程(五):一文读懂高性能网络编程中的I/O模型_3.jpeg

应用程序把一个套接口设置为非阻塞,就是告诉内核,当所请求的 I/O 操作无法完成时,不要将进程睡眠。
而是返回一个错误,应用程序基于 I/O 操作函数将不断的轮询数据是否已经准备好,如果没有准备好,继续轮询,直到数据准备好为止。

比喻: 边钓鱼边玩手机,隔会再看看有没有鱼上钩,有的话就迅速拉杆。

优点: 不会阻塞在内核的等待数据过程,每次发起的 I/O 请求可以立即返回,不用阻塞等待,实时性较好。

缺点: 轮询将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低,所以一般 Web 服务器不使用这种 I/O 模型。

I/O 多路复用(I/O multiplexing)

高性能网络编程(五):一文读懂高性能网络编程中的I/O模型_4.jpeg

在 I/O 复用模型中,会用到 Select 或 Poll 函数或 Epoll 函数(Linux 2.6 以后的内核开始支持),这两个函数也会使进程阻塞,但是和阻塞 I/O 有所不同。

这两个函数可以同时阻塞多个 I/O 操作,而且可以同时对多个读操作,多个写操作的 I/O 函数进行检测,直到有数据可读或可写时,才真正调用 I/O 操作函数。

比喻: 放了一堆鱼竿,在岸边一直守着这堆鱼竿,没鱼上钩就玩手机。

优点: 可以基于一个阻塞对象,同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程),这样可以大大节省系统资源。

缺点: 当连接数较少时效率相比多线程+阻塞 I/O 模型效率较低,可能延迟更大,因为单个连接处理需要 2 次系统调用,占用时间会有增加。

众所周之,Nginx这样的高性能互联网反向代理服务器大获成功的关键就是得益于Epoll

信号驱动式 I/O (signal-driven I/O)

高性能网络编程(五):一文读懂高性能网络编程中的I/O模型_5.jpeg

在信号驱动式 I/O 模型中,应用程序使用套接口进行信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻塞。
当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。

比喻: 鱼竿上系了个铃铛,当铃铛响,就知道鱼上钩,然后可以专心玩手机。

优点: 线程并没有在等待数据时被阻塞,可以提高资源的利用率。

缺点: 信号 I/O 在大量 IO 操作时可能会因为信号队列溢出导致没法通知。

信号驱动 I/O 尽管对于处理 UDP 套接字来说有用,即这种信号通知意味着到达一个数据报,或者返回一个异步错误。

但是,对于 TCP 而言,信号驱动的 I/O 方式近乎无用,因为导致这种通知的条件为数众多,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失。

异步 I/O(即AIO,全称asynchronous I/O)

高性能网络编程(五):一文读懂高性能网络编程中的I/O模型_6.jpeg

由 POSIX 规范定义,应用程序告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到应用程序的缓冲区)完成后通知应用程序。

这种模型与信号驱动模型的主要区别在于:信号驱动 I/O 是由内核通知应用程序何时启动一个 I/O 操作,而异步 I/O 模型是由内核通知应用程序 I/O 操作何时完成。

优点: 异步 I/O 能够充分利用 DMA 特性,让 I/O 操作与计算重叠。

缺点: 要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O。

而在 Linux 系统下,Linux 2.6才引入,目前 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 IO 复用模型模式为主。

关于AOI的介绍,请见:《Java新一代网络编程模型AIO原理及Linux系统AIO介绍》。

5 种 I/O 模型总结

高性能网络编程(五):一文读懂高性能网络编程中的I/O模型_7.jpeg

从上图中我们可以看出,越往后,阻塞越少,理论上效率也是最优。

这五种 I/O 模型中,前四种属于同步 I/O,因为其中真正的 I/O 操作(recvfrom)将阻塞进程/线程,只有异步 I/O 模型才与 POSIX 定义的异步 I/O 相匹配。

高性能网络编程中的线程模型

传统阻塞 I/O 服务模型

高性能网络编程(六):一文读懂高性能网络编程中的线程模型_1.jpeg

特点:

1)采用阻塞式 I/O 模型获取输入数据;
2)每个连接都需要独立的线程完成数据输入,业务处理,数据返回的完整操作。

存在问题:

1)当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大;
2)连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 Read 操作上,造成线程资源浪费。

Reactor 模式

基本介绍

针对传统阻塞 I/O 服务模型的 2 个缺点,比较常见的有如下解决方案:

1)基于 I/O 复用模型:多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理;
2)基于线程池复用线程资源:不必再为每个连接创建线程,将连接完成后的业务处理任务分配给线程进行处理,一个线程可以处理多个连接的业务。

I/O 复用结合线程池,这就是 Reactor 模式基本设计思想,如下图:

高性能网络编程(六):一文读懂高性能网络编程中的线程模型_2.jpeg

Reactor 模式,是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。

服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor 模式也叫 Dispatcher 模式。即 I/O 多了复用统一监听事件,收到事件后分发(Dispatch 给某进程),是编写高性能网络服务器的必备技术之一。

Reactor 模式中有 2 个关键组成:

1)Reactor:Reactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。 它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人;
2)Handlers:处理程序执行 I/O 事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际官员。Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作。

根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现:

1)单 Reactor 单线程;
2)单 Reactor 多线程;
3)主从 Reactor 多线程。

下面详细介绍这 3 种实现方式。

单 Reactor 单线程

高性能网络编程(六):一文读懂高性能网络编程中的线程模型_3.jpeg

其中,Select 是前面 I/O 复用模型介绍的标准网络编程 API,可以实现应用程序通过一个阻塞对象监听多路连接请求,其他方案示意图类似。

方案说明:

1)Reactor 对象通过 Select 监控客户端请求事件,收到事件后通过 Dispatch 进行分发;
2)如果是建立连接请求事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后的后续业务处理;
3)如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应;
4)Handler 会完成 Read→业务处理→Send 的完整业务流程。

优点:

模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成。

缺点:

性能问题,只有一个线程,无法完全发挥多核 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈。

可靠性问题,线程意外跑飞,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障。

使用场景:

客户端的数量有限,业务处理非常快速,比如 Redis,业务处理的时间复杂度 O(1)。

单 Reactor 多线程

高性能网络编程(六):一文读懂高性能网络编程中的线程模型_4.jpeg

方案说明:

  • 1)Reactor 对象通过 Select 监控客户端请求事件,收到事件后通过 Dispatch 进行分发;
  • 2)如果是建立连接请求事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后续的各种事件;
  • 3)如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应;
  • 4)Handler 只负责响应事件,不做具体业务处理,通过 Read 读取数据后,会分发给后面的 Worker 线程池进行业务处理;
  • 5)Worker 线程池会分配独立的线程完成真正的业务处理,如何将响应结果发给 Handler 进行处理;
  • 6)Handler 收到响应结果后通过 Send 将响应结果返回给 Client。

优点:

可以充分利用多核 CPU 的处理能力。

缺点:

多线程数据共享和访问比较复杂;Reactor 承担所有事件的监听和响应,在单线程中运行,高并发场景下容易成为性能瓶颈。

主从 Reactor 多线程

高性能网络编程(六):一文读懂高性能网络编程中的线程模型_5.jpeg

针对单 Reactor 多线程模型中,Reactor 在单线程中运行,高并发场景下容易成为性能瓶颈,可以让 Reactor 在多线程中运行。

方案说明:

  • 1)Reactor 主线程 MainReactor 对象通过 Select 监控建立连接事件,收到事件后通过 Acceptor 接收,处理建立连接事件;
  • 2)Acceptor 处理建立连接事件后,MainReactor 将连接分配 Reactor 子线程给 SubReactor 进行处理;
  • 3)SubReactor 将连接加入连接队列进行监听,并创建一个 Handler 用于处理各种连接事件;
  • 4)当有新的事件发生时,SubReactor 会调用连接对应的 Handler 进行响应;
  • 5)Handler 通过 Read 读取数据后,会分发给后面的 Worker 线程池进行业务处理;
  • 6)Worker 线程池会分配独立的线程完成真正的业务处理,如何将响应结果发给 Handler 进行处理;
  • 7)Handler 收到响应结果后通过 Send 将响应结果返回给 Client。

优点:

父线程与子线程的数据交互简单职责明确,父线程只需要接收新连接,子线程完成后续的业务处理。

父线程与子线程的数据交互简单,Reactor 主线程只需要把新连接传给子线程,子线程无需返回数据。

这种模型在许多项目中广泛使用,包括 Nginx 主从 Reactor 多进程模型,Memcached 主从多线程,Netty 主从多线程模型的支持。

小结

3 种模式可以用个比喻来理解:

(餐厅常常雇佣接待员负责迎接顾客,当顾客入坐后,侍应生专门为这张桌子服务)

  • 1)单 Reactor 单线程,接待员和侍应生是同一个人,全程为顾客服务;
  • 2)单 Reactor 多线程,1 个接待员,多个侍应生,接待员只负责接待;
  • 3)主从 Reactor 多线程,多个接待员,多个侍应生。

Reactor 模式具有如下的优点:

  • 1)响应快,不必为单个同步时间所阻塞,虽然 Reactor 本身依然是同步的;
  • 2)编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
  • 3)可扩展性,可以方便的通过增加 Reactor 实例个数来充分利用 CPU 资源;
  • 4)可复用性,Reactor 模型本身与具体事件处理逻辑无关,具有很高的复用性。

Proactor 模型

在 Reactor 模式中,Reactor 等待某个事件或者可应用或者操作的状态发生(比如文件描述符可读写,或者是 Socket 可读写)。 然后把这个事件传给事先注册的 Handler(事件处理函数或者回调函数),由后者来做实际的读写操作。其中的读写操作都需要应用程序同步操作,所以 Reactor 是非阻塞同步网络模型。

如果把 I/O 操作改为异步,即交给操作系统来完成就能进一步提升性能,这就是异步网络模型 Proactor。

高性能网络编程(六):一文读懂高性能网络编程中的线程模型_1.jpeg

Proactor 是和异步 I/O 相关的,详细方案如下:

1)Proactor Initiator 创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 AsyOptProcessor(Asynchronous Operation Processor)注册到内核;
2)AsyOptProcessor 处理注册请求,并处理 I/O 操作;
3)AsyOptProcessor 完成 I/O 操作后通知 Proactor;
4)Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理;
5)Handler 完成业务处理。

可以看出 Proactor 和 Reactor 的区别:

1)Reactor 是在事件发生时就通知事先注册的事件(读写在应用程序线程中处理完成);
2)Proactor 是在事件发生时基于异步 I/O 完成读写操作(由内核完成),待 I/O 操作完成后才回调应用程序的处理器来进行业务处理。

理论上 Proactor 比 Reactor 效率更高,异步 I/O 更加充分发挥 DMA(Direct Memory Access,直接内存存取)的优势。

但是Proactor有如下缺点:

1)编程复杂性,由于异步操作流程的事件的初始化和事件完成在时间和空间上都是相互分离的,因此开发异步应用程序更加复杂。应用程序还可能因为反向的流控而变得更加难以 Debug;
2)内存使用,缓冲区在读或写操作的时间段内必须保持住,可能造成持续的不确定性,并且每个并发操作都要求有独立的缓存,相比 Reactor 模式,在 Socket 已经准备好读或写前,是不要求开辟缓存的;
3)操作系统支持,Windows 下通过 IOCP 实现了真正的异步 I/O,而在 Linux 系统下,Linux 2.6 才引入,目前异步 I/O 还不完善。

因此在 Linux 下实现高并发网络编程都是以 Reactor 模型为主。

1. 03_G1_GC

1.1. 堆内存分配

整个堆分为若干的region,region的大小可以通过参数设置;

Region可以分为Eden、Survivor、Old、Humongous(专门存储大对象的Region:单个对象超过Region大小的50%,就是大对象;

如果大对象超过一个Region,则会继续申请连续的空间,作为Humongous区)和Free(空闲Region: Region的内存被释放后的分区);Free Region后续可以作为Eden、Survivor、Old或Humongous分区;

堆分为新生代+老年代;

1.2. 两个Set

  1. CSet: collection set, 待回收的Region集合

  2. RSet: remembered set,引用当前Region集合的其他集合

    引用当前Region的其他Region集合,是points-in的,每个 Region 都有一个 RSet;在 JDK10 中,只有部分 Region 才存在 RSet。

    • young gc:指向「Eden」和「Survivor」Region 的 「Old」和「Humongous」Region 集合,避免全堆扫描
    • mixed gc:确定「Old」Region 之间的相关引用,确定 Region 的回收价值

1.3. 垃圾回收过程

  1. young gc:标记-复制
    1. 「新生代」的垃圾回收
    2. CSet 中,只包含 Eden、Survivor
  2. mixed gc:标记-复制
    1. 「新生代」和「部分老年代」的垃圾回收
    2. CSet 中,包含 Eden、Survivor、部分 Old、Humongous
  3. full gc:
    1. G1 gc 失败,退化为 Serial 方式
    2. 单线程全堆扫描,对整个 Heap 进行垃圾回收,涵盖所有的「新生代」和「老年代」

G1 垃圾收集器,GC 都是针对 CSet 进行的

1.3.1. Young GC

Young GC,关键细节:

  • 针对「年轻代」的 Eden、Survivor 分区,进行 GC
  • 存活的对象,放置在 「新的 Survivor 分区」或「Old 分区」
  • 触发的时机:Eden 分区空间不足,无法为普通对象分配存储空间(非大对象)

Young GC 的执行过程:就是「标记-复制」算法

  1. 根扫描
  2. 确定「老年代」对「新生代」的引用,避免全堆扫描:
    1. 根据 card table,扫描 dirty 部分,更新 RSet
    2. 新生代中,根据 RSet,确定 Old 对 Eden 和 Survivor 对象的引用
  3. 标记复制:将存活对象,放入到 Survivor 区 或者 Old 区
    1. 新的 Survivor 区:是 Free Region 升级来的
    2. 被释放的 Eden 和 Survivor 区:会标记为 Free Region 空白的可用分区

Tips:

  • G1 在 Young GC 过程中,是串行?并行?并发?是否会暂停工作线程?
    • Re:可以多线程,就看怎么设置了,会暂停工作线程,不是并发的。

1.3.2. Mixed GC

Mixed GC,关键细节:

  • 针对「年轻代」和「部分老年代」的 GC,具体 Eden、Survivor、Old、Humongous Region
  • 存活的对象,放置在「新的 Survivor 分区」或「Old 分区」
  • 触发的时机:「并发标记周期」中,完成了最后的「筛选回收」阶段后,标记出了 X 的 Old Region 分区

Mixed GC 的执行过程:就是「标记-复制」算法

  1. 并发标记周期:针对 Old 分区,进行标记
    1. 初始标记:依赖 Young GC
    2. 扫描根分区:如果有 Young GC,则,Young GC block 阻塞等待
    3. 并发标记
      1. 可以并发进行 Young GC
      2. 结束后,并不会进入 Young GC 阶段
    4. 重新标记:不能进行 Young GC
    5. 筛选回收:结束后,进入 mixed 阶段
  2. Mixed GC本质就是对 CSet 中 Region 的回收
    1. CSet:在 mixed 模式下,其中涵盖了 Eden、Survivor、Old、Humongous Region
    2. 筛选回收阶段:针对 Old 分区
      1. 完全可回收的 Region:不存在存活的对象,直接回收 Region,标记为 Free Region 可用分区
      2. 存在部分存活的对象的 Region:标记分数后,追加在 C-Set 中

实际上,可以认为是 2 条线:

  • Young GC
    • 基于 C-Set,进行 Region 回收,本质上,只针对「年轻代」进行回收;
    • 如果 C-Set 中,涵盖了标记为 X 的 Old Region,则,称为 Mixed GC,此时,既针对「年轻代」,也针对「部分老年代」Region 进行回收
  • 并发标记周期:标记出 Old Region,哪些需要回收,标记为 X
    • 完成「并发标记阶段」后,Young GC,自动升级为 mixed 模式,即,Mixed GC
    • Mixed GC:基于 C-Set,进行 Region 回收,只不过,此时,C-Set 中,涵盖了一部分 Old Region

1 个插图:并发标记周期的说明

  • 并发标记周期
    • 触发时机:「老年代」分区的占比,达到阈值
    • 整个周期说明
  • Mixed GC:模式生效点、失效点
    • 生效点:存在 X 状态的 Old Region,即,标记并发周期的「筛选回收」阶段结束后,再次触发的 GC,就是 Mixed GC
    • 失效点:完成所有的 X 状态 Old Region 的清理后,会进入 Young GC 状态
  • 补充说明
    • 部分 X 状态的 Old Region:每次 Mixed GC,只有部分 X 状态的 Old Region 会被放入 C-Set
    • 完整 C-Set 都被回收:C-Set 中所有 Region,每次 GC 都会被回收

特别说明

G1的收集都是STW的;

但「年轻代」和「老年代」的 GC 界限模糊,采用了混合(mixed)收集的方式。

Young GC,可能快速切换为 Mixed GC,只要 X 标记的 Old Region 存在和消失,就会自动升级 or 降级;

这样,即使堆内存很大时,也可以限制收集 Region 的范围,从而降低停顿,达到设置的「暂停时间的目标」。

1.3.3. 其他

1.3.3.1. 启发式算法

启发式算法:根据执行状态,动态调整

  1. 设置了「暂停时间的目标」(默认 200ms),G1 会自动调整「年轻代」的空间大小
  2. 如果显式设置「年轻代」的大小,则,用户设置的「暂停时间的目标」会自动失效

1.3.3.2. SATB,增量式的标记算法

G1 垃圾收集器,采用了 SATB(Snapshot At The Beginning),初始快照,增量式的标记算法,具体:

  1. 标记开始时:Region 创建一个 Snapshot
  2. 存量标记:只针对 Snapshot 中存活的对象,进行标记
  3. 增量标记:Snapshot 之后,新生成的对象,都被标记为「存活对象」,此次不回收,下次标记再说

1.3.3.3. G1:适用场景

就目前而言、CMS还是默认首选的GC策略、可能在以下场景下G1更适合:

  1. 多核+大内存:服务端多核CPU、JVM内存占用较大的应用(至少大于4G)
  2. 业务多碎片:应用在运行过程中会产生大量内存碎片、需要经常压缩空间
  3. 防止高并发雪崩:想要更可控、可预期的GC停顿周期;防止高并发下应用雪崩现象

1.3.4. 小结

G1 垃圾收集器,围绕其 Young GC 和 Mixed GC,从整体宏观的角度上,跟之前所有的「串行」「并行」「并发」的垃圾收集器,存在本质的差异:

  1. 之前的垃圾收集器,要实现 2 个基本步骤
    1. 步骤1:找到需要回收的对象
    2. 步骤2:回收
    3. Note:上面两个步骤「步骤2」依赖「步骤1」,并且串行进行
  2. G1 垃圾收集器,在「老年代」,把 2 个步骤「同时进行」:
    1. 找到需要回收的对象
      1. 在找到需要回收的 Old Region 过程中,仍然可以同时「回收对象」,即 GC
      2. 找到需要回收的 Old Region 过程,称为「并发标记周期
    2. 回收对象
      1. 在「找需要回收的对象」过程中,可以持续并发的进行 GC,称为 Young GC,只会收「新生代」
      2. 「找需要回收的对象」过程结束后,再进行的 GC,称为 Mixed GC,会同时回收「新生代」和「部分老年代」

参考示意图:

G1 回收过程如下。

  1. G1 的年轻代回收,采用复制算法,并行进行收集,收集过程会 STW。
  2. G1 的老年代回收时也同时会对年轻代进行回收。主要分为四个阶段:
    1. 依然是初始标记阶段完成对根对象的标记,这个过程是STW的;
    2. 并发标记阶段,这个阶段是和用户线程并行执行的;
    3. 最终标记阶段,完成三色标记周期;
    4. 复制/清除阶段,这个阶段会优先对可回收空间较大的 Region 进行回收,即 garbage first,这也是 G1 名称的由来。

【参考文献】

  1. GC算法之G1算法
  2. JVM 实践:G1 垃圾收集器

FlinkOutputTimely

定时批量输出

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
public abstract class CommonSinkOperator<T extends Serializable> extends AbstractStreamOperator<Object>
implements ProcessingTimeCallback,
OneInputStreamOperator<T, Object> {

private List<T> list;
private ListState<T> listState;
private int bathSize;
private long interval;
private ProcessingTimeService processingTimeService;

public CommonSinkOperator() {
}

public CommonSinkOperator(int batchSize, long interval)

{

this.chainingStrategy = ChainingStrategy.ALWAYS;
this.batchSize = batchSize;
this.interval = interval;
}

@Override
public void open() throws Exception {
super.open();
if (interval > 0 && batchSize > 1) {
processingTimeService = getProcessingTimeService();
long now = processingTimeService.getCurrentProcessingTime();
processingTimeService.registerTimer(now + interval, this);
}
}

@Override
public void initializeState(

StateInitializationContext context) throws Exception {

super.initializeState(context);
this.list = new ArrayList<T>();
listState = context.getOperatorStateStore()

.getSerializableListState("batch-interval-sink");

if (context.isRestored()) {
listState.get().forEach(x -> {
list.add(x);
});
}

}

@Override
public void processElement(

StreamRecord<T> element) throws Exception {

list.add(element.getValue());
if (list.size() >= batchSize) {
saveRecords(list);
}

}

@Override
public void snapshotState(

StateSnapshotContext context) throws Exception {

super.snapshotState(context);
if (list.size() > 0) {
listState.clear();
listState.addAll(list);
}
}

@Override
public void onProcessingTime(long timestamp)

throws Exception {

if (list.size() > 0) {
saveRecords(list);
list.clear();
}
long now =processingTimeService.getCurrentProcessingTime();
processingTimeService.registerTimer(now + interval,this);

}

public abstract void saveRecords(List<T> datas);
}

Flink-任务提交

org.apache.zeppelin.flink.FlinkScalaInterpreter

命令行提交方式

yarn per-job

单任务Attach模式

默认是 Attach 模式,即客户端会一直等待直到程序结束才会退出。

通过 -m yarn-cluster 指定 Yarn 模式

Yarn 上显示名字为 Flink session cluster,这个 Batch 的 Wordcount 任务运行完会 FINISHED。

单任务Detached模式

由于是 Detached 模式,客户端提交完任务就退出了

Yarn 上显示为 Flink per-job cluster

yarn session

也有attach和detached模式的区分

Flink run 方式提交(推荐模式)

yarn session需要先启动一个集群,然后在提交作业。
对于Flink run直接提交作业就相对比较简单,不需要额外的去启动一个集群,直接提交作业,即可完成Flink作业。
命令: bin/flink run -m yarn-cluster examples/batch/WordCount.jar,注意使用参数-m yarn-cluster提交到yarn集群。

application mode

应用程序的main方法在jobManager上运行,创建仅仅在特定应用程序的作业之间共享的会话集群,在应用程序完成时终止。

提交任务到yarn并监控任务运行情况

./flink run -m yarn-cluster -p 4 -yjm 1024m -ytm 1096m -ynm 1 ../examples/batch/WordCount.jar

./flink run-application -t yarn-application ../examples/batch/WordCount.jar

指定application name为1

然后通过yarn-rest-api拿取任务信息

【参考文献】

  1. flink on yarn模式下两种提交job方式

Timer

TimerService

SystemProcessingTimeService

org.apache.flink.streaming.runtime.tasks.SystemProcessingTimeService