标签:加锁 自身 跳转 了解 his var name 要求 也会
深度好文推荐:
Java内存模型是围绕着并发过程中如何处理原子性、可见性和顺序性这三个特征来设计的。
主要目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。可以保证并发编程场景中的原子性、可见性和有序性。
同步:在发出一个功能调用的时候,在没有得到响应之前,该调用就不返回;
异步:在发出一个功能调用的时候,不需要等待响应,继续进行它该做的事情,一旦得到响应了过后给予一定的处理。
了解jvm内存模型前,了解下cpu和计算机内存的交互情况。【因为Java虚拟机内存模型定义的访问操作与计算机十分相似】
在计算机中,cpu和内存的交互最为频繁,相比内存,磁盘读写太慢,内存相当于高速的缓冲区。但是随着cpu的发展,
内存的读写速度也远远赶不上cpu。因此cpu厂商在每颗cpu上加上高速缓存,用于缓解这种情况。
cpu上加入了高速缓存这样做解决了处理器和内存的矛盾(一快一慢),但是引来的新的问题 - 缓存一致性
如何保证多个处理器运算涉及到同一个内存区域时,多线程场景下会存在缓存一致性问题,那么运行时保证数据一致性?
为了解决这个问题,各个处理器需遵循相关的缓存一致性协议【MSI、MESI协议等】来保证一致性。
cpu缓存与内存多级分布图
cpu缓存与内存交互图
缓存的工作原理是当CPU要读取一个数据时,首先从缓存中查找,如果找到就立即读取并送给CPU处理;如果没有找到,就用相对慢的速度内存中读取并送给CPU处理,同时把这个数据所在的数据块调入缓存中,可以使得以后对整块数据的读取都从缓存中进行,不必再调用内存。 正是这样的读取机制使CPU读取缓存的命中率非常高(大多数CPU可达90%左右),也就是说CPU下一次要读取的数据90%都在缓存中,大约10%需要从内存读取。 这大大节省了CPU直接读取内存的时间,也使CPU读取数据时基本无需等待。总的来说,CPU读取数据的顺序是先缓存后内存。 按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存,二级缓存,部分高端CPU还具有三级缓存,每一级缓存中所储存的全部数据都是下一级缓存的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。 当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。一般来说,每级缓存的命中率大概都在80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取,由此可见一级缓存是整个CPU缓存架构中最为重要的部分
在多核cpu中,每个处理器都有各自的高速缓存(L1,L2,L3),而主内存确只有一个 。
CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找,每个cpu有且只有一套自己的缓存。
为了解决不同cpu间缓存不一致的问题,在cpu层面提供了一个充分必要条件:缓存屏障(Memory Barrier)。
Load Barrier
和 Store Barrier
即读屏障和写屏障。【内存屏障是硬件层的】为什么需要内存屏障? 由于现代操作系统都是多处理器操作系统,每个处理器都会有自己的缓存,可能存再不同处理器缓存不一致的问题,而且由于操作系统可能存在重排序,导致读取到错误的数据,
因此,操作系统提供了一些内存屏障以解决这种问题. 简单来说: 1.在不同CPU执行的不同线程对同一个变量的缓存值不同,为了解决这个问题。 2.用volatile可以解决上面的问题,不同硬件对内存屏障的实现方式不一样。java屏蔽掉这些差异,volatile关键字可以让jvm执行生成内存屏障的指令。 对于读屏障:在指令前插入读屏障,可以让高速缓存中的数据失效,强制从主内存取。 内存屏障的作用? cpu执行指令可能是无序的,它有两个比较重要的作用 1.禁止指令重排序:阻止屏障两侧指令重排序 2.可见性:强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
当我们声明某个变量为volatile修饰时,这个变量就有了线程可见性,volatile通过在读写操作前后添加内存屏障。
摘自《深入理解Java虚拟机》: “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令” lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能: 1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成; 2)它会强制将对缓存的修改操作立即写入主存; 3)如果是写操作,它会导致其他CPU中对应的缓存行无效
用代码展示相当于:
//相当于读写时加锁,保证及时可见性,并发时不被随意修改。 public class SynchronizedInteger { private long value; public synchronized int get() { return value; } public synchronized void set(long value) { this.value = value; } }
volatile型变量拥有如下特性:
可见性,对于一个该变量的读,一定能看到读之前最后的写入。
原子性,对volatile变量的读写具有原子性,即单纯读和写的操作,都不会受到干扰。
volatile提供的功能: 1.那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新 2.
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中
简单的说,就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。
臭名昭著的双重检查(其中一种单例模式),是一种延迟初始化的实现技巧,避免了同步的开销,因为在早期的JVM,同步操作性能很差,所以才出现了这样的小技巧。
private static Something instance = null; public Something getInstance() { if (instance == null) { synchronized (this) { if (instance == null) instance = new Something(); } } return instance; }
Java 内存模型还会对指令进行重排序操作,重排序可以发生在好几个地方:编译器、运行时、JIT等,在执行程序时为了提高性能编译器和处理器经常会对指令进行重排序操作,重排序主要分下面几类:
编译器优化重排序:编译器在不改变单线程程序语义的前提下可以重新安排语句的执行顺序。
指令级并行重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性处理器可以改变语句对应机器指令的执行顺序。
内存系统重排序:由于处理器使用缓存和读写缓冲区使得加载和存储操作看上去可能是在乱序执行。
Java内存模型的主要目标是定义程序中变量的访问规则。即在虚拟机中将变量存储到主内存或者将变量从主内存取出这样的底层细节。注意:这里的变量是指实例字段,静态字段,构成数组对象的元素,但是不包括局部变量和方法参数(因为这是线程私有的)。
java虚拟机中主内存和工作内存交互,就是一个变量如何从主内存传输到工作内存中,如何把修改后的变量从工作内存同步回主内存。
如果要把一个变量从主内存传输到工作内存,那就要顺序的执行read和load操作,如果要把一个变量从工作内存回写到主内存,就要顺序的执行store和write操作。
对于普通变量,虚拟机只是要求顺序的执行,并没有要求连续的执行,所以如下也是正确的。
对于两个线程,分别从主内存中读取变量a和b的值,并不一样要read a; load a; read b; load b; 也会出现如下执行顺序:read a; read b; load b; load a;
(对于volatile修饰的变量会有一些其他规则,后边会详细列出),对于这8中操作,虚拟机也规定了一系列规则,在执行这8中操作的时候必须遵循如下的规则:
当然,最重要的还是如开始所说,这8个动作必须是原子的,不可分割的。针对volatile修饰的变量,会有一些特殊规定。
关键字volatile可以说是java虚拟机中提供的最轻量级的同步机制。java内存模型对volatile专门定义了一些特殊的访问规则。这些规则有些晦涩拗口,先列出规则,然后用更加通俗易懂的语言来解释:
假定T表示一个线程,V和W分别表示两个volatile修饰的变量,那么在进行read、load、use、assign、store和write操作的时候需要满足如下规则:
总结上面三条规则,前面两条可以概括为:volatile类型的变量保证对所有线程的可见性。第三条为:volatile类型的变量禁止指令重排序优化。
public class VolatileTest { public static volatile int race = 0; public static void increase() { race++ } private static final int THREADS_COUNT = 20; public void static main(String[] args) { Thread[] threads = new Thread[THREADS_COUNT); for (int = 0; i < THREADS_COUNT; i++) { threads[i] = new Thread(new Runnable(){ @Override public void run() { for (int j = 0; j < 10000; j++) { increase(); } } }); threads[i].start(); } while (Thread.activeCount() > 1) { Thread.yield(); } System.out.println(race); } }
race++
操作不是原子性的,是分多个步骤完成的。假设两个线程a、b同时取到了主内存的值,是0,这是没有问题的,在进行++
操作的时候假设线程a执行到一半,线程b执行完了,这时线程b立即同步给了主内存,主内存的值为1,而线程a此时也执行完了,同步给了主内存,此时的值仍然是1,线程b的结果被覆盖掉了。Map configOptions; char[] configText; //volatile类型变量 volatile boolean initialized = false; //假设以下代码在线程A中执行 //模拟读取配置信息,读取完成后认为是初始化完成 configOptions = new HashMap(); configText = readConfigFile(fileName); processConfigOptions(configText, configOptions);
// 要确保initialized置为true是在前三行代码执行后,即将配置信息放入map中后。否则,JVM可能会对指令进行重排序,导致map值尚未存入,而initialized已经置为true. initialized = true; //假设以下代码在线程B中执行 //等待initialized为true后,读取配置信息进行操作 while ( !initialized) { sleep(); } doSomethingWithConfig();
initialized = true
就有可能先于之前的三行代码执行,而此时线程B发现initialized为true了,就执行doSomethingWithConfig()
方法,但是里面的配置信息都是null的,就会出现问题了。initialized = true
执行的时候,前边的三行代码一定执行完成了,那么线程B读取的配置文件信息就是正确的。Java内存模型要求对主内存和工作内存交换的八个动作是原子的,正如章节开头所讲,对long和double有一些特殊规则。八个动作中lock、unlock、read、load、use、assign、store、write对待32位的基本数据类型都是原子操作,对待long和double这两个64位的数据,java虚拟机规范对java内存模型的规定中特别定义了一条相对宽松的规则:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,也就是允许虚拟机不保证对64位数据的read、load、store和write这4个动作的操作是原子的。这也就是我们常说的long和double的非原子性协定(Nonautomic Treatment of double and long Variables)。
在一个线程内部,按照代码的顺序,书写在前面的先行发生与后边的。或者更准确的说是在控制流顺序前面的先行发生与控制流后面的,而不是代码顺序,因为会有分支、跳转、循环等。
一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须注意的是对同一个锁,后面是指时间上的后面
对一个volatile变量的写操作先行发生与后面对这个变量的读操作,这里的后面是指时间上的先后顺序
Thread对象的start()方法先行发生与该线程的每个动作。当然如果你错误的使用了线程,创建线程后没有执行start方法,而是执行run方法,那此句话是不成立的,但是如果这样其实也不是线程了
线程中的所有操作都先行发生与对此线程的终止检测,可以通过Thread.join()和Thread.isAlive()的返回值等手段检测线程是否已经终止执行
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
一个对象的初始化完成先行发生于他的finalize方法的执行,也就是初始化方法先行发生于finalize方法
如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。
时间先后顺序与先行发生原则之间基本没有太大关系。我们衡量并发安全的问题的时候不要受到时间先后顺序的干扰,一切以先行发生原则为准。
Java内存模型
Java内存模型把Java虚拟机内部划分为线程栈和堆。
每一个运行在Java虚拟机里的线程都拥有自己的线程栈。这个线程栈包含了这个线程调用的方法当前执行点相关的信息。一个线程仅能访问自己的线程栈。
一个线程创建的本地变量对其它线程不可见,仅自己可见。
即使两个线程执行同样的代码,这两个线程任然在在自己的线程栈中的代码来创建本地变量。因此,每个线程拥有每个本地变量的独有版本。
所有原始类型的本地变量都存放在线程栈上,因此对其它线程不可见。一个线程可能向另一个线程传递一个原始类型变量的拷贝,但是它不能共享这个原始类型变量自身。
堆上包含在Java程序中创建的所有对象,无论是哪一个对象创建的。这包括原始类型的对象版本。
如果一个对象被创建然后赋值给一个局部变量,或者用来作为另一个对象的成员变量,这个对象任然是存放在堆上。
一个本地变量可能是原始类型,在这种情况下,它总是“呆在”线程栈上。
一个本地变量也可能是指向一个对象的一个引用。在这种情况下,引用(这个本地变量)存放在线程栈上,但是对象本身存放在堆上。
一个对象可能包含方法,这些方法可能包含本地变量。这些本地变量任然存放在线程栈上,即使这些方法所属的对象存放在堆上。
一个对象的成员变量可能随着这个对象自身存放在堆上。不管这个成员变量是原始类型还是引用类型。
静态成员变量跟随着类定义一起也存放在堆上。
存放在堆上的对象可以被所有持有对这个对象引用的线程访问。当一个线程可以访问一个对象时,它也可以访问这个对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,它们将会都访问这个对象的成员变量,但是每一个线程都拥有这个本地变量的私有拷贝。
现代计算机硬件架构的简单图示 Java内存模型和硬件内存架构之间的桥接
并发内存模型的实质:Java内存模型围绕着并发过程中如何处理原子性、可见性和顺序性这三个特征来设计的。
由Java内存模型来直接保证原子性的变量操作包括read、load、use、assign、store、write这6个动作,虽然存在long和double的特例,但基本可以忽律不计,目前虚拟机基本都对其实现了原子性。如果需要更大范围的控制,lock和unlock也可以满足需求。lock和unlock虽然没有被虚拟机直接开给用户使用,但是提供了字节码层次的指令monitorenter和monitorexit对应这两个操作(而这两个正是synchronized关键字在字节码层次上实现的操作),对应到java代码就是synchronized关键字,因此在synchronized块之间的代码都具有原子性。
原子性操作不能被打断,要么执行完,要么不执行。
可见性是指一个线程修改了一个变量的值后,其他线程立即可以感知到这个值的修改。正如前面所说,volatile类型的变量在修改后会立即同步给主内存,在使用的时候会从主内存重新读取,是依赖主内存为中介来保证多线程下变量对其他线程的可见性的。
除了volatile,synchronized和final也可以实现可见性。synchronized关键字是通过unlock之前必须把变量同步回主内存来实现的,final则是在初始化后就不会更改,所以只要在初始化过程中没有把this指针传递出去也能保证对其他线程的可见性。
有序性从不同的角度来看是不同的。单纯单线程来看都是有序的,但到了多线程就会跟我们预想的不一样。可以这么说:如果在本线程内部观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句说的就是“线程内表现为串行的语义”,后半句值得是“指令重排序”现象和主内存与工作内存之间同步存在延迟的现象。
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java中,可以使用synchronized
和volatile
来保证多线程之间操作的有序性。实现方式有所区别:
volatile
关键字会禁止指令重排。synchronized
关键字保证同一时刻只允许一条线程操作。
如果想让 Java 并发程序正确的执行必须保证原子性、有序性、可见性,只要三者中有任意一个不满足并发都无法正确执行。
保证有序性的关键字有volatile和synchronized,volatile禁止了指令重排序,而synchronized则由“一个变量在同一时刻只能被一个线程对其进行lock操作”来保证。
总体来看,synchronized对三种特性都有支持,虽然简单,但是如果无控制的滥用对性能就会产生较大影响。
参考来源:
标签:加锁 自身 跳转 了解 his var name 要求 也会
原文地址:https://www.cnblogs.com/guhaibing/p/12196558.html