关于 Java 运行时的内存区域

每运行一个 Java 程序,它就要启动一个 Java 虚拟机的实例,所以说,Java 虚拟机的生命周期是和进程相同的(或许这里顺序该交换一下),一个 Java 虚拟机维护一个进程。

Java 虚拟机会将其管理的内存划分为若干不同的数据区域——

可以看到,Java 运行时管理的内存区域包括程序计数器(PC),虚拟机栈(VM Stack),本地方法栈(Native Method Stack),堆(Heap),方法区和直接内存。

其中,每个线程都拥有(且私有)——

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

以下区域被所有线程共享——

  • 方法区(可以认为方法区是虚拟机的一种规范,对于 HotSpot 虚拟机,元空间(JDK1.8 以后)是它的实现。所以可以说元空间就是方法区)
  • 直接内存(不是运行时数据区的一部分)

运行时各数据区域

简述下各区域及其作用。

Java 虚拟机栈(VM Stack)

Java 内存可以粗糙地区分为堆内存(heap)和栈内存(stack)。其中,每一个线程的虚拟机栈就是这里的栈,或者说是这里的栈中局部变量表部分(局部变量表存储当前函数的局部变量,包括编译期可知的 8 个基本数据类型和对象引用)。其作用就如 C 语言中的函数调用栈。其存储一个个栈帧(Stack frame)。栈帧会在 return 语句或抛出异常后弹出。

栈中只存储对象的引用,对象的实例一般来说存放在堆中。但是,如果某方法中使用的对象没有被返回,没有被外界调用,该对象是可以分配在栈上的。这种机制称为逃逸分析

每一个栈帧都拥有:局部变量表,操作数栈,动态链接,方法出口信息。(这啥啊……)

Java 虚拟机栈会出现两种错误——StackOverFlowErrorOutOfMemoryError

stackOverFlowError:若栈的深度超过 Java 虚拟机的最大深度,此时若不允许动态扩展栈的内存大小,则抛出该错误。(或许 99%的stackOverFlowError错误都是因为递归忘记写出口了吧:)

OutOfMemoryError:Java 虚拟机中无空闲内存,且垃圾回收器无法提供更多内存时,该错误会被抛出。

每个线程都有自己的虚拟机栈,因为它们都要进行函数调用,在线程切换后,要能够恢复此时的上下文。基于相同的理由,每个对象也都私有自己的 PC

程序计数器(PC)

程序计数器(Program Counter Register)可以认为是一个线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支,循环,跳转,异常处理等功能都需要 PC 来完成。在多线程切换时,每个线程都需要记录当前线程执行的位置,从而在获得运行权限的时候能够从切换前的位置继续执行(线程恢复),这也是 PC 的工作。

程序计数器是唯一不会出现OutOfMemoryError的内存区域。

PC 在执行 native 方法的时候,其值为 undefined(?),即不起作用。

本地方法栈(Native Method Stack)

本地方法栈同虚拟机栈功能相似,唯一的区别在于虚拟机栈用来执行 Java 方法,本地方法栈则用来执行 Native 方法。

堆(Heap)——存放对象实例

这里的堆指的并非数据结构中的堆。其用的是 heap 的英文释义——(凌乱的)一堆;许多;大量

堆是 JVM 管理的最大的一块内存区域。其唯一的目的在于存放对象的实例。几乎所有的对象实例(和数组)都分配在堆中(一些技术,如 JIT,逃逸分析的成熟会导致一些微妙的变化——一些对象不分配在堆中而是栈中)。堆是垃圾收集器管理的主要区域,因此也被称作** GC 堆(Garbage Collected Heap)**。

堆分为三部分——新生代(Young Generation),老年代(Old Generation)和永久代(Permanent Generation),JDK1.8 后,永久代被移除,取而代之的是元空间(MetaSpace)。或者说,方法区的实现由永久代改为了元空间。

堆是各线程共享的。因而是线程不安全的。

新生代包括 Eden(亚当,意义不言而喻)区和两个 Survivor 区(称为 s0,s1,或 from,to 区)。对象一般首先在 Eden 区分配,在经过一次新生代垃圾回收后,如果对象还存活,则进入 s0,s1,年龄加 1(或初始年龄设为 1),年龄增长到一定程度则晋升到老年代。

堆中容易出现OutOfMemoryError错误。其主要有几种情况——

  • OutOfMemoryError: GC Overhead Limit Exceeded:JVM 花太多时间进行垃圾回收,并且只能回收很少的堆空间。
  • OutOfMemoryError: Java heap space:创建新对象时堆空间用尽。(堆空间与设定有关,与计算机内存无关)

方法区

方法区与堆一样,也是各个线程共享的。它用于存储已被虚拟机加载的类信息(即反射所使用的信息。这些信息是用来描述类的,因此也叫类的元数据)、常量静态变量即时编译器编译后的代码等数据。

在规范中,方法区逻辑上属于堆。但方法区又有一个别名非堆(Non-Heap),与堆区别开来

