JVM虚拟机基础
# Java虚拟机-JVM

什么是JVM
基本概念
JVM是一种抽象化的计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的,JVM有自己完善的硬件架构,如处理器,堆栈,寄存器等,还有一些具体的操作指令,JVM屏蔽了与JVM平台具体操作系统相关的信息,我们只需专注Java代码
JVM是一个内存中的虚拟机,所以JVM的存储即是内存,下面是JVM的结构,包括
- Class Loader(类加载器):依据特定格式,加载class文件到内存
- Runtime Data Area(JVM内存空间结构模型):堆、栈、方法区、程序技术器
- Execution Engine:对命令进行解析
- Native interface(本地接口):融合不同开发语言的原生库为Java所用,比如C++等语言中已经开发了的库,Java就可以不用开发了,而是可以直接为Java所用

运行过程
Java源码首先被编译成class字节码文件,然后再有不同平台的JVM进行解析,Java语言在不同平台上运行时不需要再进行编译,Java虚拟机直接将字节码文件转换为对应平台的机器码

一个程序从开始运行,虚拟机就实例化,多个程序存在多个虚拟机实例。程序退出或者关闭,则虚拟机实例消亡,多个虚拟机实例之间数据不能共享

JVM内存区域
JVM内存区域主要分为线程私有区域(程序计数器,虚拟机栈,本地方法栈)、线程共享区域(堆,方法区),直接内存三个部分。

各区域的生命周期
线程私有区域
线程私有区域的生命周期与线程相同,依赖用户线程的启动和结束,在JVM中创建和销毁,每个线程都与操作系统的本地线程直接映射。
线程共享区
线程共享区随虚拟机的启动/关闭而创建/销毁
线程私有区
程序计数器
程序计数器是当前线程所执行的字节码行号指示器,通过改变计数器的值来选取下一条需要执行的字节码指令,他具有如下几个特点
- 正在执行java方法的话,计数器记录的是虚拟机字节码指令的地址,如果是Native方法,则为空
- 这块内存区域是虚拟机规范中唯一没有OutOfMemoryError的区域(只记录行号,不会发生泄漏)
- 通过改变计数器的值来选取下一条需要执行的字节码指令
为什么要使用程序计数器:由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令,为了线程切换可以恢复到正确的执行位置,每个线程都需要一个独立的程序计数器,指向所执行的字节码指令地址,不同线程之间的程序计数器互不影响,独立存储。
虚拟机栈
虚拟机栈是描述Java方法执行的内存模型,每个方法执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表,操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机中的入栈和出栈的过程。
栈帧是用来存储数据和部分过程的数据结构,同时也被用来处理动态链接和方法返回值。栈帧随着方法调用而创建,方法结束而销毁--无论正常完成还是异常-----这也是栈的内存不需要GC回收的原因。

注意:
- 如果线程请求的栈深度大于虚拟机所允许的深度,则会出现StackOverflowError,比如递归层数过多。
- 如果虚拟机栈可以动态扩展,扩展到无法申请足够的内存,则出现OutOfMemoryError
- 通常说栈是指虚拟机栈的局部变量表部分
本地方法栈
本地方法栈与虚拟机栈类似,区别在于虚拟机栈描述Java执行方法,而本地方法栈描述Native,比如调用c或c++
线程共享区
方法区/元空间
元空间用于存储被虚拟机加载的类信息,常量,静态变量、即使编译器编译后的代码缓存等数据,JDK8以后把类的元数据放在本地内存中,在jdk7以前,这一块属于永久代
元空间和永久代的区别:元空间和永久代都是方法区的实现,但元空间使用本地内存,而永久代使用jvm内存
元空间替换永久代的优势:
- 在jdk6时字符串常量池存放在永久代中,容易出现性能问题和内存溢出
- 类和方法的信息大小难以确定,给永久代的大小指定带来困难
- 使用永久代实现方法区会为GC带来不必要的复杂性
运行时常量池
运行时常量池是方法区的一部分,用于存放Class文件的常量池表,常量池表用于存放编译期生成的各种字面量与符号引用,符号引用翻译出来的直接引用也会存储在运行时常量池中
Java堆
堆用于保存创建的对象实例和数组,是垃圾收集器进行垃圾收集的主要区域。由于收集器采用分代算法,因此堆从GC的角度可分为新生代(Eden去,From Survivor区和To Survivor区)和老年代。
内存设置:-Xms 堆初始大小;-Xmx 堆最大扩容大小;-Xmn 新生代大小
根据虚拟机规范,Java堆可以存在物理上不连续的内存空间,就像磁盘空间只要是逻辑连续的即可,他的内存大小可以设为固定,也可以扩展,当前主流的Hotpot虚拟机等都能实现扩展,如果堆没有内存完成实例分配,而且堆无法扩展将报OutOfMemoryError错误
若没有默认的命令行指定初始化和最大的堆大小,则堆大小取决于计算机的物理内存大小, 默认最大堆在物理内存192MB以下时是物理内存的一半,并且在物理内存1GB以下时,为其四分之一
Java堆从GC的角度可细分为:新生代(Eden区,From Survivor区和To Survivor区)和老年代

