JVM 概览
JVM体系结构
Java体系结构
类加载
概览
Java的类是在运行时按需加载入内存的,并不是一次性加载所有的类,这种做法能够减少内存空间的开销。
类的生命周期
类的加载过程
类加载过程就是类生命周期中的前5步。
1、加载
- 通过类的全限定名查找classpath,从.class文件或者是jar包中获取定义该类的二进制字节流。
2、验证
验证从class文件中读取的字节流信息符合当前虚拟机的要求,且是安全的。
3、准备
将该字节流表示的静态存储结果转换为方法区中的运行时存储结构,即产生了一个Class对象作为之后该类实例化的模板,并初始化类变量的默认值。
public static int v1 = 123; // 准备阶段 初始化为默认值 0
public static final int v2 = 123; // 准备阶段 初始化为final值 123
4、解析
解析实际上就类似C语言链接中的重定位步骤,将符号引用重定位为实际地址的引用。为了支持Java中的多态,这个时候的直接引用可能还不能够确定,所以可能会在初始化完成之后再进行解析。
5、初始化
初始化阶段就开始真正执行类中的Java代码。
这个过程中主要注意几点
- 静态语句块只能访问到定义在其之前的类变量,定义在其后的只能赋值不能访问。
public class Test {
static {
i = 20; // 正常编译通过
System.out.println(i); // java: 非法前向引用
}
private static int i = 10;
}
- 类资源的初始化是先字段后方法,初始化的顺序是按照源代码的顺序。
public class Test {
static {
i = 20; // 正常编译通过
}
private static int i = 10;
public static void main(String[] args) {
System.out.println(i); // 10
}
}
类初始化时机
1、主动引用
虚拟机规范中并没有强制约束何时进行类的加载,但是严格规定了必须对类进行初始化的情况(加载、验证、准备随之发生)。
- 遇到new、getstatic、putstatic、invokestatic这四条字节码指令时,若类没有进行过初始化则会先进行初始化。注意在读取或者设置final的类字段时例外,因为final的变量在编译期存入了常量池。
- 使用 java.lang.reflect 包进行反射调用时,若类没有进行初始化,则需要先触发初始化。
- 初始化一个类时,若父类还未被初始化,先触发父类的初始化。
- 虚拟机启动时的入口类,即main方法所在的类需要先初始化。
2、被动引用
非主动引用就是被动引用了,被动引用不会触发初始化。常见例子包括:
子类引用父类的静态字段,子类不会初始化。
System.out.println(Subclass.val); // val 是父类的静态变量
定义一个类数组,不会触发此类的初始化。该过程只对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的类,包含了数组的属性和方法。
ArrayClass[] arr = new ArrayClass[10];
常量在编译阶段会存入调用类的常量池中,所以在使用常量的时候本质上没有引用到定义常量的类,不会触发该类初始化。
int val = ConstClass.VALUE;
类加载器
就是类的快递员
有几种
- 启动类加载器 Bootstrap (C++)。加载最基本的Java类,例如Java.lang中的类,提供用户的基本使用环境,是根加载器。
- 拓展类加载器 Extension (Java)。加载的是Java中的拓展类,例如Javax库中的类就是由其加载。
- 应用程序类加载器 AppClassLoader。加载用户自定义的类。就是当前程序的classpath中的.class文件。
Test t = new Test();
t.getClass().getClassLoader().getParent().getParent(); => null (Bootstrap)
t.getClass().getClassLoader().getParent(); => ExtClassLoader
t.getClass().getClassLoader(); => AppClassLoader
parent delegation 双亲委派
一个类加载器首先将类加载请求转发到父类加载器,只有当父类加载器无法完成时才尝试自己加载。
所以实际上类的加载是自顶向下,从最顶部的启动类加载器部分开始加载,先到先得。
优先往上找,先到先用。作用是保证原生代码基础类的统一,防止用户定义的重名的类污染Java原生代码。同时使得所有类实际上都派生于Object,使得JVM能够通过Object联系到所有类,方便管理。
沙箱安全
双亲委派就是为了沙箱安全机制,保护原生Java的源码
JVM运行时数据区
【线程私有】PC寄存器
每一个线程都独自拥有一个程序计数器,PC寄存器就是每一个Java语言程序执行的向导,其保存着即将执行的Java指令的字节码指针,由执行引擎读取字节码的指令。
如果当前执行的的是native
方法,即非Java的方法,则PC寄存器为null
。
不会发生OOM错误。
【线程私有】本地方法栈
装载和运行native
方法,即本地方法。本地方法不在Java的管辖范围内,JVM也不可控,一般是跟C语言相关的方法库,故单独另外分出一个栈来供其运行,与Java的方法区分开来。
例如我们在启动线程时的start()
方法底层就调用了一个native
的start0()
方法,本地方法只有声明没有实现,因为实现不是Java控制的。所以当我们利用start()
方法创建一个线程后,线程是处于OS概念中的就绪态,至于是否占有CPU运行是由操作系统的调度来决定。
【线程私有】Java栈
栈管运行,堆管数据,一个运行的方法一个栈帧。
Java栈主管Java的方法运行,线程创建时创建,生命周期跟随线程,可以说它就是线程的工作空间。线程结束,Java栈也就跟着释放,不存在垃圾回收。
存什么
八种基本数据类型(int, short, long, float, double, char, byte, boolean) + 引用(方法引用、对象引用)。
- 存方法中的本地变量:输入输出参数。
- 存方法中的数据:类文件、方法。
- 存栈操作,记录入栈、出栈操作。
运行原理
遇到方法,方法栈帧入栈。从栈顶开始一个个执行,一个方法一个栈帧,执行完一个,出栈一个。
注意点
先进先出,由于main
是一切程序的入口,所以栈底永远都是**main**
方法。
如图所示,栈顶Stack Frame 1
就是当前正在执行的方法,当前方法执行完后根据父帧的信息返回到调用者栈帧,即PC寄存器指向调用者栈预留好的返回地址准备执行回到调用者方法。
栈溢出
当方法的调用过多导致栈帧塞满帧,就会出现StackOverflowError
错误。
【线程共享】方法区
方法区存在垃圾回收,但是比较少。
java8之后方法区(规范)的实现就是元空间(存在于主内存中)
存什么
- 类的模板信息(大Class)。
类加载器从磁盘读出类的文件后,就把Class文件存入方法区,之后堆中的实例对象就是根据这个模板刻出来的。 - 类的资源
即所有static
静态的字段;而普通的对象资源是存在Java堆中的。 - 常量池。
- 编译器编译后的代码缓存。
垃圾回收主要是针对常量池的常量回收和类的卸载。
常量池
方法区的一部分。class文件中的常量(编译器生成的字面量和符号引用)会在类加载后存放在这个区域。
除了在编译期生成的常量,还可以动态生成,例如String的intern()方法。
【线程共享】Java堆
堆的结构
还是那句话“栈管运行,堆管数据”,堆中存储的主要就是对象实例的数据。一个JVM实例只存在一个堆,其大小默认情况下是内存的1/4。从GC回收的角度,物理上分为两个区域:
- 新生区 1/3,内部Eden:From:To = 8:1:1。
- 养老区 2/3。
逻辑上分为三个区域:
- 新生区。
- 养老区。
- 元空间。
新生区
新生区是所有对象产生的地方,是大部分对象消亡的地方。为了GC的复制算法进行垃圾回收,又分为3个部分:
- Eden区,所有对象产生的地方。
- 幸存区:
- From区,上一次MinorGC前存放幸存对象的地方。
- To区,本次MinorGC前,空间的地方。
From区和To区在每一次MinorGC都会有交换,不是固定的,而是一种动态的逻辑身份。
每一次MinorGC前,当前空闲的是To区,先把幸存的对象复制到To区,然后GC一次性清空Eden、From区;接着当前的To区由于有了对象,就变成了下一次MinorGC的From区;当前的From区被清空了,就变成了下一次MinorGC时的To区。 总而言之:每次GC完,有交换,谁空谁是To。
老生区
养老区中存储的也是对象,这些对象是在新生区经过多次MinorGC后存活下来的。每当一个对象在新生区的From、To区中周转一次,年龄+1,默认情况下15岁就可以去养老了。养老区内存紧张的时候会触发FullGC(标清+标整),hold不住了就会报OOM(OutOfMemory)错误。
元空间(逻辑上属于堆)
元空间是对方法区的实现(java8之前为永久代),存储元数据,就是描述数据的数据,即类的描述信息。之前提过方法区存储的是类的模板信息,这就类似一个接口,只告诉你有什么而不是具体的,所以将方法区的模板实现后这些数据包括类、常量池、方法、代码缓存就存到了元空间中。
还有一点要注意的是,元空间和其前身永久代的区别就在于:元空间并不存在虚拟机中,而是使用本地内存。因此相比前身永久代,元空间的空间更加充裕,大小受本地实际内存限制,从而可以存放更多元数据。
堆参数调整
实际的应用中,我们可能会需要调整一下JVM默认的堆参数来满足实际的开发需求,下面看看如何进行堆参数的调整,同时通过OOM验证堆的结构。
首先通过如下代码,我们可以查看我们的CPU核数,和当前堆的一些默认设定。 -Xmx
参数就是堆的最大内存限制, -Xms
就是堆的初始内存限制。
public static void main(String[] args) {
System.out.println("CPU核数:" + Runtime.getRuntime().availableProcessors());
// 获取当前实例的运行时数据区对象
// 运行时数据区就是我们所说的JVM的运行时数据区,只不过封装成了一个类
long maxMemory = Runtime.getRuntime().maxMemory();
long totalMemory = Runtime.getRuntime().totalMemory();
System.out.println("-Xmx: MAX_MEMMORY = " + maxMemory + "bytes, " + (maxMemory / (double) 1024 / 1024) + "MB");
System.out.println("-Xms: TOTAL_MEMORY = " + totalMemory + "bytes, " + (totalMemory / (double) 1024 / 1024) + "MB");
}
然后我查看本机的内存大小,发现JVM堆的默认最大值大概就是本机内存的1/4.
注意在调整的时候,最大堆内存和初始堆内存应该设置成一样,这样GC清完空间后就无需重新计算堆区的大小从而防止GC和应用程序争抢空间,使得理论峰值忽高忽低。
-Xms1024m -Xmx1024m -XX:+PrintGCDetails // 更改堆的初始、最大大小为1G,同时打印GC信息
然后为了能够快速观察到OOM错误,我将堆的大小改为了10m,然后 while(true)
使劲 new
对象,之后通过控制台就可以观察到垃圾回收的过程:先是多个GC,之后出现FullGC,FullGC搞不定最后就出现了OOM错误。
运行时各区的交互
在方法中如果要用一个变量new
一个对象,那么就会先通过双亲委派的方法加载相应的类,之后类的模板信息存在方法区中。根据方法区中的模板,Java堆中实例化一个对象,之后将对象的引用返回到栈,存到变量中。
对于HotSpot版本的JVM,是通过指针指向Java堆来访问对象;Java堆中存放实例的数据,通过指针指向方法区中类的元数据。这个所谓的元数据就是描述类数据的数据,就是大Class。
GC垃圾回收
什么是GC回收
GC是作用在方法区和Java堆中的一种垃圾回收机制。当我们启动了 main
函数后,GC线程就已经开始跑在后台了。所以一般情况下,每一个程序至少有两个线程。
public class Test {
public static void main(String[] args) {
// 一旦运行,就有两个线程:main + GC
}
}
什么对象要回收
垃圾对象判断
引用计数法 Reference Counting
这种方法在Java在很少用,此算法让每一个对象维护一个引用计数器,引用数量为零的对象就是垃圾对象,要被清除。这种方法有两个很明显的缺点:
- 每次对象赋值后都要维护对应的计数器,消耗空间资源
- 不能处理循环引用,所谓循环引用就是A对象中的某个字段引用着B对象,B对象中的某个字段引用着A对象。这样当A和B的引用变量同时变为空后,仍然有对用户不可见的引用分别指向AB,这样就无法回收了。
// 循环引用
Person objA = new Person();
Person objB = new Person();
objA.field = objB;
objB.field = objA;
objA = null; // objA对象的field仍然指向objB
objB = null; // objB对象的field仍然指向objA
可达性分析法 Reachable Analysis
通过 GC Root
作为根进行搜索,不能够搜索达到的对象就是垃圾对象。 GC Root
一般是:
- Java栈中的引用变量
- 方法区中的常量、静态变量
- 本地方法栈的引用变量。
- 虚拟机内部自带的引用,如基本数据类型的Class对象,常见异常、错误
四种引用
四种引用分别是强弱软虚,他们的主要区别在于什么时候会被垃圾回收器给清除掉。
强引用,只要强引用存在就算OOM也不会被回收。
Object o = new Object();
软引用,在要发生OOM之前会被回收,即内存不够的时候就干掉。
根据这个特性,软引用很适合用作内存敏感的高速缓存,当内存足够的时候,很容以获取缓存;当内存紧张的时候就将缓存干掉。
// 浏览器对象
Browser browser = new Browser();
// 后台加载浏览页面
BrowserPage page = browser.getPage();
// 将浏览完毕的页面设置为软引用
SoftReference<BrowserPage> sf = new SoftReference<>(page);
... ...
// 回退或者再次浏览此页面
if (sf.get() != null) {
page = sf.get(); // 内存充足,可以直接获取缓存
} else {
page = browser.getPage(); // 内存不足,缓存的页面已经回收,重新获取
sf = new SoftReference<>(page); // 再次将页面缓存
}
弱引用,活不过下一次垃圾回收,比软引用生命更加短暂。这种引用的好处就是,将对象回收的控制权交给其他的引用。
A a = new A();
WeakReference b = new WeakReference(a);
a = null;
// 此时,A 对象能否被垃圾回收,控制权在于引用 a,b 不会造成影响!
虚引用,不会对对象的生命周期构成影响,也无法通过虚引用获取一个对象,一个对象的虚引用就是形同虚设,任何时候都可能被回收。唯一目的就是在回收的时候获得一个系统通知,所以虚引用可用于附加在对象上。
PhantomReference
虚引用常用于追踪对象被垃圾回收的活动,由于虚引用必须和引用队列(ReferenceQueue)联合使用,当一个有虚引用的对象持有虚引用的时候,在对象回收之前就会把虚引用加入到队列中。所以可以检查引用队列中是否存在该虚引用来知道对象是否将要进行垃圾回收,这样就可以在对象被垃圾回收之前进行一些操作。
GC回收日志
基本格式:
[名称:GC前的内存占用 -> GC后的内存占用(该区的总内存大小)]
GC回收4种算法
复制算法 Copying
复制算法就是维护两个空间,From区和To区。现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。将存活对象复制到To区,然后清空Eden和From区,接着交换From、To的角色。
这种方式的优点很明显:
- 由于对存储对象的区域一次性清空,所以不会有内存碎片。可以使用
bump-the-pointer
实现复制到To区时快速分配内存。 - 对象的存活率低的时候,效率高。
但缺点也很明显:
- 需要一个干净的空间作为To区,可用空间缩小为原来的1/2,空间利用率低。
复制算法主要应用于堆中年轻代的垃圾回收MinorGC,因为这个区域的对象正常情况下存活概率低(大概2%)。所以年轻代中 Eden:From:To = 8:1:1,只用一小部分To区来作为存储被复制的幸存对象的区域。注意From和To的逻辑角色在每一次GC后是动态交换的。
标记清除法 Mark Sweep
标记清除顾名思义,标记出要清除的对象,之后将对象清除就可以了。所以不需要额外的空间,但是缺点是:
- 会产生内存碎片,即删除掉零散的对象后会有不连续的空闲空间散布在各处,有时无法利用来存储一个完整的新对象。
- 效率不稳定,扫描两次空间分别标记、清除。
标记压缩法 Mark Compact
标记压缩法,就是在标记清除法的基础上,将剩余幸存的对象进行重新整理,往一端滑动存活对象,然后直接清理掉端边界以外的内存。优点就是:
- 没有内存碎片,提高了空间的利用率。
但是缺点就是:
- 效率低下。效率比较:标记复制 > 标记清除 > 标记压缩。
分代收集
分代收集就是GC回收使用的策略,这也是为何堆分为多个区域的原因:
- 次数上频繁的收集年轻代,复制算法。因为年轻代的生产率、死亡率都高,所以需要高效的清除算法,而存储幸存对象的空间不需要太大,故有 Eden:From:To = 8:1:1。
- 次数上较少收集老年代,标清和标整相结合(多次GC后才整理)。老年代的存活率较高,区域大,所以一定时间内的内存碎片是可以接受的,Young:Old = 1:2 。
- 元空间基本上不会动。
垃圾收集器
垃圾收集器的主要特性:
- 单线程、多线程:单线程指垃圾收集器使用一个线程工作,多线程指垃圾收集器使用多个线程进行工作;
- 串行、并行:相对于用户程序的概念。串行指垃圾收集器和用户程序交替执行,即GC工作时需要暂停用户程序;并行指垃圾收集器和用户程序同时执行。
1、【Y】Serial 收集器
单线程串行。
2、【Y】ParNew 收集器
Serial的升级版,多线程串行。
3、【Y】Parallel Scavenge 收集器
多线程串行,与其他收集器尽可能提高响应时间不同,其目标是尽可能高的吞吐量,即CPU尽可能多地处理用户程序。
所以高吞吐量可以更加高效地利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。
吞吐量、响应速度是两个不能够兼得的东西,所以本质上是在对这两者进行权衡。
4、【O】Serial Old 收集器
Serial收集器的老年代版本。
5、【O】Parallel Old 收集器
Parallel Scavenge收集器的老年代版本。
6、【O】CMS 收集器
Concurrent Mark Sweep收集器。以最短回收停顿时间为目标。工作流程如下:
- 初始标记:标记GC Roots能够直接关联到的对象,速度很快,串行执行,stop the world;
- 并发标记:深入可达性分析、扫描整个对象关系图的过程,是整个回收过程中耗时最长的,并行执行,不需要停顿用户程序。
- 并发重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的部分标记记录,串行执行,stop the world。
- 并发清除,并行执行,不需要停顿用户程序。
总结:消耗时间长的操作并发标记、并发清除,都是可以和用户线程一起并行工作,不会产生停顿。
存在的缺点:
- 吞吐量较低:低停顿时间是以牺牲吞吐量为代价的,这导致了应用程序对CPU的利用率不够高。
- 无法处理浮动垃圾:浮动垃圾是指并发清除阶段、并发标记由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次GC才能进行回收。由于浮动垃圾的存在,需要预留出一部分内存空间。这意味着CMS收集器不能像其他串行回收的垃圾收集器那样等待老年代快满的时候再回收。
若预留的内存不足以存放浮动垃圾,就会出现Concurrent Mode Failure
,此时JVM将临时冻结用户程序并启用Serial Old 来替代CMS。 - 标记清除算法导致内存碎片,出现老年代有空间剩余却没有连续空间来存储对象,进而提前触发 Full GC。(和用户程序并行执行使得无法移动存活对象进行内存整理,只能MS)
7、【Y/O】G1 收集器
Garbage-First,面向服务端应用的垃圾收集器,在多CPU和大内存的场景下有很好的性能。G1可以直接对新生代和老年代一起回收。
G1把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离,仅仅是逻辑的划分。
通过引入 Region 的概念,将原本的一整块内存空间划分成多个小空间,使得每一个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得预测停顿时间模型成为可能。通过记录每一个Region的垃圾回收时间和回收所获得的空间,可以维护一个优先列表 Remembered Set,每次根据允许的收集时间,优先回收价值最大的 Region。
不考虑维护 Remembered Set 的操作,则G1的运作流程如下:
- 初始标记
- 并发标记,并行执行。
- 最终标记:为了修正并发标记期间,用户程序继续运行而产生变动的记录。这个阶段串行执行 STW。
- 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来指定回收计划,把决定回收的那一部分Region 的存活复制整理到空的 Region中,再清理掉整个 Region。
因为只回收一部分 Region,且涉及到对象的移动,采用串行 STW 来执行。
由这个过程可以看出,G1除了并发标记外,其余阶段都是要暂停用户线程的,所以它并非像CMS一样纯粹地追求高响应。
主要优势:
- 空间整合:整体基于“标记-整理”算法来实现垃圾收集,局部上来看基于“复制”算法,这意味着运行期间基本不会产生内存碎片。
- 可预测停顿:使用者可以指定在一个长度为M毫秒的时间片段内,消耗在GC的时间上限。
运行时参数设置
参数一共有3大类型:
- 标配参数:
-version
等 - X参数:
-Xmixed
混合模式,-Xint
解释执行 - -XX参数:
- Boolean类型:
+
有,-
没有 - KV键值类型:通过
=
进行赋值,:=
标识已经改动过的值
- Boolean类型: