并发编程—并发编程中的基础概念

计算机的存储系统结构

计算机的存储系统以一种分层次的结构构造,如下图所示

  1. 存储器的顶层是CPU的寄存器。它们用与CPU相同的材料和工艺制成,其存取速度和CPU的运行速度一样快。
    寄存器是什么?
    CPU工作时需要从内存中取出指令并执行,但是通过访问内存得到指令和数据的时间要比执行花费的时间长得多,所以CPU都有一些用来保存关键变量和临时结果的寄存器。另外还有程序计数器、堆栈指针寄存器和程序状态字寄存器等,都有各自的功能。
  2. 高速缓存。高速缓存被分割成高速缓存行(Cache Line),其典型大小为64字节,地址0~63对应高速缓存行0,地址64~127对应高速缓存行1,以此类推。当CPU需要某个数据时,会先去查找所需要的高速缓存行是否在高速缓存中,如果在,称为高速缓存命中,也就不需要通过总线把访问请求送往内存。高速缓存如果未命中则需要访问内存,这将会付出很大的时间代价。

    如下是最简单的高速缓存配置图:
    高速缓存示意图
    早期的一些系统就是类似的架构。在这种架构中,CPU核心不再直连到主存。数据的读取和存储都经过高速缓存。CPU核心与高速缓存之间是一条特殊的快速通道。在简化的表示法中,主存与高速缓存都连到系统总线上,这条总线同时还用于与其它组件(比如硬盘控制器、键盘控制器等)通信。

    在高速缓存出现后不久,系统变得更加复杂。高速缓存与主存之间的速度差异进一步拉大,直到加入了另一级缓存。新加入的这一级缓存比第一级缓存更大,但是更慢。由于加大一级缓存的做法从经济上考虑是行不通的,所以有了二级缓存,甚至现在的有些系统拥有三级缓存,如下图:
    多级高速缓存示意图

  3. 内存(又称主存),这是存储系统的主力。所有不能在高速缓存中命中的访问请求都会转往内存。
  4. 磁盘(硬盘),也称为外存。由于磁盘的机械结构导致其访问速度很慢。

什么是上下文切换?

《Java并发编程的艺术》中是这样描述的:
即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CUP时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程时同时执行的,时间片一般是几十毫秒(ms)。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换

内存模型的相关概念

计算机在执行程序时,每条指令都是由CPU执行,执行指令过程中,必定会涉及到数据的读取和写入。程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,在九十年代以后,现代计算机的CPU执行速度越来越快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此在CPU和内存之间加入了工作内存(working memory,是高速缓存和寄存器的一个抽象,这个解释源于《Concurrent Programming in Java: Design Principles and Patterns, Second Edition》§2.2.7,原文:Every thread is defined to have a working memory (an abstraction of caches and registers) in which to store values. ),注意工作内存并不是主存的某个部分

当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的工作内存当中,那么CPU进行计算时就可以直接从它的工作内存读取数据和向其中写入数据,当运算结束之后,再将工作内存中的数据刷新到主存中。举个简单的例子,比如下面的这段代码:

    i = i + 1;

当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到工作内存当中,然后CPU执行指令对i进行加1操作,然后将执行完的结果写入工作内存,最后将工作内存中i最新的值刷新到主存中。

这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的工作内存。

比如同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。但是事实会是这样吗?

我们知道整个过程应该是:两个线程首先都要从主存中读取i的值存入到各自所在的CPU的工作内存中,然后两个线程各自进行加1操作,最后各自把i的最新值写入到内存。可能存在这样一种情况:,线程1首先读取了i的值是0,然后进行加1,在没有将结果刷新的主存之前,如果线程2此时读取主存中的值也会是0,线程2同样自己进行加1操作。最终两个线程计算的结果都是1,不管是线程1还是线程2先将结果刷新到主存中,主存中的i最终的值都是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。(注意:这段过程分析是以i = i + 1是非原子性操作为前提的,关于原子性操作的概念在后面有解释)

也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。
为了解决缓存不一致性问题,通常来说有以下2种解决方法:

  1. 通过在总线加LOCK#锁的方式
  2. 通过缓存一致性协议

这2种方式都是硬件层面上提供的方式。
在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。

比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。
所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量(即在其他CPU中也存在该变量的副本),会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,就能够知道自己工作内存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

