JVM之内存结构篇

内存结构

文章目录

  • 内存结构
    • 1. 整体结构
    • 2. 程序计数器(PC)
      • 2.1 定义
      • 2.2 执行过程
      • 2.3 特点
    • 3. 虚拟机栈
      • 3.1 虚拟机栈三个区域之一:局部变量区
      • 3.2 虚拟机栈三个区域之二:运行环境区
      • 3.3 虚拟机栈三个区域之三:操作数区
      • 3.4 关于虚拟机栈的几个问题
      • 3.5 内存溢出
    • 4. 本地方法栈
    • 5. 堆
      • 5.1 定义
      • 5.2 堆内存的分区
      • 5.3 TLAB
      • 5.4 堆内存溢出
      • 5.5 堆内存诊断
      • 5.6 问题分析
    • 6. 方法区
      • 6.1 定义
      • 6.2 栈、堆、方法区的交互关系
      • 6.3 方法区的内部结构
        • 6.3.1 类型信息
        • 6.3.2 域信息
        • 6.3.3 方法信息
        • 6.3.4 静态变量
        • 6.3.5 运行时常量池
          • 6.3.5.1 运行时常量池介绍
          • 6.3.5.2 重要组成部分之一:StringTable
          • 6.3.5.3 intern方法
          • JDK 1.6
          • 6.3.5.4 StringTable的位置
          • 6.3.5.5 StringTable的调优
      • 6.4 方法区内存溢出
      • 6.5 方法区的垃圾回收
    • 7. 直接内存
      • 7.1 定义
      • 7.2 基本使用
      • 7.3 释放原理
          • 直接内存的回收机制总结
    • 8. 面试题

(此学习笔记由黑马程序员的JVM教程以及《深入理解JAVA虚拟机》整理而来)

1. 整体结构

JVM之内存结构篇_第1张图片

2. 程序计数器(PC)

2.1 定义

  • 作用:程序计数器是用于存放执行指令的地方
  • 为了保证程序(在操作系统中理解为进程)能够连续地执行下去,CPU必须具有某些手段来确定下一条指令的地址。而程序计数器正是起到这种作用,所以通常又称为指令计数器
  • 在程序开始执行前,必须将它的起始地址,即程序的一条指令所在的内存单元地址送入PC,因此程序计数器(PC)的内容即是从内存提取的第一条指令的地址

2.2 执行过程

1. 在程序开始执行前,将程序指令序列的起始地址,即程序的**第一条指令所在的内存单元地址**送入PC,CPU按照PC的指示从内存读取第一条指令(**取指**)。

2. 当执行指令时,CPU自动地修改PC的内容,即每执行一条指令PC增加一个量,这个量等于指令所含的字节数(指令字节数),使PC总是指向下一条将要取 指的指令地址。由于大多数指令都是按顺序来执行的,所以修改PC的过程通常只是简单的对PC加指令字节数。
3. 当程序转移时,转移指令执行的最终结果就是要改变PC的值,此PC值就是转去的目标地址。处理器总是按照PC指向取指、译码、执行,以此实现了程序转移

2.3 特点

  • 线程私有
    • CPU会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU就会去执行另一个线程中的代码
    • 程序计数器是每个线程所私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一句指令
  • 不会存在内存溢出

JVM之内存结构篇_第2张图片

3. 虚拟机栈

  • 定义:
    • 每个线程运行需要的内存空间,称为虚拟机栈
    • 每个栈由多个栈帧组成,对应着每次调用方法时所占用的内存
    • 每个线程只能有一个活动栈帧,对应着当前正在执行的方法

3.1 虚拟机栈三个区域之一:局部变量区

  • 每个Java方法使用一个固定大小的局部变量集。它们按照与vars寄存器的字偏移量来寻址。局部变量都是32位的。长整数双精度浮点数占据了两个局部变量的空间,却按照第一个局部变量的索引来寻址。(例如,一个具有索引n的局部变量,如果是一个双精度浮点数,那么它实际占据了索引n和n+1所代表的存储空间。)虚拟机规范并不要求在局部变量中的64位的值是64位对齐的。虚拟机提供了把局部变量中的值装载到操作数栈的指令,也提供了把操作数栈中的值写入局部变量的指令。

3.2 虚拟机栈三个区域之二:运行环境区

  • 运行环境区在运行环境中包含的信息用于动态链接正常的方法返回以及异常传播

    • 动态连接:

      运行环境包括对指向当前类和当前方法的解释器符号表的指针,用于支持方法代码的动态链接。方法的class文件代码在引用要调用的方法和要访问的变量时使用符号。动态链接把符号形式的方法调用翻译成实际方法调用,装载必要的类以解释还没有定义的符号,并把变量访问翻译成与这些变量运行时的存储结构相应的偏移地址。动态链接方法和变量使得方法中使用的其它类的变化不会影响到本程序的代码。

    • 正常的方法返回:

      如果当前方法正常地结束了,在执行了一条具有正确类型的返回指令时,调用的方法会得到一个返回值。执行环境在正常返回的情况下用于恢复调用者的寄存器,并把调用者的程序计数器增加一个恰当的数值,以跳过已执行过的方法调用指令,然后在调用者的执行环境中继续执行下去

    • 异常和错误传播

      异常情况在Java中被称作Error(错误)或Exception(异常),是Throwable类的子类,在程序中的原因是:①动态链接错,如无法找到所需的class文件。②运行时错,如对一个空指针的引用

    • 程序使用了throw语句:

      当异常发生时,Java虚拟机采取如下措施:

      • 检查与当前方法相联系的catch子句表。每个catch子句包含其有效指令范围,能够处理的异常类型,以及处理异常的代码块地址。

      • 与异常相匹配的catch子句应该符合下面的条件:造成异常的指令在其指令范围之内,发生的异常类型是其能处理的异常类型的子类型。如果找到了匹配的catch子句,那么系统转移到指定的异常处理块处执行;如果没有找到异常处理块,重复寻找匹配的catch子句的过程,直到当前方法的所有嵌套的catch子句都被检查过。

      • 由于虚拟机从第一个匹配的catch子句处继续执行,所以catch子句表中的顺序是很重要的。因为Java代码是结构化的,因此总可以把某个方法的所有的异常处理器都按序排列到一个表中,对任意可能的程序计数器的值,都可以用线性的顺序找到合适的异常处理块,以处理在该程序计数器值下发生的异常情况。

      • 如果找不到匹配的catch子句,那么当前方法得到一个"未截获异常"的结果并返回到当前方法的调用者,好像异常刚刚在其调用者中发生一样。如果在调用者中仍然没有找到相应的异常处理块,那么这种错误传播将被继续下去。如果错误被传播到最顶层,那么系统将调用一个缺省的异常处理块

3.3 虚拟机栈三个区域之三:操作数区

  • 操作数栈区机器指令只从操作数栈中取操作数,对它们进行操作,并把结果返回到栈中。选择栈结构的原因是:在只有少量寄存器或非通用寄存器的机器(如Intel486)上,也能够高效地模拟虚拟机的行为。操作数栈是32位的。它用于给方法传递参数,并从方法接收结果,也用于支持操作的参数,并保存操作的结果。例如,iadd指令将两个整数相加。相加的两个整数应该是操作数栈顶的两个字。这两个字是由先前的指令压进堆栈的。这两个整数将从堆栈弹出、相加,并把结果压回到操作数栈中。
  • 每个原始数据类型都有专门的指令对它们进行必须的操作。每个操作数在栈中需要一个存储位置,除了long和double型,它们需要两个位置。操作数只能被适用于其类型的操作符所操作。例如,压入两个int类型的数,如果把它们当作是一个long类型的数则是非法的。在Sun的虚拟机实现中,这个限制由字节码验证器强制实行。但是,有少数操作(操作符dupe和swap),用于对运行时数据区进行操作时是不考虑类型的

