Java之深入JVM的探讨之旅

Java之深入JVM的探讨之旅

JVM(Java 虚拟机)学习Java语言开发的朋友们都知道jvm是我们进行Java程序开发不可或缺的一个东西。你知道它叫Java虚拟机也应该知道就是因为有了它才赋予了我们Java语言一个强大的特性——跨平台的开发语言。就是因为Java有了跨平台性的特点所以它才会在最近十年里一直称霸着计算机程序开发语言的王座。
知道有了它之后Java语言就有了跨平台性的特点,但是你知道它是个怎么样的工作原理吗?所以接下来就由我来带领大家一起深入探讨JVM的工作原理的讲解。这也是我在学习JVM当中的一些笔记吧。把它们放到网上希望能帮助到那些学习Java的新生们。讲解可能有些暇质大家在看的时候可以发表评论自己的看法。
首先让我们看看Java概念图的描述

在这里插入图片描述
JDK工具解析:(JDK为java的开发工具包)java开发者必备
javac:java编辑器(把java源码文件翻译成.class的字节码文件)
javadoc:文档生成器(把一些自己编写的java源码生自己的API文档类似于java官方API)
jar:打包工具(也是把写好的资源文件打包压缩成以.jar结尾的压缩文件方便后期调用,类似于我们开发时经常导入的第三方jar包)
javap:反编译工具(就是javac的死对头,把.class字节码文件反编译成.java源码文件)
javaConsole:监视器(符合JMX的图形工具,用于监视Java虚拟机。它可以监视本地和远程JVM。它还可以监视和管理应用程序。)

JRE工具解析:(JRE为java的运行环境包被包含在JDK中)java运行者必备
核心类库:
IDL—— Java IDL技术将Java(平台对象请求代理体系结构)功能添加到Java平台,提供基于标准的互操作性和连接性。
JDBC—— 提供给Java编程语言对数据库建立操作关联的API
JNDI—— Internet上的Java远程方法调用Inter-ORB协议技术RMI编程模型支持通过RMI API对CORBA服务器和应用程序进行编程。
RMI—— 远程方法调用(RMI)通过在用Java编程语言编写的程序之间提供远程通信来支持分布式应用程序的开发。
RMI-IIOP—— Internet上的Java远程方法调用Inter-ORB协议技术RMI编程模型支持通过RMI API对CORBA服务器和应用程序进行编程。
Scripting—— Java SE包括JSR 223:Java™Platform API的脚本,这是Java应用程序可以“托管”脚本引擎的框架。
I / O—— 在java.io和java.nio包管理应用程序的I / O提供了丰富的API集。该功能包括文件和设备I / O,对象序列化,缓冲区管理和字符集支持。
详细请查看

今天我们主讲的是包含在JRE中的JVM
JDK提供Java虚拟机(VM)的一个或多个实现:(这个是java官方解释)
1、在通常用于客户端应用程序的平台上,JDK附带了一个名为Java HotSpot Client VM(客户端VM)的VM实现。调整客户端VM以减少启动时间和内存占用。-client启动应用程序时,可以使用命令行选项调用它。(大致就是说在我们的计算机上都会由机器生产商安装好的JVM,在我们打开机器的时候可以启动它进行相关操作)
2、在所有平台上,JDK都附带了一个名为Java HotSpot Server VM (服务器VM)的Java虚拟机实现。服务器VM旨在实现最高的程序执行速度。-server启动应用程序时,可以使用命令行选项调用它 。

