多线程竞态及线程安全性

竞态

竞态:多线程编程中经常遇到一个问题就是对于同样的输入,程序输出有时候时正确的,有时候却是错误的。这种一个计算结果的正确性与时间有关的现象就被称为竞态。如下所示:

数字计数器:

public class NumGenerator {
    public final static NumGenerator Instance = new NumGenerator();

    private int num = 0;
    public int nexNum(){
        return num++;
    }

    public static NumGenerator getInstance(){
        return Instance;
    }
}

线程T1:

public class T1 extends Thread{
    NumGenerator num = NumGenerator.getInstance();
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "  " +  num.nexNum());
    }
}

竞态Demo:

public class Race {
    public static void main(String[] args) {
        T1[] t1 = new T1[8];
        for(int i = 0; i < 8; i++){
            t1[i] = new T1();
        }
        for (Thread ct : t1){
            ct.start();
        }
    }
}

如上代码所示:在数字生成器中新建一个用 final static 修饰的实例对象,它在编译期初始化并不能被改变。在下面用不同的线程对实例对象进行 +1 操作并打印出来,结果如下:

Thread-1  0
Thread-3  2
Thread-2  1
Thread-5  3
Thread-0  0
Thread-6  4
Thread-7  6
Thread-4  5

这里就出现了问题,既然是一个对象的 num 属性,我使用不同的线程对它进行加一操作打印它输出的也该是 01234567,就像一排人进行报数,按道理应该按顺序报数,怎么会报重呢,这就是竞态造成的结果
首先我们来分析一下 num++ 语句在计算机中执行的过程,它并不像我们表面所看到的那么简单,计算机要先读取这个数,然后对它进行加一操作,最后将结果返回给这个数。如下所示:

load(num, r1); ① 将 num 的值从内存读取到寄存器r1中
increment(r1); ② 将寄存器r1的值增加1
store(num, r1); ③ 将寄存器r1的值写入到变量num对应的内存空间

我们根据上述输出结果可以想象到线程 Thread-1 ,Thread-0,Thread-2的执行过程可能如下:

时间\线程 Thread-0 Thread-1 Thread-2
t1 执行其它操作 执行其它操作
t2 执行其它操作
t3 执行其它操作
t4 执行其它操作

如上表 Thread-0 先读取 num的值并在t2时刻进行更新。但是在t2时刻在线程1读取了num的值,这是线程0还没有将更新的值写入到变量空间,因此线程1读取的值为0。同理,在t4时刻线程0已经完成了更新,而线程1更新的值还正在写入(即使写入了,也会覆盖线程0更新的值,num的值还是1),因此线程2读取到的值2为1。

竞态产生的条件

竞态的两种模式:read-modify-write(读-改-写)和 check -then-act(检测而后行动)

read-modify-write(读-改-写) 操作可以分为一下三个步骤:读取变量值、根据该值进行计算、更新共享变量的值。如下:

load(num, r1); ① 将 num 的值从内存读取到寄存器r1中
increment(r1); ② 将寄存器r1的值增加1
store(num, r1); ③ 将寄存器r1的值写入到变量num对应的内存空间

一个线程在执行指令①之后到开始执行指令②的这段时间内其它线程可能已经更新可共享变量的值,这就使得该线程读取执行指令②使用的是共享变量的旧值(读脏数据)。

check -then-act(检测而后行动) 操作可以分为这样几个步骤:读取某个共享变量的值、根据改变量的值决定下一步的动作是什么,如下代码所示;

public int nextNum(){
	if(num >= 999){
		num = 0;
	} else {
		num++;
	}
}

一个线程在执行完子操作①到开始执行子操作②这段时间内,其它线程可能已经更新了共享变量的值而使得if语句的条件变得不成立,那么线程仍然会执行子操作②,尽管这个子操作所需要的前提实际上并未成立。

从上述示例可以看出: 竞态可以被看作访问(读取、更新)同一组共享变量的多个线程所执行的操作相互交错。

防止竞态

1、 使用局部变量:对于局部变量(包括形式参数和方法体内定义的变量),由于不同的线程各自访问的是各自的那一份局部变量,因此局部变量不会导致竞态。
2、 使用 synchronized 关键字

