众所周知,Java与C等语言区别的其中一点就是拥有自动内存管理机制,知道Java内存的结构才能更好的定位内存溢出等问题,本文主要对Java内存区域进行分析
目录
运行时数据区
运行时数据区是Java虚拟机执行Java方法是划分的数据区域,如下图所示
程序计数器
程序计数器主要功能控制程序指令执行。它能保存内存地址,记录当前要执行的字节码地址,在方法执行的时候通过改变计数器的值选取下一条要执行的字节码指令。(所以很明显程序计数器是不会内存溢出的,因为他内含的指令长度不变,是Java虚拟机规范中唯一一个没有OutOfMemoryError情况的)
程序计数器的另外一个功能是多线程记录CPU切换前执行的指令。当多线程的时候程序计数器需要切换线程执行指令,切换的时候自己的线程要记住自己执行的位置,方便切换回来后继续执行。所以程序计数器正是因为他的线程不共享才能实现改功能。
Java虚拟机栈
Java虚拟机栈同样是线程不共享的,他描述的是Java执行方法的线程内存模型,栈内存如图所示:
每一个方法执行的时候会创建一个栈帧,存储局部变量表、操作数栈、帧数据(动态连接,方法出口)。
局部变量表
存储所有局部变量,他的本质是个数组,里面的每个位置就是局部变量槽(long和double类型数据占两个变量槽)当有实例方法的时候还要存this。局部变量槽可以复用,当变量不再生效的时候当前槽可以复用。
帧数据
动态链接:
动态链接用于在运行时将符号引用转换为直接引用。方法调用的目标方法在编译时可能只是一个符号引用,在运行时需要通过动态链接来确定实际的方法地址。
当一个类实现了接口,在调用接口方法时,需要通过动态链接来确定实际调用的是哪个类实现的接口方法。
方法出口:
当一个方法调用另一个方法时,当前方法的执行状态(包括程序计数器的值等)会被保存,当被调用的方法执行完毕后,就可以通过方法出口的信息返回到调用处继续执行。也就是说栈上面的方法结束时弹出,弹出的同时把下面的栈帧的地址交给计数器(方法出口)。
异常情况
线程请求的栈深度大于虚拟机允许的深度,抛出StackOverError异常
栈扩展时候无法申请到足够的内存抛出OutOfMemoryError异常(在内存非常有限的系统中,频繁地创建线程,每个线程都有自己的 JVM 栈,当内存不足以分配新的栈空间时)
本地方法栈
与Java虚拟机栈类似,这里不过多赘述,本地方法栈主要是为本地方法服务。
Java堆
线程共享的,是虚拟机管理内存中最大的一块,唯一目的是存放对象实例。在 Java 程序运行过程中,当通过new
关键字创建一个对象或者创建一个数组时,这些对象和数组就会在 Java 堆中分配内存空间。
从容量角度描述Java堆主要分三种,如图:
“used” 表示 Java 堆中当前已经被对象占用的内存量;“total” 是 Java 堆设定的总容量,是 Java 虚拟机启动时确定的堆内存大小;“max” 通常也代表 Java 堆内存的最大容量。
used会逐渐扩展total的区域,当total不足的时候,也会扩张,直到max,但是每当total<max的时候内存就已经溢出了,这里需要再垃圾回收机制中具体讲解。笔者尚未了解,请见谅。
Java堆可以设置-Xmx
参数用于设置 Java 堆的最大内存,-Xms
参数用于设置 Java 堆的初始内存。合理地设置堆大小对于 Java 程序的性能非常重要。max默认为系统的1/4,total默认系统的1/64。最好将max和total的值设置为一样的,这样可以避免total在不足的时候不断扩张浪费时间。
方法区
在 Java 8 之前,方法区是在虚拟机内部有一个专门的内存区域来实现,有独立的内存空间,并且会受到虚拟机内存参数(如 - XX:MaxPermSize)的限制。这个时期方法区也被称为永久代(Permanent Generation)。
Java 8 之后,方法区被元空间(Metaspace)所取代。元空间并不在虚拟机内部,而是使用本地内存(Native Memory)。这一改变使得方法区的大小不再受限于固定的虚拟机内存参数,理论上元空间可以使用的内存大小只受限于系统的物理内存大小。

运行时常量池
在类加载后,将类的常量池表(存储在字节码文件中)中的内容放入运行时的内存区域。也就是从符号引用到直接引用。
组成
字面常量
包括字符串字面量、基本数据类型(如int
、long
、float
、double
、boolean
、char
)的常量值等。例如,在代码double pi = 3.14;
中,3.14
这个字面常量就会存储在运行时常量池中。对于字符串字面量,Java 会对相同的字符串进行共享存储,以节省内存空间。例如,在代码String s1 = "abc"; String s2 = "abc";
中,s1
和s2
指向的是运行时常量池中同一个"abc"
字符串。
符号引用
这是一种在编译期用来表示一个目标(如类、方法、字段)的符号表示。例如,在编译一个 Java 类时,对于类中调用的其他方法,编译器不会直接生成方法的实际内存地址,而是生成一个符号引用。当类加载和解析过程中,这个符号引用会被转换为直接引用,这个转换过程可能会涉及到查询运行时常量池中的信息。
动态性
intern()方法最具有代表性。
public class test {
public static void main(String[] args) {
String str1 = new StringBuilder("hello").append("world").toString();
System.out.println(str1.intern() == str1);
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);
}
}
JDK6:
字符串常量池和运行时常量池都是在永久代也就是方法区中的(到jdk7的时候,字符串常量池和静态变量被从方法区拿到了堆中,运行时常量池剩下的还在方法区),堆里面没有字符串常量池,intern(),第一次遇到的字符串“helloworld”实例复制到永久代的字符串常量池中,返回字符串实例引用,而“java”在JVM启动的时候就会加入到常量池,s1引用在堆中,s1.intern()的引用在字符串常量池中
JDK8以后:
当执行 str1.intern()
时,发现运行时常量池中没有 “helloworld”,就会把 str1
所指向的那个堆中的字符串对象的引用直接放入运行时常量池,所以这里 str1.intern() == str1
的结果为 true
,表示它们指向的是同一个字符串对象。
调用 str2.intern()
时,情况有所不同。因为 “java” 这个字符串在 Java 程序启动时,很可能已经作为常量被加载到运行时常量池中了。所以当执行 str2.intern()
时,它会直接返回运行时常量池中已有的 “java” 字符串对象的引用,而这个引用和 str2
所指向的新创建的堆中的字符串对象不是同一个,所以 str2.intern() == str2
的结果为 false
,表示它们指向的是不同的字符串对象。
异常情况
如果方法区内存不足,在 Java 8 之前的永久代时期,可能会抛出OutOfMemoryError
异常。例如,当加载了过多的类,或者常量池中的常量过多,导致永久代空间被填满,就会出现这种情况。
在 Java 8 之后的元空间,虽然可以使用的内存空间变大了,但如果无限制地生成类或者加载大量的元数据,也可能会导致系统内存被耗尽,从而引发OutOfMemoryError
异常,因为元空间使用的是本地内存。
直接内存
直接受操作系统管理的一块内存区域。在一些需要高性能、低延迟的场景下,如网络通信(NIO,New - Input/Output)中的数据缓冲区,就会使用直接内存。与 Java 堆内存相比,直接内存不受 Java 堆大小的限制,它的大小只受限于物理内存和操作系统的限制。
以下内容参考自ChatGPT
与 Java 堆内存的关系和交互
- 数据可以在 Java 堆内存和直接内存之间进行传输。例如,当从网络读取数据到直接内存缓冲区后,可能需要将数据复制到 Java 堆内存中的对象中,以便在 Java 程序中进行进一步的处理。这种数据传输需要显式地进行操作,通常使用
ByteBuffer
的get()
方法将直接内存中的数据读取到堆内存的字节数组或者其他数据结构中。- 同时,Java 堆内存中的数据也可以传输到直接内存。例如,在准备发送网络数据时,先将 Java 堆内存中的数据复制到直接内存缓冲区,然后通过网络发送出去。
潜在风险和注意事项
- 内存泄漏风险:由于直接内存不受 Java 虚拟机垃圾收集器的直接管理,如果在使用完直接内存后没有正确地释放,就会导致内存泄漏。例如,在一个长时间运行的网络服务器应用程序中,如果不断地分配直接内存用于接收数据,但没有及时释放,直接内存会一直被占用,最终可能导致系统内存耗尽。
- 内存溢出风险:虽然直接内存不受 Java 堆大小的限制,但如果过度使用直接内存,也会导致系统内存不足。因为直接内存占用的是物理内存,当直接内存和 Java 堆内存以及其他系统进程所占用的内存总和超过物理内存时,就会出现内存溢出的情况。因此,在使用直接内存时,需要合理地估算和控制其使用量。
以上就是笔者对于Java内存结构的认识,从周志明先生的《深入理解Java虚拟机第3版》学习而来,见识尚浅,希望各位批评指正。