十分钟带你深入了解JVM运行机制

一、JVM

什么是JVM

JVM(Java Virtual Machine)其实是一套标准。通过定义虚拟机,像真实计算机一样,能够运行字节码指令。JVM的好处是可以屏蔽操作系统的细节, 使Java可以一次编写,到处运行。

实现JVM的厂商有很多,比如Hotspot、JRockit、IBM J9等等。今天我们重点来聊一聊主流的Hotspot,因为Oracle JDK与OpenJDK都是采用HotSpot VM。从源码层面说,它们俩基本上没什么区别。

JVM的作用

JVM是Java字节码执行的引擎,为Java程序的执行提供必要的支持,它还能优化Java字节码,使之转换成效率更高的机器指令。程序员编写的程序最终都要在 JVM 上执行,JVM 中类的装载是由类加载器(ClassLoader)和它的子类来实现的。ClassLoader是Java运行时一个重要的系统组件,负责在运行时查找和装入类文件的类。

JVM屏蔽了与具体操作系统平台相关的信息,从而实现了Java程序只需生成在JVM上运行的字节码文件(class 文件),就可以在多种平台上不加修改地运行。不同平台对应着不同的JVM,在执行字节码时,JVM负责将每一条要执行的字节码送给解释器,解释器再将其翻译成特定平台环境的机器指令并执行。Java语言最重要的特点就是跨平台运行,使用JVM就是为了支持与操作系统无关,实现跨平台运行。

JVM的架构设计

JVM的架构设计,总体来看HotSpot VM 主要由3个核心部分组成:

  1. 类装载子系统(Class Loader Subsystem)。
  2. 运行时数据区(Runtime Data Areas)。
  3. 执行引擎(Execution Engine)。

jvm-

将编译好的.class文件装载到类加载子系统,它的主要功能是查找并验证类文件、完成相关内存空间的分配和对象赋值。

类文件加载到内存之后由运行时数据区来完成数据存储和数据交换。运行时数据区又分为线程共享内存区和线程隔离内存区。线程共享内存区包括方法区和堆区,它们是程序员能够通过编写代码直接操作的内存区,而线程隔离内存区包括栈区、程序计数器和本地方法栈,它们是完全由JVM来调度的内存区域。

程序计数器(线程私有)

一块较小的内存空间, 是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器,这类内存也称为“线程私有”的内存。 正在执行 java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如果还是 Native 方法,则为空。这个内存区域是唯一一个在虚拟机中没有规定任何 OutOfMemoryError 情况的区域。

虚拟机栈(线程私有)

每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

堆(Heap-线程共享)-运行时数据区

是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最重 要的内存区域。由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以 细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代。

方法区/永久代(线程共享)

即我们常说的永久代(Permanent Generation), 用于存储被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据. HotSpot VM 把 GC 分代收集扩展至方法区, 即使用 Java 堆的永久代来实现方法区, 这样 HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版 本、字段、方法、接口等描述等信息外,还有一项信息是常量池

