深入理解Java虚拟机技术-JVM的基本组成


1. JVM内存模型

1.1 Java程序的执行流程

Java是一门面向虚拟机的程序设计语言,开发者所编写的代码本质上都属于虚拟机代码,这样在每次执行Java程序的时候就都必须启动Java虚拟机的进程来进行相关代码的解析执行,而一个常见的Java程序执行流程如下:

  • 编写.java源程序文件,而后使用JDK提供的编译器javac.exe命令(java compile)将.java程序编译为JVM可以使用的.class字节码文件。该字节码文件为虚拟机可以使用的程序文件。
  • 当进行Java类程序类解析的时候,启动java.exe启动JVM进程的时候,JVM会通过类加载器(可以由使用系统默认的类加载器,也可以采用自定义的类加载器)加载各个类的字节码文件,随后会将操作继续交给JVM执行引擎处理。
  • 虚拟机的代码如果想要被JVM所执行,那么肯定就需要通过ClassLoader加载了,这个要求一定要有CLASSPATH属性
  • ClassPath属性是啥?简单来说,ClassPath属性就是用来给ClassLoader指路的,我们通过操作系统终端设置环境变量,来对这个环境变量进行指定的。
  • 一般来说,用户设置的CLASSPATH这个变量设置的是当前目录路径,这样的话便于编译与运行。
  • 用户指定的PATHCLASSPATH有什么区别?
  • PATH是操作系统指定的,定义了所有可执行程序的路径
  • ClassPath是JRE提供的,它用于定义Java程序解释时类的加载路径。
  • Java项目在运行过程之中经常需要使用本地的操作系统提供的方法库native方法,这样就可以通过本地方法接口实现本地方法库的调用
  • 在整个程序执行过程之中,JVM会使用一段空间来存储程序执行期间所需要使用到的数据和相关信息,而这段空间就被称为运行时数据区,也就是JVM的内存空间

Java程序中的内存管理实际上指的就是对运行时数据区这段空间的管理操作,所有对象的创建、操作数据的存储都在此内存空间中完成,而为了对该内存空间进行更加详细的划分,得到各个子内存的区域。

  • 线程共享区域:方法区、堆内存(当同一个堆内存被不同的栈内存所访问的时候就会产生同步问题,简单来说就是临界资源(临界区)被不同的线程访问了,这时候就涉及到同步和互斥的问题)
  • 线程独享区域:栈内存(保存的是一个地址信息,简单点理解就是保存的是对象名称)、程序计数器、本地方法栈

1.2 程序计数器PC(Program Counter Register)

程序计数器在编译的时候会为每一个字节码文件中的程序代码分配一个程序位置,用来标记下一个要执行的指令的位置。PC可以被理解为行号的指示器,Java解释器在执行字节码文件的时候,会依据行号的顺序一直向下执行。PC的责任就是告诉你每一行代码执行结束之后下一行应该要执行哪些。

每一个线程都有一个独立的程序计数器,只路该线程的执行顺序,所以它属于线程的私有区域,并且该区域所占用的内存空间很小。

操作系统层面上的PC

为了保证程序(在操作系统中理解为进程)能够连续地执行下去,CPU必须具有某些手段来确定下一条指令的地址。当执行一条指令时,首先需要根据PC中存放的指令地址,将指令由内存取到指令寄存器(IR)中,此过程称,为“取指令”。与此同时,PC中的地址或自动加1或由转移指针给出下一条指令的地址。此后经过分析指令,执行指令。完成第一条指令的执行,而后根据PC取出第二条指令的地址,如此循环,执行每一条指令。

1.3 栈内存(Stack)

栈内存又被称为Java虚拟机栈,每个线程都有私有的栈内存空间。线程对象每调用一个方法都会自动产生一个栈帧,这些栈帧保存在该栈之中,并遵循先进先出的栈的数据结构原则实现数据的存储。

栈帧的组成

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,每一个线程都会提供各自的栈帧集合,栈帧中保存了一个方法从开始调用到执行完毕的整个过程。

  • 局部变量表

