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

 更新时间:2020年11月17日 08:50:46   作者:H.U.C-王子  
这篇文章主要介绍了Java 并发编程的可见性、有序性和原子性的相关资料,帮助大家更好的理解和学习Java并发编程,感兴趣的朋友可以了解下。

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

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

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

并发编程的幕后

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

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

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

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

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

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

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

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

什么是可见性

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

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

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

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

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

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

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

什么是原子性

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

现在操作系统的任务切换一般指的是更轻量级的线程切换,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。

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

什么是有序性

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

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

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

public class Singleton {
 static Singleton instance;
 static Singleton getInstance(){
  if (instance == null) {
   synchronized(Singleton.class) {
    if (instance == null)
     instance = new Singleton();
    }
  }
  return instance;
 }
}

假设有两个线程 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 并发编程的资料请关注脚本之家其它相关文章!

相关文章

  • JAVA简单链接Oracle数据库 注册和登陆功能的实现代码

    JAVA简单链接Oracle数据库 注册和登陆功能的实现代码

    这篇文章主要介绍了JAVA链接Oracle并实现注册与登录功能的代码实例,有需要的朋友可以参考一下
    2014-01-01
  • 一文搞懂Spring中@Autowired和@Resource的区别

    一文搞懂Spring中@Autowired和@Resource的区别

    @Autowired 和 @Resource 都是 Spring/Spring Boot 项目中,用来进行依赖注入的注解。它们都提供了将依赖对象注入到当前对象的功能,但二者却有众多不同,并且这也是常见的面试题之一,所以我们今天就来盘它
    2022-08-08
  • SpringBoot中的文件上传与下载详解

    SpringBoot中的文件上传与下载详解

    这篇文章主要介绍了SpringBoot中的文件上传与下载详解,springboot是spring家族中的一个全新框架,用来简化spring程序的创建和开发过程,本文我们就一起来看看上传与下载的操作,需要的朋友可以参考下
    2023-08-08
  • 详解Spring 中 Bean 的生命周期

    详解Spring 中 Bean 的生命周期

    这篇文章主要介绍了Spring 中 Bean 的生命周期的相关资料,帮助大家更好的理解和使用spring框架,感兴趣的朋友可以了解下。
    2021-01-01
  • Springboot线程池并发处理数据优化方式

    Springboot线程池并发处理数据优化方式

    这篇文章主要介绍了Springboot线程池并发处理数据优化方式,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2021-12-12
  • SpringMVC中处理静态资源的过程详解

    SpringMVC中处理静态资源的过程详解

    本文给大家介绍SpringMVC中处理静态资源的过程,结合示例代码给大家讲解的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧
    2023-11-11
  • Java中的浮点数分析

    Java中的浮点数分析

    Java中的浮点数分析...
    2006-12-12
  • java前后端传值,参数有集合类型的数据时的两种操作方式

    java前后端传值,参数有集合类型的数据时的两种操作方式

    这篇文章主要介绍了java前后端传值,参数有集合类型的数据时的两种操作方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2023-11-11
  • Java中的concurrenthashmap集合详细剖析

    Java中的concurrenthashmap集合详细剖析

    这篇文章主要介绍了Java中的concurrenthashmap集合详细剖析,有参构造后第一次put时会进行初始化,由源码可知,会先判断所传入的容量是否>=最大容量的一半,如果满足条件,就会将容量修改为最大值,反之则会将容量改为所传入数*1.5+1,需要的朋友可以参考下
    2023-11-11
  • Java获取文件的路径及常见问题解决方案

    Java获取文件的路径及常见问题解决方案

    这篇文章主要介绍了Java获取文件的路径及常见问题解决方案,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2020-03-03

最新评论