# 运行时数据区域

# 程序计数器:

  • (1)较小内存空间,可以看作是当前线程所执行的字节码的行号指示器;
  • (2)字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令;
  • (3)分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。
  • (4)每个线程都有一个独立的程序计数器,线程间互不影响,独立存储,称之为 “线程私有” 的内存;
  • (5)如果线程正在执行的是一个 java 方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;
  • (6)如果正在执行的是 Native 方法,计数器值为空;
  • (7)此内存区域是唯一一个在 java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域;

# 虚拟机栈:

  • (1)线程私有,生命周期与线程周期一样;
  • (2)描述的是 java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息、每一个方法从调用 直至执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
  • (3)通常说的栈内存即是虚拟机栈;
  • (4)局部变量表:存放编译期可知的各种基本数据类型(8 个)、对象引用(引用类型,不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是句柄 或其他与此对象相关的位置)、returnAddress 类型(指向一条字节码指令的地址);其中 64bit 长度的 long 和 double 类型数据会占用 2 个局部变量空间(Slot),其他类 型占 1 个;
  • (5)局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,其在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的 大小;
  • (6)在 java 虚拟机规范中,这个区域有两种异常情况:
    • 如果线程请求的栈深度大于虚拟机所允许的深度,抛出 StackOverFlowError;
    • 如果虚拟机栈可以动态扩展(当前大部分 java 虚拟机都可以,只不过 java 虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,会抛出 OutOfMemoryError;

# 本地方法栈:

  • (1)作用与虚拟机栈类似
  • (2)区别:虚拟机栈为虚拟机执行 java 方法(即字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务;
  • (3)可自由实现本地方法栈(如语言、使用方式,数据结构),虚拟机规范没有强制规定(如 HotSpot 就是直接把本地方法栈和虚拟机栈合二为一);
  • (4)异常:StackOverFlowError 和 OutOfMemoryError;

# 堆:

  • (1)java 虚拟机所管理的内存最大的一块;
  • (2)线程间共享;
  • (3)虚拟机启动时创建;
  • (4)此内存区域唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存(虚拟机规范描述:所有的对象实例以及数组都要在堆上分配,但随着发展其变 得并非那么绝对);
  • (5)堆屎垃圾收集器管理的主要区域,固也称 “GC 堆”(由于现在收集器基本都采用分代收集算法,所以堆还可以细分为:新生代和老年代,再细分有 Eden 空间、 From Survivor 空间、To Survivor 空间等);
  • (6)线程共享的 java 堆中可能划分出多个线程私有的分配缓冲区(TLAB)
  • (7)无论哪个区域,存储的都是对象实例;
  • (8)java 虚拟机规范规定,java 堆可以是处于物理上不连续的内存空间中,只要逻辑上连续即可,所以当前主流虚拟机都是按照可扩展实现的(通过 - Xmx,-Xms 控 制)
  • (9)如果堆中没有内存可以完成对象实例分配,而且堆也无法扩展时,抛出 OutOfMemoryError;

# 方法区:

  • (1)线程间共享;
  • (2)用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据;
  • (3)虚拟机规范把方法区描述为堆的一个逻辑部分,别名 Non-Heap(非堆);
  • (4)在 HotSpot 虚拟机上方法区可以称为 “永久代”,这样 HotSpot 的垃圾收集器可以像管理 java 堆一样管理这部分内存,但使用永久代来实现方法区容易遇到内存溢出 问题,目前 JDK1.7 已经把原本放在永久代的字符串常量池移出;
  • (5)此区域可以选择不实现垃圾收集,但不以为不回收,更不是永久存在,这区域的内存回收目标主要是针对常量池的回收和类型的卸载(类型卸载条件相当苛 刻);
  • (6)方法去无法满足内存分配需求时,抛出 OutOfMemoryError;

# 运行时常量池(方法区一部分)

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

② 对于运行时常量池,java 虚拟机规范没有做任何细节的要求,提供商实现可以不同;

③ 动态性:并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量存入池中,如 String 类的 intern () 方法;

④ 常量池中无法再申请到内存时,抛出 OutOfMemoryError;

# 直接内存

① JDK1.4 加入 NIO,引入一种基于通道与缓冲区的 I/O 方式,它可以使用 Native 函数库直接范配堆外内存,然后通过一个存储在 java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样可以在一些场景中提高性能,因为避免了在 java 堆和 Native 堆中来回复制数据;

② 由①可得其不受 java 堆大小限制,但是受本机总内存(RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制;

③ 有可能出现 OutOfMemoryError;

# 对象的创建(普通对象,不包含数组和 Class 对象)

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

② 分配内存:对象所需大小在类加载后已确定,分配方式分为 “指针碰撞”(假设 java 内存是绝对规整的)和 “空闲列表”(假设 java 内存不是规整的,已使用内存和空闲内存相互交错,虚拟机必须维护一个列表),选择哪种方式由 java 堆是否规整决定,而 java 堆是否规整由其所采用的垃圾收集收集器是否带有压缩整理功能决定;

③ 并发情况下内存分配,指针可能正在给对象 A 分配内存,指针还未及修改就给 B 又同时分配内存,两种解决方案:

  • (1)分配内存空间的动作同步处理(保证更新操作原子性)
  • (2)把内存分配动作按照线程划分在不同的空间之中进行,即每个线程在 java 堆中预先分配一小块内存,称为 “本地线程分配缓冲”(TLAB),只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定,虚拟机是否使用 TLAB 可通过 “-XX:+/-UseTLAB” 参数设定;

④ 内存分配完后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头);

⑤ 虚拟机对对象进行必要的设置(如哪个类的实例、元数据信息、对象哈希码、对象的 GC 分代年龄等信息,这些信息存放在对象头中);

⑥ new 指令结束,然后是 init 方法,把对象按照程序员的意愿进行初始化;

⑦ 对象创建完毕;

# 对象的内存布局

① HotSpot 中布局分 3 快区域:对象头、实例数据、和对齐填充;

② 对象头:包含两部分

  • (1)存储自身的运行时数据:哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据长度区别于 32bit 和 64bit,官方称为 “Mark Word”,考虑到空间效率,Mark Word 被设计成一个非固定的数据结构以便在绩效的空间内存储尽量多的信息,对象头是与对象自身定义的数据无关的额外存储成本;
  • (2)类型指针:即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;

(区别于数组,数组对象需要在对象头保存有一块记录数组长度的数据,因为虚拟机可以通过普通 java 对象的元数据信息确定 java 对象的大小,但是从数据的元数据中却无法确定数组的大小)

③ 实例数据:

  • (1)是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容;
  • (2)存储顺序受虚拟机分配策略参数和字段在 java 源码中定义顺序影响,HotShot 默认分配策略为 longs/double,ints,shorts/chars,bytes/booleans,oops(相同宽度的字段总是被分配到一起);

④ 对齐填充:不是必然存在的,无特别含义,仅起着占位符的作用,HotShot 自动内存管理系统要求对象起始地址必须是 8 字节的整数倍(就是对象的大小必须是 8 字节的整数倍),而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,用此补全;

# 对象的访问定位

① java 程序通过栈上的引用数据来操作具体对象;

② 虚拟机规范只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中对象的具体位置,所以对象访问取决于虚拟机具体实现方式,目前主流访问方式有使用句柄和直接指针两种:

  • (1)使用句柄:java 堆中划分一块内存作为句柄池,reference 引用中存储的就是对象的句柄地址,而句柄中包含对象实例数据与类型数据各自的具体信息;
  • (2)直接指针:java 堆对象的布局中必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接是对象地址;

③ 使用句柄特点:好处是 reference 中存储的是稳定的句柄地址,对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改;

④ 直接指针特点:好处是速度快,节省一次指针定位的时间开销,由于对象访问频繁,这类开销积少成多也很可观,HotSpot 使用的就是直接指针这种访问对象定位的方式;

# 实战:OutOfMemoryError 异常

① 虚拟机规范中,除了程序计数器外,其他几个内存区域都可能发生 OOM(OutOfMemoryError 简称);

② 测试时可以自定义虚拟机启动参数,如命令行运行,直接在 java 命令之后书写(java -jar xxx.jar ... ...), 如果是 eclipse ide,可以在 Debug/Run 标签页的(x)=Arguments 的 VM arguments 下书写参数设置,java 堆内存溢出异常测试代码可以这样,然后通过内存映像分析工具(eclipse memory analyzer)分析:

List<Object> list = new ArrayList<>();
while(true){
    list.add(new Object);
}

③ 检测,如果是内存泄漏,查看泄露对象到 GC Roots 的引用链,找到泄露对象是通过怎样的路径与 GC Roots 相关联并导致垃圾收集器无法自动回收他们的;如果不是内存泄漏,就是内存中对象确实是必须存活的,可以定位到虚拟机参数,物理内存,或从代码上检查是否存在某些对象生命周期、持续时间过长的情况,尝试减少程序运行期间的内存消耗;

# 虚拟机栈和本地方法栈溢出

① HotSpot 不区分虚拟机栈和本地方法栈,其 - Xoss 参数存在但是无效,栈容量只由 - Xss 参数设定;

② 虚拟机规范中描述了两种异常:

  • (1)如果线程请求的栈深度大于虚拟机所允许的最大深度,抛出 StackOverFlowError;
  • (2)如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OOM;

③ 作者测试实验:在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是 StackOverflowError 异常;

④ 操作系统分配给每个进程的内存是有限制的,如 32 位 windows 限制为 2GB,虚拟机提供了参数来控制 java 堆和方法区的这两个部分内存的最大值,最大堆容量(Xmx),最大方法区容量(-MaxPermSize),用 2GB-Xmx-MaxPermSize - 程序计数器内存容量(很小的)- 虚拟机进程本身消耗的内存,剩下的内存就是虚拟机栈和本地方法栈瓜分,每个线程分配的内存越大,线程数量就越少,建立线程时就容易把剩下的内存耗尽;

⑤ 由④引发的问题,在不能减少线程数或者更换 64 位虚拟机情况下,只能通过减少最大堆和减少栈容量来换取更多线程;

# 方法区和运行时常量池溢出

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

② 使用 String.intern (),这是一个 Native 方法,作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。

③ 书中作者使用 JDK1.6(1.6 及之前由于常量池分配在永久代内,可以通过 - XX:PermSize 和 - XX:MaxPermSize 限制方法区大小,从而间接限制其中常量池的容量)测试:

List<String> list = new ArrayList<>();
int i = 0;
while(true){
    list.add( String.valueOf(i++).intern() );
}

此代码会出现 OOM:PermGen space,说明运行时常量池属于方法区(HotSpot 虚拟机中的永久代)的一部分;

④ 而 JDK1.7 版本中不会得到相同的结果,while 将会一直进行下去(我没有尝试)。

⑤ 在尝试如下代码:

String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println( str1.intern()==str1 );
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println( str2.intern()==str2 );

在 JDK1.6 中,会得到两个 false;而 JDK1.7 中运行,会得到一个 true,一个 false;(未尝试)

JDK1.6 中,intern () 方法把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串的引用,而由 StringBuilder 创建的字符串实例在 java 堆上,所以必然不是同一个引用,返回 false

JDK1.7 中,intern () 方法不会再复制实例,只在常量池中记录首次出现的实例引用,因此 intern () 返回的引用和由 StringBuilder 创建的那个字符串实例是同一个。对 str2 返回 false 是因为 “java” 这个字符串在执行 StringBuilder.toString () 之前已经出现过,字符串常量池中已经存在它的引用,不符合首次出现的原则,而计算机软件这个字符串是首次出现的。

⑥ 当前很多主流框架,如 Spring,Hibernate,在对类进行增强时,都会使用到 CGLIB 这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的 Class 可以加载入内存。

⑦ 一个类要被垃圾收集器回收掉,判定条件是比较苛刻的

# 本机直接内存溢出

① DirectMemory 容量可以通过 - XX:MaxDirectMemorySize 指定,如不指定,默认与 Java 堆最大值(-Xmx)一样,如下程序(书中作者写)

Filed unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while(true){
  unsafe.allocateMemory(_1MB);
}

直接通过反射获取 Unsafe 实例进行内存分配(Unsafe 类的 getUnsafe () 方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有 rt.jar 中的类才能使用 Unsafe 的功能)

原因:虽然使用了 DirectByteBuffer 分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统请求分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请内存的方法是 unsafe.allocateMemory (_1MB);