Java多线程之线程安全问题详解

面试题:

  • 什么是线程安全和线程不安全?
  • 自增运算是不是线程安全的?如何保证多线程下 i++ 结果正确?

1. 什么是线程安全和线程不安全?

什么是线程安全呢?当多个线程并发访问某个Java对象时,无论系统如何调度这些线程,也无论这些线程将如何交替操作,这个对象都能表现出一致的、正确的行为,那么对这个对象的操作是线程安全的。

如果这个对象表现出不一致的、错误的行为,那么对这个对象的操作不是线程安全的,发生了线程的安全问题。

2. 自增运算为什么不是线程安全的?

线程安全实验:两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?具体的代码如下

public class ThreadDemo {
    private static int i = 0;
    public static void main(String[] args) throws InterruptedException {
        // 线程1对变量i做5000次自增运算
         Thread t1 = new Thread(()->{
             for(int j=0;j<5000;j++){
                 i++;
             }
         });
         Thread t2 = new Thread(()->{
             for(int j=0;j<5000;j++){
                 i--;
             }
         });
         t1.start();
         t2.start();
         // 主线程等待t1线程和t2线程执行结束再继续执行
         t1.join();
         t2.join();
        System.out.println(i);// 581 / -1830 / 0
    }
}

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析。

例如对于 i++ 而言,实际会产生如下的 JVM 字节码指令:

getstatic i  // 获取静态变量i的值
iconst_1     // 准备常量1
iadd         // 自增
putstatic i  // 将修改后的值存入静态变量i

而对应 i-- 也是类似:

getstatic i  // 获取静态变量i的值
iconst_1     // 准备常量1
isub         // 自减
putstatic i  // 将修改后的值存入静态变量

而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:

Java多线程之线程安全问题详解_第1张图片

如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:

Java多线程之线程安全问题详解_第2张图片

但多线程下这 8 行代码可能交错运行:

出现负数的情况:

Java多线程之线程安全问题详解_第3张图片

出现正数的情况:

Java多线程之线程安全问题详解_第4张图片

因此,一个自增运算符是一个复合操作,至少包括三个JVM指令:“内存取值”“寄存器增加1”和“存值到内存”。这三个指令在JVM内部是独立进行的,中间完全可能会出现多个线程并发进行。“内存取值”“寄存器增加1”和“存值到内存”这三个JVM指令本身是不可再分的,它们都具备原子性,是线程安全的,也叫原子操作。但是,两个或者两个以上的原子操作合在一起进行操作就不再具备原子性了。比如先读后写,就有可能在读之后,其实这个变量被修改了,出现读和写数据不一致的情况。

3. 临界区资源和竞态条件

在多个线程操作相同资源(如变量、数组或者对象)时就可能出现线程安全问题。一般来说,只在多个线程对这个资源进行写操作的时候才会出现问题,如果是简单的读操作,不改变资源的话,显然是不会出现问题的。

临界区资源表示一种可以被多个线程使用的公共资源或共享数据,但是每一次只能有一个线程使用它。一旦临界区资源被占用,想使用该资源的其他线程则必须等待。在并发情况下,临界区资源是受保护的对象。

临界区代码段是每个线程中访问临界资源的那段代码,多个线程必须互斥地对临界区资源进行访问。线程进入临界区代码段之前,必须在进入区申请资源,申请成功之后执行临界区代码段,执行完成之后释放资源。临界区代码段的进入和退出如图所示:

在这里插入图片描述

竞态条件可能是由于在访问临界区代码段时没有互斥地访问而导致的特殊情况。如果多个线程在临界区代码段的并发执行结果可能因为代码的执行顺序不同而不同,我们就说这时在临界区出现了竞态条件问题。

比如下面代码中的临界区资源和临界区代码段:

public class SafeDemo {
    // 临界区资源
    private static int i = 0;
    // 临界区代码段
    public void selfIncrement(){
        for(int j=0;j<5000;j++){
            i++;
        }
    }
    // 临界区代码段
    public void selfDecrement(){
        for(int j=0;j<5000;j++){
            i--;
        }
    }
	// 这个不是临界区代码,因为虽然使用了共享资源,但是这个方法并没有被多个线程同时访问
    public int getI(){
        return i;
    }
}
public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        SafeDemo safeDemo = new SafeDemo();
        Thread t1 = new Thread(()->{
            safeDemo.selfIncrement();
        });
        Thread t2 = new Thread(()->{
            safeDemo.selfDecrement();
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(safeDemo.getI());
    }
}

当多个线程访问临界区的selfIncrement()方法时,就会出现竞态条件的问题。更标准地说,当两个或多个线程竞争同一个资源时,对资源的访问顺序就变得非常关键。为了避免竞态条件的问题,我们必须保证临界区代码段操作具备排他性。这就意味着当一个线程进入临界区代码段执行时,其他线程不能进入临界区代码段执行。

总结:

(1) 一个程序运行多个线程本身是没有问题的,问题出在多个线程访问共享资源,多个线程读共享资源其实也没有问题,而在多个线程对共享资源读写操作时发生指令交错,就会出现问题 ;

(2) 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区代码块;

(3) 多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件;

在Java中,可以使用synchronized关键字,使用Lock显式锁实例,或者使用原子变量(AtomicVariables)对临界区代码段进行排他性保护。

本篇文章就到这里了,希望能够给你带来帮助,也希望您能够多多关注脚本之家的更多内容!      

你可能感兴趣的:(Java多线程之线程安全问题详解)