编程技术网

关注微信公众号,定时推送前沿、专业、深度的编程技术资料。

 找回密码
 立即注册

QQ登录

只需一步,快速开始

极客时间

Java 运行数数据区域-Java内存区域

你的怀里 Jvm 2021-12-22 01:14 146人围观

腾讯云服务器

Java 运行数数据区域

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

JDK 1.8 和之前的版本略有不同:

  • JDK 1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区
  • JDK 1.7 字符串常量池被从方法区中拿到了堆,运行时常量池剩下的内容还在方法区
  • JDK1.8 HotSpot 虚拟机移除了永久代,采用元空间(Metaspace) 代替方法区,这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间。

程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器(逻辑上)。主要有以下两个作用:

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

此外,程序计数器还有如下特性:

  • 通过改变计数器的值来选取下一条需要执行的字节码指令
  • 和线程一对一的关系,即“线程私有”
  • 对 Java 方法计数,如果是 Native 方法则计数器值为 Undefined
  • 只是计数,不会发生内存泄漏,生命周期随着线程的创建而创建,随着线程的结束而死亡。

Java 虚拟机栈

每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表操作数栈、动态链接、方法出口信息等。

从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。Java 方法有两种返回方式:

  • return 语句
  • 抛出异常

不管使用哪种返回方式都会导致栈帧被弹出。

可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小:

java -Xss512M HackTheJava

该区域可能抛出以下异常:

  • 当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常;
  • 栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。

注意:HotSpot 虚拟机的栈容量不可以进行动态扩展的,所以在 HotSpot 虚拟机是不会由于虚拟机栈无法扩展而导致 OOM 的,但是如果申请时就失败,仍然会出现 OOM 异常。

局部变量表

局部变量表主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)和对象引用。

操作数栈

  • 局部变量表:包含方法执行过程中的所有变量
  • 操作数栈:入栈、出栈、复制、交换、产生消费变量

通过 javap 命令分析 Java 汇编指令,感受操作数栈和局部变量表的关系。

定义测试类:该类中定义了一个静态方法 add()

public class JVMTest {
    public static int add(int a ,int b) {
        int c = 0;
        c = a + b;
        return c;
    }
}

使用 javap 命令(javap 分析的是字节码文件)

javap -verbose JVMTest

得到如下汇编指令:

  public static int add(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=2
         0: iconst_0
         1: istore_2
         2: iload_0
         3: iload_1
         4: iadd
         5: istore_2
         6: iload_2
         7: ireturn
      LineNumberTable:
        line 5: 0
        line 6: 2
        line 7: 6

解读上述指令:

  • stack = 2 说明栈的深度是 2 ;
  • locals = 3 说明有 3 个本地变量 ;
  • args_size = 2 说明该方法需传入 2 个参数
  • load 指令表示入操作数栈,store 表示出操作数栈

执行 add(1,2),说明局部变量表和操作数栈的关系:

  • 首先会将栈帧按照程序计数器指令从大到小依次入栈,栈帧按照程序计数器指令依次出栈。
  • 数据 1、2 是入参,已经存在局部变量表 0、1 位置
  • 首先执行 iconst_0,将数据 0 push 进操作数栈
  • 执行 istore_2,将数据 0 pop出操作数栈并放入局部变量表中 2 位置
  • 执行 iload_0,将 0 位置元素(数值 1) push 进操作数栈
  • 执行 iload_1,将 1 位置元素(数值 2) push 进操作数栈
  • 执行 iadd,将数值1和数值2元素 pop出操作数栈,执行加法运算后,得到结果3,将 3 push 进操作数栈
  • 执行 istore_2,将数据 3 pop出操作数栈并放入局部变量表中 2 位置
  • 执行 iload_2,将 2 位置元素(数值 3)push 进操作数栈
  • 执行 ireturn,返回操作数栈栈顶元素

本地方法栈

本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。

本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。

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

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

几乎所有的对象实例以及数组都在这里分配内存,是垃圾收集的主要区域,所以也被称作 GC 堆(Garbage Collected Heap)

现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法。可以将堆分成两块:

  • 新生代 (Young Generation),新生代可以划分为 Eden 、From Survivor、To Survivor 空间
  • 老年代 (Old Generation)

堆不需要连续内存,并且可以动态增加其内存,增加失败会抛出 OutOfMemoryError 异常。

可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。

java -Xms1M -Xmx2M HackTheJava

JDK 7 版本及 JDK 7 版本之前,Hotspot 虚拟机的堆结构如下:

  • 新生代 (Young Generation)
  • 老年代 (Old Generation)
  • 永久代 (Permanent Generation)

JDK 8 版本之后 HotSpot 虚拟机的永久代被彻底移除了,取而代之是元空间,元空间使用的是直接内存。

堆和栈的关系

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。引用对象、数组时,栈里定义变量保存堆中目标的首地址。

栈和堆的区别:

  • 物理地址

    堆的物理内存分配是不连续的;

    栈的物理内存分配是连续的

  • 分配内存

    堆是不连续的,分配的内存是在运行期确定的,大小不固定;

    栈是连续的,分配的内存在编译器就已经确定,大小固定

  • 存放内容

    堆中存放的是对象和数组,关注的是数据的存储;

    栈中存放局部变量,关注的是程序方法的执行

  • 是否线程私有

    堆内存中的对象对所有线程可见,可被所有线程访问;

    栈内存属于某个线程私有的

  • 异常

    栈扩展失败,会抛出 StackOverflowError;

    堆内存不足,会抛出 OutOfMemoryError

方法区

用于存放已被加载的类信息常量静态变量即时编译器编译后的代码等数据。

和堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。

对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。

HotSpot 虚拟机把它当成永久代来进行垃圾回收。但很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。

方法区与永久代

方法区只是一个 JVM 规范,在不同的 JVM 上方法区的实现可能是不同的。

方法区和永久代的关系类似 Java 中接口和类的关系,类实现了接口,永久代就是 HotSpot 虚拟机对 JVM 规范中方法区的一种实现方式

方法区是 JVM 规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。

元空间与永久代

方法区只是一个 JVM 规范,永久代与元空间都是其一种实现方式。在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间中:元空间存储类的元信息,静态变量和常量池等则放入堆中

元空间与永久代的最大区别在于:元空间使用本地内存,而永久代使用 JVM 的内存,元空间相比永久代具有如下优势:

  • 永久代存在一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace 可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。
  • 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,可以加载更多的类。

运行时常量池

运行时常量池是方法区的一部分。

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

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量 一定只有编译期才能产生,运行期间也可以将新的常量放入池中,例如 String 类的 intern()。

字符串常量池

JDK 1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区。

JDK 1.7 字符串常量池被单独拿到堆,运行时常量池剩下的内容还在方法区。

JDK1.8 HotSpot 虚拟机移除了永久代,采用元空间(Metaspace) 代替方法区,这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间。

String 对象创建方式

创建方式 1:

String str1 = "abcd"; 

创建方式 2:

String str2 = new String("abcd");

这两种不同的创建方法是有差别的:

方式 1 是在常量池中获取对象("abcd" 属于字符串字面量,因此编译时期会在常量池中创建一个字符串对象)。

方式 2 会创建两个字符串对象(前提是常量池中还没有 "abcd" 字符串对象):

  • "abcd" 属于字符串字面量,因此编译时期会在常量池中创建一个字符串对象,该字符串对象指向这个 "abcd" 字符串字面量;
  • 使用 new 的方式会在堆中创建一个字符串对象。

(字符串常量"abcd"在编译期就已经确定放入常量池,而 Java 堆上的"abcd"是在运行期初始化阶段才确定)。

str1 指向常量池中的 “abcd” 对象,而 str2 指向堆中的字符串对象。

String 的 intern() 方法

String 的 intern() 是一个 Native 方法,当调用 intern() 方法时:

  • 如果运行时常量池中已经包含一个等于该 String 对象内容的字符串,则返回常量池中该字符串的引用。
  • 如果没有等于该 String 对象的字符串,JDK1.7 之前(不包含 1.7)是在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用,JDK1.7 以及之后的处理方式是对于存在堆中的对象,在常量池中直接保存对象的引用,而不会重新创建对象。
String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);