一组变量值的存储空间,用于存放方法参数和方法内部定义的所有局部变量。并且在其进行编译的时候就已经为其分配局部变量表的最大容量。局部变量表的容量以变量槽slot为最小的存储单位。

每个变量槽都可以存储32位长度地数据,如int,byte,boolean等类型的数据,而对于64位的数据,如long或者double类型的数据,Java虚拟机将会写入两个连续的变量槽以实现其变量的存储

变量槽的复用

所有的方法调用都会占用栈帧空间,为了尽可能地节省栈帧空间,局部变量表中的变量槽是可以重用的,如果计数器的指令已经超出了某个变量的作用域(利用到它的代码已经全部执行完毕了)。

这个变量对应的变量槽就可以交给其他变量来使用,但是这样的处理会对垃圾收集的性能产生影响。

例如,在执行某些较为繁琐的处理方法的时候,一般会占用比较多的变量槽,如果在执行完毕后没有对变量槽赋予新的内容或者清空变量槽数据,则垃圾收集器是无法及时地进行内存回收的

  • 操作数栈

表达式的计算是在栈中实现存储的,所有的方法的计算处理操作过程中,会有各种字节码的指令通过操作数栈来实现内容的读写(入栈和出栈)操作。以完成最终的就散处理操作。操作数栈和局部变量表类似,会在代码编译的时候分配所需局部变量表的最大容量

  • 常量引用池

在代码中使用final修饰的成员属性,会在程序编译的时候载入常量池,常量池的可以实现数据共享,这样可以避免频繁地创建和销毁对象而带来性能下降

在Java中的常量池分为两种:静态常量池运行时常量池

  • 静态常量池

其中静态常量池是在代码编译的时候确定的,包含字符串常量、类与方法的信息

  • 运行时常量池

动态运行时常量是在Java虚拟机完成类的加载后和程序运行后得到常量的。

  • 方法返回地址

每一个方法执行完成后,都需要返回该方法的调用处,这样程序才可以继续执行。在Java中返回的手段有return异常中断

  • 动态连接

每个栈帧都包含一个指向当前方法所在类的动态运行时常量池,这样在当前方法执行时如果要调用其他方法,就可以通过动态运行时查那个良找到对应的符号引用,然后将符号引用转为直接引用。最终实现对应方法的调用。

每一个线程对象都具有自己的栈内存空间,其中可能产生两类错误StackOverFlowErrorOutOfMemoryError。如果某一个线程调用某项操作时,栈的深度大于虚拟机允许的最大深度,就会抛出 栈溢出异常,但是大多数Java虚拟机都允许动态扩展虚拟机栈的大小,有小部分是固定长度的,因此线程一致可以申请更多的内存空间,一但出现了内存不足,就会OOM

参数传递和参数共享

在JVM中,两个栈帧是彼此完全独立的,但是JVM会根据环境进行响应的优化,使得两个栈桢出现部分区域的重叠。

1.4 本地方法栈

Java程序在执行时需要调用本地操作系统所提供的C函数,这些操作并不受JVM的限制,而是执行这些操作所需要的数据都被保存在本地方法栈Native Method Stack之中

1.5 方法区

方法区是整个JVM中非常重要的一块内存区,此块区域是所有线程对象共享的区域,在方法区中保存了每一个类的信息(类名称、方法信息、成员信息、接口信息等)、静态变量、常量、常量池信息。一般而言在方法区很少执行垃圾收集操作

1.6 堆内存

堆内存主要保存的是具体的数据信息,在JVM启动的时候自动会进行创建,此内存空间为所有线程对象共享区域,但是在Java开发之中,开发人员可以不处理此空间的释放,它会由Java垃圾收集器自动进行释放,所以此空间为垃圾收集器的主要管理区域。

堆内存是JVM优化的重点。

1.7 直接内存

直接内存并不会受JVM控制,它指的是在虚拟机之外的主机内存,JDK提供一种基于通道Channel和缓冲区Buffer的内存分配方式,会将本地方法库分配在直接内存,并通过存储在JVM堆内存中的直接内存映射来引用,由于直接内存受本机系统内存的限制,所以也有可能OOM

