JVM简介
Java虚拟机 (英语:Java Virtual Machine,缩写JVM),一种能够执行Java字节码的虚拟机。 JVM屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。通过对中央处理器(CPU)所执行的软件实现,实现能执行编译过的Java程序码(Applet)与应用程序)。
下面是JVM的体系结构图示:
JVM被分为三个主要子系统:
- 类加载器(ClassLoader)
- 运行时数据区(内存区域)
- 执行引擎(Execution Engine)
类加载器(ClassLoader):
Java类加载器(Class Loader)是Java虚拟机(JVM)的一部分,负责将类文件加载到内存中,并生成对应的Java类对象。类加载器在Java应用程序的运行过程中起着重要的作用,它负责动态地加载类文件,解析类的字节码,并将其转换为可执行的Java类。它有三个主要阶段:
-
加载(Loading) :加载是一个阶段,用于加载类文件。基本上,加载分为三个部分:
- 引导类加载器(Bootstrap class loader)
- 应用类加载器(Application class loader)
- 扩展类加载器(Extension class loader)
上述类加载器在加载类文件时遵循双亲委派模型。
-
链接(Linking):链接过程将加载的类与JVM中的其他类进行关联。链接分为三个阶段:
- 验证(Verification):验证类的字节码是否符合Java虚拟机规范,并确保类的安全性。
- 准备(Preparation):为类的静态变量分配内存空间,并初始化默认值。
- 解析(Resolution):将符号引用转换为直接引用,即将类、方法、字段的引用解析为实际内存地址。
-
初始化(Initialization):初始化阶段对类进行实际的初始化操作,包括执行静态代码块和静态变量的赋值等。在此阶段,类的构造函数也会被调用。
运行时数据区
运行时数据区是指在Java应用程序运行期间,JVM用于存储和管理数据的不同区域。这些区域各司其职,确保了 Java 程序的正确执行。JVM 运行时数据区主要分为五个部分:程序计数器(Program Counter Register
)、虚拟机栈(VM Stack
)、本地方法栈(Native Method Stack
)、堆(Heap
)、方法区(Method Area
)。JVM运行时数据区在程序运行时动态地分配和释放内存,内存管理由JVM自动完成。不同的数据区域有不同的内存管理机制和垃圾回收算法,以保证程序运行的效率和稳定性。
其中程序计数器、虚拟机栈、本地方法栈属于线程私有区域,跟随线程的启动和结束而建立和销毁。堆和方法区是线程共享区域,跟随虚拟机进程的启动而存在。
程序计数器(Program Counter Register
) 是一块较小的内存空间,作用是指示当前线程正在执行的 JVM 字节码指令地址。
虚拟机栈(JVM Stack
) 存放的是一些基本类型的变量(如int, long)和对象引用。Java 方法执行的内存模型是以栈帧(Stack Frame)为基础的,每个方法在执行的时候都会创建一个栈帧,栈帧中存放了局部变量表、操作数栈、动态链接、方法出口等信息。
本地方法栈(Native Method Stack
) 与虚拟机栈类似,其主要服务于 JVM 使用到的 Native 方法。
堆区(Heap
) 是 JVM 所管理的最大一块内存空间,主要用于存放所有线程共享的 Java 对象实例。这也是垃圾回收器主要活动区域。
方法区(Method Area
) 是用来存储加载的类信息、常量、静态变量等数据的。这个区域是线程共享的。
执行引擎
执行引擎执行".class"(字节码)文件。它逐行读取字节码,利用各个内存区域中的数据和信息执行指令。执行引擎可以分为三个部分:
-
解释器(
Interpreter
):它逐行解释字节码并执行。缺点是当一个方法被多次调用时,每次都需要解释执行。 -
即时编译器(
Just-In-Time Compiler,JIT
):用于提高解释器的效率。它将整个字节码编译为本机代码,因此当解释器遇到重复的方法调用时,JIT会直接提供相应的本机代码,无需重新解释执行,从而提高效率。 -
垃圾回收器(
Garbage Collector
):它销毁无引用的对象。有关垃圾回收器的更多信息,请参考垃圾回收器。
Java本地接口(Java Native Interface,JNI
):
它是与本地方法库交互的接口,提供执行所需的本机库(C、C++)。它使JVM能够调用C/C++库,并被C/C++库调用,这些库可能是特定于硬件的。
本机方法库(Native Method Libraries
):
它是执行引擎所需的一组本机库(C、C++)的集合。
运行时数据区详解
程序计数器
程序计数器(Program Counter Register
)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为"线程私有"的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined
)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError
情况的区域。
Java虚拟机栈
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks
)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型 : 每个方法在执行的同时都会创建一个栈帧(Stack Frame
)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表主要存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不等同于指针,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。
操作数栈则是在执行字节码指令时用到的临时存储区,比如在进行算数运算时,操作数栈就会用来存放操作数和接收结果。
本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
- 与 Java 环境外交互:有时 Java 应用需要与 Java 外面的环境交互,这就是本地方法存在的原因。
- 与操作系统交互:JVM 支持 Java 语言本身和运行时库,但是有时仍需要依赖一些底层系统的支持。通过本地方法,我们可以实现用 Java 与实现了 jre 的底层系统交互, JVM 的一些部分就是 C 语言写的。
- Sun's Java:Sun的解释器就是C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分都是用 Java 实现的,它也通过一些本地方法与外界交互。比如,类
java.lang.Thread
的setPriority()
的方法是用Java 实现的,但它实现调用的是该类的本地方法setPrioruty()
,该方法是C实现的,并被植入 JVM 内部。
在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
Java堆
对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么"绝对"了。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做"GC堆"(Garbage Collected Heap
)。为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域(分代的唯一理由就是优化 GC 性能):
- 新生代(年轻代):新对象和没达到一定年龄的对象都在新生代
- 老年代(养老区):被长时间使用的对象,老年代的内存空间应该要比年轻代更大
- 元空间(JDK1.8之前叫永久代):像一些方法中的操作临时对象等,JDK1.8之前是占用JVM内存,JDK1.8之后直接使用物理内存
年轻代 (Young Generation)
年轻代是所有新对象创建的地方。当填充年轻代时,执行垃圾收集。这种垃圾收集称为Minor GC 。年轻一代被分为三个部分------伊甸园(Eden Memory )和两个幸存区(Survivor Memory,被称为from/to或s0/s1),默认比例是8:1:1
老年代(Old Generation)
旧的一代内存包含那些经过许多轮小型 GC 后仍然存活的对象。通常,垃圾收集是在老年代内存满时执行的。老年代垃圾收集称为主GC,通常需要更长的时间。
大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝。
方法区
不管是 JDK8 之前的永久代,还是 JDK8 及以后的元空间,都可以看作是 Java 虚拟机规范中方法区的实现。
虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
在JVM中,运行时常量池是线程安全的。每个线程都有一个自己的线程栈,其中包含了局部变量表,而这些局部变量表中所引用的对象都位于堆中。当一个线程需要引用运行时常量池中的常量时,JVM会先将常量值从运行时常量池中复制到线程栈的局部变量表中,然后再进行引用。
为什么要移除持久代
HotSpot团队选择移除持久代,有内因和外因两部分,从外因来说,我们看一下JEP 122的Motivation(动机)部分:
This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.
大致就是说移除持久代也是为了和JRockit进行融合而做的努力,JRockit用户并不需要配置持久代(因为JRockit就没有持久代)。
从内因来说,持久代大小受到-XX:PermSize和-XX:MaxPermSize两个参数的限制,而这两个参数又受到JVM设定的内存大小限制,这就导致在使用中可能会出现持久代内存溢出的问题,因此在Java 8及之后的版本中彻底移除了持久代而使用Metaspace来进行替代。
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
操作直接内存
在 NIO 中引入了一种基于通道和缓冲的 IO 方式。它可以通过调用本地方法直接分配 Java 虚拟机之外的内存,然后通过一个存储在堆中的DirectByteBuffer
对象直接操作该内存,而无须先将外部内存中的数据复制到堆中再进行操作,从而提高了数据操作的效率。
直接内存的大小不受 Java 虚拟机控制,但既然是内存,当内存不足时就会抛出 OutOfMemoryError 异常。
栈、堆、方法区的关系
逃逸分析
逃逸分析(Escape Analysis
)是目前 Java 虚拟机中比较前沿的优化技术。这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
-
当一个对象在方法中被定义后,它可能被外部方法所引用,如作为调用参数传递到其他地方中,称为
方法逃逸
。 -
再如赋值给类变量或可以在其他线程中访问的实例变量,称为
线程逃逸
-
使用逃逸分析,编译器可以对代码做如下优化:
- 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 将堆分配转化为栈分配:如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
- 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。
Java
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer s = new StringBuffer();
s.append(s1);
s.append(s2);
return s;
}
s 是一个方法内部变量,上边的代码中直接将 s 返回,这个 StringBuffer 的对象有可能被其他方法所改变,导致它的作用域就不只是在方法内部,即使它是一个局部变量,但还是逃逸到了方法外部,称为方法逃逸
。
还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸
。
- 在编译期间,如果 JIT 经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。
- jvm 参数设置,
-XX:+DoEscapeAnalysis
:开启逃逸分析 ,-XX:-DoEscapeAnalysis
: 关闭逃逸分析 - 从 jdk 1.7 开始已经默认开始逃逸分析。
TLAB
- TLAB 的全称是 Thread Local Allocation Buffer,即线程本地分配缓存区,是属于 Eden 区的,这是一个线程专用的内存分配区域,线程私有,默认开启的(当然也不是绝对的,也要看哪种类型的虚拟机)
- 堆是全局共享的,在同一时间,可能会有多个线程在堆上申请空间,但每次的对象分配需要同步的进行(虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性)但是效率却有点下降
- 所以用 TLAB 来避免多线程冲突,在给对象分配内存时,每个线程使用自己的 TLAB,这样可以使得线程同步,提高了对象分配的效率
- 当然并不是所有的对象都可以在 TLAB 中分配内存成功,如果失败了就会使用加锁的机制来保持操作的原子性
-XX:+UseTLAB
使用 TLAB,-XX:+TLABSize
设置 TLAB 大小
对象分配过程
通过逃逸分析、TLAB我们再看下对象的分配过程:
- 根据逃逸分析判断对象是否在栈上分配
- 如果栈上分配,使用标量替换方式,把对象分配到
VM Stack
中。如果线程销毁或方法调用结束后,自动销毁,不需要GC
介入。
- 如果栈上分配,使用标量替换方式,把对象分配到
- 判断是否是大对象
- 如果是,直接分配到堆上
Old Generation
老年代上。如果对象变为垃圾后,由老年代GC
回收。
- 如果是,直接分配到堆上
- 判断是否在
TLAB
中分配- 如果是,在
TLAB
中分配堆上Eden
区。 - 否则,在
TLAB
外堆上的Eden
区分配。
- 如果是,在
- 如果创建新对象时,
Eden
空间填满了,就会触发Minor GC
,将 Eden 不再被其他对象引用的对象进行销毁,再加载新的对象放到 Eden 区,特别注意的是Survivor
区满了是不会触发Minor GC
的,而是 Eden 空间填满了,Minor GC
才顺便清理Survivor
区 - 将 Eden 中剩余的对象移到
Survivor0
区 - 再次触发垃圾回收,此时上次
Survivor
下来的,放在Survivor0
区的,如果没有回收,就会放到Survivor1
区 - 再次经历垃圾回收,又会将幸存者重新放回
Survivor0
区,依次类推 - 默认是 15 次的循环,超过 15 次,则会将幸存者区幸存下来的转去老年区 jvm 参数设置次数 :
-XX:MaxTenuringThreshold=N
进行设置