JDK 6 输出结果:

false

JDK 8 输出结果:

true

补充:String 的 intern() 方法详解

字符串拼接问题

String str1 = "hello";
String str2 = "world";

String str3 = "hello" + "world";//常量池中的对象
String str4 = str1 + str2; //在堆上创建的新的对象
String str5 = "helloworld";//常量池中的对象

System.out.println(str3 == str4);
System.out.println(str3 == str5);
System.out.println(str4 == str5);

输出结果如下:

false
true
false

str1、str2 是从字符串常量池中获取的对象。

对于 str3,字符串 "hello" 和字符串 "world" 相加有后得到 "helloworld",在字符串常量池中创建 "helloworld" 对象。

对于 str4,str1+str2 则会在堆中创建新的 "helloworld" 对象。

对于 str5,字符串常量池已有 "helloworld" 对象,str5 直接引用该对象。

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

直接内存

在 JDK 1.4 中新引入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。

Hotpot 虚拟机对象

对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:

  • 对象头
  • 实例数据
  • 对齐填充

对象头

Hotspot 虚拟机的对象头包括两部分信息:

一部分用于存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志等等);

另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

实例数据

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

对齐填充

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。

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

对象的创建

Java 对象的创建过程分为以下5步:

  • 类加载检查
  • 分配内存
  • 初始化零值
  • 设置对象头
  • 执行 <init> 方法

1. 类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用, 并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。 如果没有,那必须先执行相应的类加载过程。

2. 分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。 对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定。

Java 堆内存是否规整,则取决于 GC 收集器的算法是“标记-清除”,还是“标记-整理”(也称作“标记-压缩”),值得注意的是,“复制算法”内存也是规整的。

两种内存分配方式

指针碰撞
  • 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界值指针,只需要向着没用过的内存方向将指针移动一段与对象大小相等的距离。
  • 适用场景:堆内存规整(即没有内存碎片)的情况
  • GC(Garbage Collection)收集器:Serial、ParNew
空闲列表
  • 原理:虚拟机会维护一个列表,在该列表中记录哪些内存块是可用的,在分配的时候,找一块足够大的内存块划分给对象示例,然后更新列表记录
  • 适用场景:堆内存规整
  • GC(Garbage Collection)收集器:CMS

内存分配并发问题

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

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
  • TLAB:每一个线程预先在Java堆中分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才采用上述的 CAS 进行内存分配。

3. 初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

4. 设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

5. 执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init > 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的访问定位

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

使用句柄

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

直接指针

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

这两种对象访问方式各有优势:

  • 使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改
  • 使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
腾讯云服务器 阿里云服务器
关注微信
^