Java内存区域详解
Java 内存区域与Java 内存模型区别
首先我们区分一下内存区域和内存模型的概念,很多人都会混淆这两个概念。
Java 内存区域是指 JVM 在运行 Java 程序时划分的不同内存区域,用于管理数据和指令。主要包括以下几个部分:
• 堆(Heap)
• 方法区(Method Area)
• 栈(Stack)
• 程序计数器(Program Counter Register)
• 本地方法栈(Native Method Stack)
Java 内存模型 (Java Memory Model, JMM)
Java 内存模型描述的是多线程环境下 Java 程序中变量的访问和操作的规则,特别是关于变量的可见性和指令重排序的行为。JMM 定义了在不同的线程中,变量的值如何在主内存和线程的工作内存之间传递。
好了,区分完这两个概念我们继续往下看,本篇文章主要讲的是 Java 内存区域,内存模型我们下次再讲。
Java内存区域详解
1.8 之前:
JDK1.8(含)之后:
区别就是 1.8有一个元数据区替代方法区了。
JDK 1.7 其实是并没完全移除方法区,但是不会像1.6以前报 “java.lang.OutOfMemoryError: PermGen space
”,而是报 java.lang.OutOfMemoryError: Java heap space
1.7部分内容(比如 常量池、静态变量有方法区转移到了堆)
那么,永久代为什么被移出HotSpot JVM了?
1、由于Permanent Generation内存经常不够用或发生内存泄露,引发恼人的java.lang.OutOfMemoryError: PermGen (在Java Web开发中非常常见)。
2、移除Permanent Generation可以促进HotSpot JVM与JRockit VM的融合,因为JRockit没有永久代。
3、对永久代进行调优是很困难的。
为什么要引入元空间:
1、字符串存在永久代中,容易出现性能问题和内存溢出。
2、类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
3、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
4、Oracle 可能会将HotSpot 与 JRockit 合二为一。
好了,通过上面的简单介绍之后,我们终于开始正题!!详细介绍下每个区的作用。
1.程序计数器
(1)它是一块很小的内存空间,几乎可以忽略不记。也是运算速度最快的存储区域。
(2)在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
(3)任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法**。程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址;或者,如果是在执行 native方法,则是未指定值(undefined)(因为PC寄存器是java层面的,本地方法栈是C/C++层面的)。**
(4)它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成;
(5)字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令;
(6)它是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
PC 寄存器作用
作用:PC 寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。
2.Java虚拟机栈
1、栈中存储什么
① 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在;
② 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame);
③ 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息;
2、栈运行原理
(1)JVM 直接对 Java 栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”/“后进先出”原则。
(2)在一条活动线程中,一个时间点上,只会有一个活动栈帧。即只有当前正在执行的方法栈帧(栈顶栈帧)是有效的,这个栈帧被称为 当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class);
(3)执行引擎运行的所有字节码指令只针对当前栈帧进行操作;
(4)如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
(5)不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧;
(6)如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
(7)Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令;另外一种是抛出异常,不管使用哪种方式,都会导致栈帧被弹出。
3.栈帧的内部结构
每个栈帧中存储着:
① 局部变量表(Local Variables);
② 操作数栈(Operand Stack)(或表达式栈);
③ 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用);
④ 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义);
⑤ 一些附加信息;
a.局部变量表(Local Variables)
(1)局部变量表也被称之为局部变量数组或本地变量表;
(2)定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及 returnAddress 类型;
(3)由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
(4)局部变量表所需的容量大小是在编译期确定下来的,并保存在方法 Code 属性的 maxinum local variables 数据项中。在方法运行期间是不会改变局部变量表的大小的。
(5)方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少;
(6)局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
b、关于 Slot 的理解
(1)参数值的存放总是在局部变量数组的 index 0开始,到数组长度-1的索引结束。
(2)局部变量表,最基本的存储单元是 Slot(变量槽);
(3)局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress 类型的变量;
(4)在局部变量表里,32位以内的类型只占用一个 slot(包括returnAddress类型,引用类型),64位的类型(long和double)占用两个 slot。
① byte、short、char 在存储前被转换为 int,boolean 也被转换为 int,0 表示 false,非0表示 true, float 也占用一个 slot;
② long 和 double 则占据两个 Slot。
(5)JVM 会为局部变量表中每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值;
(6)当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 Slot 上;
(7)如果需要访问局部变量中一个 64bit 的局部变量值时,只需要使用前一个索引即可。(比如:访问 long 或 double 类型变量);
(8)如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 slot 处,其余的参数按照参数表顺序继续排列。
4、Slot 的重复利用
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
Demo:
public void test4() {
int a = 0;
{
int b = 0;
b = a + 1;
}
//变量c使用之前已经销毁的变量b占据的slot的位置
int c = a + 1;
}
Nr.:局部变量的编号。
• 起始PC(Start PC):变量的生效起始位置,即在方法字节码中的偏移量(Program Counter)。
• 长度(Length):从起始位置开始,变量的有效字节码长度。
• 序号(Slot Index):局部变量表中的槽位编号(Slot)。JVM 使用此编号来访问局部变量。
• 名称(Name):变量名。
• 描述符(Descriptor):变量的类型描述符(如 I 表示 int,L... 表示对象类型)。
Nr. 0: this
• 起始PC:0
• 长度:13
• 序号:0
• 名称:this
• 描述符:Lcom/njf/java1/LocalVariablesTest;
• 说明:this 是当前对象的引用,始终占据 Slot 0,并且在整个方法执行期间有效。
2. Nr. 1: a
• 起始PC:2
• 长度:11
• 序号:1
• 名称:a
• 描述符:I(int类型)
• 说明:a 是局部变量,占据 Slot 1,从字节码偏移量 2 开始,到偏移量 13 结束,有效长度为 11 个字节码指令。
3. Nr. 2: b
• 起始PC:4
• 长度:4
• 序号:2
• 名称:b
• 描述符:I(int类型)
• 说明:b 是局部变量,占据 Slot 2,在块内定义,从偏移量 4 开始,到偏移量 8 结束,长度为 4 个字节码指令。
4. Nr. 3: c
• 起始PC:12
• 长度:1
• 序号:2
• 名称:c
• 描述符:I(int类型)
• 说明:c 是局部变量,占据 Slot 2,其生命周期从偏移量 12 开始,仅持续 1 个字节码指令。
结论:
Slot 0:始终为 this 对象引用。
• Slot 1:用于变量 a。
• Slot 2:最先用于 b,当 b 的作用域结束后,该槽位被释放并复用给 c。
5、局部变量表字节码分析
源代码:
public static void main(String[] args) {
LocalVariablesTest test = new LocalVariablesTest();
int num = 10;
test.test1();
}
字节码指令分析图:
jclasslib 分析:
6、静态变量与局部变量
(1)变量的分类
按照数据类型分:①基本数据类型 ②引用数据类型
按照在类中声明的位置分:①成员变量 ②局部变量
- 成员变量:在使用前,都经历过默认初始化赋值
-
- 变量:linking 的 prepare 阶段:给类变量默认赋值 ---> initial 阶段:给类变量显式赋值即静态代码块赋值
- 实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
- 局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过
(2)静态变量与局部变量的对比
① 参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配;
② 我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在 “初始化”阶段,赋予程序员在代码中定义的初始值;
③ 和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须认为的初始化,否则无法使用。
Demo:
public void test5Temp(){
int num;
//Variable 'num' might not have been initialized
//System.out.println(num);//错误信息:变量num未进行初始化
}
这样的代码是错误的,没有赋值不能够使用。
7、补充说明
(1)在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递;
(2)局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收;
8.操作数栈(Operaand Stack)
(1)每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称之为表达式栈(Expression Stack);
(2)操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)/出栈(pop);
① 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用它们后再把结果压入栈;
② 比如:执行复制、交换、求和等操作。
3.本地方法栈
Java虚拟机栈是调用Java方法;本地方法栈是调用本地native方法,可以认为是通过 JNI
(Java Native Interface) 直接调用本地 C/C++ 库,不受JVM控制。
Native方法
Java虚拟机栈与本地方法栈的调用过程
本地方法栈也会抛出 StackOverflowError 和 OutOfMemoryError 异常
4.Java堆
对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所
有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java
世界里“几乎”所有的对象实例都在这里分配内存。
Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”(Garbage Collected
Heap,幸好国内没翻译成“垃圾堆”)。
现在的虚拟机(包括HotSpot VM)都是采用分代回收算法。在分代回收的思想中, 把堆分为:新生代+老年代+永久代(1.8没有了); 新生代 又分为 Eden + From Survivor + To Survivor区。
如何分配内存(重点)
接下来说一说创建出来的对象是怎么分配内存的
分配步骤:
① new的对象先放伊甸园区。此区有大小限制;
② 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
③ 然后将伊甸园中的剩余对象移动到幸存者0区;
④ 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会 放到幸存者1区;
⑤ 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区;
⑥ 啥时候能去养老区呢?可以设置次数。默认是15次。 可以设置参数:-XX:MaxTenuringThreshold=
⑦ 在养老区,相对悠闲。当养老区内存不足时,再次触发GC: Major GC,进行养老区的内存清理;
⑧ 养老区执行了Major Gc之后发现依然无法进行对象的保存,就会产生OOM异常 java. lang. outOfMemoryError: Java heap space;
总结:
1、Survivor区满了不会触发YGC,当伊甸园区满的时候,会把伊甸园区和幸存者区一起进行回收;
2、幸存者区有可能直接到老年代;
3、针对幸存者S0,S1区的总结;复制之后有交换,谁空谁是to;
4、关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/ 元空间收集。
对象分配的特殊情况
5.方法区
方法区
方法区(Method Area)与 Java 堆一样,是所有线程共享的内存区域。
方法区(Method Area)存储什么?
《深入理解 Java虚拟机》书中对方法区(Method Area)存储内存描述如下:
它用于存储已被虚拟机加载的 类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
①这个类型的完整有效名称(全名-他名.类名);
②这个类型直接父类的完整有效名(对于interface或是java.lang.object,都没有父类);
③这个类型的修饰符(public, abstract, final的某个子集);
④这个类型直接接口的一个有序列表;
域(Field)信息
① JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
② 域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
方法(Method)信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:·
① 方法名称
② 方法的返回类型(或void)
③ 方法参数的数量和类型(按顺序)
④ 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
⑤ 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
⑥ 异常表(abstract和native方法除外)每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
non-final的类变量
① 静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。·
② 类变量被类的所有实例共享,即使没有类实例时你也可以访问它。
补充说明∶全局常量
被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。
运行时常量池 VS 常量池
常量池
① 方法区,内部包含了运行时常量池。
② 字节码文件,内部包含了常量池。
③ 要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。
④ 要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。
官方地址:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html
如下:Class文件的结构:
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用。
运行时常量池
(1)运行时常量池(Runtime constant Pool)是方法区的一部分。
(2)常量池表(Constant Pool Table)是class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
(3)运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
(4)JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
(5)运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。
如String.intern() 可以动态放到运行时常量池
(6)运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。
(7)当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛outofMemoryError异常。
关于方法区和元空间的关系:
方法区是JVM规范概念,而永久代则是Hotspot虚拟机特有的概念,简单点理解:方法区和堆内存的永久代其实一个东西,但是方法区是包含了永久代。 只有 HotSpot 才有 “PermGen space”,而对于其他类型的虚拟机,如 JRockit(Oracle)、J9(IBM) 并没有“PermGen space”
元空间
1.8就把方法区改用元空间了。类的元信息被存储在元空间中。元空间没有使用堆内存,而是与堆不相连的本地内存区域。所以,理论上系统可以使用的内存有多大,元空间就有多大,所以不会出现永久代存在时的内存溢出问题。
可以通过 -XX:MetaspaceSize
和 -XX:MaxMetaspaceSize
来指定元空间的大小。
8、总结:
参考:
https://cloud.tencent.com/developer/article/1748395?cps_key=1d358d18a7a17b4a6df8d67a62fd3d3d
《深入理解Java虚拟机》
常见面试题
1、百度:JVM内存模型,有哪些区?分别是干什么的?
2、蚂蚁金服:
① Java 8的内存分代改进;
② JVM 内存分哪几个区?每个区的作用是什么?
③ JVM 内部结构?栈和堆的区别?堆的结构?为什么两个 survivor 区?
④ Eden 和 Survivor 的比例分配?
3、小米:JVM内存分区,为什么要有新生代和老年代?
4、字节跳动:
① JVM的内存分区;
② JVM 的运行时数据区;
③ 什么时候对象会进行老年代?
5、京东:
① JVM 的内存结构,Eden和Survivor比例?
② JVM 内存为什么要分为新生代,老年代,持久代。新生代中为什么药分为 Eden和Survivor?
6、天猫:
① JVM 内存模式以及分区,需要详细到每个区放什么?
② JVM的内存模型,Java8做了什么修改?
7、拼多多:
JVM 内存分哪几个区,每个区的作用是什么?
8、美团:
① Java 内存分配;
② JVM 的永久代会发生垃圾回收吗?
③ JVM 内存分区,为什么要有新生代和老年代?Java虚拟机在运行程序时会把其自动管理的内存划分为以下几个区域,每个区域都有的用途以及创建销毁的时机。接下来会分别介绍各个区域的功能。
评论区