JDK7以前
- 新生代(Young Generation)
- 老年代(Old Generation)
- 永久代(Permanent Space)
![]()
JDK8及以后
- 年轻代(Young Generation)
- 老年代(Old Generation)
年轻代
用来存放新生的,生命周期较短的对象,一般占用1/3的堆空间,由于频繁创建对象,因此新生代会频繁触发MinorGC进行垃圾回收,新生代由分为Eden区,From Servivor区,To Servivor区
Eden区
Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配给老年代),当Eden区内存不够时会触发MinorGC,对新生代区进行一次垃圾回收
ServivorFrom
ServivorTo
老年代
主要用于存放生命周期长的对象。
永久代(Jdk7及以前)
指内存的永久保存区域,主要存放Class(现在存在元空间)和Meta(元数据---中介数据)的信息,与存放实例的区域不同,GC不会在主程序运行期对永久区域进行清理。所以这会导致永久代的区域会随着加载的Class的增多而挤满,最终抛出OOM异常
为什么堆区域要分代?
分代的目的是为了提高GC的效率,将堆根据分代垃圾回收策略去划分更利于内存的管理,因为堆中存储的对象生命周期不尽相同,根据各对象的生命周期使用不同的分代收集策略能提高垃圾回收的效率,比如讲生命周期较短的对象放在一起,那么每次只关注如何保留少量存活的对象而不是去标记那些需要大量回收的对象,这样以低代价回收大量空间。如果生命周期较长的对象放在一起,虚拟机就可以以较低的频率来回收这个区域,这样同时兼顾了垃圾回收的时间开销和内存空间的有效利用。
新生代为什么要分区?
因为新生代存储的对象是生命周期比较短的对象,因此每次垃圾回收需要回收大量对象,这样存活的对象就很少了,所以垃圾回收使用复制Coping算法,但是复制算法需要至少两块内存区域去将一块区域的对象复制到另一块区域,如果将内容直接复制到老年代,老年代将会被很快填满,触发Full GC(Major GC伴随Minor GC),而进行一次Full GC消耗的时间是很多的,所以新生代出现了Survivor的分区,因此Survivor分区的意义就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证只有经历了一定的Minor GC次数后依然存活的对象,才会进入老年代
为什么要设置两个Survivor区
设置两个Survivor区的最大好处就是解决内存碎片化
为什么一块Survivor区不行?假设现在只有一块Survivor区,那么模拟一下Minor GC的流程:
新创建的对象会被存放在Eden区,一旦Eden区满了,就会触发Minor GC,利用复制算法将Eden中存活的对象移动到Survivor区。这样循环下去,当下次Eden区又满了是,问题来了,此时Eden区和Survivor都存在一些存活的对象,如果此时将Eden区的对象放到Survivor区,就会导致两部分对象的内存区域不连续,也就导致了内存碎片化。因此需要两块Survivor去解决这个问题,当Eden和S0都有存活对象时,就将这两个部分的对象按照连续的内存地址存放在S1中。接着S0和Eden清空,下一轮S0和S1互换角色,如此往复,知道对象的年龄到达一定次数被送入老年代。
直接内存
直接内存不是虚拟机运行时数据区的一部分,这部分内存会被频繁使用,并可能导致OutOfMemoryError异常的出现,直接内存的分配不会受到Java堆大小的限制,但是会受到本机总内存的限制,如果只考虑虚拟机内存而忽视直接内存,则可能出现动态扩展虚拟机内存导致超过物理内存限制出现OutOfMemory的情况
JVM内存溢出
堆
如果堆没有内存完成实例分配,而且堆无法扩展将报OutOfMemoryError错误,堆内存OOM异常是实际应用中最常见的内存溢出异常情况
异常信息:"Java heap space"
堆内存大小设置:-Xms -Xmx
内存溢出测试
在IDEA中设置VM Option参数为-Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError,限制Java堆的大小为20MB,不可扩容,参数-XX:+HeapDumpOnOutOfMemoryError是为了让虚拟机出现内存溢出时Dump出当前的内存堆转储快照以便时候分析
/**
* @program: algorithmDemo
* @description: -Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError
* @author: sirelly
* @create: 2020-10-15 14:52
**/
public class HeapOOM {
static class OOMobject{
}
public static void main(String[] args) {
ArrayList<OOMobject> ooMobjects = new ArrayList<>();
while (true){
ooMobjects.add(new OOMobject());
System.out.println(11);
}
}
}
运行结果
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid10100.hprof ...
Heap dump file created [19801166 bytes in 0.082 secs]
问题解决:通过内存映像分析工具对Dump出来的堆转储快照进行分析,确认内存中导致OOM的对象是否必要,分析出是内存泄露(Memory Leak)还是内存溢出(Memory Overflow)
- 如果是内存泄露,则通过工具进一步检查泄露对象到GC Roots的引用链,找出产生内存泄露的具体i位置
- 如果不是内存泄露,则可以根据堆参数-Xms和-Xmx调整内存大小,并从代码上检查是否有某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。
方法区和运行时常量池
如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。
异常信息:PermGen space(永久代空间,比如jdk6时的字符串常量池被挤爆)
不同JDK版本之间的intern()方法的区别-JDK6 VS JDK6+
public class ConstantPoolOOM { public static void main(String[] args) { String s1 = new StringBuilder("计算机").append("软件").toString(); System.out.println(s1.intern() == s1); String s2 = new StringBuilder("ja").append("va").toString(); System.out.println(s2.intern() == s2); } }这段代码在JDK 6中运行,会得到两个false,而在JDK 7中运行,会得到一个true和一个false。
JDK6中:intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在Java堆上,所以必然不可能是同一个引用,结果将返回false。
JDK7中:当调用intern方法时,如果字符串常量池先前已创建该字符串对象,则返回池中的该字符串引用。否则,如果该字符串对象已经存在于java堆中,则将堆中对于此对象的引用添加到字符串常量池中,并且返回该引用;如果堆中不存在,则在池中创建该字符串并返回其引用
第一个为true是因为“计算机软件”字符串是首次出现,intern()方法就把这个字符串在堆中的引用添加到字符串常量池,然后返回引用,因此是同一个引用,第二个为false是因为"java"字符串在字符串常量池中已经存在了,因此intern返回的是字符串常量池中的引用,与对中的对象引用不同
为什么jdk7之后方法区中的常量池会移动到堆中
因为之前字符串常量池存在于永久代中,而永久代的内存极为有限,如果频繁调用intern,就会使字符串常量池被挤爆,报出OutOfMemoryError
虚拟机栈/本地方法栈
在《Java虚拟机规范》中描述了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
- 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。
栈容量设置:-Xss
StackOverflowError异常测试
写一个斐波那契递归
package com.esperdemo.reflect;
/**
* @program: demo
* @description:
* @author: sirelly
* @create: 2020-09-22 10:55
**/
public class Feibonacci {
//F(0)=0, F(1)=1,当n>=2时,F(n) = F(n-1)+F(n-2)
public static int feibonacci(int n){
if(n == 0){return 0;}
if(n == 1){return 1;}
return feibonacci(n-1) + feibonacci(n-2);
}
public static void main(String[] args) {
System.out.println(feibonacci(0));
System.out.println(feibonacci(1));
System.out.println(feibonacci(2));
System.out.println(feibonacci(5));
}
}
这时值都比较小,可以打印出相应的值,当我们将值设为10000时,就会报出java.lang.StackOverflowError的异常
问题原因:当线程执行一个方法时,就会随之创建一个栈帧,并将创建的栈帧压入虚拟机栈中,当方法执行完毕之后就会将方法出栈,因此可知当前执行方法的栈帧位于虚拟机栈的顶部。而递归每执行一个方法就压一个栈帧到虚拟机栈,一旦超过一定深度就会报StackOverflowErrow
解决思路:限制递归次数或使用循环代替
本机直接内存
容量大小设置:-XX:MaxDirectMemorySize,如果不指定,则默认与Java堆最大值(-Xmx)大小一致
由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。
垃圾回收与算法

