JVM规范中的内存模型
根据JVM规范,JVM内存共分5块
- 虚拟机栈(其内有栈帧概念,每一个具体方法的调用表示为一个栈帧)
每个线程都会有一个私有栈,随线程的创建而创建,线程的销毁而销毁.虚拟机栈中有栈帧概念,每一个具体的方法调用表示为一个栈帧,存放局部变量表(基本数据类型和对象引用指针,方法出口灯)
虚拟机栈就是我们应用程序常规理解中的栈,当栈的调用深度超过JVM允许范围会抛出StackOverflowerError - 本地方法栈
JVM的Native相关,一般来说应用程序开发无需关心本地方法栈 - 程序计数器
每个线程都会有一个自己的程序计数器,用以在多线程执行中,记录该线程执行到指令哪一步的行号
线程上下文切换,从寄存器恢复上一步的执行阶段就靠这个,一般来说无需关心 - 方法区
方法区是线程共享,存放的是类信息(字段,方法等),常理池,编译代码等等. - 堆
堆区是线程共享的,JVM中的对象都是在堆上分配,也是GC的工作目标,当堆无法再分配出需要的内存空间时,会抛出OutOfMemoryError
注意,StackOverFlowError 和 OutOfMemoryError 是
规范之中最常见的配置如下:
- -Xmx1000m
设置JVM的最大内存为1000M - -Xms1000m
设置JVM的初始内存为1000M(一般来说,最大初始设一样就行,避免GC之后再让JVM重新调整内存) - -Xmn1g
设置年轻代为1G
Hotspot-JVM8
Hotspot是最经典应用最为广泛的JVM规范实现.
这里就以HotspotJVM8为例,说一说 HotspotJVM7->8的变化
抛弃永生代
HotspotJVM7->8的最大的变化就是移除了永生代.代之以Metaspace.(无论永生代还是Metaspace,本质上都是Hotspot对方法区的一种具体实现的概念而不是规范中定义的)
移除永生代其实从7开始就已经在做了.在7中永生代中部分内容(符号引用(Symbols),字面量(interned strings),静态变量(class statics))就已经移动到堆中
简单撑爆一个静态变量可以看出
while(true){
TestClass.list.add("123");
}
- 1.6抛出OutofMemoryError:PerGen space
- 1.7抛出OutofMemoryError:Java heap space
- 1.8抛出OutofMemoryError:Metaspace
Metaspace
Metaspace的本质与永生代其实是同一个东西,都是对规范中方法区的具体实现.
Metaspace与永生代的最大不同在于,永生代的本质其实是架构在堆上的(老年代,或者说与堆共同使用JVM的内存),算是一种堆内分配,而Metaspace是直接使用操作系统内存,是一种堆外分配,仅受操作系统内存限制
Metaspace的参数调整如下(意思是永生代已经无效了)
- -XX:MetaspaceSize=1000m
初始空间大小,达到该值会触发类型GC.默认21M - -XX:MaxMetaspaceSize=1000m 默认无限制
个人理解方法区移出堆内,引入Metaspace的原因是:
- 常量区放在堆内是比较容易撑爆堆的.(因为字符串在常量区)
- 减少堆内的复杂环境,让GC跑的更加精确有效
类型信息是非常复杂,其本身也是一种Java对象,放在堆上也会参与GC,移出去就不会被GC扫描,能少扫描对象绝对是好事.而且类型信息本身是对应用程序透明的(类型对象又不是应用程序创建),参与共同GC又非常怪异.所以独立出来让GC专心对付应用程序创建的对象,再用专门的类型GC专门对付类型对象 - 更多的分解带来更少的GC暂停干扰,让类型GC时不必停止整个应用程序,G1就是最好的说明
类指针压缩空间(CCS,Compressed Class Space)
类指针压缩是一种针对如今的高内存带来超大对象数量的应对. 每一个对象都会有一个受托管的操作系统内存指针(oop, ordinary object pointer),一般来说32位足够了,但是因为大内存带来的超大对象数量,为了满足唯一指向,会被迫将32位提升为64位指针.因为指针的翻倍,加上超大数量,可能会仅仅因为指针就浪费相当大的内存.指针压缩就是应对这种情况产物
类指针压缩的压缩算法为零基压缩.
压缩对象指针是基于Java堆内存基地址的偏移量.这样可以将地址降低到32位可以放下(地址的本质是一个数字)
类指针压缩默认在64位虚拟机中默认开启,即在32位JVM,或显示关闭UseCompressedOops时关闭压缩,具体策略如下:
- 堆内存在4G以下,不会使用压缩指针
- 堆内存在4G以上,32G以下,使用零基压缩
- 堆内存在32G以上,放弃使用压缩指针(所以一个JVM上限最好不要超过32G,超大内存会有相当多的麻烦)
JVM8的内存模型如下
-
JVM的内存结构,主要分为以下几块
- 程序计数器(program counter register)
- 栈区(Stack)
- 堆区(Heap)
- 方法区(Method Area)
程序计数器
程序计数器用来存储某个线程当前执行到的字节码行号
多线程情况下,JVM就是依靠这个计数器在切换线程上下文时保证可以恢复到上次执行的地方
所以程序计数器是每个线程都会私有一个自己的行号,并且程序计数器是唯一一个不会发生OOM的地方,程序计数器属于GC工作对象
栈区
JVM中的栈区其实分为两部分 JVM栈和本地方法栈
栈区存储都是线程私有的,并且整个栈区都不属于GC工作对象
对栈来说,如果超出JVM允许的栈最大深度,会抛出StackOutFlow异常
JVM栈
JVM栈是JVM的执行过程抽象,每一个方法执行时都会生成一个栈帧(Stack Frame)
栈帧存储的是执行方法时的局部变量,操作数栈,动态链接,方法出口等
JVM每个方法从调用到执行完成的过程,就是产生一个栈帧并从JVM入栈到出栈的过程
也就是说 栈帧的入栈出栈=方法执行的开始和结束
注意:
局部变量存储的是编译器可知的各种基本数据类型(boolean、 byte、 char、 short、 int、float、 long、 double)和对象引用
所以如果方法内局部变量对象,这个对象本身依然是存储在堆上,只是在局部变量中压入对象的引用(指针)
一个面试题:
public static void main(string[] args){
//局部变量,但局部变量区只会存入v的引用,v的实际存储在常量区
String v = "abc"
//局部变量,这个就比较麻烦了
//首先局部变量区的引用存储的是堆上的对象的指针
//而堆上的对象里面再存一个常量区的引用
String v = new String("abc")
}
本地方法栈
本地方法栈与JVM栈没有本质区别,只是JVM用来执行一些本地语言执行方法
方法区
方法区存储的是类的类型&方法信息,静态变量以及常量区
方法区的数据是线程共享的
方法区有OOM情况,但方法也不属于GC工作对象.方法区有自己回收机制:类型加载和类型卸载
方法区还有一种称呼为永生代,这是将方法区认为是堆的一个组成部分
这种说法也没问题,方法区的确有很多与堆相同的特征,比如线程共享,有内存分配与OOM等等,但是方法区,或者说永生代,不归GC负责,这一点切记
堆区
堆是JVM中最大的一个区,也是GC的主要工作对象.
堆对象创建
所有的对象,数组等等都在堆上创建
对象的组成
- 对象头
用于存储对象自身的运行时数据.如哈希码,GC分代年龄,锁状态,线程持有锁,偏向线程ID,偏向时间戳等,还有对象的类型指针,数组的话还有长度等等 - 对象实例数据
对象真正存储的有效信息,引用类型、基本类型 - 填充物
java中数据类型必须是byte的整数倍,通过“对齐填充”来保证,也就是占位符填充物
对象的创建
- 确保类型已加载,如果没加载就加载类型
- 确保对象所属类型已经经过初始化阶段
- 分配内存
TLAB(Thread Local Allocation Buffer,TLAB) 或者通过CAS锁直接在eden中分配对象 - 如果需要,则对象初始化零值
- 设置 对象头Header
- 如果有引用,将该对象引用放入栈中
- 对象按照程序员的意愿进行初始化(构造器)
在3,6,7中,对象初始化后接着分配引用,这时虽然拿到了引用,但是对象初始化并未完成,所以double check 如果不使用 volatile 就会出现问题的原因
对象分配内存方式
- 直接移动指针划分需要的内存((通过CAS方式保证其他线程不会并行的使用该内存)
- 预先对每个线程划分一小块可使用的内存,这个线程中的对象初始化时则直接使用这一小块内存,直到预先分配的内存使用完,再去使用CAS锁去Heap中分配新的内存(-XX:+/-UseTLAB 设置)
堆的内存结构
HotSpot将堆内存(这里不包括方法区,或者说永生代),分为两大块
- 新生代(Young) 新生代内部再分为(Eden,S0,S1)三个部分,默认为8:1:1
- 老年代(Old)
所以堆的内存结构就是4块.Young代(Eden,S0,S1)+Old代
堆的内存结构要配合GC说明,详见下章