一文详解Android开发必知必会JVM知识

164 阅读11分钟

先看一张图, 这是Java代码从编译到CPU运行的整个过程。 这个过程,涵盖了本篇文章的所有知识点:

  • JVM的内存模型
  • JVM类加载过程
  • JVM的内存分配
  • JVM判断对象存活原则
  • JVM垃圾回收算法

image-20230207140947426

JVM的内存模型

JVM的内部结构

image-20230206135801476

JVM内部分成3个部分

  • 类加载器:用来加载类信息,加载后型存储在jvm内存模型的方法区中
  • 运行时数据区(内存模型):这里的内存模型指的就是JVM的内存模型,分为5个部分,方法区,堆,java方法栈,native方法栈,程序计数器
  • 执行引擎:解释执行字节码,把字节码处理成汇编指令

知识补充: javac 是jre里面提供的工具,用来把.java文件编译成.class的字节码文件。 字节码文件经过jvm处理之后变成汇编指令。 汇编指令再加载到设备的主存之中,后变成机器指令由CPU执行。 所以, 只要是能够编译成.class字节码文件,就能由java虚拟机解释执行,实现跨平台。 kotlin groovy、JRuby、jython 都编译字节码由jvm解释执行。

知识补充:为什么jvm执行的文件叫做字节码, 这是因为jvm每次执行指令大小就是1个字节。这个字节里面包括了操作码和操作数。操作码就是运算符,操作数就是要进行运算的数。

内存模型

image-20230207141109012

根据是否共享线程数据可分为2大区域

  • 线程可共享的数据: 方法区数据, 堆数据
  • 线程独享的数据:java方法栈数据, native方法栈数据, 程序计数器

线程可共享数据, 在jvm内存中, 只存在一份, 方法区和堆数据都只有一份。

而线程独享的数据, java方法栈、native方法栈和程序计数器每个线程都存在一份。

方法区存储哪些数据

类信息、常量、静态变量存储在方法区

堆栈存储哪些数据

方法中成员变量的存储

如果是java基础类型, 则变量和值存储在方法栈中。

如果是引用类型, 变量存在方法栈中,值,也就是对象实例,存储在堆中。

全局变量的存储

全局变量, 也就是类中的变量。

不管是基础类型还是引用类型, 成员变量和值都存储在堆中。

所以大部分的对象实例都是存在堆内存中的, 只有方法中的基础类型成员变量和其值,引用变量这三种存储在栈内存中。JVM的GC主要针对的也是堆内存的回收,所以也被叫做GC堆。

java、native方法栈的作用

java是基于线程执行的, 每一个线程都有对应的方法栈程序计数器。 在方法执行时, jvm会把方法打包成一个栈帧,栈帧里面包含了方法的全部信息, 方法每调用一次,就会在当前线程的方法栈创建一个栈帧并入栈,方法执行结束后,出栈。 java方法在java方法栈出入栈 ,native方法在native方法栈出入栈。

栈帧存储了以下4个区域的信息

  • 局部变量表,记录局部变量
  • 操作数据,记录操作数据
  • 动态连接,确定具体的方法
  • 返回地址,返回程序计数器的地址

程序计数器的作用

程序计数器用来记录当前线程下一条运行的指令, 因为cpu时间片切换可能导致当前线程未执行完就切到其他线程。

类加载过程

类加载的5大流程

类加载过程有5步

  • 加载
  • 验证
  • 准备
  • 解析
  • 初始化

类加载5大流程中的具体工作

  • 加载:把类信息加载到jvm的内存模型中的方法区

  • 验证:验证类型信息是否符合jvm要求的规范

  • 准备:分配变量内存和初始化值

  • 解析:把符号引用处理为直接引用

  • 初始化:合并类变量和静态语句块

内存分配

内存分配指的是从堆中分配出一块大小合适的内存块给对象。

有两种方式进行堆中的内存分配

  • 指针碰撞
  • 空闲列表

指针碰撞

如果堆中内存分配是规整的,则只需要将记录内存的指针移动对象所需大小的位置。

空闲列表

如果堆中内存分配是不规整的,则需要记录哪些内存块已使用, 哪些内存块未使用。 从未使用的内存块中找出合适大小的分配给对象。

内存分配是否规整, 取决是jvm使用的垃圾回收器,带压缩整理的回收器,内存分配是规整的。

判断对象是否存活

判断对象是否存活有两种方法

  • 引用计数法
  • 可达性分析法

引用计数法

给对象设置一个引用计数器,通过引用计数器来记录引用对象的地方, 引用+1,引用失效-1。为0时,代表对象已“死去”。

可达性分析法

image-20230206140409371

可达性分析就是通过GC Roots 作为根节点向下搜寻,找出可达和不可达的对象。

GC Roots

GC Roots就是对象,并且是当前不可被回收的对象。

哪些对象可以作为GC Roots对象?

  1. 静态变量引用的对象
  2. 常量引用的对象
  3. java栈帧本地变量表引用的对象
  4. native栈帧本地变量表引用的对象
  5. 被同步锁锁定的对象

为什么以上对象可作为GC Roots?

  • 静态变量和常量都存储在方法区中。在进程运行期间,不会被回收。

  • 前面说过栈帧的存储, 在方法执行时, jvm会把方法打包成一个栈帧入栈, 栈帧存储了方法里面的变量。 只要方法没结束, 里面变量所引用的对象就不会被回收

  • 当前线程持有对象锁,对象也不能被回收, 如果回收,对象锁就失效了。

对象引用类型

可达性分析中,提到了对象的引用,在Java中,有4种引用类型

  • 强引用
  • 软引用
  • 弱引用
  • 虚引用

