美文网首页
并发编程基础

并发编程基础

作者: 狮_子歌歌 | 来源:发表于2017-03-27 18:41 被阅读75次

    并发编程

    并发编程主要解决两个问题:

    • 线程之间如何通信,指线程之间以何种机制交换信息。
    • 线程之间如何同步,指程序控制不同线程操作发生相对顺序。

    本文主要从Java内存模型理解Java线程通信和同步

    基础

    并发编程模型类型

    并发编程模型类型有两种,共享内存和消息传递。

    通信

    在共享内存模型中,线程间的通信是利用线程共享程序公共状态来隐式实现的。

    在消息传递模型中,线程之间没有共享内存,只有通过明确地发送消息来显示的实现通信。

    同步

    在共享内存模型中,同步需要显示执行。程序员在代码中指定某一方法或代码片段在线程间互斥执行(Java中这些方法或片段称为临界区)。

    在消息传递模型中,由于发送消息在消息接收前,所以同步是隐式实现的。

    Java线程通信

    主要从Java内存模型来理解Java中的并发。

    Java中的并发实现是依据于共享内存并发模型。

    Java内存模型复习

    程序员常说的栈内存和堆内存是Java虚拟机运行时数据区重要的两块组成部分。堆内存是线程共享的,而栈内存是线程私有的。

    Java内存模型控制线程之间的通信,定义了线程内存与计算机主内存(Main Memory)之间的抽象关系:线程之间共享变量存储在主内存中。

    共享变量主要指存储在除线程私有内存中的其它数据,包括堆内存中对象的实例域和方法区中的静态变量、常量等。

    而线程私有内存中存储了访问的共享变量副本,以及局部变量、方法参数、异常处理器参数。

    线程私有内存

    局部变量、方法参数、异常处理器参数等都是线程创建的本地变量,仅自己可见,可以通过值传递方式发送给另一个线程,但是不能共享自身的本地变量。

    引用类型传递的是引用值,并不是对象本身。

    共享内存

    包括堆内存、方法区。该存储区的数据都是所有线程共享的。当线程私有内存使用它们时,基本类型使用其值、引用类型使用堆内存引用。

    对象的实例域也是在堆内存中,而对象方法的局部变量、方法参数、异常处理器参数都是存放在线程私有内存中的。

    线程访问共享变量

    假如现在有一个线程A,对线B。B中有一个setter方法C,设置B的一个实例域D。

    当A执行C时,首先会在A私有内存中对D进行一个拷贝(基本类型值拷贝,引用类型引用拷贝),然后执行setter方法,顺序如下:

    • 将D的副本加载到寄存器。
    • 执行setter方法指令。
    • 将结果写入A堆内存的对象实例域中。

    由于这样的操作过程,会导致线程不是原子操作。

    Java内存模型控制通信

    上面大概理解了线程使用共享变量,那么线程私有内存与共享内存交互是谁实现的?就是Java内存模型实现的。

    假设有另一个线程E也需要访问D,而且理想状态是E的执行会等待A执行完毕后执行(单处理器,使用时间切片实现并发)。这样保证了E获取到D的最新数据,实现了A、E通信。

    由于使用时间切片进行并发,所以会导致非原子操作,造成竞争条件,所以同步在共享内存并发模型中尤为重要。

    总结

    Java内存模型作用就是实现线程私有内存和主内存(也就是共享内存)之间交互,从而提供内存可见性,实现了线程之间的通信。

    Java内存模型引发的问题

    由于执行代码时,Java内存模型与硬件之间的交互,导致共享的变量存储在主内存中的不同区域中(例如各线程私有的区域),单处理器时间切片实现并发,非原子操作等因素主要引发两个问题:共享内存可见性和竞争条件。

    CPU架构

    现代计算机CPU都有一层缓存。计算机寄存器会将执行后的最终结果写入缓存,而缓存与内存交互是以块的形式,且非实时的。这样的设计是为了提高速度,减少CPU等待时间。

    共享内存可见性问题

    在Java中有可能遇到共享内存可见性问题。

    例如,内存中有一数据count,初始化为0。线程A,B分别对count执行不同的指令。

    线程A执行

    count++;
    

    线程B执行打印指令。

    如果以上两个指令在同一个线程中执行,输出为1。但是如果是两个线程,那么输出不一定为1,有可能为0。其原因有可能是CPU架构设计原因,也有可能是单处理器未同步A、B线程等。造成的原因很复杂。

    这也称为"内存一致性错误",需要进行数据同步操作。

    该问题的主要原因是多线程没有正确使用volatile声明或同步的情况下共享一个变量,一个线程对变量的操作结果对于其他线程是不可见的。

    解决原理happens-before

    要想解决共享内存可见性问题,首先要了解happens-before。Java1.5以后使用JSR-133内存模型,它使用happends-before关系来阐述操作之间的内存可见性。

    如果一个操作的结果需要对另一个操作可见,那么两个操作之间必须要存在happens-before关系。

    注意,happens-before并不决定两个操作执行的顺序先后。它只是保证前一个操作的执行结果对后一个操作的可见性

    竞争条件

    和共享内存可见性类似,但是更强调同时。竞争条件是指如果两个或者更多的线程共享一个对象,多个线程在这个共享对象上更新,就有可能发生竞争条件情况,而且结果是不可预测的。共享内存可见性则是保证前一个操作的结果对后一个操作可见。

    例如,程序源代码需求是在两个线程对同一个对象的成员变量分别加1(初始值为0),理论结果应该是2。由于单处理器原因,采用时间切片的方式实现并发,这就导致输出结果可能是1。操作如下:

    • 线程A从堆内存中拷贝成员变量count的初始值至私有区,并执行代码;
    • CPU等待刷新内存或其他原因,转而执行线程B。
    • 同理线程B先拷贝count值,此时由于线程A的结果未存入堆内存中,导致拷贝的值仍未0。

    而在Java中发生这类错误的原因是没有正确的采用同步。一个同步的代码块或者代码片段称为临界区,同一时间只允许一个线程执行,而且保证线程执行完毕后结果刷新到JVM运行时数据结构中的共享区中(解决了共享内存可见性)。

    解决原理顺序一致性

    如果一个多线程程序正确使用了同步,保证了程序执行的顺序一致性。顺序一致性表示,该程序在Java内存模型中执行的结果,与在顺序一致性内存模型中执行结果相同。

    采用的同步方式指,synchronized,volatile,final等关键词,以及锁的使用。

    final关键词修饰的实例域必须在对象实例化过程中构造器执行完毕前赋值,且其只读特性,可以在多线程环境下安全的共享,而不需要额外的同步开销。

    顺序一致性内存模型

    它是一种理论参考模型,保证了内存可见性。它主要有两大特性:

    • 一个线程中的所有操作必须按照程序的顺序来执行。
    • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

    所以同步后一方面实现代码的临界区(线程互斥),另一方面提供内存一致性保障。

    总结

    由于Java虚拟机采用共享内存并发模型,以及Java运行时数据结构的特点和硬件之间的交互,产生了共享内存可见性和竞争条件两个问题。

    共享内存可见性问题主要指保证前一个操作结果对于后一个可见,而竞争条件描述的是多线程同时更新共享变量,导致的不可预料错误。

    解决这些问题的关键在于操作是否原子性,而在Java中实现操作的原子性是通过同步实现的。

    原子性

    上述两个问题的解决办法就是利用原子操作。一个完整的原子操作,不会被任何操作打断,直到它完成。而在Java中原子操作是通过同步来实现的。

    通常同步的方法主要有synchronized,锁,volatile,final。

    volatile

    在Java中对一类变量的访问属于原子操作:

    • 对除了long和double的基本类型变量以及引用类型的变量进行读写操作都是原子性的。
    • 对所有声明为volatile的变量的读写操作都是原子性的。

    注意这并不意味这些原子性变量就不需要同步。它们所保证的是读写操作原子性,而其他操作依旧是非原子。

    原子性变量实际上建立了写与读之间的happens-before关系。

    happens-before规则主要有:

    1. 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
    2. 监视器锁规则:对一个监视器的解锁,happens- before 于随后对这个监视器的加锁。
    3. volatile 变量规则:对一个 volatile 域的写,happens- before于任意后续对这个volatile域的读。
    4. 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。

    例如方法:

    private boolean done;
    public void flipDone() {
        done = !done;
    }
    

    不能保证读取、翻转和写入整个过程不被打断。

    所以如果多线程程序中,线程除了访问原子性变量操作以外还有其他的操作,那么必须使用同步。但是使用原子性变量优势在于,如果仅仅是访问操作,比起使用同步代码更高效。

    参考

    Java 内存模型

    遗留问题

    遗留问题.png

    相关文章

      网友评论

          本文标题:并发编程基础

          本文链接:https://www.haomeiwen.com/subject/sunrottx.html