java虚拟机系列(一)- 内存区域详解

java虚拟机系列(一)- 内存区域详解

  • java虚拟机系列(一)- 内存区域详解
    • 一、运行时数据区
      • 1.1程序计数器
      • 1.2 java虚拟机栈
      • 1.3 本地方法栈
      • 1.4 java堆
      • 1.5 方法区
      • 1.6 运行时常量池
      • 1.7 直接内存
    • 二、hotspot虚拟机对象探秘
      • 2.1 对象的创建
      • 2.2 对象的内存布局
      • 2.3 对象的访问定位
    • 三、 OutOfMemoryError异常
      • 3.1 堆溢出
      • 3.2 虚拟机栈和本地方法栈
      • 3.3 方法区和运行时常量池溢出
      • 3.4 本机直接内存溢出

java虚拟机系列(一)- 内存区域详解

一、运行时数据区

java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,java虚拟机所管理的内存主要包括以下几个运行时数据区域,如下图所示。
java虚拟机系列(一)- 内存区域详解_第1张图片
其中,方法区和堆是所有线程共享区域,程序计数器、虚拟机栈、本地方法栈为为线程独有区域。

1.1程序计数器

程序计数器是一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器,它是线程私有的。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令

如果线程执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是native方法,这个计数器的值则为空。此内存区域是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

我们写好的java代码编译成.class后缀的字节码文件后,这个.class字节码文件才是计算机能够读懂的代码,这样的代码大概如下。

D:\java\myproject2\demo\target\classes\com\example\demo\demo>javap -c Demo_Integer.class
Compiled from "Demo_Integer.java"
public class com.example.demo.demo.Demo_Integer {
  public com.example.demo.demo.Demo_Integer();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: aconst_null
       1: astore_1
       2: aload_1
       3: invokevirtual #2                  // Method java/lang/Integer.intValue:()I
       6: bipush        100
       8: iadd
       9: istore_2
      10: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      13: iload_2
      14: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
      17: return
}

像”0: aload_0“、”1: astore_1“这类东西就是字节码指令,它对应着一条一条的机器指令,计算机只有读到这种机器码指令,才知道具体要干什么。

程序计数器就是用来记录当前线程执行的字节码指令位置的,每个线程都有自己的程序计数器,专门记录当前这个线程目前执行到了哪一条字节码指令。大概如下图所示:
java虚拟机系列(一)- 内存区域详解_第2张图片

1.2 java虚拟机栈

与程序计数器一样,java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是java方法执行的内存模型:每个方法执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至完成的过程,就对应一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表存放了编译期可知的各种基本类型和引用类型,它不等同于对象,可能是指向对象起始地址的引用指针。局部变量表所需的内存空间在编译期间完成分配,在方法运行期间不会改变局部变量表的大小。

比如有如下代码:
java虚拟机系列(一)- 内存区域详解_第3张图片
如果一个线程调用了sayHello方法,就会为该方法创建一个栈帧,栈帧中的局部变量表中会有“name”这个局部变量。

接着“sayHello”方法调用了另外一个“greeting”方法,这时会给“greeting”方法又创建一个栈帧,压入线程的Java虚拟机栈里面,“greeting”方法的局部变量里表里又会有一个“greet”变量。

当“greeting”方法执行完毕,“greeting”方法对应的栈帧就会从java虚拟机栈中出栈。

如果“sayHello”方法也执行完毕,那么“sayHello”方法对应的栈帧就也会从java虚拟机栈中出栈。
java虚拟机系列(一)- 内存区域详解_第4张图片
如果方法递归调用死循环,线程申请的栈深度大于虚拟机栈所允许的深度,将会抛出StackOverflowError(栈溢出)异常,如果java虚拟机栈动态扩展无法申请到足够的内存,会抛出OutOfMemoryError(内存溢出)异常。

1.3 本地方法栈

