首页 > 技术文章 > 线程知识(多线程的三大特性,内存模型,volatile,重排序)

ljl150 2020-03-10 17:32 原文

一,多线程的三大特性

  原子性,可见性,有序性。

  原子性是指在一次的操作或者多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行;

  可见性是指一个线程被多个线程共享,当其中一个线程对共享变量进行了修改,那么另外的线程可以立即看到修改后的最新值;

  有序性有两个方面的表现:

    1.在一个线程内观察,所有的操作都是有序的,所有的执行指令按照“串行”(as-if-serial)。

    2.在线程间观察,从一个线程观察另一个线程,则线程的执行是交替的,是正序的。

    程序代码在执行过程中的先后顺序,由于Java在编译器以及运行期间的优化,导致了代码的执行顺序未必就是开发者编写代码的顺序。一般来说,处理器为了提高程序的运行效率,可能会对输入的代码指令做一定的优化,它不会百分之百的保证代码的执行顺序严格按照编写代码中的顺序来进行,但是它会保证程序的最终运算结果是编码时所期望的值。在单线程情况下,无论怎样的重排序最终都会保证程序的执行结果和代码顺序执行的结果是一致的,但是在多线程的情况下,如果有序性得不到保证,那么很有可能出现问题。

  

 二,java内存模型

  在Java内存模型中,内存分为主内存和工作内存两个部分,其中主内存是所有线程所共享的(如堆中的存储的对象和基本数据类型),而工作内存则是每个线程分配一份,各线程的工作内存间彼此独立、互不可见,在线程启动的时候,虚拟机为每个线程分配一块工作内存,不仅包含了线程内部定义的局部变量,也包含了线程所需要使用的共享变量(主内存中的内容)的副本,即为了提高执行效率,读取副本比直接读取主内存更快,注意 线程不能直接操作主内存,只有先操作了工作内存之后才能写入主内存。

  首先我们思考一下一个java线程要向另外一个线程进行通信,应该怎么做,我们再把需求明确一点,一个java线程对一个变量的更新怎么通知到另外一个线程呢?我们知道java当中的实例对象、数组元素都放在主内存中,主内存是线程共享的。而每一个线程都有自己私有的内存空间(称为工作内存),如果线程1要向线程2通信,一定会经过类似的流程:

clip_image002

1、 线程1将自己工作内存中的X更新为1并刷新到主内存中;

2、 线程2从主内存读取变量X=1,更新到自己的工作内存中,从而线程2读取的X就是线程1更新后的值。

从上面的流程看出线程之间的通信都需要经过主内存,而主内存与工作内存的交互,则需要Java内存模型(JMM)来管理器。下图演示了JMM如何管理主内存和工作内存:

clip_image004

当线程1需要将一个更新后的变量值刷新到主内存中时,需要经过两个步骤:

  1、 工作内存执行store操作;

  2、 主内存执行write操作;

完成这两步即可将工作内存中的变量值刷新到主内存,即线程1工作内存和主内存的变量值保持一致;

当线程2需要从主内存中读取变量的最新值时,同样需要经过两个步骤:

  1、主内存执行read操作,将变量值从主内存中读取出来;

  2、工作内存执行load操作,将读取出来的变量值更新到本地内存的副本;

完成这两步,线程2的变量和主内存的变量值就保持一致了。

