JVM(七) - Jvm内存模型

一、Jvm介绍

1、JVM内存模型图:

JVM = 类加载器(classloader) + 运行时数据区域(runtime data area) + 执行引擎(execution engine)

类加载器:通过过全限定名加载二进制数据class文件到Jvm内存中,具体原理见类加载文章。

JVM(七) - Jvm内存模型_第1张图片

2、Jvm运行时数据区(Runtime Data Area)

Jvm虚拟机在Java程序执行时,会把Jvm内存划分为若干不同区域。这些区域各有用途,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。

JVM(七) - Jvm内存模型_第2张图片

3、JDK1.8内存结构 

JVM(七) - Jvm内存模型_第3张图片

JDK1.8 中,永久代(方法区)已完全被元数据区(Meatspace)所取代

  • 方法区Method Area是JVM规范的逻辑概念,在JDK1.7中对应永久代,在JDK1.8中对应元数据区;
  • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。
  • 不过元空间与永久代最大的区别在于:永久代大小是需要设定的,所以会存在永久代的内存溢出;元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下元空间的大小仅受电脑物理内存限制。

直接内存/本地内存(Direct Memory):并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,属于堆外内存。这部分内存也被频繁地使用,如 NIO 类,当本地内存不足时也会导致OutOfMemoryError 异常出现。

为什么JDK1.8要移除永久代 ,使用metaspace代替?

官方说明:移除永久代是为融合HotSpot JVM与 JRockit VM而做出的努力,因为JRockit没有永久代。

Oracle收购BEA获得了JRockit的所有权后,准备把JRockit中的优秀功能,譬如Java Mission Control管理工具,移植到HotSpot 虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到HotSpot未来的发展,在JDK 6的 时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了 JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中

实际应用:

  • 永久代内存经常不够用或发生内存泄露,爆出异常java.lang.OutOfMemoryError: PermGen。
  • 类方法信息比较难确定其大小,所以放在直接内存中;
  • 永久代中可GC回收效益低;

 

4、Java内存模型(JMM)

即Java内存模型(Java Memory Model),

  • JMM本身是一种抽象的概念,是Jvm的一种规范;
  • 规范了Jvm与计算机内存(RAM)是如何协同工作的,定义Jvm在计算机内存(RAM)中的工作方式;
  • 这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式;
  • JMM中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问;
  • JVM是多线程的,程序运行的实体是线程,每个线程都会分配自己的工作内存(栈),为线程私有空间;
  • 线程对变量读写等操作必须在工作内存中进行,所以线程想操作变量时首先要将变量从主内存拷贝到自己的工作内存内操作,操作完后再将变量刷写回主内存。
  • 所以线程不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝;
  • 也叫Java多线程内存模型,和CPU缓存模型相似,是基于CPU缓存模型建立,屏蔽了不同硬件和操作系统的访问差异,保证了Java程序在各种平台下对内存的访问一致的机制及规范

JVM(七) - Jvm内存模型_第4张图片

对象是存储在堆中,线程栈(虚拟机栈)中存储的是对象的引用,对象不也是在堆中?

当线程运行到引用对象数据时会根据局部表中的对象引用地址去找到主存中的真实对象,然后将对象拷贝到自己的工作内存再操作,当所操作的对象是一个大对象时(1MB+)并不会完全拷贝,而是将自己操作和需要的那部分成员拷贝到自己的线程栈中。

JMM与JVM内存区域的划分区别?

  • JMM描述的是一组规则,JVM通过这组规则控制程Java中各个变量在共享数据区域和私有数据区域的访问方式;
  • JMM与JVM内存唯一相似点,都存在共享数据区域和私有数据区域;
  • JMM中主内存属于共享数据区域,也被描称堆内存,从某个程度上讲应该包括了堆和方法区;
  • 主内存: 主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中(除开开启了逃逸分析和标量替换的栈上分配和TLAB分配),不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量;由于是共享数据区域,多条线程对同一个变量进行非原子性操作时可能会发现线程安全问题。
  • JMM工作内存是线程私有数据区域,也被称为线程栈,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈;
  • 工作内存: 主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,当然也包括了字节码行号指示器、相关Native方法的信息;
  • 工作内存是每个线程的私有区域,因此不能被其他线程访问,所以线程间的通信必须通过主内存来完成,因此存储在工作内存的数据不存在线程安全问题;

二、JVM内存模型

JVM内存(JVM运行时数据区)分为五大区域:

1、程序计数器(Program Counter Register)

存放当前线程执行到的字节码的行号

  • 每个程序计数器只用来记录一个线程的行号,所以是线程私有,它的生命周期与线程相同;
  • 字节码解释器工作时,会通过改变这个计数器的值来取下一条语句指令,所以执行引擎会去修改线程的程序计数器
  • 执行的是一个Java方法,则计数器记录虚拟机字节码指令地址;
  • 执行的是一个本地(native,由C语言编写完成)方法,则计数器的值为Undefined;
  • 程序计数器内存区域是虚拟机规范中唯一没有OutOfMemoryError的区域

为什么需要程序计数器?为什么程序计数器为线程私有呢?

对于一个处理器,是通过不断切换CPU来实现同时运行的效果。在一个确定的时刻都只会执行一个线程中的指令,一个线程中有多个指令,为了线程切换可以恢复到正确执行位置,每个线程都需有独立的一个程序计数器,不同线程之间的程序计数器互不影响,独立存储。

2、虚拟机栈(VM Stacks)

Java方法执行和调用的内存模型,存储Java方法执行的相关数据

  • 也是线程私有的,生命周期和线程保持一致
  • 一个线程对于一个虚拟机栈,当前CPU调度的那个线程叫做活动线程。
  • 每个方法被执行时会创建一个栈帧(Stack Frame),进入虚拟机栈,一个栈帧对应一个方法;
  • 每个方法结束对应着栈帧的出栈,入栈表示被调用,出栈表示执行完毕或者返回异常;
  • 栈帧是一个数据结构,所以用于存储局部变量表(Local Variable)操作数栈(Operand Stack)动态链接(Dynamic Link)方法出口(Return Address)等信息;
  • 活动线程的虚拟机栈里最顶部的栈帧代表当前正在执行的方法,这个栈帧也被叫做"当前栈帧"。
  • 在同一时刻、同一条线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。

JVM(七) - Jvm内存模型_第5张图片

 栈帧的大小是什么时候确定的?

编译程序代码的时候,就已经确定了局部变量表和操作数栈的大小,而且在方法表的Code属性中写好了。所以调用一个方法时,这个方法在栈中需要分配多大的局部变量空间是完全确定的,不会受到运行期数据的影响。

Java虚拟机栈可能出现两种类型的异常:

  • StackOverflowError:线程请求的栈深度大于虚拟机允许的栈深度时抛出异常。
  • OutOfMemoryError:虚拟机栈空间可以动态扩展,当动态扩展是无法申请到足够的空间时异常。

3、本地方法栈(Native Method Stacks)

虚拟机栈是为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native 方法(C/C++语言)服务。本地方法栈中栈帧和Java虚拟机的栈帧、以及栈的指针是不一样的,所以单独开辟一块空间,供本地方法使用。

  • JVM对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。
  • Sun HotSpot 虚拟机直接就把本地方法栈和虚拟机栈合二为一;
  • 本地方法栈区也会抛出StackOverflowErrorOutOfMemoryError异常。

4、Java堆(Java Heap)

用来存放对象实例,几乎所有的对象实例都在这里分配内存

  • 所有线程共享的,在虚拟机启动时创建;
  • Java堆是Jvm虚拟机所管理的内存中最大的一块;
  • Java堆存放几乎所有对象实例,这些对象通过new、newarray、 anewarray 和 multianewarray 等指令创建,不需要显示的释放对象内存;
  • Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做"GC堆";
  • 从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;新生代又有Eden空间、From Survivor空间、To Survivor空间三部分
  • Java 堆不需要连续内存,像磁盘空间只要逻辑是连续的即可。

Java虚堆可能出现的异常:

  • OutOfMemoryError:Java堆可以通过动态扩展其内存,增加失败会抛出 OutOfMemoryError 异常。

为什么堆并不是存储着全部对象实例?

虽然JVM虚拟机规范对堆的描述是:所有对象实例及数组都要在堆上分配内存。但随着JIT编译器的发展和逃逸分析技术的成熟,所以并不全是这样。

  • 即时编译器:可以把Java字节码,包括需要被解释的指令的程序转换成可以直接发送给处理器的指令的程序。
  • 逃逸分析:通过逃逸分析来决定某些实例或变量是否要在堆中进行分配,如果JVM开启了逃逸分析,即可将这些变量直接在栈上进行分配,而非堆上进行分配。这些变量的指针可以被全局所引用,或者其其它线程所引用。

5、方法区(Method Area)

用于存放已被加载的类信息(名称、修饰符、类中的Field信息、类中的方法信息等)、常量池(类中定义为final类型)、静态变量(类中定义的static类型)、即时编译器编译后的代码等数据。

  • 所有线程共享的,在虚拟机启动时创建;
  • 除了跟堆一样可以空间不连续,定义和扩展空间,还可以选择不实现垃圾收集
  • 运行时常量池是方法区的一部分;
  • 调用类对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区;
  • 在一定条件下它也会被GC,方法区进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现,HotSpot 虚拟机把它当成永久代(Permanent Generation)来进行垃圾回收;
  • 不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。

运行时常量池(Runtime Constant Pool)

