目录
1、JVM内存划分
1.1、程序计数器(Program Counter Register)
1.2、方法区(Method Area)
1.3、本地方法栈(Native Method Stacks)
1.4、虚拟机栈(JVM Stacks)
1.5、Java堆(Heap)
2、JVM类加载
2.1、加载过程
2.2、类加载流程
2.3、类加载器【双亲委派模型】
3、JVM的垃圾回收
3.1、如何确定对象已死
3.1.1、回收哪些内存
3.1.2、基于引用计数找垃圾(Java不采取该方案)
3.1.3、基于可达性分析找垃圾(Java采取方案)
3.2、垃圾回收算法
3.3.1标记-清除算法
3.3.2标记-复制算法
3.3.3标记-整理算法
3.3.4分代回收
3.3、垃圾收集器
1)、Serial收集器
2)、ParNew收集器
3)、CMS收集器
4)、G1收集器(唯一一款全区域的垃圾回收器)
1、JVM内存划分
JVM区域包括以下几个:程序计数器,栈,堆,方法区,图中的元数据区可以理解为方法区 【1.8之后搞了个元数据区,在这之前都是方法区的】,毕竟JVM有很多版本,不同版本的运行时区域划分也不一样。
1.1、程序计数器(Program Counter Register)
它的作用就是记录当前线程所执行的位置。 这样,当线程重新获得CPU的执行权的时候,就直接从记录的位置开始执行,分支、循环、跳转、异常处理也都依赖这个程序计数器来完成。
1.2、方法区(Method Area)
方法区也是一块被重点关注的区域,主要特点如下:
-
线程共享区域,因此这是线程不安全的区域。
-
它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
-
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
1.3、本地方法栈(Native Method Stacks)
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。
虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
1.4、虚拟机栈(JVM Stacks)
-
Java虚拟机栈是线程私有的,每一个线程都有独享一个虚拟机栈,它的生命周期与线程相同。
-
虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
-
存放基本数据类型(boolean、byte、char、short、int、float、long、double)以及对象的引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
-
这个区域可能有两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩,当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。
1.5、Java堆(Heap)
栈管运行,堆管存储。则虚拟机栈负责运行代码,而虚拟机堆负责存储数据。
Java堆区具有下面几个特点:
-
存储的是我们new来的对象,不存放基本类型和对象引用。
-
由于创建了大量的对象,垃圾回收器主要工作在这块区域。
-
线程共享区域,因此是线程不安全的。
-
能够发生OutOfMemoryError。
其实,Java堆区还可以划分为新生代和老年代,新生代又可以进一步划分为Eden区、Survivor 1区、Survivor 2区
2、JVM类加载
2.1、加载过程
如果JVM想要执行这个.class文件,我们需要将其装进一个类加载器中,它就像一个搬运工一样,会把所有的.class文件全部搬进JVM里面来
知识点:
- Java文件经过编译后变成了.class字节码文件
- 字节码文件通过类加载器被搬运到JVM虚拟机中
- 虚拟机主要的5大部分:
方法区、堆都是线程共享区域,有线程安全问题;栈、本地方法栈、计数器都是线程独享区域,不存在线程安全问题;而JVM的调优主要是围绕堆、栈两大块进行的。
- 总结来看,JVM主要通过分为以下4个部分,来执行Java程序的,分别是:类加载器(ClassLoader)、运行时数据区(Runtime Data Area)、执行引擎(Execution Engine)、本地库接口(Native Interface)
2.2、类加载流程
类加载的过程包括了加载、验证、准备、解析、初始化5个阶段。在这5个阶段中,加载、验证、准备和初始化这4个阶段发生的顺序是确定的,而解析阶段就不一定了,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始的,而不是按顺序进行或完成的,因为这些阶段通常都是互相交叉的混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段
- 加载:查找并加载类的二进制数据;在Java堆中也创建一个java.lang.Class类的对象,作为方法区这个类的各种数据的访问入口;
- 连接:连接包含了三个部分:验证、准备、初始化
- 验证:文件格式、元数据、字节码、符号引用验证
- 准备:为类的静态变量分配内存,并将其初始化为默认值【例:public static int a = 12;它的默认初始值为0而不是12】
- 解析:把类中的符号引用转换为直接引用,也就是初始化常量的过程
- 初始化:为类的静态变量赋予正确的初始值,初始化阶段就是执行类构造器方法的过程
这里真正的对类对象进行初始化,特别是静态成员。类加载过程中是在执行某方法(如main方法)之前执行的,类加载的时候会进行静态代码块的执行,想要创建实例,必然先得类加载,静态代码块只会执行一次,构造方法与普通代码块每次实例对象都会执行,并且普通代码块比静态代码块先执行
所以可以得到结论,静态的代码块,普通代码块,构造方法执行顺序为:静态代码块--普通代码块--构造方法
- 使用:new出对象程序中使用
- 卸载:执行垃圾回收
2.3、类加载器【双亲委派模型】
双亲委派模型是类加载中的一个环节,属于Loading阶段,它是描述如何根据类的全限定名找到class文件的过程。
在JVM里面提供了一组专门的对象,用来进行类的加载,即类加载器,当然既然双亲委派模型是类加载中的一部分,所以其所描述找.class文件的过程也是类加载器来负责的
但是想要找全class文件可不容易,毕竟.class文件可能在jdk目录里面,可能在项目的目录里面,还可能在其他特定的位置,因此JVM提供了多了类加载器,每一个类加载负责在一个片区里面找,毕竟分工明确,才能事半功倍
默认的类加载器主要有三个:
- BootStrapClassLoading:负责加载标准库里面的类,如:String,Random,Scanner等
- ExtensionClassLoader:负责加载JDK扩展的类,现在基本上很少使用了
- ApplicationClassLoader:负责加载当前项目目录中的类
除了默认的几个类加载器,程序员还可以自定义类加载器,来加载其他目录的类,如Tomcat就自定义了类加载器,用来专门加载webapp目录中的.class文件,但是自定义的类加载器未必要遵守双亲委派模型,毕竟你在自己特定的目录下还没有找到对应的.class文件,再去标准库去找基本也是未果,Tomcat中的自定义的类加载器就没有遵守双亲委派模型
而双亲委派模型就描述了类加载过程中的找目录的环节,它的内容如下:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。
因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(去自己的片区搜索)。
举两个栗子:第一个,我们要去找标准库里面的String.class文件,它的过程大致如下:
- 首先ApplicationClassLoader类收到类加载请求,但是它先询问父类加载器是否加载过,即询问ExtensionClassLoader类是否加载过。
- 如果ExtensionClassLoader类没有加载过,请求就会向上传递到ExtensionClassLoader类,然后同理,询问它的父加载器BootstrapClassLoader是否加载过。
- 如果BootstrapClassLoader没有加载过,则加载请求就会到BootstrapClassLoader加载器这里,由于BootstrapClassLoader加载器是最顶层的加载器,它就会去标准库进行搜索,看是否有String类,我们知道String是在标准库中的,因此可以找到,请求的加载任务完成,这个过程也就结束了。
第二个栗子,我要加载搜索项目目录中的Test类,过程如下:
- 首先ApplicationClassLoader类收到类加载请求,但是它先询问父类加载器是否加载过,即询问ExtensionClassLoader类是否加载过。
- 如果ExtensionClassLoader类没有加载过,请求就会向上传递到ExtensionClassLoader类,然后同理,询问它的父加载器BootstrapClassLoader是否加载过。
- 如果BootstrapClassLoader没有加载过,则加载请求就会到BootstrapClassLoader加载器这里,由于BootstrapClassLoader加载器是最顶层的加载器,它就会去标准库进行搜索,看是否有Test类,我们知道Test类不在标准库,所以会回到子加载器里面搜索。
- 同理,ExtensionClassLoader加载器也没有Test类,会继续向下,到ApplicationClassLoader加载器中寻找,由于ApplicationClassLoader加载器搜索的就是项目目录,因此可以找到Test类,全过程结束。
当然,如果在ApplicationClassLoader还没有找到,就会抛出异常。
总的来说,双亲委派模型就是找class
文件的过程,只是名字挺高大上的,其实也没啥。
双亲委派模型的优点:
- 当自定义类与标准库中的类重名时,一定会加载标准库中的那个类,保证了Java的核心API不会被篡改。
- 使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一。
- 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A类 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了。
3、JVM的垃圾回收
3.1、如何确定对象已死
3.1.1、回收哪些内存
我们知道我们运行时,内存分为四个区,分别是程序计数器,栈,堆,方法区。
对于程序计数器,它占据固定大小的内存,不涉及释放,那么也就用不到GC;对于栈空间,函数执行完毕,对应的栈帧自动释放了,也不需要GC;对于方法区,主要进行类加载,虽然需要进行"类卸载",此时需要释放内存,但是这个操作的频率是非常低的;最后对于堆空间,经常需要释放内存,是最需要进行GC的。
在堆空间,内存的分布有三种,一是正在使用的内存,二是未使用且未回收的内存,三是未分配的内存,那内存中的对象,也有三种情况,对象内存全部在使用(相当于对象整体全部在使用),对象的内存部分在使用(相当于对象的一部分在使用),对象的内存不使用(对象也就使用完毕了),对于这三类对象,前两类不需要回收,最后一类需要回收。
所以,垃圾回收的基本单位是对象,而不是字节,对于如何找到垃圾,常用有引用计数法与可达性分析法两种方式。
3.1.2、基于引用计数找垃圾(Java不采取该方案)
所谓基于引用计数判断垃圾,就是给每一个对象带上一个计数器,来记录该对象被多少个引用变量所指,如果这个计数器的值为0
则表示该对象需要回收,比如有一个Test对象,它被两个引用所指,所以这个Test对象所带计数器的值就是2
。
如果上述的伪代码是在一个方法中,待方法执行完毕,方法中的局部引用变量被销毁,那么Test对象的引用计数变为0,此时就会被回收。
由此可见,基于引用计数的方案非常简单高效并且可靠,但是它拥有两个致命缺陷:
- 当对象的内存大小较小时,由于每个对象都需要一个计数器,因此空间利用率较低。
- 存在循环引用的问题,会出现对象既不使用也不释放的情况。
第一点好理解,就是当计数器与对象所需内存差不多的情况下,空间利用率只有50%,并不高。
第二点,难理解一点,下面我们来举例子进行分析。
class Test {
Test t = null;
}
//main方法中:
Test t1 = new Test();
Test t2 = new Test();
t1.t = t2;
t2.t = t1;
执行上述伪代码,运行时内存图如下:
然后,我们把变量t1
与t2
置为null
,伪代码如下:
//伪代码:
t1 = null;
t2 = null;
执行完上面伪代码,运行时内存图如下:
根据图可知,两个对象的属性相互指向另一个对象,使得计数器的值都为1
,由于对象外界没有指向这两个对象的引用,于是这两个对象处于既不被使用,也不被释放的尴尬场景当中,这就是循环引用问题。
3.1.3、基于可达性分析找垃圾(Java采取方案)
所谓可达性分析就是通过额外的线程,对整个内存空间的对象进行扫描。但扫描不是盲扫,先会找到一些起始位置GCRoots,从该位置开始,以类似深度优先搜索的方式去找对象,它会对所有可以找到的对象进行标记,没有找到的对象自然也就没有标记,那这部分有标记的对象是可达的,未被标记的对象是不可达的,未被标记的对象就会被JVM视为垃圾进行回收。
对于这个GCRoots,它来自:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,例如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- 常量池中引用所指向的对象。
- 方法区中静态成员所指向的对象。
- 所有被同步锁(synchronized关键字)持有的对象。
3.2、垃圾回收算法
垃圾回收的算法最常见的有以下几种:
标记-清除算法
标记-复制算法
标记-整理算法
分代回收算法(本质就是综合上述算法,在堆的不同区采取不同的策略)
3.3.1标记-清除算法
标记其实就是可达性分析的过程,在可达性分析的过程中,会标记可达的对象,其他 不可达的对象,都会被视为垃圾进行回收。
比如经过一轮标记后,标记状态如图:
回收后,内存状态如图:
我们发现,内存是释放了,但是回收后,未分配的内存空间有点“散”啊,我们知道申请内存的时候得到的内存是连续的,所以说,由于未分配的内存是碎片化的,假设你的主机有1GB为分配的内存,但是这些内存是碎片形式存在的,当申请500MB内存的时候,可能会申请失败,毕竟不能保证有一块大于500MB的连续内存空间,这也是标记-清除算法的缺陷。
3.3.2标记-复制算法
为了解决标记-清除算法所带来的内存碎片化的问题,引入了复制算法。
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况。
复制算法的第一步还是要通过可达性分析进行标记,得到那一部分需要进行回收,那一部分需要保留,不能回收。
标记完成后,会将还在使用的内存连续复制到另外一块等大的内存上,这样得到的未分配内存一直都是连续的,而不是碎片化的。
回收后,复制算法得到的未分配空间是连续的,完美地弥补了清除算法的缺陷。
但是,复制算法也有缺陷:
- 空间利用率低。
- 如果可回收的内存少,需保留的内存大,复制的开销也大。
3.3.3标记-整理算法
标记-整理算法针对复制算法做出进一步改进。其中的标记过程仍然与“标记-清除”算法一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
标记回收前的内存图:
然后,将存活对象按照某一顺序(比如从左到右,从上到下的顺序)拷贝到非存活对象的内存区域,得到了以下回收后的内存分布图:
解决了标记-复制算法空间利用率低的问题,但是复制的开销问题并没有得到解决。
3.3.4分代回收
上述的回收算法都有缺陷,分代回收就是将上述三种算法结合起来分区使用,分代回收会针对对象进行分类,以熬过的GC扫描轮数作为“年龄”,然后针对不同年龄采取不同的方案。
我们知道GC主要是回收堆上的无用内存,我们先来了解一下堆的划分,堆包括新生代(Young)、老年代(Old),注意这个名称不是固定的,在IBM J9虚拟机中对应称为婴儿区(Nursery)和长存区(Tenured),名字不同但其含义是一样的。而新生代包括一个伊甸区(Eden)与两个幸存区(Survivor,分代回收算法就会根据不同的代去采取不同的标记-xx算法。
在新生代,包括一个伊甸区与两个幸存区,伊甸区存储的是未经受GC扫描的对象,也就是刚刚创建的对象。
幸存区存储了经过若干轮存储的对象,通过实际经验得出,新生代的对象具有“朝生夕灭”的特点,也就是说只有少部分的伊甸区对象才能熬过第一轮的GC扫描,所以到幸存区的对象相比于伊甸区少的多,正因为大部分新生代的对象熬不过JVM第一轮扫描,所以伊甸区与幸存区的分配比例并不是1:1的关系,HotSpot虚拟机默认一个Eden和一个Survivor的大小比例是8∶1,正因为新生代的存活率较小,所以新生代使用的垃圾回收算法为标记-复制算法最优,毕竟存活率越小,对于标记-复制算法,复制的开销也就很小。
不妨我们将第一个Survivor称为活动空间,第二个Survivor称为空闲空间,一旦发生GC,将10%的活动区间与另外80%中存活的对象复制到10%的空闲空间,接下来,将之前90%的内存全部释放,以此类推。
在后续几轮GC中,幸存区的对象在两个Survivor中进行标记-复制算法。
在继续持续若干轮GC后,幸存区的对象就会被转移到老年代,老年代中都是年龄较老的对象,根据经验,一个对象越老,继续存活的可能性就越大,因此老年代的GC扫描频率远低于新生代,所以老年代采用标记-整理的算法进行内存回收,毕竟老年代存活率高,对于标记-整理算法,复制转移的开销很低。
3.3、垃圾收集器
1)、Serial收集器
这一部分,我们了解即可,首先有请历史最悠久的Serial收集器(新生代收集器,串行GC)与 Serial Old收集器(老年代收集器,串行GC)登场,这两类收集器前者是新生代收集器,后者是老年代收集器,采用串行GC的方式进行垃圾收集,由于串行GC开销较大,会产生较严重的STW。
STW是什么?
Stop The World (STW),你可以理解为你打游戏的时候,你的xxx来干xxx,使得你不得不中断游戏,这段中断的时间就相当于STW,或者你理解为由于设备原因使得你打游戏很卡,这些卡顿的时间就是STW。
2)、ParNew收集器
然后就是 ParNew收集器(新生代收集器,并行GC),Parallel Scavenge收集器(新生代收集器,并行GC),Parallel Old收集器(老年代收集器,并行GC),前两个是新生代的收集器,最后一个是老年代的收集器,这组收集器引入了多线程,并发情况下,GC处理效率相比于前一组更高,但是如果在单线程情况下,可能不会比Serial收集器要好,此外,Parallel Scavenge收集器相比于 ParNew收集器只是多了些参数而已。
3)、CMS收集器
CMS收集器,该收集器设计的初衷是尽量使得STW时间尽量地短, 特点:
- 初始标记,过程速度很快,只是找到GCRoots,只会引起短暂的STW。
- 并发标记,虽然速度很慢,但是它可以和业务线程并发执行,不会产生STW。
- 重新标记,在并发标记过程中,业务代码可能会改变标记的结果,需要进行一次微调,由于是微调,引起的STW很短。
- 回收内存,也是和业务代码并发。
前三部分的标记过程就是将可达性分析给拆开了,回收内存主要用于标记-整理,老年代专属。
4)、G1收集器(唯一一款全区域的垃圾回收器)
G1收集器,它把内存分为分成了很多的小区域(Region),并且给这些Region做了标记,有些Region放新生代对象,有些Region放老年代对象。
GC扫描的时候,只扫描一部分Region,不追求一次扫描完,分多次来扫描,这样对业务代码执行影响更小。
G1收集器可以优化STW的时间小于1ms。本质上CMS与G1都是化整为零的思想。
最后,做个小总结,垃圾回收本质靠运行时环境,来帮助程序员完成内存释放的工作,但是它有以下缺点:
- 产生额外的开销。
- 可能会影响程序的流畅运行(STW造成)
下期见!!!