JVM内存模型、原理、垃圾回收、调优

JVM内存模型、原理、垃圾回收、调优,这Java语言的基础,作为Java从业人员是必须要掌握的,另外这也是面试经常会问到的知识。

-----------------------------------------------------------

 

JVM内存模型

我们先从JVM内存模型说起,它包括如下几部分:

1、堆

    所有程序创建的对象都存放在这里

2、方法区

    类元信息都存放在这,包括类的类型信息、常量池、域信息、方法信息。JDK 8 之前,也叫做永久区(perm),可以通过jvm参数指定大小,并且和老年代的垃圾回收绑定。JDK8之后这块功能区域称之为元空间,不再受限于设置的内存大小,而是系统可用内存。每个类的加载器的存储区域都称为一个元空间,所有的这些元空间合在一起,就是这里所说的元空间。

3、程序计数器

    每个线程一个计数器,记录下一条要执行的指令

4、栈

    线程私有数据区域。每个方法会有一个栈帧,多个栈帧连接起来,就是堆栈。栈帧包含如下数据:

  • 操作数栈:程序的逻辑操作被转化为栈式指令集,存储在这里。
  • 帧数据:常量池、返回地址等上下文信息
  • 局部变量表:存储局部变量

5、本地方法栈

    JVM内部方法的方法栈

 

JVM原理简述

 

Oop-Class模型

JVM运行时,会把Java中的类转化为C++中的对等描述对象,比如Java类对等的对象为instanceKlass,方法的对等对象methodKlass。这些描述Java类的C++对象,保存在方法区。当new一个具体的java对象时,会把该对象的C++对等对象保存在堆区,这个对象为instanceOop,oop的意思是普通对象指针,而不是面向对象编程,这是c++中的概念。instanceOop中保存了对象的属性信息。instanceOop会有一个指针指向它所对应的类型instanceKlass。

下图为示意图,真实情况略有区别。

JVM内存模型、原理、垃圾回收、调优_第1张图片

字节码

简单说一下字节码的结构,字节码是java文件编译后产生的文件,里面包含了java源代码的各种信息,如下:‘

说明
魔数 固定为CAFEBABE,并不是字面量,而是十六进制数值。(不过我觉得是故意用这几个字母的)
版本号 前两个字节大版本号,后两个字节小版本号
常量池 两部分组成,第一部分常量池数量,第二部分常量池数组。常量池数组中存放常量池元素。
访问标识--access_flag) 标注类或者接口的访问信息。
类信息--this_class 当前类的全限定名,包+类名,指向常量池的索引值
父类信息--super_class 父类全限定名,包+类名,指向常量池的索引值
接口信息 包括interface_count,实现的接口数量。interfaces数组,实现的接口名称
字段信息 fields_count,变量总和,包括类变量(静态变量)和成员变量。field_info_fiels数组,记录各个变量的详细信息。
方法信息 

methods_count,方法数量。method_info_methods数组,每一个方法的全部细节都包含在里面,包括代码指令。数组中对象的attributes字段十分重要,不同类型的attributes描述方法的不同纬度,比如方法抛出异常、内部类列表、字节码指令等。

 

 

 

 

 

 

 

 

 

 

 

 

Java类加载过程 

类加载的过程就是JVM把字节码转化为JVM内部对象的过程。

java类加载过程分为四步如下:

步骤 描述
加载 JVM通过类的限定名查找到字节码文件,对字节码进行解析,生成JVM中的对等描述对象
验证 确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
准备 为类变量(静态变量)申请空间,并设置默认初始值。不包含用final修饰的static变量,final类型在编译时已经分配了,
解析 常量池中的符号引用替换为直接引用的过程。
初始化 初始化一个类事,如果有父类,先去初始化父类。初始化静态变量和执行静态代码块。编译阶段会向字节码中自动写入clinit方法,这就是类的初始化方法。注意这不是对象的初始化方法,对象的初始化方法是构造函数,编译时转化为init方法。

 

 

 

 

 

 

 

 

 

 

JVM执行引擎工作原理

以上我们知道了Java类如何加载到虚拟机中,而且知道java方法会被转化为代码指令,另外对方法的调用采用栈桢这种结构。而JVM由C++开发,C语言如何如何能够调用到汇编指令的呢?这是函数指针起到的作用。

C语言中可以把一个变量指向一个函数的首地址,这就是函数指针。而C语言编译后,函数变成了机器指令。所以我们可以在Java的编译阶段就把函数转化为一段机器指令,然后C语言的函数指针指向这段机器的首地址,从而实现了JVM从C语言到Java的跨越。

JVM内部的call_sub就是一个函数指针,而他就是JVM内部从C跨向机器指令的大门。内部实现还是很复杂,涉及C++,汇编。本文只通过下图简单讲解。我们明白原理即可。

JVM内存模型、原理、垃圾回收、调优_第2张图片

 

垃圾回收

垃圾回收的主战场在堆内存,所有的对象都保存在这里,这里充斥着对象的生老病死,所以必须及时把不用的对象清理掉释放内存。在讲垃圾回收前,先简单了解几个基础的垃圾回收算法:

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倍,当然也可以自行设置。

由于新生代垃圾回收频繁,所以采用的是标记-复制算法,而老年代采用的是标记-整理算法。

GC触发时机

Minor GC,新对象生成,并且在Eden申请空间失败时,触发。Eden区GC频繁,需要使用速度快的算法。

Full GC,对整个堆进行整理,尽量减少Full GC次数。JVM调优大部分工作是对Full GC调节。如下原因导致Full GC

  1. 老年代写满
  2. 持久代写满
  3. System.gc()显示调用
  4. 上一次GC后Heap各区域分配策略动态变化

由于GC发生时,内存的地址要发生改变,所以JVM要停止当前运行的程序,也就是Stop the world。因此要尽量减少垃圾回收的时长和次数。而JVM调优也是围绕这两点,尤其是减少老年代的垃圾回收,因为老年代内存空间大,而且标记整理算法耗时也会长。

常见垃圾回收器

  • Serial收集器(复制算法),新生代,单线程,简单高效,client模式默认
  • Serial Old收集器(标记-整理算法) 老年代。
  • ParNew收集器(停止-复制算法) serial的多线程版本 server模式下首选
  • Parallel Scavenge收集器(停止-复制算法) 并行
  • Parallel Old收集器(停止-复制算法) 老年版本,对应Parallel Scavenge,并行
  • CMS(Concurrent Mark Sweep)收集器(标记-清理算法)追求最短GC回收停顿时间,CPU占用高,响应时间快。
  • G1,最新一代垃圾回收,也是追求短停顿时间,适合大内存的机器。

JVM调优

我总结基本的JVM调优有三个部分:

1、虚拟机栈调优

2、堆内存调优

3、其他

虚拟机栈调优

虚拟机栈的内存大小,决定了方法调用的嵌套深度。每一个方法在内存中称为一个栈帧,栈帧大小并不是固定的,我们知道它存储了局部变量表,所以局部变量多的话,栈帧的大小也会变大。那么同样栈内存,局部变量更多,方法嵌套的深度就会变小。

栈内存大小通过参数-Xss进行设置。栈空间分配太小,达不到调用深度,出现stackOverflow异常,此时需要调大-Xss。

-Xss设置过大,会导致系统支持线程总数下降,原因是每个线程有自己的栈,而机器的内存是有限的。如果大量线程并发,减小堆内存和栈内存,给更多的线程腾出内存空间,增加线程数量。不过堆内存过小会导致频繁GC,这是需要平衡的地方。

堆内存调优

堆内存是垃圾回收的主要战场,所以对内存设置的调优是要重点关注的。主要有如下几种手段:

  1. 调大堆内存。通过调大对内存,使得JVM可以容纳更多的对象,那么出相应的垃圾回收次数必然减少。
  2. 设置最大堆内存和最小堆内存为同样的值。这样做防止内存震荡,jvm对内存不会有扩容操作。另外如果设置的不一样,JVM会尽量让堆内存维持在最小值,只有经过垃圾回收还不足以容纳全部对象时才会扩大堆内存。
  3. 调高新生代大小,设置较大的survivor区。有句话叫让新生代留在新生代,如果新生代内存设置过小,那么很容易造成较新的对象几次垃圾回收后,就因为新生代空间不足而进入了老年代,而老年代的垃圾回收算法更慢,停顿时间更长。所谓让新生代留在新生代就是尽量让对象在新生代多经过几轮回收后才会进入老年代
  4. 调整进入老年代对象大小。通过合理设置次值,可以让大对象直接进入老年代,这样可以腾出新生代的空间,容纳更多的新对象。但是如果大对象不幸生命周期很短,就会造成长久占用老年代空间。
  5. 调整进入老年代的垃圾回收次数。通过设置合理值,让对象在恰当的时机进入老年代,而不是一直停留在新生代占据新生代空间

总结一下,就是调大内存、固定大小、尽量让新生代可用空间更大。不过这些参数都是有正反两个方向的作用,一定是综合考虑,经过多次调试实验才能得出最终的设置值。调优重点在调上。

常用工具有JDK自带的 jconsole.exe、jvisualvm.exe

其他调优方式

  1. 使用并行回收器代理串行回收器。大内存的机器,可以尝试使用CMS或者G1
  2. 禁显示GC
  3. 禁用类元数据回收
  4. 禁用类验证
  5. 设置出问题时导出堆快照
  6. 设置发生错误时,虚拟机运行一段第三方脚本

 

 

 

 

 

你可能感兴趣的:(Java面试知识点总结,JVM,虚拟机,调优,垃圾回收,内存模型)