首页>>后端>>java->Java JVM内存模型(运行时数据区域)详解

Java JVM内存模型(运行时数据区域)详解

时间:2023-12-01 本站 点击:0

Java程序在运行时,需要在内存中的分配空间。为了提高运算效率,java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的内存区域,因为每一片区域都有特定的处理数据方式和内存管理方式。

@[toc]

根据《 Java虚拟机规范(Java SE 7版 )》的规定,Java虚拟机所管理的内存将会主要包括以下几个运行时数据区域:程序计数器、方法区、堆、虚拟机栈、本地方法栈,当还有一些其他特殊的内存区域,比如常量池、直接内存(不属于JVM内存)等。

1 程序计数器(Program Counter Register )

程序计数器(Program Counter Register)是一块较小的内存空间,它可以是看作当前线程所执行的字节码的行号指示器,又称PC寄存器( PC register )。每个线程启动的时候,都会创建一个程序计数器。

在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器(CPU)执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(cpu、对于多核处理器来说是一个内核)都只会执行一条线程中的指令(CPU一般是使用时间片轮转方式让线程轮询占用的,所以当前线程CPU 时间片用完后,要让出CPU,等下次轮到自己的时候再执行。)因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

在JVM规范中规定,如果线程执行的是非native方法即Java方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是native方法,则程序计数器中的值是undefined。此内存区域是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。

2 虚拟机栈(Java Virtual Machine Stacks)

Java虚拟机栈( Java Virtual Machine Stacks ) 也是线程私有的,它的生命周期与线程相同。虚拟机栈是描述java方法执行的内存模型:存放的是一个个的栈帧,每个栈帧对应一个被调用的方法,每个方法在执行时会形成一个栈帧(Stack Frame)。在栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些额外的附加信息。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、 float、long、 double ) 、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 returnAddress类型 (指向了一条字节码指令的地址)。

其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

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

3 本地方法栈(Native Method Stack)

Java 虚拟机实现可能会使用到传统的栈(通常称为 C stack) 来支持 native 方法(指使用 Java 以外的其他语言编写的方法)的执行, 这个栈就是本地方法栈 (native method stack)。

虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。Sun HotSpot直接就把本地方法栈和虚拟机栈合二为一。本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。

4 堆(Java Heap)

Java堆(Java Heap)是JVM内存管理的最大的一块区域,此内存区域的唯一目的就是存放对象的实例,所有对象实例与数组都要在堆上分配内存,几乎所有的对象实例都在这里分配内存,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,“所有的对象都分配在堆上”也渐渐变得不是那么“绝对”了,后面会讲到这两个技术。

它也是垃圾收集器的主要管理区域,因此很多时候也被称做“GC堆”,堆内存基于GC又可以分成几部分,在堆内存模型和垃圾回收部分中会有讲到。

从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有 Eden空间、 From Survivor空间、 To Survivor空间等。

从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB )。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例。

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

4.1 逃逸分析技术

通过JIT即时编译器的应用,可以对代码做出逃逸分析,逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问的分析技术,是JVM为了优化对象分配而做的一种优化措施,编译器会根据逃逸分析的结果对代码进行优化。

Java的逃逸分析是方法级别的,因为JIT的即时编译是方法级别。

通过逃逸分析可以做出进一步优化:

栈上分配。将堆分配转化为栈分配。如果逃逸分析发现某个对象只会在方法中被分配,并且指向该对象的引用永远不会逃逸到方法之外,即被外部引用,那么该对象就可以在分配在栈上,而不是在堆上,这样该对象所占用的内存空间就可以随栈帧出栈而销毁。栈上分配的目的是为了减少将对象分配到堆上的概率,节约堆内存,减少GC压力。

标量替换。通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。

锁消除。如果发现某个对象,或者某个代码片段只能同时被一个线程访问,那么在这个对象或者代码片上的同步操作可以被省去。最常见的就是JDK1.5之前字符串引用相加时会被转换为StringBuffer 中的 append方法,但是方法被 Synchronized 关键字修饰,会使用到锁,从而导致性能下降。但实际上,一个局部方法内使用StringBuffer 和 StringBuilder 的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。

5 方法区(Method Area)

方法区在JVM中也是一个非常重要的区域,它与堆一样,是被线程共享的区域。在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码、运行时常量池(逻辑包含字符串常量池)等。

方法区是堆的一个逻辑部分,为了区分Java堆,它还有一个别名Non-Heap(非堆)。相对而言,GC对于这个区域的收集是很少出现的。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

5.1 永久代和方法区的关系

在Java7及之前版本,我们也习惯称方法区它为“永久代”(Permanent Generation),更确切来说,应该是“HotSpot使用永久代实现了方法区”!

方法区是JVM的规范,永久代是HotSpot虚拟机在JDK1.7及之前对方法区的具体实现,因此元空间也是方法区在JDK1.8中的一种实现。

6 直接内存(Direct Memory)

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError 异常出现。

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据,可以使用NIO包下ByteBuffer#allocateDirect方法来创建堆外内存,更底层是使用Unsafe类来申请的。

6.1 直接内存的OufOfMemoryError

显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。另外,若我们通过参数“-XX:MaxDirectMemorySize”指定了直接内存的最大值,其超过指定的最大值时,也会抛出内存溢出异常。

6.2 为什么使用堆外内存

减少了垃圾回收

使用堆外内存的话,堆外内存是直接受操作系统管理(而不是虚拟机)。这样做的结果就是能保持一个较小的堆内内存,以减少垃圾收集对应用的影响。

提升复制速度(io效率)

堆内内存由JVM管理,属于“用户态”;而堆外内存由OS管理,属于“内核态”。如果从堆内向磁盘写数据时,数据会被先复制到堆外内存,即内核缓冲区,然后再由OS写入磁盘,使用堆外内存避免了这个操作。

6.3 堆外内存申请

JDK的ByteBuffer类提供了一个接口allocateDirect(int capacity)进行堆外内存的申请,底层通过unsafe.allocateMemory(size)实现。Netty、Mina等框架提供的接口也是基于ByteBuffer封装的。

6.4 直接内存(堆外内存)与堆内存比较

直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显。

直接内存IO读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显。

6.5 直接内存使用场景

有很大的数据需要存储,它的生命周期很长。

适合频繁的IO操作,例如网络并发场景。

6.6 直接内存的回收

直接内存的分配:

