前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Java 并发篇03 -序性、可见性、原子性。

Java 并发篇03 -序性、可见性、原子性。

作者头像
haoming1100
发布2019-07-30 15:53:59
4770
发布2019-07-30 15:53:59
举报
文章被收录于专栏:步履前行步履前行

首先,还是想给大家继续话痨话痨,脱离舒适区,努力坚持下去。我们只要超越百分之80 的人就够了。就像陈皓老师说的,你只看看中国的互联网,你就会发现,他们基本上全部都是在消费大众,让大众变得更为地愚蠢和傻瓜。所以,在今天的中国,你基本上不用做什么,只需要不使用中国互联网,你就很自然地超过大多数人了。 少看一些公众号,知乎,知识星球,微博,每天密切关注大佬,对我们个人的帮助太少了,毕竟所处环境不同。说句不好听的,天天看架构师怎么做怎么做,问题是我们是业务端的,你天天看做价格有啥用。要去找一个业务比你做的好,性能调优比你好的人去学,不要夸大层次去学。 少看头条,抖音,斗鱼,恶搞系列等视频,防止陷入时间黑洞。 少听八卦,职场见闻,社会热点,争议话题等,这些东西跟我们屁关系都没有,还天天跟一群 SB 撕逼破坏自己的心情。最近比特币又火了,那个某某钱包又开始收益了,少关心这些玩意,赚了还想继续,亏了天天心情不好,我要是那会怎么怎么做多好。 知识在深,不在广,不要天天的追求新技术,新知识,持久的才是需要学习的,你觉得进步的那是你的认知,不是知识。 最重要的,少 碎片化学习,你学来的那玩意都联系不起来,有啥用。记不住脑子里面的,建议大家多学读一读书。要系统化的去学习。 学会看文章标题,一些文章一看就是广告。 还有,技术要落地,不是你今天学个 dubbo、netty 学会了就会了,没有落地场景,你学的那玩意是应付面试的,不是学习的。

这篇文章,我们将给大家来讲解引起我们并发问题的三大因素--— 有序性、可见性、原子性。这三个问题是属于并发领域的所以并不涉及语言。

首先,我们来聊聊什么是安全性。

一个对象是不是线程安全的,取决于它是否被多个线程访问,如果是单线程,它处于同步状态对对象可变状态的访问,所以一定是线程安全的。如果在多线程情况下,没有协同对可变状态的访问,那么就是非安全。

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替指向,并且在主调用代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的 –《Java 并发编程实战》

通过上面的引用,我们知道了:

  • 单线程一定是线程安全的
  • 无状态对象一定是线程安全的

上面讲到的无状态对象是指 方法的计算过程只存在于线程栈上的局部变量中,并且只能有当前正在执行的线程访问。比如:

代码语言:javascript
复制
private Integer sum(int num) {
        return ++num;
}

原子性

说到原子性,可能大家的第一反应就是 i++ 和 ++i 了。

jvm 是如何执行 i++的,我们接着来看。

代码语言:javascript
复制
public class AtomicIntegerTest {
    public static void main(String[] args) {
        AtomicIntegerTest atomicIntegerTest = new AtomicIntegerTest();
        atomicIntegerTest.sum(10);
    }

    public int sum(int i) {
        i = i++;
        return i;
    }
}

然后我们通过 javap 看它的反编译后的文件 如下

代码语言:javascript
复制
Classfile /D:/Work/math-teaching/src/test/java/base/AtomicIntegerTest.class //Class文件当前所在位置
  Last modified 2019-7-2; size 384 bytes // 最后修改时间,文件大小
  MD5 checksum 48f1e270d21b6836df2a88c8545dd2fd  // md5 值
  Compiled from "AtomicIntegerTest.java" //编译自哪个文件
public class base.AtomicIntegerTest // 类的全限定名
  minor version: 0 // jdk次版本号
  major version: 52 // jdk主版本号。
  flags: ACC_PUBLIC, ACC_SUPER //为Public类型