Java内存模型中对数据的操作存在8种:

 

 

  我们想象一下,由于工作内存这个中间层的出现,线程1和线程2必然存在延迟的问题,例如线程1在工作内存中更新了变量,但还没刷新到主内存,而此时线程2获取到的变量值就是未更新的变量值,又或者线程1成功将变量更新到主内存,但线程2依然使用自己工作内存中的变量值,同样会出问题。不管出现哪种情况都可能导致线程间的通信不能达到预期的目的。这时候可能许多人想到用synchronized或加锁来解决问题,但是这有点杀鸡用牛刀,所以更合理的解决方法就是使用volatile。那么接下来就介绍下volatile的用法。

 三,Volatile

   1,保证此变量对所有线程的可见性,但不能保证原子性。这里的可见性指的是,当线程1修改了某个变量的值时,volatile保证了新值能立即同步到主内存,且线程2工作内存中的这个变量会强制立即失效,这使得线程2在使用这个变量时,要先判断该变量的值是否已失效(即值是否已经与主内存中的值不一致),如果已失效,就必须去主内存中获取最新的变量值,才可以使用,所以对所有线程具有可见性。但普通变量做不到这点,普通变量从主内存(在堆中)load到本地内存(在当前线程的栈帧中),之后,线程就不再和主内存中该变量值有任何关系,而是直接操作在本地内存上的变量值(这样代码执行效率高),这时,如果主内存中或其他线程中的该变量值发生任何变化,都不会影响到这个线程的该变量值,也正是这个原因导致并发情况下出现数据不一致的问题。

  2,禁止指令重排序优化。有volatile修饰的变量,在汇编层代码上,会添加一个lock前缀的指令,也就是多执行一个"load addl $0x0,(%esp)"操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU允许将多条指令分开发送给各个相应电路单元除理)。

Volatile工作原理:

在volatile关键字所修饰的变量时,在汇编层代码上,会添加一个lock前缀的指令

Lock前缀指令相当于添加了一个内存屏障,内存屏障提供的功能:
1、它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

  对上面这段代码来说,意思就是语句3既不会放到1.2前面执行,也不会放到3.4后面执行,但是语句1与语句2之间是怎么执行的就不作保障了,语句3与语句4也同理。

2、它会强制将对缓存的修改操作立即写入主存;
3、如果是写操作,它会导致其他CPU中对应的缓存行无效。

解决内存一致性问题:

一个变量在多个CPU中存在缓存(一般多线程编程时才会出现),那么就可能存在缓存不一致的问题。

两种解决方案(都是在硬件上实现的):
第一种:通过总线加LocK锁前缀的方法锁定总线
第二种:通过缓存一致性协议(MESI协议)(缓存行 维护两个状态位 M,E,S,I)

 

 

 

 

volatile优化:

  volatile的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

 【代码演示】

 1 class ThreadV extends Thread{
 2     volatile static  boolean flag=true;
 3     @Override
 4     public void run() {
 5         while (flag){
 6            System.out.println("子线程");
 7         }
 8     }
 9 }
10 public class VolatileDemo {
11     public static void main(String[] args) {
12         new ThreadV().start();
13         try {
14             Thread.sleep(1000);
15         } catch (InterruptedException e) {
16             e.printStackTrace();
17         }
18         ThreadV.flag=false;
19     }
20 }

  当线程的run方法中为while(){};时,如果变量不加volatile修饰,run方法就会一直运行。如果加volatile修饰,运行一秒就会停止。

   当线程的run方法中为while(){System.out.println()};时,不管变量加不加volatile修饰,run方法运行一秒就会停止。

 

 1 public class ThreadVolatile {
 2     private volatile static boolean flag=true;
 3     public static void main(String[] args) {
 4         Thread threadA=new Thread(new Runnable() {
 5             @Override
 6             public void run() {
 7                 while(flag){
 8                     System.out.println("子线程");
 9                 }
10                 System.out.println("子线程结束");
11             }
12         });
13         Thread threadB=new Thread(new Runnable() {
14             @Override
15             public void run() {
16                 try {
17                     Thread.sleep(1000);
18                 } catch (InterruptedException e) {
19                     e.printStackTrace();
20                 }
21                 flag=false;
22                 System.out.println("一秒钟结束");
23             }
24         });
25         threadA.start();
26         threadB.start();
27     }
28 }

 

运行结果:

 

   只有在while中没有打印语句时,没有用volatile线程A会一直循环,但使用volatile后,就会跳出while。

  如果while中有打印语句,不管有没有使用volatile,线程A都会跳出while循环。

 

四、 谈谈volatile和synchronized两者的区别

  volatile:可见性。禁止重排序。

  synchronized:原子性,可见性,可重排序,会阻塞。

  从使用上来看

  1) volatile关键字是变量修饰符,只能用于修饰实例变量或者类变量,不能用于修饰方法以及方法参数、局部变量、常量等;

  2) synchronized关键字不能用于对变量的修饰,只能用于修饰方法或者语句块;

  3) volatile修饰的变量可以为null,synchronized关键字同步语句块的monitor对象不能为null;

  对原子性的保证

  1) volatile无法保证原子性;(因此volatile无法替代synchronized)

  2) 由于synchronized是一种排他的机制,因此被synchronized关键字修饰的同步代码是无法被中途打断的,因此其能够保证代码的原子性;

  对可见性的保证

    两者均可以保证共享资源在多线程间的可见性,但是实现机制完全不同

  1) Synchronized借助于JVM指令monitor enter和monitor exit对通过排他的方式使得同步代码串行化,在monitor exit时所有共享资源都将会被刷新到主内存中;(因为每次只允许一个线程进行操作)

  2) 相比于synchronized关键字,volatile使用机器指令“lock;”的方式迫使其他线程工作内存中的数据失效,不 得到主内存中进行再次加载;

  对有序性的保证

  1) volatile关键字禁止JVM编译器以及处理器对其进行重排序,所有它能够保证有序性;

  2) 虽然synchronized关键字所修饰的同步方法也可以保证顺序性,但是这种顺序性是以程序的串行化执行换来的,在synchronized关键字所修饰的代码块中代码指令也会发生指令重排序的情况,但是由于synchronized关键字同步的作用,所以对程序来说没有任何的影响;

  其他

  1) volatile不需要加锁,比synchronized更轻量级,而且不会使得线程陷入阻塞,synchronized关键字会使得线程进入阻塞状态;

  2) volatile标记的变量不会被编译器优化,而synchronized标记的变量可以被编译器优化(如编译器重排序的优化)

注意:volatile本质是在告诉jvm当前变量在寄存器中的值是不确定的,使用前,需要先从主存中读取,因此可以实现可见性。而对于n=m,n=n+1,n++等操作时,volatile关键字将会失效,不能起到像synchronized一样的线程同步(原子性)的效果。因为n++不是个原子性的操作,而是个复合操作,我们可以简单将这个操作理解为由这三步组成:1.读取2.加一3.赋值。也就是说,这三个子操作可能会割开执行:假如volatile修饰的变量n原本为10,现有线程A和线程B两个线程同时进行n++操作,线程A先对变量n操作,在取出变量后,由于某因素线程A被阻塞,这时线程B又对变量n进行操作,取出变量,加一,然后将变量立即写回主内存中,由于变量n是被volatile修饰的,所以线程A的副本中的变量n的值就会变无效,但是要注意的是,线程A在进行n++操作,在取出变量时,会将变量n赋给另一个临时变量来存储(设为tmp),tmp属于工作内存的局部变量表的,所以在变量n变无效的时候,tmp仍然是有效的。这时候线程A就会接着被阻塞之前的操作继续进行,加一,写入主内存。由于线程B在线程A取出数据后,已经对变量加一过一次,所以此时线程A再将操作完的数据写回主内存就会出现问题。现在用下面代码演示下:

【代码演示】:复合操作。

 1 import java.util.concurrent.CountDownLatch;
 3 class ThreadV {
 4      private CountDownLatch countDownLatch=new CountDownLatch(10);//设置需要等待的线程数
 5      volatile int num = 0;
 6     public void collect() {
 7         for (int i = 0; i <1000; i++) {
 8             num++;
 9         }
10         countDownLatch.countDown();
11     }
12     public void getNum(){
13         try {
14             countDownLatch.await();
15         } catch (InterruptedException e) {
16             e.printStackTrace();
17         }
18         System.out.println(num);
19     }
20 }
21 public class VolatileDemo {
22     public static void main(String[] args) throws InterruptedException {
23         ThreadV threadV=new ThreadV();
24         for (int i = 0; i <10; i++) {//创建10个线程来加数
25             new Thread(new Runnable() {
26                 @Override
27                 public void run() {
28                     threadV.collect();
29                 }
30             }).start();
31         }
32         new Thread(new Runnable() {//创建一个线程来获取num值
33             @Override
34             public void run() {
35                 threadV.getNum();//等待所有线程运行完,也就是countDown()减为0,返回num的值
36             }
37         }).start();
38     }
39 }

运行结果:

   可以发现运行结果与预期不符。虽然使用了volatile,但结果还是不对。所以针对num++这类复合类的操作,volatile就会失效,然而我们可以使用java并发包中的原子操作类,原子操作类是通过循环CAS的方式来保证其原子性的。

 1 import java.util.concurrent.CountDownLatch;
 2 import java.util.concurrent.atomic.AtomicInteger;
 3 class ThreadV {
 4      private CountDownLatch countDownLatch=new CountDownLatch(10);//设置需要等待的线程数
 5      static AtomicInteger num = new AtomicInteger(0);
 6     public void collect() {
 7         for (int i = 0; i <1000; i++) {
 8             num.incrementAndGet();//原子性的num++,通过循环CAS方式
 9         }
10         countDownLatch.countDown();
11     }
12     public void getNum(){
13         try {
14             countDownLatch.await();
15         } catch (InterruptedException e) {
16             e.printStackTrace();
17         }
18         System.out.println(num);
19     }
20 }
21 public class VolatileDemo {
22     public static void main(String[] args) throws InterruptedException {
23         ThreadV threadV=new ThreadV();
24         for (int i = 0; i <10; i++) {//创建10个线程来加数
25             new Thread(new Runnable() {
26                 @Override
27                 public void run() {
28                     threadV.collect();
29                 }
30             }).start();
31         }
32         new Thread(new Runnable() {//创建一个线程来获取num值
33             @Override
34             public void run() {
35                 threadV.getNum();//等待所有线程运行完,也就是countDown()减为0,返回num的值
36             }
37         }).start();
38     }
39 }

运行结果: 

 

volatile的应用场景:

1、一般用来修饰Boolean类型的共享状态标志位
2、单例模式下的双重校验锁
3、修饰单个的变量
修饰变量,
vaoltile String str = new String(“Hello”);
volatile修饰对象的引用时,只对引用地址能够实时感知变化,无法保证对象的可见性
注意点:
volatile对于基本数据类型(值直接从主内存向工作内存copy)才有用。但是对于对象来说,似乎没有用,因为volatile只是保证对象引用的可见性,而对对象内部的字段,它保证不了任何事。即便是在使用ThreadLocal时,每个线程都有一份变量副本,这些副本本身也是存储在堆中的,线程栈桢中保存的仍然是基本数据类型和变量副本的引用。所以,千万不要指望有了volatile修饰对象,对象就会像基本数据类型一样整体呈现原子性的工作了。事实上,如果一个对象被volatile修饰,那么就表示它的引用具有了可见性。从而使得对于变量引用的任何变更,都在线程间可见。