1.8 补充:String常量池设置

String概述

String是被声明为final的,因此是不可被继承的,它实现了Serializable,表明字符串是可以被序列化的。其内部定义了final char[] value,用于存储字符串数据。区别于new给一个字符串赋值,字符串值声明在字符串常量值中。字符串中的常量值是不会存储相同的内容的。

String的不可变性

当对字符串进行重新赋值的时候,需要重写指定内存区域赋值,不能使用原有的value进行赋值。这句话的含义是说新的value不会在旧的value上进行修改而成为一个新的value,而是重新new出一个内存区域,原value和新value各占一个区域。

String实例化的方式

  • 通过字面量定义的方式

这种方式的话,由于常量池中不会有两个相同内容的常量,因此s1和s2都指向同一个内存区域,也就是s1和s2的内存地址是一样的

//通过字面量定义的方式:此时数据abc声明在方法区中的字符串常量池中
      String s1="abc";
      String s2="abc";
  • 通过new + 构造器()的方式
//通过new+构造器的方式: 此时s3,s4保存的地址值,是数据在堆空间中开辟以后对应的地址值
      String s3 = new String("abc");
      String s4 = new String("abc");

虽然s3和s4的内容一样,但是s3和s4的内存地址不一样。

“a”+”b”+”c”一共创建了多少个对象?

  • String str1 = “a”+”b”+”c”

这种情况下由于是字面量式声明,如果编译器进行优化的话,这时候就会被优化为

String str1 = "abc",这时候会产生一个地址引用常量指向常量池中的”abc”,因此创建一个对象

如果编译器不进行优化的话,就会产生5个对象,分析如下:

“a”,”b”,”c”,”ab”,”abc”一共五个字符串常量对象

  • String str1 = new String(“abc”)

这时候采用的是new+构造器的方式,因此这时候堆内存中会有一个字符串对象,指向常量池中的对象,创建了两个对象

String a = “a”;
String b = “b”;
String c = “c”;
String str = a + b + c;

这种情况下编译器会将其优化为StringBuilder,因此会创建三个对象

new StringBuilder、new String、”abc”常量

2.Java对象访问模式

在Java程序开发之中对象是基础的组成单元,所有的对象数据全部都保存在堆内存之中,在栈中会保存指定堆内存的起始地址,而所有的对象信息都会在栈内存的局部变量表中进行存储,对象类型的相关数据则会存储在方法区之中。

引用类型在Java虚拟机规范之中只规定了一个指向对象的引用,并没有规定使用哪种方式去定位,所以不同的虚拟机规范中对引用对象的访问方式可能有所不同,常见的访问方式有:

  • 句柄访问方式:在堆内存空间中单独划分一块新的内存空间,作为句柄池的存储空间,每个句柄池可以保存多组句柄数据,每组句柄数据包含对象类型数据(该数据是保存在方法区的)的指针与对象实例数据的指针。在进行引用操作的时候,局部变量表会保存句柄的内存引用地址,而后根据句柄池中的数据找到对象的类型与实例内容。

句柄:是一个用来标识对象或者项目的标识符,可以用来描述窗体、文件、对象等,它在C++程序设计中经常被提及,它并不是一种具体的、固定不变的数据类型或者实体,而是代表了程序设计中的一个广义概念。它是指获取另一个对象的方法,一个广义的指针,它的目的就是建立起与被访问对象之间的一个唯一联系。

  • 指针访问方式:在Java堆内存之中直接保存对象的实例数据,在进行引用操作的时候,局部变量表会保存该实例数据的内存引用地址,而对象的相关类型数据可以直接通过方法区进行加载。

使用句柄访问方式的最大优势在于:当进行对象回收的时候,只会修改句柄中的指针数据,而局部变量表中的引用地址不会发生任何的改变,但是需要二次寻址,所以性能较差。而使用指针访问方式速度较快,在HotSpot虚拟机标准中就是基于指针访问方式实现对象访问的。

3. JIT编译器

为了不断提升执行的性能,HotSpot虚拟机在其内部引入了JIT(Just-In-Time)处理机制,其最大的特点就是在于程序运行期间对热点代码进行二次编译。

