并发编程-多线程三大特性

工作中许多地方需要涉及到多线程的设计与开发,java多线程开发当中我们为了线程安全所做的任何操作其实都是围绕多线程的三个特性:原子性可见性有序性展开的。

1.原子性

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

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

比如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的账户就是你要操作的共享变量,那么不保证执行操作原子性的后果是十分严重的。

1.1.哪些是共享变量

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

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

1.2.哪些是原子操作

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

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

/**
 * @Auther: bruceliu
 * @Classname ThreadCount
 * @Date: 2020/2/21 18:11
 * @QQ交流群:750190373 (攻城狮大本营)
 * @Description: TODO
 */
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等来保证。
在这里插入图片描述

1.3.如何保证操作的原子性

使用较多的三种方式:

内置锁(同步关键字):synchronized;
显示锁:Lock;
自旋锁:CAS;

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

2.可见性

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

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,为什么循环还不会停止呢?这正是因为多线程之间可见性的问题。在单线程环境中,如果向某个变量写入某个值,在没有其他写入操作的影响下,那么你总能取到你写入的那个值。然而在多线程环境中,当你的读操作和写操作在不同的线程中执行时,情况就并非你想象的理所当然,也就是说不满足多线程之间的可见性,所以为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

3.Java线程内存模型

共享内存模型指的就是Java内存模型(简称JMM),JMM决定一个线程对共享变量的写入时,能对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
在这里插入图片描述
从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。 

在这里插入图片描述
如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

总结:什么是Java内存模型:java内存模型简称jmm,定义了一个线程对另一个线程可见。共享变量存放在主内存中,每个线程都有自己的本地内存,当多个线程同时访问一个数据的时候,可能本地内存没有及时刷新到主内存,所以就会发生线程安全问题。

我们来看下JMM(java线程内存模型):
在这里插入图片描述
JMM规定多线程之间的共享变量存储在主存中,每个线程单独拥有一个本地内存(逻辑概念),本地内存存储线程操作的共享变量副本;

  • JMM中的变量指的是线程共享变量(实例变量,static字段和数组元素),不包括线程私有变量(局部变量和方法参数);
  • JMM规定线程对变量的写操作都在自己的本地内存对副本进行,不能直接写主存中的对应变量;
  • 多线程间变量传递通过主存完成(Java线程通信通过共享内存),线程修改变量后通过本地内存写回主存,从主存读取变量,彼此不允许直接通信(本地内存私有原因);

综上,JMM通过控制主存和每个线程的本地内存的数据交互,保证一致的内存可见性;也就是说线程之间“变量的共享”都需要通过刷新主内存,其他线程读取来完成,而一旦无法保证这个动作完成,多个线程之间是无法及时获取共享变量的变化的。那么我们怎么知道什么时候工作内存的变量会刷写到主内存当中呢?这其实要基于java的happens-before原则(先行发生原则),这也也与多线程的有序性相关,后面阐述。

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

3.volatile

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

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

/**
 * @Auther: bruceliu
 * @Classname VolatileApp
 * @Date: 2020/2/21 18:16
 * @QQ交流群:750190373 (攻城狮大本营)
 * @Description: TODO
 */
public class VolatileApp {

    //volatile
    private volatile static boolean isOver = false;

    private volatile 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;
    }
}

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

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

Volatile与Synchronized区别
(1)从而我们可以看出volatile虽然具有可见性但是并不能保证原子性。
(2)性能方面,synchronized关键字是防止多个线程同时执行一段代码,就会影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized。但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。

4.有序性

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

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

一般来说处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。如下:

int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;     //语句4

则因为重排序,他还可能执行顺序为 2-1-3-4,1-3-2-4
但绝不可能 2-1-4-3,因为这打破了依赖关系。
显然重排序对单线程运行是不会有任何问题,而多线程就不一定了,所以我们在多线程编程时就得考虑这个问题了。

5.重排序

先说说什么是重排序,看下面这段代码。

int a = 1;
char b = ‘b’;
byte c = 2;

这三行代码很简单,就是简单的声明了3个变量。你可能会认为,这三行代码的执行顺序就如写代码的顺序一样,按a b c的顺序进行,实际上是不一定的。java语言是允许重排序的,也就是不按照abc的顺序执行,可能是cba,bac都有可能。前提是不改变执行结果,数据之间不存在依赖。
如果

int a = 1;
int b = a;

像上面这种b对a有数据依赖的,是不会被重排序的,执行顺序必然是a在b之前。

5.1.数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:
在这里插入图片描述
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

5.2.as-if-serial语义

as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

