其实JVM中的内存池并不是真实存在的,它是OS堆中划分出来的一部分,会通过chunk来记录内存的使用。
JVM内存模型其实就是JVM在启动的时候从操作系统内存中要了一块大内存,然后将这个大内存分成五个区域:方法区、堆区、虚拟机栈、本地方法栈、程序计数器。
public class Test {
public static Test test4 = new Test();
public static void main(String[] args) {
Test demo = new Test();
System.out.println(demo.add());
}
public int add() {
int a = 10;
int b = 20;
return a + b;
}
}
当我们执行这段代码的时候,在JVM中到底发生了什么呢?
我们再来细分.class的加载:
2.1、类加载子系统
我们先来了解一下下面四个概念:
class文件:指的是硬盘上的.class文件
class content :指的是类加载器将.class文件加载到内存,存储字节码文件数据的那块内存区域。 在源码中是使用stream字节流来读取的
ClassFilesStream* cfs = stream();
然后会对读取到的文件进行解析,会解析成以下几部分:
在方法区中,实际上会按照不同的类加载器分配出对应的内存空间,用来存储该类加载加载的类:
static int i = 1; //the value 1 is stored in the PermGen sectionstatic
Object o = new SomeObject(); //the reference(pointer/memory address) is stored in the PermGen section, the object itself is not.
class对象 :Class clazz = Test.class;clazz对象就是一个class 对象。在JVM中,真正获取到的是InstanceMirrorKlass实例
对象 :Test obj = new Test() 或者通过反射实例化的对象;
然后我们再来理解一下方法区这个概念:
2.2、方法区
方法区是规范,永久代和元空间是具体的实现。
这里需要思考一下,为什么使用元空间取代了永久代?
1、GC算法,分开存储有利于GC算法的实现;
2、OOM
3、硬件的发展:之前的32bit中,内核层占了2G,应用层之占2G,所以为了限制JVM的占用内存就放到一起管理
4、字符串也会OOM
2.3、元空间
如果不做调优的话,元空间的大小是多少呢?
我们可以使用命令“java -XX:+PrintFlagsInitial”来查看:
uintx MetaspaceSize = 21810376 // 大约20.80 Mb
元空间的调优参数是:
-XX:MetaspaceSize
-XX:MaxMetaspaceSize
元空间如何调优:
1、其实和堆区的调优一样,最大和最小值要设置成一样,防止内存抖动;内存忽大忽小,或者需要提前判断是否需要扩容,这些都会带来性能开销;
2、调成多少:物理内存的1/32;
3、如何查看服务运行之后元空间的大小:visualVM(需要额外开端口)、arthas(阿里)
2.4、本地方法栈
Java调用C、C++的动态链接库、运行里面的函数需要的栈(JVM内存很多都是通过JNI实现的,但是现在随着socket的发展,JNI的使用越来越少)
2.5、虚拟机栈
问题:JVM中有多少个虚拟机栈?一个线程一个!虚拟机栈是线程私有的。
问题:虚拟机栈中最小的是栈帧,那么虚拟机栈中有几个栈帧?方法调用次数个!
问题:为什么需要栈帧这个数据结构?为了处理方法的运行更清晰。
PS:方法是静态的,只有方法运行的时候才会产生栈帧!
栈帧组成:
方法区中的klass对象会存储方法对象集合,包括了main方法
JVM内部执行main方法的过程:
1、创建运行main方法需要的栈帧
2、将main方法的操作数栈指针赋值给线程的属性:操作数栈
3、将main方法的局部变量表指针赋值给线程的属性:局部表指针
JVM内部执行add方法的过程:
public int add() {
int a = 10;
int b = 20;
return a + b;
}
1、首先创建add方法的栈帧;
2、在add方法的栈帧中保存main方法的字节码的下一行的程序计数器
我们可以看到,在main方法的程序计数器的第12行调用了add方法,那么调用完成之后应该紧接着调用下一行,也就是程序计数器的第15行。
3、线程的局部开始指针(main的)保存至add方法的栈帧
4、线程的操作数栈开始指针(main的)保存至add方法的栈帧
5、将add方法的局部表指针赋值给线程的局部表指针
6、将add方法的操作数栈指针赋值给线程的操作数栈指针
PS:每个线程都维护有两个指针:局部变量表和操作数栈当前指针。最开始的时候,有一个线程执行首先执行main方法,那么他所维护的这两个指针应当是和main方法相关的,即mian方法的局部变量表和main方法的操作数栈。但是当main方法中调用add方法的时候,这两个值会动态的执行add方法的局部变量表和add方法的操作数栈。而当add方法执行完成后,这两个指针应当又指向main方法的局部变量和操作数栈。所以,这里有恢复现场的说法。
2.6、程序计数器
因为当前的JVM是虚拟出来的,所以这里的程序计数器实际上是字节码的索引。即字节码执行的行号。
真正的程序计数器是EIP(32bit)、RIP(64bit),是OS级别的。
2.7、局部变量表
LocalVariableTable, 存放局部变量的表。每个方法都维护有一份自己的局部变量表。
2.8、操作数栈
push
pop
Test obj = new Test();
0 new #2 <com/jihu/test/jvm/Test> (第一步)
1、在堆区生成了一个对象(不完全对象,未执行构造方法)InstanceOopDesc
2、将不完全对象的指针压入操作数栈,此时这个不完全对象指向堆区
3 dup (dup:duplcate 第二步)
1、复制栈顶元素
2、压入栈
这里我们可能会有一个疑问,为什么要复制呢?
答:将对象指针作为this传参
这里有一个面试题,this指针是何时复制的?
答:非静态方法,第一个参数就是this指针
4 invokespecial #3 <com/jihu/test/jvm/Test.<init>> (第三步)
非静态方法,第一个参数就是this指针
1、执行invokespecial字节码指令,完成运行方法的环境构建
this指针赋值,此时方法的第一个变量就是this指针
2、有了this指针之后,就会执行默认的构造方法
这一段字节码执行完成后,这个对象就是一个完全对象了。
7 astore_1 (第四步)
1、将栈顶元素pop
2、将完全对象的地址复制给局部变量表index=1的位置,即复制给测试代码中的demo局部变量。(即完成对象的赋值)
第一步:将不完全对象的指针压入操作数栈,此时这个不完全对象指向堆区
第二步:复制栈顶元素并压入栈,此时只有一个不完全对象,会复制该对象
第三步:赋值this指针,执行默认构造,实行之后该对象便是一个完全对象
这里首先会将复制到栈顶的那个不完全对象出栈,然后在将index=0复制为this指针。
即,每个方法的局部变量表中,index=0都是this指针。
第四步:将完全对象的地址赋值给局部变量表index=1的位置,即赋值给测试代码中的demo局部变量。(即完成对象的赋值)
Test demo = new Test();
此时,我们是不是对这一个简单的new对象的一行代码有了更充分的认识呢?
最小大小:默认为物理内存的1 / 64
最大大小:默认为物理内存的1 / 4
调优参数:
-Xms
-Xmx
需要调成一样大
参考这篇文章理解更详细的GC过程:https://blog.csdn.net/wy5612087/article/details/52369677
为什么对象会进入老年代:
1、15次gc后仍然存活的对象
(4bit,0000~11111)最大15次
2、大对象
何为大对象?大小超过Eden区的一半(源码中解释的)
大对象的计算标准不是固定的,Eden区的大小在GC之后是会发生变化的。
空间担保是针对Eden区的
1个Eden区和2个Survivor区(分别叫from和to),默认比例为8:1.
如果此时Eden总共是80M,第一次young GC之后还剩20M, 此时survivor区存不下20M,所以这部分对象会直接进入老年代
调优的目的是最好每一次youngGC之后,都能回收所有垃圾,竟可能的减少full GC
4、动态年龄判断
jvm动态对象年龄判定,是根据Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold要求的年龄。
1、虚拟机栈指向方法区:动态链接
2、虚拟机栈指向堆区:对象的引用(局部变量表)
3、方法区指向堆区:引用类型的静态属性
4、堆区指向方法区:Klass pointer 对象的内存布局
Klass pointer 指向该对象的instanceKlass实例