zoukankan      html  css  js  c++  java
  • JAVA多线程之并发编程三大核心问题

    概述

    并发编程是Java语言的重要特性之一,它能使复杂的代码变得更简单,从而极大的简化复杂系统的开发。并发编程可以充分发挥多处理器系统的强大计算能力,随着处理器数量的持续增长,如何高效的并发变得越来越重要。但是开发难,并发更难,因为并发程序极易出现bug,这些bug是比较诡异的,跟踪难,且难以复现。如果要解决这些问题就要正确的发现这些问题,这就需要弄清并发编程的本质,以及并发编程要解决什么问题。本文主要讲解并发要解决的三大问题:原子性、可见性、有序性。

    基本概念

    硬件的发展

    硬件的发展中,一直存在一个矛盾,CPU、内存、I/O设备的速度差异。

    速度排序:CPU >> 内存 >> I/O设备

    为了平衡这三者的速度差异,做了如下优化:

    1. CPU 增加了缓存,以均衡内存与CPU的速度差异;

    2. 操作系统增加了进程、线程,以分时复用CPU,进而均衡I/O设备与CPU的速度差异;

    3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

     优化之后,速度和性能的提升也伴随着开发所带来的各种新问题,比如多线程利用多个cpu问题。

    并发和并行

    〔美〕布雷谢斯的书籍并发的艺术一书中的引述是:

    如果某个系统支持两个或者多个动作(Action)同时存在,那么这个系统就是一个并发系统。如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统。并发系统与并行系统这两个定义之间的关键差异在于“存在”这个词。

    在并发程序中可以同时拥有两个或者多个线程。这意味着,如果程序在单核处理器上运行,那么这两个线程将交替地换入或者换出内存。这些线程是同时“存在”的——每个线程都处于执行过程中的某个状态。如果程序能够并行执行,那么就一定是运行在多核处理器上。此时,程序中的每个线程都将分配到一个独立的处理器核上,因此可以同时运行。
    我相信你已经能够得出结论——“并行”概念是“并发”概念的一个子集。也就是说,你可以编写一个拥有多个线程或者进程的并发程序,但如果没有多核处理器来执行这个程序,那么就不能以并行方式来运行代码。因此,凡是在求解单个问题时涉及多个执行流程的编程模式或者执行行为,都属于并发编程的范畴。
     

    重排序概念

    在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。
    从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序。
     

     1,编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

    2,指令级并行的重排序:处理器将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

    3,内存系统的重排序:处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去可能实在乱序执行。

    重排序需要遵守一定的规则:

    重排序遵守数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这2个操作就存在数据依赖性。存在数据依赖性的操作,不可以重排序。数据依赖性只是针对单个处理器中执行的指令序列和单个线程中执行的操作。

    重排序遵守as-if-serial操作:就是说不管怎么重排序,单线程程序的执行结果都不会改变。

    但是重排序也会带来一些问题,导致多线程程序出现可见性和有序性的问题。下面我在一一描述。

    JAVA内存模型(JMM)

    Java内存模型(Java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。

    这里的变量指的是:共享变量

    1、所有的变量都存储在主内存中

    2、每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)

    X 变量就是共享变量:

    happens-before原则

    一提到happens-before原则,就让人有点“丈二和尚摸不着头脑”。这个涵盖了整个JMM中可见性原则的规则,究竟如何理解,把我个人一些理解记录下来。两个操作间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行。happens-before仅仅要求前一个操作对后一个操作可见。

    happens-before部分规则如下:

    1、程序顺序规则:一个线程中的每个操作happens-before于该线程中的任意后续操作

    2、监视器锁(同步)规则:对于一个监视器的解锁,happens-before于随后对这个监视器的加锁

    注1:为什么是部分happens-before原则,因为这篇文章是让你理解happens-before原则,我会尽量让你专注在这件事情上不被其他的所影响

    注2:程序顺序规则中所说的每个操作happens-before于该线程中的任意后续操作并不是说前一个操作必须要在后一个操作之前执行,而是指前一个操作的执行结果必须对后一个操作可见,如果不满足这个要求那就不允许这两个操作进行重排序。

    举例:

    1
    2
    3
    4
    5
    6
    7
    8
    public double rectangleArea(double length , double width){
    double leng;
    double wid;
    leng=length;//A
    wid=width;//B
    double area=leng*wid;//C
    return area;
    }

    在程序中

    A  happens-before  B

    B  happens-before C

    A  happens-before C //happens-before具有传递规则

    根据happens-before规则我们来分析重排序后可能产生的结果

    因为A  happens-before  B,所以A操作产生的结果leng一定要对B操作可见,但是现在B操作并没有用到length,所以这两个操作可以重排序,那A操作是否可以和C操作重排序呢,如果A操作和C操作进行了重排序,因为leng没有被赋值,所以leng=0,area=0*wid也就是area=0;这个结果显然是错误的,所以A操作是不能和C操作进行重排序的(这就是注2中说的前一个操作的执行结果必须对后羿操作可见,如果不满足这个要求就不允许这两个操作进行重排序)。

    可见性

    简而言之:一个线程对共享变量的修改,另一个线程能够立刻看到,我们称之为可见性。

    为什么会用可见性?

    对于如今的多核处理器,每个cpu都有自己的缓存,而缓存仅仅对他所在的处理器可见,CPU缓存与内存的数据不容易保持一致。为了避免处理器停顿下来等待向内存写入数据而产生的延迟,处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区合并对同一内存地址的多次写,并以批处理的方式刷新,也就是说写缓冲区不会即时将数据刷新到主内存中。缓存不能及时刷新导致可见性问题。

    举例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public class Test {
    public int a = 0;
     
    public void increase() {
            a++;
        }
     
    public static void main(String[] args) {
    final Test test = new Test();
    for (int i = 0; i < 10; i++) {
    new Thread() {
    public void run() {
    for (int j = 0; j < 1000; j++)
                            test.increase();
                    };
                }.start();
            }
     
    while (Thread.activeCount() > 1) {
    // 保证前面的线程都执行完
                Thread.yield();
            }
            System.out.println(test.a);
        }
    }

      

    目的:10个线程将inc加到10000。

    结果:每次运行,得到的结果都小于10000。

    原因分析:

    假设线程1和线程2同时开始执行,那么第一次都会将a=0 读到各自的CPU缓存里,线程1执行a++之后a=1,但是此时线程2是看不到线程1中a的值的,所以线程2里a=0,执行a++后a=1。

    线程1和线程2各自CPU缓存里的值都是1,之后线程1和线程2都会将自己缓存中的a=1写入内存,导致内存中a=1,而不是我们期望的2。所以导致最终 a 的值都是小于 10000 的。这就是缓存的可见性问题。

     要实现共享变量的可见性,必须保证两点:

    1、线程修改后的共享变量值能够及时从工作内存刷新到主内存中
    2、其他线程能够及时把共享变量的最新值从主内存更新到自己的工作内存中

    原子性

    原子性:把一个或者多个操作在cpu执行过程中不被中断的特性称为原子性。

    在并发编程中,原子性的定义不应该和事务中的原子性(一旦代码运行异常可以回滚)一样。应该理解为:一段代码,或者一个变量的操作,在一个线程没有执行完之前,不能被其他线程执行。也就是说:

    1,原子操作是对于多线程而言的,对于单一线程,无所谓原子性。有点多线程常识的朋友这个都应该知道,但也要时刻牢记

    2,原子操作是针对共享变量的。因此,涉及局部变量(如方法中的变量)我们是没必要要求它具有原子性的。

    3,原子操作是不可分割的。(我们要站在多线程的角度)指访问某个共享变量的操作从其执行线程之外的线程来看,该操作要么已经执行完毕,要么尚未发生,其他线程不会看到执行操作的中间结果。学过数据库的朋友应该很熟悉这种原子性。那么,站在访问变量的角度,我们可以这样看,如果要改变一个对象,而该对象包含一组需要同时改变的共享变量,那么,在一个线程开始改变一个变量之后,在其它线程看来,这个对象的所有属性要么都被修改,要么都没有被修改,不会看到部分修改的中间结果。

    并且记住,在Java语言中,long型和double型以外的任何类型的变量的写操作都是原子操作。(不提读操作的原因是如果所有线程都是读操作的话,那么没必要保持原子性。

    为什么会有原子性问题?

    线程是CPU调度的基本单位。CPU会根据不同的调度算法进行线程调度,将时间片分派给线程。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。

    如:对于一段代码,一个线程还没执行完这段代码但是时间片耗尽,在等待CPU分配时间片,此时其他线程可以获取执行这段代码的时间片来执行这段代码,导致多个线程同时执行同一段代码,也就是原子性问题。

    线程切换带来原子性问题。

    在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

    1
    2
    3
    4
    i = 0;      // 原子性操作
    j = i;      // 不是原子性操作,包含了两个操作:读取i,将i值赋值给j
    i++;            // 不是原子性操作,包含了三个操作:读取i值、i + 1 、将+1结果赋值给i
    i = j + 1;      // 不是原子性操作,包含了三个操作:读取j值、j + 1 、将+1结果赋值给i

     举例:还是上文中的代码,10个线程将inc加到10000。假设在保证可见性的情况下,仍然会因为原子性问题导致执行结果达不到预期。为方便看,把代码贴到这里:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public class Test {
    public int a = 0;
     
    public void increase() {
            a++;
        }
     
    public static void main(String[] args) {
    final Test test = new Test();
    for (int i = 0; i < 10; i++) {
    new Thread() {
    public void run() {
    for (int j = 0; j < 1000; j++)
                            test.increase();
                    };
                }.start();
            }
     
    while (Thread.activeCount() > 1) {
    // 保证前面的线程都执行完
                Thread.yield();
            }
            System.out.println(test.a);
        }
    }

      

     目的:10个线程将inc加到10000。
    结果:每次运行,得到的结果都小于10000。

    原因分析:

    首先来看a++操作,其实包括三个操作: 

    ①读取a=0; 

    ②计算0+1=1; 

    ③将1赋值给a; 

    保证a++的原子性,就是保证这三个操作在一个线程没有执行完之前,不能被其他线程执行。

    关键一步:线程2在读取a的值时,线程1还没有完成a=1的赋值操作,导致线程2的计算结果也是a=1。

    问题在于没有保证a++操作的原子性。如果保证a++的原子性,线程1在执行完三个操作之前,线程2不能执行a++,那么就可以保证在线程2执行a++时,读取到a=1,从而得到正确的结果。

    有序性

     有序性:程序执行的顺序按照代码的先后顺序执行。导致乱序的原因有:指令的重排序和存储子系统的重排序。分别来自编译器处理器和高速缓存写缓冲器。

    编译器为了优化性能,有时候会改变程序中语句的先后顺序。例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的Bug。

    举例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class Singleton {
      static Singleton instance;
      static Singleton getInstance(){
        if (instance == null) {
          synchronized(Singleton.class) {
            if (instance == null)
              instance = new Singleton();
            }
        }
        return instance;
      }
    }

      

    在获取实例getInstance()的方法中,我们首先判断 instance是否为空,如果为空,则锁定 Singleton.class并再次检查instance是否为空,如果还为空则创建Singleton的一个实例。
    看似很完美,既保证了线程完全的初始化单例,又经过判断instance为null时再用synchronized同步加锁。但是还有问题!

    instance = new Singleton(); 创建对象的代码,分为三步:
    ①分配内存空间
    ②初始化对象Singleton
    ③将内存空间的地址赋值给instance

    但是这三步经过重排之后:
    ①分配内存空间
    ②将内存空间的地址赋值给instance
    ③初始化对象Singleton

    会导致什么结果呢?

    线程A先执行getInstance()方法,当执行完指令②时恰好发生了线程切换,切换到了线程B上;如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现instance!=null,所以直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常。

    执行时序图:

    VOLATILE关键字使用

    volatile原理

    volatile保证有序性原理

    前文介绍过,JMM通过插入内存屏障指令来禁止特定类型的重排序。

    java编译器在生成字节码时,在volatile变量操作前后的指令序列中插入内存屏障来禁止特定类型的重排序。

    volatile内存屏障插入策略:

    在每个volatile写操作的前面插入一个StoreStore屏障。
    在每个volatile写操作的后面插入一个StoreLoad屏障。
    在每个volatile读操作的后面插入一个LoadLoad屏障。
    在每个volatile读操作的后面插入一个LoadStore屏障。

     Store:数据对其他处理器可见(即:刷新到内存中)
    Load:让缓存中的数据失效,重新从主内存加载数据。

    volatile保证可见性原理

    volatile内存屏障插入策略中有一条,“在每个volatile写操作的后面插入一个StoreLoad屏障”。

    StoreLoad屏障会生成一个Lock前缀的指令,Lock前缀的指令在多核处理器下会引发了两件事:

    1. 将当前处理器缓存行的数据写回到系统内存。
    2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的。

    volatile内存可见的写-读过程

    volatile修饰的变量进行写操作。

    由于编译期间JMM插入一个StoreLoad内存屏障,JVM就会向处理器发送一条Lock前缀的指令。

    Lock前缀的指令将该变量所在缓存行的数据写回到主内存中,并使其他处理器中缓存了该变量内存地址的数据失效。

    当其他线程读取volatile修饰的变量时,本地内存中的缓存失效,就会到到主内存中读取最新的数据。

    保证可见性

    volatile保证了不同线程对volatile修饰变量进行操作时的可见性。

    对一个volatile变量的读,(任意线程)总是能看到对这个volatile变量最后的写入。

    一个线程修改volatile变量的值时,该变量的新值会立即刷新到主内存中,这个新值对其他线程来说是立即可见的。

    一个线程读取volatile变量的值时,该变量在本地内存中缓存无效,需要到主内存中读取。

    举例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    boolean stop = false;// 是否中断线程1标志
    //Tread1
    new Thread() {
        public void run() {
            while(!stop) {
              doSomething();
            }
        };
    }.start();
    //Tread2
    new Thread() {
        public void run() {
            stop = true;
        };
    }.start();

    目的: Tread2设置stop=true时,Tread1读取到stop=true,Tread1中断执行。

    问题: 虽然大多数时候可以达到中断线程1的目的,但是有可能发生Tread2设置stop=true后,Thread1未被中断的情况,而且这种情况引发的都是比较严重的线上问题,排查难度很大。

    问题分析: Tread2设置stop=true时,并未将stop=true刷到主内存,导致Tread1到主内存中读取到的仍然是stop=false,Tread1就会继续执行。也就是有内存可见性问题。

    解决: stop变量用volatile修饰。
    Tread2设置stop=true时,立即将volatile修饰的变量stop=true刷到主内存;
    Tread1读取stop的值时,会到主内存中读取最新的stop值。

    保证有序性

    volatile关键字能禁止指令重排序,保证了程序会严格按照代码的先后顺序执行,即保证了有序性。

    volatile的禁止重排序规则:

    1)当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
    2)当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
    3)当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

    举例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    boolean inited = false;// 初始化完成标志
    //线程1:初始化完成,设置inited=true
    new Thread() {
        public void run() {
            context = loadContext();   //语句1
            inited = true;             //语句2
        };
    }.start();
    //线程2:每隔1s检查是否完成初始化,初始化完成之后执行doSomething方法
    new Thread() {
        public void run() {
            while(!inited){
              Thread.sleep(1000);
            }
            doSomething(context);
        };
    }.start();

    目的: 线程1初始化配置,初始化完成,设置inited=true。线程2每隔1s检查是否完成初始化,初始化完成之后执行doSomething方法。

    问题: 线程1中,语句1和语句2之间不存在数据依赖关系,JMM允许这种重排序。如果在程序执行过程中发生重排序,先执行语句2后执行语句1,会发生什么情况?

    当线程1先执行语句2时,配置并未加载,而inited=true设置初始化完成了。线程2执行时,读取到inited=true,直接执行doSomething方法,而此时配置未加载,程序执行就会有问题。

    解决: volatile修饰inited变量。
    volatile修饰inited,“当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。”,保证线程1中语句1与语句2不能重排序。

    不保证原子性

    volatile是不能保证原子性的。

    原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。

    举例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public class VolatileTest {
        public volatile int a = 0;
     
        public void increase() {
            a++;
        }
     
        public static void main(String[] args) {
            final VolatileTest test = new VolatileTest();
            for (int i = 0; i < 10; i++) {
                new Thread() {
                    public void run() {
                        for (int j = 0; j < 1000; j++)
                            test.increase();
                    };
                }.start();
            }
     
            while (Thread.activeCount() > 1) {
                // 保证前面的线程都执行完
                Thread.yield();
            }
            System.out.println(test.a);
        }
    }

    目的: 10个线程将inc加到10000。

    结果: 每次运行,得到的结果都小于10000。

    原因分析:

    首先来看a++操作,其实包括三个操作:
    ①读取a=0;
    ②计算0+1=1;
    ③将1赋值给a;
    保证a++的原子性,就是保证这三个操作在一个线程没有执行完之前,不能被其他线程执行。

    一个可能的执行时序图如下:

    关键一步:线程2在读取a的值时,线程1还没有完成a=1的赋值操作,导致线程2读取到当前a=0,所以线程2的计算结果也是a=1。

    问题在于没有保证a++操作的原子性。如果保证a++的原子性,线程1在执行完三个操作之前,线程2不能执行a++,那么就可以保证在线程2执行a++时,读取到a=1,从而得到正确的结果。

    解决:

    synchronized保证原子性,用synchronized修饰increase()方法。

    CAS来实现原子性操作,AtomicInteger修饰变量a。

    Synchronized的使用

    在java代码中使用synchronized可是使用在代码块和方法中,根据Synchronized用的位置可以有这些使用场景:

    synchronized其实就是锁,先来介绍一下锁的机制。

    锁的内存语义

    synchronized的底层是使用操作系统的mutex lock实现的。

    内存可见性:同步快的可见性是由“如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值”、“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)”这两条规则获得的。

    操作原子性:持有同一个锁的两个同步块只能串行地进入

    锁的内存语义:

    当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中

    当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量

    锁释放和锁获取的内存语义:

    线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。

    线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。

    线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

    对象锁(monitor)机制

    现在我们来看看synchronized的具体底层实现。先写一个简单的demo:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class SynchronizedDemo {
        public static void main(String[] args) {
            synchronized (SynchronizedDemo.class) {
            }
            method();
        }
     
        private static void method() {
        }
    }

    上面的代码中有一个同步代码块,锁住的是类对象,并且还有一个同步静态方法,锁住的依然是该类的类对象。编译之后,切换到SynchronizedDemo.class的同级目录之后,然后用javap -v SynchronizedDemo.class查看字节码文件:

    如图,上面用黄色高亮的部分就是需要注意的部分了,这也是添Synchronized关键字之后独有的。执行同步代码块后首先要先执行monitorenter指令,退出的时候monitorexit指令。通过分析之后可以看出,使用Synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗?答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。

     总结

    并发编程的本质就是解决三大问题:原子性、可见性、有序性。

    原子性:一个或者多个操作在 CPU 执行的过程中不被中断的特性。由于线程的切换,导致多个线程同时执行同一段代码,带来的原子性问题。

    可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。缓存不能及时刷新导致了可见性问题。

    有序性:程序执行的顺序按照代码的先后顺序执行。编译器为了优化性能而改变程序中语句的先后顺序,导致有序性问题。

    启发:线程的切换、缓存及编译优化都是为了提高性能,但是引发了并发编程的问题。这也告诉我们技术在解决一个问题时,必然会带来另一个问题,需要我们提前考虑新技术带来的问题以规避风险。

     
     
  • 相关阅读:
    关于unittest框架的传参问题
    爬虫的框架:Scarpy
    Robot Frameworke在python3上搭建环境以及快捷方式的创建
    安装第三方模块报错:read time out
    操作正则表达式遇到的问题
    gil锁 线程队列 线程池
    并发编程
    网络编程传输文件
    粘包现象
    UDP协议下的socket
  • 原文地址:https://www.cnblogs.com/sunny-miss/p/11974770.html
Copyright © 2011-2022 走看看