Java HotSpot技术的一些功能,对于两种VM实现都是通用的,如下所示。

  1. 自适应编译器 - 使用标准解释器启动应用程序,但在运行时会对代码进行分析,以检测性能瓶颈或“热点”。Java HotSpot VM编译代码中性能关键部分以提高性能,同时避免不必要的编译很少使用的代码(大多数程序)。Java HotSpot VM还使用自适应编译器来动态决定如何使用内嵌等技术优化编译代码。编译器执行的运行时分析允许它消除猜测,确定哪些优化将产生最大的性能优势。
  2. 快速内存分配和垃圾收集 - Java HotSpot技术为对象提供快速内存分配,并提供快速,高效,最先进的垃圾收集器选择。
  3. 线程同步 - Java编程语言允许使用多个并发的程序执行路径(称为“线程”)。Java HotSpot技术提供了一种线程处理功能,旨在轻松扩展以用于大型共享内存多处理器服务器。

接下来我们看看JVM的内部结构图:
官方JVM图解

翻译版本
文字解释:源码.java ——>(javac编辑器)——>字节码.class ——> 类加载器(JVM)——>运行时数据区(JVM)——>执行引擎(JVM) ——>机器码 ——>机器识别处理

java虚拟机内存模型中定义的访问操作与物理计算机处理的基本一致。
引用图片
JVM定义了控制Java代码解释执行和具体实现的五种规格,它们是:

  1. JVM指令系统
  2. JVM寄存器
  3. JVM 栈结构
  4. JVM 碎片回收堆

工作原理:
1、JVM是java的核心和基础,在java编译器和os平台之间的虚拟处理器。它是一种基于下层的操作系统和硬件平台并利用软件方法来实现的抽象的计算机,可以在上面执行java的字节码程序。
2、 java编译器只需面向JVM,生成JVM能理解的代码或字节码文件。Java源文件经编译器,编译成字节码程序,通过JVM将每一条指令翻译成不同平台机器码,通过特定平台运行。
重点JVM的“内存管理”:内存分配、垃圾回收。

JVM在运行时会把自己所管理的内存划分成若干个不同 数据区域
线程私有:程序计数器、本地方法栈、虚拟机栈。指令(线程私有,执线程运行时初始化,运行结束销毁)
线程共享:堆、方法区。数据(线程共享)
引用图片
在这里插入图片描述