垃圾回收需要确定三件事
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
问什么有了垃圾回收机制还要关注垃圾回收?
当各种内存泄露,内存溢出等问题发生时,当垃圾收集称为系统达到更高并发的瓶颈时,我们就需要对垃圾回收进行监控和调节
如何确定对象为垃圾
引用计数算法
思想:判断对象的引用数量,来决定对象是否需要被回收
过程:在Java中,引用和对象时相关联的,如果要操作对象就需要引用。每个对象都有一个引用计数器,被引用+1,完成引用就-1。任何引用计数为0的对象实例都可以被当做垃圾回收。
当一个对象被创建时,实例分配给一个引用对象,该对象的引用计数器会被设置为1,被其他对象引用,又会加1,如果该对象的引用超过生命周期,如在某个方法内引用,方法结束后, 引用计数器就会减1。因为该引用变量是局部变量,存储在虚拟机栈上,方法结束后栈帧出栈。
结果:任何引用计数为0的对象实例都可以被当做垃圾回收
优点:执行效率高(只过滤引用计数器为0的对象),程序执行受影响小
缺点:无法检测出循环引用的情况,导致内存泄露(对象该被回收,但无法被回收,最终可能导致内存溢出)
循环引用的情况
package com.esperdemo.gc;
/**
* @program: demo
* @description:
* @author: sirelly
* @create: 2020-09-24 10:48
**/
public class MyObject {
public MyObject childNode;
}
package com.esperdemo.gc;
/**
* @program: demo
* @description:
* @author: sirelly
* @create: 2020-09-24 10:50
**/
public class ReferenceCounterProblem {
public static void main(String[] args) {
MyObject myObject1 = new MyObject();
MyObject myObject2 = new MyObject();
myObject1.childNode = myObject2;
myObject2.childNode = myObject1;
}
}
可达性分析算法
为了解决引用计数法的循环引用问题,Java使用了可达性分析算法。
思想:通过判断对象的引用链是否可达来判断对象是否可以被回收
过程:通过GC Root作为起始点开始遍历,如果在GC Root和一个对象之间没有可达的路径,则称该对象是不可达的,这里不可达对象不等于可回收对象,不可达对象变为可回收对象至少需要经历两次标记过程。两次标记后就面临回收

