JVM 内存结构划分
堆内存
概念
Heap 堆
- 通过new关键字创建对象都会使用堆内存
- 一个JVM实例只存在一个堆内存,堆也是java内存管理的核心区域,java堆区在JVM启动的时候即被创建,其空间大小也就确定了,它是JVM管理的最大一个块内存空间
特点
他是线程共享的,堆中对象都需要考虑线程安全问题,
《java虚拟机规范》中规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,所有的线程共享Java堆,在堆中还可以划分线程私有的缓冲区(Thread local Allocation buffer, TLAB)
有垃圾回收机制
说明
当栈帧被执行的时候,里面有对象的创建,那么栈帧里面仅仅事保存对象名以及对应的地址值,真正的对象存储是被分配到了堆内存:(全流程图)
内存分配关系
《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上(The heap is the run-time data area from which memory for all class instances and arrays is allocated)
要注意的是:“几乎”所有的对象实例都在这里分配内存---是从实际使用角度看的,因为还有一些对象是在栈上分配的
数组和对象可能永远不会存储在栈上,因为战阵中保存引用,这个引用指向对象或者数组在堆中的位置
比如下面一段简单的代码
public class Demo2 { public static void main(String[] args){ Hello h1 = new Hello(); Hello h2 = new Hello(); } class Hello{ } }
在内存中的存放位置以及执行流程如下:
对内存大小配置
java 堆区用于存储Java对象实例,那么堆的大小在JVM启动的时候就已经设定好了,大家可以通过-Xmx和-Xms来及逆行设置
"-Xms" 用于表示堆区的起始内存,等价于-XX:InitialHeapSize
一旦堆区中的内存大小超过"-Xmx"所指定的最大内存时,会抛出OutOfMemoryError异常
通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分割计算堆区的大小,从而提高性能
默认情况下:
初始内存大小:电脑物理内存大小/64
最大内存大小:电脑物理内存大小/4可以通过下面代码进行查看:
/** * 查看堆内存的参数 * * -Xms : 用来设置堆空间(年轻代 + 老年代) 的初始内存大小 * -X : 是JVM运行的参数 * ms : memory size的缩写 * -Xmx : 用来设置堆空间(年轻代 + 老年代) 的最大内存大小 */ private void seeMemorySize(){ //返回java虚拟机中的堆内存总量 long memorySize = Runtime.getRuntime().totalMemory() / 1024 / 1024; System.out.println("虚拟机中堆内存总量" + memorySize + "M"); //返回java虚拟机中的堆内存最大内存参数 long memoryMax = Runtime.getRuntime().maxMemory() / 1024 / 1024; System.out.println("虚拟机中最大堆内存参数" + memoryMax + "M"); //返回java虚拟机中的堆内存空闲数量 long freeMemory = Runtime.getRuntime().freeMemory() / 1024 / 1024; System.out.println("虚拟机中堆内存空闲数量" + freeMemory + "M"); }
堆内存分代模型
- 存储在jvm中的java对象可以被划分为两类:
- 一类是生命周期较短的瞬时对象,这类对象的创建和销毁都非常迅速,生命周期短的,及时回收即可
- 另一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致,比如class字节码对象,只有满足三个条件的情况下,才会被GC也就是卸载:
- 该类的所有实例都已经被GC
- 加载该类的ClassLoader已经被GC
- 该类的java.lang.Class对象那个没有在任何地方被引用
java 堆区进一步细分的话,可以划分为年轻代/新生代(YoungGen)和老年代(OldGen),其中年轻代又可以划分为Eden空间,Survivor()空间和Survivor1空间(有时也叫做from区,to区)
- 上面这参数开发中一般不会调:
- Eden : From : To -> 8:1:1
- 新生代:老年代 -> 1:2
配置新生代与老年代在堆结构中的占比:
默认情况下:-XX:NewRatio=2, 表示新生代占1,老年代占2,新生代占整个堆的1/3
可以自定义:-XX:NewRatio=4, 表示新生代占1,老年代占4,新生代占整个堆的1/5
当发现在整个项目中,生命周期长的对象偏多,那么可以通过过调整老年代的大小,来进行调优
再HotSpot重,Eden空间和另外两个Survivor空间默认占比是8:1:1,当然开发人员可以通过选项"-XX:SurvivorRatio"调整这个空间比例,比如:-XX:SurvivorRatio=8
几乎所有的java对象都是在Eden区被new出来的,绝大部分的java对象的销毁都在新生代进行了,(有些大的对象再Eden区无法存储的时候,将直接进入老年代)
IBM公司的专门研究表明,新生代中80%的对象都是"朝生夕死"的
可以使用选项"-Xmn"设置新生代最大内存大小
这个参数一般使用默认值就可以了
查看堆内存情况指令
jps 查看当前进程的PID
jstat -gc PID 查看当前的堆内存情况
#参数说明
S0C: 第一个幸存区(From)的大小
S1C:第二个幸存区(To)的大小
S0U: from 区的使用大小
S1U: To 区的使用大小
EC: EDen区的大小
EU: EDen区使用大小
OC: 老年代大小
OU: 老年代使用大小
MC: 方法区大小
MU: 方法区使用大小
CCSC: 压缩类空间大小
CCSU: 压缩类空间使用大小
YGC: 年轻代垃圾回收次数
YGCT: 年轻代垃圾回收消耗时间
FGC: 老年代垃圾回收次数
FGCT: 老年代垃圾回收消耗时间
GCT: 垃圾回收消耗总时间
图解对象分配机制
为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配,在哪里分配等问题,并且由于内存分配算法与回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片
因此,整个对内存的对象分配核心规则如下:
- 所有的new对象先放在伊甸园区,包括Class对象
- 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对新生代去进行垃圾回收(MinorGc/YoungGC),将伊甸园区中的不再被其它对象所引用的对象进行销毁,然后将伊甸园中的剩余对象移动到幸存者0区(或者1区,不确定),在加载新的对象放到Eden区
- 如果再次触发垃圾回收,此时上次幸存下来的放在幸存者0区对象,如果没有被回收,将会和本次Eden区中幸存下来的对象一起放到Survivor1区
- 后续以此类推,循环反复
- 进入老年区的方法:可以设置次数,默认是15次,可以通过参数-XX:MaxTenuringThreshold=N进行最大年龄的设置
注意事项
在Eden区满了的时候,才会触发MinorGC,而幸存区满了之后,不会触发MinorGC操作
如果Survivor区满了以后,将会触发一些特殊的规则,也就是可能直接晋升老年代
对象分配流程案例实战
代码实例
public class ObAssTest { private static User user = new User(); public static void main(String[] args) throws InterruptedException { user.login(); for (int i = 0; i < 100; i++) { doSomeThings(); Thread.sleep(200); } } private static void doSomeThings(){ Student student = new Student(); student.study(); } class User{ public void login(){ System.out.println("登录"); } } class Student{ public void study(){ System.out.println("学习"); } } }
以上代码的对象分配流程为:
在jvm启动的时候,会将所有的.class字节码文件加载jvm方法区中,并在方法区中生成对应的class对象,在class对象中会生成对应的静态方法以及静态变量,引用类型的静态变量赋值为null
此时栈空间会生成一个虚拟机栈,要将test类的main()方法压入栈中,所以此时的流程是jvm先通过方法区中的class对象在堆空间的Eden区域生成一个Test对象模板,然后给这个对象模板的static应用数据类型赋值:通过User的class对象创建User的模板对象,并根据这个模板对象创建一个User对象,然后将这个User对象的地址赋值到Test模板对象中
- 此时虚拟机栈继续向下执行,执行User对象的Login()方法,执行完毕后,将这个方法弹栈,然后继续向下执行,执行下面的循环方法
- 循环方法执行时,需要用到Student对象,所以这个时候虚拟机会现根据Student.class对象创建Student模板对象,并且根据这个模板对象创建Student对象,并把这个对象的地址赋值到虚拟机栈中的doSomeThings方法中,由方法的内部变量进行调用,调用完成后,循环方法会被弹栈,方法内部的引用也会被销毁,此时Eden空间中的Student对象没有引用指向,会成为垃圾
- 循环往复,这时候执行多次,堆内存中的Student对象会越来越多,但是都没有引用指向,所以当Student对象放不下的时候,就会出发minorGC来清理Eden空间中的垃圾对象,同时将堆内存中的模板对象以及User对象放置在Servivor0或者servivor1空间中,并标记这些对象的年龄(GC次数),循环往复
- 最终当方法执行完毕之后,Servivor中的模板对象以及User对象会被移到老年代中,如果老年代满了以后,会执行FullGC(stop the world),将所有的程序停止,进行全盘大清理,将老年代和新生代中的所有垃圾对象进行整体清理
大对象频繁创建OOM案例实战
执行以下代码
byte[] bytes = new byte[new Random().nextInt(1024 * 1024 * 2)];
/**
* 大对象频繁创建导致系统OOM
*
* 在使用的时候,需要设置JVM的参数: -Xms300M -Xms300M : 新生代100M,老年代200M,Eden80,S010,S110
*/
private void bigObjOOM() throws InterruptedException {
ArrayList<MemoryTest> memoryArr = new ArrayList<>();
while (true){
memoryArr.add(new MemoryTest());
Thread.sleep(200);
}
}
然后通过java自带的检测程序jvisualVM来进行监控,但是需要安装一个插件 visual GC
HotSpot中方法区的演进+内部结构
方法区主要存放的是[class],而堆钟主要存放的是[实例化对象]
- 方法区(Method Area)与java堆一样,是各个线程共享的内存区域
- 方法去在jvm启动的时候被创建,并且它的实际物理内存空间中和java堆区一样都可以是不连续的
- 方法区的大小,跟堆空间一样,可以选择固定大小或者可拓展
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误,java.lang.OuttoMemoryError:PerGen space或者java.lang.OutOfMemoryError:Metaspace
- 加载大量的第三方的jar包
- Tomcat部署的工程过多(30-50个)
- 大量动态的生成反射类
- 关闭JVM就会释放这个区域的内存
HotSpot中方法区的演进
在jdk7之前,习惯上把方法区,称为永久代,jdk8开始,使用元空间取代了永久代,本质上,方法区和永久代并不等价,仅是对HotSpot而言的,《java虚拟机规范》对如何实现方法区,不做统一的要求,例如:BEAJRockit/IBM j9中不存在永久代的概念
现在来看,当年使用永久代,不是好的idea,导致java程序更容易OOM(超过-XX:MaxPermsize上限)
元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
永久代,元空间二者并不是名字变了,内部结构也调整了
根据《java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常
方法区内部结构
《深入理解java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息,常量,静态变量,即时编译器编译后的代码缓存等
常量池
一个有效的字节码文件除了包含类的版本信息,字段,方法以及接口等描述符信息外,海报狠了一项信息就是常量池表(Constant Pool Table),包含各种字面量和对类型,域和方法的符号引用
常量池,可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型,字面量等类型
运行时常量池
方法区,内部包含了运行时常量池
要弄清楚方法区的运行时常量池,需要理解清楚classFile中的常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分
常量池表(Constant Pool Table)是class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池
JVM 为每个已加载的类型(类或者接口)都维护一个常量池
,池中的数据项像数组项一样,通过索引
访问
运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后
才能够获得的方法或者字段引用,此时不再是常量池中的符号地址
了,这里更换为真实地址
当创建类或者接口的运行时常量池时,如果构造运行时常量池所需要的内存空间超过了方法区所能提供的最大值,则JVM会抛出OutToMemoryError异常
HotSpot方法区的演进细节
首先明确:只有HotSpot猜又永久代,BEA JRockit,IBM,J9等来说,是不存在永久代的概念的,原则上如何实现方法区属于虚拟机实现细节,不受《java虚拟机规范》管束,并不要求统一。
HotSpot中方法区的变化:
版本 | 内容 |
---|---|
JDK1.6及以前 | 有永久代,静态变量存储在永久代上 |
JDK1.7 | 有永久代,但是已经逐步"去永久代",字符串常量池,静态变量移除,保存在堆中 |
JDK1.8 | 无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池,静态变量仍在堆中 |
JDK6的时候:
1.7的时候
1.8的时候
StringTable
StringTable叫做字符串常量池,用于存放字符串常量,这样当我们使用的相同的字符串对象时,就可以直接从StringTable中获取而不用重新创建对象。
StringTable为什么要调整位置
jdk7中将StringTable放到了堆空间中,因为永久代的回收效率很低,在Fullgc的时候才会触发,而Fullgc是老年代的空间不足,新生代不足时才会触发
这就导致了StringTable回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足,放到堆里,能及时回收内存。
String相关的面试题如下:
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
//问
System.out.println(s3 == s4);
System.out.println(s3 == s5);
System.out.println(s3 == s6);
String x1 = "cd";
String x2 = new String("c") + new String("d");
//问: 如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2);