java

JVM系列(3) JMM & volatile, synchro

2020-02-16  本文已影响0人  suxin1932
高速缓存, 主内存, 工作内存
JMM
happens-before原则
synchronized, volatile, ReentrantLock

1.高速缓存, 主内存, 工作内存

#主存
计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。
由于程序运行过程中的临时数据是存放在"主存(物理内存)"当中的,这时就存在一个问题,
由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,
因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。
#高速缓存
因此在CPU里面就有了高速缓存。
也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,
那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,
当运算结束之后,再将高速缓存中的数据刷新到主存当中。

#高速缓存在多核CPU或多线程中---->缓存一致性问题
举个简单的例子,比如下面的这段代码:
"i = i + 1;"
当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,
然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。
这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。
在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存
(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。
比如同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。
可能存在下面一种情况:
初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,
然后线程1进行加1操作,然后把i的最新值1写入到内存。
线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。
最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。

#高速缓存缓存不一致解决方案
如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。
为了解决缓存不一致性问题,通常来说有以下2种解决方法("硬件层面"):
1)通过在总线加LOCK#锁的方式  
在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。
因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,
也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。
比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,
那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。
这样就解决了缓存不一致的问题。
2)通过缓存一致性协议  
但是由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。  
所以就出现了缓存一致性协议。
最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。
它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,
会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,
发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。        
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,
每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,
当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,
当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
高速缓存不一致的解决方案.jpg java中主存与线程工作内存.png
线程1和线程2要想进行数据的交换一般要经历下面的步骤:

1.线程1把工作内存1中的更新过的共享变量刷新到主内存中去。
2.线程2到主内存中去读取线程1刷新过的共享变量,然后copy一份到工作内存2中去。

2.Java内存模型

Java内存模型的主要目标是定义程序中各个变量的访问规则,
即在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。
此处的变量与Java编程里面的变量有所不同步,它包含了实例字段、静态字段和构成数组对象的元素,
但不包含局部变量和方法参数,因为后者是线程私有的,不会共享,当然不存在数据竞争问题
(如果局部变量是一个reference引用类型,它引用的对象在Java堆中可被各个线程共享,
但是reference引用本身在Java栈的局部变量表中,是线程私有的)。

#JMM
在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)
来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
为了获得较高的执行效能,Java内存模型并没有限制执行引起使用处理器的特定寄存器或者缓存来和主内存进行交互,
也没有限制即时编译器进行调整代码执行顺序这类优化措施。

JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),
线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,
线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,
而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,
但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。
不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。

2.1 Java内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的

#原子性(Atomicity):
一个操作不能被打断,要么全部执行完毕,要么不执行。
在这点上有点类似于事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。

基本类型数据的访问大都是原子操作,long 和double类型的变量是64位,
但是在32位JVM中,32位的JVM会将64位数据的读写操作分为2次32位的读写操作来进行,
这就导致了long、double类型的变量在32位虚拟机中是非原子操作,
数据有可能会被破坏,也就意味着多个线程在并发访问的时候是线程非安全的。

"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操作,写入新的值。

#不过这里有一点需要注意:
在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。
但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。

#synchronized和Lock来保证原子性
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,
如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。
由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,
那么自然就不存在原子性问题了,从而保证了原子性。
#可见性:
一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量这种修改(变化)。

Java内存模型是通过将在工作内存中的变量修改后的值同步到主内存,
在读取变量前从主内存刷新最新值到工作内存中,这种依赖主内存的方式来实现可见性的。

无论是普通变量还是volatile变量都是如此,
区别在于:volatile的特殊规则保证了volatile变量值修改后的新值立刻同步到主内存,
每次使用volatile变量前立即从主内存中刷新,
因此volatile保证了多线程之间的操作变量的可见性,而普通变量则不能保证这一点。

"除了volatile关键字能实现可见性之外,还有synchronized, Lock,final也是可以的。"

使用synchronized关键字,在同步方法/同步块开始时(Monitor Enter),
使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),
在同步方法/同步块结束时(Monitor Exit),会将工作内存中的变量值同步到主内存中去
(即将线程私有的工作内存中的值写入到主内存进行同步)。

使用Lock接口的最常用的实现ReentrantLock(重入锁)来实现可见性:
当我们在方法的开始位置执行lock.lock()方法,这和synchronized开始位置(Monitor Enter)有相同的语义,
即使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),
在方法的最后finally块里执行lock.unlock()方法,和synchronized结束位置(Monitor Exit)有相同的语义,
即会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。

