前言

一、JVM简介

二、JVM的内存区域

三、 栈帧执行对内存区域的影响

四、 本地方法栈

五、代码和对象是如何在JVM中分配的

六、深入理解JVM内存区域

七、补充


到目前为止,android开发做了有4年左右。在平时的业务开发中,还是比较顺利的,但是面对整个android方向的知识体系还不是那么的健全。

一方面由于对技术的热情,另一方面由于个人的android项目比较多,所以在探索IOS领域(老本行)的同时,今天开始也要对比的、系统的总结一下android方面的实现原理以及技术点。

在IOS中,内存管理机制的核心是引用计数,那么在android中的内存管理机制是怎么样的呢,今天就具体的总结一下。


一、JVM简介

1、JVM简述

首先Java 语言是一门通用的、面向对象的、支持并发的程序语言。它的语法与 C 和 C++语言非常
相似,但隐藏了许多 C 和 C++中复杂、深奥及不安全的语言特性。
JVM是Java虚拟机,Java虚拟机是整个Java平台的基石。Java 虚拟机可以看作是一台抽象的计算机。
第一个 Java 虚拟机的原型机是由 Sun Microsystems 公司实现的,它被用在一种类似 PDA
(Personal Digital Assistant,俗称掌上电脑)的手持设备上仿真实现 Java 虚拟机指令
集。
简单来说,JVM不是一个简单的工具, 而是一种规范。

2、Java从编译到执行的过程

  • (1)java源文件通过javac编译成.class文件
  • (2).class通过JVM的类加载器进行加载。
  • (3)通过字节码引擎和JIT编译器执行
解释执行:因为JVM是c++写的,所以c++中会有一个解释器,比如java中创建对象(new Object),在c++中会判断:
if (new) {
    c++ 代码完成java中的new
}

这就是解释执行。但是这种方式,就会经过JVM的一次翻译,速度会慢一点。但是是大多数都是走的解释执行。

JIT执行(hotspot):当方法、代码等执行达到一定次数后,就会走JIT。这里会直接把.class翻译成汇编或者机器码(预先翻译的时间会长,翻译的结果会放在codecache中)。整体速度比较快。
  • (4)生成机器码

其中JDK,包括了整个java的开发包,包含了JRE,JVM等。
JRE包含了JVM以及运行时所需要的java类库。

3、JVM是一种规范

为什么说JVM是一种规范呢?因为:
(1)JVM跨平台
比如:windows、linux、unix、android、mac
(2)JVM的语言无关性
比如:java、kotlin、groovy
JVM本身只识别字节码,它有一个字节码规范。无论是哪种高级语言,最终按照规范生成字节码即可。(这里就和llvm中生成中间代码IR如出一辙)。

4、JVM的发展

  • Oracle:Hotspot、Jrockit
原来属于BEA 公司,曾号称世界上最快的 JVM,后被 Oracle 公司收购,合并于 Hotspot。
  • IBM: J9
  • BEA: LiquidVM
  • 阿里:TaobaoVM
只有一定体量、一定规模的厂商才会开发自己的虚拟机,
比如淘宝有自己的 VM,它实际上是 Hotspot 的定制版,
专门为淘宝准备的,阿里、天猫都是用的这款虚拟机。
  • 华为:毕昇(sheng)
  • zual:zing (c4回收算法)
它属于 zual 这家公司,非常牛,是一个商业产品,很贵!
它的垃圾回收速度非常快(1 毫秒之内),是业界标杆。
它的一个垃圾回收的算法后来被Hotspot 吸收才有了现在的 ZGC。

二、JVM的内存区域

1、JVM运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。

(1)线程共享区

 <1> 方法区(在hotspot中称为永久代、元空间,也可以说是hotspot使用了永久代或者元空间实现了方法区)
 <2> 堆

(2)线程私有区

<1> 虚拟机栈
<2> 本地方法栈
<3> 程序计数器

(3)直接内存(堆外内存)
比如一台8G的机器,JVM启动后,运行时数据区占5G,剩余的3G就是直接内存,java也可以申请使用,但是使用完需要释放。

