【深入理解Java虚拟机】第二章 Java内存区域与内存溢出异常

2.2 运行时数据区域

在这里插入图片描述

2.2.1 程序计数器(program counter register)

JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟
作用:PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令

程序计数器是一块较小的内存空间(只存储指令地址),是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。

可看做是当前线程所执行的行号指示器。用来指示线程选取下一条需要执行的字节码。各条线程之间计数器互相不影响,独立存储,“线程私有”的内存,生命周期与线程的生命周期保持一致。

  • 线程在执行一个java方法,该计数器记录的是正在执行的虚拟机字节码指令地址
  • 执行Native方法时,该计数器值为Undefined

实例
对于如下代码

public class Test {

    public static void main(String[] args) {
        int i = 10;
        int j = 20;
        int k = i + j;

        String s = "abc";
        System.out.println(i);
        System.out.println(k);
    }

}

使用javap -v Test.class反编译之后,可以得到如下内容
在这里插入图片描述

两个常见问题:

  1. 使用PC寄存器存储字节码指令地址有什么用?为什么使用PC寄存器记录当前线程的执行地址?

    CPU需要不停地切换各个线程,切换回来后,需要知道从哪里接着开始

    JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令

  2. PC寄存器为什么设定为线程私有

    为了能够准确的记录各个线程正在执行的当前字节码指令地址,最好的就是每个线程分配一个

2.2.2 Java虚拟机栈

1、栈是运行时单位,而堆是存储时单位

栈解决程序的运行问题,即程序如何运行,或者说如何处理数据

堆解决数据存储问题,即数据怎么放,放在哪

线程私有的,生命周期与线程相同

2、该区域会出现两种异常

  1. StackoverflowError:线程请求的栈深度大于虚拟机所允许的

    /**
     * PC中使用默认值:count - 9782
     * 设置栈大小 -Xss256k : count - 2214
     */
    public class Test {
        private static int count = 1;
        public static void main(String[] args) {
            System.out.println(count++);
            main(args);
        }
    }
    
  2. OutOfMemoryError:大部分虚拟机栈可动态扩展,如果扩展时无法申请到足够的内存

3、栈的存储单位

栈中的数据以栈帧(Stack Frame)格式存在,每个方法对应一个栈帧。栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息

4、栈的运行原理

对栈的操作:压栈、出栈

在一条线程中,一个时间点上只有一个活动的栈帧:

只有当前正在执行的方法的栈帧是有效的,称为当前栈帧(Current Frame)

与当前栈帧相对应的方法称为当前方法(Current Method)

定义这个方法的类称为当前类(Current Class)

执行引擎运行的所有字节码指令只针对当前栈帧操作

如果该方法调用了其它方法,对应的新的栈帧会被创建,放在栈顶,称为新的当前帧

不能在一个栈帧中引用另一个线程中的栈帧

return指令和抛出异常都会导致栈帧被弹出

5、栈帧的内部结构

局部变量表(Local Variables)

操作数栈(Operand Stack)(或表达式栈)

动态链接(Dynamic Link)(或指向运行时常量池的方法引用)

方法返回地址(Return Address)(或方法正常退出或异常退出的定义)

一些附加信息

6、局部变量表

也称为局部变量数组或本地变量表,javap 反编译字节码文件中的 LocalVariableTable

定义为数字数组,用于存储方法参数和定义在方法体内的局部变量

这些数据类型包括:基本数据类型、对象引用(reference)、returnAddress类型

局部变量表是建立在线程的栈上,不存在数据安全问题

局部变量表所需容量在编译期确定下来

在方法运行期间不会改变表大小

7、关于slot的理解

局部变量表的最基本存储单位是Slot(变量槽)

局部变量表中,32位以内的类型只占一个slot(包括returnAddress类型)

​ byte、short、char存储前转换成int

​ boolean也转换成int:0 - false、非0 - true

64位的类型(long、double)占两个slot

当实例方法被调用时,其方法参数和方法体内部定义的局部变量会按照顺序被复制到局部变量表中的每个slot中

如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可

如果当前帧是由构造方法或实例方法创建,this指针存放在index0的slot处

8、slot的重复利用

如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量可能会复用过期局部变量槽位,从而节省资源

在这里插入图片描述

9、静态变量和局部变量的对比

类变量两次初始化:

  1. 准备阶段:对类变量设置零值
  2. 初始化阶段:赋予代码中定义的初始值

局部变量表不存在初始化,意味着局部变量必须人为初始化

public void test4() {
    int i;
    System.out.println(i); // Variable 'i' might not have been initialized
}

10、操作数栈

在方法执行过程中,根据字节码指令,向栈中写入数据或提取数据,即入栈出栈

操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量的临时的存储空间

操作数栈是JVM执行引擎的一个工作区,当一个方法开始执行的时候,一个新的栈帧被创建,这个方法的操作数栈是空的

操作数栈在编译期就确定了栈深度用于存储数值,保存在Code - stack属性中

    Code:
      stack=2, locals=2, args_size=1
         0: sipush        1111
         3: istore_1
         4: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: iload_1
         8: invokevirtual #6                  // Method java/io/PrintStream.println:(I)V
        11: return

栈中任何一个元素可以是任意的java数据类型

32bit类型占用一个栈单位深度

64bit类型占用两个栈单位深度

如果被调用的方法有返回值,其返回值将会被压入当前栈桢的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令

jvm的解释引擎是基于栈的执行引擎,其中栈即操作数栈

11、栈顶缓存技术(Top-of-Stack Caching)

操作数是存储在内存中的,频繁地执行内存读写操作会影响执行速度,因此将栈顶元素全部缓存在物理CPU的寄存器中,以降低对内存的读写次数,提升执行引擎的执行效率

12、动态链接

栈帧内部包含着一个指向运行时常量池中该栈帧所属方法的引用。目的是为了支持当前方法的代码能够实现动态链接

在字节码文件中,所有变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池中,动态链接的作用就是将这些符号引用转换为调用方法的直接引用

在这里插入图片描述

在这里插入图片描述

常量池的作用:提供一些符号和常量,便于指令的识别

13、方法的调用

JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关

当字节码被转载进JVM时,被调用的目标方法在编译器可知,且运行期间保持不变时,将符号引用转换为直接引用的过程称为静态链接

反之,如果被调用的方法在编译期无法确定,只能在程序运行期间确定,则称为动态链接

绑定机制 – 多态特性:

绑定是一个字段、方法或者类将符号引用被替换为直接引用的过程

早期绑定:被调用的方法在编译期可知,且运行时保持不变,则使用静态链接的方式将符号引用转换为直接引用

晚期绑定:只能在程序运行期间根据动态链接绑定

14、方法的调用:虚方法与非虚方法

非虚方法:编译期确定调用版本,且在运行期不可变。静态方法、私有方法、final方法、实例构造器、父类方法

其它方法称为虚方法

普通调用指令:

  1. invokestatic:调用静态方法,解析阶段确定唯一的方法版本
  2. invokespecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本
  3. invokevirtual:调用所有虚方法(final也使用此指令,但为非虚方法;自定义的方法也为此指令)
  4. invokeinterface:调用接口方法

动态调用指令:

  1. invokedynamic:动态解析出需要调用的方法,然后执行

    使得JVM支持动态类型语言

    lamda表达式中有使用

静态类型语言:对于类型的检查在编译期 – Java

动态类型语言:对于类型的检查在运行期 – js、python

15、方法的调用:方法重写的本质

  1. 找到操作数栈顶的第一个元素所执行的对象的实例类型,记做C;
  2. 如果在C中找到与常量中的描述符和简单名称都相符的方法,则进行权限访问校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回IllegalAccessError
  3. 否则,按照继承关系从下往上依次对C的各个父类进行2中的搜索和验证过程;
  4. 如果始终没有找到合适的方法,则抛出AbstractMethodError

为了提升性能,减少搜索过程,对于很频繁的动态分配过程,JVM会建立虚方法表(virtual method table),使用索引表代替查找


每个类都有一个虚方法表,表中存放着各个方法的实际入口


虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始化值准备完成之后,JVM会把该类的方法表也初始化完毕

16、方法返回地址

存放调用该方法的pc寄存器的值

A方法在第3行调用了方法B

B方法结束时,让执行引擎继续执行方法A的第4行

正常结束时的返回指令:

ireturn : boolean, byte, char, short, int

lreturn : long

freturn : float

dreturn : double

areturn : 引用类型

return : void方法、实例初始化方法init、类和接口的初始化方法clinit


异常结束时的返回指令:

存储在异常处理表中,方便发生异常时找到处理异常的代码

通过异常完成出口的不会给上层调用者产生任何的返回值

2.2.3 本地方法栈

为虚拟机使用到的Native方法服务,其它方面与虚拟机栈相同。
抛出StackoverflowError(比如递归调用N多次)和OutOfMemoryError(申请不到足够的内存去扩展或创建新的线程)

2.2.4 Java堆

被所有线程共享的内存区域,在虚拟机启动时创建。此区域的唯一目的就是存放对象实例。

java堆是垃圾收集器管理的主要区域。

java虚拟机规范规定:

  1. java堆可处于物理上不连续的内存空间,只要逻辑上连续即可
  2. 实现时,既可以是固定可也可以是可扩展的
  3. 若堆中没有内存完成实力分配,且堆也无法再扩展,将抛出OutOfMemoryError

2、堆的核心概述:内存细分

约定:新生区 = 新生代 = 年轻代、养老区 = 老年区 = 老年代、永久区 = 永久代

Java7及之前的堆内存逻辑上分为:新生区、养老区、永久区

Young Generation Space 新生区 Young/New

​ | ---- 又被划分为Eden和Survivor

Tenure Generation Space 养老区 Old/Tenure

Permanent Space 永久区 Perm

Java8及之后堆内存逻辑上分为:新生区、养老区、元空间

Young Generation Space 新生区 Young/New

​ | ---- 又被划分为Eden和Survivor

Tenure Generation Space 养老区 Old/Tenure

Meta Space 元空间 Meta

对于如下代码

/**
 * -Xms10m -Xmx10m
 */
public class Test {
    public static void main(String[] args) {
        System.out.println("start...");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("end...");
    }
}

在运行后,通过java visualVM能够看到新生代和老年代的分配

在这里插入图片描述

3、堆空间大小的设置

-Xms(memory start)用于表示堆区(新生代+老年代)的起始内存,等价于:-XX:InitialHeapSize

-Xmx用于表示堆区(新生代+老年代)的最大内存,等价于:-XX:MaxHeapSize

通常会将-Xms和-Xmx配置相同的值,目的是为了能够在GC后清理完堆区后不需要重新分隔计算堆区的大小,提高性能

默认:

InitialHeapSie:电脑物理内存大小 / 64

MaxHeapSize:电脑物理内存大小 / 4

在通过java打印堆内存时,会只计算一个survivor区的大小,所以打印出来的值会小于设置的值

查看堆内存大小方式:

  1. jps + jstatus gc pid
  2. -XX:+PrintGCDetails

4、年轻代和老年代

-XX:NewRatio=4,表示新生代占1,老年代占4(默认是是2)

-XX:SurvivorRatio : 设置新生代中Eden与Survivor区的比例(默认是是8)

​ |-- 但是在使用默认值时,因为存在JVM自适应的情况,可能实际大小不是8

几乎所有对象都是在Eden区被new出来的

5、对象分配过程

  1. new的对象先放Eden,此区有大小限制
  2. 当Eden满时,会进行YoungGC(MinorGC),将仍然被使用的对象移动到S0区(age+1)
  3. 当Eden区再次满时,会对Eden和S0进行YoungGC,将仍然被使用的对象移动到S1区
  4. 重复步骤2、3,直到达到15次时(默认值,可通过-XX:MaxTenuringThreshod=<N>设置),将对象移动到老年代

频繁回收新生代,很少回收老年代,几乎不回收永久代/元空间

在这里插入图片描述

6、MinorGC、MajorGC、FullGC

GC时并非每次都对新生代、老年代、方法区一起回收,大部分时候都是新生代