3.4 关于虚拟机栈的几个问题

面试题:

  1. 垃圾回收是否涉及栈内存?

    • 不需要。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以不需通过垃圾回收机制去回收内存。
  2. 栈内存的分配越大越好吗?

    • 不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。

      如:400MB的物理内存,如果每个栈内存分配100MB,那最多只能有四个线程同时执行,执行效率并不一定会提高。

  3. 方法内的局部变量是否是线程安全的?

    • 如果方法内局部变量没有逃离方法的作用范围,则是线程安全
    • 如果如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全问题

3.5 内存溢出

  • Java.lang.stackOverflowError 栈内存溢出

  • 发生原因

    • 虚拟机栈中,栈帧过多(无限递归)
    • 每个栈帧所占用过大
  • 线程运行诊断:

    CPU占用过高

    • Linux环境下运行某些程序的时候,可能导致CPU的占用过高,这时需要定位占用CPU过高的线程
      • top命令,查看是哪个进程占用CPU过高
      • ps H -eo pid, tid(线程id), %cpu | grep 刚才通过top查到的进程号 通过ps命令进一步查看是哪个线程占用CPU过高
      • jstack 进程id 通过查看进程中的线程的nid,刚才通过ps命令看到的tid来对比定位,注意jstack查找出的线程id是16进制的需要转换

4. 本地方法栈

  • 本地方法栈 (Native Method Stack)

  • Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用

  • 一些带有native关键字的方法就是需要JAVA去调用本地的C或者C++方法,因为JAVA有时候没法直接和操作系统底层交互,所以需要用到本地方法

    如:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vrze90HM-1658405658646)(C:\Users\10642\AppData\Roaming\Typora\typora-user-images\image-20220721083629552.png)]
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZcExDVFH-1658405658647)(C:\Users\10642\AppData\Roaming\Typora\typora-user-images\image-20220721083645157.png)]
    在这里插入图片描述

  • 本地方法栈,也是线程私有

  • 允许被实现成固定或者是可动态扩展的内存大小(在内存溢出方面和虚拟机栈相同)

    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError 异常。
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutOfMemoryError异常。

5. 堆

5.1 定义

  • 堆(Heap)的概述:

    一个JVM实例只有一个堆内存,堆也是Java内存管理的核心区域,堆在JVM启动的时候创建,其空间大小也被创建,是JVM中最大的一块内存空间,所有线程共享Java堆,物理上不连续的、逻辑上连续的内存空间,几乎所有的实例都在这里分配内存,在方法结束后,堆中的对象不会马上删除,仅仅在垃圾收集的时候被删除,堆是GC(垃圾收集器)执行垃圾回收的重点区域。

  • 通过new关键字,创建的对象都会使用堆内存

  • 特点:

    • 堆是线程共享的,堆中对象都需要考虑线程安全问题

    • 有垃圾回收机制

5.2 堆内存的分区

  • 为什么要进行分区?

    经过研究,不同对象的生命周期不同。70%-99%的对象都是临时对象