本地方法栈与虚拟机栈所发挥的作用非常相似,区别就是虚拟机栈为虚拟机执行java方法服务,而本地方法栈则为虚拟机执行native方法服务。

1.4 java堆

java堆是java虚拟机所管理的内存中最大的一块,java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都是在此分配内存。

java堆是垃圾收集器管理的主要区域,因此很多时候也称之为“GC堆”。从内存回收的角度看,现在收集器基本都是采用分代收集算法,所以java堆还可以细分为新生代和老年代。

根据java虚拟机规范规定,java堆可以处于物理上不连续的内存空间中,但是在逻辑上必须是连续的。如果在堆中没有内存完成实例对象分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError(内存溢出)异常。

1.5 方法区

方法区与java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息(类的版本、字段、 接口、方法等描述信息)、常量、静态变量、即时编译器编译后的代码等数据。也称之为“非堆”。

由于大多数java程序都是在HotSpot虚拟机上开发和部署,所以很多人把方法区称之为“永久代”,实质上两者并不等价,仅仅是因为HotSpot虚拟机团队把GC分代收集扩展至方法区(使用永久代来实现方法区),这样HotSpot的垃圾收集器可以像管理java堆一样管理这部分内存。其它的虚拟机(BEA JRocket、IBM J9)是不存在永久代的概念的。

值得一提的是,JDK1.7中已经把永久代的字符串常量池移出到java堆中。而到了JDK1.8已经没有了永久代的概念,取而代之的是元空间(Metaspace)。

1.6 运行时常量池

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

一般来说,运行时常量池除了保存Class文件中描述的符号引用外,还会保存翻译出来的直接引用。

运行时常量池相对于Class文件常量池的另外一个特征是具备动态性。也就是说常量可以在编译期产生进入Class文件常量池从而再进入运行时常量池,也可以在运行期产生直接进入运行时常量池。常用的例如String类的intern()方法,编译器会将字符串对象里面的字符串添加到字符串常量池中(这也从侧面说明了字符串常量池也是运行时常量池的一部分)。

由于运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError(内存溢出)异常。

值得一提的是,JDK1.7中已经把永久代的字符串常量池移出到java堆中,而到java1.8中已经将符号引用转移到了本地堆(native heap)中、字面量转移到了java堆(java heap)中,类的静态变量转移到了java堆(java heap)。

import org.springframework.data.redis.core.RedisTemplate;
public class Test{

   public static void main() {
     String s=”adc”;

     System.out.println(“s=”+s);

   }

}

如上图所示。

  • 字面量: 是指由字母,数字等构成的字符串或者数值,它只能作为右值出现,等号右边的值,如上述代码中的 “abc” 就是字面量。
  • 符号引用: 编译期的字符串变量等,如上述代码在编译时S会被解析成符号引用。(符号引用以一组符号来描述所引用的目标, 符号可以是任何形式的字面量, 只要使用时能够无歧义的定位到目标即可,如上述代码中就使用了符号引用“org.springframework.data.redis.core.RedisTemplate”来表示RedisTemplate类的地址)
  • 直接引用: 运行期的字符串变量等,如上述代码在运行时S会被解析成直接引用(指向对象、方法、类变量的指针)。

1.7 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。本机直接内存的分配不会受到java堆大小的限制,但会受到本机总内存的限制。

二、hotspot虚拟机对象探秘

以常用的HotSpot虚拟机和常用的内存区域java堆为例,深入探讨HotSpot虚拟机在java堆中对象分配、布局和访问的全过程。在一些特定的场景下会用到如NIO类的一些操作。

2.1 对象的创建

package com.example.demo.javasebase;

import com.example.demo.demo.Person;

/**
 * Create by likaihai 2019/5/6
 */
public class DemoNewPerson
{
    public static void main(String[] args)
    {
        Person person = new Person();
        System.out.println(person);
    }
}

