JVM八股总结(干活满满)

JVM八股

JVM
JVM的功能

image.png


解释和运行——通过解释器执行和通过即时编译器产生本地代码执行
内存管理——类加载器,运行时数据区,垃圾回收
即时编译——即时编译器,逃逸分析,方法内联
JVM的组成

image.png


类加载器——加载class字节码文件中的内容到内存中
运行时数据区域——负责管理jvm使用到的内存,比如创建对象和销毁对象
执行引擎——将字节码文件中的指令解释称机器码,同时使用即时编译器优化性能
本地接口——调用本地已经编译好的方法,比如虚拟机中提供的c++方法。
类加载器
类的生命周期
类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段::加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。
这 7 个阶段的顺序如下图所示:


类加载过程
Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?
系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。


加载
类加载过程的第一步,主要完成下面 3 件事情:
1通过全类名获取定义此类的二进制字节流。
2将字节流所代表的静态存储结构转换为方法区的运行时数据结构。
3在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口。
虚拟机规范上面这 3 点并不具体,因此是非常灵活的。比如:"通过全类名获取定义此类的二进制字节流" 并没有指明具体从哪里获取( ZIP、 JAR、EAR、WAR、网络、动态代理技术运行时动态生成、其他文件生成比如 JSP...)、怎样获取。
加载这一步主要是通过我们后面要讲到的 类加载器 完成的。类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定(不过,我们也能打破由双亲委派模型)。
验证
验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段这一步在整个类加载过程中耗费的资源还是相对较多的,但很有必要,可以有效防止恶意代码的执行。任何时候,程序安全都是第一位。
不过,验证阶段也不是必须要执行的阶段。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
验证阶段主要由四个检验阶段组成:
1文件格式验证(Class 文件格式检查)
2元数据验证(字节码语义检查)
3字节码验证(程序语义检查)
4符号引用验证(类的正确性检查)

文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
符号引用验证发生在类加载过程中的解析阶段,具体点说是 JVM 将符号引用转化为直接引用的时候(解析阶段会介绍符号引用和直接引用)。
符号引用验证的主要目的是确保解析阶段能正常执行,如果无法通过符号引用验证,JVM 会抛出异常,比如:
●java.lang.IllegalAccessError:当类试图访问或修改它没有权限访问的字段,或调用它没有权限访问的方法时,抛出该异常。
●java.lang.NoSuchFieldError:当类试图访问或修改一个指定的对象字段,而该对象不再包含该字段时,抛出该异常。
●java.lang.NoSuchMethodError:当类试图访问一个指定的方法,而该方法不存在时,抛出该异常。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
1这时候进行内存分配的仅包括类变量( Class Variables ,即静态变量,被 static 关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
2从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。不过有一点需要注意的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。
3这里所设置的初始值"通常情况"下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。
《深入理解 Java 虚拟机》7.34 节第三版对符号引用和直接引用的解释如下:


举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
初始化
初始化阶段是执行初始化方法 <clinit> ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
对于<clinit> () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit> () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。
对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):
●当遇到 new、 getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
●当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。
●当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
●当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。
●当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。
●使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forName("..."), newInstance() 等等。如果类没初始化,需要触发其初始化。
●初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
●当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
●MethodHandle 和 VarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。
●「补充,来自issue745open in new window」 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
类卸载
卸载类即该类的 Class 对象被 GC。
卸载类需要满足 3 个要求:
1该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
2该类没有在其他任何地方被引用
3该类的类加载器的实例已被 GC
所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
只要想通一点就好了,JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。

