天天看点

【并发编程】(三)多线程带来的线程安全问题1.什么是线程安全问题2.用三个例子来说明线程安全问题3.总结

文章目录

  • 1.什么是线程安全问题
    • 1.1.并发编程的三要素
  • 2.用三个例子来说明线程安全问题
    • 2.1.可见性问题
      • 2.1.1.模拟可见性问题
      • 2.1.2.如何解决可见性问题
    • 2.2.原子性问题
      • 2.2.1.模拟原子性问题
      • 2.2.2.Synchronized
    • 2.3.有序性问题
  • 3.总结

1.什么是线程安全问题

线程安全问题指的是多个线程访问共享资源时,在缺少同步措施的情况下对共享资源进行了写操作而导致的执行结果与预期值不符的情况。

但如果多个线程只是共同读取共享资源,不进行写操作是不会有线程安全问题的。

共享资源:就是多个线程都可以共同访问的资源,例如:在JVM中,处于堆、方法区中的对象。

1.1.并发编程的三要素

也可以说是实现线程安全需要满足的条件,原子性、可见性、有序性。

  • 原子性:指一个操作不可分割,要么全部成功,要么全部失败。
  • 可见性:一个线程对共享变量的修改,对另外一个线程立即可见。
  • 有序性:程序执行顺序,按照代码的先后顺序执行。

三者任意一个出问题都会导致线程安全问题。

2.用三个例子来说明线程安全问题

2.1.可见性问题

2.1.1.模拟可见性问题

在上一篇《如何优雅的中断一个线程》1.2中提到使用中断标识来中断一个线程时,提到使用一个用volatile修饰的boolean字段来作为循环的条件,如果去掉这个volatile会发生什么事呢?代码如下:

public class VisibilityDemo implements Runnable{

    private static boolean stopFlag = false;

    @Override
    public void run() {
        System.out.println("开始循环");
        while (!stopFlag) {

        }
        System.out.println("停止循环");
    }

    public static void main(String[] args) throws InterruptedException {
        Thread volatileThread = new Thread(new VisibilityDemo());
        volatileThread.start();
        // 主线程睡一秒再修改标识,让上面volatileThread充分运行
        TimeUnit.SECONDS.sleep(1);
        stopFlag = true;
    }
}
           

预期的结果是打印出开始循环,然后停顿1秒后打印停止循环。但实际上打印出开始循环,等待1秒修改标识后,依然没有打印出停止循环,且线程处于活动状态。

这说明volatileThread并没有获取到main线程对stopFlag修改后的值。

2.1.2.如何解决可见性问题

加上volatile修饰:

会按照预期结果打印,开始循环,然后停顿1秒后打印停止循环。

这说明volatile可以解决内存的可见性问题。

2.2.原子性问题

2.2.1.模拟原子性问题

volatile解决了可见性问题后,一个线程对共享变量的修改对另一个线程立即可见,是不是就一定的线程安全了呢?

不如看下面的例子,亲自验证一下:

public class AtomicityDemo implements Runnable {

    private static volatile int value = 0;

    @Override
    public void run() {
        value++;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            Thread thread = new Thread(new AtomicityDemo());
            thread.start();
        }

        TimeUnit.SECONDS.sleep(1);
        System.out.println(value);
    }
}
           

多次执行这段代码得到的结果可能不一致,总的来说,会打印出一个<=1000的数字。这说明即使是使用volatile修饰,还是可能因为原子性的问题造成程序执行结果与预期值不符。

可以看到的是,线程中只有一行代码,value++,也就是说value++是个非原子操作。

如何证明value++没有原子性?

可以使用 javap -c 打出这个类的汇编码来看看value++都做了什么,大部分的汇编码省略了,下面只复制出run方法那一部分:

public void run();
    Code:
       0: getstatic     #2                  // Field value:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field value:I
       8: return
           
  • getstatic:获取指定类的静态域, 并将其压入栈顶
  • iconst_1:将int型1推送至栈顶
  • iadd:将栈顶两int型数值相加并将结果压入栈顶
  • putstatic:为指定类的静态域赋值

整个value++在编译后分成了上面4个步骤,证明了value++不具备原子性。

在《【JVM】(二)运行时数据区及内存模型》的2.1.2有类似的方法运行过程图示,将图示中的一个局部变量替换成静态变量就可以了。

为什么不具备原子性就会有线程安全问题呢?

