转载

Java并发编程(一)并发特性

最近想总结一些Java并发相关的内容,先写吧,写到哪儿就是哪[捂脸]

1、物理计算机的并发问题

在说明Java并发特性之前,先简单了解一下物理计算机中的并发问题,这二者有不少相似之处。物理机对并发的处理方案对于虚拟机也有很大的参考意义。

“并发”在计算机领域内,一直是比较头疼。因为并发不仅仅是计算的事情,也是存储的事情。我们在处理并发时,不可能只靠CPU就能完成,也需要与内存交互,比如读取运算数据,存储运算结果等。

但是,由于CPU的处理效率和内存的处理效率差了几个数量级,计算机不得不引入高速缓存作为内存和CPU之间的缓冲,将运算需要使用的数据复制到缓存中,减少I/O瓶颈,加速运算,当运算完成之后,再将数据从缓存同步回内存中,这样能够提升不少处理的效率。

不过,在引入高速缓存的同时,也带来了另外一个问题——缓存一致性。每个处理器都有自己的高速缓存,而他们又共享同一主内存,当多个处理器任务都是涉及到同一块主内存区域时,就会出现缓存数据不一致的问题。

同时,为了解决一致性的问题,高速缓存就需要遵守一些一致性协议(MSI等协议)来规范对数据的读写。

具体示意图,如下:

Java并发编程(一)并发特性

注:引入物理计算机并发的概念,主要是为了提供一种思路,实际上的实现远比描述的要复杂。

2、Java的内存模型

众所周知,Java本身的运行是基于虚拟机的,在虚拟机的规范中,Java定义了一种内存模型,来屏蔽掉硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

模型分为主内存和工作内存,所有的变量(局部变量除外,局部变量都是线程私有的,不存在并发问题)都存储在主内存中。每条线程具有自己的工作内存,其中工作内存中保存了线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接操作主内存中的变量。不同线程之间是无法访问对方的工作内存,线程间变量值的传递均需要通过主内存来完成,示意图如下:

Java并发编程(一)并发特性

注:这里提到的主内存和工作内存,实际上和我们常说的Java内存分为堆、栈、方法区等并不是同一层次的划分,二者基本上没有直接联系。如果一定要勉强对应的话,那主内存主要对应于Java堆中的对象实例部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存直接对应于物理硬件的内存,而工作内存可能优先存储于高速缓存中。

关于主内存与工作内存之间具体的交互协议,也就是说,一个变量如何从主内存拷贝到工作内存,又是如何从工作内存同步回到主内存的。Java定义了8种操作来实现的,并且虚拟机保证每一种操作都是原子的。

注:8种操作分别是lock、unlock、read、load、use、assign、store、write.

Java并发编程(一)并发特性

上图所示,是两组操作,一组是读取,一组是写入。

值得注意的是,Java模型只要求这两个操作必须是顺序执行,但并没有保证是连续执行,这点区别是非常大的。

也就是说,read和load之间、store和write之间是可以插入其他指令的。

接下来,我们关注一下,Java并发中的三个特性,原子性、可见性和有序性

原子性

由Java内存模型,我们可以得知,在工作内存和主内存交互时,尽管每一条指令是原子性的,但是每一组指令并不是顺序的。

比如,我们对主内存中的变量a、b进行访问时,一种可能出现的顺序是

read a、read b、load b、load a.

因此,在多线程的环境下,会出现并发访问主内存数据的问题。

那么Java是如何满足原子性的需求呢?

Java内存模型中提供了lock和unlock操作来满足原子性的需求。

尽管虚拟机没有直接给用户提供操作,但是提供了更高层次的语法,这就是Java代码中的同步块——synchronized。

当然了,使用ReentrantLock也可以满足原子性。

注:

基础类型变量(byte、short、int、float、boolean等)都是原子性操作,不存在并发问题,通过反编译成汇编语言,可以看到基础类型变量的操作只有一条汇编语句。

但是,对于long和dobule型,未必是原子性操作,主要原因还是因为这两个都是8个字节(64位),Java规范允许将64位数据的读写操作划分为两次32位的操作。

可见性

可见性指的是当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值。这种依赖主内存作为传递媒介的方式来实现可见性的。

对于普通的变量来说,Java是不会保证可见性的,如下图所示:

Java并发编程(一)并发特性

线程1和线程2都将x读取到工作内存中,但是线程2将x的值改成b,并没有及时更新主内存,此时工作内存1仍然取值a。这就出现了可见性的问题。

Java是使用volatile关键字实现变量的可见性的。

volatile类型的变量,在修改值之后,会立即刷新到主内存。并且每次使用前都是从主内存中刷新。

注:synchronized同时也能实现可见性

有序性

为了提升效率,尽量充分利用计算能力,Java虚拟机的即时编译器会对指令进行重新排序优化。

指令的重新排序,不保证程序中的各个语句执行的先后顺序同代码中的顺序一致,但是它会保证程序最终结果执行结果和代码顺序执行的结果是一致的。

//同一个线程内

int a=10;//语句1

int r = 2;//语句2

a = a+3;//语句3

r= a*a;//语句4

复制代码

执行的顺序可能是这样的:

Java并发编程(一)并发特性

那有没有可能语句4和语句3调换一下?

这种是不可能的,因为语句4依赖于语句3,所以语句4只能在语句3之后执行。

以上是单线程的情况,在单线程中,尽管指令可能是无序的,但是最终执行的结果是有序的。

那,换到多线程的情况下,就不一样了,我们看一个例子。

//线程1

context = loadContext(); //语句1

inited = true; // 语句2

//线程2

while(!inited) {

    sleep();

}

doSomething(context);
复制代码

例子中,线程1的语句1和2没有依赖性,因此可能会发生重排序。

假如发生了重排序,在线程1执行过程中先执行了2,而此时线程2以为初始化工作已经完成,那么会跳出循环,执行doSomething(context)方法,而此时context并未初始化,会导致程序报错。

这也就是无序会导致的并发问题。

Java提供了有序性保证的机制,通过volatile和synchronized都可以实现。

我们将上面的代码改造一下:

//线程1

context = loadContext(); //语句1

volatile inited = true; // 语句2

//线程2

while(!inited) {

    sleep();

}

doSomething(context);
复制代码

这样的话,语句2和语句1的顺序就会有保证了。

本文主要参考

《深入理解Java虚拟机第二版》 周志明

原文  https://juejin.im/post/5d04e0f451882570da220368
正文到此结束
Loading...