JVM(一):自动内存管理与对象创建

概述

JVM其实指的就是Java虚拟机,要知道Java之所以获得如此广泛的认可,除了它是一门结构严谨、面向对象的编程语言之外,它还摆脱了硬件平台的束缚,实现了"一次编写,到处运行"的概念;还提供了一种相对安全的内存管理和访问机制,避免了绝大部分内存泄漏和指针越界问题;实现了热点代码检测和运行时编译及优化。。。

这一系列优化,绝大多数都是因为Java自己实现了自己的虚拟机

JDK、JRE、JVM的关系

  • JDK:用于支持Java程序开发的最小环境
  • JRE:Java虚拟机和Java SE的API构成
  • JDK是包含JRE的,JDK是在JRE的基础上,还拓展了一些工具以及工具API

自动内存管理

Java管理内存完全是交由Java虚拟机的自动内存管理机制的,不需要像C语言一样,进行手动的内存管理,即每去new一个对象,做一个new操作都要去写配对的free/delete代码,交由Java虚拟机管理内存不容易出现内存泄漏和内存溢出问题,虽然看起来交由虚拟机管理一切都很好,一切权力都交由了虚拟机去管理,一旦出现问题,如果不了解虚拟机如何使用内存的,就很难进行排查和修正。

运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些数据区域有着自己的生命周期,即何时创建以及销毁的时间都是不同的

总共分为五个区域

  • Method Area:方法区
  • VM Stack:虚拟机栈
  • Native Method Stack:本地方法栈
  • Heap:堆
  • Program Counter Register:程序计数器

在这里插入图片描述
对于这5个区域,其中有两个是线程共有的,也就是所有线程都共享的

  • Method Area:方法区
  • Heap:堆

另外的三个是线程私有的,也就是线程隔离的,每个线程都有自己的这三个区域

  • VM Stack:虚拟机栈
  • Native Method Stack:本地方法栈
  • Program Counter Register:程序计数器

下面就简单看一下这5个区是干什么的

程序计数器

首先要认识,线程执行Java代码其实是执行字节码,程序计数器就是记录当前线程执行到第几行字节码指令的,相当于就是个字节码指令的行号指示器

程序计数器占用比较小的内存,但其实它相当于是程序控制流的指示器,指示字节码解释器要执行的下一条字节码指令

字节码解释器工作时,就是通过改变这个计数器的值去选取下一条需要执行的字节码指令,各种流程控制的基础功能,比如循环、判断、跳转都要依赖这个计数器去完成的

程序计数器是线程私有的,这是因为每个线程有自己要执行的字节码指令,所以每个线程都要有自己独立区分的程序计数器来记录,否则在多线程的时候,当上下文进行切换的时候,线程无法回归到上一次执行的位置,正确执行下去

当线程正在执行的是一个Java方法,该线程的程序计数器记录的是正在执行的虚拟机字节码指令的地址;但如果正在执行的是本地方法,也就是native修饰的方法,这个计数器值则为空,并且程序计数器是唯一一个没有规定任何OutOfMemoryError情况的区域

程序计数器的生命周期是依赖用户线程的启动和结束而建立和销毁的,也就是线程启动,程序计数器创建;线程结束,程序计数器销毁

Java虚拟机栈

Java虚拟机栈其实就是Java方法执行的线程内存模型,每个方法被执行的时候,Java虚拟机先会为这个方法同步去创建一个栈帧,该栈帧可以用来存储局部变量表、操作数帧、动态连接、方法出口等信息,该方法被调用直至完毕的过程,其实就对应着该栈帧从入栈到出栈的过程

每个线程都有自己需要执行的方法,所以虚拟机栈和程序计数器一样,都是线程私有,并且生命周期都是依赖用户线程的启动和结束而建立和销毁的

局部变量表

Java虚拟机栈里面比较重要的一个区域为局部变量表,每个进来的栈帧都有自己的局部变量表

局部变量表存放了以下数据类型

  • 基本数据类型
  • 对象引用(对象的引用指针)
  • returnAddress类型(一个指向了一条字节码指令的指针)

局部变量表用局部变量槽来作为存储空间,比如,8个字节大小的long和double类型的数据会占用两个变量槽,而其他的数据类型只会占用一个

局部变量表所需的内存空间由多少个局部变量槽来决定,并且局部变量表所需要的内存空间是在编译期间就完成分配的,当进入一个方法的时候,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的(执行方法创建栈帧),在方法运行期键时不会改变局部变量表的大小,即不会改变局部变量槽的数量(调用方法过程中,栈帧入栈和出栈过程中,局部变量表的局部变量槽数量不会发生变换)

当线程请求的栈深度大于虚拟机所允许的深度,也就是太多的栈帧存放在虚拟机栈里面时(一般由于递归引起),将会抛出StackOverflowError异常,如果Java虚拟机栈容量可以进行动态扩展,当栈扩展时无法申请到足够的内存,则会抛出OutOfMemoryError异常,也就是OOM异常

本地方法栈

