天天看点

JavaEE核心「JVM 的作用、类加载器、JVM内存模型、GC回收机制」

作者:XII点 Java

1、JVM的作用

JVM是Java Virtual Machine的缩写。我们安装的JDK中包含了JRE,在JRE中,包含了java的虚拟机和核心类库,如果想要运行java程序,则需要上述的JRE环境。

java是一门高级程序语言,直接运行在硬件上并不现实,所以要在运行之前,需要对其进行一些转换。

转换过程:通过编译器将java程序转换成虚拟机能识别的指令序列,也叫做java字节码。java虚拟机会将字节码文件(.class文件)加载到JVM中,由JVM进行解释和执行。

JVM运行在操作系统之上,与硬件没有直接的交互。

JavaEE核心「JVM 的作用、类加载器、JVM内存模型、GC回收机制」

2、类加载器

类加载器(ClassLoad),负责加载class文件,经过类加载器加载并初始化之后,会得到Class,实例化对象的时候会参考Class。如Person.class经过ClassLoad加载初始化后,会得到Person Class,之后每次实例化对象的时候,都会参考Person Class。

类加载器的分类

启动类加载器(Bootstrap):主要负责加载jre中的最为基础、最为重要的类。如$JAVA_HOME/jre/lib/rt.jar等。它由C++代码实现,没有对应的java对象,因此在java中,尝试获取此类时,只能使用null来指代。

扩展类加载器(Extension):由Java代码实现,用于加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类。

应用程序类加载器(AppClassLoad):由Java代码实现, 它负责加载应用程序路径下的类。比如Person.java,Car.java等程序员自定义的类。

用户自定义加载器。

其中Bootstrap是Extension的父类加载器,Extension是AppClassLoad的父类加载器。

双亲委派机制

每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器(在此加载器中如果还有父类加载器,依旧向上请求)。在父类加载器没有找到所请求的类的情况下,该类加载器才会向下尝试去加载。在AppClassLoad中仍然没有找到所请求的类,会抛出ClassNotFound异常。当子类和父类同时有相同的类时,由双亲委派机制决定,会优先使用父类的。

好处:

通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次

防止java核心api中定义类型不会被用户恶意替换和篡改,从而引发错误。

3、JVM内存模型

JavaEE核心「JVM 的作用、类加载器、JVM内存模型、GC回收机制」

(1)Execution Engine:执行引擎(Execution Engine)负责解释命令,提交操作系统执行。

当JVM需要调用系统的硬件时,如CPU,硬盘等,需要向操作系统发送命令,但命令操作系统无法理解,这时就需要Execution Engine负责将命令解释并提交给操作系统。

(2)Native Method Stack:本地方法栈( Native Method Stack)在每个操作系统内部,都定义了很多本地方法库。这些本地方法库中,定义了很多调用本地操作系统的方法,也称之为本地方法接口。

当需要执行Native方法时,需要将Native方法压入Native Method Stack,然后向操作系统发送指令,交给执行引擎(Execution Engine) 解释命令,之后调用本地方法接口(Native Interface),调用本地方法接口时又会用到本地方法库。

(3)Program Counter Register:程序计数器,也叫PC寄存器,是一个小指针,提示下一个需要执行栈内的哪一个方法。

(4)Method Area:方法区(Method Area),方法区是被所有线程共享,所有定义的方法的信息都保存在该区域,此区属于共享区间。

静态变量 + 常量 + 类信息(构造方法/接口定义) + 运行时常量池存在方法区中。

(5)Stark:栈,栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就结束,生命周期和线程一致,是线程私有的。

8种基本类型的变量(byte,short,int,double,float,long,char,bool)+ 对象的引用变量 + 实例方法都是在栈内存中分配。

在栈区域规定了两种异常状态:如果线程请求的栈深度大于虚拟机所允许的深度,则抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,在扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

栈帧:一个线程的每个方法在调用时都会在栈上划分一块区域,用于存储方法所需要的变量等信息,这块区域称之为栈帧(stack frame)。栈由多个栈帧构成,好比一部电影由多个帧的画面构成。

栈运行原理:栈中的数据都是以栈帧(Stack Frame)为载体存在。在栈中,方法的调用顺序遵循“先进后出”/“后进先出”原则。

(6)堆heap

堆的逻辑设计:堆是java虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。堆内存的大小是可以调节的(通过 -Xmx 和 -Xms 控制),一般为物理内存的1 / 64 ,最大为物理内存的1 / 4。在逻辑上可以划分为三部分,新生区(Young Generation Space)、养老区(Tenure generation space)、永久区(Permanent Space),永久区在JDK1.8后为元空间。

JavaEE核心「JVM 的作用、类加载器、JVM内存模型、GC回收机制」

堆的物理设计:在 Java 中,堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。

JavaEE核心「JVM 的作用、类加载器、JVM内存模型、GC回收机制」