可作为GC Root的对象
- 虚拟机栈中引用的对象(栈帧中的本地变量表)
- 方法区中常量引用对象(比如类中定义了一个常量,而该常量保存的是某对象的引用地址)
- 方法区中类静态属性引用的对象,比如Java类的引用类型静态变量
- 本地方法栈中JNI(通常说的Natve方法)引用对象
- 所有被同步锁(synchronized关键字)持有的对象
垃圾回收算法
标记-清除算法(Mark-Sweep)
这种回收算法可分为标记和清除两个过程
- 标记:使用可达性算法从GC ROOT开始扫描,对可回收的对象进行标记
- 清除:对堆内存从头到尾进行线性遍历,回收不可达对象内存
缺点:
- 该算法最大的问题就是会内存碎片化严重,后续可能大对象找不到利用空间的问题,如果大对象找不到足够的连续空间,则会触发另一次垃圾收集
- 其次是执行效率不稳定,如果Java堆中包含大量对象,而且大部分是需要被回收的,则导致标记和清除两个过程执行效率随对象的增长而增长

复制算法(Copying)
为了解决标记-清理算法内存碎片化以及面对大量回收对象效率低而提出的算法。按内存容量将内存划分为两块,每次只使用其中一块,当一块内存存满后,将尚存活的对象复制到另一个内存块上,然后把已使用的内存清理掉
适用场景:适用于对象存活率低的场景(因为存活率高,复制效率就低了),比如年轻代,内存回收时不用考虑内存碎片化等复杂情况
优点:
- 解决了内存碎片化问题
- 顺序分配内存,简单高效
- 适用于对象存活率较低的场景(复制操作较少),比如您清代的回收
缺点:内存被压缩到了一半,且存活对象较多时,复制算法效率会大大降低。

