Java虚拟机一:Java运行时内存区域及对象的创建

Posted 刘镓旗

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java虚拟机一:Java运行时内存区域及对象的创建相关的知识,希望对你有一定的参考价值。

一、Java运行时内存区域

首先我们都知道Java的内存管理是由虚拟机管理的,但是如果我们不了解虚拟机的内存管理那么就会造成内存泄露进而导致内存溢出。而且如果不了解内存的分配情况,当我们真的出现了内存泄露或者溢出的时候,我们排查起来那将是异常艰难的,所以了解Java的内存分配是很必要的,对我们的程序的内存优化也是大有利益的。而且,很多人把虚拟机只分为堆内存和栈内存,这种说法是不对的,可是为什么又会有很多人这么认为呢,其实是很多人都只关注 “对象” 所占用的内存区域。废话不多话了,我们今天就来看一下虚拟机到底都有哪些内存区域,而且是干什么的。

[声明]Java虚拟机设计的知识点主要来源于周志明先生的深入理解Java虚拟机

1.1 Java运行时的数据区域:
Java虚拟机在运行程序的过程当中会将内存划分为若干个区域,每个区域都有不同的作用,有的区域是随着虚拟机的进程启动,有的而是随着线程的创建和结束而创建与销毁的,先来看一张图。

Java运行时内存区域是由:方法区(常量池是它一部分),虚拟机栈,本地方法栈,堆,和程序计数器组成。

1.1.1 程序计数器:

程序计数器是一块较小的内存区域,他其实是当前线程所执行字节码的行号指示器,其实就类似于我们的代码一行行执行的行号。字节码的解释器在工作的时候是通过改变这个计数器的值来决定需下一次需要再执行哪一个执行字节码、分支、循环、异常或者线程恢复等操作的,再说白点就是执行完这次操作下次需要执行什么操作。当执行Java方法的时候那么它所存储的是正在执行的字节码的指令地址,而如果执行的是native方法的话,那么它存储的值为null,程序计数器是为一个不会抛出OOM的内存区域。

切记,上边说了它是“当前”线程所执行字节码的行号指示器,那么我们Java虚拟机是多线程的,那么也就是说其实每一个线程都会有自己独立的程序计数器独自存储,这也是上面图中说的它是线程隔离的区域

1.1.2 Java虚拟机栈:

虚拟机栈表示的是Java方法的内存模型,每个方法在执行的时候都会创建一个 “栈帧” 用来存储局部变量表、操作数栈、动态链接、方法出口等,每一个方法从开始执行到结束,都对应了一个栈帧在虚拟机栈中入栈到出栈的过程,虚拟机栈和程序计数器一样,也是线程独立的。
这个内存区域中规定了两种异常,1.如果线程请求的栈深度大于虚拟机规定的深度,将抛出StackOverflowError异常;2.如果虚拟机在动态扩展时无法申请到足够内存会抛出OutOfMemoryError异常。
这里可能会有人疑惑,那么我们平时说的压栈出栈难道说的不是一个栈吗?当然不是,我们每一个线程都会有一个独立的虚拟机栈,为什么我们平时会误以为只有一个栈呢,个人觉得应该是因为我们是默认在 “主线程”,“主线程” ,“主线程”中开发的,所有会误以为说的压栈出栈都是一个栈

这里先只介绍栈帧和局部变量表,操作数栈、动态链接、和方法出口会在后续的文章再说。

1) 栈帧 :

栈帧是用于虚拟机进行方法执行时的数据结构,每一个栈帧中都包括了局部变量表,操作数栈,动态链接,方法出口等信息。

在代码编译的时候,栈帧中需要多大内存的局部变量表,多深的操作数帧等都已经确定,并且写入到了 “方法表”中的Code属性中,也就是说一个栈帧需要分配多少内存早就在编译的时候就确定了,在运行期是不受影响的。

一个线程中的方法调用链可能很长,很多方法都可能同时处于执行状态,在活动的线程中,只有位于栈顶的栈帧才是有效的,称之为当前栈帧,与这个栈帧相关联的方法就是当前方法,执行引擎运行的字节码指令只会对当前栈帧进行操作,下面我们看一下栈帧的示意图。

重点:每个方法执行时都会产生一个栈帧,而且一个方法可以被多个线程调用执行,但是栈帧并不是共享的,栈帧只是虚拟机栈中的一个组成元素,而每个线程都有属于自己的虚拟机栈,栈帧只会属于每个线程的虚拟机栈,切记

2) 局部变量表 :

局部变量表是一组用来存储方法参数和方法内声明的局部变量的存储空间,在被生成Class文件之前就已经确定的容量大小,存储在方法表Code属性中的max_locals中。

局部变量以变量槽(Slot)为最小单位,至少可以存放一个boolean、byte、char、short、int、float、reference、retuenAddress类型的数据,也就是说变量槽可以存放一个32位长度以内的数据结构,但是这里reference其实是一个对象的实例引用,Java规范并没有说明他的大小,至于retuenAddress现在已经很少了,就不说了。

那么估计会有人疑惑了,变量槽说了可以存放一个32位长度的数据结构,但是我们明明可以在方法中声明一个long或者double,其实虚拟机使用了分割连续存储的方式,也就是使用连续的两个变量槽来存储64位的数据,不过Java规范明确表明了不允许使用任何方式来单独访问其中一个,否则在虚拟机在类加载的过程中会抛出异常。

局部变量表使用了索引的方式,值的范围是0-Slot的最大数量,为什么这么说而不说0-最后一个变量呢?,因为其中64位的是占两个连续的Slot的,如果执行的不是static的方法的话,那么索引的0位默认是this,代表的是这个方法所属对象的引用。

重点:为了节省内存空间,局部变量表中的变量槽(Slot)是可以被复用的,方法体中的作用域并不一定覆盖整个方法体(要理解哦,例如方法中的if和else都是各自的作用域),当计数器(程序计数器)的的值超过了某个变量的作用域,那么这个变量的变量槽可以交给其他变量使用(要理解是超出了这个作用域),不过这样也是有弊端的,下面使用例子说明:

例子1:
    public static void main(String[]args)()
        byte[] placeholder = new byte[64*1024*1024];
        System.gc();
    

上面例子在方法中像虚拟机申请了一个64M的数据,然后通知虚拟机进行垃圾回收,可以通过在虚拟机运行参数上加上–verbose:gc 来查看垃圾回收的过程

例子1结果:
[GC 66846K->65824K(125632K),0.0032678 secs]
[Full GC 65824K->65746K(125632K),0.0064131 secs]

我们发现placeholder 并没有被回收所占用的内存,因为在执行gc的时候变量placeholder 还处于作用域中,所有虚拟机是不敢进行回收的,再看

例子2:
    public static void main(String[]args)()
        
            byte[] placeholder = new byte[64*1024*1024];
        
        System.gc();
    

这次我们将placeholder的作用域改变一下,从代码逻辑上看在执行gc的时候placeholder已经不在被使用了,看一下结果

例子2结果:
[GC 66846K->65888K(125632K),0.0009397 secs]
[Full GC 65888K->65746K(125632K),0.0051574 secs]

这样写还是没有被回收,再来看下一个例子

例子3:
public static void main(String[]args)()
        
            byte[] placeholder = new byte[64*1024*1024];
        
        int a = 0;
        System.gc();
    

例子3结果:
[GC 66401K->65778K(125632K),0.0035471 secs]
[Full GC 65778K->218K(125632K),0.0140596 secs]

例子3中被正确的回收了,原因是因为局部变量表中的变量槽(Slot)在离开自己的作用域后没有任何的局部变量的读写了,placeholder原本占用的Slot没有再被其他的变量所复用,所有作为GC Roots一部分的局部变量表仍然持有placeholder的引用,试想一下,如果如果有一个占用内存很多的变量,而且再离开自己的作用域后会执行一段很耗时的操作,那么这个对象就不会被回收,所以在遇到这种情况的时候建议收到将对象置为null,其实也就是等同于上边的int a = 0的操作,只是为了让局部变量表再一次的读写操作复用上一个Slot,当然这种情况再被JIT编译后是不会存在的。所以手动置null这要看自己的习惯了。

1.1.3 本地方法栈:

本地方法栈和虚拟机栈的作用类似,只不过虚拟机栈是用来服务Java方法执行的(字节码),而本地方法栈用来服务Native方法的。

1.1.4 Java堆:

Java堆是虚拟机管理内存的最大一块,Java堆是被所有线程共享的一块区域,在虚拟机启动的时候创建。Java堆唯一的目的就是存放对象实例,几乎所有的对象实例及数组都在这里分配,Java规范中也是这么规定的,但是随着现在JIT编译器的发展和“逃逸分析”技术的成熟,也不一定所有的对象都分配在堆上了,逃逸分析会在后续文章中说。如果堆中没有内存可以分配给实例了,并且堆无法扩展时,将会抛出OutOfMemoryError异常。

Java堆是垃圾收集器管理的主要区域。从内存回收的角度来看,由于现在收集器基本都采用了分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细一点有Eden空间、From Survivor空间、To Survivor空间等。从内存分配的角度来看,因为Java堆是线程共享的,所以可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),不过无论怎么划分都与存储的内容无关,存储的都是对象的实例,进一步划分只不过是为了更好的回收内存和更快的分配内存,Java堆的内存分配会在后续文章说..

1.1.5 方法区:

方法区和Java堆一样,是线程共享的区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。jdk1.7之前HotSpot虚拟机把GC分代收集延伸到了方法区,大家将这部分称之为永久代,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,这样HotSpot的垃圾收集器可以像管理Java堆一样的来管理这部分空间,但是这样做却更容易造成内存溢出的风险,因为永久代有-XX:MaxPermSize的参数设置或者说是有上限,当达到上限的时候就会抛出 Java.lang.OutOfMemoryError:PermGen space的异常,标示永久代内存溢出了。而在jdk1.7的时候HotSpot将字符串常量池移出了永久代,移动到了Java堆中,在1.8中彻底的取消了永久代,改用元空间代替.

