《深入理解java虚拟机》读书笔记-第二章Java内存区域和内存溢出异常
阅读原文时间:2023年07月11日阅读:1
java1.7和java8的jvm存在差异,本文先按照《深入理解java虚拟机》的讲解内容总结,并将java8的改变作为附录放在文末

图:java虚拟机运行时数据区

1.程序计数器

  • 概念与作用:

    一块较小的内存空间,可以看作时当前线程执行字节码的行号指示器,字节码解释器通过控制改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖程序计数器完成。

  • 线程私有的

    为什么要设计成线程私有的:

    • java多线程的实现方式:

      java多线程是通过线程轮流切换,分配处理器执行时间的方式实现的,在任何一个确定的时刻,一个处理器(多核处理器来说是一个内核)都只会执行一个线程中的指令。

    • 为了线程切换之后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储

  • 线程正在执行一个java方法,程序计数器记录的是正在执行的虚拟机字节码指令地址,如果是native方法,这个计数器的值为空

    1.为什么:
    程序计数器存放的是Java字节码的地址,而native方法的方法体是非Java的,所以程序计数器的值才未定义。
    2.native方法执行结束怎么确保下一次的执行位置
    这是因为每个Java线程(这个说话优点绝对,至少HotSpot是这样的)都直接映射到一个OS(操作空间)线程上执行。所以native方法就在本地线程上执行,无需理会JVM规范中的程序计数器的概念。native方法执行后会退出(栈帧pop),方法退出返回到被调用的地方继续执行程序。
  • 唯一在Java虚拟机规范中没有规定任何OutOfMemoryError情况区域。

2.虚拟机栈

联想斗破苍穹炼丹理解java虚拟机栈
1.药方:编写的java代码
2.药鼎:操作数栈
3.储物戒指:局部变量表
i++ 反编译的文件 iloal_1(从局部变量表中的1位置加载值到操作数栈栈顶),iinc1 1(将局部变量表中的数值加1)
这也是就为什么i++先使用后自加的愿意,改变了局部变量中的值,但是操作数栈中的值时改变之前的值
++i 反编译的文件是iinc1 1 (将局部变量表中的数值加1)后iloal_1(从局部变量表中的1位置加载值到操作数栈栈顶)
这就是为什么++i是先自己后使用的原因,先改变局部变量表中的值,后将自加的值加入到操作数栈
这也是i++ ++i线程不安全的原因,想象i++只做完第一步,加入到了操作数栈,后被其他线程抢占,这个时候还没有写回
  • 概念和作用:描述了java方法执行的线程内存模型,每个方法被执行的时候,java虚拟机都会同步的创建一个栈帧用于存储局部变量表操作数栈动态连接方法出口等信息。 每一个方法执行完毕的过程都对应一个栈帧从虚拟机栈中入栈到出栈的过程。

    • 局部变量表(局部变量数组或本地变量表):

      存放编译器可知的java虚拟机基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用(reference类型,并不等同对象本身,可能是指向对象起始位置的引用指针,也可能是指向一个代表对象的句柄,或者其他与次对象相关的位置)和retrunAddress(指向一条字节码指令的地址)

      1.由于局部变量是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题.
      2.局部变量表中变量只在当前方法调用中有效,在方法执行中,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程.当方法随着栈帧的销毁,局部变量表也随之销毁.
      • slot:这些数据类型在局部变量表在和的存储空间以局部变量槽,64位的double和long的数据会占用两个变量槽,其余数据类型只会占用一个。
      • 局部变量表所需的内部空间在编译期间完成分配,当进入一个方法,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量的大小
    • 操作数栈:主要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间。

      • 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建,这个方法的操作数栈是空的。
      • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值。
      • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中。
      • 我们说是java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
    • return address:方法返回

      • 正常完成出口是指方法正常完成并退出,没有抛出任何异常(包括Java虚拟机异常以及执行时通过throw语句显示抛出的异常)。如果当前方法正常完成,则根据当前方法返回的字节码指令,这时有可能会有返回值传递给方法调用者(调用它的方法),或者无返回值。具体是否有返回值以及返回值的数据类型将根据该方法返回的字节码指令确定。

      • 异常完成出口是指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。

        1.无论方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态。
        2.方法退出过程实际上就等同于把当前栈帧出栈,因此退出可以执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压如调用者的操作数栈中,调整PC计数器的值以指向方法调用指令后的下一条指令。

    • 动态连接

      每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。在Java源文件被编译到字节码文件时,所有的变量和方法引用都作为符号引用(Symbilic Reference)保存在class文件的常量池里。

      比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

  • 异常:线程请求的栈深度大于虚拟机允许的深度:StackOverflowError,如果java虚拟机栈容量可以动态扩展,扩展无法申请到空间是抛出OutOfMermoryError(hotspot不允许动态扩展)

3.java堆

  • 概念和作用:

    • 在虚拟机启动的时候创建,线程所共享,作用是存放对象实例。
    • java堆是垃圾收集器管理的内存区域,java堆也被称作gc堆
  • 可以使用-Xmx和-Xms来设定java堆的大小,如果超过可以使用的大小抛出OutOfMermoryError

  • 新生代,老年代,永久代,Eden等名词这些区域划分知识一部分垃圾收集器的设计风格,并不意味着有具体的的内存布局

4.方法区

  • 线程共享的内存区域,用于存放被虚拟机加载的类型信息,常量,静态变量,即时编译器i编译后的代码缓存等数据
  • jvm方法区可不是想垃圾回收:类型的卸载的判断条件十分苛刻——指必须确保这个类型将不会被使用到
  • 如果方法区无法满足新的内存分配要求时抛出OutOfMermoryError
  • 运行时常量池:方法区的一部分,class文件除了存有类的版本,字段,方法,接口 等描述信息,还有一项信息时常量池表,用于存放编译器生成的隔各种字面量和符号引用,这部分内容在类加载后存放到运行时常量池中。java并不要求常量只能在编译器产生,并非预置入class文件中的常量池内容才能进入方法区运行时常量池,运行期间也可以放入池中
  • 常量池无法申请到内存抛出OutOfMermoryError

5.直接内存

并不属于Java虚拟机中定义的内存区域
NIO基于通道,与缓冲区的io方式,可以使用navtive 函数直接分配堆外内存,通过存储与java堆里面DirectByteBuffer对象作为这块内存的引用进行操作,避免java堆和native堆中来回复制数据
各个内存之和大于物理内存的时候抛出OutOfMermoryError

1.对象的创建过程

  • 当虚拟机执行到new指令时,收件检查这个指令的参数是否在常量池中定位到一个类的符号引用

  • 检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有先执行类加载过程

  • 类加载检查通过后,虚拟机为新生对象分配内存,对象所需的内存在类加载之后便可以完全确定(分配内存=把虚拟机的一块确定大小的内存从java堆中划分出来)

    1.假如java堆中内存绝对规整,空闲和使用过的内存分别放在两边,中间放着一个指针作为分解分配一段和对象大小相同的内存————指针碰撞
    2.如果内存并不规整,那么虚拟机需要维护一个列表,记录那些内存块可以用,找到可用的内存分配给对象,更新列表————空闲列表
    3.如果虚拟机java堆的收集器具备空间压缩整理能力使用指针碰撞,基于清除算法的收集器,其堆使用空闲列表(Serial,parnew具备压缩处理能力,CMS基于清除算法)
  • 内存分配完成,虚拟机给分配到的内存空间(不包括对象头)初始化为零值

  • 进行必要的设置:该对象是哪个类的实例,如何找到类的信息,对象的hashcode(在调用hashcode的时候才计算),对象的GC分代年龄存放于对象头中(指此一个新对象已经产生,java程序的构造函数才刚刚开始)

  • 接下来按照构造函数的内容进行对象属性的赋值,执行剩余逻辑,一个真正可以用的对象创建出来

    对象位于堆,对象的引用位于虚拟机栈

2.如何确保线程安全

指在给对象A分配内存时,指针还没来得及修改,这是给B分配内存指针还位于原来的位置
  1. 使用CAS配上失败重试的方式保证更新操作的原子性
  2. 为每一个线程分配一小块堆内存(本地线程缓冲池)优先在本地线程缓冲池分配内存,缓冲池用完,分配新的缓冲池才需要同步锁定【-XX:+/-UseTLAB可设置虚拟机是否使用本地缓冲池】

3.对象的内存布局

对象在内存中的布局主要分为三部分:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)

  • 对象头(object header):包括了关于堆对象的布局、类型、GC状态、同步状态和标识哈希码的基本信息。Java对象和vm内部对象都有一个共同的对象头格式。
  • 实例数据(Instance Data):主要是存放类的数据信息,父类的信息,对象字段属性信息。
  • 对齐填充(Padding):为了字节对齐,填充的数据,不是必须的。

1.对象头

  • Mark word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳(如果对象是一个数组对象,对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小,根据对象的类型和数组的大小计算对象的大小)等等。

    mark word是一个动态的结构,便于极小的空间存储大量的数据(其中使用2个比特记录锁状态,比如01指未锁定)
    • 锁标志位(lock):区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
    • biased_lock:是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
    • 分代年龄(age):表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。(对象分代年龄的分配的空间是4bit,而4bit能表示的最大数就是2^4-1 = 15,这也是为什么15次会晋升到老年代)
    • 对象的hashcode(hash):运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。
    • 偏向锁的线程ID(JavaThread):偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
    • epoch:偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
    • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。
    • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。
  • Klass Pointer:类型指针,是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

2.实例数据

如果对象有属性字段,则这里会有数据信息。如果对象无属性字段,则这里就不会有数据。根据字段类型的不同占不同的字节,例如boolean类型占1个字节,int类型占4个字节等等,无论是从父类继承下来的字段还是子类自己定义的字段,都必需记录下来。

这部分的存储顺序受到虚拟机分配策略和java代码中定义的字段顺序有关,默认的分配顺序是long/double,int,short,char,byte/boolean,oop(对象指针,比如string类型,存储指向对象的地址),大小相同的分配在一起,如果虚拟机设置允许(默认便允许)子类窄变量允许插入到父类变量的间隙中(压缩空间)

3.对齐填充

Hotspot虚拟机自动内存管理系统要求对象的起始地址必须是8字节的,如果对象实例数据部分不是8字节的整数倍使用对齐填充

字段内存对齐的其中一个原因,是让字段只出现在同一CPU的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。其实对其填充的最终目的是为了计算机高效寻址

4.对象的访问定位

java程序定位堆中的对象主要使用栈中的reference数据,访问到堆中对象的具体方式有主要有两种:句柄和直接指针(hotspot采用此)

1.句柄:

  • java堆中分配一块作为句柄池,存储对象的对象的句柄,包含实例数据和类型数据的具体地址信息

  • 优点:对象被移动(垃圾收集器常移动对象,保证内存的规整)的时候,只需要改变句柄中的实例的数据,栈中的reference不需要改变

2.直接指针

  • reference直接指向对象实例数据的堆中地址,对象的实例数据存放对象类型地址的指针
  • 优点:访问速度块,只需要一次指针定位便可用找到对象实例数据,对象访问十分频繁,这类开销积少成多效果很客观