标记-整理算法(Mark-Compact)
为了解决内存碎片化以及内存压缩等问题,提出标记-整理算法。该算法的标记阶段与Mark-Sweep相同,标记后不是清理对象,而是将存活的对象移向内存的一端,然后清除端边界外的对象。
- 标记:从GC Root开始扫描,对存活的对象进行标记
- 整理:移动所有存活对象,且按照内存地址次序依次排列存活对象,然后将末端内存地址以后的内存全部回收
优点
- 解决了内存碎片化的问题
- 解决了内存压缩的问题,不用设置两块内存互换
- 适用于存活率高的场景
缺点:需要移动元素,成本较高,尤其是老年代这种每次回收都有大量对象存活的区域,移动存活对 象并更新所有引用这些对象的地方是非常负重的操作,而且这种移动对象的操作必须暂停用户程序,即stop the world

分代收集算法(Generational Collector)
分代收集算法是目前大部分JVM所采用的方法,其核心思想是根据对象存活的不同生命周期划分内存区域,目的是提高JVM的回收效率
Java堆从GC的角度可细分为:新生代(Eden区,From Survivor区和To Survivor区)和老年代

JDK7以前
- 新生代(Young Generation)
- 老年代(Old Generation)
- 永久代(Permanent Space)
![]()
JDK8及以后
- 年轻代(Young Generation)
- 老年代(Old Generation)
GC的分类
-
部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集
- 新生代收集(Minor GC/Young GC)
- 老年代收集(Major GC/Old GC):Major GC可能在其他资料上指整堆收集
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集,目前只有G1收集器有
-
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
-
Minor GC:用于新生代GC
-
Full GC和 Major GC:用于老年代GC
年轻代与复制算法
由于年轻代对象存活率较低,通常采用复制Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,需要复制的操作较少
MinorGC过程(复制-清空-互换)
每次使用Eden和其中一块survivor区,当垃圾回收时,将存活的对象一次性复制到另一块survivor空间上,最后清理掉用过的Eden区和survivor区
-
Eden、ServivorFrom对象复制到ServivorTo,年龄+1
首先将Eden和ServivorFrom区域中存货的对象复制到ServivorTo区域(如果有对象的年龄达到了老年标准,则直接复制到老年区),同时把这些对象的年龄+1
-
清空Eden,ServivorFrom
复制完成后,将Eden区和ServivorFrom区的对象清空
-
ServivorTo和ServivorFrom互换
最后将ServivorTo和ServivorFrom互换,原ServivorTo成为下一次GC的ServivorFrom区
年轻代垃圾回收过程
比如Eden区最多能保存4个对象,survivor区最多能够保存3个对象
1.新生对象被放在Eden区,当Eden区被挤满后,会触发一次MinorGC,存活的对象会被复制到其中一块Survivor区,并将其年龄加1,然后清除Eden区
2.当第二次Eden区被挤满后,又会触发一次Minor GC,将S0和Eden区中存活的对象复制到S1中,并将其年龄加1,然后清空Eden和S0
3.如果Eden又满了,然后就需要将Eden区和S1的对象复制到S0,也即原来的SurvivorTo区变成了SurvivorFrom区
这样周而复始,对象在survivor区每熬过一次其年龄就加1,如果年龄超过一定值,一般是15岁,那么就进入老年区,也可以通过参数-XX:MaxTenuringThreshold来调整
什么情况进入老年代
- 经过一定MinorGC,年龄超过一定值,依然存活的对象
- Survivor区存放不下的对象
- 新生成的大对象(通过参数
-XX:+PretenuerSizeThreshold来调整对象大小超过的限度)
常用调优参数
- -XX:SurvivorRatio:Eden和Survivor区的比值,默认为8:1
- -XX:NewRatio:新生代和老年代内存大小比例(他们的总大小取决于堆的大小,即-Xms,-Xmx)
- -XX:MaxTenuringThreshold:对象从年轻代上升到老年代经历的GC次数的最大阈值
老年代与标记算法
由于老年代对象比较稳定,存活率较高,并且没有额外空间对他进行担保,因此采用标记-清理算法和标记-整理算法
老年代一般伴随Full GC和Major GC,由于老年代比较稳定,因此MajorGC不会频繁执行。在进行MajorGC前一般会进行一次MinorGC,使得新生代的对象晋升入老年代,导致老年代空间不够时触发。当无法找到足够大的连续空间分配给新创建的较大对象时页会提前触发一次MajorGC进行垃圾回收腾出空间。
MajorGC采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC耗时较长,因为要扫描再回收,当老年代也装不下时,就会抛出OOM(Out Of Memory)异常。
触发Full GC的条件
- 老年代空间不足
- JDK7以前,永久代空间不足
- Minor GC晋升到老年代的平均大小大于老年代剩余空间
- 调用System.gc()
- CMS GC时出现promotion failed,concurrent mode failure
GC垃圾收集器
垃圾回收算法是内存回收的方法论,垃圾收集器是内存回收的实践者
JDK1.6中HotSpot虚拟机的垃圾收集器如下(连线代表能搭配使用):