HotSpot虚拟机中,Java是通过解释器(Interpreter)实现代码的运行的,当某些代码执行较为频繁的时候,JVM就会认为这些代码是热点代码,为了提高热点代码的执行效率,JVM会将这些热点代码编译为与本地平台相关的机器码,并进行各种层次的优化,此时的操作就是通过JIT编译来完成的。

JIT编译器不是虚拟机的必需部分

Java虚拟机规范中并没有规定Java虚拟机内必须有JIT编译器,更没有限定或者指导JIT编译器应该如何去实现。但是,JIT编译器编译性能的好坏、代码优化程度的高度是衡量一款商用虚拟机优秀与否的关键指标,它也是虚拟机中最能体现虚拟机技术水平的核心部分

当程序需要迅速启动和执行的时候,解释器首先可以发挥作用,省去编译的时间,立即执行,在程序运行后,随着时间的推移,JIT编译器发挥作用,把越来越多的代码编译成机器码之后,可以获得更高的执行效率,如果程序运行环境中(如嵌入式系统)内存资源限制较大,可以使用解释器执行来执行来节约内存,否则可以使用编译器执行来提升效率,但是并非全部的代码都可以进行二次编译。

Java解释器:解释器是Java虚拟机非常重要的一部分,它的工作就是把字节码转化为机器码并在特定的平台进行运行。简单一点,java的解释器只是一个基于虚拟机JVM平台的程序 ,即jdk或jre目录下bin目录中的java.exe文件。 Java解释器相当于运行Java字节码的“CPU”,但该“CPU”不是通过硬件实现的,而是用软件实现的。

如果编译后产生了罕见陷阱,则可以通过逆优化方案,退回到解释器执行。

HotSpot虚拟机中有两个JIT编译器,分别为Client Compiler(简称为C1编译器)Server Compiler(C2编译器),具体的使用特点如下:

  • -client:启动速度快,占用内存小,执行效率低于-server的执行效率,默认状态下不进行动态编译,适用于单机桌面版程序
  • -server:启动速度慢,占用内存大,执行效率高,适用于服务端,也是默认的编译器。

分层编译模型

Java1.7引入了分层编译(对应参数-XX:+TieredCompilation),综合了C1编译器的启动性能优势和C2编译器的峰值性能优势,分层编译将Java虚拟机的执行状态分成了5个层次。

4. JVM堆内存结构

所有运行在Java虚拟机之中的数据都会保存在堆内存之中,所以堆内存是Java运行时数据区内最大的存储空间。堆内存也属于多线程共享空间,所有线程执行时所需要的数据都保存在此处。堆内存是JVM在进程启动时分配的逻辑内存空间,为了更加便于堆内存空间的垃圾收集操作,进行了分代设计,每一个分代空间如下:

  • 新生代:用于保存所有新创建的对象,同时负责对象的老年代晋升
  • 老年代:又称为旧生代,专门保存那些会长期驻留的对象内容,然后用户创建了较大的对象,则该对象会直接存储进老年代,不再通过新生代晋升。
  • 元空间:代表直接物理内存(非JVM开辟空间)

由于实际项目应用中一个JVM进程的存储对象数量不确定,因此在默认的分配策略下堆内存空间也使用了可伸缩的设计。当发现堆内存空间不足的时候,其会自动在内存伸缩区扩充已有堆内存的存储空间,而当发现当前系统中内存充足的时候,则会进行该内存空间的释放。

思考内存碎片

JVM所使用的堆内存空间因为引入了动态伸缩区,所以一定会产生大量的内存碎片,由于所使用的内存并不是一块连续的物理内存,因此在进行伸缩处理以及对象存储的时候存在一定的性能缺陷。在进行JVM调优的时候,首先要解决的就是物理内存的连续性的问题

堆内存缓冲区在进行操作的时候,会根据当前已分配堆内存的占用比例来实现堆内存的扩容,而这一操作需要用到扩容的触发机制,以及扩容的实现机制。但是在高并发的场景下,由于堆内存增长迅速,有可能出现扩容不及时所带来的内存假耗尽的问题,导致程序中出现OOM的异常,最终导致JVM崩溃。