用于存储编译期Class文件就生成的字面常量、符号引用、翻译出来的直接引用,会在类加载后被放入这个区域;除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern(),这部分常量也会被放入运行时常量池。

 // 可以通过javap -v 类名查看类文件的常量池

public class ShowJOL {
    // a为常量,在javac编译时就将常量
    public static final int a = 3;
    public static int b = 4;
    
    public static void main(String[] args) {}
}


// javap -v showJOL
Classfile /Users/shaotuo/java/java/target/classes/edward/com/ShowJOL.class
  Last modified 2022-3-16; size 507 bytes
  MD5 checksum 127d5263043487cdb8f7d956d5eef3f3
  Compiled from "ShowJOL.java"
public class edward.com.ShowJOL
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
// 常量池部分
Constant pool:
   #1 = Methodref          #4.#24         // java/lang/Object."":()V
   #2 = Fieldref           #3.#25         // edward/com/ShowJOL.b:I
   #3 = Class              #26            // edward/com/ShowJOL
   #4 = Class              #27            // java/lang/Object
   #5 = Utf8               a
   #6 = Utf8               I
   #7 = Utf8               ConstantValue
   // 常量a的值,直接放入常量池
   #8 = Integer            3
   #9 = Utf8               b
  #10 = Utf8               
  #11 = Utf8               ()V
  #12 = Utf8               Code
  #13 = Utf8               LineNumberTable
  #14 = Utf8               LocalVariableTable
  #15 = Utf8               this
  #16 = Utf8               Ledward/com/ShowJOL;
  #17 = Utf8               main
  #18 = Utf8               ([Ljava/lang/String;)V
  #19 = Utf8               args
  #20 = Utf8               [Ljava/lang/String;
  #21 = Utf8               
  #22 = Utf8               SourceFile
  #23 = Utf8               ShowJOL.java
  #24 = NameAndType        #10:#11        // "":()V
  #25 = NameAndType        #9:#6          // b:I
  #26 = Utf8               edward/com/ShowJOL
  #27 = Utf8               java/lang/Object
{
  public static final int a;
    descriptor: I
    flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: int 3

  public static int b;
    descriptor: I
    flags: ACC_PUBLIC, ACC_STATIC

  public edward.com.ShowJOL();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."":()V
         4: return
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Ledward/com/ShowJOL;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 13: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       1     0  args   [Ljava/lang/String;
    
  // 类的构造器,clinit<>初始化函数,也不会将常量a的赋值代码放入Code,不会参与类的初始化
  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: iconst_4
         1: putstatic     #2                  // Field b:I
         4: return
      LineNumberTable:
        line 6: 0
}
SourceFile: "ShowJOL.java"

方法区的各版本变动:

  • JDK1.7之前HotSpot 使用永久代实现方法区;HotSpot 使用 GC 分代实现方法区带来了很大便利;
  • JDK1.7开始HotSpot 开始移除永久代。其中符号引用(Symbols)被移动到 Native Heap中,字符串常量和类引用被移动到 Java Heap中
  • JDK1.8 中,永久代才完全被元空间(Meatspace)所取代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:永久代大小是需要设定的,所以存在永久代的内存溢出元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下元空间的大小仅受本地物理内存限制;

JVM(七) - Jvm内存模型_第6张图片

 JVM(七) - Jvm内存模型_第7张图片

JVM(七) - Jvm内存模型_第8张图片 

测试JDK1.8中的方法区中的字符串常量是在什么内存中?

在JDK1.7前,字符串常量是存在永久代中;但JDK1.7开始,字符串常量和类引用被移动到 Java Heap中。所以可用通过测试常量池的内存溢出来判断,是堆溢出还是永久代溢出。

// String类下面的原生方法
// 作用是如果字符串常量池已经包含一个等于这个String对象的字符串,则返回代表池中的这个字符串的String对象
public native String intern();

// 测试程序
public class MethodAreaTest {
    public static void main(String[] args) {
        // list数组为了保持着引用 防止full gc回收常量池的String对象
        List list = new ArrayList();

        int i = 0;
        while(true){
            System.out.println(i);
            // String.valueOf(),将i字符串化,切会放入常量池中
            list.add(String.valueOf(i++).intern());
        }
    }
}

// 运行main方法设置相关JVM内存参数
// 设置Java堆的大小为1m,不可扩容
// UseGCOverheadLimit关闭GC
-Xmx1m -Xms1m -XX:-UseGCOverheadLimit

执行结果为Java Heap OOM异常:

JVM(七) - Jvm内存模型_第9张图片

三、JVM内存参数配置

JMM内存占用图:

JVM(七) - Jvm内存模型_第10张图片 

新生代与老年代默认比例为Young:Old = 1 : 2

新生代中默认Eden : from : to = 8 : 1 : 1

JVM(七) - Jvm内存模型_第11张图片

 

相关JVM参数配置:

  • -Xms:设置堆的最小空间大小
  • -Xmx:设置堆的最大空间大小
  • -Xmn:设置年轻代大小
  • -XX:NewSize设置新生代最小空间大小
  • -XX:MaxNewSize设置新生代最大空间大小
  • -XX:NewRatio设置老年代与新生代的比例,默认为2,则Old:Young = 2:1
  • -XX:SurvivorRatio设置Eden区与一个Survivor(两个Survivor是相同大小)的比例,默认为8,则Eden:from:to=8:1:1
  • -XX:PermSize设置永久代最小空间大小,JDK1.8便失去意义
  • -XX:MaxPermSize设置永久代最大空间大小,JDK1.8便失去意义
  • -XX:MetaspaceSize设置元数据区初识空间大小
  • -XX:MaxMetaspaceSize设置元数据区最大空间大小
  • -Xss:设置每个线程虚拟机栈及堆栈的大小
  • -XX:+UseParallelGC : 选择垃圾收集器为并行收集器,此配置仅对年轻代有效。即上述配置下,年轻代使用并发收集,而年老代仍旧使用串行收集。
  • -XX:ParallelGCThreads=20:配置并行收集器的线程数,即:同时多少个线程一起进行垃圾回收。此值最好配置与处理器数目相等。
-Xmx3550m -Xms3550m -Xmn2g  -Xss128k
// 设置JVM最大可用内存为3550M
// 设置JVM最小内存也为3550M,-Xms与-Xmx相同,保证JVM堆内存不可变,可避免每次垃圾回收完成后JVM重新分配内存
// 设置年轻代大小为2G
// JDK1.7之前 堆大小=年轻代大小+年老代大小+永久代大小,永久代一般固定大小为64m,
// JDK1.7之后,永久代完全被元空间(Meatspace)所取代,放在本地内存中
// 设置每个线程的堆栈大小128K。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K;
// 操作系统对一个进程内的线程数是有限制的,在3000~5000 左右

 

四、内存溢出与内存泄漏

内存泄漏:分配出去的内存回收不了;

内存溢出:指系统内存不够用了;

1、堆溢出

可以分为:内存泄漏和内存溢出,这两种情况都会抛出OutOfMemoryError:java heap space异常。

  • 内存泄漏是指对象实例在新建和使用完毕后,仍然被引用,没能被垃圾回收释放,一直积累直到没有剩余内存可用。
public class OOMTest {

    public static void main(String[] args) {
        List list = new ArrayList<>();

        while (true) {
            list.add(UUID.randomUUID());
        }
    }
}
// -Xms200m -Xmx200m -Xmn200m

JVM(七) - Jvm内存模型_第12张图片

  • 内存溢出是指新建一个实例对象时,实例对象所需占用的内存空间大于堆的可用空间。
public class OOMTest {

    public static void main(String[] args) {
        List list = new ArrayList<>();
        list.add(new byte[1024*1024*1000]);
    }
}

 要解决这个异常,一般先通过内存映像分析工具对堆转储快照分析,确定内存的对象是否是必要的,即判断是 内存泄露 还是 内存溢出。如果是内存泄露,可以进一步通过工具查看泄露对象到GC Roots的引用链,比较准确地定位出泄露代码的位置。如果是内存溢出,可以调大虚拟机堆参数,或者从代码上检查是否存在某些对象生命周期过长的情况。

 

2、栈溢出

虚拟机栈(VM Stack)存放主要是栈帧( 局部变量表、操作数栈、动态链接、方法出口信息 ),无论虚拟机栈还是本地方法栈都为线程私有,所以栈溢出和线程内存大小相关,其中有两类:

SOF(StackOverflowError异常)

线程内方法调用层次太深,内存不够新建栈帧。抛出java.lang.StackOverflowError错误。一般由于方法的嵌套调用层次太多,比如递归调用,使得虚拟机栈中不断创建栈帧导致,线程栈中的所有栈帧的大小的总和大于-Xss设置的值,从而产生StackOverflowError溢出异常。

public class SOFTest {
    public static void method() {
        method();
    }

    public static void main(String[] args) {
        method();
    }
}

JVM(七) - Jvm内存模型_第13张图片

 OOM

当Java 程序启动一个新线程时,若没有足够的空间为该线程分配Java栈(一个线程Java栈的大小由-Xss设置决定),JVM将抛出OutOfMemoryError异常。

public class OOMTest {
    public static void main(String[] args) {
        while (true) {
            new Thread(() -> {
                try {
                    Thread.sleep(1000000);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

JVM(七) - Jvm内存模型_第14张图片

 

你可能感兴趣的:(JVM系列,jvm,java,开发语言)