在上面的Class加载章节中,我们讲解了JVM是如何将Class文件加载进内存的。那当我们对这个Class进行实例化之后,JVM又是如何处理这个实例化对象的呢?我们今天来了解下Java的内存模型JMM
内存分布
当我们在代码中写完 T t = new T(),我们知道这是这是将Class T实例化了一个t对象,那这个t对象,在内存中是如何体现的呢?
主要分为普通对象和数组对象,差别不大,我们先看下普通对象的内存分布情况:
实例化的普通对象t的组成主要有4块内容:
- markWord :对象头,里面有GC次数,锁等信息,占8字节
- Class point:指针,用于将该实例指向内存中某一个具体的Class对象,4字节或者8字节(取决于是否开启指针压缩)
- 实例数据:Class对象中定义从那些成员变量
- padding 对齐:部位,为了使整个实例对象的大小为8字节的整数倍
可能大家还没怎么理解,举一个例子说明:
Object obj = new Object()
请问:obj对象有几个字节?
对照上面的讲解,首先markWord,8字节;Class point指针,将obj对象指向Object.Class对象,如果开启指针压缩,则占4个字节,如果没有则占8字节;没有实例数据,占0字节,下面到padding部分了,这个得计算一下了:
如果开启指针压缩,则obj现有字节数为8+4=12,那padding部分需要凑满最小的8字节整数倍为16,所以padding部分占16-12=4字节
如果没有开启指针压缩,则obj现有字节数为8+8=16,已经是8字节的整数倍,所以padding部分占16-16=0字节。
那如果实例对象是个数组,又是个上面情况呢?
T[] t = new T[2]
我们可以看到,对比普通实例对象,数组的对象会多出一项,用于记录数组的长度,占4个字节,其他都是一样的。
PS:简单介绍下开启指针压缩:在64位平台的HotSpot中使用32位指针,内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力。可以通过使用命令:
-xx:+UseComparessedPointers 开启Class pointer的指针压缩
-xx:+UseComparessedOops 开启普通对象引用的指针压缩。
MarkWord
关于MarkWork的定义,是在markOops.hpp文件中,JDK的每个版本之间的定义稍有不同,且markWord里的内容,跟对象当前的状态有关,锁定状态/无锁状态等情况下是不一样的,大体如下:
解释一下:
- 当对象在无锁状态下,且对象没有被复写hashCode()方法,且被调用hashCode方法,JVM根据对象体的内容生成hashCode,也叫identityHashCode,写入markWord的前25位,接着的后4位代表分代年龄,后1位代表是否是偏向锁,再2位代表锁的状态
- 如果对象进入轻量级锁状态,前25+4位 记录的是拥有当前轻量级锁的线程ID指针等信息
- 如果对象是重量级锁的状态,同上
- 如果对象进入GC了,同上
- 如果对象进入偏向锁,同上
- 32位的JVM和64位的JVM在位数以及代表的内容上稍有不同,不过都是大同小异的,差不多这么理解就可以了。
同时通过上图,我们可以看到分代年龄是占了4位,转为10进制,取值范围位0-15,这也解释了为什么JVM默认将一个对象尝试回收了15之后就转移进入老年代,因为标记的最大值就是15,后面无法再次标记了。
运行时数据区
运行时数据区(run time data area)有下面几个部分组成:
- program counter ,简称PC,也叫程序计数器,寄存器,用来存储需要执行的下一条指令地址
- Direct Memory,直接内存,JVM直接从用户态读取内核态的数据
- heap,堆,用来存放对象等
- Method Area:方法区,存储:常量池(Constant poll)、方法数据、方法代、类的信息(Mate Space)等。
- stack,栈,分有JVM虚拟机栈(JMS)以及本地方法栈(NMS),JMS:每个JMS里面都用来存放栈帧(Frame),而每个方法都会创建一个栈帧;NMS:主要与虚拟机用到的 Native 方法相关,一般情况下, Java 应用程序员并不需要关心这部分的内容。
运行时数据区,这个是JVM规范出来的,是一个逻辑概念,每个JVM厂商之间的具体实现会不一样,JVM规范中内存区域的划分和每家具体实现中jvm内存区域的划分,它们之间存在一个映射关系
如:Hotspot,在JDK1.8之前,Method Area对应是属于老年代,不会被GC,1.8之后定义出Meta Space,其归属于堆,是可以被GC的。
下面简单梳理下他们之间的关系:
- 每一个线程都有自己的PC,虚拟机栈,本地方法栈,这些都是线程私有的
- Method Area以及heap,放的都是class,常量等相关信息,属于线程共有
栈帧Frame
这个栈帧是个什么东西呢?一个Frame的组成有下面几个部分:
- Local Variable Table :局部变量表
- Operand Stack:操作数栈
- Dynamic Linking : 动态链接。举个简单的例子,我们在一个方法A中调用了方法B,当执行到这一行时,该去哪里找B方法的相应信息呢?没错,就是通过Dynamic Linking,去Class的常量池中去找到B方法的信息,然后再执行B方法。
- return address:返回值地址
Dynamic Linking和return address相信大家现在已经知道是什么意思了,Local Variable Table 和Operand Stack又具体是什么呢?
public class Main {
public static void main(String[] args) {
int i = 8;
i = i++;
System.out.println(i);
}
}
首先我们使用javap命令将该方法的class文件进行反编译,
我们可以看到,在void mian()方法的反编译下面,有这么几块:
- descriptor: ([Ljava/lang/String;)V , “[Ljava/lang/String”其中"[L"代表是个数组,“String”代表字符串,合起来就是表示,方法入参是个String类型的数组,后面的V代表方法返回值为void
- flags:public,代表方法是public的
- code:方法体,后面讲
- LineNumberTable:行号
- LocalVariableTable:这变就是局部变量表了
下面我们简单看下这个LocalVariableTable:
有两条记录,看"name"这一栏,有两个值args、i,这不正好是我们main方法里面的入参和局部变量i嘛!到这边相信大家已经很清楚Frame里面的局部变量表存放的是哪些信息了。
目前还剩下 Operand Stack操作数栈了,下面我们具体看下反编译后的code部分,不过在这之前,需要先了解下JVM的几个基本的指令:
指令 | 描述 |
---|---|
iconst_1 | int型常量值1进栈 |
bipush | 将一个byte型常量值推送至栈顶 |
istore_1 | 将栈顶int型数值存入下标为1的局部变量(索引号是从0开始的) |
iadd | 栈顶两int型数值相加,并且结果进栈 |
iload_1 | 将下标为1的局部变量入栈(索引号是从0开始的) |
new | 创建一个对象,并且其引用进栈 |
newarray | 创建一个基本类型数组,并且其引用进栈 |
invokevirtual | 调用实例方法 |
invokespecial | 调用超类构造方法、实例初始化方法、私有方法 |
invokestatic | 调用静态方法 |
invokeinterface | 调用接口方法 |
Code:
stack=2, locals=2, args_size=1 //栈深度为2,局部变量表有2个值,入参1个值
0: bipush 8 // 将8压栈,此时操作数栈的栈顶为8
2: istore_1 //将栈顶数据出栈,也就是将8进行出栈,同时将8赋值给局部变量表下标为1的变量,也就是变量i,下标为0的局部变量为args
3: iload_1 //将下标为1的变量压入操作数栈,也就是变量i入栈,经过上一步,局部变量i已经被赋值为8,所以入栈之后,栈顶为8
4: iinc 1, 1 //对下标为1的变量+1
7: istore_1 //同上,将栈顶的8出栈,同时将8赋值给局部变量表下标为1的变量,也就是变量i
8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
11: iload_1
12: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
15: return
回到上面的例子,方法A调用方法B,该线程的JVM栈情况是这样的: