点击蓝色“程序员的时光 ”关注我 ,标注“星标”,及时阅读最新技术文章
写在前面:
java
小伙伴儿们,你们好!今天来学习Java虚拟机相关内容,做为面试必问的知识点,来深刻了解一波!程序员
思惟导图:web

1,JVM是什么?
1.1,概述
JVM是Java Virtual Machine
(Java虚拟机)的缩写,JVM是一种用于计算设备的规范。引入Java虚拟机后,Java语言在不一样平台上运行时不须要从新编译。Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就能够在多种平台上不加修改地运行。任何平台只要装有针对于该平台的Java虚拟机,字节码文件(.class)就能够在该平台上运行。这就是“一次编译,屡次运行”。面试
所谓java能实现跨平台,是由在不一样平台上运行不一样的虚拟机决定的,所以java文件的执行不直接在操做系统上执行,而是经过jvm虚拟机执行,咱们能够从这张图看到,JVM并无直接与硬件打交道,而是与操做系统交互用以执行java程序。算法

1.2,JVM运行流程

这个是JVM的组成图,由四个部分组成:编程
-
类加载器数组
类加载器的做用是加载类文件到内存。好比咱们执行一个.java程序的文件,首先使用javac命令进行编译,生成.class文件。而后咱们须要用类加载器将字节码文件加载到内存中去,经过jvm后续的模块进行加载执行程序。至因而否可以执行,则由执行引擎负责。安全
-
执行引擎服务器
执行引擎也叫解释器,负责解释命令,提交操做系统执行。微信
-
本地接口
它的做用是融合不一样的编程语言为Java所用,目前该方法使用的是愈来愈少了,除非是与硬件有关的应用,好比经过Java程序驱动打印机,或者Java系统管理生产设备。
-
运行时数据区
运行数据区是整个JVM的重点。咱们全部写的程序都被加载到这里,以后才开始运行,Java生态系统如此的繁荣,得益于该区域的优良自治。整个JVM框架由加载器加载文件,而后执行器在内存中处理数据,须要与异构系统交互是能够经过本地接口进行!
2,JVM的内存区域
内存区域也就是上面的运行时数据区。对于从事C或者C++的程序员来讲,必须对每一个对象的整个生命周期负责。可是对java程序员来讲,在jvm的自动内存管理机制下,不须要为每个对象去写delete
或者free
代码,不容易出现内存泄漏或内存溢出的问题。但正由于java程序员将内存管理权力交给了内存管理机制,因此一旦出现内存泄漏或者内存溢出的问题,在对jvm内存结构不清楚的状况下,排查错误将会成为一项很是复杂且困难的工做。
运行时数据区