操作数栈结构
——>程序计数器(Program Counter Register):指向当前线程正在执行的字节码指令的地址(指令行号),由于java是多线程的,在CPU进行线程切换的时候确保程序中的线程在恢复自己的CPU执行能够回线程到切换前的执行位置,所以用程序计数器来确定CPU到底是执行到那个线程了并且赋予了CPU时间片(可以占用CPU的时间段)
由于Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在Java 虚拟机规范中没有规定任何OutOfMemoryError 情况的区域。
——>本地方法栈(Native Method Stacks):执行过程跟虚拟机栈差不多,(本地方法栈执行native方法、虚拟机栈执行java方法)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native 方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError 和OutOfMemoryError异常。
——>虚拟机栈(Java Virtual Machine Stacks):存储特点(先进后出),存储当前线程运行方法(栈帧:局部变量表、操作数栈、动态连接、返回地址,主要由这四个部分构成)所需的数据、指令、返回地址(对象引用)。
当类中的方法被调用时都会产生一个结构都一样的“栈帧”
VM Args: -Xss128k 表示虚拟机栈的内存大小,当虚拟机栈的所有栈帧的内存总和超过了128k就会产生栈内存溢出(会报StackOverFlowError异常),当每个栈帧增添变量时会造成虚拟机栈能容纳栈帧的数量减少(-Xss / 单个栈帧内存 = 栈帧数量),为栈帧增添变量会增加单个栈帧的内存。
程序优化:(虚拟机栈默认内存空间为1M)

  1. 尽量合理分配每个栈帧的内存,让虚拟机栈尽可能可以容纳更多的栈帧。
    高并发、多线程的项目:
    默认:是按虚拟机栈的内存空间(-Xss)默认1M,可是我们的程序有1000个线程同时运行(1M * 1000 = 1G)这时是非常消耗我们计算机有限的内存空间的。
    优化:把虚拟机栈内存空间(-Xss)修改为200k,同样程序有1000个线程同时运行(200k * 1000 = 200M)这时比较很明显了嘛。
  2. 合理利用栈帧的内存空间,尽量少创建一个无关紧要的变量以减少栈帧的内存。
    高并发、多线程的项目:
    虚拟机栈都是1M内存,每个栈帧有10000个int(4个字节)变量,20000个char(2个字节)变量,10000个float(4个字节)变量,10000个long(8个字节)变量,其中有一半为无用的变量。
    默认:
    10000 x 4bytes + 20000 x 2bytes + 10000 x 4bytes + 10000 x 8bytes = 200000bytes
    1M = 2^20bytes
    2^20bytes / 200000bytes = 100000 00000 00000个栈帧(每个栈帧只计算变量内存空间)
    优化:
    5000 x 4bytes + 10000 x 2bytes + 5000 x 4bytes + 5000 x 8bytes = 100000bytes
    1M = 2^20bytes
    2^20bytes / 100000bytes = 200000 00000 00000个栈帧(每个栈帧只计算变量内存空间)
    局部变量表:(首位存放的是this对象代表本栈帧)存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)的变量名字、对象引用(reference 类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress 类型(指向了一条字节码指令的地址)。
    操作数栈:存放的是各种基本类型的变量值和引用类型的引用地址。
    动态连接:这里与java的多态性区别不大,就是说在我们类中的操作方法可以有很多种形态。动态连接就是此时产生操作的多态操作方法与对象间的连接。
    返回地址:就是操作方法最后的return操作。

在这里插入图片描述

——>方法区
方法区(Method Area)与Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java 堆区分开来。对于习惯在HotSpot 虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot 虚拟机的设计团队选择把GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机(如BEA JRockit、IBM J9 等)来说是不存在永久代的概念的。即使是HotSpot 虚拟机本身,根据官方发布的路线图信息,现在也有放弃永久代并“搬家”至Native Memory 来实现方法区的规划了。Java 虚拟机规范对这个区域的限制非常宽松,除了和Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。在Sun 公司的BUG 列表中,曾出现过的若干个严重的BUG 就是由于低版本的HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。

类信息:类被加载后的信息
常量:在类中被定义为常量的数据变量
静态变量:在类中被static修饰的变量(随着类的加载而加载)
即时编译期译后代码
在这里插入图片描述
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant PoolTable),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。Java 虚拟机对Class 文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。但对于运行时常量池,Java 虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。不过,一般来说,除了保存Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中①。运行时常量池相对于Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只能在编译期产生,也就是并非预置入Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String 类的intern() 方法。既然运行时常量池是方法区的一部分,自然会受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError 异常