上面三个操作的数据依赖关系如下图所示:
在这里插入图片描述
如上图所示,A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。下图是该程序的两种执行顺序:
在这里插入图片描述
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

5.3.重排序发生在以下几个阶段

编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

5.4.重排序的意义

为什么会重排序,上面这几个阶段里大概提到了,提高并行效率,编译器认为重排序后执行更优,指令级重排序并行效率更好等等。在单个线程的视角看来,重排序不存在任何问题,重排序不改变执行结果,如下例:

int a = 1;
int b = 2;
int c = a + b;

c因为对a和b有数据依赖,因此c不会被重排序,但是a 、b的执行可能被重排序。但在单个线程下,这种重排序不存在任何问题,不论先初始化a、还是先初始化b,c的值都是3。但是在多线程情况下,重排序就可能带来问题,如下例:
线程T1执行:

a = 1; //共享变量 int a
b = true; //共享变量 boolean b

线程T2执行:

if (b){
	int c = a;
	System.out.println(c);
}

假如某个并发时刻,T2检测到b变量已经是true值了,并且变量都对T2可见。c 赋值得到的一定是1吗?

答案是不一定,原因就是重排序问题的存在,在多线程环境下,会造成问题。T1线程如果 a 和 b变量的赋值被重排序了,b先于 a发生,这个重排序对T1线程本身不存在什么问题,之前我们已经讨论过。但是在T2这个线程看来,这个执行就有问题了,因为在T2看来,如果没有重排序,b值变为true之前,a已经被赋值1了。而重排序使得这个推断变得不确定,b有可能先执行,a还没来的及执行,此时线程T2已经看到b变更,然后去获取a的值,自然不等于1。

5.5.happen-before原则

因为有以上重排序问题,会导致并发执行的问题,那么有没有方法解决呢?

happen-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()方法的开始;

第一条

单线程情况下,happen-before原则告诉你,你放心的认为代码写在前面的就是先执行就ok了,不会有任何问题的。(当然实际并非如此,因为有指令重排序嘛,有的虽然写在前面,但是未必先执行,但是单线程情况下,这并不会给实际造成任何问题,写在前面的代码造成的影响一定对后面的代码可见)

happen-before 有一种记法,hb(a,b) ,表示a比b先行发生。单线程情况下,写在前面的代码都比写在后面的代码先行发生

int a = 1int b= 2;

hb(a,b)
第二条
看如下代码
线程T1:

a = 1;
lock.unlock();
b = ture;

线程T2:

if (b){
	lock.lock();
	int c = a;
	System.out.println(c);
	lock.unlock();
}

此前在讲重排序的时候说过这个问题,说c有可能读取到的a值不一定是1。因为重排序,导致a的赋值语句可能没执行。但是现在在

b赋值之前加了解锁操作,线程T2在读取到b值变更后,做了加锁操作。这时候就是第二条原则生效的时候,它告诉我们,假如在时间上T1的lock.unlock()先执行了,T2 的lock.lock()后执行,那么T1 unlock之前的所有变更,a = 1这个变更,T2是一定可见的,即T2 在 lock后,c拿到的值一定是 a 被赋值1的值。

因为 a = 1 和 lock.unlock() 有 hb 关系 hb(a=1 , lock.unlock() )

第二条原则 hb(unlock, lock), 而 hb(lock , c = a ),因此c在被赋值a时,a=1一定会先行发生。

第三条
volatile关键字修饰的变量的写先行发生与对这个变量的读,如下
线程T1:

a = 1;
vv = 33;//volatile
b = ture;

线程T2:

if (b){
	int ff = vv;// vv is volatile
	int c = a;
	System.out.println(c);
}

与前面的锁原则一样,这次是volatile变量 写happen-before读。线程T2在读取a变量前先读取以下vv这个volatile变量。因为第三条原则的存在,只要T1在时间上执行了vv写操作,T2在执行vv读操作后,a=1的赋值一定可以被T2读到。

第四条、第五条、第六条

线程T1 start方法,先行发生于T1即将做的所有操作。
如,在某个线程中启动thread1

a = 1;
thread1.start();

如上,a =1 先行发生 thread.start(),而第四条规则又说,start方法先行发生该线程即将做的所有操作,那么a =1 ,也必将先行发生于 thread1 的任何操作。所以thread1启动后,是一定可以读取到a的值为1的

五、六条类似,线程终止前的所有操作先行发生于终止方法的返回。这就保障了一个线程结束后,其他线程一定能感知到线程所做的所有变更。

第七条
对象被垃圾回收调用finalize时,对象的构造一定已经先行发生。

第八条
传递性

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

发布了274 篇原创文章 · 获赞 80 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/BruceLiu_code/article/details/104431990