多线程环境下为什么会有线程不安全

目录

什么是线程安全

导致线程不安全的原因

一、原子性

二、可见性

三、有序性


我们知道进程是操作系统资源分配(包括cpu、内存、磁盘IO等)的最小单位,线程是CPU调度的最小单位

一个进程可以有多个线程,但至少有一个线程;而一个线程只能在一个进程的地址空间内活动。内存资源分配给进程,同一个进程的所有线程共享该进程所有资源。而CPU分配给线程,即真正在处理器运行的是线程。

  1. 单核cpu:实现多进程依靠于操作系统的进程调度算法,比如时间片轮转算法,比如有3个正在运行的程序(即三个进程),操作系统会让单核cpu轮流来运行这些进程,然后一个进程只运行2ms,这样看起来就像多个进程同时在运行,从而实现多进程。如果没有i/o阻塞,使用多进程将没有意义,因为上下文切换也需要时间。

  2. 多核cpu:多核cpu是一枚处理器中集成多个完整的计算引擎(内核)。

    多核cpu和单核cpu对于进程来说都是并发,并不是并行。

    但是多核cpu每一个核心都可以独立执行一个线程,所以多核cpu可以真正实现多线程的并行。比如四核可以把线程1234分配给核心1234,如果还有线程567就要等待cpu的调度。线程1234属于并行;如果一会核心1停止执行线程1改为执行线程5,那线程15属于并发。

什么是线程安全

线程安全是指在多线程环境下,每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的。

举一个线程不安全的例子

public class Test {
     private  static  int count;
    private static class Thread1 extends Thread {
        public void run() {
            for (int i = 0; i < 1000; i++) {
                count ++;
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread1  t1 = new Thread1();
        Thread1  t2 = new Thread1();
        t1.start();
        t2.start();
        //main主线程内调用join()方法:休眠主线程,等待t1、t2线程执行完毕主线程再继续,即最后输出count值
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

 这段代码实现的逻辑很简单,首先定义了一个int型的count变量,然后开启了两个线程,每个线程执行1000次循环,循环中对count进行加1操作。等待两个线程都执行完成后,打印count的值。

 那么这段代码的输出结果是多少呢?可能很多人会说是2000。但是程序运行后却发现结果大概率不是2000,而是一个比2000略小的数,比如1998这样,而且每次运行的结果可能都不相同。

那么这是为什么呢?这就是线程不安全。线程安全是指在多线程环境下,程序可以始终执行正确的行为,符合预期的逻辑。比如我们刚刚的程序,共两个线程,每个线程对count变量累加1000次,预期的逻辑是count被累加了2000次,而代码执行的结果却不是2000,所以它是线程不安全的。

为什么是不安全的呢?因为count++的指令在实际执行的过程中不是原子性的,而是要分为读、改、写三步来进行;即先从内存中读出count的值,然后执行+1操作,再将结果写回内存中,如下图所示。

这就是线程在计算机中真实的执行过程,看起来好像没问题啊,别急,再看一张图

看出来问题了么?上图中线程1执行了两次自加操作,而线程2执行了一次自加操作,但是count却从6变成了8,只加了2。

我们看一下为什么会出现这种情况。当线程1读取count的值为6完成后,此时切换到了线程2执行,线程2同样读取到了count的值为6,而后进行改和写操作,count的值变为了7;此时线程又切回了线程1,但是线程1中count的值依然是线程2修改前的6,这就是问题所在!即线程2修改了count的值,但是这种修改对线程1不可见,导致了程序出现了线程不安全的问题,没有符合我们预期的逻辑。

(此处对上述引用的例子存疑:上述代码运行的两个线程,他们是在两个核心里运行吗?如果是两个核心就不需要抢占cpu了吧,可是测试发现确实结果打印小于2000,说明确实进行了线程切换,那么为什么我的双核cpu执行两个线程还需要线程切换呢?)

导致线程不安全的原因

主要有三点:

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

一、原子性

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

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

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

int i = 0;       //1
int j = i ;      //2
i++;         //3
i = 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;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;     //语句4

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

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

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

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

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

而对于多线程:

//线程1:
context = loadContext();   //语句1
inited = 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()方法的开始。


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

为什么会有多线程?什么是线程安全?如何保证线程安全? - 知乎

http://ask.zol.com.cn/q/2867905.html

Java多线程编程基础三(原子性,可见性和有序性)_先说好不能骂我的博客-CSDN博客_原子性

  • 13
    点赞
  • 38
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值