Constant pool:
   #1 = Methodref          #5.#16         // java/lang/Object."<init>":()V
   #2 = Class              #17            // base/AtomicIntegerTest
   #3 = Methodref          #2.#16         // base/AtomicIntegerTest."<init>":()V
   #4 = Methodref          #2.#18         // base/AtomicIntegerTest.sum:(I)I
   #5 = Class              #19            // java/lang/Object
   #6 = Utf8               <init>
   #7 = Utf8               ()V
   #8 = Utf8               Code
   #9 = Utf8               LineNumberTable
  #10 = Utf8               main
  #11 = Utf8               ([Ljava/lang/String;)V
  #12 = Utf8               sum
  #13 = Utf8               (I)I
  #14 = Utf8               SourceFile
  #15 = Utf8               AtomicIntegerTest.java
  #16 = NameAndType        #6:#7          // "<init>":()V
  #17 = Utf8               base/AtomicIntegerTest
  #18 = NameAndType        #12:#13        // sum:(I)I
  #19 = Utf8               java/lang/Object
{
  public base.AtomicIntegerTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      // stack 最大操作数栈,JVM运行时会根据这个值来分配栈帧(Frame)中的操作栈深度,此处为1 
      // locals: 局部变量所需的存储空间,单位为Slot, Slot是虚拟机为局部变量分配内存时所使用的最小单位,为4个字节大小。
      //args_size: 方法参数的个数,这里是1,因为每个实例方法都会有一个隐藏参数this
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable: // 该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系
        line 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class base/AtomicIntegerTest
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: astore_1
         8: aload_1
         9: bipush        10
        11: invokevirtual #4                  // Method sum:(I)I
        14: pop
        15: return
      LineNumberTable:
        line 6: 0
        line 7: 8   
        line 8: 15

  public int sum(int);
    descriptor: (I)I
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=2, args_size=2
         0: iload_1   //将指定的int 型本地变量推送至栈顶
         1: iinc          1, 1  //将指定 int 型变量增加指定值(也就是我们的 ++ i)
         4: istore_1
         5: iload_1
         6: ireturn //从当前方法返回 int
      LineNumberTable:
        line 11: 0
        line 12: 5
}
SourceFile: "AtomicIntegerTest.java"

看完上诉的 class 文件,大家应该明白了, i = i ++ 这一操作 有 3 个步骤。

  • 把 i 变量读取到栈顶
  • 对 i 进行加法运算
  • 存入

上述情况在单线程下是 OK 的,但是在多线程下 一个简单的 i = i ++ 使用 3 条 CPU 指令,这三条指令由于操作系统 线程 时间分片的原因,多个系统进行执行的话就会带来意想不到的变故。

在操作系统由于 IO 阻塞等问题太慢的时候,出现了多线程,多线程的核心就是轮训 cpu 执行,而这个执行的时间就是 时间片(也就是我们通常说的 任务切换时间)

时间片又称为“量子(quantum)”或“处理器片(processor slice)”是分时操作系统分配给每个正在运行的进程微观上的一段CPU时间(在抢占内核中是:从进程开始运行直到被抢占的时间)。 — 维基百科

其实说白了就是在我们的现代操作系统中,允许同时运行多个进程,而这个同时是在我们用户的使用感知上,而在CPU中则是不停的在切换。毕竟因为时间片太短了。

时间片的分配由 OS 的调度程序分配给各个进程,当然进程中也有各个线程。然后内核就通过这个调度程序分配相当的初始时间片,然后每个 进程/线程 轮训去执行相应的时间,至于由哪个进程/线程去执行,则由调度程序通过线程优先级和调度算法去判断。当时间片执行完的时候,内核又会重新为每个进程去计算并分配新的时间片,如此反复。

所以我上边废话了一堆,其实就是一句话,因为多线程的缘故,一个Java 语法被分割成了 3 条 CPU 指令,单线程情况下,当然 OK。多线程情况下 ,因为都要抢占来执行 变量,所以就无法正确的执行。当然,如果多个线程都知道上一个线程处理的结果,是不是就正确执行下去了呢? -– 对的。就是我们下面讲到的可见性。

可见性

在计算机发展的过程中,因为我们的程序大部分都是要操作内存的,有些程序还要访问 IO,比如我们的 文件的读写,微服务的通信等,所以 CPU、内存等硬件设备一直在不停的升级调优,但是这里就遇到了个问题,设备之间的差异性。很正常的,电脑都有 SSD 和机械硬盘,同样的 CPU ,搭配不同的硬盘设备都有不同的体验效果,这个时候就是差异性了。比如 电脑是 16G 内存,那么就算你 CPU 也是顶配,但是硬盘是个普通的机械硬盘,那么你得程序处理性能依旧有限,毕竟IO读写变成了瓶颈。所以不能单方面的提高某一设备。

所以各位大佬为了提高性能,就想出了各种法子,然后我们编写多线程的就学的很恶心了:

  • CPU 增加 多 级缓存,提高内存速度
  • OS 增加多进程、多线程 以及多路复用等各种吊炸天技术
  • 编译器的指令重排序

之后的文章,涉及到的知识点都或多或少的讲解下,毕竟大家是来学习的,不是来当韭菜