同时由于伸缩区是动态分配的,那么就有可能造成物理内存的碎片,因为每一次都只开放一点点的内存,那么最终就有可能因为大量的碎片而导致整个应用的性能下降。

在这种情况下,最佳的做法就是避免执行这种动态内存伸缩逻辑的操作,一次性为项目分配足够多的内存空间。

4.1 新生代内存管理

分代设计的目的是为了提高GC性能,提高GC对象的查找速度

每一个Java应用在启动过程中都会创建出大量的对象实例,所有新创建的对象实例都存储在新生代内存区,但是这些对象实例可能会长期驻留,也有可能只会使用一次。为了便于不同使用周期的对象实例管理,JVM也对新生代进行了分代设计,其一共分为3个子区域。

  • 伊甸园区:所有新创建的对象都直接存储在该区域之中

需要注意的是,超大对象是不会保存在新生代之中的。

比如,在编写JDBC中,编写如下SQL:select * from student,假设student中有20w条数据,这时候超大的对象就会进入到老年代之中。

  • 存活区:主要保存从伊甸园区传过来的对象,此时伊甸园区已满,分为如下两个区域
    • 存活0区(S0区):负责对象晋升老年代的相关处理
    • 存活1区(S1区):负责对象晋升老年代的相关处理

两个存活区中总有一个会是空的

在新生代中的两个存活区并不是固定的,也就是当S0负责对象晋升处理的时候,S1将会是空的,而当S1区负责对象晋升处理的时候,S0区是空的,这两个内存区交替工作。

所谓交替工作就是这两个区会互换身份,同一块内存区域在不同的时间节点可能是S0可能是S1

在默认的内存分配策略下,伊甸园区和两个存活区的分配比例为8:1:1,也就是分配给伊甸园区的内存空间是最大的,而两个存活区的内存空间大小相同,这样可以极大地方便新对象的内存分配管理。

新生代保存大量的新生对象,同时这些新生对象有可能只被短期利用,当发现新生代的内存空间不足的时候,JVM就会执行MinorGC操作来释放伊甸园的内存空间,经过多次MinorGC操作还保存下来的对象就被放到存放区之中。

在实际的JVM的运行过程之中,由于伊甸园区总会保存大量的新生对象,所以HotSpot提供了BTP(Bump The Pointer,碰撞指针)TLAB(Thread Local Allocation Buffers,本地线程分配区)两种内存分配技术。

BTP内存分配技术

将伊甸园区想象为一个栈结构,每一次保存的对象都放在伊甸园的栈顶,这次在每次创建新对象的时候检查最后保存的对象即可确定是否有足够的内存空间。这种做法可以极大地提高内存分配的速度。

BTP的理解

所谓碰撞指针,就是栈顶指针和内存空间顶指针的碰撞,每次分配内存空间的时候,都会让空间顶部的指针与栈顶指针进行作差运算,从而可以知道还有多少是空的。

由于BTP技术需要依据顺序进行内存分配,因此在高并发的处理机制下就会出现内存阻塞的问题,从而导致内存分配失败。

TLAB内存分配技术

虽然BTP算法发可以提高内存分配速度,但是这种做法并不适用于多线程的高并发应用环境,所以产生了TLAB分配算法,该算法将伊甸园分为若干个子区域,每个子区域分别使用BTP技术进行对象的保存与内存分配。这种算法虽然可以提高内存的分配效率,但是会产生内存碎片。

TLAB的理解

类似于内存管理的固定分区技术,为每个线程分配一个固定的内存大小区域,在里面进行BTP的内存算法分配

新生代内存是整个JVM堆内存之中重要的组成部分,如果在JVM运行期间为其分配的内存调小,则会导致该内存空间被迅速占满,从而出现频繁的MinorGC操作。而在某些临时对象执行了过多的MinorGC操作之后,JVM会认为该对象应该要被产生过去保存,最终就会导致某些临时对象被存活区直接晋升到老年代。这样不仅会失去MinorGC的意义,而且频繁的MinorGC也会导致处理性能的下降。

