Java基础 - JVM内存管理
前言
一、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程序,直接操纵了本地内存