JVM 全称 Java Virtual Machine,它是一种规范。JVM 是一个虚拟化的操作系统,类似于 Linux 或者 Windows 的操作系统,只是它架在操作系统上,接收字节码,把字节码翻译成操作系统上的机器码且进行执行。
Android的运行环境都是在ART虚拟机或者是Dalvik虚拟机上,为什么Android程序员需要学习JVM,理解JVM可以帮助我们更好的了解Java内存区域、对象的创建和内存分配、垃圾的回收以及常见的垃圾回收算法等等,然后将其运用到Android开发中,有助于处理app中的内存问题。
在JVM中内存主要分为堆、程序计数器、方法区、虚拟机栈和本地方法栈等
同时按照与线程的关系也可以这么划分区域:
虚拟机栈是先进后出(FILO)的数据结构,是每个线程私有的,线程在运行时,在执行每个方法的时候都会打包成一个栈帧,存储了局部变量表
,操作数栈
,动态链接
,方法出口
等信息,然后放入栈。每个时刻正在执行的当前方法就是虚拟机栈顶的栈桢。方法的执行就对应着栈帧在虚拟机栈中入栈和出栈的过程。
在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦方法完成相应的调用,则出栈。
栈帧一般包含四个区域:(局部变量表、操作数栈、动态连接、返回地址)
局部变量表:
用于存放我们的局部变量的(方法中的变量),主要存放我们的 Java 的八大基础数据类型,如果是局部的一些对象,只需要存放它的一个引用地址即可。
操作数栈
存放 java 方法执行的操作数的,它就是一个栈,先进后出的栈结构,操作数栈,就是用来操作的,操作的元素可以是任意的 java 数据类型。
动态连接
每个栈帧中都包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接。
返回地址
正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等、
异常退出
我们来写一个简单的java代码查看汇编指令
public class Stack {
public static void main(String[] args) {
int a = 10;
int b = 20;
int c = a + b;
System.out.println(c);
}
}
执行 javac
生成class,再执行 javap -v Stack.class
Classfile /C:/Users/Jack/Desktop/Stack.class
Last modified 2021-11-24; size 406 bytes
MD5 checksum 38b185e462a0e2aa0a06afdc67dfe12c
Compiled from "Stack.java"
public class com.test.Stack
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#14 // java/lang/Object."":()V
#2 = Fieldref #15.#16 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #17.#18 // java/io/PrintStream.println:(I)V
#4 = Class #19 // com/Stack
#5 = Class #20 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 main
#11 = Utf8 ([Ljava/lang/String;)V
#12 = Utf8 SourceFile
#13 = Utf8 Stack.java
#14 = NameAndType #6:#7 // "":()V
#15 = Class #21 // java/lang/System
#16 = NameAndType #22:#23 // out:Ljava/io/PrintStream;
#17 = Class #24 // java/io/PrintStream
#18 = NameAndType #25:#26 // println:(I)V
#19 = Utf8 com/Stack
#20 = Utf8 java/lang/Object
#21 = Utf8 java/lang/System
#22 = Utf8 out
#23 = Utf8 Ljava/io/PrintStream;
#24 = Utf8 java/io/PrintStream
#25 = Utf8 println
#26 = Utf8 (I)V
{
public com.test.Stack();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: bipush 10 //将常量10压入操作数栈(int取值-128~127时使用bipush指令)
2: istore_1 //将10从操作数栈存储到局部变量表第1个位置
3: bipush 20 //将常量20压入操作数栈
5: istore_2 //将20从操作数栈存储到局部变量表第2个位置
6: iload_1 //加载局部变量第1个变量压入操作数栈
7: iload_2 //加载局部变量第2个变量压入操作数栈
8: iadd //操作数栈中的前两个变量相加,并将结果压入操作数栈顶
9: istore_3 //将相加的得出的结果30从操作数栈存储到局部变量表第3个位置
10: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
13: iload_3
14: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
17: return // 返回
LineNumberTable:
line 5: 0
line 6: 3
line 7: 6
line 8: 10
line 9: 17
}
SourceFile: "Stack.java"
程序计数器(Program Counter Register)是一块较小的内存空间,是线程私有的,各线程之间独立存储,互不影响。它可以看作是当前线程所执行的字节码的行号指示器。比如如下字节码内容,在每个字节码`前面都有一个数字,我们可以认为它就是程序计数器存储的内容。它主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。
方法区(Method Area)是可供各条线程共享的运行时内存区域。它存储了每一个类的结构信息,例如运行时常量池(Runtime Constant Pool)字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法。方法区是 JVM 对内存的“逻辑划分”,在 JDK1.7 及之前很多开发者都习惯将方法区称为“永久代”,是因为在 HotSpot 虚拟机中,设计人员使用了永久代来实现了 JVM 规范的方法区。在 JDK1.8 及以后使用了元空间来实现方法区。
本地方法栈跟 Java 虚拟机栈的功能类似,它服务的对象是 native 方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域。虚拟机规范无强制规定,各版本虚拟机自由实现 ,HotSpot 直接把本地方法栈和虚拟机栈合二为一 。
运行时常量池(Runtime Constant Pool)是每一个类或接口的常量池(Constant_Pool)的运行时表示形式,它包括了若干种不同的常量:从编
译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。运行时常量池是方法区的一部分。
堆是 JVM 上最大的内存区域,我们创建的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。堆空间一般是程序启动时,就申请了,但是并不一定会全部使用。随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC(Garbage Collection)。
对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。
直接内存有一种更加科学的叫法,堆外内存。JVM 在运行时,会从操作系统申请大块的堆内存,进行数据的存储;同时还有虚拟机栈、本地方法栈和程序计数器,这块称之为栈区。操作系统剩余的内存也就是堆外内存。
虚拟机遇到一条 new 指令时,首先检查是否被类加载器加载,如果没有,那必须先执行相应的类加载过程。类加载就是把 class 加载到 JVM 的运行时数据区的过程。对象的创建分为5个步骤,分别是 检查加载
、分配内存
、内存空间初始化
、设置
、对象初始化
。
检查加载
首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用(符号引用 :符号引用以一组符号来描述所引用的目标),并且检查类是否已经被加载、解析和初始化过。
分配内存
虚拟机将为新生对象在堆中分配内存。
内存空间初始化
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(如 int 值为 0,boolean 值为 false 等等)。这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
设置
虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息,这些信息存放在对象的对象头之中。
对象初始化
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。一般来说,执行 new 指令之后会接着初始化构造方法,这样一个真正可用的对象才算完全产生出来。
在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个 java 数组,那么在对象头中还有一块用于记录数组长度的数据。
对齐填充并不是必然存在的,它仅仅起着占位符的作用。
我们都知道GC的作用主要在堆区,那么怎么判断堆中的对象是不是存活呢,没有存活的一定是垃圾。JVM规范中主要有两种方法,分别是引用计数法、可达性分析算法。
引用计数算法是在对象中分配一个空间来保存它的引用计数,如果这个对象在其他地方被引用,引用计数就+1,当引用被删除时,引用计数就 -1,当引用计数为0时,那么该对象就会被回收。但是引用计数法有一个缺陷,当两个对象相互引用时,就不能判断为垃圾会一直存活。目前JVM 中没有采用引用计数法,只是在Python中使用了这个算法。
可达性分析是利用GC Roots对象作为起点,从这些起点向下搜索,直至所有对象引用所有完毕,这样就形成了很多条的引用链,其他不在引用链中的对象表示没有被引用就会判定为垃圾。JVM中就是采用的可达性分析。
可以作为GC Roots的对象主要有以下几种:
虚拟机栈(栈帧中的局部变量表)中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
Native 引用的对象 (JNI)
强引用 StrongReference
通常我们通过new创建的的对象返回的引用就是强引用,只要有强引用关联(与根可达)还在,垃圾回收器就永远不会回收掉被引用的对象。
软引用 SoftReference
软引用关联的对象,系统将要发生内存溢出(OOM)之前,这些对象就会被回收。
弱引用 WeakReference
用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC 发生时,不管内存够不够,都会被回收。
虚引用 PhantomReference
最弱的引用,随时都可能会被垃圾回收器回收掉,一般用的地方不多。
JVM堆分为两大块,分别是 新生代
和 老年代
,新生代占堆空间的1/3,老年代占堆空间的1/3。在新生代中又分为三个区,分别是Eden区
、Survivor From区
和 Survivor To区
,Eden区占新生代的8/10,From区占新生代的1/10,To区占新生代的1/10,From区和To区的作用是GC时采用的复制-清除算法,复制策略就是将原来存在的内存分为两个相等的区,使用一块进行新生代的内存分配,当要GC时,则将存活的对象复制进入另一块空闲的内存,然后将使用的内存进行清除,从而又有一个空闲区和一个使用区,并且不会有碎片问题。实际上并不需要两个1:1的分区比例,因为一般存活的对象很少,所以JVM聪明的讲新生代占据的总内存分为Eden:From:To = 8:1:1三部分,其中Eden就用来分配新的对象内存,From则用于GC时的复制,那为什么需要两个Survivor区呢,因为复制后From区虽然现在很整齐,没有碎片,当下一次进行回收时,Eden区和From区里都存在需要回收的对象,则From区也会出现碎片。
对象优先分配在Eden区
大多数情况下,对象在新生代的 Eden 区中分配。
大对象直接分配到老年代
大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组
长期存活的对象分配到老年代
每一个对象的对象头中都有一个对象年龄的计数器,当分配在新生代的对象发生了一次Minor GC(新生代GC),对象年龄就会+1,当它的年龄增加到一定程度(垃圾回收器默认为15)时,就会被晋升到老年代中。
对象年龄可以动态判定
复制算法是将内存划分为两块相等的区域,每次只使用一块区域,当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使
用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可,
实现简单,运行高效。只是这种算法的内存利用率很低,每次只能使用一半的内存。
传统的复制算法空间利用率很低,所以就诞生了一种更加优化的复制算法–Appel 式回收
,具体做法是分配一块较大的 Eden
区和两块较小的 Survivor空间
(可以叫做 From区
或者 To区
)。大概原理前面已经讲到过JVM堆的分区 。
标记清除算法分为两步,分布是标记和清除。首先扫描所有对象标记出需要回收的对象,在标记完成后扫描回收清除所有被标记的对象。由于需要扫描两次,因此回收效率比较低。它还有一个最大问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。标记清除算法主要用于老年代。
标记整理算法分为三个步骤,分别是标记、整理和清除。首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。标记整理算法虽然没有内存碎片,但是效率偏低。标记整理算法主要用于老年代。
JVM的垃圾回收器根据类型主要分为单线程垃圾回收器、多线程并行垃圾回收器和并发垃圾垃圾回收器,之间的关系见下图(图片来自网络)。
垃圾回收器 | 回收区域 | 算法 | 垃圾回收器类型 |
---|---|---|---|
Serial | 新生代 | 复制算法 | 单线程(串行) |
Parallel Scavenge | 新生代 | 复制算法 | 并行的多线程回收器 |
ParNew | 新生代 | 复制算法 | 并行的多线程收集器 |
Serial Old | 老年代 | 标记整理算法 | 单线程(串行) |
Parallel Old | 老年代 | 标记整理算法 | 并行的多线程回收器 |
CMS | 老年代 | 标记清除算法 | 并发的多线程回收器 |
G1 | 跨新生代和老年代 | 标记整理 + 化整为零 | 并发的多线程回收器 |
具体的介绍和分析可以查看下面的文章:
JVM常见垃圾回收器介绍
JVM之G1垃圾回收器
《深入理解Java虚拟机》
《Java虚拟机规范(Java SE 7)》
Java虚拟机(JVM)你只要看这一篇就够了!
JVM系列篇:JVM的4种垃圾回收算法、垃圾回收机制与总结