类与类加载器
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。比较两个类是否相等,只有在这两个类是由同一个类加载器加载得到前提下才有意义,否则,即使这两个类来源于通一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
绝大多数Java程序都会使用以下三个系统提供的类加载器进行加载。
在Java虚拟机的角度来看,之存在两种不同的类加载器;一种是启动类加载器,这个类加载器使用C++语言实现,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都集成自抽象类java.lang.ClassLoader。
启动类加载器
这个类加载器负责加载存放在<JAVA_HOME>\lib目录,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器处理,那直接用null代替即可(因为是用C++实现的)。
扩展类加载器
这个类负责加载<JAVA_HOME>\lib\ext目录中,根据加载器的名称,可以推断出这是一种Java系统类库的扩展机制,用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能。
应用类加载器
应用类加载器负责加载用户类路径上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
类的双亲委派机制
双亲委派模型,其实就是一种类加载器的层次关系,除了顶层的启动类加载器外,其它的类加载器都要有自己的父类加载器。
一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
如何打破双亲委派机制
1.自定义类加载器,重写LoadClass方法。
2.委派线程上下文加载器(应用类加载器加载)。
双亲委派机制的优缺点
优点
使得Java类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一,避免了多份同样字节码的加载。
java核心api中定义类型不会被随意替换,可以防止核心API库被随意篡改。
缺点
限制了类加载器的灵活性:双亲委派机制规定了类加载器必须按照从上到下的顺序进行加载,这样就限制了类加载器的灵活性。有时候,我们可能需要自定义的类加载器加载一些特殊的类,但是由于双亲委派机制的限制,可能无法实现。
运行时数据区
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

image.png


程序计数器
程序计数器是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。
为什么每个线程都需要有一个程序计数器?
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行为止,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。
程序计数器会OOM吗?为什么?
程序计数器是运行时数据区唯一一块没有OOM的区域。因为程序计数器只存吓一跳字节码指令的地址,并且随着线程的结束而销毁。
Java虚拟机栈
与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型;每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈从入栈到出栈的过程。
虚拟机栈会OOM吗?为什么
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OOM异常
本地方法栈
本地方法栈与虚拟机栈所发挥的作用是非常相似的,区别只是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的本地方法服务。
Java堆
Java堆是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,并且所有的对象都应该被放在堆中。
HotSpot对象的创建
当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从 Java 堆中划分出来。
指针碰撞
假设 Java 堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为「指针碰撞」(Bump The Pointer)
空闲列表
但如果 Java 堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为「空闲列表」(Free List)。
如何选择?
选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用 Serial、ParNew 等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用 CMS 这种基于清除(Sweep)算法的收集器时,理论上 [1] 就只能采用较为复杂的空闲列表来分配内存。
后续步骤
1.虚拟机必须将分配到的内存空间初始化为零值,保证Java代码中对象的实例字段不赋初始值就可以直接使用,使程序能访问到这些字段的数据类型锁对应的零值。
2.对对象头进行必要的设置
3.完成上述步骤,执行构造函数(Class文件中的<init>方法)
对象内存布局
对象可分为3块区域:对象头、对象实例数据、对齐填充。

对象

对象头

对象自身运行时数据

类型指针

对象实例数据

对齐填充

1.对象头。对象头信息分为两部分。第一部分存储对象运行时数据。第二部分时类型指针,即对象指向它的类元数据指针。虚拟机通过这个指针确定这个对象是哪个类的实例。
2.实例数据部分:对象真正存储的有效信息,无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
3.对齐填充:占位符的作用。 由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说, 就是对象的大小必须是8字节的整数倍。
对象的访问定位
Java程序需要通过栈上的reference数据来操作堆上的 具体对象。
主流的访问方式有使用句柄和直接指针两种。


对象在内存中被移动时,句柄和直接指针有差异:

句柄

栈中Reference不用变

定位对象要两次

直接指针

一次即可定位

栈中Reference要变

方法区
方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
永久代
JDK8以前,HotSpot虚拟机设计团队把收集器的分代设计扩展至方法区,用永久代实现方法区,省去专门为方法区编写内存管理代码的工作。
JDK7,HotSpot将字符串常量池和静态变量从永久代移出,放在堆中,JDK8时利用本地内存中的元空间代替。
缺点
1.永久代有内存上限,而其他虚拟机(无永久代)只要没有触碰到进程可用内存上限,就不会内存溢出。
2.极少数的方法(String::intern)因为永久代的原因导致不同虚拟机下有不同的表现。
方法区会OOM吗?
会,大量生成Class的时候。
运行时常量池
运行时常量池是方法区的一部分,Class文件中的常量池表,用于存放编译器生成的各种字面量与符号引用,将在类加载后存放到方法区的运行时常量池中。
运行时常量池具备动态性,Java语言并不要求常量一定只有编译器才能产生,也就是说,并非预置入Class文件中的常量池,才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,比如String类的intern()方法。