2、Java方法的运行与虚拟机栈

  public static void main(String[] args) {
        A();
    }

    public static void A(){
        B();
    }

    public static void B(){
        C();
    }

    public static void C(){

    }

当我们运行这段代码的时候,会开启一条线程来执行。当开启线程之后,就会创建一个虚拟栈,这个虚拟机栈就属于这个线程。所以虚拟机栈是线程私有的。

虚拟机栈存储当前线程运行方法所需的数据、指令、返回地址。

当我们调用main方法的时候,会将main(栈帧1)压入虚拟机栈。
当我们调用A方法的时候,会将A(栈帧2)压入虚拟机栈。
当我们调用B方法的时候,会将B(栈帧3)压入虚拟机栈。
当我们调用C方法的时候,会将C(栈帧4)压入虚拟机栈。

当C方法执行完之后,栈帧4就会出栈。

虚拟机栈也有大小限制:-Xss,默认值取决于平台,一般默认为1024KB。


当把以上代码其中一个方法改成:

 public static void A(){
        A();
}

此时就会报错-栈溢出:

Exception in thread "main" java.lang.StackOverflowError

3、-Xss调优事例

当内存比较紧张的时候,假如系统内存为200M,此时需要开300个线程执行任务,那么一个线程创建的虚拟机栈默认大小为1M,此时就会发生内存溢出。这时可以利用-Xss来调优,比如可以改为 -Xss256k。

4、程序计数器

指向当前线程正在执行的字节码指令的地址

5、虚拟机栈-栈帧


如图所示,栈帧中包括:
(1)局部变量表
(2)操作数栈
(3)动态连接
(4)完成出口

三、 栈帧执行对内存区域的影响

1、编译.class文件

以下demo为例:

public class Person {
    public  int work()throws Exception{//一个方法对应一个栈帧
        int x =1;// iconst_1 、 istore_1
        int y =2;// iconst_2 、 istore_2
        int z =(x+y)*10;
        return  z;
    }
    public static void main(String[] args) throws Exception{
        Person person = new Person();
        person.work();  //这个字节码的行号(针对本方法偏移)
        person.hashCode();//方法属于本地方法 ---本地方法栈  4
    }
}

由于JVM接收的是字节码(.class),所以需要找到编译之后的.class文件。然后使用javap命令

javap -v Person.class

字节码如下:

 Compiled from "Person.java"