直接内存直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError 异常出现,所以我们放到这里一起讲解。在JDK 1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用Native 函数库直接分配堆外内存,然后通过一个存储在Java 堆里面的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java 堆和Native 堆中来回复制数据。显然,本机直接内存的分配不会受到Java 堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括RAM 及SWAP 区或者分页文件)的大小及处理器寻址空间的限制。服务器管理员配置虚拟机参数时,一般会根据实际内存设置-Xmx等参数信息,但经常会忽略掉直接内存,使得各个内存区域的总和大于物理内存限制(包括物理上的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。逻辑内存模型我们已经看到了,那当我们建立一个对象的时候是怎么进行访问的呢?在Java 语言中,对象访问是如何进行的?对象访问在Java 语言中无处不在,是最普通的程序行为,但即使是最简单的访问,也会却涉及Java 栈、Java 堆、方法区这三个最重要内存区域之间的关联关系,如下面的这句代码:Object obj = new Object();假设这句代码出现在方法体中,那“Object obj”这部分的语义将会反映到Java 栈的本地变量表中,作为一个reference 类型数据出现。而“new Object()”这部分的语义将会反映到Java 堆中,形成一块存储了Object 类型所有实例数据值(Instance Data,对象中各个实例字段的数据)的结构化内存,根据具体类型以及虚拟机实现的对象内存布局(Object Memory Layout)的不同,这块内存的长度是不固定的。另外,在Java 堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。由于reference 类型在Java 虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java 堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄和直接指针。如果使用句柄访问方式,Java 堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息,如下图所示。

在这里插入图片描述

如果使用直接指针访问方式,Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference 中直接存储的就是对象地址,如下图所示
在这里插入图片描述
这两种对象的访问方式各有优势,使用句柄访问方式的最大好处就是reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference 本身不需要被修改。使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。就本书讨论的主要虚拟机Sun HotSpot 而言,它是使用第二种方式进行对象访问的,但从整个软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。

——>Java堆
对于大多数应用来说,Java 堆(Java Heap)是Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java 虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配①,但是随着JIT 编译器的发展与逃逸分析技术的逐渐成熟,栈上分配、标量替换②优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC 堆”(GarbageCollected Heap,幸好国内没翻译成“垃圾堆”)。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java 堆中还可以细分为:新生代和老年代;再细致一点的有Eden 空间、From Survivor 空间、To Survivor 空间等。如果从内存分配的角度看,线程共享的Java 堆中可能划分出多个线程私有的分配缓冲区(Thread LocalAllocation Buffer,TLAB)。不过,无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。在本章中,我们仅仅针对内存区域的作用进行讨论,Java 堆中的上述各个区域的分配和回收等细节将会是下一章的主题。根据Java 虚拟机规范/的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms 控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。
对象实例
数组
——>Java堆的大小参数设置
-Xmx 堆区内存可被分配的最大上限
-Xms 堆区内存初始化分配的大小

JMM(java内存模型)
基于分代的思想
堆:新生代(占堆内存1/3)、老年代(2/3)
——新生代:Eden区(占新生区内存8/10)、From Survivor区(1/10)、To urvivor区(1/10)
因为大厂们做过实验证明:有90%的对象不用垃圾回收机制的。——只有大概10%的对象要去使用垃圾回收(复制回收算法)
有10%的内存空间作为预留区。所以才有8:1:1 = 80% :10% :10% 的比例。
引用图片
JMM——对象内存分配
1、对象优先在Eden区分配
2、在新生代长期存活的对象会优先进入老年代
3、内存容量超过新生代的Eden区的对象会直接进入老年代
4、动态判断对象的年龄:同一个年龄段的对象数内存总量超过了新生代的内存容量,就会进入老年代

JMM——对象回收(判断对象的存活)
1、引用计数算法:根据设置的生命周期次数,每GC(执行)1次,初始次数会自减1,等到初始次数为0的时候,就会调用此方法销毁对象
2、可达性分配算法:如下图所示

在这里插入图片描述
Minor GC(新生代)——复制回收算法(优点速度快,缺点是只有50%的利用率)使用于大部分的对象都不需要垃圾回收的时候
在这里插入图片描述

Full GC(老年代)
标记清除算法:会带来内存碎片(当对象需要的内存大于一个局部内存片的时候)——优先使用
在这里插入图片描述
标记整理算法(老年代)当会产生过多碎片时,使用此算法进行内存空间整理(性能低下)因为老年代会产生很多内存碎片,所以是为什么老年代需要两种算法配合使用。
在这里插入图片描述

在这里插入图片描述

JVM的相关知识:
1、线程私有区域的生命周期是跟随线程的
2、堆(JMM新生代,老年代)是垃圾回收的重点区域
3、GC的发展趋势会使用收器(回收量几百M或者几个G)
JDK11——ZGC(TB级别):有色指针,加载屏障

JVM常用问题处理方式

  1. 保存堆栈快照日记
  2. 分析内存泄露——占用堆栈域对象内存超过了此堆栈初始化的内存空间大小(-Xmx最大分配值)
  3. 整理内存设置——设置堆栈的初始化内存空间大小(-Xms最小分配值、-Xmx最大分配值)
  4. 控制垃圾回收频率
  5. 选择合适的垃圾回收器

这篇文章是我本人参考多篇大佬级别的博客抽取出来比较好的内容