并发编程中的三个概念

在并发编程中,我们通常会遇到以下三个概念:原子性,可见性和有序性。下面先介绍一下这三个概念:

原子性

原子性:指一系列操作是不可分割的,一旦执行则整个过程将会一次性全部执行完成,不会停留在中间状态。

假如对一个32位的变量赋值:

    i = 9;

如果这个过程不具备原子性,则会有可能:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
比如下面这段代码:

int i = 0;

//线程1执行的代码
i = 10;

//线程2执行的代码
j = i;

如果i = 10这条语句由线程1执行,当线程1把i的初始值加载到它的工作内存中,然后赋值为10,但是线程1将工作内存中赋好的值再刷新到主存中的时间是不确定的。如果线程1还没有刷新的主存中,而此时线程2执行j = i,它会先去主存读取i的值并加载到它的工作内存中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

有序性

对于下面这段代码:

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,因为这里可能会发生指令重排序(Instruction Reorder)

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

重排序的种类:

  • 编译期重排。编译源代码时,编译器依据对上下文的分析,对指令进行重排序,以之更适合于CPU的并行执行。
  • 运行期重排,CPU在执行过程中,动态分析依赖部件的效能,对指令做重排序优化。

对于上面的代码,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。

但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

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

这段代码有4个语句,那么可能的一个执行顺序是:

那么可不可能是这个执行顺序呢: 语句2——>语句1——>语句4——>语句3
不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

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

//线程2:
while(!inited ){
  sleep(1)
}
doSomethingwithconfig(context);

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

所以,指令重排序不会影响单个线程的执行,但是会影响到多线程执行的正确性。

Java内存模型的理解

前面谈到的是并发编程中可能会碰到的问题。接下来介绍一下Java内存模型的做法。

在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。需要知道的是:为了获得较好的执行性能,减少对编译器和CPU的束缚,Java内存模型并没有限制执行引擎使用处理器的工作内存来提升指令执行速度,也没有限制编译器和CPU对指令进行重排序。

其实在JMM设计中遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

但是也正是因为JMM上述的不加限制,导致JMM中也会存在可见性的问题。所以提供了happens-before规则,来指定两个操作之间的执行顺序,解决可见性问题。这两个操作可以在一个线程之内,也可以是在不同线程之间。happens-before是JMM最核心的概念。

happens-before原则

happens-before的概念最初由Leslie Lamport在其一篇影响深远的论文(《Time,Clocks andthe Ordering of Events in a Distributed System》)中提出。Leslie Lamport使用happens-before来定义分布式系统中事件之间的偏序关系(partial ordering)。Leslie Lamport在这篇论文中给出了一个分布式算法,该算法可以将该偏序关系扩展为某种全序关系。

Java内存模型中定义了许多Action,有些Action之间存在happens-before关系(并不是所有Action两两之间都有happens-before关系,如果两个操作之间不存在happens-before规则,则重排序不会影响执行结果)。对于ActionA happens-before ActionB,我们可以描述为hb(ActionA,ActionB)。

happens-before规则不是描述实际操作的先后顺序,它是用来描述可见性的一种规则:

  • Each action in a thread happens-before every subsequent action in that thread.
    程序顺序规则,意思是:线程中上一个动作及之前的所有写操作在该线程执行下一个动作时对该线程可见(也就是说,同一个线程中前面的所有写操作对后面的操作可见)
  • An unlock on a monitor happens-before every subsequent lock on that monitor.
    锁定规则,意思是:如果线程1解锁了monitor a,接着线程2锁定了a,那么,线程1解锁a之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)。
  • A write to a volatile field happens-before every subsequent read of that volatile.
    volatile变量规则,意思是:如果线程1写入了volatile变量v,接着线程2读取了v,那么,线程1写入v及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)。
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。

这8条原则摘自《深入理解Java虚拟机》。

下面用happens-before规则分析两个例子:
Java的api文档中对于java.util.concurrent包下的类有如下说明:

The methods of all classes in java.util.concurrent and its subpackages extend these guarantees to higher-level synchronization. In particular:
  • Actions in a thread prior to placing an object into any concurrent collection happen-before actions subsequent to the access or removal of that element from the collection in another thread.
  • Actions in a thread prior to the submission of a Runnable to an Executor happen-before its execution begins. Similarly for Callables submitted to an ExecutorService.
  • Actions taken by the asynchronous computation represented by a Future happen-before actions subsequent to the retrieval of the result via Future.get() in another thread.
  • Actions prior to "releasing" synchronizer methods such as Lock.unlock, Semaphore.release, and CountDownLatch.countDown happen-before actions subsequent to a successful "acquiring" method such as Lock.lock, Semaphore.acquire, Condition.await, and CountDownLatch.await on the same synchronizer object in another thread.
  • For each pair of threads that successfully exchange objects via an Exchanger, actions prior to the exchange() in each thread happen-before those subsequent to the corresponding exchange() in another thread.
  • Actions prior to calling CyclicBarrier.await and Phaser.awaitAdvance (as well as its variants) happen-before actions performed by the barrier action, and actions performed by the barrier action happen-before actions subsequent to a successful return from the corresponding await in other threads.

此处使用CopyOnWriteArrayList分析一下第一条:Actions in a thread prior to placing an object into any concurrent collection happen-before actions subsequent to the access or removal of that element from the collection in another thread.(放入一个元素到并发集合要发生于从并发集合中取元素之前)。

CopyOnWriteArrayList的set方法源码:

    /**
     * Replaces the element at the specified position in this list with the
     * specified element.
     *
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    public E set(int index, E element) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            E oldValue = get(elements, index);

            if (oldValue != element) {
                int len = elements.length;
                Object[] newElements = Arrays.copyOf(elements, len);
                newElements[index] = element;
                setArray(newElements);
            } else {
                // Not quite a no-op; ensures volatile write semantics
                setArray(elements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }

在上面else调用了setArray(elements)方法:

final void setArray(Object[] a) {
    array = a;
}

一个简单的赋值,array是volatile类型。elements是从getArray()方法取过来的,getArray()实现如下:

final Object[] getArray() {
    return array;
}

也很简单,直接返回array。取得array,又重新赋值给array,为什么要这样做呢?setArray(elements)上有条简单的注释,但可能不是太容易明白。正如前文提到的那条javadoc上的规定,放入一个元素到并发集合与从并发集合中取元素之间要有hb关系。set是放入,get是取,怎么才能使得set与get之间有hb关系,set方法的最后有unlock操作,如果get里有对这个锁的lock操作,那么就好满足了,但是get并没有加锁:

public E get(int index) {
    return (E)(getArray()[index]);
}

但是get里调用了getArray,getArray里有读volatile的操作,只需要set走任意代码路径都能遇到写volatile操作就能满足条件了,这里主要就是if…else…分支,if里有个setArray操作,如果只是从单线程角度来说,else里的setArray(elements)是没有必要的,但是为了使得走else这个代码路径时也有写volatile变量操作,就需要加一个setArray(elements)调用。

JMM中的原子性操作

下列操作是原子操作:

  • all assignments of primitive types except for long and double
  • all assignments of references
  • all operations of java.concurrent.Atomic* classes
  • all assignments to volatile longs and doubles

为什么long型赋值不是原子操作呢?例如:

long foo = 65465498L;

实时上java会分两步写入这个long变量,先写32位,再写后32位。这样就线程不安全了。如果改成下面的就线程安全了:

private volatile long foo;


对于下面一个例子,哪些操作是原子性操作?

x = 10;         //语句1
y = x;         //语句2
x++;           //语句3
x = x + 1;     //语句4

根据上面列出的规则,只有语句1是原子性操作,其他三个语句都不是原子性操作。
语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存这2个操作都是原子性操作,但是合起来就不是原子性操作了。
同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
所以上面4个语句只有语句1的操作具备原子性。

从上面可以看出,Java内存模型只保证了很少的操作是原子性的,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了。

可见性问题的解决

对于普通的共享变量,当被一个线程修改之后,什么时候被写入主存是不确定的,其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

对于可见性,Java提供了volatile关键字来保证可见性
当一个共享变量被volatile修饰时,和普通变量的区别是:①修改这个变量时会强制将修改后的值刷新的主内存中;②这个变量在工作内存中的缓存行将失效,当有其它线程需要读取时,它都会去主内存中读取新值。关于volatile关键字,将在随后的文章再作说明。

阅读 1.5k

推荐阅读
技术文档整理
用户专栏

后端开发

21 人关注
31 篇文章
专栏主页