引言
本文主要介绍JVM相关内容,带着问题去逐步掌握JVM相关原理和机制,帮助我们更好的理解java程序是如何在虚拟机中运行的。
一、JVM介绍
1.1 java虚拟机的结构是如何设计的
JVM即java虚拟机,是套标准。能运行字节码指令,jvm的好处是可以屏蔽操作系统细节,达到一次编写,到处运行的目的。如下图所示例:
实现JVM的厂商很多,如HotSpot、JRockit、IBMJ9等。主流是HotSpot,JDK和OpenJDK都是基于HotSpot。
HotSpot JVM架构组成 :类装载器子系统、运行时数据区、执行引擎
运行机制:
-
首先由类装载器子系统把编译好的.class字节码文件装载到类加载子系统,主要过程查找并验证,完成内存分配和对象赋值。
-
类装载到内存后,由运行时数据区完成数据的存储和交换。
JVM的内存区域和对应的调优参数总结成下图所示,便于大家记录调优涉及到的参数(我个人认为所谓的JVM调优其实有点水,只是调整一些配置,设置参数,不能称之为调优,咱也没那个能力调优不是吗哈哈)。
3.执行引擎主要包含编辑器 (JIT Compiler)和垃圾回收器 (Garbage Collector)
即时编译器主要用于将字节码翻译成操作系统能够执行的CPU指令,可以通过JVM参数来设置是解释执行还是编译执行。
解释执行 ,就是把字节码作为源程序输入解释执行,不必等待编译器全部编译后才执行,可以节省不必要的编译时间。
编译执行 ,由编译程序先把目标代码一次性编译成目标程序,再由机器运行,执行效率更高,占用内存资源更少。
在HotSpot中,默认是以上两种方式的组合。
垃圾回收器主要是回收运行时方法区产生的垃圾数据,就是各种垃圾回收算法的实现,大概分为三种,复制算法、标记清除算法、标记整理算法,我们可以通过JVM来设置选择哪种算法。
这里放一张整体的交互图,通过这个图我们对JVM有一个更清晰的认知:
1.2 什么是双亲委派机制
以下是Java的类加载机制:
Java编译器把Java源文件编译成.class文件,再由JVM把.class文件加载到内存中,加载成功后得到一个Class字节码对象。拿到字节码对象之后就可以进行实例化了。
重点来了,类的加载过程需要用到加载器,JVM提供了3种类装载器,分别是Bootstrap类加载器、Extension类加载器和Application类加载器。
-
Bootstrap类加载器,主要负责java核心类库的加载,也就是%{JAVA_HOME}\lib下的rt.jar,resources.jar等。
-
Extension类加载器,%{JAVA_HOME}\lib\ext目录下的jar包和class文件。
-
Application类加载器,主要负责当前应用中的classpath下的所有jar包和类文件。
除了以上JVM提供的类加载器,我们还可以通过继承ClassLoader的方式,重写findClass()方法来自定义类加载器,来满足一些特殊场景的需求,比如从远程网络加载.class文件。
下面的图展示了各种类加载器之间的关系:
双亲委派机制或者父级委托模型,就是按照类加载器的层级关系逐层进行委派,如下图所示。
这种上级委托加载机制设计有两个好处:
-
保证安全性,这种层级关系代表的是一种优先级,所有类加载优先使用Bootstrap。对于核心类库就无法破坏,比如自己写一个java.lang.String,最后会交给Bootstrap加载器,每个类加载器都有自己的作用范围,自己写的类无法覆盖核心类库中的类。
-
避免重复加载。避免类已经被父类加载器加载了,重复加载导致混乱问题,也是一种向上检查的过程。
二、内存管理
2.1 JVM如何判断对象可以回收
JVM中判断一个对象是否可以被回收,首先应该判断这个对象是否还在被使用,只有没被使用的对象才能被回收。
引用计数器,就是为每一个对象添加计数器,用来统计指向当前对象的引用次数,每引用一次该对象,就把这个计数器加一,反之减少,当变为0时说明可以回收了。缺点也很明显,需要额外的存储空间来存储引用计数器。但是实现简单,效率高。
主流的JVM没有采用这个方法,因为引用计数器在处理一些的循环引用或者相互依赖的情况时,可能会出现一些不再使用但是又无法回收的内存,造成内存泄漏的问题,如下图所示:
主流JVM使用一个叫可达性分析的算法来判断对象是否应该被回收。
先来说下这个可达性分析的概念:
- 可达性分析 (Reachability Analysis)是垃圾回收(Garbage Collection, GC)机制中用来确定哪些对象是活跃的、哪些对象可以被回收的一种算法。它的基本原理是从一组被称为"GC Roots"的根对象开始,沿着对象之间的引用链进行深度优先或者广度优先的搜索,来判断哪些对象是可达的。
- GC Root:指的是垃圾回收机制可以访问到的一系列对象起点 。这些根对象是垃圾回收器在进行可达性分析算法时的起始点。如果一个对象可以从GC root直接或间接地访问到,那么这个对象就被认为是可达的,不会被垃圾回收器回收。以下是一些常见的GC root类型:
- 栈中的本地变量:方法参数、局部变量等。
- 静态字段引用:类中的静态成员变量。
- 常量池中的引用:如字符串常量池中的引用。
- 线程工作内存中的引用:每个活动线程的引用。
- JNI(Java Native Interface)全局引用:本地代码通过JNI持有的一些Java对象引用。
GC Root 对象比如虚拟机栈中的引用,本地方法栈的引用,这些都是不能回收的,沿着这些对象的引用链往下搜索,寻找它的直接和间接引用对象。遍历完后如果发现某些对象不可达,那就认为这些对象已经没有用了,需要被回收。垃圾回收时会先找到所有的GC Root,这个过程会暂停所有用户线程,也就是stop the world,再从根节点向下寻找,可达对象保留,不可达的就回收。
2.2 如何理解JVM中的GC算法
GC就是Garbage Collector,垃圾回收算法主要有三种:
-
标记清除算法 :将存活的对象打上标记,没有被标记的对象就是需要被回收的垃圾对象。
缺点:会产生比较多的内存碎片,随着时间推移,这些内存碎片存在,系统无法再大量分配连续的内存空间。这会触发频繁的GC操作。 -
标记复制算法 :把内存分为两等份,每次使用其中一份,等到正在使用的这部分内存满了之后,就会标记出存活对象,然后把存活对象复制到另一部分闲置的内存中,留在第一部分内存中的对象会全部被垃圾回收器回收。
缺点:导致实际使用的内存只有50%,浪费空间。对象量大的话,复制会很耗时。这种算法适合处理存活对象少,垃圾对象比较多的场景。 -
标记整理算法 :先标记出存活的对象,再把所有存活的对象整理到内存空间的另一端,而没有被标记的对象就可以被覆盖或者释放。
缺点:本质和标记清除算法差不多,缺点也类似,还多了一个移动的操作。
java对象基本都是临时对象,用完就回收,根据对象在内存中的中的存活时间,分为年轻代、老年代、永久代。
年轻代采用标记复制算法,每次复制后存活下来的对象少。
老年代是经历过几次垃圾回收的对象,JVM认为可能继续存活下来,采用的是标记清除算法。
永久代表示会一直存活的对象,只有在触发Full GC的情况下才会回收,永久代如果创建对象过多会造成内存溢出,永久代采用的也是标记清除算法。不过重要的是JDK8移除了永久代的概念,取而代之的是元空间,也就是方法区
2.3 JVM年龄分代为什么是15次
JVM的年龄分代策略是用于决定对象何时从年轻代(Young Generation)晋升到老年代(Old Generation)的一种机制。
对象在年轻代中经历多次垃圾回收(Garbage Collection,GC)后,如果仍然存活,其年龄值会逐渐增加。这个年龄值是由对象头部的Mark Word中的部分位来表示的。
在Java 8及之前版本中,对象年龄的表示使用了Mark Word中的4个比特位,这意味着年龄的最大值是2^4 - 1 = 15。这是因为4比特可以表示从0到15共16种状态,但通常0表示新创建的对象,因此15是对象年龄可以达到的最大值。
默认情况下,GC年龄达到15岁后,对象就会从年轻代转移到老年代。
选择15次作为年龄上限的原因主要是基于统计和经验。JVM的设计者发现,大多数对象在创建后很快就会被垃圾回收,只有少数对象会存活足够长的时间以至需要移动到老年代。15次是一个足够大的数字,可以确保那些确实需要长期存在的对象能够被识别并移到老年代,同时又不会浪费过多的位数来表示年龄,因为Mark Word的空间是有限的,需要存储其他重要的信息,如锁标志、线程ID 等。
如果需要改变这个默认值,可以通过JVM参数进行调整,但是由于Mark Word的位数限制,年龄的最大值不能超过15。在某些JVM实现中,可以通过参数如-XX:MaxTenuringThreshold来调整这个阈值,但实际可设置的范围可能受限于Mark Word中年龄字段的位数。
2.4 JVM为什么使用元空间代替永久代
JVM使用元空间(Metaspace)代替永久代(Permanent Generation)的主要原因是为了解决永久代的一些固有问题,尤其是与内存管理和性能相关的问题。以下是几个关键因素:
-
内存溢出问题: 在JDK 1.8之前,永久代的大小是固定的或者可以通过JVM参数调整,但它仍然属于堆内存的一部分。这意味着如果永久代的大小设置不当,很容易出现OutOfMemoryError: PermGen space错误。元空间将类元数据存储在本地内存中,而不是堆内存中,这避免了由于永久代满而导致的频繁垃圾收集。
-
更灵活的内存管理: 元空间的大小不再受到堆大小的限制,而是受限于系统的可用物理内存和最大映射文件大小。这使得元空间的管理更加灵活,可以随着类的加载和卸载自动扩展和收缩,减少了手动调整JVM参数的需求。
-
减少GC压力: 将类元数据移出堆意味着减少了堆内存的负载,从而减少了垃圾回收的压力。在永久代中,类元数据的垃圾回收(也称为类卸载)是一个复杂的过程,而在元空间中,类元数据的管理与堆上对象的管理分离,使得垃圾回收更加高效。
-
简化JVM配置: 使用元空间之后,开发人员和系统管理员不需要再关注永久代的大小设置,因为元空间的大小会根据需要自动调整。这简化了JVM的配置过程,减少了因配置不当导致的性能问题。
-
性能优化: 元空间的引入允许JVM对类元数据的访问进行优化,因为它们现在位于本地内存中,这可能比堆内存提供更快的访问速度。