学习JVM,这一篇够吗?

424 阅读19分钟

本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

如果是Java程序员,从入行开始就一直听到JVM这些词,哪怕已经工作多年,它还是一个高频的词汇,仿佛无处不在,之所以高频的原因当然是它很重要,如果理解不了JVM,就很难写出高质量的Java代码。

关于JVM的内容个人或者网上都总结过很多次了,再单独的拎一个知识点拿来说好像起不来多少作用,这里尽可能一次性将JVM核心知识体系的内容都说一遍。

目录

1、Java内存模型(JMM) 2、JVM运行时内存结构 3、JVM垃圾回收器、垃圾回收算法 4、JVM编译器&编译优化 5、JVM加载类原理 6、JVM优化原则和工具 7、JVM问题排查步骤

Java内存模型(JMM)

注意JVM和JMM的区别,JMM是JVM制定的一种规范,来屏蔽掉各种操作系统内存访问的差异,以实现Java程序在各种平台下都能达到一致的访问效果。

JMM主要描述了:

  • 线程如何与内存进行交互。
  • JVM如何与计算机的内存进行交互
  • 定义了变量的访问规则,主要围绕了原子性,有序性和可见性进行展开。

img

JMM的三大特性

JMM的三大特性(原子性、可见性、有序性) ,volatile只保证了两个,即可见性和有序性,不满足原子性

  • 原子性(Atomicity):原子性是指,一个操作是不可中断的。即使是多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。JDK的包中提供了专门的原子包java.util.concurrent.atomic,synchronized关键字还有Lock来让程序在并发环境下具有原子性的特点。

  • 可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其它线程能立即得知这个修改。

    volatile,synchronized和final关键字能实现可见性。使用final关键字需要注意对象逃逸

  • 有序性:如果再本线程内观察,所有操作都是有序的,如果再一个线程中观察另外一个线程,那么所有操作都是无序的。前半句是指“线程内表现为串行”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟现象”

主内存与工作内存交互

JMM定义了8种操作来完成主内存与工作内存的交互细节,虚拟机必须保证这8种操作的每一个操作都是原子的,不可再分的。

  • lock: 作用于主内存的变量,把变量标识为线程独占的状态
  • unlock: 与lock对应,把主内存中处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read: 作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存,便于随后的load使用。
  • load:作用于工作内存的变量,把read读取到的变量放入工作内存副本
  • use: 作用于工作内存,把工作内存的变量值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign: 作用于工作内存,把执行引擎收到的值赋给工作内存的变量,虚拟机遇到赋值字节码时候执行这个操作
  • store:作用于工作内存,把变量的值传输到住内存中,以便随后的write使用
  • write:作用于主内存,把store操作从工作内存得到的值放入主内存的变量中。

img

执行上述8种基本操作的规则:

  • 不允许read和load,store和write操作之一单独出现。
  • 不允许一个线程丢弃它最近的assign操作。即变量在工作内存中改变了账号必须把变化同步回主内存
  • 一个新的变量只允许在主内存中诞生,不允许工作内存直接使用未初始化的变量。
  • 一个变量同一时刻只允许一条线程进行lock操作,但同一线程可以lock多次,lock多次之后必须执行同样次数的unlock操作
  • 如果对一个变量进行lock操作,那么将会清空工作内存中此变量的值。
  • 不允许对未lock的变量进行unlock操作,也不允许unlock一个被其它线程lock的变量
  • 如果一个变量执行unlock操作,必须先把次变了同步回主内存中。

这8种操作定义相当严禁,实践起来又比较麻烦,但是可以有助于我们理解多线程的工作原理。有一个与此8种操作相等的Happen-before原则。

面试相关问题

多线程的时候为什么会拿到不一样的值?

JMM规定所有的共享变量都存储在主内存中,而每条线程有自己的工作内存(本地内存),工作内存保存了共享变量的副本,而不同内存又无法访问对方的工作内存,所以如果线程在工作内存中修改了变量副本,其它线程是无从得知的。

Volitile工作原理是什么?

Volitile修饰的变量会被加一个lock指令,这个指令做两件事情,

  1. 在变量改变之后,会立刻从cpu高速缓存写到内存,不写到工作区内容。
  2. 会通知其他cpu缓存中的该变量的值设置成无效,用到该变量时会到内存中重新读取该变量的值。

Lock指令保证了缓存一致性原理。

简单说就是加上volitile让线程读写内存都直接和主内存交互,而不考虑线程工作区内存。

Volatile变量为什么在并发下不安全?

volatile变量在各个线程的工作内存中也可以存在不一致的情况,但由于每次使用之前都要刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题,但是Java里面的运算并非原子操作。

假如说一个写入值操作不需要依赖依赖这个值的原先值,那么在进行写入的时候我们就不需要进行读取操作。 写入操作对原本的值的时候没有要求,那么所有线程都可以写入新的值,虽然读取到的值是相同的,每个线程的操作也是正确的,但是最终结果却是错误的。

JMM

感兴趣的可以运行如下代码:

public class VolatileTest {
    public static volatile int count = 0;
    public static final int THREAD_COUNT = 20;

    public static void add(){
        count++;
    }

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int i = 0; i < THREAD_COUNT; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    add();
                }
            });
            threads[i].start();
        }
        
        for (int i = 0; i < THREAD_COUNT; i++) {
            threads[i].join();
        }        
        
        System.out.println(count);
    }
    
}
// 如果并发正确的话:应该是20000,但是每次运行结果都不到20000

Volatile适合做什么?

适合做标量,当一个线程对某个变量进行读写操作,而其它线程仅仅进行读操作的时候,是可以保证volatile的正确性的。如下:

volatile bool stopped;
public void stop(){
    stopped = true
}

while(!stoppped){
    // 执行操作
}

synchronized和volitile的区别?

Synchronized保证了原子性,可见性与有序性,它的工作时对同步的代码块加锁,使得每次只有一个线程进入代码块,从而保证线程安全。synchronized反应到字节码层面就是monitorenter与monitorexit.

volitile只保证了属性的可见性和有序性,不保证最终的原子性。而synchronized会保证属性的原子性。

虽然synchonized关键字看起来是万能的,能保证线程安全性,但是越万能的控制往往越伴随着越大的性能影响。

JVM运行时内存结构

在Java程序运行期间,主内存区的数据存储区域,其中一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。这里参考下JVM指定规范:

  • pc寄存器
  • JVM Stack(虚拟机栈)
  • Heap(堆)
  • Method Area(方法区)
  • Run-Time Constant Pool(运行时常量池)
  • Native Method Stacks(原生方法栈或本地方法栈)

img

内存结构说明

1. pc寄存器

可以支持多条线程同时允许,每一条Java虚拟机线程都有自己的pc寄存器。任意时刻,一条JVM线程之后执行一个方法的代码,这个方法被称为当前方法(current method) 如果这个方法不是native的,那么PC寄存器就保存JVM正在执行的字节码指令地址。 如果是native的,那么pc寄存器的值为undefined pc寄存器的容量至少能保证一个returnAddress类型的数据或者一个平台无关的本地指针的值。

2. JVM Stack(虚拟机栈)

  • 每一个JVM线程都有自己的私有虚拟机栈,这个栈与线程同时创建,用于存储栈帧(Frame)。
  • 栈用来存储局部变量与一些过程结果的地方。在方法调用和返回中也扮演了很重要的角色。
  • 栈可以试固定分配的也可以动态调整
    • 如果请求线程分配的容量超过JVM栈允许的最大容量,抛出StackOverflowError异常
    • 如果JVM栈可以动态扩展,扩展的动作也已经尝试过,但是没有申请到足够的内存,则抛出OutofMemoryError异常

3. Heap(堆)

堆是可以可供各个线程共享的运行时存储区域,也是供所有类的实例和数组对象分配内存的区域。堆在JVM启动的时候创建。 堆所存储的就是被GC所管理的各种对象。 堆也是可以固定大小和动态调整的: 实际所需的堆超过的GC所提供的最大容量,那么JVM抛出OutofMemoryError异常。

4. Method Area(方法区)

也是各个线程共享的运行时内存区,它存储每一个类的实例信息,运行时常量池,字段和方法数据,构造函数和普通方法的字节码等内容。还有一些特殊方法。

方法区是堆的逻辑组成部分,也在JVM启动时创建,简单的JVM可以不实现这个区域的垃圾收集。

方法区也可固定大小和动态分配与堆一样,内存空间不够,那么JVM抛出OutofMemoryError异常。

5. Run-Time Constant Pool(运行时常量池)

在方法区中分配,在加载类和接口到虚拟机之后,就创建对应的运行时常量池。

它是class文件中每一个类或接口的常量池表的运行时表现形式。像字符串。Java的主要类型。

存储区域不够用时候抛出OutofMemoryError异常。

6. Native Method Stacks(原生方法栈或本地方法栈)

JDK中native的方法,System类和Thread类中有很多。使用C语言编写的方法,这个也通常叫做C stack。

可以不支持本地方法栈,但是如果支持的时候,这个栈一般会在线程创建的时候按线程分配。

与栈的错误一样,StackOverFlowError和OutOfMemeoryError.

一个案例

img

  • 一个本地变量可能是原始类型,在这种情况下,它总是“呆在”线程栈上。
  • 一个本地变量也可能是指向一个对象的一个引用。在这种情况下,引用(这个本地变量)存放在线程栈上,但是对象本身存放在堆上。
  • 一个对象可能包含方法,这些方法可能包含本地变量。这些本地变量任然存放在线程栈上,即使这些方法所属的对象存放在堆上。
  • 一个对象的成员变量可能随着这个对象自身存放在堆上。不管这个成员变量是原始类型还是引用类型。
  • 静态成员变量跟随着类定义一起也存放在堆上。
  • 存放在堆上的对象可以被所有持有对这个对象引用的线程访问。当一个线程可以访问一个对象时,它也可以访问这个对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,它们将会都访问这个对象的成员变量,但是每一个线程都拥有这个本地变量的私有拷贝。

相关面试问题

JVM堆内部有哪些组成?

堆的划分新生代和老年代,其中新生代又分为两个部分,Eden区和Survivor区。

技术分享图片

一个对象从创建到销毁在JVM中的过程?

1、默认情况下对象创建后会存储在新生代中

2、当Eden区满了之后,会发生一次minor GC,如果对象存活会被移动到Survivor区域、如果Suvivor区装不下对象,会直接移动到老年代

3、当对象如果一直都在被使用的话,那么就一直不会被回收,到了minor gc次数到了一定程度,对象移动到老年代。

4、当对象不在被使用的时候,进行回收。

JVM垃圾回收器及算法

垃圾回收算法

在查看垃圾回收具体过程的时候,运行程序加上: -XX:+PrintGCDetails打印详细的垃圾回收过程。

程序计数器,虚拟机栈,本地方法区是三个区域随着线程创建而创建,线程的销毁而销毁,不在垃圾回收的范围内。垃圾回收的区域主要集中在堆与方法区中。

引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效的时候,计数器就减1;任何时刻计数器为0的对象就是不可能再被使用的。 客观说:引用计数器实现简单,判定效率也足够高,在部分情况下是一个不错的算法。但JVM并没有使用引用计数法来管理内存。

可达性分析算法

主流的商业语言(Java,C#)都是通过可达性分析来判定对象是否存活的。 算法思路:通过一系列称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何的引用链,(也就是从GC Roots到这个对象不可达),那么证明此对象是不可用的。

如图:object5,object6,object7虽然互相有关联,但他们到GC Roots是不可达的,所以他们会被判定为可回收的对象。

img

Java中可作为GC Roots的对象:

  • 虚拟机栈(栈帧中的本地变量表引用的对象)
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(Native方法)引用的对象

标记-清除算法

如名字分为两个阶段: 标记:首先标记所有需要回收的对象,在标记完成之后统计回收所有被标记的对象,它的标记过程即为上面的可达性分析算法。 清除:清除所有被标记的对象

缺点:

  1. 效率不足,标记和清除效率都不高
  2. 空间问题,标记清除之后会产生大量不连续的内存碎片,导致大对象分配无法找到足够的空间,提前进行垃圾回收。

复制算法

将可用的内存按容量划分为大小相等的2块,每次只用一块,当这一块的内存用完了,就将存活的对象复制到另外一块上面,然后把已使用过的内存空间一次清理掉。

缺点:

  1. 将内存缩小了原本的一般,代价比较高
  2. 大部分对象是“朝生夕灭”的,所以不必按照1:1的比例划分。

现在商业虚拟机采用这种算法回收新生代,但不是按1:1的比例,而是将内存区域划分为eden 空间、from 空间、to 空间 3 个部分。 其中 from 空间和 to 空间可以视为用于复制的两块大小相同、地位相等,且可进行角色互换的空间块。from 和 to 空间也称为 survivor 空间,即幸存者空间,用于存放未被回收的对象。

在垃圾回收时,eden 空间中的存活对象会被复制到未使用的 survivor 空间中 (假设是 to),正在使用的 survivor 空间 (假设是 from) 中的年轻对象也会被复制到 to 空间中 (大对象,或者老年对象会直接进入老年带,如果 to 空间已满,则对象也会直接进入老年代)。此时,eden 空间和 from 空间中的剩余对象就是垃圾对象,可以直接清空,to 空间则存放此次回收后的存活对象。这种改进的复制算法既保证了空间的连续性,又避免了大量的内存空间浪费。

标记-压缩算法

在老年代的对象大都是存活对象,复制算法在对象存活率教高的时候,效率就会变得比较低。根据老年代的特点,有人提出了“标记-压缩算法(Mark-Compact)”

标记过程与标记-清除的标记一样,但后续不是对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存。

这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。

分代收集算法

根据对象存活的周期不同将内存划分为几块,一般是把Java堆分为老年代和新生代,这样根据各个年代的特点采用适当的收集算法。

新生代每次收集都有大量对象死去,只有少量存活,那就选用复制算法,复制的对象数较少就可完成收集。 老年代对象存活率高,使用标记-压缩算法,以提高垃圾回收效率。

垃圾回收器

img

Serial收集器

它是最基本、发展历史最为悠久的收集器,单线程的收集器,在执行收集时,必须暂停其它的工作线程,直到它收集结束。早些年Java卡顿的现象就是由它导致的。

img

两大特点:

  • 它仅仅使用单线程进行垃圾回收
  • 它独占式的垃圾回收

虽然串行收集器进行垃圾回收时给用户带来的体验极差,但是它简单高效,对于内存不是很大的场景一般停顿时间可以控制在很低几乎感知不到。只要不频繁发生,小小的停顿还是可以接受的。

Serial收集器对于运行在Client模式下的虚拟机来说是一个好的选择。

参数: -XX:+UseSerialGC 指定使用新生代串行收集器和老年代串行收集器。

ParNew收集器

它是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器所有的控制参数,实现上两者也共用了相当多的代码。

它是运行在Server模式下的首选新生代收集器。

ParNew收集器在单线程环境中,效果不会比Serial好。在CPU的数量增加下,在GC时可以更好的利用资源。

参数: -XX:+UseParNewGC 表示新生代使用并行收集器,老年代使用串行收集器 -XX:ParallelGCThreads 限制垃圾收集器的线程数

Parallel Scavenge

也是新生代收集器,也使用复制算法,又是并行的。Parallel Scanvenge收集器的特点是它的关注点是达到可控制的吞吐量(Throughout)

总运行时间 = 运行用户代码时间 + 垃圾收集时间 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

停顿时间越短越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量可以更高效的利用CPU的时间,尽快完成程序的运算任务,主要适合在后台计算而不需要太多的交互任务。

参数: -XX:+UseParallelGC:新生代使用并行回收收集器,老年代使用串行收集器。 -XX:+UseParallelOldGC:新生代和老年代都是用并行回收收集器 -XX:MaxGCPauseMillis 垃圾收集器最大的停顿时间,单位为毫秒 -XX:GCTimeRatio 直接设置吞吐量大小,垃圾收集时间占总时间的比值。 -XX:UseAdaptiveSizePolicy 打开这个参数之后,就不用手动设置新生代,老年代的比例了,虚拟机会根据当前的运行情况自动调整。自适应策略也是Parallel Scanvenge与ParNew收集器的一个区别。

Serial Old

它是Serial收集器的老年代版本,通用是单线程,使用标记压缩算法,也是主要给client模式下的虚拟机使用。

如果用在Server模式下,则是与Parallel Scavenge收集器搭配使用。

参数: -XX:+UseParallelGC:新生代使用并行回收收集器,老年代使用串行收集器。

Parallel Old

Parallel Scavenge收集器的老年代版本,使用多线程的标记-整理算法,比Serial Old收集器出现的晚,可以更充分的利用多CPU的能力。

多CPU的情况下可以使用Parallel Old

参数: -XX:+UseParallelOldGC:新生代和老年代都是用并行回收收集器

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,注重服务的响应速度,希望系统停顿时间最短,CMS收集器非常适合这类应用的需求。

使用标记清除算法,过程有4个步骤:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清除

初始标记和重新标记,两个步骤仍需要停顿,初始标记仅仅是标记一下GC Roots能直接关联到的对象,速度很快。并发阶段就是GC Root Tracing的过程,重新标记阶段则是对用户程序继续运作而导致标记变动的一部分对象标记记录,这个阶段时间也很端比初始标记长,并发标记短。

CMS的缺点:

  • 对CPU资源非常敏感,需保证足够的CPU资源
  • 标记清除算法的缺点,会有大量的空间碎片
  • 无法处理浮动垃圾,程序不断的运行就会有不断的垃圾产生,而CMS收集器无法集中处理它们,只好在下次GC时清理掉。

根据描述,可以看到CMS收集器适合那些服务器资源比较多人使用,内存充足,CPU充足,然后使用CMS收集器。

G1收集器

特点:

  • 并行与并发,停顿时间更短
  • 分代收集,仍然有分代概念
  • 空间整合,不会产生碎片
  • 可预测的停顿,可以设置停顿时间在多少范围内

在G1之前进行收集的范围都是整个新生代或老年代,而G1不再是这样,使用G1收集器的时候,Java堆的内存布局就与其它的收集器有很大的区别,它将整个Java堆划分为多个大小相等的独立区域,虽然还保有区域的概念,但新生代和老年代已经不再是物理隔离的了。

G1收集器过程:

  • 初始标记 标记GCRoots 能直接关联到的对象
  • 并发标记 进行可达性分析,找出存活对象,耗时稍长
  • 最终标记 标记并发标记期间因用户程序继续运行而导致的标记变动
  • 筛选回收 对各个Region区域回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。

现在JVM默认的收集器还不是G1收集器,但可以尝试。

ZGC(Z Garbage Collector)

从JDK11中引入的一种新的支持弹性伸缩低延迟垃圾收集器,ZGC可以工作在KB~TB的内存之下,作为一种并发的垃圾收集器,ZGC保证应用延迟不会超过10毫秒(即便在堆内存很大的情况下),在JDK11中是以实验阶段的特性被发布出来的,到JDK13时,ZGC可以支持到16TB的堆内存,并且可以将未提交的内存归还给操作系统

它可以标记内存,复制和迁移(relocate)内存,所有的操作都是并发的,同时它有一个并发的引用处理器

其它的垃圾收集器都是使用store barriers,ZGC使用load barriers,用于跟踪内存

  • lock->unlock->read->load 读内存
  • use->assign->store->write 写内存

ZGC可以更加灵活的配置大小和策略,相比于G1,它可以更好的处理非常大(very large)对象的释放

ZGC只有一代,没有新生代,老年代什么的,但是ZGC可以支持局部压缩,在内存恢复和迁移(reclaim and relocate)时,ZGC仍然有很高的性能

ZGC依赖NUMA-aware(非均衡存储器访问),需要我们的内存支持这种特点

核心原理

几个术语:

  • parallel 多个垃圾收集线程在一起工作,应用可能会停止
  • serial 垃圾收集器只有一个线程在工作
  • stop the world 应用程序停止
  • concurrent 垃圾收集器在后台运行,应用程序同时也在运行
  • incremental 在垃圾收集工作结束之前,先停止垃圾收集,等一会再过来完成剩下的工作

ZGC引入了两个新的概念,pointer coloringload barriers.

Point Coloring

这个特性让ZGC能够发现,标记,定位和重新映射对象,它只能工作在64位的操作系统上,实现colored pointer需要虚拟地址(virtual address masking)。

image-20191107074011335

  • finalizable 对象可以被finalizer到达
  • marked0 和marked1 标记可达的对象
  • remap 引用指针到当前对象的地址,对象可能会被relocate,这个地址表示对象被relocate
Load Barrier

load barrier是一段代码,当线程从堆中加载引用的时候被运行。例如,当我们访问对象的一个非主要类型的属性。

在ZGC中,load barrier检查引用的元数据位,根据元数据位对引用的对象做一些处理,因此可能在我们获取对象的时候对象的引用会被修改掉,但是不影响我们的使用。

JVM编译器&编译优化

Java语言的“编译期”,可能指的是一个前端编译期,把*.java文件转变为 *.class文件的过程;也可能是虚拟机的后端运行期编译器(JIT)把字节码转变为机器码的过程,还可能是指使用静态编译器(AOT编译器,Ahead Of Time Compiler)直接把 java文件编译成本地机器码的过程。

早期编译 & 晚期编译

早期编译:

  • javac编译器:解析与符号表填充,注解处理,生成字节码
  • java语法糖:语法糖有助于代码开发,但是编译后就会解开糖衣,还原到基础语法的class二进制文件 重载要求方法具备不同的特征签名(不包括返回值),但是class文件中,只要描述不是完全一致的方法就可以共存,如:

晚期编译:

HotSpot虚拟机内的即时编译 解析模式 -Xint 编译模式 -Xcomp 混合模式 Mixed mode 分层编译:解释执行 -> C1(Client Compiler)编译 -> C2编译(Server Compiler) 触发条件:基于采样的热点探测,基于计数器的热点探测

编译期很广,我们这里讨论即时编译器,以下简称JIT,因为JIT在运行期的优化过程对于程序的运行更加重要。

JIT简介

Java程序最初是通过解释器来解释执行的,当虚拟器发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”,为了提高热点代码的执行效率,在运行时,虚拟机会把这些代码编译为机器码,并进行各种层次的优化,完成这个任务的编译器成为即使编译器(JIT)。

简单说就是热点代码会被进行优化。

img

image.png

热点代码

  • 被多次调用的方法:方法调用的多了,代码执行次数也多,成为热点代码很正常。
  • 被多次执行的循环体:假如一个方法被调用的次数少,只有一次或两次,但方法内有个循环,一旦涉及到循环,部分代码执行的次数肯定多,这些多次执行的循环体内代码也被认为“热点代码”

检测热点代码

判断一段代码是不是热点代码,是不是需要触发JIT,这样的行为成为热点探测,主要方式有两种。

  • 基于采样的热点探测:采样,指把时间域或空间域的连续量转化成离散量的过程,也就是取一部分,周期性的检查线程的栈顶,如果发现某些方法经常出现在栈顶,即热点方法。

    缺点:不够精确,容易受到线程阻塞或外界因素的影响 优点:简单,高效

  • 基于计数的热点探测(HotSpot虚拟器默认):为每个方法甚至是代码块建立计数器,统计执行次数,如果执行次数超过一定阈值就认为是热点代码。

    缺点:实现麻烦 优点:统计结果精确

HotSpot虚拟器为每个方法准备了两类计数器:方法调用计数器和回边计数器,两个计数器都有一定的阈值,超过阈值就会触发JIT.

-XX:CompileThreshold 可以设置阈值大小,Client 编译器模式下,阈值 默认的值 1500,而 Server 编译器模式下,阈值 默认的值则是 10000。

编译器优化

当JVM编译代码时,它会将汇编指令保存在代码缓存,代码缓存具有固定大小,一旦它被填满,JVM将不能编译更多的代码。

–XX:ReservedCodeCacheSize 选项去增加代码缓存的大小。

查看编译日志

JVM启动时,-XX:+PrintCompilation,它会报告什么时候代码缓存满了,以及什么时候编译停止了。

另外可以通过jstat来查看编译器信息。

jstat -compile JVM进程ID
➜  ~ jstat -compiler 56067
Compiled Failed Invalid   Time   FailedType FailedMethod
     968      0       0     0.80          0

JVM加载类原理

JVM只认得class文件,不管是Java还是Groovy,Scala等语言,最终到JVM层面都是class文件的形式。想要了解其加载方式就需要弄清楚class文件格式。

JVM规范严格定义了CLASS文件的格式,有严格的数据结构:

详细的描述我们可以从JVM规范说明书里面查阅:docs.oracle.com/javase/spec…

加载过程

Java虚拟机加载类的全过程包括,加载,验证,准备,解析和初始化。

在加载阶段,虚拟机需要完成以下三件事:

  1. 通过类的全限名获取此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转换为方法区的运行时数据区
  3. 在内存中生成一个代表这个类的Class对象,作为方法区的这个类的各种数据访问入口。

比较两个类是否相等,只有在两个类是由同一个类加载器加载的前提下才有意义,否则尽管两个类是同一个Class文件,只要类加载器不同,那么这两个类必定不相等。

双亲委派机制

img

绝大部分Java程序都会用到以下三种系统提供的类加载器:

  • BootStrap ClassLoader,负责加载<JAVA_HOME>/lib或被-Xbootclasspath指定路径下的类库,开发者不可以直接使用
  • Extension ClassLoader,负责加载<JAVA_HOME>/lib/ext或被java.ext.dirs系统变量指定的路径中的所有类库,开发者可以直接使用
  • App ClassLoader,这个类加载器是ClassLoader.getSystemClassLoader()的返回值,负责加载用户类路径上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序没有自定义过类加载器,那么系统默认使用这个类加载器。

应用程序一般都会用到以上三种类加载器,如果有必要我们可以指定自己的类加载器。

双亲委派的工作流程:如果一个类收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委派给父类来实现,每一个层次的类加载器都是这样,因此所有的类加载请求都会最终传送到启动类加载器,只有当父类加载器无法完成这个加载请求,子类加载器才会自己尝试加载。

要点:

  • 类加载请求全部交给自己的父类来操作
  • 父类加载器加载不了的自己加载

热部署 & 类隔离

弄明白了JVM是如何加载类的,就可以通过自定义的类加载器实现热部署。

热部署实现大致有两步:

1、实现自定义类加载器,从不同的位置加载类的内容,比如可以通过配置中心配置加载地址

2、此时内存中存在新旧两个类,替换原有的类加载器即可。

加载类的核心代码区:

/**

*/
    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先检查,类是否已经被加载
            Class<?> c = findLoadedClass(name);
            
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    //只要父类不为空,那么父类来加载
                    if (parent != null) {                    
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    //父类加载器无法加载类,抛出异常
                }
                
                //如果父类没有加载成功,然后自己寻找对应的类, 我们可以实现自己的findClass,进而实现自定义类加载器
                if (c == null) {
                    long t1 = System.nanoTime();
                    c = findClass(name);                
//记录状态
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

JVM优化原则和工具

调优原则

  • 预期的效果,是否一定要调优
  • JVM版本号,操作系统,CPU
  • 调优的衡量指标:增加并发量?还是减少RT?

调优过程

参考Java performance的一张图

image-20210820083609426

调优参数

查看可用的参数:

java -X   
java -XX:+PrintFlagsFinal

主要参数:

-XX:NewRatio:年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代) -XX:SurvivorRatio:Eden区与Survivor区的大小比值 -XX:+DisableExplicitGC 关闭System.gc() -XX:MaxTenuringThread 对象在Suvivor区域中的年龄到达多少进入Old Gen -XX:PretenureSizeThreshold 另大于这个设置值的对象直接在老年代分配,避免Eden区和Suvivor区域之间大量的内存分配

image-20210820084025739

image-20210820084114209

具体参数参考:www.cnblogs.com/redcreen/ar…

调优工具

  • Java命令行
  • 在线GC分析 gceasy.io/
  • Jconsole、jivualvm
  • Arthas

jstat

最原始却最有效的一种方式,不需要对java应用做额外的配置,安装JDK的时候默认就有的工具,当我们想要了解JVM运行状态的时候,一个jstat就能满足我们大部分的需求。

1 一般jstat与java程序安装在一起,我们可以看一下。

img

2 使用

# 例如:jstat -class -t 79065 1000 5,代表监控pid为79065的jvm进程,查看其class信息,并且没1s钟监控一次,总共监控5次。
jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]

# jstat可以监控的内容如下
jstat -options
-class 统计JVM中classload的信息
-compiler  统计JIT编译器的信息
-gc  统计GC的堆内存信息
-gccapacity    统计JVM的堆内存剩余空间
-gccause  统计导致最新一次GC的原因
-gcmetacapacity
-gcnew  统计新生代的信息
-gcnewcapacity  
-gcold  统计老年代的信息
-gcoldcapacity
-gcutil  显示GC的统计信息
-printcompilation  显示JVM的编译方法统计

img

列解释:

S0C 存活区0的容量(KB) S1C 存活区1的容量(KB) S0U 存活区0使用的空间 (KB). S1U 存活区1的利用空间 (KB). EC Eden区的容量(KB). EU Eden区利用的容量(KB). OC 老年代容量(KB). OU 老年代使用容量(KB). PC 当前永久带的容量(KB). PU 永久带使用容量(KB). YGC 发生了多少次Young GC YGCT Young GC的时间 FGC Full GC的次数 FGCT Full GC的收集时间 GCT 总共的GC时间.

看到上面是不是发现,简单直接有有效。

jconsole或jvisualvm

这两个工具也是JDK自带的内容,jvisualvm可能需要在线下载一下,不过两者都是通过jmx来访问JVM然后进行统计的,在启动JVM的时候,要指定jmx的内容。

