运行时数据区
本专栏学习内容来自尚硅谷宋红康老师的视频以及《深入理解JVM虚拟机》第三版
有兴趣的小伙伴可以点击视频地址观看,也可以点击下载电子书
概述
JVM所有的步骤其实可以理解为厨师炒菜
- 加载:对应着大厨的手下准备食材的过程
- 运行时数据区:可以看作准备好的食材分类放在桌子上供大厨使用
- 执行引擎:大厨使用准备好的食材开始做菜
JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是以来用户线程的启动和结束而建立和销毁。
JVM中的运行时数据区分为以下五块内容
- 程序计数器,也称作PC寄存器
- 本地方法栈
- 虚拟机栈
- 堆
- 方法区
如下图所示,JVM支持多线程,在多线程的情况下,方法区和堆中的对象是共享的,程序计数器、本地方法栈和虚拟机栈是每个线程各拥有一份的。
堆
概述
- 一个进程对用了一个JVM实例,一个进程中的多个线程共享堆中的数据。
- 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。
- Java堆区再JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。
- 堆内存的大小是可以调节的
- -Xmx:设置JVM最大可用堆内存
- -Xms:设置厨师堆大小,一般和Xmx保持一致
- 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但再逻辑上它应该被视为连续的。
- 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区。
- 《Java虚拟机规范》中堆Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。
- 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用地址,这个引用指向对象或者数组在堆中的位置。
- 在方法结束后,堆中的对象不会马上被溢出,仅仅在垃圾收集的时候才会被移除。
- 如果在方法结束后直接移除的话会出现以下问题
- 多个方法同时调用同一个对象,会导致高频率的触发垃圾回收,会影响程序性能
- 堆,是GC执行垃圾回收的重点区域。
堆的内存结构
现代垃圾收集器大部分基于分代收集理论设计,堆空间细分为:
-
Java7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区
- 新生区(新生代、年轻代) Young Generation Space 又被划分为Eden区和Survivor区
- 养老区(老年区、老年代) Old Generation Space
- 永久区(永久代) Permanent Space
-
Java8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间
- 新生区(新生代、年轻代) Young Generation Space 又被划分为Eden区和Survivor区
- 养老区(老年区、老年代) Old Generation Space
- 元空间 Meta Space 并不属于堆,而是本地内存
设置程序的堆内存
-Xms10m -Xmx10m
,启动程序通过jdk/bin目录下的jvisualvm程序来观察堆内存红框中是新生代内存空间,蓝框中是老年代内存空间,两者加起来正好10M
堆空间大小的设置和查看
JVM初始分配的内存由-Xms指定,默认是物理内存的1/64;JVM最大分配的内存由-Xmx指定,默认是物理内存的1/4。
以下代码可以查看堆空间的内存总量以及堆空间的最大内存,在不手动设置时,小黄的运行
public class HeapSpace {
public static void main(String[] args) {
//返回Java虚拟机中的堆内存总量
long totalMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
//返回Java虚拟机中的最大堆内存
long maxMemory = Runtime.getRuntime().maxMemory() /1024 / 1024;
System.out.println("-Xms:" + totalMemory + "M");//-Xms:243M
System.out.println("-Xmx:" + maxMemory + "M");//-Xmx:3611M
System.out.println("系统内存大小为:" + totalMemory * 64 / 1024 + "G");//系统内存大小为:15G
System.out.println("系统内存大小为:" + maxMemory * 4 / 1024 + "G");//系统内存大小为:14G
}
}
将堆内存属性设置为-Xms600m -Xmx600m
执行以下代码
这里提一句题外话:开发中建议将堆内存初始值和最大值的大小设为相同的数值,如果不同,堆内存会自动扩容、自动缩小,会增加压力。
发现程序中计算的堆内存空间只有575M,跟我们设置的不太一样
public class HeapSpace {
public static void main(String[] args) {
//返回Java虚拟机中的堆内存总量
long totalMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
//返回Java虚拟机中的最大堆内存
long maxMemory = Runtime.getRuntime().maxMemory() /1024 / 1024;
System.out.println("-Xms:" + totalMemory + "M"); //-Xms:575M
System.out.println("-Xmx:" + maxMemory + "M"); //-Xmx:575M
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
这是因为S0C和S1C两个幸存者区Java计算内存时只会计算其中一个,并且在实际运用是也只会使用其中一个
新生代和老年代
- 存储在JVM中的对象可以被划分为两类
- 一类时生命周期较短的瞬时对象,这类对象的创建和消亡都非常的迅速
- 另外一类对象的生命周期却非常长,在某些极端情况下还能够与JVM的生命周期保持一致
- Java堆区进一步细分的话可以划分为新生代和老年代
- 其中新生代又可以划分为Eden区,Survivor0区和Survivor1区
在JVM中几乎所有的对象都是在Eden区被new出来的,在Eden区的对象会被垃圾回收,没有被垃圾回收的对象会存入Survivor区,然后在经过一系列的GC操作,还没有消亡的对象会转存到老年代中。
相关VM指令
虽然提供了一些可以设置年轻代、老年代的内存参数,但实际上我们并不推荐对其进行设置
-XX:SurvivorRatio
:设置年轻代中Eden与Survivor区的比例,默认值是8,也就是Eden:Survivor0:Survivor1=8:1:1
,虽然官方文档以及哪里都是这么说的,但实际经过测试发现其比例是6:1:1,如果想要设置为默认值,需要显示的进行设置-XX:NewRatio
:设置年轻代与老年代的比例,默认值是2,也就是说年轻代占1/3,老年代占2/3-Xmn
:设置年轻代的空间的大小,如果与-XX:NewRatio
指令冲突,-Xmn
优先
对象分配过程
先简单介绍一下图中各个模块的名称
- 红色小方块:生命周期已经结束的对象
- 绿色小方块:生命周期还未结束的对象
- 绿色小方块中的数字:年龄计数器
- 蓝色方块:Eden区
- 黄色/肉色方块:幸存者0区/幸存者1区
- 橙色方块:老年代
- YGC/Minor GC ,Promotion:垃圾回收机制,下面暂时统称为垃圾回收
一般的对象分配过程
如下图所示,当Eden区内存满时,会执行垃圾回收机制,将Eden区中的所有对象进行检查,生命周期已经结束的对象被回收,生命周期还未结束的对象存入幸存者0区/或者幸存者1区(具体存入哪个区,需要判断哪个区是空的,存入空的幸存者区)。存入幸存者区的对象会被赋上年龄计数器,没变更一次位置,年龄计数器就是加一。
需要注意的是,执行YGC/Minor GC的判断条件是Eden区已满,执行对象时Eden区+S0区+S1区。
如下图所示,接下来Eden区又满了,这时候因为S1区是空的,所以生命周期未结束的对象存入S1区,S0区中的生命周期未结束的对象通过复制的方式复制到S1区,并且年龄计数器加一。
如下图所示,Eden区到幸存者区我们就不再介绍了,这里重点介绍一下幸存者区到老年代的过程。
当幸存者区中的对象的年龄计数器已经达到了15(这个是可以设置的,默认是15)并且生命周期还未结束的时候,将对象从幸存者区复制到老年代中。
对象分配过程的特殊情况
上面描述得一般情况只说了所有区域能放的下的情况,而过程中往往可能出现幸存者区放不下或者老年代也放不下该对象的情况,通过下面的流程图,我们可以知道当遇到特殊情况时如何进行处理。
代码演示
//-Xms200m -Xmx200m
public class HeapInstanceTest {
byte[] buffer = new byte[new Random().nextInt(1024*200)];
public static void main(String[] args) {
ArrayList<HeapInstanceTest> list = new ArrayList<>();
while (true){
list.add(new HeapInstanceTest());
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
通过下图我们可以验证上述的分析过程,Eden区满时会存入幸存者区,都满时会存入老年代,当老年代满时,会报出OOM内存溢出异常。
Minor GC、Major GC、Full GC的区别
JVM在进行GC时,并非每次都对上面三个内存区域(年轻代、老年代、方法区)一起回收的,大部分时候回收的都是指年轻代。
针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分手机(Partial GC),一种是整堆收集(Full GC)
- 部分收集:不是完整收集整个Java堆的垃圾收集,其中又分为
- 年轻代收集(Minor GC / Young GC):对于年轻代的垃圾回收
- 老年代收集(Major GC / Old GC):对于老年代的垃圾回收
- 目前只有CMS GC会又单独收集老年代的行为
- 很多时候Major GC和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收
- 混合收集(Mixed GC):负责整个年轻代以及部分老年代的垃圾回收
- 目前只有G1 GC会有这种行为
- 整堆收集(Full GC):负责整个Java堆和方法区的垃圾回收
Minor GC的触发机制
- 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden区满,Survivor区满不会触发GC,但是每次Minor GC会清理年轻代的内存(包括Eden、Survivor0、Survivor1)
- 因为Java对象大多都是具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
- Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才会恢复运行
Major GC的触发机制
- 当对象从老年代消失时,我们说Major GC或Full GC发生了
- 出现了Major GC,经常会伴随至少一次的Minor GC(但并非绝对的)
- 也就是说在老年代空间不足时,会先尝试触发Minor GC,如果空间还是不足,再触发Major GC
- Major GC的速度一般会比Minor GC慢10倍以上,并且STW的时间更长
- 如果Major GC之后,内存还是不足,就报OOM内存溢出异常了
Full GC触发机制
说明:Full GC时开发或调优中尽量避免的,这样STW的暂停时间会短一些
- 调用System.GC()时,系统建议执行Full GC,但不是必然执行
- 老年代空间不足
- 方法区空间不足
堆空间的分代思想
JVM开发者将堆空间分成年轻代、老年代以及方法区,如果不进行分代,能否实现堆空间?
答案是可以的,但是我们进行分代就好像垃圾分类一样,都是为了提高效率,对于程序来讲就是优化性能。
如果所有对象都存放在一个空间中,对于Minor GC操作时,需要遍历更多的对象,也就增加了SWT的时间。因为这其中有包含一些生命周期未到的对象,而将这些对象都存放到其他区中,那么遍历的对象数量就会减少,从而提高了性能。
内存分配策略
针对不同年龄段的对象分配原则如下所示:
-
优先分配到Eden
-
大对象直接分配到老年代
- 注意:尽量避免程序中出现过多的大对象,尤其是这些大对象生命周期还非常的短!!!
-
长期存活的对象分配到老年代
-
动态对象年龄判断
- 如果幸存者区中相同年龄的所有对象大小的总和大于幸存者区内存空间的一般,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
-
空间分配担保
-
在某些极端情况下,Eden区的需要转到幸存者区的对象幸存者区容纳不下,那么多余的对象会存放到老年代中
-
-XX:HandlePromotionFailure
-
验证大对象直接分配到老年代
如下图所示,对于当前设置来说bytes数组占20M,是一个大对象,他直接存储到了老年代中。
对象分配过程:TLAB(Thread Local Allocation Buffer)
为什么会有TLAB?
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆中划分内存空间是线程不安全的
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
什么是TLAB?
- 从内存模型而不是垃圾回收的角度,对Eden区域继续进行划分,JVM未每个线程分配了一个私有缓存区域,她包含在Eden空间内
- 多线程同事分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将内存分配方式称为快速分配策略
TLAB说明
- 尽管不是所有的对象实例都能够在TLAB中分配内存,但JVM确实是将TLAB作为内存分配的首选
- 在程序中,开发人员可以通过选项
-XX:UseTLAB
设置是否开启TLAB空间(默认是开启的) - 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,可以通过选项
-XX:TLABWasteTargetPercent
设置TLAB占用Eden空间的百分比大小 - 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存
堆空间的常用参数
-XX:PrintFlagsInital
:查看所有参数的默认初始值-XX:PringFlagsFinal
:查看所有参数的最终值(可能存在修改,不再是初始值)- 通过命令行查看某个线程某个参数的指令
jinfo -flag 参数 进程ID
- 通过命令行查看某个线程某个参数的指令
-Xms
:初始堆空间内存(默认为物理内存的1/64)-Xmx
:最大堆空间内存(默认为物理内存的1/4)-Xmn
:设置新生代的大小(初始值及最大值)-XX:NewRatio
:配置新生代与老年代在堆结构的占比-XX:SurvivorRatio
:设置Eden区和幸存者区空间的比例-XX:MaxTenuringThreshold
:设置新生代垃圾的最大年龄-XX:+PrintGCDetails
:输出详细的GC处理日志-XX:HandlePromotionFailure
:是否设置空间分配担保
空间分配担保
JDK7之前
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
- 如果大于,则此次Minor GC是安全的
- 如果小于,则虚拟机会查看
-XX:HandlePromotionFailure
设置值是否允许担保失败- 如果
HandlePromotionFailure=true
,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小- 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的
- 如果小于,则改为进行一次Full GC
- 如果
HandlePromotionFailure=false
,则改为进行一次Full GC
- 如果
JDK7及以后
HandlePromotionFailure
参数不会再影响虚拟机的空间分配担保策略,虽然在源码中还定义了HandlePromotionFailure
参数,但是在代码中不会再使用它。JDK7及以后的规则变为了:只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否认进行Full GC
逃逸分析
概述
编程语言的编译优化原理中,分析指针动态范围的方法称之为逃逸分析。可以有效减少Java程序中同步负载和内存堆分配压力的跨还书全局数据流分析算法。也就是说可以通过逃逸分析判断对象的引用和使用范围从而决定是否要将这个对象分配到堆上面。
没有发生逃逸的对象,可以被分配到栈上,随着方法执行的结束,栈空间就被移除。
代码分析
快速判断程序有没有发生逃逸,就是观察new的对象实体,是否有可能在方法外部被调用,如果在外部被调用,则发生逃逸,反之亦然。
//new的对象实体,是否有可能在方法外部被调用
public class EscapeAnalysis {
public EscapeAnalysis obj;
//方法返回EscapeAnalysis对象,发生了逃逸
public EscapeAnalysis getInstance(){
return obj == null ? new EscapeAnalysis() : obj;
}
//为成员变量复制,发生了逃逸
public void setObj(){
this.obj = new EscapeAnalysis();
}
//仅在方法内部使用,未发生逃逸
public void useEscapeAnalysis(){
EscapeAnalysis escapeAnalysis = new EscapeAnalysis();
}
//调用成员变量,发生逃逸
public void useInstance(){
EscapeAnalysis instance = getInstance();
}
}
代码优化——栈上分配
JIT编译器在变异期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。
JDK7及以后是默认开启逃逸分析的
/**
* -Xms256m -Xmx256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
* XX:-DoEscapeAnalysis:关闭逃逸分析(默认是开启的)
*/
public class StackAllocation {
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("执行时间:" + (end - start));
Thread.sleep(1000000);
}
private static void alloc(){
User user = new User();
}
static class User{
}
}
在不开启逃逸分析的情况下,运行以上代码会发现他执行了GC,并且执行时间为50毫秒
[GC (Allocation Failure) [PSYoungGen: 65536K->672K(76288K)] 65536K->672K(251392K), 0.0020210 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 66208K->576K(76288K)] 66208K->576K(251392K), 0.0008499 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
执行时间:50
开启逃逸分析后,因为我们创建user对象的方法未发生逃逸的情况,所以会讲对象存储在栈空间,随着方法的结束而消亡,所以并未出现GC的情况,并且执行的速度也快了将近10倍
执行时间:6ms
代码优化——同步省略
如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
在动态变异同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的所对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能够大大提高并发性和性能。这个取消同步的过程就叫做同步省略,也叫做锁消除
如以下代码
public void f(){
Object h = new Object();
synchronized(h){
System.out.println(h);
}
}
其实上面代码并不是一个规范的代码,实际上如果两个线程同时调用这个方法是,锁的要求是必须使用同一对象,这里我们暂时不纠结。主要是h对象的生命周期只在f方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉,优化成:
public void f(){
Object h = new Object();
System.out.println(h);
}
代码优化之标量替换
标量指的是一个无法再分解成更小的数据的数据,Java中原始数据类型就是标量。相对的那些还可以分解的数据叫做聚合量,Java中的对象就是聚合量,因为他可以分解成其他的聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么近过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替,这个过程就是标量替换。
拆解的好处就在于,可以不使用连续的空间来存储该对象
执行以下代码
/**
* -Xms100m -Xmx100m -XX:-EliminateAllocations -XX:+PrintGCDetails
* -XX:-EliminateAllocations:不开启标量替换
*/
public class ScalarReplace {
public static class User{
public String name;
public int id;
}
public static void alloc(){
User user = new User();
user.id = 1;
user.name = "tom";
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("执行时间:" + (end - start) + "ms");
}
}
当我们通过-XX:-EliminateAllocations
关闭标量替换时,执行结果如下,发生了多次GC,并且执行时间46毫秒
当我们开启标量替换时,他会讲User对象分解成int和string的标量,从而不需要在堆空间中创建User对象,也不会发生GC的情况,并且时间也大大的缩短
总结
- 年轻代时对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命
- 老年代中放置长生命周期的对象,通常都是从Survivor区域筛选拷贝过来的Java对象。当然,也有特殊情况,我们知道普通的对象会被分配在TLAB上;如果对象较大,JVM会试图直接分配在Eden其他位置上;如果对象太大,完全无法在新生代中找到足够长的连续空闲空间,JVM会直接分配到老年代
- 当GC只发生在年轻代中,回收年轻对象的GC行为被称为Minor GC;当GC发生在老年代时则被称为Major GC或者Full GC。一般的,Minor GC发生的频率比Major GC高很多,即老年代中垃圾回收发生的频率低于年轻代
方法区
栈、堆、方法区之间的关联
举一个较为普遍的例子
User user = new User();
/**
* User 操作在方法区
* user 操作在虚拟机栈
* new User() 操作在堆中,并且堆中有一个到对象数据类型的指针,执行方法区中的 User
*/
概述
方法区在哪里?
《Java虚拟机规范》中明确说明,“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩”。但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。
所以,方法区看作是一块独立于Java堆的内存空间
如下图所示,我们设置堆的大小为100m,我们发现Eden区+Survivor区+old区已经占满了100m,由此可以证明方法区是堆外的一块空间。
方法区的基本理解
- 方法区与Java堆一样,是各个线程共享的内存区域
- 方法区在JVM启动的时候被创建,并且它的实际物理内存空间和Java堆区一样都可以是不连续的
- 方法区的大小,跟堆空间一样,可以选择固定大小或者课扩展
- 方法去的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出的错误OOM
- 加载大量的第三方jar包
- tomcat部署的工程过多
- 大量动态的生成反射类
- 关闭JVM就会释放这个区域的内存
HotSpot中方法区的演进
- 在JDK8以前,方法区采用的永久代的概念,但是方法区不等同于永久代,方法区类似于一个接口,而永久代实现了该接口,并且接下来的元空间也实现了该接口。
- 永久代会导致Java程序更容易出现OOM异常
- 到了JDK8及以后,终于完全废弃了永久代的概念,改用元空间来代替
- 元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间与永久代最大的区别在于:元空间不再虚拟机设置的内存中,而是直接使用本地内存
- 永久代、元空间二者并不只是名字变了,内部结构也调整了
设置方法区内存的大小
方法区的大小不鄙视固定的,JVM可以根据应用的需要动态调整。
JDK8以前
- 通过
-XX:PermSize
来设置永久代初始分配空间,默认值是20.75M - 通过
-XX:MaxPermSize
来设置永久代最大可分配空间。32位机器默认是64M,64位机器默认是82M - 当JVM加载的类信息容量超过了这个值,会报异常OutOfMemoryError:PermGen space
JDK8及以后
- 元空间大小可以使用参数
-XX:MetaspaceSize
和-XX:MaxMatespaceSize
指定,代替上述原有的两个参数 - 默认值依赖于平台。windows下,
-XX:MetaspaceSize
是21M,-XX:MaxMatespaceSize
的默认值是-1,即没有限制 - 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元空间发生溢出,虚拟机一样会抛出异常OutOfMemoryError:PermGen:Metaspace
-XX:MetaspaceSize
是指初始的元空间大小,对于一个64位的服务器端JVM来说,其默认值是21M。这就是初始的高水位线,一旦触及这个水位线,Full GC会被触发并且卸载没用的类(即这些类对应的类加载器不再存货),然后这个高水位线将会被充值。新的高水位线的值取决于GC之后释放了多少元空间,如果释放的空间不足,那么在不超过MaxMatespaceSize时,适当提高该值,如果释放空间过多,则适当降低该值。- 如果初始化的高水位线设置过低,上述的调整情况会触发很多次,为了避免频繁的GC,建议将
-XX:MetaspaceSize
设置为一个相对较高的值
OOM异常举例
因为小黄的电脑只安装了JDK8,所以永久代的案例就不演示了。
通过以下代码,可以观察到OOM异常,不设置方法区内存的情况下,会正常执行完毕
设置了-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
的情况下,会抛出OOM异常
public class OOMTest extends ClassLoader{
public static void main(String[] args) {
int j = 0;
try {
OOMTest oomTest = new OOMTest();
for (int i = 0; i < 10000; i++) {
//创建ClassWriter对象,用于生成类的二进制字节码
ClassWriter classWriter = new ClassWriter(0);
//指明版本号、修饰符、类名、包名、父类、接口
classWriter.visit(Opcodes.V1_8,Opcodes.ACC_PUBLIC,"Class" + i,null,"java/lang/Object",null);
//返回byte[]
byte[] bytes = classWriter.toByteArray();
//类的加载
oomTest.defineClass("Class" + i,bytes,0,bytes.length);
j++;
}
} finally {
System.out.println(j);
}
}
}
方法区的内部结构
《深入理解Java虚拟机》书中对方法区存储内容描述如下:它用于存储一杯虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
以下的代码进行反编译,来逐步分析
public class MethodInnerStrucTest implements Comparable<String>, Serializable {
//属性
public int num = 10;
public static final int id = 2;
private static String str = "测试方法的内部结构";
//构造器
//方法
public void test1(){
int count = 20;
System.out.println("count = " + count);
}
public static int test2(int cal){
int result = 0;
try {
int value = 30;
result = value / cal;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
@Override
public int compareTo(String o) {
return 0;
}
}
类型信息
对每个加载的类型(类Class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或者java.lang.Object,都没有父类)
- 这个类型的修饰符
- 这个类型直接接口的一个有序列表
域信息
Java中的域也就是平常所说的字段或者属性
- JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
- 域的相关信息包括:域名称、域类型、域修饰符
方法信息
JVM必须保存所有方法的一下信息,同域信息一样包括声明顺序
- 方法名称
- 方法的返回类型
- 方法参数的数量和类型(按顺序)
- 方法的修饰符
- 方法的字节码、操作数栈、局部变量表及大小(abstract和native方法除外)
- 异常表(abstract和native方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
运行时常量池
在方法区内部还有一块非常重要的区域叫做运行时常量池,字节码文件中的常量池通过类加载器加载,就变成了方法区中的运行时常量池。所以在学习运行时常量池之前,需要先了解一下常量池。
字节码文件中的常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表,包括各种字面量和对类型、域、方法的符号引用。
为什么需要常量池
就好比炒菜,旁边放了很多调料,在制作番茄炒蛋时需要用到盐,在制作红烧肉时需要用到料酒、盐等相关调料。两道菜都用到了盐,但肯定用的是同一包盐,不可能说炒一道菜换一包盐。
对于Java中的类来说,常量池就相当于调料的区域,Object类是所有的类的父类,创建一个类时,他会指向常量池中的Object类
运行时常量池介绍
- 运行时常量池是方法区的一部分。
- 常量池表是class文件的一部分,用于存放编译器生成的各种字面量域符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
- 在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
- JVM为每个已加载的类或接口都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的
- 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
- 运行时常量池相对于Class文件常量池的另一重要特征是:具备动态性
- 运行时常量池类似于传统编程语言中的符号表,但是他所包含的数据却比符号表更加丰富一些
- 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存超过了方法区所能提供的最大值,JVM会抛出OOM异常
方法区的演进细节
首先明确,之后HotSpot虚拟机才有永久代。JRockit、J9等来说是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节 ,不受《Java虚拟机规范》管束,并不要求统一。
HotSpot中方法区的变化
JDK1.6及以前 | 有永久代,静态变量存放在永久代上 |
---|---|
JDK1.7 | 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中 |
JDK1.8及以后 | 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间中,但字符串常量池、静态变量仍在堆中 |
为什么将StringTable放入堆空间?
JDK7中将StringTable放到了堆空间。因为永久代的回收效率很低,在Full GC的时候才会触发,而Full GC是在老年代空间不足、永久代不足时才会触发。这导致了StringTable的回收效率不高。而我们开发过程中会有大量的字符串被创建,回收效率低,会导致永久代内存不足。放到堆里,能及时回收内存。
静态变量存在哪里?
所有的对象和数组的实体都存在堆中。而变量本身(用于存放基本数据类型或引用地址)存在哪里有以下几种情况:
-
局部变量,放在栈帧中的局部变量表
void foo(){ //user:变量本身 //new User():对象实体 User user = new User(); }
-
实例成员变量,放在堆中的对象中
User user = new User();
-
静态成员变量,JDK6及一千放在方法区中,JDK7开始放在堆中Class对象中
static User user = new User();
方法区的垃圾回收
《Java虚拟机规范》堆方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区去中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在。
一般来说方法区的回收效果比较难令人满意,尤其是类型的写在,条件相当苛刻,但这部分区域的回收有时又确实是有必要的。
方法区的垃圾回收主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
HotSpot虚拟机对于常量池的回收策略,只要常量池中的常量没有被任何地方引用,就可以被回收。
对于不再使用的类型的回收时相当复杂的:
- 需要同事满足以下三个条件:
- 该类所有的实例已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,否则通常是很难达成的
- 该类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
- Java虚拟机被允许堆满足上述桑格条件的无用类进行回收,但只是”被允许“,而并不是和对象一样,没有引用了就必然会被回收
- 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载能力,以保证不会对方法区造成过大的内存压力
运行时数据区面试题
以上就学完了JVM中整个运行时数据区的所有内容,接下来来看一些面试题,巩固知识。
Q1:说一下JVM内存模型,有哪些区?分别是干什么的?
首先跟我们的文章一样,运行时数据区分为线程共享的和线程独立的,其中PC寄存器、虚拟机栈、本地方法栈属于线程独立的,堆、方法区属于线程共享的。
- PC寄存器会存储当前线程正在执行的Java方法的JVM指令地址;如果是在执行native方法,则是未指定值(undefined),PC寄存器不会出现OOM异常也不需要进行垃圾回收。
- 虚拟机栈会存储当前线程正在执行的方法,每个方法会以栈帧的形式保存,栈帧中又包含局部变量表、操作数栈、动态链接以及方法返回地址。
- 局部变量表是一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括基本数据类型、对象引用以及returnAddress类型。
- 操作数栈在方法执行的过程中,根据字节码指令,往栈中写入数据或者读取数据。
- 动态链接用于指向运行时常量池中方法的引用
- 方法返回地址用于存放调用该方法的PC寄存器的值
- 本地方法栈和虚拟机栈类似,只不过是用来执行本地方法的
- 堆,Java中所有new的对象都存储在堆中,堆又分为新生代、老年代,新生代又分为Eden区以及两个Survivor区,通常情况下,新创建的对象会先被分配到Eden区,当Eden区满的时候会执行Minor GC对Eden区以及Survivor区进行垃圾回收,如果Eden区中还有对象存活会被复制到Survivor区中,并赋值一个年龄计数器,当对象的年龄计数器达到指定值时(默认是15)还存活,这个对象将被存放到老年代中
- 方法区,主要用来存放类型信息,在JDK7及以前HotSpot虚拟机使用永久代来实现方法区,永久代使用的是虚拟机设置的内存,比较容易较为频繁的出现垃圾回收,而对方法区的垃圾回收是非常复杂的,SWT的时间非常长。再JDK8及以后,HotSpot虚拟机舍弃了永久代,使用元空间的概念实现方法区,与永久代最大的不同在于元空间直接使用的是本地内存。
Q2:栈和堆的区别?堆的结构?为什么有两个Survivor区?
-
栈和堆的区别
首先栈是线程独立的,堆是多个线程共享的;栈存储方法的信息,堆存储对象的实例;栈不会进行垃圾回收,堆需要垃圾回收
-
堆的结构:Q1已经讲过
-
为什么有两个Survivor区?
提高GC效率,这里需要等学到垃圾回收的复制算法才能解答
Q3:JVM内存分区,为什么要有新生代和老年代?
其实不分区也可以实现堆应有的方法,分成新生代老年代是为了提高GC的效率
在不分区的情况下,进行垃圾回收的时候需要遍历堆中的所有对象,而有些对象是一直存活的,也就是说会出现无效遍历的情况
在分区的情况下,可以把生命周期较长的对象存放到老年代,这样遍历时就明显的可以减少次数
Q4:新生代中为什么要分为Eden区和Survivor区?
如果没有Survivor区,那么Eden区每次执行Minor GC都会将存活的对象送到老年代中,老年代的空间很快就会被填满,老年代进行一次Full GC消耗的时间比 Minor GC长得多,所以需要分为Eden和Survivor。
Q5:JVM的永久代会发生垃圾回收吗?
在《Java虚拟机规范中》并没有对永久代进行明确的规定。在HotSpot虚拟机中会发生垃圾回收,主要回收的是常量池中废弃的常量和不再使用的类型。