0%

java: jvm

Overview

  • 运行时数据区域
  • HotSpot虚拟机对象探秘
  • 垃圾收集器与内存分配策略
  • 垃圾收集算法

运行时数据区域

运行时数据区
包含以下五个:前两个所有线程共享,后三者线程隔离

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

程序计数器

当前线程所执行的字节码行号指示器。
对于任何确定的时刻,一个处理器都只会执行一条线程中的指令,为了线程切换的正确性,每条线程都要有一个自己的程序计数器,这是一部分较小的内存空间,是线程私有的。
如果执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是一个Native方法,这个计数器的值则为空(undified)
native关键字说明其修饰的方法是一个原生态方法,方法对应的实现不是在当前文件,而是在用其他语言(如C和C++)实现的文件中。Java语言本身不能对操作系统底层进行访问和操作,但是可以通过JNI接口调用其他语言来实现对底层的访问。

虚拟机栈

它也是线程私有的,生命周期与线程相同。Java方法执行的内存模型里,每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表操作数栈动态链接方法出口等信息。
局部变量表里保存基本数据类型、对象引用和return address。对于64bit的longdouble类型数据,占领两个局部变量空间Slot,其余数据类型都只占一个。
当一个线程请求的栈深度超过虚拟机允许的深度,则报StackOverflowError异常;如果虚拟机栈可以扩展,扩展后无法申请到足够的内存,则报OutOfMemoryError异常

本地方法栈

与虚拟机栈的区别是:虚拟机栈为虚拟机执行Java方法(即字节码)服务,而本地方法栈则为虚拟机使用到的Native方法(JNI)服务。
虚拟机可以自由实现它,有的虚拟机把本地方法栈和虚拟机栈直接二合一了

Java堆

是虚拟机管理内存中最大的一块,被所有线程共享,在虚拟机启动时创建。作用是:存放对象实例,几乎所有的对象都在上面。为什么是几乎,是因为JIT编译器发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将导致一些微妙的变化发生,所以没那么绝对了。

方法区

也是线程共享的内存区域,存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机把方法区描述为堆的一个逻辑部分,但它有一个别名,Non-heap,目的是与Java堆区分开来

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

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

直接内存

既不是运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是被频繁使用。
JDK14中新加入了NIO(new input/output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样可以提高性能,因为避免了在Java堆和Native堆中来回复制数据。

HotSpot虚拟机对象探秘

对象的创建

当虚拟机遇到了一个new指令的时候,先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的class是否已经被加载、解析和初始化,如果没有则先执行类加载过程。类加载通过后,虚拟机将为这个对象分配内存,其大小在类加载通过后可以完全确定。
如果堆中的内存是绝对规整的,所有用过的内存放在一边,没用过的内存放在另外一边,分配的内存就只是把中间这个指针移动对象大小的距离,这一种方式被称为指针碰撞(Bump the Pointer)。如果不是绝对规整的,即已使用的和空闲的内存交错,虚拟机就必须维护一个链表(Free List),记录哪些内存块是可用的,分配的时候去找就可以了。
除了如何划分,还要处理并发情况下的情况。线程A和线程B都要分配内存,A还没有分配结束,B又使用了相同的内存空间,就会有问题。
解决方案有两个:

  1. 堆分配内存空间的动作进行同步处理,即虚拟机采用CAS配上失败重试的方式来保证更新操作的原子性
  2. 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer),哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB分配完以后,并分配新的TLAB时,才需要同步锁定。

内存分配完之后,这一块会被清零。之后虚拟机对对象进行设置,例如这个对象是哪个类的实例、如何找类的metadata、对象的哈希码、对象的GC分代年龄等信息,这些被存在对象头(Object Header)中。之后根据虚拟机的运行状态,如是否使用偏向锁,对象头还有不同的设置。
上面的过程结束后,所有的字段都还是0,这时开始执行init方法,按照程序员的意愿进行初始化,最终一个真正可用的对象蔡算完全产生出来。

对象的内存布局

