Java 内存区域

首先,在 JVM 中会将内存区域划分成五个部分(如上所示),其中蓝色区域(方法区、堆区)是线程间共享的,绿色区域(虚拟机栈、本地方法栈、程序计数器)是线程私有的,每个线程独立的保留一份数据。各个区域存储的数据及功能如下:
方法区
方法区是线程共享的一片区域,它主要存放的是虚拟机加载出来的类信息、常量、静态变量、即时编译器遍以后的代码等。由于这些数据在程序执行中不经常出现改变,所以也称其为静态区或者永久代。
这个区域在近几个 Java 版本中有一些变化,如 Java 7 中将方法区的字符串常量池移到堆中,Java 8 中没有了方法区,而是将其放到一个叫做“元空间”的与堆不相连的本地内存区域。
运行时常量池是方法区中的一部分,这部分内容主要是编译期生成的一些符号引用和字面量,字面量即字符串、final 修饰的变量,符号引用包括字段和方法的名称和描述符、类和接口的路径名。
当方法区申请不到需要的空间时,也会抛出 OutOfMemoryError 异常。
堆区
堆区也是线程共享的一块内存,它会在虚拟机创建的时候初始化,其内部主要保存的就是各种对象的实例,所以是各片内存区域中最大的一块,也是主要的垃圾回收区域。一般情况下,为了垃圾回收的方便,还会将堆区区分为年轻代、老年代等,不同年代的垃圾回收是独立的,当有新的对象创建的时候,会先将其放到年轻代,当经历过数次年轻代的 GC 还没有被回收或者年轻代已满时,就可能将其转移到老年代。
在堆区中有可能抛出 OutOfMemoryError 异常,当堆区中的剩余内存不足时,就会抛出这个异常。不过大部分情况下抛出异常并不是堆区没有剩余空间,而是在垃圾回收的过程中,本应该被回收的垃圾没有被正确回收,被称为内存泄漏。
另外,还有一个关于变量存储的误区。一般情况下都会说,对象的引用存储于虚拟机栈中,对象的实例存储于堆中。这种说法无可厚非,但是并不准确。准确的说,只有局部变量的引用是存储于虚拟机栈中,成员变量的引用和对象实例还都是存储于堆中的。为什么会是这样?因为成员变量原则上来说是属于实例的一部分,所谓对象的实例,就是包含它的对象头、实例数据和对齐填充,而成员变量就是属于实例数据。所以,按照这种理解,成员变量并不是通常所说的“变量”,它在内存中的表现更应该说成是实例的一部分。而常说的“变量”,则更多的是指局部变量,即在方法中声明变量。
虚拟机栈
首先,虚拟机栈是线程私有的,它的生命周期也与线程一致,每一个线程都有且只有一个虚拟机栈,或者说,线程的工作环境就是由虚拟机栈提供的。
每个线程都会有自己的一套执行过程,其执行的字节码位置由程序计数器标出,但是这还不够,每个线程还需要保存其方法调用过程,以及调用方法的时候生命的一些局部变量,这些就是虚拟机栈完成的。
既然是栈,必然有栈帧,而栈帧在程序执行过程中就对应着方法的调用。每当调用一个方法,就会在虚拟机栈中入栈一个新的栈帧,一般来说,栈帧中存放的数据有如下几种:
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
局部变量表
局部变量表顾名思义,就是存储着在 Java 方法中声明一些局部变量,这又根据变量的类型有所不同,如果变量是基本类型,则直接存储变量的值,如果变量是对象,那么这里存放的就是实例的引用。另外,在调用方法的时候,如果方法含有参数,这些参数也是存储于局部变量表中的。
局部变量表的大小是在 Java 程序编译的时候就确定的,编译器会根据方法的参数、声明的局部变量确定局部变量表的大小,并且每一个变量都分配一个标号。如果这是一个非静态方法,那么局部变量表第一个存放的就是调用本方法的实例的引用,这也就是为什么在非静态方法内部使用 this 可以得到对象实例引用的原因,然后接着存放的就是方法的参数、局部变量。
操作数栈
Java 虚拟机的执行是基于栈的,比如,在执行一条加法指令的时候,首先将其中一个加数取出放入栈中,然后再取出另一个加数与之相加,更新栈中的数据,最后将结果出栈并放回目标地址,及 Java 中的计算都是基于入栈、出栈完成的,这个栈就是操作数栈。
与其他的一些不同,代码的执行是基于寄存器的,计算过程产生的中间数字会放在寄存器中。Java 之所以选择了这种执行方法,就是因为基于栈的操作可以给 Java 提供更好的可移植性,基于栈就意味着只需要使用操作系统提供的栈,所有的操作系统提供的栈大同小异,但是如果要基于寄存器,就需要依赖于具体的 CPU 硬件。不过基于栈的操作主要的缺点就是执行速度会相对较慢,栈是放在内存中的,自然无法与寄存器相比。
动态连接
Java 文件被编译成 Class 文件之后,其内部会产生大量的引用符号,一些符号是类中对应方法的引用,这些符号有些会在类加载或第一次使用的时候转化为引用,如 static 、final 关键字标记的方法,这些方法不能被重写,所以只需要一次转化,这种转化叫做静态解析。另外一些方法,因为涉及到继承、重写机制,只能在运行时才能确定这些方法引用具体引用哪个实现方法,这种叫做动态连接。
所以在每一个栈帧中,都保存着一个指向运行时常量池的引用,这个引用就是为了支持上述的动态连接,当这个方法要调用其他方法的时候,先找到对应的常量池,然后根据常量池找到要调用的方法的引用,由于在运行时常量池中的引用是动态变化的,所以调用的方法也会根据实际情况而有不同。
方法返回地址
每一个栈帧对应着一个方法,那么当方法执行完毕(或者是遇到没有捕获的异常结束)之后,就需要返回到上一个方法执行上一个方法的逻辑,所以每一个栈帧都需要保存上一个方法的中,下一个字节码的地址,在当前方法退出之后,会将返回地址赋值给程序计数器。
一个方法的退出,还伴随着当前栈帧的出栈,所以,栈顶的栈帧永远都对应着正在执行的方法。在虚拟机栈中,Java 定义了两种异常,分别是 OutOfMemoryError 和 StackOverflowError 。
JVM 给虚拟机栈规定了一个最大深度,当深度超过了最大值,及方法调用的深度超过了最大值之后,就会抛出 StackOverflowError 异常,这种情况一般出现在递归调用里面。
另外,虚拟机栈的内存虽然可以动态扩展,但也有一定的限制,当虚拟机申请不到需要的空间时,就会抛出 OutOfMemoryError 异常。
本地方法栈
本地方法栈与虚拟机栈的功能类似,只不过一个服务于 Java 方法,一个服务于本地方法。
在本地方法栈中同样可能抛出两种异常:OutOfMemoryError 和 StackOverflowError 。
另外,JVM 并没有强制规定本地方法栈使用的语言、使用方法和数据结构,所以在具体实现中也可以将虚拟机栈和本地方法栈合二为一,如 HotSpot 。
程序计数器
Java 程序在运行之前会被编译成字节码,通过字节码解释器将其转化成可执行语言,程序计数器就是帮助字节码工作的,它始终指向当前线程执行到的字节码行号,这就类似于在做汇编程序编写的时候的 IP 寄存器,正是通过改变程序计数器的值,完成了 Java 程序中的分支、循环、跳转等功能,因为每个线程是独立执行的,所以程序计数器是一个线程私有的内存空间,负责控制本线程内程序的执行。
另外,程序计数器只是负责 Java 程序的执行,所以当本地方法执行的时候,程序计数器的值为空。
程序计数器也是这几个区域中唯一没有 OOM(OufOfMemory)的区域。至于为什么,可能是因为目前而言 Java 字节码还是有一定的长度限制的(如受到计算机内存的限制),程序计数器本身就只是一个数字,只要在开始分配一个足够大的空间存放这个数,那么在正常情况下是不会出现溢出的。
Java 内存模型

