JVM 内存结构
一、前言
有很多小伙伴会把内存结构和内存模型两个概念搞混
内存结构,就是我们常说的堆、栈、方法区这些部分
内存模型,则是为了解决并发的可见性和有序性问题,引入的概念
还是和之前一样,这篇博客是我在阅读大量资料之后,总结出的提纲,不适合用来入门,想要系统学习内存结构的相关知识的话,可以去翻阅下面这篇文档哦:
JVM 内存结构
二、内存结构知识点整理
1、讲讲 JVM 中内存结构的组成
主要分为下面这些部分:
JVM 中的程序计数器,其本质和 CPU 的寄存器是类似的,用来存储指向下一条指令的地址;可以将其理解为纸带模型的那条纸带
之所以需要程序计数器,是因为我们在线程切换之前,必须要记录程序当前运行的位置,好在切换回该线程的时候,可以从之前线程中断的地方继续运行,也正因为这点,程序计数器是线程私有的
虚拟机栈是线程私有的,每开启一个线程,就会为其分配一个虚拟机栈,其存储单位是栈帧,一般一个方法对应一个栈帧。当我们的调用过深,超过栈空间的时候,虚拟机栈会抛出 StackOverFlow 异常,如果虚拟机栈在尝试扩容的时候,内存空间不足,会抛出 OOM 异常
JDK中,有一些方法是 native 修饰的,这些方法,一般都是 c/c++写的,内嵌在 JVM 中,我们的虚拟机栈因为只能管控 java 程序,自然无法处理这些本地方法,所以,需要专门一个本地方法栈来管理他们。
堆空间主要是存储我们创建的对象,是线程共享的,分为新生代,老年代,元空间(1.7前是永久代,这里要理清一下元空间和永久代的区别,永久代是在堆中的,与新生代和老年代的空间是连续的,同样受到 JVM 的管控,但是 java8 之后的元空间,就是在堆外内存中了,也就是我们计算机中的 ram ,同时,字符串常量池也被移到了元空间中),当堆空间不足的时候,会使用垃圾回收算法去清理堆空间。(垃圾回收算法后面会介绍)
注意,方法区只是一个规范,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据
其实现,是由厂商定义的,可以是永久代,可以是元空间(java8 之后)
方法区可以理解为接口,而永久代和元空间,可以理解为不同的接口实现
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等
2、讲讲栈帧的结构
栈帧是虚拟机栈的存储单位
栈帧分为下面四个部分
局部变量表即存在当前方法中的变量
局部变量只在当前方法中有效,当当前方法结束,局部变量表也随之销毁
主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
在昨天的类加载器那一章,我们介绍过,在连接的解析阶段,会将符号引用转换为引用句柄
这里的动态链接,即被从符号引用转换过来的句柄
用来存放调用该方法的 PC 寄存器的值。
3、讲讲堆内存的结构
堆内存分为下面几个部分:
所有的新对象创建之后,都会先放在新生代中
新生代同时分为 Eden(伊甸)区,survivor0(幸存区0)和 survivor1(幸存区1),其划分比例为 8:1:1
Eden 区使用的是标记清除法,两个幸存区大小相等,可以很明显的看出,其使用的是复制清除法
当新生代的对象挺过了一定次数的 GC,就会被转移至老年代,老年代的 GC 频率相对就会低很多
这里要注意一点,大对象会直接进入老年代,避免大量的内存拷贝
无论是永久代还是元空间,其都是方法区的实现
1.8 之前的永久代,是和新生代和老年代一起的,都是由JVM管控;而1.8 之后的元空间,就是在物理内存区域中,不受 JVM 控制
4、为什么永久代会被元空间替换
在web项目中,功能点一般都是随着项目启动加载进内存,随着项目停止销毁的(controller,service,dao),所以它们都是会被放入永久代中的
因为不同项目的功能点个数是不一样的,为之设置永久代大小就会变得十分困难,一旦项目体积变大,功能点变多,那么永久代就会频繁出现 OOM 异常
而是用元空间后,方法区的大小就只是受限于物理内存大小了,所以大的项目选用内存较大的服务器,就不太会出现 OOM 异常
5、讲讲对象从创建到被回收的过程
新对象被创建之后,会被分配到新生代的 eden 区中
当 (第一次)eden 满时,JVM 会进行 minor GC,对 eden 区中不用的对象进行回收,并将幸存的对象转入幸存区 0
当(第二次)eden 满的时候,会扫描 eden 和幸存区0,将不用的对象清除,并将所有幸存对象复制进 幸存区1,并清空幸存区0
当(第三期)eden 满的时候,会扫描 eden 和幸存区1,将不用的对象清除,并将所有幸存对象复制进 幸存区0,并清空幸存区1
新生代的分配过程就是这样循环往复进行的
一旦新生代的对象 GC 标记次数超过一定阈值(默认15次,即当前对象躲过了15次 GC),或者某次 minor GC过后,某个幸存区满了,那么这些对象就会被转移至老年代
老年代的 GC 频率会低很多,一旦老年代也满了,就会触发 Major GC,去清理老年代
6、了解逃逸分析吗?讲讲为什么需要逃逸分析及其会做的优化
逃逸分析,就是判断方法中的对象是否会被外部引用
拿下面这个代码举例:
public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
因为 sb 对象可能被外部调用,所以我们认为 sb 对象逃逸了
如果不希望其逃逸,代码可以修改成如下形式:
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
基于逃逸分析,我们就不一定要将对象在堆中进行空间申请了,从而能够减轻 GC 的压力
基于逃逸分析,能做出的优化如下:
1、栈上分配:
如果分析发现,当前对象只会在当前方法中被使用,那么就没必要在堆中为其申请空间了,在虚拟机栈上为其分配空间即可(反正这个对象就是随着当前方法的出栈而不再使用的),从而可以减轻 GC 负担
2、同步省略(锁消除):
public void keep() {
Object keeper = new Object();
synchronized(keeper) {
System.out.println(keeper);
}
}
上述代码中,keeper 对象的生命周期因为只在 keep() 方法中(其实我们也可以看出,这个加锁其实没什么卵用…),其他线程并没有访问到,所以锁会被优化掉,变成下面这种形式:
public void keep() {
Object keeper = new Object();
System.out.println(keeper);
}
3、标量替换:
所谓标量,就是不可再分的变量,Java 中的基础数据类型,就是标量
对应标量的,就是聚合量,聚合量,是可在被分为标量和其他聚合量的,对象就是聚合量
在 JIT 阶段,通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM 不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。这个过程就是标量替换。
public static void main(String[] args) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
private int x;
private int y;
}
上述方法中,Point 对象没有逃逸出 alloc() 方法,所以point 会被拆成标量,从而不用再在堆中申请空间了
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x="+x+"; point.y="+y);
}
但是,逃逸分析并不成熟,其也有自己的弊端,比如经过一同逃逸分析后,发现没有对象逃逸,那么这次分析的过程,就白白浪费了
三、预告
明天,我会分享内存模型的相关知识,让各位对内存结构和内存模型的区别有一个感性的认识
|