/***直接内存申请*@paramcapacity大小*@return*/publicstaticByteBufferallocateDirect(intcapacity){returnnewDirectByteBuffer(capacity);}DirectByteBuffer(intcap){super(-1,0,cap,cap);//内存是否按页分配对齐booleanpa=VM.isDirectMemoryPageAligned();//获取每页内存大小intps=Bits.pageSize();//分配内存的大小,如果是按页对齐方式,需要再加一页内存的容量longsize=Math.max(1L,(long)cap+(pa?ps:0));//用Bits类保存总分配内存(按页分配)的大小和实际内存的大小Bits.reserveMemory(size,cap);longbase=0;try{//在堆外内存的基地址,指定内存大小base=unsafe.allocateMemory(size);}catch(OutOfMemoryErrorx){Bits.unreserveMemory(size,cap);throwx;}//进行内存初始化unsafe.setMemory(base,size,(byte)0);//计算堆外内存的基地址if(pa&&(base%ps!=0)){//Rounduptopageboundaryaddress=base+ps-(base&(ps-1));}else{address=base;}//构建Cleaner对象用于跟踪DirectByteBuffer对象的垃圾回收,以实现当DirectByteBuffer被垃圾回收时,分配的堆外内存一起被释放。cleaner=Cleaner.create(this,newDeallocator(base,size,cap));att=null;}

unsafe.allocateMemory(size)用于分配直接内存,但是这块内存需要进行手动释放,JVM并不会进行回收,幸好Unsafe提供了另一个接口freeMemory方法可以对申请的堆外内存进行释放。

在Cleaner 内部中通过一个列表,维护了针对每一个 directBuffer 的一个回收堆外内存的线程对象(Runnable),回收操作是发生在 Cleaner 的 clean() 方法中。

publicstaticCleanercreate(Objectvar0,Runnablevar1){returnvar1==null?null:add(newCleaner(var0,var1));}privateCleaner(Objectvar1,Runnablevar2){super(var1,dummyQueue);this.thunk=var2;}publicvoidclean(){if(remove(this)){try{//此处会调用Deallocator的run方法,Deallocator实现了Runnable接口,是DirectByteBuffer的内部类。this.thunk.run();}catch(finalThrowablevar2){AccessController.doPrivileged(newPrivilegedAction<Void>(){publicVoidrun(){if(System.err!=null){(newError("Cleanerterminatedabnormally",var2)).printStackTrace();}System.exit(1);returnnull;}});}}}privatestaticclassDeallocatorimplementsRunnable{privatestaticUnsafeunsafe=Unsafe.getUnsafe();privatelongaddress;privatelongsize;privateintcapacity;privateDeallocator(longaddress,longsize,intcapacity){assert(address!=0);this.address=address;this.size=size;this.capacity=capacity;}publicvoidrun(){if(address==0){//Paranoiareturn;}//调用unsafe提供的方法释放内存unsafe.freeMemory(address);address=0;Bits.unreserveMemory(size,capacity);}}

当初始化一块堆外内存时,对象的引用关系如下:

其中first是Cleaner类的静态变量,Cleaner对象在初始化时会被添加到Clener链表中,和first形成引用关系,ReferenceQueue是用来保存需要回收的Cleaner对象。如果该DirectByteBuffer对象没有了任何引用,那么该DirectByteBuffer可以被回收。

此时,只有Cleaner对象唯一保存了堆外内存的数据(开始地址、大小和容量),Cleaner类继承了PhantomReference,因此Cleaner对象是一个虚引用对象,在下一次GC时,该Cleaner对象将会被放入到ReferenceQueue中,并触发clean方法。

因此Cleaner对象的clean方法主要有两个作用:

把自身从Clener链表删除,从而在下次GC时能够被回收

释放堆外内存

如果JVM一直没有执行FGC的话,无效的Cleaner对象就无法放入到ReferenceQueue中,从而堆外内存也一直得不到释放,内存岂不是会爆?

其实在初始化DirectByteBuffer对象时,如果当前堆外内存的条件很苛刻时,会主动调用System.GC()强制执行FGC。

7 常量池(Constant Pool)

是专门用于管理被final修饰的,以及在编译期被确定值并被保存在已编译的class文件中的一些数据,它还包括了关于类、方法、接口中的常量,还包括字符串的字面常量赋值形式,还能存放运行时产生的常量,比如部分基本类型包装类(Byte 范围-128-12、Character 范围0-127、Short 范围-128-127、Integer 范围-128-127、Long 范围-128-127)。

常量池又分为字符串常量池、class常量池和运行时常量池。

7.1 字符串常量池(String Constant Pool)

7.1.1 字符串常量池在Java内存区域的哪个位置?

在JDK1.6及之前版本,字符串常量池是放在Perm Gen区(也就是方法区)中,逻辑包含在运行时常量池中。

在JDK1.7版本开始,字符串常量池从方法区被移到了堆中了。至于为什么移到堆内,大概是由于方法区的内存空间太小了。在大量使用字符串的场景下会导致 OutOfMemoryError 错误。

7.1.2 字符串常量池是什么?

在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个Hash表,默认值大小长度是1009;这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了StringTable上。

在JDK1.6中,StringTable的长度是固定的,长度就是1009,因此如果放入String Pool中的String非常多,就会造成hash冲突,导致链表过长,当调用String.intern()时会需要到链表上一个一个找,从而导致性能大幅度下降;

在JDK1.7中,StringTable的长度可以通过参数指定:

-XX:StringTableSize=66666

7.1.3 字符串常量池里放的是什么?

在JDK1.6及之前版本中,String Pool里放的都是字符串常量;

在JDK1.7中,由于String.intern()发生了改变,因此String Pool中也可以存放放于堆内的字符串对象的引用。JDK1.7及之后,常量池移动到堆中,如常量池不存在,则在内存中找,如果内存中有,就在常量池中保存一个指向内存中对象的引用。

7.1.4 String.intern()方法

JDK1.6中:intern()方法会先去查询常量池中是否有已经存在,如果存在,则返回常量池中的引用,如果不存在,会把首次遇到的字符串实例复制到常量池中,返回的也是常量池中这个字符串的实例的引用。

JDK1.7及之后:会先判断常量池中是否存在当前字符串,如果存在,判断这个常量是存在的引用还是常量,如果是引用,返回引用地址指向的堆空间对象,如果是常量,则直接返回常量池常量引用;如果不存在,就在常量池中保存一个指向堆内存中对象的引用。

案例:

Stringstr1="a";Stringstr2="b";Stringstr3="ab";Stringstr4=str1+str2;Stringstr5=newString("ab");System.out.println(str5.equals(str3));System.out.println(str5==str3);System.out.println(str5.intern()==str3);System.out.println(str5.intern()==str4);truefalsetruefalse

str5.equals(str3)这个结果为true,因为字符串的值的内容相同。

str5 == str3对比的是引用的地址是否相同,由于str5采用new String方式定义的,所以地址引用一定不相等。所以结果为false。

当str5调用intern的时候,会检查字符串池中是否含有该字符串。由于之前定义的str3已经进入字符串池中,所以会得到相同的引用。

当str4 = str1 + str2后,str4的值也为”ab”,但是为什么这个结果会是false呢?因为字符串引用的计算,虚拟机会优化成stringbuilder的计算(JDK1.7之后): 即:new StringBuilder(String.valueOf(str1))).append(str2).toString(),这会在堆空间生成对象,肯定返回false。

7.1.5 关于常量计算的一些规则

对于直接做+运算的两个字符串(字面量)常量,并不会放入字符串常量池中,而是直接把运算后的结果放入字符串常量池中(String s = "abc"+ "def", 会直接生成“abcdef"字符串常量 而不把 "abc" "def"放进常量池)

对于先声明的字符串字面量常量,会放入字符串常量池,但是若使用字面量的引用进行运算就不会把运算后的结果放入字符串常量池中了(String s = new String("abc") + new String("def"),在构造过程中不会生成“abcdef"字符串常量) 即:JVM会对字符串常量的运算进行优化,未声明的,只放结果;已经声明的,只放声明。

常量池中同时存在字符串常量和字符串引用。直接赋值和用字符串调用String构造函数都可能导致常量池中生成字符串常量;而intern()方法会尝试将堆中对象的引用放入常量池。(JDK1.7之后)

String str1 = "a"; String str2 = "b"; String str4 = str1 + str2; //该语句只在堆中生成一个对象(str4)

这句被Java编译器做了优化, 实际上使用StringBuilder实现的(不在堆里生成str1和str2对象)。

4.Stringstr5=newString("ab");(字符串常量池中不存在"ab"时)在字符串常量池中创建"ab"对象,在堆中生成了一个对象str5,str5指向堆上new的对象,而str5内部的charvalue[]则指向常量池中的charvalue[],即”ab”。

7.2 class常量池(Class Constant Pool)

我们写的每一个Java类被编译后,就会形成一份class文件;class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。

每个class文件都有一个class常量池,又称静态常量池。

7.3 运行时常量池(Runtime Constant Pool)

运行时常量池存在于内存中,也就是class常量池(constant_pool table)被加载到内存之后的版本,不同之处是:它的字面量可以动态的添加(String#intern()),符号引用可以被解析为直接引用。

JVM在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,JVM就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在解析阶段,会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,也就是我们上面所说的StringTable,以保证运行时常量池所引用的字符串与字符串常量池中是一致的。

JDK1.7及之前运行时常量池存在于方法区中,之后移动到元空间中。

既然运行时常从池是方法区的一部分, 自然受到方法区内存的限制. 当常量池无法再申请到内存时会抛出OutOfMemoryError异常.

8 字面量、符号引用、直接引用

8.1 字面量

在计算机科学中, 字面量(literal)是用于表达源代码中一个固定值的表示法(notation)。几乎所有计算机编程语言都具有对基本值的字面量表示, 诸如: 整数, 浮点数以及字符串; 而有很多也对布尔类型和字符类型的值也支持字面量表示; 还有一些甚至对枚举类型的元素以及像数组, 记录和对象等复合类型的值也支持字面量表示法。

int i = 1;把整数1赋值给int型变量i,整数1就是Java字面量; String s = "abc";中的abc也是字面量。

字面量包括:

文本字符串

八种基本类型的值

被声明为final的明确值的常量等

8.2 符号引用

符号引用是一组符号, 用来描述所引用的目标, 符号可以是任何形式的字面量, 只要使用时能够无歧义的定位到目标即可。 例如, 在Java中, 一个Java类将会编译成一个class文件. 在编译时,Java类并不知道所引用的类的实际地址, 因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类, 在编译时People类并不知道Language类的实际内存地址, 因此只能使用符号org.simple.Language来表示Language类的地址。

符号引用包括:

类和方法的全限定名

字段的名称和描述符

方法的名称和描述符

8.3 直接引用

直接引用说白了, 就是程序运行时可以定位到引用的东西(类, 对象, 变量或者方法等)的地址。直接引用在类加载的解析阶段会被解析为符号引用。

直接引用可以是:

直接指向目标的指针.(个人理解为: 指向方法区中类对象, 类变量和类方法的指针)。

相对偏移量.(指向实例的变量, 方法的指针)。

一个间接定位到对象的句柄(在对象部分会有讲解)。

相关文章:

《深入理解Java虚拟机》

《Java虚拟机规范》

如有需要交流,或者文章有误,请直接留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:/java/5532.html