从JVM的内存分区到对象如何使用它们

全文概括

​ 虚拟机可以看作一台抽象的计算机,有自己的指令集和运行时内存分区。堆和方法区是线程共享的,随JVM创建消亡。栈和PC计数器是线程私有的。

​ 堆是最大的一块,为对象分配内存就是从堆中分配。分配时需要考虑并发操作。

​ 方法区存放各个类的信息,包括运行时常量池、字节码、和初始化方法。

​ 一个线程一个栈,一个方法一个栈帧。栈帧中有局部变量表、操作数栈和常量池指针。

​ 将类信息加载到方法区中,对象实例在堆中分配内存,使栈的本地变量中对象的引用指向堆中的实例,实例再指向方法区的类型。然后执行初始化这块实例内存。

正文​

全部手敲,如果需要转载请注明出处。

主要知识来自《Java虚拟机规范》(Java SE8) 周志明翻译的那本。还有《深入理解Java虚拟机:JVM高级特性与最佳实践》

此外还参考了下面这些链接文章:https://www.jianshu.com/p/eaef248b5a2c;https://blog.csdn.net/uotail/article/details/83373794

​ Java虚拟机可以看作一台抽象的计算机,有自己的指令集运行时内存区域

​ 这些分区是逻辑上的,是抽象的。和计算机的实际内存是不同的。比如Java栈实际上是被计算机分配在计算机的堆上的。

​ 这些分区有些是所有线程共享的,随着虚拟机的创建和销毁。有些是线程私有的,每个线程都有一份,随着线程创建和销毁

一、 线程共享的区域

1.1 堆 Java Heap

​ 被所有线程共享的一块内存区域,主要用于存放对象实例

​ 是Java虚拟机所管理的内存中最大的一块儿,物理上可以不连续。所有的对象实例和数据都要在堆上进行分配。这些对象被GC管理着。

1.1.1 为对象分配内存的方法*

​ 为对象分配内存就是把一块大小确定的内存从堆内存中划分出来。

​ Java虚拟机采用哪种方式为新生对象分配内存,取决于所使用的垃圾收集器

​ 为了分带垃圾收集,堆可以细分为新生代(细分为Eden:from:to)和老年代(Old Generation)。

​ 当垃圾收集器具有整理过程时,虚拟机将采用指针碰撞的方式;当垃圾收集器的回收过程没有整理过程时,则采用空闲列表方式。

指针碰撞法

​ 已分配的内存和空闲内存分别在不同的一侧,通过一个指针作为分界点,需要分配内存时,仅仅需要把指针往空闲的一端移动与对象大小相等的距离。

空闲列表法

​ 已分配的内存和空闲内存相互交错,JVM通过维护一个列表,记录可用的内存块信息

1.1.2 多线程如何并发分配内存?

对象创建是一个非常频繁的行为,进行堆内存分配时还需要考虑多线程并发问题,可能出现正在给对象A分配内存,指针或记录还未更新,对象B又同时分配到原来的内存,解决这个问题有两种方案:

CAS(Compare And Swap)*

​ 比较并替换,实现并发算法时常用到的一种技术。在Java中,主要在Atomic包,调用Unsafe类相关方法体现。

​ 涉及三个值:内存中真正存的值(可能被其他线程改变)、逻辑上的原值、(当前线程)要写入的新值。通过循环检查内存中的值是不是原来的值,以此来判断是不是正有其他线程在改变它。判断成功立即写入新值,这一步是原子的。

​ 存在ABA问题,即内存中虽然还是逻辑上原来的值,但是已经被变成B过了。这个问题JDK1.5已经通过AtomicStampedReference类解决,将对象追加一个版本号stamp,这样每次改变过都是一个“全新的值”

​ 具体可以看我的这篇文章https://www.jianshu.com/p/c8e9bce8b3c6

本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)

​ 把内存分配的行为按照线程进行划分,在不同的空间中进行,每个线程在Java堆中预先分配一个内存块。哪个线程要分配内存,就在它的TLAB上分配,如果用完了,再追加时再去考虑同步。JVM参数中-XX:+/-UseTLAB可以设定是否使用此策略。

1.1.3 堆溢出

​ 堆是用来存储对象实例的。如果对象达到一定的数量,并且这些对象和根对象之间有可达路径,那么就不能被垃圾收集器回收,超过堆的最大容量,就会出现溢出,抛OutOfMemoryError

​ 具体原因可能有两种。首先可能是出现了死循环。其次可能是启动JVM时分配的堆太小,如-Xms100m -Xmx200m设置堆最小100最大200m。

1.2 方法区

​ 方法区用于每个类的结构信息和一些特殊方法。类的结构信息不仅有字段和方法的数据、构造函数和普通方法的字节码,还有运行时常量池。而特殊方法主要是指实例初始化方法、类或接口初始化方法。

1.2.1 类的结构信息

运行时常量池 Runtime constant pool

​ 它包括了若干种不同的常量,从编译期可知的数值字面量到必须在运行期解析后才能获得的方法或者字段引用

​ 一些虚拟机,只想对象实例的引用是一个指向句柄的指针。这个句柄由包含了两个指针:一个指向该对象各个方法及对应的Class对象的表格。另一个指向在堆中分配的数据。

​ 但HotSpot VM中,指向对象的引用并不通过句柄,而是直接指向堆中对象的实例数据。这个详见第三部分。

1.2.2 初始化方法*

实例初始化方法

​ 只能在实例初始化期间通过JVM的invokespecial指令调用。且只能在尚为初始化的实例上调用一次。

类或接口初始化方法

​ 由JVM自身隐式调用,没有任何JVM字节码指令可以调用这个方法。只会在类的初始化阶段中由虚拟机自身调用

1.2.3 方法区溢出

如果运行时方法区产生了大量的类,超出了方法区的最大容量,将抛出OutOfMemoryError异常。

二、线程私有的分区

2.1 Java栈

​ Java栈是线程私有的,是Java方法的执行模型。每个线程对应一个栈,每个线程在执行一个方法时会创建一个对应的栈帧(Stack Frame),栈帧负责存储局部变量变量表、操作数栈、动态链接和方法返回地址等信息。每个方法的调用过程,相当于栈帧在Java栈的入栈和出栈过程

2.1.1 栈帧

​ 随方法调用创建和销毁,同时可能存在多个,但只有栈顶的当前栈帧是活动的。

从JVM的内存分区到对象如何使用它们_第1张图片
12421988-22c1ea0b98238988.png

如上图,一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来讲,活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。

局部变量表 Local Variable Table

​ 局部变量存放了编译器可知的各种类型,即JVM可操作的类型。

​ 通过方法的code属性保存及提供给栈帧使用。局部变量表所需的内存空间在编译期间完成分配。

​ 以变量槽(Slot)为最小存储单位,可以放32bit。对于long和double,以高位对齐方式分配两个连续Slot。

JVM可操作的数据类型

原始类型 primitive

  • 数值类型 numeric type

    整型(byte/short/int/long/char)和浮点型(float/double)

  • boolean类型

    false是0,true是1。没有专门的字节码指令,只能转换成byte或int再操作

  • returnAddress类型

    指向一条JVM操作码opcode,原始类型中唯一一个不能直接和Java的数据类型相对应的。无法在程序运行期间修改。

引用类型 reference

  • 类class 类型
  • 数组array 类型
  • 接口interface类型
  • null
字节码指令

​ 字节开头字母大多指明了为哪种数据类型服务:
a-reference,d-double,f-float,c-char,b-byte,s-short,l-lomg,i-int

​ 使用索引定位访问,第0个位置一定是用来存储this,即当前对象的引用。随便把一个a.java文件javac编译一下,再用javap -verbose a就可以看到任意一个非static的方法,前两句指令都是aload_0(将this拿到)和dup(装入操作数栈)。就是方便线程访问当前对象的类信息和类域

