1、Java运行时数据区域
Java虚拟机在执行Java程序的过程中,会把它所管理的内存划分为若干个不同的数据区域。
- 程序计数器
- Java虚拟机栈
- 本地方法栈
- Java堆
- 方法区
- 运行时常量池
- 直接内存
示例类
public class Math {
public static final int initData = 666;
public static User user = new User();
public int compute() {
//一个方法对应一块栈帧内存区域
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
// compute是在常量池中的
math.compute();
System.out.println(111);
}
}
当一个类被编译成class文件后,会通过类加载子系统(C++实现)把字节码转入到运行时数据区中,字节码执行引擎(C++实现)会加载执行程序代码
1.1、程序计数器
程序计数器(Program Counter Register)是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址
,就是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等都是依托程序计数器完成的。
也就是说处理器在时间片切换时,为了线程切换后能恢复到(找到)正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间互不影响,独立存储,线程私有的内存
白话理解:
程序正在运行或马上运行代码内存的位置(行号) ,代码运行时,字节码执行引擎会跟随运行代码行修改计数器的值。
线程独有
1.2 Java虚拟机栈
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,生命周期与线程相同;
白话理解:
- 栈(Java虚拟机栈):例如示例中,线程运行主方法,java虚拟机会在线程栈(Java虚拟机栈)内分配一个独立的内存空间,用来存放线程运行过程中局部变量的内存空间
线程独有
虚拟机栈描述的是Java方法执行的线程内存模型
:每个方法在执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)
用于存储局部变量表,操作数帧,动态链接,方法出口等信息。即使是递归调用,也是在栈中继续分配栈帧
一个方法的调用到执行结束,就对应着一个栈帧在虚拟机中从入栈到出栈的过程,先进后出
,和程序调用过程相符,例如main方法运行时,main方法先入栈,main方法调用compute方法,伴随着compute方法入栈,compute运行结束,compute出栈,main结束,main出栈。
执行javap -c Math.class,对代码进行反汇编将字节码转为指令码
public int compute();
Code:
# 将int类型常量1压入操作数栈,数字0一般指this
0: iconst_1
# 将int类型值存入局部变量1
1: istore_1
# 将int类型常量2压入栈
2: iconst_2
# 将int类型值存入局部变量2
3: istore_2
# 从局部变量1中装载int类型值
4: iload_1
# 从局部变量2中装载int类型值
5: iload_2
# 执行int类型的加法
6: iadd
# 将一个8位带符号整数压入栈
7: bipush 10
# 执行int类型的乘法,跳过8 是因为,10也会占内存位置
9: imul
# 将int类型值存入局部变量3
10: istore_3
# 从局部变量3中装载int类型值
11: iload_3
# 从方法中返回int类型的数据
12: ireturn
查看compute方法的指令码可知:
- 变量 int a = 1; 的赋值过程
- 1先压入操作数栈
- 1出栈赋值给局部变量表中局部变量下标1(或索引)的变量a(0一般指this)
局部变量表
- 局部变量表存放的是编译期
可知
的各种Java虚拟机基本类型(boolean、byte、char、short、int、float、long、double)、对象的引用(reference类型,指的是对象地址的引用或句柄或其他于此对象相关的地址)、returnAddress(指向了一条字节码指令的地址)。 - 这个数据类型在局部变量表中的
存储空间以局部变量槽(Slot)
表示,64位的长度(long,double)占用2个变量槽,其余都是1个。 - 局部变量表所需的内存空间,
在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部面量表的大小(大小指的是变量槽的数量)
- 虚拟机一个变量槽占用的比特(例如32比特,64比特或者更大),是由具体的虚拟机决定的
- 例子中:
main方法中math在局部变量表中对应的值是堆中math地址的引用
操作数栈
程序在运行过程中,操作数在运算的过程中,做操作的一块临时中转的内存空间
。个人理解:如下代码在运算过程中,从局部变量表中分别取出1和2压入操作数栈中,分别出栈1和2 ,求和后(CPU操作),在压入操作数栈,10压入操作数栈,出栈 3和10,得到结果(CPU操作)后再入栈,30出栈,赋值给局部变量表中变量cint a = 1; int b = 2; int c = (a + b) * 10;
动态链接
程序运行过程中,将符号引用转化成直接引用, 直接引用地址存放到动态链接中
- 例如上面例子中,main方法在运行时,compute方法是不会被加载的,只会先加载静态方法。程序在运行到compute方法时,会解析这个符号 (compute),找到这个符号在内存中的位置,存放到动态链接中
方法出口
记录方法运行的位置等信息
- 例如compute方法调用结束后,要在回到main中继续执行System.out.println(111),所以需要记录执行方法的位置等信息
虚拟机栈中会引用堆中对象的地址
《Java虚拟机规范》中这个内存区域规定了两类异常状况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverflowError
- 每次方法调用都会有一个栈帧压入虚拟机栈。操作系统给JVM分配的内存是有限的,JVM分配给“虚拟机栈”的内存是有限的。 如果方法调用过多,导致虚拟机栈满了就会溢出。 这里
栈深度就是指栈帧的数量
- 每次方法调用都会有一个栈帧压入虚拟机栈。操作系统给JVM分配的内存是有限的,JVM分配给“虚拟机栈”的内存是有限的。 如果方法调用过多,导致虚拟机栈满了就会溢出。 这里
- 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存空间会抛出OutOfMemoryError
HotSpot虚拟机栈容量不支持动态扩展,所以只要线程申请到栈空间就不会出现OOM,如果申请失败则会出现OOM异常
1.3、本地方法栈
本地方法栈(Native Method Stack):本地方法栈服务的对象是JVM执行的native方法
,而虚拟机栈服务的是JVM执行的java方法。
-
线程独有
-
Native Method就是一个java调用非java代码的接口。该方法的实现由非java语言实现,比如C或C++。
- 例如Object类中
- 例如Object类中
1.4、Java堆
Java堆(Java Heap)是虚拟机所管理内存中最大的一块。Java 堆是被所有线程共享的一块内存区域
,在虚拟机启动时创建
,用于存放对象实例。Java堆是垃圾回收器管理的内存区域
也称为GC堆(字节码执行引擎会 进行垃圾收集)。
一般new出来的对象都放在堆中
,一般存放在Eden区
线程共享
Java堆可以处于物理上不连续的内存区域,但在逻辑上应该被视为连续的,但对于大对象(典型的如数组对象),多数虚拟机实现处于简单实现,存储高效的考虑,很可能会要求连续的内存空间
Java虚拟机的堆内存分为新生代、老年代、永久代、Eden、Survivor,在当前的HotSpot中上述提法就有很多需要商榷的地方。Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存
JDK8之后,无永久代,改为元空间(Metaspace),使用的是直接内存
Java堆既可以被实现成固定大小,也可以是可扩展的,当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 设定),如果Java堆中没有内存完成实例分配,并且堆也无法再扩展,Java虚拟机将会抛出OutOfMemoryError异常
1.5、方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域
,主要是用来存放已被虚拟机加载的类相关信息
,包括类信息、运行时常量池、字符串常量池。类信息又包括了类的版本、字段、方法、接口和父类等信息。
线程共享
方法区会引用堆中对象的地址
// 静态对象会存在方法区中,而new出来的对象是放在方法区中的,方法区中存的是地址的引用
public static User user = new User();
JDK 8 HotSpot完全放弃了永久代的概念,改用本地内存中实现的元空间(Meta-space)来代替
《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或可扩展外,甚至可以选择不实现垃圾收集
。相对而言,垃圾收集行为在这个区域的确比较少出现,,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标最主要是针对常量池的回收和对类型的卸载
,但是回收效果比较难令人满意,尤其是类型的卸载。
方法区无法满足新的内存分配需求时将会抛出OutOfMemoryError异常
1.6、运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table)
用于存放编译期生成的各种字面量与符号引用
,在类加载后存放到方法区运行常量池中
,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中
-
字面量:
等号右边的八种基本类型的值、字符串值、声明为final的常量的值
。//a为常量,10为字面量 final int a = 10; // b 为变量,hello world!为字面量 string b = "hello world!";
-
符号引用:可以是任意类型的字面量。只要能无歧义的定位到目标。在编译期间由于暂时不知道类的直接引用,因此先使用符号引用代替。最终还是会转换为直接引用访问目标。符号引用就是某个变量,在编译的时候,无法确定其内存地址。
String str = "Hello World!" // str在编译的时候就会编译为符号引用。 System.out.println(str);
-
直接引用:程序运行时可以定位到引用的东西(类, 对象, 变量或者方法等)的地址
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是String类的intern()方法。
-
intren方法:
通俗的讲,是将字符串放入常量池中。
/** * 表达式右边是纯字符串常量,则存放在常量池中 * 表达式右边存在字符串引用,则存放在堆中 */ public class test { public static void main(String[] args) { String s1="aaa"; String s2="bbb"; String s3="aaabbb"; String s4=s1+s2; String s5="aaa"+"bbb"; String s6=new String("aaabbb"); // false System.out.println(s3==s4); // true System.out.println(s3==s4.intern()); // true System.out.println(s3==s5); // false System.out.println(s3==s6); // true System.out.println(s3==s6.intern()); } }
说明:s1,s2,s3,s5均存放在常量池中,s4,s6存放在堆中。
当常量池无法再申请到内存时会抛出OutOfMemoryError异常
1.7、直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域,但是这部分内存也被频繁的使用,也可能导致OutOfMemoryError异常
JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、及SWAP区或者分页文件)的大小及处理器寻址空间的限制
。服务器管理员配置虚拟机参数时,一般会根据实际内存设置-Xmx等参数信息,但经常会忽略掉直接内存,使得各个内存区域的总和大于物理内存限制(包括物理 上的和操作系统 级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
参考《深入理解Java虚拟机》-周志明