JVM知识点整理

JVM

Java虚拟机在执行java程序的过程中,会把它的内存划分为若干个不同的运行时数据区域,如图所示:

image-20191023234041536


程序计数器PC

程序计数器是一块较小的内存空间,字节码解释器工作时,就是通过改变这个计数器的值来选取下一条要执行的字节码指令。

  • 如果线程执行的是java方法,计数器存储的正在执行的虚拟机字节码地址
  • 如果线程执行的是native方法,那么这个计数器值为空(Undefined)
  • 为了线程切换后,会回到正确的执行位置,每个线程都需要有一个独立的程序计数器(线程私有的)
  • 唯一不会报出Out of Memory的区域


虚拟机栈

概念

Java虚拟机栈也是线程私有的。它的生命周期与线程相同。

虚拟机栈描述的是java方法执行的内存模型:每个方法在执行的同时,会创建一个栈帧,该栈帧用于存储局部变量表、操作数栈、动态链接方法出口等信息。每一个方法从调用到执行完成的过程,就对应着一个栈帧入栈到出栈的过程。

经常有人说“栈内存”,就是指虚拟机栈。

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

如果虚拟机栈的大小可以动态扩展,但是虚拟机无法申请到足够的内存,就会抛出OutOfMemory异常。


栈帧

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。它是虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。


局部变量表

存放编译器可知的基本数据类型、对象引用、返回地址类型。

其中,64位长度的long和double会占用2个局部变量空间(slot),其余的数据类型只占用1个。

局部变量表所占用的内存空间在编译器完成分配。当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的(因为过了编译期。。),在方法运行期间不会改变局部变量表的大小。



返回值

如果有返回值的话,压入调用者栈帧中的操作数栈中,并且把PC的值指向 方法调用指令 后面的一条指令地址。



操作数栈
  • 操作数栈的最大深度在编译的时候已经确定(写入方法区code属性的max_stacks项中)

  • 操作数栈的的元素可以是任意Java类型,包括long和double,32位数据占用栈空间为1,64位数据占用2。

  • 方法刚开始执行的时候,栈是空的,当方法执行过程中,各种字节码指令往栈中存取数据。



动态链接
  • 每个栈帧都持有在运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。

  • Class文件的常量池中有大量的符号引用,字节码调用方法的指令将这些符号引用作为参数。这些符号引用一部分在类加载阶段或者第一次使用阶段转化为直接引用,这种转化称为静态解析。另一部分将会在每一次运行期间转换为直接引用,这种转化称为动态解析。

  • 线程私有的。


本地方法栈

  • 本地方法栈和虚拟机栈的功能相似,虚拟机栈为虚拟机执行Java方法服务,本地方法栈则是为虚拟机使用到的本地方法服务。
  • 线程私有



Java堆

  • Java堆是虚拟机管理的内存中最大的一块
  • Java堆是所有线程共享的区域
  • 在虚拟机启动时创建
  • 此内存区域的唯一目的就是存放对象实例,几乎所有对象实例都在这里分配内存。存放new生成的对象和数组
  • Java堆是垃圾收集器管理的内存区域,因此很多时候称为“GC堆”
    • 从内存回收角度讲,现在的垃圾收集器打多使用分代收集,把内存划分为新生代,老年代
    • 从内存分配角度讲,线程共享的java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer TLAB)
  • 堆在物理上可以是不连续的内存空间,只不过逻辑上是连续的即可



方法区

概念:与Java堆一样,各个线程共享的区域。用于存储Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。有一个别名“Non-Heap” (非堆)

版本差异

在JDK1.6及之前,运行时常量池是方法区的一个部分,同时方法区里面存储了类的元数据信息、静态变量、即时编译器编译后的代码(比如spring 使用IOC或者AOP创建bean时,或者使用cglib,反射的形式动态生成class信息)等。

在JDK1.7及以后,JVM已经将运行时常量池从方法区中移了出来,在JVM堆开辟了一块区域存放常量池。

方法区和永久代的关系

在HotSpot中,设计者将方法区纳入GC分代收集,像对待堆一样来管理这部分内存,能够省去编写管理这块内存的工作,所以HotSpot虚拟机使用者更愿意将方法区称为老年代。

但是把方法区纳入永久代,更容易造成永久代的内存溢出。

方法区和永久代的关系很像Java中接口和类的关系,类实现了接口,而永久代就是HotSpot虚拟机对虚拟机规范中方法区的一种实现方式。



运行时常量池

  • Class文件中除了存有类的版本、字段、方法、接口等描述信息,还有一项是常量池,存有这个类的 编译期生成的各种字面量和符号引用,这部分内容将在类加载后,存放到方法区的运行时常量池中。
  • 是方法区的一部分
  • 用于存放编译期生成的各种字面量和符号引用
  • 运行时常量池对于class文件中常量池的另外一个重要特性是动态性。并非class文件常量池中的内容才能进入运行时常量池。在运行期间,在运行期间,也可能将新的常量放入池中,比如String类的intern()方法。
  • 这么看,运行时常量池含有所有类的常量



直接内存

直接内存(堆外内存)并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中农定义的内存区域。

在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通脱一个存储在Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据

本机直接内存的分配不会受到Java 堆大小的限制,受到本机总内存大小限制,JVM中有参数可以限制直接内存的大小(-XX MaxDirectMemorySize)。

配置虚拟机参数时,不要忽略直接内存,防止出现OutOfMemoryError异常。

Ecache就是使用堆外内存实现的。



堆外内存的回收

JDK中使用DirectByteBuffer对象来表示堆外内存,每个DirectByteBuffer对象在初始化时,都会创建一个对用的Cleaner对象,这个Cleaner对象会在合适的时候执行unsafe.freeMemory(address),从而回收这块堆外内存。

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

image-20191103183537170

ReferenceQueue是用来保存需要回收的Cleaner对象。

如果该DirectByteBuffer对象在一次GC中被回收了

image-20191103183617388

在GC时,把该Cleaner对象放入到ReferenceQueue中,并触发clean方法。

Cleaner对象的clean方法主要有两个作用:
1、把自身从Clener链表删除,从而在下次GC时能够被回收
2、释放堆外内存

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

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

直接内存满了之后,不会主动通知垃圾收集器进行回收,而是等到老年代满了之后,触发Full GC,然后顺便回收直接内存中的废弃对象。



元空间

HotSpot虚拟机在1.8之后已经取消了永久代,改为元空间,类的元信息被存储在元空间中。元空间没有使用堆内存,而是与堆不相连的本地内存区域。所以,理论上系统可以使用的内存有多大,元空间就有多大,所以不会出现永久代存在时的内存溢出问题。

这项改造也是有必要的:

  • 永久代的调优是很困难的,虽然可以设置永久代的大小,但是很难确定一个合适的大小,因为其中的影响因素很多,比如类数量的多少、常量数量的多少等。
  • **永久代中的元数据的位置也会随着一次full GC发生移动,比较消耗虚拟机性能。**同时,HotSpot虚拟机的每种类型的垃圾回收器都需要特殊处理永久代中的元数据。
  • 将元数据从永久代剥离出来,不仅实现了对元空间的无缝管理,还可以简化Full GC以及对以后的并发隔离类元数据等方面进行优化。



JVM的启动过程

JVM的启动流程大致分为几个步骤:

配置JVM的装载环境

Java代码执行时,需要一个JVM环境,JVM环境的创建包括两部分,JVM.dll文件的查找和装载。

  • JVM.dll文件的查找
  • JVM.dll文件的装载:



解析虚拟机参数

装载完JVM以后,需要对启动参数进行解析,其实在装载JVM环境的过程中,已经解析了部分参数。

  • 设置线程栈大小:

  • 执行Java main方法:

Java Main函数的执行流程大致如下:

  • 新建JVM实例:

  • 加载主类的class

  • 查找main方法

  • 执行main方法


JAVA OOMOut Of Memory Error,内存溢出):

1、概念:堆内存没有足够空间分配给对象,并且垃圾收集器也没有空间回收时,就会抛出这个错误。

2、造成OOM的原因:

​ (1)在初始化JVM的阶段,设置给JVM可用的内存太少了

(2)用完的对象没有释放,导致内存泄漏。

3、OOM有哪几种类型:

​ (1)堆空间不够大造成溢出

​ (2)虚拟机栈的深度不能够扩展

(3)方法区溢出



九、JVM退出的几种情况:

1、执行了System.exit(int status)方法

2、程序执行结束

3、程序在执行过程中遇到了异常或者错误而终止运行(main方法里面throws抛出的异常,将会被JVM捕获,然后JVM就会异常退出了)

4、操作系统出现错误导致Java 虚拟机进程终止

十、直接内存:

1、直接内存不是Java虚拟机运行时数据区的一部分,而是计算机本身的内存。利用NIO可以使用Native函数库直接分配堆外内存,然后通Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能提升性能,因为避免了在Java堆和Native堆中来回复制数据的开销。

2、本机直接内存的分配不会受到Java堆大小的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现Out of Memory异常。



HotSpot虚拟机



