文章目录
JVM体系结构
Java运行时数据区的内存区域简介:
- 程序计数器: 指向当前线程正在执行的字节码的地址,行号。(记录线程执行到哪里了,为了防止线程被挂起后,重新唤醒从执行到的地方再次执行)
- Java 虚拟机栈(线程栈): 一个线程对应一块线程栈空间,每个方法在执行的同时都会在线程栈中创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在线程栈中入栈到出栈的过程。
本地方法栈: 同虚拟机栈,不同的是,它存的是本地方法的数据。
堆 Heap: 在JVM启动时创建的一块内存区域,是被所有Java线程共享的,不是线程安全的。堆是存储Java对象的地方,保存了所有的对象实例和数组。也是GC(内存回收)管理的主要区域,可以细分为:新生代、老年代、永久代;新生代又分为Eden空间、From Survivor(幸存)空间、To Survivor空间。
方法区 Method Area: 是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
常量池 Constant Pool: 是方法区的一个数据结构,用于保存编译期间生成的各种字节码和常量字符串等数据。
Java类加载过程
一个Java类从编码到最终完成执行,包括两个过程:编译运行。
编译:通过javac命令将.java文件编程成.class文件。
运行:将.class文件通过类加载器加载到内存中,并运行。
类在JVM中的生命周期: 加载、链接(验证、准备、解析)、初始化、使用、卸载。
类的加载时机:
JVM运行的时候,并不是一次性加载所有类的,而是使用到哪个就加载哪个,并且只会加载一次。
1、new 一个对象实例的时候。
2、访问类或接口的静态变量,或者给静态变量赋值。
3、调用类的静态方法。
4、反射 Class.forName(“com.demo.ClassA”)。
5、初始化一个子类,首先会初始化父类。
类装载器 ClassLoader
类装载器 ClassLoader 是负责加载class
文件的,将class
文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构。ClassLoader
只负责文件的加载,至于它是否可运行,则由Execution Engine
决定。
这里需要区分一下class
与Class
。小写的class
,是指使用javac
命令编译 Java 代码后所生成的以.class
为后缀名的字节码文件。而大写的Class
,是 JDK 提供的java.lang.Class
,可以理解为封装类的模板。多用于反射场景,例如 JDBC 中的加载驱动,Class.forName("com.mysql.jdbc.Driver");
下图Car.class
字节码文件被ClassLoader
类装载器加载并初始化,在方法区中生成了一个Car Class
的类模板,而平时所用到的实例化,就是在这个类模板的基础上,形成了一个个实例,即car1
,car2
。反过来讲,可以对某个具体的实例进行getClass()
操作,就可以得到该实例的类模板,即Car Class
。再接着,对这个类模板进行getClassLoader()
操作,就可以得到这个类模板是由哪个类装载器进行加载的。
Tip: 扩展一下,JVM并不仅仅只是通过检查文件后缀名是否是.class
来判断是否加载,最主要的是通过class
文件中特定的文件标示,即下图test.class
文件中的cafe babe
。
有哪些类装载器
1、虚拟机自带的类加载器
- 启动类加载器(
Bootstrap
),也叫根加载器,加载%JAVAHOME%/jre/lib/rt.jar
- 扩展类加载器(
Extension
),加载%JAVAHOME%/jre/lib/ext/*.jar
,例如javax.swing
包 - 应用程序类加载器(
AppClassLoader
),也叫系统类加载器,加载%CLASSPATH%
的所有类。
2、 用户自定义的加载器 : 用户可以自定义类的加载方式,但必须是Java.lang.ClassLoader
的子类。
双亲委派和沙箱安全
父类委托机制。
通过下面代码来观察这几个类加载器。首先,我们先看自定义的MyObject
,首先通过getClassLoader()
获取到的是AppClassLoader
,然后getParent()
得到ExtClassLoader
,再getParent()
竟然是null
?可能大家会有疑惑,不应该是Bootstrap
加载器么?这是因为,BootstrapClassLoader
是使用C++
语言编写的,Java
在加载的时候就成了null
。
我们再来看Java自带的Object
,通过getClassLoader()
获取到的加载器直接就是BootstrapClassLoader
,如果要想getParent()
的话,因为是null
值,所以就会报java.lang.NullPointerException
空指针异常。
输出中,sun.misc.Launcher
是JVM相关调用的入口程序。
自定义了一个java.lang.String
类,并且创建main
方法后运行,发现报错了,提示找不到main
方法,但是明明我们定义了main
方法啊,引出双亲委派和沙箱安全。
(1)双亲委派,当一个类收到了类加载请求,它首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,因此所有的加载请求都应该传送到启动类加载器中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。
采用双亲委派的一个好处是,比如加载位于rt.jar
包中的类java.lang.Object
,不管是哪个加载器加载这个类,最终都是委派给顶层的启动类加载器进行加载,确保哪怕使用了不同的类加载器,最终得到的都是同样一个Object
对象。
(2)沙箱安全,是基于双亲委派机制上采取的一种JVM
的自我保护机制,假设你要写一个java.lang.String
的类,由于双亲委派机制的原理,此请求会先交给BootStrapClassLoader
试图进行加载,但是BootStrapClassLoader
在加载类时首先通过包和类名查找rt.jar
中有没有该类,有则优先加载rt.jar
包中的类,因此就保证了java
的运行机制不会被破坏,确保你的代码不会污染到Java
的源码。保证了大家使用的类是同一套体系的,统一的class
。保证java
源代码不受污染,保证源码干净一致,这叫沙箱安全机制。
所以,类加载器的加载顺序如下:
- 当
AppClassLoader
加载一个class
时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader
去完成。 - 当
ExtClassLoader
加载一个class
时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader
去完成。 - 如果
BootStrapClassLoader
加载失败(例如在$JAVA_HOME/jre/lib
里未查找到该class
),会使用ExtClassLoader
来尝试加载。 - 若
ExtClassLoader
也加载失败,则会使用AppClassLoader
来加载,如果AppClassLoader
也加载失败,则会报出异常ClassNotFoundException
。
Tip: rt.jar
是什么?为什么可以在idea
这些开发工具中可以直接去使用String、ArrayList
、甚至一些JDK
提供的类和方法?因为这些都在rt.jar
中定义好了,且直接被启动类加载器进行加载了。
堆体系结构
Java7之前,堆结构图如下,而Java8则只将永久区变成了元空间。
Minor GC
针对的是新生代的垃圾回收。
在新生代中经历了几次Minor GC
仍然存活的对象,就会被放到老年代。
Major GC
针对的是老年代的垃圾回收。
Full GC
是针对整堆(包括新生代和老年代)做垃圾回收的。
永久代(Perm
)主要存放已被虚拟机加载的类信息,常量,静态变量等数据。
对象在堆中的生命周期
1、首先,新生区是类的诞生、成长、消亡的区域。一个类在这里被创建并使用,最后被垃圾回收器收集,结束生命。
2、其次,所有的类都是在Eden Space
被new
出来的。而当Eden Space
的空间用完时,程序又需要创建对象,JVM
的垃圾回收器则会将Eden Space
中不再被其他对象所引用的对象进行销毁,也就是垃圾回收(Minor GC
)。此时的GC可以认为是轻量级GC。
3、然后将Eden Space
中剩余的未被回收的对象,移动到From Space
,以此往复,直到From Space
也满了的时候,再对From Space
进行垃圾回收,剩余的未被回收的对象,则再移动到To Space
。To Space
也满了的话,再移动至Old Space
。
4、最后,如果Old Space
也满了的话,那么这个时候就会被垃圾回收(Major GC or Full GC)并将该区的内存清理。此时的GC可以认为是重量级GC。如果Old Space
被GC垃圾回收之后,依旧处于占满状态的话,就会产生我们场景的OOM
异常,即OutOfMemoryError
。
Minor GC的过程
Survivor 0 Space
,幸存者0区,也叫from
区;
Survivor 1 Space
,幸存者1区,也叫to
区。
其中,from
区和to
区的区分不是固定的,是互相交换的,意思是说,在每次GC之后,两者会进行交换,谁空谁就是to
区。
(1)Eden Space
、from
复制到to
,年龄+1。
首先,当Eden Space
满时,会触发第一次GC,把还活着的对象拷贝到from
区。而当Eden Space
再次触发GC时,会扫描Eden Space
和from
,对这两个区进行垃圾回收,经过此次回收后依旧存活的对象,则直接复制到to
区(如果对象的年龄已经达到老年(15)的标准,则移动至老年代区),同时把这些对象的年龄+1。
(2)清空Eden Space、from
然后,清空Eden Space
和from
中的对象,此时的from
是空的。
(3)from
和to
互换
最后,from
和to
进行互换,原from
成为下一次GC时的to
,原to
成为下一次GC时的from
。部分对象会在from
和to
中来回进行交换复制,如果交换15次(由JVM参数MaxTenuringThreshold决定,默认15),最终依旧存活的对象就会移动至老年代。
总结一句话,GC之后有交换,谁空谁是to
。
堆参数调优
-Xms
:初始堆分配大小,默认为物理内存的 1/64
-Xmx
:最大分配内存,默认为物理内存的 1/4
-XX:+PrintGCDetails
:输出详细的GC处理日志
IDEA中配置JVM内存参数:
在【Run】->【Edit Configuration…】->【VM options】中,输入参数-Xms1024m -Xmx1024m -XX:+PrintGCDetails
,然后保存退出。
JVM的初始内存和最大内存一般怎么配:
初始内存和最大内存一定是一样大,理由是避免GC
和应用程序争抢内存,进而导致内存忽高忽低产生停顿。
出现java.lang.OutOfMemoryError: Java heap space
异常,说明Java虚拟机的堆内存不够,造成堆内存溢出。原因有两点:
- Java虚拟机的堆内存设置太小,可以通过参数
-Xms
和-Xmx
来调整。 - 代码中创建了大量对象,并且长时间不能被GC回收(存在被引用)。
- 存在大对象。
GC常见算法
引用计数法、标记清除法、标记压缩算法、复制算法、分代算法。
栈-线程栈
线程栈,每一个线程运行的时候,Java虚拟机都会给这个线程分配一块独立的栈内存空间,来放线程的局部变量。
栈的组成:
栈实际上就是栈帧组成的,而每一个栈帧又存储着与之对应的方法的局部变量表,操作数栈,动态链接,方法出口。
也就是一个方法对应一个栈帧,栈帧就是Java中每个方法的存放空间。
- 局部变量表:存放着方法中的局部变量
- 操作数栈:用来存放操作方法中的数的一个临时栈内存空间
- 动态链接:把符号引用存在直接应用存在内存空间中
- 方法出口:记录该方法调用完毕应该回到的地方
一个简单的Math运行过程
public class Math {
public static final int initDate = 666;
public static User user = new User();
public Math() {
}
public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 3;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.compute();
System.out.println("end...);
}
}
1、将class文件加载进类加载子系统
2、开辟一个包含堆、方法区、栈、本地方法栈、程序计数器的空间
3、字节码引擎开始执行
反编译的结果:
分析compute()函数的指向流程理解帧栈这个空间。
Compiled from "Math.java"
public class com.shen.Main.jvm.Math {
public static final int initDate;
public com.shen.Main.jvm.User user;
public com.shen.Main.jvm.Math();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: new #2 // class com/shen/Main/jvm/User
8: dup
9: invokespecial #3 // Method com/shen/Main/jvm/User."<init>":()V
12: putfield #4 // Field user:Lcom/shen/Main/jvm/User;
15: return
public int compute();
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: iconst_3
8: imul
9: istore_3
10: iload_3
11: ireturn
public static void main(java.lang.String[]);
Code:
0: new #5 // class com/shen/Main/jvm/Math
3: dup
4: invokespecial #6 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #7 // Method compute:()I
12: pop
13: return
}
栈帧的局部变量其实是用一个数组进行存储的
其中特殊的局部变量0就是this
public int compute();
Code:
0: iconst_1 将局部变量1 放入到操作数栈
1: istore_1 将int类型的值存入局部变量1(将int值赋给在局部变量表的局部变量)
2: iconst_2
3: istore_2
(这就明白了 这两个实际上指行的就是 现在局部变量表中开辟一个b的空间 然后在从操作数栈中弹栈赋给局部变量b)
4: iload_1 //局部变量1压入栈
5: iload_2 //局部变量2 压入栈
6: iadd //弹栈两次 执行int 类型的add
7: iconst_3 //将计算结果 压入栈
8:bipush //将 10压入栈
9: imul //计算乘法结果 在压入栈中
10: istore_3 //将结果存给局部变量3
11: iload_3 //取出局部变量3的值
12: ireturn //return int
程序计数器
属于线程私有,用来存放线程执行代码的位置(就是.class文件中的行号) 由字节码执行引擎来操作。
Java是多线程的,当一个线程执行的过程中,被挂起了,程序计数器就是记录了被挂起时运行到的位置,然后当重新唤醒后,就会从程序计数器记录的位置处开始执行。
方法区
常量 public static final int initDate = 666;
静态变量(指向堆空间) public static User user = new User();
类信息
JDK8方法区使用的是物理内存,叫元空间。
堆
存放各种new出来的对象
局部变量表会指向
方法区中的静态变量也会指向
堆结构和垃圾搜集过程
垃圾搜集都是字节码执行器在做。
注: 只是Eden 区。对于S区,一个是 s0 存放着存活对象,一个是 s1 空的,等待 Minor GC 完成后,来转移存活的对象,并不用于分配给新生对象。
- 首先 new 出来的对象都会放在 Eden 区,如果 Eden 区没有足够的空间分配给新的对象,会触发Minor GC
- 存活对象会放到s0中
- 如果 Eden 区再次没有足够的空间分配给新的对象,再次搜集s0和Eden存活对象,放到s1中
- 清空 s0、Eden 区,让后 s0 和 s1 对换下,这时 s0 存放了存活的对象,s1 是空的
- 重复 第4步和第5步
- 当 s1 区空间放不下存活的对象或如果有对象的年龄到达15,会直接把对象转移到老年区
- 老年区满了,会启动 Full GC,启动 Full GC 时候必须暂停所有执行的代码(所有的用户线程)也就是STW(stop the world), 因为我们的对象必须要有一个确定的状态,不暂停的话就无法确定
老年代存放的对象: 对象类型的静态变量,缓存对象,数据库连接池中的对象,Spring容器中的Bean(Controller,Service),这些对象都一直被 GC Root引用,所以最终都会放到老年代。还有就是大对象会直接放入到老年代,大对象就是一块连续的内存空间在 Eden 区放不下,会直接进入老年代。
什么是 STW
stop the world 停止整个世界
STW 会停止所有的用户线程。
进行 Minor GC 和 Full GC 前都会触发STW。
Java虚拟机在进行垃圾回收的时候回触发STW:
如果在进行GC的时候,用户线程也在运行中,GC Root和对象是不断变化的,无法确定一个对象的状态,这时Java设计了STW,暂停所有的用户线程,让所有的对象都有一个确定的状态,然后快速的进行GC来回收垃圾对象,一般STW的时候会很短,对用户来说只是卡顿了下,但是频繁的STW也会使用户感觉网站很卡,用户体验不是很好。
JVM调优
JVM虚拟机调优的目的: 减少 full gc 次数,也就是减少STW次数,减少了STW次数也就是减少了暂停用户线程的次数,卡顿次数少,使用户体验更好。
1、评估系统每秒产生对象的大小
2、新产生的对象在一秒后如果变为垃圾对象,是否会被在minor gc清理
3、可以通过对象的年龄和在minor gc时是否占用survivor区50%,判断对象是否需要被清理还是放到老年代
4、如果在minor gc时频繁的通过占用survivor区50%将对象放到老年代,其实这些对象在一秒后也变成了垃圾对象,但是被放到了老年代,需要通过full gc才会被清理,表示年轻代配置不合理,这时需要加大年轻代的占用空间
优化前参数:
优化后参数:
JVM 调优常用参数
JVM优化之 -Xss -Xms -Xmx -Xmn 参数设置
新生代占比: 堆的 1/3
老年代占比: 堆的 2/3
新生代内部划分占比: 分成10份,伊甸区:8/10,两个survivor区分别为1/10
-Xms 堆内存的最小大小,默认为物理内存的1/64
-Xmx 堆内存的最大大小,默认为物理内存的1/4
-Xmn 堆内新生代的大小。通过这个值也可以得到老生代的大小:-Xmx减去-Xmn
-Xss 设置每个线程可使用的内存大小,即栈的大小。在相同物理内存下,减小这个值能生成更多的线程,当然操作系统对一个进程内的线程数还是有限制的,不能无限生成。线程栈的大小是个双刃剑,如果设置过小,可能会出现栈溢出,特别是在该线程内有递归、大的循环时出现溢出的可能性更大,如果该值设置过大,就有影响到创建栈的数量,如果是多线程的应用,就会出现内存溢出的错误。
JVM 调优经验
频繁的 full gc ,会导致网站很卡。
分析:
在促销的时候每秒产生1000多单,有3台8G内存的订单系统,分配到每台就是300单/秒。
假如每个订单对象是1KB,每秒就是300KB,下单的时候还会涉及到其他对象,比如库存,积分之类的,差不多放大20倍,每秒产生的对象300KB20对象。
可能同时还会有其他的操作,比如订单查询,库存查询,再放大10倍,每秒产生的对象就是 300KB20*10 = 60M,在一秒后都变为垃圾对象。
JVM设置给堆的大小是3G,默认老年代是2G,年轻代是1G,年轻代按照8:1:1划分,eden就是800M,每个s区分别就是100M。
按照每秒产生60M对象,在13秒的时候就会占满eden区,进行young gc,这时有一秒的对象是存活的,也就是60M,会进入s区,根据如果在一次young gc后,一批存活的对象总大小大于s区内存大小的50%,那么此时这批对象就可以直接进入老年代。那么我们每13秒就会有60M对象进入老年代,其实这批60M的对象在一秒后就变为垃圾对象了,老年代大小是2G,2048/60*13 差不多在7分钟左右就把老年代占满了,这时会进行full gc把所有的垃圾对象清除。
每7分钟左右进行full gc是不合理的,因为full gc是重量级的,stw会比较长,会导致系统很卡。
我们优化的方向因为是在young gc阶段就把垃圾对象清除掉,因为young gc是轻量级的,stw停顿几乎可以忽略,几乎不会对系统产生影响。
根据上面的案例我们就可以吧年轻代的大小调高到2G,这时eden就有1.6G。每个s区就是200M,这时候大概运行25秒的时候回占满eden,然后进行young gc,存活的对象就是60M,这时没有超过s区的一半,所以不会进入老年代,直接放在s区了,在进行下一次young gc的时候,还是只有60M对象存活,保证了垃圾对象在young gc的时候就被清除了,只有少量一直存活的对象会进入老年代,不会频繁的进行full gc了。
优化前的参数:堆大小是3G,老年代2G,年轻代1G,
调优后的JVM参数:-Xmn2048,设置年轻代为2G
面试题:怎么判定对象是否存活
通过可达性分析来判定对象是否存活。
通过一系列被 GC Roots 引用的对象,作为起点,从这些起点向下搜索,当一个对象没有被任何一个 GC Roots 引用时,该对象可以回收。
什么对象是"GC Roots"的对象?
- 虚拟机栈中栈桢中的局部变量(也叫局部变量表)中引用的对象。
- 方法区中类的静态变量、常量引用的对象
面试题:常见的垃圾收集方法
复制算法:
将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当一块内存用完了,将存活的对象复制到另一块上面,然后把已使用的内存空间一次清理掉。
这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等情况,对象按顺序分配内存即可,实现简单,运行高效。
只是这种算法将内存缩小为原来的一半,代价较高。
标准-清除:
分为两个阶段:首先标记出需要回收的对象,在标记完成后统一回收被标记的对象。
标记清除后会产生大量不连续内存碎片。
标记-整理:
与 标准-清除 算法一样,但后续不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。解决了内存碎片问题。
年轻代垃圾回收算法: 使用了复制算法,划分了两个 s 区,总有一个 s 区是空的,用来存放存活的对象。因为每次 young gc 存活的对象很少,不需要划分很大的内存空间,减少了代价。
老年代垃圾回收算法: 使用了 标记-整理。
面试题:什么对象进入老年代
- 对象优先在 Eden 分区: young gc 后将存活的对象放入 Survivor 空间,如果 Survivor 空间无法放入对象时,只能通过空间分配担保机制提前转移到老年代。
- 大对象直接进入老年代: 怎么判断一个对象是大对象呢,需要大量连续内存空间的 Java 对象,如果在 Eden 区分配失败,直接分配到老年代,到底多大才进入老年代呢,提供了一个
-XX:PretenureSizeThreshold=<byte size>
参数(默认是0,表示任何对象首先在 Eden 区分配),大于这个值的参数直接在老年代分配,避免新生代中的 Eden 区及两个 Survivor 区发生大量内存复制。 - 长期存活的对象进入老年代: 虚拟机会给每个对象定义一个对象年龄计数器。如果对象在 Eden 出生并且经过一次 Minor GC 后任然存活,且能够被 Survivor 容纳,将被移动到 Survivor 空间中,并且对象年龄设为 1。每次 Minor GC 后对象任然存活在 Survivor 区中,年龄就加 1,当年龄到达
-XX:MaxTenuringThreshold
(默认是15)参数设定的值时,将会移动到老年代。 - 动态年龄判断: 虚拟机不是永远要求对象的年龄必须达到 15 才会将对象移动到老年代去。如果 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。
- 空间分配担保: 在 Minor GC 前,虚拟机会检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果条件成立,那么 进行 Minor GC ,否则将进行一次 Full GC。