浅谈并发及Java实现 (一) – 并发设计的三大原则

34次阅读

共计 3298 个字符,预计需要花费 9 分钟才能阅读完成。

并发设计的三大原则
原子性
原子性:对共享变量的操作相对于其他线程是不可干扰的,即其他线程的执行只能在该原子操作完成后或开始前执行。
通过一个小例子理解
public class Main {

private static Integer a = 0;

public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(50);
for (int i = 0; i < 50; i++) {
pool.submit(() -> {
a = a + 1;
});
}
pool.shutdown();

// 等待线程全部结束
while(!pool.isTerminated());
System.out.println(a);
}
}
这里创建了一个包含 50 个线程的线程池,并让每个线程执行一次自增的操作,最后等待全部线程执行结束之后打印 a 的值。理论上,这个 a 的值应该是 50 吧,但实际运行发现并不是如此,而且多次运行的结果不一样。
分析一下原因,在多线程的情况下,a = a + 1 这一条语句是可能被多个线程同时执行或交替执行的,而这条语句本身分为 3 个步骤,读取 a 的值,a 的值 +1,写回 a。假设现在 a 的值为 1,线程 A 和线程 B 正在执行。线程 A 读取 a 得值为 1,并将 a 得值 +1(线程 A 内 a 的值目前依旧为 1),此时线程 B 读取 a 得值为 1,将 a 值 +1,写回 a,此时 a 为 2,线程 A 再次运行,将刚才 + 1 后的 a 值 (2) 写回 a。发现两个线程运行结束后 a 的值为 2。
以一个表格描述运行的过程。

线程 A
线程 B
a

读取 a
读取 a
1

a + 1
a + 1, 写回结果
2

写回结果

2

这一现象发生的原因,正是因为 a = a + 1 其实是由多个步骤所构成的,在一个线程操作的过程中,其他线程也可以进行操作,所以发生了非预期的错误结果。
因此,若能保证一个线程在执行操作共享变量的时候,其他线程不能操作,即不能干扰的情况下,就能保证程序正常的运行了,这就是原子性。
可见性
可见性:当一个线程修改了状态,其他的线程能够看到改变。
了解过计算机组成原理的应该知道,为了缓解 CPU 过高的执行速度和内存过低的读取速度的矛盾,CPU 内置了缓存功能,能够存储近期访问过的数据,若需要再次操作这些数据,只需要从缓存中读取即可,大大减少了内存 I / O 的时间。
(此处应当有 JVM 的内存结构分析,待添加) 但此时就产生了一个问题,在多处理器的情况下,若对同一个内存区域进行操作,就会在多个处理器缓存中存在该内存区域的拷贝。但每个处理器对结果的操作并不能对其他处理器可见,因为各个处理器都在读取自己的缓存区域,这就造成了缓存不一致的情况。
同样以一个小例子理解
public class Main {
private static Boolean ready = false;
private static Integer number = 0;

public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (!ready) ;
System.out.println(number);
}).start();
Thread.sleep(100);
number = 42;
ready = true;
System.out.println(“Main Thread Over !”);
}
}

这里 ready 初始化为 false,创建一个线程,持续监测 ready 的值,直到为 true 后打印 number 的结果。主线程则在创建完线程后给 ready 和 number 重新赋值。
运行之后发现,程序打印出了 Main Thread Over!意味着主线程结束,此时 ready 和 number 应该已经被赋值,但等待很久之后发现还是没有正常打印出 number 的值。
因为这里在主线程让线程暂停了一段时间,保证子线程先运行,此时子线程读到的内存中的 ready 为 false, 并拷贝至自身的缓存,当主线程运行时,修改了 ready 的值,而子线程并不知道这一事件的发生,依旧在使用缓冲中的值。这正是因为多线程下缓存的不一致,即可见性问题。
如果有兴趣的同学可以将 Thread.sleep(100); 这句取消,看看结果,分析一下原因。
有序性
有序性:程序执行的顺序按照代码的先后顺序执行。
可能有同学看到这一条不是很理解,而且这个相关的例子也很难给出,因为存在很大的随机性。首先理解一下,为什么会有这一条,难道程序的执行顺序还不是按照我写的代码的顺序吗?
其实还真不一定是。
上面讲到,每个处理器都会有一个高速缓存,在程序运行中,更多次数的命中缓存,往往意味着更高效率的运行,而缓存的空间实际是很小的,可能时常需要让出空间为新变量使用。针对这一点,很多编译器内置了一个优化,通过不影响程序的运行结果,调整部分代码的位置,使得高速缓存的利用率提升。
例如
Integer a,b;
a = a + 1; //(1)
b = b – 3; //(2)
a = a + 1; //(3)
如果处理器的缓存空间很小,只能存下一个变量,那么将第 (3) 句放置 (1),(2) 句之间,是不是缓存多使用了一次,而且没有改变程序的运行结果。这就是重排序问题,当然重排序提升的不仅仅是缓存利用率,还有其他很多的方面。
到这里,可能会有疑问,不是说保证不影响程序运行结果才会有重排序发生吗,为什么还要考虑这一点。
重排序遵守一个 happens-before 原则,而这个原则实则并没有对多线程交替的情况进行考虑,因为这太复杂,考虑多线程的交替性还要进行重排序而不影响运行结果的最好办法,就是不排序 :-)

happens-before 原则

同一个线程中的每个 Action 都 happens-before 于出现在其后的任何一个 Action。
对一个监视器的解锁 happens-before 于每一个后续对同一个监视器的加锁。
对 volatile 字段的写入操作 happens-before 于每一个后续的同一个字段的读操作。
Thread.start()的调用会 happens-before 于启动线程里面的动作。
Thread 中的所有动作都 happens-before 于其他线程检查到此线程结束或者 Thread.join()中返回或者 Thread.isAlive()==false。
一个线程 A 调用另一个另一个线程 B 的 interrupt()都 happens-before 于线程 A 发现 B 被 A 中断 (B 抛出异常或者 A 检测到 B 的 isInterrupted() 或者 interrupted())。
一个对象构造函数的结束 happens-before 与该对象的 finalizer 的开始
如果 A 动作 happens-before 于 B 动作,而 B 动作 happens-before 与 C 动作,那么 A 动作 happens-before 于 C 动作。

那么,多线程下的重排序会怎么样影响程序的结果呢?还是拿上一个例子来讲
public class Main {
private static volatile Boolean ready = false;
private static volatile Integer number = 0;

public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (!ready) ;
System.out.println(number);
}).start();
number = 42; //(1)
ready = true; //(2)
System.out.println(“Main Thread Over !”);
}
}

注意此处删除了线程休眠的代码。
这里我们假设理想的情况,现在整个程序已经满足了可见性 (具体怎么实现见后文),而此时发生了重排序,将(1)(2) 两行的内容进行了交换,子线程开始了运行,并持续检测 ready 中。主线程执行,由于发生了重排序,(2)将先会执行,此时子线程看到 ready 变为了 true,之后打印出 number 的值,此时,number 的值为 0,而预期的结果应该是 42。
这就是在多线程情况下要求程序执行的顺序按照代码的先后顺序执行的原因之一。

正文完
 0