(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

垃圾回收算法和策略

什么是垃圾回收机制(GC)

通俗的说:

  • GC过程:停车场(堆)保安(GC)让很久不用废弃的汽车(无用的对象)从车位挪走的过程,就叫做GC。
  • 内存泄露:废弃没有人使用的汽车无法挪走。
  • 内存溢出:停车场所有车位都有车子占满了,再来车子没地方停了。

想深入理解GC的原理,必须先要讲一下JVM内存管理机制,因为这样我们才能知道哪些对象、什么时候回收以及怎么回收。

GC机制

有三个是不需要进行垃圾回收的:本地方法栈、程序计数器、虚拟机栈。因为它们的生命周期是和线程同步的,随线程的消耗占用的内存会自动释放。

1.查找算法

经典的引用计数法,每个对象添加到引用计数器,每被引用一次,计数器+1,时区引用计数器-1,在计数器在一段时间内为0时,即认为该对象可以被回收了。但是这个算法有个明细的缺陷:当两个对象互相引用时,但是二者都已经没有作用时,理应把它们都回收,但是由于它们互相引用不符合垃圾回收条件,所以就导致无法处理。

因此Sun的JVM没有采用这种算法,而是采用一个叫—根搜索算法(可达性算法)

基本思想是:从一个叫GC Roots的根节点触发,向下搜索,如果一个对象不能达到GC Roots的时候,说明该对象不能被引用,可以被回收。

这样就解决了利用计数算法的缺陷。那什么样的类需要被回收:

该类的所有实例都已经被回收;加载该类的ClassLoad已经被回收;该类对应反射类java.lang.Class对象没有被任何地方引用;

2.内存分区

内存主要被分为三块:新生代(Young Generation)、旧生代(Old Generation)、持久代(Permanent Generation)。三代的特点不同,造就了他们使用的GC算法不同,新生代生命周期短,快速创建和销毁的对象,旧生代时候生命周期较长的对象,持久度Sun Hotspot虚拟机中就是指方法区。

jvm-6

  • 新生代:大致分为Eden区和Survivor区,Survivor区有分为相同的两部分:FromSpace和ToSpace。新建对象都是从新生代分配内存,Eden区不足时,会把存活的对象转移到Survivor区。当新生代进行垃圾回收时会发出Minor GC(也叫做Young GC)
  • 旧生代:旧生代用于存放新生代多次回收依然存活的对象,如缓存对象。当旧生代满了的时候需要对旧生代进行回收,旧生代的来及回收称作Major GC(也叫做Full GC)。
  • 持久代:在Sun JVM中就是方法区的意思,尽管大多数JVM没有这一代。

3.GC算法

复制:复制算法采用的方式为从根集合进行扫描,将存活的对象移动到一块空闲的区域,当存活对象较少时,复制算法会比较高效(新生代Eden区就是采用这种算法),其带来的成本需要一块额外的空闲空间和对象移动。

标记-清除:该算法采用的方式是从根集合开始扫描,对存活对象进行标记,标记完毕后,再赛马整个空间中未被标记的对象,并进行清除。标记和清除的过程如下:

gc-1

gc-2

标记-清除不需要移动对象,且仅对不存活的对象进行清理,在空间中存活对象较多时效率比较高,但由于只清除没有重新整理,因此会造成内存碎片。

标记-清除-压缩:该算法与标记-清除算法类似,但是在清除后会把活的对象向左端空闲空间移动,然后再更新其引用对象的指针。

gc-3

由于进行了移动规整动作,该算法避免了标记-清除的碎片问题,由于需要移动,因此成本增加了(该算法使用于旧生代)

四垃圾收集器

在JVM中,GC是由垃圾收集器来执行,所以在实际应用场景中,我们需要选择合适的垃圾收集器,是client端默认的GC方式。

1.串行收集器(Serial GC)

在串行收集器中minor 和 majorGC 过程都是用一个线程进行回收的。它的最大特点是进行垃圾回收时,需要对所有在执行的线程暂停(STW)。

2.ParNew GC

基本和Serial GC一样,但本质是加入了多线程机制,提高效率,这样它可以被用于server端

3.Parallel Scavenge GC

在整个扫描和复制过程采用多线程的方式进行,适用于多CPU、多暂停时间要求较短的应用,是server基本的默认GC方式。

4.CMS(Concurrent Mark Sweep)收集器

该收集器目标是解决Serial GC 停顿问题,以达到最短回收时间。常见B/S架构的应用就适合这种收集器,因为其高并发、高响应的特点,CMS是基于标记-清除算法实现的。

CMS优点:并发收集、低停顿、但远没有达到完美。

缺点:

  • 对CPU资源非常敏感,在并发阶段虽然不会导致用户停顿,但是会占用CPU资源而导致应用程序变慢,总吞吐量下降。
  • 无法处理浮动垃圾,可能出现”Concurrent Mode Failure“,失败而导致另一次Full GC。
  • 基于标记清除算法的实现,因此会产生碎片。

5.G1收集器

相比CMS收集器有不少改进,首先基于标记-压缩算法,不会产生内存碎片,汽车客运比较精准的控制停顿。

6.Serial Old收集器

Serial 收集的旧生代版本,使用”标记-压缩“算法。主要使用在Client模式下的虚拟机。

7.Parallel Old 收集器

是Parallel Scavenge 收集器旧生代版本,使用多线程和”标记-压缩“算法。

二、类加载器

类加载器的作用

ClassLoader是Java的核心组件,所有的Class都是由ClassLoader进行加载的,ClassLoader负责通过各种方式将Class信息的二进制数据流读入JVM内部,转换为一个与目标类对应的java.lang.Class对象实例。然后交给Java虚拟机进行链接、初始化等操作。因此,ClassLoader在整个装载阶段,只能影响到类的加载,而无法通过ClassLoader去改变类的链接和初始化行为。

类加载器的分类和关系

并不是所有的类都是由同一个类加载器加载的,广义的来说,JVM类加载器分为两大类,分别为启动类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)。