本地方法栈的功能其实与虚拟机栈十分相似,唯一的区别就是虚拟机栈管理的是Java方法,而本地方法栈管理的是Native方法,因为Native方法每个线程都会有,是公用的,所以本地方法栈也是线程共享的,其生命周期是随着虚拟机进程的启动而一直存在着

本地方法栈同样也会抛出StackOverflowErrorOutOfMemoryError

Java堆

Java堆是五个区域中,占用内存最大的一部分

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,即进程启动时创建而会一直存在着,该区域的唯一目的就是存放所有实例化的Java对象,有些人会说Java堆为GC堆,因为GC回收主要就是在这里进行,Java堆里面还分为新生代、老年代、永久代。。。这些以后再研究

Java堆可以被实现为固定大小的,也可以是扩展的,通过参数-Xmx和-Xms就可以设定Java堆空间大小

Java堆也会抛出OOM异常,当Java堆中没有内存继续给实例分配,并且堆也无法继续再扩展时,就会抛出OOM异常

可见OOM异常,一般是由于Java堆或栈无法扩展内存时就会抛出

方法区

方法区与Java堆一样,都是各个线程共享的区域,其用来存储已被Java虚拟机加载的类型信息、常量和静态变量、即时编译器编译后的代码缓存等数据

同理,方法区无法满足新的内存分配需求时,也会抛出OOM异常

运行时常量池

方法区对于各种常量的存放是存放在运行时常量池里面的,也就是说,运行时常量池是方法区的一部分

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

运行时常量池是方法区的一部分,所以也会受到方法区的限制,当常量池无法再申请到内存时,就会抛出OOM异常

直接内存

直接内存并不是Java虚拟机运行时数据区域的一部分,但Java中会使用到这个内存,直接内存其实就是堆外内存,是本机的直接内存

在JDK1.4时,添加了一个NIO类(New Input/Output),引入了一种基于通道与缓冲区的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象来操控这部分内存,这样是可以提升性能的,因为避免了在Java堆和Native堆中来回复制数据,也就是不用维持一致性

这方面的内存不会受到Java堆的影响,但会受到本机总内存的影响,一样也会由于动态扩展申请内存失败而抛出OOM异常

对象创建

我们在Java里面创建一个对象的时候,往往使用一个new关键字就完事了,当虚拟机遇到了一条new指令,其背后的过程大概如下

  • 检查new指令的参数是否能在常量池中定位到一个类的符号引用(常量池存储着类的信息),并且去检查这个符号引用代表的类是否已经被加载、解析和初始化过

    • 如果没有被加载过,或没有符号引用,那就会先去执行类的加载过程
  • 类加载完毕后,接下来虚拟机就会为新生对象去分配内存(对象所需的内存大小在类加载完成后便可以完全确定了)

    • 这里的操作其实就是在Java堆里面去划分内存去存储这个对象,但不同情况的Java堆划分内存的方式也不一样,主要有以下两种

      • 指针碰撞:针对Java堆的内存是绝对规整的情况,即在Java堆中,所有被使用过的内存会被放在一边,空闲的内存则为另一边,那么中间只需使用一个指针来作为分界点的指示器,要分配多少内存就让该指针往空闲的内存方向移动即可,这种方式称为指针碰撞
      • 空闲列表:针对Java堆的内存并不是规整的情况,即在Java堆中,已使用的内存和空闲的内存是交替存放的,相互交错在一起,此时就没有办法进行简单的指针碰撞来分配内存了,此时就要虚拟机去主动维护一个列表,该列表记录了哪些内存可用的,在分配内存的时候,从该列表中找到一块足够大的空间划分给对象实例,然后再更新列表,这种分配方式就称为空闲列表
    • 虚拟机使用哪种方式来分配内存,取决于Java堆的内存是不是规整的,而Java堆的内存是不是规整的,又取决于采用的垃圾收集器

      • 如果采用的垃圾收集器有空间压缩整理能力(Compact),比如采用,那么就会采用指针碰撞,比如Serial、ParNew
      • 如果采用的垃圾收集器没有空间压缩整理能力(Compact),就会采用空闲列表,比如CMS这种基于清除算法的收集器
  • 内存分配完了之后,虚拟机还必须将分配到的内存空间都初始化为零值,除了对象头之外,这步操作保证了对象的属性可以在不赋初始值的情况下直接使用,也就是有默认值,让程序能访问到这些字段的数据类型的默认值(零值)

  • 对创建的对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到这个类的元数据信息、对象的哈希码等,这些信息会存放在对象的对象头(Object Header)之中。

  • 此时从虚拟机来看,一个对象就已经产生了,但此时还没有给该对象的属性进行赋值,而且对象需要的一些其他资源和状态信息也还没有按照预定的意图构造好,此时的对象属性值全部都为默认值,需要进行构造方法的调用,对应的就是调用Class文件中的init方法,所以接下来就是要调执行class文件的init方法

  • 至此一个对象才会被创建出来

对象创建的并发问题

当多个线程去创建对象,并且都去操控Java堆时,就会遇到并发问题了,可能一个线程正在打算使用这块内存,另外一个线程也打算使用这块内存,那就容易产生并发从而导致被覆盖掉

