Java并发(一、概述)

离上次写博客又隔了很久,心中有愧。在我不断使用Java的过程中,几乎都是拿来就用,就Java并发这块我还没有系统的梳理过,趁着国庆有空余时间,把它梳理一遍。以下部分内容参考相关书籍,以作学习之用,特此说明。

1.并行定律

随着科技的发展,集成电路上的晶体管数量也达到了物理极限,摩尔定律也随之不再那么有效,例如Amdahl定律和Gustafson定律代替它成为计算机性能发展的源动力。从这个演变也可以看出,计算机的性能发展也不得不从追求处理器频率到多核并行处理的发展过程。

1.1.定义

  所谓阿姆达尔(Amdahl)定律,它定义了串行系统并行化后的加速比的计算公式和理论上限。

1.2.公式

  就是其公式就是:

  其中Sp就是加速比,T1是优化前系统耗时,Tp是优化之后系统耗时,p就是处理器个数。那么这个公式意义就是 加速比 = 优化前系统耗时 / 优化后系统耗时。
  我们逐步看一下它的公式推导:

  其中,p为处理器个数,F为串行比率,那么1-F就是并行的比例了。这个公式就是计算优化后的耗时公式,将这个公式代入加速比公式我们就可以得出CPU的处理器数量越多,那么加速比与系统的串行率就成反比:

  我们不妨看个例子,假设现在有个系统是按如下方式串行运行的:
Java并发(一、概述)_第1张图片

  这个系统有三步,其中第一步和第三步都是100ms,第二步是200ms,整个串行的运行时间是400ms。那我们现在可能要对这个系统做个优化,已知这个系统是两个核心,那么如果Step2的操作内部由串行改为并行,那么理想情况可能是这样的:
Java并发(一、概述)_第2张图片

  我们看到Step2分解成并行的操作,那么代入公式得到最终它的加速比为1.2。我们不妨推算一下,假设处理器的个数为无穷大,那么Step2的操作耗时无限趋近于0,那么对于这个系统而言,它的加速比(300ms/200ms)最大也不过是1.5。也就是说,P越趋近于无穷大,那么Sp=1/F。
  加速比越高,表明优化效果越好。根据Amdahl定律,使用多核的CPU对系统优化,优化的效果取决于CPU的数量和系统串行化程序的比重,如果仅仅提升Cpu数量,而不降低程序的串行比重,也是无法提高系统性能的。所以,我们要根本上去改变程序的串行行为,合理的并行与增加处理器数量,才能获得更大的性能提升。

1.3.Gustafson定律

   Gustafson定律只是从不同的角度去阐述处理器个数、串行比例和加速比之间的关系。所以这里不再赘述。

2.Java内存模型

2.1.处理器、高速缓存、主存交互

   提高计算机的性能并不是让计算机同时处理多个任务那样简单,处理器需要和内存交互,例如读取数据、存储运算结果,因为现代计算机的处理器能力太强,存储设备的读写速度与之相差太大,所以在存储设备和处理器之间加上高速缓存来作为处理器和内存之间的缓冲。这样的话CPU就不需要等待相对而言缓慢的内存读写了。
   当高速缓存作为一种解决处理器与内存读写速度矛盾的手段时,带来了新的问题,那就是缓存一致性。处理器有对应的高速缓存,而它们又对应同一块主内存。当多个处理器的运算都涉及到同一个主内存时,该如何保证数据的一致性?所以为了解决一致性,又在处理器访问缓存时候遵循一些协议。
   那么诸如Java虚拟机的内存模型之类就可以理解成,在特定的操作协议下,对特定的内存或者高速缓存进行读写的过程抽象。
   除了高速缓存之外,处理器也会对输入代码乱序执行优化(Out-Of-Order Execution)优化,这种优化并不能保证处理器的执行顺序会和输入代码的顺序一致,但会保证最终输入的结果是一致的。与之相对应的,Java也存在着一套类似的机制,就是指令重排(Instruction Reorder)优化。
Java并发(一、概述)_第3张图片

2.2.Java内存模型(JMM)

   Java虚拟机定义了一套内存模型来屏蔽各种硬件和操作系统带来的内存访问差异,实现Java程序在各平台下达到一致的内存访问结果。
   JMM主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储、取出的底层细节。这里的变量包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为这些是线程私有的,不会被共享。
   Java内存模型规定所有变量都存在主内存,每条线程都有自己的工作内存,线程所有对变量的操作都必须在工作内存中执行,线程的工作内存保存了被该线程使用到的变量主内存拷贝,不能直接读写主内存中的变量,线程之间变量值传递需要通过主内存完成。线程、主内存、工作内存交互如下:
Java并发(一、概述)_第4张图片

2.3.内存间的交互操作

   在主内存和工作内存之间的交互协议的具体细节,Java内存模型定义了8个操作来完成,虚拟机来保证这8个操作都是原子的。

操作 说明 描述
lock 锁定 作用于主内存的变量,将一个变量标识为一条线程独占的状态
unlock 解锁 作用于主内存的变量,将一个标记为锁定状态的变量解锁,以便其它线程使用
read 读取 作用于主内存的变量,将一个变量的值从主内存传输到线程的工作内存中,以便load操作使用
load 载入 作用于工作内存的变量,将read操作读取过来的值放入工作内存的变量副本中
use 使用 作用于工作内存的变量,将工作内存的值传递给执行引擎,虚拟机遇到一个需要使用变量的字节码指令就会这么做
assign 赋值 作用于工作内存的变量,从执行引擎接受到的值赋给工作内存的变量,虚拟机遇到一个给变量赋值的字节码指令就会这么做
store 存储 作用于工作内存的变量,将工作内存的变量的值传递给主内存中,以便write操作
write 写入 作用于主内存的变量,它把store操作从工作内存中得到的变量赋值放入主内存的变量中

   Java内存模型只要求两个操作必须按顺序执行,而没有保证是连续执行,也就是说两个指令之间是可以有其它指令的。Java内存模型还规定可在执行上述8种基本操作时必须满足以下的规则:
   * 不允许read和load、store和write操作之一单独出现;
   * 不允许一个线程丢弃它最近的assign操作,即assign操作之后必须将值同步给主内存;
   * 不允许一个线程没发生过assign就把数据同步给主内存;
   * 一个新的变量只能诞生在主内存,不允许工作内存直接使用一个未被(load和assign)的变量;
   * 一个变量在同一时刻只允许同一条线程对其进行lock操作;
   * 如果对一个变量执行lock,那么将清空这个变量在工作内存的此变量的值,在执行引擎使用这个变量之前,重新执行load和assign操作初始化工作内存的值;
   * 如果一个变量事先没有被lock操作锁定,就允许对其或其它线程进行unlock操作;
   * 对一个变量执行unlock操作之前,必须先同步回主内存;

2.4.原子性(Atomicity)、可见性(Visibility)、有序性(Ordering)

   原子性(Atomicity):原子性是指一个操作是不可中断的,一旦一个操作开始,就不会被其它线程干扰。Java内存模型来直接保证原子性变量的操作包括read、load、assign、use、store和write,基本可以认为基本数据类型的读写是原子性的,但是double、long类型例外,这是它们的非原子性协定决定的。当然,Java内存模型还提供了lock和unlock来满足更大范围的原子性操作,这两个操作反映到字节码指令就是monitorenter和monitorexit隐式的操作,反映到代码上就是synchronized关键字。
   可见性(Visibility):可见性是指一个线程修改了共享变量的值,其它线程能立即得知这个更改。Java内存模型是通过变量修改后将新值同步给主内存,在变量读取前从主内存刷新变量值依赖主内存作为传递媒介的方式来实现可见性的,无论这个变量是否被volatile修饰,但它们的区别是volatile变量的特殊规则能立即同步到主内存,以及每次使用前从主内存刷新,而普通变量不行。当然,除了volatile能实现可见性之外,synchronized和final同样可以。synchronized的可见性是通过“对一个变量执行unlock操作之前,必须把此变量同步回主内存中”这条规则获得的;而final的可见性是指,被final修饰的字段在构造器一旦初始化完成,并且构造器没把this的引用传递出去,那么在其它线程就能看见final字段的值。
   有序性(Ordering):前面也提到,java会指令重排,代码顺序未必和指令执行顺序一致。Java提供了volatile和synchronized来保证线程之间操作的有序性。volatile关键字本身就禁止指令重排,而synchronized是由“一个变量在同一时刻只允许一条线程对其lock操作”这条规则获得。