JAVA虚拟机规范定义,所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。比如jdk内置的扩展类加载器(Extension ClassLoader)和应用类加载器(Application ClassLoader)都属于自定义类加载器,他们之间的关系如下:

jvm-5

  • 除了顶层的启动类加载器外,其余的类加载器都应当有自己的“父类”加载器。
  • 不同类加载器看似是继承(Inheritance)关系,实际上是包含关系。在下层加载器中,包含着上层加载器的引用。
启动类加载器(Bootstrap ClassLoader)
  • 这个类加载使用C/C++语言实现的,嵌套在JVM内部。
  • 它用来加载Java的核心库(JAVAHOME/jre/lib/rt.jar或sun.boot.class.path路径下的内容)。用于提供JVM自身需要的类。也就是说只加载包名为java、javax、sun等开头的类。
  • 并不继承自java.lang.ClassLoader,没有父加载器。
  • 被启动类加载器加载的类,获取他们的类加载器为null, 也就是说启动类加载器没有这个java类,因为她是C/C++实现的。
扩展类加载器(ExtClassLoader)
  • Java语言编写,继承于ClassLoader类, 由sun.misc.Launcher$ExtClassLoader实现。它的父类加载器为启动类加载器。
  • 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
系统类加载器(AppClassLoader)
  • Java语言编写,继承于ClassLoader类, 由sun.misc.Launcher$AppClassLoader。它的父类加载器为扩展类加载器。
  • 它加载环境变量classpath和系统属性java.class.path 指定路径下的类库,比如我们自己写的java类就是由系统类加载器加载的。
  • 通过ClassLoader的getSystemClassLoader()方法可以获取到该类加载器。

类加载器特点

  • 类的唯一性,对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在Java虚拟机中的唯一性。也就是说,比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。
  • 可见性,子类加载器可以访问父类加载器加载的类型,反之不行。也就是可以调用AppClassLoader中loadClass加载我们自己写的Person类,但是不能用ExtClassLoader加载我们写的Person类。
  • 单一性,已经被父类加载器加载过的类,不会重复加载,可以直接使用。

三、内存模型

Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在。

它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

jvm-3

Java虚拟机规范定义了Java内存模型(Java Memory Model,JMM)来实现屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。要抓住重点:屏蔽硬件差异,保证并发。而程序的功能就是数据流的交互,所以保证数据的快速、正确访问就是Java内存模型的核心。

jvm-4

需要注意的是,JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性展开的(稍后会分析)。JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。

现在总结一下JMM模型的三大特性(要和事务的ACID对比):

(1) 原子性:JMM会保证read/load/assign/use/store/write的原子性,如果需要更大范围的原子性,可以使用lock和unlock,这个从代码层面来看就是synchronized

