从多线程的三个特性理解多线程开发

时间:2022-09-06 22:42:40

       工作中许多地方需要涉及到多线程的设计与开发,java多线程开发当中我们为了线程安全所做的任何操作其实都是围绕多线程的三个特性:原子性、可见性、有序性展开的。针对这三个特性的资料网上已经很多了,在这里我希望在站在便于理解的角度,用相对直观的方式阐述这三大特性,以及为什么要实现和满足三大特性。

一、原子性

原子性是指一个操作或者一系列操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。其实这句话就是在告诉你,如果有多个线程执行相同一段代码时,而你又能够预见到这多个线程相互之间会影响对方的执行结果,那么这段代码是不满足原子性的。结合到实际开发当中,如果代码中出现这种情况,大概率是你操作了共享变量。

针对这个情况网上有个很经典的例子,银行转账问题:

比如A和B同时向C转账10万元。如果转账操作不具有原子性,A在向C转账时,读取了C的余额为20万,然后加上转账的10万,计算出此时应该有30万,但还未来及将30万写回C的账户,此时B的转账请求过来了,B发现C的余额为20万,然后将其加10万并写回。然后A的转账操作继续——将30万写回C的余额。这种情况下C的最终余额为30万,而非预期的40万。 如果A和B两个转账操作是在不同的线程中执行,而C的账户就是你要操作的共享变量,那么不保证执行操作原子性的后果是十分严重的。

OK,上面的状况我们理清楚了,由此可以引申出下列三个问题

1、哪些是共享变量

从JVM内存模型的角度上讲,存储在堆内存上数据都是线程共享的,如实例化的对象、全局变量、数组等。存储在线程栈上的数据是线程独享的,如局部变量、操作栈、动态链接、方法出口等信息。

举个通俗的例子,如果你的执行方法相当于做菜,你可以认为每个线程都是一名厨师,方法执行时会在虚拟机栈中创建栈帧,相当于给每个厨师分配一个单独的厨房,做菜也就是执行方法的过程中需要很多资源,里面的锅碗瓢盆各种工具,就诸如你在方法内的局部变量是每个厨师独享的;但如果需要使用水电煤气等公共资源,就诸如全局变量一般是共享的,使用时需要保证线程安全。

2、哪些是原子操作

既然是要保证操作的原子性,如何判断我的操作是否符合原子性呢,一段代码肯定是不符合原子性的,因为它包含很多步操作。但如果只是一行代码呢,比如上面的银行转账的例子如果没有这么复杂,共享变量“C的账户”只是一个简单的count++操作呢?针对这个问题,首先我们要明确,看起来十分简单的一句代码,在JMM(java线程内存模型)中可能是需要多步操作的。

先来看一个经典的例子:使用程序实现一个计数器,期望得到的结果是1000,代码如下:

public class threadCount {
     public volatile static int count = 0; 
     public static void main( String[] args ) throws InterruptedException {
          ExecutorService threadpool = Executors.newFixedThreadPool(1000);
            for (int i = 0; i < 1000; i++) {
                threadpool.execute(new Runnable() {
                    @Override
                    public void run() {
                        count++;
                    }
                });
            }
            threadpool.shutdown();
            //保证提交的任务全部执行完毕
            threadpool.awaitTermination(10000, TimeUnit.SECONDS);
            System.out.println(count);
     }
}

运行程序你可以看到,输出的结果并不每次都是期望的1000,这正是因为count++不是原子操作,线程不安全导致的错误结果。

实际上count++包含2个操作,首先它先要去读取count的值,再将count的值写入工作内存,虽然读取count的值以及将count的值写入工作内存 2个操作都是原子性操作,但合起来就不是原子性操作了。

在JMM中定义了8中原子操作,如下图所示,原子性变量操作包括read、load、assign、use、store、write,其实你可以理解为只有JMM定义的一些最基本的操作是符合原子性的,如果需要对代码块实行原子性操作,则需要JMM提供的lock、unlock、synchronized等来保证。

从多线程的三个特性理解多线程开发

 

3、如何保证操作的原子性

使用较多的三种方式:

内置锁(同步关键字):synchronized;

显示锁:Lock;

自旋锁:CAS;

当然这三种实现方式和保证同步的机制上都有所不同,在这里我们不做深入的说明。

二、可见性

可见性是一种复杂的属性,因为可见性的错误通常比较隐蔽并且违反我们的直觉。

我们看下面这段代码

public class VolatileApp {
    //volatile
    private static boolean isOver = false;

    private static int number = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!isOver) {
                    //Thread.yield();
                }
                System.out.println(number);
            }
        });
        thread.start();
        Thread.sleep(1000);
        number = 50;
        isOver = true;
    }

}

如果你直接运行上面的代码,那么你永远也看不到number的输出的,线程将会无限的循环下去。你可能会有疑问代码当中明明已经把isOver设置为了false,为什么循环还不会停止呢?这正是因为多线程之间可见性的问题。在单线程环境中,如果向某个变量写入某个值,在没有其他写入操作的影响下,那么你总能取到你写入的那个值。然而在多线程环境中,当你的读操作和写操作在不同的线程中执行时,情况就并非你想象的理所当然,也就是说不满足多线程之间的可见性,所以为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

