JVM
JVM底层原理看这一篇就够了,带你彻底搞懂JVM底层原理 - 知乎 (zhihu.com)
什么是JVM
Java Virtual Machine Java程序的运行环境(java二进制字节码的运行环境)
好处:
- 一次编写,到处运行
- 自动内存管理,垃圾回收机制
JVM组成部分
JVM组成部分
- 类装载子系统
- 字节码执行引擎
- 运行时数据区
JDK体系结构图
类加载器
JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到UJVM中,从而让Java程序能够启动起来。
双亲委派模型
加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类
好处
- 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
- 为了安全,保证类库API不会被修改
类装载的执行过程
类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)
加载
- 通过类的全名,获取类的二进制数据流。
- 解析类的二进制数据流为方法区内的数据结构(Java类模型)
- 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口
验证
准备
为类变量分配内存并设置类变量初始值
- static变量,分配空间在准备阶段完成(设置默认值),赋值在初始化阶段完成
- static变量是final的基本类型,以及字符串常量,值已确定,赋值在准备阶段完成
- static变量是final的引用类型,那么赋值也会在初始化阶段完成
解析
把类中的符号引用转换为直接引用
初始化
对类的静态变量,静态代码块执行初始化操作
- 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
- 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。
使用
JVM开始从入口方法开始执行用户的程序代码
- 调用静态类成员信息(比如:静态字段、静态方法
- 使用new关键字为其创建对象实例
卸载
当用户程序代码执行完毕后,JVM便开始销毁创建的Class对象
运行数据区
Java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构。 具体来说:这两种架构之间的区别:
基于栈式架构的特点
- 设计和实现更简单,适用于资源受限的系统;
- 避开了寄存器的分配难题:使用零地址指令方式分配。
- 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,
- 编译器容易实现。
- 不需要硬件支持,可移植性更好,更好实现跨平台·
基于寄存器架构的特点
- 典型的应用是x86的二进制指令集:比如传统的Pc以及Android的Davlik虚拟机。
- 指令集架构则完全依赖硬件,可移植性差>性能优秀和执行更高效;
- 花费更少的指令去完成一项操作。
- 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主。有难学的
举例如下:
1. 堆
**堆是由一个或多个线程共享的运行时数据区。**它是用于存储对象实例的地方,几乎所有的对象实例和数组都会被分配在堆上。堆的存储空间是动态分配的,其大小可以在创建JVM时设定,也可以根据需要进行扩展。对象实例在堆中分配和回收,由Java的垃圾回收机制自动处理。
2. 虚拟机栈(线程)
**栈是一个线程私有的运行时数据区,用于存储局部变量、方法参数、方法返回值以及方法调用时的临时数据。**每个线程在执行过程中都有一个对应的栈帧(Stack Frame),每个方法调用时都会在栈上创建一个栈帧,方法执行完毕后,栈帧会被销毁。栈的大小是固定的,由虚拟机在创建线程时设定。
- 局部变量
- 操作数栈
- 动态链接
- 方法出口
垃圾回收是否设计栈内存
垃圾回收主要指就是堆内存,当栈帧弹栈以后,内存就会释放
栈内存分配越大越好吗?
未必,默认的栈内存通常为1024k 栈帧过大会导致线程数变少,例如,机器总内存为512m,目前能活动的线程数则为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半.
方法内的局部变量是否线程安全?
- 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
- 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
栈内存溢出情况
- 栈帧过多导致栈内存溢出,典型问题:递归调用
- 栈帧过大导致栈内存溢出
3. 方法区(元空间)
- 方法区也是一个各个线程共享的运行时数据区,用于存储类信息、常量池、静态变量、即时编译器编译后的代码等数据。
- 方法区的大小也是固定的,由虚拟机在创建JVM时设定,虚拟机启动的时候创建,关闭虚拟机时释放。
- 在Java 8之前,方法区是一个逻辑上连续的内存空间,而在Java 8及之后的版本中,方法区被移除,类的元数据和静态变量被放在了堆中的一个称为"元空间"的区域。
class Animal
{
private String name;//实例成员变量
private int age;
public static void color(){//静态方法(属于类,直接用类名访问)
System.out.println("White");
}
public void identity(){//实例方法(属于对象,必须用对象访问)
System.out.println(name+age);
}
public static void main(String[] args)
{
Animal.color();//类名.静态方法
Animal puppy=new Animal();//创建对象
puppy.name="张三";
puppy.age=20;
puppy.identity();//对象.实例方法
puppy.color();//也可对象.静态方法
}
}
可能会提问的问题
4. 程序计数器
程序计数器:线程私有的,内部保存的字节码的行号。用于记录正在执行的字节码指令的地址。
我们对以下代码进行编译
public class Apk {
public static void main(String[] args) {
System.out.println("hello word");
}
}
运行以下代码
javac Apk.java
通过命令反编译java代码
javap -v Apk.class
得到的反编译结果为
Classfile /D:/code/k1/src/Apk.class
Last modified 2023年8月21日; size 410 bytes
SHA-256 checksum 7aaeb0dd543ec63b5814f0ef0d379c698838178431aadfa5a4af26b3e6b9e63f
Compiled from "Apk.java"
public class Apk
minor version: 0
major version: 61
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #21 // Apk
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // hello word
#14 = Utf8 hello word
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // Apk
#22 = Utf8 Apk
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 main
#26 = Utf8 ([Ljava/lang/String;)V
#27 = Utf8 SourceFile
#28 = Utf8 Apk.java
{
public Apk();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String hello word
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
}
SourceFile: "Apk.java"
getstatic
:就是获取静态数据,System.out
ldc
:加载常量
5. 本地方法栈
直接内存
- 直接内存:并不属于JVM中的内存结构,不由VM进行管理。是虚拟机的系统内存,
- 常见于NIO操作时,用于数据缓冲区,它分配回收成本较高,但读写性能高。
垃圾回收机制
为啥需要垃圾回收机制
对于系统而言,内存迟早都会被消耗完,因为不断的分配内存空间而不进行回溯,就好像不停的产生生活垃圾 但是除了释放垃圾对象,也需要对于内存空间进行碎片管理,没有垃圾回收就不能保证应用程序的正常化进行
什么时候垃圾回收机制
在Java内存运行时区域的各个部分中,堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。
堆区分为老年代
和新生代
,新生代又分为Eden区、s0区和s1区(99%的对象能new到Eden区,1%大对象new到老年代),当对象去堆区申请空间时
- 先去Eden区看有无足够空间,有分配,无mirror GC
- 有分配,无去s区,有Eden区对象移到s区,无去old区
- 有s移到old,Eden移到s,无full GC
- old区有同上,无OOM
判断对象是否存活的方法
引用计数算法
垃圾收集器所关注的正是堆和方法区的内存该如何管理的问题,我们平时所说的内存分配与回收也仅仅特指这一部分内存。
在对象中添加一个引用计数器:
- 每当有一个地方引用它时,计数器值就加一;
- 当引用失效时,计数器值就减一;
==任何时刻计数器为零的对象就是不可能再被使用的==。
但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。
举个简单的例子:对象objA和objB都有字段instance,赋值令objA.instance=objB及 objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。
例子2
例:一个对象A只要有任何一个对象引用了A则A的引用计数器就+1,当引用失效时,引用计数器就-1.只要对象A的引用计数器的值为0,即标识对象A不可能再被使用,可进行回收
- 优点:实现简单,垃圾对象便于识别,判断效率高
- 缺点: 他需要单独的字段存储计数器,这样的做法增加的存储空间的开销 每次赋值需要额外的加减法计算,增加了时间开销 引用计数算法最大的问题是
无法处理循环引用
的情况,这是一个比较致命的缺陷
可达性分析算法
当前主流的商用程序语言的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。
相对于引用计数算法,他有效的
解决了在引用计数算法中的循环引用问题
,防止内存泄漏发生这种类型的垃圾收集也叫作追踪性垃圾收集
这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
如下图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象:
总结的图
GC Roots的对象
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
总结:一个指针,他保存了堆里面的对象,但自己又不在堆当中,那么他就是一个Root
垃圾回收算法
1. 标记清除法
背景: 标记清除算法是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并应用于Lisp语言
执行过程:
(1)当堆空间中有效内存空间被耗尽时,就会停止这个程序(Stop the world),然后进行两项工作,标记,清除这两部分 (2)标记:从引用根节点上开始遍历(可达性分析算法)标记所有被引用的对象。一般是在对象Header中记录为可达对象。 (3)清除:对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收
缺点:效率不高;在进行GC的时候需要停止整个应用程序,导致用户体验差;且会产生的大量的内存碎片。
注意: 在这里的清除不是去干掉具体内存中的数据,而是本身分配的是一组连续的内存编码给我们使用,清除就是在回收这些空闲地址,将他们保存在空闲地址表当中,下次有心得对象需要空间时去判断是否够用
2. 复制算法
- 背景: 为了解决
标记-清除算法在垃圾收集效率方面的缺陷
,M.LMinsky与1963年发表了著名论文,”使用双存储区的Lisp语言垃圾收集器“,该论文中被描述的算法被人们称之为复制算法。 - 执行过程: 将内存空间分为两块,每次只使用其中一块,在垃圾回收的时候,将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块,交换两个内存角色。
- 缺点: 1.需要两倍空间 2.GC需要维护对象的引用关系,时间开销加大 此种方案使用与垃圾对象较少,量级不大的情况
3. 标记整理
- 背景: 复制算法的高效是简历在存货对象少、垃圾对象多的前提下。这种情况在新生代中经常法神,但是在老年代,更常见的情况是大部分对象都是存货的。如果依然使用复制算法,由于存货对象多,复制成本也会非常高。因此基于老年代使用复制算法并不适用。
- 执行过程: 第一阶段与标记清除算法一致。 第二阶段将所有的存货对象压缩到内存的一段,按照顺讯排放,之后清理边界外所有空间
4. 分代回收算法
背景:为了满足垃圾回收的效率最优性,所以分代回收算法应运而生。
分代回收算法基于一个事实:不同的对象生命周期是不一样的,因此,不同生命周期的对象可以采取不同的手机方式,以便于提高回收效率。一般是把JAVA堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同回收算法,相对提高效率。
在系统运行过程汇总,会产生大量对象,其中有些对象是业务信息相关,如HTTP请求的Session、线程、Socket连接等对象,这类对象跟业务挂钩,因此生命周期长,还有一部分是运行过程汇总生成的临时变量,这些对象生命周期短,比如:String,这些对象甚至只使用一次即可回收
目前所有GC都采用分代收集算法进行执行 对象的状态经过大量的调研研究划分为年青代与老年代两个类别
(1)年轻代:区域相对小,对象生命周期短、存活率低,且产生应用频繁 复制算法回收整理速度是最快的。复制算法效率只与当前存活对象大小有关,因此很实用与年青代的回收,而空间问题,因为存活率问题,所以单独开辟S0,S1两块空间处理清除后结果 (2)老年代:区域较大,生命周期长、存活率高,回收不及年青代频繁 这种情况存在大量存过对象下,复制不适用,所以一般是用清除与整理算法混合实现。
MinorGC、Mixed GC、FullGC的区别是什么
- MinorGC【young GC】发生在新生代的垃圾回收,暂停时间短(STw)
- Mixed GC新生代+老年代部分区域的垃圾回收,G1收集器特有
- FullGC:新生代+老年代完整垃圾回收,暂停时间长(STW),应尽力避免
Mark阶段的开销与存活对象的数量成正比 Sweep阶段的开销与所管理的大小成正比 Compact阶段的开销与存活对象的数据成正比
垃圾回收器
1. 串行垃圾收集器
Serial和Serial Old串行垃圾收集器,是指使用单线程进行垃圾回收,堆内存较小,适合个人电脑
- Serial 作用于新生代,采用复制算法
- Serial Old 作用于老年代,采用标记-整理算法
垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成。
2. 并行垃圾收集器
Parallel New和Parallel Old是一个并行垃圾回收器,JDK8默认使用此垃圾回收器
Parallel New作用于新生代,采用复制算法
Parallel Old作用于老年代,采用标记-整理算法
垃圾回收时,多个线程在工作,并且java应用中的 所有线程都要暂停(STW),等待垃圾回收的完成。
3. CMS (并发)垃圾收集器
CMS全称Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。其最大特点是在进行垃圾回收时,应用仍然能正常运行。
4. G1垃圾收集器
- 应用于新生代和老年代,在JDK9之后默认使用G1
- 划分成多个区域,每个区域都可以充当eden,survivor,old,humongous,其中 humongous 专为大对象准备
- 采用复制算法
- 响应时间与吞吐量兼顾
- 分成三个阶段:新生代回收、并发标记、混合收集
- 如果并发失败(即回收速度赶不上创建新对象速度),会触发Full GC
Young Collection(年轻代垃圾回收)
- 初始时,所有区域都处于空闲状态
- 创建了一些对象,挑出一些空闲区域作为伊甸园区存储这些对象
- 当伊甸园需要垃圾回收时,挑出一个空闲区域作为幸存区,用复制算法复制存活对象,需要暂停用户线程
- 随着时间流逝,伊甸园的内存又有不足
- 将伊甸园以及之前幸存区中的存活对象,采用复制算法,复制到新的幸存区,其中较老对象晋升至老年代
Young Collection + Concurrent Mark(年轻代垃圾回收+并发标记)
当老年代占用内存超过阈值(默认是45%)后,触发并发标记,这时无需暂停用户线程
- 并发标记之后,会有重新标记阶段解决漏标问题,此时需要暂停用户线程。
- 这些都完成后就知道了老年代有哪些存活对象,随后进入混合收集阶段。此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少)的区域(这也是Gabage First名称的由来)。
Mixed Collection(混合垃圾回收)
复制完成,内存得到释放。进入下一轮的新生代回收、并发标记、混合收集
总结
- serial:针对新生代,jdk1.3之前,单线程,复制算法,垃圾回收会stop the world(停止用户代码执行)
- serial old:针对老年代,jdk1.3之前,标记整理
- parNew,parallel Scavenge:新生代,多线程
- parallel:老年代
G1:jdk1.7之后,新生代/老年代,可预测停顿(提供最优的停顿时间),空间整理(提供最大的吞吐量)
CMS:jdk1.7之后,老年代 使用空闲列表回收,不对老年代进行整理
垃圾回收算器底层算法:
常用GC垃圾回收器性能对比
强引用,弱引用,软引用,虚引用
强引用:只有所有GC Roots对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
User user = new User();
软引用:仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收
User user = new User():
SoftReference softReference = new SoftReference(user);
弱引用:仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
User user = new User();
WeakReference weakReference = new WeakReference(user);
虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由Reference Handler线程调用虚引用相关方法释放直接内存
User user = new User();
ReferenceQueue referenceQueue = new ReferenceQueue();
PhantomReference phantomReference = new PhantomReference(user,queue);
JVM调优
哪里设置参数值
war包部署在tomcat中设置
jar包部署在启动参数设置
javajava -Xms512m -Xmx1024m -jar xxxx.jar
参数有哪些
对于JVM调优,主要就是调整年轻代、老年代、元空间的内存空间大小及使用的垃圾回收器类型。
设置堆空间大小
堆空间设置多少合适?
- 最大大小的默认值是物理内存的1/4,初始大小是物理内存的1/64
- 堆太小,可能会频繁的导致年轻代和老年代的垃圾回收,会产生stw,暂停用户线程
- 堆内存大肯定是好的,存在风险,假如发生了fullgc,它会扫描整个堆空间,暂停用户线程的时间长
- 设置参考推荐:尽量大,也要考察一下当前计算机其他程序的内存使用情况
虚拟机栈的设置
虚拟机栈的设置:每个线程默认会开启1M的内存,用于存放栈帧、调用参数、局部变量等,但一般256K就够用。通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。
bash-Xss #对每个线程stack大小的调整,-Xss128k
年轻代中Eden区和两个Survivor区的大小比例
设置年轻代中Eden区和两个Survivor区的大小比例。该值如果不设置,则默认比例为8:1:1。通过增大Eden区的大小,来减少YGC发生的次数,但有时我们发现,虽然次数减少了,但Eden区满的时候,由于占用的空间较大,导致释放缓慢,此时STW的时间较长,因此需要按照程序情况去调优。
bash-XXSurvivorRatio=8 #表示年轻代中的分配比率:survivor:eden = 2:8
年轻代晋升老年代阈值
bash-XX:MaxTenuringThreshold=threshold
默认为15取值范围0-15
设置垃圾回收收集器
通过增大吞吐量提高系统性能,可以通过设置并行垃圾回收收集器。
bash-XX:+UseParallelGc -XX:+UseParallelOldGc
JVM调优工具
jstat工具
是JVM统计监测工具。可以用来显示垃圾回收信息、类加载信息、新生代统计信息等。
Java内存泄漏的排查思路
参考资料