JVM虚拟机,对大部分Java程序员而言,是既熟悉又陌生的存在,Java程序在虚拟机的自动内存管理机制帮助下,减少了绝大部分的内存管理工作。但也正是因为如此,虚拟机如果出现了内存溢出或者泄露的情况,问题排查、BUG修复也成了一项异常艰苦的工作。
本系列,是基于《JVM高级特性与最佳实现第3版》这本书,整理的个人学习笔记,旨在学习过程中,结合其他许多资料和博客,对以往模糊不清晰的知识点予以深度梳理。
首先看一下运行时数据区域的定义。JVM虚拟机在执行Java程序的过程中,会把自身所管理的内存划分为多个区域,每个区域的用途、生命周期各不相同。
说到运行时数据区域,必须了解的一本著作是《Java虚拟机规范》
,这本书是Oracle官方发布的虚拟机开发规范,是所有厂商开发虚拟机时都要遵守的,也就是除了我们最常使用的Oracle虚拟机(HotSpot),像IBM的J9虚拟机,还有其他的厂商所开发的虚拟机,都必须要遵守这本书中定义的很多细节规范。在《Java虚拟机规范》
中规定的运行时数据区域包含如下几个部分。
这张图是从《JVM高级特性与最佳实践》中直接拿出来的,仔细看,除了真正的运行时数据区
,还包含了和执行引擎
、本地接口
、本地方法
库的交互。所以,今天的主要内容是梳理并学习运行时数据区域里面包含的程序计数器
,堆
,方法区
,虚拟机栈
,本地方法栈
这5块内存区域。
1. 程序计数器
- 程序计数器的工作原理是什么
程序计数器存储了当前线程正在执行的虚拟机字节码指令(Java方法),比如下图PC寄存器的数字5可以理解为程序计数器所记录的数据,当前线程获取到时间片的时候,执行引擎会读取程序计数器的值,并找到5所对应的指令后进行运算。
字节码解释器工作时,会不断地改变这个计数器的值,来记录下一条需要执行的字节码执行的行号。
- 程序计数器存储字节码执行有什么用
因为CPU需要不断的切换各个线程,当线程切换回来后,需要知道接着从哪里开始执行,字节码解释器通过改变程序计数器的值,来明确下一条应该执行什么字节码指令。
- 程序计数器为什么没有内存溢出
程序计数器使用了非常小的一块内存空间,只用于记录字节码指令地址,不会有内存膨胀的问题。在官方的《JVM虚拟机规范》
中也没有规定此区域有任何的OOM的情况。
2. 虚拟机栈和本地方法栈
虚拟机栈和本地方法栈同属于栈内存的一部分,不同点虚拟机栈执行的是Java方法,而本地方法栈执行的是Native方法。
- Java方法执行的线程内存模型
在JVM管理的内存中,有一块区域是虚拟机栈,每个线程创建时,都会在内存划分出一块线程所属的内存,线程销毁时所述内存被回收。
线程执行Java程序时,会从某个Java方法开始,比如main()
方法。Java方法被调用的时候,会在当前线程的栈内存中创建一个栈帧
,用于存储局部变量表
,操作数栈
,动态链接
,返回方法
,通常还会包含一些附加信息。
这里的局部变量表
包含了方法内部的基本数据类型(int/shot/long等),对象的引用(比如指向对象的指针),returnAddress类型(指向字节码指令地址的指针)。操作数栈
是一个先进后出的结构,在方法执行的过程中,字节码指令会往操作数栈中写入或者读取数据,也就是入栈和出栈。动态链接
为了支持方法调用过程中,将符号引用转换为直接引用。方法返回地址
用于记录在方法执行完成退出或者抛出异常时,能够返回到当前方法被调用到的位置。
下面,我们假设 main 方法内部调用a方法,a方法内部调用了b方法,b方法内部调用了c方法。
- 本地方法栈
在《Java虚拟机规范》中,对本地方法栈并没有做严格的要求,所以在具体实现中,HotSpot虚拟机中,直接把本地方法栈和虚拟机栈合二为一。在不同的操作系统中,要求的栈帧大小是不一样的,比如64位的Windows系统下,最低不能小于180k,否则虚拟机将无法启动。
- OOM内存溢出
在实际开发中,可能会碰到栈内存溢出的情况,但通常在现在的机制下,无论是栈帧太大或者虚拟机栈容量太小,当新的栈帧无法分配内存时,Hotspot虚拟机抛出的都是StackOveflowError
的异常。
3. 堆
堆内存是我们开发中最常使用到,也是出现问题最多的地方,使用不当经常会碰到堆内存溢出的情况。一个JVM实例只存在一个堆内存,是属于所有线程的,堆也是内存管理的核心区域,存放的主要是实例对象和数组。当然,需要明确的是 并不是所有的实例对象都会在堆中分配。
- 内存分配方式
《JVM虚拟机规范》中,规定堆内存在逻辑
上是连续的,在物理
上可以是不连续的。所以Jvm对于两种不同情况的堆内存管理有 指针碰撞
和空闲列表
,而决定使用哪种内存管理方式,则是由堆所采用的垃圾收集算法,是不是具备 内存空间压缩的能力。
指针碰撞
:垃圾收集算法具备 内存空间压缩的能力,堆中使用过的内存放在一边,未使用的内存放在另一边,中间放着一个指针,每次分配新内存只需要移动指针就可以。
空闲列表
:堆内存并不规整,已使用和未使用的内存区域通过jvm维护的一张列表记录下来,这张表就是 空闲列表,当有对象分配时,只要在这张表上找到合适的记录,分配完成之后再更新表的数据就可以了。
堆内存可以被设置为固定的大小,也可以设置成可以扩展的,通过-Xms和-Xmx两个参数来配置。
- 堆内存的区域划分
在内存回收的角度来看,由于很多GC收集器是基于分代理论设计的,所有经常会在讨论堆内存时,划分“新生代”、“老年代”、“永久代”这些经典的分代。但在讨论这些概念时,必须要声明使用的是哪种垃圾收集器为前提。在《Java虚拟机规范》一书中并没有对堆内存进行详细划分,这些堆内部分区的概念,仅仅是某些垃圾收集器的设计风格或者实现原理。在G1垃圾收集器出现之后,这样的分区概念逐渐被弱化,甚至已经不再体提及。
上述图中,左侧为经典的分代设计,右侧为G1收集器的Region分区设计。
- 堆内存分配的线程安全问题
由于堆内存是可以被所有线程共享的,所以这里很容易出现线程不安全的问题。我们假设一个例子,两个线程都要进行对象的创建,A线程刚刚划分了一块内存准备分配对象,还没来得及将指针调整到正确位置,B线程获取到时间片以后,又读取了指针位置进行内存分配,这样就会出现问题。目前,虚拟机采用的方式基本上是CAS+失败重试的方式,还有一种是使用TLAB技术,也就是本地线程分配缓冲区。
在虚拟机开启了TLAB (Thread-Local Allocation Buffer)
功能的情况下,线程初始化的时候,虚拟机会在堆上划分出一小块TLAB
内存来给当前线程使用,这样每个线程都有自己的空间,在分配内存时,就会在这块内存上分配,互相之间也不存在竞争的情况,解决线程安全性的同时也能提升效率。下面,我们假设在使用了内存分代技术
的前提下,开启了TLAB的功能。
需要说明的是,线程专属内存并不是所有的操作都会独享,只是在分配
这一个动作上是独享的,而对于读取、对象的移动和回收都是可以被其他线程操作的。比如,分代情况下,TLAB是分配在Eden区的,但如果执行垃圾回收就有可能被移动到Survivor区。
还有一点需要说明的是,TLAB的空间其实很小,对于大对象的分配还是有可能在堆上直接分配的,具体步骤是先去尝试在TLAB上分配,空间如果不如就判断需要进入老年代还是在Eden区域分配,在执行这样的操作时,就需要配上CAS+失败重试的操作。
由于TLAB在分配的时候,是从堆中划分,类似于对象分配原理一样,在为线程分配TLAB内存空间时,需要进行并发控制来避免线程不安全的问题。所以,“堆是线程共享的内存区域”这句话也不完全正确,正是因为TLAB的存在,使得线程有了独享各自内存区域的可能。
下面是一篇很好的关于TLAB技术的解析:< Hotspot Java对象创建和TLAB源码解析 >
4. 方法区
方法区也是线程共享的内存区域,与Java堆不同的是,方法区中存储的是加载的类型信息、常量、静态变量、即时编译后缓存的代码数据等
。在JDK8前后,方法区的实现做了比较大的改动,从永久代
调整为元空间
,我们来看一下这个过程。
首先说明,不管是永久代还是元空间,都仅仅是jdk不同版本下,对方法区
的实现方式不同而已,在《Java虚拟机规范》中并没有规定任何实现细节。我们来看一下永久代
和元空间
的概念和定义。
4.1 永久代
永久代
设计初衷是为了把堆分代的思路扩展到方法区,使得垃圾收集器可以像管理Java堆一样来管理方法区的内存,省去一部分专门给方法区写垃圾回收器的开发工作。但实际上,这种设计方式一直都带来了一些问题,我们看下永久代的特点。
- 存在于JDK7及以前
- 位置属于JVM堆内存的一部分
- 存储内容包含JVM加载的类的信息、常量池、静态变量JIT编译后的代码等
- 特点:
- 设置参数(-XX:PermSize和-XX:MaxPermSize)对永久代大小有固定限制
- 如果永久代内存空间不足,会抛出异常:
java.lang.OutOfMemoryError: PermGen space
4.2 元空间
随着oracle陆续收购了JRocket和HotSpot虚拟机,后续版本里面,就永久舍弃了永久代,使用本地内存,也就是元空间
来实现方法区。我们看下元空间的特性:
-
存在于JDK8及以后的版本
-
位置上,不在虚拟机的堆内存中了,而是使用本地内存(操作系统内存),但是从操作系统的角度来看,元空间仍然是进程的一部分内存,这里的区别在于,与jvm堆内存相比,元空间不受JVM堆的大小限制。
-
元空间的大小只会受到本地内存的限制,虽然可以通过一些参数去调整元空间的大小(比如 -XX:MetaspaceSize和-XX:MaMetaspaceSize),但是这些参数不会严格控制元空间的大小,而是用于垃圾收集器启动阈值和元空间自动增长的控制。
- -XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。
- -XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。
5. 常量池
其实,我们应该听过好几种常量池,或这个在不同版本里面常量池的不同种实现,在这里,我们来梳理一下。
5.1 Class文件常量池:
Class文件常量池
又可以叫静态常量池
、编译期常量池
,此常量池实在Class文件编译阶段,由编译器生成并存储在字节码文件中的,是以二进制的形式记录在生成的.class文件中,所以在类文件编译之后,此常量池中记录的东西就不会再变化了。Class文件常量池
中存储的是类文件中包含的常量数据,以及对其他类、方法或者字段的符号引用,是每个类的私有常量池,与类一一对应。
5.2 运行时常量池:
运行时常量池
也可以叫做动态常量池
,类加载后在JVM中动态生成运行时常量池,每个类都会有自己的运行时常量池。类加载时,会把编译期间生成的类信息和常量等(存储在Class文件常量池中),会加载到类对应的运行时常量池中。运行时常量池
相对于Class文件常量池
一个特性是具备动态性,在类加载期间通过反射生成的类、方法等,也会动态加载到运行时常量池中。运行时常量池
位于方法区中(1.7的永久代或1.8的者元空间),均不属于堆内存。
5.3 字符串常量池:
字符串常量池
是jvm中一个特殊的存储区域,提供为所有线程可以共享的常量,所以在全局中仅此一份,可以提高存储空间、提升性能。
由于字符串具有的不可变性,一旦创建,其值不能再改变。当我们在创建一个字符串时,jvm会首先检查字符串产量池中是否存在,如果存在就直接返回现有对象的引用,否在会创建此字符串并返回引用。这个机制在不同的java版本中有不同的表现。
jdk 1.7版本前:字符串常量池位于方法区(永久代)中,但是由于永久代的大小是固定的,而且难以调整,当存储的数据量超过永久代的最大容量时,就会抛出PermGen Space
的错误,这种固定大小的设置限制了Java应用的扩展性,尤其是在大量使用字符串、动态生成类和大量使用反射的场景下。
在这些版本中,字符串常量池位于方法区,而不在Java堆中,当我们在代码中创建一个字符串字面量时,如String s = "Hello"
,Jvm会检查字符串常量池中是否存在内容为"Hello"
的字符串对象:
- 如果存在,Jvm会直接返回该字符串对象在防范区中的地址引用;
- 如果不存在,Jvm会在方法区的字符串常量池中创建一个新的字符串对象,并且返回这个对象的地址引用;
// 此代码案例是运行在jdk1.6中//这一行代码中,在堆中产生了一个字符串为“12”的对象,但是在方法区中并没有“12”这个字符串,// 但需要注意的是,方法区中已经有了“1”和“2”的字符串。String str1 = new String("1") + new String("2");// 这一行代码中, str1.intern() 判断产量池中并没有“12”,于是在常量池中创建一个“12”的字符串,// 此时返回的是常量池中“12”这个字符串的地址信息。对于intern和str1来说,一个是在堆中的地址,// 一个是在常量池中的地址,所以,肯定是不相等的,所以intern == str1为false;String intern = str1.intern();// 这一行代码中,首先去常量池中查看有没有“12”,发现已经有了字符串“12”,这时候是字符串而不是引用,// 于是返回常量池中“12”这个字符串的地址信息,其实和intern是一样的返回值,所以intern==str2为true;// 同理,str1和str2,str1是堆中的地址信息,str2是常量池中的地址信息,所以str2==str1,也是false;String str2 = "12";System.out.println(str1 == str2);//falseSystem.out.println(intern == str1);//falseSystem.out.println(intern == str2);//true
jdk 1.7版本及其后:从Jdk1.7开始,字符串常量池被异动到了Java堆中。当我们创建一个字符串对象时,如String s = "Hello"
,此处创建的字符串对象位于Jvm的堆中,这里不是直接创建在字符串常量池的内存中,到这个时候,字符串常量池中存放的是指向Java堆中字符串对象的引用:
- 如果已经存在一个等于"hello"的字符串对象的引用,直接返回该引用,注意是返回在堆中的引用地址;
- 如果不存在,首先在堆内存中创建一个字符串对象,然后把堆中的地址引用存放在字符串常量池中,然后再返回该地址(这里的地址是堆中的地址引用);
// 以下代码是运行在jdk1.8中// 这一行代码中,在堆中产生了一个字符串为“12”的对象,但是在方法区中并没有“12”这个字符串,// 但需要注意的是,方法区中已经有了“1”和“2”的字符串。String str1 = new String("1") + new String("2");// 这一行代码中, str1.intern() 判断产量池中并没有“12”,于是将堆中“12”这个对象的地址信息复制到方法区中,// 并返回该地址信息(堆中的地址信息)。所以,intern的返回值与str1是一样的地址信息,intern == str1为true;String intern = str1.intern();// 这一行代码中,首先常产量池中查看有没有“12”,发现已经有了字符串“12”的引用,于是不再创建,// 直接返回该地址信息(堆中的地址信息)。所以,其实str2和str1是一样的地址信息,// str1==str2为true;intern的返回值与str2也是一样的地址信息,所以intern == str2为true。String str2 = "12";System.out.println(str1 == str2);//trueSystem.out.println(intern == str1);//trueSystem.out.println(intern == str2);//true
参考文章:https://blog.csdn.net/qq_45659753/article/details/131922160