Java对象创建过程

  • 虚拟机遇到一条new指令时,首先检查这个指令的参数能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经加载、连接和初始化。如果没有,就执行该类的加载过程
  • 为该对象分配内存
    • 设Java堆是规整的,所有用过的内存放在一边,空闲的内存放在另外一边,中间放着一个指针作为分界点的指示器。那分配内存只是把指针向空闲空间那边挪动与对象大小相等的距离,这种分配称为“指针碰撞”
    • 假设Java堆不是规整的,用过的内存和空闲的内存相互交错,那就没办法进行“指针碰撞”。虚拟机通过维护一个列表,记录哪些内存块是可用的,在分配的时候找出一块足够大的空间分配给对象实例,并更新表上的记录。这种分配方式称为“空闲列表“。
    • 使用哪种分配方式由Java堆是否规整决定。Java堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定。像使用Serial、ParNew收集器自带压缩整理功能的收集器时,就会使用指针碰撞的内存分配方式;而使用CMS收集器时,由于采用的是标记-清除算法,因此采用空闲列表。
    • 分配对象保证线程安全的做法:虚拟机使用CAS配上失败重试的方式保证更新操作的原子性。(实际上还有另外一种方案:每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲,TLAB。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才进行同步锁定。虚拟机是否使用TLAB,由-XX:+/-UseTLAB参数决定)
  • 虚拟机为分配的内存空间初始化为零值(默认值)
  • 虚拟机对对象头进行必要的设置,例如这个对象是哪个类的实例、如何才能找到对象的元数据信息、对象的Hash码、对象的GC分代年龄等信息。这些信息存放在对象的对象头中。
  • 执行方法,把对象按照程序员的意愿进行初始化。
    • java在编译后,在字节码文件中生成方法,称之为实例构造器。方法会依次执行:
      • 父类变量初始化
      • 父类语句块
      • 父类构造函数
      • 子类变量初始化
      • 子类语句块
      • 子类构造函数
    • java在编译之后会在字节码文件中生成方法,称之为类构造器。会依次执行:
      • 父类静态变量初始化
      • 父类静态语句块
      • 子类静态变量初始化
      • 子类静态语句块
    • 是在类加载器过程中执行的,是对象实例化过程中执行的。所以一定比先执行,整个执行顺序为:
      • 父类静态变量初始化
      • 父类静态语句块
      • 子类静态变量初始化
      • 子类静态语句块
      • 父类变量初始化
      • 父类语句块
      • 父类构造函数
      • 子类变量初始化
      • 子类语句块
      • 子类构造函数



对象的内存布局

在HotSop虚拟机中,对象在内存中存储的布局可以分为:对象头、实例数据、对其填充。


对象头

对象头包括两部分信息:

  • 用于存储对象自身的运行时数据,如hashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,官方称它为“mark world”。mark world被设计成非固定的数据结构,以便在在有限的内存空间中存储尽量多的信息。它会根据对象的状态,存储不同的信息,如下:

    image-20191024232117997

  • 对象头的另外一部分是类型指针,即指向对象的类元数据的指针。虚拟机通过这个指针确定这个对象是哪个类的实例。

  • 如果是java数组,在对象头中还有一块内存数据用于记录数组长度。因为虚拟机可以通过普通java对象的元数据信息确定java对象的大小,但从数组的元数据中无法确定数组的大小,因此需要增加这个数组长度的内存。



实例数据

实例数据部分 是对象真正存储的有效信息,也是程序代码中定义的各类型的字段内容。无论是父类继承下来的,还是子类中定义的,都需要记录起来。这部分的存储顺序会收到虚拟机分配策略参数和 字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配策略是longs/doubles、ints,shorts/chars,bytes/booleans,oops(对象指针)。从分配策略可以看出,相同字宽的可以放在一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。


对齐填充

对齐填充并不是必然存在的,它仅仅起着占位符的作用。由于HotSpot要求对象起始地址必须是8字节的整数倍,换句话说,对象的大小必须是8字节的整数倍 。而对象头正好是8的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要对齐填充来补全。


对象的定位访问

Java程序需要通过栈上的引用来操作堆上的具体对象。目前主要的访问方式有句柄、直接指针。



句柄

使用句柄的方式,Java堆中将会划分出一块内存作为作为句柄池,引用中存储的就是对象的句柄的地址。而句柄中包含了对象实例数据和对象类型数据的地址。

image-20190223184723669



直接指针

使用直接指针的方式,引用中存储的就是对象的地址。对象头中存储了类型数据的指针,可以用来访问对象类型数据。

image-20190223184746278



两种方式各有优点
  • 使用句柄访问的好处是引用中存放的是稳定的句柄地址,当对象被移动(比如说垃圾回收时移动对象),只会改变句柄中实例数据指针,而引用不会被修改。
  • 使用直接指针,节省了一次指针定位的时间开销。



问题

什么情况下使用堆外内存,需要注意什么?

当需要使用大块内存空间作为缓存的时间,如果使用堆内存,会给GC带来压力。这时候就可以使用堆外内存(直接内存)。

使用堆外内存的时候,一定要配置虚拟机参数来限制堆外内存的大小,避免内存溢出。

参考

堆外内存

堆外内存的回收

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值