文章目录
- JAVA内存模型
- JVM主内存与工作内存描述
- JVM内存间交互规则
- JVM先行发生原则
- 内存交互基本操作的 3 个特性
- 原子性(Atomicity)
- 可见性(Visibility)
- 有序性(Ordering)
- 上述内存模型与Java多线程之间的问题
JAVA内存模型
- 共享变量:如果一个变量在
多个线程的工作内存
中都存在副本,那么这个变量就是这几个线程的共享变量。 - 上面的工作内存其实是java内存模型抽象出来的概念,下面简要介绍一下java内存模型(JMM)。
java内存模型(java memory model): 描述了java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。
不同的平台,内存模型是不一样的,我们可以把内存模型理解为
在特定操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。
Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,简称 JMM)来屏蔽掉各种硬件和操作系统的内存访问差异
,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,不必因为不同平台上的物理机的内存模型的差异,对各平台定制化开发程序。
更具体一点说,Java 内存模型提出目标在于,定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
从上图可以得出结论:
所有
的变量都存储在主内存
中每个
线程都有自己独立的工作内存
,里面保存该线程使用到的变量的副本(主内存该变量的一份拷贝)。
JMM关于synchronized的两条规定:
- 线程解锁前,必须把
共享变量的最新值刷新到主内存中
- 线程加锁时,将
清空工作内存中共享变量的值
,从而使用
共享变量时需要从主内存中重新读取最新的值
。(加锁和解锁需要是同一把锁)
线程解锁前对共享变量的修改在下次加锁前对其他线程可见。
JVM主内存与工作内存描述
JVM将内存为主内存
和工作内存
两个部分。
主内存: 主要包括本地方法区
和 堆
- Java 内存模型规定了
所有变量都存储在主内存(Main Memory)中
(此处的主内存与介绍物理硬件的主内存名字一样,两者可以互相类比,但此处仅是虚拟机内存的一部分)。
工作内存: 每个线程
都有一个工作内存,工作内存中主要包括两个部分,一个是属于该线程私有的栈
和 对主存部分变量拷贝的寄存器
(包括程序计数器PC和cup工作的高速缓存区)。
- 每个线程都有自己的
工作内存(Working Memory,又称本地内存.)
,线程的工作内存中保存了该线程使用到的变量,该变量是主内存中的共享变量的副本拷贝
。 - (工作内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。)
线程执行的时候,将首先从主内存读值
,再load到工作内存中的副本
中,然后传给处理器
执行,执行完毕后再给工作内存中的副本赋值
,随后工作内存再把值传回给主存
,主存中的值才更新。
在这个过程中如果出现多个线程同时在处理这些值,岂不是会出现并发问题?
所有的变量
都存储在主内存
中(虚拟机内存的一部分),对于所有线程都是共享
的每个线程都有自己的工作内存,工作内存中保存的是主存中某些变量的值的副本拷贝
,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量
。- 线程之间
无法直接访问对方的工作内存中的变量值的
,线程间变量的传递均需要通过主内存
来完成。
这种划分与Java运行时内存区域中堆、栈、元空间等的划分是不同层次的划分,两者基本没有关系。硬要联系的话,大致上主内存对应Java堆中对象的实例数据部分、工作内存对应栈的部分区域;从更低层次上说,主内存对应物理硬件内存、工作内存对应寄存器和高速缓存。
JVM内存间交互规则
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定义了下面 8 种操作来完成。
- Lock(锁定):作用于主内存中的变量,把一个变量标识为被一个线程独占的状态。
- Unlock(解锁):作用于主内存中的变量, 将一个变量从锁定状态(Lock)释放出来,释放后的变量才可以被其他线程锁定(Lock)
Read(读取)
:作用于主内存中的变量,将一个变量的值从主内存传输到工作内存中,以便随后的load操作使用Load(加载)
:作用于工作内存中的变量,把read操作从主内存中得到的变量的值放入工作内存的变量副本中。- Use(使用):作用于工作内存中的变量,把工作内存中一个变量的值传递给执行引擎。(每当虚拟机遇到一个需要使用到变量的值的字节码指令时就会执行这个操作。)
- Assign(赋值):作用于工作内存中的变量,把一个从执行引擎接收到的值赋值给工作内存中的变量。(每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。)
Store(存储)
:作用于工作内存中的变量,把工作内存中的一个变量的值传送到主内存中,以便随后 write 操作使用。Write(写入)
:作用于主内存中的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。
需知:
- 在将变量从主内存读取到工作内存中,必须顺序执行
read(读取)、load(加载)
; - 要将变量从工作内存同步回主内存中,必须顺序执行
store(存储)、write(写入)
。
这8种操作必须遵循以下规则:
-
不允许
read(读取)和load(加载)、store(存储)和write(写入)
操作之一单独出现
。即不允许一个变量从主内存被读取了,但是工作内存不接受,或者从工作内存回写了但是主内存不接受。不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步会主内存中
-
不允许一个线程丢弃它最近的一个
assign(赋值)
操作,即变量在工作内存被更改后必须同步改更改回主内存。
即:执行store(存储),write(写入)操作 -
工作内存中的变量在没有执行过
assign(赋值)
操作时,不允许无意义的同步回主内存。 即:执行store(存储),write(写入)操作 -
在执行
use(使用)
前必须已执行load(加载)
,在执行store(存储)
前必须已执行assign(赋值)
。 -
一个变量在
同一时刻
只允许一个线程对其执行lock操作
,一个线程可以对同一个变量重复执行多次lock,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。 -
一个线程在
lock
一个变量的时候,将会清空
工作内存中的此变量的值,执行引擎在use(使用)
前必须重新read(读取)和load(加载)
初始化变量的值。 -
在执行
unlock
之前,必须首先执行了store(存储)和write(写入)
操作对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)
-
线程不允许
unlock
其他线程的lock
操作。并且unlock操作必须是在本线程的lock操作之后。如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
从上面可以看出,把变量从主内存复制到工作内存需要顺序执行read、load
,从工作内存同步回主内存则需要顺序执行store、write`。总结:
- read、load、use必须成对顺序出现,但不要求连续出现。assign、store、write同之;
- 变量诞生和初始化:变量只能从主内存“诞生”,且须先初始化后才能使用,即在use/store前须先load/assign;
- lock一个变量后会清空工作内存中该变量的值,使用前须先初始化;unlock前须将变量同步回主内存;
- 一个变量同一时刻只能被一线程lock,lock几次就须unlock几次;未被lock的变量不允许被执行unlock,一个线程不能去unlock其他线程lock的变量。
JVM先行发生原则
Java内存模型具备一些先天的“有序性”,即不需要通过任何同步手段(volatile、synchronized等)就能够得到保证的有序性,这个通常也称为happens-before原则。
如果2个操作的执行次序不符合先行原则且无法从happens-before
原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
- 程序次序规则(Program Order Rule):一个线程内,逻辑上书写在前面的操作先行发生于书写在后面的操作。
- 锁定规则(Monitor Lock Rule):一个unLock操作先行发生于后面对同一个锁的lock操作。“后面”指时间上的先后顺序。
- volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作。“后面”指时间上的先后顺序。
- 传递规则(Transitivity):如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每个一个动作。
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生(通过Thread.interrupted()检测)。
- 线程终止规则(Thread Termination Rule):线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
- 对象终结规则(Finaizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于他的finalize()方法的开始。
内存交互基本操作的 3 个特性
Java 内存模型是围绕着在并发过程中如何处理这 3 个特性来建立的.
原子性(Atomicity)
原子性, 即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
- 示例方法:
{i++ (i为实例变量)}
这样一个简单语句主要由3个操作组成:
- 读取变量i的值
- 进行加一操作
- 将新的值赋值给变量i
如果对实例变量i的操作不做额外的控制,那么多个线程同时调用,就会出现覆盖现象,丢失部分更新。
另外,如果再考虑上工作内存和主存之间的交互,可细分为以下几个操作:
- read 从主存读取到工作内存 (非必须)
- load 赋值给工作内存的变量副本(非必须)
- use 工作内存变量的值传给执行引擎执行引擎执行加一操作
- assign 把从执行引擎接收到的值赋给工作内存的变量
- store 把工作内存中的一个变量的值传递给主内存(非必须)
- write 把工作内存中变量的值写到主内存中的变量(非必须)
可见性(Visibility)
-
可见性
:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
- 正如上面“交互操作流程”中所说明的一样,JMM 是通过在线程 1 变量工作内存修改后将新值同步(store,write)回主内存,线程 2 在变量读取前从主内存刷新(read,load)变量值,将主内存作为传递媒介的方式来实现可见性。
- 存在可见性问题的根本原因:是由于缓存的存在,
线程持有的是共享变量的副本,无法感知其他线程对于共享变量的更改,导致读取的值不是最新的
while (flag) {//语句1doSomething();//语句2 }flag = false;//语句3
- 线程1判断flag标记,满足条件则执行语句2;线程2将flag标记置为false,但由于可见性问题,线程1无法感知,就会一直循环处理语句2
有序性(Ordering)
-
有序性:即
程序执行的顺序按照代码的先后顺序执行
-
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性
由于编译重排序和指令重排序的存在,是的程序真正执行的顺序不一定是跟代码的顺序一致,这种情况在多线程情况下会出现问题。
if (inited == false) { context = loadContext(); //语句1inited = true; //语句2 } doSomethingwithconfig(context); //语句3
- 由于语句1和语句2没有依赖性,
语句1和语句2可能 并行执行 或者 语句2先于语句1执行
,如果这段代码2个线程
同时执行,线程1
执行了语句2
,而语句1
还没有执行完,这个时候线程2
判断inited为true
,则执行语句3
,但由于context
没有初始化完成,则会导致出现未知的异常。
上述内存模型与Java多线程之间的问题
java的多线程并发问题最终都会反映在java的内存模型上
,所谓线程安全无非是要控制多个线程对某个资源的有序访问或修改
。总结java的内存模型,要解决两个主要的问题:可见性 和 有序性。
那么,何谓可见性?
- 多个线程之间是
不能互相传递数据通信
的,它们之间的沟通只能通过共享变量来
进行。Java内存模型(JMM)
规定了jvm有主内存,主内存是多个线程共享的
。当new一个对象的时候,也是被分配在主内存中,每个线程都有自己的工作内存
,工作内存存储了主存的某些对象的副本
,当然线程的工作内存大小是有限制
的。
共享变量可见性实现的原理:
线程1对共享变量的修改要想被线程2及时看到,必须要经过如下几个步骤:
- 将工作内存1中更新过的共享变量更新到主内存中(store-write)
- 将主内存中最新的共享变量的值更新到工作内存2中(read-load)
当线程操作某个对象时,执行顺序如下
:
- 从主存复制变量到当前工作内存 (
read(读取)-load(加载)
) - 执行代码,改变共享变量值 (
use(使用)-assign(赋值)
) - 用工作内存数据刷新主存相关内容 (
store(存储)-write(写入)
)
当一个共享变量在多个线程
的工作内存中都有副本
时,如果一个线程修改了这个共享变量,那么其他线程应该能够看到这个被修改后的值
,这就是多线程的可见性
问题 。 java中volatile解决了可见性问题,接下来看一下volatile关键字:
volatile关键字
- volatile是java提供的一种同步手段,只不过它是
轻量级的同步
,为什么这么说,因为volatile只能保证多线程的内存可见性
,不能保证多线程的执行有序性
。而最彻底的同步
要保证有序性和可见性,例如synchronized
。 任何被volatile修饰的变量,都不拷贝副本到工作内存(read-load),任何修改都及时写在主存。
因此对于volatile修饰的变量的修改,所有线程马上就能看到
,但是volatile不能保证对变量的修改是有序
的。
什么意思呢?假如有这样的代码:
public class Test{ public volatile int a; public void add(int count){ a = a+count; }
}
- 当一个
Test对象
被多个线程共享,a的值不一定是正确的,因为 a=a+count 包含了好几步操作,而此时多个线程的执行是无序的,因为没有任何机制来保证多个线程的执行有序性和原子性
。volatile存在的意义是,任何线程对 a 的修改,都会马上被其他线程读取到
,因为直接操作主存,没有线程对工作内存和主存的同步。所以,volatile的使用场景是有限的,在有限的一些情形下可以使用 volatile 变量替代锁。
要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中
volatile只保证了可见性,所以volatile适合直接赋值的场景,如:
public class Test{ public volatile int a; public void setA(int a){ this.a=a; }
}
- 在没有volatile声明时,多线程环境下,a 的最终值不一定是正确的,因为 this.a=a; 涉及到给 a 赋值和将 a 同步回主存的步骤,这个顺序可能被打乱。如果用volatile声明了,读取主存副本到工作内存和同步a到主存的步骤,相当于是一个原子操作。所以简单来说,volatile适合这种场景:
一个变量被多个线程共享,线程直接给这个变量赋值。
这是一种很简单的同步场景,这时候使用volatile的开销将会非常小。
使用volatile前后示意图
从上图可以直观的看到volatile的实现可见性的原理,线程对变量读取/写入的时候,直接从主内存中读,而不是从线程的工作内存。也就避免了其他线程操作时修改了变量的值。
实现原理
volatile底层是通过cpu提供的
内存屏障指令
来实现的。硬件层的内存屏障分为两种:Load Barrier
和Store Barrier
即读屏障和写屏障。
.
内存屏障有2个作用:
- 阻止屏障两侧的指令重排序
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效
那么继续说什么是有序性呢?
多个线程执行时,CPU对线程的调度是随机的,我们不知道当前程序被执行到哪步就切换到了下一个线程
- 线程在引用变量时不能直接从主内存中引用 , 如果线程工作内存中没有该变量 , 则会从主内存中拷贝一个副本到工作内存中,这个过程为
(read(读取)-load()加载)
,完成后线程会引用该副本, - 线程不能直接为主存中字段赋值,它会将值指定给工作内存中的变量副本
(assign(赋值))
,完成后这个变量副本会同步到主存储区进行(store(存储)-write(写入))
操作,至于何时同步过去,根据JVM实现系统决定。
这里看一个最经典的例子就是银行汇款问题
- 一个银行账户存款100,这时一个人从该账户取10元,同时另一个人向该账户汇10元,那么余额应该还是100。
- 那么此时可能发生这种情况,A线程负责取款,B线程负责汇款,A从主内存读到100,B从主内存读到100,A执行减10操作,并将数据刷新到主内存,这时主内存数据100-10=90,而B内存执行加10操作,并将数据刷新到主内存,最后主内存数据100+10=110,显然这是一个严重的问题,
我们要保证A线程和B线程有序执行,先取款后汇款或者先汇款后取款。
这里将一个非原子操作进行分解分步说明: 假设有一个共享变量 x,线程Thread1 执行 x=x+1 。从上面的描述中可以知道 x=x+1 并不是一个原子操作,它的执行过程如下:
- 从主存中读取变量x副本到工作内存
- 给x加1
- 将x加1后的值写回主存
如果另外一个线程b执行x=x-1,执行过程如下:
- 从主存中读取变量x副本到工作内存
- 给x减1
- 将x减1后的值写回主存
那么显然,最终的x的值是不可靠的。假设x现在为10,线程a加1,线程b减1,从表面上看,似乎最终x还是为10,但是多线程情况下会有这种情况发生:
- 线程a从主存读取x副本到工作内存,工作内存中x值为10
- 线程b从主存读取x副本到工作内存,工作内存中x值为10
- 线程a将工作内存中x加1,工作内存中x值为11
- 线程a将x提交主存中,主存中x为11
- 线程b将工作内存中x值减1,工作内存中x值为9
- 线程b将x提交到中主存中,主存中x为9,同样,x有可能为11,每次执行的结果都是不确定的,因为线程的执行顺序是不可预见的。这是java同步产生的根源,
synchronized关键字保证了多个线程对于同步块是互斥的
,synchronized
作为一种同步手段,解决java多线程的执行有序性和内存可见性
,而volatile
关键字之解决多线程的内存可见性问题
。
synchronized关键字
上面说了,java用synchronized关键字做为多线程并发环境的执行有序性的保证手段之一
。当一段代码会修改共享变量,这一段代码成为互斥区或临界区,为了保证共享变量的正确性,synchronized标示了临界区。典型的用法如下:
synchronized(锁){ 临界区代码
}
为了保证银行账户的安全,可以操作账户的方法如下:
public synchronized void add(int putMoney) { money = money+ putMoney;
}
public synchronized void minus(int getMoney) { money = money - getMoney;
}
刚才不是说了synchronized的用法是这样的吗:
synchronized(锁){
临界区代码
}
那么对于public synchronized void add(int putMoney)
这种情况,意味着什么呢?其
实这种情况,锁就是这个方法所在的对象。同理,如果方法是public static synchronized void add(int putMoney)
,那么锁就是这个方法所在的class。
理论上,每个对象都可以做为锁,但一个对象做为锁时,应该被多个线程共享,这样才显得有意义,在并发环境下,一个没有共享的对象作为锁是没有意义的. 假如有这样的代码:
public class ThreadTest{ public void test(){ Object lock=new Object(); synchronized (lock){ //do something } }
}
lock变量
作为一个锁存在根本没有意义,因为它根本不是共享对象,每个线程进来都会执行Object lock=new Object(); 每个线程都有自己的lock,根本不存在锁竞争。
-
每个锁对象都有两个队列
,一个是就绪队列
,一个是阻塞队列
,就绪队列存储了将要获得锁的线程
,阻塞队列存储了被阻塞的线程
,当一个被线程被唤醒(notify)后,才会进入到就绪队列,等待cpu的调度。 -
当一开始线程a 第一次执行account.add方法时,jvm会检查锁对象account的就绪队列是否已经有线程在等待,如果有则表明account的锁已经被占用了,由于是第一次运行,account的就绪队列为空,所以线程a获得了锁,执行account.add方法。如果恰好在这个时候,线程b要执行account.minus方法,因为线程a已经获得了锁还没有释放,所以线程b要进入account的就绪队列,等到得到锁后才可以执行。
一个线程执行临界区代码过程如下:
- 获得同步锁
- 清空工作内存
- 从主内存拷贝变量的最新副本到工作内存
(read(读取)-load(加载))
- 执行代码
(use(使用)-assign(赋值))
- 将更改后的共享变量的值刷新到主内存
(store(存储)-write(写入))
- 释放锁
可见,synchronized既保证了多线程的并发有序性,又保证了多线程的内存可见性,但同时也降低了程序的性能
参考:JMM详细介绍