垃圾收集器与内存分配策略
判断对象是否存活
引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为0的对象就是不可能再被使用的。
但是如果A类的子对象引用B,B类的子对象引用A,而且没有任何引用指向A和B,理论上A和B都会被回收,但是引用计数法无法分析出来。
可达性分析算法
通过一系列的GC Roots的跟对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链,如果某个对象到GC Roots没有任何引用链相连,则此对象不可能再被使用。

image.png


引用
强引用
无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
软引用
软引用是用来描述一些还有用,但非必须得对象。只被软引用关联着的对象,在系统发生内存溢出异常前,会把这些对象列进回收返回之中。
弱引用
弱引用用来描述非必须对象。弱引用关联的对象只能生存到下一次垃圾收集发生为止。
虚引用
虚引用,是最弱的一种引用,无法通过虚引用取得一个对象实例。作用是在这个对象被收集器回收时收到一个系统通知。
finalize()避免死亡

image.png


垃圾收集算法
标记-清除算法
遍历gc Roots标记出所有需要回收的对象,然后统一回收所有未被标记的对象。
缺点
1.标记和清除两个过程的执行效率都随对象数量增长而降低。
2.内存空间碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致分配大对象找不到足够的连续内存。
标记-复制算法
半区复制
半区复制将内存按容量划分为两个大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象赋值到另外一块上面,然后再把已使用过的内存空间一次清理掉。
缺点
1.内存空间缩小为原来的一半,太浪费。
2.如果对象大多数存活,那么复制对象的开销过大。
Appel式回收
Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survior空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中依然存活的对象一次性复制到另一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor。这样只有10%的新生代内存空间是会被浪费的,如果存活的对象所占空间大于Survior那么会让其他内存(老年代)进行分配担保。
标记-整理算法
标记过程与标记-清除算法一样,但是后续不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。在这个过程中必须暂停用户应用进程才能进行(ZGC能做到并发),会造成很大的STW
记忆集与卡表
记忆集是一种用于记录从非收集区区域指向收集区域的指针集合的抽象数据结构。标记阶段将记忆集中的对象加入GC Roots 中一起扫描,就可以将被引用对象标记为存活。
目前最常用卡表(精确到一块内存区域)去实现记忆集,卡表最简单的形式是一个字节数组,字节数组每一个元素都对应着内存区域中一块特定大小的内存块,称为"卡页";一个卡页的内存中通常不止包含一个对象,只要卡页内有一个对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1。
对象赋值时,通过写屏障维护记忆集,类似于AOP切面编程。
JVM三色标记
白色:表示对象尚未被垃圾收集器访问过。在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,代表不可达。
黑色:表示对象已经被垃圾收集器访问过,并且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接指向某个白色对象。
灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

image.png


对象消失
当且仅当一下两个条件同时满足时,会产生对象消失的问题。本应该是黑色的对象被误标为白色: ·赋值器插入了一条或多条从黑色对象到白色对象的新引用;
·赋值器删除了全部从灰色对象到白色对象的直接或间接引用;
解决策略
增量更新-CMS
当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。
原始快照(SATB)-G1
当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

经典收集器
Serial收集器(标记复制)
Serial收集器采用标记复制算法,是一个单线程工作的收集器,它在进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
ParNew收集器(标记复制)
除了同时使用多条线程进行垃圾收集之外,其他行为与Serial收集器一致。
Parallel Scavenge收集器
Parallel收集器是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。Parallel Scavenge收集器能够设置最大垃圾收集停顿时间和吞吐量大小,通过垃圾收集的自适应调节策略实现。
自适应调节策略:虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大吞吐量。
Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发手机,基于标记-整理算法。
Serial Old收集器
Serial Old是Serial收集器的老年代版本,是一个单线程收集器,使用标记-整理算法,可以作为CMS收集器发生失败时的后备预案,在并发手机发生Concurrent Mode Failure时使用。
CMS收集器(Concurrent Mark Sweep)
CMS收集器是一种以获取最短回收停顿时间为目标的收集器,采用标记-清除算法。
CMS回收的四个步骤
1)初始标记 STW
仅仅是标记一下GC Roots能直接关联到的对象,速度很快。
2)并发标记
从GC Roots的直接关联对象开始遍历整个对象图的过程。
3)重新标记 STW
修正并发期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,
4)并发清除
清理掉判断已经死亡的对象。

image.png


缺点
1.CMS对处理器资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低总吞吐量。
2.CMS无法处理浮动垃圾进而导致另一次完全STW的Full GC的产生。在CMS并发标记和并发清理阶段,用户线程是还在继续娙的,程序在运行自然就会伴随有新的垃圾对象不断产生,但这一部分垃圾独享是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留到下一次垃圾收集时再清理掉,这一部分垃圾称为浮动垃圾。
要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次并发失败(Concurrent Mode Failure),这时虚拟机不得不临时启动Serial Old来进行老年代的收集。
3.CMS是一款基于标记-清除算法实现的收集器,会导致大量的空间碎片产生;
Garbage First-G1收集器
G1的划分
G1收集器不同于其他收集器,它可以面向对内任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
G1把连续的Java堆划分为多个大小相等的独立区域,每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同的Region采用不同的策略去处理。Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象就会被判定为大对象。而那些超过了整个Region容量的超级大对象,将会被放在N个连续的Humongous Region之中。
过程
1.初始标记,STW,采用三色标记从GC Root可直达对象(利用卡表(实现记忆集),将跨代引用的对象,放到GC Roots之中)。
2.并发标记,并发执行,对存活对象进行标记。
3.最终标记,STW,处理SATB相关的对象标记。
4.清理,STW,如果区域中没有任何存活的对象就直接清理。
5.转移,将存活对象复制到别的区域。STW 间隔很大。
Young GC
条件
年轻代占总堆60%以上。
规则
1.新创建的对象会放在Eden区。当G1判断年轻代不足,无法分配对象时需要回收会执行Young GC。
2.标记出Eden和Survivor区域中的存活对象。
3.根据配置最大暂停时间选择某些区域将存活对象赋值到一个新的Survivor区中(年龄+1),清空这些区域。
4.后续Young GC与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区。
5.当某个存活对象到达阈值(默认值15),将被放入老年代。
Mixed GC
条件
老年代占总堆达到阈值(默认45%)时会触发混合回收Mixed GC。混合回收会回收整个年轻代+不分老年代。
CMS与G1之对比
G1优点
1.指定最大停顿时间,按收益动态确定回收集
2.不会产生内存空间碎片
CMS之优
1.记忆集占内存空间少,CMS只有年轻代需要卡表,而G1每个Region都需要卡表。
ZGC
在ZGC中,与G1垃圾收集器一样将内存划分成很多个区域,这些区域被称之为Zpage。Zpage分成三类大中小,管控粒度比G1更细。
小区域:2M,只能保存256KB内的对象。
中区域:32M,保存256KB-4M的对象。
大区域:只保存一个大于4M的对象。
G1转移需要停顿的主要原因
G1转移需要将对象复制到另一块区域,完全转移完成后,改变引用指向的地址。但如果在完全转移之前,用户更改了对象的数据,那么更改的是转移之前对象的数据。

image.png


ZGC解决转移停顿
在ZGC中,使用了读屏障Load Barrier技术,来实现转移后对象的获取。当获取一个对象引用时,会触发读后的屏障指令,如果对象指向的不是转移后的对象,通过转移表,用户线程会将引用指向转移后的对象。
ZGC的着色指针
着色指针将原来的8字节保存地址的指针拆分成了三部分;
1.最低的44位,用于表示对象的地址,所以最多能表示16TB的内存空间。
2.中间4位是颜色位,每一位只能存放0或者1,并且同一时间只有其中一位是1.

image.png