final关键字的可见性是指:
被final修饰的变量,在构造函数数一旦初始化完成,
并且在构造函数中并没有把“this”的引用传递出去(“this”引用逃逸是很危险的,
其他的线程很可能通过该引用访问到只“初始化一半”的对象),那么其他线程就可以看到final变量的值。
#有序性:
对于一个线程的代码而言,我们总是以为代码的执行是从前往后的,依次执行的。
这么说不能说完全不对,在单线程程序里,确实会这样执行;
但是在多线程并发时,程序的执行就有可能出现乱序。
用一句话可以总结为:在本线程内观察,操作都是有序的;
如果在一个线程中观察另外一个线程,所有的操作都是无序的。
前半句是指“线程内表现为串行语义(WithIn Thread As-if-Serial Semantics)”,
后半句是指“指令重排”现象和“工作内存和主内存同步延迟”现象。

比如:下列语句可能的顺序是 2-1-3-4
"
int a = 10; //语句1
int r = 2; //语句2
a = a + 3; //语句3
r = a*a; //语句4
"
那么可不可能是这个执行顺序呢: 
"语句2   语句1    语句4   语句3"
不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,
如果一个指令Instruction 2必须用到Instruction 1的结果,
那么处理器会保证Instruction 1会在Instruction 2之前执行。

虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?
"
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
  sleep()
}
doSomethingWithConfig(context);
"
上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。
假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,
那么就会跳出while循环,去执行doSomethingWithConfig(context)方法,而此时context并没有被初始化,就会导致程序出错。


在Java里面,可以通过volatile关键字来保证一定的“有序性”。
另外可以通过synchronized和Lock来保证有序性,很显然,
synchronized和Lock保证每个时刻是有一个线程执行同步代码,
相当于是让线程顺序执行同步代码,自然就保证了有序性。

在单线程程序中,不会发生“指令重排”和“工作内存和主内存同步延迟”现象,"只在多线程程序中出现"。

另外,Java内存模型具备一些先天的“有序性”,
即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 "happens-before 原则"。
如果两个操作的执行次序无法从happens-before原则推导出来,
那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

2.2 happens-before原则:

Happens-Before的语义包含了两个最重要的保证:
1. 如果A操作happens-before于B操作,那么A操作一定在B操作之前执行。即禁止了重排序。
2. 如果A操作happens-before于B操作,那么A操作的执行结果一定对B可见。即保证了内存可见性。


下面是Java内存模型下一些”天然的“happens-before关系,
这些happens-before关系无须任何同步器协助就已经存在,可以在编码中直接使用。
如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,
它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。

a.程序次序规则(Pragram Order Rule):
在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。
准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。

b.管程锁定规则(Monitor Lock Rule):
一个unlock操作先行发生于后面对同一个锁的lock操作。
这里必须强调的是同一个锁,而”后面“是指时间上的先后顺序。

c.volatile变量规则(Volatile Variable Rule):
对一个volatile变量的写操作先行发生于后面对这个变量的读取操作,
这里的”后面“同样指时间上的先后顺序。

d.线程启动规则(Thread Start Rule):
Thread对象的start()方法先行发生于此线程的每一个动作。

e.线程终于规则(Thread Termination Rule):
线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,
Thread.isAlive()的返回值等作段检测到线程已经终止执行。

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

g.对象终结规则(Finalizer Rule):
一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始。

h.传递性(Transitivity):
如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

一个操作”时间上的先发生“不代表这个操作会是”先行发生“,
那如果一个操作”先行发生“是否就能推导出这个操作必定是”时间上的先发生 “呢?
也是不成立的,一个典型的例子就是指令重排序。
所以时间上的先后顺序与happens-before原则之间基本没有什么关系,
所以衡量并发安全问题一切必须以happens-before 原则为准。

3.volatile关键字

3.1 volatile关键字概述

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
1)保证了不同线程对这个变量进行操作时的可见性,
即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
2)禁止进行指令重排序。

3.2 volatile保证有序性(防指令重排)

一句话说完就是内存屏障保证了volatile的有序性。

volatile关键字禁止指令重排序有两层意思:
1)当程序执行到volatile变量的读操作或者写操作时,
在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;
在其后面的操作肯定还没有进行。
2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,
也不能把volatile变量后面的语句放到其前面执行。
#举个例子:
// x、y为非volatile变量
// flag为volatile变量
x = 2; // 语句1
y = 0; // 语句2
volatile flag = true; // 语句3
x = 4; // 语句4
y = -1; // 语句5

#解读
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,
不会将语句3放到语句1、语句2前面,也不会将语句3放到语句4、语句5后面。
但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
并且volatile关键字能保证:
执行到语句3时,语句1和语句2必定是执行完毕了的,
且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