如上述代码,当虚拟机遇到一条new指令时,首先检查这个指令的参数Person能否在常量池中定位到这个Person类的符号引用“com.example.demo.demo.Person”,并检查符号引用所代表的类Person是否被加载、解析和初始化过。如果没有就先执行初始化过程。

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存大小在类加载完成后即可完全确定,此时在java堆中划分出一块确定的内存大小。

内存的分配方法又分为两种:指针碰撞、空闲列表

  • 指针碰撞: 如果java堆中内存的规整(连续)的,已用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。

  • 空闲列表: 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。

  • 小结: 选择哪种内存分配方式由java堆是否规整决定,而java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。在采用Serial、ParNew等带compact(压缩)过程的垃圾收集器时,java堆会被压缩整理为规整的,此时系统采用指针碰撞的分配方式来分配内存,而使用CMS这种基于mark-sweep(标记清除)算法的垃圾收集器时,通常采用空闲列表的方式来分配内存。

由于对象的创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,例如可能指针正在给对象A分配内存,还没来得及修改,对象B又同时使用原来的指针来分配内存的情况。

解决虚拟机频繁创建对象线程不安全的两种方案:同步处理、线程划分处理

  • 同步处理: 对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS失败重试的方式保证更新操作的原子性。
  • 线程划分处理: 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer TLAB),哪个线程需要分配内存就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定,虚拟机是否需要使用TLAB可以通过-XX:+/-UseTLAB参数来设定。

对象内存分配完成后的处理

(1)对象分配完成后,虚拟机将分配到的内存空间都初始化为零值,如对象的实例数值字段为设为0值(不包括对象头)。

(2)接下来对对象进行一些必要的设置,如对象属于哪个类、如何找到类的元信息、对象的哈希码、对象的GC分代年龄等,这些信息存放在对象的对象头中。

(3)此时从虚拟机的角度来看,一个新的对象已将创建完成了,但是从java程序的角度来看,对象的创建才刚刚开始————方法还没有执行,所有的字段值都还为零值。

java代码如下:

package com.example.demo.javasebase;

import com.example.demo.demo.Person;

/**
 * Create by likaihai 2019/5/6
 */
public class DemoNewPerson
{
    public static void main(String[] args)
    {
        Person person = new Person();
        System.out.println(person);
    }
}

.class文件的代码如下:

D:\java\myproject2\demo\target\classes\com\example\demo\javasebase>javap -c DemoNewPerson.class
Compiled from "DemoNewPerson.java"
public class com.example.demo.javasebase.DemoNewPerson {
  public com.example.demo.javasebase.DemoNewPerson();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class com/example/demo/demo/Person
       3: dup
       4: invokespecial #3                  // Method com/example/demo/demo/Person."":()V
       7: astore_1
       8: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
      11: aload_1
      12: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
      15: return
}

从上方Class代码反编译后的代码中可知道,在执行new指令之后,接着执行invokespecial指令(也就是执行方法),把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

2.2 对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)对齐填充(Padding)

  • 对象头(Header): 对象头又包括两部分信息

    • 第一部分存储对象自身运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳、死亡状态标记等。
    • 第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。另外如果对象是一个java数组,那在对象头中还必须有一块用于记录数组长度的数据。
  • 实例数据: 对象真正存储的有效的信息,也是程序代码中所定义的各种类型的字段内容,无论是从父类继承还是子类定义都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在java源码中的定义顺序的影响。

  • 对齐填充: 并不是必然存在的,也没有特别的含义、只起到了占位符的作用,因为HotSpot虚拟机规定对象的大小必须是8字节的整数倍,因为对象头的大小正好是8字节的整数倍,所以当实例数据部分没有对齐时,就需要通过对齐填充来补全。

2.3 对象的访问定位

