走进JVM -- 内存布局

概念

内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行.
JVM内存布局规定了java在运行过程中内存申请,分配,管理的策略,保证了JVM的高效稳定运行.

经典的JVM内存布局

在这里插入图片描述

Heap (堆区)

Heap是OOM 故障最主要的发源地,它存储着几乎所有的实例对象,堆由垃圾收集器自动回收,堆区由各子线程共享使用. 通常情况下,它占用的空间是所有内存区域中最大的,但如果无节制的创建大量对象,也容易消耗完所有的空间,
堆的内存空间既可以固定大小,也可以在运行时动态的调整,通过如下参数设定初始值和最大值,比如 -Xms256M -Xmx1024M ,其中 -X 表示它是JVM运行参数, ms 是memory start 的简称, mx 是memory max 的简称.分别代表最小堆容量和最大堆容量.
在线上生产环境中,JVM 的Xms 和 Xmx 设置成一样大小,避免在GC 后调整堆大小时带来的额外压力.

堆分成两大块: 新生代 和老年代.
对象产生之初在新生代,不如暮年时进入老年代,但是老年代也接纳在新生代无法容纳的超大对象.
新生代 = 1个 Eden区 + 2个Survivor区.
绝大部分对象在Eden区生成,当Eden区装填满的时候,会触发Young Garbage Collection ,即 YGC. 在垃圾回收的时候,在Eden区实现清除策略,没有被引用的对象则直接回收,依然存活的对象会被移送到Survivor 区,Survivor区 分为S0 和 S1两块内存空间,每次YGC的时候,他们将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清楚,交换两块空间的使用状态,如果YGC 要移送的对象大于Survivor 区容量的上限,则直接移交给老年代.每个对象都有一个计数器,每次YGC都会加1 ,
-XX:MaxTenuringThreshold 参数能配置计数器的值达到某个阈值的时候,对象从新生代晋升至老年代. 如果该参数配置为1 ,那么从新生代的Eden区直接移至老年代.默认值为 15.可以在Survivor区交换14次之后,晋升至老年代.

  • 对象分配与简要GC流程图
    在这里插入图片描述

如果Survivor 区无法放下,或者超大对象的阈值超过上限,则尝试在老年代中进行分配; 如果老年代也无法放下,则会触发 Full Garbage Collection,即 FGC. 如果依然无法放下,则抛出OOM.
出错时的堆内信息对解决问题非常有帮助,所以给JVM 设置运行参数: -XX:+HeapDumpOnOutOfMemoryError,让JVM遇到OOM异常时能输出堆内信息,特别是对相隔数月才出现的OOM 异常尤为重要.

Metaspace (元空间)

在JDK7 及之前的版本中,只有Hotspot才有Perm区,为永久代,它在启动时固定大小,很难进行调优,并且FGC 时会移动类元信息.
在JDK8 使用元空间替换永久代, 区别于永久代,元空间在本地内存中分配. 在JDK8 里,Perm 区中的所有内容中字符串常量移至堆内存,其他内容包括类元信息,字段,静态属性,方法,常量等都移动到元空间内.

JVM Stack (虚拟机栈)

栈(Stack) 是一个先进后出的数据结构,就像子弹的单夹,最后压入的子弹先发射,压在底部的子弹最后发射,撞针只能访问位于顶部的那一颗子弹.
相对于基于寄存器的运行环境来说,JVM是基于栈结构的运行环境,栈结构移植性更好,可控性更强. JVM 中的虚拟机栈是描述Java 方法执行的内存区域,它是线程私有的. 栈中的元素用于支持虚拟机进行方法调用,每个方法从开始调用到执行完成的过程,就是栈帧从入栈到出栈的过程.
在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧. 正在执行的方法称为当前方法,栈帧是方法运行的基本结构. 在执行引擎运行时,所有指令都只能针对当前栈帧进行操作,而 StackOverflowError表示请求的栈溢出,导致内存耗尽, 通常出现在递归方法中.

  • 操作栈的压栈与出栈如图
    在这里插入图片描述

虚拟机栈通过压栈和出栈的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另一个栈帧. 在执行的过程中,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定. 栈帧在整个JVM体系中的地位颇高,包括局部变量表,操作栈,动态连接,方法返回地址等.

(1) 局部变量表
局部变量表时存放方法参数和局部变量的区域,相对于类属性变量的准备阶段和初始化阶段来说,局部变量没有准备阶段,必须显式初始化.
如果是非静态方法,则在index[0] 位置上存储的是方法所属对象的实例引用,随后存储的是参数和局部变量. 字节码指令中的STORE 指令就是将操作栈中计算完成的局部变量写回局部变量表的存储空间内.

(2) 操作栈
操作栈是一个初始状态为空的桶式结构栈. 在方法执行过程中,会有各种指令往栈中写入和提取信息,JVM 的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈.字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中.

  • 下面一段代码说明操作栈与局部变量表的交互