操作数栈 operand stack

​ 用来暂存指令取出来的操作数,也用来准备调用方法的参数及接收方法返回的结果。

常量池指针 constant_pool point

​ 动态链接,在类加载的过程中,将代码中的符号引用转换成了直接引用。

2.1.2 栈溢出

​ 如果线程请求的栈深度,大于虚拟机所允许的深度,将抛出StackoverflowError异常。

​ 但栈的深度也不是越深越好,不然一个线程就要耗费很多内存,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。​

2.2 本地方法栈

​ 执行native方法,也会抛出StackoverflowError异常和OutOfMemoryError异常,有的虚拟机(如HotSpot VM)将本地方法栈与上面的虚拟机栈合二为一。

2.3 PC指令计数器 program counter

​ 记录着JVM正在执行的opcode的地址。分支、循环、跳转、异常处理和线程恢复等操作都依赖这个计数器完成。如果当前方法是native,pc寄存器是undefined。

三、对象怎么使用JVM内存分区

3.1 在内存中的布局

​ 以HotSpot为例对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance)、对象填充(Padding)。

从JVM的内存分区到对象如何使用它们_第2张图片
285763-20170807173010534-177864240.png

3.1.1 对象头

markword

​ 用来存储对象自身的运行时数据,例如hashcode、GC年代、锁状态、线程持有的锁、偏向线程的ID、偏向时间戳等。

Class对象指针

​ 也叫类型指针,指向这个对象的类的元数据,JVM乐意根据它知道这个对象是哪个类的实例。

元数据(meta data)

​ 结构化的数据。将任何信息拆解成元数据项目(名字/键)和元数据内容(值),并且可以有层次。比如XML中,每个标签都是一个元数据。每个标签可以有多个子标签,每个标签都有自己的属性,他们表明了这个元数据要表达的意图。

3.1.2 实例数据

​ 即程序中的字段,包括父类继承来的。这部分的存储顺序和虚拟机还有源码中的位置都有关系,HotSpot默认是相同宽度的字段在一起。long/double> int > short/char > byte/boolean > oop(Ordinary Object Pointers)。但子类中较窄的可能会插入父类的空隙里。

3.1.3 对象填充

​ HotSpot VM 要求对象的起始地址是8字节的整数倍。

3.2 对象怎么创建

​ 在JVM遇到new时(这里是普通的对象非数组或Class),检查这个类符号引用在不在方法区的常量池中,进而检查是否被加载过。如果没有,首先要执行类加载。

​ 如果已加载,那么要给新对象分配内存,即在Java堆中划出一块内存来(大小在类加载完成确定)。

​ 将分配到的内存(除对象头外)都初始化为0。如果使用TLAB,则这一步需提到TLAB前。

​ 根据对象头,对这个对象进行必要设置。

​ 至此,对于JVM,新对象已经产生。但对于Java程序来说,对象创建是这之后执行的过程。

​ 字节码中如果跟随了invokespecial指令,那么执行new指令后接着执行方法区的方法。

3.3 对象的引用

​ Java程序通过栈上的引用类型reference来操作堆中的具体对象。目前主流的访问方式有两种,句柄和直接指针。HotSpot VM 使用的是直接指针。

句柄访问模式

从JVM的内存分区到对象如何使用它们_第3张图片
20170213153426104.png

​ 好处是,reference值不变,在对象被移动(GC时需要常常移动),只改变了句柄中的实例数据指针。

直接指针访问

从JVM的内存分区到对象如何使用它们_第4张图片
20170213153623949.png

​ 好处是节省了一次指针定位的时间开销。对象的访问是一件非常频繁的事。

谢谢观看,如果有用麻烦点个喜欢,对我是莫大的鼓励。

你可能感兴趣的:(从JVM的内存分区到对象如何使用它们)