您的位置:首页 > 其它

深入理解JVM——第一章 内存结构学习笔记

2019-06-01 17:50 531 查看

为什么要了解虚拟机?

JVM不单单只支持Java语言,也支持其他语言(Scala、Kotlin、Groovy等等)。
区块链2.0–以太坊(比特币是区块链1.0) 中提供了EVM的虚拟机,它的实现和JVM类似,基于栈、生成脚本编译成字节码来执行。知识通用(理论大于实际)。

虚拟机历史——解释执行和编译执行(针对字节码的执行):

解释执行就是边翻译为机器码边执行、即时编译(编译执行)就是先将一个方法中的所有字节码全部编译成机器码之后再执行。Hotspot采用的是先解释执行,到了一定时机后热点代码(多次执行、循环等)再翻译成机器码。热点代码探测技术(通过执行计数器找到最有编译价值的代码,如果代码用得非常频繁,就会把这些代码编译成本地代码)。

JRockit采取的方法是在执行class时直接编译为机器码(Java程序启动速度会比较慢)。J9和Hotspot比较接近,主要是用在IBM产品(IBM WebSphere和IBM的AIX平台上),华为有的项目用的J9。、

谷歌:Google Android Dalivk VM:使用的寄存器架构,执行dex(Dalvik Executable)通过class转化而来。

未来的Java技术

  • 模块化:OSGI(动态化、模块化),应用层面就是微服务,互联网的发展方向。
  • 混合语言:多个语言都可以运行在JVM中,google的Kotlin 成为了 Android 的官方语言,Scala(Kafka)。
  • 多核并行:CPU从高频次转变为多核心,多核时代。JDK1.7引入了Fork/Join,JDK1.8提出lambda表达式(函数式编程天生适合并行运行)。
  • 丰富语法:JDK5提出自动装箱、泛型(并发编程讲到)、动态注解等语法。JDK7二进制原生支持。try-catch-finally 至try-with-resource。
  • 64位:虽然同样的程序64位内存消耗比32位要多一点,但是支持内存大,所以虚拟机都会完全过渡到64位,32位的JVM有4G的堆大小限制。
  • 更强的垃圾回收器(现在主流CMS、G1):JDK11 –ZGC(暂停时间不超过10毫秒,且不会随着堆的增加而增加,TB级别的堆回收)):有色指针、加载屏障。JDK12支持并发类卸载,进一步缩短暂停时间 JDK13(计划于2019年9月)将最大堆大小从4TB增加到16TB。

一、Java SE体系架构


JavaSE:Java平台标准版,为Java EE和Java ME提供了基础。
JDK:Java开发工具包,JDK是JRE的超集,包含JRE中的所有内容,以及开发程序所需的编译器和调试程序等工具。
JRE:Java SE运行时环境 ,提供库、Java虚拟机和其他组件来运行用Java编程语言编写的程序。主要类库,包括:程序部署发布、用户界面工具类、继承库、其他基础库,语言和工具基础库。
JVM:java虚拟机,负责JavaSE平台硬件和操作系统无关性、编译执行代码(字节码)和平台安全性。

1、JVM整体介绍:


JVM = 类加载器(classloader) + 执行引擎(execution engine) + 运行时数据区域(runtime data area)

1.1、什么是jvm?

(1)jvm是一种用于计算设备的规范,它是一个虚构出来的机器,是通过在实际的计算机上仿真模拟各种功能实现的。
(2)jvm包含一套字节码指令集,一组寄存器,一个栈,一个垃圾回收堆和一个存储方法域。
(3)JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

1.2运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
线程私有:程序计数器、虚拟机栈、本地方法栈;线程共享:堆、方法区。

这个是抽象概念,内部实现依赖寄存器、高速缓存、主内存(具体要分析JVM源码 C++语言实现,没必要看)。计算机的运行=指令+数据,指令用于执行方法的,数据用于存放数据和对象的。

虚拟机栈:执行java方法、本地方法栈——执行本地方法、程序计数器(程序执行的计数器)。
Java中的数据:变量、常量、对象、数组相关。

二、线程私有区域

1、程序计数器

线程私有,它的生命周期与线程相同。可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,如:分支、循环、跳转、异常处理、线程恢复(多线程切换)等基础功能。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie方法,这个计数器值则为空(undefined)。程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,所以此区域不会出现OutOfMemoryError的情况。各线程之间独立存储,互不影响。

2、虚拟机栈(JVM后续的执行子程序有详细的见解)

2.1 栈的概念

数据结构的特点和java中方法中调用方法的特性一致。(为什么JVM使用栈 –演示代码StackFilo)
异常:线程请求的栈深度大于虚拟机所允许的深度:StackOverflowError
JVM动态扩展时无法申请到足够的内存时:OutOfMemoryError

< 4000 a id="22_Java_Virtual_Machine_Stacks_60">2.2 虚拟机栈(Java Virtual Machine Stacks)

与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame )用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

经常有人把Java 内存区分为堆内存(Heap)和栈内存(Stack),这种分法比较粗糙,Java 内存区域的划分实际上远比这复杂。这种划分方式的流行只能说明大多数程序员最关注的、与对象内存分配关系最密切的内存区域是这两块。其中所指的“堆”在后面会专门讲述,而所指的“栈”就是现在讲的虚拟机栈,或者说是虚拟机栈中的局部变量表部分。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress 类型(指向了一条字节码指令的地址)。其中64 位长度的long 和double 类型的数据会占用2 个局部变量空间(Slot),其余的数据类型只占用1 个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

在Java 虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError 异常;如果虚拟机栈可以动态扩展(当前大部分的Java 虚拟机都可动态扩展,只不过Java 虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError 异常。

注意:栈的大小缺省为1M,可用参数 –Xss调整大小,例如-Xss256k

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

可以通过命令将class文件反编译成文本文件:
例如:javap -v JavaStack.class >JavaStack.txt

对应的部分字节码信息:

更多字节码参考:http://bbs.xiangxueketang.cn/forum.php?mod=viewthread&tid=46&extra=page

2.3 栈帧

每个方法在执行的同时都会创建一个栈帧,栈帧还可以划分:局部变量表、操作数栈、动态连接、返回地址。当方法正常返回或者调用过程中抛出未捕获的异常时,栈帧将出栈。

  • 局部变量表:顾名思义就是局部变量的表,用于存放我们的局部变量的。首先它是一个32位的长度,主要存放我们的Java的八大基础数据类型,一般32位就可以存放下,其中64
    位长度的long 和double 类型的数据会占用2 个局部变量空间(Slot),其余的数据类型只占用1
    个。如果是局部的一些对象,比如我们的Object对象,我们只需要存放它的一个引用地址即可(基本数据类型、对象引用、returnAddress类型)。

  • 操作数据栈:存放我们方法执行的操作数,操作数栈在执行字节码指令过程中被用到,这种方式类似于原生 CPU 寄存器。大部分 JVM 字节码把时间花费在操作数栈的操作上:入栈、出栈、复制、交换、产生消费变量的操作。因此,局部变量数组和操作数栈之间的交换变量指令操作通过字节码频繁执行。比如,一个简单的变量初始化语句将产生两条跟操作数栈交互的字节码。

int i; 被编译成下面的字节码:

0:    iconst_0    // Push 0 to top of the operand stack
1:    istore_1    // Pop value from top of operand stack and store as local variable 1
  • 动态连接:Java语言特性多态(需要类加载、运行时才能确定具体的方法,后续有详细的讲解)
    每个栈帧都有一个运行时常量池的引用。这个引用指向栈帧当前运行方法所在类的常量池。通过这个引用支持动态链接(dynamic linking)。C/C++ 代码一般被编译成对象文件,然后多个对象文件被链接到一起产生可执行文件或者 dll。在链接阶段,每个对象文件的符号引用被替换成了最终执行文件的相对偏移内存地址。在 Java中,链接阶段是运行时动态完成的。
    当 Java 类文件编译时,所有变量和方法的引用都被当做符号引用存储在这个类的常量池中。符号引用是一个逻辑引用,实际上并不指向物理内存地址。JVM 可以选择符号引用解析的时机,一种是当类文件加载并校验通过后,这种解析方式被称为饥饿方式。另外一种是符号引用在第一次使用的时候被解析,这种解析方式称为惰性方式。无论如何 ,JVM 必须要在第一次使用符号引用时完成解析并抛出可能发生的解析错误。绑定是将对象域、方法、类的符号引用替换为直接引用的过程。绑定只会发生一次,一旦绑定,符号引用会被完全替换。如果一个类的符号引用还没有被解析,那么就会载入这个类。每个直接引用都被存储为相对于存储结构(与运行时变量或方法的位置相关联的)偏移量。
  • 返回地址:正常返回(调用程序计数器中的地址作为返回)

三步曲:

  • 恢复上层方法的局部变量表和操作数栈。
  • 把返回值(如果有的话)压入调用者栈帧的操作数栈中。
  • 调整PC计数器的值以指向方法调用指令后面的一条指令。

异常的话(通过异常处理器表<非栈帧中的>来确定)。

2、本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native 方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它,本地方法栈native方法调用 JNI到了底层的C/C++(c/c++可以触发汇编语言,然后驱动硬件)。甚至有的虚拟机(譬如Sun HotSpot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError 和OutOfMemoryError异常。

三、线程共享区域

1、Java堆:老年代、新生代

对于大多数应用来说,Java 堆(Java Heap)是Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java 虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配,但是随着JIT 编译器的发展与逃逸分析技术的逐渐成熟,栈上分配、标量替换②优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。

Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC 堆”(Garbage Collected Heap)。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java 堆中还可以细分为:新生代和老年代;再细致一点的有Eden 空间、From Survivor 空间、To Survivor 空间等。如果从内存分配的角度看,线程共享的Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过,无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。

根据Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms 控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。

可用以下参数调整:
-Xms:堆的最小值;
-Xmx:堆的最大值;
-Xmn:新生代的大小;
-XX:NewSize;新生代最小值;
-XX:MaxNewSize:新生代最大值;
例如- Xmx256m

2、方法区(JDK1.7和以前—永久代,JDK1.8—元空间)

方法区(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 异常。

方法区可用以下参数调整:
jdk1.7及以前:-XX:PermSize;-XX:MaxPermSize;
jdk1.8以后:-XX:MetaspaceSize; -XX:MaxMetaspaceSize;
jdk1.8以后大小就只受本机总内存的限制,如:-XX:MaxMetaspaceSize=3M

2.1类信息

类的完整有效名、返回值类型、修饰符(public,private…)、变量名、方法名、方法代码、这个类型直接父类的完整有效名(除非这个类型是interface或是 java.lang.Object,两种情况下都没有父类)、类的直接接口的一个有序列表。

2.2 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

Java 虚拟机对Class 文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。但对于运行时常量池,Java 虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。不过,一般来说,除了保存Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只能在编译期产生,也就是并非预置入Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String 类的intern() 方法。既然运行时常量池是方法区的一部分,自然会受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError 异常。

常量池中可以存储多种类型的数据:数字型、字符串型、类引用型、域引用型、方法引用。

示例代码如下:
Object foo = new Object();
写成字节码将是下面这样:
0:    new #2             // Class java/lang/Object
1:    dup				// 将操作数栈定的数据复制一份,并压入栈
2:    invokespecial #3     // pop出栈引用值,调用其构造函数,完成对象的初始化

new 操作码的后面紧跟着操作数 #2 。这个操作数是常量池的一个索引,表示它指向常量池的第二个实体。第二个实体是一个类的引用,这个实体反过来引用了另一个在常量池中包含 UTF8 编码的字符串类名的实体(// Class java/lang/Object)。然后,这个符号引用被用来寻找 java.lang.Object 类。new 操作码创建一个类实例并初始化变量。新类实例的引用则被添加到操作数栈。dup 操作码创建一个操作数栈顶元素引用的额外拷贝。最后用 invokespecial 来调用第 2 行的实例初始化方法。操作码也包含一个指向常量池的引用。初始化方法把操作数栈出栈的顶部引用当做此方法的一个参数。最后这个新对象只有一个引用,这个对象已经完成了创建及初始化。

  • 符号引用:一个java类(假设为People类)被编译成一个class文件时,如果People类引用了Tool类,但是在编译时People类并不知道引用类的实际内存地址,因此只能使用符号引用来代替。而在类装载器装载People类时,此时可以通过虚拟机获取Tool类的实际内存地址,因此便可以将符号org.simple.Tool替换为Tool类的实际内存地址,及直接引用地址。即在编译时用符号引用来代替引用类,在加载时再通过虚拟机获取该引用类的实际地址。
    以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局是无关的,引用的目标不一定已经加载到内存中。

  • 字面量
    文本字符串:String a = “abc”, 这个abc就是字面量。
    八种基本类型int 1d810 a = 1; 这个1就是字面量。
    声明为final的常量。

3、JVM各版本内存区域的变化:

四、直接内存

直接内存(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异常。


可以通过-XX:MaxDirectMemorySize来设置(默认与堆内存最大值一样,也会出现OOM异常)。

测试用例JavaStack:设置JVM参数-Xmx100m,运行异常,因为如果没设置-XX:MaxDirectMemorySize,则默认与-Xmx参数值相同,分配128M直接内存超出限制范围。

站在线程角度来看:虚拟机栈、本地方法栈、程序计数器三个区域的生命周期和线程相同。线程共享区域:就复杂多了,后续完善。

五、深入辨析堆和栈

  • 功能:以栈帧的方式存储方法调用的过程,并存储方法调用过程中基本数据类型的变量(int、short、long、byte、float、double、boolean、char等)以及对象的引用变量,其内存分配在栈上,变量出了作用域就会自动释放。而堆内存用来存储Java中的对象,无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。
  • 线程独享还是共享:栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。堆内存中的对象对所有线程可见,堆内存中的对象可以被所有线程访问。
  • 空间大小:栈的内存要远远小于堆内存。如果你使用递归的话,那么你的栈很快就会充满并产生StackOverFlowError。
    栈溢出:参数:-Xss256k

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出java.lang.StackOverflowError 异常。如果JVM栈可以动态扩展(大部分JVM是可以的),当扩展时无法申请到足够内存则抛出OutOfMemoryError异常。而堆内存没有可用的空间存储生成的对象,JVM会抛出java.lang.OutOfMemoryError。

  • 虚拟机栈带给我们的启示:
    方法的执行因为要打包成栈桢,所以天生要比实现同样功能的循环慢,所以树的遍历算法中:递归和非递归(循环来实现)都有存在的意义。递归代码简洁,非递归代码复杂但是速度较快。
    OutOfMemoryError:不断建立线程。(一般演示不出,演示出来机器也死了)
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: