前言
JVM可以说是下到应届生,上到高级开发都是面试必考的知识,只是深浅的不同罢了,但是百变不离其中,了解JVM的基本原理与作用,大部分的面试题也能对答如流。为避免阅读疲劳,该系列会将JVM分为两个部分,第一部分主要介绍JVM的内存划分以及类加载过程,第二部分则是主要讲解GC以及JMM相关问题。
本系列章节面向的用户群体主要是应付面试,因此本文章不会深入讲解相关底层原理,主要以简单理解的方式为读者提供应对面试的学习思路,方便读者快速理清JVM面试相关知识,轻松走过面试第一关。
我们通过以下常见的JVM面试问题作为学习的入口:
1. JVM运行时数据区的内存怎么进行划分?
2. JVM的类加载是怎么样的?
3. 如何针对JVM内存参数进行调优?
以上三个问题可以说是jvm面试的基本问题,接下来我会以总结式的方式进行问题的解答,让读者快速上手这类问题。
1. JVM运行时数据区
其实JVM运行时数据区可以通过分类进行快速记忆,其中可以分为线程共享以及线程私有:
1. 线程共享的区域有:堆(感觉只要是学java的这个不可能答不出来)、方法区
2. 线程私有的区域有:虚拟机栈(java方法)、本地方法栈(c/c++方法)、程序计数器
大体可以通过下图进行一个大概的了解:
上图已经很好的诠释了相应的内存划分,以及某些数据具体存储在哪,接下来我们根据这个图进行一个简单的总结:
程序计数器是占用空间最小的内存区域,不会出现OOM,主要记录当前线程正在执行的方法的指令地址,方便线程切换后能恢复到下一条指令的位置,如果是执行native方法,则该计数器为空。(由于这块老是被遗忘,写在第一处让读者记忆深刻点)
-
虚拟机栈是用来存放栈帧的,说白了栈帧就是方法运行时数据存储的空间,这栈帧主要由四个部分组成:
Ⅰ. 局部变量,方法体里面包含的变量,如基本变量 int , float 等以及对象的引用如
Student s
Ⅱ. 操作数栈,对数据的运算需要一个栈来进行,可以理解为自己开发一个算术运算时使用的栈结构,如果有数据进行计算的时候会将数据放入,然后计算得出的结果也会存到这里面
Ⅲ. 动态链接,存储了指向class常量池的方法符号引用(一个字符串),说白了这个东西就是查找方法具体位置的一种懒加载机制。在第一次运行会通过这个字符串找到相应方法所在位置,之后将这个位置替换掉符号引用,变为直接引用。
Ⅳ. 返回地址,这个应该很好理解,我得知道方法运行完后要回去哪,这个地址便是指向进入这个方法时候的地址,方便返回
本地方法栈,与虚拟机栈差不多,只不过运行的是native(c/c++)方法
堆,存储对象以及数组的区域,基本上是jvm内存里面最大的区域。
PS:如果方法里面的一个对象不存在被外界调用,那么jvm可能会将这个对象存放在栈里面,不过目前怎么判断一个对象不怎么被外界调用(即逃逸分析)不太成熟,知道这个的人也不多(面试就遇到一回问这个)。
- 方法区,主要存储虚拟机加载的类信息、常量、静态变量,例如上面说的符号引用也存在这。
PS: 读者对于方法区最大的疑惑可能就是jdk1.8后的变化,其实jdk1.8前方法区属于堆的一个子集,只不过1.8之后变成了和堆的并交(如图中所示),其中类的元信息存储在直接内存,而常量池、静态变量的引用对象还是存在堆中。
最后总结一下,JVM每遇到一个线程,就为其分配一个程序计数器,虚拟机栈和本地方法栈,栈里面指向的对象一般都在堆中,线程终止的时候便会清空这些内存 ( 以上都是线程私有,因此和线程共存亡 ) ,而堆中的对象则根据可达性分析算法进行生死判断,这个会在下一章节进行讲解。
2. JVM的类加载流程
废话不多说,大体流程看图:
让我们来一句话总结一下类的加载过程:将 Class 文件的字节流转换为方法区中的 Class 对象(加载),检查该 Class 文件的字节流是否规范、合法(验证),为该 Class 的类变量( static )进行空间分配(准备),对该 Class 中的符号引用转换为直接引用(解析),最后执行用户编写的构造器代码(初始化)
PS : 其中准备阶段和解析阶段有些小细节,准备阶段如果是类常量( static final ),则会直接进行赋值操作,否则赋予初始值( int 就赋值0,对象就赋值 null 等),在初始化阶段才会进行具体赋值;而解析阶段则可能是一个懒加载情况,有可能是在初始化后被其他对象进行调用才会将符号引用转为直接引用。
类加载还有一个重要的考点就是类加载机制 ,也就是所谓的双亲委派机制,即从下往上抛类的信息,优先由上层负责加载,如果上层加载失败,则往下抛,如果最底层的加载器也无法加载,则抛出 ClassNotFoundException 异常;这个机制的作用就是为了防止核心类被第三方类进行调包操作,防止多个系统级别的 Class 生成。
然而,这个机制也挖了不少坑,例如A类调用B类,则B类的加载得由加载A类的类加载器完成;但是 jdbc 核心类由 bootstrap类加载器 加载,而这个加载器只会加载lib/rt.jar而无法加载第三方的jar类,便会出现第三方数据库厂商jar无法被动加载的情况。
所谓的主动加载其实就是我们用户通过 Class.forName() 进行类加载,被动加载则是无需我们通过编写这类代码进行该类的加载
这个时候就引入SPI 和线程上下文类加载器( Thread Context ClassLoader )来加载第三方的jar,所以为什么新版的 mysql 驱动包无需我们手动进行类加载也可以自动加载(旧版的没有用上面提到的加载器)。一般来说这个线程上下文类加载器就是 Application-ClassLoader,所以可以直接加载,但是这种用法从某种意义上属于打破双亲委派机制的一种情况,因为需要由启动类加载器进行加载的类,由应用程序类加载器去进行加载了。
3. JVM内存参数调优
JVM内存调优主要表现在对JVM内存参数的设定上,通过设置符合生产情况来稳定项目的运行,而不是提高运行性能,我们主要看设置的最频繁的参数列表,其余参数目前来说大部分内存参数的默认情况可以基本符合线上需求
示例参数 | 描述 |
---|---|
-Xms20m | 堆初始值20M ,等同于-XX:InitalHeapSize=20m,默认为物理内存的1/64 |
-Xmx20m | 堆最大可用值20M ,等同于-XX:MaxHeapSize=20m,默认为物理内存的1/4 |
-Xss1m | 单个线程栈的大小,等同于-XX:ThreadStackSize,一般默认512k~1024k |
我们尽量将堆的初始值和最大值设置一样,这样做的好处是为了防止JVM在内存不够的时候向OS申请内存以及GC回收后JVM释放部分内存导致的性能下降,在初始化的时候限制死JVM的大小,具体设置多大看具体的项目情况。如果经常出现OOM或FULL GC,可以考虑是否为JVM堆设置的过小,或者是否发生内存泄漏。
xss参数设置的越大,那么可创建的线程数量便越少,因为每个线程栈占用的空间越多,导致可用的内存越少;同时,如果设置堆的内存越大,可创建的线程也会变少,因为线程不在堆内存上创建,JVM只是创建了一个Thread对象,线程在堆内存之外的内存上创建,由OS具体生成,堆给的内存越多,那么OS可使用的内存越少。所以如果项目比较少用到递归或者深度较大的方法,那么这个值可以相对设置小一点,以免出现无法创建线程的OOM。
总结
由于篇幅原因,本章节只是粗略概括目前JVM内存区域划分以及类加载的基本知识内容以及基本的面试题,如果读者想要更进一步,可以通过阅读相关书籍与博客来加深相应的技术深度。
接下来,我们回到最初的三个问题
1. JVM运行时数据区的内存怎么进行划分?
2. JVM的类加载是怎么样的?
3. 如何针对JVM内存参数进行调优?
如果你们可以很流畅的回答这些问题,那么恭喜你,该章节的内容已经全部掌握,如果不行,希望可以回到对应问题讲解的地方,或者对某个不了解的点进行额外的知识搜索,尽量用自己组织的语言回答这些问题。