3.16位未使用
并发标记阶段
1.遍历所有对象,标记可以到达的每一个对象是否存活,标记为marked0。
2.选择需要转移的Zpage,并创建转移表,用于记录转移钱对象和转以后对象地址。
3.转移GC Root不关联的对象,将不转移的对象着色指针remapped设置为1
4.转移完之后,转移前的Zpage就可以清空了,转移表需要保存下来。
5.如果有用户进程利用一个对象应用转以后的对象,将会通过读屏障,更改引用,并且将这两个引用之间的指针变为repmapped。
6.第二次垃圾回收时候,如果Marked为1代表上一轮重映射还没有完成,先完成重映射从转移表中找到老对象转以后的新对象,再进行标记,如果Remap为1,只需要进行标记。
7.将转移映射表删除,释放内存空间。

image.png


后端编译与优化
解释器与编译器
编译器是源程序的每一条语句都编译成机器语言,并保存成二进制文件,而解释器则只在执行程序时,才一条一条地解释称机器语言给计算机来执行。
解释器可以首先发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,反之可以用编译执行来提升效率。
即时编译器
当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为"热点代码",为了提高热点代码的执行效率,虚拟机将会把这些代码编译成本地机器码,以各种手段对代码进行优化。
编译对象
即使编译器编译的目标是热点代码,包括两类。
1.被多次调用的方法。
2.多次执行的循环体。
对于两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。对于后一者而言,编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执行)会稍有不同,编译时会传入执行入口点字节码序号。这种编译方式因为编译发生在方法执行过程中,因此被形象地称为栈上替换,即方法的栈帧还在栈上,方法就被替换了。
触发条件
要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为"热点探测",目前主流的有两种判定方式。
基于采样的热点探测
采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
基于计数器的热点探测
采用这种方法的虚拟机会为每个方法建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来要麻烦一点,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对来说更加精确严谨。
计数器
为了实现热点技术,HotSpot为每个方法准备了两类计数器:方法调用计数器和回边计数器,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。
方法调用计数器
当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码执行。如果不存在已被编译过的版本,则将该方法的调用计数器加一,然后判断方法调用计数器与回边计数器值之和是否驰好过方法调用计数器的阈值。一旦已超过阈值的话,将会向即时编译器提交一个该方法的代码编译请求。
如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法就会使用已编译的版本。
在默认设置下,方法调用计数器统计的并不是方法被调用的绝对技术,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减。
回边计数器
回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令被称为“回边”。很显然建立回边计数器统计的目的是为了触发栈上的替换编译。
当解释器遇到一条回边指令时,会先检查将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。
与方法器不同,回边计数器没有技术热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次进入该方法的时候就会执行标准编译过程。
编译器优化
方法内联
方法内联的优化行为理解起来是没有任何困难的,不过就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用。
逃逸分析
逃逸分析的基本原理是:分析对象作用域,当一个对象在方法里面被定义后,它肯呢个被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸,甚至还有被外部线程访问,这种称为线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外,或者逃逸程度比较低,则可能为这个对象实例采用不同程度的优化。
栈上分配:如果确定一个对象不会逃逸处线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不支持线程逃逸。
标量替换:逃逸分析四能证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量代替。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步优化手段创建条件。
同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析确定一个变量不会逃逸处线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施就可以安全地消除掉。