public class ex1.Person
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
//静态常量池
Constant pool:
   #1 = Methodref          #6.#29         // java/lang/Object."<init>":()V
   #2 = Class              #30            // ex1/Person
   #3 = Methodref          #2.#29         // ex1/Person."<init>":()V
   #4 = Methodref          #2.#31         // ex1/Person.work:()I
   #5 = Methodref          #6.#32         // java/lang/Object.hashCode:()I
   #6 = Class              #33            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lex1/Person;
  #14 = Utf8               work
  #15 = Utf8               ()I
  #16 = Utf8               x
  #17 = Utf8               I
  #18 = Utf8               y
  #19 = Utf8               z
  #20 = Utf8               Exceptions
  #21 = Class              #34            // java/lang/Exception
  #22 = Utf8               main
  #23 = Utf8               ([Ljava/lang/String;)V
  #24 = Utf8               args
  #25 = Utf8               [Ljava/lang/String;
  #26 = Utf8               person
  #27 = Utf8               SourceFile
  #28 = Utf8               Person.java
  #29 = NameAndType        #7:#8          // "<init>":()V
  #30 = Utf8               ex1/Person
  #31 = NameAndType        #14:#15        // work:()I
  #32 = NameAndType        #35:#15        // hashCode:()I
  #33 = Utf8               java/lang/Object
  #34 = Utf8               java/lang/Exception
  #35 = Utf8               hashCode
{
 //构造方法
  public ex1.Person();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lex1/Person;
  //work方法
  public int work() throws java.lang.Exception;
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: bipush        10
         9: imul
        10: istore_3
        11: iload_3
        12: ireturn
      LineNumberTable:
        line 9: 0
        line 10: 2
        line 11: 4
        line 12: 11
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      13     0  this   Lex1/Person;
            2      11     1     x   I
            4       9     2     y   I
           11       2     3     z   I
    Exceptions:
      throws java.lang.Exception
  //main方法
  public static void main(java.lang.String[]) throws java.lang.Exception;
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class ex1/Person
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: astore_1
         8: aload_1
         9: invokevirtual #4                  // Method work:()I
        12: pop
        13: aload_1
        14: invokevirtual #5                  // Method java/lang/Object.hashCode:()I
        17: pop
        18: return
      LineNumberTable:
        line 15: 0
        line 16: 8
        line 17: 13
        line 18: 18
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      19     0  args   [Ljava/lang/String;
            8      11     1 person   Lex1/Person;
    Exceptions:
      throws java.lang.Exception
}

可以看到work方法的Code部分:

         0: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: bipush        10
         9: imul
        10: istore_3
        11: iload_3
        12: ireturn

以上是字节码的地址,执行代码的时候,程序计数器就会记录运行方法的字节码的行号。

2、栈帧执行分析:

work方法的Code部分前面的0-12下标,其实指的是偏移量(下面具体说明)

  • 当程序运行到work方法的第0行时:iconst_1
    如图所示:

    (1)此时程序计数器等于0
    (2)会有一个指令为iconst_1,创建出一个int类型的常量,然后将1(常量值)压入操作数栈。
    (3)此时局部变量表第0行默认为this

  • 程序继续跑,当运行到work方法的第1行时: istore_1
    如图所示:

    (1)此时程序计数器等于1
    (2)指令为istore_1(存储命令),它后面跟的1表示要保存到局部变量表的下标。也就是它会把操作数栈中栈顶的值压入到局部变量表中索引为1的位置。
    (3)此时局部变量表第1行为1。
    所以int x = 1,就表示了2个指令:iconst_1 、 istore_1。
    同样的,int y =2,表示了2个指令:iconst_2 、 istore_2。

注意:数据一定要先压入到操作数栈,再从操作数栈取出压入到局部变量表。

  • 继续,当程序运行到work方法的第4行时:iload_1(也就是要执行int z =(x+y)*10)
    因为x和y已经存在于局部变量表中了,所以需要去加载数据。
    如图所示:

    (1)此时程序计数器等于4
    (2)指令iload_1(加载命令),它后面跟的1表示局部变量表的下标,也就表示它会把局部变量表中下标为1的数据加载到操作数栈。

  • 当程序运行到work方法的第5行时:iload_2
    如图所示:

    (1)此时程序计数器等于5
    (2)指令iload_2(加载命令),它后面跟的2表示局部变量表的下标,也就表示它会把局部变量表中下标为2的数据加载到操作数栈。

  • 当程序运行到work方法的第6行时:iadd (进行运算)
    此时会用到算数指令: (iadd)

    它的操作为3步:(1)先把1和2两个值从操作数栈出栈 (2)进行相加得到3 (3)把3重新压入到操作数栈。

  • 当程序运行到work方法的第7行时:bipush
    (1)此时程序计数器等于7
    (2)使用bipush命令将10推入到操作数栈顶。

  • 当程序运行到work方法的第9行时:imul
    (1)此时程序计数器等于9
    (2)imul指令,进行相乘操作,并将相乘的结果30压入到操作数栈

  • 当程序运行到work方法的第10行时:istore_3
    如图所示:

    (1)此时程序计数器等于10
    (2)使用istore_3指令,将结果30存入到局部变量表下标为3的位置。

  • 最后2条指令iload_3和ireturn,是将结果30重新压入到操作数栈,然后返回。

    此时work方法结束。注意:istore是压入局部变量表,iload是压入操作数栈。

3、为什么上面的步骤中没有第8步

以上的指令,0-12指的是针对work方法,字节码的偏移量。所以指令的大小不同,偏移量就不同。
比如bipush指令,它就占了2个偏移量,所以没有第8步。

总结:程序计数器记录的是偏移量。

4、完成出口

比如work方法是从main方法跳转的。

 public static void main(String[] args) throws Exception{
        Person person = new Person();
        person.work();  //这个  3  字节码的行号(针对 本方法偏移)
        person.hashCode();//方法属于本地方法 ---本地方法栈  4
}

假如 person.work();这一行在字节码中针对main方法的偏移量是3,那么work方法的完成出口就是3,此时当work方法结束之后,就会回到main方法中的第3行,然后继续执行第4行。

总结:完成出口就是记录跳转方法的偏移,方便在被跳转方法执行完成之后,需要回到之前的指令偏移。

5、动态连接

  • 静态链接:
    如果一个class文件被装载进JVM内部,被调用的目标方法在编译期可知,且运行期间不变时,将符号引用转为直接引用的过程称为静态链接
  • 动态链接:和多太相关
    编译期无法确定,只能在“程序运行期间”将调用方法的符号引用转为直接引用。

四、 本地方法栈

本地方法栈是为JVM使用到本地(Native)方法服务。
比如Java不支持开线程,需要通过其他库或者底层接口去开线程,这些接口或库可能是c、c++、汇编写的。
那么开线程的方法就是本地方法。
再比如上面的Person类中,person.hashCode();方法

public native int hashCode();

此方法前面有native标识,所以它是一个本地方法。
而本地方法栈,是一个类似于虚拟机栈的方法栈。

但是在hotspot中,本地方法栈和虚拟机栈是同一快内存,只是不同名称而已。
另外需要注意:程序计数器只能记录虚拟机栈的方法。如果调用本地方法,程序计数器会为null。

五、代码和对象是如何在JVM中分配的

看以下demo:

public class ObjectAndClass {
    static int age=18;// 静态变量(基本数据类型)
    final static int sex=1;//常量(基本数据类型)
    ObjectAndClass2 object = new ObjectAndClass2();// 成员变量指向(对象)
    private boolean isMagic;//成员变量 

 //启动一个线程,创建一个虚拟机栈,数据结构,单个,压入一个栈帧
    public static void main(String[] args) {
        int x=18;//局部变量(基本数据类型)
        long y=1;//局部变量(基本数据类型)
        ObjectAndClass lobject = new ObjectAndClass();//局部变量 引用  (对象)
        lobject.isMagic=true;// isMagic跟随对象,堆空间
        lobject.hashCode();//方法中调用方法  本地方法(C++语言写  JNI)
        ByteBuffer bb = ByteBuffer.allocateDirect(128*1024*1024);//直接分配128M的直接内存
    }

    public static class ObjectAndClass2 {}
}

如图:

  • 1、首先ObjectAndClass这个类,在加载的时候,放在方法区。静态变量age和常量sex也放在方法区。
  • 2、ObjectAndClass2 object = new ObjectAndClass2(); 这句代码在类加载的时候不会执行,因为成员变量是跟随对象的。
  • 3、isMagic也是成员变量,类加载的时候不会处理。
  • 4、如果加入static {};时,这个静态代码块会在类加载的时候执行。
  • 5、main方法中的x和y是放在虚拟机栈的栈帧中。
  • 6、ObjectAndClass lobject = new ObjectAndClass(); 其中new ObjectAndClass(); 在堆中分配内存,而局部变量lobject(对象引用)也是放在虚拟机栈的栈帧中。
  • 7、当调用new ObjectAndClass();时,会调用其构造方法,也就会执行成员变量 ObjectAndClass2 object = new ObjectAndClass2();的代码。因为object属于lobject对象中的成员变量,所以object是存放在堆中的。
  • 8、直接内存:
    ByteBuffer bb = ByteBuffer.allocateDirect(12810241024);
    直接申请128M内存,直接内存和unsafe类相关。注意:unsafe是不安全的。
  • 9、直接内存会绕过JVM的垃圾回收。需要手动方式释放内存。
  • 10、ByteBuffer.allocateDirect(12810241024);可以自动释放,因为内部会处理释放问题。但是用unsafe开辟的内存,需要手动释放。

六、深入理解JVM内存区域

demo如下:

/**
 * VM参数
 * -Xms30m -Xmx30m  -XX:MaxMetaspaceSize=30m  -XX:+UseConcMarkSweepGC -XX:-UseCompressedOops
 */
public class JVMObject {

    public final static String MAN_TYPE = "man"; // 常量
    public static String WOMAN_TYPE = "woman";  // 静态变量

    public static void main(String[] args) throws Exception {
        Teacher T1 = new Teacher();
        T1.setName("Magic");
        T1.setSexType(MAN_TYPE);
        T1.setAge(36);
        for (int i = 0; i < 15; i++) {
            System.gc();
        }
        Teacher T2 = new Teacher();
        T2.setName("Geng");
        T2.setSexType(MAN_TYPE);
        T2.setAge(18);
        Thread.sleep(Integer.MAX_VALUE);
    }
}

class Teacher {
    String name;
    String sexType;
    int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getSexType() {
        return sexType;
    }

    public void setSexType(String sexType) {
        this.sexType = sexType;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

1、JVM运行流程

(1)JVM申请内存
当我们运行以上代码之后,通过jps命令查看:(jps会列出目标系统上检测的 Java 虚拟机 (JVM))

51209
9484 Main
56556 Launcher
56557 JVMObject
56574 Jps

可以查看到JVMObject的进程号。
windows下可以通过命令jinfo -flags 56557查看JVM默认配置,我是mac电脑,由于JDK版本问题,会报错,后面升级JDK再尝试这个命令。
这个命令执行之后,会显示出默认配置,比如:InitialHeapSize(初始化堆空间)、MaxHeapSize(最大堆空间)等。其中Command line是通过程序设置的。

(2)初始化运行时数据区

初始化方法区和堆

(3)类加载
将类和常量、静态变量放在方法区。

(4)执行方法
当执行main方法的时候,会创建栈(因为hotspot将虚拟机栈和本地方法栈合二唯一了,所以统一叫栈)。

(5)创建对象
Teacher T1 = new Teacher();
T1在栈帧中,new Teacher()分配在堆中

如图总结:

2、堆空间分代划分简述

分为:新生代(Eden、From、To)和老年代(存放经过多次垃圾回收,但是没有释放掉的对象)。
比如上面demo中的T1 ,经过15次gc之后,应该进入老年代。
T2则还是在新生代。

可以通过JHSDB查看JVM内存

输入下命令启动HSDB程序

java -cp /Library/Java/JavaVirtualMachines/jdk1.8.0_311.jdk/Contents/Home/lib/sa-jdi.jar  sun.jvm.hotspot.HSDB

再次查看jps

56657 Launcher
56658 JVMObject
57843 HSDB
58393 Jps
51209
9484 Main

用HSDB,Attach此进程号。就可以看到具体进程信息和线程信息。就不在演示了。

3、内存溢出

栈溢出

public class StackError {
    public static void main(String[] args) {
        A();
    }
    public static void A(){
        A();//死递归  栈帧不断 A不断入栈,不会出栈
    }
}

堆溢出:垃圾回收没有回收掉

七、补充

1、对象经历一次垃圾回收,没有被回收掉,会将其age+1。

当age达到15时,晋级为老年代。
之所以是15,是因为底层记录对象的年龄是4位二进制(1111),也就是最大为15.

2、关于OOM

(1)OOM是非常严重的问题,除了程序计数器,其他内存区域都有溢出的风险。
(2)元空间也会发生OOM(就是方法区栈溢出)
(3)平时开发密切相关的是堆溢出
注意:堆、虚拟机栈、方法区都可能会发生内存溢出,只有程序计数器不会。

3、Java中直接内存(堆外内存)有哪些

(1)使用了Java的Unfase类,做了一些本地内存的操作
(2)JNI或者JNA程序,直接操纵了本地内存