2.5.Happen-Before原则

   Java里的有序性除了靠volatile和synchronized两个关键字完成,其实还隐藏着先行发生(Happen-Before)原则,通过这个原则和之前的规则基本能解决并发环境下两个操作之间的冲突问题。
   * 程序次序原则(Program Order Rule):一个线程内保证语义的串行;
   * 管程锁定原则(Monitor Lock Rule):unlock操作必定在之后的同一个锁的lock操作之前;
   * volatile规则(Volatile Variable Rule):volatile变量的写操作先行发生于后面这个变量的读操作;
   * 线程启动规则(Thread Start Rule):线程的start()方法先于该线程其它的每一个动作;
   * 线程终止规则(Thread Termination Rule):线程的所有操作都先于该线程的终结(Thread.join());
   * 线程中断规则(Thread Interruption Rule):线程的interrupt()方法调用先行发生于被中断线程的代码检测到中断事件的发生;
   * 对象终结规则(Finalizer Rule):一个对象的初始化完成先行与它的finalize()方法的开始;
   * 传递性 (Transitivity):如果A操作先于B操作,B操作先于C操作,那么A必定先于C;

3.volatile

3.1.语义

   Java内存模型基本是围绕原子性、有序性、可见性展开,而volatile关键字的语义,一是保证此变量对所有线程的可见性,二是禁止指令重排。可以看出,volatile不能保证原子性,这个需要通过加锁或者一些原子类来实现。
   举个例子:

public class VolatileTest {

	public static volatile int i = 0;

	public static void increase() {
		i++;
	}

	public static class IncreaseTask implements Runnable{
		public void run() {
			for (int y = 0; y < 10000; y++) {
				increase();
			}
		}
	}

	public static void main(String[] args) throws InterruptedException {
		Thread[] threads = new Thread[10];
		for (int i = 0; i < 10; i++) {
			threads[i] = new Thread(new IncreaseTask());
			threads[i].start();
		}
		for (int i = 0; i < 10; i++) {
			threads[i].join();
		}
		System.out.println(i);
	}

}

   在上面这段代码中,变量i用volatile修饰,循环10个线程,每个线程内部对i递增10000次,如果这段代码并发成功的话,预期的结果应该是100000。但是运行结果可见,每次的结果值都小于100000。
   这个正是因为increase()方法内部对i递增的处理,也就是 i++ 这一段代码不是原子的,代码虽然只有一行,但是编译出来的字节码指令却有多个指令,而且每个指令本身未必就是原子的,因为这些指令还会转化成若干个本地机器码指令。不难分析出,每个线程取到i的值那一刻,volatile保证了这一刻取到的是正确的数据,但是继续往下执行的时候,这个值就可能已经被其它线程修改了,而此时的数据就变成过期的数据,同步到主内存中的数据就可能是一个较小的数据。
   除了在操作递增时候加锁之外,使用AtomicInteger原子类代替int一样可以得到预期的结果。

3.2.volatile的可见性和指令重排

   volatile修饰的变量,赋值后的指令会多出一个内存屏障,这个内存屏障会杜绝后面的指令排到前面去。这种内存屏障其实就是一个空操作,这个空操作指令是lock前缀,它的作用就是使得本CPU的Cache写入内存(write和store操作),该写入动作使得其它CPU或者别的内核无效化其Cache,所以通过这样的一个空操作,让volatile修饰的变量对其它CPU立即可见。也因此,这个空操作指令在同步到内存时,意味着所有的操作都已经执行完成,这样就形成了“指令排序无法越过屏障”的效果。

你可能感兴趣的:(Java并发(一、概述))