JVM 内存模型

JVM架构.jpg

Jvm 内存模型主要包括程序计数器(寄存器)、虚拟机栈、本地方法栈、堆、方法区、运行常量池、直接内存几部分组成。

程序计数器

记录正在执行的虚拟机字节码指令的地址(如果正在执行的是 Native 方法则为空)。

当有多个线程交叉执行时,被中断的线程的程序当前执行到哪条内存地址会被保存下来,以便用于被中断的线程恢复执行时再按照被中断时的指令地址继续执行下去。

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

虚拟机栈

线程私有,它的生命周期与线程相同,每当创建一个线程,虚拟机就会为该线程创建对应的栈。

每个栈又包含多个栈帧 (Stack Frame),每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每当一个方法执行完成时,该栈帧就会弹出栈帧的元素作为这个方法的返回值,并且清除这个栈帧。

栈的栈顶的栈帧就是当前正在执行的活动栈,也就是当前正在执行的方法,程序计数器也会指向该地址。只有这个活动的栈帧的本地变量可以被操作栈使用,当在这个栈帧中调用另外一个方法时,与之对应的一个新的栈帧被创建,这个新创建的栈帧被放到虚拟机栈的栈顶,变为当前的活动栈。现在只有这个栈的本地变量才能被使用,当这个栈帧中所有指令都完成时,这个栈帧被移除 Java 栈,刚才的那个栈帧变为活动栈帧,前面栈帧的返回值变为这个栈帧的操作栈的一个操作数。

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

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

  1. 当线程请求的栈深度超过虚拟机所允许的最大值,会抛出 StackOverflowError 异常;
  2. 如果虚拟机栈可动态扩展,但是动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。

在 Hot Spot 虚拟机中,可以使用 -Xss 参数来设置栈的大小。栈的大小直接决定了函数调用的可达深度。

本地方法栈

与 Java 虚拟机栈类似,它们之间的区别在于虚拟机栈为 JVM 执行 Java 方法服务,而本地方法栈为 JVM 执行 Native 方法服务。本地方法栈也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

堆是被所有线程共享的一块内存区域,不是线程安全的,在虚拟机启动时创建。此内存区域的目的就是存放对象实例,几乎所有对象实例和数组都在这里分配内存。

  • 堆得内存由 -Xms 指定,默认是物理内存的 1/64;最大的内存由 -Xmx 指定,默认是物理内存的 1/4;
  • 默认空余的堆内存小于 40% 时,就会增大,直到 -Xmx 设置的内存。具体的比例可以由 -XX:MinHeapFreeRatio 指定;
  • 空余的内存大于 70% 时,就会减少内存,直到 -Xms 设置的大小。具体由 -XX:MaxHeapFreeRatio 指定;

堆是垃圾收集器管理的主要区域(”GC 堆”)。现在收集器基本都是采用分代收集算法。堆内存可分为三类:

  1. 新生代(Young Generation): 程序新创建的对象都是从新生代分配内存
    • Eden Space
    • S0 Survivor Space
    • S1 Survivor Space
  2. 老生代(Old Generation): 用于存放经过多次新生代 GC 仍然存活的对象。新建的对象也有可能直接进入老年代,主要有两种情况:1、大对象,可通过启动参数设置-XX:PretenureSizeThreshold=1024(单位为字节,默认为0)来代表超过多大时就不在新生代分配,而是直接在老年代分配;2、大的数组对象,且数组中无引用外部对象。
  3. Permanent Generation: 主要包含类、方法的元信息,从JDK1.8开始移除,不需要连续内存,可以通过 -Xmx 和 -Xms 来控制动态扩展内存大小,如果动态扩展失败会抛出 OutOfMemoryError 异常。

方法区

方法区存放了要加载的类的信息(名称、修饰符等)、类中的静态常量、类中定义为 final 类型的常量、类中的 Field 信息、类中的方法信息,当在程序中通过 Class 对象的 getName.isInterface 等方法来获取信息时,这些数据都来源于方法区。

方法区是被 Java 线程锁共享的,不像 Java 堆中其他部分一样会频繁被 GC 回收,它存储的信息相对比较稳定,在一定条件下会被 GC。和 Java 堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。

对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现,HotSpot 虚拟机把它当成永久代来进行垃圾回收。

运行时常量池

