
对于开发人员来说,如果不了解Java的JVM,那真的是很难写得一手好代码,很难查得一手好bug,同时,JVM也是面试环节重点考察点,下面我们来了解一下JVM内部结构!
jvm内存关系如图所示:

上边的图会结合Math类进行分析,Math如下所示
//Math类
public class Math {
public static final int initData = 666;
public static User user = new User();
public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.compute();
}
}
1.1 Math中的main方法执行流程
①:首先Math.class由应用类加载器加载到jvm内存中。
②:在线程执行Math中的main方法时,会在jvm整个栈内存中会给当前线程分配一块内存作为当前的线程栈 空间。
③:main()方法只要一开始运行,会在当前线程的栈空间内部压入main()方法的栈帧,栈帧内用来存放main方法的局部变量,比如 math,在main方法的栈帧中存储的是Math对象的对象头地址,用来指向堆空间中Math对象的位置。
④:然后开始执行compute()方法,会在当前线程的栈空间内,压入一个compute()的栈帧(每一个方法对应一个栈帧),栈帧内部包含局部变量表、操作数栈、动态链接、方法出口等。
⑤:等compute()方法执行完毕,释放栈空间,继续执行main()方法
结论:
- 线程栈中每一个方法对应一个栈帧,保证每个方法内部的局部变量相互隔离。
- 栈帧的执行遵循先进后出原则,即先压入的main方法后执行,后压入的compute方法先执行。
1.2 线程栈内部结构解析
局部变量表:存储局部变量,如a=1,b=2,或者对象。
操作数栈:也是一个栈,用来临时存放局部变量表中的局部变量的结果值,例如执行compute方法时,先把a的值放入局部变量表,再把a的值 1 放入操作数栈,然后再把操作数栈的值1,赋给局部变量表中的a,包括 (a + b) * 10的计算也在操作数栈中进行。
动态链接:把符号引用转换为直接引用,比如执行到compute()方法时,会把这个方法的符号转换为方法区的地址。
方法出口:方法执行完毕后,回到哪一行,哪个方法的哪个位置,是方法出口来记录的。
1.3 什么是程序计数器,为什么每个线程都要包含程序计数器?
①:程序计数器是记录当前线程的执行字节码的位置。
②:字节码执行引擎在执行方法区中的字节码信息时,会记录当前执行的行号,每当线程往下执行一步,就会改变程序计数器的行号。
③:在多线程模式下,如果当前线程的cpu时间片被别的线程抢去,等他再抢回cpu时,需要记录上一步执行的位置,否则要从头开始执行,而程序计数器就提供了当前执行的位置信息,所以每个线程都有自己的程序计数器。
1.4 本地方法栈
因为java的底层是C++,如果线程在执行过程中调用了很底层的C++方法(比如Thread.start()为native修饰),那么当前线程应包含本地方法栈。本地方法栈即调用C++程序使用的。
2. JVM内部的对象创建流程(new 对象流程)

对象创建流程拆解如下:
2.1 类加载检查
在jvm遇到一条new 指令时,首先检查能否在常量池中定位到这个类的符号引用,并检查这个类是否被加载、解析初始化过(因为jvm是按需加载类的)。如果没有,那必须执行对应的类加载过程。
2.2 分配内存
在通过jvm类加载检查后,jvm会给这个对象分配内存。在堆内存中划分一块给这个对象。下边的第三标题有详解!
内存分配存在以下两个问题
①:jvm是如何划分内存的?1.指针碰撞 (Bump the Pointer)
如果堆内存是规整的,也就是说所有用过的内存都放在左边,空闲的内存放在右边,中间放着一个指针作为分界点。在内存分配时,只是把指针向右边位移一段与对象大小相等的距离被称为指针碰撞。
2.空闲列表 (Free List)
如果堆内存不是规整的,也就是说使用过的内存和未使用的相互交错,JVM就会维护一个列表,列表上记录了所有未使用的内存,然后找一块和对象大小一致的内存,把这块内存划分给对象,并更新列表上的记录。
②:如何解决高并发情况下,内存分配问题?
即正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的 情况。
1. CAS
采用CAS + 失败重试的方式保证分配内存这个操作的原子性,同步处理。
2. 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
把内存分配的动作按照每个线程划分在不同空间内进行。即每个线程在Java堆中预先分配一小块内存,在自己的内存中分配对象内存
2.3 初始化 (赋0)
内存分配完成后,需要给内存中所有的东西都初始化为零值。这一步保证了java代码中对象的实例字段不被赋初始值就直接使用,不过程序访问到的知识这个字段对应数据类型的零值,如 0 ,null,false等。
2.4 设置对象头
初始化0值之后,jvm要设置对象的对象头,例如这个对象属于哪个实例,对象的hash码,GC年龄等信息。
首先要知道对象在内存中分为3部分:
1.对象头 2.实例数据 3.对齐填充