六,重排序(多线程情况下)

  在虚拟机层面,为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,虚拟机会按照自己的一些规则(这规则后面再叙述)将程序编写顺序打乱——即写在后面的代码在时间顺序上可能会先执行,而写在前面的代码会后执行——以尽可能充分地利用CPU。拿上面的例子来说:假如不是a=1的操作,而是a=new byte[1024*1024](分配1M空间),那么它会运行地很慢,此时CPU是等待其执行结束呢,还是先执行下面那句flag=true呢?显然,先执行flag=true可以提前使用CPU,加快整体效率,当然这样的前提是不会产生错误(什么样的错误后面再说)。虽然这里有两种情况:一种是后面的代码先于前面的代码开始执行;另一种前面的代码先开始执行,但当效率较慢的时候,后面的代码开始执行并先于前面的代码执行结束。不管谁先开始,总之后面的代码在一些情况下存在先结束的可能。  

  虽然java的虚拟机和处理机会进行重排序,但它不会乱进行排序,而是遵循一定的规则,在java和CPU、内存之间都有一套严格的指令重排序规则,哪些可以重排,哪些不能重排都有规矩的。下列流程演示了一个java程序从编译到执行会经历哪些重排序:

 

       clip_image008

 

  在这个流程中第一步属于编译器重排查,编译器重排序会按JMM的规范严格进行,换言之编译器重排序一般不会对程序的正确逻辑造成影响。第二、三步属于处理器重排序,处理器重排序JMM就不好管了,怎么办呢?它会要求java编译器在生成指令时加入内存屏障,内存屏障是什么?你可以理解为一个不透风的保护罩,把不能重排序的java指令保护起来,那么处理器在遇到内存屏障保护的指令时就不会对它进行重排序了。关于在哪些地方该加入内存屏障,内存屏障有哪些种类,各有什么作用,这些知识点这里就不再阐述了。可以参考JVM规范相关资料。

 

  CPU会对代码执行实现优化,进行重排序操作,但不会对有数据依赖性关系的代码做重排序。重排序过的代码执行顺序可能会发生改变,但是执行结果不会发生任何改变。

  1,什么是数据依赖性?

  如:int  a=3;

     int  b=2;

     int  c=a+b;

  这里可以认为a和b是没有相互依赖关系的,因为不论a的代码在前还是b的代码在前,都不会影响代码最后的运行结果。而a和c,或者b和c具有依赖关系,具有依赖性的代码不会被重排序。

    如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

  数据依赖性分为三种类型:

    写后读:    a=1;b=a;

    写后写: a=1;a=2;

    读后写: a=b;b=1;

  上面这三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会改变。  

  前面提到过,编译器和处理机可能会对操作做重排序。编译器和处理机在重排序时,会遵守数据依赖性,编译器和处理机不会改变存在数据依赖关系的两个操作的执行顺序。

  注意:这里所说的数据依赖性仅针对单个处理机中执行的指令序列和单个线程中执行的操作,不同处理机之间和不同线程之间的数据依赖性不被编译器和处理机考虑。

 1 public class Resort {
 2     private static int a=1;
 3     private static boolean flag=false;
 4     static class ThreadA extends Thread{
 5         @Override
 6         public void run() {//写操作
 7             a=2;
 8             flag=true;
 9             //由于上面两个代码不具有数据依赖性,所以在CPU进行优化时,可能代码执行顺序会发生变化。
10         }
11     }
12     static class ThreadB extends Thread{
13         @Override
14         public void run() {//读操作
15             if (flag)
16             System.out.println(a);
17         }
18     }
19     public static void main(String[] args) {
20         for (int i = 0; i <10000 ; i++) {//
21             new ThreadA().start();
22             new ThreadB().start();
23         }
24     }
25 }

   在没有重排序的情况下,只有线程A在执行完run方法,将flag变量的值改为true时,线程B的run方法才能打印出结果,由于a变量是在flag前面进行赋值的,所以线程B打印出来的结果是2。

  由于线程A的run方法中的两个代码没有数据依赖性,所以这两个代码可能会发生重排序,如果发生了重排序,线程A就会先执行flag=true,在执行a=2;所以在线程B的run方法能打印出结果时,a还未被赋值2,所以打印出来的结果就是1。由于多线程情况下未必会试出重排序的结论,所以多试一些。

  为了禁止重排序,可以在变量前加volatile进行修饰。

1 private volatile static int a=1;
2 private volatile static boolean flag=false;

 

还可以看看这篇文章,真心感觉挺不错的:https://www.cnblogs.com/chengxiao/p/6528109.html

推荐阅读