JVM入门

什么是JVM?它有什么用?

Java虚拟机是运行Java字节码的虚拟机,它就是一台小型计算机,因为它的存在,屏蔽掉了底层操作系统的差异,使JAVA可以一次编写,随处运行。
JVM工作动作

了解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 个步骤:加载验证准备解析初始化使用卸载。其中验证,准备,解析这三个步骤统称为连接接

加载
  1. 将 class 文件加载到内存
  2. 将静态数据结构转化成方法区中运行时的数据结构
  3. 在堆中生成一个代表这个类的java.lang.Class对象作为数据访问的入口
链接
  1. 验证:确保加载的类符合 JVM 规范和安全,保证被校验类不做出危害JVM的事情
  2. 准备:为static变量在方法区中分配空间,并设置初始值,例如static int a=3;在此阶段就会在方法区完成创建,并设置初始默认值为0
  3. 解析:虚拟机将常量池内的符号引用替换为直接引用的过程,例如虚拟机将常量池内的符号引用替换为直接引用的过程在此阶段直接转换为指针或者对象地址
初始化

初始化其实就是执行类构造器方法的<clinit>()的过程,而且要保证执行前父类的<clinit>()方法执行完毕。<cinit>会顺序执行所有类变量(static 修饰的成员变量)显式初始化和静态代码块中语句,例如上文的static int a=3就是这时候完成赋值的。

卸载

当对象使用完成后,GC会将无用对象从内存中卸载

类加载器的加载顺序

加载一个Class类的类加载器其实不止一个,按照类别我们可以把它分为:

  1. BootStrap ClassLoaderrt.jar
  2. Extention ClassLoader:主要负责加载jre/lib/ext目录下的一些扩展的jar包
  3. App ClassLoader:指定的classpath下面的jar包
  4. Custom ClassLoader:自定义的类加载器

双亲委派机制

为了保证类被重复加载并且Java自带的rt.jar中的类被篡改,出现了一种叫双亲委派的机制。
当某个类加载器需要加载某个.class文件时,它首先会检查是否加载过,然后会将这个任务委托给他的上级类加载器检查是否加载过此.class文件,逐级递归这个操作,直到到达BootStrap ClassLoader这时候才开始考虑自己是否能加载,如果上级的类加载器都没有找到加载所需的Class,子加载器才会自行尝试加载,如果所有加载器都没有找到加载所需的Class就抛出**ClassNotFoundException**。

双亲委派工作流

简单的代码例子解释JAVA文件是如何运行的

如下所示,我们先编写一个User类

/**
 * 用户类
 */
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类的静态方法和非静态方法。

