JVM内存结构与内存模型

JVM内存结构

前言

java开发人员不像C/C++开发人员那样需要自己来管理内存,每一个对象从出生到死亡都需要由开发人员来管理,对于初级开发人员来说很容易出现内存问题。

而java开发人员就很"幸运"了,内存的管理几乎全部交给JVM虚拟机来管理的,不容易出现内存溢出以及内存泄漏问题,但是也正是内存管理交给虚拟机来管理,一旦出现内存问题边无从下手,所以对于JVM内存方面知识我们还是需要了解一些的,本篇我们主要探讨其中的JVM内存结构。

java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干不同的区域,这些区域有各自用途,有些区域随虚拟机的启动而存在,有些则依赖用户线程的创建和结束而存在和销毁。

java虚拟机所管理的内存包括以下几个运行时数据区域:

JVM内存结构与内存模型_第1张图片下面我们着重看一下这几个区的具体作用。

运行时数据区域

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。

在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie方法,这个计数器值则为空(Undefined)。

此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

java虚拟机栈

java虚拟机栈也是线程私有的,其生命周期与线程相同。

虚拟机栈描述的是java方法执行的内存模型:每个方法在执行的时候都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息。
每一个方法从调用到执行完成过程中,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

在Java虚拟机规范中,对这个区域规定了两种异常状况:
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;

如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。

本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

Java堆

对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

方法区

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

Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。

这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。

根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

JVM内存模型

内存模型和内存结构?估计很多开发人员这两个概念搞不清楚,甚至将二者混为一谈。
其实二者有很大区别,JVM内存模型是一种规范,在Java内存模型中,描述了在多线程代码中,哪些行为是正确的、合法的,以及多线程之间如何进行通信,代码中变量的读写行为如何反应到内存、CPU缓存的底层细节。

在Java中包含了几个关键字:volatile、final和synchronized,帮助程序员把代码中的并发需求描述给编译器。Java内存模型中定义了它们的行为,确保正确同步的Java代码在所有的处理器架构上都能正确执行。

而JVM内存结构指的就是堆,栈,程序计数器等由JVM自动管理的内存区域,各个区域有自己的职责。

目前无论手机还是电脑等设备大部分都是多CPU系统,在多CPU的系统中,每个CPU都有多级缓存,一般分为L1、L2、L3缓存,因为这些缓存的存在,提供了数据的访问性能,也减轻了数据总线上数据传输的压力,同时也带来了很多新的挑战,比如两个CPU同时去操作同一个内存地址,会发生什么?在什么条件下,它们可以看到相同的结果?这些都是需要解决的。

在CPU的层面,内存模型定义了一个充分必要条件,保证其它CPU的写入动作对该CPU是可见的,而且该CPU的写入动作对其它CPU也是可见的,那这种可见性,应该如何实现呢?

有些处理器提供了强内存模型,所有CPU在任何时候都能看到内存中任意位置相同的值,这种完全是硬件提供的支持。其它处理器,提供了弱内存模型,需要执行一些特殊指令(就是经常听到的,memory barriers内存屏障),刷新CPU缓存的数据到内存中,保证这个写操作能够被其它CPU可见,或者将CPU缓存的数据设置为无效状态,保证其它CPU的写操作对本CPU可见。通常这些内存屏障的行为由底层实现。

内存屏障,除了实现CPU之间的数据可见性之外,还有一个重要的职责,可以禁止指令的重排序,比如编译器会觉得把一个变量的写操作放在最后会更有效率,编译后,这个指令就在最后了(只要不改变程序的语义,编译器、执行器就可以这样自由的随意优化),一旦编译器对某个变量的写操作进行优化(放到最后),那么在执行之前,另一个线程将不会看到这个执行结果。

下面看一个指令重排序的例子:

Class Reordering {
  int x = 0, y = 0;
  public void writer() {
    x = 1;
    y = 2;
  }

  public void reader() {
    int r1 = y;
    int r2 = x;
  }
}

