【转】可能是把Java内存区域讲的最清楚的一篇文章

概述

对于 Java 程序员来说,在虚拟机自动管理内存的机制下,不再需要想 C/C++ 那样为一个 new 操作写对应的 delete/free 操作,不容出现内存泄漏和内存溢出的问题。正是因为 Java 程序员把内存控制权交给了 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查就是一项非常艰巨的任务。

运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分为若干个不同的数据区域:

这些组成部分一些是线程私有的,一些是线程共享的。

线程私有的:

  • 本地计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 方法区
  • 直接内存

程序计数器

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

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”内存。

从上面的介绍我们知道线程计数器主要有两个作用:

  1. 字节码解释器通过改变程序计数器一次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的声明周期随着线程的创建而创建,随着线程的结束而结束。

Java 虚拟机栈

与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型。

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈就是现在所说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。(实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)

局部变量表主要存放了编译可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象其实地址的引用指针,也可能是指向一个对标对象的句柄或者其他与此对象相关的位置)。

Java 虚拟机栈会出现两种异常:

  • StackOverFlowError:
    若 Java 虚拟机栈内存大小不允许动态扩展,那么当前线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就排除该异常。
  • OutOfMemeryError:
    若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈内存用完了,无法再动态扩展了,此时排除该异常。

Java 虚拟机栈是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈为虚拟机使用到的 Native 方法服务。在 HotSpot 虚拟机中和 Java 虚拟机合二为一。

本地方法执行的时候,在本地方法栈也会创建一个栈帧,用于存放本地方法的局部表量表、操作数栈、动态链接、出口信息。

方法执行完毕后的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。

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

Java 堆是垃圾回收器管理的主要区域,因此也被称作 GC 堆,从垃圾回收的角度,由于现在收集器基本都采用分代手机算法,所以 Java 堆还可以细分为:

  • 新生代
  • 老年代

新生代再细致一点划分有:

  • Eden 控件(伊甸园代)
  • 幸存者代(Survivor)

幸存者代再细致一点划分有:

  • From Survivor
  • To Survivor

进一步划分的目的是更好的回收内存,或者更快的分配内存。

大多数情况下,对象创建会直接在伊甸园区分配。当伊甸园区内存已满的时候,会进行第一次 GC 操作,称之为 Minor GC,在这次 GC 操作之后,不能被回收的对象会被放入到幸存者区

幸存者区被划成平分的二等分(默认二等分,可配置),每次伊甸园区中不被回收的对象都会放入到幸存者区域划分的两个区域中的其中一个。
那么会放到幸存者区中两个分区中的哪一个?是这样的,实际上 Minor GC 是在整个新生代区域中进行的,扫描到伊甸园区有存活对象,会选择一个空的幸存者区存放,同时将另一个幸存者区中的存活对象也存放到这个幸存者区中。

也就是说,总有一个幸存者区是空着的。

每个对象都有一个 age 属性,每一次 Minor GC 过后留下来的存活对象的 age 值都会 +1,当这个 age 属性达到设定数值(默认为 15)后,这些存活对象会被转移到老年代中。

注意,一些大的对象,初始化就会进入到老年代中。

老年代中又是如何进行 GC 的呢?主要有以下几种情况:

  • 调用 System.gc() 方法,系统会建议进行 GC 操作,但是不一定会执行
  • 老年代空间不足
  • 方法区空间不足

在 JDK 1.8 中移除了整个永久代,取而代之的是一个叫做 元空间(MetaSpace)的区域(永久代使用的是 JVM 的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。

推荐阅读: Java8内存模型—永久代 PermGen 和元空间 Metaspace

方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的就是与 Java 堆区分开来。

HotSpot 虚拟机中方法区也常备称为 永久代,本质上二者并不等价。仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已。这样 HotSpot 虚拟机的垃圾回收器就可以像管理 Java 堆一样来管理这部分内存了。但这并不是一个好主意,因为这样更容易出现内存溢出问题。

相对而言,垃圾收集行为在这个区域出现是比较少的,但并非数据进入方法区后就永久存在了。

运行时常量池

运行时常量池是方法区的一部分。class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期间生成的各种字面量和符号引用)

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

JDK 1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

推荐阅读 Java中几种常量池的区分

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范定义的内存区域,但这部分内存也被频繁的使用。而且也可能导致 OutOfMemoryError 异常出现。

JDK 1.4 中新加入的 NIO(New Input/Outpu)类,引入了一中基于通道(Channel)和缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本地总内存大小以及处理器寻址空间的限制。

HotSpot 虚拟机对象探秘

通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。

对象的创建

下图便是 Java 对象创建的过程

  1. 类加载检查:
    虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已经被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  2. 分配内存:
    在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配控件的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配的方式有: 指针碰撞空闲列表 两种,选择哪种分配方式由 Java 堆是否整齐决定,而 Java 堆是否整齐又由所采用的垃圾回收器是否带有压缩功能决定。