对象在内存中的存储布局可分为三个:对象头(Obeject Header)、实际数据(Instance Data)、对齐补充(Padding)。
对象头包含两部分信息:

  1. 一部分用于存储对象自身的运行时数据,比如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这些数据在32bit和64bit的虚拟机中分别为32bit和64bit,被称为Mark Word。其实运行时数据很多,32or54的bitmap已经不够记录这些数据了,但是对象头信息是与对象自身定义的数据无关的额外存储空间成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储更多的信息,即它会根据对象的状态复用自己的存储空间。eg. 32bit下,如果对象处于未锁定状态下,25个bit存储哈希码,4bit存储对象分代年龄,2bit存储锁标志位,1bit固定为0,而在其他状态下(轻量级锁定、重量级锁定、GC标记、可偏向),对象的存储内容见下表。
存储内容 标志位 状态
哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标记
横向线程ID、偏向时间戳、对象分代年龄 01 可偏向
  1. 另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,即查找对象的元数据信息并不一定要经过对象本身。如果对象是一个Java数组,那么在对象头中必须有一块记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是仅仅从数组的元数据中却无法确定数组大小。

对象的访问定位

Java程序需要通过栈上的reference数据来操作堆上的具体对象。reference类型在虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问,是由虚拟机实现决定的。目前主流的访问方式有使用句柄和直接指针两种。

  1. 句柄访问,Java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据类型数据各自的具体地址信息
  2. 直接指针访问,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。

这两种对象访问方式各有优势,使用句柄来访问的最大好处是reference中存储的是稳定的句柄地址,在对象被移动时(GC)只会改变句柄中的实例数据指针,而reference本身不需要修改。使用直接指针最大的好处是速度更快,节省了一次指针定位的时间开销,由于对象的访问成变在Java中非常频繁,因此可以有不错的效果。

垃圾收集器与内存分配策略

概述

GC(garbage collection)需要完成三件事情:哪些内存需要回收?什么时候回收?怎么回收?

  • 对于程序计数器、虚拟机栈、本地方法栈,生命周期与线程相同,栈中的栈帧随着方法的进入和退出有条不紊地进行着,每一个栈帧中分配多少内存基本上都是在类结构确定下来就已知的,所以内存的分配和回收都有确定性,所以不需要考虑回收的问题
  • 对于堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,GC关注的就是这部分内存,所以后续讨论的也仅仅只有这一部分内存。

引用计数法

给对象添加一个引用计数器,每当有一个地方引用它的时候,ref++,当引用失效的时候,ref–。但是,目前主流的Java虚拟机里没有选用它的!因为很难解决循环引用的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ReferenceCountingGC{
public Object instance = null;
private static final int _1MB = 1024 * 1024;
private byte[] bigSize = new byte[2 * _1MB];

public static void testGC(){
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

// force to execute gc
System.gc();
}
}

可达性分析算法

通过可达性分析(Reachability Analysis)来判定对象是否存活的。基本思路是通过一系列的称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来讲,就是从GC Roots到达这个对象不可达),则证明这个对象是不可用的。
在Java中,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用对象
  • 本地方法栈中JNI(也就是Native)引用的对象

引用

在JDK1.2以前,引用是一种很弱的概念,即如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用,这种定义太狭隘了,因为一个对象只有被引用或者没有被引用两种状态。但是我们希望描述这么一种对象:当内存空间足够的时候,则保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃它们,很多系统的缓存功能都符合这样的应用场景。
JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)四种,依次减弱

  • 强引用:指在程序代码之中普遍存在的,Object obj = new Object();这样子的,只要强引用还在,GC就永远不会回收被引用的对象。
  • 软引用:描述一些有用但并非必须的对象。由于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会报内存溢出的异常。JDK1.2之后,有SoftReference类来实现软引用
  • 弱引用:也是用来描述非必须对象的,比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾收集发生之前,当GC工作时,无论当前内存是否足够,都会回收掉。JDK1.2之后提供了WeakReference
  • 虚引用:也被称为幽灵引用或者幻影引用,是最弱的。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是:能在这个对象被收集器回收时收到一个系统通知。JDK1.2之后提供了PhantomReference

垃圾收集算法

标记-清除算法

复制算法

标记-整理算法

分代收集算法

垃圾收集器

Serial

ParNew

Parallel Scavenge

Serial Old

Parallel Old

CMS

G1

内存分配与回收策略

对象优先在Eden分配

大对象直接进入老年代

长期存活的对象将进入老年代

动态对象年龄判定

空间分配担保