假设此时有两个线程分别为A,B,A线程负责写操作,B线程负责读操作,线程B读到y的值为2,在代码层面x设置为1在y设置为2之前,那么能保证此时B线程读到的x的值为1吗?

当然不行! 因为在writer方法中,可能发生了重排序,y的写入动作可能发在x写入之前,这种情况下,线程B就有可能看到x的值还是0。

在Java中包含了几个关键字:volatile、final和synchronized,帮助程序员把代码中的并发需求描述给编译器。Java内存模型中定义了它们的行为,确保正确同步的Java代码在所有的处理器架构上都能正确执行。

下面我们重新认识一下这几个关键字的意义。

synchronized

提到synchronized你肯定会想到互斥,没错,synchronized确实有互斥的语义,但是互斥的意义是什么呢?仅仅是我写的时候你不能操作?其实synchronized有更深的语义。

Synchronization保证了线程在同步块期间写入的动作,对于后续进入该代码块的线程是可见的(这里需要注意是对同一个monitor对象而言)。

在一个线程退出同步块时,线程释放monitor对象,它的作用是把CPU缓存数据(本地缓存数据)刷新到主内存中,从而实现该线程的行为可以被其它线程看到。
在其它线程进入到该代码块时,需要获得monitor对象,它在作用是使CPU缓存失效,从而使变量从主内存中重新加载(如果CPU缓存数据有效,会从缓存中获取数据),然后就可以看到之前线程对该变量的修改。

synchronized还有一个语义是禁止指令的重排序,对于编译器来说,同步块中的代码不会移动到获取和释放monitor外面。

volatile

volatile主要用于线程间通信,来保证可见性。

volatile字段的每次读行为都能看到其它线程最后一次对该字段的写行为,通过它就可以避免拿到缓存中陈旧数据。

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

另外volatile关键字对于重排序也有一定的限制。

看一个例子:

class VolatileExample {
  int x = 0;
  volatile boolean v = false;//v用volatile修饰
  public void writer() {
    x = 42;
    v = true;
  }

  public void reader() {
    if (v == true) {
      //uses x - guaranteed to see 42.
    }
  }
}

假设一个线程A执行writer,另一个线程B执行reader,writer中对变量v的写入把x的写入也刷新到主内存中。reader方法中会从主内存重新获取v的值,所以如果线程B看到v的值为true,就能保证拿到的x是42.(x设置成42发生在把v设置成true之前,volatile禁止这两个写入行为的重排序)。

如果变量v不是volatile,那么以上的描述就不成立了,因为执行顺序可能是v=true, x=42,或者对于线程B来说,根本看不到v被设置成了true。

final

如果一个类包含final字段,且在构造函数中初始化,那么正确的构造一个对象后,final字段被设置后对于其它线程是可见的。

看一个例子:

class FinalFieldExample {
  final int x;//final修饰
  int y;
  static FinalFieldExample f;
  public FinalFieldExample() {
    x = 3;
    y = 4;
  }

  static void writer() {
    f = new FinalFieldExample();
  }

  static void reader() {
    if (f != null) {
      int i = f.x;
      int j = f.y;
    }
  }
}

一个线程A执行reader方法,线程B执行writer方法,如果f已经在线程B初始化好,那么可以确保线程A看到x值是3,因为它是final修饰的,而不能确保看到y的值是4。

总结

本篇我们简要讨论了一下java中的内存模型与内存结构,内存模型主要来说就是jvm定义的一种规范,主要描述多线程情况下内存的可见性以及指令的重排序等,而对于开发人员来说平时我们就是通过synchronized,final,volatile等关键字来实现的,如果对这部分感兴趣,或者想深入了解,可以自行查阅更多资料学习。

而对于内存结构主要就是规定jvm各个区域用来存储什么的,详细讲解可以参考《深入理解java虚拟机》这本书。

你可能感兴趣的:(Java,Android,随笔)