运行时常量池是方法区的一部分。 常量池在编译期间就被确定,类加载后,Class 文件中的常量池(用于存放编译期生成的各种字面量和符号引用)就会被放到这个区域。 在运行期间也可以用过 String 类的 intern() 方法将新的常量放入该区域。

直接内存

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

总结

名称 特征 作用 配置 异常
程序计数器 线程私有、占用内存小,生命周期与线程相同 字节码行号
虚拟机栈 线程私有,生命周期与线程相同,使用连续的内存空间 存放局部变量表、操作栈、动态链接、方法出口 -Xss StackOverflowError OutOfMemoryError
线程共享,生命周期与虚拟机相同,可以不使用连续的内存地址 保存对象实例,所有对象实例(包括数组)都要在堆上分配 -Xms -Xmx -Xmn OutOfMemoryError
方法区 线程共享,生命周期与虚拟机相同,可以不使用连续的内存地址 存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据 -XX:PermSize -XX:MaxPermSize OutOfMemoryError
运行时常量池 方法区的一部分,具有动态性 存放字面量及符号引用

主内存和工作内存

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与 Java 编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。

Java 内存模型规定了所有的变量都存储在主内存中。每个线程还有自己的工作内存, 线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile 变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。

JVM架构.jpg

线程 1 和线程 2 要想进行数据的交换一般要经历下面的步骤:

  1. 线程 1 把工作内存 1 中的更新过的共享变量刷新到主内存中去。
  2. 线程 2 到主内存中去读取线程 1 刷新过的共享变量,然后 copy 一份到工作内存 2 中去。

Java 内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的。

原子性

Java 内存模型中的原子性指一个操作不能被打断,要么全部执行完毕,要么不执行。

基本类型数据的访问大都是原子操作,long 和 double 类型的变量是64位。在 32 位 JVM 中,32 位的 JVM 会将 64 位数据的读写操作分为 2 次 32 位的读写操作来进行,这就导致了 long、double 类型的变量在 32 位虚拟机中是非原子操作,数据有可能会被破坏,也就意味着多个线程在并发访问的时候是线程非安全的。

可见性

Java 内存模型中的可见性指一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量这种修改(变化)

Java 内存模型是通过将在工作内存中的变量修改后的值同步到主内存,在读取变量前从主内存刷新最新值到工作内存中这种依赖主内存的方式来实现可见性的。

无论是普通变量还是 volatile 变量都是如此,区别在于:volatile 的特殊规则保证了 volatile 变量值修改后的新值立刻同步到主内存,每次使用 volatile 变量前立即从主内存中刷新,因此 volatile 保证了多线程之间的操作变量的可见性,而普通变量则不能保证这一点。

除了 volatile 关键字能实现可见性之外,还有 synchronized,Lock,final 也是可以的。

使用 synchronized 关键字,在同步方法/同步块开始时,使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中);在同步方法/同步块结束时,会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)

使用 Lock 接口的最常用的实现 ReentrantLock 来实现可见性:当我们在方法的开始位置执行 lock.lock() 方法,这和 synchronized 开始位置有相同的语义,即使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在方法的最后 finally 块里执行lock.unlock()方法,和 synchronized 结束位置(Monitor Exit)有相同的语义,即会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。

final 关键字的可见性是指:被 final 修饰的变量,在构造函数一旦初始化完成,并且在构造函数中并没有把 “this” 的引用传递出去(“this” 引用逃逸是很危险的,其他的线程很可能通过该引用访问到只“初始化一半”的对象),那么其他线程就可以看到 final 变量的值。

有序性

对于单线程的代码,我们总是认为代码的执行是从前往后的,依次执行的。这么说不能说完全不对,在单线程程序里,确实会这样执行;但是在多线程并发时,程序的执行就有可能出现乱序。用一句话可以总结为:在本线程内观察,操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行语义(WithIn Thread As-if-Serial Semantics)”,后半句是指“指令重排”现象和“工作内存和主内存同步延迟”现象。

Java 提供了两个关键字 volatile 和 synchronized 来保证多线程之间操作的有序性。volatile 关键字本身通过加入内存屏障来禁止指令的重排序,而 synchronized 关键字通过一个变量在同一时间只允许有一个线程对其进行加锁的规则来实现,

在单线程程序中,不会发生“指令重排”和“工作内存和主内存同步延迟”现象,只在多线程程序中出现。