目录
前言
我们如果要对程序内存占用高的问题进行分析,首先我们需要了解具体是什么数据导致内存占用高,然后对具体的问题再具体分析。本文对JAVA运行时的数据区的基础知识知识进行整理。参考了《深入理解Java虚拟机》、《深入解析Java虚拟机HotSpot》、《HotSpot实战》三本书。
下面提到的虚拟机都特指JDK1.8版本的HotSpot VM,其他虚拟机的实现有可能不太一样。
运行时数据区
JVM虚拟机规范要求,运行时数据分为七个区域:程序计数器、Java堆、方法区、虚拟机栈、本地方法栈、运行时常量池以及本地内存(也被称为堆外内存或直接内存。
其中程序计数器、虚拟机栈和本地方法栈是线程私有的。由于Java支持多线程执行,因此每个线程需要保存当前线程的运行时信息,包括当前执行的代码位置,以及其他运行时所必要的信息。
程序计数器
程序计数器在JAVA虚拟机规范中称为Program Counter Register
,即为PC寄存器,它可以看作当前线程所执行的字节码行号指示器,字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令。
行号指示器的行号,并不是我们直接理解的行号,实际是字节码的索引号。JVM的字节码占用1个字节,而常量池索引占用2个字节。因此下面的例子中,第一行字节码共占用3个字节。所以第二行dup
的行号为3。
需要注意,只有执行的是非本地(Native)方法,程序寄存器才会记录JAVA虚拟机正在执行的字节码指令地址,若当前执行方法是本地方法,则程序计数器的值为空(Undefined)。
方法区
在编译产生的class文件中,包含有类的类型信息和常量池等信息,这些信息在类加载时会被加载到运行时的方法区中。方法区主要用于存储被虚拟机加载的类信息、静态变量、JIT后的代码字节码缓存、运行池常量。虚拟机规范把方法区列为堆的一部分,但是虚拟机实现可以不实现方法区的自动垃圾回收,而是依赖于对常量池和类型的卸载来完成。
实现方式
在JDK1.7之前,HotSpot是使用GC的永久代来实现方法区,省去了专门编写方法区的内存管理代码。
从JDK1.8开始,使用元空间替代永久代来存放方法区的数据。元空间属于本地内存。简而言之使用了本地内存替换堆内存来存放方法区的数据。
若方法区内存空间不满足内存分配的请求时,将抛出
OutOfMemoryError
异常。
类型信息
类型信息包括代码中的类名、修饰符、字段描述符和方法描述符。
字段描述符
字段描述符用于表示类、实例和局部变量。比如用L
表示对象,用[
表示数组等。
字段描述符内部解释表如下图所示。
字段描述符 | 类型 | 含义 |
---|---|---|
B | byte | 有符号的字节型数 |
C | char | unicode字符码点,UFT-16编码 |
D | double | 双精度浮点数 |
F | float | 单精度浮点数 |
I | int | 整型数 |
J | long | 长整数 |
L className | reference | className的类的实例 |
S | short | 有符号短整数 |
Z | boolean | 布尔值true/false |
[ | reference | 一维数组 |
方法描述符
方法描述符表示0个或多个参数描述符以及1个返回值描述符,用于表示方法的签名信息。若返回值为void则用V表示。
方法描述符的格式: (参数描述符)
+ 返回值描述符
。
比如Object m(int i, double d, Thread t)(){}
方法可以表示为(IDLjava/lang/Thread;)Ljava/lang/Object;
。
I
是int
类型的字段描述符D
是double
类型的字段描述符Ljava/lang/Thread;
是Thread
类型的内部描述符Ljava/lang/Object;
是方法的返回值为object
类型
方法描述符分割各标识符的符号不用
.
,而用/
表示。
public class SymbolTest{
private final static String staticParameter = "1245";
public static void main(String[] args) {
String name = "jake";
int age = 54;
System.out.println(name);
System.out.println(age);
}
}
上面一个简单的例子,编译通过后,可以通过javap -s xxx.class
命令查看内部签名。
D:studyjavasymbolreferenceoutproductionsymbolreference>javap -s com.company.SymbolTest
Compiled from "SymbolTest.java"
public class com.company.SymbolTest {
public com.company.SymbolTest();
descriptor: ()V
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
}
可以看出无参构造函数的方法描述符为()V
,main方法的方法描述符为([Ljava/lang/String;)V
运行时常量池
运行时常量池保存了编译期常量和运行期常量。编译期常量是在编译时编译器生成的字面量和符号引用。在类加载时,会把编译时的符号引用保存到符号表,字符串保存到字符串表,实际内容是保存到堆中。
字面量指的是代码中直接写的字符串或数值等常量或声明为final
的常量值。比如string str="abc"
或int value = 1
这里的abc
和1
都属于字面量。运行期常量值的是运行期产生的新的常量,比如String.intern()
方法产生的字符串常量会被保存到运行时常量池缓存起来复用。
运行时常量在方法区中分配,在加载类和接口到虚拟机后就会创建对应的运行时常量。若创建运行时常量所需的内存空间超过了方法区所能提供的最大值,则会抛出OutOfMemoryError
异常。
还是上面的代码示例,通过javap -v
可以输出包括运行时常量的附加信息。下面列出了了部分常量输出内容。
D:studyjavasymbolreferenceoutproductionsymbolreference>javap -v com.company.SymbolTest
...
Constant pool:
#1 = Methodref #7.#28 // java/lang/Object."<init>":()V
#2 = String #29 // jake
#3 = Fieldref #30.#31 // java/lang/System.out:Ljava/io/PrintStream;
...
#7 = Class #36 // java/lang/Object
#8 = Utf8 staticParameter
#9 = Utf8 Ljava/lang/String;
#10 = Utf8 ConstantValue
#11 = String #37 // 1245
#12 = Utf8 <init>
#13 = Utf8 ()V
...
#18 = Utf8 Lcom/company/SymbolTest;
#19 = Utf8 main
#20 = Utf8 ([Ljava/lang/String;)V
#21 = Utf8 args
#22 = Utf8 [Ljava/lang/String;
#23 = Utf8 name
#24 = Utf8 age
#25 = Utf8 I
#26 = Utf8 SourceFile
#27 = Utf8 SymbolTest.java
#28 = NameAndType #12:#13 // "<init>":()V
#29 = Utf8 jake
...
#35 = Utf8 com/company/SymbolTest
#36 = Utf8 java/lang/Object
#37 = Utf8 1245
...
通过输出的静态常量信息可以很清楚的看出JVM编译时对字面量和符号引用的处理,包括类型名、变量名、方法等都用符号来代替了。比如第一个常量为对象类构造方法java/lang/Object."<init>":()V
。去除其他不相关的常量,最终的符号引用和字面量关系如下表。
索引 | 类型 | 值 |
---|---|---|
0 | Methodref | #7.#28(java/lang/Object."<init>":()V ) |
... | ||
7 | Class | #36 |
... | ||
12 | Utf8 | <init> |
13 | Utf8 | ()V |
... | ||
28 | NameAndType | #12:#13("<init>":()V ) |
... | ||
36 | Utf8 | java/lang/Object |
Java虚拟机栈和本地方法栈
JVM规范要求JVM线程要同时具有本地方法栈和java虚拟机栈。JVM自身执行使用本地方法栈,而执行java方法使用java虚拟机栈。不过在hotspot实现中,本地方法栈和java虚拟机栈,共用同一块线程栈。
和程序计数器一样,每一个JAVA虚拟机线程都有自己私有的JAVA虚拟机栈。Java虚拟机规范允许Java虚拟机栈被实现为固定大小,也允许动态扩展和收缩。
当线程请求的栈深度大于虚拟机允许的栈深度,则会抛出
StackOverflowError
异常。当栈动态扩展无法申请到足够的内存时,则会排除OutOfMemoryError
异常。
栈帧
线程栈描述的是方法执行的线程的内存模型。那么如果进行描述呢?每个方法执行的时候当前执行线程会在Java虚拟机栈中分配当前方法的栈帧,栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。当方法执行完后,栈帧就会被丢弃,继续执行下一个栈帧。
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了。
局部变量表
局部变量表用于存储基础数据类型、对象引用和returnAddress类型。
局部变量表实际上就是一个数组,以一个局部变量槽(Slot)为最小单位,HotSpot VM的局部变量槽大小为32位。局部变量表所需的内存空间是在编译时分配,运行时局部变量所占用的空间是确定的,也就是数组的槽数。基础数据类型占用1个或2个槽,对象引用和returnAddress类型占用1个槽。
需要注意,在方法调用的时候,局部变量槽可以复用从而减少内存占用。许多情况部分局部变量作用域不会覆盖到整个方法体,当某局部变量不再使用时,该槽即可给其他局部变量使用。用下面例子为例:
public static void main(String[] args){
int value = 2;
{
int doubleValue = value *2;
System.out.println(doubleValue);
}
int count = 1;
System.out.println(count);
}
通过javap -l
可以输出本地变量表,从下面输出信息可以看出 doubleValue
和count
变量的Slot值都为2,2号槽被重用了。
D:studyjavasymbolreference argetclassescomcompany>javap -l LocalVariableSlotReuse.class
Compiled from "LocalVariableSlotReuse.java"
public class com.company.LocalVariableSlotReuse {
public com.company.LocalVariableSlotReuse();
...
LocalVariableTable:
Start Length Slot Name Signature
6 7 2 doubleValue I
0 23 0 args [Ljava/lang/String;
2 21 1 value I
15 8 2 count I
}
不过局部变量槽重用虽然可以减少内存占用空间,但是可能会影响到垃圾回收。因为在作用域内的引用对象,即使离开了作用域,虽然已经没有对象可达根,但是仍然被栈帧的局部变量表引用着。
java虚拟机定义了的若干种可达根(GC Roots),只要是根可达的对象就不能被垃圾回收,局部变量表就算其中之一。
基础数据类型
JAVA有8个基础数据类型:boolean、byte、char、short、int、float、long、double。其中long和double占用2个槽,其他基础数据类型都占用1个槽。
局部变量表使用索引进行定位访问。局部变量的索引值从0开始。调用实例方法时,第0个局部变量用于存储当前对象实例(即this关键字)。局部变量从第1个开始;而调用静态方法时,局部变量从第0个开始。
以下面代码为例
public int add(int a ,int b ){
return a+b;
}
public int staticAdd(int a ,int b ){
return a+b;
}
通过javap -l
输出本地变量表。可以发现实例方法第0个局部变量被this对象占用。
public int add(int, int);
LineNumberTable:
line 5: 0
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this Lcom/company/LocalVariableSlot0Use;
0 4 1 a I
0 4 2 b I
public static int staticAdd(int, int);
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 a I
0 4 1 b I
returnAddress
returnAddress
是一个指针,指向一条虚拟机指令的操作码。这些操作码包括jsr
、ret
和jsr_w
。在JDK 7之前,这些操作码用于实现finally语句块的跳转和返回。从JDK 7开始,虚拟机已不允许这几个操作码了,finally与catch同样使用异常表实现。与catch不同的是,每个catch会有指定的异常类型,而finally支持所有异常类型,即保证正常和异常最终都可以调用到finally代码块中。
void tryFinally(){
try{
tryItOut();
}finally{
wrapItUp();
}
}
当class文件版本小于或等于50时,jsr指令将下一条指令地址(第7条return)在跳转前压入操作数栈中,跳转后通过a_store 2
将栈顶元素(即第7条return指令的地址)保存到第2个局部变量,再finally结束后,通过ret 2返回到局部变量2所指向的地址。
void tryFinally();
Code:
0: aload_0
1: invokevirtual #2 // Method tryItOut:()V
4: jsr 14
7:return
8: astore_1
9: jsr 14
12: aload_1
13: athrow
14: astore_2
15: aload_0
16: invokevirtual #3 // Method wrapItUp:()V
19: ret 2
Exception table:
from to target type
0 4 8 any
当class文件版本大于50时,使用异常表实现finally代码块。
void tryFinally();
Code:
0: aload_0
1: invokevirtual #2 // Method tryItOut:()V
4: aload_0
5: invokevirtual #3 // Method wrapItUp:()V
8: goto 18
11: astore_1
12: aload_0
13: invokevirtual #3 // Method wrapItUp:()V
16: aload_1
17: athrow
18: return
Exception table:
from to target type
0 4 11 any
操作数栈
每个栈帧内部都包含一个后进先出(LIFO)的操作数栈。操作数栈的最大深度由编译期决定。操作数栈中保存了局部变量表或对象实例中的常量或变量值。在调用方法时,也保存调用方法的参数和返回值。
若局部变量是long或double类型,则需要占用2个单位的栈深度。
举个例子,当执行以下代码。右边注释的[]
表示操作数栈,左边时栈底,右边是栈顶。
// //[]
int a = 1; //[1]
int b = 2; //[1,2]
int c = a+b;//[3]->[]
注意:
c=a+b
,通过iadd
读取栈顶的2个数相加后重新入到操作数栈,因此操作数栈中的内容为3
,然后从操作数栈中出栈保存到c变量中,操作数栈就空了。
栈帧中的布局是精心设计的,头是操作数栈,尾是局部变量表。由于方法调用的参数也会保存在局部变量表,因此虚拟机在必要时会堆方法调用进行优化,将两个栈帧的操作数栈和局部变量表进行共享,从而减少参数传递和内存占用。
动态连接
每个栈帧内部都包含当前方法所在类型的运行时常量池的引用,以便对当前方法的代码实现动态连接。
在编译时,会将调用的方法或成员变量通过符号引用的方式保存。这样同样的字符串就以同样的符号保存,可以减少class文件大小。在类加载的解析阶段,一部分可以直接确定运行时地址的符号会被转换为直接引用,这种转换成为静态解析。另一部分在运行时才能确定实际方法地址的符号在运行期才会被转换为直接引用,这部分被称为动态连接(比如子类重写方法、接口实现发方法等)。
符号引用也被称为描述符(Descriptor),是通过特定的语法来表示的。调用的方法的符号引用称为方法描述符(Method Descriptor),成员变量称为字段描述符(Parameter Descriptor)。
动态连接确保了JVM在运行时可以对方法指向正确的方法调用(接口实现或方法重写)或更合适的方法调用(方法重载)。
JVM在方法区中会建立一个虚方法表(vtable)和接口方法表(itable)。
虚方法表和接口方法表保存了方法的的实际入口地址,如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
方法返回地址
当通过动态连接调用其他类方法时,栈帧中需要保存被调用的位置,以便方法调用完成后可以返回到被调用时的位置。
当方法正常调用完成后,则栈帧正常恢复局部变量表、操作数栈和调用者的程序计数器正确的位置,若有返回值,则将返回值压入到调用者的栈帧的操作数栈中。
当方法异常调用完成后,则会导致Java虚拟机抛出异常,若当前方法没有任何可以处理该异常的异常处理器,则当前方法的操作数栈和局部变量表都会被丢弃,随后恢复到调用者的栈帧,此时不会有任何返回值压入到调用者的操作数栈中。同时将异常交易给调用者的异常处理器处理。
Java堆
Java虚拟机中,Java堆用于保存各种对象实例,是Java虚拟机所管理的内存中最大的一块,并且该内存被所有线程所共享。
Java栈由线程自动创建和销毁,栈帧由方法的创建和销毁自动管理。而Java堆则由垃圾收集器进行自动收集并回收。垃圾收集器在不同场景下通过最优的垃圾收集算法对垃圾进行收集。
为了提高垃圾收集性能,在JDK1.8及以前,大部分垃圾回收器采用分代模型。这种模型的理论依据是,大部分对象都是朝生夕灭的,而存活越久的对象越不容易被垃圾回收。Java堆将空间分为新生代、老年代。新生代又被分为Eden区和Survivor区。通过分区将不同“纨绔子弟”存放与老年代,老年代只有在FullGC时才会回收,大部分情况下只需要回收新生代即可,从而提高垃圾回收效率,避免堆整个堆进行分析。
随着程序内存占用变大,使用分代模型无法满足大堆的垃圾回收效率,后面出现了分区模型,将堆分为若干小块,每个区域通常情况下可以单独做垃圾回收而不影响其他区域。这种模型化整为零,比如G1垃圾回收器就采用这种模型。具体垃圾回收算法不在这里讨论
本地内存
在JDK1.4引入了NIO,使得Java可以很方便的使用本地内存。本地内存可以脱离堆内存的大小限制,避免由于堆内存达到最大堆阈值导致OOM。不过本地内存依然受限于程序所支持的最大内存。
不过在日常编码时如果我们自己要使用本地内存,则需要注意,本地内存不受GC管控,在使用完时必须手动释放。
在NIO中,可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的
DirectByteBuffer
对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。在DirectByteBuffer
分配时,会通过Cleaner
机制将DirectByteBuffer
对象和本地内存进行关联,当DirectByteBuffer
对象被垃圾回收后,就会通知Cleaner
线程执行本地内存释放。
参考文档
- JVM jsr和ret指令始终理解不了?returnAddress又怎么理解呢?
- 如何理解ByteCode、IL、汇编等底层语言与上层语言的对应关系?
- The Java Virtual Machine Instruction Set
- JVM(hotspot)栈帧实现
- Java虚拟机底层原理和流程,看懂你就掌握60%JVM
- 《深入理解Java虚拟机》
- 《Java虚拟机规范(Java SE 8版)》
微信扫一扫二维码关注订阅号杰哥技术分享
出处:https://www.cnblogs.com/Jack-Blog/p/13426728.html
作者:杰哥很忙
本文使用「CC BY 4.0」创作共享协议。欢迎转载,请在明显位置给出出处及链接。