方式一:指定密码,增加安全性

java  -Dcom.sun.management.jmxremote.port=5000  -Dcom.sun.management.jmxremote.password.file=/Users/aihe/Documents/jmxremote.password   -Dcom.sun.management.jmxremote.authenticate=true -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.access.file=/Users/aihe/Documents/jmxremote.access -jar target/jmxdemo-0.0.1-SNAPSHOT.jar

img

img

img

arthas

阿里开源的一款好用的jvm监控工具,有点像是把jdk中自带的命令行工具做了集合。

1 安装

# 安装方式一
curl -L https://alibaba.github.io/arthas/install.sh | sh
# 安装方式二
java -jar arthas-boot.jar --repo-mirror aliyun --use-http

2 使用

java -jar arthas-boot.jar

img

image-20191001223248949

img

image-20191001223319224

参考地址:alibaba.github.io/arthas/inst…

JVM问题排查步骤

频繁FullGC排查方式

一般现象为CPU飙高,RT增加,另外很可能出现OOM。当碰到这种现象时,如何定位:

Jmap

打印heap的概要信息,GC使用的算法,heap(堆)的配置及JVM堆内存的使用情况.

jmap -heap pid
复制代码

打印每个class的实例数目,内存占用,类全名信息,VM的内部类名字开头会加上前缀”*”. 如果live子参数加上后,只统计活的对象数量.