图片来自CSDN

CPU 分为三级缓存。其中: 一级缓存 是内置在 CPU 内部和 CPU 以相同速度运行的,可以有效提高 CPU 的运行效率的。所以如果这个缓存足够大,我们程序的内存数据都放这里面,跟坐火箭一样快,但是想想就好了,因为这个缓存受到了 CPU 结构限制,一般都很小的。 二级缓存 该缓存位于 CPU 和内存之间的临时存储器中,容量比内存小,但是交换速度还是很快。二级缓存中的数据是内存数据中的一部分,是短时间内 CPU 马上要访问的数据。、 三级缓存 为了读取二级缓存未命中的数据设计缓存,就是二级缓存没有的数据在三级缓存中存放着(还有 5% 需要从内存读取)。原理是使用比较快的存储设备从慢存储设备中读取数据 copy 到当前,当有需要的时候再读取。和我们 Java 经常说的懒加载相似。 一级缓存在 CPU 中,需要 2~4 个时钟周期,二级缓存需要 10 个左右时钟周期,三级缓存则需要 30~40 个时钟周期。 今天的CPU将三级缓存全部集成到CPU芯片上。多核CPU通常为每个核配有独享的一级和二级缓存,以及各核之间共享的三级缓存。

再回到 我们的 Java 中,比如下面这段代码

代码语言:javascript
复制
public class AtomicIntegerTest {

    private static long num = 0;

    private void add() {
        for (int i = 0; i < 10000; i++) {
            num += 1;
        }
    }

    public static long calc() throws InterruptedException {
        final AtomicIntegerTest test = new AtomicIntegerTest();
        // 执行 add() 操作
        Thread th1 = new Thread(() -> test.add());
        Thread th2 = new Thread(() -> test.add());
        // 启动线程
        th1.start();
        th2.start();
        // 等待
        th1.join();
        th2.join();
        return num;
    }

    public static void main(String[] args) throws InterruptedException {
        calc();
        System.out.println(num);
    }
}

输出的结果却是 11412 、 12581。每次运行的结果还不相同。

其实说白了 就是 th1 和 th2 同时开始执行,首次的时候都是吧 num读到 缓存中(这里我们忽略三级缓存问题),执行完 num += 1 之后,各自的缓存中值都是1,同时写入内存,这个时候的内存还是1,而不是我们期望的 2。这就是缓存可见性的问题。

如果循环竞争的次数很小,比如 10次,那么结果可能还是对的,但是次数增大,那么结果差的就越大。

这里解释下,线程运行的时候,数据的计算是在 CPU 缓存中,而线程的数据 则是在 内存中,CPU缓存不在内存中,上面已经提到了,它是一块很小的芯片,至于什么时候把 CPU 中的数据写到缓存中,就要看 CPU 的心情了,所以 Java 中有一个volatile 声明,强制刷新到内存中,这个就是 volatile的写入屏障问题,也是所谓happen-before问题,我们在后面的时候会继续讲到。

有序性

在我们依赖的 jvm中,还有个更坑的问题,就是有序性,也就是指定重排序。程序为了优化性能,有的时候会把高级程序中的代码体进行重新排序,比如

代码语言:javascript
复制
 int a = 1;
 int b = 2;

编译后就变成了

代码语言:javascript
复制
int b = 2,;
int a = 1;

这种只是调整了语句,并不会改变程序的最终结果。

但是在我们的并发编程中,就出现了意想不到了。

首先大家要记住一点

不能通过 happens-before 原则推导的,JMM允许重排。

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

--— 深入理解 Java 虚拟机

那么重排序到底是什么呢?

在许多情况下,对程序变量(对象实例字段,类静态字段和数组元素)的访问可能看起来以与程序指定的顺序不同的顺序执行。编译器可以自由地使用优化名称中的指令顺序。处理器可能在某些情况下不按顺序执行指令。可以以不同于程序指定的顺序在寄存器,处理器高速缓存和主存储器之间移动数据。

例如,如果一个线程写入字段a然后写入字段b,并且b的值不依赖于a的值,则编译器可以自由地重新排序这些操作,并且缓存可以自由地将b刷新到main记忆之前的。有许多潜在的重新排序源,例如编译器,JIT和缓存

编译器,运行时和硬件应该合谋创建as-if-serial (如果串行执行)语义的假象,这意味着在单线程程序中,程序不应该能够观察重新排序的影响。

代码语言:javascript
复制
public class ReorderTest {

    int x = 0, y = 0;

    public void writer() {
        x = 1;
        y = 2;
    }