JVM之内存结构篇_第3张图片

  • Java7及以前将堆空间逻辑上分成三部分:新生代+老年代+永久代

  • Java8及以后将堆内存逻辑上分为:新生代+老年代+元空间

    • 新生代:

      1. 新生代使用了复制算法

      2. 新生代为gc的重点对象,经官方测试70%对象都生命周期都会在新生代中完结

      3. 新生代又分为了eden、survivor1、survivor2,对象创建先放在eden中,经过一定时间还幸存就会放在幸存者区

        • eden(新生区): 当初始加载对象时会进入新生区
        • survivor(幸存区)
          • 幸存区又分为from 和 to :谁为空谁为to ,始终都会有一个区域为空
          • 幸存区不会主动进行垃圾回收,只会eden回收时才会附带进行gc
          • 当在幸存区中的阈值达到了15后(默认15可修改)会自动进入老年代
          • 当新生区(eden)出现了内存不足时,会进行YoungGC(新生代垃圾回收),将没有指针的对象回收,把指针引向的对象放入survivor1或者survivor2区域中,eden清空,数据放入一个survivor中。当第二次进行gc那么会将eden数据放入另一个空的survivor中,并且将当前survivor中有效数据,放入空的survivor中,依次类推。
      4. 内存比例分默认为:8:1:1

      5. 新生代收集器:Minor GC/Young GC

    • 老年代:

      1. 较大的对象数据会放入老年代
      2. 老年代的数据都是相对于持久的不会频繁的gc
      3. (MajorGC / Old GC) 在进行majorGc时会至少进行一次minorGc ,而且majorgc的效率是比minorGc 慢10倍的
      4. 老年代收集器:MajorGC / Old GC 要区分与Full GC
  • 设置堆内存的大小:

    • Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,可以通过选项**“-Xmx"和”-Xms"**来进行设置

      • "-Xms"用于表示堆区的起始内存,等价于-xx:InitialHeapSize
      • "-Xmx"则用于表示堆区的最大内存,等价于-XX:MaxHeapSize
    • 一旦堆区中的内存大小超过“-Xmx"所指定的最大内存时,将会抛出outofMemoryError异常

    • 通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能

5.3 TLAB

  • 什么是TLAB(Thread Local Allocation Buffer)?

    《深入理解java虚拟机》中:
    如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。在本章中,我们仅仅针对内存区域的作用进行讨论,Java堆中的上述各个区域的分配、回收等细节将会是下一章的主题。
    

    从中不难得出:

    • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
    • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程问题。同时还能够提升内存分配的吞吐量,因此我们可以将内存分配方式称之为快速分配策略
    • 基本上所有OpenJDK衍生出来的JVM都提供了TLAB的设计。
  • 进一步理解TLAB:

    • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
    • 在程序中,开发人员可以通过选项**"-XX:UseTLAB"设置是否开启TLAB空间**
    • 默认情况下,TLAB空间的内存非常小,仅占用整个Eden空间的1%,当然我们可以通过选项"-XX:TLABWasteTargetPercent"设置TLAB空间所占用Eden空间的百分比大小。
    • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存
  • 为什么有TLAB?
    • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
    • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
    • 为了避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

5.4 堆内存溢出

//代码演示堆内存溢出
//可以通过在VM options中设置 -Xmx8m  将堆内存改为8m
public class stackTest{
    public static void main(String args[]){
        int i = 0;
        try{
            List<String> list = new ArrayList<>();
            String a = "hello";
            while(true){
                list.add(a);
                a = a + a;
                i++;
            }
        }catch (Throwable e){
            e.printStackTrace();
            System.out.println(i);
        }
    }
}
  • 运行此程序,java控制台报错:java.lang.OutOfMemoryError: Java heap space

5.5 堆内存诊断

  • 工具的使用:
    1. jps工具:查看当前系统中有哪些java进程
    2. jmap工具:查看堆内存占用情况
    3. jconsole工具:图形界面的,多功能的监测工具,可以连续监测
    4. jvirsualvm:可以使用里面的堆转储 dump功能,抓取内存快照
//演示堆内存
public class Test {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("1...");
        Thread.sleep(30000);
        byte[] array = new byte[1024 * 1024 * 10]; //10mb
        System.out.println("2...");
        Thread.sleep(30000);
        array = null;
        System.gc();
        System.out.println("3...");
        Thread.sleep(30000);
    }
}
  1. 使用jps

    JVM之内存结构篇_第4张图片

  2. 使用jmap -heap 进程号

    ​ 1. 启动时

    JVM之内存结构篇_第5张图片

    1. 创建10mb的byte后

    JVM之内存结构篇_第6张图片

    1. gc后

    JVM之内存结构篇_第7张图片

  3. 使用jconsole

    JVM之内存结构篇_第8张图片

5.6 问题分析

堆是分配对象的唯一选择么?

在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:
随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
  • 在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术

  • 此外,前面提到的基于openJDk深度定制的TaoBaovm,其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。

  • 如何将堆上的对象分配到栈,需要使用逃逸分析手段

    • 这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域:
      • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
      • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
    • 在JDK 1.7 版本之后,HotSpot中默认就已经开启了逃逸分析,如果使用的是较早的版本,则可以通过:
      • 选项“-xx:+DoEscapeAnalysis"显式开启逃逸分析
      • 通过选项“-xx:+PrintEscapeAnalysis"查看逃逸分析的筛选结果
  • 结论:

    • 开发中能使用局部变量的,就不要使用在方法外定义。

    • 使用逃逸分析,编译器可以对代码做如下优化:

      1. 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配

        • 栈上分配:JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
        • 常见的栈上分配的场景:给成员变量赋值、方法返回值、实例引用传递。
      2. 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

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

6. 方法区

6.1 定义

在《深入理解java虚拟机》中对方法区是这样说明的:
	方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。
	说到方法区,不得不提一下“永久代”这个概念,尤其是在JDK 8以前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(PermanentGeneration),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得
HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是对于其他虚拟机实现,譬如BEA JRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了Java应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小,而J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题),而且有极少数方法(例如String::intern())会因永久代的原因而导致不同虚拟机下有不同的表现。当Oracle收购BEA获得了JRockit的所有权后,准备把JRockit中的优秀功能,譬如Java Mission Control管理工具,移植到HotSpot虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到HotSpot未来的发展,在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了[1],到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。
	《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。
	根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

从中不难的的得出:

  1. 方法区主要存放的是类Class,而堆中主要存放的是实例化的对象
  2. 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
  3. 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
  4. 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
  5. 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:ava.lang.OutofMemoryError:PermGen space 或者java.lang.OutOfMemoryError:Metaspace
  • 加载大量的第三方的jar包
  • Tomcat部署的工程过多(30~50个)
  • 大量动态的生成反射类
  1. 关闭JVM就会释放这个区域的内存
  • 从线程共享与否的角度来看

    JVM之内存结构篇_第9张图片

    ThreadLocal:如何保证多个线程在并发环境下的安全性?典型应用就是数据库连接管理,以及会话管理

6.2 栈、堆、方法区的交互关系

JVM之内存结构篇_第10张图片

JVM之内存结构篇_第11张图片

6.3 方法区的内部结构

JVM之内存结构篇_第12张图片

《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等

JVM之内存结构篇_第13张图片

6.3.1 类型信息
  • 对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

    • 这个类型的完整有效名称(全名=包名.类名)
    • 这个类型直接父类的完整有效名(对于interface或是java.lang.object,都没有父类)
    • 这个类型的修饰符(public,abstract,final的某个子集)
    • 这个类型直接接口的一个有序列表
6.3.2 域信息
  • JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
  • 域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
6.3.3 方法信息
  • JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
    • 方法名称
    • 方法的返回类型(或void)
    • 方法参数的数量和类型(按顺序)
    • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
    • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
    • 异常表(abstract和native方法除外):每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
6.3.4 静态变量
  • 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
  • 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它
6.3.5 运行时常量池
6.3.5.1 运行时常量池介绍
  • 常量池表(Constant Pool Table)是Class文件的一部分,这部分内容将在类加载后存放到方法区的运行时常量池中。虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
  • 常量池是.class文件中的,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
  • JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
  • 运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性
  • 运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些。
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。
6.3.5.2 重要组成部分之一:StringTable
  • 串池StringTable的特征

    • 常量池中的字符串仅是符号,只有在被用到时才会转化为对象
    • 利用串池的机制,来避免重复创建字符串对象
    • 字符串变量拼接的原理是StringBuilder
    • 字符串常量拼接的原理是编译器优化
    • 可以使用intern方法,主动将串池中还没有的字符串对象放入串池中
    • 注意:无论是串池还是堆里面的字符串,都是对象

针对串池特征的案例:

  • 通过String str = “”; 的方式创建字符串

    //常量池中的信息,都会被加载到运行时常量池中,但这是a 、b 、ab 仅是常量池中的符号,还没有成为java字符串
    public class StringTableStudy {
    	public static void main(String[] args) {
    		String s1 = "a"; 
    		String s2 = "b";
    		String s3 = "ab";
    	}
    }
    
    //反编译后的结果:
    0: ldc           #2                  // String a
    2: astore_1
    3: ldc           #3                  // String b
    5: astore_2
    6: ldc           #4                  // String ab
    8: astore_3
    9: return
    

    当执行到 ldc #2 时,会把符号 a 变为 “a” 字符串对象,并放入串池中(hashtable结构 不可扩容)

    当执行到 ldc #3 时,会把符号 b 变为 “b” 字符串对象,并放入串池中

    当执行到 ldc #4 时,会把符号 ab 变为 “ab” 字符串对象,并放入串池中

    最终StringTable [“a”, “b”, “ab”]

    注意:字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候(如 ldc #2)时,该字符串才会被创建并放入串池中。

  • 使用拼接字符串变量对象创建字符串的过程:

    public class StringTableStudy {
    	public static void main(String[] args) {
    		String s1 = "a";
    		String s2 = "b";
    		String s3 = "ab";
    		//拼接字符串变量对象来创建新的字符串
    		String s4 = s1 + s2; 
    	}
    }
    
    //反编译后的结果:
    Code:
          stack=2, locals=5, args_size=1
             0: ldc           #2                  // String a
             2: astore_1
             3: ldc           #3                  // String b
             5: astore_2
             6: ldc           #4                  // String ab
             8: astore_3
             9: new           #5                  // class java/lang/StringBuilder
            12: dup
            13: invokespecial #6                  // Method java/lang/StringBuilder."":()V
            16: aload_1
            17: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String
    ;)Ljava/lang/StringBuilder;
            20: aload_2
            21: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String
    ;)Ljava/lang/StringBuilder;
            24: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/Str
    ing;
            27: astore        4
            29: return
    
    1. 通过拼接的方式来创建字符串的过程是:StringBuilder().append(“a”).append(“b”).toString()
    2. 最后的toString方法的返回值是new String(),但字符串的和拼接的字符串一致,但是两个不同的字符串,一个存在于串池之中,一个存在于堆内存之中。故System.out.println(s3 == s4)的返回值为false
  • 使用拼接字符串常量对象的方法创建字符串

    public class StringTableStudy {
    	public static void main(String[] args) {
    		String s1 = "a";
    		String s2 = "b";
    		String s3 = "ab";
    		String s4 = a+b;
    		//使用拼接字符串常量对象的方法创建字符串
    		String s5 = "a" + "b";
    	}
    }
    
    //反编译后的结果:
     Code:
          stack=2, locals=6, args_size=1
             0: ldc           #2                  // String a
             2: astore_1
             3: ldc           #3                  // String b
             5: astore_2
             6: ldc           #4                  // String ab
             8: astore_3
             9: new           #5                  // class java/lang/StringBuilder
            12: dup
            13: invokespecial #6                  // Method java/lang/StringBuilder."":()V
            16: aload_1
            17: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String
    ;)Ljava/lang/StringBuilder;
            20: aload_2
            21: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String
    ;)Ljava/lang/StringBuilder;
            24: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/Str
    ing;
            27: astore        4
            //ab3初始化时直接从串池中获取字符串
            29: ldc           #4                  // String ab
            31: astore        5
            33: return
    

    拼接字符串常量和拼接字符串变量的比较:

    • 使用拼接字符串常量的方法来创建新的字符串时,因为内容是常量,javac在编译期会进行优化,结果已在编译期确定为ab,而创建ab的时候已经在串池中放入了"ab",所以s5直接从串池中获取值,所以进行的操作和 s5 = “ab” 一致。
    • 使用拼接字符串变量的方法来创建新的字符串时,因为内容是变量,只能在运行期确定它的值,所以需要使用StringBuilder来创建
6.3.5.3 intern方法
  • JDK 1.6

    调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中

    • 如果串池中没有该字符串对象,会将该字符串对象复制一份(即新创建一个对象),再放入到串池中
    • 如果有该字符串对象,则放入失败

    无论放入是否成功,都会返回串池中的字符串对象

    注意:此时无论调用intern方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象

  • JDK 1.8

    调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中

    • 如果串池中没有该字符串对象,则放入成功(即把自己的地址放在常量池中

    • 如果有该字符串对象,则放入失败

      但是无论放入是否成功,都会返回串池中的字符串对象

    注意:此时如果调用intern方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象

//测试1
public class Test1 {
    public static void main(String[] args) {
        String s = new String("a") + new String("b");
        String s2 = s.intern();
        System.out.println(s2 == "ab");//true
        System.out.println(s == "ab");//true
    }
}
//测试2
public class Test2 {
    public static void main(String[] args) {
        String x = "ab";
        String s = new String("a") + new String("b");
        String s2 = s.intern();
        System.out.println(s2 == x);//true
        System.out.println(s == x);//false
    }
}

思考:为什么第6行的代码返回值为true?

反推:

根据intern(JDK1.8)的描述可以得知:该方法将"ab"放到常量池成功,说明常量池中没有该字符串对象。但是第3行的代码通过拼接的方式来创建字符串的过程是:StringBuilder().append(“a”).append(“b”).toString()。而StringBuilder的toString方法的返回值是new String(),不是应该同时在堆和常量池中创建了"ab"吗?为什么常量池中会没有"ab"呢?

根据源码得知:

StringBuilder的toString方法的返回值是**new String(char value[], int offset, int count)**而不是普通的new String();

JVM之内存结构篇_第14张图片JVM之内存结构篇_第15张图片
JVM之内存结构篇_第16张图片
这个new String()方法的底层实际上是一个简单的复制,只会在堆中创建对象,并不会在常量池中创建对象,故常量池中没有"ab"

6.3.5.4 StringTable的位置

在JDK1.8中,将StringTable的位置从方法区中的常量池转移到堆中

JVM之内存结构篇_第17张图片

  • 为什么要调整位置?
  • jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而ful1gc是老年代的空间不足、永久代不足时才会触发。这就导致stringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存
6.3.5.5 StringTable的调优
  • 调整 -XX:StringTableSize=桶个数
    • 因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少字符串放入串池所需要的时间
  • 考虑将字符串对象是否入池
    • 可以通过intern方法减少重复入池

6.4 方法区内存溢出

  • 1.8以前会导致永久代内存溢出(java.lang.OutOfMemoryError:PermGen space)
  • 1.8以后会导致元空间内存溢出(java.lang.OutOfMemoryError:Metaspace)

6.5 方法区的垃圾回收

  • 有些人认为方法区(如Hotspot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的zGC收集器就不支持类卸载)。

  • 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。

  • 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型

    先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:

    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符
  • HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。回收废弃常量与回收Java堆中的对象非常类似。(关于常量的回收比较简单,重点是类的回收)

  • 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

    • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
    • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如osGi、JSP的重加载等,否则通常是很难达成的。
    • 该类对应的java.lang.Class对象没有在任何地方被引用无法在任何地方通过反射访问该类的方法
  • Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class 以及 -XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息

  • 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及oSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

7. 直接内存

7.1 定义

  • Direct Memory

    • 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。属于操作系统,直接内存是在Java堆外的、直接向系统申请的内存区间。
    • 来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
    • 分配回收成本较高,但访问直接内存的速度会优于Java堆,即读写性能高
      • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
      • Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
    • 不受JVM内存回收管理
  • 使用下列代码,直接分配本地内存空间

    int BUFFER = 1024*1024*1024; // 1GB
    ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
    

7.2 基本使用

  • 文件读写流程

    JVM之内存结构篇_第18张图片

  • 使用了DirectBuffer

    JVM之内存结构篇_第19张图片

  • 直接内存是操作系统和Java代码都可以访问的一块区域,无需将代码从系统内存复制到Java堆内存,从而提高了效率

7.3 释放原理

  • 直接内存的回收不是通过JVM的垃圾回收来释放的,而是通过unsafe.freeMemory来手动释放

  • 流程:

    1. 申请直接内存

      //通过ByteBuffer申请1M的直接内存
      ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1M)
      
    2. allocateDirect的实现

      public static ByteBuffer allocateDirect(int capacity) {
          return new DirectByteBuffer(capacity);
      }
      
    3. DirectByteBuffer类

      DirectByteBuffer(int cap) {   // package-private
         
          super(-1, 0, cap, cap);
          boolean pa = VM.isDirectMemoryPageAligned();
          int ps = Bits.pageSize();
          long size = Math.max(1L, (long)cap + (pa ? ps : 0));
          Bits.reserveMemory(size, cap);
      
          long base = 0;
          try {
              base = unsafe.allocateMemory(size); //申请内存
          } catch (OutOfMemoryError x) {
              Bits.unreserveMemory(size, cap);
              throw x;
          }
          unsafe.setMemory(base, size, (byte) 0);
          if (pa && (base % ps != 0)) {
              // Round up to page boundary
              address = base + ps - (base & (ps - 1));
          } else {
              address = base;
          }
          cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); //通过虚引用,来实现直接内存的释放,this为虚引用的实际对象
          att = null;
      }
      
    4. 这里调用了一个Cleaner的create方法,且后台线程还会对虚引用的对象监测,如果虚引用的实际对象(这里是DirectByteBuffer)被回收以后,就会调用Cleaner的clean方法,来清除直接内存中占用的内存

      public void clean() {
              if (remove(this)) {
                  try {
                      this.thunk.run(); //调用run方法
                  } catch (final Throwable var2) {
                      AccessController.doPrivileged(new PrivilegedAction<Void>() {
                          public Void run() {
                              if (System.err != null) {
                                  (new Error("Cleaner terminated abnormally", var2)).printStackTrace();
                              }
      
                              System.exit(1);
                              return null;
                          }
                      });
                  }
              }
      }
      
    5. 对应对象的run方法

      public void run() {
          if (address == 0) {
              // Paranoia
              return;
          }
          unsafe.freeMemory(address); //释放直接内存中占用的内存
          address = 0;
          Bits.unreserveMemory(size, capacity);
      }
      
  • 直接内存的回收机制总结
    • 使用了Unsafe类来完成直接内存的分配回收,回收需要**主动调用freeMemory()**方法
    • ByteBuffer的实现内部使用了Cleaner(虚引用)来检测ByteBuffer。一旦ByteBuffer被垃圾回收,那么会由ReferenceHandler来调用Cleaner的clean方法调用freeMemory来释放内存

8. 面试题

百度 三面:说一下JVM内存模型吧,有哪些区?分别干什么的?

蚂蚁金服: Java8的内存分代改进 JVM内存分哪几个区,每个区的作用是什么? 一面:JVM内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个survivor区? 二面:Eden和survior的比例分配

小米: jvm内存分区,为什么要有新生代和老年代

字节跳动: 二面:Java的内存分区 二面:讲讲vm运行时数据库区 什么时候对象会进入老年代?

京东: JVM的内存结构,Eden和Survivor比例。 JVM内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为Eden和survivor。

天猫: 一面:Jvm内存模型以及分区,需要详细到每个区放什么。 一面:JVM的内存模型,Java8做了什么改

拼多多: JVM内存分哪几个区,每个区的作用是什么?

美团: java内存分配 jvm的永久代中会发生垃圾回收吗? 一面:jvm内存分区,为什么要有新生代和老年代?

你可能感兴趣的:(JVM,jvm,java,面试)