Java 内存区域和Java内存模型有何区别?
这是一个非常容易让人混淆的问题,Java 内存区域和内存模型完全是不一样的东西,
Java 内存区域 , 也叫 内存区域 、 JVM内存模型 ,和 Java 虚拟机(JVM)的运行时区域相关,是指 JVM运行时将数据分区域存储,强调对内存空间的划分。
Java内存模型 ,也叫 内存模型(JMM) ,是Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,屏蔽各个操作系统的差异。通俗点说: JMM规范了程序中变量的访问规则,保证了操作的原子性、可见性、有序性, 我们下文慢慢道来。
我们知道JVM 运行时内存区域是分区域的,分为栈、堆等,其实这些都是 JVM 定义的逻辑概念。但在传统的硬件内存架构中是没有栈和堆这种概念。
其中:
图中栈可以细分为: 虚拟机栈(JVM Stacks) 和 本地方法栈(Native Method Stack)
虚拟机栈(JVM Stacks): 线程私有,它的生命周期和线程相同 ,描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个线帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每个方法从调用直至执行完成的过程,都对应着一个线帧在虚拟机栈中入栈到出栈的过程
本地方法栈(Native Method Stack): 线程私有 ,本地方法栈与虚拟机栈的作用是一样的,只不过虚拟机栈是服务Java方法的,而本地方法栈是为虚拟机调用Native方法服务的。在Java虚拟机规范中对于本地方法栈没有特殊的要求,虚拟机可以自由的实现它,因此 在Sun HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一了 。线程开始调用本地方法时,会进入 不再受 JVM 约束的世界。本地方法可以通过 JNI(Java Native Interface) 来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和 JVM 相同的能力和权限。 JNI 类本地方法最著名的应该是 System.currentTimeMillis()
堆(Heap)
虚拟机堆是Java虚拟机中内存最大的一块,是被 所有线程共享 的,在虚拟机启动时候创建,Java堆唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存,随着JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化的技术将会导致一些微妙的变化,所有的对象都分配在堆上渐渐变得不那么“绝对”了。
Java中 栈和堆 既存在于计算机的 高速缓存 中,又存在于 主存 中,所以两者并没有很直接的关系。
Java 线程与主内存的关系
Java 内存模型(JMM)抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。
在 JDK1.2 之前,Java 的内存模型实现总是从 主存 (即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存 本地内存 (比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
什么是主内存?什么是本地内存?
主内存 :所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)
本地内存 :每个线程都有一个私有的本地内存来存储共享变量的副本,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本。
Java 内存模型其实是一种规范,定义了很多东西:
所有的变量都存储在主内存(Main Memory)中。
每个线程都有一个私有的本地内存 (Local Memory),本地内存中存储了该线程以读/写共享变量的拷贝副本。
线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主内存。
不同的线程之间无法直接访问对方本地内存中的变量。
这里所讲的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。
线程间通信
线程间的通信一般有两种方式进行,一是通过 消息传递 ,二是 共享内存 。Java 线程间的通信采用的是 共享内存 方式,JMM 为共享变量提供了线程间的保障。如果两个线程都对一个共享变量进行操作,共享变量初始值为 1,每个线程都变量进行加 1,预期共享变量的值为 3。在 JMM 规范下会有一系列的操作。我们直接来看下图:
在多线程的情况下,对主内存中的共享变量进行操作可能发生线程安全问题,比如:线程 1 和线程 2 同时 对同一个共享变量进行操作,执行 +1 操作,线程 1 、线程2 读取的共享变量是否是彼此修改前还是修改后的值呢,这个是无法确定的,这种情况和CPU的高速缓存与内存之间的问题非常相似
如何实现主内存与工作内存的变量同步,为了更好的控制主内存和本地内存的交互,Java 内存模型定义了八种操作来实现:
工作内存即本地内存
重温Java 并发三大特性
原子性
原子性:即一个或者多个操作作为一个整体,要么全部执行,要么都不执行,并且操作在执行过程中不会被线程调度机制打断;而且这种操作一旦开始,就一直运行到结束,中间不会有任何上下文切换(context switch)
比如:
inti =0;//语句1,原子性i++;//语句2,非原子性
语句1大家一幕了然,语句2却许多人容易犯迷糊, i++ 其实可以分为3步:
i 被从局部变量表(内存)取出,
压入操作栈(寄存器),操作栈中自增
使用栈顶值更新局部变量表(寄存器更新写入内存)
执行上述3个步骤的时候是可以进行线程切换的,或者说是可以被另其他线程的 这3 步打断的,因此 语句2 不是一个原子性操作
在 Java 中,可以借助 synchronized 、各种 Lock 以及各种原子类实现原子性。
synchronized 和各种 Lock 是通过保证任一时刻只有一个线程访问该代码块,因此可以保证其原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile 或者 final 关键字)来保证原子操作。
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。
我们来看一个例子:
publicclassVisibilityTest{privateboolean flag =true;publicvoidchange(){ flag =false; System.out.println(Thread.currentThread().getName() +",已修改flag=false"); }publicvoidload(){ System.out.println(Thread.currentThread().getName() +",开始执行.....");inti =0;while(flag) { i++; } System.out.println(Thread.currentThread().getName() +",结束循环"); }publicstaticvoidmain(String[] args) throws InterruptedException{ VisibilityTest test =newVisibilityTest();// 线程threadA模拟数据加载场景Thread threadA =newThread(() -> test.load(),"threadA"); threadA.start();// 让threadA执行一会儿Thread.sleep(1000);// 线程threadB 修改 共享变量flagThread threadB =newThread(() -> test.change(),"threadB"); threadB.start(); }}
threadA 负责循环,threadB负责修改 共享变量flag ,如果flag=false时,threadA 会结束循环,但是上面的例子会死循环。原因是threadA无法立即读取到共享变量flag修改后的值。我们只需 private volatile boolean flag = true; 加上 volatile 关键字threadA就可以立即退出循环了。
Java中的 volatile关键字 提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。
因此,可以使用 volatile 来保证多线程操作时变量的可见性。除了 volatile ,Java中的 synchronized 和 final 两个关键字 以及各种 Lock也可以实现可见性。
有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
inti =0;intj =0;i =10;//语句1j =1;//语句2
但由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。语句可能的执行顺序如下:
语句1 语句2
语句2 语句1
指令重排对于非原子性的操作,在不影响最终结果的情况下,其拆分成的原子操作可能会被重新排列执行顺序。 指令重排不会影响单线程的执行结果,但是会影响多线程并发执行的结果正确性 。