本专栏学习内容来自尚硅谷宋红康老师的视频以及《深入理解JVM虚拟机》第三版
有兴趣的小伙伴可以点击视频地址观看,也可以点击下载电子书
JVM所有的步骤其实可以理解为厨师炒菜
JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是以来用户线程的启动和结束而建立和销毁。
JVM中的运行时数据区分为以下五块内容

如下图所示,JVM支持多线程,在多线程的情况下,方法区和堆中的对象是共享的,程序计数器、本地方法栈和虚拟机栈是每个线程各拥有一份的。

现代垃圾收集器大部分基于分代收集理论设计,堆空间细分为:
Java7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区
Java8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间
设置程序的堆内存-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中几乎所有的对象都是在Eden区被new出来的,在Eden区的对象会被垃圾回收,没有被垃圾回收的对象会存入Survivor区,然后在经过一系列的GC操作,还没有消亡的对象会转存到老年代中。
虽然提供了一些可以设置年轻代、老年代的内存参数,但实际上我们并不推荐对其进行设置
-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区内存满时,会执行垃圾回收机制,将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内存溢出异常。

JVM在进行GC时,并非每次都对上面三个内存区域(年轻代、老年代、方法区)一起回收的,大部分时候回收的都是指年轻代。
针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分手机(Partial GC),一种是整堆收集(Full GC)
说明:Full GC时开发或调优中尽量避免的,这样STW的暂停时间会短一些
JVM开发者将堆空间分成年轻代、老年代以及方法区,如果不进行分代,能否实现堆空间?
答案是可以的,但是我们进行分代就好像垃圾分类一样,都是为了提高效率,对于程序来讲就是优化性能。
如果所有对象都存放在一个空间中,对于Minor GC操作时,需要遍历更多的对象,也就增加了SWT的时间。因为这其中有包含一些生命周期未到的对象,而将这些对象都存放到其他区中,那么遍历的对象数量就会减少,从而提高了性能。
针对不同年龄段的对象分配原则如下所示:
优先分配到Eden
大对象直接分配到老年代
长期存活的对象分配到老年代
动态对象年龄判断
空间分配担保
在某些极端情况下,Eden区的需要转到幸存者区的对象幸存者区容纳不下,那么多余的对象会存放到老年代中
-XX:HandlePromotionFailure
验证大对象直接分配到老年代
如下图所示,对于当前设置来说bytes数组占20M,是一个大对象,他直接存储到了老年代中。

-XX:UseTLAB设置是否开启TLAB空间(默认是开启的)-XX:TLABWasteTargetPercent设置TLAB占用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之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
-XX:HandlePromotionFailure设置值是否允许担保失败
HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
HandlePromotionFailure=false,则改为进行一次Full GCJDK7及以后
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的情况,并且时间也大大的缩短

举一个较为普遍的例子
User user = new User();
/**
* User 操作在方法区
* user 操作在虚拟机栈
* new User() 操作在堆中,并且堆中有一个到对象数据类型的指针,执行方法区中的 User
*/

《Java虚拟机规范》中明确说明,“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩”。但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。
所以,方法区看作是一块独立于Java堆的内存空间
如下图所示,我们设置堆的大小为100m,我们发现Eden区+Survivor区+old区已经占满了100m,由此可以证明方法区是堆外的一块空间。

方法区的大小不鄙视固定的,JVM可以根据应用的需要动态调整。
-XX:PermSize来设置永久代初始分配空间,默认值是20.75M-XX:MaxPermSize来设置永久代最大可分配空间。32位机器默认是64M,64位机器默认是82M-XX:MetaspaceSize和-XX:MaxMatespaceSize指定,代替上述原有的两个参数-XX:MetaspaceSize是21M,-XX:MaxMatespaceSize的默认值是-1,即没有限制-XX:MetaspaceSize是指初始的元空间大小,对于一个64位的服务器端JVM来说,其默认值是21M。这就是初始的高水位线,一旦触及这个水位线,Full GC会被触发并且卸载没用的类(即这些类对应的类加载器不再存货),然后这个高水位线将会被充值。新的高水位线的值取决于GC之后释放了多少元空间,如果释放的空间不足,那么在不超过MaxMatespaceSize时,适当提高该值,如果释放空间过多,则适当降低该值。-XX:MetaspaceSize设置为一个相对较高的值因为小黄的电脑只安装了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必须在方法区中存储以下类型信息

Java中的域也就是平常所说的字段或者属性

JVM必须保存所有方法的一下信息,同域信息一样包括声明顺序

在方法区内部还有一块非常重要的区域叫做运行时常量池,字节码文件中的常量池通过类加载器加载,就变成了方法区中的运行时常量池。所以在学习运行时常量池之前,需要先了解一下常量池。
字节码文件中的常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表,包括各种字面量和对类型、域、方法的符号引用。
为什么需要常量池
就好比炒菜,旁边放了很多调料,在制作番茄炒蛋时需要用到盐,在制作红烧肉时需要用到料酒、盐等相关调料。两道菜都用到了盐,但肯定用的是同一包盐,不可能说炒一道菜换一包盐。
对于Java中的类来说,常量池就相当于调料的区域,Object类是所有的类的父类,创建一个类时,他会指向常量池中的Object类
运行时常量池介绍
首先明确,之后HotSpot虚拟机才有永久代。JRockit、J9等来说是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节 ,不受《Java虚拟机规范》管束,并不要求统一。
HotSpot中方法区的变化
| JDK1.6及以前 | 有永久代,静态变量存放在永久代上 |
|---|---|
| JDK1.7 | 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中 |
| JDK1.8及以后 | 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间中,但字符串常量池、静态变量仍在堆中 |

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虚拟机对于常量池的回收策略,只要常量池中的常量没有被任何地方引用,就可以被回收。
对于不再使用的类型的回收时相当复杂的:
以上就学完了JVM中整个运行时数据区的所有内容,接下来来看一些面试题,巩固知识。
Q1:说一下JVM内存模型,有哪些区?分别是干什么的?
首先跟我们的文章一样,运行时数据区分为线程共享的和线程独立的,其中PC寄存器、虚拟机栈、本地方法栈属于线程独立的,堆、方法区属于线程共享的。
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虚拟机中会发生垃圾回收,主要回收的是常量池中废弃的常量和不再使用的类型。