JAVA内存模型
Java内存模型
概述
Java Memory Model,Java中因为不同的目的可以将Java划分为三种内存模型,gc内存模型、并发内存模型、Java对象模型
gc内存模型
简述
整体上,分为3部分:栈,堆,程序计数器。
他们每一部分有其各自的用途,虚拟机栈保存着每一条线程的执行程序调用堆栈;堆保存着类对象、数组的具体信息;程序计数器保存着每一条线程下一次执行指令位置。
这三块区域中栈和线程计数器是线程私有的,也就是说没一个线程拥有其独立的栈和程序计数器。
具体分类
虚拟机栈
首先,虚拟机栈和本地方法栈是两个不同的内存区域,他们的主要区别在于服务的对象和执行的代码类型。
- 虚拟机栈 每个Java线程在创建时都会分配一个虚拟机栈。
- 主要用于管理Java方法的调用和执行,包括局部变量、操作数栈、动态链接和方法返回地址等信息。
- 保存基本数据类型
- 保存了对象的引用
- 编译时就确定了大小,在运行时这个大小不会改变
总的来说,虚拟机栈负责管理Java方法执行,而本地方法栈负责管理通过JNI调用的本地方法的执行。
当栈空间不足或者超过了其允许的最大深度,可能会抛出StackOverflowError异常。如果堆外内存不足,无法扩展虚拟机栈,可能会抛出OutOfMemoryError异常。
本地方法栈(Native Method Stack)
- 主要服务于(Java Native Interface) 调用的本地(Native)方法,这些方法通常是用C、C++等非Java语言编写的。
- 在栈中,会为每一个线程创建一个栈,线程越多,栈的内存使用越大。
对于每一个线程栈,当一个方法在线程中执行的时候,会在线程栈中创建一个栈帧(stack frame),用于存放该方法的上下文(局部变量表、操作数栈、方法返回地址等等)。每一个方法从调用到执行完毕的过程,就是对应着一个栈帧入栈出栈的过程。
方法区
- 与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。很多人都更愿意把方法区称为“永久代”(Permanent Generation)。从jdk1.7已经开始准备“去永久代”的规划,jdk1.7的HotSpot中,已经把原本放在方法区中的静态变量、字符串常量池等移到堆内存中。
- 方法区也是所有线程共享,主要用于存储类的信息、常量池、方法数据、方法代码等。
- 方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”
元空间
在jdk1.8中,永久代已经不存在,存储类信息、编译后的代码数据等已经移动到了元空间(MetaSpace)中,元空间并没有处于堆上,而是直接占用的本地内存(Native Memory)。
元空间的本质和永久代类似,都是对jvm规范中方法去的实现。不过元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存,因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过一下参数来制定元空间的大小。
- -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
- -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。
堆
整个内存占用最大的,内存占用最多的
存放对象的实例对象
运行时动态分配
程序计数器
程序计数器是一个线程私有的内存区域,它主要用于存储当前线程正在执行的字节码指令的地址或偏移量。
并发内存模型
Java试图定义一个Java内存模型来屏蔽掉各种硬件/操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的内存访问效果。
Java并发内存模型以及内存操作规则
Java内存模型中规定了所有变量都存储到主内存(如虚拟机物理内存的一部分)中,每一个线程都有一个自己的工作内存(如cpu中的高速缓存)。线程中的工作内存保存了该线程使用到的变量的主内存的副本拷贝。线程对变量的所有操作(读取、赋值等)必须在该线程的工作内存中进行。线程间变量的值传递进需要通过主内存来完成。
需要注意的是,工作内存是一个抽象的概念,它并不对应于操作系统或硬件中的任何特定内存区域。它是Java内存模型为了更好地描述和控制并发环境下的内存行为而引入的一个概念。在实际的Java虚拟机(JVM)实现中,工作内存的操作可能涉及到处理器缓存、寄存器以及其他优化技术。
关于主内存与工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存。如何从工作内存同步到主内存中的实现细节,Java内存模型定义了8种操作来完成,这8种操作每一种都是原子操作,8种操作如下:
- lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;
- unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定;
- read(读取):作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用;
- load(载入):作用于工作内存,它把read操作的值放入工作内存中的变量副本中;
- use(使用):作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作;
- assign(赋值):作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作;
- store(存储):作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用;
- write(写入):作用于主内存,它把store传送值放到主内存中的变量中。
Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现,以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。
- 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
- 如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。
volatile型变量的特殊规则
当一个变量被定义成功volatile后,他将具备两种特性:
- 保证此变量对所有线程的可见性。这里的可见性是指当一个线程修改了这个变量的值,新指对于其他线程来说是可以立即得知的。而普通变量是做不到这点的。
- 禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获得正确的结果,可不能保证变量赋值操作的顺序与程序中的执行顺序一致,在单线程中,我们是无法感知这一点的。
原子性、可见性与有序性
Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的。
- 原子性(Atomicity):由Java内存模型来直接保证的原子性变量包括read、load、assign、use、store和write,我们大致可以认为基本数据类型的访问读写是具备原子性的。如果应用场景需要一个更大方位的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式的使用这两个操作,这两个字节码指令反应到Java代码中就是同步块–synchronized关键字,因此在synchronized块之间的操作也具备原子性。
- 可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。上文在讲解volatile变量的时候我们已详细讨论过这一点。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final.同步快的可见性是由“对一个变量执行unlock操作前,必须先把此变量同步回主内存”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把"this"的引用传递出去,那么在其他线程中就能看见final字段的值。
- 有序性(Ordering):Java内存模型的有序性在前面讲解volatile时也详细的讨论过了,Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的:如果在一个线程中观察另外一个线程,所有的线程操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行的进入。
Java对象模型
- 首先针对一个Model类,会在方法区创建出类的信息,instanceKlass
- 该类new出来的实例对象都会放到堆中,堆中的对象又分为对象头和实例数据两部分
- 若对象被调用了,那就会在栈中保存这个对象的引用
延伸
指令重排序
Java中的指令重排序是指在编译器和处理器执行Java程序时,为了优化性能可能在改变原始代码中指令的执行顺序。这种重排序通常发生在已下3个层面。
- 编译器重排序
- Java源代码被编译成字节码后,编译器在生成字节码的过程中可能会进行指令重排序,以提高代码的执行效率。
- 运行时优化重排序
- 在JVM运行时,即时编译器(Just-In-Time, JIT)会对热点代码进行优化,并可能进行指令重排序,以进一步提升执行速度。
- 处理器重排序
- 现代处理器为了提高执行效率,会采用乱序执行(Out-of-Order Execution)技术。这意味着处理器并不严格按照程序的顺序来执行指令,而是根据指令之间的依赖关系和其他优化策略动态调整执行顺序。
代码示例
/**
java 指令重排序:
在这个例子中,Singleton类使用双重检查锁定模式来确保instance字段只被初始化一次。在没有指令重排序的情况下,这个实现看起来是线程安全的。然而,由于Java内存模型和指令重排序的存在,这个实现可能会引发问题。
问题出在instance = new Singleton();这行代码上。这行代码实际上包含了三个操作:
1. 分配内存给Singleton对象。
2. 调用Singleton类的构造函数初始化对象。
3. 将instance字段指向新创建的对象。
由于指令重排序,这三个操作的实际执行顺序可能与上述顺序不同。例如,处理器可能先执行第3步,然后再执行第1步和第2步。在这种情况下,另一个线程可能会看到instance字段不为null,但实际上对象还没有完全初始化,从而导致不可预测的行为。
为了解决这个问题,可以将instance字段声明为volatile,以确保其初始化过程中的指令不会被重排序:
*/
public class Singleton {
private static Singleton singleton = null;
private Singleton(){};
private Singleton getSingleton(){
if(null == singleton){ // 第一次检查
synchronized(Singleton.class){
if(null == singleton){ // 第二次检查
singleton = new Singleton();
}
}
}
return singleton;
}
}
as-if-serial 语义(仿佛串行语义)
Java的“as-if-serial”是Java内存模型中的一项重要原则,这个原则规定,无论编译器和处理器如何进行指令重排序或者并行执行,对于单线程程序来说,其执行结果必须与代码按照原始顺序(即串行执行)执行的结果相同。
换句话说,尽管在实际执行过程中可能会发生指令重排序和并行执行以优化性能,但Java内存模型确保了这些优化不会改变单线程程序的预期行为。从程序员的角度来看,程序的行为就像是按照源代码的顺序逐行执行一样。
"as-if-serial semantics"原则的主要目的是保证编程的直观性和可预测性,使得程序员无需关心底层的并发细节和优化策略。然而,在多线程环境下,由于线程之间的交互和共享数据访问,需要额外的同步机制(如synchronized、volatile等)来确保数据的一致性和正确性。
需要注意的是,as if seral 原则只适用于单线程环境,在多线程环境中,为了保证数据的可见性和一致性,需要适当的同步机制来控制并发访问。
内存屏障(Java Memory Barrier或Java Memory Fence)
介绍
是一种确保特定内存操作顺序的指令,他主要用于控制并发环境下的内存可见性和一致性。内存屏障的主要作用包括:
1. 阻止重排序
内存屏障可以防止编译器和处理器对内存操作进行重排序。在内存屏障之前的操作必须在屏障之后的操作 之前完成。
2.确保可见性
内存屏障能够强制更新当前线程的工作内存与主内存之间的数据同步。在一个线程写入一个变量后放置一个写屏障,可以确保其他线程能够看到这个写入的最新值。
3.维持数据依赖性
内存屏障能够维护程序中的数据依赖性,确保不会因为重排序而破坏程序
的逻辑。
在Java中,内存屏障通常是通过已下方式实现应用的:
- volatile关键字
当一个变量被声明为volatile时,Java内存模型会自动插入必要的内存屏障来确保该变量的读写操作遵循一定的内存访问规则,包括禁止重排序和确保可见性。 - synchronized关键字
使用synchronized同步块或方法也会引入内存屏障,以确保在临界区内的操作按照预期的顺序执行,并且对共享数据的修改对其他线程是可见的。 - java.util.concurrent原子类
Java的并发包提供了原子类(如AtomicInteger、AtomicLong等),这些类的内部通常使用CAS(Compare-and-Swap)操作,并在必要时插入内存屏障来保证操作的原子性和可见性。
分类
Java内存屏障可以分为读屏障(Load Barrier)和写屏障(Store Barrier)。
1. 读屏障
对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载新数据;
2. 写屏障
对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。
可参考
Happen-before
学习happens-before的目的不是只限于知道这些规则的存在,可是要进一步知道如何实现和维护这些happens-before关系,在代码中加以注意。
happens-before规则是从Java代码设计层面保证有序性和可见性的机制。