2.1,程序计算器
程序计数器是一小块的内存区域,能够看作当前线程执行字节码的行号指示器,在虚拟机的概念模型里,**字节码解释工做就是经过改变这个程序计数器的值来选取下一个要执行的字节码指令。**好比分支控制,循环控制,跳转,异常等操做,线程恢复等功能都是经过这个计数器来完成。
因为jvm的多线程是经过线程的轮流切换并分配处理器执行时间来实现的。所以,在任何一个肯定的时刻,一个处理器(对于多核处理器来讲是一个内核)都只会执行一条线程中的指令。所以,为了线程切换后能回到正确的执行位置,每条线程都须要本身独有的程序计数器,多条线程计数器之间互不影响,独立存储。咱们称这类内存区域为线程私有的内存区域。
若是线程执行的是Java方法时,程序计数器记录的是 Java 虚拟机正在执行的字节码指令的地址,而在线程执行 Native 方法时,程序计数器为空,由于此时 Java 虚拟机调用是和操做系统相关的接口,接口的实现不是 Java 语言,而是 C语言和 C++。
程序计数器是惟一一个在Java虚拟机中不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的建立而建立,随着线程的结束而结束。
2.2,Java虚拟机栈
与程序计数器一致,Java虚拟机栈也是线程私有的,生命周期与线程相同。虚拟机栈描述的是Java方法的执行内存模型,每一个方法在执行的时候都会建立一个栈帧(用于存储局部变量表、操做数栈、动态链栈、方法出口等信息)。每个方法从执行到结束的过程,就对应一个栈帧从入栈到出栈的过程。
Java内存能够粗糙地分为堆内存(Heap)和栈内存(Stack),固然Java内存区域的划分实际上远比这复杂,咱们如今所说的Java虚拟机栈就是这里的栈内存,或者说是虚拟机栈中局部变量表部分。
局部变量表存放了编译器可知的四类八种基本数据类型(boolean、byte、char、short、int、float、long、double),对象引用(reference类型,它不一样于对象自己,多是一个指向对象起始地址的引用指针,也多是指向一个表明对象的句柄或其余与此对象相关的位置)。
Java虚拟机会出现两种异常情况:
若是线程在栈中申请的深度大于虚拟机所容许的深度,将出现StackOverFlowError
异常; 若是虚拟机栈能够动态扩展,且扩展没法申请到足够的内存,就会抛出OutOfMemoryError
异常。
2.3,本地方法栈
本地方法栈与虚拟机栈的做用很是相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务,在 HotSpot 虚拟机中直接将虚拟机栈和本地方法栈合二为一。
与Java虚拟机栈同样,本地方法栈在执行的时候也会建立一个栈帧(用于存储局部变量表、操做数栈、动态链栈、方法出口等信息)。也会抛出StackOverFlowError
异常和OutOfMemoryError
异常。
2.4,Java堆
Java堆是JVM所管理的内存中最大的一块区域,Java堆是被全部线程所共享的一片区域,在虚拟机启动时建立。此内存区域的惟一目的就是存放对象实例,几乎全部的对象实例以及数组都在这里分配内存。
Java堆是垃圾收集器管理的主要区域,所以也被称做GC堆(Garbage Collected Heap)。从内存回收的角度看,因为如今收集器基本都采用分代垃圾收集算法,因此Java堆还能够细分为:新生代和老年代。**进一步划分的目的是更好地回收内存,或者更快地分配内存。**根据JVM的规范规定,Java堆能够处于物理上不连续的内存空间,只要逻辑上是连续的便可。若是在堆中没有完成内存分配,且堆也没有可扩展的内存空间,则会抛出OutOfMemoryError
异常。
2.5,方法区
方法区与 Java 堆同样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,可是它却有一个别名叫作 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
Java虚拟机相对而言对方法区的限制很是宽松,除了和堆同样不须要连续的空间和能够选择固定大小或者可扩展以外,还能够选择不实现垃圾回收。相对而言,垃圾回收在这个区域算比较少见了,但并不是数据进入方法区之后就能够实现永久存活了,这个区域的回收目标主要是常量池的回收和对类型的卸载,通常来讲,这个区域的回收成绩是比较难以让人满意的。尤为是类型的卸载,条件至关苛刻。根据Java虚拟机规范规定,当方法区没法知足内存分配时,将抛出OutOfMemoryError
异常。
咱们在这里举一个简单例子来看看,看看上述的哪些信息会存放上方法区中;
静态变量和常量,在编译期间就放在方法区中;
//静态变量,在编译期间存放在方法区
private static int num=10;
//常量,在编译期间存放在方法区
private final String name="boy";
咱们先来看看new String时堆中的变化;
String s1="hello";
String s2=new String("hello");
String s3=new String("hello");
System.out.println(s1==s3); // false
System.out.println(s2==s3); // false
这个输出的结果确定是false,采用new的时候会在堆内存开辟一块空间存放hello对象,虽然s2和s3指向的内容相同,可是栈种存放的地址不一样,因此是不相等的。

对于引用类型来讲,"=="指的是地址值的比较。
双引号直接写的字符串是在常量池之中,而new的对象则不在池之中。
再来看看运行期间添加进常量池的;
String s2=new String("hello");
String s3=new String("hello");
//在运行过程当中添加进常量池中
System.out.println(s2.intern()==s3.intern());

若是常量池中存在当前字符串,那么直接返回常量池中该对象的引用。
若是常量池中没有此字符串, 会将此字符串引用保存到常量池中后, 再直接返回该字符串的引用!
2.6,运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各类字面量和符号引用)。既然运行时常量池时方法区的一部分,天然受到方法区内存的限制,当常量池没法再申请到内存时会抛出 OutOfMemoryError
异常。
2.7,直接内存
直接内存并不属于Jvm运行时数据区的一部分,可是这部份内存区域被频繁的调用,也可能发生OutOfMemoryError异常。显然本机的直接内存不会受到Java堆分配内存的影响,可是既然是内存,确定要受到本机总内存大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但常常忽略直接内存。使得各个区域的内存总和大于物理内存限制,从而致使动态扩展时出现OutOfMemoryError
异常。
3,Java对象的建立过程
下面这张图就是Java对象建立的过程,总共来讲分为五部分;

