likes
comments
collection
share

Android进阶宝典 -- JVM运行时数据区

作者站长头像
站长
· 阅读数 8

JVM(Java虚拟机),平时在日常开发工作中根本用不到,却是面试的重点,所以为什么要掌握JVM呢?关键在于掌握好JVM能让我们的应用变得更健壮。我们是否遇到过下面的场景:

(1)App莫名其妙地产生卡顿; (2)线下测试好好的,到了线上就出现OOM; (3)自己写的代码质量不高;

上述这些问题的出现就是因为我们对于JVM的基础知识掌握不牢固,导致写出的代码线上出现各种问题;为什么出现卡顿,有一部分原因是因为频繁GC导致内存抖动;为什么出现OOM,哪块内存区域会出现OOM或者栈溢出,所以学习JVM的重要性不言而喻。

Android进阶宝典 -- JVM运行时数据区

1 什么是JVM

JVM可以理解为一种规范,任何高级语言只要通过编译器能生成.class文件,都可以交给JVM来加载执行,所以无论是Java,还是Kotlin,虽然JVM被称作是Java虚拟机,但是Kotlin也是会被编译器编译成.class文件,交由JVM加载,所以即便在日常开发工作中使用Kotlin,也需要掌握JVM的原理。

Android进阶宝典 -- JVM运行时数据区 JVM主要由3大部分组成:类加载器、运行时数据区、执行引擎

类加载器:将编译好的class文件加载到JVM内存当中; 运行时数据区:主要是指文章开头JVM内存模型,用于存储程序执行过程中产生的数据; 执行引擎:执行字节码指令,会跟运行时数据区有交互,产生的数据会存储在运行时数据区

Android进阶宝典 -- JVM运行时数据区

2 运行时数据区 -- 栈内存

本节着重介绍JVM运行时数据区,主要分为两大部分,堆和栈。

2.1 堆和栈的职责

按照程序运行时的功能划分,堆是运行时的存储单位,栈是运行时的处理单位

也就是说,堆是解决数据存储问题,数据应该存在哪?怎么存?而栈则是解决程序运行问题,程序如何执行,怎么处理数据,方法怎么执行等

2.2 程序计数器

程序计数器,也是属于一种寄存器,它是唯一一块不会产生内存泄漏的区域;它的主要作用就是在多线程的场景下,记录代码执行位置

首先我们看一个简单的方法

public static void getByteCode() {
    int a = 10;
    int b = 20;
    int c = a + b;
}

这个方法在JVM中执行时的字节码指令为:

 0 bipush 10
 2 istore_0
 3 bipush 20
 5 istore_1
 6 iload_0
 7 iload_1
 8 iadd
 9 istore_2
10 return

如果有熟悉CPU时间片轮转机制的伙伴们,应该知道程序运行的时间会被切片,每一段都是一个CPU的时间片,如下图

Android进阶宝典 -- JVM运行时数据区

例如有多个线程A和B......会争夺CPU时间片,当线程A获取到时间片1的时候,执行getByteCode方法,从0 - 5,然后下一秒,线程B获取了CPU时间片2,然后线程A又获取了时间片3,这个时候线程A需要知道之前执行到哪个位置,然后继续从5位置执行

在JVM的栈存储区中,程序计数器的执行速度是最快的,虚拟机栈其次。

2.3 虚拟机栈

虚拟机栈的职责:负责承载程序运行过程中产生的值变量、运算结果、方法的调用以及返回数据的管理,它属于线程私有的,随着线程的创建而开辟。

我们知道,虚拟机栈最核心的一个功能就是方法的执行,每个方法的执行,都会有一个栈帧入栈。

public class JVMByteUtils {

    public static void getByteCode() {
        int a = 10;
        int b = 20;
        Person person = new Person("小明");
        int c = a + b;
        int age = getMyAge();
        int sum = c + age;
        person.setName("小王");
    }


    private static int getMyAge(){
        return 19;
    }
}

Android进阶宝典 -- JVM运行时数据区

getByteCode方法会首先被压入栈内,然后getMyAge方法栈帧入栈,出栈顺序为先进后出。默认情况下,虚拟机栈空间为1M,这个选项是可修改的,一旦超出虚拟机栈内存的大小,就会栈溢出Stack Overflow

在栈帧中,主要分为4大块区域,分别为:

Android进阶宝典 -- JVM运行时数据区

2.3.1 局部变量表

