java多线程编程--原子性、可见性、有序性

多线程编程要保证的三个方面:

详情请查看:https://blog.csdn.net/qq_43470725/article/details/120457461
https://blog.csdn.net/duyabc/article/details/110946997
https://blog.csdn.net/u012715840/article/details/55804719

java程序最开始是从 启动jvm进程—> 启动java程序线程–>开始程序

  • 原子性;
  • 可见性;
  • 有序性;
    要理解这三个性质,我们需要先了解jvm:
    参考:jvm
    在这里插入图片描述

1、原子性

定义:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。原子性就像数据库里面的事务一样,他们是一个团队,同生共死。

作用:
一个很经典的例子就是银行账户转账问题:比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。

请分析以下哪些操作是原子性操作:

1、 x = 10; //语句1
2、y = x; //语句2
3、x++; //语句3
4、x = x + 1; //语句4

咋一看,有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。

在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

2、可见性

原理:
谈谈硬件架构模型
先谈谈硬件是如何工作的,举个例子,你在window操作系统上需要下载一个游戏(20M),就需要使用cpu和内存了,在这个过程中cpu负责计算,比如计算下载进度,统计下载完成一共需要多少时间等,内存为cpu提供数据的,负责保存游戏的所有信息,比如游戏的大小(20M)数据。在这个过程中,cpu从内存上取游戏大小这个数据,然后cpu去计算下载进度,把计算出的进度结果再写到内存,最终呈现到用户页面,大概对cpu和内存应该有个大概的认识了吧!看上去下载游戏这个过程分工明确,没有问题,但实际上cpu的计算速度比内存的存取速度高了不知道多少个数量级,这个过程cpu很空闲啊(如图一),cpu你闲着没事干那就是浪费资源浪费钱啊,这是个问题,于是人们就想了个办法,在内存上面加个(高速)缓存,如果是一些常用信息,比如游戏大小这个数据,那就不用在内存取了,直接在缓存上拿(如图二),而缓存设计的存取速度是很快的,当然价格也更高,如果刚好缓存上有这个游戏大小数据,这个操作在计算机的世界叫做缓存命中,这样就解决了cpu很闲的问题。哈哈,还是举个简单例子吧,咱春节买票回家,尽管你的手速很快,但是还是一票难求,12306官网响应速度慢,没办法家还是要回的,那就找黄牛,虽然价格贵但是能解决你的痛点。这个例子中你,12306系统,黄牛分别对应cpu,内存和缓存,方便你理解。顺便说下,这个黄牛其实也是设计模式中的代理。

案例分析JMM不可见性
JMM是根据硬件架构映射出来的,不是真实存在的,硬件模型是物理的,是真实存在的,如下图所示,**如果现在有两个线程AB需要同时将共享变量c的值加1,最终的程序运行的结果c的值可能是3,也可能是2。**那我们一起来看看程序执行过程吧,

程序初始化,线程AB将拷贝主内存的共享变量c到各自的工作内存,此时工作内存A,工作内存B的初始化值c值都为1,初始化结束,如下图所示。这里可以把线程A理解成cpu1,线程B理解成cpu2,工作内存理解成高速缓存。这个过程因为工作内存是线程私有的,因为每个高速缓存是属于不同CPU是不可见的,工作内存A看不见工作内存B的c值为1,相反工作内存B也看不到工作内存A的c值。

在这里插入图片描述

当线程AB同时将共享变量c加1时,如果线程A先获取时间片,此时工作内存A的c值加1等于2,然后由工作内存A将变量c=2同步到主内存,此时主内存c变量为2了,线程A执行结束,释放时间片给线程B,如下图所示。此时主内存会更新线程B的工作内存B,将c=2告诉线程B,并更新工作内存B的值c=2,此时B获取时间片,看到工作内存B值是c=2,加1,c=3,线程B将c=3写到主内存,此时主内存c的值就是3了,线程B执行结束,整个程序结束

其实在这个过程中,还有一种意外情况,如果线程A执行结束后,将主内存的c值变为2,如果主内存c=2还没有同步更新到工作内存B呢?此时问题就来了,线程B获取时间片后发现自己的工作内存变量c还是1,然后加1,此时c=2,将c再更新到主内存,此时主内存的值还是2,主内存再同步c=2的值给线程B已经失去意义了,因为线程全部执行完毕。在这个程序执行过程中,其实导致线程安全的本质问题是主内存通知不及时才导致发生的,这个案例中因为主内存不能及时将c=2的值更新到线程B的工作内存,导致线程B获取不到c已经更新为2了。

在这里插入图片描述
硬件缓存不一致方案:

1)总线Lock#锁。锁定总线的开销比较大,在缓存更新内存后,其他的cpu都会被锁定住,禁止与内存通信,这样开销就大了。

在这里插入图片描述

2)MESI协议。这是缓存一致性协议的具体实现,它通过嗅探技术识别哪个cpu想修改主内存缓存行信息,如果该缓存行是共享的,先将该缓存行刷新到主内存,再设置其他cpu的高速缓存的缓存行无效,但频繁的嗅探其他cpu想修改的共享数据,也会导致总线风暴。

刚才上面也说了硬件方面的方案,多线程对共享变量是不可见的,Java方面也提供了

两个关键字

来保证多线程情况下共享变量的可见性方案。

volatile实现可见性
在JVM手册中,当多线程写被volatile修饰的共享变量时,有两层语义。

1)该变量立即刷新到主内存。

2)使其他线程的共享变量立即失效。言外之意当其他线程需要的时候再从主内存取。

在上述案例中,如果c为一个布尔值并且被volatile修饰,那么当线程AB同时更新共享变量c时,此时c对于工作内存AB是可见的。

synchronized(Lock也能够保证可见性)实现可见性

在JVM手册中,synchronized可见性也有两层语义。

1)在线程加锁时,必须从主内存获取值。

2)在线程解锁时,必须把共享变量刷新到主内存。

这两句说明了,时刻保持主内存数据最新,当新的线程获取锁需要从主内存获取值。

**局部变量:**但是并不能保证局部变量(写在方法里面的变量)的可见性,因为局部变量是在栈里面,也就是在每个线程(底层就是不同的栈)里面都有一个自己的局部变量值

3、有序性

重排序对单线程没有影响,对多线程就会造成影响!!!

重排序
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,
    ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

在这里插入图片描述
**上述的1属于编译器重排序,2和3属于处理器重排序。**这些重排序都可能会导致多线程程序出现内存可见性问题。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
列表内容对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

重排序对多线程的影响

class ReorderExample {
    int a = 0;
    boolean flag = false;

    public void writer() {
        a = 1;                   //1
        flag = true;             //2
    }

    Public void reader() {
        if (flag) {              //3
            int i =  a * a;      //4
            ……
        }
    }
}

flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入?答案是:不一定能看到。
  
  由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。

操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还根本没有被线程A写入,在这里多线程程序的语义被重排序破坏了!

如果要防止重排序,需要使用volatile关键字,volatile关键字可以保证变量的操作是不会被重排序的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值