在 Java 8 之后,方法区的实现改为元空间,它使用直接内存。最大元空间大小默认为 unlimited,因此,元空间溢出的可能性更小。如果溢出,说明计算机的物理内存被用尽。

元空间会抛出OutOfMemoryError: MetaSpace错误。

方法区与永久代的关系

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。

——引用自 JavaGuide

运行时常量池(Runtime constant pool)

运行时常量池是方法区的一部分,其用于存放编译期生成的各种字面量符号引用字符串常量池在堆中,运行时常量池在方法区中(元空间中)。

字面量指代码文件中直接给定的量,如”hello”, 3, 233l 等。

符号引用简单来说就是包含一定信息的符号,经过几层解析后,其最终指向一个具有类型和层次的字符串。见 JVM 里的符号引用如何存储?

常量池中存储对象的引用,实际对象仍旧存储在堆中。

直接内存

直接内存不是虚拟机运行时数据区的一部分,但是其会经常被频繁使用。其也会抛出OutOfMemoryError错误。

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

——同上

关于常量池

只要使用 new 方法,便需要创建新的对象。

String 类的常量池

String 对象的两种创建方式

1
2
3
4
5
6
7
8
String s1 = "abcd"; // 先检查常量池中有无"abcd",若无则在常量池中创建对象,然后再让 s1 指向该对象,否则直接指向
String s2 = new String("abcd"); // 在堆中创建
String s3 = new String("abcd"); // 在堆中创建
String s4 = "abcd";
String s5 = s3.intern(); // 检查常量池
System.out.println(s1==s2); //false
System.out.println(s2==s3); //false
System.out.println(s1==s4); //true,它们指向同一个常量池中的对象

如图。当使用 new 创建的时候,则创建在堆中,通过字面量创建则创建在(或者指向)常量池中。当然,字符串常量池也在堆中。

也可以使用 intern 方法,这是一个 Native 方法,它检查常量池,如果包含,则直接返回常量池中该字符串的引用,如果不包含,则在常量池中记录对该字符串的引用(此处存疑),并返回该引用

这里好像有问题,见如下代码——

1
2
3
String s1 = new String("hello");
String s2 = s1.intern();
System.out.println(s1 == s2); // false

这里,如果 intern 的行为是在常量池中记录对该字符串的引用并返回的话,s2 理应和 s1 指向同一个对象,但是结果确是 false……怪东西。

字符串拼接

字符串拼接会重新创建对象,所以应当尽量避免多个字符串的拼接,如果需要拼接,应当使用 StringBuilder(线程不安全)和 StringBuffer(线程安全,但性能较差)类。

两个常量池中的对象拼接,其结果在常量池中,堆上创建的对象(与堆上的对象或常量池中对象)拼接则创建在堆中。

1
2
3
4
5
6
7
8
9
10
11
String s1 = "str";
String s2 = "ing";

String s3 = "str" + "ing";//常量池中的对象
String s4 = s1 + s2; //在堆上创建的新的对象
String s5 = "string";//常量池中的对象
String s6 = s1 + "ing"; // 堆中对象和常量池中对象拼接,仍然在堆中创建
System.out.println(s3 == s4);//false
System.out.println(s3 == s5);//true
System.out.println(s4 == s5);//false
System.out.println(s3 == s6);//false

new String(“hello”) 创建几个字符串对象?

答案是一个或两个。JVM 首先检查常量池中是否存在字符串常量”hello”。如果存在,则只在堆中创建一个字符串常量”hello”,如果不存在,则先在池中创建,再在堆中创建。因此是一个或两个。

包装类型的常量池

字符串常量池存在于堆中而运行时常量池存在于方法区中

八种包装类型中,六种包装类型都实现了常量池技术。它们是四种整型 Byte,Short,Integer 和 Long,Boolean 和 Character,两种浮点数 Float 和 Double 没有实现。

四种整型包装类创建了 [-128,127] 范围类的相应类型的缓存数据,Character 实现了数据在 [0, 127](从\u0000\u007f)范围的缓存数据,Boolean 直接返回 True 和 False(也就是说,所有 Boolean 的变量,其值为 true 的都==常量池中的 TRUE,其值为 false 的都==常量池中的 FALSE)。

在自动装箱(如通过字面量初始化)时,Java 在编译时会自动封装代码,从而使用常量池中的对象。

1
2
3
4
5
6
7
8
9
Integer i1 = 40; // 其将被编译成 Integer i1 = Integer.valueOf(40);
Integer i2 = new Integer(40); // 不利用缓存,直接在堆中创建新对象
System.out.println(i1==i2);//false

public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)]; // 直接利用方法区中的缓存
return new Integer(i);
}

同时,需要明确,运算符和==操作符不适用于包装类,因而其会被拆箱后进行运算。但是实际工作中,不应当依赖这种自动装箱机制,对于包装类,应当使用 equals 方法进行比较。

1
2
3
4
Integer i1 = new Integer(40);
Integer i2 = new Integer(2);
Integer i3 = 42;
System.out.println(i3 == i1 + i2); // true, 其会被自动拆箱为 i3.intValue() == i1.intValue() + i2.intValue();


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!