技术文章

高并发必备篇(三):线程的内存模型(下)

1. Java内存模型

java内存模型(Java Memory Model,简称JMM)是由JVM规范定义的,它实现了java程序在不同的硬件和操作系统平台上都能达到内存访问的一致性,而JMM中主要定义的是程序中变量的访问规则。

 

Java内存模型中,按照线程是否共享内存将虚拟机内存划分为两部分内存:主内存线程工作内存

 

线程的内存模型

 

●主内存:java虚拟机中规定所有变量都存储在主内存中,主内存是共享内存区域,所有线程都可以访问。上图中堆内存和方法区内存是主内存区域。

 

●工作内存:java虚拟机中会为每个线程创建自己的工作内存,工作内存中存储了线程运行所需的主内存变量的副本数据,JVM中变量的运算和修改都需要在工作内存中进行,不能直接在主内存中进行,线程和线程之间也不能互相访问工作内存,工作内存运算完的结果写入主内存之后其他线程才能访问。上图中栈、本地方法栈和程序计数器就是工作内存区域了。

 

线程在主内存和工作内存之间交互如下图所示:

 

线程的内存模型

 

上图中也看到了,JVM中共定义了8种原子性(下面会讲解原子性)操作来实现主内存和工作内存的交互:

 

●read:将主内存中的一个变量的值读取出来;

●load:将read操作读取的变量值存储到工作内存的副本中;

●use:把工作内存中的变量的值传递给执行引擎;

●assign:把从执行引擎中接收的值赋值给工作内存中的变量;

●store:把工作内存中一个变量的值传递到主内存;

●write:将store操作传递的值写入到主内存的变量中;

●lock:将主内存中的一个变量标识为某个线程独占的锁定状态;

●unlock:将主内存中线程独占的一个变量从锁定状态中释放。

 

通过上述8种原子操作的描述,我们如果要把一个变量从主内存传输到工作内存,那就要执行read和load操作,如果要把一个变量从工作内存写回主内存,就要执行store和write操作。

 

当然JVM的这几种操作我们是无法直接调用,我们只能通过java中指定好的一些关键字如synchronized等来调用部分操作。


 线程操作的三个特性:

 

Java内存模型中想要保证线程并发的安全,那么必须要了解原子性、可见性和有序性线程的这三个特性。

 

●原子性(atomicity)

 

原子性是指在一个任务执行中cpu不可以在中途暂停然后再调度,即操作不能被中断,要不就执行完成,要不就不执行。

 

例如:int a = 10; 这个操作是不可分割的,那么它就是原子性操作。而int b = a+10; 这个操作是可以分割的那么就不是原子性操作。

 

原子性操作在JVM中是线程安全的,非原子性操作在多线程并发下就会出现线程不安全的问题,这之后就需要我们使用同步技术把非原子性操作变成原子性操作。

 

JVM中lock 和 unlock就是把非原子性操作变成原子性操作,JVM中使用的两个字节码指令monitorenter和monitorexit来实现lock和unlock操作,而我们的java代码中则是使用synchronized关键字完成上述的两个操作。

 

●可见性(Visibility)

 

简单来说就是在多线程操作变量的时候,一个线程修改了变量的值其它线程可以立即知晓这个修改。

 

想要实现可见性需要线程在工作内存中修改了变量值之后立马同步到主内存中刷新主内存中变量的值,线程再次使用变量的时候重新从主内存中读取。这样其他线程在使用的会后就可以使用最新的变量值。

 

Java中线程的可见性可以通过三个关键字来实现,volatile、synchronized以及final。volatile关键字我们后面使用的会后再说,synchronized保证有序性是因为unlock操作之前必须把变量同步回主内存来实现的。

 

final关键字是因为其修饰的变量在初始化后就会变成不会更改的常量,所以只要在初始化过程中没有把this引用传递出去被外部使用就能保证变量被线程调用的可见性。

 

●有序性(Orderliness)

 

有序性是指在同一个线程中的所有操作都是有序执行的,但由于指令重排序等行为会导致指令执行的顺序不一定是按照代码中的先后顺序执行的。

 

之前提到过在多线程操作中指令重排会导致线程不安全。在java中可以通过关键字volatile和synchronized保证线程的有序性。

 

Volatile是因为变量被其修饰后指令就不会出现重排保证有序性,而synchronized 是因为在变量被lock锁定之后同一时间只能被一个线程使用,即相当于单线程操作,而单线程的指令重排是没有问题的。

 

重谈指令重排 和 Happens-Before原则

 

指令重排是编译器为了提高指令执行效率,只要程序的最终结果等同于它在严格的顺序化环境中执行的结果,就可以对指令的执行顺序进行重排序,也就是说重排序后指令的执行顺序不一定是代码的顺序。

 

在单线程下因指令重排的保证执行的结果所以前面指令执行的顺序是否按照代码的顺序不重要,但是在多线程下就会出现问题。

 

例如一个线程A修改了一个变量且还没有来得及写入但是另外一个线程B却进行读取这个变量的时候就会出现问题,这个就是因为线程变量修改不可见和顺序改变引起的了。

 

上面讲到了volatile和synchronized关键字可以保可见性和有序性,但是Java内存模型中所有的可见性和有序性也不是都要依靠volatile和synchronized来实现,否则不仅会使得我们的一些操作变得非常繁琐,也会大大降低性能(synchronized滥用会对性能影响很大)。

 

JMM 为保证线程操作的可见性和有序性定义了一个两个操作之间的偏序关系,称之为Happens-Before(先行发生)原则。比如说操作A先行发生于操作B,那么在B操作发生之前,A操作产生的变量修改等操作都会被操作B可见。

 

Happens-Before 具体如下:

 

●程序顺序规则
 一个线程操作中,每个操作都先行发生于它的后续操作。简单来说就是按照代  码逻辑发生。

 

●监视器锁规则
 一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须注意的是  对同一个锁,后面是指时间上的后面。

 

●volatile变量规则
 对一个volatile变量的写操作先行发生与后面对这个变量的读操作。

 

●线程启动规则
 Thread对象的start()方法先行发生与该线程的每个动作。

 

●线程终止规则
 线程中的所有操作都先行发生与对此线程的终止检测,可以通过Thread.join()  和Thread.isAlive()的返回值等手段检测线程是否已经终止执行。

 

●线程中断规则
 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的  发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

 

●对象finalize规则
 一个对象的初始化完成先行发生于他的finalize方法(GC回收对象调用的方法)  的执行。

 

●传递性
 如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发  生于操作C。

 

通过上述描述我们就可以知道我们衡量并发安全的问题需要以Happens-Before 原则为准,而不是盲目的使用synchronized等关键字来实现并发的安全。

 

happens-Before 中提到的线程的一些方法如:join()、isAlive()、interrupt()等方法,我们会在下篇文字中介绍“线程的操作方法”的时候带大家认识它们,这里就不做过多的解释了。

 

综上所述:从硬件内存到线程模型到我们JVM内存模型及其线程的各种特性和JMM先行发生规则,我们对于实现线程的并发安全就有了实现思路和理解,这也对我们后面理解实现线程安全的手段奠定了良好的基础。