主要存储在方法中定义的局部变量,例如getByteCode方法中的变量a、b、c、age等等;它是一个数组,除了存储局部变量之外,还会存储方法的参数。

Android进阶宝典 -- JVM运行时数据区

我们可以看一下,在getByteCode方法中,局部变量表中有6个元素,其中person也在其中。

其实我们在一开始学习的时候,经常说在栈中存储引用变量,其实是不准确的,确切的说是存储在栈帧中的局部变量表中。

Android进阶宝典 -- JVM运行时数据区

注意:方法参数也会存储在局部变量表中哦

2.3.2 操作数栈

主要用于在方法的执行过程中,根据字节码指令,往栈内压入数据或者提取数据。主要的操作像赋值、交换、四大运算(+ - x ÷)等。

public static int testOp(){
    int a = 10;
    int b = 20;
    int c = (a + b) * 5;
    return c;
}

我们看下这个方法的字节码指令:

 0 bipush 10
 2 istore_0
 3 bipush 20
 5 istore_1
 6 iload_0
 7 iload_1
 8 iadd
 9 iconst_5
10 imul
11 istore_2
12 iload_2
13 ireturn

局部变量表位置表:

Android进阶宝典 -- JVM运行时数据区

我们主要看2个指令:

(1)执行bipush 10命令,将局部变量10压入操作数栈; (2)执行istore_0命令,将局部变量10出栈,放入局部变量表0的位置,也就是给a赋值的地方;\

所以在操作数栈中,会把所有的计算工作完成,然后执行istore_x命令,将所得的结果放入局部变量表中

2.3.3 动态链接

Android进阶宝典 -- JVM运行时数据区 当.class文件通过类加载器加载完成之后,会将class文件信息存储在方法区

Android进阶宝典 -- JVM运行时数据区 那么class文件信息包含哪些呢,使用javap -v -p ./JVMByteUtils.class命令可以查看,那么看上图其实包含很多,其中有一个最重要的就是常量池,像#1、#2......这些都是符号引用。

0 bipush 10
 2 istore_1
 3 bipush 20
 5 istore_2
 6 new #2 <com/lay/mvi/jvm/Person>
 9 dup
10 ldc #3 <小明>
12 invokespecial #4 <com/lay/mvi/jvm/Person.<init> : (Ljava/lang/String;)V>
15 astore_3
16 iload_1
17 iload_2
18 iadd
19 istore 4
21 invokestatic #5 <com/lay/mvi/jvm/JVMByteUtils.getMyAge : ()I>
24 istore 5

我们看下getByteCode方法的字节码指令,当执行到getMyAge方法的时候,字节码指令是invokespecial #5,在常量池中我们可以看到 #5 就是getMyAge方法。

Android进阶宝典 -- JVM运行时数据区

所以当Java文件编译成.class文件之后,所有的变量还有方法都是作为符号引用存储在class文件的常量池中Constant Pool中,当调用某个方法的时候(一般都是invoke指令),并不是直接去找某个方法,而是利用符号引用去常量池中去查找,最终通过符号引用一层一层查找到了直接引用 getMyAge。

3 运行时数据区 -- 堆内存

堆区是线程共享区域,在一个进程中只有一个堆区,所有线程共享堆区。

Android进阶宝典 -- JVM运行时数据区

在堆区的内存结构中,需要分为2大块:新生代和老年代,在Java 7之前还有一个区域被称为是永久代,在Java8之后叫做元空间,其实无论是永久代还是元空间,都是用来存储长期存在的常量对象,在方法区。

那么分区的目的是什么呢?为了提高GC性能,如果所有的对象全部存储在一块区域,在GC扫描的时候就需要扫描全部对象,非常消耗资源;做了分区就有了优先级之分,可以重点扫描某个区域,提高GC性能。

3.1 内存分配与GC引入

我们知道,当我们创建一个对象的时候,是存储在堆区的,那么具体细化存储在哪里呢?其实大部分对象出生地都是在新生代的eden区。

Android进阶宝典 -- JVM运行时数据区

当Eden区内存满了之后,就会触发一次Minor GC,俗称小GC,会扫描Eden区发现哪些对象可以被回收,如果不能回收的对象,年龄加1,放入survivor(From)区

Android进阶宝典 -- JVM运行时数据区 等到Eden区又满了之后,会再次触发 Minor GC,这个时候会扫描Eden区和From区

Android进阶宝典 -- JVM运行时数据区

这个时候,会把Eden区和From区的不死对象整理全部放在to区,这样的目的是什么呢?