3.1,类加载过程
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,而且检查这个符号引用表明的类是否已被加载过、解析和初始化过。若是没有,那必须先执行相应的类加载过程。
3.2,分配内存
在类加载检查经过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后即可肯定,为对象分配空间的任务等同于把一块肯定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪一种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
指针碰撞:
-
场景:Java堆中内存是绝对规整的; -
原理:全部用过的内存都放在一边,空闲的内存放在另一边,中间放一个指针做为分界点的指示器,分配内存时只须要把那个指针向空闲空间那边挪动一段与对象大小相等的距离就能够了; -
GC收集器:Serial、ParNew等带Compact过程的收集器。
空闲列表:
-
场景:Java堆中内存不是规整的; -
原理:虚拟机会维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录; -
GC收集器:CMS基于Mark-Sweep算法的收集器。
内存分配并发的问题
在建立对象的时候还须要考虑的一个问题就是在并发状况下,线程是否安全的问题。由于建立对象在虚拟机中是很是频繁的行为,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的状况。所以必需要保证线程安全,解决这个问题有两种方案:
-
**CAS以及失败重试(比较和交换机制):**对分配内存空间的操做进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操做的原子性。CAS操做须要输入两个数值,一个旧值(操做前指望的值)和一个新值,在操做期间先比较旧值有没有发送变化,若是没有变化,才交换成新值,不然不进行交换。 -
**TLAB(分配缓冲):**把内存分配的动做按照线程划分在不一样的空间之中进行,即每一个线程在Java堆中预先分配一小块私有内存,也就是本地线程分配缓冲。TLAB的目的是在为新对象分配内存空间时,让每一个Java应用线程能在使用本身专属的分配指针来分配空间,减小同步开销。
3.3,初始化零值
内存分配完成后,虚拟机须要将分配到的内存空间都初始化为零值(不包括对象头),这一步操做保证了对象的实例字段在 Java 代码中能够不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
3.4,设置对象头
初始化零值完成以后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不一样,如是否启用偏向锁等,对象头会有不一样的设置方式。
3.5,执行Init方法
在上面工做都完成以后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象建立才刚开始,<init>
方法尚未执行,全部的字段都还为零。因此通常来讲,执行 new 指令以后会接着执行 <init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算彻底产生出来。
4,对象的访问定位
创建对象就是为了使用对象,咱们的Java程序经过栈上的 reference
数据来操做堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有使用句柄和直接指针两种。
4.1,使用句柄
若是使用句柄的话,那么Java堆中将会划分出一块内存来做为句柄池,reference
中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。如图所示:

4.2,直接指针
若是使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference 中存储的直接就是对象的地址。如图所示:

这两种对象访问方式各有优点,**使用句柄来访问的最大好处就是reference 中存储的是稳定的句柄地址,**在对象被移动时只会改变句柄中的实例数据指针,而 reference 自己不须要修改。**使用直接指针访问方式最大的好处就是速度更快,**它节省了一次指针定位的时间开销。因为对象的访问在Java中很是频繁,所以这类开销聚沙成塔后也是一项很是乐观的执行成本。
5,OutOfMemoryError(内存溢出)异常
在Java虚拟机规范的描述中,除了程序计算器以外,**虚拟机内存的其余几个运行时区域都有发生OutOfMemoryError
异常的可能。**如今咱们经过两个实例来验证异常发生的场景,也会初步介绍几个与内存相关的最基本的虚拟机参数。
5.1,堆内存异常
咱们来演示一下堆内存的异常:
/**
* @author 公众号:程序员的时光
* @create 2020-11-23 08:54
* @description
*/
public class HeapOOM {
public static void main(String[] args) {
//测试堆内存异常
List<HeapOOM> heapOOMList=new ArrayList<>();
//这里只添加一个对象,不会发生异常
heapOOMList.add(new HeapOOM());
//添加进死循环,不断地new对象,堆内存已经耗尽
while (true) {
heapOOMList.add(new HeapOOM());
}
}
}
在运行这个程序以前,咱们先要设置Java虚拟机的参数。因为IDEA默认设置的堆内存很大,因此咱们须要单个配置;点击Run >> Edit Configurations
,而后就开始配置,以下,初始化堆内存和最大堆内存都设置为10m,看看上面的死循环可否在10m内存中完成;

咱们来看运行结果:

能够看到堆内存发生异常,上面的死循环中咱们不断地new对象,致使堆内存已经耗尽,没法为新生的对象分配内存,从而发生异常。
5.2,栈内存异常
再来看看栈内存异常:
/**
* @author 公众号:程序员的时光
* @create 2020-11-23 09:14
* @description
*/
public class StackOOM {
public static void main(String[] args) {
test();
}
//咱们设置一个简单的递归方法,没有跳出递归条件的话,就会发生栈内存异常
public static void test(){
test();
}
}
咱们设置一个简单的递归方法,可是不给出跳出递归条件,这样的话就会异。
运行结果以下:

这种是线程请求的栈深度超过虚拟机所容许的最大深度,抛出StackOverflowError
异常,缘由就是使用不合理的递归形成的。
咱们再来看看第二种异常状况:
/**
* @author 公众号:程序员的时光
* @create 2020-11-23 10:05
* @description
*/
public class StackOOM1 {
//线程任务,每一个线程任务一直在执行
private void WinStop(){
while(true){
System.out.println(System.currentTimeMillis());
}
}
//不断建立线程
public void StackByThread(){
while(true){
Thread thread=new Thread(new Runnable() {
@Override
public void run() {
WinStop();
}
});
}
}
public static void main(String[] args) {
StackOOM1 stackOOM1=new StackOOM1();
stackOOM1.StackByThread();
}
}
上述代码的理论上运行结果是:Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
,可是运行这段代码可能会致使操做系统卡顿,运行须谨慎。
这种是虚拟机在扩展栈时没法申请到足够的内存空间,抛出OutOfMemoryError
异常,缘由是不断建立活跃的线程形成的。
微信搜索公众号《程序员的时光》
好了,今天就先分享到这里了,下期继续给你们带来JVM垃圾回收面试内容!
更多干货、优质文章,欢迎关注个人原创技术公众号~
参考文献:
[1].深刻理解Java虚拟机(第2版) .做者 周志明
本文分享自微信公众号 - 程序员的时光(gh_9211ec727426)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。