public int simpleMethod() {
	int x = 13;
	int y = 14;
	int z = x + y;

	return z;
}
  • 详细的字节码操作顺序如下:
public simpleMethod();
descriptor: () I
flags: ACC_PUBLIC
Code:
 stack=2, locals=4, args_Size=1 // 最大栈深度为2, 局部变量个数为4
 	BIPUSH 13   // 常量13 压入操作栈
 	ISTORE_1    // 并保存到局部变量表的 slot_1 中 (第1处)
 	BIPUSH 14   // 常量14压入操作栈,注意是BIPUSH
 	ISTORE_2    // 并保存到局部变量表的slot_2 中
 	ILOAD_1     // 把局部变量表的slot_1 元素(int x) 压入操作栈
 	ILOAD_2  	// 把局部变量表的slot_2元素(int y) 压入操作栈
 	IADD 		// 把上方的两个数都取出来,在CPU 里加一下,并压回操作栈的栈顶
 	ISTORE_3 	// 把栈顶的结果存储到局部变量表的 slot_3 中

	ILOAD_3		
	IRETURN		// 返回栈顶的元素值

第一处说明: 局部变量表就像一个中药柜,里面有很多抽屉,依次编号为 0, 1, 2,3, … , n,字节码指令ISTORE_1 就是打开1号抽屉,把栈顶中的数 13 存进去, 栈是一个很深的竖桶,任何时候只能对桶口元素进行操作,所以暑假只能在栈顶进行存取. 某些指令可直接在抽屉里进行,比如iinc 指令,直接对抽屉里的数值进+1操作.
常见的 i++ 和 ++i 的区别,可以从字节码上对比出来

a=i++a=++i
0: iload_10: iinc 1,1
1: iinc 1,13: ilaod_1
4: istore_24: istore_2

在表左列,iload_1 从局部变量表的第1号抽屉里取出一个数,压入栈顶,下一步直接在抽屉里实现 +1 的操作,而这个操作对栈顶元素的值没有影响. 所以 istore_2 只是把栈顶元素赋值给 a; 表格右列, 先在第1号抽屉里执行+1 操作,然后通过 iload_1 把第1号抽屉里的数压入栈顶,所以 istore_2 存入的是 +1 之后的值.
i++ 并非原子操作,即使通过 volatile 关键字进行修饰,多个线程同时写的话,也会产生数据相互覆盖的问题.
(3). 动态连接
每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态连接.
(4). 方法返回地址
方法执行时有两种退出情况, 第一,正常退出,即正常执行到任何方法的返回字节码指令, 如 RETURN, IRETURN, ARETURN 等;
第二,异常退出,无论何种退出情况,都将返回至方法当前被调用的位置, 方法退出的过程相当于弹出当前栈帧,
退出可能由三种方式:

  • 返回值压入上层调用栈帧.
  • 异常信息抛给能够处理的栈帧
  • PC 计数器指向方法调用后的下一条指令

Native Method Stacks (本地方法栈)

本地方法栈 ( Native Method Stacks) 在JVM 内存布局中, 也是线程对象私有的, 但是虚拟机栈 “主内”, 而本地方法栈"主外" ,这个 “内外” 是针对JVM 来说的,本地方法栈为 Native 方法服务. 线程开始调用本地方法时,会进入一个不再受JVM 约束的世界. 本地方法可以通过 JNI(Java Native Interface) 来访问虚拟机运行时的数据区,甚至可以调用寄存器, 具有和 JVM 相同的能力和权限. 当大量本地方法出现时,势必会削弱JVM 对系统的控制力,以为它的出错信息都比较黑盒.对于内存不足的情况,本地方法栈还是会抛出 native heap OutOfMemory.
最著名的本地方法(JNI 类本地方法)应该是 System.currentTimeMillis(), JNI 使 Java 深度使用操作系统的特性功能,复用非 Java 代码. 但是在项目过程中, 如果大流量使用其他语言来实现JNI, 就会丧失跨平台特性,威胁到程序运行的稳定性.

Program Counter Register (程序计数寄存器)

在 程序计数寄存器 ( Program Counter Register, PC) 中, Register 的命名源于CPU 的寄存器, CPU只有把数据装载到寄存器才能够运行. 寄存器存储指令相关的现场信息,由于CPU 时间片轮限制, 众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或多核处理器中的一个内核,只会执行某个线程中的一条指令.这样必然导致经常中断或恢复,如何保证分毫无差呢?
每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器用来存放指令的偏移量和行号指示器等, 线程执行或恢复都要依赖程序计数器.程序计数器在各个线程之间不影响,此区域也不会发生内存溢出异常.
最后,从线程共享的角度来看.堆和元空间是所有线程共享的,而虚拟机栈,本地方法栈,程序计数器是线程内部私有的,从这个角度看Java 内存结构,如图:
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值