Java虚拟机——简单知识总结

267 阅读19分钟

类加载的过程

加载:通过一个类的全限定名来获取这个类的二进制字节流,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,最后生成一个代表这个类的class对象,作为方法区中这个类的各种数据的入口。 验证:这一阶段的目的是确保这个class文件是安全的,是符合虚拟机规范的。主要对文件格式,元数据,字节码和符号引用进行验证。 准备:给类变量在方法区中分配内存,并赋系统初始值。这时候分配的仅包含类变量(即被static修饰的变量),不包含实例变量。 解析:将常量池内的符号引用替换为直接引用。(符号引用:能无歧义的定位到目标的任何形式的字面量,比如说一个字符串能唯一识别一个变量,那么这个字符串就是符号引用;直接引用:可以是直接指向目标内存地址的指针)。 初始化:主要是执行类构造器 clinit()方法的过程。clinit()方法由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的。


双亲委派模型

三种最常见的类加载器:启动类加载器扩展类加载器应用程序类加载器

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。并且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把加载的请求交由父类加载器处理。具体工作过程:

  • 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个加载请求委托给父类的加载器去执行;
  • 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  • 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载。

优势:

  • 避免类的重复加载
  • 保护程序安全,防止核心API被随意篡改

GC roots 的对象有哪些

  • 虚拟机栈中(栈帧中的本地变量表)引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象
  • 所有被synchronized持有的对象
  • JVM内部的引用,如基本数据类型对应的Class(包装类)对象,常驻异常对象(NPE, OOM),系统类加载器。
  • 反应 JVM 内部情况的 JMXBean,JVM TI 中注册的回调、本地代码缓存等。

GC时安全点的位置选择

安全点太多,GC过于频繁,增大运行时负荷;安全点太少,GC等待时间太长。所以一般在如下几个位置选择安全点:

  • 循环的末尾
  • 方法临返回前
  • 调用方法之后
  • 抛异常的位置

选择这些位置主要目的就是避免程序长时间无法进入安全点,比如JVM在做GC之前要等所有应用线程进入安全点,如果一个线程一直没有进入安全点,就会导致GC时JVM停顿时间延长,比如超大的循环导致执行GC等待时间过长。


四种引用

强引用:最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。(不回收)

软引用:在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。(内存不足即回收)

弱引用:被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。(发现即回收)

虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虛引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知(对象回收跟踪)


永久代和元空间

它们两个都是方法区的具体实现,JDK8之前是永久代,之后用元空间代替了永久代。

元空间与永久代之间最大的区别在于:

  • 元空间并不在java虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:-XX:MetaspaceSize。初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。

注:JDK7 及之后版本的 JVM 已经将字符串常量池从方法区中移了出来,在堆中开辟了一块区域存放字符串常量池。


StringTable为什么要调整

StringTable就是字符串常量池,因为永久代的回收效率很低,在full GC 的时候才会触发,而full GC 是老年代的空间不足、永久代不足时,才会出发,这就导致了StringTable的回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足,放到堆里,能及时回收。


堆溢出、栈溢出、永久代/元空间溢出的原因

堆溢出:

  • 代码中存在很多超大对象的分配,比如说很大的数组。
  • 可能存在内存泄漏,大量对象引用没有释放,JVM 无法对其自动回收,常见于使用了 File 等资源没有回收。
  • 超出预期的访问量/数据量,通常是上游系统请求流量飙升,常见于各类促销/秒杀活动,可以结合业务流量指标排查是否有尖状峰值。
  • 过度使用终结器(Finalizer),该对象没有立即被 GC。

解决方法:

  1. 针对大部分情况,通常只需要通过 -Xmx参数调高 JVM 堆内存空间即可。
  2. 检查超大对象的合理性,比如是否一次性查询了数据库全部结果,而没有做结果数限制。
  3. 通过jmap命令,把堆内存dump下来分析检查是否出现内存泄漏的问题。如不存在内存泄漏问题,可以通过加大堆内存空间来解决,如果存在内存泄漏,需要找到持有的对象,修改代码设计,比如关闭没有释放的连接。
  4. 如果是业务峰值压力,可以考虑添加机器资源,或者做限流降级。

栈溢出:

  • 可能是创建了大量的线程导致的。
  • 递归深度太大或是循环调用。

解决方法:

  1. 降低每个线程栈的大小。
  2. 限制线程创建的数量。

方法区溢出:

  • 在JDK7之前,频繁的错误使用String.intern()方法。
  • 运行期间产生大量的代理类,导致方法区撑爆。
  • 应用长时间运行,没有重启。

解决方法:

  1. 调大方法区的内存大小。
  2. 检查是否存在大量由于反射生成的代理类。调整JVM参数,允许自动卸载class。
  3. 重启JVM。

JIT编译器

在Java的编译体系中,一个Java的源代码文件变成计算机可执行的机器指令的过程中,需要经过两段编译。

  • 第一段是把.java文件转换成.class文件,这叫前端编译。
  • 第二段编译是把.class字节码文件编译成机器指令的过程叫做后端编译,而JIT就是后端编译。

Java是一个半解释半编译型语言,是因为在JVM的执行引擎部分,它既有解释器,又有JIT编译器。目前Hotspot虚拟机采用解释器和JIT编译器并存的架构。当JVM启动时,解释器可以首先发挥作用,而不必等待JIT编译器全部编译完成后在执行,这样可以省去许多不必要的编译时间,随着时间的推移,编译器发挥作用,把越来越多的热点代码编译成本地代码,获得更高的执行效率。

解释器:字节码一进来,解释器就开始逐行去解释,翻译成机器码指令,然后去执行,所以说,解释器的响应速度很快。

JIT编译器:将热点代码编译成本地机器相关的机器码,并进行优化,把机器码缓存到方法区里面,当下次用到这段代码的时候,就不需要使用解释器逐条解释执行,而是直接取出机器指令执行,效率很高,但是相对于解释器而言,它的响应速度比较慢。

热点代码检测: 1、基于采样的方式探测:周期性检测各个线程的栈顶,发现某个方法经常出现在栈顶,就认为是热点方法。好处就是简单,缺点就是无法精确确认一个方法的热度。容易受线程阻塞或别的原因干扰热点探测。

2、基于计数器的热点探测:采用这种方法的jvm会为每个方法建立两个计数器,分别是方法调用计数器和回边计数器。

  • 方法调用计数器:用于统计方法的调用次数。
  • 回边计数器:用于统计方法中循环体执行的循环次数。

两计数器之和超过阈值就认为是热点方法,触发JIT编译。当然这个计数器也不是一直增加的,不然到达一定的时间,所有代码都会是热点代码,这样显然不科学,所以这个计数器不是绝对次数,而是相对次数,即在一定时间内方法被调用的次数。超过一定的时间限度,计数器会减少一半,这个过程叫做计数器的热度衰减

在Hotspot虚拟机中,有两个JIT编译器,分别是Client Compiler和Server Compiler,简称C1和C2。

  • C1编译器:会对字节码进行简单和可靠的优化,耗时短,已达到更快的编译速度。
    • 方法内联:将引用的函数代码编译到引用点出,这样可以减少栈帧的生成过程,减少参数传递以及跳转过程。
    • 去虚拟化:对唯一的实现类进行内联。
    • 冗余消除:在运行期间把一些不会运行的代码折叠掉。
  • C2编译器:进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高。
    • 标量替换:用标量值替代聚合对象的属性值。
    • 栈上分配:经过逃逸分析后,如果对象未逃逸到方法外,则在栈上分配对象,而不是在堆上。
    • 同步消除:清除同步操作,如果确定了只会有一个线程访问该同步块,不会出现线程安全问题,则会把锁消除。通常指的是synchronized。

逃逸分析: 逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术,是一种可以有效减少Java 程序中同步负载内存堆分配压力的跨函数全局数据流分析算法。其基本行为是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其它地方中,称为方法逃逸。简单的说就是,如果一个方法中的对象能被外部线程访问或是调用,则称这个对象发生了逃逸。在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着JIT编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否可以将对象的内存分配从堆转化为栈


JVM的堆为什么要分代

主要是出于对GC性能的考虑,如果不分代的话,所有的对象都堆在一块,这样每次GC的时候都要去找哪些对象是没用的,这样会对堆空间全部扫描一遍,效率比较低。从对象的特点来看,很多对象都是产生之后很快就没用了的,即朝生夕死,所以如果分代的话,我们GC的时候可以先考虑回收这些对象,而不用扫描全部对象,这样也能腾出很大的内存空间。


字符串常量的内存分配

JDK6及以前,字符串常量池是放在方法区中的。

JDK7时,将字符串常量池的位置调整到了Java堆内。

  • 所有字符串都保存在堆中,和其它普通对象一样,这样可以让你在进行调优应用时只需要调整堆的大小即可。

JDK8时,方法区的实现是从永久代改为了元空间,字符串常量池依旧在堆中。


String的intern()方法

如果不是用双引号申明的String对象,可以用String提供的intern()方法,intern()方法从字符串常量池中查询当前字符串是否存在,若存在,直接返回常量池中的对象;若不存在就会在常量池中创建当前的字符串对象。对于程序中大量存在的字符串,尤其其中存在很多重复字符串时,使用intern()可以节省内存空间。

  • JDK1.6中,将这个字符串对象尝试放入字符串常量池中。
    • 如果池中有,则不会放入,返回池中的已有对象的地址。
    • 如果池中没有,会把此对象复制一份,放入池中,并返回创建的对象地址。
  • JDK1.7起,将这个字符串对象尝试放入字符串常量池中。
    • 如果池中有,则不会放入,返回池中的已有对象的地址。
    • 如果池中没有,会把对象的引用地址复制一份,放入池中,并返回该引用地址。

String s = new String("ab");会创建几个对象?

一个对象是:new关键字在堆空间创建的。

另一个对象是:字符串常量池中的创建的对象。字节码指令:ldc

String s = new String("a") + new String("b");会创建几个对象?

对象1:new StringBuilder() 对象2:new String("a") 对象3:字符串常量池的"a" 对象4:new String("b") 对象5:字符串常量池的"b" 深入剖析:StringBuilder中的toString()方法: 对象6:new String("ab") toString()方法的调用,在字符串常量池中,没有生成"ab"


JVM调优参数

-Xms:初始堆大小,JVM 启动的时候,给定堆空间大小。

-Xmx:最大堆大小,JVM 运行过程中,如果初始堆空间不足的时候,最大可以扩展到多少。

-Xmn:设置年轻代大小。

-XX:NewRatio=n 设置年轻代和老年代的比值。如: -XX:NewRatio=3,表示年轻代与老年代比值为 1:3

-XX:SurvivorRatio=n 年轻代中 Eden 区与两个 Survivor 区的比值。注意 Survivor 区有两个。8表示两个Survivor :eden=2:8 ,即一个Survivor占年轻代的1/10,默认就为8

-Xss:设置每个线程的堆栈大小。JDK5后每个线程 Java 栈大小为 1M,以前每个线程堆栈大小为 256K。

-XX:ThreadStackSize=n 线程堆栈大小

-XX:PermSize=n 设置持久代初始值

-XX:MaxPermSize=n 设置持久代大小

-XX:MaxTenuringThreshold=n 设置年轻带垃圾对象最大年龄。如果设置为 0 的话,则对象不经过 Survivor 区,直接进入老年代。


GC算法

复制算法

为了解决内存碎片问题,将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当使用的这块空间用完了,就将存活对象复制到另一块,再把已使用过的内存空间一次清理掉,主要用于进行新生代。缺点是浪费空间。

标记-清除算法

分为标记清除阶段,首先从每个 GC Roots 出发依次标记有引用关系的对象,最后清除没有标记的对象。执行效率不稳定,如果堆包含大量对象且大部分需要回收,必须进行大量标记清除,导致效率随对象数量增长而降低。存在内存空间碎片化问题,会产生大量不连续的内存碎片,导致以后需要分配大对象时容易触发 Full GC。

标记-整理算法

标记过程与标记-清除算法一样,但不直接清理可回收对象,而是让所有存活对象都向内存空间一端移动,然后清理掉边界以外的内存,无内存碎片问题,但是移动整理需要暂停用户线程,用户线程停顿时间变长


垃圾收集器

Serial

使用复制算法、单线程工作,进行垃圾收集时必须暂停所有用户线程。简单高效,内存消耗最小,单线程下GC效率比较高。

ParNew

使用复制算法,Serial 的多线程版本,除了使用多线程进行垃圾收集外其余行为完全一致。因为除Serial外,目前只有它能与CMS收集器配合工作;

Parallel Scavenge

使用复制算法,是多线程收集器。关注点是能够达到一个可控制的吞吐量

Serial Old

使用标记-整理算法,Serial 的老年代版本,单线程工作。

Parellel Old

使用标记-整理算法,Parallel Scavenge 的老年代版本,支持多线程。配合Parallel Scavenge 使用。

CMS

使用标记-清除算法,以获取最短回收停顿时间为目标,过程分为四个步骤:初始标记、并发标记、重新标记、并发清除

  • 初始标记:初始标记仅是标记 GC Roots 能直接关联的对象,速度很快,需要STW
  • 并发标记:从 GC Roots 的直接关联对象开始遍历整个堆内对象,耗时较长不需要暂停用户线程
  • 重新标记:修正并发标记期间因用户程序运作而导致标记产生变动的那部分记录,需要STW
  • 并发清除:清理标记阶段判断的已死亡对象,不需要移动存活对象,该阶段也可与用户线程并发。

缺点:① 对处理器资源敏感,并发阶段虽然不会导致用户线程暂停,但会降低吞吐量。② 无法处理浮动垃圾。③ 会产生空间碎片

G1

G1 将整个堆划分为一个个大小相等的小块(称为一个 region),每一块的内存是连续的。并保留分代的概念,G1 中每个region也会充当 Eden、Survivor、Old 三种角色,但是它们不是固定的,这使得内存使用更加地灵活。执行垃圾收集时,和 CMS 一样,G1 收集线程在标记阶段和应用程序线程并发执行,标记结束后,G1 也就知道哪些region基本上是垃圾,存活对象极少,G1 会先从这些region下手,因为从这些region能很快释放得到很大的可用空间,这也是为什么 G1 被取名为 Garbage-First 的原因

G1 的主要关注点在于达到可控的停顿时间,在这个基础上尽可能提高吞吐量。

G1 使用了停顿预测模型来满足用户指定的停顿时间目标,并基于目标来选择进行垃圾回收的region数量。G1 采用增量回收的方式,每次回收一些区块,而不是整堆回收。

G1 垃圾收集过程:

  • **初始标记:**标记 GC Roots 能直接关联到的对象,需要 STW 但耗时很短
  • 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆的对象图。耗时长但可与用户线程并发,扫描完成后要重新处理 SATB 记录的在并发时有变动的对象。
  • **最终标记:**对用户线程做短暂暂停,处理并发阶段结束后仍遗留下来的少量 SATB 记录。
  • **筛选回收:**对各 Region 的回收价值排序,根据用户期望停顿时间制定回收计划。必须暂停用户线程,由多条收集线程并行完成。

可由用户指定期望停顿时间是 G1 的一个强大功能,但该值不能设得太低,一般设置为100~300 ms。


故障处理工具

  • jps:虚拟机进程状况工具

  • jstat:虚拟机统计信息监视工具

  • jinfo:Java 配置信息工具

  • jmap:Java 内存映像工具,得到运行java程序的内存分配的详细情况。

  • jhat:虚拟机堆转储快照分析工具

  • jstack:Java 堆栈跟踪工具,能得到运行java程序的java stack,可以轻松得知当前线程的运行情况。