java程序需要通过栈上的reference数据来操作堆上的具体对象,虚拟机规范规定reference类型是一个指向对象的引用,并没有规定这个引用通过何种方式去访问堆中对象的具体位置。目前访问对象的主流方式主要有两种:
句柄、直接指针。

  • 句柄: 在java堆中会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄有包含了对象的实例数据和类型数据(对象所属的类的信息)各自的地址信息,如下图所示。
    java虚拟机系列(一)- 内存区域详解_第5张图片
  • 指针指针: reference中存储的就是对象地址,对象中存放对象类型数据的地址,如下图所示。
    java虚拟机系列(一)- 内存区域详解_第6张图片
  • 小结

(1) 句柄访问的优势: reference中存储的是稳定的句柄地址,在垃圾收集时对象被移动只会改变句柄中到对象实例数据的指针,而不会改变reference引用到句柄的地址。
(2)直接访问的优势:
速度更快,节省了一次指针定位的时间开销,由于对象在java中访问频繁,因此这类开销积少成多也是一种非常可观的执行成本。hotspot虚拟机就是使用这种方式进行对象访问的。

三、 OutOfMemoryError异常

在java虚拟机规范中,除了程序计数器外,虚拟机其它运行区域都有可能发生OutOfMemoryError异常。

3.1 堆溢出

java程序中,只要不断的创建对象,并且保证GC Roots(一系列的根对象,在下一篇垃圾收集文章中将会详细讲解)

  • -Xms: 初始化堆内存大小,不包括持久代的内存,默认为物理内存的1/64,不会超过1G
  • -Xmx: 最大堆内存大小,不包括持久代内存,默认为物理内存的1/4

3.2 虚拟机栈和本地方法栈

hotspot虚拟机不区分虚拟机栈和本地方法栈,因此,虽然-Xoss参数(设置本地方法栈大小)存在,但是实际上是无效的,栈容量只与-Xss参数设定有关。

  • -Xss: 设置每个线程栈容量大小,一般情况下256K是足够了

关于虚拟机栈和本地方法栈,虚拟机规范中描述了两种异常如下。

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError(栈溢出)异常。
  • 如果虚拟机在扩展时无法申请到足够的内存空间,将会抛出OutOfMemoryError异常(实际场景中在单线程环境下很难产生这种异常)。

因为操作系统分配给每个进程的内存是有限制的,例如32位系统windows限制为2G。

虚拟机栈和本地方法栈内存空间的大小 = 系统分配给虚拟机进程的内存 - (最大堆容量Xmx + 最大方法区容量MaxPermSize)

这样一来,如果每个线程占用的栈容量越大,那么可建立的线程数量自然就越少。

3.3 方法区和运行时常量池溢出

1.6版本之前,运行时常量池分配在永久代内,我们可以通过-XX:PermSize和XX:MaxPermSize限制方法区大小,从而限制其中运行时常量池容量。

  • -XX:PermSize: 设置方法区内存初始值,默认是物理内存的1/64
  • -XX:MaxPermSize: 设置最方法区内存的最大值

由于方法区存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。当方法区Class的数量很多的时候就会导致OutOfMemoryError异常,比如操作字节码生成大量的动态类。

方法区溢出也是一种常见的内存溢出异常、一个类要被垃圾回收期回收掉,判定条件是比较苛刻的。

3.4 本机直接内存溢出

本机直接内存容量可通过-XX:MaxDirectMemorySize来设置,如果不设置,则默认为java堆(-Xmx指定)最大值一样。

直接内存导致的内存溢出,在heap dump文件中不会看见明显的异常,如果OOM内存溢出之后dump文件很小,但是程序中又直接或间接的使用了NIO,那就很有可能是本机直接内存溢出的问题。


  • 参考资料:https://mp.weixin.qq.com/s/zDYphlLpp0hrFbyJbw7umQ
  • 参考资料:http://www.cnblogs.com/paddix/p/5309550.html
  • 参考资料:https://blog.csdn.net/BraveLoser/article/details/82500474
  • 参考资料:《深入理解java虚拟机》

你可能感兴趣的:(jvm系列)