举个例子,如果有两个线程同时getstatic获取了初始值0,再进行自增操作iadd,那么两个线程运行的结果都是1,都会将1赋值个静态变量。但是实际上正确的结果是2。

这就是具备可见性但不具备原子性导致线程安全的原因,即使线程A对共享变量的修改对线程B可见,但是线程B已经在线程A作修改的步骤中获取了静态变量的原始值,那运算结果也是错误的。

2.2.2.Synchronized

改造一下上面的代码,加入synchronized块。

@Override
public void run() {
	synchronized (AtomicityDemo.class) {
		value++;
	}
}
           

此时无论如何运行,结果都是1000,再看一下汇编码有什么变化:

public void run();
    Code:
      ……
       4: monitorenter
       5: getstatic     #3                  // Field value:I
       8: iconst_1
       9: iadd
      10: putstatic     #3                  // Field value:I
      13: aload_1
      14: monitorexit
      ……
           

在同一时间只有一个线程能进入monitorenter进行value++的操作,在前一个线程执行monitorexit后,下一个线程才有可能进入monitorenter,这两个指令的底层就是Java内存模型中定义的原子操作,lock和unlock。

也就是说,Synchronized同步代码块中的代码是原子性的代码,这一部分是串行执行的,在一个线程执行完这段指令码之前,没有另外的线程可以执行到这段指令码。

此外,Synchronized不仅仅可以保证原子性,也可以保证可见性和有序性。

在Synchronized块中涉及到的对共享变量的修改,会在unlock前同步到主内存中,这样的机制同样保证的可见性。

同样的,Synchronized每次只让一个线程进入同步块执行操作,可以保证在同步块中对共享变量的修改一定在下一个线程读取共享变量之前,也就是保证了有序性。

上面所说的Synchronized保证原子性、可见性、有序性的前提是共享变量只能出现在同步块中,下面2.3的有序性问题中提到单例的双检锁,就是共享变量出现在了同步块外需要注意的问题。

2.3.有序性问题

除了可见性和原子性之外,还必须保证有序性,才能保证线程安全。Java中的有序性问题涉及到编译器和CPU的指令重排序,在Java程序中很难复现出来。下面使用一个很常见的面试题来聊一聊有序性的问题。

先写一个简单的双检锁懒汉式单例:

public class DoubleCheckDemo {

    private volatile static DoubleCheckDemo instance;

    public static DoubleCheckDemo getInstance() {
        if (instance == null) {
            synchronized (DoubleCheckDemo.class) {
                if (instance == null) {
                    instance = new DoubleCheckDemo();
                }
            }
        }
        return instance;
    }
}
           

双检锁单例的目的简单提一下,就是为了保证在避免instance初始化多次的情况下,尽可能的提高并发访问的效率,但这不是本次研究的重点。

在做出这样的优化的同时,出现了另外一个问题——半初始化问题。

什么是半初始化问题?

对象在初始化的时候一共分为三步,按照执行顺序分别为:

① 分配对象的内存空间。

② 初始化对象。

③ 将instance变量指向对象所在的内存空间。

如果按照①②③的顺序,这个单例是没有问题的,但是经过指令重排优化,可能执行的顺序会变成①③②。

想象一下,假如现在有两个线程的A和B,线程A执行到 new DoubleCheckDemo()时,这个步骤发生了指令重排,在执行到①③指令的时候,线程B进入第一个if判断,此时instance已经指向了内存空间,那么instance == null的判断就是false,这时候会return instance。

那么业务代码中拿到的就是一个未初始化完成的instance对象,直接使用的时候肯定会出问题的。用一个更简单的理解就是,if判断的时候instance不为null,实际使用的时候instance为null。

执行图示如下:

【并发编程】(三)多线程带来的线程安全问题1.什么是线程安全问题2.用三个例子来说明线程安全问题3.总结

如何解决有序性问题?

造成这段代码出现有序性问题的原因是instance变量在Synchronized块外做了一次null判断,这个操作并不能保证有序性。

所以在instance变量前加一个volatile修饰,禁用指令重排序优化,保证有序性。

3.总结

① 造成线程安全问题的原因是程序代码不具备三个特性——原子性、可见性、有序性。

② 使用volatile修饰变量,能解决变量的可见性和有序性问题,Java中的原子性操作往往加上volatile修饰被操作的字段就能保证线程安全。

③ synchronized同步代码块(或同步方法)解决原子性问题,此外还可以解决可见性问题。