/**
 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会开始进行内部的堆、栈、方法区等进行内存空间划分,分配内存大小。

  1. 第一步JVM会将编译好得到的App.class,加载至类加载器(ClassLoader)
    App类的类加载过程

.class文件被加载到类加载器时,他会经历7个步骤:加载验证准备解析初始化使用卸载,在加载阶段,由它将类信息常量到方法区中,而在准备阶段会将静态变量加载到方法区,为静态变量分配内存并设置默认值。
方法区存放信息

  1. JVM找到App的主程序入口,j将main方法压入栈中,执行Main方法

Main执行

  1. main方法中的第一条语句为User user = new User();,但是JVM发现方法区中没有User类的信息,于是开始加载这个类。
    User类的类加载过程

将这个类的信息存放到方法区,并在堆区创建一个Class对象作为方法区信息的入口。
User类的加载过程2

  1. 加载完User类后,JVM在堆中会开辟一个空间调用构造函数初始化User的实例,并让User实例持有指向方法区中的User类的类型信息的引用

<图片占位符>

  1. 然后main方法调用setName时,JVM根据user的引用会先到堆内存找到User的实例,通过其引用找到方法区中User类的方法表得到setName方法的字节码地址,从而完成调用。

<图片占位符>

  1. 按照上面的步骤完成对setAge方法和sayHello方法的调用;方法执行完按照入栈顺序先进后出弹出,虚拟机栈随着线程一起销毁。

虚拟机堆

JVM 内存会划分为堆内存和非堆内存,堆内存中也会划分为年轻代老年代,而非堆内存则为永久代。年轻代又会分为EdenSurvivor区。Survivor 也会分为**FromPlaceToPlace**,toPlacesurvivor 区域是空的。这里所说的永久代只在JDK1.8之前才会出现。在JDK1.8后因为兼容性问题则使用元空间(MetaSpace)代替,最大区别是metaSpace 是不存在于JVM 中的,它使用的是本地内存(物理机上的内存),所以理论上来说物理机内存多大,元空间内存就可以多大。它有两个参数

MetaspaceSize:初始化元空间大小,控制发生GC 
MaxMetaspaceSize:限制元空间大小上限,防止占用过多物理内存。

年轻代

上文说到年轻代会分为EdenSurvivor区,而Survivor区又平均分为**FromPlaceToPlace。所以EdenFromPlace 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区就比较特别了,它分为Survivor0Survivor1区。JVM使用from和to两个指针管理这两块区域,其中from指针指向有对象的区域空间,to指针指向空闲区域的Survivor空间。

老年代

老年代是存储长期存活的对象的,一旦这个空间满了就会触发一次Full GCFull GC期间会停止所有线程等待GC的完成,所以对于高响应的应用应该尽量减少Full GC的发生避免超时。

这就意味着高并发多创建对象的业务场景下,需要合理分配老年代的内存。一旦发生Full GC了仍然无法容纳新对象,就会产生OOM问题。

年轻代To老年代

如何判断对象是否需要被干掉

  1. 引用计数器计算:给对象添加一个引用计数器,被引用时+1,引用失效时-1。减至为0时不再使用。但是这种方式始终无法解决对象循环引用的情况。例如栈中没有引用指向当前两个对象,但是堆中两个对象互相引用对方。

对象互相引用

  1. 可达性分析计算:这是一种类似于二叉树的实现,将一系列的GC ROOTS作为起始的存活对象集,从这个节点往下搜索,搜索所走过的路径成为引用链,把能被该集合引用到的对象加入到集合中。而任何GC ROOTS都不可达的对象则是不可用的要被回收掉。

而以下几种可以作为GC ROOTS:

  1. 虚拟机栈(栈帧中的本地方法表)中引用的对象(局部变量)
  2. 方法区中静态变量,被该变量所引用的对象不可回收
  3. 方法区中常量引用的对象
  4. 本地方法栈(即 native 修饰的方法)中 JNI 引用的对象,该对象都标记为不可回收
  5. 已启动的且未终止的 Java 线程,该线程引用的对象不可回收

判断对象是否真正死亡

判断一个对象的死亡至少需要两次标记

  1. 如果对象经过可达性分析之后没发现与GC ROOTS相连的引用链,则将它第一次标记并且进行一次筛选,然后判断该对象是否要执行finalize方法,若确定执行则放入F-Queue队列中。
  2. F-Queue中的对象调用finalize(),若此时还没有重新与引用链上的任何对象建立连接,则说明该对象要被回收了。

垃圾回收算法

标记清除算法

如下图,这种算法很简单,算法分为标记清除两个阶段,标记出需要被回收的对象的空间,标记结束后统一回收。缺点同样明显,容易造成内存中的碎片很多,会导致我们创建大对象时分配不到一块连续空间供其使用。

标记清除

复制算法

这种算法同上文所说的survivor 一样使用fromto两个指针。from存放当前存活对象,满了以后将存活对象复制到to上,然后交换指针的内容。这样解决了碎片的问题,但是缺点一样明显,可利用的空间缩水了。不过它们分配的时候也不是按照 1:1 这样进行分配的。

复制算法

标记整理算法

复制算法在对象存活率高的时候会有一定的效率问题,标记过程仍然与“标记-清除”算法一样,但将存活对象全部挪到一端,然后直接清楚边界以外内存,确保空闲的内存空间是连续。

标记整理算法

分代收集算法

该算法为上面算法整合版,根据年代特点采用最适当的收集算法,年轻代存活率低,采用复制算法,老年代存活率高,采用标记清除或标记整理算法进行回收。

标记整理算法

复制算法在对象存活率高的时候会有一定的效率问题,标记过程仍然与“标记-清除”算法一样,但将存活对象全部挪到一端,然后直接清楚边界以外内存,确保空闲的内存空间是连续。
标记整理算法

分代收集算法

该算法为上面算法整合版,根据年代特点采用最适当的收集算法,年轻代存活率低,采用复制算法,老年代存活率高,采用标记清除或标记整理算法进行回收。

参考文献

大白话带你认识JVM

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值