我们来看下JMM(java线程内存模型):

从多线程的三个特性理解多线程开发

JMM规定多线程之间的共享变量存储在主存中,每个线程单独拥有一个本地内存( 逻辑概念),本地内存存储线程操作的共享变量副本;
  • JMM中的变量指的是线程共享变量(实例变量,static字段和数组元素),不包括线程私有变量(局部变量和方法参数);
  • JMM规定线程对变量的写操作都在自己的本地内存对副本进行,不能直接写主存中的对应变量;
  • 多线程间变量传递通过主存完成(Java线程通信通过共享内存),线程修改变量后通过本地内存写回主存,从主存读取变量,彼此不允许直接通信(本地内存私有原因);
综上,JMM通过控制主存和每个线程的本地内存的数据交互,保证一致的内存可见性;也就是说线程之间“变量的共享”都需要通过刷新主内存,其他线程读取来完成,而一旦无法保证这个动作完成,多个线程之间是无法及时获取共享变量的变化的。那么我们怎么知道什么时候工作内存的变量会刷写到主内存当中呢?这其实要基于java的happens-before原则(先行发生原则),这也也与多线程的有序性相关,我们放到后面阐述。

volatile

保证线程之间可见性的手段有多种,在上面的代码中,我们就可以通过volatile修饰静态变量来保证线程的可见性。

你可以把volatile变量看作一种削弱的同步机制,它可以确保将变量的更新操作通知到其他线程;使用volatile保证可见性相比一般的同步机制更加轻量级,开销也相对更低。

其实这里还有另外一种情况,如果上面的代码中你撤销对Thread.yield()的注释,你会发现即便没有volatile的修饰两个静态变量 ,number也会正常打印输出了,乍一看你会以为可见性是没有问题的,其实不然,这是因为Thread.yield()的加入,使JVM帮助你完成了线程的可见性。

下面这段段话阐述的比较明确:

程序运行中,JVM会尽力保证内存的可见性,即便这个变量没有加同步关键字。换句话说,只要CPU有时间,JVM会尽力去保证变量值的更新。这种与volatile关键字的不同在于,volatile关键字会强制的保证线程的可见性。而不加这个关键字,JVM也会尽力去保证可见性,但是如果CPU一直有其他的事情在处理,它也没办法。也就是说Thread.yield()的加入,线程让出了一部分执行时间,使CPU从一直被while循环占用中占分配出了一些时间给JVM,这才能够保证线程的可见性。
所以说如果你不用volatile变量强制保证线程的可见性,虽然运行结果可能符合预期,也并不代表程序是线程安全的,你的程序会在有“隐患”的状态下运行,出现问题也不好排查与处理。

三、有序性

理解多线程的有序性其实是比较困难的,因为你很难直观的去观察到它。

有序性的本义是指程序在执行的时候,程序的代码执行顺序和语句的顺序是一致的。但是在Java内存模型中,是允许编译器和处理器对指令进行重排序的,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。也就是说在多线程中代码的执行顺序,不一定会与你直观上看到的代码编写的逻辑顺序一致。

下面我们举个简单的例子:

线程A:

context = loadContext(); //语句1
inited = true; //语句2

线程B:

while(!inited ){
 sleep
}
doSomethingwithconfig(context);

线程A中的代码中语句1与语句2之间没有必然的联系,所以线程A是会发生重排序问题的,也就是说语句2会在语句1之前执行,这必然会影响到线程B的执行(context没有实例化)。

其实指令的重排序之所以抽象难懂,因为它是一种较为底层的行为,是基于编译器对你代码进行深层优化的一种结果,结合上面的例子如果loadContext()中存在阻塞的话,优先执行语句2可以说是一种合理的行为。

四、happen-before规则

上面我们也提到了,多线程的可见性与有序性之间其实是有联系的,如果程序没有按你希望的顺序执行,那么可见性也就无从谈起。JMM(Java 线程内存模型) 中的 happen-before规则,该规则定义了 Java 多线程操作的有序性和可见性,防止了编译器重排序对程序结果的影响。

按照官方的说法:

当一个变量被多个线程读取并且至少被一个线程写入时,如果读操作和写操作没有happen-before关系,则会产生数据竞争问题。 要想保证操作 B 的线程看到操作 A 的结果(无论 A 和 B 是否在一个线程),那么在 A 和 B 之间必须满足 HB 原则,如果没有,将有可能导致重排序。 当缺少 happen-before关系时,就可能出现重排序问题。

简单来说可以理解为在JMM中,如果一个的线程执行的结果需要对另一个对另一个线程B可见,那么这两个线程A操作与线程B操作之间必须存在happens-before关系。happens-before规则如下:
1.程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
2.锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
3.volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
4.传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
5.线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
7.线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
8.对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

从上面的规则中我们可以看到,使用synchronized、volatile,加锁lock等方式一般及可以保证线程的可见性与有序性。

通过以上对多线程三大特性的总结,可以看出多线程开发中线程安全问题主要是基于原子性、可见性、有序性实现的,在这里我根据自己的理解进行了一下简单整理和阐述,自我感觉还是比较浅显的,如有不足之处还望指出与海涵。