虽然 Java 的线程共享同一内存,但是在线程创建时 Java 会给每一个线程分配一个工作内存,用于存储线程私有的数据,又因为所有的变量都存储在主内存,所以当某个线程要操作一个变量的时候,首先需要将变量从主内存拷贝到工作内存,然后才能执行读取、赋值等操作。
所以当多个线程工作的时候,若线程 1 需要修改某个变量,设为 a ,需要先将 a 加载到工作内存,然后更改这里面 a 的副本的值。此时如果线程 2 也要修改变量 a 的值,程序应该怎么执行?直接按照线程 1 的过程来一遍?这肯定是不行的,要修改 a 需要先得到线程 1 修改之后的 a 的值。但是工作内存是线程私有的,一个线程并不能直接访问另一个线程的工作内存,而需要使用主内存做中转。首先,将线程 1 中变量 a 的副本的值写回到主内存,然后线程 2 才能从主内存拷贝一份副本到它的工作内存再修改。那此时如果线程 1 想再次修改变量 a 呢?此时线程 1 的工作内存已经有 a 的副本了,是否可以直接修改?也不行,为了保证正确性,在修改之前还应该得到线程 2 修改的结果,这还需要使用主内存做中转。
为了保证多线程唤醒下修改变量 a 的正确性,各线程之间应该按照如上的描述工作才行,但是事实上 Java 并没有默认提供这种功能,所以在多线程编程环境下,首要考虑的就是线程安全性,即多线程操作数据,其结果应该与逻辑上没有工作内存的存在相同,或者说,每个线程所做的操作都应该能反映到结果上。但是如果没有相应的措施,很可能达不到这种要求。
例如,两个线程 1 、2 在同一时刻请求对变量 a 做修改,如果线程 1 首先获得到 CPU ,那么它会首先将变量加载到工作内存,然后修改,最后写回。但是很有可能在线程 1 执行这个操作的过程中,假如执行到了修改这一步之后,它失去了 CPU 进入就绪状态,此时线程 2 执行操作,并执行完了写会操作,此时又调度到线程 1 ,执行线程 1 的写回。此时,线程 2 的操作就被覆盖了,如果是执行如下操作:
public void fun() {
i++;
}
两个线程执行完毕之后,i 最终只会增加 1 。
为了满足多线程环境的工作,Java 提供了相关的类 ReentrantLock (重入锁),关键字 synchronized 、volatile 。与多线程相关的有三个特性:原子性、有序性、可见性。
原子性
原子性即某一个操作不可分割,在执行过程中不能中断。在 Java 中,单个的读操作、写操作是不可分割的,如i = 5,而其他的操作,如x = y,则分为两步,首先读取 y 的值,然后将这个值赋给 x 。在多线程环境中,凡是非原子性的操作,都可能出现问题。Java 中提供了锁机制以完成线程间原子性的操作,在锁住的代码范围内,同时只能有一个线程进入,关于线程锁这里有讲到 Java 线程锁 。
有序性
为了程序执行的效率,编译器可能会在不影响代码结果的情况下对代码重新排序,但是这里所谓的不影响结果,只是不影响单线程执行的结果,如下面的代码:
int i = 0;
int j = 0;
boolean flag = false;
// thread 1
public void fun1() {
i = 1;
flag = true;
}
// thread 2
public void fun2() {
if (flag) {
j = i + 2;
}
}
当有两个线程分别执行fun1()和fun2()方法的时候,结果应该只有两种:
- j = 0
- j = 3
但是在实际中,编译器可能会重排fun1()的代码,使之变为:
public void fun1() {
flag = true;
i = 1;
}
此时执行完两个方法之后,j 的可能值为 0 、2 、3 。这就是指令重排序在多线程编程中的危害。在 Java 中也提供了相应的手段,volatile 关键字,被这个关键字修饰的变量存在的语句不会被重排序(所有在代码中位于这个语句前的不会被重排序到这个语句后面,所有在代码中位于这个语句后面的不会被重排序到这个语句前)。合理使用 volalite 就能保证上述的错误不会发生,如使用 volatile 修饰 flag 变量。
另外,volatile 还能够保证变量的可见性。
可见性
可见性是指,当一个线程对某个变量做出了修改之后,会立刻反馈到其他线程中去。volatile 能保证变量可见性,当一个线程修改了某个被 volatile 修饰的变量之后,JVM 会将这个变量在其他所有工作内存中的这个变量的副本都标记为失效,其他线程要使用这个变量只能重新从主内存中读取。
volatile 提供了数据操作的有序性和可见性,但并不能保证原子性,在多线程中光有 volatile 并不能保证线程安全。如果采用类比的话,对于操作i++,它原本应该分为三步,读取、修改、写回。而使用了 volatile 之后可以看作将这个过程变成了两步,读取、修改并写回,也就是说,在读取与修改这两步操作中还是可以被中断,所以当两个线程同时执行i++这个操作的时候,还是会导致结果出错。