欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

Java 并发编程的可见性、有序性和原子性

程序员文章站 2022-06-27 14:23:32
并发编程无论在哪门语言里,都属于高级篇,面试中也尝尝会被问到。想要深入理解并发编程机制确实不是一件容易的事,因为它涉及到计算机底层和操作系统的相关知识,如果对这部分知识不是很清楚可能会导致理解困难。在...

并发编程无论在哪门语言里,都属于高级篇,面试中也尝尝会被问到。想要深入理解并发编程机制确实不是一件容易的事,因为它涉及到计算机底层和操作系统的相关知识,如果对这部分知识不是很清楚可能会导致理解困难。

在这个专栏里,王子会尽量以白话和图片的方式剖析并发编程本质,希望可以让大家更容易理解。

今天我们就来谈一谈可见性、有序性和原子性都是什么东西。

并发编程的幕后

进入主题之前,我们先来了解一下并发编程的幕后。

随着cpu、内存和i/o设备的不断升级,它们之间一直存在着一个矛盾,就是速度不一致问题。cpu的速度高于内存,内存的速度又高于i/o设备。

我们写的代码中大多数内容都会经过内存处理,有些内容会去读写i/o设备,根据木桶理论,整体的性能取决于最慢的操作,就是i/o设备,所以单单提升cpu的性能是不够的。

为了最大化体现出cpu的性能,计算机底层主要做了三部分优化:

1.cpu增加了缓存,比内存速度更快,平衡内存的速度

2.操作系统增加了进程和线程,可以对cpu分时复用

3.编译程序会进行指令的重排,使缓存更好的发挥性能

我们平时的工作中其实一直都享受着这些优化后的成果,但同时他们也会导致一些很难找到原因的bug。

什么是可见性

首先我们就来看看什么是可见性。

一个线程对共享变量的修改,另一个线程可以感知到,我们称其为可见性。

在单核时代,其实是不存在可见性问题的,因为所有的线程都是在一个cpu中工作的,一个线程的写操作对于其他的线程一定是可见的。

Java 并发编程的可见性、有序性和原子性

但是多核cpu出现后,每个cpu都有自己的缓存,多个线程在不同的cpu中处理数据就会导致不可见问题。

Java 并发编程的可见性、有序性和原子性

假设变量v的值是1, 两个线程同时执行了v++操作,首先会从内存中读取变量v的数据到各自的cpu缓存中,这个时候两个cpu缓存中的v都是1,执行v++后,两个变量v都变成了2,然后再写回内存,内存中的变量v就变成了2。

但其实我们想看到的结果v最终应该是3才对。

在cpu1缓存中执行v++后,cpu2缓存无法感知的到,这就是可见性问题。而由于可见性问题导致的最终数据不正确,就是线程安全问题。

什么是原子性

由于i/o的速度太慢,早期的操作系统发明了多进程,就是允许某个进程执行一小段时间后,重新选择一个进程来执行,这个过程叫做任务切换,而这一小段的时间我们称其为时间片。

Java 并发编程的可见性、有序性和原子性

现在操作系统的任务切换一般指的是更轻量级的线程切换,java的并发编程是基于多线程的,自然也会存在线程切换。

一般会在时间片结束的时候进行线程切换,java语言中执行的一段简单的代码往往需要多条cpu的指令实现,比如count++这部分代码,至少需要三条cpu指令:

1.首先把count从内存中读取到cpu的寄存器中

2.在寄存器中执行+1操作

3.最后将count的值写入内存中(可能写入到cpu的缓存中)

而线程切换是可以发生在任意的一条cpu指令执行之后的,注意,这里说的是cpu的指令,而不是java语言中的指令,对于上面的三条指令来说,我们假设 count=0,如果线程 a 在指令 1 执行完后做线程切换,线程 a 和线程 b 按照下图的顺序执行,那么我们会发现两个线程都执行了 count++ 的操作,但是得到的结果不是我们期望的 2,而是 1。

Java 并发编程的可见性、有序性和原子性

这就是线程切换导致的数据错误问题,我们把一个或者多个操作在 cpu 执行的过程中不被中断的特性称为原子性,cpu 能保证的原子操作是 cpu 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

什么是有序性

有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“x=1;y=2;”编译器优化后可能变成“y=2;x=1;”。

在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候调整了语句的顺序可能导致意想不到的 bug。

在 java 领域一个经典的案例就是利用双重检查创建单例对象,代码如下:

假设有两个线程 a、b 同时调用 getinstance() 方法,他们会同时发现 instance == null ,于是同时对 singleton.class 加锁,此时 jvm 保证只有一个线程能够加锁成功(假设是线程 a),另外一个线程则会处于等待状态(假设是线程 b);线程 a 会创建一个 singleton 实例,之后释放锁,锁释放后,线程 b 被唤醒,线程 b 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 b 检查 instance == null 时会发现,已经创建过 singleton 实例了,所以线程 b 不会再创建一个 singleton 实例。

这个过程看上去是不是无懈可击,没有漏洞?

答案是否定的,问题就出在了new操作上,我们以为的new操作是这样的:

1.分配一块内存空间

2.在这块内存空间上初始化singleton实例对象

3.把这个对象的内存地址赋值给instance变量

但实际上由于指令重排,优化后的过程是这样的:

1.分配一块内存空间

2.把这快内存空间的内存地址赋值给instance变量

3.在这块内存空间上初始化singleton实例对象 

那么这样调换顺序后会发生什么呢?

我们假设线程 a 先执行 getinstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 b 上;如果此时线程 b 也执行 getinstance() 方法,那么线程 b 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。 

总结

使用并发编程开发,往往会出现很多难以找到原因的bug,通过对可见性、有序性和原子性的分析,可以为我们排查并发导致的bug提供一些思路。

cpu缓存会导致可见性

指令重排会导致有序性

线程切换会导致原子性

以上就是本篇文章的三个核心内容,那我们下篇文章继续。

以上就是java 并发编程的可见性、有序性和原子性的详细内容,更多关于java 并发编程的资料请关注其它相关文章!