JVM调优
JVM 调优是一个很大的话题,在回答“如何进行 JVM 调优?”之前,首先我们要回答一个更为关键的问题,那就是,我们为什么要进行 JVM 调优?
只有知道了为什么要进行 JVM 调优之后,你才能准确的回答出来如何进行 JVM 调优?
要进行 JVM 调优无非就是以下两种情况:
1目标驱动型的 JVM 调优,如,我们是为了最短的停顿时间所以要进行 JVM 调优,或者是我们为了最大吞吐量所以要进行 JVM 调优等。
2问题驱动型的 JVM 调优,因为生产环境出现了频繁的 FullGC 了,导致程序执行变慢,所以我们要进行 JVM 调优。
所以,针对不同的 JVM 调优的手段和侧重点也是不同的。
总的来说,JVM 进行调优的流程如下:
1确定 JVM 调优原因
2分析 JVM(目前)运行情况
3设置 JVM 调优参数
4压测观测调优后的效果
5应用调优后的配置
具体来说它们的执行如下。
1.确定JVM调优原因
先确定是目标驱动型的 JVM 调优,还是问题驱动型的 JVM 调优。
如果是目标性的 JVM 调优,那么 JVM 调优实现思路就比较简单了,如:
1以最短停顿时间为目标的调优,只需要将垃圾收集器设置成以最短停顿时间的为目标的垃圾收集器即可,如 CMS 收集器或 G1 收集器。
2以吞吐量为目标的调优,只需要将垃圾收集器设置为 Parallel Scavenge 和 Parallel Old 这种以吞吐量为主要目标的垃圾回收器即可。
如果是以问题驱动的 JVM 调优,那就要先分析问题是什么,然后再进行下一步的调优了。
2.分析JVM运行情况
我们可以借助于目前主流的监控工具 Prometheus + Grafana 和 JDK 自带的命令行工具,如 jps、jstat、jinfo、jstack 等进行 JVM 运行情况的分析。
主要分析的点是 Young GC 和 Full GC 的频率,以及垃圾回收的执行时间。
3.设置JVM调优参数
常见的 JVM 调优参数有以下几个:
●调整堆内存大小:通过设置 -Xms(初始堆大小)和 -Xmx(最大堆大小)参数来调整堆内存大小,避免频繁的垃圾回收。
●选择合适的垃圾回收器:根据应用程序的性能需求和特点,选择合适的垃圾回收器,如 Serial GC、Parallel GC、CMS GC、G1 GC 等。
●调整新生代和老年代比:通过设置 -XX:NewRatio 参数来调整新生代和老年代的比例,优化内存分配。
●设置合适的堆中的各个区域比例:通过设置 -XX:SurvivorRatio 参数和 -XX:MaxTenuringThreshold 参数来调整 Eden 区、Survivor 区和老年代的比例,避免过早晋升和过多频繁的垃圾回收。
●设置对象从年轻代进入老年代的年龄值:-XX:InitialTenuringThreshold=7 表示 7 次年轻代存活的对象就会进入老年代。
●设置元空间大小:在 JDK 1.8 版本中,元空间的默认大小会根据操作系统有所不同。具体来说,在 Windows 上,元空间的默认大小为 21MB;而在 Linux 上,其默认大小为 24MB。然而如果元空间不足也有可能触发 Full GC 从而导致程序执行变慢,因此我们可以通过 -XX:MaxMetaspaceSize= 设置元空间的最大容量。
4.压测观测调优后的效果
JVM 参数调整之后,我们要通过压力测试来观察 JVM 参数调整前和调整后的差别,以确认调整后的效果。
5.应用调优后的配置
在确认了 JVM 参数调整后的效果满足需求之后,就可以将 JVM 的参数配置应用与生产环境了

JVM参数总结
最重要的JVM参数总结 | JavaGuide

JVM(Java虚拟机)通过监控堆内存的使用情况来判断是否需要进行垃圾回收(GC)。当堆内存中的对象占用达到一定阈值时,JVM会触发GC来回收不再使用的内存,以便使可用内存重新分配给新的对象。 JVM通过使用垃圾回收器(GC器)来执行垃圾回收操作。GC器会根据一定的算法(如标记清除、复制等)来检查并回收不再使用的对象。当GC器开始执行时,它会遍历堆内存中的对象,标记需要回收的对象,并将这些对象从堆内存中删除或重置为可用状态。 在JVM中,可以通过一些参数和工具来查看GC的情况。其中,可以使用jstat命令来实时监控GC的执行情况。这个命令可以显示GC的次数、执行时间、回收的内存量等信息。通过查看这些数据,可以了解GC的频率及效率,以判断是否需要调整JVM的配置参数来优化垃圾回收。 此外,还可以通过使用Profiling工具(如VisualVM)来分析JVM内存的使用情况和GC的执行情况。这些工具通常提供了图形化界面,可以清晰地显示GC的过程和结果。通过观察对象的创建和销毁、堆内存的使用情况等指标,可以判断GC的状况,并进行调整以提高系统性能。 总之,JVM通过监控堆内存的使用情况和使用专用的GC器来判断是否需要进行垃圾回收操作。通过使用一些工具和命令,我们可以查看GC的执行情况,据此进行性能优化和调整。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值