解决这个问题有两种方案

  • 对分配内存空间的动作进行同步处理,采用自旋+CAS的方式来保证更新操作的原子性
  • 把内存分配的动作按照线程划分在不同空间之中进行,即给每个Java线程都预先在Java堆中分配内存,该内存称为本地现场分配缓冲(Thread Local Allocation Buffer,TLAB),线程就在自己的TLAB上进行分配,线程不能干涉其他线程的TLAB,只有当TLAB用完了之后,需要分配新的缓存区时,才进行同步锁定

JVM默认采用的方案是自旋+CAS,如果要使用TLAB,则需要通过-XX:+/-UserTLAB参数来设定

对象的内存布局

下面来看一下,在Java堆里面,一个对象的内存究竟是怎样的

对象在Java堆内存中的存储布局可以划分为三个部分

  • 对象头(Header)
    • Mark Word
    • 类型指针
  • 实例数据(Instance Data)
  • 对其填充(Padding):这个对其填充其实是针对实例数据的,当实例数据不是8字节的整数倍时会进行填充
对象头

对象头存放两部分信息

  • Mark Word:用于存储对象自身的运行时数据,比如哈希码、GC分代年龄;还有关于锁的信息,比如锁状态标志、线程持有的锁、锁偏向线程ID、偏向时间戳等,对于这部分的存储,这部分数据的长度在32位和64位的虚拟机中分别为32比特和64比特,Java虚拟机提供了压缩指针功能,因为这部分的数据比较多,特别是运行时数据,运行时数据其实已经超过了32、64位的BitMap结构所能记录的最大限度,但这些对象头的信息是与对象自身定义的数据无关的额外存储成本,所以Mark Word被设计成一个有着动态定义的数据结构,支持压缩功能,通过根据对象的状态复用自己的存储空间,以便在极小的空间内可以存储尽量多的数据

  • 类型指针:即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例

举个例子,当未被同步锁锁定状态下的对象的对象头信息如下

实例数据

实例数据才是对象真正存储的有效信息

实例数据不仅仅包含自己的数据,还要包含父类的数据,这里不仅简单的保存实例数据,其还要根据对应的类型进行顺序分配,这里面就涉及分配策略问题,而这个分配策略就是虚拟机的一个分配策略参数(-XX:FieldsAllocationStyle参数),实例数据的顺序分配不仅受分配策略的影响,同时还会受字段在Java源码中定义顺序的影响(字段是在父类,还是在子类)

HotSpot虚拟机默认的分配顺序为高字节到低字节,即long/double、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,成员属性的指针,也就是一般对象),从该分配策略可以看到,对于相同宽度的字段其总是会被放在一起存放,并且高宽度的会放在前面(受分配策略影响),在满足这个条件的情况下,父类中定义的变量会出现在子类之前(受源码影响),但对于该对象而言,无论变量来自父类的还是来自子类的,对象都会使用内存去存放该变量,那么为了节省空间,HotSpot提供了+XX:CompactFields参数来允许子类之中较窄的变量可以插入到父类变量的空隙之中,从而节省出空间出来

对齐填充

HotSpot虚拟机规定了,对于自动内存管理系统要求对象的地址一定要为8字节的整数倍,因为对象的结束地址会涉及到另一个对象的开始地址,所以每个对象的内存占用大小必须为8字节的整数倍,也就是64位的整数倍,如果整个对象的内存不足8字节的整数倍,那么就要进行对齐填充

对于对象头部分,已经被设计成是8字节的倍数了,那么对于实例数据来说,也要为8字节的倍数才能符合要求,所以对其填充其实说白了就是对实例数据进行填充,让实例数据满足8字节倍数

对象的访问定位

对象创建好了之后,接下来就是使用了,那么对于这个对象的使用,Java程序是通过虚拟机栈上的reference数据来操作堆上的具体对象

reference对象访问堆里面的对象有两种方式

  • 句柄访问:句柄访问又为间接访问,使用句柄访问的话,Java堆需要划分出一块内存来作为句柄池,句柄池里面存放对象在Java堆的具体地址,句柄里面存放的是句柄池的地址,然后通过句柄池再找到对象地址
  • 指针访问:存放的就是对象地址,通过对象地址直接在Java堆中找到对象

句柄访问过程如下所示

在这里插入图片描述

直接访问如下所示

在这里插入图片描述

两种访问方式的优缺点

对于句柄访问来说

  • 优点在于,当对象在Java堆的地址发生改变之后,只需要改变句柄池即可,而不需要改变栈中的reference,在垃圾收集时经常需要移动对象
  • 缺点在于,访问速率会比较慢,因为需要两次指针定位的时间开销

对于直接访问来说

  • 优点在于:速度更快,相比于句柄访问节省了一次指针定位的时间开销,Java中访问对象是很频繁的,所以这一次节省的时间开销,累积起来是很客观的
  • 缺点在于:当对象的地址改变后,会影响reference

对于HotSpot而言,主要以直接访问来进行对象访问

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值