①:对象头包含两部分信息
①:Mark Word标记字段。用于存储对象自身的运行时数据, 如哈 希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等。默认是32位,超出32位的会使用指针压缩压进32位以内!
②:Klass Pointer类型指针。即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
②:实例数据: 存放类的属性数据信息,包括父类的属性信息;③:对齐填充: 由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
注意:整个对象(对象头、实例数据、对象填充)在内存中是存在指针压缩的!
问题①:什么是java对象的指针压缩?有什么作用?
在64位平台的HotSpot中使用32位指针,32位地址最大支持4G内存(2³²)。当堆内存大于4G,小于32G时,jvm会启用指针压缩,把高于32位地址(也就是2³²约等于4G内存)的地址压缩进32位以内。可以减少64位平台下的内存消耗,所以jdk1.8默认开启指针压缩。
作用:通过指针压缩使对象变小,进而减少堆内存消耗,避免频繁GC
启用指针压缩:XX:+UseCompressedOops(默认开启)
禁止指针压缩:XX:UseCompressedOops
注意:
①:堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址
②:堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,所以堆内 存不要大于32G为好
2.5 执行init方法
执行init方法与上边的初始化 (赋0)不同,对应到语言层面上,就是为属性赋上代码里指定的值 。比如 a = 6 ,这里指的是把a赋值为6
3. 对象内存分配流程
第二段说到对象加载后,jvm会给这个对象分配内存。那具体的分配流程如下(不一定全在堆上分配)

3.1 为什么有的对象会分配在栈中?
当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。
为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的 内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
3.2 对象逃逸分析和标量替换对象逃逸分析:就是分析对象的作用域,看这个对象是否被外部方法所引用。如下图所示,JDK7之后默认开启逃逸分析。

