从零开始JVM实战高手,建议收藏,加源妹儿微信 “ ymsdsss”领取整套JVM实战资料和精品视频,关注微信公众号 “疯狂Java程序猿” ,后续会推出JVM,Mybatis,SpringBoot,Redis等等一些列从入门到源码剖析的精品视频和文章。你的鼓励是我最大的动力。
配套视频:https://www.bilibili.com/video/BV1nN411F7VT/?spm_id_from=333.337.search-card.all.click
作者:源码时代-Raymon老师
目前市面上已有太多的JVM相关教程和书籍,但是大部分偏理论,比较枯燥难懂,少有结合实际业务开发,站在项目开发的视角下去分析和讲解相关经验的教程;而本套教程会从零开始带着大家一步一步深入了解JVM底层原理,以及结合一些开发中的典型生产环境问题来进行实战剖析,并且几乎采用一步一图的方式进行讲解。
通过核心理论和实战案例的结合,希望能对大家对JVM的理解和应用更上一层楼。
从Java代码经历编译生成对应字节码文件,再经由类加载器加载,经历验证、准备、解析、初始化阶段,整个过程我们称之为类加载阶段,也是我们JVM第一部分重要的开端;接下来我们正式进入主战场JVM内存区域,来看看JVM在不同内存区域之间的巧妙设计。
首先我们来看第一个内存区域:程序计数器,他的作用是记住下一条jvm指令的执行地址,Program Counter Register 程序计数器(PC寄存器);他的特点是线程私有,不会有内存溢出,首先我们来看一段非常简单的代码:
public class Demo1 {
public static void main(String[] args) {
int num1 = 1;
System.out.println(num1);
int num2 = 2;
System.out.println(num2);
}
}
这个代码大家都能看懂,但是JVM能看懂吗?答案是:NO!
JVM是不识别我们写的代码的,我们的java代码会被编译为.class字节码文件,而字节码文件中的代码才是JVM能识别和执行的,这些代码我们也叫【字节码指令】,它对应了一条一条的机器指令,JVM通过将这些指令再解释翻译为机器指令,来操作我们的计算器进行执行。
上述的代码对应的字节码指令如下:
0 iconst_1
1 istore_1
2 getstatic #2 <java/lang/System.out>
5 iload_1
6 invokevirtual #3 <java/io/PrintStream.println>
9 iconst_2
10 istore_2
11 getstatic #2 <java/lang/System.out>
14 iload_2
15 invokevirtual #3 <java/io/PrintStream.println>
18 return
注意:这些字节码指令就是由 字节码执行引擎 来执行的!
那么在执行字节码指令的时候,JVM里就需要一个特殊的内存区域,也就是【程序计数器】用来记录当前执行的字节码指令的位置 ,也就是记录目前执行到了哪一条字节码指令。
当执行一条指令时,首先需要根据PC中存放的指令地址,将指令由内存取到指令寄存器中,此过程称为“取指令”。与此同时,PC中的地址或自动加1或由转移指针给出下一条指令的地址。此后经过分析指令,执行指令。完成第一条指令的执行,而后根据PC取出第二条指令的地址,如此循环,执行每一条指令。
由于java多线程的执行,我们的代码可能会开启多个线程并发执行不同的代码,所以对应着会有多个线程来并发的执行不同的代码指令。
而每个线程底层是根据CPU分配给它的时间片的方式、依次轮流来执行的, 可能A线程执行⼀段时间后就切换为B线程来执行了,B线程执行时间结束后,再切换回A线程执行了, 此时线程A肯定要知道自己上⼀次执行到字节码指令的哪个位置了,才能在上次的位置继续执行下去。
所以:程序计数器就扮演了一个这样的角色,记录每个线程执行字节码指令位置;并且程序计数器每个线程都是私有的,专门为各自线程记录每次执行字节码指令的位置,方便下次线程切换回来时还能找的到上次执行的位置继续执行。
Java Virtual Machine Stacks (Java 虚拟机栈),他是每个线程运行时所需要的内存,称为虚拟机栈,他有如下特点
Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素
Java代码的执行一定是由线程来执行某个方法中的代码,哪怕就是我们的main()方法也是有一个主线程来执行的,在main线程执行main()方法的代码指令的时候,就会通过main线程对应的程序计数器来记录自己执行的指令位置。
main()方法本质上是一个方法,在main()中也可以调用其他的方法,而每个方法中也有自己的局部变量数据,因此JVM提供了一块内存区域用来保存每个方法内的局部变量等数据,这个区域就是Java虚拟机栈。
每个栈帧主要存放:局部变量表,操作数栈,动态连接和方法返回地址等信息。每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
当我们在线程中调用了一个方法,就会对该方法创建一个对应的栈帧,比如我们如下的代码:
public class Demo1 {
public static void main(String[] args) {
int num1 = 1;
System.out.println(num1);
int num2 = 2;
System.out.println(num2);
method1();
}
public static void method1(){
int num3 = 20;
System.out.println("哈哈哈哈");
}
}
并且当method1方法执行完毕后会弹出该栈队列,最后弹出main()方法栈帧,代表整个main方法代码执行完毕。这也对应了栈的特点:先进后出。
一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在 调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与 这个栈帧所关联的方法被称为“当前方法”(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作
栈内存面试案例剖析
栈内存面试案例剖析
栈帧每次执行结束自动弹栈,所以不会涉及到垃圾的产生,也就不会对栈内存进行垃圾回收
栈内存分配越大越好吗?
并不是,假设分配的物理内存是100MB,每个线程栈大小是1MB,那么可以分配100个线程,但是如果提升了线程栈大小,那可以分配的对应线程数就变少了。
我们先来看官网给出的每个虚拟机栈默认的大小分配:
Linux系统上默认就是1MB,当然我们可以通过-Xss进行大小的更改
方法内的局部变量是否线程安全?
//方法内局部变量:线程安全
public static void method1(){
StringBuilder sb = new StringBuilder();
sb.append(1);
sb.append(2);
sb.append(3);
System.out.println(sb);
}
//方法内局部变量引用对象:线程不安全
public static void method2(StringBuilder sb){
sb.append(1);
sb.append(2);
sb.append(3);
System.out.println(sb);
}
//方法内局部变量引用对象提供暴露:线程不安全
public static StringBuilder method3(){
StringBuilder sb = new StringBuilder();
sb.append(1);
sb.append(2);
sb.append(3);
return sb;
}
栈帧过多,没有足够的栈内存空间来存储更多的栈帧,导致内存溢出, 将抛出StackOverflowError异常。 常见的情况就是递归调用,不断产生新的栈帧,前面的栈帧不释放。
我们可以通过以下代码来测试和实验:
/**
* @Description: VM Args: -Xss128k
对于不同版本的Java虚拟机和不同的操作系统, 栈容量最小值可能会有所限制, 这主要取决于操作系统内存分页大小。 譬如上述方法中的参数-Xss128k可以正常用于32位Windows系统下的JDK 6, 但是如果用于64位Windows系统下的JDK 11, 则会提示栈容量最小不能低于180K, 而在Linux下这个值则可能是228K, 如果低于这个最小限制, HotSpot虚拟器启动时会提示:The Java thread stack size specified is too small. Specify at least 228k
*/
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + oom.stackLength);
throw e;
}
}
}
打印结果如下
栈帧过大导致内存溢出, 将抛出StackOverflowError异常。
我们这次可以尝试将每一个栈帧的局部变量给多占用一点空间,这样每个栈帧的大小就会变大,我们还是设定每个线程栈空间为128K,看看以下代码运行后,多少次就会撑满内存:
/**
* @Description: VM Args: -Xss128k
*/
public class JavaVMStackSOF2 {
private static int stackLength = 0;
public static void test() {
long unused1, unused2, unused3, unused4, unused5,
unused6, unused7, unused8, unused9, unused10,
unused11, unused12, unused13, unused14, unused15,
unused16, unused17, unused18, unused19, unused20,
unused21, unused22, unused23, unused24, unused25,
unused26, unused27, unused28, unused29, unused30,
unused31, unused32, unused33, unused34, unused35,
unused36, unused37, unused38, unused39, unused40,
unused41, unused42, unused43, unused44, unused45,
unused46, unused47, unused48, unused49, unused50,
unused51, unused52, unused53, unused54, unused55,
unused56, unused57, unused58, unused59, unused60,
unused61, unused62, unused63, unused64, unused65,
unused66, unused67, unused68, unused69, unused70,
unused71, unused72, unused73, unused74, unused75,
unused76, unused77, unused78, unused79, unused80,
unused81, unused82, unused83, unused84, unused85,
unused86, unused87, unused88, unused89, unused90,
unused91, unused92, unused93, unused94, unused95,
unused96, unused97, unused98, unused99, unused100;
stackLength++;
test();
}
public static void main(String[] args) throws Throwable {
try {
test();
}catch (Error e){
System.out.println("stack length:" + stackLength);
throw e;
}
}
}
最后总结出一个结论:无论是由于栈帧太大还是虚拟机栈容量太小, 当新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常。
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
说明:
为什么要使用本地方法?
Java使用起来很方便,然而Java代码有一定的局限性,有时候不能和系统底层交互,或是追求程序的效率时。这时候就需要更加底层的语言和更快的运行效率。
方便与Java之外的环境交互,如与操作系统或某些硬件交换信息,本地方法为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。
虚拟机本来就是由C++写的,一些操作系统特性JVM没有封装提供出来,那我们就可以自行使用C语言来实现它,并通过本地方法来调用。最求更快的运行效率
这幅图展示了JAVA虚拟机内部线程运行的全景图。一个线程可能在整个生命周期中都执行Java方法,操作它的Java栈;或者它可能毫无障碍地在Java栈和本地方法栈之间跳转。
该线程首先调用了两个Java方法,而第二个Java方法又调用了一个本地方法,这样导致虚拟机使用了一个本地方法栈。假设这是一个C语言栈,其间有两个C函数,第一个C函数被第二个Java方法当做本地方法调用,而这个C函数又调用了第二个C函数。之后第二个C函数又通过本地方法接口回调了一个Java方法(第三个Java方法),最终这个Java方法又调用了一个Java方法(它成为图中的当前方法)。
堆是用来存放通过 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();
int[] arr = new int[3];
}
}
class Hello{
//这个h是一个类变量
private static Hello h = new Hello();
}
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除,也就是触发了GC的时候,才会进行回收。如果堆中对象马上被回收,那么用户线程就会受到影响。堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
堆内存大小配置
Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项"-Xmx"和"-Xms"来进行设置。
一旦堆区中的内存大小超过“-Xmx"所指定的最大内存时,将会抛出OutOfMemoryError异常。
通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。默认情况下,
可以通过如下代码进行查看:
/**
* -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
* -X:是jvm运行参数
* ms:memory start
* -Xmx:用来设置堆空间(年轻代+老年代)的最大内存大小
*/
public class Demo2 {
public static void main(String[] args) {
// 返回Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
// 返回Java虚拟机试图使用的最大堆内存
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms:" + initialMemory + "M");
System.out.println("-Xmx:" + maxMemory + "M");
}
}
堆内存分代模型-新生代和老年代
存储在JVM中的Java对象可以被划分为两类:
一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速,生命周期短的,及时回收即可。
另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。
比如Class字节码对象,只有满足三个条件的情况下,才会被GC也就是卸载:
Java堆区进一步细分的话,可以划分为年轻代/新生代(YoungGen)和老年代(oldGen),其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)。
默认情况下:-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"设置新生代最大内存大小。这个参数一般使用默认值就可以了。
图解对象分配机制
为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
因此整个堆内存的对象分配核心规则如下:
啥时候能去养老区呢?可以设置次数,默认是15次。可以通过设置参数:-XX:MaxTenuringThreshold= N 进行最大年龄的设置。
注意:在Eden区满了的时候,才会触发MinorGC,而幸存者区满了后,不会触发MinorGC操作。 如果Survivor区满了后,将会触发一些特殊的规则,也就是可能直接晋升老年代,具体分析请往下看。
对象分配流程案例实战
我们来一起看下如下的代码,对他们在内存中的分配做一个剖析:
public class Test {
private static User user = new User();
public static void main(String[] args) throws InterruptedException {
user.login();
for (int i = 0; i < 100; i++) {
doSomething();
Thread.sleep(200);
}
}
public static void doSomething(){
Student stu = new Student();
stu.study();
}
}
class User{
public void login(){
System.out.println("登录");
};
}
class Student{
public void study(){
System.out.println("I'm studying");
};
}
Test类中静态成员变量 user 是长期存活的,并且分配在新生代中。
main方法中通过for循环调用了100次 doSomething() 方法,方法中会创建Student()对象。
我们先将时间定格在执行完第一次后,内存中的分配情况是:
以上仅仅是执行第一次doSomething() 方法后的情况,如果执行10次后的情况会发生什么样的变化呢?
首先大家要明确,我们的doSomething() 方法执行完后对应的栈帧肯定会弹栈,那么对应栈帧的局部变量也相应被释放回收,我们堆内存中的实例对象就会变成无引用的垃圾对象了:
当最后一次 doSomething() 方法执行完后 对应栈帧弹栈,那么堆内存中新生代里面的Student实例对象就存在了有10个对象没有地址引用,后续如果再继续产生一些垃圾对象,当新生代中的内容空间已无法分配空间的时候,就会进行“ Minor GC ”,将对应新生代的垃圾对象进行回收:
回收后的内存就仅剩User这个对象了,并且User对象会进入幸存者区:
当然如果我们的程序在经历了15次“ Minor GC”后还没有被回收的对象就会被放入我们的老年代进行管理;比如我们的User实例对象,因为一直被Test类静态变量引用,所以它不会被回收。
当然如果老年代里面的空间也存满了后,也会触发垃圾回收叫FullGC(Stop the world),把老年代中没用的垃圾对象进行清理。
方法区主要存放的是『class』,而堆中主要存放的是『实例化的对象』
HotSpot中方法区的演进
在jdk7及以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。
本质上,方法区和永久代并不等价。仅是对hotspot而言的。《Java虚拟机规范》对如何实现方法区,不做统一要求。例如:BEAJRockit / IBM J9 中不存在永久代的概念。
现在来看,当年使用永久代,不是好的idea。导致Java程序更容易oom(超过-XX:MaxPermsize上限)
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。
永久代、元空间二者并不只是名字变了,内部结构也调整了。根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常。
《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。方法区,内部包含了运行时常量池
常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外,还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用
常量池、可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量 等类型。
常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,通过索引访问。
运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛outofMemoryError异常。
方法区的演进细节
首先明确:只有Hotspot才有永久代。BEA JRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。
Hotspot中方法区的变化:
版本 | 内容 |
---|---|
JDK1.6及以前 | 有永久代,静态变量存储在永久代上 |
JDK1.7 | 有永久代,但已经逐步 “去永久代”,字符串常量池,静态变量移除,保存在堆中 |
JDK1.8 | 无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池、静态变量仍然在堆中。 |
JDK6的时候:
JDK7的时候:
JDK8的时候,元空间大小只受物理内存影响
StringTable
StringTable叫做字符串常量池,用于存放字符串常量,这样当我们使用相同的字符串对象时,就可以直接从StringTable中获取而不用重新创建对象。
jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。
这就导致stringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
JVM从加载到内存全过程图