3.3 volatile保证可见性

// 线程1
boolean stop = false;
while(!stop){
   doSomething();
}
// 线程2
stop = true;
很多人在中断线程时可能都会采用这种标记办法。但是也有可能会导致无法中断线程造成死循环了。
在前面已经解释过,每个线程在运行过程中都有自己的工作内存,
线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。  
当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,
那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
但是用volatile修饰之后就变得不一样了:
>> 使用volatile关键字会强制将修改的值立即写入主存;
>> 使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效
(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效)
>> 由于线程1的工作内存中缓存变量stop的缓存行无效,
所以线程1再次读取变量stop的值时会去主存读取。

那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值, 然后将修改后的值写入内存), 
会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,
发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。

待探讨案例

    /**
     * 环境配置
     * java version "1.8.0_151"
     * Java(TM) SE Runtime Environment (build 1.8.0_151-b12)
     * Java HotSpot(TM) 64-Bit Server VM (build 25.151-b12, mixed mode)
     */
    private static class VolatileDemo03 {
        // 将这里改为 volatile, block1 处可以执行到, 不再是死循环了
        private static boolean flag = false;
        // 将这里的改为 volatile 或者 将 int 改为 Integer, block1 处可以执行到, 不再是死循环了
        private static int count = 0;

        public static void main(String[] args) {
            new Thread(() -> {
                try {
                    // 将这里的 sleep && 将下一行的打印语句 注释掉, block1 处可以执行到, 不再是死循环了
                    TimeUnit.SECONDS.sleep(2L);
                    flag = true;
                    System.out.println("flag 已经设置为: " + flag);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();

            while (!flag) {
                count++;
                // 在这里打印出 count 或者其他任意内容, block1 处可以执行到, 不再是死循环了
                // System.out.println(count);
            }
            // block1: 当前默认代码情况下, 这一行代码永远执行不到, 代码处于死循环中
            System.out.println("count=" + count);
        }
    }

3.4 volatile为何不能保证原子性

3.5 volatile的应用场景

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,
而volatile关键字在某些情况下性能要优于synchronized,但无法替代synchronized关键字,
因为volatile关键字无法保证操作的原子性。
通常来说,使用volatile必须具备以下2个条件:
1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
事实上,上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

几个Java中使用volatile的几个场景:
1.状态标记量
2.单例模式中的 double check

4.synchronized关键字

在 Java 早期版本,synchronized属于重量级锁,效率低下,
因为使用监视器锁(monitor)依赖于底层操作系统的互斥锁(Mutex Lock)来实现,
而 Java 的线程是映射到操作系统的原生线程之上的,
如果挂起或者唤醒一个线程,都需要操作系统从用户态切换到内核态,
这个状态之间的切换需要耗费较高的时间成本,
这也是为什么早期的synchronized效率低的原因。
synchronized使对象在同一时刻只能被一个线程访问,因此能够解决多线程之间数据同步问题。

4.1 使用方式

>> 同步普通方法:对当前对象加锁。
>> 同步静态方法:对当前Class对象加锁。
>> 同步当前对象:对当前代码块加锁。
>> 同步指定对象:对当前代码块加锁。
>> 同步指定class类:对指定的Class对象加锁。

4.2 实现原理

synchronized同步语句块的实现使用 JVM 的是monitorenter和monitorexit指令,
其中 monitorenter 指令指向同步代码块的开始位置,
 monitorexit 指令指向同步代码块的结束位置。
当执行 monitorenter 指令时,线程试图获取锁,也就是获取 monitor 的持有权。
每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。

monitor 对象存在于每个对象的对象头中,synchronized 便是通过这种方式获取锁的,
也就是为什么 Java 中任意对象可以作为锁的原因。

4.3 synchronized和ReentrantLock的区别

#两者都是可重入锁
可重入锁,已经获得锁的线程可以再次获取自己的内部锁。
比如一个线程获得了某个对象锁,此时这个对象锁还没有释放,
当这个线程再次想要获取这个对象锁的时候还是可以获取的,
如果锁不可重入的话,就会造成死锁。
同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

#依赖不同
synchronized依赖于JVM,而RennTrantLock依赖于API。
JDK1.6为 synchronized 关键字进行了很多优化,
但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。
RenntrantLock 是 JDK 层面实现,也就是 API 层面,
需要lock() 和 unlock()方法配合 try/finally 语句块来完成。

参考资料
https://www.jianshu.com/p/c672e7f52ba0
https://www.cnblogs.com/thisiswhy/p/12551295.html

上一篇 下一篇

猜你喜欢

热点阅读