新生代分为eden区、from和to区,他们是两块大小相等并且可以互换角色的空间(当进行轻量级垃圾回收后仍然存在对象的区称为from区)。绝大多数情况下,新new出来的对象首先分配在eden区,在新生代回收后,如果对象还存活(被引用),则进入from或to区,之后每经过一次新生代回收,如果对象存活则它的年龄就加1,对象达到一定的年龄(默认15)后,则进入老年代,否则进入to区。在新生代进行垃圾回收使用的是轻量级垃圾回收(Minor GC),在老年代中进行的是重量级的垃圾回收(Major GC),当在老年代中无法进行垃圾回收,会触发OOM(OutOfMemory)。

永久区perm:永久存储区是一个常驻内存区域,用于存放JDK自身所携带的 Class,Interface 的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会轻易被垃圾回收器回收掉的,关闭 JVM 才会释放此区域所占用的内存。

Jdk1.6及之前: 有永久代, 字符串常量池1.6在方法区

Jdk1.7: 有永久代,但已经逐步“去永久代”,字符串常量池1.7在堆

Jdk1.8及之后: 无永久代,用元空间替代

4、GC

JVM中的Garbage Collection,简称GC,它会不定时去堆内存中清理不可达(没有被引用)对象。

GC分类:

JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。因此GC按照回收的区域又分了两种类型,一种是普通GC(minor GC),一种是全局GC(major GC or Full GC),

   新生代GC(minor GC):只针对新生代区域的GC。

老年代GC(major GC or Full GC):针对老年代的GC,偶尔伴随对新生代的GC以及对永久代的GC。

Minor GC触发机制:当年轻代满时就会触发Minor GC,这里的年轻代满指的是Eden区满,Survivor满不会引发GC。

Full GC触发机制:当年老代满时会引发Full GC,Full GC将会同时回收年轻代、年老代,当永久代满时也会引发Full GC,会导致Class、Method元信息的卸载。

GC工作特点:理论上GC过程中会频繁收集Young区,很少收集Old区,基本不动Perm区(元空间/方法区)。

标记不可达对象的引用计数法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器值减1.任何时刻计数器值为0的对象就是不可能再被使用的。引用计数法就是如果一个对象没有被任何引用指向,则可视之为垃圾。这种方法的缺点就是不能检测到循环指向的存在。主流的Java虚拟机里面都没有选用引用计数算法来管理内存。

标记不可达对象的可达性分析(GC Roots算法):根搜索算法的基本思路就是通过一系列名为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

垃圾回收的三种方式:

清除:把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。清除这种回收方式的原理及其简单,但是有两个缺点。一是会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。另一个则是分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java 虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。

压缩:把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。

复制:把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即堆空间的使用效率极其低下。复制必交换,谁空谁为to。

总结:回收死亡对象的内存共有三种方式,分别会造成内存碎片的清除、性能开销较大的压缩、以及堆使用效率较低的复制。当然,现代的垃圾回收器往往会综合上述几种回收方式,综合它们优点的同时规避它们的缺点。

垃圾回收四大算法:

标记复制(Mark-Copying)算法:

当我们调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。当 Eden 区的空间耗尽了, Java 虚拟机便会触发一次 Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到 Survivor 区。

新生代共有两个 Survivor 区,我们分别用 from 和 to 来指代。其中 to 指向的 Survivior 区是空的。当发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。

Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

万一存活对象数量比较多,那么To域的内存可能不够存放,这个时候会借助老年代的空间。

因此Minor GC使用的则是标记-复制算法。将 Survivor 区中的老存活对象晋升到老年代,然后将剩下的存活对象和 Eden 区的存活对象复制到另一个 Survivor 区中。理想情况下,Eden 区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记 - 复制算法的效果极好。

标记清除(Mark-Sweep)算法:

老年代一般是由标记清除或者是标记清除与标记整理的混合实现。标记清除算法一般应用于老年代,因为老年代的对象生命周期比较长。该算法先对所有可访问的对象,做个标记再遍历堆,把未被标记的对象回收(标记活的)。

缺点:回收时,应用需要挂起,也就是stop the world,导致用户体验极差。由于需要遍历全堆对象,效率比较低(递归与全堆对象遍历)。造成内存碎片化。

标记压缩(Mark--Compact)算法:

标记清除算法和标记压缩算法非常相同,但是标记压缩算法在标记清除算法之上解决内存碎片化,也消除了复制算法当中,内存减半的高额代价。但效率低,压缩阶段,由于移动了可用对象,需要去更新引用。

标记清除压缩(Mark-Sweep-Compact)算法:

标记清除压缩(Mark-Sweep-Compact)算法是标记清除算法和标记压缩算法的结合算法。其原理和标记清除算法一致,只不过会在多次GC后,进行一次Compact操作!