年轻代常见的垃圾收集器
Serial收集器(-XX:+UseSerialGC,复制算法)
是Java虚拟机中最基本,历史最悠久的垃圾收集器
特点:
- 单线程收集,采用复制算法,进行垃圾收集时,必须暂停所有工作流程
- 简单的高效,Client模式下默认的年轻代收集器,他是所有收集器里额外内存消耗最小的,没有线程交互开销

如何查看JVM的运行模式
JVM的运行模式分为Server和Client,可以通过
java -version查看当前JVM的运行模式
ParNew收集器(-XX:+UseParNewGC,复制算法)
他是Serial收集器的多线程版本,也使用复制算法,除了使用多线程之外,其余行为与Serial收集器一样,在垃圾收集过程中同样也要暂停所有其他工作的线程
特点
- 多线程收集,其余的行为,特点和Serial收集器一样
- 单核执行效率不如Serial(存在线程交互开销),在多核下执行才有优势
- 是很多Java虚拟机运行在Server模式的新生代默认垃圾收集器

垃圾收集器的并行和并发
- 并行(Parallel):指同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
- 并发(Concurrent):指同一时间垃圾收集器线程与用户线程都在运行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。
Parallel Scavenge收集器(-XX:+UseParallelGC,复制算法)
系统吞吐量:吞吐量 = 运行用户代码的时间/(运行用户代码的时间 + 垃圾收集时间)
特点
- 多线程收集器,比起关注用户线程的停顿时间(良好的响应速度,提升用户体验,适合多交互任务的情况),更关注系统可控制的吞吐量(高吞吐量可以高效利用CPU,吞吐量越高,垃圾收集时间越少,适合交互任务较少,长时间在后台运行的情况)
- 在多核下执行才有优势,Server模式下默认的年轻代收集器
- 自适应调节策略
自适应调节策略:通过-XX:+UseAdaptiveSizePolicy开启,开启自适应调节后,我们通过-XX:MaxGCPauseMillis参数(关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设定一个优化目标,就可以不用人工指定新生代大小(-Xmn),Eden与Survivor区的比例(-XX:SurvivorRatio),晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会自动调节参数提供最合适的停顿时间或最大吞吐量
Parallel Scavenge收集器使用两个参数控制吞吐量:
- XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
- XX:GCRatio 直接设置吞吐量的大小。

直观上,只要最大的垃圾收集停顿时间越小,吞吐量是越高的,但是GC停顿时间的缩短是以牺牲吞吐量和新生代空间作为代价的。比如原来10秒收集一次,每次停顿100毫秒,现在变成5秒收集一次,每次停顿70毫秒。停顿时间下降的同时,吞吐量也下降了。
老年代常见的垃圾收集器
Serial Old收集器(-XX:+UseSerialOldGC,标记-整理算法)
特点:
- 单线程收集,进行垃圾收集时,必须暂停所有工作线程
- 简单高效,Client模式下默认的老年代收集器

Parallel Old收集器(-XX:+UseParallelOldGC,标记-整理算法)
Parallel Old收集器是Parallel Scavenge的老年代版本,在JDK1.6开始提供。如果系统对吞吐量的要求比较高,可以考虑新生代Parallel Scavenge和老年代Parallel Old收集器搭配的策略。
特点:多线程,吞吐量优先

CMS收集器(- XX:+UseConcMarkSweepGC,标记清除算法)
CMS收集器即Concurrent mark sweep收集器,主要目的是获取最短的垃圾回收停顿时间,与其他老年代使用标记-整理算法不同,他使用多线程的标记-清除算法
这款收集器是HotSpot虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。
特点:
- 几乎能与用户线程做到同时工作
- 最短垃圾回收停顿时间可以提高程序的交互性
- 适合较多存活的对象
垃圾回收过程:
- 初始标记:stop-the-world,暂停正在执行的业务,仅仅标记GC Roots能直接关联到的对象
- 并发标记:并发追溯标记,程序不会停顿,从GC Roots的直接关联对象开始遍历整个对象图的过程
- 重新标记:暂停虚拟机,修正并发标记时间内,因用户程序继续运作而导致标记变动的一部分对象的标记记录
- 并发清理:清理垃圾对象,程序不会停顿,因为不需要移动对象

Garbage First(G1)收集器(-XX:+UseG1GC,复制+标记-整理算法)
Garbage First垃圾收集器开创了收集器面向局部收集的设计思路和基于Region的内存布局形式,相比于CMS收集器,G1收集器两个最突出的改进是:
- 基于标记整理算法,不产生内存碎片
- 可以非常精准的控制停顿时间,在不牺牲吞吐量的前提下,实现低停顿垃圾回收
G1是一个面向全堆的收集器,不再需要其他新生代收集器的配合工作
G1收集器G1不再坚持固定大小以及固定数量的分代区域划分,它把堆内存划分为大小固定的几个独立区域Region,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,确保G1收集器可以在有限时间获得最高的垃圾收集效率。
特点:
- 可预测的停顿(将Region作为单次回收的最小单元,每次收集到的内存空间都是Region大小的整数倍)
- 将整个Java堆内存划分为多个大小相等的Region
- 优先级区域回收机制
- 不再坚持固定大小以及固定数量的分代区域划分
缺点:垃圾收集产生的内存占用(Footprint)和程序运行时的额外执行负载(Overload)较高
四种引用类型
JDK1.2之前的引用描述:reference类型的数据中存储的数值代表另外一块内存的起始地址,就称该reference数据代表某块内存,某个对象的引用。这种描述方式不能描述其他更精细化的对象,如当内存空间足够时,能够保留在内存中,当内存空间在垃圾回收后很紧张,则可以抛弃这些对象
JDK1.2之后,Java将引用分为下面4个部分
强引用
Java中最常见的就是强引用,他是传统引用的定义,把一个对象赋值给一个引用变量,这个引用变量就是一个强引用,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回
收掉被引用的对象,即使该对象以后都不会被使用,JVM也不会回收,因此强引用是造成Java内存泄露的最要原因之一
软引用
软引用需要用SorftReference类来实现,对于只有软引用的对象来说,当系统足够时不会被回收,内存不足时被回收
弱引用
弱引用需要用WeakReference类来实现,它比软引用的生命周期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管JVM内存空间是否足够,都会回收该对象占用的内存。
虚引用
虚引用需要PhantomReference类来实现,他不能单独使用,必须和引用队列联合使用,其唯一目的是为了能够在对象被收集器回收时受到一个系统通知。
JVM类加载机制
Class文件中描述了各类信息,最终都需要加载到虚拟机中之后才能被运行和使用
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称为虚拟机的类加在机制。与其他在编译时需要进行连接的语言不同,Java语言的类加载,连接和初始化过程都是在程序运行期间完成的
类加载过程
一个类从被加载到虚拟机内存到卸载出内存,类的生命周期分为加载,连接,初始化,使用,卸载几个部分。

加载
ClassLoader根据一个类的全限定类名加载Class文件字节码到内存中,将静态数据转换为运行时类型数据,生成一个代表类的java.lang.Class对象,这个对象作为程序访问方法区中类型数据的外部接口
连接
连接分为验证,准备,解析三个部分
验证
检查加载的Class文件的正确性和安全性,是否符合当前虚拟机的要求,需保证这些信息被当做代码后不会危害虚拟机自身的安全。
准备
为类变量(static变量)分配存储空间并设置类变量初始值,即在方法区中分配这些变量所使用的内存空间
这里所说的初始值是变量初始值,不是赋值,比如定义一个类变量
public static int v = 300;那么这里设置的初始值是0
此外,如果类字段是常量final类型,则准备阶段的变量将值直接设置为初始值如
public static final int v = 300;此时在准备阶段虚拟机就会将v设置为300
解析
JVM将常量池中的符号引用转换为直接引用
当第一次运行时,要根据符号引用即字符串的内容,在该类方法表中找到这个方法,运行一次后,符号引用会被替换为直接引用,下次就不用再搜索了,直接引用就是偏移量,通过偏移量虚拟机可以直接在该类的内存区域中找到方法字节码的初始位置。
符号引用
符号引用是以符号来描述引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。 各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
直接引用
直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用和虚拟机的布局是相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经被加载到了内存中了。
初始化
初始化是类加载的最后一个阶段,他执行类变量赋值和静态代码块
初始化就是执行类构造器
类加载器
ClassLoader类加载器再Java中意义重大,他主要工作在Class的类装载的加载阶段,其主要作用是从外部获得Class二进制数据流,然后交给JVM进行连接和初始化等操作。
类加载器的种类
- BootStrap ClassLoader:C++编写,是虚拟机的一部分(其他类加载器在外部,并继承ClassLoader),加载核心库java.*,负责加载JAVA_HOME\lib目录中的,或通过-Xbootclasspath参数指定路径中的
- Extension ClassLoader:Java编写,加载扩展库 javax.*,负责加载JAVA_HOME\lib\ext目录中的,或通过java.ext.dirs系统变量指定路径中的类库。
- Application ClassLoader:Java编写,负责加载类路径classpath路径上所有类库
- 自定义 ClassLoader:Java编写,定制化类加载,通过继承java.lang.ClassLoader实现自定义的类加载器
实现自定义ClassLoader的实现
两个重要的函数
- findClass(String name) ----寻找class文件,并将class的二进制数据读取出来,传给下一步的defineClass
package com.esperdemo.reflect;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
/**
* @program: demo
* @description:
* @author: sirelly
* @create: 2020-09-21 20:05
**/
public class MyClassLoader extends ClassLoader{
private String path;
private String classLoaderName;
public MyClassLoader(String path, String classLoaderName){
this.path = path;
this.classLoaderName = classLoaderName;
}
//用于寻找类文件
@Override
public Class findClass(String name){
byte[] b = loadClassDate(name);
return defineClass(name,b,0,b.length);
}
//用于加载类文件
private byte[] loadClassDate(String name) {
name = path + name + ".class";
InputStream in = null;
ByteArrayOutputStream out = null;
try{
in = new FileInputStream(new File(name));
out = new ByteArrayOutputStream();
int i = 0;
while((i = in.read())!= -1){
out.write(i);
}
}catch (Exception e){
e.printStackTrace();
}finally {
try {
out.close();
in.close();
}catch (Exception e){
e.printStackTrace();
}
}
return out.toByteArray();
}
}
package com.esperdemo.reflect;
import java.sql.SQLOutput;
/**
* @program: demo
* @description:
* @author: sirelly
* @create: 2020-09-21 20:26
**/
public class ClassLoaderCheck {
public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
MyClassLoader myClassLoader = new MyClassLoader("C:\\Users\\Administrator\\Desktop\\", "myClassLoader");
Class<?> robot = myClassLoader.loadClass("Robot");
System.out.println(robot.getClassLoader());
robot.newInstance();
}
}
JVM通过双亲委派模型进行类的加载
双亲委派机制
双亲委派模型即各种类加载器之间的层次关系
当一个类加载器收到类加载的请求,他首先不会去尝试加载这个类,而是递归地把这个请求委派给父类加载器完成。当父加载器找不到指定的类时,子加载器尝试自己加载。
为什么要双亲委派机制
因为不用的ClassLoader负责加载的路径和方式有所不同,为了实现分工,各自负责各自的部分,使得逻辑更加明确,所以在类加载过程中,会出现一个机制去让他们相互协作,形成一个整体,这个机制就是双亲委派机制,使用双亲委派机制能够避免一个类被重复加载,防止内存中存在多分同样的的字节码,也能够避免系统类被修改。
双亲委派机制过程:先查看加载的类型是否被加载过,若加载过则直接返回Class对象,否则递归调用父加载器的loadClass,如果直到最上层都没加载过此类,则从最上层开始查找路径是否能够加载此类,如果不能加载此类则委派给下一层去查找是否能够加载,最后直到最下层,如果都无法加载此类,则抛出异常classNotFund
程序源码
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
// 首先,检查请求的类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
//若没有加载,递归向上查看父类是否加载
c = parent.loadClass(name, false);
} else {
//到达BootstrapClassLoader查看是否加载此类
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载时
// 再调用本身的findClass方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
为什么说Custom ClassLoader的上层就是AppClassLoader
通过类的getClassLoader().getParent()可以查看


双亲委派机制的好处:
避免一个类被重复加载,也即防止内存中存在多份同样的的字节码
比如 如果有人想替换系统级别的类String.class,篡改他的实现,但在这种机制下这些系统的类已经被BootStrap classLoader加载过了,所以不会再去加载,从一定程度上防止了危险代码的植入



浙公网安备 33010602011771号