标量替换:通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,如果栈有足够连续的空间可以存储这个对象,那就直接存储(因为存储对象需要连续空间)。如果没有足够大的连续空间,那就会把这个对象分解为标量,存储在栈的碎片空间中。
标量和聚合量的区别?
聚合量即完整的对象,标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及 reference类型等)
3.3 栈上分配、逃逸分析案例
设置jvm参数
‐Xmx15m ‐Xms15m:堆内存15M
‐XX:+DoEscapeAnalysis:是否开启逃逸分析
‐XX:+EliminateAllocations:是否开启标量替换
‐XX:+PrintGC:打印GC日志
创建一亿次对象, 通过开启和关闭逃逸分析来感受jvm的GC情况
public class AllotOnStack {
public static void main(String[] args) {
long start = System.currentTimeMillis();
//代码调用了一亿次alloc(),如果是分配到堆上,大概需要1GB以上堆空间。
//如果堆空间小于该值,必然会触发GC。
for (int i = 0; i < 100000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
private static void alloc() {
User user = new User();
user.setId(1);
user.setName("zhuge");
}
}
使用如下参数不会发生GC(开启逃逸分析和标量替换)
-Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC XX:+EliminateAllocations
结果:

使用如下参数都会发生大量GC
①:-Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
②:-Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
结果:

4. 堆内存对象流转和GC
通过对象内存分配流程图可知,大对象和逃逸出的对象会在堆中分配内存,

4.1 Minor GC和Full GC 有什么不同?Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。Major GC/Full GC:一般会回收老年代 ,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢 10倍以上。
4.2 哪种对象会进入老年代?(发生频繁GC优先检查以下4点!)①:大对象直接进入老年代
何为大对象,如果没有通过-XX:PretenureSizeThreshold设置大对象的值,则大小超过Eden区大小的对象,会直接放入老年代。如果通过-XX:PretenureSizeThreshold设置大对象的值为30M ,则超过30M的对象会直接进入老年代。通过设置这个值的大小,可以避免为大对象分配内存时的复制操作而降低效率
②:长期存活的对象将进入老年代
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度 (默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代 的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。③:对象的动态年龄判断④:老年代空间分配担保机制
4.3 什么是对象的动态年龄判断?
动态年龄判断机制一般是在minor gc之后才被触发的!!
在minor gc之后,如果Survivor区中的这批对象总大小大于Survivor区域域内存大小的50%,那么>=这批年龄最大值的对象,都会被放入老年代。
例如:Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会 把年龄n(含)以上的对象都放入老年代。
这个动态年龄判断的机制作用其实是希望那些可能是长期存活的对象,尽早进入老年代,避免多次复制操作而降低效率。
4.4 什么是老年代空间分配担保机制?
①:在每一次minor gc之前JVM都会计算下老年代剩余可用空间,看这个空间是否小于年轻代里所有对象大小之和。
②:如果不小于,则年轻代直接进行minor gc。
③:如果小于,看jvm是否设置了“-XX:-HandlePromotionFailure”担保参数(jdk1.8默认就设置了)。
④:如果没设置,则年轻代直接进行minor GC。
⑤:如果设置了,并且老年代剩余空间 < 每一次minor gc后进入老年代的对象的平均大小,就会触发Full GC.
⑥:如果设置了,并且老年代剩余空间 > 每一次minor gc后进入老年代的对象的平均大小,则认为老年代有足够空间存储,不会进行Full GC ,年轻代进行minor gc。
具体流程如图所示:

4.5 堆内存垃圾回收算法(堆内存哪些对象会被回收)
①:可达性分析算法
将“GC Roots” 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的 对象都是垃圾对象
GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等
②:引用计数法
给对象添加一个引用计数器,当有一个地方引用它,计数器+1,当引用失效,计数器-1,任何时候计数器为0的对象是垃圾对象。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决 对象之间相互循环引用的问题。

常见的引用类型①:强引用:普通的变量引用 User user = new User();②:软引用:创建的对象被包裹在另一个对象的构造器中,正常情况不会被回收,但是GC做完后发现释放不出空间存放 新的对象,则会把这些软引用的对象回收掉。软引用可用来实现内存敏感的高速缓存。
SoftReference<User> user = new SoftReference<User>(new User());
例如:浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从 缓存中取出呢?这就要看具体的实现策略了。
(1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建
(2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出③:弱引用:弱引用跟没引用差不多,GC会直接回收掉,很少用④:虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用
4.6 方法区哪些对象会被回收?
方法区主要回收的是无用的类,条件苛刻,回收内容很少!
类需要同时满足下面3个条件才能算是 “无用的类” :
①:该类的所有实例都被回收,java堆中不存在该类的任何实例
②:加载类的ClassLoader已经被回收(除了自定义类加载器,其他的类加载器基本不会回收!)
③:该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
欢迎大家关注我的专栏:
小青秧的java之路zhuanlan.zhihu.com
里面不定期分享Java架构技术知识点及解析,还会不断更新BATJ面试专题,欢迎大家前来探讨交流,如有好的文章也欢迎投稿。(注意专栏简介的介绍获取最新一线大厂Java面试题总结资料!)
注意专栏简介的介绍获取最新一线大厂Java面试题总结资料!