JVM内存模型、原理、垃圾回收、调优,这Java语言的基础,作为Java从业人员是必须要掌握的,另外这也是面试经常会问到的知识。
-----------------------------------------------------------
我们先从JVM内存模型说起,它包括如下几部分:
1、堆
所有程序创建的对象都存放在这里
2、方法区
类元信息都存放在这,包括类的类型信息、常量池、域信息、方法信息。JDK 8 之前,也叫做永久区(perm),可以通过jvm参数指定大小,并且和老年代的垃圾回收绑定。JDK8之后这块功能区域称之为元空间,不再受限于设置的内存大小,而是系统可用内存。每个类的加载器的存储区域都称为一个元空间,所有的这些元空间合在一起,就是这里所说的元空间。
3、程序计数器
每个线程一个计数器,记录下一条要执行的指令
4、栈
线程私有数据区域。每个方法会有一个栈帧,多个栈帧连接起来,就是堆栈。栈帧包含如下数据:
5、本地方法栈
JVM内部方法的方法栈
JVM运行时,会把Java中的类转化为C++中的对等描述对象,比如Java类对等的对象为instanceKlass,方法的对等对象methodKlass。这些描述Java类的C++对象,保存在方法区。当new一个具体的java对象时,会把该对象的C++对等对象保存在堆区,这个对象为instanceOop,oop的意思是普通对象指针,而不是面向对象编程,这是c++中的概念。instanceOop中保存了对象的属性信息。instanceOop会有一个指针指向它所对应的类型instanceKlass。
下图为示意图,真实情况略有区别。
字节码
简单说一下字节码的结构,字节码是java文件编译后产生的文件,里面包含了java源代码的各种信息,如下:‘
项 | 说明 |
魔数 | 固定为CAFEBABE,并不是字面量,而是十六进制数值。(不过我觉得是故意用这几个字母的) |
版本号 | 前两个字节大版本号,后两个字节小版本号 |
常量池 | 两部分组成,第一部分常量池数量,第二部分常量池数组。常量池数组中存放常量池元素。 |
访问标识--access_flag) | 标注类或者接口的访问信息。 |
类信息--this_class | 当前类的全限定名,包+类名,指向常量池的索引值 |
父类信息--super_class | 父类全限定名,包+类名,指向常量池的索引值 |
接口信息 | 包括interface_count,实现的接口数量。interfaces数组,实现的接口名称 |
字段信息 | fields_count,变量总和,包括类变量(静态变量)和成员变量。field_info_fiels数组,记录各个变量的详细信息。 |
方法信息 | methods_count,方法数量。method_info_methods数组,每一个方法的全部细节都包含在里面,包括代码指令。数组中对象的attributes字段十分重要,不同类型的attributes描述方法的不同纬度,比如方法抛出异常、内部类列表、字节码指令等。 |
类加载的过程就是JVM把字节码转化为JVM内部对象的过程。
java类加载过程分为四步如下:
步骤 | 描述 |
加载 | JVM通过类的限定名查找到字节码文件,对字节码进行解析,生成JVM中的对等描述对象 |
验证 | 确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。 |
准备 | 为类变量(静态变量)申请空间,并设置默认初始值。不包含用final修饰的static变量,final类型在编译时已经分配了, |
解析 | 常量池中的符号引用替换为直接引用的过程。 |
初始化 | 初始化一个类事,如果有父类,先去初始化父类。初始化静态变量和执行静态代码块。编译阶段会向字节码中自动写入clinit方法,这就是类的初始化方法。注意这不是对象的初始化方法,对象的初始化方法是构造函数,编译时转化为init方法。 |
以上我们知道了Java类如何加载到虚拟机中,而且知道java方法会被转化为代码指令,另外对方法的调用采用栈桢这种结构。而JVM由C++开发,C语言如何如何能够调用到汇编指令的呢?这是函数指针起到的作用。
C语言中可以把一个变量指向一个函数的首地址,这就是函数指针。而C语言编译后,函数变成了机器指令。所以我们可以在Java的编译阶段就把函数转化为一段机器指令,然后C语言的函数指针指向这段机器的首地址,从而实现了JVM从C语言到Java的跨越。
JVM内部的call_sub就是一个函数指针,而他就是JVM内部从C跨向机器指令的大门。内部实现还是很复杂,涉及C++,汇编。本文只通过下图简单讲解。我们明白原理即可。
垃圾回收的主战场在堆内存,所有的对象都保存在这里,这里充斥着对象的生老病死,所以必须及时把不用的对象清理掉释放内存。在讲垃圾回收前,先简单了解几个基础的垃圾回收算法:
1、引用计数算法
对象有一次引用,计数+1。当引用数为0时,被回收。
2、可达性分析算法
所有引用关系看作一张图,所有关联的引用节点顺藤摸瓜,统计一遍,没有统计到的节点,被认为没有被引用,可被回收。
没有相连接的引用,被第一次标记。然后再进行筛选,如果finalize中没有重新和引用链建立关系的被二次标记,然后被回收。
以上是两个统计哪些对象没有被引用的算法。下面是真正使用的垃圾回收算法:
1、标记-清除算法
对存活对象进行标记,完成后,扫描未被标记的对象,进行回收。存活对象多时,极为高效。但是会造成内存碎片
2、标记-复制算法
为了克服句柄开销和解决内存碎片问题。把堆分为对象面和空闲面,扫描时把活动对象复制到空闲面,这样没有空隙。
3、标记-整理算法
在清除时,回收不存活对象占用的空间后,将所有的存活对象左移,并更新指针。成本更高,解决碎片问题。
那么java使用的是哪种算法呢?实际上是混合使用的,现在先不展开来说,我们继续讲下一部分内容。
前文讲到垃圾回收主战场在堆,所以回收操作是在堆内存进行的。为了提高垃圾回收的效率和效果,堆内存又被划分为如下几个部分:
1、年轻代 (Eden,survivor0,survivor1)
2、老年代
顾名思义,年轻代容纳的就是年轻的对象,也就是创建时间并不是很长的对象。这些对象随着垃圾回收的触发,有些被回收掉了,有些挺过一次又一次的垃圾回收(一直存在饮用),达到一定阈值,被移入老年代。老年代存放的都是存活时间长的对象。由此可见,新生代的垃圾回收比较频繁,而老年代存放对象由于相对稳定,所以不会经常进行垃圾回收。只有当年轻代的垃圾回收还不能释放足够空间时,才对老年代进行垃圾回收。
新生代GC也叫Minor GC,发生频率高(不一定eden满了触发)。eden:s0:s1=8:1:1,默认以此比例分配内存。新生代垃圾回收有如下三种情况
a)s0能够容纳eden存活对象
1、eden存活对象移入s0。
2、清空eden。
jvm启动后,最先出现的场景。很简单,让den保持空白,活得对象放入s0
b)s0满了
1、eden存活对象和s0存活对象移入s1。
2、清空eden和s0。
3、s1交换到s0,保持s1为空。
随着垃圾回收的进行,s0也会满了,此时触发eden和s0同时回收。存活对象放入s1,然后再交换回s0。讲到这里你应该明白为什么s0和s1是1:1了吧?原因就是这里使用了复制算法。
c)s1放不下eden和s0存活对象时
1、存活对象直接放到老年代
此时会认为存活的对象已经够老,可以进入老年代了。其实不进入也没有办法,因为年轻代已经没有空间了。。。
此时存活的新生代对象已经进入了老年代,但是如果老年代也放不下这些存活对象,那么将会触发老年代垃圾回收,也就是full GC,也称为Major GC。
比较理想的情况是,老年代中的对象都是生命周期比较长的对象,如果很年轻的对象就进入了老年代,那么就需要调优了。老年代空间一般是新生代的2倍,当然也可以自行设置。
由于新生代垃圾回收频繁,所以采用的是标记-复制算法,而老年代采用的是标记-整理算法。
Minor GC,新对象生成,并且在Eden申请空间失败时,触发。Eden区GC频繁,需要使用速度快的算法。
Full GC,对整个堆进行整理,尽量减少Full GC次数。JVM调优大部分工作是对Full GC调节。如下原因导致Full GC
由于GC发生时,内存的地址要发生改变,所以JVM要停止当前运行的程序,也就是Stop the world。因此要尽量减少垃圾回收的时长和次数。而JVM调优也是围绕这两点,尤其是减少老年代的垃圾回收,因为老年代内存空间大,而且标记整理算法耗时也会长。
我总结基本的JVM调优有三个部分:
1、虚拟机栈调优
2、堆内存调优
3、其他
虚拟机栈的内存大小,决定了方法调用的嵌套深度。每一个方法在内存中称为一个栈帧,栈帧大小并不是固定的,我们知道它存储了局部变量表,所以局部变量多的话,栈帧的大小也会变大。那么同样栈内存,局部变量更多,方法嵌套的深度就会变小。
栈内存大小通过参数-Xss进行设置。栈空间分配太小,达不到调用深度,出现stackOverflow异常,此时需要调大-Xss。
-Xss设置过大,会导致系统支持线程总数下降,原因是每个线程有自己的栈,而机器的内存是有限的。如果大量线程并发,减小堆内存和栈内存,给更多的线程腾出内存空间,增加线程数量。不过堆内存过小会导致频繁GC,这是需要平衡的地方。
堆内存是垃圾回收的主要战场,所以对内存设置的调优是要重点关注的。主要有如下几种手段:
总结一下,就是调大内存、固定大小、尽量让新生代可用空间更大。不过这些参数都是有正反两个方向的作用,一定是综合考虑,经过多次调试实验才能得出最终的设置值。调优重点在调上。
常用工具有JDK自带的 jconsole.exe、jvisualvm.exe