(2) 可见性:可见性就是当一个线程修改了共享变量的值,其他线程能立即得知这个修改。而volatile就是搞这个的。JMM通过变量修改后回写主内存,读取前从主内存刷新变量值这种依赖主内存作为中介的方法实现可见性,只不过volatile的特殊规则能使新值立即同步主内存,使用前立即从主内存刷新。所以可以说volatile保证了多线程操作时变量的可见性,而普通变量不能保证这一点。除了volatile之外,Java还有两个关键字能实现可见性:synchronized和final,synchronized是由“对一个变量执行unlock操作之前,必须把此变量的值回写到主内存”这条规则实现的。

(3) 有序性:JMM的有序性可以总结为一句话:本线程内观察,所有的操作都是有序的;如果是旁观者线程,被观察线程的操作都是无序的。前半句是指”线程内表现为串行的语义“,后半句是指”指令重排序“和”工作内存和主内存存在同步延迟“的现象。Java语言提供了volatile和synchronized保证线程之间操作的有序性。

五、调优和监控

JVM调优的方法和工具

JVM性能调优的目的

对JVM内存的系统级的调优主要目的:是减少GC的频率和Full GC的 次数

Full GC

会对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以比较慢,因此应该尽可能减少Full GC的次数。

导致Full GC的原因

1)老年代被写满

调优时尽量让对象在新生代GC时被回收、让对象在新生代多存活一段时间和不要创建过大的对象及数组避免直接在老年代创建对象。

2)持久代空间不足

增大Perm Gen空间,避免太多静态对象,控制好新生代和老年代的比例,在对JVM调优的过程中,很大一部分工作就是对Full GC的调节。

JVM性能调优方法和步骤

1.监控GC的状态

使用各种JVM工具,查看当前日志,分析当前JVM参数设置,并且分析当前堆内存快照和gc日志,根据实际的各区域内存划分和GC执行时间,进行优化。

举例:系统崩溃前的一些现象:

  • 每次垃圾回收时间越来越长,由之前的10ms延长到50ms左右,FullGC的实际也由之前的0.5s延长到4、5s
  • FullGC的次数越来越多,最频繁时隔不到1分钟就进行一次FullGC
  • 老年代内存越来越大且每次FullGC后老年代没有内存被释放

主键到达OutOfMemoryError的临界值,这个时候就需要分析JVM内存快照dump。

2.生成堆的dump文件

通过JMX的MBean生成当前的Heap信息,大小为一个3G(整个堆的大小)的hprof文件,如果没有启动JMX可以通过jmap命令来生成改文件。

3.分析dump文件

打开这个3G的堆信息文件,显然一般的Window系统没有这么大的内存,必须借助高配置的Linux,几种工具打开该文件:

  • Visual VM
  • IBM HeapAnalyzer
  • JDK 自带的Hprof工具
  • Mat(Eclipse专门的静态内存分析工具)推荐使用
4.分析结果,判断是否需要优化

如果各项参数设置合理,系统没有超时日志出现,GC频率不高,GC耗时不高,那么没有必要进行GC优化,如果GC事件超过1-3秒,或者频繁GC,则必须优化。

注:如果满足下面的指标,则一般不需要进行GC优化:

  • MinorGC执行时间不到50ms;
  • MinorGC执行不频繁,约10秒一次;
  • Full GC执行时间不到1s;
  • Full GC执行频率不频繁,不低于10分钟1次;
5.调整GC类型和内存分配

如果内存分配过大或过小,或者采用的GC收集器比较慢,则应该优先调整这些参数,并且找1台或几台机器进行beta,然后对比优化过的机器和没有优化的机器的性能对比,并有针对性的作出最后选择。

6.不断的分析和调整

通过不断的实验和试错,分析并找到最合适的参数,如果找到最合适的参数,则姜这些参数应用到所有服务器。

0%