    public void reader() {
        int r1 = y;
        int r2 = x;
    }

    public static void main(String[] args) {
        ReorderTest reorderTest = new ReorderTest();
        Thread th1 = new Thread(() -> reorderTest.writer());
        Thread th2 = new Thread(() -> reorderTest.reader());
        System.out.println("x = " + reorderTest.x);
        System.out.println("y = " + reorderTest.y);

    }
}

上诉代码输出的是

代码语言:javascript
复制
x = 0
y = 0

比如 (臭名昭着的)双重检查锁定(也称为多线程单例模式)是一种旨在支持延迟初始化同时避免同步开销的技巧。

代码语言:javascript
复制
// double-checked-locking - don't do this!

private static Something instance = null;

public Something getInstance() {
  if (instance == null) {
    synchronized (this) {
      if (instance == null)
        instance = new Something();
    }
  }
  return instance;
}

假设有 两个线程 A 、线程 B 在同一时间调用 getInstance() ,那么首先会判断 instance 是否等于 null,假如此刻 是null 的话,开始竞争锁,如果 线程 A 竞争到锁了,那么进行初始化操作;执行完成后,唤醒线程 B。此时线程 B 获取到锁,然后继续判断 instance == null ,线程A 已经初始化过了,所以线程 B 此时会直接返回。理论上来说,是没有问题的。

其实问题就出现在了 new Something() 这个方法上。我们认为的过程应该是这样:

  • 给 Something 对象分配新的地址
  • 调用 Something 构造函数,初始化新对象的成员变量
  • 建立引用

但是可能实际上是这样:

  • 分配内存
  • 分配引用
  • 调用构造函数

还是继续上面,如果 线程 A 分配了内存,以及分配了引用,但是没有调用构造函数。此时线程B 进行 ,它看到 instance 不是null,就直接返回了,而此时并没有完成构造函数的调用。这里还有一个 happens-before 原则

happens-before

图片来自 www.logicbig.com

Happens-before定义程序中所有操作的部分排序。为了保证执行操作Y的线程可以看到操作X的结果(X和Y是否出现在不同的线程中),X和Y之间必然存在一个先发生的关系。在没有发生 - 之前排序的情况下在两个操作之间,JVM可以根据需要自由重新排序

Happens-before 之前发生的不仅仅是'时间'中的动作重新排序,而且还保证了对内存的读写顺序。执行写入和读取到内存的两个线程可以在 CPU 时钟时间方面与其他操作保持一致,但可能看不到彼此一致的更改(内存一致性错误),除非它们之前发生关系。

  • 单线程规则:单线程中的每个操作都发生在该程序顺序中稍后出现的该线程中的每个操作之前。
  • 监视器锁定规则:监视器锁定(退出同步方法/块)上的解锁发生 - 在每次后续获取同一监视器锁定之前。
  • 易失性变量规则:在对该相同字段的每次后续读取之前发生对易失性字段的写入。易失性字段的写入和读取具有与进入和退出监视器(读取和写入时的同步块)类似的内存一致性效果,但实际上没有获取监视器/锁定。
  • 线程启动规则:线程上的Thread.start()调用发生在启动线程中的每个操作之前。假设线程A通过调用threadA.start()生成一个新线程B. 在线程B的run方法中执行的所有操作都将看到线程A调用threadA.start()方法,之前(仅在线程A中)发生在它们之前。
  • 线程连接规则线程中的所有操作都发生在任何其他线程从该线程上的连接成功返回之前。假设线程A通过调用threadA.start()生成一个新线程B,然后调用threadA.join()。线程A将在join()调用时等待,直到线程B的run方法完成。在join方法返回后,线程A中的所有后续操作都将看到线程B的run方法中执行的所有操作都发生在它们之前。
  • 传递性:如果A发生在B之前,B发生在C之前,那么A发生在C之前。

The memory model describes possible behaviors of a program. An implementation is free to produce any code it likes, as long as all resulting executions of a program produce a result that can be predicted by the memory model. This provides a great deal of freedom for the implementor to perform a myriad of code transformations, including the reordering of actions and removal of unnecessary synchronization. --- Java语言规范 -基于 Java8

可以看出在规范中明确提出了,只要程序的所有结果可由内存模型预测出,那么实现者就可以自由地生成它喜欢的任何代码。包括重新排序动作和删除不必要的同步。而这也是Java 通过内存模型定义及其底层的关系。也是 一次编写,随从运行的核心。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2019-07-29 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 原子性
  • 可见性
  • 有序性
    • 那么重排序到底是什么呢?
    • happens-before
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档