• 导致线程不安全的原因


    导致线程不安全的原因

    主要有三点:

    • 原子性:一个或者多个操作在 CPU 执行的过程中被中断
    • 可见性:一个线程对共享变量的修改,另外一个线程不能立刻看到
    • 有序性:程序执行的顺序没有按照代码的先后顺序执行

    一、原子性

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

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

    所以这两个操作必须要具备原子性才能保证不出现一些意外的问题。同样地反映到并发编程中会出现什么结果呢?举一个简单的例子:

    int i = 0;       //1int j = i ;      //2i++;         //3i = j + 1;   //4
    

    Java中的原子操作包括:

    1. 除long和double之外的基本类型的赋值操作
    2. 所有引用reference的赋值操作
    3. java.concurrent.Atomic.* 包中所有类的一切操作
    1在Java中,除long和double之外的基本类型的赋值操作都是原子性操作; 2中包含了两个操作:读取i,将i值赋值给j 3中包含了三个操作:读取i值、i + 1 、将+1结果赋值给i; 4中同三一样
    

    要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保。

    二、可见性

    可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

    举个简单的例子,看下面这段代码:

    //线程1执行的代码int i = 0;i = 10; //线程2执行的代码j = i;
    

    假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i = 10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

    在上面已经分析了,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。

    对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

    三、有序性

    即程序执行的顺序按照代码的先后顺序执行

    为什么程序执行的顺序会和代码的执行顺序不一致呢?java平台包括两种编译器:静态编译器(javac)和动态编译器(jit:just in time)。静态编译器是将.java文件编译成.class文件(二进制文件),之后便可以解释执行。动态编译器是将.class文件编译成机器码,之后再由jvm运行。问题一般会出现在动态编译器上,因为动态编译器为了程序的整体性能会进行指令重排序,虽然重排序可以提升程序的性能,但是重排序之后会导致源代码中指定的内存访问顺序与实际的执行顺序不一样。

    对于单线程,指令重排序会考虑指令之间的数据依赖性,从而保证程序最终结果会和代码顺序执行结果相同;但对于多线程就会出现线程不安全的问题。

    举个例子:

    int a = 10;    //语句1int r = 2;    //语句2a = a + 3;    //语句3r = a*a;     //语句4
    

    这段代码有4个语句,那么可能的一个执行顺序是:

    语句2 -> 语句1 -> 语句3 -> 语句4

    但不可能是这个执行顺序:

    语句2 -> 语句1 -> 语句4 -> 语句3。

    如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。可以看到虽然重排序不会影响单个线程内程序执行的结果。

    而对于多线程:

    //线程1:context = loadContext();   //语句1inited = true;             //语句2 //线程2:while(!inited ){  sleep()}doSomethingwithconfig(context);
    

    上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

    在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

    补充:happens-before原则(先行发生原则)

    程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。

    锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作。

    volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。

    传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。

    线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作。

    线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。

    线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。

    对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。


    要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

  • 相关阅读:
    用PHP实现 HTTP断点续传、分块下载文件(Socket)
    织梦cms 判断当前页面是否为首页和栏目页高亮
    Go Exec 僵尸与孤儿进程
    Nginx 配置 SSL,很系统!很完整!解决 Nginx 报错 nginx: [emerg] unknown directive "ssl" in /usr/local/nginx/conf/nginx.conf
    CentOS7安装及配置 Zabbix Server全步骤,超详细教程
    CentOS7安装及配置 Zabbix Agent全步骤,超详细教程
    top查看cpu消耗最高线程
    Kisso 使用笔记(个人总结,一直在更新)
    Android问题总汇
    关于Vue中checkbox复选框的双向数据绑定问题
  • 原文地址:https://www.cnblogs.com/athony/p/16004460.html
Copyright © 2020-2023  润新知