特点

  • 强引用(new Object):只要还在存在引用,就不会被回收, 手动置null后可被回收
  • 软引用(SoftReference):内存不够用,要触发oom时会被回收
  • 弱引用(WeakReference):只要触发GC就会被回收
  • 虚引用(PhantomReference):对象要被回收时,会存入到给定的那个队列里面

GC时处理引用对象,就像是我们整理房间大扫除一样,强引用就像是生活中的必需品,只要还需要用, 就不会被我们当做垃圾扔掉。 而软引用就是那种占地方,又不是生活必需品的东西, 当房间的空间不足的时候,我们就会考虑扔掉它。 弱引用就是房间里面的垃圾,每次大扫除我们都会扔掉它。 虚引用就是现在不用,以后可能会用的东西, 在打扫时, 我们会把这些东西(对象)放到储物柜(消息队列)收纳起来,需要的时候再从储物柜里拿出来使用。

使用场景

  • 强引用:日常业务逻辑开发,成员变量,局部变量
  • 软引用:实现缓存类业务
  • 弱引用:避免内存泄漏,handler,dialog
  • 虚引用:NIO中使用虚引用管理堆外内存

垃圾回收算法

垃圾回收算法有复制和标记两种思想的算法,具体实现有以下算法:

  • 复制算法(1比1 复制、 8:1:1复制)
  • 标记-清除算法
  • 标记-整理算法
  • 新生代-老年代分别处理

复制算法

将堆内存1:1分成2块,每次只使用其中一块, 在回收时把存活的对象复制到另外一块,再把原来那块内存全清空。

这样做的问题是空闲浪费比较大。 所以在JVM采用的8:1:1的方式来实现内存分配和回收。对象创建的时候分配在8和其中的一个1上面, 回收时,把存活的对象复制到另外一个1上面,清空原来的8和1。

image-20230206140506185

标记清除算法

标记需要回收的对象,标记完成统一回收。

在标记确认对象可回收阶段,具体实现是:

当对象通过可达性分析确认为不可达之后, 如果不必要执行finalize方法,就直接判定为可回收。 如果有必要执行finalize方法, 则放入一个的集合。

在这个集合里面的对象, 会被执行每一个对象的finalize方法,再执行finalize方法的时候, 会再次对该对象进行可达性分析, 如果不可达, 判定为可回收。 可达则保留并放入即将回收的集合。

image-20230206140551951

标记整理算法

和标记清除算法差不多,只是在清除前进行一次整理,再清除。

具体实现是:把所有存活的都行都向一端移动进行整理,整理完成后,清除掉边界以外的内存。

image-20230206140616651

新生代老年代收集算法

新生代和老年代

根据对象的存活周期, 把堆内存分为新生代和老年代,分配比例为1:2。

新创建的对象一般都在新生代区域。大对象则直接被分配到老年代,比如很长的字符串或数组。

在新生代里面经过15次GC还存在的对象, 会被移动到老年代。

新生代和老年代使用的算法

根据新生代和老年代对象的特性:新生代的对象回收频率最高,老年代的回收频率较低。

复制算法更适用于新生代,因为大部分对象生命周期短,复制过去的对象比较少。

标记整理算法更适用于老生代,因为老年代对象生命周期短,需要做的标记和整理的工作少。

新生代老年代算法

什么时候触发GC

  • 在创建对象的时候, eden空间不足的时候。 触发Minor GC, 对新生代内存区域GC。

  • 老年代内存区域被写满,或者手动调用Systemt.gc()触发FullGC,对整个堆内存进行回收。

总结

  • 在Java代码被javac编译成字节码之后,JVM开始处理字节码文件。

  • JVM的类加载器开始类加载过程,经过5个步骤:加载,验证, 准备,解析,初始化。

    • 加载过程把类加载到JVM内存模型的方法区中
    • 验证过程校验类信息是否符合JVM规范
    • 准备过程开始分配内存和初始化值
    • 解析过程把间接引用转换为直接引用
    • 初始化过程合并类变量和静态语句块
  • 在分配内存时,根据变量的类型,位置,把变量和这个变量的值分配到方法区,堆内存,或者栈内存。

  • 当新生代内存不足时触发Minor GC,对新生代内存区域进行内存回收。当老年代内存区域内存不足时,触发 Full GC, 对整个堆内存进行Full GC。 也可以手动调用Systemt.GC(); 开启堆内存回收。

  • 内存回收时,先要判断对象是否存活,在使用垃圾回收算法来回收内存区域中已经死亡的(没在使用的)对象。判断对象是否存活有两种方法:

    • 一种是引用计数法,给对象分配一个引用计数器,对象被引用一次就+1,取消引用就-1。 当引用计数器的值为0的时候表示对象已死亡。
    • 一种是可达性分析法, 通过GC Roots向下搜索,找出不大到达GC Roots的对象,这种对象就是不可达的。 在经过标记和筛选之后判定这些对象是否存活。
  • 知道那些对象需要被回收之后, 通过回收算法,复制算法或者标记-清除, 标记-整理算法,回收内存上已经死亡的对象。

    • 复制算法就是把内存1比1分成两份或者8比1比分成3份。 每次分配内存时只使用其中的一份, 回收时, 把这份上还存活的对象复制到另外一份。 然后清理这份的整块区域。

    • 标记清除/标记整理 算法, 就是对内存块上的对象状态进行标记,在内存回收阶段,清除已死亡状态的对象。

    • 根据对象的生命周期,会把堆内存按1比2分配成新生代内存区域和老年代内存区域。 新生代内存区域使用复制回收算法。老年代内存区域使用标记整理算法。因为新生代的生命周期短,回收频率高。所以标记工作量大,不适合标记整理算法。 而适合整块复制、清除的复制算法。