JVM内存管理(二)
概述
在传统的C/C++开发中,我们经常通过使用申请内存的方式来创建对象或是存放某些数据,但是这样也带来了一些额外的问题,我们要在何时释放这些内存,怎么才能使得内存的使用最高效,因此,内存管理是一个非常严肃的问题。
比如我们就可以通过C语言动态申请内存,并用于存放数据:
1 |
|
而在Java中,这种操作实际上是不允许的,Java只支持直接使用基本数据类型和对象类型,至于内存到底如何分配,并不是由我们来处理,而是JVM帮助我们进行控制,这样就帮助我们节省很多内存上的工作,虽然带来了很大的便利,但是,一旦出现内存问题,我们就无法像C/C++那样对所管理的内存进行合理地处理,因为所有的内存操作都是由JVM在进行,只有了解了JVM的内存管理机制,我们才能够在出现内存相关问题时找到解决方案。
运行时数据区
既然要管理内存,那么肯定不会是杂乱无章的,JVM对内存的管理采用的是分区治理,不同的内存区域有着各自的职责所在,在虚拟机运行时,内存区域如下划分:
我们可以看到,内存区域一共分为5个区域,其中方法区和堆是所有线程共享的区域,随着虚拟机的创建而创建,虚拟机的结束而销毁,而虚拟机栈、本地方法栈、程序计数器都是线程之间相互隔离的,每个线程都有一个自己的区域,并且线程启动时会自动创建,结束之后会自动销毁。内存划分完成之后,我们的JVM执行引擎和本地库接口,也就是Java程序开始运行之后就会根据分区合理地使用对应区域的内存了。
程序计数器
首先我们来介绍一下程序计数器,它和我们的传统8086 CPU中PC寄存器的工作差不多,因为JVM虚拟机目的就是实现物理机那样的程序执行。在8086 CPU中,PC作为程序计数器,负责储存内存地址,该地址指向下一条即将执行的指令,每解释执行完一条指令,PC寄存器的值就会自动被更新为下一条指令的地址,进入下一个指令周期时,就会根据当前地址所指向的指令,进行执行。
而JVM中的程序计数器可以看做是当前线程所执行字节码的行号指示器,而行号正好就指的是某一条指令,字节码解释器在工作时也会改变这个值,来指定下一条即将执行的指令。
因为Java的多线程也是依靠时间片轮转算法进行的,因此一个CPU同一时间也只会处理一个线程,当某个线程的时间片消耗完成后,会自动切换到下一个线程继续执行,而当前线程的执行位置会被保存到当前线程的程序计数器中,当下次轮转到此线程时,又继续根据之前的执行位置继续向下执行。
程序计数器因为只需要记录很少的信息,所以只占用很少一部分内存。
虚拟机栈
虚拟机栈就是一个非常关键的部分,看名字就知道它是一个栈结构,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(其实就是栈里面的一个元素),栈帧中包括了当前方法的一些信息,比如局部变量表、操作数栈、动态链接、方法出口等。
其中局部变量表就是我们方法中的局部变量,实际上局部变量表在class文件中就已经定义好了,操作数栈就是我们之前字节码执行时使用到的栈结构; 每个栈帧还保存了一个可以指向当前方法所在类的运行时常量池,目的是:当前方法中如果需要调用其他方法的时候,能够从运行时常量池中找到对应的符号引用,然后将符号引用转换为直接引用,然后就能直接调用对应方法,这就是动态链接(我们还没讲到常量池,暂时记住即可,建议之后再回顾一下),最后是方法出口,也就是方法该如何结束,是抛出异常还是正常返回。
我们来分析一下这段代码在虚拟机栈中是如何执行的
1 | public class Main { |
当我们的主方法执行后,会依次执行三个方法a() -> b() -> c() -> 返回。如下图所示,执行完main方法后,会在虚拟机栈中创建一个栈帧,这个栈帧里会存放main方法的局部变量表、操作数栈等内容。
接着执行int res = a();
,这时候需要调用方法a(),当前main方法就不会继续向下运行了,而是去执行方法a(),那么同样的,将此方法也入栈,注意是放入到栈顶位置,main方法的栈帧会被压下去
这时,进入方法a之后,又执行到了return b();
,这时候会进入到方法b,在虚拟机栈中增加b方法的栈帧,最后在进入c,因此,到达方法c的时候,我们的虚拟机栈变成了
现在我们依次执行方法c中的指令,最后返回a+b的结果,在方法c返回之后,也就代表方法c已经执行结束了,栈帧4会自动出栈,这时栈帧3就得到了上一栈帧返回的结果,并继续执行,但是由于紧接着马上就返回,所以继续重复栈帧4的操作,此时栈帧3也出栈并继续将结果交给下一个栈帧2,最后栈帧2再将结果返回给栈帧1,然后栈帧1就可以继续向下运行了,最后输出结果。
本地方法栈
本地方法栈与虚拟机栈作用差不多,主要区别是本地方发展为虚拟机使用的本地方法(Native)服务。在《Java虚拟机规范》中对本地方法栈的实现没有做强制要求,有的虚拟机还会把虚拟机栈和本地方法栈合二为一,比如Hotspot。
堆
堆是整个Java应用程序共享的区域,也是整个虚拟机最大的一块内存空间,而此区域的职责就是存放和管理对象。
Java堆是垃圾收集器管理的区域,主要回收的就是堆区域的对象实例,现在的垃圾收集器主要是基于分代手机理论设计的,经常会看到“新生代”、“老年代”等名词,分代设计主要是为了更方便快速的垃圾回收,这部分内容会在后面详细说明。
方法区
方法区也是整个Java应用程序共享的区域,它用于存储所有的类信息、常量、静态变量、动态编译缓存等数据,可以大致分为两个部分,一个是类信息表,一个是运行时常量池。方法区也是我们要重点介绍的部分。
首先类信息表中存放的是当前应用程序加载的所有类信息,包括类的版本、字段、方法、接口等信息,同时会将编译时生成的常量池数据全部存放到运行时常量池中。当然,常量也并不是只能从类信息中获取,在程序运行时,也有可能会有新的常量进入到常量池。
其实我们的String类正是利用了常量池进行优化,这里我们编写一个测试用例:
1 | System.out.println(str1 == str2); |
得到的结果也是显而易见的,由于str1和str2是单独创建的两个对象,那么这两个对象实际上会在堆中存放,保存在不同的地址,所以当我们使用==判断时,得到的结果false,而使用equals时因为比较的是值,所以得到true。现在我们来稍微修改一下:
1 | public static void main(String[] args) { |
现在我们没有使用new的形式,而是直接使用双引号创建,那么这时得到的结果就变成了两个true,这是为什么呢?
这其实是因为我们直接使用双引号赋值,会先在常量池中查找是否存在相同的字符串,若存在,则将引用直接指向该字符串;若不存在,则在常量池中生成一个字符串,再将引用指向该字符串。
实际上两次调用String类的intern()方法,和上面的效果差不多,也是第一次调用会将堆中字符串复制并放入常量池中,第二次通过此方法获取字符串时,会查看常量池中是否包含,如果包含那么会直接返回常量池中字符串的地址:
1 | //不能直接写"abc",双引号的形式,写了就直接在常量池里面吧abc创好了 |
所以上述结果中得到的依然是两个true。在JDK1.7之后,稍微有一些区别,在调用intern()方法时,当常量池中没有对应的字符串时,不会再进行复制操作,而是将其直接修改为指向当前字符串堆中的的引用:
1 | public static void main(String[] args) { |
所以最后我们会发现,str1.intern()和str1都是同一个对象,结果为true。
值得注意的是,在JDK7之后,字符串常量池从方法区移动到了堆中。
总结
最后我们再来进行一个总结,各个内存区域的用途:
(线程独有)程序计数器:保存当前程序的执行位置。
(线程独有)虚拟机栈:通过栈帧来维持方法调用顺序,帮助控制程序有序运行。
(线程独有)本地方法栈:同上,作用与本地方法。
堆:所有的对象和数组都在这里保存。
方法区:类信息、即时编译器的代码缓存、运行时常量池。
当然,这些内存区域划分仅仅是概念上的,具体的实现过程我们后面还会提到。
OutOfMemoryError
实际上,在Java程序运行时,内存容量不可能是无限制的,当我们的对象创建过多或是数组容量过大时,就会导致我们的堆内存不足以存放更多新的对象或是数组,这时就会出现错误,比如:
1 | public static void main(String[] args) { |
这里我们申请了一个容量为21亿多的int型数组,显然,如此之大的数组不可能放在我们的堆内存中,所以程序运行时就会这样:
Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit at com.test.Main.main(Main.java:5)
这里得到了一个OutOfMemoryError错误,也就是我们常说的内存溢出错误。
堆内存溢出
我们可以通过参数来控制堆内存的最大值和最小值:
- Xms最小值 -Xmx最大值
比如我们现在限制堆内存为固定值1M大小,并且在抛出内存溢出异常时保存当前的内存堆转储快照:
注意堆内存不要设置太小,不然连虚拟机都不足以启动,接着我们编写一个一定会导致内存溢出的程序:
1 | public class Main { |
在程序运行之后,可以看到错误出现原因正是Java heap space,也就是堆内存满了。
java.lang.OutOfMemoryError: Java heap space Dumping heap to java_pid35172.hprof ...
栈溢出
我们接着来看栈溢出,我们知道,虚拟机栈会在方法调用时插入栈帧,那么,设想如果出现无限递归的情况呢?
1 | public class Main { |
这很明显是一个永无休止的程序,并且会不断继续向下调用test方法本身,那么按照我们之前的逻辑推导,无限地插入栈帧那么一定会将虚拟机栈塞满,所以,当栈的深度已经不足以继续插入栈帧时,就会这样:Exception in thread "main" java.lang.StackOverflowError...
这也是我们常说的栈溢出,它和堆溢出比较类似,也是由于容纳不下才导致的,我们可以使用-Xss来设定栈容量。
堆外内存
除了堆内存可以存放对象数据以外,我们也可以申请堆外内存(直接内存),也就是不受JVM管控的内存区域,这部分区域的内存需要我们自行去申请和释放,实际上本质就是JVM通过C/C++调用malloc函数申请的内存,当然得我们自己去释放了。不过虽然是直接内存,不会受到堆内存容量限制,但是依然会受到本机最大内存的限制,所以还是有可能抛出OutOfMemoryError异常。
这里我们需要提到一个堆外内存操作类:Unsafe,就像它的名字一样,虽然Java提供堆外内存的操作类,但是实际上它是不安全的,只有你完全了解底层原理并且能够合理控制堆外内存,才能安全地使用堆外内存。
注意这个类不让我们new,也没有直接获取方式,看名字就知道他的设计者可能不想让我们使用:
1 | public final class Unsafe { |
所以我们这里就通过反射来使用它:
1 | public static void main(String[] args) throws IllegalAccessException { |
成功拿到Unsafe类之后,我们就可以开始申请堆外内存了,比如我们现在想要申请一个int大小的内存空间,并在此空间中存放一个int类型的数据:
1 | public static void main(String[] args) throws IllegalAccessException { |
我们可以来看一下allocateMemory底层是如何调用的,这是一个native方法,我们来看C++源码:
1 | UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory0(JNIEnv *env, jobject unsafe, jlong size)) { |
接着来看:
1 | void* os::malloc(size_t size, MEMFLAGS flags) { |
所以,我们上面的Java代码转换为C代码,差不多就是这个意思:
1 |
|
所以说,直接内存实际上就是JVM申请的一块额外的内存空间,但是它并不在受管控的几种内存空间中,当然这些内存依然属于是JVM的,由于JVM提供的堆内存会进行垃圾回收等工作,效率不如直接申请和操作内存来得快,一些比较追求极致性能的框架会用到堆外内存来提升运行速度,如nio框架。
B站「青空の霞光」
《深入理解Java虚拟机》