Hotspot中,GC按照回收区域分为:

  1. 部分收集(Partial GC):不是收集整个java堆
    1. 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集
    2. 老年代收集(Major GC / Old GC):只是老年代的垃圾收集
      1. 目前,只有CMS GC有单独收集老年代的行为
      2. 很多时候Major GC会和Full GC混合使用需要具体分辨是老年代回收还是整堆回收
    3. 混合收集(Mixed GC):收集整个新生代及部分老年代的垃圾
      1. 目前只有G1 GC有这种行为
  2. 整堆收集(Full GC):收集整个java堆和方法区的垃圾

MinorGC触发机制:

  1. 当Eden满时,就会触发Minor GC。Survivor满时不会触发Minor GC
  2. java对象大都具备朝生夕死的特点,所以Minor GC非常频繁,回收速度也较快
  3. Minor GC会引发Stop the World,暂停用户线程,等垃圾回收结束时,用户线程才恢复运行

老年代GC(Major GC / Full GC)触发机制:

  1. 出现Major GC,经常会伴随至少一次Minor GC
    1. 也就是老年代空间不足时,会先触发Minor GC,如果之后空间还是不足,则触发Major GC
  2. Major GC一般速度比Minor GC慢10倍以上
  3. 如果Major GC后,内存还是不足,就报OOM

Full GC触发机制:

  1. 调用System.gc(),系统建议执行Full GC,但不是不然执行
  2. 老年到空间不足
  3. 方法去空间不足
  4. 通过Minor GC后进入老年代的平均大小大与老年代的可用内存
  5. 由Eden、S0向S1复制时,对象大小大于S1的可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

7、内存分配策略

  1. 优先分配Eden
  2. 大对象直接分配到老年代
    1. 尽量避免程序中出现过多的大对象
  3. 长期存活的对象分配到老年代
  4. 动态对象n年龄判断
    1. 如果S区中的相同年龄的所有对象大小的总和大于S区空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshod中要求的年龄
  5. 空间分配担保
    1. -XX:HandlerPromotionFailure

8、对象分配过程:TLAB(Thread Local Allocation Buffer)

为什么有TLAB:

堆是线程共享区域,由于对象实例的创建在JVM中非常频繁,因此在并发环境中从堆区划分内存是线程不安全的,

为了避免多个线程操作同一地址,需要加锁等机制,进而影响分配速度

什么是TLAB:

从内存模型的角度,对Eden区继续进行划分,JVM为每个线程分配了一个私有缓存区域

多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能提升内存分配吞吐量,因此我们可以将这种内存分配方式称为快速分配策略

JVM将TLAB作为内存分配的首选,但不是所有的实例都能在TLAB中分配到内存

可通过-XX:useTLAB设置是否开启TLAB

在这里插入图片描述

默认情况下,TLAB仅占Eden的1%(可通过-XX:TLABWasteTargetPercent设置)

当对象在TLAB中分配内存失败,JVM就会尝试使用加锁机制确保操作原子性,在Eden中分配内存

9、堆空间常用jvm参数

在这里插入图片描述

-xx:HandlerPromotionFailure:在JDK7及之后,该参数的值不会再影响到JVM的空间分配担保策略。只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则就进行Full GC

10、逃逸分析

如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,就可能被优化成栈上分配内存,这样就无需在堆上分配,也就无需进行GC

当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸

// 发生逃逸
public StringBuffer test(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb;
}

// 不会发生逃逸
public String test(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}

-XX:+DoEscapeAnalysis:显示开启逃逸分析

-XX:+PrintEscapeAnalysis:查看逃逸分析的筛选结果

11、逃逸分析:代码优化

栈上分配


同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以考虑不同步

JIT借助逃逸分析判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其它线程

如果没有,JIT会在编译时取消对这部分代码的同步

// 这段代码本身就没有实现安全同步
public void test() {
    Object lock = new Object();
    synchronized (lock) {
        System.out.println("test");
    }
}

// 优化成
public void test() {
    Object lock = new Object();
    System.out.println("test");
}

分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中

标量:指一个无法再分解成更小的数据的数据。java中的原始数据类型就是标量

相对与标量,还可以分解的数据叫聚合量,java中的对象就是聚合量,因为其可以分解成其它聚合量和标量

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问,就会把该对象拆解成若干个其中包含的成员变量来代替,这就叫做标量替换

class Point{
    int x;
    int y;
}

public void test() {
    Point point = new Point();
    System.out.println(point.x + " -- " + point.y);
}

// 优化后
public void test() {
    int x = 0;
    int y = 0;
    System.out.println(x + " -- " + y);
}

2.2.5 方法区

线程共享区域,用于存储已被JVM加载的类信息、常量、静态变量、及时编译器编译后的代码等数据。

垃圾收集行为在这个区域比较少出现,该区域的内存回收目标主要针对常量池的回收和对类型的卸载。通常该区域的回收表现不好,尤其是类型的卸载

当方法区无法满足内存分配需求时,将抛出OutOfMemoryError

2.2.6 运行时常量池

方法区的一部分。

Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池,用于存放编译期生成的各种字面量和符号引用,这部分将在类加载后进入方法区的运行时常量池中。

运行时常量池相对于Class文件常量池的另一个重要特征是具备动态性,java并不要求常量只在编译期产生,也就是并非预置入Class文件常量池的内容才能进入方法区运行时常量池,在运行期间也可将新的常量放入池中,例如String类的intern()

该池是方法区中一部分,所以也会抛出OutOfMemoryError

2.2.7 直接内存

直接内存不是jvm运行时数据区的一部分,也不是jvm规范中定义的内存区域。

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

本机直接内存不收Java堆大小限制,但会受到本机总内存(RAM及SWAP区或者分页文件)大小及处理器寻址空间的限制。

服务器管理员在配置jvm参数时,经常忽略直接内存。使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError


2.3 HotSpot虚拟机对象探秘

2.3.1 对象的创建

在这里插入图片描述

1.首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查该符号代表的类是否已被加载、解析和初始化过

2a.对象所需内存大小在类加载完成后便可确定。为对象分配空间时,选择哪种方式有堆内存是够规整决定,而堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能所决定。

​ (1).指针碰撞:假设java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的放在另一边,中间放一个指针 作为分界点的指示器,那分配内存就是把指针移动一段距离

​ (2).空闲列表:堆内存不规整,已用的内存和空闲的相互交错,jvm就必须维护一个列表,记录哪些内存块可用,在分配的时候从列表中找一块足够大的空间划分给对象实例,并更新列表记录

2b. 还需要考虑对象创建在jvm中是否非常频繁的行为。即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。两种方案:

​ (1).对分配内存空间的动作进行同步处理

​ (2).把内存分配的动作按照线程划分在不同的空间之中,即每个线程在堆中预先分配一小块内存(称本地线程分配缓冲,TLAB,Thread Local Allocation Buffer)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定

3. 保证了对象的实例字段在代码中可不赋初值就直接使用

4. 对对象头的设置

2.3.2 对象的内存布局

HotSpot vm中,对象在内存中的布局可分为:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

对象头包括:

1.存储对象自身的运行时数据。如哈希值、GC分代年龄、锁状态标志、偏向线程ID、偏向时间戳等。这部分数据长度视VM的位数而定,官方称“Mark Word”。Mark Word会根据对象状态复用存储空间。

在这里插入图片描述

2.类型指针,即对象指向它的类元数据的指针,VM通过该指针来确定该对象是哪个类的实例。

3.如果,对象是一个数组,那对象头中还一块用于记录数组长度的数据。

实例数据部分是对象真正存储的有效信息,也是在代码中定义的各种类型的字段内容,包括从父类继承和子类中定义的。这部分的存储顺序会受到VM默认的分配策略参数和字段在代码中定义顺序的影响。

对齐填充并不是必然存在的,也没有特别的含义,仅仅起到占位符的作用。

2.3.3 对象的访问定位

程序通过栈上的reference数据来操作堆上的具体对象,目前主流方式有两种:

1.句柄访问:堆中会划分出一块内存作为句柄池,reference中存储对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。优点在于reference中存储的是稳定的句柄地址,在对象被移动时只改变句柄中的实力数据指针,而reference本身不修改

在这里插入图片描述

2.指针访问:堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。优点在于速度快,节省了一次定位指针的时间开销。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值