public synchronized int nextNum(){
	if(num >= 999){
		num = 0;
	} else {
		num++;
	}
}

关键字synchronized会使其修饰的方法在任一时刻内只能够被一个线程执行,这使得该方法涉及的共享变量在任一时刻只能够有一个线程访问(读,写),从而避免了这个方法的交错执行而导致的干扰,这样就消除了竞态。

线程安全性

线程安全性: 一般情况下,如果一个类在单线程环境下能够正常运行,并且在多线程环境下,在其使用放不必为其做任何改变的情况下也能正常运行,那么我们就称其为线程安全的。

线程安全问题概括来说表现为3个方面: 原子性、可见性、有序性

1、原子性

对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,相应的我们称该操作具有原子性。就像去银行取钱,取钱扣钱一气呵成,如果你只取钱银行卡没扣钱那就是诈骗,只扣钱没取出来那就是操作失误。

在java中有两种方式实现原子性,一种是使用锁(Lock)锁具有排他性,即它能够保障一个共享变量在任意一个时刻只能够被一个线程访问。这就排除了多个线程在同一时刻访问同一个共享变量而导致冲突的可能性,即消除了竞态。 另一种是利用处理器提供的处理器提供的专门的CAS指令,CAS与锁类似,但是是在硬件这一层次实现的,他可以被看作是硬件锁
在java语言中,long和double类型以外的任何类型的变量的写操作都是原子操作,即对基础类型的变量(short、int、float、char、boolean、byte)的写操作都是原子的。这是因为 java 中的long/double 型的变量会占用 64位的存储空间,而32位的java虚拟机对这种变量的写操作可能会被分解为两个步骤来实施,比如先写低32位,再写高32位。那么再多个线程共享一个这样的变量的时候可能会出翔一个线程在写高32位的时候,另一个线程在写低32位的场景。

使用volatile关键字修饰的共享变量就可以保障对该变量写操作的原子性。volatile关键字仅能保证写操作的原子性,它并不能保证其它操作(如 read-modify-write(读-改-写)和 check -then-act(检测而后行动)操作的原子性)。

2、可见性

在多线程的环境下,一个线程对某个变量进行更新之后,后续访问改变量的线程可能无法立刻读取到这个更新的结果,甚至永远也无法读取到这个更新的结果。这就是线程安全问题的另一个表现形式:可见性。

可见性问题与计算机的存储系统有关。程序中的变量可能会被分配到寄存器而不是主内存中进行存储,如下图所示:
在这里插入图片描述
每个处理器都有其寄存器,而一个处理器无法读取到另外一个处理器寄存器中的内容。因此,如果两个线程分别运行在不同的处理器上,而这两个线程所共享的变量却被分配到寄存器上进行存储,那么可见性问题就会产生。
另一方面,一个处理器上运行的变量更新可能只是更新到该处理器的写缓冲器中,还没达到该处理器的高速缓冲器中,更不用说主内存了,这也会导致不可见。

解决方法: 使用volatile 关键字修饰变量,在更新的时候进行 冲刷处理器缓存(使用一个处理器对共享变量所作的更新最终被写入到该处理器的高速缓存或者主内存中,而不是始终保存在其写缓冲器中) 和 刷新处理器缓存 (一个处理器在读取共享变量的时候,如果其它处理器在之前已经更新了该变量,那么该处理器的高速缓存器必须从其它处理器的高速缓存或者主内存中对相应的变量进行缓存同步)。

3、有序性

有序性是指在什么情况下一个处理器运行的一个线程所执行的内存访问操作在另一个处理器上运行的其它线程看来是乱序的。无序是因为发生了指令重排序。指令重排序是一种动作,它确确实实的对指令的顺序做出了调整,其重排序的对象是指令。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

解决方法: volatile 关键字、synchronized 关键字都能够实现有序性。 volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。synchronized 关键字保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。

4、可见性和有序性的区别

可见性是有序性的基础。
有序性影响可见性。由于重排序的作用,一个线程对共享变量的更新对于另一个线程而言可能变得不可见。

发布了44 篇原创文章 · 获赞 27 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_42784951/article/details/102890537