1.1.6 运行时常量池:

运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等信息外,还有一项是常量池,用于存放在“编译期间”生成的各种字面量和符号引用,这部分内容在类被加载以后进入到方法区的运行时常量池中。字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的变量值等,符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:类和接口的全限定名、字段名称和描述符、方法名称和描述符。
运行时常量池相对于Class文件常量池的一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非置于Class文件常量池中的内容才能进入到运行时常量池,运行期间也可能将新的常量放入运行时常量池,这种特性用的最多的是String.intern()方法
既然运行时常量池属于方法区,那么自然受到方法区内存的限制,当常量池无法申请到内存的时候也会抛出OutOfMemoryError异常.

重点:以上为深入理解Java虚拟机中的定义,但是对于常量池的理解有很多种理解和说法,每个人理解的可能都不相同,这里只说个人理解:从上面的定义中我们可以知道每个Class文件都有一个属于自己的常量池,这个常量池是每个Class独有的,主要用来存放在“在编译期间“产生的常量和符号引用,在运行期间会将Class中常量池的东西放入运行时常量中,我们常说的常量池应该就是这个运行时常量池。运行时常量池和Class常量池对于字符串常量是使用Symbol的符号引用,在每个Class的常量池中都有一份自己的Symbol内容,是不共享的。而在运行时常量池中这个Symbol的内容是类共享的。而这个Symbol其实通过C代码实现的StringTable,也就是字符串常量池。

二、HotSpot虚拟机对象过程

2.1 对象的创建

虚拟机遇到new指令时,将会先去检查这个指令的参数是否能够在常量池中定位到一个类的”符号引用”,并检查这个符号引用代表的类是否已经被加载、解析和初始化过,如果没有,那么就会先执行类的加载过程,类的加载过程会在后续文章中说。

在类加载完成后,接下来就是虚拟机给对象分配内存了。对象所需内存的大小在类被加载完成后就已经确定。给对象分配内存的过程有两种方式,选择哪种分配方式取决于Java堆采用的垃圾回收器是否压缩整理功能决定:
1。指针碰撞: 在给对象分配内存的时候,如果Java堆中的内存是规整的,也就是所有用过的内存放在一边,空闲的内存放在一边,在中间的临界点是内存指针,那么这个时候如果要给新对象分配内存只需要将指针像空闲内存那边挪动一块与要分配对象内存大小相等的距离,这种方式称为“指针碰撞”。
2。空闲列表:在给对象分配内存的时候,如果Java堆中的内存是不规整的,那么虚拟机就需要维护一个列表来存储哪些内存是可用的,在分配内存的时候在列表中找出一快足够大的内存空间分配给对象实例,并更新维护列表的记录,这种方式称之为空闲列表。

Java虚拟机在分配内存的时候线程是不安全的,可能会出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这种情况虚拟机也有两种方案:一种是对分配空间的动作进行同步锁处理;另一种是吧内存分配的动态按照线程划分在不同的空间中,就是每个现在都在Java堆中预先分配一小块内存,称为本地线程分配缓冲区(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存就在哪个线程的TLAB上分配,直到TLAB的内存用完以后分配新的TLAB时才需要进行同步锁定。

内存分配完成后,虚拟机需要将分配的内存空间都初始化为零值,这个操作保证了对象的字段都会有一个默认值。接下来虚拟机要对对象进行一些必要的设置,例如这个对象是哪个类的实例、对象的哈希码、锁状态标志、对象的GC分代年龄等这些都存放在对象的头(Object Header)之中。
在上面的工作完成以后,虚拟机还会再将对象按照程序员设置的初始内容来初始化对象。

2.1 对象的访问

建立对象是为了使用对象,Java程序通过在栈上的引用数据来操作堆上的具体对象,Java规范并没有规定如何通过引用去定和访问堆中对象的具体位置,所以对象的访问方式也是取决于虚拟机的实现,目前主流的访问方式有使用句柄和直接指针两种。

如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,引用中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址;

上图对象的类型数据为什么在方法区,上面我们介绍运行时常量池的时候说过了,这里再说一次常量池存放的是在编译期间产生的字面量和符号引用,字面量相当于Java层面的常量,符号引用相当于编译原理,主要包括三种类型:类和接口的全限定名,字段名称和描述符,方法名称和描述符。

而如果使用直接指针访问,那么Java堆对象就必须考虑如何放置访问类型数据的相关信息,因为引种中存储的直接就是对象地址。

这两种访问方式各有优势,使用句柄来访问的最大好处是在对象被移动的时候(垃圾收集时)只会改变句柄中的实例数据指针,而引用本身不需要修改,使用直接指针访问的最大好处就是速度快,因为他减少了一次指针定位的时间,HotSpot虚拟机采用的是直接指针访问。

以上是关于Java虚拟机一:Java运行时内存区域及对象的创建的主要内容,如果未能解决你的问题,请参考以下文章

Java虚拟机二:垃圾回收机制

Java 虚拟机

java虚拟机:Java内存区域及对象

Java虚拟机一 内存管理机制

JVM学习-java内存区域与异常

深入理解jvm虚拟机一