Java并发的问题及应对办法

并发问题的源头

并发?为啥需要并发呢?自然是为了性能,增强算力以及协调能力

在现今计算机器体系中,涉及性能的主要有CPU、内存、IO三方面,而这三者的速度也是天壤之别,形象之讲,CPU天上一天,内存是地上一年,IO则要地上十年

怎么应对:

1、CPU增加了多级缓存,均衡与内存的速度差异,并且还从单核发展为多核增加算力

2、操作系统增加线程,分时复用CPU,均衡CPU与IO的速度差异

3、通过即时编译器重排序,处理器乱序执行,以及内存系统重排序优化指令执行次序,更好地利用缓存

但这些措施并不是百利无害的,并发问题就是其中一害。

1、缓存导致的可见性问题

多核时代,每个核都有各自的L1,L2缓存,在各自缓存中修改的数据相互不可见。

《缓存是个面子工程》提到的硬件缓存,也带来了并发问题。

Java内存模型

2、线程切换带来的原子性问题

这主要有些看似一行的代码,其实需要多条CPU指令才能完成

如count+=1,需要三条指令

指令1:把变量count从内存加载到CPU的寄存器

指令2:在寄存器中执行+1操作

指令3:最后将结果写入内存

当多线程时,线程切换时三条指令就会被错误执行,打破了原子性,导致逻辑的错误。

3、编译优化带来的有序性问题

编译器为了优化性能,有时改变了程序中语句的先后顺序。

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}

new Singleton()这句话感觉是

  1. 分配一块内存M
  2. 在内存M上初始化Singleton对象
  3. 然后M的地址赋值给instance变量

实际执行路径却是:

  1. 分配一块内存M
  2. 将M的地址赋值给instance变量
  3. 最后在内存M上初始化Singleton对象

JMM

如何解决上述的三大问题,JSR-133定义了内存模型JMM

A memory model describes, given a program and an execution trace of that program, whether the execution trace is a legal execution of the program. For the Java programming language, the memory model works by examining each read in an execution trace and checking that the write observed by that read is valid according to certain rules.

也就是说一个内存模型描述了一个给定的程序和和它的执行路径是否一个合法的执行路径。对于java序言来说,内存模型通过考察在程序执行路径中每一个读操作,根据特定的规则,检查写操作对应的读操作是否能是有效的。
java内存模型只是定义了一个规范,具体的实现可以是根据实际情况自由实现的。但是实现要满足java内存模型定义的规范。

内存模型的种类大致有两种:

Sequential Consistency Memory Model: 连续一致性模型。这个模型定义了程序执行的顺序和代码执行的顺序是一致的。也就是说 如果两个线程,一个线程T1对共享变量A进行写操作,另外一个线程T2对A进行读操作。如果线程T1在时间上先于T2执行,那么T2就可以看见T1修改之后的值。
这个内存模型比较简单,也比较直观,比较符合现实世界的逻辑。但是这个模型定义比较严格,在多处理器并发执行程序的时候,会严重的影响程序的性能。因为每次对共享变量的修改都要立刻同步会主内存,不能把变量保存到处理器寄存器里面或者处理器缓存里面。导致频繁的读写内存影响性能。

这种模型相当于禁用了缓存。如果再禁止编译器优化,就算是彻底解决上述问题了,但性能将受到严重影响。

Happens-Before Memory Model: 先行发生模型。这个模型理解起来就比较困难。先介绍一个先行发生关系 (Happens-Before Relationship)
  如果有两个操作A和B存在A Happens-Before B,那么操作A对变量的修改对操作B来说是可见的这个先行并不是代码执行时间上的先后关系,而是保证执行结果是顺序的

happens-before

happens-before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 happens-before 规则。

happens-before规则:

程序次序规则(program order rule): 在一个线程内,先在前面的代码操作先行。准确的说控制流顺序而不是代码顺序。需要考虑分支,循环等结构。

管程锁定规则(monitor lock rule):同一个资源锁,先unlock,之后才能lock。

Volatile变量规则(volatile variable rule):一个变量被volatile修饰,多线程操作,先执行操作,再执行读操作。(同时写操作只能有一个)

线程启动规则(Thread start rule):Thread对象的start方法,先行发生于此线程的每一个方法。

线程终止规则(Thread Termination rule):该线程的所有方法,先行发生于该线程的终止检测方法。例如:可以通过Thread.join方法结束,Thread.isAlive()的返回值等手段检测到线程已经终止执行。

线程中断规则(Thread Interruption Rule): 中断方法先行发生于,中断检测方法。中断方法interrupt(),中断检测interrupted()方法。

对象终结规则(finalizer rule): 一个对象的初始化完成(构造函数执行结束)先行发生于它的finalizer方法的开始。

传递性(Transitivity): 如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

as-if-serial

在谈happens-befre常会提到as-if-serial

即时编译器保证程序能够遵守as-if-serial属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象。也就是经过重排序的执行结果要与顺序执行的结果保持一致。

而且,如果两个操作之间存在数据依赖时,编译器不能调整它们的顺序,否则将造成程序语义的改变。

1
2
3
4
5
6
7
public class AsIfSerialDemo {
public static void main(String[] args) {
int a = 10;//1
int b = 10;//2
int c = a+ b;//3
}
}

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

VS 时间先行

对于happens-before先行发生,怎么理解,最常与“时间先后发生”搞混淆。

happens-before 关系是用来描述两个操作的内存可见性的。

如果操作 X happens-before 操作 Y,那么 X 的结果对于 Y 可见。那么与“时间先后发生”顺序有什么区别?

《JSR-133: JavaTM Memory Model and Thread Specification》,happens-before是这样定义的:

Two actions can be ordered by a happens-before relationship.
If one action happens-before another, then the first is visible to and ordered before the second.
It should be stressed that a happens-before relationship between two actions does not imply that
those actions must occur in that order in a Java platform implementation. The happens-before
relation mostly stresses orderings between two actions that conflict with each other, and defines
when data races take place.

从定义中可以看出两点:

1、the first is visible to and ordered before the second

如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前

2、does not imply that those actions must occur in that order in a Java platform implementation

两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行


由这两条可以得出,JMM是要求当有happens-before关系时,不仅要求了可见性,而且在时间上也得保证有序。然而在不改变语义的前提下,Java平台的实现可以自主决定。这也就表明了happens-before与时间先后没有更大的关联性。

A happens-before B does not imply A happening before B.

A happening before B does not imply A happens-before B.

一个操作 “先行发生” 并不意味着这个操作必定是“时间上的先发生”

1
2
3
// 以下操作在同一个线程中执行
int i = 1;
int j = 2;

根据happens-before规则第一条,“int i = 1” 的操作先行发生(Happens-before)于 “int j = 2”,但在保证语义不改变的前提下,重排序了两条语句,那在时间上,“int j=2”先执行了。

《The Happens-Before Relation》这篇文章中,作者还举了个示例:

1
2
3
4
5
6
7
8
int A = 0;
int B = 0;

void foo()
{
A = B + 1; // (1)
B = 1; // (2)
}

虽然(1) happens-before (2),而且从上面的as-if-serial判断,(1) 得happen before (2) ,但作者观察并不是。

从图上可看出,A被赋值为0,B被赋值为1,但 (1) 没被执行呢。

关于这个问题,在stackoverflow happens-before 被讨论了。有人指出作者说得不对,而也有人给出解答:

A and B are locations in memory. However the operation B+1 does not happen in memory, it happens in the CPU. Specifically, the author is describing these two operations.

A = B + 1 (1)

  • A1 - The value in memory location B (0) is loaded into a CPU register
  • A2 - The CPU register is incremented by 1
  • A3 - The value in the CPU register (1) is written to memory location A

B = 1 (2)

  • B1 - The value 1 is written to memory location B

Happens-Before requires that the read of B (step A1) happens before the write of B (step B1). However, the rest of the operations have no interdependence and can be reordered without affecting the result. Any of these sequences will produce the same outcome

  • A1, B1, A2, A3
  • A1, A2, B1, A3
  • A1, A2, A3, B1

一个操作 “时间上的先发生” 也不能代表这个操作会是“先行发生”

1
2
3
4
5
6
7
8
9
10
11
private int value = 0;

// 线程 A 调用
pubilc void setValue(int value){
this.value = value;
}

// 线程 B 调用
public int getValue(){
return value;
}

假设存在线程 A 和 B,线程 A 先(时间上的先后)调用了 setValue(1),然后线程 B 调用了同一个对象的 getValue() ,那么线程 B 收到的返回值是什么? 0和1都有可能。因为两个操作之间没有happens-before关系。

volatile

volatile字段的happens-before关系指的是在两个不同线程中,【volatile的写操作】 happens-before之后【对同一字段的读操作】。这里有个关键字“之后”,指的是时间上的先后。
也就是我这边写,你之后再读就一定能读得到我刚刚写的值。普通字段则没有这个保证。也就是上面的setValue()与getValue()示例问题

1
2
3
4
5
6
7
8
9
10
11
12
13

int a=0;
volatile int b=0;

public void method1() {
int r2 = a;
b = 1;
}

public void method2() {
int r1 = b;
a = 2;
}

首先,b加了volatile之后,并不能保证b=1一定先于r1=b,而是保证r1=b始终能够看到b的最新值。比如说b=1;b=2,之后在另一个CPU上执行r1=b,那么r1会被赋值为2。
如果先执行r1=b,然后在另外一个CPU上执行b=1和b=2,那么r1将看到b=1之前的值。

在没有标记volatile的时候,同一线程中,r2=a和b=1存在happens before关系,但因为没有数据依赖可以重排列。一旦标记了volatile,即时编译器和CPU需要考虑到多线程happens-before关系,因此不能自由地重排序。

volatile与synchronized的区别,可以查看《volatile synchronized cas》

总结

本篇总结了Java并发问题的本质:可见性、原子性、有序性;以及应对这些问题,JMM中happens-before模型的规则。以及happens-before与happen before的区别。

公众号:码农戏码
欢迎关注微信公众号『码农戏码』