Android进阶宝典 -- JVM运行时数据区

因为From区和to区在逻辑上是一块连续的内存区域,当进行一次垃圾回收之后,部分对象被回收,内存变得不连续了,从而出现了内存碎片,采用这种复制算法目的就是为了清空内存碎片,提高查询效率。

当连续Minor GC之后,不分对象的年龄超过6,那么这些对象将会被分配到了老年代 Android进阶宝典 -- JVM运行时数据区

这个时候,随着对象不断分配,老年代中的对象越来越多,最终老年代的内存不足时,会触发Major GC,同时会伴随至少一次Minor GC。

当分配一个大对象时,因为Eden区放不下,则会直接跳过新生代进入老年代,这个时候,老年代也接不住,会进入一次Major GC,如果还是放不下,那么就会直接OOM,这也是OOM产生的直接原因。

3.2 对象逃逸与代码优化

前面我们提到,大部分的对象都是分配在堆内存中,也就是说会有一小部分的对象并不是分配在堆内存中,是这样的吗?先看下面的代码

public void testGc() {
    Point point = new Point();
    //....do something
    point = null;
}

在一个方法中,当创建的point对象使用完成之后,直接在方法中完成了销毁,这种就是未发生对象逃逸;

public Point testGc2() {
    Point point = new Point();
    point.set(50, 50);
    return point;
}

当在一个方法中完成对象创建,并作为返回值将对象抛出,这种就是发生了对象逃逸。

那么产生对象逃逸之后,会有什么影响呢?因为对于没有发生对象逃逸的场景,会得到虚拟机的优化,也就是说会通过JIT来做逃逸分析,判定当前对象有没有可能被外界使用,主要分为两种:

(1)栈上分配

对于没有发生逃逸的对象,当前对象可能会被优化直接在栈上分配,这样就会减少GC的次数

public void alloc(){
    Point point = new Point();
}

例如point对象,作用域只在alloc方法内部,JIT编译器在编译字节码时会做逃逸分析,因为point其他地方用不到,因此直接优化为在栈上分配内存。

public static void alloc(){
    Point point = new Point();
}

public static void main(String[] args) {
    long l = System.currentTimeMillis();
    for (int i = 0; i < 100000000; i++) {
        alloc();
    }
    System.out.println("耗时==>"+(System.currentTimeMillis() - l));
}

如果在关闭逃逸分析后,上述代码执行过程中会频繁发生GC;而开启逃逸分析之后,这个方法执行过程中没有GC产生,而且执行速度提高几十倍。

当然这个过程不是每次都会栈上分配,而是会权衡堆区内存决定是否在栈上分配。

(2)标量替换

标量,我们可以认为它就是基本数据类型,这是数据结构的最小量级,不能再往下细分了;还有一个概念叫做聚合量,我们可以理解为对象,它可以拆开更细小的标量。

public static void alloc(){
    Point point = new Point();
}

我们还是看alloc方法,在这个方法中,对象p就是聚合量

public class Point implements Parcelable {
    public int x;
    public int y;
}

那么如果这个对象没有发生逃逸,那么JIT会做什么优化呢?对象都不需要创建,而是直接在栈上创建它用到的成员标量。

public static void alloc(){
//        Point point = new Point();
    int x = 0;
    int y = 0;
}

4 对象在JVM中的内存结构

4.1 对象创建的过程

在日常开发工作中,我们常用的创建对象的方式主要分为以下几种: (1)通过new关键字创建; (2)通过反射; (3)通过clone的方法,obj.clone; (4)序列化或者反序列化

接下来,我们通过字节码看下类是如何创建的,以alloc方法为例:

0 new #8 <android/graphics/Point>
3 dup
4 invokespecial #9 <android/graphics/Point.<init> : ()V>
7 astore_0
8 return

当我们使用new关键字创建对象时:

(1)首先执行了new指令,然后拿到一个符号引用,会去class常量池中查找是否能定位到这个符号引用

Android进阶宝典 -- JVM运行时数据区 如果没有找到,那么就会抛出异常ClassNotFoundException;如果找到了,就会通过类加载器加载,生成class类对象。

(2)然后,为该对象在堆内存分配内存空间,具体分配需要参看3.1小节内存分配规则;

(3)设置对象头信息,包括对象头(hashcode、内存分代信息、年龄、锁信息等等)、实例数据、对其填充

(4)最后执行init进行对象初始化。