jmap -histo:live pid
复制代码

输出jvm的heap内容到文件,live子选项是可选的,假如指定live选项,那么只输出活的对象到文件.

jmap -dump:live,format=b,file=myjmapfile.txt pid
复制代码

finalizerinfo 打印正等候回收的对象的信息

jmap -finalizerinfo pid
复制代码

arthas

查看当前的jvm进程堆内存状态

dashboard
复制代码

watch系统中某个方法,某个属性的值,严重对象当前状态。

# watch 类全路径  方法名   '属性或者方法的全路径引用'
watch demo.MathGame primeFactors 'target.illegalArgumentCount'
# 观察方法的第一个参数值
watch demo.MathGame primeFactors params[0]
复制代码

jmap文件分析

自带的命令分析

jhat <heap-dump-file>
复制代码

jvisuvm装入,装入时记得选hprof,有几个不同选项,apps,hprof

image-20201101232452903

建议

  • 代码中使用无界队列,如LinkedBlockingQuene一定要注意,最好设置长度限制
  • ThreadLocal用完记得remove对象

CPU飙高定位线程&死循环

配合Top命令一起使用,死循环会导致CPU不断的飙升。

1、查看导致CPU飙高的线程

top -H -p [具体进程号]
top -H -p 24278 

2、在top中使用的是10进制,在jstack中打印的线程是16进制,做一次转换。

3、在jstack中定位线程

代码中的死锁排查

查看线程堆栈信息,在发生死锁的时候可以利用这个命令查找死锁或者在发生死循环的时候利用此命令排查。

jstack之后会默认帮我们找到具体死锁是什么

img

最后

以上基本上过去JVM所学和接触中比较关键的部分了,主要参考了简书上的个人笔记:www.jianshu.com/c/a3ee92d5b… 虽然想尽可能立体和完整的把JVM精华讲出来,但JVM本身博大精深,这里只求能更全面的认识下JVM是什么,还有很多的细节暂时没有考虑到。

后续有关JVM的知识点都会尽可能的更新到这篇文章中,希望能对读者有所帮助,个人希望借此文章对JVM的认知重新升级一遍。