什么是JVM?它有什么用?
Java虚拟机是运行Java字节码的虚拟机,它就是一台小型计算机
,因为它的存在,屏蔽掉了底层操作系统的差异,使JAVA
可以一次编写,随处运行。
了解JVM内存划分吗?
方法区
方法区是用于存放类信息 、常量 、静态变量以及编译信息等数据。
堆
堆
存储的是对象实例,数组等信息。例如new User()这样的操作就是在堆中分配了一个内存空间存放User的实例 。它和方法区都属于线程共享区域
。所以他们俩在多线程情况下存在线程安全问题
的。
栈
栈
是我们的代码的运行空间,我们编写的每一个方法都会放到栈
里面运行。栈区分为虚拟机栈和本地方法栈,其中:
虚拟机栈
每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame)
,对应着一次次的 Java 方法调用。每个栈帧中存储着:局部变量表 、操作数栈 、动态链接 、方法返回地址、一些附加信息等信息。
在Java
中方法是可以嵌套使用的,但这不意味着可以无限嵌套使用,当方法嵌套调用的深度大于虚拟机栈的最大深度,就会报StackOverflowError
,这种错误常常发生在递归代码中。
虚拟机属于线程独享,所以不存在垃圾回收。每个方法随着调用的结束栈空间也随之释放。所以栈的生命周期和所处的线程是一致的。
这就使得虚拟机栈中的局部变量可以被复用,而局部变量是和方法参数存放在局部变量表的,该表的容量是以Slot
为最小单位,一个Slot
可以存放32位以内的数据类型。
虚拟机是通过索引定位的方式使用局部变量表,范围为[0,局部变量表的 slot 的数量]。例如某个虚拟机栈当前局部变量表被使用的索引为0-n,一旦虚拟机执行的代码超过了n位置,那么n之前的内存空间就可以再次被使用。
本地方法栈
在一些源码中你会看到带有native关键字修饰的方法,这种用native修饰的方法就是本地方法,这是使用C来实现的,一般这些方法都会被放到一个叫做本地方法栈的区域。
程序计数器
概念与操作系统的程序计数器差不多,记录当前线程下一行要执行的指令的地址,和栈
一样都是线程独享
的,不存在线程安全问题并且它也是内存区域中唯一一个不会出现OutOfMemoryError
的区域。 如果执行的是 native 方法,那这个指针就不工作了。
小结
总结一下运行时区域,整体如下图所示
类加载器
什么是类加载器?
类加载器实现将编译后的 class 文件字节码内容加载到内存中,并将这些内容转为为方法区的运行时数据结构,注意ClassLoader
只能决定类加载,至于能不能运行则是由 Execution Engine
来决定。
类加载器的工作流程
类加载器的工作流程总共有 7 个步骤:加载
,验证
,准备
,解析
,初始化
,使用
,卸载
。其中验证,准备,解析这三个步骤统称为连接接。
加载
- 将 class 文件加载到内存
- 将静态数据结构转化成方法区中运行时的数据结构
- 在堆中生成一个代表这个类的
java.lang.Class
对象作为数据访问的入口
链接
- 验证:确保加载的类符合
JVM
规范和安全,保证被校验类不做出危害JVM
的事情 - 准备:为
static
变量在方法区中分配空间,并设置初始值,例如static int a=3;
在此阶段就会在方法区完成创建,并设置初始默认值为0 - 解析:虚拟机将常量池内的符号引用替换为直接引用的过程,例如
虚拟机将常量池内的符号引用替换为直接引用的过程
在此阶段直接转换为指针或者对象地址
初始化
初始化其实就是执行类构造器方法的<clinit>()
的过程,而且要保证执行前父类的<clinit>()
方法执行完毕。<cinit>
会顺序执行所有类变量(static 修饰的成员变量)显式初始化和静态代码块中语句,例如上文的static int a=3
就是这时候完成赋值的。
卸载
当对象使用完成后,GC
会将无用对象从内存中卸载
类加载器的加载顺序
加载一个Class类的类加载器其实不止一个,按照类别我们可以把它分为:
BootStrap ClassLoader
:rt.jar
Extention ClassLoader
:主要负责加载jre/lib/ext
目录下的一些扩展的jar包App ClassLoader
:指定的classpath
下面的jar包Custom ClassLoader
:自定义的类加载器
双亲委派机制
为了保证类被重复加载并且Java自带的rt.jar
中的类被篡改,出现了一种叫双亲委派
的机制。 当某个类加载器需要加载某个.class
文件时,它首先会检查是否加载过,然后会将这个任务委托给他的上级类加载器检查是否加载过此.class
文件,逐级递归这个操作,直到到达BootStrap ClassLoader
这时候才开始考虑自己是否能加载,如果上级的类加载器都没有找到加载所需的Class,子加载器才会自行尝试加载,如果所有加载器都没有找到加载所需的Class就抛出**ClassNotFoundException
**。
简单的代码例子解释JAVA文件是如何运行的
如下所示,我们先编写一个User类
java
/**
* 用户类
*/
public class User {
private String name;
private Integer age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
编写App
类,声明一个常量、静态变量、非静态方法以及静态方法,常量和静态变量主要用来完成用户类实例化,然后调用App
类的静态方法和非静态方法。
java
/**
1. App类
*/
public class App {
/**
* 变量
*/
private static String USER_NAME = "张三";
/**
* 常量
*/
private final static Integer AGE = 25;
/**
* 非静态方法
*/
private void sayHello(User user) {
System.out.println("My name is " + user.getName());
}
/**
* 静态方法
*/
private static void print() {
System.out.println("静态方法输出");
}
public static void main(String[] args) {
User user = new User();
user.setName(USER_NAME);
user.setAge(AGE);
App app = new App();
app.sayHello(user);
print();
}
}
首先JVM会先向操作系统申请分配内存空间,然后内存空间分配下来后,JVM会开始进行内部的堆、栈、方法区等进行内存空间划分,分配内存大小。
- 第一步JVM会将编译好得到的App.class,加载至
类加载器(ClassLoader)
从.class
文件被加载到类加载器时,他会经历7个步骤:加载
,验证
,准备
,解析
,初始化
,使用
,卸载
,在加载阶段,由它将类信息 、常量 到方法区中,而在准备阶段会将静态变量 加载到方法区,为静态变量 分配内存并设置默认值。
JVM
找到App
的主程序入口,j将main方法压入栈中,执行Main方法
- main方法中的第一条语句为
User user = new User();
,但是JVM
发现方法区中没有User
类的信息,于是开始加载这个类。
将这个类的信息存放到方法区,并在堆区创建一个Class对象作为方法区信息的入口。
- 加载完User类后,
JVM
在堆中会开辟一个空间调用构造函数初始化User的实例,并让User
实例持有指向方法区中的User类的类型信息
的引用
<图片占位符>
- 然后
main
方法调用setName
时,JVM
根据user
的引用会先到堆内存找到User
的实例,通过其引用找到方法区中User
类的方法表得到setName
方法的字节码地址,从而完成调用。
<图片占位符>
- 按照上面的步骤完成对
setAge
方法和sayHello
方法的调用;方法执行完按照入栈顺序先进后出弹出,虚拟机栈随着线程一起销毁。
虚拟机堆
JVM
内存会划分为堆内存和非堆内存,堆内存中也会划分为年轻代 和老年代 ,而非堆内存则为永久代 。年轻代又会分为Eden 和Survivor 区。Survivor
也会分为**FromPlace
和 ToPlace
**,toPlace
的survivor
区域是空的。这里所说的永久代只在JDK1.8
之前才会出现。在JDK1.8
后因为兼容性问题则使用元空间(MetaSpace)
代替,最大区别是metaSpace
是不存在于JVM
中的,它使用的是本地内存(物理机上的内存),所以理论上来说物理机内存多大,元空间内存就可以多大。它有两个参数
java
MetaspaceSize:初始化元空间大小,控制发生GC
MaxMetaspaceSize:限制元空间大小上限,防止占用过多物理内存。
年轻代
上文说到年轻代会分为Eden 和Survivor 区,而Survivor 区又平均分为**FromPlace
和 ToPlace
。所以Eden
,FromPlace
和 ToPlace
的默认占比为 8:1:1
**。当然这个东西其实也可以通过一个 -XX:+UsePSAdaptiveSurvivorSizePolicy
参数来根据生成对象的速率动态调整。
我们刚创建的对象都会先放到Eden区,我们都知道堆内存是线程共享的,所以Eden区也是线程共享的,但是为了确保多线程情况下防止两个对象公用一个内存,JVM
的处理是专门划出一块连续的空间给每个线程分配一个独立空间,这个操作我们称作TLAB
。
当 Eden
区满了之后,就会触发第一次Minor GC
,存活下来的对象会从Eden
区移动到 Survivor0
区。Survivor0
区满后不会触发 Minor GC
,而是当下一次Eden区也满了之后才再次触发Minor GC
,此时就会将存活对象移动到 Survivor1
区,还会把 from 和 to 两个指针交换,这样保证了一段时间内总有一个 survivor 区为空且 to 所指向的 survivor 区为空。经过**15
**次的Minor GC
后仍然存活的对象j就会被移动到老年代,这里15是由 **-XX:MaxTenuringThreshold
**指定的,因为 HotSpot
会在对象头中的标记字段里记录年龄,分配到的空间仅有 4 位,所以最多只能记录到 15。
一旦Eden区满了之后,就会触发第一次Minor GC
,就会将存活的对象从Eden区放到Survivor区。 Survivor区就比较特别了,它分为Survivor0
和Survivor1
区。JVM
使用from和to两个指针管理这两块区域,其中from指针指向有对象的区域空间,to指针指向空闲区域的Survivor空间。
老年代
老年代是存储长期存活的对象的,一旦这个空间满了就会触发一次Full GC
,Full GC
期间会停止所有线程等待GC
的完成,所以对于高响应的应用应该尽量减少Full GC
的发生避免超时。
这就意味着高并发多创建对象的业务场景下,需要合理分配老年代的内存。一旦发生Full GC
了仍然无法容纳新对象,就会产生OOM
问题。
如何判断对象是否需要被干掉
- 引用计数器计算:给对象添加一个引用计数器,被引用时+1,引用失效时-1。减至为0时不再使用。但是这种方式始终无法解决对象循环引用的情况。例如栈中没有引用指向当前两个对象,但是堆中两个对象互相引用对方。
- 可达性分析计算:这是一种类似于二叉树的实现,将一系列的
GC ROOTS
作为起始的存活对象集,从这个节点往下搜索,搜索所走过的路径成为引用链,把能被该集合引用到的对象加入到集合中。而任何GC ROOTS
都不可达的对象则是不可用的要被回收掉。
而以下几种可以作为GC ROOTS
:
- 虚拟机栈(栈帧中的本地方法表)中引用的对象(局部变量)
- 方法区中静态变量,被该变量所引用的对象不可回收
- 方法区中常量引用的对象
- 本地方法栈(即 native 修饰的方法)中
JNI
引用的对象,该对象都标记为不可回收 - 已启动的且未终止的 Java 线程,该线程引用的对象不可回收
判断对象是否真正死亡
判断一个对象的死亡至少需要两次标记
- 如果对象经过可达性分析之后没发现与
GC ROOTS
相连的引用链,则将它第一次标记并且进行一次筛选,然后判断该对象是否要执行finalize
方法,若确定执行则放入F-Queue
队列中。 - 将
F-Queue
中的对象调用finalize()
,若此时还没有重新与引用链上的任何对象建立连接,则说明该对象要被回收了。
垃圾回收算法
标记清除算法
如下图,这种算法很简单,算法分为标记
和清除
两个阶段,标记出需要被回收的对象的空间,标记结束后统一回收。缺点同样明显,容易造成内存中的碎片很多,会导致我们创建大对象时分配不到一块连续空间供其使用。
复制算法
这种算法同上文所说的survivor
一样使用from
和to
两个指针。from
存放当前存活对象,满了以后将存活对象复制到to
上,然后交换指针的内容。这样解决了碎片的问题,但是缺点一样明显,可利用的空间缩水了。不过它们分配的时候也不是按照 1:1
这样进行分配的。
标记整理算法
复制算法在对象存活率高的时候会有一定的效率问题,标记过程仍然与"标记-清除"算法一样,但将存活对象全部挪到一端,然后直接清楚边界以外内存,确保空闲的内存空间是连续。
分代收集算法
该算法为上面算法整合版,根据年代特点采用最适当的收集算法,年轻代存活率低,采用复制算法,老年代存活率高,采用标记清除或标记整理算法进行回收。
标记整理算法
复制算法在对象存活率高的时候会有一定的效率问题,标记过程仍然与"标记-清除"算法一样,但将存活对象全部挪到一端,然后直接清楚边界以外内存,确保空闲的内存空间是连续。
分代收集算法
该算法为上面算法整合版,根据年代特点采用最适当的收集算法,年轻代存活率低,采用复制算法,老年代存活率高,采用标记清除或标记整理算法进行回收。