在创建过程的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

    1. CAS + 失败重试:
      CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,知道成功为止。虚拟机采用 CAS 配上失败重试的方法保证更新操作的原子
    2. TLAB:
      为每一个线程预先在 Eden 区分配一块内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中剩余内存或者 TLAB 的内用用完,再采用上述的 CAS 进行内存分配。
    1. 初始化零值:
      内存分配完成后,虚拟机需要将分配到内存空间都初始化零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初值直接使用,程序能访问到这些字段的数据类型所对应的零值。
    2. 设置对象头:
      初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启动偏向锁等,对象头也会有不同的设置方式。
    3. 执行 init 方法:
      在上面的工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚刚开始,<init> 方法还没有执行,所有字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正客用的对象才算完全生产出来。

    对象的内存布局

    在 Hotspot 虚拟机中,对象在内存中的布局可以分为3快区域:对象头实例数据对齐填充

    HotSpot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

    实例数据部分是对象阵阵存储的有效信息,也是在程序中所定义的各种类型的字段内容。

    对齐填充部分不是必然存在的,也没什么特别的含义,仅仅起占位作用。因为 HotSpot 虚拟机的自动内存管理系统要求系统起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

    对象的访问定位

    简历对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有:使用句柄直接指针 两种:

    • 使用句柄:
      如果使用句柄的话,那么 Java 堆中将会划分出一块内存区域作为句柄池,reference 中存储的就是对象的句柄地址,而句柄汇总包含对象实例数据与类型数据各自的具体地址信息;

    • 直接指针:
      如果使用直接指针访问,那么 Java 堆对象的布局就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储直接就是对象的地址。

    这两种方式各有优势。使用句柄访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

    重点补充内容

    String类和常量池

    String 对象的两种创建方式:

    String str1 = "abcd";
    String str2 = new String("abcd");
    System.out.println(str1 == str2);//false

    这两种不同的创建方法是有差别的,第一种方式是在常量池中拿对象,第二种方式是直接在堆内存控件创建一个新对象。

    记住,只要使用了 new 方法,就需要创建新的对象。

    String 类型的常量池比较特殊,它的主要使用方法有两种:

    • 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
    • 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建于此 String 内内容相同的字符串,并返回常量池中创建的字符串引用。
    String s1 = new String("计算机");
    String s2 = s1.intern();
    String s3 = "计算机";
    System.out.println(s1);    //计算机
    System.out.println(s1 == s2);    //false,因为一个是对内存中的 String 对象,一个是常量池中的 String对象。
    System.out.println(s3 == s2);    //true,因为两个都是常量池中的 String 对象。

    String 字符串拼接

    String str1 = "str";
    String str2 = "ing";
    String str3 = "str" + "ing";    //常量池中的对象
    String str4 = str1 + str2;    //在对上创建新的对象
    String str5 = "string";    //常量池中的对象
    System.out.println(s3 == s4);    //false
    System.out.println(s3 == s5);    //true
    System.out.println(s4 == s5);    //false

    尽量避免多个字符串拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者StringBuffer

    问题:String s1 = new String("abc"); 这句话创建了几个对象?

    答案:创建了两个对象。

    解析:先有字符串 “abc”放入常量池,然后 new 了一份字符串 abc 放入 Java 堆(字符串常量“abc”在编译器就已经确定放入常量池,而 Java 堆上的 “abc”是在运行期初始化阶段才确定),然后 Java 栈的 str1 指向 Java 堆上的 “abc”。

    8 种基本类型的包装类和常量池
    Java 基本类型的包装类大部分实现了常量池技术,Byte,Short,Integer,Long,Character,Boolean;
    这几种包装类默认创建了数值 [-128, 127] 的相应类型的缓存数据,但是超出此范围仍然会创建新的对象。
    两种浮点型类型的包装类:Float、Double 并没有实现常量池技术。

    Integer i1 = 33;
            Integer i2 = 33;
            System.out.println(i1 == i2);    //输出 true
            Integer i11 = 333;
            Integer i22 = 333;
            System.out.println(i11 == i22);    //输出 false
            Double i3 = 1.2;
            Double i4 = 1.2;
            System.out.println(i3 == i4);    //输出 false

    Integer 缓存源代码:

    //此方法将使用缓存 -128 到 127 (包括端点)范围内的值,并可以缓存此范围之外的其他值。
        public static Integer valueOf(int i) {
            if (i >= IntegerCache.low && i <= IntegerCache.high)
                return IntegerCache.cache[i + (-IntegerCache.low)];
            return new Integer(i);
        }

    应用场景:
    Integer i1 = 40;Java 在编译的时候会直接将代码封装成 Integer i1 = Integer.valueOf(40),从而使用常量池中的对象。
    Integer i1 = new Integer(40); 这种情况下会创建新的对象。

    Integer i1 = 40;
    Integer i2 = new Integer(40);
    System.out.println(i1 == i2);    //输出 false

    Integer 比较更丰富的一个例子

            Integer i1 = 40;
            Integer i2 = 40;
            Integer i3 = 0;
            Integer i4 = new Integer(40);
            Integer i5 = new Integer(40);
            Integer i6 = new Integer(0);
    
            System.out.println("i1=i2     " + (i1 == i2));    //true
            System.out.println("i1=i2+i3     " + (i1 == i2 + i3));    //true
            System.out.println("i1=i4     " + (i1 == i4));    //false
            System.out.println("i4=i5     " + (i4 == i5));    //false
            System.out.println("i4=i5+i6     " + (i4 == i5 + i6));    //true
            System.out.println("40=i5+i6     " + (40 == i5 + i6));    //true

    解析:
    语句 i4 == i5 + i6,因为 + 这个操作符不适用于 Integer 对象,首先 i5 和 i6 进行自动拆箱操作,进行数组相加,即 i4 = 40。然后 Integer 对象无法与数值进行直接比较,所以 i4 自动拆箱转为 int 值 40,最终条语句转为 40 == 40 进行数值比较。

    参考

    本文转自 程序猿 - 可能是把Java内存区域讲的最清楚的一篇文章