新生代内存空间调整参数

序号 参数 描述
01 -Xmn 设置新生堆内存大小,默认为当前物理内存的1/64
02 -Xss 设置每个线程栈的大小,默认为1MB,减少此数据可以产生更多的线程。
03 -XX:NewSize 新生代初始内存大小,该值应该小于-Xms配置值
04 -XX:MaxNewSize 新生代可被分配的最大内存,该值应该要小于-Xmx的配置值
05 -XX:SurviorRatio 设置伊甸园与存活区的比例,默认为8:1:1

4.2 老年代内存管理

所有新创建的对象都保存在伊甸园区,但是当伊甸园区不够用的时候,会触发新生代中MinorGC的操作,当某一个操作经历过多次的MinorGC之后,JVM会认为该对象是一个需要被长期使用的对象,将会通过存活区将其晋级到老年代中进行存储,老年代的主要作用是保存常驻对象实例数据。

由于老年代保存的都是常驻对象,因此对老年代进行垃圾回收的可能性很低,JVM中老年代的GC称为MajorGC(或者称为FullGC)。一般只有在新生代已经被完全占满的时候才会进行MajorGC操作,所以MajorGC的执行次数很少,在JVM进程启动的时候,有如下参数可以配置

序号 参数 描述
01 -XX:+UseAdaptiveSizePolicy 是否采用动态控制策略,如果启用则会动态调整Java堆内存各个区域的大小以及进入老年代的年龄(每进行一此MinorGC则对象增长一岁)
02 -XX:PretenureSizeThreshold 设置直接进入老年代的对象大小
03 -XX:MaxTenuringThreshold 设置进入到老年代的对象年龄

4.3 元空间

永久代与元空间

JDK8之后,HotSpot标准中取消了一直提供的永久代的内存结构,取而代之的是一个元空间(Meta Space)的概念,元空间是直接的物理内存,也就是说该内存空间不在JVM中存储,而存储在JVM外的物理之中,因此不受JVM内存的限制

取消的方法区和永久代设计

永久代中存储的实际上都是不可能被GC的内容,包括反射所产生的操作也是在永久代中进行了存储。

JDK8以前的版本中是没有元空间的,与之相关的是一个被称为永久代的内存空间,很多人会将永久代称为方法区(方法区中保存了类信息、常量、变量等内容),主要的原因在于方法区是一个虚拟机的公共实现规范,而HotSpot虚拟机中通过永久代实现了方法区,同时开发者可以通过-XX:MaxPermSize参数来进行永久代内存大小的设置

永久代是HotSpot虚拟机提供的实现方案,在JDK1.7以前的版本永久代与老年代捆绑在一起,不管哪一个内存空间被占满都会触发MajorGC的操作,所以永久代的内存配置就会比较麻烦,如果设置得过大则会浪费空间,如果设置得太小就会频繁触发GC

JDK1.7开始,HotSpot就开始移除方法区,将原本的符号引用移动到本地堆内存将字面量和静态变量移到Java堆内存,但依然保存了永久代的概念。

由于元空间直接在本地内存之中,所以其不会受对内存GC操作的影响,而为了便于元空间的内容管理,其内部又分为了两个子空间Klass Metaspace(保存类结构信息)NoKlass Metaspace(保存类内容信息)

在默认情况下元空间可以在本地内存中无限制地进行扩充,这样就解决旧版本中永久代内存的设置问题。

序号 参数 描述
01 -XX:MetaspaceSize 设置元空间的初始大小,默认为20.8MB
02 -XX:MaxMetaspaceSize 设置元空间的最大容量,默认是没有限制的(受到本机物理内存的限制)
03 -XX:MinMetaspaceFreeRatio 执行MetaSpaceGC之后,最小的剩余元空间百分比,默认为40%
04 -XX:MaxMetaspaceFreeRatio 执行MetaSpaceGC之后,最大的剩余元空间百分比,默认为70%

文章作者: 穿山甲
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 穿山甲 !
  目录